feat: 同步视觉风格与主体身份母版工作区

对齐后端 dev 059e597,新增项目风格编辑锁定、身份文本生成、身份参考图与母版切换。
整理六个工作区入口,接通人物及场景母版继承说明、形态图片来源追溯。
补充草稿保护、正式 ID 校验、费用确认及部分失败回执,67 项测试和静态检查、构建通过。
未修改后端,未调用真实模型;本环境未完成浏览器视觉验收。
This commit is contained in:
GouJ
2026-08-28 19:28:27 +08:00
parent 47443c0efe
commit 669f4d9df0
34 changed files with 2273 additions and 22 deletions
@@ -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>
+41
View File
@@ -0,0 +1,41 @@
import { request } from '../../lib/http'
import type {
GenerateIdentityImageInput,
IdentityBatchResult,
IdentityImage,
SaveIdentityInput,
SubjectIdentity
} from './types'
/** 身份接口使用正式 Subject ID,与形态 ID、角色表 ID 分开。 */
function identityPath(subjectId: string) {
return `/subjects/${encodeURIComponent(subjectId)}/identity`
}
/** 身份文本和参考图分开操作;有费用的请求均不自动重试。 */
export const subjectIdentityApi = {
get: (subjectId: string, signal?: AbortSignal) =>
request<SubjectIdentity | null>(identityPath(subjectId), { signal }),
save: (subjectId: string, input: SaveIdentityInput) =>
request<SubjectIdentity>(identityPath(subjectId), { method: 'PUT', body: input }),
generate: (subjectId: string, force: boolean) =>
request<SubjectIdentity>(`${identityPath(subjectId)}/generate`, {
method: 'POST',
body: { force },
timeoutMs: 0
}),
generateProject: (projectId: string, input: { force: boolean; concurrency: number }) =>
request<IdentityBatchResult>(`/projects/${encodeURIComponent(projectId)}/subject-identities/generate`, {
method: 'POST',
body: input,
timeoutMs: 0
}),
listImages: (subjectId: string, signal?: AbortSignal) =>
request<IdentityImage[]>(`${identityPath(subjectId)}/images`, { signal }),
generateImage: (subjectId: string, input: GenerateIdentityImageInput) =>
request<IdentityImage>(`${identityPath(subjectId)}/images`, { method: 'POST', body: input, timeoutMs: 0 }),
setAnchor: (subjectId: string, imageId: string) =>
request<IdentityImage>(`${identityPath(subjectId)}/images/${encodeURIComponent(imageId)}/anchor`, {
method: 'PUT'
})
}
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import type { SaveIdentityInput, SubjectIdentity } from '../types'
/** 身份文本草稿不随轮询覆盖;父页面切换主体或保存成功后重建编辑器。 */
const props = defineProps<{ identity: SubjectIdentity | null; disabled: boolean; module: string }>()
const emit = defineEmits<{ save: [input: SaveIdentityInput]; dirty: [value: boolean] }>()
const form = reactive({ description: '', generationPrompt: '', isLocked: false })
const baseline = ref('')
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
watch(
() => props.identity,
value => {
if (baseline.value && dirty.value) return
Object.assign(form, {
description: value?.description ?? '',
generationPrompt: value?.generationPrompt ?? '',
isLocked: value?.isLocked ?? false
})
baseline.value = JSON.stringify(form)
},
{ immediate: true }
)
watch(dirty, value => emit('dirty', value), { immediate: true })
/** 保存用户输入;空文本允许保存,但生图入口仍会检查必要 Prompt。 */
function save() {
if (!props.disabled) emit('save', { ...form })
}
</script>
<template>
<form class="mt-5" @submit.prevent="save">
<fieldset :disabled="disabled" class="space-y-4">
<label class="block"
><span class="field-label">稳定身份描述</span
><textarea
id="identity-description"
v-model="form.description"
class="input min-h-28"
placeholder="只描述跨形态不变的面部、结构、材质等特征"
/>
</label>
<label class="block"
><span class="field-label">身份核心提示词稳定事实</span
><textarea
id="identity-prompt"
v-model="form.generationPrompt"
class="input min-h-36"
placeholder="不固定某一形态的服装、伤势或临时状态"
/>
</label>
<p class="text-xs leading-6 text-muted">
{{
module === 'prop'
? '保留道具固定文字、编号、部件位置、结构与材质组合;不要泛化原有辨识细节,也不要写临时损伤或摆放状态。'
: module === 'scene'
? '保留建筑骨架、入口、窗户、楼梯与固定布局,不写临时天气、人群或灯光。'
: '保留有依据的五官、骨相、发际线与体型,不固定某套服装、临时表情或伤势。'
}}
这里保存身份事实背景视角和参考图约束由后端另行编译不是最终整段生图 Prompt
</p>
<label class="flex items-start gap-2 text-xs leading-6"
><input
id="identity-lock"
v-model="form.isLocked"
type="checkbox"
class="mt-1.5 accent-accent"
/>确认并锁定身份文本锁定后仍可人工保存生成图片及切换母版</label
>
<div class="flex flex-wrap items-center justify-between gap-3">
<span class="text-xs text-muted">{{
dirty ? '有未保存修改,自动刷新不会覆盖草稿。' : '图片以已保存的身份提示词为准。'
}}</span
><button class="button button-primary" type="submit" :disabled="disabled">保存主体身份</button>
</div>
</fieldset>
</form>
</template>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { AssetImage, StatusBadge } from '../../../components/ui'
import { referenceImageUrl } from '../../../lib/assets'
import { formatDate } from '../../../lib/format'
import { canBeAnchor, currentAnchor, identityViewLabels, readImageProvenance } from '../model'
import type { IdentityImage } from '../types'
/** 身份图库区分权威母版、primary 候选和辅助视图,保留后端原始状态。 */
const props = defineProps<{ images: IdentityImage[]; subjectName: string; disabled: boolean }>()
const emit = defineEmits<{ anchor: [imageId: string] }>()
const selectedId = ref('')
const confirming = ref(false)
const anchor = computed(() => currentAnchor(props.images))
const selected = computed(
() => props.images.find(image => image.id === selectedId.value) ?? anchor.value ?? props.images[0]
)
const canSelect = computed(
() => !props.disabled && !!selected.value && canBeAnchor(selected.value) && !selected.value.isAnchor
)
const imageUrl = computed(() => referenceImageUrl(selected.value?.imageUrl ?? ''))
watch(
() => selected.value?.id,
() => {
confirming.value = false
}
)
/** 只发出用户确认过的目标 ID,父页面再核验图片归属。 */
function chooseAnchor() {
if (!canSelect.value || !confirming.value || !selected.value) return
emit('anchor', selected.value.id)
confirming.value = false
}
</script>
<template>
<section class="mt-5 border-t border-line pt-5" aria-label="身份参考图库">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-medium">身份参考图 · {{ images.length }}</h3>
<span class="tag">{{ anchor ? '已选母版' : '尚无母版' }}</span>
</div>
<p class="mt-2 text-xs leading-6 text-muted">
身份母版确定是谁形态主图确定该造型长什么样切换母版仅影响之后的生成不会替换已有形态图分镜参考图或提示词
</p>
<template v-if="selected">
<AssetImage
:src="selected.status === 'completed' ? selected.imageUrl : null"
:alt="`${subjectName} · ${identityViewLabels[selected.viewType]}`"
class="asset-image-preview mt-4"
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端图片结果'"
/>
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<StatusBadge :status="selected.status" /><span class="tag">{{
selected.isAnchor
? '当前身份母版'
: selected.viewType === 'primary'
? '母版候选'
: identityViewLabels[selected.viewType]
}}</span
><span class="text-xs text-muted"
>{{ selected.enabled ? '启用' : '停用' }} · {{ selected.width || '—' }} ×
{{ selected.height || '—' }}</span
>
</div>
<a
v-if="imageUrl && selected.status === 'completed'"
:href="imageUrl"
target="_blank"
rel="noopener noreferrer"
class="text-button"
>打开原图</a
>
</div>
<p v-if="selected.error" class="alert alert-error mt-3" role="alert">{{ selected.error }}</p>
<div class="mt-3 flex flex-wrap items-center gap-3">
<button
v-if="!selected.isAnchor"
class="button button-secondary"
:disabled="!canSelect"
@click="confirming = true"
>
设为身份母版</button
><span class="text-[11px] text-muted"
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
>
</div>
<p v-if="selected.viewType !== 'primary'" class="mt-2 text-xs text-muted">
辅助视图不能直接设为母版可以选它作为参考另生成一张 primary 候选
</p>
<div v-if="confirming" class="alert mt-3">
<p class="text-xs">
确认切换身份母版 primary
母版将停用并保留后续人物场景形态生图会引用新母版道具暂不自动引用此操作不调用模型
</p>
<div class="mt-3 flex gap-3">
<button class="button button-primary" :disabled="!canSelect" @click="chooseAnchor">
确认切换身份母版</button
><button class="text-button" @click="confirming = false">取消</button>
</div>
</div>
<details v-if="selected.prompt" class="mt-4 text-xs">
<summary class="cursor-pointer text-muted">本次实际提示词</summary>
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p>
</details>
<p
v-if="readImageProvenance(selected.rawJson).referenceImageId"
class="mt-3 break-all text-[11px] text-muted"
>
本次锚定图片 ID{{ readImageProvenance(selected.rawJson).referenceImageId }}
</p>
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
<div class="image-history mt-4" aria-label="身份图片历史">
<button
v-for="image in images"
:key="image.id"
class="image-history-item"
:class="{ selected: image.id === selected.id }"
:aria-label="`查看身份图片 ${image.id}`"
:aria-pressed="image.id === selected.id"
@click="selectedId = image.id"
>
<AssetImage
:src="image.status === 'completed' ? image.imageUrl : null"
:alt="identityViewLabels[image.viewType]"
:retryable="false"
:empty-text="image.status === 'failed' ? '失败' : '无图'"
/><span class="mt-1 block text-[10px]">{{
image.isAnchor
? '当前母版'
: image.viewType === 'primary'
? '母版候选'
: identityViewLabels[image.viewType]
}}</span>
</button>
</div>
</template>
<p v-else class="py-8 text-sm text-muted">尚无身份参考图先保存身份提示词再生成第一张母版</p>
</section>
</template>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
import { validImageSize } from '../../subject-images/model'
import { currentAnchor, identityViewLabels } from '../model'
import type { GenerateIdentityImageInput, IdentityImage, IdentityViewType } from '../types'
/** 身份图生成配置;默认自动引用母版,不提供后端不存在的“忽略母版”开关。 */
const props = defineProps<{ subjectId: string; subjectName: string; images: IdentityImage[]; disabled: boolean }>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ generate: [input: GenerateIdentityImageInput] }>()
const viewType = ref<IdentityViewType>('primary')
const referenceImageId = ref('')
const prompt = ref('')
const width = ref<number | ''>('')
const height = ref<number | ''>('')
const acknowledged = ref(false)
const anchor = computed(() => currentAnchor(props.images))
const references = computed(() => props.images.filter(image => image.status === 'completed' && image.imageUrl))
const valid = computed(
() =>
validImageSize(width.value, height.value) &&
(!referenceImageId.value || references.value.some(image => image.id === referenceImageId.value))
)
watch(
() => [open.value, props.subjectId],
() => {
viewType.value = 'primary'
referenceImageId.value = ''
prompt.value = ''
width.value = ''
height.value = ''
acknowledged.value = false
}
)
/** 提交显式的 Seedream 参数;省略空参考 ID 以保留后端自动选择逻辑。 */
function submit() {
if (props.disabled || !valid.value || !acknowledged.value) return
emit('generate', {
provider: 'seedream',
viewType: viewType.value,
...(referenceImageId.value ? { referenceImageId: referenceImageId.value } : {}),
...(prompt.value.trim() ? { prompt: prompt.value.trim() } : {}),
...(width.value !== '' && height.value !== '' ? { width: width.value, height: height.value } : {})
})
open.value = false
}
</script>
<template>
<AppDialog v-model:open="open" title="生成身份参考图" :description="subjectName">
<form class="mt-5 space-y-4" @submit.prevent="submit">
<p class="alert text-xs">
调用 Seedream每次新增一张图片可能产生费用第一张成功的 primary 图自动成为母版已有母版时
primary 图仅作为停用候选保留
</p>
<label class="block"
><span class="field-label">参考视角</span
><select id="identity-view" v-model="viewType" class="input">
<option v-for="(label, value) in identityViewLabels" :key="value" :value="value">
{{ label }}{{ value }}
</option>
</select></label
>
<label class="block"
><span class="field-label">身份锚定来源</span
><select id="identity-reference" v-model="referenceImageId" class="input">
<option value="">
{{ anchor ? '自动引用当前身份母版' : '自动选择(当前无母版,将按文本生成)' }}
</option>
<option v-for="image in references" :key="image.id" :value="image.id">
{{ identityViewLabels[image.viewType] }} · {{ image.id }}
</option>
</select></label
>
<p class="text-xs leading-6 text-muted">
只能选择此主体已完成的图片后端需取得 Provider 可访问的远程地址本地预览可见不保证远程地址仍有效
</p>
<label class="block"
><span class="field-label">覆盖本次完整提示词可选</span
><textarea
id="identity-image-prompt"
v-model="prompt"
class="input min-h-24"
placeholder="建议留空,使用后端编译的身份约束和视角要求"
/>
</label>
<p v-if="prompt.trim()" class="text-xs text-danger">
自定义文本会替换后端编译的完整提示词请自行包含身份约束和视角要求
</p>
<div class="grid grid-cols-2 gap-4">
<label
><span class="field-label">宽度px</span
><input
id="identity-width"
v-model.number="width"
type="number"
min="1"
step="1"
class="input"
placeholder="后端默认" /></label
><label
><span class="field-label">高度px</span
><input
id="identity-height"
v-model.number="height"
type="number"
min="1"
step="1"
class="input"
placeholder="后端默认"
/></label>
</div>
<p v-if="!valid" class="text-xs text-danger" role="alert">尺寸须成对填写正整数指定的参考图必须仍可用</p>
<label class="flex items-start gap-2 text-xs leading-6"
><input
id="identity-image-cost"
v-model="acknowledged"
type="checkbox"
class="mt-1.5 accent-accent"
/>我已确认后台没有同项目任务了解本次会调用模型并可能产生费用</label
>
<div class="dialog-footer">
<button type="button" class="button button-secondary" @click="open = false">取消</button
><button class="button button-primary" type="submit" :disabled="disabled || !valid || !acknowledged">
确认生成身份图
</button>
</div>
</form>
</AppDialog>
</template>
@@ -0,0 +1,124 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import IdentityImageDialog from './components/IdentityImageDialog.vue'
import IdentityGallery from './components/IdentityGallery.vue'
import { canBeAnchor, currentAnchor, groupIdentitySubjects, readImageProvenance } from './model'
import { identityImageFixture } from './testing/fixtures'
import { formFixture } from '../subject-images/testing/fixtures'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
})
/** 从真实 Reka Portal 内获取确认按钮。 */
function button(label: string) {
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
if (!item) throw new Error(`缺少按钮 ${label}`)
return item
}
/** 修改 Portal 表单控件并触发 Vue 绑定。 */
function input(selector: string, value: string) {
const item = document.querySelector<HTMLInputElement | HTMLSelectElement>(selector)!
item.value = value
item.dispatchEvent(new Event(item.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
}
describe('身份图与母版契约', () => {
it('启用的辅助视角不是母版,primary 候选停用时仍可选为母版', () => {
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false })
expect(canBeAnchor(front)).toBe(false)
expect(canBeAnchor(candidate)).toBe(true)
expect(currentAnchor([front, candidate])).toBeUndefined()
expect(canBeAnchor(identityImageFixture({ status: 'failed' }))).toBe(false)
expect(canBeAnchor(identityImageFixture({ imageUrl: null }))).toBe(false)
})
it('正式主体关联校验不接受不同主体的 form,追溯 JSON 兼容旧数据', () => {
expect(() => groupIdentitySubjects([{ ...formFixture(), subjectId: 'wrong' }])).toThrow('不匹配')
expect(readImageProvenance('{bad')).toEqual({})
expect(readImageProvenance(null)).toEqual({})
expect(readImageProvenance('{"identityAnchorImageId":"anchor","referenceImageId":7}')).toMatchObject({
identityAnchorImageId: 'anchor',
referenceImageId: undefined
})
})
it('辅助视角传递明确参考图、成对尺寸和本次 Prompt,不传形态生图字段', async () => {
wrapper = mount(IdentityImageDialog, {
attachTo: document.body,
props: {
open: true,
subjectId: 's1',
subjectName: '林夏',
images: [identityImageFixture()],
disabled: false
}
})
await flushPromises()
input('#identity-view', 'three-quarter')
input('#identity-reference', 'identity-image-1')
input('#identity-width', '2048')
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
await flushPromises()
expect(button('确认生成身份图').disabled).toBe(true)
input('#identity-height', '2048')
input('#identity-image-prompt', ' 自定义身份提示词 ')
await flushPromises()
button('确认生成身份图').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([
[
{
provider: 'seedream',
viewType: 'three-quarter',
referenceImageId: 'identity-image-1',
width: 2048,
height: 2048,
prompt: '自定义身份提示词'
}
]
])
})
it('切换主体清空生图配置和费用确认,失效参考图不能提交', async () => {
wrapper = mount(IdentityImageDialog, {
attachTo: document.body,
props: {
open: true,
subjectId: 's1',
subjectName: '林夏',
images: [identityImageFixture()],
disabled: false
}
})
await flushPromises()
input('#identity-reference', 'identity-image-1')
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
await flushPromises()
await wrapper.setProps({ images: [] })
expect(button('确认生成身份图').disabled).toBe(true)
await wrapper.setProps({ subjectId: 's2', subjectName: '陆川' })
expect(document.querySelector<HTMLSelectElement>('#identity-reference')!.value).toBe('')
expect(document.querySelector<HTMLInputElement>('#identity-image-cost')!.checked).toBe(false)
})
it('辅助图不能切换母版,提示词按纯文本展示', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
disabled: false,
images: [identityImageFixture({ viewType: 'front', isAnchor: false })]
}
})
expect(button('设为身份母版').disabled).toBe(true)
expect(wrapper.text()).toContain('<script>不执行</script>')
expect(wrapper.find('script').exists()).toBe(false)
expect(wrapper.emitted('anchor')).toBeUndefined()
})
})
+4
View File
@@ -0,0 +1,4 @@
/** 主体身份模块公共入口。 */
export { subjectIdentityApi } from './api'
export { currentAnchor, readImageProvenance } from './model'
export type { SubjectIdentity, IdentityImage, GenerateIdentityImageInput } from './types'
+60
View File
@@ -0,0 +1,60 @@
import { reactive } from 'vue'
import type { SubjectFormAsset } from '../subject-images/types'
import type { IdentityBatchResult, IdentityImage, IdentitySubject } from './types'
/** 视角名称保持与后端四种固定值对应。 */
export const identityViewLabels = {
primary: '身份母版',
front: '正面',
'three-quarter': '三分之四侧面',
'full-body': '全身'
} as const
/** 将同一主体的多个形态归并成一条,避免为每个形态重复创建 Identity。 */
export function groupIdentitySubjects(forms: SubjectFormAsset[]): IdentitySubject[] {
const subjects = new Map<string, IdentitySubject>()
for (const form of forms) {
if (form.subjectId !== form.subject.id) throw new Error('形态与主体 ID 不匹配,请刷新后重试。')
const subject = subjects.get(form.subjectId) ?? { ...form.subject, forms: [] }
subject.forms.push(form)
subjects.set(form.subjectId, subject)
}
return [...subjects.values()]
}
/** 只有成功且有图片的 primary 候选可成为母版;停用候选也允许重新启用。 */
export function canBeAnchor(image: IdentityImage): boolean {
return image.viewType === 'primary' && image.status === 'completed' && !!image.imageUrl
}
/** 使用后端 isAnchor,不把 front/full-body 的 enabled 错当成母版。 */
export function currentAnchor(images: IdentityImage[]): IdentityImage | undefined {
return images.find(image => image.isAnchor && image.enabled && canBeAnchor(image))
}
/** 仅读取追溯所需 ID,不把原始 Provider 数据渲染为 HTML。 */
export function readImageProvenance(rawJson?: string | null): {
referenceImageId?: string
identityAnchorImageId?: string
} {
try {
const parsed: unknown = JSON.parse(rawJson || '{}')
if (!parsed || typeof parsed !== 'object') return {}
const value = parsed as Record<string, unknown>
return {
referenceImageId: typeof value.referenceImageId === 'string' ? value.referenceImageId : undefined,
identityAnchorImageId:
typeof value.identityAnchorImageId === 'string' ? value.identityAnchorImageId : undefined
}
} catch {
return {}
}
}
/** 批量回执按项目保存在当前会话,切页不丢失,刷新浏览器后不伪造恢复。 */
const sessions = reactive<Record<string, { receipt: IdentityBatchResult | null }>>({})
/** 取得指定项目的身份文本批量回执。 */
export function getIdentitySession(projectId: string) {
return (sessions[projectId] ??= { receipt: null })
}
@@ -0,0 +1,39 @@
import type { IdentityImage, SubjectIdentity } from '../types'
/** 身份文本测试数据;subjectId 使用正式数据库主体 ID。 */
export function identityFixture(overrides: Partial<SubjectIdentity> = {}): SubjectIdentity {
return {
id: 'identity-db-1',
subjectId: 'subject-db-1',
description: '稳定面部特征',
generationPrompt: '保持相同五官与骨相',
isLocked: false,
images: [],
createdAt: '2026-08-28T00:00:00Z',
updatedAt: '2026-08-28T00:00:00Z',
...overrides
}
}
/** 专用身份图库查询包含 isAnchor,辅助视角即使启用也不是母版。 */
export function identityImageFixture(overrides: Partial<IdentityImage> = {}): IdentityImage {
return {
id: 'identity-image-1',
identityId: 'identity-db-1',
source: 'generated',
viewType: 'primary',
provider: 'seedream',
model: 'configured-model',
prompt: '<script>不执行</script>',
imageUrl: '/storage/identity.png',
width: 2048,
height: 2048,
status: 'completed',
enabled: true,
isAnchor: true,
error: null,
createdAt: '2026-08-28T00:00:00Z',
updatedAt: '2026-08-28T00:00:00Z',
...overrides
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { SubjectImageStatus, SubjectFormAsset } from '../subject-images/types'
/** 只有 primary 视图可以选为身份母版,其余视图是辅助参考。 */
export type IdentityViewType = 'primary' | 'front' | 'three-quarter' | 'full-body'
/** 身份参考图;isAnchor 仅由专用图片查询接口返回,不能用 enabled 代替。 */
export interface IdentityImage {
id: string
identityId: string
source: 'upload' | 'generated'
viewType: IdentityViewType
provider: string | null
model: string | null
prompt: string | null
imageUrl: string | null
width: number | null
height: number | null
status: SubjectImageStatus
enabled: boolean
isAnchor?: boolean
error: string | null
rawJson?: string | null
createdAt: string
updatedAt: string
}
/** 稳定身份描述及生图提示词,不混入具体形态的临时状态。 */
export interface SubjectIdentity {
id: string
subjectId: string
description: string | null
generationPrompt: string | null
isLocked: boolean
images: IdentityImage[]
createdAt: string
updatedAt: string
}
/** 从正式形态目录归并主体,不使用 checkpoint 的候选 ID。 */
export type IdentitySubject = SubjectFormAsset['subject'] & {
forms: SubjectFormAsset[]
}
/** 人工保存身份;锁定只保护 AI 文本重生成,不限制人工保存或生图。 */
export type SaveIdentityInput = Partial<Pick<SubjectIdentity, 'description' | 'generationPrompt' | 'isLocked'>>
/** 单张身份生图;空 referenceImageId 应省略,由后端自动引用当前母版。 */
export interface GenerateIdentityImageInput {
provider: 'seedream'
viewType: IdentityViewType
referenceImageId?: string
prompt?: string
width?: number
height?: number
}
/** 身份文本批量生成回执,不代表已生成参考图。 */
export interface IdentityBatchResult {
total: number
targetCount: number
generated: number
skipped: number
skippedLocked: number
failed: number
failures: { subjectId: string; subjectRef: string; error: string }[]
}
@@ -0,0 +1,201 @@
import { computed, ref, watch } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { visualStyleApi } from '../visual-style'
import { subjectImagesApi } from '../subject-images/api'
import { hasRunningImages } from '../subject-images/model'
import { runOperation } from '../workflows/operations'
import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard'
import { subjectIdentityApi } from './api'
import { canBeAnchor, getIdentitySession, groupIdentitySubjects } from './model'
import type { GenerateIdentityImageInput, IdentityImage, SaveIdentityInput, SubjectIdentity } from './types'
/** 单主体保存响应必须与提交时固定的正式 ID 一致。 */
function assertIdentity(value: SubjectIdentity, id: string) {
if (!value || value.subjectId !== id || value.images.some(image => image.identityId !== value.id))
throw new Error('后端未返回匹配的主体身份,请刷新核对。')
}
/** 主体目录、项目风格和当前身份分开查询,未创建 Identity 不误报为空图库错误。 */
export function useSubjectIdentity() {
const { projectId, operation, blocked: projectBlocked } = useProjectMutationGuard()
const selectedId = ref('')
const editorRevision = ref(0)
const dirty = ref(false)
const concurrency = ref(3)
const force = ref(false)
const catalog = usePolling(projectId, async (id, signal) => {
const forms = await subjectImagesApi.listForms(id, signal)
if (
forms.some(
form => form.subject.projectId !== id || form.images.some(image => image.subjectFormId !== form.id)
)
)
throw new Error('主体目录与当前项目不匹配,请刷新后重试。')
return { subjects: groupIdentitySubjects(forms), running: forms.some(form => hasRunningImages(form.images)) }
})
const styleQuery = usePolling(projectId, async (id, signal) => {
const style = await visualStyleApi.get(id, signal)
if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。')
return { style }
})
const subjects = computed(() => catalog.data.value?.subjects ?? [])
const subject = computed(() => subjects.value.find(item => item.id === selectedId.value))
const selectionKey = computed(() => subject.value?.id ?? '')
const detail = usePolling(selectionKey, async (id, signal) => {
if (!id) return null
const identity = await subjectIdentityApi.get(id, signal)
if (!identity) return { identity: null, images: [] as IdentityImage[] }
assertIdentity(identity, id)
const images = await subjectIdentityApi.listImages(id, signal)
if (images.some(image => image.identityId !== identity.id)) throw new Error('身份图片与当前主体不匹配。')
return { identity, images }
})
const identity = computed(() => detail.data.value?.identity ?? null)
const images = computed(() => detail.data.value?.images ?? [])
const session = computed(() => getIdentitySession(projectId.value))
const blocked = computed(
() =>
projectBlocked.value ||
!!catalog.error.value ||
!catalog.data.value ||
!!catalog.data.value.running ||
!!styleQuery.error.value ||
!styleQuery.data.value
)
const detailBlocked = computed(
() =>
blocked.value ||
!subject.value ||
!detail.data.value ||
!!detail.error.value ||
hasRunningImages(images.value)
)
const hasStyle = computed(() => !!styleQuery.data.value?.style)
const canGenerateText = computed(
() => !detailBlocked.value && hasStyle.value && !identity.value?.isLocked && !dirty.value
)
const canGenerateImage = computed(
() => !detailBlocked.value && hasStyle.value && !!identity.value?.generationPrompt?.trim() && !dirty.value
)
const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
watch(subjects, rows => {
if (!rows.some(item => item.id === selectedId.value)) selectedId.value = rows[0]?.id ?? ''
})
watch(selectionKey, () => {
dirty.value = false
editorRevision.value++
})
/** 长请求跨主体切换后只更新原项目的操作回执,不用旧响应覆盖新主体。 */
async function writeIdentity(label: string, action: (id: string) => Promise<SubjectIdentity>) {
if (detailBlocked.value || !subject.value) return
const id = subject.value.id
const project = projectId.value
const ok = await runOperation(project, label, async () => {
const result = await action(id)
assertIdentity(result, id)
if (selectionKey.value === id && projectId.value === project) {
// 图片的 isAnchor 只能从专用查询获取,保存文本时暂时保留已有图片列表。
detail.data.value = { identity: result, images: images.value }
editorRevision.value++
dirty.value = false
}
})
if (ok) await detail.refresh()
}
/** 人工保存可修改已锁定身份;锁定只阻止自动重生成。 */
function save(input: SaveIdentityInput) {
return writeIdentity('保存主体身份', id => subjectIdentityApi.save(id, input))
}
/** 单主体 AI 生成与已存在文本的覆盖均需要页面确认。 */
function generate() {
if (!canGenerateText.value) return
const overwrite = !!identity.value
return writeIdentity('AI 生成主体身份', id => subjectIdentityApi.generate(id, overwrite))
}
/** 批量仅生成身份文本,已锁定项由后端跳过,回执保留部分失败。 */
async function generateProject() {
if (blocked.value || !hasStyle.value || !batchValid.value || dirty.value || !subjects.value.length) return
const id = projectId.value
const target = getIdentitySession(id)
const input = { concurrency: concurrency.value, force: force.value }
target.receipt = null
await runOperation(id, '批量生成主体身份文本', async () => {
target.receipt = await subjectIdentityApi.generateProject(id, input)
})
await detail.refresh()
}
/** 生图不修改文字锁定;即使有自定义 Prompt,也需已有身份 Prompt 与项目风格。 */
async function generateImage(input: GenerateIdentityImageInput) {
if (!canGenerateImage.value || !subject.value || !identity.value) return
if (
input.referenceImageId &&
!images.value.some(
image => image.id === input.referenceImageId && image.status === 'completed' && image.imageUrl
)
)
return
const id = subject.value.id
const identityId = identity.value.id
await runOperation(projectId.value, `生成 ${subject.value.name} 身份参考图`, async () => {
const image = await subjectIdentityApi.generateImage(id, input)
if (!image || image.identityId !== identityId || image.status !== 'completed' || !image.imageUrl)
throw new Error(image?.error || '后端未返回已完成的身份图片,请先刷新核对,不要立即重复生图。')
})
await detail.refresh()
}
/** 候选切换母版只允许成功的 primary 图,不能将辅助视角直接升级为母版。 */
async function setAnchor(imageId: string) {
const image = images.value.find(item => item.id === imageId)
if (detailBlocked.value || !subject.value || !image || !canBeAnchor(image) || image.isAnchor) return
const id = subject.value.id
const identityId = image.identityId
await runOperation(projectId.value, '切换主体身份母版', async () => {
const result = await subjectIdentityApi.setAnchor(id, imageId)
if (
!result ||
result.id !== imageId ||
result.identityId !== identityId ||
!result.enabled ||
!canBeAnchor(result)
)
throw new Error('接口未确认身份母版切换,请刷新核对。')
})
await detail.refresh()
}
return {
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
}
}