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

197 lines
9.0 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 WorkspacePage from '../../components/ui/WorkspacePage.vue'
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
import { NAlert, NButton } from 'naive-ui'
import { LoaderCircle, LockKeyhole, LockKeyholeOpen, Palette, RefreshCw, TriangleAlert } from '@lucide/vue'
import { computed, ref } from 'vue'
import { useQuery } from '../../composables/useQuery'
import { runOperation } from '../workflows/operations'
import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { visualStyleApi } from './api'
import type { AddStyleImageInput, SaveVisualStyleInput, VisualStyle, VisualStyleImage } from './types'
import StyleEditor from './components/StyleEditor.vue'
import StyleImages from './components/StyleImages.vue'
/** 项目视觉风格工作区:文本编辑、锁定和风格图记录分开管理。 */
const { projectId, blocked } = useProjectMutationGuard()
/** 风格页仅在进入、切换项目或显式刷新时读取。 */
const query = useQuery(projectId, async (id, signal) => {
const style = await visualStyleApi.get(id, signal)
assertProject(style, id)
return { style }
})
const style = computed(() => query.data.value?.style ?? null)
const disabled = computed(() => blocked.value || !!query.error.value || !query.data.value)
const editorRevision = ref(0)
const imageRevision = ref(0)
const dirty = ref(false)
/** 状态是只读说明,与操作按钮分组;读取失败不能被误报成尚未创建。 */
const statusInfo = computed(() => {
if (query.error.value) return { label: '读取失败', hint: '请刷新重试,现有内容不会被覆盖', icon: TriangleAlert }
if (!query.data.value) return { label: '正在读取', hint: '正在获取项目视觉风格', icon: LoaderCircle }
if (!style.value) return { label: '尚未创建', hint: '可使用 AI 生成,或在下方手动填写', icon: Palette }
return style.value.isLocked
? { label: '已锁定', hint: 'AI 不会覆盖,仍可在下方手动编辑', icon: LockKeyhole }
: { label: '未锁定', hint: '定稿后可在下方锁定,避免 AI 覆盖', icon: LockKeyholeOpen }
})
/** 防止错误项目的数据进入编辑器;空风格是正常的首次使用状态。 */
function assertProject(value: VisualStyle | null, id: string) {
if (value && (value.projectId !== id || value.images.some(image => image.visualStyleId !== value.id)))
throw new Error('视觉风格与当前项目不匹配,请刷新后重试。')
}
/** 操作成功后重建草稿,失败则保留用户输入;不因查询失败重发写请求。 */
async function writeStyle(label: string, action: (id: string) => Promise<VisualStyle>) {
if (disabled.value) return
const id = projectId.value
const ok = await runOperation(id, label, async () => {
const result = await action(id)
if (!result) throw new Error('后端未返回已保存的视觉风格,请刷新核对。')
assertProject(result, id)
if (projectId.value === id) {
query.data.value = { style: result }
editorRevision.value++
dirty.value = false
}
})
if (ok) await query.refresh()
}
/** 人工保存允许修改锁定风格。 */
function save(input: SaveVisualStyleInput) {
void writeStyle('保存项目视觉风格', id => visualStyleApi.save(id, input))
}
/** AI 覆盖必须先保存或放弃草稿,并在已锁定时禁止提交。 */
function generate() {
if (style.value?.isLocked || dirty.value) return
const force = !!style.value
void writeStyle('AI 生成项目视觉风格', id => visualStyleApi.generate(id, force))
}
/** 图片写操作固定项目与风格归属,保存后再读取列表。 */
async function writeImage(label: string, action: (id: string) => Promise<VisualStyleImage>, reset = false) {
if (disabled.value || !style.value) return
const id = projectId.value
const styleId = style.value.id
const ok = await runOperation(id, label, async () => {
const result = await action(id)
if (!result || result.visualStyleId !== styleId) throw new Error('后端未确认风格图片变更,请刷新核对。')
})
if (ok && reset && projectId.value === id) imageRevision.value++
await query.refresh()
}
/** 登记已存在的图片地址,不上传文件或调用模型。 */
function addImage(input: AddStyleImageInput) {
void writeImage('登记风格参考图', id => visualStyleApi.addImage(id, input), true)
}
/** 风格图启停不受文字锁定影响。 */
function toggleImage(image: VisualStyleImage) {
void writeImage('修改风格参考图启用状态', id => visualStyleApi.setImageEnabled(id, image.id, !image.enabled))
}
/** 删除已由子组件二次确认,不删除实际存储文件。 */
function removeImage(image: VisualStyleImage) {
void writeImage('移除风格参考图记录', id => visualStyleApi.removeImage(id, image.id))
}
</script>
<template>
<WorkspacePage compact class="visual-style-page"
><template #header>
<!-- 以下是标题锁定状态与生成操作 -->
<div class="workspace-toolbar visual-style-toolbar">
<h2 class="font-semibold">项目视觉风格</h2>
<div class="visual-style-status" role="status">
<component :is="statusInfo.icon" :size="16" aria-hidden="true" />
<strong>{{ statusInfo.label }}</strong>
<p>{{ statusInfo.hint }}</p>
</div>
<div class="visual-style-actions toolbar-actions">
<ConfirmAction
:label="style ? 'AI 重新生成风格' : 'AI 生成风格'"
:disabled="disabled || !!style?.isLocked || dirty"
acknowledgement
description="调用文本模型生成整体及分类风格。重新生成会覆盖已保存的文本与硬约束;旧身份、旧图片和已有提示词不会自动更新。"
@confirm="generate"
/>
<NButton
:loading="query.loading.value"
@click="query.refresh"
quaternary
class="icon-button"
aria-label="刷新风格"
title="刷新风格"
><template #icon><RefreshCw :size="16" /></template
></NButton>
<RouterLink :to="`/projects/${projectId}/subject-identity`" class="text-button"
>下一步主体身份 </RouterLink
>
</div>
</div>
</template>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mb-4"
>{{ query.error.value }} 请确认后端 dev 已更新并重启查询失败不等同于尚未创建
</NAlert>
<p v-if="dirty" class="mb-4 text-xs text-muted">
有未保存修改AI 重生成暂不可用刷新不会覆盖编辑内容离开页面不会自动保存
</p>
<DetailDisclosure title="风格生成规则" class="mb-5">
AI
生成时若项目和世界观没有明确其它人物背景人物风格默认采用中国人物选角基线人工填写的人物背景和角色事实优先修改风格只影响之后的生成旧资产不会自动更新 </DetailDisclosure
><StyleEditor
v-if="query.data.value"
:key="editorRevision"
:visual-style="style"
:disabled="disabled"
@save="save"
@dirty="dirty = $event" />
<p v-else-if="query.loading.value" class="py-8 text-sm text-muted" role="status">正在读取视觉风格</p>
<StyleImages
:key="imageRevision"
:project-id="projectId"
:images="style?.images ?? []"
:disabled="disabled || !style"
@add="addImage"
@toggle="toggleImage"
@remove="removeImage"
/></WorkspacePage>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css";
.visual-style-page {
container: visual-style-header / inline-size;
}
.visual-style-status {
@apply flex items-center gap-2 min-w-0 flex-1 flex-nowrap;
}
.visual-style-status > svg {
@apply shrink-0 text-muted;
}
.visual-style-status strong {
@apply text-xs font-medium whitespace-nowrap;
}
.visual-style-status p {
@apply mt-0 mx-0 mb-0 min-w-0 truncate text-muted text-xs leading-[1.6];
}
.visual-style-actions {
@apply shrink-0;
}
/* 窄内容区让说明占满首行剩余空间,操作独占下一行并从左侧排开。 */
@container visual-style-header (max-width: 760px) {
.visual-style-page .workspace-toolbar {
@apply gap-x-3 gap-y-2;
}
.visual-style-actions.toolbar-actions {
@apply ml-0 w-full justify-start;
}
}
</style>