feat: 接入形态图库与 Seedream 生图操作
展示已有主图、候选图和失败记录,支持单图及批量生成、主图选择。 补充正式 ID 校验、费用确认、图片加载占位和回归测试。 依赖后端 eac8dc3 的项目形态图库只读接口。
This commit is contained in:
@@ -34,8 +34,9 @@ API_PROXY_TARGET=http://localhost:3412
|
||||
| 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 |
|
||||
| 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 |
|
||||
| 镜头工具 | 主体参考图与缺失原因、确定性 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/breakdown` | 拆解 graph 的 API、类型、配置页面、主体与分镜组件 |
|
||||
| `src/features/storyboard` | 分镜 API、类型、生成操作、依赖判断、设计与状态、镜头资源、生成回执 |
|
||||
| `src/features/subject-images` | 正式形态图库、图片生成参数、历史图片、主图选择与批量回执 |
|
||||
| `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 |
|
||||
| `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 |
|
||||
| `src/composables` | 具备取消和竞态保护的轮询 |
|
||||
@@ -76,7 +78,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
||||
## 接口基线
|
||||
|
||||
对齐后端 `qianlanse/short-drama-agent` 的 `dev`:
|
||||
`6ce284b9d93e8c9d0b7ca57d265148e354642095`。
|
||||
`eac8dc3b1b2ce1ff2bdfeb01d7281cd838f07285`,包含本次新增的只读 `GET /projects/:projectId/subject-forms` 接口。请同步更新前后端 `dev` 后重启后端。
|
||||
|
||||
| HTTP | 路径(以 /api 为前缀) | 用途 |
|
||||
| --- | --- | --- |
|
||||
@@ -100,6 +102,11 @@ 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 | `/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 查询取得阶段状态,避免重复拉取同样内容。
|
||||
|
||||
@@ -127,6 +134,18 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
|
||||
9. 参考图和 GenerationSpec 按需 GET,切换镜头或设计变化时取消旧查询。GenerationSpec 需要有效的 Direction、VisualState 和形态关联,不生成图片或视频。
|
||||
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,没有分页或轻量状态接口。长篇剧本会增加轮询流量。下一步建议后端增加:
|
||||
@@ -182,5 +201,7 @@ server {
|
||||
6. 进入分镜设计,确认正式镜头 ID 与 Beat 对应;先生成单集 Direction,再生成 VisualState,检查覆盖率和主体状态。
|
||||
7. 用小型测试项目验证批量补齐、跳过完整集、失败诊断和明确覆盖;确认关闭覆盖后不会重生成完整设计。
|
||||
8. 给主体形态准备参考图,读取镜头参考图和 GenerationSpec;确认 Prompt 无正文时经确认生成、有正文且不覆盖时复用。不提交实际视频任务。
|
||||
9. 打开“形态图片”,检查已有主图自动显示;单个形态新增候选图,查看历史、失败原因和实际 Prompt,再设为主图。
|
||||
10. 在小型测试项目中验证批量补齐与“已有主图也新增候选图”;回到分镜刷新参考图,确认使用新选择的主图。
|
||||
|
||||
自动测试使用模拟 API,覆盖两条既有流程及分镜 API 参数、依赖判断、正式 ID 合并、校验与部分失败、切换剧集的竞态、查询错误恢复、Prompt 确认和纯文本展示。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
|
||||
自动测试使用模拟 API,覆盖既有流程、分镜接口与竞态,以及形态图片自动读取、正式 ID 生图、主图 PUT、费用确认、尺寸校验、候选图选择、批量部分失败和图片加载错误。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
|
||||
|
||||
+9
-1
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
||||
import {
|
||||
Clapperboard,
|
||||
Camera,
|
||||
Images,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Layers,
|
||||
@@ -55,13 +56,20 @@ 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}/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
|
||||
: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">03</span></RouterLink
|
||||
><span class="sidebar-label ml-auto text-[10px] text-stone-500">04</span></RouterLink
|
||||
>
|
||||
</nav></template
|
||||
>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { X } from '@lucide/vue'
|
||||
|
||||
/** 统一弹窗容器,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 })
|
||||
</script>
|
||||
|
||||
@@ -22,6 +22,7 @@ const open = defineModel<boolean>('open', { default: false })
|
||||
<DialogOverlay class="dialog-overlay" />
|
||||
<DialogContent
|
||||
class="dialog-content"
|
||||
:class="{ 'dialog-wide': wide }"
|
||||
@interact-outside="busy && $event.preventDefault()"
|
||||
@escape-key-down="busy && $event.preventDefault()"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -2,3 +2,4 @@
|
||||
export { default as AppDialog } from './AppDialog.vue'
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as StatusBadge } from './StatusBadge.vue'
|
||||
export { default as AssetImage } from './AssetImage.vue'
|
||||
|
||||
@@ -341,6 +341,7 @@ function exportResult() {
|
||||
<main class="min-w-0">
|
||||
<TabsContent v-for="option in moduleOptions" :key="option.value" :value="option.value"
|
||||
><SubjectList
|
||||
:project-id="project?.id"
|
||||
:subjects="subjects.filter(item => item.module === option.value)"
|
||||
:forms="forms" /></TabsContent
|
||||
><TabsContent value="storyboard"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { EmptyState } from '../../../components/ui'
|
||||
import type { SubjectCandidate, SubjectForm } from '../types'
|
||||
|
||||
/** 主体列表按稳定 ref 展示;形态以 profileId 关联。 */
|
||||
const props = defineProps<{ subjects: SubjectCandidate[]; forms: SubjectForm[] }>()
|
||||
const props = defineProps<{ subjects: SubjectCandidate[]; forms: SubjectForm[]; projectId?: string }>()
|
||||
const search = ref('')
|
||||
const filtered = computed(() =>
|
||||
props.subjects.filter(item =>
|
||||
@@ -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-images`, query: { subjectRef: subject.ref } }"
|
||||
class="text-button mt-3"
|
||||
>查看形态图片/生图</RouterLink
|
||||
>
|
||||
<details class="subject-details mt-4">
|
||||
<summary>
|
||||
<ChevronDown :size="14" />外观描述与形态
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
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 { projectContextKey, useProjectData } from './context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
@@ -45,6 +45,9 @@ const operation = computed(() => getOperation(id.value))
|
||||
<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
|
||||
>
|
||||
|
||||
@@ -155,7 +155,10 @@ function exportResult(kind: 'spec' | 'prompt') {
|
||||
后端未返回参考图或缺失记录。
|
||||
</p>
|
||||
<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>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -110,19 +110,5 @@ export function directionLabel(value: string, field?: string): string {
|
||||
return labels[value] ?? value
|
||||
}
|
||||
|
||||
/** 只开放 http(s) 与同源 /storage 路径,拦截模型/数据中的 javascript 或协议相对 URL。 */
|
||||
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
|
||||
}
|
||||
}
|
||||
/** 保留已有导出路径;图库与分镜共用同一套图片地址校验。 */
|
||||
export { referenceImageUrl } from '../../lib/assets'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export { subjectImagesApi } from './api'
|
||||
export type { SubjectFormAsset, SubjectImage, GenerateFormImageInput, ImageBatchResult } from './types'
|
||||
@@ -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()]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
} from '../storyboard/testing/fixtures'
|
||||
import { getStoryboardSession } from '../storyboard/model'
|
||||
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 响应仅用于测试,不会打包进生产页面。 */
|
||||
const fixture: ProjectDetail = {
|
||||
@@ -81,16 +84,133 @@ function mountStoryboard() {
|
||||
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(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {} })
|
||||
getImageSession(fixture.id).receipt = null
|
||||
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const shot = designedShot()
|
||||
const fetcher = vi
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ApiError, optionalResource, request } from './http'
|
||||
import { projectsApi } from '../features/projects/api'
|
||||
import { breakdownApi } from '../features/breakdown/api'
|
||||
import { storyboardApi } from '../features/storyboard/api'
|
||||
import { subjectImagesApi } from '../features/subject-images/api'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
@@ -10,6 +11,41 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ export class ApiError extends Error {
|
||||
|
||||
/** 请求选项;长工作流显式关闭超时,不自动重试任何 POST。 */
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST'
|
||||
method?: 'GET' | 'POST' | 'PUT'
|
||||
body?: unknown
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
|
||||
@@ -25,6 +25,11 @@ export const router = createRouter({
|
||||
component: () => import('../features/breakdown/BreakdownPage.vue'),
|
||||
meta: { title: '剧本拆解' }
|
||||
},
|
||||
{
|
||||
path: 'subject-images',
|
||||
component: () => import('../features/subject-images/SubjectImagesPage.vue'),
|
||||
meta: { title: '形态图片' }
|
||||
},
|
||||
{
|
||||
path: 'storyboard',
|
||||
component: () => import('../features/storyboard/StoryboardPage.vue'),
|
||||
|
||||
@@ -614,6 +614,72 @@
|
||||
background: #8a9b6f;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) 100px 145px auto;
|
||||
|
||||
Reference in New Issue
Block a user