refactor: 使用 Naive UI 重构后台布局与黑白双主题
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
node_modules
|
node_modules*
|
||||||
dist
|
dist
|
||||||
coverage
|
coverage
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ API_PROXY_TARGET=http://localhost:3412
|
|||||||
|
|
||||||
- Vite 8、Vue 3、TypeScript、Vue Router
|
- Vite 8、Vue 3、TypeScript、Vue Router
|
||||||
- Tailwind CSS 4,通过 `@tailwindcss/vite` 集成
|
- Tailwind CSS 4,通过 `@tailwindcss/vite` 集成
|
||||||
- Reka UI:Dialog、Tabs、Checkbox、Progress 等无样式基础组件
|
- Naive UI:布局、菜单、表格、选择器、输入框、按钮、弹窗、标签、折叠面板、进度和时间线
|
||||||
- Oxlint:脚本质量检查;ESLint:补充 Vue 模板语义
|
- Oxlint:脚本质量检查;ESLint:补充 Vue 模板语义
|
||||||
- Oxfmt(不是 `oxformat`):沿用后端四空格、单引号、无分号、无尾逗号的风格
|
- Oxfmt(不是 `oxformat`):沿用后端四空格、单引号、无分号、无尾逗号的风格
|
||||||
- Vitest + Vue Test Utils + happy-dom:API 契约、组件交互和异步生命周期测试
|
- Vitest + Vue Test Utils + happy-dom:API 契约、组件交互和异步生命周期测试
|
||||||
@@ -76,13 +76,28 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
| `src/features/subject-identity` | 稳定身份 API、正式主体目录、文本编辑、生图配置、身份图库和母版切换 |
|
| `src/features/subject-identity` | 稳定身份 API、正式主体目录、文本编辑、生图配置、身份图库和母版切换 |
|
||||||
| `src/features/production` | 视频提示词、镜头首帧、视频任务、就绪诊断、资产候选与批量回执 |
|
| `src/features/production` | 视频提示词、镜头首帧、视频任务、就绪诊断、资产候选与批量回执 |
|
||||||
| `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 |
|
| `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 |
|
||||||
| `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 |
|
| `src/components/ui` | Naive UI 公共封装、工作区滚动容器与图片展示 |
|
||||||
| `src/composables` | 具备取消和竞态保护的轮询 |
|
| `src/composables` | 具备取消和竞态保护的轮询、系统主题与偏好持久化 |
|
||||||
| `src/lib` | HTTP、错误格式、日期和导出工具 |
|
| `src/lib` | HTTP、错误格式、日期和导出工具 |
|
||||||
| `src/router` | 按 graph 拆分的懒加载路由 |
|
| `src/router` | 按 graph 拆分的懒加载路由 |
|
||||||
|
|
||||||
每个功能模块通过 `index.ts` 暴露公共入口。业务类型和行为函数均添加中文职责注释。不要把下一条 graph 的服务继续堆进 `App.vue`。
|
每个功能模块通过 `index.ts` 暴露公共入口。业务类型和行为函数均添加中文职责注释。不要把下一条 graph 的服务继续堆进 `App.vue`。
|
||||||
|
|
||||||
|
## 后台布局与主题
|
||||||
|
|
||||||
|
- 外层应用固定为视口高度;侧栏、顶栏、项目标题和工作流导航不会随正文滚动。
|
||||||
|
- 项目列表采用固定表头表格;剧本、分镜、身份与生产页的目录和详情独立滚动,其他工作区只滚动内容区。
|
||||||
|
- 生成配置与批量操作放在可折叠区域,回执到达后自动展开;查询错误始终可见,不隐藏在折叠内容中。
|
||||||
|
- 右上角提供浅色、暗黑、跟随系统三种模式,默认跟随系统,选择保存在本机浏览器。存储被禁用时仍可在当前会话切换。
|
||||||
|
- 以黑白灰为主,仅错误、警告、成功等语义状态保留辅助色。Naive UI 和业务内容共用主题;加载前应用偏好,减少主题闪烁。
|
||||||
|
- 窄屏默认收起侧栏,目录与正文改用紧凑布局;执行记录仍可查看。长弹窗在自身内部滚动。
|
||||||
|
|
||||||
|
布局样式集中在 `src/admin.css`,业务排版保留在 `src/styles.css`。新增页面应使用 `WorkspacePage`,避免通过 `body` 或 `window.scrollTo` 管理滚动。
|
||||||
|
|
||||||
|
本次 UI 重构保留前端 `dev@5551653` 的业务更新,包括过期形态图、主首帧和主视频的提示与更新入口。首帧默认只补当前剧集;存在过期主首帧时优先更新全项目过期项。非覆盖模式下的过期主视频重建完成后,会接替旧主视频;普通新增候选不自动替换主资产。
|
||||||
|
|
||||||
|
本地验收建议使用 1920×1080、1280×720 和窄屏视口:打开长剧本、长项目标题和多镜头项目,确认顶栏不移动、目录与正文能分别滚到底;切换暗黑模式后检查弹窗、下拉菜单、图片失败占位,刷新后主题应保持。
|
||||||
|
|
||||||
## 接口基线
|
## 接口基线
|
||||||
|
|
||||||
对齐后端 `qianlanse/short-drama-agent` 的 `dev`:
|
对齐后端 `qianlanse/short-drama-agent` 的 `dev`:
|
||||||
|
|||||||
+14
-1
@@ -3,7 +3,20 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#242722" />
|
<meta name="theme-color" content="#202020" />
|
||||||
|
<script>
|
||||||
|
// 在应用挂载前恢复主题,避免深色模式出现白色闪屏。
|
||||||
|
try {
|
||||||
|
const preference = localStorage.getItem('drama-studio-theme')
|
||||||
|
const dark =
|
||||||
|
preference === 'dark' ||
|
||||||
|
((!preference || preference === 'system') && matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||||
|
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
|
||||||
|
} catch {
|
||||||
|
/* 存储不可用时由应用主题配置接管。 */
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<meta name="description" content="短剧创作工作台:管理剧本生成、主体拆解与分镜规划。" />
|
<meta name="description" content="短剧创作工作台:管理剧本生成、主体拆解与分镜规划。" />
|
||||||
<title>短剧工作台</title>
|
<title>短剧工作台</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+15
-15
@@ -17,30 +17,30 @@
|
|||||||
"prepare": "lefthook install"
|
"prepare": "lefthook install"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@lucide/vue": "1.34.0",
|
||||||
|
"naive-ui": "2.45.3",
|
||||||
"vue": "3.5.42",
|
"vue": "3.5.42",
|
||||||
"vue-router": "^4.6.0",
|
"vue-router": "^4.6.0"
|
||||||
"reka-ui": "2.10.4",
|
|
||||||
"@lucide/vue": "1.34.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.0",
|
"@commitlint/cli": "^21.2.2",
|
||||||
"vite": "8.2.2",
|
"@commitlint/config-conventional": "^21.2.2",
|
||||||
"typescript": "^6.0.3",
|
|
||||||
"vue-tsc": "^3.2.0",
|
|
||||||
"tailwindcss": "^4.1.0",
|
|
||||||
"@tailwindcss/vite": "^4.1.0",
|
"@tailwindcss/vite": "^4.1.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"oxlint": "^1.78.0",
|
"@typescript-eslint/parser": "^8.67.0",
|
||||||
"oxfmt": "^0.60.0",
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
"eslint": "^10.0.0",
|
"eslint": "^10.0.0",
|
||||||
"eslint-plugin-vue": "^10.0.0",
|
"eslint-plugin-vue": "^10.0.0",
|
||||||
"@typescript-eslint/parser": "^8.67.0",
|
|
||||||
"vitest": "^4.0.15",
|
|
||||||
"@vue/test-utils": "^2.4.6",
|
|
||||||
"happy-dom": "^20.0.0",
|
"happy-dom": "^20.0.0",
|
||||||
"lefthook": "^2.1.10",
|
"lefthook": "^2.1.10",
|
||||||
"@commitlint/cli": "^21.2.2",
|
"oxfmt": "^0.60.0",
|
||||||
"@commitlint/config-conventional": "^21.2.2"
|
"oxlint": "^1.78.0",
|
||||||
|
"tailwindcss": "^4.1.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "8.2.2",
|
||||||
|
"vitest": "^4.0.15",
|
||||||
|
"vue-tsc": "^3.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.18.0",
|
"node": ">=22.18.0",
|
||||||
|
|||||||
Generated
+170
-161
@@ -11,9 +11,9 @@ importers:
|
|||||||
'@lucide/vue':
|
'@lucide/vue':
|
||||||
specifier: 1.34.0
|
specifier: 1.34.0
|
||||||
version: 1.34.0(vue@3.5.42(typescript@6.0.3))
|
version: 1.34.0(vue@3.5.42(typescript@6.0.3))
|
||||||
reka-ui:
|
naive-ui:
|
||||||
specifier: 2.10.4
|
specifier: 2.45.3
|
||||||
version: 2.10.4(vue@3.5.42(typescript@6.0.3))
|
version: 2.45.3(vue@3.5.42(typescript@6.0.3))
|
||||||
vue:
|
vue:
|
||||||
specifier: 3.5.42
|
specifier: 3.5.42
|
||||||
version: 3.5.42(typescript@6.0.3)
|
version: 3.5.42(typescript@6.0.3)
|
||||||
@@ -184,6 +184,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==}
|
resolution: {integrity: sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
|
'@css-render/plugin-bem@0.15.14':
|
||||||
|
resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==}
|
||||||
|
peerDependencies:
|
||||||
|
css-render: ~0.15.14
|
||||||
|
|
||||||
|
'@css-render/vue3-ssr@0.15.14':
|
||||||
|
resolution: {integrity: sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.11
|
||||||
|
|
||||||
|
'@emotion/hash@0.8.0':
|
||||||
|
resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
|
||||||
|
|
||||||
'@eslint-community/eslint-utils@4.10.1':
|
'@eslint-community/eslint-utils@4.10.1':
|
||||||
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
|
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
@@ -214,18 +227,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
|
resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||||
|
|
||||||
'@floating-ui/core@1.8.0':
|
|
||||||
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
|
||||||
|
|
||||||
'@floating-ui/dom@1.8.0':
|
|
||||||
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
|
|
||||||
|
|
||||||
'@floating-ui/utils@0.2.12':
|
|
||||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
|
||||||
|
|
||||||
'@floating-ui/vue@1.1.11':
|
|
||||||
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
|
|
||||||
|
|
||||||
'@humanfs/core@0.19.2':
|
'@humanfs/core@0.19.2':
|
||||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -246,12 +247,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
|
|
||||||
'@internationalized/date@3.12.3':
|
|
||||||
resolution: {integrity: sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==}
|
|
||||||
|
|
||||||
'@internationalized/number@3.6.7':
|
|
||||||
resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==}
|
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -272,6 +267,9 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@juggle/resize-observer@3.4.0':
|
||||||
|
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
|
||||||
|
|
||||||
'@lucide/vue@1.34.0':
|
'@lucide/vue@1.34.0':
|
||||||
resolution: {integrity: sha512-eB48kx1oe9O6mJAg0gFWgv66y4rhYJYuhfWF8wIz/YBFPDDHWHI7+kRJpkvz/8kePY6ddbwxXIGfP8E54wlGhA==}
|
resolution: {integrity: sha512-eB48kx1oe9O6mJAg0gFWgv66y4rhYJYuhfWF8wIz/YBFPDDHWHI7+kRJpkvz/8kePY6ddbwxXIGfP8E54wlGhA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -641,9 +639,6 @@ packages:
|
|||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
'@swc/helpers@0.5.23':
|
|
||||||
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
|
||||||
|
|
||||||
'@tailwindcss/node@4.3.3':
|
'@tailwindcss/node@4.3.3':
|
||||||
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
|
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
|
||||||
|
|
||||||
@@ -738,14 +733,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||||
|
|
||||||
'@tanstack/virtual-core@3.17.8':
|
|
||||||
resolution: {integrity: sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==}
|
|
||||||
|
|
||||||
'@tanstack/vue-virtual@3.13.36':
|
|
||||||
resolution: {integrity: sha512-gKpExv4RbB9luVG+SucTXoqPZv/gzu/Yvz6BNO+8kpNxJ2x+I/ulryzl5W9BRciahZGp5Tls3Dp5XP1ztVGbMw==}
|
|
||||||
peerDependencies:
|
|
||||||
vue: ^2.7.0 || ^3.0.0
|
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
@@ -761,12 +748,15 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/lodash-es@4.17.12':
|
||||||
|
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
|
||||||
|
|
||||||
|
'@types/lodash@4.17.25':
|
||||||
|
resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
|
||||||
|
|
||||||
'@types/node@24.13.3':
|
'@types/node@24.13.3':
|
||||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||||
|
|
||||||
'@types/web-bluetooth@0.0.21':
|
|
||||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
|
||||||
|
|
||||||
'@types/whatwg-mimetype@3.0.2':
|
'@types/whatwg-mimetype@3.0.2':
|
||||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||||
|
|
||||||
@@ -912,19 +902,6 @@ packages:
|
|||||||
'@vue/server-renderer':
|
'@vue/server-renderer':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vueuse/core@14.4.0':
|
|
||||||
resolution: {integrity: sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==}
|
|
||||||
peerDependencies:
|
|
||||||
vue: ^3.5.0
|
|
||||||
|
|
||||||
'@vueuse/metadata@14.4.0':
|
|
||||||
resolution: {integrity: sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==}
|
|
||||||
|
|
||||||
'@vueuse/shared@14.4.0':
|
|
||||||
resolution: {integrity: sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==}
|
|
||||||
peerDependencies:
|
|
||||||
vue: ^3.5.0
|
|
||||||
|
|
||||||
abbrev@2.0.0:
|
abbrev@2.0.0:
|
||||||
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
|
||||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||||
@@ -971,14 +948,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==}
|
resolution: {integrity: sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==}
|
||||||
engines: {node: '>=22'}
|
engines: {node: '>=22'}
|
||||||
|
|
||||||
aria-hidden@1.2.6:
|
|
||||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
|
||||||
engines: {node: '>=10'}
|
|
||||||
|
|
||||||
assertion-error@2.0.1:
|
assertion-error@2.0.1:
|
||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
async-validator@4.2.5:
|
||||||
|
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
@@ -1063,14 +1039,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
css-render@0.15.14:
|
||||||
|
resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==}
|
||||||
|
|
||||||
cssesc@3.0.0:
|
cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
csstype@3.0.11:
|
||||||
|
resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==}
|
||||||
|
|
||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
date-fns-tz@3.2.0:
|
||||||
|
resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==}
|
||||||
|
peerDependencies:
|
||||||
|
date-fns: ^3.0.0 || ^4.0.0
|
||||||
|
|
||||||
|
date-fns@4.4.0:
|
||||||
|
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1083,9 +1073,6 @@ packages:
|
|||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
defu@6.1.7:
|
|
||||||
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1198,6 +1185,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
evtd@0.2.4:
|
||||||
|
resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==}
|
||||||
|
|
||||||
expect-type@1.4.0:
|
expect-type@1.4.0:
|
||||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1275,6 +1265,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==}
|
resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
highlight.js@11.12.0:
|
||||||
|
resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -1573,6 +1567,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
lodash-es@4.18.1:
|
||||||
|
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||||
|
|
||||||
|
lodash@4.18.1:
|
||||||
|
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||||
|
|
||||||
lru-cache@10.4.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
@@ -1597,6 +1597,12 @@ packages:
|
|||||||
muggle-string@0.4.1:
|
muggle-string@0.4.1:
|
||||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||||
|
|
||||||
|
naive-ui@2.45.3:
|
||||||
|
resolution: {integrity: sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.0
|
||||||
|
|
||||||
nanoid@3.3.18:
|
nanoid@3.3.18:
|
||||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -1617,9 +1623,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
ohash@2.0.12:
|
|
||||||
resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==}
|
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -1713,11 +1716,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
reka-ui@2.10.4:
|
|
||||||
resolution: {integrity: sha512-kbS5GAbkHkYj0EVKAg5ZPEPndhBjeUFSa1Aq5m5ftQwyHnB1v5tw8X6fcwfKH/ry3StPXblMWM1DW82MVS71cw==}
|
|
||||||
peerDependencies:
|
|
||||||
vue: '>= 3.4.0'
|
|
||||||
|
|
||||||
require-from-string@2.0.2:
|
require-from-string@2.0.2:
|
||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1735,6 +1733,9 @@ packages:
|
|||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
seemly@0.3.10:
|
||||||
|
resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==}
|
||||||
|
|
||||||
semver@7.8.5:
|
semver@7.8.5:
|
||||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1815,15 +1816,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
|
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
treemate@0.3.11:
|
||||||
|
resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==}
|
||||||
|
|
||||||
ts-api-utils@2.5.0:
|
ts-api-utils@2.5.0:
|
||||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||||
engines: {node: '>=18.12'}
|
engines: {node: '>=18.12'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4'
|
typescript: '>=4.8.4'
|
||||||
|
|
||||||
tslib@2.8.1:
|
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -1842,6 +1843,11 @@ packages:
|
|||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
vdirs@0.1.8:
|
||||||
|
resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.11
|
||||||
|
|
||||||
vite@8.2.2:
|
vite@8.2.2:
|
||||||
resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==}
|
resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1926,23 +1932,17 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vooks@0.2.12:
|
||||||
|
resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.0
|
||||||
|
|
||||||
vscode-uri@3.2.0:
|
vscode-uri@3.2.0:
|
||||||
resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
|
resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
|
||||||
|
|
||||||
vue-component-type-helpers@3.3.11:
|
vue-component-type-helpers@3.3.11:
|
||||||
resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==}
|
resolution: {integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==}
|
||||||
|
|
||||||
vue-demi@0.14.10:
|
|
||||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
hasBin: true
|
|
||||||
peerDependencies:
|
|
||||||
'@vue/composition-api': ^1.0.0-rc.1
|
|
||||||
vue: ^3.0.0-0 || ^2.6.0
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@vue/composition-api':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
vue-eslint-parser@10.4.1:
|
vue-eslint-parser@10.4.1:
|
||||||
resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==}
|
resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1968,6 +1968,11 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vueuc@0.4.66:
|
||||||
|
resolution: {integrity: sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.0.11
|
||||||
|
|
||||||
whatwg-mimetype@3.0.0:
|
whatwg-mimetype@3.0.0:
|
||||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2170,6 +2175,16 @@ snapshots:
|
|||||||
|
|
||||||
'@conventional-changelog/template@1.4.0': {}
|
'@conventional-changelog/template@1.4.0': {}
|
||||||
|
|
||||||
|
'@css-render/plugin-bem@0.15.14(css-render@0.15.14)':
|
||||||
|
dependencies:
|
||||||
|
css-render: 0.15.14
|
||||||
|
|
||||||
|
'@css-render/vue3-ssr@0.15.14(vue@3.5.42(typescript@6.0.3))':
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.42(typescript@6.0.3)
|
||||||
|
|
||||||
|
'@emotion/hash@0.8.0': {}
|
||||||
|
|
||||||
'@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(jiti@2.7.0))':
|
'@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(jiti@2.7.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint: 10.9.1(jiti@2.7.0)
|
eslint: 10.9.1(jiti@2.7.0)
|
||||||
@@ -2200,26 +2215,6 @@ snapshots:
|
|||||||
'@eslint/core': 1.2.1
|
'@eslint/core': 1.2.1
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
'@floating-ui/core@1.8.0':
|
|
||||||
dependencies:
|
|
||||||
'@floating-ui/utils': 0.2.12
|
|
||||||
|
|
||||||
'@floating-ui/dom@1.8.0':
|
|
||||||
dependencies:
|
|
||||||
'@floating-ui/core': 1.8.0
|
|
||||||
'@floating-ui/utils': 0.2.12
|
|
||||||
|
|
||||||
'@floating-ui/utils@0.2.12': {}
|
|
||||||
|
|
||||||
'@floating-ui/vue@1.1.11(vue@3.5.42(typescript@6.0.3))':
|
|
||||||
dependencies:
|
|
||||||
'@floating-ui/dom': 1.8.0
|
|
||||||
'@floating-ui/utils': 0.2.12
|
|
||||||
vue-demi: 0.14.10(vue@3.5.42(typescript@6.0.3))
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@vue/composition-api'
|
|
||||||
- vue
|
|
||||||
|
|
||||||
'@humanfs/core@0.19.2':
|
'@humanfs/core@0.19.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@humanfs/types': 0.15.0
|
'@humanfs/types': 0.15.0
|
||||||
@@ -2236,14 +2231,6 @@ snapshots:
|
|||||||
|
|
||||||
'@humanwhocodes/retry@0.4.3': {}
|
'@humanwhocodes/retry@0.4.3': {}
|
||||||
|
|
||||||
'@internationalized/date@3.12.3':
|
|
||||||
dependencies:
|
|
||||||
'@swc/helpers': 0.5.23
|
|
||||||
|
|
||||||
'@internationalized/number@3.6.7':
|
|
||||||
dependencies:
|
|
||||||
'@swc/helpers': 0.5.23
|
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
@@ -2272,6 +2259,8 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@juggle/resize-observer@3.4.0': {}
|
||||||
|
|
||||||
'@lucide/vue@1.34.0(vue@3.5.42(typescript@6.0.3))':
|
'@lucide/vue@1.34.0(vue@3.5.42(typescript@6.0.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.42(typescript@6.0.3)
|
vue: 3.5.42(typescript@6.0.3)
|
||||||
@@ -2452,10 +2441,6 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.23':
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@tailwindcss/node@4.3.3':
|
'@tailwindcss/node@4.3.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
@@ -2524,13 +2509,6 @@ snapshots:
|
|||||||
tailwindcss: 4.3.3
|
tailwindcss: 4.3.3
|
||||||
vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)
|
vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)
|
||||||
|
|
||||||
'@tanstack/virtual-core@3.17.8': {}
|
|
||||||
|
|
||||||
'@tanstack/vue-virtual@3.13.36(vue@3.5.42(typescript@6.0.3))':
|
|
||||||
dependencies:
|
|
||||||
'@tanstack/virtual-core': 3.17.8
|
|
||||||
vue: 3.5.42(typescript@6.0.3)
|
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
@@ -2544,12 +2522,16 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/lodash-es@4.17.12':
|
||||||
|
dependencies:
|
||||||
|
'@types/lodash': 4.17.25
|
||||||
|
|
||||||
|
'@types/lodash@4.17.25': {}
|
||||||
|
|
||||||
'@types/node@24.13.3':
|
'@types/node@24.13.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
'@types/web-bluetooth@0.0.21': {}
|
|
||||||
|
|
||||||
'@types/whatwg-mimetype@3.0.2': {}
|
'@types/whatwg-mimetype@3.0.2': {}
|
||||||
|
|
||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
@@ -2759,19 +2741,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@vue/server-renderer': 3.5.42
|
'@vue/server-renderer': 3.5.42
|
||||||
|
|
||||||
'@vueuse/core@14.4.0(vue@3.5.42(typescript@6.0.3))':
|
|
||||||
dependencies:
|
|
||||||
'@types/web-bluetooth': 0.0.21
|
|
||||||
'@vueuse/metadata': 14.4.0
|
|
||||||
'@vueuse/shared': 14.4.0(vue@3.5.42(typescript@6.0.3))
|
|
||||||
vue: 3.5.42(typescript@6.0.3)
|
|
||||||
|
|
||||||
'@vueuse/metadata@14.4.0': {}
|
|
||||||
|
|
||||||
'@vueuse/shared@14.4.0(vue@3.5.42(typescript@6.0.3))':
|
|
||||||
dependencies:
|
|
||||||
vue: 3.5.42(typescript@6.0.3)
|
|
||||||
|
|
||||||
abbrev@2.0.0: {}
|
abbrev@2.0.0: {}
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.18.0):
|
acorn-jsx@5.3.2(acorn@8.18.0):
|
||||||
@@ -2810,12 +2779,10 @@ snapshots:
|
|||||||
|
|
||||||
argue-cli@3.1.0: {}
|
argue-cli@3.1.0: {}
|
||||||
|
|
||||||
aria-hidden@1.2.6:
|
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
async-validator@4.2.5: {}
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
balanced-match@4.0.4: {}
|
balanced-match@4.0.4: {}
|
||||||
@@ -2894,18 +2861,29 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
css-render@0.15.14:
|
||||||
|
dependencies:
|
||||||
|
'@emotion/hash': 0.8.0
|
||||||
|
csstype: 3.0.11
|
||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
|
csstype@3.0.11: {}
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
date-fns-tz@3.2.0(date-fns@4.4.0):
|
||||||
|
dependencies:
|
||||||
|
date-fns: 4.4.0
|
||||||
|
|
||||||
|
date-fns@4.4.0: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
defu@6.1.7: {}
|
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
@@ -3029,6 +3007,8 @@ snapshots:
|
|||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
|
evtd@0.2.4: {}
|
||||||
|
|
||||||
expect-type@1.4.0: {}
|
expect-type@1.4.0: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
@@ -3103,6 +3083,8 @@ snapshots:
|
|||||||
- bufferutil
|
- bufferutil
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
|
highlight.js@11.12.0: {}
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
@@ -3322,6 +3304,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
|
lodash-es@4.18.1: {}
|
||||||
|
|
||||||
|
lodash@4.18.1: {}
|
||||||
|
|
||||||
lru-cache@10.4.3: {}
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
@@ -3342,6 +3328,28 @@ snapshots:
|
|||||||
|
|
||||||
muggle-string@0.4.1: {}
|
muggle-string@0.4.1: {}
|
||||||
|
|
||||||
|
naive-ui@2.45.3(vue@3.5.42(typescript@6.0.3)):
|
||||||
|
dependencies:
|
||||||
|
'@css-render/plugin-bem': 0.15.14(css-render@0.15.14)
|
||||||
|
'@css-render/vue3-ssr': 0.15.14(vue@3.5.42(typescript@6.0.3))
|
||||||
|
'@types/lodash': 4.17.25
|
||||||
|
'@types/lodash-es': 4.17.12
|
||||||
|
async-validator: 4.2.5
|
||||||
|
css-render: 0.15.14
|
||||||
|
csstype: 3.2.3
|
||||||
|
date-fns: 4.4.0
|
||||||
|
date-fns-tz: 3.2.0(date-fns@4.4.0)
|
||||||
|
evtd: 0.2.4
|
||||||
|
highlight.js: 11.12.0
|
||||||
|
lodash: 4.18.1
|
||||||
|
lodash-es: 4.18.1
|
||||||
|
seemly: 0.3.10
|
||||||
|
treemate: 0.3.11
|
||||||
|
vdirs: 0.1.8(vue@3.5.42(typescript@6.0.3))
|
||||||
|
vooks: 0.2.12(vue@3.5.42(typescript@6.0.3))
|
||||||
|
vue: 3.5.42(typescript@6.0.3)
|
||||||
|
vueuc: 0.4.66(vue@3.5.42(typescript@6.0.3))
|
||||||
|
|
||||||
nanoid@3.3.18: {}
|
nanoid@3.3.18: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
@@ -3356,8 +3364,6 @@ snapshots:
|
|||||||
|
|
||||||
obug@2.1.4: {}
|
obug@2.1.4: {}
|
||||||
|
|
||||||
ohash@2.0.12: {}
|
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
@@ -3468,22 +3474,6 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
reka-ui@2.10.4(vue@3.5.42(typescript@6.0.3)):
|
|
||||||
dependencies:
|
|
||||||
'@floating-ui/dom': 1.8.0
|
|
||||||
'@floating-ui/vue': 1.1.11(vue@3.5.42(typescript@6.0.3))
|
|
||||||
'@internationalized/date': 3.12.3
|
|
||||||
'@internationalized/number': 3.6.7
|
|
||||||
'@tanstack/vue-virtual': 3.13.36(vue@3.5.42(typescript@6.0.3))
|
|
||||||
'@vueuse/core': 14.4.0(vue@3.5.42(typescript@6.0.3))
|
|
||||||
'@vueuse/shared': 14.4.0(vue@3.5.42(typescript@6.0.3))
|
|
||||||
aria-hidden: 1.2.6
|
|
||||||
defu: 6.1.7
|
|
||||||
ohash: 2.0.12
|
|
||||||
vue: 3.5.42(typescript@6.0.3)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@vue/composition-api'
|
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
@@ -3511,6 +3501,8 @@ snapshots:
|
|||||||
'@rolldown/binding-win32-arm64-msvc': 1.2.5
|
'@rolldown/binding-win32-arm64-msvc': 1.2.5
|
||||||
'@rolldown/binding-win32-x64-msvc': 1.2.5
|
'@rolldown/binding-win32-x64-msvc': 1.2.5
|
||||||
|
|
||||||
|
seemly@0.3.10: {}
|
||||||
|
|
||||||
semver@7.8.5: {}
|
semver@7.8.5: {}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
@@ -3577,12 +3569,12 @@ snapshots:
|
|||||||
|
|
||||||
tinyrainbow@3.1.1: {}
|
tinyrainbow@3.1.1: {}
|
||||||
|
|
||||||
|
treemate@0.3.11: {}
|
||||||
|
|
||||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -3597,6 +3589,11 @@ snapshots:
|
|||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
vdirs@0.1.8(vue@3.5.42(typescript@6.0.3)):
|
||||||
|
dependencies:
|
||||||
|
evtd: 0.2.4
|
||||||
|
vue: 3.5.42(typescript@6.0.3)
|
||||||
|
|
||||||
vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0):
|
vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.33.0
|
lightningcss: 1.33.0
|
||||||
@@ -3637,14 +3634,15 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
|
|
||||||
|
vooks@0.2.12(vue@3.5.42(typescript@6.0.3)):
|
||||||
|
dependencies:
|
||||||
|
evtd: 0.2.4
|
||||||
|
vue: 3.5.42(typescript@6.0.3)
|
||||||
|
|
||||||
vscode-uri@3.2.0: {}
|
vscode-uri@3.2.0: {}
|
||||||
|
|
||||||
vue-component-type-helpers@3.3.11: {}
|
vue-component-type-helpers@3.3.11: {}
|
||||||
|
|
||||||
vue-demi@0.14.10(vue@3.5.42(typescript@6.0.3)):
|
|
||||||
dependencies:
|
|
||||||
vue: 3.5.42(typescript@6.0.3)
|
|
||||||
|
|
||||||
vue-eslint-parser@10.4.1(eslint@10.9.1(jiti@2.7.0)):
|
vue-eslint-parser@10.4.1(eslint@10.9.1(jiti@2.7.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -3678,6 +3676,17 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
|
vueuc@0.4.66(vue@3.5.42(typescript@6.0.3)):
|
||||||
|
dependencies:
|
||||||
|
'@css-render/vue3-ssr': 0.15.14(vue@3.5.42(typescript@6.0.3))
|
||||||
|
'@juggle/resize-observer': 3.4.0
|
||||||
|
css-render: 0.15.14
|
||||||
|
evtd: 0.2.4
|
||||||
|
seemly: 0.3.10
|
||||||
|
vdirs: 0.1.8(vue@3.5.42(typescript@6.0.3))
|
||||||
|
vooks: 0.2.12(vue@3.5.42(typescript@6.0.3))
|
||||||
|
vue: 3.5.42(typescript@6.0.3)
|
||||||
|
|
||||||
whatwg-mimetype@3.0.0: {}
|
whatwg-mimetype@3.0.0: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
|
|||||||
+134
-114
@@ -1,6 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, h, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NConfigProvider,
|
||||||
|
NGlobalStyle,
|
||||||
|
NLayout,
|
||||||
|
NLayoutHeader,
|
||||||
|
NLayoutSider,
|
||||||
|
NMenu,
|
||||||
|
NSelect,
|
||||||
|
NTooltip,
|
||||||
|
zhCN,
|
||||||
|
dateZhCN,
|
||||||
|
type MenuOption
|
||||||
|
} from 'naive-ui'
|
||||||
import {
|
import {
|
||||||
Clapperboard,
|
Clapperboard,
|
||||||
Camera,
|
Camera,
|
||||||
@@ -14,127 +28,133 @@ import {
|
|||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
Settings2
|
Settings2
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { DialogTrigger } from 'reka-ui'
|
|
||||||
import { AppDialog } from './components/ui'
|
import { AppDialog } from './components/ui'
|
||||||
|
import { useTheme } from './composables/useTheme'
|
||||||
|
|
||||||
/** 应用框架只承载导航与连接说明,不混入 graph 业务。 */
|
/** 应用外壳固定在视口内,业务数据仍由各工作区自行加载。 */
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(window.matchMedia('(max-width: 800px)').matches)
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
const projectId = computed(() => (route.params.projectId ? String(route.params.projectId) : ''))
|
const { preference, theme, overrides } = useTheme()
|
||||||
|
const projectId = computed(() => String(route.params.projectId || ''))
|
||||||
const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
|
const themeOptions = [
|
||||||
|
{ label: '浅色模式', value: 'light' },
|
||||||
|
{ label: '暗黑模式', value: 'dark' },
|
||||||
|
{ label: '跟随系统', value: 'system' }
|
||||||
|
]
|
||||||
|
const workflowItems = [
|
||||||
|
['create-drama', '剧本创作', FileText],
|
||||||
|
['breakdown', '剧本拆解', Layers],
|
||||||
|
['visual-style', '视觉风格', Palette],
|
||||||
|
['subject-identity', '主体身份', Fingerprint],
|
||||||
|
['subject-images', '形态图片', Images],
|
||||||
|
['storyboard', '分镜设计', Camera],
|
||||||
|
['production', '镜头生产', Clapperboard]
|
||||||
|
] as const
|
||||||
|
/** 菜单链接使用 RouterLink,保留新标签打开和浏览器导航行为。 */
|
||||||
|
const menuOptions = computed<MenuOption[]>(() => [
|
||||||
|
{
|
||||||
|
key: '/projects',
|
||||||
|
label: () => h(RouterLink, { to: '/projects' }, () => '我的剧本'),
|
||||||
|
icon: () => h(FolderOpen, { size: 18 })
|
||||||
|
},
|
||||||
|
...(projectId.value
|
||||||
|
? [
|
||||||
|
{ type: 'divider' as const, key: 'divider' },
|
||||||
|
...workflowItems.map(([path, label, icon]) => ({
|
||||||
|
key: '/projects/' + projectId.value + '/' + path,
|
||||||
|
label: () => h(RouterLink, { to: '/projects/' + projectId.value + '/' + path }, () => label),
|
||||||
|
icon: () => h(icon, { size: 18 })
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-shell" :class="{ 'sidebar-collapsed': collapsed }">
|
<NConfigProvider
|
||||||
|
:theme="theme"
|
||||||
|
:theme-overrides="overrides"
|
||||||
|
:locale="zhCN"
|
||||||
|
:date-locale="dateZhCN"
|
||||||
|
class="app-provider"
|
||||||
|
>
|
||||||
|
<NGlobalStyle />
|
||||||
<a href="#main-content" class="skip-link">跳到主要内容</a>
|
<a href="#main-content" class="skip-link">跳到主要内容</a>
|
||||||
<aside class="sidebar">
|
<NLayout has-sider class="admin-layout">
|
||||||
<RouterLink to="/projects" class="brand"
|
<NLayoutSider
|
||||||
><span class="brand-mark"><Clapperboard :size="21" :stroke-width="1.5" /></span
|
bordered
|
||||||
><span class="sidebar-label"
|
collapse-mode="width"
|
||||||
><strong>短剧工作台</strong><small>SHORT DRAMA STUDIO</small></span
|
:collapsed="collapsed"
|
||||||
></RouterLink
|
:width="208"
|
||||||
|
:collapsed-width="64"
|
||||||
|
:native-scrollbar="false"
|
||||||
|
class="admin-sider"
|
||||||
>
|
>
|
||||||
<div class="sidebar-section-label sidebar-label">工作空间</div>
|
<RouterLink to="/projects" class="admin-brand" aria-label="短剧工作台首页">
|
||||||
<nav aria-label="主导航">
|
<Clapperboard :size="24" /><span v-if="!collapsed"
|
||||||
<RouterLink to="/projects" class="side-link" :class="{ selected: !projectId }" title="我的剧本"
|
>短剧工作台<small>SHORT DRAMA STUDIO</small></span
|
||||||
><FolderOpen :size="18" /><span class="sidebar-label">我的剧本</span></RouterLink
|
|
||||||
>
|
|
||||||
</nav>
|
|
||||||
<template v-if="projectId"
|
|
||||||
><div class="sidebar-section-label sidebar-label mt-8">当前项目</div>
|
|
||||||
<nav aria-label="工作流导航">
|
|
||||||
<RouterLink
|
|
||||||
:to="`/projects/${projectId}/create-drama`"
|
|
||||||
class="side-link"
|
|
||||||
active-class="selected"
|
|
||||||
title="剧本创作"
|
|
||||||
><FileText :size="18" /><span class="sidebar-label">剧本创作</span
|
|
||||||
><span class="sidebar-label ml-auto text-[10px] text-stone-500">01</span></RouterLink
|
|
||||||
><RouterLink
|
|
||||||
:to="`/projects/${projectId}/breakdown`"
|
|
||||||
class="side-link"
|
|
||||||
active-class="selected"
|
|
||||||
title="剧本拆解"
|
|
||||||
><Layers :size="18" /><span class="sidebar-label">剧本拆解</span
|
|
||||||
><span class="sidebar-label ml-auto text-[10px] text-stone-500">02</span></RouterLink
|
|
||||||
><RouterLink
|
|
||||||
:to="`/projects/${projectId}/visual-style`"
|
|
||||||
class="side-link"
|
|
||||||
active-class="selected"
|
|
||||||
title="视觉风格"
|
|
||||||
><Palette :size="18" /><span class="sidebar-label">视觉风格</span
|
|
||||||
><span class="sidebar-label ml-auto text-[10px] text-stone-500">03</span></RouterLink
|
|
||||||
><RouterLink
|
|
||||||
:to="`/projects/${projectId}/subject-identity`"
|
|
||||||
class="side-link"
|
|
||||||
active-class="selected"
|
|
||||||
title="主体身份"
|
|
||||||
><Fingerprint :size="18" /><span class="sidebar-label">主体身份</span
|
|
||||||
><span class="sidebar-label ml-auto text-[10px] text-stone-500">04</span></RouterLink
|
|
||||||
><RouterLink
|
|
||||||
:to="`/projects/${projectId}/subject-images`"
|
|
||||||
class="side-link"
|
|
||||||
active-class="selected"
|
|
||||||
title="形态图片"
|
|
||||||
><Images :size="18" /><span class="sidebar-label">形态图片</span
|
|
||||||
><span class="sidebar-label ml-auto text-[10px] text-stone-500">05</span></RouterLink
|
|
||||||
><RouterLink
|
|
||||||
:to="`/projects/${projectId}/storyboard`"
|
|
||||||
class="side-link"
|
|
||||||
active-class="selected"
|
|
||||||
title="分镜设计"
|
|
||||||
><Camera :size="18" /><span class="sidebar-label">分镜设计</span
|
|
||||||
><span class="sidebar-label ml-auto text-[10px] text-stone-500">06</span></RouterLink
|
|
||||||
>
|
>
|
||||||
</nav></template
|
</RouterLink>
|
||||||
>
|
<NMenu
|
||||||
<div class="sidebar-footer">
|
:value="route.path"
|
||||||
<AppDialog
|
:options="menuOptions"
|
||||||
v-model:open="settingsOpen"
|
:collapsed="collapsed"
|
||||||
title="后端连接"
|
:collapsed-width="64"
|
||||||
description="页面直接使用现有后端接口,所有生成操作都会真实提交。"
|
:collapsed-icon-size="19"
|
||||||
><template #trigger
|
/>
|
||||||
><DialogTrigger class="side-link w-full" title="后端连接"
|
</NLayoutSider>
|
||||||
><Settings2 :size="17" /><span class="sidebar-label">连接说明</span></DialogTrigger
|
<NLayout class="admin-main">
|
||||||
></template
|
<NLayoutHeader bordered class="admin-topbar">
|
||||||
>
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<dl class="detail-grid mt-7">
|
<NButton
|
||||||
<dt>API 地址</dt>
|
quaternary
|
||||||
<dd>
|
circle
|
||||||
<code>{{ apiBase }}</code>
|
:aria-label="collapsed ? '展开侧栏' : '折叠侧栏'"
|
||||||
</dd>
|
@click="collapsed = !collapsed"
|
||||||
<dt>开发环境</dt>
|
>
|
||||||
<dd>复制 .env.example 为 .env,将 API_PROXY_TARGET 指向你的后端服务,然后重启 Vite。</dd>
|
<PanelLeftOpen v-if="collapsed" :size="18" /><PanelLeftClose v-else :size="18" />
|
||||||
<dt>生产环境</dt>
|
</NButton>
|
||||||
<dd>为 /api 和 /storage 配置反向代理,并为生成、拆解和恢复接口设置足够长的读取超时。</dd>
|
<span class="truncate text-sm">{{ route.meta.title || '工作空间' }}</span>
|
||||||
<dt>状态更新</dt>
|
</div>
|
||||||
<dd>项目详情每 6 秒刷新一次;checkpoint 只在节点或批次结束后更新。</dd>
|
<div class="flex items-center gap-3">
|
||||||
</dl></AppDialog
|
<NSelect
|
||||||
>
|
v-model:value="preference"
|
||||||
<div class="sidebar-divider"></div>
|
:options="themeOptions"
|
||||||
<button
|
:virtual-scroll="false"
|
||||||
class="side-link w-full"
|
size="small"
|
||||||
:aria-label="collapsed ? '展开侧栏' : '折叠侧栏'"
|
class="theme-select"
|
||||||
@click="collapsed = !collapsed"
|
aria-label="主题模式"
|
||||||
>
|
/>
|
||||||
<PanelLeftOpen v-if="collapsed" :size="17" /><PanelLeftClose v-else :size="17" /><span
|
<NTooltip
|
||||||
class="sidebar-label"
|
><template #trigger
|
||||||
>收起侧栏</span
|
><NButton quaternary circle aria-label="后端连接" @click="settingsOpen = true"
|
||||||
>
|
><Settings2 :size="18" /></NButton></template
|
||||||
</button>
|
>后端连接</NTooltip
|
||||||
<p class="sidebar-label mt-3 px-3 text-[10px] text-stone-500">创作 · 整理 · 逐镜成形</p>
|
>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</NLayoutHeader>
|
||||||
<div class="app-main">
|
<main id="main-content" tabindex="-1" class="admin-content"><RouterView /></main>
|
||||||
<header class="topbar">
|
</NLayout>
|
||||||
<span class="text-xs text-muted"
|
</NLayout>
|
||||||
>短剧制作 / <span class="text-ink">{{ route.meta.title || '工作空间' }}</span></span
|
<AppDialog
|
||||||
><span class="topbar-note"
|
v-model:open="settingsOpen"
|
||||||
>创作工作区 <span class="ml-2 font-mono text-[10px] text-faint">v0.1</span></span
|
title="后端连接"
|
||||||
>
|
description="页面使用真实后端接口,生成操作会实际提交。"
|
||||||
</header>
|
>
|
||||||
<main id="main-content" tabindex="-1"><RouterView /></main>
|
<dl class="detail-grid mt-6">
|
||||||
</div>
|
<dt>API 地址</dt>
|
||||||
</div>
|
<dd>
|
||||||
|
<code>{{ apiBase }}</code>
|
||||||
|
</dd>
|
||||||
|
<dt>开发环境</dt>
|
||||||
|
<dd>在 .env 中配置 API_PROXY_TARGET,修改后重启 Vite。</dd>
|
||||||
|
<dt>生产环境</dt>
|
||||||
|
<dd>为 /api、/storage 配置反向代理,并为长工作流设置足够的超时。</dd>
|
||||||
|
<dt>状态更新</dt>
|
||||||
|
<dd>项目详情每 6 秒刷新;关闭页面不会取消后端任务。</dd>
|
||||||
|
</dl>
|
||||||
|
</AppDialog>
|
||||||
|
</NConfigProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+574
@@ -0,0 +1,574 @@
|
|||||||
|
/* 管理后台视口边界:document 不滚动,各个工作区和面板独立滚动。 */
|
||||||
|
:root {
|
||||||
|
--app-ink: #202020;
|
||||||
|
--app-muted: #737373;
|
||||||
|
--app-surface: #ffffff;
|
||||||
|
--app-subtle: #f5f5f5;
|
||||||
|
--app-border: #e5e5e5;
|
||||||
|
--app-inverse: #ffffff;
|
||||||
|
--app-shadow: #00000018;
|
||||||
|
--app-danger: #b42318;
|
||||||
|
--app-success: #447454;
|
||||||
|
}
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--app-ink: #ededed;
|
||||||
|
--app-muted: #a3a3a3;
|
||||||
|
--app-surface: #1c1c1c;
|
||||||
|
--app-subtle: #252525;
|
||||||
|
--app-border: #363636;
|
||||||
|
--app-inverse: #171717;
|
||||||
|
--app-shadow: #00000055;
|
||||||
|
--app-danger: #ee8b83;
|
||||||
|
--app-success: #91ba9b;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app,
|
||||||
|
.app-provider {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
background: var(--app-surface);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.admin-layout {
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.admin-main {
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.admin-main > .n-layout-scroll-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.admin-sider {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.admin-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 74px;
|
||||||
|
padding: 0 20px;
|
||||||
|
color: var(--app-ink);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.admin-brand > svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.admin-brand span {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.admin-brand small {
|
||||||
|
display: block;
|
||||||
|
font-size: 8px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--app-muted);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.admin-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 20px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.theme-select {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
.admin-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.project-frame {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.project-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 14px 24px 0;
|
||||||
|
background: var(--app-surface);
|
||||||
|
border-bottom: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
.project-header .n-page-header__main,
|
||||||
|
.project-header .n-page-header__title {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.project-title {
|
||||||
|
max-width: min(58vw, 980px);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.project-tabs {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.project-tabs .n-tabs-nav {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.project-notices {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 100px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 24px;
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.project-notices .n-alert {
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.project-view {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.workspace-page {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.workspace-heading {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 16px 24px 12px;
|
||||||
|
max-height: 35%;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.workspace-heading h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.workspace-heading .text-sm {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.workspace-heading .production-controls {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.workspace-heading .n-input-number {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.workspace-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.workspace-scroll-content {
|
||||||
|
padding: 4px 24px 24px;
|
||||||
|
}
|
||||||
|
.workspace-scroll > .n-scrollbar-container {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.workspace-split {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 4px 24px 20px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.workspace-split > .my-6 {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.workspace-overview {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 42%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--app-surface);
|
||||||
|
border: 1px solid var(--app-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.workspace-feedback {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 24%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.workspace-feedback .n-alert + .n-alert {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.workspace-split > .n-alert {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 24%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.script-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.script-section > :first-child {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.script-section > :first-child .n-tabs {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.script-section .content-with-history {
|
||||||
|
flex: 1;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.script-workspace-page .workspace-split > :not(.script-section) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.storyboard-workspace-page .storyboard-workspace {
|
||||||
|
flex: 1;
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.storyboard-coverage {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.overview-content {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.workspace-loading {
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.production-workspace,
|
||||||
|
.identity-workspace {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.production-shot-list {
|
||||||
|
max-height: none;
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.production-workspace > article {
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.identity-workspace > aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.identity-workspace {
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.identity-workspace > div {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.identity-subject-list {
|
||||||
|
max-height: none;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.storyboard-workspace {
|
||||||
|
height: clamp(360px, 65dvh, 850px);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.storyboard-workspace > article,
|
||||||
|
.storyboard-workspace .shot-list {
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
.content-with-history {
|
||||||
|
height: clamp(360px, 66dvh, 850px);
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 207px;
|
||||||
|
}
|
||||||
|
.content-with-history > main,
|
||||||
|
.content-with-history > aside,
|
||||||
|
.script-page {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
.script-workspace {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.episode-list {
|
||||||
|
max-height: none;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.project-table-area {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.project-data-table {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.project-data-table .n-data-table-base-table {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.project-index-heading {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.project-index-heading h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.project-index-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.project-search {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
.project-name-cell {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.project-name-cell strong,
|
||||||
|
.project-name-cell small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.project-name-cell small {
|
||||||
|
color: var(--app-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.app-dialog {
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
max-height: calc(100dvh - 32px);
|
||||||
|
}
|
||||||
|
.app-dialog .n-card-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dialog-body-scroll {
|
||||||
|
max-height: calc(100dvh - 154px);
|
||||||
|
}
|
||||||
|
.app-dialog .n-card__content {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
.asset-image .n-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.asset-image .n-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.n-button__content {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.n-button:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.n-button.episode-link,
|
||||||
|
.n-button.shot-link,
|
||||||
|
.n-button.production-shot-link,
|
||||||
|
.n-button.identity-subject-item,
|
||||||
|
.n-button.casting-item,
|
||||||
|
.n-button.image-history-item {
|
||||||
|
height: auto;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.n-button.episode-link .n-button__content,
|
||||||
|
.n-button.shot-link .n-button__content {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.n-button.production-shot-link .n-button__content,
|
||||||
|
.n-button.identity-subject-item .n-button__content,
|
||||||
|
.n-button.image-history-item .n-button__content {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.n-button.casting-item .n-button__content {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.n-button.episode-link .n-button__border,
|
||||||
|
.n-button.shot-link .n-button__border,
|
||||||
|
.n-button.production-shot-link .n-button__border,
|
||||||
|
.n-button.identity-subject-item .n-button__border {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.search-field {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
.search-field input {
|
||||||
|
width: 100%;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.select-control {
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
.n-checkbox {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.n-checkbox .n-checkbox__label {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.n-alert {
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.n-collapse.panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.n-table.project-table th {
|
||||||
|
background: var(--app-subtle);
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
.production-video {
|
||||||
|
background: #080808;
|
||||||
|
}
|
||||||
|
.button-primary {
|
||||||
|
color: var(--app-inverse);
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
z-index: 3000;
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.project-title {
|
||||||
|
max-width: 44vw;
|
||||||
|
}
|
||||||
|
.production-controls {
|
||||||
|
grid-template-columns: minmax(130px, 1fr) 90px;
|
||||||
|
}
|
||||||
|
.content-with-history {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.admin-topbar {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
.admin-sider:not(.n-layout-sider--collapsed) {
|
||||||
|
position: absolute;
|
||||||
|
top: 56px;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: 6px 0 24px var(--app-shadow);
|
||||||
|
}
|
||||||
|
.project-header {
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
}
|
||||||
|
.project-title {
|
||||||
|
max-width: 37vw;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.project-header .n-page-header__extra {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.project-header .n-tag {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.workspace-heading {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.workspace-scroll-content,
|
||||||
|
.workspace-split {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
.workspace-heading .production-controls {
|
||||||
|
grid-template-columns: minmax(100px, 1fr) 80px;
|
||||||
|
}
|
||||||
|
.production-workspace {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: 110px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.production-shot-list {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
height: 110px;
|
||||||
|
}
|
||||||
|
.production-shot-link {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.identity-workspace {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: 180px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.storyboard-workspace {
|
||||||
|
grid-template-columns: 120px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.storyboard-controls {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.content-with-history {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr) 130px;
|
||||||
|
}
|
||||||
|
.history-panel {
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.script-workspace {
|
||||||
|
grid-template-columns: 105px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.episode-list {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
border-right: 1px solid var(--app-border);
|
||||||
|
}
|
||||||
|
.episode-link {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.script-page {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.project-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.project-index-toolbar .n-radio-group {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.production-pipeline {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import { NModal, NScrollbar } from 'naive-ui'
|
||||||
DialogRoot,
|
/** Naive UI 管理焦点锁定、Esc、遮罩与主题;长弹窗只滚动内部正文。 */
|
||||||
DialogPortal,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogClose
|
|
||||||
} from 'reka-ui'
|
|
||||||
import { X } from '@lucide/vue'
|
|
||||||
|
|
||||||
/** 统一弹窗容器,Reka UI 负责焦点锁定、Escape 和无障碍语义。 */
|
|
||||||
defineProps<{ title: string; description: string; busy?: boolean; wide?: boolean }>()
|
defineProps<{ title: string; description: string; busy?: boolean; wide?: boolean }>()
|
||||||
const open = defineModel<boolean>('open', { default: false })
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogRoot v-model:open="open">
|
<slot name="trigger" />
|
||||||
<slot name="trigger"></slot>
|
<NModal
|
||||||
<DialogPortal>
|
v-model:show="open"
|
||||||
<DialogOverlay class="dialog-overlay" />
|
preset="card"
|
||||||
<DialogContent
|
:title="title"
|
||||||
class="dialog-content"
|
:closable="!busy"
|
||||||
:class="{ 'dialog-wide': wide }"
|
:mask-closable="!busy"
|
||||||
@interact-outside="busy && $event.preventDefault()"
|
:close-on-esc="!busy"
|
||||||
@escape-key-down="busy && $event.preventDefault()"
|
:style="{ width: wide ? '1040px' : '600px' }"
|
||||||
>
|
class="app-dialog"
|
||||||
<div class="flex items-start justify-between gap-4">
|
role="dialog"
|
||||||
<div>
|
:aria-label="title"
|
||||||
<DialogTitle class="text-xl font-semibold">{{ title }}</DialogTitle
|
>
|
||||||
><DialogDescription class="mt-2 text-sm leading-6 text-muted">{{
|
<NScrollbar class="dialog-body-scroll">
|
||||||
description
|
<p class="text-sm leading-6 text-muted">{{ description }}</p>
|
||||||
}}</DialogDescription>
|
<slot />
|
||||||
</div>
|
</NScrollbar>
|
||||||
<DialogClose class="icon-button shrink-0" aria-label="关闭弹窗" :disabled="busy"
|
</NModal>
|
||||||
><X :size="18"
|
|
||||||
/></DialogClose>
|
|
||||||
</div>
|
|
||||||
<slot></slot>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogPortal>
|
|
||||||
</DialogRoot>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NButton, NImage } 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'
|
||||||
@@ -20,21 +21,23 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="asset-image">
|
<div class="asset-image">
|
||||||
<img
|
<NImage
|
||||||
v-if="url && !failed"
|
v-if="url && !failed"
|
||||||
:src="url"
|
:src="url"
|
||||||
:alt="alt"
|
:alt="alt"
|
||||||
loading="lazy"
|
lazy
|
||||||
referrerpolicy="no-referrer"
|
preview-disabled
|
||||||
|
object-fit="contain"
|
||||||
|
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||||
@error="failed = true"
|
@error="failed = true"
|
||||||
/>
|
/>
|
||||||
<span v-else class="asset-image-empty"
|
<span v-else class="asset-image-empty"
|
||||||
><ImageOff :size="22" :stroke-width="1.3" /><span>{{
|
><ImageOff :size="22" :stroke-width="1.3" /><span>{{
|
||||||
src ? '图片无法加载' : emptyText || '尚未生成图片'
|
src ? '图片无法加载' : emptyText || '尚未生成图片'
|
||||||
}}</span
|
}}</span
|
||||||
><button v-if="failed && retryable" type="button" class="text-button" @click.stop="failed = false">
|
><NButton v-if="failed && retryable" @click.stop="failed = false" text size="small">
|
||||||
重试加载
|
重试加载
|
||||||
</button></span
|
</NButton></span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NEmpty } from 'naive-ui'
|
||||||
import { FileText } from '@lucide/vue'
|
import { FileText } from '@lucide/vue'
|
||||||
|
/** 没有数据时展示真实空态,不创建虚构项目或结果。 */
|
||||||
/** 空数据与等待生成共用的轻量占位,不放入演示数据。 */
|
|
||||||
defineProps<{ title: string; description: string }>()
|
defineProps<{ title: string; description: string }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="empty-state">
|
<NEmpty class="empty-state" :description="title">
|
||||||
<FileText :size="28" :stroke-width="1.4" class="mb-4 text-muted" />
|
<template #icon><FileText :size="30" :stroke-width="1.4" /></template>
|
||||||
<h3 class="font-medium">{{ title }}</h3>
|
<template #extra
|
||||||
<p class="mt-2 max-w-md text-sm leading-6 text-muted">{{ description }}</p>
|
><p class="max-w-md text-sm leading-6 text-muted">{{ description }}</p>
|
||||||
<div class="mt-5"><slot></slot></div>
|
<div class="mt-5"><slot /></div
|
||||||
</div>
|
></template>
|
||||||
|
</NEmpty>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NTag } from 'naive-ui'
|
||||||
import { statusLabels } from '../../lib/format'
|
import { statusLabels } from '../../lib/format'
|
||||||
import type { ProjectStatus } from '../../features/projects/types'
|
import type { ProjectStatus } from '../../features/projects/types'
|
||||||
|
/** 状态使用 Naive 标签,同时保留文本,避免仅依赖颜色区分。 */
|
||||||
/** 状态同时提供文字和颜色,避免只依赖色觉。 */
|
const props = defineProps<{ status: string; label?: string }>()
|
||||||
defineProps<{ status: string; label?: string }>()
|
const type = computed(() =>
|
||||||
|
['failed', 'blocked', 'cancelled'].includes(props.status)
|
||||||
|
? 'error'
|
||||||
|
: ['completed', 'ready'].includes(props.status)
|
||||||
|
? 'success'
|
||||||
|
: ['running', 'generating', 'pending', 'unlocked', 'candidate_pending'].includes(props.status)
|
||||||
|
? 'warning'
|
||||||
|
: 'default'
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="status-badge" :data-status="status">
|
<NTag :type="type" size="small" :bordered="false" :data-status="status">{{
|
||||||
<span class="status-dot" aria-hidden="true"></span
|
label || statusLabels[status as ProjectStatus] || status
|
||||||
>{{ label || statusLabels[status as ProjectStatus] || status }}
|
}}</NTag>
|
||||||
</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NScrollbar } from 'naive-ui'
|
||||||
|
/** 工作区固定标题和操作区;仅内容容器滚动,不向 document 传播滚动。 */
|
||||||
|
defineProps<{ split?: boolean }>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="workspace-page">
|
||||||
|
<header v-if="$slots.header" class="workspace-heading"><slot name="header" /></header>
|
||||||
|
<div v-if="split" class="workspace-split"><slot /></div>
|
||||||
|
<NScrollbar v-else class="workspace-scroll" content-class="workspace-scroll-content"><slot /></NScrollbar>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { h } from 'vue'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { NConfigProvider, NModal, NScrollbar } from 'naive-ui'
|
||||||
|
import App from '../../App.vue'
|
||||||
|
import WorkspacePage from './WorkspacePage.vue'
|
||||||
|
import AppDialog from './AppDialog.vue'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | undefined
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = undefined
|
||||||
|
localStorage.clear()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('管理后台组件边界', () => {
|
||||||
|
it('滚动边界样式包含 document 锁定和工作区收缩约束', () => {
|
||||||
|
// happy-dom 不计算视口几何;这里只保护 CSS 契约,真实滚动仍需浏览器验收。
|
||||||
|
const adminCss = readFileSync('src/admin.css', 'utf8')
|
||||||
|
expect(adminCss).toMatch(/html,\s*body,\s*#app,\s*\.app-provider\s*\{[^}]*overflow:\s*hidden/)
|
||||||
|
expect(adminCss).toMatch(/\.workspace-page\s*\{[^}]*min-height:\s*0;[^}]*overflow:\s*hidden/)
|
||||||
|
})
|
||||||
|
it('固定标题在滚动容器之外,普通工作区只有内容使用滚动组件', () => {
|
||||||
|
wrapper = mount(WorkspacePage, {
|
||||||
|
slots: { header: '<h2>固定操作区</h2>', default: '<p>正文</p>' }
|
||||||
|
})
|
||||||
|
expect(wrapper.get('.workspace-heading').text()).toBe('固定操作区')
|
||||||
|
expect(wrapper.getComponent(NScrollbar).text()).toBe('正文')
|
||||||
|
expect(wrapper.getComponent(NScrollbar).find('h2').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('分栏工作区不再包一层整页滚动', () => {
|
||||||
|
wrapper = mount(WorkspacePage, {
|
||||||
|
props: { split: true },
|
||||||
|
slots: { default: '<aside>目录</aside><article>正文</article>' }
|
||||||
|
})
|
||||||
|
expect(wrapper.findComponent(NScrollbar).exists()).toBe(false)
|
||||||
|
expect(wrapper.get('.workspace-split').findAll(':scope > *')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('真实主题选择器切换暗色,侧栏保持全部七个工作流入口', async () => {
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/projects', component: { render: () => h('p', '项目列表') } },
|
||||||
|
{
|
||||||
|
path: '/projects/:projectId/:workspace',
|
||||||
|
component: { render: () => h(WorkspacePage, {}, () => '镜头详情') }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
await router.push('/projects/test/production')
|
||||||
|
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('.n-menu a')).toHaveLength(8)
|
||||||
|
expect(wrapper.get('#main-content .workspace-page').text()).toBe('镜头详情')
|
||||||
|
await wrapper.get('.theme-select .n-base-selection').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const darkOption = [...document.querySelectorAll<HTMLElement>('.n-base-select-option')].find(
|
||||||
|
item => item.textContent?.trim() === '暗黑模式'
|
||||||
|
)
|
||||||
|
expect(darkOption).toBeDefined()
|
||||||
|
darkOption!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||||
|
expect(wrapper.getComponent(NConfigProvider).props('theme')?.name).toBe('dark')
|
||||||
|
expect(localStorage.getItem('drama-studio-theme')).toBe('dark')
|
||||||
|
await wrapper.get('[aria-label="折叠侧栏"]').trigger('click')
|
||||||
|
expect(wrapper.find('[aria-label="展开侧栏"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('长弹窗使用内部滚动,提交期间禁止遮罩和 Esc 关闭', async () => {
|
||||||
|
wrapper = mount(AppDialog, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: { open: true, title: '确认生图', description: '费用确认', busy: true },
|
||||||
|
slots: { default: '<p>内容</p>' }
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.getComponent(NModal).props()).toMatchObject({
|
||||||
|
closable: false,
|
||||||
|
maskClosable: false,
|
||||||
|
closeOnEsc: false
|
||||||
|
})
|
||||||
|
expect(document.querySelector('.dialog-body-scroll .n-scrollbar-container')).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { effectScope, nextTick, type EffectScope } from 'vue'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useTheme } from './useTheme'
|
||||||
|
|
||||||
|
let scope: EffectScope | undefined
|
||||||
|
afterEach(() => {
|
||||||
|
scope?.stop()
|
||||||
|
scope = undefined
|
||||||
|
localStorage.clear()
|
||||||
|
delete document.documentElement.dataset.theme
|
||||||
|
document.documentElement.style.colorScheme = ''
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 可控系统主题,验证跟随模式与事件清理,不依赖测试机器偏好。 */
|
||||||
|
function setup(dark = false) {
|
||||||
|
const listeners = new Set<(event: MediaQueryListEvent) => void>()
|
||||||
|
vi.stubGlobal('matchMedia', () => ({
|
||||||
|
matches: dark,
|
||||||
|
addEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.add(listener),
|
||||||
|
removeEventListener: (_: string, listener: (event: MediaQueryListEvent) => void) => listeners.delete(listener)
|
||||||
|
}))
|
||||||
|
scope = effectScope()
|
||||||
|
const state = scope.run(useTheme)!
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
listeners,
|
||||||
|
change: (matches: boolean) => listeners.forEach(listener => listener({ matches } as MediaQueryListEvent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('黑白双主题', () => {
|
||||||
|
it('默认跟随系统并实时响应,销毁时移除监听', async () => {
|
||||||
|
const state = setup()
|
||||||
|
expect(state.preference.value).toBe('system')
|
||||||
|
expect(document.documentElement.dataset.theme).toBe('light')
|
||||||
|
state.change(true)
|
||||||
|
await nextTick()
|
||||||
|
expect(state.isDark.value).toBe(true)
|
||||||
|
expect(document.documentElement.style.colorScheme).toBe('dark')
|
||||||
|
expect(state.listeners.size).toBe(1)
|
||||||
|
scope!.stop()
|
||||||
|
expect(state.listeners.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('手动选择优先于系统,并持久化到下一次挂载', async () => {
|
||||||
|
const state = setup(true)
|
||||||
|
state.preference.value = 'light'
|
||||||
|
await nextTick()
|
||||||
|
state.change(true)
|
||||||
|
await nextTick()
|
||||||
|
expect(state.isDark.value).toBe(false)
|
||||||
|
expect(localStorage.getItem('drama-studio-theme')).toBe('light')
|
||||||
|
scope!.stop()
|
||||||
|
const restored = setup(true)
|
||||||
|
expect(restored.preference.value).toBe('light')
|
||||||
|
expect(restored.theme.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('深浅模式同时更新 Naive 主题和业务颜色标记', async () => {
|
||||||
|
const state = setup()
|
||||||
|
expect(state.overrides.value.common?.primaryColor).toBe('#202020')
|
||||||
|
state.preference.value = 'dark'
|
||||||
|
await nextTick()
|
||||||
|
expect(state.theme.value?.name).toBe('dark')
|
||||||
|
expect(state.overrides.value.common?.primaryColor).toBe('#f5f5f5')
|
||||||
|
expect(state.overrides.value.Button?.textColorPrimary).toBe('#171717')
|
||||||
|
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无效存储回退系统模式', () => {
|
||||||
|
localStorage.setItem('drama-studio-theme', 'unexpected')
|
||||||
|
const state = setup(true)
|
||||||
|
expect(state.preference.value).toBe('system')
|
||||||
|
expect(state.isDark.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('禁用存储仍能切换当前会话的主题', async () => {
|
||||||
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||||
|
throw new Error('storage blocked')
|
||||||
|
})
|
||||||
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||||
|
throw new Error('storage blocked')
|
||||||
|
})
|
||||||
|
const state = setup()
|
||||||
|
state.preference.value = 'dark'
|
||||||
|
await nextTick()
|
||||||
|
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { computed, onScopeDispose, ref, watchEffect } from 'vue'
|
||||||
|
import { darkTheme, type GlobalThemeOverrides } from 'naive-ui'
|
||||||
|
|
||||||
|
/** 用户主题偏好;system 会跟随系统的实时深浅色变化。 */
|
||||||
|
export type ThemePreference = 'light' | 'dark' | 'system'
|
||||||
|
const storageKey = 'drama-studio-theme'
|
||||||
|
|
||||||
|
/** 读取主题时兼容隐私模式或禁用存储的浏览器。 */
|
||||||
|
function readPreference(): ThemePreference {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(storageKey)
|
||||||
|
return value === 'light' || value === 'dark' ? value : 'system'
|
||||||
|
} catch {
|
||||||
|
return 'system'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主题只影响展示,不修改项目配置;卸载时移除系统主题监听。 */
|
||||||
|
export function useTheme() {
|
||||||
|
const preference = ref<ThemePreference>(readPreference())
|
||||||
|
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
const systemDark = ref(media.matches)
|
||||||
|
const isDark = computed(() => preference.value === 'dark' || (preference.value === 'system' && systemDark.value))
|
||||||
|
const theme = computed(() => (isDark.value ? darkTheme : null))
|
||||||
|
const overrides = computed<GlobalThemeOverrides>(() => {
|
||||||
|
const dark = isDark.value
|
||||||
|
const primary = dark ? '#f5f5f5' : '#202020'
|
||||||
|
const hover = dark ? '#d4d4d4' : '#444444'
|
||||||
|
return {
|
||||||
|
common: {
|
||||||
|
primaryColor: primary,
|
||||||
|
primaryColorHover: hover,
|
||||||
|
primaryColorPressed: hover,
|
||||||
|
primaryColorSuppl: primary,
|
||||||
|
infoColor: primary,
|
||||||
|
infoColorHover: hover,
|
||||||
|
infoColorPressed: hover,
|
||||||
|
infoColorSuppl: primary,
|
||||||
|
borderRadius: '6px',
|
||||||
|
bodyColor: dark ? '#141414' : '#f5f5f5',
|
||||||
|
cardColor: dark ? '#1c1c1c' : '#ffffff',
|
||||||
|
modalColor: dark ? '#202020' : '#ffffff',
|
||||||
|
popoverColor: dark ? '#262626' : '#ffffff',
|
||||||
|
tableColor: dark ? '#1c1c1c' : '#ffffff',
|
||||||
|
borderColor: dark ? '#363636' : '#e5e5e5',
|
||||||
|
textColorBase: dark ? '#ededed' : '#202020',
|
||||||
|
fontFamily:
|
||||||
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
textColorPrimary: dark ? '#171717' : '#ffffff',
|
||||||
|
textColorHoverPrimary: dark ? '#171717' : '#ffffff',
|
||||||
|
textColorPressedPrimary: dark ? '#171717' : '#ffffff'
|
||||||
|
},
|
||||||
|
Layout: {
|
||||||
|
color: dark ? '#141414' : '#f5f5f5',
|
||||||
|
headerColor: dark ? '#1c1c1c' : '#ffffff',
|
||||||
|
siderColor: dark ? '#1c1c1c' : '#ffffff'
|
||||||
|
},
|
||||||
|
Menu: {
|
||||||
|
itemColorActive: dark ? '#333333' : '#eeeeee',
|
||||||
|
itemColorActiveHover: dark ? '#383838' : '#e7e7e7',
|
||||||
|
itemTextColorActive: primary,
|
||||||
|
itemTextColorActiveHover: primary,
|
||||||
|
itemIconColorActive: primary,
|
||||||
|
itemIconColorActiveHover: primary
|
||||||
|
},
|
||||||
|
Checkbox: { checkMarkColor: dark ? '#171717' : '#ffffff' },
|
||||||
|
Progress: { fillColor: primary }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
/** 系统切换只更新 system 模式下的实际显示。 */
|
||||||
|
function syncSystem(event: MediaQueryListEvent) {
|
||||||
|
systemDark.value = event.matches
|
||||||
|
}
|
||||||
|
media.addEventListener('change', syncSystem)
|
||||||
|
onScopeDispose(() => media.removeEventListener('change', syncSystem))
|
||||||
|
watchEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = isDark.value ? 'dark' : 'light'
|
||||||
|
document.documentElement.style.colorScheme = isDark.value ? 'dark' : 'light'
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, preference.value)
|
||||||
|
} catch {
|
||||||
|
/* 存储不可用时保留当前会话偏好。 */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { preference, isDark, theme, overrides }
|
||||||
|
}
|
||||||
@@ -1,16 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
|
import { NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui'
|
||||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||||
import {
|
import { Download, Layers, LoaderCircle, ArrowRight } from '@lucide/vue'
|
||||||
TabsRoot,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
TabsContent,
|
|
||||||
CheckboxRoot,
|
|
||||||
CheckboxIndicator,
|
|
||||||
ProgressRoot,
|
|
||||||
ProgressIndicator
|
|
||||||
} from 'reka-ui'
|
|
||||||
import { Check, Download, Layers, LoaderCircle, ArrowRight } from '@lucide/vue'
|
|
||||||
import { EmptyState, StatusBadge } from '../../components/ui'
|
import { EmptyState, StatusBadge } from '../../components/ui'
|
||||||
import { useProjectContext } from '../projects/context'
|
import { useProjectContext } from '../projects/context'
|
||||||
import { breakdownApi } from './api'
|
import { breakdownApi } from './api'
|
||||||
@@ -88,7 +80,7 @@ watch(
|
|||||||
)
|
)
|
||||||
onScopeDispose(invalidatePreview)
|
onScopeDispose(invalidatePreview)
|
||||||
|
|
||||||
/** Reka Checkbox 的不确定值不视为选择。 */
|
/** 只接受明确勾选状态,不把不确定值作为模块选择。 */
|
||||||
function toggleModule(module: BreakdownModule, checked: boolean | 'indeterminate') {
|
function toggleModule(module: BreakdownModule, checked: boolean | 'indeterminate') {
|
||||||
if (checked === true && !modules.value.includes(module)) modules.value.push(module)
|
if (checked === true && !modules.value.includes(module)) modules.value.push(module)
|
||||||
else modules.value = modules.value.filter(item => item !== module)
|
else modules.value = modules.value.filter(item => item !== module)
|
||||||
@@ -140,7 +132,11 @@ function exportResult() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-6">
|
<WorkspacePage
|
||||||
|
><template #header
|
||||||
|
><h2 class="text-lg font-semibold">剧本拆解</h2>
|
||||||
|
<p class="text-xs text-muted">分组抽取、主体整理与分镜结果</p></template
|
||||||
|
>
|
||||||
<div v-if="!project?.episodes.length" class="panel">
|
<div v-if="!project?.episodes.length" class="panel">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="还没有可拆解的剧集"
|
title="还没有可拆解的剧集"
|
||||||
@@ -159,54 +155,53 @@ function exportResult() {
|
|||||||
{{ project.episodes.length }} 集剧本。每个分组分别抽取所选模块,再生成主体与分镜。
|
{{ project.episodes.length }} 集剧本。每个分组分别抽取所选模块,再生成主体与分镜。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="tag">来源:正式剧集</span>
|
<NTag size="small" :bordered="false">来源:正式剧集</NTag>
|
||||||
</div>
|
</div>
|
||||||
<div class="breakdown-config">
|
<div class="breakdown-config">
|
||||||
<div>
|
<div>
|
||||||
<label class="field-label" for="group-size">每组集数</label>
|
<label class="field-label" for="group-size">每组集数</label>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<input
|
<NInputNumber
|
||||||
id="group-size"
|
|
||||||
v-model.number="groupSize"
|
|
||||||
type="number"
|
|
||||||
class="input w-24"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
:disabled="operation.pending"
|
:disabled="operation.pending"
|
||||||
/><span class="text-sm text-muted">集 / 组</span>
|
:input-props="{ id: 'group-size' }"
|
||||||
|
class="w-24"
|
||||||
|
:value="typeof groupSize === 'number' ? groupSize : null"
|
||||||
|
@update:value="groupSize = $event ?? 0"
|
||||||
|
:min="1"
|
||||||
|
:step="1"
|
||||||
|
></NInputNumber
|
||||||
|
><span class="text-sm text-muted">集 / 组</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="flex flex-wrap gap-5 border-0 p-0">
|
<fieldset class="flex flex-wrap gap-5 border-0 p-0">
|
||||||
<legend class="field-label mb-3">抽取模块</legend>
|
<legend class="field-label mb-3">抽取模块</legend>
|
||||||
<label
|
<NCheckbox
|
||||||
|
:checked="modules.includes(option.value)"
|
||||||
|
:disabled="operation.pending"
|
||||||
|
:aria-label="option.label"
|
||||||
|
@update:checked="toggleModule(option.value, $event)"
|
||||||
v-for="option in moduleOptions"
|
v-for="option in moduleOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="flex cursor-pointer items-start gap-2.5"
|
class="flex cursor-pointer items-start gap-2.5"
|
||||||
><CheckboxRoot
|
|
||||||
:model-value="modules.includes(option.value)"
|
|
||||||
:disabled="operation.pending"
|
|
||||||
class="checkbox mt-0.5"
|
|
||||||
:aria-label="option.label"
|
|
||||||
@update:model-value="toggleModule(option.value, $event)"
|
|
||||||
><CheckboxIndicator><Check :size="12" /></CheckboxIndicator></CheckboxRoot
|
|
||||||
><span
|
><span
|
||||||
><span class="block text-sm font-medium">{{ option.label }}</span
|
><span class="block text-sm font-medium">{{ option.label }}</span
|
||||||
><span class="mt-1 block text-[11px] text-muted">{{ option.description }}</span></span
|
><span class="mt-1 block text-[11px] text-muted">{{ option.description }}</span></span
|
||||||
></label
|
></NCheckbox
|
||||||
>
|
>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<button
|
<NButton
|
||||||
class="button button-secondary self-end"
|
|
||||||
:disabled="!configValid || previewBusy || operation.pending || !!error"
|
:disabled="!configValid || previewBusy || operation.pending || !!error"
|
||||||
@click="loadPreview"
|
@click="loadPreview"
|
||||||
>
|
class="self-end"
|
||||||
<LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
|
><LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!configValid" class="mt-4 text-xs text-danger">
|
<p v-if="!configValid" class="mt-4 text-xs text-danger">
|
||||||
每组集数必须是正整数,并至少选择一个抽取模块。
|
每组集数必须是正整数,并至少选择一个抽取模块。
|
||||||
</p>
|
</p>
|
||||||
<p v-if="previewError" class="alert alert-error mt-4" role="alert">{{ previewError }}</p>
|
<NAlert v-if="previewError" role="alert" type="error" :show-icon="false" class="mt-4">{{
|
||||||
|
previewError
|
||||||
|
}}</NAlert>
|
||||||
<div v-if="preview" class="group-preview">
|
<div v-if="preview" class="group-preview">
|
||||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
@@ -266,29 +261,28 @@ function exportResult() {
|
|||||||
><span>主体 {{ subjects.length }}</span
|
><span>主体 {{ subjects.length }}</span
|
||||||
><span>分镜剧集 {{ shots.length }} / {{ plans.length }}</span>
|
><span>分镜剧集 {{ shots.length }} / {{ plans.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressRoot
|
<NProgress
|
||||||
v-if="summary?.total"
|
v-if="summary?.total"
|
||||||
class="progress-track mt-3"
|
|
||||||
:model-value="summary.completed"
|
|
||||||
:max="summary.total"
|
|
||||||
aria-label="已完成抽取任务"
|
aria-label="已完成抽取任务"
|
||||||
><ProgressIndicator
|
type="line"
|
||||||
class="progress-fill"
|
:show-indicator="false"
|
||||||
:style="{ width: Math.min(100, (summary.completed / summary.total) * 100) + '%' }"
|
:height="4"
|
||||||
/></ProgressRoot>
|
:percentage="Math.min(100, Math.max(0, (summary.completed / Math.max(1, summary.total)) * 100))"
|
||||||
<p v-if="execution?.errorMessage" class="alert alert-error mt-4" role="alert">
|
class="mt-3"
|
||||||
{{ execution.errorMessage }}
|
></NProgress>
|
||||||
</p>
|
<NAlert v-if="execution?.errorMessage" role="alert" type="error" :show-icon="false" class="mt-4">{{
|
||||||
<div v-if="validation && !validation.valid" class="alert alert-error mt-4">
|
execution.errorMessage
|
||||||
<p class="font-medium">分镜校验未通过</p>
|
}}</NAlert>
|
||||||
|
<NAlert v-if="validation && !validation.valid" type="error" :show-icon="false" class="mt-4"
|
||||||
|
><p class="font-medium">分镜校验未通过</p>
|
||||||
<ul class="mt-2 list-inside list-disc space-y-1">
|
<ul class="mt-2 list-inside list-disc space-y-1">
|
||||||
<li v-for="(issue, index) in validation.issues" :key="index">
|
<li v-for="(issue, index) in validation.issues" :key="index">
|
||||||
第 {{ issue.episodeNo }} 集<span v-if="issue.beatNo"> / Beat {{ issue.beatNo }}</span
|
第 {{ issue.episodeNo }} 集<span v-if="issue.beatNo"> / Beat {{ issue.beatNo }}</span
|
||||||
><span v-if="issue.shotNo"> / Shot {{ issue.shotNo }}</span
|
><span v-if="issue.shotNo"> / Shot {{ issue.shotNo }}</span
|
||||||
>:{{ issue.message }}
|
>:{{ issue.message }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul></NAlert
|
||||||
</div>
|
>
|
||||||
<div v-if="showRecovery" class="recovery-strip mt-4">
|
<div v-if="showRecovery" class="recovery-strip mt-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium">从中断处继续</p>
|
<p class="text-sm font-medium">从中断处继续</p>
|
||||||
@@ -319,42 +313,39 @@ function exportResult() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<TabsRoot v-model="tab" class="mt-7">
|
<div class="mt-7">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<TabsList class="tabs-list" aria-label="拆解结果"
|
<NTabs v-model:value="tab" type="line" size="small" aria-label="拆解结果"
|
||||||
><TabsTrigger
|
><NTab v-for="option in moduleOptions" :key="option.value" :name="option.value"
|
||||||
v-for="option in moduleOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
class="tab-trigger"
|
|
||||||
>{{ option.label
|
>{{ option.label
|
||||||
}}<span>{{
|
}}<span>{{ subjects.filter(item => item.module === option.value).length }}</span></NTab
|
||||||
subjects.filter(item => item.module === option.value).length
|
><NTab name="storyboard">分镜</NTab><NTab name="tasks">任务明细</NTab></NTabs
|
||||||
}}</span></TabsTrigger
|
><NButton :disabled="!snapshot" @click="exportResult" text size="small"
|
||||||
><TabsTrigger value="storyboard" class="tab-trigger">分镜</TabsTrigger
|
><Download :size="14" />导出 JSON
|
||||||
><TabsTrigger value="tasks" class="tab-trigger">任务明细</TabsTrigger></TabsList
|
</NButton>
|
||||||
><button class="text-button" :disabled="!snapshot" @click="exportResult">
|
|
||||||
<Download :size="14" />导出 JSON
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="content-with-history panel mt-4">
|
<div class="content-with-history panel mt-4">
|
||||||
<main class="min-w-0">
|
<main class="min-w-0">
|
||||||
<TabsContent v-for="option in moduleOptions" :key="option.value" :value="option.value"
|
<template v-for="option in moduleOptions" :key="option.value"
|
||||||
><SubjectList
|
><div v-if="tab === option.value">
|
||||||
:project-id="project?.id"
|
<SubjectList
|
||||||
:subjects="subjects.filter(item => item.module === option.value)"
|
:project-id="project?.id"
|
||||||
:forms="forms" /></TabsContent
|
:subjects="subjects.filter(item => item.module === option.value)"
|
||||||
><TabsContent value="storyboard"
|
:forms="forms"
|
||||||
><div class="mb-4 flex justify-end">
|
/></div
|
||||||
|
></template>
|
||||||
|
<div v-if="tab === 'storyboard'">
|
||||||
|
<div class="mb-4 flex justify-end">
|
||||||
<RouterLink :to="`/projects/${project?.id}/storyboard`" class="text-button"
|
<RouterLink :to="`/projects/${project?.id}/storyboard`" class="text-button"
|
||||||
>进入分镜设计<ArrowRight :size="14"
|
>进入分镜设计<ArrowRight :size="14"
|
||||||
/></RouterLink>
|
/></RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<StoryboardList :plans="plans" :episodes="shots" /></TabsContent
|
<StoryboardList :plans="plans" :episodes="shots" />
|
||||||
><TabsContent value="tasks" class="p-5"
|
</div>
|
||||||
><div v-if="snapshot?.tasks?.length" class="table-scroll">
|
<div v-if="tab === 'tasks'" class="p-5">
|
||||||
<table class="project-table">
|
<div v-if="snapshot?.tasks?.length" class="table-scroll">
|
||||||
<thead>
|
<NTable :single-line="false" class="project-table"
|
||||||
|
><thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>分组</th>
|
<th>分组</th>
|
||||||
<th>模块</th>
|
<th>模块</th>
|
||||||
@@ -390,18 +381,18 @@ function exportResult() {
|
|||||||
<td colspan="4" class="text-danger">{{ task.errorMessage }}</td>
|
<td colspan="4" class="text-danger">{{ task.errorMessage }}</td>
|
||||||
</tr></template
|
</tr></template
|
||||||
>
|
>
|
||||||
</tbody>
|
</tbody></NTable
|
||||||
</table>
|
>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else
|
v-else
|
||||||
title="尚无抽取任务记录"
|
title="尚无抽取任务记录"
|
||||||
description="启动拆解后,任务会在后端保存 checkpoint 时更新。预览中的任务尚未执行。"
|
description="启动拆解后,任务会在后端保存 checkpoint 时更新。预览中的任务尚未执行。"
|
||||||
/></TabsContent>
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<HistoryPanel :checkpoints="checkpoints" workflow="breakdown" />
|
<HistoryPanel :checkpoints="checkpoints" workflow="breakdown" />
|
||||||
</div>
|
</div>
|
||||||
</TabsRoot>
|
</div> </template
|
||||||
</template>
|
></WorkspacePage>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NSelect, NTag } from 'naive-ui'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Clock3 } from '@lucide/vue'
|
import { Clock3 } from '@lucide/vue'
|
||||||
import { EmptyState } from '../../../components/ui'
|
import { EmptyState } from '../../../components/ui'
|
||||||
@@ -28,14 +29,25 @@ const purposeLabels: Record<string, string> = {
|
|||||||
<div v-if="plan" class="p-5 lg:p-7">
|
<div v-if="plan" class="p-5 lg:p-7">
|
||||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
|
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
<label class="flex items-center gap-3 text-sm"
|
<label class="flex items-center gap-3 text-sm"
|
||||||
>选择剧集<select v-model="selected" class="input w-auto" aria-label="选择分镜剧集">
|
>选择剧集<NSelect
|
||||||
<option v-if="selected === undefined" :value="undefined">
|
aria-label="选择分镜剧集"
|
||||||
第 {{ plan.episodeNo }} 集 · {{ plan.episodeTitle }}
|
v-model:value="selected"
|
||||||
</option>
|
:options="[
|
||||||
<option v-for="item in plans" :key="item.episodeNo" :value="item.episodeNo">
|
...(selected === undefined
|
||||||
第 {{ item.episodeNo }} 集 · {{ item.episodeTitle }}
|
? [
|
||||||
</option>
|
{
|
||||||
</select></label
|
label: String(' 第 ' + plan.episodeNo + ' 集 · ' + plan.episodeTitle),
|
||||||
|
value: undefined
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...plans.map(item => ({
|
||||||
|
label: String(' 第 ' + item.episodeNo + ' 集 · ' + item.episodeTitle),
|
||||||
|
value: item.episodeNo
|
||||||
|
}))
|
||||||
|
]"
|
||||||
|
class="select-control"
|
||||||
|
></NSelect></label
|
||||||
><span class="text-xs text-muted"
|
><span class="text-xs text-muted"
|
||||||
>{{ plan.beats.length }} 个节拍 ·
|
>{{ plan.beats.length }} 个节拍 ·
|
||||||
{{ episode?.beatShots.reduce((sum, beat) => sum + beat.shots.length, 0) ?? 0 }} 个镜头</span
|
{{ episode?.beatShots.reduce((sum, beat) => sum + beat.shots.length, 0) ?? 0 }} 个镜头</span
|
||||||
@@ -45,14 +57,14 @@ const purposeLabels: Record<string, string> = {
|
|||||||
<p class="font-medium">{{ plan.storyGoal }}</p>
|
<p class="font-medium">{{ plan.storyGoal }}</p>
|
||||||
<p class="mt-2 text-sm leading-6 text-muted">{{ plan.emotionalArc }}</p>
|
<p class="mt-2 text-sm leading-6 text-muted">{{ plan.emotionalArc }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!episode" class="alert mb-5">
|
<NAlert v-if="!episode" type="info" :show-icon="false" class="mb-5">
|
||||||
本集已完成节拍规划,镜头尚未生成。请等待进度更新,或在确认中断后使用“补齐缺失镜头”。
|
本集已完成节拍规划,镜头尚未生成。请等待进度更新,或在确认中断后使用“补齐缺失镜头”。
|
||||||
</p>
|
</NAlert>
|
||||||
<section v-for="beat in plan.beats" :key="beat.beatNo" class="beat-section">
|
<section v-for="beat in plan.beats" :key="beat.beatNo" class="beat-section">
|
||||||
<div class="beat-heading">
|
<div class="beat-heading">
|
||||||
<span class="beat-number">{{ String(beat.beatNo).padStart(2, '0') }}</span>
|
<span class="beat-number">{{ String(beat.beatNo).padStart(2, '0') }}</span>
|
||||||
<h3 class="text-sm font-semibold">{{ beat.title }}</h3>
|
<h3 class="text-sm font-semibold">{{ beat.title }}</h3>
|
||||||
<span class="tag ml-auto">{{ purposeLabels[beat.purpose] || beat.purpose }}</span>
|
<NTag size="small" :bordered="false">{{ purposeLabels[beat.purpose] || beat.purpose }}</NTag>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-4 mt-3 text-sm leading-6 text-muted">{{ beat.description }}</p>
|
<p class="mb-4 mt-3 text-sm leading-6 text-muted">{{ beat.description }}</p>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NCollapse, NCollapseItem, NInput, NTag } from 'naive-ui'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Search, ChevronDown } from '@lucide/vue'
|
import { Search, ChevronDown } from '@lucide/vue'
|
||||||
import { EmptyState } from '../../../components/ui'
|
import { EmptyState } from '../../../components/ui'
|
||||||
@@ -21,7 +22,12 @@ const filtered = computed(() =>
|
|||||||
<div class="mb-5 flex items-center justify-between gap-3">
|
<div class="mb-5 flex items-center justify-between gap-3">
|
||||||
<p class="text-sm text-muted">{{ subjects.length }} 个主体</p>
|
<p class="text-sm text-muted">{{ subjects.length }} 个主体</p>
|
||||||
<div class="search-field">
|
<div class="search-field">
|
||||||
<Search :size="14" /><input v-model="search" placeholder="搜索名称或引用" aria-label="搜索主体" />
|
<Search :size="14" /><NInput
|
||||||
|
placeholder="搜索名称或引用"
|
||||||
|
aria-label="搜索主体"
|
||||||
|
:input-props="{ 'aria-label': '搜索主体' }"
|
||||||
|
v-model:value="search"
|
||||||
|
></NInput>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filtered.length" class="divide-y divide-line">
|
<div v-if="filtered.length" class="divide-y divide-line">
|
||||||
@@ -46,28 +52,31 @@ const filtered = computed(() =>
|
|||||||
class="text-button mt-3"
|
class="text-button mt-3"
|
||||||
>查看形态图片/生图</RouterLink
|
>查看形态图片/生图</RouterLink
|
||||||
>
|
>
|
||||||
<details class="subject-details mt-4">
|
<NCollapse class="subject-details mt-4"
|
||||||
<summary>
|
><NCollapseItem name="details"
|
||||||
<ChevronDown :size="14" />外观描述与形态
|
><template #header
|
||||||
<span class="ml-1 text-muted">{{
|
><ChevronDown :size="14" />外观描述与形态
|
||||||
forms.filter(form => form.profileId === subject.profileId).length
|
<span class="ml-1 text-muted">{{
|
||||||
}}</span>
|
forms.filter(form => form.profileId === subject.profileId).length
|
||||||
</summary>
|
}}</span></template
|
||||||
<p class="mt-3 whitespace-pre-wrap text-sm leading-7 text-muted">
|
>
|
||||||
{{ subject.appearance_prompt || '暂无外观描述' }}
|
<p class="mt-3 whitespace-pre-wrap text-sm leading-7 text-muted">
|
||||||
</p>
|
{{ subject.appearance_prompt || '暂无外观描述' }}
|
||||||
<div
|
|
||||||
v-for="form in forms.filter(item => item.profileId === subject.profileId)"
|
|
||||||
:key="form.formId"
|
|
||||||
class="mt-3 border-l-2 border-line pl-4"
|
|
||||||
>
|
|
||||||
<p class="text-sm font-medium">
|
|
||||||
{{ form.formName || form.name }}<span v-if="form.isDefault" class="tag ml-2">默认</span>
|
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-sm leading-6 text-muted">{{ form.description }}</p>
|
<div
|
||||||
<p class="mt-2 text-xs leading-6 text-muted">{{ form.appearancePrompt }}</p>
|
v-for="form in forms.filter(item => item.profileId === subject.profileId)"
|
||||||
</div>
|
:key="form.formId"
|
||||||
</details>
|
class="mt-3 border-l-2 border-line pl-4"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
{{ form.formName || form.name
|
||||||
|
}}<NTag v-if="form.isDefault" size="small" :bordered="false">默认</NTag>
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-muted">{{ form.description }}</p>
|
||||||
|
<p class="mt-2 text-xs leading-6 text-muted">{{ form.appearancePrompt }}</p>
|
||||||
|
</div></NCollapseItem
|
||||||
|
></NCollapse
|
||||||
|
>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
|
import { NButton, NCollapse, NCollapseItem, NProgress, NTab, NTabs, NTag } from 'naive-ui'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent, ProgressRoot, ProgressIndicator } from 'reka-ui'
|
|
||||||
import { ArrowRight, Download, Check, Circle } from '@lucide/vue'
|
import { ArrowRight, Download, Check, Circle } from '@lucide/vue'
|
||||||
import { EmptyState } from '../../components/ui'
|
import { EmptyState } from '../../components/ui'
|
||||||
import { useProjectContext } from '../projects/context'
|
import { useProjectContext } from '../projects/context'
|
||||||
@@ -67,13 +68,19 @@ function exportScript() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-6">
|
<WorkspacePage split class="script-workspace-page"
|
||||||
<div class="stage-strip">
|
><template #header
|
||||||
<div v-for="(stage, index) in stages" :key="stage.label" class="stage-item" :class="{ done: stage.done }">
|
><div class="stage-strip">
|
||||||
<Check v-if="stage.done" :size="15" /><Circle v-else :size="13" /><span>{{ stage.label }}</span
|
<div
|
||||||
><ArrowRight v-if="index < stages.length - 1" :size="13" class="stage-arrow" />
|
v-for="(stage, index) in stages"
|
||||||
</div>
|
:key="stage.label"
|
||||||
</div>
|
class="stage-item"
|
||||||
|
:class="{ done: stage.done }"
|
||||||
|
>
|
||||||
|
<Check v-if="stage.done" :size="15" /><Circle v-else :size="13" /><span>{{ stage.label }}</span
|
||||||
|
><ArrowRight v-if="index < stages.length - 1" :size="13" class="stage-arrow" />
|
||||||
|
</div></div
|
||||||
|
></template>
|
||||||
<div v-if="!complete" class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
<div v-if="!complete" class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||||||
<p class="text-sm text-muted">
|
<p class="text-sm text-muted">
|
||||||
已写入 {{ episodes.length }}<span v-if="total"> / {{ total }}</span> 集<span v-if="!total">
|
已写入 {{ episodes.length }}<span v-if="total"> / {{ total }}</span> 集<span v-if="!total">
|
||||||
@@ -98,28 +105,26 @@ function exportScript() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ProgressRoot
|
<NProgress
|
||||||
v-if="total && !complete"
|
v-if="total && !complete"
|
||||||
class="progress-track mt-4"
|
|
||||||
:model-value="Math.min(episodes.length, total)"
|
|
||||||
:max="total"
|
|
||||||
aria-label="已写入剧集数量"
|
aria-label="已写入剧集数量"
|
||||||
><ProgressIndicator
|
type="line"
|
||||||
class="progress-fill"
|
:show-indicator="false"
|
||||||
:style="{ width: Math.min(100, (episodes.length / total) * 100) + '%' }"
|
:height="4"
|
||||||
/></ProgressRoot>
|
:percentage="Math.min(100, Math.max(0, (Math.min(episodes.length, total) / Math.max(1, total)) * 100))"
|
||||||
<TabsRoot v-model="tab" class="mt-7">
|
class="mt-4"
|
||||||
|
></NProgress>
|
||||||
|
<div class="script-section">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<TabsList class="tabs-list" aria-label="剧本内容"
|
<NTabs v-model:value="tab" type="line" size="small" aria-label="剧本内容"
|
||||||
><TabsTrigger class="tab-trigger" value="episodes"
|
><NTab name="episodes"
|
||||||
>剧集正文 <span>{{ episodes.length }}</span></TabsTrigger
|
>剧集正文 <span>{{ episodes.length }}</span></NTab
|
||||||
><TabsTrigger class="tab-trigger" value="characters">角色设定</TabsTrigger
|
><NTab name="characters">角色设定</NTab><NTab name="world">世界观</NTab
|
||||||
><TabsTrigger class="tab-trigger" value="world">世界观</TabsTrigger
|
><NTab name="review">审核记录</NTab></NTabs
|
||||||
><TabsTrigger class="tab-trigger" value="review">审核记录</TabsTrigger></TabsList
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button class="text-button" :disabled="!episodes.length" @click="exportScript">
|
<NButton :disabled="!episodes.length" @click="exportScript" text size="small"
|
||||||
<Download :size="14" />导出剧本</button
|
><Download :size="14" />导出剧本</NButton
|
||||||
><RouterLink
|
><RouterLink
|
||||||
v-if="complete"
|
v-if="complete"
|
||||||
class="text-button text-accent"
|
class="text-button text-accent"
|
||||||
@@ -130,21 +135,20 @@ function exportScript() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="content-with-history panel mt-4">
|
<div class="content-with-history panel mt-4">
|
||||||
<main class="min-w-0">
|
<main class="min-w-0">
|
||||||
<TabsContent value="episodes" class="h-full">
|
<div v-if="tab === 'episodes'" class="h-full">
|
||||||
<div v-if="episode" class="script-workspace">
|
<div v-if="episode" class="script-workspace">
|
||||||
<aside class="episode-list">
|
<aside class="episode-list">
|
||||||
<p class="px-4 pb-3 pt-5 text-[11px] font-medium tracking-wider text-muted">剧集目录</p>
|
<p class="px-4 pb-3 pt-5 text-[11px] font-medium tracking-wider text-muted">剧集目录</p>
|
||||||
<button
|
<NButton
|
||||||
v-for="item in episodes"
|
v-for="item in episodes"
|
||||||
:key="item.episode"
|
:key="item.episode"
|
||||||
class="episode-link"
|
|
||||||
:class="{ active: episode.episode === item.episode }"
|
|
||||||
:aria-pressed="episode.episode === item.episode"
|
:aria-pressed="episode.episode === item.episode"
|
||||||
@click="selected = item.episode"
|
@click="selected = item.episode"
|
||||||
|
class="episode-link"
|
||||||
|
:class="{ active: episode.episode === item.episode }"
|
||||||
|
><span class="episode-number">{{ String(item.episode).padStart(2, '0') }}</span
|
||||||
|
><span class="truncate">{{ item.title }}</span></NButton
|
||||||
>
|
>
|
||||||
<span class="episode-number">{{ String(item.episode).padStart(2, '0') }}</span
|
|
||||||
><span class="truncate">{{ item.title }}</span>
|
|
||||||
</button>
|
|
||||||
</aside>
|
</aside>
|
||||||
<article class="script-page">
|
<article class="script-page">
|
||||||
<p class="eyebrow">第 {{ String(episode.episode).padStart(2, '0') }} 集</p>
|
<p class="eyebrow">第 {{ String(episode.episode).padStart(2, '0') }} 集</p>
|
||||||
@@ -167,9 +171,9 @@ function exportScript() {
|
|||||||
title="剧本正在酝酿"
|
title="剧本正在酝酿"
|
||||||
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。"
|
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。"
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</div>
|
||||||
<TabsContent value="characters" class="p-6 lg:p-8"
|
<div v-if="tab === 'characters'" class="p-6 lg:p-8">
|
||||||
><div v-if="project?.characters.length" class="divide-y divide-line">
|
<div v-if="project?.characters.length" class="divide-y divide-line">
|
||||||
<article
|
<article
|
||||||
v-for="character in project.characters"
|
v-for="character in project.characters"
|
||||||
:key="character.id"
|
:key="character.id"
|
||||||
@@ -177,7 +181,7 @@ function exportScript() {
|
|||||||
>
|
>
|
||||||
<div class="mb-3 flex items-center gap-3">
|
<div class="mb-3 flex items-center gap-3">
|
||||||
<h3 class="text-lg font-semibold">{{ character.name }}</h3>
|
<h3 class="text-lg font-semibold">{{ character.name }}</h3>
|
||||||
<span class="tag">{{ character.role || '角色' }}</span
|
<NTag size="small" :bordered="false">{{ character.role || '角色' }}</NTag
|
||||||
><span class="text-xs text-muted">{{ character.occupation }}</span>
|
><span class="text-xs text-muted">{{ character.occupation }}</span>
|
||||||
</div>
|
</div>
|
||||||
<dl class="detail-grid">
|
<dl class="detail-grid">
|
||||||
@@ -194,10 +198,10 @@ function exportScript() {
|
|||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState v-else title="尚无角色设定" description="角色生成结束后会在这里显示。"
|
<EmptyState v-else title="尚无角色设定" description="角色生成结束后会在这里显示。" />
|
||||||
/></TabsContent>
|
</div>
|
||||||
<TabsContent value="world" class="p-6 lg:p-8"
|
<div v-if="tab === 'world'" class="p-6 lg:p-8">
|
||||||
><div v-if="project?.world">
|
<div v-if="project?.world">
|
||||||
<p class="eyebrow">故事的发生之地</p>
|
<p class="eyebrow">故事的发生之地</p>
|
||||||
<h2 class="mb-7 mt-3 text-xl font-semibold">
|
<h2 class="mb-7 mt-3 text-xl font-semibold">
|
||||||
{{ project.world.era }} · {{ project.world.location }}
|
{{ project.world.era }} · {{ project.world.location }}
|
||||||
@@ -214,15 +218,17 @@ function exportScript() {
|
|||||||
<dd>{{ project.world[field.key] || '未提供' }}</dd></template
|
<dd>{{ project.world[field.key] || '未提供' }}</dd></template
|
||||||
>
|
>
|
||||||
</dl>
|
</dl>
|
||||||
<details v-if="project.world.rules" class="mt-6 text-sm">
|
<NCollapse v-if="project.world.rules" class="mt-6 text-sm"
|
||||||
<summary class="cursor-pointer text-muted">世界规则</summary>
|
><NCollapseItem name="details"
|
||||||
<pre class="json-view mt-3">{{ JSON.stringify(project.world.rules, null, 2) }}</pre>
|
><template #header>世界规则</template>
|
||||||
</details>
|
<pre class="json-view mt-3">{{ JSON.stringify(project.world.rules, null, 2) }}</pre>
|
||||||
|
</NCollapseItem></NCollapse
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState v-else title="尚无世界观" description="世界观会在角色设定之后生成。"
|
<EmptyState v-else title="尚无世界观" description="世界观会在角色设定之后生成。" />
|
||||||
/></TabsContent>
|
</div>
|
||||||
<TabsContent value="review" class="p-6 lg:p-8"
|
<div v-if="tab === 'review'" class="p-6 lg:p-8">
|
||||||
><div v-if="project?.reviews.length" class="space-y-5">
|
<div v-if="project?.reviews.length" class="space-y-5">
|
||||||
<article
|
<article
|
||||||
v-for="review in project.reviews"
|
v-for="review in project.reviews"
|
||||||
:key="review.id"
|
:key="review.id"
|
||||||
@@ -244,15 +250,15 @@ function exportScript() {
|
|||||||
v-else
|
v-else
|
||||||
title="还没有审核记录"
|
title="还没有审核记录"
|
||||||
description="剧集生成完成后,工作流会进行内容审核与必要的改写。"
|
description="剧集生成完成后,工作流会进行内容审核与必要的改写。"
|
||||||
/>
|
/><NCollapse v-if="state?.rewriteSuggestion" class="mt-5 text-sm"
|
||||||
<details v-if="state?.rewriteSuggestion" class="mt-5 text-sm">
|
><NCollapseItem name="details"
|
||||||
<summary class="cursor-pointer text-muted">查看改写建议原文</summary>
|
><template #header>查看改写建议原文</template>
|
||||||
<pre class="json-view mt-3">{{ JSON.stringify(state.rewriteSuggestion, null, 2) }}</pre>
|
<pre class="json-view mt-3">{{ JSON.stringify(state.rewriteSuggestion, null, 2) }}</pre>
|
||||||
</details></TabsContent
|
</NCollapseItem></NCollapse
|
||||||
>
|
>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<HistoryPanel :checkpoints="checkpoints" workflow="create-drama" />
|
<HistoryPanel :checkpoints="checkpoints" workflow="create-drama" />
|
||||||
</div>
|
</div></div
|
||||||
</TabsRoot>
|
></WorkspacePage>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
|
import { NCollapse, NCollapseItem, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NSelect } from 'naive-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
|
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
|
||||||
import { ProgressRoot, ProgressIndicator } from 'reka-ui'
|
|
||||||
import { EmptyState, StatusBadge } from '../../components/ui'
|
import { EmptyState, StatusBadge } from '../../components/ui'
|
||||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||||
import { issueLabel, productionStatusLabel } from './model'
|
import { issueLabel, productionStatusLabel } from './model'
|
||||||
@@ -31,7 +32,6 @@ const {
|
|||||||
blocked,
|
blocked,
|
||||||
run
|
run
|
||||||
} = useProduction()
|
} = useProduction()
|
||||||
const staleKeyframes = computed(() => query.data.value?.keyframes.stalePrimaryKeyframe ?? 0)
|
|
||||||
const staleVideos = computed(() => query.data.value?.videos.stalePrimaryVideo ?? 0)
|
const staleVideos = computed(() => query.data.value?.videos.stalePrimaryVideo ?? 0)
|
||||||
const stages = computed(() => {
|
const stages = computed(() => {
|
||||||
const data = query.data.value
|
const data = query.data.value
|
||||||
@@ -87,186 +87,238 @@ const identityBlocked = computed(
|
|||||||
(query.data.value?.keyframes.missingIdentityAnchor ?? 0) +
|
(query.data.value?.keyframes.missingIdentityAnchor ?? 0) +
|
||||||
(query.data.value?.keyframes.identityUnlocked ?? 0)
|
(query.data.value?.keyframes.identityUnlocked ?? 0)
|
||||||
)
|
)
|
||||||
|
const staleKeyframes = computed(() => query.data.value?.keyframes.stalePrimaryKeyframe ?? 0)
|
||||||
|
|
||||||
/** 父页查询包含整个项目的就绪统计,单镜头变更后立即刷新。 */
|
/** 父页查询包含整个项目的就绪统计,单镜头变更后立即刷新。 */
|
||||||
function refreshProject() {
|
function refreshProject() {
|
||||||
void query.refresh()
|
void query.refresh()
|
||||||
}
|
}
|
||||||
|
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
|
||||||
|
const overview = ref<string[]>([])
|
||||||
|
watch(
|
||||||
|
() => session.value.receipt,
|
||||||
|
receipt => {
|
||||||
|
if (receipt) overview.value = ['overview']
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="mt-7">
|
<WorkspacePage split
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
><template #header
|
||||||
<div>
|
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
<h2 class="text-lg font-semibold">镜头生产</h2>
|
|
||||||
<p class="mt-2 text-sm text-muted">从可生成的提示词和首帧,到异步视频任务与最终成片。</p>
|
|
||||||
</div>
|
|
||||||
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button">
|
|
||||||
<ArrowLeft :size="14" />回到分镜设计
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
<p class="alert mt-5 text-xs">
|
|
||||||
每一步都按后端就绪检查执行。覆盖只会新增首帧/视频候选,不会替换当前主资产;上游参考资产变化后,下游过期结果会被标记并阻止继续使用,需要重新生成后才能恢复就绪。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section class="panel mt-5 p-5" aria-label="项目生产配置">
|
|
||||||
<div class="production-controls">
|
|
||||||
<label>
|
|
||||||
<span class="field-label">当前剧集</span>
|
|
||||||
<select
|
|
||||||
:value="episodeNo"
|
|
||||||
class="input"
|
|
||||||
aria-label="选择生产剧集"
|
|
||||||
@change="selectedEpisode = Number(($event.target as HTMLSelectElement).value)"
|
|
||||||
>
|
|
||||||
<option v-if="!episodeOptions.length" :value="0">暂无剧集</option>
|
|
||||||
<option v-for="episode in episodeOptions" :key="episode.episodeNo" :value="episode.episodeNo">
|
|
||||||
第 {{ episode.episodeNo }} 集 · {{ episode.title }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span class="field-label">批量并发</span>
|
|
||||||
<input
|
|
||||||
v-model.number="concurrency"
|
|
||||||
class="input"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
:disabled="operation.pending"
|
|
||||||
/>
|
|
||||||
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 pb-3 text-xs">
|
|
||||||
<input
|
|
||||||
v-model="force"
|
|
||||||
type="checkbox"
|
|
||||||
class="accent-accent"
|
|
||||||
:disabled="operation.pending"
|
|
||||||
/>覆盖模式:为已有结果新增候选
|
|
||||||
</label>
|
|
||||||
<button class="text-button mb-2 ml-auto" :disabled="query.loading.value" @click="query.refresh">
|
|
||||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="mt-3 text-[11px] leading-6 text-muted">
|
|
||||||
批量操作始终面向整个项目,不受当前剧集选择影响;当前剧集只控制下方逐镜检查。Seedream 与 Seedance
|
|
||||||
均可能产生费用。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
|
||||||
{{ query.error.value }}<span v-if="query.data.value"> 当前保留上次读取的数据,生产操作已暂停。</span>
|
|
||||||
</p>
|
|
||||||
<p v-if="videoRunning" class="alert mt-4" role="status">
|
|
||||||
当前有 {{ videoRunning }} 个视频任务等待或生成中。后端正在轮询 Provider;同一镜头不会重复提交活动任务。
|
|
||||||
</p>
|
|
||||||
<p v-if="identityBlocked" class="alert mt-4">
|
|
||||||
当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
|
|
||||||
Identity、确认演员母版并锁定后,首帧才能就绪。
|
|
||||||
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2">前往角色选角 →</RouterLink>
|
|
||||||
</p>
|
|
||||||
<p v-if="staleKeyframes" class="alert mt-4" role="status">
|
|
||||||
当前有 {{ staleKeyframes }} 个主首帧已过期。它们使用的主体参考资产已经变化,视频阶段会被阻止;可在下方逐镜重新生成,或使用“补齐 / 更新主首帧”批量处理。
|
|
||||||
</p>
|
|
||||||
<p v-if="staleVideos" class="alert mt-4" role="status">
|
|
||||||
当前有 {{ staleVideos }} 个主视频已过期。它们使用的主首帧或主体参考图已经变化;非覆盖模式批量生成时会只重建这些过期主视频。
|
|
||||||
</p>
|
|
||||||
<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>
|
|
||||||
<span class="font-mono text-xs text-muted">{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">{{ stage.description }}</p>
|
|
||||||
<ProgressRoot
|
|
||||||
:model-value="stage.done"
|
|
||||||
:max="Math.max(stage.data?.total ?? 0, 1)"
|
|
||||||
:aria-label="`${stage.title}完成数量`"
|
|
||||||
class="progress-track mt-4"
|
|
||||||
>
|
|
||||||
<ProgressIndicator
|
|
||||||
class="progress-fill"
|
|
||||||
:style="{
|
|
||||||
width: `${stage.data?.total ? Math.min(100, (stage.done / stage.data.total) * 100) : 0}%`
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</ProgressRoot>
|
|
||||||
<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.data?.ready ?? 0)"
|
|
||||||
acknowledgement
|
|
||||||
:description="
|
|
||||||
stage.key === 'videos'
|
|
||||||
? staleVideos
|
|
||||||
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
|
|
||||||
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
|
|
||||||
: stage.key === 'keyframes'
|
|
||||||
? '处理缺失主首帧和参考资产已变化的过期主首帧。过期镜头会按当前主体参考资产重新生成并恢复为可用于视频的主首帧;覆盖模式只新增候选。'
|
|
||||||
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
|
||||||
"
|
|
||||||
:primary="stage.key === 'videos'"
|
|
||||||
@confirm="run(stage.key)"
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
<h3 class="text-sm font-semibold">视频任务状态</h3>
|
<h2 class="text-lg font-semibold">镜头生产</h2>
|
||||||
<p class="mt-2 text-xs text-muted">状态统计取每个镜头最近一次任务,不等同于主视频数量。</p>
|
<p class="mt-2 text-sm text-muted">从可生成的提示词和首帧,到异步视频任务与最终成片。</p>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmAction
|
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button">
|
||||||
label="重试失败视频"
|
<ArrowLeft :size="14" />回到分镜设计
|
||||||
:disabled="blocked || !batchValid || !query.data.value.videoStatus.failed"
|
</RouterLink>
|
||||||
acknowledgement
|
|
||||||
description="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
|
|
||||||
@confirm="run('retry-videos')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<dl class="production-status-grid mt-4">
|
<section class="mt-3" aria-label="项目生产配置">
|
||||||
<div><dt>完成</dt><dd>{{ query.data.value.videoStatus.completed }}</dd></div>
|
<div class="production-controls">
|
||||||
<div><dt>排队/生成</dt><dd>{{ videoRunning }}</dd></div>
|
<label>
|
||||||
<div><dt>失败</dt><dd>{{ query.data.value.videoStatus.failed }}</dd></div>
|
<span class="field-label">当前剧集</span>
|
||||||
<div><dt>取消</dt><dd>{{ query.data.value.videoStatus.cancelled }}</dd></div>
|
<NSelect
|
||||||
<div><dt>未开始</dt><dd>{{ query.data.value.videoStatus.notStarted }}</dd></div>
|
:value="episodeNo"
|
||||||
</dl>
|
aria-label="选择生产剧集"
|
||||||
</div>
|
@update:value="selectedEpisode = Number($event)"
|
||||||
|
:options="[
|
||||||
|
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
|
||||||
|
...episodeOptions.map(episode => ({
|
||||||
|
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
|
||||||
|
value: episode.episodeNo
|
||||||
|
}))
|
||||||
|
]"
|
||||||
|
class="select-control"
|
||||||
|
></NSelect>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span class="field-label">批量并发</span>
|
||||||
|
<NInputNumber
|
||||||
|
:disabled="operation.pending"
|
||||||
|
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||||
|
@update:value="concurrency = $event ?? 0"
|
||||||
|
:min="1"
|
||||||
|
:step="1"
|
||||||
|
></NInputNumber>
|
||||||
|
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
|
||||||
|
</label>
|
||||||
|
<NCheckbox
|
||||||
|
v-model:checked="force"
|
||||||
|
:disabled="operation.pending"
|
||||||
|
class="flex items-center gap-2 pb-3 text-xs"
|
||||||
|
>覆盖模式:为已有结果新增候选
|
||||||
|
</NCheckbox>
|
||||||
|
<NButton
|
||||||
|
:disabled="query.loading.value"
|
||||||
|
@click="query.refresh"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="mb-2 ml-auto"
|
||||||
|
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-[11px] leading-6 text-muted">
|
||||||
|
提示词与视频批量操作面向全项目;首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式新增全项目候选。Seedream
|
||||||
|
与 Seedance 均可能产生费用。
|
||||||
|
</p>
|
||||||
|
</section></template
|
||||||
|
>
|
||||||
|
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||||
|
>{{ query.error.value
|
||||||
|
}}<span v-if="query.data.value"> 当前保留上次读取的数据,生产操作已暂停。</span></NAlert
|
||||||
|
>
|
||||||
|
<NCollapse v-model:expanded-names="overview" class="workspace-overview"
|
||||||
|
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||||||
|
><div class="overview-content">
|
||||||
|
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||||
|
每一步都按后端就绪检查执行。覆盖只会新增首帧/视频候选,不会替换当前主资产;上游参考资产变化后,下游过期结果会被标记并阻止继续使用,需要重新生成后才能恢复就绪。
|
||||||
|
</NAlert>
|
||||||
|
<NAlert v-if="videoRunning" role="status" type="info" :show-icon="false" 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">
|
||||||
|
<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>
|
||||||
|
<span class="font-mono text-xs text-muted"
|
||||||
|
>{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">{{ stage.description }}</p>
|
||||||
|
<NProgress
|
||||||
|
:aria-label="`${stage.title}完成数量`"
|
||||||
|
type="line"
|
||||||
|
:show-indicator="false"
|
||||||
|
:height="4"
|
||||||
|
:percentage="
|
||||||
|
Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
(stage.done / Math.max(1, Math.max(stage.data?.total ?? 0, 1))) * 100
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="mt-4"
|
||||||
|
></NProgress>
|
||||||
|
<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.data?.ready ?? 0)"
|
||||||
|
acknowledgement
|
||||||
|
:description="
|
||||||
|
stage.key === 'videos'
|
||||||
|
? staleVideos && !force
|
||||||
|
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
|
||||||
|
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
|
||||||
|
: stage.key === 'keyframes'
|
||||||
|
? '处理缺失主首帧和参考资产已变化的过期主首帧。过期镜头会按当前主体参考资产重新生成并恢复为可用于视频的主首帧;覆盖模式只新增候选。'
|
||||||
|
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
||||||
|
"
|
||||||
|
:primary="stage.key === 'videos'"
|
||||||
|
@confirm="run(stage.key)"
|
||||||
|
/>
|
||||||
|
</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="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
|
||||||
|
@confirm="run('retry-videos')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<dl class="production-status-grid mt-4">
|
||||||
|
<div>
|
||||||
|
<dt>完成</dt>
|
||||||
|
<dd>{{ query.data.value.videoStatus.completed }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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 class="my-6 flex flex-wrap items-center justify-between gap-4">
|
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold">第 {{ episodeNo || '—' }} 集 · 逐镜生产</h3>
|
<h3 class="text-sm font-semibold">第 {{ episodeNo || '—' }} 集 · 逐镜生产</h3>
|
||||||
@@ -276,35 +328,43 @@ function refreshProject() {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="shot" class="panel production-workspace">
|
<div v-if="shot" class="panel production-workspace">
|
||||||
<nav class="production-shot-list" aria-label="本集生产镜头">
|
<nav class="production-shot-list" aria-label="本集生产镜头">
|
||||||
<button
|
<NButton
|
||||||
v-for="item in shots"
|
v-for="item in shots"
|
||||||
:key="item.shotId"
|
:key="item.shotId"
|
||||||
class="production-shot-link"
|
|
||||||
:class="{ selected: item.shotId === shot.shotId }"
|
|
||||||
:aria-current="item.shotId === shot.shotId ? 'true' : undefined"
|
:aria-current="item.shotId === shot.shotId ? 'true' : undefined"
|
||||||
@click="selectedShot = item.shotId"
|
@click="selectedShot = item.shotId"
|
||||||
>
|
class="production-shot-link"
|
||||||
<span class="font-mono text-[10px] text-muted">B{{ item.beatNo }} / S{{ item.shotNo }}</span>
|
:class="{ selected: item.shotId === shot.shotId }"
|
||||||
<span class="mt-1 block truncate text-xs font-medium">{{ item.title }}</span>
|
><span class="font-mono text-[10px] text-muted">B{{ item.beatNo }} / S{{ item.shotNo }}</span
|
||||||
<span
|
><span class="mt-1 block truncate text-xs font-medium">{{ item.title }}</span
|
||||||
v-if="query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)?.primaryKeyframeStale"
|
><span
|
||||||
|
v-if="
|
||||||
|
query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
|
||||||
|
?.primaryKeyframeStale
|
||||||
|
"
|
||||||
class="mt-2 flex items-center gap-2 text-[10px]"
|
class="mt-2 flex items-center gap-2 text-[10px]"
|
||||||
>
|
>
|
||||||
<CircleAlert :size="12" />主首帧已过期
|
<CircleAlert :size="12" />主首帧已过期 </span
|
||||||
</span>
|
><span
|
||||||
<span
|
v-else-if="
|
||||||
v-else-if="query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.primaryVideoStale"
|
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.primaryVideoStale
|
||||||
|
"
|
||||||
class="mt-2 flex items-center gap-2 text-[10px]"
|
class="mt-2 flex items-center gap-2 text-[10px]"
|
||||||
>
|
>
|
||||||
<CircleAlert :size="12" />主视频已过期
|
<CircleAlert :size="12" />主视频已过期 </span
|
||||||
</span>
|
><span v-else class="mt-2 flex items-center gap-2 text-[10px] text-muted">
|
||||||
<span v-else class="mt-2 flex items-center gap-2 text-[10px] text-muted">
|
|
||||||
<CheckCircle2
|
<CheckCircle2
|
||||||
v-if="query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status === 'skipped'"
|
v-if="
|
||||||
|
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
|
||||||
|
'skipped'
|
||||||
|
"
|
||||||
:size="12"
|
:size="12"
|
||||||
/>
|
/>
|
||||||
<CircleAlert
|
<CircleAlert
|
||||||
v-else-if="query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status === 'blocked'"
|
v-else-if="
|
||||||
|
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
|
||||||
|
'blocked'
|
||||||
|
"
|
||||||
:size="12"
|
:size="12"
|
||||||
/>
|
/>
|
||||||
{{
|
{{
|
||||||
@@ -313,8 +373,8 @@ function refreshProject() {
|
|||||||
'not_started'
|
'not_started'
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</span>
|
</span></NButton
|
||||||
</button>
|
>
|
||||||
</nav>
|
</nav>
|
||||||
<article class="min-w-0 p-5 lg:p-7">
|
<article class="min-w-0 p-5 lg:p-7">
|
||||||
<header class="mb-5 border-b border-line pb-5">
|
<header class="mb-5 border-b border-line pb-5">
|
||||||
@@ -331,12 +391,24 @@ function refreshProject() {
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p>
|
<p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p>
|
||||||
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
|
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
|
||||||
<p v-if="keyframeItem?.primaryKeyframeStale" class="alert mt-4 text-xs" role="status">
|
<NAlert
|
||||||
|
v-if="keyframeItem?.primaryKeyframeStale"
|
||||||
|
role="status"
|
||||||
|
type="info"
|
||||||
|
:show-icon="false"
|
||||||
|
class="mt-4 text-xs"
|
||||||
|
>
|
||||||
主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。
|
主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。
|
||||||
</p>
|
</NAlert>
|
||||||
<p v-if="videoItem?.primaryVideoStale" class="alert mt-4 text-xs" role="status">
|
<NAlert
|
||||||
|
v-if="videoItem?.primaryVideoStale"
|
||||||
|
role="status"
|
||||||
|
type="info"
|
||||||
|
:show-icon="false"
|
||||||
|
class="mt-4 text-xs"
|
||||||
|
>
|
||||||
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
|
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
|
||||||
</p>
|
</NAlert>
|
||||||
<ul v-if="selectedIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
|
<ul v-if="selectedIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
|
||||||
<li v-for="(issue, index) in selectedIssues" :key="`${issue.code}-${index}`">
|
<li v-for="(issue, index) in selectedIssues" :key="`${issue.code}-${index}`">
|
||||||
{{ issueLabel(issue.code) }}:{{ issue.reason }}
|
{{ issueLabel(issue.code) }}:{{ issue.reason }}
|
||||||
@@ -364,6 +436,6 @@ function refreshProject() {
|
|||||||
</EmptyState>
|
</EmptyState>
|
||||||
<p v-else-if="query.loading.value" class="panel mt-5 p-8 text-sm text-muted" role="status">
|
<p v-else-if="query.loading.value" class="panel mt-5 p-8 text-sm text-muted" role="status">
|
||||||
正在检查全项目生产就绪状态……
|
正在检查全项目生产就绪状态……
|
||||||
</p>
|
</p></WorkspacePage
|
||||||
</section>
|
>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NCheckbox, NInputNumber } 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 { validOptionalSize } from '../model'
|
import { validOptionalSize } from '../model'
|
||||||
@@ -50,37 +51,43 @@ function submit() {
|
|||||||
:title="`${replacePrimary ? '重新生成主首帧' : '生成首帧'} · ${shotTitle}`"
|
:title="`${replacePrimary ? '重新生成主首帧' : '生成首帧'} · ${shotTitle}`"
|
||||||
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 Seedream。"
|
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 Seedream。"
|
||||||
>
|
>
|
||||||
<p v-if="replacePrimary" class="alert mt-5 text-xs">
|
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
|
||||||
当前主首帧使用的主体参考资产已经变化。新图成功后应设为主首帧,旧图会继续保留为历史候选。
|
当前主首帧使用的主体参考资产已经变化。新图成功后应设为主首帧,旧图会继续保留为历史候选。
|
||||||
</p>
|
</NAlert>
|
||||||
<div class="mt-5 grid grid-cols-2 gap-4">
|
<div class="mt-5 grid grid-cols-2 gap-4">
|
||||||
<label
|
<label
|
||||||
><span class="field-label">宽度(可选)</span
|
><span class="field-label">宽度(可选)</span
|
||||||
><input v-model.number="width" class="input" type="number" min="1"
|
><NInputNumber
|
||||||
/></label>
|
:value="typeof width === 'number' ? width : null"
|
||||||
|
@update:value="width = $event ?? ''"
|
||||||
|
:min="1"
|
||||||
|
></NInputNumber
|
||||||
|
></label>
|
||||||
<label
|
<label
|
||||||
><span class="field-label">高度(可选)</span
|
><span class="field-label">高度(可选)</span
|
||||||
><input v-model.number="height" class="input" type="number" min="1"
|
><NInputNumber
|
||||||
/></label>
|
:value="typeof height === 'number' ? height : null"
|
||||||
|
@update:value="height = $event ?? ''"
|
||||||
|
:min="1"
|
||||||
|
></NInputNumber
|
||||||
|
></label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!sizeValid" class="mt-2 text-xs text-danger">宽高需要同时留空,或同时填写正整数。</p>
|
<p v-if="!sizeValid" class="mt-2 text-xs text-danger">宽高需要同时留空,或同时填写正整数。</p>
|
||||||
<label class="mt-5 flex items-start gap-3 text-sm leading-6">
|
<NCheckbox v-model:checked="setPrimary" class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||||
<input v-model="setPrimary" class="mt-1 accent-accent" type="checkbox" />生成成功后设为当前主首帧
|
>生成成功后设为当前主首帧
|
||||||
</label>
|
</NCheckbox>
|
||||||
<p v-if="hasPrimary && setPrimary" class="alert mt-3 text-xs">
|
<NAlert v-if="hasPrimary && setPrimary" type="info" :show-icon="false" class="mt-3 text-xs">
|
||||||
当前已有主首帧。新图成功后将成为视频生成使用的首帧,旧图仍保留为候选。
|
当前已有主首帧。新图成功后将成为视频生成使用的首帧,旧图仍保留为候选。
|
||||||
</p>
|
</NAlert>
|
||||||
<label class="mt-5 flex items-start gap-3 text-sm leading-6">
|
<NCheckbox
|
||||||
<input
|
id="confirm-keyframe-cost"
|
||||||
id="confirm-keyframe-cost"
|
v-model:checked="confirmed"
|
||||||
v-model="confirmed"
|
class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||||
class="mt-1 accent-accent"
|
>我已确认调用 Seedream 可能产生费用,且当前没有相同镜头的生图任务。
|
||||||
type="checkbox"
|
</NCheckbox>
|
||||||
/>我已确认调用 Seedream 可能产生费用,且当前没有相同镜头的生图任务。
|
|
||||||
</label>
|
|
||||||
<div class="dialog-footer mt-6">
|
<div class="dialog-footer mt-6">
|
||||||
<button class="button button-secondary" @click="open = false">取消</button>
|
<NButton @click="open = false">取消</NButton>
|
||||||
<button class="button button-primary" :disabled="!canSubmit" @click="submit">确认生成首帧</button>
|
<NButton :disabled="!canSubmit" @click="submit" type="primary">确认生成首帧</NButton>
|
||||||
</div>
|
</div>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton } from 'naive-ui'
|
||||||
import { Download } from '@lucide/vue'
|
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'
|
||||||
@@ -16,7 +17,7 @@ function exportReceipt() {
|
|||||||
<section class="panel mt-4 p-5" aria-label="镜头生产回执">
|
<section class="panel mt-4 p-5" aria-label="镜头生产回执">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-medium">{{ receipt.title }} · 本次回执</h3>
|
<h3 class="text-sm font-medium">{{ receipt.title }} · 本次回执</h3>
|
||||||
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
|
<NButton @click="exportReceipt" text size="small"><Download :size="13" />导出诊断</NButton>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="receipt.kind === 'prompts'" class="mt-3 text-xs text-muted">
|
<p v-if="receipt.kind === 'prompts'" class="mt-3 text-xs text-muted">
|
||||||
全项目 {{ receipt.result.total }} 镜 · 本次目标 {{ receipt.result.targetCount }} · 已生成
|
全项目 {{ receipt.result.total }} 镜 · 本次目标 {{ receipt.result.targetCount }} · 已生成
|
||||||
@@ -36,12 +37,18 @@ function exportReceipt() {
|
|||||||
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败
|
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败
|
||||||
{{ receipt.result.failed }}
|
{{ receipt.result.failed }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="receipt.kind === 'videos' && receipt.result.created" class="alert mt-3" role="status">
|
<NAlert
|
||||||
|
v-if="receipt.kind === 'videos' && receipt.result.created"
|
||||||
|
role="status"
|
||||||
|
type="info"
|
||||||
|
:show-icon="false"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
视频任务已经创建,但尚未代表成片完成。后台会继续查询 Seedance,以下方任务状态和视频资产为准。
|
视频任务已经创建,但尚未代表成片完成。后台会继续查询 Seedance,以下方任务状态和视频资产为准。
|
||||||
</p>
|
</NAlert>
|
||||||
<p v-if="receipt.result.failed" class="alert alert-error mt-3" role="alert">
|
<NAlert v-if="receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
|
||||||
本次存在部分失败,成功提交或生成的结果不会回滚。请查看逐镜错误后再重试。
|
本次存在部分失败,成功提交或生成的结果不会回滚。请查看逐镜错误后再重试。
|
||||||
</p>
|
</NAlert>
|
||||||
<ul v-if="receipt.result.failures.length" class="mt-3 space-y-2 text-xs text-danger">
|
<ul v-if="receipt.result.failures.length" class="mt-3 space-y-2 text-xs text-danger">
|
||||||
<li v-for="failure in receipt.result.failures" :key="failure.shotId">
|
<li v-for="failure in receipt.result.failures" :key="failure.shotId">
|
||||||
<code>{{ failure.shotId }}</code
|
<code>{{ failure.shotId }}</code
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ExternalLink, FileSearch, ImagePlus, RefreshCw } from '@lucide/vue'
|
import { ExternalLink, FileSearch, ImagePlus, RefreshCw } from '@lucide/vue'
|
||||||
import { AssetImage, StatusBadge } from '../../../components/ui'
|
import { AssetImage, StatusBadge } from '../../../components/ui'
|
||||||
@@ -189,18 +190,18 @@ async function inspectSpecs() {
|
|||||||
<p class="mt-1 text-[11px] text-muted">记录每次候选与失败,主资产决定下一阶段实际输入。</p>
|
<p class="mt-1 text-[11px] text-muted">记录每次候选与失败,主资产决定下一阶段实际输入。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button class="text-button" :disabled="inspecting" @click="inspectSpecs">
|
<NButton :disabled="inspecting" @click="inspectSpecs" text size="small"
|
||||||
<FileSearch :size="13" />{{ inspecting ? '检查中…' : '检查生成规格' }}
|
><FileSearch :size="13" />{{ inspecting ? '检查中…' : '检查生成规格' }}</NButton
|
||||||
</button>
|
>
|
||||||
<button class="text-button" :disabled="query.loading.value" @click="refreshAll">
|
<NButton :disabled="query.loading.value" @click="refreshAll" text size="small"
|
||||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新资产
|
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新资产
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||||
{{ query.error.value }} 当前保留上次成功读取的资产,操作已暂停。
|
>{{ query.error.value }} 当前保留上次成功读取的资产,操作已暂停。
|
||||||
</p>
|
</NAlert>
|
||||||
<p v-if="specError" class="alert alert-error mt-4" role="alert">{{ specError }}</p>
|
<NAlert v-if="specError" role="alert" type="error" :show-icon="false" class="mt-4">{{ specError }}</NAlert>
|
||||||
|
|
||||||
<div class="production-stage mt-5">
|
<div class="production-stage mt-5">
|
||||||
<div class="production-stage-heading">
|
<div class="production-stage-heading">
|
||||||
@@ -246,9 +247,9 @@ async function inspectSpecs() {
|
|||||||
:label="keyframeStale ? '主首帧已过期' : productionStatusLabel(keyframeReadiness.status)"
|
:label="keyframeStale ? '主首帧已过期' : productionStatusLabel(keyframeReadiness.status)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="keyframeStale" class="alert mt-3 text-xs" role="status">
|
<NAlert v-if="keyframeStale" role="status" type="info" :show-icon="false" class="mt-3 text-xs">
|
||||||
当前主首帧使用的主体参考资产已经变化,旧图仍保留但不会继续进入视频生成。请重新生成主首帧。
|
当前主首帧使用的主体参考资产已经变化,旧图仍保留但不会继续进入视频生成。请重新生成主首帧。
|
||||||
</p>
|
</NAlert>
|
||||||
<ul v-if="keyframeReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
|
<ul v-if="keyframeReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
|
||||||
<li v-for="issue in keyframeReadiness.issues" :key="issue.code">
|
<li v-for="issue in keyframeReadiness.issues" :key="issue.code">
|
||||||
{{ issueLabel(issue.code) }}:{{ issue.reason }}
|
{{ issueLabel(issue.code) }}:{{ issue.reason }}
|
||||||
@@ -264,34 +265,44 @@ async function inspectSpecs() {
|
|||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
|
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
|
||||||
<span v-if="item.isPrimary" class="tag">主首帧</span>
|
<NTag v-if="item.isPrimary" size="small" :bordered="false">主首帧</NTag>
|
||||||
<span v-if="item.isPrimary && keyframeStale" class="tag">已过期</span>
|
<NTag v-if="item.isPrimary && keyframeStale" size="small" :bordered="false">已过期</NTag>
|
||||||
<span class="text-[10px] text-muted">{{ item.width || '—' }} × {{ item.height || '—' }}</span>
|
<span class="text-[10px] text-muted"
|
||||||
|
>{{ item.width || '—' }} × {{ item.height || '—' }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="item.error" class="mt-2 line-clamp-3 text-xs text-danger">{{ item.error }}</p>
|
<p v-if="item.error" class="mt-2 line-clamp-3 text-xs text-danger">{{ item.error }}</p>
|
||||||
<p class="mt-2 text-[10px] text-muted">{{ formatDate(item.createdAt) }}</p>
|
<p class="mt-2 text-[10px] text-muted">{{ formatDate(item.createdAt) }}</p>
|
||||||
<button
|
<NButton
|
||||||
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
|
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
|
||||||
class="text-button mt-2"
|
|
||||||
:disabled="assetBlocked"
|
:disabled="assetBlocked"
|
||||||
@click="confirmingKeyframe = item.id"
|
@click="confirmingKeyframe = item.id"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="mt-2"
|
||||||
>
|
>
|
||||||
设为主首帧
|
设为主首帧
|
||||||
</button>
|
</NButton>
|
||||||
<div v-if="confirmingKeyframe === item.id" class="alert mt-2 text-xs">
|
<NAlert
|
||||||
<p>确认让后续视频使用此首帧?已有视频不会自动重生成。</p>
|
v-if="confirmingKeyframe === item.id"
|
||||||
|
type="info"
|
||||||
|
:show-icon="false"
|
||||||
|
class="mt-2 text-xs"
|
||||||
|
><p>确认让后续视频使用此首帧?已有视频不会自动重生成。</p>
|
||||||
<div class="mt-2 flex gap-3">
|
<div class="mt-2 flex gap-3">
|
||||||
<button class="button button-primary" @click="setPrimaryKeyframe">确认切换</button>
|
<NButton @click="setPrimaryKeyframe" type="primary">确认切换</NButton>
|
||||||
<button class="text-button" @click="confirmingKeyframe = ''">取消</button>
|
<NButton @click="confirmingKeyframe = ''" text size="small">取消</NButton>
|
||||||
</div>
|
</div></NAlert
|
||||||
</div>
|
>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="mt-4 text-xs text-muted">尚无首帧记录。</p>
|
<p v-else class="mt-4 text-xs text-muted">尚无首帧记录。</p>
|
||||||
<button class="button button-secondary mt-4" :disabled="!canGenerateKeyframe" @click="keyframeOpen = true">
|
<NButton :disabled="!canGenerateKeyframe" @click="keyframeOpen = true" class="mt-4"
|
||||||
<ImagePlus :size="14" />{{ keyframeStale ? '重新生成主首帧' : currentKeyframe ? '再生成一张候选' : '生成首帧' }}
|
><ImagePlus :size="14" />{{
|
||||||
</button>
|
keyframeStale ? '重新生成主首帧' : currentKeyframe ? '再生成一张候选' : '生成首帧'
|
||||||
|
}}</NButton
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="production-stage">
|
<div class="production-stage">
|
||||||
@@ -306,9 +317,9 @@ async function inspectSpecs() {
|
|||||||
:label="videoStale ? '主视频已过期' : productionStatusLabel(videoReadiness.status)"
|
:label="videoStale ? '主视频已过期' : productionStatusLabel(videoReadiness.status)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="videoStale" class="alert mt-3 text-xs" role="status">
|
<NAlert v-if="videoStale" role="status" type="info" :show-icon="false" class="mt-3 text-xs">
|
||||||
当前主视频使用的主首帧或主体参考图已经变化。旧视频仍保留;重新生成完成后,新视频会自动接替过期主视频。
|
当前主视频使用的主首帧或主体参考图已经变化。旧视频仍保留;重新生成完成后,新视频会自动接替过期主视频。
|
||||||
</p>
|
</NAlert>
|
||||||
<ul v-if="videoReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
|
<ul v-if="videoReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
|
||||||
<li v-for="issue in videoReadiness.issues" :key="issue.code">
|
<li v-for="issue in videoReadiness.issues" :key="issue.code">
|
||||||
{{ issueLabel(issue.code) }}:{{ issue.reason }}
|
{{ issueLabel(issue.code) }}:{{ issue.reason }}
|
||||||
@@ -326,11 +337,15 @@ async function inspectSpecs() {
|
|||||||
<div class="min-w-0 flex-1 p-4">
|
<div class="min-w-0 flex-1 p-4">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
|
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
|
||||||
<span v-if="item.isPrimary" class="tag">主视频</span>
|
<NTag v-if="item.isPrimary" size="small" :bordered="false">主视频</NTag>
|
||||||
<span v-if="item.isPrimary && videoStale" class="tag">已过期</span>
|
<NTag v-if="item.isPrimary && videoStale" size="small" :bordered="false">已过期</NTag>
|
||||||
<span class="text-[10px] text-muted">{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span>
|
<span class="text-[10px] text-muted"
|
||||||
|
>{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="item.error" class="alert alert-error mt-3" role="alert">{{ item.error }}</p>
|
<NAlert v-if="item.error" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||||||
|
item.error
|
||||||
|
}}</NAlert>
|
||||||
<p class="mt-3 break-all text-[10px] text-muted">
|
<p class="mt-3 break-all text-[10px] text-muted">
|
||||||
{{ item.provider }} · {{ item.model }} · {{ formatDate(item.createdAt) }}
|
{{ item.provider }} · {{ item.model }} · {{ formatDate(item.createdAt) }}
|
||||||
</p>
|
</p>
|
||||||
@@ -341,24 +356,25 @@ async function inspectSpecs() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-button"
|
class="text-button"
|
||||||
>打开视频<ExternalLink :size="12" /></a
|
>打开视频<ExternalLink :size="12"
|
||||||
>
|
/></a>
|
||||||
<button
|
<NButton
|
||||||
v-if="item.status === 'completed' && item.videoUrl && !item.isPrimary"
|
v-if="item.status === 'completed' && item.videoUrl && !item.isPrimary"
|
||||||
class="text-button"
|
|
||||||
:disabled="assetBlocked"
|
:disabled="assetBlocked"
|
||||||
@click="confirmingVideo = item.id"
|
@click="confirmingVideo = item.id"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
设为主视频
|
设为主视频
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="confirmingVideo === item.id" class="alert mt-3 text-xs">
|
<NAlert v-if="confirmingVideo === item.id" type="info" :show-icon="false" class="mt-3 text-xs"
|
||||||
<p>确认把此候选设为当前镜头主视频?旧成片仍会保留。</p>
|
><p>确认把此候选设为当前镜头主视频?旧成片仍会保留。</p>
|
||||||
<div class="mt-2 flex gap-3">
|
<div class="mt-2 flex gap-3">
|
||||||
<button class="button button-primary" @click="setPrimaryVideo">确认切换</button>
|
<NButton @click="setPrimaryVideo" type="primary">确认切换</NButton>
|
||||||
<button class="text-button" @click="confirmingVideo = ''">取消</button>
|
<NButton @click="confirmingVideo = ''" text size="small">取消</NButton>
|
||||||
</div>
|
</div></NAlert
|
||||||
</div>
|
>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,46 +394,43 @@ async function inspectSpecs() {
|
|||||||
primary
|
primary
|
||||||
@confirm="generateVideo"
|
@confirm="generateVideo"
|
||||||
/>
|
/>
|
||||||
<button
|
<NButton v-if="activeVideo" :disabled="assetBlocked" @click="refreshActiveVideo"
|
||||||
v-if="activeVideo"
|
><RefreshCw :size="14" />刷新任务状态
|
||||||
class="button button-secondary"
|
</NButton>
|
||||||
:disabled="assetBlocked"
|
|
||||||
@click="refreshActiveVideo"
|
|
||||||
>
|
|
||||||
<RefreshCw :size="14" />刷新任务状态
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details v-if="keyframeSpec || videoSpec" class="mt-5 border-t border-line pt-4 text-xs">
|
<NCollapse v-if="keyframeSpec || videoSpec" class="mt-5 border-t border-line pt-4 text-xs"
|
||||||
<summary class="cursor-pointer font-medium">本镜确定性生成规格</summary>
|
><NCollapseItem name="details"
|
||||||
<div class="mt-4 grid gap-5 lg:grid-cols-2">
|
><template #header>本镜确定性生成规格</template>
|
||||||
<div v-if="keyframeSpec">
|
<div class="mt-4 grid gap-5 lg:grid-cols-2">
|
||||||
<h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5>
|
<div v-if="keyframeSpec">
|
||||||
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p>
|
<h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5>
|
||||||
<ul v-if="keyframeSpec.references.length" class="mt-3 space-y-1 text-[11px] text-muted">
|
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p>
|
||||||
<li
|
<ul v-if="keyframeSpec.references.length" class="mt-3 space-y-1 text-[11px] text-muted">
|
||||||
v-for="reference in keyframeSpec.references"
|
<li
|
||||||
:key="`${reference.role}:${reference.imageId}`"
|
v-for="reference in keyframeSpec.references"
|
||||||
>
|
:key="`${reference.role}:${reference.imageId}`"
|
||||||
{{ reference.subjectRef }} ·
|
>
|
||||||
{{ reference.role === 'identity-anchor' ? '演员身份母版' : '形态主图' }} ·
|
{{ reference.subjectRef }} ·
|
||||||
<code>{{ reference.imageId }}</code>
|
{{ reference.role === 'identity-anchor' ? '演员身份母版' : '形态主图' }} ·
|
||||||
</li>
|
<code>{{ reference.imageId }}</code>
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
<div v-if="videoSpec">
|
</div>
|
||||||
<h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5>
|
<div v-if="videoSpec">
|
||||||
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ videoSpec.videoPrompt }}</p>
|
<h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5>
|
||||||
<p v-if="videoSpec.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6 text-muted">
|
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ videoSpec.videoPrompt }}</p>
|
||||||
Negative prompt: {{ videoSpec.negativePrompt }}
|
<p v-if="videoSpec.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6 text-muted">
|
||||||
</p>
|
Negative prompt: {{ videoSpec.negativePrompt }}
|
||||||
<p class="mt-3 break-all font-mono text-[10px] text-muted">
|
</p>
|
||||||
Keyframe ID · {{ videoSpec.keyframe.id }}
|
<p class="mt-3 break-all font-mono text-[10px] text-muted">
|
||||||
</p>
|
Keyframe ID · {{ videoSpec.keyframe.id }}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</div></NCollapseItem
|
||||||
|
></NCollapse
|
||||||
|
>
|
||||||
<KeyframeDialog
|
<KeyframeDialog
|
||||||
v-model:open="keyframeOpen"
|
v-model:open="keyframeOpen"
|
||||||
:shot-id="shot.shotId"
|
:shot-id="shot.shotId"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 从 Reka Portal 中查找精确按钮。 */
|
/** 从 Naive 弹窗中查找精确按钮。 */
|
||||||
function button(label: string): HTMLButtonElement {
|
function button(label: string): HTMLButtonElement {
|
||||||
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
|
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
|
||||||
if (!item) throw new Error(`缺少按钮 ${label}`)
|
if (!item) throw new Error(`缺少按钮 ${label}`)
|
||||||
|
|||||||
@@ -1,76 +1,85 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, provide } from 'vue'
|
import { computed, provide } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import { NAlert, NButton, NPageHeader, NSpin, NTab, NTabs, NEllipsis } from 'naive-ui'
|
||||||
ArrowLeft,
|
import { RefreshCw } from '@lucide/vue'
|
||||||
RefreshCw,
|
|
||||||
FileText,
|
|
||||||
Layers,
|
|
||||||
Camera,
|
|
||||||
Images,
|
|
||||||
Palette,
|
|
||||||
Fingerprint,
|
|
||||||
LoaderCircle,
|
|
||||||
Clapperboard
|
|
||||||
} from '@lucide/vue'
|
|
||||||
import { StatusBadge } from '../../components/ui'
|
import { StatusBadge } from '../../components/ui'
|
||||||
import { projectContextKey, useProjectData } from './context'
|
import { projectContextKey, useProjectData } from './context'
|
||||||
import { getOperation } from '../workflows/operations'
|
import { getOperation } from '../workflows/operations'
|
||||||
|
|
||||||
/** 项目级数据与操作状态跨 graph 页面共享。 */
|
/** 项目共享查询保持不变,标题与导航固定,子工作区负责内容滚动。 */
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const id = computed(() => String(route.params.projectId))
|
const id = computed(() => String(route.params.projectId))
|
||||||
const context = useProjectData(id)
|
const context = useProjectData(id)
|
||||||
provide(projectContextKey, context)
|
provide(projectContextKey, context)
|
||||||
const operation = computed(() => getOperation(id.value))
|
const operation = computed(() => getOperation(id.value))
|
||||||
|
const tabs = [
|
||||||
|
['create-drama', '剧本创作'],
|
||||||
|
['breakdown', '剧本拆解'],
|
||||||
|
['visual-style', '视觉风格'],
|
||||||
|
['subject-identity', '主体身份'],
|
||||||
|
['subject-images', '形态图片'],
|
||||||
|
['storyboard', '分镜设计'],
|
||||||
|
['production', '镜头生产']
|
||||||
|
] as const
|
||||||
|
/** 路由控制当前工作流,不额外缓存一份可能失步的标签状态。 */
|
||||||
|
function switchWorkflow(path: string) {
|
||||||
|
void router.push('/projects/' + id.value + '/' + path)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-container">
|
<section class="project-frame">
|
||||||
<RouterLink to="/projects" class="back-link"><ArrowLeft :size="14" />全部剧本</RouterLink>
|
<header class="project-header">
|
||||||
<div class="page-heading mt-5">
|
<NPageHeader @back="router.push('/projects')">
|
||||||
<div class="min-w-0">
|
<template #title
|
||||||
<p class="eyebrow">项目工作台</p>
|
><NEllipsis class="project-title">{{
|
||||||
<h1 class="break-words">
|
context.project.value?.title || context.project.value?.topic || '读取项目'
|
||||||
{{ context.project.value?.title || context.project.value?.topic || '读取项目' }}
|
}}</NEllipsis></template
|
||||||
</h1>
|
|
||||||
<p class="page-description">{{ context.project.value?.style || '剧本与拆解结果' }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex shrink-0 items-center gap-3">
|
|
||||||
<StatusBadge v-if="context.project.value" :status="context.project.value.status" /><button
|
|
||||||
class="button button-secondary"
|
|
||||||
:disabled="context.loading.value"
|
|
||||||
@click="context.refresh"
|
|
||||||
>
|
>
|
||||||
<RefreshCw :size="14" :class="{ 'animate-spin': context.loading.value }" /><span
|
<template #extra
|
||||||
class="hidden sm:inline"
|
><div class="flex items-center gap-3">
|
||||||
>刷新</span
|
<StatusBadge v-if="context.project.value" :status="context.project.value.status" /><NButton
|
||||||
>
|
size="small"
|
||||||
</button>
|
:loading="context.loading.value"
|
||||||
</div>
|
@click="context.refresh"
|
||||||
|
><template #icon><RefreshCw :size="14" /></template>刷新</NButton
|
||||||
|
>
|
||||||
|
</div></template
|
||||||
|
>
|
||||||
|
</NPageHeader>
|
||||||
|
<NTabs
|
||||||
|
:value="String(route.path.split('/').at(-1))"
|
||||||
|
type="line"
|
||||||
|
size="small"
|
||||||
|
class="project-tabs"
|
||||||
|
aria-label="项目工作流"
|
||||||
|
@update:value="switchWorkflow"
|
||||||
|
>
|
||||||
|
<NTab v-for="[path, label] in tabs" :key="path" :name="path">{{ label }}</NTab>
|
||||||
|
</NTabs>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
v-if="context.error.value || operation.pending || operation.error || operation.notice"
|
||||||
|
class="project-notices"
|
||||||
|
>
|
||||||
|
<NAlert v-if="context.error.value" type="error" :show-icon="false"
|
||||||
|
>{{ context.error.value
|
||||||
|
}}<span v-if="context.project.value"> 当前保留上次成功读取的数据。</span></NAlert
|
||||||
|
>
|
||||||
|
<NAlert v-if="operation.pending" :show-icon="false" role="status"
|
||||||
|
>{{ operation.label }}。可切换页面查看结果,请勿重复提交;关闭页面不会取消后端任务。</NAlert
|
||||||
|
>
|
||||||
|
<NAlert v-if="operation.error" type="error" :show-icon="false">{{ operation.error }}</NAlert>
|
||||||
|
<NAlert v-if="operation.notice" :show-icon="false" role="status">{{ operation.notice }}</NAlert>
|
||||||
</div>
|
</div>
|
||||||
<nav class="workflow-nav" aria-label="项目工作流">
|
<div class="project-view">
|
||||||
<RouterLink :to="`/projects/${id}/create-drama`"><FileText :size="17" />剧本创作</RouterLink>
|
<RouterView v-if="context.project.value" :key="id" />
|
||||||
<RouterLink :to="`/projects/${id}/breakdown`"><Layers :size="17" />剧本拆解</RouterLink>
|
<NSpin
|
||||||
<RouterLink :to="`/projects/${id}/visual-style`"><Palette :size="17" />视觉风格</RouterLink>
|
v-else-if="context.loading.value"
|
||||||
<RouterLink :to="`/projects/${id}/subject-identity`"><Fingerprint :size="17" />主体身份</RouterLink>
|
class="workspace-loading"
|
||||||
<RouterLink :to="`/projects/${id}/subject-images`"><Images :size="17" />形态图片</RouterLink>
|
description="正在读取项目和工作流记录…"
|
||||||
<RouterLink :to="`/projects/${id}/storyboard`"><Camera :size="17" />分镜设计</RouterLink>
|
/>
|
||||||
<RouterLink :to="`/projects/${id}/production`"><Clapperboard :size="17" />镜头生产</RouterLink>
|
|
||||||
</nav>
|
|
||||||
<p v-if="context.error.value" class="alert alert-error mt-4" role="alert">
|
|
||||||
{{ context.error.value }}<span v-if="context.project.value"> 当前保留上次成功读取的数据。</span>
|
|
||||||
</p>
|
|
||||||
<p v-if="operation.pending" class="alert mt-4 flex items-center gap-2" role="status">
|
|
||||||
<LoaderCircle :size="16" class="shrink-0 animate-spin" />{{
|
|
||||||
operation.label
|
|
||||||
}}。可切换页面查看结果,请勿重复提交或关闭浏览器;关闭页面不会取消后端任务。
|
|
||||||
</p>
|
|
||||||
<p v-if="operation.error" class="alert alert-error mt-4" role="alert">{{ operation.error }}</p>
|
|
||||||
<p v-if="operation.notice" class="alert mt-4" role="status">{{ operation.notice }}</p>
|
|
||||||
<RouterView v-if="context.project.value" :key="id" />
|
|
||||||
<div v-else-if="context.loading.value" class="py-12 text-sm text-muted" role="status">
|
|
||||||
正在读取项目和工作流记录……
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,162 +1,134 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, h, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import { ArrowUpRight, Search, RefreshCw, Clapperboard } from '@lucide/vue'
|
import { NAlert, NButton, NDataTable, NInput, NRadioButton, NRadioGroup, type DataTableColumns } from 'naive-ui'
|
||||||
|
import { RefreshCw, Search } from '@lucide/vue'
|
||||||
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
import { usePolling } from '../../composables/usePolling'
|
import { usePolling } from '../../composables/usePolling'
|
||||||
import { EmptyState, 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'
|
||||||
|
import type { Project } from './types'
|
||||||
import CreateProjectDialog from './components/CreateProjectDialog.vue'
|
import CreateProjectDialog from './components/CreateProjectDialog.vue'
|
||||||
|
|
||||||
/** 项目索引:真实查询、客户端筛选,以及进入两条 graph 的入口。 */
|
/** 项目表格使用固定表头与独立滚动;搜索筛选仍作用于真实接口数据。 */
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const query = usePolling(ref('projects'), (_, signal) => projectsApi.list(signal), 12_000)
|
const query = usePolling(ref('projects'), (_, signal) => projectsApi.list(signal), 12_000)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const filter = ref('all')
|
const filter = ref('all')
|
||||||
|
const page = ref(1)
|
||||||
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 => {
|
||||||
const matches = `${item.title ?? ''} ${item.topic} ${item.style ?? ''}`
|
const matches = ((item.title ?? '') + ' ' + item.topic + ' ' + (item.style ?? ''))
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(search.value.toLowerCase().trim())
|
.includes(search.value.trim().toLowerCase())
|
||||||
return matches && (filter.value === 'all' || item.status === filter.value)
|
return matches && (filter.value === 'all' || item.status === filter.value)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
const filters = [
|
||||||
/** 202 后直接打开工作流,后续刷新由项目布局负责。 */
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'generating', label: '生成中' },
|
||||||
|
{ value: 'completed', label: '已完成' },
|
||||||
|
{ value: 'need_review', label: '待审核' },
|
||||||
|
{ value: 'failed', label: '失败' }
|
||||||
|
]
|
||||||
|
watch([search, filter], () => {
|
||||||
|
page.value = 1
|
||||||
|
})
|
||||||
|
/** 表格列通过正式项目 ID 跳转,不依赖列表索引。 */
|
||||||
|
const columns: DataTableColumns<Project> = [
|
||||||
|
{
|
||||||
|
title: '剧本名称',
|
||||||
|
key: 'title',
|
||||||
|
width: 300,
|
||||||
|
render: row =>
|
||||||
|
h(
|
||||||
|
RouterLink,
|
||||||
|
{ to: '/projects/' + encodeURIComponent(row.id) + '/create-drama', class: 'project-name-cell' },
|
||||||
|
() => [h('strong', row.title || row.topic), h('small', row.topic)]
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ title: '风格', key: 'style', width: 200, ellipsis: { tooltip: true }, render: row => row.style || '未设置' },
|
||||||
|
{ title: '状态', key: 'status', width: 120, render: row => h(StatusBadge, { status: row.status }) },
|
||||||
|
{ title: '最近更新', key: 'updatedAt', width: 165, render: row => formatDate(row.updatedAt) }
|
||||||
|
]
|
||||||
|
/** 创建回执只用于跳转,生成进度以后端查询为准。 */
|
||||||
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'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-container">
|
<WorkspacePage split>
|
||||||
<div class="page-heading">
|
<template #header>
|
||||||
<div>
|
<div class="page-heading project-index-heading">
|
||||||
<p class="eyebrow">工作空间 / 项目</p>
|
<div>
|
||||||
<h1>我的剧本</h1>
|
<h1>我的剧本</h1>
|
||||||
<p class="page-description">从故事到镜头,在这里继续你的创作。</p>
|
<p class="page-description">从故事到镜头,继续你的创作。</p>
|
||||||
</div>
|
|
||||||
<CreateProjectDialog @created="openProject" />
|
|
||||||
</div>
|
|
||||||
<div class="workspace-note">
|
|
||||||
<Clapperboard :size="20" :stroke-width="1.5" /><span
|
|
||||||
>剧本创作 <span class="mx-3 text-faint">/</span> 主体拆解
|
|
||||||
<span class="mx-3 text-faint">/</span> 分镜规划</span
|
|
||||||
><span class="ml-auto hidden text-xs text-muted sm:block">两个工作流,一个项目</span>
|
|
||||||
</div>
|
|
||||||
<div class="toolbar mt-7">
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<button
|
|
||||||
v-for="item in [
|
|
||||||
{ value: 'all', label: '全部项目' },
|
|
||||||
{ value: 'generating', label: '生成中' },
|
|
||||||
{ value: 'completed', label: '已完成' },
|
|
||||||
{ value: 'need_review', label: '待审核' },
|
|
||||||
{ value: 'failed', label: '失败' }
|
|
||||||
]"
|
|
||||||
:key="item.value"
|
|
||||||
class="filter-button"
|
|
||||||
:class="{ active: filter === item.value }"
|
|
||||||
:aria-pressed="filter === item.value"
|
|
||||||
@click="filter = item.value"
|
|
||||||
>
|
|
||||||
{{ item.label
|
|
||||||
}}<span v-if="item.value === 'all'" class="ml-2 text-muted">{{ projects.length }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="search-field">
|
|
||||||
<Search :size="15" /><input v-model="search" aria-label="搜索项目" placeholder="搜索剧本" />
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<CreateProjectDialog @created="openProject" />
|
||||||
class="icon-button"
|
|
||||||
aria-label="刷新项目"
|
|
||||||
:disabled="query.loading.value"
|
|
||||||
@click="query.refresh"
|
|
||||||
>
|
|
||||||
<RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
<div class="project-index-toolbar">
|
||||||
{{ query.error.value }}<button class="ml-3 underline" @click="query.refresh">重新连接</button>
|
<NRadioGroup v-model:value="filter" size="small" aria-label="项目状态筛选"
|
||||||
</div>
|
><NRadioButton v-for="item in filters" :key="item.value" :value="item.value">{{
|
||||||
<div class="panel mt-4 overflow-hidden" :aria-busy="query.loading.value">
|
item.label
|
||||||
<div v-if="!query.data.value && query.loading.value" class="p-10 text-sm text-muted" role="status">
|
}}</NRadioButton></NRadioGroup
|
||||||
正在读取项目……
|
|
||||||
</div>
|
|
||||||
<div v-else-if="filtered.length" class="table-scroll">
|
|
||||||
<table class="project-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>剧本名称</th>
|
|
||||||
<th>风格</th>
|
|
||||||
<th>创作状态</th>
|
|
||||||
<th>最近更新</th>
|
|
||||||
<th><span class="sr-only">打开项目</span></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="project in filtered" :key="project.id">
|
|
||||||
<td>
|
|
||||||
<RouterLink :to="`/projects/${project.id}/create-drama`" class="project-name"
|
|
||||||
><span class="project-monogram">{{
|
|
||||||
(project.title || project.topic).slice(0, 1)
|
|
||||||
}}</span
|
|
||||||
><span class="min-w-0"
|
|
||||||
><strong class="block truncate font-medium">{{
|
|
||||||
project.title || project.topic
|
|
||||||
}}</strong
|
|
||||||
><span class="mt-1 block max-w-md truncate text-xs text-muted">{{
|
|
||||||
project.topic
|
|
||||||
}}</span></span
|
|
||||||
></RouterLink
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">{{ project.style || '未设置' }}</td>
|
|
||||||
<td><StatusBadge :status="project.status" /></td>
|
|
||||||
<td class="whitespace-nowrap text-xs text-muted">{{ formatDate(project.updatedAt) }}</td>
|
|
||||||
<td>
|
|
||||||
<RouterLink
|
|
||||||
:to="`/projects/${project.id}/create-drama`"
|
|
||||||
class="icon-button"
|
|
||||||
:aria-label="`打开 ${project.title || project.topic}`"
|
|
||||||
><ArrowUpRight :size="17"
|
|
||||||
/></RouterLink>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<EmptyState
|
|
||||||
v-else-if="query.data.value"
|
|
||||||
:title="projects.length ? '没有匹配的剧本' : '第一部故事,从这里开始'"
|
|
||||||
:description="
|
|
||||||
projects.length
|
|
||||||
? '试试其他关键词,或切换项目状态。'
|
|
||||||
: '新建一个剧本,生成角色与剧集;完成后,再将故事拆解为主体和分镜。'
|
|
||||||
"
|
|
||||||
><button v-if="projects.length" class="button button-secondary" @click="clearFilters">
|
|
||||||
清除筛选
|
|
||||||
</button></EmptyState
|
|
||||||
>
|
>
|
||||||
<EmptyState
|
<div class="flex items-center gap-2">
|
||||||
v-else
|
<NInput
|
||||||
title="等待连接后端"
|
v-model:value="search"
|
||||||
description="启动后端服务并检查 API_PROXY_TARGET,连接成功后会显示你已有的项目。"
|
clearable
|
||||||
/>
|
placeholder="搜索剧本"
|
||||||
|
class="project-search"
|
||||||
|
:input-props="{ 'aria-label': '搜索项目' }"
|
||||||
|
><template #prefix><Search :size="15" /></template
|
||||||
|
></NInput>
|
||||||
|
<NButton :loading="query.loading.value" aria-label="刷新项目" @click="query.refresh"
|
||||||
|
><RefreshCw :size="16"
|
||||||
|
/></NButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-4 text-xs text-muted">
|
<NAlert v-if="query.error.value" type="error" :show-icon="false"
|
||||||
项目数据来自后端数据库<span v-if="query.updatedAt.value">
|
>{{ query.error.value }}<NButton text class="ml-3" @click="query.refresh">重新连接</NButton></NAlert
|
||||||
|
>
|
||||||
|
<div class="project-table-area">
|
||||||
|
<NDataTable
|
||||||
|
class="project-data-table"
|
||||||
|
:columns="columns"
|
||||||
|
:data="filtered"
|
||||||
|
:row-key="row => row.id"
|
||||||
|
:loading="query.loading.value"
|
||||||
|
flex-height
|
||||||
|
table-layout="fixed"
|
||||||
|
:scroll-x="785"
|
||||||
|
:pagination="{ page, pageSize: 20, onUpdatePage: (value: number) => (page = value) }"
|
||||||
|
>
|
||||||
|
<template #empty
|
||||||
|
><div>
|
||||||
|
{{
|
||||||
|
query.error.value
|
||||||
|
? '等待连接后端'
|
||||||
|
: projects.length
|
||||||
|
? '没有匹配的剧本'
|
||||||
|
: '还没有剧本,点击右上角新建'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<NButton v-if="search || filter !== 'all'" text class="mt-3" @click="clearFilters"
|
||||||
|
>清除筛选</NButton
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
</NDataTable>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
共 {{ filtered.length }} 个项目<span v-if="query.updatedAt.value">
|
||||||
· 更新于 {{ formatDate(query.updatedAt.value) }}</span
|
· 更新于 {{ formatDate(query.updatedAt.value) }}</span
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</WorkspacePage>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NInput, NInputNumber } from 'naive-ui'
|
||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref } from 'vue'
|
||||||
import { DialogTrigger } from 'reka-ui'
|
|
||||||
import { ArrowRight, LoaderCircle, Plus } from '@lucide/vue'
|
import { ArrowRight, LoaderCircle, Plus } from '@lucide/vue'
|
||||||
import { AppDialog } from '../../../components/ui'
|
import { AppDialog } from '../../../components/ui'
|
||||||
import { projectsApi } from '../api'
|
import { projectsApi } from '../api'
|
||||||
@@ -47,58 +47,54 @@ async function submit() {
|
|||||||
:busy="busy"
|
:busy="busy"
|
||||||
>
|
>
|
||||||
<template #trigger
|
<template #trigger
|
||||||
><DialogTrigger class="button button-primary"><Plus :size="16" />新建剧本</DialogTrigger></template
|
><NButton @click="open = true" type="primary"><Plus :size="16" />新建剧本</NButton></template
|
||||||
>
|
>
|
||||||
<form class="mt-7 space-y-5" @submit.prevent="submit">
|
<form class="mt-7 space-y-5" @submit.prevent="submit">
|
||||||
<div>
|
<div>
|
||||||
<label class="field-label" for="topic">故事主题 <span class="text-accent">*</span></label
|
<label class="field-label" for="topic">故事主题 <span class="text-accent">*</span></label
|
||||||
><textarea
|
><NInput
|
||||||
id="topic"
|
|
||||||
v-model="form.topic"
|
|
||||||
class="input min-h-32 resize-y"
|
|
||||||
placeholder="描述主角、故事背景,以及你希望展开的核心冲突……"
|
placeholder="描述主角、故事背景,以及你希望展开的核心冲突……"
|
||||||
required
|
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
></textarea>
|
:input-props="{ id: 'topic', required: true }"
|
||||||
|
class="min-h-32 resize-y"
|
||||||
|
v-model:value="form.topic"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
|
></NInput>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-[1fr_110px] gap-4">
|
<div class="grid grid-cols-[1fr_110px] gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="field-label" for="style">剧本风格</label
|
<label class="field-label" for="style">剧本风格</label
|
||||||
><input
|
><NInput
|
||||||
id="style"
|
|
||||||
v-model="form.style"
|
|
||||||
class="input"
|
|
||||||
placeholder="如:都市悬疑、爽文反转"
|
placeholder="如:都市悬疑、爽文反转"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
/>
|
:input-props="{ id: 'style' }"
|
||||||
|
v-model:value="form.style"
|
||||||
|
></NInput>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="field-label" for="episode-count">计划集数</label
|
<label class="field-label" for="episode-count">计划集数</label
|
||||||
><input
|
><NInputNumber
|
||||||
id="episode-count"
|
|
||||||
v-model.number="form.episodeCount"
|
|
||||||
class="input"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
required
|
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
/>
|
:input-props="{ id: 'episode-count', required: true }"
|
||||||
|
:value="typeof form.episodeCount === 'number' ? form.episodeCount : null"
|
||||||
|
@update:value="form.episodeCount = $event ?? 0"
|
||||||
|
:min="1"
|
||||||
|
:step="1"
|
||||||
|
></NInputNumber>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs leading-5 text-muted">
|
<p class="text-xs leading-5 text-muted">
|
||||||
建议先用 3 集验证生成效果。生成会实际调用后端模型,产生相应费用。
|
建议先用 3 集验证生成效果。生成会实际调用后端模型,产生相应费用。
|
||||||
</p>
|
</p>
|
||||||
<p v-if="error" class="alert alert-error" role="alert">{{ error }}</p>
|
<NAlert v-if="error" role="alert" type="error" :show-icon="false" class=" ">{{ error }}</NAlert>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<button type="button" class="button button-secondary" :disabled="busy" @click="open = false">
|
<NButton :disabled="busy" @click="open = false"> 取消</NButton
|
||||||
取消</button
|
><NButton :disabled="busy" type="primary" attr-type="submit"
|
||||||
><button type="submit" class="button button-primary" :disabled="busy">
|
><LoaderCircle v-if="busy" :size="16" class="animate-spin" />开始生成<ArrowRight
|
||||||
<LoaderCircle v-if="busy" :size="16" class="animate-spin" />开始生成<ArrowRight
|
|
||||||
v-if="!busy"
|
v-if="!busy"
|
||||||
:size="16"
|
:size="16"
|
||||||
/>
|
/></NButton>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent, ProgressRoot, ProgressIndicator } from 'reka-ui'
|
import {
|
||||||
|
NAlert,
|
||||||
|
NButton,
|
||||||
|
NCheckbox,
|
||||||
|
NCollapse,
|
||||||
|
NCollapseItem,
|
||||||
|
NInputNumber,
|
||||||
|
NProgress,
|
||||||
|
NSelect,
|
||||||
|
NTab,
|
||||||
|
NTabs
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ArrowLeft, Download, RefreshCw } from '@lucide/vue'
|
import { ArrowLeft, Download, RefreshCw } from '@lucide/vue'
|
||||||
import { EmptyState } from '../../components/ui'
|
import { EmptyState } from '../../components/ui'
|
||||||
import { downloadText } from '../../lib/format'
|
import { downloadText } from '../../lib/format'
|
||||||
@@ -10,6 +22,8 @@ import ShotDesign from './components/ShotDesign.vue'
|
|||||||
import ShotTools from './components/ShotTools.vue'
|
import ShotTools from './components/ShotTools.vue'
|
||||||
import { useStoryboard } from './useStoryboard'
|
import { useStoryboard } from './useStoryboard'
|
||||||
|
|
||||||
|
const detailTab = ref('design')
|
||||||
|
|
||||||
/** 分镜工作区按单集查看正式数据,生成配置与镜头检查分开。 */
|
/** 分镜工作区按单集查看正式数据,生成配置与镜头检查分开。 */
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
@@ -65,167 +79,197 @@ function exportEpisode() {
|
|||||||
'application/json'
|
'application/json'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
/** 回执到达后打开诊断区;平时留出镜头检查空间。 */
|
||||||
|
const overview = ref<string[]>([])
|
||||||
|
watch(
|
||||||
|
() => session.value.receipt,
|
||||||
|
receipt => {
|
||||||
|
if (receipt) overview.value = ['generation']
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-7">
|
<WorkspacePage split class="storyboard-workspace-page"
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
><template #header
|
||||||
<div>
|
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
<h2 class="text-lg font-semibold">分镜设计</h2>
|
<div>
|
||||||
<p class="mt-2 text-sm text-muted">从镜头拆解到摄影设计,再补齐每一镜的即时状态。</p>
|
<h2 class="text-lg font-semibold">分镜设计</h2>
|
||||||
|
<p class="mt-2 text-sm text-muted">从镜头拆解到摄影设计,再补齐每一镜的即时状态。</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink :to="`/projects/${id}/breakdown`" class="text-button"
|
||||||
|
><ArrowLeft :size="14" />回到拆解</RouterLink
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink :to="`/projects/${id}/breakdown`" class="text-button"
|
<section class="mt-3" aria-label="分镜生成配置">
|
||||||
><ArrowLeft :size="14" />回到拆解</RouterLink
|
<div class="storyboard-controls">
|
||||||
>
|
<label
|
||||||
</div>
|
><span class="field-label">当前剧集</span
|
||||||
<section class="panel mt-5 p-5" aria-label="分镜生成配置">
|
><NSelect
|
||||||
<div class="storyboard-controls">
|
:value="episodeNo"
|
||||||
<label
|
aria-label="选择分镜剧集"
|
||||||
><span class="field-label">当前剧集</span
|
@update:value="selectedEpisode = Number($event)"
|
||||||
><select
|
:options="[
|
||||||
:value="episodeNo"
|
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
|
||||||
class="input"
|
...episodeOptions.map(episode => ({
|
||||||
aria-label="选择分镜剧集"
|
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
|
||||||
@change="selectedEpisode = Number(($event.target as HTMLSelectElement).value)"
|
value: episode.episodeNo
|
||||||
|
}))
|
||||||
|
]"
|
||||||
|
class="select-control"
|
||||||
|
></NSelect
|
||||||
|
></label>
|
||||||
|
<label
|
||||||
|
><span class="field-label">批量并发</span
|
||||||
|
><NInputNumber
|
||||||
|
:disabled="operation.pending"
|
||||||
|
:input-props="{ id: 'storyboard-concurrency' }"
|
||||||
|
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||||
|
@update:value="concurrency = $event ?? 0"
|
||||||
|
:min="1"
|
||||||
|
:step="1"
|
||||||
|
></NInputNumber
|
||||||
|
><span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span></label
|
||||||
>
|
>
|
||||||
<option v-if="!episodeOptions.length" :value="0">暂无剧集</option>
|
<label
|
||||||
<option v-for="episode in episodeOptions" :key="episode.episodeNo" :value="episode.episodeNo">
|
><span class="field-label">状态自动修复次数</span
|
||||||
第 {{ episode.episodeNo }} 集 · {{ episode.title }}
|
><NInputNumber
|
||||||
</option>
|
:disabled="operation.pending"
|
||||||
</select></label
|
:input-props="{ id: 'storyboard-repairs' }"
|
||||||
>
|
:value="typeof maxRepairAttempts === 'number' ? maxRepairAttempts : null"
|
||||||
<label
|
@update:value="maxRepairAttempts = $event ?? 0"
|
||||||
><span class="field-label">批量并发</span
|
:min="0"
|
||||||
><input
|
:step="1"
|
||||||
id="storyboard-concurrency"
|
></NInputNumber
|
||||||
v-model.number="concurrency"
|
><span v-if="!repairsValid" class="mt-1 block text-xs text-danger"
|
||||||
class="input"
|
>请输入非负整数,0 表示不修复</span
|
||||||
type="number"
|
></label
|
||||||
min="1"
|
>
|
||||||
step="1"
|
<NCheckbox
|
||||||
|
v-model:checked="force"
|
||||||
:disabled="operation.pending"
|
:disabled="operation.pending"
|
||||||
/><span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span></label
|
class="flex items-center gap-2 pb-3 text-xs"
|
||||||
>
|
>批量覆盖已有结果</NCheckbox
|
||||||
<label
|
>
|
||||||
><span class="field-label">状态自动修复次数</span
|
</div>
|
||||||
><input
|
</section></template
|
||||||
id="storyboard-repairs"
|
>
|
||||||
v-model.number="maxRepairAttempts"
|
<NCollapse v-model:expanded-names="overview" class="workspace-overview"
|
||||||
class="input"
|
><NCollapseItem name="generation" title="生成配置、批量操作与回执"
|
||||||
type="number"
|
><div class="overview-content">
|
||||||
min="0"
|
<div class="generation-row">
|
||||||
step="1"
|
<div>
|
||||||
:disabled="operation.pending"
|
<h3 class="text-sm font-semibold">
|
||||||
/><span v-if="!repairsValid" class="mt-1 block text-xs text-danger"
|
<span class="mr-2 font-mono text-xs text-muted">01</span>导演摄影设计
|
||||||
>请输入非负整数,0 表示不修复</span
|
</h3>
|
||||||
></label
|
<p class="mt-2 text-xs leading-6 text-muted">
|
||||||
>
|
景别、机位、运镜与构图。单集操作会重新生成并覆盖;批量默认跳过完整剧集。
|
||||||
<label class="flex items-center gap-2 pb-3 text-xs"
|
</p>
|
||||||
><input
|
</div>
|
||||||
v-model="force"
|
<div class="flex flex-wrap gap-2">
|
||||||
type="checkbox"
|
<ConfirmAction
|
||||||
class="accent-accent"
|
label="生成本集导演设计"
|
||||||
:disabled="operation.pending"
|
:disabled="!allowed['direction-one']"
|
||||||
/>批量覆盖已有结果</label
|
acknowledgement
|
||||||
>
|
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。"
|
||||||
</div>
|
@confirm="run('direction-one')"
|
||||||
<div class="generation-row">
|
/><ConfirmAction
|
||||||
<div>
|
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'"
|
||||||
<h3 class="text-sm font-semibold">
|
:disabled="!allowed['direction-all']"
|
||||||
<span class="mr-2 font-mono text-xs text-muted">01</span>导演摄影设计
|
acknowledgement
|
||||||
</h3>
|
:description="
|
||||||
<p class="mt-2 text-xs leading-6 text-muted">
|
force
|
||||||
景别、机位、运镜与构图。单集操作会重新生成并覆盖;批量默认跳过完整剧集。
|
? '重新生成项目全部剧集的导演设计,覆盖已有结果。'
|
||||||
|
: '对整个项目生成导演设计,跳过已完整保存的剧集,可用于重试失败或未完成的剧集。'
|
||||||
|
"
|
||||||
|
@confirm="run('direction-all')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="generation-row">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold">
|
||||||
|
<span class="mr-2 font-mono text-xs text-muted">02</span>即时视觉状态
|
||||||
|
</h3>
|
||||||
|
<p class="mt-2 text-xs leading-6 text-muted">
|
||||||
|
表情、姿态、位置、环境与连续性。每集需要完整导演设计;批量中缺少前置设计的剧集会单独报错。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<ConfirmAction
|
||||||
|
label="生成本集即时状态"
|
||||||
|
:disabled="!allowed['visual-one']"
|
||||||
|
acknowledgement
|
||||||
|
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
|
||||||
|
@confirm="run('visual-one')"
|
||||||
|
/><ConfirmAction
|
||||||
|
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
|
||||||
|
:disabled="!allowed['visual-all']"
|
||||||
|
acknowledgement
|
||||||
|
:description="
|
||||||
|
force
|
||||||
|
? '重新生成整个项目的即时状态,覆盖已有结果。每集必须先有完整导演设计。'
|
||||||
|
: '补齐项目即时状态,跳过完整剧集;未完成导演设计的剧集会返回失败原因。'
|
||||||
|
"
|
||||||
|
@confirm="run('visual-all')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-[11px] leading-6 text-muted">
|
||||||
|
修改或覆盖上游设计后,后端不会自动使旧状态、旧提示词失效;请按顺序重生成下游内容。自动修复次数仅用于
|
||||||
|
VisualState。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<NCollapse class="mt-3 border-t border-line pt-3"
|
||||||
<div class="flex flex-wrap gap-2">
|
><NCollapseItem name="details"
|
||||||
<ConfirmAction
|
><template #header>视频提示词批量操作</template>
|
||||||
label="生成本集导演设计"
|
<p class="my-3 text-xs leading-6 text-muted">
|
||||||
:disabled="!allowed['direction-one']"
|
后端会先校验 GenerationSpec
|
||||||
acknowledgement
|
与主体参考图。这里只生成提示词;首帧、视频任务、就绪诊断与失败重试已整理到
|
||||||
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。"
|
<RouterLink :to="`/projects/${id}/production`" class="text-button mx-1"
|
||||||
@confirm="run('direction-one')"
|
>镜头生产</RouterLink
|
||||||
/><ConfirmAction
|
>。
|
||||||
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'"
|
</p>
|
||||||
:disabled="!allowed['direction-all']"
|
<ConfirmAction
|
||||||
acknowledgement
|
:label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'"
|
||||||
:description="
|
:disabled="!allowed.prompts"
|
||||||
force
|
acknowledgement
|
||||||
? '重新生成项目全部剧集的导演设计,覆盖已有结果。'
|
:description="
|
||||||
: '对整个项目生成导演设计,跳过已完整保存的剧集,可用于重试失败或未完成的剧集。'
|
force
|
||||||
"
|
? '调用模型重新生成全项目镜头的视频提示词,覆盖已有正文。'
|
||||||
@confirm="run('direction-all')"
|
: '调用模型生成缺少视频提示词的镜头,已有提示词直接跳过。参考图缺失会返回失败。'
|
||||||
/>
|
"
|
||||||
</div>
|
@confirm="run('prompts')" /></NCollapseItem
|
||||||
</div>
|
></NCollapse>
|
||||||
<div class="generation-row">
|
<GenerationReceipt v-if="session.receipt" :receipt="session.receipt" /></div></NCollapseItem
|
||||||
<div>
|
></NCollapse>
|
||||||
<h3 class="text-sm font-semibold">
|
<div class="workspace-feedback">
|
||||||
<span class="mr-2 font-mono text-xs text-muted">02</span>即时视觉状态
|
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||||
</h3>
|
>{{ query.error.value }} 当前保留上次成功读取的数据,暂停生成操作。<NButton
|
||||||
<p class="mt-2 text-xs leading-6 text-muted">
|
@click="query.refresh"
|
||||||
表情、姿态、位置、环境与连续性。每集需要完整导演设计;批量中缺少前置设计的剧集会单独报错。
|
text
|
||||||
</p>
|
size="small"
|
||||||
</div>
|
class="ml-3"
|
||||||
<div class="flex flex-wrap gap-2">
|
>
|
||||||
<ConfirmAction
|
重试查询
|
||||||
label="生成本集即时状态"
|
</NButton></NAlert
|
||||||
:disabled="!allowed['visual-one']"
|
><NAlert
|
||||||
acknowledgement
|
v-if="!prerequisites.directionEpisode || !prerequisites.visualEpisode"
|
||||||
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
|
type="info"
|
||||||
@confirm="run('visual-one')"
|
:show-icon="false"
|
||||||
/><ConfirmAction
|
class="mt-4"
|
||||||
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
|
|
||||||
:disabled="!allowed['visual-all']"
|
|
||||||
acknowledgement
|
|
||||||
:description="
|
|
||||||
force
|
|
||||||
? '重新生成整个项目的即时状态,覆盖已有结果。每集必须先有完整导演设计。'
|
|
||||||
: '补齐项目即时状态,跳过完整剧集;未完成导演设计的剧集会返回失败原因。'
|
|
||||||
"
|
|
||||||
@confirm="run('visual-all')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-4 text-[11px] leading-6 text-muted">
|
|
||||||
修改或覆盖上游设计后,后端不会自动使旧状态、旧提示词失效;请按顺序重生成下游内容。自动修复次数仅用于
|
|
||||||
VisualState。
|
|
||||||
</p>
|
|
||||||
<details class="mt-3 border-t border-line pt-3">
|
|
||||||
<summary class="cursor-pointer text-xs font-medium">视频提示词批量操作</summary>
|
|
||||||
<p class="my-3 text-xs leading-6 text-muted">
|
|
||||||
后端会先校验 GenerationSpec
|
|
||||||
与主体参考图。这里只生成提示词;首帧、视频任务、就绪诊断与失败重试已整理到
|
|
||||||
<RouterLink :to="`/projects/${id}/production`" class="text-button mx-1">镜头生产</RouterLink>。
|
|
||||||
</p>
|
|
||||||
<ConfirmAction
|
|
||||||
:label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'"
|
|
||||||
:disabled="!allowed.prompts"
|
|
||||||
acknowledgement
|
|
||||||
:description="
|
|
||||||
force
|
|
||||||
? '调用模型重新生成全项目镜头的视频提示词,覆盖已有正文。'
|
|
||||||
: '调用模型生成缺少视频提示词的镜头,已有提示词直接跳过。参考图缺失会返回失败。'
|
|
||||||
"
|
|
||||||
@confirm="run('prompts')"
|
|
||||||
/>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
|
||||||
{{ query.error.value }} 当前保留上次成功读取的数据,暂停生成操作。<button
|
|
||||||
class="text-button ml-3"
|
|
||||||
@click="query.refresh"
|
|
||||||
>
|
>
|
||||||
重试查询
|
最新 Breakdown
|
||||||
</button>
|
缺少本集生成所需的镜头、主体或形态数据。已保存设计仍可查看;请先在拆解页完成或恢复相应阶段。 </NAlert
|
||||||
</p>
|
><NAlert
|
||||||
<p v-if="!prerequisites.directionEpisode || !prerequisites.visualEpisode" class="alert mt-4">
|
v-else-if="data && !directionComplete && data.directions.shotCount"
|
||||||
最新 Breakdown 缺少本集生成所需的镜头、主体或形态数据。已保存设计仍可查看;请先在拆解页完成或恢复相应阶段。
|
type="info"
|
||||||
</p>
|
:show-icon="false"
|
||||||
<p v-else-if="data && !directionComplete && data.directions.shotCount" class="alert mt-4">
|
class="mt-4"
|
||||||
本集导演设计尚未补齐,请先完成第 01 步,再生成本集即时状态。
|
>
|
||||||
</p>
|
本集导演设计尚未补齐,请先完成第 01 步,再生成本集即时状态。
|
||||||
<GenerationReceipt v-if="session.receipt" :receipt="session.receipt" />
|
</NAlert>
|
||||||
|
</div>
|
||||||
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
|
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold">第 {{ episodeNo || '—' }} 集 · 镜头检查</h3>
|
<h3 class="text-sm font-semibold">第 {{ episodeNo || '—' }} 集 · 镜头检查</h3>
|
||||||
@@ -234,28 +278,27 @@ function exportEpisode() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button class="text-button" :disabled="query.loading.value" @click="query.refresh">
|
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
||||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新分镜</button
|
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新分镜</NButton
|
||||||
><button class="text-button" :disabled="!shots.length" @click="exportEpisode">
|
><NButton :disabled="!shots.length" @click="exportEpisode" text size="small"
|
||||||
<Download :size="13" />导出本集
|
><Download :size="13" />导出本集
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-5 grid gap-4 sm:grid-cols-2">
|
<div class="storyboard-coverage grid grid-cols-2 gap-3">
|
||||||
<div v-for="item in coverage" :key="item.title" class="panel px-5 py-4">
|
<div v-for="item in coverage" :key="item.title" class="panel px-3 py-2">
|
||||||
<div class="mb-3 flex items-center justify-between text-xs">
|
<div class="mb-3 flex items-center justify-between text-xs">
|
||||||
<span>{{ item.title }}</span
|
<span>{{ item.title }}</span
|
||||||
><span class="font-mono text-muted">{{ item.count }} / {{ item.total }} 镜头</span>
|
><span class="font-mono text-muted">{{ item.count }} / {{ item.total }} 镜头</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressRoot
|
<NProgress
|
||||||
:model-value="item.count"
|
|
||||||
:max="Math.max(item.total, 1)"
|
|
||||||
:aria-label="item.title + '已保存覆盖率'"
|
:aria-label="item.title + '已保存覆盖率'"
|
||||||
class="progress-track"
|
type="line"
|
||||||
><ProgressIndicator
|
:show-indicator="false"
|
||||||
class="progress-fill"
|
:height="4"
|
||||||
:style="{ width: (item.total ? Math.min(100, (item.count / item.total) * 100) : 0) + '%' }"
|
:percentage="Math.min(100, Math.max(0, (item.count / Math.max(1, Math.max(item.total, 1))) * 100))"
|
||||||
/></ProgressRoot>
|
class=""
|
||||||
|
></NProgress>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!data && query.loading.value" class="panel p-8 text-sm text-muted" role="status">
|
<div v-if="!data && query.loading.value" class="panel p-8 text-sm text-muted" role="status">
|
||||||
@@ -271,21 +314,20 @@ function exportEpisode() {
|
|||||||
>
|
>
|
||||||
BEAT {{ String(item.beatNo).padStart(2, '0') }}
|
BEAT {{ String(item.beatNo).padStart(2, '0') }}
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<NButton
|
||||||
class="shot-link"
|
|
||||||
:class="{ selected: shot.shotId === item.shotId }"
|
|
||||||
:aria-current="shot.shotId === item.shotId ? 'true' : undefined"
|
:aria-current="shot.shotId === item.shotId ? 'true' : undefined"
|
||||||
@click="selectedShot = item.shotId"
|
@click="selectedShot = item.shotId"
|
||||||
>
|
class="shot-link"
|
||||||
<span class="font-mono text-[10px] text-muted">{{ String(item.shotNo).padStart(2, '0') }}</span
|
:class="{ selected: shot.shotId === item.shotId }"
|
||||||
|
><span class="font-mono text-[10px] text-muted">{{ String(item.shotNo).padStart(2, '0') }}</span
|
||||||
><span class="min-w-0"
|
><span class="min-w-0"
|
||||||
><span class="block truncate text-xs font-medium">{{ item.title }}</span
|
><span class="block truncate text-xs font-medium">{{ item.title }}</span
|
||||||
><span class="mt-1 block text-[10px] text-muted"
|
><span class="mt-1 block text-[10px] text-muted"
|
||||||
>{{ item.direction ? '设计已保存' : '待设计' }} ·
|
>{{ item.direction ? '设计已保存' : '待设计' }} ·
|
||||||
{{ item.visualState ? '状态已保存' : '待状态' }}</span
|
{{ item.visualState ? '状态已保存' : '待状态' }}</span
|
||||||
></span
|
></span
|
||||||
>
|
></NButton
|
||||||
</button></template
|
></template
|
||||||
>
|
>
|
||||||
</nav>
|
</nav>
|
||||||
<article class="min-w-0 p-5 lg:p-7">
|
<article class="min-w-0 p-5 lg:p-7">
|
||||||
@@ -306,14 +348,15 @@ function exportEpisode() {
|
|||||||
标题与剧情描述取自最近可用 Breakdown 快照;以下设计与状态取自数据库。
|
标题与剧情描述取自最近可用 Breakdown 快照;以下设计与状态取自数据库。
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
<TabsRoot default-value="design"
|
<div>
|
||||||
><TabsList class="tabs-list" aria-label="镜头详情"
|
<NTabs v-model:value="detailTab" type="line" size="small" aria-label="镜头详情"
|
||||||
><TabsTrigger value="design" class="tab-trigger">设计与状态</TabsTrigger
|
><NTab name="design">设计与状态</NTab><NTab name="tools">参考图与生成规格</NTab></NTabs
|
||||||
><TabsTrigger value="tools" class="tab-trigger">参考图与生成规格</TabsTrigger></TabsList
|
>
|
||||||
><TabsContent value="design" class="pt-6"><ShotDesign :shot="shot" /></TabsContent
|
<div v-if="detailTab === 'design'" class="pt-6"><ShotDesign :shot="shot" /></div>
|
||||||
><TabsContent value="tools" class="pt-6"
|
<div v-if="detailTab === 'tools'" class="pt-6">
|
||||||
><ShotTools :key="shot.shotId" :project-id="id" :shot="shot" :disabled="blocked" /></TabsContent
|
<ShotTools :key="shot.shotId" :project-id="id" :shot="shot" :disabled="blocked" />
|
||||||
></TabsRoot>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -323,6 +366,6 @@ function exportEpisode() {
|
|||||||
><RouterLink :to="`/projects/${id}/breakdown`" class="button button-secondary mt-4"
|
><RouterLink :to="`/projects/${id}/breakdown`" class="button button-secondary mt-4"
|
||||||
>前往拆解</RouterLink
|
>前往拆解</RouterLink
|
||||||
></EmptyState
|
></EmptyState
|
||||||
>
|
></WorkspacePage
|
||||||
</div>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NTable } from 'naive-ui'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Download } from '@lucide/vue'
|
import { Download } from '@lucide/vue'
|
||||||
import { downloadText } from '../../../lib/format'
|
import { downloadText } from '../../../lib/format'
|
||||||
@@ -31,19 +32,19 @@ function exportReceipt() {
|
|||||||
<section class="panel mt-5 p-5" aria-label="生成回执">
|
<section class="panel mt-5 p-5" aria-label="生成回执">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-semibold">{{ receipt.title }} · 本次回执</h3>
|
<h3 class="text-sm font-semibold">{{ receipt.title }} · 本次回执</h3>
|
||||||
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
|
<NButton @click="exportReceipt" text size="small"><Download :size="13" />导出诊断</NButton>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="failed" class="alert alert-error mt-3" role="alert">
|
<NAlert v-if="failed" role="alert" type="error" :show-icon="false" class="mt-3">
|
||||||
本次存在失败、校验未通过或未保存项。关闭“批量覆盖已有结果”后再补齐,可跳过已完整保存项。
|
本次存在失败、校验未通过或未保存项。关闭“批量覆盖已有结果”后再补齐,可跳过已完整保存项。
|
||||||
</p>
|
</NAlert>
|
||||||
<template v-if="receipt.kind === 'batch'">
|
<template v-if="receipt.kind === 'batch'">
|
||||||
<p class="my-4 text-xs text-muted">
|
<p class="my-4 text-xs text-muted">
|
||||||
共 {{ receipt.result.episodeCount }} 集 · 完成 {{ receipt.result.completedEpisodes }} · 跳过
|
共 {{ receipt.result.episodeCount }} 集 · 完成 {{ receipt.result.completedEpisodes }} · 跳过
|
||||||
{{ receipt.result.skippedEpisodes }} · 失败 {{ receipt.result.failedEpisodes }}
|
{{ receipt.result.skippedEpisodes }} · 失败 {{ receipt.result.failedEpisodes }}
|
||||||
</p>
|
</p>
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
<table class="project-table">
|
<NTable :single-line="false" class="project-table"
|
||||||
<thead>
|
><thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>剧集</th>
|
<th>剧集</th>
|
||||||
<th>结果</th>
|
<th>结果</th>
|
||||||
@@ -71,8 +72,8 @@ function exportReceipt() {
|
|||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody></NTable
|
||||||
</table>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="receipt.kind === 'prompts'">
|
<template v-else-if="receipt.kind === 'prompts'">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NTag } from 'naive-ui'
|
||||||
import { directionLabel } from '../model'
|
import { directionLabel } from '../model'
|
||||||
import type { DesignedShot } from '../types'
|
import type { DesignedShot } from '../types'
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ defineProps<{ shot: DesignedShot }>()
|
|||||||
</h3>
|
</h3>
|
||||||
<template v-if="shot.direction"
|
<template v-if="shot.direction"
|
||||||
><div class="mb-5 flex flex-wrap gap-2">
|
><div class="mb-5 flex flex-wrap gap-2">
|
||||||
<span
|
<NTag
|
||||||
v-for="field in [
|
v-for="field in [
|
||||||
{ key: 'shotSize', label: '景别' },
|
{ key: 'shotSize', label: '景别' },
|
||||||
{ key: 'cameraAngle', label: '角度' },
|
{ key: 'cameraAngle', label: '角度' },
|
||||||
@@ -23,8 +24,9 @@ defineProps<{ shot: DesignedShot }>()
|
|||||||
{ key: 'depthOfField', label: '景深' }
|
{ key: 'depthOfField', label: '景深' }
|
||||||
] as const"
|
] as const"
|
||||||
:key="field.key"
|
:key="field.key"
|
||||||
class="tag"
|
size="small"
|
||||||
>{{ field.label }} · {{ directionLabel(shot.direction[field.key], field.key) }}</span
|
:bordered="false"
|
||||||
|
>{{ field.label }} · {{ directionLabel(shot.direction[field.key], field.key) }}</NTag
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<dl class="detail-grid">
|
<dl class="detail-grid">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NCheckbox } from 'naive-ui'
|
||||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||||
import { Download, ExternalLink, RefreshCw } from '@lucide/vue'
|
import { Download, ExternalLink, RefreshCw } from '@lucide/vue'
|
||||||
import { storyboardApi } from '../api'
|
import { storyboardApi } from '../api'
|
||||||
@@ -101,20 +102,18 @@ function exportResult(kind: 'spec' | 'prompt') {
|
|||||||
<section>
|
<section>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-semibold">主体参考图</h3>
|
<h3 class="text-sm font-semibold">主体参考图</h3>
|
||||||
<button
|
<NButton :disabled="busy.references || locked" @click="load('references')"
|
||||||
class="button button-secondary"
|
><RefreshCw :size="13" :class="{ 'animate-spin': busy.references }" />{{
|
||||||
:disabled="busy.references || locked"
|
|
||||||
@click="load('references')"
|
|
||||||
>
|
|
||||||
<RefreshCw :size="13" :class="{ 'animate-spin': busy.references }" />{{
|
|
||||||
references ? '刷新参考图' : '读取参考图'
|
references ? '刷新参考图' : '读取参考图'
|
||||||
}}
|
}}</NButton
|
||||||
</button>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs leading-6 text-muted">
|
<p class="mt-2 text-xs leading-6 text-muted">
|
||||||
读取镜头已绑定形态的图片,不生成新图。参考图缺失会影响视频提示词生成。
|
读取镜头已绑定形态的图片,不生成新图。参考图缺失会影响视频提示词生成。
|
||||||
</p>
|
</p>
|
||||||
<p v-if="errors.references" class="alert alert-error mt-3" role="alert">{{ errors.references }}</p>
|
<NAlert v-if="errors.references" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||||||
|
errors.references
|
||||||
|
}}</NAlert>
|
||||||
<template v-if="references">
|
<template v-if="references">
|
||||||
<div class="reference-grid mt-4">
|
<div class="reference-grid mt-4">
|
||||||
<article v-for="item in references.references" :key="item.shotSubjectId" class="reference-card">
|
<article v-for="item in references.references" :key="item.shotSubjectId" class="reference-card">
|
||||||
@@ -165,24 +164,24 @@ function exportResult(kind: 'spec' | 'prompt') {
|
|||||||
<section class="border-t border-line pt-6">
|
<section class="border-t border-line pt-6">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-semibold">镜头生成规格</h3>
|
<h3 class="text-sm font-semibold">镜头生成规格</h3>
|
||||||
<button
|
<NButton
|
||||||
class="button button-secondary"
|
|
||||||
:disabled="locked || busy.spec || !shot.direction || !shot.visualState"
|
:disabled="locked || busy.spec || !shot.direction || !shot.visualState"
|
||||||
@click="load('spec')"
|
@click="load('spec')"
|
||||||
|
>{{ busy.spec ? '编译中…' : '读取生成规格' }}</NButton
|
||||||
>
|
>
|
||||||
{{ busy.spec ? '编译中…' : '读取生成规格' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs leading-6 text-muted">
|
<p class="mt-2 text-xs leading-6 text-muted">
|
||||||
后端合并主体稳定身份、具体形态、摄影设计与即时状态,编译为 GenerationSpec,不调用模型。需要本镜头已有
|
后端合并主体稳定身份、具体形态、摄影设计与即时状态,编译为 GenerationSpec,不调用模型。需要本镜头已有
|
||||||
Direction 和 VisualState。
|
Direction 和 VisualState。
|
||||||
</p>
|
</p>
|
||||||
<p v-if="errors.spec" class="alert alert-error mt-3" role="alert">{{ errors.spec }}</p>
|
<NAlert v-if="errors.spec" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||||||
|
errors.spec
|
||||||
|
}}</NAlert>
|
||||||
<template v-if="spec"
|
<template v-if="spec"
|
||||||
><div class="mt-4 flex items-center justify-between">
|
><div class="mt-4 flex items-center justify-between">
|
||||||
<span class="text-xs text-muted"
|
<span class="text-xs text-muted"
|
||||||
>{{ spec.subjects.length }} 个主体 · {{ spec.durationSeconds }} 秒</span
|
>{{ spec.subjects.length }} 个主体 · {{ spec.durationSeconds }} 秒</span
|
||||||
><button class="text-button" @click="exportResult('spec')"><Download :size="13" />导出规格</button>
|
><NButton @click="exportResult('spec')" text size="small"><Download :size="13" />导出规格</NButton>
|
||||||
</div>
|
</div>
|
||||||
<pre class="storyboard-json mt-3">{{ JSON.stringify(spec, null, 2) }}</pre>
|
<pre class="storyboard-json mt-3">{{ JSON.stringify(spec, null, 2) }}</pre>
|
||||||
</template>
|
</template>
|
||||||
@@ -204,19 +203,14 @@ function exportResult(kind: 'spec' | 'prompt') {
|
|||||||
: '读取此镜头已有提示词;若不存在,将调用模型并保存结果。后端会检查参考图是否齐全。'
|
: '读取此镜头已有提示词;若不存在,将调用模型并保存结果。后端会检查参考图是否齐全。'
|
||||||
"
|
"
|
||||||
@confirm="generatePrompt"
|
@confirm="generatePrompt"
|
||||||
/><label class="flex items-center gap-2 text-xs"
|
/><NCheckbox v-model:checked="force" :disabled="locked" class="flex items-center gap-2 text-xs"
|
||||||
><input
|
>覆盖此镜头提示词</NCheckbox
|
||||||
v-model="force"
|
|
||||||
type="checkbox"
|
|
||||||
class="accent-accent"
|
|
||||||
:disabled="locked"
|
|
||||||
/>覆盖此镜头提示词</label
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="prompt" class="mt-5">
|
<div v-if="prompt" class="mt-5">
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
<span class="text-xs font-medium">本会话已读取的正文</span
|
<span class="text-xs font-medium">本会话已读取的正文</span
|
||||||
><button class="text-button" @click="exportResult('prompt')"><Download :size="13" />导出</button>
|
><NButton @click="exportResult('prompt')" text size="small"><Download :size="13" />导出</NButton>
|
||||||
</div>
|
</div>
|
||||||
<p class="whitespace-pre-wrap text-sm leading-7">{{ prompt.videoPrompt }}</p>
|
<p class="whitespace-pre-wrap text-sm leading-7">{{ prompt.videoPrompt }}</p>
|
||||||
<template v-if="prompt.negativePrompt"
|
<template v-if="prompt.negativePrompt"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
|
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { UserCheck } from '@lucide/vue'
|
import { UserCheck } from '@lucide/vue'
|
||||||
@@ -115,198 +117,244 @@ function chooseAnchor(imageId: string) {
|
|||||||
if (subject.value?.module === 'character') void confirmCasting(imageId)
|
if (subject.value?.module === 'character') void confirmCasting(imageId)
|
||||||
else void setAnchor(imageId)
|
else void setAnchor(imageId)
|
||||||
}
|
}
|
||||||
|
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
|
||||||
|
const overview = ref<string[]>([])
|
||||||
|
watch(
|
||||||
|
() => session.value.receipt,
|
||||||
|
receipt => {
|
||||||
|
if (receipt) overview.value = ['casting']
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="mt-7">
|
<WorkspacePage split
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
><template #header
|
||||||
<div>
|
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
<h2 class="text-lg font-semibold">主体身份</h2>
|
|
||||||
<p class="mt-2 text-sm text-muted">为同一个人物、场景或道具固定稳定特征,再生成不同造型。</p>
|
|
||||||
</div>
|
|
||||||
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button"
|
|
||||||
>下一步:形态图片 →</RouterLink
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<p class="alert mt-5 text-xs">
|
|
||||||
Character 现在需要依次完成身份文本、选角候选、确认演员并锁定
|
|
||||||
Identity,之后首帧才能就绪。未明确人物背景时,AI
|
|
||||||
默认以中国人物作为选角基线;项目和角色的明确事实始终优先。场景和道具仍使用普通身份母版流程。
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value"
|
|
||||||
class="alert alert-error mt-4"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }}
|
|
||||||
当前相关操作已暂停,请刷新核对后端。
|
|
||||||
</p>
|
|
||||||
<p v-if="styleQuery.data.value && !hasStyle" class="alert mt-4">
|
|
||||||
先创建项目视觉风格,才能 AI 生成身份或身份图片。<RouterLink
|
|
||||||
:to="`/projects/${projectId}/visual-style`"
|
|
||||||
class="text-button ml-2"
|
|
||||||
>设置视觉风格 →</RouterLink
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
<section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<span class="production-step"><UserCheck :size="17" /></span>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-semibold">角色选角</h3>
|
|
||||||
<p class="mt-2 text-xs leading-6 text-muted">
|
|
||||||
只处理 Character。每张候选都从 Identity 独立生成,不复用旧 Anchor;确认选择时才会同时切换
|
|
||||||
Anchor 并锁定 Identity。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="font-mono text-xs text-muted">{{ casting.ready }} / {{ casting.total }} 已完成</span>
|
|
||||||
</div>
|
|
||||||
<dl class="casting-stats mt-4">
|
|
||||||
<div>
|
<div>
|
||||||
<dt>缺身份文本</dt>
|
<h2 class="text-lg font-semibold">主体身份</h2>
|
||||||
<dd>{{ casting.missingIdentity }}</dd>
|
<p class="mt-2 text-sm text-muted">为同一个人物、场景或道具固定稳定特征,再生成不同造型。</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<RouterLink :to="`/projects/${projectId}/subject-images`" class="text-button"
|
||||||
<dt>缺候选图</dt>
|
>下一步:形态图片 →</RouterLink
|
||||||
<dd>{{ casting.missingAnchor }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>待确认演员</dt>
|
|
||||||
<dd>{{ casting.candidatePending }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>母版未锁定</dt>
|
|
||||||
<dd>{{ casting.unlocked }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>选角完成</dt>
|
|
||||||
<dd>{{ casting.ready }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<div class="mt-5 flex flex-wrap items-end gap-4 border-t border-line pt-4">
|
|
||||||
<label class="w-28">
|
|
||||||
<span class="field-label">并发数</span>
|
|
||||||
<input
|
|
||||||
v-model.number="concurrency"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
class="input"
|
|
||||||
:disabled="operation.pending"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="w-28">
|
|
||||||
<span class="field-label">本批上限</span>
|
|
||||||
<input
|
|
||||||
v-model.number="candidateLimit"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
class="input"
|
|
||||||
:disabled="operation.pending"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="flex gap-2 pb-2 text-xs">
|
|
||||||
<input v-model="force" type="checkbox" :disabled="operation.pending" />覆盖未锁定身份文本
|
|
||||||
</label>
|
|
||||||
<ConfirmAction
|
|
||||||
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
|
|
||||||
:disabled="blocked || !hasStyle || !batchValid || dirty || !casting.total"
|
|
||||||
acknowledgement
|
|
||||||
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
|
|
||||||
@confirm="generateProject(true)"
|
|
||||||
/>
|
|
||||||
<ConfirmAction
|
|
||||||
label="生成选角候选"
|
|
||||||
:disabled="
|
|
||||||
castingBlocked || !hasStyle || !batchValid || !candidateLimitValid || !casting.missingAnchor
|
|
||||||
"
|
|
||||||
acknowledgement
|
|
||||||
description="最多按本批上限为缺少 Anchor 的 Character 调用 Seedream,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
|
|
||||||
@confirm="generateCastingCandidates"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="!batchValid || !candidateLimitValid" class="mt-3 text-xs text-danger">
|
|
||||||
并发数与本批上限都必须是正整数。
|
|
||||||
</p>
|
|
||||||
<div class="casting-list mt-5">
|
|
||||||
<button
|
|
||||||
v-for="item in casting.items"
|
|
||||||
:key="item.subjectId"
|
|
||||||
class="casting-item"
|
|
||||||
:class="{ selected: selectedId === item.subjectId }"
|
|
||||||
@click="select(item.subjectId)"
|
|
||||||
>
|
>
|
||||||
<span class="min-w-0">
|
</div></template
|
||||||
<span class="block truncate text-xs font-medium">{{ item.subjectName }}</span>
|
>
|
||||||
<span class="mt-1 block font-mono text-[10px] text-muted">{{ item.subjectRef }}</span>
|
<NAlert
|
||||||
</span>
|
v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value"
|
||||||
<StatusBadge :status="item.status" :label="castingStatusLabel(item.status)" />
|
role="alert"
|
||||||
</button>
|
type="error"
|
||||||
</div>
|
:show-icon="false"
|
||||||
</section>
|
class="mt-4"
|
||||||
<details class="panel mb-5 p-5">
|
>{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }}
|
||||||
<summary class="cursor-pointer text-sm font-medium">高级:全部主体身份文本</summary>
|
当前相关操作已暂停,请刷新核对后端。
|
||||||
<p class="mt-3 text-xs leading-6 text-muted">
|
</NAlert>
|
||||||
同时处理 Character、Scene 和 Prop;角色选角建议优先使用上方专用入口。已锁定身份始终跳过。
|
<NCollapse v-model:expanded-names="overview" class="workspace-overview"
|
||||||
</p>
|
><NCollapseItem name="casting" title="角色选角总览与批量操作"
|
||||||
<ConfirmAction
|
><div class="overview-content">
|
||||||
class="mt-4"
|
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||||
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
|
Character 现在需要依次完成身份文本、选角候选、确认演员并锁定
|
||||||
:disabled="blocked || !hasStyle || !batchValid || dirty || !subjects.length"
|
Identity,之后首帧才能就绪。未明确人物背景时,AI
|
||||||
acknowledgement
|
默认以中国人物作为选角基线;项目和角色的明确事实始终优先。场景和道具仍使用普通身份母版流程。
|
||||||
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
|
</NAlert>
|
||||||
@confirm="generateProject(false)"
|
<NAlert v-if="styleQuery.data.value && !hasStyle" type="info" :show-icon="false" class="mt-4">
|
||||||
/>
|
先创建项目视觉风格,才能 AI 生成身份或身份图片。<RouterLink
|
||||||
</details>
|
:to="`/projects/${projectId}/visual-style`"
|
||||||
<section v-if="session.receipt" class="panel mb-5 p-5" aria-label="身份文本批量回执">
|
class="text-button ml-2"
|
||||||
<div class="flex justify-between gap-3">
|
>设置视觉风格 →</RouterLink
|
||||||
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
|
></NAlert
|
||||||
<button class="text-button" @click="exportReceipt">导出诊断</button>
|
>
|
||||||
</div>
|
<section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
|
||||||
<p v-if="session.receipt.kind === 'identities'" class="mt-3 text-xs text-muted">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
共 {{ session.receipt.result.total }} 个主体 · 目标 {{ session.receipt.result.targetCount }} · 生成
|
<div class="flex items-start gap-3">
|
||||||
{{ session.receipt.result.generated }} · 跳过 {{ session.receipt.result.skipped }}(含锁定
|
<span class="production-step"><UserCheck :size="17" /></span>
|
||||||
{{ session.receipt.result.skippedLocked }})· 失败 {{ session.receipt.result.failed }}
|
<div>
|
||||||
</p>
|
<h3 class="text-sm font-semibold">角色选角</h3>
|
||||||
<p v-else class="mt-3 text-xs text-muted">
|
<p class="mt-2 text-xs leading-6 text-muted">
|
||||||
共 {{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
|
只处理 Character。每张候选都从 Identity 独立生成,不复用旧
|
||||||
{{ session.receipt.result.missingAnchor }} · 本批目标 {{ session.receipt.result.targetCount }} · 已生成
|
Anchor;确认选择时才会同时切换 Anchor 并锁定 Identity。
|
||||||
{{ session.receipt.result.generated }} · 失败 {{ session.receipt.result.failed }}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<p v-if="session.receipt.result.failed" class="alert alert-error mt-3" role="alert">
|
</div>
|
||||||
本次存在部分失败,成功结果不会回滚。请检查逐角色原因后再重试。
|
<span class="font-mono text-xs text-muted"
|
||||||
</p>
|
>{{ casting.ready }} / {{ casting.total }} 已完成</span
|
||||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
>
|
||||||
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectId">
|
</div>
|
||||||
{{ failure.subjectRef }} · {{ failure.subjectId }}:{{ failure.error }}
|
<dl class="casting-stats mt-4">
|
||||||
</li>
|
<div>
|
||||||
</ul>
|
<dt>缺身份文本</dt>
|
||||||
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话;最终状态以刷新后的正式记录为准。</p>
|
<dd>{{ casting.missingIdentity }}</dd>
|
||||||
</section>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>缺候选图</dt>
|
||||||
|
<dd>{{ casting.missingAnchor }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>待确认演员</dt>
|
||||||
|
<dd>{{ casting.candidatePending }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>母版未锁定</dt>
|
||||||
|
<dd>{{ casting.unlocked }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>选角完成</dt>
|
||||||
|
<dd>{{ casting.ready }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="mt-5 flex flex-wrap items-end gap-4 border-t border-line pt-4">
|
||||||
|
<label class="w-28">
|
||||||
|
<span class="field-label">并发数</span>
|
||||||
|
<NInputNumber
|
||||||
|
:disabled="operation.pending"
|
||||||
|
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||||
|
@update:value="concurrency = $event ?? 0"
|
||||||
|
:min="1"
|
||||||
|
:step="1"
|
||||||
|
></NInputNumber>
|
||||||
|
</label>
|
||||||
|
<label class="w-28">
|
||||||
|
<span class="field-label">本批上限</span>
|
||||||
|
<NInputNumber
|
||||||
|
:disabled="operation.pending"
|
||||||
|
:value="typeof candidateLimit === 'number' ? candidateLimit : null"
|
||||||
|
@update:value="candidateLimit = $event ?? 0"
|
||||||
|
:min="1"
|
||||||
|
:step="1"
|
||||||
|
></NInputNumber>
|
||||||
|
</label>
|
||||||
|
<NCheckbox
|
||||||
|
v-model:checked="force"
|
||||||
|
:disabled="operation.pending"
|
||||||
|
class="flex gap-2 pb-2 text-xs"
|
||||||
|
>覆盖未锁定身份文本
|
||||||
|
</NCheckbox>
|
||||||
|
<ConfirmAction
|
||||||
|
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
|
||||||
|
:disabled="blocked || !hasStyle || !batchValid || dirty || !casting.total"
|
||||||
|
acknowledgement
|
||||||
|
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过;覆盖文本不会自动更新已有候选图。"
|
||||||
|
@confirm="generateProject(true)"
|
||||||
|
/>
|
||||||
|
<ConfirmAction
|
||||||
|
label="生成选角候选"
|
||||||
|
:disabled="
|
||||||
|
castingBlocked ||
|
||||||
|
!hasStyle ||
|
||||||
|
!batchValid ||
|
||||||
|
!candidateLimitValid ||
|
||||||
|
!casting.missingAnchor
|
||||||
|
"
|
||||||
|
acknowledgement
|
||||||
|
description="最多按本批上限为缺少 Anchor 的 Character 调用 Seedream,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
|
||||||
|
@confirm="generateCastingCandidates"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="!batchValid || !candidateLimitValid" class="mt-3 text-xs text-danger">
|
||||||
|
并发数与本批上限都必须是正整数。
|
||||||
|
</p>
|
||||||
|
<div class="casting-list mt-5">
|
||||||
|
<NButton
|
||||||
|
v-for="item in casting.items"
|
||||||
|
:key="item.subjectId"
|
||||||
|
@click="select(item.subjectId)"
|
||||||
|
class="casting-item"
|
||||||
|
:class="{ selected: selectedId === item.subjectId }"
|
||||||
|
><span class="min-w-0">
|
||||||
|
<span class="block truncate text-xs font-medium">{{ item.subjectName }}</span>
|
||||||
|
<span class="mt-1 block font-mono text-[10px] text-muted">{{
|
||||||
|
item.subjectRef
|
||||||
|
}}</span> </span
|
||||||
|
><StatusBadge :status="item.status" :label="castingStatusLabel(item.status)"
|
||||||
|
/></NButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<NCollapse class="panel mb-5 p-5"
|
||||||
|
><NCollapseItem name="details"
|
||||||
|
><template #header>高级:全部主体身份文本</template>
|
||||||
|
<p class="mt-3 text-xs leading-6 text-muted">
|
||||||
|
同时处理 Character、Scene 和
|
||||||
|
Prop;角色选角建议优先使用上方专用入口。已锁定身份始终跳过。
|
||||||
|
</p>
|
||||||
|
<ConfirmAction
|
||||||
|
class="mt-4"
|
||||||
|
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
|
||||||
|
:disabled="blocked || !hasStyle || !batchValid || dirty || !subjects.length"
|
||||||
|
acknowledgement
|
||||||
|
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
|
||||||
|
@confirm="generateProject(false)" /></NCollapseItem
|
||||||
|
></NCollapse>
|
||||||
|
<section v-if="session.receipt" class="panel mb-5 p-5" aria-label="身份文本批量回执">
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
|
||||||
|
<NButton @click="exportReceipt" text size="small">导出诊断</NButton>
|
||||||
|
</div>
|
||||||
|
<p v-if="session.receipt.kind === 'identities'" class="mt-3 text-xs text-muted">
|
||||||
|
共 {{ session.receipt.result.total }} 个主体 · 目标
|
||||||
|
{{ session.receipt.result.targetCount }} · 生成 {{ session.receipt.result.generated }} ·
|
||||||
|
跳过 {{ session.receipt.result.skipped }}(含锁定
|
||||||
|
{{ session.receipt.result.skippedLocked }})· 失败 {{ session.receipt.result.failed }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="mt-3 text-xs text-muted">
|
||||||
|
共 {{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
|
||||||
|
{{ session.receipt.result.missingAnchor }} · 本批目标
|
||||||
|
{{ session.receipt.result.targetCount }} · 已生成 {{ session.receipt.result.generated }} ·
|
||||||
|
失败 {{ session.receipt.result.failed }}
|
||||||
|
</p>
|
||||||
|
<NAlert
|
||||||
|
v-if="session.receipt.result.failed"
|
||||||
|
role="alert"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
|
本次存在部分失败,成功结果不会回滚。请检查逐角色原因后再重试。
|
||||||
|
</NAlert>
|
||||||
|
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||||
|
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectId">
|
||||||
|
{{ failure.subjectRef }} · {{ failure.subjectId }}:{{ failure.error }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-3 text-[11px] text-muted">
|
||||||
|
回执仅保留于当前浏览器会话;最终状态以刷新后的正式记录为准。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div></NCollapseItem
|
||||||
|
></NCollapse
|
||||||
|
>
|
||||||
<div v-if="subjects.length" class="identity-workspace">
|
<div v-if="subjects.length" class="identity-workspace">
|
||||||
<aside class="panel min-w-0 p-4">
|
<aside class="panel min-w-0 p-4">
|
||||||
<h3 class="text-sm font-medium">
|
<h3 class="text-sm font-medium">
|
||||||
主体目录 <span class="ml-2 text-xs text-muted">{{ subjects.length }}</span>
|
主体目录 <span class="ml-2 text-xs text-muted">{{ subjects.length }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<input v-model="search" class="input mt-4" placeholder="搜索主体或引用" aria-label="搜索主体身份" />
|
<NInput
|
||||||
<select v-model="module" class="input mt-3" aria-label="身份主体类型">
|
placeholder="搜索主体或引用"
|
||||||
<option value="all">全部类型</option>
|
aria-label="搜索主体身份"
|
||||||
<option value="character">人物</option>
|
:input-props="{ 'aria-label': '搜索主体身份' }"
|
||||||
<option value="scene">场景</option>
|
class="mt-4"
|
||||||
<option value="prop">道具</option>
|
v-model:value="search"
|
||||||
</select>
|
></NInput>
|
||||||
|
<NSelect
|
||||||
|
aria-label="身份主体类型"
|
||||||
|
v-model:value="module"
|
||||||
|
:options="[
|
||||||
|
{ label: String('全部类型'), value: 'all' },
|
||||||
|
{ label: String('人物'), value: 'character' },
|
||||||
|
{ label: String('场景'), value: 'scene' },
|
||||||
|
{ label: String('道具'), value: 'prop' }
|
||||||
|
]"
|
||||||
|
class="mt-3"
|
||||||
|
></NSelect>
|
||||||
<div class="identity-subject-list mt-4">
|
<div class="identity-subject-list mt-4">
|
||||||
<button
|
<NButton
|
||||||
v-for="item in filtered"
|
v-for="item in filtered"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="identity-subject-item"
|
|
||||||
:class="{ selected: selectedId === item.id }"
|
|
||||||
:aria-pressed="selectedId === item.id"
|
:aria-pressed="selectedId === item.id"
|
||||||
@click="select(item.id)"
|
@click="select(item.id)"
|
||||||
>
|
class="identity-subject-item"
|
||||||
<span class="block font-medium">{{ item.name }}</span
|
:class="{ selected: selectedId === item.id }"
|
||||||
|
><span class="block font-medium">{{ item.name }}</span
|
||||||
><span class="mt-1 block text-[11px] text-muted"
|
><span class="mt-1 block text-[11px] text-muted"
|
||||||
>{{ item.ref }} · {{ item.forms.length }} 个形态<span
|
>{{ item.ref }} · {{ item.forms.length }} 个形态<span
|
||||||
v-if="casting?.items.find(row => row.subjectId === item.id)"
|
v-if="casting?.items.find(row => row.subjectId === item.id)"
|
||||||
@@ -316,26 +364,28 @@ function chooseAnchor(imageId: string) {
|
|||||||
castingStatusLabel(casting.items.find(row => row.subjectId === item.id)!.status)
|
castingStatusLabel(casting.items.find(row => row.subjectId === item.id)!.status)
|
||||||
}}</span
|
}}</span
|
||||||
></span
|
></span
|
||||||
>
|
></NButton
|
||||||
</button>
|
>
|
||||||
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体,请调整筛选。</p>
|
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体,请调整筛选。</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<NButton
|
||||||
class="text-button mt-4"
|
|
||||||
:disabled="catalog.loading.value || castingQuery.loading.value"
|
:disabled="catalog.loading.value || castingQuery.loading.value"
|
||||||
@click="refreshDirectory"
|
@click="refreshDirectory"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="mt-4"
|
||||||
>
|
>
|
||||||
刷新主体目录
|
刷新主体目录
|
||||||
</button>
|
</NButton>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div v-if="pendingSelection" class="alert mb-4">
|
<NAlert v-if="pendingSelection" type="info" :show-icon="false" class="mb-4"
|
||||||
<p class="text-xs">当前主体有未保存修改,切换会丢弃草稿。</p>
|
><p class="text-xs">当前主体有未保存修改,切换会丢弃草稿。</p>
|
||||||
<div class="mt-3 flex gap-3">
|
<div class="mt-3 flex gap-3">
|
||||||
<button class="button button-secondary" @click="discardAndSelect">放弃草稿并切换</button
|
<NButton @click="discardAndSelect">放弃草稿并切换</NButton
|
||||||
><button class="text-button" @click="pendingSelection = ''">继续编辑</button>
|
><NButton @click="pendingSelection = ''" text size="small">继续编辑</NButton>
|
||||||
</div>
|
</div></NAlert
|
||||||
</div>
|
>
|
||||||
<section v-if="subject" class="panel p-5 sm:p-6">
|
<section v-if="subject" class="panel p-5 sm:p-6">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -357,13 +407,13 @@ function chooseAnchor(imageId: string) {
|
|||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="text-button" :disabled="detail.loading.value" @click="detail.refresh">
|
<NButton :disabled="detail.loading.value" @click="detail.refresh" text size="small">
|
||||||
刷新身份
|
刷新身份
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="detail.error.value" class="alert alert-error mt-4" role="alert">
|
<NAlert v-if="detail.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||||
{{ detail.error.value }} 查询失败时不允许生成或覆盖。
|
>{{ detail.error.value }} 查询失败时不允许生成或覆盖。
|
||||||
</p>
|
</NAlert>
|
||||||
<p v-if="!detail.data.value && detail.loading.value" class="py-8 text-sm text-muted" role="status">
|
<p v-if="!detail.data.value && detail.loading.value" class="py-8 text-sm text-muted" role="status">
|
||||||
正在读取主体身份…
|
正在读取主体身份…
|
||||||
</p>
|
</p>
|
||||||
@@ -375,21 +425,18 @@ function chooseAnchor(imageId: string) {
|
|||||||
acknowledgement
|
acknowledgement
|
||||||
description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。"
|
description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。"
|
||||||
@confirm="generate"
|
@confirm="generate"
|
||||||
/><button
|
/><NButton
|
||||||
v-if="subject.module === 'character'"
|
v-if="subject.module === 'character'"
|
||||||
class="button button-secondary"
|
|
||||||
:disabled="!canGenerateCandidate"
|
:disabled="!canGenerateCandidate"
|
||||||
@click="castingOpen = true"
|
@click="castingOpen = true"
|
||||||
>
|
>
|
||||||
生成选角候选</button
|
生成选角候选</NButton
|
||||||
><button
|
><NButton
|
||||||
v-if="subject.module !== 'character' || !!anchor"
|
v-if="subject.module !== 'character' || !!anchor"
|
||||||
class="button button-secondary"
|
|
||||||
:disabled="!canGenerateImage"
|
:disabled="!canGenerateImage"
|
||||||
@click="imageOpen = true"
|
@click="imageOpen = true"
|
||||||
|
>{{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}</NButton
|
||||||
>
|
>
|
||||||
{{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}
|
|
||||||
</button>
|
|
||||||
</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
|
||||||
@@ -434,8 +481,8 @@ function chooseAnchor(imageId: string) {
|
|||||||
<div class="mt-5 border-t border-line pt-5">
|
<div class="mt-5 border-t border-line pt-5">
|
||||||
<h4 class="text-xs font-medium">此主体的形态</h4>
|
<h4 class="text-xs font-medium">此主体的形态</h4>
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
<span v-for="form in subject.forms" :key="form.id" class="tag"
|
<NTag v-for="form in subject.forms" :key="form.id" size="small" :bordered="false"
|
||||||
>{{ form.name }}{{ form.isDefault ? ' · 默认' : '' }}</span
|
>{{ form.name }}{{ form.isDefault ? ' · 默认' : '' }}</NTag
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!subject.forms.length" class="mt-3 text-xs text-muted">
|
<p v-if="!subject.forms.length" class="mt-3 text-xs text-muted">
|
||||||
@@ -467,8 +514,7 @@ function chooseAnchor(imageId: string) {
|
|||||||
:module="subject.module"
|
:module="subject.module"
|
||||||
:images="images"
|
:images="images"
|
||||||
:disabled="!canGenerateImage"
|
:disabled="!canGenerateImage"
|
||||||
@generate="generateImage"
|
@generate="generateImage" />
|
||||||
/>
|
|
||||||
<CastingCandidateDialog
|
<CastingCandidateDialog
|
||||||
v-if="subject?.module === 'character'"
|
v-if="subject?.module === 'character'"
|
||||||
v-model:open="castingOpen"
|
v-model:open="castingOpen"
|
||||||
@@ -476,6 +522,5 @@ function chooseAnchor(imageId: string) {
|
|||||||
:subject-name="subject.name"
|
:subject-name="subject.name"
|
||||||
:disabled="!canGenerateCandidate"
|
:disabled="!canGenerateCandidate"
|
||||||
@generate="generateCastingCandidate"
|
@generate="generateCastingCandidate"
|
||||||
/>
|
/></WorkspacePage>
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NCheckbox, NInput, NInputNumber } 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 { validImageSize } from '../../subject-images/model'
|
import { validImageSize } from '../../subject-images/model'
|
||||||
@@ -38,18 +39,20 @@ function submit() {
|
|||||||
<template>
|
<template>
|
||||||
<AppDialog v-model:open="open" title="生成角色选角候选" :description="`${subjectName} · 候选不会自动成为身份母版`">
|
<AppDialog v-model:open="open" title="生成角色选角候选" :description="`${subjectName} · 候选不会自动成为身份母版`">
|
||||||
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
||||||
<p class="alert text-xs">
|
<NAlert type="info" :show-icon="false" class="text-xs">
|
||||||
后端会依据角色 Identity、项目视觉风格和默认选角约束独立生成一张 primary
|
后端会依据角色 Identity、项目视觉风格和默认选角约束独立生成一张 primary
|
||||||
候选,不会复用当前身份母版。生成完成后仍需人工点击“确认选角”。
|
候选,不会复用当前身份母版。生成完成后仍需人工点击“确认选角”。
|
||||||
</p>
|
</NAlert>
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="field-label">覆盖本次完整提示词(可选)</span>
|
<span class="field-label">覆盖本次完整提示词(可选)</span>
|
||||||
<textarea
|
<NInput
|
||||||
id="casting-candidate-prompt"
|
|
||||||
v-model="prompt"
|
|
||||||
class="input min-h-24"
|
|
||||||
placeholder="建议留空,使用后端编译的稳定身份和选角约束;未明确人物背景时默认中国人物"
|
placeholder="建议留空,使用后端编译的稳定身份和选角约束;未明确人物背景时默认中国人物"
|
||||||
/>
|
:input-props="{ id: 'casting-candidate-prompt' }"
|
||||||
|
class="min-h-24"
|
||||||
|
v-model:value="prompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
<p v-if="prompt.trim()" class="text-xs text-danger">
|
<p v-if="prompt.trim()" class="text-xs text-danger">
|
||||||
自定义文本会替换后端完整提示词,请自行包含人物身份、基础服装和中性参考图要求。
|
自定义文本会替换后端完整提示词,请自行包含人物身份、基础服装和中性参考图要求。
|
||||||
@@ -57,27 +60,35 @@ function submit() {
|
|||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<label
|
<label
|
||||||
><span class="field-label">宽度(px)</span
|
><span class="field-label">宽度(px)</span
|
||||||
><input v-model.number="width" class="input" type="number" min="1" placeholder="后端默认"
|
><NInputNumber
|
||||||
/></label>
|
placeholder="后端默认"
|
||||||
|
:value="typeof width === 'number' ? width : null"
|
||||||
|
@update:value="width = $event ?? ''"
|
||||||
|
:min="1"
|
||||||
|
></NInputNumber
|
||||||
|
></label>
|
||||||
<label
|
<label
|
||||||
><span class="field-label">高度(px)</span
|
><span class="field-label">高度(px)</span
|
||||||
><input v-model.number="height" class="input" type="number" min="1" placeholder="后端默认"
|
><NInputNumber
|
||||||
/></label>
|
placeholder="后端默认"
|
||||||
|
:value="typeof height === 'number' ? height : null"
|
||||||
|
@update:value="height = $event ?? ''"
|
||||||
|
:min="1"
|
||||||
|
></NInputNumber
|
||||||
|
></label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!valid" class="text-xs text-danger">宽高需要同时留空,或同时填写正整数。</p>
|
<p v-if="!valid" class="text-xs text-danger">宽高需要同时留空,或同时填写正整数。</p>
|
||||||
<label class="flex items-start gap-2 text-xs leading-6">
|
<NCheckbox
|
||||||
<input
|
id="casting-candidate-cost"
|
||||||
id="casting-candidate-cost"
|
v-model:checked="acknowledged"
|
||||||
v-model="acknowledged"
|
class="flex items-start gap-2 text-xs leading-6"
|
||||||
type="checkbox"
|
>我已确认调用 Seedream 可能产生费用,且当前没有同项目生成任务。
|
||||||
class="mt-1.5 accent-accent"
|
</NCheckbox>
|
||||||
/>我已确认调用 Seedream 可能产生费用,且当前没有同项目生成任务。
|
|
||||||
</label>
|
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<button type="button" class="button button-secondary" @click="open = false">取消</button>
|
<NButton @click="open = false">取消</NButton>
|
||||||
<button type="submit" class="button button-primary" :disabled="disabled || !valid || !acknowledged">
|
<NButton :disabled="disabled || !valid || !acknowledged" type="primary" attr-type="submit">
|
||||||
确认生成候选
|
确认生成候选
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
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'
|
||||||
|
|
||||||
@@ -34,21 +35,27 @@ function save() {
|
|||||||
<fieldset :disabled="disabled" class="space-y-4">
|
<fieldset :disabled="disabled" class="space-y-4">
|
||||||
<label class="block"
|
<label class="block"
|
||||||
><span class="field-label">稳定身份描述</span
|
><span class="field-label">稳定身份描述</span
|
||||||
><textarea
|
><NInput
|
||||||
id="identity-description"
|
:disabled="disabled"
|
||||||
v-model="form.description"
|
|
||||||
class="input min-h-28"
|
|
||||||
placeholder="只描述跨形态不变的面部、结构、材质等特征"
|
placeholder="只描述跨形态不变的面部、结构、材质等特征"
|
||||||
/>
|
:input-props="{ id: 'identity-description' }"
|
||||||
|
class="min-h-28"
|
||||||
|
v-model:value="form.description"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
<label class="block"
|
<label class="block"
|
||||||
><span class="field-label">身份核心提示词(稳定事实)</span
|
><span class="field-label">身份核心提示词(稳定事实)</span
|
||||||
><textarea
|
><NInput
|
||||||
id="identity-prompt"
|
:disabled="disabled"
|
||||||
v-model="form.generationPrompt"
|
|
||||||
class="input min-h-36"
|
|
||||||
placeholder="不固定某一形态的服装、伤势或临时状态"
|
placeholder="不固定某一形态的服装、伤势或临时状态"
|
||||||
/>
|
:input-props="{ id: 'identity-prompt' }"
|
||||||
|
class="min-h-36"
|
||||||
|
v-model:value="form.generationPrompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs leading-6 text-muted">
|
<p class="text-xs leading-6 text-muted">
|
||||||
{{
|
{{
|
||||||
@@ -60,18 +67,22 @@ function save() {
|
|||||||
}}
|
}}
|
||||||
这里保存身份事实;背景、视角和参考图约束由后端另行编译,不是最终整段生图 Prompt。
|
这里保存身份事实;背景、视角和参考图约束由后端另行编译,不是最终整段生图 Prompt。
|
||||||
</p>
|
</p>
|
||||||
<label class="flex items-start gap-2 text-xs leading-6"
|
<NCheckbox
|
||||||
><input id="identity-lock" v-model="form.isLocked" type="checkbox" class="mt-1.5 accent-accent" />{{
|
:disabled="disabled"
|
||||||
|
id="identity-lock"
|
||||||
|
v-model:checked="form.isLocked"
|
||||||
|
class="flex items-start gap-2 text-xs leading-6"
|
||||||
|
>{{
|
||||||
module === 'character'
|
module === 'character'
|
||||||
? '确认并锁定演员身份。Character 必须同时具有身份母版并锁定 Identity,首帧才能进入就绪状态。'
|
? '确认并锁定演员身份。Character 必须同时具有身份母版并锁定 Identity,首帧才能进入就绪状态。'
|
||||||
: '确认并锁定身份文本。锁定后仍可人工保存、生成图片及切换母版。'
|
: '确认并锁定身份文本。锁定后仍可人工保存、生成图片及切换母版。'
|
||||||
}}</label
|
}}</NCheckbox
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<span class="text-xs text-muted">{{
|
<span class="text-xs text-muted">{{
|
||||||
dirty ? '有未保存修改,自动刷新不会覆盖草稿。' : '图片以已保存的身份提示词为准。'
|
dirty ? '有未保存修改,自动刷新不会覆盖草稿。' : '图片以已保存的身份提示词为准。'
|
||||||
}}</span
|
}}</span
|
||||||
><button class="button button-primary" type="submit" :disabled="disabled">保存主体身份</button>
|
><NButton :disabled="disabled" type="primary" attr-type="submit">保存主体身份</NButton>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { AssetImage, StatusBadge } from '../../../components/ui'
|
import { AssetImage, StatusBadge } from '../../../components/ui'
|
||||||
import { referenceImageUrl } from '../../../lib/assets'
|
import { referenceImageUrl } from '../../../lib/assets'
|
||||||
@@ -49,14 +50,14 @@ function chooseAnchor() {
|
|||||||
<section class="mt-5 border-t border-line pt-5" aria-label="身份参考图库">
|
<section class="mt-5 border-t border-line pt-5" aria-label="身份参考图库">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-medium">身份参考图 · {{ images.length }}</h3>
|
<h3 class="text-sm font-medium">身份参考图 · {{ images.length }}</h3>
|
||||||
<span class="tag">{{ anchor ? '已选母版' : '尚无母版' }}</span>
|
<NTag size="small" :bordered="false">{{ anchor ? '已选母版' : '尚无母版' }}</NTag>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs leading-6 text-muted">
|
<p class="mt-2 text-xs leading-6 text-muted">
|
||||||
身份母版确定“是谁”;形态主图确定“该造型长什么样”。切换母版仅影响之后的生成,不会替换已有形态图、分镜参考图或提示词。
|
身份母版确定“是谁”;形态主图确定“该造型长什么样”。切换母版仅影响之后的生成,不会替换已有形态图、分镜参考图或提示词。
|
||||||
</p>
|
</p>
|
||||||
<p v-if="character && anchor" class="alert mt-3 text-xs leading-6">
|
<NAlert v-if="character && anchor" type="info" :show-icon="false" class="mt-3 text-xs leading-6">
|
||||||
如果要更换演员身份,请生成新的“选角候选”并确认选角。正面、三分之四、全身等辅助身份图会继续引用当前母版,只用于保持同一演员,不会重新选择人物。
|
如果要更换演员身份,请生成新的“选角候选”并确认选角。正面、三分之四、全身等辅助身份图会继续引用当前母版,只用于保持同一演员,不会重新选择人物。
|
||||||
</p>
|
</NAlert>
|
||||||
<template v-if="selected">
|
<template v-if="selected">
|
||||||
<AssetImage
|
<AssetImage
|
||||||
:src="selected.status === 'completed' ? selected.imageUrl : null"
|
:src="selected.status === 'completed' ? selected.imageUrl : null"
|
||||||
@@ -66,13 +67,13 @@ function chooseAnchor() {
|
|||||||
/>
|
/>
|
||||||
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
|
<div class="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge :status="selected.status" /><span class="tag">{{
|
<StatusBadge :status="selected.status" /><NTag size="small" :bordered="false">{{
|
||||||
selected.isAnchor
|
selected.isAnchor
|
||||||
? '当前身份母版'
|
? '当前身份母版'
|
||||||
: selected.viewType === 'primary'
|
: selected.viewType === 'primary'
|
||||||
? '母版候选'
|
? '母版候选'
|
||||||
: identityViewLabels[selected.viewType]
|
: identityViewLabels[selected.viewType]
|
||||||
}}</span
|
}}</NTag
|
||||||
><span class="text-xs text-muted"
|
><span class="text-xs text-muted"
|
||||||
>{{ selected.enabled ? '启用' : '停用' }} · {{ selected.width || '—' }} ×
|
>{{ selected.enabled ? '启用' : '停用' }} · {{ selected.width || '—' }} ×
|
||||||
{{ selected.height || '—' }}</span
|
{{ selected.height || '—' }}</span
|
||||||
@@ -87,15 +88,15 @@ function chooseAnchor() {
|
|||||||
>打开原图</a
|
>打开原图</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="selected.error" class="alert alert-error mt-3" role="alert">{{ selected.error }}</p>
|
<NAlert v-if="selected.error" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||||||
|
selected.error
|
||||||
|
}}</NAlert>
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||||
<button
|
<NButton
|
||||||
v-if="!selected.isAnchor || (character && !identityLocked)"
|
v-if="!selected.isAnchor || (character && !identityLocked)"
|
||||||
class="button button-secondary"
|
|
||||||
:disabled="!canSelect"
|
:disabled="!canSelect"
|
||||||
@click="confirming = true"
|
@click="confirming = true"
|
||||||
>
|
>{{ character ? '确认选角并锁定' : '设为身份母版' }}</NButton
|
||||||
{{ character ? '确认选角并锁定' : '设为身份母版' }}</button
|
|
||||||
><span class="text-[11px] text-muted"
|
><span class="text-[11px] text-muted"
|
||||||
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
|
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
|
||||||
>
|
>
|
||||||
@@ -103,8 +104,8 @@ function chooseAnchor() {
|
|||||||
<p v-if="selected.viewType !== 'primary'" class="mt-2 text-xs text-muted">
|
<p v-if="selected.viewType !== 'primary'" class="mt-2 text-xs text-muted">
|
||||||
辅助视图不能直接设为母版;它会继承当前身份母版,不适合用来更换演员。
|
辅助视图不能直接设为母版;它会继承当前身份母版,不适合用来更换演员。
|
||||||
</p>
|
</p>
|
||||||
<div v-if="confirming" class="alert mt-3">
|
<NAlert v-if="confirming" type="info" :show-icon="false" class="mt-3"
|
||||||
<p class="text-xs">
|
><p class="text-xs">
|
||||||
{{
|
{{
|
||||||
character
|
character
|
||||||
? '确认选择这张图片作为正式演员?后端会在同一事务中切换身份母版并锁定 Identity,完成后首帧才具备稳定人物身份。'
|
? '确认选择这张图片作为正式演员?后端会在同一事务中切换身份母版并锁定 Identity,完成后首帧才具备稳定人物身份。'
|
||||||
@@ -112,15 +113,18 @@ function chooseAnchor() {
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-3 flex gap-3">
|
<div class="mt-3 flex gap-3">
|
||||||
<button class="button button-primary" :disabled="!canSelect" @click="chooseAnchor">
|
<NButton :disabled="!canSelect" @click="chooseAnchor" type="primary">{{
|
||||||
{{ character ? '确认演员选择' : '确认切换身份母版' }}</button
|
character ? '确认演员选择' : '确认切换身份母版'
|
||||||
><button class="text-button" @click="confirming = false">取消</button>
|
}}</NButton
|
||||||
</div>
|
><NButton @click="confirming = false" text size="small">取消</NButton>
|
||||||
</div>
|
</div></NAlert
|
||||||
<details v-if="selected.prompt" class="mt-4 text-xs">
|
>
|
||||||
<summary class="cursor-pointer text-muted">本次实际提示词</summary>
|
<NCollapse v-if="selected.prompt" class="mt-4 text-xs"
|
||||||
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p>
|
><NCollapseItem name="details"
|
||||||
</details>
|
><template #header>本次实际提示词</template>
|
||||||
|
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p></NCollapseItem
|
||||||
|
></NCollapse
|
||||||
|
>
|
||||||
<p
|
<p
|
||||||
v-if="readImageProvenance(selected.rawJson).referenceImageId"
|
v-if="readImageProvenance(selected.rawJson).referenceImageId"
|
||||||
class="mt-3 break-all text-[11px] text-muted"
|
class="mt-3 break-all text-[11px] text-muted"
|
||||||
@@ -129,16 +133,15 @@ function chooseAnchor() {
|
|||||||
</p>
|
</p>
|
||||||
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
|
<p class="mt-2 break-all font-mono text-[10px] text-muted">Identity image ID · {{ selected.id }}</p>
|
||||||
<div class="image-history mt-4" aria-label="身份图片历史">
|
<div class="image-history mt-4" aria-label="身份图片历史">
|
||||||
<button
|
<NButton
|
||||||
v-for="image in images"
|
v-for="image in images"
|
||||||
:key="image.id"
|
:key="image.id"
|
||||||
class="image-history-item"
|
|
||||||
:class="{ selected: image.id === selected.id }"
|
|
||||||
:aria-label="`查看身份图片 ${image.id}`"
|
:aria-label="`查看身份图片 ${image.id}`"
|
||||||
:aria-pressed="image.id === selected.id"
|
:aria-pressed="image.id === selected.id"
|
||||||
@click="selectedId = image.id"
|
@click="selectedId = image.id"
|
||||||
>
|
class="image-history-item"
|
||||||
<AssetImage
|
:class="{ selected: image.id === selected.id }"
|
||||||
|
><AssetImage
|
||||||
:src="image.status === 'completed' ? image.imageUrl : null"
|
:src="image.status === 'completed' ? image.imageUrl : null"
|
||||||
:alt="identityViewLabels[image.viewType]"
|
:alt="identityViewLabels[image.viewType]"
|
||||||
:retryable="false"
|
:retryable="false"
|
||||||
@@ -149,8 +152,8 @@ function chooseAnchor() {
|
|||||||
: image.viewType === 'primary'
|
: image.viewType === 'primary'
|
||||||
? '母版候选'
|
? '母版候选'
|
||||||
: identityViewLabels[image.viewType]
|
: identityViewLabels[image.viewType]
|
||||||
}}</span>
|
}}</span></NButton
|
||||||
</button>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="py-8 text-sm text-muted">尚无身份参考图。先保存身份提示词,再生成第一张母版。</p>
|
<p v-else class="py-8 text-sm text-muted">尚无身份参考图。先保存身份提示词,再生成第一张母版。</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NCheckbox, NInput, NInputNumber, NSelect } 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 { validImageSize } from '../../subject-images/model'
|
import { validImageSize } from '../../subject-images/model'
|
||||||
@@ -61,40 +62,50 @@ function submit() {
|
|||||||
<template>
|
<template>
|
||||||
<AppDialog v-model:open="open" title="生成身份参考图" :description="subjectName">
|
<AppDialog v-model:open="open" title="生成身份参考图" :description="subjectName">
|
||||||
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
||||||
<p class="alert text-xs">
|
<NAlert type="info" :show-icon="false" class="text-xs">
|
||||||
调用 Seedream,每次新增一张图片,可能产生费用。第一张成功的 primary 图自动成为母版;已有母版时,新
|
调用 Seedream,每次新增一张图片,可能产生费用。第一张成功的 primary 图自动成为母版;已有母版时,新
|
||||||
primary 图仅作为停用候选保留。
|
primary 图仅作为停用候选保留。
|
||||||
</p>
|
</NAlert>
|
||||||
<label class="block"
|
<label class="block"
|
||||||
><span class="field-label">参考视角</span
|
><span class="field-label">参考视角</span
|
||||||
><select id="identity-view" v-model="viewType" class="input">
|
><NSelect
|
||||||
<option v-for="[value, label] in allowedViews" :key="value" :value="value">
|
id="identity-view"
|
||||||
{{ label }}({{ value }})
|
v-model:value="viewType"
|
||||||
</option>
|
:options="allowedViews.map(([value, label]) => ({ label: label + '(' + value + ')', value }))"
|
||||||
</select></label
|
class="select-control"
|
||||||
>
|
></NSelect
|
||||||
|
></label>
|
||||||
<label class="block"
|
<label class="block"
|
||||||
><span class="field-label">身份锚定来源</span
|
><span class="field-label">身份锚定来源</span
|
||||||
><select id="identity-reference" v-model="referenceImageId" class="input">
|
><NSelect
|
||||||
<option value="">
|
id="identity-reference"
|
||||||
{{ anchor ? '自动引用当前身份母版' : '自动选择(当前无母版,将按文本生成)' }}
|
v-model:value="referenceImageId"
|
||||||
</option>
|
:options="[
|
||||||
<option v-for="image in references" :key="image.id" :value="image.id">
|
{
|
||||||
{{ identityViewLabels[image.viewType] }} · {{ image.id }}
|
label: String(anchor ? '自动引用当前身份母版' : '自动选择(当前无母版,将按文本生成)'),
|
||||||
</option>
|
value: ''
|
||||||
</select></label
|
},
|
||||||
>
|
...references.map(image => ({
|
||||||
|
label: String(identityViewLabels[image.viewType] + ' · ' + image.id),
|
||||||
|
value: image.id
|
||||||
|
}))
|
||||||
|
]"
|
||||||
|
class="select-control"
|
||||||
|
></NSelect
|
||||||
|
></label>
|
||||||
<p class="text-xs leading-6 text-muted">
|
<p class="text-xs leading-6 text-muted">
|
||||||
只能选择此主体已完成的图片。后端需取得 Provider 可访问的远程地址;本地预览可见不保证远程地址仍有效。
|
只能选择此主体已完成的图片。后端需取得 Provider 可访问的远程地址;本地预览可见不保证远程地址仍有效。
|
||||||
</p>
|
</p>
|
||||||
<label class="block"
|
<label class="block"
|
||||||
><span class="field-label">覆盖本次完整提示词(可选)</span
|
><span class="field-label">覆盖本次完整提示词(可选)</span
|
||||||
><textarea
|
><NInput
|
||||||
id="identity-image-prompt"
|
|
||||||
v-model="prompt"
|
|
||||||
class="input min-h-24"
|
|
||||||
placeholder="建议留空,使用后端编译的身份约束和视角要求"
|
placeholder="建议留空,使用后端编译的身份约束和视角要求"
|
||||||
/>
|
:input-props="{ id: 'identity-image-prompt' }"
|
||||||
|
class="min-h-24"
|
||||||
|
v-model:value="prompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
<p v-if="prompt.trim()" class="text-xs text-danger">
|
<p v-if="prompt.trim()" class="text-xs text-danger">
|
||||||
自定义文本会替换后端编译的完整提示词,请自行包含身份约束和视角要求。
|
自定义文本会替换后端编译的完整提示词,请自行包含身份约束和视角要求。
|
||||||
@@ -102,40 +113,38 @@ function submit() {
|
|||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<label
|
<label
|
||||||
><span class="field-label">宽度(px)</span
|
><span class="field-label">宽度(px)</span
|
||||||
><input
|
><NInputNumber
|
||||||
id="identity-width"
|
placeholder="后端默认"
|
||||||
v-model.number="width"
|
:input-props="{ id: 'identity-width' }"
|
||||||
type="number"
|
:value="typeof width === 'number' ? width : null"
|
||||||
min="1"
|
@update:value="width = $event ?? ''"
|
||||||
step="1"
|
:min="1"
|
||||||
class="input"
|
:step="1"
|
||||||
placeholder="后端默认" /></label
|
></NInputNumber></label
|
||||||
><label
|
><label
|
||||||
><span class="field-label">高度(px)</span
|
><span class="field-label">高度(px)</span
|
||||||
><input
|
><NInputNumber
|
||||||
id="identity-height"
|
|
||||||
v-model.number="height"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
class="input"
|
|
||||||
placeholder="后端默认"
|
placeholder="后端默认"
|
||||||
/></label>
|
:input-props="{ id: 'identity-height' }"
|
||||||
|
:value="typeof height === 'number' ? height : null"
|
||||||
|
@update:value="height = $event ?? ''"
|
||||||
|
:min="1"
|
||||||
|
:step="1"
|
||||||
|
></NInputNumber
|
||||||
|
></label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!valid" class="text-xs text-danger" role="alert">尺寸须成对填写正整数;指定的参考图必须仍可用。</p>
|
<p v-if="!valid" class="text-xs text-danger" role="alert">尺寸须成对填写正整数;指定的参考图必须仍可用。</p>
|
||||||
<label class="flex items-start gap-2 text-xs leading-6"
|
<NCheckbox
|
||||||
><input
|
id="identity-image-cost"
|
||||||
id="identity-image-cost"
|
v-model:checked="acknowledged"
|
||||||
v-model="acknowledged"
|
class="flex items-start gap-2 text-xs leading-6"
|
||||||
type="checkbox"
|
>我已确认后台没有同项目任务,了解本次会调用模型并可能产生费用。</NCheckbox
|
||||||
class="mt-1.5 accent-accent"
|
|
||||||
/>我已确认后台没有同项目任务,了解本次会调用模型并可能产生费用。</label
|
|
||||||
>
|
>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<button type="button" class="button button-secondary" @click="open = false">取消</button
|
<NButton @click="open = false">取消</NButton
|
||||||
><button class="button button-primary" type="submit" :disabled="disabled || !valid || !acknowledged">
|
><NButton :disabled="disabled || !valid || !acknowledged" type="primary" attr-type="submit">
|
||||||
确认生成身份图
|
确认生成身份图
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from './model'
|
} from './model'
|
||||||
import { identityImageFixture } from './testing/fixtures'
|
import { identityImageFixture } from './testing/fixtures'
|
||||||
import { formFixture } from '../subject-images/testing/fixtures'
|
import { formFixture } from '../subject-images/testing/fixtures'
|
||||||
|
import { expandSections, selectControl } from '../../testing/naive'
|
||||||
|
|
||||||
let wrapper: VueWrapper | undefined
|
let wrapper: VueWrapper | undefined
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -23,7 +24,7 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 从真实 Reka Portal 内获取确认按钮。 */
|
/** 从真实 Naive 弹窗内获取确认按钮。 */
|
||||||
function button(label: string) {
|
function button(label: string) {
|
||||||
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
|
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
|
||||||
if (!item) throw new Error(`缺少按钮 ${label}`)
|
if (!item) throw new Error(`缺少按钮 ${label}`)
|
||||||
@@ -32,6 +33,10 @@ function button(label: string) {
|
|||||||
|
|
||||||
/** 修改 Portal 表单控件并触发 Vue 绑定。 */
|
/** 修改 Portal 表单控件并触发 Vue 绑定。 */
|
||||||
function input(selector: string, value: string) {
|
function input(selector: string, value: string) {
|
||||||
|
if (selector === '#identity-view' || selector === '#identity-reference') {
|
||||||
|
selectControl(wrapper!, 'id', selector.slice(1)).vm.$emit('update:value', value)
|
||||||
|
return
|
||||||
|
}
|
||||||
const item = document.querySelector<HTMLInputElement | HTMLSelectElement>(selector)!
|
const item = document.querySelector<HTMLInputElement | HTMLSelectElement>(selector)!
|
||||||
item.value = value
|
item.value = value
|
||||||
item.dispatchEvent(new Event(item.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
|
item.dispatchEvent(new Event(item.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
|
||||||
@@ -136,8 +141,8 @@ describe('身份图与母版契约', () => {
|
|||||||
await wrapper.setProps({ images: [] })
|
await wrapper.setProps({ images: [] })
|
||||||
expect(button('确认生成身份图').disabled).toBe(true)
|
expect(button('确认生成身份图').disabled).toBe(true)
|
||||||
await wrapper.setProps({ subjectId: 's2', subjectName: '陆川' })
|
await wrapper.setProps({ subjectId: 's2', subjectName: '陆川' })
|
||||||
expect(document.querySelector<HTMLSelectElement>('#identity-reference')!.value).toBe('')
|
expect(selectControl(wrapper!, 'id', 'identity-reference').props('value')).toBe('')
|
||||||
expect(document.querySelector<HTMLInputElement>('#identity-image-cost')!.checked).toBe(false)
|
expect(document.querySelector('#identity-image-cost')!.getAttribute('aria-checked')).toBe('false')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Character 普通身份图入口只提供辅助视角,primary 必须走选角候选接口', async () => {
|
it('Character 普通身份图入口只提供辅助视角,primary 必须走选角候选接口', async () => {
|
||||||
@@ -153,9 +158,9 @@ describe('身份图与母版契约', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const options = [...document.querySelectorAll<HTMLOptionElement>('#identity-view option')]
|
const select = selectControl(wrapper!, 'id', 'identity-view')
|
||||||
expect(options.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body'])
|
expect(select.props('options')!.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body'])
|
||||||
expect(document.querySelector<HTMLSelectElement>('#identity-view')!.value).toBe('front')
|
expect(select.props('value')).toBe('front')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('辅助图不能切换母版,提示词按纯文本展示', async () => {
|
it('辅助图不能切换母版,提示词按纯文本展示', async () => {
|
||||||
@@ -170,6 +175,7 @@ describe('身份图与母版契约', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
expect(button('确认选角并锁定').disabled).toBe(true)
|
expect(button('确认选角并锁定').disabled).toBe(true)
|
||||||
|
await expandSections()
|
||||||
expect(wrapper.text()).toContain('<script>不执行</script>')
|
expect(wrapper.text()).toContain('<script>不执行</script>')
|
||||||
expect(wrapper.find('script').exists()).toBe(false)
|
expect(wrapper.find('script').exists()).toBe(false)
|
||||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
|
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { Search, RefreshCw, Download, ImagePlus, ArrowRight, AlertTriangle } from '@lucide/vue'
|
import { Search, RefreshCw, Download, ImagePlus, ArrowRight, AlertTriangle } from '@lucide/vue'
|
||||||
@@ -87,20 +89,23 @@ function resetFilters() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="mt-7">
|
<WorkspacePage
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
><template #header
|
||||||
<div>
|
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
<h2 class="text-lg font-semibold">形态图片</h2>
|
<div>
|
||||||
<p class="mt-2 text-sm text-muted">为人物、场景和道具准备造型参考图。已有图片直接从数据库读取。</p>
|
<h2 class="text-lg font-semibold">形态图片</h2>
|
||||||
</div>
|
<p class="mt-2 text-sm text-muted">为人物、场景和道具准备造型参考图。已有图片直接从数据库读取。</p>
|
||||||
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button"
|
</div>
|
||||||
>去检查分镜参考图<ArrowRight :size="14"
|
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button"
|
||||||
/></RouterLink>
|
>去检查分镜参考图<ArrowRight :size="14"
|
||||||
</div>
|
/></RouterLink></div></template
|
||||||
<p class="alert mt-5 text-xs">
|
><NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||||||
Character 形态图会记录生成时使用的 Identity Anchor。更换演员母版后,旧主图会标记为“身份母版已变更”,且后端会阻止它继续进入分镜首帧;请基于当前母版重新生成候选并确认新的主参考图。
|
Character 形态图会记录生成时使用的 Identity
|
||||||
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2">管理主体身份 →</RouterLink>
|
Anchor。更换演员母版后,旧主图会标记为“身份母版已变更”,且后端会阻止它继续进入分镜首帧;请基于当前母版重新生成候选并确认新的主参考图。
|
||||||
</p>
|
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
|
||||||
|
>管理主体身份 →</RouterLink
|
||||||
|
></NAlert
|
||||||
|
>
|
||||||
<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">
|
||||||
@@ -110,17 +115,19 @@ function resetFilters() {
|
|||||||
</span>
|
</span>
|
||||||
<span class="ml-3 text-xs text-muted">Seedream · 后端配置</span>
|
<span class="ml-3 text-xs text-muted">Seedream · 后端配置</span>
|
||||||
</p>
|
</p>
|
||||||
<button class="text-button" :disabled="query.loading.value" @click="query.refresh">
|
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
||||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库
|
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图库
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-[11px] leading-6 text-muted">
|
<p class="mt-2 text-[11px] leading-6 text-muted">
|
||||||
每 6 秒更新图片记录;主图供分镜引用,候选图和历史失败记录均保留。过期主图不会自动删除,也不会被批量任务静默替换。
|
每 6
|
||||||
|
秒更新图片记录;主图供分镜引用,候选图和历史失败记录均保留。过期主图不会自动删除,也不会被批量任务静默替换。
|
||||||
</p>
|
</p>
|
||||||
<div v-if="staleForms.length" class="alert alert-error mt-4">
|
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-4"
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
><div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<span class="flex items-center gap-2 text-xs">
|
<span class="flex items-center gap-2 text-xs">
|
||||||
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} 个 Character Form 仍在使用旧 Identity Anchor。
|
<AlertTriangle :size="14" />检测到 {{ staleForms.length }} 个 Character Form 仍在使用旧 Identity
|
||||||
|
Anchor。
|
||||||
</span>
|
</span>
|
||||||
<ConfirmAction
|
<ConfirmAction
|
||||||
label="重新生成过期形态"
|
label="重新生成过期形态"
|
||||||
@@ -128,75 +135,78 @@ function resetFilters() {
|
|||||||
acknowledgement
|
acknowledgement
|
||||||
description="只为身份母版已经变化的 Character Form 调用 Seedream,各新增一张基于当前 Anchor 的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
|
description="只为身份母版已经变化的 Character Form 调用 Seedream,各新增一张基于当前 Anchor 的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
|
||||||
@confirm="generateStale"
|
@confirm="generateStale"
|
||||||
/>
|
/></div
|
||||||
</div>
|
></NAlert>
|
||||||
</div>
|
<NCollapse class="mt-4 border-t border-line pt-4"
|
||||||
<details class="mt-4 border-t border-line pt-4">
|
><NCollapseItem name="details"
|
||||||
<summary class="cursor-pointer text-xs font-medium">项目批量生图</summary>
|
><template #header>项目批量生图</template>
|
||||||
<div class="mt-4 flex flex-wrap items-end gap-4">
|
<div class="mt-4 flex flex-wrap items-end gap-4">
|
||||||
<label class="w-28">
|
<label class="w-28">
|
||||||
<span class="field-label">批量并发</span>
|
<span class="field-label">批量并发</span>
|
||||||
<input
|
<NInputNumber
|
||||||
id="image-concurrency"
|
:disabled="operation.pending"
|
||||||
v-model.number="concurrency"
|
:input-props="{ id: 'image-concurrency' }"
|
||||||
type="number"
|
:value="typeof concurrency === 'number' ? concurrency : null"
|
||||||
min="1"
|
@update:value="concurrency = $event ?? 0"
|
||||||
step="1"
|
:min="1"
|
||||||
class="input"
|
:step="1"
|
||||||
|
></NInputNumber>
|
||||||
|
</label>
|
||||||
|
<NCheckbox
|
||||||
|
v-model:checked="force"
|
||||||
:disabled="operation.pending"
|
:disabled="operation.pending"
|
||||||
|
class="flex items-center gap-2 pb-2 text-xs"
|
||||||
|
>已有主图也新增候选图
|
||||||
|
</NCheckbox>
|
||||||
|
<ConfirmAction
|
||||||
|
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
|
||||||
|
:disabled="blocked || !batchValid || !forms.length"
|
||||||
|
acknowledgement
|
||||||
|
:description="
|
||||||
|
force
|
||||||
|
? '对项目全部形态调用 Seedream 新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
||||||
|
: '仅对没有主图的形态调用 Seedream 生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
|
||||||
|
"
|
||||||
|
@confirm="generateProject"
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label class="flex items-center gap-2 pb-2 text-xs">
|
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数。</p>
|
||||||
<input
|
<p class="mt-3 text-xs leading-6 text-muted">
|
||||||
v-model="force"
|
作用于整个项目,不受下方筛选影响。批量使用后端默认尺寸与形态提示词,不会启动视频生成。
|
||||||
type="checkbox"
|
</p></NCollapseItem
|
||||||
class="accent-accent"
|
></NCollapse
|
||||||
:disabled="operation.pending"
|
>
|
||||||
/>已有主图也新增候选图
|
|
||||||
</label>
|
|
||||||
<ConfirmAction
|
|
||||||
:label="force ? '为全项目新增候选图' : '补齐项目主参考图'"
|
|
||||||
:disabled="blocked || !batchValid || !forms.length"
|
|
||||||
acknowledgement
|
|
||||||
:description="
|
|
||||||
force
|
|
||||||
? '对项目全部形态调用 Seedream 新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
|
||||||
: '仅对没有主图的形态调用 Seedream 生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
|
|
||||||
"
|
|
||||||
@confirm="generateProject"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数。</p>
|
|
||||||
<p class="mt-3 text-xs leading-6 text-muted">
|
|
||||||
作用于整个项目,不受下方筛选影响。批量使用后端默认尺寸与形态提示词,不会启动视频生成。
|
|
||||||
</p>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||||
{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据。</span>
|
>{{ query.error.value }}<span v-if="forms.length"> 当前展示上次读取的数据。</span></NAlert
|
||||||
</p>
|
><NAlert v-if="running" role="status" type="info" :show-icon="false" class="mt-4">
|
||||||
<p v-if="running" class="alert mt-4" role="status">
|
|
||||||
数据库中仍有排队或生成中的图片,已暂停新的生图操作。长时间无变化时,请先核对后台任务;刷新或关闭页面不会取消任务。
|
数据库中仍有排队或生成中的图片,已暂停新的生图操作。长时间无变化时,请先核对后台任务;刷新或关闭页面不会取消任务。
|
||||||
</p>
|
</NAlert>
|
||||||
<section v-if="session.receipt" class="panel mt-4 p-5" aria-label="生图批量回执">
|
<section v-if="session.receipt" class="panel mt-4 p-5" aria-label="生图批量回执">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
|
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
|
||||||
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
|
<NButton @click="exportReceipt" text size="small"><Download :size="13" />导出诊断</NButton>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-xs text-muted">
|
<p class="mt-3 text-xs text-muted">
|
||||||
共 {{ session.receipt.result.total }} 个形态 · 本次目标 {{ session.receipt.result.targetCount }} · 生成
|
共 {{ session.receipt.result.total }} 个形态 · 本次目标 {{ session.receipt.result.targetCount }} · 生成
|
||||||
{{ session.receipt.result.generated }} · 跳过 {{ session.receipt.result.skipped }} · 失败
|
{{ session.receipt.result.generated }} · 跳过 {{ session.receipt.result.skipped }} · 失败
|
||||||
{{ session.receipt.result.failed }}
|
{{ session.receipt.result.failed }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="session.receipt.result.failed" class="alert alert-error mt-3" role="alert">
|
<NAlert v-if="session.receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
|
||||||
部分形态生图失败。已生成的图片保留;先查看失败原因,再按形态重新生图。
|
部分形态生图失败。已生成的图片保留;先查看失败原因,再按形态重新生图。
|
||||||
</p>
|
</NAlert>
|
||||||
<p v-else-if="session.receipt.title === '刷新过期人物形态图'" class="alert mt-3 text-xs">
|
<NAlert
|
||||||
|
v-else-if="session.receipt.title === '刷新过期人物形态图'"
|
||||||
|
type="info"
|
||||||
|
:show-icon="false"
|
||||||
|
class="mt-3 text-xs"
|
||||||
|
>
|
||||||
新图当前仍是候选图。请进入对应形态的“查看图片与记录”,确认人物身份和造型后再设为主参考图;旧主图在此之前仍保持过期状态。
|
新图当前仍是候选图。请进入对应形态的“查看图片与记录”,确认人物身份和造型后再设为主参考图;旧主图在此之前仍保持过期状态。
|
||||||
</p>
|
</NAlert>
|
||||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||||
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectFormId">
|
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectFormId">
|
||||||
<code>{{ failure.subjectFormId }}</code>:{{ failure.error }}
|
<code>{{ failure.subjectFormId }}</code
|
||||||
|
>:{{ failure.error }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话;图片以刷新后的数据库记录为准。</p>
|
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话;图片以刷新后的数据库记录为准。</p>
|
||||||
@@ -204,20 +214,25 @@ function resetFilters() {
|
|||||||
<div class="my-6 flex flex-wrap items-center gap-4">
|
<div class="my-6 flex flex-wrap items-center gap-4">
|
||||||
<label class="search-field">
|
<label class="search-field">
|
||||||
<Search :size="14" />
|
<Search :size="14" />
|
||||||
<input v-model="search" placeholder="搜索主体、引用或形态" aria-label="搜索形态图片" />
|
<NInput
|
||||||
</label>
|
placeholder="搜索主体、引用或形态"
|
||||||
<select v-model="module" class="input w-auto" aria-label="筛选主体类型">
|
:input-props="{ 'aria-label': '搜索形态图片' }"
|
||||||
<option value="all">全部类型</option>
|
v-model:value="search"
|
||||||
<option value="character">人物</option>
|
></NInput>
|
||||||
<option value="scene">场景</option>
|
|
||||||
<option value="prop">道具</option>
|
|
||||||
</select>
|
|
||||||
<label class="flex items-center gap-2 text-xs">
|
|
||||||
<input v-model="onlyMissing" type="checkbox" class="accent-accent" />仅看缺少主图
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 text-xs">
|
|
||||||
<input v-model="onlyStale" type="checkbox" class="accent-accent" />仅看身份过期
|
|
||||||
</label>
|
</label>
|
||||||
|
<NSelect
|
||||||
|
aria-label="筛选主体类型"
|
||||||
|
v-model:value="module"
|
||||||
|
:options="[
|
||||||
|
{ label: String('全部类型'), value: 'all' },
|
||||||
|
{ label: String('人物'), value: 'character' },
|
||||||
|
{ label: String('场景'), value: 'scene' },
|
||||||
|
{ label: String('道具'), value: 'prop' }
|
||||||
|
]"
|
||||||
|
class="select-control"
|
||||||
|
></NSelect>
|
||||||
|
<NCheckbox v-model:checked="onlyMissing" class="flex items-center gap-2 text-xs">仅看缺少主图 </NCheckbox>
|
||||||
|
<NCheckbox v-model:checked="onlyStale" class="flex items-center gap-2 text-xs">仅看身份过期 </NCheckbox>
|
||||||
<span class="ml-auto text-xs text-muted">{{ filtered.length }} 个形态</span>
|
<span class="ml-auto text-xs text-muted">{{ filtered.length }} 个形态</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
|
<p v-if="!query.data.value && query.loading.value" class="py-10 text-sm text-muted" role="status">
|
||||||
@@ -241,7 +256,7 @@ function resetFilters() {
|
|||||||
<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 text-xs">
|
||||||
{{ form.name }}<span v-if="form.isDefault" class="tag ml-2">默认形态</span>
|
{{ form.name }}<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 }}
|
||||||
@@ -249,27 +264,42 @@ function resetFilters() {
|
|||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ path: `/projects/${id}/subject-identity`, query: { subjectId: form.subjectId } }"
|
:to="{ path: `/projects/${id}/subject-identity`, query: { subjectId: form.subjectId } }"
|
||||||
class="text-button mt-3"
|
class="text-button mt-3"
|
||||||
>查看主体身份与母版 →</RouterLink>
|
>查看主体身份与母版 →</RouterLink
|
||||||
|
>
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge v-if="hasRunningImages(form.images)" status="generating" />
|
<StatusBadge v-if="hasRunningImages(form.images)" status="generating" />
|
||||||
<span v-if="isPrimaryIdentityStale(form)" class="tag text-danger">身份母版已变更</span>
|
<NTag v-if="isPrimaryIdentityStale(form)" size="small" :bordered="false">身份母版已变更</NTag>
|
||||||
<span v-else-if="primaryImage(form.images)" class="tag">主参考图</span>
|
<NTag v-else-if="primaryImage(form.images)" size="small" :bordered="false">主参考图</NTag>
|
||||||
<span v-else-if="coverImage(form)" class="tag">候选图 · 尚未设主图</span>
|
<NTag v-else-if="coverImage(form)" size="small" :bordered="false">候选图 · 尚未设主图</NTag>
|
||||||
<span v-else-if="form.images[0]?.status === 'failed'" class="text-xs text-danger">最近一次生成失败</span>
|
<span v-else-if="form.images[0]?.status === 'failed'" class="text-xs text-danger"
|
||||||
|
>最近一次生成失败</span
|
||||||
|
>
|
||||||
<span class="text-[10px] text-muted">{{ form.images.length }} 条记录</span>
|
<span class="text-[10px] text-muted">{{ form.images.length }} 条记录</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="isPrimaryIdentityStale(form)" class="alert alert-error mt-3 text-xs leading-6">
|
<NAlert
|
||||||
当前主图由旧演员母版生成,后续分镜已禁止继续引用。请基于当前 Identity Anchor 重新生成候选图,并在确认后设置为新的主参考图。
|
v-if="isPrimaryIdentityStale(form)"
|
||||||
</p>
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
class="mt-3 text-xs leading-6"
|
||||||
|
>
|
||||||
|
当前主图由旧演员母版生成,后续分镜已禁止继续引用。请基于当前 Identity Anchor
|
||||||
|
重新生成候选图,并在确认后设置为新的主参考图。
|
||||||
|
</NAlert>
|
||||||
<p v-if="form.images[0]?.status === 'failed'" class="mt-2 line-clamp-2 text-xs text-danger">
|
<p v-if="form.images[0]?.status === 'failed'" class="mt-2 line-clamp-2 text-xs text-danger">
|
||||||
{{ form.images[0].error }}
|
{{ form.images[0].error }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<button class="text-button" @click="openGallery(form)">查看图片与记录</button>
|
<NButton @click="openGallery(form)" text size="small">查看图片与记录</NButton>
|
||||||
<button class="button button-secondary" :disabled="blocked" @click="openGenerate(form)">
|
<NButton :disabled="blocked" @click="openGenerate(form)"
|
||||||
<ImagePlus :size="14" />
|
><ImagePlus :size="14" />
|
||||||
{{ isPrimaryIdentityStale(form) ? '基于当前母版重新生成' : coverImage(form) ? '再生成一张' : '生成图片' }}
|
{{
|
||||||
</button>
|
isPrimaryIdentityStale(form)
|
||||||
|
? '基于当前母版重新生成'
|
||||||
|
: coverImage(form)
|
||||||
|
? '再生成一张'
|
||||||
|
: '生成图片'
|
||||||
|
}}</NButton
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -283,21 +313,19 @@ function resetFilters() {
|
|||||||
: '先完成剧本拆解和主体形态持久化,再来生成参考图。'
|
: '先完成剧本拆解和主体形态持久化,再来生成参考图。'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<button v-if="forms.length" class="button button-secondary" @click="resetFilters">清除筛选</button>
|
<NButton v-if="forms.length" @click="resetFilters">清除筛选</NButton>
|
||||||
<RouterLink v-else :to="`/projects/${id}/breakdown`" class="button button-secondary">前往剧本拆解</RouterLink>
|
<RouterLink v-else :to="`/projects/${id}/breakdown`" class="button button-secondary"
|
||||||
</EmptyState>
|
>前往剧本拆解</RouterLink
|
||||||
<GenerateImageDialog
|
> </EmptyState
|
||||||
|
><GenerateImageDialog
|
||||||
v-model:open="generateOpen"
|
v-model:open="generateOpen"
|
||||||
:form="generateForm"
|
:form="generateForm"
|
||||||
:disabled="blocked || !generateForm"
|
:disabled="blocked || !generateForm"
|
||||||
@generate="submitGenerate"
|
@generate="submitGenerate" /><ImageGalleryDialog
|
||||||
/>
|
|
||||||
<ImageGalleryDialog
|
|
||||||
v-model:open="galleryOpen"
|
v-model:open="galleryOpen"
|
||||||
:project-id="id"
|
:project-id="id"
|
||||||
:form="galleryForm"
|
:form="galleryForm"
|
||||||
:disabled="blocked || !galleryForm"
|
:disabled="blocked || !galleryForm"
|
||||||
@changed="query.refresh"
|
@changed="query.refresh"
|
||||||
/>
|
/></WorkspacePage>
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber } 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 { primaryImage, validImageSize } from '../model'
|
import { primaryImage, validImageSize } from '../model'
|
||||||
@@ -51,7 +52,9 @@ function submit() {
|
|||||||
:description="`${form?.subject.name || ''} · ${form?.name || ''}`"
|
:description="`${form?.subject.name || ''} · ${form?.name || ''}`"
|
||||||
>
|
>
|
||||||
<form class="mt-6 space-y-5" @submit.prevent="submit">
|
<form class="mt-6 space-y-5" @submit.prevent="submit">
|
||||||
<p class="alert text-xs">使用后端配置的 Seedream 模型,可能产生费用。每次新增一张图片,不删除历史结果。</p>
|
<NAlert type="info" :show-icon="false" class="text-xs"
|
||||||
|
>使用后端配置的 Seedream 模型,可能产生费用。每次新增一张图片,不删除历史结果。</NAlert
|
||||||
|
>
|
||||||
<p class="text-xs leading-6 text-muted">
|
<p class="text-xs leading-6 text-muted">
|
||||||
{{
|
{{
|
||||||
form?.subject.module === 'character' || form?.subject.module === 'scene'
|
form?.subject.module === 'character' || form?.subject.module === 'scene'
|
||||||
@@ -62,66 +65,68 @@ function submit() {
|
|||||||
</p>
|
</p>
|
||||||
<label class="block"
|
<label class="block"
|
||||||
><span class="field-label">自定义提示词(可选,仅用于本次)</span
|
><span class="field-label">自定义提示词(可选,仅用于本次)</span
|
||||||
><textarea
|
><NInput
|
||||||
id="image-prompt"
|
|
||||||
v-model="prompt"
|
|
||||||
class="input min-h-28"
|
|
||||||
placeholder="留空使用已保存的 generationPrompt,其次使用 appearancePrompt"
|
placeholder="留空使用已保存的 generationPrompt,其次使用 appearancePrompt"
|
||||||
/>
|
:input-props="{ id: 'image-prompt' }"
|
||||||
|
class="min-h-28"
|
||||||
|
v-model:value="prompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
<details v-if="form?.generationPrompt || form?.appearancePrompt" class="text-xs">
|
<NCollapse v-if="form?.generationPrompt || form?.appearancePrompt" class="text-xs"
|
||||||
<summary class="cursor-pointer text-muted">查看后端默认提示词</summary>
|
><NCollapseItem name="details"
|
||||||
<p class="mt-3 whitespace-pre-wrap leading-6">{{ form.generationPrompt ?? form.appearancePrompt }}</p>
|
><template #header>查看后端默认提示词</template>
|
||||||
</details>
|
<p class="mt-3 whitespace-pre-wrap leading-6">
|
||||||
|
{{ form.generationPrompt ?? form.appearancePrompt }}
|
||||||
|
</p></NCollapseItem
|
||||||
|
></NCollapse
|
||||||
|
>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<label
|
<label
|
||||||
><span class="field-label">宽度(px)</span
|
><span class="field-label">宽度(px)</span
|
||||||
><input
|
><NInputNumber
|
||||||
id="image-width"
|
placeholder="后端默认"
|
||||||
v-model.number="width"
|
:input-props="{ id: 'image-width' }"
|
||||||
class="input"
|
:value="typeof width === 'number' ? width : null"
|
||||||
type="number"
|
@update:value="width = $event ?? ''"
|
||||||
min="1"
|
:min="1"
|
||||||
step="1"
|
:step="1"
|
||||||
placeholder="后端默认" /></label
|
></NInputNumber></label
|
||||||
><label
|
><label
|
||||||
><span class="field-label">高度(px)</span
|
><span class="field-label">高度(px)</span
|
||||||
><input
|
><NInputNumber
|
||||||
id="image-height"
|
|
||||||
v-model.number="height"
|
|
||||||
class="input"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
placeholder="后端默认"
|
placeholder="后端默认"
|
||||||
/></label>
|
:input-props="{ id: 'image-height' }"
|
||||||
|
:value="typeof height === 'number' ? height : null"
|
||||||
|
@update:value="height = $event ?? ''"
|
||||||
|
:min="1"
|
||||||
|
:step="1"
|
||||||
|
></NInputNumber
|
||||||
|
></label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted">宽高同时留空时使用后端默认 2K;自定义尺寸须满足模型限制。</p>
|
<p class="text-xs text-muted">宽高同时留空时使用后端默认 2K;自定义尺寸须满足模型限制。</p>
|
||||||
<p v-if="!dimensionsValid" class="text-xs text-danger" role="alert">宽高需要同时填写正整数,或同时留空。</p>
|
<p v-if="!dimensionsValid" class="text-xs text-danger" role="alert">宽高需要同时填写正整数,或同时留空。</p>
|
||||||
<p v-if="!hasPrompt" class="text-xs text-danger" role="alert">当前形态没有可用提示词,请填写本次提示词。</p>
|
<p v-if="!hasPrompt" class="text-xs text-danger" role="alert">当前形态没有可用提示词,请填写本次提示词。</p>
|
||||||
<label class="flex gap-2 text-sm"
|
<NCheckbox v-model:checked="setPrimary" class="flex gap-2 text-sm">生成成功后设为此形态主参考图</NCheckbox>
|
||||||
><input v-model="setPrimary" type="checkbox" class="accent-accent" />生成成功后设为此形态主参考图</label
|
|
||||||
>
|
|
||||||
<p v-if="setPrimary && form && primaryImage(form.images)" class="text-xs text-muted">
|
<p v-if="setPrimary && form && primaryImage(form.images)" class="text-xs text-muted">
|
||||||
会替换当前主图标记,旧图片保留。已有视频提示词不会自动更新。
|
会替换当前主图标记,旧图片保留。已有视频提示词不会自动更新。
|
||||||
</p>
|
</p>
|
||||||
<label class="flex items-start gap-2 text-xs leading-6"
|
<NCheckbox
|
||||||
><input
|
id="confirm-image-cost"
|
||||||
id="confirm-image-cost"
|
v-model:checked="acknowledged"
|
||||||
v-model="acknowledged"
|
class="flex items-start gap-2 text-xs leading-6"
|
||||||
type="checkbox"
|
>我已确认后台没有同项目的生成任务,了解本次会调用模型并产生费用。</NCheckbox
|
||||||
class="mt-1.5 accent-accent"
|
|
||||||
/>我已确认后台没有同项目的生成任务,了解本次会调用模型并产生费用。</label
|
|
||||||
>
|
>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<button type="button" class="button button-secondary" @click="open = false">取消</button
|
<NButton @click="open = false">取消</NButton
|
||||||
><button
|
><NButton
|
||||||
type="submit"
|
|
||||||
class="button button-primary"
|
|
||||||
:disabled="disabled || !acknowledged || !dimensionsValid || !hasPrompt"
|
:disabled="disabled || !acknowledged || !dimensionsValid || !hasPrompt"
|
||||||
|
type="primary"
|
||||||
|
attr-type="submit"
|
||||||
>
|
>
|
||||||
确认生成图片
|
确认生成图片
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NCollapse, NCollapseItem, 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'
|
||||||
@@ -83,14 +84,16 @@ async function choosePrimary() {
|
|||||||
>
|
>
|
||||||
<div class="mt-5 flex items-center justify-between">
|
<div class="mt-5 flex items-center justify-between">
|
||||||
<span class="text-xs text-muted">{{ images.length }} 条图片记录</span
|
<span class="text-xs text-muted">{{ images.length }} 条图片记录</span
|
||||||
><button class="text-button" :disabled="query.loading.value" @click="query.refresh">
|
><NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
||||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图片
|
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新图片
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="query.error.value" class="alert alert-error mt-3" role="alert">
|
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-3"
|
||||||
{{ query.error.value }} 当前保留上次读取的记录,主图操作已暂停。
|
>{{ query.error.value }} 当前保留上次读取的记录,主图操作已暂停。
|
||||||
</p>
|
</NAlert>
|
||||||
<p v-if="operation.error" class="alert alert-error mt-3" role="alert">{{ operation.error }}</p>
|
<NAlert v-if="operation.error" role="alert" type="error" :show-icon="false" class="mt-3">{{
|
||||||
|
operation.error
|
||||||
|
}}</NAlert>
|
||||||
<div v-if="selected" class="mt-4">
|
<div v-if="selected" class="mt-4">
|
||||||
<AssetImage
|
<AssetImage
|
||||||
:key="selected.id"
|
:key="selected.id"
|
||||||
@@ -101,8 +104,11 @@ async function choosePrimary() {
|
|||||||
/>
|
/>
|
||||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<StatusBadge :status="selected.status" /><span v-if="selected.isPrimary" class="tag"
|
<StatusBadge :status="selected.status" /><NTag
|
||||||
>当前主参考图</span
|
v-if="selected.isPrimary"
|
||||||
|
size="small"
|
||||||
|
:bordered="false"
|
||||||
|
>当前主参考图</NTag
|
||||||
><span class="text-xs text-muted">{{ selected.width || '—' }} × {{ selected.height || '—' }}</span>
|
><span class="text-xs text-muted">{{ selected.width || '—' }} × {{ selected.height || '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
@@ -114,36 +120,37 @@ async function choosePrimary() {
|
|||||||
>打开原图<ExternalLink :size="12"
|
>打开原图<ExternalLink :size="12"
|
||||||
/></a>
|
/></a>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="selected.error" class="alert alert-error mt-3 whitespace-pre-wrap" role="alert">
|
<NAlert
|
||||||
{{ selected.error }}
|
v-if="selected.error"
|
||||||
</p>
|
role="alert"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
class="mt-3 whitespace-pre-wrap"
|
||||||
|
>{{ selected.error }}</NAlert
|
||||||
|
>
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||||
<button
|
<NButton v-if="!selected.isPrimary" :disabled="!canSetPrimary" @click="confirming = true">
|
||||||
v-if="!selected.isPrimary"
|
设为主参考图</NButton
|
||||||
class="button button-secondary"
|
|
||||||
:disabled="!canSetPrimary"
|
|
||||||
@click="confirming = true"
|
|
||||||
>
|
|
||||||
设为主参考图</button
|
|
||||||
><span class="text-[11px] text-muted"
|
><span class="text-[11px] text-muted"
|
||||||
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
|
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="confirming" class="alert mt-3">
|
<NAlert v-if="confirming" type="info" :show-icon="false" class="mt-3"
|
||||||
<p class="text-xs">确认将这张图用于此形态后续的分镜参考?旧主图仍会保留,此操作不调用模型。</p>
|
><p class="text-xs">确认将这张图用于此形态后续的分镜参考?旧主图仍会保留,此操作不调用模型。</p>
|
||||||
<div class="mt-3 flex gap-3">
|
<div class="mt-3 flex gap-3">
|
||||||
<button class="button button-primary" :disabled="!canSetPrimary" @click="choosePrimary">
|
<NButton :disabled="!canSetPrimary" @click="choosePrimary" type="primary"> 确认切换主图</NButton
|
||||||
确认切换主图</button
|
><NButton @click="confirming = false" text size="small">取消</NButton>
|
||||||
><button class="text-button" @click="confirming = false">取消</button>
|
</div></NAlert
|
||||||
</div>
|
>
|
||||||
</div>
|
<NCollapse v-if="selected.prompt" class="mt-4 text-xs"
|
||||||
<details v-if="selected.prompt" class="mt-4 text-xs">
|
><NCollapseItem name="details"
|
||||||
<summary class="cursor-pointer text-muted">查看本次实际提示词</summary>
|
><template #header>查看本次实际提示词</template>
|
||||||
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p>
|
<p class="mt-3 whitespace-pre-wrap leading-6">{{ selected.prompt }}</p>
|
||||||
<p v-if="selected.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6">
|
<p v-if="selected.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6">
|
||||||
Negative prompt: {{ selected.negativePrompt }}
|
Negative prompt: {{ selected.negativePrompt }}
|
||||||
</p>
|
</p></NCollapseItem
|
||||||
</details>
|
></NCollapse
|
||||||
|
>
|
||||||
<p class="mt-3 break-all font-mono text-[10px] text-muted">Image ID · {{ selected.id }}</p>
|
<p class="mt-3 break-all font-mono text-[10px] text-muted">Image ID · {{ selected.id }}</p>
|
||||||
<p class="mt-2 break-all text-[11px] text-muted">
|
<p class="mt-2 break-all text-[11px] text-muted">
|
||||||
{{
|
{{
|
||||||
@@ -153,16 +160,15 @@ async function choosePrimary() {
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<div class="image-history mt-5" aria-label="图片历史">
|
<div class="image-history mt-5" aria-label="图片历史">
|
||||||
<button
|
<NButton
|
||||||
v-for="image in images"
|
v-for="image in images"
|
||||||
:key="image.id"
|
:key="image.id"
|
||||||
class="image-history-item"
|
|
||||||
:class="{ selected: image.id === selected.id }"
|
|
||||||
:aria-label="`查看图片 ${image.id}`"
|
:aria-label="`查看图片 ${image.id}`"
|
||||||
:aria-pressed="image.id === selected.id"
|
:aria-pressed="image.id === selected.id"
|
||||||
@click="selectedId = image.id"
|
@click="selectedId = image.id"
|
||||||
>
|
class="image-history-item"
|
||||||
<AssetImage
|
:class="{ selected: image.id === selected.id }"
|
||||||
|
><AssetImage
|
||||||
:src="image.status === 'completed' ? image.imageUrl : null"
|
:src="image.status === 'completed' ? image.imageUrl : null"
|
||||||
:alt="`${form?.name}候选图`"
|
:alt="`${form?.name}候选图`"
|
||||||
:retryable="false"
|
:retryable="false"
|
||||||
@@ -177,8 +183,8 @@ async function choosePrimary() {
|
|||||||
: { completed: '候选图', failed: '失败记录', pending: '排队中', generating: '生成中' }[
|
: { completed: '候选图', failed: '失败记录', pending: '排队中', generating: '生成中' }[
|
||||||
image.status
|
image.status
|
||||||
]
|
]
|
||||||
}}</span>
|
}}</span></NButton
|
||||||
</button>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="py-10 text-center text-sm text-muted">
|
<p v-else class="py-10 text-center text-sm text-muted">
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import GenerateImageDialog from './components/GenerateImageDialog.vue'
|
|||||||
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
|
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
|
||||||
import { coverImage, hasRunningImages, primaryImage, validImageSize } from './model'
|
import { coverImage, hasRunningImages, primaryImage, validImageSize } from './model'
|
||||||
import { formFixture, imageFixture } from './testing/fixtures'
|
import { formFixture, imageFixture } from './testing/fixtures'
|
||||||
|
import { expandSections } from '../../testing/naive'
|
||||||
|
|
||||||
let wrapper: VueWrapper | undefined
|
let wrapper: VueWrapper | undefined
|
||||||
/** Reka 将弹窗挂载到 body,需要从实际弹窗找到按钮。 */
|
/** Naive 将弹窗挂载到 body,需要从实际弹窗找到按钮。 */
|
||||||
function button(label: string): HTMLButtonElement {
|
function button(label: string): HTMLButtonElement {
|
||||||
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
|
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
|
||||||
if (!element) throw new Error('找不到按钮:' + label)
|
if (!element) throw new Error('找不到按钮:' + label)
|
||||||
@@ -88,7 +89,7 @@ describe('形态图片选择与操作', () => {
|
|||||||
await wrapper.setProps({ form: { ...form, id: 'form-db-2' } })
|
await wrapper.setProps({ form: { ...form, id: 'form-db-2' } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(width.value).toBe('')
|
expect(width.value).toBe('')
|
||||||
expect(document.querySelector<HTMLInputElement>('#confirm-image-cost')!.checked).toBe(false)
|
expect(document.querySelector('#confirm-image-cost')!.getAttribute('aria-checked')).toBe('false')
|
||||||
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
|
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
button('确认生成图片').click()
|
button('确认生成图片').click()
|
||||||
@@ -125,6 +126,7 @@ describe('形态图片选择与操作', () => {
|
|||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(fetcher.mock.calls).toHaveLength(1)
|
expect(fetcher.mock.calls).toHaveLength(1)
|
||||||
|
await expandSections()
|
||||||
expect(document.body.textContent).toContain('<script>模型提示词</script>')
|
expect(document.body.textContent).toContain('<script>模型提示词</script>')
|
||||||
expect(document.querySelector('[role="dialog"] script')).toBeNull()
|
expect(document.querySelector('[role="dialog"] script')).toBeNull()
|
||||||
button('设为主参考图').click()
|
button('设为主参考图').click()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
|
import { NAlert, NButton, NTag } from 'naive-ui'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { usePolling } from '../../composables/usePolling'
|
import { usePolling } from '../../composables/usePolling'
|
||||||
import { runOperation } from '../workflows/operations'
|
import { runOperation } from '../workflows/operations'
|
||||||
@@ -87,18 +89,22 @@ function removeImage(image: VisualStyleImage) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="mt-7">
|
<WorkspacePage
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
><template #header
|
||||||
<div>
|
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
<h2 class="text-lg font-semibold">项目视觉风格</h2>
|
<div>
|
||||||
<p class="mt-2 text-sm text-muted">确定项目统一的视觉语言,再为人物、场景和道具建立稳定身份。</p>
|
<h2 class="text-lg font-semibold">项目视觉风格</h2>
|
||||||
</div>
|
<p class="mt-2 text-sm text-muted">确定项目统一的视觉语言,再为人物、场景和道具建立稳定身份。</p>
|
||||||
<RouterLink :to="`/projects/${projectId}/subject-identity`" class="text-button"
|
</div>
|
||||||
>下一步:主体身份 →</RouterLink
|
<RouterLink :to="`/projects/${projectId}/subject-identity`" class="text-button"
|
||||||
>
|
>下一步:主体身份 →</RouterLink
|
||||||
</div>
|
>
|
||||||
|
</div></template
|
||||||
|
>
|
||||||
<div class="my-5 flex flex-wrap items-center gap-3">
|
<div class="my-5 flex flex-wrap items-center gap-3">
|
||||||
<span class="tag">{{ style ? (style.isLocked ? '已锁定' : '未锁定') : '尚未创建' }}</span>
|
<NTag size="small" :bordered="false">{{
|
||||||
|
style ? (style.isLocked ? '已锁定' : '未锁定') : '尚未创建'
|
||||||
|
}}</NTag>
|
||||||
<ConfirmAction
|
<ConfirmAction
|
||||||
:label="style ? 'AI 重新生成风格' : 'AI 生成风格'"
|
:label="style ? 'AI 重新生成风格' : 'AI 生成风格'"
|
||||||
:disabled="disabled || !!style?.isLocked || dirty"
|
:disabled="disabled || !!style?.isLocked || dirty"
|
||||||
@@ -106,26 +112,24 @@ function removeImage(image: VisualStyleImage) {
|
|||||||
description="调用文本模型生成整体及分类风格。重新生成会覆盖已保存的文本与硬约束;旧身份、旧图片和已有提示词不会自动更新。"
|
description="调用文本模型生成整体及分类风格。重新生成会覆盖已保存的文本与硬约束;旧身份、旧图片和已有提示词不会自动更新。"
|
||||||
@confirm="generate"
|
@confirm="generate"
|
||||||
/>
|
/>
|
||||||
<button class="text-button" :disabled="query.loading.value" @click="query.refresh">刷新风格</button>
|
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small">刷新风格</NButton>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="query.error.value" class="alert alert-error mb-4" role="alert">
|
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mb-4"
|
||||||
{{ query.error.value }} 请确认后端 dev 已更新并重启;查询失败不等同于尚未创建。
|
>{{ query.error.value }} 请确认后端 dev 已更新并重启;查询失败不等同于尚未创建。
|
||||||
</p>
|
</NAlert>
|
||||||
<p v-if="dirty" class="mb-4 text-xs text-muted">
|
<p v-if="dirty" class="mb-4 text-xs text-muted">
|
||||||
有未保存修改,AI 重生成暂不可用。自动刷新不会覆盖编辑内容;离开页面不会自动保存。
|
有未保存修改,AI 重生成暂不可用。自动刷新不会覆盖编辑内容;离开页面不会自动保存。
|
||||||
</p>
|
</p>
|
||||||
<p class="alert mb-5 text-xs">
|
<NAlert type="info" :show-icon="false" class="mb-5 text-xs">
|
||||||
AI
|
AI
|
||||||
生成时,若项目和世界观没有明确其它人物背景,人物风格默认采用中国人物选角基线;人工填写的人物背景和角色事实优先。修改风格只影响之后的生成,旧资产不会自动更新。
|
生成时,若项目和世界观没有明确其它人物背景,人物风格默认采用中国人物选角基线;人工填写的人物背景和角色事实优先。修改风格只影响之后的生成,旧资产不会自动更新。 </NAlert
|
||||||
</p>
|
><StyleEditor
|
||||||
<StyleEditor
|
|
||||||
v-if="query.data.value"
|
v-if="query.data.value"
|
||||||
:key="editorRevision"
|
:key="editorRevision"
|
||||||
:style="style"
|
:visual-style="style"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@save="save"
|
@save="save"
|
||||||
@dirty="dirty = $event"
|
@dirty="dirty = $event" />
|
||||||
/>
|
|
||||||
<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"
|
||||||
@@ -134,6 +138,5 @@ function removeImage(image: VisualStyleImage) {
|
|||||||
@add="addImage"
|
@add="addImage"
|
||||||
@toggle="toggleImage"
|
@toggle="toggleImage"
|
||||||
@remove="removeImage"
|
@remove="removeImage"
|
||||||
/>
|
/></WorkspacePage>
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NButton, NCheckbox, NInput, NTag } from 'naive-ui'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import type { SaveVisualStyleInput, VisualStyle } from '../types'
|
import type { SaveVisualStyleInput, VisualStyle } from '../types'
|
||||||
|
|
||||||
/** 编辑草稿独立于轮询结果,刷新不会覆盖尚未保存的输入。 */
|
/** 编辑草稿独立于轮询结果,刷新不会覆盖尚未保存的输入。 */
|
||||||
const props = defineProps<{ style: VisualStyle | null; disabled: boolean }>()
|
const props = defineProps<{ visualStyle: VisualStyle | null; disabled: boolean }>()
|
||||||
const emit = defineEmits<{ save: [input: SaveVisualStyleInput]; dirty: [value: boolean] }>()
|
const emit = defineEmits<{ save: [input: SaveVisualStyleInput]; dirty: [value: boolean] }>()
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -27,7 +28,7 @@ const constraintsError = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
watch(
|
watch(
|
||||||
() => props.style,
|
() => props.visualStyle,
|
||||||
value => {
|
value => {
|
||||||
if (baseline.value && dirty.value) return
|
if (baseline.value && dirty.value) return
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
@@ -65,63 +66,84 @@ function save() {
|
|||||||
<fieldset :disabled="disabled" class="space-y-5">
|
<fieldset :disabled="disabled" class="space-y-5">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h3 class="font-medium">风格说明</h3>
|
<h3 class="font-medium">风格说明</h3>
|
||||||
<span v-if="dirty" class="tag">有未保存修改</span>
|
<NTag v-if="dirty" size="small" :bordered="false">有未保存修改</NTag>
|
||||||
</div>
|
</div>
|
||||||
<label class="block"
|
<label class="block"
|
||||||
><span class="field-label">风格名称</span><input id="style-name" v-model="form.name" class="input"
|
><span class="field-label">风格名称</span
|
||||||
/></label>
|
><NInput :disabled="disabled" :input-props="{ id: 'style-name' }" v-model:value="form.name"></NInput
|
||||||
|
></label>
|
||||||
<label class="block"
|
<label class="block"
|
||||||
><span class="field-label">整体视觉语言</span
|
><span class="field-label">整体视觉语言</span
|
||||||
><textarea
|
><NInput
|
||||||
id="style-prompt"
|
:disabled="disabled"
|
||||||
v-model="form.prompt"
|
|
||||||
class="input min-h-32"
|
|
||||||
placeholder="媒介、真实感、色彩和整体美术方向"
|
placeholder="媒介、真实感、色彩和整体美术方向"
|
||||||
/>
|
:input-props="{ id: 'style-prompt' }"
|
||||||
|
class="min-h-32"
|
||||||
|
v-model:value="form.prompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
<div class="grid gap-5 lg:grid-cols-3">
|
<div class="grid gap-5 lg:grid-cols-3">
|
||||||
<label
|
<label
|
||||||
><span class="field-label">人物风格与选角背景</span
|
><span class="field-label">人物风格与选角背景</span
|
||||||
><textarea
|
><NInput
|
||||||
v-model="form.characterPrompt"
|
:disabled="disabled"
|
||||||
class="input min-h-32"
|
|
||||||
placeholder="人物质感、妆造与默认选角背景;留空时 AI 默认采用中国人物基线"
|
placeholder="人物质感、妆造与默认选角背景;留空时 AI 默认采用中国人物基线"
|
||||||
/>
|
class="min-h-32"
|
||||||
|
v-model:value="form.characterPrompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
<label
|
<label
|
||||||
><span class="field-label">场景风格补充</span
|
><span class="field-label">场景风格补充</span
|
||||||
><textarea v-model="form.scenePrompt" class="input min-h-32" />
|
><NInput
|
||||||
|
:disabled="disabled"
|
||||||
|
class="min-h-32"
|
||||||
|
v-model:value="form.scenePrompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
<label
|
<label
|
||||||
><span class="field-label">道具风格补充</span
|
><span class="field-label">道具风格补充</span
|
||||||
><textarea v-model="form.propPrompt" class="input min-h-32" />
|
><NInput
|
||||||
|
:disabled="disabled"
|
||||||
|
class="min-h-32"
|
||||||
|
v-model:value="form.propPrompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="block"
|
<label class="block"
|
||||||
><span class="field-label">硬约束 · JSON 字符串数组</span
|
><span class="field-label">硬约束 · JSON 字符串数组</span
|
||||||
><textarea
|
><NInput
|
||||||
id="style-constraints"
|
:disabled="disabled"
|
||||||
v-model="form.constraints"
|
|
||||||
class="input min-h-24 font-mono text-xs"
|
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
/>
|
:input-props="{ id: 'style-constraints' }"
|
||||||
|
class="min-h-24 font-mono text-xs"
|
||||||
|
v-model:value="form.constraints"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
<p v-if="constraintsError" class="text-xs text-danger" role="alert">{{ constraintsError }}</p>
|
<p v-if="constraintsError" class="text-xs text-danger" role="alert">{{ constraintsError }}</p>
|
||||||
<label class="flex items-start gap-2 text-sm"
|
<NCheckbox
|
||||||
><input
|
:disabled="disabled"
|
||||||
id="style-lock"
|
id="style-lock"
|
||||||
v-model="form.isLocked"
|
v-model:checked="form.isLocked"
|
||||||
type="checkbox"
|
class="flex items-start gap-2 text-sm"
|
||||||
class="mt-1 accent-accent"
|
>确认并锁定风格,阻止后续 AI 自动覆盖</NCheckbox
|
||||||
/>确认并锁定风格,阻止后续 AI 自动覆盖</label
|
|
||||||
>
|
>
|
||||||
<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="flex justify-end">
|
||||||
<button class="button button-primary" type="submit" :disabled="disabled || !!constraintsError">
|
<NButton :disabled="disabled || !!constraintsError" type="primary" attr-type="submit">
|
||||||
保存视觉风格
|
保存视觉风格
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import { AssetImage } from '../../../components/ui'
|
import { AssetImage } from '../../../components/ui'
|
||||||
import { referenceImageUrl } from '../../../lib/assets'
|
import { referenceImageUrl } from '../../../lib/assets'
|
||||||
@@ -43,29 +44,36 @@ function remove(image: VisualStyleImage) {
|
|||||||
<fieldset :disabled="disabled" class="flex flex-wrap items-end gap-3">
|
<fieldset :disabled="disabled" class="flex flex-wrap items-end gap-3">
|
||||||
<label class="min-w-48 flex-1"
|
<label class="min-w-48 flex-1"
|
||||||
><span class="field-label">图片地址</span
|
><span class="field-label">图片地址</span
|
||||||
><input
|
><NInput
|
||||||
v-model="form.imageUrl"
|
|
||||||
class="input"
|
|
||||||
placeholder="https://… 或 /storage/…"
|
placeholder="https://… 或 /storage/…"
|
||||||
aria-label="风格图片地址"
|
:input-props="{ 'aria-label': '风格图片地址' }"
|
||||||
/></label>
|
v-model:value="form.imageUrl"
|
||||||
|
></NInput
|
||||||
|
></label>
|
||||||
<label
|
<label
|
||||||
><span class="field-label">分类</span
|
><span class="field-label">分类</span
|
||||||
><select v-model="form.category" class="input">
|
><NSelect
|
||||||
<option v-for="(label, value) in categories" :key="value" :value="value">{{ label }}</option>
|
v-model:value="form.category"
|
||||||
</select></label
|
:options="[
|
||||||
>
|
...Object.entries(categories).map(([value, label]) => ({
|
||||||
|
label: String(label),
|
||||||
|
value: value
|
||||||
|
}))
|
||||||
|
]"
|
||||||
|
class="select-control"
|
||||||
|
></NSelect
|
||||||
|
></label>
|
||||||
<label class="w-24"
|
<label class="w-24"
|
||||||
><span class="field-label">排序</span
|
><span class="field-label">排序</span
|
||||||
><input
|
><NInputNumber
|
||||||
v-model.number="form.sortOrder"
|
:input-props="{ 'aria-label': '风格图片排序' }"
|
||||||
class="input"
|
:value="typeof form.sortOrder === 'number' ? form.sortOrder : null"
|
||||||
type="number"
|
@update:value="form.sortOrder = $event ?? 0"
|
||||||
step="1"
|
:step="1"
|
||||||
aria-label="风格图片排序"
|
></NInputNumber
|
||||||
/></label>
|
></label>
|
||||||
<label class="flex gap-2 pb-2 text-xs"><input v-model="form.enabled" type="checkbox" />启用</label>
|
<NCheckbox v-model:checked="form.enabled" class="flex gap-2 pb-2 text-xs">启用</NCheckbox>
|
||||||
<button class="button button-secondary" type="submit" :disabled="disabled || !valid">登记参考图</button>
|
<NButton :disabled="disabled || !valid" attr-type="submit">登记参考图</NButton>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
<p v-if="form.imageUrl && !valid" class="mt-2 text-xs text-danger" role="alert">
|
<p v-if="form.imageUrl && !valid" class="mt-2 text-xs text-danger" role="alert">
|
||||||
@@ -85,10 +93,12 @@ function remove(image: VisualStyleImage) {
|
|||||||
<p v-if="image.provider || image.model" class="mt-2 break-words text-[11px] text-muted">
|
<p v-if="image.provider || image.model" class="mt-2 break-words text-[11px] text-muted">
|
||||||
{{ image.provider }} {{ image.model }}
|
{{ image.provider }} {{ image.model }}
|
||||||
</p>
|
</p>
|
||||||
<details v-if="image.prompt" class="mt-2 text-xs">
|
<NCollapse v-if="image.prompt" class="mt-2 text-xs"
|
||||||
<summary>实际提示词</summary>
|
><NCollapseItem name="details"
|
||||||
<p class="mt-2 whitespace-pre-wrap">{{ image.prompt }}</p>
|
><template #header>实际提示词</template>
|
||||||
</details>
|
<p class="mt-2 whitespace-pre-wrap">{{ image.prompt }}</p></NCollapseItem
|
||||||
|
></NCollapse
|
||||||
|
>
|
||||||
<div class="mt-3 flex flex-wrap gap-3">
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
<a
|
<a
|
||||||
v-if="referenceImageUrl(image.imageUrl)"
|
v-if="referenceImageUrl(image.imageUrl)"
|
||||||
@@ -98,19 +108,25 @@ function remove(image: VisualStyleImage) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>原图</a
|
>原图</a
|
||||||
>
|
>
|
||||||
<button class="text-button" :disabled="disabled" @click="emit('toggle', image)">
|
<NButton :disabled="disabled" @click="emit('toggle', image)" text size="small">{{
|
||||||
{{ image.enabled ? '停用' : '启用' }}
|
image.enabled ? '停用' : '启用'
|
||||||
</button>
|
}}</NButton>
|
||||||
<button class="text-button text-danger" :disabled="disabled" @click="confirmingId = image.id">
|
<NButton
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="confirmingId = image.id"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="text-danger"
|
||||||
|
>
|
||||||
移除
|
移除
|
||||||
</button>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="confirmingId === image.id" class="mt-3 text-xs">
|
<div v-if="confirmingId === image.id" class="mt-3 text-xs">
|
||||||
<p>仅移除参考图记录,不删除远程文件。</p>
|
<p>仅移除参考图记录,不删除远程文件。</p>
|
||||||
<div class="mt-2 flex gap-3">
|
<div class="mt-2 flex gap-3">
|
||||||
<button class="text-button text-danger" :disabled="disabled" @click="remove(image)">
|
<NButton :disabled="disabled" @click="remove(image)" text size="small" class="text-danger">
|
||||||
确认移除</button
|
确认移除</NButton
|
||||||
><button class="text-button" @click="confirmingId = ''">取消</button>
|
><NButton @click="confirmingId = ''" text size="small">取消</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { NButton, NCheckbox } from 'naive-ui'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { DialogTrigger } from 'reka-ui'
|
|
||||||
import { AppDialog } from '../../components/ui'
|
import { AppDialog } from '../../components/ui'
|
||||||
|
|
||||||
/** 耗费模型额度或覆盖结果的动作必须经过明确确认。 */
|
/** 耗费模型额度或覆盖结果的动作必须经过明确确认。 */
|
||||||
@@ -32,32 +32,24 @@ function confirm() {
|
|||||||
<template>
|
<template>
|
||||||
<AppDialog v-model:open="open" :title="label" :description="description">
|
<AppDialog v-model:open="open" :title="label" :description="description">
|
||||||
<template #trigger
|
<template #trigger
|
||||||
><DialogTrigger
|
><NButton :type="primary ? 'primary' : 'default'" :disabled="disabled" @click="open = true">{{
|
||||||
class="button"
|
label
|
||||||
:class="primary ? 'button-primary' : 'button-secondary'"
|
}}</NButton></template
|
||||||
:disabled="disabled"
|
|
||||||
>{{ label }}</DialogTrigger
|
|
||||||
></template
|
|
||||||
>
|
>
|
||||||
<p class="mt-5 text-sm leading-6 text-muted">
|
<p class="mt-5 text-sm leading-6 text-muted">
|
||||||
此操作会提交到真实后端,可能调用模型并产生费用。请勿在其他页面或终端同时启动相同工作流。
|
此操作会提交到真实后端,可能调用模型并产生费用。请勿在其他页面或终端同时启动相同工作流。
|
||||||
</p>
|
</p>
|
||||||
<label v-if="acknowledgement" class="mt-5 flex items-start gap-3 text-sm leading-6"
|
<NCheckbox
|
||||||
><input
|
v-model:checked="acknowledged"
|
||||||
v-model="acknowledged"
|
v-if="acknowledgement"
|
||||||
class="mt-1 accent-accent"
|
class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||||
type="checkbox"
|
>我已确认后台任务停止,当前没有同项目的生成或拆解任务在运行。</NCheckbox
|
||||||
/>我已确认后台任务停止,当前没有同项目的生成或拆解任务在运行。</label
|
|
||||||
>
|
>
|
||||||
<div class="dialog-footer mt-6">
|
<div class="dialog-footer mt-6">
|
||||||
<button class="button button-secondary" @click="open = false">取消</button
|
<NButton @click="open = false">取消</NButton
|
||||||
><button
|
><NButton :disabled="disabled || (acknowledgement && !acknowledged)" @click="confirm" type="primary">
|
||||||
class="button button-primary"
|
确认{{ label }}</NButton
|
||||||
:disabled="disabled || (acknowledgement && !acknowledged)"
|
|
||||||
@click="confirm"
|
|
||||||
>
|
>
|
||||||
确认{{ label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { NEmpty, NTimeline, NTimelineItem } from 'naive-ui'
|
||||||
import { Clock3 } from '@lucide/vue'
|
import { Clock3 } from '@lucide/vue'
|
||||||
import { formatDate, nodeLabel } from '../../lib/format'
|
import { formatDate, nodeLabel } from '../../lib/format'
|
||||||
import { workflowCheckpoints } from './selectors'
|
import { workflowCheckpoints } from './selectors'
|
||||||
@@ -14,9 +15,13 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
|
|||||||
<aside class="history-panel">
|
<aside class="history-panel">
|
||||||
<h3 class="mb-1 flex items-center gap-2 text-sm font-semibold"><Clock3 :size="15" />执行记录</h3>
|
<h3 class="mb-1 flex items-center gap-2 text-sm font-semibold"><Clock3 :size="15" />执行记录</h3>
|
||||||
<p class="mb-6 text-xs leading-5 text-muted">最近 18 个 checkpoint · 自动刷新</p>
|
<p class="mb-6 text-xs leading-5 text-muted">最近 18 个 checkpoint · 自动刷新</p>
|
||||||
<p v-if="!history.length" class="text-xs leading-6 text-muted">工作流尚未保存执行记录。</p>
|
<NEmpty v-if="!history.length" size="small" description="工作流尚未保存执行记录。" />
|
||||||
<ol v-else class="timeline">
|
<NTimeline v-else>
|
||||||
<li v-for="(item, index) in history" :key="item.checkpointId" :class="{ 'timeline-latest': index === 0 }">
|
<NTimelineItem
|
||||||
|
v-for="item in history"
|
||||||
|
:key="item.checkpointId"
|
||||||
|
:type="item.state.workflowExecution?.status === 'failed' ? 'error' : 'default'"
|
||||||
|
>
|
||||||
<p class="text-xs font-medium">{{ nodeLabel(item.metadata?.nodeName) }}</p>
|
<p class="text-xs font-medium">{{ nodeLabel(item.metadata?.nodeName) }}</p>
|
||||||
<p class="mt-1 text-[11px] text-muted">
|
<p class="mt-1 text-[11px] text-muted">
|
||||||
{{ formatDate(item.createdAt)
|
{{ formatDate(item.createdAt)
|
||||||
@@ -27,8 +32,8 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
|
|||||||
<span v-if="item.state.workflowExecution?.status === 'failed'" class="mt-1 block text-xs text-danger"
|
<span v-if="item.state.workflowExecution?.status === 'failed'" class="mt-1 block text-xs text-danger"
|
||||||
>运行失败</span
|
>运行失败</span
|
||||||
>
|
>
|
||||||
</li>
|
</NTimelineItem>
|
||||||
</ol>
|
</NTimeline>
|
||||||
<p class="mt-5 border-t border-line pt-4 text-[11px] leading-5 text-muted">
|
<p class="mt-5 border-t border-line pt-4 text-[11px] leading-5 text-muted">
|
||||||
记录在节点或批次结束后更新,长时间无新记录不一定意味着任务失败。
|
记录在节点或批次结束后更新,长时间无新记录不一定意味着任务失败。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import SubjectIdentityPage from '../subject-identity/SubjectIdentityPage.vue'
|
|||||||
import { styleFixture, styleImageFixture } from '../visual-style/testing/fixtures'
|
import { styleFixture, styleImageFixture } from '../visual-style/testing/fixtures'
|
||||||
import { identityFixture, identityImageFixture } from '../subject-identity/testing/fixtures'
|
import { identityFixture, identityImageFixture } from '../subject-identity/testing/fixtures'
|
||||||
import { getIdentitySession } from '../subject-identity/model'
|
import { getIdentitySession } from '../subject-identity/model'
|
||||||
|
import { expandSections, selectControl } from '../../testing/naive'
|
||||||
|
import ProductionPage from '../production/ProductionPage.vue'
|
||||||
|
import { getProductionSession } from '../production/model'
|
||||||
|
|
||||||
/** 新资产页面复用已有项目上下文和内存路由,不接入真实模型。 */
|
/** 新资产页面复用已有项目上下文和内存路由,不接入真实模型。 */
|
||||||
async function mountAssets(page: 'style' | 'identity', query = '') {
|
async function mountAssets(page: 'style' | 'identity', query = '') {
|
||||||
@@ -39,6 +42,7 @@ async function mountAssets(page: 'style' | 'identity', query = '') {
|
|||||||
global: { plugins: [router], provide: { [projectContextKey as symbol]: provided } }
|
global: { plugins: [router], provide: { [projectContextKey as symbol]: provided } }
|
||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
if (page === 'identity') await expandSections()
|
||||||
return provided
|
return provided
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +82,7 @@ function context(): ReturnType<typeof useProjectData> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 找到挂载到 body 的 Reka 弹窗按钮。 */
|
/** 找到挂载到 body 的 Naive 弹窗按钮。 */
|
||||||
function button(label: string): HTMLButtonElement {
|
function button(label: string): HTMLButtonElement {
|
||||||
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
|
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
|
||||||
if (!element) throw new Error('找不到按钮:' + label)
|
if (!element) throw new Error('找不到按钮:' + label)
|
||||||
@@ -89,7 +93,7 @@ function button(label: string): HTMLButtonElement {
|
|||||||
async function confirmGeneration(label: string) {
|
async function confirmGeneration(label: string) {
|
||||||
button(label).click()
|
button(label).click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const acknowledgement = document.querySelector<HTMLInputElement>('[role="dialog"] input[type="checkbox"]')
|
const acknowledgement = document.querySelector<HTMLElement>('[role="dialog"] [role="checkbox"]')
|
||||||
expect(button('确认' + label).disabled).toBe(true)
|
expect(button('确认' + label).disabled).toBe(true)
|
||||||
acknowledgement!.click()
|
acknowledgement!.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -98,13 +102,15 @@ async function confirmGeneration(label: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 挂载分镜页面并复用项目上下文 fixture。 */
|
/** 挂载分镜页面并复用项目上下文 fixture。 */
|
||||||
function mountStoryboard() {
|
async function mountStoryboard() {
|
||||||
const provided = context()
|
const provided = context()
|
||||||
provided.data.value!.checkpoints = [storyboardCheckpoint()]
|
provided.data.value!.checkpoints = [storyboardCheckpoint()]
|
||||||
wrapper = mount(StoryboardPage, {
|
wrapper = mount(StoryboardPage, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
global: { provide: { [projectContextKey as symbol]: provided }, stubs: { RouterLink: true } }
|
global: { provide: { [projectContextKey as symbol]: provided }, stubs: { RouterLink: true } }
|
||||||
})
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await expandSections()
|
||||||
return provided
|
return provided
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +126,11 @@ async function mountSubjectImages() {
|
|||||||
global: { plugins: [router], provide: { [projectContextKey as symbol]: context() } }
|
global: { plugins: [router], provide: { [projectContextKey as symbol]: context() } }
|
||||||
})
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
await expandSections()
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
getProductionSession(fixture.id).receipt = null
|
||||||
getIdentitySession(fixture.id).receipt = null
|
getIdentitySession(fixture.id).receipt = null
|
||||||
wrapper?.unmount()
|
wrapper?.unmount()
|
||||||
wrapper = undefined
|
wrapper = undefined
|
||||||
@@ -133,6 +141,38 @@ afterEach(() => {
|
|||||||
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
|
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('内容级滚动下的生产反馈', () => {
|
||||||
|
it('默认折叠总览时查询错误仍显示在内容区外层', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new Error('生产连接中断')))
|
||||||
|
wrapper = mount(ProductionPage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.workspace-overview .n-collapse-item--active').exists()).toBe(false)
|
||||||
|
const alert = wrapper.get('.workspace-split [role="alert"]')
|
||||||
|
expect(alert.element.closest('.workspace-overview')).toBeNull()
|
||||||
|
expect(alert.text()).toContain('无法连接后端')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('回执返回时自动展开总览,部分失败诊断不会留在折叠区内', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new Error('离线测试')))
|
||||||
|
wrapper = mount(ProductionPage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
getProductionSession(fixture.id).receipt = {
|
||||||
|
kind: 'retry',
|
||||||
|
title: '重试失败视频任务',
|
||||||
|
result: { totalFailed: 1, retried: 0, failed: 1, failures: [{ shotId: 'shot-1', error: '供应商不可用' }] }
|
||||||
|
}
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.workspace-overview .n-collapse-item--active').exists()).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('供应商不可用')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('视觉风格与主体身份工作区', () => {
|
describe('视觉风格与主体身份工作区', () => {
|
||||||
it('从形态深链接进入后,手动切换主体不会被目录刷新切回', async () => {
|
it('从形态深链接进入后,手动切换主体不会被目录刷新切回', async () => {
|
||||||
const form = formFixture()
|
const form = formFixture()
|
||||||
@@ -180,7 +220,7 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
await mountAssets('identity')
|
await mountAssets('identity')
|
||||||
await wrapper!.get('#identity-description').setValue('人工稳定身份')
|
await wrapper!.get('#identity-description').setValue('人工稳定身份')
|
||||||
await wrapper!.get('#identity-lock').setValue(true)
|
await wrapper!.get('#identity-lock').trigger('click')
|
||||||
button('保存主体身份').click()
|
button('保存主体身份').click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||||
@@ -226,7 +266,7 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
await mountAssets('style')
|
await mountAssets('style')
|
||||||
button('AI 重新生成风格').click()
|
button('AI 重新生成风格').click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
document.querySelector<HTMLInputElement>('[role="dialog"] input[type="checkbox"]')!.click()
|
document.querySelector<HTMLElement>('[role="dialog"] [role="checkbox"]')!.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(button('确认AI 重新生成风格').disabled).toBe(false)
|
expect(button('确认AI 重新生成风格').disabled).toBe(false)
|
||||||
button('取消').click()
|
button('取消').click()
|
||||||
@@ -250,7 +290,7 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
await wrapper!.get('#style-constraints').setValue('{"不应静默丢弃":true}')
|
await wrapper!.get('#style-constraints').setValue('{"不应静默丢弃":true}')
|
||||||
expect(button('保存视觉风格').disabled).toBe(true)
|
expect(button('保存视觉风格').disabled).toBe(true)
|
||||||
await wrapper!.get('#style-constraints').setValue('["真人写实"]')
|
await wrapper!.get('#style-constraints').setValue('["真人写实"]')
|
||||||
await wrapper!.get('#style-lock').setValue(true)
|
await wrapper!.get('#style-lock').trigger('click')
|
||||||
await wrapper!.get('#style-name').setValue('雨夜风格')
|
await wrapper!.get('#style-name').setValue('雨夜风格')
|
||||||
button('保存视觉风格').click()
|
button('保存视觉风格').click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -625,7 +665,7 @@ describe('工作台页面交互', () => {
|
|||||||
expect(wrapper!.text()).toContain('服务暂不可用')
|
expect(wrapper!.text()).toContain('服务暂不可用')
|
||||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', concurrency: 2, force: false })
|
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', concurrency: 2, force: false })
|
||||||
await wrapper!.get('.panel details input[type="checkbox"]').setValue(true)
|
await wrapper!.get('.n-collapse [role="checkbox"]').trigger('click')
|
||||||
await confirmGeneration('为全项目新增候选图')
|
await confirmGeneration('为全项目新增候选图')
|
||||||
const lastPost = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST').at(-1)!
|
const lastPost = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST').at(-1)!
|
||||||
expect(JSON.parse(lastPost[1]!.body as string).force).toBe(true)
|
expect(JSON.parse(lastPost[1]!.body as string).force).toBe(true)
|
||||||
@@ -740,11 +780,11 @@ describe('工作台页面交互', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
mountStoryboard()
|
await mountStoryboard()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await confirmGeneration('生成本集导演设计')
|
await confirmGeneration('生成本集导演设计')
|
||||||
expect(button('补齐项目导演设计').disabled).toBe(true)
|
expect(button('补齐项目导演设计').disabled).toBe(true)
|
||||||
await wrapper!.get('select').setValue('2')
|
selectControl(wrapper!, 'aria-label', '选择分镜剧集').vm.$emit('update:value', 2)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
wrapper!.unmount()
|
wrapper!.unmount()
|
||||||
wrapper = undefined
|
wrapper = undefined
|
||||||
@@ -791,7 +831,7 @@ describe('工作台页面交互', () => {
|
|||||||
return new Response(JSON.stringify({ data: result }))
|
return new Response(JSON.stringify({ data: result }))
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
mountStoryboard()
|
await mountStoryboard()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(button('生成本集导演设计').disabled).toBe(false)
|
expect(button('生成本集导演设计').disabled).toBe(false)
|
||||||
expect(button('生成本集即时状态').disabled).toBe(true)
|
expect(button('生成本集即时状态').disabled).toBe(true)
|
||||||
@@ -844,12 +884,12 @@ describe('工作台页面交互', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
mountStoryboard()
|
await mountStoryboard()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await confirmGeneration('补齐项目导演设计')
|
await confirmGeneration('补齐项目导演设计')
|
||||||
expect(wrapper!.get('[aria-label="生成回执"] [role="alert"]').text()).toContain('存在失败')
|
expect(wrapper!.get('[aria-label="生成回执"] [role="alert"]').text()).toContain('存在失败')
|
||||||
expect(wrapper!.text()).toContain('模型校验失败')
|
expect(wrapper!.text()).toContain('模型校验失败')
|
||||||
await wrapper!.get('input[type="checkbox"]').setValue(true)
|
await wrapper!.get('[role="checkbox"]').trigger('click')
|
||||||
await confirmGeneration('重生成全部导演设计')
|
await confirmGeneration('重生成全部导演设计')
|
||||||
const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||||
expect(posts.map(([, init]) => JSON.parse(init!.body as string))).toEqual([
|
expect(posts.map(([, init]) => JSON.parse(init!.body as string))).toEqual([
|
||||||
@@ -877,9 +917,9 @@ describe('工作台页面交互', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
mountStoryboard()
|
await mountStoryboard()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await wrapper!.get('select[aria-label="选择分镜剧集"]').setValue('2')
|
selectControl(wrapper!, 'aria-label', '选择分镜剧集').vm.$emit('update:value', 2)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(oldRequests.every(request => request.signal.aborted)).toBe(true)
|
expect(oldRequests.every(request => request.signal.aborted)).toBe(true)
|
||||||
for (const request of oldRequests)
|
for (const request of oldRequests)
|
||||||
@@ -911,7 +951,7 @@ describe('工作台页面交互', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
mountStoryboard()
|
await mountStoryboard()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
fail = true
|
fail = true
|
||||||
button('刷新分镜').click()
|
button('刷新分镜').click()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
import './admin.css'
|
||||||
|
|
||||||
/** 单页应用入口;不在浏览器保存模型密钥。 */
|
/** 单页应用入口;不在浏览器保存模型密钥。 */
|
||||||
createApp(App).use(router).mount('#app')
|
createApp(App).use(router).mount('#app')
|
||||||
|
|||||||
+1
-2
@@ -57,8 +57,7 @@ export const router = createRouter({
|
|||||||
component: () => import('../components/NotFoundPage.vue'),
|
component: () => import('../components/NotFoundPage.vue'),
|
||||||
meta: { title: '页面不存在' }
|
meta: { title: '页面不存在' }
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
scrollBehavior: () => ({ top: 0 })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 路由切换同步浏览器标题,不依赖页面组件主动修改。 */
|
/** 路由切换同步浏览器标题,不依赖页面组件主动修改。 */
|
||||||
|
|||||||
+99
-527
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { NSelect } from 'naive-ui'
|
||||||
|
|
||||||
|
/** 展开真实折叠面板后再测试内部操作,避免对不可见控件进行断言。 */
|
||||||
|
export async function expandSections() {
|
||||||
|
for (let depth = 0; depth < 4; depth++) {
|
||||||
|
const headers = document.querySelectorAll<HTMLElement>(
|
||||||
|
'.n-collapse-item:not(.n-collapse-item--active) > .n-collapse-item__header .n-collapse-item__header-main'
|
||||||
|
)
|
||||||
|
if (!headers.length) return
|
||||||
|
headers.forEach(header => header.click())
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Naive Select 没有原生 select,通过公开事件模拟选项变化并保留业务守卫。 */
|
||||||
|
export function selectControl(wrapper: VueWrapper, attribute: string, value: string) {
|
||||||
|
const select = wrapper.findAllComponents(NSelect).find(item => item.attributes(attribute) === value)
|
||||||
|
if (!select) throw new Error('找不到选择框:' + value)
|
||||||
|
return select
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user