feat: 实现剧本创作与拆解前端工作台
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 浏览器请求地址。开发环境通过 Vite 代理,生产环境需配置同源反向代理。
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
# 仅供 Vite 开发服务器使用,不会暴露后端密钥。
|
||||||
|
API_PROXY_TARGET=http://localhost:3412
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
.eslintcache
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{ "recommendations": ["Vue.volar", "oxc.oxc-vscode", "dbaeumer.vscode-eslint", "bradlc.vscode-tailwindcss"] }
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
|
||||||
|
"vue.server.hybridMode": true
|
||||||
|
}
|
||||||
@@ -1,5 +1,150 @@
|
|||||||
# Short Drama Agent Front
|
# Short Drama Agent Front
|
||||||
|
|
||||||
短剧 Agent 前端工作台,采用 Vite、Vue、TypeScript、Tailwind CSS 与 Reka UI。
|
短剧 Agent 的前端工作台。第一阶段支持 `create-drama` 与 `breakdown`,使用真实后端 API,不包含演示数据或浏览器端模型调用。
|
||||||
|
|
||||||
初期开发在 `dev` 分支,功能范围为 `create-drama` 与 `breakdown`。
|
## 启动
|
||||||
|
|
||||||
|
需要 Node.js >= 22.18 和 pnpm 11。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
cp .env.example .env
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问 http://localhost:5173。先在后端仓库启动 `pnpm dev`,默认监听 **3412**。
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
API_PROXY_TARGET=http://localhost:3412
|
||||||
|
```
|
||||||
|
|
||||||
|
更改环境变量后重启 Vite。后端若运行在其他机器或端口,请修改 `API_PROXY_TARGET`。不能把 API Key、数据库密码等秘密写入任何 `VITE_*` 变量。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
| 工作区 | 已实现 |
|
||||||
|
| --- | --- |
|
||||||
|
| 项目列表 | 真实项目读取、搜索、状态筛选、新建剧本 |
|
||||||
|
| 剧本创作 | 主题/风格/集数配置、剧集目录与正文、角色、世界观、审核、执行记录、正文导出 |
|
||||||
|
| 创作恢复 | 恢复剧集生成、恢复改写,操作前确认 |
|
||||||
|
| 拆解配置 | 每组集数、人物/场景/道具模块选择、后端分组预览 |
|
||||||
|
| 拆解结果 | 主体、别名、视觉形态、Episode → Beat → Shot、主体绑定、抽取任务状态、校验问题、JSON 导出 |
|
||||||
|
| 拆解恢复 | 失败抽取重试、缺失剧集镜头补齐、分镜引用与 Form 绑定修复 |
|
||||||
|
|
||||||
|
本阶段没有实现:手工编辑剧本(后端暂无对应写接口)、StoryboardDirection graph、图像或视频生成、用户登录。正文中的模型输出按纯文本显示,避免执行不可信 HTML。
|
||||||
|
|
||||||
|
## 技术与规范
|
||||||
|
|
||||||
|
- Vite 8、Vue 3、TypeScript、Vue Router
|
||||||
|
- Tailwind CSS 4,通过 `@tailwindcss/vite` 集成
|
||||||
|
- Reka UI:Dialog、Tabs、Checkbox、Progress 等无样式基础组件
|
||||||
|
- Oxlint:脚本质量检查;ESLint:补充 Vue 模板语义
|
||||||
|
- Oxfmt(不是 `oxformat`):沿用后端四空格、单引号、无分号、无尾逗号的风格
|
||||||
|
- Vitest + Vue Test Utils + happy-dom:API 契约、组件交互和异步生命周期测试
|
||||||
|
- Lefthook + Commitlint:提交前执行检查,提交信息使用 `feat:`、`fix:`、`refactor:` 等
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm check # lint + format:check + typecheck + test
|
||||||
|
pnpm format # 格式化
|
||||||
|
pnpm lint:fix # 修复可自动处理的问题
|
||||||
|
pnpm build # Vue 类型检查 + 生产构建
|
||||||
|
pnpm preview # 预览 dist,不含开发代理
|
||||||
|
```
|
||||||
|
|
||||||
|
Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue SFC 的类型检查由 `vue-tsc` 承担。未照搬后端 `--type-aware` 来冒充完整 Vue 类型检查。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
| 目录 | 职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/features/projects` | 项目接口、类型、列表、新建弹窗、项目布局与共享上下文 |
|
||||||
|
| `src/features/create-drama` | 剧本创作 graph 页面 |
|
||||||
|
| `src/features/breakdown` | 拆解 graph 的 API、类型、配置页面、主体与分镜组件 |
|
||||||
|
| `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 |
|
||||||
|
| `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 |
|
||||||
|
| `src/composables` | 具备取消和竞态保护的轮询 |
|
||||||
|
| `src/lib` | HTTP、错误格式、日期和导出工具 |
|
||||||
|
| `src/router` | 按 graph 拆分的懒加载路由 |
|
||||||
|
|
||||||
|
每个功能模块通过 `index.ts` 暴露公共入口。业务类型和行为函数均添加中文职责注释。不要把下一条 graph 的服务继续堆进 `App.vue`。
|
||||||
|
|
||||||
|
## 接口基线
|
||||||
|
|
||||||
|
对齐后端 `qianlanse/short-drama-agent` 的 `dev`:
|
||||||
|
`de47d42eddd5acdbd32a8b4ed3044f9155d18508`。
|
||||||
|
|
||||||
|
| HTTP | 路径(以 /api 为前缀) | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET / POST | `/projects` | 项目列表 / 创建剧本 |
|
||||||
|
| GET | `/projects/:id` | 正式数据库详情、剧集、角色、世界观与审核 |
|
||||||
|
| GET | `/projects/:id/checkpoints` | 两条 graph 的 checkpoint;前端按 workflowName 过滤 |
|
||||||
|
| POST | `/projects/:id/resume-generation` | 继续生成未完成剧集 |
|
||||||
|
| POST | `/projects/:id/resume-rewrite` | 恢复审核与改写 |
|
||||||
|
| GET | `/projects/:id/breakdown-preview?groupSize=3&modules=character,scene,prop` | 真实分组与任务预览 |
|
||||||
|
| POST | `/projects/:id/breakdown/start` | 启动拆解 |
|
||||||
|
| POST | `/projects/:id/breakdown/retry` | 仅重试失败的抽取 Task |
|
||||||
|
| POST | `/projects/:id/breakdown/resume-shots` | 复用已成功剧集镜头,生成缺失剧集 |
|
||||||
|
| POST | `/projects/:id/breakdown/resume-storyboard` | 修复已有镜头的 SubjectRef / Form,再持久化 |
|
||||||
|
|
||||||
|
API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。
|
||||||
|
|
||||||
|
### 异步与恢复约定
|
||||||
|
|
||||||
|
1. `POST /projects` 返回顶层 `202 { projectId, status }`,其他接口通常返回 `{ data }`;HTTP 层分别兼容。
|
||||||
|
2. Breakdown 和恢复接口目前是**长 HTTP 请求**。不设客户端短超时,不自动重试 POST。关闭页面只会丢失请求连接,**不会取消后端任务**。
|
||||||
|
3. 项目列表每 12 秒、项目详情每 6 秒串行刷新。隐藏页面暂停自动查询。切换项目或离开布局时取消查询,旧响应不会覆盖新项目。
|
||||||
|
4. 抽取任务进度不是全流程进度。抽取完成之后还需要主体合并、形态、节拍、镜头和持久化。
|
||||||
|
5. 后端失败 checkpoint 可能只有错误。页面回看最近可用的完整快照以保留成果,同时使用最新记录的 execution 状态。显示的是**最近可用快照**,并不保证全部成果来自最后一次执行。
|
||||||
|
6. 后端并不为每个阶段提供持久化的 running 状态。没有明确终态时,页面显示“阶段快照 · 执行状态待确认”,不会猜测成功或失败。
|
||||||
|
7. 恢复操作要求确认后台已停止。当前只做浏览器会话内、项目级互斥,不能替代后端跨浏览器/跨用户的任务锁。断网后先检查后台和 checkpoint,不要立即重复点击。
|
||||||
|
8. 修改分组或模块后,必须重新预览再启动;重新拆解会调用完整流程,可能替换旧的主体和分镜。
|
||||||
|
|
||||||
|
### 后端限制
|
||||||
|
|
||||||
|
目前 `checkpoints` 接口返回完整历史与 state,没有分页或轻量状态接口。长篇剧本会增加轮询流量。下一步建议后端增加:
|
||||||
|
|
||||||
|
- 持久化 executionId、运行状态、跨请求互斥和幂等键;
|
||||||
|
- 精简的进度接口及按 graph 分页的 checkpoint;
|
||||||
|
- 将 Breakdown 改为 202 异步任务提交,再以轮询或 SSE 读取进度。
|
||||||
|
|
||||||
|
前端没有假装这些能力已经存在,也没有为了显示进度去调用 `stream-test` 等测试接口。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
`pnpm build` 生成 `dist`,使用 Nginx 等静态服务器部署。SPA 深层路由需要回退到 `index.html`。
|
||||||
|
开发代理只存在于 Vite dev server,`vite preview` 与生产环境需配置独立后端地址或反向代理。
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /var/www/short-drama-agent-front/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
# 保留 /api 前缀,不在 proxy_pass 后追加斜杠。
|
||||||
|
proxy_pass http://127.0.0.1:3412;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实际生成时间可能超过代理限制,仍建议将后端改为异步提交协议。部署前应给应用加访问控制;当前后端没有身份认证,不建议直接对公网开放。
|
||||||
|
|
||||||
|
## 本地联调验收
|
||||||
|
|
||||||
|
1. 后端启动成功,前端项目列表能显示数据库项目。
|
||||||
|
2. 新建 3 集剧本,确认获取项目 ID 后进入创作页,完成后能读到正文。
|
||||||
|
3. 查看角色、世界观与审核,再进入拆解页;预览分组与模块。
|
||||||
|
4. 启动拆解,查看 checkpoint、主体、形态、每集 Beat / Shot 和绑定。
|
||||||
|
5. 在可控测试项目中触发故障,确认 retry / resume-shots / resume-storyboard 各自对应正确失败阶段。
|
||||||
|
|
||||||
|
自动测试使用模拟 API,只验证前端契约与交互,不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** 与后端一致,使用 Conventional Commits,例如 feat: 实现剧本工作台。 */
|
||||||
|
export default { extends: ['@commitlint/config-conventional'] }
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import tsParser from '@typescript-eslint/parser'
|
||||||
|
|
||||||
|
/** Oxlint 检查脚本,ESLint 仅补充 Vue 模板语义检查,格式统一交给 Oxfmt。 */
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist/**', 'coverage/**', 'node_modules/**'] },
|
||||||
|
...pluginVue.configs['flat/essential'],
|
||||||
|
{
|
||||||
|
files: ['**/*.vue'],
|
||||||
|
languageOptions: { parserOptions: { parser: tsParser } },
|
||||||
|
rules: { 'vue/multi-word-component-names': 'off' }
|
||||||
|
}
|
||||||
|
]
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#242722" />
|
||||||
|
<meta name="description" content="短剧创作工作台:管理剧本生成、主体拆解与分镜规划。" />
|
||||||
|
<title>短剧工作台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
pre-commit:
|
||||||
|
commands:
|
||||||
|
check:
|
||||||
|
run: pnpm check
|
||||||
|
commit-msg:
|
||||||
|
commands:
|
||||||
|
commitlint:
|
||||||
|
run: pnpm exec commitlint --edit {1}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { defineConfig } from 'oxfmt'
|
||||||
|
|
||||||
|
/** 项目的 Oxfmt 格式化规则 */
|
||||||
|
export default defineConfig({
|
||||||
|
// 单参数箭头函数省略括号
|
||||||
|
arrowParens: 'avoid',
|
||||||
|
// 多行标签的右尖括号单独换行
|
||||||
|
bracketSameLine: false,
|
||||||
|
// 对象大括号保留空格
|
||||||
|
bracketSpacing: true,
|
||||||
|
// 自动格式化嵌入代码
|
||||||
|
embeddedLanguageFormatting: 'auto',
|
||||||
|
// 统一使用 LF 换行
|
||||||
|
endOfLine: 'lf',
|
||||||
|
// 按 CSS 规则处理 HTML 空白
|
||||||
|
htmlWhitespaceSensitivity: 'css',
|
||||||
|
// 排除生成物、依赖和文档
|
||||||
|
ignorePatterns: [
|
||||||
|
'dist',
|
||||||
|
'coverage',
|
||||||
|
'docs',
|
||||||
|
'node_modules',
|
||||||
|
'README*.md',
|
||||||
|
|
||||||
|
'.agent',
|
||||||
|
'.agents',
|
||||||
|
'.codex',
|
||||||
|
'.claude',
|
||||||
|
'.langgraph_api',
|
||||||
|
'.worktree',
|
||||||
|
'*.lock',
|
||||||
|
'*-lock.yaml'
|
||||||
|
],
|
||||||
|
// 文件末尾保留换行
|
||||||
|
insertFinalNewline: true,
|
||||||
|
// JSON 系列文件单独配置
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
// 匹配 JSON、JSON5 和 JSONC
|
||||||
|
files: ['*.json', '*.json5', '*.jsonc', '**/*.json', '**/*.json5', '**/*.jsonc'],
|
||||||
|
options: {
|
||||||
|
// 保留原有属性引号
|
||||||
|
quoteProps: 'preserve',
|
||||||
|
// JSON 使用双引号
|
||||||
|
singleQuote: false,
|
||||||
|
// JSON 不添加尾逗号
|
||||||
|
trailingComma: 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// 单行最大 120 字符
|
||||||
|
printWidth: 120,
|
||||||
|
// 不自动折叠 Markdown 文本
|
||||||
|
proseWrap: 'never',
|
||||||
|
// 仅必要时给属性名加引号
|
||||||
|
quoteProps: 'as-needed',
|
||||||
|
// 语句末尾不加分号
|
||||||
|
semi: false,
|
||||||
|
// JavaScript 使用单引号
|
||||||
|
singleQuote: true,
|
||||||
|
// 不自动排序导入
|
||||||
|
sortImports: false,
|
||||||
|
// 不自动排序 package.json
|
||||||
|
sortPackageJson: false,
|
||||||
|
// 使用四空格缩进
|
||||||
|
tabWidth: 4,
|
||||||
|
// 多行结构不添加尾逗号
|
||||||
|
trailingComma: 'none'
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'oxlint'
|
||||||
|
|
||||||
|
/** 延续后端质量规则,增加浏览器及 Vue 脚本环境。模板由 ESLint 补充。 */
|
||||||
|
export default defineConfig({
|
||||||
|
categories: { correctness: 'error', suspicious: 'warn' },
|
||||||
|
env: { browser: true, node: true, es2021: true },
|
||||||
|
plugins: ['eslint', 'typescript', 'unicorn', 'vue', 'vitest'],
|
||||||
|
ignorePatterns: ['dist/**', 'coverage/**', 'node_modules/**'],
|
||||||
|
rules: {
|
||||||
|
eqeqeq: ['error', 'always'],
|
||||||
|
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||||
|
'no-debugger': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'typescript/no-explicit-any': 'error',
|
||||||
|
'vitest/no-focused-tests': 'error'
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "short-drama-agent-front",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"lint": "oxlint . && eslint . --cache",
|
||||||
|
"lint:fix": "oxlint --fix . && eslint . --cache --fix",
|
||||||
|
"format": "oxfmt .",
|
||||||
|
"format:check": "oxfmt --check .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"check": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test",
|
||||||
|
"prepare": "lefthook install"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "3.5.42",
|
||||||
|
"vue-router": "^4.6.0",
|
||||||
|
"reka-ui": "2.10.4",
|
||||||
|
"@lucide/vue": "1.34.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"vite": "8.2.2",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vue-tsc": "^3.2.0",
|
||||||
|
"tailwindcss": "^4.1.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"oxlint": "^1.78.0",
|
||||||
|
"oxfmt": "^0.60.0",
|
||||||
|
"eslint": "^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",
|
||||||
|
"lefthook": "^2.1.10",
|
||||||
|
"@commitlint/cli": "^21.2.2",
|
||||||
|
"@commitlint/config-conventional": "^21.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.18.0",
|
||||||
|
"pnpm": ">=11.1.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@11.19.0"
|
||||||
|
}
|
||||||
Generated
+3729
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
lefthook: true
|
||||||
|
vue-demi: true
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- '@vue/compiler-core@3.5.42'
|
||||||
|
- '@vue/compiler-dom@3.5.42'
|
||||||
|
- '@vue/compiler-sfc@3.5.42'
|
||||||
|
- '@vue/compiler-ssr@3.5.42'
|
||||||
|
- '@vue/reactivity@3.5.42'
|
||||||
|
- '@vue/runtime-core@3.5.42'
|
||||||
|
- '@vue/runtime-dom@3.5.42'
|
||||||
|
- '@vue/server-renderer@3.5.42'
|
||||||
|
- '@vue/shared@3.5.42'
|
||||||
|
- vue@3.5.42
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { Clapperboard, FolderOpen, FileText, Layers, PanelLeftClose, PanelLeftOpen, Settings2 } from '@lucide/vue'
|
||||||
|
import { DialogTrigger } from 'reka-ui'
|
||||||
|
import { AppDialog } from './components/ui'
|
||||||
|
|
||||||
|
/** 应用框架只承载导航与连接说明,不混入 graph 业务。 */
|
||||||
|
const route = useRoute()
|
||||||
|
const collapsed = ref(false)
|
||||||
|
const settingsOpen = ref(false)
|
||||||
|
const projectId = computed(() => (route.params.projectId ? String(route.params.projectId) : ''))
|
||||||
|
const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell" :class="{ 'sidebar-collapsed': collapsed }">
|
||||||
|
<a href="#main-content" class="skip-link">跳到主要内容</a>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<RouterLink to="/projects" class="brand"
|
||||||
|
><span class="brand-mark"><Clapperboard :size="21" :stroke-width="1.5" /></span
|
||||||
|
><span class="sidebar-label"
|
||||||
|
><strong>短剧工作台</strong><small>SHORT DRAMA STUDIO</small></span
|
||||||
|
></RouterLink
|
||||||
|
>
|
||||||
|
<div class="sidebar-section-label sidebar-label">工作空间</div>
|
||||||
|
<nav aria-label="主导航">
|
||||||
|
<RouterLink to="/projects" class="side-link" :class="{ selected: !projectId }" title="我的剧本"
|
||||||
|
><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
|
||||||
|
>
|
||||||
|
</nav></template
|
||||||
|
>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<AppDialog
|
||||||
|
v-model:open="settingsOpen"
|
||||||
|
title="后端连接"
|
||||||
|
description="页面直接使用现有后端接口,所有生成操作都会真实提交。"
|
||||||
|
><template #trigger
|
||||||
|
><DialogTrigger class="side-link w-full" title="后端连接"
|
||||||
|
><Settings2 :size="17" /><span class="sidebar-label">连接说明</span></DialogTrigger
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
<dl class="detail-grid mt-7">
|
||||||
|
<dt>API 地址</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{{ apiBase }}</code>
|
||||||
|
</dd>
|
||||||
|
<dt>开发环境</dt>
|
||||||
|
<dd>复制 .env.example 为 .env,将 API_PROXY_TARGET 指向你的后端服务,然后重启 Vite。</dd>
|
||||||
|
<dt>生产环境</dt>
|
||||||
|
<dd>为 /api 配置反向代理,并为拆解和恢复接口设置足够长的读取超时。</dd>
|
||||||
|
<dt>状态更新</dt>
|
||||||
|
<dd>项目详情每 6 秒刷新一次;checkpoint 只在节点或批次结束后更新。</dd>
|
||||||
|
</dl></AppDialog
|
||||||
|
>
|
||||||
|
<div class="sidebar-divider"></div>
|
||||||
|
<button
|
||||||
|
class="side-link w-full"
|
||||||
|
:aria-label="collapsed ? '展开侧栏' : '折叠侧栏'"
|
||||||
|
@click="collapsed = !collapsed"
|
||||||
|
>
|
||||||
|
<PanelLeftOpen v-if="collapsed" :size="17" /><PanelLeftClose v-else :size="17" /><span
|
||||||
|
class="sidebar-label"
|
||||||
|
>收起侧栏</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<p class="sidebar-label mt-3 px-3 text-[10px] text-stone-500">创作 · 整理 · 逐镜成形</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="app-main">
|
||||||
|
<header class="topbar">
|
||||||
|
<span class="text-xs text-muted"
|
||||||
|
>短剧制作 / <span class="text-ink">{{ route.meta.title || '工作空间' }}</span></span
|
||||||
|
><span class="topbar-note"
|
||||||
|
>创作工作区 <span class="ml-2 font-mono text-[10px] text-faint">v0.1</span></span
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<main id="main-content" tabindex="-1"><RouterView /></main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { EmptyState } from './ui'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-container">
|
||||||
|
<EmptyState title="这个页面不存在" description="请检查地址,或回到项目列表继续创作。"
|
||||||
|
><RouterLink to="/projects" class="button button-primary">返回我的剧本</RouterLink></EmptyState
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
DialogRoot,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogClose
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { X } from '@lucide/vue'
|
||||||
|
|
||||||
|
/** 统一弹窗容器,Reka UI 负责焦点锁定、Escape 和无障碍语义。 */
|
||||||
|
defineProps<{ title: string; description: string; busy?: boolean }>()
|
||||||
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot v-model:open="open">
|
||||||
|
<slot name="trigger"></slot>
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay class="dialog-overlay" />
|
||||||
|
<DialogContent
|
||||||
|
class="dialog-content"
|
||||||
|
@interact-outside="busy && $event.preventDefault()"
|
||||||
|
@escape-key-down="busy && $event.preventDefault()"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<DialogTitle class="text-xl font-semibold">{{ title }}</DialogTitle
|
||||||
|
><DialogDescription class="mt-2 text-sm leading-6 text-muted">{{
|
||||||
|
description
|
||||||
|
}}</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<DialogClose class="icon-button shrink-0" aria-label="关闭弹窗" :disabled="busy"
|
||||||
|
><X :size="18"
|
||||||
|
/></DialogClose>
|
||||||
|
</div>
|
||||||
|
<slot></slot>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { FileText } from '@lucide/vue'
|
||||||
|
|
||||||
|
/** 空数据与等待生成共用的轻量占位,不放入演示数据。 */
|
||||||
|
defineProps<{ title: string; description: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="empty-state">
|
||||||
|
<FileText :size="28" :stroke-width="1.4" class="mb-4 text-muted" />
|
||||||
|
<h3 class="font-medium">{{ title }}</h3>
|
||||||
|
<p class="mt-2 max-w-md text-sm leading-6 text-muted">{{ description }}</p>
|
||||||
|
<div class="mt-5"><slot></slot></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { statusLabels } from '../../lib/format'
|
||||||
|
import type { ProjectStatus } from '../../features/projects/types'
|
||||||
|
|
||||||
|
/** 状态同时提供文字和颜色,避免只依赖色觉。 */
|
||||||
|
defineProps<{ status: string; label?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="status-badge" :data-status="status">
|
||||||
|
<span class="status-dot" aria-hidden="true"></span
|
||||||
|
>{{ label || statusLabels[status as ProjectStatus] || status }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** 公共 UI 的统一导出入口。 */
|
||||||
|
export { default as AppDialog } from './AppDialog.vue'
|
||||||
|
export { default as EmptyState } from './EmptyState.vue'
|
||||||
|
export { default as StatusBadge } from './StatusBadge.vue'
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { effectScope, nextTick, ref } from 'vue'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { usePolling } from './usePolling'
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers())
|
||||||
|
|
||||||
|
describe('异步轮询生命周期', () => {
|
||||||
|
it('切换项目忽略旧响应,卸载时不再排队请求', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const key = ref('first')
|
||||||
|
const finish = new Map<string, (value: string) => void>()
|
||||||
|
const loader = vi.fn<(id: string) => Promise<string>>(
|
||||||
|
(id: string) =>
|
||||||
|
new Promise<string>(resolve => {
|
||||||
|
finish.set(id, resolve)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const scope = effectScope()
|
||||||
|
const query = scope.run(() => usePolling(key, loader, 1000))!
|
||||||
|
key.value = 'second'
|
||||||
|
await nextTick()
|
||||||
|
finish.get('second')!('新项目')
|
||||||
|
await Promise.resolve()
|
||||||
|
finish.get('first')!('旧项目')
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(query.data.value).toBe('新项目')
|
||||||
|
scope.stop()
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000)
|
||||||
|
expect(loader).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('失败时保留已有数据,并向页面显示错误', async () => {
|
||||||
|
const scope = effectScope()
|
||||||
|
const loader = vi
|
||||||
|
.fn<() => Promise<string>>()
|
||||||
|
.mockResolvedValueOnce('上次成功数据')
|
||||||
|
.mockRejectedValueOnce(new Error('连接已断开'))
|
||||||
|
const query = scope.run(() => usePolling(ref('p'), loader))!
|
||||||
|
await Promise.resolve()
|
||||||
|
await query.refresh()
|
||||||
|
expect(query.data.value).toBe('上次成功数据')
|
||||||
|
expect(query.error.value).toBe('连接已断开')
|
||||||
|
scope.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { onScopeDispose, ref, watch, type Ref } from 'vue'
|
||||||
|
import { errorMessage } from '../lib/http'
|
||||||
|
|
||||||
|
/** 串行轮询:请求完成后才计时;切换项目立即取消,过期响应不会覆盖新项目。 */
|
||||||
|
export function usePolling<T>(
|
||||||
|
key: Ref<string>,
|
||||||
|
loader: (key: string, signal: AbortSignal) => Promise<T>,
|
||||||
|
interval = 6000
|
||||||
|
) {
|
||||||
|
const data = ref<T | null>(null) as Ref<T | null>
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const updatedAt = ref('')
|
||||||
|
let generation = 0
|
||||||
|
let controller: AbortController | undefined
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
|
/** 隐藏页面只安排下一次检查,不继续拉取完整 checkpoint。 */
|
||||||
|
function schedule() {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (document.visibilityState === 'hidden') schedule()
|
||||||
|
else void refresh()
|
||||||
|
}, interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动刷新也取消上一次查询,避免同时存在多个轮询链。 */
|
||||||
|
async function refresh() {
|
||||||
|
if (disposed) return
|
||||||
|
const current = ++generation
|
||||||
|
clearTimeout(timer)
|
||||||
|
controller?.abort()
|
||||||
|
controller = new AbortController()
|
||||||
|
const signal = controller.signal
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await loader(key.value, signal)
|
||||||
|
if (current !== generation || disposed) return
|
||||||
|
data.value = result
|
||||||
|
error.value = ''
|
||||||
|
updatedAt.value = new Date().toISOString()
|
||||||
|
} catch (cause) {
|
||||||
|
if (current === generation && !signal.aborted && !disposed) error.value = errorMessage(cause)
|
||||||
|
} finally {
|
||||||
|
if (current === generation && !disposed) {
|
||||||
|
loading.value = false
|
||||||
|
schedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
key,
|
||||||
|
() => {
|
||||||
|
data.value = null
|
||||||
|
error.value = ''
|
||||||
|
updatedAt.value = ''
|
||||||
|
void refresh()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
onScopeDispose(() => {
|
||||||
|
disposed = true
|
||||||
|
generation++
|
||||||
|
clearTimeout(timer)
|
||||||
|
controller?.abort()
|
||||||
|
})
|
||||||
|
return { data, loading, error, updatedAt, refresh }
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
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 { useProjectContext } from '../projects/context'
|
||||||
|
import { breakdownApi } from './api'
|
||||||
|
import type { BreakdownAction, BreakdownInput, BreakdownModule, BreakdownPreview } from './types'
|
||||||
|
import { breakdownSnapshot, recoveryOptions, workflowCheckpoints } from '../workflows/selectors'
|
||||||
|
import { getOperation, runOperation } from '../workflows/operations'
|
||||||
|
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||||
|
import HistoryPanel from '../workflows/HistoryPanel.vue'
|
||||||
|
import SubjectList from './components/SubjectList.vue'
|
||||||
|
import StoryboardList from './components/StoryboardList.vue'
|
||||||
|
import { errorMessage } from '../../lib/http'
|
||||||
|
import { downloadText, nodeLabel } from '../../lib/format'
|
||||||
|
|
||||||
|
/** 拆解工作区:先预览真实分组,再启动;恢复操作根据 checkpoint 分阶段展示。 */
|
||||||
|
const { project, checkpoints, refresh, error } = useProjectContext()
|
||||||
|
const groupSize = ref(3)
|
||||||
|
const modules = ref<BreakdownModule[]>(['character', 'scene', 'prop'])
|
||||||
|
const preview = ref<BreakdownPreview | null>(null)
|
||||||
|
const previewBusy = ref(false)
|
||||||
|
const previewError = ref('')
|
||||||
|
const tab = ref('character')
|
||||||
|
const moduleOptions: { value: BreakdownModule; label: string; description: string }[] = [
|
||||||
|
{ value: 'character', label: '人物', description: '人物身份、外观与形态' },
|
||||||
|
{ value: 'scene', label: '场景', description: '故事空间与环境特征' },
|
||||||
|
{ value: 'prop', label: '道具', description: '关键物件与视觉描述' }
|
||||||
|
]
|
||||||
|
const records = computed(() => workflowCheckpoints(checkpoints.value, 'breakdown'))
|
||||||
|
const snapshot = computed(() => breakdownSnapshot(checkpoints.value))
|
||||||
|
const result = computed(() => snapshot.value?.breakdownResult ?? snapshot.value)
|
||||||
|
const subjects = computed(() => result.value?.subjectCandidates ?? [])
|
||||||
|
const forms = computed(() => result.value?.subjectForms ?? [])
|
||||||
|
const plans = computed(() => result.value?.storyboardPlans ?? [])
|
||||||
|
const shots = computed(() => result.value?.storyboardEpisodeShots ?? [])
|
||||||
|
const summary = computed(() => snapshot.value?.taskSummary)
|
||||||
|
const validation = computed(() => result.value?.storyboardShotValidation ?? snapshot.value?.storyboardShotValidation)
|
||||||
|
const operation = computed(() => getOperation(project.value!.id))
|
||||||
|
const recovery = computed(() => recoveryOptions(checkpoints.value))
|
||||||
|
const execution = computed(() => snapshot.value?.workflowExecution)
|
||||||
|
const taskStopped = computed(() => execution.value?.status === 'failed' || execution.value?.status === 'completed')
|
||||||
|
const showRecovery = computed(
|
||||||
|
() => records.value.length > 0 && (execution.value?.status !== 'completed' || validation.value?.valid === false)
|
||||||
|
)
|
||||||
|
const configValid = computed(
|
||||||
|
() => Number.isSafeInteger(groupSize.value) && groupSize.value > 0 && modules.value.length > 0
|
||||||
|
)
|
||||||
|
const input = computed<BreakdownInput>(() => ({ groupSize: groupSize.value, modules: [...modules.value] }))
|
||||||
|
const canStart = computed(
|
||||||
|
() =>
|
||||||
|
!!project.value?.episodes.length &&
|
||||||
|
project.value.status !== 'generating' &&
|
||||||
|
!!preview.value &&
|
||||||
|
configValid.value &&
|
||||||
|
!operation.value.pending &&
|
||||||
|
!error.value &&
|
||||||
|
(!records.value.length || taskStopped.value)
|
||||||
|
)
|
||||||
|
let previewController: AbortController | undefined
|
||||||
|
let previewVersion = 0
|
||||||
|
|
||||||
|
/** 输入变化立即废弃旧预览,防止按新配置启动旧的任务清单。 */
|
||||||
|
function invalidatePreview() {
|
||||||
|
previewVersion++
|
||||||
|
previewController?.abort()
|
||||||
|
preview.value = null
|
||||||
|
previewBusy.value = false
|
||||||
|
previewError.value = ''
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
groupSize,
|
||||||
|
() => modules.value.join(','),
|
||||||
|
() => project.value?.episodes.map(item => item.episode + ':' + item.content).join('|')
|
||||||
|
],
|
||||||
|
invalidatePreview
|
||||||
|
)
|
||||||
|
onScopeDispose(invalidatePreview)
|
||||||
|
|
||||||
|
/** Reka Checkbox 的不确定值不视为选择。 */
|
||||||
|
function toggleModule(module: BreakdownModule, checked: boolean | 'indeterminate') {
|
||||||
|
if (checked === true && !modules.value.includes(module)) modules.value.push(module)
|
||||||
|
else modules.value = modules.value.filter(item => item !== module)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取服务器生成的任务分组,不在浏览器伪造任务 ID。 */
|
||||||
|
async function loadPreview() {
|
||||||
|
if (!configValid.value || !project.value || previewBusy.value) return
|
||||||
|
invalidatePreview()
|
||||||
|
const version = previewVersion
|
||||||
|
previewController = new AbortController()
|
||||||
|
previewBusy.value = true
|
||||||
|
try {
|
||||||
|
const data = await breakdownApi.preview(project.value.id, input.value, previewController.signal)
|
||||||
|
if (version === previewVersion) preview.value = data
|
||||||
|
} catch (cause) {
|
||||||
|
if (version === previewVersion) previewError.value = errorMessage(cause)
|
||||||
|
} finally {
|
||||||
|
if (version === previewVersion) previewBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 长请求不随路由切换取消,返回后刷新 checkpoint 与正式项目数据。 */
|
||||||
|
async function run(action: BreakdownAction) {
|
||||||
|
if (!project.value || operation.value.pending || error.value) return
|
||||||
|
if (action === 'start' && !canStart.value) return
|
||||||
|
const labels: Record<BreakdownAction, string> = {
|
||||||
|
start: '正在拆解剧本',
|
||||||
|
retry: '正在重试失败抽取',
|
||||||
|
'resume-shots': '正在补齐镜头',
|
||||||
|
'resume-storyboard': '正在修复分镜绑定'
|
||||||
|
}
|
||||||
|
const id = project.value.id
|
||||||
|
await runOperation(id, labels[action], () =>
|
||||||
|
breakdownApi.run(id, action, action === 'start' ? input.value : undefined)
|
||||||
|
)
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出所见的后端结果;阶段性数据也明确标记为 snapshot。 */
|
||||||
|
function exportResult() {
|
||||||
|
if (!snapshot.value) return
|
||||||
|
downloadText(
|
||||||
|
`breakdown-${project.value?.id}-snapshot.json`,
|
||||||
|
JSON.stringify(snapshot.value, null, 2),
|
||||||
|
'application/json'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6">
|
||||||
|
<div v-if="!project?.episodes.length" class="panel">
|
||||||
|
<EmptyState
|
||||||
|
title="还没有可拆解的剧集"
|
||||||
|
description="先完成剧本创作。拆解会读取数据库中的正式剧集,不需要上传 checkpoint。"
|
||||||
|
><RouterLink class="button button-primary" :to="`/projects/${project?.id}/create-drama`"
|
||||||
|
>前往剧本创作<ArrowRight :size="14" /></RouterLink
|
||||||
|
></EmptyState>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<section class="panel p-5 lg:p-6">
|
||||||
|
<div class="mb-5 flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="flex items-center gap-2 font-semibold"><Layers :size="17" />拆解设置</h2>
|
||||||
|
<p class="mt-2 text-xs leading-5 text-muted">
|
||||||
|
读取已保存的
|
||||||
|
{{ project.episodes.length }} 集剧本。每个分组分别抽取所选模块,再生成主体与分镜。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="tag">来源:正式剧集</span>
|
||||||
|
</div>
|
||||||
|
<div class="breakdown-config">
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="group-size">每组集数</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="group-size"
|
||||||
|
v-model.number="groupSize"
|
||||||
|
type="number"
|
||||||
|
class="input w-24"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
:disabled="operation.pending"
|
||||||
|
/><span class="text-sm text-muted">集 / 组</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<fieldset class="flex flex-wrap gap-5 border-0 p-0">
|
||||||
|
<legend class="field-label mb-3">抽取模块</legend>
|
||||||
|
<label
|
||||||
|
v-for="option in moduleOptions"
|
||||||
|
:key="option.value"
|
||||||
|
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 class="block text-sm font-medium">{{ option.label }}</span
|
||||||
|
><span class="mt-1 block text-[11px] text-muted">{{ option.description }}</span></span
|
||||||
|
></label
|
||||||
|
>
|
||||||
|
</fieldset>
|
||||||
|
<button
|
||||||
|
class="button button-secondary self-end"
|
||||||
|
:disabled="!configValid || previewBusy || operation.pending || !!error"
|
||||||
|
@click="loadPreview"
|
||||||
|
>
|
||||||
|
<LoaderCircle v-if="previewBusy" :size="14" class="animate-spin" />预览分组
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!configValid" class="mt-4 text-xs text-danger">
|
||||||
|
每组集数必须是正整数,并至少选择一个抽取模块。
|
||||||
|
</p>
|
||||||
|
<p v-if="previewError" class="alert alert-error mt-4" role="alert">{{ previewError }}</p>
|
||||||
|
<div v-if="preview" class="group-preview">
|
||||||
|
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p class="text-sm">
|
||||||
|
<strong>{{ preview.groupCount }}</strong> 个分组 <span class="mx-2 text-faint">/</span>
|
||||||
|
<strong>{{ preview.estimatedTaskCount }}</strong> 个抽取任务
|
||||||
|
</p>
|
||||||
|
<ConfirmAction
|
||||||
|
:label="records.length ? '重新拆解' : '开始拆解'"
|
||||||
|
:description="
|
||||||
|
records.length
|
||||||
|
? '这会重新运行整个 Breakdown,可能替换已保存的主体与分镜。只想恢复失败阶段时,请使用下方对应恢复操作。'
|
||||||
|
: '将按当前预览配置启动完整的 Breakdown 工作流。抽取结束后仍需完成主体整理、分镜生成与入库。'
|
||||||
|
"
|
||||||
|
:acknowledgement="records.length > 0"
|
||||||
|
primary
|
||||||
|
:disabled="!canStart"
|
||||||
|
@confirm="run('start')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<div v-for="group in preview.groups" :key="group.groupId" class="group-chip">
|
||||||
|
<span class="text-muted">{{ String(group.groupNo).padStart(2, '0') }}</span
|
||||||
|
><span>第 {{ group.startEpisodeNo }}–{{ group.endEpisodeNo }} 集</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="project.status === 'generating'" class="mt-4 text-xs text-danger">
|
||||||
|
剧本仍在生成,暂不允许启动拆解,避免读取到不完整的剧集。
|
||||||
|
</p>
|
||||||
|
<p v-if="records.length && !taskStopped" class="mt-4 text-xs leading-5 text-muted">
|
||||||
|
后端尚无明确的完成或失败记录。为避免重复执行,已禁用整条重新拆解;如进程已中断,请先核实后台,再选择对应恢复操作。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section v-if="snapshot" class="mt-5">
|
||||||
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 class="text-sm font-medium">
|
||||||
|
最近可用拆解快照
|
||||||
|
<span class="ml-2 text-xs font-normal text-muted">{{
|
||||||
|
nodeLabel(records.at(-1)?.metadata?.nodeName)
|
||||||
|
}}</span>
|
||||||
|
</h2>
|
||||||
|
<StatusBadge
|
||||||
|
:status="execution?.status || 'unknown'"
|
||||||
|
:label="
|
||||||
|
execution?.status === 'completed'
|
||||||
|
? '工作流已完成'
|
||||||
|
: execution?.status === 'failed'
|
||||||
|
? '工作流失败'
|
||||||
|
: '阶段快照 · 执行状态待确认'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="summary" class="flex flex-wrap items-center gap-x-6 gap-y-2 text-xs text-muted">
|
||||||
|
<span
|
||||||
|
>抽取完成 <strong class="text-ink">{{ summary.completed }} / {{ summary.total }}</strong></span
|
||||||
|
><span>失败 {{ summary.failed }}</span
|
||||||
|
><span>主体 {{ subjects.length }}</span
|
||||||
|
><span>分镜剧集 {{ shots.length }} / {{ plans.length }}</span>
|
||||||
|
</div>
|
||||||
|
<ProgressRoot
|
||||||
|
v-if="summary?.total"
|
||||||
|
class="progress-track mt-3"
|
||||||
|
:model-value="summary.completed"
|
||||||
|
:max="summary.total"
|
||||||
|
aria-label="已完成抽取任务"
|
||||||
|
><ProgressIndicator
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{ width: Math.min(100, (summary.completed / summary.total) * 100) + '%' }"
|
||||||
|
/></ProgressRoot>
|
||||||
|
<p v-if="execution?.errorMessage" class="alert alert-error mt-4" role="alert">
|
||||||
|
{{ execution.errorMessage }}
|
||||||
|
</p>
|
||||||
|
<div v-if="validation && !validation.valid" class="alert alert-error mt-4">
|
||||||
|
<p class="font-medium">分镜校验未通过</p>
|
||||||
|
<ul class="mt-2 list-inside list-disc space-y-1">
|
||||||
|
<li v-for="(issue, index) in validation.issues" :key="index">
|
||||||
|
第 {{ issue.episodeNo }} 集<span v-if="issue.beatNo"> / Beat {{ issue.beatNo }}</span
|
||||||
|
><span v-if="issue.shotNo"> / Shot {{ issue.shotNo }}</span
|
||||||
|
>:{{ issue.message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-if="showRecovery" class="recovery-strip mt-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">从中断处继续</p>
|
||||||
|
<p class="mt-1 text-xs leading-5 text-muted">
|
||||||
|
抽取失败、镜头缺失、主体绑定异常分别处理。禁用表示近期 checkpoint 不具备所需数据。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<ConfirmAction
|
||||||
|
label="重试失败抽取"
|
||||||
|
description="只重试 character / scene / prop 抽取任务中的失败项。不能用于镜头阶段失败。"
|
||||||
|
acknowledgement
|
||||||
|
:disabled="!recovery.retry || operation.pending || !!error"
|
||||||
|
@confirm="run('retry')"
|
||||||
|
/><ConfirmAction
|
||||||
|
label="补齐缺失镜头"
|
||||||
|
description="复用已完成剧集的镜头,仅生成尚未完成的 Episode Shot,再进行校验与入库。"
|
||||||
|
acknowledgement
|
||||||
|
:disabled="!recovery.shots || operation.pending || !!error"
|
||||||
|
@confirm="run('resume-shots')"
|
||||||
|
/><ConfirmAction
|
||||||
|
label="修复分镜绑定"
|
||||||
|
description="复用已有镜头,修复 SubjectRef 与视觉 Form 绑定,再校验并保存。"
|
||||||
|
acknowledgement
|
||||||
|
:disabled="!recovery.storyboard || operation.pending || !!error"
|
||||||
|
@confirm="run('resume-storyboard')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<TabsRoot v-model="tab" class="mt-7">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<TabsList class="tabs-list" aria-label="拆解结果"
|
||||||
|
><TabsTrigger
|
||||||
|
v-for="option in moduleOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
class="tab-trigger"
|
||||||
|
>{{ option.label
|
||||||
|
}}<span>{{
|
||||||
|
subjects.filter(item => item.module === option.value).length
|
||||||
|
}}</span></TabsTrigger
|
||||||
|
><TabsTrigger value="storyboard" class="tab-trigger">分镜</TabsTrigger
|
||||||
|
><TabsTrigger value="tasks" class="tab-trigger">任务明细</TabsTrigger></TabsList
|
||||||
|
><button class="text-button" :disabled="!snapshot" @click="exportResult">
|
||||||
|
<Download :size="14" />导出 JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="content-with-history panel mt-4">
|
||||||
|
<main class="min-w-0">
|
||||||
|
<TabsContent v-for="option in moduleOptions" :key="option.value" :value="option.value"
|
||||||
|
><SubjectList
|
||||||
|
:subjects="subjects.filter(item => item.module === option.value)"
|
||||||
|
:forms="forms" /></TabsContent
|
||||||
|
><TabsContent value="storyboard"
|
||||||
|
><StoryboardList :plans="plans" :episodes="shots" /></TabsContent
|
||||||
|
><TabsContent value="tasks" class="p-5"
|
||||||
|
><div v-if="snapshot?.tasks?.length" class="table-scroll">
|
||||||
|
<table class="project-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>分组</th>
|
||||||
|
<th>模块</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>尝试</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="task in snapshot.tasks" :key="task.taskId"
|
||||||
|
><tr>
|
||||||
|
<td>
|
||||||
|
第 {{ task.group.startEpisodeNo }}–{{ task.group.endEpisodeNo }} 集
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ moduleOptions.find(item => item.value === task.module)?.label }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge
|
||||||
|
:status="task.status"
|
||||||
|
:label="
|
||||||
|
{
|
||||||
|
pending: '等待中',
|
||||||
|
running: '执行中',
|
||||||
|
completed: '完成',
|
||||||
|
failed: '失败'
|
||||||
|
}[task.status]
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{{ task.attempt }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="task.errorMessage">
|
||||||
|
<td colspan="4" class="text-danger">{{ task.errorMessage }}</td>
|
||||||
|
</tr></template
|
||||||
|
>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
title="尚无抽取任务记录"
|
||||||
|
description="启动拆解后,任务会在后端保存 checkpoint 时更新。预览中的任务尚未执行。"
|
||||||
|
/></TabsContent>
|
||||||
|
</main>
|
||||||
|
<HistoryPanel :checkpoints="checkpoints" workflow="breakdown" />
|
||||||
|
</div>
|
||||||
|
</TabsRoot>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { optionalResource, request } from '../../lib/http'
|
||||||
|
import type { BreakdownAction, BreakdownInput, BreakdownPreview, BreakdownState } from './types'
|
||||||
|
|
||||||
|
/** Breakdown API;预览为 GET,启动和恢复为等待完成的长 POST。 */
|
||||||
|
export const breakdownApi = {
|
||||||
|
preview: (id: string, input: BreakdownInput, signal?: AbortSignal) => {
|
||||||
|
const query = new URLSearchParams({ groupSize: String(input.groupSize), modules: input.modules.join(',') })
|
||||||
|
return request<BreakdownPreview>(`/projects/${encodeURIComponent(id)}/breakdown-preview?${query}`, { signal })
|
||||||
|
},
|
||||||
|
latest: (id: string, signal?: AbortSignal) =>
|
||||||
|
optionalResource(request<BreakdownState>(`/projects/${encodeURIComponent(id)}/breakdown/latest`, { signal })),
|
||||||
|
run: (id: string, action: BreakdownAction, input?: BreakdownInput) =>
|
||||||
|
request<BreakdownState>(`/projects/${encodeURIComponent(id)}/breakdown/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: input,
|
||||||
|
timeoutMs: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Clock3 } from '@lucide/vue'
|
||||||
|
import { EmptyState } from '../../../components/ui'
|
||||||
|
import type { EpisodePlan, EpisodeShots } from '../types'
|
||||||
|
|
||||||
|
/** Episode → Beat → Shot 的分层查看,不提前加入下一条 graph 的导演字段。 */
|
||||||
|
const props = defineProps<{ plans: EpisodePlan[]; episodes: EpisodeShots[] }>()
|
||||||
|
const selected = ref<number>()
|
||||||
|
const plans = computed(() => (props.plans.length ? props.plans : props.episodes.map(item => item.episodePlan)))
|
||||||
|
const plan = computed(() => plans.value.find(item => item.episodeNo === selected.value) ?? plans.value[0])
|
||||||
|
const episode = computed(() => props.episodes.find(item => item.episodeNo === plan.value?.episodeNo))
|
||||||
|
const purposeLabels: Record<string, string> = {
|
||||||
|
establish: '建立场景',
|
||||||
|
introduce: '引入',
|
||||||
|
action: '行动',
|
||||||
|
dialogue: '对白',
|
||||||
|
reaction: '反应',
|
||||||
|
reveal: '揭示',
|
||||||
|
transition: '过渡',
|
||||||
|
climax: '高潮',
|
||||||
|
resolution: '收束',
|
||||||
|
hook: '钩子'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="plan" class="p-5 lg:p-7">
|
||||||
|
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<label class="flex items-center gap-3 text-sm"
|
||||||
|
>选择剧集<select v-model="selected" class="input w-auto" aria-label="选择分镜剧集">
|
||||||
|
<option v-if="selected === undefined" :value="undefined">
|
||||||
|
第 {{ plan.episodeNo }} 集 · {{ plan.episodeTitle }}
|
||||||
|
</option>
|
||||||
|
<option v-for="item in plans" :key="item.episodeNo" :value="item.episodeNo">
|
||||||
|
第 {{ item.episodeNo }} 集 · {{ item.episodeTitle }}
|
||||||
|
</option>
|
||||||
|
</select></label
|
||||||
|
><span class="text-xs text-muted"
|
||||||
|
>{{ plan.beats.length }} 个节拍 ·
|
||||||
|
{{ episode?.beatShots.reduce((sum, beat) => sum + beat.shots.length, 0) ?? 0 }} 个镜头</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-7 border-l-2 border-accent pl-4">
|
||||||
|
<p class="font-medium">{{ plan.storyGoal }}</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-muted">{{ plan.emotionalArc }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="!episode" class="alert mb-5">
|
||||||
|
本集已完成节拍规划,镜头尚未生成。请等待进度更新,或在确认中断后使用“补齐缺失镜头”。
|
||||||
|
</p>
|
||||||
|
<section v-for="beat in plan.beats" :key="beat.beatNo" class="beat-section">
|
||||||
|
<div class="beat-heading">
|
||||||
|
<span class="beat-number">{{ String(beat.beatNo).padStart(2, '0') }}</span>
|
||||||
|
<h3 class="text-sm font-semibold">{{ beat.title }}</h3>
|
||||||
|
<span class="tag ml-auto">{{ purposeLabels[beat.purpose] || beat.purpose }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mb-4 mt-3 text-sm leading-6 text-muted">{{ beat.description }}</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="shot in episode?.beatShots.find(item => item.beatNo === beat.beatNo)?.shots ?? []"
|
||||||
|
:key="shot.shotNo"
|
||||||
|
class="shot-row"
|
||||||
|
>
|
||||||
|
<div class="shot-label">
|
||||||
|
镜头 {{ String(shot.shotNo).padStart(2, '0')
|
||||||
|
}}<span class="mt-2 flex items-center gap-1 text-[11px]"
|
||||||
|
><Clock3 :size="11" />{{ shot.durationSeconds }}s</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h4 class="text-sm font-medium">{{ shot.title }}</h4>
|
||||||
|
<p class="mt-2 text-sm leading-7">{{ shot.description }}</p>
|
||||||
|
<p class="mt-2 text-xs leading-6 text-muted">视觉重点 · {{ shot.visualFocus }}</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<code v-for="ref in shot.subjectRefs" :key="ref" class="subject-ref">{{ ref }}</code>
|
||||||
|
</div>
|
||||||
|
<p v-if="shot.subjectBindings?.length" class="mt-2 text-xs leading-6 text-muted">
|
||||||
|
{{
|
||||||
|
shot.subjectBindings
|
||||||
|
.map(binding => binding.subjectRef + ' · ' + binding.formName)
|
||||||
|
.join(' / ')
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
title="分镜规划尚未生成"
|
||||||
|
description="完成主体整理后,工作流会先规划每集的剧情节拍,再逐集生成镜头。"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Search, ChevronDown } from '@lucide/vue'
|
||||||
|
import { EmptyState } from '../../../components/ui'
|
||||||
|
import type { SubjectCandidate, SubjectForm } from '../types'
|
||||||
|
|
||||||
|
/** 主体列表按稳定 ref 展示;形态以 profileId 关联。 */
|
||||||
|
const props = defineProps<{ subjects: SubjectCandidate[]; forms: SubjectForm[] }>()
|
||||||
|
const search = ref('')
|
||||||
|
const filtered = computed(() =>
|
||||||
|
props.subjects.filter(item =>
|
||||||
|
`${item.name} ${item.ref} ${item.aliases?.join(' ') ?? ''}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search.value.trim().toLowerCase())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-5 lg:p-7">
|
||||||
|
<div class="mb-5 flex items-center justify-between gap-3">
|
||||||
|
<p class="text-sm text-muted">{{ subjects.length }} 个主体</p>
|
||||||
|
<div class="search-field">
|
||||||
|
<Search :size="14" /><input v-model="search" placeholder="搜索名称或引用" aria-label="搜索主体" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="filtered.length" class="divide-y divide-line">
|
||||||
|
<article v-for="subject in filtered" :key="subject.profileId" class="py-5 first:pt-0">
|
||||||
|
<div class="mb-3 flex flex-wrap items-center gap-3">
|
||||||
|
<h3 class="font-semibold">{{ subject.name }}</h3>
|
||||||
|
<code class="subject-ref">{{ subject.ref }}</code
|
||||||
|
><span v-if="subject.aliases?.length" class="text-xs text-muted"
|
||||||
|
>别名:{{ subject.aliases.join('、') }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm leading-7">{{ subject.description }}</p>
|
||||||
|
<details class="subject-details mt-4">
|
||||||
|
<summary>
|
||||||
|
<ChevronDown :size="14" />外观描述与形态
|
||||||
|
<span class="ml-1 text-muted">{{
|
||||||
|
forms.filter(form => form.profileId === subject.profileId).length
|
||||||
|
}}</span>
|
||||||
|
</summary>
|
||||||
|
<p class="mt-3 whitespace-pre-wrap text-sm leading-7 text-muted">
|
||||||
|
{{ subject.appearance_prompt || '暂无外观描述' }}
|
||||||
|
</p>
|
||||||
|
<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 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>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
:title="subjects.length ? '没有匹配的主体' : '尚无主体结果'"
|
||||||
|
description="主体会在抽取、合并与档案整理完成后出现在这里。若未启用该模块,则不会生成此类主体。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** Breakdown 模块公共入口。 */
|
||||||
|
export { breakdownApi } from './api'
|
||||||
|
export type { BreakdownInput, BreakdownState, BreakdownModule } from './types'
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/** 三种可配置的抽取模块,分镜并不是第四个模块。 */
|
||||||
|
export type BreakdownModule = 'character' | 'scene' | 'prop'
|
||||||
|
|
||||||
|
/** 拆解分组,来自后端预览接口。 */
|
||||||
|
export interface EpisodeGroup {
|
||||||
|
groupId: string
|
||||||
|
groupNo: number
|
||||||
|
startEpisodeNo: number
|
||||||
|
endEpisodeNo: number
|
||||||
|
episodes: { episodeId: string; episodeNo: number; title: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 抽取任务统计,不代表整个 Breakdown 工作流已完成。 */
|
||||||
|
export interface TaskSummary {
|
||||||
|
total: number
|
||||||
|
pending: number
|
||||||
|
running: number
|
||||||
|
completed: number
|
||||||
|
failed: number
|
||||||
|
finished: number
|
||||||
|
allFinished: boolean
|
||||||
|
hasFailed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个模块 × 分组的抽取任务。 */
|
||||||
|
export interface BreakdownTask {
|
||||||
|
taskId: string
|
||||||
|
module: BreakdownModule
|
||||||
|
group: EpisodeGroup
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
attempt: number
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 传给预览与启动接口的相同配置。 */
|
||||||
|
export interface BreakdownInput {
|
||||||
|
groupSize: number
|
||||||
|
modules: BreakdownModule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拆解启动前的服务器预览结果。 */
|
||||||
|
export interface BreakdownPreview {
|
||||||
|
episodeCount: number
|
||||||
|
groupCount: number
|
||||||
|
estimatedTaskCount: number
|
||||||
|
groups: EpisodeGroup[]
|
||||||
|
tasks: BreakdownTask[]
|
||||||
|
modules: BreakdownModule[]
|
||||||
|
groupSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化主体;appearance_prompt 保留后端字段名。 */
|
||||||
|
export interface SubjectCandidate {
|
||||||
|
profileId: string
|
||||||
|
name: string
|
||||||
|
ref: string
|
||||||
|
description: string
|
||||||
|
module: BreakdownModule
|
||||||
|
appearance_prompt: string
|
||||||
|
aliases?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同一主体的不同视觉形态。 */
|
||||||
|
export interface SubjectForm {
|
||||||
|
formId: string
|
||||||
|
profileId: string
|
||||||
|
type: BreakdownModule
|
||||||
|
name: string
|
||||||
|
formName?: string
|
||||||
|
isDefault: boolean
|
||||||
|
description: string
|
||||||
|
appearancePrompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分镜规划中的叙事节拍。 */
|
||||||
|
export interface StoryboardBeat {
|
||||||
|
beatNo: number
|
||||||
|
title: string
|
||||||
|
purpose: string
|
||||||
|
description: string
|
||||||
|
visualFocus: string
|
||||||
|
narrativeGoal: string
|
||||||
|
emotionalTone: string
|
||||||
|
estimatedDurationSeconds: number
|
||||||
|
subjectRefs: string[]
|
||||||
|
isKeyBeat: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单集分镜规划。 */
|
||||||
|
export interface EpisodePlan {
|
||||||
|
episodeNo: number
|
||||||
|
episodeTitle: string
|
||||||
|
storyGoal: string
|
||||||
|
centralConflict: string
|
||||||
|
emotionalArc: string
|
||||||
|
pacing: string
|
||||||
|
endingHook: string
|
||||||
|
beats: StoryboardBeat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最小连续镜头及其主体形态绑定。 */
|
||||||
|
export interface StoryboardShot {
|
||||||
|
shotNo: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
visualFocus: string
|
||||||
|
subjectRefs: string[]
|
||||||
|
durationSeconds: number
|
||||||
|
subjectBindings?: { subjectRef: string; profileId: string; formId: string; formName: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单集按 Beat 组织的镜头结果。 */
|
||||||
|
export interface EpisodeShots {
|
||||||
|
episodeNo: number
|
||||||
|
episodePlan: EpisodePlan
|
||||||
|
beatShots: { beatNo: number; shots: StoryboardShot[] }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分镜校验报告。 */
|
||||||
|
export interface ShotValidation {
|
||||||
|
valid: boolean
|
||||||
|
issues: { episodeNo: number; beatNo?: number | null; shotNo?: number | null; message: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Breakdown 的最终成果及阶段 checkpoint 共有的可展示字段。 */
|
||||||
|
export interface BreakdownResult {
|
||||||
|
subjectCandidates?: SubjectCandidate[]
|
||||||
|
subjectForms?: SubjectForm[]
|
||||||
|
storyboardPlans?: EpisodePlan[]
|
||||||
|
storyboardEpisodeShots?: EpisodeShots[]
|
||||||
|
storyboardShotValidation?: ShotValidation
|
||||||
|
storyboardNeedsManualReview?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作流执行结果与抽取任务状态独立。 */
|
||||||
|
export interface WorkflowExecution {
|
||||||
|
executionId: string
|
||||||
|
status: 'running' | 'completed' | 'failed'
|
||||||
|
errorMessage?: string
|
||||||
|
startedAt: string
|
||||||
|
completedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** latest 和 checkpoint 的恢复状态;失败 checkpoint 可能只有 execution。 */
|
||||||
|
export interface BreakdownState extends BreakdownResult {
|
||||||
|
workflowExecution?: WorkflowExecution
|
||||||
|
runConfig?: BreakdownInput & { episodeGroups: EpisodeGroup[] }
|
||||||
|
taskSummary?: TaskSummary
|
||||||
|
tasks?: BreakdownTask[]
|
||||||
|
breakdownResult?: BreakdownResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 不同失败阶段的恢复端点,不能用 retry 替代所有恢复。 */
|
||||||
|
export type BreakdownAction = 'start' | 'retry' | 'resume-shots' | 'resume-storyboard'
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { TabsRoot, TabsList, TabsTrigger, TabsContent, ProgressRoot, ProgressIndicator } from 'reka-ui'
|
||||||
|
import { ArrowRight, Download, Check, Circle } from '@lucide/vue'
|
||||||
|
import { EmptyState } from '../../components/ui'
|
||||||
|
import { useProjectContext } from '../projects/context'
|
||||||
|
import { projectsApi } from '../projects/api'
|
||||||
|
import { workflowCheckpoints } from '../workflows/selectors'
|
||||||
|
import { getOperation, runOperation } from '../workflows/operations'
|
||||||
|
import HistoryPanel from '../workflows/HistoryPanel.vue'
|
||||||
|
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||||
|
import { downloadText, formatDate } from '../../lib/format'
|
||||||
|
|
||||||
|
/** 剧本创作工作区,正式数据库内容为准,checkpoint 只补充计划与恢复信息。 */
|
||||||
|
const { project, checkpoints, refresh, error } = useProjectContext()
|
||||||
|
const selected = ref<number>()
|
||||||
|
const tab = ref('episodes')
|
||||||
|
const dramaRecords = computed(() => workflowCheckpoints(checkpoints.value, 'create-drama'))
|
||||||
|
const state = computed(() => dramaRecords.value.at(-1)?.state)
|
||||||
|
const episodes = computed(() => project.value?.episodes ?? [])
|
||||||
|
const episode = computed(() => episodes.value.find(item => item.episode === selected.value) ?? episodes.value[0])
|
||||||
|
const total = computed(() => state.value?.episodeCount)
|
||||||
|
const operation = computed(() => getOperation(project.value!.id))
|
||||||
|
const complete = computed(() => project.value?.status === 'completed')
|
||||||
|
const hasCheckpoint = computed(() => dramaRecords.value.length > 0)
|
||||||
|
const canGenerate = computed(
|
||||||
|
() =>
|
||||||
|
!complete.value &&
|
||||||
|
hasCheckpoint.value &&
|
||||||
|
!!project.value?.characters.length &&
|
||||||
|
!!project.value?.world &&
|
||||||
|
(!total.value || episodes.value.length < total.value)
|
||||||
|
)
|
||||||
|
const canRewrite = computed(
|
||||||
|
() =>
|
||||||
|
!complete.value &&
|
||||||
|
hasCheckpoint.value &&
|
||||||
|
!!episodes.value.length &&
|
||||||
|
(!total.value || episodes.value.length >= total.value)
|
||||||
|
)
|
||||||
|
const stages = computed(() => [
|
||||||
|
{ label: '角色设定', done: !!project.value?.characters.length },
|
||||||
|
{ label: '世界观', done: !!project.value?.world },
|
||||||
|
{ label: '编写剧集', done: !!total.value && episodes.value.length >= total.value },
|
||||||
|
{ label: '审核与改写', done: project.value?.reviews[0]?.passed === true },
|
||||||
|
{ label: '完成', done: complete.value }
|
||||||
|
])
|
||||||
|
|
||||||
|
/** 恢复操作不能以“查看状态”的名义发送,始终在确认后调用。 */
|
||||||
|
async function resume(action: 'resume-generation' | 'resume-rewrite') {
|
||||||
|
if (!project.value || operation.value.pending || error.value) return
|
||||||
|
const id = project.value.id
|
||||||
|
await runOperation(id, action === 'resume-generation' ? '恢复剧集生成' : '恢复剧本改写', () =>
|
||||||
|
projectsApi.resume(id, action)
|
||||||
|
)
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出当前数据库中的完整正文,方便本地校对。 */
|
||||||
|
function exportScript() {
|
||||||
|
if (!project.value) return
|
||||||
|
const content = episodes.value
|
||||||
|
.map(item => `第 ${item.episode} 集 ${item.title}\n\n${item.content}`)
|
||||||
|
.join('\n\n————————————\n\n')
|
||||||
|
downloadText(`${project.value.title || '剧本'}.txt`, content)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="stage-strip">
|
||||||
|
<div v-for="(stage, index) in stages" :key="stage.label" 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>
|
||||||
|
<div v-if="!complete" class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
已写入 {{ episodes.length }}<span v-if="total"> / {{ total }}</span> 集<span v-if="!total">
|
||||||
|
· 等待计划集数</span
|
||||||
|
>。{{
|
||||||
|
project?.status === 'generating' ? '生成状态来自后端,请等待下一次更新。' : '可按中断阶段继续处理。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<ConfirmAction
|
||||||
|
label="恢复生成"
|
||||||
|
description="用于剧集尚未写完的情况。后端会检查角色、世界观与 checkpoint,并继续处理剧集。"
|
||||||
|
acknowledgement
|
||||||
|
:disabled="!canGenerate || operation.pending || !!error"
|
||||||
|
@confirm="resume('resume-generation')"
|
||||||
|
/><ConfirmAction
|
||||||
|
label="恢复改写"
|
||||||
|
description="用于剧集已齐全但审核未通过的情况。将读取 checkpoint 的改写上下文并重新审核。"
|
||||||
|
acknowledgement
|
||||||
|
:disabled="!canRewrite || operation.pending || !!error"
|
||||||
|
@confirm="resume('resume-rewrite')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProgressRoot
|
||||||
|
v-if="total && !complete"
|
||||||
|
class="progress-track mt-4"
|
||||||
|
:model-value="Math.min(episodes.length, total)"
|
||||||
|
:max="total"
|
||||||
|
aria-label="已写入剧集数量"
|
||||||
|
><ProgressIndicator
|
||||||
|
class="progress-fill"
|
||||||
|
:style="{ width: Math.min(100, (episodes.length / total) * 100) + '%' }"
|
||||||
|
/></ProgressRoot>
|
||||||
|
<TabsRoot v-model="tab" class="mt-7">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<TabsList class="tabs-list" aria-label="剧本内容"
|
||||||
|
><TabsTrigger class="tab-trigger" value="episodes"
|
||||||
|
>剧集正文 <span>{{ episodes.length }}</span></TabsTrigger
|
||||||
|
><TabsTrigger class="tab-trigger" value="characters">角色设定</TabsTrigger
|
||||||
|
><TabsTrigger class="tab-trigger" value="world">世界观</TabsTrigger
|
||||||
|
><TabsTrigger class="tab-trigger" value="review">审核记录</TabsTrigger></TabsList
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button class="text-button" :disabled="!episodes.length" @click="exportScript">
|
||||||
|
<Download :size="14" />导出剧本</button
|
||||||
|
><RouterLink
|
||||||
|
v-if="complete"
|
||||||
|
class="text-button text-accent"
|
||||||
|
:to="`/projects/${project?.id}/breakdown`"
|
||||||
|
>进入拆解<ArrowRight :size="14"
|
||||||
|
/></RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content-with-history panel mt-4">
|
||||||
|
<main class="min-w-0">
|
||||||
|
<TabsContent value="episodes" class="h-full">
|
||||||
|
<div v-if="episode" class="script-workspace">
|
||||||
|
<aside class="episode-list">
|
||||||
|
<p class="px-4 pb-3 pt-5 text-[11px] font-medium tracking-wider text-muted">剧集目录</p>
|
||||||
|
<button
|
||||||
|
v-for="item in episodes"
|
||||||
|
:key="item.episode"
|
||||||
|
class="episode-link"
|
||||||
|
:class="{ active: episode.episode === item.episode }"
|
||||||
|
:aria-pressed="episode.episode === item.episode"
|
||||||
|
@click="selected = item.episode"
|
||||||
|
>
|
||||||
|
<span class="episode-number">{{ String(item.episode).padStart(2, '0') }}</span
|
||||||
|
><span class="truncate">{{ item.title }}</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
<article class="script-page">
|
||||||
|
<p class="eyebrow">第 {{ String(episode.episode).padStart(2, '0') }} 集</p>
|
||||||
|
<h2 class="mt-3 text-2xl font-semibold">{{ episode.title }}</h2>
|
||||||
|
<p v-if="episode.summary" class="script-summary">{{ episode.summary }}</p>
|
||||||
|
<div class="script-body">{{ episode.content }}</div>
|
||||||
|
<dl v-if="episode.conflict || episode.hook" class="script-notes">
|
||||||
|
<template v-if="episode.conflict"
|
||||||
|
><dt>核心冲突</dt>
|
||||||
|
<dd>{{ episode.conflict }}</dd></template
|
||||||
|
><template v-if="episode.hook"
|
||||||
|
><dt>结尾钩子</dt>
|
||||||
|
<dd>{{ episode.hook }}</dd></template
|
||||||
|
>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
title="剧本正在酝酿"
|
||||||
|
description="剧集写入数据库后会自动出现在这里。角色与世界观生成期间,可以在右侧查看已保存的执行记录。"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="characters" class="p-6 lg:p-8"
|
||||||
|
><div v-if="project?.characters.length" class="divide-y divide-line">
|
||||||
|
<article
|
||||||
|
v-for="character in project.characters"
|
||||||
|
:key="character.id"
|
||||||
|
class="py-5 first:pt-0"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center gap-3">
|
||||||
|
<h3 class="text-lg font-semibold">{{ character.name }}</h3>
|
||||||
|
<span class="tag">{{ character.role || '角色' }}</span
|
||||||
|
><span class="text-xs text-muted">{{ character.occupation }}</span>
|
||||||
|
</div>
|
||||||
|
<dl class="detail-grid">
|
||||||
|
<template
|
||||||
|
v-for="field in [
|
||||||
|
{ key: 'personality', label: '性格' },
|
||||||
|
{ key: 'goal', label: '目标' },
|
||||||
|
{ key: 'secret', label: '秘密' }
|
||||||
|
] as const"
|
||||||
|
:key="field.key"
|
||||||
|
><dt>{{ field.label }}</dt>
|
||||||
|
<dd>{{ character[field.key] || '未提供' }}</dd></template
|
||||||
|
>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<EmptyState v-else title="尚无角色设定" description="角色生成结束后会在这里显示。"
|
||||||
|
/></TabsContent>
|
||||||
|
<TabsContent value="world" class="p-6 lg:p-8"
|
||||||
|
><div v-if="project?.world">
|
||||||
|
<p class="eyebrow">故事的发生之地</p>
|
||||||
|
<h2 class="mb-7 mt-3 text-xl font-semibold">
|
||||||
|
{{ project.world.era }} · {{ project.world.location }}
|
||||||
|
</h2>
|
||||||
|
<dl class="detail-grid">
|
||||||
|
<template
|
||||||
|
v-for="field in [
|
||||||
|
{ key: 'background', label: '背景' },
|
||||||
|
{ key: 'coreConflict', label: '核心冲突' },
|
||||||
|
{ key: 'tone', label: '基调' }
|
||||||
|
] as const"
|
||||||
|
:key="field.key"
|
||||||
|
><dt>{{ field.label }}</dt>
|
||||||
|
<dd>{{ project.world[field.key] || '未提供' }}</dd></template
|
||||||
|
>
|
||||||
|
</dl>
|
||||||
|
<details v-if="project.world.rules" class="mt-6 text-sm">
|
||||||
|
<summary class="cursor-pointer text-muted">世界规则</summary>
|
||||||
|
<pre class="json-view mt-3">{{ JSON.stringify(project.world.rules, null, 2) }}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<EmptyState v-else title="尚无世界观" description="世界观会在角色设定之后生成。"
|
||||||
|
/></TabsContent>
|
||||||
|
<TabsContent value="review" class="p-6 lg:p-8"
|
||||||
|
><div v-if="project?.reviews.length" class="space-y-5">
|
||||||
|
<article
|
||||||
|
v-for="review in project.reviews"
|
||||||
|
:key="review.id"
|
||||||
|
class="border-b border-line pb-5"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex justify-between gap-3">
|
||||||
|
<span
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="review.passed ? 'text-success' : 'text-danger'"
|
||||||
|
>{{ review.passed ? '审核通过' : '需要修改' }}</span
|
||||||
|
><time class="text-xs text-muted">{{ formatDate(review.createdAt) }}</time>
|
||||||
|
</div>
|
||||||
|
<p class="whitespace-pre-wrap text-sm leading-7">
|
||||||
|
{{ review.message || '本次审核未附加说明。' }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
v-else
|
||||||
|
title="还没有审核记录"
|
||||||
|
description="剧集生成完成后,工作流会进行内容审核与必要的改写。"
|
||||||
|
/>
|
||||||
|
<details v-if="state?.rewriteSuggestion" class="mt-5 text-sm">
|
||||||
|
<summary class="cursor-pointer text-muted">查看改写建议原文</summary>
|
||||||
|
<pre class="json-view mt-3">{{ JSON.stringify(state.rewriteSuggestion, null, 2) }}</pre>
|
||||||
|
</details></TabsContent
|
||||||
|
>
|
||||||
|
</main>
|
||||||
|
<HistoryPanel :checkpoints="checkpoints" workflow="create-drama" />
|
||||||
|
</div>
|
||||||
|
</TabsRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Create Drama 页面入口,路由通过动态导入进行分包。 */
|
||||||
|
export { default as CreateDramaPage } from './CreateDramaPage.vue'
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, provide } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ArrowLeft, RefreshCw, FileText, Layers, LoaderCircle } from '@lucide/vue'
|
||||||
|
import { StatusBadge } from '../../components/ui'
|
||||||
|
import { projectContextKey, useProjectData } from './context'
|
||||||
|
import { getOperation } from '../workflows/operations'
|
||||||
|
|
||||||
|
/** 项目级数据与操作状态跨 graph 页面共享。 */
|
||||||
|
const route = useRoute()
|
||||||
|
const id = computed(() => String(route.params.projectId))
|
||||||
|
const context = useProjectData(id)
|
||||||
|
provide(projectContextKey, context)
|
||||||
|
const operation = computed(() => getOperation(id.value))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-container">
|
||||||
|
<RouterLink to="/projects" class="back-link"><ArrowLeft :size="14" />全部剧本</RouterLink>
|
||||||
|
<div class="page-heading mt-5">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="eyebrow">项目工作台</p>
|
||||||
|
<h1 class="break-words">
|
||||||
|
{{ context.project.value?.title || context.project.value?.topic || '读取项目' }}
|
||||||
|
</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
|
||||||
|
class="hidden sm:inline"
|
||||||
|
>刷新</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="workflow-nav" aria-label="项目工作流">
|
||||||
|
<RouterLink :to="`/projects/${id}/create-drama`"
|
||||||
|
><FileText :size="17" />剧本创作<span class="nav-code">create-drama</span></RouterLink
|
||||||
|
>
|
||||||
|
<RouterLink :to="`/projects/${id}/breakdown`"
|
||||||
|
><Layers :size="17" />剧本拆解<span class="nav-code">breakdown</span></RouterLink
|
||||||
|
>
|
||||||
|
</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>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ArrowUpRight, Search, RefreshCw, Clapperboard } from '@lucide/vue'
|
||||||
|
import { usePolling } from '../../composables/usePolling'
|
||||||
|
import { EmptyState, StatusBadge } from '../../components/ui'
|
||||||
|
import { formatDate } from '../../lib/format'
|
||||||
|
import { projectsApi } from './api'
|
||||||
|
import CreateProjectDialog from './components/CreateProjectDialog.vue'
|
||||||
|
|
||||||
|
/** 项目索引:真实查询、客户端筛选,以及进入两条 graph 的入口。 */
|
||||||
|
const router = useRouter()
|
||||||
|
const query = usePolling(ref('projects'), (_, signal) => projectsApi.list(signal), 12_000)
|
||||||
|
const search = ref('')
|
||||||
|
const filter = ref('all')
|
||||||
|
const projects = computed(() => query.data.value ?? [])
|
||||||
|
const filtered = computed(() =>
|
||||||
|
projects.value.filter(item => {
|
||||||
|
const matches = `${item.title ?? ''} ${item.topic} ${item.style ?? ''}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search.value.toLowerCase().trim())
|
||||||
|
return matches && (filter.value === 'all' || item.status === filter.value)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 202 后直接打开工作流,后续刷新由项目布局负责。 */
|
||||||
|
function openProject(id: string) {
|
||||||
|
void router.push(`/projects/${encodeURIComponent(id)}/create-drama`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多项筛选的重置放在函数中,避免格式化后产生无效模板表达式。 */
|
||||||
|
function clearFilters() {
|
||||||
|
search.value = ''
|
||||||
|
filter.value = 'all'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-container">
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">工作空间 / 项目</p>
|
||||||
|
<h1>我的剧本</h1>
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
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 v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
||||||
|
{{ query.error.value }}<button class="ml-3 underline" @click="query.refresh">重新连接</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel mt-4 overflow-hidden" :aria-busy="query.loading.value">
|
||||||
|
<div v-if="!query.data.value && query.loading.value" class="p-10 text-sm text-muted" role="status">
|
||||||
|
正在读取项目……
|
||||||
|
</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
|
||||||
|
v-else
|
||||||
|
title="等待连接后端"
|
||||||
|
description="启动后端服务并检查 API_PROXY_TARGET,连接成功后会显示你已有的项目。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-xs text-muted">
|
||||||
|
项目数据来自后端数据库<span v-if="query.updatedAt.value">
|
||||||
|
· 更新于 {{ formatDate(query.updatedAt.value) }}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { request, optionalResource } from '../../lib/http'
|
||||||
|
import type { Checkpoint } from '../workflows/types'
|
||||||
|
import type { CreateProjectInput, DramaState, Project, ProjectDetail } from './types'
|
||||||
|
|
||||||
|
/** 项目与 Create Drama API;路径严格对应后端 dev。 */
|
||||||
|
export const projectsApi = {
|
||||||
|
list: (signal?: AbortSignal) => request<Project[]>('/projects', { signal }),
|
||||||
|
detail: (id: string, signal?: AbortSignal) =>
|
||||||
|
request<ProjectDetail>(`/projects/${encodeURIComponent(id)}`, { signal }),
|
||||||
|
create: (input: CreateProjectInput) =>
|
||||||
|
request<{ projectId: string; status: string }>('/projects', { method: 'POST', body: input }),
|
||||||
|
state: (id: string, signal?: AbortSignal) =>
|
||||||
|
optionalResource(request<DramaState>(`/projects/${encodeURIComponent(id)}/state`, { signal })),
|
||||||
|
checkpoints: (id: string, signal?: AbortSignal) =>
|
||||||
|
request<Checkpoint[]>(`/projects/${encodeURIComponent(id)}/checkpoints`, { signal }),
|
||||||
|
resume: (id: string, action: 'resume-generation' | 'resume-rewrite') =>
|
||||||
|
request<unknown>(`/projects/${encodeURIComponent(id)}/${action}`, { method: 'POST', timeoutMs: 0 })
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { DialogTrigger } from 'reka-ui'
|
||||||
|
import { ArrowRight, LoaderCircle, Plus } from '@lucide/vue'
|
||||||
|
import { AppDialog } from '../../../components/ui'
|
||||||
|
import { projectsApi } from '../api'
|
||||||
|
import { errorMessage } from '../../../lib/http'
|
||||||
|
|
||||||
|
/** 新建剧本表单,202 成功后交由父级跳转,不在前端模拟生成。 */
|
||||||
|
const emit = defineEmits<{ created: [projectId: string] }>()
|
||||||
|
const open = ref(false)
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const form = reactive({ topic: '', style: '爽文反转', episodeCount: 3 })
|
||||||
|
|
||||||
|
/** 校验正整数集数和主题,禁止双击产生重复项目。 */
|
||||||
|
async function submit() {
|
||||||
|
if (busy.value) return
|
||||||
|
error.value = ''
|
||||||
|
if (!form.topic.trim() || !Number.isSafeInteger(form.episodeCount) || form.episodeCount <= 0) {
|
||||||
|
error.value = '请填写故事主题,并输入大于 0 的整数集数。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const result = await projectsApi.create({
|
||||||
|
...form,
|
||||||
|
topic: form.topic.trim(),
|
||||||
|
style: form.style.trim() || '爽文反转'
|
||||||
|
})
|
||||||
|
open.value = false
|
||||||
|
form.topic = ''
|
||||||
|
emit('created', result.projectId)
|
||||||
|
} catch (cause) {
|
||||||
|
error.value = errorMessage(cause)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppDialog
|
||||||
|
v-model:open="open"
|
||||||
|
title="新建剧本"
|
||||||
|
description="从一个故事想法开始。提交后,工作流将依次生成角色、世界观和剧集,并进行审核。"
|
||||||
|
:busy="busy"
|
||||||
|
>
|
||||||
|
<template #trigger
|
||||||
|
><DialogTrigger class="button button-primary"><Plus :size="16" />新建剧本</DialogTrigger></template
|
||||||
|
>
|
||||||
|
<form class="mt-7 space-y-5" @submit.prevent="submit">
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="topic">故事主题 <span class="text-accent">*</span></label
|
||||||
|
><textarea
|
||||||
|
id="topic"
|
||||||
|
v-model="form.topic"
|
||||||
|
class="input min-h-32 resize-y"
|
||||||
|
placeholder="描述主角、故事背景,以及你希望展开的核心冲突……"
|
||||||
|
required
|
||||||
|
:disabled="busy"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-[1fr_110px] gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="style">剧本风格</label
|
||||||
|
><input
|
||||||
|
id="style"
|
||||||
|
v-model="form.style"
|
||||||
|
class="input"
|
||||||
|
placeholder="如:都市悬疑、爽文反转"
|
||||||
|
:disabled="busy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="episode-count">计划集数</label
|
||||||
|
><input
|
||||||
|
id="episode-count"
|
||||||
|
v-model.number="form.episodeCount"
|
||||||
|
class="input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
required
|
||||||
|
:disabled="busy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs leading-5 text-muted">
|
||||||
|
建议先用 3 集验证生成效果。生成会实际调用后端模型,产生相应费用。
|
||||||
|
</p>
|
||||||
|
<p v-if="error" class="alert alert-error" role="alert">{{ error }}</p>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<button type="button" class="button button-secondary" :disabled="busy" @click="open = false">
|
||||||
|
取消</button
|
||||||
|
><button type="submit" class="button button-primary" :disabled="busy">
|
||||||
|
<LoaderCircle v-if="busy" :size="16" class="animate-spin" />开始生成<ArrowRight
|
||||||
|
v-if="!busy"
|
||||||
|
:size="16"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { computed, inject, type InjectionKey } from 'vue'
|
||||||
|
import { usePolling } from '../../composables/usePolling'
|
||||||
|
import { projectsApi } from './api'
|
||||||
|
import type { ProjectDetail } from './types'
|
||||||
|
import type { Checkpoint } from '../workflows/types'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
/** 同一项目的两个 graph 共用一份查询,避免每个面板重复请求。 */
|
||||||
|
export function useProjectData(id: Ref<string>) {
|
||||||
|
const query = usePolling(id, async (projectId, signal) => {
|
||||||
|
const [project, checkpoints] = await Promise.all([
|
||||||
|
projectsApi.detail(projectId, signal),
|
||||||
|
projectsApi.checkpoints(projectId, signal)
|
||||||
|
])
|
||||||
|
return { project, checkpoints }
|
||||||
|
})
|
||||||
|
const project = computed<ProjectDetail | null>(() => query.data.value?.project ?? null)
|
||||||
|
const checkpoints = computed<Checkpoint[]>(() => query.data.value?.checkpoints ?? [])
|
||||||
|
return { ...query, project, checkpoints }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目布局向子页面提供的类型安全上下文。 */
|
||||||
|
export const projectContextKey: InjectionKey<ReturnType<typeof useProjectData>> = Symbol('project-context')
|
||||||
|
|
||||||
|
/** 读取项目上下文,错误布局在开发阶段立即暴露。 */
|
||||||
|
export function useProjectContext() {
|
||||||
|
const context = inject(projectContextKey)
|
||||||
|
if (!context) throw new Error('项目页面必须位于 ProjectLayout 中')
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** 项目模块公共入口。 */
|
||||||
|
export { projectsApi } from './api'
|
||||||
|
export type { Project, ProjectDetail, CreateProjectInput } from './types'
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/** 后端 DramaProject.status 的原始取值。 */
|
||||||
|
export type ProjectStatus = 'draft' | 'generating' | 'completed' | 'need_review' | 'failed'
|
||||||
|
|
||||||
|
/** 创建请求不包含标题,标题由后端在工作流收尾时更新。 */
|
||||||
|
export interface CreateProjectInput {
|
||||||
|
topic: string
|
||||||
|
style: string
|
||||||
|
episodeCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目列表记录;列表接口不包含剧集数量,不能据此推断生成进度。 */
|
||||||
|
export interface Project {
|
||||||
|
id: string
|
||||||
|
title: string | null
|
||||||
|
topic: string
|
||||||
|
style: string | null
|
||||||
|
status: ProjectStatus
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 正式数据库剧集;episode 是编号,与 Breakdown 的 episodeNo 区分。 */
|
||||||
|
export interface Episode {
|
||||||
|
id?: string
|
||||||
|
episode: number
|
||||||
|
title: string
|
||||||
|
summary?: string | null
|
||||||
|
content: string
|
||||||
|
conflict?: string | null
|
||||||
|
hook?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剧本阶段的角色设定,不等同于拆解后的主体资产。 */
|
||||||
|
export interface Character {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
role?: string | null
|
||||||
|
age?: number | null
|
||||||
|
occupation?: string | null
|
||||||
|
personality?: string | null
|
||||||
|
goal?: string | null
|
||||||
|
secret?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 正式数据库保存的世界观。 */
|
||||||
|
export interface World {
|
||||||
|
background?: string | null
|
||||||
|
era?: string | null
|
||||||
|
location?: string | null
|
||||||
|
coreConflict?: string | null
|
||||||
|
tone?: string | null
|
||||||
|
rules?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端审核记录,列表按最新在前返回。 */
|
||||||
|
export interface Review {
|
||||||
|
id: string
|
||||||
|
passed: boolean
|
||||||
|
message?: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目详情接口包含的关联数据。 */
|
||||||
|
export interface ProjectDetail extends Project {
|
||||||
|
episodes: Episode[]
|
||||||
|
characters: Character[]
|
||||||
|
world: World | null
|
||||||
|
reviews: Review[]
|
||||||
|
tasks: { id: string; type: string; status: string; error?: string | null }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create Drama checkpoint 中页面使用的状态切片。 */
|
||||||
|
export interface DramaState {
|
||||||
|
episodeCount?: number
|
||||||
|
episodes?: Episode[]
|
||||||
|
retryCount?: number
|
||||||
|
reviewPassed?: boolean
|
||||||
|
rewriteSuggestion?: unknown
|
||||||
|
rewritePlan?: unknown
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { DialogTrigger } from 'reka-ui'
|
||||||
|
import { AppDialog } from '../../components/ui'
|
||||||
|
|
||||||
|
/** 耗费模型额度或覆盖结果的动作必须经过明确确认。 */
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
disabled?: boolean
|
||||||
|
acknowledgement?: boolean
|
||||||
|
primary?: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ confirm: [] }>()
|
||||||
|
const open = ref(false)
|
||||||
|
const acknowledged = ref(false)
|
||||||
|
|
||||||
|
/** 发出事件后关闭弹窗,操作状态由项目级锁负责。 */
|
||||||
|
function confirm() {
|
||||||
|
if (props.disabled || (props.acknowledgement && !acknowledged.value)) return
|
||||||
|
emit('confirm')
|
||||||
|
open.value = false
|
||||||
|
acknowledged.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppDialog v-model:open="open" :title="label" :description="description">
|
||||||
|
<template #trigger
|
||||||
|
><DialogTrigger
|
||||||
|
class="button"
|
||||||
|
:class="primary ? 'button-primary' : 'button-secondary'"
|
||||||
|
:disabled="disabled"
|
||||||
|
>{{ label }}</DialogTrigger
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
<p class="mt-5 text-sm leading-6 text-muted">
|
||||||
|
此操作会提交到真实后端,可能调用模型并产生费用。请勿在其他页面或终端同时启动相同工作流。
|
||||||
|
</p>
|
||||||
|
<label v-if="acknowledgement" class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||||
|
><input
|
||||||
|
v-model="acknowledged"
|
||||||
|
class="mt-1 accent-accent"
|
||||||
|
type="checkbox"
|
||||||
|
/>我已确认后台任务停止,当前没有同项目的生成或拆解任务在运行。</label
|
||||||
|
>
|
||||||
|
<div class="dialog-footer mt-6">
|
||||||
|
<button class="button button-secondary" @click="open = false">取消</button
|
||||||
|
><button
|
||||||
|
class="button button-primary"
|
||||||
|
:disabled="disabled || (acknowledgement && !acknowledged)"
|
||||||
|
@click="confirm"
|
||||||
|
>
|
||||||
|
确认{{ label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AppDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Clock3 } from '@lucide/vue'
|
||||||
|
import { formatDate, nodeLabel } from '../../lib/format'
|
||||||
|
import { workflowCheckpoints } from './selectors'
|
||||||
|
import type { Checkpoint } from './types'
|
||||||
|
|
||||||
|
/** 时间线只说明 checkpoint 已保存,不把每条记录误标为工作流成功。 */
|
||||||
|
const props = defineProps<{ checkpoints: Checkpoint[]; workflow: string }>()
|
||||||
|
const history = computed(() => workflowCheckpoints(props.checkpoints, props.workflow).slice(-18).toReversed())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="history-panel">
|
||||||
|
<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 v-if="!history.length" class="text-xs leading-6 text-muted">工作流尚未保存执行记录。</p>
|
||||||
|
<ol v-else class="timeline">
|
||||||
|
<li v-for="(item, index) in history" :key="item.checkpointId" :class="{ 'timeline-latest': index === 0 }">
|
||||||
|
<p class="text-xs font-medium">{{ nodeLabel(item.metadata?.nodeName) }}</p>
|
||||||
|
<p class="mt-1 text-[11px] text-muted">
|
||||||
|
{{ formatDate(item.createdAt)
|
||||||
|
}}<span v-if="item.metadata?.nodeDurationMs">
|
||||||
|
· {{ (item.metadata.nodeDurationMs / 1000).toFixed(1) }}s</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<span v-if="item.state.workflowExecution?.status === 'failed'" class="mt-1 block text-xs text-danger"
|
||||||
|
>运行失败</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p class="mt-5 border-t border-line pt-4 text-[11px] leading-5 text-muted">
|
||||||
|
记录在节点或批次结束后更新,长时间无新记录不一定意味着任务失败。
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** 跨 graph 的观测与操作接口。 */
|
||||||
|
export { getOperation, runOperation } from './operations'
|
||||||
|
export { workflowCheckpoints, breakdownSnapshot, recoveryOptions } from './selectors'
|
||||||
|
export type { Checkpoint } from './types'
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { getOperation, runOperation } from './operations'
|
||||||
|
|
||||||
|
describe('项目级长请求互斥', () => {
|
||||||
|
it('同一项目不同时执行两个 graph,错误不伪装成功', async () => {
|
||||||
|
let fail!: (reason: Error) => void
|
||||||
|
const action = vi.fn<() => Promise<unknown>>(
|
||||||
|
() =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
fail = reject
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const first = runOperation('operation-test', '拆解', action)
|
||||||
|
expect(await runOperation('operation-test', '改写', action)).toBe(false)
|
||||||
|
expect(action).toHaveBeenCalledTimes(1)
|
||||||
|
fail(new Error('连接中断'))
|
||||||
|
expect(await first).toBe(false)
|
||||||
|
expect(getOperation('operation-test')).toMatchObject({ pending: false, error: '连接中断', notice: '' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
import { errorMessage } from '../../lib/http'
|
||||||
|
|
||||||
|
/** 当前浏览器会话中的操作状态;切换路由不会丢失长请求。 */
|
||||||
|
export interface Operation {
|
||||||
|
pending: boolean
|
||||||
|
label: string
|
||||||
|
error: string
|
||||||
|
notice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按项目而非 graph 上锁,防止改写剧本与拆解同时提交。 */
|
||||||
|
const operations = reactive<Record<string, Operation>>({})
|
||||||
|
|
||||||
|
/** 取得项目操作状态,不向浏览器持久化虚假的后台运行状态。 */
|
||||||
|
export function getOperation(projectId: string): Operation {
|
||||||
|
return (operations[projectId] ??= { pending: false, label: '', error: '', notice: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只提交一次;断网不自动重试,后台是否已执行由刷新后的 checkpoint 确认。 */
|
||||||
|
export async function runOperation(projectId: string, label: string, action: () => Promise<unknown>) {
|
||||||
|
const operation = getOperation(projectId)
|
||||||
|
if (operation.pending) return false
|
||||||
|
Object.assign(operation, { pending: true, label, error: '', notice: '' })
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
operation.notice = '请求已返回,正在重新读取后端状态;最终结果以下方记录为准。'
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
operation.error = errorMessage(error)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
operation.pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import CreateProjectDialog from '../projects/components/CreateProjectDialog.vue'
|
||||||
|
import BreakdownPage from '../breakdown/BreakdownPage.vue'
|
||||||
|
import CreateDramaPage from '../create-drama/CreateDramaPage.vue'
|
||||||
|
import ProjectsPage from '../projects/ProjectsPage.vue'
|
||||||
|
import { projectContextKey, type useProjectData } from '../projects/context'
|
||||||
|
import type { ProjectDetail } from '../projects/types'
|
||||||
|
import type { Checkpoint } from './types'
|
||||||
|
|
||||||
|
/** 模拟 API 响应仅用于测试,不会打包进生产页面。 */
|
||||||
|
const fixture: ProjectDetail = {
|
||||||
|
id: 'page-test-project',
|
||||||
|
title: '雨夜来信',
|
||||||
|
topic: '一封信改变了两个人的命运',
|
||||||
|
style: '都市悬疑',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: '2026-08-27T00:00:00Z',
|
||||||
|
updatedAt: '2026-08-27T00:00:00Z',
|
||||||
|
episodes: [{ episode: 1, title: '来信', content: '<script>不要执行模型内容</script>\n第一场:旧书店。' }],
|
||||||
|
characters: [{ id: 'character', name: '林知夏' }],
|
||||||
|
world: { era: '当代' },
|
||||||
|
reviews: [],
|
||||||
|
tasks: []
|
||||||
|
}
|
||||||
|
let wrapper: VueWrapper | undefined
|
||||||
|
|
||||||
|
/** 使用真实上下文形状,不绕过页面内的异步操作与按钮守卫。 */
|
||||||
|
function context(): ReturnType<typeof useProjectData> {
|
||||||
|
const data = ref({ project: fixture, checkpoints: [] as Checkpoint[] })
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
project: computed(() => data.value.project),
|
||||||
|
checkpoints: computed(() => data.value.checkpoints),
|
||||||
|
loading: ref(false),
|
||||||
|
error: ref(''),
|
||||||
|
updatedAt: ref(''),
|
||||||
|
refresh: vi.fn<() => Promise<void>>().mockResolvedValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找到挂载到 body 的 Reka 弹窗按钮。 */
|
||||||
|
function button(label: string): HTMLButtonElement {
|
||||||
|
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
|
||||||
|
if (!element) throw new Error('找不到按钮:' + label)
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = undefined
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('工作台页面交互', () => {
|
||||||
|
it('项目筛选无结果时可清除条件并返回真实列表', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn<typeof fetch>().mockImplementation(async () => new Response(JSON.stringify({ data: [fixture] })))
|
||||||
|
)
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [{ path: '/', component: ProjectsPage }]
|
||||||
|
})
|
||||||
|
await router.push('/')
|
||||||
|
wrapper = mount(ProjectsPage, { attachTo: document.body, global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('雨夜来信')
|
||||||
|
await wrapper.get('input[aria-label="搜索项目"]').setValue('不存在的关键词')
|
||||||
|
expect(wrapper.text()).toContain('没有匹配的剧本')
|
||||||
|
button('清除筛选').click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('tbody tr')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('新建弹窗提交真实参数并返回 202 的项目 ID', async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValue(new Response('{"projectId":"created","status":"generating"}', { status: 202 }))
|
||||||
|
vi.stubGlobal('fetch', fetcher)
|
||||||
|
wrapper = mount(CreateProjectDialog, { attachTo: document.body })
|
||||||
|
button('新建剧本').click()
|
||||||
|
await flushPromises()
|
||||||
|
const topic = document.querySelector<HTMLTextAreaElement>('#topic')!
|
||||||
|
topic.value = ' 雨夜来信 '
|
||||||
|
topic.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
const count = document.querySelector<HTMLInputElement>('#episode-count')!
|
||||||
|
count.value = '6'
|
||||||
|
count.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.emitted('created')).toEqual([['created']])
|
||||||
|
expect(JSON.parse(fetcher.mock.calls[0]![1]!.body as string)).toEqual({
|
||||||
|
topic: '雨夜来信',
|
||||||
|
style: '爽文反转',
|
||||||
|
episodeCount: 6
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('修改每组集数会废弃预览,重新预览并确认后才能启动', async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||||
|
if (String(url).includes('breakdown-preview'))
|
||||||
|
return new Response(
|
||||||
|
'{"data":{"episodeCount":1,"groupCount":1,"estimatedTaskCount":3,"groups":[],"tasks":[],"modules":["character","scene","prop"],"groupSize":2}}'
|
||||||
|
)
|
||||||
|
return new Response('{"data":{"workflowExecution":{"status":"completed"}}}')
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetcher)
|
||||||
|
const provided = context()
|
||||||
|
wrapper = mount(BreakdownPage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { provide: { [projectContextKey as symbol]: provided } }
|
||||||
|
})
|
||||||
|
button('预览分组').click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(button('开始拆解').disabled).toBe(false)
|
||||||
|
await wrapper.get('#group-size').setValue('2')
|
||||||
|
expect(document.body.textContent).not.toContain('开始拆解')
|
||||||
|
button('预览分组').click()
|
||||||
|
await flushPromises()
|
||||||
|
button('开始拆解').click()
|
||||||
|
await flushPromises()
|
||||||
|
button('确认开始拆解').click()
|
||||||
|
await flushPromises()
|
||||||
|
const post = fetcher.mock.calls.find(call => call[1]?.method === 'POST')
|
||||||
|
expect(post?.[0]).toBe('/api/projects/page-test-project/breakdown/start')
|
||||||
|
expect(JSON.parse(post![1]!.body as string)).toEqual({ groupSize: 2, modules: ['character', 'scene', 'prop'] })
|
||||||
|
expect(provided.refresh).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('剧本文本按纯文本显示,不执行模型输出的 HTML', () => {
|
||||||
|
wrapper = mount(CreateDramaPage, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
||||||
|
})
|
||||||
|
expect(wrapper.find('.script-body').text()).toContain('<script>不要执行模型内容</script>')
|
||||||
|
expect(wrapper.find('.script-body script').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { breakdownSnapshot, recoveryOptions, workflowCheckpoints } from './selectors'
|
||||||
|
import type { Checkpoint } from './types'
|
||||||
|
import type { BreakdownState } from '../breakdown/types'
|
||||||
|
|
||||||
|
/** 只构造测试所需的真实 checkpoint 字段,避免页面依赖虚构 DTO。 */
|
||||||
|
function checkpoint(index: number, state: BreakdownState, workflowName = 'breakdown'): Checkpoint {
|
||||||
|
return { checkpointId: String(index), workflowName, createdAt: new Date(index * 1000).toISOString(), state }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Checkpoint 选择与恢复', () => {
|
||||||
|
it('按 graph 隔离并保持输入不可变', () => {
|
||||||
|
const records = [checkpoint(2, {}), checkpoint(1, {}, 'create-drama')]
|
||||||
|
expect(workflowCheckpoints(records, 'breakdown').map(item => item.checkpointId)).toEqual(['2'])
|
||||||
|
expect(records[0]?.checkpointId).toBe('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只有错误的最新 checkpoint 仍能展示上一份成果,同时保留最新失败状态', () => {
|
||||||
|
const result = breakdownSnapshot([
|
||||||
|
checkpoint(1, {
|
||||||
|
breakdownResult: { subjectCandidates: [] },
|
||||||
|
runConfig: { groupSize: 3, modules: ['character'], episodeGroups: [] }
|
||||||
|
}),
|
||||||
|
checkpoint(2, {
|
||||||
|
workflowExecution: {
|
||||||
|
executionId: 'e',
|
||||||
|
status: 'failed',
|
||||||
|
startedAt: '',
|
||||||
|
errorMessage: '模型未返回 JSON'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
expect(result?.runConfig?.groupSize).toBe(3)
|
||||||
|
expect(result?.workflowExecution?.status).toBe('failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('新阶段不能继承上一轮 completed 状态', () => {
|
||||||
|
const result = breakdownSnapshot([
|
||||||
|
checkpoint(1, {
|
||||||
|
workflowExecution: { executionId: 'e', status: 'completed', startedAt: '' },
|
||||||
|
breakdownResult: {}
|
||||||
|
}),
|
||||||
|
checkpoint(2, { runConfig: { groupSize: 2, modules: ['scene'], episodeGroups: [] } })
|
||||||
|
])
|
||||||
|
expect(result?.workflowExecution).toBeUndefined()
|
||||||
|
expect(result?.runConfig?.groupSize).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('恢复窗口和后端一致,过旧的失败任务不启用重试', () => {
|
||||||
|
const records = Array.from({ length: 11 }, (_, index) =>
|
||||||
|
checkpoint(
|
||||||
|
index,
|
||||||
|
index === 0
|
||||||
|
? {
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
taskId: 't',
|
||||||
|
module: 'prop',
|
||||||
|
status: 'failed',
|
||||||
|
attempt: 1,
|
||||||
|
group: { groupId: 'g', groupNo: 1, startEpisodeNo: 1, endEpisodeNo: 1, episodes: [] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect(recoveryOptions(records).retry).toBe(false)
|
||||||
|
expect(recoveryOptions(records.slice(0, 10)).retry).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { BreakdownState } from '../breakdown/types'
|
||||||
|
import type { Checkpoint } from './types'
|
||||||
|
|
||||||
|
/** 按工作流隔离并排序,后端数组顺序变化不会影响恢复判断。 */
|
||||||
|
export function workflowCheckpoints(checkpoints: Checkpoint[], name: string): Checkpoint[] {
|
||||||
|
return checkpoints
|
||||||
|
.filter(item => item.workflowName === name)
|
||||||
|
.toSorted((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 失败快照可能只有错误;回看最近有效状态,同时只使用最新记录的执行状态。 */
|
||||||
|
export function breakdownSnapshot(checkpoints: Checkpoint[]): BreakdownState | null {
|
||||||
|
const records = workflowCheckpoints(checkpoints, 'breakdown')
|
||||||
|
const latest = records.at(-1)
|
||||||
|
if (!latest) return null
|
||||||
|
const data =
|
||||||
|
records
|
||||||
|
.slice(-30)
|
||||||
|
.toReversed()
|
||||||
|
.find(item => item.state.runConfig || item.state.breakdownResult || item.state.storyboardPlans?.length)
|
||||||
|
?.state ?? {}
|
||||||
|
return { ...data, workflowExecution: latest.state.workflowExecution }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据最近后端可恢复窗口推导操作;页面仍需先确认任务已经结束。 */
|
||||||
|
export function recoveryOptions(checkpoints: Checkpoint[]) {
|
||||||
|
const records = workflowCheckpoints(checkpoints, 'breakdown').toReversed()
|
||||||
|
const retry = records.slice(0, 10).some(item => item.state.tasks?.some(task => task.status === 'failed'))
|
||||||
|
const shots = records
|
||||||
|
.slice(0, 30)
|
||||||
|
.some(({ state }) =>
|
||||||
|
Boolean(
|
||||||
|
state.storyboardPlans?.length &&
|
||||||
|
state.subjectCandidates?.length &&
|
||||||
|
state.subjectForms &&
|
||||||
|
(state.storyboardEpisodeShots?.length ?? 0) < state.storyboardPlans.length
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const storyboard = records
|
||||||
|
.slice(0, 10)
|
||||||
|
.some(({ state }) =>
|
||||||
|
Boolean(state.storyboardEpisodeShots?.length && state.subjectCandidates?.length && state.subjectForms)
|
||||||
|
)
|
||||||
|
return { retry, shots, storyboard }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { DramaState } from '../projects/types'
|
||||||
|
import type { BreakdownState } from '../breakdown/types'
|
||||||
|
|
||||||
|
/** 观测接口返回的 checkpoint,保留工作流归属以防混淆两条 graph。 */
|
||||||
|
export interface Checkpoint {
|
||||||
|
checkpointId: string
|
||||||
|
workflowName: string
|
||||||
|
createdAt: string
|
||||||
|
metadata?: { nodeName?: string; nodeDurationMs?: number; [key: string]: unknown } | null
|
||||||
|
state: DramaState & BreakdownState
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ProjectStatus } from '../features/projects/types'
|
||||||
|
|
||||||
|
/** 原始项目状态的中文名称。 */
|
||||||
|
export const statusLabels: Record<ProjectStatus, string> = {
|
||||||
|
draft: '草稿',
|
||||||
|
generating: '生成中',
|
||||||
|
completed: '已完成',
|
||||||
|
need_review: '待审核',
|
||||||
|
failed: '生成失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日期统一使用用户设备所在时区。 */
|
||||||
|
export function formatDate(value?: string): string {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime())
|
||||||
|
? '—'
|
||||||
|
: new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点中文名称;未知新节点仍显示原名,方便后端扩展。 */
|
||||||
|
export function nodeLabel(name?: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
createProject: '创建项目',
|
||||||
|
generateCharacter: '角色设定',
|
||||||
|
generateWorld: '世界观',
|
||||||
|
generateEpisode: '编写剧集',
|
||||||
|
reviewDrama: '审核剧本',
|
||||||
|
generateRewriteSuggestion: '改写建议',
|
||||||
|
rewrite: '改写剧本',
|
||||||
|
finishProject: '项目收尾',
|
||||||
|
resumeRewrite: '恢复改写',
|
||||||
|
prepare: '准备分组',
|
||||||
|
prepareRetry: '准备失败重试',
|
||||||
|
summarizeTasks: '汇总抽取任务',
|
||||||
|
extractTask: '执行抽取',
|
||||||
|
mergeMentions: '合并提及',
|
||||||
|
buildProfiles: '整合主体档案',
|
||||||
|
buildSubjects: '构建主体',
|
||||||
|
generateStoryboardPlan: '规划剧情节拍',
|
||||||
|
generateStoryboardShots: '生成分镜',
|
||||||
|
storyboard_shots_progress: '镜头批次已保存',
|
||||||
|
storyboard_ready: '分镜校验完成,准备入库',
|
||||||
|
extract: '模块抽取',
|
||||||
|
merge_mentions: '合并提及',
|
||||||
|
build_profiles: '整合主体档案',
|
||||||
|
build_subjects: '构建主体',
|
||||||
|
generate_storyboard_plan: '规划剧情节拍',
|
||||||
|
generate_storyboard_shots: '生成分镜',
|
||||||
|
completed: '工作流完成',
|
||||||
|
failed: '工作流失败',
|
||||||
|
partial_failed: '部分抽取失败',
|
||||||
|
finalize: '结果入库'
|
||||||
|
}
|
||||||
|
return name ? (labels[name] ?? name) : '状态记录'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载纯文本或 JSON,不将模型内容作为 HTML 渲染。 */
|
||||||
|
export function downloadText(filename: string, content: string, type = 'text/plain;charset=utf-8') {
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type }))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename.replace(/[<>:"/\\|?*]/g, '_')
|
||||||
|
link.click()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiError, optionalResource, request } from './http'
|
||||||
|
import { projectsApi } from '../features/projects/api'
|
||||||
|
import { breakdownApi } from '../features/breakdown/api'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('后端 API 契约', () => {
|
||||||
|
it('解包 data,同时保留创建项目的顶层 202 结构', async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<(url: string, options: RequestInit) => Promise<Response>>()
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({ data: [{ id: 'p1' }] })))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ projectId: 'p2', status: 'generating' }), { status: 202 })
|
||||||
|
)
|
||||||
|
vi.stubGlobal('fetch', fetcher)
|
||||||
|
expect(await projectsApi.list()).toEqual([{ id: 'p1' }])
|
||||||
|
expect(await projectsApi.create({ topic: '故事', style: '悬疑', episodeCount: 3 })).toEqual({
|
||||||
|
projectId: 'p2',
|
||||||
|
status: 'generating'
|
||||||
|
})
|
||||||
|
expect(JSON.parse(fetcher.mock.calls[1]![1].body as string)).toEqual({
|
||||||
|
topic: '故事',
|
||||||
|
style: '悬疑',
|
||||||
|
episodeCount: 3
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只将缺少 checkpoint 的 404 视为可选结果,保留服务器错误', async () => {
|
||||||
|
expect(await optionalResource(Promise.reject(new ApiError('没有 checkpoint', 404)))).toBeNull()
|
||||||
|
await expect(optionalResource(Promise.reject(new ApiError('数据库异常', 500)))).rejects.toMatchObject({
|
||||||
|
status: 500
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保留错误细节且拒绝 HTML 代理错误', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi
|
||||||
|
.fn<(url: string, options: RequestInit) => Promise<Response>>()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ message: '校验失败', issues: ['缺少形态'] }), { status: 400 })
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(new Response('<html>Bad Gateway</html>', { status: 502 }))
|
||||||
|
)
|
||||||
|
await expect(request('/projects')).rejects.toMatchObject({ status: 400, details: ['缺少形态'] })
|
||||||
|
await expect(request('/projects')).rejects.toThrow('接口未返回 JSON')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('预览模块使用逗号参数,三种恢复不误发到 start', async () => {
|
||||||
|
const fetcher = vi
|
||||||
|
.fn<(url: string, options: RequestInit) => Promise<Response>>()
|
||||||
|
.mockImplementation(() => Promise.resolve(new Response('{"data":{}}')))
|
||||||
|
vi.stubGlobal('fetch', fetcher)
|
||||||
|
await breakdownApi.preview('a/b', { groupSize: 3, modules: ['character', 'scene'] })
|
||||||
|
for (const action of ['retry', 'resume-shots', 'resume-storyboard'] as const)
|
||||||
|
await breakdownApi.run('p', action)
|
||||||
|
expect(fetcher.mock.calls.map(call => call[0])).toEqual([
|
||||||
|
'/api/projects/a%2Fb/breakdown-preview?groupSize=3&modules=character%2Cscene',
|
||||||
|
'/api/projects/p/breakdown/retry',
|
||||||
|
'/api/projects/p/breakdown/resume-shots',
|
||||||
|
'/api/projects/p/breakdown/resume-storyboard'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('长工作流不使用普通查询的超时,不自动重试 POST', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
let finish!: (value: Response) => void
|
||||||
|
const fetcher = vi.fn<(url: string, options: RequestInit) => Promise<Response>>().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>(resolve => {
|
||||||
|
finish = resolve
|
||||||
|
})
|
||||||
|
)
|
||||||
|
vi.stubGlobal('fetch', fetcher)
|
||||||
|
const running = breakdownApi.run('p', 'start', { groupSize: 3, modules: ['prop'] })
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000)
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1)
|
||||||
|
expect(fetcher.mock.calls[0]![1].signal?.aborted).toBe(false)
|
||||||
|
finish(new Response('{"data":{"workflowExecution":{"status":"failed"}}}'))
|
||||||
|
expect(await running).toMatchObject({ workflowExecution: { status: 'failed' } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/** HTTP 错误携带状态码与后端细节,供页面区别空数据、校验错误与网络故障。 */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly details?: unknown
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求选项;长工作流显式关闭超时,不自动重试任何 POST。 */
|
||||||
|
export interface RequestOptions {
|
||||||
|
method?: 'GET' | 'POST'
|
||||||
|
body?: unknown
|
||||||
|
signal?: AbortSignal
|
||||||
|
timeoutMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断可安全访问键的 JSON 对象。 */
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容普通 {data} 响应,以及 POST /projects 的顶层 202 响应。 */
|
||||||
|
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const base = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, '')
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutMs = options.timeoutMs ?? 30_000
|
||||||
|
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined
|
||||||
|
const abort = () => controller.abort()
|
||||||
|
options.signal?.addEventListener('abort', abort, { once: true })
|
||||||
|
if (options.signal?.aborted) controller.abort()
|
||||||
|
try {
|
||||||
|
const response = await fetch(base + path, {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' })
|
||||||
|
},
|
||||||
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
let data: unknown
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : undefined
|
||||||
|
} catch {
|
||||||
|
throw new ApiError('接口未返回 JSON,请检查 API 地址与代理配置。', response.status)
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
isRecord(data) && typeof data.message === 'string' ? data.message : `请求失败(${response.status})`
|
||||||
|
throw new ApiError(
|
||||||
|
message,
|
||||||
|
response.status,
|
||||||
|
isRecord(data) ? (data.details ?? data.issues ?? data.error) : data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (isRecord(data) && 'data' in data ? data.data : data) as T
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) throw error
|
||||||
|
if (options.signal?.aborted) throw error
|
||||||
|
if (controller.signal.aborted)
|
||||||
|
throw new ApiError('请求等待超时。后台任务可能仍在运行,请先刷新状态,不要重复提交。', 0)
|
||||||
|
throw new ApiError('无法连接后端。请检查服务与代理;已提交的任务可能仍在运行。', 0)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
options.signal?.removeEventListener('abort', abort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅允许指定的“尚无 checkpoint”404 视为缺省,不吞掉 500 或网络错误。 */
|
||||||
|
export async function optionalResource<T>(promise: Promise<T>): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return await promise
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 404) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页面统一显示可操作的错误信息。 */
|
||||||
|
export function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError && error.details) {
|
||||||
|
const details = typeof error.details === 'string' ? error.details : JSON.stringify(error.details)
|
||||||
|
return `${error.message} ${details}`
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : '操作失败,请刷新后重试。'
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import { router } from './router'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
/** 单页应用入口;不在浏览器保存模型密钥。 */
|
||||||
|
createApp(App).use(router).mount('#app')
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
/** 按 graph 分路由,项目布局保留共享数据与长请求状态。 */
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', redirect: '/projects' },
|
||||||
|
{
|
||||||
|
path: '/projects',
|
||||||
|
component: () => import('../features/projects/ProjectsPage.vue'),
|
||||||
|
meta: { title: '我的剧本' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/projects/:projectId',
|
||||||
|
component: () => import('../features/projects/ProjectLayout.vue'),
|
||||||
|
redirect: to => `/projects/${String(to.params.projectId)}/create-drama`,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'create-drama',
|
||||||
|
component: () => import('../features/create-drama/CreateDramaPage.vue'),
|
||||||
|
meta: { title: '剧本创作' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'breakdown',
|
||||||
|
component: () => import('../features/breakdown/BreakdownPage.vue'),
|
||||||
|
meta: { title: '剧本拆解' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
component: () => import('../components/NotFoundPage.vue'),
|
||||||
|
meta: { title: '页面不存在' }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
scrollBehavior: () => ({ top: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 路由切换同步浏览器标题,不依赖页面组件主动修改。 */
|
||||||
|
router.afterEach(to => {
|
||||||
|
document.title = `${String(to.meta.title || '工作空间')} · 短剧工作台`
|
||||||
|
})
|
||||||
+978
@@ -0,0 +1,978 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/* 工作台主题:纸白内容、炭灰导航、克制的朱砂色操作。 */
|
||||||
|
@theme {
|
||||||
|
--color-ink: #292c28;
|
||||||
|
--color-muted: #797c74;
|
||||||
|
--color-faint: #acafa6;
|
||||||
|
--color-line: #e6e7e0;
|
||||||
|
--color-accent: #b45435;
|
||||||
|
--color-danger: #b14236;
|
||||||
|
--color-success: #4d7654;
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #f6f7f3;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
summary {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
button:not(:disabled),
|
||||||
|
summary,
|
||||||
|
select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
select:focus-visible {
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
::selection {
|
||||||
|
background: #ead7c8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 216px minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 29px 16px 21px;
|
||||||
|
background: #242722;
|
||||||
|
color: #d2d5cb;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 0 8px;
|
||||||
|
margin-bottom: 43px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 33px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid #737265;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #e9dbc5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.brand strong {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
display: block;
|
||||||
|
color: #f2f2e8;
|
||||||
|
}
|
||||||
|
.brand small {
|
||||||
|
display: block;
|
||||||
|
font-size: 8px;
|
||||||
|
letter-spacing: 0.11em;
|
||||||
|
color: #8e9386;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.sidebar-section-label {
|
||||||
|
padding: 0 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #7e8577;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.side-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a9b0a0;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s;
|
||||||
|
}
|
||||||
|
.side-link:hover {
|
||||||
|
background: #2e332b;
|
||||||
|
color: #edf0e6;
|
||||||
|
}
|
||||||
|
.side-link.selected {
|
||||||
|
color: #f3f0e4;
|
||||||
|
background: #393e32;
|
||||||
|
}
|
||||||
|
.side-link.selected svg {
|
||||||
|
color: #c0c9aa;
|
||||||
|
}
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 30px;
|
||||||
|
}
|
||||||
|
.sidebar-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #393d34;
|
||||||
|
margin: 13px 9px;
|
||||||
|
}
|
||||||
|
.sidebar-collapsed {
|
||||||
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.sidebar-collapsed .sidebar-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sidebar-collapsed .sidebar {
|
||||||
|
padding-inline: 12px;
|
||||||
|
}
|
||||||
|
.sidebar-collapsed .brand {
|
||||||
|
padding: 0 9px;
|
||||||
|
}
|
||||||
|
.sidebar-collapsed .side-link {
|
||||||
|
justify-content: center;
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
.app-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
height: 66px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding-inline: 40px;
|
||||||
|
border-bottom: 1px solid var(--color-line);
|
||||||
|
background: #fbfcf8;
|
||||||
|
}
|
||||||
|
.topbar-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.page-container {
|
||||||
|
max-width: 1540px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 37px 40px 56px;
|
||||||
|
}
|
||||||
|
.page-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.page-heading h1 {
|
||||||
|
margin: 9px 0 0;
|
||||||
|
font-size: 27px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.11em;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.page-description {
|
||||||
|
margin-top: 9px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
min-height: 36px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
}
|
||||||
|
.button-primary {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.button-primary:hover:not(:disabled) {
|
||||||
|
background: #9e472c;
|
||||||
|
}
|
||||||
|
.button-secondary {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #dcded5;
|
||||||
|
color: #4b5146;
|
||||||
|
}
|
||||||
|
.button-secondary:hover:not(:disabled) {
|
||||||
|
background: #f0f2ea;
|
||||||
|
border-color: #c7cbbf;
|
||||||
|
}
|
||||||
|
.icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 33px;
|
||||||
|
width: 33px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.icon-button:hover:not(:disabled) {
|
||||||
|
background: #eeefe9;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
.text-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.text-button:hover:not(:disabled) {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.workspace-note {
|
||||||
|
display: flex;
|
||||||
|
gap: 13px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 17px 20px;
|
||||||
|
background: #edefe5;
|
||||||
|
border: 1px solid #e2e5d7;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #656e53;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.filter-button {
|
||||||
|
padding: 7px 11px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.filter-button:hover {
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
.filter-button.active {
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: #e9ece2;
|
||||||
|
}
|
||||||
|
.search-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.search-field input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 11px;
|
||||||
|
width: 145px;
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
.search-field:focus-within {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.project-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.project-table th {
|
||||||
|
padding: 13px 20px;
|
||||||
|
background: #fafbf7;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 1px solid var(--color-line);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.project-table td {
|
||||||
|
padding: 21px 20px;
|
||||||
|
border-bottom: 1px solid #eeefe9;
|
||||||
|
}
|
||||||
|
.project-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.project-table tbody tr:hover {
|
||||||
|
background: #fcfcf9;
|
||||||
|
}
|
||||||
|
.project-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 13px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.project-monogram {
|
||||||
|
width: 39px;
|
||||||
|
height: 45px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #eeeee5;
|
||||||
|
border: 1px solid #e3e2d6;
|
||||||
|
border-radius: 3px 5px 5px 3px;
|
||||||
|
box-shadow: inset 3px 0 #dddfcf;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: 'Songti SC', serif;
|
||||||
|
font-size: 17px;
|
||||||
|
color: #6c715c;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
.status-badge[data-status='completed'] {
|
||||||
|
color: #557b50;
|
||||||
|
}
|
||||||
|
.status-badge[data-status='generating'],
|
||||||
|
.status-badge[data-status='running'] {
|
||||||
|
color: #aa8137;
|
||||||
|
}
|
||||||
|
.status-badge[data-status='failed'] {
|
||||||
|
color: #b25040;
|
||||||
|
}
|
||||||
|
.status-badge[data-status='need_review'] {
|
||||||
|
color: #977536;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 300px;
|
||||||
|
padding: 52px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: #1b201c70;
|
||||||
|
}
|
||||||
|
.dialog-content {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 51;
|
||||||
|
width: min(560px, calc(100vw - 32px));
|
||||||
|
max-height: 90dvh;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: 0 24px 90px #141c2329;
|
||||||
|
}
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--color-line);
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4b5047;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--color-ink);
|
||||||
|
border: 1px solid #d9ddd2;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.input::placeholder {
|
||||||
|
color: #a3a79b;
|
||||||
|
}
|
||||||
|
.input:disabled {
|
||||||
|
background: #f5f6f2;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
padding: 12px 15px;
|
||||||
|
border: 1px solid #dfdfca;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f4f4e9;
|
||||||
|
color: #6c6b45;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.alert-error {
|
||||||
|
border-color: #ebd4cd;
|
||||||
|
background: #fcf4f0;
|
||||||
|
color: #9f4a38;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 7px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.workflow-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 28px;
|
||||||
|
border-bottom: 1px solid #dfe2d8;
|
||||||
|
}
|
||||||
|
.workflow-nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 0 2px 16px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.workflow-nav a.router-link-active {
|
||||||
|
color: #454f37;
|
||||||
|
border-color: #5d7050;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.nav-code {
|
||||||
|
font:
|
||||||
|
10px ui-monospace,
|
||||||
|
monospace;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.stage-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.stage-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: #969c8d;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.stage-item.done {
|
||||||
|
color: #607550;
|
||||||
|
}
|
||||||
|
.stage-arrow {
|
||||||
|
margin-left: 12px;
|
||||||
|
color: #c1c6b8;
|
||||||
|
}
|
||||||
|
.progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #e7eade;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #7d9167;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.tabs-list {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 3px;
|
||||||
|
background: #eceedf;
|
||||||
|
border-radius: 5px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tab-trigger {
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #7f8574;
|
||||||
|
}
|
||||||
|
.tab-trigger[data-state='active'] {
|
||||||
|
background: #fff;
|
||||||
|
color: #343c2a;
|
||||||
|
box-shadow: 0 1px 3px #384e2110;
|
||||||
|
}
|
||||||
|
.tab-trigger span {
|
||||||
|
margin-left: 6px;
|
||||||
|
opacity: 0.65;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.content-with-history {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 207px;
|
||||||
|
min-height: 480px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.history-panel {
|
||||||
|
padding: 23px 21px;
|
||||||
|
background: #fafbf7;
|
||||||
|
border-left: 1px solid var(--color-line);
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 9px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.timeline li {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 0 23px 17px;
|
||||||
|
border-left: 1px solid #dfe3d7;
|
||||||
|
}
|
||||||
|
.timeline li::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -3.5px;
|
||||||
|
top: 5px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #c5ccbc;
|
||||||
|
}
|
||||||
|
.timeline li:last-child {
|
||||||
|
border-left-color: transparent;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.timeline .timeline-latest::before {
|
||||||
|
background: #8a9b6f;
|
||||||
|
box-shadow: 0 0 0 3px #e9edde;
|
||||||
|
}
|
||||||
|
.script-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 166px minmax(0, 1fr);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.episode-list {
|
||||||
|
border-right: 1px solid var(--color-line);
|
||||||
|
padding: 0 7px 20px;
|
||||||
|
background: #fdfefa;
|
||||||
|
max-height: 850px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.episode-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 11px 9px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.episode-link:hover {
|
||||||
|
background: #f2f4ec;
|
||||||
|
}
|
||||||
|
.episode-link.active {
|
||||||
|
color: #666b43;
|
||||||
|
background: #eeeee0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.episode-number {
|
||||||
|
font:
|
||||||
|
10px ui-monospace,
|
||||||
|
monospace;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.script-page {
|
||||||
|
padding: 34px 34px 50px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.script-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
line-height: 1.9;
|
||||||
|
padding: 17px 0 22px;
|
||||||
|
margin-bottom: 23px;
|
||||||
|
border-bottom: 1px solid var(--color-line);
|
||||||
|
}
|
||||||
|
.script-body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 2.15;
|
||||||
|
color: #474b42;
|
||||||
|
}
|
||||||
|
.script-notes {
|
||||||
|
margin-top: 36px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--color-line);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.script-notes dt {
|
||||||
|
color: #7f8267;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.script-notes dd {
|
||||||
|
color: var(--color-muted);
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 70px minmax(0, 1fr);
|
||||||
|
gap: 15px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
.detail-grid dt {
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.detail-grid dd {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.json-view {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f6f7f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
font:
|
||||||
|
11px/1.9 ui-monospace,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #f1f3eb;
|
||||||
|
color: #7b826d;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.breakdown-config {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 145px minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
.checkbox {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid #c7cebb;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.checkbox[data-state='checked'] {
|
||||||
|
background: #788761;
|
||||||
|
border-color: #788761;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.group-preview {
|
||||||
|
margin-top: 24px;
|
||||||
|
border-top: 1px solid var(--color-line);
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.group-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid #e4e7db;
|
||||||
|
background: #fafbf6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.recovery-strip {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 17px;
|
||||||
|
border: 1px dashed #d8dccc;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.subject-ref {
|
||||||
|
display: inline-flex;
|
||||||
|
background: #f0f2e8;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #727d58;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.subject-details summary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: #7f846f;
|
||||||
|
font-size: 11px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.subject-details summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.subject-details[open] summary svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.beat-section {
|
||||||
|
margin-top: 25px;
|
||||||
|
padding-top: 23px;
|
||||||
|
border-top: 1px solid var(--color-line);
|
||||||
|
}
|
||||||
|
.beat-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.beat-number {
|
||||||
|
color: #889174;
|
||||||
|
font:
|
||||||
|
11px ui-monospace,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
.shot-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 17px;
|
||||||
|
padding: 17px;
|
||||||
|
border: 1px solid #e7e9df;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #fdfefb;
|
||||||
|
}
|
||||||
|
.shot-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 47px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #8a907d;
|
||||||
|
}
|
||||||
|
.skip-link {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
top: -80px;
|
||||||
|
left: 15px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中等宽度保留正文空间,执行记录下移,移动端导航收为图标。 */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.page-container {
|
||||||
|
padding-inline: 26px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
padding-inline: 26px;
|
||||||
|
}
|
||||||
|
.content-with-history {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.history-panel {
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid var(--color-line);
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
.breakdown-config {
|
||||||
|
grid-template-columns: 120px minmax(0, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.breakdown-config > button {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 62px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
padding: 23px 7px 15px;
|
||||||
|
}
|
||||||
|
.sidebar-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
padding: 0 7px;
|
||||||
|
margin-bottom: 35px;
|
||||||
|
}
|
||||||
|
.side-link {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 11px 10px;
|
||||||
|
}
|
||||||
|
.sidebar-collapsed {
|
||||||
|
grid-template-columns: 62px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.sidebar-collapsed .sidebar {
|
||||||
|
padding-inline: 7px;
|
||||||
|
}
|
||||||
|
.sidebar-collapsed .brand {
|
||||||
|
padding-inline: 7px;
|
||||||
|
}
|
||||||
|
.page-container {
|
||||||
|
padding: 25px 18px 40px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
height: 56px;
|
||||||
|
padding-inline: 18px;
|
||||||
|
}
|
||||||
|
.topbar-note {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.page-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.page-heading h1 {
|
||||||
|
font-size: 23px;
|
||||||
|
}
|
||||||
|
.nav-code {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.workflow-nav {
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.workspace-note {
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 10px;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
.workspace-note span.mx-3 {
|
||||||
|
margin-inline: 6px;
|
||||||
|
}
|
||||||
|
.script-workspace {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.episode-list {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--color-line);
|
||||||
|
padding: 8px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.episode-list > p {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.episode-link {
|
||||||
|
width: auto;
|
||||||
|
max-width: 170px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.script-page {
|
||||||
|
padding: 25px 20px 35px;
|
||||||
|
}
|
||||||
|
.timeline {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.breakdown-config {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.dialog-content {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
.stage-strip {
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
.stage-arrow {
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
.tab-trigger {
|
||||||
|
padding: 7px 8px;
|
||||||
|
}
|
||||||
|
.project-table td,
|
||||||
|
.project-table th {
|
||||||
|
padding-inline: 14px;
|
||||||
|
}
|
||||||
|
.shot-row {
|
||||||
|
gap: 12px;
|
||||||
|
padding: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "*.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
/** 开发代理保留 /api 前缀;长工作流不使用代理层的短超时。 */
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
return {
|
||||||
|
plugins: [vue(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: env.API_PROXY_TARGET || 'http://localhost:3412',
|
||||||
|
changeOrigin: true,
|
||||||
|
timeout: 0,
|
||||||
|
proxyTimeout: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
/** 使用轻量 DOM 测试 API、异步状态和 Vue 页面行为。 */
|
||||||
|
export default defineConfig({ plugins: [vue()], test: { environment: 'happy-dom', restoreMocks: true } })
|
||||||
Reference in New Issue
Block a user