197 lines
9.0 KiB
Vue
197 lines
9.0 KiB
Vue
<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>
|