267 lines
11 KiB
Vue
267 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import AppForm from '../../../components/ui/AppForm.vue'
|
||
import { NFormItem } from 'naive-ui'
|
||
import { fieldRule, imageUrlRule } from '../../../lib/form-rules'
|
||
import { NButton, NCheckbox, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||
import { reactive, ref } from 'vue'
|
||
import { AppDialog, AssetImage } from '../../../components/ui'
|
||
import { referenceImageUrl } from '../../../lib/assets'
|
||
import { errorMessage } from '../../../lib/http'
|
||
import { projectAssetsApi } from '../../project-assets/api'
|
||
import type { ProjectAsset } from '../../project-assets/types'
|
||
import type { AddStyleImageInput, StyleCategory, VisualStyleImage } from '../types'
|
||
|
||
/** 风格图可关联项目资产,也保留外部地址兼容入口。 */
|
||
const props = defineProps<{ projectId: string; images: VisualStyleImage[]; disabled: boolean }>()
|
||
const emit = defineEmits<{
|
||
add: [input: AddStyleImageInput]
|
||
toggle: [image: VisualStyleImage]
|
||
remove: [image: VisualStyleImage]
|
||
}>()
|
||
const form = reactive({ imageUrl: '', category: 'overall' as StyleCategory, sortOrder: 0, enabled: true })
|
||
const confirmingId = ref('')
|
||
const assetPickerOpen = ref(false)
|
||
const assetLoading = ref(false)
|
||
const assetError = ref('')
|
||
const assets = ref<ProjectAsset[] | null>(null)
|
||
const selectedAssetId = ref('')
|
||
const categories = { overall: '整体', character: '人物', scene: '场景', prop: '道具' } as const
|
||
|
||
/** 仅通过校验后发出新增请求,输入框在失败时保留供修正。 */
|
||
function add() {
|
||
if (props.disabled) return
|
||
emit('add', { ...form, imageUrl: form.imageUrl.trim(), source: 'upload' })
|
||
}
|
||
|
||
/** 素材仅在用户打开选择器时读取,避免风格页首屏增加重复请求。 */
|
||
async function openAssetPicker() {
|
||
if (props.disabled) return
|
||
assetPickerOpen.value = true
|
||
assetError.value = ''
|
||
selectedAssetId.value = ''
|
||
assetLoading.value = true
|
||
try {
|
||
const rows = await projectAssetsApi.list(props.projectId)
|
||
if (rows.some(asset => asset.projectId !== props.projectId)) throw new Error('素材与当前项目不匹配。')
|
||
assets.value = rows.filter(asset => asset.type === 'image')
|
||
} catch (error) {
|
||
assetError.value = errorMessage(error)
|
||
} finally {
|
||
assetLoading.value = false
|
||
}
|
||
}
|
||
|
||
function addAsset() {
|
||
if (props.disabled || !selectedAssetId.value) return
|
||
emit('add', {
|
||
projectAssetId: selectedAssetId.value,
|
||
category: form.category,
|
||
source: 'upload',
|
||
enabled: form.enabled,
|
||
sortOrder: form.sortOrder
|
||
})
|
||
assetPickerOpen.value = false
|
||
}
|
||
|
||
/** 删除只移除记录,需确认且不声称删除远程文件。 */
|
||
function remove(image: VisualStyleImage) {
|
||
if (props.disabled || confirmingId.value !== image.id) return
|
||
confirmingId.value = ''
|
||
emit('remove', image)
|
||
}
|
||
const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeInteger, '排序须为整数') }
|
||
</script>
|
||
|
||
<template>
|
||
<section class="panel style-images-panel mt-5 p-5 sm:p-6" aria-label="风格参考图">
|
||
<h3 class="font-medium">
|
||
风格参考图 <span class="ml-2 text-xs text-muted">{{ images.length }} 张</span>
|
||
</h3>
|
||
<p class="mt-2 text-xs leading-6 text-muted">
|
||
优先从项目资产库选择,也可登记已有 HTTP(S) 或 /storage/
|
||
图片地址。启用的参考图会参与后续身份与形态生图,已有图片不会自动更新。
|
||
</p>
|
||
<AppForm :model="form" :rules="rules" :disabled="disabled" class="mt-4" @submit="add">
|
||
<fieldset :disabled="disabled" class="form-controls style-image-controls">
|
||
<NFormItem class="style-image-url" path="imageUrl" label="图片地址"
|
||
><NInput
|
||
placeholder="https://… 或 /storage/…"
|
||
:input-props="{ 'aria-label': '风格图片地址' }"
|
||
v-model:value="form.imageUrl"
|
||
></NInput
|
||
></NFormItem>
|
||
<NFormItem path="category" label="分类" :label-props="{ for: 'style-image-category' }"
|
||
><NSelect
|
||
id="style-image-category"
|
||
v-model:value="form.category"
|
||
:options="[
|
||
...Object.entries(categories).map(([value, label]) => ({
|
||
label: String(label),
|
||
value: value
|
||
}))
|
||
]"
|
||
class="select-control"
|
||
></NSelect
|
||
></NFormItem>
|
||
<NFormItem path="sortOrder" label="排序"
|
||
><NInputNumber
|
||
:input-props="{ 'aria-label': '风格图片排序' }"
|
||
:value="typeof form.sortOrder === 'number' ? form.sortOrder : null"
|
||
@update:value="form.sortOrder = $event ?? 0"
|
||
:step="1"
|
||
></NInputNumber
|
||
></NFormItem>
|
||
<NCheckbox v-model:checked="form.enabled" class="control-row-checkbox text-xs">启用</NCheckbox>
|
||
<div class="style-image-submit flex gap-2">
|
||
<NButton :disabled="disabled" @click="openAssetPicker">从资产库选择</NButton>
|
||
<NButton :disabled="disabled" attr-type="submit">登记参考图</NButton>
|
||
</div>
|
||
</fieldset>
|
||
</AppForm>
|
||
<div v-if="images.length" class="style-image-list mt-5">
|
||
<article v-for="image in images" :key="image.id" class="surface-inset min-w-0 p-3">
|
||
<AssetImage
|
||
:src="image.imageUrl"
|
||
:alt="`${categories[image.category]}风格参考图`"
|
||
class="aspect-square"
|
||
preview
|
||
/>
|
||
<p class="mt-3 text-xs">
|
||
{{ categories[image.category] }} · {{ image.enabled ? '已启用' : '已停用' }} · 排序
|
||
{{ image.sortOrder }}
|
||
</p>
|
||
<p v-if="image.provider || image.model" class="mt-2 break-words text-[11px] text-muted">
|
||
{{ image.provider }} {{ image.model }}
|
||
</p>
|
||
<div class="mt-3 flex flex-wrap gap-3">
|
||
<a
|
||
v-if="referenceImageUrl(image.imageUrl)"
|
||
:href="referenceImageUrl(image.imageUrl)!"
|
||
class="text-button"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>原图</a
|
||
>
|
||
<NButton :disabled="disabled" @click="emit('toggle', image)" text size="small">{{
|
||
image.enabled ? '停用' : '启用'
|
||
}}</NButton>
|
||
<NButton
|
||
:disabled="disabled"
|
||
@click="confirmingId = image.id"
|
||
text
|
||
size="small"
|
||
class="text-danger"
|
||
>
|
||
移除
|
||
</NButton>
|
||
</div>
|
||
<div v-if="confirmingId === image.id" class="mt-3 text-xs">
|
||
<p>仅移除参考图记录,不删除远程文件。</p>
|
||
<div class="mt-2 flex gap-3">
|
||
<NButton :disabled="disabled" @click="remove(image)" text size="small" class="text-danger">
|
||
确认移除</NButton
|
||
><NButton @click="confirmingId = ''" text size="small">取消</NButton>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
<p v-else class="mt-5 text-sm text-muted">尚无风格参考图。先保存视觉风格,再登记图片。</p>
|
||
|
||
<AppDialog
|
||
v-model:open="assetPickerOpen"
|
||
title="从项目资产库选择"
|
||
description="选择一张项目图片作为当前分类的风格参考。这里只创建引用,不复制或删除原素材。"
|
||
wide
|
||
>
|
||
<p v-if="assetError" class="mt-4 text-sm text-danger">{{ assetError }}</p>
|
||
<p v-else-if="assetLoading" class="mt-4 text-sm text-muted" role="status">正在读取项目素材…</p>
|
||
<div v-else-if="assets?.length" class="style-asset-picker mt-4">
|
||
<button
|
||
v-for="asset in assets"
|
||
:key="asset.id"
|
||
type="button"
|
||
class="style-asset-option"
|
||
:class="{ 'is-selected': selectedAssetId === asset.id }"
|
||
:aria-pressed="selectedAssetId === asset.id"
|
||
@click="selectedAssetId = asset.id"
|
||
>
|
||
<AssetImage :src="asset.publicUrl" :alt="asset.name" class="aspect-[4/3]" />
|
||
<span class="block truncate p-3 text-left text-xs">{{ asset.name }}</span>
|
||
</button>
|
||
</div>
|
||
<div v-else-if="assets" class="mt-5 text-sm text-muted">
|
||
资产库暂无图片。<RouterLink :to="`/projects/${projectId}/assets`" class="text-button"
|
||
>前往资产库上传</RouterLink
|
||
>
|
||
</div>
|
||
<div class="mt-5 flex justify-end gap-3">
|
||
<NButton @click="assetPickerOpen = false">取消</NButton>
|
||
<NButton type="primary" :disabled="!selectedAssetId || disabled" @click="addAsset"
|
||
>登记为{{ categories[form.category] }}参考图</NButton
|
||
>
|
||
</div>
|
||
</AppDialog>
|
||
</section>
|
||
</template>
|
||
|
||
<style>
|
||
@reference "../../../styles/styles.css";
|
||
/* 按面板实际宽度布局,侧栏或移动端压缩内容时也能保持地址输入可用。 */
|
||
.style-images-panel {
|
||
container-type: inline-size;
|
||
}
|
||
.style-image-controls {
|
||
display: grid;
|
||
grid-template-columns: minmax(180px, 1fr) 130px 96px auto auto;
|
||
gap: 12px;
|
||
min-width: 0;
|
||
}
|
||
.style-image-submit {
|
||
margin-top: 29px;
|
||
}
|
||
.style-image-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 260px));
|
||
gap: 16px;
|
||
}
|
||
.style-asset-picker {
|
||
@apply grid grid-cols-[repeat(auto-fill,_minmax(min(180px,_100%),_1fr))] gap-3;
|
||
}
|
||
.style-asset-option {
|
||
@apply min-w-0 overflow-hidden text-left bg-(--app-subtle) border border-transparent;
|
||
}
|
||
.style-asset-option:hover {
|
||
@apply bg-(--app-control-hover);
|
||
}
|
||
.style-asset-option.is-selected {
|
||
@apply border-(--color-accent);
|
||
}
|
||
.style-image-controls .select-control {
|
||
min-width: 0;
|
||
width: 100%;
|
||
}
|
||
@container (max-width: 640px) {
|
||
.style-image-controls {
|
||
grid-template-columns: minmax(0, 1fr) 96px auto;
|
||
}
|
||
.style-image-url,
|
||
.style-image-submit {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.style-images-panel .app-form .style-image-controls > .style-image-submit {
|
||
margin-top: 0;
|
||
width: 100%;
|
||
min-height: 36px;
|
||
}
|
||
}
|
||
@container (max-width: 300px) {
|
||
.style-image-controls {
|
||
grid-template-columns: minmax(0, 1fr) 96px;
|
||
}
|
||
.style-images-panel .app-form .style-image-controls > .n-checkbox {
|
||
grid-column: 1 / -1;
|
||
margin-top: 0;
|
||
}
|
||
}
|
||
</style>
|