Compare commits

...
41 Commits
Author SHA1 Message Date
GouJ 0ce9ae00e7 style: 统一生成配置表单对齐与间距 2026-09-22 22:29:15 +08:00
GouJ 94ae147250 feat: 优化生成配置布局与选项控件 2026-09-22 22:17:09 +08:00
GouJ 1c5b1e448a feat: 增加操作步骤引导与错误修复入口 2026-09-22 21:40:58 +08:00
GouJ be06caaa9f feat: 同步最新生图与身份能力 2026-09-22 19:56:12 +08:00
GouJ 65e8f9c3ba style: 优化剧集阅读吸顶标题 2026-09-22 19:29:27 +08:00
GouJ a442087ea9 style: 更新品牌标识与侧栏切换图标 2026-09-22 19:16:40 +08:00
GouJ 7763170830 style: 统一页面表单操作左对齐 2026-09-22 19:03:29 +08:00
GouJ 869ebf2f2c fix: 补齐轮询组合式函数 2026-09-22 18:55:27 +08:00
GouJ 05cebc5331 style: 统一折叠标题颜色与主体操作间距 2026-09-22 18:49:03 +08:00
GouJ 8e8fab307f ui: 默认展开内容折叠区 2026-09-22 18:42:56 +08:00
GouJ b9db84c261 style: 优化风格参考图布局 2026-09-22 16:55:55 +08:00
GouJ 6e5856714e style: 增加形态名称与标签间距 2026-09-22 15:04:53 +08:00
GouJ e9547b13fa fix: 保持瀑布流空图片为正方形 2026-09-22 14:53:08 +08:00
GouJ f0957c237b style: 统一形态网格图片区尺寸 2026-09-22 14:40:38 +08:00
GouJ 9502ebdc16 style: 资产详情左栏宽度随图片自适应 2026-09-22 12:44:27 +08:00
GouJ 6e542a5de7 style: 让资产详情预览宽度跟随图片 2026-09-22 11:56:45 +08:00
GouJ 5f78efaad1 style: 优化资产列表图片自适应展示 2026-09-22 11:52:46 +08:00
GouJ a3599b03d0 style: 完整展示资产详情图片并移除底部边线 2026-09-22 11:44:20 +08:00
GouJ df449e0d7d style: 调整资产悬停操作与详情间距 2026-09-22 11:39:50 +08:00
GouJ f3c9f6ca41 style: 重构资产卡片覆盖层与详情弹窗 2026-09-22 11:34:27 +08:00
GouJ 2240a90424 style: 优化资产卡片信息与瀑布流布局 2026-09-22 11:12:40 +08:00
GouJ 2d0d7e22d3 feat: 在全局资产库内完成素材管理 2026-09-22 11:05:08 +08:00
GouJ fec3b8dcac feat: 全局资产库直接展示全部图片 2026-09-22 10:54:23 +08:00
GouJ eec957a2e4 refactor: 将资产库移至全局导航 2026-09-22 09:28:38 +08:00
GouJ b834d1d259 feat: 新增项目资产库与形态参考素材 2026-09-21 19:54:51 +08:00
GJ e6d02a20a7 feat(short-drama-agent-front): 优化项目查询与工作区界面 2026-09-21 19:36:47 +08:00
GJ 227db5450d feat(short-drama-agent-front): 新增项目标题输入 2026-09-21 14:32:59 +08:00
GJ c1ec88daf9 feat(short-drama-agent-front): 优化图片加载动效 2026-09-21 10:58:00 +08:00
GouJ b78152778b feat: 形态图片默认使用瀑布流 2026-09-21 10:26:40 +08:00
GouJ a253711435 feat: 优化生产面板并增加图片网格加载 2026-09-21 10:21:22 +08:00
GouJ 01f274cd8e feat: 对接项目生成配置与单集成片 2026-09-21 10:03:50 +08:00
GouJ 1bbfd30627 fix: match script tab content spacing to breakdown workspace 2026-09-14 18:27:36 +08:00
GouJ a691b904eb fix: remove workspace tab navigation bottom border 2026-09-14 18:24:09 +08:00
GouJ 59b2c2aaa3 fix: use primary text only for active workspace tabs 2026-09-14 18:22:04 +08:00
GouJ c135d7e599 fix: center a fixed 10px active indicator under workspace tabs 2026-09-14 18:15:43 +08:00
GouJ 95fff56513 fix: remove gaps between workspace tabs 2026-09-14 18:08:49 +08:00
GouJ ed0715f1c8 fix: retain hover background on active workspace tabs 2026-09-14 18:06:28 +08:00
GouJ 20360c8318 fix: add horizontal padding to workspace tabs 2026-09-14 18:03:59 +08:00
GouJ cc09696394 test: cover project scroll loading and filter reset 2026-09-14 16:32:24 +08:00
GouJ d8842c329a fix: replace project pagination footer with scroll-to-load batches 2026-09-14 16:32:23 +08:00
GouJ a2f09faca8 fix: default planned episode count to one 2026-09-14 16:22:38 +08:00
90 changed files with 4132 additions and 710 deletions
+1
View File
@@ -7,3 +7,4 @@ coverage
*.tsbuildinfo *.tsbuildinfo
.DS_Store .DS_Store
.worktree/ .worktree/
.worktrees/
+15
View File
@@ -44,3 +44,18 @@
- 浅色主画布统一白色,输入框使用浅灰,Tab 导航、辅助区和图库使用统一主题变量,首屏 theme-color 与应用一致。深色视频播放背景保留。 - 浅色主画布统一白色,输入框使用浅灰,Tab 导航、辅助区和图库使用统一主题变量,首屏 theme-color 与应用一致。深色视频播放背景保留。
验证使用模拟 API,不调用真实生成模型。浏览器无法访问当前本地预览地址,尚未完成浏览器截图验收或真实后端联调。 验证使用模拟 API,不调用真实生成模型。浏览器无法访问当前本地预览地址,尚未完成浏览器截图验收或真实后端联调。
## 2026-09-21 项目资产接口
- `POST /projects/:projectId/assets` 使用 multipart 的 `file``name``category`,浏览器负责生成 boundary,前端不手工设置 `Content-Type`
- GETPUT/DELETE 项目资产接口全部使用正式项目 ID 与资产 ID;编辑只修改名称和分类,不替换物理文件。
- 素材公开字段包含 `publicUrl`、类型、MIME、扩展名、大小、分类、元数据和时间;页面只读取 `metadata.originalName` 作为辅助搜索,不依赖内部存储路径。
- 视觉风格新增参考图可发送 `projectAssetId`,后端从所属项目素材解析 `imageUrl`;外部 `imageUrl` 入口继续兼容。资产关联关系不在公开风格图 DTO 中推断。
- 删除仍由后端执行引用保护;前端不先删风格记录,也不将失败响应显示为已删除。
## 2026-09-22 增量同步
- 新增形态图和镜头首帧的 POST 预览接口;请求体默认 `{}`,只读取后端当前生成配置与参考图规划,不产生模型任务。
- 首帧预览按 `referencePlan.selected``omitted` 展示 Provider 上限后的真实输入;不再从通用 `keyframe-spec` 推断最终选图。
- 形态提示词、形态图片及主体身份文本批处理均可发送可选 `module`;省略时保持全项目行为。
- 场景/道具身份母版使用 `/projects/:projectId/subject-identities/anchors/generate`,请求包含 `module``limit``concurrency`;成功项已由后端设为 Anchor 并锁定,不再由前端逐项补 PUT。
+18
View File
@@ -13,6 +13,7 @@
| 拆解与恢复 | 拆解设置与恢复 | `breakdown-preview``breakdown/start``retry``resume-shots``resume-storyboard` | 保留正式剧集分组、模块选择、预览、失败恢复与 JSON 导出 | | 拆解与恢复 | 拆解设置与恢复 | `breakdown-preview``breakdown/start``retry``resume-shots``resume-storyboard` | 保留正式剧集分组、模块选择、预览、失败恢复与 JSON 导出 |
| 运行诊断 | 创作/拆解的执行记录 → 查看完整运行诊断 | GET `metrics``timeline/grouped`、共享 `checkpoints` | 新增全量时间线、阶段分组、工作流与节点筛选、独立失败提示、JSON 导出 | | 运行诊断 | 创作/拆解的执行记录 → 查看完整运行诊断 | GET `metrics``timeline/grouped`、共享 `checkpoints` | 新增全量时间线、阶段分组、工作流与节点筛选、独立失败提示、JSON 导出 |
| 视觉风格 | 视觉风格 | GETPUTPOST `visual-style` 及图片登记、启停、删除 | 保留锁定、分类提示词、硬约束、AI 生成与图片记录 | | 视觉风格 | 视觉风格 | GETPUTPOST `visual-style` 及图片登记、启停、删除 | 保留锁定、分类提示词、硬约束、AI 生成与图片记录 |
| 项目资产库 | 资产库/视觉风格参考图选择器 | `/projects/:id/assets` 上传、列表、详情、编辑、删除 | 新增 JPEG/PNG/WebP 上传、名称与分类管理、筛选预览;被视觉风格引用时由后端阻止删除 |
| 稳定身份 | 主体身份 | `subjects/:id/identity`、单个/项目/角色文本生成 | 保留人工修改、锁定及批量文本生成 | | 稳定身份 | 主体身份 | `subjects/:id/identity`、单个/项目/角色文本生成 | 保留人工修改、锁定及批量文本生成 |
| 角色选角 | 主体身份角色模块 | `character-casting/readiness`、批量/单个 candidates、图片 `casting` | 保留候选、检查、确认母版并原子锁定 | | 角色选角 | 主体身份角色模块 | `character-casting/readiness`、批量/单个 candidates、图片 `casting` | 保留候选、检查、确认母版并原子锁定 |
| 身份参考图 | 主体身份图库 | `identity/images`、图片 `anchor` | 保留完整预览、历史、辅助视角与母版切换;修正道具继承说明 | | 身份参考图 | 主体身份图库 | `identity/images`、图片 `anchor` | 保留完整预览、历史、辅助视角与母版切换;修正道具继承说明 |
@@ -77,3 +78,20 @@
- 已通过 lint、格式、TypeScript、Vitest(含质量链路及菜单/折叠交互回归)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。 - 已通过 lint、格式、TypeScript、Vitest(含质量链路及菜单/折叠交互回归)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。
- 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。 - 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。
## 2026-09-21 项目资产与形态参考同步
核对后端 dev `d9ca93ef54a4c5fbe3917c504531a45f31cb9966`。新增独立项目资产库页面,接入图片上传、列表筛选、详情预览、名称/分类编辑和删除;上传使用 multipart,前端与后端同时限制 JPEG、PNG、WebP 及单张 20MB。视觉风格页可按需读取资产库并以正式 `projectAssetId` 登记参考图,仍保留外部 URL 兼容入口。后端负责校验资产归属、图片类型和删除引用保护。
启用的视觉风格参考图会由后端编译为形态生图的分类参考职责,身份母版继续承担主体一致性。前端不拼接 Provider Prompt,也不把资产库图片自动设为形态主图;已有形态图不会因资产或风格参考变化被客户端静默替换。
## 2026-09-22 生图预览与主体模块批处理
核对后端 dev `9d927cd3e9d7e01415ad953bf3a0957bc3825050`,补齐以下正式能力:
- 形态生图弹窗接入 POST `/subject-forms/:subjectFormId/images/preview`,展示后端实际 Provider、模型、参考图顺序、生成配置与最终 Prompt;预览不创建生图任务。
- 首帧弹窗接入 POST `/storyboard-shots/:shotId/keyframe/preview`,展示视觉风格、身份母版和形态主图经过去重与 Provider 上限规划后的实际选中/省略结果。
- 形态提示词与形态图片批处理支持 `module=character|scene|prop`,前端提供统一批处理范围选择,不再只能执行全项目全部类型。
- 身份文本批处理支持模块筛选;场景/道具新增稳定母版小批量入口,成功后由后端原子设为 Anchor 并锁定 Identity,前端保留部分失败回执。
- 全局 GET `/assets` 已由现有资产库页面使用,无需新增重复页面。
预览结果只用于提交前核对,不作为持久化任务或质量通过证明;真正生成仍需费用确认,并以刷新后的正式资产记录为准。
+18 -12
View File
@@ -15,6 +15,7 @@ import {
} from 'naive-ui' } from 'naive-ui'
import { import {
Clapperboard, Clapperboard,
Archive,
Camera, Camera,
Images, Images,
Palette, Palette,
@@ -22,11 +23,12 @@ import {
FolderOpen, FolderOpen,
FileText, FileText,
Layers, Layers,
Menu, PanelLeftClose,
PanelLeftOpen,
Settings2, Settings2,
X X
} from '@lucide/vue' } from '@lucide/vue'
import { AppDialog } from './components/ui' import { AppDialog, StudioLogo } from './components/ui'
import { useTheme } from './composables/useTheme' import { useTheme } from './composables/useTheme'
import ThemeToggle from './components/ui/ThemeToggle.vue' import ThemeToggle from './components/ui/ThemeToggle.vue'
import { projectAccessKey, type ProjectAccess } from './features/projects/access' import { projectAccessKey, type ProjectAccess } from './features/projects/access'
@@ -124,6 +126,11 @@ const menuOptions = computed<MenuOption[]>(() => {
label: () => h(RouterLink, { to: '/projects' }, () => h('span', { class: 'admin-menu-label' }, '我的剧本')), label: () => h(RouterLink, { to: '/projects' }, () => h('span', { class: 'admin-menu-label' }, '我的剧本')),
icon: createMenuIcon(FolderOpen) icon: createMenuIcon(FolderOpen)
}, },
{
key: '/assets',
label: () => h(RouterLink, { to: '/assets' }, () => h('span', { class: 'admin-menu-label' }, '资产库')),
icon: createMenuIcon(Archive)
},
...(projectId.value ...(projectId.value
? [ ? [
{ type: 'divider' as const, key: 'divider' }, { type: 'divider' as const, key: 'divider' },
@@ -189,7 +196,7 @@ const menuOptions = computed<MenuOption[]>(() => {
:class="{ 'is-collapsed': collapsed }" :class="{ 'is-collapsed': collapsed }"
aria-label="短剧工作台首页" aria-label="短剧工作台首页"
> >
<Clapperboard :size="narrow ? NAV_ICON_SIZE : 24" /><span <StudioLogo :size="narrow ? 23 : 27" /><span class="admin-brand-copy"
>短剧工作台<small>SHORT DRAMA STUDIO</small></span >短剧工作台<small>SHORT DRAMA STUDIO</small></span
> >
</RouterLink> </RouterLink>
@@ -243,7 +250,9 @@ const menuOptions = computed<MenuOption[]>(() => {
aria-controls="main-navigation" aria-controls="main-navigation"
@click="toggleNav" @click="toggleNav"
> >
<template #icon><Menu :size="20" /></template> <template #icon
><component :is="collapsed ? PanelLeftOpen : PanelLeftClose" :size="20"
/></template>
</NButton> </NButton>
<span class="truncate text-sm">{{ route.meta.title || '工作空间' }}</span> <span class="truncate text-sm">{{ route.meta.title || '工作空间' }}</span>
</div> </div>
@@ -356,20 +365,17 @@ const menuOptions = computed<MenuOption[]>(() => {
@apply flex shrink-0 items-center gap-3 h-[74px] overflow-hidden py-0 px-5 text-ink whitespace-nowrap; @apply flex shrink-0 items-center gap-3 h-[74px] overflow-hidden py-0 px-5 text-ink whitespace-nowrap;
min-width: var(--admin-sider-expanded); min-width: var(--admin-sider-expanded);
} }
.admin-brand > svg { .admin-brand-copy {
@apply shrink-0;
}
.admin-brand span {
@apply text-[15px] font-semibold; @apply text-[15px] font-semibold;
opacity: 1; opacity: 1;
transition: transition:
opacity 0.2s ease, opacity 0.2s ease,
transform 0.2s ease; transform 0.2s ease;
} }
.admin-sider.is-collapsed .admin-brand span { .admin-sider.is-collapsed .admin-brand-copy {
opacity: 0; opacity: 0;
} }
.admin-brand small { .admin-brand-copy small {
@apply block text-[8px] tracking-[1px] font-normal text-muted mt-[3px]; @apply block text-[8px] tracking-[1px] font-normal text-muted mt-[3px];
} }
.admin-topbar { .admin-topbar {
@@ -519,7 +525,7 @@ const menuOptions = computed<MenuOption[]>(() => {
.admin-brand { .admin-brand {
@apply h-12 min-w-0 flex-1 justify-start px-3.5; @apply h-12 min-w-0 flex-1 justify-start px-3.5;
} }
.admin-sider.is-collapsed .admin-brand span, .admin-sider.is-collapsed .admin-brand-copy,
.admin-sider.is-collapsed .n-menu-item-content-header, .admin-sider.is-collapsed .n-menu-item-content-header,
.admin-sider.is-collapsed .admin-settings-label { .admin-sider.is-collapsed .admin-settings-label {
opacity: 1; opacity: 1;
@@ -539,7 +545,7 @@ const menuOptions = computed<MenuOption[]>(() => {
.admin-sider.is-collapsed .admin-sider-footer, .admin-sider.is-collapsed .admin-sider-footer,
.admin-sider .n-menu-item-content-header, .admin-sider .n-menu-item-content-header,
.admin-menu-label, .admin-menu-label,
.admin-brand span, .admin-brand-copy,
.admin-settings-label { .admin-settings-label {
transition: none; transition: none;
} }
+5 -7
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NImage } from 'naive-ui' import { NButton } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { ImageOff } from '@lucide/vue' import { ImageOff } from '@lucide/vue'
import { referenceImageUrl } from '../../lib/assets' import { referenceImageUrl } from '../../lib/assets'
import RevealImage from './RevealImage.vue'
/** 图片统一铺满容器;原图预览按场景开启,历史选择按钮不触发预览。 */ /** 图片统一铺满容器;原图预览按场景开启,历史选择按钮不触发预览。 */
const props = withDefaults( const props = withDefaults(
@@ -28,16 +29,12 @@ watch(
<template> <template>
<div class="asset-image"> <div class="asset-image">
<NImage <RevealImage
v-if="url && !failed" v-if="url && !failed"
:src="url" :src="url"
:preview-src="url"
:alt="alt" :alt="alt"
lazy :preview="preview"
:preview-disabled="!preview"
:object-fit="objectFit" :object-fit="objectFit"
:img-props="{ referrerpolicy: 'no-referrer' }"
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
@error="failed = true" @error="failed = true"
/> />
<span v-else class="asset-image-empty" <span v-else class="asset-image-empty"
@@ -63,6 +60,7 @@ watch(
.asset-image-empty { .asset-image-empty {
@apply flex items-center justify-center flex-col gap-2.5 p-5 text-muted text-[11px] text-center; @apply flex items-center justify-center flex-col gap-2.5 p-5 text-muted text-[11px] text-center;
} }
.asset-image .reveal-image,
.asset-image .n-image { .asset-image .n-image {
@apply w-full h-full min-h-0 min-w-0 flex justify-center; @apply w-full h-full min-h-0 min-w-0 flex justify-center;
} }
+8 -2
View File
@@ -2,9 +2,9 @@
import { ref } from 'vue' import { ref } from 'vue'
import { NButton, NCollapse, NCollapseItem } from 'naive-ui' import { NButton, NCollapse, NCollapseItem } from 'naive-ui'
/** 保留说明与诊断的可发现入口,默认不占据主内容区域;阻塞错误不应放入此组件。 */ /** 说明与诊断默认直接展示,同时保留手动收起能力;阻塞错误不应放入此组件。 */
withDefaults(defineProps<{ title?: string }>(), { title: '使用说明' }) withDefaults(defineProps<{ title?: string }>(), { title: '使用说明' })
const expanded = ref<string[]>([]) const expanded = ref<string[]>(['details'])
/** 原生按钮保留 Enter/Space 键盘激活;文字点击不再向外重复触发折叠。 */ /** 原生按钮保留 Enter/Space 键盘激活;文字点击不再向外重复触发折叠。 */
function toggle() { function toggle() {
expanded.value = expanded.value.length ? [] : ['details'] expanded.value = expanded.value.length ? [] : ['details']
@@ -33,6 +33,12 @@ defineExpose({
.detail-disclosure.n-collapse { .detail-disclosure.n-collapse {
@apply text-xs text-muted; @apply text-xs text-muted;
} }
.detail-disclosure .n-button,
.detail-disclosure .n-button:hover,
.detail-disclosure .n-button:focus,
.detail-disclosure .n-button:active {
color: var(--app-ink) !important;
}
.detail-disclosure-content { .detail-disclosure-content {
@apply py-3 px-3.5 bg-(--app-subtle) text-ink leading-[1.8]; @apply py-3 px-3.5 bg-(--app-subtle) text-ink leading-[1.8];
} }
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { NImage } from 'naive-ui'
import { ref, watch } from 'vue'
/** 全局图片加载组件参数。 */
interface Props {
/** 图片地址。 */
src: string
/** 图片替代文本。 */
alt: string
/** 图片在容器内的适应方式。 */
objectFit?: 'contain' | 'cover'
/** 是否允许查看原图。 */
preview?: boolean
/** 是否启用浏览器原生懒加载。 */
lazy?: boolean
}
/** 全局图片加载组件事件。 */
interface Emits {
/** 图片加载失败事件。 */
error: [event: Event]
}
/** 图片展示参数。 */
const props = withDefaults(defineProps<Props>(), { objectFit: 'cover', preview: false, lazy: true })
/** 图片加载事件。 */
const emit = defineEmits<Emits>()
/** 图片是否已经加载完成。 */
const loaded = ref(false)
/** 图片地址变化后恢复加载占位状态。 */
function reset() {
loaded.value = false
}
/** 图片加载完成后直接切换为淡入状态。 */
function reveal() {
loaded.value = true
}
/**
* 转发图片加载失败事件。
* @param event 图片加载失败事件
*/
function fail(event: Event) {
emit('error', event)
}
/** 监听图片地址变化并重置加载状态。 */
watch(() => props.src, reset)
</script>
<template>
<span class="reveal-image" :class="{ 'is-loaded': loaded }" :aria-busy="!loaded">
<NImage
:src="src"
:preview-src="src"
:alt="alt"
:lazy="lazy"
:preview-disabled="!preview"
:object-fit="objectFit"
:img-props="{ referrerpolicy: 'no-referrer' }"
:previewed-img-props="{ alt, referrerpolicy: 'no-referrer' }"
@load="reveal"
@error="fail"
/>
<!-- 以下是 Instagram 风格的图片加载骨架模块 -->
<span v-if="!loaded" class="reveal-image-placeholder" aria-hidden="true" />
</span>
</template>
<style>
@reference "../../styles/styles.css";
.reveal-image {
@apply relative block w-full h-full min-w-0 min-h-0 overflow-hidden bg-(--app-subtle);
}
.reveal-image > .n-image,
.reveal-image > .n-image img {
@apply w-full h-full min-w-0 min-h-0;
}
.reveal-image > .n-image img {
opacity: 0;
transition: opacity 220ms cubic-bezier(0.25, 1, 0.5, 1);
}
.reveal-image.is-loaded > .n-image img {
opacity: 1;
}
.reveal-image-placeholder {
@apply absolute inset-0 pointer-events-none;
background-color: var(--app-control);
animation: reveal-image-pulse 1.2s cubic-bezier(0.65, 0, 0.35, 1) infinite alternate;
}
@keyframes reveal-image-pulse {
from {
opacity: 0.72;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.reveal-image > .n-image img,
.reveal-image-placeholder {
animation: none;
transition: none;
}
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { Film, Sparkles } from '@lucide/vue'
withDefaults(defineProps<{ size?: number }>(), { size: 28 })
</script>
<template>
<span class="studio-logo" :style="{ width: `${size}px`, height: `${size}px` }" aria-hidden="true">
<Film class="studio-logo-film" :size="size" :stroke-width="1.8" />
<Sparkles class="studio-logo-ai" :size="Math.round(size * 0.46)" :stroke-width="2.4" />
</span>
</template>
<style scoped>
.studio-logo {
position: relative;
display: inline-grid;
flex: 0 0 auto;
place-items: center;
color: var(--app-ink);
}
.studio-logo-film {
display: block;
}
.studio-logo-ai {
position: absolute;
top: -12%;
right: -15%;
color: var(--app-accent-text);
fill: var(--app-subtle);
}
</style>
+2
View File
@@ -2,6 +2,8 @@
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 StudioLogo } from './StudioLogo.vue'
export { default as AssetImage } from './AssetImage.vue' export { default as AssetImage } from './AssetImage.vue'
export { default as RevealImage } from './RevealImage.vue'
export { default as DirectoryItem } from './DirectoryItem.vue' export { default as DirectoryItem } from './DirectoryItem.vue'
export { default as WorkspaceTools } from './WorkspaceTools.vue' export { default as WorkspaceTools } from './WorkspaceTools.vue'
+1
View File
@@ -78,3 +78,4 @@ export function usePolling<T>(
}) })
return { data, loading, error, updatedAt, refresh } return { data, loading, error, updatedAt, refresh }
} }
+51
View File
@@ -0,0 +1,51 @@
import { onScopeDispose, ref, watch, type Ref } from 'vue'
import { errorMessage } from '../lib/http'
/** 异步查询仅在首次加载、查询目标变化或显式刷新时请求,切换目标会取消过期响应。 */
export function useQuery<T>(key: Ref<string>, loader: (key: string, signal: AbortSignal) => Promise<T>) {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(false)
const error = ref('')
const updatedAt = ref('')
let generation = 0
let controller: AbortController | undefined
let disposed = false
/** 取消上一次查询并读取当前目标,失败时保留已有数据。 */
async function refresh() {
if (disposed) return
const current = ++generation
controller?.abort()
controller = new AbortController()
const signal = controller.signal
loading.value = true
try {
const result = await loader(key.value, signal)
if (current !== generation || disposed) return
data.value = result
error.value = ''
updatedAt.value = new Date().toISOString()
} catch (cause) {
if (current === generation && !signal.aborted && !disposed) error.value = errorMessage(cause)
} finally {
if (current === generation && !disposed) loading.value = false
}
}
watch(
key,
() => {
data.value = null
error.value = ''
updatedAt.value = ''
void refresh()
},
{ immediate: true }
)
onScopeDispose(() => {
disposed = true
generation++
controller?.abort()
})
return { data, loading, error, updatedAt, refresh }
}
@@ -58,28 +58,29 @@ const filtered = computed(() =>
> >
{{ subject.description }} {{ subject.description }}
</p> </p>
<NButton <div v-if="(subject.description?.length ?? 0) > 160 || projectId" class="subject-record-actions">
v-if="(subject.description?.length ?? 0) > 160" <NButton
text v-if="(subject.description?.length ?? 0) > 160"
size="small" text
class="mt-2" size="small"
:aria-expanded="expanded.has(subject.profileId)" :aria-expanded="expanded.has(subject.profileId)"
@click="toggleDescription(subject.profileId)" @click="toggleDescription(subject.profileId)"
>{{ expanded.has(subject.profileId) ? '收起介绍' : '展开完整介绍' }}</NButton >{{ expanded.has(subject.profileId) ? '收起介绍' : '展开完整介绍' }}</NButton
> >
<RouterLink <RouterLink
v-if="projectId" v-if="projectId"
:to="{ path: `/projects/${projectId}/subject-identity`, query: { subjectRef: subject.ref } }" :to="{ path: `/projects/${projectId}/subject-identity`, query: { subjectRef: subject.ref } }"
class="text-button mt-3 mr-4" class="text-button"
>管理稳定身份母版</RouterLink >管理稳定身份母版</RouterLink
> >
<RouterLink <RouterLink
v-if="projectId" v-if="projectId"
:to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }" :to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }"
class="text-button mt-3" class="text-button"
>查看形态图片生图</RouterLink >查看形态图片生图</RouterLink
> >
<NCollapse class="subject-details mt-4" </div>
<NCollapse class="subject-details mt-4" :default-expanded-names="['details']"
><NCollapseItem name="details" ><NCollapseItem name="details"
><template #header ><template #header
>外观描述与形态 >外观描述与形态
@@ -128,6 +129,9 @@ const filtered = computed(() =>
.subject-form-card { .subject-form-card {
@apply mt-3 p-4 min-w-0 wrap-anywhere bg-(--app-subtle); @apply mt-3 p-4 min-w-0 wrap-anywhere bg-(--app-subtle);
} }
.subject-record-actions {
@apply mt-4 flex flex-wrap items-center gap-y-2 gap-x-4;
}
.subject-record-list > article:nth-child(even) .subject-form-card { .subject-record-list > article:nth-child(even) .subject-form-card {
@apply bg-(--app-control); @apply bg-(--app-control);
} }
+9 -12
View File
@@ -135,17 +135,11 @@ function exportScript() {
]" ]"
@select="$event === 'history' ? (showHistory = !showHistory) : exportScript()" @select="$event === 'history' ? (showHistory = !showHistory) : exportScript()"
/> />
<RouterLink
v-if="complete"
class="text-button text-accent script-next-link"
:to="`/projects/${project?.id}/breakdown`"
>进入拆解<ArrowRight :size="14"
/></RouterLink>
</div> </div>
</template> </template>
</NTabs> </NTabs>
</div> </div>
<div class="content-with-history panel mt-4" :class="{ 'history-hidden': !showHistory }"> <div class="content-with-history panel mt-2" :class="{ 'history-hidden': !showHistory }">
<main class="min-w-0"> <main class="min-w-0">
<div v-show="tab === 'episodes'" class="h-full"> <div v-show="tab === 'episodes'" class="h-full">
<EpisodeReader v-if="episodes.length" :episodes="episodes" /> <EpisodeReader v-if="episodes.length" :episodes="episodes" />
@@ -198,7 +192,10 @@ function exportScript() {
<dd>{{ project.world[field.key] || '未提供' }}</dd></template <dd>{{ project.world[field.key] || '未提供' }}</dd></template
> >
</dl> </dl>
<NCollapse v-if="project.world.rules" class="mt-6 text-sm" <NCollapse
v-if="project.world.rules"
class="mt-6 text-sm"
:default-expanded-names="['details']"
><NCollapseItem name="details" ><NCollapseItem name="details"
><template #header>世界规则</template> ><template #header>世界规则</template>
<NScrollbar class="code-scroll mt-3" x-scrollable> <NScrollbar class="code-scroll mt-3" x-scrollable>
@@ -219,7 +216,10 @@ function exportScript() {
v-else v-else
title="暂无可用审核结果" title="暂无可用审核结果"
description="项目详情不再提供审核记录;可在执行记录中查看工作流状态。" description="项目详情不再提供审核记录;可在执行记录中查看工作流状态。"
/><NCollapse v-if="state?.rewriteSuggestion" class="mt-5 text-sm" /><NCollapse
v-if="state?.rewriteSuggestion"
class="mt-5 text-sm"
:default-expanded-names="['details']"
><NCollapseItem name="details" ><NCollapseItem name="details"
><template #header>查看改写建议原文</template> ><template #header>查看改写建议原文</template>
<NScrollbar class="code-scroll mt-3" x-scrollable> <NScrollbar class="code-scroll mt-3" x-scrollable>
@@ -298,9 +298,6 @@ function exportScript() {
.script-section .tabs-toolbar-actions { .script-section .tabs-toolbar-actions {
@apply min-h-0 flex-nowrap; @apply min-h-0 flex-nowrap;
} }
.script-section .tabs-toolbar-actions .script-next-link {
@apply hidden;
}
.script-section .content-with-history:not(.history-hidden) { .script-section .content-with-history:not(.history-hidden) {
@apply relative; @apply relative;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
+5 -1
View File
@@ -229,6 +229,7 @@ onBeforeUnmount(() => observer?.disconnect())
@apply outline-[2px_solid_var(--app-accent)] outline-offset-[-3px]; @apply outline-[2px_solid_var(--app-accent)] outline-offset-[-3px];
} }
.reader-content { .reader-content {
--reader-gutter: 32px;
@apply mx-0 p-8 text-left; @apply mx-0 p-8 text-left;
/* 正文紧邻目录左对齐,保留阅读行宽,不在宽屏内容区居中。 */ /* 正文紧邻目录左对齐,保留阅读行宽,不在宽屏内容区居中。 */
max-width: 900px; max-width: 900px;
@@ -241,7 +242,9 @@ onBeforeUnmount(() => observer?.disconnect())
min-height: max(160px, calc(var(--reader-height) - 64px)); min-height: max(160px, calc(var(--reader-height) - 64px));
} }
.reader-chapter-heading { .reader-chapter-heading {
@apply mb-6; @apply sticky top-0 z-[2] mb-6 bg-(--app-surface);
margin-inline: calc(var(--reader-gutter) * -1);
padding: 14px var(--reader-gutter) 18px;
} }
.reader-chapter-heading p { .reader-chapter-heading p {
@apply text-muted text-[13px] font-medium mb-2.5; @apply text-muted text-[13px] font-medium mb-2.5;
@@ -277,6 +280,7 @@ onBeforeUnmount(() => observer?.disconnect())
@apply text-[11px]; @apply text-[11px];
} }
.reader-content { .reader-content {
--reader-gutter: 18px;
@apply px-[18px]; @apply px-[18px];
} }
} }
+70
View File
@@ -0,0 +1,70 @@
import { request } from '../../lib/http'
import type {
CreativeProfile,
CreativeProfileInput,
GenerationMediaType,
GenerationModelCatalog,
GenerationOverride,
GenerationOverrideInput,
GenerationOverrideTargetType,
ResolvedGenerationConfig
} from './types'
function projectPath(projectId: string) {
return `/projects/${encodeURIComponent(projectId)}`
}
function overridePath(
projectId: string,
targetType: GenerationOverrideTargetType,
targetId: string,
mediaType: GenerationMediaType
) {
return `${projectPath(projectId)}/generation-overrides/${targetType}/${encodeURIComponent(targetId)}/${mediaType}`
}
export const generationConfigApi = {
catalog: (signal?: AbortSignal) => request<GenerationModelCatalog>('/generation-models', { signal }),
profile: (projectId: string, signal?: AbortSignal) =>
request<CreativeProfile | null>(`${projectPath(projectId)}/creative-profile`, { signal }),
saveProfile: (projectId: string, input: CreativeProfileInput) =>
request<CreativeProfile>(`${projectPath(projectId)}/creative-profile`, { method: 'PUT', body: input }),
resolve: (
projectId: string,
mediaType: GenerationMediaType,
input: {
provider?: string
model?: string
options?: Record<string, unknown>
target?: { targetType: GenerationOverrideTargetType; targetId: string }
}
) =>
request<ResolvedGenerationConfig>(`${projectPath(projectId)}/generation-config/${mediaType}/resolve`, {
method: 'POST',
body: input
}),
override: (
projectId: string,
targetType: GenerationOverrideTargetType,
targetId: string,
mediaType: GenerationMediaType,
signal?: AbortSignal
) => request<GenerationOverride | null>(overridePath(projectId, targetType, targetId, mediaType), { signal }),
saveOverride: (
projectId: string,
targetType: GenerationOverrideTargetType,
targetId: string,
mediaType: GenerationMediaType,
input: GenerationOverrideInput
) =>
request<GenerationOverride>(overridePath(projectId, targetType, targetId, mediaType), {
method: 'PUT',
body: input
}),
deleteOverride: (
projectId: string,
targetType: GenerationOverrideTargetType,
targetId: string,
mediaType: GenerationMediaType
) => request<unknown>(overridePath(projectId, targetType, targetId, mediaType), { method: 'DELETE' })
}
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { computed, onScopeDispose, ref, watch } from 'vue'
import { NAlert, NButton, NCollapse, NCollapseItem, NFormItem, NSelect, NSpin } from 'naive-ui'
import { errorMessage } from '../../../lib/http'
import { generationConfigApi } from '../api'
import type {
CreativeProfileAspectRatio,
CreativeProfileInput,
GenerationModelCatalog,
GenerationModelItem,
GenerationOptions
} from '../types'
import GenerationOptionFields from './GenerationOptionFields.vue'
const props = defineProps<{ projectId: string }>()
const emit = defineEmits<{ saved: [profile: CreativeProfileInput] }>()
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const notice = ref('')
const catalog = ref<GenerationModelCatalog | null>(null)
const profile = ref<CreativeProfileInput | null>(null)
let controller: AbortController | null = null
const aspectRatios = ['9:16', '16:9', '1:1', '4:3', '3:4'].map(value => ({ label: value, value }))
const enabledImages = computed(() => catalog.value?.images?.filter(item => item.enabled && item.model) ?? [])
const enabledVideos = computed(() => catalog.value?.videos?.filter(item => item.enabled && item.model) ?? [])
const imageSelection = computed(() =>
profile.value ? selectionValue(profile.value.imageProvider, profile.value.imageModel) : null
)
const videoSelection = computed(() =>
profile.value ? selectionValue(profile.value.videoProvider, profile.value.videoModel) : null
)
const imageModel = computed(() =>
enabledImages.value.find(item =>
profile.value ? item.provider === profile.value.imageProvider && item.model === profile.value.imageModel : false
)
)
const videoModel = computed(() =>
enabledVideos.value.find(item =>
profile.value ? item.provider === profile.value.videoProvider && item.model === profile.value.videoModel : false
)
)
function selectionValue(provider: string, model: string) {
return `${provider}\u0000${model}`
}
function modelOptions(items: GenerationModelItem[]) {
return items.map(item => ({
label: `${item.providerLabel} · ${item.modelLabel}`,
value: selectionValue(item.provider, item.model || '')
}))
}
function defaultOptions(item: GenerationModelItem): GenerationOptions {
return Object.fromEntries(
item.generationOptions.flatMap(schema => {
if (schema.providerDefault !== undefined) return [[schema.key, schema.providerDefault]]
if (!schema.required) return []
if (schema.type === 'boolean') return [[schema.key, false]]
if (schema.type === 'select' && schema.options?.length) {
const first = schema.options[0]
return first ? [[schema.key, first.value]] : []
}
if ((schema.type === 'integer' || schema.type === 'number') && schema.min !== undefined)
return [[schema.key, schema.min]]
if (schema.type === 'size') return [[schema.key, 'auto']]
return []
})
)
}
function selectModel(type: 'image' | 'video', value: string) {
if (!profile.value) return
const items = type === 'image' ? enabledImages.value : enabledVideos.value
const item = items.find(row => selectionValue(row.provider, row.model || '') === value)
if (!item?.model) return
if (type === 'image') {
profile.value.imageProvider = item.provider
profile.value.imageModel = item.model
profile.value.imageOptions = defaultOptions(item)
} else {
profile.value.videoProvider = item.provider
profile.value.videoModel = item.model
profile.value.videoOptions = defaultOptions(item)
}
}
function createInitial(nextCatalog: GenerationModelCatalog): CreativeProfileInput | null {
const image =
nextCatalog.images.find(item => item.enabled && item.isDefault && item.model) ??
nextCatalog.images.find(item => item.enabled && item.model)
const video =
nextCatalog.videos.find(item => item.enabled && item.isDefault && item.model) ??
nextCatalog.videos.find(item => item.enabled && item.model)
if (!image?.model || !video?.model) return null
return {
aspectRatio: '9:16',
imageProvider: image.provider,
imageModel: image.model,
imageOptions: defaultOptions(image),
videoProvider: video.provider,
videoModel: video.model,
videoOptions: defaultOptions(video)
}
}
async function load() {
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = ''
notice.value = ''
try {
const [nextCatalog, saved] = await Promise.all([
generationConfigApi.catalog(controller.signal),
generationConfigApi.profile(props.projectId, controller.signal)
])
catalog.value = nextCatalog
profile.value = saved
? {
aspectRatio: saved.aspectRatio,
imageProvider: saved.imageProvider,
imageModel: saved.imageModel,
imageOptions: { ...saved.imageOptions },
videoProvider: saved.videoProvider,
videoModel: saved.videoModel,
videoOptions: { ...saved.videoOptions }
}
: createInitial(nextCatalog)
} catch (cause) {
if (!controller.signal.aborted) error.value = errorMessage(cause)
} finally {
if (!controller.signal.aborted) loading.value = false
}
}
async function save() {
if (!profile.value || saving.value) return
saving.value = true
error.value = ''
notice.value = ''
try {
const saved = await generationConfigApi.saveProfile(props.projectId, profile.value)
profile.value = {
aspectRatio: saved.aspectRatio,
imageProvider: saved.imageProvider,
imageModel: saved.imageModel,
imageOptions: { ...saved.imageOptions },
videoProvider: saved.videoProvider,
videoModel: saved.videoModel,
videoOptions: { ...saved.videoOptions }
}
notice.value = '项目生成配置已保存,后续首帧和视频生成会继承此配置。'
emit('saved', profile.value)
} catch (cause) {
error.value = errorMessage(cause)
} finally {
saving.value = false
}
}
watch(() => props.projectId, load, { immediate: true })
onScopeDispose(() => controller?.abort())
</script>
<template>
<NCollapse class="production-tool-section creative-profile-settings" :default-expanded-names="['creative-profile']">
<NCollapseItem name="creative-profile" title="项目画布与生成模型">
<NSpin :show="loading">
<NAlert v-if="error" type="error" :show-icon="false" class="mb-3">{{ error }}</NAlert>
<NAlert v-if="notice" type="success" :show-icon="false" class="mb-3">{{ notice }}</NAlert>
<NAlert
v-if="!loading && (!enabledImages.length || !enabledVideos.length)"
type="warning"
:show-icon="false"
>
后端尚未启用完整的图片和视频模型暂时无法保存项目生成配置
</NAlert>
<div v-if="profile" class="creative-profile-grid">
<NFormItem label="作品宽高比">
<NSelect
:value="profile.aspectRatio"
:options="aspectRatios"
:disabled="saving"
@update:value="profile.aspectRatio = $event as CreativeProfileAspectRatio"
/>
</NFormItem>
<div></div>
<section class="model-section">
<NFormItem label="图片模型">
<NSelect
:value="imageSelection"
:options="modelOptions(enabledImages)"
:disabled="saving"
@update:value="selectModel('image', $event)"
/>
</NFormItem>
<GenerationOptionFields
v-model="profile.imageOptions"
:schemas="imageModel?.generationOptions || []"
:disabled="saving"
/>
</section>
<section class="model-section">
<NFormItem label="视频模型">
<NSelect
:value="videoSelection"
:options="modelOptions(enabledVideos)"
:disabled="saving"
@update:value="selectModel('video', $event)"
/>
</NFormItem>
<GenerationOptionFields
v-model="profile.videoOptions"
:schemas="videoModel?.generationOptions || []"
:disabled="saving"
/>
</section>
</div>
<div class="page-form-actions creative-profile-actions">
<NButton
type="primary"
:loading="saving"
:disabled="loading || !profile || !enabledImages.length || !enabledVideos.length"
@click="save"
>保存项目生成配置</NButton
>
<span class="text-xs text-muted">切换模型时会按新模型重置生成参数</span>
</div>
</NSpin>
</NCollapseItem>
</NCollapse>
</template>
<style scoped>
@reference "../../../styles/styles.css";
.creative-profile-grid {
@apply grid grid-cols-[repeat(2,_minmax(0,_1fr))] items-start gap-x-5 gap-y-6;
}
.model-section {
@apply min-w-0 p-4 bg-(--app-subtle);
}
.model-section > :deep(.n-form-item) {
@apply mb-6;
}
.creative-profile-grid > :deep(.n-form-item--top-labelled),
.model-section > :deep(.n-form-item--top-labelled) {
grid-template-rows: minmax(var(--n-label-height), auto) auto auto;
}
.creative-profile-grid > :deep(.n-form-item-label),
.model-section > :deep(.n-form-item-label) {
min-height: 30px;
padding: 0 0 8px;
}
.creative-profile-grid > :deep(.n-form-item-blank),
.model-section > :deep(.n-form-item-blank) {
min-height: 34px;
align-items: center;
}
.creative-profile-settings {
@apply mb-7;
}
.creative-profile-actions {
@apply mt-5 pt-1;
}
@media (max-width: 900px) {
.creative-profile-grid {
@apply grid-cols-[minmax(0,_1fr)];
}
.creative-profile-grid > div:empty {
@apply hidden;
}
}
</style>
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { NCheckbox, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
import type { GenerationOptionSchema, GenerationOptions } from '../types'
defineProps<{ schemas: GenerationOptionSchema[]; modelValue: GenerationOptions; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: GenerationOptions] }>()
function update(model: GenerationOptions, key: string, value: string | number | boolean | null) {
const next = { ...model }
if (value === null || value === '') delete next[key]
else next[key] = value
emit('update:modelValue', next)
}
function selectValue(value: string | number | boolean | undefined) {
return typeof value === 'string' || typeof value === 'number' ? value : null
}
function selectOptions(schema: GenerationOptionSchema) {
return [...(schema.options || []), ...(schema.specialValues || [])].flatMap(option =>
typeof option.value === 'string' || typeof option.value === 'number'
? [{ label: option.label, value: option.value }]
: []
)
}
function isDuration(schema: GenerationOptionSchema) {
return /duration|时长/i.test(`${schema.key} ${schema.label}`)
}
function optionKey(value: string | number) {
return `${typeof value}:${value}`
}
/** 尺寸和时长使用有限选项,避免用户输入 Provider 不支持的自由值。 */
function constrainedOptions(schema: GenerationOptionSchema, current: string | number | boolean | undefined) {
const options = selectOptions(schema)
if (schema.type === 'size') {
for (const value of ['1K', '1.5K', '2K']) options.push({ label: value, value })
} else if (
isDuration(schema) &&
Number.isInteger(schema.min) &&
Number.isInteger(schema.max) &&
schema.max! >= schema.min! &&
schema.max! - schema.min! <= 60
) {
for (let value = schema.min!; value <= schema.max!; value++) {
options.push({ label: `${value}`, value })
}
}
if (typeof schema.providerDefault === 'string' || typeof schema.providerDefault === 'number') {
options.push({ label: String(schema.providerDefault), value: schema.providerDefault })
}
if (typeof current === 'string' || typeof current === 'number') {
options.push({ label: String(current), value: current })
}
const seen = new Set<string>()
return options.filter(option => {
const key = optionKey(option.value)
if (seen.has(key)) return false
seen.add(key)
return true
})
}
</script>
<template>
<div v-if="schemas.length" class="generation-option-grid">
<NFormItem
v-for="schema in schemas"
:key="schema.key"
:label="`${schema.label}${schema.required ? ' *' : ''}`"
:feedback="schema.description"
:show-feedback="!!schema.description"
>
<NCheckbox
v-if="schema.type === 'boolean'"
:checked="modelValue[schema.key] === true"
:disabled="disabled"
@update:checked="update(modelValue, schema.key, $event)"
>启用</NCheckbox
>
<NSelect
v-else-if="schema.type === 'select' || schema.type === 'size' || isDuration(schema)"
:value="selectValue(modelValue[schema.key])"
:options="constrainedOptions(schema, modelValue[schema.key])"
:disabled="disabled"
:clearable="!schema.required"
@update:value="update(modelValue, schema.key, $event)"
/>
<NInputNumber
v-else-if="schema.type === 'integer' || schema.type === 'number'"
:value="typeof modelValue[schema.key] === 'number' ? (modelValue[schema.key] as number) : null"
:min="schema.min"
:max="schema.max"
:precision="schema.type === 'integer' ? 0 : undefined"
:disabled="disabled"
clearable
@update:value="update(modelValue, schema.key, $event)"
/>
<NInput
v-else
:value="typeof modelValue[schema.key] === 'string' ? (modelValue[schema.key] as string) : ''"
:disabled="disabled"
placeholder="例如 2048x2048 或 auto"
clearable
@update:value="update(modelValue, schema.key, $event)"
/>
</NFormItem>
</div>
<p v-else class="text-xs text-muted">当前模型没有开放额外生成参数</p>
</template>
<style scoped>
@reference "../../../styles/styles.css";
.generation-option-grid {
@apply grid grid-cols-[repeat(2,_minmax(0,_1fr))] items-start gap-x-4 gap-y-6;
}
.generation-option-grid :deep(.n-form-item) {
@apply min-w-0 mb-0 self-start;
}
/* Naive 默认使用 1fr 扩展控件行;同排说明文字不等高时会把较短字段的控件推到底部。 */
.generation-option-grid :deep(.n-form-item--top-labelled) {
grid-template-rows: minmax(var(--n-label-height), auto) auto auto;
}
.generation-option-grid :deep(.n-form-item-label) {
min-height: 30px;
padding: 0 0 8px;
}
.generation-option-grid :deep(.n-form-item-blank) {
min-height: 34px;
align-items: center;
}
.generation-option-grid :deep(.n-form-item-feedback-wrapper:not(:empty)) {
min-height: 0;
padding: 8px 0 0;
line-height: 1.55;
}
@media (max-width: 760px) {
.generation-option-grid {
@apply grid-cols-[minmax(0,_1fr)];
}
}
</style>
+80
View File
@@ -0,0 +1,80 @@
export type CreativeProfileAspectRatio = '9:16' | '16:9' | '1:1' | '4:3' | '3:4'
export type GenerationOptionValue = string | number | boolean
export type GenerationOptions = Record<string, GenerationOptionValue>
export interface GenerationOptionChoice {
label: string
value: GenerationOptionValue
}
export interface GenerationOptionSchema {
key: string
label: string
type: 'boolean' | 'integer' | 'number' | 'select' | 'size'
required: boolean
options?: GenerationOptionChoice[]
min?: number
max?: number
specialValues?: GenerationOptionChoice[]
providerDefault?: GenerationOptionValue
description?: string
}
export interface GenerationModelItem {
type: 'image' | 'video'
provider: string
providerLabel: string
model: string | null
modelLabel: string
supported: boolean
enabled: boolean
isDefault: boolean
capabilities: Record<string, unknown>
generationOptions: GenerationOptionSchema[]
}
export interface GenerationModelCatalog {
defaults: { imageProvider: string; videoProvider: string }
images: GenerationModelItem[]
videos: GenerationModelItem[]
}
export interface CreativeProfileInput {
aspectRatio: CreativeProfileAspectRatio
imageProvider: string
imageModel: string
imageOptions: GenerationOptions
videoProvider: string
videoModel: string
videoOptions: GenerationOptions
}
export interface CreativeProfile extends CreativeProfileInput {
id?: string
projectId: string
createdAt?: string
updatedAt?: string
}
export interface ResolvedGenerationConfig {
provider: string
model: string
options: GenerationOptions
}
export type GenerationOverrideTargetType = 'shot' | 'asset'
export type GenerationMediaType = 'image' | 'video'
export interface GenerationOverrideInput {
provider?: string | null
model?: string | null
options: GenerationOptions
}
export interface GenerationOverride extends GenerationOverrideInput {
projectId: string
targetType: GenerationOverrideTargetType
targetId: string
mediaType: GenerationMediaType
}
+334 -251
View File
@@ -30,6 +30,8 @@ import { routeLocationKey } from 'vue-router'
import { queryText } from './asset-links' import { queryText } from './asset-links'
import QualityDialog from './components/QualityDialog.vue' import QualityDialog from './components/QualityDialog.vue'
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue' import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
import CreativeProfileSettings from '../generation-config/components/CreativeProfileSettings.vue'
import EpisodeAssemblyPanel from './components/EpisodeAssemblyPanel.vue'
const qualityOpen = ref(false) const qualityOpen = ref(false)
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */ /** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
@@ -167,6 +169,17 @@ watch(
if (receipt) toolsOpen.value = true if (receipt) toolsOpen.value = true
} }
) )
watch(
() => route?.query.configure,
value => {
if (value === 'profile') toolsOpen.value = true
},
{ immediate: true }
)
function profileSaved() {
operation.value.error = ''
}
const batchModel = computed(() => ({ concurrency: concurrency.value })) const batchModel = computed(() => ({ concurrency: concurrency.value }))
const batchRules = { concurrency: integerRule('批量并发') } const batchRules = { concurrency: integerRule('批量并发') }
const keyframeModel = computed(() => ({ const keyframeModel = computed(() => ({
@@ -225,8 +238,9 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
label="批量生产" label="批量生产"
:has-receipt="!!session.receipt || !!session.pipelineReceipt" :has-receipt="!!session.receipt || !!session.pipelineReceipt"
> >
<div class="flex flex-wrap items-end justify-between gap-4"> <div class="production-tools-intro">
<div> <div>
<p class="eyebrow">PRODUCTION</p>
<h2 class="text-lg font-semibold">镜头生产</h2> <h2 class="text-lg font-semibold">镜头生产</h2>
<p class="mt-2 text-sm text-muted"> <p class="mt-2 text-sm text-muted">
从可生成的提示词和首帧到异步视频任务与最终成片 从可生成的提示词和首帧到异步视频任务与最终成片
@@ -236,7 +250,13 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
<ArrowLeft :size="14" />回到分镜设计 <ArrowLeft :size="14" />回到分镜设计
</RouterLink> </RouterLink>
</div> </div>
<section class="mt-3" aria-label="项目生产配置"> <section class="production-batch-config" aria-label="项目生产配置">
<div class="production-section-heading">
<div>
<h3 class="text-sm font-semibold">批量执行设置</h3>
<p class="mt-1 text-xs text-muted">控制本次手动批处理的并发与覆盖方式</p>
</div>
</div>
<AppForm <AppForm
:model="batchModel" :model="batchModel"
:rules="batchRules" :rules="batchRules"
@@ -262,272 +282,285 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
</NCheckbox> </NCheckbox>
</div> </div>
</AppForm> </AppForm>
<DetailDisclosure title="处理范围与覆盖规则" class="mt-3" </section>
<div class="production-tool-stack">
<CreativeProfileSettings :project-id="id" @saved="profileSaved" />
<DetailDisclosure title="处理范围与覆盖规则" class="production-tool-section"
><p class="text-xs leading-6 text-muted"> ><p class="text-xs leading-6 text-muted">
提示词与视频就绪检查面向全项目视频实际提交受本批镜头上限控制并使用严格质量流水线首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选图片模型视频模型与视觉校验均可能产生费用 提示词与视频就绪检查面向全项目视频实际提交受本批镜头上限控制并使用严格质量流水线首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选图片模型视频模型与视觉校验均可能产生费用
</p></DetailDisclosure </p></DetailDisclosure
> >
</section> <div class="workspace-overview-panel">
<div class="workspace-overview-panel"> <NCollapse v-model:expanded-names="overview" class="production-tool-section"
<NCollapse v-model:expanded-names="overview" ><NCollapseItem name="overview" title="项目生产总览与批量操作"
><NCollapseItem name="overview" title="项目生产总览与批量操作" ><div class="overview-content">
><div class="overview-content"> <NAlert
<NAlert v-if="videoRunning"
v-if="videoRunning" role="status"
role="status" type="info"
type="info" :show-icon="false"
:show-icon="false" class="mt-4"
class="mt-4"
>
当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询
Provider同一镜头不会重复提交活动任务
</NAlert>
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
当前有 {{ identityBlocked }} 项角色身份前置问题Character 必须生成
Identity确认演员母版并锁定后首帧才能就绪
<RouterLink
:to="`/projects/${id}/subject-identity`"
class="text-button ml-2"
>前往角色选角 </RouterLink
></NAlert
>
<NAlert
v-if="staleKeyframes"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有
{{ staleKeyframes }}
个主首帧已过期它们使用的主体参考资产已经变化视频阶段会被阻止可在下方逐镜重新生成或使用补齐
/ 更新主首帧批量处理
</NAlert>
<NAlert
v-if="staleVideos"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有
{{ staleVideos }}
个主视频已过期它们使用的主首帧或主体参考图已经变化非覆盖模式批量生成时会只重建这些过期主视频
</NAlert>
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
<div v-if="query.data.value" class="production-pipeline mt-5">
<article
v-for="stage in stages"
:key="stage.key"
class="panel production-pipeline-card"
> >
<div class="flex items-start justify-between gap-3"> 当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询
<div class="flex items-start gap-3"> Provider同一镜头不会重复提交活动任务
<span class="production-step" </NAlert>
><component :is="stage.icon" :size="17" <NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
/></span> 当前有 {{ identityBlocked }} 项角色身份前置问题Character 必须生成
<div> Identity确认演员母版并锁定后首帧才能就绪
<p class="eyebrow">STEP {{ stage.code }}</p> <RouterLink
<h3 class="mt-2 text-sm font-semibold"> :to="`/projects/${id}/subject-identity`"
{{ stage.title }} class="text-button ml-2"
</h3> >前往角色选角 </RouterLink
></NAlert
>
<NAlert
v-if="staleKeyframes"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有
{{ staleKeyframes }}
个主首帧已过期它们使用的主体参考资产已经变化视频阶段会被阻止可在下方逐镜重新生成或使用补齐
/ 更新主首帧批量处理
</NAlert>
<NAlert
v-if="staleVideos"
role="status"
type="info"
:show-icon="false"
class="mt-4"
>
当前有
{{ staleVideos }}
个主视频已过期它们使用的主首帧或主体参考图已经变化非覆盖模式批量生成时会只重建这些过期主视频
</NAlert>
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
<div v-if="query.data.value" class="production-pipeline mt-5">
<article
v-for="stage in stages"
:key="stage.key"
class="panel production-pipeline-card"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3">
<span class="production-step"
><component :is="stage.icon" :size="17"
/></span>
<div>
<p class="eyebrow">STEP {{ stage.code }}</p>
<h3 class="mt-2 text-sm font-semibold">
{{ stage.title }}
</h3>
</div>
</div> </div>
<span class="font-mono text-xs text-muted"
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span
>
</div> </div>
<span class="font-mono text-xs text-muted" <p class="mt-4 min-h-10 text-xs leading-5 text-muted">
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span {{ stage.description }}
> </p>
</div> <NProgress
<p class="mt-4 min-h-10 text-xs leading-5 text-muted"> :aria-label="`${stage.title}完成数量`"
{{ stage.description }} type="line"
</p> :show-indicator="false"
<NProgress :height="4"
:aria-label="`${stage.title}完成数量`" :percentage="
type="line" Math.min(
:show-indicator="false" 100,
:height="4" Math.max(
:percentage=" 0,
Math.min( (stage.done /
100, Math.max(
Math.max( 1,
0, Math.max(stage.data?.total ?? 0, 1)
(stage.done / )) *
Math.max(1, Math.max(stage.data?.total ?? 0, 1))) * 100
100 )
) )
) "
" class="mt-4"
class="mt-4" ></NProgress>
></NProgress> <dl class="production-stats mt-4">
<dl class="production-stats mt-4"> <div>
<dt>可执行</dt>
<dd>{{ stage.data?.ready ?? 0 }}</dd>
</div>
<div>
<dt>已有结果</dt>
<dd>{{ stage.data?.skipped ?? 0 }}</dd>
</div>
<div>
<dt>阻塞</dt>
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
</div>
</dl>
<p
v-if="stage.key === 'keyframes' && staleKeyframes"
class="mt-3 text-[11px] leading-5"
>
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
</p>
<p
v-if="stage.key === 'videos' && staleVideos"
class="mt-3 text-[11px] leading-5"
>
主视频已过期 {{ staleVideos }} ·
会按当前主首帧与参考资产重新生成
</p>
<p
v-if="stage.key === 'keyframes' && identityBlocked"
class="mt-3 text-[11px] leading-5 text-danger"
>
身份缺失
{{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
母版缺失
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} ·
未锁定
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
</p>
<ConfirmAction
class="mt-4"
:label="stage.action"
:disabled="
blocked ||
!batchValid ||
(stage.key === 'keyframes' && !keyframeValid) ||
(stage.key === 'videos' && !videoQualityValid) ||
!(stage.data?.ready ?? 0)
"
acknowledgement
:description="
stage.key === 'videos'
? staleVideos && !force
? `当前存在过期主视频,非覆盖模式只会为其中至多 ${videoLimit || '—'} 个镜头启动严格质量流水线;候选自动校验并有限修复,通过后才接替旧主视频。`
: `只为至多 ${videoLimit || '—'} 个就绪镜头启动严格质量流水线。任务创建后由后端自动校验,最多修复 ${videoRepairAttempts || '—'} 次,通过后才晋升主视频。`
: stage.key === 'keyframes'
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
"
:primary="stage.key === 'videos'"
@confirm="run(stage.key)"
/>
<AppForm
v-if="stage.key === 'keyframes'"
:model="keyframeModel"
:rules="keyframeRules"
:disabled="operation.pending"
validate-on-change
class="mt-4 grid gap-3"
>
<NFormItem path="keyframeLimit" label="本批上限(可选)"
><NInputNumber
:input-props="{ 'aria-label': '本批上限可选' }"
:value="keyframeLimit === '' ? null : keyframeLimit"
@update:value="keyframeLimit = $event ?? ''"
:min="1"
:precision="0"
:disabled="operation.pending"
placeholder="留空不限制"
/></NFormItem>
<div class="grid grid-cols-2 gap-3">
<NFormItem path="keyframeWidth" label="宽度(像素)"
><NInputNumber
:input-props="{ 'aria-label': '宽度像素' }"
:value="keyframeWidth === '' ? null : keyframeWidth"
@update:value="keyframeWidth = $event ?? ''"
:min="1"
:disabled="operation.pending"
placeholder="默认" /></NFormItem
><NFormItem path="keyframeHeight" label="高度(像素)"
><NInputNumber
:input-props="{ 'aria-label': '高度像素' }"
:value="
keyframeHeight === '' ? null : keyframeHeight
"
@update:value="keyframeHeight = $event ?? ''"
:min="1"
:disabled="operation.pending"
placeholder="默认"
/></NFormItem>
</div>
</AppForm>
<AppForm
v-if="stage.key === 'videos'"
:model="videoModel"
:rules="videoRules"
:disabled="operation.pending"
validate-on-change
class="mt-4 grid gap-3"
>
<NFormItem path="videoLimit" label="本批镜头上限"
><NInputNumber
v-model:value="videoLimit"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频质量任务镜头上限' }"
/></NFormItem>
<NFormItem path="videoRepairAttempts" label="每镜最多自动修复"
><NInputNumber
v-model:value="videoRepairAttempts"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频自动修复次数' }"
/></NFormItem>
</AppForm>
</article>
</div>
<div v-if="query.data.value" class="panel mt-4 p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<dt>可执行</dt> <h3 class="text-sm font-semibold">视频任务状态</h3>
<dd>{{ stage.data?.ready ?? 0 }}</dd> <p class="mt-2 text-xs text-muted">
状态统计取每个镜头最近一次任务不等同于主视频数量
</p>
</div>
<ConfirmAction
label="重试失败视频"
:disabled="
blocked ||
!batchValid ||
!query.data.value.videoStatus.failed
"
acknowledgement
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
@confirm="run('retry-videos')"
/>
</div>
<dl class="production-status-grid mt-4">
<div>
<dt>完成</dt>
<dd>{{ query.data.value.videoStatus.completed }}</dd>
</div> </div>
<div> <div>
<dt>已有结果</dt> <dt>排队生成</dt>
<dd>{{ stage.data?.skipped ?? 0 }}</dd> <dd>{{ videoRunning }}</dd>
</div> </div>
<div> <div>
<dt>阻塞</dt> <dt>失败</dt>
<dd>{{ stage.data?.blocked ?? 0 }}</dd> <dd>{{ query.data.value.videoStatus.failed }}</dd>
</div>
<div>
<dt>取消</dt>
<dd>{{ query.data.value.videoStatus.cancelled }}</dd>
</div>
<div>
<dt>未开始</dt>
<dd>{{ query.data.value.videoStatus.notStarted }}</dd>
</div> </div>
</dl> </dl>
<p
v-if="stage.key === 'keyframes' && staleKeyframes"
class="mt-3 text-[11px] leading-5"
>
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
</p>
<p
v-if="stage.key === 'videos' && staleVideos"
class="mt-3 text-[11px] leading-5"
>
主视频已过期 {{ staleVideos }} · 会按当前主首帧与参考资产重新生成
</p>
<p
v-if="stage.key === 'keyframes' && identityBlocked"
class="mt-3 text-[11px] leading-5 text-danger"
>
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} ·
母版缺失
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} ·
未锁定
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
</p>
<ConfirmAction
class="mt-4"
:label="stage.action"
:disabled="
blocked ||
!batchValid ||
(stage.key === 'keyframes' && !keyframeValid) ||
(stage.key === 'videos' && !videoQualityValid) ||
!(stage.data?.ready ?? 0)
"
acknowledgement
:description="
stage.key === 'videos'
? staleVideos && !force
? `当前存在过期主视频,非覆盖模式只会为其中至多 ${videoLimit || '—'} 个镜头启动严格质量流水线;候选自动校验并有限修复,通过后才接替旧主视频。`
: `只为至多 ${videoLimit || '—'} 个就绪镜头启动严格质量流水线。任务创建后由后端自动校验,最多修复 ${videoRepairAttempts || '—'} 次,通过后才晋升主视频。`
: stage.key === 'keyframes'
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
"
:primary="stage.key === 'videos'"
@confirm="run(stage.key)"
/>
<AppForm
v-if="stage.key === 'keyframes'"
:model="keyframeModel"
:rules="keyframeRules"
:disabled="operation.pending"
validate-on-change
class="mt-4 grid gap-3"
>
<NFormItem path="keyframeLimit" label="本批上限(可选)"
><NInputNumber
:input-props="{ 'aria-label': '本批上限可选' }"
:value="keyframeLimit === '' ? null : keyframeLimit"
@update:value="keyframeLimit = $event ?? ''"
:min="1"
:precision="0"
:disabled="operation.pending"
placeholder="留空不限制"
/></NFormItem>
<div class="grid grid-cols-2 gap-3">
<NFormItem path="keyframeWidth" label="宽度(像素)"
><NInputNumber
:input-props="{ 'aria-label': '宽度像素' }"
:value="keyframeWidth === '' ? null : keyframeWidth"
@update:value="keyframeWidth = $event ?? ''"
:min="1"
:disabled="operation.pending"
placeholder="默认" /></NFormItem
><NFormItem path="keyframeHeight" label="高度(像素)"
><NInputNumber
:input-props="{ 'aria-label': '高度像素' }"
:value="keyframeHeight === '' ? null : keyframeHeight"
@update:value="keyframeHeight = $event ?? ''"
:min="1"
:disabled="operation.pending"
placeholder="默认"
/></NFormItem>
</div>
</AppForm>
<AppForm
v-if="stage.key === 'videos'"
:model="videoModel"
:rules="videoRules"
:disabled="operation.pending"
validate-on-change
class="mt-4 grid gap-3"
>
<NFormItem path="videoLimit" label="本批镜头上限"
><NInputNumber
v-model:value="videoLimit"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频质量任务镜头上限' }"
/></NFormItem>
<NFormItem path="videoRepairAttempts" label="每镜最多自动修复"
><NInputNumber
v-model:value="videoRepairAttempts"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频自动修复次数' }"
/></NFormItem>
</AppForm>
</article>
</div>
<div v-if="query.data.value" class="panel mt-4 p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold">视频任务状态</h3>
<p class="mt-2 text-xs text-muted">
状态统计取每个镜头最近一次任务不等同于主视频数量
</p>
</div>
<ConfirmAction
label="重试失败视频"
:disabled="
blocked || !batchValid || !query.data.value.videoStatus.failed
"
acknowledgement
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
@confirm="run('retry-videos')"
/>
</div> </div>
<dl class="production-status-grid mt-4"> </div></NCollapseItem
<div> ></NCollapse
<dt>完成</dt> >
<dd>{{ query.data.value.videoStatus.completed }}</dd> </div>
</div> <EpisodeAssemblyPanel :project-id="id" />
<div> <AdvancedProduction />
<dt>排队生成</dt>
<dd>{{ videoRunning }}</dd>
</div>
<div>
<dt>失败</dt>
<dd>{{ query.data.value.videoStatus.failed }}</dd>
</div>
<div>
<dt>取消</dt>
<dd>{{ query.data.value.videoStatus.cancelled }}</dd>
</div>
<div>
<dt>未开始</dt>
<dd>{{ query.data.value.videoStatus.notStarted }}</dd>
</div>
</dl>
</div>
</div></NCollapseItem
></NCollapse
>
</div> </div>
<AdvancedProduction />
</WorkspaceTools> </WorkspaceTools>
</div></div </div></div
></template> ></template>
@@ -691,6 +724,46 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
<style> <style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */ /* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../styles/styles.css"; @reference "../../styles/styles.css";
.production-tools-intro {
@apply flex flex-wrap items-end justify-between gap-4 p-[18px] bg-(--app-surface);
}
.production-tools-intro .eyebrow {
@apply mb-2;
}
.production-batch-config {
@apply mt-3 p-4 bg-(--app-surface);
}
.production-section-heading {
@apply flex items-start justify-between gap-4;
}
.workspace-tools-body .production-batch-config .production-controls {
@apply mt-4 mb-0;
}
.production-tool-stack {
@apply mt-3 px-4 bg-(--app-surface);
}
.production-tool-stack .production-tool-section.n-collapse {
@apply m-0;
box-shadow: inset 0 1px var(--app-border);
}
.production-tool-stack > :first-child.production-tool-section.n-collapse {
box-shadow: none;
}
.production-tool-stack .production-tool-section .n-collapse-item__header {
@apply min-h-12 py-0;
}
.production-tool-stack .production-tool-section .n-collapse-item__header-main {
@apply text-[13px] font-medium;
}
.production-tool-stack .production-tool-section .n-collapse-item__content-inner {
@apply pt-1 pb-4 pl-6;
}
.production-tool-stack .detail-disclosure-content {
@apply py-3 px-4 bg-(--app-subtle);
}
.production-tool-stack .workspace-overview-panel {
@apply m-0;
}
.production-controls { .production-controls {
@apply grid grid-cols-[minmax(220px,_1.6fr)_minmax(110px,_0.5fr)_minmax(230px,_1fr)_auto] items-end gap-[18px]; @apply grid grid-cols-[minmax(220px,_1.6fr)_minmax(110px,_0.5fr)_minmax(230px,_1fr)_auto] items-end gap-[18px];
} }
@@ -733,6 +806,16 @@ const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairA
} }
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.production-tools-intro,
.production-section-heading {
@apply items-start;
}
.production-tool-stack {
@apply px-3;
}
.production-tool-stack .production-tool-section .n-collapse-item__content-inner {
@apply pl-4;
}
.production-controls, .production-controls,
.production-workspace { .production-workspace {
@apply grid-cols-[minmax(0,_1fr)]; @apply grid-cols-[minmax(0,_1fr)];
+26 -5
View File
@@ -6,6 +6,7 @@ import type {
GenerateQualityVideosInput, GenerateQualityVideosInput,
KeyframeBatchResult, KeyframeBatchResult,
KeyframeReadiness, KeyframeReadiness,
KeyframeGenerationPreview,
KeyframeSpec, KeyframeSpec,
ProjectVideoStatus, ProjectVideoStatus,
PromptReadiness, PromptReadiness,
@@ -20,7 +21,9 @@ import type {
VideoQualityBatchResult, VideoQualityBatchResult,
VideoQualityConfig, VideoQualityConfig,
VideoGenerationSpec, VideoGenerationSpec,
VideoReadiness VideoReadiness,
ProjectAssemblyReadiness,
EpisodeAssemblyResult
} from './types' } from './types'
/** 项目路径统一编码,避免业务组件手工拼接 ID。 */ /** 项目路径统一编码,避免业务组件手工拼接 ID。 */
@@ -35,21 +38,36 @@ function shotPath(id: string) {
/** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */ /** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */
export const productionApi = { export const productionApi = {
/** 读取各集主视频是否满足顺序拼接条件,不运行 FFmpeg。 */
assemblyReadiness: (projectId: string, signal?: AbortSignal) =>
request<ProjectAssemblyReadiness>(`${projectPath(projectId)}/assembly/readiness`, { signal }),
/** 将单集有效主视频按分镜顺序拼为无声成片。 */
assembleEpisode: (episodeId: string) =>
request<EpisodeAssemblyResult>(`/episodes/${encodeURIComponent(episodeId)}/assembly/videos`, {
method: 'POST',
body: {},
timeoutMs: 0
}),
/** 根据显式指定的模型查询最终输入,不能用通用规格推断授权素材是否生效。 */ /** 根据显式指定的模型查询最终输入,不能用通用规格推断授权素材是否生效。 */
providerInputSpec: (shotId: string, provider: string, signal?: AbortSignal) => providerInputSpec: (shotId: string, provider: string, signal?: AbortSignal) =>
request<ProviderInputSpec>(`${shotPath(shotId)}/provider-input-spec?provider=${encodeURIComponent(provider)}`, { request<ProviderInputSpec>(`${shotPath(shotId)}/provider-input-spec?provider=${encodeURIComponent(provider)}`, {
signal signal
}), }),
/** 查询当前计划,不生成图片、视频或提示词。 */ /** 查询当前计划,不生成图片、视频或提示词。 */
plan: (projectId: string, signal?: AbortSignal) => plan: (projectId: string, signal?: AbortSignal, providers?: { imageProvider?: string; videoProvider?: string }) => {
request<ProductionPlan>(`${projectPath(projectId)}/production/plan`, { signal }), const params = new URLSearchParams()
if (providers?.imageProvider) params.set('imageProvider', providers.imageProvider)
if (providers?.videoProvider) params.set('videoProvider', providers.videoProvider)
const query = params.size ? `?${params}` : ''
return request<ProductionPlan>(`${projectPath(projectId)}/production/plan${query}`, { signal })
},
/** 读取真实主资产完成状态,不能用任务提交回执代替。 */ /** 读取真实主资产完成状态,不能用任务提交回执代替。 */
status: (projectId: string, signal?: AbortSignal) => status: (projectId: string, signal?: AbortSignal) =>
request<ProjectProductionStatus>(`${projectPath(projectId)}/production/status`, { signal }), request<ProjectProductionStatus>(`${projectPath(projectId)}/production/status`, { signal }),
startPipeline: (projectId: string) => startPipeline: (projectId: string, providers?: { imageProvider?: string; videoProvider?: string }) =>
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, { request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
method: 'POST', method: 'POST',
body: {}, body: providers ?? {},
timeoutMs: 0 timeoutMs: 0
}), }),
promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) => promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
@@ -89,6 +107,9 @@ export const productionApi = {
request<ShotKeyframe[]>(`${shotPath(shotId)}/keyframes`, { signal }), request<ShotKeyframe[]>(`${shotPath(shotId)}/keyframes`, { signal }),
keyframeSpec: (shotId: string, signal?: AbortSignal) => keyframeSpec: (shotId: string, signal?: AbortSignal) =>
request<KeyframeSpec>(`${shotPath(shotId)}/keyframe-spec`, { signal }), request<KeyframeSpec>(`${shotPath(shotId)}/keyframe-spec`, { signal }),
/** 预览最终参考图规划与 Provider Prompt,不创建首帧任务。 */
previewKeyframe: (shotId: string) =>
request<KeyframeGenerationPreview>(`${shotPath(shotId)}/keyframe/preview`, { method: 'POST', body: {} }),
generateKeyframe: (shotId: string, input: GenerateKeyframeInput) => generateKeyframe: (shotId: string, input: GenerateKeyframeInput) =>
request<ShotKeyframe | null>(`${shotPath(shotId)}/keyframe`, { request<ShotKeyframe | null>(`${shotPath(shotId)}/keyframe`, {
method: 'POST', method: 'POST',
@@ -6,10 +6,10 @@ import { downloadText, formatDate } from '../../../lib/format'
import { useAdvancedProduction } from '../useAdvancedProduction' import { useAdvancedProduction } from '../useAdvancedProduction'
import ProductionPlanPanel from './ProductionPlanPanel.vue' import ProductionPlanPanel from './ProductionPlanPanel.vue'
/** 高级入口明确真实流程边界,默认折叠且不在挂载时触发预检或生成。 */ /** 高级入口默认展示真实流程边界,但不会在挂载时触发预检或生成。 */
const { id, operation, session, checking, error, check, blocked, preflight, start } = useAdvancedProduction() const { id, operation, session, checking, error, check, blocked, preflight, start } = useAdvancedProduction()
const receipt = computed(() => session.value.pipelineReceipt) const receipt = computed(() => session.value.pipelineReceipt)
const expanded = ref<string[]>([]) const expanded = ref<string[]>(['advanced'])
watch( watch(
receipt, receipt,
value => { value => {
@@ -29,7 +29,7 @@ function exportReceipt() {
} }
</script> </script>
<template> <template>
<NCollapse v-model:expanded-names="expanded" class="mt-5" <NCollapse v-model:expanded-names="expanded" class="production-tool-section"
><NCollapseItem name="advanced" title="高级:串联生产(全项目)"> ><NCollapseItem name="advanced" title="高级:串联生产(全项目)">
<NAlert type="warning" :show-icon="false" <NAlert type="warning" :show-icon="false"
>依次补齐形态提示词参考图首帧质量校验与修复视频提示词再提交视频任务视觉风格身份母版导演设计和即时状态仍需提前准备</NAlert >依次补齐形态提示词参考图首帧质量校验与修复视频提示词再提交视频任务视觉风格身份母版导演设计和即时状态仍需提前准备</NAlert
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { onScopeDispose, ref, watch } from 'vue'
import { NAlert, NButton, NCollapse, NCollapseItem, NProgress, NSpin } from 'naive-ui'
import { mediaAssetUrl } from '../../../lib/assets'
import { errorMessage } from '../../../lib/http'
import { productionApi } from '../api'
import type { EpisodeAssemblyResult, ProjectAssemblyReadiness } from '../types'
const props = defineProps<{ projectId: string }>()
const readiness = ref<ProjectAssemblyReadiness | null>(null)
const loading = ref(false)
const assembling = ref('')
const error = ref('')
const results = ref<Record<string, EpisodeAssemblyResult>>({})
let controller: AbortController | null = null
async function refresh() {
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = ''
try {
readiness.value = await productionApi.assemblyReadiness(props.projectId, controller.signal)
} catch (cause) {
if (!controller.signal.aborted) error.value = errorMessage(cause)
} finally {
if (!controller.signal.aborted) loading.value = false
}
}
async function assemble(episodeId: string) {
if (assembling.value) return
assembling.value = episodeId
error.value = ''
try {
const result = await productionApi.assembleEpisode(episodeId)
results.value = { ...results.value, [episodeId]: result }
await refresh()
} catch (cause) {
error.value = errorMessage(cause)
} finally {
assembling.value = ''
}
}
function resultUrl(episodeId: string) {
const result = results.value[episodeId]
return result ? mediaAssetUrl(result.videoUrl) : null
}
watch(
() => props.projectId,
() => {
results.value = {}
void refresh()
},
{ immediate: true }
)
onScopeDispose(() => controller?.abort())
</script>
<template>
<NCollapse class="production-tool-section" :default-expanded-names="['episode-assembly']">
<NCollapseItem name="episode-assembly" title="单集成片拼接">
<NSpin :show="loading">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-xs text-muted">
Beat Shot 顺序拼接当前有效主视频V1 只输出无声视频不包含对白配乐字幕和转场
</p>
<NButton size="small" :disabled="loading" @click="refresh">刷新就绪状态</NButton>
</div>
<NAlert v-if="error" type="error" :show-icon="false" class="mt-3">{{ error }}</NAlert>
<p v-if="readiness" class="mt-3 text-xs">
已就绪 {{ readiness.ready }}/{{ readiness.total }} · 阻塞 {{ readiness.blocked }}
</p>
<div v-if="readiness?.items.length" class="assembly-list mt-3">
<article v-for="item in readiness.items" :key="item.episodeId" class="assembly-row">
<div class="min-w-0">
<h4 class="truncate text-sm font-semibold">
{{ item.episodeNo }} · {{ item.title }}
</h4>
<p class="mt-1 text-xs text-muted">
主视频 {{ item.readyShots }}/{{ item.totalShots }} · {{ item.totalDurationSeconds }}
</p>
<NProgress
class="mt-2"
type="line"
:show-indicator="false"
:percentage="
item.totalShots ? Math.round((item.readyShots / item.totalShots) * 100) : 0
"
:status="item.ready ? 'success' : 'default'"
/>
<p
v-for="(issue, index) in item.issues.slice(0, 3)"
:key="index"
class="mt-1 text-xs text-muted"
>
<span v-if="issue.shotNo">镜头 {{ issue.shotNo }}</span>{{ issue.reason }}
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<NButton
size="small"
:disabled="!item.ready || !!assembling"
:loading="assembling === item.episodeId"
@click="assemble(item.episodeId)"
>生成无声成片</NButton
>
<NButton
v-if="resultUrl(item.episodeId)"
tag="a"
size="small"
:href="resultUrl(item.episodeId) || undefined"
target="_blank"
rel="noopener"
>查看成片</NButton
>
</div>
</article>
</div>
<p v-else-if="readiness && !loading" class="mt-3 text-xs text-muted">当前项目还没有可拼接的剧集</p>
</NSpin>
</NCollapseItem>
</NCollapse>
</template>
<style scoped>
@reference "../../../styles/styles.css";
.assembly-list {
@apply grid gap-2;
}
.assembly-row {
@apply grid grid-cols-[minmax(0,_1fr)_auto] items-center gap-4 p-4 bg-(--app-subtle);
}
@media (max-width: 760px) {
.assembly-row {
@apply grid-cols-[minmax(0,_1fr)];
}
}
</style>
@@ -2,11 +2,13 @@
import AppForm from '../../../components/ui/AppForm.vue' import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui' import { NFormItem } from 'naive-ui'
import { sizeRules } from '../../../lib/form-rules' import { sizeRules } from '../../../lib/form-rules'
import { NAlert, NButton, NCheckbox, NInputNumber } from 'naive-ui' import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInputNumber, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui' import { AppDialog } from '../../../components/ui'
import { errorMessage } from '../../../lib/http'
import { productionApi } from '../api'
import { validOptionalSize } from '../model' import { validOptionalSize } from '../model'
import type { GenerateKeyframeInput, ShotKeyframe } from '../types' import type { GenerateKeyframeInput, KeyframeGenerationPreview, ShotKeyframe } from '../types'
/** 单镜头首帧表单;普通候选仍由用户明确选择,过期主首帧默认替换为新图。 */ /** 单镜头首帧表单;普通候选仍由用户明确选择,过期主首帧默认替换为新图。 */
const props = defineProps<{ const props = defineProps<{
@@ -22,6 +24,9 @@ const width = ref<number | ''>('')
const height = ref<number | ''>('') const height = ref<number | ''>('')
const setPrimary = ref(false) const setPrimary = ref(false)
const confirmed = ref(false) const confirmed = ref(false)
const preview = ref<KeyframeGenerationPreview | null>(null)
const previewError = ref('')
const previewPending = ref(false)
const hasPrimary = computed(() => props.keyframes.some(item => item.isPrimary && item.status === 'completed')) const hasPrimary = computed(() => props.keyframes.some(item => item.isPrimary && item.status === 'completed'))
const sizeValid = computed(() => validOptionalSize(width.value, height.value)) const sizeValid = computed(() => validOptionalSize(width.value, height.value))
const canSubmit = computed(() => !props.disabled && sizeValid.value && confirmed.value) const canSubmit = computed(() => !props.disabled && sizeValid.value && confirmed.value)
@@ -32,6 +37,9 @@ function reset() {
height.value = '' height.value = ''
setPrimary.value = !!props.replacePrimary || !hasPrimary.value setPrimary.value = !!props.replacePrimary || !hasPrimary.value
confirmed.value = false confirmed.value = false
preview.value = null
previewError.value = ''
previewPending.value = false
} }
watch([() => props.shotId, open], reset, { immediate: true }) watch([() => props.shotId, open], reset, { immediate: true })
@@ -47,6 +55,23 @@ function submit() {
} }
const formModel = computed(() => ({ width: width.value, height: height.value })) const formModel = computed(() => ({ width: width.value, height: height.value }))
const rules = sizeRules(() => formModel.value) const rules = sizeRules(() => formModel.value)
/** 读取后端最终参考图选择与 Prompt,不创建首帧候选。 */
async function loadPreview() {
if (!props.shotId || previewPending.value) return
previewPending.value = true
previewError.value = ''
try {
const result = await productionApi.previewKeyframe(props.shotId)
if (!result || result.shotId !== props.shotId) throw new Error('预览结果与当前镜头不匹配。')
preview.value = result
} catch (cause) {
preview.value = null
previewError.value = errorMessage(cause)
} finally {
previewPending.value = false
}
}
</script> </script>
<template> <template>
@@ -66,6 +91,49 @@ const rules = sizeRules(() => formModel.value)
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs"> <NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
当前主首帧使用的主体参考资产已经变化新图成功后应设为主首帧旧图会继续保留为历史候选 当前主首帧使用的主体参考资产已经变化新图成功后应设为主首帧旧图会继续保留为历史候选
</NAlert> </NAlert>
<div class="mt-5 flex flex-wrap items-center gap-3">
<NButton :loading="previewPending" @click="loadPreview">预览实际生成输入</NButton>
<span class="text-xs text-muted">只读取参考图规划与最终 Prompt不调用图片模型</span>
</div>
<NAlert v-if="previewError" type="error" :show-icon="false" class="mt-3">{{ previewError }}</NAlert>
<div v-if="preview" class="panel mt-3 p-4 text-xs leading-6">
<div class="flex flex-wrap items-center gap-2">
<strong>{{ preview.provider }} / {{ preview.model }}</strong>
<NTag size="small" :bordered="false">
已选 {{ preview.referencePlan.selected.length }} /
{{ preview.referencePlan.totalCandidates }}
</NTag>
<NTag v-if="preview.referencePlan.omitted.length" size="small" type="warning" :bordered="false">
省略 {{ preview.referencePlan.omitted.length }}
</NTag>
</div>
<ul v-if="preview.referencePlan.selected.length" class="mt-3 space-y-1 text-muted">
<li v-for="item in preview.referencePlan.selected" :key="`${item.role}:${item.imageId}`">
{{ item.subjectRef }} · {{ item.role }} · <code>{{ item.imageId }}</code>
</li>
</ul>
<NCollapse class="mt-3" :default-expanded-names="['prompt']">
<NCollapseItem name="prompt" title="最终 Provider Prompt">
<p class="mt-3 whitespace-pre-wrap">{{ preview.providerPrompt }}</p>
</NCollapseItem>
<NCollapseItem
v-if="preview.referencePlan.omitted.length"
name="omitted"
title="未进入模型的参考图"
>
<ul class="mt-3 space-y-1 text-muted">
<li v-for="item in preview.referencePlan.omitted" :key="`${item.role}:${item.imageId}`">
{{ item.subjectRef }} · {{ item.role }} · <code>{{ item.imageId }}</code>
</li>
</ul>
</NCollapseItem>
<NCollapseItem name="config" title="生成配置">
<pre class="mt-3 whitespace-pre-wrap">{{
JSON.stringify(preview.generationConfig, null, 2)
}}</pre>
</NCollapseItem>
</NCollapse>
</div>
<div class="mt-5 grid grid-cols-2 gap-4"> <div class="mt-5 grid grid-cols-2 gap-4">
<NFormItem path="width" label="宽度(可选)" <NFormItem path="width" label="宽度(可选)"
><NInputNumber ><NInputNumber
@@ -83,7 +83,7 @@ const problems = computed(() => [
{{ plan.manualBlocked }} {{ plan.manualBlocked }}
项需人工处理同一镜头可能重复计数可先生成就绪的主体参考图成功结果会保留遇到阻塞时后续阶段可能暂停 项需人工处理同一镜头可能重复计数可先生成就绪的主体参考图成功结果会保留遇到阻塞时后续阶段可能暂停
</NAlert> </NAlert>
<NCollapse v-if="problems.length" class="mt-3"> <NCollapse v-if="problems.length" class="mt-3" :default-expanded-names="['issues']">
<NCollapseItem name="issues" title="查看前置条件与阻塞原因"> <NCollapseItem name="issues" title="查看前置条件与阻塞原因">
<ul class="space-y-3 text-xs"> <ul class="space-y-3 text-xs">
<li v-for="item in problems" :key="item.key"> <li v-for="item in problems" :key="item.key">
@@ -4,7 +4,7 @@ import { Download } from '@lucide/vue'
import { downloadText } from '../../../lib/format' import { downloadText } from '../../../lib/format'
import type { ProductionReceipt } from '../types' import type { ProductionReceipt } from '../types'
/** 批量回执只描述本次请求,不能替代持续轮询的数据库状态。 */ /** 批量回执只描述本次请求,不能替代显式刷新后的数据库状态。 */
const props = defineProps<{ receipt: ProductionReceipt }>() const props = defineProps<{ receipt: ProductionReceipt }>()
/** 导出完整回执,保留逐镜失败 ID 供后端排障。 */ /** 导出完整回执,保留逐镜失败 ID 供后端排障。 */
@@ -125,7 +125,7 @@ const rules = computed(() => ({
limit: integerRule('本批镜头上限'), limit: integerRule('本批镜头上限'),
...sizeRules(() => formModel.value) ...sizeRules(() => formModel.value)
})) }))
const optionsExpanded = ref<string[]>([]) const optionsExpanded = ref<string[]>(['options'])
</script> </script>
<template> <template>
@@ -277,7 +277,7 @@ const optionsExpanded = ref<string[]>([])
session.error session.error
}}</NAlert> }}</NAlert>
<QualityResult v-if="session.receipt" :receipt="session.receipt" @locate="emit('locate', $event)" /> <QualityResult v-if="session.receipt" :receipt="session.receipt" @locate="emit('locate', $event)" />
<NCollapse v-else-if="saved" class="mt-4" <NCollapse v-else-if="saved" class="mt-4" :default-expanded-names="['saved']"
><NCollapseItem name="saved" :title="`本会话最近校验 · ${saved.passed ? '通过' : '未通过'}`" ><NCollapseItem name="saved" :title="`本会话最近校验 · ${saved.passed ? '通过' : '未通过'}`"
><p>{{ saved.summary }}</p> ><p>{{ saved.summary }}</p>
<p v-for="(issue, index) in saved.issues" :key="index" class="mt-2 text-danger"> <p v-for="(issue, index) in saved.issues" :key="index" class="mt-2 text-danger">
@@ -4,7 +4,7 @@ import { NAlert, NButton, NCollapse, NCollapseItem } from 'naive-ui'
import { downloadText } from '../../../lib/format' import { downloadText } from '../../../lib/format'
import type { QualityReceipt } from '../quality.types' import type { QualityReceipt } from '../quality.types'
/** 摘要失败直接显示,完整逐轮信息按需展开,不用完成请求冒充质量通过。 */ /** 摘要失败完整逐轮信息默认直接显示,不用完成请求冒充质量通过。 */
const props = defineProps<{ receipt: QualityReceipt }>() const props = defineProps<{ receipt: QualityReceipt }>()
const emit = defineEmits<{ locate: [shotId: string] }>() const emit = defineEmits<{ locate: [shotId: string] }>()
const passed = computed(() => const passed = computed(() =>
@@ -113,7 +113,7 @@ function exportResult() {
> >
{{ issue.frameLabel ? `${issue.frameLabel} · ` : '' }}{{ issue.description }} {{ issue.frameLabel ? `${issue.frameLabel} · ` : '' }}{{ issue.description }}
</NAlert> </NAlert>
<NCollapse class="mt-4"> <NCollapse class="mt-4" :default-expanded-names="['details']">
<NCollapseItem name="details" title="主体检查与执行详情"> <NCollapseItem name="details" title="主体检查与执行详情">
<template v-if="receipt.kind === 'keyframe' || receipt.kind === 'video'"> <template v-if="receipt.kind === 'keyframe' || receipt.kind === 'video'">
<p class="text-xs"> <p class="text-xs">
@@ -67,7 +67,7 @@ const qualityOpen = ref(false)
const qualityTarget = ref<QualityTarget | null>(null) const qualityTarget = ref<QualityTarget | null>(null)
const specsOpen = ref(false) const specsOpen = ref(false)
/** 从实际候选打开质量面板,保留单镜头主操作,不默认展开全部校验参数。 */ /** 从实际候选打开质量面板,保留单镜头主操作。 */
function openQuality(kind: 'keyframe' | 'video', assetId: string) { function openQuality(kind: 'keyframe' | 'video', assetId: string) {
qualityTarget.value = { qualityTarget.value = {
kind, kind,
+88
View File
@@ -146,6 +146,53 @@ export interface KeyframeSpec {
references: KeyframeReference[] references: KeyframeReference[]
} }
export type ImageReferenceRole =
| 'style-overall'
| 'style-character'
| 'style-scene'
| 'style-prop'
| 'identity-anchor'
| 'form-primary'
export interface KeyframePreviewReference {
originalImageNumber: number
subjectRef: string
module: string
role: ImageReferenceRole
imageId: string
imageUrl: string
}
export interface KeyframeReferencePlanItem {
subjectId?: string
subjectRef: string
module: string
role: ImageReferenceRole
imageId: string
subjectFormId?: string
}
/** 首帧生成前的无费用参考图规划与 Provider Prompt 预览。 */
export interface KeyframeGenerationPreview {
shotId: string
episodeNo: number
beatNo: number
shotNo: number
provider: string
model: string
generationConfig: Record<string, unknown>
references: KeyframePreviewReference[]
referencePlan: {
provider: string
maxReferenceImages: number | null
totalCandidates: number
strategy: 'all' | 'subject-coverage' | 'repair-aware'
selected: KeyframeReferencePlanItem[]
omitted: KeyframeReferencePlanItem[]
}
providerPrompt: string
}
/** 视频生成规格中的主体参考图。 */ /** 视频生成规格中的主体参考图。 */
export interface VideoReference { export interface VideoReference {
/** 当前目标视频模型的授权素材,不拼接模型私有地址。 */ /** 当前目标视频模型的授权素材,不拼接模型私有地址。 */
@@ -455,3 +502,44 @@ export interface ProviderInputSpec {
providerAsset: { provider: string; assetId: string } | null providerAsset: { provider: string; assetId: string } | null
})[] })[]
} }
/** 单集成片拼接就绪问题。 */
export interface EpisodeAssemblyIssue {
shotId?: string
beatNo?: number
shotNo?: number
reason: string
}
/** 项目中单集的成片拼接就绪状态。 */
export interface EpisodeAssemblyReadinessItem {
episodeId: string
episodeNo: number
title: string
ready: boolean
totalShots: number
readyShots: number
totalDurationSeconds: number
issues: EpisodeAssemblyIssue[]
}
/** 项目成片拼接就绪汇总;查询不会运行 FFmpeg。 */
export interface ProjectAssemblyReadiness {
projectId: string
total: number
ready: number
blocked: number
complete: boolean
items: EpisodeAssemblyReadinessItem[]
}
/** 单集拼接成功回执;V1 产物为无声视频。 */
export interface EpisodeAssemblyResult {
projectId: string
episodeId: string
episodeNo: number
title: string
shotCount: number
durationSeconds: number
videoUrl: string
}
@@ -6,16 +6,21 @@ import { hasRunningImages } from '../subject-images/model'
import { getOperation, runOperation } from '../workflows/operations' import { getOperation, runOperation } from '../workflows/operations'
import { hasRunningWorkflow } from '../workflows/selectors' import { hasRunningWorkflow } from '../workflows/selectors'
import { errorMessage } from '../../lib/http' import { errorMessage } from '../../lib/http'
import { generationConfigApi } from '../generation-config/api'
import { productionApi } from './api' import { productionApi } from './api'
import { getProductionSession } from './model' import { getProductionSession } from './model'
/** 使用后端计划区分可自动补齐和人工阻塞,保留项目归属及活动任务检查。 */ /** 使用后端计划区分可自动补齐和人工阻塞,保留项目归属及活动任务检查。 */
export async function checkPipeline(projectId: string) { export async function checkPipeline(projectId: string) {
const profile = await generationConfigApi.profile(projectId)
const providers = profile
? { imageProvider: profile.imageProvider, videoProvider: profile.videoProvider }
: undefined
const [project, checkpoints, forms, plan, status] = await Promise.all([ const [project, checkpoints, forms, plan, status] = await Promise.all([
projectsApi.detail(projectId), projectsApi.detail(projectId),
projectsApi.checkpoints(projectId), projectsApi.checkpoints(projectId),
subjectImagesApi.listForms(projectId), subjectImagesApi.listForms(projectId),
productionApi.plan(projectId), productionApi.plan(projectId, undefined, providers),
productionApi.status(projectId) productionApi.status(projectId)
]) ])
if ( if (
@@ -26,6 +31,7 @@ export async function checkPipeline(projectId: string) {
) )
throw new Error('预检返回了其他项目的数据,请重新读取。') throw new Error('预检返回了其他项目的数据,请重新读取。')
const issues: string[] = [] const issues: string[] = []
if (!profile) issues.push('尚未配置项目画布与生成模型,请先在批量生产工具中保存配置。')
const shotIds = new Set(plan.details.keyframes.map(item => item.shotId)) const shotIds = new Set(plan.details.keyframes.map(item => item.shotId))
if ( if (
shotIds.size !== plan.keyframes.total || shotIds.size !== plan.keyframes.total ||
@@ -54,7 +60,15 @@ export async function checkPipeline(projectId: string) {
else if (![plan.subjectImages, plan.keyframes, plan.videoPrompts, plan.videos].some(stage => stage.planned > 0)) else if (![plan.subjectImages, plan.keyframes, plan.videoPrompts, plan.videos].some(stage => stage.planned > 0))
issues.push('当前没有可执行的制作任务,请先处理计划中的阻塞项。') issues.push('当前没有可执行的制作任务,请先处理计划中的阻塞项。')
// 人工阻塞在计划中独立展示;不拦截其余 ready 形态的部分成功生产。 // 人工阻塞在计划中独立展示;不拦截其余 ready 形态的部分成功生产。
return { projectId, issues, plan, status, shotCount: plan.keyframes.total, checkedAt: new Date().toISOString() } return {
projectId,
issues,
plan,
status,
profile,
shotCount: plan.keyframes.total,
checkedAt: new Date().toISOString()
}
} }
/** 预检不付费;确认后再次预检并锁住本浏览器项目,长请求不自动重发。 */ /** 预检不付费;确认后再次预检并锁住本浏览器项目,长请求不自动重发。 */
@@ -128,7 +142,15 @@ export function useAdvancedProduction() {
if (result.issues.length) throw new Error('提交前预检发现条件变化,未启动生产。请处理下方问题。') if (result.issues.length) throw new Error('提交前预检发现条件变化,未启动生产。请处理下方问题。')
target.pipelineReceipt = null target.pipelineReceipt = null
submitted = true submitted = true
const receipt = await productionApi.startPipeline(projectId) const receipt = await productionApi.startPipeline(
projectId,
result.profile
? {
imageProvider: result.profile.imageProvider,
videoProvider: result.profile.videoProvider
}
: undefined
)
if (!receipt || receipt.projectId !== projectId) if (!receipt || receipt.projectId !== projectId)
throw new Error('回执项目不匹配,请读取资产状态核对,不要直接重试。') throw new Error('回执项目不匹配,请读取资产状态核对,不要直接重试。')
target.pipelineReceipt = receipt target.pipelineReceipt = receipt
+16 -20
View File
@@ -1,5 +1,5 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { usePolling } from '../../composables/usePolling' import { useQuery } from '../../composables/useQuery'
import { useProjectContext } from '../projects/context' import { useProjectContext } from '../projects/context'
import { mergeDesignedShots } from '../storyboard/model' import { mergeDesignedShots } from '../storyboard/model'
import { storyboardApi } from '../storyboard/api' import { storyboardApi } from '../storyboard/api'
@@ -45,24 +45,20 @@ export function useProduction() {
) )
const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value, force.value])) const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value, force.value]))
/** 生产页已有刷新按钮,就绪状态改为手动更新。 */ /** 生产页已有刷新按钮,就绪状态改为手动更新。 */
const query = usePolling( const query = useQuery(queryKey, async (key, signal) => {
queryKey, const [projectId, number, overwrite] = JSON.parse(key) as [string, number, boolean]
async (key, signal) => { if (!projectId || !number) return null
const [projectId, number, overwrite] = JSON.parse(key) as [string, number, boolean] const [directions, prompts, keyframes, videos, videoStatus] = await Promise.all([
if (!projectId || !number) return null storyboardApi.directions(projectId, number, signal),
const [directions, prompts, keyframes, videos, videoStatus] = await Promise.all([ productionApi.promptReadiness(projectId, overwrite, signal),
storyboardApi.directions(projectId, number, signal), productionApi.keyframeReadiness(projectId, overwrite, signal),
productionApi.promptReadiness(projectId, overwrite, signal), productionApi.videoReadiness(projectId, overwrite, signal),
productionApi.keyframeReadiness(projectId, overwrite, signal), productionApi.projectVideoStatus(projectId, signal)
productionApi.videoReadiness(projectId, overwrite, signal), ])
productionApi.projectVideoStatus(projectId, signal) if (directions.projectId !== projectId || directions.episodeNo !== number)
]) throw new Error('镜头生产查询返回了不匹配的项目或剧集,请刷新后重试。')
if (directions.projectId !== projectId || directions.episodeNo !== number) return { directions, prompts, keyframes, videos, videoStatus }
throw new Error('镜头生产查询返回了不匹配的项目或剧集,请刷新后重试。') })
return { directions, prompts, keyframes, videos, videoStatus }
},
false
)
const source = computed(() => sourceEpisodes.value.find(item => item.episodeNo === episodeNo.value)) const source = computed(() => sourceEpisodes.value.find(item => item.episodeNo === episodeNo.value))
const shots = computed(() => mergeDesignedShots(query.data.value?.directions ?? null, null, source.value)) const shots = computed(() => mergeDesignedShots(query.data.value?.directions ?? null, null, source.value))
const shot = computed(() => shots.value.find(item => item.shotId === selectedShot.value) ?? shots.value[0]) const shot = computed(() => shots.value.find(item => item.shotId === selectedShot.value) ?? shots.value[0])
@@ -99,7 +95,7 @@ export function useProduction() {
context.project.value?.status !== 'completed' context.project.value?.status !== 'completed'
) )
/** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */ /** 批量操作只向就绪镜头提交;最终资产状态由显式刷新确认。 */
async function run(command: ProductionCommand) { async function run(command: ProductionCommand) {
if ( if (
blocked.value || blocked.value ||
+11 -24
View File
@@ -1,29 +1,16 @@
import { computed, type MaybeRefOrGetter, type Ref } from 'vue' import { computed, type Ref } from 'vue'
import { usePolling } from '../../composables/usePolling' import { useQuery } from '../../composables/useQuery'
import { storyboardApi } from '../storyboard/api' import { storyboardApi } from '../storyboard/api'
/** 仅查询已由当前项目列表确认的镜头,切换目标自动取消旧响应。 */ /** 仅查询已由当前项目列表确认的镜头,切换目标自动取消旧响应。 */
export function useShotReferences( export function useShotReferences(projectId: Ref<string>, shotId: Ref<string>) {
projectId: Ref<string>,
shotId: Ref<string>,
interval: MaybeRefOrGetter<number | false> = 6000
) {
const key = computed(() => JSON.stringify([projectId.value, shotId.value])) const key = computed(() => JSON.stringify([projectId.value, shotId.value]))
return usePolling( return useQuery(key, async (value, signal) => {
key, const [project, shot] = JSON.parse(value) as [string, string]
async (value, signal) => { if (!project || !shot) return null
const [project, shot] = JSON.parse(value) as [string, string] const result = await storyboardApi.references(shot, signal)
if (!project || !shot) return null if (!result || result.shotId !== shot || !Array.isArray(result.references) || !Array.isArray(result.missing))
const result = await storyboardApi.references(shot, signal) throw new Error('镜头参考素材返回不匹配,请刷新后重试。')
if ( return result
!result || })
result.shotId !== shot ||
!Array.isArray(result.references) ||
!Array.isArray(result.missing)
)
throw new Error('镜头参考素材返回不匹配,请刷新后重试。')
return result
},
interval
)
} }
@@ -0,0 +1,544 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { NAlert, NButton, NFormItem, NInput, NPopconfirm, NSelect, NTag, NTooltip } from 'naive-ui'
import { RefreshCw, Search, Trash2, Upload } from '@lucide/vue'
import { AppDialog, AssetImage, EmptyState } from '../../components/ui'
import AppForm from '../../components/ui/AppForm.vue'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { useQuery } from '../../composables/useQuery'
import { fieldRule } from '../../lib/form-rules'
import { errorMessage } from '../../lib/http'
import { projectsApi } from '../projects/api'
import { projectAssetsApi } from './api'
import type { GlobalProjectAsset, ProjectAssetProject } from './types'
/** 后端当前允许的图片格式与大小。 */
const ACCEPTED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
const MAX_FILE_SIZE = 20 * 1024 * 1024
const categoryPattern = /^[a-z0-9_-]+$/
const categoryLabels: Record<string, string> = {
overall: '综合',
character: '人物',
scene: '场景',
prop: '道具',
reference: '参考素材'
}
/** 全局接口一次返回全部图片及所属剧本,避免按项目产生 N 次请求。 */
const query = useQuery(ref('all-project-assets'), (_, signal) => projectAssetsApi.listAll(signal))
/** 上传需要列出包括尚无资产在内的全部剧本。 */
const projectsQuery = useQuery(ref('asset-project-options'), (_, signal) => projectsApi.list(signal))
const search = ref('')
const projectId = ref<string | null>(null)
const category = ref<string | null>(null)
const busy = ref(false)
const actionError = ref('')
const fileError = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const uploadOpen = ref(false)
const editOpen = ref(false)
const selectedFile = ref<File | null>(null)
const selectedAsset = ref<GlobalProjectAsset | null>(null)
const overflowTitles = reactive(new Set<string>())
const uploadForm = reactive({ projectId: '', name: '', category: 'reference' })
const editForm = reactive({ name: '', category: '' })
const assets = computed(() => query.data.value ?? [])
const projectOptions = computed(() => {
const projects = new Map<string, ProjectAssetProject>()
assets.value.forEach(asset => projects.set(asset.project.id, asset.project))
return [...projects.values()]
.toSorted((a, b) => projectName(a).localeCompare(projectName(b), 'zh-CN'))
.map(project => ({ label: projectName(project), value: project.id }))
})
const uploadProjectOptions = computed(() =>
(projectsQuery.data.value ?? [])
.toSorted((a, b) => projectName(a).localeCompare(projectName(b), 'zh-CN'))
.map(project => ({ label: projectName(project), value: project.id }))
)
const categoryOptions = computed(() =>
[...new Set(assets.value.map(asset => asset.category))]
.toSorted()
.map(value => ({ label: categoryLabel(value), value }))
)
const filteredAssets = computed(() => {
const keyword = search.value.trim().toLowerCase()
return assets.value.filter(asset => {
if (projectId.value && asset.projectId !== projectId.value) return false
if (category.value && asset.category !== category.value) return false
if (!keyword) return true
return [asset.name, asset.category, projectName(asset.project)].some(value =>
value.toLowerCase().includes(keyword)
)
})
})
const formRules = {
projectId: fieldRule(value => typeof value === 'string' && !!value, '请选择所属剧本'),
name: fieldRule(value => typeof value === 'string' && !!value.trim(), '请输入素材名称'),
category: fieldRule(
value => typeof value === 'string' && categoryPattern.test(value.trim()),
'分类只能包含小写字母、数字、下划线和连字符'
)
}
function projectName(project: Pick<ProjectAssetProject, 'title' | 'topic'>) {
return project.title?.trim() || project.topic
}
function categoryLabel(value: string) {
return categoryLabels[value] ?? value
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
function formatDate(value: string) {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function metadataOriginalName(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return '未记录'
const originalName = (value as Record<string, unknown>).originalName
return typeof originalName === 'string' && originalName.trim() ? originalName : '未记录'
}
function clearFilters() {
search.value = ''
projectId.value = null
category.value = null
}
/** 同时刷新图片与上传所需的剧本选项。 */
async function refreshAll() {
await Promise.all([query.refresh(), projectsQuery.refresh()])
}
/** 先校验本地文件,再在当前页选择剧本并确认资料。 */
function selectFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
input.value = ''
fileError.value = ''
actionError.value = ''
if (!file) return
if (!ACCEPTED_TYPES.has(file.type)) {
fileError.value = '仅支持 JPEG、PNG、WebP 图片。'
return
}
if (!file.size || file.size > MAX_FILE_SIZE) {
fileError.value = '上传图片不能为空且不能超过 20MB。'
return
}
selectedFile.value = file
uploadForm.projectId = projectId.value ?? ''
uploadForm.name = file.name.replace(/\.[^.]+$/, '') || file.name
uploadForm.category = 'reference'
uploadOpen.value = true
}
async function uploadAsset() {
const file = selectedFile.value
if (!file || busy.value) return
busy.value = true
actionError.value = ''
try {
await projectAssetsApi.upload(uploadForm.projectId, {
file,
name: uploadForm.name.trim(),
category: uploadForm.category.trim()
})
uploadOpen.value = false
selectedFile.value = null
await query.refresh()
} catch (error) {
actionError.value = errorMessage(error)
} finally {
busy.value = false
}
}
function openEdit(asset: GlobalProjectAsset) {
selectedAsset.value = asset
editForm.name = asset.name
editForm.category = asset.category
actionError.value = ''
editOpen.value = true
}
/** 只在标题真实溢出时启用 Tooltip,短标题不产生多余浮层。 */
function checkTitleOverflow(assetId: string, event: MouseEvent) {
const element = event.currentTarget as HTMLElement
if (element.scrollWidth > element.clientWidth) overflowTitles.add(assetId)
else overflowTitles.delete(assetId)
}
async function updateAsset() {
const asset = selectedAsset.value
if (!asset || busy.value) return
busy.value = true
actionError.value = ''
try {
await projectAssetsApi.update(asset.projectId, asset.id, {
name: editForm.name.trim(),
category: editForm.category.trim()
})
editOpen.value = false
selectedAsset.value = null
await query.refresh()
} catch (error) {
actionError.value = errorMessage(error)
} finally {
busy.value = false
}
}
async function removeAsset() {
const asset = selectedAsset.value
if (!asset || busy.value) return
busy.value = true
actionError.value = ''
try {
await projectAssetsApi.remove(asset.projectId, asset.id)
editOpen.value = false
selectedAsset.value = null
await query.refresh()
} catch (error) {
actionError.value = errorMessage(error)
} finally {
busy.value = false
}
}
</script>
<template>
<WorkspacePage compact class="asset-library-page">
<template #header>
<div class="page-heading asset-library-heading">
<div>
<h1>资产库</h1>
<p class="page-description">汇总并管理所有剧本的人物场景道具和参考图片</p>
</div>
<div class="flex items-center gap-2">
<input
ref="fileInput"
class="sr-only"
type="file"
accept="image/jpeg,image/png,image/webp"
aria-label="选择全局资产图片"
@change="selectFile"
/>
<NButton
type="primary"
:disabled="busy || !uploadProjectOptions.length"
@click="fileInput?.click()"
>
<template #icon><Upload :size="16" /></template>上传图片
</NButton>
<NButton
quaternary
class="icon-button"
:loading="query.loading.value || projectsQuery.loading.value"
aria-label="刷新资产库"
title="刷新资产库"
@click="refreshAll"
>
<template #icon><RefreshCw :size="16" /></template>
</NButton>
</div>
</div>
</template>
<NAlert v-if="fileError" type="error" :show-icon="false" class="mb-4">{{ fileError }}</NAlert>
<NAlert v-if="query.error.value" type="error" :show-icon="false" class="mb-4">
{{ query.error.value }}<NButton text class="ml-3" @click="query.refresh">重新连接</NButton>
</NAlert>
<NAlert v-if="projectsQuery.error.value" type="warning" :show-icon="false" class="mb-4">
剧本列表读取失败暂时不能上传新图片
<NButton text class="ml-3" @click="projectsQuery.refresh">重新读取</NButton>
</NAlert>
<div class="asset-library-filters">
<NInput
v-model:value="search"
clearable
placeholder="搜索图片、分类或所属剧本"
:input-props="{ 'aria-label': '搜索资产图片' }"
>
<template #prefix><Search :size="15" /></template>
</NInput>
<NSelect
v-model:value="projectId"
clearable
filterable
placeholder="全部剧本"
:options="projectOptions"
aria-label="筛选所属剧本"
/>
<NSelect
v-model:value="category"
clearable
placeholder="全部分类"
:options="categoryOptions"
aria-label="筛选资产分类"
/>
<span class="text-xs text-muted">{{ filteredAssets.length }} / {{ assets.length }} </span>
</div>
<div v-if="filteredAssets.length" class="asset-library-grid">
<article
v-for="asset in filteredAssets"
:key="asset.id"
class="asset-library-card"
role="button"
tabindex="0"
:aria-label="`查看 ${asset.name} 详情`"
@click="openEdit(asset)"
@keydown.enter.prevent="openEdit(asset)"
@keydown.space.prevent="openEdit(asset)"
>
<AssetImage :src="asset.publicUrl" :alt="asset.name" class="asset-library-image" />
<div class="asset-library-overlay">
<NTooltip placement="top-start" trigger="hover" :disabled="!overflowTitles.has(asset.id)">
<template #trigger>
<h2 class="asset-library-title" @mouseenter="checkTitleOverflow(asset.id, $event)">
{{ asset.name }}
</h2>
</template>
{{ asset.name }}
</NTooltip>
<div class="asset-library-meta">
<p class="truncate text-xs" :title="projectName(asset.project)">
{{ projectName(asset.project) }}
</p>
<NTag size="small" :bordered="false" class="asset-library-tag">
{{ categoryLabel(asset.category) }}
</NTag>
</div>
</div>
</article>
</div>
<EmptyState
v-else-if="query.data.value"
:title="assets.length ? '没有匹配的图片' : '资产库还是空的'"
:description="assets.length ? '调整搜索、剧本或分类筛选。' : '选择剧本并上传第一张图片。'"
>
<NButton v-if="assets.length" @click="clearFilters">清除筛选</NButton>
<NButton v-else type="primary" :disabled="!uploadProjectOptions.length" @click="fileInput?.click()">
上传第一张图片
</NButton>
</EmptyState>
<p v-else class="py-10 text-center text-sm text-muted" role="status">正在读取全部资产图片</p>
<AppDialog
v-model:open="uploadOpen"
title="上传资产图片"
description="选择所属剧本并确认名称和分类,提交后仍停留在当前资产库。"
:busy="busy"
>
<NAlert v-if="actionError" type="error" :show-icon="false" class="mt-5">{{ actionError }}</NAlert>
<AppForm :model="uploadForm" :rules="formRules" :disabled="busy" class="mt-5" @submit="uploadAsset">
<p class="mb-4 text-sm">{{ selectedFile?.name }} · {{ formatBytes(selectedFile?.size ?? 0) }}</p>
<NFormItem path="projectId" label="所属剧本">
<NSelect v-model:value="uploadForm.projectId" filterable :options="uploadProjectOptions" />
</NFormItem>
<NFormItem path="name" label="素材名称"><NInput v-model:value="uploadForm.name" /></NFormItem>
<NFormItem path="category" label="分类标识">
<NInput v-model:value="uploadForm.category" placeholder="例如 character、scene、prop" />
</NFormItem>
<div class="mt-4 flex justify-end gap-3">
<NButton :disabled="busy" @click="uploadOpen = false">取消</NButton>
<NButton type="primary" attr-type="submit" :loading="busy">确认上传</NButton>
</div>
</AppForm>
</AppDialog>
<AppDialog
v-model:open="editOpen"
title="素材详情与管理"
description="查看文件详情,也可在当前页面修改名称和分类或删除素材。"
:busy="busy"
wide
>
<NAlert v-if="actionError" type="error" :show-icon="false" class="mt-5">{{ actionError }}</NAlert>
<AppForm :model="editForm" :rules="formRules" :disabled="busy" class="mt-5" @submit="updateAsset">
<div v-if="selectedAsset" class="asset-management-layout">
<AssetImage
:src="selectedAsset.publicUrl"
:alt="selectedAsset.name"
preview
object-fit="contain"
class="asset-management-preview"
/>
<div class="asset-management-fields">
<NFormItem label="所属剧本">
<NInput :value="projectName(selectedAsset.project)" disabled />
</NFormItem>
<NFormItem path="name" label="素材名称"><NInput v-model:value="editForm.name" /></NFormItem>
<NFormItem path="category" label="分类标识">
<NInput v-model:value="editForm.category" />
</NFormItem>
<dl class="asset-detail-grid">
<dt>原始文件名</dt>
<dd>{{ metadataOriginalName(selectedAsset.metadata) }}</dd>
<dt>文件类型</dt>
<dd>{{ selectedAsset.mimeType }} · {{ selectedAsset.extension.toUpperCase() }}</dd>
<dt>文件大小</dt>
<dd>{{ formatBytes(selectedAsset.size) }}</dd>
<dt>创建时间</dt>
<dd>{{ formatDate(selectedAsset.createdAt) }}</dd>
<dt>更新时间</dt>
<dd>{{ formatDate(selectedAsset.updatedAt) }}</dd>
</dl>
</div>
</div>
<div class="asset-management-footer">
<NPopconfirm
:positive-button-props="{ disabled: busy }"
positive-text="确认删除"
negative-text="取消"
@positive-click="removeAsset"
>
<template #trigger>
<NButton class="text-danger" :disabled="busy">
<template #icon><Trash2 :size="14" /></template>删除素材
</NButton>
</template>
删除后物理文件也会移除正在被视觉风格引用的素材会由后端阻止删除
</NPopconfirm>
<div class="flex gap-3">
<NButton :disabled="busy" @click="editOpen = false">取消</NButton>
<NButton type="primary" attr-type="submit" :loading="busy">保存修改</NButton>
</div>
</div>
</AppForm>
</AppDialog>
</WorkspacePage>
</template>
<style scoped>
@reference "../../styles/styles.css";
.asset-library-page {
container: asset-library / inline-size;
}
.asset-library-heading {
@apply flex items-start justify-between gap-4;
}
.asset-library-filters {
@apply grid grid-cols-[minmax(220px,_420px)_220px_180px_auto] items-center gap-3 mb-4 p-3 bg-(--app-subtle);
}
.asset-library-grid {
column-width: 240px;
column-gap: 16px;
}
.asset-library-card {
@apply relative inline-block w-full min-w-0 overflow-hidden bg-(--app-surface) align-top cursor-pointer outline-none;
break-inside: avoid;
margin-bottom: 16px;
}
.asset-library-card:focus-visible {
box-shadow: inset 0 0 0 2px var(--color-accent);
}
.asset-library-image {
@apply block aspect-auto bg-transparent;
}
.asset-library-image :deep(.reveal-image),
.asset-library-image :deep(.n-image) {
@apply block h-auto min-h-0 bg-transparent;
}
.asset-library-image :deep(.n-image img) {
@apply block w-full h-auto min-h-0 object-contain;
}
.asset-library-overlay {
@apply absolute inset-0 flex flex-col justify-end p-[10px] text-white;
background: linear-gradient(to top, rgb(0 0 0 / 0.84) 0, rgb(0 0 0 / 0.35) 42%, transparent 70%);
}
.asset-library-title {
@apply block w-full truncate text-sm font-semibold;
}
.asset-library-meta {
@apply flex min-w-0 items-center justify-between gap-2 mt-1.5;
}
.asset-library-meta p {
color: rgb(255 255 255 / 0.76);
}
.asset-library-tag {
@apply shrink-0 text-white bg-white/15 backdrop-blur-sm;
}
.asset-management-layout {
@apply grid items-start gap-5;
grid-template-columns: max-content minmax(0, 1fr);
}
.asset-management-preview {
--asset-detail-image-max-height: min(56dvh, 560px);
display: block;
width: fit-content;
max-width: 100%;
height: auto;
min-height: 0;
aspect-ratio: auto;
justify-self: start;
}
.asset-management-preview :deep(.reveal-image),
.asset-management-preview :deep(.n-image) {
display: block;
width: fit-content;
max-width: 100%;
height: auto;
min-height: 0;
}
.asset-management-preview :deep(.n-image img) {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: var(--asset-detail-image-max-height);
object-fit: contain;
}
.asset-management-fields {
@apply min-w-0;
}
.asset-management-fields :deep(.n-form-item) {
@apply mb-4;
}
.asset-management-fields :deep(.n-form-item-label) {
@apply pb-2;
}
.asset-detail-grid {
@apply grid grid-cols-[88px_minmax(0,_1fr)] gap-x-4 gap-y-2 mt-1 p-3 bg-(--app-subtle) text-xs;
}
.asset-detail-grid dt {
@apply text-muted;
}
.asset-detail-grid dd {
@apply min-w-0 break-all text-ink;
}
.asset-management-footer {
@apply flex items-center justify-between gap-3 mt-5 pt-4;
}
@container asset-library (max-width: 840px) {
.asset-library-filters {
@apply grid-cols-2;
}
.asset-management-layout {
@apply grid-cols-1;
}
.asset-management-preview {
--asset-detail-image-max-height: min(42dvh, 420px);
}
}
@container asset-library (max-width: 560px) {
.asset-library-heading {
@apply items-start;
}
.asset-library-filters {
@apply grid-cols-1;
}
.asset-library-filters > span {
@apply justify-self-start;
}
}
</style>
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { NAlert, NButton, NFormItem, NInput, NPopconfirm, NSelect, NTag } from 'naive-ui'
import { Pencil, RefreshCw, Search, Trash2, Upload } from '@lucide/vue'
import { AppDialog, AssetImage, EmptyState } from '../../components/ui'
import AppForm from '../../components/ui/AppForm.vue'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { fieldRule } from '../../lib/form-rules'
import { useQuery } from '../../composables/useQuery'
import { runOperation } from '../workflows/operations'
import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard'
import { projectAssetsApi } from './api'
import type { ProjectAsset } from './types'
/** 后端第一阶段只支持这三种图片格式,单文件最多 20MB。 */
const ACCEPTED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])
const MAX_FILE_SIZE = 20 * 1024 * 1024
const categoryPattern = /^[a-z0-9_-]+$/
const categoryLabels: Record<string, string> = {
overall: '综合',
character: '人物',
scene: '场景',
prop: '道具',
reference: '参考素材'
}
const { projectId, blocked } = useProjectMutationGuard()
const query = useQuery(projectId, async (id, signal) => {
const assets = await projectAssetsApi.list(id, signal)
if (assets.some(asset => asset.projectId !== id)) throw new Error('素材列表包含其它项目的数据,请刷新后重试。')
return assets
})
const assets = computed(() => query.data.value ?? [])
const disabled = computed(() => blocked.value || !!query.error.value || !query.data.value)
const search = ref('')
const category = ref<string | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const uploadOpen = ref(false)
const editOpen = ref(false)
const selectedFile = ref<File | null>(null)
const selectedAsset = ref<ProjectAsset | null>(null)
const fileError = ref('')
const uploadForm = reactive({ name: '', category: 'reference' })
const editForm = reactive({ name: '', category: '' })
const categoryOptions = computed(() => {
const values = new Set([...Object.keys(categoryLabels), ...assets.value.map(asset => asset.category)])
return [...values].toSorted().map(value => ({ label: categoryLabel(value), value }))
})
const filteredAssets = computed(() => {
const keyword = search.value.trim().toLowerCase()
return assets.value.filter(asset => {
if (category.value && asset.category !== category.value) return false
if (!keyword) return true
return [asset.name, asset.category, metadataOriginalName(asset.metadata)].some(value =>
value.toLowerCase().includes(keyword)
)
})
})
const formRules = {
name: fieldRule(value => typeof value === 'string' && !!value.trim(), '请输入素材名称'),
category: fieldRule(
value => typeof value === 'string' && categoryPattern.test(value.trim()),
'分类只能包含小写字母、数字、下划线和连字符'
)
}
function categoryLabel(value: string) {
return categoryLabels[value] ?? value
}
function metadataOriginalName(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
const originalName = (value as Record<string, unknown>).originalName
return typeof originalName === 'string' ? originalName : ''
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
function formatDate(value: string) {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function clearFilters() {
search.value = ''
category.value = null
}
/** 先由浏览器校验格式和大小,再打开资料确认弹窗。 */
function selectFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] ?? null
input.value = ''
fileError.value = ''
if (!file) return
if (!ACCEPTED_TYPES.has(file.type)) {
fileError.value = '仅支持 JPEG、PNG、WebP 图片。'
return
}
if (!file.size || file.size > MAX_FILE_SIZE) {
fileError.value = '上传图片不能为空且不能超过 20MB。'
return
}
selectedFile.value = file
uploadForm.name = file.name.replace(/\.[^.]+$/, '') || file.name
uploadForm.category = 'reference'
uploadOpen.value = true
}
async function uploadAsset() {
const file = selectedFile.value
if (!file || disabled.value) return
const id = projectId.value
const ok = await runOperation(id, '上传项目素材', async () => {
const asset = await projectAssetsApi.upload(id, {
file,
name: uploadForm.name.trim(),
category: uploadForm.category.trim()
})
if (!asset || asset.projectId !== id) throw new Error('后端未返回当前项目的素材记录,请刷新核对。')
})
if (!ok || projectId.value !== id) return
uploadOpen.value = false
selectedFile.value = null
await query.refresh()
}
function openEdit(asset: ProjectAsset) {
selectedAsset.value = asset
editForm.name = asset.name
editForm.category = asset.category
editOpen.value = true
}
async function updateAsset() {
const asset = selectedAsset.value
if (!asset || disabled.value) return
const id = projectId.value
const ok = await runOperation(id, '更新项目素材', async () => {
const result = await projectAssetsApi.update(id, asset.id, {
name: editForm.name.trim(),
category: editForm.category.trim()
})
if (!result || result.id !== asset.id || result.projectId !== id)
throw new Error('后端未确认素材更新,请刷新核对。')
})
if (!ok || projectId.value !== id) return
editOpen.value = false
selectedAsset.value = null
await query.refresh()
}
async function removeAsset(asset: ProjectAsset) {
if (disabled.value) return
const id = projectId.value
const ok = await runOperation(id, '删除项目素材', async () => {
const result = await projectAssetsApi.remove(id, asset.id)
if (!result || result.id !== asset.id || result.projectId !== id)
throw new Error('后端未确认素材删除,请刷新核对。')
})
if (ok && projectId.value === id) await query.refresh()
}
</script>
<template>
<WorkspacePage compact class="project-assets-page">
<template #header>
<div class="workspace-toolbar project-assets-toolbar">
<div class="min-w-0">
<h2 class="font-semibold">项目资产库</h2>
<p class="mt-1 text-xs text-muted">集中管理可复用图片素材上传本身不会调用生成模型</p>
</div>
<div class="toolbar-actions">
<input
ref="fileInput"
class="sr-only"
type="file"
accept="image/jpeg,image/png,image/webp"
aria-label="选择项目素材图片"
@change="selectFile"
/>
<NButton type="primary" :disabled="disabled" @click="fileInput?.click()">
<template #icon><Upload :size="16" /></template>上传图片
</NButton>
<NButton
quaternary
class="icon-button"
aria-label="刷新资产库"
title="刷新资产库"
:loading="query.loading.value"
@click="query.refresh"
><template #icon><RefreshCw :size="16" /></template
></NButton>
</div>
</div>
</template>
<NAlert v-if="fileError" type="error" :show-icon="false" class="mb-3">{{ fileError }}</NAlert>
<NAlert v-if="query.error.value" type="error" :show-icon="false" class="mb-3">
{{ query.error.value }} 当前保留上次成功读取的素材写操作已暂停
</NAlert>
<div class="asset-filter-bar">
<NInput
v-model:value="search"
clearable
placeholder="搜索名称、分类或原文件名"
:input-props="{ 'aria-label': '搜索项目素材' }"
>
<template #prefix><Search :size="15" /></template>
</NInput>
<NSelect
v-model:value="category"
clearable
placeholder="全部分类"
:options="categoryOptions"
aria-label="筛选素材分类"
/>
<span class="text-xs text-muted">{{ filteredAssets.length }} / {{ assets.length }} </span>
</div>
<div v-if="filteredAssets.length" class="project-asset-grid">
<article v-for="asset in filteredAssets" :key="asset.id" class="project-asset-card">
<AssetImage :src="asset.publicUrl" :alt="asset.name" preview class="project-asset-image" />
<div class="project-asset-content">
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="truncate text-sm font-semibold" :title="asset.name">{{ asset.name }}</h3>
<p v-if="metadataOriginalName(asset.metadata)" class="mt-1 truncate text-[11px] text-muted">
{{ metadataOriginalName(asset.metadata) }}
</p>
</div>
<NTag size="small" :bordered="false">{{ categoryLabel(asset.category) }}</NTag>
</div>
<p class="mt-3 text-[11px] text-muted">
{{ asset.extension.toUpperCase() }} · {{ formatBytes(asset.size) }} ·
{{ formatDate(asset.createdAt) }}
</p>
<div class="mt-3 flex items-center gap-3">
<NButton text size="small" :disabled="disabled" @click="openEdit(asset)">
<template #icon><Pencil :size="14" /></template>编辑
</NButton>
<NPopconfirm
:positive-button-props="{ disabled }"
positive-text="确认删除"
negative-text="取消"
@positive-click="removeAsset(asset)"
>
<template #trigger>
<NButton text size="small" class="text-danger" :disabled="disabled">
<template #icon><Trash2 :size="14" /></template>删除
</NButton>
</template>
删除后物理文件也会移除正在被视觉风格引用的素材会由后端阻止删除
</NPopconfirm>
</div>
</div>
</article>
</div>
<EmptyState
v-else-if="query.data.value"
:title="assets.length ? '没有匹配的素材' : '资产库还是空的'"
:description="assets.length ? '调整搜索或分类筛选。' : '上传 JPEG、PNG 或 WebP 图片,单张不超过 20MB。'"
>
<NButton v-if="assets.length" @click="clearFilters">清除筛选</NButton>
<NButton v-else type="primary" :disabled="disabled" @click="fileInput?.click()">上传第一张图片</NButton>
</EmptyState>
<p v-else class="py-10 text-center text-sm text-muted" role="status">正在读取项目素材</p>
<AppDialog
v-model:open="uploadOpen"
title="上传项目素材"
description="确认名称和分类后上传。分类会用于资产筛选,并进入后端存储路径。"
:busy="blocked"
>
<AppForm :model="uploadForm" :rules="formRules" :disabled="blocked" class="mt-5" @submit="uploadAsset">
<p class="mb-4 text-sm">{{ selectedFile?.name }} · {{ formatBytes(selectedFile?.size ?? 0) }}</p>
<NFormItem path="name" label="素材名称"><NInput v-model:value="uploadForm.name" /></NFormItem>
<NFormItem path="category" label="分类标识">
<NInput v-model:value="uploadForm.category" placeholder="例如 character、scene、prop" />
</NFormItem>
<div class="mt-4 flex justify-end gap-3">
<NButton :disabled="blocked" @click="uploadOpen = false">取消</NButton>
<NButton type="primary" attr-type="submit" :loading="blocked">确认上传</NButton>
</div>
</AppForm>
</AppDialog>
<AppDialog
v-model:open="editOpen"
title="编辑素材信息"
description="只修改名称和分类,不会替换图片文件或修改已保存的引用地址。"
:busy="blocked"
>
<AppForm :model="editForm" :rules="formRules" :disabled="blocked" class="mt-5" @submit="updateAsset">
<NFormItem path="name" label="素材名称"><NInput v-model:value="editForm.name" /></NFormItem>
<NFormItem path="category" label="分类标识"><NInput v-model:value="editForm.category" /></NFormItem>
<div class="mt-4 flex justify-end gap-3">
<NButton :disabled="blocked" @click="editOpen = false">取消</NButton>
<NButton type="primary" attr-type="submit" :loading="blocked">保存修改</NButton>
</div>
</AppForm>
</AppDialog>
</WorkspacePage>
</template>
<style>
@reference "../../styles/styles.css";
.project-assets-page {
container: project-assets / inline-size;
}
.project-assets-toolbar {
@apply items-start;
}
.asset-filter-bar {
@apply grid grid-cols-[minmax(220px,_420px)_180px_auto] items-center gap-3 py-3 px-4 bg-(--app-subtle);
}
.project-asset-grid {
@apply grid grid-cols-[repeat(auto-fill,_minmax(min(250px,_100%),_1fr))] gap-4 pt-4;
}
.project-asset-card {
@apply min-w-0 overflow-hidden bg-(--app-surface);
}
.project-asset-image {
@apply aspect-[4/3];
}
.project-asset-content {
@apply p-4;
}
@container project-assets (max-width: 620px) {
.asset-filter-bar {
@apply grid-cols-1;
}
.asset-filter-bar > span {
@apply justify-self-start;
}
}
</style>
+27
View File
@@ -0,0 +1,27 @@
import { request } from '../../lib/http'
import type { GlobalProjectAsset, ProjectAsset, UpdateProjectAssetInput, UploadProjectAssetInput } from './types'
/** 固定项目资产路径并编码正式数据库 ID。 */
function assetPath(projectId: string, assetId?: string) {
const base = `/projects/${encodeURIComponent(projectId)}/assets`
return assetId ? `${base}/${encodeURIComponent(assetId)}` : base
}
/** 项目素材 API;上传使用 multipart,浏览和编辑不调用生成模型。 */
export const projectAssetsApi = {
listAll: (signal?: AbortSignal) => request<GlobalProjectAsset[]>('/assets', { signal }),
list: (projectId: string, signal?: AbortSignal) => request<ProjectAsset[]>(assetPath(projectId), { signal }),
get: (projectId: string, assetId: string, signal?: AbortSignal) =>
request<ProjectAsset>(assetPath(projectId, assetId), { signal }),
upload: (projectId: string, input: UploadProjectAssetInput) => {
const body = new FormData()
body.append('file', input.file)
body.append('name', input.name)
body.append('category', input.category)
return request<ProjectAsset>(assetPath(projectId), { method: 'POST', body, timeoutMs: 0 })
},
update: (projectId: string, assetId: string, input: UpdateProjectAssetInput) =>
request<ProjectAsset>(assetPath(projectId, assetId), { method: 'PUT', body: input }),
remove: (projectId: string, assetId: string) =>
request<ProjectAsset>(assetPath(projectId, assetId), { method: 'DELETE' })
}
+2
View File
@@ -0,0 +1,2 @@
export { projectAssetsApi } from './api'
export type { ProjectAsset, UpdateProjectAssetInput, UploadProjectAssetInput } from './types'
+42
View File
@@ -0,0 +1,42 @@
/** 项目资产库当前只接收图片;保留 type 字段以兼容后端后续扩展。 */
export interface ProjectAsset {
id: string
projectId: string
name: string
type: string
category: string
mimeType: string
extension: string
size: number
publicUrl: string
metadata: unknown
createdAt: string
updatedAt: string
}
/** 全局资产列表附带最小剧本信息,供筛选和来源标识使用。 */
export interface ProjectAssetProject {
id: string
title: string | null
topic: string
status: string
}
/** 全局资产接口记录。 */
export interface GlobalProjectAsset extends ProjectAsset {
project: ProjectAssetProject
}
/** 素材编辑只修改展示信息,不替换物理文件。 */
export interface UpdateProjectAssetInput {
name?: string
category?: string
metadata?: Record<string, unknown>
}
/** 上传表单与后端 multipart 字段一一对应。 */
export interface UploadProjectAssetInput {
file: File
name: string
category: string
}
+64 -3
View File
@@ -21,7 +21,10 @@ mobileMedia.addEventListener('change', syncMobile)
onScopeDispose(() => mobileMedia.removeEventListener('change', syncMobile)) onScopeDispose(() => mobileMedia.removeEventListener('change', syncMobile))
// 图库使用手动刷新,父布局也停止轮询,避免浏览素材时被周期性更新打断。 // 图库使用手动刷新,父布局也停止轮询,避免浏览素材时被周期性更新打断。
const isGallery = computed(() => route.path.replace(/\/+$/, '').endsWith('/subject-images')) const isGallery = computed(() => {
const path = route.path.replace(/\/+$/, '')
return path.endsWith('/subject-images') || path.endsWith('/assets')
})
const context = useProjectData( const context = useProjectData(
id, id,
computed(() => (isGallery.value ? false : 6000)) computed(() => (isGallery.value ? false : 6000))
@@ -30,6 +33,48 @@ provide(projectContextKey, context)
const operation = computed(() => getOperation(id.value)) const operation = computed(() => getOperation(id.value))
const complete = computed(() => isProjectComplete(context.project.value, id.value)) const complete = computed(() => isProjectComplete(context.project.value, id.value))
const isCreation = computed(() => route.path.replace(/\/+$/, '').endsWith('/create-drama')) const isCreation = computed(() => route.path.replace(/\/+$/, '').endsWith('/create-drama'))
type OperationGuide = { title: string; description: string; action?: string; target?: 'profile' | 'style' | 'identity' }
const operationGuide = computed<OperationGuide | null>(() => {
const error = operation.value.error?.trim()
if (!error) return null
const normalized = error.toLowerCase()
if (normalized.includes('creative profile'))
return {
title: '缺少项目生成配置',
description: '先选择图片和视频生成模型,保存后再继续当前操作。',
action: '配置生成模型',
target: 'profile'
}
if (error.includes('视觉风格') || normalized.includes('visual style'))
return {
title: '缺少项目视觉风格',
description: '先完成视觉风格,再回来继续生成。',
action: '设置视觉风格',
target: 'style'
}
if (error.includes('身份') || normalized.includes('identity'))
return {
title: '主体身份尚未就绪',
description: '先按主体身份页的步骤补齐文本、母版并完成锁定。',
action: '处理主体身份',
target: 'identity'
}
return { title: '操作未完成', description: error }
})
/** 已知前置条件直接送达对应工作区;保留当前页面,修复后可继续原操作。 */
function resolveOperationError() {
const target = operationGuide.value?.target
if (target === 'style') void router.push(`/projects/${id.value}/visual-style`)
else if (target === 'identity') void router.push(`/projects/${id.value}/subject-identity`)
else if (target === 'profile') {
const onIdentityPage = route.path.replace(/\/+$/, '').endsWith('/subject-identity')
void router.push({
path: onIdentityPage ? route.path : `/projects/${id.value}/production`,
query: { ...route.query, configure: 'profile' }
})
}
}
const access = inject(projectAccessKey, undefined) const access = inject(projectAccessKey, undefined)
let published: ProjectAccess | null = null let published: ProjectAccess | null = null
watchEffect(() => { watchEffect(() => {
@@ -65,7 +110,17 @@ onScopeDispose(() => {
<NAlert v-if="operation.pending" :show-icon="false" role="status" <NAlert v-if="operation.pending" :show-icon="false" role="status"
>{{ operation.label }}可切换页面查看结果请勿重复提交</NAlert >{{ operation.label }}可切换页面查看结果请勿重复提交</NAlert
> >
<NAlert v-if="operation.error" type="error" :show-icon="false">{{ operation.error }}</NAlert> <NAlert v-if="operationGuide" type="error" :show-icon="false" class="operation-guide">
<div class="operation-guide-content">
<div>
<strong>{{ operationGuide.title }}</strong>
<span class="ml-2">{{ operationGuide.description }}</span>
</div>
<NButton v-if="operationGuide.action" size="small" type="primary" @click="resolveOperationError">{{
operationGuide.action
}}</NButton>
</div>
</NAlert>
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert> <NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
</NScrollbar> </NScrollbar>
<div class="project-view"> <div class="project-view">
@@ -74,7 +129,7 @@ onScopeDispose(() => {
v-else-if="context.project.value?.id === id" v-else-if="context.project.value?.id === id"
class="project-access-gate" class="project-access-gate"
title="请先完成剧本创作" title="请先完成剧本创作"
:description="`剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、分镜设计与镜头生产。${isGallery ? '请手动刷新项目状态。' : '状态会自动更新。'}`" :description="`剧本完成后,才能使用拆解、视觉风格、主体身份、形态图片、资产库、分镜设计与镜头生产。${isGallery ? '请手动刷新项目状态。' : '状态会自动更新。'}`"
> >
<NButton type="primary" @click="router.push(`/projects/${id}/create-drama`)">返回剧本创作</NButton> <NButton type="primary" @click="router.push(`/projects/${id}/create-drama`)">返回剧本创作</NButton>
<NButton v-if="isGallery" class="ml-3" :loading="context.loading.value" @click="context.refresh" <NButton v-if="isGallery" class="ml-3" :loading="context.loading.value" @click="context.refresh"
@@ -115,6 +170,9 @@ onScopeDispose(() => {
.project-notices .n-alert { .project-notices .n-alert {
@apply py-[7px] px-3 text-xs; @apply py-[7px] px-3 text-xs;
} }
.operation-guide-content {
@apply flex items-center justify-between gap-4;
}
.project-view { .project-view {
@apply flex-1 min-h-0 overflow-hidden; @apply flex-1 min-h-0 overflow-hidden;
} }
@@ -134,5 +192,8 @@ onScopeDispose(() => {
.project-title { .project-title {
@apply text-sm; @apply text-sm;
} }
.operation-guide-content {
@apply items-start flex-col gap-2;
}
} }
</style> </style>
+35 -16
View File
@@ -1,10 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, ref, watch } from 'vue' import { computed, h, nextTick, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import { NAlert, NButton, NDataTable, NInput, NRadioButton, NRadioGroup, type DataTableColumns } from 'naive-ui' import {
NAlert,
NButton,
NDataTable,
NInput,
NRadioButton,
NRadioGroup,
type DataTableColumns,
type DataTableInst
} from 'naive-ui'
import { RefreshCw, Search } from '@lucide/vue' import { RefreshCw, Search } from '@lucide/vue'
import WorkspacePage from '../../components/ui/WorkspacePage.vue' import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import { usePolling } from '../../composables/usePolling' import { useQuery } from '../../composables/useQuery'
import { StatusBadge } from '../../components/ui' import { StatusBadge } from '../../components/ui'
import { formatDate } from '../../lib/format' import { formatDate } from '../../lib/format'
import { projectsApi } from './api' import { projectsApi } from './api'
@@ -13,11 +22,14 @@ import CreateProjectDialog from './components/CreateProjectDialog.vue'
/** 项目表格使用固定表头与独立滚动;搜索筛选仍作用于真实接口数据。 */ /** 项目表格使用固定表头与独立滚动;搜索筛选仍作用于真实接口数据。 */
const router = useRouter() const router = useRouter()
/** 列表已有刷新按钮,只在进入、筛选切换和手动刷新时读取。 */ /** 列表已有刷新按钮,只在进入和手动刷新时读取;筛选在完整结果中进行。 */
const query = usePolling(ref('projects'), (_, signal) => projectsApi.list(signal), false) const query = useQuery(ref('projects'), (_, signal) => projectsApi.list(signal))
const search = ref('') const search = ref('')
const filter = ref('all') const filter = ref('all')
const page = ref(1) /** 接口暂未分页:对完整查询结果分批展示,滚动时追加而非切换页码。 */
const PAGE_SIZE = 20
const visibleCount = ref(PAGE_SIZE)
const table = ref<DataTableInst | null>(null)
const projects = computed(() => query.data.value ?? []) const projects = computed(() => query.data.value ?? [])
const filtered = computed(() => const filtered = computed(() =>
projects.value.filter(item => { projects.value.filter(item => {
@@ -34,9 +46,20 @@ const filters = [
{ value: 'need_review', label: '待审核' }, { value: 'need_review', label: '待审核' },
{ value: 'failed', label: '失败' } { value: 'failed', label: '失败' }
] ]
watch([search, filter], () => { const visibleProjects = computed(() => filtered.value.slice(0, visibleCount.value))
page.value = 1 /** 条件或接口数据更新后从首批重新展示,避免保留旧列表的滚动位置。 */
watch([search, filter, query.data], async () => {
visibleCount.value = PAGE_SIZE
await nextTick()
table.value?.scrollTo({ top: 0 })
}) })
/** 靠近表格底部时追加下一批,保留已有行与当前位置。 */
function loadMore(event: Event) {
const scroller = event.target as HTMLElement | null
if (!scroller || query.loading.value || visibleCount.value >= filtered.value.length) return
if (scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight > 120) return
visibleCount.value = Math.min(visibleCount.value + PAGE_SIZE, filtered.value.length)
}
/** 表格列通过正式项目 ID 跳转,不依赖列表索引。 */ /** 表格列通过正式项目 ID 跳转,不依赖列表索引。 */
const columns: DataTableColumns<Project> = [ const columns: DataTableColumns<Project> = [
{ {
@@ -58,7 +81,7 @@ const columns: DataTableColumns<Project> = [
function openProject(id: string) { function openProject(id: string) {
void router.push('/projects/' + encodeURIComponent(id) + '/create-drama') void router.push('/projects/' + encodeURIComponent(id) + '/create-drama')
} }
/** 清空条件后返回第一页,不改变后端数据。 */ /** 清空条件后返回首批,不改变后端数据。 */
function clearFilters() { function clearFilters() {
search.value = '' search.value = ''
filter.value = 'all' filter.value = 'all'
@@ -115,18 +138,19 @@ function clearFilters() {
> >
<div class="project-table-area"> <div class="project-table-area">
<NDataTable <NDataTable
ref="table"
class="project-data-table" class="project-data-table"
:bordered="false" :bordered="false"
:bottom-bordered="false" :bottom-bordered="false"
striped striped
:columns="columns" :columns="columns"
:data="filtered" :data="visibleProjects"
:row-key="row => row.id" :row-key="row => row.id"
:loading="query.loading.value" :loading="query.loading.value"
flex-height flex-height
table-layout="fixed" table-layout="fixed"
:scroll-x="785" :scroll-x="785"
:pagination="{ page, pageSize: 20, onUpdatePage: (value: number) => (page = value) }" @scroll="loadMore"
> >
<template #empty <template #empty
><div> ><div>
@@ -144,11 +168,6 @@ function clearFilters() {
> >
</NDataTable> </NDataTable>
</div> </div>
<p class="text-xs text-muted">
共 {{ filtered.length }} 个项目<span v-if="query.updatedAt.value">
· 更新于 {{ formatDate(query.updatedAt.value) }}</span
>
</p>
</WorkspacePage> </WorkspacePage>
</template> </template>
@@ -11,12 +11,16 @@ import { errorMessage } from '../../../lib/http'
/** 新建剧本表单,202 成功后交由父级跳转,不在前端模拟生成。 */ /** 新建剧本表单,202 成功后交由父级跳转,不在前端模拟生成。 */
const emit = defineEmits<{ created: [projectId: string] }>() const emit = defineEmits<{ created: [projectId: string] }>()
/** 新建剧本弹窗是否打开。 */
const open = ref(false) const open = ref(false)
/** 创建请求是否正在提交。 */
const busy = ref(false) const busy = ref(false)
/** 创建请求的错误提示。 */
const error = ref('') const error = ref('')
const form = reactive({ topic: '', style: '', episodeCount: 3 }) /** 与后端 POST /projects 入参一致的创建表单。 */
const form = reactive({ title: '', topic: '', style: '', episodeCount: 1 })
/** 校验正整数集数和主题,禁止双击产生重复项目。 */ /** 校验项目标题、主题与正整数集数,禁止双击产生重复项目。 */
async function submit() { async function submit() {
if (busy.value) return if (busy.value) return
error.value = '' error.value = ''
@@ -24,10 +28,12 @@ async function submit() {
try { try {
const result = await projectsApi.create({ const result = await projectsApi.create({
...form, ...form,
title: form.title.trim(),
topic: form.topic.trim(), topic: form.topic.trim(),
style: form.style.trim() style: form.style.trim()
}) })
open.value = false open.value = false
form.title = ''
form.topic = '' form.topic = ''
emit('created', result.projectId) emit('created', result.projectId)
} catch (cause) { } catch (cause) {
@@ -36,7 +42,12 @@ async function submit() {
busy.value = false busy.value = false
} }
} }
const rules = { topic: requiredTextRule('故事主题'), episodeCount: integerRule('计划集数') } /** 创建项目表单校验规则。 */
const rules = {
title: requiredTextRule('项目标题'),
topic: requiredTextRule('故事主题'),
episodeCount: integerRule('计划集数')
}
</script> </script>
<template> <template>
@@ -57,6 +68,15 @@ const rules = { topic: requiredTextRule('故事主题'), episodeCount: integerRu
class="mt-7 space-y-4" class="mt-7 space-y-4"
@submit="submit" @submit="submit"
> >
<!-- 以下是项目基本信息模块 -->
<NFormItem path="title" label="项目标题" :label-props="{ for: 'title' }"
><NInput
v-model:value="form.title"
placeholder="例如:记忆当铺"
:disabled="busy"
:input-props="{ id: 'title', autocomplete: 'off' }"
></NInput
></NFormItem>
<NFormItem path="topic" label="故事主题" :label-props="{ for: 'topic' }" <NFormItem path="topic" label="故事主题" :label-props="{ for: 'topic' }"
><NInput ><NInput
placeholder="描述主角、故事背景,以及你希望展开的核心冲突……" placeholder="描述主角、故事背景,以及你希望展开的核心冲突……"
@@ -68,6 +88,7 @@ const rules = { topic: requiredTextRule('故事主题'), episodeCount: integerRu
:autosize="{ minRows: 3, maxRows: 12 }" :autosize="{ minRows: 3, maxRows: 12 }"
></NInput ></NInput
></NFormItem> ></NFormItem>
<!-- 以下是生成参数模块 -->
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4">
<NFormItem path="style" label="剧本风格" :label-props="{ for: 'style' }" <NFormItem path="style" label="剧本风格" :label-props="{ for: 'style' }"
><NInput ><NInput
@@ -92,7 +113,7 @@ const rules = { topic: requiredTextRule('故事主题'), episodeCount: integerRu
></NFormItem> ></NFormItem>
</div> </div>
<p class="text-xs leading-5 text-muted"> <p class="text-xs leading-5 text-muted">
建议先用 3 集验证生成效果生成会实际调用后端模型产生相应费用 建议先用 1 集验证生成效果生成会实际调用后端模型产生相应费用
</p> </p>
<NAlert v-if="error" role="alert" type="error" :show-icon="false" class=" ">{{ error }}</NAlert> <NAlert v-if="error" role="alert" type="error" :show-icon="false" class=" ">{{ error }}</NAlert>
<div class="dialog-footer"> <div class="dialog-footer">
+10 -14
View File
@@ -1,23 +1,19 @@
import { computed, inject, type InjectionKey, type MaybeRefOrGetter } from 'vue' import { computed, inject, type InjectionKey } from 'vue'
import { usePolling } from '../../composables/usePolling' import { useQuery } from '../../composables/useQuery'
import { projectsApi } from './api' import { projectsApi } from './api'
import type { ProjectDetail } from './types' import type { ProjectDetail } from './types'
import type { Checkpoint } from '../workflows/types' import type { Checkpoint } from '../workflows/types'
import type { Ref } from 'vue' import type { Ref } from 'vue'
/** 同一项目的各个 graph 共用一份查询,避免每个面板重复请求。 */ /** 同一项目的各个 graph 共用一份查询,避免每个面板重复请求。 */
export function useProjectData(id: Ref<string>, interval: MaybeRefOrGetter<number | false> = 6000) { export function useProjectData(id: Ref<string>) {
const query = usePolling( const query = useQuery(id, async (projectId, signal) => {
id, const [project, checkpoints] = await Promise.all([
async (projectId, signal) => { projectsApi.detail(projectId, signal),
const [project, checkpoints] = await Promise.all([ projectsApi.checkpoints(projectId, signal)
projectsApi.detail(projectId, signal), ])
projectsApi.checkpoints(projectId, signal) return { project, checkpoints }
]) })
return { project, checkpoints }
},
interval
)
const project = computed<ProjectDetail | null>(() => query.data.value?.project ?? null) const project = computed<ProjectDetail | null>(() => query.data.value?.project ?? null)
const checkpoints = computed<Checkpoint[]>(() => query.data.value?.checkpoints ?? []) const checkpoints = computed<Checkpoint[]>(() => query.data.value?.checkpoints ?? [])
return { ...query, project, checkpoints } return { ...query, project, checkpoints }
+6 -1
View File
@@ -1,10 +1,15 @@
/** 后端 DramaProject.status 的原始取值。 */ /** 后端 DramaProject.status 的原始取值。 */
export type ProjectStatus = 'draft' | 'generating' | 'completed' | 'need_review' | 'failed' export type ProjectStatus = 'draft' | 'generating' | 'completed' | 'need_review' | 'failed'
/** 创建请求不包含标题,标题由后端在工作流收尾时更新。 */ /** 创建请求参数,与后端 POST /projects 入参保持一致。 */
export interface CreateProjectInput { export interface CreateProjectInput {
/** 项目标题 */
title: string
/** 故事主题 */
topic: string topic: string
/** 剧本风格 */
style: string style: string
/** 计划生成集数 */
episodeCount: number episodeCount: number
} }
+1 -1
View File
@@ -298,7 +298,7 @@ const rules = { concurrency: integerRule('批量并发'), maxRepairAttempts: int
修改或覆盖上游设计后后端不会自动使旧状态旧提示词失效请按顺序重生成下游内容自动修复次数仅用于 修改或覆盖上游设计后后端不会自动使旧状态旧提示词失效请按顺序重生成下游内容自动修复次数仅用于
VisualState VisualState
</p> </p>
<NCollapse class="mt-3 pt-3" <NCollapse class="mt-3 pt-3" :default-expanded-names="['details']"
><NCollapseItem name="details" ><NCollapseItem name="details"
><template #header>视频提示词批量操作</template> ><template #header>视频提示词批量操作</template>
<p class="my-3 text-xs leading-6 text-muted"> <p class="my-3 text-xs leading-6 text-muted">
+19 -23
View File
@@ -1,5 +1,5 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { usePolling } from '../../composables/usePolling' import { useQuery } from '../../composables/useQuery'
import { useProjectContext } from '../projects/context' import { useProjectContext } from '../projects/context'
import { breakdownSnapshot } from '../workflows/selectors' import { breakdownSnapshot } from '../workflows/selectors'
import { getOperation, runOperation } from '../workflows/operations' import { getOperation, runOperation } from '../workflows/operations'
@@ -38,28 +38,24 @@ export function useStoryboard() {
0 0
) )
const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value])) const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value]))
/** 分镜页已有刷新按钮,不再定时轮询当前剧集。 */ /** 分镜页仅在进入、切换剧集或显式刷新时读取。 */
const query = usePolling( const query = useQuery(queryKey, async (key, signal) => {
queryKey, const [projectId, number] = JSON.parse(key) as [string, number]
async (key, signal) => { if (!projectId || !number) return null
const [projectId, number] = JSON.parse(key) as [string, number] const [directions, visualStates] = await Promise.all([
if (!projectId || !number) return null storyboardApi.directions(projectId, number, signal),
const [directions, visualStates] = await Promise.all([ storyboardApi.visualStates(projectId, number, signal)
storyboardApi.directions(projectId, number, signal), ])
storyboardApi.visualStates(projectId, number, signal) if (
]) directions.projectId !== projectId ||
if ( visualStates.projectId !== projectId ||
directions.projectId !== projectId || directions.episodeNo !== number ||
visualStates.projectId !== projectId || visualStates.episodeNo !== number
directions.episodeNo !== number || ) {
visualStates.episodeNo !== number throw new Error('分镜查询返回了不匹配的项目或剧集,请刷新后重试。')
) { }
throw new Error('分镜查询返回了不匹配的项目或剧集,请刷新后重试。') return { directions, visualStates }
} })
return { directions, visualStates }
},
false
)
const data = query.data const data = query.data
const session = computed(() => getStoryboardSession(id.value)) const session = computed(() => getStoryboardSession(id.value))
/** 单集重生成返回的是最新镜头正文,优先于不会随局部覆盖更新的历史 Breakdown 快照。 */ /** 单集重生成返回的是最新镜头正文,优先于不会随局部覆盖更新的历史 Breakdown 快照。 */
@@ -17,8 +17,8 @@ import {
NTag NTag
} from 'naive-ui' } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { RefreshCw, UserCheck } from '@lucide/vue' import { Check, Circle, RefreshCw, UserCheck } from '@lucide/vue'
import { AssetImage, DirectoryItem, EmptyState, StatusBadge } from '../../components/ui' import { AssetImage, DirectoryItem, EmptyState, StatusBadge } from '../../components/ui'
import { downloadText } from '../../lib/format' import { downloadText } from '../../lib/format'
import ConfirmAction from '../workflows/ConfirmAction.vue' import ConfirmAction from '../workflows/ConfirmAction.vue'
@@ -29,10 +29,12 @@ import IdentityGallery from './components/IdentityGallery.vue'
import IdentityImageDialog from './components/IdentityImageDialog.vue' import IdentityImageDialog from './components/IdentityImageDialog.vue'
import CastingCandidateDialog from './components/CastingCandidateDialog.vue' import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
import IdentityThumbnail from './components/IdentityThumbnail.vue' import IdentityThumbnail from './components/IdentityThumbnail.vue'
import CreativeProfileSettings from '../generation-config/components/CreativeProfileSettings.vue'
import type { IdentitySubject } from './types' import type { IdentitySubject } from './types'
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */ /** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
const route = useRoute() const route = useRoute()
const router = useRouter()
const { const {
projectId, projectId,
operation, operation,
@@ -42,10 +44,10 @@ const {
catalog, catalog,
castingQuery, castingQuery,
styleQuery, styleQuery,
profileQuery,
detail, detail,
identity, identity,
images, images,
castingItem,
session, session,
concurrency, concurrency,
candidateLimit, candidateLimit,
@@ -56,6 +58,7 @@ const {
castingBlocked, castingBlocked,
detailBlocked, detailBlocked,
hasStyle, hasStyle,
hasCreativeProfile,
canGenerateText, canGenerateText,
canGenerateImage, canGenerateImage,
canGenerateCandidate, canGenerateCandidate,
@@ -64,6 +67,7 @@ const {
save, save,
generate, generate,
generateProject, generateProject,
generateStableAnchors,
generateCastingCandidates, generateCastingCandidates,
generateImage, generateImage,
generateCastingCandidate, generateCastingCandidate,
@@ -72,6 +76,7 @@ const {
} = useSubjectIdentity() } = useSubjectIdentity()
const search = ref('') const search = ref('')
const module = ref('all') const module = ref('all')
const identityBatchModule = ref<'all' | 'character' | 'scene' | 'prop'>('all')
const imageOpen = ref(false) const imageOpen = ref(false)
const castingOpen = ref(false) const castingOpen = ref(false)
const pendingSelection = ref('') const pendingSelection = ref('')
@@ -160,6 +165,121 @@ watch(
) )
const formModel = computed(() => ({ concurrency: concurrency.value, candidateLimit: candidateLimit.value })) const formModel = computed(() => ({ concurrency: concurrency.value, candidateLimit: candidateLimit.value }))
const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRule('本批上限') } const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRule('本批上限') }
type IdentityFlowAction = 'style' | 'profile' | 'identity' | 'candidate' | 'confirm' | 'lock' | 'forms'
const primaryCandidate = computed(() =>
images.value.find(image => image.viewType === 'primary' && image.status === 'completed' && image.imageUrl)
)
const identityTextReady = computed(() => !!identity.value?.generationPrompt?.trim())
const candidateReady = computed(() => !!anchor.value || !!primaryCandidate.value)
const identityReady = computed(() => !!anchor.value && !!identity.value?.isLocked)
const identityFlow = computed(() => {
const character = subject.value?.module === 'character'
return [
{ label: '身份文本', done: identityTextReady.value },
{ label: character ? '选角候选' : '身份参考图', done: candidateReady.value },
{ label: '确认并锁定', done: identityReady.value }
]
})
const currentFlowIndex = computed(() => {
const index = identityFlow.value.findIndex(step => !step.done)
return index < 0 ? identityFlow.value.length : index
})
const nextAction = computed<{ action: IdentityFlowAction; title: string; description: string; label: string }>(() => {
if (!hasStyle.value)
return {
action: 'style',
title: '先设置视觉风格',
description: '身份生成需要项目视觉风格。',
label: '设置视觉风格'
}
if (!hasCreativeProfile.value)
return {
action: 'profile',
title: '先配置生成模型',
description: '选择项目图片与视频模型后才能生图。',
label: '配置生成模型'
}
if (!identityTextReady.value)
return {
action: 'identity',
title: '第 1 步:生成身份文本',
description: '生成稳定身份描述和核心提示词。',
label: '生成身份文本'
}
if (!candidateReady.value)
return subject.value?.module === 'character'
? {
action: 'candidate',
title: '第 2 步:生成选角候选',
description: '生成候选后再确认正式演员。',
label: '生成选角候选'
}
: {
action: 'candidate',
title: '第 2 步:生成身份参考图',
description: '生成一张可选为母版的参考图。',
label: '生成身份参考图'
}
if (anchor.value && !identity.value?.isLocked)
return {
action: 'lock',
title: '第 3 步:锁定身份',
description: '勾选锁定并保存,后续生成才会稳定引用当前母版。',
label: '前往锁定'
}
if (!identityReady.value)
return {
action: 'confirm',
title: '第 3 步:确认并锁定',
description: '在下方选择母版;人物会同时锁定,场景和道具还需保存锁定状态。',
label: '前往确认母版'
}
return {
action: 'forms',
title: '主体身份已完成',
description: '可以继续生成此主体的形态图片。',
label: '查看形态图片'
}
})
function openProfileSettings() {
toolsOpen.value = true
overview.value = []
if (route.query.configure !== 'profile') void router.replace({ query: { ...route.query, configure: 'profile' } })
}
function runNextAction() {
if (nextAction.value.action === 'style') void router.push(`/projects/${projectId.value}/visual-style`)
else if (nextAction.value.action === 'profile') openProfileSettings()
else if (nextAction.value.action === 'identity') void generate()
else if (nextAction.value.action === 'candidate') {
if (subject.value?.module === 'character') castingOpen.value = true
else imageOpen.value = true
} else if (nextAction.value.action === 'confirm') {
document.querySelector('#identity-reference-gallery')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else if (nextAction.value.action === 'lock') {
document.querySelector('#identity-lock')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
} else if (nextAction.value.action === 'forms') {
void router.push({
path: `/projects/${projectId.value}/subject-images`,
query: { subjectRef: subject.value?.ref }
})
}
}
async function profileSaved() {
operation.value.error = ''
await profileQuery.refresh()
}
watch(
() => route.query.configure,
value => {
if (value === 'profile') openProfileSettings()
},
{ immediate: true }
)
</script> </script>
<template> <template>
@@ -186,6 +306,7 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
title="角色选角总览与批量操作" title="角色选角总览与批量操作"
:has-receipt="!!session.receipt" :has-receipt="!!session.receipt"
> >
<CreativeProfileSettings :project-id="projectId" @saved="profileSaved" />
<div class="identity-tools-intro flex flex-wrap items-end justify-between gap-4"> <div class="identity-tools-intro flex flex-wrap items-end justify-between gap-4">
<div> <div>
<h2 class="text-lg font-semibold">主体身份</h2> <h2 class="text-lg font-semibold">主体身份</h2>
@@ -301,6 +422,7 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
blocked || blocked ||
!hasStyle || !hasStyle ||
!batchValid || !batchValid ||
!candidateLimitValid ||
dirty || dirty ||
!casting.total !casting.total
" "
@@ -343,13 +465,63 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
/></NButton> /></NButton>
</div> </div>
</section> </section>
<NCollapse class="panel mb-5 p-5" <section class="panel mb-5 p-5" aria-label="场景与道具身份母版">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 class="text-sm font-semibold">场景与道具稳定母版</h3>
<p class="mt-2 text-xs leading-6 text-muted">
为已有身份提示词但缺少有效母版的场景或道具小批量生图成功后后端会自动设为
Anchor 并锁定 Identity
</p>
</div>
<div class="flex flex-wrap gap-3">
<ConfirmAction
label="生成场景母版"
:disabled="
blocked ||
!hasStyle ||
!batchValid ||
!candidateLimitValid ||
dirty ||
!subjects.length
"
acknowledgement
:description="`最多处理 ${candidateLimit} 个场景,生成成功后自动设为身份母版并锁定。可能产生图片模型费用。`"
@confirm="generateStableAnchors('scene')"
/>
<ConfirmAction
label="生成道具母版"
:disabled="
blocked ||
!hasStyle ||
!batchValid ||
dirty ||
!subjects.length
"
acknowledgement
:description="`最多处理 ${candidateLimit} 个道具,生成成功后自动设为身份母版并锁定。可能产生图片模型费用。`"
@confirm="generateStableAnchors('prop')"
/>
</div>
</div>
</section>
<NCollapse class="panel mb-5 p-5" :default-expanded-names="['details']"
><NCollapseItem name="details" ><NCollapseItem name="details"
><template #header>高级全部主体身份文本</template> ><template #header>高级全部主体身份文本</template>
<p class="mt-3 text-xs leading-6 text-muted"> <p class="mt-3 text-xs leading-6 text-muted">
同时处理 CharacterScene 同时处理 CharacterScene
Prop角色选角建议优先使用上方专用入口已锁定身份始终跳过 Prop角色选角建议优先使用上方专用入口已锁定身份始终跳过
</p> </p>
<NSelect
v-model:value="identityBatchModule"
class="mt-4 w-48"
aria-label="身份文本批处理范围"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('仅人物'), value: 'character' },
{ label: String('仅场景'), value: 'scene' },
{ label: String('仅道具'), value: 'prop' }
]" />
<ConfirmAction <ConfirmAction
class="mt-4" class="mt-4"
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'" :label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
@@ -358,7 +530,17 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
" "
acknowledgement acknowledgement
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。" description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
@confirm="generateProject(false)" /></NCollapseItem @confirm="
identityBatchModule === 'character'
? generateProject(true)
: generateProject(
false,
identityBatchModule === 'scene' ||
identityBatchModule === 'prop'
? identityBatchModule
: undefined
)
" /></NCollapseItem
></NCollapse> ></NCollapse>
<section <section
v-if="session.receipt" v-if="session.receipt"
@@ -382,13 +564,25 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
{{ session.receipt.result.skippedLocked }}· 失败 {{ session.receipt.result.skippedLocked }}· 失败
{{ session.receipt.result.failed }} {{ session.receipt.result.failed }}
</p> </p>
<p v-else class="mt-3 text-xs text-muted"> <p
v-else-if="session.receipt.kind === 'casting'"
class="mt-3 text-xs text-muted"
>
{{ session.receipt.result.totalCharacters }} 个角色 · 缺候选 {{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
{{ session.receipt.result.missingAnchor }} · 本批目标 {{ session.receipt.result.missingAnchor }} · 本批目标
{{ session.receipt.result.targetCount }} · 已生成 {{ session.receipt.result.targetCount }} · 已生成
{{ session.receipt.result.generated }} · 失败 {{ session.receipt.result.generated }} · 失败
{{ session.receipt.result.failed }} {{ session.receipt.result.failed }}
</p> </p>
<p v-else class="mt-3 text-xs text-muted">
{{ session.receipt.result.total }} {{
session.receipt.result.module === 'scene' ? '场景' : '道具'
}}
· 符合条件 {{ session.receipt.result.eligibleCount }} · 本批目标
{{ session.receipt.result.targetCount }} · 已生成并锁定
{{ session.receipt.result.generated }} · 失败
{{ session.receipt.result.failed }}
</p>
<NAlert <NAlert
v-if="session.receipt.result.failed" v-if="session.receipt.result.failed"
role="alert" role="alert"
@@ -428,6 +622,17 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
当前相关操作已暂停请刷新核对后端 当前相关操作已暂停请刷新核对后端
</NAlert></NScrollbar </NAlert></NScrollbar
> >
<NAlert
v-if="profileQuery.data.value && !hasCreativeProfile"
type="warning"
:show-icon="false"
class="identity-prerequisite"
>
<div class="flex flex-wrap items-center justify-between gap-3">
<span><strong>需要先配置项目生成模型</strong>否则身份图和选角候选无法生成</span>
<NButton type="primary" @click="openProfileSettings">立即配置</NButton>
</div>
</NAlert>
<div v-if="subjects.length" class="identity-workspace"> <div v-if="subjects.length" class="identity-workspace">
<!-- 以下是主体目录 --> <!-- 以下是主体目录 -->
<aside class="panel directory-panel identity-directory"> <aside class="panel directory-panel identity-directory">
@@ -504,20 +709,6 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
<h3 class="font-semibold"> <h3 class="font-semibold">
{{ subject.name }} <code class="subject-ref ml-2">{{ subject.ref }}</code> {{ subject.name }} <code class="subject-ref ml-2">{{ subject.ref }}</code>
</h3> </h3>
<p class="mt-2 text-xs text-muted">
<template v-if="subject.module === 'character' && castingItem">
选角状态{{ castingStatusLabel(castingItem.status) }}
</template>
<template v-else>
{{
identity
? identity.isLocked
? '身份文本已锁定'
: '身份文本未锁定'
: '尚未创建身份'
}}
</template>
</p>
</div> </div>
<NButton :disabled="detail.loading.value" @click="detail.refresh" text size="small"> <NButton :disabled="detail.loading.value" @click="detail.refresh" text size="small">
刷新身份 刷新身份
@@ -530,25 +721,39 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
正在读取主体身份 正在读取主体身份
</p> </p>
<template v-if="detail.data.value"> <template v-if="detail.data.value">
<div class="mt-4 flex flex-wrap gap-3"> <div class="identity-flow mt-4" aria-label="主体身份操作步骤">
<ConfirmAction <ol class="identity-flow-steps">
:label="identity ? 'AI 重新生成身份' : 'AI 生成身份'" <li
:disabled="!canGenerateText" v-for="(step, index) in identityFlow"
acknowledgement :key="step.label"
description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。" :class="{ done: step.done, current: index === currentFlowIndex }"
@confirm="generate" >
/><NButton <span class="identity-flow-marker">
v-if="subject.module === 'character'" <Check v-if="step.done" :size="14" />
:disabled="!canGenerateCandidate" <Circle v-else :size="12" />
@click="castingOpen = true" </span>
> <span>{{ index + 1 }}. {{ step.label }}</span>
生成选角候选</NButton </li>
><NButton </ol>
v-if="subject.module !== 'character' || !!anchor" <div class="identity-next-action">
:disabled="!canGenerateImage" <div>
@click="imageOpen = true" <strong>{{ nextAction.title }}</strong>
>{{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}</NButton <p>{{ nextAction.description }}</p>
> </div>
<NButton
type="primary"
:disabled="
operation.pending ||
(nextAction.action === 'identity' && !canGenerateText) ||
(nextAction.action === 'candidate' &&
(subject.module === 'character'
? !canGenerateCandidate
: !canGenerateImage))
"
@click="runNextAction"
>{{ nextAction.label }}</NButton
>
</div>
</div> </div>
<p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改再使用 AI 或生成图片</p> <p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改再使用 AI 或生成图片</p>
<IdentityEditor <IdentityEditor
@@ -582,6 +787,7 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
</div> </div>
</div> </div>
<IdentityGallery <IdentityGallery
id="identity-reference-gallery"
:key="selectedId" :key="selectedId"
:images="images" :images="images"
:subject-name="subject.name" :subject-name="subject.name"
@@ -673,6 +879,38 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
.identity-workspace-page { .identity-workspace-page {
container: identity-header / inline-size; container: identity-header / inline-size;
} }
.identity-prerequisite {
@apply shrink-0 mx-0 mb-3;
}
.identity-flow {
@apply p-4 bg-(--app-subtle);
}
.identity-flow-steps {
@apply flex flex-wrap items-center gap-x-6 gap-y-2;
}
.identity-flow-steps li {
@apply flex items-center gap-2 text-xs text-muted;
}
.identity-flow-steps li.current {
@apply text-accent font-semibold;
}
.identity-flow-steps li.done {
@apply text-muted;
}
.identity-flow-marker {
@apply grid place-items-center w-5 h-5 rounded-full border border-(--app-border);
}
.identity-flow-steps li.done .identity-flow-marker,
.identity-flow-steps li.current .identity-flow-marker {
@apply border-(--app-accent) text-accent;
}
.identity-next-action {
@apply mt-4 pt-4 flex items-center justify-between gap-4;
border-top: 1px solid var(--app-border);
}
.identity-next-action p {
@apply mt-1 text-xs text-muted;
}
.toolbar-type-filter { .toolbar-type-filter {
@apply w-[152px] min-w-0; @apply w-[152px] min-w-0;
} }
@@ -768,6 +1006,9 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
@apply shrink-0; @apply shrink-0;
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.identity-next-action {
@apply items-start flex-col;
}
.identity-workspace { .identity-workspace {
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[auto_minmax(0,_1fr)] gap-2.5; @apply grid-cols-[minmax(0,_1fr)] grid-rows-[auto_minmax(0,_1fr)] gap-2.5;
} }
+12 -1
View File
@@ -8,7 +8,9 @@ import type {
GenerateIdentityImageInput, GenerateIdentityImageInput,
IdentityBatchResult, IdentityBatchResult,
IdentityImage, IdentityImage,
IdentityModule,
SaveIdentityInput, SaveIdentityInput,
StableAnchorBatchResult,
SubjectIdentity SubjectIdentity
} from './types' } from './types'
@@ -29,7 +31,7 @@ export const subjectIdentityApi = {
body: { force }, body: { force },
timeoutMs: 0 timeoutMs: 0
}), }),
generateProject: (projectId: string, input: { force: boolean; concurrency: number }) => generateProject: (projectId: string, input: { force: boolean; concurrency: number; module?: IdentityModule }) =>
request<IdentityBatchResult>(`/projects/${encodeURIComponent(projectId)}/subject-identities/generate`, { request<IdentityBatchResult>(`/projects/${encodeURIComponent(projectId)}/subject-identities/generate`, {
method: 'POST', method: 'POST',
body: input, body: input,
@@ -56,6 +58,15 @@ export const subjectIdentityApi = {
`/projects/${encodeURIComponent(projectId)}/character-casting/candidates/generate`, `/projects/${encodeURIComponent(projectId)}/character-casting/candidates/generate`,
{ method: 'POST', body: input, timeoutMs: 0 } { method: 'POST', body: input, timeoutMs: 0 }
), ),
/** 为场景或道具生成稳定身份母版,并由后端原子设为 Anchor、锁定 Identity。 */
generateStableAnchors: (
projectId: string,
input: { module: 'scene' | 'prop'; limit: number; concurrency: number }
) =>
request<StableAnchorBatchResult>(
`/projects/${encodeURIComponent(projectId)}/subject-identities/anchors/generate`,
{ method: 'POST', body: input, timeoutMs: 0 }
),
listImages: (subjectId: string, signal?: AbortSignal) => listImages: (subjectId: string, signal?: AbortSignal) =>
request<IdentityImage[]>(`${identityPath(subjectId)}/images`, { signal }), request<IdentityImage[]>(`${identityPath(subjectId)}/images`, { signal }),
generateImage: (subjectId: string, input: GenerateIdentityImageInput) => generateImage: (subjectId: string, input: GenerateIdentityImageInput) =>
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import AppForm from '../../../components/ui/AppForm.vue' import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui' import { NFormItem } from 'naive-ui'
import DetailDisclosure from '../../../components/ui/DetailDisclosure.vue'
import { NButton, NCheckbox, NInput } from 'naive-ui' import { NButton, NCheckbox, NInput } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import type { SaveIdentityInput, SubjectIdentity } from '../types' import type { SaveIdentityInput, SubjectIdentity } from '../types'
@@ -36,63 +35,42 @@ function save() {
<template> <template>
<AppForm :model="form" :disabled="disabled" class="mt-5" @submit="save"> <AppForm :model="form" :disabled="disabled" class="mt-5" @submit="save">
<fieldset :disabled="disabled" class="space-y-4"> <fieldset :disabled="disabled" class="space-y-4">
<NFormItem <NFormItem class="block" path="description" label="身份描述" :label-props="{ for: 'identity-description' }"
class="block"
path="description"
label="稳定身份描述"
:label-props="{ for: 'identity-description' }"
><NInput ><NInput
:disabled="disabled" :disabled="disabled"
placeholder="只描述跨形态不变的面部、结构、材质等特征" placeholder="只描述跨形态不变的面部、结构、材质等特征"
:input-props="{ id: 'identity-description' }" :input-props="{ id: 'identity-description' }"
class="min-h-28" class="min-h-20"
v-model:value="form.description" v-model:value="form.description"
type="textarea" type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }" :autosize="{ minRows: 2, maxRows: 8 }"
></NInput> ></NInput>
</NFormItem> </NFormItem>
<NFormItem <NFormItem
class="block" class="block"
path="generationPrompt" path="generationPrompt"
label="身份核心提示词(稳定事实)" label="核心提示词"
:label-props="{ for: 'identity-prompt' }" :label-props="{ for: 'identity-prompt' }"
><NInput ><NInput
:disabled="disabled" :disabled="disabled"
placeholder="不固定某一形态的服装、伤势或临时状态" placeholder="不固定某一形态的服装、伤势或临时状态"
:input-props="{ id: 'identity-prompt' }" :input-props="{ id: 'identity-prompt' }"
class="min-h-36" class="min-h-24"
v-model:value="form.generationPrompt" v-model:value="form.generationPrompt"
type="textarea" type="textarea"
:autosize="{ minRows: 3, maxRows: 12 }" :autosize="{ minRows: 2, maxRows: 8 }"
></NInput> ></NInput>
</NFormItem> </NFormItem>
<DetailDisclosure title="身份填写与锁定规则"
><p class="text-xs leading-6 text-muted">
{{
module === 'prop'
? '保留道具固定文字、编号、部件位置、结构与材质组合;不要泛化原有辨识细节,也不要写临时损伤或摆放状态。'
: module === 'scene'
? '保留建筑骨架、入口、窗户、楼梯与固定布局,不写临时天气、人群或灯光。'
: '保留有依据的五官、骨相、发际线与体型,不固定某套服装、临时表情或伤势。'
}}
这里保存身份事实背景视角和参考图约束由后端另行编译不是最终整段生图 Prompt
</p>
<p class="mt-2 text-xs text-muted">
人物需要同时确认母版并锁定身份首帧才能就绪锁定后仍可人工编辑生成图片及切换母版
</p></DetailDisclosure
>
<NCheckbox <NCheckbox
:disabled="disabled" :disabled="disabled"
id="identity-lock" id="identity-lock"
v-model:checked="form.isLocked" v-model:checked="form.isLocked"
class="flex items-start gap-2 text-xs leading-6" class="flex items-start gap-2 text-xs leading-6"
>{{ module === 'character' ? '确认并锁定演员身份' : '确认并锁定身份文本' }}</NCheckbox >{{ module === 'character' ? '锁定演员身份' : '锁定身份' }}</NCheckbox
> >
<div class="flex flex-wrap items-center justify-between gap-3"> <div class="page-form-actions">
<span class="text-xs text-muted">{{ <NButton :disabled="disabled" type="primary" attr-type="submit">保存主体身份</NButton>
dirty ? '有未保存修改,刷新不会覆盖草稿。' : '图片以已保存的身份提示词为准。' <span v-if="dirty" class="text-xs text-muted">有未保存修改</span>
}}</span
><NButton :disabled="disabled" type="primary" attr-type="submit">保存主体身份</NButton>
</div> </div>
</fieldset> </fieldset>
</AppForm> </AppForm>
@@ -52,12 +52,6 @@ function chooseAnchor() {
<h3 class="text-sm font-medium">身份参考图 · {{ images.length }}</h3> <h3 class="text-sm font-medium">身份参考图 · {{ images.length }}</h3>
<NTag size="small" :bordered="false">{{ anchor ? '已选母版' : '尚无母版' }}</NTag> <NTag size="small" :bordered="false">{{ anchor ? '已选母版' : '尚无母版' }}</NTag>
</div> </div>
<p class="mt-2 text-xs leading-6 text-muted">
身份母版确定是谁形态主图确定该造型长什么样切换母版仅影响之后的生成不会替换已有形态图分镜参考图或提示词
</p>
<NAlert v-if="character && anchor" type="info" :show-icon="false" class="mt-3 text-xs leading-6">
如果要更换演员身份请生成新的选角候选并确认选角正面三分之四全身等辅助身份图会继续引用当前母版只用于保持同一演员不会重新选择人物
</NAlert>
<template v-if="selected"> <template v-if="selected">
<AssetImage <AssetImage
:key="selected.id" :key="selected.id"
@@ -70,7 +64,6 @@ function chooseAnchor() {
/> />
<div class="image-history-heading"> <div class="image-history-heading">
<h4 class="font-medium">历史记录 · {{ images.length }} </h4> <h4 class="font-medium">历史记录 · {{ images.length }} </h4>
<span class="text-muted">点击缩略图切换预览可横向滚动</span>
</div> </div>
<NScrollbar <NScrollbar
x-scrollable x-scrollable
@@ -158,7 +151,6 @@ function chooseAnchor() {
><NButton @click="confirming = false" text size="small">取消</NButton> ><NButton @click="confirming = false" text size="small">取消</NButton>
</div></NAlert </div></NAlert
> >
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
</template> </template>
<p v-else class="py-8 text-sm text-muted">尚无身份参考图先保存身份提示词再生成第一张母版</p> <p v-else class="py-8 text-sm text-muted">尚无身份参考图先保存身份提示词再生成第一张母版</p>
</section> </section>
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { NImage } from 'naive-ui'
import { Box, MapPin, UserRound } from '@lucide/vue' import { Box, MapPin, UserRound } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { referenceImageUrl } from '../../../lib/assets' import { referenceImageUrl } from '../../../lib/assets'
import { subjectIdentityApi } from '../api' import { subjectIdentityApi } from '../api'
import { currentAnchor } from '../model' import { currentAnchor } from '../model'
import RevealImage from '../../../components/ui/RevealImage.vue'
/** source 为 undefined 时按需查询;null 表示上层已确认没有母版,不能用候选或形态图替代。 */ /** source 为 undefined 时按需查询;null 表示上层已确认没有母版,不能用候选或形态图替代。 */
const props = defineProps<{ const props = defineProps<{
@@ -53,7 +53,7 @@ watch(url, () => {
imageFailed.value = false imageFailed.value = false
}) })
/** 仅读取正式主体的身份图库;切项目、筛选或刷新时中止旧请求,不轮询每个缩略图。 */ /** 仅读取正式主体的身份图库;切项目、筛选或刷新时中止旧请求。 */
watch( watch(
() => [props.projectId, props.subjectId, props.identityId, props.source, props.refreshKey, visible.value], () => [props.projectId, props.subjectId, props.identityId, props.source, props.refreshKey, visible.value],
async (_value, _oldValue, onCleanup) => { async (_value, _oldValue, onCleanup) => {
@@ -89,16 +89,7 @@ watch(
<template> <template>
<span ref="target" class="identity-thumbnail" :title="label"> <span ref="target" class="identity-thumbnail" :title="label">
<NImage <RevealImage v-if="url && !imageFailed" :src="url" :alt="label" @error="imageFailed = true" />
v-if="url && !imageFailed"
:src="url"
:alt="label"
object-fit="cover"
preview-disabled
lazy
:img-props="{ referrerpolicy: 'no-referrer' }"
@error="imageFailed = true"
/>
<span v-else class="identity-thumbnail-placeholder" role="img" :aria-label="label"> <span v-else class="identity-thumbnail-placeholder" role="img" :aria-label="label">
<component :is="icon" :size="22" :stroke-width="1.4" aria-hidden="true" /> <component :is="icon" :size="22" :stroke-width="1.4" aria-hidden="true" />
<span aria-hidden="true">{{ kind }}</span> <span aria-hidden="true">{{ kind }}</span>
@@ -113,7 +104,8 @@ watch(
@apply flex w-12 h-[64px] overflow-hidden bg-(--app-subtle); @apply flex w-12 h-[64px] overflow-hidden bg-(--app-subtle);
} }
.identity-thumbnail .n-image, .identity-thumbnail .n-image,
.identity-thumbnail .n-image img { .identity-thumbnail .n-image img,
.identity-thumbnail .reveal-image {
@apply w-full h-full; @apply w-full h-full;
} }
.identity-thumbnail-placeholder { .identity-thumbnail-placeholder {
+23
View File
@@ -67,6 +67,28 @@ export interface IdentityBatchResult {
failures: { subjectId: string; subjectRef: string; error: string }[] failures: { subjectId: string; subjectRef: string; error: string }[]
} }
export type IdentityModule = 'character' | 'scene' | 'prop'
export interface StableAnchorBatchResult {
module: 'scene' | 'prop'
total: number
eligibleCount: number
targetCount: number
generated: number
failed: number
failures: { subjectId: string; subjectRef: string; subjectName: string; error: string }[]
results: Array<{
subjectId: string
subjectRef: string
subjectName: string
success: boolean
imageId?: string
imageUrl?: string | null
identityLocked?: boolean
error?: string
}>
}
/** Character 的正式选角阶段状态。 */ /** Character 的正式选角阶段状态。 */
export type CharacterCastingStatus = 'missing_identity' | 'missing_anchor' | 'candidate_pending' | 'unlocked' | 'ready' export type CharacterCastingStatus = 'missing_identity' | 'missing_anchor' | 'candidate_pending' | 'unlocked' | 'ready'
@@ -144,3 +166,4 @@ export interface ConfirmCastingResult {
export type IdentityReceipt = export type IdentityReceipt =
| { kind: 'identities'; title: string; result: IdentityBatchResult } | { kind: 'identities'; title: string; result: IdentityBatchResult }
| { kind: 'casting'; title: string; result: CastingBatchResult } | { kind: 'casting'; title: string; result: CastingBatchResult }
| { kind: 'stable-anchors'; title: string; result: StableAnchorBatchResult }
@@ -1,6 +1,7 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { usePolling } from '../../composables/usePolling' import { usePolling } from '../../composables/usePolling'
import { visualStyleApi } from '../visual-style' import { visualStyleApi } from '../visual-style'
import { generationConfigApi } from '../generation-config/api'
import { subjectImagesApi } from '../subject-images/api' import { subjectImagesApi } from '../subject-images/api'
import { hasRunningImages } from '../subject-images/model' import { hasRunningImages } from '../subject-images/model'
import { runOperation } from '../workflows/operations' import { runOperation } from '../workflows/operations'
@@ -56,6 +57,11 @@ export function useSubjectIdentity() {
}, },
false false
) )
const profileQuery = usePolling(
projectId,
async (id, signal) => ({ profile: await generationConfigApi.profile(id, signal) }),
false
)
const castingQuery = usePolling( const castingQuery = usePolling(
projectId, projectId,
async (id, signal) => { async (id, signal) => {
@@ -106,11 +112,17 @@ export function useSubjectIdentity() {
hasRunningImages(images.value) hasRunningImages(images.value)
) )
const hasStyle = computed(() => !!styleQuery.data.value?.style) const hasStyle = computed(() => !!styleQuery.data.value?.style)
const hasCreativeProfile = computed(() => !!profileQuery.data.value?.profile)
const canGenerateText = computed( const canGenerateText = computed(
() => !detailBlocked.value && hasStyle.value && !identity.value?.isLocked && !dirty.value () => !detailBlocked.value && hasStyle.value && !identity.value?.isLocked && !dirty.value
) )
const canGenerateImage = computed( const canGenerateImage = computed(
() => !detailBlocked.value && hasStyle.value && !!identity.value?.generationPrompt?.trim() && !dirty.value () =>
!detailBlocked.value &&
hasStyle.value &&
hasCreativeProfile.value &&
!!identity.value?.generationPrompt?.trim() &&
!dirty.value
) )
const canGenerateCandidate = computed( const canGenerateCandidate = computed(
() => () =>
@@ -164,13 +176,17 @@ export function useSubjectIdentity() {
} }
/** 批量仅生成身份文本,已锁定项由后端跳过,回执保留部分失败。 */ /** 批量仅生成身份文本,已锁定项由后端跳过,回执保留部分失败。 */
async function generateProject(charactersOnly = false) { async function generateProject(charactersOnly = false, module?: 'scene' | 'prop') {
if (blocked.value || !hasStyle.value || !batchValid.value || dirty.value || !subjects.value.length) return if (blocked.value || !hasStyle.value || !batchValid.value || dirty.value || !subjects.value.length) return
const id = projectId.value const id = projectId.value
const target = getIdentitySession(id) const target = getIdentitySession(id)
const input = { concurrency: concurrency.value, force: force.value } const input = { concurrency: concurrency.value, force: force.value, ...(module ? { module } : {}) }
target.receipt = null target.receipt = null
const title = charactersOnly ? '批量生成角色身份文本' : '批量生成全部主体身份文本' const title = charactersOnly
? '批量生成角色身份文本'
: module
? `批量生成${module === 'scene' ? '场景' : '道具'}身份文本`
: '批量生成全部主体身份文本'
await runOperation(id, title, async () => { await runOperation(id, title, async () => {
target.receipt = { target.receipt = {
kind: 'identities', kind: 'identities',
@@ -183,6 +199,35 @@ export function useSubjectIdentity() {
await Promise.all([detail.refresh(), castingQuery.refresh()]) await Promise.all([detail.refresh(), castingQuery.refresh()])
} }
/** 场景/道具身份母版由后端独立生成、设为 Anchor 并锁定,保留逐主体失败回执。 */
async function generateStableAnchors(module: 'scene' | 'prop') {
if (
blocked.value ||
!hasStyle.value ||
!hasCreativeProfile.value ||
!batchValid.value ||
!candidateLimitValid.value ||
dirty.value
)
return
const id = projectId.value
const target = getIdentitySession(id)
const title = `批量生成${module === 'scene' ? '场景' : '道具'}身份母版`
target.receipt = null
await runOperation(id, title, async () => {
target.receipt = {
kind: 'stable-anchors',
title,
result: await subjectIdentityApi.generateStableAnchors(id, {
module,
limit: candidateLimit.value,
concurrency: concurrency.value
})
}
})
await Promise.all([catalog.refresh(), detail.refresh()])
}
/** 小批量只为 missing_anchor 的 Character 生成候选,不自动选择演员。 */ /** 小批量只为 missing_anchor 的 Character 生成候选,不自动选择演员。 */
async function generateCastingCandidates() { async function generateCastingCandidates() {
if ( if (
@@ -303,6 +348,7 @@ export function useSubjectIdentity() {
catalog, catalog,
castingQuery, castingQuery,
styleQuery, styleQuery,
profileQuery,
detail, detail,
identity, identity,
images, images,
@@ -317,6 +363,7 @@ export function useSubjectIdentity() {
castingBlocked, castingBlocked,
detailBlocked, detailBlocked,
hasStyle, hasStyle,
hasCreativeProfile,
canGenerateText, canGenerateText,
canGenerateImage, canGenerateImage,
canGenerateCandidate, canGenerateCandidate,
@@ -325,6 +372,7 @@ export function useSubjectIdentity() {
save, save,
generate, generate,
generateProject, generateProject,
generateStableAnchors,
generateCastingCandidates, generateCastingCandidates,
generateImage, generateImage,
generateCastingCandidate, generateCastingCandidate,
@@ -61,6 +61,7 @@ const {
limit, limit,
promptConcurrency, promptConcurrency,
promptForce, promptForce,
batchModule,
generatePrompt, generatePrompt,
generatePrompts, generatePrompts,
blocked, blocked,
@@ -145,7 +146,7 @@ watch(
) )
const search = ref('') const search = ref('')
/** 切换排版复用同一份卡片和查询结果,不重新读取图库或清空筛选。 */ /** 切换排版复用同一份卡片和查询结果,不重新读取图库或清空筛选。 */
const layout = ref<GalleryLayout>('grid') const layout = ref<GalleryLayout>('masonry')
const module = ref('all') const module = ref('all')
/** 形态图片状态筛选;单选下拉避免移动端复选项占据两行。 */ /** 形态图片状态筛选;单选下拉避免移动端复选项占据两行。 */
type ImageStatusFilter = 'all' | 'missing' type ImageStatusFilter = 'all' | 'missing'
@@ -345,6 +346,21 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
v-model:force="promptForce" v-model:force="promptForce"
@generate="generatePrompts" @generate="generatePrompts"
/> />
<div class="mt-5 flex flex-wrap items-center gap-3">
<span class="text-xs text-muted">批处理范围</span>
<NSelect
v-model:value="batchModule"
class="w-40"
aria-label="形态批处理范围"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('仅人物'), value: 'character' },
{ label: String('仅场景'), value: 'scene' },
{ label: String('仅道具'), value: 'prop' }
]"
/>
<span class="text-xs text-muted">同时应用于批量提示词与批量生图</span>
</div>
<div class="panel mt-5 p-5"> <div class="panel mt-5 p-5">
<div class="flex flex-wrap items-center justify-between gap-4"> <div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-sm"> <p class="text-sm">
@@ -355,7 +371,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
<p class="mt-2 text-[11px] leading-6 text-muted"> <p class="mt-2 text-[11px] leading-6 text-muted">
图库不自动刷新可点击刷新图库读取最新记录主图供分镜引用候选图和历史失败记录均保留过期主图不会自动删除或替换 图库不自动刷新可点击刷新图库读取最新记录主图供分镜引用候选图和历史失败记录均保留过期主图不会自动删除或替换
</p> </p>
<NCollapse class="mt-4 pt-4" <NCollapse class="mt-4 pt-4" :default-expanded-names="['details']"
><NCollapseItem name="details" ><NCollapseItem name="details"
><template #header>项目批量生图</template> ><template #header>项目批量生图</template>
<AppForm :model="formModel" :rules="rules" :disabled="operation.pending" validate-on-change> <AppForm :model="formModel" :rules="rules" :disabled="operation.pending" validate-on-change>
@@ -700,6 +716,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
<AssetImage <AssetImage
:src="coverImage(form)?.imageUrl" :src="coverImage(form)?.imageUrl"
:alt="`${form.subject.name} · ${form.name}`" :alt="`${form.subject.name} · ${form.name}`"
:class="{ 'form-image-has-source': Boolean(coverImage(form)?.imageUrl) }"
:object-fit="layout === 'masonry' ? 'contain' : 'cover'" :object-fit="layout === 'masonry' ? 'contain' : 'cover'"
preview preview
:empty-text="hasRunningImages(form.images) ? '后台正在生成图片' : '尚未生成图片'" :empty-text="hasRunningImages(form.images) ? '后台正在生成图片' : '尚未生成图片'"
@@ -709,8 +726,9 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
<h3 class="text-sm font-semibold">{{ form.subject.name }}</h3> <h3 class="text-sm font-semibold">{{ form.subject.name }}</h3>
<code class="subject-ref">{{ form.subject.ref }}</code> <code class="subject-ref">{{ form.subject.ref }}</code>
</div> </div>
<p class="mt-2 text-xs"> <p class="mt-2 flex items-center gap-2 text-xs">
{{ form.name }}<NTag v-if="form.isDefault" size="small" :bordered="false">默认形态</NTag> <span>{{ form.name }}</span>
<NTag v-if="form.isDefault" size="small" :bordered="false">默认形态</NTag>
</p> </p>
<p v-if="form.description" class="mt-3 line-clamp-2 text-xs leading-6 text-muted"> <p v-if="form.description" class="mt-3 line-clamp-2 text-xs leading-6 text-muted">
{{ form.description }} {{ form.description }}
@@ -813,6 +831,10 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
.form-image-card { .form-image-card {
@apply overflow-hidden; @apply overflow-hidden;
} }
/* 普通网格的有图与空状态统一使用正方形图片区;瀑布流仍按原图比例展示。 */
.form-image-grid:not(.form-image-masonry) .asset-image {
aspect-ratio: 1 / 1;
}
/* 浅色卡片使用轻灰底,与白色画布区分;网格和瀑布流共用,深色保持原有层次。 */ /* 浅色卡片使用轻灰底,与白色画布区分;网格和瀑布流共用,深色保持原有层次。 */
:root:not([data-theme='dark']) .form-image-card { :root:not([data-theme='dark']) .form-image-card {
background: var(--app-subtle); background: var(--app-subtle);
@@ -840,10 +862,13 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
grid-row-end: span var(--form-image-row-span, 1); grid-row-end: span var(--form-image-row-span, 1);
} }
.form-image-masonry .asset-image { .form-image-masonry .asset-image {
aspect-ratio: 1 / 1;
}
.form-image-masonry .asset-image.form-image-has-source {
@apply aspect-auto; @apply aspect-auto;
} }
.form-image-masonry .asset-image .n-image, .form-image-masonry .asset-image.form-image-has-source .n-image,
.form-image-masonry .asset-image .n-image img { .form-image-masonry .asset-image.form-image-has-source .n-image img {
@apply h-auto; @apply h-auto;
} }
.gallery-sticky-controls > .form-image-filter-region { .gallery-sticky-controls > .form-image-filter-region {
@@ -888,7 +913,7 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
@apply justify-self-end text-muted text-xs whitespace-nowrap; @apply justify-self-end text-muted text-xs whitespace-nowrap;
} }
.form-image-display-controls { .form-image-display-controls {
@apply flex items-center justify-self-end gap-3; @apply flex items-center justify-self-end gap-4;
} }
.gallery-layout-switch { .gallery-layout-switch {
@apply flex items-center gap-2; @apply flex items-center gap-2;
+5 -1
View File
@@ -6,7 +6,8 @@ import type {
FormPromptResult, FormPromptResult,
ImageBatchResult, ImageBatchResult,
SubjectFormAsset, SubjectFormAsset,
SubjectImage SubjectImage,
SubjectImageGenerationPreview
} from './types' } from './types'
/** 对正式 SubjectForm 数据库 ID 编码,不接受按名称拼出的路径。 */ /** 对正式 SubjectForm 数据库 ID 编码,不接受按名称拼出的路径。 */
@@ -33,6 +34,9 @@ export const subjectImagesApi = {
listForms: (projectId: string, signal?: AbortSignal) => listForms: (projectId: string, signal?: AbortSignal) =>
request<SubjectFormAsset[]>(`/projects/${encodeURIComponent(projectId)}/subject-forms`, { signal }), request<SubjectFormAsset[]>(`/projects/${encodeURIComponent(projectId)}/subject-forms`, { signal }),
listImages: (formId: string, signal?: AbortSignal) => request<SubjectImage[]>(formPath(formId), { signal }), listImages: (formId: string, signal?: AbortSignal) => request<SubjectImage[]>(formPath(formId), { signal }),
/** 解析正式 Prompt、参考图顺序与后端生成配置,不创建图片任务。 */
preview: (formId: string) =>
request<SubjectImageGenerationPreview>(`${formPath(formId)}/preview`, { method: 'POST', body: {} }),
generate: (formId: string, input: GenerateFormImageInput) => generate: (formId: string, input: GenerateFormImageInput) =>
request<SubjectImage | null>(formPath(formId), { method: 'POST', body: input, timeoutMs: 0 }), request<SubjectImage | null>(formPath(formId), { method: 'POST', body: input, timeoutMs: 0 }),
generateProject: (projectId: string, input: GenerateProjectImagesInput) => generateProject: (projectId: string, input: GenerateProjectImagesInput) =>
@@ -2,11 +2,13 @@
import AppForm from '../../../components/ui/AppForm.vue' import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui' import { NFormItem } from 'naive-ui'
import { sizeRules } from '../../../lib/form-rules' import { sizeRules } from '../../../lib/form-rules'
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber } from 'naive-ui' import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui' import { AppDialog } from '../../../components/ui'
import { errorMessage } from '../../../lib/http'
import { subjectImagesApi } from '../api'
import { primaryImage, validImageSize } from '../model' import { primaryImage, validImageSize } from '../model'
import type { GenerateFormImageInput, SubjectFormAsset } from '../types' import type { GenerateFormImageInput, SubjectFormAsset, SubjectImageGenerationPreview } from '../types'
/** 单形态生成确认:尺寸与自定义 Prompt 只用于本次请求。 */ /** 单形态生成确认:尺寸与自定义 Prompt 只用于本次请求。 */
const props = defineProps<{ form: SubjectFormAsset | null; disabled: boolean }>() const props = defineProps<{ form: SubjectFormAsset | null; disabled: boolean }>()
@@ -17,6 +19,9 @@ const width = ref<number | ''>('')
const height = ref<number | ''>('') const height = ref<number | ''>('')
const setPrimary = ref(false) const setPrimary = ref(false)
const acknowledged = ref(false) const acknowledged = ref(false)
const preview = ref<SubjectImageGenerationPreview | null>(null)
const previewError = ref('')
const previewPending = ref(false)
const dimensionsValid = computed(() => validImageSize(width.value, height.value)) const dimensionsValid = computed(() => validImageSize(width.value, height.value))
/** 每次打开重新建立表单,避免把另一形态的 Prompt 或尺寸带入请求。 */ /** 每次打开重新建立表单,避免把另一形态的 Prompt 或尺寸带入请求。 */
@@ -27,11 +32,31 @@ watch(
width.value = '' width.value = ''
height.value = '' height.value = ''
acknowledged.value = false acknowledged.value = false
preview.value = null
previewError.value = ''
previewPending.value = false
setPrimary.value = props.form ? !primaryImage(props.form.images) : false setPrimary.value = props.form ? !primaryImage(props.form.images) : false
}, },
{ immediate: true } { immediate: true }
) )
/** 预览由后端按当前正式配置编译,不提交图片生成任务。 */
async function loadPreview() {
if (!props.form || previewPending.value) return
previewPending.value = true
previewError.value = ''
try {
const result = await subjectImagesApi.preview(props.form.id)
if (!result || result.subjectFormId !== props.form.id) throw new Error('预览结果与当前形态不匹配。')
preview.value = result
} catch (cause) {
preview.value = null
previewError.value = errorMessage(cause)
} finally {
previewPending.value = false
}
}
/** 完成前置确认后只发出一次明确的生图请求,不触发整条生产流程。 */ /** 完成前置确认后只发出一次明确的生图请求,不触发整条生产流程。 */
function submit() { function submit() {
if (!props.form || props.disabled || !dimensionsValid.value || !acknowledged.value) return if (!props.form || props.disabled || !dimensionsValid.value || !acknowledged.value) return
@@ -68,6 +93,33 @@ const rules = { ...sizeRules(() => formModel.value) }
人物场景道具均会引用已锁定身份的当前母版保持身份空间骨架或物件结构身份未锁定或没有母版时不会继承该图片更换母版不会自动更新已有形态图 人物场景道具均会引用已锁定身份的当前母版保持身份空间骨架或物件结构身份未锁定或没有母版时不会继承该图片更换母版不会自动更新已有形态图
身份母版与此处的形态主参考图是两种不同用途的图片 身份母版与此处的形态主参考图是两种不同用途的图片
</p> </p>
<div class="flex flex-wrap items-center gap-3">
<NButton :loading="previewPending" :disabled="!form" @click="loadPreview">预览实际生成输入</NButton>
<span class="text-xs text-muted">只读取后端编译结果不调用图片模型</span>
</div>
<NAlert v-if="previewError" type="error" :show-icon="false">{{ previewError }}</NAlert>
<div v-if="preview" class="panel p-4 text-xs leading-6">
<div class="flex flex-wrap items-center gap-2">
<strong>{{ preview.provider }} / {{ preview.model }}</strong>
<NTag size="small" :bordered="false">{{ preview.references.length }} 张参考图</NTag>
</div>
<ul v-if="preview.references.length" class="mt-3 space-y-1 text-muted">
<li v-for="item in preview.references" :key="`${item.imageNumber}:${item.imageId}`">
{{ item.imageNumber }} · {{ item.role }} · <code>{{ item.imageId }}</code>
</li>
</ul>
<p v-else class="mt-3 text-muted">本次没有图片参考</p>
<NCollapse class="mt-3" :default-expanded-names="['prompt']">
<NCollapseItem name="prompt" title="最终 Provider Prompt">
<p class="mt-3 whitespace-pre-wrap">{{ preview.providerPrompt }}</p>
</NCollapseItem>
<NCollapseItem name="config" title="生成配置">
<pre class="mt-3 whitespace-pre-wrap">{{
JSON.stringify(preview.generationConfig, null, 2)
}}</pre>
</NCollapseItem>
</NCollapse>
</div>
<NFormItem <NFormItem
class="block" class="block"
path="prompt" path="prompt"
@@ -82,7 +134,10 @@ const rules = { ...sizeRules(() => formModel.value) }
:autosize="{ minRows: 3, maxRows: 12 }" :autosize="{ minRows: 3, maxRows: 12 }"
></NInput> ></NInput>
</NFormItem> </NFormItem>
<NCollapse v-if="form?.generationPrompt || form?.appearancePrompt" class="text-xs" <NCollapse
v-if="form?.generationPrompt || form?.appearancePrompt"
class="text-xs"
:default-expanded-names="['details']"
><NCollapseItem name="details" ><NCollapseItem name="details"
><template #header>{{ ><template #header>{{
form?.generationPrompt ? '查看正式生图提示词' : '查看原始外观素材(不是最终提示词)' form?.generationPrompt ? '查看正式生图提示词' : '查看原始外观素材(不是最终提示词)'
@@ -3,7 +3,7 @@ import { NScrollbar, NAlert, NButton, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { ExternalLink, RefreshCw } from '@lucide/vue' import { ExternalLink, RefreshCw } from '@lucide/vue'
import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui' import { AppDialog, AssetImage, StatusBadge } from '../../../components/ui'
import { usePolling } from '../../../composables/usePolling' import { useQuery } from '../../../composables/useQuery'
import { referenceImageUrl } from '../../../lib/assets' import { referenceImageUrl } from '../../../lib/assets'
import { formatDate } from '../../../lib/format' import { formatDate } from '../../../lib/format'
import { getOperation, runOperation } from '../../workflows/operations' import { getOperation, runOperation } from '../../workflows/operations'
@@ -18,16 +18,12 @@ const emit = defineEmits<{ changed: [] }>()
const selectedId = ref('') const selectedId = ref('')
const confirming = ref(false) const confirming = ref(false)
const key = computed(() => (open.value ? (props.form?.id ?? '') : '')) const key = computed(() => (open.value ? (props.form?.id ?? '') : ''))
const query = usePolling( const query = useQuery(key, async (id, signal) => {
key, if (!id) return null
async (id, signal) => { const rows = await subjectImagesApi.listImages(id, signal)
if (!id) return null if (rows.some(image => image.subjectFormId !== id)) throw new Error('图片记录与当前形态不匹配,请刷新后重试。')
const rows = await subjectImagesApi.listImages(id, signal) return rows
if (rows.some(image => image.subjectFormId !== id)) throw new Error('图片记录与当前形态不匹配,请刷新后重试。') })
return rows
},
false
)
const images = computed(() => query.data.value ?? []) const images = computed(() => query.data.value ?? [])
const selected = computed( const selected = computed(
() => images.value.find(image => image.id === selectedId.value) ?? primaryImage(images.value) ?? images.value[0] () => images.value.find(image => image.id === selectedId.value) ?? primaryImage(images.value) ?? images.value[0]
+20
View File
@@ -56,6 +56,8 @@ export interface GenerateFormImageInput {
export interface GenerateProjectImagesInput { export interface GenerateProjectImagesInput {
concurrency: number concurrency: number
force: boolean force: boolean
/** 可按主体模块缩小批处理范围;省略时处理全部模块。 */
module?: SubjectModule
/** 仅限制本次真正生图的数量,留空表示不限制。 */ /** 仅限制本次真正生图的数量,留空表示不限制。 */
limit?: number limit?: number
} }
@@ -83,6 +85,24 @@ export type FormPromptResult = Omit<SubjectFormAsset, 'subject' | 'images'> & {
export interface GenerateFormPromptsInput { export interface GenerateFormPromptsInput {
concurrency: number concurrency: number
force: boolean force: boolean
module?: SubjectModule
}
export type SubjectModule = 'character' | 'scene' | 'prop'
/** 形态生图前的无费用实际输入预览。 */
export interface SubjectImageGenerationPreview {
subjectFormId: string
provider: string
model: string
generationConfig: Record<string, unknown>
references: Array<{
imageNumber: number
role: string
imageId: string
imageUrl: string
}>
providerPrompt: string
} }
/** 长请求的回执按项目隔离,在会话内切换页面后仍可查看。 */ /** 长请求的回执按项目隔离,在会话内切换页面后仍可查看。 */
@@ -1,45 +1,39 @@
import { computed, type Ref } from 'vue' import { computed, type Ref } from 'vue'
import { usePolling } from '../../composables/usePolling' import { useQuery } from '../../composables/useQuery'
import { productionApi } from '../production/api' import { productionApi } from '../production/api'
import { useShotReferences } from '../production/useShotReferences' import { useShotReferences } from '../production/useShotReferences'
/** 镜头影响检查与素材查询独立;检查失败不把素材误判为正常或阻断独立的素材操作。 */ /** 镜头影响检查与素材查询独立;检查失败不把素材误判为正常或阻断独立的素材操作。 */
export function useKeyframeImpact(projectId: Ref<string>, sourceShotId: Ref<string>) { export function useKeyframeImpact(projectId: Ref<string>, sourceShotId: Ref<string>) {
const query = usePolling( const query = useQuery(projectId, async (id, signal) => {
projectId, if (!id) return null
async (id, signal) => { const [keyframes, videos] = await Promise.all([
if (!id) return null productionApi.keyframeReadiness(id, false, signal),
const [keyframes, videos] = await Promise.all([ productionApi.videoReadiness(id, false, signal)
productionApi.keyframeReadiness(id, false, signal), ])
productionApi.videoReadiness(id, false, signal) if (!keyframes || !Array.isArray(keyframes.items) || !videos || !Array.isArray(videos.items))
]) throw new Error('主首帧影响检查未返回镜头列表。')
if (!keyframes || !Array.isArray(keyframes.items) || !videos || !Array.isArray(videos.items)) const byShot = new Map(videos.items.map(item => [item.shotId, item]))
throw new Error('主首帧影响检查未返回镜头列表。') if (byShot.size !== keyframes.items.length || keyframes.items.some(item => !byShot.has(item.shotId)))
const byShot = new Map(videos.items.map(item => [item.shotId, item])) throw new Error('首帧与视频检查的镜头列表不一致,可能正在重新拆解,请刷新后重试。')
if (byShot.size !== keyframes.items.length || keyframes.items.some(item => !byShot.has(item.shotId))) return {
throw new Error('首帧与视频检查的镜头列表不一致,可能正在重新拆解,请刷新后重试。') ...keyframes,
return { items: keyframes.items.map(item => {
...keyframes, const issues = byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? []
items: keyframes.items.map(item => { return {
const issues = ...item,
byShot.get(item.shotId)?.issues.filter(issue => issue.code === 'stale_keyframe') ?? [] primaryKeyframeStale: !!item.primaryKeyframeStale || issues.length > 0,
return { inconsistent: item.primaryKeyframeStale === false && issues.length > 0
...item, }
primaryKeyframeStale: !!item.primaryKeyframeStale || issues.length > 0, })
inconsistent: item.primaryKeyframeStale === false && issues.length > 0 }
} })
})
}
},
false
)
const stale = computed(() => query.data.value?.items.filter(item => item.primaryKeyframeStale) ?? []) const stale = computed(() => query.data.value?.items.filter(item => item.primaryKeyframeStale) ?? [])
// 不根据 URL 中的任意镜头 ID 发请求,必须属于当前项目的就绪列表。 // 不根据 URL 中的任意镜头 ID 发请求,必须属于当前项目的就绪列表。
const source = computed(() => query.data.value?.items.find(item => item.shotId === sourceShotId.value)) const source = computed(() => query.data.value?.items.find(item => item.shotId === sourceShotId.value))
const references = useShotReferences( const references = useShotReferences(
projectId, projectId,
computed(() => source.value?.shotId ?? ''), computed(() => source.value?.shotId ?? '')
false
) )
const relatedForms = computed( const relatedForms = computed(
() => new Set(references.data.value?.references.map(item => item.subjectFormId) ?? []) () => new Set(references.data.value?.references.map(item => item.subjectFormId) ?? [])
@@ -6,7 +6,7 @@ import { getOperation, runOperation } from '../workflows/operations'
import { workflowCheckpoints } from '../workflows/selectors' import { workflowCheckpoints } from '../workflows/selectors'
import { subjectImagesApi } from './api' import { subjectImagesApi } from './api'
import { getImageSession, hasRunningImages } from './model' import { getImageSession, hasRunningImages } from './model'
import type { GenerateFormImageInput } from './types' import type { GenerateFormImageInput, SubjectModule } from './types'
/** 形态图库统一读取数据库记录并管理单图/批量长请求,不调用生产流程。 */ /** 形态图库统一读取数据库记录并管理单图/批量长请求,不调用生产流程。 */
export function useSubjectImages() { export function useSubjectImages() {
@@ -17,6 +17,7 @@ export function useSubjectImages() {
const limit = ref<number | ''>('') const limit = ref<number | ''>('')
const promptConcurrency = ref(3) const promptConcurrency = ref(3)
const promptForce = ref(false) const promptForce = ref(false)
const batchModule = ref<'all' | SubjectModule>('all')
const query = usePolling( const query = usePolling(
id, id,
async (projectId, signal) => { async (projectId, signal) => {
@@ -91,7 +92,11 @@ export function useSubjectImages() {
async function generatePrompts() { async function generatePrompts() {
if (blocked.value || !promptValid.value || !forms.value.length) return if (blocked.value || !promptValid.value || !forms.value.length) return
const projectId = id.value const projectId = id.value
const input = { concurrency: promptConcurrency.value, force: promptForce.value } const input = {
concurrency: promptConcurrency.value,
force: promptForce.value,
...(batchModule.value === 'all' ? {} : { module: batchModule.value })
}
const target = getImageSession(projectId) const target = getImageSession(projectId)
target.promptReceipt = null target.promptReceipt = null
await runOperation(projectId, '批量生成形态正式提示词', async () => { await runOperation(projectId, '批量生成形态正式提示词', async () => {
@@ -124,7 +129,8 @@ export function useSubjectImages() {
const input = { const input = {
concurrency: concurrency.value, concurrency: concurrency.value,
force: force.value, force: force.value,
...(limit.value === '' ? {} : { limit: limit.value }) ...(limit.value === '' ? {} : { limit: limit.value }),
...(batchModule.value === 'all' ? {} : { module: batchModule.value })
} }
const target = getImageSession(projectId) const target = getImageSession(projectId)
target.receipt = null target.receipt = null
@@ -149,6 +155,7 @@ export function useSubjectImages() {
limit, limit,
promptConcurrency, promptConcurrency,
promptForce, promptForce,
batchModule,
promptValid, promptValid,
concurrencyValid, concurrencyValid,
generatePrompt, generatePrompt,
+8 -11
View File
@@ -4,7 +4,7 @@ import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
import { NAlert, NButton } from 'naive-ui' import { NAlert, NButton } from 'naive-ui'
import { LoaderCircle, LockKeyhole, LockKeyholeOpen, Palette, RefreshCw, TriangleAlert } from '@lucide/vue' import { LoaderCircle, LockKeyhole, LockKeyholeOpen, Palette, RefreshCw, TriangleAlert } from '@lucide/vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { usePolling } from '../../composables/usePolling' import { useQuery } from '../../composables/useQuery'
import { runOperation } from '../workflows/operations' import { runOperation } from '../workflows/operations'
import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard' import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard'
import ConfirmAction from '../workflows/ConfirmAction.vue' import ConfirmAction from '../workflows/ConfirmAction.vue'
@@ -15,16 +15,12 @@ import StyleImages from './components/StyleImages.vue'
/** 项目视觉风格工作区:文本编辑、锁定和风格图记录分开管理。 */ /** 项目视觉风格工作区:文本编辑、锁定和风格图记录分开管理。 */
const { projectId, blocked } = useProjectMutationGuard() const { projectId, blocked } = useProjectMutationGuard()
/** 风格页已有刷新按钮,不再定时轮询。 */ /** 风格页仅在进入、切换项目或显式刷新时读取。 */
const query = usePolling( const query = useQuery(projectId, async (id, signal) => {
projectId, const style = await visualStyleApi.get(id, signal)
async (id, signal) => { assertProject(style, id)
const style = await visualStyleApi.get(id, signal) return { style }
assertProject(style, id) })
return { style }
},
false
)
const style = computed(() => query.data.value?.style ?? null) const style = computed(() => query.data.value?.style ?? null)
const disabled = computed(() => blocked.value || !!query.error.value || !query.data.value) const disabled = computed(() => blocked.value || !!query.error.value || !query.data.value)
const editorRevision = ref(0) const editorRevision = ref(0)
@@ -158,6 +154,7 @@ function removeImage(image: VisualStyleImage) {
<p v-else-if="query.loading.value" class="py-8 text-sm text-muted" role="status">正在读取视觉风格</p> <p v-else-if="query.loading.value" class="py-8 text-sm text-muted" role="status">正在读取视觉风格</p>
<StyleImages <StyleImages
:key="imageRevision" :key="imageRevision"
:project-id="projectId"
:images="style?.images ?? []" :images="style?.images ?? []"
:disabled="disabled || !style" :disabled="disabled || !style"
@add="addImage" @add="addImage"
@@ -145,7 +145,7 @@ const constraintsDetails = ref<InstanceType<typeof DetailDisclosure> | null>(nul
<p class="text-xs leading-6 text-muted"> <p class="text-xs leading-6 text-muted">
锁定后仍可人工编辑并保存取消勾选并保存后才能再次使用 AI 重生成 锁定后仍可人工编辑并保存取消勾选并保存后才能再次使用 AI 重生成
</p> </p>
<div class="flex justify-end"> <div class="page-form-actions">
<NButton :disabled="disabled" type="primary" attr-type="submit"> 保存视觉风格 </NButton> <NButton :disabled="disabled" type="primary" attr-type="submit"> 保存视觉风格 </NButton>
</div> </div>
</fieldset> </fieldset>
@@ -4,12 +4,15 @@ import { NFormItem } from 'naive-ui'
import { fieldRule, imageUrlRule } from '../../../lib/form-rules' import { fieldRule, imageUrlRule } from '../../../lib/form-rules'
import { NButton, NCheckbox, NInput, NInputNumber, NSelect } from 'naive-ui' import { NButton, NCheckbox, NInput, NInputNumber, NSelect } from 'naive-ui'
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { AssetImage } from '../../../components/ui' import { AppDialog, AssetImage } from '../../../components/ui'
import { referenceImageUrl } from '../../../lib/assets' import { referenceImageUrl } from '../../../lib/assets'
import { errorMessage } from '../../../lib/http'
import { projectAssetsApi } from '../../project-assets/api'
import type { ProjectAsset } from '../../project-assets/types'
import type { AddStyleImageInput, StyleCategory, VisualStyleImage } from '../types' import type { AddStyleImageInput, StyleCategory, VisualStyleImage } from '../types'
/** 风格图只登记已有地址;不伪造文件上传或风格生图功能。 */ /** 风格图可关联项目资产,也保留外部地址兼容入口。 */
const props = defineProps<{ images: VisualStyleImage[]; disabled: boolean }>() const props = defineProps<{ projectId: string; images: VisualStyleImage[]; disabled: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
add: [input: AddStyleImageInput] add: [input: AddStyleImageInput]
toggle: [image: VisualStyleImage] toggle: [image: VisualStyleImage]
@@ -17,6 +20,11 @@ const emit = defineEmits<{
}>() }>()
const form = reactive({ imageUrl: '', category: 'overall' as StyleCategory, sortOrder: 0, enabled: true }) const form = reactive({ imageUrl: '', category: 'overall' as StyleCategory, sortOrder: 0, enabled: true })
const confirmingId = ref('') const confirmingId = ref('')
const assetPickerOpen = ref(false)
const assetLoading = ref(false)
const assetError = ref('')
const assets = ref<ProjectAsset[] | null>(null)
const selectedAssetId = ref('')
const categories = { overall: '整体', character: '人物', scene: '场景', prop: '道具' } as const const categories = { overall: '整体', character: '人物', scene: '场景', prop: '道具' } as const
/** 仅通过校验后发出新增请求,输入框在失败时保留供修正。 */ /** 仅通过校验后发出新增请求,输入框在失败时保留供修正。 */
@@ -25,6 +33,36 @@ function add() {
emit('add', { ...form, imageUrl: form.imageUrl.trim(), source: 'upload' }) emit('add', { ...form, imageUrl: form.imageUrl.trim(), source: 'upload' })
} }
/** 素材仅在用户打开选择器时读取,避免风格页首屏增加重复请求。 */
async function openAssetPicker() {
if (props.disabled) return
assetPickerOpen.value = true
assetError.value = ''
selectedAssetId.value = ''
assetLoading.value = true
try {
const rows = await projectAssetsApi.list(props.projectId)
if (rows.some(asset => asset.projectId !== props.projectId)) throw new Error('素材与当前项目不匹配。')
assets.value = rows.filter(asset => asset.type === 'image')
} catch (error) {
assetError.value = errorMessage(error)
} finally {
assetLoading.value = false
}
}
function addAsset() {
if (props.disabled || !selectedAssetId.value) return
emit('add', {
projectAssetId: selectedAssetId.value,
category: form.category,
source: 'upload',
enabled: form.enabled,
sortOrder: form.sortOrder
})
assetPickerOpen.value = false
}
/** 删除只移除记录,需确认且不声称删除远程文件。 */ /** 删除只移除记录,需确认且不声称删除远程文件。 */
function remove(image: VisualStyleImage) { function remove(image: VisualStyleImage) {
if (props.disabled || confirmingId.value !== image.id) return if (props.disabled || confirmingId.value !== image.id) return
@@ -40,8 +78,8 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
风格参考图 <span class="ml-2 text-xs text-muted">{{ images.length }} </span> 风格参考图 <span class="ml-2 text-xs text-muted">{{ images.length }} </span>
</h3> </h3>
<p class="mt-2 text-xs leading-6 text-muted"> <p class="mt-2 text-xs leading-6 text-muted">
登记已有 HTTP(S) /storage/ 优先从项目资产库选择也可登记已有 HTTP(S) /storage/
图片地址后端尚无文件上传接口这些图片目前仅管理记录现有身份形态生图不会自动将风格图片传给模型 图片地址启用的参考图会参与后续身份与形态生图已有图片不会自动更新
</p> </p>
<AppForm :model="form" :rules="rules" :disabled="disabled" class="mt-4" @submit="add"> <AppForm :model="form" :rules="rules" :disabled="disabled" class="mt-4" @submit="add">
<fieldset :disabled="disabled" class="form-controls style-image-controls"> <fieldset :disabled="disabled" class="form-controls style-image-controls">
@@ -74,10 +112,13 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
></NInputNumber ></NInputNumber
></NFormItem> ></NFormItem>
<NCheckbox v-model:checked="form.enabled" class="control-row-checkbox text-xs">启用</NCheckbox> <NCheckbox v-model:checked="form.enabled" class="control-row-checkbox text-xs">启用</NCheckbox>
<NButton class="style-image-submit" :disabled="disabled" attr-type="submit">登记参考图</NButton> <div class="style-image-submit flex gap-2">
<NButton :disabled="disabled" @click="openAssetPicker">从资产库选择</NButton>
<NButton :disabled="disabled" attr-type="submit">登记参考图</NButton>
</div>
</fieldset> </fieldset>
</AppForm> </AppForm>
<div v-if="images.length" class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div v-if="images.length" class="style-image-list mt-5">
<article v-for="image in images" :key="image.id" class="surface-inset min-w-0 p-3"> <article v-for="image in images" :key="image.id" class="surface-inset min-w-0 p-3">
<AssetImage <AssetImage
:src="image.imageUrl" :src="image.imageUrl"
@@ -125,10 +166,46 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
</article> </article>
</div> </div>
<p v-else class="mt-5 text-sm text-muted">尚无风格参考图先保存视觉风格再登记图片</p> <p v-else class="mt-5 text-sm text-muted">尚无风格参考图先保存视觉风格再登记图片</p>
<AppDialog
v-model:open="assetPickerOpen"
title="从项目资产库选择"
description="选择一张项目图片作为当前分类的风格参考。这里只创建引用,不复制或删除原素材。"
wide
>
<p v-if="assetError" class="mt-4 text-sm text-danger">{{ assetError }}</p>
<p v-else-if="assetLoading" class="mt-4 text-sm text-muted" role="status">正在读取项目素材</p>
<div v-else-if="assets?.length" class="style-asset-picker mt-4">
<button
v-for="asset in assets"
:key="asset.id"
type="button"
class="style-asset-option"
:class="{ 'is-selected': selectedAssetId === asset.id }"
:aria-pressed="selectedAssetId === asset.id"
@click="selectedAssetId = asset.id"
>
<AssetImage :src="asset.publicUrl" :alt="asset.name" class="aspect-[4/3]" />
<span class="block truncate p-3 text-left text-xs">{{ asset.name }}</span>
</button>
</div>
<div v-else-if="assets" class="mt-5 text-sm text-muted">
资产库暂无图片<RouterLink :to="`/projects/${projectId}/assets`" class="text-button"
>前往资产库上传</RouterLink
>
</div>
<div class="mt-5 flex justify-end gap-3">
<NButton @click="assetPickerOpen = false">取消</NButton>
<NButton type="primary" :disabled="!selectedAssetId || disabled" @click="addAsset"
>登记为{{ categories[form.category] }}参考图</NButton
>
</div>
</AppDialog>
</section> </section>
</template> </template>
<style> <style>
@reference "../../../styles/styles.css";
/* 按面板实际宽度布局,侧栏或移动端压缩内容时也能保持地址输入可用。 */ /* 按面板实际宽度布局,侧栏或移动端压缩内容时也能保持地址输入可用。 */
.style-images-panel { .style-images-panel {
container-type: inline-size; container-type: inline-size;
@@ -139,6 +216,26 @@ const rules = { imageUrl: imageUrlRule, sortOrder: fieldRule(Number.isSafeIntege
gap: 12px; gap: 12px;
min-width: 0; min-width: 0;
} }
.style-image-submit {
margin-top: 29px;
}
.style-image-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 260px));
gap: 16px;
}
.style-asset-picker {
@apply grid grid-cols-[repeat(auto-fill,_minmax(min(180px,_100%),_1fr))] gap-3;
}
.style-asset-option {
@apply min-w-0 overflow-hidden text-left bg-(--app-subtle) border border-transparent;
}
.style-asset-option:hover {
@apply bg-(--app-control-hover);
}
.style-asset-option.is-selected {
@apply border-(--color-accent);
}
.style-image-controls .select-control { .style-image-controls .select-control {
min-width: 0; min-width: 0;
width: 100%; width: 100%;
+3 -2
View File
@@ -40,11 +40,12 @@ export type SaveVisualStyleInput = Partial<
> >
> >
/** 新增参考图使用已有地址,允许指定分类、启用状态及展示顺序。 */ /** 新增参考图使用资产库正式 ID 或外部地址,允许指定分类、启用状态及展示顺序。 */
export interface AddStyleImageInput { export interface AddStyleImageInput {
category: StyleCategory category: StyleCategory
source: 'upload' source: 'upload'
imageUrl: string imageUrl?: string
projectAssetId?: string
enabled: boolean enabled: boolean
sortOrder: number sortOrder: number
} }
@@ -136,7 +136,7 @@ function exportReport() {
<p class="mt-2 break-all font-mono text-[11px] text-muted">{{ node.checkpointId }}</p> <p class="mt-2 break-all font-mono text-[11px] text-muted">{{ node.checkpointId }}</p>
</article> </article>
</div> </div>
<NCollapse v-else class="mt-4" <NCollapse v-else class="mt-4" :default-expanded-names="groups.map(group => group.phase)"
><NCollapseItem ><NCollapseItem
v-for="group in groups" v-for="group in groups"
:key="group.phase" :key="group.phase"
+11
View File
@@ -0,0 +1,11 @@
const APP_TITLE = '短剧工作台'
/**
* 生成浏览器标签标题,并在项目工作区中优先展示项目名称。
* @param pageTitle 当前页面名称
* @param projectTitle 当前项目名称
* @returns 浏览器标签标题
*/
export function buildDocumentTitle(pageTitle: unknown, projectTitle?: string | null) {
return [projectTitle?.trim(), String(pageTitle || '工作空间'), APP_TITLE].filter(Boolean).join(' · ')
}
+5 -2
View File
@@ -26,6 +26,9 @@ function isRecord(value: unknown): value is Record<string, unknown> {
/** 兼容普通 {data} 响应,以及 POST /projects 的顶层 202 响应。 */ /** 兼容普通 {data} 响应,以及 POST /projects 的顶层 202 响应。 */
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> { export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const base = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, '') const base = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, '')
const multipart = typeof FormData !== 'undefined' && options.body instanceof FormData
const body: BodyInit | undefined =
options.body === undefined ? undefined : multipart ? (options.body as FormData) : JSON.stringify(options.body)
const controller = new AbortController() const controller = new AbortController()
const timeoutMs = options.timeoutMs ?? 30_000 const timeoutMs = options.timeoutMs ?? 30_000
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined
@@ -37,9 +40,9 @@ export async function request<T>(path: string, options: RequestOptions = {}): Pr
method: options.method ?? 'GET', method: options.method ?? 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }) ...(options.body === undefined || multipart ? {} : { 'Content-Type': 'application/json' })
}, },
body: options.body === undefined ? undefined : JSON.stringify(options.body), body,
signal: controller.signal signal: controller.signal
}) })
const text = await response.text() const text = await response.text()
+12 -1
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { buildDocumentTitle } from '../lib/document-title'
/** 按 graph 分路由,项目布局保留共享数据与长请求状态。 */ /** 按 graph 分路由,项目布局保留共享数据与长请求状态。 */
export const router = createRouter({ export const router = createRouter({
@@ -10,6 +11,11 @@ export const router = createRouter({
component: () => import('../features/projects/ProjectsPage.vue'), component: () => import('../features/projects/ProjectsPage.vue'),
meta: { title: '我的剧本' } meta: { title: '我的剧本' }
}, },
{
path: '/assets',
component: () => import('../features/project-assets/AssetLibraryPage.vue'),
meta: { title: '资产库' }
},
{ {
path: '/projects/:projectId', path: '/projects/:projectId',
component: () => import('../features/projects/ProjectLayout.vue'), component: () => import('../features/projects/ProjectLayout.vue'),
@@ -40,6 +46,11 @@ export const router = createRouter({
component: () => import('../features/subject-images/SubjectImagesPage.vue'), component: () => import('../features/subject-images/SubjectImagesPage.vue'),
meta: { title: '形态图片' } meta: { title: '形态图片' }
}, },
{
path: 'assets',
component: () => import('../features/project-assets/ProjectAssetsPage.vue'),
meta: { title: '资产库' }
},
{ {
path: 'storyboard', path: 'storyboard',
component: () => import('../features/storyboard/StoryboardPage.vue'), component: () => import('../features/storyboard/StoryboardPage.vue'),
@@ -62,5 +73,5 @@ export const router = createRouter({
/** 路由切换同步浏览器标题,不依赖页面组件主动修改。 */ /** 路由切换同步浏览器标题,不依赖页面组件主动修改。 */
router.afterEach(to => { router.afterEach(to => {
document.title = `${String(to.meta.title || '工作空间')} · 短剧工作台` document.title = buildDocumentTitle(to.meta.title)
}) })
+24 -5
View File
@@ -215,6 +215,24 @@ body {
min-height: 40px; min-height: 40px;
box-sizing: border-box; box-sizing: border-box;
} }
/* 每项保留一致的左右留白。 */
.workspace-tabs.n-tabs .n-tabs-nav .n-tabs-tab {
padding-inline: 16px;
}
/* 相邻 Tab 紧贴排列,间隔由各项内部留白提供。 */
.workspace-tabs.n-tabs .n-tabs-nav .n-tabs-tab-pad {
width: 0;
}
/* 选中状态仅通过文字颜色表达,不显示底部指示线。 */
.workspace-tabs.n-tabs .n-tabs-nav .n-tabs-bar {
display: none;
}
/* 标签与两侧附加操作共用无底边框的导航行。 */
.workspace-tabs.n-tabs .n-tabs-nav.n-tabs-nav--line-type.n-tabs-nav--top .n-tabs-nav-scroll-content,
.workspace-tabs.n-tabs .n-tabs-nav.n-tabs-nav--line-type.n-tabs-nav--top .n-tabs-nav__prefix,
.workspace-tabs.n-tabs .n-tabs-nav.n-tabs-nav--line-type.n-tabs-nav--top .n-tabs-nav__suffix {
border-bottom: none;
}
.workspace-tabs .n-tabs-nav-scroll-wrapper { .workspace-tabs .n-tabs-nav-scroll-wrapper {
min-width: 0; min-width: 0;
} }
@@ -439,10 +457,11 @@ body {
} }
} }
/* 导航保留明确选中态与轻量悬停反馈,明暗模式共用主题变量。 */ /* 明暗模式沿用组件的 primary 文字色,选中和悬停均不绘制背景。 */
.workspace-tabs .n-tabs-tab--active { .workspace-tabs.n-tabs .n-tabs-nav .n-tabs-tab--active {
font-weight: 600; color: var(--n-tab-text-color-active);
} }
.workspace-tabs .n-tabs-tab:hover { .workspace-tabs.n-tabs .n-tabs-nav .n-tabs-tab--active,
background: var(--app-subtle); .workspace-tabs.n-tabs .n-tabs-nav .n-tabs-tab:hover {
background: transparent;
} }
+8
View File
@@ -182,6 +182,14 @@
gap: 10px; gap: 10px;
padding-top: 20px; padding-top: 20px;
} }
/* 页面级表单的保存/执行操作从左侧开始,说明文字紧随其后;弹窗底部仍保持右对齐。 */
.page-form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 8px 16px;
}
.field-label { .field-label {
display: block; display: block;
margin-bottom: 9px; margin-bottom: 9px;
+5 -4
View File
@@ -37,8 +37,11 @@ describe('精简入口保留功能', () => {
expect(wrapper.emitted('select')).toHaveLength(1) expect(wrapper.emitted('select')).toHaveLength(1)
}) })
it('补充说明默认展开,键盘可聚焦的标题可查看原文', async () => { it('补充说明默认展开,键盘可聚焦的标题仍可手动收起和展开', async () => {
wrapper = mount(DetailDisclosure, { props: { title: '规则说明' }, slots: { default: '完整规则仍然保留' } }) wrapper = mount(DetailDisclosure, { props: { title: '规则说明' }, slots: { default: '完整规则仍然保留' } })
expect(wrapper.text()).toContain('完整规则仍然保留')
await wrapper.get('.n-collapse-item__header-main').trigger('click')
await flushPromises()
expect(wrapper.text()).not.toContain('完整规则仍然保留') expect(wrapper.text()).not.toContain('完整规则仍然保留')
await wrapper.get('.n-collapse-item__header-main').trigger('click') await wrapper.get('.n-collapse-item__header-main').trigger('click')
await flushPromises() await flushPromises()
@@ -47,9 +50,7 @@ describe('精简入口保留功能', () => {
it('风格高级字段收起不丢失保存值,展开后仍可修改', async () => { it('风格高级字段收起不丢失保存值,展开后仍可修改', async () => {
wrapper = mount(StyleEditor, { props: { visualStyle: null, disabled: false } }) wrapper = mount(StyleEditor, { props: { visualStyle: null, disabled: false } })
expect(wrapper.find('#style-constraints').exists()).toBe(false) expect(wrapper.find('#style-constraints').exists()).toBe(true)
await wrapper.get('.n-collapse-item__header-main').trigger('click')
await flushPromises()
await wrapper.get('#style-constraints').setValue('["保留硬约束"]') await wrapper.get('#style-constraints').setValue('["保留硬约束"]')
const fields = wrapper.findAllComponents(NInput) const fields = wrapper.findAllComponents(NInput)
fields.find(item => item.props('placeholder')?.includes('人物质感'))!.vm.$emit('update:value', '人物风格') fields.find(item => item.props('placeholder')?.includes('人物质感'))!.vm.$emit('update:value', '人物风格')
+14 -1
View File
@@ -1,5 +1,5 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { NImage } from 'naive-ui' import { NImage } from 'naive-ui'
import AssetImage from '@/components/ui/AssetImage.vue' import AssetImage from '@/components/ui/AssetImage.vue'
@@ -8,6 +8,8 @@ afterEach(() => {
wrapper?.unmount() wrapper?.unmount()
wrapper = undefined wrapper = undefined
document.body.innerHTML = '' document.body.innerHTML = ''
vi.useRealTimers()
vi.unstubAllGlobals()
}) })
describe('资源图片展示与原图预览', () => { describe('资源图片展示与原图预览', () => {
@@ -31,6 +33,17 @@ describe('资源图片展示与原图预览', () => {
expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('contain') expect(wrapper.get<HTMLImageElement>('img').element.style.objectFit).toBe('contain')
}) })
it('图片加载前显示轻量骨架占位,加载完成后直接淡入图片并移除占位', async () => {
wrapper = mount(AssetImage, { props: { src: '/storage/reveal.png', alt: '加载中的图片' } })
expect(wrapper.get('.reveal-image').attributes('aria-busy')).toBe('true')
expect(wrapper.find('.reveal-image-placeholder').exists()).toBe(true)
expect(wrapper.find('.reveal-image-grid').exists()).toBe(false)
await wrapper.get('img').trigger('load')
expect(wrapper.get('.reveal-image').classes()).toContain('is-loaded')
expect(wrapper.get('.reveal-image').attributes('aria-busy')).toBe('false')
expect(wrapper.find('.reveal-image-placeholder').exists()).toBe(false)
})
it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => { it('cover 仅裁切卡片,点击使用 Naive 原生预览展示同一完整资源,遮罩可关闭', async () => {
wrapper = mount(AssetImage, { wrapper = mount(AssetImage, {
attachTo: document.body, attachTo: document.body,
+29 -3
View File
@@ -29,6 +29,21 @@ afterEach(() => {
}) })
describe('全站平面主题', () => { describe('全站平面主题', () => {
it('页面级表单操作统一左对齐,弹窗操作仍保持右对齐', () => {
const css = readAllStyles()
expect(css).toMatch(
/\.page-form-actions\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*align-items:\s*center;[^}]*justify-content:\s*flex-start;[^}]*gap:\s*8px 16px;/
)
expect(css).toMatch(/\.dialog-footer\s*\{[^}]*justify-content:\s*flex-end;/)
for (const file of [
'src/features/subject-identity/components/IdentityEditor.vue',
'src/features/visual-style/components/StyleEditor.vue',
'src/features/generation-config/components/CreativeProfileSettings.vue'
]) {
expect(readFileSync(file, 'utf8')).toContain('page-form-actions')
}
})
it.each([false, true])('模式 %s 的绿色按钮使用白字,禁用后灰底灰字且不可提交', dark => { it.each([false, true])('模式 %s 的绿色按钮使用白字,禁用后灰底灰字且不可提交', dark => {
localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light') localStorage.setItem('drama-studio-theme', dark ? 'dark' : 'light')
vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} })) vi.stubGlobal('matchMedia', () => ({ matches: dark, addEventListener() {}, removeEventListener() {} }))
@@ -168,15 +183,19 @@ describe('全站平面主题', () => {
it('瀑布流按行填充自适应列宽,卡片与图片均保留实际高度', () => { it('瀑布流按行填充自适应列宽,卡片与图片均保留实际高度', () => {
const css = readAllStyles() const css = readAllStyles()
expect(css).toMatch(
/\.form-image-grid:not\(\.form-image-masonry\) \.asset-image\s*\{[^}]*aspect-ratio:\s*1 \/ 1;/
)
expect(css).toMatch( expect(css).toMatch(
/\.gallery-workspace-page \.form-image-masonry\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(auto-fill, minmax\(min\(260px, 100%\), 1fr\)\);[^}]*grid-auto-flow:\s*row dense;[^}]*grid-auto-rows:\s*1px;/ /\.gallery-workspace-page \.form-image-masonry\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(auto-fill, minmax\(min\(260px, 100%\), 1fr\)\);[^}]*grid-auto-flow:\s*row dense;[^}]*grid-auto-rows:\s*1px;/
) )
expect(css).toMatch( expect(css).toMatch(
/\.form-image-masonry > \.form-image-card\s*\{[^}]*grid-column-start:\s*var\(--form-image-column,\s*auto\);[^}]*grid-row-start:\s*var\(--form-image-row-start,\s*auto\);[^}]*grid-row-end:\s*span var\(--form-image-row-span,\s*1\);/ /\.form-image-masonry > \.form-image-card\s*\{[^}]*grid-column-start:\s*var\(--form-image-column,\s*auto\);[^}]*grid-row-start:\s*var\(--form-image-row-start,\s*auto\);[^}]*grid-row-end:\s*span var\(--form-image-row-span,\s*1\);/
) )
expect(css).toMatch(/\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*auto;/) expect(css).toMatch(/\.form-image-masonry \.asset-image\s*\{[^}]*aspect-ratio:\s*1 \/ 1;/)
expect(css).toMatch(/\.form-image-masonry \.asset-image\.form-image-has-source\s*\{[^}]*aspect-ratio:\s*auto;/)
expect(css).toMatch( expect(css).toMatch(
/\.form-image-masonry \.asset-image \.n-image,\s*\.form-image-masonry \.asset-image \.n-image img\s*\{[^}]*height:\s*auto;/ /\.form-image-masonry \.asset-image\.form-image-has-source \.n-image,\s*\.form-image-masonry \.asset-image\.form-image-has-source \.n-image img\s*\{[^}]*height:\s*auto;/
) )
}) })
@@ -254,6 +273,14 @@ describe('全站平面主题', () => {
expect(readFileSync('src/components/ui/DirectoryItem.vue', 'utf8')).not.toContain('icon-button') expect(readFileSync('src/components/ui/DirectoryItem.vue', 'utf8')).not.toContain('icon-button')
}) })
it('风格参考图操作按钮与输入行对齐,缩略图在宽屏下保持紧凑', () => {
const css = readAllStyles()
expect(css).toMatch(/\.style-image-submit\s*\{[^}]*margin-top:\s*29px;/)
expect(css).toMatch(
/\.style-image-list\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(auto-fill, minmax\(min\(220px, 100%\), 260px\)\);[^}]*gap:\s*16px;/
)
})
it('业务面板、列表分组和标签以背景区分,保留键盘焦点与选中指示', () => { it('业务面板、列表分组和标签以背景区分,保留键盘焦点与选中指示', () => {
// happy-dom 不计算布局;此项保护 CSS 契约,不能替代浏览器视觉验收。 // happy-dom 不计算布局;此项保护 CSS 契约,不能替代浏览器视觉验收。
const css = readAllStyles() const css = readAllStyles()
@@ -298,4 +325,3 @@ describe('全站平面主题', () => {
} }
}) })
}) })
+54 -3
View File
@@ -93,8 +93,7 @@ describe('统一的 Naive UI 表单校验', () => {
it('折叠后的非法 JSON 仍拦截保存并展开错误,修正后保留草稿正常提交', async () => { it('折叠后的非法 JSON 仍拦截保存并展开错误,修正后保留草稿正常提交', async () => {
wrapper = mount(StyleEditor, { attachTo: document.body, props: { visualStyle: null, disabled: false } }) wrapper = mount(StyleEditor, { attachTo: document.body, props: { visualStyle: null, disabled: false } })
await flushPromises() await flushPromises()
button('分类风格与硬约束').click() expect(button('分类风格与硬约束').getAttribute('aria-expanded')).toBe('true')
await flushPromises()
await input('#style-name', '手工风格') await input('#style-name', '手工风格')
await input('#style-constraints', '{"invalid":true}') await input('#style-constraints', '{"invalid":true}')
button('分类风格与硬约束').click() button('分类风格与硬约束').click()
@@ -109,7 +108,10 @@ describe('统一的 Naive UI 表单校验', () => {
}) })
it('登记参考图拦截非法地址和小数排序,允许有效存储地址与负整数排序', async () => { it('登记参考图拦截非法地址和小数排序,允许有效存储地址与负整数排序', async () => {
wrapper = mount(StyleImages, { attachTo: document.body, props: { images: [], disabled: false } }) wrapper = mount(StyleImages, {
attachTo: document.body,
props: { projectId: 'form-test-project', images: [], disabled: false }
})
await input('[aria-label="风格图片地址"]', 'javascript:alert(1)') await input('[aria-label="风格图片地址"]', 'javascript:alert(1)')
await input('[aria-label="风格图片排序"]', '1.5') await input('[aria-label="风格图片排序"]', '1.5')
await submit() await submit()
@@ -122,6 +124,55 @@ describe('统一的 Naive UI 表单校验', () => {
expect(wrapper.emitted('add')?.[0]?.[0]).toMatchObject({ imageUrl: '/storage/style.png', sortOrder: -1 }) expect(wrapper.emitted('add')?.[0]?.[0]).toMatchObject({ imageUrl: '/storage/style.png', sortOrder: -1 })
}) })
it('按需读取项目资产并用正式素材 ID 登记风格参考图', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
new Response(
JSON.stringify({
data: [
{
id: 'asset-db-1',
projectId: 'form-test-project',
name: '人物质感参考',
type: 'image',
category: 'character',
mimeType: 'image/png',
extension: 'png',
size: 1024,
publicUrl: '/storage/reference.png',
metadata: null,
createdAt: '2026-09-21T00:00:00Z',
updatedAt: '2026-09-21T00:00:00Z'
}
]
})
)
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(StyleImages, {
attachTo: document.body,
props: { projectId: 'form-test-project', images: [], disabled: false }
})
expect(fetcher).not.toHaveBeenCalled()
button('从资产库选择').click()
await flushPromises()
expect(fetcher).toHaveBeenCalledWith(
'/api/projects/form-test-project/assets',
expect.objectContaining({ method: 'GET' })
)
const option = document.querySelector<HTMLButtonElement>('.style-asset-option')!
expect(option.textContent).toContain('人物质感参考')
option.click()
await flushPromises()
button('登记为整体参考图').click()
await flushPromises()
expect(wrapper.emitted('add')?.[0]?.[0]).toMatchObject({
projectAssetId: 'asset-db-1',
category: 'overall',
source: 'upload'
})
})
it('异步验证期间切换对象或重复提交不会执行旧动作', async () => { it('异步验证期间切换对象或重复提交不会执行旧动作', async () => {
let finish: (() => void) | undefined let finish: (() => void) | undefined
const model = reactive({ text: '有效输入' }) const model = reactive({ text: '有效输入' })
+17 -6
View File
@@ -125,14 +125,16 @@ describe('管理后台组件边界', () => {
await router.push('/projects/test/production') await router.push('/projects/test/production')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } }) wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises() await flushPromises()
expect(wrapper.findAll('.n-menu .n-menu-item')).toHaveLength(8) expect(wrapper.findAll('.n-menu .n-menu-item')).toHaveLength(9)
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情') expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
expect(wrapper.get('.theme-toggle').text()).toBe('') expect(wrapper.get('.theme-toggle').text()).toBe('')
expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu') expect(wrapper.get('.theme-toggle').attributes('aria-haspopup')).toBe('menu')
expect(wrapper.getComponent(ThemeToggle).findComponent(NTooltip).exists()).toBe(false) expect(wrapper.getComponent(ThemeToggle).findComponent(NTooltip).exists()).toBe(false)
expect(wrapper.get('.theme-toggle').attributes('title')).toBeUndefined() expect(wrapper.get('.theme-toggle').attributes('title')).toBeUndefined()
expect(wrapper.get('.theme-toggle svg').classes()).toContain('lucide-monitor') expect(wrapper.get('.theme-toggle svg').classes()).toContain('lucide-monitor')
expect(wrapper.get('.admin-topbar [aria-controls="main-navigation"] svg').classes()).toContain('lucide-menu') expect(wrapper.get('.admin-topbar [aria-controls="main-navigation"] svg').classes()).toContain(
'lucide-panel-left-close'
)
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
for (const [key, label, icon] of [ for (const [key, label, icon] of [
['dark', '暗黑模式', 'moon'], ['dark', '暗黑模式', 'moon'],
@@ -173,9 +175,14 @@ describe('管理后台组件边界', () => {
} }
expect(firstMenuIcon().type).not.toBe(NPopover) expect(firstMenuIcon().type).not.toBe(NPopover)
expect(wrapper.get('.admin-sider-footer').findComponent(NPopover).exists()).toBe(false) expect(wrapper.get('.admin-sider-footer').findComponent(NPopover).exists()).toBe(false)
expect(wrapper.get('[aria-label="折叠侧栏"] svg').classes()).toContain('lucide-panel-left-close')
expect(wrapper.findAll('.studio-logo svg')).toHaveLength(2)
expect(wrapper.find('.studio-logo .lucide-film').exists()).toBe(true)
expect(wrapper.find('.studio-logo .lucide-sparkles').exists()).toBe(true)
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click') await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true) expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
expect(wrapper.get('[aria-label="展开侧栏"] svg').classes()).toContain('lucide-panel-left-open')
expect(wrapper.getComponent(NMenu).props('collapsed')).toBe(false) expect(wrapper.getComponent(NMenu).props('collapsed')).toBe(false)
expect(firstMenuIcon().type).not.toBe(NPopover) expect(firstMenuIcon().type).not.toBe(NPopover)
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 64px') expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 64px')
@@ -189,6 +196,7 @@ describe('管理后台组件边界', () => {
const labels = wrapper.findAll('.n-menu-item-content-header').map(item => item.text()) const labels = wrapper.findAll('.n-menu-item-content-header').map(item => item.text())
expect(labels).toEqual([ expect(labels).toEqual([
'我的剧本', '我的剧本',
'资产库',
'剧本创作', '剧本创作',
'剧本拆解', '剧本拆解',
'视觉风格', '视觉风格',
@@ -202,7 +210,7 @@ describe('管理后台组件边界', () => {
await settings.trigger('click') await settings.trigger('click')
await flushPromises() await flushPromises()
expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull() expect(document.querySelector('[role="dialog"][aria-label="后端连接"]')).not.toBeNull()
expect(wrapper.findAll('.admin-nav-scroll .n-menu .n-menu-item')).toHaveLength(8) expect(wrapper.findAll('.admin-nav-scroll .n-menu .n-menu-item')).toHaveLength(9)
}) })
it('侧栏菜单独立滚动,窄屏改为不占正文宽度的全屏固定层', () => { it('侧栏菜单独立滚动,窄屏改为不占正文宽度的全屏固定层', () => {
@@ -215,7 +223,7 @@ describe('管理后台组件边界', () => {
expect(css).toMatch(/\.admin-sider\.is-collapsed \.n-menu-item-content__icon\s*\{[^}]*pointer-events:\s*none/) expect(css).toMatch(/\.admin-sider\.is-collapsed \.n-menu-item-content__icon\s*\{[^}]*pointer-events:\s*none/)
expect(css).toMatch(/\.admin-sider-footer\s*\{[^}]*flex-shrink:\s*0/) expect(css).toMatch(/\.admin-sider-footer\s*\{[^}]*flex-shrink:\s*0/)
expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-settings-label\s*\{[^}]*opacity:\s*0/) expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-settings-label\s*\{[^}]*opacity:\s*0/)
expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-brand span\s*\{[^}]*opacity:\s*0/) expect(css).toMatch(/\.admin-sider\.is-collapsed \.admin-brand-copy\s*\{[^}]*opacity:\s*0/)
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.admin-brand\s*\{[^}]*height:\s*48px/) expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.admin-brand\s*\{[^}]*height:\s*48px/)
expect(css).toContain('@media (max-width: 800px)') expect(css).toContain('@media (max-width: 800px)')
expect(css).toMatch(/\.admin-sider\s*\{[^}]*transition:\s*width\s+0\.32s/) expect(css).toMatch(/\.admin-sider\s*\{[^}]*transition:\s*width\s+0\.32s/)
@@ -295,7 +303,7 @@ describe('管理后台组件边界', () => {
const collapsedSettings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]') const collapsedSettings = wrapper.get('.admin-sider-footer [aria-label="后端连接"]')
expect(collapsedSettings.text()).toBe('后端连接') expect(collapsedSettings.text()).toBe('后端连接')
expect(collapsedSettings.classes()).toContain('is-collapsed') expect(collapsedSettings.classes()).toContain('is-collapsed')
expect(wrapper.get('.admin-topbar [aria-label="打开主菜单"] svg').classes()).toContain('lucide-menu') expect(wrapper.get('.admin-topbar [aria-label="打开主菜单"] svg').classes()).toContain('lucide-panel-left-open')
await wrapper.get('.admin-topbar [aria-label="打开主菜单"]').trigger('click') await wrapper.get('.admin-topbar [aria-label="打开主菜单"]').trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.findAll('.admin-sider')).toHaveLength(1) expect(wrapper.findAll('.admin-sider')).toHaveLength(1)
@@ -304,8 +312,11 @@ describe('管理后台组件边界', () => {
expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 100%') expect(wrapper.get('.admin-sider').attributes('style')).toContain('width: 100%')
expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBeUndefined() expect(wrapper.get('.admin-sider').attributes('aria-hidden')).toBeUndefined()
expect(wrapper.find('.admin-sider-mask').exists()).toBe(false) expect(wrapper.find('.admin-sider-mask').exists()).toBe(false)
expect(wrapper.get('.admin-sider').findAll('.n-menu .n-menu-item')).toHaveLength(8) expect(wrapper.get('.admin-sider').findAll('.n-menu .n-menu-item')).toHaveLength(9)
expect(wrapper.get('.admin-topbar [aria-label="关闭主菜单"]').attributes('aria-expanded')).toBe('true') expect(wrapper.get('.admin-topbar [aria-label="关闭主菜单"]').attributes('aria-expanded')).toBe('true')
expect(wrapper.get('.admin-topbar [aria-label="关闭主菜单"] svg').classes()).toContain(
'lucide-panel-left-close'
)
const expandedBrand = wrapper.get('.admin-brand') const expandedBrand = wrapper.get('.admin-brand')
expect(expandedBrand.classes()).not.toContain('is-collapsed') expect(expandedBrand.classes()).not.toContain('is-collapsed')
expect(expandedBrand.text()).toContain('短剧工作台') expect(expandedBrand.text()).toContain('短剧工作台')
+1
View File
@@ -84,3 +84,4 @@ describe('异步轮询生命周期', () => {
scope.stop() scope.stop()
}) })
}) })
+73
View File
@@ -0,0 +1,73 @@
import { effectScope, nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useQuery } from '@/composables/useQuery'
afterEach(() => vi.useRealTimers())
/** 测试中尚未完成的查询请求。 */
interface PendingRequest {
/** 完成当前查询。 */
resolve: (value: string) => void
/** 当前查询的取消信号。 */
signal: AbortSignal
}
describe('异步查询生命周期', () => {
it('页面停留期间不重复请求,仍支持首次读取、手动刷新及查询目标切换', async () => {
vi.useFakeTimers()
const key = ref('first')
const loader = vi.fn<(id: string) => Promise<string>>(async id => id)
const scope = effectScope()
const query = scope.run(() => useQuery(key, loader))!
await Promise.resolve()
expect(query.data.value).toBe('first')
await vi.advanceTimersByTimeAsync(30_000)
expect(loader).toHaveBeenCalledTimes(1)
await query.refresh()
expect(loader).toHaveBeenCalledTimes(2)
key.value = 'second'
await nextTick()
await Promise.resolve()
expect(query.data.value).toBe('second')
await vi.advanceTimersByTimeAsync(30_000)
expect(loader).toHaveBeenCalledTimes(3)
scope.stop()
})
it('切换目标取消旧请求并忽略迟到响应', async () => {
const key = ref('first')
const requests = new Map<string, PendingRequest>()
const loader = vi.fn<(id: string, signal: AbortSignal) => Promise<string>>(
(id, signal) =>
new Promise<string>(resolve => {
requests.set(id, { resolve, signal })
})
)
const scope = effectScope()
const query = scope.run(() => useQuery(key, loader))!
key.value = 'second'
await nextTick()
expect(requests.get('first')!.signal.aborted).toBe(true)
requests.get('second')!.resolve('新项目')
await Promise.resolve()
requests.get('first')!.resolve('旧项目')
await Promise.resolve()
expect(query.data.value).toBe('新项目')
scope.stop()
expect(requests.get('second')!.signal.aborted).toBe(true)
})
it('失败时保留已有数据,并向页面显示错误', async () => {
const scope = effectScope()
const loader = vi
.fn<() => Promise<string>>()
.mockResolvedValueOnce('上次成功数据')
.mockRejectedValueOnce(new Error('连接已断开'))
const query = scope.run(() => useQuery(ref('p'), loader))!
await Promise.resolve()
await query.refresh()
expect(query.data.value).toBe('上次成功数据')
expect(query.error.value).toBe('连接已断开')
scope.stop()
})
})
@@ -248,7 +248,7 @@ describe('拆解页内容滚动', () => {
['人物', 'character'], ['人物', 'character'],
['场景', 'scene'], ['场景', 'scene'],
['道具', 'prop'] ['道具', 'prop']
] as const)('%s 的奇偶主体展开后均使用独立形态块,搜索重排不影响形态归属', async (label, module) => { ] as const)('%s 的奇偶主体默认展示独立形态块,搜索重排不影响形态归属', async (label, module) => {
const record = checkpoint() const record = checkpoint()
// 前两个主体各准备两种形态,覆盖奇偶条纹和默认标签,不调用模型接口。 // 前两个主体各准备两种形态,覆盖奇偶条纹和默认标签,不调用模型接口。
record.state.breakdownResult!.subjectForms = [0, 1].flatMap(subjectIndex => record.state.breakdownResult!.subjectForms = [0, 1].flatMap(subjectIndex =>
@@ -267,8 +267,6 @@ describe('拆解页内容滚动', () => {
const articles = wrapper!.findAll('.subject-record-list > article') const articles = wrapper!.findAll('.subject-record-list > article')
for (const index of [0, 1]) { for (const index of [0, 1]) {
const article = articles[index]! const article = articles[index]!
await article.get('.n-collapse-item__header-main').trigger('click')
await flushPromises()
const cards = article.findAll('.subject-form-card') const cards = article.findAll('.subject-form-card')
expect(cards).toHaveLength(2) expect(cards).toHaveLength(2)
expect(cards[0]!.get('.n-tag').text()).toBe('默认') expect(cards[0]!.get('.n-tag').text()).toBe('默认')
@@ -71,7 +71,14 @@ describe('连续剧本阅读器', () => {
expect(content).toContain('padding: 32px') expect(content).toContain('padding: 32px')
expect(content).toContain('text-align: left') expect(content).toContain('text-align: left')
expect(content).not.toContain('auto') expect(content).not.toContain('auto')
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.reader-content\s*\{\s*padding-inline:\s*18px/) expect(css).toMatch(
/\.reader-chapter-heading\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*z-index:\s*2;[^}]*background:\s*var\(--app-surface\)/
)
expect(css).toMatch(
/\.reader-chapter-heading\s*\{[^}]*margin-inline:\s*calc\(var\(--reader-gutter\) \* -1\);[^}]*padding:\s*14px var\(--reader-gutter\) 18px;/
)
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.reader-content\s*\{[^}]*padding-inline:\s*18px/)
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.reader-content\s*\{[^}]*--reader-gutter:\s*18px/)
expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.episode-reader\s*\{[^}]*72px/) expect(css).toMatch(/@media \(max-width: 800px\)[\s\S]*?\.episode-reader\s*\{[^}]*72px/)
}) })
@@ -0,0 +1,53 @@
import { mount } from '@vue/test-utils'
import { NInputNumber, NSelect } from 'naive-ui'
import { describe, expect, it } from 'vitest'
import GenerationOptionFields from '../../../src/features/generation-config/components/GenerationOptionFields.vue'
import type { GenerationOptionSchema } from '../../../src/features/generation-config/types'
function mountFields(schemas: GenerationOptionSchema[]) {
return mount(GenerationOptionFields, {
props: { schemas, modelValue: { size: '2K', durationSeconds: 4, seed: 1 } }
})
}
describe('生成模型参数字段', () => {
it('图片尺寸使用下拉框并保留标准尺寸与当前值', () => {
const wrapper = mountFields([
{ key: 'size', label: '图片尺寸', type: 'size', required: true, providerDefault: 'auto' }
])
const select = wrapper.getComponent(NSelect)
expect(select.props('clearable')).toBe(false)
expect(select.props('options')).toEqual(
expect.arrayContaining([
expect.objectContaining({ value: '1K' }),
expect.objectContaining({ value: '1.5K' }),
expect.objectContaining({ value: '2K' }),
expect.objectContaining({ value: 'auto' })
])
)
expect(wrapper.findComponent(NInputNumber).exists()).toBe(false)
})
it('视频时长按后端范围生成秒数选项,其他数值仍可输入', () => {
const wrapper = mountFields([
{
key: 'durationSeconds',
label: '生成时长',
type: 'integer',
required: true,
min: 4,
max: 6,
specialValues: [{ label: '自动', value: -1 }]
},
{ key: 'seed', label: '随机种子', type: 'integer', required: false }
])
const select = wrapper.getComponent(NSelect)
expect(select.props('options')).toEqual([
{ label: '自动', value: -1 },
{ label: '4 秒', value: 4 },
{ label: '5 秒', value: 5 },
{ label: '6 秒', value: 6 }
])
expect(wrapper.findAllComponents(NInputNumber)).toHaveLength(1)
})
})
@@ -140,4 +140,59 @@ describe('镜头生产数据契约', () => {
await flushPromises() await flushPromises()
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }]) expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
}) })
it('首帧生成前可预览后端最终选图规划和 Prompt', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
new Response(
JSON.stringify({
data: {
shotId: 'shot-1',
episodeNo: 1,
beatNo: 1,
shotNo: 1,
provider: 'seedream',
model: 'image-model',
generationConfig: {},
references: [],
referencePlan: {
provider: 'seedream',
maxReferenceImages: 4,
totalCandidates: 2,
strategy: 'subject-coverage',
selected: [
{
subjectRef: '@CH0001',
module: 'character',
role: 'identity-anchor',
imageId: 'anchor-1'
}
],
omitted: [
{
subjectRef: '@SC0001',
module: 'scene',
role: 'form-primary',
imageId: 'scene-1'
}
]
},
providerPrompt: '最终首帧 Prompt'
}
})
)
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(KeyframeDialog, {
attachTo: document.body,
props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false }
})
await flushPromises()
button('预览实际生成输入').click()
await flushPromises()
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-1/keyframe/preview')
expect(document.body.textContent).toContain('已选 1 / 2 张')
expect(document.body.textContent).toContain('省略 1 张')
expect(document.body.textContent).toContain('最终首帧 Prompt')
expect(wrapper.emitted('generate')).toBeUndefined()
})
}) })
+2 -2
View File
@@ -374,7 +374,7 @@ describe('视觉质量契约与付费边界', () => {
}) })
describe('质量面板渐进展示', () => { describe('质量面板渐进展示', () => {
it('默认仅校验,未确认时不可提交,打开或更改参数不产生费用', async () => { it('默认展示校验参数,未确认时不可提交,打开或更改参数不产生费用', async () => {
const data = server() const data = server()
wrapper = mount(QualityDialog, { wrapper = mount(QualityDialog, {
attachTo: document.body, attachTo: document.body,
@@ -388,7 +388,7 @@ describe('质量面板渐进展示', () => {
await flushPromises() await flushPromises()
expect(validationButton().disabled).toBe(true) expect(validationButton().disabled).toBe(true)
expect(document.body.textContent).toContain('更多参数与模型限制') expect(document.body.textContent).toContain('更多参数与模型限制')
expect(document.querySelector('[aria-label="额外允许的画面文字"]')).toBeNull() expect(document.querySelector('[aria-label="额外允许的画面文字"]')).not.toBeNull()
expect(data.fetcher).not.toHaveBeenCalled() expect(data.fetcher).not.toHaveBeenCalled()
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true) wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
await flushPromises() await flushPromises()
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { projectAssetsApi } from '@/features/project-assets/api'
import type { ProjectAsset } from '@/features/project-assets/types'
const asset: ProjectAsset = {
id: 'asset-db-1',
projectId: 'project-db-1',
name: '雨夜街道',
type: 'image',
category: 'scene',
mimeType: 'image/png',
extension: 'png',
size: 2048,
publicUrl: '/storage/project/scene.png',
metadata: { originalName: 'scene.png' },
createdAt: '2026-09-21T00:00:00Z',
updatedAt: '2026-09-21T00:00:00Z'
}
afterEach(() => vi.unstubAllGlobals())
describe('项目资产 API', () => {
it('通过单个全局接口读取全部项目素材', async () => {
const globalAsset = {
...asset,
project: { id: 'project-db-1', title: '记忆当铺', topic: '记忆交易', status: 'completed' }
}
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({ data: [globalAsset] })))
vi.stubGlobal('fetch', fetcher)
await expect(projectAssetsApi.listAll()).resolves.toEqual([globalAsset])
expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher).toHaveBeenCalledWith('/api/assets', expect.objectContaining({ method: 'GET' }))
})
it('使用 multipart 上传文件,不手工设置 Content-Type', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response(JSON.stringify({ data: asset }), { status: 201 }))
vi.stubGlobal('fetch', fetcher)
const file = new File(['image'], 'scene.png', { type: 'image/png' })
await expect(
projectAssetsApi.upload('project-db-1', { file, name: '雨夜街道', category: 'scene' })
).resolves.toEqual(asset)
const [url, init] = fetcher.mock.calls[0]!
expect(url).toBe('/api/projects/project-db-1/assets')
expect(init?.method).toBe('POST')
expect(init?.headers).toEqual({ Accept: 'application/json' })
expect(init?.body).toBeInstanceOf(FormData)
const body = init?.body as FormData
expect(body.get('file')).toBe(file)
expect(body.get('name')).toBe('雨夜街道')
expect(body.get('category')).toBe('scene')
})
it('按正式项目和素材 ID 读取、编辑及删除', async () => {
const fetcher = vi
.fn<typeof fetch>()
.mockImplementation(async () => new Response(JSON.stringify({ data: asset })))
vi.stubGlobal('fetch', fetcher)
await projectAssetsApi.list('project/db')
await projectAssetsApi.get('project/db', 'asset/db')
await projectAssetsApi.update('project/db', 'asset/db', { name: '新名称', category: 'reference' })
await projectAssetsApi.remove('project/db', 'asset/db')
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method ?? 'GET'])).toEqual([
['/api/projects/project%2Fdb/assets', 'GET'],
['/api/projects/project%2Fdb/assets/asset%2Fdb', 'GET'],
['/api/projects/project%2Fdb/assets/asset%2Fdb', 'PUT'],
['/api/projects/project%2Fdb/assets/asset%2Fdb', 'DELETE']
])
expect(fetcher.mock.calls[2]?.[1]?.body).toBe(JSON.stringify({ name: '新名称', category: 'reference' }))
})
})
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import { NTooltip } from 'naive-ui'
import AssetLibraryPage from '@/features/project-assets/AssetLibraryPage.vue'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
describe('全局资产库', () => {
it('单次读取并直接显示全部剧本图片,支持按图片与剧本搜索', async () => {
const assets = [
{
id: 'asset-scene',
projectId: 'project-one',
name: '雨夜街道',
type: 'image',
category: 'scene',
mimeType: 'image/png',
extension: 'png',
size: 4096,
publicUrl: '/storage/scene.png',
metadata: { originalName: 'rain-street-original.png' },
createdAt: '2026-09-22T00:00:00Z',
updatedAt: '2026-09-22T00:00:00Z',
project: { id: 'project-one', title: '记忆当铺', topic: '记忆交易', status: 'completed' }
},
{
id: 'asset-character',
projectId: 'project-two',
name: '林默正面照',
type: 'image',
category: 'character',
mimeType: 'image/jpeg',
extension: 'jpg',
size: 8192,
publicUrl: '/storage/character.jpg',
metadata: null,
createdAt: '2026-09-22T01:00:00Z',
updatedAt: '2026-09-22T01:00:00Z',
project: { id: 'project-two', title: '天降甘霖', topic: '乡村故事', status: 'completed' }
}
]
const projects = assets.map(asset => ({
...asset.project,
style: null,
createdAt: '',
updatedAt: ''
}))
const fetcher = vi.fn<typeof fetch>().mockImplementation(async input => {
const url = String(input)
return new Response(JSON.stringify({ data: url.endsWith('/projects') ? projects : assets }))
})
vi.stubGlobal('fetch', fetcher)
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/assets', component: AssetLibraryPage },
{ path: '/projects/:projectId/assets', component: { template: '<div />' } }
]
})
await router.push('/assets')
wrapper = mount(AssetLibraryPage, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
expect(fetcher).toHaveBeenCalledTimes(2)
expect(fetcher).toHaveBeenCalledWith('/api/assets', expect.objectContaining({ method: 'GET' }))
expect(wrapper.findAll('.asset-library-card')).toHaveLength(2)
expect(wrapper.text()).toContain('雨夜街道')
expect(wrapper.text()).toContain('林默正面照')
expect(wrapper.text()).toContain('记忆当铺')
expect(wrapper.findAllComponents(NTooltip)).toHaveLength(2)
const title = wrapper.get('.asset-library-title')
expect(title.classes()).toContain('asset-library-title')
expect(wrapper.findComponent(NTooltip).props('disabled')).toBe(true)
Object.defineProperties(title.element, {
scrollWidth: { configurable: true, value: 240 },
clientWidth: { configurable: true, value: 120 }
})
await title.trigger('mouseenter')
expect(wrapper.findComponent(NTooltip).props('disabled')).toBe(false)
expect(wrapper.get('.asset-library-card').text()).not.toContain('rain-street-original.png')
expect(wrapper.get('.asset-library-card').text()).not.toContain('4.0 KB')
expect(wrapper.find('[aria-label="预览图片"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="查看素材详情"]').exists()).toBe(false)
await wrapper.get('.asset-library-card').trigger('click')
await flushPromises()
const dialog = document.querySelector('[role="dialog"][aria-label="素材详情与管理"]')!
expect(dialog).not.toBeNull()
expect(dialog.textContent).toContain('rain-street-original.png')
expect(dialog.textContent).toContain('image/png')
expect(dialog.textContent).toContain('4.0 KB')
expect(router.currentRoute.value.path).toBe('/assets')
await wrapper.get('input[aria-label="搜索资产图片"]').setValue('天降甘霖')
expect(wrapper.findAll('.asset-library-card')).toHaveLength(1)
expect(wrapper.text()).toContain('林默正面照')
expect(wrapper.text()).not.toContain('雨夜街道')
})
})
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import ProjectAssetsPage from '@/features/project-assets/ProjectAssetsPage.vue'
import { projectContextKey } from '@/features/projects/context'
import { testProjectContext } from '@/testing/project-context'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
describe('项目资产库页面', () => {
it('读取当前项目素材并支持名称、分类和原文件名筛选', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
new Response(
JSON.stringify({
data: [
{
id: 'asset-scene',
projectId: 'capability-test',
name: '雨夜街道',
type: 'image',
category: 'scene',
mimeType: 'image/png',
extension: 'png',
size: 4096,
publicUrl: '/storage/scene.png',
metadata: { originalName: 'street-original.png' },
createdAt: '2026-09-21T00:00:00Z',
updatedAt: '2026-09-21T00:00:00Z'
},
{
id: 'asset-character',
projectId: 'capability-test',
name: '林默正面照',
type: 'image',
category: 'character',
mimeType: 'image/jpeg',
extension: 'jpg',
size: 8192,
publicUrl: '/storage/character.jpg',
metadata: null,
createdAt: '2026-09-21T01:00:00Z',
updatedAt: '2026-09-21T01:00:00Z'
}
]
})
)
)
vi.stubGlobal('fetch', fetcher)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/projects/:projectId/assets', component: ProjectAssetsPage }]
})
await router.push('/projects/capability-test/assets')
wrapper = mount(ProjectAssetsPage, {
attachTo: document.body,
global: {
plugins: [router],
provide: { [projectContextKey as symbol]: testProjectContext() }
}
})
await flushPromises()
expect(fetcher).toHaveBeenCalledWith(
'/api/projects/capability-test/assets',
expect.objectContaining({ method: 'GET' })
)
expect(wrapper.findAll('.project-asset-card')).toHaveLength(2)
expect(wrapper.text()).toContain('雨夜街道')
expect(wrapper.text()).toContain('林默正面照')
await wrapper.get('input[aria-label="搜索项目素材"]').setValue('street-original')
expect(wrapper.findAll('.project-asset-card')).toHaveLength(1)
expect(wrapper.text()).toContain('雨夜街道')
expect(wrapper.text()).not.toContain('林默正面照')
await wrapper.get('input[aria-label="搜索项目素材"]').setValue('没有结果')
expect(wrapper.findAll('.project-asset-card')).toHaveLength(0)
expect(wrapper.text()).toContain('没有匹配的素材')
})
})
+17 -28
View File
@@ -10,7 +10,7 @@ import { projectsApi } from '@/features/projects/api'
import type { ProjectDetail, ProjectStatus } from '@/features/projects/types' import type { ProjectDetail, ProjectStatus } from '@/features/projects/types'
import { readAllStyles } from '@/testing/styles' import { readAllStyles } from '@/testing/styles'
const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'storyboard', 'production'] const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'assets', 'storyboard', 'production']
let wrapper: VueWrapper | undefined let wrapper: VueWrapper | undefined
afterEach(() => { afterEach(() => {
wrapper?.unmount() wrapper?.unmount()
@@ -66,21 +66,14 @@ async function openProject(initialPath: string) {
} }
describe('剧本完成前的下游访问限制', () => { describe('剧本完成前的下游访问限制', () => {
it('进入图库暂停父级轮询,返回其他工作区恢复定时更新', async () => { it('项目工作区停留期间不重复读取项目详情和工作流记录', async () => {
vi.useFakeTimers() vi.useFakeTimers()
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('manual-gallery', 'completed')) const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('manual-project', 'completed'))
const checkpoints = vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) const checkpoints = vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { router } = await openProject('/projects/manual-gallery/production') await openProject('/projects/manual-project/production')
await router.push('/projects/manual-gallery/subject-images')
await flushPromises()
await vi.advanceTimersByTimeAsync(30_000) await vi.advanceTimersByTimeAsync(30_000)
expect(detail).toHaveBeenCalledTimes(1) expect(detail).toHaveBeenCalledTimes(1)
expect(checkpoints).toHaveBeenCalledTimes(1) expect(checkpoints).toHaveBeenCalledTimes(1)
await router.push('/projects/manual-gallery/production')
await flushPromises()
await vi.advanceTimersByTimeAsync(6000)
expect(detail).toHaveBeenCalledTimes(2)
expect(checkpoints).toHaveBeenCalledTimes(2)
}) })
it('图库直接链接只读取一次项目,未完成时可手动刷新解锁', async () => { it('图库直接链接只读取一次项目,未完成时可手动刷新解锁', async () => {
@@ -91,7 +84,7 @@ describe('剧本完成前的下游访问限制', () => {
await openProject('/projects/manual-gate/subject-images') await openProject('/projects/manual-gate/subject-images')
await vi.advanceTimersByTimeAsync(30_000) await vi.advanceTimersByTimeAsync(30_000)
expect(detail).toHaveBeenCalledTimes(1) expect(detail).toHaveBeenCalledTimes(1)
expect(wrapper!.get('.project-access-gate').text()).not.toContain('状态会自动更新') expect(wrapper!.get('.project-access-gate').text()).toContain('请手动刷新项目状态')
status = 'completed' status = 'completed'
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新项目状态')! const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新项目状态')!
await refresh.trigger('click') await refresh.trigger('click')
@@ -123,24 +116,21 @@ describe('剧本完成前的下游访问限制', () => {
} }
) )
it('轮询完成状态自动解锁;重新变为待审核时撤下下游面板', async () => { it('未完成项目停留期间不自动解锁,仅在手动刷新后进入下游页面', async () => {
vi.useFakeTimers() vi.useFakeTimers()
let status: ProjectStatus = 'generating' let status: ProjectStatus = 'generating'
vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status)) vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status))
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { mounted } = await openProject('/projects/polling/production') const { mounted } = await openProject('/projects/manual-refresh/production')
expect(mounted).not.toHaveBeenCalled() expect(mounted).not.toHaveBeenCalled()
status = 'completed' status = 'completed'
await vi.advanceTimersByTimeAsync(6000) await vi.advanceTimersByTimeAsync(6000)
await flushPromises() await flushPromises()
expect(wrapper!.get('.workspace-probe').text()).toBe('production')
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
status = 'need_review'
await vi.advanceTimersByTimeAsync(6000)
await flushPromises()
expect(wrapper!.find('.workspace-probe').exists()).toBe(false) expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作') await wrapper!.get('.project-access-gate button:nth-of-type(2)').trigger('click')
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2) await flushPromises()
expect(wrapper!.get('.workspace-probe').text()).toBe('production')
expect(wrapper!.findAll('.n-menu a')).toHaveLength(9)
expect(mounted).toHaveBeenCalledExactlyOnceWith('production') expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
}) })
@@ -154,10 +144,10 @@ describe('剧本完成前的下游访问限制', () => {
) )
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { router, mounted } = await openProject('/projects/first/production') const { router, mounted } = await openProject('/projects/first/production')
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8) expect(wrapper!.findAll('.n-menu a')).toHaveLength(9)
await router.push('/projects/second/production') await router.push('/projects/second/production')
await flushPromises() await flushPromises()
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2) expect(wrapper!.findAll('.n-menu a')).toHaveLength(3)
expect(wrapper!.find('.workspace-probe').exists()).toBe(false) expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
resolveSecond(project('second', 'draft')) resolveSecond(project('second', 'draft'))
await flushPromises() await flushPromises()
@@ -165,10 +155,10 @@ describe('剧本完成前的下游访问限制', () => {
expect(mounted).toHaveBeenCalledExactlyOnceWith('production') expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
await router.push('/projects') await router.push('/projects')
await flushPromises() await flushPromises()
expect(wrapper!.findAll('.n-menu a')).toHaveLength(1) expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
}) })
it('读取失败保持锁定;错误区重试成功后解锁,页头不恢复标签与刷新按钮', async () => { it('读取失败保持锁定;错误区重试成功后解锁且不恢复项目标题栏', async () => {
const detail = vi const detail = vi
.spyOn(projectsApi, 'detail') .spyOn(projectsApi, 'detail')
.mockRejectedValueOnce(new Error('项目读取失败')) .mockRejectedValueOnce(new Error('项目读取失败'))
@@ -176,9 +166,8 @@ describe('剧本完成前的下游访问限制', () => {
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([]) vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const { mounted } = await openProject('/projects/retry/storyboard') const { mounted } = await openProject('/projects/retry/storyboard')
expect(mounted).not.toHaveBeenCalled() expect(mounted).not.toHaveBeenCalled()
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2) expect(wrapper!.findAll('.n-menu a')).toHaveLength(3)
expect(wrapper!.find('.project-header .n-button').exists()).toBe(false) expect(wrapper!.find('.project-header').exists()).toBe(false)
expect(wrapper!.find('.project-header .n-tag').exists()).toBe(false)
await wrapper!.get('.project-notices button').trigger('click') await wrapper!.get('.project-notices button').trigger('click')
await flushPromises() await flushPromises()
expect(detail).toHaveBeenCalledTimes(2) expect(detail).toHaveBeenCalledTimes(2)
@@ -159,7 +159,7 @@ describe('身份图与母版契约', () => {
expect(wrapper.emitted('anchor')).toBeUndefined() expect(wrapper.emitted('anchor')).toBeUndefined()
}) })
it('轮询新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => { it('刷新新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
const anchor = identityImageFixture({ id: 'anchor' }) const anchor = identityImageFixture({ id: 'anchor' })
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false }) const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
wrapper = mount(IdentityGallery, { wrapper = mount(IdentityGallery, {
@@ -133,6 +133,15 @@ async function gallery(query = '') {
} }
describe('过期首帧到具体素材定位', () => { describe('过期首帧到具体素材定位', () => {
it('没有图片的形态卡片标记为方形占位', async () => {
const { first } = await gallery()
first.images = []
await wrapper!.get('[aria-label="刷新图库"]').trigger('click')
await flushPromises()
const card = wrapper!.findAll('.form-image-card').find(item => item.attributes('data-form-id') === first.id)!
expect(card.get('.asset-image').classes()).toContain('form-image-empty')
})
it('网格与瀑布流切换保留卡片、滚动容器和筛选,不发起额外请求', async () => { it('网格与瀑布流切换保留卡片、滚动容器和筛选,不发起额外请求', async () => {
const { fetcher } = await gallery() const { fetcher } = await gallery()
const requestCount = fetcher.mock.calls.length const requestCount = fetcher.mock.calls.length
@@ -140,29 +149,31 @@ describe('过期首帧到具体素材定位', () => {
const grid = wrapper!.get('.form-image-grid').element const grid = wrapper!.get('.form-image-grid').element
const cards = wrapper!.findAll('.form-image-card').map(card => card.element) const cards = wrapper!.findAll('.form-image-card').map(card => card.element)
scroll.scrollTop = 240 scroll.scrollTop = 240
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('true') expect(wrapper!.get('.form-image-masonry').element).toBe(grid)
expect(wrapper!.get('[aria-label="网格布局"]').classes()).toContain('layout-option-active') expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('true')
expect(wrapper!.get('[aria-label="瀑布流布局"]').classes()).not.toContain('layout-option-active') expect(wrapper!.get('[aria-label="瀑布流布局"]').classes()).toContain('layout-option-active')
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('false')
expect(wrapper!.get('[aria-label="网格布局"]').classes()).not.toContain('layout-option-active')
expect( expect(
wrapper! wrapper!
.get('.gallery-layout-switch') .get('.gallery-layout-switch')
.findAll('button') .findAll('button')
.map(button => button.attributes('aria-label')) .map(button => button.attributes('aria-label'))
).toEqual(['网格布局', '瀑布流布局', '刷新图库', '批量生图']) ).toEqual(['网格布局', '瀑布流布局', '刷新图库', '批量生图'])
await wrapper!.get('[aria-label="瀑布流布局"]').trigger('click') await wrapper!.get('[aria-label="网格布局"]').trigger('click')
expect(wrapper!.get('.form-image-masonry').element).toBe(grid) expect(wrapper!.find('.form-image-masonry').exists()).toBe(false)
expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('true') expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('true')
expect(wrapper!.get('[aria-label="瀑布流布局"]').classes()).toContain('layout-option-active') expect(wrapper!.get('[aria-label="网格布局"]').classes()).toContain('layout-option-active')
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('false') expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('false')
expect(wrapper!.get('[aria-label="网格布局"]').classes()).not.toContain('layout-option-active') expect(wrapper!.get('[aria-label="瀑布流布局"]').classes()).not.toContain('layout-option-active')
expect(wrapper!.findAll('.form-image-card').map(card => card.element)).toEqual(cards) expect(wrapper!.findAll('.form-image-card').map(card => card.element)).toEqual(cards)
expect(scroll.scrollTop).toBe(240) expect(scroll.scrollTop).toBe(240)
expect( expect(
wrapper!.get<HTMLElement>('.form-image-card').element.style.getPropertyValue('--form-image-aspect') wrapper!.get<HTMLElement>('.form-image-card').element.style.getPropertyValue('--form-image-aspect')
).toBe('') ).toBe('')
await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('雨夜') await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('雨夜')
await wrapper!.get('[aria-label="网格布局"]').trigger('click') await wrapper!.get('[aria-label="瀑布流布局"]').trigger('click')
expect(wrapper!.find('.form-image-masonry').exists()).toBe(false) expect(wrapper!.get('.form-image-masonry').element).toBe(grid)
expect(wrapper!.get('.form-image-grid').element).toBe(grid) expect(wrapper!.get('.form-image-grid').element).toBe(grid)
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1) expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
expect(wrapper!.get('.form-image-card').text()).toContain('雨夜形态') expect(wrapper!.get('.form-image-card').text()).toContain('雨夜形态')
@@ -70,6 +70,41 @@ describe('形态图片选择与操作', () => {
expect(document.body.textContent).toContain('母版') expect(document.body.textContent).toContain('母版')
expect(document.body.textContent).not.toContain('道具形态生图暂不自动引用') expect(document.body.textContent).not.toContain('道具形态生图暂不自动引用')
}) })
it('按需预览正式形态的实际参考图和 Provider Prompt,不触发生图事件', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
new Response(
JSON.stringify({
data: {
subjectFormId: 'form-db-1',
provider: 'seedream',
model: 'image-model',
generationConfig: { width: 2048 },
references: [
{
imageNumber: 1,
role: 'identity-anchor',
imageId: 'anchor-1',
imageUrl: '/storage/anchor.png'
}
],
providerPrompt: '最终形态 Prompt'
}
})
)
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(GenerateImageDialog, {
attachTo: document.body,
props: { open: true, form: formFixture(), disabled: false }
})
await flushPromises()
button('预览实际生成输入').click()
await flushPromises()
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/subject-forms/form-db-1/images/preview')
expect(document.body.textContent).toContain('seedream / image-model')
expect(document.body.textContent).toContain('最终形态 Prompt')
expect(wrapper.emitted('generate')).toBeUndefined()
})
it('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => { it('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => {
const form = formFixture() const form = formFixture()
form.images.unshift( form.images.unshift(
@@ -112,6 +112,14 @@ describe('形态正式提示词与批量配置', () => {
expect(service.session.value.promptReceipt?.result.failures[0]?.error).toBe('模型拒绝') expect(service.session.value.promptReceipt?.result.failures[0]?.error).toBe('模型拒绝')
expect(service.session.value.receipt).toBeNull() expect(service.session.value.receipt).toBeNull()
}) })
it('批量提示词和图片可按主体模块缩小范围', async () => {
const { service, posts } = await setup()
service.batchModule.value = 'scene'
await service.generatePrompts()
await service.generateProject()
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toMatchObject({ module: 'scene' })
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toMatchObject({ module: 'scene' })
})
it('图片数量上限可选,非法上限和并发阻止提交', async () => { it('图片数量上限可选,非法上限和并发阻止提交', async () => {
const { service, posts } = await setup() const { service, posts } = await setup()
for (const limit of [0, -1, 1.5]) { for (const limit of [0, -1, 1.5]) {
+15 -31
View File
@@ -21,7 +21,6 @@ import {
} from '@/features/storyboard/testing/fixtures' } from '@/features/storyboard/testing/fixtures'
import { getStoryboardSession } from '@/features/storyboard/model' import { getStoryboardSession } from '@/features/storyboard/model'
import { getOperation } from '@/features/workflows/operations' import { getOperation } from '@/features/workflows/operations'
import { readAllStyles } from '@/testing/styles'
import SubjectImagesPage from '@/features/subject-images/SubjectImagesPage.vue' import SubjectImagesPage from '@/features/subject-images/SubjectImagesPage.vue'
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures' import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
import { getImageSession } from '@/features/subject-images/model' import { getImageSession } from '@/features/subject-images/model'
@@ -379,7 +378,7 @@ describe('视觉风格与主体身份工作区', () => {
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')! const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
expect(put[0]).toBe('/api/subjects/subject-db-1/identity') expect(put[0]).toBe('/api/subjects/subject-db-1/identity')
expect(JSON.parse(put[1]!.body as string)).toMatchObject({ description: '人工稳定身份', isLocked: true }) expect(JSON.parse(put[1]!.body as string)).toMatchObject({ description: '人工稳定身份', isLocked: true })
expect(button('AI 重新生成身份').disabled).toBe(true) expect(button('生成选角候选').disabled).toBe(false)
await wrapper!.get('#identity-description').setValue('新的未保存草稿') await wrapper!.get('#identity-description').setValue('新的未保存草稿')
await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click') await wrapper!.findAll('.identity-subject-item')[1]!.trigger('click')
expect(wrapper!.text()).toContain('切换会丢弃草稿') expect(wrapper!.text()).toContain('切换会丢弃草稿')
@@ -539,11 +538,10 @@ describe('视觉风格与主体身份工作区', () => {
expect(wrapper!.text()).toContain('2 个形态') expect(wrapper!.text()).toContain('2 个形态')
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-1/identity'))).toBe(true) expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-1/identity'))).toBe(true)
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/identity/images'))).toBe(false) expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/identity/images'))).toBe(false)
expect(button('生成选角候选').disabled).toBe(true) expect(button('配置生成模型').disabled).toBe(false)
expect(button('AI 生成身份').disabled).toBe(false)
}) })
it('锁定阻止身份 AI 覆盖但不阻止人工编辑和生图,生图默认省略 referenceImageId', async () => { it('锁定阻止身份 AI 覆盖但不阻止人工编辑,并按步骤进入后续操作', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => { const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()]) if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture({ isLocked: true })) if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture({ isLocked: true }))
@@ -553,18 +551,9 @@ describe('视觉风格与主体身份工作区', () => {
}) })
vi.stubGlobal('fetch', fetcher) vi.stubGlobal('fetch', fetcher)
await mountAssets('identity') await mountAssets('identity')
expect(button('AI 重新生成身份').disabled).toBe(true) expect(wrapper!.text()).toContain('主体身份已完成')
expect(button('保存主体身份').disabled).toBe(false) expect(button('保存主体身份').disabled).toBe(false)
button('生成辅助身份图').click() expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
await flushPromises()
expect(button('确认生成身份图').disabled).toBe(true)
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
await flushPromises()
button('确认生成身份图').click()
await flushPromises()
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(post[0]).toBe('/api/subjects/subject-db-1/identity/images')
expect(JSON.parse(post[1]!.body as string)).toEqual({ viewType: 'front' })
}) })
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => { it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
@@ -578,8 +567,7 @@ describe('视觉风格与主体身份工作区', () => {
}) })
) )
await mountAssets('identity') await mountAssets('identity')
expect(button('AI 重新生成身份').disabled).toBe(true) expect(button('设置视觉风格').disabled).toBe(false)
expect(button('生成选角候选').disabled).toBe(true)
expect(button('保存主体身份').disabled).toBe(false) expect(button('保存主体身份').disabled).toBe(false)
}) })
@@ -759,7 +747,7 @@ describe('视觉风格与主体身份工作区', () => {
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')! const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
expect(put[0]).toBe('/api/subjects/subject-db-1/identity/images/casting-1/casting') expect(put[0]).toBe('/api/subjects/subject-db-1/identity/images/casting-1/casting')
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/anchor'))).toBe(false) expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/anchor'))).toBe(false)
expect(wrapper!.text()).toContain('选角状态:选角已完成') expect(wrapper!.text()).toContain('主体身份已完成')
expect(wrapper!.text()).toContain('演员身份已确认并锁定') expect(wrapper!.text()).toContain('演员身份已确认并锁定')
}) })
@@ -804,17 +792,19 @@ describe('视觉风格与主体身份工作区', () => {
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture()) if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
if (String(url).endsWith('/identity/images')) return jsonResponse([]) if (String(url).endsWith('/identity/images')) return jsonResponse([])
if (init?.method === 'POST') throw new Error('connection lost') if (init?.method === 'POST') throw new Error('connection lost')
if (String(url).endsWith('/identity')) return jsonResponse(null)
return jsonResponse(identityFixture()) return jsonResponse(identityFixture())
}) })
vi.stubGlobal('fetch', fetcher) vi.stubGlobal('fetch', fetcher)
const provided = await mountAssets('identity') const provided = await mountAssets('identity')
await confirmGeneration('AI 重新生成身份') button('生成身份文本').click()
await flushPromises()
expect(fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1) expect(fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1)
expect(getOperation(fixture.id).error).toContain('无法连接后端') expect(getOperation(fixture.id).error).toContain('无法连接后端')
provided.data.value!.project = { ...fixture, status: 'generating' } provided.data.value!.project = { ...fixture, status: 'generating' }
await flushPromises() await flushPromises()
expect(button('保存主体身份').disabled).toBe(true) expect(button('保存主体身份').disabled).toBe(true)
expect(button('生成选角候选').disabled).toBe(true) expect(button('生成身份文本').disabled).toBe(true)
}) })
}) })
@@ -894,7 +884,7 @@ describe('工作台页面交互', () => {
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1) expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
}) })
it('形态卡片铺满并打开原生图片预览,历史管理仍从独立按钮进入', async () => { it('瀑布流形态卡片完整展示并打开原生图片预览,历史管理仍从独立按钮进入', async () => {
const form = formFixture() const form = formFixture()
const fetcher = vi const fetcher = vi
.fn<typeof fetch>() .fn<typeof fetch>()
@@ -905,7 +895,7 @@ describe('工作台页面交互', () => {
await mountSubjectImages() await mountSubjectImages()
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click') await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
const card = wrapper!.get('.form-image-card') const card = wrapper!.get('.form-image-card')
expect(card.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: false }) expect(card.getComponent(NImage).props()).toMatchObject({ objectFit: 'contain', previewDisabled: false })
await card.get('img').trigger('click') await card.get('img').trigger('click')
await flushPromises() await flushPromises()
expect(document.querySelector('.n-image-preview')?.getAttribute('src')).toContain( expect(document.querySelector('.n-image-preview')?.getAttribute('src')).toContain(
@@ -1500,13 +1490,6 @@ describe('工作台页面交互', () => {
expect(provided.refresh).toHaveBeenCalledOnce() expect(provided.refresh).toHaveBeenCalledOnce()
}) })
it('窄屏隐藏进入拆解,选择器需压过工具栏文字按钮', () => {
const css = readAllStyles()
expect(css).toMatch(
/@media \(max-width: 800px\)[\s\S]*?\.tabs-toolbar-actions \.script-next-link\s*\{[^}]*display:\s*none/
)
})
it('切换剧本标签保留阅读容器和位置,执行记录按需展开', async () => { it('切换剧本标签保留阅读容器和位置,执行记录按需展开', async () => {
wrapper = mount(CreateDramaPage, { wrapper = mount(CreateDramaPage, {
attachTo: document.body, attachTo: document.body,
@@ -1514,7 +1497,8 @@ describe('工作台页面交互', () => {
}) })
const suffix = wrapper.get('.script-section .n-tabs-nav__suffix') const suffix = wrapper.get('.script-section .n-tabs-nav__suffix')
expect(suffix.findAll('button').map(item => item.attributes('aria-label'))).toEqual(['剧本更多操作']) expect(suffix.findAll('button').map(item => item.attributes('aria-label'))).toEqual(['剧本更多操作'])
expect(suffix.get('router-link-stub').attributes('to')).toBe(`/projects/${fixture.id}/breakdown`) expect(suffix.find('router-link-stub').exists()).toBe(false)
expect(suffix.text()).not.toContain('进入拆解')
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
reader.scrollTop = 120 reader.scrollTop = 120
expect(wrapper.find('.history-panel').exists()).toBe(false) expect(wrapper.find('.history-panel').exists()).toBe(false)
+25
View File
@@ -4,6 +4,8 @@ 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' import { subjectImagesApi } from '@/features/subject-images/api'
import { subjectIdentityApi } from '@/features/subject-identity/api'
import { productionApi } from '@/features/production/api'
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
@@ -43,6 +45,29 @@ describe('后端 API 契约', () => {
}) })
expect(fetcher.mock.calls[4]![1]!.body).toBeUndefined() expect(fetcher.mock.calls[4]![1]!.body).toBeUndefined()
}) })
it('新增预览、模块批处理和稳定母版接口使用正式数据库 ID', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
vi.stubGlobal('fetch', fetcher)
await subjectImagesApi.preview('form/id')
await subjectImagesApi.generatePrompts('project/id', { concurrency: 2, force: false, module: 'scene' })
await subjectImagesApi.generateProject('project/id', { concurrency: 1, force: false, module: 'prop' })
await subjectIdentityApi.generateProject('project/id', { concurrency: 2, force: false, module: 'scene' })
await subjectIdentityApi.generateStableAnchors('project/id', { module: 'prop', limit: 3, concurrency: 2 })
await productionApi.previewKeyframe('shot/id')
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
['/api/subject-forms/form%2Fid/images/preview', 'POST'],
['/api/projects/project%2Fid/subject-forms/generation-prompts', 'POST'],
['/api/projects/project%2Fid/subject-images/generate', 'POST'],
['/api/projects/project%2Fid/subject-identities/generate', 'POST'],
['/api/projects/project%2Fid/subject-identities/anchors/generate', 'POST'],
['/api/storyboard-shots/shot%2Fid/keyframe/preview', 'POST']
])
expect(JSON.parse(fetcher.mock.calls[4]![1]!.body as string)).toEqual({
module: 'prop',
limit: 3,
concurrency: 2
})
})
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)