From dafdc8983f2ddc05feed6aa154893c90a3b14352 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 17 Apr 2026 18:03:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.18.3=20=E7=89=88=E6=9D=83=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E5=8F=8B=E5=A5=BD=E6=8F=90=E7=A4=BA=20+=20=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=88=A0=E9=99=A4=E5=8D=B3=E6=A2=A6=E5=BC=8F=E8=BF=9E?= =?UTF-8?q?=E7=BB=AD=E9=87=8D=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/utils/airdrama_client.py | 1 + docs/archive/celery-polling-fix-20260404.md | 134 ++ docs/archive/design-review.md | 134 ++ ...ce 2.0 & 2.0 fast API文档(邀测用户版).md | 692 +++++++ ...限填客户名称】Assets API 参考文档(邀测用户版).md | 1201 ++++++++++++ docs/archive/prd.md | 1611 +++++++++++++++++ docs/archive/test-report.md | 175 ++ web/src/store/inputBar.ts | 40 +- web/test/e2e/bug2-rename.spec.ts | 133 ++ .../unit/removeReferenceRelabeling.test.ts | 234 +++ 10 files changed, 4352 insertions(+), 3 deletions(-) create mode 100644 docs/archive/celery-polling-fix-20260404.md create mode 100644 docs/archive/design-review.md create mode 100644 docs/archive/old-Seedance 2.0 & 2.0 fast API文档(邀测用户版).md create mode 100644 docs/archive/old-「保密信息」【申请权限填客户名称】Assets API 参考文档(邀测用户版).md create mode 100644 docs/archive/prd.md create mode 100644 docs/archive/test-report.md create mode 100644 web/test/e2e/bug2-rename.spec.ts create mode 100644 web/test/unit/removeReferenceRelabeling.test.ts diff --git a/backend/utils/airdrama_client.py b/backend/utils/airdrama_client.py index 2796085..a3b548d 100644 --- a/backend/utils/airdrama_client.py +++ b/backend/utils/airdrama_client.py @@ -15,6 +15,7 @@ ERROR_MESSAGES = { 'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试', # Output content moderation 'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试', + 'OutputVideoSensitiveContentDetected.PolicyViolation': '生成的视频涉及版权限制内容(如知名IP、名人肖像等),已被系统拦截,请修改提示词后重试', 'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截', # Parameter errors 'InvalidParameter': '请求参数无效,请检查输入内容', diff --git a/docs/archive/celery-polling-fix-20260404.md b/docs/archive/celery-polling-fix-20260404.md new file mode 100644 index 0000000..6f6013a --- /dev/null +++ b/docs/archive/celery-polling-fix-20260404.md @@ -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`) diff --git a/docs/archive/design-review.md b/docs/archive/design-review.md new file mode 100644 index 0000000..259e0b0 --- /dev/null +++ b/docs/archive/design-review.md @@ -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 个管理页面共享一致的 Sidebar,active 高亮正确 | +| 管理后台路由 — 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: 骨架屏加载态未实现 +- 用户表格缺少头像列(可在开发阶段补充) diff --git a/docs/archive/old-Seedance 2.0 & 2.0 fast API文档(邀测用户版).md b/docs/archive/old-Seedance 2.0 & 2.0 fast API文档(邀测用户版).md new file mode 100644 index 0000000..969cf38 --- /dev/null +++ b/docs/archive/old-Seedance 2.0 & 2.0 fast API文档(邀测用户版).md @@ -0,0 +1,692 @@ +# 【申请权限填客户名称】Seedance 2.0 & 2.0 fast API文档(邀测用户版) + +该文档目前仅限开白客户使用,发送前请和销管确认客户是否在开白名单内 + +***【❗️❗️❗️】该文档限制客户申请权限,只有返回了服务协议的客户方可申请*** + +本文介绍 Seedance 2.0 & 2.0 fast 模型相较于存量模型 **新增/配置有区别 **的 API 参数介绍,存量 API 参数的完整介绍参见 [视频生成 API](https://www.volcengine.com/docs/82379/1520758?lang=zh)。 + +> 本文档仅限预览及邀测用户使用: +> +> * 不承诺正式API上线100%一致。 +> +> * 仅限邀测用户阅读,请勿截图/分享给其他人员。 +> +> * 您上传的内容请确保由您原创或已取得授权。 + +# 模型能力 + +> **Seedance 2.0 和 Seedance 2.0 fast 提供的模型能力一致,**追求最高生成品质,推荐使用 **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 **`string` `必选` + +输入内容的类型,此处应为 **text**。 + +*** + +content.**text **`string` `必选` + +输入给模型的文本提示词,描述期望生成的视频。 + +支持中英文。建议中文不超过500字,英文不超过1000词。字数过多信息容易分散,模型可能因此忽略细节,只关注重点,造成视频缺失部分元素。提示词的更多使用技巧请参见 [Seedance 提示词指南](https://www.volcengine.com/docs/82379/1587797)。 + + + + + +* **图片信息** `object` + +输入给模型的图片信息。 + +*** + +content.**type **`string` `必选` + +输入内容的类型,此处应为 **image\_url**。 + +*** + +content.**image\_url **`object` `必选` + +输入给模型的图片对象。 + +*** + +content.image\_url.**url **`string` `必选` + +图片 URL 、图片 Base64 编码、素材 ID。 + +* 图片 URL:填入图片的公网 URL。 + +* Base64 编码:将本地文件转换为 Base64 编码字符串,然后提交给大模型。遵循格式:data:image/<图片格式>;base64,\,注意 <图片格式> 需小写,如 data:image/png;base64,{base64\_image}。 + +* 素材 ID:用于视频生成的预置素材及虚拟人像的 ID,遵循格式:asset://\,可从 [素材&虚拟人像库](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) +> +> * 宽高长度(px):(300, 6000) +> +> * 大小:单张图片小于 30 MB。请求体大小不超过 64 MB。大文件请勿使用Base64编码。 +> +> * 图片数量: +> +> * 图生视频-首帧:1 张 +> +> * 图生视频-首尾帧:2 张 +> +> * Seedance 2.0 & 2.0 fast 多模态参考生视频:1\~9 张 + +*** + +content.**role **`string` `条件必填` + +图片的位置或用途。 + +> **注意** +> +> * **图生视频-首帧**、**图生视频-首尾帧**、**多模态参考生视频**(包括参考图、视频、音频)为 3 种互斥场景,**不可混用**。 +> +> * **多模态参考生视频**可通过提示词指定参考图片作为首帧/尾帧,间接实现“首尾帧+多模态参考”效果。若需严格保障首尾帧和指定图片一致,**优先使用图生视频-首尾帧**(配置 role 为 **first\_frame / last\_frame**)。 + +*** + +**图生视频-首帧** + +> 需要传入1个 image\_url 对象 + +* **字段role取值:** + + * **first\_frame 或不填** + +*** + +**图生视频-首尾帧** + +> 需要传入2个 image\_url 对象 + +* **字段role取值:** + + * 首帧图片对应的字段 role 为:**first\_frame**,必填 + + * 尾帧图片对应的字段 role 为:**last\_frame**,必填 + +*** + +**图生视频-参考图 ** + +> 可传入 1\~9 个 image\_url 对象 + +* **字段role取值**: + + * 每张参考图对应的字段 role 均为:**reference\_image**,必填 + + + + + +* **视频信息** `object` + +输入给模型的视频信息。仅 Seedance 2.0 & 2.0 fast 支持输入视频。 + +*** + +content.**type **`string` `必选` + +输入内容的类型,此处应为 **video\_url**。 + +*** + +content.**video\_url **`object` `必选` + +输入给模型的视频对象。 + +*** + +content.video\_url.**url **`string` `必选` + +视频URL、素材 ID。 + +* 视频 URL:填入视频的公网 URL。 + +* 素材 ID:用于视频生成的预置素材及虚拟人像视频的 ID,遵循格式:asset://\。可从[素材&虚拟人像库](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] + +*** + +content.**role **`string` `条件必填` + +视频的位置或用途。当前仅支持 **reference\_video**。 + + + + + +* **音频信息 **`object` + +输入给模型的音频信息。仅 Seedance 2.0 & 2.0 fast 支持输入音频。注意不可单独输入音频,应至少包含 1 个参考视频或图片。 + +*** + +content.**type **`string` `必选` + +输入内容的类型,此处应为 **audio\_url**。 + +*** + +content.**audio\_url **`object` `必选` + +输入给模型的音频对象。 + +*** + +content.audio\_url.**url **`string` `必选` + +音频 URL 、音频 Base64 编码、素材 ID。 + +* 音频 URL:填入音频的公网 URL。 + +* Base64 编码:将本地文件转换为 Base64 编码字符串,然后提交给大模型。遵循格式:data:audio/<音频格式>;base64,\,注意 <音频格式> 需小写,如 data:audio/wav;base64,{base64\_audio}。 + +* 素材 ID:用于视频生成的虚拟人的音频素材 ID,遵循格式:asset://\。可从[素材&虚拟人像库](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 **`string` `条件必填` + +音频的位置或用途。当前仅支持 **reference\_audio** 。 + + + +#### **service\_tier** `string` + + Seedance 2.0 & 2.0 fast 暂不支持 + + + +#### **generate\_audio **`boolean` + +> Seedance 2.0 & 2.0 fast 默认值: true + +控制生成的视频是否包含与画面同步的声音。 + +* true:模型输出的视频包含同步音频。模型会基于文本提示词与视觉内容,自动生成与之匹配的人声、音效及背景音乐。建议将对话部分置于双引号内,以优化音频生成效果。例如:男人叫住女人说:“你记住,以后不可以用手指指月亮。” + +* false:模型输出的视频为无声视频。 + +> **说明** +> +> 生成的有声视频均为单声道,和传入的音频声道数无关。 + +#### + +#### **draft **`boolean` + + Seedance 2.0 & 2.0 fast 暂不支持 + + + +#### **tools **`object[]` + +> 仅 Seedance 2.0 & 2.0 fast 支持 + +配置模型要调用的工具。 + +*** + +tools.**type **`string` + +指定使用的工具类型。 + +* web\_search:联网搜索工具。 + +> **说明** +> +> * 开启联网搜索后,模型会根据用户的提示词自主判断是否搜索互联网内容(如商品、天气等)。可提升生成视频的时效性,但也会增加一定的时延。 +> +> * 实际搜索次数可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 usage.tool\_usage.**web\_search** 字段获取,如果为 0 表示未搜索。 + + + +#### **resolution ** `string` + +> Seedance 2.0 & 2.0 fast 默认值:720p + +视频分辨率,取值范围: + +* 480p + +* 720p + + + +#### **ratio **`string` + +> Seedance 2.0 & 2.0 fast 默认值: adaptive + +生成视频的宽高比例。不同宽高比对应的宽高像素值见下方表格。 + +* 16:9 + +* 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` + +> Seedance 2.0 & 2.0 fast 默认值:5 + +生成视频时长,仅支持整数,单位:秒。 + +取值范围: + +* \[4,15] 或设置为-1 + +> **配置方法** +> +> * 指定具体时长:支持有效范围内的任一整数。 +> +> * 智能指定:设置为 -1,表示由模型在有效范围内自主选择合适的视频长度(整数秒)。实际生成视频的时长可通过 [查询视频生成任务 API](https://www.volcengine.com/docs/82379/1521309?lang=zh) 返回的 **duration** 字段获取。注意视频时长与计费相关,请谨慎设置。 + + + +#### **frames** `integer` + +Seedance 2.0 & 2.0 fast 暂不支持 + + + +#### **camera\_fixed** `boolean` + + 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 **`object[]` + +> 仅 Seedance 2.0 & 2.0 fast 支持 + +配置模型要调用的工具。 + +*** + +tools.**type **`string` + +指定使用的工具类型。 + +* web\_search:联网搜索工具。 + + + +#### **usage** `object` + +本次请求的 token 用量。 + +*** + +usage.**completion\_tokens** `integer` + +模型输出视频花费的 token 数量。 + +*** + +usage.**total\_tokens** `integer` + +本次请求消耗的总 token 数量。 + +*** + +usage.**tool\_usage **`object` + +> 仅 Seedance 2.0 & 2.0 fast 支持 + +使用工具的用量信息。 + +*** + +usage.tool\_usage.**web\_search **`integer` + +实际调用联网搜索工具的次数,仅开启联网搜索时返回。 + + + +# 调用简介及示例 + +## 流程简介 + +任务接口是异步接口,视频生成任务流程 + +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) + > + > * 宽高长度(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] + + + + > **注意**: + > + > 有关提交流程、承诺函签署所需材料的具体信息,请联系您的销售代表了解详情。 + +3. 方舟将对您提供的素材进行审核,通过审核的素材将被上传至虚拟人像库。 + +4. 入库后,每个人物组素材将通过以下示例中的形式返回,您可解压后查看: + + ![Image Token: PKu6b3391oUbVKxxEGjchxBVnbg](images/PKu6b3391oUbVKxxEGjchxBVnbg.png) + +示例中: + +* Andy 为您提交的人物名称 + +* group-20260310035119-9mzqn 为该人物组的 ID + +* 解压后,可查看每张素材的 Asset ID,如: + +![Image Token: VV0ybrxNfouEhZxTjqCcX1epnzb](images/VV0ybrxNfouEhZxTjqCcX1epnzb.png) + +* 您可按 `asset: //` 规则拼接 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) + +* 仅支持使用已入库素材生成视频。 diff --git a/docs/archive/old-「保密信息」【申请权限填客户名称】Assets API 参考文档(邀测用户版).md b/docs/archive/old-「保密信息」【申请权限填客户名称】Assets API 参考文档(邀测用户版).md new file mode 100644 index 0000000..75cb4f3 --- /dev/null +++ b/docs/archive/old-「保密信息」【申请权限填客户名称】Assets API 参考文档(邀测用户版).md @@ -0,0 +1,1201 @@ +# 「⚠️保密信息」【申请权限填客户名称】Assets API 参考文档(邀测用户版) + +本文介绍素材资产(Assets)API 接口的参数。您可以使用以下 Assets API 接口创建、管理个人人像素材资产。 + +> 本文档仅限预览及邀测用户使用: +> +> * 不承诺正式 API 上线100%一致。 +> +> * 仅限邀测用户阅读,请勿截图/分享给其他人员。 +> +> * 您需确保上传的虚拟人像符合以下条件: +> +> * 您合法拥有该素材,并享有完整的使用及处分权限。素材不包含未获授权的第三方商标、标识类内容。 +> +> * 素材不得与任何自然人肖像或形象雷同,素材不存在抄袭、盗用情形,不会侵害任何第三方的人格权、知识产权等合法权益。 +> +> * 素材不包含违反法规、违背公序良俗、危害国家安全的内容。 + + + +# 素材资产(Assets)API 接口功能 + +> **素材资产的概念说明:** +> +> * **Asset(素材资产)**:一个素材文件(图片),是方舟 Seedance 2.0 系列模型可直接用于推理的可信资产。 +> +> * 仅需入库推理需使用的素材资产,不需使用的素材资产请勿入库。 +> +> * 仅可使用已入库素材资产的 Id (Asset ID) 进行视频生成,同一形象未入库素材无法使用。 +> +> * **Asset Group(素材资产组合)**:单个素材文件为一个 Asset,每个 Asset 属于一个 Asset Group。 +> +> * 可以使用素材组自由管理素材,例如可将同一人物、同一工作室或项目组的素材放入同一素材组合进行管理。 + +**Asset (Group) 创建接口:** + +1. CreateAssetGroup:创建素材资产组合。**首次创建素材资产组合时需在控制台签署授权函,详情参考 [ 私域虚拟人像素材库 (WIP)](https://bytedance.larkoffice.com/wiki/RtHgwpJgviwFXLkQ9hLcRooEnVe)** + +2. CreatAsset:创建素材资产。该接口可用于上传个人素材资产,创建素材资产后可利用返回字段中的素材 **Id (需处于 `active` 状态)**用于 Seedance 2.0 系列模型生成视频。 + + + +**Asset (Group) 管理接口:** + +* ListAssetGroups:查询素材资产组合列表。 + +* ListAssets:查询素材资产列表。 + +* GetAsset:查询素材资产信息。 + +* GetAssetGroup:查询素材资产组合信息。 + +* UpdateAssetGroup:更新素材资产组合信息。 + +* UpdateAsset:更新素材资产信息。 + + + +# 鉴权方式 + +调用素材资产(Assets)API 接口需使用 Access Key 鉴权,详情参考 [获取 API 访问密钥(AK/SK)](https://www.volcengine.com/docs/6257/64983?lang=zh)。 + + + +# 限流要求 + +* 并发数限制:账号下同一时刻在处理中的任务数量上限,超过此限制的任务将进入队列等待处理。**同时进行处理的asset创建任务不超过30。** + +* QPS 限流: API接口每秒查询请求的总数上限。超过此限制的查询请求会报错。 + +| 接口名 | 账号维度的 QPS 限流 | +| ---------------- | ------------ | +| CreateAssetGroup | 30 | +| CreateAsset | 30 | +| ListAssetGroups | 30 | +| ListAssets | 30 | +| GetAsset | 100 | +| GetAssetGroup | 100 | +| UpdateAsset | 30 | +| UpdateAssetGroup | 30 | + +# CreateAssetGroup + +> **POST **/open/CreateAssetGroup + +创建 Asset Group(素材资产组合)组合,用作素材资产管理。 + +> **首次创建 Asset Group(素材资产组合)需在控制台签署授权函,详情参考 [ 私域虚拟人像素材库 (WIP)](https://bytedance.larkoffice.com/wiki/RtHgwpJgviwFXLkQ9hLcRooEnVe)** + +### **Name** `string` `必填` + +Asset Group(素材资产组合)的名称,上限为 64 字符。 + +*** + +### **Description** `string` + +Asset Group(素材资产组合)的描述,上限为 300 字符。 + +*** + +### **GroupType **`string` + +Asset Group(素材资产组合)的类型。可选值: + +* AIGC:虚拟人像。 + +*** + +### **ProjectName **`string`** ** + +资源所属的项目名称,默认值为`default`。 + +若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。 + +*** + + + +## 返回参数 + +### **Id** `string` + +Asset Group(素材资产组合)的 Id。 + +*** + +返回示例: + +```bash +{ + "Id": "group-2026**********-*****" +} +``` + + + +# CreateAsset + +> **POST **/open/CreateAsset + +向指定的Asset Group(素材资产组合)内创建Asset(素材资产)。 + + + +## 请求参数 + +### **GroupId** `string` `必填` + +Asset(素材资产)所属的 Asset Group(素材资产组合)的 Id。 + +*** + +### **URL** `string` `必填` + +传入的Asset(素材资产)的公共可访问地址。 + +*** + +### **Name** `string` + +Asset(素材资产)的名称,上限为64个字符。 + +*** + +### **AssetType `string` `必填`** + +Asset(素材资产)的类型,当前仅支持传入图像。可选值: + +* Image:Asset(素材资产)的类型为图像。 + +> **传入图像的要求说明** +> +> * 格式:jpeg、png、webp、bmp、tiff、gif、heic/heif +> +> * 宽高比(宽/高): (0.4, 2.5) +> +> * 宽高长度(px):(300, 6000) +> +> * 大小:单张图片小于 30 MB。 + +*** + +### **ProjectName** `string` + +资源所属的项目名称,默认值为`default`。 + +若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。 + +> 需要和待传入的 Asset Group(素材资产组合)的 **ProjectName **保持一致。 + +*** + + + +## 返回参数 + +### **Id **`string` + +Asset(素材资产)的 Id。 + +*** + +返回示例: + +```bash +{ + "Id": "Asset-2026**********-*****" +} +``` + + + +# ListAssets + +> **POST **/open/ListAssets + +查询符合筛选条件的Assets(素材资产)列表。 + +## 请求参数 + +### **Filter** `object` `必填` + +搜索的过滤条件。 + +*** + +Filter.**GroupIds** `array` + +Asset(素材资产)所属的 Asset Group(素材资产组合)的 Id。 + +*** + +Filter.**GroupType** `string` `必填` + +Asset Group(素材资产组合)的类型。可选值: + +* AIGC:虚拟人像。 + +*** + +Filter.**Statuses** `array`** ** + +任务状态。 + +* Active:素材资产(Asset)已处理完毕,可以使用。 + +* Processing:素材资产(Asset)正在预处理,无法使用。 + +* Failed:素材资产(Asset)处理失败。 + +*** + +Filter.**Name** `string`** ** + +Asset(素材资产)的名称,上限为64个字符。 + +*** + +### **PageNumber** `int (i64)` `必填` + +搜索页码,可用于列表分页功能,从 1 开始。例如:"page\_number": 1,即返回第一页的搜索结果。 + +*** + +### **PageSize** `int (i64)` `必填` + +每页搜索结果的数量,上限为100。 + +*** + +### **SortBy** `string` + +用于排序的字段名称,默认值 `createTime`。支持以下类型: + +* CreateTime:根据创建时间排序。 + +* UpdateTime:根据更新时间排序。 + +* GroupId:根据资产素材组的 Id 排序。 + +*** + +### **SortOrder** `string` + +排序顺序,默认值 `Desc`。可选值: + +* Desc:降序 + +* Asc:升序 + +*** + +### **ProjectName** `string` + +资源所属的项目名称,默认值为`default`。 + +若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。 + +*** + + + +## 返回参数 + +### **Items** `array[]` + +符合筛选条件的Asset(素材资产)数组。 + +*** + +Items.**Id** `string` + +Asset(素材资产)的 Id。 + +*** + +Items.**name** `string` + +Asset(素材资产)的名称,上限为64个字符。 + +*** + +Items.**URL** `string` + +Asset(素材资产)的公共可访问地址。 + +*** + +Items.**GroupId** `string` + +Asset(素材资产)所属的 Asset Group(素材资产组合)的 Id。 + +*** + +Items.**AssetType** `string` + +Asset(素材资产)的类型。 + +* Image:Asset(素材资产)的类型为图像。 + +*** + +Items.**Status** `string` + +任务状态。 + +* Active:素材资产(Asset)已处理完毕,可以使用。 + +* Processing:素材资产(Asset)正在预处理,无法使用。 + +* Failed:素材资产(Asset)处理失败。 + +*** + +Items.**Error** `object` + +错误信息。 + +*** + +Items.Error.**Code** `string` + +错误码。 + +*** + +Items.Error.**Message** `string` + +错误信息。 + +*** + +Items.**ProjectName** `string` + +资源所属的项目名称。 + +*** + +Items.**CreateTime **`string` + +创建时间。 + +*** + +Items.**UpdateTime **`string` + +更新时间。 + +*** + +### **TotalCount **`int (i64)` + +返回总数。 + +*** + +### **PageNumber **`int (i64)` + +返回的页数。 + +*** + +### **PageSize **`int (i64)` + +每页搜索结果的数量,上限为100。 + + + +# ListAssetGroups + +> **POST **/open/ListAssetGroups + +查询符合筛选条件的Asset Groups(素材资产组合)列表。 + +## 请求参数 + +### **Filter** `object` `必填` + +搜索的过滤条件。 + +*** + +Filter.**name** `string` + +Asset Group(素材资产组合)的名称,上限为64个字符。 + +*** + +Filter.**GroupIds** `array` + +Asset(素材资产)所属的 Asset Group(素材资产组合)的 Id。 + +*** + +Filter.**GroupType** `string`** **`必填` + +Asset Group(素材资产组合)的类型。可选值: + +* AIGC:虚拟人像。 + +*** + +### **PageNumber** `int (i64)` + +搜索页码,可用于列表分页功能,从 1 开始。例如:"page\_number": 1,即返回第一页的搜索结果。 + +*** + +### **PageSize** `int (i64)` + +每页搜索结果的数量,上限为100。 + +*** + +### **SortBy** `string` + +用于排序的字段名称,默认值 `createTime`。支持以下类型: + +* CreateTime:根据创建时间排序。 + +* UpdateTime:根据更新时间排序。 + +*** + +### **SortOrder** `string` + +排序顺序,默认值 `Desc`。可选值: + +* Desc:降序 + +* Asc:升序 + +*** + +### **ProjectName** `string` + +资源所属的项目名称,默认值为`default`。 + +若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。 + +*** + + + +## 返回参数 + +### **TotalCount **`int (i64)` + +返回的 Asset Group(素材资产组合)的总数。 + +*** + +### **Items** `array[]` + +符合筛选条件的 Asset Group(素材资产组合)数组。 + +*** + +Items.**Id** `string` + +Asset Group(素材资产组合)的 Id。 + +*** + +Items.**Name** `string` + +Asset Group(素材资产组合)的名称,上限为64个字符。 + +*** + +Items.**Title** `string` + +Asset Group(素材资产组合)的标题。 + +> 即将下线,请直接使用参数 Name + +*** + +Items.**Description** `string` + +Asset Group(素材资产组合)的描述,上限为 300 字符。 + +*** + +Items.**GroupType** `string` + +Asset Group(素材资产组合)的类型。 + +* AIGC:虚拟人像。 + +*** + +Items.**ProjectName** `string` + +资源所属的项目名称。 + +*** + +Items.**CreateTime** `string` + +创建时间。 + +*** + +Items.**UpdateTime** `string` + +更新时间。 + +*** + +### **PageNumber **`int (i64)` + +返回的页数。 + +*** + +### **PageSize **`int (i64)` + +每页搜索结果的数量,上限为100。 + +*** + + + +# GetAssetGroup + +> **POST **/open/GetAssetGroup + +获取单个Asset Group(素材资产组合)信息。 + +## 请求参数 + + + +### **Id **`string` `必填` + +Asset Group(素材资产组合)的 Id + +*** + +### **ProjectName **`string` + +需要查询的 Asset Group(素材资产组合)所属的项目名称,默认值为`default`。 + +若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。 + +*** + + + +## 返回参数 + +### **Id** `string` + +Asset Group(素材资产组合)的 Id。 + +*** + +### **Name** `string` + +Asset Group(素材资产组合)的名称,上限为64个字符。 + +*** + +### **Title** `string` + +Asset Group(素材资产组合)的标题。 + +> 即将下线,请直接使用参数 Name + +*** + +### **Description** `string` + +Asset Group(素材资产组合)的描述,上限为 300 字符。 + +*** + +### **GroupType **`string` + +Asset Group(素材资产组合)的类型。 + +* AIGC:虚拟人像 + +*** + +### **ProjectName **`string`** ** + +资源所属的项目名称。 + +*** + +### **CreateTime **`string` + +创建时间。 + +*** + +### **UpdateTime **`string` + +更新时间。 + +*** + +## + +# GetAsset + +> **POST **/open/GetAsset + +获取单个Asset(素材资产)信息。 + +## 请求参数 + + + +### **Id **`string` `必填` + +Asset(素材资产)的 Id。 + +*** + +### **ProjectName **`string` + +需要查询的 Asset(素材资产)所属的项目名称,默认值为`default`。 + +若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。 + +*** + + + +## 返回参数 + +### **Id **`string` + +Asset(素材资产)的 Id。 + +*** + +### **Name** `string` + +Asset(素材资产)的名称,上限为64个字符。 + +*** + +### **URL **`string` + +Asset(素材资产)的访问地址。 + +*** + +### **AssetType **`string` + +Asset(素材资产)的类型。 + +* Image:Asset(素材资产)的类型为图像。 + +*** + +### **GroupId** `string` + +Asset(素材资产)所属的 Asset Group(素材资产组合)的 Id。 + +*** + +### **Status** `string` + +任务状态。 + +* Active:素材资产(Asset)已处理完毕,可以使用。 + +* Processing:素材资产(Asset)正在预处理,无法使用。 + +* Failed:素材资产(Asset)处理失败。 + +*** + +### **Error** `object` + +错误信息。 + +*** + +Error.**Code** `string` + +错误码。 + +*** + +Error.**Message** `string` + +错误信息。 + +*** + +### **CreateTime **`string` + +创建时间。 + +*** + +### **UpdateTime ** `string` + +更新时间。 + +*** + +### **ProjectName** `string` + +资源所属的项目名称。 + +*** + +## + +# **UpdateAssetGroup** + +> **POST **/open/UpdateAssetGroup + +更新单个 Asset Group(素材资产组合)信息。当前仅支持更新 Asset Group(素材资产组合)的 Name 和 Description。 + +## 请求参数 + +### **Id **`string` `必填` + +需要更新的 Asset Group(素材资产组合)的 Id + +*** + +### **Name **`string` + +需要更新的 Asset Group(素材资产组合)的新名称,上限为64个字符。 + +*** + +### **Description** `string` + +需要更新的 Asset Group(素材资产组合)的新描述,上限为 300 字符。 + +*** + +### **ProjectName** `string` + +需要更新的 Asset Group(素材资产组合)所属的项目名称,默认值为`default`。 + +若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。 + +*** + + + +## 返回参数 + +### **Id** `string` + +Asset Group(素材资产组合)的 Id。 + +*** + + + + + +# **UpdateAsset** + +> **POST **/open/UpdateAsset + +更新单个Asset(素材资产)信息。当前仅支持更新Asset(素材资产)的 Name。 + +## 请求参数 + +### **Id **`string` `必填` + +需要更新的 Asset(素材资产)的 Id + +*** + +### **Name **`string` + +需要更新的 Asset(素材资产)的新名称,上限为64个字符。 + +*** + +### **ProjectName** `string` + +需要更新的 Asset(素材资产)所属的项目名称,默认值为`default`。 + +若资源不在默认项目中,需填写正确的项目名称,获取项目名称,请查看 [文档](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)。 + +*** + + + +## 返回参数 + +### **Id** `string` + +Asset(素材资产)的 Id。 + +*** + + + +# 代码示例: + +> **以下示例为使用 Asset API 创建素材资产并用于视频生成的使用链路:** +> +> 1. **创建素材资产组合:**调用 **CreateAssetGroup** 接口创建一个素材资产组合(Asset Group),用于对同一项目或人物的素材进行统一管理。首次创建时需在控制台签署授权函。 +> +> 2. **上传素材资产并等待预处理完成:**调用 **CreateAsset** 接口上传图片素材,传入图片的公共访问URL及所属的Group ID,获得素材资产ID(Asset ID)。 +> 由于上传的素材资产需经过预处理后才能使用,可轮询调用 **GetAsset** 接口查询素材状态,直至状态变为 `Active`。若状态为 `Failed` 则表示处理失败。 +> +> 3. **在视频生成 API 中使用素材:**当素材资产状态为 `Active` 后,将素材ID按 `Asset://` 的格式拼接成URL,在视频生成API(如Seedance 2.0系列模型)的请求中,将该URL作为参考图像的 `image_url` 传入,即可使用该素材资产生成视频。 +> +> **API 鉴权方式区别说明** +> +> * **Asset API:**Access Key 鉴权,详情参考 [获取 API 访问密钥(AK/SK)](https://www.volcengine.com/docs/6257/64983?lang=zh)。 +> +> * **视频生成 API:**API Key 鉴权,详情参考 [管理 API Key](https://www.volcengine.com/docs/82379/1361424?lang=zh)。 +> +> **素材库[项目](https://www.volcengine.com/docs/82379/1359411?lang=zh#03ec4a65)(Project)隔离说明** +> +> * 向指定的 Asset Group(素材资产组合)内创建或查询 Asset(素材资产)时,需保证两者的 **ProjectName **一致 +> +> * Asset(素材资产)所属的 **ProjectName** 需与调用视频生成 API 接口时使用的 API key 所属的 **ProjectName** 一致 + +## 1. 创建素材资产组合 + +```go +package main + +import ( + "fmt" + + "github.com/bytedance/sonic" + "github.com/volcengine/volcengine-go-sdk/volcengine" + "github.com/volcengine/volcengine-go-sdk/volcengine/credentials" + "github.com/volcengine/volcengine-go-sdk/volcengine/session" + "github.com/volcengine/volcengine-go-sdk/volcengine/universal" +) + +func main() { + config := volcengine.NewConfig().WithCredentials(credentials.NewStaticCredentials("", "", "")).WithRegion("cn-beijing") + sess, _ := session.NewSession(config) + resp, err := universal.New(sess).DoCall( + universal.RequestUniversal{ + ServiceName: "ark", + Action: "CreateAssetGroup", + Version: "2024-01-01", + HttpMethod: universal.POST, + ContentType: universal.ApplicationJSON, + }, + //根据实际情况填写 + &map[string]any{ + "Name": "test", + "Description": "test", + "GroupType": "AIGC", + }, + ) + if err != nil { + return + } + if resp == nil { + return + } + respData, err := sonic.Marshal(resp) + fmt.Println(string(respData)) +} +``` + +返回示例 + +```json +{"ResponseMetadata":{"RequestId":"20260318155041036F7CB6362358FB40FC","Action":"CreateAssetGroup","Version":"2024-01-01","Service":"ark","Region":"cn-beijing"},"Result":{"Id":"group-2026**********-*****"}} +``` + + + +## 2. 上传素材资产并获取素材资产信息 + +```go +package main + +import ( + "errors" + "fmt" + "time" + + "github.com/bytedance/sonic" + "github.com/volcengine/volcengine-go-sdk/volcengine" + "github.com/volcengine/volcengine-go-sdk/volcengine/credentials" + "github.com/volcengine/volcengine-go-sdk/volcengine/session" + "github.com/volcengine/volcengine-go-sdk/volcengine/universal" +) + +const ( + region = "cn-beijing" + serviceName = "ark" + version = "2024-01-01" + + // 轮询配置 + pollInterval = 3 * time.Second + pollTimeout = 2 * time.Minute +) + +func main() { + // TODO: 替换为你的 AK / SK + ak := "" + sk := "" + + // TODO: 替换为你的实际参数 + groupID := "" + assetURL := "" + assetType := "Image" + projectName := "Default" + + config := volcengine.NewConfig(). + WithCredentials(credentials.NewStaticCredentials(ak, sk, "")). + WithRegion(region) + + sess, err := session.NewSession(config) + if err != nil { + fmt.Printf("create session failed: %v\n", err) + return + } + + client := universal.New(sess) + + // 1. 创建素材资产 + assetID, err := createAsset(client, groupID, assetURL, assetType, projectName) + if err != nil { + fmt.Printf("create asset failed: %v\n", err) + return + } + + fmt.Printf("asset created, AssetId = %s\n", assetID) + + // 2. 查询素材资产状态 + finalURL, err := waitForAssetActive(client, assetID, pollInterval, pollTimeout) + if err != nil { + fmt.Printf("poll asset failed: %v\n", err) + return + } + + fmt.Printf("asset is active, URL = %s\n", finalURL) +} + +// createAsset 调用 CreateAsset 并返回 AssetId +func createAsset(client *universal.Universal, groupID, url, assetType, projectName string) (string, error) { + resp, err := client.DoCall( + universal.RequestUniversal{ + ServiceName: serviceName, + Action: "CreateAsset", + Version: version, + HttpMethod: universal.POST, + ContentType: universal.ApplicationJSON, + }, + &map[string]any{ + "GroupId": groupID, + "URL": url, + "AssetType": assetType, + "ProjectName": projectName, + }, + ) + if err != nil { + return "", err + } + if resp == nil { + return "", errors.New("create asset response is nil") + } + + // 打印原始返回,便于排查 + respData, _ := sonic.Marshal(resp) + fmt.Printf("CreateAsset response: %s\n", string(respData)) + + assetID := extractString(resp, "Result", "Id") + if assetID == "" { + assetID = extractString(resp, "Result", "AssetId") + } + if assetID == "" { + assetID = extractString(resp, "Id") + } + if assetID == "" { + assetID = extractString(resp, "AssetId") + } + + if assetID == "" { + return "", fmt.Errorf("cannot find AssetId in response: %s", string(respData)) + } + + return assetID, nil +} + +// waitForAssetActive 查询 GetAsset,直到 Active / Failed / 超时 +func waitForAssetActive(client *universal.Universal, assetID string, interval, timeout time.Duration) (string, error) { + deadline := time.Now().Add(timeout) + + for { + if time.Now().After(deadline) { + return "", fmt.Errorf("polling timeout after %v, assetID=%s", timeout, assetID) + } + + status, url, errMsg, err := getAssetStatus(client, assetID) + if err != nil { + return "", err + } + + fmt.Printf("asset status: %s\n", status) + + switch status { + case "Processing": + time.Sleep(interval) + continue + case "Active": + if url == "" { + return "", fmt.Errorf("asset is Active but URL is empty, assetID=%s", assetID) + } + return url, nil + case "Failed": + if errMsg == "" { + errMsg = "unknown asset processing error" + } + return "", fmt.Errorf("asset processing failed: %s", errMsg) + default: + // 若返回其他状态,保守处理为继续查询 + fmt.Printf("unexpected status %q, continue polling...\n", status) + time.Sleep(interval) + } + } +} + +// getAssetStatus 调用 GetAsset,返回 Status / URL / Error +func getAssetStatus(client *universal.Universal, assetID string) (status, url, errMsg string, err error) { + resp, err := client.DoCall( + universal.RequestUniversal{ + ServiceName: serviceName, + Action: "GetAsset", + Version: version, + HttpMethod: universal.POST, + ContentType: universal.ApplicationJSON, + }, + &map[string]any{ + "Id": assetID, + }, + ) + if err != nil { + return "", "", "", err + } + if resp == nil { + return "", "", "", errors.New("get asset response is nil") + } + + // 打印原始返回,便于排查 + respData, _ := sonic.Marshal(resp) + fmt.Printf("GetAsset response: %s\n", string(respData)) + + // 兼容不同层级的字段位置 + status = extractString(resp, "Result", "Status") + if status == "" { + status = extractString(resp, "Status") + } + + url = extractString(resp, "Result", "URL") + if url == "" { + url = extractString(resp, "URL") + } + + errMsg = extractString(resp, "Result", "Error") + if errMsg == "" { + errMsg = extractString(resp, "Error") + } + + return status, url, errMsg, nil +} + +// extractString 从响应中按层级安全提取字符串 +func extractString(data any, keys ...string) string { + current := data + + for _, key := range keys { + switch v := current.(type) { + case map[string]any: + next, ok := v[key] + if !ok { + return "" + } + current = next + + case *map[string]any: + if v == nil { + return "" + } + next, ok := (*v)[key] + if !ok { + return "" + } + current = next + + default: + return "" + } + } + + switch v := current.(type) { + case string: + return v + case fmt.Stringer: + return v.String() + case nil: + return "" + default: + return fmt.Sprintf("%v", v) + } +} +``` + +返回示例 + +```json +CreateAsset response: {"ResponseMetadata":{"RequestId":"202603181520431F067112A17FC078A6DF","Action":"CreateAsset","Version":"2024-01-01","Service":"ark","Region":"cn-beijing"},"Result":{"Id":"Asset-2026**********-*****"}} +asset created, AssetId = asset-20260318072044-n8bcl +GetAsset response: {"ResponseMetadata":{"Service":"ark","Region":"cn-beijing","RequestId":"202603181520448A995106924553F77D0E","Action":"GetAsset","Version":"2024-01-01"},"Result":{"Name":"","GroupId":"group-2026**********-*****","CreateTime":"2026-03-18T07:20:44Z","ProjectName":"default","Id":"Asset-2026**********-*****","URL":"","AssetType":"Image","Status":"Processing","UpdateTime":"2026-03-18T07:20:44Z"}} +asset status: Processing +GetAsset response: {"Result":{"UpdateTime":"2026-03-18T07:20:47Z","ProjectName":"default","Id":"Asset-2026**********-*****","CreateTime":"2026-03-18T07:20:44Z","Name":"","URL":"","AssetType":"Image","GroupId":"group-2026**********-*****","Status":"Processing"},"ResponseMetadata":{"Version":"2024-01-01","Service":"ark","Region":"cn-beijing","RequestId":"2026031815204766FE2BA543E6FF666F66","Action":"GetAsset"}} +asset status: Processing +GetAsset response: {"ResponseMetadata":{"Version":"2024-01-01","Service":"ark","Region":"cn-beijing","RequestId":"202603181520511F067112A17FC078A75A","Action":"GetAsset"},"Result":{"Name":"","URL":"https://ark-media-asset-stg.tos-cn-beijing.volces.com/xxxx","AssetType":"Image","Status":"Active","Id":"Asset-2026**********-*****","GroupId":"group-2026**********-*****","CreateTime":"2026-03-18T07:20:44Z","UpdateTime":"2026-03-18T07:20:47Z","ProjectName":"default"}} +asset status: Active +asset is active, URL = https://ark-media-asset-stg.tos-cn-beijing.volces.com/xxxx +``` + + + +更多语言的示例代码详见: + +> 注意替换 Demo 中的 AK与SK,若需调用其他接口如 ListAssets,需替换 ACTION 与对应请求参数。 + +| **Python** | 创建素材资产组合: 上传素材资产并获取素材资产信息: | +| ---------- | --------------------------- | +| **Java** | 创建素材资产组合: 上传素材资产并获取素材资产信息: | +| **PHP** | 创建素材资产组合: 上传素材资产: | + + + +## 3. 素材资产用于视频生成 + +当上传的素材资产状态为 `Active` 时,可将素材 Id 按 `Asset: //` 的规则拼接 URL,以在 **视频生成 API **中使用对应的素材资产生成视频: + +```json + { + "type": "image_url", + "image_url": { + "url": "Asset://Asset-2026**********-*****" + }, + "role": "reference_image" + }, +``` + +使用素材资产生成视频的具体调用方式请参考[ 【申请权限填客户名称】Seedance 2.0 & 2.0 fast API文档(邀测用户版)](https://bytedance.larkoffice.com/wiki/SANpwJ9bgiKgrykLaMTcAB0InWc#share-ONSwd51ezoXCJqxkAm2cIC61nMX)。 + diff --git a/docs/archive/prd.md b/docs/archive/prd.md new file mode 100644 index 0000000..a598312 --- /dev/null +++ b/docs/archive/prd.md @@ -0,0 +1,1611 @@ +# 产品需求文档 (PRD) + +## 1. 项目概述 + +- **项目名称**: Jimeng Clone — AI 视频生成平台 +- **一句话描述**: 1:1 还原即梦平台 (jimeng.jianying.com) 的 AI 视频生成页面,包含完整的用户认证体系、视频生成输入界面、后台管理系统和用户个人中心 +- **目标用户**: 前端开发学习者、AI 视频产品原型验证团队、平台管理员 +- **项目范围**: 视频生成输入栏(InputBar)及其交互逻辑 + Django 后端 API + 用户登录注册 + 后台管理(生成秒数统计与限制) + 用户个人中心(消费概览与记录) + +### 开发阶段划分 + +| 阶段 | 范围 | 状态 | +|------|------|------| +| **Phase 1** | 纯前端视频生成输入界面(InputBar、工具栏、上传、模式切换) | ✅ 已完成 | +| **Phase 2** | Django 后端 + 用户认证 + 前端路由 + 管理后台(基于调用次数) | ✅ 已完成 | +| **Phase 3** | 计量单位变更(次数→秒数)+ 管理后台重做(多页面 Sidebar)+ 用户个人中心 | 🔲 待开发 | + +> **当前迭代范围**: Phase 3。Phase 1 和 Phase 2 功能已完成。Phase 3 在 Phase 2 基础上进行重大重构和新增功能。文档中 `[Phase 3]` 标记用于区分本次迭代的新增/修改内容。 + +### Phase 3 核心变更摘要 + +1. **计量单位变更**: 所有「调用次数」改为「生成秒数」,用户每次生成视频消耗的是秒数(= 视频时长),不是次数 +2. **后台管理系统重做**: 从单页面改为多页面 + 左侧 Sidebar 导航(仪表盘/用户管理/消费记录/系统设置) +3. **新增用户个人中心** `/profile`: 消费概览 + 消费记录 + 趋势迷你图 +4. **图表库引入**: 使用 ECharts(echarts + echarts-for-react)展示趋势图、排行榜、环形进度条 +5. **设计升级**: 管理后台深色主题(Linear/Vercel 风格)、骨架屏加载、页面过渡动画 + +## 2. 功能需求 + +### 2.1 核心功能(P0) + +#### 已有前端功能 + +- [x] **深色主题全屏页面** — 背景色 `#0a0a0f`,页面无滚动,输入栏固定在底部居中,最大宽度 ~900px +- [x] **InputBar 容器** — 背景 `#16161e`,边框 `1px solid #2a2a38`,圆角 `20px`,内部分为上半区(上传+输入)和下半区(工具栏) +- [x] **提示词多行文本输入框** — 自适应高度(min 1行,max ~6行),支持换行输入,placeholder 根据当前模式动态切换 +- [x] **全能参考模式(默认模式)** — 左侧 [+ 参考内容] 上传按钮,支持上传 1-5 张图片/视频文件;上传后显示缩略图网格,每张标注「图片1」「图片2」等序号,每个缩略图右上角有 × 关闭按钮;上传区与文本输入框左右排列 +- [x] **首尾帧模式** — 上传区变为 [首帧缩略图] ↔ [+ 尾帧] 双框布局,中间双向箭头图标连接;首帧和尾帧各上传一张图片 +- [x] **模式切换下拉** — 工具栏中「全能参考/首尾帧」下拉菜单,点击切换模式,切换时联动:上传区 UI、比例选项、时长默认值 +- [x] **工具栏按钮行** — 一行横排按钮,透明背景,灰色文字 `#8a8a9a`,hover 时微亮背景 +- [x] **发送按钮** — 圆形按钮,内含上箭头图标;无内容时灰色不可点击,有内容(文字或上传文件)时变为蓝色 `#00b8e6` 可点击 + +#### [Phase 2] 用户认证系统 + +- [x] **用户注册页面** — 前端注册页 `/register`,包含用户名、邮箱、密码、确认密码字段,提交后调用后端注册 API +- [x] **用户登录页面** — 前端登录页 `/login`,包含用户名/邮箱、密码字段,登录成功后获取 JWT Token 并存储到 localStorage +- [x] **JWT 认证机制** — 后端签发 Access Token(有效期 2 小时)+ Refresh Token(有效期 7 天),前端请求自动附加 Authorization Bearer Token +- [x] **登录状态保持** — 前端全局 Auth 状态管理(Zustand),未登录用户自动跳转到登录页,已登录用户显示用户信息和退出按钮 +- [x] **Token 自动刷新** — Access Token 过期时自动使用 Refresh Token 刷新,刷新失败则跳转到登录页 + +#### [Phase 2] Django 后端服务 + +- [x] **Django 项目初始化** — 在 `backend/` 子目录创建 Django 项目,连接 MySQL 云数据库 +- [x] **RESTful API** — 使用 Django REST Framework (DRF) 提供用户认证、视频生成记录等 API +- [x] **CORS 配置** — 允许前端开发服务器(localhost:5173)跨域访问后端 API + +#### [Phase 3] 计量单位变更(次数 → 秒数) + +- [ ] **用户配额字段变更** — User 模型的 `daily_limit` / `monthly_limit`(次数)改为 `daily_seconds_limit` / `monthly_seconds_limit`(秒数),默认值分别为 600秒/日、6000秒/月 +- [ ] **消费记录增加秒数字段** — GenerationRecord 模型新增 `seconds_consumed` 字段(FloatField),记录每次生成消耗的秒数(= 视频时长 duration) +- [ ] **配额检查逻辑变更** — 后端配额检查从「今日调用次数 < daily_limit」改为「今日消费秒数 < daily_seconds_limit」 +- [ ] **前端展示变更** — 所有显示「剩余次数」的地方改为「剩余秒数」,UserInfoBar 组件配额显示改为秒数 + +#### [Phase 3] 后台管理系统重做 + +- [ ] **管理后台布局** — 采用左侧 Sidebar + 右侧内容区的经典管理后台布局,Sidebar 固定宽度 240px,支持折叠 +- [ ] **Sidebar 导航菜单** — 包含 4 个导航项:仪表盘、用户管理、消费记录、系统设置,当前项高亮,使用 react-router 嵌套路由 +- [ ] **管理后台路由** — `/admin` 为管理后台根路由(重定向到 `/admin/dashboard`),子路由:`/admin/dashboard`、`/admin/users`、`/admin/records`、`/admin/settings` + +#### [Phase 3] 用户个人中心 + +- [ ] **个人中心页面** — 新增 `/profile` 路由,已登录用户可访问,展示个人消费概览和历史记录 +- [ ] **消费概览卡片** — 显示已用秒数/总额度(环形进度条,使用 ECharts gauge)、今日已用/日限额、本月已用/月限额 + +### 2.2 重要功能(P1) + +#### 已有前端功能 + +- [x] **视频生成下拉按钮** — 蓝色文字 `#00b8e6` + 视频图标 + 下拉箭头,点击展开下拉菜单(菜单项仅 UI 展示,如"视频生成"/"图片生成"等) +- [x] **模型选择按钮** — 显示「Seedance 2.0」+ 钻石图标,点击展开模型选择下拉(仅 UI 展示,如 Seedance 2.0 / Seedance 2.0 Fast) +- [x] **比例选择按钮** — 全能参考模式下显示屏幕图标 + 当前比例值,点击弹出选项:`16:9` / `9:16` / `1:1` / `21:9` / `4:3` / `3:4`,默认 `21:9`;首尾帧模式下显示「自动匹配」不可选择 +- [x] **时长选择按钮** — 时钟图标 + 当前时长值,点击弹出选项:`5s` / `10s` / `15s`;全能参考模式默认 `15s`,首尾帧模式默认 `5s` +- [x] **@ 按钮** — 仅在全能参考模式下显示,点击插入 `@` 符号到文本输入框光标位置 +- [x] **文件上传交互** — 点击上传区触发文件选择器,accept 为 `image/*,video/*`;上传后生成本地预览缩略图;支持拖拽上传 +- [x] **上传数量限制** — 全能参考模式最多 5 张,超出时 toast 提示;首尾帧模式首帧/尾帧各 1 张 + +#### [Phase 2] 后台管理与生成接入 + +- [x] **Django Admin 集成** — 启用 Django Admin 面板(`/admin/`),管理员可查看和管理所有用户、视频生成记录 +- [x] **发送按钮接入后端** — 点击发送按钮时,调用后端 `/api/v1/video/generate` 接口(需登录),后端记录调用并检查配额 + +#### [Phase 3] 仪表盘页面(`/admin/dashboard`) + +- [ ] **核心指标卡片** — 4 个统计卡片:总用户数、今日新增用户、今日消费秒数、本月消费秒数。每个卡片显示数值 + 环比变化百分比 + 趋势箭头(↑绿色/↓红色) +- [ ] **消费趋势折线图** — 使用 ECharts 折线图展示近 30 天每日消费秒数趋势,支持 tooltip 悬浮显示具体数值,X轴为日期,Y轴为秒数 +- [ ] **用户消费排行柱状图** — 使用 ECharts 水平柱状图展示消费 Top 10 用户(按本月消费秒数降序),柱状图标签显示用户名和秒数 +- [ ] **时间范围选择器** — 支持「今日 / 近7天 / 近30天 / 自定义时间范围」切换,图表数据联动更新 +- [ ] **图表 Mock 数据** — 开发阶段使用 30 天的真实结构 Mock 数据(随机波动、周末低谷),确保图表有真实感 + +#### [Phase 3] 用户管理页面(`/admin/users`) + +- [ ] **用户列表表格** — 分页展示所有用户(每页 20 条),列:头像、用户名、邮箱、注册时间、状态(启用/禁用)、日限额(秒)、月限额(秒)、今日消费(秒)、本月消费(秒)、操作 +- [ ] **搜索和筛选** — 支持按用户名/邮箱关键字搜索 + 按状态筛选(全部/启用/禁用) +- [ ] **配额编辑** — 每个用户行的操作列有「编辑配额」按钮,点击弹出模态框,可修改日限额秒数和月限额秒数 +- [ ] **用户状态管理** — 操作列有「启用/禁用」开关按钮,点击后调用 API 切换用户 `is_active` 状态 +- [ ] **用户详情抽屉** — 点击用户名展开右侧抽屉面板,显示用户详情 + 该用户近期消费记录列表 + +#### [Phase 3] 消费记录页面(`/admin/records`) + +- [ ] **消费明细表格** — 分页展示所有用户的消费记录,列:时间、用户名、消费秒数、视频描述(prompt 截断)、生成模式、状态 +- [ ] **时间范围筛选** — 日期选择器,支持选择起止日期筛选记录 +- [ ] **用户筛选** — 支持按用户名搜索筛选特定用户的消费记录 +- [ ] **导出功能** — 「导出 CSV」按钮,将当前筛选条件下的消费记录导出为 CSV 文件 + +#### [Phase 3] 系统设置页面(`/admin/settings`) + +- [ ] **全局默认配额设置** — 表单修改全局默认日限额秒数和月限额秒数(新注册用户自动获得此配额) +- [ ] **系统公告管理** — 公告文本编辑框 + 启用/禁用开关,启用后公告内容展示在用户端页面顶部 + +#### [Phase 3] 用户个人中心详细功能 + +- [ ] **消费记录列表** — 分页展示当前用户的消费记录,每条记录显示:时间、消费秒数、生成的视频描述(prompt)、生成模式、状态 +- [ ] **消费趋势迷你图** — 使用 ECharts Sparkline 样式展示近 7 天 / 近 30 天每日消费秒数趋势,可切换时间范围 +- [ ] **配额提示** — 当日额度消费超过 80% 时显示黄色警告提示,超过 100% 时显示红色禁用提示 + +### 2.3 锦上添花(P2) + +- [x] **下拉菜单动画** — 下拉展开/收起有 fade + slide 动画过渡 +- [x] **文本输入框自动聚焦** — 页面加载后自动 focus 到文本输入框 +- [x] **键盘快捷键** — `Ctrl/Cmd + Enter` 触发发送(等同点击发送按钮) +- [ ] **上传进度条** — 文件上传时缩略图上显示加载进度 +- [ ] **拖拽排序** — 全能参考模式下已上传的缩略图支持拖拽调整顺序 +- [x] **响应式适配** — 移动端窄屏下工具栏按钮文字隐藏只显示图标,输入栏宽度自适应 +- [ ] **Tooltip 提示** — 工具栏按钮 hover 显示功能说明 tooltip +- [ ] **[Phase 2] 忘记密码** — 邮箱验证码找回密码流程 +- [ ] **[Phase 2] 用户个人资料编辑** — 查看和修改个人信息(头像、昵称) +- [ ] **[Phase 3] 页面切换过渡动画** — 路由切换时使用 fade/slide 过渡动画,提升体验流畅度 +- [ ] **[Phase 3] 数据加载骨架屏** — 管理后台和个人中心的数据加载使用骨架屏(Skeleton)替代 loading spinner +- [ ] **[Phase 3] Sidebar 折叠模式** — 管理后台 Sidebar 支持折叠为图标模式,增大内容区宽度 + +## 3. 技术栈建议 + +### 3.1 现有技术栈(保留) + +| 层级 | 技术选型 | 说明 | +|------|---------|------| +| 前端框架 | React 18 + TypeScript | 函数组件 + Hooks | +| 构建工具 | Vite 5 | 极速 HMR,原生 ESM | +| UI 组件库 | @arco-design/web-react | 字节跳动设计系统,即梦同款 | +| 状态管理 | Zustand | 轻量、TypeScript 友好 | +| 样式方案 | CSS Modules + Arco Design Token | 深色主题定制 | +| 图标 | @arco-design/web-react/icon + 自定义 SVG | 工具栏图标 | +| 文件处理 | 浏览器原生 File API | 本地预览、缩略图生成 | + +### 3.2 [Phase 2] 已有前端依赖(保留) + +| 依赖 | 说明 | +|------|------| +| react-router-dom v7 | 前端路由(登录页、注册页、管理页) | +| axios | HTTP 请求库,支持拦截器实现 Token 自动附加和刷新 | + +### 3.3 [Phase 3] 新增前端依赖 + +| 依赖 | 说明 | +|------|------| +| echarts | 图表库核心,用于折线图、柱状图、环形图等数据可视化 | +| echarts-for-react | ECharts 的 React 封装组件,声明式使用图表 | + +> **组件优先级**: 如果 Arco Design 内置了对应组件(如 Table、Modal、Skeleton、DatePicker),优先使用 Arco 组件,ECharts 仅用于复杂图表。 + +### 3.4 [Phase 2] 后端技术栈(保留) + +| 层级 | 技术选型 | 说明 | +|------|---------|------| +| 后端框架 | Django 4.2+ (LTS) | Python Web 框架 | +| API 框架 | Django REST Framework (DRF) | RESTful API 开发 | +| 认证方案 | djangorestframework-simplejwt | JWT Token 签发与验证 | +| 数据库 | MySQL 8.0(阿里云 RDS) | 云数据库,已提供连接信息 | +| 数据库驱动 | mysqlclient | Django 官方推荐的 MySQL 驱动 | +| CORS | django-cors-headers | 跨域请求支持 | +| 后端管理 | Django Admin | 内置管理后台 | +| 部署 | Gunicorn + Nginx | 生产环境部署方案 | + +> **后端代码目录**: 所有后端代码放在项目根目录下的 `backend/` 子目录中。 + +### 3.5 数据库连接配置(不变) + +```python +# backend/config/settings.py +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': 'video_auto', + 'USER': 'ai_video', + 'PASSWORD': 'JogNQdtrd3WY8CBCAiYfYEGx', + 'HOST': 'rm-7xv1uaw910558p1788o.mysql.rds.aliyuncs.com', + 'PORT': '3306', + 'OPTIONS': { + 'charset': 'utf8mb4', + 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", + }, + } +} +``` + +## 4. 页面列表 + +### 4.1 [已有] 视频生成页 (`/`) + +**整体布局**: +``` +┌─────────────────────────────────────────────┐ +│ [Phase 3 修改] 顶部用户信息: │ +│ 用户名 | 剩余: 345s/600s(日) | [个人中心] [退出]│ +│ │ +│ 深色背景空白区域 │ +│ #0a0a0f │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ InputBar (底部固定) │ │ +│ │ max-width: 900px, 居中 │ │ +│ │ 背景: #16161e │ │ +│ │ 边框: #2a2a38, 圆角: 20px │ │ +│ │ │ │ +│ │ ┌──────┐ ┌──────────────────────┐ │ │ +│ │ │上传区 │ │ 提示词文本输入框 │ │ │ +│ │ │ │ │ │ │ │ +│ │ └──────┘ └──────────────────────┘ │ │ +│ │ │ │ +│ │ ─────────── 工具栏按钮行 ────────── │ │ +│ │ [视频生成▼][模型][模式▼][比例][时长] │ │ +│ │ [@] flex空白 [发送⬆] │ │ +│ └─────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +- [Phase 3 修改] 顶部用户信息区的配额显示从「剩余 N 次」改为「剩余 N 秒」,增加「个人中心」链接 +- [Phase 2] 需要登录才能访问,未登录重定向到 `/login` + +**全能参考模式 — 上传区细节**: +``` +┌──────────┐ +│ + │ ← 初始状态: [+ 参考内容] 按钮 +│ 参考内容 │ 虚线边框,点击触发上传 +└──────────┘ + +┌───┐┌───┐┌──────┐ +│图1││图2││ + │ ← 已上传状态: 缩略图网格 + 添加按钮 +│ × ││ × ││ │ 每张有序号标签和关闭按钮 +└───┘└───┘└──────┘ +``` + +**首尾帧模式 — 上传区细节**: +``` +┌──────┐ ┌──────┐ +│ 首帧 │ ↔ │+ 尾帧 │ ← 两个独立上传框 +│ │ │ │ 中间双向箭头 +└──────┘ └──────┘ +``` + +**工具栏按钮排布**: +``` +全能参考模式: +[🎬 视频生成 ▼] [💎 Seedance 2.0] [✨ 全能参考 ▼] [🖥 21:9] [🕐 15s] [@] ——flex空白—— [⬆ 发送] + +首尾帧模式: +[🎬 视频生成 ▼] [💎 Seedance 2.0] [🔀 首尾帧 ▼] [自动匹配] [🕐 5s] ——flex空白—— [⬆ 发送] +``` + +### 4.2 [已有] 登录页 (`/login`) + +``` +┌─────────────────────────────────────────────┐ +│ │ +│ 深色背景 #0a0a0f │ +│ │ +│ ┌──────────────────────┐ │ +│ │ Jimeng Clone │ │ +│ │ │ │ +│ │ 用户名/邮箱: [____] │ │ +│ │ 密码: [____] │ │ +│ │ │ │ +│ │ [ 登录 ] │ │ +│ │ │ │ +│ │ 没有账号?去注册 → │ │ +│ └──────────────────────┘ │ +│ │ +└─────────────────────────────────────────────┘ +``` + +- 表单验证:用户名/邮箱必填,密码最少 6 位 +- 登录成功后跳转到 `/`(视频生成页) +- 风格与主应用一致:深色主题,卡片式表单 + +### 4.3 [已有] 注册页 (`/register`) + +``` +┌─────────────────────────────────────────────┐ +│ │ +│ 深色背景 #0a0a0f │ +│ │ +│ ┌──────────────────────┐ │ +│ │ 创建账号 │ │ +│ │ │ │ +│ │ 用户名: [____] │ │ +│ │ 邮箱: [____] │ │ +│ │ 密码: [____] │ │ +│ │ 确认密码: [____] │ │ +│ │ │ │ +│ │ [ 注册 ] │ │ +│ │ │ │ +│ │ 已有账号?去登录 → │ │ +│ └──────────────────────┘ │ +│ │ +└─────────────────────────────────────────────┘ +``` + +- 表单验证:用户名 3-20 位、邮箱格式校验、密码最少 6 位、两次密码一致 +- 注册成功后自动登录并跳转到 `/` + +### 4.4 [Phase 3] 管理后台布局(`/admin/*`) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Jimeng Admin [管理员名] [退出] │ +├────────────┬─────────────────────────────────────────────────┤ +│ │ │ +│ SIDEBAR │ CONTENT AREA │ +│ 240px │ (根据子路由渲染不同页面) │ +│ │ │ +│ ┌──────┐ │ │ +│ │ 📊 │ │ │ +│ │仪表盘 │ │ │ +│ ├──────┤ │ │ +│ │ 👥 │ │ │ +│ │用户 │ │ │ +│ │管理 │ │ │ +│ ├──────┤ │ │ +│ │ 📋 │ │ │ +│ │消费 │ │ │ +│ │记录 │ │ │ +│ ├──────┤ │ │ +│ │ ⚙️ │ │ │ +│ │系统 │ │ │ +│ │设置 │ │ │ +│ └──────┘ │ │ +│ │ │ +│ ─────── │ │ +│ [返回首页] │ │ +│ │ │ +├────────────┴─────────────────────────────────────────────────┤ +│ Jimeng Clone Admin v3.0 │ +└──────────────────────────────────────────────────────────────┘ +``` + +**设计规范(管理后台专用)**: +- 整体风格参考 Linear / Vercel Dashboard,深色主题 +- 背景色 `#0a0a0f`,Sidebar 背景 `#111118`,内容区背景 `#0a0a0f` +- Sidebar 当前项背景 `rgba(255, 255, 255, 0.08)`,文字 `#ffffff` +- 非当前项文字 `#8a8a9a`,hover 背景 `rgba(255, 255, 255, 0.04)` +- 卡片背景 `#16161e`,边框 `1px solid #2a2a38`,圆角 `12px` +- 数据加载时显示 Arco Skeleton 骨架屏 + +### 4.5 [Phase 3] 仪表盘页面(`/admin/dashboard`) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ 仪表盘 [今日] [近7天] [近30天] [自定义]│ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ 总用户数 │ │今日新增 │ │今日消费 │ │本月消费 │ │ +│ │ 1,234 │ │ +23 │ │ 4,560s │ │ 89,010s │ │ +│ │ ↑12% │ │ ↑15% │ │ ↓5% │ │ ↑8% │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 消费趋势 (ECharts 折线图) │ │ +│ │ │ │ +│ │ Y轴: 秒数 ___/\___ │ │ +│ │ / \___/\ │ │ +│ │ ___/\___/ \___ │ │ +│ │ │ │ +│ │ X轴: 日期 (3/1 3/5 3/10 3/15 3/20 3/25 3/30) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 用户消费排行 Top 10 (ECharts 水平柱状图) │ │ +│ │ │ │ +│ │ user_a ████████████████████████ 2,340s │ │ +│ │ user_b ██████████████████ 1,890s │ │ +│ │ user_c ████████████████ 1,560s │ │ +│ │ ... │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- 仅 `is_staff=True` 的用户可访问 +- 统计卡片使用 Arco Card 组件 + 自定义样式 +- 折线图使用 ECharts `line` 类型,开启 `tooltip`、`dataZoom` 交互 +- 柱状图使用 ECharts `bar` 类型,水平方向,标签显示用户名和秒数 +- 时间范围选择器使用 Arco DatePicker.RangePicker +- 数据来源:`GET /api/v1/admin/stats` + +### 4.6 [Phase 3] 用户管理页面(`/admin/users`) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ 用户管理 │ +│ │ +│ [🔍 搜索用户名/邮箱___________] [状态: 全部 ▼] [刷新] │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 用户名 │ 邮箱 │ 注册时间 │ 状态 │ 日限额│ │ +│ │ │ │ │ │ (秒) │ │ +│ │ 月限额 │ 今日消费(秒) │ 本月消费(秒)│ 操作 │ │ +│ ├──────────┼──────────────┼──────────┼──────┼──────┤ │ +│ │ user_a │ a@test.com │ 3/1 │ ✅ │ 600 │ │ +│ │ 6000 │ 123 │ 2345 │[编辑][禁用] │ │ +│ ├──────────┼──────────────┼──────────┼──────┼──────┤ │ +│ │ user_b │ b@test.com │ 3/5 │ ❌ │ 300 │ │ +│ │ 3000 │ 0 │ 0 │[编辑][启用] │ │ +│ └──────────┴──────────────┴──────────┴──────┴──────┘ │ +│ │ +│ 共 56 条 [< 1 2 3 >] │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- 使用 Arco Table 组件(分页、排序) +- 搜索使用 Arco Input.Search +- 状态筛选使用 Arco Select +- 编辑配额使用 Arco Modal 弹窗 +- 用户详情使用 Arco Drawer 右侧抽屉 +- 数据来源:`GET /api/v1/admin/users` + +### 4.7 [Phase 3] 消费记录页面(`/admin/records`) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ 消费记录 [导出 CSV] │ +│ │ +│ [用户名搜索____] [时间: 2026-03-01 ~ 2026-03-12] [查询] │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 时间 │ 用户名 │ 消费秒数│ 视频描述 │ │ +│ │ │ │ │ (prompt截断) │ │ +│ │ 模式 │ 状态 │ │ +│ ├───────────────────┼────────┼────────┼─────────────┤ │ +│ │ 3/12 14:30:00 │ user_a │ 15s │ 一只猫在... │ │ +│ │ 全能参考 │ 已完成 │ │ +│ ├───────────────────┼────────┼────────┼─────────────┤ │ +│ │ 3/12 14:25:00 │ user_b │ 5s │ 日落海边... │ │ +│ │ 首尾帧 │ 生成中 │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ 共 1,234 条 [< 1 2 3 ... 62 >] │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- 使用 Arco Table 组件(分页) +- 时间范围筛选使用 Arco DatePicker.RangePicker +- 导出 CSV:前端调用 API 获取全部数据并生成 CSV 文件下载 +- 数据来源:`GET /api/v1/admin/records` + +### 4.8 [Phase 3] 系统设置页面(`/admin/settings`) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ 系统设置 │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 全局默认配额 │ │ +│ │ │ │ +│ │ 默认每日限额 (秒): [____600____] │ │ +│ │ 默认每月限额 (秒): [____6000___] │ │ +│ │ │ │ +│ │ [保存配额设置] │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 系统公告 [启用公告: ON] │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ │ │ │ +│ │ │ 公告内容 (支持纯文本) │ │ │ +│ │ │ │ │ │ +│ │ └──────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ [保存公告] │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- 使用 Arco Form、InputNumber、Switch、Input.TextArea 组件 +- 保存后 showToast 提示「设置已保存」 +- 数据来源:`GET/PUT /api/v1/admin/settings` + +### 4.9 [Phase 3] 用户个人中心(`/profile`) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ [← 返回首页] 个人中心 [用户名] [退出] │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 消费概览 │ │ +│ │ │ │ +│ │ ┌────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ (环形图)│ │ 今日额度 │ │ 本月额度 │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ 已用 │ │ 已用: 123s │ │ 已用: 2345s │ │ │ +│ │ │ 345s │ │ 限额: 600s │ │ 限额: 6000s │ │ │ +│ │ │ /600s │ │ ████████░░░ │ │ ████░░░░░░░ │ │ │ +│ │ │ │ │ 20.5% │ │ 39.1% │ │ │ +│ │ └────────┘ └──────────────┘ └──────────────┘ │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 消费趋势 [近7天] [近30天] │ │ +│ │ ___/\___/\___ (Sparkline 迷你折线图) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 消费记录 │ │ +│ │ │ │ +│ │ 3/12 14:30 │ 15s │ 一只猫在花园... │ 全能参考 │ 完成│ │ +│ │ 3/12 14:25 │ 5s │ 日落海边散步... │ 首尾帧 │ 完成│ │ +│ │ 3/12 13:00 │ 10s │ 城市夜景延时... │ 全能参考 │ 失败│ │ +│ │ ... │ │ +│ │ │ │ +│ │ [加载更多] │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- 环形进度条使用 ECharts `gauge` 类型(半环或全环) +- 进度条使用 Arco Progress 组件 +- 消费记录列表使用 Arco List 或 Table 组件,支持「加载更多」分页 +- Sparkline 迷你图使用 ECharts `line` 类型(无坐标轴,仅曲线) +- 数据来源:`GET /api/v1/profile/overview` + `GET /api/v1/profile/records` +- 深色主题风格与主应用一致 + +## 5. API 设计 + +### 5.1 [已有] 视频生成(Phase 3 修改配额返回字段) + +``` +POST /api/v1/video/generate +Content-Type: multipart/form-data +Authorization: Bearer + +Request: +{ + "prompt": string, + "mode": "universal" | "keyframe", + "model": "seedance_2.0" | "seedance_2.0_fast", + "aspect_ratio": "16:9" | "9:16" | "1:1" | "21:9" | "4:3" | "3:4" | "auto", + "duration": 5 | 10 | 15, + "references": File[], + "first_frame": File | null, + "last_frame": File | null +} + +Response: 202 Accepted +{ + "task_id": "uuid", + "status": "queued", + "estimated_time": 120, + "seconds_consumed": 15, ← [Phase 3] 本次消耗秒数 + "remaining_seconds_today": 345 ← [Phase 3] 今日剩余秒数 +} + +Error Response: 429 Too Many Requests +{ + "error": "quota_exceeded", + "message": "您今日的生成额度已用完", ← [Phase 3] 改为秒数描述 + "daily_seconds_limit": 600, + "daily_seconds_used": 600, + "reset_at": "2026-03-13T00:00:00+08:00" +} +``` + +### 5.2 [已有] 文件上传(不变) + +``` +POST /api/v1/upload +Content-Type: multipart/form-data +Authorization: Bearer + +Request: +{ + "file": File, + "type": "image" | "video" +} + +Response: 200 OK +{ + "file_id": "uuid", + "url": "https://cdn.example.com/...", + "thumbnail_url": "https://cdn.example.com/.../thumb.jpg", + "width": 1920, + "height": 1080, + "duration": 10.5 +} +``` + +### 5.3 [已有] 用户注册(不变) + +``` +POST /api/v1/auth/register +Content-Type: application/json + +Request: +{ + "username": "string (3-20字符)", + "email": "string (合法邮箱)", + "password": "string (最少6位)" +} + +Response: 201 Created +{ + "user": { + "id": 1, + "username": "johndoe", + "email": "john@example.com" + }, + "tokens": { + "access": "eyJ...", + "refresh": "eyJ..." + } +} + +Error Response: 400 Bad Request +{ + "username": ["该用户名已被注册"], + "email": ["该邮箱已被注册"] +} +``` + +### 5.4 [已有] 用户登录(不变) + +``` +POST /api/v1/auth/login +Content-Type: application/json + +Request: +{ + "username": "string (用户名或邮箱)", + "password": "string" +} + +Response: 200 OK +{ + "user": { + "id": 1, + "username": "johndoe", + "email": "john@example.com", + "is_staff": false + }, + "tokens": { + "access": "eyJ...", + "refresh": "eyJ..." + } +} + +Error Response: 401 Unauthorized +{ + "error": "invalid_credentials", + "message": "用户名或密码错误" +} +``` + +### 5.5 [已有] Token 刷新(不变) + +``` +POST /api/v1/auth/token/refresh +Content-Type: application/json + +Request: +{ + "refresh": "eyJ..." +} + +Response: 200 OK +{ + "access": "eyJ..." +} +``` + +### 5.6 [已有] 获取当前用户信息(Phase 3 修改配额字段) + +``` +GET /api/v1/auth/me +Authorization: Bearer + +Response: 200 OK +{ + "id": 1, + "username": "johndoe", + "email": "john@example.com", + "is_staff": false, + "quota": { + "daily_seconds_limit": 600, ← [Phase 3] 改为秒数 + "daily_seconds_used": 123, + "monthly_seconds_limit": 6000, + "monthly_seconds_used": 2345 + } +} +``` + +### 5.7 [Phase 3] 管理后台 — 仪表盘统计 + +``` +GET /api/v1/admin/stats?period=30d +Authorization: Bearer +(requires is_staff=True) + +Query Parameters: + period: "today" | "7d" | "30d" | "custom" + start_date: "2026-03-01" (当 period=custom 时必填) + end_date: "2026-03-12" (当 period=custom 时必填) + +Response: 200 OK +{ + "total_users": 1234, + "new_users_today": 23, + "seconds_consumed_today": 4560, + "seconds_consumed_this_month": 89010, + "today_change_percent": -5.0, + "month_change_percent": 8.0, + "daily_trend": [ + {"date": "2026-02-11", "seconds": 3200}, + {"date": "2026-02-12", "seconds": 4100}, + ... + ], + "top_users": [ + { + "user_id": 1, + "username": "user_a", + "seconds_consumed": 2340 + }, + ... + ] +} +``` + +### 5.8 [Phase 3] 管理后台 — 用户列表 + +``` +GET /api/v1/admin/users?page=1&page_size=20&search=&status=&sort_by=created_at&order=desc +Authorization: Bearer +(requires is_staff=True) + +Query Parameters: + page: int (默认 1) + page_size: int (默认 20,最大 100) + search: string (按用户名或邮箱模糊搜索) + status: "active" | "disabled" | "" (空表示全部) + sort_by: "created_at" | "seconds_today" | "seconds_month" (排序字段) + order: "asc" | "desc" + +Response: 200 OK +{ + "total": 56, + "page": 1, + "page_size": 20, + "results": [ + { + "id": 1, + "username": "user_a", + "email": "a@test.com", + "is_active": true, + "date_joined": "2026-03-01T10:00:00+08:00", + "daily_seconds_limit": 600, + "monthly_seconds_limit": 6000, + "seconds_today": 123, + "seconds_this_month": 2345 + }, + ... + ] +} +``` + +### 5.9 [Phase 3] 管理后台 — 用户详情 + 消费记录 + +``` +GET /api/v1/admin/users/:id +Authorization: Bearer +(requires is_staff=True) + +Response: 200 OK +{ + "id": 1, + "username": "user_a", + "email": "a@test.com", + "is_active": true, + "is_staff": false, + "date_joined": "2026-03-01T10:00:00+08:00", + "daily_seconds_limit": 600, + "monthly_seconds_limit": 6000, + "seconds_today": 123, + "seconds_this_month": 2345, + "seconds_total": 5678, + "recent_records": [ + { + "id": 101, + "created_at": "2026-03-12T14:30:00+08:00", + "seconds_consumed": 15, + "prompt": "一只猫在花园里追蝴蝶", + "mode": "universal", + "model": "seedance_2.0", + "status": "completed" + }, + ... + ] +} +``` + +### 5.10 [Phase 3] 管理后台 — 修改用户配额 + +``` +PUT /api/v1/admin/users/:id/quota +Authorization: Bearer +(requires is_staff=True) +Content-Type: application/json + +Request: +{ + "daily_seconds_limit": 900, + "monthly_seconds_limit": 9000 +} + +Response: 200 OK +{ + "user_id": 1, + "username": "user_a", + "daily_seconds_limit": 900, + "monthly_seconds_limit": 9000, + "updated_at": "2026-03-12T14:30:00+08:00" +} +``` + +### 5.11 [Phase 3] 管理后台 — 启用/禁用用户 + +``` +PATCH /api/v1/admin/users/:id/status +Authorization: Bearer +(requires is_staff=True) +Content-Type: application/json + +Request: +{ + "is_active": false +} + +Response: 200 OK +{ + "user_id": 1, + "username": "user_a", + "is_active": false, + "updated_at": "2026-03-12T14:30:00+08:00" +} +``` + +### 5.12 [Phase 3] 管理后台 — 消费记录列表 + +``` +GET /api/v1/admin/records?page=1&page_size=20&search=&start_date=&end_date= +Authorization: Bearer +(requires is_staff=True) + +Query Parameters: + page: int (默认 1) + page_size: int (默认 20,最大 100) + search: string (按用户名搜索) + start_date: "2026-03-01" (起始日期) + end_date: "2026-03-12" (结束日期) + +Response: 200 OK +{ + "total": 1234, + "page": 1, + "page_size": 20, + "results": [ + { + "id": 101, + "created_at": "2026-03-12T14:30:00+08:00", + "user_id": 1, + "username": "user_a", + "seconds_consumed": 15, + "prompt": "一只猫在花园里追蝴蝶", + "mode": "universal", + "model": "seedance_2.0", + "aspect_ratio": "16:9", + "status": "completed" + }, + ... + ] +} +``` + +### 5.13 [Phase 3] 管理后台 — 系统设置 + +``` +GET /api/v1/admin/settings +Authorization: Bearer +(requires is_staff=True) + +Response: 200 OK +{ + "default_daily_seconds_limit": 600, + "default_monthly_seconds_limit": 6000, + "announcement": "系统将于今晚 22:00 进行维护", + "announcement_enabled": true +} + +PUT /api/v1/admin/settings +Authorization: Bearer +(requires is_staff=True) +Content-Type: application/json + +Request: +{ + "default_daily_seconds_limit": 600, + "default_monthly_seconds_limit": 6000, + "announcement": "系统将于今晚 22:00 进行维护", + "announcement_enabled": true +} + +Response: 200 OK +{ + "default_daily_seconds_limit": 600, + "default_monthly_seconds_limit": 6000, + "announcement": "系统将于今晚 22:00 进行维护", + "announcement_enabled": true, + "updated_at": "2026-03-12T14:30:00+08:00" +} +``` + +### 5.14 [Phase 3] 用户个人中心 — 消费概览 + +``` +GET /api/v1/profile/overview?period=7d +Authorization: Bearer + +Query Parameters: + period: "7d" | "30d" (趋势数据的时间范围) + +Response: 200 OK +{ + "daily_seconds_limit": 600, + "daily_seconds_used": 123, + "monthly_seconds_limit": 6000, + "monthly_seconds_used": 2345, + "total_seconds_used": 5678, + "daily_trend": [ + {"date": "2026-03-06", "seconds": 45}, + {"date": "2026-03-07", "seconds": 120}, + {"date": "2026-03-08", "seconds": 0}, + {"date": "2026-03-09", "seconds": 88}, + {"date": "2026-03-10", "seconds": 200}, + {"date": "2026-03-11", "seconds": 156}, + {"date": "2026-03-12", "seconds": 123} + ] +} +``` + +### 5.15 [Phase 3] 用户个人中心 — 消费记录 + +``` +GET /api/v1/profile/records?page=1&page_size=20 +Authorization: Bearer + +Query Parameters: + page: int (默认 1) + page_size: int (默认 20) + +Response: 200 OK +{ + "total": 45, + "page": 1, + "page_size": 20, + "results": [ + { + "id": 101, + "created_at": "2026-03-12T14:30:00+08:00", + "seconds_consumed": 15, + "prompt": "一只猫在花园里追蝴蝶", + "mode": "universal", + "model": "seedance_2.0", + "aspect_ratio": "16:9", + "status": "completed" + }, + ... + ] +} +``` + +## 6. 数据模型 + +### 6.1 已有前端 Store 类型(保留) + +```typescript +// 创作模式 +type CreationMode = 'universal' | 'keyframe'; + +// 模型选项 +type ModelOption = 'seedance_2.0' | 'seedance_2.0_fast'; + +// 宽高比 +type AspectRatio = '16:9' | '9:16' | '1:1' | '21:9' | '4:3' | '3:4'; + +// 时长 +type Duration = 5 | 10 | 15; + +// 上传文件 +interface UploadedFile { + id: string; + file: File; + type: 'image' | 'video'; + previewUrl: string; + label: string; +} + +// 输入栏状态 Store +interface InputBarStore { + mode: CreationMode; + setMode: (mode: CreationMode) => void; + model: ModelOption; + setModel: (model: ModelOption) => void; + aspectRatio: AspectRatio; + setAspectRatio: (ratio: AspectRatio) => void; + duration: Duration; + setDuration: (duration: Duration) => void; + prompt: string; + setPrompt: (prompt: string) => void; + references: UploadedFile[]; + addReference: (file: File) => void; + removeReference: (id: string) => void; + clearReferences: () => void; + firstFrame: UploadedFile | null; + lastFrame: UploadedFile | null; + setFirstFrame: (file: File | null) => void; + setLastFrame: (file: File | null) => void; + canSubmit: () => boolean; + switchMode: (mode: CreationMode) => void; + submit: () => void; + reset: () => void; +} +``` + +### 6.2 已有下拉菜单配置(保留) + +```typescript +interface DropdownOption { + label: string; + value: string; + icon?: string; + disabled?: boolean; +} + +const generationTypes: DropdownOption[] = [ + { label: '视频生成', value: 'video', icon: 'video' }, + { label: '图片生成', value: 'image', icon: 'image' }, +]; + +const modelOptions: DropdownOption[] = [ + { label: 'Seedance 2.0', value: 'seedance_2.0', icon: 'diamond' }, + { label: 'Seedance 2.0 Fast', value: 'seedance_2.0_fast', icon: 'diamond' }, +]; + +const modeOptions: DropdownOption[] = [ + { label: '全能参考', value: 'universal', icon: 'sparkle' }, + { label: '首尾帧', value: 'keyframe', icon: 'swap' }, +]; + +const aspectRatioOptions: DropdownOption[] = [ + { label: '16:9', value: '16:9' }, + { label: '9:16', value: '9:16' }, + { label: '1:1', value: '1:1' }, + { label: '21:9', value: '21:9' }, + { label: '4:3', value: '4:3' }, + { label: '3:4', value: '3:4' }, +]; + +const durationOptions: DropdownOption[] = [ + { label: '5s', value: '5' }, + { label: '10s', value: '10' }, + { label: '15s', value: '15' }, +]; +``` + +### 6.3 [Phase 2] 前端 Auth Store 类型(Phase 3 修改配额字段) + +```typescript +interface User { + id: number; + username: string; + email: string; + is_staff: boolean; +} + +// [Phase 3] 配额字段改为秒数 +interface Quota { + daily_seconds_limit: number; // 原 daily_limit + daily_seconds_used: number; // 原 daily_used + monthly_seconds_limit: number; // 原 monthly_limit + monthly_seconds_used: number; // 原 monthly_used +} + +interface AuthStore { + // 状态 + user: User | null; + accessToken: string | null; + refreshToken: string | null; + isAuthenticated: boolean; + isLoading: boolean; + + // 操作 + login: (username: string, password: string) => Promise; + register: (username: string, email: string, password: string) => Promise; + logout: () => void; + refreshAccessToken: () => Promise; + fetchUserInfo: () => Promise; + + // 配额 + quota: Quota | null; + fetchQuota: () => Promise; +} +``` + +### 6.4 [Phase 3] 前端新增类型 + +```typescript +// 管理后台统计数据 +interface AdminStats { + total_users: number; + new_users_today: number; + seconds_consumed_today: number; // 原 calls_today + seconds_consumed_this_month: number; // 原 calls_this_month + today_change_percent: number; + month_change_percent: number; + daily_trend: { date: string; seconds: number }[]; + top_users: { user_id: number; username: string; seconds_consumed: number }[]; +} + +// 管理后台用户列表项 +interface AdminUser { + id: number; + username: string; + email: string; + is_active: boolean; + date_joined: string; + daily_seconds_limit: number; + monthly_seconds_limit: number; + seconds_today: number; + seconds_this_month: number; +} + +// 管理后台消费记录 +interface AdminRecord { + id: number; + created_at: string; + user_id: number; + username: string; + seconds_consumed: number; + prompt: string; + mode: CreationMode; + model: ModelOption; + aspect_ratio: string; + status: 'queued' | 'processing' | 'completed' | 'failed'; +} + +// 系统设置 +interface SystemSettings { + default_daily_seconds_limit: number; + default_monthly_seconds_limit: number; + announcement: string; + announcement_enabled: boolean; +} + +// 用户个人中心概览 +interface ProfileOverview { + daily_seconds_limit: number; + daily_seconds_used: number; + monthly_seconds_limit: number; + monthly_seconds_used: number; + total_seconds_used: number; + daily_trend: { date: string; seconds: number }[]; +} + +// 用户消费记录 +interface ProfileRecord { + id: number; + created_at: string; + seconds_consumed: number; + prompt: string; + mode: CreationMode; + model: ModelOption; + aspect_ratio: string; + status: 'queued' | 'processing' | 'completed' | 'failed'; +} + +// 分页响应 +interface PaginatedResponse { + total: number; + page: number; + page_size: number; + results: T[]; +} +``` + +### 6.5 后端数据模型(Django Models) + +#### [Phase 2 → Phase 3 修改] 用户模型 + +```python +# backend/apps/accounts/models.py + +from django.contrib.auth.models import AbstractUser +from django.db import models + +class User(AbstractUser): + """扩展用户模型 — Phase 3: 配额单位改为秒数""" + email = models.EmailField(unique=True, verbose_name='邮箱') + # [Phase 3] 改为秒数限制 + daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限') + monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + verbose_name = '用户' + verbose_name_plural = '用户' +``` + +> **迁移说明**: 需要创建 Django migration 将 `daily_limit` → `daily_seconds_limit`,`monthly_limit` → `monthly_seconds_limit`,并将现有数据按「原次数 × 15」换算为秒数(假设平均每次生成 15 秒)。 + +#### [Phase 2 → Phase 3 修改] 生成记录模型 + +```python +# backend/apps/generation/models.py + +import uuid +from django.db import models +from django.conf import settings + +class GenerationRecord(models.Model): + """视频生成记录 — Phase 3: 新增消费秒数字段""" + MODE_CHOICES = [ + ('universal', '全能参考'), + ('keyframe', '首尾帧'), + ] + MODEL_CHOICES = [ + ('seedance_2.0', 'Seedance 2.0'), + ('seedance_2.0_fast', 'Seedance 2.0 Fast'), + ] + STATUS_CHOICES = [ + ('queued', '排队中'), + ('processing', '生成中'), + ('completed', '已完成'), + ('failed', '失败'), + ] + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='generation_records', + verbose_name='用户' + ) + task_id = models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='任务ID') + prompt = models.TextField(blank=True, verbose_name='提示词') + mode = models.CharField(max_length=20, choices=MODE_CHOICES, verbose_name='创作模式') + model = models.CharField(max_length=30, choices=MODEL_CHOICES, verbose_name='模型') + aspect_ratio = models.CharField(max_length=10, verbose_name='宽高比') + duration = models.IntegerField(verbose_name='视频时长(秒)') + seconds_consumed = models.FloatField(default=0, verbose_name='消费秒数') # [Phase 3] 新增 + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='queued', verbose_name='状态') + created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间') + + class Meta: + verbose_name = '生成记录' + verbose_name_plural = '生成记录' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['user', 'created_at']), + ] +``` + +> **消费秒数逻辑**: `seconds_consumed` = 视频 `duration`(5/10/15秒),在调用生成 API 时自动设置。 + +#### [Phase 2 → Phase 3 修改] 配额配置模型 + +```python +# backend/apps/generation/models.py (续) + +class QuotaConfig(models.Model): + """全局配额配置 — Phase 3: 改为秒数""" + default_daily_seconds_limit = models.IntegerField(default=600, verbose_name='默认每日秒数上限') + default_monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='默认每月秒数上限') + announcement = models.TextField(blank=True, default='', verbose_name='系统公告') # [Phase 3] 新增 + announcement_enabled = models.BooleanField(default=False, verbose_name='启用公告') # [Phase 3] 新增 + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = '系统配置' + verbose_name_plural = '系统配置' + + def save(self, *args, **kwargs): + # 单例模式,确保只有一条记录 + self.pk = 1 + super().save(*args, **kwargs) +``` + +## 7. 非功能需求 + +### 性能要求 +- 首屏加载 < 2s(Vite 构建,代码分割) +- 文件上传后缩略图预览 < 200ms(使用 URL.createObjectURL 本地生成) +- 下拉菜单展开/收起动画 < 150ms +- 文本输入无感知延迟(非受控组件或 debounce) +- [Phase 2] 后端 API 响应时间 < 500ms(除文件上传外) +- [Phase 2] JWT Token 验证 < 50ms +- [Phase 3] ECharts 图表渲染 < 300ms(含数据加载) +- [Phase 3] 管理后台页面切换 < 200ms(路由级代码分割) +- [Phase 3] 分页列表请求 < 300ms + +### 安全要求 +- 文件上传仅限 image/* 和 video/* MIME 类型 +- 文件大小限制:图片 < 20MB,视频 < 100MB +- 使用 URL.createObjectURL 生成预览,组件卸载时调用 URL.revokeObjectURL 释放内存 +- [Phase 2] 密码使用 Django 默认的 PBKDF2 算法加密存储 +- [Phase 2] JWT Access Token 有效期 2 小时,Refresh Token 有效期 7 天 +- [Phase 2] 数据库密码等敏感配置通过环境变量管理(生产环境),开发环境可硬编码在 settings 中 +- [Phase 2] API 接口对未认证请求返回 401,对权限不足返回 403 +- [Phase 2] 管理接口仅 `is_staff=True` 用户可访问 +- [Phase 3] 管理后台操作(修改配额、禁用用户)需记录操作日志 +- [Phase 3] CSV 导出需要防止 CSV 注入攻击(prompt 字段可能包含特殊字符) + +### 响应式设计要求 +- 桌面端(≥1024px):InputBar 最大宽度 900px 居中;管理后台 Sidebar 240px + 内容区自适应 +- 平板端(768px-1023px):InputBar 宽度 90% 居中;管理后台 Sidebar 折叠为图标模式 +- 移动端(<768px):InputBar 宽度 95%,工具栏按钮文字隐藏仅显示图标;管理后台 Sidebar 隐藏,使用汉堡菜单 + +### 浏览器兼容 +- Chrome 90+、Firefox 90+、Safari 15+、Edge 90+ + +## 8. 验收标准 + +> **重要**: Phase 1 和 Phase 2 功能已完成。当前验收范围为 **Phase 3 全部功能**。Phase 1/2 功能保持不变,不重复验收。 + +### Phase 1 验收标准(已通过) + +#### P0(已全部通过) +1. 页面打开后显示深色全屏背景 `#0a0a0f`,底部居中显示 InputBar +2. InputBar 样式与参考截图视觉一致:背景 `#16161e`、边框 `#2a2a38`、圆角 `20px` +3. 默认处于「全能参考」模式,显示 [+ 参考内容] 上传按钮 + 提示词输入框 +4. 点击 [+ 参考内容] 可选择图片/视频文件,上传后显示带序号的缩略图 +5. 上传 1-5 张文件后缩略图正确显示,每个有 × 关闭按钮可删除 +6. 切换到「首尾帧」模式后,上传区变为首帧 ↔ 尾帧双框布局 +7. 工具栏所有按钮正确显示,布局与参考截图一致 +8. 发送按钮状态正确:无内容时灰色,有内容时蓝色 `#00b8e6` + +#### P1(已全部通过) +9. 「视频生成」下拉、「模型选择」下拉、「模式切换」下拉均可正常展开和选择 +10. 比例选择按钮点击弹出 6 个选项,选中后按钮文字更新 +11. 时长选择按钮点击弹出 3 个选项,选中后按钮文字更新 +12. 切换模式时联动正确:全能参考→首尾帧时比例变为「自动匹配」、时长变为 5s、隐藏 @ 按钮 +13. 文件上传支持拖拽 + +#### P2(已全部通过) +14. 下拉菜单有动画过渡效果 +15. `Ctrl/Cmd + Enter` 可触发发送 +16. 页面加载后文本输入框自动聚焦 +17. 移动端下工具栏按钮自适应 + +### Phase 2 验收标准(已通过) + +#### P0(已全部通过) +18. 未登录用户访问 `/` 自动跳转到 `/login` +19. 注册页表单验证正确,注册成功后自动登录跳转到首页 +20. 登录页输入正确凭据后成功登录,获取 JWT Token +21. 后端 Django 服务正常启动,能连接 MySQL 数据库 +22. 发送按钮点击后调用后端 API,后端记录调用并返回剩余配额 + +#### P1(已全部通过) +23. 管理员登录后可访问管理后台,普通用户无法访问 +24. 管理后台正确显示用户总数、调用统计等指标 +25. 管理员可修改用户的每日/每月调用限额 +26. 超出限额时前端显示友好提示,后端返回 429 状态码 +27. Django Admin 后台(`/admin/`)可管理用户和生成记录 + +### Phase 3 验收标准(当前迭代) + +#### P0(必须全部通过) +28. 所有「调用次数」展示改为「生成秒数」—— UserInfoBar 显示「剩余 Ns/Ns(日)」而非「剩余 N 次」 +29. 后端 User 模型字段 `daily_limit`/`monthly_limit` 迁移为 `daily_seconds_limit`/`monthly_seconds_limit` +30. GenerationRecord 模型新增 `seconds_consumed` 字段,生成 API 返回 `seconds_consumed` 和 `remaining_seconds_today` +31. 管理后台使用左侧 Sidebar + 右侧内容区布局,Sidebar 包含 4 个导航项(仪表盘/用户管理/消费记录/系统设置) +32. `/admin/dashboard` 仪表盘页面显示 4 个统计卡片(总用户、今日新增、今日消费秒数、本月消费秒数)+ ECharts 消费趋势折线图 + 用户消费排行柱状图 +33. `/admin/users` 用户管理页面支持分页列表、搜索筛选、编辑配额、启用/禁用用户 +34. `/profile` 用户个人中心显示消费概览(环形进度条)+ 消费记录列表 + +#### P1 +35. `/admin/records` 消费记录页面显示所有用户消费明细,支持时间范围筛选和导出 CSV +36. `/admin/settings` 系统设置页面支持修改全局默认配额和管理系统公告 +37. 用户个人中心显示消费趋势 Sparkline 迷你图(近 7天/30天 切换) +38. 仪表盘统计卡片显示环比变化百分比 + 趋势箭头 +39. 用户管理页面点击用户名展开详情抽屉,显示用户详情 + 近期消费记录 +40. 管理后台深色主题与 Linear/Vercel Dashboard 风格一致 +41. 数据加载使用骨架屏(Skeleton) + +#### P2 +42. 管理后台 Sidebar 支持折叠为图标模式 +43. 页面切换有 fade/slide 过渡动画 +44. 当日额度消费超过 80% 时用户端显示黄色警告提示 + +## 9. 模式切换联动逻辑 + +| 切换动作 | 上传区 | 比例 | 时长 | @ 按钮 | Placeholder | +|---------|--------|------|------|--------|-------------| +| → 全能参考 | [+ 参考内容] 多文件上传 | 恢复用户之前选择(默认 21:9) | 恢复用户之前选择(默认 15s) | 显示 | "上传1-5张参考图或视频,输入文字,自由组合图、文、音、视频多元素,定义精彩互动。" | +| → 首尾帧 | [首帧] ↔ [+ 尾帧] | 自动匹配(灰色不可选) | 5s(可切换) | 隐藏 | "输入描述,定义首帧到尾帧的运动过程" | + +> **注意**: 切换模式时不清空已输入的提示词文本,但上传的文件会被清空(因为两种模式的上传逻辑不同)。 + +## 10. 设计规范(Design Token) + +### 颜色 + +| Token | 值 | 用途 | +|-------|-----|------| +| `--color-bg-page` | `#0a0a0f` | 页面背景 | +| `--color-bg-input-bar` | `#16161e` | InputBar 背景 | +| `--color-border-input-bar` | `#2a2a38` | InputBar 边框 | +| `--color-primary` | `#00b8e6` | 主强调色(发送按钮、视频生成文字) | +| `--color-text-primary` | `#ffffff` | 主文字 | +| `--color-text-secondary` | `#8a8a9a` | 次文字(工具栏按钮、placeholder) | +| `--color-text-disabled` | `#4a4a5a` | 禁用文字 | +| `--color-bg-hover` | `rgba(255, 255, 255, 0.06)` | 按钮 hover 背景 | +| `--color-bg-dropdown` | `#1e1e2a` | 下拉菜单背景 | +| `--color-bg-upload` | `rgba(255, 255, 255, 0.04)` | 上传区背景 | +| `--color-border-upload` | `#2a2a38` | 上传区虚线边框 | +| `--color-btn-send-disabled` | `#3a3a4a` | 发送按钮禁用状态 | +| `--color-btn-send-active` | `#00b8e6` | 发送按钮激活状态 | + +### [Phase 3] 管理后台专用颜色 + +| Token | 值 | 用途 | +|-------|-----|------| +| `--color-bg-sidebar` | `#111118` | Sidebar 背景 | +| `--color-sidebar-active` | `rgba(255, 255, 255, 0.08)` | Sidebar 当前项背景 | +| `--color-sidebar-hover` | `rgba(255, 255, 255, 0.04)` | Sidebar hover 背景 | +| `--color-bg-card` | `#16161e` | 卡片/面板背景 | +| `--color-border-card` | `#2a2a38` | 卡片边框 | +| `--color-success` | `#00b894` | 正向指标(↑绿色) | +| `--color-danger` | `#e74c3c` | 负向指标(↓红色) / 禁用状态 | +| `--color-warning` | `#f39c12` | 警告提示(额度 80%+) | + +### 圆角 + +| Token | 值 | 用途 | +|-------|-----|------| +| `--radius-input-bar` | `20px` | InputBar 容器 | +| `--radius-btn` | `8px` | 工具栏按钮 | +| `--radius-send-btn` | `50%` | 发送按钮(圆形) | +| `--radius-thumbnail` | `8px` | 缩略图 | +| `--radius-dropdown` | `12px` | 下拉菜单 | +| `--radius-card` | `12px` | [Phase 3] 管理后台卡片 | + +### 尺寸 + +| Token | 值 | 用途 | +|-------|-----|------| +| `--input-bar-max-width` | `900px` | InputBar 最大宽度 | +| `--send-btn-size` | `36px` | 发送按钮直径 | +| `--thumbnail-size` | `80px` | 上传缩略图尺寸 | +| `--toolbar-height` | `44px` | 工具栏行高 | +| `--toolbar-btn-height` | `32px` | 工具栏按钮高度 | +| `--sidebar-width` | `240px` | [Phase 3] Sidebar 宽度 | +| `--sidebar-collapsed-width` | `64px` | [Phase 3] Sidebar 折叠宽度 | + +## 11. 组件树结构 + +``` +App +├── AuthProvider // [Phase 2] 认证上下文 +├── Router // [Phase 2] 路由 +│ ├── /login → LoginPage // [Phase 2] 登录页 +│ ├── /register → RegisterPage // [Phase 2] 注册页 +│ ├── / → ProtectedRoute // [Phase 2] 需要登录 +│ │ └── VideoGenerationPage +│ │ ├── UserInfoBar // [Phase 2] 顶部用户信息 + [Phase 3] 秒数配额 + 个人中心链接 +│ │ ├── PageBackground +│ │ └── InputBar +│ │ ├── InputArea +│ │ │ ├── UploadSection +│ │ │ │ ├── UniversalUpload +│ │ │ │ │ ├── UploadButton +│ │ │ │ │ └── ThumbnailGrid +│ │ │ │ │ └── ThumbnailItem +│ │ │ │ └── KeyframeUpload +│ │ │ │ ├── FrameUpload +│ │ │ │ ├── ArrowIcon +│ │ │ │ └── FrameUpload +│ │ │ └── PromptInput +│ │ └── Toolbar +│ │ ├── GenerationTypeDropdown +│ │ ├── ModelSelector +│ │ ├── ModeDropdown +│ │ ├── AspectRatioSelector +│ │ ├── DurationSelector +│ │ ├── AtButton +│ │ ├── FlexSpacer +│ │ └── SendButton +│ ├── /profile → ProtectedRoute // [Phase 3] 用户个人中心 +│ │ └── ProfilePage +│ │ ├── ProfileHeader // 返回首页 + 用户信息 +│ │ ├── ConsumptionOverview // 消费概览卡片 +│ │ │ ├── EChartsGauge // 环形进度条 +│ │ │ ├── DailyQuotaCard // 今日额度进度条 +│ │ │ └── MonthlyQuotaCard // 本月额度进度条 +│ │ ├── ConsumptionTrend // Sparkline 迷你趋势图 +│ │ └── ConsumptionRecordList // 消费记录列表 +│ ├── /admin → ProtectedRoute (requireAdmin) // [Phase 3] 管理后台 +│ │ └── AdminLayout // Sidebar + Content 布局 +│ │ ├── AdminSidebar // 左侧导航栏 +│ │ │ ├── SidebarLogo // Logo +│ │ │ ├── SidebarNav // 导航菜单 +│ │ │ │ ├── NavItem (仪表盘) +│ │ │ │ ├── NavItem (用户管理) +│ │ │ │ ├── NavItem (消费记录) +│ │ │ │ └── NavItem (系统设置) +│ │ │ └── SidebarFooter // 返回首页链接 +│ │ └── AdminContent // 右侧内容区 (Outlet) +│ │ ├── /admin/dashboard → DashboardPage +│ │ │ ├── StatsCards // 4 个指标卡片 +│ │ │ ├── TrendLineChart // ECharts 折线图 +│ │ │ └── TopUsersBarChart // ECharts 柱状图 +│ │ ├── /admin/users → UsersPage +│ │ │ ├── UserSearchBar // 搜索 + 筛选 +│ │ │ ├── UserTable // Arco Table +│ │ │ ├── QuotaEditModal // 配额编辑弹窗 +│ │ │ └── UserDetailDrawer // 用户详情抽屉 +│ │ ├── /admin/records → RecordsPage +│ │ │ ├── RecordFilters // 筛选条件 +│ │ │ ├── RecordTable // Arco Table +│ │ │ └── ExportButton // 导出 CSV +│ │ └── /admin/settings → SettingsPage +│ │ ├── QuotaSettingsCard // 全局配额表单 +│ │ └── AnnouncementCard // 公告管理 +│ └── * → Navigate to / +``` + +## 12. 后端项目结构 + +``` +backend/ +├── manage.py +├── requirements.txt +├── config/ # Django 项目配置 +│ ├── __init__.py +│ ├── settings.py +│ ├── urls.py +│ ├── wsgi.py +│ └── asgi.py +├── apps/ +│ ├── accounts/ # 用户认证模块 +│ │ ├── __init__.py +│ │ ├── models.py # User 扩展模型 (Phase 3: 秒数字段) +│ │ ├── serializers.py # 注册/登录序列化器 +│ │ ├── views.py # 注册/登录/Token刷新 API +│ │ ├── urls.py +│ │ └── admin.py # 用户管理 Admin +│ └── generation/ # 视频生成模块 +│ ├── __init__.py +│ ├── models.py # GenerationRecord (Phase 3: +seconds_consumed) + QuotaConfig (Phase 3: +announcement) +│ ├── serializers.py # [Phase 3] 新增管理后台 + 个人中心序列化器 +│ ├── views.py # [Phase 3] 新增管理后台 API (stats/users/records/settings) + 个人中心 API (overview/records) +│ ├── urls.py # [Phase 3] 新增路由 +│ ├── admin.py # 生成记录 Admin +│ └── middleware.py # 配额检查中间件 (Phase 3: 改为秒数检查) +``` + +## 13. 参考截图说明 + +参考截图存放于 `/Users/maidong/Desktop/zyc/研究openclaw/视频生成平台/` 目录: + +| 文件 | 内容描述 | +|------|---------| +| `20260311-154443.jpeg` | **空状态全貌** — 完整展示了 InputBar 在无输入时的样式 | +| `20260311-154432.jpeg` | **已上传状态** — 展示了上传 1 张图片后的 InputBar | +| `20260311-154407.jpeg` | **有内容状态** — 展示了输入完整提示词 + 多张图片引用后的 InputBar | + +## 14. 修订历史 + +| 日期 | 版本 | 变更内容 | +|------|------|---------| +| 2026-03-11 | v1.0 | 初始版本 — 纯前端视频生成输入界面 | +| 2026-03-12 | v2.0 | 增量迭代 — 新增 Django 后端、用户认证系统、后台管理系统 | +| 2026-03-12 | v2.1 | **需求修订(BUG-002)** — 引入开发阶段划分(Phase 1 / Phase 2),验收标准按阶段分组 | +| 2026-03-12 | v2.2 | **需求修订(BUG-002 后续)** — Phase 2 功能已完成开发,更新阶段状态和验收范围 | +| 2026-03-12 | v3.0 | **重大迭代 — Phase 3** — 计量单位从「调用次数」改为「生成秒数」;管理后台从单页面重做为多页面 Sidebar 布局(仪表盘/用户管理/消费记录/系统设置);新增用户个人中心(/profile)含消费概览、环形进度条、消费趋势 Sparkline、消费记录列表;引入 ECharts 图表库;管理后台深色主题升级(参考 Linear/Vercel 风格);新增骨架屏加载和页面过渡动画;新增 8 个 API 端点、修改 3 个已有 API 端点 | diff --git a/docs/archive/test-report.md b/docs/archive/test-report.md new file mode 100644 index 0000000..cab323d --- /dev/null +++ b/docs/archive/test-report.md @@ -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 验收 + Sidebar(14 个) +- ✅ 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 Accepted,seconds_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** | diff --git a/web/src/store/inputBar.ts b/web/src/store/inputBar.ts index b434f51..7eb18ce 100644 --- a/web/src/store/inputBar.ts +++ b/web/src/store/inputBar.ts @@ -218,9 +218,43 @@ export const useInputBarStore = create((set, get) => ({ }, removeReference: (id) => { const state = get(); - const ref = state.references.find((r) => r.id === id); - if (ref) URL.revokeObjectURL(ref.previewUrl); - set({ references: state.references.filter((r) => r.id !== id) }); + const removedRef = state.references.find((r) => r.id === id); + if (!removedRef) return; + 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(); // 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(`
${newEditorHtml}
`, '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: () => { const state = get(); diff --git a/web/test/e2e/bug2-rename.spec.ts b/web/test/e2e/bug2-rename.spec.ts new file mode 100644 index 0000000..093ef3f --- /dev/null +++ b/web/test/e2e/bug2-rename.spec.ts @@ -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']); + }); +}); diff --git a/web/test/unit/removeReferenceRelabeling.test.ts b/web/test/unit/removeReferenceRelabeling.test.ts new file mode 100644 index 0000000..2681f51 --- /dev/null +++ b/web/test/unit/removeReferenceRelabeling.test.ts @@ -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 `@${label}`; +} + +describe('removeReference — 即梦式连续重命名', () => { + beforeEach(() => { + useInputBarStore.getState().reset(); + }); + + describe('图片重命名', () => { + it('删除图片2 后,图片3 重命名为图片2(references + 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 = '纯文本,没有 mention 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>/); + }); + }); +});