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
+46 -3
View File
@@ -1,6 +1,6 @@
# Short Drama Agent Front # 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 导出 | | 拆解结果 | 主体、别名、视觉形态、Episode → Beat → Shot、主体绑定、抽取任务状态、校验问题、JSON 导出 |
| 拆解恢复 | 失败抽取重试、缺失剧集镜头补齐、分镜引用与 Form 绑定修复 | | 拆解恢复 | 失败抽取重试、缺失剧集镜头补齐、分镜引用与 Form 绑定修复 |
| 视觉风格 | AI 生成、人工编辑、分类 Prompt、JSON 硬约束、锁定;风格参考图地址登记、启停和删除 |
| 主体身份 | 按正式主体归并形态,单个/批量生成身份文本,人工编辑及锁定,部分失败诊断 |
| 身份母版 | Seedream 生图、四种参考视角、指定身份参考图、母版候选与切换、实际 Prompt 和来源追溯 |
| 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 | | 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 |
| 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 | | 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 |
| 镜头工具 | 主体参考图与缺失原因、确定性 GenerationSpec 查询与导出、单镜头/批量视频提示词 | | 镜头工具 | 主体参考图与缺失原因、确定性 GenerationSpec 查询与导出、单镜头/批量视频提示词 |
@@ -67,6 +70,8 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| `src/features/breakdown` | 拆解 graph 的 API、类型、配置页面、主体与分镜组件 | | `src/features/breakdown` | 拆解 graph 的 API、类型、配置页面、主体与分镜组件 |
| `src/features/storyboard` | 分镜 API、类型、生成操作、依赖判断、设计与状态、镜头资源、生成回执 | | `src/features/storyboard` | 分镜 API、类型、生成操作、依赖判断、设计与状态、镜头资源、生成回执 |
| `src/features/subject-images` | 正式形态图库、图片生成参数、历史图片、主图选择与批量回执 | | `src/features/subject-images` | 正式形态图库、图片生成参数、历史图片、主图选择与批量回执 |
| `src/features/visual-style` | 项目视觉风格 API、类型、编辑器、锁定和风格参考图管理 |
| `src/features/subject-identity` | 稳定身份 API、正式主体目录、文本编辑、生图配置、身份图库和母版切换 |
| `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 | | `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 |
| `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 | | `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 |
| `src/composables` | 具备取消和竞态保护的轮询 | | `src/composables` | 具备取消和竞态保护的轮询 |
@@ -78,7 +83,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
## 接口基线 ## 接口基线
对齐后端 `qianlanse/short-drama-agent``dev` 对齐后端 `qianlanse/short-drama-agent``dev`
`eac8dc3b1b2ce1ff2bdfeb01d7281cd838f07285`,包含本次新增的只读 `GET /projects/:projectId/subject-forms` 接口。请同步更新前后端 `dev` 后重启后端 `059e59709359dccd08b08cb35c6aff95c5e03f2b`2026-08-28),包含 VisualStyle、SubjectIdentity、身份母版、人物/场景形态自动引用母版及道具稳定特征提取。本轮只修改前端,请确保后端依照其开发流程同步了新 Prisma 结构并重启服务
| HTTP | 路径(以 /api 为前缀) | 用途 | | HTTP | 路径(以 /api 为前缀) | 用途 |
| --- | --- | --- | | --- | --- | --- |
@@ -102,11 +107,21 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| GET | `/storyboard-shots/:shotId/generation-spec` | 后端确定性编译结果,不调用模型 | | GET | `/storyboard-shots/:shotId/generation-spec` | 后端确定性编译结果,不调用模型 |
| POST | `/storyboard-shots/:shotId/video-prompt` | 复用已有 Prompt 或调用模型生成;`force` 控制覆盖 | | POST | `/storyboard-shots/:shotId/video-prompt` | 复用已有 Prompt 或调用模型生成;`force` 控制覆盖 |
| POST | `/projects/:id/video-prompts/generate` | 项目批量 Prompt`concurrency``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 | | GET | `/subject-forms/:formId/images` | 此形态的全部图片历史及实际 Prompt |
| POST | `/subject-forms/:formId/images` | 单图生成;显式 `provider: seedream`,可选 Prompt 和成对尺寸,`setPrimary` | | POST | `/subject-forms/:formId/images` | 单图生成;显式 `provider: seedream`,可选 Prompt 和成对尺寸,`setPrimary` |
| PUT | `/subject-forms/:formId/images/:imageId/primary` | 选择已完成图片作为主参考图,不调用模型 | | PUT | `/subject-forms/:formId/images/:imageId/primary` | 选择已完成图片作为主参考图,不调用模型 |
| POST | `/projects/:id/subject-images/generate` | 全项目批量生图;`provider``concurrency``force` | | 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 查询取得阶段状态,避免重复拉取同样内容。 API 层也提供 `state``breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。
@@ -146,6 +161,28 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
8. 图片使用持久化后的 `/storage/...` 地址,开发代理已配置。生产环境也必须代理 `/storage/`,不能让 SPA 回退返回 index.html。无效 URL、加载失败、无图和后端生成失败有各自提示;不会自动使用可能过期的 Provider 临时地址。 8. 图片使用持久化后的 `/storage/...` 地址,开发代理已配置。生产环境也必须代理 `/storage/`,不能让 SPA 回退返回 index.html。无效 URL、加载失败、无图和后端生成失败有各自提示;不会自动使用可能过期的 Provider 临时地址。
9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。 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,没有分页或轻量状态接口。长篇剧本会增加轮询流量。下一步建议后端增加: 目前 `checkpoints` 接口返回完整历史与 state,没有分页或轻量状态接口。长篇剧本会增加轮询流量。下一步建议后端增加:
@@ -203,5 +240,11 @@ server {
8. 给主体形态准备参考图,读取镜头参考图和 GenerationSpec;确认 Prompt 无正文时经确认生成、有正文且不覆盖时复用。不提交实际视频任务。 8. 给主体形态准备参考图,读取镜头参考图和 GenerationSpec;确认 Prompt 无正文时经确认生成、有正文且不覆盖时复用。不提交实际视频任务。
9. 打开“形态图片”,检查已有主图自动显示;单个形态新增候选图,查看历史、失败原因和实际 Prompt,再设为主图。 9. 打开“形态图片”,检查已有主图自动显示;单个形态新增候选图,查看历史、失败原因和实际 Prompt,再设为主图。
10. 在小型测试项目中验证批量补齐与“已有主图也新增候选图”;回到分镜刷新参考图,确认使用新选择的主图。 10. 在小型测试项目中验证批量补齐与“已有主图也新增候选图”;回到分镜刷新参考图,确认使用新选择的主图。
11. 进入“视觉风格”,保存或生成风格,检查分类 Prompt 和硬约束;锁定后 AI 入口禁用,人工编辑仍可保存。登记一张已有风格图,检查启停和移除确认。
12. 进入“主体身份”,检查同一主体的多形态归并。生成/保存身份文本并锁定,再生成第一张 primary 图;生成第二张后,确认旧母版未被替换。
13. 为同一主体选择参考来源并生成 front / three-quarter / full-body 图;确认辅助图不能直接设为母版。将第二张 primary 候选设为母版后,检查旧母版停用且保留。
14. 对人物和场景形态分别新增图片,在图片详情检查当次母版 ID;旧主图应保持不变,手动切换后再刷新分镜参考图。道具不应显示自动继承母版已生效。
本轮新增测试覆盖风格 JSON/锁定/图片 DELETE、身份归并/前置条件/尺寸/显式参考图/母版切换、部分失败、旧响应、草稿保护与费用确认。模型调用均使用模拟 API。当前执行环境未能通过浏览器访问本地开发服务,**未完成浏览器视觉验收及真实模型联调**,请按上述步骤在本地验收。
自动测试使用模拟 API,覆盖既有流程、分镜接口与竞态,以及形态图片自动读取、正式 ID 生图、主图 PUT、费用确认、尺寸校验、候选图选择、批量部分失败和图片加载错误。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。 自动测试使用模拟 API,覆盖既有流程、分镜接口与竞态,以及形态图片自动读取、正式 ID 生图、主图 PUT、费用确认、尺寸校验、候选图选择、批量部分失败和图片加载错误。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
+18 -2
View File
@@ -5,6 +5,8 @@ import {
Clapperboard, Clapperboard,
Camera, Camera,
Images, Images,
Palette,
Fingerprint,
FolderOpen, FolderOpen,
FileText, FileText,
Layers, Layers,
@@ -56,20 +58,34 @@ const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
title="剧本拆解" title="剧本拆解"
><Layers :size="18" /><span class="sidebar-label">剧本拆解</span ><Layers :size="18" /><span class="sidebar-label">剧本拆解</span
><span class="sidebar-label ml-auto text-[10px] text-stone-500">02</span></RouterLink ><span class="sidebar-label ml-auto text-[10px] text-stone-500">02</span></RouterLink
><RouterLink
:to="`/projects/${projectId}/visual-style`"
class="side-link"
active-class="selected"
title="视觉风格"
><Palette :size="18" /><span class="sidebar-label">视觉风格</span
><span class="sidebar-label ml-auto text-[10px] text-stone-500">03</span></RouterLink
><RouterLink
:to="`/projects/${projectId}/subject-identity`"
class="side-link"
active-class="selected"
title="主体身份"
><Fingerprint :size="18" /><span class="sidebar-label">主体身份</span
><span class="sidebar-label ml-auto text-[10px] text-stone-500">04</span></RouterLink
><RouterLink ><RouterLink
:to="`/projects/${projectId}/subject-images`" :to="`/projects/${projectId}/subject-images`"
class="side-link" class="side-link"
active-class="selected" active-class="selected"
title="形态图片" title="形态图片"
><Images :size="18" /><span class="sidebar-label">形态图片</span ><Images :size="18" /><span class="sidebar-label">形态图片</span
><span class="sidebar-label ml-auto text-[10px] text-stone-500">03</span></RouterLink ><span class="sidebar-label ml-auto text-[10px] text-stone-500">05</span></RouterLink
><RouterLink ><RouterLink
:to="`/projects/${projectId}/storyboard`" :to="`/projects/${projectId}/storyboard`"
class="side-link" class="side-link"
active-class="selected" active-class="selected"
title="分镜设计" title="分镜设计"
><Camera :size="18" /><span class="sidebar-label">分镜设计</span ><Camera :size="18" /><span class="sidebar-label">分镜设计</span
><span class="sidebar-label ml-auto text-[10px] text-stone-500">04</span></RouterLink ><span class="sidebar-label ml-auto text-[10px] text-stone-500">06</span></RouterLink
> >
</nav></template </nav></template
> >
@@ -34,6 +34,12 @@ const filtered = computed(() =>
> >
</div> </div>
<p class="text-sm leading-7">{{ subject.description }}</p> <p class="text-sm leading-7">{{ subject.description }}</p>
<RouterLink
v-if="projectId"
:to="{ path: `/projects/${projectId}/subject-identity`, query: { subjectRef: subject.ref } }"
class="text-button mt-3 mr-4"
>管理稳定身份母版</RouterLink
>
<RouterLink <RouterLink
v-if="projectId" v-if="projectId"
:to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }" :to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }"
+7 -13
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, provide } from 'vue' import { computed, provide } from 'vue'
import { useRoute } from 'vue-router' 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 { StatusBadge } from '../../components/ui'
import { projectContextKey, useProjectData } from './context' import { projectContextKey, useProjectData } from './context'
import { getOperation } from '../workflows/operations' import { getOperation } from '../workflows/operations'
@@ -39,18 +39,12 @@ const operation = computed(() => getOperation(id.value))
</div> </div>
</div> </div>
<nav class="workflow-nav" aria-label="项目工作流"> <nav class="workflow-nav" aria-label="项目工作流">
<RouterLink :to="`/projects/${id}/create-drama`" <RouterLink :to="`/projects/${id}/create-drama`"><FileText :size="17" />剧本创作</RouterLink>
><FileText :size="17" />剧本创作<span class="nav-code">create-drama</span></RouterLink <RouterLink :to="`/projects/${id}/breakdown`"><Layers :size="17" />剧本拆解</RouterLink>
> <RouterLink :to="`/projects/${id}/visual-style`"><Palette :size="17" />视觉风格</RouterLink>
<RouterLink :to="`/projects/${id}/breakdown`" <RouterLink :to="`/projects/${id}/subject-identity`"><Fingerprint :size="17" />主体身份</RouterLink>
><Layers :size="17" />剧本拆解<span class="nav-code">breakdown</span></RouterLink <RouterLink :to="`/projects/${id}/subject-images`"><Images :size="17" />形态图片</RouterLink>
> <RouterLink :to="`/projects/${id}/storyboard`"><Camera :size="17" />分镜设计</RouterLink>
<RouterLink :to="`/projects/${id}/subject-images`"
><Images :size="17" />形态图片<span class="nav-code">subject-image</span></RouterLink
>
<RouterLink :to="`/projects/${id}/storyboard`"
><Camera :size="17" />分镜设计<span class="nav-code">storyboard</span></RouterLink
>
</nav> </nav>
<p v-if="context.error.value" class="alert alert-error mt-4" role="alert"> <p v-if="context.error.value" class="alert alert-error mt-4" role="alert">
{{ context.error.value }}<span v-if="context.project.value"> 当前保留上次成功读取的数据</span> {{ context.error.value }}<span v-if="context.project.value"> 当前保留上次成功读取的数据</span>
@@ -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
}
}
@@ -86,12 +86,17 @@ function resetFilters() {
<div class="flex flex-wrap items-end justify-between gap-4"> <div class="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<h2 class="text-lg font-semibold">形态图片</h2> <h2 class="text-lg font-semibold">形态图片</h2>
<p class="mt-2 text-sm text-muted">为人物场景和道具准备参考图已有图片直接从数据库读取</p> <p class="mt-2 text-sm text-muted">为人物场景和道具准备造型参考图已有图片直接从数据库读取</p>
</div> </div>
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button" <RouterLink :to="`/projects/${id}/storyboard`" class="text-button"
>去检查分镜参考图<ArrowRight :size="14" >去检查分镜参考图<ArrowRight :size="14"
/></RouterLink> /></RouterLink>
</div> </div>
<p class="alert mt-5 text-xs">
人物与场景形态在有身份母版时自动引用以保持人物身份或空间结构一致无母版仍可独立生成道具暂不自动引用
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2">管理主体身份 </RouterLink>
更换母版后已有形态主图仍保留需主动生图并选择新主图
</p>
<div class="panel mt-5 p-5"> <div class="panel mt-5 p-5">
<div class="flex flex-wrap items-center justify-between gap-4"> <div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-sm"> <p class="text-sm">
@@ -208,6 +213,11 @@ function resetFilters() {
<p v-if="form.description" class="mt-3 line-clamp-2 text-xs leading-6 text-muted"> <p v-if="form.description" class="mt-3 line-clamp-2 text-xs leading-6 text-muted">
{{ form.description }} {{ form.description }}
</p> </p>
<RouterLink
:to="{ path: `/projects/${id}/subject-identity`, query: { subjectId: form.subjectId } }"
class="text-button mt-3"
>查看主体身份与母版 </RouterLink
>
<div class="mt-3 flex flex-wrap items-center gap-2"> <div class="mt-3 flex flex-wrap items-center gap-2">
<StatusBadge v-if="hasRunningImages(form.images)" status="generating" /><span <StatusBadge v-if="hasRunningImages(form.images)" status="generating" /><span
v-if="primaryImage(form.images)" v-if="primaryImage(form.images)"
@@ -52,6 +52,14 @@ function submit() {
> >
<form class="mt-6 space-y-5" @submit.prevent="submit"> <form class="mt-6 space-y-5" @submit.prevent="submit">
<p class="alert text-xs">使用后端配置的 Seedream 模型可能产生费用每次新增一张图片不删除历史结果</p> <p class="alert text-xs">使用后端配置的 Seedream 模型可能产生费用每次新增一张图片不删除历史结果</p>
<p class="text-xs leading-6 text-muted">
{{
form?.subject.module === 'character' || form?.subject.module === 'scene'
? '后端会自动引用此人物/场景当前的身份母版,保持身份或空间结构;没有母版时仍可按文本生成。更换母版不会自动更新已有形态图。'
: '当前道具形态生图暂不自动引用身份母版。'
}}
身份母版与此处的形态主参考图是两种不同用途的图片
</p>
<label class="block" <label class="block"
><span class="field-label">自定义提示词可选仅用于本次</span ><span class="field-label">自定义提示词可选仅用于本次</span
><textarea ><textarea
@@ -5,6 +5,7 @@ import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
import { usePolling } from '../../../composables/usePolling' import { usePolling } from '../../../composables/usePolling'
import { referenceImageUrl } from '../../../lib/assets' import { referenceImageUrl } from '../../../lib/assets'
import { formatDate } from '../../../lib/format' import { formatDate } from '../../../lib/format'
import { readImageProvenance } from '../../subject-identity'
import { getOperation, runOperation } from '../../workflows/operations' import { getOperation, runOperation } from '../../workflows/operations'
import { subjectImagesApi } from '../api' import { subjectImagesApi } from '../api'
import { hasRunningImages, primaryImage } from '../model' import { hasRunningImages, primaryImage } from '../model'
@@ -144,6 +145,13 @@ async function choosePrimary() {
</p> </p>
</details> </details>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Image ID · {{ selected.id }}</p> <p class="mt-3 break-all font-mono text-[10px] text-muted">Image ID · {{ selected.id }}</p>
<p class="mt-2 break-all text-[11px] text-muted">
{{
readImageProvenance(selected.rawJson).identityAnchorImageId
? `本次引用身份母版 ID${readImageProvenance(selected.rawJson).identityAnchorImageId}`
: '此记录未提供身份母版来源,不能据此判断是否使用了当前母版。'
}}
</p>
<div class="image-history mt-5" aria-label="图片历史"> <div class="image-history mt-5" aria-label="图片历史">
<button <button
v-for="image in images" v-for="image in images"
@@ -23,6 +23,15 @@ afterEach(() => {
}) })
describe('形态图片选择与操作', () => { describe('形态图片选择与操作', () => {
it.each(['character', 'scene', 'prop'])('按后端最新 %s 模块展示母版继承范围', async module => {
const form = formFixture()
form.subject.module = module
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, form, disabled: false } })
await flushPromises()
expect(document.body.textContent).toContain(
module === 'prop' ? '道具形态生图暂不自动引用' : '后端会自动引用此人物/场景当前的身份母版'
)
})
it('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => { it('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => {
const form = formFixture() const form = formFixture()
form.images.unshift( form.images.unshift(
+1 -1
View File
@@ -20,7 +20,7 @@ export function coverImage(form: SubjectFormAsset): SubjectImage | undefined {
} }
/** 查询发现后台仍在运行时也阻止重复提交,不只依赖本地 loading。 */ /** 查询发现后台仍在运行时也阻止重复提交,不只依赖本地 loading。 */
export function hasRunningImages(images: SubjectImage[]): boolean { export function hasRunningImages(images: Pick<SubjectImage, 'status'>[]): boolean {
return images.some(image => image.status === 'pending' || image.status === 'generating') return images.some(image => image.status === 'pending' || image.status === 'generating')
} }
+2
View File
@@ -18,6 +18,8 @@ export interface SubjectImage {
/** 详情查询才返回实际使用的 Prompt;列表不包含长文本。 */ /** 详情查询才返回实际使用的 Prompt;列表不包含长文本。 */
prompt?: string prompt?: string
negativePrompt?: string | null negativePrompt?: string | null
/** 详情保留生成追溯信息,可读取当时引用的身份母版 ID。 */
rawJson?: string | null
} }
/** 正式 SubjectForm 及其所属主体、图片,不使用 checkpoint 的领域 formId。 */ /** 正式 SubjectForm 及其所属主体、图片,不使用 checkpoint 的领域 formId。 */
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
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 = usePolling(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)
/** 防止错误项目的数据进入编辑器;空风格是正常的首次使用状态。 */
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>
<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-identity`" class="text-button"
>下一步主体身份 </RouterLink
>
</div>
<div class="my-5 flex flex-wrap items-center gap-3">
<span class="tag">{{ style ? (style.isLocked ? '已锁定' : '未锁定') : '尚未创建' }}</span>
<ConfirmAction
:label="style ? 'AI 重新生成风格' : 'AI 生成风格'"
:disabled="disabled || !!style?.isLocked || dirty"
acknowledgement
description="调用文本模型生成整体及分类风格。重新生成会覆盖已保存的文本与硬约束;旧身份、旧图片和已有提示词不会自动更新。"
@confirm="generate"
/>
<button class="text-button" :disabled="query.loading.value" @click="query.refresh">刷新风格</button>
</div>
<p v-if="query.error.value" class="alert alert-error mb-4" role="alert">
{{ query.error.value }} 请确认后端 dev 已更新并重启查询失败不等同于尚未创建
</p>
<p v-if="dirty" class="mb-4 text-xs text-muted">
有未保存修改AI 重生成暂不可用自动刷新不会覆盖编辑内容离开页面不会自动保存
</p>
<p class="alert mb-5 text-xs">
修改风格只影响之后的相关生成已有主体身份形态图片和视频提示词不会自动更新请按需要逐步重生成
</p>
<StyleEditor
v-if="query.data.value"
:key="editorRevision"
: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"
:images="style?.images ?? []"
:disabled="disabled || !style"
@add="addImage"
@toggle="toggleImage"
@remove="removeImage"
/>
</section>
</template>
+25
View File
@@ -0,0 +1,25 @@
import { request } from '../../lib/http'
import type { AddStyleImageInput, SaveVisualStyleInput, VisualStyle, VisualStyleImage } from './types'
/** 固定项目范围并编码正式 ID。 */
function stylePath(projectId: string) {
return `/projects/${encodeURIComponent(projectId)}/visual-style`
}
/** 风格查询、编辑与图片登记;只有 generate 会调用文本模型。 */
export const visualStyleApi = {
get: (projectId: string, signal?: AbortSignal) => request<VisualStyle | null>(stylePath(projectId), { signal }),
save: (projectId: string, input: SaveVisualStyleInput) =>
request<VisualStyle>(stylePath(projectId), { method: 'PUT', body: input }),
generate: (projectId: string, force: boolean) =>
request<VisualStyle>(`${stylePath(projectId)}/generate`, { method: 'POST', body: { force }, timeoutMs: 0 }),
addImage: (projectId: string, input: AddStyleImageInput) =>
request<VisualStyleImage>(`${stylePath(projectId)}/images`, { method: 'POST', body: input }),
setImageEnabled: (projectId: string, imageId: string, enabled: boolean) =>
request<VisualStyleImage>(`${stylePath(projectId)}/images/${encodeURIComponent(imageId)}/enabled`, {
method: 'PUT',
body: { enabled }
}),
removeImage: (projectId: string, imageId: string) =>
request<VisualStyleImage>(`${stylePath(projectId)}/images/${encodeURIComponent(imageId)}`, { method: 'DELETE' })
}
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import type { SaveVisualStyleInput, VisualStyle } from '../types'
/** 编辑草稿独立于轮询结果,刷新不会覆盖尚未保存的输入。 */
const props = defineProps<{ style: VisualStyle | null; disabled: boolean }>()
const emit = defineEmits<{ save: [input: SaveVisualStyleInput]; dirty: [value: boolean] }>()
const form = reactive({
name: '',
prompt: '',
characterPrompt: '',
scenePrompt: '',
propPrompt: '',
constraints: '[]',
isLocked: false
})
const baseline = ref('')
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
const constraintsError = computed(() => {
try {
const value: unknown = JSON.parse(form.constraints)
return Array.isArray(value) && value.every(item => typeof item === 'string')
? ''
: '硬约束须为字符串数组,例如 ["真人写实", "禁止二次元"]。'
} catch {
return '硬约束 JSON 格式不正确,请修正后保存。'
}
})
watch(
() => props.style,
value => {
if (baseline.value && dirty.value) return
Object.assign(form, {
name: value?.name ?? '默认视觉风格',
prompt: value?.prompt ?? '',
characterPrompt: value?.characterPrompt ?? '',
scenePrompt: value?.scenePrompt ?? '',
propPrompt: value?.propPrompt ?? '',
constraints: JSON.stringify(value?.hardConstraints ?? [], null, 2),
isLocked: value?.isLocked ?? false
})
baseline.value = JSON.stringify(form)
},
{ immediate: true }
)
watch(dirty, value => emit('dirty', value), { immediate: true })
/** 显式校验 JSON 后保存结构化约束;不对错误结构做静默转换。 */
function save() {
if (props.disabled || constraintsError.value) return
emit('save', {
name: form.name.trim(),
prompt: form.prompt,
characterPrompt: form.characterPrompt,
scenePrompt: form.scenePrompt,
propPrompt: form.propPrompt,
hardConstraints: JSON.parse(form.constraints) as string[],
isLocked: form.isLocked
})
}
</script>
<template>
<form class="panel p-5 sm:p-6" @submit.prevent="save">
<fieldset :disabled="disabled" class="space-y-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="font-medium">风格说明</h3>
<span v-if="dirty" class="tag">有未保存修改</span>
</div>
<label class="block"
><span class="field-label">风格名称</span><input id="style-name" v-model="form.name" class="input"
/></label>
<label class="block"
><span class="field-label">整体视觉语言</span
><textarea
id="style-prompt"
v-model="form.prompt"
class="input min-h-32"
placeholder="媒介、真实感、色彩和整体美术方向"
/>
</label>
<div class="grid gap-5 lg:grid-cols-3">
<label
><span class="field-label">人物风格补充</span
><textarea v-model="form.characterPrompt" class="input min-h-32" />
</label>
<label
><span class="field-label">场景风格补充</span
><textarea v-model="form.scenePrompt" class="input min-h-32" />
</label>
<label
><span class="field-label">道具风格补充</span
><textarea v-model="form.propPrompt" class="input min-h-32" />
</label>
</div>
<label class="block"
><span class="field-label">硬约束 · JSON 字符串数组</span
><textarea
id="style-constraints"
v-model="form.constraints"
class="input min-h-24 font-mono text-xs"
spellcheck="false"
/>
</label>
<p v-if="constraintsError" class="text-xs text-danger" role="alert">{{ constraintsError }}</p>
<label class="flex items-start gap-2 text-sm"
><input
id="style-lock"
v-model="form.isLocked"
type="checkbox"
class="mt-1 accent-accent"
/>确认并锁定风格阻止后续 AI 自动覆盖</label
>
<p class="text-xs leading-6 text-muted">
锁定后仍可人工编辑并保存取消勾选并保存后才能再次使用 AI 重生成
</p>
<div class="flex justify-end">
<button class="button button-primary" type="submit" :disabled="disabled || !!constraintsError">
保存视觉风格
</button>
</div>
</fieldset>
</form>
</template>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { AssetImage } from '../../../components/ui'
import { referenceImageUrl } from '../../../lib/assets'
import type { AddStyleImageInput, StyleCategory, VisualStyleImage } from '../types'
/** 风格图只登记已有地址;不伪造文件上传或风格生图功能。 */
const props = defineProps<{ images: VisualStyleImage[]; disabled: boolean }>()
const emit = defineEmits<{
add: [input: AddStyleImageInput]
toggle: [image: VisualStyleImage]
remove: [image: VisualStyleImage]
}>()
const form = reactive({ imageUrl: '', category: 'overall' as StyleCategory, sortOrder: 0, enabled: true })
const confirmingId = ref('')
const categories = { overall: '整体', character: '人物', scene: '场景', prop: '道具' } as const
const valid = computed(() => !!referenceImageUrl(form.imageUrl) && Number.isSafeInteger(form.sortOrder))
/** 仅通过校验后发出新增请求,输入框在失败时保留供修正。 */
function add() {
if (props.disabled || !valid.value) return
emit('add', { ...form, imageUrl: form.imageUrl.trim(), source: 'upload' })
}
/** 删除只移除记录,需确认且不声称删除远程文件。 */
function remove(image: VisualStyleImage) {
if (props.disabled || confirmingId.value !== image.id) return
confirmingId.value = ''
emit('remove', image)
}
</script>
<template>
<section class="panel mt-5 p-5 sm:p-6" aria-label="风格参考图">
<h3 class="font-medium">
风格参考图 <span class="ml-2 text-xs text-muted">{{ images.length }} </span>
</h3>
<p class="mt-2 text-xs leading-6 text-muted">
登记已有 HTTP(S) /storage/
图片地址后端尚无文件上传接口这些图片目前仅管理记录现有身份形态生图不会自动将风格图片传给模型
</p>
<form class="mt-4" @submit.prevent="add">
<fieldset :disabled="disabled" class="flex flex-wrap items-end gap-3">
<label class="min-w-48 flex-1"
><span class="field-label">图片地址</span
><input
v-model="form.imageUrl"
class="input"
placeholder="https://… 或 /storage/…"
aria-label="风格图片地址"
/></label>
<label
><span class="field-label">分类</span
><select v-model="form.category" class="input">
<option v-for="(label, value) in categories" :key="value" :value="value">{{ label }}</option>
</select></label
>
<label class="w-24"
><span class="field-label">排序</span
><input
v-model.number="form.sortOrder"
class="input"
type="number"
step="1"
aria-label="风格图片排序"
/></label>
<label class="flex gap-2 pb-2 text-xs"><input v-model="form.enabled" type="checkbox" />启用</label>
<button class="button button-secondary" type="submit" :disabled="disabled || !valid">登记参考图</button>
</fieldset>
</form>
<p v-if="form.imageUrl && !valid" class="mt-2 text-xs text-danger" role="alert">
请填写有效图片地址和整数排序
</p>
<div v-if="images.length" class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<article v-for="image in images" :key="image.id" class="min-w-0 rounded border border-line p-3">
<AssetImage
:src="image.imageUrl"
:alt="`${categories[image.category]}风格参考图`"
class="aspect-square"
/>
<p class="mt-3 text-xs">
{{ categories[image.category] }} · {{ image.enabled ? '已启用' : '已停用' }} · 排序
{{ image.sortOrder }}
</p>
<p v-if="image.provider || image.model" class="mt-2 break-words text-[11px] text-muted">
{{ image.provider }} {{ image.model }}
</p>
<details v-if="image.prompt" class="mt-2 text-xs">
<summary>实际提示词</summary>
<p class="mt-2 whitespace-pre-wrap">{{ image.prompt }}</p>
</details>
<div class="mt-3 flex flex-wrap gap-3">
<a
v-if="referenceImageUrl(image.imageUrl)"
:href="referenceImageUrl(image.imageUrl)!"
class="text-button"
target="_blank"
rel="noopener noreferrer"
>原图</a
>
<button class="text-button" :disabled="disabled" @click="emit('toggle', image)">
{{ image.enabled ? '停用' : '启用' }}
</button>
<button class="text-button text-danger" :disabled="disabled" @click="confirmingId = image.id">
移除
</button>
</div>
<div v-if="confirmingId === image.id" class="mt-3 text-xs">
<p>仅移除参考图记录不删除远程文件</p>
<div class="mt-2 flex gap-3">
<button class="text-button text-danger" :disabled="disabled" @click="remove(image)">
确认移除</button
><button class="text-button" @click="confirmingId = ''">取消</button>
</div>
</div>
</article>
</div>
<p v-else class="mt-5 text-sm text-muted">尚无风格参考图先保存视觉风格再登记图片</p>
</section>
</template>
+3
View File
@@ -0,0 +1,3 @@
/** 视觉风格模块公共入口。 */
export { visualStyleApi } from './api'
export type { VisualStyle, VisualStyleImage, StyleCategory, SaveVisualStyleInput } from './types'
@@ -0,0 +1,39 @@
import type { VisualStyle, VisualStyleImage } from '../types'
/** 仅供测试使用的项目视觉风格,与后端持久化返回字段一致。 */
export function styleFixture(overrides: Partial<VisualStyle> = {}): VisualStyle {
return {
id: 'style-db-1',
projectId: 'page-test-project',
name: '都市悬疑真人短剧',
prompt: '真人写实,冷暖对比',
characterPrompt: '真实皮肤质感',
scenePrompt: '现代城市',
propPrompt: '真实材质',
hardConstraints: ['禁止二次元'],
isLocked: false,
images: [],
createdAt: '2026-08-28T00:00:00Z',
updatedAt: '2026-08-28T00:00:00Z',
...overrides
}
}
/** 风格图记录不包含文件上传或模型调用。 */
export function styleImageFixture(overrides: Partial<VisualStyleImage> = {}): VisualStyleImage {
return {
id: 'style-image-1',
visualStyleId: 'style-db-1',
category: 'overall',
source: 'upload',
imageUrl: '/storage/style.png',
enabled: true,
sortOrder: 0,
provider: null,
model: null,
prompt: null,
createdAt: '2026-08-28T00:00:00Z',
updatedAt: '2026-08-28T00:00:00Z',
...overrides
}
}
+51
View File
@@ -0,0 +1,51 @@
/** 风格参考图分类,与后端枚举保持一致。 */
export type StyleCategory = 'overall' | 'character' | 'scene' | 'prop'
/** 已登记的风格参考图;这里只登记地址,不代表前端上传了文件。 */
export interface VisualStyleImage {
id: string
visualStyleId: string
category: StyleCategory
source: 'upload' | 'generated'
imageUrl: string
enabled: boolean
sortOrder: number
provider: string | null
model: string | null
prompt: string | null
createdAt: string
updatedAt: string
}
/** 项目统一视觉风格;hardConstraints 保留后端 JSON,避免静默丢弃旧结构。 */
export interface VisualStyle {
id: string
projectId: string
name: string
prompt: string | null
characterPrompt: string | null
scenePrompt: string | null
propPrompt: string | null
hardConstraints: unknown
isLocked: boolean
images: VisualStyleImage[]
createdAt: string
updatedAt: string
}
/** 人工保存只发送本次可编辑字段,锁定仍允许用户主动保存。 */
export type SaveVisualStyleInput = Partial<
Pick<
VisualStyle,
'name' | 'prompt' | 'characterPrompt' | 'scenePrompt' | 'propPrompt' | 'hardConstraints' | 'isLocked'
>
>
/** 新增参考图使用已有地址,允许指定分类、启用状态及展示顺序。 */
export interface AddStyleImageInput {
category: StyleCategory
source: 'upload'
imageUrl: string
enabled: boolean
sortOrder: number
}
+6 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, watch } from 'vue'
import { DialogTrigger } from 'reka-ui' import { DialogTrigger } from 'reka-ui'
import { AppDialog } from '../../components/ui' import { AppDialog } from '../../components/ui'
@@ -15,6 +15,11 @@ const emit = defineEmits<{ confirm: [] }>()
const open = ref(false) const open = ref(false)
const acknowledged = ref(false) const acknowledged = ref(false)
/** 取消后重新打开,或确认目标改变,都必须重新确认任务与费用。 */
watch([open, () => props.label, () => props.description], () => {
acknowledged.value = false
})
/** 发出事件后关闭弹窗,操作状态由项目级锁负责。 */ /** 发出事件后关闭弹窗,操作状态由项目级锁负责。 */
function confirm() { function confirm() {
if (props.disabled || (props.acknowledgement && !acknowledged.value)) return if (props.disabled || (props.acknowledgement && !acknowledged.value)) return
+381
View File
@@ -22,6 +22,30 @@ import { getOperation } from './operations'
import SubjectImagesPage from '../subject-images/SubjectImagesPage.vue' import SubjectImagesPage from '../subject-images/SubjectImagesPage.vue'
import { formFixture, imageFixture } from '../subject-images/testing/fixtures' import { formFixture, imageFixture } from '../subject-images/testing/fixtures'
import { getImageSession } from '../subject-images/model' import { getImageSession } from '../subject-images/model'
import VisualStylePage from '../visual-style/VisualStylePage.vue'
import SubjectIdentityPage from '../subject-identity/SubjectIdentityPage.vue'
import { styleFixture, styleImageFixture } from '../visual-style/testing/fixtures'
import { identityFixture, identityImageFixture } from '../subject-identity/testing/fixtures'
import { getIdentitySession } from '../subject-identity/model'
/** 新资产页面复用已有项目上下文和内存路由,不接入真实模型。 */
async function mountAssets(page: 'style' | 'identity', query = '') {
const component = page === 'style' ? VisualStylePage : SubjectIdentityPage
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/assets', component }] })
await router.push('/assets' + query)
const provided = context()
wrapper = mount(component, {
attachTo: document.body,
global: { plugins: [router], provide: { [projectContextKey as symbol]: provided } }
})
await flushPromises()
return provided
}
/** 统一构造独立响应,避免复用已消费的 Response body。 */
function jsonResponse(data: unknown) {
return new Response(JSON.stringify({ data }))
}
/** 模拟 API 响应仅用于测试,不会打包进生产页面。 */ /** 模拟 API 响应仅用于测试,不会打包进生产页面。 */
const fixture: ProjectDetail = { const fixture: ProjectDetail = {
@@ -99,6 +123,7 @@ async function mountSubjectImages() {
} }
afterEach(() => { afterEach(() => {
getIdentitySession(fixture.id).receipt = null
wrapper?.unmount() wrapper?.unmount()
wrapper = undefined wrapper = undefined
document.body.innerHTML = '' document.body.innerHTML = ''
@@ -108,6 +133,362 @@ afterEach(() => {
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' }) Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
}) })
describe('视觉风格与主体身份工作区', () => {
it('从形态深链接进入后,手动切换主体不会被目录刷新切回', async () => {
const form = formFixture()
const second = {
...form,
id: 'form-2',
subjectId: 'subject-2',
images: [],
subject: { ...form.subject, id: 'subject-2', name: '陆川', ref: '@CH0002' }
}
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>().mockImplementation(async url => {
if (String(url).endsWith('/subject-forms')) return jsonResponse([form, second])
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
return jsonResponse(null)
})
)
await mountAssets('identity', '?subjectId=subject-db-1')
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
await flushPromises()
button('刷新主体目录').click()
await flushPromises()
expect(wrapper!.findAll('.identity-subject-item')[1]!.attributes('aria-pressed')).toBe('true')
})
it('身份文本保存发送正式主体 ID 与锁定状态,草稿切换需确认', async () => {
let identity = identityFixture()
const form = formFixture()
const second = {
...form,
id: 'form-2',
subjectId: 'subject-2',
images: [],
subject: { ...form.subject, id: 'subject-2', name: '陆川', ref: '@CH0002' }
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (String(url).endsWith('/subject-forms')) return jsonResponse([form, second])
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
if (String(url).endsWith('/identity/images')) return jsonResponse([])
if (String(url).includes('/subject-2/')) return jsonResponse(null)
if (init?.method === 'PUT') identity = { ...identity, ...JSON.parse(init.body as string) }
return jsonResponse(identity)
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
await wrapper!.get('#identity-description').setValue('人工稳定身份')
await wrapper!.get('#identity-lock').setValue(true)
button('保存主体身份').click()
await flushPromises()
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
expect(put[0]).toBe('/api/subjects/subject-db-1/identity')
expect(JSON.parse(put[1]!.body as string)).toMatchObject({ description: '人工稳定身份', isLocked: true })
expect(button('AI 重新生成身份').disabled).toBe(true)
await wrapper!.get('#identity-description').setValue('新的未保存草稿')
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
expect(wrapper!.text()).toContain('切换会丢弃草稿')
expect((wrapper!.get('#identity-description').element as HTMLTextAreaElement).value).toBe('新的未保存草稿')
button('放弃草稿并切换').click()
await flushPromises()
expect((wrapper!.get('#identity-description').element as HTMLTextAreaElement).value).toBe('')
})
it('风格图拒绝危险 URL,登记只传已有地址及分类排序', async () => {
const image = styleImageFixture()
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async (_url, init) => jsonResponse(init?.method === 'POST' ? image : styleFixture()))
vi.stubGlobal('fetch', fetcher)
await mountAssets('style')
await wrapper!.get('[aria-label="风格图片地址"]').setValue('javascript:alert(1)')
expect(button('登记参考图').disabled).toBe(true)
await wrapper!.get('[aria-label="风格图片地址"]').setValue('/storage/style.png')
button('登记参考图').click()
await flushPromises()
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(post[0]).toBe('/api/projects/page-test-project/visual-style/images')
expect(JSON.parse(post[1]!.body as string)).toEqual({
imageUrl: '/storage/style.png',
category: 'overall',
source: 'upload',
enabled: true,
sortOrder: 0
})
expect((wrapper!.get('[aria-label="风格图片地址"]').element as HTMLInputElement).value).toBe('')
})
it('AI 确认取消后重新打开必须再次确认,不能复用上次勾选', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse(styleFixture()))
vi.stubGlobal('fetch', fetcher)
await mountAssets('style')
button('AI 重新生成风格').click()
await flushPromises()
document.querySelector<HTMLInputElement>('[role="dialog"] input[type="checkbox"]')!.click()
await flushPromises()
expect(button('确认AI 重新生成风格').disabled).toBe(false)
button('取消').click()
await flushPromises()
button('AI 重新生成风格').click()
await flushPromises()
expect(button('确认AI 重新生成风格').disabled).toBe(true)
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
})
it('视觉风格首次查询为空时可人工保存,JSON 校验和锁定字段准确传递', async () => {
let style: ReturnType<typeof styleFixture> | null = null
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
if (init?.method === 'PUT') style = styleFixture(JSON.parse(init.body as string))
return jsonResponse(style)
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('style')
expect(wrapper!.text()).toContain('尚未创建')
await wrapper!.get('#style-constraints').setValue('{"不应静默丢弃":true}')
expect(button('保存视觉风格').disabled).toBe(true)
await wrapper!.get('#style-constraints').setValue('["真人写实"]')
await wrapper!.get('#style-lock').setValue(true)
await wrapper!.get('#style-name').setValue('雨夜风格')
button('保存视觉风格').click()
await flushPromises()
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
expect(put[0]).toBe('/api/projects/page-test-project/visual-style')
expect(JSON.parse(put[1]!.body as string)).toMatchObject({
name: '雨夜风格',
isLocked: true,
hardConstraints: ['真人写实']
})
expect(button('AI 重新生成风格').disabled).toBe(true)
expect(button('保存视觉风格').disabled).toBe(false)
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
})
it('刷新风格不覆盖未保存草稿,草稿存在时禁止 AI 重生成', async () => {
let style = styleFixture()
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>().mockImplementation(async () => jsonResponse(style))
)
await mountAssets('style')
await wrapper!.get('#style-prompt').setValue('人工草稿')
style = styleFixture({ prompt: '后台新内容' })
button('刷新风格').click()
await flushPromises()
expect((wrapper!.get('#style-prompt').element as HTMLTextAreaElement).value).toBe('人工草稿')
expect(button('AI 重新生成风格').disabled).toBe(true)
})
it('风格接口 404 显示错误而不是当作空风格,禁止写入', async () => {
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>().mockResolvedValue(new Response('{"message":"路由不存在"}', { status: 404 }))
)
await mountAssets('style')
expect(wrapper!.text()).toContain('路由不存在')
expect(button('AI 生成风格').disabled).toBe(true)
expect(wrapper!.find('#style-prompt').exists()).toBe(false)
})
it('风格图启停用 PUT,移除经确认用 DELETE,均不调用生图接口', async () => {
let image = styleImageFixture()
let removed = false
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (init?.method === 'PUT') {
image = { ...image, enabled: false }
return jsonResponse(image)
}
if (init?.method === 'DELETE') {
removed = true
return jsonResponse(image)
}
expect(String(url)).toContain('/visual-style')
return jsonResponse(styleFixture({ images: removed ? [] : [image] }))
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('style')
button('停用').click()
await flushPromises()
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
expect(put[0]).toBe('/api/projects/page-test-project/visual-style/images/style-image-1/enabled')
expect(JSON.parse(put[1]!.body as string)).toEqual({ enabled: false })
button('移除').click()
await flushPromises()
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false)
button('确认移除').click()
await flushPromises()
expect(fetcher.mock.calls.find(([, init]) => init?.method === 'DELETE')?.[0]).toBe(
'/api/projects/page-test-project/visual-style/images/style-image-1'
)
expect(wrapper!.text()).toContain('尚无风格参考图')
})
it('主体目录按正式 Subject ID 去重,无 Identity 时不请求会报错的图片接口', async () => {
const form = formFixture()
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
if (String(url).endsWith('/subject-forms'))
return jsonResponse([form, { ...form, id: 'form-db-2', images: [], name: '雨夜造型' }])
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
return jsonResponse(null)
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
expect(wrapper!.findAll('.identity-subject-item')).toHaveLength(1)
expect(wrapper!.text()).toContain('2 个形态')
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-1/identity'))).toBe(true)
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/identity/images'))).toBe(false)
expect(button('生成身份参考图').disabled).toBe(true)
expect(button('AI 生成身份').disabled).toBe(false)
})
it('锁定阻止身份 AI 覆盖但不阻止人工编辑和生图,生图默认省略 referenceImageId', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture({ isLocked: true }))
if (init?.method === 'POST') return jsonResponse(identityImageFixture())
if (String(url).endsWith('/identity/images')) return jsonResponse([identityImageFixture()])
return jsonResponse(identityFixture({ isLocked: true }))
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
expect(button('AI 重新生成身份').disabled).toBe(true)
expect(button('保存主体身份').disabled).toBe(false)
button('生成身份参考图').click()
await flushPromises()
expect(button('确认生成身份图').disabled).toBe(true)
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
await flushPromises()
button('确认生成身份图').click()
await flushPromises()
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(post[0]).toBe('/api/subjects/subject-db-1/identity/images')
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', viewType: 'primary' })
})
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>().mockImplementation(async url => {
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
if (String(url).endsWith('/visual-style')) return jsonResponse(null)
if (String(url).endsWith('/identity/images')) return jsonResponse([])
return jsonResponse(identityFixture())
})
)
await mountAssets('identity')
expect(button('AI 重新生成身份').disabled).toBe(true)
expect(button('生成身份参考图').disabled).toBe(true)
expect(button('保存主体身份').disabled).toBe(false)
})
it('身份批量生成区分锁定跳过和部分失败,不将 HTTP 200 当作全部成功', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (init?.method === 'POST')
return jsonResponse({
total: 3,
targetCount: 2,
generated: 1,
skipped: 1,
skippedLocked: 1,
failed: 1,
failures: [{ subjectId: 'failed-db', subjectRef: '@CH0003', error: '文本模型超时' }]
})
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
return jsonResponse(null)
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
await confirmGeneration('补齐项目身份文本')
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(post[0]).toBe('/api/projects/page-test-project/subject-identities/generate')
expect(JSON.parse(post[1]!.body as string)).toEqual({ force: false, concurrency: 3 })
expect(wrapper!.text()).toContain('部分主体生成失败')
expect(wrapper!.text()).toContain('含锁定 1')
expect(wrapper!.text()).toContain('文本模型超时')
})
it('身份候选母版切换需确认,只发送正确 Subject ID 的 anchor PUT', async () => {
let selected = false
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
if (init?.method === 'PUT') {
selected = true
return jsonResponse(identityImageFixture({ id: 'candidate' }))
}
if (String(url).endsWith('/identity/images'))
return jsonResponse([identityImageFixture({ id: 'candidate', enabled: selected, isAnchor: selected })])
return jsonResponse(identityFixture())
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
button('设为身份母版').click()
await flushPromises()
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false)
button('确认切换身份母版').click()
await flushPromises()
expect(fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')?.[0]).toBe(
'/api/subjects/subject-db-1/identity/images/candidate/anchor'
)
expect(wrapper!.text()).toContain('当前身份母版')
})
it('切换主体会取消旧查询,迟到的身份结果不覆盖当前主体', async () => {
let finish!: (value: Response) => void
const form = formFixture()
const second = {
...form,
id: 'form-2',
subjectId: 'subject-2',
images: [],
subject: { ...form.subject, id: 'subject-2', name: '陆川', ref: '@CH0002' }
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
if (path.endsWith('/subject-forms')) return jsonResponse([form, second])
if (path.endsWith('/visual-style')) return jsonResponse(styleFixture())
if (path.endsWith('/subject-db-1/identity'))
return new Promise(resolve => {
finish = resolve
})
if (path.endsWith('/identity/images')) return jsonResponse([])
return jsonResponse(
identityFixture({ id: 'identity-2', subjectId: 'subject-2', description: '陆川的稳定身份' })
)
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
await flushPromises()
expect(
fetcher.mock.calls.find(([url]) => String(url).endsWith('/subject-db-1/identity'))?.[1]?.signal?.aborted
).toBe(true)
finish(jsonResponse(identityFixture()))
await flushPromises()
expect((wrapper!.get('#identity-description').element as HTMLTextAreaElement).value).toBe('陆川的稳定身份')
})
it('生成失败不自动重发,后端正在拆解时禁止身份写操作', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
if (String(url).endsWith('/identity/images')) return jsonResponse([])
if (init?.method === 'POST') throw new Error('connection lost')
return jsonResponse(identityFixture())
})
vi.stubGlobal('fetch', fetcher)
const provided = await mountAssets('identity')
await confirmGeneration('AI 重新生成身份')
expect(fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
expect(getOperation(fixture.id).error).toContain('无法连接后端')
provided.data.value!.project = { ...fixture, status: 'generating' }
await flushPromises()
expect(button('保存主体身份').disabled).toBe(true)
expect(button('生成身份参考图').disabled).toBe(true)
})
})
describe('工作台页面交互', () => { describe('工作台页面交互', () => {
it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => { it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => {
const fetcher = vi const fetcher = vi
@@ -0,0 +1,21 @@
import { computed } from 'vue'
import { useProjectContext } from '../projects/context'
import { getOperation } from './operations'
import { workflowCheckpoints } from './selectors'
/** 资产编辑沿用项目级互斥;剧本生成或拆解期间不修改其下游资产。 */
export function useProjectMutationGuard() {
const context = useProjectContext()
const projectId = computed(() => context.project.value?.id ?? '')
const operation = computed(() => getOperation(projectId.value))
const blocked = computed(
() =>
!projectId.value ||
!!context.error.value ||
operation.value.pending ||
context.project.value?.status === 'generating' ||
workflowCheckpoints(context.checkpoints.value, 'breakdown').at(-1)?.state.workflowExecution?.status ===
'running'
)
return { projectId, operation, blocked }
}
+1 -1
View File
@@ -12,7 +12,7 @@ export class ApiError extends Error {
/** 请求选项;长工作流显式关闭超时,不自动重试任何 POST。 */ /** 请求选项;长工作流显式关闭超时,不自动重试任何 POST。 */
export interface RequestOptions { export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
body?: unknown body?: unknown
signal?: AbortSignal signal?: AbortSignal
timeoutMs?: number timeoutMs?: number
+10
View File
@@ -25,6 +25,16 @@ export const router = createRouter({
component: () => import('../features/breakdown/BreakdownPage.vue'), component: () => import('../features/breakdown/BreakdownPage.vue'),
meta: { title: '剧本拆解' } meta: { title: '剧本拆解' }
}, },
{
path: 'visual-style',
component: () => import('../features/visual-style/VisualStylePage.vue'),
meta: { title: '视觉风格' }
},
{
path: 'subject-identity',
component: () => import('../features/subject-identity/SubjectIdentityPage.vue'),
meta: { title: '主体身份' }
},
{ {
path: 'subject-images', path: 'subject-images',
component: () => import('../features/subject-images/SubjectImagesPage.vue'), component: () => import('../features/subject-images/SubjectImagesPage.vue'),
+34
View File
@@ -62,6 +62,40 @@
} }
@layer components { @layer components {
.identity-workspace {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
align-items: start;
gap: 20px;
}
.identity-subject-list {
max-height: 560px;
overflow-y: auto;
}
.identity-subject-item {
display: block;
width: 100%;
text-align: left;
padding: 12px;
border-radius: 4px;
font-size: 12px;
overflow-wrap: anywhere;
}
.identity-subject-item:hover {
background: #f6f7f3;
}
.identity-subject-item.selected {
background: #f1e8df;
color: var(--color-accent);
}
@media (max-width: 900px) {
.identity-workspace {
grid-template-columns: minmax(0, 1fr);
}
.identity-subject-list {
max-height: 220px;
}
}
.app-shell { .app-shell {
display: grid; display: grid;
grid-template-columns: 216px minmax(0, 1fr); grid-template-columns: 216px minmax(0, 1fr);