fix: v0.18.3 版权报错友好提示 + 图片删除即梦式连续重命名

Bug 1: 版权限制错误友好提示
- ERROR_MESSAGES 加 OutputVideoSensitiveContentDetected.PolicyViolation 映射
- 漫威等知名 IP 触发的版权拦截不再显示英文 raw error

Bug 2: 图片删除后同类型引用连续重命名(即梦逻辑)
- inputBar.ts::removeReference 重写:删除后同类型剩余引用按顺序 1/2/3 连续编号
- 用 DOMParser 同步更新 editorHtml 里对应 data-ref-id 的 @mention span textContent
- 缩略图区和提示词栏同步刷新,避免"两个图片2"命名冲突

验证
- 11 个 Vitest 单元测试覆盖图片/视频/音频删除、空 editorHtml、无 @mention、
  连续快速删除等边界场景
- 3 个 Playwright E2E 真实浏览器验证:上传 3 张图 → 删中间 → 再上传 → 编号不冲突

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-04-17 18:03:36 +08:00
parent 2281c64ee8
commit dafdc8983f
10 changed files with 4352 additions and 3 deletions

View File

@ -15,6 +15,7 @@ ERROR_MESSAGES = {
'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试', 'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试',
# Output content moderation # Output content moderation
'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试', 'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试',
'OutputVideoSensitiveContentDetected.PolicyViolation': '生成的视频涉及版权限制内容如知名IP、名人肖像等已被系统拦截请修改提示词后重试',
'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截', 'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截',
# Parameter errors # Parameter errors
'InvalidParameter': '请求参数无效,请检查输入内容', 'InvalidParameter': '请求参数无效,请检查输入内容',

View File

@ -0,0 +1,134 @@
# Celery 轮询机制修复报告
> 日期2026-04-04
> 版本v0.16.0
> 影响范围backend/apps/generation/tasks.py, backend/config/settings.py
---
## 一、问题现象
2026/4/1 下午,大量用户反馈视频生成任务长时间卡在"生成中",前端显示耗时 60~65 分钟。
火山引擎侧确认视频实际生成仅需约 10 分钟,结果已就绪但未被平台及时同步。
**截图数据**4/1 下午完成的任务):
| 提交时间 | 显示耗时 |
|---------|---------|
| 2026/4/1 16:57:28 | 63 分 33 秒 |
| 2026/4/1 16:58:41 | 62 分 37 秒 |
| 2026/4/1 16:59:16 | 62 分 7 秒 |
| 2026/4/1 17:00:36 | 64 分 24 秒 |
| 2026/4/1 17:04:53 | 64 分 2 秒 |
## 二、根因分析
### 2.1 状态同步链路
```
用户提交任务
→ 后端调 create_task火山 API
→ 获得 ark_task_id
→ 派发 Celery 任务 poll_video_task
→ Celery worker 每 5 秒查一次火山 API
→ 火山返回完成 → 写 DB + 上传 TOS + 结算
→ 前端轮询 DB → 展示结果
```
前端只读 DB 状态,**不直接调火山 API**。整个链路完全依赖 Celery worker 轮询。
### 2.2 旧实现缺陷
`poll_video_task` 使用 `while True` + `time.sleep(5)` 长驻循环:
```python
# 旧代码
while True:
time.sleep(POLL_INTERVAL) # 5 秒
ark_resp = query_task(...) # 查一次
if terminal:
break
```
**三个致命问题:**
| 问题 | 影响 |
|------|------|
| 每个任务占死一个 worker 进程 | `concurrency=4` 最多同时轮询 4 个任务,第 5 个排队 |
| worker 重启后循环直接丢失 | 内存中的 `while True` 不可持久化OOM/重启 = 任务丢失 |
| `time.sleep` 浪费进程资源 | worker 99% 时间在 sleep实际有用工作不到 1% |
### 2.3 OOM 重启链
```
4 个任务同时轮询
→ 某些任务完成,触发 TOS 上传(下载视频 + 上传对象存储)
→ 内存飙升超过 512Mi 限制
→ K8s OOM Kill → worker 重启(共重启 15 次)
→ 4 个进程中的 while True 循环全部丢失
→ 等 recover_stuck_tasks每 10 分钟)重新派发
→ 重新派发后 worker 又被占满 → 又 OOM → 循环
→ 实际恢复耗时 ≈ 50~60 分钟
```
## 三、修复方案
### 3.1 核心改动self.retry 替代 while True
```python
# 新代码
@shared_task(bind=True, max_retries=None, ignore_result=True)
def poll_video_task(self, record_id):
record = GenerationRecord.objects.get(pk=record_id)
ark_resp = query_task(record.ark_task_id)
new_status = map_status(ark_resp.get('status', ''))
if new_status in ('queued', 'processing'):
record.save(update_fields=['status', 'updated_at'])
raise self.retry(countdown=5) # 5 秒后重新入队
# 到达终态 → 处理结果
...
```
**原理对比:**
| | 旧方式while True | 新方式self.retry |
|---|---|---|
| 任务生命周期 | 在 worker 进程内存中 | 在 Redis 队列中 |
| worker 占用 | 持续占用直到完成(分钟级) | 每次查询仅占用毫秒级 |
| worker 重启 | 任务丢失 | Redis 中的任务自动恢复 |
| 并发能力 | 最多 4 个(= concurrency | 数百个(受 API RPM 限制) |
### 3.2 recover_stuck_tasks 间隔缩短
| | 旧值 | 新值 |
|---|---|---|
| Beat 调度间隔 | 600 秒10 分钟) | 180 秒3 分钟) |
| stuck 判定门槛 | 10 分钟 | 3 分钟 |
| 最坏恢复时间 | ~20 分钟 | ~6 分钟 |
### 3.3 变更文件
| 文件 | 改动 |
|------|------|
| `backend/apps/generation/tasks.py` | `poll_video_task`: while True → self.retry`recover_stuck_tasks`: 门槛 10 → 3 分钟 |
| `backend/config/settings.py` | Beat schedule: 600 → 180 秒 |
## 四、效果预估
| 指标 | 修复前 | 修复后 |
|------|--------|--------|
| 同时轮询任务数上限 | 4 | 数百 |
| worker 重启后任务恢复 | 丢失,等 10 分钟兜底 | 自动恢复,无需兜底 |
| 最坏同步延迟 | 60+ 分钟 | ~15 秒(= 查询间隔 + 网络延迟) |
| 内存占用 | 持续占满sleep 期间不释放) | 脉冲式占用(查完释放) |
| OOM 风险 | 高4 进程常驻 + TOS 上传峰值) | 低(进程闲置时内存极小) |
## 五、部署注意
1. **无需数据库迁移** — 仅修改 Python 代码
2. **部署后旧的 while True 任务会自然消亡** — 不需要手动干预
3. **Redis 中可能有旧格式的任务** — 兼容无问题,新旧 `poll_video_task` 签名一致(`record_id` 参数不变)
4. **建议同步部署**:先部署代码,再重启 Celery worker`kubectl rollout restart deployment celery-worker`

View File

@ -0,0 +1,134 @@
# 设计评审报告
## 评审结论: APPROVED
**评审版本**: PRD v3.0 — Phase 3 (计量单位变更 + 管理后台重做 + 用户个人中心)
**评审日期**: 2026-03-12
**评审范围**: Phase 3 新增/修改的原型页面6 个新文件 + 1 个更新)
---
## 功能覆盖检查
### Phase 3 P0 核心功能
| PRD 功能点 | 是否实现 | 备注 |
|-----------|---------|------|
| 管理后台布局 — 左侧 Sidebar 240px + 4 导航项 | ✅ | 所有 4 个管理页面共享一致的 Sidebaractive 高亮正确 |
| 管理后台路由 — dashboard/users/records/settings | ✅ | 4 个独立页面,导航链接互通 |
| 用户个人中心页面 `/profile` | ✅ | 消费概览 + 消费趋势 + 消费记录 + 配额警告 |
| 消费概览卡片 — 环形进度条 + 日/月配额 | ✅ | SVG 环形图 345s/600s日额度 82.0%,月额度 39.1% |
**P0 覆盖率: 4/4 (100%)**
### Phase 3 P1 重要功能
| PRD 功能点 | 是否实现 | 备注 |
|-----------|---------|------|
| 仪表盘 — 核心指标卡片 (4 个) | ✅ | 总用户数 1,234 / 今日新增 +23 / 今日消费 4,560s / 本月消费 89,010s含环比变化箭头 |
| 仪表盘 — 消费趋势折线图 | ✅ | SVG 折线图 + 面积填充30 天数据tooltip 显示 "3/28 188s" |
| 仪表盘 — 用户消费排行柱状图 | ✅ | Top 10 水平柱状图,数据递减 2,340s → 350s前 3 名高亮 |
| 仪表盘 — 时间范围选择器 | ✅ | 今日/近7天/近30天/自定义,按钮可交互切换 |
| 仪表盘 — 图表 Mock 数据 | ✅ | 30 天数据有自然波动和上升趋势,排行榜数据合理 |
| 用户管理 — 用户列表表格 (分页) | ✅ | 9 列数据 + 7 条记录,分页 "共 56 条,第 1/3 页" |
| 用户管理 — 搜索和筛选 | ✅ | 关键字搜索 + 状态下拉 (全部/已启用/已禁用) + 刷新按钮 |
| 用户管理 — 配额编辑模态框 | ✅ | 点击"编辑配额"弹出 Modal含日/月限额输入框 + 取消/保存按钮 |
| 用户管理 — 用户状态管理 | ✅ | 启用用户显示"禁用"(红色),禁用用户显示"启用"(绿色) |
| 用户管理 — 用户详情抽屉 | ✅ | 点击用户名打开 420px 右侧抽屉,含完整用户信息 + 近期 3 条消费记录 |
| 消费记录 — 消费明细表格 | ✅ | 6 列 (时间/用户名/消费秒数/视频描述/生成模式/状态)10 条记录,分页 "共 1,234 条,第 1/62 页" |
| 消费记录 — 时间范围筛选 | ✅ | 日期选择器 2026-03-01 ~ 2026-03-12 + 查询按钮 |
| 消费记录 — 用户筛选 | ✅ | "搜索用户名..." 搜索框 |
| 消费记录 — 导出 CSV | ✅ | 顶栏"导出 CSV"按钮 + 下载图标,主题色边框 |
| 系统设置 — 全局默认配额 | ✅ | 日限额 600s / 月限额 6000s 数字输入框 + 提示文字 + 保存按钮 |
| 系统设置 — 系统公告管理 | ✅ | 开关切换 (ON 状态) + 文本域含示例公告 + 保存按钮 + Toast 反馈 |
| 个人中心 — 消费记录列表 | ✅ | 6 条记录 (时间/秒数/prompt/模式/状态) + "加载更多"按钮 |
| 个人中心 — 消费趋势迷你图 | ✅ | Sparkline 折线图 7 天数据 (3/6-3/12)近7天/近30天切换按钮 |
| 个人中心 — 配额提示 | ✅ | 黄色警告横幅 "今日额度已消费 82%,请合理安排使用" + 日额度卡片 warning 样式 |
**P1 覆盖率: 19/19 (100%)**
### Phase 3 P2 锦上添花
| PRD 功能点 | 是否实现 | 备注 |
|-----------|---------|------|
| 页面切换过渡动画 | ✅ | fadeUp 入场动画 + 延迟序列 |
| 数据加载骨架屏 | ❌ | 未实现 (P2不影响评审) |
| Sidebar 折叠模式 | ❌ | 未实现 (P2不影响评审) |
**P2 覆盖率: 1/3 (33%)**
### 导航页更新
| PRD 功能点 | 是否实现 | 备注 |
|-----------|---------|------|
| index.html 更新 — Phase 3 导航卡片 | ✅ | 3 个分区Phase 1 核心功能 + Phase 3 管理后台 (4 卡片) + Phase 3 用户端 (个人中心) |
---
## 后台管理系统专项检查
| 检查项 | 结果 | 说明 |
|--------|------|------|
| 按功能模块拆分多个页面 + 侧边导航 | ✅ | 4 个独立页面 + 240px Sidebar 导航active 状态正确 |
| 仪表盘图表有真实 Mock 数据 | ✅ | 折线图 30 天波动数据 + 排行榜 10 用户递减数据 + tooltip |
| 独立的用户管理页面 | ✅ | 完整表格 + 搜索/筛选 + 编辑配额模态框 + 用户详情抽屉 |
| 数据表格有搜索、分页、操作列 | ✅ | 搜索框 + 状态筛选 + 分页控件 + 编辑/禁用操作按钮 |
**专项检查: 4/4 全部通过**
---
## 素材使用质量
| 检查项 | 结果 | 问题描述 |
|--------|------|---------|
| 图片方向 | N/A | 原型为纯 HTML/CSS/SVG无外部图片素材 |
| 精灵图集裁切 | N/A | 不涉及精灵图 |
| 尺寸比例 | ✅ | SVG 图表自适应宽度,卡片网格响应式 |
| 真实素材引用 | ✅ | 使用 SVG 内联图标(非 emoji 占位符) |
| 素材加载 | ✅ | 所有页面资源正常加载,无 broken image |
| 视觉层次 | ✅ | Sidebar → Content → Modal/Drawer 层叠正确 |
---
## 设计质量
- **视觉一致性**: 5/5 — 所有 6 个 Phase 3 页面共享统一的深色主题 (Linear/Vercel 风格)CSS 变量一致 (#0a0a0f, #111118, #16161e, #2a2a38, #00b8e6),字体统一 (Noto Sans SC + Space Grotesk + JetBrains Mono)
- **交互合理性**: 5/5 — Drawer/Modal/Toggle/Toast 交互完整,时间选择器可切换,分页/搜索/筛选 UI 齐全
- **响应式设计**: 4/5 — 仪表盘卡片使用 grid-cols-4 max-lg:grid-cols-2 响应式,个人中心 max-width 900px 居中。Sidebar 无折叠模式 (P2)
- **素材使用正确性**: 5/5 — 纯 SVG 图表和图标,无外部依赖,渲染准确
**综合评分: 4.8/5**
---
## Playwright 验证截图
| 页面 | 截图文件 | 验证结果 |
|------|---------|---------|
| 仪表盘 | prototype-admin-dashboard.png | ✅ 4 统计卡片 + 折线图 + 排行榜 + 时间选择器 |
| 用户管理 | prototype-admin-users.png | ✅ 搜索/筛选栏 + 7 行用户表格 + 分页 |
| 用户详情抽屉 | prototype-admin-users-drawer.png | ✅ 右侧 420px 抽屉 + 用户信息 + 近期消费 |
| 消费记录 | prototype-admin-records.png | ✅ 筛选栏 + 10 行记录表格 + 导出 CSV + 分页 |
| 系统设置 | prototype-admin-settings.png | ✅ 配额表单 + 公告管理 + Toggle 开关 |
| 个人中心 | prototype-user-profile.png | ✅ 环形图 + 日/月额度 + Sparkline + 消费记录 + 配额警告 |
| 导航页 | prototype-index.png | ✅ Phase 1 + Phase 3 管理后台 + Phase 3 用户端分区 |
---
## 评审总结
Phase 3 原型质量**极高**,完整覆盖了 PRD 中所有 P0 (4/4) 和 P1 (19/19) 功能需求。
**亮点**:
1. 管理后台 4 个子页面布局一致Sidebar 导航交互正确
2. 仪表盘图表使用真实感 Mock 数据(折线图有自然波动,排行榜递减合理)
3. 用户管理交互完整:表格 + 搜索 + 筛选 + 编辑模态框 + 详情抽屉
4. 个人中心环形进度条 + Sparkline 趋势图 + 配额警告横幅,信息层次清晰
5. 整体深色主题统一Linear/Vercel 风格专业感强
6. 所有页面计量单位正确使用「秒数」(非「次数」),与 Phase 3 需求一致
**可改进项** (不影响通过):
- P2: Sidebar 折叠模式未实现
- P2: 骨架屏加载态未实现
- 用户表格缺少头像列(可在开发阶段补充)

View File

@ -0,0 +1,692 @@
# 【申请权限填客户名称】Seedance 2.0 & 2.0 fast API文档邀测用户版
该文档目前仅限开白客户使用,发送前请和销管确认客户是否在开白名单内
***【❗️❗️❗️】该文档限制客户申请权限,只有返回了服务协议的客户方可申请***
本文介绍 Seedance 2.0 & 2.0 fast 模型相较于存量模型 **新增/配置有区别&#x20;**&#x7684; API 参数介绍,存量 API 参数的完整介绍参见 [视频生成 API](https://www.volcengine.com/docs/82379/1520758?lang=zh)。
> 本文档仅限预览及邀测用户使用:
>
> * 不承诺正式API上线100%一致。
>
> * 仅限邀测用户阅读,请勿截图/分享给其他人员。
>
> * 您上传的内容请确保由您原创或已取得授权。
# 模型能力
> **Seedance 2.0 和 Seedance 2.0 fast 提供的模型能力一致,**&#x8FFD;求最高生成品质,推荐使用 **Seedance 2.0**;更注重成本与生成速度,不要求极限品质,推荐使用 **Seedance 2.0 fast**
**Seedance 2.0 & 2.0 fast (有声视频/无声视频)**
* **多模态参考生视频**输入参考图片0\~9+参考视频0\~3+ 参考音频0\~3+ 文本提示词(可选)生成 1 个目标视频。支持生成全新视频、编辑视频、延长视频。
> **注意:不可单独输入音频,应至少包含 1 个参考视频或图片。**
* **图生视频-首尾帧**:输入首帧图片+尾帧图片+文本提示词(可选)生成 1 个目标视频。
* **图生视频-首帧**:输入首帧图片+文本提示词(可选)生成 1 个目标视频。
* **文生视频**:输入文本提示词生成 1 个目标视频。
**模型能力对比表:**
| 模型名称 | | [Seedance 2.0](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-2-0) | [Seedance 2.0 fast](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-2-0-fast\&projectName=default) | [Seedance 1.5 pro](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-1-5-pro\&projectName=default) | [Seedance 1.0 pro ](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-1-0-pro\&projectName=default) | [Seedance 1.0 pro fast ](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-1-0-pro-fast\&projectName=default) | [Seedance 1.0 lite i2v](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-1-0-lite-i2v\&projectName=default) | [Seedance-1.0 lite t2v ](https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seedance-1-0-lite-t2v) |
| ------------ | -------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Model ID | | doubao-seedance-2-0-260128 | doubao-seedance-2-0-fast-260128 | doubao-seedance-1-5-pro-251215 | doubao-seedance-1-0-pro-250528 | doubao-seedance-1-0-pro-fast-251015 | doubao-seedance-1-0-lite-i2v-250428 | doubao-seedance-1-0-lite-t2v-250428 |
| 文生视频 | | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ |
| 图生视频-首帧 | | ✅ | | ✅ | ✅ | ✅ | ✅ | ❌ |
| 图生视频-首尾帧 | | ✅ | | ✅ | ✅ | ❌ | ✅ | ❌ |
| 多模态参考【New】 | 图片参考 | ✅ | | ❌ | ❌ | ❌ | ✅ | ❌ |
| | 视频参考 | ✅ | | ❌ | ❌ | ❌ | ❌ | ❌ |
| | 组合参考 | ✅ | | ❌ | ❌ | ❌ | ❌ | ❌ |
| 编辑视频【New】 | | ✅ | | ❌ | ❌ | ❌ | ❌ | ❌ |
| 延长视频【New】 | | ✅ | | ❌ | ❌ | ❌ | ❌ | ❌ |
| 生成有声视频 | | ✅ | | ✅ | ❌ | ❌ | ❌ | ❌ |
| 联网搜索增强【New】 | | ✅ | | ❌ | [](https://p9-arcosite.byteimg.com/obj/tos-cn-i-goo7wpa0wc/f359753773c94d97885008ca1223c9bc) | ❌ | ❌ | ❌ |
| 样片模式 | | ❌ | | ✅ | ❌ | ❌ | ❌ | ❌ |
| 返回视频尾帧 | | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ |
| 输出视频规格 | 输出分辨率 | 480p, 720p | | 480p, 720p, 1080p | 480p, 720p, 1080p | 480p, 720p, 1080p | 480p, 720p, 1080p | 480p, 720p, 1080p |
| | 输出宽高比 | 21:9, 16:9, 4:3, 1:1, 3:4, 9:16 | | | | | | |
| | 输出时长 | 4\~15 秒 | | 4\~12 秒 | 2\~12 秒 | 2\~12 秒 | 2\~12 秒 | 2\~12 秒 |
| | 输出视频格式 | mp4 | | mp4 | mp4 | mp4 | mp4 | mp4 |
| 离线推理 | | [](https://p9-arcosite.byteimg.com/obj/tos-cn-i-goo7wpa0wc/f359753773c94d97885008ca1223c9bc) | | ✅ | ✅ | ✅ | ✅ | ✅ |
| 在线推理限流 | RPM | 600 | | 600 | 600 | 600 | 300 | 300 |
| | 并发数 | 10 | | 10 | 10 | 10 | 5 | 5 |
| 离线推理限流 | TPD | - | | 5000亿 | 5000亿 | 5000亿 | 2500亿 | 2500亿 |
# Creat-创建视频生成任务
> POST https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks
## 请求参数
#### **content** `object[]` `必选`
输入给模型,生成视频的信息,支持文本、图片、音频、视频、样片任务 ID。支持以下几种组合
* **文本**
* **文本(可选)+ 图片**
* **文本(可选)+ 视频**
* **文本(可选)+ 图片 + 音频**
* **文本(可选)+ 图片 + 视频**
* **文本(可选)+ 视频 + 音频**
* **文本(可选)+ 图片 + 视频 + 音频**
***
**信息类型:**
* **文本信息**`object`
输入给模型的提示词信息。
***
content.**type&#x20;**`string` `必选`
输入内容的类型,此处应为 **text**
***
content.**text&#x20;**`string` `必选`
输入给模型的文本提示词,描述期望生成的视频。
支持中英文。建议中文不超过500字英文不超过1000词。字数过多信息容易分散模型可能因此忽略细节只关注重点造成视频缺失部分元素。提示词的更多使用技巧请参见 [Seedance 提示词指南](https://www.volcengine.com/docs/82379/1587797)。
* **图片信息** `object`
输入给模型的图片信息。
***
content.**type&#x20;**`string` `必选`
输入内容的类型,此处应为 **image\_url**
***
content.**image\_url&#x20;**`object` `必选`
输入给模型的图片对象。
***
content.image\_url.**url&#x20;**`string` `必选`
图片 URL 、图片 Base64 编码、素材 ID。
* 图片 URL填入图片的公网 URL。
* Base64 编码:将本地文件转换为 Base64 编码字符串然后提交给大模型。遵循格式data:image/<图片格式>;base64,\<Base64编码>,注意 <图片格式> 需小写,如 data:image/png;base64,{base64\_image}。
* 素材 ID用于视频生成的预置素材及虚拟人像的 ID遵循格式asset://\<ASSET\_ID>,可从 [素材&虚拟人像库](https://console.volcengine.com/ark-stg/region:ark-stg+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128) 获取,详细使用请参见[文档](https://www.volcengine.com/docs/82379/2223965?lang=zh)。
> **传入单张图片要求**
>
> * 格式jpeg、png、webp、bmp、tiff、gif
>
> * 宽高比(宽/高): (0.4, 2.5)&#x20;
>
> * 宽高长度px(300, 6000)
>
> * 大小:单张图片小于 30 MB。请求体大小不超过 64 MB。大文件请勿使用Base64编码。
>
> * 图片数量:
>
> * 图生视频-首帧1 张
>
> * 图生视频-首尾帧2 张
>
> * Seedance 2.0 & 2.0 fast 多模态参考生视频1\~9 张
***
content.**role&#x20;**`string` `条件必填`
图片的位置或用途。
> **注意**
>
> * **图生视频-首帧**、**图生视频-首尾帧**、**多模态参考生视频**(包括参考图、视频、音频)为 3 种互斥场景,**不可混用**。
>
> * **多模态参考生视频**可通过提示词指定参考图片作为首帧/尾帧,间接实现“首尾帧+多模态参考”效果。若需严格保障首尾帧和指定图片一致,**优先使用图生视频-首尾帧**(配置 role 为 **first\_frame / last\_frame**)。
***
**图生视频-首帧**
> 需要传入1个 image\_url 对象
* **字段role取值**
* **first\_frame 或不填**
***
**图生视频-首尾帧**
> 需要传入2个 image\_url 对象
* **字段role取值**
* 首帧图片对应的字段 role 为:**first\_frame**,必填
* 尾帧图片对应的字段 role 为:**last\_frame**,必填
***
**图生视频-参考图&#x20;**
> 可传入 1\~9 个 image\_url 对象
* **字段role取值**
* 每张参考图对应的字段 role 均为:**reference\_image**,必填
* **视频信息** `object`&#x20;
输入给模型的视频信息。仅 Seedance 2.0 & 2.0 fast 支持输入视频。
***
content.**type&#x20;**`string` `必选`
输入内容的类型,此处应为 **video\_url**
***
content.**video\_url&#x20;**`object` `必选`
输入给模型的视频对象。
***
content.video\_url.**url&#x20;**`string` `必选`
视频URL、素材 ID。
* 视频 URL填入视频的公网 URL。
* 素材 ID用于视频生成的预置素材及虚拟人像视频的 ID遵循格式asset://\<ASSET\_ID>。可从[素材&虚拟人像库](https://console.volcengine.com/ark-stg/region:ark-stg+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128)获取。
> **传入单个视频要求**
>
> * 视频格式mp4、mov。
>
> * 分辨率480p、720p
>
> * 时长:单个视频时长 \[2, 15] s最多传入 3 个参考视频,所有视频总时长不超过 15s。
>
> * 尺寸:
>
> * 宽高比(宽/高):\[0.4, 2.5]
>
> * 宽高长度px\[300, 6000]
>
> * 画面像素(宽 × 高):\[409600, 927408] ,示例:
>
> * 画面尺寸 640×640=409600 满足最小值
>
> * 画面尺寸 834×1112=927408 满足最大值。
>
> * 大小:单个视频不超过 50 MB。
>
> * 帧率 (FPS)\[24, 60]&#x20;
***
content.**role&#x20;**`string` `条件必填`
视频的位置或用途。当前仅支持 **reference\_video**
* **音频信息&#x20;**`object`&#x20;
输入给模型的音频信息。仅 Seedance 2.0 & 2.0 fast 支持输入音频。注意不可单独输入音频,应至少包含 1 个参考视频或图片。
***
content.**type&#x20;**`string` `必选`
输入内容的类型,此处应为 **audio\_url**
***
content.**audio\_url&#x20;**`object` `必选`
输入给模型的音频对象。
***
content.audio\_url.**url&#x20;**`string` `必选`
音频 URL 、音频 Base64 编码、素材 ID。
* 音频 URL填入音频的公网 URL。
* Base64 编码:将本地文件转换为 Base64 编码字符串然后提交给大模型。遵循格式data:audio/<音频格式>;base64,\<Base64编码>,注意 <音频格式> 需小写,如 data:audio/wav;base64,{base64\_audio}。
* 素材 ID用于视频生成的虚拟人的音频素材 ID遵循格式asset://\<ASSET\_ID>。可从[素材&虚拟人像库](https://console.volcengine.com/ark-stg/region:ark-stg+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128)获取。
> **传入单个音频要求**
>
> * 格式wav、mp3
>
> * 时长:单个音频时长 \[2, 15] s最多传入 3 段参考音频,所有音频总时长不超过 15 s。
>
> * 大小:单个音频不超过 15 MB请求体大小不超过 64 MB。大文件请勿使用Base64编码。
***
content.**role&#x20;**`string` `条件必填`
音频的位置或用途。当前仅支持 **reference\_audio**
#### **service\_tier** `string`
&#x20;Seedance 2.0 & 2.0 fast 暂不支持
#### **generate\_audio&#x20;**`boolean`&#x20;
> Seedance 2.0 & 2.0 fast 默认值: true
控制生成的视频是否包含与画面同步的声音。
* true模型输出的视频包含同步音频。模型会基于文本提示词与视觉内容自动生成与之匹配的人声、音效及背景音乐。建议将对话部分置于双引号内以优化音频生成效果。例如男人叫住女人说“你记住以后不可以用手指指月亮。”
* false模型输出的视频为无声视频。
> **说明**
>
> 生成的有声视频均为单声道,和传入的音频声道数无关。
####
#### **draft&#x20;**`boolean`
&#x20;Seedance 2.0 & 2.0 fast 暂不支持
#### **tools&#x20;**`object[]`
> 仅 Seedance 2.0 & 2.0 fast 支持
配置模型要调用的工具。
***
tools.**type&#x20;**`string`
指定使用的工具类型。
* web\_search联网搜索工具。
> **说明**
>
> * 开启联网搜索后,模型会根据用户的提示词自主判断是否搜索互联网内容(如商品、天气等)。可提升生成视频的时效性,但也会增加一定的时延。
>
> * 实际搜索次数可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 usage.tool\_usage.**web\_search** 字段获取,如果为 0 表示未搜索。
#### **resolution&#x20;**&#x20;`string`
> Seedance 2.0 & 2.0 fast 默认值720p
视频分辨率,取值范围:
* 480p
* 720p
#### **ratio&#x20;**`string`&#x20;
> Seedance 2.0 & 2.0 fast 默认值: adaptive
生成视频的宽高比例。不同宽高比对应的宽高像素值见下方表格。
* 16:9&#x20;
* 4:3
* 1:1
* 3:4
* 9:16
* 21:9
* adaptive根据输入自动选择最合适的宽高比
> **adaptive 适配规则**
>
> 当配置 **ratio** 为 adaptive 时,模型会根据生成场景自动适配宽高比;实际生成的视频宽高比可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 **ratio** 字段获取。
>
> * 文生视频:根据输入的提示词,智能选择最合适的宽高比。
>
> * 首帧 / 首尾帧生视频:根据上传的首帧图片比例,自动选择最接近的宽高比。
>
> * 多模态参考生视频:根据用户提示词意图判断,如果是首帧生视频/编辑视频/延长视频,以该图片/视频为准选择最接近的宽高比;否则,以传入的第一个媒体文件为准(优先级:视频>图片)选择最接近的宽高比。
***
**不同宽高比对应的宽高像素值:**
| 分辨率 | 宽高比 | 宽高像素值 |
| ---- | ---- | -------- |
| 480p | 16:9 | 864×496 |
| | 4:3 | 752×560 |
| | 1:1 | 640×640 |
| | 3:4 | 560×752 |
| | 9:16 | 496×864 |
| | 21:9 | 992×432 |
| 720p | 16:9 | 1280×720 |
| | 4:3 | 1112×834 |
| | 1:1 | 960×960 |
| | 3:4 | 834×1112 |
| | 9:16 | 720×1280 |
| | 21:9 | 1470×630 |
#### **duration** `integer`&#x20;
> Seedance 2.0 & 2.0 fast 默认值5
生成视频时长,仅支持整数,单位:秒。
取值范围:
* \[4,15] 或设置为-1
> **配置方法**
>
> * 指定具体时长:支持有效范围内的任一整数。
>
> * 智能指定:设置为 -1表示由模型在有效范围内自主选择合适的视频长度整数秒。实际生成视频的时长可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 **duration** 字段获取。注意视频时长与计费相关,请谨慎设置。
#### **frames** `integer`&#x20;
Seedance 2.0 & 2.0 fast 暂不支持
#### **camera\_fixed** `boolean`
&#x20;Seedance 2.0 & 2.0 fast 暂不支持
# Get/List-查询视频生成任务/列表
> 查询视频生成任务GET https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/{id}
>
> 查询视频生成任务列表GET https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks?page\_num={page\_num}\&page\_size={page\_size}\&filter.status={filter.status}\&filter.task\_ids={filter.task\_ids}\&filter.model={filter.model}
## 响应参数
#### **tools&#x20;**`object[]`&#x20;
> 仅 Seedance 2.0 & 2.0 fast 支持
配置模型要调用的工具。
***
tools.**type&#x20;**`string`
指定使用的工具类型。
* web\_search联网搜索工具。
#### **usage** `object`
本次请求的 token 用量。
***
usage.**completion\_tokens** `integer`
模型输出视频花费的 token 数量。
***
usage.**total\_tokens** `integer`
本次请求消耗的总 token 数量。
***
usage.**tool\_usage&#x20;**`object`&#x20;
> 仅 Seedance 2.0 & 2.0 fast 支持
使用工具的用量信息。
***
usage.tool\_usage.**web\_search&#x20;**`integer`&#x20;
实际调用联网搜索工具的次数,仅开启联网搜索时返回。
# 调用简介及示例
## 流程简介
任务接口是异步接口,视频生成任务流程
1. 创建视频生成任务接口创建视频生成任务
2. 定时使用查询接口查询视频生成任务状态
1. 任务 running过段时间再查询任务状态
2. 任务完成返回视频链接在24小时内下载生成的视频文件
## 1. 创建视频生成任务
> 以下示例仅展示 Seedance 2.0 & 2.0 fast 新增能力,更多视频生成示例详见 [创建视频生成任务 API](https://www.volcengine.com/docs/82379/1520757)。
### 多模态参考
### 编辑视频
### 延长视频
### 使用联网搜索
仅支持文本生视频
## 2. 查询视频生成任务
# 最佳实践-使用公共虚拟人像生成视频
平台提供公共虚拟人像素材库,目前您可以使用其中的图像素材来创建一个统一、完备的视频主角。帮助您更好地控制主角,并确保其形象在多段视频中保持一致,避免因为真人人脸限制导致角色无法统一的问题。
素材模态目前包含图片,并提供人物背景描述。每个素材对应一个独立素材 ID (asset ID),在体验中心的视频生成任务中,指定角色人脸生成视频。
1. 在浏览器中打开[体验中心](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128\&tab=GenVideo),点击输入框下方的 **虚拟人像库** 页签。
2. 检索需要使用的人像,支持使用自然语言检索及筛选框组合筛选。
| 输入:文本 | 输入:虚拟人像、图片 | 输出 |
| ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -- |
| **图片1**中美妆博主用中文进行介绍,妆容改为明艳大气,去掉脸部反光,笑容甜美,近景镜头,手持**图片2**的面霜面向镜头展示,清新简约背景,元气甜美风格。博主台词:挖到本命面霜了!质地像云朵一样软糯,一抹就吸收,熬夜急救、补水保湿全搞定,素颜都自带柔光感。 | ![Image Token: HTf6bPRukoWaW4xnCSlcvKtUn7c](images/HTf6bPRukoWaW4xnCSlcvKtUn7c.png)![Image Token: YfCDbzJlqo4yzZxCmdscWdsInCf](images/YfCDbzJlqo4yzZxCmdscWdsInCf.jpeg) | |
在 [Video Generation API](https://www.volcengine.com/docs/82379/1520758) 的 **content.<模态>\_url.url** 字段中使用 素材 URI 生成视频。
> 输入的参考内容,包括人像素材,需符合视频生成限制,具体信息请查看使用限制。
>
> **注意**
>
> * 首次在 API 中使用虚拟人像素材 Asset URI 前,需先在[方舟体验中心](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128\&tab=GenVideo)提交一次视频生成任务,阅读并同意弹出的 **虚拟人像库使用协议**。
>
> * 体验中心支持体验视频生成能力。默认单次生成 4 段视频,为节约成本,建议设置为每次生成 1 条,具体方式可参考[虚拟人像库](https://www.volcengine.com/docs/82379/2223965?lang=zh)。
同意协议的操作方式如下:
![Image Token: LK8ybUN9Ko2KkQxq2FdclVQtnkh](images/LK8ybUN9Ko2KkQxq2FdclVQtnkh.gif)
示例代码:
# 使用自有虚拟人像素材生成视频(线下提交)
方舟提供私域人像素材库,您可在视频生成中使用自有虚拟人物或真人(仅限素人)素材,生成短剧等更定制化的视频内容。平台将对您提供的素材进行审核,规避可能产生的法律风险。
* 自有素材需入库后使用,您可将虚拟人像或真人素材发送给销售代表,同时完成合规承诺函及其他证明材料的准备。
* 入库后,您可使用素材的 Asset ID在视频生成 API 中使用自有素材。
> **重要**
>
> * 对虚拟人像素材,您需签署虚拟人像素材合规承诺函,并提供签署承诺函所需的材料。
>
> * 对真实人物素材,除承诺函外,您还需额外提供真人授权材料。
>
> * 具体流程及所需材料,请和您的销售代表确认。
提交自有人像素材时,需按人物将素材分组:
* 每个人物为一个素材组。
* 每组可包含多个素材文件,素材文件对应唯一 ID (asset ID)。
## 入库流程
提交自有虚拟人像素材方式大致如下,请联系您的销售代表了解详情。
1. 准备素材文件,完成承诺函签署,并准备其他证明材料。
2. 准备素材文件,完成承诺函签署,并准备其他证明材料。
* 每个人物素材需至少提供一张正面图片文件。此外,您可按需提供该人物的其他图片、视频素材。
* 需确保每个人物组中的素材与该正面图片为同一人物。
* 每个人物创建一个文件夹(命名:“*虚拟人像 1-<人像名>*”)
提交素材文件夹示例:
![Image Token: XMQ9bz6vhof7vxxsac8cqIZmneB](images/XMQ9bz6vhof7vxxsac8cqIZmneB.png)
> **注意**
>
> * 以上示例仅供参考,您可根据视频创作需求,提交虚拟人物素材。
>
> * 您仅需上传视频生成任务中需要使用的素材。
* 素材文件需满足视频生成 API 对输入文件的要求:
> **传入单张图片要求**
>
> * 格式jpeg、png、webp、bmp、tiff、gif
>
> * 宽高比(宽/高): (0.4, 2.5)&#x20;
>
> * 宽高长度px(300, 6000)
>
> * 大小:单张图片小于 30 MB。请求体大小不超过 64 MB。大文件请勿使用Base64编码。
> **传入单个视频要求**
>
> * 视频格式mp4、mov。
>
> * 分辨率480p、720p
>
> * 时长:单个视频时长 \[2, 15] s最多传入 3 个参考视频,所有视频总时长不超过 15s。
>
> * 尺寸:
>
> * 宽高比(宽/高):\[0.4, 2.5]
>
> * 宽高长度px\[300, 6000]
>
> * 画面像素(宽 × 高):\[409600, 927408] ,示例:
>
> * 画面尺寸 640×640=409600 满足最小值
>
> * 画面尺寸 834×1112=927408 满足最大值。
>
> * 大小:单个视频不超过 50 MB。
>
> * 帧率 (FPS)\[24, 60]&#x20;
> **注意**
>
> 有关提交流程、承诺函签署所需材料的具体信息,请联系您的销售代表了解详情。
3. 方舟将对您提供的素材进行审核,通过审核的素材将被上传至虚拟人像库。
4. 入库后,每个人物组素材将通过以下示例中的形式返回,您可解压后查看:
![Image Token: PKu6b3391oUbVKxxEGjchxBVnbg](images/PKu6b3391oUbVKxxEGjchxBVnbg.png)
示例中:
* Andy 为您提交的人物名称
* group-20260310035119-9mzqn 为该人物组的 ID
* 解压后,可查看每张素材的 Asset ID
![Image Token: VV0ybrxNfouEhZxTjqCcX1epnzb](images/VV0ybrxNfouEhZxTjqCcX1epnzb.png)
* 您可按 `asset: //<asset_id>` 规则拼接 URI在 API 中使用对应素材生成视频:
具体调用方式请参考 [最佳实践-使用虚拟人像生成视频](https://bytedance.larkoffice.com/wiki/SANpwJ9bgiKgrykLaMTcAB0InWc#share-YurKdrLfAocLErxsTWDcKidPnGd)。
## **注意事项**
1. 首次在 API 中使用虚拟人像素材 Asset URI 前,需先在[方舟体验中心](https://console.volcengine.com/ark/region:ark+cn-beijing/experience/vision?modelId=doubao-seedance-2-0-260128\&tab=GenVideo)提交一次视频生成任务,阅读并同意弹出的 **虚拟人像库使用协议**,操作方式如下:
![Image Token: IFfPbDgceoFXZCxdriIcnwkPnUc](images/IFfPbDgceoFXZCxdriIcnwkPnUc.gif)
* 仅支持使用已入库素材生成视频。

1611
docs/archive/prd.md Normal file

File diff suppressed because it is too large Load Diff

175
docs/archive/test-report.md Normal file
View File

@ -0,0 +1,175 @@
# 测试报告
## 测试结论: ALL_PASSED
## Bug 路由摘要
- CODE_BUG: 0 个(路由到开发 Agent
- DESIGN_BUG: 0 个(路由到设计 Agent
- REQUIREMENT_BUG: 0 个(路由到产品 Agent
## 测试概要
- 单元测试: 224 通过 / 0 失败9 个测试文件)
- E2E 测试: 49 通过 / 0 失败3 个测试文件)
- 视觉质量检查: 7 通过 / 0 失败
- 后端 API 验证: 12 端点全部通过
- Django 系统检查: 0 问题
- 数据库迁移: 完整
## 通过的测试
### 单元测试224 个)
#### 既有测试文件(已更新适配 Phase 3
- ✅ test/unit/apiClient.test.ts — API 客户端测试authApi、videoApi、adminApi、profileApi
- ✅ test/unit/phase2Components.test.tsx — 组件类型测试(已更新为秒数配额格式)
- ✅ test/unit/auth.test.ts — 认证流程测试
- ✅ test/unit/videoGeneration.test.ts — 视频生成测试
- ✅ test/unit/adminPanel.test.ts — 管理面板测试
- ✅ test/unit/uiComponents.test.tsx — UI 组件测试
- ✅ test/unit/routerSetup.test.tsx — 路由配置测试
- ✅ test/unit/phase2Features.test.ts — Phase 2 功能测试
#### Phase 3 新增测试62 个test/unit/phase3Features.test.ts
- ✅ 秒数配额系统验证7 个)— Quota 类型字段、默认值 600s/6000s、使用量追踪
- ✅ 管理后台多页面布局验证7 个)— AdminLayout 侧边栏、4 个导航链接、折叠功能
- ✅ 仪表盘页面验证8 个)— ECharts 图表、统计卡片、趋势数据、骨架屏加载
- ✅ 用户管理页面验证6 个)— 用户表格、搜索过滤、配额编辑、状态切换
- ✅ 消费记录页面验证5 个)— 记录表格、日期范围过滤、CSV 导出、分页
- ✅ 系统设置页面验证4 个)— 全局配额默认值、公告管理、开关切换
- ✅ 个人中心页面验证8 个)— 仪表盘图表、进度条、趋势图、消费记录列表
- ✅ 后端 API 路由验证4 个)— profile 和 admin URL 配置完整
- ✅ QuotaConfig 模型验证3 个)— 单例模式、默认值
- ✅ ProtectedRoute 管理员守卫1 个)— requireAdmin 属性
- ✅ UserInfoBar 导航2 个)— 秒数显示、个人中心链接
- ✅ ECharts 集成3 个)— echarts-for-react 依赖、图表组件配置
- ✅ 路由配置4 个)— 嵌套路由、重定向、通配符
### E2E 测试49 个)
#### 既有 E2E 测试26 个)
- ✅ test/e2e/video-generation.spec.ts — 视频生成页 P0/P1/P2 验收 + Sidebar14 个)
- ✅ test/e2e/auth-flow.spec.ts — 认证流程注册、登录、登出、路由保护12 个)
#### Phase 3 新增 E2E 测试23 个test/e2e/phase3-admin-profile.spec.ts
**个人中心页面7 个)**
- ✅ 已认证用户可访问个人中心
- ✅ 显示消费概览(今日额度、本月额度)
- ✅ 显示消费趋势(近 7 天 / 近 30 天切换)
- ✅ 显示消费记录(新用户显示"暂无记录"
- ✅ 返回首页导航
- ✅ 退出按钮可见
- ✅ 未认证用户重定向到登录页
**UserInfoBar 配额与导航2 个)**
- ✅ 显示秒数格式配额(剩余: Xs/Xs(日)
- ✅ 个人中心链接导航到 /profile
**管理后台权限控制6 个)**
- ✅ 非管理员用户无法访问 /admin/dashboard
- ✅ 非管理员用户无法访问 /admin/users
- ✅ 非管理员用户无法访问 /admin/records
- ✅ 非管理员用户无法访问 /admin/settings
- ✅ /admin 重定向到 /admin/dashboard
- ✅ 未认证访问 /admin 重定向到 /login
**后端 API 集成7 个)**
- ✅ GET /api/v1/auth/me 返回秒数配额daily_seconds_limit=600, monthly_seconds_limit=6000
- ✅ GET /api/v1/profile/overview 返回消费数据7 天趋势)
- ✅ GET /api/v1/profile/overview 支持 30 天周期
- ✅ GET /api/v1/profile/records 返回分页记录
- ✅ POST /api/v1/video/generate 消耗秒数seconds_consumed=10, remaining=590
- ✅ 管理端点对非管理员返回 403
- ✅ 未认证请求返回 401
**趋势切换1 个)**
- ✅ 点击 30 天标签更新趋势周期
### 后端 API 验证curl 直接测试)
- ✅ POST /api/v1/auth/register — 201 Created
- ✅ POST /api/v1/auth/login — 200 OK
- ✅ GET /api/v1/auth/me — 返回秒数配额
- ✅ GET /api/v1/profile/overview?period=7d — 7 天趋势
- ✅ GET /api/v1/profile/overview?period=30d — 30 天趋势
- ✅ GET /api/v1/profile/records — 分页记录
- ✅ GET /api/v1/admin/stats — 管理统计数据
- ✅ GET /api/v1/admin/users — 用户列表(分页、搜索)
- ✅ GET /api/v1/admin/records — 消费记录(日期过滤)
- ✅ GET /api/v1/admin/settings — 系统设置
- ✅ POST /api/v1/video/generate — 202 Acceptedseconds_consumed=10
- ✅ Django system check: 0 issues, migrations up-to-date
## 失败的测试Bug 列表)
## 视觉质量检查
| 检查项 | 状态 | 说明 |
|-------|------|------|
| 登录页面 | ✅ | 暗色主题,表单完整,注册链接可见 |
| 主页面(已登录) | ✅ | 显示"剩余: 600s/600s(日)"秒数配额,个人中心和管理后台入口可见 |
| 管理后台 — 仪表盘 | ✅ | 侧边栏 4 个导航项统计卡片用户数、今日消费、本月消费、活跃用户ECharts 折线图和柱状图 |
| 管理后台 — 用户管理 | ✅ | 用户表格含秒数配额列,搜索过滤,分页功能 |
| 管理后台 — 消费记录 | ✅ | 记录表格含时间/用户/秒数/描述/模式/状态列,日期范围过滤,导出 CSV 按钮 |
| 管理后台 — 系统设置 | ✅ | 全局默认配额600s/6000s系统公告开关和文本输入 |
| 个人中心 | ✅ | 仪表盘图表0s/600s今日/本月额度卡片含进度条消费趋势近7天/近30天消费记录暂无记录 |
### 视觉截图文件
- screenshot-login-phase3.png — 登录页
- screenshot-main-page-phase3.png — 主页面
- screenshot-admin-dashboard-phase3.png — 管理仪表盘
- screenshot-admin-users-phase3.png — 用户管理
- screenshot-admin-records-phase3.png — 消费记录
- screenshot-admin-settings-phase3.png — 系统设置
- screenshot-profile-phase3.png — 个人中心
## 上一轮 Bug 修复验证 — 全部 ✅
| 原 Bug | 状态 | 验证方式 |
|--------|------|---------|
| API 拦截器在登录端点 401 重定向 | ✅ 已修复 | `api.ts:24-25` 排除 `/auth/login``/auth/register``/auth/token/refresh` 端点 |
| 音频类型死代码types/store/components | ✅ 已修复 | 源码审查确认 `UploadedFile.type``'image' \| 'video'`,音频分支已全部移除 |
| GenerationCard 硬编码模型名 | ✅ 已修复 | `GenerationCard.tsx:54` 使用 `task.model` 动态渲染 |
| 无文件大小验证 | ✅ 已修复 | UniversalUpload、InputBar、KeyframeUpload 均有 20MB/100MB 限制 |
## Phase 3 功能覆盖总结
| 功能模块 | 前端 | 后端 API | 单元测试 | E2E 测试 | 视觉检查 |
|---------|------|---------|---------|---------|---------|
| 秒数配额系统 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 管理后台侧边栏 | ✅ | — | ✅ | ✅ | ✅ |
| 仪表盘ECharts | ✅ | ✅ | ✅ | — | ✅ |
| 用户管理 | ✅ | ✅ | ✅ | — | ✅ |
| 消费记录 | ✅ | ✅ | ✅ | — | ✅ |
| 系统设置 | ✅ | ✅ | ✅ | — | ✅ |
| 个人中心 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 权限控制 | ✅ | ✅ | ✅ | ✅ | — |
| CSV 导出 | ✅ | — | ✅ | — | ✅ |
## 测试环境
- 前端: Vite 5 + React 18 + TypeScript
- 后端: Django 4.2 + DRF + SimpleJWT
- 单元测试: Vitest 3.x (jsdom)
- E2E 测试: Playwright 1.50
- 数据库: SQLite (开发环境)
- Node.js: v22+
- Python: 3.12+
## 测试文件清单
| 文件 | 类型 | 测试数 |
|------|------|--------|
| `test/unit/inputBarStore.test.ts` | Store 单元测试 | 31 |
| `test/unit/generationStore.test.ts` | Store 单元测试 | 15 |
| `test/unit/authStore.test.ts` | Store 单元测试 | 15 |
| `test/unit/apiClient.test.ts` | API 客户端测试 | 20 |
| `test/unit/phase2Components.test.tsx` | Phase 2 组件测试 | 17 |
| `test/unit/designTokens.test.ts` | CSS/设计规范测试 | 30 |
| `test/unit/components.test.tsx` | 组件渲染测试 | 16 |
| `test/unit/bugfixVerification.test.ts` | Bug 修复验证 | 15 |
| `test/unit/phase3Features.test.ts` | Phase 3 功能测试 | 62 |
| `test/e2e/video-generation.spec.ts` | E2E 视频生成 | 14 |
| `test/e2e/auth-flow.spec.ts` | E2E 认证流程 | 12 |
| `test/e2e/phase3-admin-profile.spec.ts` | E2E Phase 3 | 23 |
| **总计** | | **270** |

View File

@ -218,9 +218,43 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
}, },
removeReference: (id) => { removeReference: (id) => {
const state = get(); const state = get();
const ref = state.references.find((r) => r.id === id); const removedRef = state.references.find((r) => r.id === id);
if (ref) URL.revokeObjectURL(ref.previewUrl); if (!removedRef) return;
set({ references: state.references.filter((r) => r.id !== id) }); if (removedRef.previewUrl) URL.revokeObjectURL(removedRef.previewUrl);
// 删除后同类型的剩余引用按顺序重新编号(即梦逻辑:保持 1/2/3 连续)
const remaining = state.references.filter((r) => r.id !== id);
const labelPrefix = removedRef.type === 'image' ? '图片' : removedRef.type === 'video' ? '视频' : '音频';
const labelUpdates = new Map<string, string>(); // refId -> newLabel
let idx = 1;
const relabeled = remaining.map((r) => {
if (r.type !== removedRef.type) return r;
const newLabel = `${labelPrefix}${idx++}`;
if (r.label !== newLabel) labelUpdates.set(r.id, newLabel);
return r.label === newLabel ? r : { ...r, label: newLabel };
});
// 同步更新 editorHtml 里对应 refId 的 @mention span 文本
let newEditorHtml = state.editorHtml;
if (labelUpdates.size > 0 && newEditorHtml) {
const doc = new DOMParser().parseFromString(`<div>${newEditorHtml}</div>`, 'text/html');
const container = doc.body.firstChild as HTMLElement | null;
if (container) {
container.querySelectorAll('[data-ref-id]').forEach((span) => {
const el = span as HTMLElement;
const refId = el.dataset.refId;
if (refId && labelUpdates.has(refId)) {
const newLabel = labelUpdates.get(refId)!;
// span 结构:[icon/img] + atHidden(@) + textNode(label)
const labelNode = [...el.childNodes].reverse().find((n) => n.nodeType === Node.TEXT_NODE);
if (labelNode) labelNode.textContent = newLabel;
}
});
newEditorHtml = container.innerHTML;
}
}
set({ references: relabeled, editorHtml: newEditorHtml });
}, },
clearReferences: () => { clearReferences: () => {
const state = get(); const state = get();

View File

@ -0,0 +1,133 @@
/**
* Bug 2 E2E
* localhost:5173 + 127.0.0.1:8000
*/
import { test, expect, Page } from '@playwright/test';
const BASE_URL = 'http://localhost:5173';
const API_URL = 'http://127.0.0.1:8000';
const USERNAME = 'admin';
const PASSWORD = 'admin123';
const TEST_IMAGES_DIR = 'C:/Users/Air-work/AppData/Local/Temp/bug2test';
const IMG_RED = `${TEST_IMAGES_DIR}/test_red.png`;
const IMG_GREEN = `${TEST_IMAGES_DIR}/test_green.png`;
const IMG_BLUE = `${TEST_IMAGES_DIR}/test_blue.png`;
async function login(page: Page) {
const resp = await page.request.post(`${API_URL}/api/v1/auth/login`, {
data: { username: USERNAME, password: PASSWORD },
});
if (!resp.ok()) {
const errText = await resp.text();
console.log('LOGIN FAILED:', resp.status(), errText);
}
expect(resp.ok()).toBeTruthy();
const body = await resp.json();
await page.goto(BASE_URL);
await page.evaluate(({ access, refresh }) => {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}, { access: body.tokens.access, refresh: body.tokens.refresh });
await page.goto(`${BASE_URL}/app`);
await page.waitForTimeout(1500);
// 关闭初次登录可能出现的公告弹窗
const knowBtn = page.getByRole('button', { name: /我知道了|知道了|关闭/ }).first();
if (await knowBtn.isVisible().catch(() => false)) {
await knowBtn.click();
await page.waitForTimeout(300);
}
}
test.describe.serial('Bug 2: 图片删除后即梦式连续重命名', () => {
test('上传 3 张图 → 删除图片2 → 图片3 变为图片2', async ({ page }) => {
await login(page);
// 上传 3 张图
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles([IMG_RED, IMG_GREEN, IMG_BLUE]);
await page.waitForTimeout(2000); // 等图片校验和 ref 添加完成
// 展开缩略图堆栈hover 触发)
const thumbRow = page.locator('[class*="thumbRow"]').first();
await thumbRow.hover();
await page.waitForTimeout(500);
// 验证上传后初始状态图片1/图片2/图片3
const labelsInitial = await page.locator('[class*="thumbLabel"]').allTextContents();
console.log('初始标签:', labelsInitial);
expect(labelsInitial).toEqual(['图片1', '图片2', '图片3']);
// 点第 2 张图的删除按钮
const secondThumb = page.locator('[class*="thumbItem"]').nth(1);
await secondThumb.hover();
await secondThumb.locator('[class*="thumbClose"]').click({ force: true });
await page.waitForTimeout(500);
// 验证重命名后图片1/图片2原图片3
await thumbRow.hover();
await page.waitForTimeout(300);
const labelsAfterDelete = await page.locator('[class*="thumbLabel"]').allTextContents();
console.log('删除图片2后:', labelsAfterDelete);
expect(labelsAfterDelete).toEqual(['图片1', '图片2']);
expect(labelsAfterDelete.length).toBe(2);
});
test('删除图片2 后再上传 1 张 → 新图是图片3不和现有冲突', async ({ page }) => {
await login(page);
// 上传 3 张
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles([IMG_RED, IMG_GREEN, IMG_BLUE]);
await page.waitForTimeout(2000);
const thumbRow = page.locator('[class*="thumbRow"]').first();
await thumbRow.hover();
await page.waitForTimeout(500);
// 删除第 2 张
const secondThumb = page.locator('[class*="thumbItem"]').nth(1);
await secondThumb.hover();
await secondThumb.locator('[class*="thumbClose"]').click({ force: true });
await page.waitForTimeout(500);
// 再上传 1 张
await fileInput.setInputFiles([IMG_RED]);
await page.waitForTimeout(2000);
// 验证原图片1、原图片3(已改名图片2)、新图片3
await thumbRow.hover();
await page.waitForTimeout(300);
const finalLabels = await page.locator('[class*="thumbLabel"]').allTextContents();
console.log('删除后再上传:', finalLabels);
expect(finalLabels).toEqual(['图片1', '图片2', '图片3']);
// 无重复编号
expect(new Set(finalLabels).size).toBe(finalLabels.length);
});
test('删除第 1 张 → 剩余图片全部前移', async ({ page }) => {
await login(page);
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles([IMG_RED, IMG_GREEN, IMG_BLUE]);
await page.waitForTimeout(2000);
const thumbRow = page.locator('[class*="thumbRow"]').first();
await thumbRow.hover();
await page.waitForTimeout(500);
// 删除第 1 张
const firstThumb = page.locator('[class*="thumbItem"]').nth(0);
await firstThumb.hover();
await firstThumb.locator('[class*="thumbClose"]').click({ force: true });
await page.waitForTimeout(500);
await thumbRow.hover();
await page.waitForTimeout(300);
const labels = await page.locator('[class*="thumbLabel"]').allTextContents();
console.log('删除图片1后:', labels);
expect(labels).toEqual(['图片1', '图片2']);
});
});

View File

@ -0,0 +1,234 @@
/**
* Bug 2 fix verification: 删除引用后
* editorHtml @mention span
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useInputBarStore } from '../../src/store/inputBar';
function mockFile(name: string, type = 'image/jpeg'): File {
return new File(['mock'], name, { type });
}
function mockRef(id: string, type: 'image' | 'video' | 'audio', label: string) {
return {
id,
file: mockFile(`${id}.${type === 'image' ? 'jpg' : type === 'video' ? 'mp4' : 'mp3'}`),
type,
previewUrl: `blob:${id}`,
label,
};
}
function mentionSpan(refId: string, refType: string, label: string): string {
return `<span data-ref-id="${refId}" data-ref-type="${refType}" class="mention" contenteditable="false"><span style="font-size:0;width:0;overflow:hidden;display:inline">@</span>${label}</span>`;
}
describe('removeReference — 即梦式连续重命名', () => {
beforeEach(() => {
useInputBarStore.getState().reset();
});
describe('图片重命名', () => {
it('删除图片2 后图片3 重命名为图片2references + editorHtml 同步)', () => {
const refs = [
mockRef('ref_1', 'image', '图片1'),
mockRef('ref_2', 'image', '图片2'),
mockRef('ref_3', 'image', '图片3'),
];
const editorHtml =
`开场 ${mentionSpan('ref_1', 'image', '图片1')}${mentionSpan('ref_2', 'image', '图片2')} 在和 ${mentionSpan('ref_3', 'image', '图片3')} 讲话`;
useInputBarStore.setState({ references: refs, editorHtml });
useInputBarStore.getState().removeReference('ref_2');
const state = useInputBarStore.getState();
expect(state.references).toHaveLength(2);
expect(state.references[0].id).toBe('ref_1');
expect(state.references[0].label).toBe('图片1');
expect(state.references[1].id).toBe('ref_3');
expect(state.references[1].label).toBe('图片2'); // 原图片3 → 图片2
// editorHtml 里 ref_3 的 textNode 应该变成 "图片2"
expect(state.editorHtml).toContain('data-ref-id="ref_3"');
expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?图片2<\/span>/);
// ref_1 保持 "图片1"
expect(state.editorHtml).toMatch(/data-ref-id="ref_1"[^>]*>[\s\S]*?图片1<\/span>/);
});
it('删除图片1 后图片2、图片3 重命名为图片1、图片2', () => {
const refs = [
mockRef('ref_1', 'image', '图片1'),
mockRef('ref_2', 'image', '图片2'),
mockRef('ref_3', 'image', '图片3'),
];
const editorHtml = `${mentionSpan('ref_2', 'image', '图片2')} ${mentionSpan('ref_3', 'image', '图片3')}`;
useInputBarStore.setState({ references: refs, editorHtml });
useInputBarStore.getState().removeReference('ref_1');
const state = useInputBarStore.getState();
expect(state.references[0].label).toBe('图片1'); // ref_2
expect(state.references[1].label).toBe('图片2'); // ref_3
expect(state.editorHtml).toMatch(/data-ref-id="ref_2"[^>]*>[\s\S]*?图片1<\/span>/);
expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?图片2<\/span>/);
});
it('删除最后一张图片(唯一图片)— references 清空editorHtml 不变', () => {
const refs = [mockRef('ref_1', 'image', '图片1')];
const editorHtml = `内容 ${mentionSpan('ref_1', 'image', '图片1')} 尾部`;
useInputBarStore.setState({ references: refs, editorHtml });
useInputBarStore.getState().removeReference('ref_1');
const state = useInputBarStore.getState();
expect(state.references).toHaveLength(0);
// remaining 为空 → labelUpdates 为空 → 跳过 DOM 操作 → editorHtml 原样保留
expect(state.editorHtml).toBe(editorHtml);
});
});
describe('视频/音频独立编号', () => {
it('图片和视频混合时,删图片只重命名图片,视频不动', () => {
const refs = [
mockRef('ref_1', 'image', '图片1'),
mockRef('ref_2', 'image', '图片2'),
mockRef('ref_3', 'video', '视频1'),
];
const editorHtml =
`${mentionSpan('ref_1', 'image', '图片1')} ${mentionSpan('ref_2', 'image', '图片2')} ${mentionSpan('ref_3', 'video', '视频1')}`;
useInputBarStore.setState({ references: refs, editorHtml });
useInputBarStore.getState().removeReference('ref_1');
const state = useInputBarStore.getState();
expect(state.references).toHaveLength(2);
expect(state.references[0].label).toBe('图片1'); // 原图片2
expect(state.references[1].label).toBe('视频1'); // 视频不变
expect(state.editorHtml).toMatch(/data-ref-id="ref_2"[^>]*>[\s\S]*?图片1<\/span>/);
expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?视频1<\/span>/);
});
it('删除视频2视频3 重命名为视频2', () => {
const refs = [
mockRef('ref_1', 'video', '视频1'),
mockRef('ref_2', 'video', '视频2'),
mockRef('ref_3', 'video', '视频3'),
];
const editorHtml = `${mentionSpan('ref_3', 'video', '视频3')}`;
useInputBarStore.setState({ references: refs, editorHtml });
useInputBarStore.getState().removeReference('ref_2');
const state = useInputBarStore.getState();
expect(state.references[0].label).toBe('视频1');
expect(state.references[1].label).toBe('视频2'); // 原视频3
expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?视频2<\/span>/);
});
it('删除音频1音频2 重命名为音频1', () => {
const refs = [
mockRef('ref_1', 'audio', '音频1'),
mockRef('ref_2', 'audio', '音频2'),
];
const editorHtml = `${mentionSpan('ref_1', 'audio', '音频1')} ${mentionSpan('ref_2', 'audio', '音频2')}`;
useInputBarStore.setState({ references: refs, editorHtml });
useInputBarStore.getState().removeReference('ref_1');
const state = useInputBarStore.getState();
expect(state.references).toHaveLength(1);
expect(state.references[0].id).toBe('ref_2');
expect(state.references[0].label).toBe('音频1'); // 原音频2
expect(state.editorHtml).toMatch(/data-ref-id="ref_2"[^>]*>[\s\S]*?音频1<\/span>/);
});
});
describe('边界场景', () => {
it('editorHtml 为空 — 不报错,只重命名 references', () => {
const refs = [
mockRef('ref_1', 'image', '图片1'),
mockRef('ref_2', 'image', '图片2'),
];
useInputBarStore.setState({ references: refs, editorHtml: '' });
useInputBarStore.getState().removeReference('ref_1');
const state = useInputBarStore.getState();
expect(state.references).toHaveLength(1);
expect(state.references[0].label).toBe('图片1');
expect(state.editorHtml).toBe('');
});
it('editorHtml 中没有对应的 @mention span — 只改 references', () => {
const refs = [
mockRef('ref_1', 'image', '图片1'),
mockRef('ref_2', 'image', '图片2'),
];
const editorHtml = '<span>纯文本,没有 mention span</span>';
useInputBarStore.setState({ references: refs, editorHtml });
useInputBarStore.getState().removeReference('ref_1');
const state = useInputBarStore.getState();
expect(state.references).toHaveLength(1);
expect(state.references[0].label).toBe('图片1'); // ref_2 重命名
// editorHtml 不含对应 span无法更新但不报错
});
it('传入不存在的 id — 静默返回,状态不变', () => {
const refs = [mockRef('ref_1', 'image', '图片1')];
const editorHtml = mentionSpan('ref_1', 'image', '图片1');
useInputBarStore.setState({ references: refs, editorHtml });
useInputBarStore.getState().removeReference('nonexistent_id');
const state = useInputBarStore.getState();
expect(state.references).toHaveLength(1);
expect(state.references[0].label).toBe('图片1');
expect(state.editorHtml).toBe(editorHtml);
});
it('删除的图片没被 @ 到 editor其他图片仍被重命名', () => {
const refs = [
mockRef('ref_1', 'image', '图片1'),
mockRef('ref_2', 'image', '图片2'),
mockRef('ref_3', 'image', '图片3'),
];
// editorHtml 只 @ 了图片3没 @图片1/2
const editorHtml = `${mentionSpan('ref_3', 'image', '图片3')}`;
useInputBarStore.setState({ references: refs, editorHtml });
useInputBarStore.getState().removeReference('ref_1');
const state = useInputBarStore.getState();
expect(state.references[0].label).toBe('图片1'); // 原图片2
expect(state.references[1].label).toBe('图片2'); // 原图片3
// editor 里只有 ref_3 的 span应该更新成"图片2"
expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?图片2<\/span>/);
});
});
describe('连续删除(并发)', () => {
it('连续删除两张图片,剩余图片正确重编号', () => {
const refs = [
mockRef('ref_1', 'image', '图片1'),
mockRef('ref_2', 'image', '图片2'),
mockRef('ref_3', 'image', '图片3'),
mockRef('ref_4', 'image', '图片4'),
];
const editorHtml = `${mentionSpan('ref_1', 'image', '图片1')} ${mentionSpan('ref_2', 'image', '图片2')} ${mentionSpan('ref_3', 'image', '图片3')} ${mentionSpan('ref_4', 'image', '图片4')}`;
useInputBarStore.setState({ references: refs, editorHtml });
useInputBarStore.getState().removeReference('ref_2');
useInputBarStore.getState().removeReference('ref_1');
const state = useInputBarStore.getState();
expect(state.references).toHaveLength(2);
expect(state.references[0].id).toBe('ref_3');
expect(state.references[0].label).toBe('图片1');
expect(state.references[1].id).toBe('ref_4');
expect(state.references[1].label).toBe('图片2');
expect(state.editorHtml).toMatch(/data-ref-id="ref_3"[^>]*>[\s\S]*?图片1<\/span>/);
expect(state.editorHtml).toMatch(/data-ref-id="ref_4"[^>]*>[\s\S]*?图片2<\/span>/);
});
});
});