visuddhinanda 2 هفته پیش
والد
کامیت
d281b449d3
32فایلهای تغییر یافته به همراه967 افزوده شده و 0 حذف شده
  1. 5 0
      dashboard-test/.gitignore
  2. 137 0
      dashboard-test/README.md
  3. 130 0
      dashboard-test/documents/development/testing-strategy.md
  4. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/01-list.png
  5. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/02-tab-0.png
  6. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/02-tab-1.png
  7. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/02-tab-2.png
  8. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/03-search.png
  9. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/06-create.png
  10. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/07-show.png
  11. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/08-chapter.png
  12. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/08-term.png
  13. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/09-share.png
  14. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/10-setting.png
  15. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/11-webhooks.png
  16. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/12-bad-id.png
  17. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/13-create-done.png
  18. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/13-create-filled.png
  19. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/14-edit-filled.png
  20. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/14-edit-saved.png
  21. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/15-menu.png
  22. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/15-transfer.png
  23. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/16-delete-confirm.png
  24. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/16-delete-done.png
  25. BIN
      dashboard-test/documents/testlog/assets/channel_20260907_034904/17-deleted-detail.png
  26. 209 0
      dashboard-test/documents/testlog/channel_20260907_034904.md
  27. 19 0
      dashboard-test/package.json
  28. 42 0
      dashboard-test/playwright.config.ts
  29. 43 0
      dashboard-test/src/lib/collect.ts
  30. 24 0
      dashboard-test/src/tests/auth.setup.ts
  31. 345 0
      dashboard-test/src/tests/channel.spec.ts
  32. 13 0
      dashboard-test/tsconfig.json

+ 5 - 0
dashboard-test/.gitignore

@@ -0,0 +1,5 @@
+node_modules/
+/package-lock.json
+playwright/.auth/
+playwright-report/
+test-results/

+ 137 - 0
dashboard-test/README.md

@@ -0,0 +1,137 @@
+# dashboard-test
+
+`dashboard-v6` 的 Playwright 端到端测试。**本目录仅用于 dashboard 测试**,
+不含任何应用代码,也不参与 dashboard-v6 的构建。
+
+它替代的是「人工点页面 + 肉眼盯 console」的手工测试:脚本驱动真实浏览器操作页面,
+自动采集 console 报错、未捕获异常、请求失败和 HTTP 4xx/5xx,并逐步截图。
+
+设计取舍见 [`documents/development/testing-strategy.md`](./documents/development/testing-strategy.md)。
+
+---
+
+## 安装
+
+```bash
+cd dashboard-test
+npm install
+npx playwright install chromium
+```
+
+依赖只有两个,且都在 `devDependencies`:
+
+| 包                | 用途                     |
+| ----------------- | ------------------------ |
+| `@playwright/test`| 测试运行器 + 浏览器驱动  |
+| `@types/node`     | TypeScript 类型          |
+
+## 前置:启动 dev server
+
+测试直连本地 dev server,需先在**另外的终端**起好:
+
+```bash
+cd ../api-v13      && php artisan serve      # http://127.0.0.1:8000
+cd ../dashboard-v6 && npm run dev            # http://127.0.0.1:4000/pcd-v2026/
+```
+
+## 运行
+
+```bash
+npm test                    # 跑全部
+npm run test:channel        # 只跑 channel 路由
+npm run test:ui             # UI Mode,可视化单步调试
+npm run test:headed         # 显示浏览器窗口
+npm run report              # 打开上次的 HTML 报告
+npm run codegen             # 录制操作生成选择器
+```
+
+正式记录一次测试时,把截图输出到带时间戳的目录:
+
+```bash
+E2E_SHOT=documents/testlog/assets/channel_20260907_034904 npm run test:channel
+```
+
+### 环境变量
+
+| 变量               | 默认值                              | 说明                         |
+| ------------------ | ----------------------------------- | ---------------------------- |
+| `E2E_BASE`         | `http://127.0.0.1:4000/pcd-v2026/`  | 被测站点根地址               |
+| `E2E_USER`         | `test161`                           | 测试账号                     |
+| `E2E_PASS`         | `12345`                             | 密码                         |
+| `E2E_SHOT`         | `documents/testlog/assets/channel_latest` | 截图输出目录           |
+| `E2E_DESTRUCTIVE`  | 未设置(执行)                      | 设为 `0` 跳过增删改用例      |
+
+---
+
+## 目录结构
+
+目录布局参照 `dashboard-v6`:代码在 `src/`,文档在 `documents/`。
+
+```
+dashboard-test/
+├── playwright.config.ts       配置:baseURL、串行执行、trace/video、setup 依赖
+├── src/
+│   ├── tests/
+│   │   ├── auth.setup.ts      登录一次,存 storageState
+│   │   └── channel.spec.ts    /workspace/channel 及子路由
+│   └── lib/
+│       └── collect.ts         console / pageerror / HTTP 错误采集与噪音过滤
+├── documents/
+│   ├── development/
+│   │   └── testing-strategy.md   方案取舍与约定
+│   └── testlog/
+│       ├── channel_<时间戳>.md    每次测试的结论与待办
+│       └── assets/<路由>_<时间戳>/ 该次测试的截图
+├── playwright/.auth/          登录态缓存(gitignore)
+├── playwright-report/         HTML 报告(gitignore)
+└── test-results/              失败用例的 trace / video(gitignore)
+```
+
+## 登录
+
+按 [官方推荐的 setup project 模式](https://playwright.dev/docs/auth):
+`src/tests/auth.setup.ts` 作为 `setup` 项目先跑一次登录,把会话存到
+`playwright/.auth/user.json`;`chromium` 项目通过 `dependencies: ['setup']`
+和 `storageState` 复用,所有用例都以已登录状态开始。
+
+---
+
+## 约定
+
+### 一个路由一个 spec
+
+`src/tests/<路由名>.spec.ts`,用 `test.describe` 按子路由分组,
+`test.step` 对应测试日志里的编号步骤。
+
+各 `describe` 相互独立(不用 `mode: 'serial'`),一个失败不阻断其余用例;
+执行顺序由 `workers: 1` 保证。
+
+### 已知缺陷写成断言
+
+发现的 bug 直接写进断言,注释标明对应的 BUG 编号,**修复前保持红灯**。
+当前 `src/tests/channel.spec.ts` 有两条这样的断言:
+
+- BUG-1 Webhooks 页签不应跳到已废弃的 `/studio/...`
+- BUG-4 不存在的频道不应出现 `Unexpected Application Error`
+
+所以 `npm test` 现在是 **4 通过 / 2 失败**,两个失败即上述待修项。
+
+### 破坏性操作
+
+在开发机上**实际执行**(新建、保存、删除),但:
+
+- 只操作脚本自建的临时数据,命名 `e2e-tmp-<timestamp>`
+- 测试结束必须清理干净,日志中声明无残留
+- 涉及第三方的操作(转让、分享授权)只验证弹窗渲染,不提交
+- `E2E_DESTRUCTIVE=0` 可整体跳过
+
+### 测试日志
+
+每次正式测试在 `documents/testlog/` 下写一个
+`<路由名>_<YYYYMMDD_HHMMSS>.md`,截图放同名 assets 目录并在 md 中内嵌引用。
+
+固定结构:概览表 → 结论 → 一、通过的功能(含截图)→ 二、待修复
+(每条一个 `- [ ]` 代办,含日志片段、截图、定位到文件行号的原因、修复方向)→
+三、未覆盖 / 待确认。
+
+已有记录:[`documents/testlog/channel_20260907_034904.md`](./documents/testlog/channel_20260907_034904.md)

+ 130 - 0
dashboard-test/documents/development/testing-strategy.md

@@ -0,0 +1,130 @@
+# 前端测试方案
+
+> dashboard-v6 · React 19 + Vite + Ant Design · 记录于 2026-09-07
+> 实现位于独立目录 `mint/dashboard-test`,运行方式见 [README](../../README.md)
+
+---
+
+## 目标
+
+把「人工点页面 + 肉眼看 console」的手工测试,交给 AI 助手(Claude Code)在容器内自动完成,
+同时**不改动 dashboard-v6 的依赖**——测试代码与依赖全部隔离在独立目录 `mint/dashboard-test`。
+
+---
+
+## 方案总览
+
+| 层面       | 选择                                    | 说明                                    |
+| ---------- | --------------------------------------- | --------------------------------------- |
+| 浏览器驱动 | Playwright Test(headless chromium)    | 独立目录 `dashboard-test` 的 devDependencies |
+| 运行位置   | 开发容器内                              | 与 dev server 同一网络,直连 127.0.0.1  |
+| 交互方式   | `@playwright/test` spec 文件            | 而非 MCP 逐步操作,理由见下             |
+| 采集内容   | console 日志、pageerror、HTTP 4xx/5xx、请求失败、截图 |                           |
+
+### 为什么不用 Playwright MCP
+
+MCP 每步一次工具调用,交互性确实更好,但有四个代价:
+
+1. **上下文消耗大** —— 每步返回整页 accessibility snapshot,中等复杂度页面就是几万 token,
+   点十次即耗尽上下文。脚本方式一次跑完只回来十几行日志。
+2. **每步一次往返** —— 「登录 → 列表 → 筛选 → 详情 → 断言」= 5 次调用。测已知流程时纯浪费。
+3. **不留资产** —— 点完即逝,下次重测要从头再点;脚本是文件,可重跑、可沉淀为回归测试。
+4. **不确定性** —— 同样指令两次可能点到不同元素;脚本里的选择器是确定的。
+
+**结论:探索性调试(不知道页面哪坏了、需要边看边试)可临时启用 MCP;
+已知流程的重复验证一律用脚本。** 当前默认走脚本。
+
+---
+
+## 环境
+
+### 依赖安装位置
+
+测试是一个独立的 npm 工程 `mint/dashboard-test`,与 dashboard-v6 完全隔离:
+
+```bash
+cd dashboard-test
+npm install                      # @playwright/test + @types/node,均在 devDependencies
+npx playwright install chromium
+```
+
+dashboard-v6 的 `package.json`、`package-lock.json`、`node_modules` **零改动**。
+
+### dev server 启动
+
+由 AI 在后台启动,端口如下:
+
+| 服务             | 命令                                             | 地址                                 |
+| ---------------- | ------------------------------------------------ | ------------------------------------ |
+| api-v13 后端     | `cd api-v13 && php artisan serve`                | http://127.0.0.1:8000                |
+| api-v13 前端     | `cd api-v13 && npm run dev`                      | http://127.0.0.1:5173(占用则 5174) |
+| **dashboard-v6** | `cd dashboard-v6 && npm run dev`                 | **http://127.0.0.1:4000/pcd-v2026/** |
+
+---
+
+## 实现要点
+
+- **登录**:`src/tests/auth.setup.ts` 作为 setup project 跑一次登录,
+  `storageState` 存到 `playwright/.auth/user.json`,各用例通过
+  `dependencies: ['setup']` 复用([官方推荐模式](https://playwright.dev/docs/auth))。
+- **日志采集**:`src/lib/collect.ts` 挂 `console` / `pageerror` / `requestfailed` /
+  `response>=400`,并过滤掉 antd、React 开发态的已知废弃警告,
+  只留真正值得看的内容。这是替代"人肉盯控制台"的关键。
+- **已知缺陷写成断言**:发现的 bug 直接写进 `expect`,注释标注 BUG 编号,
+  修复前保持红灯,修复后自动变绿——测试同时充当回归网。
+- **失败留证**:`trace: 'retain-on-failure'` + `video: 'retain-on-failure'`,
+  失败用例可用 `npx playwright show-trace` 逐帧回放。
+
+---
+
+## 目录约定
+
+| 路径                                       | 内容                                       |
+| ------------------------------------------ | ------------------------------------------ |
+| `src/tests/<路由名>.spec.ts`                    | 一个路由一个 spec                          |
+| `src/tests/auth.setup.ts`                       | 登录并保存 storageState                    |
+| `src/lib/collect.ts`                            | 日志采集与噪音过滤                         |
+| `README.md`                                     | 运行方式与环境变量                         |
+| `documents/development/testing-strategy.md`     | 本文:方案取舍与约定                       |
+| `documents/testlog/<路由名>_<时间戳>.md`        | 每次测试的日志,待修项用 `- [ ]` 代办标记  |
+| `documents/testlog/assets/<路由名>_<时间戳>/`   | 该次测试的截图,在 md 中内嵌引用           |
+
+目录布局参照 `dashboard-v6`:代码在 `src/`,文档在 `documents/`。
+
+时间戳格式 `YYYYMMDD_HHMMSS`,例:`channel_20260907_034904.md`。
+
+**测试日志固定结构**:概览表格 → 结论 → 一、通过的功能(含截图)→
+二、待修复(每条一个 `- [ ]` 代办,含日志片段、截图、定位到文件行号的原因、修复方向)→
+三、未覆盖(破坏性操作等)。
+
+**破坏性操作**(新建、保存、删除)在开发机上实际执行。约定:
+- 只操作脚本自己创建的临时数据,命名 `e2e-tmp-<timestamp>`
+- 测试结束必须清理,日志中声明「未留残留数据」
+- 用 `E2E_DESTRUCTIVE=0` 可跳过这部分
+- 涉及第三方(转让、分享授权)的操作仍只验证弹窗渲染,列入「未覆盖」
+
+---
+
+## 待办
+
+- [x] **登录态**:`harness.login()` 走一遍登录流程后用 `context.storageState()`
+      存到 `.auth.json`(已 gitignore)复用。测试账号通过 `E2E_USER` / `E2E_PASS` 传入。
+- [x] **脚本入库**:已迁到独立工程 `mint/dashboard-test`,随代码进 git,
+      依赖隔离在自己的 `package.json`。
+- [ ] **端口冲突**:5173 常被宿主机上已有的 dev server 占用,注意避免重复启动。
+
+---
+
+## 未来方向(需要时再动依赖)
+
+若接受修改 `package.json`,业界主流组合为:
+
+| 层面      | 方案                        | 备注                                          |
+| --------- | --------------------------- | --------------------------------------------- |
+| 单元/组件 | **Vitest** + React Testing Library | 已基本取代 Jest;browser mode 在真实浏览器跑组件测试 |
+| E2E       | **Playwright**              | ✅ 已采用(本目录)                            |
+| API mock  | **MSW**                     | 网络层拦截,单测与浏览器共用一套 handler      |
+| 视觉回归  | Playwright `toHaveScreenshot()` | 需团队协作看 diff 才上 Chromatic / Percy  |
+
+E2E 一层已落地。单元/组件测试要落地必须动 dashboard-v6 的 `package.json`,
+待需要时再议。

BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/01-list.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/02-tab-0.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/02-tab-1.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/02-tab-2.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/03-search.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/06-create.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/07-show.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/08-chapter.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/08-term.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/09-share.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/10-setting.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/11-webhooks.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/12-bad-id.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/13-create-done.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/13-create-filled.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/14-edit-filled.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/14-edit-saved.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/15-menu.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/15-transfer.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/16-delete-confirm.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/16-delete-done.png


BIN
dashboard-test/documents/testlog/assets/channel_20260907_034904/17-deleted-detail.png


+ 209 - 0
dashboard-test/documents/testlog/channel_20260907_034904.md

@@ -0,0 +1,209 @@
+# 测试日志 · /workspace/channel
+
+| 项目     | 内容                                                        |
+| -------- | ----------------------------------------------------------- |
+| 路由     | `/workspace/channel`(含 `:channelId`、`:channelId/setting`)|
+| 时间     | 2026-09-07 03:49                                            |
+| 账号     | `test161`                                                   |
+| 环境     | dashboard-v6 `:4000` · api-v13 `:8000` · 本地容器            |
+| 脚本     | [`src/tests/channel.spec.ts`](../../src/tests/channel.spec.ts)         |
+| 截图     | `assets/channel_20260907_034904/`                           |
+| 复跑     | `E2E_SHOT=documents/testlog/assets/channel_20260907_034904 npm run test:channel` |
+
+---
+
+## 结论
+
+17 个测试步骤(含破坏性操作),**通过 13 项,发现 2 个功能性缺陷 + 5 个体验/告警问题**。
+其中 Webhooks 页签完全不可达(白屏),chapter 列表永远为空。
+
+频道的增删改(CRUD)全链路正常,数据可持久化,删除后列表与详情页均正确失效。
+
+---
+
+## 一、通过的功能
+
+| # | 功能                        | 结果                          |
+| - | --------------------------- | ----------------------------- |
+| 1 | 列表页加载                  | 3 条,无报错                  |
+| 2 | 页签 此工作室 / 协作 / 社区 | 3 / 1 / 20 条,切换正常       |
+| 3 | 关键词搜索 + 清空           | 搜 `test` → 1 条;清空 → 3 条 |
+| 4 | 类型列筛选                  | 译文 / Nissaya / 注疏 / 原文  |
+| 5 | 创建时间排序                | 正常                          |
+| 6 | 新建频道表单                | 名称 / 类型 / 语言 正常弹出   |
+| 7 | 频道详情页                  | 标题、面包屑正常              |
+| 8 | 详情页 term 页签            | 正常                          |
+| 9 | 分享弹窗                    | 正常打开                      |
+| 10| 设置页 - 基本信息           | 表单正常渲染                  |
+| 11| **新建频道(真实提交)**    | rows 3 → 4,提示「操作成功」  |
+| 12| **编辑基本信息(真实保存)**| 提示「操作成功」,重载后 summary 已持久化 |
+| 13| **删除频道(真实删除)**    | 确认弹窗文案正确,rows 4 → 3  |
+| 14| **删除后访问原详情页**      | 正确失效(接口 400)          |
+
+### 列表页
+
+![列表页](assets/channel_20260907_034904/01-list.png)
+
+### 新建频道
+
+![新建](assets/channel_20260907_034904/06-create.png)
+
+### 频道详情
+
+![详情](assets/channel_20260907_034904/07-show.png)
+
+### 设置页
+
+![设置](assets/channel_20260907_034904/10-setting.png)
+
+### 破坏性操作
+
+新建频道(填入临时名称 `e2e-tmp-<timestamp>`):
+
+![新建填写](assets/channel_20260907_034904/13-create-filled.png)
+
+编辑基本信息并保存,重载后确认 summary 已写库:
+
+![编辑保存](assets/channel_20260907_034904/14-edit-saved.png)
+
+行操作菜单(分享 / 转让 / 删除)与删除确认弹窗:
+
+![删除确认](assets/channel_20260907_034904/16-delete-confirm.png)
+
+> 转让弹窗文案:「将 e2e-tmp-… 转让下面的用户。操作后需要等待对方确认后,资源才会被转移。」
+> 因需要接收方账号,**未实际提交**,仅确认弹窗与用户选择器正常渲染。
+>
+> ![转让弹窗](assets/channel_20260907_034904/15-transfer.png)
+
+---
+
+## 二、待修复
+
+### - [ ] BUG-1 🔴 Webhooks 页签跳转到不存在的路由,整页白屏
+
+点设置页 **Webhooks** 标签后跳到
+`/studio/test161/channel/{id}/setting/webhooks` → **404 Unexpected Application Error**。
+
+```
+[warning] No routes matched location "/studio/test161/channel/.../setting/webhooks"
+[error]   Error handled by React Router default ErrorBoundary: ErrorResponseImpl
+```
+
+![webhooks 白屏](assets/channel_20260907_034904/11-webhooks.png)
+
+**原因**:`src/pages/workspace/channel/setting.tsx:55` 仍使用 v4 旧路径:
+
+```js
+navigate(`/studio/${studioName}/channel/${channelId}/setting/${activeKey}`);
+```
+
+而 `src/routes/channelRoutes.ts` 中 setting 的实际路径是
+`/workspace/channel/:channelId/setting`。
+
+**附带问题**:`setting.tsx:20-22` 读取 `useParams()` 的 `type` 和 `id`,
+但路由未定义这两个参数,恒为 `undefined`,导致页签状态无法从 URL 恢复、
+WebhookEdit 分支(`id === "new"` / 编辑既有 webhook)永远进不去。
+
+**修复方向**:路由补 `setting/:type?/:id?` 子路由,`navigate` 改为 `/workspace/channel/...`。
+
+---
+
+### - [ ] BUG-2 🔴 chapter 列表接口 400,且异常未捕获
+
+```
+[HTTP 400]   /api/v2/progress?view=chapter&channel=...&progress=0.01&offset=0
+[PAGEERROR]  HttpError: no data
+```
+
+详情页 chapter 页签恒为「暂无数据」。
+
+![chapter 空](assets/channel_20260907_034904/08-chapter.png)
+
+**原因**:后端 `api-v13/app/Http/Controllers/ProgressController.php@index`
+的 switch 只实现了 `view=channel`,其余分支一律返回 `error('invalid view', 400)`。
+前端 `src/components/channel/ChapterInChannelList.tsx:177` 传的是 `view=chapter`。
+
+**同类问题**:`src/components/tipitaka/PaliChapterChannelList.tsx:23`
+传的 `view=chapter_channels` 后端同样未实现(本次未测,待验证)。
+
+**附带问题**:前端未 catch,直接冒泡成 pageerror。
+
+> ⚠️ `ProgressController.php` 当前在工作区有未提交改动,可能正在重构中。
+
+---
+
+### - [ ] BUG-3 🟡 详情页 chapter 表出现两个「公开性」列
+
+`ChapterInChannelList.tsx` 的 `progress` 列(:96)和 `view` 列(:110)
+都使用了 `forms.fields.publicity.label`,表头连续出现两个「公开性」。
+应分别改为「进度」和「视图」对应的 i18n key。
+
+---
+
+### - [ ] BUG-4 🟡 不存在的 channelId 导致白屏
+
+`channelLoader` 抛 `HttpError: no res` 后落到 React Router 默认 ErrorBoundary。
+
+![非法 id](assets/channel_20260907_034904/12-bad-id.png)
+
+**修复方向**:给 channel 路由加 `errorElement`,展示「频道不存在」而非堆栈。
+
+---
+
+### - [ ] BUG-5 🟡 删除后工具栏徽标计数不刷新
+
+删除频道后列表已刷新为空,但左上角徽标仍显示「此工作室 **4**」,实际只剩 3 条;
+需整页刷新才恢复正确。
+
+![删除后徽标未更新](assets/channel_20260907_034904/16-delete-done.png)
+
+**原因**:`src/components/channel/ChannelTable.tsx` 中拉取
+`/api/v2/channel-my-number` 的 `useEffect` 依赖数组只有 `[studioName]`,
+新建 / 删除后不会重新请求。
+
+**修复方向**:把计数请求抽成函数,在 `showDeleteConfirm` 成功回调和
+`ChannelCreate` 的 `onSuccess` 里与 `ref.current?.reload()` 一起调用。
+
+同样问题在**新建**后也存在(rows 变 4 但徽标仍为 3)。
+
+---
+
+### - [ ] BUG-6 🟢 社区页签头像 404
+
+```
+[HTTP 404] /storage/attachments-staging/2062811b-....jpg
+[HTTP 404] /storage/attachments-staging/44f57bab-....jpg
+```
+
+可能仅为本地环境缺少附件文件,需在其他环境复核。
+
+---
+
+### - [ ] BUG-7 🟢 React 开发态告警
+
+| 告警                                                                      | 位置                     |
+| ------------------------------------------------------------------------- | ------------------------ |
+| `Cannot update a component (ProTable) while rendering a different component (HeaderMenu)` | 切换工具栏页签时 setState in render |
+| `Form already set 'initialValues' with path 'type' / 'source_type'`        | 设置页 `channel/Edit`,initialValues 与字段 initialValue 冲突 |
+| `No HydrateFallback element provided to render during initial hydration`   | 路由配置                 |
+
+---
+
+## 三、未覆盖 / 待确认
+
+| 项 | 说明 |
+| --- | --- |
+| 转让频道提交 | 需要接收方账号,仅验证到弹窗渲染。补测需准备第二个测试账号 |
+| 分享弹窗授权提交 | 同上,需要目标用户 |
+| Webhooks 增删改 | 被 BUG-1 阻塞,路由修复后再测 |
+| 删除成功提示 | 删除后 1.2s 未捕获到 `message.success("删除成功")`,创建/保存的提示均能捕获。存疑,待人工确认是否真的没弹 |
+
+### 测试数据
+
+破坏性测试使用临时频道 `e2e-tmp-<timestamp>`,**测试结束已删除,未留残留数据**。
+脚本可用 `E2E_DESTRUCTIVE=0` 跳过这部分。
+
+### 回归断言
+
+BUG-1、BUG-4 已写成 `src/tests/channel.spec.ts` 里的断言,修复前 `npm test`
+会稳定报这两个失败(4 通过 / 2 失败),修复后自动转绿。

+ 19 - 0
dashboard-test/package.json

@@ -0,0 +1,19 @@
+{
+  "name": "dashboard-test",
+  "version": "1.0.0",
+  "private": true,
+  "description": "dashboard-v6 的 Playwright 端到端测试",
+  "type": "module",
+  "scripts": {
+    "test": "playwright test",
+    "test:ui": "playwright test --ui",
+    "test:headed": "playwright test --headed",
+    "test:channel": "playwright test channel",
+    "report": "playwright show-report",
+    "codegen": "playwright codegen http://127.0.0.1:4000/pcd-v2026"
+  },
+  "devDependencies": {
+    "@playwright/test": "^1.62.1",
+    "@types/node": "^24.10.1"
+  }
+}

+ 42 - 0
dashboard-test/playwright.config.ts

@@ -0,0 +1,42 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * dashboard-v6 端到端测试配置
+ * 文档: https://playwright.dev/docs/test-configuration
+ *
+ * 前置:dev server 需已启动
+ *   cd ../api-v13     && php artisan serve
+ *   cd ../dashboard-v6 && npm run dev
+ */
+export default defineConfig({
+  testDir: './src/tests',
+  /* 本项目是对开发环境的手工测试替代,串行执行更贴近真人操作,日志也更好读 */
+  fullyParallel: false,
+  workers: 1,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 2 : 0,
+  timeout: 90_000,
+  expect: { timeout: 15_000 },
+  reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
+
+  use: {
+    baseURL: process.env.E2E_BASE ?? 'http://127.0.0.1:4000/pcd-v2026/',
+    viewport: { width: 1600, height: 1000 },
+    trace: 'retain-on-failure',
+    video: 'retain-on-failure',
+    actionTimeout: 30_000,
+  },
+
+  projects: [
+    /* 登录一次,把会话存到 playwright/.auth/user.json */
+    { name: 'setup', testMatch: /.*\.setup\.ts/ },
+    {
+      name: 'chromium',
+      use: {
+        ...devices['Desktop Chrome'],
+        storageState: 'playwright/.auth/user.json',
+      },
+      dependencies: ['setup'],
+    },
+  ],
+});

+ 43 - 0
dashboard-test/src/lib/collect.ts

@@ -0,0 +1,43 @@
+import type { Page } from '@playwright/test';
+
+/** 已知的第三方库废弃警告 / React 开发态噪音,不计入问题清单 */
+const NOISE =
+  /Spin.*deprecated|destroyOnClose|Dropdown\.Button.*deprecated|ProList.*deprecated|Alert.*deprecated|React does not recognize|Invalid DOM property|empty string \(""\) was passed|Static function can not consume|React DevTools/;
+
+/**
+ * 挂上 console / pageerror / 请求失败 / HTTP 4xx-5xx 采集。
+ * 这是本套测试的核心——它替代的正是"人肉盯着 devtools 控制台"这件事。
+ */
+export function collect(page: Page) {
+  let logs: string[] = [];
+
+  page.on('console', (m) => {
+    const t = m.type();
+    if ((t === 'error' || t === 'warning') && !NOISE.test(m.text())) {
+      logs.push(`[${t}] ${m.text().slice(0, 250)}`);
+    }
+  });
+  page.on('pageerror', (e) => logs.push(`[PAGEERROR] ${e.message.slice(0, 250)}`));
+  page.on('requestfailed', (r) =>
+    logs.push(`[REQFAIL] ${r.url()} :: ${r.failure()?.errorText}`)
+  );
+  page.on('response', (r) => {
+    if (r.status() >= 400) logs.push(`[HTTP ${r.status()}] ${r.url()}`);
+  });
+
+  return {
+    /** 当前累积的日志 */
+    get all() {
+      return logs;
+    },
+    reset() {
+      logs = [];
+    },
+    /** 打印某个测试步骤采集到的内容并清空 */
+    dump(step: string) {
+      console.log(`\n### ${step}`);
+      logs.length ? logs.forEach((l) => console.log('   ' + l)) : console.log('   (clean)');
+      logs = [];
+    },
+  };
+}

+ 24 - 0
dashboard-test/src/tests/auth.setup.ts

@@ -0,0 +1,24 @@
+import { test as setup, expect } from '@playwright/test';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+export const AUTH_FILE = path.join(__dirname, '../../playwright/.auth/user.json');
+
+const USER = process.env.E2E_USER ?? 'test161';
+const PASS = process.env.E2E_PASS ?? '12345';
+
+setup('登录并保存会话', async ({ page }) => {
+  await page.goto('anonymous/sign-in');
+
+  // ProForm 会渲染隐藏 input,必须用 :visible 过滤
+  const inputs = page.locator('form input:visible');
+  await inputs.nth(0).fill(USER);
+  await inputs.nth(1).fill(PASS);
+  await page.getByRole('button', { name: /提 *交/ }).click();
+
+  // 登录成功会跳离 sign-in
+  await expect(page).not.toHaveURL(/sign-in/, { timeout: 20_000 });
+
+  await page.context().storageState({ path: AUTH_FILE });
+});

+ 345 - 0
dashboard-test/src/tests/channel.spec.ts

@@ -0,0 +1,345 @@
+import { test, expect, type Page } from '@playwright/test';
+import { collect } from '../lib/collect.js';
+
+/**
+ * /workspace/channel 及其子路由(:channelId、:channelId/setting)
+ *
+ * 截图输出目录由 E2E_SHOT 指定,默认写到 documents/testlog/assets/channel_latest,
+ * 正式记录时传入带时间戳的目录,与 documents/testlog/channel_<时间戳>.md 对应。
+ */
+const SHOT = process.env.E2E_SHOT ?? 'documents/testlog/assets/channel_latest';
+const TAB = '.ant-pro-table-list-toolbar-inline-menu-item';
+
+const shot = (page: Page, name: string) =>
+  page.screenshot({ path: `${SHOT}/${name}.png`, fullPage: true });
+const rows = (page: Page) => page.locator('.ant-table-tbody tr.ant-table-row').count();
+const toast = (page: Page) =>
+  page.locator('.ant-message').innerText().catch(() => '(无)');
+
+/* 各 describe 相互独立:一个失败不应阻断其余用例(顺序由 workers:1 保证) */
+
+test.describe('频道列表 /workspace/channel', () => {
+  test('列表加载、页签、搜索、筛选、排序、新建表单', async ({ page }) => {
+    const log = collect(page);
+
+    await test.step('1. 列表页加载', async () => {
+      await page.goto('workspace/channel', { waitUntil: 'networkidle' });
+      await page.waitForTimeout(2000);
+      console.log('title:', await page.title(), '| rows:', await rows(page));
+      await expect(page.locator('.ant-table')).toBeVisible();
+      await shot(page, '01-list');
+      log.dump('1. 列表页加载');
+    });
+
+    await test.step('2. 工具栏页签', async () => {
+      const n = await page.locator(TAB).count();
+      expect(n).toBeGreaterThan(0);
+      for (let i = 0; i < n; i++) {
+        const label = (await page.locator(TAB).nth(i).innerText()).trim().replace(/\s+/g, '');
+        await page.locator(TAB).nth(i).click();
+        await page.waitForTimeout(2500);
+        console.log(`\n>>> 页签「${label}」rows: ${await rows(page)}`);
+        await shot(page, `02-tab-${i}`);
+        log.dump(`2.${i} 页签 ${label}`);
+      }
+      await page.locator(TAB).nth(0).click();
+      await page.waitForTimeout(2000);
+      log.reset();
+    });
+
+    await test.step('3. 关键词搜索', async () => {
+      const kw = page.getByPlaceholder('请输入').first();
+      await kw.fill('test');
+      await page.keyboard.press('Enter');
+      await page.waitForTimeout(2500);
+      const hit = await rows(page);
+      console.log('\n>>> 搜索 "test" rows:', hit);
+      expect(hit).toBeGreaterThan(0);
+      await shot(page, '03-search');
+      log.dump('3. 关键词搜索');
+
+      await kw.fill('');
+      await page.keyboard.press('Enter');
+      await page.waitForTimeout(2000);
+      console.log('>>> 清空搜索 rows:', await rows(page));
+      log.dump('3b. 清空搜索');
+    });
+
+    await test.step('4. 类型列筛选', async () => {
+      await page.locator('th .ant-table-filter-trigger').nth(1).click();
+      await page.waitForTimeout(1200);
+      const items = await page
+        .locator('.ant-dropdown:visible .ant-dropdown-menu-item')
+        .allInnerTexts();
+      console.log('\n>>> 类型筛选项:', items.join(' / '));
+      expect(items.length).toBeGreaterThan(0);
+      await page.keyboard.press('Escape');
+      await page.waitForTimeout(600);
+      log.dump('4. 类型列筛选');
+    });
+
+    await test.step('5. 创建时间排序', async () => {
+      await page.locator('th.ant-table-column-has-sorters').first().click();
+      await page.waitForTimeout(2500);
+      console.log('\n>>> 排序后 rows:', await rows(page));
+      log.dump('5. 创建时间排序');
+    });
+
+    await test.step('6. 新建频道表单(不提交)', async () => {
+      await page.mouse.move(800, 700);
+      await page.waitForTimeout(800);
+      await page.getByRole('button', { name: /新建|新增/ }).first().click();
+      await page.waitForTimeout(2000);
+      await shot(page, '06-create');
+      const labels = await page.locator('.ant-popover:visible label').allInnerTexts();
+      console.log('\n>>> 新建表单字段:', labels.join(' / '));
+      expect(labels.length).toBeGreaterThan(0);
+      log.dump('6. 新建频道表单');
+      await page.keyboard.press('Escape');
+      await page.waitForTimeout(1000);
+    });
+  });
+});
+
+test.describe('频道详情 /workspace/channel/:channelId', () => {
+  test('详情页、term/chapter 页签、分享弹窗', async ({ page }) => {
+    const log = collect(page);
+
+    await page.goto('workspace/channel', { waitUntil: 'networkidle' });
+    await page.waitForTimeout(2000);
+    log.reset();
+
+    const link = page.locator('.ant-table-tbody tr.ant-table-row button.ant-btn-link').first();
+    const name = (await link.innerText()).trim();
+
+    await test.step('7. 打开频道详情', async () => {
+      await link.click();
+      await page.waitForTimeout(3500);
+      console.log(`\n>>> 打开频道「${name}」-> ${page.url()}\n    title: ${await page.title()}`);
+      await expect(page).toHaveURL(/\/workspace\/channel\/[^/]+$/);
+      await shot(page, '07-show');
+      log.dump('7. 频道详情页');
+    });
+
+    for (const t of ['term', 'chapter']) {
+      await test.step(`8. 详情 tab ${t}`, async () => {
+        await page.locator('.ant-tabs-tab', { hasText: new RegExp(`^${t}$`, 'i') }).first().click();
+        await page.waitForTimeout(3000);
+        await shot(page, `08-${t}`);
+        log.dump(`8. 详情 tab ${t}`);
+      });
+    }
+
+    await test.step('9. 分享弹窗', async () => {
+      await page.getByRole('button', { name: /分享|share/i }).first().click();
+      await page.waitForTimeout(3000);
+      await shot(page, '09-share');
+      const n = await page.locator('.ant-modal:visible, .ant-drawer:visible').count();
+      console.log('\n>>> 分享弹窗数量:', n);
+      expect(n).toBe(1);
+      log.dump('9. 分享弹窗');
+      await page.keyboard.press('Escape');
+      await page.waitForTimeout(1000);
+    });
+  });
+});
+
+test.describe('频道设置 /workspace/channel/:channelId/setting', () => {
+  test('基本信息与 Webhooks 页签', async ({ page }) => {
+    const log = collect(page);
+
+    await page.goto('workspace/channel', { waitUntil: 'networkidle' });
+    await page.waitForTimeout(2000);
+    await page.locator('.ant-table-tbody tr.ant-table-row button.ant-btn-link').first().click();
+    await page.waitForTimeout(3000);
+    const chId = page.url().split('/channel/')[1].split(/[/?]/)[0];
+    log.reset();
+
+    await test.step('10. 设置页 基本信息', async () => {
+      await page.goto(`workspace/channel/${chId}/setting`, { waitUntil: 'networkidle' });
+      await page.waitForTimeout(3000);
+      console.log(`\n>>> 设置页 ${page.url()} | title: ${await page.title()}`);
+      console.log('    tabs:', (await page.locator('.ant-tabs-tab').allInnerTexts()).join(' / '));
+      await shot(page, '10-setting');
+      log.dump('10. 设置页 基本信息');
+    });
+
+    await test.step('11. Webhooks 页签', async () => {
+      await page.locator('.ant-tabs-tab', { hasText: /webhook/i }).first().click();
+      await page.waitForTimeout(3000);
+      console.log('\n>>> 点 Webhooks 后 url:', page.url());
+      await shot(page, '11-webhooks');
+      log.dump('11. 设置页 Webhooks');
+
+      // BUG-1: 应停留在 /workspace/... 而非跳到已废弃的 /studio/...
+      // 修复前此断言失败,是预期的红灯。
+      await expect(page, 'Webhooks 页签不应跳到已废弃的 /studio 路径').toHaveURL(
+        /\/workspace\/channel\/[^/]+\/setting/
+      );
+    });
+  });
+
+  test('12. 不存在的 channelId', async ({ page }) => {
+    const log = collect(page);
+    await page.goto('workspace/channel/00000000-0000-0000-0000-000000000000', {
+      waitUntil: 'networkidle',
+    });
+    await page.waitForTimeout(3000);
+    await shot(page, '12-bad-id');
+    const body = (await page.locator('body').innerText()).slice(0, 200).replace(/\n+/g, ' | ');
+    console.log('\n>>> 页面文本:', body);
+    log.dump('12. 不存在的 channelId');
+
+    // BUG-4: 应展示友好的错误页,而非 React Router 默认 ErrorBoundary
+    expect(body, '不存在的频道应有友好提示').not.toContain('Unexpected Application Error');
+  });
+});
+
+/* ══════════════════════════════════════════════════════════
+ *  破坏性操作 —— 会真实写库,只操作脚本自建的 e2e-tmp-* 数据
+ *  E2E_DESTRUCTIVE=0 可跳过
+ * ══════════════════════════════════════════════════════════ */
+test.describe('频道增删改(破坏性)', () => {
+  test.skip(process.env.E2E_DESTRUCTIVE === '0', '已通过 E2E_DESTRUCTIVE=0 跳过');
+
+  test('新建 → 编辑保存 → 转让弹窗 → 删除 → 校验失效', async ({ page }) => {
+    const log = collect(page);
+    const TMP = `e2e-tmp-${Date.now()}`;
+    let newId = '';
+
+    await test.step('13. 新建频道(提交)', async () => {
+      await page.goto('workspace/channel', { waitUntil: 'networkidle' });
+      await page.waitForTimeout(2000);
+      const before = await rows(page);
+      log.reset();
+
+      await page.mouse.move(800, 700);
+      await page.waitForTimeout(500);
+      await page.getByRole('button', { name: /新建|新增/ }).first().click();
+      await page.waitForTimeout(1500);
+
+      const pop = page.locator('.ant-popover:visible');
+      await pop.locator('input:visible').first().fill(TMP);
+      await pop.locator('.ant-select').last().click(); // 语言必填
+      await page.waitForTimeout(1200);
+      await page.locator('.ant-select-dropdown:visible .ant-select-item-option').first().click();
+      await page.waitForTimeout(500);
+      await shot(page, '13-create-filled');
+
+      await pop.getByRole('button', { name: /提 *交/ }).click();
+      await page.waitForTimeout(3000);
+      const after = await rows(page);
+      console.log(`\n>>> 新建「${TMP}」rows: ${before} -> ${after}`);
+      console.log('    提示:', (await toast(page)).trim());
+      await shot(page, '13-create-done');
+      log.dump('13. 新建频道(提交)');
+      expect(after).toBe(before + 1);
+    });
+
+    await test.step('14. 编辑基本信息(保存 + 持久化校验)', async () => {
+      await page.goto('workspace/channel', { waitUntil: 'networkidle' });
+      await page.waitForTimeout(2500);
+      const kw = page.getByPlaceholder('请输入').first();
+      await kw.fill(TMP);
+      await page.keyboard.press('Enter');
+      await page.waitForTimeout(2500);
+      expect(await rows(page), '应能搜到刚新建的频道').toBe(1);
+
+      await page.locator('.ant-table-tbody tr.ant-table-row a', { hasText: /设置/ }).first().click();
+      await page.waitForTimeout(3500);
+      newId = page.url().split('/channel/')[1].split('/')[0];
+      console.log(`\n>>> 设置页 ${page.url()}`);
+      log.reset();
+
+      const SUMMARY = `e2e 自动化测试写入 ${new Date().toISOString()}`;
+      await page.locator('textarea').first().fill(SUMMARY);
+      await shot(page, '14-edit-filled');
+      await page.getByRole('button', { name: /提 *交|保 *存/ }).first().click();
+      await page.waitForTimeout(2500);
+      console.log('    保存提示:', (await toast(page)).trim());
+      await shot(page, '14-edit-saved');
+      log.dump('14. 编辑基本信息(保存)');
+
+      await page.reload({ waitUntil: 'networkidle' });
+      await page.waitForTimeout(3000);
+      const persisted = await page.locator('textarea').first().inputValue();
+      console.log(`>>> 重载后 summary ${persisted === SUMMARY ? '✓ 已持久化' : '✗ 未持久化'}`);
+      log.dump('14b. 重载验证持久化');
+      expect(persisted, 'summary 应已写库').toBe(SUMMARY);
+    });
+
+    await test.step('15. 转让弹窗(不提交,需接收方)', async () => {
+      await page.goto('workspace/channel', { waitUntil: 'networkidle' });
+      await page.waitForTimeout(2000);
+      const kw = page.getByPlaceholder('请输入').first();
+      await kw.fill(TMP);
+      await page.keyboard.press('Enter');
+      await page.waitForTimeout(2500);
+      log.reset();
+
+      await page.locator('.ant-table-tbody tr.ant-table-row button.ant-dropdown-trigger').first().click();
+      await page.waitForTimeout(1500);
+      await shot(page, '15-menu');
+      const menu = await page.locator('.ant-dropdown:visible .ant-dropdown-menu-item').allInnerTexts();
+      console.log('\n>>> 行操作菜单:', menu.join(' / '));
+
+      await page.locator('.ant-dropdown:visible .ant-dropdown-menu-item', { hasText: /转让/ }).first().click();
+      await page.waitForTimeout(3000);
+      await shot(page, '15-transfer');
+      const text = await page
+        .locator('.ant-modal:visible, .ant-drawer:visible')
+        .innerText()
+        .catch(() => '(无)');
+      console.log('>>> 弹窗文本:', text.slice(0, 300).replace(/\n+/g, ' | '));
+      expect(text).toContain('转让');
+      log.dump('15. 转让弹窗');
+      await page.keyboard.press('Escape');
+      await page.waitForTimeout(1500);
+    });
+
+    await test.step('16. 删除频道', async () => {
+      await page.goto('workspace/channel', { waitUntil: 'networkidle' });
+      await page.waitForTimeout(2000);
+      const beforeDel = await rows(page);
+      const kw = page.getByPlaceholder('请输入').first();
+      await kw.fill(TMP);
+      await page.keyboard.press('Enter');
+      await page.waitForTimeout(2500);
+      log.reset();
+
+      await page.locator('.ant-table-tbody tr.ant-table-row button.ant-dropdown-trigger').first().click();
+      await page.waitForTimeout(1500);
+      await page.locator('.ant-dropdown:visible .ant-dropdown-menu-item', { hasText: /删除/ }).first().click();
+      await page.waitForTimeout(2000);
+      await shot(page, '16-delete-confirm');
+      const confirm = await page.locator('.ant-modal-confirm:visible').innerText().catch(() => '(无)');
+      console.log('\n>>> 删除确认弹窗:', confirm.replace(/\n+/g, ' | ').slice(0, 200));
+      expect(confirm).toContain('不可撤销');
+
+      await page
+        .locator('.ant-modal-confirm:visible .ant-btn-dangerous, .ant-modal-confirm:visible .ant-btn-primary')
+        .first()
+        .click();
+      await page.waitForTimeout(1200);
+      console.log('    删除提示:', (await toast(page)).trim());
+      await shot(page, '16-delete-done');
+      await page.waitForTimeout(2500);
+      log.dump('16. 删除频道');
+
+      await page.goto('workspace/channel', { waitUntil: 'networkidle' });
+      await page.waitForTimeout(2500);
+      const afterDel = await rows(page);
+      console.log(`\n>>> 删除后列表 rows: ${beforeDel} -> ${afterDel}`);
+      expect(afterDel).toBe(beforeDel - 1);
+    });
+
+    await test.step('17. 删除后访问原详情页', async () => {
+      log.reset();
+      await page.goto(`workspace/channel/${newId}`, { waitUntil: 'networkidle' });
+      await page.waitForTimeout(3000);
+      await shot(page, '17-deleted-detail');
+      console.log('>>> 访问已删除频道:', (await page.locator('body').innerText()).slice(0, 150).replace(/\n+/g, ' | '));
+      log.dump('17. 删除后访问原详情页');
+    });
+  });
+});

+ 13 - 0
dashboard-test/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "types": ["node"],
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "noEmit": true
+  },
+  "include": ["src", "playwright.config.ts"]
+}