Files
short-drama-agent-front/src/features/visual-style/components/StyleImages.vue
T

267 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>