feat: 同步视觉风格与主体身份母版工作区
对齐后端 dev 059e597,新增项目风格编辑锁定、身份文本生成、身份参考图与母版切换。 整理六个工作区入口,接通人物及场景母版继承说明、形态图片来源追溯。 补充草稿保护、正式 ID 校验、费用确认及部分失败回执,67 项测试和静态检查、构建通过。 未修改后端,未调用真实模型;本环境未完成浏览器视觉验收。
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Short Drama Agent Front
|
||||
|
||||
短剧 Agent 的前端工作台。支持 `create-drama`、`breakdown` 与 `storyboard` 工作区,使用真实后端 API,不包含演示数据或浏览器端模型调用。
|
||||
短剧 Agent 的前端工作台。支持剧本创作、拆解、视觉风格、主体身份、形态图片与分镜设计,使用真实后端 API,不包含演示数据或浏览器端模型调用。
|
||||
|
||||
## 启动
|
||||
|
||||
@@ -31,6 +31,9 @@ API_PROXY_TARGET=http://localhost:3412
|
||||
| 拆解配置 | 每组集数、人物/场景/道具模块选择、后端分组预览 |
|
||||
| 拆解结果 | 主体、别名、视觉形态、Episode → Beat → Shot、主体绑定、抽取任务状态、校验问题、JSON 导出 |
|
||||
| 拆解恢复 | 失败抽取重试、缺失剧集镜头补齐、分镜引用与 Form 绑定修复 |
|
||||
| 视觉风格 | AI 生成、人工编辑、分类 Prompt、JSON 硬约束、锁定;风格参考图地址登记、启停和删除 |
|
||||
| 主体身份 | 按正式主体归并形态,单个/批量生成身份文本,人工编辑及锁定,部分失败诊断 |
|
||||
| 身份母版 | Seedream 生图、四种参考视角、指定身份参考图、母版候选与切换、实际 Prompt 和来源追溯 |
|
||||
| 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 |
|
||||
| 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 |
|
||||
| 镜头工具 | 主体参考图与缺失原因、确定性 GenerationSpec 查询与导出、单镜头/批量视频提示词 |
|
||||
@@ -67,6 +70,8 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
||||
| `src/features/breakdown` | 拆解 graph 的 API、类型、配置页面、主体与分镜组件 |
|
||||
| `src/features/storyboard` | 分镜 API、类型、生成操作、依赖判断、设计与状态、镜头资源、生成回执 |
|
||||
| `src/features/subject-images` | 正式形态图库、图片生成参数、历史图片、主图选择与批量回执 |
|
||||
| `src/features/visual-style` | 项目视觉风格 API、类型、编辑器、锁定和风格参考图管理 |
|
||||
| `src/features/subject-identity` | 稳定身份 API、正式主体目录、文本编辑、生图配置、身份图库和母版切换 |
|
||||
| `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 |
|
||||
| `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 |
|
||||
| `src/composables` | 具备取消和竞态保护的轮询 |
|
||||
@@ -78,7 +83,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
||||
## 接口基线
|
||||
|
||||
对齐后端 `qianlanse/short-drama-agent` 的 `dev`:
|
||||
`eac8dc3b1b2ce1ff2bdfeb01d7281cd838f07285`,包含本次新增的只读 `GET /projects/:projectId/subject-forms` 接口。请同步更新前后端 `dev` 后重启后端。
|
||||
`059e59709359dccd08b08cb35c6aff95c5e03f2b`(2026-08-28),包含 VisualStyle、SubjectIdentity、身份母版、人物/场景形态自动引用母版及道具稳定特征提取。本轮只修改前端,请确保后端依照其开发流程同步了新 Prisma 结构并重启服务。
|
||||
|
||||
| HTTP | 路径(以 /api 为前缀) | 用途 |
|
||||
| --- | --- | --- |
|
||||
@@ -102,11 +107,21 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
||||
| GET | `/storyboard-shots/:shotId/generation-spec` | 后端确定性编译结果,不调用模型 |
|
||||
| POST | `/storyboard-shots/:shotId/video-prompt` | 复用已有 Prompt 或调用模型生成;`force` 控制覆盖 |
|
||||
| POST | `/projects/:id/video-prompts/generate` | 项目批量 Prompt;`concurrency`、`force` |
|
||||
| GET | `/projects/:id/subject-forms` | 正式 SubjectForm ID、主体名称/引用、主图与图片元数据;本次新增只读接口 |
|
||||
| GET | `/projects/:id/subject-forms` | 正式 SubjectForm ID、主体名称/引用、主图与图片元数据;只读查询 |
|
||||
| GET | `/subject-forms/:formId/images` | 此形态的全部图片历史及实际 Prompt |
|
||||
| POST | `/subject-forms/:formId/images` | 单图生成;显式 `provider: seedream`,可选 Prompt 和成对尺寸,`setPrimary` |
|
||||
| PUT | `/subject-forms/:formId/images/:imageId/primary` | 选择已完成图片作为主参考图,不调用模型 |
|
||||
| POST | `/projects/:id/subject-images/generate` | 全项目批量生图;`provider`、`concurrency`、`force` |
|
||||
| GET / PUT | `/projects/:id/visual-style` | 查询/人工保存视觉风格,包含分类 Prompt、硬约束和锁定 |
|
||||
| POST | `/projects/:id/visual-style/generate` | AI 生成风格;已有风格重生成显式 `force: true`,锁定时禁止 |
|
||||
| POST | `/projects/:id/visual-style/images` | 登记已有风格图片地址,不上传文件 |
|
||||
| PUT | `/projects/:id/visual-style/images/:imageId/enabled` | 风格图片启停 |
|
||||
| DELETE | `/projects/:id/visual-style/images/:imageId` | 删除风格图片记录,不删除远程文件 |
|
||||
| GET / PUT | `/subjects/:subjectId/identity` | 查询/人工编辑稳定身份;使用正式 Subject ID |
|
||||
| POST | `/subjects/:subjectId/identity/generate` | AI 生成身份文本,需要项目视觉风格 |
|
||||
| POST | `/projects/:id/subject-identities/generate` | 项目批量身份文本;`force`、`concurrency`,已锁定始终跳过 |
|
||||
| GET / POST | `/subjects/:subjectId/identity/images` | 查询带 `isAnchor` 的身份图片/Seedream 生图 |
|
||||
| PUT | `/subjects/:subjectId/identity/images/:imageId/anchor` | 将成功的 primary 候选设为身份母版 |
|
||||
|
||||
API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。
|
||||
|
||||
@@ -146,6 +161,28 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
|
||||
8. 图片使用持久化后的 `/storage/...` 地址,开发代理已配置。生产环境也必须代理 `/storage/`,不能让 SPA 回退返回 index.html。无效 URL、加载失败、无图和后端生成失败有各自提示;不会自动使用可能过期的 Provider 临时地址。
|
||||
9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。
|
||||
|
||||
### 视觉风格、身份与形态的衔接
|
||||
|
||||
建议操作顺序:**剧本创作 → 剧本拆解 → 视觉风格 → 主体身份 → 形态图片 → 分镜设计**。视觉风格也可以在拆解前准备;这不是前端强制启动的整条生产流程。
|
||||
|
||||
| 层级 | 职责 | 本次可操作内容 |
|
||||
| --- | --- | --- |
|
||||
| VisualStyle | 项目整体美术与视觉语言 | 整体/人物/场景/道具 Prompt、JSON 字符串数组硬约束、锁定、风格图片记录 |
|
||||
| SubjectIdentity | 同一主体跨形态不变的特征 | 稳定身份描述、身份 Prompt、锁定、单个及批量 AI 文本生成 |
|
||||
| Identity Image | 固定主体身份,人物主要固定脸部与体型 | primary、front、three-quarter、full-body 四类生图、身份锚定来源、母版选择 |
|
||||
| SubjectForm Image | 某个具体造型或状态的图片 | 原有单图/批量生图与主图选择;人物/场景图自动引用当前身份母版 |
|
||||
|
||||
1. 风格与身份的 `isLocked` 只禁止 AI 自动覆盖文本。已锁定记录仍可人工编辑并保存;身份生图和母版切换也不受文字锁定限制。需要 AI 重生成时,先取消锁定并保存。
|
||||
2. 身份 AI 生成需要已创建 VisualStyle;身份图生成还需要已保存的非空 `generationPrompt`。不存在身份时不调用会报错的图片 GET。404 或网络失败不会被当作“尚未创建”。
|
||||
3. 后端暂无全项目主体/身份的 GET。前端从正式 `subject-forms` 目录按 `subjectId` 去重,选中主体后按需读取身份和专用图库;无形态主体暂不在目录展示。批量仍由后端处理项目全部主体,可能多于目录可见数,不受当前筛选影响。未使用 checkpoint 或 `DramaCharacter.id` 拼造 Subject ID。
|
||||
4. 身份文本批量回执显示 `total`、`targetCount`、`generated`、`skipped`、`skippedLocked`、`failed` 和逐主体失败原因。它不代表图片已生成,也不是实时任务进度。后端没有批量身份生图接口,前端不自行循环触发有费用的图片请求。
|
||||
5. 身份图片的 `isAnchor` 来自专用 GET。`enabled: true` 的正面、侧面或全身图不是母版;只有成功且有地址的 `primary` 图可经 PUT 设为母版。第一张 primary 图自动启用;已有母版后,新 primary 图作为停用候选保留,不静默替换。辅助视图不能直接选为母版,但可以作为新 primary 图的参考来源。
|
||||
6. 身份生图不传 `referenceImageId` 时,由后端自动选择当前母版;无母版时按文本生成。指定时只能选择当前身份已完成的图片;候选图即使停用也可作为显式参考。后端没有“有母版但忽略它”的参数。尺寸成对留空或提供正整数。自定义 Prompt 会替换后端编译的完整文本,建议保留默认。
|
||||
7. 目前**人物与场景形态生图**会自动注入当前身份母版,分别固定人物身份和空间骨架;道具暂不自动引用。没有身份或母版时,原形态生图流程仍可使用。已有母版但缺少 Provider 可访问远程地址时,后端会报错;本地 `/storage/` 能显示并不证明原始 Provider URL 仍可访问。前端显示后端错误,不自动重试消耗额度。
|
||||
8. 修改风格、身份文本或母版,不会自动使旧图失效或重生成。已有形态主图仍会被分镜使用;需要保持新身份时,主动生成候选图并设为形态主图,再刷新分镜参考图,按需覆盖视频提示词。图库详情展示当次 `identityAnchorImageId` 或 `referenceImageId`;旧记录没有该字段时明确显示未知,不推断它使用了当前母版。
|
||||
9. 风格参考图支持登记已有 HTTP(S)/`/storage/` 地址、分类、排序、启停和删除记录;**没有文件上传接口,也没有风格图 AI 生成接口**。当前身份图/形态图 Provider 调用不会自动消费风格参考图片,页面不暗示登记后已生效。
|
||||
10. 编辑草稿与轮询分离。保存成功才清空草稿,失败保留;主体切换会提示放弃未保存修改。离开页面不会自动保存。所有新写操作沿用项目级会话互斥,生成前需要费用与后台任务确认,无法替代后端跨浏览器锁。
|
||||
|
||||
### 后端限制
|
||||
|
||||
目前 `checkpoints` 接口返回完整历史与 state,没有分页或轻量状态接口。长篇剧本会增加轮询流量。下一步建议后端增加:
|
||||
@@ -203,5 +240,11 @@ server {
|
||||
8. 给主体形态准备参考图,读取镜头参考图和 GenerationSpec;确认 Prompt 无正文时经确认生成、有正文且不覆盖时复用。不提交实际视频任务。
|
||||
9. 打开“形态图片”,检查已有主图自动显示;单个形态新增候选图,查看历史、失败原因和实际 Prompt,再设为主图。
|
||||
10. 在小型测试项目中验证批量补齐与“已有主图也新增候选图”;回到分镜刷新参考图,确认使用新选择的主图。
|
||||
11. 进入“视觉风格”,保存或生成风格,检查分类 Prompt 和硬约束;锁定后 AI 入口禁用,人工编辑仍可保存。登记一张已有风格图,检查启停和移除确认。
|
||||
12. 进入“主体身份”,检查同一主体的多形态归并。生成/保存身份文本并锁定,再生成第一张 primary 图;生成第二张后,确认旧母版未被替换。
|
||||
13. 为同一主体选择参考来源并生成 front / three-quarter / full-body 图;确认辅助图不能直接设为母版。将第二张 primary 候选设为母版后,检查旧母版停用且保留。
|
||||
14. 对人物和场景形态分别新增图片,在图片详情检查当次母版 ID;旧主图应保持不变,手动切换后再刷新分镜参考图。道具不应显示自动继承母版已生效。
|
||||
|
||||
本轮新增测试覆盖风格 JSON/锁定/图片 DELETE、身份归并/前置条件/尺寸/显式参考图/母版切换、部分失败、旧响应、草稿保护与费用确认。模型调用均使用模拟 API。当前执行环境未能通过浏览器访问本地开发服务,**未完成浏览器视觉验收及真实模型联调**,请按上述步骤在本地验收。
|
||||
|
||||
自动测试使用模拟 API,覆盖既有流程、分镜接口与竞态,以及形态图片自动读取、正式 ID 生图、主图 PUT、费用确认、尺寸校验、候选图选择、批量部分失败和图片加载错误。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
|
||||
|
||||
+18
-2
@@ -5,6 +5,8 @@ import {
|
||||
Clapperboard,
|
||||
Camera,
|
||||
Images,
|
||||
Palette,
|
||||
Fingerprint,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Layers,
|
||||
@@ -56,20 +58,34 @@ const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
title="剧本拆解"
|
||||
><Layers :size="18" /><span class="sidebar-label">剧本拆解</span
|
||||
><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
|
||||
:to="`/projects/${projectId}/subject-images`"
|
||||
class="side-link"
|
||||
active-class="selected"
|
||||
title="形态图片"
|
||||
><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
|
||||
:to="`/projects/${projectId}/storyboard`"
|
||||
class="side-link"
|
||||
active-class="selected"
|
||||
title="分镜设计"
|
||||
><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
|
||||
>
|
||||
|
||||
@@ -34,6 +34,12 @@ const filtered = computed(() =>
|
||||
>
|
||||
</div>
|
||||
<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
|
||||
v-if="projectId"
|
||||
:to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ArrowLeft, RefreshCw, FileText, Layers, Camera, Images, LoaderCircle } from '@lucide/vue'
|
||||
import { ArrowLeft, RefreshCw, FileText, Layers, Camera, Images, Palette, Fingerprint, LoaderCircle } from '@lucide/vue'
|
||||
import { StatusBadge } from '../../components/ui'
|
||||
import { projectContextKey, useProjectData } from './context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
@@ -39,18 +39,12 @@ const operation = computed(() => getOperation(id.value))
|
||||
</div>
|
||||
</div>
|
||||
<nav class="workflow-nav" aria-label="项目工作流">
|
||||
<RouterLink :to="`/projects/${id}/create-drama`"
|
||||
><FileText :size="17" />剧本创作<span class="nav-code">create-drama</span></RouterLink
|
||||
>
|
||||
<RouterLink :to="`/projects/${id}/breakdown`"
|
||||
><Layers :size="17" />剧本拆解<span class="nav-code">breakdown</span></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
|
||||
>
|
||||
<RouterLink :to="`/projects/${id}/create-drama`"><FileText :size="17" />剧本创作</RouterLink>
|
||||
<RouterLink :to="`/projects/${id}/breakdown`"><Layers :size="17" />剧本拆解</RouterLink>
|
||||
<RouterLink :to="`/projects/${id}/visual-style`"><Palette :size="17" />视觉风格</RouterLink>
|
||||
<RouterLink :to="`/projects/${id}/subject-identity`"><Fingerprint :size="17" />主体身份</RouterLink>
|
||||
<RouterLink :to="`/projects/${id}/subject-images`"><Images :size="17" />形态图片</RouterLink>
|
||||
<RouterLink :to="`/projects/${id}/storyboard`"><Camera :size="17" />分镜设计</RouterLink>
|
||||
</nav>
|
||||
<p v-if="context.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ 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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
/** 主体身份模块公共入口。 */
|
||||
export { subjectIdentityApi } from './api'
|
||||
export { currentAnchor, readImageProvenance } from './model'
|
||||
export type { SubjectIdentity, IdentityImage, GenerateIdentityImageInput } from './types'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<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>
|
||||
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button"
|
||||
>去检查分镜参考图<ArrowRight :size="14"
|
||||
/></RouterLink>
|
||||
</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="flex flex-wrap items-center justify-between gap-4">
|
||||
<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">
|
||||
{{ form.description }}
|
||||
</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">
|
||||
<StatusBadge v-if="hasRunningImages(form.images)" status="generating" /><span
|
||||
v-if="primaryImage(form.images)"
|
||||
|
||||
@@ -52,6 +52,14 @@ function submit() {
|
||||
>
|
||||
<form class="mt-6 space-y-5" @submit.prevent="submit">
|
||||
<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"
|
||||
><span class="field-label">自定义提示词(可选,仅用于本次)</span
|
||||
><textarea
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
|
||||
import { usePolling } from '../../../composables/usePolling'
|
||||
import { referenceImageUrl } from '../../../lib/assets'
|
||||
import { formatDate } from '../../../lib/format'
|
||||
import { readImageProvenance } from '../../subject-identity'
|
||||
import { getOperation, runOperation } from '../../workflows/operations'
|
||||
import { subjectImagesApi } from '../api'
|
||||
import { hasRunningImages, primaryImage } from '../model'
|
||||
@@ -144,6 +145,13 @@ async function choosePrimary() {
|
||||
</p>
|
||||
</details>
|
||||
<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="图片历史">
|
||||
<button
|
||||
v-for="image in images"
|
||||
|
||||
@@ -23,6 +23,15 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
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('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => {
|
||||
const form = formFixture()
|
||||
form.images.unshift(
|
||||
|
||||
@@ -20,7 +20,7 @@ export function coverImage(form: SubjectFormAsset): SubjectImage | undefined {
|
||||
}
|
||||
|
||||
/** 查询发现后台仍在运行时也阻止重复提交,不只依赖本地 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')
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface SubjectImage {
|
||||
/** 详情查询才返回实际使用的 Prompt;列表不包含长文本。 */
|
||||
prompt?: string
|
||||
negativePrompt?: string | null
|
||||
/** 详情保留生成追溯信息,可读取当时引用的身份母版 ID。 */
|
||||
rawJson?: string | null
|
||||
}
|
||||
|
||||
/** 正式 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
import { AppDialog } from '../../components/ui'
|
||||
|
||||
@@ -15,6 +15,11 @@ const emit = defineEmits<{ confirm: [] }>()
|
||||
const open = ref(false)
|
||||
const acknowledged = ref(false)
|
||||
|
||||
/** 取消后重新打开,或确认目标改变,都必须重新确认任务与费用。 */
|
||||
watch([open, () => props.label, () => props.description], () => {
|
||||
acknowledged.value = false
|
||||
})
|
||||
|
||||
/** 发出事件后关闭弹窗,操作状态由项目级锁负责。 */
|
||||
function confirm() {
|
||||
if (props.disabled || (props.acknowledgement && !acknowledged.value)) return
|
||||
|
||||
@@ -22,6 +22,30 @@ import { getOperation } from './operations'
|
||||
import SubjectImagesPage from '../subject-images/SubjectImagesPage.vue'
|
||||
import { formFixture, imageFixture } from '../subject-images/testing/fixtures'
|
||||
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 响应仅用于测试,不会打包进生产页面。 */
|
||||
const fixture: ProjectDetail = {
|
||||
@@ -99,6 +123,7 @@ async function mountSubjectImages() {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
getIdentitySession(fixture.id).receipt = null
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
@@ -108,6 +133,362 @@ afterEach(() => {
|
||||
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('工作台页面交互', () => {
|
||||
it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => {
|
||||
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
@@ -12,7 +12,7 @@ export class ApiError extends Error {
|
||||
|
||||
/** 请求选项;长工作流显式关闭超时,不自动重试任何 POST。 */
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT'
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
body?: unknown
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
|
||||
@@ -25,6 +25,16 @@ export const router = createRouter({
|
||||
component: () => import('../features/breakdown/BreakdownPage.vue'),
|
||||
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',
|
||||
component: () => import('../features/subject-images/SubjectImagesPage.vue'),
|
||||
|
||||
@@ -62,6 +62,40 @@
|
||||
}
|
||||
|
||||
@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 {
|
||||
display: grid;
|
||||
grid-template-columns: 216px minmax(0, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user