feat: 接入形态图库与 Seedream 生图操作

展示已有主图、候选图和失败记录,支持单图及批量生成、主图选择。
补充正式 ID 校验、费用确认、图片加载占位和回归测试。
依赖后端 eac8dc3 的项目形态图库只读接口。
This commit is contained in:
GouJ
2026-08-28 15:16:25 +08:00
parent bae69c3c62
commit 47443c0efe
26 changed files with 1338 additions and 25 deletions
+24 -3
View File
@@ -34,8 +34,9 @@ API_PROXY_TARGET=http://localhost:3412
| 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 | | 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 |
| 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 | | 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 |
| 镜头工具 | 主体参考图与缺失原因、确定性 GenerationSpec 查询与导出、单镜头/批量视频提示词 | | 镜头工具 | 主体参考图与缺失原因、确定性 GenerationSpec 查询与导出、单镜头/批量视频提示词 |
| 形态图片 | 正式形态图库、主图/候选图预览、图片历史与失败记录、Seedream 单图/批量生图、切换主参考图 |
尚未实现:手工编辑剧本或分镜设计(后端暂无对应写接口)、主体图生成、实际视频生成、用户登录。模型输出按纯文本显示,避免执行不可信 HTML。参考图只接受 HTTP(S) 或后端 `/storage/` 路径。 尚未实现:手工编辑剧本或分镜设计(后端暂无对应写接口)、实际视频生成、用户登录。模型输出按纯文本显示,避免执行不可信 HTML。参考图只接受 HTTP(S) 或后端 `/storage/` 路径。
## 技术与规范 ## 技术与规范
@@ -65,6 +66,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| `src/features/create-drama` | 剧本创作 graph 页面 | | `src/features/create-drama` | 剧本创作 graph 页面 |
| `src/features/breakdown` | 拆解 graph 的 API、类型、配置页面、主体与分镜组件 | | `src/features/breakdown` | 拆解 graph 的 API、类型、配置页面、主体与分镜组件 |
| `src/features/storyboard` | 分镜 API、类型、生成操作、依赖判断、设计与状态、镜头资源、生成回执 | | `src/features/storyboard` | 分镜 API、类型、生成操作、依赖判断、设计与状态、镜头资源、生成回执 |
| `src/features/subject-images` | 正式形态图库、图片生成参数、历史图片、主图选择与批量回执 |
| `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 | | `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 |
| `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 | | `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 |
| `src/composables` | 具备取消和竞态保护的轮询 | | `src/composables` | 具备取消和竞态保护的轮询 |
@@ -76,7 +78,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
## 接口基线 ## 接口基线
对齐后端 `qianlanse/short-drama-agent``dev` 对齐后端 `qianlanse/short-drama-agent``dev`
`6ce284b9d93e8c9d0b7ca57d265148e354642095` `eac8dc3b1b2ce1ff2bdfeb01d7281cd838f07285`,包含本次新增的只读 `GET /projects/:projectId/subject-forms` 接口。请同步更新前后端 `dev` 后重启后端
| HTTP | 路径(以 /api 为前缀) | 用途 | | HTTP | 路径(以 /api 为前缀) | 用途 |
| --- | --- | --- | | --- | --- | --- |
@@ -100,6 +102,11 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| GET | `/storyboard-shots/:shotId/generation-spec` | 后端确定性编译结果,不调用模型 | | GET | `/storyboard-shots/:shotId/generation-spec` | 后端确定性编译结果,不调用模型 |
| POST | `/storyboard-shots/:shotId/video-prompt` | 复用已有 Prompt 或调用模型生成;`force` 控制覆盖 | | POST | `/storyboard-shots/:shotId/video-prompt` | 复用已有 Prompt 或调用模型生成;`force` 控制覆盖 |
| POST | `/projects/:id/video-prompts/generate` | 项目批量 Prompt`concurrency``force` | | POST | `/projects/:id/video-prompts/generate` | 项目批量 Prompt`concurrency``force` |
| GET | `/projects/:id/subject-forms` | 正式 SubjectForm ID、主体名称/引用、主图与图片元数据;本次新增只读接口 |
| GET | `/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` |
API 层也提供 `state``breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。 API 层也提供 `state``breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。
@@ -127,6 +134,18 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
9. 参考图和 GenerationSpec 按需 GET,切换镜头或设计变化时取消旧查询。GenerationSpec 需要有效的 Direction、VisualState 和形态关联,不生成图片或视频。 9. 参考图和 GenerationSpec 按需 GET,切换镜头或设计变化时取消旧查询。GenerationSpec 需要有效的 Direction、VisualState 和形态关联,不生成图片或视频。
10. 后端当前的 Video Prompt 仍使用镜头描述和参考图,**尚未消费 GenerationSpec**。没有独立 GET Prompt 接口,“读取/生成提示词”仍是 POST;不存在 Prompt 时会调用模型,因此必须确认。`force: false` 复用已有正文;批量响应只有计数和失败列表,不能凭计数构造 Prompt 正文。`generated` 是后端处理成功数,可能包含已存在但状态不为 `prompt_ready` 的复用项。 10. 后端当前的 Video Prompt 仍使用镜头描述和参考图,**尚未消费 GenerationSpec**。没有独立 GET Prompt 接口,“读取/生成提示词”仍是 POST;不存在 Prompt 时会调用模型,因此必须确认。`force: false` 复用已有正文;批量响应只有计数和失败列表,不能凭计数构造 Prompt 正文。`generated` 是后端处理成功数,可能包含已存在但状态不为 `prompt_ready` 的复用项。
### 形态图片约定
1. 入口为 `/projects/:projectId/subject-images`;项目导航、拆解主体列表和分镜参考图缺失提示均可进入。图库自动读取后端已生成图片,不需要重新生图才能显示。
2. 生图使用正式数据库 `SubjectForm.id`。Checkpoint 的领域 `formId` 不是数据库 ID,不能直接调用图片接口。新增查询从项目关联读取正式形态,返回轻量图片元数据,不包含图片的 `rawJson` 或长 Prompt;打开历史弹窗后再读取详细记录。
3. 单图明确发送 `provider: 'seedream'`,避免后端单图入口默认使用 mock。模型和凭证仅配置在后端,前端不保存 API Key。默认使用后端的 generationPrompt,其次 appearancePrompt;可为本次输入自定义 Prompt,详情显示后端最终实际使用的文本。
4. 单图宽高同时留空使用后端默认 2K,自定义时必须同时提供正整数,具体尺寸限制仍由模型校验。当前 Seedream Provider 没有使用 negativePrompt,所以页面不提供无效的负向提示词输入。
5. 单图生成新增历史记录,不删除旧图。无主图时默认勾选“生成成功后设为主图”;已有主图时默认不替换。候选图可以在历史弹窗二次确认后通过 PUT 设为主图,只允许已完成图片。
6. 批量默认跳过已有主图的形态;勾选“已有主图也新增候选图”发送 `force: true`。后端批量 force 只生成新图,**不会替换已有主图**。批量作用于整个项目,不受类型/文字/缺图筛选影响,也不接收单图的尺寸与自定义 Prompt。
7. 图库每 6 秒读取一次真实记录,弹窗只在打开时查询该形态图片。数据库存在 pending / generating 时暂停再次生图;网络错误不自动重试有费用的请求。HTTP 200 中的批量部分失败单独展示。
8. 图片使用持久化后的 `/storage/...` 地址,开发代理已配置。生产环境也必须代理 `/storage/`,不能让 SPA 回退返回 index.html。无效 URL、加载失败、无图和后端生成失败有各自提示;不会自动使用可能过期的 Provider 临时地址。
9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。
### 后端限制 ### 后端限制
目前 `checkpoints` 接口返回完整历史与 state,没有分页或轻量状态接口。长篇剧本会增加轮询流量。下一步建议后端增加: 目前 `checkpoints` 接口返回完整历史与 state,没有分页或轻量状态接口。长篇剧本会增加轮询流量。下一步建议后端增加:
@@ -182,5 +201,7 @@ server {
6. 进入分镜设计,确认正式镜头 ID 与 Beat 对应;先生成单集 Direction,再生成 VisualState,检查覆盖率和主体状态。 6. 进入分镜设计,确认正式镜头 ID 与 Beat 对应;先生成单集 Direction,再生成 VisualState,检查覆盖率和主体状态。
7. 用小型测试项目验证批量补齐、跳过完整集、失败诊断和明确覆盖;确认关闭覆盖后不会重生成完整设计。 7. 用小型测试项目验证批量补齐、跳过完整集、失败诊断和明确覆盖;确认关闭覆盖后不会重生成完整设计。
8. 给主体形态准备参考图,读取镜头参考图和 GenerationSpec;确认 Prompt 无正文时经确认生成、有正文且不覆盖时复用。不提交实际视频任务。 8. 给主体形态准备参考图,读取镜头参考图和 GenerationSpec;确认 Prompt 无正文时经确认生成、有正文且不覆盖时复用。不提交实际视频任务。
9. 打开“形态图片”,检查已有主图自动显示;单个形态新增候选图,查看历史、失败原因和实际 Prompt,再设为主图。
10. 在小型测试项目中验证批量补齐与“已有主图也新增候选图”;回到分镜刷新参考图,确认使用新选择的主图。
自动测试使用模拟 API,覆盖两条既有流程分镜 API 参数、依赖判断、正式 ID 合并、校验与部分失败、切换剧集的竞态、查询错误恢复、Prompt 确认和纯文本展示。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。 自动测试使用模拟 API,覆盖既有流程分镜接口与竞态,以及形态图片自动读取、正式 ID 生图、主图 PUT、费用确认、尺寸校验、候选图选择、批量部分失败和图片加载错误。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
+9 -1
View File
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { import {
Clapperboard, Clapperboard,
Camera, Camera,
Images,
FolderOpen, FolderOpen,
FileText, FileText,
Layers, Layers,
@@ -55,13 +56,20 @@ const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
title="剧本拆解" title="剧本拆解"
><Layers :size="18" /><span class="sidebar-label">剧本拆解</span ><Layers :size="18" /><span class="sidebar-label">剧本拆解</span
><span class="sidebar-label ml-auto text-[10px] text-stone-500">02</span></RouterLink ><span class="sidebar-label ml-auto text-[10px] text-stone-500">02</span></RouterLink
><RouterLink
:to="`/projects/${projectId}/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
><RouterLink ><RouterLink
:to="`/projects/${projectId}/storyboard`" :to="`/projects/${projectId}/storyboard`"
class="side-link" class="side-link"
active-class="selected" active-class="selected"
title="分镜设计" title="分镜设计"
><Camera :size="18" /><span class="sidebar-label">分镜设计</span ><Camera :size="18" /><span class="sidebar-label">分镜设计</span
><span class="sidebar-label ml-auto text-[10px] text-stone-500">03</span></RouterLink ><span class="sidebar-label ml-auto text-[10px] text-stone-500">04</span></RouterLink
> >
</nav></template </nav></template
> >
+2 -1
View File
@@ -11,7 +11,7 @@ import {
import { X } from '@lucide/vue' import { X } from '@lucide/vue'
/** 统一弹窗容器,Reka UI 负责焦点锁定、Escape 和无障碍语义。 */ /** 统一弹窗容器,Reka UI 负责焦点锁定、Escape 和无障碍语义。 */
defineProps<{ title: string; description: string; busy?: boolean }>() defineProps<{ title: string; description: string; busy?: boolean; wide?: boolean }>()
const open = defineModel<boolean>('open', { default: false }) const open = defineModel<boolean>('open', { default: false })
</script> </script>
@@ -22,6 +22,7 @@ const open = defineModel<boolean>('open', { default: false })
<DialogOverlay class="dialog-overlay" /> <DialogOverlay class="dialog-overlay" />
<DialogContent <DialogContent
class="dialog-content" class="dialog-content"
:class="{ 'dialog-wide': wide }"
@interact-outside="busy && $event.preventDefault()" @interact-outside="busy && $event.preventDefault()"
@escape-key-down="busy && $event.preventDefault()" @escape-key-down="busy && $event.preventDefault()"
> >
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ImageOff } from '@lucide/vue'
import { referenceImageUrl } from '../../lib/assets'
/** 安全图片展示,资源失效或地址不合法时提供明确占位,不自动改用临时 Provider URL。 */
const props = withDefaults(
defineProps<{ src?: string | null; alt: string; emptyText?: string; retryable?: boolean }>(),
{ retryable: true }
)
const failed = ref(false)
const url = computed(() => (props.src ? referenceImageUrl(props.src) : null))
watch(
() => props.src,
() => {
failed.value = false
}
)
</script>
<template>
<div class="asset-image">
<img
v-if="url && !failed"
:src="url"
:alt="alt"
loading="lazy"
referrerpolicy="no-referrer"
@error="failed = true"
/>
<span v-else class="asset-image-empty"
><ImageOff :size="22" :stroke-width="1.3" /><span>{{
src ? '图片无法加载' : emptyText || '尚未生成图片'
}}</span
><button v-if="failed && retryable" type="button" class="text-button" @click.stop="failed = false">
重试加载
</button></span
>
</div>
</template>
+1
View File
@@ -2,3 +2,4 @@
export { default as AppDialog } from './AppDialog.vue' export { default as AppDialog } from './AppDialog.vue'
export { default as EmptyState } from './EmptyState.vue' export { default as EmptyState } from './EmptyState.vue'
export { default as StatusBadge } from './StatusBadge.vue' export { default as StatusBadge } from './StatusBadge.vue'
export { default as AssetImage } from './AssetImage.vue'
+1
View File
@@ -341,6 +341,7 @@ function exportResult() {
<main class="min-w-0"> <main class="min-w-0">
<TabsContent v-for="option in moduleOptions" :key="option.value" :value="option.value" <TabsContent v-for="option in moduleOptions" :key="option.value" :value="option.value"
><SubjectList ><SubjectList
:project-id="project?.id"
:subjects="subjects.filter(item => item.module === option.value)" :subjects="subjects.filter(item => item.module === option.value)"
:forms="forms" /></TabsContent :forms="forms" /></TabsContent
><TabsContent value="storyboard" ><TabsContent value="storyboard"
@@ -5,7 +5,7 @@ import { EmptyState } from '../../../components/ui'
import type { SubjectCandidate, SubjectForm } from '../types' import type { SubjectCandidate, SubjectForm } from '../types'
/** 主体列表按稳定 ref 展示;形态以 profileId 关联。 */ /** 主体列表按稳定 ref 展示;形态以 profileId 关联。 */
const props = defineProps<{ subjects: SubjectCandidate[]; forms: SubjectForm[] }>() const props = defineProps<{ subjects: SubjectCandidate[]; forms: SubjectForm[]; projectId?: string }>()
const search = ref('') const search = ref('')
const filtered = computed(() => const filtered = computed(() =>
props.subjects.filter(item => props.subjects.filter(item =>
@@ -34,6 +34,12 @@ const filtered = computed(() =>
> >
</div> </div>
<p class="text-sm leading-7">{{ subject.description }}</p> <p class="text-sm leading-7">{{ subject.description }}</p>
<RouterLink
v-if="projectId"
:to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }"
class="text-button mt-3"
>查看形态图片生图</RouterLink
>
<details class="subject-details mt-4"> <details class="subject-details mt-4">
<summary> <summary>
<ChevronDown :size="14" />外观描述与形态 <ChevronDown :size="14" />外观描述与形态
+4 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, provide } from 'vue' import { computed, provide } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ArrowLeft, RefreshCw, FileText, Layers, Camera, LoaderCircle } from '@lucide/vue' import { ArrowLeft, RefreshCw, FileText, Layers, Camera, Images, LoaderCircle } from '@lucide/vue'
import { StatusBadge } from '../../components/ui' import { StatusBadge } from '../../components/ui'
import { projectContextKey, useProjectData } from './context' import { projectContextKey, useProjectData } from './context'
import { getOperation } from '../workflows/operations' import { getOperation } from '../workflows/operations'
@@ -45,6 +45,9 @@ const operation = computed(() => getOperation(id.value))
<RouterLink :to="`/projects/${id}/breakdown`" <RouterLink :to="`/projects/${id}/breakdown`"
><Layers :size="17" />剧本拆解<span class="nav-code">breakdown</span></RouterLink ><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`" <RouterLink :to="`/projects/${id}/storyboard`"
><Camera :size="17" />分镜设计<span class="nav-code">storyboard</span></RouterLink ><Camera :size="17" />分镜设计<span class="nav-code">storyboard</span></RouterLink
> >
@@ -155,7 +155,10 @@ function exportResult(kind: 'spec' | 'prompt') {
后端未返回参考图或缺失记录 后端未返回参考图或缺失记录
</p> </p>
<p v-if="references.missing.length" class="mt-3 text-xs text-muted"> <p v-if="references.missing.length" class="mt-3 text-xs text-muted">
先在后端补齐对应形态的图片再刷新本页暂不包含主体图生成 在形态图库补齐图片或设置主图再刷新本镜头参考图
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button ml-2"
>前往形态图片</RouterLink
>
</p> </p>
</template> </template>
</section> </section>
+2 -16
View File
@@ -110,19 +110,5 @@ export function directionLabel(value: string, field?: string): string {
return labels[value] ?? value return labels[value] ?? value
} }
/** 只开放 http(s) 与同源 /storage 路径,拦截模型/数据中的 javascript 或协议相对 URL。 */ /** 保留已有导出路径;图库与分镜共用同一套图片地址校验。 */
export function referenceImageUrl(value: string): string | null { export { referenceImageUrl } from '../../lib/assets'
const raw = value.trim()
try {
if (raw.startsWith('/storage/') && !raw.includes('\\')) {
const api = import.meta.env.VITE_API_BASE_URL || '/api'
const origin = /^https?:\/\//i.test(api) ? new URL(api).origin : window.location.origin
const url = new URL(raw, origin)
return url.pathname.startsWith('/storage/') ? url.href : null
}
const url = new URL(raw)
return ['http:', 'https:'].includes(url.protocol) ? url.href : null
} catch {
return null
}
}
@@ -0,0 +1,260 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Search, RefreshCw, Download, ImagePlus, ArrowRight } from '@lucide/vue'
import { AssetImage, EmptyState, StatusBadge } from '../../components/ui'
import { downloadText } from '../../lib/format'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { useSubjectImages } from './useSubjectImages'
import { coverImage, hasRunningImages, primaryImage } from './model'
import type { GenerateFormImageInput, SubjectFormAsset } from './types'
import GenerateImageDialog from './components/GenerateImageDialog.vue'
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
/** 形态图库以正式数据库为准,自动显示已在后端生成的图片。 */
const route = useRoute()
const {
id,
forms,
query,
operation,
session,
concurrency,
force,
blocked,
running,
batchValid,
generate,
generateProject
} = useSubjectImages()
const search = ref('')
const module = ref('all')
const onlyMissing = ref(false)
const generateOpen = ref(false)
const galleryOpen = ref(false)
const generateId = ref('')
const galleryId = ref('')
const generateForm = computed(() => forms.value.find(form => form.id === generateId.value) ?? null)
const galleryForm = computed(() => forms.value.find(form => form.id === galleryId.value) ?? null)
const completeCount = computed(() => forms.value.filter(form => primaryImage(form.images)).length)
const filtered = computed(() =>
forms.value.filter(
form =>
(module.value === 'all' || form.subject.module === module.value) &&
(!onlyMissing.value || !primaryImage(form.images)) &&
`${form.subject.name} ${form.subject.ref} ${form.name}`
.toLowerCase()
.includes(search.value.trim().toLowerCase())
)
)
watch(
() => route.query.subjectRef,
value => {
search.value = typeof value === 'string' ? value : ''
},
{ immediate: true }
)
/** 打开指定正式形态的生图表单。 */
function openGenerate(form: SubjectFormAsset) {
generateId.value = form.id
generateOpen.value = true
}
/** 查看形态全部候选图和失败记录。 */
function openGallery(form: SubjectFormAsset) {
galleryId.value = form.id
galleryOpen.value = true
}
/** 本次提交的 ID 和配置由弹窗固定,关闭弹窗不取消后台任务。 */
function submitGenerate(formId: string, input: GenerateFormImageInput) {
void generate(formId, input)
}
/** 导出真实批量回执供排障,不构造成功图片地址。 */
function exportReceipt() {
downloadText('subject-images-receipt.json', JSON.stringify(session.value.receipt, null, 2), 'application/json')
}
/** 搜索为空时可一次恢复完整图库。 */
function resetFilters() {
search.value = ''
module.value = 'all'
onlyMissing.value = false
}
</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/${id}/storyboard`" class="text-button"
>去检查分镜参考图<ArrowRight :size="14"
/></RouterLink>
</div>
<div class="panel mt-5 p-5">
<div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-sm">
<strong>{{ completeCount }}</strong> / {{ forms.length }} 个形态已有主图<span
class="ml-3 text-xs text-muted"
>Seedream · 后端配置</span
>
</p>
<button class="text-button" :disabled="query.loading.value" @click="query.refresh">
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库
</button>
</div>
<p class="mt-2 text-[11px] leading-6 text-muted">
6 秒更新图片记录主图供分镜引用候选图和历史失败记录均保留覆盖率不是当前批次进度
</p>
<details class="mt-4 border-t border-line pt-4">
<summary class="cursor-pointer text-xs font-medium">项目批量生图</summary>
<div class="mt-4 flex flex-wrap items-end gap-4">
<label class="w-28"
><span class="field-label">批量并发</span
><input
id="image-concurrency"
v-model.number="concurrency"
type="number"
min="1"
step="1"
class="input"
:disabled="operation.pending" /></label
><label class="flex items-center gap-2 pb-2 text-xs"
><input
v-model="force"
type="checkbox"
class="accent-accent"
:disabled="operation.pending"
/>已有主图也新增候选图</label
><ConfirmAction
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
:disabled="blocked || !batchValid || !forms.length"
acknowledgement
:description="
force
? '对项目全部形态调用 Seedream 新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
: '仅对没有主图的形态调用 Seedream 生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
"
@confirm="generateProject"
/>
</div>
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数</p>
<p class="mt-3 text-xs leading-6 text-muted">
作用于整个项目不受下方筛选影响批量使用后端默认尺寸与形态提示词不会启动视频生成
</p>
</details>
</div>
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据</span>
</p>
<p v-if="running" class="alert mt-4" role="status">
数据库中仍有排队或生成中的图片已暂停新的生图操作长时间无变化时请先核对后台任务刷新或关闭页面不会取消任务
</p>
<section v-if="session.receipt" class="panel mt-4 p-5" aria-label="生图批量回执">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
</div>
<p class="mt-3 text-xs text-muted">
{{ session.receipt.result.total }} 个形态 · 本次目标 {{ session.receipt.result.targetCount }} · 生成
{{ session.receipt.result.generated }} · 跳过 {{ session.receipt.result.skipped }} · 失败
{{ session.receipt.result.failed }}
</p>
<p v-if="session.receipt.result.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.result.failures" :key="failure.subjectFormId">
<code>{{ failure.subjectFormId }}</code
>{{ failure.error }}
</li>
</ul>
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话图片以刷新后的数据库记录为准</p>
</section>
<div class="my-6 flex flex-wrap items-center gap-4">
<label class="search-field"
><Search :size="14" /><input
v-model="search"
placeholder="搜索主体、引用或形态"
aria-label="搜索形态图片" /></label
><select v-model="module" class="input w-auto" aria-label="筛选主体类型">
<option value="all">全部类型</option>
<option value="character">人物</option>
<option value="scene">场景</option>
<option value="prop">道具</option></select
><label class="flex items-center gap-2 text-xs"
><input v-model="onlyMissing" type="checkbox" class="accent-accent" />仅看缺少主图</label
><span class="ml-auto text-xs text-muted">{{ filtered.length }} 个形态</span>
</div>
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
正在读取形态和已有图片
</p>
<div v-else-if="filtered.length" class="form-image-grid">
<article v-for="form in filtered" :key="form.id" class="panel form-image-card">
<AssetImage
:src="coverImage(form)?.imageUrl"
:alt="`${form.subject.name} · ${form.name}`"
:empty-text="hasRunningImages(form.images) ? '后台正在生成图片' : '尚未生成图片'"
/>
<div class="p-4">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">{{ form.subject.name }}</h3>
<code class="subject-ref">{{ form.subject.ref }}</code>
</div>
<p class="mt-2 text-xs">
{{ form.name }}<span v-if="form.isDefault" class="tag ml-2">默认形态</span>
</p>
<p v-if="form.description" class="mt-3 line-clamp-2 text-xs leading-6 text-muted">
{{ form.description }}
</p>
<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)"
class="tag"
>主参考图</span
><span v-else-if="coverImage(form)" class="tag">候选图 · 尚未设主图</span
><span v-else-if="form.images[0]?.status === 'failed'" class="text-xs text-danger"
>最近一次生成失败</span
><span class="text-[10px] text-muted">{{ form.images.length }} 条记录</span>
</div>
<p v-if="form.images[0]?.status === 'failed'" class="mt-2 line-clamp-2 text-xs text-danger">
{{ form.images[0].error }}
</p>
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
<button class="text-button" @click="openGallery(form)">查看图片与记录</button
><button class="button button-secondary" :disabled="blocked" @click="openGenerate(form)">
<ImagePlus :size="14" />{{ coverImage(form) ? '再生成一张' : '生成图片' }}
</button>
</div>
</div>
</article>
</div>
<EmptyState
v-else-if="query.data.value"
:title="forms.length ? '没有匹配的形态' : '还没有正式形态'"
:description="
forms.length
? '调整筛选条件后再查看。批量生图不受筛选影响。'
: '先完成剧本拆解和主体形态持久化,再来生成参考图。'
"
><button v-if="forms.length" class="button button-secondary" @click="resetFilters">清除筛选</button
><RouterLink v-else :to="`/projects/${id}/breakdown`" class="button button-secondary"
>前往剧本拆解</RouterLink
></EmptyState
>
<GenerateImageDialog
v-model:open="generateOpen"
:form="generateForm"
:disabled="blocked || !generateForm"
@generate="submitGenerate"
/>
<ImageGalleryDialog
v-model:open="galleryOpen"
:project-id="id"
:form="galleryForm"
:disabled="blocked || !galleryForm"
@changed="query.refresh"
/>
</section>
</template>
+30
View File
@@ -0,0 +1,30 @@
import { request } from '../../lib/http'
import type {
GenerateFormImageInput,
GenerateProjectImagesInput,
ImageBatchResult,
SubjectFormAsset,
SubjectImage
} from './types'
/** 对正式 SubjectForm 数据库 ID 编码,不接受按名称拼出的路径。 */
function formPath(id: string) {
return `/subject-forms/${encodeURIComponent(id)}/images`
}
/** 形态图库 API:读取与生成分离,生成不自动重试,不设置客户端短超时。 */
export const subjectImagesApi = {
listForms: (projectId: string, signal?: AbortSignal) =>
request<SubjectFormAsset[]>(`/projects/${encodeURIComponent(projectId)}/subject-forms`, { signal }),
listImages: (formId: string, signal?: AbortSignal) => request<SubjectImage[]>(formPath(formId), { signal }),
generate: (formId: string, input: GenerateFormImageInput) =>
request<SubjectImage | null>(formPath(formId), { method: 'POST', body: input, timeoutMs: 0 }),
generateProject: (projectId: string, input: GenerateProjectImagesInput) =>
request<ImageBatchResult>(`/projects/${encodeURIComponent(projectId)}/subject-images/generate`, {
method: 'POST',
body: input,
timeoutMs: 0
}),
setPrimary: (formId: string, imageId: string) =>
request<SubjectImage | null>(`${formPath(formId)}/${encodeURIComponent(imageId)}/primary`, { method: 'PUT' })
}
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
import { primaryImage, validImageSize } from '../model'
import type { GenerateFormImageInput, SubjectFormAsset } from '../types'
/** 单形态生成确认:尺寸与自定义 Prompt 只用于本次请求。 */
const props = defineProps<{ form: SubjectFormAsset | null; disabled: boolean }>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ generate: [formId: string, input: GenerateFormImageInput] }>()
const prompt = ref('')
const width = ref<number | ''>('')
const height = ref<number | ''>('')
const setPrimary = ref(false)
const acknowledged = ref(false)
const dimensionsValid = computed(() => validImageSize(width.value, height.value))
const hasPrompt = computed(
() => !!(prompt.value.trim() || (props.form?.generationPrompt ?? props.form?.appearancePrompt ?? '').trim())
)
/** 每次打开重新建立表单,避免把另一形态的 Prompt 或尺寸带入请求。 */
watch(
() => [open.value, props.form?.id],
() => {
prompt.value = ''
width.value = ''
height.value = ''
acknowledged.value = false
setPrimary.value = props.form ? !primaryImage(props.form.images) : false
},
{ immediate: true }
)
/** 完成前置确认后只发出一次明确的生图请求,不触发整条生产流程。 */
function submit() {
if (!props.form || props.disabled || !dimensionsValid.value || !hasPrompt.value || !acknowledged.value) return
emit('generate', props.form.id, {
provider: 'seedream',
setPrimary: setPrimary.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="`${form?.subject.name || ''} · ${form?.name || ''}`"
>
<form class="mt-6 space-y-5" @submit.prevent="submit">
<p class="alert text-xs">使用后端配置的 Seedream 模型可能产生费用每次新增一张图片不删除历史结果</p>
<label class="block"
><span class="field-label">自定义提示词可选仅用于本次</span
><textarea
id="image-prompt"
v-model="prompt"
class="input min-h-28"
placeholder="留空使用已保存的 generationPrompt,其次使用 appearancePrompt"
/>
</label>
<details v-if="form?.generationPrompt || form?.appearancePrompt" class="text-xs">
<summary class="cursor-pointer text-muted">查看后端默认提示词</summary>
<p class="mt-3 whitespace-pre-wrap leading-6">{{ form.generationPrompt ?? form.appearancePrompt }}</p>
</details>
<div class="grid grid-cols-2 gap-4">
<label
><span class="field-label">宽度px</span
><input
id="image-width"
v-model.number="width"
class="input"
type="number"
min="1"
step="1"
placeholder="后端默认" /></label
><label
><span class="field-label">高度px</span
><input
id="image-height"
v-model.number="height"
class="input"
type="number"
min="1"
step="1"
placeholder="后端默认"
/></label>
</div>
<p class="text-xs text-muted">宽高同时留空时使用后端默认 2K自定义尺寸须满足模型限制</p>
<p v-if="!dimensionsValid" class="text-xs text-danger" role="alert">宽高需要同时填写正整数或同时留空</p>
<p v-if="!hasPrompt" class="text-xs text-danger" role="alert">当前形态没有可用提示词请填写本次提示词</p>
<label class="flex gap-2 text-sm"
><input v-model="setPrimary" type="checkbox" class="accent-accent" />生成成功后设为此形态主参考图</label
>
<p v-if="setPrimary && form && primaryImage(form.images)" class="text-xs text-muted">
会替换当前主图标记旧图片保留已有视频提示词不会自动更新
</p>
<label class="flex items-start gap-2 text-xs leading-6"
><input
id="confirm-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
type="submit"
class="button button-primary"
:disabled="disabled || !acknowledged || !dimensionsValid || !hasPrompt"
>
确认生成图片
</button>
</div>
</form>
</AppDialog>
</template>
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ExternalLink, RefreshCw } from '@lucide/vue'
import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
import { usePolling } from '../../../composables/usePolling'
import { referenceImageUrl } from '../../../lib/assets'
import { formatDate } from '../../../lib/format'
import { getOperation, runOperation } from '../../workflows/operations'
import { subjectImagesApi } from '../api'
import { hasRunningImages, primaryImage } from '../model'
import type { SubjectFormAsset } from '../types'
/** 形态图片历史与主图选择;历史查询只在弹窗打开时进行。 */
const props = defineProps<{ projectId: string; form: SubjectFormAsset | null; disabled: boolean }>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ changed: [] }>()
const selectedId = ref('')
const confirming = ref(false)
const key = computed(() => (open.value ? (props.form?.id ?? '') : ''))
const query = usePolling(key, async (id, signal) => {
if (!id) return null
const rows = await subjectImagesApi.listImages(id, signal)
if (rows.some(image => image.subjectFormId !== id)) throw new Error('图片记录与当前形态不匹配,请刷新后重试。')
return rows
})
const images = computed(() => query.data.value ?? [])
const selected = computed(
() => images.value.find(image => image.id === selectedId.value) ?? primaryImage(images.value) ?? images.value[0]
)
const operation = computed(() => getOperation(props.projectId))
const canSetPrimary = computed(
() =>
!props.disabled &&
!operation.value.pending &&
!query.error.value &&
!hasRunningImages(images.value) &&
selected.value?.status === 'completed' &&
!!selected.value.imageUrl &&
!selected.value.isPrimary
)
const imageUrl = computed(() => (selected.value?.imageUrl ? referenceImageUrl(selected.value.imageUrl) : null))
watch(key, () => {
selectedId.value = ''
confirming.value = false
})
watch(
() => selected.value?.id,
() => {
confirming.value = false
}
)
/** 切换主图只调用 PUT,不调用模型;目标在提交时固定。 */
async function choosePrimary() {
if (!canSetPrimary.value || !confirming.value || !selected.value || !props.form) return
const formId = props.form.id
const imageId = selected.value.id
const projectId = props.projectId
confirming.value = false
await runOperation(projectId, `设置 ${props.form.subject.name} · ${props.form.name} 主图`, async () => {
const result = await subjectImagesApi.setPrimary(formId, imageId)
if (
!result ||
result.id !== imageId ||
result.subjectFormId !== formId ||
!result.isPrimary ||
result.status !== 'completed'
)
throw new Error('接口未确认主图切换,请先刷新核对后端状态。')
})
emit('changed')
await query.refresh()
}
</script>
<template>
<AppDialog
v-model:open="open"
:title="`${form?.subject.name || ''} · ${form?.name || '形态图片'}`"
description="主参考图会供后续分镜使用。切换主图不会重新生成已有视频提示词。"
wide
>
<div class="mt-5 flex items-center justify-between">
<span class="text-xs text-muted">{{ images.length }} 条图片记录</span
><button class="text-button" :disabled="query.loading.value" @click="query.refresh">
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图片
</button>
</div>
<p v-if="query.error.value" class="alert alert-error mt-3" role="alert">
{{ query.error.value }} 当前保留上次读取的记录主图操作已暂停
</p>
<p v-if="operation.error" class="alert alert-error mt-3" role="alert">{{ operation.error }}</p>
<div v-if="selected" class="mt-4">
<AssetImage
:key="selected.id"
:src="selected.status === 'completed' ? selected.imageUrl : null"
:alt="`${form?.subject.name} · ${form?.name}`"
:empty-text="selected.status === 'failed' ? '本次生成失败' : '等待后端生成结果'"
class="asset-image-preview"
/>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<StatusBadge :status="selected.status" /><span v-if="selected.isPrimary" class="tag"
>当前主参考图</span
><span class="text-xs text-muted">{{ selected.width || '—' }} × {{ selected.height || '—' }}</span>
</div>
<a
v-if="imageUrl && selected.status === 'completed'"
:href="imageUrl"
target="_blank"
rel="noopener noreferrer"
class="text-button"
>打开原图<ExternalLink :size="12"
/></a>
</div>
<p v-if="selected.error" class="alert alert-error mt-3 whitespace-pre-wrap" role="alert">
{{ selected.error }}
</p>
<div class="mt-4 flex flex-wrap items-center gap-3">
<button
v-if="!selected.isPrimary"
class="button button-secondary"
:disabled="!canSetPrimary"
@click="confirming = true"
>
设为主参考图</button
><span class="text-[11px] text-muted"
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
>
</div>
<div v-if="confirming" class="alert mt-3">
<p class="text-xs">确认将这张图用于此形态后续的分镜参考旧主图仍会保留此操作不调用模型</p>
<div class="mt-3 flex gap-3">
<button class="button button-primary" :disabled="!canSetPrimary" @click="choosePrimary">
确认切换主图</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>
<p v-if="selected.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6">
Negative prompt: {{ selected.negativePrompt }}
</p>
</details>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Image ID · {{ selected.id }}</p>
<div class="image-history mt-5" 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="`${form?.name}候选图`"
:retryable="false"
:empty-text="
{ pending: '排队中', generating: '生成中', failed: '生成失败', completed: '图片缺失' }[
image.status
]
"
/><span class="mt-1 block text-[10px]">{{
image.isPrimary
? '主参考图'
: { completed: '候选图', failed: '失败记录', pending: '排队中', generating: '生成中' }[
image.status
]
}}</span>
</button>
</div>
</div>
<p v-else class="py-10 text-center text-sm text-muted">
{{ query.loading.value ? '正在读取图片记录…' : '此形态尚无图片记录,可以先生成一张。' }}
</p>
</AppDialog>
</template>
+172
View File
@@ -0,0 +1,172 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AssetImage } from '../../components/ui'
import { getOperation } from '../workflows/operations'
import GenerateImageDialog from './components/GenerateImageDialog.vue'
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
import { coverImage, hasRunningImages, primaryImage, validImageSize } from './model'
import { formFixture, imageFixture } from './testing/fixtures'
let wrapper: VueWrapper | undefined
/** Reka 将弹窗挂载到 body,需要从实际弹窗找到按钮。 */
function button(label: string): HTMLButtonElement {
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
if (!element) throw new Error('找不到按钮:' + label)
return element
}
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
Object.assign(getOperation('gallery-test'), { pending: false, label: '', error: '', notice: '' })
})
describe('形态图片选择与操作', () => {
it('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => {
const form = formFixture()
form.images.unshift(
imageFixture({ id: 'failed', isPrimary: false, status: 'failed', imageUrl: null }),
imageFixture({ id: 'candidate', isPrimary: false })
)
expect(coverImage(form)?.id).toBe('image-db-1')
form.images.pop()
expect(primaryImage(form.images)).toBeUndefined()
expect(coverImage(form)?.id).toBe('candidate')
expect(hasRunningImages(form.images)).toBe(false)
form.images.push(imageFixture({ status: 'generating', isPrimary: false }))
expect(hasRunningImages(form.images)).toBe(true)
})
it('尺寸可同时留空,但不接受单边、零、负数或非整数', () => {
expect(validImageSize('', '')).toBe(true)
expect(validImageSize(2048, 2048)).toBe(true)
for (const [width, height] of [
[1024, ''],
['', 1024],
[0, 1024],
[-1, 1024],
[10.5, 1024]
] as const)
expect(validImageSize(width, height)).toBe(false)
})
it('生图确认发送 Seedream 与正式形态 ID,默认不替换已有主图', async () => {
wrapper = mount(GenerateImageDialog, {
attachTo: document.body,
props: { open: true, form: formFixture(), disabled: false }
})
await flushPromises()
expect(button('确认生成图片').disabled).toBe(true)
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
await flushPromises()
button('确认生成图片').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { provider: 'seedream', setPrimary: false }]])
})
it('新形态默认设主图,填写一侧尺寸时不能提交,切换形态清空自定义提示词', async () => {
const form = formFixture()
form.images = []
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, form, disabled: false } })
await flushPromises()
const width = document.querySelector<HTMLInputElement>('#image-width')!
width.value = '2048'
width.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
await flushPromises()
expect(button('确认生成图片').disabled).toBe(true)
await wrapper.setProps({ form: { ...form, id: 'form-db-2' } })
await flushPromises()
expect(width.value).toBe('')
expect(document.querySelector<HTMLInputElement>('#confirm-image-cost')!.checked).toBe(false)
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
await flushPromises()
button('确认生成图片').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([['form-db-2', { provider: 'seedream', setPrimary: true }]])
})
it('图库不自动生图,主图切换经确认后 PUT,失败图片不能设主图', async () => {
let primary = false
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
if (init?.method === 'PUT') {
primary = true
return new Response(JSON.stringify({ data: imageFixture() }))
}
return new Response(
JSON.stringify({
data: [
imageFixture({ isPrimary: primary }),
imageFixture({
id: 'failed',
isPrimary: false,
status: 'failed',
imageUrl: null,
error: '供应商拒绝请求'
})
]
})
)
})
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ImageGalleryDialog, {
attachTo: document.body,
props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false }
})
await flushPromises()
expect(fetcher.mock.calls).toHaveLength(1)
expect(document.body.textContent).toContain('<script>模型提示词</script>')
expect(document.querySelector('[role="dialog"] script')).toBeNull()
button('设为主参考图').click()
await flushPromises()
expect(fetcher.mock.calls).toHaveLength(1)
button('确认切换主图').click()
await flushPromises()
expect(fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')?.[0]).toBe(
'/api/subject-forms/form-db-1/images/image-db-1/primary'
)
expect(wrapper.emitted('changed')).toHaveLength(1)
document.querySelector<HTMLButtonElement>('[aria-label="查看图片 failed"]')!.click()
await flushPromises()
expect(button('设为主参考图').disabled).toBe(true)
expect(document.body.textContent).toContain('供应商拒绝请求')
})
it('关闭图片弹窗取消查询,迟到的旧形态响应不会污染再次打开的形态', async () => {
let finish!: (response: Response) => void
const fetcher = vi
.fn<typeof fetch>()
.mockImplementationOnce(
() =>
new Promise(resolve => {
finish = resolve
})
)
.mockImplementation(async () => new Response('{"data":[]}'))
vi.stubGlobal('fetch', fetcher)
wrapper = mount(ImageGalleryDialog, {
attachTo: document.body,
props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false }
})
await flushPromises()
await wrapper.setProps({ open: false })
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
await wrapper.setProps({ open: true, form: { ...formFixture('gallery-test'), id: 'form-db-2' } })
await flushPromises()
finish(new Response(JSON.stringify({ data: [imageFixture()] })))
await flushPromises()
expect(document.body.textContent).not.toContain('Image ID · image-db-1')
expect(document.body.textContent).toContain('此形态尚无图片记录')
})
it('图片加载失败有占位,地址变化可恢复,危险协议不会写入 img', async () => {
wrapper = mount(AssetImage, { props: { src: '/storage/a.png', alt: '形态主图' } })
await wrapper.get('img').trigger('error')
expect(wrapper.text()).toContain('图片无法加载')
await wrapper.setProps({ src: '/storage/b.png' })
expect(wrapper.get('img').attributes('src')).toContain('/storage/b.png')
await wrapper.setProps({ src: 'javascript:alert(1)' })
expect(wrapper.find('img').exists()).toBe(false)
})
})
+2
View File
@@ -0,0 +1,2 @@
export { subjectImagesApi } from './api'
export type { SubjectFormAsset, SubjectImage, GenerateFormImageInput, ImageBatchResult } from './types'
+31
View File
@@ -0,0 +1,31 @@
import { reactive } from 'vue'
import type { ImageSession, SubjectFormAsset, SubjectImage } from './types'
/** 会话回执不当作后端持久化任务状态。 */
const sessions = reactive<Record<string, ImageSession>>({})
/** 获取指定项目的生图回执,避免切换路由后丢失长请求结果。 */
export function getImageSession(projectId: string): ImageSession {
return (sessions[projectId] ??= { receipt: null })
}
/** 主图必须是后端已完成且显式标记 primary 的图片。 */
export function primaryImage(images: SubjectImage[]): SubjectImage | undefined {
return images.find(image => image.isPrimary && image.status === 'completed')
}
/** 列表优先展示主图,否则展示最新成功候选图;失败记录不会遮住可用图片。 */
export function coverImage(form: SubjectFormAsset): SubjectImage | undefined {
return primaryImage(form.images) ?? form.images.find(image => image.status === 'completed' && image.imageUrl)
}
/** 查询发现后台仍在运行时也阻止重复提交,不只依赖本地 loading。 */
export function hasRunningImages(images: SubjectImage[]): boolean {
return images.some(image => image.status === 'pending' || image.status === 'generating')
}
/** 图片尺寸必须成对提供;空值表示由后端使用默认尺寸。 */
export function validImageSize(width: number | '', height: number | ''): boolean {
if (width === '' && height === '') return true
return Number.isSafeInteger(width) && Number(width) > 0 && Number.isSafeInteger(height) && Number(height) > 0
}
@@ -0,0 +1,36 @@
import type { SubjectFormAsset, SubjectImage } from '../types'
/** 仅在测试使用的数据库图片 fixture,模拟已保存的本地资源地址。 */
export function imageFixture(overrides: Partial<SubjectImage> = {}): SubjectImage {
return {
id: 'image-db-1',
subjectFormId: 'form-db-1',
provider: 'seedream',
model: 'configured-model',
imageUrl: '/storage/subjects/project/form/image.png',
width: 2048,
height: 2048,
status: 'completed',
isPrimary: true,
error: null,
createdAt: '2026-08-28T08:00:00Z',
updatedAt: '2026-08-28T08:00:00Z',
prompt: '<script>模型提示词</script>',
...overrides
}
}
/** 正式形态 ID 与领域 formId 分开;测试禁止以 checkpoint 编号调用生图。 */
export function formFixture(projectId = 'page-test-project'): SubjectFormAsset {
return {
id: 'form-db-1',
subjectId: 'subject-db-1',
name: '日常造型',
description: '深色外套',
appearancePrompt: 'a person in a dark coat',
generationPrompt: null,
isDefault: true,
subject: { id: 'subject-db-1', projectId, ref: '@CH0001', name: '林知夏', module: 'character' },
images: [imageFixture()]
}
}
+65
View File
@@ -0,0 +1,65 @@
/** 后端图片记录的持久化状态,不以 HTTP 请求完成代替生成成功。 */
export type SubjectImageStatus = 'pending' | 'generating' | 'completed' | 'failed'
/** 图库列表返回的图片元数据;正式图片 ID 与形态 ID 均来自数据库。 */
export interface SubjectImage {
id: string
subjectFormId: string
provider: string
model: string
imageUrl: string | null
width: number | null
height: number | null
status: SubjectImageStatus
isPrimary: boolean
error: string | null
createdAt: string
updatedAt: string
/** 详情查询才返回实际使用的 Prompt;列表不包含长文本。 */
prompt?: string
negativePrompt?: string | null
}
/** 正式 SubjectForm 及其所属主体、图片,不使用 checkpoint 的领域 formId。 */
export interface SubjectFormAsset {
id: string
subjectId: string
name: string
description: string | null
appearancePrompt: string | null
generationPrompt: string | null
isDefault: boolean
subject: { id: string; projectId: string; ref: string; name: string; module: string }
images: SubjectImage[]
}
/** 单形态生图配置,明确指定 Seedream,避免后端单图入口默认使用 mock。 */
export interface GenerateFormImageInput {
provider: 'seedream'
prompt?: string
width?: number
height?: number
setPrimary: boolean
}
/** 批量生图只接受这些配置;不能把单张尺寸配置误传给批量接口。 */
export interface GenerateProjectImagesInput {
provider: 'seedream'
concurrency: number
force: boolean
}
/** HTTP 200 也可能包含部分失败;此响应不返回成功图片列表。 */
export interface ImageBatchResult {
total: number
targetCount: number
generated: number
skipped: number
failed: number
failures: { subjectFormId: string; error: string }[]
}
/** 长请求的回执按项目隔离,在会话内切换页面后仍可查看。 */
export interface ImageSession {
receipt: { title: string; result: ImageBatchResult } | null
}
@@ -0,0 +1,104 @@
import { computed, ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { ApiError } from '../../lib/http'
import { useProjectContext } from '../projects/context'
import { getOperation, runOperation } from '../workflows/operations'
import { workflowCheckpoints } from '../workflows/selectors'
import { subjectImagesApi } from './api'
import { getImageSession, hasRunningImages } from './model'
import type { GenerateFormImageInput } from './types'
/** 形态图库统一读取数据库记录并管理单图/批量长请求,不调用生产流程。 */
export function useSubjectImages() {
const context = useProjectContext()
const id = computed(() => context.project.value?.id ?? '')
const concurrency = ref(2)
const force = ref(false)
const query = usePolling(id, async (projectId, signal) => {
if (!projectId) return []
try {
const forms = await subjectImagesApi.listForms(projectId, signal)
if (
forms.some(
form =>
form.subject.projectId !== projectId ||
form.images.some(image => image.subjectFormId !== form.id)
)
)
throw new Error('形态图库返回了不匹配的项目或图片,请刷新后重试。')
return forms
} catch (error) {
if (error instanceof ApiError && error.status === 404)
throw new Error(
'后端尚未提供形态图库查询,请更新后端 dev 并重启服务(GET /projects/:id/subject-forms)。',
{ cause: error }
)
throw error
}
})
const forms = computed(() => query.data.value ?? [])
const operation = computed(() => getOperation(id.value))
const session = computed(() => getImageSession(id.value))
const running = computed(() => forms.value.some(form => hasRunningImages(form.images)))
const breakdownRunning = computed(
() =>
workflowCheckpoints(context.checkpoints.value, 'breakdown').at(-1)?.state.workflowExecution?.status ===
'running'
)
const blocked = computed(
() =>
operation.value.pending ||
!!context.error.value ||
!!query.error.value ||
query.data.value === null ||
running.value ||
breakdownRunning.value ||
context.project.value?.status === 'generating'
)
const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
/** 捕获正式 Form ID,生成后重新读取图片;刷新失败也不自动重发有费用的请求。 */
async function generate(formId: string, input: GenerateFormImageInput) {
if (blocked.value) return
const form = forms.value.find(item => item.id === formId)
if (!form) return
const projectId = id.value
await runOperation(projectId, `生成 ${form.subject.name} · ${form.name} 图片`, async () => {
const image = await subjectImagesApi.generate(formId, input)
if (!image || image.subjectFormId !== formId || image.status !== 'completed' || !image.imageUrl)
throw new Error(image?.error || '接口未返回已完成的图片,请先刷新图片记录核对后端状态。')
})
await query.refresh()
}
/** 批量始终面向整个项目,不受页面筛选影响;force 仅新增候选图,不替换已有主图。 */
async function generateProject() {
if (blocked.value || !batchValid.value || !forms.value.length) return
const projectId = id.value
const input = { provider: 'seedream' as const, concurrency: concurrency.value, force: force.value }
const target = getImageSession(projectId)
target.receipt = null
await runOperation(projectId, '批量生成项目形态图片', async () => {
target.receipt = {
title: input.force ? '为全项目新增候选图' : '补齐项目主参考图',
result: await subjectImagesApi.generateProject(projectId, input)
}
})
await query.refresh()
}
return {
id,
forms,
query,
operation,
session,
concurrency,
force,
blocked,
running,
batchValid,
generate,
generateProject
}
}
+120
View File
@@ -19,6 +19,9 @@ import {
} from '../storyboard/testing/fixtures' } from '../storyboard/testing/fixtures'
import { getStoryboardSession } from '../storyboard/model' import { getStoryboardSession } from '../storyboard/model'
import { getOperation } from './operations' 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'
/** 模拟 API 响应仅用于测试,不会打包进生产页面。 */ /** 模拟 API 响应仅用于测试,不会打包进生产页面。 */
const fixture: ProjectDetail = { const fixture: ProjectDetail = {
@@ -81,16 +84,133 @@ function mountStoryboard() {
return provided return provided
} }
/** 形态图库使用真实路由解析来自主体列表的筛选参数。 */
async function mountSubjectImages() {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/projects/:projectId/:workspace', component: SubjectImagesPage }]
})
await router.push(`/projects/${fixture.id}/subject-images`)
wrapper = mount(SubjectImagesPage, {
attachTo: document.body,
global: { plugins: [router], provide: { [projectContextKey as symbol]: context() } }
})
await flushPromises()
}
afterEach(() => { afterEach(() => {
wrapper?.unmount() wrapper?.unmount()
wrapper = undefined wrapper = undefined
document.body.innerHTML = '' document.body.innerHTML = ''
vi.unstubAllGlobals() vi.unstubAllGlobals()
Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {} }) Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {} })
getImageSession(fixture.id).receipt = null
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' }) Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
}) })
describe('工作台页面交互', () => { describe('工作台页面交互', () => {
it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async () => new Response(JSON.stringify({ data: [formFixture()] })))
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
expect(wrapper!.get('.form-image-card img').attributes('src')).toContain(
'/storage/subjects/project/form/image.png'
)
expect(wrapper!.text()).toContain('林知夏')
expect(wrapper!.text()).toContain('主参考图')
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
['/api/projects/page-test-project/subject-forms', 'GET']
])
await wrapper!.get('[aria-label="搜索形态图片"]').setValue('不存在的形态')
expect(wrapper!.text()).toContain('没有匹配的形态')
button('清除筛选').click()
await flushPromises()
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
})
it('形态单图确认后发送正式 ID 并刷新候选图,不会调用 production/start', async () => {
const form = formFixture()
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
if (init?.method === 'POST') {
const candidate = imageFixture({ id: 'new-candidate', isPrimary: false, imageUrl: '/storage/new.png' })
form.images.unshift(candidate)
return new Response(JSON.stringify({ data: candidate }))
}
return new Response(JSON.stringify({ data: [form] }))
})
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
button('再生成一张').click()
await flushPromises()
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
await flushPromises()
document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await flushPromises()
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(post[0]).toBe('/api/subject-forms/form-db-1/images')
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', setPrimary: false })
expect(wrapper!.text()).toContain('2 条记录')
expect(wrapper!.get('.form-image-card img').attributes('src')).not.toContain('/storage/new.png')
expect(fetcher.mock.calls.some(([url]) => String(url).includes('production/start'))).toBe(false)
})
it('批量生图保留部分失败回执,默认跳过有主图项,筛选不会成为批量参数', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(
async (_url, init) =>
new Response(
JSON.stringify({
data:
init?.method === 'POST'
? {
total: 2,
targetCount: 1,
generated: 0,
skipped: 1,
failed: 1,
failures: [{ subjectFormId: 'form-db-2', error: '服务暂不可用' }]
}
: [formFixture()]
})
)
)
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
await wrapper!.get('[aria-label="搜索形态图片"]').setValue('@CH0001')
await confirmGeneration('补齐项目主参考图')
expect(wrapper!.get('[aria-label="生图批量回执"]').text()).toContain('部分形态生图失败')
expect(wrapper!.text()).toContain('服务暂不可用')
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', concurrency: 2, force: false })
await wrapper!.get('.panel details input[type="checkbox"]').setValue(true)
await confirmGeneration('为全项目新增候选图')
const lastPost = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST').at(-1)!
expect(JSON.parse(lastPost[1]!.body as string).force).toBe(true)
})
it('后端持久化生成中或查询失败时暂停生图,旧图库仍可查看', async () => {
let fail = false
const form = formFixture()
form.images.unshift(imageFixture({ id: 'running', isPrimary: false, status: 'generating', imageUrl: null }))
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async () =>
fail
? new Response('{"message":"数据库异常"}', { status: 500 })
: new Response(JSON.stringify({ data: [form] }))
)
vi.stubGlobal('fetch', fetcher)
await mountSubjectImages()
expect(button('再生成一张').disabled).toBe(true)
expect(wrapper!.text()).toContain('数据库中仍有排队或生成中的图片')
fail = true
button('刷新图库').click()
await flushPromises()
expect(wrapper!.text()).toContain('数据库异常')
expect(wrapper!.find('.form-image-card img').exists()).toBe(true)
expect(button('补齐项目主参考图').disabled).toBe(true)
})
it('参考图拦截危险地址,生成规格只通过 GET 读取正式形态和状态', async () => { it('参考图拦截危险地址,生成规格只通过 GET 读取正式形态和状态', async () => {
const shot = designedShot() const shot = designedShot()
const fetcher = vi const fetcher = vi
+16
View File
@@ -0,0 +1,16 @@
/** 只允许 HTTP(S) 与后端 /storage 路径;生产环境绝对 API 地址使用其源站。 */
export function referenceImageUrl(value: string): string | null {
const raw = value.trim()
try {
if (raw.startsWith('/storage/') && !raw.includes('\\')) {
const api = import.meta.env.VITE_API_BASE_URL || '/api'
const origin = /^https?:\/\//i.test(api) ? new URL(api).origin : window.location.origin
const url = new URL(raw, origin)
return url.pathname.startsWith('/storage/') ? url.href : null
}
const url = new URL(raw)
return ['http:', 'https:'].includes(url.protocol) ? url.href : null
} catch {
return null
}
}
+36
View File
@@ -3,6 +3,7 @@ import { ApiError, optionalResource, request } from './http'
import { projectsApi } from '../features/projects/api' import { projectsApi } from '../features/projects/api'
import { breakdownApi } from '../features/breakdown/api' import { breakdownApi } from '../features/breakdown/api'
import { storyboardApi } from '../features/storyboard/api' import { storyboardApi } from '../features/storyboard/api'
import { subjectImagesApi } from '../features/subject-images/api'
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
@@ -10,6 +11,41 @@ afterEach(() => {
}) })
describe('后端 API 契约', () => { describe('后端 API 契约', () => {
it('图库读取与单图、批量和切换主图使用各自接口,默认显式 Seedream', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":[]}'))
vi.stubGlobal('fetch', fetcher)
await subjectImagesApi.listForms('a/b')
await subjectImagesApi.listImages('form/id')
await subjectImagesApi.generate('form/id', {
provider: 'seedream',
setPrimary: false,
width: 2048,
height: 2048,
prompt: '本次提示词'
})
await subjectImagesApi.generateProject('a/b', { provider: 'seedream', concurrency: 2, force: false })
await subjectImagesApi.setPrimary('form/id', 'image/id')
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
['/api/projects/a%2Fb/subject-forms', 'GET'],
['/api/subject-forms/form%2Fid/images', 'GET'],
['/api/subject-forms/form%2Fid/images', 'POST'],
['/api/projects/a%2Fb/subject-images/generate', 'POST'],
['/api/subject-forms/form%2Fid/images/image%2Fid/primary', 'PUT']
])
expect(JSON.parse(fetcher.mock.calls[2]![1]!.body as string)).toEqual({
provider: 'seedream',
setPrimary: false,
width: 2048,
height: 2048,
prompt: '本次提示词'
})
expect(JSON.parse(fetcher.mock.calls[3]![1]!.body as string)).toEqual({
provider: 'seedream',
concurrency: 2,
force: false
})
expect(fetcher.mock.calls[4]![1]!.body).toBeUndefined()
})
it('分镜单集显式持久化,批量保持 force 和零次修复参数', async () => { it('分镜单集显式持久化,批量保持 force 和零次修复参数', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}')) const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
vi.stubGlobal('fetch', fetcher) vi.stubGlobal('fetch', fetcher)
+1 -1
View File
@@ -12,7 +12,7 @@ export class ApiError extends Error {
/** 请求选项;长工作流显式关闭超时,不自动重试任何 POST。 */ /** 请求选项;长工作流显式关闭超时,不自动重试任何 POST。 */
export interface RequestOptions { export interface RequestOptions {
method?: 'GET' | 'POST' method?: 'GET' | 'POST' | 'PUT'
body?: unknown body?: unknown
signal?: AbortSignal signal?: AbortSignal
timeoutMs?: number timeoutMs?: number
+5
View File
@@ -25,6 +25,11 @@ export const router = createRouter({
component: () => import('../features/breakdown/BreakdownPage.vue'), component: () => import('../features/breakdown/BreakdownPage.vue'),
meta: { title: '剧本拆解' } meta: { title: '剧本拆解' }
}, },
{
path: 'subject-images',
component: () => import('../features/subject-images/SubjectImagesPage.vue'),
meta: { title: '形态图片' }
},
{ {
path: 'storyboard', path: 'storyboard',
component: () => import('../features/storyboard/StoryboardPage.vue'), component: () => import('../features/storyboard/StoryboardPage.vue'),
+66
View File
@@ -614,6 +614,72 @@
background: #8a9b6f; background: #8a9b6f;
box-shadow: 0 0 0 3px #e9edde; box-shadow: 0 0 0 3px #e9edde;
} }
.dialog-content.dialog-wide {
width: min(820px, calc(100vw - 36px));
}
.form-image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
gap: 20px;
}
.form-image-card {
overflow: hidden;
}
.asset-image {
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 4 / 3;
overflow: hidden;
background: #eff0e9;
}
.asset-image img {
width: 100%;
height: 100%;
object-fit: contain;
min-height: 0;
}
.asset-image-empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
padding: 20px;
color: var(--color-muted);
font-size: 11px;
text-align: center;
}
.asset-image-preview {
aspect-ratio: auto;
height: min(48vh, 480px);
}
.image-history {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 4px;
}
.image-history-item {
width: 110px;
flex-shrink: 0;
padding: 4px;
border: 1px solid var(--color-line);
border-radius: 4px;
}
.image-history-item.selected {
border-color: var(--color-accent);
background: #faf2ed;
}
.image-history-item .asset-image-empty {
font-size: 9px;
padding: 6px;
gap: 4px;
}
.image-history-item .asset-image-empty svg {
width: 14px;
height: 14px;
}
.storyboard-controls { .storyboard-controls {
display: grid; display: grid;
grid-template-columns: minmax(180px, 1fr) 100px 145px auto; grid-template-columns: minmax(180px, 1fr) 100px 145px auto;