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 留给后续实时协作(多人同时编辑分镜)。
This diff is collapsed.
This diff is collapsed.
# 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` 枚举
This diff is collapsed.
This diff is collapsed.
# 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)
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
---
---
今天完成(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
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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