diff --git a/README.md b/README.md
index a5cdb6e..1d6b3cb 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Short Drama Agent Front
-短剧 Agent 的前端工作台。支持 `create-drama`、`breakdown` 与 `storyboard` 工作区,使用真实后端 API,不包含演示数据或浏览器端模型调用。
+短剧 Agent 的前端工作台。支持剧本创作、拆解、视觉风格、主体身份、形态图片与分镜设计,使用真实后端 API,不包含演示数据或浏览器端模型调用。
## 启动
@@ -31,6 +31,9 @@ API_PROXY_TARGET=http://localhost:3412
| 拆解配置 | 每组集数、人物/场景/道具模块选择、后端分组预览 |
| 拆解结果 | 主体、别名、视觉形态、Episode → Beat → Shot、主体绑定、抽取任务状态、校验问题、JSON 导出 |
| 拆解恢复 | 失败抽取重试、缺失剧集镜头补齐、分镜引用与 Form 绑定修复 |
+| 视觉风格 | AI 生成、人工编辑、分类 Prompt、JSON 硬约束、锁定;风格参考图地址登记、启停和删除 |
+| 主体身份 | 按正式主体归并形态,单个/批量生成身份文本,人工编辑及锁定,部分失败诊断 |
+| 身份母版 | Seedream 生图、四种参考视角、指定身份参考图、母版候选与切换、实际 Prompt 和来源追溯 |
| 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 |
| 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 |
| 镜头工具 | 主体参考图与缺失原因、确定性 GenerationSpec 查询与导出、单镜头/批量视频提示词 |
@@ -67,6 +70,8 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| `src/features/breakdown` | 拆解 graph 的 API、类型、配置页面、主体与分镜组件 |
| `src/features/storyboard` | 分镜 API、类型、生成操作、依赖判断、设计与状态、镜头资源、生成回执 |
| `src/features/subject-images` | 正式形态图库、图片生成参数、历史图片、主图选择与批量回执 |
+| `src/features/visual-style` | 项目视觉风格 API、类型、编辑器、锁定和风格参考图管理 |
+| `src/features/subject-identity` | 稳定身份 API、正式主体目录、文本编辑、生图配置、身份图库和母版切换 |
| `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 |
| `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 |
| `src/composables` | 具备取消和竞态保护的轮询 |
@@ -78,7 +83,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
## 接口基线
对齐后端 `qianlanse/short-drama-agent` 的 `dev`:
-`eac8dc3b1b2ce1ff2bdfeb01d7281cd838f07285`,包含本次新增的只读 `GET /projects/:projectId/subject-forms` 接口。请同步更新前后端 `dev` 后重启后端。
+`059e59709359dccd08b08cb35c6aff95c5e03f2b`(2026-08-28),包含 VisualStyle、SubjectIdentity、身份母版、人物/场景形态自动引用母版及道具稳定特征提取。本轮只修改前端,请确保后端依照其开发流程同步了新 Prisma 结构并重启服务。
| HTTP | 路径(以 /api 为前缀) | 用途 |
| --- | --- | --- |
@@ -102,11 +107,21 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| GET | `/storyboard-shots/:shotId/generation-spec` | 后端确定性编译结果,不调用模型 |
| POST | `/storyboard-shots/:shotId/video-prompt` | 复用已有 Prompt 或调用模型生成;`force` 控制覆盖 |
| POST | `/projects/:id/video-prompts/generate` | 项目批量 Prompt;`concurrency`、`force` |
-| GET | `/projects/:id/subject-forms` | 正式 SubjectForm ID、主体名称/引用、主图与图片元数据;本次新增只读接口 |
+| GET | `/projects/:id/subject-forms` | 正式 SubjectForm ID、主体名称/引用、主图与图片元数据;只读查询 |
| GET | `/subject-forms/:formId/images` | 此形态的全部图片历史及实际 Prompt |
| POST | `/subject-forms/:formId/images` | 单图生成;显式 `provider: seedream`,可选 Prompt 和成对尺寸,`setPrimary` |
| PUT | `/subject-forms/:formId/images/:imageId/primary` | 选择已完成图片作为主参考图,不调用模型 |
| POST | `/projects/:id/subject-images/generate` | 全项目批量生图;`provider`、`concurrency`、`force` |
+| GET / PUT | `/projects/:id/visual-style` | 查询/人工保存视觉风格,包含分类 Prompt、硬约束和锁定 |
+| POST | `/projects/:id/visual-style/generate` | AI 生成风格;已有风格重生成显式 `force: true`,锁定时禁止 |
+| POST | `/projects/:id/visual-style/images` | 登记已有风格图片地址,不上传文件 |
+| PUT | `/projects/:id/visual-style/images/:imageId/enabled` | 风格图片启停 |
+| DELETE | `/projects/:id/visual-style/images/:imageId` | 删除风格图片记录,不删除远程文件 |
+| GET / PUT | `/subjects/:subjectId/identity` | 查询/人工编辑稳定身份;使用正式 Subject ID |
+| POST | `/subjects/:subjectId/identity/generate` | AI 生成身份文本,需要项目视觉风格 |
+| POST | `/projects/:id/subject-identities/generate` | 项目批量身份文本;`force`、`concurrency`,已锁定始终跳过 |
+| GET / POST | `/subjects/:subjectId/identity/images` | 查询带 `isAnchor` 的身份图片/Seedream 生图 |
+| PUT | `/subjects/:subjectId/identity/images/:imageId/anchor` | 将成功的 primary 候选设为身份母版 |
API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。
@@ -146,6 +161,28 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
8. 图片使用持久化后的 `/storage/...` 地址,开发代理已配置。生产环境也必须代理 `/storage/`,不能让 SPA 回退返回 index.html。无效 URL、加载失败、无图和后端生成失败有各自提示;不会自动使用可能过期的 Provider 临时地址。
9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。
+### 视觉风格、身份与形态的衔接
+
+建议操作顺序:**剧本创作 → 剧本拆解 → 视觉风格 → 主体身份 → 形态图片 → 分镜设计**。视觉风格也可以在拆解前准备;这不是前端强制启动的整条生产流程。
+
+| 层级 | 职责 | 本次可操作内容 |
+| --- | --- | --- |
+| VisualStyle | 项目整体美术与视觉语言 | 整体/人物/场景/道具 Prompt、JSON 字符串数组硬约束、锁定、风格图片记录 |
+| SubjectIdentity | 同一主体跨形态不变的特征 | 稳定身份描述、身份 Prompt、锁定、单个及批量 AI 文本生成 |
+| Identity Image | 固定主体身份,人物主要固定脸部与体型 | primary、front、three-quarter、full-body 四类生图、身份锚定来源、母版选择 |
+| SubjectForm Image | 某个具体造型或状态的图片 | 原有单图/批量生图与主图选择;人物/场景图自动引用当前身份母版 |
+
+1. 风格与身份的 `isLocked` 只禁止 AI 自动覆盖文本。已锁定记录仍可人工编辑并保存;身份生图和母版切换也不受文字锁定限制。需要 AI 重生成时,先取消锁定并保存。
+2. 身份 AI 生成需要已创建 VisualStyle;身份图生成还需要已保存的非空 `generationPrompt`。不存在身份时不调用会报错的图片 GET。404 或网络失败不会被当作“尚未创建”。
+3. 后端暂无全项目主体/身份的 GET。前端从正式 `subject-forms` 目录按 `subjectId` 去重,选中主体后按需读取身份和专用图库;无形态主体暂不在目录展示。批量仍由后端处理项目全部主体,可能多于目录可见数,不受当前筛选影响。未使用 checkpoint 或 `DramaCharacter.id` 拼造 Subject ID。
+4. 身份文本批量回执显示 `total`、`targetCount`、`generated`、`skipped`、`skippedLocked`、`failed` 和逐主体失败原因。它不代表图片已生成,也不是实时任务进度。后端没有批量身份生图接口,前端不自行循环触发有费用的图片请求。
+5. 身份图片的 `isAnchor` 来自专用 GET。`enabled: true` 的正面、侧面或全身图不是母版;只有成功且有地址的 `primary` 图可经 PUT 设为母版。第一张 primary 图自动启用;已有母版后,新 primary 图作为停用候选保留,不静默替换。辅助视图不能直接选为母版,但可以作为新 primary 图的参考来源。
+6. 身份生图不传 `referenceImageId` 时,由后端自动选择当前母版;无母版时按文本生成。指定时只能选择当前身份已完成的图片;候选图即使停用也可作为显式参考。后端没有“有母版但忽略它”的参数。尺寸成对留空或提供正整数。自定义 Prompt 会替换后端编译的完整文本,建议保留默认。
+7. 目前**人物与场景形态生图**会自动注入当前身份母版,分别固定人物身份和空间骨架;道具暂不自动引用。没有身份或母版时,原形态生图流程仍可使用。已有母版但缺少 Provider 可访问远程地址时,后端会报错;本地 `/storage/` 能显示并不证明原始 Provider URL 仍可访问。前端显示后端错误,不自动重试消耗额度。
+8. 修改风格、身份文本或母版,不会自动使旧图失效或重生成。已有形态主图仍会被分镜使用;需要保持新身份时,主动生成候选图并设为形态主图,再刷新分镜参考图,按需覆盖视频提示词。图库详情展示当次 `identityAnchorImageId` 或 `referenceImageId`;旧记录没有该字段时明确显示未知,不推断它使用了当前母版。
+9. 风格参考图支持登记已有 HTTP(S)/`/storage/` 地址、分类、排序、启停和删除记录;**没有文件上传接口,也没有风格图 AI 生成接口**。当前身份图/形态图 Provider 调用不会自动消费风格参考图片,页面不暗示登记后已生效。
+10. 编辑草稿与轮询分离。保存成功才清空草稿,失败保留;主体切换会提示放弃未保存修改。离开页面不会自动保存。所有新写操作沿用项目级会话互斥,生成前需要费用与后台任务确认,无法替代后端跨浏览器锁。
+
### 后端限制
目前 `checkpoints` 接口返回完整历史与 state,没有分页或轻量状态接口。长篇剧本会增加轮询流量。下一步建议后端增加:
@@ -203,5 +240,11 @@ server {
8. 给主体形态准备参考图,读取镜头参考图和 GenerationSpec;确认 Prompt 无正文时经确认生成、有正文且不覆盖时复用。不提交实际视频任务。
9. 打开“形态图片”,检查已有主图自动显示;单个形态新增候选图,查看历史、失败原因和实际 Prompt,再设为主图。
10. 在小型测试项目中验证批量补齐与“已有主图也新增候选图”;回到分镜刷新参考图,确认使用新选择的主图。
+11. 进入“视觉风格”,保存或生成风格,检查分类 Prompt 和硬约束;锁定后 AI 入口禁用,人工编辑仍可保存。登记一张已有风格图,检查启停和移除确认。
+12. 进入“主体身份”,检查同一主体的多形态归并。生成/保存身份文本并锁定,再生成第一张 primary 图;生成第二张后,确认旧母版未被替换。
+13. 为同一主体选择参考来源并生成 front / three-quarter / full-body 图;确认辅助图不能直接设为母版。将第二张 primary 候选设为母版后,检查旧母版停用且保留。
+14. 对人物和场景形态分别新增图片,在图片详情检查当次母版 ID;旧主图应保持不变,手动切换后再刷新分镜参考图。道具不应显示自动继承母版已生效。
+
+本轮新增测试覆盖风格 JSON/锁定/图片 DELETE、身份归并/前置条件/尺寸/显式参考图/母版切换、部分失败、旧响应、草稿保护与费用确认。模型调用均使用模拟 API。当前执行环境未能通过浏览器访问本地开发服务,**未完成浏览器视觉验收及真实模型联调**,请按上述步骤在本地验收。
自动测试使用模拟 API,覆盖既有流程、分镜接口与竞态,以及形态图片自动读取、正式 ID 生图、主图 PUT、费用确认、尺寸校验、候选图选择、批量部分失败和图片加载错误。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
diff --git a/src/App.vue b/src/App.vue
index 9ba32b7..475427c 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -5,6 +5,8 @@ import {
Clapperboard,
Camera,
Images,
+ Palette,
+ Fingerprint,
FolderOpen,
FileText,
Layers,
@@ -56,20 +58,34 @@ const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
title="剧本拆解"
>
diff --git a/src/features/breakdown/components/SubjectList.vue b/src/features/breakdown/components/SubjectList.vue
index 80d0fda..e3ef447 100644
--- a/src/features/breakdown/components/SubjectList.vue
+++ b/src/features/breakdown/components/SubjectList.vue
@@ -34,6 +34,12 @@ const filtered = computed(() =>
>
import { computed, provide } from 'vue'
import { useRoute } from 'vue-router'
-import { ArrowLeft, RefreshCw, FileText, Layers, Camera, Images, LoaderCircle } from '@lucide/vue'
+import { ArrowLeft, RefreshCw, FileText, Layers, Camera, Images, Palette, Fingerprint, LoaderCircle } from '@lucide/vue'
import { StatusBadge } from '../../components/ui'
import { projectContextKey, useProjectData } from './context'
import { getOperation } from '../workflows/operations'
@@ -39,18 +39,12 @@ const operation = computed(() => getOperation(id.value))
{{ context.error.value }} 当前保留上次成功读取的数据。
diff --git a/src/features/subject-identity/SubjectIdentityPage.vue b/src/features/subject-identity/SubjectIdentityPage.vue
new file mode 100644
index 0000000..b4aa239
--- /dev/null
+++ b/src/features/subject-identity/SubjectIdentityPage.vue
@@ -0,0 +1,317 @@
+
+
+
+
+
+
+
主体身份
+
为同一个人物、场景或道具固定稳定特征,再生成不同造型。
+
+
下一步:形态图片 →
+
+
+ 人物与场景形态图在有身份母版时自动引用,分别保持人物身份与空间结构;没有母版仍可按原逻辑生图。道具暂不自动引用。旧图不会因母版切换而自动更新。
+
+
+ {{ catalog.error.value || styleQuery.error.value }} 当前操作已暂停,请刷新核对后端。
+
+
+ 先创建项目视觉风格,才能 AI 生成身份或身份图片。设置视觉风格 →
+
+
+ 项目批量生成身份文本
+
+
+
+
+
+ 并发必须是正整数。
+
+ 后端没有全项目身份查询接口;左侧从正式形态目录归并主体,点击后读取其身份。无形态主体暂不在目录展示,但后端批量会处理项目全部主体。
+
+
+
+
+
身份文本批量回执
+
+
+
+ 共 {{ session.receipt.total }} 个主体 · 目标 {{ session.receipt.targetCount }} · 生成
+ {{ session.receipt.generated }} · 跳过 {{ session.receipt.skipped }}(含锁定
+ {{ session.receipt.skippedLocked }})· 失败 {{ session.receipt.failed }}
+
+
+ 部分主体生成失败,已成功结果保留。请检查失败原因,再补齐或单独生成。
+
+
+ -
+ {{ failure.subjectRef }} · {{ failure.subjectId }}:{{ failure.error }}
+
+
+ 回执仅保留于当前浏览器会话,不代表图片已生成。
+
+
+
+
+
+
当前主体有未保存修改,切换会丢弃草稿。
+
+
+
+
+
+
+
+
+ {{ subject.name }} {{ subject.ref }}
+
+
+ {{
+ identity
+ ? identity.isLocked
+ ? '身份文本已锁定'
+ : '身份文本未锁定'
+ : '尚未创建身份'
+ }}
+
+
+
+
+
+ {{ detail.error.value }} 查询失败时不允许生成或覆盖。
+
+
+ 正在读取主体身份…
+
+
+
+
+
+ 请先保存身份修改,再使用 AI 或生成图片。
+
+
+
+
+
当前身份母版
+
+ {{
+ subject.module === 'prop'
+ ? '道具形态生图暂不自动引用此母版。'
+ : '后续人物/场景形态图会继承此图的身份或空间结构。'
+ }}
+
+
+
+
+
+
+
此主体的形态
+
+ {{ form.name }}{{ form.isDefault ? ' · 默认' : '' }}
+
+
查看此主体形态图片 →
+
+
+
+
+ 正在读取正式主体目录…
+ 前往剧本拆解
+
+
+
diff --git a/src/features/subject-identity/api.ts b/src/features/subject-identity/api.ts
new file mode 100644
index 0000000..0da3a18
--- /dev/null
+++ b/src/features/subject-identity/api.ts
@@ -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(identityPath(subjectId), { signal }),
+ save: (subjectId: string, input: SaveIdentityInput) =>
+ request(identityPath(subjectId), { method: 'PUT', body: input }),
+ generate: (subjectId: string, force: boolean) =>
+ request(`${identityPath(subjectId)}/generate`, {
+ method: 'POST',
+ body: { force },
+ timeoutMs: 0
+ }),
+ generateProject: (projectId: string, input: { force: boolean; concurrency: number }) =>
+ request(`/projects/${encodeURIComponent(projectId)}/subject-identities/generate`, {
+ method: 'POST',
+ body: input,
+ timeoutMs: 0
+ }),
+ listImages: (subjectId: string, signal?: AbortSignal) =>
+ request(`${identityPath(subjectId)}/images`, { signal }),
+ generateImage: (subjectId: string, input: GenerateIdentityImageInput) =>
+ request(`${identityPath(subjectId)}/images`, { method: 'POST', body: input, timeoutMs: 0 }),
+ setAnchor: (subjectId: string, imageId: string) =>
+ request(`${identityPath(subjectId)}/images/${encodeURIComponent(imageId)}/anchor`, {
+ method: 'PUT'
+ })
+}
diff --git a/src/features/subject-identity/components/IdentityEditor.vue b/src/features/subject-identity/components/IdentityEditor.vue
new file mode 100644
index 0000000..0716863
--- /dev/null
+++ b/src/features/subject-identity/components/IdentityEditor.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
diff --git a/src/features/subject-identity/components/IdentityGallery.vue b/src/features/subject-identity/components/IdentityGallery.vue
new file mode 100644
index 0000000..0bf980f
--- /dev/null
+++ b/src/features/subject-identity/components/IdentityGallery.vue
@@ -0,0 +1,141 @@
+
+
+
+
+
+
身份参考图 · {{ images.length }}
+ {{ anchor ? '已选母版' : '尚无母版' }}
+
+
+ 身份母版确定“是谁”;形态主图确定“该造型长什么样”。切换母版仅影响之后的生成,不会替换已有形态图、分镜参考图或提示词。
+
+
+
+
+
+ {{
+ selected.isAnchor
+ ? '当前身份母版'
+ : selected.viewType === 'primary'
+ ? '母版候选'
+ : identityViewLabels[selected.viewType]
+ }}{{ selected.enabled ? '启用' : '停用' }} · {{ selected.width || '—' }} ×
+ {{ selected.height || '—' }}
+
+
打开原图
+
+ {{ selected.error }}
+
+ {{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}
+
+
+ 辅助视图不能直接设为母版;可以选它作为参考,另生成一张 primary 候选。
+
+
+
+ 确认切换身份母版?旧 primary
+ 母版将停用并保留。后续人物/场景形态生图会引用新母版,道具暂不自动引用。此操作不调用模型。
+
+
+
+
+
+
+ 本次实际提示词
+ {{ selected.prompt }}
+
+
+ 本次锚定图片 ID:{{ readImageProvenance(selected.rawJson).referenceImageId }}
+
+ Identity image ID · {{ selected.id }}
+
+
+
+
+ 尚无身份参考图。先保存身份提示词,再生成第一张母版。
+
+
diff --git a/src/features/subject-identity/components/IdentityImageDialog.vue b/src/features/subject-identity/components/IdentityImageDialog.vue
new file mode 100644
index 0000000..63968b6
--- /dev/null
+++ b/src/features/subject-identity/components/IdentityImageDialog.vue
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
diff --git a/src/features/subject-identity/identity.test.ts b/src/features/subject-identity/identity.test.ts
new file mode 100644
index 0000000..9468d7e
--- /dev/null
+++ b/src/features/subject-identity/identity.test.ts
@@ -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(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('#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('#identity-image-cost')!.click()
+ await flushPromises()
+ await wrapper.setProps({ images: [] })
+ expect(button('确认生成身份图').disabled).toBe(true)
+ await wrapper.setProps({ subjectId: 's2', subjectName: '陆川' })
+ expect(document.querySelector('#identity-reference')!.value).toBe('')
+ expect(document.querySelector('#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('')
+ expect(wrapper.find('script').exists()).toBe(false)
+ expect(wrapper.emitted('anchor')).toBeUndefined()
+ })
+})
diff --git a/src/features/subject-identity/index.ts b/src/features/subject-identity/index.ts
new file mode 100644
index 0000000..c5e39b4
--- /dev/null
+++ b/src/features/subject-identity/index.ts
@@ -0,0 +1,4 @@
+/** 主体身份模块公共入口。 */
+export { subjectIdentityApi } from './api'
+export { currentAnchor, readImageProvenance } from './model'
+export type { SubjectIdentity, IdentityImage, GenerateIdentityImageInput } from './types'
diff --git a/src/features/subject-identity/model.ts b/src/features/subject-identity/model.ts
new file mode 100644
index 0000000..f39eb6b
--- /dev/null
+++ b/src/features/subject-identity/model.ts
@@ -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()
+ 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
+ return {
+ referenceImageId: typeof value.referenceImageId === 'string' ? value.referenceImageId : undefined,
+ identityAnchorImageId:
+ typeof value.identityAnchorImageId === 'string' ? value.identityAnchorImageId : undefined
+ }
+ } catch {
+ return {}
+ }
+}
+
+/** 批量回执按项目保存在当前会话,切页不丢失,刷新浏览器后不伪造恢复。 */
+const sessions = reactive>({})
+
+/** 取得指定项目的身份文本批量回执。 */
+export function getIdentitySession(projectId: string) {
+ return (sessions[projectId] ??= { receipt: null })
+}
diff --git a/src/features/subject-identity/testing/fixtures.ts b/src/features/subject-identity/testing/fixtures.ts
new file mode 100644
index 0000000..0821ab1
--- /dev/null
+++ b/src/features/subject-identity/testing/fixtures.ts
@@ -0,0 +1,39 @@
+import type { IdentityImage, SubjectIdentity } from '../types'
+
+/** 身份文本测试数据;subjectId 使用正式数据库主体 ID。 */
+export function identityFixture(overrides: Partial = {}): 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 {
+ return {
+ id: 'identity-image-1',
+ identityId: 'identity-db-1',
+ source: 'generated',
+ viewType: 'primary',
+ provider: 'seedream',
+ model: 'configured-model',
+ prompt: '',
+ 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
+ }
+}
diff --git a/src/features/subject-identity/types.ts b/src/features/subject-identity/types.ts
new file mode 100644
index 0000000..3b2a7ce
--- /dev/null
+++ b/src/features/subject-identity/types.ts
@@ -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>
+
+/** 单张身份生图;空 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 }[]
+}
diff --git a/src/features/subject-identity/useSubjectIdentity.ts b/src/features/subject-identity/useSubjectIdentity.ts
new file mode 100644
index 0000000..7b67ac9
--- /dev/null
+++ b/src/features/subject-identity/useSubjectIdentity.ts
@@ -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) {
+ 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
+ }
+}
diff --git a/src/features/subject-images/SubjectImagesPage.vue b/src/features/subject-images/SubjectImagesPage.vue
index 4bd3c15..2619bf7 100644
--- a/src/features/subject-images/SubjectImagesPage.vue
+++ b/src/features/subject-images/SubjectImagesPage.vue
@@ -86,12 +86,17 @@ function resetFilters() {
形态图片
-
为人物、场景和道具准备参考图。已有图片直接从数据库读取。
+
为人物、场景和道具准备造型参考图。已有图片直接从数据库读取。
去检查分镜参考图
+
+ 人物与场景形态在有身份母版时自动引用,以保持人物身份或空间结构一致;无母版仍可独立生成。道具暂不自动引用。
+ 管理主体身份 →
+ 更换母版后,已有形态主图仍保留,需主动生图并选择新主图。
+
@@ -208,6 +213,11 @@ function resetFilters() {
{{ form.description }}
+
查看主体身份与母版 →