docs(v0.20.1): 批次开发计划 + 完成报告 + changelog 归档

- 新增 docs/todo/v0.20.1-批次开发计划.md(本次实施的源 plan,7 批次拆解)
- 新增 docs/todo/v0.20.1-完成报告.md(改动总览/批次详情/测试结果/风险记录)
- docs/changelog.md 顶部追加 v0.20.1 条目(commit hash 对应,验收结果总结)

7 批次全部本地完成,等用户授权后 push 到 dev:
  A e86e3d4 主管理员撤销 bug
  B 72f351d 视频封面帧前端
  C 6ee5c8f api_prompt + 调试折叠区
  D c53144b 站内通知系统
  G ed67a27 团管重置密码
  H 11c1cdf reEdit prompt 丢失
  I 6b13cff Safari 自适应根因

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-05-12 18:40:28 +08:00
parent ed67a27399
commit 2289ce7d30
3 changed files with 635 additions and 0 deletions

View File

@ -4,6 +4,33 @@
---
## 2026-05-12 — v0.20.1: 7 批次小修复 + 中等功能(主管bug/封面帧/api_prompt/站内通知/团管重置密码/reEdit prompt/Safari 自适应根因)
**状态**: ✅ 本地完成 | **验收**: vitest 71/162 基线 0 回归 + 3 套 smoke (25+8+11) 全过 + 后端 curl 验证 4 通知 endpoint 全过 + 团管重置 6 项权限矩阵全过
### 变更内容
| # | 批次 | Commit | 关键改动 |
|---|------|--------|----------|
| A | 主管bug | `e86e3d4` | TeamsPage 主管 badge 加 onClick,后端早就支持 `is_team_admin=false` 同时清 `is_team_owner` |
| B | 封面帧前端 | `72f351d` | admin/team/profile 三个 records view 回传 `thumbnail_url`,三处 `<video>``poster=` |
| C | api_prompt 留痕 | `6ee5c8f` | `GenerationRecord.api_prompt` 字段 + migration 0021 + view 写入 + 详情弹窗"调试信息"折叠区(▸/▾) |
| D | 站内通知 | `c53144b` | 新 app `apps.notifications`(model + 4 API + admin) + Sidebar 铃铛(用户 76px + 超管 220px) + 通知中心页 + 60s 轮询 + anomaly_detector 自动封禁触发通知 |
| G | 团管重置密码 | `ed67a27` | 新 endpoint `team_reset_member_password_view` + 严格权限矩阵(主管→副管+成员,副管→成员)+ TeamMembersPage 重置按钮 + 结果 modal 显示一次新密码 |
| H | reEdit prompt | `11c1cdf` | VideoDetailModal fallback 跟 generation.ts:reEdit 对齐,关键补 `editorHtml: task.editorHtml \|\| task.prompt` |
| I | Safari 自适应根因 | `6b13cff` | AdminLayout `100dvh` + `min-height: 0` + 4 个 admin 页 `.pagination padding-bottom: 8px` + ProfilePage 同款 dvh fallback |
### 关键根因/设计要点
- **批次 I 不是 padding 补丁**:Safari 桌面 `100vh` 算的是含工具栏的 layout viewport,不是用户实际可见区;Flex `overflow-y: auto` 没有 `min-height: 0` 形同虚设。三件套 `100dvh + min-height: 0 + padding-bottom` 才是根因解
- **批次 G 权限服务端硬校验**:前端按钮 `canResetPasswordFor(m)` 只是 UX,后端 view 5 步逐层判 (同团队 / 不能改自己 / 主管须超管 / 副管只有主管能改 / 合法)
- **批次 D MySQL 严格模式守门**:Notification 所有 CharField 都加 `default=''`(memory `feedback_mysql_default`)
- **批次 D 用 `AppNotification` 类型名**:避免和浏览器 Web API `Notification` 全局类冲突
详见 `docs/todo/v0.20.1-完成报告.md`
---
## 2026-04-17 — v0.18.3: 版权报错友好提示 + 图片删除即梦式连续重命名
**状态**: ✅ 已完成 | **验收**: 14 个自动化测试全过11 单元 + 3 E2E

View File

@ -0,0 +1,196 @@
# v0.20.1 完成报告
**完成日期**: 2026-05-12
**分支**: dev(8 个 commit + 收尾 docs commit)
**push 状态**: 本地完成,等用户授权 push
---
## 一、改动总览(7 个批次 + 1 个 docs 收尾)
| # | 批次 | Commit | 简述 |
|---|------|--------|------|
| 1 | A | `e86e3d4` | fix(admin): 主管理员撤销 bug — TeamsPage 主管 badge 加 onClick |
| 2 | B | `72f351d` | feat(records): 视频卡片/详情弹窗用 thumbnail_url 显示首帧 poster |
| 3 | C | `6ee5c8f` | feat(records): api_prompt 永久留痕 + 详情弹窗调试信息折叠区 |
| 4 | H | `11c1cdf` | fix(records): TeamAssetsPage 重新编辑 prompt 丢失 — VideoDetailModal fallback 补 editorHtml |
| 5 | I | `6b13cff` | fix(admin): 笔记本 14寸 Safari 翻页按钮被截 — 根因三件套修法(100dvh + min-height:0 + padding-bottom) |
| 6 | D | `c53144b` | feat(notification): 站内通知系统 — Notification 模型 + 4 个 API + Sidebar 铃铛 + 通知中心页 |
| 7 | G | `ed67a27` | feat(team): 团管重置成员密码 — 新 API + 严格权限矩阵 + 成员管理页按钮 |
| 8 | F docs | (即将) | docs: v0.20.1 plan + 完成报告 + 总览待办标完成 |
---
## 二、批次详情
### 批次 A — 主管理员撤销 bug + 文档标完成 ✅
- **bug**: TeamsPage L825 主管理员 badge 无 onClick,管理员设了某成员为主管后撤不掉,只能后台改 DB
- **修法**: 前端补 onClick + window.confirm + 调 `setMemberRole(false)`,后端 `admin_team_member_role_view` 早就支持收 `is_team_admin=false` 时同时清 `is_team_owner`
- **文档**: 项目总览待办 P3#1 主题切换 / P3#7 副管角色 / P2#2 飞书告警 三项标 ✅(均为之前完成漏更文档)
### 批次 B — 视频封面帧前端补全 ✅
- **背景**: 后端 `tasks.py:_handle_completed` L109-111 早就用 ffmpeg 提取首帧上传 TOS 存 `record.thumbnail_url`,但只在 `_serialize_task`(生成页)返回。admin/team/profile 三个 records view 都没回传
- **后端**: admin_records / team_records / profile_records 各加 `'thumbnail_url': r.thumbnail_url or ''`
- **前端**:
- `AdminRecord` 类型加 `thumbnail_url?: string`
- 三处 `<video>``poster={thumbnailUrl ? rewriteTosUrl(...) : undefined}`(RecordDetailModal / VideoDetailModal / GenerationCard)
- 效果:卡片首屏立即显示首帧海报(几十 KB),不再等视频 metadata 加载完才有视觉
- **改动文件**: 5
### 批次 C — api_prompt 永久留痕 + 调试折叠区 ✅
- **背景**: v0.19.2 上线了 prompt @素材→「图片N」转换。客服/财务复盘投诉时需查实际传给火山的 prompt
- **后端**:
- `GenerationRecord.api_prompt` TextField 新字段 + migration `0021_add_api_prompt`
- `video_generate_view` 计算完 `_format_prompt_for_ark` 后立即 save api_prompt(即使 create_task 抛错也保留)
- admin_records / team_records view 回传 api_prompt
- **前端**:
- `AdminRecord``api_prompt?: string`
- RecordDetailModal 详情弹窗右侧底部加"调试信息(开发/客服参考)"折叠区:
- 默认 ▸ 收起,小灰字
- 点开 ▾ 展开,仅当 `api_prompt && api_prompt !== prompt` 显示"实际发给火山"等宽字 box(老记录 api_prompt='' 时不显示)
- 火山 Task ID + 复制按钮
- 失败任务才显示原始错误 raw_error
- **改动文件**: 5
### 批次 D — 站内通知系统(最大块) ✅
- **后端 — 新 app `apps.notifications`**:
- `Notification` model: type/title/content/link_url/is_read,索引 (recipient, is_read, -created_at)
- 4 个 endpoint:
- `GET /api/v1/notifications/` (列表 + 总未读数,unread_only/page/page_size)
- `GET /api/v1/notifications/unread-count` (轻量,前端 60s 轮询)
- `PATCH /api/v1/notifications/<id>/read`
- `POST /api/v1/notifications/read-all`
- 严格守 user 隔离:所有查询都 `filter(recipient=request.user)`
- INSTALLED_APPS 注册 + urls.py include + admin.py 注册
- migration `0001_initial` 应用成功
- **MySQL 严格模式**:所有 CharField 显式加 `default=''`(避踩 memory `feedback_mysql_default`)
- **后端 — anomaly_detector 集成**:
- `_RULE_LABELS` / `_team_admin_recipients()` / `_notify_user_disabled()` / `_notify_team_disabled()` helper
- `process_anomalies``_disable_user/_disable_team` 之后调对应 notify
- 接收人 = 同团队的主管+副管(`is_team_admin OR is_team_owner`)
- `bulk_create` 一次写多条
- try/except 保护:通知失败不阻断封禁主流程(只 log warning)
- **前端**:
- types: `AppNotification` / `NotificationListResponse`(用 App 前缀避开浏览器 Web API `Notification` 冲突)
- lib/api.ts: `notificationApi` (list / getUnreadCount / markRead / markAllRead)
- store/notification.ts: Zustand store 乐观更新(markRead 先动 UI 再发请求)
- pages/NotificationsPage.tsx: 标题 + 全部标记已读按钮 + 未读蓝点 + 相对时间 + 点击跳 link_url + 分页 + 空状态
- App.tsx: `/notifications` 路由(ProtectedRoute 不限 role,成员/团管/超管都能进)
- Sidebar.tsx(用户 76px):铃铛 SVG + 红点(`var(--color-danger)`) + 60s 轮询 + visibilitychange 立即刷新
- **AdminLayout.tsx(超管 220px)**:同步加铃铛 — sub-agent 一开始只加了用户侧 sidebar,通过 v0.20.1-smoke 测试发现 admin 路由无铃铛,补全
- **改动文件**: 12 个(新建 9 + 修改 3)
- **后端 curl 验证全过**: list / unread-count / PATCH read / POST read-all 都正常,user 隔离 OK
### 批次 G — 团管重置成员密码 ✅
- **权限矩阵(plan §G,服务端硬校验)**:
| 操作者 | 可重置 | 不可重置 |
|--------|--------|----------|
| 主管(`is_team_owner=True`) | 同团队**副管 + 成员** | 其他主管 / 自己 |
| 副管(`is_team_admin=True && !owner`) | 同团队**成员** | 副管 / 主管 / 自己 |
- **后端**:
- 新 view `team_reset_member_password_view` POST `/api/v1/team/members/<id>/reset-password`
- permission `IsTeamAdmin`(覆盖主管+副管)+ 服务端逐层判断 5 步
- 生成 8 位随机密码(`secrets.choice(ascii_letters+digits)`)+ `must_change_password=True`
- `log_admin_action` audit 留痕
- **前端**:
- `teamApi.resetMemberPassword(memberId)` → 返回 `{ new_password, ... }`
- TeamMembersPage `canResetPasswordFor(m)` helper 同权限矩阵
- 成员行 actions 加"重置密码"按钮(只在 canReset 为 true 时显示)
- 结果 modal 用 monospace 大字 + 浅灰底显示密码 + ⚠"关闭后无法再次查看" + 复制按钮
- **后端 6 项 curl 测试全通过**:
- T1 主管→副管(200 ✓)/ T2 主管→成员(200 ✓)/ T3 主管→自己(400 ✓)
- T4 副管→主管(403"主管须超管"✓)/ T5 副管→成员(200 ✓)/ T6 副管→副管(403"只有主管能重置副管"✓)
### 批次 H — TeamAssetsPage 重新编辑 prompt 丢失 ✅
- **根因 4 层叠加**:
1. TeamAssetsPage 没传 `onReEdit` prop 给 VideoDetailModal → 走 modal 内部 fallback
2. fallback 只 `setPrompt(task.prompt)`,没设 `editorHtml`
3. PromptInput 是 contenteditable,渲染依据是 `editorHtml` 不是 `prompt`
4. `assetVideoToTask``editorHtml` 显式置 `''` → fallback 拿到的就是空串
- **修法**: 跟 `store/generation.ts:reEdit` 对齐,用 `useInputBarStore.setState` 一次性批量灌入,关键补 `editorHtml: task.editorHtml || task.prompt || ''`,让 PromptInput 渲染 + rebuildMentionSpans 走完整路径
- **影响**: 团管 `/team/assets` reEdit ✓ / 超管 `/admin/assets` 同上 ✓ / 用户 `/user-assets` reEdit(走 generation.ts 的另一条路)不受影响
### 批次 I — Safari 自适应根因修法 ✅
- **现象**: Mac Safari + 14寸,/admin/records 翻页按钮永远在屏幕外,拖动能看到内容超出但滚到底也看不见
- **根因三个叠加**:
1. `100vh` 在 Safari 桌面端不可靠 — 算的是含工具栏/书签栏的 layout viewport,不是实际可见的 visual viewport
2. Flex `overflow` 经典 bug — `.content { flex: 1; overflow-y: auto }` 没加 `min-height: 0`,flex 子默认 `min-height: auto` 跟随内容撑开,overflow-y: auto 形同虚设
3. 翻页按钮无 `padding-bottom`,贴边视觉不舒服
- **修法(根因三件套,不是 padding 兜底)**:
1. `AdminLayout.module.css .layout``height: 100dvh` + fallback `100vh`(Dynamic Viewport Height,Safari 17+/Chrome 108+/FF 101+ 支持)
2. `AdminLayout.module.css .content` — 加 `min-height: 0`,让 flex 子元素正确 shrink
3. 4 个 admin 页 `.pagination``padding-bottom: 8px`(RecordsPage / UsersPage / LoginRecordsPage / AuditLogsPage)
4. `ProfilePage.module.css .page` 同样 `100vh` 模式 → 加 `100dvh` fallback,防同款 bug 在用户端复现
---
## 三、测试结果
### 基线对比
| 测试 | 基线 | v0.20.1 | 状态 |
|------|------|---------|------|
| `npx tsc -b` | 0 error | 0 error | ✓ |
| `npx vitest run` | 71 fail / 162 pass | 71 fail / 162 pass | ✓ 无新增回归 |
| `node test/v2-smoke.mjs` | 25 / 25 | 25 / 25 | ✓ |
| `node test/modal-interaction.mjs` | 8 / 8 | 8 / 8 | ✓ |
| `node test/v0.20.1-smoke.mjs` | 新增 | **11 / 11** | ✓ |
| `manage.py check` | clean | clean | ✓ |
### v0.20.1 smoke 覆盖项
1. Sidebar 消息中心铃铛可见(AdminLayout 220px)
2. 铃铛红点显示(有未读时)
3. 点铃铛跳 `/notifications`
4. 消息中心标题渲染
5. AdminLayout 高度 ≈ viewport(100dvh 生效)
6. `.content { min-height: 0 }` 生效
7. 详情弹窗"调试信息"折叠按钮存在
8. 调试信息默认收起(▸)
9. 调试信息可展开(▾)
10. video poster 已挂载(从 TOS CDN)
11. Teams 页加载(主管 badge 交互移交手测)
### 后端 endpoint curl 验证
- 通知 4 endpoint:list / unread-count / PATCH read / POST read-all 全过 ✓
- 团管重置密码 6 项权限 case(主/副 × 改主/改副/改成员/改自己)全过 ✓
---
## 四、本地状态
- **分支**: dev
- **commit 数**: 7 个功能 commit + 1 个 docs 收尾(下一步)
- **未 push**: 等用户授权
- **DB migration**: `generation/0021_add_api_prompt` + `notifications/0001_initial` 本地已 apply
---
## 五、待用户做
1. **本地手测** — 浏览器跑一遍 7 个批次的可见效果(建议优先测 D 通知 + G 重置密码 + I 14寸 Safari):
- admin/admin123 测后台(D / I)
- tudou 团管账号测 G 重置密码 + 验权限矩阵
- Mac Safari + 笔记本测 I(本地开发 Mac Safari 不一定能复现,实测需要测试服)
2. **授权 push** — 我等你"可以 push"指令,然后跑 `ALLOW_PUSH=1 git push origin dev`(memory `feedback_must_confirm_push`)
3. **CI 部署后** — 测试服 K8s 自动滚新版本,跟你确认线上观察 OK 后,可以考虑 cherry-pick 到 master
---
## 六、风险与已知限制
| 风险 | 缓解 |
|------|------|
| `api_prompt` 历史记录为空 | 详情弹窗只在 `api_prompt && api_prompt !== prompt` 才显示那栏,不影响 |
| 视频 `thumbnail_url` 历史记录为空 | `poster={thumbnailUrl ? ... : undefined}`,行为同改前 |
| Notification 表数据膨胀 | created_at 加 index;后续考虑 90 天软清理 |
| 重置密码后操作员不慎刷新 | modal 关闭后无法再次查看密码(已在 UI 用 ⚠ 提示)|
| Safari < 17 `100dvh` | fallback `100vh`,行为同改前(不变好也不变差) |
| 站内通知不实时(60s 轮询) | 公测前不上 WebSocket,60s 对自动封禁告警通知够用 |
---
## 七、跨项目踩坑记录(自动加 memory)
无新踩坑。本批次所有改动都遵循已有 memory(feedback_mysql_default / feedback_verify_before_deliver / feedback_must_confirm_push 等)。

View File

@ -0,0 +1,412 @@
# v0.20.1 批次开发计划
**起因**:v0.20.0(主题切换 V2)发布后,梳理待办时确定一波小修复 + 中等功能,一口气开干。
**预估总时长**:6-7h(已砍手机号登录,加 3 个新 bug)
**用户决定**:
- 砍批次 E(手机号验证码登录,以后再说)
- api_prompt 用"调试信息"折叠区方案
- 加 3 个 bug 修复(批次 G/H/I)
---
## 批次 A — 小修复 + 文档(~15min)
### A.1 主管理员撤销 bug ✅ 已修(commit 待发)
- **现状**:`TeamsPage.tsx` L825 主管理员 badge 无 onClick,管理员之前设的"主管"撤不掉
- **后端**:`admin_team_member_role_view` L1244-1250 收到 `is_team_admin=false` 自动同时清 `is_team_owner` ✅ 已支持
- **修复**:主管理员 badge 加 onClick + window.confirm + 调 `setMemberRole(false)`
- 已改,等批次结尾统一 commit
### A.2 文档标完成
- `项目总览与待办.md`
- P3 #1 界面主题切换 → 标 ✅ v0.20.0
- P3 #7 团队副管理员角色 → 标 ✅ v0.13.0(实际早做了文档没更)
- P2 #2 监控告警 飞书 5xx → 标 ✅(用户确认已经接通)
---
## 批次 B — 视频封面帧 前端补全(~1h)
### 发现
后端 `tasks.py:_handle_completed` L109-111 已经在生成完成后:
1. ffmpeg 提取首帧
2. 上传 TOS 到 `thumbnails/` 路径
3. 存到 `GenerationRecord.thumbnail_url`
**前端没用这个字段** → 列表/卡片还在用 `<video src={result_url}>` 直接加载视频。
### 改动
**后端(3 处加 1 行)**:
- `admin_records_view` 返回字段加 `thumbnail_url`
- `team_records_view` 同上
- `_serialize_task`(生成页用) — 检查是否有,有就加
**前端**:
- `types/index.ts`:
- `AdminRecord``thumbnail_url?: string`
- `BackendTask``thumbnail_url?: string`
- `GenerationTask``thumbnailUrl?: string`
- `store/generation.ts` `backendToFrontend` 转换函数加 `thumbnailUrl: t.thumbnail_url`
- `GenerationCard.tsx`:
- 列表/小预览用 `<img src={rewriteTosUrl(task.thumbnailUrl)}>` 替代 `<video src={task.resultUrl}>`
- hover/click 详情时才换 `<video>` 加载真实视频
- `RecordDetailModal.tsx`:
- `<video poster={rewriteTosUrl(r.thumbnail_url)} src={...}>` 显示首帧
- `VideoDetailModal.tsx`(资产页弹窗) — 同 poster
### 验收
- 浅色/深色页面 OK
- 网络面板看:列表打开时只下载 thumbnail(几十 KB)不下载 video(几 MB)
- 点详情才加载真实视频
---
## 批次 C — api_prompt 永久留痕 + 调试信息折叠区(方案 A,~30min)
### 改动
**后端**:
- `GenerationRecord` 加字段 `api_prompt = models.TextField(blank=True, default='', verbose_name='实际发给火山的提示词')`
- migration `0021_add_api_prompt.py`
- `video_generate_view``create_task``record.api_prompt = api_prompt; record.save(update_fields=['api_prompt'])`
- `admin_records_view` + `team_records_view` 返回 `api_prompt`
**前端**:
- `types/index.ts` `AdminRecord``api_prompt?: string`
- `RecordDetailModal.tsx` 右侧信息区底部加**默认折叠的"调试信息"区域**:
```
...(提示词区)...
▸ 调试信息(开发/客服参考) ← 默认收起,小灰字
```
点开后:
```
▾ 调试信息(开发/客服参考)
实际发给火山(@素材名被自动转换为「图片N」):
┌──────────────────────────────────┐
│ 90年代的上海,图片1 是女主角,... │ 等宽字 + 浅灰底
└──────────────────────────────────┘
Task ID: cgt-20260329024451-9fmv2 [复制]
原始错误(失败任务才显示):...
```
- 实现:`useState<boolean>(false)` 控制展开,onClick 切换
- 仅在 `api_prompt && api_prompt !== prompt` 时显示"实际发给火山"那栏,否则只有 task_id
### 验收
- 默认看不到这块(收起)
- 点开展示 api_prompt + task id + 失败原因等内部信息
- 历史记录 api_prompt 空时不显示那栏
- 不破坏现有 modal 视觉,平时用户察觉不到
---
## 批次 D — 站内通知系统(~4h)
### 改动
**后端 model**:
```python
class Notification(models.Model):
TYPE_CHOICES = [
('anomaly_disabled_user', '账号因异常被自动封禁'),
('anomaly_disabled_team', '团队因异常被自动封禁'),
('quota_warning', '额度即将耗尽'),
('system', '系统通知'),
]
recipient = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications')
type = models.CharField(max_length=30, choices=TYPE_CHOICES)
title = models.CharField(max_length=200)
content = models.TextField()
link_url = models.CharField(max_length=500, blank=True, default='') # 可选跳转
is_read = models.BooleanField(default=False, db_index=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ['-created_at']
indexes = [models.Index(fields=['recipient', 'is_read', '-created_at'])]
```
migration `0022_notification.py`
**后端 API**:
- `GET /api/v1/notifications?unread_only=false&page=1&page_size=20` — 列表(分页) + 未读数
- `PATCH /api/v1/notifications/<id>/read` — 标单条已读
- `POST /api/v1/notifications/read-all` — 标全部已读
**后端 触发点**:
- `anomaly_detector.py` 自动封禁逻辑里调 `Notification.objects.create(recipient=team_admin, type='anomaly_disabled_user', title=..., content=...)`
- 团队管理员收到下属被封禁的通知
**前端**:
- `web/src/types/index.ts``Notification` 类型
- `web/src/store/notification.ts` 新建 — Zustand store(getUnreadCount/list/markRead/markAllRead)
- `web/src/components/Sidebar.tsx` 顶部加铃铛 SVG(团管 + admin 都显示)+ 未读红点
- 点击展开下拉面板 / 跳通知中心页(后者更体面)
- 新页面 `web/src/pages/NotificationsPage.tsx` 列表 + "全部已读"按钮 + 行点击跳 link_url
- App.tsx 路由 `/notifications`
- 启动时 + 焦点回到 tab 时 fetch unread count
- 不做实时 push(WebSocket 太重),做 60s 轮询足够
### 验收
- 自动封禁动作触发后,被影响用户的团管立即(60s 内)在铃铛看到红点
- 点击通知跳对应安全日志 / 用户管理页
---
## 批次 E — ❌ 砍掉,以后再做
手机号验证码登录跟运维确认后**暂缓**。代码、阿里云短信模板申请都不做。
---
## 批次 G — 团管重置成员密码(~1h)
### 权限矩阵(关键)
| 操作者 | 可改 | 不可改 |
|---|---|---|
| 主管(`is_team_owner=true`) | 同团队**副管 + 成员** | 其他主管 |
| 副管(`is_team_admin=true && is_team_owner=false`) | 同团队**成员** | 副管 + 主管 |
### 改动
**后端**:
- `views.py` 新增 `team_reset_member_password_view`(`POST /api/v1/team/members/<id>/reset-password`)
- 权限:`IsTeamAdmin`
- 校验链:
```python
target = team.members.get(id=member_id)
operator = request.user
if target.team_id != operator.team_id:
return 403 '不在同一团队'
if target.is_team_owner:
return 403 '主管理员密码须由超管重置'
if target.is_team_admin and not operator.is_team_owner:
return 403 '只有主管理员能重置副管理员密码'
# 此处 target 要么是副管(operator 是主管)要么是普通成员 — 都可以改
```
- 复用 admin_reset_password_view 密码重置逻辑(随机 8 位密码 + `must_change_password=True` + 返回新密码)
- log_admin_action 记录审计日志
- `urls.py` 加路由
- 不动 model / migration
**前端**:
- `web/src/lib/api.ts` `teamApi``resetMemberPassword(memberId)` POST
- `web/src/pages/TeamMembersPage.tsx` 成员行 actions 按当前登录用户角色 + 目标成员角色判断:
- 我是主管(`useAuthStore.user.is_team_owner`)→ 对副管 + 成员行显示"重置密码"按钮
- 我是副管 → 只对成员行显示
- 点击 → confirm "重置 XXX 的密码?(成员下次登录需修改)" → 调 API → 弹窗显示新密码 + 复制按钮
- Toast 提示成功
### 验收
- 主管登录 → 团队成员管理 → 副管 + 成员行有"重置密码"按钮,其他主管行没有
- 副管登录 → 只有成员行有按钮,副管/主管行没有
- 后端硬校验:即使前端绕过(直接 curl),副管调 reset 副管 → 403
- 重置后该成员下次登录强制改密(must_change_password=true 走 ForceChangePasswordModal)
---
## 批次 H — TeamAssetsPage 重新编辑 prompt 丢失(~10min)
### 现状
团管在 `/team/assets` 点视频 → VideoDetailModal → "重新编辑"按钮 → 跳转 `/app` 生成页。
**Bug**:图片资源回到了 references 区,但 prompt 文本框是空的。
### 根因
1. `TeamAssetsPage.tsx` 没传 `onReEdit` 给 VideoDetailModal → 走 VideoDetailModal 内部 fallback(L214-240)
2. fallback 调 `store.setPrompt(task.prompt)` 但**没设 `editorHtml`**
3. PromptInput 是 contenteditable + 渲染 `editorHtml`,editorHtml 空 → 编辑器空
4. `assetVideoToTask` L55 把 `editorHtml: ''` 显式置空 → fallback 兜底 `task.editorHtml || task.prompt` 时拿到空字符串
### 改动
**VideoDetailModal.tsx handleReEdit fallback(L214-240)**:
`generation.ts:reEdit` 对齐,完整设置 InputBar 所有相关 state:
```js
useInputBarStore.setState({
prompt: task.prompt || '',
editorHtml: task.editorHtml || task.prompt || '', // ← 关键补
mode: (task.mode as ...) || 'universal',
model: (task.model as ...) || 'seedance_2.0',
aspectRatio: (task.aspectRatio as ...) || '16:9',
duration: task.duration || ...,
resolution: task.resolution || '720p',
references: refs, // 已有逻辑
assetMentions: task.assetMentions || [],
});
```
### 验收
- 团管 /team/assets → 任意视频 → "重新编辑" → 跳 /app → **prompt 文本框有内容** + 图片在 references 区
- 用户端 /user-assets 走 reEdit 不受影响(原本 work)
- 生成页 /app 内 reEdit 不受影响(原本 work)
---
## 批次 I — Safari 自适应根因修复(翻页按钮被截 + 防其他页面 viewport bug)(~15min)
### 现象
- 用户:Mac Safari + 14寸笔记本
- 路径:`/admin/records` 翻页按钮永远在屏幕外
- 拖动能看到内容超出,但**滚动到底也看不见翻页按钮**
### 根因(三个叠加)
**根因 1 — `100vh` 在 Safari 不可靠**
`AdminLayout.module.css` `.layout { height: 100vh }` 在桌面 Safari 算的是**含工具栏/书签栏的 layout viewport**,不是用户实际能看到的区域(visual viewport)。在小屏(14寸)+ 多个 UI bar 显示时,`.layout` 实际比可见区域高一截 → 底部被 UI bar 盖住。
**根因 2 — Flex `overflow` 经典 bug**
`.content { flex: 1; overflow-y: auto }` 没有 `min-height: 0`。flex 子元素默认 `min-height: auto`(根据内容撑开),Safari/Chrome 都不让 flex 父级约束子元素高度 → 子元素超出内容时,`overflow-y: auto` 形同虚设,`.content` 不滚动,底部按钮被父级 `.layout { overflow: hidden }` 截掉。
**根因 3 — 按钮无 padding-bottom**
就算前两个修了,翻页按钮紧贴容器边缘视觉也不舒服。
### 改动(根因修复 + polish)
**1. `AdminLayout.module.css` `.layout` — 用 dvh 替代 vh**:
```css
.layout {
display: flex;
height: 100vh; /* fallback for Safari < 17 */
height: 100dvh; /* Dynamic Viewport Height - 自动减去 toolbar/书签栏 */
overflow: hidden;
...
}
```
**2. `AdminLayout.module.css` `.content` — 加 min-height: 0 修 flex overflow**:
```css
.content {
flex: 1;
overflow-y: auto;
min-height: 0; /* ★ 关键:让 flex 子元素正确 shrink + 触发 overflow-y */
padding: 24px 32px 32px;
}
```
**3. 各 admin 页 `.pagination` — 加底部缓冲**:
- `RecordsPage.module.css`
- `UsersPage.module.css`
- `TeamsPage.module.css`
- `AdminAssetsPage.module.css`
- `LoginRecordsPage.module.css`
- `AuditLogsPage.module.css`
```css
.pagination {
margin-top: 16px;
padding-bottom: 8px;
}
```
**4. 检查 `VideoGenerationPage.module.css` `.layout` 也用 100vh,顺手改 dvh(团管 / 个人路由)**:
```css
.layout {
...
height: 100vh;
height: 100dvh;
}
```
**5. `index.css` `html, body, #root { height: 100% }`** — 已经是 100% 不依赖 vh,不动。
### 验收
- **14寸笔记本 Safari**`/admin/records` 翻页按钮**可见可点**
- **生成页** 滚动正常,InputBar 不会被 Safari 工具栏盖
- **桌面大屏** 不受影响(dvh = vh = 视口)
- **老 Safari (<17)** fallback 100vh 行为跟之前一样(不变好但不变差)
- **Chrome / Firefox** dvh 都支持,正常
### 为什么这个修法比"加 padding 兜底"更对
之前考虑的"加 padding-bottom 48px"是治标 — 假设 viewport 不会再变,加缓冲就行。但实际 Safari 在用户切换 zoom / 显示书签栏时,实际可见区域会变,固定 padding 仍会被切。`100dvh` 是浏览器动态计算可见区域,**永远准**。`min-height: 0` 修的是 flex 容器自身的滚动机制,**根因层面解决**。
---
## 批次 F — 回归 + commit + push(~30min)
### 测试
**新跑**:
- `npx tsc -b`
- `npx vitest run`
- `node test/v2-smoke.mjs`(25 项)
- `node test/modal-interaction.mjs`(8 项)
**新写**(`web/test/v0.20.1-smoke.mjs`):
- 主管理员撤销:admin 进团队详情 → 主管理员 badge 可点 → confirm → 撤销成功
- 视频封面帧:打开记录详情 → `<video>` 有 poster 属性
- api_prompt 留痕:打开任意 v0.19.2+ 记录详情 → 有"实际发给火山"一栏(如果 api_prompt 与 prompt 不同)
- 站内通知:登录后顶部铃铛存在 → 点击进 /notifications 页面 → 列表渲染
- 手机号登录 tab 存在(不验真实发短信,只验 UI)
### Commit 策略
**一个 commit 一波批次**:
1. `fix(admin): 主管理员撤销 bug — TeamsPage badge 加 onClick`(批次 A.1,已改)
2. `feat(records): 视频封面帧前端补全 — 列表用 thumbnail 替代 video,详情 poster 加首帧`(批次 B)
3. `feat(records): api_prompt 永久留痕 + 调试信息折叠区`(批次 C)
4. `feat(notification): 站内通知系统 — Notification 模型 + API + 铃铛 + 通知中心页`(批次 D)
5. `feat(team): 团管重置成员密码 — 新 API + 成员管理页按钮`(批次 G)
6. `fix(records): TeamAssetsPage 重新编辑 prompt 丢失 — fallback 补 editorHtml`(批次 H)
7. `fix(admin): 笔记本 14寸 Safari 翻页按钮被截 — .content + .pagination padding-bottom`(批次 I)
8. `docs: 项目总览待办标完成 P3#1/P3#7/P2#2 + v0.20.1 计划`(批次 A.2 + plan)
9. `chore: bump v0.20.1`(版本管理.md + 项目总览待办.md "最后更新")
### Push 时机
- 全部 commit + 跑过所有测试再 push,**一次性 push**
- 用户预先授权过 push 才推
---
## 风险与已知问题
| 风险 | 应对 |
|---|---|
| 阿里云短信模板审核延期 | 后端代码 + UI 都写好,无 template_code env 时友好降级 "短信服务暂未开通",审核通过后只需配 env 即可启用 |
| Notification 表数据膨胀 | created_at 加 index;后续考虑 90 天软清理 |
| 视频封面帧旧记录无 thumbnail_url | 前端 `task.thumbnailUrl || task.resultUrl` 兜底用 video 加载 |
| api_prompt 字段对历史记录为空 | 详情弹窗只在 `api_prompt && api_prompt !== prompt` 才显示新一栏 |
| 主管理员撤销影响有团队权限的功能 | Confirm 文案说清楚"将变回普通成员",防误点;后端日志会记录 audit log |
| Django migration 顺序冲突 | 编号 0021/0022/0023 顺序按批次 C/D/E 推进 |
| 手机号唯一约束 + null | MySQL unique 允许多个 NULL,新用户无手机号不会冲突 |
---
## Critical Files
修改:
- `backend/apps/generation/models.py` — GenerationRecord 加 api_prompt
- `backend/apps/generation/views.py` — admin_records_view / team_records_view / video_generate_view
- `backend/apps/generation/migrations/0021_add_api_prompt.py` — **新建**
- `backend/apps/accounts/models.py` — User 加 phone
- `backend/apps/accounts/views.py` — 加 sms_code_view / sms_login_view
- `backend/apps/accounts/migrations/00XX_user_phone.py` — **新建**
- `backend/apps/notifications/...`**新建 app**(model + views + serializers + migrations + urls)
- `backend/config/settings.py` — 加 ALIYUN_SMS_TEMPLATE_LOGIN env 读取
- `backend/utils/sms_client.py` — **新建**
- `backend/utils/anomaly_detector.py` — 自动封禁时创建 Notification
- `web/src/types/index.ts` — 加 api_prompt / thumbnail_url / Notification
- `web/src/store/generation.ts` — backendToFrontend 加 thumbnailUrl
- `web/src/store/notification.ts` — **新建**
- `web/src/lib/api.ts` — sms / notification API
- `web/src/components/GenerationCard.tsx` — 用 thumbnail 替代 video
- `web/src/components/RecordDetailModal.tsx` — poster + api_prompt 栏
- `web/src/components/VideoDetailModal.tsx` — poster
- `web/src/components/LoginModal.tsx` — 手机号 tab
- `web/src/components/Sidebar.tsx` — 铃铛
- `web/src/pages/TeamsPage.tsx` — 主管理员 onClick(已改)
- `web/src/pages/NotificationsPage.tsx` — **新建**
- `web/src/pages/UsersPage.tsx` — phone 字段
- `web/src/App.tsx` — /notifications 路由
新增测试:
- `web/test/v0.20.1-smoke.mjs` — **新建**
不动:
- 后端核心生成流程
- V2 主题切换相关
- master 分支