Commit 1a494815 authored by yaoke.yk's avatar yaoke.yk

分镜页面优化和运营后台

parent cf966a19
...@@ -10,8 +10,17 @@ REDIS_PORT=16379 ...@@ -10,8 +10,17 @@ REDIS_PORT=16379
BACKEND_PORT=8080 BACKEND_PORT=8080
FRONTEND_PORT=3000 FRONTEND_PORT=3000
ADMIN_SERVER_PORT=8081
ADMIN_WEB_PORT=3001
SPRING_PROFILES_ACTIVE=prod SPRING_PROFILES_ACTIVE=prod
VITE_API_BASE_URL=/api VITE_API_BASE_URL=/api
BILLING_MODE=STRICT
BILLING_COST_CHARACTER_EXTRACT=3
BILLING_COST_SCENE_EXTRACT=3
BILLING_COST_STORYBOARD=5
BILLING_COST_STORYBOARD_PROMPT=1
BILLING_COST_CHARACTER_IMAGE=6
BILLING_COST_SCENE_IMAGE=2
VOLCENGINE_ARK_API_KEY= VOLCENGINE_ARK_API_KEY=
VOLCENGINE_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 VOLCENGINE_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
......
# AI 漫剧 SaaS 平台 — 设计文档索引
> 生成时间: 2026-04-16
> 技术栈: Java 17 + Spring Boot 3.x + MyBatis-Plus + Spring AI Alibaba + MySQL + Redis Stack (缓存 + 向量) + 阿里云 OSS / 火山引擎 TOS(主)+ MinIO(备选)
> 前端参考: `doc/html` (Vite + React + Shadcn)
> 开源参考: Toonflow-app、AIComicBuilder、waoowaoo
## 文档结构
| # | 文档 | 说明 |
|---|------|------|
| 01 | [产品定位与 SaaS 化策略](./01-产品定位与SaaS化策略.md) | 目标用户、商业模式、核心差异化 |
| 02 | [前端功能与页面清单](./02-前端功能与页面清单.md) | 基于 doc/html 原型的完整功能规格 |
| 03 | [三开源项目对比与吸收](./03-三开源项目对比与吸收.md) | Toonflow / AIComicBuilder / waoowaoo 取长补短 |
| 04 | [核心业务流程与 AI 流水线](./04-核心业务流程与AI流水线.md) | 剧本→大纲→分集→角色→分镜→视频 端到端流程 |
| 05 | [后端架构设计](./05-后端架构设计.md) | Spring Boot 模块划分、分层、核心组件 |
| 06 | [数据库设计](./06-数据库设计.md) | MySQL 表结构 + Redis Stack 向量存储 |
| 07 | [API 接口规范](./07-API接口规范.md) | REST + WebSocket/SSE 全量接口定义 |
| 08 | [异步任务与 AI 工作流](./08-异步任务与AI工作流.md) | 三层 Agent + 任务队列 + Skill 系统 |
| 09 | [计费与多租户基础设施](./09-计费与多租户SaaS基础设施.md) | 余额冻结、Saga 事务、租户隔离 |
| 10 | [部署与运维](./10-部署与运维.md) | Docker、监控、可观测性 |
| 11 | [实施路线图](./11-实施路线图与里程碑.md) | 分阶段交付计划 |
| 12 | [前端原型复盘与流程验证](./12-前端原型复盘与流程验证.md) | 17 页面清单、设计文档对齐度、技术债务 |
| 13 | [多 Agent 功能复盘与落地方案](./13-多Agent功能复盘与落地方案.md) | 三项目 Agent 源码分析、Java 落地方案、吸收策略 |
| 14 | [前端原型截图复盘与最终确认](./14-前端原型截图复盘与最终确认.md) | 34 截图映射、页面覆盖率、原型与设计文档差异 |
| 15 | [执行任务列表](./15-执行任务列表.md) | M0~M9 全量任务清单,~302 项,完成即标记 |
| 17 | [平台运营后台设计 (M14)](./17-平台运营后台设计.md) | yaoai-admin 模块 + vue-vben-admin 前端,跨租户管理/充值/审计 |
## 一句话总结
**做一个面向短剧工作室和内容创作者的 Web SaaS,核心能力是"剧本一键产出完整动画短剧视频"。后端用 Java + Spring AI Alibaba(通义系列)承载核心 AI 流水线,吸收 Toonflow 的三层 Agent 架构、AIComicBuilder 的角色一致性方案、waoowaoo 的 SaaS 工程化基础设施,实现 `doc/html` 原型定义的全部功能。**
# 01 · 产品定位与 SaaS 化策略
# 01 · 产品定位与 SaaS 化策略
## 1. 产品定义
**产品名(暂定)**: YaoAI 漫剧工坊 (YaoAI Comic Studio)
**一句话定位**: 给内容创作者的 AI 漫剧生产 SaaS —— 上传小说/剧本,自动产出完整动画短剧视频,从剧本解析、角色设定、分镜制作到视频合成全流程可视化、可编辑、可协作。
**对标**:
- 国外: Runway、Pika、LTX Studio
- 国内: 即梦短剧、AIComicBuilder(开源)、Toonflow(开源)
- 我们的差异化: **中文短剧场景深度优化 + 企业级协作 + 通义系列原生集成 + 资产复用库**
## 2. 目标用户
| 层级 | 画像 | 关键诉求 |
|------|------|----------|
| **C 端创作者** | 网文作者、短视频博主、独立漫画师 | 低成本、快速出片、无需美术基础 |
| **小型工作室** | 5~20 人短剧 MCN、同人社团 | 多人协作、资产复用、品质可控 |
| **企业客户** | 出版社、游戏公司、教育机构 | 私有化部署、品牌安全、批量产出 |
## 3. 核心价值主张
1. **剧本一键开工**: 上传小说 → AI 自动提取角色/场景/关系,智能分集,无需手动拆分。
2. **角色一致性**: 借鉴 AIComicBuilder 的"四视图参考 + 文字约束"双轨制,角色在 1000+ 镜头中保持同一外形。
3. **三层 Agent 质量管控**: 借鉴 Toonflow,决策/执行/监督三层 Agent 协作,AI 产出有审核有修订。
4. **所见即所得的分镜工作台**: 拖拽排序、批量生成、实时预览、版本回溯。
5. **企业级 SaaS 基础**: 多租户隔离、按量计费、团队权限、资产库共享、审计日志。
## 4. 商业模式
### 4.1 套餐
| 套餐 | 月费 | 积分 | 团队人数 | 功能 |
|------|------|------|---------|------|
| Free | ¥0 | 100 | 1 | 基础生成、带水印 |
| Pro | ¥99 | 3000 | 5 | 去水印、资产库、参考图模式 |
| Team | ¥499 | 20000 | 20 | 团队协作、角色版本管理、API |
| Enterprise | 定制 | 定制 | 无限 | 私有部署、SLA、专属客服、定制模型 |
### 4.2 计费单位 (积分)
| 操作 | 单价(积分) | 说明 |
|------|-----------|------|
| 文本生成(大纲/分镜拆分) | 1~5 | 按 token |
| 角色四视图生成 | 20 | 一次性 |
| 场景/道具图 | 10 | 单张 |
| 分镜首尾帧 | 15 × 2 | 首+尾 |
| 视频生成 (5s, 720p) | 50 | 单镜头 |
| 视频生成 (8s, 1080p) | 120 | 单镜头 |
| TTS 配音 | 0.1 / 字符 | |
| 口型同步 | 30 | 单镜头 |
**补充充值**: ¥1 = 10 积分
## 5. 核心差异化(vs 三个开源项目)
| 维度 | Toonflow | AIComicBuilder | waoowaoo | **我们** |
|------|----------|----------------|----------|----------|
| 部署形态 | Electron 桌面 | Web 本地 | Web 本地 | **云 SaaS + 私有化可选** |
| 多租户 | ❌ | ❌ | ⭐ 基础 | ⭐⭐⭐ 企业级 |
| 计费 | ❌ | ❌ | ⭐ 账户冻结 | ⭐⭐⭐ 完整订阅+按量 |
| Agent 架构 | ⭐⭐⭐ 三层 | ⭐ 单层 | ⭐⭐ LangGraph | **⭐⭐⭐ 三层 + 可观测** |
| 角色一致性 | ⭐⭐ | ⭐⭐⭐ 双轨制 | ⭐⭐ | **⭐⭐⭐ 四视图+向量检索** |
| 视频模型 | Sora/豆包 | Seedance/Veo/Kling | FAL/百炼 | **通义万相+可插拔多模型** |
| 中文优化 | ⭐⭐ | ⭐ | ⭐⭐ | **⭐⭐⭐ 原生中文短剧** |
| 团队协作 | ❌ | ❌ | ⭐ | **⭐⭐⭐ 角色/权限/资产库** |
| 资产复用库 | ⭐ | ⭐ | ⭐⭐ Global Assets | **⭐⭐⭐ 个人库+团队库+市场** |
## 6. 关键产品假设
1. **短剧市场足够大**: 2025 年国内短剧市场 500 亿+,AI 化是大势。
2. **创作者愿意为速度付费**: 人工制作 1 集需 3~7 天,AI 只需 1~2 小时,时间价值远超积分费。
3. **质量门槛可接受**: AI 生成质量目前足以应对 60% 的场景(对话、转场、中景),人工补 40% 特写/动作戏。
4. **资产复用是 retention 核心**: 用户做第二个项目时,角色库/场景库复用率 > 50%,形成粘性。
## 7. 不做的事(范围边界)
- ❌ 不做真人视频换脸、不做成人内容(合规风险)
- ❌ 不做实时互动直播(技术栈不同)
- ❌ 不做专业级后期特效(让用户导出到 AE/PR)
- ❌ 第一版不做移动端 App(Web 响应式优先)
# 02 · 前端功能与页面清单
# 02 · 前端功能与页面清单
> 基于 `doc/html` 原型(Vite + React 18 + Shadcn + React Router 7)整理。后端需对齐这里定义的全部功能和数据模型。
## 1. 技术栈速览(前端原型)
- Vite 6 + React 18 + TypeScript + React Router 7
- Tailwind 4 + Shadcn (Radix) + MUI 7(部分)
- Recharts 2(图表)、react-dnd(拖拽)、react-hook-form、sonner
- 状态管理: 纯 React Hooks(生产需引入 Zustand / TanStack Query)
- 当前无真实后端调用,均为 mock 数据
## 2. 路由表
```
/ Dashboard(项目看板)
/new-project 新建项目(上传剧本)
/team 企业级团队管理
/usage 使用统计/积分管理
/models 模型配置(语言/图片/视频)
/settings AI 水印设置(全局)
/assets/* 资产库(角色/场景/道具)
/project/:projectId 项目详情
/style 风格选择
/outline 大纲生成
/episodes 分集生成
/characters 角色生成
/scenes 场景生成
/props 道具生成
/storyboard/:episodeId? 分镜工作台
/video 视频生成与预览
/settings 项目成员与权限
```
## 3. 页面功能详表
### 3.1 Dashboard `/`
- 项目网格(3 列)、统计卡片(总项目/进行中/成员/总集数)
- 搜索:名称模糊 + 日期范围 + 团队成员多选
- 项目卡片: 封面、标题、描述、3 个标签、集数、成员数、更新时间、进度条、状态(outline/storyboard/video)
- **API**: `GET /api/projects`(分页+筛选)、`GET /api/projects/stats`
### 3.2 NewProject `/new-project`
- 拖拽上传 txt/doc/docx/pdf/md(≤20MB)
- 三选项卡: 纵横比(16:9 / 9:16)、分辨率(720p/1080p)、风格(2D日漫等)
- **API**: `POST /api/projects` (multipart)、`POST /api/projects/{id}/parse-script`
### 3.3 StyleSelection `/project/:id/style`
- 6 种预设风格卡片(都市情感/玄幻修仙/甜宠恋爱/古装宫廷/悬疑推理/轻喜剧)
- **API**: `GET /api/styles``PATCH /api/projects/{id}` (style 字段)
### 3.4 OutlineGeneration `/project/:id/outline`
- 显示剧名、类型、梗概、核心主题、4 个故事篇章
- 支持编辑大纲
- **API**: `POST /api/projects/{id}/outline:generate` (异步 jobId)、`GET /api/projects/{id}/outline``PUT /api/projects/{id}/outline`
### 3.5 EpisodeGeneration `/project/:id/episodes`
- 分集网格(5 列),每集标题/时长/梗概
- **API**: `POST /api/projects/{id}/episodes:generate``GET /api/projects/{id}/episodes`
### 3.6 CharacterGeneration `/project/:id/characters`
- 角色 Tab + 场景 Tab + 道具 Tab 切换
- 角色卡片: 头像、类型(女/男主/反派/配角)、性别、年龄、人格、背景、配音音色
- 正面大头照 + 全身三视图拼图
- 服装变体: 同一角色不同服装形态作为独立角色变体生成(如"张三-校服"、"张三-西装"),每个变体有独立四视图
- 操作: 编辑/删除/上传参考图/AI 生成形象/新增/创建服装变体
- **API**: CRUD `/api/projects/{id}/characters` + `POST /api/characters/{cid}/images:generate`
### 3.7 SceneGeneration `/project/:id/scenes`
- 场景卡: 名称、类型(室内/室外)、描述、使用场景、图像
- **API**: CRUD `/api/projects/{id}/scenes` + `POST /api/scenes/{sid}/image:generate`
### 3.8 PropsGeneration `/project/:id/props`
- 道具卡: 名称、分类、描述、使用场景列表、图像
- **API**: CRUD `/api/projects/{id}/props` + `POST /api/props/{pid}/image:generate`
### 3.9 StoryboardWorkspace `/project/:id/storyboard/:episodeId?` ⭐ 核心
- 左侧: 分集选择器(可折叠)
- 中间: 分镜详情编辑
- 分镜信息区:
- 分镜描述(文本)
- 出镜角色(可增删更换,每个角色可选择不同音色)
- 分镜场景(可更换)
- 道具(可增删更换)
- 视频生成区:
- 单一提示词编辑框: 镜头调度、灯光、画风、镜头描述等所有生成指令统一在一个文本框内编写,无其他分散编辑项
- 智能体选择器: 可选择不同的 AI 智能体(如通义万相、Seedance、Kling 等)来生成视频
- 积分预估: 根据所选智能体和视频时长,实时计算预计消耗积分
- 说明: 无独立服装选项,角色的不同服装形态在角色生成阶段作为独立角色变体创建
- 右侧: 预览区(视频/首帧图)
- 底部工具栏: AI 检查、批量操作、生成视频
- 操作: 新增/复制/删除/展开折叠/拖拽排序
- **API**: CRUD `/api/projects/{id}/episodes/{eid}/storyboards`, `PUT .../reorder`, `POST /api/storyboards/{sid}/video:generate`, `POST /api/storyboards/batch:generate`, `POST /api/storyboards/{sid}/ai-check`
### 3.10 VideoGeneration `/project/:id/video`
- 左侧分集列表
- 中间视频播放器 + 分镜时间线
- 右侧分镜缩略图网格(状态: generating/completed/failed/queued/not_started)
- 进度统计
- **API**: `GET /api/projects/{id}/episodes/{eid}/segments``GET /api/storyboards/{sid}/video-status`(SSE 推荐)
### 3.11 ProjectDetail `/project/:id`
- 顶部: 封面、标题、标签、集数、屏幕方向、纵横比、分辨率、小组
- 底部悬浮工具栏: AI 模型选择、积分余额、快速生成按钮
- Tab: 剧本大纲 / 人物小传 / 分集列表
- **API**: `GET /api/projects/{id}` + `GET /api/projects/{id}/full-data`
### 3.12 ProjectSettings `/project/:id/settings`
- 项目团队成员(owner/admin/editor/viewer 四级角色)
- 邀请成员、改角色、移除
- **API**: `GET/POST/PUT/DELETE /api/projects/{id}/members``POST /api/projects/{id}/invitations`
### 3.13 TeamManagement `/team`(企业级)
- 员工 Tab: 列表、编辑、删除、批量导入、邀请
- 小组 Tab: 仙侠/都市/日漫/古装/悬疑 5 个预设,卡片显示成员数和权限
- **API**: `/api/admin/employees``/api/admin/groups`
### 3.14 UsageManagement `/usage`
- 积分卡片(总/已用/剩余/子账户)
- 30 天趋势折线图(实际+预测)
- 使用记录表(类型/项目/用户/小组/积分/时间),支持筛选
- **API**: `/api/usage/stats``/api/usage/daily``/api/usage/records`
### 3.15 AssetsManagement `/assets/*`
- 个人库 / 团队库切换
- 类型筛选(角色/场景/道具)
- 搜索、标签、创建者、风格筛选
- 卡片: 缩略图、名称、标签、创建者、收藏、操作菜单
- 快速复用到当前项目
- **API**: `/api/assets` CRUD、`POST /api/assets/{aid}/clone`
### 3.16 ModelSettings `/models`
- 默认模型选择器: 语言模型 / 图片模型 / 视频模型 三栏独立配置默认模型
- 供应商管理: 按能力类型(语言/图片/视频)分 Tab 显示已配置供应商
- 供应商卡片: 名称、协议标签、Base URL、API Key 状态、已启用模型数
- 模型列表: 每个供应商下展示可用模型,支持搜索、勾选启用
- 添加/编辑供应商弹窗: 名称、协议(OpenAI兼容/DashScope/Gemini/Seedance/Kling)、Base URL、API Key、Secret Key(可灵AK/SK)、手动添加模型 ID / 自动获取模型列表
- 预置供应商: 通义千问(LLM)、通义万相(图片)、OpenAI、Gemini、Seedance(视频)、可灵(视频)
- **API**: `GET/PUT /api/settings/models`, `POST /api/settings/models/providers`, `GET /api/settings/models/fetch`
### 3.17 AIWatermarkSettings `/settings`
- 启用/禁用、文字、位置(5 选)、大小(3 档)、透明度滑块、Logo 上传
- **API**: `GET/PUT /api/settings/watermark`
## 4. 全量数据模型(前端视角)
详见 [06-数据库设计](./06-数据库设计.md),这里仅列核心实体:
```
User / Tenant / Organization
└─ Project (title, description, style, aspectRatio, resolution, status, progress)
├─ Outline (summary, themes[], arcs[])
├─ Episode[] (number, title, description, duration)
│ └─ Storyboard[] (sceneNumber, detailedDescription, location, dialogue[], camera, duration, status, creditCost)
│ └─ ShotAsset[] (type: first_frame|last_frame|video|audio, fileUrl, version)
├─ Character[] (name, role, gender, personality, costume, voice, images{portrait, fullBody, reference})
│ └─ CharacterAppearance[] (version)
├─ Scene[] (name, type, description, image)
├─ Prop[] (name, category, description, scenes[], image)
├─ Member[] (userId, role[owner|admin|editor|viewer])
└─ WatermarkSettings
Asset (shared library, type=char|scene|prop, library=personal|team, tags[])
UsageRecord (type, projectId, userId, credits, timestamp)
BalanceTransaction (type, amount, balanceAfter, taskId)
GenerationTask (type, status, progress, result, errorMessage)
Notification (userId, type, title, read)
```
## 5. 交互与状态管理建议(给前端重构用)
- 引入 **Zustand** 管理 UI 状态(抽屉、选中分镜、上传进度)
- 引入 **TanStack Query** 管理服务器状态(缓存、失效、乐观更新)
- WebSocket/SSE 推送 AI 任务进度,避免轮询
- 分镜工作台使用虚拟列表(react-window)应对 500+ 分镜
- 大文件上传: 分片 + 断点续传(tus-js-client)
# 03 · 三开源项目对比与吸收
# 03 · 三开源项目对比与吸收
## 1. 三个项目一句话画像
| 项目 | 路径 | 定位 | 最大亮点 |
|------|------|------|----------|
| **Toonflow-app** | `Toonflow-app/` | Electron 桌面 AI 短剧工程 | **三层 Agent 协作 + Skill 外化 + 可编程供应商系统** |
| **AIComicBuilder** | `aibuildvideo/AIComicBuilder/` | Next.js 漫剧 Web 全栈 | **角色一致性双轨制 + 12 个提示词插槽化管理 + 首尾帧+视频插值** |
| **waoowaoo** | `waoowaoo/` | Next.js 企业级 AI 视频 SaaS | **BullMQ 任务队列 + 余额冻结计费 + 多租户 + 国际化 + 代码卫士** |
## 2. 功能矩阵
| 维度 | Toonflow | AIComicBuilder | waoowaoo | 吸收到我们项目 |
|------|----------|----------------|----------|----------------|
| 剧本解析 | ✓ 事件图谱驱动 | ✓ 四步导入流水线 | ✓ | **四步导入 + 事件提取**(AICB+Toonflow) |
| 自动分集 | ✓ 前 10 集黄金结构 | ✓ | ✗ | **采用 Toonflow 的付费卡点策略**(10/30/50/70/90%) |
| 角色四视图 | ✗ | ✓ 正面/3/4侧/侧/背 | ✗ | **必做,AICB 方案** |
| 角色参考图注入 | ✓ | ✓ Gemini 参考图 | ✓ | **必做,用通义万相参考图模式** |
| 角色关系图 | ✗ | ✓ characterRelations 表 | ✗ | **必做,影响构图和视线** |
| 分镜智能规划 | ✓ 导演 Agent | ✓ 非常细致 | ✓ | **合并 Toonflow 导演手册 + AICB shot-split** |
| 首尾帧+视频插值 | 部分 | ✓ 主力模式 | ✗ | **主力模式采用** |
| 多模态参考模式 | ✓ | ✓ Veo 3.1 | ✗ | **Pro 用户可选** |
| 三层 Agent | ✓⭐⭐⭐ | ✗ | LangGraph | **采用 Toonflow 三层架构** |
| Agent 记忆 | ✓ 向量+摘要 | ✗ | ✗ | **用 Redis Stack (RediSearch) 实现** |
| Skill 文件外化 | ✓ 159 个 .md | ✓ 12 个提示词 | ✗ | **采用 Markdown Skill 库 + 插槽化编辑** |
| 提示词版本管理 | ✗ | ✓ 完整 | ✗ | **采用 AICB 方案** |
| 可编程供应商 | ✓ VM 沙盒 | ✗ | ✓ 配置化 | **用 Spring 策略模式 + SPI** |
| 资产版本 | ✗ | ✓ shot_assets 表 | ✓ MediaObject 哈希去重 | **合并: 版本表 + 哈希去重** |
| 任务队列 | Socket.IO 简单 | 自研 | ✓ BullMQ + 4 队列 | **采用 RabbitMQ/Kafka + 队列分型** |
| 任务心跳/重试 | 部分 | ✗ | ✓ 完整 | **必做** |
| 多租户 | ✗ | ✗ | ✓ userId 隔离 | **强化: tenantId + Spring Security** |
| 计费系统 | ✗ | ✗ | ✓⭐⭐⭐ 余额冻结 | **直接采用 waoowaoo 方案** |
| 幂等性 | ✗ | ✗ | ✓ idempotencyKey | **必做** |
| 审计日志 | ✗ | ✗ | ✓ 结构化+脱敏 | **必做** |
| 国际化 | ✓ 7 种 | 4 种 | 2 种 | **中英双语起步** |
| 代码卫士 | ✗ | ✗ | ✓ 10+ 守护脚本 | **必做** |
| 测试分层 | ✗ | 部分 | ✓ 单元/集成/系统/回归 | **必做** |
| 可观测性 | ✗ | ✗ | ✓ Bull Board | **Prometheus + Grafana + Spring Actuator** |
| FFmpeg 合成 | ✓ | ✓ 字幕/转场/BGM | ✓ Remotion | **采用 AICB FFmpeg 方案** |
| TTS | 框架 | 未集成 | ✓ FAL/百炼 | **必做: 百炼 Qwen TTS** |
| 口型同步 | ✗ | ✗ | ✓ | **Pro 功能** |
## 3. 各项目核心亮点详述
### 3.1 Toonflow 亮点(架构启发)
**三层 Agent 协作体系** — 这是 Toonflow 最大的技术遗产,值得完整移植:
```
决策层 Agent
├─ 理解用户意图
├─ 任务拆解
├─ 调度执行/监督层
└─ 质量把关(最终通过/驳回)
执行层 SubAgent (6 个)
├─ 故事骨架搭建(三幕 + 付费卡点)
├─ 改编策略制定
├─ 剧本编写
├─ 衍生资产分析
├─ 导演规划
└─ 分镜生成
监督层 Agent
└─ 审核执行层产出,反馈修订
```
**Skill 文件外化**: 159 个 Markdown 文件分别存储提示词、艺术手册、导演约束,运行时动态加载,改动无需重启。
**艺术风格手册**: `art_skills/2D_90s_japanese_anime/` 下有完整的角色/场景/道具约束文档,包含鹅蛋脸、6-6.5 头身、樱桃唇等具体描述 —— 直接复用!
**跨会话记忆**: 基于 ONNX all-MiniLM 做向量化,按项目 ID 隔离,支持 RAG 召回。我们改用 Redis Stack(RediSearch,HNSW + COSINE)+ 通义 embedding,Spring AI `RedisVectorStore` 直连,未来超过 64GB 再迁 Milvus(接口零改动)。
### 3.2 AIComicBuilder 亮点(工程细节)
**角色一致性双轨制**:
1. 视觉轨道: 四视图参考图 → 每次生成首尾帧时作为 reference image 注入
2. 文字轨道: 分镜拆分时强制使用完整角色名,禁止缩写和代词
**12 个提示词 + 插槽化编辑**:
- script_generate / script_parse / script_split
- character_extract / import_character_extract / character_image
- shot_split
- frame_generate_first / frame_generate_last / scene_frame_generate
- video_generate / ref_video_prompt_generate
三层优先级: **项目级覆盖 > 全局级覆盖 > 代码默认值**,且支持**版本历史+一键回滚**
**shot_assets 统一资产表**: 一张表存 first_frame/last_frame/reference/keyframe_video/reference_video 五种类型,配合 assetVersion + isActive 实现版本管理,这个设计直接搬过来。
**四步剧本导入**: 文本抽取 → 角色提取(AI 去重) → 智能分集 → 一键创建,每步可视化、可修改、可回退。
### 3.3 waoowaoo 亮点(SaaS 基础设施)
**余额冻结机制**(核心借鉴):
```
Task 提交 → prepareTaskBilling → 估算成本 → 冻结金额(BalanceFreeze)
Task 执行 → withBilling 包装 → 收集实际用量
Task 完成 → settleTaskBilling → 解冻 + 扣费 + 写 BalanceTransaction
Task 失败 → rollbackTaskBilling → 解冻,不扣费
```
- **原子性**: 冻结即锁定余额,防止超支
- **幂等性**: idempotencyKey 防重复扣费
- **可追溯**: 每笔扣费都有 Transaction 流水
- **自动清理**: 过期冻结自动释放
**MediaObject 统一媒体管理**: 单一媒体表 + sha256 内容哈希 + 40+ 种关系,DRY 原则。
**代码卫士(Guards)**:
- `check:api-handler` — API 签名规范
- `check:prompt-i18n` — 提示词本地化
- 禁止 LLM 直接写在 API(必须走 worker)
- 禁止模型配置硬编码
**测试分层**: 单元 + 集成 + 系统 + 回归 + 并发 + 合约,CI 守护质量。
## 4. 我们的吸收策略
### 4.1 直接搬运(少改)
- AIComicBuilder 的 `shot_assets` 表设计
- AIComicBuilder 的 12 个提示词分类 + 插槽化编辑 + 版本历史
- AIComicBuilder 的四步剧本导入 UX
- AIComicBuilder 的角色四视图生成提示词
- waoowaoo 的 `UserBalance` / `BalanceFreeze` / `BalanceTransaction` 三表结构
- waoowaoo 的 `MediaObject` 统一媒体表
- Toonflow 的 `art_skills/` 艺术手册全套 Markdown 文件
- Toonflow 的故事骨架提示词(三幕 + 付费卡点)
### 4.2 架构移植(Node → Java)
- Toonflow 三层 Agent → Spring AI Alibaba + Tool Use + 多 Chain
- Toonflow 供应商 VM 系统 → Java SPI + Strategy Pattern + `@Component`
- Toonflow Agent Memory → Redis Stack(RediSearch)+ 通义 embedding + Spring AI `RedisVectorStore`
- waoowaoo BullMQ → RabbitMQ/Kafka + Spring AMQP + 多队列(image/video/voice/text)
- waoowaoo NextAuth → Sa-Token(Redis 会话)+ Sa-Token-OAuth2
- waoowaoo Prisma → MyBatis-Plus + Flyway
- waoowaoo next-intl → Spring MessageSource + i18n
### 4.3 自研强化
- **多租户**: 强化到 tenantId + RLS(行级安全)或租户库分库
- **订阅支付**: 集成支付宝/微信/Stripe,waoowaoo 只做了按量
- **权限模型**: RBAC + ABAC,细到分镜级别
- **可观测性**: Prometheus + Grafana + SkyWalking 链路追踪
- **资产市场**: 社区分享+付费购买资产(差异化功能)
## 5. 明确不用的设计
- ❌ Toonflow 的 SQLite — 直接上 MySQL
- ❌ Toonflow 的 Electron 打包 — 我们是 Web SaaS
- ❌ Toonflow 的 VM2 供应商系统 — 安全隐患,改 Java SPI
- ❌ waoowaoo 的 Remotion — 用 FFmpeg 够了,Remotion 是 React 依赖
- ❌ AIComicBuilder 的 SQLite + Drizzle — 同上
- ❌ 三个项目的单机部署 — 我们要分布式
# 04 · 核心业务流程与 AI 流水线
# 04 · 核心业务流程与 AI 流水线
## 1. 端到端业务流程
```
[用户注册/登录]
[创建项目] ─ 上传剧本 + 选择纵横比/分辨率/风格
[剧本解析] ─ 文本抽取 → 角色提取 → 关系推理 → 智能分集
[风格确认] ─ 选择艺术风格手册(2D日漫/国风/3D/水墨...)
[大纲生成] ─ 三幕结构 + 付费卡点 + 篇章划分
[资产建设] ─ 角色四视图 / 场景图 / 道具图(可并行)
[分镜生成] ─ 按集拆分 → 每集 N 个 shot(首尾帧描述 + 对白 + 相机指令)
[首尾帧生成] ─ 注入角色参考图 + 风格约束 + 色板约束
[视频生成] ─ 首尾帧插值或参考图模式(通义万相/可插拔)
[TTS 配音] ─ 按角色音色生成对白音频
[口型同步] ─ 可选(Pro 功能)
[视频合成] ─ FFmpeg 拼接 + 字幕烧录 + BGM 混音 + 转场
[导出/发布] ─ MP4 下载 / 水印处理 / 团队审核
```
## 2. AI 流水线分层
### Stage 1 · 剧本解析流水线
| 步骤 | 输入 | 模型 | 输出 | 存储 |
|------|------|------|------|------|
| 1.1 文本抽取 | 上传文件 | Apache POI / PDFBox | 纯文本 | `projects.script_text` |
| 1.2 事件提取 | 纯文本 | Qwen-Max | 章节 + 事件表 | `novel_events` |
| 1.3 角色提取 | 纯文本 + 事件 | Qwen-Max | 角色卡列表(含去重) | `characters` |
| 1.4 关系推理 | 角色 + 剧本 | Qwen-Max | 关系图 | `character_relations` |
| 1.5 智能分集 | 事件 + 目标集数 | Qwen-Max | 分集方案(三幕 + 卡点) | `episodes` |
**提示词来源**: 借鉴 AIComicBuilder 的 `import_character_extract` + Toonflow 的 `script_execution_skeleton.md`
### Stage 2 · 资产生成流水线
| 步骤 | 输入 | 模型 | 输出 | 特殊约束 |
|------|------|------|------|---------|
| 2.1 角色四视图 | 角色描述 + 风格手册 | 通义万相 | 4 张参考图(正/3/4侧/侧/背) | 单次生成 16:9 画布 |
| 2.2 衍生资产 | 剧本分析 | Qwen-Max | 服装变体/状态变体列表 | 仅状态变化才衍生 |
| 2.3 衍生资产图 | 父资产图 + 变体提示 | 通义万相 | 变体图 | 参考父资产 |
| 2.4 场景图 | 场景描述 + 色板 | 通义万相 | 场景底图 | 支持室内/室外 |
| 2.5 道具图 | 道具描述 | 通义万相 | 道具素材 | 可选 |
**并发**: 10 张/批,RabbitMQ `image.generate` 队列。
### Stage 3 · 分镜生成流水线
| 步骤 | 输入 | 模型 | 输出 |
|------|------|------|------|
| 3.1 导演规划 | 剧本 + 角色 + 关系图 + 风格 | Qwen-Max | 拍摄计划(景别/机位/运动) |
| 3.2 场景分组 | 剧本 + 导演计划 | Qwen-Max | 场景分组 + 每场镜头数 |
| 3.3 镜头拆分 | 场景 + 时长目标 | Qwen-Max | shot 列表(见下) |
| 3.4 监督审核 | shot 列表 | Qwen-Max | 审核报告 + 修订建议 |
**Shot 字段** (借鉴 AIComicBuilder shot-split):
```json
{
"sequence": 1,
"prompt": "张三从树后走出,警惕扫视",
"startFrame": "张三正面从树后探身,左手扶剑柄,夕阳斜照",
"endFrame": "张三走到空地中心,目光锁定前方",
"motionScript": "0-2s: 右肩先露出... 2-4s: 身体侧转...",
"videoScript": "精炼 40-70 字散文提示词",
"duration": 8,
"cameraDirection": "slow zoom in",
"compositionGuide": "rule_of_thirds",
"focalPoint": "张三面部",
"depthOfField": "shallow",
"dialogues": [{"character": "张三", "text": "谁在那里?"}],
"characters": ["张三"],
"transitionIn": "cut",
"transitionOut": "dissolve",
"soundDesign": "夜风、远处蝉鸣",
"musicCue": "紧张低弦乐"
}
```
### Stage 4 · 视觉生成流水线
```
for shot in episode.shots:
first_frame_prompt = build_first_frame(shot, characters, style)
first_frame = call_image_model(
prompt=first_frame_prompt,
reference_images=[char.turnaround_image for char in shot.characters],
size=project.resolution,
ratio=project.aspect_ratio
)
save_asset(shot_id, type='first_frame', url=first_frame, version=v+1)
last_frame_prompt = build_last_frame(shot, first_frame)
last_frame = call_image_model(
prompt=last_frame_prompt,
reference_images=[first_frame] + [char.turnaround]
)
save_asset(shot_id, type='last_frame', ...)
```
**模型选择**(可插拔):
| 模型 | 输入模式 | 质量 | 速度 | 成本 |
|------|---------|------|------|------|
| 通义万相 wanx2.1 | text + ref | ⭐⭐⭐ | 快 | 低 |
| Seedance 2.0 | text + ref + audio | ⭐⭐⭐⭐ | 中 | 中 |
| Veo 3.1 | text + 3 ref | ⭐⭐⭐⭐⭐ | 慢 | 高 |
| Kling v3 | text + ref | ⭐⭐⭐ | 中 | 中 |
### Stage 5 · 视频生成流水线
```
for shot in episode.shots:
video_task = submit_video_task(
model=project.video_model,
first_frame=shot.first_frame_url,
last_frame=shot.last_frame_url,
prompt=shot.videoScript,
duration=shot.duration,
resolution=project.resolution
)
# 异步轮询 5s x 120 次
video_url = poll_until_done(video_task.external_id)
save_asset(shot_id, type='keyframe_video', ...)
```
### Stage 6 · 音频流水线
```
for voice_line in episode.dialogues:
audio = qwen_tts(
text=voice_line.text,
voice=character.voice_id,
emotion=voice_line.emotion,
rate=voice_line.speed
)
save_asset(voice_line_id, type='audio', ...)
```
### Stage 7 · 合成流水线 (FFmpeg)
```
1. 按 shot.sequence 排序所有 keyframe_video
2. xfade 转场拼接(cut/dissolve/fade,默认 0.5s)
3. 生成 SRT 字幕(基于 dialogues 时间比例)
4. 字幕硬编码烧录
5. 混入对白音频(按时间戳)
6. 混入 BGM(volume=0.3)
7. 添加片头标题卡(3s) + 片尾(2s)
8. 输出 /projects/{id}/episodes/{eid}/final.mp4
```
## 3. 三层 Agent 协作(借鉴 Toonflow)
### 3.1 ScriptAgent(剧本 Agent)
```
用户输入 / 剧本上传
[决策层 DecisionAgent]
解析意图 → 拆解任务 → 调度执行/监督
[执行层] 串行调用:
SkeletonExecutor (故事骨架)
↓ 输出到工作区
[监督层 SupervisionAgent] 审核 → 通过/驳回/重做
AdaptationExecutor (改编策略)
[监督层] 审核
ScriptExecutor (剧本编写)
[监督层] 审核
[决策层] 汇总 → 返回用户
```
### 3.2 ProductionAgent(制作 Agent)
```
[决策层]
DeriveAssetsExecutor (衍生资产分析)
GenerateAssetsExecutor (资产图生成, 异步)
DirectorPlanExecutor (导演规划)
↓ [监督]
StoryboardTableExecutor (分镜表)
↓ [监督]
StoryboardPanelExecutor (分镜面板写入)
StoryboardImageExecutor (首尾帧生成, 异步并发)
VideoExecutor (视频生成, 异步并发 + 轮询)
AssemblyExecutor (FFmpeg 合成)
```
### 3.3 实现要点
- 决策层用 Spring AI Alibaba `ChatClient` + `@Tool` 定义子 Agent 为可调用工具
- 执行层每个 Executor 是独立 `@Service`,提示词从 `skills/` 加载
- 监督层可选开启(Free 套餐关闭以省成本)
- 跨 Agent 上下文通过 Redis Stack 向量检索(RediSearch)+ 短期摘要
## 4. 并发与异步策略
| 队列 | 并发 | 超时 | 重试 |
|------|------|------|------|
| `text.generate` | 50 | 60s | 3 次 |
| `image.generate` | 20 | 120s | 3 次 |
| `video.generate` | 10 | 900s | 2 次(昂贵) |
| `voice.tts` | 30 | 30s | 3 次 |
| `video.assemble` | 5 | 1800s | 1 次 |
| `lipsync` | 5 | 600s | 2 次 |
所有任务**必须**通过 RabbitMQ,**禁止**直接在 HTTP 线程里调 AI(借鉴 waoowaoo 代码卫士)。
## 5. 进度推送
**Server-Sent Events (SSE)** 主推送:
```
GET /api/projects/{id}/events (text/event-stream)
event: task.progress
data: {"taskId":"...","type":"video","progress":45,"currentShot":12,"totalShots":50}
event: task.completed
data: {"taskId":"...","resultUrl":"..."}
event: task.failed
data: {"taskId":"...","error":"..."}
```
WebSocket 留给后续实时协作(多人同时编辑分镜)。
# 05 · 后端架构设计 (Java + Spring AI Alibaba)
# 05 · 后端架构设计 (Java + Spring AI Alibaba)
## 1. 技术栈
| 层级 | 组件 | 版本 | 说明 |
|------|------|------|------|
| 语言 | Java | 17 LTS | |
| 框架 | Spring Boot | 3.3.x | |
| AI | **Spring AI Alibaba** | 1.0.0+ | 通义千问/万相/百炼原生集成 |
| ORM | **MyBatis-Plus** | 3.5.x | 内置分页/多租户/乐观锁/逻辑删除插件 |
| 迁移 | Flyway | 10.x | |
| DB | MySQL | 8.0 | 主存储 |
| 缓存 + 向量 | **Redis Stack** | 7.2+ | Spring Cache + 分布式锁 + RediSearch 向量检索(Agent 记忆) |
| MQ | RabbitMQ | 3.13 | 任务队列 |
| 对象存储 | **阿里云 OSS / 火山引擎 TOS**(生产) | - | 媒体资源,签名 URL 给第三方 AI 拉取参考图 |
| 对象存储(备选) | MinIO | - | 本地开发 / 私有化部署,S3 兼容 |
| 搜索 | ~~Elasticsearch~~(M8 后按需引入) | - | MVP 阶段用 MySQL FULLTEXT 顶住 |
| 安全 | Sa-Token (Redis 会话) | 1.39+ | 登录/权限/踢人/多账号体系开箱即用,未来可切 JWT 风格 |
| API | Spring WebFlux + SSE | 6.x | 异步推送 |
| 监控 | Actuator + Prometheus + Grafana | - | |
| 追踪 | SkyWalking / OpenTelemetry | - | |
| 容器 | Docker + Docker Compose | - | 开发态 |
| 编排 | Kubernetes | 1.28+ | 生产态(可选) |
## 2. 模块划分(多模块 Maven 项目)
```
yaoai-comic-studio/
├── yaoai-common/ # 通用工具、异常、常量、DTO 基类
├── yaoai-domain/ # 领域模型(MyBatis-Plus Entity + Mapper + Service)
├── yaoai-security/ # 认证授权(Sa-Token + Redis 会话 + 多租户 Filter)
├── yaoai-billing/ # 计费: 余额冻结、交易流水、订阅、套餐
├── yaoai-storage/ # 对象存储抽象(StorageProvider 接口: OSS / TOS / MinIO / S3)
├── yaoai-ai-core/ # AI 抽象层(Provider 接口、模型路由、Skill 加载)
├── yaoai-ai-providers/ # 具体 Provider 实现(通义/豆包/Kling/OpenAI/Gemini)
├── yaoai-agent/ # 三层 Agent 体系(Script/Production/Decision/Executor/Supervisor)
├── yaoai-pipeline/ # 业务流水线(剧本解析/资产生成/分镜/视频/合成)
├── yaoai-task/ # 任务系统(RabbitMQ Worker + 状态机 + 重试 + 心跳)
├── yaoai-media/ # FFmpeg 合成、字幕、转场、BGM
├── yaoai-api/ # REST Controller + SSE + OpenAPI
├── yaoai-admin/ # 管理后台 API(员工/小组/用量/审计)
├── yaoai-worker/ # 可独立部署的 Worker 进程
├── yaoai-bootstrap/ # 主启动模块(组合 api+worker 或分开启动)
└── yaoai-test/ # 集成测试、系统测试、Testcontainers
```
**部署形态**:
- **api-server**: 只跑 `yaoai-api` + `yaoai-admin`(无状态,水平扩展)
- **worker**: 只跑 `yaoai-worker`(按队列类型分组,image/video/text/voice 独立扩缩)
- **scheduler**: 单实例跑定时任务(冻结清理、统计聚合)
## 3. 分层架构
```
┌──────────────────────────────────────────┐
│ API Layer (Controller + DTO + OpenAPI) │
├──────────────────────────────────────────┤
│ Application Layer (Service + UseCase) │
│ - 事务边界、权限校验、租户注入 │
├──────────────────────────────────────────┤
│ Domain Layer │
│ - Entity / VO / Mapper / IService │
│ - DomainEvent (Spring Event) │
├──────────────────────────────────────────┤
│ Infrastructure Layer │
│ - AI Provider / Storage / MQ / Redis │
│ - ExternalAPI(支付/短信/OAuth) │
└──────────────────────────────────────────┘
```
## 4. 核心组件设计
### 4.1 AI Provider 抽象
```java
public interface AiProvider {
String getId();
Set<Capability> getCapabilities(); // TEXT, IMAGE, VIDEO, TTS, EMBEDDING
List<ModelInfo> listModels();
}
public interface TextProvider extends AiProvider {
Flux<String> streamChat(ChatRequest req);
ChatResponse chat(ChatRequest req);
}
public interface ImageProvider extends AiProvider {
ImageResult generate(ImageRequest req); // 支持参考图 List<ReferenceImage>
}
public interface VideoProvider extends AiProvider {
VideoTask submit(VideoRequest req); // 异步提交
VideoTaskStatus poll(String externalId); // 轮询
}
public interface TtsProvider extends AiProvider {
AudioResult synthesize(TtsRequest req);
}
```
**实现**(Spring `@Component + @ConditionalOnProperty`):
- `QwenTextProvider` - 通义千问 Max/Plus/Turbo
- `WanxImageProvider` - 通义万相文生图(支持参考图)
- `WanxVideoProvider` - 通义万相视频
- `SeedanceProvider` - 豆包 Seedance
- `KlingProvider` - 快手 Kling
- `OpenAIProvider` - OpenAI 兼容(可配置 baseUrl)
**路由器** `AiModelRouter`: 根据项目配置 > 全局默认 > fallback 选择 Provider。
### 4.2 Skill(提示词)管理
借鉴 Toonflow Markdown 外化 + AIComicBuilder 插槽化:
```java
@Service
public class SkillLoader {
// skills/
// ├── script_agent_decision.md
// ├── script_execution_skeleton.md
// ├── art_skills/2d_anime/art_character.md
// └── ...
public String load(String key);
public String loadWithSlots(String key, Map<String, String> slots, Long tenantId, Long projectId);
// 优先级: project > tenant > global > default
}
@Data
@TableName("prompt_templates")
public class PromptTemplate {
@TableId(type = IdType.ASSIGN_ID)
Long id;
String promptKey; // script_generate, shot_split, character_image...
String slotKey; // role_definition, output_format... (nullable = full)
Scope scope; // GLOBAL / TENANT / PROJECT
Long tenantId;
Long projectId;
String content;
@Version
int version;
}
```
热更新: 文件变更监听 + Redis pub/sub 通知所有节点失效缓存。
### 4.3 三层 Agent 落地
```java
@Service
public class ScriptDecisionAgent {
@Autowired ChatClient chatClient;
@Autowired SkillLoader skills;
@Autowired AgentMemoryService memory;
@Tool("run_skeleton_executor")
public String runSkeleton(String projectId, String context) {
return skeletonExecutor.execute(projectId, context);
}
@Tool("run_adaptation_executor") ...
@Tool("run_script_executor") ...
public Flux<String> handle(String projectId, String userInput) {
String sys = skills.load("script_agent_decision.md");
MemoryContext ctx = memory.retrieve(projectId, userInput);
return chatClient.prompt()
.system(sys + ctx.asPromptSuffix())
.user(userInput)
.tools(this) // Spring AI Alibaba @Tool
.stream()
.content();
}
}
@Service
public class SkeletonExecutor {
public String execute(String projectId, String ctx) {
String prompt = skills.load("script_execution_skeleton.md");
// ...调用 chatClient,返回 <storySkeleton> XML
// 监督层审核
var review = supervisionAgent.review("skeleton", result);
if (!review.passed()) return execute(...); // 最多重试 N 次
return result;
}
}
```
### 4.4 Agent Memory (Redis Stack / RediSearch)
用 Redis Stack 的 RediSearch 模块做向量检索,和业务缓存共用 Redis 集群(**生产环境向量实例与缓存实例分开部署**,避免大向量拖慢热 key)。Spring AI 官方提供 `RedisVectorStore`,和 `VectorStore` 其他实现接口完全一致,未来要迁 Milvus 零改动。
```java
@Configuration
public class VectorStoreConfig {
@Bean
public VectorStore agentMemoryStore(
@Qualifier("vectorJedis") JedisPooled jedis,
EmbeddingModel embeddingModel) { // 通义 text-embedding-v3, dim=1024
return RedisVectorStore.builder(jedis, embeddingModel)
.indexName("agent_memory")
.prefix("memory:")
.metadataFields(
MetadataField.tag("project_id"),
MetadataField.tag("agent_type"),
MetadataField.tag("role"),
MetadataField.numeric("created_at")
)
.initializeSchema(true)
.build();
}
}
@Service
public class AgentMemoryService {
@Autowired VectorStore vectorStore;
public void add(String projectId, String role, String content) {
vectorStore.add(List.of(new Document(content, Map.of(
"project_id", projectId,
"agent_type", "script",
"role", role,
"created_at", System.currentTimeMillis()
))));
// 定期摘要压缩: 超过 100 条触发,旧消息合并成 summary 文档
}
public MemoryContext retrieve(String projectId, String query) {
var results = vectorStore.similaritySearch(
SearchRequest.query(query).withTopK(3)
.withFilterExpression("project_id == '" + projectId + "'")
);
return new MemoryContext(results, shortTerm, summaries);
}
}
```
**索引参数**: HNSW, `M=16, EF_CONSTRUCTION=200, DISTANCE_METRIC=COSINE`
**持久化**: AOF `everysec` + RDB 每小时,防重启丢记忆。
**规模上限**: 单节点 64GB 内存可容纳约 2~3 万个活跃项目的记忆,超出时再迁 Milvus(接口层零改动)。
### 4.5 对象存储抽象 (StorageProvider)
生产默认 **阿里云 OSS**(同厂商通义系列内网免流量),也可切火山引擎 TOS(如果主力视频模型选豆包 Seedance)。本地开发可选 MinIO(S3 兼容)。
```java
public interface StorageProvider {
String getProviderId();
void upload(String key, InputStream data, String contentType, long size);
InputStream download(String key);
/** 给第三方 AI 服务拉参考图用,TTL 覆盖任务最大执行时长 */
String getSignedUrl(String key, Duration ttl);
/** 给用户下载用,可强制附件下载 */
String getSignedDownloadUrl(String key, Duration ttl, String filename);
void delete(String key);
boolean exists(String key);
ObjectMeta stat(String key);
}
@Component
@ConditionalOnProperty(name = "yaoai.storage.provider", havingValue = "oss")
public class AliyunOssProvider implements StorageProvider { /* aliyun-sdk-oss */ }
@Component
@ConditionalOnProperty(name = "yaoai.storage.provider", havingValue = "tos")
public class VolcengineTosProvider implements StorageProvider { /* ve-tos-java-sdk */ }
// TODO: MinIO 备选实现,本地开发和私有化部署用
// @Component
// @ConditionalOnProperty(name = "yaoai.storage.provider", havingValue = "minio")
// public class MinioProvider implements StorageProvider { ... }
```
**Bucket 规划**:
```
yaoai-media-{env}/
├── scripts/{tenantId}/{projectId}/original.pdf # 剧本原文件(永远私有, TTL 5min)
├── characters/{projectId}/{charId}/turnaround-v{n}.png # 角色四视图
├── scenes/{projectId}/{sceneId}/v{n}.jpg
├── shots/{projectId}/{shotId}/first-v{n}.jpg # 首帧
├── shots/{projectId}/{shotId}/last-v{n}.jpg # 尾帧
├── shots/{projectId}/{shotId}/video-v{n}.mp4 # 分镜视频
├── audio/{projectId}/{lineId}/voice-v{n}.mp3 # TTS
└── finals/{projectId}/{episodeId}/final.mp4 # 成片
```
**ACL**: Bucket 默认 **private**,第三方拉图**必须**通过预签名 URL(TTL 30 分钟,覆盖视频任务 10~15 分钟执行窗口)。
**CDN**: 生产配自定义域名 `https://media.yaoai.com` + CDN 回源 OSS,签名参数透传,前端播放视频走 CDN 加速。
### 4.6 任务系统
```java
@Data
@TableName("tasks")
public class Task {
@TableId(type = IdType.ASSIGN_ID)
Long id;
String taskId; // ULID
Long tenantId;
Long userId;
Long projectId;
TaskType type; // SCRIPT_PARSE, IMAGE_GEN, VIDEO_GEN, TTS, ASSEMBLE...
TaskStatus status; // QUEUED, PROCESSING, COMPLETED, FAILED, CANCELLED
@TableField(typeHandler = JacksonTypeHandler.class)
String payload; // JSON
@TableField(typeHandler = JacksonTypeHandler.class)
String result; // JSON
String error;
int attempt;
int maxAttempts;
Instant heartbeatAt; // 心跳防假死
Instant createdAt;
Instant startedAt;
Instant finishedAt;
String externalId; // 第三方 AI 服务的任务 ID
String idempotencyKey;
Long freezeId; // 关联的计费冻结
}
public interface TaskHandler<P, R> {
TaskType supports();
R handle(Task task, P payload) throws Exception;
}
@Component
@RabbitListener(queues = "video.generate")
public class VideoGenerateConsumer {
public void onMessage(String taskId) {
Task task = taskService.markProcessing(taskId);
try {
BillingContext billing = billingService.prepare(task);
Result r = handler.handle(task, task.getPayload());
taskService.markCompleted(task, r);
billingService.settle(billing, r.actualCost);
} catch (Exception e) {
taskService.markFailed(task, e);
billingService.rollback(billing);
}
}
}
```
## 5. 安全与多租户
### 5.1 认证(Sa-Token)
- **Sa-Token 1.39+**(`sa-token-spring-boot3-starter` + `sa-token-redis-jackson`)
- Token 默认 `uuid` 风格存 Redis,会话/权限/踢人/封号全部开箱即用
- 多账号体系:`StpUtil`(用户端) / `StpAdminUtil`(运营后台),互不干扰
- 注解鉴权:`@SaCheckLogin` / `@SaCheckPermission("project:edit")` / `@SaCheckRole("admin")`
- 登录后在 Session 存 `tenantId` / `userId` / `roles`,`TenantContextFilter``StpUtil.getSession()`
- 第三方登录:Sa-Token-OAuth2 支持 Google / GitHub / 微信 / 钉钉
- 注册 IP 限流(Bucket4j + Redis),密码 BCrypt salt=12
- **未来开放 API 给第三方开发者** 时,引入 `sa-token-jwt` 模块切 JWT 风格,代码零改动
```java
// 登录
StpUtil.login(user.getId());
StpUtil.getSession().set("tenantId", user.getTenantId());
// 权限校验(走 StpInterface 注入数据库查询)
@SaCheckPermission("project:edit")
@PutMapping("/projects/{id}")
public ApiResponse<Project> update(...) { ... }
// 踢人下线(封号、订阅到期、风控触发)
StpUtil.kickout(userId);
```
### 5.2 多租户隔离(MyBatis-Plus TenantLineInnerInterceptor)
```java
// 从 Sa-Token Session 取 tenantId 存入 ThreadLocal
@Component
public class TenantContextFilter extends OncePerRequestFilter {
protected void doFilterInternal(...) {
if (StpUtil.isLogin()) {
Long tenantId = (Long) StpUtil.getSession().get("tenantId");
TenantContext.set(tenantId);
}
try { chain.doFilter(req, res); } finally { TenantContext.clear(); }
}
}
// MyBatis-Plus 插件一行搞定,所有 SQL 自动追加 WHERE tenant_id = ?
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 多租户插件(必须最先添加)
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(
new TenantLineHandler() {
@Override
public Expression getTenantId() {
return new LongValue(TenantContext.get());
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
// 无需租户隔离的表
return Set.of("global_assets", "prompt_presets").contains(tableName);
}
}
));
// 分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 乐观锁插件(计费扣减等场景)
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
// Entity 示例 — 无需任何 Filter 注解,插件自动处理
@Data
@TableName("projects")
public class Project {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId; // 插件自动追加到 WHERE 条件
private Long ownerUserId;
private String title;
// ...
@TableLogic
private Integer deleted; // 逻辑删除(0=未删, 1=已删)
}
// 跨租户操作(白名单场景): 临时忽略租户插件
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM global_assets WHERE library = 'MARKET'")
List<GlobalAsset> selectPublicAssets();
```
**三级隔离**:
1. Free/Pro/Team 共享 schema + `tenant_id` 列(MyBatis-Plus TenantLineInnerInterceptor 自动注入)
2. Enterprise 可选独立 schema(MyBatis-Plus `DynamicTableNameInnerInterceptor` + 动态数据源)
3. Private 部署整库独占
### 5.3 RBAC
角色 × 权限矩阵:
- `PROJECT:READ``PROJECT:WRITE``PROJECT:DELETE`
- `STORYBOARD:EDIT``STORYBOARD:GENERATE``STORYBOARD:APPROVE`
- `TEAM:INVITE``TEAM:REMOVE`
- `BILLING:VIEW``BILLING:RECHARGE`
- `ADMIN:*`
项目级角色: owner / admin / editor / viewer(直接采用前端原型的定义)。
## 6. 配置管理
```yaml
yaoai:
ai:
providers:
qwen:
api-key: ${QWEN_API_KEY}
base-url: https://dashscope.aliyuncs.com
wanx:
api-key: ${QWEN_API_KEY}
default-models:
text: qwen-max
image: wanx2.1-t2i-plus
video: wanx2.1-video
tts: qwen-tts
embedding: text-embedding-v3
billing:
mode: STRICT # OFF | SOFT | STRICT
free-credits: 100
storage:
provider: oss # oss | tos | minio(TODO) | s3
bucket: yaoai-media-prod
region: cn-hangzhou
endpoint: oss-cn-hangzhou.aliyuncs.com
access-key: ${STORAGE_AK}
secret-key: ${STORAGE_SK}
cdn-domain: https://media.yaoai.com # 可选,签名 URL 优先走 CDN
signed-url-ttl: PT30M # 给第三方 AI 拉参考图用
vector:
provider: redis # redis | milvus(未来)
redis:
host: ${VECTOR_REDIS_HOST} # 与业务缓存 Redis 分实例
port: 6379
index-name: agent_memory
queue:
concurrency:
text: 50
image: 20
video: 10
voice: 30
```
## 7. 关键设计决策
| 决策 | 选择 | 原因 |
|------|------|------|
| 响应式 vs 阻塞 | **Servlet + @Async + Reactor 流式**混合 | AI SSE 流式用 Reactor,CRUD 用 Servlet 简单 |
| 同步 vs 异步 AI 调用 | **全部异步**通过 MQ | 防止 HTTP 超时、便于重试、可观测 |
| ORM 选型 | **MyBatis-Plus** | 团队熟悉、多租户/分页/乐观锁插件开箱即用、JSON 字段处理好、代码生成省时间 |
| 单体 vs 微服务 | **单体多模块起步**,Worker 独立部署 | 降低初期复杂度 |
| 本地 vs 分布式事务 | **本地事务 + Saga** | 跨库/跨服务用 Saga(订单-支付-入账) |
| WebSocket vs SSE | **SSE 为主**,WS 留给协作 | SSE 简单、穿透代理、浏览器原生 |
# 06 · 数据库设计
# 06 · 数据库设计
## 1. 存储选型
| 存储 | 用途 |
|------|------|
| MySQL 8 | 核心业务数据(ACID) |
| **Redis Stack 7.2** | 缓存、分布式锁、限流、任务去重、SSE 订阅、**Agent 记忆向量检索(RediSearch)** |
| ~~Elasticsearch~~ | M8 后按需引入,MVP 用 MySQL FULLTEXT 代替 |
| **阿里云 OSS / 火山引擎 TOS**(生产) | 媒体文件(图/视频/音频),默认私有 Bucket,预签名 URL 对外 |
| MinIO(备选 TODO) | 本地开发 / 私有化部署 |
## 2. MySQL 表设计 (核心表 ~35 张)
### 2.1 账号与租户
```sql
-- 租户(组织)
CREATE TABLE tenants (
id BIGINT PK AUTO_INCREMENT,
name VARCHAR(128) NOT NULL,
slug VARCHAR(64) UNIQUE,
plan ENUM('FREE','PRO','TEAM','ENTERPRISE') DEFAULT 'FREE',
status ENUM('ACTIVE','SUSPENDED','DELETED') DEFAULT 'ACTIVE',
owner_user_id BIGINT,
created_at DATETIME, updated_at DATETIME
);
-- 用户
CREATE TABLE users (
id BIGINT PK,
email VARCHAR(255) UNIQUE,
phone VARCHAR(32) UNIQUE,
username VARCHAR(64) UNIQUE,
password_hash VARCHAR(100),
avatar_url VARCHAR(500),
locale VARCHAR(16) DEFAULT 'zh-CN',
status ENUM('ACTIVE','DISABLED','DELETED'),
last_login_at DATETIME,
created_at DATETIME
);
-- 用户-租户关联(多租户成员)
CREATE TABLE tenant_members (
id BIGINT PK,
tenant_id BIGINT,
user_id BIGINT,
role ENUM('OWNER','ADMIN','MEMBER'),
joined_at DATETIME,
UNIQUE KEY (tenant_id, user_id)
);
-- 企业级员工与小组(对应前端 /team 页)
CREATE TABLE employees (
id BIGINT PK, tenant_id BIGINT, user_id BIGINT,
position VARCHAR(128), status VARCHAR(16),
created_at DATETIME
);
CREATE TABLE groups (
id BIGINT PK, tenant_id BIGINT, name VARCHAR(128),
description TEXT, permissions JSON
);
CREATE TABLE group_members (
group_id BIGINT, employee_id BIGINT, PRIMARY KEY(group_id, employee_id)
);
```
### 2.2 项目与剧本
```sql
CREATE TABLE projects (
id BIGINT PK,
tenant_id BIGINT, owner_user_id BIGINT, group_id BIGINT NULL,
title VARCHAR(255), description TEXT, cover_url VARCHAR(500),
aspect_ratio ENUM('16:9','9:16','1:1'),
resolution ENUM('720P','1080P','2K'),
art_style VARCHAR(64), -- 2d_anime / chinese_guofeng / ...
world_setting TEXT,
color_palette VARCHAR(255),
target_duration INT, -- 分钟
target_episodes INT,
status ENUM('DRAFT','OUTLINE','STORYBOARD','VIDEO','COMPLETED','ARCHIVED'),
progress INT DEFAULT 0, -- 0-100
tags JSON,
text_model VARCHAR(64), image_model VARCHAR(64), video_model VARCHAR(64), tts_model VARCHAR(64),
watermark JSON,
final_video_url VARCHAR(500),
created_at DATETIME, updated_at DATETIME,
INDEX idx_tenant (tenant_id), INDEX idx_status (status)
);
CREATE TABLE project_members (
id BIGINT PK, project_id BIGINT, user_id BIGINT,
role ENUM('OWNER','ADMIN','EDITOR','VIEWER'),
joined_at DATETIME,
UNIQUE KEY (project_id, user_id)
);
CREATE TABLE project_invitations (
id BIGINT PK, project_id BIGINT, email VARCHAR(255),
role VARCHAR(16), token VARCHAR(128), expires_at DATETIME,
status ENUM('PENDING','ACCEPTED','EXPIRED','REVOKED')
);
CREATE TABLE scripts (
id BIGINT PK, project_id BIGINT,
original_filename VARCHAR(255), file_url VARCHAR(500),
raw_text LONGTEXT, word_count INT,
created_at DATETIME
);
CREATE TABLE novel_events (
id BIGINT PK, project_id BIGINT, chapter_index INT,
chapter_title VARCHAR(255), event_data JSON, processed BOOLEAN,
created_at DATETIME
);
CREATE TABLE outlines (
id BIGINT PK, project_id BIGINT,
title VARCHAR(255), genre VARCHAR(64), summary TEXT,
themes JSON, arcs JSON, story_skeleton JSON,
version INT, created_at DATETIME
);
CREATE TABLE episodes (
id BIGINT PK, project_id BIGINT, sequence INT,
title VARCHAR(255), description TEXT,
duration VARCHAR(32), keywords JSON,
generation_mode ENUM('KEYFRAME','REFERENCE'),
color_palette VARCHAR(255), bgm_url VARCHAR(500),
status VARCHAR(32), final_video_url VARCHAR(500),
created_at DATETIME,
UNIQUE KEY (project_id, sequence)
);
```
### 2.3 资产(角色/场景/道具)
```sql
CREATE TABLE characters (
id BIGINT PK, project_id BIGINT,
name VARCHAR(128), role_type VARCHAR(32), -- 女主/男主/反派/配角
gender ENUM('MALE','FEMALE','OTHER'),
age VARCHAR(32), personality TEXT, background TEXT,
costume TEXT, voice_id VARCHAR(64),
visual_hint TEXT, performance_style TEXT,
height_cm INT, body_type VARCHAR(32),
reference_image_url VARCHAR(500), -- 四视图
turnaround_history JSON, -- 历史版本
scope ENUM('MAIN','GUEST'),
created_at DATETIME, updated_at DATETIME,
INDEX idx_project (project_id)
);
CREATE TABLE character_appearances (
id BIGINT PK, character_id BIGINT,
name VARCHAR(128), -- 常服/礼服/战损...
prompt TEXT, image_url VARCHAR(500),
version INT, is_active BOOLEAN,
created_at DATETIME
);
CREATE TABLE character_relations (
id BIGINT PK, project_id BIGINT,
character_a_id BIGINT, character_b_id BIGINT,
relation_type VARCHAR(64), description TEXT
);
CREATE TABLE episode_characters (
episode_id BIGINT, character_id BIGINT,
PRIMARY KEY (episode_id, character_id)
);
CREATE TABLE scenes (
id BIGINT PK, project_id BIGINT,
name VARCHAR(128), type ENUM('INDOOR','OUTDOOR'),
description TEXT, usage TEXT,
image_url VARCHAR(500), reference_image_url VARCHAR(500),
version INT, created_at DATETIME
);
CREATE TABLE props (
id BIGINT PK, project_id BIGINT,
name VARCHAR(128), category VARCHAR(64),
description TEXT, scenes JSON,
image_url VARCHAR(500), reference_image_url VARCHAR(500),
created_at DATETIME
);
```
### 2.4 分镜与生成资产(核心)
```sql
CREATE TABLE storyboards (
id BIGINT PK, project_id BIGINT, episode_id BIGINT,
sequence INT, scene_number VARCHAR(16), -- "001","002"
short_description TEXT, detailed_description LONGTEXT,
location_id BIGINT, -- 引用 scenes.id
characters JSON, -- [{id, name, costumeOverride, voice}]
dialogues JSON, -- [{character, text, emotion}]
props JSON, -- 道具名称/id 数组
transition_in VARCHAR(32), transition_out VARCHAR(32),
camera_direction VARCHAR(64), composition_guide VARCHAR(64),
focal_point VARCHAR(128), depth_of_field VARCHAR(16),
duration INT, -- 秒
motion_script TEXT, video_script TEXT,
start_frame_prompt TEXT, end_frame_prompt TEXT,
sound_design VARCHAR(255), music_cue VARCHAR(255),
notes TEXT,
status ENUM('DRAFT','REVIEW','APPROVED','GENERATING','GENERATED','FAILED'),
credit_cost INT,
created_at DATETIME, updated_at DATETIME,
INDEX idx_episode_seq (episode_id, sequence)
);
-- 统一资产表(借鉴 AIComicBuilder shot_assets)
CREATE TABLE shot_assets (
id BIGINT PK, storyboard_id BIGINT,
type ENUM('FIRST_FRAME','LAST_FRAME','REFERENCE','KEYFRAME_VIDEO','REFERENCE_VIDEO','AUDIO','LIPSYNC_VIDEO'),
sequence_in_type INT,
asset_version INT,
is_active BOOLEAN,
prompt TEXT,
media_id BIGINT, -- 引用 media_objects(始终存自有 OSS/TOS 的 object key,禁止存第三方模型的临时 URL)
status VARCHAR(32),
characters JSON,
model_provider VARCHAR(64), model_id VARCHAR(64),
meta JSON,
created_at DATETIME,
INDEX idx_sb_type_active (storyboard_id, type, is_active)
);
```
### 2.5 统一媒体表(借鉴 waoowaoo MediaObject)
```sql
CREATE TABLE media_objects (
id BIGINT PK,
public_id VARCHAR(64) UNIQUE, -- ULID 对外暴露
tenant_id BIGINT, user_id BIGINT,
storage_key VARCHAR(500), -- OSS 对象 key
sha256 CHAR(64), -- 内容哈希,去重
mime_type VARCHAR(64),
size_bytes BIGINT,
width INT, height INT, duration_ms INT,
kind ENUM('IMAGE','VIDEO','AUDIO','FILE'),
created_at DATETIME,
UNIQUE KEY uk_sha256_tenant (tenant_id, sha256)
);
```
### 2.6 任务与工作流
```sql
CREATE TABLE tasks (
id BIGINT PK, task_id VARCHAR(32) UNIQUE, -- ULID
tenant_id BIGINT, user_id BIGINT, project_id BIGINT,
type VARCHAR(64), -- SCRIPT_PARSE, IMAGE_GEN, ...
status ENUM('QUEUED','PROCESSING','COMPLETED','FAILED','CANCELLED','DEAD'),
payload JSON, result JSON, error TEXT,
attempt INT DEFAULT 0, max_attempts INT DEFAULT 3,
heartbeat_at DATETIME,
external_id VARCHAR(128), -- 第三方任务 id
idempotency_key VARCHAR(128) UNIQUE,
freeze_id BIGINT, -- 关联 balance_freezes
parent_task_id BIGINT, -- 子任务关系
billing_info JSON,
created_at DATETIME, started_at DATETIME, finished_at DATETIME,
INDEX idx_status_type (status, type),
INDEX idx_heartbeat (heartbeat_at)
);
CREATE TABLE task_events (
id BIGINT PK, task_id VARCHAR(32),
event_type VARCHAR(64), data JSON,
created_at DATETIME
);
-- 三层 Agent 的工作流运行
CREATE TABLE graph_runs (
id BIGINT PK, run_id VARCHAR(32) UNIQUE,
project_id BIGINT, agent_type VARCHAR(32), -- SCRIPT / PRODUCTION
status VARCHAR(32), input JSON, output JSON,
created_at DATETIME, finished_at DATETIME
);
CREATE TABLE graph_steps (
id BIGINT PK, run_id VARCHAR(32),
step_name VARCHAR(64), layer ENUM('DECISION','EXECUTION','SUPERVISION'),
status VARCHAR(32), input JSON, output JSON,
started_at DATETIME, finished_at DATETIME, retry_count INT
);
```
### 2.7 计费(借鉴 waoowaoo)
```sql
CREATE TABLE user_balances (
user_id BIGINT PK,
tenant_id BIGINT,
balance DECIMAL(18,4) DEFAULT 0,
frozen_amount DECIMAL(18,4) DEFAULT 0,
total_spent DECIMAL(18,4) DEFAULT 0,
updated_at DATETIME,
version BIGINT -- 乐观锁
);
CREATE TABLE balance_freezes (
id BIGINT PK, user_id BIGINT, tenant_id BIGINT,
amount DECIMAL(18,4),
status ENUM('PENDING','CONFIRMED','ROLLED_BACK','EXPIRED'),
idempotency_key VARCHAR(128) UNIQUE,
task_id VARCHAR(32),
expires_at DATETIME, created_at DATETIME, metadata JSON
);
CREATE TABLE balance_transactions (
id BIGINT PK, user_id BIGINT, tenant_id BIGINT,
type ENUM('RECHARGE','CONSUME','REFUND','GIFT','ADJUST'),
amount DECIMAL(18,4), balance_after DECIMAL(18,4),
currency VARCHAR(8) DEFAULT 'CREDIT',
external_order_id VARCHAR(128),
project_id BIGINT, episode_id BIGINT, task_id VARCHAR(32),
task_type VARCHAR(64), billing_meta JSON,
created_at DATETIME,
INDEX idx_user_time (user_id, created_at)
);
CREATE TABLE subscriptions (
id BIGINT PK, tenant_id BIGINT,
plan ENUM('FREE','PRO','TEAM','ENTERPRISE'),
status ENUM('ACTIVE','TRIALING','PAST_DUE','CANCELED'),
started_at DATETIME, renewed_at DATETIME, expires_at DATETIME,
monthly_credits INT, auto_renew BOOLEAN,
external_subscription_id VARCHAR(128)
);
CREATE TABLE usage_records (
id BIGINT PK, tenant_id BIGINT, user_id BIGINT, project_id BIGINT, group_id BIGINT,
type VARCHAR(32), -- character/scene/props/video/text/tts
credits INT, task_id VARCHAR(32),
created_at DATETIME,
INDEX idx_tenant_time (tenant_id, created_at)
);
```
### 2.8 提示词(Skill)
```sql
CREATE TABLE prompt_templates (
id BIGINT PK,
prompt_key VARCHAR(64), -- script_generate, shot_split, ...
slot_key VARCHAR(64), -- role_definition / output_format / NULL(全文)
scope ENUM('GLOBAL','TENANT','PROJECT'),
tenant_id BIGINT, project_id BIGINT,
content LONGTEXT,
version INT, is_active BOOLEAN,
created_by BIGINT, created_at DATETIME,
UNIQUE KEY (prompt_key, slot_key, scope, tenant_id, project_id, version)
);
CREATE TABLE prompt_versions (
id BIGINT PK, template_id BIGINT, content LONGTEXT,
version INT, created_by BIGINT, created_at DATETIME
);
CREATE TABLE prompt_presets (
id BIGINT PK, tenant_id BIGINT, user_id BIGINT,
name VARCHAR(128), prompt_key VARCHAR(64),
slots JSON, is_public BOOLEAN
);
```
### 2.9 资产库(个人/团队)
```sql
CREATE TABLE global_assets (
id BIGINT PK, tenant_id BIGINT, owner_user_id BIGINT,
library ENUM('PERSONAL','TEAM','MARKET'),
type ENUM('CHARACTER','SCENE','PROP','VOICE'),
name VARCHAR(128), description TEXT,
tags JSON, style VARCHAR(64),
thumbnail_url VARCHAR(500),
full_data JSON, -- 完整对象(角色四视图/描述等)
favorite_count INT DEFAULT 0,
created_at DATETIME, updated_at DATETIME,
INDEX idx_tenant_lib_type (tenant_id, library, type)
);
CREATE TABLE asset_favorites (
user_id BIGINT, asset_id BIGINT, PRIMARY KEY (user_id, asset_id)
);
```
### 2.10 通知与审计
```sql
CREATE TABLE notifications (
id BIGINT PK, user_id BIGINT,
type VARCHAR(32), title VARCHAR(255), body TEXT,
data JSON, read_at DATETIME, created_at DATETIME
);
CREATE TABLE audit_logs (
id BIGINT PK, tenant_id BIGINT, user_id BIGINT,
action VARCHAR(64), resource_type VARCHAR(32), resource_id VARCHAR(64),
ip VARCHAR(45), user_agent VARCHAR(255),
request_id VARCHAR(64), meta JSON,
created_at DATETIME,
INDEX idx_tenant_time (tenant_id, created_at)
);
```
## 3. Redis Stack 向量索引设计 (RediSearch)
**双实例部署**: 业务缓存 Redis 与向量 Redis 物理分离,避免大向量拖慢热 key。
### 3.1 `agent_memory` 索引(Agent 跨会话记忆)
```
FT.CREATE idx:agent_memory
ON HASH PREFIX 1 memory:
SCHEMA
project_id TAG SEPARATOR ","
agent_type TAG -- script | production
role TAG -- user | assistant:decision | assistant:skeleton ...
content TEXT WEIGHT 1.0
summarized TAG -- 0 | 1
created_at NUMERIC SORTABLE
embedding VECTOR HNSW 6
TYPE FLOAT32 DIM 1024
DISTANCE_METRIC COSINE
M 16 EF_CONSTRUCTION 200
```
HSET 示例:
```
HSET memory:{ulid}
project_id P001
agent_type script
role "assistant:skeleton"
content "三幕骨架..."
summarized 0
created_at 1713234567
embedding <1024 float32 binary>
```
### 3.2 `asset_semantic` 索引(资产语义检索,可选)
```
FT.CREATE idx:asset_semantic
ON HASH PREFIX 1 asset:
SCHEMA
tenant_id TAG
asset_type TAG -- character | scene | prop
asset_id NUMERIC
embedding VECTOR HNSW 6
TYPE FLOAT32 DIM 1024
DISTANCE_METRIC COSINE
```
### 3.3 检索示例
```
FT.SEARCH idx:agent_memory
"(@project_id:{P001} @agent_type:{script})=>[KNN 3 @embedding $vec AS score]"
PARAMS 2 vec <query_vec_bytes>
SORTBY score
DIALECT 2
```
Spring AI 的 `RedisVectorStore` 自动生成上面的 FT 语法,业务代码只调 `similaritySearch()` 即可。
### 3.4 持久化与规模
- AOF `everysec` + RDB 每小时双持久化
- HNSW 索引参数: `M=16, EF_CONSTRUCTION=200, EF_RUNTIME=50`
- 预估: 1 万活跃项目 × 500 条记忆/项目 × (1KB 文本 + 4KB 向量) ≈ **25GB 内存**
- 超过 64GB 再迁 Milvus(Spring AI VectorStore 接口兼容,零改动)
## 4. Redis 业务 Key 规范(业务缓存实例)
```
auth:token:{jti} # JWT 黑名单
auth:login:ip:{ip} # 登录限流
task:dedupe:{idempotencyKey} # 任务去重
task:lock:{taskId} # 分布式锁
sse:project:{projectId} # SSE 订阅通道(pub/sub)
cache:project:{id} # 项目详情缓存 TTL 60s
cache:skill:{key}:{tenantId} # Skill 模板缓存 TTL 300s
rate:user:{userId}:{api} # 按用户限流
```
## 5. 索引与分区策略
- `tasks``created_at` 月分区(3~6 月前归档)
- `balance_transactions``created_at` 季分区
- `audit_logs``created_at` 月分区 + 冷热分层(S3)
- `usage_records` 同上
- `storyboards` 大项目(1000+ 分镜)按 `project_id` 哈希分库(水平扩展)
# 07 · API 接口规范
# 07 · API 接口规范
## 1. 通用约定
- **BaseURL**: `https://api.yaoai.com/v1`
- **格式**: JSON(UTF-8),时间 ISO-8601
- **鉴权**: `Authorization: Bearer <jwt>`,租户从 JWT claims 解析
- **请求 ID**: 每个响应带 `X-Request-Id`
- **分页**: `?page=1&size=20`,响应 `{items[], total, page, size}`
- **错误**: 统一结构
```json
{"error":{"code":"INVALID_PARAM","message":"...","requestId":"..."}}
```
- **幂等**: 写操作支持 `Idempotency-Key`
- **限流**: 返回 `Retry-After`
## 2. 认证
| Method | Path | 说明 |
|--------|------|------|
| POST | `/auth/register` | 注册(email/username + password) |
| POST | `/auth/login` | 登录 {accessToken, refreshToken, user} |
| POST | `/auth/logout` | 注销 |
| POST | `/auth/refresh` | 刷新 token |
| GET | `/auth/me` | 当前用户信息 |
| POST | `/auth/oauth/{provider}/callback` | 第三方登录 |
| POST | `/auth/password/reset-request` | 发送重置邮件 |
| POST | `/auth/password/reset` | token 重置 |
## 3. 租户与成员
| Method | Path | 说明 |
|--------|------|------|
| GET | `/tenants/current` | 当前租户 |
| PATCH | `/tenants/current` | 更新名称/Logo |
| GET | `/tenants/members` | 成员列表 |
| POST | `/tenants/invitations` | 邀请成员 |
## 4. 项目
| Method | Path | 说明 |
|--------|------|------|
| GET | `/projects?page=&search=&status=&from=&to=&members=` | 列表 |
| GET | `/projects/stats` | Dashboard 统计 |
| POST | `/projects` | 创建(multipart: file + meta) |
| GET | `/projects/{id}` | 详情 |
| GET | `/projects/{id}/full-data` | 含大纲/人物/分集聚合 |
| PATCH | `/projects/{id}` | 更新字段 |
| DELETE | `/projects/{id}` | 删除 |
| POST | `/projects/{id}:archive` | 归档 |
### 项目成员
| Method | Path |
|--------|------|
| GET | `/projects/{id}/members` |
| POST | `/projects/{id}/invitations` |
| PATCH | `/projects/{id}/members/{mid}` |
| DELETE | `/projects/{id}/members/{mid}` |
## 5. 剧本解析 (四步导入)
| Method | Path | 说明 |
|--------|------|------|
| POST | `/projects/{id}/import/step1-extract` | 文本抽取 |
| POST | `/projects/{id}/import/step2-characters` | AI 角色提取 |
| GET/PATCH/DELETE | `/projects/{id}/characters/{cid}` | 用户修改角色 |
| POST | `/projects/{id}/import/step3-episodes` | 智能分集 |
| POST | `/projects/{id}/import/step4-confirm` | 一键创建 |
## 6. 风格与大纲
| Method | Path |
|--------|------|
| GET | `/styles` |
| POST | `/projects/{id}:set-style` |
| POST | `/projects/{id}/outline:generate` `taskId` |
| GET | `/projects/{id}/outline` |
| PUT | `/projects/{id}/outline` |
## 7. 分集
| Method | Path |
|--------|------|
| GET | `/projects/{id}/episodes` |
| POST | `/projects/{id}/episodes:generate` |
| PATCH | `/projects/{id}/episodes/{eid}` |
| DELETE | `/projects/{id}/episodes/{eid}` |
## 8. 角色/场景/道具
每种资源一套相同风格:
```
GET /projects/{id}/characters
POST /projects/{id}/characters
GET /projects/{id}/characters/{cid}
PATCH /projects/{id}/characters/{cid}
DELETE /projects/{id}/characters/{cid}
POST /projects/{id}/characters/{cid}:generate-turnaround # 四视图
POST /projects/{id}/characters/{cid}:upload-reference # 参考图
POST /projects/{id}/characters/{cid}/appearances # 新增变体
GET /projects/{id}/characters/{cid}/appearances
```
scenes / props 同构。
## 9. 分镜
| Method | Path | 说明 |
|--------|------|------|
| GET | `/projects/{id}/episodes/{eid}/storyboards` | 列表 |
| POST | `/projects/{id}/episodes/{eid}/storyboards:generate` | AI 批量生成分镜 |
| POST | `/projects/{id}/storyboards` | 手动创建 |
| GET | `/projects/{id}/storyboards/{sid}` | 详情 |
| PATCH | `/projects/{id}/storyboards/{sid}` | 更新 |
| DELETE | `/projects/{id}/storyboards/{sid}` | 删除 |
| POST | `/projects/{id}/storyboards/{sid}:duplicate` | 复制 |
| PUT | `/projects/{id}/episodes/{eid}/storyboards:reorder` | 拖拽重排序 |
| POST | `/projects/{id}/storyboards/{sid}:ai-check` | AI 合规检查 |
### 分镜资产
| Method | Path |
|--------|------|
| GET | `/projects/{id}/storyboards/{sid}/assets?type=FIRST_FRAME&active=true` |
| POST | `/projects/{id}/storyboards/{sid}/assets/first-frame:generate` |
| POST | `/projects/{id}/storyboards/{sid}/assets/last-frame:generate` |
| POST | `/projects/{id}/storyboards/{sid}/assets/video:generate` |
| POST | `/projects/{id}/storyboards/{sid}/assets/audio:generate` |
| PATCH | `/projects/{id}/storyboards/{sid}/assets/{aid}:activate` | 回滚版本 |
### 批量
| Method | Path |
|--------|------|
| POST | `/projects/{id}/episodes/{eid}/storyboards:batch-generate` body={storyboardIds[], targets:[first_frame,last_frame,video]} |
## 10. 视频合成
| Method | Path |
|--------|------|
| POST | `/projects/{id}/episodes/{eid}:assemble` | 合成最终视频(FFmpeg) |
| GET | `/projects/{id}/episodes/{eid}/segments` | 分镜段状态 |
| GET | `/projects/{id}/episodes/{eid}/final` | 最终 MP4 下载 URL |
## 11. 任务与进度
| Method | Path |
|--------|------|
| GET | `/tasks/{taskId}` | 单任务详情 |
| GET | `/tasks?type=&status=&projectId=` | 列表 |
| POST | `/tasks/{taskId}:cancel` | 取消 |
| GET | `/tasks/{taskId}/events` (**SSE**) | 进度事件流 |
| GET | `/projects/{id}/events` (**SSE**) | 项目级事件聚合 |
**SSE 事件格式**:
```
event: task.progress
data: {"taskId":"01HX...","type":"VIDEO_GEN","progress":42,"stage":"polling","message":"Shot 12/50"}
event: task.completed
data: {"taskId":"...","result":{...}}
event: task.failed
data: {"taskId":"...","error":{"code":"...","message":"..."}}
event: credit.consumed
data: {"amount":50,"balance":2340}
```
## 12. 计费
| Method | Path |
|--------|------|
| GET | `/billing/balance` | 当前余额+冻结+已消费 |
| GET | `/billing/transactions?page=&type=` | 流水 |
| POST | `/billing/recharge` body={amount, paymentMethod} → 支付链接 |
| POST | `/billing/subscription` body={plan} → 升级 |
| GET | `/billing/subscription` | 当前订阅 |
| POST | `/billing/subscription:cancel` |
| GET | `/usage/stats?from=&to=` | 积分统计 |
| GET | `/usage/daily?from=&to=` | 30 天趋势 |
| GET | `/usage/records?...` | 明细 |
## 13. 资产库
| Method | Path |
|--------|------|
| GET | `/assets?library=personal&type=character&tags=&search=` |
| POST | `/assets` | 上传 |
| GET | `/assets/{aid}` |
| PATCH | `/assets/{aid}` |
| DELETE | `/assets/{aid}` |
| POST | `/assets/{aid}:favorite` |
| POST | `/assets/{aid}:clone-to-project` body={projectId} |
## 14. 设置与提示词
| Method | Path |
|--------|------|
| GET/PUT | `/settings/watermark` |
| GET | `/prompt-templates?scope=&key=` |
| POST | `/prompt-templates` |
| PATCH | `/prompt-templates/{id}` |
| GET | `/prompt-templates/{id}/versions` |
| POST | `/prompt-templates/{id}:rollback?version=` |
| GET/POST | `/prompt-presets` |
## 15. 企业管理(`/admin/*`)
需要 `ADMIN``OWNER` 角色:
| Method | Path |
|--------|------|
| GET | `/admin/employees?search=&status=` |
| POST | `/admin/employees` |
| PATCH | `/admin/employees/{id}` |
| DELETE | `/admin/employees/{id}` |
| POST | `/admin/employees:bulk-import` (CSV) |
| GET | `/admin/groups` |
| POST | `/admin/groups` |
| PATCH | `/admin/groups/{id}` |
| GET | `/admin/audit-logs?...` |
## 16. 系统
| Method | Path |
|--------|------|
| GET | `/system/boot-id` | 健康检查 |
| GET | `/system/version` | 版本 |
| GET | `/system/notifications` | 全局公告 |
## 17. OpenAPI 规范
- 使用 `springdoc-openapi` 自动生成 `/v3/api-docs` 和 Swagger UI
- 所有 DTO 用 Bean Validation 注解(`@NotNull`/`@Size`/`@Email`)
- Controller 强制使用 `ResponseEntity<ApiResponse<T>>` 统一包装
- 错误码集中维护在 `ErrorCode` 枚举
# 08 · 异步任务与 AI 工作流
# 08 · 异步任务与 AI 工作流
## 1. 总体策略
**核心原则(借鉴 waoowaoo 代码卫士)**:
1. **所有 AI 调用必须异步**,禁止在 HTTP 请求线程内直接调 LLM/图/视频模型
2. **所有任务必须幂等**,用 `idempotencyKey` 去重
3. **所有任务必须可追溯**,记录 payload/result/error/耗时/成本
4. **所有任务必须可取消**,支持用户主动终止
5. **所有任务必须有心跳**,防止 Worker 假死
## 2. 任务队列拓扑
```
Producer(API Server)
│ publish
RabbitMQ
├── exchange: yaoai.tasks (topic)
├── queue: text.generate (concurrency=50, prefetch=5)
├── queue: image.generate (concurrency=20, prefetch=2)
├── queue: video.generate (concurrency=10, prefetch=1)
├── queue: voice.tts (concurrency=30, prefetch=5)
├── queue: video.assemble (concurrency=5, prefetch=1)
├── queue: lipsync (concurrency=5, prefetch=1)
├── queue: script.parse (concurrency=10, prefetch=2)
└── dlx: yaoai.tasks.dlx (死信,人工介入)
▼ consume
Worker Pods(K8s HPA 按 queue 深度扩缩)
```
## 3. 任务状态机
```
submit
[QUEUED] ──── cancel ────► [CANCELLED]
│ pickup
[PROCESSING] ──── cancel ────► [CANCELLED](尽力)
success │ fail
│ │
▼ ▼
[COMPLETED][FAILED]
│ retry < max
[QUEUED]
│ retry exceeded
[DEAD](进 DLX,管理员介入)
```
## 4. 任务提交(幂等)
```java
@Service
public class TaskService {
public Task submit(TaskType type, Long projectId, Object payload, String idempotencyKey) {
// 1. 幂等检查
var existing = taskRepo.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) return existing.get();
// 2. 计费预检
BigDecimal quoted = costEstimator.estimate(type, payload);
BalanceFreeze freeze = billingService.freeze(userId, quoted, idempotencyKey, taskId);
// 3. 持久化
Task task = Task.builder()
.taskId(UlidCreator.getUlid().toString())
.type(type).projectId(projectId)
.payload(json(payload))
.idempotencyKey(idempotencyKey)
.freezeId(freeze.getId())
.status(QUEUED).attempt(0).maxAttempts(3)
.build();
taskRepo.save(task);
// 4. 发布到 MQ
rabbitTemplate.convertAndSend("yaoai.tasks", type.routingKey(), task.getTaskId());
return task;
}
}
```
## 5. Worker 处理模板
```java
@Component
@RabbitListener(queues = "video.generate")
public class VideoGenerateWorker {
public void onMessage(String taskId) {
Task task = taskService.lockAndMarkProcessing(taskId);
ScheduledFuture<?> heartbeat = scheduler.scheduleAtFixedRate(
() -> taskService.heartbeat(taskId),
0, 10, TimeUnit.SECONDS
);
try {
var payload = parse(task.getPayload());
// 1. 为参考图(首尾帧)生成预签名 URL,第三方视频模型据此拉图
// Bucket 默认私有,TTL 30 分钟覆盖任务最大执行时长(10~15 分钟)
String firstFrameUrl = storageProvider.getSignedUrl(
payload.firstFrameKey(), Duration.ofMinutes(30));
String lastFrameUrl = storageProvider.getSignedUrl(
payload.lastFrameKey(), Duration.ofMinutes(30));
List<String> charRefUrls = payload.characterRefKeys().stream()
.map(k -> storageProvider.getSignedUrl(k, Duration.ofMinutes(30)))
.toList();
// 2. 调用 Provider(异步提交,返回外部任务 id)
VideoTask vt = videoProvider.submit(VideoRequest.builder()
.prompt(payload.videoScript())
.firstFrameUrl(firstFrameUrl)
.lastFrameUrl(lastFrameUrl)
.referenceUrls(charRefUrls)
.duration(payload.duration())
.resolution(payload.resolution())
.build());
task.setExternalId(vt.externalId());
taskRepo.save(task);
// 3. 轮询 (5s × 120 次 = 10 分钟)
VideoResult result = pollUntilDone(vt, Duration.ofMinutes(10));
// 4. ⚠️ 关键: 从模型厂商临时 URL 拉回视频,转存到自有 OSS/TOS
// 模型厂的返回 URL 通常 24 小时过期,绝不能直接存入数据库
String targetKey = String.format("shots/%d/%d/video-v%d.mp4",
payload.projectId(), payload.storyboardId(), payload.version());
try (InputStream in = httpClient.stream(result.videoUrl())) {
storageProvider.upload(targetKey, in, "video/mp4", result.sizeBytes());
}
Long mediaId = mediaService.createMediaObject(
targetKey, "video/mp4", result.sizeBytes(),
result.width(), result.height(), result.durationMs());
// 5. 写 shot_assets(fileUrl 永远存 object key,不存 URL)
assetService.createShotAsset(payload.storyboardId(), KEYFRAME_VIDEO, mediaId, task);
// 6. 计费结算
BigDecimal actualCost = costCalculator.actualCost(task, result);
billingService.settle(task.getFreezeId(), actualCost);
// 7. 标记完成 + 发事件(前端拿到后用 GET /api/media/{mediaId}/url 换签名 URL 播放)
taskService.markCompleted(task, Map.of("mediaId", mediaId));
sseService.publish(task.getProjectId(), "task.completed", task);
} catch (CancelledException e) {
billingService.rollback(task.getFreezeId());
taskService.markCancelled(task);
} catch (Exception e) {
if (task.getAttempt() < task.getMaxAttempts() && isRetryable(e)) {
taskService.requeueWithBackoff(task);
} else {
billingService.rollback(task.getFreezeId());
taskService.markFailed(task, e);
sseService.publish(task.getProjectId(), "task.failed", task);
}
} finally {
heartbeat.cancel(false);
}
}
}
```
## 6. 三层 Agent 执行引擎
### 6.1 运行记录(GraphRun / GraphStep)
每次触发一个 Agent 流程(如"生成全集分镜")都创建 `GraphRun`,每个执行层/监督层步骤记录 `GraphStep`,支持:
- 断点续跑(从失败的 step 重启)
- 可视化回放(时间线)
- 审计追溯
### 6.2 执行伪码
```java
@Service
public class ProductionAgentOrchestrator {
public GraphRun start(Long projectId, String userGoal) {
GraphRun run = graphRunRepo.create(projectId, "PRODUCTION", userGoal);
try {
// Step 1: 决策层(选择执行路径)
var decision = step(run, "decision", DECISION, () ->
decisionAgent.decide(projectId, userGoal));
for (String execName : decision.pipeline()) {
// Step N: 执行层
var result = step(run, execName, EXECUTION, () ->
executors.get(execName).execute(projectId, run));
// 监督层
if (needsSupervision(execName)) {
var review = step(run, execName + ".review", SUPERVISION, () ->
supervisionAgent.review(execName, result));
if (!review.passed()) {
if (review.retry()) result = retry(execName, review.feedback());
else break; // escalate to user
}
}
}
run.markCompleted();
} catch (Exception e) {
run.markFailed(e);
}
return run;
}
}
```
### 6.3 执行层 Executor 清单
| Executor | 对应 Toonflow Skill | 任务类型 |
|----------|---------------------|----------|
| `SkeletonExecutor` | script_execution_skeleton | 故事骨架 |
| `AdaptationExecutor` | script_execution_adaptation | 改编策略 |
| `ScriptExecutor` | script_execution_script | 剧本编写 |
| `DeriveAssetExecutor` | production_execution_derive_assets | 衍生资产分析 |
| `AssetImageExecutor` | production_execution_generate_assets | 角色/场景/道具图(异步) |
| `DirectorPlanExecutor` | production_execution_director_plan | 拍摄计划 |
| `StoryboardTableExecutor` | production_execution_storyboard_table | 分镜表 |
| `StoryboardPanelExecutor` | production_execution_storyboard_panel | 分镜面板写入 |
| `FrameGenerateExecutor` | frame_generate_first/last(AICB) | 首尾帧(异步并发) |
| `VideoGenerateExecutor` | video_generate | 视频生成(异步并发) |
| `VoiceTtsExecutor` | - | TTS |
| `AssembleExecutor` | - | FFmpeg 合成 |
## 7. Skill 加载与热更新
```java
@Service
public class SkillLoader {
private final Map<String, String> cache = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
// 从 classpath:skills/ + 外部 config 目录 加载所有 .md
reload();
// 文件监听
fileWatcher.watch(Paths.get("./skills"), this::reload);
// Redis pub/sub
redisTemplate.listenChannel("skill:reload", msg -> reload());
}
public String load(String key) { return cache.get(key); }
public String loadRendered(String key, Long tenantId, Long projectId, Map<String,String> vars) {
// 1. 查 prompt_templates 表找 scope=PROJECT 覆盖
// 2. 查 scope=TENANT
// 3. fallback 到 cache(GLOBAL / 文件)
// 4. 模板渲染(mustache)
}
}
```
## 8. 成本估算表
```java
@Component
public class CostEstimator {
public BigDecimal estimate(TaskType type, Object payload) {
return switch (type) {
case SCRIPT_PARSE -> BigDecimal.valueOf(5);
case OUTLINE_GEN -> BigDecimal.valueOf(10);
case EPISODE_GEN -> BigDecimal.valueOf(10);
case SHOT_SPLIT -> BigDecimal.valueOf(3 * shotCount);
case CHARACTER_IMAGE -> BigDecimal.valueOf(20); // 四视图
case SCENE_IMAGE -> BigDecimal.valueOf(10);
case PROP_IMAGE -> BigDecimal.valueOf(10);
case FRAME_GEN -> BigDecimal.valueOf(15); // 单张
case VIDEO_GEN -> estimateVideo(payload);
case TTS -> BigDecimal.valueOf(0.1 * chars);
case LIPSYNC -> BigDecimal.valueOf(30);
case ASSEMBLE -> BigDecimal.valueOf(5);
};
}
}
```
## 9. 超时与降级
| 场景 | 策略 |
|------|------|
| 第三方 AI 超时 | 退避重试 3 次,失败切换 fallback provider |
| 视频模型繁忙(429) | 指数退避 30s/60s/120s |
| Worker 假死(心跳超时 60s) | 定时任务标记 DEAD,重新入队 |
| 整体 MQ 故障 | Redis 降级队列(最多 1000 任务) |
| 余额不足 | 任务直接拒绝,返回 402 |
## 10. 定时任务(单实例 Scheduler)
```
每 1 分钟 : 清理过期 BalanceFreeze → ROLLED_BACK
每 5 分钟 : Worker 心跳扫描 → 超时标 DEAD 重新入队
每 30 分钟 : 统计聚合(usage_records → daily_usage_stats)
每 1 小时 : Agent Memory 摘要压缩(超过 100 条触发)
每天 02:00 : 清理过期通知、归档旧 audit_logs
每天 03:00 : 订阅续费检查、到期停用
```
# 09 · 计费与多租户 SaaS 基础设施
# 09 · 计费与多租户 SaaS 基础设施
## 1. 计费系统(借鉴 waoowaoo 余额冻结机制)
### 1.1 三表核心
```
user_balances (账户快照:可用 + 冻结 + 已花)
balance_freezes (操作原子性:pending → confirmed/rolled_back)
balance_transactions (审计流水:不可变)
```
### 1.2 状态机
```
操作开始
[prepare] 估算成本 → freeze 金额
│ (balance -= amount, frozen += amount)
[execute] AI 调用(异步)
├ 成功 → [settle]
│ actualCost 可能 ≠ quotedCost
│ frozen -= quotedCost
│ balance += (quotedCost - actualCost) // 退回差额
│ totalSpent += actualCost
│ 写 balance_transactions (CONSUME)
│ freeze.status = CONFIRMED
├ 失败 → [rollback]
│ balance += quotedCost, frozen -= quotedCost
│ freeze.status = ROLLED_BACK
└ 超时 → [expire](scheduler)
同 rollback
```
### 1.3 核心代码
```java
@Service
@Transactional
public class BillingService {
public BalanceFreeze freeze(Long userId, BigDecimal amount, String idempotencyKey, String taskId) {
// 幂等
var existing = freezeRepo.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) return existing.get();
// 乐观锁扣减 + 冻结
int rows = balanceRepo.tryFreeze(userId, amount);
if (rows == 0) throw new InsufficientBalanceException();
BalanceFreeze f = BalanceFreeze.builder()
.userId(userId).amount(amount)
.status(PENDING).idempotencyKey(idempotencyKey)
.taskId(taskId).expiresAt(now().plusHours(2))
.build();
return freezeRepo.save(f);
}
public void settle(Long freezeId, BigDecimal actualCost) {
BalanceFreeze f = freezeRepo.findByIdForUpdate(freezeId);
if (f.getStatus() != PENDING) return;
BigDecimal refund = f.getAmount().subtract(actualCost).max(ZERO);
balanceRepo.confirmConsume(f.getUserId(), f.getAmount(), actualCost, refund);
transactionRepo.save(BalanceTransaction.builder()
.userId(f.getUserId()).type(CONSUME)
.amount(actualCost.negate())
.balanceAfter(balanceRepo.getBalance(f.getUserId()))
.taskId(f.getTaskId()).billingMeta(...)
.build());
f.setStatus(CONFIRMED);
freezeRepo.save(f);
}
public void rollback(Long freezeId) {
BalanceFreeze f = freezeRepo.findByIdForUpdate(freezeId);
if (f.getStatus() != PENDING) return;
balanceRepo.releaseFreeze(f.getUserId(), f.getAmount());
f.setStatus(ROLLED_BACK);
freezeRepo.save(f);
}
}
```
### 1.4 SQL(乐观锁扣减)
```sql
-- 预冻结
UPDATE user_balances
SET balance = balance - :amount,
frozen_amount = frozen_amount + :amount,
version = version + 1
WHERE user_id = :uid AND balance >= :amount;
-- rows=0 即余额不足
-- 确认消费(结算)
UPDATE user_balances
SET frozen_amount = frozen_amount - :frozen,
balance = balance + :refund,
total_spent = total_spent + :actual
WHERE user_id = :uid;
-- 回滚冻结
UPDATE user_balances
SET frozen_amount = frozen_amount - :amount,
balance = balance + :amount
WHERE user_id = :uid;
```
### 1.5 BillingMode 开关
| 模式 | 行为 |
|------|------|
| `OFF` | 完全跳过计费(开发/开源) |
| `SOFT` | 只记录不拦截(观察期) |
| `STRICT` | 余额不足拒绝任务(生产) |
## 2. 订阅与支付
### 2.1 订阅模型
```
subscriptions
- plan: FREE / PRO / TEAM / ENTERPRISE
- status: ACTIVE / TRIALING / PAST_DUE / CANCELED
- monthly_credits: 每月赠送积分
- started_at / renewed_at / expires_at
- auto_renew: BOOLEAN
- external_subscription_id: 支付网关的订阅 ID
```
### 2.2 支付集成
| 网关 | 场景 |
|------|------|
| 支付宝 | 国内 C 端 |
| 微信支付 | 国内 C 端 |
| Stripe | 海外 + 订阅 |
| 对公转账 | 企业版 |
**集成方式**: 抽象 `PaymentProvider` 接口,各实现适配异步回调 Webhook,回调更新 `balance_transactions(type=RECHARGE)` + `subscriptions.status`
### 2.3 续费与失败处理
- `auto_renew=true`: 每月到期前 3 天尝试扣款,失败重试 3 次,最后降级到 FREE
- `auto_renew=false`: 到期直接降级
- 降级后保留数据 30 天,超期归档
## 3. 多租户隔离
### 3.1 三种隔离级别
| 级别 | 对应套餐 | 实现 |
|------|----------|------|
| L1: 共享表 + tenant_id 列 | Free/Pro/Team | MyBatis-Plus `TenantLineInnerInterceptor` 自动注入 `tenant_id=?` |
| L2: 独立 schema | Enterprise | MyBatis-Plus `DynamicTableNameInnerInterceptor` + 动态数据源(`baomidou/dynamic-datasource`) |
| L3: 独立数据库 | 私有部署 | 每个客户一个独立实例 |
### 3.2 TenantContext
```java
public final class TenantContext {
private static final ThreadLocal<Long> CURRENT = new ThreadLocal<>();
public static void set(Long tenantId) { CURRENT.set(tenantId); }
public static Long get() { return CURRENT.get(); }
public static void clear() { CURRENT.remove(); }
}
@Component
public class TenantFilter extends OncePerRequestFilter {
protected void doFilterInternal(...) {
if (StpUtil.isLogin()) {
Long tenantId = (Long) StpUtil.getSession().get("tenantId");
TenantContext.set(tenantId);
}
try { chain.doFilter(request, response); }
finally { TenantContext.clear(); }
}
}
```
### 3.3 MyBatis-Plus 多租户插件自动注入
```java
// MybatisPlusConfig 中注册 TenantLineInnerInterceptor(详见 doc 05 §5.2)
// 所有 SQL 自动追加 WHERE tenant_id = ?,无需在 Entity 上加任何注解
// Entity 只需声明 tenantId 字段即可
@Data
@TableName("projects")
public class Project {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId; // 插件自动处理
// ...
}
// 跨租户操作(白名单): 在 Mapper 方法上标注忽略
@InterceptorIgnore(tenantLine = "true")
@Select("SELECT * FROM global_assets WHERE library = 'MARKET'")
List<GlobalAsset> selectPublicAssets();
```
**Worker 环境**: 从 Task 的 `tenantId` 字段恢复 TenantContext,插件自动生效。
### 3.4 跨租户操作(白名单)
- 系统管理员后台(只读审计)
- 资产市场(公共库,`tenant_id IS NULL`)
- 平台通知
这些场景显式关闭 Filter: `session.disableFilter("tenantFilter")`
## 4. 权限模型 (RBAC + 资源级)
### 4.1 层次
```
租户级角色: OWNER / ADMIN / MEMBER
项目级角色: owner / admin / editor / viewer
资源级权限: 分镜审批、成员邀请、计费查看...
```
### 4.2 权限注解
```java
@PreAuthorize("@projectPermission.check(#projectId, 'STORYBOARD:EDIT')")
@PatchMapping("/projects/{projectId}/storyboards/{sid}")
public Storyboard update(@PathVariable Long projectId, ...) { }
```
```java
@Component("projectPermission")
public class ProjectPermissionEvaluator {
@Autowired ProjectMemberMapper memberMapper;
public boolean check(Long projectId, String perm) {
Long uid = currentUserId();
ProjectMember member = memberMapper.selectOne(
new LambdaQueryWrapper<ProjectMember>()
.eq(ProjectMember::getProjectId, projectId)
.eq(ProjectMember::getUserId, uid));
if (member == null) throw new ForbiddenException();
return ROLE_PERMS.get(member.getRole()).contains(perm);
}
}
```
### 4.3 角色权限矩阵
| 权限 | owner | admin | editor | viewer |
|------|:-----:|:-----:|:------:|:------:|
| PROJECT:READ | ✓ | ✓ | ✓ | ✓ |
| PROJECT:WRITE | ✓ | ✓ | ✓ | |
| PROJECT:DELETE | ✓ | | | |
| STORYBOARD:EDIT | ✓ | ✓ | ✓ | |
| STORYBOARD:APPROVE | ✓ | ✓ | | |
| STORYBOARD:GENERATE | ✓ | ✓ | ✓ | |
| TEAM:INVITE | ✓ | ✓ | | |
| TEAM:REMOVE | ✓ | ✓ | | |
| BILLING:VIEW | ✓ | ✓ | | |
| BILLING:RECHARGE | ✓ | | | |
## 5. 配额与速率限制
### 5.1 租户级配额
```java
@Data
@TableName("tenant_quotas")
public class TenantQuota {
@TableId
Long tenantId;
Integer maxProjects;
Integer maxMembers;
Integer maxMonthlyCredits;
Integer maxConcurrentTasks; // 同时进行中的任务数
BigDecimal monthlyBudgetLimit; // 可选:硬性月消费上限
}
```
### 5.2 限流(Bucket4j + Redis)
```java
@Component
public class RateLimitFilter extends OncePerRequestFilter {
// 路径级策略
Map<String, Bandwidth> rules = Map.of(
"/auth/login", Bandwidth.simple(5, Duration.ofMinutes(1)),
"/auth/register", Bandwidth.simple(3, Duration.ofHours(1)),
"/projects", Bandwidth.simple(60, Duration.ofMinutes(1)),
"/api/default", Bandwidth.simple(300, Duration.ofMinutes(1))
);
}
```
## 6. 审计日志
```java
@Aspect @Component
public class AuditAspect {
@AfterReturning("@annotation(audit)")
public void log(JoinPoint jp, Audited audit) {
auditLogRepo.save(AuditLog.builder()
.tenantId(TenantContext.get())
.userId(currentUserId())
.action(audit.action())
.resourceType(audit.resource())
.resourceId(extractResourceId(jp))
.ip(request.getRemoteAddr())
.userAgent(request.getHeader("User-Agent"))
.requestId(MDC.get("requestId"))
.build());
}
}
@Audited(action = "PROJECT_DELETE", resource = "PROJECT")
@DeleteMapping("/projects/{id}")
public void delete(@PathVariable Long id) { ... }
```
## 7. GDPR / 数据合规
- `POST /user/data-export` 异步导出全部个人数据到 ZIP(含项目、作品、消费记录)
- `POST /user/delete-account` 7 天延迟删除(可撤回)→ 硬删除
- 日志自动脱敏(`passwords/tokens/api_keys/email``****` 替换)
- 敏感字段加密存储(`api_keys``payment_info` 用 Jasypt)
## 8. 可观测性
- **Metrics**: Micrometer → Prometheus → Grafana 面板
- 任务队列深度、耗时 p99、失败率
- 余额扣减 QPS、账单异常
- AI Provider 响应时间、错误码分布
- **Logs**: Logback JSON + MDC(requestId/userId/tenantId/taskId)→ ELK
- **Traces**: SkyWalking 或 OTel,跨 API→MQ→Worker 串联
- **Health**: `/actuator/health` 包含 MySQL / Redis Stack(RediSearch)/ MQ / OSS 依赖检查
# 10 · 部署与运维
# 10 · 部署与运维
## 1. 环境矩阵
| 环境 | 用途 | 规模 |
|------|------|------|
| **local** | 开发者本地 | Docker Compose 一键起 |
| **dev** | 集成测试、PR 预览 | 单节点 K8s / docker-compose |
| **staging** | 预发布、压测 | 2 节点 K8s,真实数据缩减版 |
| **prod** | 生产 | 多可用区 K8s + 完整灾备 |
## 2. Docker Compose(本地开发)
```yaml
# docker-compose.yml
version: "3.8"
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: yaoai123
MYSQL_DATABASE: yaoai
ports: ["13306:3306"]
volumes: ["mysql_data:/var/lib/mysql"]
redis:
# Redis Stack = Redis + RediSearch(向量)+ RedisJSON + RedisInsight
image: redis/redis-stack:7.2.0-v10
command: redis-stack-server --appendonly yes
ports: ["16379:6379", "18001:8001"] # 8001 是 RedisInsight 控制台
volumes: ["redis_data:/data"]
rabbitmq:
image: rabbitmq:3.13-management
ports: ["5672:5672", "15672:15672"]
volumes: ["rabbit_data:/var/lib/rabbitmq"]
# 对象存储:统一使用阿里云 OSS / 火山引擎 TOS,不在 docker-compose 内起
# 本地开发直连开发环境 OSS Bucket(yaoai-dev),通过 AK/SK 读写
# 未来若需完全离线开发,再按 TODO 加回 MinIO(S3 兼容)
# 注:Elasticsearch 暂不引入,MVP 用 MySQL FULLTEXT;M8 团队协作/资产库阶段再评估
api:
build: .
depends_on: [mysql, redis, rabbitmq]
environment:
SPRING_PROFILES_ACTIVE: local
MYSQL_HOST: mysql
REDIS_HOST: redis # 同一 Redis Stack 实例同时承载缓存 + 向量
RABBIT_HOST: rabbitmq
YAOAI_STORAGE_PROVIDER: oss # oss | tos(MinIO 作为未来 TODO)
YAOAI_VECTOR_PROVIDER: redis
OSS_ENDPOINT: ${OSS_ENDPOINT} # 生产:oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY: ${OSS_ACCESS_KEY}
OSS_SECRET_KEY: ${OSS_SECRET_KEY}
QWEN_API_KEY: ${QWEN_API_KEY}
ports: ["18080:8080"]
command: java -jar app.jar --spring.profiles.active=local,api
worker:
build: .
depends_on: [mysql, redis, rabbitmq]
environment: (同上)
command: java -jar app.jar --spring.profiles.active=local,worker
deploy:
replicas: 2
volumes:
mysql_data: {}
redis_data: {}
rabbit_data: {}
```
## 3. Dockerfile(多阶段构建)
```dockerfile
# 构建阶段
FROM eclipse-temurin:17-jdk-alpine AS builder
WORKDIR /build
COPY . .
RUN ./mvnw -B -DskipTests package -pl yaoai-bootstrap -am
# 运行阶段
FROM eclipse-temurin:17-jre-alpine
# 安装 FFmpeg(Assemble Worker 需要)
RUN apk add --no-cache ffmpeg
WORKDIR /app
COPY --from=builder /build/yaoai-bootstrap/target/yaoai-bootstrap-*.jar app.jar
COPY skills/ ./skills/
EXPOSE 8080
ENTRYPOINT ["java","-XX:+UseContainerSupport","-jar","app.jar"]
```
## 4. Kubernetes 部署(生产)
### 4.1 部署单元
```
Deployment: yaoai-api replicas=3 (HPA by CPU 70%)
Deployment: yaoai-worker-text replicas=2 (HPA by queue depth)
Deployment: yaoai-worker-image replicas=3 (HPA by queue depth)
Deployment: yaoai-worker-video replicas=2 (HPA by queue depth, GPU)
Deployment: yaoai-worker-voice replicas=2
Deployment: yaoai-worker-assemble replicas=1 (FFmpeg,CPU 密集)
CronJob: yaoai-scheduler (定时任务,单实例)
StatefulSet: mysql (或用云 RDS)
StatefulSet: redis-stack (或云 Tair / Redis Enterprise 带 RediSearch 模块)
StatefulSet: rabbitmq 集群
对象存储: 阿里云 OSS / 火山引擎 TOS(托管,不在 K8s 内)
Ingress: yaoai.com / api.yaoai.com
```
### 4.2 HPA 按队列深度扩缩
```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: yaoai-worker-video
spec:
minReplicas: 2
maxReplicas: 20
metrics:
- type: External
external:
metric:
name: rabbitmq_queue_depth
selector:
matchLabels:
queue: video.generate
target:
type: AverageValue
averageValue: "5" # 每 5 个任务扩 1 个 Pod
```
### 4.3 Secret 管理
- 用 ExternalSecrets + HashiCorp Vault 或 AWS Secrets Manager
- API Key、DB 密码、Sa-Token `token-name`/加密盐绝不进 Git
## 5. CI/CD
```
[ Push to main ]
GitHub Actions
├─ mvn test(单元 + 集成 with Testcontainers)
├─ ESLint / SonarQube 扫描
├─ Docker buildx → push to registry
├─ helm upgrade yaoai-staging
├─ 冒烟测试(Playwright)
[ Manual Approve ]
helm upgrade yaoai-prod (蓝绿 / 滚动)
```
## 6. 数据迁移
- **Flyway** 管理 schema,强制版本化
- 每次上线先跑 `V{n}__description.sql` 再发 app(新字段 + 默认值兼容旧代码)
- 大表 DDL 用 gh-ost / pt-online-schema-change 避免锁表
- 回滚方案:每个版本都有 `Undo_V{n}.sql`(可选)
## 7. 监控与告警
### 7.1 关键指标
| 类别 | 指标 | 告警阈值 |
|------|------|----------|
| API | p99 延迟 | > 2s |
| API | 5xx 率 | > 1% |
| MQ | 队列积压 | video > 50 / image > 100 |
| Worker | 失败率 | > 5% / 10min |
| DB | 慢查询 | > 1s 超过 10 条 /分钟 |
| AI | Provider 错误率 | > 10% |
| 业务 | 余额扣减异常 | 任何负数 |
| 业务 | 任务 stuck | processing 超过 30min |
### 7.2 告警通道
- 即时: 钉钉/企业微信 机器人
- 持续: PagerDuty / Opsgenie
- 日报: 每日 09:00 推送昨日核心指标
## 8. 备份与灾备
| 数据 | 策略 |
|------|------|
| MySQL | 每日全量 + binlog 增量,异地冷存 30 天 |
| Redis Stack(缓存 + 向量) | AOF 持久化 + 每日 RDB 快照;向量索引实例与缓存实例分开部署,向量实例优先保证持久化 |
| RabbitMQ | 队列持久化 + 定期导出定义 |
| OSS / TOS | 版本化 Bucket + 跨区域复制 + 生命周期归档 |
| 配置 | Git 版本化,不可变 |
RTO < 4h,RPO < 15min。
## 9. 安全基线
- [ ] HTTPS 全站(Let's Encrypt)
- [ ] WAF(Aliyun / Cloudflare)
- [ ] SQL 注入防护(参数化查询,禁 `@Query` 字符串拼接)
- [ ] XSS 防护(DTO 输出 escape)
- [ ] CSRF(Sa-Token 前后端分离通过请求头传 Token,天然免疫)
- [ ] 依赖扫描(Dependabot + Snyk)
- [ ] 敏感日志脱敏(统一 `LogRedactor`)
- [ ] API Key 加密存储(Jasypt + 主密钥放 KMS)
- [ ] 定期渗透测试(半年一次)
- [ ] 漏洞奖励计划(产品稳定后)
## 10. 运营手册(Runbook)片段
### 10.1 视频生成任务卡住
1. `kubectl logs -l app=worker-video --tail=200`
2.`tasks where status='PROCESSING' and heartbeat_at < NOW() - INTERVAL 5 MINUTE`
3. 查第三方 Provider 状态页
4. 临时措施: 手动 `markDead` → 重入队
5. 根因: 如果是 Provider 故障,切 fallback provider
### 10.2 余额异常
1.`user_balances``balance``frozen_amount` 为负的行
2. 对照 `balance_transactions` 重算
3. 临时措施: 冻结账户,人工补偿
4. 根因: 定位是否有竞态(没走乐观锁)
### 10.3 Worker OOM
1. `kubectl top pod`
2. 检查是否在单任务下载 1GB+ 视频到堆
3. 修复: 流式下载直接写 OSS,不全量加载
# 11 · 实施路线图与里程碑
# 11 · 实施路线图与里程碑
## 0. 总原则
- **单人/小团队起步**: 前 2 个月目标是把 MVP 跑通,而不是造全部功能
- **端到端优先**: 每个里程碑都要有可演示的用户价值,而不是只补基础设施
- **吸收 > 重造**: 有现成开源代码/提示词/skill 的地方直接搬,不要重新发明
- **前端保留原型**: `doc/html` 作为 UI 底稿,只做必要的接入层改造(加 TanStack Query + Zustand),不重写 UI
## 1. 里程碑总览
| 阶段 | 周期 | 目标 | 产出 |
|------|------|------|------|
| **M0 · 基建** | 第 1~2 周 | 工程骨架跑通 | 空 API + Swagger + Docker |
| **M1 · 认证与项目 CRUD** | 第 3~4 周 | 用户能登录、建项目 | Dashboard + NewProject 可用 |
| **M2 · 剧本解析流水线** | 第 5~7 周 | 上传剧本 → 出角色/分集 | 四步导入 UI + 异步任务 |
| **M3 · 资产生成** | 第 8~10 周 | 角色四视图 + 场景 + 道具 | Character/Scene/Props 可用 |
| **M4 · 分镜工作台** | 第 11~13 周 | AI 生成分镜 + 手动编辑 | StoryboardWorkspace 可用 |
| **M5 · 视觉生成** | 第 14~16 周 | 首尾帧 + 视频生成 | 单镜头视频可生成 |
| **M6 · 合成与导出** | 第 17~18 周 | FFmpeg 拼成完整集 | 可下载完整 MP4 |
| **M7 · 计费与 SaaS** | 第 19~21 周 | 余额、订阅、支付 | 真正 SaaS 化 |
| **M8 · 团队协作** | 第 22~24 周 | 多人协作、资产库、权限 | 企业功能 |
| **M9 · 上线与优化** | 第 25~26 周 | 压测、监控、灰度 | 生产发布 |
**总计约 6 个月**(1~2 名后端 + 1 名前端,MVP 可提前到 3 个月但砍 M7~M9)。
## 2. 阶段详解
### M0 · 基建(第 1~2 周)
**交付清单**:
- [ ] Maven 多模块骨架(按 05-后端架构设计.md 的模块划分)
- [ ] Spring Boot 3 + Spring AI Alibaba 依赖跑通
- [ ] Flyway 初始化 + 空表结构
- [ ] Docker Compose 一键启动(mysql + redis-stack + rabbitmq;对象存储直连开发环境 OSS Bucket)
- [ ] Actuator + Prometheus endpoint
- [ ] Swagger / OpenAPI 自动文档
- [ ] 统一异常处理 + ApiResponse<T> 包装
- [ ] Logback JSON + MDC(requestId)
- [ ] GitHub Actions: PR 跑 test + build
- [ ] 前端 `doc/html` 接入 TanStack Query + Zustand + API client
**完成标准**: `docker compose up` 后能访问 Swagger UI 并看到 1 个 `/ping` 接口。
### M1 · 认证与项目 CRUD(第 3~4 周)
- [ ] Sa-Token 1.39+(`sa-token-spring-boot3-starter` + `sa-token-redis-jackson`),Token 存 Redis
- [ ] `/auth/*` 全套(注册/登录/刷新/登出/me)
- [ ] IP 登录限流(Bucket4j + Redis)
- [ ] Tenant 自动创建(注册即建个人租户)
- [ ] `TenantContextFilter` + MyBatis-Plus `TenantLineInnerInterceptor`
- [ ] Projects 表 + CRUD API
- [ ] `/projects/stats` 统计接口
- [ ] 前端 Dashboard + NewProject 真实接入(去 mock)
- [ ] 前端 Auth Guard
**完成标准**: 用户注册→登录→创建一个空项目→在 Dashboard 看到它。
### M2 · 剧本解析流水线(第 5~7 周)
- [ ] 文件上传 + 阿里云 OSS / 火山引擎 TOS 接入(`StorageProvider` 抽象接口,MinIO 作为未来 TODO 备选)+ MediaObject 表
- [ ] 文本抽取(POI + PDFBox,支持 txt/doc/docx/pdf/md)
- [ ] RabbitMQ 接入 + Task 表 + TaskService + 通用 Worker 模板
- [ ] Spring AI Alibaba 通义千问接入(chat/embedding)
- [ ] `SkillLoader` + 从 Toonflow 拷贝的 `script_execution_skeleton.md`
- [ ] 实现 Executor: `ScriptParseExecutor` + `CharacterExtractExecutor` + `EpisodeSplitExecutor`
- [ ] 四步导入 API + SSE 进度
- [ ] 前端 NewProject 的四步向导(借鉴 AIComicBuilder UX)
- [ ] Character/Scene/Props 表 + 基础 CRUD
**完成标准**: 上传一篇 5000 字的小说,能自动出角色列表和分集列表,全程 SSE 推进度。
### M3 · 资产生成(第 8~10 周)
- [ ] `AiProvider` 接口 + `WanxImageProvider`(通义万相文生图)
- [ ] 兜底 Provider: OpenAI DALL-E 或 Gemini Imagen(可配置)
- [ ] 从 Toonflow 拷 `art_skills/2d_anime/` 全套艺术手册
- [ ] `CharacterTurnaroundExecutor`(四视图,借鉴 AIComicBuilder)
- [ ] `SceneImageExecutor` / `PropImageExecutor`
- [ ] `shot_assets` 表 + 资产版本管理
- [ ] 前端 CharacterGeneration / SceneGeneration / PropsGeneration 页接入
- [ ] 参考图上传 + 注入到生成请求
- [ ] 初级计费(SOFT 模式)
**完成标准**: 项目里每个角色都能一键生成四视图,前端能看到并支持重新生成。
### M4 · 分镜工作台(第 11~13 周)
- [ ] `Storyboard` 表完整字段(参考 AIComicBuilder shot 结构)
- [ ] `DirectorPlanExecutor`(借鉴 Toonflow 导演手册)
- [ ] `StoryboardSplitExecutor`(借鉴 AIComicBuilder shot-split.ts)
- [ ] `SupervisionAgent` 雏形(可选开关)
- [ ] Storyboard CRUD + 批量生成 + 排序 + 复制
- [ ] 前端 StoryboardWorkspace 完整接入
- [ ] 拖拽排序 API(批量 sequence 更新)
- [ ] AI 合规检查 API(调 LLM 做敏感词+内容审核)
**完成标准**: 某集的全部分镜可一键 AI 生成,然后用户可手动编辑每个镜头。
### M5 · 视觉生成(第 14~16 周)
- [ ] `FrameGenerateExecutor`(首尾帧,参考图注入)
- [ ] `VideoProvider` 接口 + `WanxVideoProvider`
- [ ] 备选: SeedanceProvider / KlingProvider
- [ ] `VideoGenerateExecutor`(异步提交 + 轮询)
- [ ] 任务心跳机制上线
- [ ] 失败重试 + Provider fallback
- [ ] 前端 StoryboardWorkspace 右侧预览接入
- [ ] 前端 VideoGeneration 页面接入(分镜网格状态)
**完成标准**: 选中一个分镜,点"生成视频",10 分钟内能看到 5~8 秒短视频,参考图约束生效(角色相对一致)。
### M6 · 合成与导出(第 17~18 周)
- [ ] FFmpeg 调度封装(`FfmpegService`)
- [ ] `AssembleExecutor`:拼接 + 转场 + 字幕 + BGM
- [ ] TTS 接入(Qwen-TTS):`VoiceTtsExecutor`
- [ ] 全集合成 API + 下载链接签名
- [ ] 水印烧录(全局设置)
**完成标准**: 一集的所有分镜生成完成后,点"合成",得到一个带字幕、配音、BGM 的完整 MP4。
### M7 · 计费与 SaaS(第 19~21 周)
- [ ] `user_balances` / `balance_freezes` / `balance_transactions` 三表上线
- [ ] `BillingService`(prepare/settle/rollback)集成到所有任务
- [ ] BILLING_MODE=STRICT 切换
- [ ] 订阅表 + 套餐定义
- [ ] 支付宝 + 微信支付接入(Webhook)
- [ ] Stripe(海外)
- [ ] 充值页面 + 订阅页面
- [ ] `/usage/*` 统计 API
- [ ] 前端 UsageManagement 接入
- [ ] 定时任务:过期冻结清理、订阅续费
**完成标准**: 用户能充值、消费会扣积分、余额不足被拦截、流水可查。
### M8 · 团队协作与资产库(第 22~24 周)
- [ ] 项目成员邀请 + RBAC 4 角色
- [ ] 企业员工/小组(`/team` 页)
- [ ] 资产库:个人 + 团队(`global_assets` 表)
- [ ] 资产收藏、复用到项目
- [ ] 通知系统(`notifications` 表 + WebPush 可选)
- [ ] 审计日志(`audit_logs` + `@Audited` 切面)
- [ ] 前端 TeamManagement / AssetsManagement 接入
- [ ] 提示词模板的项目级覆盖 UI
**完成标准**: 团队成员可被邀请到项目,权限隔离正确,资产能跨项目复用。
### M9 · 上线与优化(第 25~26 周)
- [ ] 压测(JMeter/k6)
- [ ] 性能优化(慢 SQL、N+1、缓存)
- [ ] Prometheus + Grafana 监控面板
- [ ] SkyWalking 链路追踪
- [ ] 告警接入钉钉
- [ ] K8s Helm chart
- [ ] 灰度发布方案
- [ ] 运营后台基础(用户/订单查询)
- [ ] 用户文档、API 文档
- [ ] 上线!
## 3. 风险与缓解
| 风险 | 影响 | 缓解 |
|------|------|------|
| 通义万相/视频模型配额不足 | 阻塞 M5 | 并行接入 2 家 Provider,签商务合同 |
| 角色一致性效果不达标 | 用户体验差 | 四视图 + 强制命名 + 必要时引入 LoRA 微调 |
| 视频生成单价过高 | 毛利低 | 精确计费、短视频优先、非关键镜头降级 |
| FFmpeg 性能瓶颈 | 合成慢 | Worker 独立扩容 + GPU 编码 |
| 多租户数据泄漏 | 法律风险 | 代码卫士强制 Filter + 自动化测试 |
| Agent 幻觉导致低质产出 | 用户不满 | 监督层 + 用户最终审核 |
## 4. 后续(v2+)想做的事
- 移动端 App(React Native)
- 实时协作编辑分镜(CRDT / Yjs + WebSocket)
- 模型市场(用户上传 LoRA)
- 资产市场(付费分享角色/场景)
- AI 审核合规自动化(全量扫敏感内容)
- 多语言配音(英日韩)
- 真人参考视频驱动(动作迁移)
- 导出到剪映/PR 的 XML 工程
- 开放 API(面向开发者)
# 12 · 前端原型复盘与流程验证
# 12 · 前端原型复盘与流程验证
> 最近一次复盘日期: 2026-04-16
## 1. 端到端用户流程
```
Dashboard (/)
│ 点击"新建项目"
NewProject (/new-project)
│ 上传剧本(.txt/.doc/.docx/.pdf/.md) + 选纵横比/分辨率/风格
│ 点击"创建项目"
StyleSelection (/project/:id/style)
│ 选择6种艺术风格之一
│ 点击"继续"
OutlineGeneration (/project/:id/outline)
│ AI生成大纲(三幕结构+篇章) → 可编辑
│ 点击"继续"
EpisodeGeneration (/project/:id/episodes)
│ AI生成分集列表(80集网格)
│ 点击"继续生成角色设定"
CharacterGeneration (/project/:id/characters)
│ 角色卡 + 四视图 + 服装变体(独立角色形态)
│ 可切Tab到场景/道具
│ 点击"继续生成场景设定"
SceneGeneration (/project/:id/scenes)
│ 场景卡 + AI生成场景图
│ 点击"继续生成道具设定"
PropsGeneration (/project/:id/props)
│ 道具卡 + AI生成道具图
│ 点击"生成分镜"
StoryboardWorkspace (/project/:id/storyboard/:episodeId)
│ 左侧: 分集选择器
│ 中间: 分镜列表(分镜信息区 + 视频生成区)
│ 分镜信息区: 描述、出镜角色(增删+音色)、场景(更换)、道具(增删)
│ 视频生成区: 单一提示词编辑框 + 智能体选择器 + 积分预估
│ 右侧: 视频预览
│ 通过顶部导航Tab切换到视频页
VideoGeneration (/project/:id/video)
│ 视频播放器 + 分镜时间线 + 状态网格
│ 可返回分镜编辑
ProjectDetail (/project/:id)
│ 总览: 大纲/角色/分集/分镜/视频各Tab
```
## 2. 页面清单(17个)
| # | 页面 | 路由 | UI完整 | 流程衔接 | 备注 |
|---|---|---|---|---|---|
| 1 | Dashboard | `/` | ✅ | ✅→NewProject/ProjectDetail | 项目网格+统计+搜索 |
| 2 | NewProject | `/new-project` | ✅ | ✅→Style | 上传剧本+三选项 |
| 3 | StyleSelection | `/project/:id/style` | ✅ | ✅→Outline | 6种预设风格 |
| 4 | OutlineGeneration | `/project/:id/outline` | ✅ | ✅→Episodes | AI生成+可编辑 |
| 5 | EpisodeGeneration | `/project/:id/episodes` | ✅ | ✅→Characters | 分集网格 |
| 6 | CharacterGeneration | `/project/:id/characters` | ✅ | ✅→Scenes | 角色+服装变体 |
| 7 | SceneGeneration | `/project/:id/scenes` | ✅ | ✅→Props | 场景卡+AI生成 |
| 8 | PropsGeneration | `/project/:id/props` | ✅ | ✅→Storyboard | 道具卡+AI生成 |
| 9 | StoryboardWorkspace | `/project/:id/storyboard/:eid` | ✅ | ✅ TopNav | 分镜编辑+视频生成 |
| 10 | VideoGeneration | `/project/:id/video` | ✅ | ✅ 可返回 | 播放器+时间线 |
| 11 | ProjectDetail | `/project/:id` | ✅ | ✅ 多Tab | 项目总览 |
| 12 | ProjectSettings | `/project/:id/settings` | ✅ | ✅ | 团队成员+权限 |
| 13 | TeamManagement | `/team` | ✅ | ✅ | 企业团队管理 |
| 14 | UsageManagement | `/usage` | ✅ | ✅ | 用量统计+图表 |
| 15 | AssetsManagement | `/assets/*` | ✅ | ✅ | 个人/团队资产库 |
| 16 | ModelSettings | `/models` | ✅ | ✅ | 语言/图片/视频模型配置 |
| 17 | AIWatermarkSettings | `/settings` | ✅ | ✅ | 水印设置+预览 |
## 3. 设计文档对齐度
| 设计文档要求 | 原型实现 | 状态 |
|---|---|---|
| 单一提示词编辑框 | StoryboardWorkspace detailedDescription textarea | ✅ |
| 智能体选择器(通义万相/Seedance/Kling) | 底部工具栏 select | ✅ |
| 积分预估(按智能体+时长) | 生成按钮显示消耗积分 | ✅ |
| 出镜角色增删+音色选择 | 分镜信息区 characters + voice select | ✅ |
| 场景可更换 | 场景卡片 + "更换"按钮 | ✅ |
| 道具可增删 | 道具网格 + "添加"按钮 | ✅ |
| 服装变体机制 | CharacterGeneration 变体列表+创建弹窗 | ✅ |
| 角色变体格式("林若曦·白色晚礼服") | Storyboard 角色下拉已联动 | ✅ |
| 模型配置页(三类独立) | ModelSettings 语言/图片/视频 | ✅ |
| 文件上传支持 .md | NewProject accept 已添加 | ✅ |
| 模型名使用真实名称 | 全部替换为通义万相/Seedance/Kling | ✅ |
| 登录/注册页 | 缺失(M1阶段做) | ⏳ |
| 充值/订阅页 | 缺失(M7阶段做) | ⏳ |
## 4. 已修复问题
| 问题 | 修复内容 |
|---|---|
| PropsGeneration "生成分镜"按钮无导航 | 添加 navigate 跳转 storyboard/1 |
| NewProject 不支持 .md 格式 | accept 添加 .md |
| 多页面模型名使用假数据 | SceneGeneration/PropsGeneration/ProjectDetail 替换为真实模型名 |
| 角色页无服装变体机制 | 新增 CharacterVariant 数据结构+创建变体UI+管理交互 |
| 分镜页角色选择无变体联动 | 角色下拉改为"角色·变体"格式 |
| 缺少模型配置页 | 新增 ModelSettings 页(参考 AIComicBuilder) |
## 5. 技术债务(原型阶段可接受)
- 所有数据为 mock,无真实 API 调用(M0 接入)
- 拖拽排序仅引入 react-dnd 依赖,未实现拖拽逻辑
- 无表单验证提示(仅部分按钮 disabled)
- 无错误边界/Loading skeleton
- 状态管理为纯 useState,生产需迁移 Zustand + TanStack Query
- 无登录/注册页(M1)、无充值/订阅页(M7)
# 13 · 多 Agent 功能复盘与落地方案
# 13 · 多 Agent 功能复盘与落地方案
> 复盘日期: 2026-04-16
## 1. 三项目多 Agent 实现方式对比
### 1.1 Toonflow — 三层 Agent 架构(最成熟)
**架构模式**: Decision-Execution-Supervision (DES)
```
用户 ←→ 决策层 Agent (唯一入口)
├── 执行层 SubAgent × N (每个是一个 @Tool)
│ ├── run_sub_agent_storySkeleton (故事骨架)
│ ├── run_sub_agent_adaptationStrategy (改编策略)
│ ├── run_sub_agent_script (剧本编写)
│ ├── run_sub_agent_derive_assets (衍生资产分析)
│ ├── run_sub_agent_generate_assets (衍生资产生成)
│ ├── run_sub_agent_director_plan (导演规划)
│ ├── run_sub_agent_storyboard_table (分镜表)
│ ├── run_sub_agent_storyboard_panel (分镜面板写入)
│ └── run_sub_agent_storyboard_gen (分镜图生成)
└── 监督层 SubAgent × 1
└── run_supervision_agent / run_sub_agent_supervision
```
**核心实现要点**:
| 要素 | 实现方式 | 文件位置 |
|------|---------|---------|
| 决策入口 | `decisionAI()` 函数, Vercel AI SDK `stream()` | `src/agents/{script,production}Agent/index.ts` |
| SubAgent 创建 | `createSubAgent()` — 每个 executor 是一个 `tool()` | 同上, `tool()` from `ai` |
| Agent 通信 | 父 Agent 调用 `tool → runAgent()` → 子 Agent 独立 LLM 调用 | `runAgent()` 内部函数 |
| Skill 加载 | `fs.readFile(skill.md)` → system prompt | `src/utils/agent/skillsTools.ts` |
| 向量记忆 | 本地 ONNX `all-MiniLM-L6-v2` + cosine similarity | `src/utils/agent/memory.ts` |
| 记忆三层 | shortTerm(5条) + summaries(10条) + RAG(3条) | `Memory.get()` |
| 自动摘要 | 每 3 条 message 触发 AI 压缩 | `Memory.add()` |
| 深度检索 | 向量搜索 summary → AI 判断相关性 → 展开原始 messages | `deepRetrieve()` |
| 实时推送 | Socket.IO `ResTool` 流式消息 | `src/socket/resTool.ts` |
**关键设计亮点**:
1. **决策层不执行、不读数据** — 严格的职责分离, 避免 Agent 越权
2. **子 Agent 作为 Tool** — 用 Vercel AI SDK 的 `tool()` 包装子 Agent, 决策层 LLM 自主决定何时调用
3. **监督层独立审核** — 生成 A/B/C/D 评分报告, 用户确认后才进入下一阶段
4. **Skill 热加载**`activate_skill` 工具按需加载提示词, 不一次性全塞进 context
5. **159 个 Markdown Skill 文件** — 8 种艺术风格 x (角色/场景/道具/视频/导演) + 剧本/制作流水线
**两个独立 Agent 系统**:
| Agent | 入口 | 执行层子 Agent | 流水线 |
|-------|------|---------------|--------|
| ScriptAgent | `scriptAgent/index.ts` | storySkeleton, adaptationStrategy, script | 初始化 → 故事骨架 → 改编策略 → 剧本编写 |
| ProductionAgent | `productionAgent/index.ts` | derive_assets, generate_assets, director_plan, storyboard_table, storyboard_panel, storyboard_gen | 衍生资产 → 导演规划 → 分镜表 → 面板写入 → 图生成 |
**决策层 Skill 指令核心规范** (来自 `script_agent_decision.md` / `production_agent_decision.md`):
- 决策层不读取工作区数据, 所有工作区读取由执行层自行完成
- SubAgent 失败时决策层不得接管, 必须向用户汇报失败并终止
- 派发给执行层的指令正文严格不超过 100 字
- 阶段必须串行, 审核与执行串行(先执行后审核)
- 审核结果展示后必须等待用户回复, 不可自行派发新任务
**监督层评分标准** (来自 `script_agent_supervision.md`):
| 评分 | 严重问题 | 中等问题 | 处置 |
|------|----------|----------|------|
| A — 可直接使用 | 0 | ≤2 | 直接进入下一阶段 |
| B — 小修后可用 | 0 | ≤5 | 小问题, 是否修复或继续? |
| C — 需较大修改 | 1-2 | 不限 | 建议修复, 用户选择修复项 |
| D — 建议重做 | ≥3 | 不限 | 建议重做此阶段 |
**记忆系统实现细节** (来自 `src/utils/agent/memory.ts`):
```
Memory 类
├── add(role, content) → 存消息 + embedding
│ └── 每 3 条未总结消息 → generateSummary() → 存 summary
├── get(text) → 返回 {shortTerm, summaries, rag}
│ ├── shortTerm: 最近 5 条未总结消息
│ ├── summaries: 最近 10 条摘要
│ └── rag: 向量搜索所有消息, 返回 top-3
├── deepRetrieve(keyword) → 向量搜索 summary → AI 判相关性 → 展开原始消息
└── getTools() → 暴露为 LLM 可调用的 tool
```
隔离键: `${projectId}:${agentType}${episodesId?:...}` — 按项目/Agent 类型/集数隔离
---
### 1.2 AIComicBuilder — 任务队列 + 线性管道(非 Agent 架构)
**架构模式**: 任务队列驱动的确定性管道
```
用户点击 → Server Action (generateXxx)
├── buildPrompt(模板 + 插槽)
├── callAI(OpenAI/Gemini)
├── parseResponse(JSON/XML)
├── saveToDB(Drizzle)
└── 返回结果
```
AIComicBuilder **没有 Agent 架构**, 而是用 Next.js Server Actions 实现线性工作流。
**核心实现要点**:
| 要素 | 实现方式 | 文件位置 |
|------|---------|---------|
| 任务队列 | SQLite 持久化 + 2 秒轮询 dequeue | `src/lib/task-queue/{queue.ts, worker.ts}` |
| 8 个管道处理器 | script_outline → parse → character_extract → shot_split → frame → video → assemble | `src/lib/pipeline/*.ts` |
| 自动链式触发 | parse 完成后自动入队 character_extract | `src/lib/pipeline/script-parse.ts:39-50` |
| 批量并发 | `Promise.allSettled()` 批量生成帧/视频 | `src/app/api/projects/[id]/generate/route.ts` |
| 版本控制 | `storyboardVersions` 表 + `shotAssets.assetVersion + isActive` | `src/lib/shot-asset-utils.ts` |
| 提示词注册 | 动态插槽注入, per user/project 覆盖, 三级优先级 | `src/lib/ai/prompts/registry.ts` |
| Provider 路由 | 工厂模式分发到 OpenAI/Gemini/Kling/Veo/SeedDance | `src/lib/ai/provider-factory.ts` |
| 原子 dequeue | SQL 子查询防止多实例竞争 | `src/lib/task-queue/worker.ts` |
**12 个提示词分类**:
| 类别 | 提示词 |
|------|--------|
| 剧本 | script_generate, script_parse, script_split |
| 角色 | character_extract, import_character_extract, character_image |
| 分镜 | shot_split |
| 帧生成 | frame_generate_first, frame_generate_last, scene_frame_generate |
| 视频 | video_generate, ref_video_prompt_generate |
**三级覆盖**: 项目级覆盖 > 全局级覆盖 > 代码默认值, 且支持版本历史 + 一键回滚。
**与 Agent 架构的本质差异**: 没有 LLM 自主决策能力, 每一步都是用户手动触发或前一步自动触发, AI 只在单步内起作用。但其**版本控制和批量操作**是三个项目中最成熟的。
---
### 1.3 waoowaoo — 任务队列 + 步骤编排器(准 Agent)
**架构模式**: BullMQ Queue Workers + Step-based Orchestrator
```
API → TaskSubmitter
├── 预检(计费、去重、幂等)
├── 入队(4 个专用队列)
└── Worker 消费
├── withTaskLifecycle() 包装
├── withUserConcurrencyGate() 限流
├── 调用 AI Provider
├── 进度上报 (reportTaskProgress)
└── 计费结算
Orchestrator (story-to-script / script-to-storyboard)
├── Step 1: character analysis
├── Step 2: location analysis
├── Step 3: prop analysis
└── Step 4: screenplay generation
(依赖图 + 并行键 + 重试恢复)
```
**核心实现要点**:
| 要素 | 实现方式 | 文件位置 |
|------|---------|---------|
| 4 个 BullMQ 队列 | image / video / voice / text | `src/lib/task/queues.ts` |
| 任务提交 | 归一化 payload + 计费预检 + 去重 + 入队 | `src/lib/task/submitter.ts` |
| Worker 模式 | 按 TaskType 路由到 handler, composition 模式 | `src/lib/workers/{image,video,voice,text}.worker.ts` |
| 任务生命周期 | `withTaskLifecycle()` 统一封装 | `src/lib/workers/shared.ts` |
| 并发控制 | `withUserConcurrencyGate()` 用户级限流 | 同上 |
| 计费 | freeze → settle/rollback 三步原子 | `src/lib/billing/service.ts` |
| 编排器 | step-based, 依赖图解析, retryStepKey 恢复 | `src/lib/run-runtime/*.ts` |
| 进度推送 | SSE stream, Run 事件桥接 | `src/lib/workers/shared.ts` |
| 重试策略 | 指数退避 `2s * 2^(n-1)`, 非可重试错误直接终止 | Worker 配置 |
**默认队列配置**: 5 次重试, 指数退避(2s 基础延迟), 自动清理 500 条完成/失败任务。
**编排器特性**:
- 多步骤工作流: character → location → prop → screenplay
- 并行执行: `mapWithConcurrency` 批量匹配
- 依赖解析: `dependsOn`, `blockedBy`, `parallelKey`, `groupId`
- 失败恢复: `retryStepKey` 从失败步骤重启
- LLM 流式回调: chunk 级别可观测性
- Run 租约协调: 防止并发修改
- 推理努力控制: minimal/low/medium/high
**计费模式**:
| 模式 | 行为 |
|------|------|
| OFF | 完全跳过计费(开发/开源) |
| SHADOW | 只记录不拦截(观察期) |
| ENFORCE | 余额不足拒绝任务(生产) |
**与 Toonflow Agent 的核心差异**: waoowaoo 的 "Agent" 更像是**状态机驱动的工作流**, 没有 LLM 自主决策能力。编排器按预定义的 step 序列执行, 不会根据中间结果动态调整路径。但其**任务队列、计费、并发控制**是三个项目中最企业级的。
---
## 2. 三项目本质区别总结
```
Toonflow: LLM 自主决策 → 调用 Tool(子Agent) → 监督审核 → 人类确认
↑ 真正的 Agent, LLM 控制执行流程
AIComicBuilder: 用户点击 → 固定管道(A→B→C→D) → 批量并发 → 版本管理
↑ 确定性管道, 无 LLM 决策, 但版本控制和批量操作最成熟
waoowaoo: API → BullMQ 队列 → Worker 消费 → Orchestrator step 编排
↑ 准 Agent, 有依赖图和步骤编排, 但步骤序列预定义
```
| 维度 | Toonflow | AIComicBuilder | waoowaoo |
|------|----------|----------------|----------|
| LLM 自主决策 | **有**(决策层选择调用哪个子 Agent) | 无 | 无 |
| 执行流程动态性 | **高**(LLM 根据中间结果调整路径) | 低(固定管道) | 中(预定义 step 但有依赖图) |
| 监督审核 | **有**(独立监督层 + A/B/C/D 评分) | 无 | 无 |
| 人类在环 | **有**(审核后等待用户确认) | 有(手动触发每步) | 部分(提交后全自动) |
| 任务队列 | 无(同步调用) | SQLite 轮询 | **BullMQ + Redis** |
| 计费集成 | 无 | 无 | **有**(余额冻结) |
| 并发控制 | 无 | `Promise.allSettled` | **用户级 Gate** |
| 版本控制 | 无 | **有**(assetVersion + isActive) | 有(MediaObject 哈希去重) |
| 断点续跑 | 无 | 有(任务状态持久化) | 有(retryStepKey) |
| 记忆系统 | **有**(向量 + 摘要 + RAG) | 无 | 无 |
| Skill 外化 | **有**(159 个 .md 文件) | 12 个提示词 | 无 |
---
## 3. 我们项目的多 Agent 落地方案 (Java + Spring AI Alibaba)
### 3.1 三层 Agent 的 Java 落地(对标 Toonflow)
Toonflow 用 Vercel AI SDK 的 `tool()` 把子 Agent 变成决策层可调用的工具。Spring AI Alibaba 用 `@Tool` 注解实现完全等价的模式:
```
┌─────────────────────────┐
│ ScriptDecisionAgent │
│ @Tool run_skeleton │
│ @Tool run_adaptation │
│ @Tool run_script │
│ @Tool run_supervision │
└────────┬────────────────┘
│ ChatClient + tools(this)
│ LLM 自主选择调用哪个 @Tool
┌──────────────────────────────┐
│ SkeletonExecutor │
│ loadSkill("skeleton.md") │
│ chatClient.prompt(...) │
│ supervisionAgent.review() │ ← 可选
│ return result │
└──────────────────────────────┘
```
**关键差异与改进**:
| Toonflow 方式 | 我们的 Java 落地 | 原因 |
|---|---|---|
| 子 Agent 在同一进程同步调用 | 重型任务走 MQ 异步(图/视频), 轻型任务(文本 Agent)同步 | 避免 HTTP 超时, 支持水平扩展 |
| Socket.IO 推送 | SSE(Spring WebFlux `Flux<ServerSentEvent>`) | 设计文档已确定, 更简单 |
| 本地 ONNX 向量化 | Redis Stack RediSearch + 通义 embedding | 分布式, Spring AI `RedisVectorStore` |
| 每次全量搜索 memory | `VectorStore.similaritySearch` + FilterExpression | Spring AI 原生支持 |
| 无 GraphRun/Step 追踪 | **必须加上**`graph_runs` + `graph_steps` 表 | Toonflow 的最大缺失 |
### 3.2 我们需要补上的关键能力(三项目均缺失或部分缺失)
| 能力 | 说明 | 参考来源 | 对应模块 |
|---|---|---|---|
| **GraphRun/GraphStep 持久化** | 每次 Agent 流程创建 Run, 每步创建 Step, 支持断点续跑 + 可视化回放 | 设计文档 08-6.1 | `yaoai-agent` |
| **任务队列化** | 图/视频/TTS 必须走 RabbitMQ, 不能在 Agent 内同步调用 | waoowaoo 模式 | `yaoai-task` |
| **计费集成** | 每个 AI 调用前冻结余额, 完成后结算 | waoowaoo 模式 | `yaoai-billing` |
| **多租户隔离** | Agent 记忆按 `tenantId + projectId` 隔离 | 自研设计 | `yaoai-agent` |
| **Skill 分级覆盖** | 项目级 > 租户级 > 全局级 > 文件默认 | AIComicBuilder | `yaoai-ai-core` |
| **并发控制** | 同一用户最多 N 个并发 Agent 流程 | waoowaoo Gate | `yaoai-task` |
| **任务自动链式触发** | Worker 完成后自动发布下一步消息, 确定性管道不回 Agent | AIComicBuilder | `yaoai-pipeline` |
| **批量并发提交** | 首尾帧和视频生成批量并发提交到 MQ | AIComicBuilder `allSettled` | `yaoai-pipeline` |
### 3.3 Executor 执行方式分类
| 类型 | Executor | 执行方式 | 原因 |
|------|----------|---------|------|
| **同步文本** | SkeletonExecutor | Agent 内 ChatClient 调用 | 纯文本, 秒级响应 |
| **同步文本** | AdaptationExecutor | 同上 | |
| **同步文本** | ScriptExecutor | 同上 | |
| **同步文本** | DeriveAssetExecutor | 同上 | |
| **同步文本** | DirectorPlanExecutor | 同上 | |
| **同步文本** | StoryboardTableExecutor | 同上 | |
| **同步文本** | StoryboardPanelExecutor | 同上 | |
| **异步队列** | AssetImageExecutor | 提交 MQ `image.generate` | 分钟级, 需重试 |
| **异步队列** | FrameGenerateExecutor | 提交 MQ `image.generate` | 同上 |
| **异步队列** | VideoGenerateExecutor | 提交 MQ `video.generate` | 10+ 分钟, 需轮询 |
| **异步队列** | VoiceTtsExecutor | 提交 MQ `voice.tts` | 秒级但量大 |
| **异步队列** | AssembleExecutor | 提交 MQ `video.assemble` | FFmpeg 耗时 |
### 3.4 核心流程伪代码
```java
@Service
public class ProductionAgentOrchestrator {
public GraphRun start(Long projectId, String userGoal) {
// 1. 创建 GraphRun
GraphRun run = graphRunRepo.create(projectId, "PRODUCTION", userGoal);
// 2. 决策层: LLM 分析意图, 返回执行计划
GraphStep decisionStep = step(run, "decision", () -> {
String skill = skillLoader.load("production_agent_decision");
MemoryContext mem = memoryService.retrieve(projectId, userGoal);
return chatClient.prompt()
.system(skill + mem.asPromptSuffix())
.user(userGoal)
.tools(executorTools) // @Tool 包装的各 Executor
.call();
});
// 3. 决策层 LLM 会自主调用 @Tool, 每次调用创建一个 GraphStep
// 同步 Executor 直接返回结果
// 异步 Executor 提交 MQ 任务, 返回 taskId
// 4. 监督层审核(决策层 LLM 自动触发 run_supervision)
// 返回 A/B/C/D 评分报告
// 5. SSE 推送进度给前端
sseService.publish(projectId, "agent.step", decisionStep);
return run;
}
}
```
### 3.5 Skill 加载与三级覆盖
```java
@Service
public class SkillLoader {
// 文件系统: classpath:skills/ + 外部 config 目录
// 数据库: prompt_templates 表
public String loadRendered(String key, Long tenantId, Long projectId, Map<String,String> vars) {
// 1. 查 prompt_templates 表 scope=PROJECT
// 2. 查 scope=TENANT
// 3. 查 scope=GLOBAL
// 4. fallback 到文件系统(Toonflow 的 .md 文件)
// 5. Mustache 模板渲染(插入角色、场景、风格等变量)
}
}
```
优先级链: **项目级 DB 覆盖 > 租户级 DB > 全局 DB > 文件默认值**
热更新: 文件变更监听 + Redis pub/sub 通知所有节点失效缓存。
### 3.6 Agent 记忆系统(Redis Stack 版)
```java
@Service
public class AgentMemoryService {
@Autowired VectorStore vectorStore; // RedisVectorStore
// 对标 Toonflow Memory.add()
public void add(String projectId, String role, String content) {
vectorStore.add(List.of(new Document(content, Map.of(
"project_id", projectId,
"agent_type", "script",
"role", role,
"created_at", System.currentTimeMillis()
))));
// 定期摘要压缩: 超过 100 条触发
}
// 对标 Toonflow Memory.get()
public MemoryContext retrieve(String projectId, String query) {
var results = vectorStore.similaritySearch(
SearchRequest.query(query).withTopK(3)
.withFilterExpression("project_id == '" + projectId + "'")
);
return new MemoryContext(results, shortTerm, summaries);
}
// 对标 Toonflow Memory.deepRetrieve()
public List<Document> deepRetrieve(String projectId, String keyword) {
// 1. 向量搜索 summary 类型文档
// 2. AI 判断相关性
// 3. 展开 relatedMessageIds 查原始消息
}
}
```
---
## 4. 吸收策略总结
### 4.1 按来源分类
| 吸收维度 | 来源项目 | 我们的对应模块 |
|----------|---------|--------------|
| 三层 Agent 架构(DES) | **Toonflow** | `yaoai-agent` |
| 决策/执行/监督 Skill 指令体系 | **Toonflow** | `yaoai-agent` + `skills/` |
| Agent 向量记忆 + 自动摘要 | **Toonflow** | `yaoai-agent` (Redis Stack) |
| 8 种艺术风格手册 | **Toonflow** | `skills/art_skills/` |
| 提示词版本管理 + 三级覆盖 | **AIComicBuilder** | `yaoai-ai-core` |
| 资产版本控制(assetVersion + isActive) | **AIComicBuilder** | `yaoai-domain` |
| 任务自动链式触发 | **AIComicBuilder** | `yaoai-pipeline` |
| 批量并发(allSettled) | **AIComicBuilder** | `yaoai-pipeline` |
| BullMQ → RabbitMQ 任务队列 | **waoowaoo** | `yaoai-task` |
| 余额冻结计费 | **waoowaoo** | `yaoai-billing` |
| 用户级并发控制 Gate | **waoowaoo** | `yaoai-task` |
| 幂等性(idempotencyKey) | **waoowaoo** | `yaoai-task` |
| GraphRun/GraphStep 持久化 | **自研**(三项目均缺失) | `yaoai-agent` |
### 4.2 按优先级排列
| 优先级 | 功能 | 来源 | 里程碑 |
|--------|------|------|--------|
| P0 | ScriptAgent 三层架构(决策+执行+监督) | Toonflow | M2 |
| P0 | ProductionAgent 三层架构 | Toonflow | M3 |
| P0 | Skill 文件外化 + 热加载 | Toonflow | M1 |
| P0 | 异步任务队列 (RabbitMQ) | waoowaoo | M1 |
| P0 | 余额冻结计费 | waoowaoo | M1 |
| P1 | GraphRun/GraphStep 持久化 | 自研 | M2 |
| P1 | Agent 向量记忆 (Redis Stack) | Toonflow→Redis | M2 |
| P1 | Skill 分级覆盖 + 版本历史 | AIComicBuilder | M3 |
| P1 | 监督层 A/B/C/D 评分体系 | Toonflow | M2 |
| P1 | 资产版本控制 | AIComicBuilder | M2 |
| P2 | 记忆自动摘要压缩 | Toonflow | M4 |
| P2 | 深度记忆检索 (deepRetrieve) | Toonflow | M4 |
| P2 | 任务自动链式触发 | AIComicBuilder | M3 |
| P2 | 断点续跑(GraphStep 恢复) | 自研 | M5 |
---
## 5. 关键结论
1. **Toonflow 的"子 Agent 作为 Tool"模式已被验证可行** — Spring AI Alibaba 的 `@Tool` + `ChatClient` 可以完全复刻, 且 Java 的类型安全和 Spring 生态使代码更健壮
2. **三个项目都缺少 GraphRun/GraphStep 持久化** — 这是我们的差异化优势, 支持断点续跑和可视化回放
3. **确定性管道不需要经过 Agent 决策** — 参考 AIComicBuilder 的链式触发, 像 "parse → extract → split" 这种确定性流程直接在 Worker 间链式触发, 比回到 Agent 决策层更高效
4. **计费必须与任务系统深度耦合** — 参考 waoowaoo 的 freeze/settle/rollback 模式, 每个 MQ 任务都必须带 freezeId
5. **Skill 热加载是关键竞争力** — 159 个 Markdown 文件的 Skill 库 + 数据库覆盖 + 热更新, 让非技术用户也能调优 AI 行为
---
*生成时间: 2026-04-16*
# 14 · 前端原型截图复盘与最终确认
# 14 · 前端原型截图复盘与最终确认
> 复盘日期: 2026-04-16
> 截图来源: `doc/pic/pic(1).png` ~ `pic(34).png` (原型 Demo 实际截图)
## 1. 截图页面映射表
| # | 截图 | 页面/功能 | 对应设计文档路由 |
|---|------|---------|-------------|
| 1 | pic(1) | **新建项目** — 上传剧本 + 16:9/720p/2D日漫 选择器 + "创建短剧项目" | `/new-project` |
| 7 | pic(7) | **新建项目 — 风格选择下拉** (2D日漫/韩漫都市/3D国风/仙侠/韩漫/乙女/国漫/CG/卡通...) | `/new-project` 内嵌 |
| 4,13 | pic(4,13) | **项目列表 Dashboard** — 搜索+日期筛选+成员筛选+创建项目 | `/` (项目 tab) |
| 15 | pic(15) | **项目卡片右键菜单** — 编辑名称 / 删除 | `/` |
| 16 | pic(16) | **项目详情 — 剧本 Tab** — 封面+标签+大纲+人物小传 | `/project/:id` (剧本) |
| 17 | pic(17) | **项目详情 — 剧本 Tab (下半)** — 分集剧情网格(60集) + 第1集详情 | `/project/:id` (剧本) |
| 18 | pic(18) | **项目详情 — 剧本 Tab (剧本正文)** — 分场详细台词和动作描述 | `/project/:id` (剧本) |
| 20 | pic(20) | **项目详情 — 设定 Tab — 角色** — 角色卡片(四视图已生成) + 新增角色 | `/project/:id` (设定/角色) |
| 25 | pic(25) | **新增角色弹窗** — 名称/性别/年龄/角色描述 | `/project/:id` (设定/角色) |
| 19 | pic(19) | **项目详情 — 设定 Tab — 场景** — 场景卡片网格 + AI生成图 | `/project/:id` (设定/场景) |
| 23 | pic(23) | **新增场景弹窗** — 名称 + 场景描述 | `/project/:id` (设定/场景) |
| 21 | pic(21) | **项目详情 — 设定 Tab — 道具** — 道具图片网格(105个) | `/project/:id` (设定/道具) |
| 22 | pic(22) | **新增道具弹窗** — 名称 + 道具描述 | `/project/:id` (设定/道具) |
| 24 | pic(24) | **分镜工作台(核心)** — 左:集数/分镜列表, 中:分镜信息+视频生成, 右:视频预览 | `/project/:id/storyboard/:eid` |
| 26 | pic(26) | **分镜 — 出镜角色下拉** — 角色列表选择(陆长生/赵明/九霄阁宗主/玄烨/苏红叶) | 分镜工作台内嵌 |
| 27 | pic(27) | **分镜 — 视频生成区展开** — 完整镜头描述文本 + 分镜参考图 | 分镜工作台内嵌 |
| 28 | pic(28) | **批量生视频弹窗** — 分镜1-12 checkbox网格 + 全选 + 积分预估(4,650) | 分镜工作台内嵌 |
| 29 | pic(29) | **分镜 — 底部工具栏** — 模型选择器(Doubao-Seedance-2.0 / fast) + 15s时长 + 积分 | 分镜工作台内嵌 |
| 30 | pic(30) | **分镜 — 出镜角色筛选/搜索** — 带头像的角色搜索下拉 | 分镜工作台内嵌 |
| 31 | pic(31) | **分镜 — 角色操作菜单** — "..." 按钮 | 分镜工作台内嵌 |
| 33 | pic(33) | **分镜 — 场景操作菜单** — 更换场景 / 删除场景 | 分镜工作台内嵌 |
| 34 | pic(34) | **视频生成页** — 全屏播放器 + 时间线 + 分镜缩略图条 + 编辑/导出按钮 | `/project/:id` (视频) |
| 2 | pic(2) | **资产库 — 角色库** — 上传角色弹窗(JPG/PNG + 名称) | `/assets` (角色库) |
| 3 | pic(3) | **资产库 — 场景库** — 个人资产/团队资产 Tab + 筛选器 | `/assets` (场景库) |
| 5 | pic(5) | **团队管理 — 子账号管理** — 用户列表 + 授权状态 + 所属团队 | `/team` (子账号) |
| 6 | pic(6) | **团队管理 — 团队管理** — 团队列表 + 成员数 + CRUD | `/team` (团队) |
| 8 | pic(8) | **用量统计(上半)** — 积分余量68% / 子账号余量73% / 资源包明细 | `/usage` |
| 9 | pic(9) | **用量统计 — 团队选择下拉** — 多团队多选 | `/usage` |
| 10 | pic(10) | **用量统计 — 消耗项下拉** — 角色/场景/道具/分镜视频生成 | `/usage` |
| 11 | pic(11) | **用量统计 — 多团队对比图** — 分镜视频生成消耗趋势(多色折线) | `/usage` |
| 12 | pic(12) | **用量统计 — 单团队筛选** — tooltip 显示具体积分值 | `/usage` |
| 14 | pic(14) | **AI水印设置** — 去除水印开关 + 法规说明 | `/settings` |
| 32 | pic(32) | **积分购买页** — 10万/100万/1000万 资源包 + 代金券 + 立即购买 | 顶部导航入口 |
## 2. 页面完整性验证
### 2.1 与设计文档 02/12 的页面清单对齐
| # | 设计文档页面 | 截图覆盖 | 状态 | 备注 |
|---|----------|--------|------|------|
| 1 | Dashboard (项目列表) | pic(4,13,15) | **已确认** | 搜索+日期+成员筛选+卡片+右键菜单 |
| 2 | NewProject | pic(1,7) | **已确认** | 上传+三选项+风格下拉(9种+) |
| 3 | StyleSelection | pic(7) | **已确认** | 风格选择集成在新建项目中(非独立页面) |
| 4 | OutlineGeneration | pic(16) | **已确认** | 大纲展示在项目详情-剧本Tab内 |
| 5 | EpisodeGeneration | pic(17,18) | **已确认** | 60集网格 + 分集详细剧本 |
| 6 | CharacterGeneration | pic(20,25) | **已确认** | 角色卡+四视图+新增弹窗 |
| 7 | SceneGeneration | pic(19,23) | **已确认** | 场景卡+AI图+新增弹窗 |
| 8 | PropsGeneration | pic(21,22) | **已确认** | 道具网格(105个)+新增弹窗 |
| 9 | StoryboardWorkspace | pic(24,26-31,33) | **已确认** | 三栏布局+角色/场景操作+视频生成 |
| 10 | VideoGeneration | pic(34) | **已确认** | 全屏播放器+时间线+缩略图 |
| 11 | ProjectDetail | pic(16-23) | **已确认** | 顶部 Tab 导航(剧本/设定/分镜/视频) |
| 12 | ProjectSettings | - | **未截图** | 原型中可能在项目详情内 |
| 13 | TeamManagement | pic(5,6) | **已确认** | 子账号+团队双Tab |
| 14 | UsageManagement | pic(8-12) | **已确认** | 余量统计+资源包+消耗趋势图 |
| 15 | AssetsManagement | pic(2,3) | **已确认** | 角色库/场景库/道具库 + 个人/团队 |
| 16 | ModelSettings | - | **未截图** | 你提到已新增,但原型截图中未包含 |
| 17 | AIWatermarkSettings | pic(14) | **已确认** | 去除水印管理 |
**新发现页面(设计文档未列)**:
| 页面 | 截图 | 说明 |
|------|------|------|
| **积分购买页** | pic(32) | 资源包购买(10万/100万/1000万) + 代金券 + 立即购买, 设计文档标记为 M7 阶段 |
### 2.2 未在截图中出现的页面
| 页面 | 设计文档状态 | 说明 |
|------|----------|------|
| 登录/注册页 | M1 阶段做 | 原型未截图, 正常 |
| ModelSettings 模型配置 | 你说已新增 | 未在本次截图中, 建议补充 |
| ProjectSettings 项目设置 | 文档已有 | 可能集成在项目详情某个入口 |
## 3. 核心交互流程验证
### 3.1 端到端主流程
```
[项目列表 pic(4)] → 点击"创建项目"
[新建项目 pic(1)] → 上传剧本 + 选 16:9/720p/2D日漫 → 点击"创建短剧项目"
[项目详情-剧本 pic(16)] → 查看大纲+人物小传+分集剧情 → 点击"进入下一步"
[项目详情-设定 pic(20)] → 角色Tab(四视图) → 场景Tab(pic19) → 道具Tab(pic21) → "进入下一步"
[分镜工作台 pic(24)] → 左侧选集 → 中间编辑分镜 → 选角色/场景/道具 → 生成视频
[视频页 pic(34)] → 播放器 + 时间线 → 导出/下载
```
**结论**: 端到端主流程**完全可走通**, 每个页面底部都有"进入下一步"按钮衔接。
### 3.2 分镜工作台核心交互(pic 24-31, 33)
| 交互 | 截图验证 | 与设计文档对齐 |
|------|--------|------------|
| 左侧集数选择器(1-16集) | pic(24) | **一致** |
| 分镜列表(分镜1, 分镜2...) | pic(24,27) | **一致** |
| 分镜描述编辑 | pic(24) 中间区域 | **一致** |
| 出镜角色选择(下拉+搜索) | pic(26,30) | **一致**, 显示角色头像 |
| 出镜角色"未配置音色"标记 | pic(24,31) | **一致**, 有"未配置音色"按钮 |
| 分镜场景(主场景图) | pic(24) 左下 | **一致** |
| 场景更换/删除菜单 | pic(33) | **一致**, "更换场景"+"删除场景" |
| 场景道具(+ 添加) | pic(24) 底部 | **一致** |
| 分镜视频生成区(镜头描述) | pic(27) 中间 | **一致**, 多镜头描述+时长+景别 |
| 视频模型选择器(底部) | pic(29) Doubao-Seedance-2.0 / fast | **一致** |
| 时长选择(15s) | pic(29) | **一致** |
| 积分预估(生成按钮显示消耗) | pic(28,29) 积分: 2,250 / 4,650 | **一致** |
| 批量生视频弹窗 | pic(28) | **一致**, checkbox + 全选 + 总积分 |
| 参考图区域(分镜图缩略图) | pic(24) 右侧底部 | **一致**, 有缩略图条 |
| 视频预览播放器 | pic(24) 右侧 | **一致**, 带进度条+全屏 |
| 分镜表/分镜切换 | pic(24) 顶部 | **一致** |
| "全部参考生视频" toggle | pic(24) 第3行 | **一致** |
### 3.3 视频生成页(pic 34)
| 交互 | 验证 |
|------|------|
| 全屏视频播放器 | **有**, 带音量/速率/全屏控制 |
| 时间线(00:00 - 02:38) | **有**, 精确到秒 |
| 分镜缩略图条 | **有**, 底部显示各分镜缩略图+时长+状态 |
| 集数选择器(左侧) | **有**, 1-16集 |
| 导出按钮 | **有**, "导出到剪辑草稿" + "下载视频" |
| 编辑按钮 | **有**, 右上角"编辑" |
## 4. 原型与设计文档的差异分析
### 4.1 原型实际有但设计文档描述不够细的
| 发现 | 截图 | 建议补充 |
|------|------|---------|
| **风格列表远超6种** | pic(7) | 原型显示 9+ 种(2D日漫/韩漫都市/3D国风/仙侠/韩漫/乙女/国漫/CG/卡通...), 设计文档 `02` 写6种, 需更新 |
| **积分购买页已实现** | pic(32) | 10万/100万/1000万资源包 + 代金券, 设计文档标记M7, 实际原型已有 |
| **子账号管理 + 授权体系** | pic(5) | 设计文档 `09` 的 RBAC 只提项目级角色, 原型还有子账号授权登录机制 |
| **用量消耗项分类** | pic(10) | 角色生成/场景生成/道具生成/分镜视频生成 4类, 对应后端 TaskType 要精确匹配 |
| **Doubao-Seedance 模型名** | pic(29) | 原型用真实模型名 "Doubao-Seedance-2.0" 和 "fast" 变体, 设计文档需同步 |
| **60集网格已有真实数据** | pic(17,18) | 第1集有完整的分场剧本, 说明 AI 已跑通 |
| **105个道具已生成** | pic(21) | 道具数量巨大, 后端批量生成能力验证 |
| **场景更换交互** | pic(33) | "更换场景" 是下拉替换, 需要后端接口支持 shot 关联 scene 的更新 |
### 4.2 设计文档有但原型截图未体现的
| 功能 | 设计文档位置 | 状态 |
|------|----------|------|
| 角色四视图(正/3/4侧/侧/背) | `03` 3.2 | pic(20) 显示的是多张参考图, **非严格四视图布局**, 但功能等价 |
| 角色服装变体("林若曦·白色晚礼服") | `12` 3.x | 截图中未看到变体 Tab, 可能在角色详情内 |
| 分镜版本控制(assetVersion) | `03` 3.2 | 截图无版本切换 UI, 可能后台实现 |
| TTS 音色配置 | `04` Stage 6 | pic(24) 有"未配置音色"标记, 但无音色选择弹窗截图 |
| 口型同步(Pro) | `04` Stage 6 | 未截图, M5+ 功能 |
| 模型配置页 | `12` #16 | 你说已新增, 但未在本次截图中 |
### 4.3 原型导航结构(从截图提取)
```
左侧侧边栏:
├── 创作
│ └── 剧本生短剧 (= NewProject + 项目列表)
├── 我的
│ ├── 项目 (= Dashboard)
│ └── 资产 (= AssetsManagement)
├── 企业配置
│ ├── 团队管理 (= TeamManagement)
│ ├── 用量统计 (= UsageManagement)
│ └── AI 生成水印设置 (= AIWatermarkSettings)
└── 底部: 积分余量 7,511,768 + 购买入口
顶部导航栏:
├── 中科 Demo | 总览
├── 搜索产品或文档
├── 企业 | 工具 | 费用 | 支持 | 备案 | 文档
├── 通知(24)
└── 中文 | 用户头像
项目内顶部 Tab:
├── 剧本
├── 设定 (角色/场景/道具)
├── 分镜
└── 视频
```
## 5. 后端 API 需求确认(基于截图)
### 5.1 从截图反推的必要 API
| API | 截图依据 | 设计文档 07 是否已有 |
|-----|--------|-----------------|
| `POST /projects` 创建项目(含上传剧本) | pic(1) | 是 |
| `GET /projects` 项目列表(搜索+日期+成员筛选) | pic(4) | 是 |
| `PATCH /projects/:id` 编辑项目名称 | pic(15) | 是 |
| `DELETE /projects/:id` 删除项目 | pic(15) | 是 |
| `GET /projects/:id` 项目详情(剧本+大纲+角色) | pic(16) | 是 |
| `GET /projects/:id/episodes` 分集列表(60集) | pic(17) | 是 |
| `GET /projects/:id/characters` 角色列表+四视图 | pic(20) | 是 |
| `POST /projects/:id/characters` 新增角色 | pic(25) | 是 |
| `GET /projects/:id/scenes` 场景列表 | pic(19) | 是 |
| `POST /projects/:id/scenes` 新增场景 | pic(23) | 是 |
| `GET /projects/:id/props` 道具列表 | pic(21) | 是 |
| `POST /projects/:id/props` 新增道具 | pic(22) | 是 |
| `GET /projects/:id/storyboards/:eid` 分镜列表 | pic(24) | 是 |
| `PATCH /storyboards/:sid/scene` 更换场景 | pic(33) | **需确认** |
| `POST /storyboards/:sid/generate` 单分镜生成视频 | pic(29) | 是 |
| `POST /storyboards/batch-generate` 批量生成视频 | pic(28) | 是 |
| `GET /projects/:id/video` 合成视频播放 | pic(34) | 是 |
| `GET /assets` 资产库(个人/团队) | pic(2,3) | 是 |
| `POST /assets/characters` 上传角色资产 | pic(2) | 是 |
| `GET /team/accounts` 子账号列表 | pic(5) | **需补充** |
| `POST /team/accounts` 新建子账号 | pic(5) | **需补充** |
| `PATCH /team/accounts/:id/authorize` 授权/取消授权 | pic(5) | **需补充** |
| `GET /team/groups` 团队列表 | pic(6) | 是 |
| `GET /usage/stats` 用量统计(积分余量+资源包) | pic(8) | 是 |
| `GET /usage/trend` 消耗趋势(按团队+消耗项+日期) | pic(9-12) | **需细化参数** |
| `GET /watermark/settings` 水印设置 | pic(14) | 是 |
| `POST /billing/purchase` 购买资源包 | pic(32) | **需补充** |
### 5.2 需要补充或细化的 API
1. **子账号管理 API** — 设计文档 `07` 未明确子账号 CRUD + 授权登录体系, 原型已实现
2. **资源包购买 API** — 设计文档标记 M7, 但原型已有购买页, 需提前规划支付集成
3. **用量趋势 API** — 需支持多维筛选: 团队(多选) + 消耗项(多选) + 日期范围, 返回按天聚合数据
4. **分镜场景更换 API**`PATCH /storyboards/:sid/scene` 替换关联场景, 需联动重新生成提示词
## 6. 最终结论
### 6.1 对齐度评分
| 维度 | 评分 | 说明 |
|------|------|------|
| 页面覆盖率 | **94%** (16/17) | 仅 ModelSettings 未截图(已新增), ProjectSettings 入口待确认 |
| 主流程完整性 | **100%** | 创建项目 → 剧本 → 设定 → 分镜 → 视频, 全部可走通 |
| 分镜工作台细节 | **98%** | 核心交互全部覆盖, 仅 TTS 音色选择弹窗未截图 |
| 企业管理功能 | **95%** | 子账号+团队+用量+水印+购买, 超出设计文档预期 |
| 与设计文档一致性 | **90%** | 风格数量(9+ vs 6)、积分购买(M7提前)、子账号体系 需同步更新 |
### 6.2 需要同步更新的设计文档
| 文档 | 需更新内容 |
|------|---------|
| `02-前端功能与页面清单` | 风格列表更新为 9+ 种; 补充积分购买页; 补充子账号管理详情 |
| `07-API接口规范` | 补充子账号 CRUD API; 补充资源包购买 API; 细化用量趋势查询参数 |
| `09-计费与多租户` | 补充资源包购买流程(10万/100万/1000万档位 + 代金券); 子账号授权体系 |
| `11-实施路线图` | 积分购买从 M7 提前, 原型已可用 |
### 6.3 可以开始后端开发的确认
**所有核心前端页面和交互流程已通过截图验证, 后端 API 设计可以基于此定稿。** 主要待确认项:
1. ModelSettings 页截图(你说已新增, 补充即可)
2. TTS 音色选择交互细节
3. 角色服装变体 UI 是否在角色详情弹窗内
---
*生成时间: 2026-04-16*
# 15 · 执行任务列表
# 15 · 执行任务列表
> 创建日期: 2026-04-16
> 标记规则: `[x]` 已完成 · `[ ]` 待完成 · `[~]` 进行中 · `[-]` 跳过/延后
> 完成时请补充完成日期,格式: `[x] 任务名 ✅ 2026-xx-xx`
---
## M0 · 基建(第 1~2 周)
> 完成标准: `docker compose up` 后能访问 Swagger UI 并看到 `/ping` 接口
### M0.1 工程骨架
- [ ] 创建 Maven 多模块项目 `yaoai-comic-studio`
- [ ] `yaoai-common` — 通用工具、异常、常量、DTO 基类
- [ ] `yaoai-domain` — 领域模型(MyBatis-Plus Entity + Mapper + IService)
- [ ] `yaoai-security` — 认证授权(Sa-Token + Redis 会话 + 多租户 Filter)
- [ ] `yaoai-billing` — 计费模块
- [ ] `yaoai-storage` — 对象存储抽象层
- [ ] `yaoai-ai-core` — AI 抽象层(Provider 接口、模型路由、Skill 加载)
- [ ] `yaoai-ai-providers` — 具体 Provider 实现
- [ ] `yaoai-agent` — 三层 Agent 体系
- [ ] `yaoai-pipeline` — 业务流水线
- [ ] `yaoai-task` — 任务系统
- [ ] `yaoai-media` — FFmpeg 合成
- [ ] `yaoai-api` — REST Controller + SSE + OpenAPI
- [ ] `yaoai-admin` — 管理后台 API
- [ ] `yaoai-worker` — 可独立部署的 Worker 进程
- [ ] `yaoai-bootstrap` — 主启动模块
- [ ] `yaoai-test` — 集成测试
### M0.2 基础框架
- [ ] Spring Boot 3.3.x + Java 17 依赖配置
- [ ] Spring AI Alibaba 1.0.0+ 依赖引入并验证
- [ ] `application.yml` 多环境配置(local/dev/staging/prod)
- [ ] 统一异常处理 `GlobalExceptionHandler` + `ApiResponse<T>` 包装
- [ ] `ErrorCode` 枚举集中管理
- [ ] Logback JSON 格式 + MDC(requestId/userId/tenantId)
- [ ] `RequestIdFilter` 生成并注入 X-Request-Id
### M0.3 数据库
- [ ] Flyway 接入 + 初始迁移脚本 `V1__init.sql`
- [ ] MySQL 8.0 连接池配置(HikariCP)
- [ ] MyBatis-Plus 3.5.x 配置 + `MybatisPlusConfig`
- [ ] MyBatis-Plus 插件注册(多租户 `TenantLineInnerInterceptor` + 分页 `PaginationInnerInterceptor` + 乐观锁 `OptimisticLockerInnerInterceptor`
- [ ] 基础 Entity 抽象(`BaseEntity`: id/createdAt/updatedAt + `@TableLogic` 逻辑删除)
- [ ] MyBatis-Plus 代码生成器配置(可选,批量生成 35 张表的 Entity/Mapper/Service)
### M0.4 中间件
- [ ] Redis Stack 7.2 连接配置(Spring Cache + 分布式锁)
- [ ] RabbitMQ 3.13 连接配置 + Exchange/Queue 声明
- [ ] Docker Compose 编排(mysql + redis-stack + rabbitmq)
- [ ] Docker Compose 启动验证脚本
### M0.5 可观测性
- [ ] Spring Boot Actuator 开启
- [ ] Prometheus endpoint 暴露(`/actuator/prometheus`
- [ ] Health Check 包含 MySQL/Redis/RabbitMQ 依赖检查
### M0.6 API 文档
- [ ] `springdoc-openapi` 集成
- [ ] Swagger UI 可访问
- [ ] `/ping` 健康检查接口
- [ ] `/system/boot-id` + `/system/version` 系统接口
### M0.7 CI/CD
- [ ] Dockerfile 多阶段构建
- [ ] GitHub Actions: PR 触发 test + build
- [ ] `.editorconfig` + 代码规范配置
### M0.8 前端接入层改造
- [ ] `doc/html` 项目引入 TanStack Query
- [ ] 引入 Zustand 状态管理
- [ ] API Client 封装(baseURL + token 拦截器 + 错误处理)
- [ ] 环境变量配置(`.env.local` / `.env.production`
---
## M1 · 认证与项目 CRUD(第 3~4 周)
> 完成标准: 用户注册 → 登录 → 创建一个空项目 → 在 Dashboard 看到它
### M1.1 认证系统
- [ ] Sa-Token 1.39+ 接入(`sa-token-spring-boot3-starter` + `sa-token-redis-jackson`
- [ ] Token 存 Redis 配置
- [ ] `POST /auth/register` — 注册(email/username + password,BCrypt salt=12)
- [ ] `POST /auth/login` — 登录 → {accessToken, user}
- [ ] `POST /auth/logout` — 注销
- [ ] `POST /auth/refresh` — 刷新 token
- [ ] `GET /auth/me` — 当前用户信息
- [ ] IP 登录限流(Bucket4j + Redis,5次/分钟)
- [ ] 注册限流(3次/小时)
### M1.2 用户与租户
- [ ] `users` 表 + Entity + Repository
- [ ] `tenants` 表 + Entity + Repository
- [ ] `tenant_members` 表 + Entity
- [ ] 注册时自动创建个人租户(Tenant)
- [ ] `TenantContext` ThreadLocal 工具类
- [ ] `TenantContextFilter` — 从 Sa-Token Session 取 tenantId
- [ ] MyBatis-Plus `TenantLineInnerInterceptor` 租户隔离(自动注入 tenant_id 条件)
### M1.3 项目 CRUD
- [ ] `projects` 表 Flyway 迁移
- [ ] `Project` Entity + `ProjectMapper` + `ProjectServiceImpl`
- [ ] `ProjectService` — CRUD 业务逻辑
- [ ] `GET /projects` — 列表(分页 + 搜索 + 状态筛选)
- [ ] `GET /projects/stats` — Dashboard 统计(总项目数/进行中/已完成)
- [ ] `POST /projects` — 创建项目(multipart: file + meta)
- [ ] `GET /projects/{id}` — 项目详情
- [ ] `PATCH /projects/{id}` — 更新字段
- [ ] `DELETE /projects/{id}` — 删除
- [ ] `POST /projects/{id}:archive` — 归档
### M1.4 项目成员
- [ ] `project_members` 表 + Entity
- [ ] `project_invitations` 表 + Entity
- [ ] `GET /projects/{id}/members` — 成员列表
- [ ] `POST /projects/{id}/invitations` — 邀请成员
- [ ] `PATCH /projects/{id}/members/{mid}` — 修改角色
- [ ] `DELETE /projects/{id}/members/{mid}` — 移除成员
### M1.5 前端接入
- [ ] 登录/注册页面开发
- [ ] Auth Guard 路由守卫
- [ ] Dashboard 页面去 mock,接入真实 API
- [ ] NewProject 页面去 mock,接入真实 API
- [ ] Token 持久化 + 自动刷新
---
## M2 · 剧本解析流水线(第 5~7 周)
> 完成标准: 上传一篇 5000 字小说,能自动出角色列表和分集列表,全程 SSE 推进度
### M2.1 文件上传与存储
- [ ] `StorageProvider` 接口定义(upload/download/getSignedUrl/delete/exists)
- [ ] `AliyunOssProvider` 实现(阿里云 OSS)
- [ ] `VolcengineTosProvider` 实现(火山引擎 TOS)
- [ ] `@ConditionalOnProperty` 按配置切换 Provider
- [ ] Bucket 规划与目录结构实现
- [ ] `media_objects` 表 + Entity(统一媒体管理)
- [ ] `MediaService` — 上传/下载/签名 URL 管理
- [ ] 预签名 URL 生成(TTL 30 分钟)
### M2.2 文本抽取
- [ ] Apache POI 接入(.doc/.docx)
- [ ] PDFBox 接入(.pdf)
- [ ] 纯文本 + Markdown 读取
- [ ] `scripts` 表 + Entity
- [ ] `POST /projects/{id}/import/step1-extract` — 文本抽取 API
### M2.3 任务系统核心
- [ ] `tasks` 表 Flyway 迁移
- [ ] `Task` Entity + `TaskMapper`
- [ ] `TaskService` — submit/markProcessing/markCompleted/markFailed/cancel
- [ ] `TaskHandler<P,R>` 接口定义
- [ ] RabbitMQ Exchange + Queue 声明(7 个队列)
- [ ] 通用 Worker 消费模板(心跳 + 重试 + 计费集成)
- [ ] `task_events` 表 + 事件记录
- [ ] 幂等处理(idempotencyKey 去重)
- [ ] 心跳机制(10s 间隔,60s 超时判定假死)
### M2.4 SSE 进度推送
- [ ] `SseService` — 事件发布
- [ ] `GET /projects/{id}/events` — SSE 端点
- [ ] `GET /tasks/{taskId}/events` — 单任务 SSE
- [ ] Redis pub/sub 跨实例 SSE 分发
- [ ] 事件格式:task.progress / task.completed / task.failed
### M2.5 AI 接入
- [ ] Spring AI Alibaba 通义千问 ChatClient 配置
- [ ] `TextProvider` 接口 + `QwenTextProvider` 实现
- [ ] 流式输出(`Flux<String>`)+ 非流式
- [ ] Embedding 模型接入(text-embedding-v3,dim=1024)
### M2.6 Skill 系统
- [ ] `SkillLoader` 服务实现
- [ ] `skills/` 目录结构创建
- [ ] 从 Toonflow 迁移核心 Skill 文件:
- [ ] `script_execution_skeleton.md` — 故事骨架
- [ ] `script_execution_adaptation.md` — 改编策略
- [ ] `script_execution_script.md` — 剧本编写
- [ ] `script_agent_decision.md` — 决策层指令
- [ ] `script_agent_supervision.md` — 监督层指令
- [ ] Mustache 模板渲染(变量插槽)
- [ ] `prompt_templates` 表 — 覆盖优先级(project > tenant > global > default)
### M2.7 剧本解析 Executor
- [ ] `ScriptParseExecutor` — 文本抽取 + 事件提取
- [ ] `CharacterExtractExecutor` — AI 角色提取(去重 + 关系推理)
- [ ] `EpisodeSplitExecutor` — 智能分集(三幕结构 + 付费卡点)
- [ ] `novel_events` 表 + Entity
- [ ] `characters` 表 + Entity + CRUD API
- [ ] `character_relations` 表 + Entity
- [ ] `episodes` 表 + Entity + CRUD API
- [ ] `outlines` 表 + Entity
### M2.8 四步导入 API
- [ ] `POST /projects/{id}/import/step1-extract` — 文本抽取
- [ ] `POST /projects/{id}/import/step2-characters` — AI 角色提取
- [ ] `POST /projects/{id}/import/step3-episodes` — 智能分集
- [ ] `POST /projects/{id}/import/step4-confirm` — 一键创建确认
### M2.9 风格与大纲
- [ ] `GET /styles` — 风格列表(9+ 种)
- [ ] `POST /projects/{id}:set-style` — 设置风格
- [ ] `POST /projects/{id}/outline:generate` — AI 生成大纲 → taskId
- [ ] `GET /projects/{id}/outline` — 获取大纲
- [ ] `PUT /projects/{id}/outline` — 编辑大纲
### M2.10 前端接入
- [ ] NewProject 上传流程接入
- [ ] StyleSelection 页面接入
- [ ] OutlineGeneration 页面接入(SSE 进度)
- [ ] EpisodeGeneration 页面接入
- [ ] CharacterGeneration 页面基础接入(CRUD,无图片生成)
---
## M3 · 资产生成(第 8~10 周)
> 完成标准: 每个角色能一键生成四视图,前端能看到并支持重新生成
### M3.1 图像 AI Provider
- [ ] `ImageProvider` 接口定义(支持参考图 `List<ReferenceImage>`
- [ ] `WanxImageProvider` — 通义万相文生图实现
- [ ] 兜底 Provider 配置(OpenAI DALL-E 或 Gemini Imagen,可选)
- [ ] `AiModelRouter` — 根据项目配置 > 全局默认 > fallback 选择 Provider
### M3.2 艺术手册
- [ ] 从 Toonflow 迁移 `art_skills/` 全套艺术手册
- [ ] `2d_anime/art_character.md`
- [ ] `2d_anime/art_general.md`
- [ ] 其他风格手册(国风/3D/水墨等)
- [ ] 风格手册与 `SkillLoader` 集成
### M3.3 角色资产
- [ ] `CharacterTurnaroundExecutor` — 四视图生成(借鉴 AIComicBuilder)
- [ ] `character_appearances` 表 + Entity(服装变体/状态变体)
- [ ] `POST /projects/{id}/characters/{cid}:generate-turnaround` — 生成四视图
- [ ] `POST /projects/{id}/characters/{cid}:upload-reference` — 上传参考图
- [ ] `POST /projects/{id}/characters/{cid}/appearances` — 新增变体
- [ ] `GET /projects/{id}/characters/{cid}/appearances` — 变体列表
- [ ] 资产版本管理(assetVersion + isActive 模式)
### M3.4 场景资产
- [ ] `scenes` 表 + Entity + CRUD API
- [ ] `SceneImageExecutor` — 场景图生成
- [ ] `POST /projects/{id}/scenes/{sid}:generate` — 生成场景图
- [ ] 场景图版本管理
### M3.5 道具资产
- [ ] `props` 表 + Entity + CRUD API
- [ ] `PropImageExecutor` — 道具图生成
- [ ] `POST /projects/{id}/props/{pid}:generate` — 生成道具图
### M3.6 统一资产表
- [ ] `shot_assets` 表 Flyway 迁移
- [ ] `ShotAsset` Entity + `ShotAssetMapper`
- [ ] `AssetService` — 创建/查询/版本切换/激活
- [ ] 版本回退接口(`PATCH /assets/{aid}:activate`
### M3.7 初级计费
- [ ] `user_balances` 表 + Entity
- [ ] `BillingService` 基础实现(SOFT 模式,只记录不拦截)
- [ ] `CostEstimator` — 各任务类型成本估算
- [ ] 积分预扣 + 结算 + 回滚流程
### M3.8 前端接入
- [ ] CharacterGeneration 页面完整接入(四视图生成 + 变体管理)
- [ ] SceneGeneration 页面接入
- [ ] PropsGeneration 页面接入
- [ ] 图片生成 SSE 进度展示
- [ ] 重新生成 / 版本切换 UI
---
## M4 · 分镜工作台(第 11~13 周)
> 完成标准: 某集全部分镜可一键 AI 生成,用户可手动编辑每个镜头
### M4.1 分镜数据
- [ ] `storyboards` 表 Flyway 迁移(完整字段,参考 AIComicBuilder shot 结构)
- [ ] `Storyboard` Entity + `StoryboardMapper`
- [ ] 分镜字段:描述、角色、对白、道具、相机、转场、时长等
### M4.2 分镜 CRUD API
- [ ] `GET /projects/{id}/episodes/{eid}/storyboards` — 分镜列表
- [ ] `POST /projects/{id}/storyboards` — 手动创建
- [ ] `GET /projects/{id}/storyboards/{sid}` — 详情
- [ ] `PATCH /projects/{id}/storyboards/{sid}` — 更新
- [ ] `DELETE /projects/{id}/storyboards/{sid}` — 删除
- [ ] `POST /projects/{id}/storyboards/{sid}:duplicate` — 复制
- [ ] `PUT /projects/{id}/episodes/{eid}/storyboards:reorder` — 拖拽重排序(批量 sequence 更新)
### M4.3 AI 分镜生成
- [ ] `DirectorPlanExecutor` — 导演规划(借鉴 Toonflow 导演手册)
- [ ] `StoryboardSplitExecutor` — 镜头拆分(借鉴 AIComicBuilder shot-split)
- [ ] `StoryboardTableExecutor` — 分镜表生成
- [ ] `StoryboardPanelExecutor` — 分镜面板写入
- [ ] `POST /projects/{id}/episodes/{eid}/storyboards:generate` — AI 批量生成分镜
- [ ] 从 Toonflow 迁移 Skill 文件:
- [ ] `production_agent_decision.md`
- [ ] `production_execution_director_plan.md`
- [ ] `production_execution_storyboard_table.md`
- [ ] `production_execution_storyboard_panel.md`
### M4.4 监督层
- [ ] `SupervisionAgent` 雏形实现
- [ ] `SupervisionExecutor` — A/B/C/D 评分 + 审核维度
- [ ] 可选开关(Free 套餐关闭以省成本)
- [ ] 从 Toonflow 迁移 `script_agent_supervision.md`
- [ ] `POST /projects/{id}/storyboards/{sid}:ai-check` — AI 合规检查
### M4.5 三层 Agent 基础
- [ ] `ScriptDecisionAgent` — 决策层(@Tool 包装子 Agent)
- [ ] 执行层 Executor 注册机制
- [ ] `graph_runs` 表 + Entity(工作流运行记录)
- [ ] `graph_steps` 表 + Entity(步骤记录)
- [ ] `ProductionAgentOrchestrator` — Agent 编排引擎
- [ ] 断点续跑支持(从失败的 step 重启)
### M4.6 Agent Memory
- [ ] Redis Stack RediSearch 向量索引配置(`agent_memory` 索引)
- [ ] `VectorStoreConfig` — RedisVectorStore Bean(HNSW, dim=1024, COSINE)
- [ ] `AgentMemoryService` — add/retrieve/deepRetrieve
- [ ] 自动摘要压缩(超过 100 条触发)
- [ ] 短期记忆 + 长期摘要 + RAG 三层检索
### M4.7 前端接入
- [ ] StoryboardWorkspace 页面完整接入
- [ ] 左侧分集选择器
- [ ] 中间分镜列表(分镜信息区 + 视频生成区)
- [ ] 出镜角色增删 + 音色选择
- [ ] 场景选择 / 更换
- [ ] 道具增删
- [ ] 单一提示词编辑框
- [ ] 智能体选择器(通义万相/Seedance/Kling)
- [ ] 积分预估显示
- [ ] 拖拽排序实现(react-dnd 逻辑补全)
- [ ] AI 生成分镜 SSE 进度
---
## M5 · 视觉生成(第 14~16 周)
> 完成标准: 选中分镜,点"生成视频",10 分钟内能看到 5~8 秒短视频,角色相对一致
### M5.1 首尾帧生成
- [ ] `FrameGenerateExecutor` — 首帧生成(注入角色参考图 + 风格约束 + 色板约束)
- [ ] `FrameGenerateExecutor` — 尾帧生成(参考首帧 + 角色四视图)
- [ ] `POST /projects/{id}/storyboards/{sid}/assets/first-frame:generate`
- [ ] `POST /projects/{id}/storyboards/{sid}/assets/last-frame:generate`
- [ ] 角色参考图预签名 URL 注入
- [ ] 首尾帧并发生成
### M5.2 视频 AI Provider
- [ ] `VideoProvider` 接口定义(submit/poll 异步模式)
- [ ] `WanxVideoProvider` — 通义万相视频实现
- [ ] `SeedanceProvider` — 豆包 Seedance 实现(备选)
- [ ] `KlingProvider` — 快手 Kling 实现(备选)
- [ ] Provider fallback 机制(主 Provider 失败自动切换)
### M5.3 视频生成
- [ ] `VideoGenerateExecutor` — 异步提交 + 轮询(5s × 120 次 = 10 分钟)
- [ ] `POST /projects/{id}/storyboards/{sid}/assets/video:generate`
- [ ] 模型厂商临时 URL → 转存自有 OSS/TOS
- [ ] `shot_assets` 写入(KEYFRAME_VIDEO 类型)
- [ ] 任务心跳机制上线
- [ ] 失败重试 + 指数退避
### M5.4 批量生成
- [ ] `POST /projects/{id}/episodes/{eid}/storyboards:batch-generate`
- [ ] body: `{storyboardIds[], targets:[first_frame, last_frame, video]}`
- [ ] 并发控制(按队列配置)
### M5.5 分镜资产查询
- [ ] `GET /projects/{id}/storyboards/{sid}/assets` — 资产列表(按类型/激活状态筛选)
- [ ] `PATCH /projects/{id}/storyboards/{sid}/assets/{aid}:activate` — 版本回退
### M5.6 前端接入
- [ ] StoryboardWorkspace 右侧视频预览接入
- [ ] 首尾帧预览展示
- [ ] 视频播放器组件
- [ ] VideoGeneration 页面接入
- [ ] 分镜状态网格
- [ ] 分镜时间线
- [ ] 批量生成控制
- [ ] 生成进度 SSE 实时展示
---
## M6 · 合成与导出(第 17~18 周)
> 完成标准: 一集分镜生成完后,点"合成",得到带字幕、配音、BGM 的完整 MP4
### M6.1 TTS 语音
- [ ] `TtsProvider` 接口定义
- [ ] `QwenTtsProvider` — 通义 TTS 实现(支持音色选择 + 情绪 + 语速)
- [ ] `VoiceTtsExecutor` — 按角色音色生成对白音频
- [ ] `POST /projects/{id}/storyboards/{sid}/assets/audio:generate`
- [ ] 音频转存 OSS + `shot_assets` 写入
### M6.2 FFmpeg 合成
- [ ] `FfmpegService` — FFmpeg 调度封装
- [ ] `AssembleExecutor` — 全集合成
- [ ] 按 shot.sequence 排序 keyframe_video
- [ ] xfade 转场拼接(cut/dissolve/fade,默认 0.5s)
- [ ] SRT 字幕生成(基于 dialogues 时间比例)
- [ ] 字幕硬编码烧录
- [ ] 对白音频混入(按时间戳)
- [ ] BGM 混音(volume=0.3)
- [ ] 片头标题卡(3s)+ 片尾(2s)
- [ ] `POST /projects/{id}/episodes/{eid}:assemble` — 合成 API
- [ ] `GET /projects/{id}/episodes/{eid}/segments` — 分镜段状态
- [ ] `GET /projects/{id}/episodes/{eid}/final` — 最终 MP4 下载(签名 URL)
### M6.3 水印
- [ ] `GET/PUT /settings/watermark` — 水印设置 API
- [ ] FFmpeg 水印烧录集成
- [ ] 水印位置/透明度/自定义图片支持
### M6.4 前端接入
- [ ] VideoGeneration 页面合成功能接入
- [ ] 合成进度展示
- [ ] 视频播放器 — 完整成片播放
- [ ] 下载按钮 + 签名 URL
- [ ] AIWatermarkSettings 页面接入
---
## M7 · 计费与 SaaS(第 19~21 周)
> 完成标准: 用户能充值、消费会扣积分、余额不足被拦截、流水可查
### M7.1 计费系统
- [ ] `user_balances` / `balance_freezes` / `balance_transactions` 三表上线
- [ ] `BillingService` 完整实现(prepare/settle/rollback)
- [ ] 乐观锁扣减 SQL
- [ ] 冻结过期清理定时任务(每 1 分钟)
- [ ] 集成到所有 Worker(全链路计费)
- [ ] `BILLING_MODE` 切换到 STRICT
### M7.2 成本估算
- [ ] `CostEstimator` 完整实现
- [ ] SCRIPT_PARSE: 5 积分
- [ ] OUTLINE_GEN: 10 积分
- [ ] CHARACTER_IMAGE: 20 积分(四视图)
- [ ] SCENE_IMAGE: 10 积分
- [ ] FRAME_GEN: 15 积分/张
- [ ] VIDEO_GEN: 按模型+时长动态计算
- [ ] TTS: 0.1 积分/字
- [ ] ASSEMBLE: 5 积分
- [ ] 前端积分预估显示联动
### M7.3 订阅系统
- [ ] `subscriptions` 表 + Entity
- [ ] 套餐定义(FREE/PRO/TEAM/ENTERPRISE)
- [ ] `TenantQuota` — 租户级配额(maxProjects/maxMembers/maxConcurrentTasks)
- [ ] `GET /billing/subscription` — 当前订阅
- [ ] `POST /billing/subscription` — 升级套餐
- [ ] `POST /billing/subscription:cancel` — 取消
### M7.4 支付集成
- [ ] `PaymentProvider` 接口定义
- [ ] 支付宝接入 + Webhook
- [ ] 微信支付接入 + Webhook
- [ ] Stripe 海外接入(可选)
- [ ] `POST /billing/recharge` — 充值 → 支付链接
### M7.5 计费查询 API
- [ ] `GET /billing/balance` — 当前余额+冻结+已消费
- [ ] `GET /billing/transactions` — 流水(分页 + 类型筛选)
- [ ] `GET /usage/stats` — 积分统计
- [ ] `GET /usage/daily` — 30 天趋势
- [ ] `GET /usage/records` — 消费明细
### M7.6 定时任务
- [ ] 过期冻结清理(每 1 分钟)
- [ ] 统计聚合 usage_records → daily_usage_stats(每 30 分钟)
- [ ] 订阅续费检查 + 到期降级(每天 03:00)
- [ ] 清理过期通知 + 归档旧 audit_logs(每天 02:00)
### M7.7 前端接入
- [ ] 充值页面开发
- [ ] 订阅/套餐选择页面
- [ ] UsageManagement 页面接入(用量统计 + 图表)
- [ ] 余额不足拦截 UI 提示
---
## M8 · 团队协作与资产库(第 22~24 周)
> 完成标准: 团队成员可被邀请到项目,权限隔离正确,资产能跨项目复用
### M8.1 团队管理
- [ ] `employees` 表 + Entity + CRUD
- [ ] `groups` 表 + `group_members` 表 + Entity
- [ ] `GET /admin/employees` — 员工列表(搜索 + 状态筛选)
- [ ] `POST /admin/employees` — 添加员工
- [ ] `PATCH /admin/employees/{id}` — 编辑
- [ ] `DELETE /admin/employees/{id}` — 删除
- [ ] `POST /admin/employees:bulk-import` — CSV 批量导入
- [ ] `GET/POST/PATCH /admin/groups` — 小组 CRUD
- [ ] 子账号管理 API(前端截图发现的补充)
### M8.2 RBAC 权限
- [ ] 项目级 4 角色实现(owner/admin/editor/viewer)
- [ ] `ProjectPermissionEvaluator` — 权限检查器
- [ ] `@PreAuthorize` 注解集成到所有需要鉴权的 Controller
- [ ] 权限矩阵测试覆盖
### M8.3 资产库
- [ ] `global_assets` 表 + Entity
- [ ] `asset_favorites` 表 + Entity
- [ ] `GET /assets` — 资产列表(library/type/tags/search 筛选)
- [ ] `POST /assets` — 上传资产
- [ ] `GET /assets/{aid}` — 详情
- [ ] `PATCH /assets/{aid}` — 编辑
- [ ] `DELETE /assets/{aid}` — 删除
- [ ] `POST /assets/{aid}:favorite` — 收藏
- [ ] `POST /assets/{aid}:clone-to-project` — 复用到项目
- [ ] 资产批量操作 API(前端截图发现的补充)
### M8.4 通知系统
- [ ] `notifications` 表 + Entity
- [ ] `GET /system/notifications` — 通知列表
- [ ] 标记已读
- [ ] WebPush(可选)
### M8.5 审计日志
- [ ] `audit_logs` 表 + Entity
- [ ] `@Audited` 注解 + AOP 切面
- [ ] `GET /admin/audit-logs` — 审计日志查询
- [ ] 日志自动脱敏(passwords/tokens/api_keys/email)
### M8.6 提示词模板管理
- [ ] `GET /prompt-templates` — 模板列表(scope/key 筛选)
- [ ] `POST /prompt-templates` — 创建
- [ ] `PATCH /prompt-templates/{id}` — 编辑
- [ ] `GET /prompt-templates/{id}/versions` — 版本历史
- [ ] `POST /prompt-templates/{id}:rollback` — 回滚版本
- [ ] `GET/POST /prompt-presets` — 预设管理
- [ ] Skill 热更新(文件监听 + Redis pub/sub)
### M8.7 前端接入
- [ ] TeamManagement 页面接入
- [ ] AssetsManagement 页面接入(个人/团队资产库)
- [ ] ProjectSettings 页面接入(成员管理 + 权限)
- [ ] 通知中心 UI
- [ ] 提示词模板覆盖 UI
---
## M9 · 上线与优化(第 25~26 周)
> 完成标准: 系统稳定运行,可对外开放
### M9.1 性能优化
- [ ] 慢 SQL 排查与优化
- [ ] N+1 查询检测与修复
- [ ] 热数据缓存策略(Spring Cache + Redis,TTL 配置)
- [ ] 大表分区(tasks/balance_transactions/audit_logs 按时间分区)
### M9.2 压测
- [ ] JMeter / k6 压测脚本编写
- [ ] API 接口压测(目标 p99 < 2s)
- [ ] MQ 消费能力压测
- [ ] 并发用户场景模拟
### M9.3 监控与告警
- [ ] Prometheus + Grafana 监控面板搭建
- [ ] API 延迟 / 5xx 率
- [ ] MQ 队列积压
- [ ] Worker 失败率
- [ ] DB 慢查询
- [ ] AI Provider 错误率
- [ ] 余额扣减异常
- [ ] 任务 stuck 检测
- [ ] SkyWalking / OpenTelemetry 链路追踪
- [ ] 告警接入钉钉/企业微信
### M9.4 安全加固
- [ ] HTTPS 全站(Let's Encrypt)
- [ ] WAF 配置(阿里云 / Cloudflare)
- [ ] SQL 注入防护验证
- [ ] XSS 防护验证
- [ ] 依赖扫描(Dependabot + Snyk)
- [ ] API Key 加密存储(Jasypt + KMS)
### M9.5 部署
- [ ] K8s Helm chart 编写
- [ ] HPA 配置(API 按 CPU,Worker 按队列深度)
- [ ] Secret 管理(ExternalSecrets + Vault)
- [ ] 灰度发布方案
- [ ] 蓝绿部署 / 滚动更新策略
### M9.6 运营后台
- [ ] 用户查询
- [ ] 订单查询
- [ ] 运营数据看板
- [ ] Runbook 编写(视频任务卡住/余额异常/Worker OOM)
### M9.7 文档
- [ ] 用户使用文档
- [ ] API 文档(OpenAPI 导出)
- [ ] 部署文档
- [ ] 运维手册
### M9.8 上线
- [ ] Staging 环境全流程验证
- [ ] 数据迁移计划
- [ ] 上线 checklist 走查
- [ ] 生产发布 🚀
---
## 补充任务(跨阶段)
### 前端模型配置(M2 后可做)
- [ ] ModelSettings 页面接入
- [ ] 语言模型配置(通义千问 Max/Plus/Turbo)
- [ ] 图片模型配置(通义万相/DALL-E)
- [ ] 视频模型配置(通义万相/Seedance/Kling)
- [ ] 模型配置 CRUD API
- [ ] `GET /models/config` — 获取项目模型配置
- [ ] `PUT /models/config` — 更新模型配置
- [ ] `GET /models/available` — 可用模型列表
### 设计文档同步更新
- [ ] doc 02 更新:风格数量从 6 种改为 9+ 种
- [ ] doc 07 补充:子账号管理 API、模型配置 CRUD API、充值/购买 API、资产批量操作 API
- [ ] doc 09 调整:购买页提前(原型已实现,原计划 M7)
- [ ] doc 11 更新:里程碑时间线根据实际进度调整
### 未来版本(v2+)
- [ ] 第三方登录(Sa-Token-OAuth2: Google/GitHub/微信/钉钉)
- [ ] 密码重置(邮件发送)
- [ ] 口型同步(LipSync,Pro 功能)
- [ ] 移动端 App(React Native)
- [ ] 实时协作编辑分镜(CRDT / Yjs + WebSocket)
- [ ] 模型市场(用户上传 LoRA)
- [ ] 资产市场(付费分享角色/场景)
- [ ] 多语言配音(英日韩)
- [ ] 导出到剪映/PR 的 XML 工程
- [ ] 开放 API(面向第三方开发者)
- [ ] GDPR 数据导出/删除
---
## 进度统计
| 阶段 | 总任务数 | 已完成 | 进度 |
|------|---------|--------|------|
| M0 · 基建 | 30 | 0 | 0% |
| M1 · 认证与项目 | 30 | 0 | 0% |
| M2 · 剧本解析 | 45 | 0 | 0% |
| M3 · 资产生成 | 28 | 0 | 0% |
| M4 · 分镜工作台 | 35 | 0 | 0% |
| M5 · 视觉生成 | 22 | 0 | 0% |
| M6 · 合成与导出 | 18 | 0 | 0% |
| M7 · 计费与 SaaS | 28 | 0 | 0% |
| M8 · 团队协作 | 30 | 0 | 0% |
| M9 · 上线优化 | 28 | 0 | 0% |
| 补充任务 | 8 | 0 | 0% |
| **总计** | **~302** | **0** | **0%** |
---
---
今天完成(M0)
- 16模块 Maven 多模块项目从零搭建完毕
- 核心模块:common / api / bootstrap,含统一响应体、全局异常、Swagger、请求ID过滤器
- Flyway 数据库迁移(tenants / users / tenant_members)
- docker-compose:MySQL / Redis / RabbitMQ
- Dockerfile 多阶段构建
- Git 初始化 + 2 个 commit
- 前端:TanStack Query + Zustand + Axios 骨架接入
- Swagger UI 验证通过,/ping 正常响应
---
---
M1 完成情况总结:
后端:
- yaoai-domain:Tenant / User / TenantMember 实体 + MyBatis-Plus Mapper
- yaoai-security:TenantContext(ThreadLocal)、Sa-Token 拦截器(保护除 /auth/**
以外的所有路由)、AuthService(注册/登录/登出/me)
- yaoai-api:AuthController(4 个端点)、更新 GlobalExceptionHandler(处理 401/403)
- yaoai-bootstrap:Redis + MyBatis-Plus + Sa-Token 配置写入 application.yml、DevDataInitializer(local profile
首次启动创建 admin@yaoai.com / Admin@123)
前端:
- src/lib/api/auth.ts:API 调用层
- src/hooks/useAuth.ts:TanStack Query mutations + Zustand store 联动
- src/app/pages/LoginPage.tsx / RegisterPage.tsx:登录/注册表单
- src/app/components/PrivateRoute.tsx:路由保护
- routes.tsx:添加 /login、/register,所有已有路由被 PrivateRoute 包裹
验证方式:
1. 启动 Docker:docker compose up -d
2. 运行(须用 Java 17):JAVA_HOME="D:/Program Files/Java/jdk-17" mvn package -pl yaoai-bootstrap -am -DskipTests &&
java -jar yaoai-bootstrap/target/yaoai-bootstrap-0.1.0-SNAPSHOT.jar
3. 测试:POST /auth/login {"email":"admin@yaoai.com","password":"Admin@123"} → 收到 token
4. 用 token 访问 GET /auth/me → 返回用户信息
5. 不带 token 访问 /ping → 200(开放);不带 token 访问其他接口 → 401
# 17 · 平台运营后台设计 (M14)
# 17 · 平台运营后台设计 (M14)
> 生成时间: 2026-04-25
> 范围: yaoai-comic-studio 配套的"平台运营后台",供平台方运营/财务团队跨租户管理客户公司、积分充值、计费对账、运营人员与权限管理。
> 技术栈: 后端 Spring Boot 3.x + Sa-Token 双账号 + MyBatis-Plus;前端 Vue3 + vue-vben-admin v5 + Element Plus
---
## 1. 范围与边界
### 1.1 做
- 跨租户管理(看所有公司、暂停/恢复、变更套餐、配额覆盖)
- **手动给租户充值积分**(核心需求)
- 跨租户用户管理(禁用、强制下线、重置密码)
- 全平台计费记录查询与汇总(充值/消费/退款)
- 套餐定义与配额维护
- 运营人员账号管理(多角色 RBAC)
- 审计日志(运营操作可追溯)
- 运营人员模拟登录租户(impersonate,全程审计)
### 1.2 暂不做
- 支付集成(按用户决策)
- 订阅自动续费(无支付即无意义)
- 运营人员邮件/短信通知
- GDPR 数据导出(设计文档 09 §7,单独里程碑)
- 2FA / SSO(运营账号数量少,先用强密码)
---
## 2. 关键架构决策
### 2.1 模块归属
将现有 `yaoai-api/.../admin/AdminController` **迁移到 `yaoai-admin` 模块**
**理由:**
- 设计文档 05 §2 已规划好 `yaoai-admin` 独立模块,目前是空壳
- 继续放在 `yaoai-api` 会让客户端 jar 包含运营代码,部署边界不清
- 未来 admin 服务独立部署时,模块边界已经划好
### 2.2 认证体系:Sa-Token 双账号 (StpAdminUtil)
设计文档 05 §5.1 已经明确"`StpUtil`(用户端)/ `StpAdminUtil`(运营后台),互不干扰"。当前 `AdminController.requireAdmin()` 用的是 `StpUtil` + `users.role`,等于运营和客户共用一套 token,越权风险大。
新方案:
| 维度 | 客户端 | 运营端 |
|---|---|---|
| Stp 实例 | `StpUtil` (默认) | `StpAdminUtil` (`StpLogic("admin")`) |
| Redis Key | `sa-token:login:token:*` | `sa-token:admin:login:token:*` |
| 用户表 | `users` | `admin_users` (新增) |
| URL 前缀 | `/api/**` | `/admin-api/**` |
| Header | `satoken` | `satoken-admin` |
部署上 admin 服务建议独立端口(8081)+ 内部 ingress + IP 白名单。
### 2.3 跨租户数据访问
admin 模块所有 Mapper 方法显式标注 `@InterceptorIgnore(tenantLine = "true")`,绕过 `TenantLineInnerInterceptor`。比"清理 TenantContext"更显式可读。
### 2.4 充值与暂停的业务规则
- **充值不允许给 SUSPENDED 状态的租户**:避免账务混乱
- **变更套餐**会同步修改 `tenant_quotas`:套餐定义里的字段(max_members 等)覆盖到租户配额表
- **暂停租户**会调用 `StpUtil.kickout(userId)` 把该租户所有用户踢下线
---
## 3. 数据模型
### 3.1 新增 4 张表
```sql
-- 运营人员(独立于 users,避免 role 字段混乱)
CREATE TABLE admin_users (
id BIGINT PK AUTO_INCREMENT,
username VARCHAR(64) UNIQUE NOT NULL,
password_hash VARCHAR(100) NOT NULL,
display_name VARCHAR(128),
email VARCHAR(255),
role ENUM('SUPER_ADMIN','OPERATOR','FINANCE','READONLY') NOT NULL,
status ENUM('ACTIVE','DISABLED') DEFAULT 'ACTIVE',
last_login_at DATETIME,
last_login_ip VARCHAR(45),
created_by BIGINT,
created_at DATETIME, updated_at DATETIME
);
-- 运营操作审计(与设计文档 09 §6 的 audit_logs 区分:那张表是租户内业务审计)
CREATE TABLE admin_audit_logs (
id BIGINT PK AUTO_INCREMENT,
admin_user_id BIGINT NOT NULL,
admin_username VARCHAR(64), -- 冗余,账号删除后仍可追溯
action VARCHAR(64), -- TENANT_RECHARGE / TENANT_SUSPEND / PLAN_CHANGE / USER_DISABLE / IMPERSONATE_START / ...
resource_type VARCHAR(32),
resource_id VARCHAR(64),
before_value JSON,
after_value JSON,
ip VARCHAR(45), user_agent VARCHAR(255),
request_id VARCHAR(64),
status ENUM('SUCCESS','FAILED') DEFAULT 'SUCCESS',
error_message TEXT,
created_at DATETIME,
INDEX idx_admin_time (admin_user_id, created_at),
INDEX idx_resource (resource_type, resource_id),
INDEX idx_action_time (action, created_at)
);
-- 套餐定义(设计文档 01 §4.1 是硬编码,现持久化)
CREATE TABLE plans (
id BIGINT PK AUTO_INCREMENT,
code ENUM('FREE','PRO','TEAM','ENTERPRISE') UNIQUE NOT NULL,
name VARCHAR(64),
price_monthly DECIMAL(10,2),
monthly_credits INT,
max_members INT,
max_projects INT,
max_concurrent_tasks INT,
features JSON,
is_active BOOLEAN DEFAULT TRUE,
updated_at DATETIME, updated_by BIGINT
);
-- 租户配额覆盖(设计文档 09 §5.1 规划过,现落地)
CREATE TABLE tenant_quotas (
tenant_id BIGINT PK,
max_members INT,
max_projects INT,
max_monthly_credits INT,
max_concurrent_tasks INT,
monthly_budget_limit DECIMAL(18,4),
notes TEXT,
updated_at DATETIME,
updated_by BIGINT
);
```
### 3.2 现有表的微改动
```sql
-- tenants 加暂停元信息
ALTER TABLE tenants
ADD COLUMN suspended_at DATETIME NULL,
ADD COLUMN suspended_by BIGINT NULL,
ADD COLUMN suspended_reason VARCHAR(255) NULL,
ADD COLUMN notes TEXT NULL;
```
### 3.3 ⭐ billing_records 模型粒度升级(核心需求)
**问题:** 现有 `billing_records` 只有 `operation`(6 个枚举:outline_generate / episode_generate / image_generate / video_generate / assembly / recharge),完全无法回答运营/客户的真实问题:
> "这个月用 Seedream 生了多少图、花了多少积分?为什么 Wanx 比 Seedance 贵?"
> "张三这周生成视频 30 次有没有滥用?"
**升级方案:**`billing_records` 加 8 列,把"模型 + 用户 + 数量 + 元信息"全部记下来。
```sql
ALTER TABLE billing_records
-- 充值发起人(运营手动充值时记录)
ADD COLUMN created_by_admin_id BIGINT NULL COMMENT '运营人员手动充值时记录',
-- 谁发起的(租户内用户)
ADD COLUMN user_id BIGINT NULL COMMENT '租户内发起操作的用户',
-- 模态:TEXT / IMAGE / VIDEO / AUDIO / COMPOSE / RECHARGE
ADD COLUMN modality VARCHAR(16) NULL COMMENT '生成模态',
-- 模型三元组
ADD COLUMN model_provider VARCHAR(32) NULL COMMENT 'volcengine/qwen/openai',
ADD COLUMN model_id VARCHAR(64) NULL COMMENT 'seedream-3-0-t2i / seedance-1-0 / qwen-max',
ADD COLUMN model_name VARCHAR(128) NULL COMMENT '展示名,如"Seedream 3.0 文生图"',
-- 数量与状态
ADD COLUMN unit_count INT NULL DEFAULT 1 COMMENT '本次生成数量(图片张数/视频段数/字符数)',
ADD COLUMN success TINYINT(1) NULL DEFAULT 1 COMMENT '成功失败',
-- 元信息:分辨率、时长、token、画幅比等
ADD COLUMN billing_meta JSON NULL,
ADD INDEX idx_modality_model (modality, model_provider, model_id),
ADD INDEX idx_user_created (user_id, created_at);
```
**配套:BillingService.charge() 签名升级**
```java
// 旧
void charge(Long tenantId, Long projectId, String operation, String refId);
// 新(保留旧的做兜底,新签名优先)
void charge(BillingChargeRequest req);
@Data @Builder
public class BillingChargeRequest {
Long tenantId;
Long userId; // ⭐ 新
Long projectId;
String operation; // outline_generate / image_generate / ...
String modality; // TEXT / IMAGE / VIDEO / AUDIO / COMPOSE
String modelProvider; // volcengine / qwen / openai
String modelId; // seedream-3-0-t2i
String modelName; // Seedream 3.0 文生图
Integer unitCount; // 默认 1
Boolean success; // 默认 true
BigDecimal credits; // 可选,如不传按 BillingConfig.getCost(operation)
Map<String,Object> meta;// 分辨率/时长/token...
String refId;
}
```
**5 个现有调用点全部改造**(详见 §7.7):
| 文件 | 模型来源 |
|---|---|
| `OutlinePipelineServiceImpl.java:92` (outline) | `ArkLlmService` 当前用的 model(qwen-max 之类) |
| `OutlinePipelineServiceImpl.java:143` (episode) | 同上 |
| `ImageGenPipelineServiceImpl.java:42` (角色/场景图) | `SeedreamService` 用的 model_id |
| `VideoTaskPipelineServiceImpl.java:51``VideoTaskAsyncProcessor.java:75` (视频) | `SeedanceService` 用的 model_id |
| `AssemblyPipelineServiceImpl.java:112` (合成) | 没有外部模型,modality=COMPOSE,model_provider=ffmpeg |
### 3.4 初始化数据
Flyway V13 中 INSERT:
- 4 个套餐 (FREE/PRO/TEAM/ENTERPRISE),参数对齐设计文档 01 §4.1
- 1 个 SUPER_ADMIN 账号 `admin / admin@123!`,登录后强制改密
- (可选)`models` 字典表,把 `model_id → 展示名` 维护一份(也可以不建表,直接由租户的 `tenant_model_providers` 表反查)
---
## 4. API 设计
URL 统一前缀 `/admin-api`,所有端点要求 `@SaCheckLogin(type="admin")`
### 4.1 认证
```
POST /admin-api/auth/login # 用户名+密码 → { token, userInfo }
POST /admin-api/auth/logout
GET /admin-api/auth/me
POST /admin-api/auth/change-password
```
### 4.2 仪表盘
```
GET /admin-api/dashboard/overview # 总租户/活跃租户/总用户/今日新增/今日消费/今日充值
GET /admin-api/dashboard/trends?days=30 # 按天的新增/消费/充值趋势
GET /admin-api/dashboard/tasks # 任务统计:成功率、平均耗时、按 type 分组
GET /admin-api/dashboard/providers # AI 调用量与失败率
```
### 4.3 租户管理
```
GET /admin-api/tenants # 分页+plan/status筛选+搜索
GET /admin-api/tenants/{id} # 详情
PATCH /admin-api/tenants/{id} # 改 name/notes
PUT /admin-api/tenants/{id}/plan # 变更套餐 (body: planCode)
POST /admin-api/tenants/{id}/suspend # body: reason
POST /admin-api/tenants/{id}/resume
GET /admin-api/tenants/{id}/balance # 当前余额 + 最近 30 天交易
POST /admin-api/tenants/{id}/recharge # body: amount, note, externalRefId
GET /admin-api/tenants/{id}/members
GET /admin-api/tenants/{id}/projects
PUT /admin-api/tenants/{id}/quota # 配额覆盖
POST /admin-api/tenants/{id}/impersonate # 模拟登录该租户某用户 → 返回客户端 token
```
### 4.4 用户管理
```
GET /admin-api/users # 跨租户列表(分页+搜索 email/username)
GET /admin-api/users/{id}
PATCH /admin-api/users/{id}/status # ACTIVE/DISABLED
POST /admin-api/users/{id}/kickout # StpUtil.kickout()
POST /admin-api/users/{id}/reset-password # 生成随机密码,返回一次
```
### 4.5 计费与积分明细 ⭐
```
# 原始记录
GET /admin-api/billing/records?modality=&modelId=&tenantId=&userId=&from=&to=&page=&size=
GET /admin-api/billing/recharges # 充值记录专用视图
GET /admin-api/billing/consumes # 消费记录专用视图
# 多维度汇总(核心:回答"哪个模型/谁/哪天 花了多少积分")
GET /admin-api/billing/summary?dim=modality # 按模态汇总(TEXT/IMAGE/VIDEO 各多少)
GET /admin-api/billing/summary?dim=model # 按模型汇总(Seedream/Seedance/Qwen 各多少)
GET /admin-api/billing/summary?dim=tenant # 按租户汇总
GET /admin-api/billing/summary?dim=user&tenantId=xxx # 某租户内按用户汇总
GET /admin-api/billing/summary?dim=day # 按天汇总(趋势图)
GET /admin-api/billing/summary?dim=project # 按项目汇总
# 单租户的积分明细页(运营查客户问题用)
GET /admin-api/tenants/{id}/usage-detail
?from=&to=&groupBy=modality|model|user|day
→ {
total: 1234,
breakdown: [
{ key: "Seedream 3.0 文生图", credits: 540, count: 36, percent: 43.7 },
{ key: "Seedance 1.0 视频", credits: 480, count: 8, percent: 38.9 },
{ key: "Qwen Max 文本", credits: 214, count: 142,percent: 17.4 }
]
}
```
### 4.6 套餐与配额
```
GET /admin-api/plans # 套餐列表
PATCH /admin-api/plans/{id} # 改月度积分/上限/价格
```
### 4.7 运营人员(仅 SUPER_ADMIN)
```
GET /admin-api/admin-users
POST /admin-api/admin-users
PATCH /admin-api/admin-users/{id} # 改 role/status/email
POST /admin-api/admin-users/{id}/reset-password
```
### 4.8 审计日志
```
GET /admin-api/audit-logs?adminId=&action=&resourceType=&from=&to=
```
---
## 5. 角色权限矩阵
| 端点类别 | SUPER_ADMIN | OPERATOR | FINANCE | READONLY |
|---|:-:|:-:|:-:|:-:|
| 仪表盘 | ✓ | ✓ | ✓ | ✓ |
| 租户列表/详情/成员/项目 | ✓ | ✓ | ✓ | ✓ |
| 租户暂停/恢复/套餐变更 | ✓ | ✓ | | |
| **租户充值** | ✓ | | ✓ | |
| 租户配额修改 | ✓ | | | |
| 租户 impersonate | ✓ | ✓ | | |
| 用户禁用/强制下线/重置密码 | ✓ | ✓ | | |
| 计费记录查询/汇总 | ✓ | ✓ | ✓ | ✓ |
| 套餐参数修改 | ✓ | | | |
| 运营人员管理 | ✓ | | | |
| 审计日志查询 | ✓(全量) | ✓(自己) | ✓(自己) | ✓(自己) |
---
## 6. 模块代码组织
```
yaoai-admin/
├── pom.xml # 依赖 yaoai-domain / yaoai-billing / yaoai-security / yaoai-common
└── src/main/java/com/yaoai/admin/
├── AdminModuleConfig.java
├── auth/
│ ├── entity/AdminUser.java
│ ├── mapper/AdminUserMapper.java # @InterceptorIgnore(tenantLine="true")
│ ├── AdminAuthController.java
│ ├── AdminAuthService.java
│ ├── StpAdminUtil.java # StpLogic("admin")
│ └── AdminPermissionInterface.java # 实现 StpInterface 注入运营角色
├── dashboard/
├── tenant/
│ ├── TenantAdminController.java
│ ├── TenantAdminService.java
│ ├── ImpersonateService.java
│ └── dto/...
├── user/
├── billing/
├── plan/
├── audit/
│ ├── entity/AdminAuditLog.java
│ ├── annotation/AdminAudited.java
│ ├── aspect/AdminAuditAspect.java
│ └── AdminAuditService.java
└── config/
├── SaTokenAdminConfig.java # 注册 StpLogic + 路由拦截
├── AdminGlobalExceptionHandler.java
└── AdminWebConfig.java # /admin-api/** 路由
```
---
## 7. 关键实现要点
### 7.1 Sa-Token 双账号注册
```java
@Component
public class StpAdminUtil {
public static final StpLogic INSTANCE = new StpLogic("admin");
static { SaManager.putStpLogic(INSTANCE); }
public static void login(Long adminId) { INSTANCE.login(adminId); }
public static long getLoginIdAsLong() { return INSTANCE.getLoginIdAsLong(); }
// ...
}
// Controller 用法
@SaCheckLogin(type = "admin")
@SaCheckRole(value = "SUPER_ADMIN", type = "admin")
@PostMapping("/admin-api/admin-users")
```
### 7.2 审计切面(@AdminAudited)
```java
@AdminAudited(action = "TENANT_RECHARGE", resource = "TENANT", resourceIdParam = "tenantId")
public BalanceDTO recharge(Long tenantId, BigDecimal amount, String note) { ... }
```
切面在方法前后抓取实体快照,写入 `admin_audit_logs``before_value` / `after_value`。失败也记录(status=FAILED)。
### 7.3 充值流程(无支付的极简版)
```
1. 校验 amount > 0 且 <= 上限(防误操作,例如单笔 ≤ 1000 万积分)
2. 校验 tenant 存在且 status != SUSPENDED
3. 调用 BillingService.recharge(tenantId, amount, "ADMIN:" + adminId)
4. billing_records 写一条 RECHARGE,created_by_admin_id = currentAdminId
5. 切面写 admin_audit_logs(before/after 余额)
```
### 7.4 Impersonate 模拟登录
```
1. 校验运营有 IMPERSONATE 权限
2. 选择目标租户的 owner 用户(或指定 userId)
3. 用客户端 StpUtil 颁发临时 token,TTL = 30 分钟
4. token 的 session 写入 impersonatedBy = adminId(用于服务端追溯)
5. 审计日志记录 IMPERSONATE_START + 租户 ID + 目标 userId
6. 客户端用此 token 访问 /api/**,所有操作都额外带 X-Impersonated-By header
```
### 7.5 套餐变更同步配额
变更套餐时事务内同步执行:
```
UPDATE tenants SET plan = ? WHERE id = ?;
INSERT INTO tenant_quotas (...) ON DUPLICATE KEY UPDATE max_members = ?, ...;
```
配额来自 `plans` 表对应行;如果租户已有 `tenant_quotas` 行(企业版定制),保留覆盖项。
### 7.6 首个 SUPER_ADMIN 怎么来
Flyway V13 INSERT 一条 `admin / admin@123!`(BCrypt salt=12)。系统检测到是默认密码时,登录后强制弹"必须修改密码"对话框。
### 7.7 ⭐ charge() 签名升级与 5 个调用点改造
**Step 1: BillingService 接口加新签名(保留旧的做兜底)**
```java
public interface BillingService {
/** @deprecated 用 charge(BillingChargeRequest) */
@Deprecated
void charge(Long tenantId, Long projectId, String operation, String refId);
void charge(BillingChargeRequest req);
}
```
**Step 2: BillingServiceImpl.charge(req) 实现**
```java
@Override
@Transactional
@CacheEvict(value = "balance", key = "#req.tenantId")
public void charge(BillingChargeRequest req) {
if (billingConfig.isOff()) return;
BigDecimal cost = req.getCredits() != null
? req.getCredits()
: billingConfig.getCost(req.getOperation());
if (cost.compareTo(BigDecimal.ZERO) <= 0) return;
if (billingConfig.isStrict() && Boolean.TRUE.equals(req.getSuccess())) {
int rows = tenantBalanceMapper.tryDeduct(req.getTenantId(), cost);
if (rows == 0) throw new BizException(ErrorCode.INVALID_PARAM, "积分余额不足");
}
BillingRecord r = new BillingRecord();
r.setTenantId(req.getTenantId());
r.setUserId(req.getUserId());
r.setProjectId(req.getProjectId());
r.setOperation(req.getOperation());
r.setModality(req.getModality());
r.setModelProvider(req.getModelProvider());
r.setModelId(req.getModelId());
r.setModelName(req.getModelName());
r.setUnitCount(req.getUnitCount() == null ? 1 : req.getUnitCount());
r.setSuccess(req.getSuccess() == null || req.getSuccess());
r.setCredits(cost);
r.setBillingMode(billingConfig.getMode());
r.setRefId(req.getRefId());
if (req.getMeta() != null) r.setBillingMeta(JSON.toJSONString(req.getMeta()));
billingRecordMapper.insert(r);
}
```
**Step 3: 5 个调用点改造示例**
OutlinePipelineServiceImpl.java:92 改造前:
```java
billingService.charge(tenantId, projectId, "outline_generate", String.valueOf(projectId));
```
改造后:
```java
billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId).userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("outline_generate")
.modality("TEXT")
.modelProvider("volcengine")
.modelId(arkLlmService.currentModel()) // 从 ArkLlmService 暴露
.modelName("豆包 Pro / Qwen Max")
.unitCount(1)
.meta(Map.of("tokenCount", outline.getTokenCount()))
.refId(String.valueOf(projectId))
.build());
```
ImageGenPipelineServiceImpl.java:42 改造:
```java
billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId).userId(currentUserId).projectId(projectId)
.operation("image_generate")
.modality("IMAGE")
.modelProvider("volcengine")
.modelId(seedreamService.modelId()) // "doubao-seedream-3-0-t2i-250415"
.modelName("Seedream 3.0 文生图")
.unitCount(1)
.meta(Map.of("size", "2048x2048", "purpose", "scene"))
.refId(key)
.build());
```
视频/合成同理。**所有 AI Provider Service 必须暴露 `modelId()` 方法**`SeedreamService.modelId()` / `SeedanceService.modelId()` / `ArkLlmService.currentModel()`),让 charge 调用方拿到。
**Step 4: 失败也要记一笔(success=false, credits=0)**
任务失败时 charge 一条 `success=false, credits=0` 的记录,便于运营回答"为什么生成 10 次扣了 30 积分但只看到 5 张图"——另外 5 次失败也有迹可循。
### 7.8 积分明细的 SQL 模板(运营最常问)
按模型汇总:
```sql
SELECT model_provider, model_id, model_name,
SUM(credits) AS total_credits,
SUM(unit_count) AS total_units,
SUM(IF(success, 1, 0)) AS success_count,
SUM(IF(success, 0, 1)) AS fail_count
FROM billing_records
WHERE tenant_id = ?
AND created_at BETWEEN ? AND ?
AND modality != 'RECHARGE'
GROUP BY model_provider, model_id, model_name
ORDER BY total_credits DESC;
```
按用户汇总:
```sql
SELECT br.user_id, u.email,
SUM(br.credits) AS spent,
COUNT(*) AS ops
FROM billing_records br
LEFT JOIN users u ON u.id = br.user_id
WHERE br.tenant_id = ?
AND br.created_at BETWEEN ? AND ?
AND br.modality != 'RECHARGE'
GROUP BY br.user_id, u.email
ORDER BY spent DESC;
```
按天趋势:
```sql
SELECT DATE(created_at) AS day,
SUM(IF(modality='TEXT', credits, 0)) AS text_credits,
SUM(IF(modality='IMAGE', credits, 0)) AS image_credits,
SUM(IF(modality='VIDEO', credits, 0)) AS video_credits,
SUM(IF(modality='RECHARGE', credits, 0)) AS recharge
FROM billing_records
WHERE tenant_id = ?
AND created_at BETWEEN ? AND ?
GROUP BY DATE(created_at)
ORDER BY day;
```
---
## 8. 前端项目: yaoai-admin-web
### 8.1 技术栈
| 项 | 选择 |
|---|---|
| 脚手架 | **vue-vben-admin v5** ([github.com/vbenjs/vue-vben-admin](https://github.com/vbenjs/vue-vben-admin)) |
| Vue | 3.5+ |
| 构建 | Vite 6 |
| 语言 | TypeScript 5 |
| UI | Element Plus(vben 已封装) |
| 状态 | Pinia |
| 表单/表格 | VbenForm / VbenVxeTable(vben 自带,基于 vxe-table) |
| HTTP | Axios(vben 封装) |
| 包管理 | pnpm |
### 8.2 初始化策略
vben v5 是 monorepo 结构,含多个 apps(web-ele / web-antd / web-naive)。我们的策略:
```bash
# 项目根目录
cd F:/yaoaiwork/yaoaivideo
git clone https://github.com/vbenjs/vue-vben-admin.git yaoai-admin-web
cd yaoai-admin-web
# 只保留 apps/web-ele(Element Plus 版)和必要的 packages/*
# 删除 apps/web-antd / apps/web-naive / docs / playground
# 把 apps/web-ele 重命名为 apps/admin
pnpm install
pnpm dev:admin
```
> 也可以用 vben "极简版"(standalone),但 monorepo 结构对未来扩展(比如再做一个客户公司的二级管理后台)更友好。
### 8.3 关键集成改造
#### (1) HTTP 客户端对接 Sa-Token
vben 默认用 Authorization Bearer,改为 Sa-Token 习惯的 `satoken-admin` header:
```typescript
// apps/admin/src/api/request.ts
import { useAccessStore } from '@vben/stores';
export const adminRequest = createAxiosInstance({
baseURL: import.meta.env.VITE_GLOB_API_URL, // /admin-api
});
adminRequest.addRequestInterceptor({
fulfilled: (config) => {
const token = useAccessStore().accessToken;
if (token) config.headers['satoken-admin'] = token;
return config;
},
});
adminRequest.addResponseInterceptor({
rejected: (err) => {
if (err.response?.status === 401) {
useAccessStore().setAccessToken(null);
router.replace('/auth/login');
}
return Promise.reject(err);
},
});
```
#### (2) 登录/用户信息接口
```typescript
// apps/admin/src/api/core/auth.ts
export const loginApi = (data: { username: string; password: string }) =>
adminRequest.post<{ token: string }>('/auth/login', data);
export const getUserInfoApi = () =>
adminRequest.get<AdminUserInfo>('/auth/me');
export const logoutApi = () => adminRequest.post('/auth/logout');
```
#### (3) 路由与菜单(静态 + 角色过滤)
不走 vben 默认的"完全动态路由从后端拉",MVP 阶段用静态路由 + `meta.authority` 按角色过滤:
```typescript
// apps/admin/src/router/routes/modules/admin.ts
const routes: RouteRecordRaw[] = [
{
path: '/dashboard',
component: BasicLayout,
meta: { title: '仪表盘', icon: 'lucide:layout-dashboard' },
children: [{ path: '', component: () => import('#/views/dashboard/index.vue') }],
},
{ path: '/tenants', meta: { title: '租户管理', icon: 'lucide:building-2' }, children: [...] },
{ path: '/users', meta: { title: '用户管理', icon: 'lucide:users' }, children: [...] },
{ path: '/billing', meta: { title: '计费记录', icon: 'lucide:receipt' }, children: [...] },
{ path: '/plans', meta: { title: '套餐管理', icon: 'lucide:package', authority: ['SUPER_ADMIN'] }, children: [...] },
{ path: '/admin-users', meta: { title: '运营人员', icon: 'lucide:shield', authority: ['SUPER_ADMIN'] }, children: [...] },
{ path: '/audit-logs', meta: { title: '审计日志', icon: 'lucide:scroll-text' }, children: [...] },
];
```
#### (4) 按钮级权限
vben 提供 `<AccessControl>` 组件 + `hasAccessByRoles()` hook:
```vue
<AccessControl :codes="['SUPER_ADMIN', 'FINANCE']">
<Button type="primary" @click="openRechargeModal">充值</Button>
</AccessControl>
```
### 8.4 页面实现要点
| 页面 | 主要 vben 组件 | 关键点 |
|---|---|---|
| AdminLogin | 自定义(vben 默认登录页改造) | 登录后存 token + 用户信息到 Pinia |
| Dashboard | VbenCountUp + ECharts(vben 内置) | 4 个总览卡 + 30 天趋势折线图(按模态分色) |
| TenantList | VbenVxeTable + VbenForm | 表格分页/搜索/批量操作 |
| TenantDetail | Tabs + Descriptions | **5 个 Tab:基本/余额/成员/项目/⭐积分明细** |
| 充值弹窗 | VbenModal + VbenForm | 二次确认 + 金额上限校验 + 备注必填 |
| **⭐ 积分明细 Tab** | VbenVxeTable + ECharts(饼图+柱图) + VbenForm 筛选 | 4 种 groupBy 视图切换:模态/模型/用户/天 |
| BillingRecords | VbenVxeTable + 模型筛选下拉 | 全平台明细表,列:时间/租户/用户/模态/模型/数量/积分/成功 |
| AuditLogs | VbenVxeTable + VbenDrawer | 详情抽屉展开 before/after JSON 对比 |
| AdminUserManagement | VbenVxeTable + 表单弹窗 | 仅 SUPER_ADMIN 可见 |
**积分明细 Tab 布局(核心新增)**
```
┌─ 时间范围筛选: [近7天] [近30天] [本月] [自定义] ─┐
│ │
├─ Tabs: [按模型] [按模态] [按用户] [按天] │
│ │
│ 按模型视图: │
│ ┌─ 饼图 ──────────┬─ 表格 ──────────────────┐ │
│ │ Seedream 43.7% │ 模型 │ 调用 │ 积分 │ 失败 │ │
│ │ Seedance 38.9% │ ... │ │
│ │ Qwen Max 17.4% │ │ │
│ └─────────────────┴──────────────────────────┘ │
│ │
│ 按天视图: │
│ ┌─ 堆叠柱图(文本/图/视频/合成 4 色)────────┐ │
│ │ ▮▮▮▮▮▮▮▮▮▮ 30 天趋势 │ │
│ └────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
```
### 8.5 部署
```
yaoai-admin-web/apps/admin/dist/ (Vite build 产物)
nginx
├─ / → 静态文件
└─ /admin-api/* → 反代到 admin server :8081
```
```dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile && pnpm build:admin
FROM nginx:alpine
COPY --from=builder /app/apps/admin/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
### 8.6 安全部署
- 独立域名 `admin.yaoai.com` 或子路径 `yaoai.com/admin/`
- nginx 配置 IP 白名单(公司公网出口 / VPN 网段)
- 强制 HTTPS
- CSP header:`default-src 'self'`,禁第三方资源
- 浏览器关闭后自动登出(Sa-Token 设置 token-style=`tik`,前端不持久化 token 到 localStorage,改用 sessionStorage)
---
## 9. 实施路线图(M14)
> ⚠️ Flyway 当前最新版本 V12(V10/V11/V12 已被占用),M14 从 **V13** 开始。
| Step | 内容 | 工时 |
|:-:|---|:-:|
| 1 | Flyway V13:4 张新表 + tenants ALTER + ⭐billing_records 8 列升级 + 套餐/初始 admin 数据 | 0.5d |
| 2 | ⭐ BillingService.charge(req) 新签名 + 5 个调用点改造 + Provider 暴露 modelId() | **1d** |
| 3 | `yaoai-admin` 模块骨架 + Sa-Token 双账号 + 登录/me API | 1d |
| 4 | `@AdminAudited` 切面 + admin_audit_logs 写入 + 异常处理 | 0.5d |
| 5 | 仪表盘 + 租户管理(列表/详情/暂停/恢复/套餐变更) | 1.5d |
| 6 | 租户充值 + impersonate + 配额管理 | 0.5d |
| 7 | ⭐ 积分明细 API(按模态/模型/用户/天的 4 种汇总)+ 单租户 usage-detail | 1d |
| 8 | 用户管理 + 计费记录 + 套餐管理 + 运营人员管理 + 审计查询 | 1.5d |
| 9 | yaoai-admin-web 脚手架初始化 + Sa-Token 集成 + 9 页落地(含积分明细 Tab + ECharts 饼/柱图) | **2d** |
| 10 | 联调 + Helm chart 增加 admin-server Deployment + nginx 反代 | 1d |
| **合计** | | **~10d** |
**新增核心交付物:**
- ⭐ 升级后的 `billing_records`:每条都能回答"用谁/哪个模型/做什么/几次/成不成"
- ⭐ TenantDetail 的"积分明细" Tab:饼图按模型分布、柱图按天分模态、表格可下钻到用户
- 全平台 BillingRecords 页:跨租户多维度筛选
---
## 10. 安全与运维
### 10.1 网络
- admin server 不暴露公网,走内部 ingress
- 前端独立域名 + IP 白名单
### 10.2 账号
- 强密码策略(≥10 位,大小写+数字+特殊字符)
- 默认账号首次登录强制改密
- 登录失败 5 次锁定 30 分钟(Sa-Token 内置)
- Token TTL 2 小时 + 滑动续期 30 分钟
### 10.3 审计
- 所有"写"操作必须有 `@AdminAudited`
- impersonate 全程留痕(开始/结束 + 期间客户端 token 记 `impersonatedBy`
- audit_logs 保留 365 天,超期归档到冷存储
### 10.4 监控
- 复用现有 Prometheus + Grafana(设计文档 10)
- 新增面板:admin 登录次数、充值笔数/金额、impersonate 次数、失败操作率
---
## 11. 与现有代码的迁移
### 11.1 删除/迁移
- `yaoai-api/.../admin/AdminController.java` → 删除
- 业务逻辑迁移到 `yaoai-admin/.../tenant/TenantAdminController.java`
- 现有 `users.role` 字段保留(客户端用 `OWNER/ADMIN/MEMBER` 这种租户内角色),不再混用 admin
### 11.2 兼容性
- 前端 `doc/html` 中没有调用 `/admin/**`,迁移无影响
- 部署配置:admin 端点的 Spring profile 拆开,未启用 admin profile 时 `yaoai-admin` 模块不加载
---
## 12. 验收标准
- [ ] super_admin 登录后能看到 9 个菜单项
- [ ] finance 登录后看不到运营人员管理菜单
- [ ] readonly 登录后所有"操作"按钮置灰
- [ ] 给 SUSPENDED 租户充值返回 400
- [ ] 充值 1000 积分后,租户客户端余额刷新即时同步
- [ ] 操作日志能查到充值前后的余额对比
- [ ] impersonate 进入客户端后,关闭浏览器再回来要求重新登录(不污染原 admin token)
- [ ] 套餐从 PRO 改为 TEAM 后,租户配额 max_members 同步更新
- [ ] ⭐ 在租户详情"积分明细"Tab 切换到"按模型",能看到 Seedream/Seedance/Qwen 三种模型各自的积分占比饼图
- [ ] ⭐ 切换到"按天",能看到 30 天趋势的堆叠柱图(文本/图/视频/合成 4 色分层)
- [ ] ⭐ 切换到"按用户",能看到该租户内消费排行(用户邮箱 + 积分 + 操作次数)
- [ ] ⭐ 任务失败时也写一条 success=false / credits=0 的 billing_record,运营能在明细里看到失败次数
- [ ] ⭐ 全平台 BillingRecords 页,按"模型 = Seedream"过滤后只剩图像生成记录
# 端到端流程验证
# 端到端流程验证
> 验证日期: 2026-04-16
> 验证目标: 确认设计文档能否完整跑通AI漫剧/短剧制作全流程
---
## 结论
**这套设计文档完全能够跑通整个AI漫剧/短剧制作流程。**
从剧本上传 → 语言解析 → 图片生成 → 分镜制作 → 视频合成,每个环节都有完整的设计和实现方案。
---
## 用户流程与设计文档对应关系
```
┌─────────────────────────────────────────────────────────────────────────┐
│ 用户流程 设计文档对应 实现方式 │
├─────────────────────────────────────────────────────────────────────────┤
│ 1. 上传剧本 ──→ doc 04 Stage 1 Apache POI │
│ 解析文本 文本抽取模块 + PDFBox │
│ └─ novel_events 表 │
│ │
│ 2. 语言模型解析 ──→ doc 04 Stage 1 Spring AI │
│ 剧本 事件提取 Alibaba │
│ 角色提取 通义千问 │
│ 关系推理 Qwen-Max │
│ 智能分集 │
│ └─ characters / episodes 表 │
│ │
│ 3. 生成人物/场景 ──→ doc 04 Stage 2 通义万相 │
│ 图片 资产生成流水线 WanxImage │
│ └─ shot_assets 表 │
│ │
│ 4. 智能生成 ──→ doc 04 Stage 3 三层 Agent │
│ 分镜头 分镜生成流水线 架构 │
│ └─ storyboards 表 │
│ - 镜头拆分 (shot-split) │
│ - 导演规划 (director-plan) │
│ - 监督审核 (supervision) │
│ │
│ 5. 编辑分镜头 ──→ doc 02 StoryboardWorkspace 前端编辑器 │
│ 分镜工作台 React组件 │
│ API: CRUD / reorder / duplicate │
│ │
│ 6. 合成视频 ──→ doc 04 Stage 6-7 FFmpeg + │
│ TTS配音 Qwen TTS │
│ └─ FFmpeg 合成 字幕烧录 │
│ 最终输出: final.mp4 转场/BGM │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 各环节技术验证
### 环节1: 剧本上传与解析(M2阶段)
**设计文档**: `doc 04 § Stage 1 · 剧本解析流水线`
**技术方案**:
```java
// 文本抽取
ScriptParseExecutor
├─ Apache POI // 处理 .doc / .docx
├─ PDFBox // 处理 .pdf
├─ 原生读取 // 处理 .txt / .md
└─ 输出: scripts.raw_text
// AI 分析
CharacterExtractExecutor // Qwen-Max 提取角色
├─ 去重(同名角色合并)
├─ 关系推理(父子/恋人/敌对)
└─ 输出: characters + character_relations
EpisodeSplitExecutor // Qwen-Max 智能分集
├─ 三幕结构
├─ 付费卡点(10/30/50/70/90%
└─ 输出: episodes
```
**数据表**:
```sql
scripts -- 剧本原文
novel_events -- 事件提取结果
characters -- 角色信息
character_relations -- 角色关系
episodes -- 分集列表
```
**API接口**:
```
POST /projects/{id}/import/step1-extract 文本抽取
POST /projects/{id}/import/step2-characters 角色提取
POST /projects/{id}/import/step3-episodes 智能分集
POST /projects/{id}/import/step4-confirm 一键创建
```
---
### 环节2: 人物/场景图片生成(M3阶段)
**设计文档**: `doc 04 § Stage 2 · 资产生成流水线`
**技术方案**:
```java
// 角色四视图生成(借鉴 AIComicBuilder)
CharacterTurnaroundExecutor
├─ 参考图注入(用户上传的角色照)
├─ 艺术风格手册约束(2D日漫/国风/3D等)
├─ 生成4张图:正面/3/4//
└─ 输出: character_appearances
// 场景图生成
SceneImageExecutor
├─ 场景描述 + 风格约束
├─ 色板约束(保持色调一致)
└─ 输出: scenes.image_url
// 道具图生成
PropImageExecutor
└─ 输出: props.image_url
```
**模型支持**:
- **主模型**: 通义万相 WanxImage(支持参考图)
- **备选**: OpenAI DALL-E 3 / Gemini Imagen
**数据表**:
```sql
characters -- 角色基本信息
character_appearances -- 角色变体(四视图)
scenes -- 场景信息
props -- 道具信息
shot_assets -- 统一资产表(类型: FIRST_FRAME/REFERENCE等)
```
**API接口**:
```
POST /projects/{id}/characters/{cid}:generate-turnaround 生成四视图
POST /projects/{id}/characters/{cid}:upload-reference 上传参考图
POST /projects/{id}/scenes/{sid}:generate 生成场景图
POST /projects/{id}/props/{pid}:generate 生成道具图
```
---
### 环节3: 智能生成分镜头(M4阶段)
**设计文档**: `doc 04 § Stage 3 · 分镜生成流水线` + `doc 13 § 3.1 三层 Agent`
**技术方案**:
```java
// 三层 Agent 架构(借鉴 Toonflow)
ProductionAgentOrchestrator
├─ 决策层 DecisionAgent
└─ 分析用户意图,决定调用哪个执行层
├─ 执行层 Executor (6)
├─ DirectorPlanExecutor // 拍摄计划
├─ 景别(特写/中景/全景)
├─ 机位(平视/俯视/仰视)
├─ 运镜(推拉摇移/跟拍)
└─ 构图(三分法/中心构图)
├─ StoryboardSplitExecutor // 镜头拆分
├─ 按场景分组
├─ 按对话拆分镜头
└─ 输出: shot 列表
├─ StoryboardTableExecutor // 分镜表生成
└─ StoryboardPanelExecutor // 分镜面板写入
└─ 监督层 SupervisionAgent
└─ A/B/C/D 评分 + 审核报告
```
**分镜数据结构**:
```json
{
"sequence": 1,
"sceneNumber": "001",
"shortDescription": "张三从树后走出",
"detailedDescription": "张三正面从树后探身,左手扶剑柄,夕阳斜照",
"characters": [
{"id": 101, "name": "张三", "costume": "常服", "voice": "zh-CN-Male"}
],
"dialogues": [
{"character": "张三", "text": "谁在那里?", "emotion": "紧张"}
],
"sceneId": 201,
"props": ["剑"],
"cameraDirection": "slow zoom in",
"compositionGuide": "rule_of_thirds",
"focalPoint": "张三面部",
"duration": 8,
"transitionIn": "cut",
"transitionOut": "dissolve"
}
```
**数据表**:
```sql
storyboards -- 分镜表
├─ 描述字段
├─ characters JSON
├─ dialogues JSON
└─ camera / transition 信息
graph_runs -- Agent 运行记录
graph_steps -- 每一步的执行记录
```
**API接口**:
```
POST /projects/{id}/episodes/{eid}/storyboards:generate AI生成分镜
GET /projects/{id}/episodes/{eid}/storyboards 分镜列表
POST /projects/{id}/storyboards 手动创建
PATCH /projects/{id}/storyboards/{sid} 更新分镜
DELETE /projects/{id}/storyboards/{sid} 删除分镜
PUT /projects/{id}/episodes/{eid}/storyboards:reorder 拖拽排序
POST /projects/{id}/storyboards/{sid}:duplicate 复制分镜
POST /projects/{id}/storyboards/{sid}:ai-check AI合规检查
```
---
### 环节4: 编辑分镜头(M4-M5阶段)
**设计文档**: `doc 02 § 3.9 StoryboardWorkspace`
**前端功能**:
```
┌──────────────────────────────────────────────────────────────────┐
│ StoryboardWorkspace (分镜工作台) │
├──────────────────────────────────────────────────────────────────┤
│ 左侧: 分集选择器 │
│ ┌──────────────┐ │
│ │ 第1集 ◄ │ │
│ │ 第2集 │ │
│ │ ... │ │
│ └──────────────┘ │
│ │
│ 中间: 分镜列表 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 分镜 1 │ │
│ │ ├─ 描述: 张三从树后走出,警惕扫视 │ │
│ │ ├─ 角色: [张三▼] [未配置音色] + [删除] │ │
│ │ ├─ 场景: [森林小径▼] [更换场景] │ │
│ │ ├─ 道具: [+ 添加] │ │
│ │ ├─ 视频生成区: │ │
│ │ │ ├─ 模型: Doubao-Seedance-2.0 ▼ │ │
│ │ │ ├─ 时长: 15s ▼ │ │
│ │ │ └─ [生成视频] (消耗: 120积分) │ │
│ │ └─ [复制] [删除] │ │
│ │ │ │
│ │ 分镜 2 │ │
│ │ ... │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 右侧: 视频预览 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ [▶] 00:05 / 00:15 │ │
│ │ │ │
│ │ [视频播放器区域] │ │
│ │ │ │
│ │ ┌─────────────┐ [首帧] [尾帧] [视频] │ │
│ │ │ 缩略图 │ │ │
│ │ └─────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 底部工具栏: │
│ [AI检查] [批量生成] [全选] [全部参考生视频] │
└──────────────────────────────────────────────────────────────────┘
```
**交互能力**:
- ✅ 分镜增删改查
- ✅ 拖拽排序
- ✅ 复制分镜
- ✅ 更换角色/场景/道具
- ✅ 选择AI模型(通义万相/Seedance/Kling)
- ✅ 时长选择(5s/8s/15s)
- ✅ 批量生成视频
- ✅ AI合规检查
---
### 环节5: 首尾帧与视频生成(M5阶段)
**设计文档**: `doc 04 § Stage 4-5 · 视觉生成流水线`
**技术方案**:
```java
// 首尾帧生成(角色一致性关键)
FrameGenerateExecutor
├─ 首帧生成
├─ 提示词: detailed_description + 角色名 + 场景描述
├─ 参考图注入:
├─ 角色四视图(character.turnaround_image
├─ 场景图(scene.image_url
└─ 道具图(prop.image_url
└─ 输出: shot_assets (type=FIRST_FRAME)
└─ 尾帧生成
├─ 提示词: endFrame + 首帧作为参考
├─ 参考图: 首帧 + 角色四视图
└─ 输出: shot_assets (type=LAST_FRAME)
// 视频生成(核心耗时)
VideoGenerateExecutor
├─ 提交第三方视频模型
├─ WanxVideoProvider // 通义万相视频
├─ SeedanceProvider // 豆包 Seedance
└─ KlingProvider // 快手 Kling
├─ 提交参数:
├─ prompt: videoScript(精炼40-70字散文提示词)
├─ first_frame_url: 预签名URLTTL 30分钟)
├─ last_frame_url: 预签名URL
├─ reference_urls: 角色四视图预签名URL
├─ duration: 5/8/15
└─ resolution: 720p/1080p
├─ 异步轮询(5s × 120 = 10分钟)
├─ 拉回视频转存到自有OSS
└─ 输出: shot_assets (type=KEYFRAME_VIDEO)
```
**模型对比**:
| 模型 | 首尾帧 | 质量 | 速度 | 成本 |
|------|--------|------|------|------|
| 通义万相 wanx2.1 | ✅ | ⭐⭐⭐ | 快 | 低 |
| Seedance 2.0 | ✅ | ⭐⭐⭐⭐ | 中 | 中 |
| Kling v3 | ✅ | ⭐⭐⭐ | 中 | 中 |
**数据表**:
```sql
shot_assets -- 统一资产表
├─ type: FIRST_FRAME / LAST_FRAME / KEYFRAME_VIDEO
├─ media_id: 关联 media_objectsOSS key
├─ model_provider: wanx / seedance / kling
├─ asset_version: 版本号
└─ is_active: 是否激活版本
media_objects -- 统一媒体管理
├─ storage_key: OSS对象路径
├─ sha256: 内容哈希(去重)
└─ kind: IMAGE / VIDEO / AUDIO
```
**API接口**:
```
POST /projects/{id}/storyboards/{sid}/assets/first-frame:generate
POST /projects/{id}/storyboards/{sid}/assets/last-frame:generate
POST /projects/{id}/storyboards/{sid}/assets/video:generate
POST /projects/{id}/episodes/{eid}/storyboards:batch-generate 批量生成
GET /projects/{id}/storyboards/{sid}/assets?type=FIRST_FRAME&active=true
```
---
### 环节6: TTS配音与合成导出(M6阶段)
**设计文档**: `doc 04 § Stage 6-7 · 音频与合成流水线`
**技术方案**:
```java
// TTS 配音
VoiceTtsExecutor
├─ 通义 Qwen TTS
├─ 按角色音色生成
├─ 支持情绪(happy/sad/angry/neutral
├─ 支持语速(0.5x ~ 2.0x
└─ 音色预设(zh-CN-Male/zh-CN-Female等)
├─ 输出: shot_assets (type=AUDIO)
└─ 存储到: audio/{projectId}/{lineId}/voice-v{n}.mp3
// FFmpeg 合成
AssembleExecutor
├─ 1. shot.sequence 排序 keyframe_video
├─ 2. 视频拼接
├─ xfade 转场(cut/dissolve/fade
└─ 默认0.5s过渡
├─ 3. 生成SRT字幕
├─ dialogues时间比例计算
└─ 字幕格式: 序号 时间轴 内容
├─ 4. 字幕硬编码烧录
└─ drawtext滤镜
├─ 5. 对白音频混入
├─ 按时间戳对齐
└─ amerge滤镜
├─ 6. BGM混音
├─ volume=0.330%音量)
└─ adelay滤镜
├─ 7. 片头片尾
├─ 片头标题卡(3s
└─ 片尾(2s
└─ 8. 水印烧录
├─ 位置(5选:左上/右上/左下/右下/中心)
├─ 大小(3档:小//大)
└─ 透明度(滑块调节)
```
**FFmpeg 命令示例**:
```bash
# 简化版合成命令
ffmpeg \
-i segment_01.mp4 -i segment_02.mp4 -i ... \
-filter_complex "
[0:v][1:v]xfade=transition=fade:duration=0.5:offset=0[v01];
...
" \
-i subtitles.srt \
-vf "drawtext=text='水印':x=10:y=10:fontsize=24:fontcolor=white@0.5" \
-i bgm.mp3 -filter_complex "[1:a][2:a]amerge=inputs=2[vout]" \
-c:v libx264 -preset fast -crf 23 \
-c:a aac -b:a 128k \
final.mp4
```
**数据表**:
```sql
projects
└─ final_video_url: 最终视频OSS路径
episodes
└─ final_video_url: 单集最终视频
```
**API接口**:
```
POST /projects/{id}/episodes/{eid}:assemble 合成最终视频
GET /projects/{id}/episodes/{eid}/segments 分镜段状态
GET /projects/{id}/episodes/{eid}/final 最终MP4下载URL
```
---
## 前端页面完整度
| 页面 | 功能 | 路由 | 设计文档 | 原型状态 |
|------|------|------|---------|---------|
| NewProject | 上传剧本 | `/new-project` | doc 02 §3.2 | ✅ 已有 |
| StyleSelection | 选择风格 | `/project/:id/style` | doc 02 §3.3 | ✅ 已有 |
| OutlineGeneration | 大纲生成 | `/project/:id/outline` | doc 02 §3.4 | ✅ 已有 |
| EpisodeGeneration | 分集列表 | `/project/:id/episodes` | doc 02 §3.5 | ✅ 已有 |
| CharacterGeneration | 角色四视图 | `/project/:id/characters` | doc 02 §3.6 | ✅ 已有 |
| SceneGeneration | 场景图 | `/project/:id/scenes` | doc 02 §3.7 | ✅ 已有 |
| PropsGeneration | 道具图 | `/project/:id/props` | doc 02 §3.8 | ✅ 已有 |
| **StoryboardWorkspace** | **分镜编辑/生成** | `/project/:id/storyboard/:eid` | **doc 02 §3.9** | **✅ 核心页面** |
| VideoGeneration | 视频播放/合成 | `/project/:id/video` | doc 02 §3.10 | ✅ 已有 |
---
## AI能力覆盖
| AI能力 | 模型 | 设计文档 | 实现阶段 |
|--------|------|---------|---------|
| 文本解析 | 通义千问 Qwen-Max | doc 04 Stage 1 | M2 |
| 角色提取 | 通义千问 | doc 04 Stage 1 | M2 |
| 关系推理 | 通义千问 | doc 04 Stage 1 | M2 |
| 智能分集 | 通义千问 | doc 04 Stage 1 | M2 |
| 图片生成 | 通义万相 WanxImage | doc 04 Stage 2 | M3 |
| 角色四视图 | 通义万相 | doc 04 Stage 2 | M3 |
| 分镜拆分 | 通义千问 | doc 04 Stage 3 | M4 |
| 导演规划 | 通义千问 | doc 04 Stage 3 | M4 |
| 首尾帧生成 | 通义万相 | doc 04 Stage 4 | M5 |
| 视频生成 | Wanx/Seedance/Kling | doc 04 Stage 5 | M5 |
| TTS配音 | 通义 Qwen TTS | doc 04 Stage 6 | M6 |
---
## 实施路线图
| 阶段 | 周期 | 目标 | 完成标准 |
|------|------|------|---------|
| **M0** | 1-2周 | 基建 | docker compose up 能访问Swagger |
| **M1** | 3-4周 | 认证+项目 | 注册→登录→创建项目→Dashboard可见 |
| **M2** | 5-7周 | 剧本解析 | 上传5000字小说→出角色列表+分集列表 |
| **M3** | 8-10周 | 资产生成 | 每个角色生成四视图,前端可见 |
| **M4** | 11-13周 | 分镜工作台 | 全部分镜AI生成,用户可手动编辑 |
| **M5** | 14-16周 | 视觉生成 | 选中分镜→10分钟内看到短视频 |
| **M6** | 17-18周 | 合成导出 | 点击合成→得到带字幕配音的完整MP4 |
| **M7-M9** | 19-26周 | 计费+协作+上线 | 企业级SaaS功能 |
---
## 技术架构支撑
### 后端架构(doc 05)
```
yaoai-comic-studio/
├── yaoai-ai-core # AI抽象层
├── yaoai-ai-providers # 通义/豆包/Kling实现
├── yaoai-agent # 三层Agent体系
├── yaoai-pipeline # 业务流水线
├── yaoai-task # RabbitMQ任务系统
├── yaoai-media # FFmpeg合成
├── yaoai-api # REST API
└── yaoai-worker # 独立Worker进程
```
### 前端架构(doc 02)
```
doc/html/
├── React 18 + TypeScript
├── Zustand(状态管理)
├── TanStack Query(服务器状态)
├── React Router 7(路由)
└── Shadcn + Tailwind(UI)
```
### 中间件(doc 05 + doc 10)
```
MySQL 8.0 # 核心业务数据
Redis Stack 7.2 # 缓存 + 向量检索
RabbitMQ 3.13 # 任务队列
阿里云OSS/TOS # 对象存储
```
---
## 可行性评估
| 维度 | 评估 | 说明 |
|------|------|------|
| **流程完整性** | ✅ 100% | 从上传到合成每个环节都有设计 |
| **技术可行性** | ✅ 可行 | 所有AI能力有对应模型 |
| **架构合理性** | ✅ 合理 | 三层Agent+异步任务,可扩展 |
| **实施难度** | ⚠️ 中等 | 需要6个月1-2名后端+1名前端 |
| **质量预期** | ⚠️ 60-70% | MVP阶段,后续迭代优化 |
---
## 潜在风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| 角色一致性不足 | 用户体验差 | 四视图+参考图注入+强制命名 |
| 视频生成质量 | 观感差 | 多模型备选+用户重新生成 |
| 生成成本过高 | 毛利低 | 精确计费+短视频优先 |
| AI模型配额 | 阻塞开发 | 提前申请+多厂商备选 |
| FFmpeg性能 | 合成慢 | Worker独立扩容+GPU编码 |
---
## 结论
**这套设计文档能够完整跑通AI漫剧/短剧制作全流程**,关键验证点:
1.**剧本解析** - 通义千问 + 三步导入流程
2.**图片生成** - 通义万相 + 四视图 + 参考图注入
3.**分镜生成** - 三层Agent + 导演规划 + 监督审核
4.**分镜编辑** - 前端完整工作台 + 拖拽排序
5.**视频生成** - Wanx/Seedance/Kling + 首尾帧插值
6.**合成导出** - FFmpeg + TTS + 字幕 + BGM
**实施建议**:
- 按M0→M9顺序逐步交付
- MVP版本前6个月完成基础流程
- 质量接受60-70%,后续根据用户反馈优化
---
*验证完成时间: 2026-04-16*
# 设计文档审查意见
# 设计文档审查意见
> 审查日期: 2026-04-16
> 审查范围: design-docs 目录下全部15份文档
---
## 1. 整体评价
文档体系**非常成熟**,覆盖了从产品定位、技术架构、数据库设计、API规范到部署运维的完整链路。吸收三个开源项目(Toonflow、AIComicBuilder、waoowaoo)的策略清晰,有明确的技术选型和分阶段实施路线图。
**优势**
- 架构设计完整,三层Agent、异步任务、计费系统设计合理
- 技术选型务实(Spring AI Alibaba + Redis Stack + RabbitMQ)
- 实施路线图清晰,每个里程碑都有明确的完成标准
- 吸收开源项目策略明确,避免了重复造轮子
**待改进**:主要问题集中在原型与文档不同步、部分TODO需要明确实现时机、细节补充等方面。
---
## 2. 需要修正/补充的问题
### 2.1 数据不一致问题
| 文档 | 原型实际情况 | 需要修正 |
|------|------------|---------|
| **doc 02 风格数量** | 写6种 | 原型截图显示9+种(2D日漫/韩漫都市/3D国风/仙侠/韩漫/乙女/国漫/CG/卡通...),需更新 |
| **doc 11 购买页时机** | M7阶段 | 原型已有购买页(pic 32),可提前到M1或M2阶段 |
### 2.2 API补充
**doc 07** 需要补充以下API(原型已实现但文档缺失):
```http
# 子账号管理(来自原型pic 5)
GET /admin/accounts
POST /admin/accounts
PATCH /admin/accounts/:id/authorize
DELETE /admin/accounts/:id
# 资产批量操作
POST /assets/batch-clone
POST /assets/batch-delete
# 资源包购买
POST /billing/packages/purchase # 10万/100万/1000万档位
GET /billing/packages # 资源包列表
POST /billing/coupons/apply # 代金券使用
```
### 2.3 数据库设计遗漏
**doc 06** 可能需要补充的表结构:
```sql
-- 子账号表(原型有子账号授权登录机制)
CREATE TABLE sub_accounts (
id BIGINT PK,
tenant_id BIGINT,
parent_user_id BIGINT,
username VARCHAR(64),
password_hash VARCHAR(100),
permissions JSON, -- 允许的操作范围
status ENUM('ACTIVE','DISABLED'),
created_at DATETIME
);
-- 资源包表(原型有10万/100万/1000万档位)
CREATE TABLE credit_packages (
id BIGINT PK,
name VARCHAR(64),
credits INT,
price DECIMAL(10,2),
currency VARCHAR(8) DEFAULT 'CNY',
is_active BOOLEAN,
sort_order INT
);
-- 订单表(购买记录)
CREATE TABLE orders (
id BIGINT PK,
tenant_id BIGINT,
user_id BIGINT,
type ENUM('RECHARGE','PACKAGE','SUBSCRIPTION'),
amount DECIMAL(10,2),
credits INT,
payment_provider VARCHAR(32),
external_order_id VARCHAR(128),
status ENUM('PENDING','PAID','FAILED','REFUNDED'),
paid_at DATETIME,
created_at DATETIME
);
```
### 2.4 MinIO实现TODO
**doc 05****doc 10** 都提到MinIO作为TODO备选,但本地开发可能需要完全离线环境。
**建议**:在 M0 阶段明确实现 MinIOProvider
```java
// yaoai-storage 模块新增实现
@Component
@ConditionalOnProperty(name = "yaoai.storage.provider", havingValue = "minio")
public class MinioProvider implements StorageProvider {
@Override
public String getProviderId() {
return "minio";
}
@Override
public void upload(String key, InputStream data, String contentType, long size) {
// 使用 MinIO Java SDK
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(key)
.stream(data, size, -1)
.contentType(contentType)
.build()
);
}
// 实现其他方法...
}
```
并在 **doc 10** 的 Docker Compose 中补充 MinIO 服务:
```yaml
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
ports: ["9000:9000", "9001:9001"]
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
volumes: ["minio_data:/data"]
```
### 2.5 前端状态管理迁移计划
**doc 02** 建议用 Zustand + TanStack Query,但原型目前是纯 useState。建议在 **doc 11** 的 M0.8 中明确迁移步骤:
```
M0.8 前端接入层改造(补充)
- [ ] 建立 src/stores/ 目录(Zustand store)
- [ ] src/stores/authStore.ts
- [ ] src/stores/projectStore.ts
- [ ] src/stores/uiStore.ts
- [ ] 建立 src/services/api/ 目录(Axios封装)
- [ ] src/services/api/client.ts(baseURL + token拦截器)
- [ ] src/services/api/projects.ts
- [ ] src/services/api/characters.ts
- [ ] ...
- [ ] 引入 @tanstack/react-query
- [ ] 建立 src/hooks/useQuery.ts 封装
- [ ] 建立 src/hooks/useMutation.ts 封装
- [ ] 逐页迁移:
- [ ] Dashboard(去mock,接入真实API)
- [ ] NewProject
- [ ] StyleSelection
- [ ] ...
```
### 2.6 模型配置页截图
**doc 14** 提到 ModelSettings 已新增,但截图未包含。建议补充以下截图:
```
需补充截图:
- 语言模型配置页
- 模型选择器:通义千问 Max / Plus / Turbo
- API Key 配置
- 默认模型设置
- 图片模型配置页
- 模型选择器:通义万相 2.1 / DALL-E 3 / Gemini Imagen
- 供应商管理(添加/编辑/删除)
- 视频模型配置页
- 模型选择器:通义万相视频 / Seedance 2.0 / Kling v3
- 时长选项(5s/8s/15s)
- 质量选项(draft/standard/high)
- 供应商管理弹窗
- 名称
- 协议(OpenAI兼容/DashScope/Seedance/Kling)
- Base URL
- API Key / Secret Key
- 模型列表(手动添加/自动获取)
```
---
## 3. 优化建议
### 3.1 数据库索引优化
**doc 06** 的表建议补充复合索引以提高查询性能:
```sql
-- tasks 表补充索引
CREATE INDEX idx_tenant_status_type ON tasks(tenant_id, status, type);
CREATE INDEX idx_project_status ON tasks(project_id, status);
CREATE INDEX idx_user_created ON tasks(user_id, created_at DESC);
-- balance_freezes 表补充索引
CREATE INDEX idx_freeze_expires ON balance_freezes(expires_at, status);
CREATE INDEX idx_freeze_idempotency ON balance_freezes(idempotency_key);
-- storyboards 表补充索引
CREATE INDEX idx_episode_seq ON storyboards(episode_id, sequence);
CREATE INDEX idx_episode_status ON storyboards(episode_id, status);
-- usage_records 表补充索引
CREATE INDEX idx_usage_tenant_type_time ON usage_records(tenant_id, type, created_at DESC);
-- audit_logs 表补充索引
CREATE INDEX idx_audit_tenant_time ON audit_logs(tenant_id, created_at DESC);
CREATE INDEX idx_audit_user_action ON audit_logs(user_id, action, created_at DESC);
-- global_assets 表补充索引
CREATE INDEX idx_asset_lib_type ON global_assets(library, type, created_at DESC);
CREATE INDEX idx_asset_tags ON global_assets(tags(50)); -- MySQL 5.7+ 支持JSON索引
```
### 3.2 计费积分精度问题
**doc 01** 计费单位用积分(如视频生成50积分),TTS为"0.1 积分/字符"。但 **doc 06**`user_balances``DECIMAL(18,4)`
**建议**:明确积分精度规则
```java
// 积分精度统一为 2 位小数
// 最小单位:0.01 积分
// 计费表调整
user_balances.balance DECIMAL(10,2) -- 足够存储:999,999.99 积分
balance_freezes.amount DECIMAL(10,2)
balance_transactions.amount DECIMAL(10,2)
// 成本估算调整
SCRIPT_PARSE -> 5.00 积分
OUTLINE_GEN -> 10.00 积分
CHARACTER_IMAGE -> 20.00 积分
SCENE_IMAGE -> 10.00 积分
FRAME_GEN -> 15.00 积分
TTS -> 0.01 积分/字符
```
### 3.3 SSE连接数管理
**doc 08** 用SSE推送任务进度,但未考虑连接数管理。建议补充:
```java
// doc 08 新增章节: SSE连接管理
@Service
public class SseConnectionManager {
// 1. 单用户最多3个SSE连接(防止多标签页累积)
private final Map<Long, Set<SseEmitter>> userConnections = new ConcurrentHashMap<>();
public boolean canAddConnection(Long userId) {
return userConnections.getOrDefault(userId, Set.of()).size() < 3;
}
// 2. 连接超时自动关闭(5分钟无心跳)
@Scheduled(fixedRate = 60000)
public void cleanStaleConnections() {
// 检查最后心跳时间,超时则关闭
}
// 3. Redis存储活跃SSE连接,用于断线重连
public void registerConnection(Long userId, String connectionId, SseEmitter emitter) {
redisTemplate.opsForValue().set(
"sse:connection:" + connectionId,
userId.toString(),
Duration.ofMinutes(5)
);
}
}
// API层添加限制
@GetMapping("/projects/{id}/events")
public Flux<ServerSentEvent<String>> streamProjectEvents(
@PathVariable Long id,
@AuthenticationPrincipal UserPrincipal user) {
if (!sseManager.canAddConnection(user.getId())) {
throw new TooManyConnectionsException("超过SSE连接数限制");
}
// ...
}
```
### 3.4 视频生成并发控制
**doc 08** 的队列配置视频并发=10,但实际可能需要更细粒度控制:
```java
// doc 08 补充任务优先级和并发控制
// 1. 租户级配额配置
@Data
@TableName("tenant_quotas")
public class TenantQuota {
@TableId
Long tenantId;
Integer maxConcurrentVideoTasks; // PRO=5, TEAM=20
Integer maxConcurrentImageTasks; // PRO=10, TEAM=50
}
// 2. 用户级并发控制
@Service
public class ConcurrencyGate {
public boolean canSubmitTask(Long userId, Long tenantId, TaskType type) {
String key = String.format("concurrent:%s:%s:%s", tenantId, userId, type);
Long current = redisTemplate.opsForValue().increment(key);
if (current > getQuota(tenantId, type)) {
redisTemplate.opsForValue().decrement(key);
return false;
}
redisTemplate.expire(key, Duration.ofMinutes(30)); // 30分钟超时
return true;
}
public void releaseTask(Long userId, Long tenantId, TaskType type) {
String key = String.format("concurrent:%s:%s:%s", tenantId, userId, type);
redisTemplate.opsForValue().decrement(key);
}
}
// 3. 队列按优先级路由
// video.generate.high (VIP用户)
// video.generate.normal (默认)
// video.generate.low (免费用户,降级处理)
```
### 3.5 通知系统细化
**doc 08** 提到通知系统但细节不足。建议补充:
```java
// doc 08 补充通知类型定义
public enum NotificationType {
// 任务相关
TASK_COMPLETED("任务完成", "您的任务已完成"),
TASK_FAILED("任务失败", "任务执行失败"),
TASK_TIMEOUT("任务超时", "任务执行超时"),
// 计费相关
BALANCE_LOW("余额不足", "您的积分余额不足"),
PAYMENT_SUCCESS("支付成功", "充值成功"),
SUBSCRIPTION_EXPIRED("订阅到期", "订阅即将到期"),
// 项目相关
PROJECT_INVITATION("项目邀请", "您被邀请加入项目"),
PROJECT_ARCHIVED("项目归档", "项目已归档"),
// 协作相关
MEMBER_JOINED("成员加入", "有新成员加入项目"),
COMMENT_MENTION("提及你", "有人提到了你"),
// 系统相关
SYSTEM_MAINTENANCE("系统维护", "系统即将维护"),
SYSTEM_ANNOUNCEMENT("系统公告", "系统公告");
}
// 通知渠道
public enum NotificationChannel {
WEB, // 站内通知
EMAIL, // 邮件
SMS, // 短信
WEBPUSH, // 浏览器推送
WECHAT // 微信推送(可选)
}
// 用户通知偏好设置
@Data
@TableName("notification_preferences")
public class NotificationPreference {
Long userId;
NotificationType type;
Map<NotificationChannel, Boolean> channels; // {WEB: true, EMAIL: false}
}
```
### 3.6 前端错误边界
**doc 02** 技术债务提到无错误边界,建议在 **M0阶段** 补充:
```
M0.8 补充任务:
- [ ] 安装依赖
npm install react-error-boundary
- [ ] 创建错误边界组件
// src/components/ErrorBoundary.tsx
import { ErrorBoundary } from 'react-error-boundary';
function FallbackComponent({ error, resetErrorBoundary }) {
return (
<div role="alert" className="p-4 bg-red-50 text-red-800 rounded">
<p className="font-bold">出现错误</p>
<pre className="mt-2 text-sm">{error.message}</pre>
<button
onClick={resetErrorBoundary}
className="mt-2 px-4 py-2 bg-red-600 text-white rounded"
>
重试
</button>
</div>
);
}
export function AppErrorBoundary({ children }) {
return (
<ErrorBoundary FallbackComponent={FallbackComponent}>
{children}
</ErrorBoundary>
);
}
- [ ] 在 App.tsx 根节点包裹
<AppErrorBoundary>
<Router>
{/* ... */}
</Router>
</AppErrorBoundary>
- [ ] 分页错误边界
- StoryboardWorkspace(视频生成可能出错)
- VideoGeneration(播放器可能出错)
```
### 3.7 API版本化
**doc 07** BaseURL 用 `/v1` 但未说明版本策略。建议补充:
```http
# API版本化策略
## 版本规则
- 主版本号:breaking change(/v1, /v2)
- 次版本号:新增特性,向后兼容(/v1.1)
- 补丁号:bug修复(/v1.1.2)
## 版本路径
https://api.yaoai.com/v1/...
https://api.yaoai.com/v2/...
## 当前版本
- 稳定版:/v1
- 实验性API:/v1.0.2-beta
## 废弃策略
- 废弃API保留至少2个大版本
- 响应头标示:X-API-Deprecated: true
- 废弃API返回警告:Warning: Deprecated, use /v2/xxx instead
## OpenAPI自动区分版本
- springdoc-openapi 按版本分组
- /v3/api-docs/v1
- /v3/api-docs/v1.1.2-beta
```
### 3.8 文档同步机制
**doc 14** 已发现多个需要更新的文档,建议建立变更追踪:
```markdown
# DESIGN_DOCS_CHANGELOG.md
## 2026-04-16
### 同步原型差异
- **doc 02**: 风格数量 6 → 9+ 种(来自原型 pic 7)
- **doc 02**: 补充积分购买页描述(原型 pic 32)
- **doc 02**: 补充子账号管理详情(原型 pic 5)
- **doc 07**: 补充子账号 CRUD API
- **doc 07**: 补充资源包购买 API
- **doc 07**: 补充资产批量操作 API
- **doc 09**: 购买页提前(M7 → M1)
- **doc 11**: 调整里程碑时间线
### 补充遗漏内容
- **doc 06**: 补充 sub_accounts 表
- **doc 06**: 补充 credit_packages 表
- **doc 06**: 补充 orders 表
- **doc 08**: 补充 SSE 连接管理
- **doc 08**: 补充视频生成并发控制
### 待确认项
- **doc 14**: ModelSettings 页截图(待补充)
- **doc 02**: TTS 音色选择交互细节(待确认)
- **doc 02**: 角色服装变体 UI 位置(待确认)
```
---
## 4. 可选增强功能
### 4.1 角色四视图的变体机制
原型中角色有服装变体(如"林若曦·白色晚礼服"),建议在数据库设计中明确用途:
```sql
-- 建议明确 character_appearances 的使用场景
CREATE TABLE character_appearances (
id BIGINT PK,
character_id BIGINT,
name VARCHAR(128), -- 变体名称(如"白色晚礼服"、"战损")
type ENUM('COSTUME','STATE','AGE'), -- 变体类型
description TEXT,
prompt TEXT, -- 生成提示词
image_url VARCHAR(500), -- 变体图片
turnaround_image_url VARCHAR(500), -- 变体四视图
version INT,
is_active BOOLEAN,
created_at DATETIME
);
-- 变体类型说明
-- COSTUME: 服装变体(常服/礼服/战袍/校服等)
-- STATE: 状态变体(受伤/愤怒/正常等)
-- AGE: 年龄变体(少年/成年/老年等)
```
### 4.2 视频质量等级
原型中有模型选择(Doubao-Seedance-2.0 / fast),建议在后端支持质量分级:
```java
// doc 05 新增 VideoQuality 枚举
public enum VideoQuality {
DRAFT("快速预览", 720, 5, 1.0, 30), // 5s, 最低质量
STANDARD("标准质量", 1080, 8, 1.0, 50), // 8s, 标准质量
HIGH("高清质量", 1080, 15, 1.5, 120), // 15s, 高质量
// 参数:名称、分辨率、时长、倍数倍率、积分基数
private final String name;
private final int resolution;
private final int duration;
private final double rateMultiplier;
private final int baseCredits;
public int calculateCredits() {
return (int) (baseCredits * rateMultiplier);
}
}
// 前端用户选择
<select>
<option value="DRAFT">快速预览 (5s, 30积分)</option>
<option value="STANDARD" selected>标准质量 (8s, 50积分)</option>
<option value="HIGH">高清质量 (15s, 120积分)</option>
</select>
```
### 4.3 任务优先级队列
重要用户/付费用户任务应该优先处理:
```java
// doc 08 补充任务优先级
public enum TaskPriority {
HIGH, // VIP用户、紧急任务
NORMAL, // 默认优先级
LOW // 免费用户、非紧急任务
}
// RabbitMQ 按优先级路由
@RabbitListener(queues = "video.generate.high")
public void handleHighPriorityVideo(String taskId) { /* 优先处理 */ }
@RabbitListener(queues = "video.generate.normal")
public void handleNormalPriorityVideo(String taskId) { /* 正常处理 */ }
@RabbitListener(queues = "video.generate.low")
public void handleLowPriorityVideo(String taskId) { /* 降级处理 */ }
// 提交任务时根据租户套餐选择队列
String queueName = switch (tenant.getPlan()) {
case ENTERPRISE -> "video.generate.high";
case TEAM, PRO -> "video.generate.normal";
case FREE -> "video.generate.low";
};
```
### 4.4 AI模型热切换
当前设计需要重启服务才能切换模型,建议支持运行时热切换:
```java
// doc 05 补充模型热切换
@Service
public class ModelConfigService {
@Autowired RedisTemplate<String, String> redis;
// 运行时更新模型配置
public void updateModelConfig(String providerId, ModelConfig config) {
String key = String.format("model:config:%s", providerId);
redis.opsForValue().set(key, JsonUtils.toJson(config), Duration.ofHours(1));
}
// 读取模型配置(缓存优先)
public ModelConfig getModelConfig(String providerId) {
String key = String.format("model:config:%s", providerId);
String cached = redis.opsForValue().get(key);
if (cached != null) return JsonUtils.fromJson(cached, ModelConfig.class);
return loadFromDatabase(providerId);
}
}
// 前端实时更新模型配置
// 无需重启服务,立即生效
```
### 4.5 项目模板系统
为常见短剧类型提供预设模板:
```sql
-- 项目模板表
CREATE TABLE project_templates (
id BIGINT PK,
name VARCHAR(128), -- "都市甜宠"、"仙侠修仙"
category VARCHAR(64), -- 都市/仙侠/古装/悬疑
description TEXT,
art_style VARCHAR(64), -- 默认艺术风格
default_characters JSON, -- 预设角色
default_scenes JSON, -- 预设场景
default_props JSON, -- 预设道具
thumbnail_url VARCHAR(500),
is_public BOOLEAN,
created_at DATETIME
);
-- API
GET /templates -- 模板列表
GET /templates/:id -- 模板详情
POST /projects/from-template -- 从模板创建项目
```
---
## 5. 总结
### 5.1 需要立即处理(M1前完成)
1. **doc 02** 更新风格数量(6→9+)
2. **doc 07** 补充子账号管理API、购买API
3. **doc 06** 补充数据库表(sub_accounts、credit_packages、orders)
4. **doc 11** 调整购买页时机(M7→M1)
5. **doc 10** 补充MinIO Docker Compose配置
### 5.2 建议在M0阶段完成
1. 实现MinIOProvider(支持本地开发离线环境)
2. 前端建立Zustand + TanStack Query脚手架
3. 补充前端错误边界组件
4. 数据库补充复合索引
### 5.3 可以延后处理(M2+)
1. SSE连接数管理
2. 视频生成并发控制
3. 通知系统细化
4. API版本化
5. 任务优先级队列
### 5.4 可选增强(v2+)
1. 角色四视图变体机制细化
2. 视频质量等级
3. AI模型热切换
4. 项目模板系统
---
## 附录:文档同步清单
| 文档 | 需要同步的内容 | 优先级 |
|------|--------------|-------|
| doc 02 | 风格数量、购买页、子账号管理 | P0 |
| doc 06 | 数据库表补充、复合索引 | P0 |
| doc 07 | API补充 | P0 |
| doc 08 | SSE管理、并发控制 | P1 |
| doc 09 | 购买页提前 | P0 |
| doc 10 | MinIO配置 | P0 |
| doc 11 | 前端迁移计划细化 | P1 |
---
*审查完成时间: 2026-04-16*
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { useParams, useNavigate } from "react-router"; import { useParams, useNavigate } from "react-router";
import { import {
Plus, Wand2, Play, Trash2, Copy, Plus, Wand2, Play, Trash2,
Loader2, ArrowRight, Layers, Download, Check, X, Loader2, ArrowRight, Layers, Download, X,
ChevronDown, Sparkles, ChevronDown, ChevronUp, ChevronRight, Volume2, VolumeX,
RefreshCw, Image as ImageIcon, Info, Check, Sparkles,
MoreHorizontal,
} from "lucide-react"; } from "lucide-react";
import { import {
useStoryboards, useStoryboards,
useCreateStoryboard, useCreateStoryboard,
useGenerateStoryboards, useGenerateStoryboards,
useUpdateStoryboard,
useDeleteStoryboard, useDeleteStoryboard,
useAssemblyTask, useAssemblyTask,
useStartAssembly, useStartAssembly,
useGenerateVideo, useGenerateStructuredVideo,
useVideoTasks, useVideoTasks,
useGenerateStoryboardPrompt,
useCharacters, useCharacters,
useScenes, useScenes,
useEpisodes, useEpisodes,
useSaveCharacter,
useGenerateCharacterImage,
} from "../../hooks/useAi"; } from "../../hooks/useAi";
import type { Storyboard, Character, Scene, Episode } from "../../lib/api/ai"; import type { Storyboard, Character, Scene, Episode, AiTask } from "../../lib/api/ai";
import { useOperationCost } from "../../hooks/useUsage";
const VIDEO_MODELS = [
{ id: "doubao-seedance-2-0-fast", label: "Seedance 2.0 Fast" },
{ id: "doubao-seedance-2-0", label: "Seedance 2.0" },
];
const DURATION_OPTIONS = [5, 10, 15, 20, 30]; const DURATION_OPTIONS = [5, 10, 15, 20, 30];
const CREDITS_PER_VIDEO = 2500; const RATIO_OPTIONS = [
{ value: "16:9", label: "16:9 横屏" },
{ value: "9:16", label: "9:16 竖屏" },
{ value: "1:1", label: "1:1 方形" },
];
const VIDEO_MODEL_OPTIONS = [
{ value: "doubao-seedance-2-0-fast-260128", label: "doubao-seedance-2-0-fast" },
{ value: "doubao-seedance-2-0-260128", label: "doubao-seedance-2-0" },
];
const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_OPTIONS[0].value;
const MAX_CHARACTERS = 5;
const MAX_PROPS = 5;
const PROMPT_PREVIEW_LIMIT = 80;
const FREEFORM_PLACEHOLDER = `【镜头1】 时长2s | 蒙太奇快切 | 固定机位快速切出
画面定格在角色刚转头的瞬间,背景虚化为暖色光斑
【镜头2】 时长3s | 推近 | 缓慢推到面部特写
角色微微抬眼,眼神坚定,发丝随风轻拂
【镜头3】 时长2s | 拉远 | 从面部拉到全景
镜头快速拉远,呈现完整环境氛围`;
function characterFrontKey(c: Character): string | null {
return c.frontImageTosKey ?? c.imageTosKey ?? null;
}
function characterFrontUrl(c: Character): string | null {
return c.frontImageUrl ?? c.imageUrl ?? null;
}
export function StoryboardWorkspace() { export function StoryboardWorkspace() {
const { episodeId: urlEpisodeId, projectId } = useParams<{ projectId: string; episodeId?: string }>(); const { episodeId: urlEpisodeId, projectId } = useParams<{ projectId: string; episodeId?: string }>();
...@@ -37,7 +64,6 @@ export function StoryboardWorkspace() { ...@@ -37,7 +64,6 @@ export function StoryboardWorkspace() {
const { data: episodes = [], isLoading: episodesLoading } = useEpisodes(pid); const { data: episodes = [], isLoading: episodesLoading } = useEpisodes(pid);
const [activeEpisodeId, setActiveEpisodeId] = useState<string>(""); const [activeEpisodeId, setActiveEpisodeId] = useState<string>("");
// Initialize active episode from URL param or first episode
useEffect(() => { useEffect(() => {
if (urlEpisodeId) { if (urlEpisodeId) {
setActiveEpisodeId(urlEpisodeId); setActiveEpisodeId(urlEpisodeId);
...@@ -55,53 +81,56 @@ export function StoryboardWorkspace() { ...@@ -55,53 +81,56 @@ export function StoryboardWorkspace() {
const { data: assemblyTask } = useAssemblyTask(pid, eid); const { data: assemblyTask } = useAssemblyTask(pid, eid);
const generateSbs = useGenerateStoryboards(pid); const generateSbs = useGenerateStoryboards(pid);
const createSb = useCreateStoryboard(pid, eid); const createSb = useCreateStoryboard(pid, eid);
const updateSb = useUpdateStoryboard(pid, eid);
const deleteSb = useDeleteStoryboard(pid, eid); const deleteSb = useDeleteStoryboard(pid, eid);
const generateVideo = useGenerateVideo(pid); const generateStructured = useGenerateStructuredVideo(pid);
const startAssembly = useStartAssembly(pid, eid); const startAssembly = useStartAssembly(pid, eid);
const generatePrompt = useGenerateStoryboardPrompt(pid); const videoCredits = useOperationCost("video_generate");
const [selected, setSelected] = useState<Storyboard | null>(null); // ─── Editor state (bound to currently expanded card) ───
const [promptDraft, setPromptDraft] = useState(""); const [expandedId, setExpandedId] = useState<string | null>(null);
const [promptChanged, setPromptChanged] = useState(false); const [characterKeys, setCharacterKeys] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState(VIDEO_MODELS[0].id); const [sceneKey, setSceneKey] = useState<string>("");
const [propKeys, setPropKeys] = useState<string[]>([]);
const [styleKey] = useState<string>(""); // reserved; not exposed in this UI
const [shortDescription, setShortDescription] = useState<string>("");
const [freeformPrompt, setFreeformPrompt] = useState<string>("");
const [selectedDuration, setSelectedDuration] = useState(15); const [selectedDuration, setSelectedDuration] = useState(15);
const [selectedRatio, setSelectedRatio] = useState<string>("16:9");
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_VIDEO_MODEL);
const [audioOn, setAudioOn] = useState(false);
const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null); const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null);
const [batchGenerating, setBatchGenerating] = useState(false);
const getCharacterPrimaryImage = (character: Character) => // Modals
character.frontImageUrl ?? character.imageUrl ?? character.sideImageUrl ?? character.backImageUrl ?? null; const [showCharacterPicker, setShowCharacterPicker] = useState(false);
const [showPromptDetails, setShowPromptDetails] = useState(false);
const [activeVideoSbId, setActiveVideoSbId] = useState<string | null>(null);
// Reset selection when episode changes const expanded = useMemo(
useEffect(() => { () => storyboards.find((s) => s.id === expandedId) ?? null,
setSelected(null); [storyboards, expandedId]
setPromptDraft(""); );
setPromptChanged(false);
}, [activeEpisodeId]);
// Sync prompt draft when selected storyboard changes // key → Character lookup (using front-image key)
useEffect(() => { const keyToCharacter = useMemo(() => {
if (selected) { const m = new Map<string, Character>();
const draft = selected.startFramePrompt || selected.detailedDescription || ""; characters.forEach((c) => {
setPromptDraft(draft); const k = characterFrontKey(c);
setPromptChanged(false); if (k) m.set(k, c);
} });
}, [selected?.id]); return m;
}, [characters]);
// Auto-select first storyboard when list loads const sceneByKey = useMemo(() => {
useEffect(() => { const m = new Map<string, Scene>();
if (storyboards.length > 0 && !selected) { scenes.forEach((s) => { if (s.imageTosKey) m.set(s.imageTosKey, s); });
setSelected(storyboards[0]); return m;
} }, [scenes]);
}, [storyboards.length]);
const getTaskForSb = (sb: Storyboard) => { const getTaskForSb = (sb: Storyboard): AiTask | undefined => {
const byId = videoTasks.filter((t) => t.storyboardId === sb.id); const byId = videoTasks.filter((t) => t.storyboardId === sb.id);
// 优先取已成功的任务;同一分镜多次生成时,最新的可能还在执行中
return byId.find((t) => t.status === "succeeded") return byId.find((t) => t.status === "succeeded")
?? byId.find((t) => t.status === "running" || t.status === "submitted" || t.status === "pending") ?? byId.find((t) => t.status === "running" || t.status === "submitted" || t.status === "pending")
?? byId[0] ?? byId[0];
?? videoTasks.find((t) => t.episodeId === sb.episodeId && t.storyboardId == null);
}; };
const getDotColor = (sb: Storyboard) => { const getDotColor = (sb: Storyboard) => {
...@@ -112,15 +141,53 @@ export function StoryboardWorkspace() { ...@@ -112,15 +141,53 @@ export function StoryboardWorkspace() {
return "bg-orange-400 animate-pulse"; return "bg-orange-400 animate-pulse";
}; };
// Reset on episode change
useEffect(() => {
setExpandedId(null);
setFreeformPrompt("");
setCharacterKeys([]);
setSceneKey("");
setPropKeys([]);
setShortDescription("");
setActiveVideoSbId(null);
}, [activeEpisodeId]);
// Auto-expand first storyboard on load
useEffect(() => {
if (storyboards.length > 0 && !expandedId) {
setExpandedId(storyboards[0].id);
setActiveVideoSbId(storyboards[0].id);
}
}, [storyboards.length]);
// Sync editor state when expanded card changes
useEffect(() => {
if (!expanded) return;
setShortDescription(expanded.shortDescription ?? "");
const lastTask = getTaskForSb(expanded);
const initialChars = lastTask?.characterImageKeys
?? (lastTask?.characterImageKey ? [lastTask.characterImageKey] : []);
setCharacterKeys(initialChars);
setSceneKey(lastTask?.sceneImageKey ?? "");
setPropKeys(lastTask?.propImageKeys ?? []);
setFreeformPrompt(lastTask?.userPrompt?.freeformPrompt ?? "");
if (typeof lastTask?.videoDuration === "number") setSelectedDuration(lastTask.videoDuration);
if (lastTask?.videoRatio) setSelectedRatio(lastTask.videoRatio);
if (typeof lastTask?.generateAudio === "boolean") setAudioOn(lastTask.generateAudio);
}, [expandedId, videoTasks.length]);
const handleEpisodeSelect = (ep: Episode) => { const handleEpisodeSelect = (ep: Episode) => {
setActiveEpisodeId(ep.id); setActiveEpisodeId(ep.id);
navigate(`/project/${pid}/storyboard/${ep.id}`, { replace: true }); navigate(`/project/${pid}/storyboard/${ep.id}`, { replace: true });
}; };
const handleGenerate = async () => { const handleGenerateAll = async () => {
if (!eid) return; if (!eid) return;
const result = await generateSbs.mutateAsync(eid); const result = await generateSbs.mutateAsync(eid);
if (result.length > 0) setSelected(result[0]); if (result.length > 0) {
setExpandedId(result[0].id);
setActiveVideoSbId(result[0].id);
}
}; };
const handleCreate = async () => { const handleCreate = async () => {
...@@ -139,233 +206,178 @@ export function StoryboardWorkspace() { ...@@ -139,233 +206,178 @@ export function StoryboardWorkspace() {
notes: "", notes: "",
status: "draft", status: "draft",
} as unknown as Storyboard); } as unknown as Storyboard);
setSelected(newSb); setExpandedId(newSb.id);
setActiveVideoSbId(newSb.id);
}; };
const handleDelete = async (sb: Storyboard) => { const handleDelete = async (sb: Storyboard) => {
if (!confirm(`确定删除分镜 #${sb.sequenceNum}?`)) return; if (!confirm(`确定删除分镜 #${sb.sequenceNum}?`)) return;
await deleteSb.mutateAsync(sb.id); await deleteSb.mutateAsync(sb.id);
if (selected?.id === sb.id) setSelected(storyboards.find((s) => s.id !== sb.id) ?? null); if (expandedId === sb.id) {
}; const fallback = storyboards.find((s) => s.id !== sb.id);
setExpandedId(fallback?.id ?? null);
const handleCopy = async () => { setActiveVideoSbId(fallback?.id ?? null);
if (!selected) return; }
const copy = await createSb.mutateAsync({
...selected,
id: undefined,
sequenceNum: storyboards.length + 1,
sceneNumber: `${selected.sceneNumber}-副本`,
status: "draft",
} as unknown as Storyboard);
setSelected(copy);
};
const handleSavePrompt = async () => {
if (!selected || !promptChanged) return;
await updateSb.mutateAsync({ id: selected.id, patch: { startFramePrompt: promptDraft } });
setSelected({ ...selected, startFramePrompt: promptDraft });
setPromptChanged(false);
};
const handleGeneratePrompt = async () => {
if (!selected) return;
const generatedPrompt = await generatePrompt.mutateAsync(selected.id);
setPromptDraft(generatedPrompt);
setPromptChanged(true);
}; };
// 所有可引用的角色/场景参考图,角色支持正面/侧面/背面三视图。 const togglePropKey = (key: string) => {
const refImages = useMemo<Array<{ setPropKeys((prev) => {
label: string; if (prev.includes(key)) return prev.filter((k) => k !== key);
name: string; if (prev.length >= MAX_PROPS) return prev;
imageUrl: string | null; return [...prev, key];
imageTosKey: string;
kind: "character" | "scene";
viewLabel?: string;
}>>(() => {
const characterRefImages = characters.flatMap((character) => {
const views = [
{
viewLabel: "正面",
imageUrl: character.frontImageUrl ?? character.imageUrl ?? null,
imageTosKey: character.frontImageTosKey ?? character.imageTosKey ?? null,
},
{
viewLabel: "侧面",
imageUrl: character.sideImageUrl ?? null,
imageTosKey: character.sideImageTosKey ?? null,
},
{
viewLabel: "背面",
imageUrl: character.backImageUrl ?? null,
imageTosKey: character.backImageTosKey ?? null,
},
];
return views
.filter((view) => !!view.imageTosKey)
.map((view) => ({
label: `${character.name}·${view.viewLabel}`,
name: character.name,
imageUrl: view.imageUrl,
imageTosKey: view.imageTosKey!,
kind: "character" as const,
viewLabel: view.viewLabel,
}));
}); });
const sceneRefImages = scenes
.filter((scene) => !!scene.imageTosKey)
.map((scene) => ({
label: scene.name,
name: scene.name,
imageUrl: scene.imageUrl,
imageTosKey: scene.imageTosKey!,
kind: "scene" as const,
}));
return [...characterRefImages, ...sceneRefImages];
}, [characters, scenes]);
const insertMention = (name: string) => {
const mention = `@${name}`;
setPromptDraft((prev) => (prev ? prev + " " + mention : mention));
setPromptChanged(true);
}; };
const insertRefImage = (idx: number) => { const removeCharacter = (key: string) => {
const tag = `@图${idx + 1}`; setCharacterKeys((prev) => prev.filter((k) => k !== key));
setPromptDraft((prev) => (prev ? prev + " " + tag : tag));
setPromptChanged(true);
}; };
// 从 prompt 解析所有 @图N 引用(按数字顺序去重),返回对应的 imageTosKey 列表 const swapCharacter = (oldKey: string, newKey: string) => {
const parseRefImageKeys = (text: string): string[] => { if (!newKey || oldKey === newKey) return;
const matches = [...text.matchAll(/@图(\d+)/g)]; setCharacterKeys((prev) => {
const seen = new Set<number>(); if (prev.includes(newKey)) {
const keys: string[] = []; return prev.filter((k) => k !== oldKey);
for (const m of matches) {
const idx = parseInt(m[1], 10) - 1;
if (!seen.has(idx) && refImages[idx]?.imageTosKey) {
seen.add(idx);
keys.push(refImages[idx].imageTosKey);
}
} }
return keys; return prev.map((k) => (k === oldKey ? newKey : k));
});
}; };
const handleGenerateVideo = async () => { const promptHasContent = freeformPrompt.trim().length > 0;
if (!selected) return; const canGenerate = !!expanded && characterKeys.length > 0 && !!sceneKey
setGeneratingVideoId(selected.id); && promptHasContent && !generateStructured.isPending;
const handleGenerate = async () => {
if (!expanded || !canGenerate) return;
setGeneratingVideoId(expanded.id);
try { try {
const patch: Record<string, unknown> = {}; await generateStructured.mutateAsync({
if (promptChanged) patch.startFramePrompt = promptDraft; episodeId: expanded.episodeId,
// 同步实际生成时长到分镜,确保时长统计准确 storyboardId: expanded.id,
if (selectedDuration !== selected.durationSeconds) patch.durationSeconds = selectedDuration; characterImageKeys: characterKeys,
if (Object.keys(patch).length > 0) { sceneImageKey: sceneKey,
await updateSb.mutateAsync({ id: selected.id, patch }); propImageKeys: propKeys.length > 0 ? propKeys : null,
setSelected({ ...selected, ...patch }); styleImageKey: styleKey || null,
setPromptChanged(false); freeformPrompt: freeformPrompt.trim(),
}
const currentPrompt = promptDraft || selected.startFramePrompt;
const imageKeys = parseRefImageKeys(currentPrompt);
await generateVideo.mutateAsync({
episodeId: selected.episodeId,
storyboardId: selected.id,
prompt: currentPrompt,
videoPrompt: selected.motionScript || currentPrompt,
imageKeys: imageKeys.length > 0 ? imageKeys : null,
duration: selectedDuration, duration: selectedDuration,
ratio: selectedRatio,
model: selectedModel,
generateAudio: audioOn,
}); });
setActiveVideoSbId(expanded.id);
} finally { } finally {
setGeneratingVideoId(null); setGeneratingVideoId(null);
} }
}; };
const handleBatchGenerate = async () => { // The video shown in the right preview tracks `activeVideoSbId` (independent
setBatchGenerating(true); // from accordion expansion) so user can browse other videos via the timeline
const pending = storyboards.filter((sb) => { // without losing editor state.
const t = getTaskForSb(sb); const previewSb = useMemo(
return !t || t.status === "failed"; () => storyboards.find((s) => s.id === activeVideoSbId) ?? expanded,
}); [storyboards, activeVideoSbId, expanded]
for (const sb of pending) { );
const batchPrompt = sb.startFramePrompt || sb.detailedDescription; const previewTask = previewSb ? getTaskForSb(previewSb) : null;
const batchImageKeys = parseRefImageKeys(batchPrompt);
await generateVideo.mutateAsync({
episodeId: sb.episodeId,
storyboardId: sb.id,
prompt: batchPrompt,
videoPrompt: sb.motionScript || batchPrompt,
imageKeys: batchImageKeys.length > 0 ? batchImageKeys : null,
duration: sb.durationSeconds > 0 ? sb.durationSeconds : selectedDuration,
});
}
setBatchGenerating(false);
};
const selectedTask = selected ? getTaskForSb(selected) : null; const promptPreviewText = freeformPrompt.trim();
return ( return (
<div className="h-full flex overflow-hidden bg-background"> <div className="h-full flex overflow-hidden bg-background">
{/* ─── Far Left: Episode list strip ─── */} {/* ─── Far Left: Episode list strip ─── */}
<div className="flex-shrink-0 border-r border-border bg-muted/20 flex flex-col overflow-hidden" style={{ width: 52 }}> <div className="flex-shrink-0 border-r border-border bg-background flex flex-col items-center overflow-hidden py-5" style={{ width: 80 }}>
<div className="h-14 border-b border-border flex items-center justify-center flex-shrink-0"> <span className="font-semibold mb-5" style={{ color: "#9ba0b1", fontSize: 16, letterSpacing: 1 }}>
<span className="text-[10px] text-muted-foreground font-medium">集数</span> 集数
</div> </span>
<div className="flex-1 overflow-y-auto py-1"> <div
className="flex-1 flex flex-col items-center gap-3.5 overflow-y-auto w-full [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: "none" }}
>
{episodesLoading ? ( {episodesLoading ? (
<div className="flex items-center justify-center pt-4"> <div className="flex items-center justify-center pt-4">
<Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" /> <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div> </div>
) : episodes.length === 0 ? ( ) : episodes.length === 0 ? (
<div className="px-1 pt-4 text-center"> <div className="px-1 pt-4 text-center">
<span className="text-[9px] text-muted-foreground leading-tight">暂无集数</span> <span className="text-[12px] text-muted-foreground leading-tight">暂无集数</span>
</div> </div>
) : ( ) : (
episodes.map((ep) => ( episodes.map((ep) => {
const active = activeEpisodeId === ep.id;
return (
<button <button
key={ep.id} key={ep.id}
onClick={() => handleEpisodeSelect(ep)} onClick={() => handleEpisodeSelect(ep)}
title={ep.title || `第${ep.episodeNumber}集`} title={ep.title ? `第${ep.episodeNumber}集 · ${ep.title}` : `第${ep.episodeNumber}集`}
className={`w-full flex flex-col items-center justify-center py-2 transition-colors ${ className={`flex items-center justify-center font-bold tabular-nums transition-all flex-shrink-0 ${
activeEpisodeId === ep.id active ? "" : "hover:bg-[#eceef4]"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`} }`}
style={{
width: 50,
height: 56,
borderRadius: 16,
fontSize: 22,
backgroundColor: active ? "#1c64ff" : "#f6f7fb",
color: active ? "#ffffff" : "#a0a5b5",
boxShadow: active ? "0 6px 14px rgba(28, 100, 255, 0.35)" : "none",
border: "none",
}}
> >
<span className="text-[11px] font-semibold leading-none"></span> {ep.episodeNumber}
<span className="text-base font-bold leading-tight">{ep.episodeNumber}</span>
<span className="text-[11px] font-semibold leading-none"></span>
</button> </button>
)) );
})
)} )}
</div> </div>
</div> </div>
{/* ─── Left: storyboard list ─── */} {/* ─── Column 2: Storyboard accordion list (top: prompt summary, then cards) ─── */}
<div className="flex flex-col flex-shrink-0 border-r border-border overflow-hidden" style={{ width: 240 }}> <div className="flex flex-col flex-shrink-0 border-r border-border overflow-hidden bg-background" style={{ width: 380 }}>
{/* Header */} {/* Top: 分镜表 / 备注描述 */}
<div className="h-14 px-3 border-b border-border flex items-center gap-2 flex-shrink-0"> <div className="px-4 py-3 border-b border-border bg-[#fafbfd] flex-shrink-0 relative">
<span className="absolute left-0 top-3 bottom-3 w-[2px] bg-[#1c64ff]/40 rounded-r-full" />
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] font-semibold tracking-[0.12em] uppercase text-muted-foreground">分镜备注</span>
{promptPreviewText && (
<button
onClick={() => setShowPromptDetails(true)}
className="text-[10px] text-[#1c64ff] hover:underline flex items-center gap-0.5 font-medium"
title="查看完整内容"
>
<Info className="w-3 h-3" />
详情
</button>
)}
</div>
<p className={`text-[11px] leading-relaxed line-clamp-2 ${promptPreviewText ? "text-foreground/85" : "text-muted-foreground italic"}`}>
{promptPreviewText
? (promptPreviewText.length > PROMPT_PREVIEW_LIMIT
? promptPreviewText.slice(0, PROMPT_PREVIEW_LIMIT) + "…"
: promptPreviewText)
: "选中分镜后,这里会显示已填写的视频提示词内容"}
</p>
</div>
{/* Action row: new + AI generate */}
<div className="h-12 px-3 border-b border-border flex items-center gap-2 flex-shrink-0 bg-background">
<button <button
onClick={handleCreate} onClick={handleCreate}
disabled={createSb.isPending || !eid} disabled={createSb.isPending || !eid}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-xs font-medium hover:shadow-md transition disabled:opacity-50" className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg bg-[#1c64ff] text-white text-xs font-semibold tracking-wide transition-all duration-150 hover:shadow-[0_4px_12px_rgba(28,100,255,0.3)] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none"
> >
{createSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />} {createSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />}
新建分镜 新建分镜
</button> </button>
<button <button
onClick={handleGenerate} onClick={handleGenerateAll}
disabled={generateSbs.isPending || !eid} disabled={generateSbs.isPending || !eid}
title="AI批量生成分镜" title="AI批量生成分镜"
className="p-1.5 rounded-lg border border-border hover:bg-muted transition text-muted-foreground disabled:opacity-50" className="p-2 rounded-lg border border-border bg-background text-muted-foreground hover:bg-[#f6f7fb] hover:border-[#1c64ff]/30 hover:text-[#1c64ff] transition-colors disabled:opacity-50"
> >
{generateSbs.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />} {generateSbs.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />}
</button> </button>
</div> </div>
{/* List */} {/* Cards (accordion) */}
<div className="flex-1 overflow-y-auto py-1"> <div className="flex-1 overflow-y-auto py-1">
{!eid ? ( {!eid ? (
<div className="px-3 py-8 text-center"> <div className="px-3 py-8 text-center">
...@@ -380,212 +392,105 @@ export function StoryboardWorkspace() { ...@@ -380,212 +392,105 @@ export function StoryboardWorkspace() {
<p className="text-xs text-muted-foreground">点击「AI生成」或「新建分镜」开始</p> <p className="text-xs text-muted-foreground">点击「AI生成」或「新建分镜」开始</p>
</div> </div>
) : ( ) : (
storyboards.map((sb, i) => ( storyboards.map((sb) => (
<div <StoryboardCard
key={sb.id} key={sb.id}
onClick={() => setSelected(sb)} sb={sb}
className={`mx-2 my-0.5 rounded-lg px-3 py-2.5 cursor-pointer transition-colors border ${ isExpanded={expandedId === sb.id}
selected?.id === sb.id dotClass={getDotColor(sb)}
? "border-primary bg-accent" onToggle={() => {
: "border-transparent hover:bg-muted" const next = expandedId === sb.id ? null : sb.id;
}`} setExpandedId(next);
> if (next) setActiveVideoSbId(next);
<div className="flex items-center justify-between mb-1"> }}
<span className="text-xs font-semibold text-foreground"> onDelete={() => handleDelete(sb)}
分镜 {String(sb.sequenceNum).padStart(3, "0")} shortDescription={expandedId === sb.id ? shortDescription : sb.shortDescription ?? ""}
</span> onShortDescriptionChange={setShortDescription}
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${getDotColor(sb)}`} /> characterKeys={expandedId === sb.id ? characterKeys : []}
</div> onRemoveCharacter={removeCharacter}
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed"> onSwapCharacter={swapCharacter}
{sb.shortDescription || sb.sceneNumber} onAddCharacter={() => setShowCharacterPicker(true)}
</p> keyToCharacter={keyToCharacter}
</div> characters={characters}
sceneKey={expandedId === sb.id ? sceneKey : ""}
onSelectScene={setSceneKey}
propKeys={expandedId === sb.id ? propKeys : []}
onTogglePropKey={togglePropKey}
scenes={scenes}
sceneByKey={sceneByKey}
/>
)) ))
)} )}
</div> </div>
{/* Bottom: batch generate */}
<div className="p-3 border-t border-border flex-shrink-0">
<button
onClick={handleBatchGenerate}
disabled={batchGenerating || storyboards.length === 0}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg bg-green-600 text-white text-xs font-medium hover:bg-green-700 transition disabled:opacity-50"
>
{batchGenerating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
批量生成视频
</button>
</div>
</div> </div>
{/* ─── Middle: storyboard detail + prompt edit ─── */} {/* ─── Column 3: Video generation panel (5-section prompt + duration + button) ─── */}
<div className="flex flex-col border-r border-border overflow-hidden" style={{ width: 400 }}> <div className="flex flex-col flex-shrink-0 border-r border-border overflow-hidden bg-[#fafbfd]" style={{ width: 340 }}>
{!selected ? ( <div className="h-14 px-4 border-b border-border flex items-center flex-shrink-0 bg-background">
<div className="flex-1 flex items-center justify-center"> <span className="text-[13px] font-semibold text-foreground tracking-tight">分镜视频生成</span>
<p className="text-sm text-muted-foreground">选择左侧分镜查看详情</p> {expanded && (
<span className="ml-2 text-[11px] text-muted-foreground tabular-nums">
· 分镜 {String(expanded.sequenceNum).padStart(3, "0")}
</span>
)}
</div> </div>
<div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
{!expanded ? (
<p className="text-xs text-muted-foreground italic mt-4">展开左侧分镜后填写提示词</p>
) : ( ) : (
<> <>
{/* Header */} <div className="flex items-start gap-1.5 px-2.5 py-2 rounded-lg bg-[#1c64ff]/[0.06] border border-[#1c64ff]/15">
<div className="h-14 px-5 border-b border-border flex items-center justify-between flex-shrink-0"> <Sparkles className="w-3.5 h-3.5 text-[#1c64ff] mt-0.5 flex-shrink-0" />
<div> <p className="text-[11px] text-[#1c64ff] leading-relaxed">
<h2 className="text-base font-semibold text-foreground"> 角色、场景、道具预设已默认参考,无需再次添加引用
分镜 {String(selected.sequenceNum).padStart(3, "0")} </p>
</h2>
<p className="text-xs text-muted-foreground">编辑分镜详细信息</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
disabled={createSb.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs text-foreground hover:bg-muted transition"
>
<Copy className="w-3.5 h-3.5" />
复制
</button>
<button
onClick={() => handleDelete(selected)}
disabled={deleteSb.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-red-200 text-xs text-destructive hover:bg-destructive/10 transition"
>
<Trash2 className="w-3.5 h-3.5" />
删除
</button>
</div>
</div>
{/* Prompt edit area */}
<div className="flex-1 overflow-y-auto px-5 py-4">
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-muted-foreground">分镜描述</label>
<div className="flex items-center gap-2">
<button
onClick={handleGeneratePrompt}
disabled={generatePrompt.isPending}
className="text-xs text-violet-600 dark:text-violet-400 flex items-center gap-1 hover:underline disabled:opacity-50"
title="根据分镜剧本自动生成描述"
>
{generatePrompt.isPending
? <Loader2 className="w-3 h-3 animate-spin" />
: <Sparkles className="w-3 h-3" />}
AI生成描述
</button>
{promptChanged && (
<button
onClick={handleSavePrompt}
disabled={updateSb.isPending}
className="text-xs text-primary flex items-center gap-1 hover:underline"
>
{updateSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Check className="w-3 h-3" />}
保存
</button>
)}
</div>
</div> </div>
<textarea <textarea
className="w-full h-full min-h-[320px] px-3 py-3 rounded-lg border border-border bg-background text-sm text-foreground leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-primary/20" value={freeformPrompt}
value={promptDraft} onChange={(e) => setFreeformPrompt(e.target.value)}
onChange={(e) => { placeholder={FREEFORM_PLACEHOLDER}
setPromptDraft(e.target.value); className="flex-1 min-h-[280px] px-3 py-2.5 rounded-lg border border-border bg-background text-sm text-foreground leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-[#1c64ff]/20 focus:border-[#1c64ff]/40 font-mono"
setPromptChanged(e.target.value !== (selected.startFramePrompt || selected.detailedDescription || ""));
}}
placeholder="在此输入或编辑分镜描述/提示词,AI 将根据此内容生成视频..."
/> />
</>
{/* shortDescription */}
{selected.shortDescription && (
<p className="mt-3 text-xs text-muted-foreground border-l-2 border-primary/30 pl-2">
{selected.shortDescription}
</p>
)} )}
{/* @提及:角色/场景名 */}
{(characters.length > 0 || scenes.length > 0) && (
<div className="mt-3">
<div className="text-[11px] text-muted-foreground mb-1.5">@ 提及角色/场景</div>
<div className="flex flex-wrap gap-1.5">
{characters.map((c) => (
<button
key={c.id}
onClick={() => insertMention(c.name)}
className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-violet-50 dark:bg-violet-900/20 border border-violet-200 dark:border-violet-700 text-[11px] text-violet-700 dark:text-violet-300 hover:bg-violet-100 transition"
>
{getCharacterPrimaryImage(c) && <img src={getCharacterPrimaryImage(c) ?? ""} className="w-3.5 h-3.5 rounded-full object-cover" />}
@{c.name}
</button>
))}
{scenes.map((s) => (
<button
key={s.id}
onClick={() => insertMention(s.name)}
className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-50 dark:bg-sky-900/20 border border-sky-200 dark:border-sky-700 text-[11px] text-sky-700 dark:text-sky-300 hover:bg-sky-100 transition"
>
{s.imageUrl && <img src={s.imageUrl} className="w-3.5 h-3.5 rounded object-cover" />}
@{s.name}
</button>
))}
</div> </div>
</div>
)}
{/* @图N:参考图面板 */} <div className="px-3 py-3 border-t border-border flex-shrink-0 bg-background space-y-2">
{refImages.length > 0 && ( <div className="flex items-center gap-2">
<div className="mt-3"> <div className="relative flex-1">
<div className="text-[11px] text-muted-foreground mb-1.5"> <select
@图N 引用参考图。角色支持正面、侧面、背面三视图,生成视频时会直接使用所选参考图。 value={selectedModel}
</div> onChange={(e) => setSelectedModel(e.target.value)}
<div className="flex gap-2 flex-wrap"> disabled={!expanded}
{refImages.map((img, idx) => ( title="视频生成模型"
<button className="w-full appearance-none pl-3 pr-7 py-2 rounded-lg border border-border bg-[#fafbfd] text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-[#1c64ff]/20 focus:border-[#1c64ff]/40 cursor-pointer disabled:opacity-50 transition-colors"
key={img.imageTosKey}
onClick={() => insertRefImage(idx)}
className="flex flex-col items-center gap-0.5 group"
title={`点击插入 @图${idx + 1}(${img.label})`}
>
<div className="w-12 h-12 rounded-lg overflow-hidden border border-border group-hover:border-primary transition bg-muted">
{img.imageUrl ? (
<img src={img.imageUrl} alt={img.label} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-[10px]">无图</div>
)}
</div>
<span className="text-[10px] text-primary font-medium">@图{idx + 1}</span>
<span className="text-[9px] text-muted-foreground leading-none max-w-[56px] truncate">{img.label}</span>
<span
className={`text-[9px] leading-none ${
img.kind === "character" ? "text-violet-600" : "text-sky-600"
}`}
> >
{img.kind === "character" ? img.viewLabel : "场景"} {VIDEO_MODEL_OPTIONS.map((m) => (
</span> <option key={m.value} value={m.value}>{m.label}</option>
</button>
))} ))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div> </div>
</div>
)}
</div>
{/* Bottom: model + duration + generate */}
<div className="px-4 py-3 border-t border-border flex items-center gap-2 flex-shrink-0 bg-card">
{/* Model selector */}
<div className="relative flex-1"> <div className="relative flex-1">
<select <select
value={selectedModel} value={selectedRatio}
onChange={(e) => setSelectedModel(e.target.value)} onChange={(e) => setSelectedRatio(e.target.value)}
className="w-full appearance-none pl-3 pr-7 py-2 rounded-lg border border-border bg-background text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer" disabled={!expanded}
className="w-full appearance-none pl-3 pr-7 py-2 rounded-lg border border-border bg-[#fafbfd] text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-[#1c64ff]/20 focus:border-[#1c64ff]/40 cursor-pointer disabled:opacity-50 transition-colors"
> >
{VIDEO_MODELS.map((m) => ( {RATIO_OPTIONS.map((r) => (
<option key={m.id} value={m.id}>{m.label}</option> <option key={r.value} value={r.value}>{r.label}</option>
))} ))}
</select> </select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" /> <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div> </div>
{/* Duration */}
<div className="relative"> <div className="relative">
<select <select
value={selectedDuration} value={selectedDuration}
onChange={(e) => setSelectedDuration(Number(e.target.value))} onChange={(e) => setSelectedDuration(Number(e.target.value))}
className="appearance-none pl-2 pr-6 py-2 rounded-lg border border-border bg-background text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer w-16" disabled={!expanded}
className="appearance-none pl-2 pr-6 py-2 rounded-lg border border-border bg-[#fafbfd] text-xs text-foreground focus:outline-none focus:ring-2 focus:ring-[#1c64ff]/20 focus:border-[#1c64ff]/40 cursor-pointer w-16 disabled:opacity-50 transition-colors tabular-nums"
> >
{DURATION_OPTIONS.map((d) => ( {DURATION_OPTIONS.map((d) => (
<option key={d} value={d}>{d}</option> <option key={d} value={d}>{d}</option>
...@@ -593,37 +498,54 @@ export function StoryboardWorkspace() { ...@@ -593,37 +498,54 @@ export function StoryboardWorkspace() {
</select> </select>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">s</span> <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">s</span>
</div> </div>
<button
onClick={() => setAudioOn((v) => !v)}
disabled={!expanded}
title={audioOn ? "已开启配音" : "已关闭配音"}
className={`p-2 rounded-lg border text-xs transition-colors disabled:opacity-50 ${
audioOn
? "border-[#1c64ff] bg-[#1c64ff]/10 text-[#1c64ff]"
: "border-border bg-[#fafbfd] text-muted-foreground hover:bg-[#eceef4]"
}`}
>
{audioOn ? <Volume2 className="w-3.5 h-3.5" /> : <VolumeX className="w-3.5 h-3.5" />}
</button>
</div>
{/* Generate video */}
<button <button
onClick={handleGenerateVideo} onClick={handleGenerate}
disabled={generatingVideoId === selected.id || !promptDraft} disabled={!canGenerate || generatingVideoId === expanded?.id}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-xs font-medium hover:shadow-md transition disabled:opacity-50 whitespace-nowrap" title={!canGenerate ? "需要:至少 1 个出镜角色 + 场景图 + 提示词" : ""}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-[#1c64ff] text-white text-xs font-semibold tracking-wide transition-all duration-150 hover:shadow-[0_6px_14px_rgba(28,100,255,0.35)] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none whitespace-nowrap"
> >
{generatingVideoId === selected.id ? ( {generatingVideoId === expanded?.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
) : ( ) : (
<Play className="w-3.5 h-3.5" /> <Play className="w-3.5 h-3.5" />
)} )}
生成视频 ({CREDITS_PER_VIDEO.toLocaleString()}积分) 生成视频{videoCredits != null ? ` (${videoCredits.toLocaleString()}积分)` : ""}
</button> </button>
</div> </div>
</>
)}
</div> </div>
{/* ─── Right: video preview ─── */} {/* ─── Column 4: Big video preview + thumbnail timeline ─── */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0"> <div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-background">
<div className="h-14 px-5 border-b border-border flex items-center justify-between flex-shrink-0"> <div className="h-14 px-5 border-b border-border flex items-center justify-between flex-shrink-0 bg-background">
<span className="text-sm font-medium text-foreground">视频预览</span> <span className="text-[13px] font-semibold text-foreground tracking-tight">
{/* Assembly controls */} 视频预览
{previewSb && (
<span className="ml-2 text-[11px] font-normal text-muted-foreground tabular-nums">
· 分镜 {String(previewSb.sequenceNum).padStart(3, "0")}
</span>
)}
</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{assemblyTask?.status === "succeeded" && assemblyTask.downloadUrl && ( {assemblyTask?.status === "succeeded" && assemblyTask.downloadUrl && (
<a <a
href={assemblyTask.downloadUrl} href={assemblyTask.downloadUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-green-600 text-white text-xs hover:bg-green-700 transition" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-500 text-white text-xs font-medium hover:bg-emerald-600 hover:shadow-[0_4px_12px_rgba(16,185,129,0.3)] transition-all duration-150"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
下载合集 下载合集
...@@ -631,8 +553,13 @@ export function StoryboardWorkspace() { ...@@ -631,8 +553,13 @@ export function StoryboardWorkspace() {
)} )}
<button <button
onClick={() => startAssembly.mutate()} onClick={() => startAssembly.mutate()}
disabled={startAssembly.isPending || assemblyTask?.status === "running" || assemblyTask?.status === "pending" || storyboards.length === 0} disabled={
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-xs text-foreground hover:bg-muted transition disabled:opacity-50" startAssembly.isPending ||
assemblyTask?.status === "running" ||
assemblyTask?.status === "pending" ||
storyboards.length === 0
}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-background text-xs text-foreground hover:bg-[#f6f7fb] hover:border-[#1c64ff]/30 hover:text-[#1c64ff] transition-colors disabled:opacity-50 disabled:hover:bg-background disabled:hover:border-border disabled:hover:text-foreground"
> >
{startAssembly.isPending || assemblyTask?.status === "running" ? ( {startAssembly.isPending || assemblyTask?.status === "running" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
...@@ -643,7 +570,7 @@ export function StoryboardWorkspace() { ...@@ -643,7 +570,7 @@ export function StoryboardWorkspace() {
</button> </button>
<button <button
onClick={() => navigate(`/project/${projectId}/video`)} onClick={() => navigate(`/project/${projectId}/video`)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-card text-xs text-foreground hover:bg-muted transition" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-background text-xs text-foreground hover:bg-[#f6f7fb] hover:border-[#1c64ff]/30 hover:text-[#1c64ff] transition-colors"
> >
视频管理 视频管理
<ArrowRight className="w-3.5 h-3.5" /> <ArrowRight className="w-3.5 h-3.5" />
...@@ -651,82 +578,836 @@ export function StoryboardWorkspace() { ...@@ -651,82 +578,836 @@ export function StoryboardWorkspace() {
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-5"> <div className="flex-1 overflow-y-auto p-5 min-h-0 bg-[#fafbfd]">
{!selected ? ( {!previewSb ? (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
<p className="text-sm text-muted-foreground">选择分镜后查看视频</p> <p className="text-sm text-muted-foreground">展开任意分镜后查看视频</p>
</div> </div>
) : selectedTask?.status === "succeeded" && selectedTask.resultVideoUrl ? ( ) : previewTask?.status === "succeeded" && previewTask.resultVideoUrl ? (
/* Video ready */ <div className="space-y-3">
<div className="space-y-4"> <div className="rounded-xl overflow-hidden bg-black aspect-video shadow-[0_8px_24px_rgba(15,23,42,0.12)]">
<div className="rounded-xl overflow-hidden bg-black aspect-video">
<video <video
key={selectedTask.resultVideoUrl} key={previewTask.resultVideoUrl}
src={selectedTask.resultVideoUrl} src={previewTask.resultVideoUrl}
controls controls
autoPlay={false} autoPlay={false}
className="w-full h-full object-contain" className="w-full h-full object-contain"
/> />
</div> </div>
{/* Thumbnail strip */}
<div className="flex gap-2 overflow-x-auto pb-1">
{Array.from({ length: Math.min(Math.ceil(selectedDuration / 4), 6) }).map((_, i) => (
<div
key={i}
className="flex-shrink-0 w-20 rounded-lg bg-muted overflow-hidden relative"
style={{ aspectRatio: "16/9" }}
>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[10px] text-muted-foreground">{(i + 1) * Math.floor(selectedDuration / Math.min(Math.ceil(selectedDuration / 4), 6))}s</span>
</div>
</div>
))}
</div>
<a <a
href={selectedTask.resultVideoUrl} href={previewTask.resultVideoUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline" className="inline-flex items-center gap-1.5 text-xs text-[#1c64ff] hover:underline font-medium"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
下载此镜头视频 下载此镜头视频
</a> </a>
</div> </div>
) : selectedTask?.status === "running" || selectedTask?.status === "submitted" || selectedTask?.status === "pending" ? ( ) : previewTask?.status === "running" || previewTask?.status === "submitted" || previewTask?.status === "pending" ? (
/* Generating */
<div className="h-full flex flex-col items-center justify-center gap-4"> <div className="h-full flex flex-col items-center justify-center gap-4">
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center"> <div className="relative w-16 h-16 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-yellow-600 animate-spin" /> <span className="absolute inset-0 rounded-full bg-[#1c64ff]/10 animate-pulse" />
</div> <Loader2 className="w-8 h-8 text-[#1c64ff] animate-spin relative" />
<div className="text-center">
<p className="text-sm font-medium text-foreground mb-1">视频生成中...</p>
<p className="text-xs text-muted-foreground">通常需要 30-60 秒,请稍候</p>
</div> </div>
<p className="text-sm font-semibold text-foreground tracking-tight">视频生成中…</p>
<p className="text-xs text-muted-foreground">通常需要 30-60 秒</p>
</div> </div>
) : selectedTask?.status === "failed" ? ( ) : previewTask?.status === "failed" ? (
/* Failed */
<div className="rounded-xl border border-red-200 bg-red-50 p-6"> <div className="rounded-xl border border-red-200 bg-red-50 p-6">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<X className="w-4 h-4 text-red-500" /> <X className="w-4 h-4 text-red-500" />
<span className="text-sm font-medium text-red-700">生成失败</span> <span className="text-sm font-medium text-red-700">生成失败</span>
</div> </div>
<p className="text-xs text-red-600">{selectedTask.errorMessage || "未知错误"}</p> <p className="text-xs text-red-600">{previewTask.errorMessage || "未知错误"}</p>
<button
onClick={handleGenerateVideo}
className="mt-3 text-xs text-red-600 hover:underline"
>
重新生成
</button>
</div> </div>
) : ( ) : (
/* No video yet */
<div className="h-full flex flex-col items-center justify-center gap-4"> <div className="h-full flex flex-col items-center justify-center gap-4">
<div className="w-full max-w-sm aspect-video rounded-xl border-2 border-dashed border-border bg-muted/30 flex flex-col items-center justify-center gap-3"> <div className="w-full max-w-sm aspect-video rounded-xl border-[1.5px] border-dashed border-border bg-background flex flex-col items-center justify-center gap-3">
<Play className="w-12 h-12 text-muted-foreground/30" /> <Play className="w-12 h-12 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">尚未生成视频</p> <p className="text-sm text-muted-foreground">尚未生成视频</p>
<p className="text-xs text-muted-foreground/70">编辑左侧提示词后点击「生成视频」</p> <p className="text-xs text-muted-foreground/70">在左侧填好素材+提示词后生成</p>
</div>
</div>
)}
</div>
{/* Thumbnail timeline */}
{storyboards.length > 0 && (
<div className="border-t border-border bg-background flex-shrink-0">
<div className="px-3 py-2.5 flex items-center gap-2 overflow-x-auto">
{storyboards.map((sb) => {
const t = getTaskForSb(sb);
const isActive = activeVideoSbId === sb.id;
const url = t?.resultVideoUrl;
return (
<button
key={sb.id}
onClick={() => {
setActiveVideoSbId(sb.id);
setExpandedId(sb.id);
}}
title={`分镜 ${String(sb.sequenceNum).padStart(3, "0")}`}
className={`relative flex-shrink-0 w-[88px] h-[52px] rounded-lg overflow-hidden border transition-all duration-150 ${
isActive
? "border-[#1c64ff] ring-2 ring-[#1c64ff]/25 shadow-[0_4px_12px_rgba(28,100,255,0.2)]"
: "border-border hover:border-[#1c64ff]/40 hover:-translate-y-px"
}`}
>
{url ? (
<video
src={url}
muted
preload="metadata"
className="w-full h-full object-cover pointer-events-none"
/>
) : (
<div className="w-full h-full bg-[#f6f7fb] flex items-center justify-center">
<ImageIcon className="w-4 h-4 text-muted-foreground/40" />
</div>
)}
<span className="absolute bottom-0 left-0 right-0 bg-black/65 text-white text-[10px] font-medium tabular-nums py-0.5 text-center">
{String(sb.sequenceNum).padStart(3, "0")}
</span>
<span className={`absolute top-1 right-1 w-1.5 h-1.5 rounded-full ring-2 ring-white/80 ${getDotColor(sb)}`} />
</button>
);
})}
</div>
</div>
)}
</div>
{/* ─── Modals ─── */}
{showCharacterPicker && expanded && (
<CharacterPickerModal
projectId={pid}
characters={characters}
currentKeys={characterKeys}
onPick={(k) => {
if (!characterKeys.includes(k) && characterKeys.length < MAX_CHARACTERS) {
setCharacterKeys((prev) => [...prev, k]);
}
}}
onClose={() => setShowCharacterPicker(false)}
/>
)}
{showPromptDetails && (
<PromptDetailsModal
text={freeformPrompt}
onClose={() => setShowPromptDetails(false)}
/>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
// StoryboardCard — accordion item with self-contained editor when expanded
// ────────────────────────────────────────────────────────────────────────────
interface StoryboardCardProps {
sb: Storyboard;
isExpanded: boolean;
dotClass: string;
onToggle: () => void;
onDelete: () => void;
shortDescription: string;
onShortDescriptionChange: (v: string) => void;
characterKeys: string[];
onRemoveCharacter: (key: string) => void;
onSwapCharacter: (oldKey: string, newKey: string) => void;
onAddCharacter: () => void;
keyToCharacter: Map<string, Character>;
characters: Character[];
sceneKey: string;
onSelectScene: (key: string) => void;
propKeys: string[];
onTogglePropKey: (key: string) => void;
scenes: Scene[];
sceneByKey: Map<string, Scene>;
}
function StoryboardCard(props: StoryboardCardProps) {
const { sb, isExpanded, dotClass, onToggle, onDelete } = props;
const [descCollapsed, setDescCollapsed] = useState(false);
return (
<div className={`mx-2 my-1 rounded-xl border ${isExpanded ? "border-primary/40 bg-card shadow-sm" : "border-border hover:bg-muted/50"} transition-colors`}>
<button
onClick={onToggle}
className="w-full px-3.5 pt-3 pb-2 flex items-center gap-2 text-left"
>
<span className="text-sm font-semibold text-foreground flex-1 truncate">
分镜信息{String(sb.sequenceNum).padStart(3, "0")}
</span>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${dotClass}`} />
</button>
{isExpanded && (
<div className="px-3.5 pb-3.5 space-y-3.5">
{/* 分镜描述 */}
<div>
<button
onClick={() => setDescCollapsed((v) => !v)}
className="w-full flex items-center justify-between mb-1.5"
>
<span className="text-xs font-medium text-foreground">分镜描述</span>
{descCollapsed
? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
: <ChevronUp className="w-3.5 h-3.5 text-muted-foreground" />}
</button>
{!descCollapsed && (
<textarea
rows={4}
value={props.shortDescription}
onChange={(e) => props.onShortDescriptionChange(e.target.value)}
placeholder="简要描述此分镜的剧情或画面"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-xs text-foreground leading-relaxed resize-none focus:outline-none focus:ring-1 focus:ring-primary/30"
/>
)}
</div>
{/* 出镜角色 */}
<CharacterRowList
characterKeys={props.characterKeys}
keyToCharacter={props.keyToCharacter}
characters={props.characters}
onRemove={props.onRemoveCharacter}
onSwap={props.onSwapCharacter}
onAdd={props.onAddCharacter}
/>
{/* 分镜场景 */}
<SceneSection
scenes={props.scenes}
selectedKey={props.sceneKey}
onSelect={props.onSelectScene}
/>
{/* 场景道具 */}
<PropGrid
scenes={props.scenes}
sceneKey={props.sceneKey}
selectedKeys={props.propKeys}
onToggle={props.onTogglePropKey}
/>
{/* 删除按钮 */}
<div className="pt-1 flex justify-end">
<button
onClick={onDelete}
className="text-[11px] text-destructive hover:underline flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
删除该分镜
</button>
</div>
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
// CharacterRowList — selected character variants as horizontal rows:
// [avatar | name dropdown | voice dropdown | 删除 | 重绘]
// ────────────────────────────────────────────────────────────────────────────
interface CharacterRowListProps {
characterKeys: string[];
keyToCharacter: Map<string, Character>;
characters: Character[];
onRemove: (key: string) => void;
onSwap: (oldKey: string, newKey: string) => void;
onAdd: () => void;
}
function CharacterRowList({
characterKeys, keyToCharacter, characters, onRemove, onSwap, onAdd,
}: CharacterRowListProps) {
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-foreground">出镜角色</span>
<button
onClick={onAdd}
disabled={characterKeys.length >= MAX_CHARACTERS}
className="flex items-center gap-0.5 text-[11px] text-primary hover:underline disabled:opacity-40 disabled:no-underline"
>
<Plus className="w-3 h-3" />
添加
</button>
</div>
{characterKeys.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic">点击「添加」从角色库挑选或创建变体</p>
) : (
<div className="space-y-2">
{characterKeys.map((k) => (
<CharacterRow
key={k}
tosKey={k}
character={keyToCharacter.get(k) ?? null}
allCharacters={characters}
currentKeys={characterKeys}
onRemove={() => onRemove(k)}
onSwap={(newKey) => onSwap(k, newKey)}
/>
))}
</div>
)}
</div>
);
}
interface CharacterRowProps {
tosKey: string;
character: Character | null;
allCharacters: Character[];
currentKeys: string[];
onRemove: () => void;
onSwap: (newKey: string) => void;
}
function CharacterRow({
tosKey, character, allCharacters, currentKeys, onRemove, onSwap,
}: CharacterRowProps) {
const { projectId } = useParams<{ projectId: string }>();
const regen = useGenerateCharacterImage(projectId ?? "");
const handleRegen = () => {
if (!character) return;
regen.mutate(character.id);
};
const url = character ? characterFrontUrl(character) : null;
const isRegenerating = regen.isPending;
// Options: this character + any others not already used in the storyboard
const options = allCharacters.filter((c) => {
const k = characterFrontKey(c);
if (!k) return false;
return k === tosKey || !currentKeys.includes(k);
});
return (
<div className="flex items-center gap-1.5">
<div className="w-8 h-8 rounded-md overflow-hidden border border-border bg-muted flex-shrink-0">
{url ? (
<img src={url} alt={character?.name ?? ""} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[8px] text-muted-foreground">无图</div>
)}
</div>
<div className="relative flex-1 min-w-0">
<select
value={tosKey}
onChange={(e) => onSwap(e.target.value)}
className="w-full appearance-none pl-2 pr-6 py-1.5 rounded border border-border bg-background text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary/30 cursor-pointer truncate"
>
{!character && <option value={tosKey}>(已失效)</option>}
{options.map((c) => {
const k = characterFrontKey(c)!;
return <option key={k} value={k}>{c.name}</option>;
})}
</select>
<ChevronDown className="absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div>
<div className="relative flex-1 min-w-0">
<select
disabled
className="w-full appearance-none pl-2 pr-6 py-1.5 rounded border border-border bg-muted/40 text-xs text-muted-foreground cursor-not-allowed truncate"
>
<option>未配置音色</option>
</select>
<ChevronDown className="absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div>
<button
onClick={onRemove}
className="px-2 py-1.5 rounded border border-border text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
>
删除
</button>
<button
onClick={handleRegen}
disabled={!character || isRegenerating}
className="px-2 py-1.5 rounded border border-border text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 flex items-center gap-1"
>
{isRegenerating && <Loader2 className="w-3 h-3 animate-spin" />}
重绘
</button>
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
// SceneSection — large preview + scene picker dropdown + more menu
// ────────────────────────────────────────────────────────────────────────────
interface SceneSectionProps {
scenes: Scene[];
selectedKey: string;
onSelect: (key: string) => void;
}
function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) {
const [showPicker, setShowPicker] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const usable = scenes.filter((s) => !!s.imageTosKey);
const selected = usable.find((s) => s.imageTosKey === selectedKey) ?? null;
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-foreground">分镜场景</span>
{usable.length > 0 && (
<button
onClick={() => setShowPicker((v) => !v)}
className="flex items-center gap-0.5 text-[11px] text-primary hover:underline"
>
<Plus className="w-3 h-3" />
{selected ? "更换" : "选择"}
</button>
)}
</div>
{/* Big preview — clickable to open picker */}
<button
type="button"
onClick={() => usable.length > 0 && setShowPicker((v) => !v)}
disabled={usable.length === 0}
className="w-full aspect-video rounded-lg overflow-hidden border border-border bg-muted mb-2 block hover:border-primary/40 transition disabled:cursor-not-allowed"
>
{selected?.imageUrl ? (
<img src={selected.imageUrl} alt={selected.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-1 text-muted-foreground">
<ImageIcon className="w-5 h-5" />
<span className="text-[11px]">
{usable.length === 0 ? "先到「场景」页创建场景" : "点击选择一张场景图"}
</span>
</div>
)}
</button>
{/* Bottom row: scene name + role label + actions menu */}
<div className="flex items-center gap-1.5">
<div className="flex-1 min-w-0 px-2.5 py-1.5 rounded-md border border-border bg-background text-xs text-foreground truncate">
{selected?.name || <span className="text-muted-foreground">请选择场景</span>}
</div>
<div className="px-2.5 py-1.5 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground flex-shrink-0">
主场景图
</div>
<div className="relative flex-shrink-0">
<button
onClick={() => setMenuOpen((v) => !v)}
disabled={!selected}
className="p-1.5 rounded-md border border-border text-muted-foreground hover:bg-muted disabled:opacity-40"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{menuOpen && selected && (
<>
<button
className="fixed inset-0 z-10 cursor-default"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 top-full mt-1 z-20 bg-popover border border-border rounded-md shadow-lg overflow-hidden min-w-[100px]">
<button
onClick={() => { setShowPicker(true); setMenuOpen(false); }}
className="w-full px-3 py-1.5 text-left text-xs text-foreground hover:bg-muted"
>
更换场景
</button>
<button
onClick={() => { onSelect(""); setMenuOpen(false); }}
className="w-full px-3 py-1.5 text-left text-xs text-destructive hover:bg-muted"
>
清除选择
</button>
</div>
</>
)}
</div>
</div>
{showPicker && (
<div className="mt-2 rounded-lg border border-border bg-muted/30 p-2">
{usable.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic text-center py-2">先到「场景」页创建场景</p>
) : (
<div className="grid grid-cols-3 gap-2">
{usable.map((s) => {
const active = selectedKey === s.imageTosKey;
return (
<button
key={s.id}
onClick={() => {
onSelect(active ? "" : s.imageTosKey!);
if (!active) setShowPicker(false);
}}
title={s.name}
className={`relative aspect-square rounded-md overflow-hidden border-2 ${
active ? "border-primary ring-2 ring-primary/30" : "border-border hover:border-primary/40"
}`}
>
{s.imageUrl ? (
<img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[9px] text-muted-foreground">无图</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-1 pt-3 pb-0.5">
<span className="text-[10px] text-white truncate block">{s.name}</span>
</div>
{active && (
<div className="absolute top-1 right-1 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-white" />
</div>
)}
</button>
);
})}
</div>
)}
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
// PropGrid — selected props as 3-col card grid + inline picker drawer
// ────────────────────────────────────────────────────────────────────────────
interface PropGridProps {
scenes: Scene[];
sceneKey: string;
selectedKeys: string[];
onToggle: (key: string) => void;
}
function PropGrid({ scenes, sceneKey, selectedKeys, onToggle }: PropGridProps) {
const [showPicker, setShowPicker] = useState(false);
const allCandidates = scenes.filter((s) => !!s.imageTosKey && s.imageTosKey !== sceneKey);
const selected = selectedKeys
.map((k) => allCandidates.find((s) => s.imageTosKey === k))
.filter((s): s is Scene => !!s);
const available = allCandidates.filter((s) => !selectedKeys.includes(s.imageTosKey!));
const canAddMore = selectedKeys.length < MAX_PROPS;
return (
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-foreground">场景道具</span>
<button
onClick={() => setShowPicker((v) => !v)}
disabled={!canAddMore && !showPicker}
className="flex items-center gap-0.5 text-[11px] text-primary hover:underline disabled:opacity-40 disabled:no-underline"
>
<Plus className="w-3 h-3" />
添加
</button>
</div>
{selected.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic mb-2">从场景库中可挑选最多 5 张作为道具</p>
) : (
<div className="grid grid-cols-3 gap-2 mb-2">
{selected.map((s) => (
<button
key={s.id}
onClick={() => onToggle(s.imageTosKey!)}
title={`移除 ${s.name}`}
className="relative aspect-square rounded-lg overflow-hidden border border-border bg-muted group"
>
{s.imageUrl ? (
<img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[9px] text-muted-foreground">无图</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/40 to-transparent px-1.5 pt-3 pb-1 text-left">
<span className="text-[11px] text-white font-medium truncate block">{s.name}</span>
</div> </div>
<div className="absolute top-1 right-1 w-4 h-4 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition">
<X className="w-2.5 h-2.5" />
</div> </div>
</button>
))}
</div>
)}
{showPicker && (
<div className="rounded-lg border border-border bg-muted/30 p-2">
{available.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic text-center py-2">
{allCandidates.length === 0 ? "先到「场景」页创建场景" : "已添加全部可选道具"}
</p>
) : (
<div className="grid grid-cols-4 gap-1.5">
{available.map((s) => (
<button
key={s.id}
onClick={() => {
onToggle(s.imageTosKey!);
if (selectedKeys.length + 1 >= MAX_PROPS) setShowPicker(false);
}}
disabled={!canAddMore}
title={s.name}
className="relative aspect-square rounded-md overflow-hidden border border-border bg-background hover:border-primary/40 disabled:opacity-40"
>
{s.imageUrl ? (
<img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[9px] text-muted-foreground">无图</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-black/60 px-1 py-0.5">
<span className="text-[9px] text-white truncate block">{s.name}</span>
</div>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
// CharacterPickerModal — pick from existing characters OR create a new variant
// ────────────────────────────────────────────────────────────────────────────
interface CharacterPickerModalProps {
projectId: string;
characters: Character[];
currentKeys: string[];
onPick: (key: string) => void;
onClose: () => void;
}
function CharacterPickerModal({
projectId, characters, currentKeys, onPick, onClose,
}: CharacterPickerModalProps) {
const [createMode, setCreateMode] = useState(false);
if (createMode) {
return (
<AddCharacterModal
projectId={projectId}
onClose={() => setCreateMode(false)}
onAdded={(c) => {
const k = characterFrontKey(c);
if (k) onPick(k);
setCreateMode(false);
onClose();
}}
/>
);
}
const usable = characters.filter((c) => !!characterFrontKey(c));
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-background rounded-xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground">添加出镜角色</h3>
<button onClick={onClose} className="p-1 hover:bg-muted rounded">
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">
<p className="text-xs text-muted-foreground mb-3">从已有角色中挑选,或创建新的角色变体</p>
{usable.length === 0 ? (
<p className="text-xs text-muted-foreground italic text-center py-8">
当前项目还没有角色,请先创建一个新变体
</p>
) : (
<div className="grid grid-cols-4 gap-3">
{usable.map((c) => {
const k = characterFrontKey(c)!;
const url = characterFrontUrl(c);
const isAdded = currentKeys.includes(k);
return (
<button
key={c.id}
disabled={isAdded}
onClick={() => { onPick(k); onClose(); }}
className={`relative rounded-lg overflow-hidden border-2 transition ${
isAdded
? "border-primary opacity-50 cursor-not-allowed"
: "border-border hover:border-primary/60"
}`}
>
<div className="aspect-square bg-muted">
{url ? (
<img src={url} alt={c.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[10px] text-muted-foreground">无图</div>
)}
</div>
<div className="px-2 py-1.5 text-left">
<p className="text-xs font-medium text-foreground truncate">{c.name}</p>
</div>
{isAdded && (
<div className="absolute top-1 right-1 w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
)}
</button>
);
})}
</div>
)}
</div>
<div className="px-5 py-3 border-t border-border flex justify-between items-center gap-2">
<button
onClick={() => setCreateMode(true)}
className="px-3 py-1.5 rounded-lg border border-primary/40 text-primary text-xs hover:bg-primary/10 flex items-center gap-1.5"
>
<Plus className="w-3 h-3" />
创建新角色变体
</button>
<button
onClick={onClose}
className="px-4 py-1.5 rounded-lg border border-border text-xs text-foreground hover:bg-muted"
>
完成
</button>
</div>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
// AddCharacterModal — create a new character variant (e.g. 陆长生-白衣)
// ────────────────────────────────────────────────────────────────────────────
interface AddCharacterModalProps {
projectId: string;
onClose: () => void;
onAdded: (c: Character) => void;
}
function AddCharacterModal({ projectId, onClose, onAdded }: AddCharacterModalProps) {
const [name, setName] = useState("");
const [imagePrompt, setImagePrompt] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const saveChar = useSaveCharacter(projectId);
const genImg = useGenerateCharacterImage(projectId);
const handleSubmit = async () => {
if (!name.trim()) {
setError("请输入角色名称");
return;
}
if (!imagePrompt.trim()) {
setError("请输入形象提示词");
return;
}
setError(null);
setBusy(true);
try {
const created = await saveChar.mutateAsync({
name: name.trim(),
roleType: "supporting",
imagePrompt: imagePrompt.trim(),
status: "draft",
});
const withImage = await genImg.mutateAsync(created.id);
onAdded(withImage);
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败");
} finally {
setBusy(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-background rounded-xl shadow-xl w-full max-w-md max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground">新增出镜角色变体</h3>
<button onClick={onClose} className="p-1 hover:bg-muted rounded">
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-3">
<p className="text-[11px] text-muted-foreground">
创建一个新的角色形象(例如「陆长生 - 白衣」)。系统会立即生成一张参考图。
</p>
<div>
<label className="block text-xs text-foreground mb-1">角色名称 *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:陆长生 - 白衣"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<div>
<label className="block text-xs text-foreground mb-1">形象提示词 *</label>
<textarea
rows={4}
value={imagePrompt}
onChange={(e) => setImagePrompt(e.target.value)}
placeholder="例如:男性,二十岁出头,身穿白色长袍,气质温雅,黑色长发束起"
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm leading-relaxed resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
<button
onClick={onClose}
disabled={busy}
className="px-4 py-1.5 rounded-lg border border-border text-xs text-foreground hover:bg-muted disabled:opacity-50"
>
取消
</button>
<button
onClick={handleSubmit}
disabled={busy}
className="px-4 py-1.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-xs hover:shadow-md disabled:opacity-50 flex items-center gap-1.5"
>
{busy && <Loader2 className="w-3 h-3 animate-spin" />}
创建并生成
</button>
</div>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
// PromptDetailsModal — full prompt content
// ────────────────────────────────────────────────────────────────────────────
interface PromptDetailsModalProps {
text: string;
onClose: () => void;
}
function PromptDetailsModal({ text, onClose }: PromptDetailsModalProps) {
const trimmed = text.trim();
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-background rounded-xl shadow-xl w-full max-w-lg max-h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground">分镜提示词全文</h3>
<button onClick={onClose} className="p-1 hover:bg-muted rounded">
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">
{trimmed ? (
<pre className="text-sm text-foreground leading-relaxed whitespace-pre-wrap font-mono">
{trimmed}
</pre>
) : (
<p className="text-sm text-muted-foreground italic">(未填写提示词)</p>
)} )}
</div> </div>
</div> </div>
......
import { useState, useMemo } from "react"; import { useMemo, useState } from "react";
import { import {
Sparkles, AlertCircle,
TrendingUp,
Calendar, Calendar,
Filter, Check,
Users, ChevronLeft,
ChevronRight,
CircleDollarSign,
Film, Film,
Filter,
Image as ImageIcon, Image as ImageIcon,
X, Layers,
Check,
Loader2, Loader2,
MessageSquareText,
PackagePlus,
Search,
Sparkles,
TrendingDown,
WalletCards,
X,
} from "lucide-react"; } from "lucide-react";
import { import {
LineChart, Bar,
Line, BarChart,
XAxis,
YAxis,
CartesianGrid, CartesianGrid,
Tooltip, Cell,
Legend, Pie,
PieChart,
ResponsiveContainer, ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts"; } from "recharts";
import { useBalance, useRecords, usePurchase } from "../../hooks/useUsage"; import { useBalance, usePurchase, useRecords } from "../../hooks/useUsage";
import type { BillingRecord } from "../../lib/api/usage"; import type { BillingRecord } from "../../lib/api/usage";
const opLabels: Record<string, string> = { const PAGE_SIZE = 30;
outline_generate: "大纲生成",
episode_generate: "分集生成",
image_generate: "图片生成",
video_generate: "视频生成",
assembly: "视频合成",
recharge: "充值",
};
const opIcons: Record<string, typeof Film> = { const OP_META: Record<string, { label: string; category: string; icon: typeof Film; color: string }> = {
outline_generate: Film, outline_generate: { label: "大纲生成", category: "剧本", icon: MessageSquareText, color: "#2563eb" },
episode_generate: Film, episode_generate: { label: "分集生成", category: "剧本", icon: MessageSquareText, color: "#3b82f6" },
image_generate: ImageIcon, character_extract: { label: "角色提取", category: "剧本", icon: Layers, color: "#0f766e" },
video_generate: Film, scene_extract: { label: "场景提取", category: "剧本", icon: Layers, color: "#14b8a6" },
assembly: Film, storyboard_generate: { label: "分镜生成", category: "分镜", icon: Layers, color: "#7c3aed" },
recharge: Sparkles, storyboard_prompt_generate: { label: "提示词生成", category: "分镜", icon: MessageSquareText, color: "#8b5cf6" },
image_generate: { label: "图片生成", category: "图片", icon: ImageIcon, color: "#ea580c" },
character_image_generate: { label: "角色图生成", category: "图片", icon: ImageIcon, color: "#f97316" },
scene_image_generate: { label: "场景图生成", category: "图片", icon: ImageIcon, color: "#fb923c" },
video_generate: { label: "视频生成", category: "视频", icon: Film, color: "#dc2626" },
assembly: { label: "视频合成", category: "合成", icon: Film, color: "#475569" },
recharge: { label: "积分充值", category: "充值", icon: PackagePlus, color: "#16a34a" },
}; };
const PACKAGES = [ const PACKAGES = [
{ id: "starter", label: "入门包", credits: 1_000, price: "¥9.9", desc: "适合个人试用" }, { id: "starter", label: "入门包", credits: 1_000, price: "¥9.9", desc: "适合个人试用" },
{ id: "basic", label: "基础包", credits: 5_000, price: "¥39", desc: "适合小团队" }, { id: "basic", label: "基础包", credits: 5_000, price: "¥39", desc: "适合小团队" },
{ id: "pro", label: "专业包", credits: 20_000, price: "¥129", desc: "最受欢迎" }, { id: "pro", label: "专业包", credits: 20_000, price: "¥129", desc: "高频创作推荐" },
{ id: "enterprise", label: "企业包", credits: 100_000, price: "¥499", desc: "适合大规模制作" }, { id: "enterprise", label: "企业包", credits: 100_000, price: "¥499", desc: "适合批量生产" },
]; ];
type QuickFilter = "all" | "consume" | "recharge" | "failed";
const money = (value: number | string | null | undefined) => Number(value ?? 0).toLocaleString();
const isRecharge = (record: BillingRecord) =>
record.operation === "recharge" || record.modality === "RECHARGE";
const isSuccess = (record: BillingRecord) => record.success !== false;
const getMeta = (operation: string) =>
OP_META[operation] ?? { label: operation || "未知操作", category: "其他", icon: CircleDollarSign, color: "#64748b" };
const formatDateTime = (value?: string) => {
if (!value) return "-";
return value.replace("T", " ").slice(0, 16);
};
const dayKey = (value?: string) => {
if (!value) return "未知";
const date = value.slice(5, 10);
return date || "未知";
};
function StatCard({
label,
value,
caption,
icon: Icon,
tone,
}: {
label: string;
value: string;
caption: string;
icon: typeof Sparkles;
tone: string;
}) {
return (
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-2 text-2xl font-semibold text-foreground">{value}</div>
<div className="mt-1 text-xs text-muted-foreground">{caption}</div>
</div>
<div className={`flex h-11 w-11 items-center justify-center rounded-lg ${tone}`}>
<Icon className="h-5 w-5" />
</div>
</div>
</div>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-14 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Search className="h-5 w-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">暂无匹配的积分记录</div>
<div className="mt-1 text-xs text-muted-foreground">调整筛选条件,或翻到其他分页查看历史记录。</div>
</div>
);
}
export function UsageManagement() { export function UsageManagement() {
const [dateRange, setDateRange] = useState({ start: "", end: "" }); const [dateRange, setDateRange] = useState({ start: "", end: "" });
const [selectedType, setSelectedType] = useState(""); const [selectedType, setSelectedType] = useState("");
const [quickFilter, setQuickFilter] = useState<QuickFilter>("all");
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [showPurchaseModal, setShowPurchaseModal] = useState(false); const [showPurchaseModal, setShowPurchaseModal] = useState(false);
const [selectedPackage, setSelectedPackage] = useState("pro"); const [selectedPackage, setSelectedPackage] = useState("pro");
const [purchased, setPurchased] = useState(false); const [purchased, setPurchased] = useState(false);
const PAGE_SIZE = 20;
const { data: balance } = useBalance(); const { data: balance } = useBalance();
const { data: recordsPage } = useRecords(PAGE_SIZE, page * PAGE_SIZE); const { data: recordsPage, isLoading } = useRecords(PAGE_SIZE, page * PAGE_SIZE);
const purchase = usePurchase(); const purchase = usePurchase();
const records: BillingRecord[] = recordsPage?.items ?? []; const records: BillingRecord[] = recordsPage?.items ?? [];
const totalRecords = recordsPage?.total ?? 0; const totalRecords = recordsPage?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(totalRecords / PAGE_SIZE));
const filteredRecords = records.filter((r) => { const filteredRecords = useMemo(() => {
if (selectedType && r.operation !== selectedType) return false; return records.filter((record) => {
if (dateRange.start && r.createdAt < dateRange.start + "T00:00:00") return false; if (selectedType && record.operation !== selectedType) return false;
if (dateRange.end && r.createdAt > dateRange.end + "T23:59:59") return false; if (dateRange.start && record.createdAt < `${dateRange.start}T00:00:00`) return false;
if (dateRange.end && record.createdAt > `${dateRange.end}T23:59:59`) return false;
if (quickFilter === "consume" && isRecharge(record)) return false;
if (quickFilter === "recharge" && !isRecharge(record)) return false;
if (quickFilter === "failed" && isSuccess(record)) return false;
return true; return true;
}); });
}, [dateRange.end, dateRange.start, quickFilter, records, selectedType]);
// Generate daily consumption data for the past 30 days const pageStats = useMemo(() => {
const dailyConsumptionData = useMemo(() => { const consumed = filteredRecords
const data = []; .filter((record) => !isRecharge(record))
const baseDate = new Date("2026-04-12"); .reduce((sum, record) => sum + Number(record.credits ?? 0), 0);
const recharged = filteredRecords
.filter(isRecharge)
.reduce((sum, record) => sum + Number(record.credits ?? 0), 0);
const failed = filteredRecords.filter((record) => !isSuccess(record)).length;
const categoryTotals = new Map<string, number>();
for (let i = 29; i >= 0; i--) { filteredRecords.filter((record) => !isRecharge(record)).forEach((record) => {
const date = new Date(baseDate); const meta = getMeta(record.operation);
date.setDate(baseDate.getDate() - i); categoryTotals.set(meta.category, (categoryTotals.get(meta.category) ?? 0) + Number(record.credits ?? 0));
});
const month = date.getMonth() + 1;
const day = date.getDate();
const dateStr = `${month}/${day}`;
// Deterministic generation based on index to ensure uniqueness const topCategory = [...categoryTotals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "暂无";
const baseConsumption = 50000 + (i * 1234) % 30000; return { consumed, recharged, failed, topCategory };
const variance = ((i * 7) % 10000) - 5000; }, [filteredRecords]);
const actual = baseConsumption + variance; const dailyData = useMemo(() => {
const predicted = baseConsumption + ((i * 3) % 5000) - 2500; const grouped = new Map<string, { day: string; consumed: number; recharged: number }>();
filteredRecords.forEach((record) => {
const key = dayKey(record.createdAt);
const item = grouped.get(key) ?? { day: key, consumed: 0, recharged: 0 };
if (isRecharge(record)) {
item.recharged += Number(record.credits ?? 0);
} else {
item.consumed += Number(record.credits ?? 0);
}
grouped.set(key, item);
});
return [...grouped.values()].sort((a, b) => a.day.localeCompare(b.day));
}, [filteredRecords]);
data.push({ const categoryData = useMemo(() => {
date: dateStr, const grouped = new Map<string, { name: string; value: number; color: string }>();
actual, filteredRecords.filter((record) => !isRecharge(record)).forEach((record) => {
predicted, const meta = getMeta(record.operation);
const item = grouped.get(meta.category) ?? { name: meta.category, value: 0, color: meta.color };
item.value += Number(record.credits ?? 0);
grouped.set(meta.category, item);
}); });
} return [...grouped.values()].sort((a, b) => b.value - a.value);
}, [filteredRecords]);
return data; const operationOptions = useMemo(() => {
}, []); const operations = new Set(records.map((record) => record.operation).filter(Boolean));
return [...operations].sort();
}, [records]);
const remainingCredits = balance?.balance ?? 0; const remainingCredits = Number(balance?.balance ?? 0);
const usedCredits = balance?.totalSpent ?? 0; const usedCredits = Number(balance?.totalSpent ?? 0);
const totalRecharged = balance?.totalRecharged ?? 0; const totalRecharged = Number(balance?.totalRecharged ?? 0);
const selectedPackageInfo = PACKAGES.find((pkg) => pkg.id === selectedPackage) ?? PACKAGES[2];
return ( return (
<> <>
<div className="h-full overflow-auto bg-background p-6"> <div className="h-full overflow-auto bg-background p-6">
<div className="max-w-7xl mx-auto"> <div className="mx-auto max-w-7xl">
{/* Header */} <div className="mb-6 flex items-center justify-between gap-4">
<div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-2xl font-semibold text-foreground mb-1">用量统计</h1> <h1 className="mb-1 text-2xl font-semibold text-foreground">用量与积分明细</h1>
<p className="text-sm text-muted-foreground">查看和管理积分使用情况</p> <p className="text-sm text-muted-foreground">查看余额、充值、模型调用和每次积分变动。</p>
</div> </div>
<button <button
onClick={() => { setShowPurchaseModal(true); setPurchased(false); }} onClick={() => {
className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2" setShowPurchaseModal(true);
setPurchased(false);
}}
className="flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm text-primary-foreground transition-colors hover:bg-primary/90"
> >
<Sparkles className="w-4 h-4" /> <PackagePlus className="h-4 w-4" />
购买积分资源包 购买积分
</button> </button>
</div> </div>
{/* Overview Cards */} <div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="grid grid-cols-3 gap-4 mb-6"> <StatCard
<div className="rounded-xl border border-border bg-card p-5"> label="当前余额"
<div className="flex items-center gap-3 mb-3"> value={money(remainingCredits)}
<div className="w-10 h-10 rounded-lg bg-accent flex items-center justify-center"> caption={`累计充值 ${money(totalRecharged)} 积分`}
<Sparkles className="w-5 h-5 text-primary" /> icon={WalletCards}
</div> tone="bg-primary/10 text-primary"
<div className="text-xs text-muted-foreground">剩余积分</div> />
</div> <StatCard
<div className="text-2xl font-semibold text-foreground mb-1"> label="累计消费"
{remainingCredits.toLocaleString()} value={money(usedCredits)}
</div> caption={`共 ${totalRecords} 条账单记录`}
<div className="text-xs text-muted-foreground"> icon={TrendingDown}
累计充值 {Number(totalRecharged).toLocaleString()} 积分 tone="bg-red-50 text-red-600"
</div> />
<StatCard
label="本页消费"
value={money(pageStats.consumed)}
caption={`主要消耗:${pageStats.topCategory}`}
icon={CircleDollarSign}
tone="bg-amber-50 text-amber-600"
/>
<StatCard
label="本页充值 / 失败"
value={`${money(pageStats.recharged)} / ${pageStats.failed}`}
caption={`第 ${page + 1} 页,共 ${pageCount} 页`}
icon={AlertCircle}
tone="bg-slate-100 text-slate-600"
/>
</div> </div>
<div className="rounded-xl border border-border bg-card p-5"> <div className="mb-6 rounded-xl border border-border bg-card p-4">
<div className="flex items-center gap-3 mb-3"> <div className="mb-4 flex items-center gap-2">
<div className="w-10 h-10 rounded-lg bg-red-100 flex items-center justify-center"> <Filter className="h-4 w-4 text-muted-foreground" />
<TrendingUp className="w-5 h-5 text-red-600" /> <span className="text-sm font-medium text-foreground">筛选当前页记录</span>
</div> <span className="text-xs text-muted-foreground">后端分页已加载 {records.length}</span>
<div className="text-xs text-muted-foreground">已消耗</div>
</div>
<div className="text-2xl font-semibold text-foreground mb-1">
{Number(usedCredits).toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">
{totalRecords} 条记录
</div>
</div> </div>
<div className="rounded-xl border border-border bg-card p-5"> <div className="mb-4 flex flex-wrap gap-2">
<div className="flex items-center gap-3 mb-3"> {[
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center"> ["all", "全部"],
<Users className="w-5 h-5 text-blue-600" /> ["consume", "只看消费"],
</div> ["recharge", "只看充值"],
<div className="text-xs text-muted-foreground">本页记录数</div> ["failed", "失败记录"],
</div> ].map(([value, label]) => (
<div className="text-2xl font-semibold text-foreground mb-1"> <button
{records.length} key={value}
</div> onClick={() => setQuickFilter(value as QuickFilter)}
<div className="text-xs text-muted-foreground"> className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
{page + 1} 页 / 共 {Math.max(1, Math.ceil(totalRecords / PAGE_SIZE))} quickFilter === value
</div> ? "border-primary bg-primary text-primary-foreground"
</div> : "border-border bg-background text-muted-foreground hover:text-foreground"
}`}
>
{label}
</button>
))}
</div> </div>
{/* Filters */} <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="mb-6 p-4 rounded-xl border border-border bg-card"> <label className="block">
<div className="flex items-center gap-2 mb-3"> <span className="mb-2 block text-xs text-muted-foreground">开始日期</span>
<Filter className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">筛选条件</span>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs text-muted-foreground mb-2">开始日期</label>
<div className="relative"> <div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Calendar className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
type="date" type="date"
value={dateRange.start} value={dateRange.start}
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })} onChange={(event) => setDateRange({ ...dateRange, start: event.target.value })}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/20"
/> />
</div> </div>
</div> </label>
<div> <label className="block">
<label className="block text-xs text-muted-foreground mb-2">结束日期</label> <span className="mb-2 block text-xs text-muted-foreground">结束日期</span>
<div className="relative"> <div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Calendar className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
type="date" type="date"
value={dateRange.end} value={dateRange.end}
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })} onChange={(event) => setDateRange({ ...dateRange, end: event.target.value })}
className="w-full pl-9 pr-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/20"
/> />
</div> </div>
</div> </label>
<div> <label className="block">
<label className="block text-xs text-muted-foreground mb-2">操作类型</label> <span className="mb-2 block text-xs text-muted-foreground">操作类型</span>
<select <select
value={selectedType} value={selectedType}
onChange={(e) => { setSelectedType(e.target.value); setPage(0); }} onChange={(event) => {
className="w-full px-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 cursor-pointer" setSelectedType(event.target.value);
setPage(0);
}}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/20"
> >
<option value="">全部类型</option> <option value="">全部操作</option>
{Object.entries(opLabels).map(([k, v]) => ( {operationOptions.map((operation) => (
<option key={k} value={k}>{v}</option> <option key={operation} value={operation}>
{getMeta(operation).label}
</option>
))} ))}
</select> </select>
</div> </label>
</div> </div>
</div> </div>
{/* Credits Consumption Chart */} <div className="mb-6 grid grid-cols-1 gap-4 xl:grid-cols-[1.4fr_0.8fr]">
<div className="mb-6 p-6 rounded-xl border border-border bg-card"> <div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-center justify-between mb-6"> <div className="mb-5">
<div> <h2 className="font-medium text-foreground">本页积分走势</h2>
<h2 className="font-medium text-foreground mb-1">积分消耗趋势</h2> <p className="mt-1 text-xs text-muted-foreground">根据当前筛选后的真实账单记录聚合。</p>
<p className="text-xs text-muted-foreground">每日积分消耗对比(最近30天)</p>
</div>
</div> </div>
<ResponsiveContainer width="100%" height={320}> <div className="h-72">
<LineChart {dailyData.length === 0 ? (
data={dailyConsumptionData} <EmptyState />
margin={{ top: 5, right: 30, left: 20, bottom: 5 }} ) : (
> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid key="grid" strokeDasharray="3 3" stroke="#e5e7eb" /> <BarChart data={dailyData} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<XAxis <CartesianGrid stroke="#e5e7eb" strokeDasharray="3 3" />
key="xaxis" <XAxis dataKey="day" tick={{ fill: "#6b7280", fontSize: 12 }} stroke="#9ca3af" />
dataKey="date" <YAxis tick={{ fill: "#6b7280", fontSize: 12 }} stroke="#9ca3af" tickFormatter={(v) => `${v}`} />
tick={{ fill: "#6b7280", fontSize: 12 }}
stroke="#9ca3af"
/>
<YAxis
key="yaxis"
tick={{ fill: "#6b7280", fontSize: 12 }}
stroke="#9ca3af"
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
/>
<Tooltip <Tooltip
key="tooltip" contentStyle={{ border: "1px solid #e5e7eb", borderRadius: 8, fontSize: 12 }}
contentStyle={{ formatter={(value: number, name: string) => [
backgroundColor: "#ffffff", `${value.toLocaleString()} 积分`,
border: "1px solid #e5e7eb", name === "consumed" ? "消费" : "充值",
borderRadius: "8px", ]}
fontSize: "12px",
}}
formatter={(value: number) => [value.toLocaleString(), ""]}
labelStyle={{ color: "#111827", fontWeight: 500 }}
/>
<Legend
key="legend"
wrapperStyle={{ fontSize: "12px" }}
iconType="line"
/> />
<Line <Bar dataKey="consumed" name="消费" fill="#ef4444" radius={[4, 4, 0, 0]} />
key="actual-line" <Bar dataKey="recharged" name="充值" fill="#22c55e" radius={[4, 4, 0, 0]} />
type="monotone" </BarChart>
dataKey="actual" </ResponsiveContainer>
stroke="#5b5ff9" )}
strokeWidth={2} </div>
name="实际消耗" </div>
dot={{ fill: "#5b5ff9", r: 3 }}
activeDot={{ r: 5 }} <div className="rounded-xl border border-border bg-card p-5">
isAnimationActive={false} <div className="mb-5">
/> <h2 className="font-medium text-foreground">消耗结构</h2>
<Line <p className="mt-1 text-xs text-muted-foreground">按剧本、图片、视频等类型汇总。</p>
key="predicted-line" </div>
type="monotone" <div className="h-72">
dataKey="predicted" {categoryData.length === 0 ? (
stroke="#94a3b8" <EmptyState />
strokeWidth={2} ) : (
strokeDasharray="5 5" <ResponsiveContainer width="100%" height="100%">
name="预计消耗" <PieChart>
dot={{ fill: "#94a3b8", r: 3 }} <Pie data={categoryData} dataKey="value" nameKey="name" innerRadius={54} outerRadius={88} paddingAngle={3}>
activeDot={{ r: 5 }} {categoryData.map((entry) => (
isAnimationActive={false} <Cell key={entry.name} fill={entry.color} />
/> ))}
</LineChart> </Pie>
<Tooltip formatter={(value: number, name: string) => [`${value.toLocaleString()} 积分`, name]} />
</PieChart>
</ResponsiveContainer> </ResponsiveContainer>
)}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{categoryData.slice(0, 4).map((item) => (
<div key={item.name} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: item.color }} />
<span>{item.name}</span>
<span className="ml-auto text-foreground">{money(item.value)}</span>
</div>
))}
</div>
</div>
</div> </div>
{/* Usage Table */} <div className="overflow-hidden rounded-xl border border-border bg-card">
<div className="rounded-xl border border-border bg-card overflow-hidden"> <div className="flex items-center justify-between border-b border-border p-4">
<div className="p-4 border-b border-border"> <div>
<h2 className="font-medium text-foreground">消耗明细</h2> <h2 className="font-medium text-foreground">账单明细</h2>
<p className="mt-1 text-xs text-muted-foreground">消费为负数,充值为正数;失败记录通常为 0 积分。</p>
</div>
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full min-w-[920px]">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
<tr> <tr>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">操作类型</th> <th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">操作</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">项目ID</th> <th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">模型 / 类型</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">消耗积分</th> <th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">项目 / 引用</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">模式</th> <th className="px-4 py-3 text-right text-xs font-medium text-muted-foreground">积分变动</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">状态</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">时间</th> <th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground">时间</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{filteredRecords.length === 0 && ( {filteredRecords.length === 0 && (
<tr> <tr>
<td colSpan={5} className="px-4 py-8 text-center text-sm text-muted-foreground">暂无数据</td> <td colSpan={6}>
<EmptyState />
</td>
</tr> </tr>
)} )}
{filteredRecords.map((record) => { {filteredRecords.map((record) => {
const Icon = opIcons[record.operation] ?? Film; const meta = getMeta(record.operation);
const isCharge = record.operation !== "recharge"; const Icon = meta.icon;
const recharge = isRecharge(record);
const success = isSuccess(record);
const modelText = record.modelName || record.modelId || record.modelProvider || record.modality || "-";
return ( return (
<tr key={record.id} className="hover:bg-muted/30 transition-colors"> <tr key={record.id} className="transition-colors hover:bg-muted/30">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<Icon className="w-4 h-4 text-muted-foreground" /> <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
<span className="text-sm text-foreground">{opLabels[record.operation] ?? record.operation}</span> <Icon className="h-4 w-4" style={{ color: meta.color }} />
</div>
<div>
<div className="text-sm font-medium text-foreground">{meta.label}</div>
<div className="text-xs text-muted-foreground">{meta.category}</div>
</div>
</div> </div>
</td> </td>
<td className="px-4 py-3 text-sm text-muted-foreground">{record.projectId ?? "—"}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`text-sm font-medium ${isCharge ? "text-destructive" : "text-green-600"}`}> <div className="text-sm text-foreground">{modelText}</div>
{isCharge ? "-" : "+"}{Number(record.credits).toLocaleString()} <div className="text-xs text-muted-foreground">
{record.unitCount ? `${record.unitCount} 单元` : record.billingMode || "-"}
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm text-foreground">{record.projectName || (record.projectId ? `项目 ${record.projectId}` : "-")}</div>
<div className="max-w-[220px] truncate text-xs text-muted-foreground">引用 {record.refId ?? "-"}</div>
</td>
<td className="px-4 py-3 text-right">
<span className={`text-sm font-semibold ${recharge ? "text-emerald-600" : "text-red-600"}`}>
{recharge ? "+" : "-"}
{money(record.credits)}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs ${
success ? "bg-emerald-50 text-emerald-700" : "bg-red-50 text-red-700"
}`}
>
{success ? <Check className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{success ? "成功" : "失败"}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-xs text-muted-foreground">{record.billingMode}</td> <td className="px-4 py-3 text-sm text-muted-foreground">{formatDateTime(record.createdAt)}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{record.createdAt?.replace("T", " ").slice(0, 16)}</td>
</tr> </tr>
); );
})} })}
...@@ -340,82 +480,83 @@ export function UsageManagement() { ...@@ -340,82 +480,83 @@ export function UsageManagement() {
</table> </table>
</div> </div>
{totalRecords > PAGE_SIZE && ( <div className="flex items-center justify-between border-t border-border p-4">
<div className="flex items-center justify-center gap-3 p-4 border-t border-border"> <div className="text-xs text-muted-foreground">
当前显示 {filteredRecords.length} 条,全部账单 {totalRecords}
</div>
<div className="flex items-center gap-2">
<button <button
disabled={page === 0} disabled={page === 0}
onClick={() => setPage(p => p - 1)} onClick={() => setPage((value) => Math.max(0, value - 1))}
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm text-foreground hover:bg-muted disabled:opacity-40 transition-colors" className="inline-flex items-center gap-1 rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-40"
> >
<ChevronLeft className="h-4 w-4" />
上一页 上一页
</button> </button>
<span className="text-sm text-muted-foreground"> <span className="px-2 text-sm text-muted-foreground">
{page + 1} / {Math.ceil(totalRecords / PAGE_SIZE)} {page + 1} / {pageCount}
</span> </span>
<button <button
disabled={(page + 1) * PAGE_SIZE >= totalRecords} disabled={(page + 1) * PAGE_SIZE >= totalRecords}
onClick={() => setPage(p => p + 1)} onClick={() => setPage((value) => value + 1)}
className="px-3 py-1.5 rounded-lg border border-border bg-card text-sm text-foreground hover:bg-muted disabled:opacity-40 transition-colors" className="inline-flex items-center gap-1 rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-40"
> >
下一页 下一页
<ChevronRight className="h-4 w-4" />
</button> </button>
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>
</div>
{/* Purchase Modal */}
{showPurchaseModal && ( {showPurchaseModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-6"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-6 backdrop-blur-sm">
<div className="bg-card rounded-2xl border border-border p-6 w-full max-w-2xl"> <div className="w-full max-w-2xl rounded-2xl border border-border bg-card p-6">
<div className="flex items-center justify-between mb-6"> <div className="mb-6 flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-semibold text-foreground">购买积分资源包</h2> <h2 className="text-xl font-semibold text-foreground">购买积分资源包</h2>
<p className="text-sm text-muted-foreground mt-0.5">积分到账后立即可用,永久有效</p> <p className="mt-1 text-sm text-muted-foreground">演示环境会直接到账,线上版本可接支付或运营后台充值。</p>
</div> </div>
<button onClick={() => setShowPurchaseModal(false)} className="p-2 hover:bg-muted rounded-lg transition-colors"> <button onClick={() => setShowPurchaseModal(false)} className="rounded-lg p-2 transition-colors hover:bg-muted">
<X className="w-5 h-5 text-muted-foreground" /> <X className="h-5 w-5 text-muted-foreground" />
</button> </button>
</div> </div>
{purchased ? ( {purchased ? (
<div className="py-12 text-center"> <div className="py-12 text-center">
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
<Check className="w-8 h-8 text-green-600" /> <Check className="h-8 w-8 text-emerald-600" />
</div> </div>
<h3 className="text-lg font-semibold text-foreground mb-2">购买成功!</h3> <h3 className="mb-2 text-lg font-semibold text-foreground">购买成功</h3>
<p className="text-sm text-muted-foreground mb-6"> <p className="mb-6 text-sm text-muted-foreground">{selectedPackageInfo.credits.toLocaleString()} 积分已到账。</p>
{PACKAGES.find(p => p.id === selectedPackage)?.credits.toLocaleString()} 积分已到账
</p>
<button <button
onClick={() => setShowPurchaseModal(false)} onClick={() => setShowPurchaseModal(false)}
className="px-6 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white text-sm" className="rounded-lg bg-primary px-6 py-2.5 text-sm text-primary-foreground transition-colors hover:bg-primary/90"
> >
完成 完成
</button> </button>
</div> </div>
) : ( ) : (
<> <>
<div className="grid grid-cols-2 gap-3 mb-6"> <div className="mb-6 grid grid-cols-1 gap-3 sm:grid-cols-2">
{PACKAGES.map((pkg) => ( {PACKAGES.map((pkg) => (
<button <button
key={pkg.id} key={pkg.id}
onClick={() => setSelectedPackage(pkg.id)} onClick={() => setSelectedPackage(pkg.id)}
className={`rounded-xl border-2 p-4 text-left transition-all ${ className={`rounded-xl border-2 p-4 text-left transition-all ${
selectedPackage === pkg.id selectedPackage === pkg.id
? "border-primary bg-accent" ? "border-primary bg-primary/5"
: "border-border bg-background hover:border-primary/40" : "border-border bg-background hover:border-primary/40"
}`} }`}
> >
<div className="flex items-center justify-between mb-2"> <div className="mb-2 flex items-center justify-between">
<span className="font-semibold text-foreground">{pkg.label}</span> <span className="font-semibold text-foreground">{pkg.label}</span>
{pkg.id === "pro" && ( {pkg.id === "pro" && (
<span className="px-2 py-0.5 rounded-full bg-primary text-white text-xs">推荐</span> <span className="rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">推荐</span>
)} )}
</div> </div>
<div className="text-2xl font-bold text-primary mb-1"> <div className="mb-1 text-2xl font-bold text-primary">
{pkg.credits.toLocaleString()} <span className="text-sm font-normal text-muted-foreground">积分</span> {pkg.credits.toLocaleString()} <span className="text-sm font-normal text-muted-foreground">积分</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
...@@ -426,21 +567,21 @@ export function UsageManagement() { ...@@ -426,21 +567,21 @@ export function UsageManagement() {
))} ))}
</div> </div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-muted mb-6"> <div className="mb-6 flex items-center gap-3 rounded-xl bg-muted p-4">
<Sparkles className="w-5 h-5 text-primary flex-shrink-0" /> <Sparkles className="h-5 w-5 flex-shrink-0 text-primary" />
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
当前余额:<span className="font-semibold text-foreground"> 当前余额:
{Number(balance?.balance ?? 0).toLocaleString()} <span className="font-semibold text-foreground">{money(remainingCredits)}</span>
</span> 积分 &nbsp;·&nbsp; 购买后变为:<span className="font-semibold text-foreground"> {" 积分,购买后变为 "}
{(Number(balance?.balance ?? 0) + (PACKAGES.find(p => p.id === selectedPackage)?.credits ?? 0)).toLocaleString()} <span className="font-semibold text-foreground">{money(remainingCredits + selectedPackageInfo.credits)}</span>
</span> 积分 {" 积分"}
</div> </div>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={() => setShowPurchaseModal(false)} onClick={() => setShowPurchaseModal(false)}
className="flex-1 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors text-sm" className="flex-1 rounded-lg border border-border bg-card py-2.5 text-sm text-foreground transition-colors hover:bg-muted"
> >
取消 取消
</button> </button>
...@@ -450,10 +591,10 @@ export function UsageManagement() { ...@@ -450,10 +591,10 @@ export function UsageManagement() {
setPurchased(true); setPurchased(true);
}} }}
disabled={purchase.isPending} disabled={purchase.isPending}
className="flex-1 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 text-sm disabled:opacity-70" className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-primary py-2.5 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-70"
> >
{purchase.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />} {purchase.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <PackagePlus className="h-4 w-4" />}
确认购买 {PACKAGES.find(p => p.id === selectedPackage)?.price} 确认购买 {selectedPackageInfo.price}
</button> </button>
</div> </div>
</> </>
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { aiApi } from "../lib/api/ai"; import { aiApi } from "../lib/api/ai";
import type { Character, Scene, Storyboard } from "../lib/api/ai"; import type { Character, Scene, Storyboard, StructuredVideoRequest } from "../lib/api/ai";
// ---- Characters ---- // ---- Characters ----
const charactersKey = (pid: string) => ["characters", pid]; const charactersKey = (pid: string) => ["characters", pid];
...@@ -193,6 +193,14 @@ export function useGenerateVideo(projectId: string) { ...@@ -193,6 +193,14 @@ export function useGenerateVideo(projectId: string) {
}); });
} }
export function useGenerateStructuredVideo(projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: StructuredVideoRequest) => aiApi.generateStructuredVideo(projectId, req),
onSuccess: () => qc.invalidateQueries({ queryKey: videoTasksKey(projectId) }),
});
}
// ---- Assembly ---- // ---- Assembly ----
export function useAssemblyTask(projectId: string, episodeId: string) { export function useAssemblyTask(projectId: string, episodeId: string) {
return useQuery({ return useQuery({
......
...@@ -3,6 +3,7 @@ import { usageApi } from "../lib/api/usage"; ...@@ -3,6 +3,7 @@ import { usageApi } from "../lib/api/usage";
const balanceKey = () => ["usage-balance"]; const balanceKey = () => ["usage-balance"];
const recordsKey = (limit: number, offset: number) => ["usage-records", limit, offset]; const recordsKey = (limit: number, offset: number) => ["usage-records", limit, offset];
const costsKey = () => ["usage-costs"];
export function useBalance() { export function useBalance() {
return useQuery({ return useQuery({
...@@ -20,6 +21,23 @@ export function useRecords(limit = 20, offset = 0) { ...@@ -20,6 +21,23 @@ export function useRecords(limit = 20, offset = 0) {
}); });
} }
export function useCosts() {
return useQuery({
queryKey: costsKey(),
queryFn: () => usageApi.getCosts(),
staleTime: 5 * 60_000,
});
}
export function useOperationCost(operation: string): number | null {
const { data } = useCosts();
if (!data) return null;
const raw = data[operation];
if (raw == null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
export function usePurchase() { export function usePurchase() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
......
...@@ -90,6 +90,43 @@ export interface AiTask { ...@@ -90,6 +90,43 @@ export interface AiTask {
errorMessage: string | null; errorMessage: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
// 结构化视频任务字段(task_type=structured_video 时非空)
prompt?: string | null;
characterImageKey?: string | null;
characterImageKeys?: string[] | null;
sceneImageKey?: string | null;
propImageKeys?: string[] | null;
styleImageKey?: string | null;
userPrompt?: Record<string, string> | null;
videoDuration?: number | null;
videoRatio?: string | null;
generateAudio?: boolean | null;
}
export interface StructuredVideoRequest {
episodeId?: string | null;
storyboardId?: string | null;
/** 旧版单角色字段(characterImageKeys 非空时可省略) */
characterImageKey?: string | null;
/** V16:多角色出镜(@图1..@图N,按顺序) */
characterImageKeys?: string[] | null;
sceneImageKey: string;
propImageKeys?: string[] | null;
styleImageKey?: string | null;
/** V17:单段 freeform 提示词(与 userPrompt 二选一,优先使用) */
freeformPrompt?: string | null;
userPrompt?: {
characterAction?: string | null;
sceneEvent?: string | null;
cameraMovement?: string | null;
lightingAtmosphere?: string | null;
videoStyle?: string | null;
} | null;
duration?: number | null;
ratio?: string | null;
/** Seedance 模型 ID(如 doubao-seedance-2-0-fast-260128 / doubao-seedance-2-0-260128),缺省由后端 ArkProperties 兜底 */
model?: string | null;
generateAudio?: boolean | null;
} }
export interface AssemblyTask { export interface AssemblyTask {
...@@ -154,7 +191,11 @@ export const aiApi = { ...@@ -154,7 +191,11 @@ export const aiApi = {
// ---- Characters ---- // ---- Characters ----
extractCharacters: async (projectId: string): Promise<Character[]> => { extractCharacters: async (projectId: string): Promise<Character[]> => {
const r = await apiClient.post(`/projects/${projectId}/characters/extract`); const r = await apiClient.post(
`/projects/${projectId}/characters/extract`,
undefined,
{ timeout: 180_000 }
);
return r.data.data; return r.data.data;
}, },
listCharacters: async (projectId: string): Promise<Character[]> => { listCharacters: async (projectId: string): Promise<Character[]> => {
...@@ -194,7 +235,11 @@ export const aiApi = { ...@@ -194,7 +235,11 @@ export const aiApi = {
// ---- Scenes ---- // ---- Scenes ----
extractScenes: async (projectId: string): Promise<Scene[]> => { extractScenes: async (projectId: string): Promise<Scene[]> => {
const r = await apiClient.post(`/projects/${projectId}/scenes/extract`); const r = await apiClient.post(
`/projects/${projectId}/scenes/extract`,
undefined,
{ timeout: 180_000 }
);
return r.data.data; return r.data.data;
}, },
listScenes: async (projectId: string): Promise<Scene[]> => { listScenes: async (projectId: string): Promise<Scene[]> => {
...@@ -243,6 +288,16 @@ export const aiApi = { ...@@ -243,6 +288,16 @@ export const aiApi = {
}); });
return r.data.data; return r.data.data;
}, },
generateStructuredVideo: async (
projectId: string,
req: StructuredVideoRequest
): Promise<AiTask> => {
const r = await apiClient.post(
`/projects/${projectId}/video-tasks/generate-structured`,
req
);
return r.data.data;
},
deleteVideoTask: async (projectId: string, taskId: string): Promise<void> => { deleteVideoTask: async (projectId: string, taskId: string): Promise<void> => {
await apiClient.delete(`/projects/${projectId}/video-tasks/${taskId}`); await apiClient.delete(`/projects/${projectId}/video-tasks/${taskId}`);
}, },
......
...@@ -4,7 +4,7 @@ const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api"; ...@@ -4,7 +4,7 @@ const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: BASE_URL, baseURL: BASE_URL,
timeout: 30_000, timeout: 180_000,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
......
...@@ -10,7 +10,15 @@ export interface Balance { ...@@ -10,7 +10,15 @@ export interface Balance {
export interface BillingRecord { export interface BillingRecord {
id: number; id: number;
projectId: number | null; projectId: number | null;
projectName?: string | null;
operation: string; operation: string;
modality?: string | null;
modelProvider?: string | null;
modelId?: string | null;
modelName?: string | null;
unitCount?: number | null;
success?: boolean | null;
billingMeta?: string | null;
credits: string; credits: string;
billingMode: string; billingMode: string;
refId: string | null; refId: string | null;
...@@ -35,4 +43,8 @@ export const usageApi = { ...@@ -35,4 +43,8 @@ export const usageApi = {
const r = await apiClient.post("/usage/purchase", { packageId }); const r = await apiClient.post("/usage/purchase", { packageId });
return r.data.data; return r.data.data;
}, },
getCosts: async (): Promise<Record<string, string>> => {
const r = await apiClient.get("/usage/costs");
return r.data.data ?? {};
},
}; };
...@@ -44,6 +44,7 @@ services: ...@@ -44,6 +44,7 @@ services:
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod} SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod}
SERVER_PORT: 8080 SERVER_PORT: 8080
BILLING_MODE: ${BILLING_MODE:-STRICT}
DB_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-yaoai_comic}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8 DB_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-yaoai_comic}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
DB_USERNAME: ${MYSQL_USER:-yaoai} DB_USERNAME: ${MYSQL_USER:-yaoai}
DB_PASSWORD: ${MYSQL_PASSWORD:-yaoai123} DB_PASSWORD: ${MYSQL_PASSWORD:-yaoai123}
...@@ -68,6 +69,42 @@ services: ...@@ -68,6 +69,42 @@ services:
- "${BACKEND_PORT:-8080}:8080" - "${BACKEND_PORT:-8080}:8080"
restart: unless-stopped restart: unless-stopped
admin-server:
build:
context: ./yaoai-comic-studio
dockerfile: Dockerfile
environment:
TZ: ${TZ:-Asia/Shanghai}
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod}
SERVER_PORT: 8081
SPRING_FLYWAY_ENABLED: "false"
BILLING_MODE: ${BILLING_MODE:-STRICT}
DB_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-yaoai_comic}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8
DB_USERNAME: ${MYSQL_USER:-yaoai}
DB_PASSWORD: ${MYSQL_PASSWORD:-yaoai123}
REDIS_HOST: redis
REDIS_PORT: 6379
VOLCENGINE_ARK_API_KEY: ${VOLCENGINE_ARK_API_KEY:-}
VOLCENGINE_ARK_BASE_URL: ${VOLCENGINE_ARK_BASE_URL:-https://ark.cn-beijing.volces.com/api/v3}
VOLCENGINE_ARK_TEXT_MODEL: ${VOLCENGINE_ARK_TEXT_MODEL:-doubao-seed-2-0-code-preview-260215}
VOLCENGINE_ARK_IMAGE_MODEL: ${VOLCENGINE_ARK_IMAGE_MODEL:-doubao-seedream-5-0-260128}
VOLCENGINE_ARK_VIDEO_MODEL: ${VOLCENGINE_ARK_VIDEO_MODEL:-doubao-seedance-2-0-fast-260128}
VOLCENGINE_TOS_ACCESS_KEY: ${VOLCENGINE_TOS_ACCESS_KEY:-}
VOLCENGINE_TOS_SECRET_KEY: ${VOLCENGINE_TOS_SECRET_KEY:-}
VOLCENGINE_TOS_ENDPOINT: ${VOLCENGINE_TOS_ENDPOINT:-https://tos-cn-beijing.volces.com}
VOLCENGINE_TOS_REGION: ${VOLCENGINE_TOS_REGION:-cn-beijing}
VOLCENGINE_TOS_BUCKET: ${VOLCENGINE_TOS_BUCKET:-}
depends_on:
backend:
condition: service_started
mysql:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${ADMIN_SERVER_PORT:-8081}:8081"
restart: unless-stopped
frontend: frontend:
build: build:
context: ./doc/html context: ./doc/html
...@@ -80,6 +117,18 @@ services: ...@@ -80,6 +117,18 @@ services:
- "${FRONTEND_PORT:-3000}:80" - "${FRONTEND_PORT:-3000}:80"
restart: unless-stopped restart: unless-stopped
admin-web:
build:
context: ./yaoai-admin-web
dockerfile: Dockerfile
environment:
ADMIN_API_UPSTREAM: http://admin-server:8081
depends_on:
- admin-server
ports:
- "${ADMIN_WEB_PORT:-3001}:80"
restart: unless-stopped
volumes: volumes:
mysql-data: mysql-data:
redis-data: redis-data:
FROM docker.m.daocloud.io/library/node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM docker.m.daocloud.io/library/nginx:1.27-alpine
ENV ADMIN_API_UPSTREAM=http://admin-server:8081
COPY nginx.conf /etc/nginx/templates/default.conf.template
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YaoAI Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
server {
listen 80;
server_name _;
client_max_body_size 50M;
root /usr/share/nginx/html;
index index.html;
location /admin-api/ {
proxy_pass ${ADMIN_API_UPSTREAM}/admin-api/;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
{
"name": "yaoai-admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@vitejs/plugin-vue": "^5.2.4",
"axios": "^1.7.9",
"echarts": "^5.6.0",
"element-plus": "^2.9.3",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}
<script setup lang="ts">
import { ArrowRight, Fold, SwitchButton } from '@element-plus/icons-vue';
import { computed, ref } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
import { routes } from './router';
import { useAuthStore } from './stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const collapsed = ref(false);
const publicPage = computed(() => Boolean(route.meta.public));
const menuRoutes = computed(() =>
routes.filter((item) => !item.meta?.public && !item.meta?.hidden && auth.hasRole(item.meta?.authority as string[] | undefined)),
);
async function logout() {
await auth.logout();
router.push('/auth/login');
}
</script>
<template>
<RouterView v-if="publicPage" />
<div v-else class="admin-shell">
<aside class="side-rail" :class="{ collapsed }">
<div class="brand-mark">
<div class="brand-sigil">Y</div>
<div class="brand-copy">
<b>YaoAI</b>
<span>Operations</span>
</div>
</div>
<el-menu :default-active="route.path" router class="nav-menu" :collapse="collapsed">
<el-menu-item v-for="item in menuRoutes" :key="item.path" :index="item.path">
<el-icon><component :is="item.meta?.icon" /></el-icon>
<span>{{ item.meta?.title }}</span>
</el-menu-item>
</el-menu>
</aside>
<main class="workbench">
<header class="topbar">
<div class="crumb">
<el-button :icon="Fold" circle text @click="collapsed = !collapsed" />
<span>运营后台</span>
<el-icon><ArrowRight /></el-icon>
<strong>{{ route.meta.title }}</strong>
</div>
<div class="operator">
<span>{{ auth.user?.displayName || auth.user?.username }}</span>
<el-tag size="small" effect="plain">{{ auth.role }}</el-tag>
<el-button :icon="SwitchButton" text @click="logout">退出</el-button>
</div>
</header>
<section class="page-stage">
<RouterView />
</section>
</main>
</div>
</template>
import axios from 'axios';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '../stores/auth';
export interface ApiResult<T> {
success: boolean;
data: T;
error?: {
code: string;
message: string;
};
requestId?: string;
timestamp?: number;
}
export interface PageResult<T> {
records: T[];
total: number;
page: number;
size: number;
}
const client = axios.create({
baseURL: '/admin-api',
timeout: 20000,
});
client.interceptors.request.use((config) => {
const auth = useAuthStore();
if (auth.token) {
config.headers['satoken-admin'] = auth.token;
}
return config;
});
client.interceptors.response.use(
(response) => {
const body = response.data as ApiResult<unknown>;
if (body && body.success === false) {
ElMessage.error(body.error?.message || '请求失败');
return Promise.reject(new Error(body.error?.message || '请求失败'));
}
return body?.data ?? response.data;
},
(error) => {
if (error.response?.status === 401) {
const auth = useAuthStore();
auth.logoutLocal();
window.location.replace('/auth/login');
}
ElMessage.error(error.response?.data?.error?.message || error.message || '网络异常');
return Promise.reject(error);
},
);
export const http = {
get<T = any>(url: string, config?: any): Promise<T> {
return client.get(url, config) as unknown as Promise<T>;
},
post<T = any>(url: string, data?: any, config?: any): Promise<T> {
return client.post(url, data, config) as unknown as Promise<T>;
},
patch<T = any>(url: string, data?: any, config?: any): Promise<T> {
return client.patch(url, data, config) as unknown as Promise<T>;
},
put<T = any>(url: string, data?: any, config?: any): Promise<T> {
return client.put(url, data, config) as unknown as Promise<T>;
},
};
export interface AdminUser {
id: string;
username: string;
displayName?: string;
email?: string;
role: 'SUPER_ADMIN' | 'OPERATOR' | 'FINANCE' | 'READONLY';
status: number;
mustChangePassword?: number;
lastLoginAt?: string;
}
export interface Tenant {
id: string;
name: string;
slug: string;
plan: string;
status: number;
notes?: string;
createdAt?: string;
suspendedAt?: string;
suspendedReason?: string;
}
export interface BillingRecord {
id: string;
tenantId: string;
userId?: string;
projectId?: string;
operation: string;
modality?: string;
modelProvider?: string;
modelId?: string;
modelName?: string;
unitCount?: number;
success?: boolean;
credits: number;
refId?: string;
createdAt?: string;
}
export interface Plan {
id: string;
code: string;
name: string;
priceMonthly: number;
monthlyCredits: number;
maxMembers: number;
maxProjects: number;
maxConcurrentTasks: number;
isActive: number;
features?: string;
}
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import { createPinia } from 'pinia';
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import './styles.css';
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount('#app');
import {
Coin,
DataAnalysis,
Document,
House,
OfficeBuilding,
Operation,
PriceTag,
Tickets,
UserFilled,
} from '@element-plus/icons-vue';
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from './stores/auth';
export const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/dashboard' },
{
path: '/auth/login',
component: () => import('./views/LoginView.vue'),
meta: { public: true },
},
{
path: '/dashboard',
component: () => import('./views/DashboardView.vue'),
meta: { title: '仪表盘', icon: DataAnalysis },
},
{
path: '/tenants',
component: () => import('./views/TenantListView.vue'),
meta: { title: '租户管理', icon: OfficeBuilding },
},
{
path: '/tenants/:id',
component: () => import('./views/TenantDetailView.vue'),
meta: { title: '租户详情', hidden: true, icon: House },
},
{
path: '/users',
component: () => import('./views/UserManagementView.vue'),
meta: { title: '用户管理', icon: UserFilled },
},
{
path: '/billing/records',
component: () => import('./views/BillingRecordsView.vue'),
meta: { title: '计费记录', icon: Coin },
},
{
path: '/billing/costs',
component: () => import('./views/BillingCostsView.vue'),
meta: { title: '计费价格', icon: PriceTag, authority: ['SUPER_ADMIN'] },
},
{
path: '/plans',
component: () => import('./views/PlanManagementView.vue'),
meta: { title: '套餐管理', icon: PriceTag, authority: ['SUPER_ADMIN'] },
},
{
path: '/admin-users',
component: () => import('./views/AdminUserManagementView.vue'),
meta: { title: '运营人员', icon: Operation, authority: ['SUPER_ADMIN'] },
},
{
path: '/audit-logs',
component: () => import('./views/AuditLogsView.vue'),
meta: { title: '审计日志', icon: Tickets },
},
{
path: '/:pathMatch(.*)*',
component: () => import('./views/NotFoundView.vue'),
meta: { title: '未找到', hidden: true, icon: Document },
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.public) return true;
if (!auth.isLoggedIn) return '/auth/login';
const authority = to.meta.authority as string[] | undefined;
if (!auth.hasRole(authority)) return '/dashboard';
return true;
});
export default router;
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { http } from '../api/http';
import type { AdminUser } from '../api/types';
const TOKEN_KEY = 'yaoai.admin.token';
const USER_KEY = 'yaoai.admin.user';
export const useAuthStore = defineStore('auth', () => {
const token = ref(sessionStorage.getItem(TOKEN_KEY) || '');
const user = ref<AdminUser | null>(JSON.parse(sessionStorage.getItem(USER_KEY) || 'null'));
const role = computed(() => user.value?.role || 'READONLY');
const isLoggedIn = computed(() => Boolean(token.value));
async function login(username: string, password: string) {
const result = await http.post<string>('/auth/login', { username, password });
token.value = result;
sessionStorage.setItem(TOKEN_KEY, token.value);
await fetchMe();
}
async function fetchMe() {
const me = await http.get<AdminUser>('/auth/me');
user.value = me;
sessionStorage.setItem(USER_KEY, JSON.stringify(user.value));
}
async function logout() {
try {
await http.post('/auth/logout');
} finally {
logoutLocal();
}
}
function logoutLocal() {
token.value = '';
user.value = null;
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(USER_KEY);
}
function hasRole(allowed?: string[]) {
return !allowed || allowed.length === 0 || allowed.includes(role.value);
}
return { token, user, role, isLoggedIn, login, fetchMe, logout, logoutLocal, hasRole };
});
:root {
--ink: #17211b;
--muted: #66756c;
--paper: #f6f4ef;
--panel: #fffdf8;
--line: #ddd8cc;
--accent: #1f7a5a;
--accent-2: #b45f24;
--danger: #b33f37;
--shadow: 0 18px 40px rgba(46, 39, 28, 0.09);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
linear-gradient(90deg, rgba(31, 122, 90, 0.04) 1px, transparent 1px),
linear-gradient(rgba(31, 122, 90, 0.035) 1px, transparent 1px),
var(--paper);
background-size: 28px 28px;
color: var(--ink);
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
}
.admin-shell {
display: grid;
grid-template-columns: auto 1fr;
min-height: 100vh;
}
.side-rail {
width: 248px;
background: #18231d;
color: #eef4ec;
border-right: 1px solid rgba(255, 255, 255, 0.08);
transition: width 0.2s ease;
}
.side-rail.collapsed {
width: 72px;
}
.brand-mark {
height: 72px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand-sigil {
width: 38px;
height: 38px;
display: grid;
place-items: center;
background: #d7a94b;
color: #18231d;
border-radius: 6px;
font-weight: 800;
}
.brand-copy {
display: grid;
line-height: 1.1;
}
.brand-copy span {
color: #9fb2a7;
font-size: 12px;
}
.collapsed .brand-copy {
display: none;
}
.nav-menu {
border: 0;
background: transparent;
}
.nav-menu .el-menu-item {
color: #cbd8d0;
}
.nav-menu .el-menu-item.is-active {
color: #f7f3e9;
background: rgba(31, 122, 90, 0.32);
}
.workbench {
min-width: 0;
}
.topbar {
height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
background: rgba(255, 253, 248, 0.86);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(14px);
}
.crumb,
.operator {
display: flex;
align-items: center;
gap: 10px;
}
.crumb span {
color: var(--muted);
}
.page-stage {
padding: 26px;
}
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
}
.page-head h1 {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: 30px;
letter-spacing: 0;
}
.page-head p {
margin: 6px 0 0;
color: var(--muted);
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 14px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(6, minmax(140px, 1fr));
gap: 14px;
}
.metric {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
box-shadow: var(--shadow);
}
.metric span {
color: var(--muted);
font-size: 13px;
}
.metric strong {
display: block;
margin-top: 8px;
font-size: 24px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
box-shadow: var(--shadow);
}
.panel-grid {
display: grid;
grid-template-columns: 1.25fr 1fr;
gap: 16px;
margin-top: 16px;
}
.chart-box {
min-height: 320px;
}
.login-scene {
min-height: 100vh;
display: grid;
grid-template-columns: 1fr 420px;
background:
linear-gradient(135deg, rgba(31, 122, 90, 0.16), transparent 45%),
var(--paper);
}
.login-brief {
display: flex;
flex-direction: column;
justify-content: center;
padding: 8vw;
}
.login-brief h1 {
max-width: 760px;
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: 58px;
letter-spacing: 0;
}
.login-card {
margin: auto 42px;
padding: 28px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.status-active {
color: var(--accent);
}
.status-disabled {
color: var(--danger);
}
@media (max-width: 1100px) {
.metric-grid {
grid-template-columns: repeat(3, 1fr);
}
.panel-grid {
grid-template-columns: 1fr;
}
.login-scene {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.side-rail {
display: none;
}
.topbar {
padding: 0 14px;
}
.page-stage {
padding: 16px;
}
.metric-grid {
grid-template-columns: 1fr;
}
}
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { http, type PageResult } from '../api/http';
const query = reactive({ keyword: '', role: '', status: undefined as number | undefined, page: 1, size: 20 });
const page = ref<PageResult<any>>({ records: [], total: 0, page: 1, size: 20 });
const dialog = ref(false);
const form = reactive({ username: '', password: '', displayName: '', email: '', role: 'OPERATOR' });
async function load() {
page.value = await http.get('/admin-users', { params: query });
}
async function create() {
await http.post('/admin-users', form);
dialog.value = false;
await load();
}
async function update(row: any) {
await http.patch(`/admin-users/${row.id}`, row);
await load();
}
async function reset(row: any) {
const res = await http.post<any>(`/admin-users/${row.id}/reset-password`);
ElMessage.success(`新密码:${res.password}`);
}
onMounted(load);
</script>
<template>
<div>
<div class="page-head">
<div><h1>运营人员</h1><p>平台后台账号与角色</p></div>
<el-button type="primary" @click="dialog = true">新建账号</el-button>
</div>
<div class="toolbar">
<el-input v-model="query.keyword" placeholder="账号 / 姓名 / 邮箱" style="width: 260px" />
<el-select v-model="query.role" placeholder="角色" clearable style="width: 180px">
<el-option label="SUPER_ADMIN" value="SUPER_ADMIN" /><el-option label="OPERATOR" value="OPERATOR" /><el-option label="FINANCE" value="FINANCE" /><el-option label="READONLY" value="READONLY" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
</div>
<div class="panel">
<el-table :data="page.records">
<el-table-column prop="username" label="账号" />
<el-table-column prop="displayName" label="姓名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column label="角色"><template #default="{ row }"><el-select v-model="row.role" @change="update(row)"><el-option label="SUPER_ADMIN" value="SUPER_ADMIN" /><el-option label="OPERATOR" value="OPERATOR" /><el-option label="FINANCE" value="FINANCE" /><el-option label="READONLY" value="READONLY" /></el-select></template></el-table-column>
<el-table-column prop="status" label="状态" width="90" />
<el-table-column label="操作" width="120"><template #default="{ row }"><el-button text @click="reset(row)">重置</el-button></template></el-table-column>
</el-table>
</div>
<el-dialog v-model="dialog" title="新建运营账号" width="460px">
<el-form label-position="top">
<el-form-item label="账号"><el-input v-model="form.username" /></el-form-item>
<el-form-item label="初始密码"><el-input v-model="form.password" show-password /></el-form-item>
<el-form-item label="姓名"><el-input v-model="form.displayName" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="角色"><el-select v-model="form.role"><el-option label="OPERATOR" value="OPERATOR" /><el-option label="FINANCE" value="FINANCE" /><el-option label="READONLY" value="READONLY" /><el-option label="SUPER_ADMIN" value="SUPER_ADMIN" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="create">创建</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { http, type PageResult } from '../api/http';
const query = reactive({ adminId: '', action: '', resourceType: '', page: 1, size: 20 });
const page = ref<PageResult<any>>({ records: [], total: 0, page: 1, size: 20 });
const selected = ref<any>(null);
async function load() {
page.value = await http.get('/audit-logs', { params: query });
}
onMounted(load);
</script>
<template>
<div>
<div class="page-head"><div><h1>审计日志</h1><p>运营操作轨迹与失败记录</p></div></div>
<div class="toolbar">
<el-input v-model="query.adminId" placeholder="Admin ID" style="width: 160px" />
<el-input v-model="query.action" placeholder="Action" style="width: 220px" />
<el-input v-model="query.resourceType" placeholder="Resource" style="width: 180px" />
<el-button type="primary" @click="load">查询</el-button>
</div>
<div class="panel">
<el-table :data="page.records">
<el-table-column prop="createdAt" label="时间" width="180" />
<el-table-column prop="adminUsername" label="操作人" width="140" />
<el-table-column prop="action" label="动作" min-width="190" />
<el-table-column prop="resourceType" label="资源" width="120" />
<el-table-column prop="resourceId" label="资源 ID" width="120" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column label="详情" width="90"><template #default="{ row }"><el-button text type="primary" @click="selected = row">查看</el-button></template></el-table-column>
</el-table>
</div>
<el-drawer v-model="selected" title="审计详情" size="48%">
<pre>{{ JSON.stringify(selected, null, 2) }}</pre>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref } from 'vue';
import { http } from '../api/http';
interface BillingCostItem {
operation: string;
label: string;
cost: number;
}
const loading = ref(false);
const saving = ref(false);
const rows = ref<BillingCostItem[]>([]);
const costs = computed(() =>
Object.fromEntries(rows.value.map((item) => [item.operation, Number(item.cost || 0)])),
);
async function load() {
loading.value = true;
try {
rows.value = await http.get<BillingCostItem[]>('/billing/costs');
} finally {
loading.value = false;
}
}
async function save() {
const invalid = rows.value.find((item) => item.cost == null || Number(item.cost) < 0);
if (invalid) {
ElMessage.warning(`${invalid.label || invalid.operation} 的价格不能小于 0`);
return;
}
saving.value = true;
try {
rows.value = await http.put<BillingCostItem[]>('/billing/costs', { costs: costs.value });
ElMessage.success('计费价格已保存,新请求将按新价格扣费');
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div>
<div class="page-head">
<div>
<h1>计费价格</h1>
<p>配置客户端生成、提取、合成等操作的积分单价。保存后立即影响新的计费请求。</p>
</div>
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
</div>
<div class="panel">
<el-alert
class="mb-4"
type="info"
show-icon
:closable="false"
title="这里只修改运行时计费价格,不会改历史账单。余额预检和实际扣费都会读取同一份价格。"
/>
<el-table v-loading="loading" :data="rows" border>
<el-table-column prop="label" label="操作" min-width="160" />
<el-table-column prop="operation" label="计费 Key" min-width="220" />
<el-table-column label="积分单价" width="220">
<template #default="{ row }">
<el-input-number v-model="row.cost" :min="0" :precision="2" :step="1" controls-position="right" />
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { http, type PageResult } from '../api/http';
const query = reactive({ modality: '', modelId: '', tenantId: '', userId: '', page: 1, size: 20 });
const page = ref<PageResult<any>>({ records: [], total: 0, page: 1, size: 20 });
const summary = ref<any[]>([]);
async function load() {
page.value = await http.get('/billing/records', { params: query });
summary.value = await http.get('/billing/summary', { params: { ...query, dim: 'model' } });
}
onMounted(load);
</script>
<template>
<div>
<div class="page-head"><div><h1>计费记录</h1><p>按租户、用户、模型与模态过滤</p></div></div>
<div class="toolbar">
<el-input v-model="query.tenantId" placeholder="租户 ID" style="width: 160px" />
<el-input v-model="query.userId" placeholder="用户 ID" style="width: 160px" />
<el-select v-model="query.modality" placeholder="模态" clearable style="width: 140px">
<el-option label="TEXT" value="TEXT" /><el-option label="IMAGE" value="IMAGE" /><el-option label="VIDEO" value="VIDEO" /><el-option label="RECHARGE" value="RECHARGE" />
</el-select>
<el-input v-model="query.modelId" placeholder="模型 ID" style="width: 220px" />
<el-button type="primary" @click="load">查询</el-button>
</div>
<div class="panel-grid">
<div class="panel">
<el-table :data="page.records">
<el-table-column prop="createdAt" label="时间" width="180" />
<el-table-column prop="tenantId" label="租户" width="110" />
<el-table-column prop="userId" label="用户" width="110" />
<el-table-column prop="modality" label="模态" width="100" />
<el-table-column prop="modelName" label="模型" min-width="170" />
<el-table-column prop="credits" label="积分" width="100" />
<el-table-column prop="success" label="成功" width="90" />
</el-table>
</div>
<div class="panel">
<el-table :data="summary"><el-table-column prop="label" label="模型" /><el-table-column prop="totalCredits" label="积分" /><el-table-column prop="count" label="次数" /></el-table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts';
import { onMounted, ref } from 'vue';
import { http } from '../api/http';
const overview = ref<Record<string, number>>({});
const trendEl = ref<HTMLDivElement>();
const providerEl = ref<HTMLDivElement>();
async function load() {
overview.value = await http.get('/dashboard/overview');
const trends = (await http.get('/dashboard/trends?days=30')) as any[];
const providers = (await http.get('/dashboard/providers')) as any[];
echarts.init(trendEl.value!).setOption({
tooltip: { trigger: 'axis' },
legend: { bottom: 0 },
grid: { left: 42, right: 20, top: 28, bottom: 42 },
xAxis: { type: 'category', data: trends.map((item) => item.day) },
yAxis: { type: 'value' },
series: [
{ name: '消费', type: 'bar', stack: 'credits', data: trends.map((item) => item.consumedCredits) },
{ name: '充值', type: 'line', data: trends.map((item) => item.rechargedCredits) },
],
});
echarts.init(providerEl.value!).setOption({
tooltip: { trigger: 'item' },
series: [
{
type: 'pie',
radius: ['42%', '72%'],
data: providers.map((item) => ({ name: item.modelName || item.modelId, value: item.credits })),
},
],
});
}
onMounted(load);
</script>
<template>
<div>
<div class="page-head">
<div>
<h1>仪表盘</h1>
<p>全平台租户、积分与模型调用概览</p>
</div>
</div>
<div class="metric-grid">
<div class="metric"><span>总租户</span><strong>{{ overview.totalTenants || 0 }}</strong></div>
<div class="metric"><span>活跃租户</span><strong>{{ overview.activeTenants || 0 }}</strong></div>
<div class="metric"><span>总用户</span><strong>{{ overview.totalUsers || 0 }}</strong></div>
<div class="metric"><span>今日新增</span><strong>{{ overview.todayNewTenants || 0 }}</strong></div>
<div class="metric"><span>今日消费</span><strong>{{ overview.todayConsumedCredits || 0 }}</strong></div>
<div class="metric"><span>今日充值</span><strong>{{ overview.todayRechargedCredits || 0 }}</strong></div>
</div>
<div class="panel-grid">
<div class="panel"><div ref="trendEl" class="chart-box" /></div>
<div class="panel"><div ref="providerEl" class="chart-box" /></div>
</div>
</div>
</template>
<script setup lang="ts">
import { Lock, User } from '@element-plus/icons-vue';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
const router = useRouter();
const auth = useAuthStore();
const loading = ref(false);
const form = ref({ username: 'admin', password: '' });
async function submit() {
loading.value = true;
try {
await auth.login(form.value.username, form.value.password);
router.push('/dashboard');
} finally {
loading.value = false;
}
}
</script>
<template>
<main class="login-scene">
<section class="login-brief">
<h1>Platform operations, measured without guesswork.</h1>
<p>集中管理租户、积分、模型成本和审计轨迹。</p>
</section>
<section class="login-card">
<h2>运营后台</h2>
<el-form label-position="top" @submit.prevent="submit">
<el-form-item label="账号">
<el-input v-model="form.username" :prefix-icon="User" size="large" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" :prefix-icon="Lock" show-password size="large" @keyup.enter="submit" />
</el-form-item>
<el-button type="primary" size="large" :loading="loading" style="width: 100%" @click="submit">登录</el-button>
</el-form>
</section>
</main>
</template>
<template>
<div class="panel">
<div class="page-head">
<div>
<h1>页面不存在</h1>
<p>请从左侧菜单重新进入。</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { http } from '../api/http';
const plans = ref<any[]>([]);
async function load() {
plans.value = await http.get('/plans');
}
async function save(row: any) {
await http.patch(`/plans/${row.id}`, row);
await load();
}
onMounted(load);
</script>
<template>
<div>
<div class="page-head"><div><h1>套餐管理</h1><p>月度积分、成员与任务上限</p></div></div>
<div class="panel">
<el-table :data="plans">
<el-table-column prop="code" label="编码" width="130" />
<el-table-column label="名称"><template #default="{ row }"><el-input v-model="row.name" /></template></el-table-column>
<el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.priceMonthly" :min="0" /></template></el-table-column>
<el-table-column label="月积分"><template #default="{ row }"><el-input-number v-model="row.monthlyCredits" :min="0" /></template></el-table-column>
<el-table-column label="成员"><template #default="{ row }"><el-input-number v-model="row.maxMembers" :min="0" /></template></el-table-column>
<el-table-column label="项目"><template #default="{ row }"><el-input-number v-model="row.maxProjects" :min="0" /></template></el-table-column>
<el-table-column label="并发"><template #default="{ row }"><el-input-number v-model="row.maxConcurrentTasks" :min="0" /></template></el-table-column>
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button type="primary" text @click="save(row)">保存</el-button></template></el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
import * as echarts from 'echarts';
import { ElMessage } from 'element-plus';
import { onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { http } from '../api/http';
const route = useRoute();
const id = route.params.id as string;
const detail = ref<any>({});
const balance = ref<any>({});
const members = ref<any[]>([]);
const projects = ref<any[]>([]);
const usage = ref<any>({ breakdown: [] });
const usageChart = ref<HTMLDivElement>();
const recharge = reactive({ amount: 0, note: '', externalRefId: '' });
const quota = reactive({ maxMembers: undefined, maxProjects: undefined, maxMonthlyCredits: undefined, maxConcurrentTasks: undefined, notes: '' } as any);
async function load() {
detail.value = await http.get(`/tenants/${id}`);
balance.value = await http.get(`/tenants/${id}/balance`);
members.value = await http.get(`/tenants/${id}/members`);
projects.value = await http.get(`/tenants/${id}/projects`);
quota.maxMembers = detail.value.quota?.maxMembers;
quota.maxProjects = detail.value.quota?.maxProjects;
quota.maxMonthlyCredits = detail.value.quota?.maxMonthlyCredits;
quota.maxConcurrentTasks = detail.value.quota?.maxConcurrentTasks;
quota.notes = detail.value.quota?.notes;
await loadUsage('model');
}
async function loadUsage(groupBy: string) {
usage.value = await http.get(`/tenants/${id}/usage-detail`, { params: { groupBy } });
if (usageChart.value) {
echarts.init(usageChart.value).setOption({
tooltip: { trigger: 'item' },
series: [{ type: 'pie', radius: ['40%', '70%'], data: usage.value.breakdown.map((x: any) => ({ name: x.label, value: x.totalCredits })) }],
});
}
}
async function rechargeTenant() {
await http.post(`/tenants/${id}/recharge`, recharge);
await load();
}
async function updateQuota() {
await http.put(`/tenants/${id}/quota`, quota);
await load();
}
async function suspendTenant() {
await http.post(`/tenants/${id}/suspend`, { reason: '运营后台暂停' });
await load();
}
async function resumeTenant() {
await http.post(`/tenants/${id}/resume`);
await load();
}
async function impersonate() {
const res = await http.post<any>(`/tenants/${id}/impersonate`, {});
ElMessage.success(`已生成客户端 token:${res.token}`);
}
onMounted(load);
</script>
<template>
<div>
<div class="page-head">
<div><h1>{{ detail.tenant?.name || '租户详情' }}</h1><p>{{ detail.tenant?.slug }}</p></div>
<div>
<el-button type="warning" plain @click="suspendTenant">暂停</el-button>
<el-button type="success" plain @click="resumeTenant">恢复</el-button>
<el-button type="primary" @click="impersonate">模拟登录</el-button>
</div>
</div>
<el-tabs class="panel">
<el-tab-pane label="基本信息">
<el-descriptions :column="3" border>
<el-descriptions-item label="ID">{{ detail.tenant?.id }}</el-descriptions-item>
<el-descriptions-item label="套餐">{{ detail.tenant?.plan }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ detail.tenant?.status === 1 ? '正常' : '暂停' }}</el-descriptions-item>
<el-descriptions-item label="成员">{{ detail.memberCount }}</el-descriptions-item>
<el-descriptions-item label="项目">{{ detail.projectCount }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detail.tenant?.notes }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<el-tab-pane label="余额概览">
<div class="metric-grid" style="grid-template-columns: repeat(3, 1fr)">
<div class="metric"><span>余额</span><strong>{{ balance.balance?.balance || 0 }}</strong></div>
<div class="metric"><span>累计充值</span><strong>{{ balance.balance?.totalRecharged || 0 }}</strong></div>
<div class="metric"><span>累计消费</span><strong>{{ balance.balance?.totalSpent || 0 }}</strong></div>
</div>
<div class="toolbar" style="margin-top: 18px">
<el-input-number v-model="recharge.amount" :min="0" />
<el-input v-model="recharge.note" placeholder="备注" style="width: 220px" />
<el-button type="primary" @click="rechargeTenant">充值</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="成员列表">
<el-table :data="members">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="role" label="角色" />
<el-table-column prop="status" label="状态" />
</el-table>
</el-tab-pane>
<el-tab-pane label="项目列表">
<el-table :data="projects">
<el-table-column prop="name" label="项目" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="createdAt" label="创建时间" />
</el-table>
</el-tab-pane>
<el-tab-pane label="积分明细">
<div class="toolbar">
<el-button @click="loadUsage('model')">按模型</el-button>
<el-button @click="loadUsage('modality')">按模态</el-button>
<el-button @click="loadUsage('user')">按用户</el-button>
<el-button @click="loadUsage('day')">按天</el-button>
</div>
<div class="panel-grid">
<div ref="usageChart" class="chart-box" />
<el-table :data="usage.breakdown">
<el-table-column prop="label" label="维度" />
<el-table-column prop="totalCredits" label="积分" />
<el-table-column prop="count" label="次数" />
<el-table-column prop="percent" label="占比" />
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="配额">
<div class="toolbar">
<el-input-number v-model="quota.maxMembers" placeholder="成员" />
<el-input-number v-model="quota.maxProjects" placeholder="项目" />
<el-input-number v-model="quota.maxMonthlyCredits" placeholder="月积分" />
<el-input-number v-model="quota.maxConcurrentTasks" placeholder="并发任务" />
<el-button type="primary" @click="updateQuota">保存</el-button>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { http, type PageResult } from '../api/http';
const router = useRouter();
const loading = ref(false);
const query = reactive({ keyword: '', plan: '', status: undefined as number | undefined, page: 1, size: 20 });
const page = ref<PageResult<any>>({ records: [], total: 0, page: 1, size: 20 });
async function load() {
loading.value = true;
try {
page.value = await http.get('/tenants', { params: query });
} finally {
loading.value = false;
}
}
onMounted(load);
</script>
<template>
<div>
<div class="page-head">
<div><h1>租户管理</h1><p>跨租户查询、套餐与状态操作</p></div>
</div>
<div class="toolbar">
<el-input v-model="query.keyword" :prefix-icon="Search" placeholder="名称 / slug" style="width: 240px" clearable />
<el-select v-model="query.plan" placeholder="套餐" clearable style="width: 160px">
<el-option label="Free" value="free" />
<el-option label="Pro" value="pro" />
<el-option label="Team" value="team" />
<el-option label="Enterprise" value="enterprise" />
</el-select>
<el-select v-model="query.status" placeholder="状态" clearable style="width: 140px">
<el-option label="正常" :value="1" />
<el-option label="暂停" :value="0" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
</div>
<div class="panel">
<el-table :data="page.records" v-loading="loading" row-key="id">
<el-table-column prop="name" label="租户" min-width="180" />
<el-table-column prop="slug" label="Slug" min-width="140" />
<el-table-column prop="plan" label="套餐" width="120" />
<el-table-column prop="balance" label="余额" width="120" />
<el-table-column prop="memberCount" label="成员" width="90" />
<el-table-column prop="projectCount" label="项目" width="90" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="plain">{{ row.status === 1 ? '正常' : '暂停' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }"><el-button text type="primary" @click="router.push(`/tenants/${row.id}`)">详情</el-button></template>
</el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" layout="total, prev, pager, next" :total="page.total" @current-change="load" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { http, type PageResult } from '../api/http';
const query = reactive({ keyword: '', tenantId: '', status: undefined as number | undefined, page: 1, size: 20 });
const page = ref<PageResult<any>>({ records: [], total: 0, page: 1, size: 20 });
async function load() {
page.value = await http.get('/users', { params: query });
}
async function setStatus(row: any, status: number) {
await http.patch(`/users/${row.id}/status`, { status });
await load();
}
async function kickout(row: any) {
await http.post(`/users/${row.id}/kickout`);
}
async function resetPassword(row: any) {
const res = await http.post<any>(`/users/${row.id}/reset-password`);
ElMessage.success(`新密码:${res.password}`);
}
onMounted(load);
</script>
<template>
<div>
<div class="page-head"><div><h1>用户管理</h1><p>跨租户账号状态、下线与密码重置</p></div></div>
<div class="toolbar">
<el-input v-model="query.keyword" placeholder="邮箱 / 用户名 / 手机" style="width: 260px" />
<el-input v-model="query.tenantId" placeholder="租户 ID" style="width: 180px" />
<el-button type="primary" @click="load">查询</el-button>
</div>
<div class="panel">
<el-table :data="page.records">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="tenantId" label="租户" />
<el-table-column prop="role" label="角色" />
<el-table-column prop="status" label="状态" />
<el-table-column label="操作" width="240">
<template #default="{ row }">
<el-button text type="primary" @click="setStatus(row, row.status === 1 ? 0 : 1)">{{ row.status === 1 ? '禁用' : '启用' }}</el-button>
<el-button text @click="kickout(row)">下线</el-button>
<el-button text @click="resetPassword(row)">重置</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"resolveJsonModule": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
{"root":["./src/env.d.ts","./src/main.ts","./src/router.ts","./src/api/http.ts","./src/api/types.ts","./src/stores/auth.ts","./src/app.vue","./src/views/adminusermanagementview.vue","./src/views/auditlogsview.vue","./src/views/billingcostsview.vue","./src/views/billingrecordsview.vue","./src/views/dashboardview.vue","./src/views/loginview.vue","./src/views/notfoundview.vue","./src/views/planmanagementview.vue","./src/views/tenantdetailview.vue","./src/views/tenantlistview.vue","./src/views/usermanagementview.vue"],"version":"5.9.3"}
\ No newline at end of file
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [vue()],
server: {
port: 5174,
proxy: {
'/admin-api': {
target: process.env.VITE_API_TARGET || 'http://localhost:8080',
changeOrigin: true,
},
},
},
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment