feat: 同步视觉风格与主体身份母版工作区
对齐后端 dev 059e597,新增项目风格编辑锁定、身份文本生成、身份参考图与母版切换。 整理六个工作区入口,接通人物及场景母版继承说明、形态图片来源追溯。 补充草稿保护、正式 ID 校验、费用确认及部分失败回执,67 项测试和静态检查、构建通过。 未修改后端,未调用真实模型;本环境未完成浏览器视觉验收。
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { AssetImage, EmptyState } from '../../components/ui'
|
||||
import { downloadText } from '../../lib/format'
|
||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||
import { currentAnchor } from './model'
|
||||
import { useSubjectIdentity } from './useSubjectIdentity'
|
||||
import IdentityEditor from './components/IdentityEditor.vue'
|
||||
import IdentityGallery from './components/IdentityGallery.vue'
|
||||
import IdentityImageDialog from './components/IdentityImageDialog.vue'
|
||||
|
||||
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
|
||||
const route = useRoute()
|
||||
const {
|
||||
projectId,
|
||||
operation,
|
||||
selectedId,
|
||||
subject,
|
||||
subjects,
|
||||
catalog,
|
||||
styleQuery,
|
||||
detail,
|
||||
identity,
|
||||
images,
|
||||
session,
|
||||
concurrency,
|
||||
force,
|
||||
editorRevision,
|
||||
dirty,
|
||||
blocked,
|
||||
detailBlocked,
|
||||
hasStyle,
|
||||
canGenerateText,
|
||||
canGenerateImage,
|
||||
batchValid,
|
||||
save,
|
||||
generate,
|
||||
generateProject,
|
||||
generateImage,
|
||||
setAnchor
|
||||
} = useSubjectIdentity()
|
||||
const search = ref('')
|
||||
const module = ref('all')
|
||||
const imageOpen = ref(false)
|
||||
const pendingSelection = ref('')
|
||||
const anchor = computed(() => currentAnchor(images.value))
|
||||
const filtered = computed(() =>
|
||||
subjects.value.filter(
|
||||
item =>
|
||||
(module.value === 'all' || item.module === module.value) &&
|
||||
`${item.name} ${item.ref}`.toLowerCase().includes(search.value.trim().toLowerCase())
|
||||
)
|
||||
)
|
||||
|
||||
/** 深链接仅在目标 ID 变化时选择,不因目录轮询把用户切回原主体。 */
|
||||
const linkedSubjectId = computed(
|
||||
() => subjects.value.find(item => item.id === route.query.subjectId || item.ref === route.query.subjectRef)?.id
|
||||
)
|
||||
watch(
|
||||
linkedSubjectId,
|
||||
id => {
|
||||
if (id) select(id)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(selectedId, () => {
|
||||
imageOpen.value = false
|
||||
pendingSelection.value = ''
|
||||
})
|
||||
|
||||
/** 切换主体前确认放弃当前草稿,避免误丢尚未保存的文本。 */
|
||||
function select(id: string) {
|
||||
if (id === selectedId.value) return
|
||||
if (dirty.value) pendingSelection.value = id
|
||||
else selectedId.value = id
|
||||
}
|
||||
|
||||
/** 用户明确放弃草稿后切换,查询层会取消旧请求。 */
|
||||
function discardAndSelect() {
|
||||
if (!pendingSelection.value) return
|
||||
dirty.value = false
|
||||
selectedId.value = pendingSelection.value
|
||||
}
|
||||
|
||||
/** 保存真实回执便于定位部分失败主体。 */
|
||||
function exportReceipt() {
|
||||
downloadText('subject-identities-receipt.json', JSON.stringify(session.value.receipt, null, 2), 'application/json')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mt-7">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">主体身份</h2>
|
||||
<p class="mt-2 text-sm text-muted">为同一个人物、场景或道具固定稳定特征,再生成不同造型。</p>
|
||||
</div>
|
||||
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button"
|
||||
>下一步:形态图片 →</RouterLink
|
||||
>
|
||||
</div>
|
||||
<p class="alert mt-5 text-xs">
|
||||
人物与场景形态图在有身份母版时自动引用,分别保持人物身份与空间结构;没有母版仍可按原逻辑生图。道具暂不自动引用。旧图不会因母版切换而自动更新。
|
||||
</p>
|
||||
<p v-if="catalog.error.value || styleQuery.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ catalog.error.value || styleQuery.error.value }} 当前操作已暂停,请刷新核对后端。
|
||||
</p>
|
||||
<p v-if="styleQuery.data.value && !hasStyle" class="alert mt-4">
|
||||
先创建项目视觉风格,才能 AI 生成身份或身份图片。<RouterLink
|
||||
:to="`/projects/${projectId}/visual-style`"
|
||||
class="text-button ml-2"
|
||||
>设置视觉风格 →</RouterLink
|
||||
>
|
||||
</p>
|
||||
<details class="panel my-5 p-5">
|
||||
<summary class="cursor-pointer text-sm font-medium">项目批量生成身份文本</summary>
|
||||
<div class="mt-4 flex flex-wrap items-end gap-4">
|
||||
<label class="w-28"
|
||||
><span class="field-label">并发数</span
|
||||
><input
|
||||
v-model.number="concurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input"
|
||||
:disabled="operation.pending"
|
||||
aria-label="身份批量并发"
|
||||
/></label>
|
||||
<label class="flex gap-2 pb-2 text-xs"
|
||||
><input v-model="force" type="checkbox" :disabled="operation.pending" />覆盖未锁定的已有身份</label
|
||||
>
|
||||
<ConfirmAction
|
||||
:label="force ? '重新生成未锁定身份' : '补齐项目身份文本'"
|
||||
:disabled="blocked || !hasStyle || !batchValid || dirty || !subjects.length"
|
||||
acknowledgement
|
||||
description="只生成身份描述与提示词,不生成图片。操作面向整个项目,不受筛选影响;已锁定身份始终跳过。覆盖文本不会自动更新旧身份图与形态图。"
|
||||
@confirm="generateProject"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数。</p>
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
后端没有全项目身份查询接口;左侧从正式形态目录归并主体,点击后读取其身份。无形态主体暂不在目录展示,但后端批量会处理项目全部主体。
|
||||
</p>
|
||||
</details>
|
||||
<section v-if="session.receipt" class="panel mb-5 p-5" aria-label="身份文本批量回执">
|
||||
<div class="flex justify-between gap-3">
|
||||
<h3 class="text-sm font-medium">身份文本批量回执</h3>
|
||||
<button class="text-button" @click="exportReceipt">导出诊断</button>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
共 {{ session.receipt.total }} 个主体 · 目标 {{ session.receipt.targetCount }} · 生成
|
||||
{{ session.receipt.generated }} · 跳过 {{ session.receipt.skipped }}(含锁定
|
||||
{{ session.receipt.skippedLocked }})· 失败 {{ session.receipt.failed }}
|
||||
</p>
|
||||
<p v-if="session.receipt.failed" class="alert alert-error mt-3" role="alert">
|
||||
部分主体生成失败,已成功结果保留。请检查失败原因,再补齐或单独生成。
|
||||
</p>
|
||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="failure in session.receipt.failures" :key="failure.subjectId">
|
||||
{{ failure.subjectRef }} · {{ failure.subjectId }}:{{ failure.error }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话,不代表图片已生成。</p>
|
||||
</section>
|
||||
<div v-if="subjects.length" class="identity-workspace">
|
||||
<aside class="panel min-w-0 p-4">
|
||||
<h3 class="text-sm font-medium">
|
||||
主体目录 <span class="ml-2 text-xs text-muted">{{ subjects.length }}</span>
|
||||
</h3>
|
||||
<input v-model="search" class="input mt-4" placeholder="搜索主体或引用" aria-label="搜索主体身份" />
|
||||
<select v-model="module" class="input mt-3" aria-label="身份主体类型">
|
||||
<option value="all">全部类型</option>
|
||||
<option value="character">人物</option>
|
||||
<option value="scene">场景</option>
|
||||
<option value="prop">道具</option>
|
||||
</select>
|
||||
<div class="identity-subject-list mt-4">
|
||||
<button
|
||||
v-for="item in filtered"
|
||||
:key="item.id"
|
||||
class="identity-subject-item"
|
||||
:class="{ selected: selectedId === item.id }"
|
||||
:aria-pressed="selectedId === item.id"
|
||||
@click="select(item.id)"
|
||||
>
|
||||
<span class="block font-medium">{{ item.name }}</span
|
||||
><span class="mt-1 block text-[11px] text-muted"
|
||||
>{{ item.ref }} · {{ item.forms.length }} 个形态</span
|
||||
>
|
||||
</button>
|
||||
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体,请调整筛选。</p>
|
||||
</div>
|
||||
<button class="text-button mt-4" :disabled="catalog.loading.value" @click="catalog.refresh">
|
||||
刷新主体目录
|
||||
</button>
|
||||
</aside>
|
||||
<div class="min-w-0">
|
||||
<div v-if="pendingSelection" class="alert mb-4">
|
||||
<p class="text-xs">当前主体有未保存修改,切换会丢弃草稿。</p>
|
||||
<div class="mt-3 flex gap-3">
|
||||
<button class="button button-secondary" @click="discardAndSelect">放弃草稿并切换</button
|
||||
><button class="text-button" @click="pendingSelection = ''">继续编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="subject" class="panel p-5 sm:p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold">
|
||||
{{ subject.name }} <code class="subject-ref ml-2">{{ subject.ref }}</code>
|
||||
</h3>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{
|
||||
identity
|
||||
? identity.isLocked
|
||||
? '身份文本已锁定'
|
||||
: '身份文本未锁定'
|
||||
: '尚未创建身份'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<button class="text-button" :disabled="detail.loading.value" @click="detail.refresh">
|
||||
刷新身份
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="detail.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ detail.error.value }} 查询失败时不允许生成或覆盖。
|
||||
</p>
|
||||
<p v-if="!detail.data.value && detail.loading.value" class="py-8 text-sm text-muted" role="status">
|
||||
正在读取主体身份…
|
||||
</p>
|
||||
<template v-if="detail.data.value">
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<ConfirmAction
|
||||
:label="identity ? 'AI 重新生成身份' : 'AI 生成身份'"
|
||||
:disabled="!canGenerateText"
|
||||
acknowledgement
|
||||
description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。"
|
||||
@confirm="generate"
|
||||
/><button
|
||||
class="button button-secondary"
|
||||
:disabled="!canGenerateImage"
|
||||
@click="imageOpen = true"
|
||||
>
|
||||
生成身份参考图
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改,再使用 AI 或生成图片。</p>
|
||||
<IdentityEditor
|
||||
:key="`${selectedId}:${editorRevision}`"
|
||||
:identity="identity"
|
||||
:module="subject.module"
|
||||
:disabled="detailBlocked"
|
||||
@save="save"
|
||||
@dirty="dirty = $event"
|
||||
/>
|
||||
<div v-if="anchor" class="mt-5 flex items-center gap-4 border-t border-line pt-5">
|
||||
<AssetImage
|
||||
:src="anchor.imageUrl"
|
||||
:alt="`${subject.name}当前身份母版`"
|
||||
class="h-20 w-20 shrink-0 rounded"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium">当前身份母版</p>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{
|
||||
subject.module === 'prop'
|
||||
? '道具形态生图暂不自动引用此母版。'
|
||||
: '后续人物/场景形态图会继承此图的身份或空间结构。'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<IdentityGallery
|
||||
:key="selectedId"
|
||||
:images="images"
|
||||
:subject-name="subject.name"
|
||||
:disabled="detailBlocked"
|
||||
@anchor="setAnchor"
|
||||
/>
|
||||
</template>
|
||||
<div class="mt-5 border-t border-line pt-5">
|
||||
<h4 class="text-xs font-medium">此主体的形态</h4>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<span v-for="form in subject.forms" :key="form.id" class="tag"
|
||||
>{{ form.name }}{{ form.isDefault ? ' · 默认' : '' }}</span
|
||||
>
|
||||
</div>
|
||||
<RouterLink
|
||||
:to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }"
|
||||
class="text-button mt-3"
|
||||
>查看此主体形态图片 →</RouterLink
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="catalog.loading.value" class="py-8 text-sm text-muted" role="status">正在读取正式主体目录…</p>
|
||||
<EmptyState
|
||||
v-else-if="catalog.data.value"
|
||||
title="还没有可展示的正式主体"
|
||||
description="先完成剧本拆解与主体形态持久化,再来管理稳定身份。"
|
||||
><RouterLink :to="`/projects/${projectId}/breakdown`" class="button button-secondary"
|
||||
>前往剧本拆解</RouterLink
|
||||
></EmptyState
|
||||
>
|
||||
<IdentityImageDialog
|
||||
v-if="subject"
|
||||
v-model:open="imageOpen"
|
||||
:subject-id="subject.id"
|
||||
:subject-name="subject.name"
|
||||
:images="images"
|
||||
:disabled="!canGenerateImage"
|
||||
@generate="generateImage"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user