# 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 等)。
