# 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 分支
