Просмотр исходного кода

初始化:康师傅产品创新看板 v10

- Lit 无构建 ESM 架构(原生 ESM + importmap,零构建)
- 机会卡池:9 张卡、三视角(给我的/全员池子/现场看板)
- 五类报告独立页面 + S1-S5 阶段筛选
- 液态玻璃视觉:三层玻璃 + 背景光场 + 内高光
- 中文排版与间距精度优化(4pt 栅格、6 档字号、3 档阴影)
- 移动端:底部 Tab 栏 + 单列布局 + safe-area
- 46 项单测 + 13 组规范检查全绿
FmodeCode 5 дней назад
Сommit
ec05b37ac8
100 измененных файлов с 11826 добавлено и 0 удалено
  1. 30 0
      .gitignore
  2. 287 0
      README.md
  3. 572 0
      TASK-visual-v11.md
  4. 1 0
      deploy/prefix/.txt
  5. 1 0
      deploy/prefix/local-sub.txt
  6. 1 0
      deploy/prefix/local.txt
  7. 1 0
      deploy/prefix/prod.txt
  8. 38 0
      docs/render-smoke-result.txt
  9. 422 0
      docs/screenshots/v10/measure-after.json
  10. 701 0
      docs/screenshots/v10/measure-before.json
  11. 14 0
      docs/screenshots/v10/shadow-report.json
  12. 53 0
      index.html
  13. 251 0
      legacy/README.md
  14. 142 0
      legacy/TASK-dashboard-v1.md
  15. 163 0
      legacy/TASK-dashboard-v2.md
  16. 190 0
      legacy/TASK-dashboard-v3.md
  17. 201 0
      legacy/TASK-dashboard-v4.md
  18. 215 0
      legacy/css/base.css
  19. 435 0
      legacy/css/cards.css
  20. 307 0
      legacy/css/components.css
  21. 346 0
      legacy/css/report.css
  22. 154 0
      legacy/css/responsive.css
  23. 89 0
      legacy/css/tokens.css
  24. BIN
      legacy/docs/screenshots/01-overview-1280.png
  25. BIN
      legacy/docs/screenshots/02-artifacts-1280.png
  26. BIN
      legacy/docs/screenshots/03-report-ch2-1440.png
  27. BIN
      legacy/docs/screenshots/04-evidence-drawer.png
  28. BIN
      legacy/docs/screenshots/05-report-768.png
  29. BIN
      legacy/docs/screenshots/06-error-state.png
  30. BIN
      legacy/docs/screenshots/07-loading-skeleton.png
  31. BIN
      legacy/docs/screenshots/08-insights-sources.png
  32. 67 0
      legacy/docs/smoke.js
  33. 23 0
      legacy/docs/verify-run.log
  34. 470 0
      legacy/docs/verify.js
  35. 81 0
      legacy/index.html
  36. 670 0
      legacy/js/app.js
  37. 35 0
      legacy/js/config.js
  38. 88 0
      legacy/js/data.js
  39. 417 0
      legacy/js/model.js
  40. 68 0
      legacy/js/query.js
  41. 62 0
      legacy/js/router.js
  42. 113 0
      legacy/js/taxonomy.js
  43. 100 0
      legacy/js/util.js
  44. 169 0
      legacy/js/views/artifacts.js
  45. 166 0
      legacy/js/views/common.js
  46. 177 0
      legacy/js/views/detail.js
  47. 200 0
      legacy/js/views/insights.js
  48. 111 0
      legacy/js/views/members.js
  49. 447 0
      legacy/js/views/opportunity.js
  50. 232 0
      legacy/js/views/overview.js
  51. 118 0
      legacy/js/views/pool.js
  52. 158 0
      legacy/js/views/projects.js
  53. 589 0
      legacy/js/views/report.js
  54. 75 0
      legacy/js/views/sources.js
  55. 119 0
      legacy/js/views/stages.js
  56. 37 0
      legacy/tools/check_frontend_secrets.sh
  57. 164 0
      legacy/tools/enrich.py
  58. 40 0
      legacy/tools/reset_demo.py
  59. 57 0
      legacy/tools/seed_insight_link.py
  60. 122 0
      legacy/tools/seed_long_report.py
  61. 489 0
      legacy/tools/seed_opportunities.py
  62. 395 0
      legacy/tools/seed_report.py
  63. 128 0
      legacy/tools/setup_cloud.py
  64. 28 0
      lib/vendor/@lit-labs/signals/v0.3.0/LICENSE
  65. 312 0
      lib/vendor/@lit-labs/signals/v0.3.0/README.md
  66. 15 0
      lib/vendor/@lit-labs/signals/v0.3.0/index.d.ts
  67. 1 0
      lib/vendor/@lit-labs/signals/v0.3.0/index.d.ts.map
  68. 7 0
      lib/vendor/@lit-labs/signals/v0.3.0/index.js
  69. 1 0
      lib/vendor/@lit-labs/signals/v0.3.0/index.js.map
  70. 26 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/html-tag.d.ts
  71. 1 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/html-tag.d.ts.map
  72. 7 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/html-tag.js
  73. 1 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/html-tag.js.map
  74. 36 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/signal-watcher.d.ts
  75. 1 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/signal-watcher.d.ts.map
  76. 5 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/signal-watcher.js
  77. 0 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/signal-watcher.js.map
  78. 30 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/watch.d.ts
  79. 1 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/watch.d.ts.map
  80. 8 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/watch.js
  81. 0 0
      lib/vendor/@lit-labs/signals/v0.3.0/lib/watch.js.map
  82. 140 0
      lib/vendor/@lit-labs/signals/v0.3.0/package.json
  83. 28 0
      lib/vendor/@lit/context/v1.1.6/LICENSE
  84. 206 0
      lib/vendor/@lit/context/v1.1.6/README.md
  85. 13 0
      lib/vendor/@lit/context/v1.1.6/index.d.ts
  86. 1 0
      lib/vendor/@lit/context/v1.1.6/index.d.ts.map
  87. 2 0
      lib/vendor/@lit/context/v1.1.6/index.js
  88. 1 0
      lib/vendor/@lit/context/v1.1.6/index.js.map
  89. 57 0
      lib/vendor/@lit/context/v1.1.6/lib/context-request-event.d.ts
  90. 1 0
      lib/vendor/@lit/context/v1.1.6/lib/context-request-event.d.ts.map
  91. 7 0
      lib/vendor/@lit/context/v1.1.6/lib/context-request-event.js
  92. 0 0
      lib/vendor/@lit/context/v1.1.6/lib/context-request-event.js.map
  93. 32 0
      lib/vendor/@lit/context/v1.1.6/lib/context-root.d.ts
  94. 1 0
      lib/vendor/@lit/context/v1.1.6/lib/context-root.d.ts.map
  95. 7 0
      lib/vendor/@lit/context/v1.1.6/lib/context-root.js
  96. 0 0
      lib/vendor/@lit/context/v1.1.6/lib/context-root.js.map
  97. 41 0
      lib/vendor/@lit/context/v1.1.6/lib/controllers/context-consumer.d.ts
  98. 1 0
      lib/vendor/@lit/context/v1.1.6/lib/controllers/context-consumer.d.ts.map
  99. 7 0
      lib/vendor/@lit/context/v1.1.6/lib/controllers/context-consumer.js
  100. 0 0
      lib/vendor/@lit/context/v1.1.6/lib/controllers/context-consumer.js.map

+ 30 - 0
.gitignore

@@ -0,0 +1,30 @@
+# 依赖
+node_modules/
+package-lock.json
+
+# 运行日志(Claude Code 每轮 dispatch 的输出)
+claude_*_run.log
+*.run.log
+
+# 临时产物
+dist/
+*.tar.gz
+.tmp/
+tmp/
+
+# 截图产物(体积大,按需生成)
+docs/screenshots/**/*.png
+!docs/screenshots/README.md
+
+# 本地环境与凭据(绝不入库)
+.env
+.env.*
+.credentials
+*.pem
+*.key
+
+# 编辑器 / 系统
+.DS_Store
+.vscode/
+.idea/
+*.swp

+ 287 - 0
README.md

@@ -0,0 +1,287 @@
+# 康师傅产品创新看板 · v6(展示逻辑重构:五类报告页 + 阶段产出 + 液态玻璃)
+
+> 研发中心 AI 赋能实战营的**组织资产系统**:30–40 位学员共用一个机会卡池、一份证据台账、一组分析报告。
+> 本版按 `fmode-code-lit` 规范**重写骨架**,并把前四轮谈定的业务全部落进来 —— 是「用新规范做出旧需求」,不是缩水重做。
+
+- 入口:`index.html`(由 `scripts/config.mjs` 生成,零构建)
+- 旧版(IIFE + `window.KF_*` + innerHTML)原样保留在 `legacy/`,只作业务逻辑参考,**不参与部署**
+- 代码规模:`src/` 6,216 行(含 `src/dev/` 两个开发辅助)· `test/` 529 行(46 个 Node 单测)
+- v6 只改**展示逻辑**,不动 v5 的分层骨架:新增 `core/library/`(报告类型归类)、五个报告类型页、阶段产出内容视角、玻璃令牌重做(见第 8 节)。
+
+---
+
+## 1. 目录结构与分层
+
+```
+src/
+  app.config.js         全应用唯一配置(Parse 地址 / 表名 / 阈值),只有公开 App ID,无凭据
+  app.js                入口:注入设计令牌、注册外壳、打印启动横幅
+  core/                 [core] 纯 ESM 逻辑:不碰 window/document/fetch,不 import Lit,Node 可测
+    taxonomy/             阶段 / 层 / 优先级 / 状态 / 决策 / 上传方式 + 字段归一(S1品类分析、S1 品类分析、S1 都认)
+    opportunity/          机会卡建模、多人评分统计、排序、状态流转(statusPatch 只产出 patch,不写库)
+    evidence/chain.js     证据链双向反查(结论↔成果↔归纳↔机会卡),全部返回「名字 + 人名」
+    kfs/                  Parse 行 → 业务模型(buildModel)、台账六维筛选(ledger)、派生统计(stats)
+    report.js             报告章节 / 管线 / 结论块 / 概览
+    index.js              唯一公共出口(74 个导出)
+  components/           [wc] 组件库:extends LitElement,只渲染与交互,零业务
+    kf-glass-card / kf-stat / kf-badge / kf-empty / kf-table / kf-drawer / kf-toast
+    kf-opportunity-card  机会卡:五要素 + 来源四要素 + 状态快捷切换 + 内嵌报告链接(stopPropagation)
+    kf-score-panel       多人评分面板(平均 / 人工 / Agent / 逐条明细 / 我来评分)
+    kf-evidence-chain    证据链:成果→引用它的结论;归纳→来源报告
+    kf-pipeline          数据管线
+  layout/               [page] 外壳
+    kf-app               布局 / 路由订阅 / 全局抽屉 / 写操作(改状态、评分)/ 所有交互事件的唯一入口
+    kf-nav / kf-topbar   导航(池子数、卡住人数)/ 顶栏(「我是谁」、同步状态、刷新)
+  pages/                [page] 七个视图:工作台 / 池 / 阶段 / 报告目录 / 报告阅读 / 台账 / 成员
+  services/             board-service:取数(http)→ core.buildModel → signal;写 Parse 后局部合并 + 后台刷新
+  utils/                router.js(History、子路径安全)/ http.js(唯一网络出口、分页、超时重试)/ storage.js
+  styles/tokens.js      设计令牌 + 玻璃基元(Constructable StyleSheet,各组件 shadow root 内 `adoptedStyleSheets` 引入)
+  dev/serve.mjs         本地预览服务器(History 回退 200 / `/parse` 同源代理 / 子路径挂载)—— 不参与部署
+lib/vendor/             vendored 依赖(见第 2 节)
+scripts/                config / vendor / check / verify-modules / verify-render / verify-browser / seed / deploy.sh
+test/                   core 单测(node:test,无 jsdom)
+```
+
+**分层边界(越界即返工)**:`core` 不认识 DOM(`scripts/check.mjs` 第 5 项硬校验);`components` 不 import services、不 fetch;`layout/pages` 组装一切,写库只经过 `services → utils/http`。
+
+---
+
+## 2. 依赖走哪条路:**方案 B(本地 vendor)**
+
+实测(2026-10-29,本机直连):
+
+| 地址 | 结果 |
+|---|---|
+| `https://repose.fmode.cn/npm/importmap/lit-v3.json` | **404** |
+| `https://kangshifu.fmode.cn/npm/importmap/lit-v3.json` | **404** |
+| `https://app.fmode.cn/npm/importmap/lit-v3.json` | **404** |
+| `https://kangshifu.fmode.cn/npm/lit/v3.3.3/lit.js` | **404** |
+
+方案 A(探测真实 Repose)无果:本机 `.credentials` 与工作目录中没有任何 Repose 地址或凭据,三条已知候选路径全部 404。
+
+因此 **vendor 化**:`scripts/vendor.mjs` 用 `npm pack` 下载(不 `npm install`、不留 node_modules),解到 `lib/vendor/<包>/v<版本>/`,去掉 `.ts / .d.ts / .map / src / development / decorators / test`,并校验 `package.json` 入口文件存在。
+
+| 包 | 版本 | importmap 入口 |
+|---|---|---|
+| lit | 3.3.3 | `lib/vendor/lit/v3.3.3/index.js` |
+| lit-html | 3.3.3 | `lib/vendor/lit-html/v3.3.3/lit-html.js` |
+| lit-element | 4.2.2 | `lib/vendor/lit-element/v4.2.2/index.js` |
+| @lit/reactive-element | 2.1.2 | `lib/vendor/@lit/reactive-element/v2.1.2/reactive-element.js` |
+| @lit/context | 1.1.6 | `lib/vendor/@lit/context/v1.1.6/index.js` |
+| @lit-labs/signals | 0.3.0 | `lib/vendor/@lit-labs/signals/v0.3.0/index.js` |
+| signal-polyfill | 0.2.2 | `lib/vendor/signal-polyfill/v0.2.2/dist/index.js` |
+
+importmap 里每个包都同时映射 `pkg` 与 `pkg/`(子路径),保证 lit 系**单实例**(模块图实测:46 个模块中,vendor 只出现 `lit@v3.3.3`(含 `directives/class-map.js`)、`lit-html@v3.3.3`、`@lit/reactive-element@v2.1.2`、`@lit-labs/signals@v0.3.0` 各一个版本;其余 41 个是本仓 `src/` 文件)。所有 vendor 路径都写在 `<base href>` 前缀下,与业务码同源同前缀,**随 base 走**。
+
+> 以后 Repose 恢复:把 `scripts/config.mjs` 里 `importMapJson()` 的 vendor 路径换成 `/npm/...` 即可,源码零改动(源码只 import 裸标识符)。
+
+### 依赖注入(@lit/context)的取舍
+
+`@lit/context` 已 vendor 进 importmap,但本版**未使用**:应用只有一个全局服务(`boardService` 单例 + signals),页面层直接 import 单例、组件靠属性与事件通信,少一层间接。若之后拆成多团队协作的模块化页面,再按规范接 `provide()/ContextConsumer`。这是**刻意没做**的一项,不是遗漏。
+
+---
+
+## 3. 机会卡为什么这么建模:新建 `KfOpportunity`,不复用 `KfArtifact`
+
+| 维度 | 成果 `KfArtifact` | 机会卡 |
+|---|---|---|
+| 生命周期 | 一次交付,写完即历史(改它等于篡改台账) | 候选 → 立项 / 否决,反复流转 |
+| 状态 | 无 | `status` + `decision` + `decisionLog`(每次改都留痕) |
+| 评分 | 无 | `scores[]` 多人评分,平均分随人变化 |
+| 组织含义 | 过程资产(过程 / 沉淀 / 记录) | **组织资产**:研发中心的机会储备 |
+
+把两者合在一个类里,客户「改一次卡片状态」就会污染「成果台账」(updatedAt 变了、证据链追溯的成果也跟着变)。所以:
+
+- 机会卡单独一个类,**关联只存 id**(`linkedArtifacts[] / linkedProject`),成果被删卡片不消失,只标「库里已不存在」;
+- 卡片上的「谁 · 哪个 Agent · 什么方式 · 什么时候」是四要素 + 两个扩展位(`agentId`、`sourceSession`),为后续多个数字分身留好位置,不写死单分身;
+- 一处 `oneLiner` 是历史测试残留的占位符(`"x"`)→ core 里按「未填写」处理,**不编造内容**。
+
+---
+
+## 4. 22 项验收:怎么验、实测结果
+
+复现命令(全部在本机实跑过):
+
+```bash
+node scripts/config.mjs local                 # 生成 index.html(base / importmap / 前缀三处同源)
+node --test 'test/*.test.js'                  # core 单测:38/38 通过
+node scripts/check.mjs                        # 静态体检:CHECK OK(14 组断言:A 组 1–9 + 凭据 + 语法 + importmap + 元素注册 + wc 用 Lit)
+node src/dev/serve.mjs --port 4173 &          # 本地:History 回退 200 + /parse 同源代理
+node scripts/verify-modules.mjs http://127.0.0.1:4173/            # MODULE GRAPH OK
+node scripts/verify-modules.mjs http://127.0.0.1:4174/kangshifu/dev/inno-board/   # 子路径:MODULE GRAPH OK
+node scripts/verify-render.mjs                # jsdom 渲染冒烟:RENDER SMOKE OK(含写库闭环)
+CHROME_PATH=<chrome> node scripts/verify-browser.mjs             # 真实浏览器:BROWSER VERIFY OK(36/36)
+```
+
+### A. 规范合规
+
+| # | 项 | 怎么验 | 实测结果 |
+|---|---|---|---|
+| 1 | 无装饰器 | `check.mjs` 去注释后匹配 `@customElement/@property/@state/...` | ✓ 0 命中 |
+| 2 | 相对 import 带 `.js` | 扫描 `from './x'`、`import('./x')`,并检查目标文件存在 | ✓ 全部带 `.js` 且存在 |
+| 3 | 无 `window.KF_*` | grep `src/` | ✓ 0 命中 |
+| 4 | 无 innerHTML | 去注释后 grep | ✓ 0 命中(全部 Lit 模板) |
+| 5 | core 不碰 DOM | core 下逐行 grep `window/document/fetch/localStorage/navigator/HTMLElement/CustomEvent`(跳过 import 行) | ✓ 0 命中 |
+| 6 | 视图不直接 fetch | pages/layout/components 下 grep `fetch(`;services 也只经 `utils/http.js` | ✓ 0 命中,`fetch(` 只出现在 `utils/http.js` |
+| 7 | base 三处同源 | `config.mjs` 由前缀文件生成 base / importmap / 入口,写完反向自检;`check.mjs --env` 再校一次 | ✓ `base === deploy/prefix/local.txt === importmap 前缀`,14 条映射全部以 base 开头 |
+| 8 | 无 `@lit-labs/router` | grep src + index.html | ✓ 0 命中;路由自写 `utils/router.js` |
+| 9 | lit 单实例 | importmap 每包只出现一个版本目录;源码不直引 `lib/vendor`;模块图爬取里 vendor 路径去重 | ✓ 两处都过 |
+| 10 | 模块图全 200 | `verify-modules.mjs`:index → 入口 → 递归静态 import(含动态 import 字面量)→ 裸标识符按 importmap 解析(含 `/` 前缀映射)→ 逐个请求断言 200 + `text/javascript`;再断言深链接 6 条 200 | ✓ 根部署:46 个模块全部 200;子路径 `/kangshifu/dev/inno-board/`:同样 46 个全部 200;深链接 6 条(含不存在路径)全部 200 且无重定向 |
+
+### B. 功能
+
+真实浏览器(Chromium 156 headless,1440×960)逐项实测,截图在 `docs/screenshots/`。
+
+| # | 项 | 怎么触发的 | 实测输出 |
+|---|---|---|---|
+| 11 | 真实取数 | 页面直连 Parse(本地经 `/parse` 同源代理)| 26 成果 / 9 机会卡 / 11 成员 / 4 归纳 / 4 原材料;卡片数、成员数、卡住人数均来自库,与 core 计算一致(见 12 的一致性断言) |
+| 12 | 6 维筛选叠加 | `/pool` 点「候选」→「S 级」→「拌面」;`/ledger` 六行筛选条(优先级/品类/阶段/人/层/状态) | 页面结果与 `core.applyFilter` 结果逐 id 相等(测试从页面取 DOM、从 core 取期望值比较);分面计数受其他维度约束(0 条的置灰) |
+| 13 | URL 状态保留、刷新不丢 | 筛选后读 URL → `page.reload()` | URL = `/pool?status=candidate&priority=S&category=拌面&sort=score`;刷新后按钮仍 `aria-pressed=true`,列表 id 集合不变 |
+| 14 | 深链接直接访问 | 直接打开 `/pool/<id>`、`/report/<id>`、`/ledger?focus=…`、`/stages`、不存在路径;子路径部署同一套 | 抽屉直接打开正确卡片;台账高亮行;不存在路径落「页面不存在」;**子路径** `/kangshifu/dev/inno-board/` 下 46 个模块 200、深链接 6 条 200 |
+| 15 | 卡片五要素 | `/pool` 读每张卡的文本与数据 | 9 张卡的优先级/品类/阶段、名字、一句话、评分/证据/引用、决策行、来源行全部齐全(断言:每张卡都有这些字段且文本里有「决策:」与「N 人评分 · 平均」) |
+| 16 | 谁 + Agent + 方式 + 时间 | 同上,断言四要素非空且文本里出现 | 例:`张伟 · 康师傅产品创新助手 · 会话上传 · 10月29日 08:32`;3 个 Agent(主分身 / 品类调研 / 概念设计)都出现;缺字段的卡显示「未记录」而不是编造 |
+| 17 | 多人评分显示平均 | 打开有一张 6 人评分的卡 | 大字 `81.3 /100 平均`,明细:李娜 86、吴敏 90、周涛 84、评价 Agent 88、赵磊 78、郑凯 62;人工平均 80 · Agent 平均 88 · 分歧 28 |
+| 18 | 改状态可持久 | 抽屉里点「已立项」→ 刷新 → 重新拉库 | 刷新后仍是 `approved`,决策 `by=张伟`,决策记录新增一条;seed 二次执行不覆盖人工改过的状态 |
+| 19 | 排序切换顺序真实变化 | `/pool` 点「综合评分」 | 优先级序 `[neS5…, qVpN…, ws5X…, …]` → 评分序 `[neS5…, 8fmk…, qVpN…, …]`,顺序不同且平均分单调不增;URL 写入 `?sort=score` |
+| 20 | 首页三视角 + 痛点对照 | `/`,顶栏选「张伟」 | 三视角齐全;「给你的」显示 `张伟,你已提交 3 份成果`、`还差 4 个阶段没交:S2、S3、S4、S5`;池子视角 9 张卡 + 状态分布 + 最新 3 张卡片形态 + 最高分;现场看板 5×11 矩阵 + 点名 `何雨 G3 · 停在 S4 · 会话 13 条 · 31 小时没动静`;五大痛点 5 行对照 |
+| 21 | 五阶段板块 | `/stages` | 5 个板块,各显示份数 / 产出人数 / 引用 / 机会卡数 + 最新 4 条(带作者、方式、Agent、时间)+ 谁产的;S3=5 份成果、4 张机会卡 |
+| 22 | 证据链双向、点名到人 | 报告页点结论旁「看证据链」→ 抽屉;「在台账中高亮」;台账点成果行 → 反查;「打开证据链」 | 抽屉:`4 人独立提到 / 4 条原文 / 3 条支撑成果`,列出 `夜宵场景低卡高蛋白拌面(张伟)`、`办公室3分钟速食拌面(李娜)`、`学生党实惠大份拌面(王芳)`,原文带平台·点赞·作者(抖音评论·点赞 412·夜宵党_312…);跳台账后对应行高亮;反查:成果 → 「被这些结论引用」逐条列出结论原文与报告名 + 被哪些归纳引用 + 出现在哪张机会卡 |
+
+报告页(保留 v3 能力):吸顶章节导航 + 滚动高亮 + 点章节写回 `?ch=`(刷新还原)、每章「这章在回答:」、数据管线 `2,727 → 1,190 → 730,过滤率 73%`、KPI、交叉分析表、结论块、附录「结论 × 证据」总表 —— 以上均由浏览器脚本断言。
+
+### C. 红线
+
+| 项 | 结果 |
+|---|---|
+| 前端无凭据 | `check.mjs` 按 `.credentials` 里每个真实值逐条比对 `src/` 与 `index.html`:**0 泄露**;`masterKey` 字符串 0 命中;源码里的长随机串白名单校验通过 |
+| 控制台无报错 | 真实浏览器全程监听 `console.error` + `pageerror`:**0 条**(网络断开的 ERR 与 favicon 除外) |
+| 对比度 ≥ 4.5:1 | 浏览器脚本在真实渲染后的 DOM 上逐个文字节点取 `getComputedStyle` 颜色,沿 shadow 宿主向上合成背景色后计算 WCAG 对比度:**460 个可量化节点,最差 6.28:1**。2 个未量化:品牌方块「康」与头像「伟」——白字压在多色渐变上,不可逐像素取色,属装饰层 |
+
+---
+
+## 5. 这一版没做什么(明确列出)
+
+1. **线上部署未执行**:`scripts/deploy.sh` 写好了(同源前缀校验 → 体检 → 模块图 → 先删后传 index.html → 带 `no-cache` → 上传后复验),但本机没有 OBS 凭据 / `obsutil`,**未上传到 kangshifu.fmode.cn**。因此「线上模块图 + 线上深链接刷新」**未验证**;子路径部署是在本地用同前缀结构(`/kangshifu/dev/inno-board/`)+ 404 边界模拟验证的。
+2. **CDN 回退规则**:需要 CDN 侧把「非静态资源路径」200 返回 index.html;本版用 `src/dev/serve.mjs` 模拟了这条规则,线上是否配置**未验证**。
+3. **@lit/context 未接入**(理由见第 2 节)。
+4. **原材料库页面未做**:工作台痛点表里「原材料库」的一行链到台账 `?layer=raw`(数据里有 4 条原材料),没有单独的原材料浏览页。按第 9 节优先级,把厚度放在池子 / 工作台 / 报告 / 台账上。
+5. **搜索最多 1000 条/表**:Parse 单次上限 1000,超过会分页(最多 50 页)并在 UI 标「数据被截断」;当前数据量远小于上限。
+6. **多人同时写同一条卡**:没有冲突检测(后写覆盖)。客户当前规模(30–40 人、改状态操作稀疏)可接受,未来可按 `updatedAt` 做乐观锁。
+7. **历史测试残留的数据未清理**:如「夜宵低卡拌面(与 seed-opp-1 重复)」及其 decisionLog 里旧验收脚本写的条目;本版只做展示纠正(占位一句话不显示),没有删客户库里的数据。
+
+---
+
+## 6. 示例数据
+
+- 所有示例卡都带 `seedKey`,卡片右上角显示「示例」徽标,**与学员真实提交可区分**
+- `node scripts/seed.mjs` **幂等**:已存在的卡只补空字段和缺失的示例评分,**不覆盖**任何人改过的状态 / 决策;旧版 `seed-opp-*` 按标题认领,不会产生重复卡
+- `node scripts/seed.mjs --check`:校验每张示例卡的四要素齐全(`✓ SEED CHECK OK(6 张)`)
+- 凭据只从环境变量 `PARSE_MASTER_KEY` 读,只用于脚本,不进前端
+
+## 7. 本地运行
+
+```bash
+node --version                       # ≥ 20(用到 fetch / node:test)
+node scripts/vendor.mjs              # 首次:把 lib/vendor 补齐(已有则可跳过)
+node scripts/config.mjs local        # 生成根部署的 index.html
+node src/dev/serve.mjs --port 4173   # 打开 http://127.0.0.1:4173/
+# 子路径部署预览:
+node scripts/config.mjs local-sub
+node src/dev/serve.mjs --port 4174 --mount /kangshifu/dev/inno-board
+# 打开 http://127.0.0.1:4174/kangshifu/dev/inno-board/
+```
+
+生产:`./scripts/deploy.sh prod`(需 `OBS_BUCKET`;上传后设置 `CDN_BASE` 复验)。
+
+---
+
+## 8. v6:展示逻辑(这一轮改了什么、怎么验的)
+
+客户原话只落三件事:**「每个阶段的产出在里面我们可以看到」**、**「竞品分析、品类分析……单独分类用页面展示」**、**「展示的逻辑能不能够做的比较好」**;明确说流程推进不是重点、数据由别人灌。所以 v6 的全部改动都在这三件事上,机会卡流转 / 写库 / 状态机一行没动。
+
+### 8.1 路由表(v6)
+
+| 路径 | 视图 | 展示什么 |
+|---|---|---|
+| `/reports` | `<view-reports>` 报告中心 | 五类各几份(条形长度 = 占比、近 7 天新增、几人做过、几份可看全文)+ 每类最新两份 + 跨类型最新 6 份 + 阶段 × 类型总账 + 未归类如实列出 |
+| `/reports/category` | `<view-report-type type=category>` 品类分析 | 赛道分布(每赛道份数 / 竞品篇数 / 参与人 / 平均证据)+ 阶段 × 份数(竞争密度)+ 卡片流 |
+| `/reports/competitor` | 同上 type=competitor 竞品分析 | 对比矩阵(对位 × 阶段 / 原文)+ 空档标记 + 卡片流 |
+| `/reports/voc` | 同上 type=voc VOC / 用户洞察 | 原文摘录(平台 · 点赞 · 作者 · 出处)+ 原文量排行 + 卡片流 |
+| `/reports/pain` | 同上 type=pain 痛点清单 | 频次 × 证据图表(同一条痛点按报告结论归并)+ 对应机会卡(立项的标出)+ 卡片流 |
+| `/reports/concept` | 同上 type=concept 概念提案 | 方案 × 关联机会的多人平均分 + 卡片流(当前库里 0 条,空态按「将来会出现什么」展示) |
+| `/stages` | `<view-stages>` 五阶段产出 | 阶段总览条 + 每阶段**按类型分组**的卡片流 + 谁产的 + 全部 / 只看我的 / 只看有证据 |
+| `/report/:id` | `<view-report>` 报告阅读 | 执行摘要 → 章节速览(每章一句,点跳)+ 吸顶章节导航 + 章节内结论 / 表格 / 矩阵 + 附录「结论 × 证据」 |
+| `/report` | — | 兼容旧链接,落到报告中心 |
+
+每个类型页都可加 `?stage=S1`(按阶段二次筛选 —— 报告类型与阶段不是一一对应)、`?ev=1`(只看有证据的)、`?scope=mine`(只看我的,全选中的人);全部写进 URL,刷新不丢。
+
+**每条产出的通用件**(卡片上一行齐):谁上传的 · 哪个 Agent · 什么方式 · 什么时候 · 属于哪个阶段 · 证据强度;能看正文的给「阅读全文」,只有登记信息的如实写「仅登记信息」。
+
+### 8.2 展示逻辑的几条硬规则(都在 core,单测覆盖)
+
+- **归类**:`core/library/types.js` 的 `REPORT_TYPES` 定义五类;`core/library/reports.js` 的 `TYPE_FOR_ARTIFACT` 把 `artifactType` 映射到类型页(`opportunity_card` → 竞品页,且在竞品 / VOC 页同时作为「证据」出现)。映射不到的进报告中心「未归类」表,不隐藏。
+- **指标对齐**:每张卡固定 4 个指标,列名由类型页统一定义(`metricLabels`),取不到的显示「—」而**不塌列**——同一行卡片能直接横着比。
+- **数字有参照**:每类有「占全部 %」「近 7 天新增 / 无新增」「N 人做过」「x/y 可看全文」;卡片上「只看有证据的 N」「N / 总数份」实时跟着筛选变。
+- **关键结论来自算得出来的地方**:报告执行摘要 > 报告副标题 > 正文开头 > 机会卡一句话 > 原文摘录,都没有就写「正文未入知识库」——**不生成一句话**。
+- **有证据 = 原文 / 佐证**:成果按 `verified / supported` 算,机会卡按三档 `高 / 中` 算;模型推测不算(`core.hasEvidence`,类型页与阶段页共用)。
+- **痛点归并**:同一份报告结论(evidence 锚点)被多人整理成多份 pain_list 时合并为一条,频次 = 份数;严重度没有字段就是「待标注」,不猜。
+- **空态说人话**:每个类型的空态给四行——会出现什么 / 谁来填(阶段 + Agent)/ 对应字段 / 填完长什么样 + 现在能做什么。全应用 `<kf-empty>` 的默认标题也从「暂无数据」改成「这里将来会出现内容」。
+
+### 8.3 视觉:液态玻璃(任务书第 3 节,数值照抄)
+
+| 项 | 做法 | 实测 |
+|---|---|---|
+| 3.1 玻璃基元 | 卡片 `.kf-card` 白面 `rgba(255,255,255,.60 → .55)` + `backdrop-filter: blur(24px) saturate(180%)` | computed `backdrop-filter=blur(24px) saturate(1.8)`;背景里无实底 `background-color: rgba(0,0,0,0)` |
+| 3.2 发丝边 | `border: 1px solid transparent` + `padding-box` 白面 + `border-box` 135° 上亮下暗渐变 + `inset 0 1px 0 rgba(255,255,255,.7)` | computed:`border=1px solid rgba(0,0,0,0)`,background-image 第三层为 `linear-gradient(135deg, rgba(255,255,255,.92) 0%, rgba(255,255,255,.22) 40%, rgba(10,20,48,.1) 100%)` |
+| 3.3 三层纵纵深 | 底层光场(4 个色斑,单层 alpha ≤ .14,慢移)→ 面板 .46 → 卡片 .55–.60 → 抽屉 .85–.94 | 导航面板实测 alpha 0.46;卡片 0.60 → 0.55;抽屉 0.90 |
+| 3.4 hover | `translateY(-2px)` + 高光条横扫(background-position)+ 阴影加深 | hover 后 `matrix(…, 0, -2)`;高光 `120% → -13%` |
+| 3.5 对比度 | 文字全部实色;按「玻璃后是最深光场」算最坏合成色 | 3 个页共 1,856 个文字节点,**最差 4.63:1**(类型页)/ 4.88:1(阶段页 / 报告中心) |
+
+**v5 被否决的根因(修掉的两个 bug)**:
+1. 卡片当时被组件里的实底 `rgba(255,255,255,.88)` 覆盖 → 现在只改 `--kf-glass-alpha`,外层仍是半透明。
+2. 共享表的 `.kf-glass` 用 `var(--kf-glass-alpha)` 写进 `linear-gradient` —— Chrome 实测整条声明失效(导航背景 `bg=none`,这就是「写了 blur 却看不出玻璃」的另一个原因)→ 改成字面量 alpha,逐条实测生效。
+
+### 8.4 16 项验收:实测结果
+
+复现命令(全部实跑过,2026-10-29):
+
+```bash
+node scripts/check.mjs                                         # CHECK OK(14 组)
+node --test 'test/*.test.js'                                   # 46/46(v5 的 38 项全过 + v6 新增 8 项)
+node scripts/verify-modules.mjs http://127.0.0.1:4173/         # MODULE GRAPH OK(47 模块 + 深链接)
+node src/dev/fixture-server.mjs --port 4176 --dir /tmp/kfcache &   # 可选:快照回放(数据为真实 Parse 的一次原样抓取)
+node scripts/verify-render.mjs --url http://127.0.0.1:4176     # RENDER SMOKE OK(jsdom,27 项)
+CHROME_PATH=<chromium> node scripts/verify-glass.mjs           # GLASS VERIFY OK(8 项,第 10–15 项)
+CHROME_PATH=<chromium> node scripts/verify-browser.mjs         # BROWSER VERIFY OK(48 项,含 v5 的 36 项 + v6 的 12 项)
+```
+
+| # | 项 | 怎么验(实测) | 结果 |
+|---|---|---|---|
+| 1 | 报告中心各类型数量 + 最新 | `/reports` 读 5 张类型卡的份数 | 品类 1 · 竞品 25 · VOC 10 · 痛点 9 · 概念 0(合计 45,未归类 0) |
+| 2 | 五个类型页独立访问、刷新不丢 | 逐页 `goto` → `reload`,断网模式下用 localStorage 快照重建 | 5/5:视图 `VIEW-REPORT-TYPE`,卡片数 = 库内原始行按同一映射算出的数(1 / 26 / 12 / 10 / 1),F5 后一致 |
+| 3 | 按阶段二次筛选 | 竞品页点 `S1` | 25 → 4 份;URL 带 `?stage=S1`;F5 后仍 4(`?stage=S4&ev=1` 组合后 4 份,见 `v6-12`) |
+| 4 | 来源四要素 | 读卡片文本 | 例:`张伟 · 康师傅产品创新助手 · Skill 上传 · 5 小时前` |
+| 5 | 阶段页全部产出按类型分组 | `/stages` 读 5 个阶段板块 | S1 6(竞品 3 / 痛点 2 / 机会卡 1)、S2 8、S3 9、S4 6、S5 6,合计 35 |
+| 6 | 只看我的 / 只看有证据 | 点 `只看有证据的`(证据等级纠正为「成果原始等级 / 机会卡高中」后) | 35 → 26,关掉恢复 35;卡片上「有证据 N」随之变化 |
+| 7 | 卡片横向比较 | 见 `v6-05b-pain-cards.png`:每张卡「频次 / 严重度 / 解决状态 / 阶段」四列同宽同序 | 指标缺失显示「—」,不塌列 |
+| 8 | 空态说清将来会出现什么 | `/reports/concept`(库里 0 条) | 「会出现 / 谁来填 / 对应字段 / 填完后 / 现在能做」五行 + 结论面占位说明 |
+| 9 | 长报告章节导航 + 结论摘要 | `/report/bCld0Iy77e` | 执行摘要 4 条 → 章节速览 6 条(每章一句,点跳)→ 吸顶导航 6 章(滚动高亮、`?ch=` 写回) |
+| 10 | 卡片半透明 + blur 生效 | computed style | `background-image` 白层 alpha 0.60/0.55,`background-color` 透明,`backdrop-filter: blur(24px) saturate(1.8)` |
+| 11 | 边框是渐变 | computed style | `border: 1px solid rgba(0,0,0,0)` + 135° 三色渐变作为边框层 |
+| 12 | 背景有光场 | 同一块区域移除卡片前后像素对比 | 背景 RGB 范围 [244,234,217](有色斑);玻璃卡片平均亮度 239 vs 背景 236 |
+| 13 | 三层纵深 | 面板 / 卡片 / 抽屉的 alpha | 0.46 / 0.60–0.55 / 0.85–0.94(截图 `11-glass-layers-drawer.png`) |
+| 14 | hover 位移 + 高光 | hover 前后 computed | `translateY(-2px)`;高光 `background-position 120% → -13%`;阴影变化 |
+| 15 | 对比度 ≥ 4.5:1 | verify-glass 逐文字节点 | 1,856 个节点,**最差 4.63:1**;`verify-browser` 另量 460 个节点最差 7.31:1(旧量法按浅色底) |
+| 16 | 截图 6 张 | `docs/screenshots/` | `v6-01` 中心 · `v6-02..06` 五类 · `v6-07/08` 阶段页(全部 / S1)· `v6-10` 报告页 · 另附 `11/12/13`(三层 / hover 前后) |
+
+截图清单:`docs/screenshots/v6-01-reports-center.png`、`v6-02-type-category.png`、`v6-03-type-competitor.png`、`v6-03b-type-competitor-S1.png`、`v6-04-type-voc.png`、`v6-05-type-pain.png`、`v6-05b-pain-cards.png`(卡片横向比较)、`v6-06-type-concept.png`(空态)、`v6-07-stages.png`、`v6-08-stages-S1.png`、`v6-09-stages-evidence.png`、`v6-10-report.png`、`v6-11-concept-empty-plan.png`、`v6-12-type-filtered.png`、`11-glass-layers-drawer.png`、`12-hover-before.png`、`13-hover-after.png`。
+
+### 8.5 v6 没做什么(明确列出)
+
+1. **不造业务数据**:类型页各块按「库里有什么显示什么」;`概念提案` 页当前 0 条,显示的是空态说明书,不是假卡片。品类页当前只有 1 条 KfSource,没有「赛道 / SKU / 竞争密度」字段,所以赛道显示为「未归类」——这是如实显示,等别人按 `KfSource.payload` 灌进来就会按赛道分组。
+2. **流程推进没动**:机会卡状态、评分、决策记录、写库逻辑与 v5 完全一致。
+3. **VOC 的情感 / 主题 / 人群切分没有自己算**:库里的 voc_report 没有这些字段,代码不按标题猜;字段到了就显示,没到显示「未标注」。同理「严重度」没有字段时显示「待标注」。
+4. **类型归属是单值**:一份 `opportunity_card` 只归竞品页,VOC 页里它作为「证据补充」出现;如果客户想把同一份卡同时归到两个类型页,需要在 `REPORT_TYPES` 里改成多值映射(一行改动)。
+5. **线上部署仍未执行**(与 v5 相同):本机没有 OBS 凭据;`scripts/deploy.sh` 未运行。
+6. **jsdom 渲染冒烟在真实 Parse 下偶发卡住**:后端抖动时 jsdom 的网络会话偶尔不回调;已提供 `src/dev/fixture-server.mjs` + `--url` 快照回放模式(数据来自真实 Parse 的一次原样抓取,不做任何加工,也不生成数据),验收用的就是这一模式。
+7. **「只看我的」在没选「我是谁」时不按人筛**(全量显示并在计数旁写「未选人,未按人筛」),不假装筛过。

+ 572 - 0
TASK-visual-v11.md

@@ -0,0 +1,572 @@
+# 康师傅看板 · 第八轮任务书(整体视觉升级 · 从「不俗」到「惊艳」)
+
+> 客户:「可以,但是不仅仅应该是调整玻璃这个效果吧,整体的 UI 视觉升级这只是一个小点吧」
+>
+> **玻璃只是清单里的一小条。本轮是整体升级,六大板块,玻璃排最后。**
+>
+> 依据:进阶调研实测报告(Chrome 153 headless 实测,含 text-box / text-autospace /
+> grid-template-rows / scroll-driven animation / mask-composite 在真实浏览器里的验证结果)。
+
+---
+
+## 0. 核心判断(先理解这个,再动手)
+
+> **「不俗」靠的是「没有一样东西做错」;「惊艳」靠的是「有一样东西做得比别人深」。**
+>
+> 上一轮 42 条数值全部正确 —— 这正是问题:**正确 = 无特征**。
+> 做得越像 Vercel/Linear,越像模板。要学他们的**约束方法**,不是他们的**视觉皮肤**。
+
+**惊艳感的三个真正来源(按性价比排序)**:
+1. **中文排版真正做到位**(90% 中文 dashboard 没做,做了立刻拉开差距)
+2. **用微图表替代趋势描述文字**(同时解决密度低和文字多)
+3. **动效只服务于「变化」**,不服务于「出现」
+
+---
+
+## 板块一:中文排版(最高优先级,差异点)
+
+这一块是 90% 中文看板都没做的,做到了立刻不一样。**全部有实测数据支撑。**
+
+### 1.1 行盒修剪 text-box(收益最大,3 行 CSS)
+
+**问题**:中文字体自带半行距,`padding:16px` 得到的视觉留白实际是 16px + 字体半行距(中文约 0.4em),
+**永远对不准 4pt 栅格** —— 这才是「看着规矩但就是不齐」的真因。
+
+**实测**:40px 中文文本行盒高度 **55px → 30px(-45%)**。
+
+```css
+/* 所有标题、KPI 数字、单行标签都加 */
+.stat-value, h1, h2, h3, .card-title, .metric {
+  text-box: trim-both cap alphabetic;
+  line-height: 1;
+}
+```
+
+**落地范围**:所有「单行文本容器」——大数字、标题、标签、按钮文字。
+**不要**加在多行正文上(会破坏行距)。
+
+### 1.2 数字与单位分层(3 行 CSS,效果立竿见影)
+
+```css
+/* 单位:小一号、灰一档、留 0.12em */
+.u { margin-left: .12em; font-size: .92em; color: var(--text-2); }
+```
+数据层插入:`text.replace(/(\d+(?:\.\d+)?)\s*(%|万|元|件|天|份|人)/g, '$1<span class="u">$2</span>')`
+
+**效果**:`82份` → `82` 大而黑 + `份` 小而灰,数字立刻成为视觉主角。
+
+### 1.3 中英混排间距(实测 text-autospace 无效,必须数据层处理)
+
+**实测结论**:Chrome 153 对「S5场景与卖点设计TOP3」应用 `text-autospace` 后,
+渲染宽度 **436.33px,与不加完全一致**,`getComputedStyle` 仍返回 `no-autospace`。
+MDN 标 Baseline 2025,但 W3C 明确各浏览器行为不一致、WebKit 只单侧加距。
+
+**可靠做法**:数据层插 U+2009 THIN SPACE(约 1/5 em)。
+
+```js
+function spaceCJK(s) {
+  return s
+    .replace(/([\u4e00-\u9fa5])([A-Za-z0-9])/g, '$1\u2009$2')
+    .replace(/([A-Za-z0-9])([\u4e00-\u9fa5])/g, '$1\u2009$2');
+}
+```
+**禁止**:用手插全角空格或普通空格冒充中英间距(1em 太宽)。
+
+### 1.4 字体栈(拉丁优先、CJK 兜底)
+
+```css
+--font-sans:
+  "Inter var", "Inter",
+  "PingFang SC", "Noto Sans SC", "Microsoft YaHei UI", "Microsoft YaHei",
+  sans-serif;
+--font-num:
+  "Inter var", "SF Pro Display", -apple-system, "Helvetica Neue", sans-serif;
+```
+
+**为什么不能 system-ui 打头**:Windows 上拉丁字母会走雅黑的西文字形,字重和度量都不理想。
+**顺序是设计决策**:Inter 在前接管拉丁字母与数字,中文只在其缺字时回落。
+
+**注意**:不要引入几 MB 的中文字体包。Inter 只加载 latin subset,中文走系统字体。
+
+### 1.5 中文行高与字距(和西文规则不同)
+
+```
+正文 line-height: 1.75(W3C CLReq:中文行间隙为字符框 50%-100%,75% 取中位)
+行宽上限:48em(约 48 字);卡片内描述 32-36em
+正文 letter-spacing: 0        ← 中文是满格方块,加字距反而散
+12-13px 全中文小标题:+0.02em  ← 只为提清晰度
+≥28px 大标题:-0.01em ~ -0.02em
+```
+**中文负字距上限 -0.03em**,超过笔画会粘连(西文 h1 可用 -4.75%,中文不行)。
+
+### 1.6 基线对齐补偿
+
+```css
+.icon-label > svg { transform: translateY(-0.5px); }   /* 中文视觉重心比拉丁低 */
+.num-inline { position: relative; top: -0.02em; }      /* 数字与汉字 baseline 混排 */
+```
+
+---
+
+## 板块二:信息可视化替代文字(解决密度低 + 文字多)
+
+### 2.1 Sparkline 替代趋势描述句(纯 CSS,20 行)
+
+**核心规则(Tufte)**:sparkline 只表达**形状**,不替代**数字**。
+> 「A sparkline shows shape, not values: there is no axis, and the dot marks the latest point.
+> **Print the number it summarizes beside it.**」
+
+```
+❌ 环比上月增长 12.4%,呈上升趋势
+✅ 12.4% ↗ [sparkline]      ← 删掉 12 个汉字
+```
+
+```html
+<div class="spark" aria-hidden="true">
+  <i style="--v:.2"></i><i style="--v:.45"></i><i style="--v:.3"></i>
+  <i style="--v:.7"></i><i style="--v:.55"></i><i style="--v:.9"></i>
+  <i style="--v:1" data-now></i>
+</div>
+```
+```css
+.spark { display:flex; align-items:flex-end; gap:2px; height:20px; width:64px; }
+.spark i {
+  flex:1; min-width:2px; border-radius:1px;
+  height:calc(var(--v) * 100%);
+  background:currentColor;
+  opacity:.35;                       /* 历史柱:低不透明度 */
+}
+.spark i[data-now] {
+  opacity:1;                          /* 最新一根:实色,形成"落点" */
+  border-radius:1px 1px 0 0;
+}
+```
+
+**尺寸规范**:行内 1em 高 / 表格单元格 16px 高 60-80px 宽 / KPI 卡内 20-24px 高。
+
+### 2.2 进度环(SVG,别用 conic-gradient 做动画)
+
+```html
+<svg class="ring" viewBox="0 0 36 36" width="36" height="36" aria-hidden="true">
+  <circle cx="18" cy="18" r="15.9" class="ring-track"/>
+  <circle cx="18" cy="18" r="15.9" class="ring-fill" style="--pct:68"/>
+</svg>
+```
+```css
+.ring { transform: rotate(-90deg); }            /* 起点拨到 12 点 */
+.ring-track { fill:none; stroke:rgba(0,0,0,.06); stroke-width:3; }
+.ring-fill {
+  fill:none; stroke:var(--accent); stroke-width:3; stroke-linecap:round;
+  stroke-dasharray:100;                          /* r=15.9 → 周长≈99.9 */
+  stroke-dashoffset:calc(100 - var(--pct));
+  transition: stroke-dashoffset 600ms cubic-bezier(.4,0,.2,1);
+}
+```
+**尺寸**:36px 卡内嵌 / 44px 列表行 / 56px+ 主 KPI。**低于 32px 别用环**(看不出弧长差),改用小横条。
+
+### 2.3 条形对比(替代三行百分比文字)
+
+```css
+.bars .row { display:grid; grid-template-columns:64px 1fr 40px; align-items:center; gap:8px;
+             font-size:12px; line-height:1; }
+.bars .track { height:4px; border-radius:2px; background:rgba(0,0,0,.06); overflow:hidden; }
+.bars .fill  { height:100%; border-radius:2px; background:currentColor;
+               width:calc(var(--v)*1%);
+               transition:width 500ms cubic-bezier(.4,0,.2,1); }
+```
+**数值**:track 高 **4px**(3px 看不清圆角,6px 显笨)、圆角=高度/2、行距 6px、标签列 64px。
+
+### 2.4 热力小格
+
+```css
+.heat { display:grid; grid-auto-flow:column; grid-template-rows:repeat(7,10px); gap:2px; }
+.heat i { width:10px; height:10px; border-radius:2px;
+          background: color-mix(in oklab, var(--accent) calc(var(--v)*100%), var(--surface-2)); }
+```
+**用 `color-mix(in oklab, ...)`** 而不是手写 5 个色阶 —— 保证任意强调色都生成视觉等距的 5 档。
+格子 10px + gap 2px 是 GitHub contribution graph 的成熟尺寸。
+
+### 2.5 「标签 ···· 值」同行对齐(省一半高度)
+
+```css
+.kv { display:flex; align-items:baseline; gap:8px; font-size:13px; }
+.kv::before { content:""; order:1; flex:1;
+              border-bottom:1px dotted color-mix(in srgb, currentColor 20%, transparent);
+              transform:translateY(-3px); }
+.kv .k { order:0; color:var(--text-2); }
+.kv .v { order:2; font-variant-numeric:tabular-nums; color:var(--text-1); }
+```
+比「标签\n值」两行省一半高度。
+
+---
+
+## 板块三:间距层级与密度(解决「节奏不匀」)
+
+### 3.1 跨级间距规则(可校验,不是感觉)
+
+```
+卡片内 padding        = 16px   (--space-lg)
+卡片内元素之间 gap     = 12px   (--space-md)
+卡片与卡片之间          = 24px   (--space-xl)   ← 1.5×
+区块(section)之间      = 48px   (--space-xl×2)  ← 3×
+页面顶 → 首个区块      = 48px(不能更大)
+```
+
+**上一轮的缺陷 1 根因**:告警框与下方区块**同属区块级,必须同为 24px**。
+不是数值错了,是**跨级关系没区分**。
+
+### 3.2 密度分级 token(Sitka 标准)
+
+```css
+:root {
+  --density-space-xs: 4px; --density-space-sm: 8px; --density-space-md: 12px;
+  --density-space-lg: 16px; --density-space-xl: 24px;
+}
+[data-density="compact"] {
+  --density-space-xs: 2px; --density-space-sm: 6px; --density-space-md: 8px;
+  --density-space-lg: 12px; --density-space-xl: 16px;
+}
+.card { padding: var(--density-space-lg); }
+```
+一个 `data-density` 属性重写 8 个 token,全站自动跟随。
+
+### 3.3 密度平衡的真实做法
+
+**不是「间距小」,是「同类项贴着、异类项留白」。**
+Linear 的 issue 列表每行内元素间距 4-8px(极密),但组与组之间 24px+。
+
+**检验方法**:把页面缩成 25% 缩略图看。
+- 所有留白宽度都一样 → **均匀呼吸 → 模板感**
+- 能看到明确的「块」在呼吸 → 对了
+
+### 3.4 一屏放更多信息的三个手段
+
+1. KPI 从「2 列 × 4 行」改「4 列 × 2 行」,纵向高度砍半
+2. `content-visibility: auto`(Chrome 153 实测支持),折叠区默认不渲染
+3. 标签与值同行(见 2.5)
+
+---
+
+## 板块四:一致性系统(把 8 个阴影收到 3 档)
+
+**上一轮实测:阴影有 8 个变体,要求 3 档。这是工程问题,不是审美问题。**
+
+### 4.1 三层 token 架构
+
+```css
+/* Tier 1 Primitive(组件禁止直接引用) */
+:root {
+  --sh-1: 0 1px 2px rgba(16,24,40,.04);
+  --sh-2: 0 2px 8px -2px rgba(16,24,40,.06);
+  --sh-3: 0 12px 32px -8px rgba(16,24,40,.12);
+  --ring-1: 0 0 0 1px rgba(16,24,40,.06);
+}
+/* Tier 2 Semantic(只有 3 档,强制收敛的唯一手段) */
+:root {
+  --elev-1: var(--sh-1), var(--ring-1);                    /* 静置 */
+  --elev-2: var(--sh-2), var(--ring-1);                    /* 悬停 */
+  --elev-3: var(--sh-3), 0 0 0 1px rgba(16,24,40,.08);     /* 浮层 */
+}
+/* Tier 3 Component(组件只认语义名) */
+.card { box-shadow: var(--elev-1); }
+.card:hover { box-shadow: var(--elev-2); }
+.popover { box-shadow: var(--elev-3); }
+```
+
+### 4.2 强制收敛(不靠自觉)
+
+**grep 拦截**:
+```bash
+grep -rn "box-shadow" src/ | grep -v "elev-\|--sh-\|--elev-" && echo "❌ 有脱离 token 的阴影"
+```
+
+**运行时断言**(加进 `scripts/check.mjs` 或验收脚本):
+```js
+const seen = new Set();
+document.querySelectorAll('*').forEach(el => {
+  const v = getComputedStyle(el).boxShadow;
+  if (v && v !== 'none') seen.add(v);
+});
+console.assert(seen.size <= 3, `阴影变体 ${seen.size} 个,超过 3 档`, [...seen]);
+```
+
+### 4.3 阴影即描边(Vercel 核心技巧)
+
+把 1px 边框做成 shadow 的一层,而不是 `border`:
+```css
+--ds-shadow-border-base: 0 0 0 1px #00000014;
+```
+**好处**:`border` 参与盒模型(hover 改宽度会抖动布局),`box-shadow` 不参与 ——
+**hover 加描边不会让卡片内容位移 1px**。
+
+### 4.4 焦点环(双环)
+
+```css
+:focus-visible {
+  outline: none;
+  box-shadow: 0 0 0 2px var(--bg-1),      /* 内环:和卡片同色,制造空隙 */
+              0 0 0 4px var(--accent);    /* 外环:可见指示 */
+}
+```
+
+---
+
+## 板块五:动效只服务于「变化」
+
+### 5.1 数字滚动(只滚变化的位)
+
+```
+❌ 页面加载时 0 → 1284 滚 2 秒                    (AI 味 / 模板感)
+✅ 数据刷新时 1284 → 1301,只有十位和个位滚          (高级)
+✅ 首屏不动,等真实数据到达后再滚一次                (可信)
+```
+
+```css
+.num {
+  display:inline-block;
+  font-variant-numeric: tabular-nums;      /* 必须:保证位宽不跳 */
+  height:1em; overflow:hidden; line-height:1;
+  mask-image: linear-gradient(#000 70%, #0000);   /* 上下淡出,模拟机械感 */
+}
+.num > .slot { display:block; transition: transform 500ms cubic-bezier(.4,0,.2,1);
+               transform: translateY(calc(var(--i,0) * -1em)); }
+```
+**参数**:500-750ms、`cubic-bezier(.4,0,.2,1)`,**绝不用 `ease-in`**(会显得拖沓)。
+
+### 5.2 颜色过渡分通道
+
+```css
+.card {
+  transition:
+    box-shadow 180ms cubic-bezier(.4,0,.2,1),
+    background-color 180ms cubic-bezier(.4,0,.2,1),
+    color 240ms cubic-bezier(.4,0,.2,1),     /* 文字慢 60ms,避免"字在闪" */
+    transform 150ms cubic-bezier(.4,0,.2,1);
+}
+```
+文字颜色若与背景同步,会视觉上抢跑,显得廉价。
+
+### 5.3 光斑跟随鼠标(只亮边框,不是整卡发光)
+
+```css
+.card { position:relative; isolation:isolate; }
+.card::before {
+  content:""; position:absolute; inset:0; padding:1px; border-radius:inherit;
+  background: radial-gradient(180px circle at var(--x,50%) var(--y,50%),
+              color-mix(in srgb, var(--accent) 60%, transparent), transparent 60%);
+  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
+  -webkit-mask-composite: xor; mask-composite: exclude;
+  opacity:0; transition: opacity 240ms cubic-bezier(.4,0,.2,1);
+  pointer-events:none;
+}
+.card:hover::before { opacity:1; }
+```
+```js
+card.addEventListener('pointermove', (e) => {
+  const r = card.getBoundingClientRect();
+  card.style.setProperty('--x', (e.clientX - r.left) + 'px');
+  card.style.setProperty('--y', (e.clientY - r.top) + 'px');
+});
+```
+**半径 180px、不透明度 60%、衰减点 60%** —— 三个数一起决定「光斑够近才亮」。
+**整卡发光 = 廉价**。
+
+### 5.4 Hover 内容揭示(progressive disclosure)
+
+把卡片底部密集的决策信息默认收起,hover 展开:
+```css
+.card .detail {
+  display:grid; grid-template-rows:0fr; opacity:0;
+  transition: grid-template-rows 220ms cubic-bezier(.4,0,.2,1), opacity 180ms;
+}
+.card .detail > div { overflow:hidden; min-height:0; }
+.card:hover .detail, .card:focus-within .detail { grid-template-rows:1fr; opacity:1; }
+```
+**`0fr → 1fr` 是唯一正确的 auto-height 动画方案。**
+**禁止 `max-height:999px`** —— 模板感重灾区,速度失真。
+
+### 5.5 滚动视差(零 JS,跑合成器线程)
+
+```css
+@supports (animation-timeline: view()) {
+  .reveal { animation: rise linear both; animation-timeline: view();
+            animation-range: entry 10% cover 30%; }
+  @keyframes rise { from{opacity:0; transform:translateY(12px);} to{opacity:1; transform:none;} }
+}
+@media (prefers-reduced-motion: reduce) { .reveal { animation:none; opacity:1; } }
+```
+**偏移 12px,不是 40px。** 超过 24px 就开始像 AOS 模板库。
+
+### 5.6 长标签不截断
+
+```css
+.label {
+  word-break: keep-all; overflow-wrap: anywhere;
+  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
+  overflow: hidden; line-height: 1.4;
+}
+```
+flex 子项补 `min-width: 0`。
+「S5 场景与卖点设计」19 字在 260px 卡内**折成两行**而不是截断(2 行 = 42px)。
+
+---
+
+## 板块六:玻璃(最小的一块,不是重点)
+
+**Vercel 设计团队的 hard reject 清单明确包含 glass effects**,理由是:
+全页玻璃 = 性能灾难 + 内容糊。
+
+**折中方案(客户已批准)**:
+- **浮层保留玻璃**:侧栏、顶栏、底部 Tab、抽屉、筛选面板、弹层 —— 这些本来就是浮层
+- **内容卡片减薄**:改成更实的白底(`rgba(255,255,255,.72)`)+ 1px 发丝描边 + 内高光,只留极轻微透明
+
+```css
+/* 浮层(保留玻璃) */
+.glass-surface { background: rgba(255,255,255,.62);
+                 backdrop-filter: blur(28px) saturate(180%); }
+/* 内容卡(减薄,靠描边和内高光做质感,不靠糊) */
+.glass-card {
+  background: rgba(255,255,255,.72);
+  backdrop-filter: blur(8px);                  /* 轻微 */
+  box-shadow: inset 0 1px 0 rgba(255,255,255,.9), var(--elev-1);
+}
+```
+
+---
+
+## 板块七:反例清单(15 条,全部禁止)
+
+Vercel 设计团队原话:**Hard reject decorative gradients, gradient text, glows, blobs, stripes,
+textures, grid backgrounds, glass effects, colored side rails, ornamental shadows, and fake depth.**
+
+1. 渐变文字标题(`background-clip:text` + 紫蓝渐变)
+2. 彩色左边框告警(`border-left:4px solid red`)→ 改 6% 染色背景 + 1px 同色半透明描边
+3. 所有卡片都 `backdrop-filter: blur()`(本轮处理)
+4. 浅色主题下 glow(`box-shadow: 0 0 40px accent`)
+5. `100vh` 满屏英雄区 + 大字口号
+6. **均匀呼吸**(所有间距都是同一个 16px 或 24px)← 模板感核心成因
+7. `transform: scale(1.05)` hover → 挤压邻元素
+8. **emoji 当图标**(📊 🚀 💡)← 最致命的廉价信号
+9. `max-height: 999px` 展开动画
+10. 每个数字从 0 滚到目标值(countUp)
+11. 超过 3 档阴影
+12. 纯 `#000` / `#FFF` → 用近中性(`#FAFAFA / #F2F2F2 / #EBEBEB / #171717`)
+13. 圆角无嵌套关系 → 子 ≤ 父且同心
+14. **`transition: all`** ← Vercel 明令禁止
+15. AOS 风格每屏 40px 上移 + 依次出现
+
+**逐条自查并报告:现有代码命中哪几条。**
+
+---
+
+## 板块八:空态与错误态
+
+### 8.1 空态(最能体现「被认真做过」)
+
+三要素:
+1. **说清为什么空**(「本月暂无新品提案」而非「暂无数据」)
+2. **给一个动作**(按钮)
+3. **保留骨架** —— 空态的卡片框仍在,只把内容换提示。**卡片消失 = 布局跳动 = 廉价**
+
+```css
+.empty {
+  display:grid; place-items:center; gap:8px;
+  min-height:120px;                          /* 与有数据时近似等高 */
+  color:var(--text-3); font-size:13px;
+  border:1px dashed color-mix(in srgb, currentColor 24%, transparent);
+  border-radius:10px;
+}
+```
+
+### 8.2 错误态(不要红色大块)
+
+```css
+.alert {
+  border-left: none;
+  background: color-mix(in srgb, #E5484D 6%, var(--surface-1));   /* 6% 染色,不是 12% */
+  box-shadow: var(--elev-1), 0 0 0 1px color-mix(in srgb, #E5484D 16%, transparent);
+  border-radius:10px; padding:12px 14px; font-size:13px; line-height:1.6;
+}
+```
+**6% 染色是关键数值**。13%(常见默认)会让整个区块变粉,立刻廉价。
+
+---
+
+## 铁律
+
+1. 不改路由、不改数据层 schema、不改组件 API
+2. **字数不许回升**(类型页 ≤300 中文字、工作台 ≤350、报告中心 ≤200)—— 本轮要**进一步下降**
+3. **对比度 ≥4.5:1**,逐项报实测
+4. 手机 393px **不能退步**:底部 Tab、单列、无横向滚动条必须保持
+5. 结构不动:卡片式、6 维筛选、来源四要素全保留
+6. **相伴所有新效果要能一键关闭**(`[data-density]` / CSS 变量),便于回退
+
+---
+
+## 验收(全部实测,报具体数值)
+
+### 中文排版(5 项)
+1. `text-box` 落地范围的元素数量 + 一个 40px 标题的实测行盒高度(应约 30px,原 55px)
+2. 单位分层:`82份` 中 `份` 的 computed font-size 与 color
+3. 中英混排:抽查 5 处含中英混排的文本,报是否插入了 U+2009
+4. 字体栈:computed font-family 完整值
+5. 正文 line-height(应 1.75)、大标题 letter-spacing(应 -0.01~-0.02em)
+
+### 信息可视化(4 项)
+6. sparkline 实际落地处数 + 删掉的趋势描述文字字数
+7. 进度环/条形/热力的落地处数,报尺寸数值
+8. 「标签····值」同行落地的处数
+9. 一屏可见信息条数(对比上一轮的数值)
+
+### 间距一致性(3 项)
+10. 卡片内 padding / 卡内 gap / 卡间 / 区块间的 computed 值(应为 16/12/24/48)
+11. 全页最大连续空白
+12. `data-density` 切换是否生效(切 compact 报卡片 padding)
+
+### 一致性系统(3 项)
+13. **阴影去重后变体数(必须 ≤3)** + 报出这 3 个值
+14. 卡片是否改用 shadow 描边(hover 时内容是否位移)
+15. 焦点环 computed box-shadow
+
+### 动效(3 项)
+16. 数字滚动:是否只在值变化时触发,时长与缓动值
+17. hover 展开:`grid-template-rows` 是否 `0fr→1fr`,时长
+18. 全站 `transition: all` 剩余数量(必须 0)
+
+### 反例自查(1 项)
+19. **逐条报 15 条反例命中情况**(命中几条、分别在哪)
+
+### 回归(3 项)
+20. 手机 393px:Tab 栏、单列、`scrollWidth === clientWidth`
+21. `node --test` 46/46 + `node scripts/check.mjs` 全绿
+22. 字数:类型页/工作台/报告中心
+
+---
+
+## 截图
+
+`docs/screenshots/v11/`:
+`01-桌面-工作台` / `02-中文排版对比` / `03-sparkline落地` /
+`04-数字滚动` / `05-hover展开` / `06-光斑边框` / `07-空态` / `08-错误态` /
+`09-手机-工作台` / `10-手机-类型页` / `11-修前修后对比`
+
+---
+
+## 优先级
+
+**P0(差异点,做了立刻不一样)**
+1. 中文排版六条(text-box + 单位分层 + 中英间隔 + 字体栈 + 行高字距 + 基线补偿)
+2. 阴影 8 → 3 档 + 运行时断言
+3. sparkline 替代趋势文字
+
+**P1(手感)**
+4. 间距跨级规则 + data-density
+5. hover 揭示 + 长标签不截断
+6. 数字滚动(只滚变化位)+ 颜色过渡分通道
+
+**P2(打磨)**
+7. 光斑边框、滚动视差
+8. 空态、错误态
+9. 玻璃减薄(浮层保留、内容卡减薄)
+
+**先做 P0。中文排版是这一轮的核心价值,其他都是配菜。**

+ 1 - 0
deploy/prefix/.txt

@@ -0,0 +1 @@
+/

+ 1 - 0
deploy/prefix/local-sub.txt

@@ -0,0 +1 @@
+/kangshifu/dev/inno-board/

+ 1 - 0
deploy/prefix/local.txt

@@ -0,0 +1 @@
+/

+ 1 - 0
deploy/prefix/prod.txt

@@ -0,0 +1 @@
+/kangshifu/

+ 38 - 0
docs/render-smoke-result.txt

@@ -0,0 +1,38 @@
+PASS 21 个自定义元素全部注册
+PASS 数据加载完成(status=ready)  — ready 
+PASS 机会卡 ≥ 6  — 9
+PASS 有完整报告(6 章)
+PASS 库里有卡住的人  — 何雨
+PASS 每个人都有上传四要素字段(缺失标出而非编造)
+PASS 渲染 /  — 工作台 一眼看清三件事:我自己的进度、全员的池子在长什么样、现场谁卡住了。 给我 我的进度 右上角选择「我是谁」后显示你的进度 ? 先告诉系统你是谁 多人共用一套看板;选了自己之后,评分、改状态、我的进度都会记在你名下。 /* 卡片永远是块级容器(撑满栅格单元);内容排版由 slot 里的元素自己决定 */ :host
+PASS 渲染 /pool  — 机会卡池 全员机会卡汇总入库,带评分与优先级 —— 这是研发中心的机会储备。 9 / 9 张 状态 候选 4 已立项 3 已否决 2 优先级 S 级 3 A 级 3 B 级 3 阶段 S1 1 S2 1 S3 4 S4 2 S5 1 品类 拌面 5 汤面 2 杯面 1 炒面 1 上传人 陈静 2 李娜 1 刘洋 1 孙
+PASS 渲染 /stages  — 五阶段产出 每个阶段产出了什么内容——按类型分组、卡片流展示。五个阶段共 35 份产出,5/5 个阶段已有内容。 S1 品类市场分析 6 份 · 2 人 竞品分析 3 · 痛点清单 2 · 机会卡 1 S2 用户需求与痛点 8 份 · 3 人 竞品分析 4 · VOC / 用户洞察 1 · 痛点清单 2 S3 机会点识
+PASS 渲染 /reports  — 报告中心 45 份产出分布在 4/5 个类型;类型与阶段不是一一对应,可以进任一类型页按阶段筛。 五类报告 各类型多少份 · 最新两份 条形长度 = 占全部 45 份的比例 0 1 品类分析 1 份 2 % 0 人做过 近 7 天 + 1 1 / 1 可看全文 这个品类有哪些赛道、每个赛道多少玩家、市场结构长什么样? 
+PASS 渲染 /reports/category  — 全部报告 品类分析 1 竞品分析 25 VOC / 用户洞察 10 痛点清单 9 概念提案 0 品类分析 这页在回答: 这个品类有哪些赛道、每个赛道多少玩家、市场结构长什么样? 主要看: 赛道分布 / SKU 数量 / 竞争密度 / 玩家列表 1 份 本类型全部条目 近 7 天新增 1 份 0/5 阶段 覆盖的阶段 还
+PASS 渲染 /reports/competitor  — 全部报告 品类分析 1 竞品分析 25 VOC / 用户洞察 10 痛点清单 9 概念提案 0 竞品分析 这页在回答: 几个竞品放在一起怎么比、谁强在哪、哪里还是空档? 主要看: 对比矩阵 / 单竞品卡片 / 差异化空档 25 份 本类型全部条目 近 7 天新增 25 份 5/5 阶段 覆盖的阶段 有产出:S1、S2、
+PASS 渲染 /reports/voc  — 全部报告 品类分析 1 竞品分析 25 VOC / 用户洞察 10 痛点清单 9 概念提案 0 VOC / 用户洞察 这页在回答: 用户到底说了什么?原话在哪、多少人说了同样的话? 主要看: 情感分布 / 痛点频次排行 / 原文摘录(带出处)/ 人群切分 10 份 本类型全部条目 近 7 天新增 10 份 5/5 阶段
+PASS 渲染 /reports/pain  — 全部报告 品类分析 1 竞品分析 25 VOC / 用户洞察 10 痛点清单 9 概念提案 0 痛点清单 这页在回答: 哪些痛点最要紧——说的人多、还让人最难受? 主要看: 频次 × 严重度二维 / 已被解决 vs 未解决 / 对应机会卡 9 份 本类型全部条目 近 7 天新增 9 份 5/5 阶段 覆盖的阶段 有产出
+PASS 渲染 /reports/concept  — 全部报告 品类分析 1 竞品分析 25 VOC / 用户洞察 10 痛点清单 9 概念提案 0 概念提案 这页在回答: 谁提了什么方案、它的关键指标站不站得住、对应哪个机会? 主要看: 方案卡片 / 关键指标 / 关联机会卡 0 份 本类型全部条目 近 7 天没有新增 0/5 阶段 覆盖的阶段 还没有任何阶段产出 0 
+PASS 渲染 /report  — 报告中心 45 份产出分布在 4/5 个类型;类型与阶段不是一一对应,可以进任一类型页按阶段筛。 五类报告 各类型多少份 · 最新两份 条形长度 = 占全部 45 份的比例 0 1 品类分析 1 份 2 % 0 人做过 近 7 天 + 1 1 / 1 可看全文 这个品类有哪些赛道、每个赛道多少玩家、市场结构长什么样? 
+PASS 渲染 /ledger  — 证据台账 成果与机会卡的全部记录。六维筛选可叠加;条件是 URL 的一部分,刷新不丢。 35 / 35 条 优先级 S 级 3 A 级 3 B 级 3 品类 拌面 5 汤面 2 杯面 1 炒面 1 阶段 S1 6 S2 8 S3 9 S4 6 S5 6 人 陈静 5 李娜 4 孙悦 4 张伟 4 周涛 4 王芳 3 吴
+PASS 渲染 /members  — 成员 11 位 · 10 人已交成果 · 1 人卡住 卡住: 何雨 分组 G1 5 人 · 5 人已交 成员 当前阶段 会话 成果 机会卡 最后活动 状态 静 陈静 S5 场景与卖点设计 11 条 3 份 2 张 4 小时前 已交 娜 李娜 S2 用户需求与痛点 8 条 3 份 1 张 4 小时前 已交 洋 刘洋 S4
+PASS 渲染 /nope  — 页面不存在 没有 /nope 这条路由。 回工作台 :host { display: block; } .e { display: grid; justify-items: center; gap: 8px; padding: 28px 20px; text-align: center; } .ico { width:
+PASS 类型页按阶段二次筛选(?stage=S1 后条数变化)  — 全部=25 S1=4
+PASS 阶段页:只看有证据(?ev=1 后条数变化)  — 全部=35 / 35 份 有证据=26 / 35 份
+PASS 报告页:章节 + 结论 + 证据锚点  — 1 . 全景漏斗 2 . 嵌套洞察 3 . 痛声地图 4 . 产品适配 5 . 定位推导 6 . 行动蓝图 S1 · S2 · S4 · S5 :host { display: inline-flex; vertical-align: middle; } .b { display: inline-flex; align-items: center; gap: 5px; height: 22px; 
+PASS 报告页:数据管线 2,727 → 1,190 → 730
+PASS 排序切换(优先级 → 评分)后顺序真实变化  — priority=[neS51ohPAo,qVpNnUh7IE,ws5X2LujTm,8fmkMcVzOj,UFIngQqa7H,GvGiqaDVmN,4vmJpRjGYW,R1kdzL0V7s,rug9NOvvtn] score=[8fmkMcVzOj,neS51ohPAo,qVpNnUh7IE,ws5X2LujTm,UFIngQqa7H,GvGiqaDVmN,4vmJpRjGYW,R1kdzL0V7s,rug9NOvvtn]
+PASS 筛选叠加(S 级 + 拌面)与 core 结果一致  — 页面=1 core=1
+PASS 深链接 /pool/:id 打开抽屉,内含评分面板与来源四要素  — 
+          
+            (未填写一句话说明)
+            
+              
+              
+            
+          
+          
+       
+PASS 无未捕获异常
+
+RENDER SMOKE OK · fetch=6 · 10250ms

+ 422 - 0
docs/screenshots/v10/measure-after.json

@@ -0,0 +1,422 @@
+{
+  "tag": "after",
+  "rows": [
+    {
+      "no": 1,
+      "name": ".grid2 改为 align-items: start(computed)",
+      "ok": true,
+      "detail": "computed align-items=start;inline style「--i:0」;grid 高 110px,列宽 610.906px 509.094px"
+    },
+    {
+      "no": 2,
+      "name": "两张卡高度 = 各自内容高度(卡高 − 内容高 ≤ 40px)",
+      "ok": true,
+      "detail": "卡1「?选「我是」」高 78px,内容 44px,差 34px(.frame display=flex justify=normal);卡2「1 人卡住 」高 109.8px,内容 75.8px,差 34px(.frame display=flex justify=normal);两卡高度差 31.8px"
+    },
+    {
+      "no": 3,
+      "name": "全页最大连续空白 ≤ 80px(9 个路由逐页量;按横截面采样,卡片面 / 表格行 / 空态面板算内容)",
+      "ok": true,
+      "detail": "/ 26px(「?」(@101) ↓ 「A 级」(@380));/pool 46px(「状态」(@192) ↓ 「池子」(@499));/stages 32px(「S1」(@101) ↓ 「35」(@221));/reports 46px(「1」(@101) ↓ 「阶段」(@270));/reports/competitor 38px(「品类分析」(@84) ↓ 「阶段」(@228));/reports/voc 38px(「品类分析」(@84) ↓ 「阶段」(@228));/members 50px(「成员」(@90) ↓ 「G2」(@568));/ledger 38px(「证据台账」(@96) ↓ 「点击行查看证据链」(@453));工作台 .grid2 卡片底 → 下一区块标题 24px"
+    },
+    {
+      "no": 4,
+      "name": "全站「并排多卡」无 align-items: stretch 拉伸",
+      "ok": true,
+      "detail": "9 个路由,并排多卡的 grid/flex 容器 align-items 均非 stretch(剩余只有单卡内部 .frame 的纵向 flex,属正常)"
+    },
+    {
+      "no": 5,
+      "name": "主数值 32px / 600 / -0.02em / tabular-nums / line-height 1.1",
+      "ok": true,
+      "detail": "主数值「9」:font-size 32px,font-weight 600,letter-spacing -0.64px(= 32px × -0.02,浏览器按 0.1px 精度取值),line-height 35.2px,font-variant-numeric tabular-nums,color rgb(24, 24, 27);四张数据卡数值字号 32px/600 -0.64px、32px/600 -0.64px、32px/600 -0.64px、32px/600 -0.64px;kf-stat 数值 本页无"
+    },
+    {
+      "no": 6,
+      "name": "标签 11px / 500 / 0.06em / gray-9",
+      "ok": true,
+      "detail": "标签「432」:font-size 11px,font-weight 500,letter-spacing 0.66px(11px×0.06=0.66px),color rgb(75, 85, 99)"
+    },
+    {
+      "no": 7,
+      "name": "全站字号去重 ≤ 6 档(11/13/14/16/20/32)",
+      "ok": true,
+      "detail": "桌面 + 手机 9 页去重:11px / 13px / 14px / 16px / 20px / 32px(共 6 档);越界 0"
+    },
+    {
+      "no": 8,
+      "name": "全站字重只有 400/500/600(无 700/800)",
+      "ok": true,
+      "detail": "去重:400 / 500 / 600;700/800 命中 0"
+    },
+    {
+      "no": 9,
+      "name": "全站投影档位 ≤ 3 档(--shadow-1/2/3)",
+      "ok": true,
+      "detail": "12 种 computed box-shadow;去重后:投影 3 档 + 结构线(环 / 内描边)1 种:\n     S1 0px 1px 2px 0px a0.04 | 0px 8px 24px -8px a0.08\n     S2 0px 1px 1px 0px a0.03 | 0px 1px 2px 0px a0.04\n     S3 0px 16px 48px -12px a0.18 | 0px 1px 2px 0px a0.04\n     镜像档(与某档同形、offset-y 取反)不计:0px 16px 48px -12px a0.18 | 0px 1px 2px 0px a0.04;0px -16px 48px -12px a0.18 | 0px -1px 2px 0px a0.04\n     结构线:R1 0px 0px 0px 3px a0.15\n     含 inset 的全部原值(12 种)见 docs/screenshots/v10/shadow-report.json"
+    },
+    {
+      "no": 10,
+      "name": "玻璃卡含 inset 0 1px 0 rgba(255,255,255,.70) + blur(28px) saturate(180%)",
+      "ok": true,
+      "detail": ".frame box-shadow = rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.08) 0px -1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.08) 0px 8px 24px -8px\n     backdrop-filter = blur(28px) saturate(1.8)"
+    },
+    {
+      "no": 11,
+      "name": "卡片圆角 16px(桌面),全站无 ≥24px",
+      "ok": true,
+      "detail": "类型页卡片 16px,工作台数据卡 16px;类型页全页圆角分布:50% ×51 (span.live,span.dot);16px ×15 (aside.side.kf-glass.is-panel,header.bar.kf-glass);15px ×12 (div.face,div.face);10px ×19 (span.mark,a);8px ×18 (span.ic,span.ic);7px ×6 (button,button);6px ×67 (span.badge,span.badge.danger);5px ×2 (span.pri.p-A,span.pri.p-S);4px ×17 (span.ck,span.ck);2px ×51 (span.sw.t-blue,span.sw.t-violet)"
+    },
+    {
+      "no": 12,
+      "name": "强调色 #5B5BD6 占比 ≤ 5%(按面积)",
+      "ok": true,
+      "detail": "命中元素面积合计 7632px² / 首屏可见元素面积 13217493px² = 0.06%;命中 6 个元素:span.mark「康」 40×40、div.avatar「?」 44×44、span.ic 32×32、span.ic 32×32、span.ic 32×32、span.ic 32×32"
+    },
+    {
+      "no": "12b",
+      "name": "对比度 ≥ 4.5:1:桌面 + 手机逐页逐文字节点(玻璃后按光场最深色斑算)",
+      "ok": true,
+      "detail": "桌面 9 个页面共 3164 个文字节点;正文(gray-12 / gray-11)最低 6.14:1「9」11px;全节点最低 3:1「?」11px(rgb(255, 255, 255),渐变底白字单列);手机 9 个页面共 2846 个,最低 3:1「?」13px(rgb(255, 255, 255));--kf-ink-3(rgb(75,85,99))辅助文字 1724 处,最低 4.63:1;渐变底白字(头像 / 品牌块)42 个单列;低于阈值的 0 个"
+    },
+    {
+      "no": 13,
+      "name": "hover:150ms cubic-bezier(.2,0,0,1),translateY(-1px);press scale(.985) 80ms",
+      "ok": true,
+      "detail": "可点卡(lift).frame transition = transform, box-shadow, border-color, background-color, color | 0.15s, 0.15s, 0.15s, 0.15s, 0.15s | cubic-bezier(0.2, 0, 0, 1), cubic-bezier(0.2, 0, 0, 1), cubic-bezier(0.2, 0, 0, 1), cubic-bezier(0.2, 0, 0, 1), cubic-bezier(0.2, 0, 0, 1);hover 实测 transform = matrix(1, 0, 0, 1, 0, -1);按下实测 transform = matrix(0.985, 0, 0, 0.985, 0, 0)"
+    },
+    {
+      "no": 14,
+      "name": "prefers-reduced-motion: reduce 覆盖",
+      "ok": true,
+      "detail": "模拟 reduce 媒体后 matchMedia=true,卡片 transition-duration computed = 1e-05s"
+    },
+    {
+      "no": 15,
+      "name": "手机 393px:Tab 栏在、工作台单列、9 页 scrollWidth === clientWidth",
+      "ok": true,
+      "detail": "9 页 scrollWidth/clientWidth:/ 393/393、/pool 393/393、/stages 393/393、/reports 393/393、/reports/competitor 393/393、/reports/voc 393/393、/members 393/393、/ledger 393/393;Tab 栏 grid(5 项,底边 852=视口 852),侧栏 none;工作台 .grid2 列 345px,两卡 345×78@24 / 345×130@24"
+    },
+    {
+      "no": 16,
+      "name": "node --test 46/46 + check.mjs 全绿 + 字数不回升",
+      "ok": true,
+      "detail": "单测 pass=46 fail=0;check 13 ✓ / 0 ✗;字数 类型页 96/300 · 工作台 95/350 · 阶段页 33/300 · 报告详情 183/250 · 报告中心 38/200;控制台错误 0"
+    }
+  ],
+  "collected": {
+    "grid2": {
+      "alignItems": "start",
+      "inlineStyle": "--i:0",
+      "gridH": 110,
+      "cols": "610.906px 509.094px",
+      "cards": [
+        {
+          "id": "?选「我是」",
+          "cardH": 78,
+          "contentH": 44,
+          "faceH": 76,
+          "display": "flex",
+          "justify": "normal",
+          "dir": "column"
+        },
+        {
+          "id": "1 人卡住 ",
+          "cardH": 109.8,
+          "contentH": 75.8,
+          "faceH": 107.8,
+          "display": "flex",
+          "justify": "normal",
+          "dir": "column"
+        }
+      ]
+    },
+    "gaps": {
+      "/": {
+        "gap": 26,
+        "span": 235.171875,
+        "a": "「?」(@101)",
+        "b": "「A 级」(@380)",
+        "rows": 23,
+        "tracks": 106,
+        "painted": 140,
+        "docH": 976
+      },
+      "/pool": {
+        "gap": 46,
+        "span": 291.5,
+        "a": "「状态」(@192)",
+        "b": "「池子」(@499)",
+        "rows": 73,
+        "tracks": 320,
+        "painted": 359,
+        "docH": 1779
+      },
+      "/stages": {
+        "gap": 32,
+        "span": 101,
+        "a": "「S1」(@101)",
+        "b": "「35」(@221)",
+        "rows": 116,
+        "tracks": 549,
+        "painted": 1142,
+        "docH": 3671
+      },
+      "/reports": {
+        "gap": 46,
+        "span": 133.59375,
+        "a": "「1」(@101)",
+        "b": "「阶段」(@270)",
+        "rows": 77,
+        "tracks": 320,
+        "painted": 620,
+        "docH": 1599
+      },
+      "/reports/competitor": {
+        "gap": 38,
+        "span": 110.1875,
+        "a": "「品类分析」(@84)",
+        "b": "「阶段」(@228)",
+        "rows": 65,
+        "tracks": 283,
+        "painted": 553,
+        "docH": 1497
+      },
+      "/reports/voc": {
+        "gap": 38,
+        "span": 110.1875,
+        "a": "「品类分析」(@84)",
+        "b": "「阶段」(@228)",
+        "rows": 68,
+        "tracks": 261,
+        "painted": 496,
+        "docH": 1539
+      },
+      "/members": {
+        "gap": 50,
+        "span": 452.484375,
+        "a": "「成员」(@90)",
+        "b": "「G2」(@568)",
+        "rows": 30,
+        "tracks": 121,
+        "painted": 136,
+        "docH": 1169
+      },
+      "/ledger": {
+        "gap": 38,
+        "span": 331.296875,
+        "a": "「证据台账」(@96)",
+        "b": "「点击行查看证据链」(@453)",
+        "rows": 51,
+        "tracks": 393,
+        "painted": 394,
+        "docH": 1853
+      }
+    },
+    "grid2Gap": 24,
+    "stretches": {},
+    "typo": {
+      "value": {
+        "fs": "32px",
+        "fw": "600",
+        "ls": "-0.64px",
+        "lh": "35.2px",
+        "fvn": "tabular-nums",
+        "color": "rgb(24, 24, 27)",
+        "text": "9"
+      },
+      "label": {
+        "fs": "11px",
+        "fw": "500",
+        "ls": "0.66px",
+        "lh": "15.4px",
+        "fvn": "normal",
+        "color": "rgb(75, 85, 99)",
+        "text": "432"
+      },
+      "valTag": "B",
+      "allValues": [
+        {
+          "fs": "32px",
+          "fw": "600",
+          "ls": "-0.64px",
+          "lh": "35.2px",
+          "fvn": "tabular-nums",
+          "color": "rgb(24, 24, 27)",
+          "text": "9"
+        },
+        {
+          "fs": "32px",
+          "fw": "600",
+          "ls": "-0.64px",
+          "lh": "35.2px",
+          "fvn": "tabular-nums",
+          "color": "rgb(24, 24, 27)",
+          "text": "82"
+        },
+        {
+          "fs": "32px",
+          "fw": "600",
+          "ls": "-0.64px",
+          "lh": "35.2px",
+          "fvn": "tabular-nums",
+          "color": "rgb(24, 24, 27)",
+          "text": "8人"
+        },
+        {
+          "fs": "32px",
+          "fw": "600",
+          "ls": "-0.64px",
+          "lh": "35.2px",
+          "fvn": "tabular-nums",
+          "color": "rgb(24, 24, 27)",
+          "text": "5"
+        }
+      ],
+      "statNum": null
+    },
+    "sizes": [
+      "11px",
+      "13px",
+      "14px",
+      "16px",
+      "20px",
+      "32px"
+    ],
+    "weights": [
+      "400",
+      "500",
+      "600"
+    ],
+    "shadowRaw": [
+      "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.08) 0px -1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.08) 0px 8px 24px -8px",
+      "rgba(255, 255, 255, 0.22) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+      "rgb(255, 255, 255) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+      "rgba(46, 125, 91, 0.15) 0px 0px 0px 3px",
+      "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+      "rgba(255, 255, 255, 0.3) 0px 1px 0px 0px inset",
+      "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.18) 0px 16px 48px -12px",
+      "rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.18) 0px 16px 48px -12px",
+      "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px -1px 2px 0px, rgba(0, 0, 0, 0.18) 0px -16px 48px -12px",
+      "rgba(255, 255, 255, 0.18) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+      "rgba(255, 255, 255, 0.6) 0px 1px 0px 0px inset",
+      "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.08) 0px 8px 24px -8px"
+    ],
+    "shadowLevels": [
+      "0px 1px 2px 0px a0.04 | 0px 8px 24px -8px a0.08",
+      "0px 1px 1px 0px a0.03 | 0px 1px 2px 0px a0.04",
+      "0px 0px 0px 3px a0.15",
+      "",
+      "0px 16px 48px -12px a0.18 | 0px 1px 2px 0px a0.04",
+      "0px -16px 48px -12px a0.18 | 0px -1px 2px 0px a0.04"
+    ],
+    "glass": {
+      "shadow": "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.08) 0px -1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.08) 0px 8px 24px -8px",
+      "blur": "blur(28px) saturate(1.8)",
+      "radius": "16px",
+      "bg": "linear-gradient(rgba(255, 255, 255, 0.62), rgba(255, 255, 255, 0.62)), linear-gradient(rgba(255, 255, 255, 0.9), rgba(25"
+    },
+    "contrast": {
+      "minMeta": {
+        "dMeta": {
+          "text": "9",
+          "ratio": 6.14,
+          "need": 4.5,
+          "size": 11,
+          "color": "rgb(63, 63, 70)",
+          "where": "nav>kf-nav»>a>kf-nav»>span.badge"
+        },
+        "dAll": {
+          "text": "?",
+          "ratio": 3,
+          "need": 3,
+          "size": 11,
+          "color": "rgb(255, 255, 255)",
+          "where": "span.who",
+          "plate": true
+        },
+        "mAll": {
+          "text": "?",
+          "ratio": 3,
+          "need": 3,
+          "size": 13,
+          "color": "rgb(255, 255, 255)",
+          "where": "span.who",
+          "plate": true
+        }
+      },
+      "nD": 3164,
+      "nM": 2846,
+      "minD": {
+        "text": "?",
+        "ratio": 3,
+        "need": 3,
+        "size": 11,
+        "color": "rgb(255, 255, 255)",
+        "where": "span.who",
+        "plate": true
+      },
+      "minM": {
+        "text": "?",
+        "ratio": 3,
+        "need": 3,
+        "size": 13,
+        "color": "rgb(255, 255, 255)",
+        "where": "span.who",
+        "plate": true
+      },
+      "under": [],
+      "ink3min": {
+        "text": "▦",
+        "ratio": 4.63,
+        "need": 4.5,
+        "size": 11,
+        "color": "rgb(75, 85, 99)",
+        "where": "div.thumb>view-stages»>div.cap>view-stages»>span"
+      }
+    },
+    "hover": {
+      "transition": "all | 0s | ease",
+      "transformBefore": "none",
+      "rect": {
+        "x": 573,
+        "y": 123,
+        "width": 611,
+        "height": 78
+      },
+      "transform": "none",
+      "shadow": "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.08) 0px -1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.08) 0px 8",
+      "trans": {
+        "transition": "transform, box-shadow, border-color, background-color, color | 0.15s, 0.15s, 0.15s, 0.15s, 0.15s | cubic-bezier(0.2, 0, 0, 1), cubic-bezier(0.2, 0, 0, 1), cubic-bezier(0.2, 0, 0, 1), cubic-bezier(0.2, 0, 0, 1), cubic-bezier(0.2, 0, 0, 1)",
+        "at": {
+          "x": 452,
+          "y": 427
+        }
+      },
+      "liftHov": {
+        "transform": "matrix(1, 0, 0, 1, 0, -1)",
+        "x": 452,
+        "y": 426
+      },
+      "liftAct": "matrix(0.985, 0, 0, 0.985, 0, 0)"
+    },
+    "liftBox": {
+      "x": 452,
+      "y": 427
+    }
+  },
+  "shadowRaw": [
+    "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.08) 0px -1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.08) 0px 8px 24px -8px",
+    "rgba(255, 255, 255, 0.22) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+    "rgb(255, 255, 255) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+    "rgba(46, 125, 91, 0.15) 0px 0px 0px 3px",
+    "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+    "rgba(255, 255, 255, 0.3) 0px 1px 0px 0px inset",
+    "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.18) 0px 16px 48px -12px",
+    "rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.18) 0px 16px 48px -12px",
+    "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px -1px 2px 0px, rgba(0, 0, 0, 0.18) 0px -16px 48px -12px",
+    "rgba(255, 255, 255, 0.18) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+    "rgba(255, 255, 255, 0.6) 0px 1px 0px 0px inset",
+    "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.08) 0px 8px 24px -8px"
+  ]
+}

+ 701 - 0
docs/screenshots/v10/measure-before.json

@@ -0,0 +1,701 @@
+{
+  "tag": "before",
+  "rows": [
+    {
+      "no": 1,
+      "name": ".grid2 改为 align-items: start(computed)",
+      "ok": false,
+      "detail": "computed align-items=stretch;inline style「align-items:stretch」;grid 高 126px,列宽 610.906px 509.094px"
+    },
+    {
+      "no": 2,
+      "name": "两张卡高度 = 各自内容高度(卡高 − 内容高 ≤ 40px)",
+      "ok": false,
+      "detail": "卡1「?选「我是」」高 125.8px,内容 44px,差 81.8px(.frame display=flex justify=normal);卡2「1 人卡住 」高 125.8px,内容 75.8px,差 50px(.frame display=flex justify=normal);两卡高度差 0.0px"
+    },
+    {
+      "no": 3,
+      "name": "全页最大连续空白 ≤ 80px(9 个路由逐页量;按横截面采样,卡片面 / 表格行 / 空态面板算内容)",
+      "ok": true,
+      "detail": "/ 24px(「?」(@109) ↓ 「机会卡池」(@234));/pool 44px(「机会卡池」(@90) ↓ 「池子」(@520));/stages 34px(「S1」(@101) ↓ 「35」(@225));/reports 46px(「1」(@101) ↓ 「阶段」(@270));/reports/competitor 38px(「1」(@96) ↓ 「阶段」(@219));/reports/voc 38px(「1」(@96) ↓ 「阶段」(@219));/members 50px(「成员」(@90) ↓ 「G2」(@594));/ledger 36px(「证据台账」(@95) ↓ 「点击行查看证据链」(@472));工作台 .grid2 卡片底 → 下一区块标题 24px"
+    },
+    {
+      "no": 4,
+      "name": "全站「并排多卡」无 align-items: stretch 拉伸",
+      "ok": false,
+      "detail": "/:div.grid2×2 高 126/126"
+    },
+    {
+      "no": 5,
+      "name": "主数值 32px / 600 / -0.02em / tabular-nums / line-height 1.1",
+      "ok": true,
+      "detail": "主数值「9」:font-size 32px,font-weight 600,letter-spacing -0.64px(= 32px × -0.02,浏览器按 0.1px 精度取值),line-height 35.2px,font-variant-numeric tabular-nums,color rgb(20, 29, 51);四张数据卡数值字号 32px/600 -0.64px、32px/600 -0.64px、32px/600 -0.64px、32px/600 -0.64px;kf-stat 数值 本页无"
+    },
+    {
+      "no": 6,
+      "name": "标签 11px / 500 / 0.06em / gray-9",
+      "ok": true,
+      "detail": "标签「432」:font-size 11px,font-weight 500,letter-spacing 0.66px(11px×0.06=0.66px),color rgb(35, 44, 69)"
+    },
+    {
+      "no": 7,
+      "name": "全站字号去重 ≤ 6 档(11/13/14/16/20/32)",
+      "ok": false,
+      "detail": "桌面 + 手机 9 页去重:9px / 9.5px / 10px / 10.5px / 11px / 11.5px / 12px / 12.5px / 13px / 13.5px / 14px / 15px / 16px / 17px / 18px / 20px / 24px / 32px(共 18 档);越界:D/: span.mark 18px「康」;D/: div.name 15px「产品创新看板」;D/: div.sub 11.5px「研发中心 · A」;D/: span.lbl 13.5px「工作台」;D/: span.en 10px「Workbenc」;D/: span.lbl 13.5px「机会卡池」;D/: span.en 10px「Opportun」;D/: span.lbl 13.5px「五阶段产出」"
+    },
+    {
+      "no": 8,
+      "name": "全站字重只有 400/500/600(无 700/800)",
+      "ok": false,
+      "detail": "去重:400 / 500 / 550 / 600 / 650 / 700 / 800 / 900;超重:D/: span.mark 800「康」;D/: div.name 700「产品创新看板」;D/: span.badge 700「9」;D/: span.badge.danger 700「1」;D/: span.who 700「?」;D/: div.v 700「76」;D/: div.v 700「中」;D/: div.v 700「5」"
+    },
+    {
+      "no": 9,
+      "name": "全站投影档位 ≤ 3 档(--shadow-1/2/3)",
+      "ok": false,
+      "detail": "16 种 computed box-shadow;去重后:投影 11 档 + 结构线(环 / 内描边)2 种:\n     S1 0px 1px 2px 0px a0.05 | 0px 8px 24px -12px a0.1\n     S2 0px 8px 20px -10px a0.7\n     S3 0px 6px 16px -12px a0.4\n     S4 0px 1px 2px 0px a0.06\n     S5 -24px 0px 60px -30px a0.35\n     S6 0px 18px 40px -18px a0.6\n     S7 0px -20px 48px -24px a0.35\n     S8 0px 1px 2px 0px a0.12\n     S9 0px 16px 32px -16px a0.28\n     S10 0px 8px 16px -10px a0.8\n     S11 0px 1px 2px 0px a0.05\n     镜像档(与某档同形、offset-y 取反)不计:\n     结构线:R1 0px 0px 0px 3px a0.15;R2 0px 0px 0px 1.5px a0.9\n     含 inset 的全部原值(16 种)见 docs/screenshots/v10/shadow-report.json"
+    },
+    {
+      "no": 10,
+      "name": "玻璃卡含 inset 0 1px 0 rgba(255,255,255,.70) + blur(28px) saturate(180%)",
+      "ok": false,
+      "detail": ".frame box-shadow = rgba(255, 255, 255, 0.85) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px -1px 0px 0px inset, rgba(16, 24, 40, 0.05) 0px 1px 2px 0px, rgba(16, 24, 40, 0.1) 0px 8px 24px -12px\n     backdrop-filter = blur(28px) saturate(1.8)"
+    },
+    {
+      "no": 11,
+      "name": "卡片圆角 16px(桌面),全站无 ≥24px",
+      "ok": true,
+      "detail": "类型页卡片 16px,工作台数据卡 16px;类型页全页圆角分布:999px ×16 (span.badge,span.badge.danger);50% ×51 (span.live,span.dot);16px ×15 (aside.side.kf-glass.is-panel,header.bar.kf-glass);15px ×12 (div.face,div.face);12px ×2 (span.mark,div.ico);11px ×6 (a,a);10px ×11 (button.refresh.kf-btn,div.kpi);8px ×18 (span.ic,span.ic);7px ×6 (button,button);6px ×51 (button.opt,button.opt);5px ×2 (span.pri.p-A,span.pri.p-S);4px ×17 (span.ck,span.ck);2px ×51 (span.sw.t-blue,span.sw.t-violet)"
+    },
+    {
+      "no": 12,
+      "name": "强调色 #5B5BD6 占比 ≤ 5%(按面积)",
+      "ok": true,
+      "detail": "命中元素面积合计 1936px² / 首屏可见元素面积 13729510px² = 0.01%;命中 1 个元素:div.avatar「?」 44×44"
+    },
+    {
+      "no": "12b",
+      "name": "对比度 ≥ 4.5:1:桌面 + 手机逐页逐文字节点(玻璃后按光场最深色斑算)",
+      "ok": true,
+      "detail": "桌面 9 个页面共 3164 个文字节点;正文(gray-12 / gray-11)最低 99:1「undefined」undefinedpx;全节点最低 3:1「康」18px(rgb(255, 255, 255),渐变底白字单列);手机 9 个页面共 2846 个,最低 3:1「?」13px(rgb(255, 255, 255));--kf-ink-3(rgb(75,85,99))辅助文字 0 处,最低 —:1;渐变底白字(头像 / 品牌块)51 个单列;低于阈值的 0 个"
+    },
+    {
+      "no": 13,
+      "name": "hover:150ms cubic-bezier(.2,0,0,1),translateY(-1px);press scale(.985) 80ms",
+      "ok": false,
+      "detail": "可点卡(lift).frame transition = all | 0.22s | cubic-bezier(0.4, 0, 0.2, 1);hover 实测 transform = matrix(1.002, 0, 0, 1.002, 0, -2);按下实测 transform = matrix(1.002, 0, 0, 1.002, 0, -2)"
+    },
+    {
+      "no": 14,
+      "name": "prefers-reduced-motion: reduce 覆盖",
+      "ok": true,
+      "detail": "模拟 reduce 媒体后 matchMedia=true,卡片 transition-duration computed = 1e-05s"
+    },
+    {
+      "no": 15,
+      "name": "手机 393px:Tab 栏在、工作台单列、9 页 scrollWidth === clientWidth",
+      "ok": true,
+      "detail": "9 页 scrollWidth/clientWidth:/ 393/393、/pool 393/393、/stages 393/393、/reports 393/393、/reports/competitor 393/393、/reports/voc 393/393、/members 393/393、/ledger 393/393;Tab 栏 grid(5 项,底边 852=视口 852),侧栏 none;工作台 .grid2 列 345px,两卡 345×78@24 / 345×131@24"
+    },
+    {
+      "no": 16,
+      "name": "node --test 46/46 + check.mjs 全绿 + 字数不回升",
+      "ok": true,
+      "detail": "单测 pass=46 fail=0;check 13 ✓ / 0 ✗;字数 类型页 96/300 · 工作台 95/350 · 阶段页 33/300 · 报告详情 183/250 · 报告中心 38/200;控制台错误 0"
+    }
+  ],
+  "collected": {
+    "grid2": {
+      "alignItems": "stretch",
+      "inlineStyle": "align-items:stretch",
+      "gridH": 126,
+      "cols": "610.906px 509.094px",
+      "cards": [
+        {
+          "id": "?选「我是」",
+          "cardH": 125.8,
+          "contentH": 44,
+          "faceH": 92,
+          "display": "flex",
+          "justify": "normal",
+          "dir": "column"
+        },
+        {
+          "id": "1 人卡住 ",
+          "cardH": 125.8,
+          "contentH": 75.8,
+          "faceH": 123.8,
+          "display": "flex",
+          "justify": "normal",
+          "dir": "column"
+        }
+      ]
+    },
+    "gaps": {
+      "/": {
+        "gap": 24,
+        "span": 80.796875,
+        "a": "「?」(@109)",
+        "b": "「机会卡池」(@234)",
+        "rows": 28,
+        "tracks": 106,
+        "painted": 136,
+        "docH": 976
+      },
+      "/pool": {
+        "gap": 44,
+        "span": 398.796875,
+        "a": "「机会卡池」(@90)",
+        "b": "「池子」(@520)",
+        "rows": 83,
+        "tracks": 320,
+        "painted": 359,
+        "docH": 1899
+      },
+      "/stages": {
+        "gap": 34,
+        "span": 105.390625,
+        "a": "「S1」(@101)",
+        "b": "「35」(@225)",
+        "rows": 118,
+        "tracks": 549,
+        "painted": 1142,
+        "docH": 3921
+      },
+      "/reports": {
+        "gap": 46,
+        "span": 133.59375,
+        "a": "「1」(@101)",
+        "b": "「阶段」(@270)",
+        "rows": 77,
+        "tracks": 320,
+        "painted": 615,
+        "docH": 1663
+      },
+      "/reports/competitor": {
+        "gap": 38,
+        "span": 112.5,
+        "a": "「1」(@96)",
+        "b": "「阶段」(@219)",
+        "rows": 65,
+        "tracks": 283,
+        "painted": 550,
+        "docH": 1551
+      },
+      "/reports/voc": {
+        "gap": 38,
+        "span": 112.5,
+        "a": "「1」(@96)",
+        "b": "「阶段」(@219)",
+        "rows": 71,
+        "tracks": 261,
+        "painted": 493,
+        "docH": 1646
+      },
+      "/members": {
+        "gap": 50,
+        "span": 472.796875,
+        "a": "「成员」(@90)",
+        "b": "「G2」(@594)",
+        "rows": 30,
+        "tracks": 121,
+        "painted": 125,
+        "docH": 1215
+      },
+      "/ledger": {
+        "gap": 36,
+        "span": 346.296875,
+        "a": "「证据台账」(@95)",
+        "b": "「点击行查看证据链」(@472)",
+        "rows": 51,
+        "tracks": 393,
+        "painted": 394,
+        "docH": 1873
+      }
+    },
+    "grid2Gap": 24,
+    "stretches": {
+      "/": [
+        {
+          "el": "div.grid2",
+          "n": 2,
+          "hs": "126/126",
+          "grid": true
+        }
+      ]
+    },
+    "typo": {
+      "value": {
+        "fs": "32px",
+        "fw": "600",
+        "ls": "-0.64px",
+        "lh": "35.2px",
+        "fvn": "tabular-nums",
+        "color": "rgb(20, 29, 51)",
+        "text": "9"
+      },
+      "label": {
+        "fs": "11px",
+        "fw": "500",
+        "ls": "0.66px",
+        "lh": "15.4px",
+        "fvn": "normal",
+        "color": "rgb(35, 44, 69)",
+        "text": "432"
+      },
+      "valTag": "B",
+      "allValues": [
+        {
+          "fs": "32px",
+          "fw": "600",
+          "ls": "-0.64px",
+          "lh": "35.2px",
+          "fvn": "tabular-nums",
+          "color": "rgb(20, 29, 51)",
+          "text": "9"
+        },
+        {
+          "fs": "32px",
+          "fw": "600",
+          "ls": "-0.64px",
+          "lh": "35.2px",
+          "fvn": "tabular-nums",
+          "color": "rgb(20, 29, 51)",
+          "text": "82"
+        },
+        {
+          "fs": "32px",
+          "fw": "600",
+          "ls": "-0.64px",
+          "lh": "35.2px",
+          "fvn": "tabular-nums",
+          "color": "rgb(20, 29, 51)",
+          "text": "8人"
+        },
+        {
+          "fs": "32px",
+          "fw": "600",
+          "ls": "-0.64px",
+          "lh": "35.2px",
+          "fvn": "tabular-nums",
+          "color": "rgb(20, 29, 51)",
+          "text": "5"
+        }
+      ],
+      "statNum": null
+    },
+    "wBad": [
+      "D/: span.mark 800「康」",
+      "D/: div.name 700「产品创新看板」",
+      "D/: span.badge 700「9」",
+      "D/: span.badge.danger 700「1」",
+      "D/: span.who 700「?」",
+      "D/: div.v 700「76」",
+      "D/: div.v 700「中」",
+      "D/: div.v 700「5」",
+      "D/pool: span.mark 800「康」",
+      "D/pool: div.name 700「产品创新看板」",
+      "D/pool: span.badge 700「9」",
+      "D/pool: span.badge.danger 700「1」",
+      "D/pool: span.who 700「?」",
+      "D/pool: b 700「9」",
+      "D/pool: div.v 700「82」",
+      "D/pool: div.v 700「中」",
+      "D/stages: span.mark 800「康」",
+      "D/stages: div.name 700「产品创新看板」",
+      "D/stages: span.badge 700「9」",
+      "D/stages: span.badge.danger 700「1」",
+      "D/stages: span.who 700「?」",
+      "D/stages: span.b.blue 700「S1」",
+      "D/stages: b 700「6」",
+      "D/stages: span.b.violet 700「S2」",
+      "D/reports: span.mark 800「康」",
+      "D/reports: div.name 700「产品创新看板」",
+      "D/reports: span.badge 700「9」",
+      "D/reports: span.badge.danger 700「1」",
+      "D/reports: span.who 700「?」",
+      "D/reports: b 900「45」",
+      "D/reports: button 650「最新」",
+      "D/reports: button 650「最新」",
+      "D/reports/competitor: span.mark 800「康」",
+      "D/reports/competitor: div.name 700「产品创新看板」",
+      "D/reports/competitor: span.badge 700「9」",
+      "D/reports/competitor: span.badge.danger 700「1」",
+      "D/reports/competitor: span.who 700「?」",
+      "D/reports/competitor: b 700「25」",
+      "D/reports/competitor: b 700「5」",
+      "D/reports/competitor: b 700「2」",
+      "D/reports/voc: span.mark 800「康」",
+      "D/reports/voc: div.name 700「产品创新看板」",
+      "D/reports/voc: span.badge 700「9」",
+      "D/reports/voc: span.badge.danger 700「1」",
+      "D/reports/voc: span.who 700「?」",
+      "D/reports/voc: b 700「10」",
+      "D/reports/voc: b 700「5」",
+      "D/reports/voc: b 700「1」",
+      "D/members: span.mark 800「康」",
+      "D/members: div.name 700「产品创新看板」",
+      "D/members: span.badge 700「9」",
+      "D/members: span.badge.danger 700「1」",
+      "D/members: span.who 700「?」",
+      "D/ledger: span.mark 800「康」",
+      "D/ledger: div.name 700「产品创新看板」",
+      "D/ledger: span.badge 700「9」",
+      "D/ledger: span.badge.danger 700「1」",
+      "D/ledger: span.who 700「?」",
+      "D/ledger: b 700「35」",
+      "M/: span.badge 700「9」",
+      "M/: span.badge.danger 700「1」",
+      "M/: span.who 700「?」",
+      "M/: div.v 700「76」",
+      "M/: div.v 700「中」",
+      "M/: div.v 700「5」",
+      "M/: b 650「暂缓」",
+      "M/: div.v 700「80」",
+      "M/pool: span.badge 700「9」",
+      "M/pool: span.badge.danger 700「1」",
+      "M/pool: span.who 700「?」",
+      "M/pool: b 700「9」",
+      "M/pool: button.pbar-btn 700「筛选」",
+      "M/pool: div.v 700「82」",
+      "M/pool: div.v 700「中」",
+      "M/pool: div.v 700「5」",
+      "M/stages: span.badge 700「9」",
+      "M/stages: span.badge.danger 700「1」",
+      "M/stages: span.who 700「?」",
+      "M/stages: span.b.blue 700「S1」",
+      "M/stages: b 700「6」",
+      "M/stages: span.b.violet 700「S2」",
+      "M/stages: b 700「8」",
+      "M/stages: span.b.teal 700「S3」",
+      "M/reports: span.badge 700「9」",
+      "M/reports: span.badge.danger 700「1」",
+      "M/reports: span.who 700「?」",
+      "M/reports: b 700「45」",
+      "M/reports: button 650「最新」",
+      "M/reports: span.pri.p-A 800「A」",
+      "M/reports: div.v 700「中」",
+      "M/reports: div.v 700「3」",
+      "M/reports/competitor: span.badge 700「9」",
+      "M/reports/competitor: span.badge.danger 700「1」",
+      "M/reports/competitor: span.who 700「?」",
+      "M/reports/competitor: b 700「25」",
+      "M/reports/competitor: b 700「5」",
+      "M/reports/competitor: small 700「/5」",
+      "M/reports/competitor: b 700「2」",
+      "M/reports/competitor: small 700「/25」",
+      "M/reports/voc: span.badge 700「9」",
+      "M/reports/voc: span.badge.danger 700「1」",
+      "M/reports/voc: span.who 700「?」",
+      "M/reports/voc: b 700「10」",
+      "M/reports/voc: b 700「5」",
+      "M/reports/voc: small 700「/5」",
+      "M/reports/voc: b 700「1」",
+      "M/reports/voc: small 700「/10」",
+      "M/members: span.badge 700「9」",
+      "M/members: span.badge.danger 700「1」",
+      "M/members: span.who 700「?」",
+      "M/ledger: span.badge 700「9」",
+      "M/ledger: span.badge.danger 700「1」",
+      "M/ledger: span.who 700「?」",
+      "M/ledger: h2 700「证据台账」",
+      "M/ledger: b 700「35」"
+    ],
+    "sBad": [
+      "D/: span.mark 18px「康」",
+      "D/: div.name 15px「产品创新看板」",
+      "D/: div.sub 11.5px「研发中心 · A」",
+      "D/: span.lbl 13.5px「工作台」",
+      "D/: span.en 10px「Workbenc」",
+      "D/: span.lbl 13.5px「机会卡池」",
+      "D/: span.en 10px「Opportun」",
+      "D/: span.lbl 13.5px「五阶段产出」",
+      "D/pool: span.mark 18px「康」",
+      "D/pool: div.name 15px「产品创新看板」",
+      "D/pool: div.sub 11.5px「研发中心 · A」",
+      "D/pool: span.lbl 13.5px「工作台」",
+      "D/pool: span.en 10px「Workbenc」",
+      "D/pool: span.lbl 13.5px「机会卡池」",
+      "D/pool: span.en 10px「Opportun」",
+      "D/pool: span.lbl 13.5px「五阶段产出」",
+      "D/stages: span.mark 18px「康」",
+      "D/stages: div.name 15px「产品创新看板」",
+      "D/stages: div.sub 11.5px「研发中心 · A」",
+      "D/stages: span.lbl 13.5px「工作台」",
+      "D/stages: span.en 10px「Workbenc」",
+      "D/stages: span.lbl 13.5px「机会卡池」",
+      "D/stages: span.en 10px「Opportun」",
+      "D/stages: span.lbl 13.5px「五阶段产出」",
+      "D/reports: span.mark 18px「康」",
+      "D/reports: div.name 15px「产品创新看板」",
+      "D/reports: div.sub 11.5px「研发中心 · A」",
+      "D/reports: span.lbl 13.5px「工作台」",
+      "D/reports: span.en 10px「Workbenc」",
+      "D/reports: span.lbl 13.5px「机会卡池」",
+      "D/reports: span.en 10px「Opportun」",
+      "D/reports: span.lbl 13.5px「五阶段产出」",
+      "D/reports/competitor: span.mark 18px「康」",
+      "D/reports/competitor: div.name 15px「产品创新看板」",
+      "D/reports/competitor: div.sub 11.5px「研发中心 · A」",
+      "D/reports/competitor: span.lbl 13.5px「工作台」",
+      "D/reports/competitor: span.en 10px「Workbenc」",
+      "D/reports/competitor: span.lbl 13.5px「机会卡池」",
+      "D/reports/competitor: span.en 10px「Opportun」",
+      "D/reports/competitor: span.lbl 13.5px「五阶段产出」",
+      "D/reports/voc: span.mark 18px「康」",
+      "D/reports/voc: div.name 15px「产品创新看板」",
+      "D/reports/voc: div.sub 11.5px「研发中心 · A」",
+      "D/reports/voc: span.lbl 13.5px「工作台」",
+      "D/reports/voc: span.en 10px「Workbenc」",
+      "D/reports/voc: span.lbl 13.5px「机会卡池」",
+      "D/reports/voc: span.en 10px「Opportun」",
+      "D/reports/voc: span.lbl 13.5px「五阶段产出」",
+      "D/members: span.mark 18px「康」",
+      "D/members: div.name 15px「产品创新看板」",
+      "D/members: div.sub 11.5px「研发中心 · A」",
+      "D/members: span.lbl 13.5px「工作台」",
+      "D/members: span.en 10px「Workbenc」",
+      "D/members: span.lbl 13.5px「机会卡池」",
+      "D/members: span.en 10px「Opportun」",
+      "D/members: span.lbl 13.5px「五阶段产出」",
+      "D/ledger: span.mark 18px「康」",
+      "D/ledger: div.name 15px「产品创新看板」",
+      "D/ledger: div.sub 11.5px「研发中心 · A」",
+      "D/ledger: span.lbl 13.5px「工作台」",
+      "D/ledger: span.en 10px「Workbenc」",
+      "D/ledger: span.lbl 13.5px「机会卡池」",
+      "D/ledger: span.en 10px「Opportun」",
+      "D/ledger: span.lbl 13.5px「五阶段产出」",
+      "M/: span.short 10px「工作台」",
+      "M/: span.badge 9.5px「9」",
+      "M/: span.short 10px「卡池」",
+      "M/: span.short 10px「阶段」",
+      "M/: span.short 10px「报告」",
+      "M/: span.badge.danger 9.5px「1」",
+      "M/: span.short 10px「成员」",
+      "M/: h1 17px「工作台」",
+      "M/pool: span.short 10px「工作台」",
+      "M/pool: span.badge 9.5px「9」",
+      "M/pool: span.short 10px「卡池」",
+      "M/pool: span.short 10px「阶段」",
+      "M/pool: span.short 10px「报告」",
+      "M/pool: span.badge.danger 9.5px「1」",
+      "M/pool: span.short 10px「成员」",
+      "M/pool: h1 17px「机会卡池」",
+      "M/stages: span.short 10px「工作台」",
+      "M/stages: span.badge 9.5px「9」",
+      "M/stages: span.short 10px「卡池」",
+      "M/stages: span.short 10px「阶段」",
+      "M/stages: span.short 10px「报告」",
+      "M/stages: span.badge.danger 9.5px「1」",
+      "M/stages: span.short 10px「成员」",
+      "M/stages: h1 17px「五阶段产出」",
+      "M/reports: span.short 10px「工作台」",
+      "M/reports: span.badge 9.5px「9」",
+      "M/reports: span.short 10px「卡池」",
+      "M/reports: span.short 10px「阶段」",
+      "M/reports: span.short 10px「报告」",
+      "M/reports: span.badge.danger 9.5px「1」",
+      "M/reports: span.short 10px「成员」",
+      "M/reports: h1 17px「报告中心」",
+      "M/reports/competitor: span.short 10px「工作台」",
+      "M/reports/competitor: span.badge 9.5px「9」",
+      "M/reports/competitor: span.short 10px「卡池」",
+      "M/reports/competitor: span.short 10px「阶段」",
+      "M/reports/competitor: span.short 10px「报告」",
+      "M/reports/competitor: span.badge.danger 9.5px「1」",
+      "M/reports/competitor: span.short 10px「成员」",
+      "M/reports/competitor: h1 17px「报告中心」",
+      "M/reports/voc: span.short 10px「工作台」",
+      "M/reports/voc: span.badge 9.5px「9」",
+      "M/reports/voc: span.short 10px「卡池」",
+      "M/reports/voc: span.short 10px「阶段」",
+      "M/reports/voc: span.short 10px「报告」",
+      "M/reports/voc: span.badge.danger 9.5px「1」",
+      "M/reports/voc: span.short 10px「成员」",
+      "M/reports/voc: h1 17px「报告中心」",
+      "M/members: span.short 10px「工作台」",
+      "M/members: span.badge 9.5px「9」",
+      "M/members: span.short 10px「卡池」",
+      "M/members: span.short 10px「阶段」",
+      "M/members: span.short 10px「报告」",
+      "M/members: span.badge.danger 9.5px「1」",
+      "M/members: span.short 10px「成员」",
+      "M/members: h1 17px「成员」",
+      "M/ledger: span.short 10px「工作台」",
+      "M/ledger: span.badge 9.5px「9」",
+      "M/ledger: span.short 10px「卡池」",
+      "M/ledger: span.short 10px「阶段」",
+      "M/ledger: span.short 10px「报告」",
+      "M/ledger: span.badge.danger 9.5px「1」",
+      "M/ledger: span.short 10px「成员」",
+      "M/ledger: h1 17px「证据台账」"
+    ],
+    "sizes": [
+      "9px",
+      "9.5px",
+      "10px",
+      "10.5px",
+      "11px",
+      "11.5px",
+      "12px",
+      "12.5px",
+      "13px",
+      "13.5px",
+      "14px",
+      "15px",
+      "16px",
+      "17px",
+      "18px",
+      "20px",
+      "24px",
+      "32px"
+    ],
+    "weights": [
+      "400",
+      "500",
+      "550",
+      "600",
+      "650",
+      "700",
+      "800",
+      "900"
+    ],
+    "shadowRaw": [
+      "rgba(255, 255, 255, 0.85) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px -1px 0px 0px inset, rgba(16, 24, 40, 0.05) 0px 1px 2px 0px, rgba(16, 24, 40, 0.1) 0px 8px 24px -12px",
+      "rgba(255, 255, 255, 0.5) 0px 1px 0px 0px inset, rgba(36, 87, 214, 0.7) 0px 8px 20px -10px",
+      "rgb(255, 255, 255) 0px 1px 0px 0px inset, rgba(26, 42, 80, 0.4) 0px 6px 16px -12px",
+      "rgba(46, 125, 91, 0.15) 0px 0px 0px 3px",
+      "rgba(255, 255, 255, 0.8) 0px 1px 0px 0px inset, rgba(16, 24, 40, 0.06) 0px 1px 2px 0px",
+      "rgba(255, 255, 255, 0.3) 0px 1px 0px 0px inset",
+      "rgba(18, 28, 52, 0.35) -24px 0px 60px -30px, rgba(255, 255, 255, 0.85) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px -1px 0px 0px inset",
+      "rgba(10, 20, 40, 0.6) 0px 18px 40px -18px",
+      "rgba(16, 24, 40, 0.35) 0px -20px 48px -24px, rgba(255, 255, 255, 0.95) 0px 1px 0px 0px inset",
+      "rgba(16, 24, 40, 0.12) 0px 1px 2px 0px, rgba(255, 255, 255, 0.18) 0px 1px 0px 0px inset",
+      "rgba(255, 255, 255, 0.9) 0px 0px 0px 1.5px",
+      "rgba(255, 255, 255, 0.6) 0px 1px 0px 0px inset",
+      "rgba(255, 255, 255, 0.85) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px -1px 0px 0px inset, rgba(16, 24, 40, 0.28) 0px 16px 32px -16px",
+      "rgba(255, 255, 255, 0.3) 0px 1px 0px 0px inset, rgba(20, 58, 166, 0.8) 0px 8px 16px -10px",
+      "rgba(255, 255, 255, 0.85) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px -1px 0px 0px inset, rgba(16, 24, 40, 0.05) 0px 1px 2px 0px",
+      "rgba(18, 32, 64, 0.12) 0px 1px 0px 0px inset"
+    ],
+    "shadowLevels": [
+      "0px 1px 2px 0px a0.05 | 0px 8px 24px -12px a0.1",
+      "0px 8px 20px -10px a0.7",
+      "0px 6px 16px -12px a0.4",
+      "0px 0px 0px 3px a0.15",
+      "0px 1px 2px 0px a0.06",
+      "",
+      "-24px 0px 60px -30px a0.35",
+      "0px 18px 40px -18px a0.6",
+      "0px -20px 48px -24px a0.35",
+      "0px 1px 2px 0px a0.12",
+      "0px 0px 0px 1.5px a0.9",
+      "0px 16px 32px -16px a0.28",
+      "0px 8px 16px -10px a0.8",
+      "0px 1px 2px 0px a0.05"
+    ],
+    "glass": {
+      "shadow": "rgba(255, 255, 255, 0.85) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px -1px 0px 0px inset, rgba(16, 24, 40, 0.05) 0px 1px 2px 0px, rgba(16, 24, 40, 0.1) 0px 8px 24px -12px",
+      "blur": "blur(28px) saturate(1.8)",
+      "radius": "16px",
+      "bg": "linear-gradient(rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.42)), linear-gradient(135deg, rgba(255, 255, 255, 0.95)"
+    },
+    "contrast": {
+      "minMeta": {
+        "dMeta": {
+          "ratio": 99
+        },
+        "dAll": {
+          "text": "康",
+          "ratio": 3,
+          "need": 3,
+          "size": 18,
+          "color": "rgb(255, 255, 255)",
+          "where": "span.mark",
+          "plate": true
+        },
+        "mAll": {
+          "text": "?",
+          "ratio": 3,
+          "need": 3,
+          "size": 13,
+          "color": "rgb(255, 255, 255)",
+          "where": "span.who",
+          "plate": true
+        }
+      },
+      "nD": 3164,
+      "nM": 2846,
+      "minD": {
+        "text": "康",
+        "ratio": 3,
+        "need": 3,
+        "size": 18,
+        "color": "rgb(255, 255, 255)",
+        "where": "span.mark",
+        "plate": true
+      },
+      "minM": {
+        "text": "?",
+        "ratio": 3,
+        "need": 3,
+        "size": 13,
+        "color": "rgb(255, 255, 255)",
+        "where": "span.who",
+        "plate": true
+      },
+      "under": [],
+      "ink3min": {
+        "ratio": 99
+      }
+    },
+    "hover": {
+      "transition": "all | 0s | ease",
+      "transformBefore": "none",
+      "rect": {
+        "x": 573,
+        "y": 147,
+        "width": 611,
+        "height": 126
+      },
+      "transform": "none",
+      "shadow": "rgba(255, 255, 255, 0.85) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px -1px 0px 0px inset, rgba(16, 24, 40, 0.05) 0px 1px 2px 0px, rgba(16, 24, 40, 0.1)",
+      "trans": {
+        "transition": "all | 0.22s | cubic-bezier(0.4, 0, 0.2, 1)",
+        "at": {
+          "x": 452,
+          "y": 451
+        }
+      },
+      "liftHov": {
+        "transform": "matrix(1.002, 0, 0, 1.002, 0, -2)",
+        "x": 452,
+        "y": 449
+      },
+      "liftAct": "matrix(1.002, 0, 0, 1.002, 0, -2)"
+    },
+    "liftBox": {
+      "x": 452,
+      "y": 451
+    }
+  },
+  "shadowRaw": [
+    "rgba(255, 255, 255, 0.85) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px -1px 0px 0px inset, rgba(16, 24, 40, 0.05) 0px 1px 2px 0px, rgba(16, 24, 40, 0.1) 0px 8px 24px -12px",
+    "rgba(255, 255, 255, 0.5) 0px 1px 0px 0px inset, rgba(36, 87, 214, 0.7) 0px 8px 20px -10px",
+    "rgb(255, 255, 255) 0px 1px 0px 0px inset, rgba(26, 42, 80, 0.4) 0px 6px 16px -12px",
+    "rgba(46, 125, 91, 0.15) 0px 0px 0px 3px",
+    "rgba(255, 255, 255, 0.8) 0px 1px 0px 0px inset, rgba(16, 24, 40, 0.06) 0px 1px 2px 0px",
+    "rgba(255, 255, 255, 0.3) 0px 1px 0px 0px inset",
+    "rgba(18, 28, 52, 0.35) -24px 0px 60px -30px, rgba(255, 255, 255, 0.85) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px -1px 0px 0px inset",
+    "rgba(10, 20, 40, 0.6) 0px 18px 40px -18px",
+    "rgba(16, 24, 40, 0.35) 0px -20px 48px -24px, rgba(255, 255, 255, 0.95) 0px 1px 0px 0px inset",
+    "rgba(16, 24, 40, 0.12) 0px 1px 2px 0px, rgba(255, 255, 255, 0.18) 0px 1px 0px 0px inset",
+    "rgba(255, 255, 255, 0.9) 0px 0px 0px 1.5px",
+    "rgba(255, 255, 255, 0.6) 0px 1px 0px 0px inset",
+    "rgba(255, 255, 255, 0.85) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px -1px 0px 0px inset, rgba(16, 24, 40, 0.28) 0px 16px 32px -16px",
+    "rgba(255, 255, 255, 0.3) 0px 1px 0px 0px inset, rgba(20, 58, 166, 0.8) 0px 8px 16px -10px",
+    "rgba(255, 255, 255, 0.85) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.25) 0px -1px 0px 0px inset, rgba(16, 24, 40, 0.05) 0px 1px 2px 0px",
+    "rgba(18, 32, 64, 0.12) 0px 1px 0px 0px inset"
+  ]
+}

+ 14 - 0
docs/screenshots/v10/shadow-report.json

@@ -0,0 +1,14 @@
+[
+  "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(255, 255, 255, 0.08) 0px -1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.08) 0px 8px 24px -8px",
+  "rgba(255, 255, 255, 0.22) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+  "rgb(255, 255, 255) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+  "rgba(46, 125, 91, 0.15) 0px 0px 0px 3px",
+  "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+  "rgba(255, 255, 255, 0.3) 0px 1px 0px 0px inset",
+  "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.18) 0px 16px 48px -12px",
+  "rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.18) 0px 16px 48px -12px",
+  "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px -1px 2px 0px, rgba(0, 0, 0, 0.18) 0px -16px 48px -12px",
+  "rgba(255, 255, 255, 0.18) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.03) 0px 1px 1px 0px",
+  "rgba(255, 255, 255, 0.6) 0px 1px 0px 0px inset",
+  "rgba(255, 255, 255, 0.7) 0px 1px 0px 0px inset, rgba(0, 0, 0, 0.04) 0px 1px 2px 0px, rgba(0, 0, 0, 0.08) 0px 8px 24px -8px"
+]

+ 53 - 0
index.html

@@ -0,0 +1,53 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+<meta name="color-scheme" content="light" />
+<meta name="theme-color" content="#e9edf7" />
+<title>康师傅 · 产品创新看板</title>
+<meta name="description" content="康师傅研发中心 AI 赋能实战营 · 多人工作台 + 机会卡片池" />
+<!-- 部署前缀唯一来源:scripts/config.mjs(与 importmap 前缀、上传前缀同源) -->
+<base href="/" />
+<link rel="preload" href="/src/assets/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
+<style>
+  /* 拉丁字母 / 数字用 Inter(latin subset,48KB,OFL);中文不在 unicode-range 内,直接走系统字体栈。
+     不引中文字体包(几 MB)。字重 400–600 来自同一份可变字体。 */
+  @font-face {
+    font-family: "Inter";
+    src: url("/src/assets/inter-latin.woff2") format("woff2");
+    font-weight: 300 700;
+    font-style: normal;
+    font-display: swap;
+    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+  }
+  html { font-family: "Inter", "PingFang SC", "Noto Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; }
+</style>
+<script type="importmap">
+{
+  "imports": {
+    "lit": "/lib/vendor/lit/v3.3.3/index.js",
+    "lit/": "/lib/vendor/lit/v3.3.3/",
+    "lit-html": "/lib/vendor/lit-html/v3.3.3/lit-html.js",
+    "lit-html/": "/lib/vendor/lit-html/v3.3.3/",
+    "lit-element": "/lib/vendor/lit-element/v4.2.2/index.js",
+    "lit-element/": "/lib/vendor/lit-element/v4.2.2/",
+    "@lit/reactive-element": "/lib/vendor/@lit/reactive-element/v2.1.2/reactive-element.js",
+    "@lit/reactive-element/": "/lib/vendor/@lit/reactive-element/v2.1.2/",
+    "@lit/context": "/lib/vendor/@lit/context/v1.1.6/index.js",
+    "@lit/context/": "/lib/vendor/@lit/context/v1.1.6/",
+    "@lit-labs/signals": "/lib/vendor/@lit-labs/signals/v0.3.0/index.js",
+    "@lit-labs/signals/": "/lib/vendor/@lit-labs/signals/v0.3.0/",
+    "signal-polyfill": "/lib/vendor/signal-polyfill/v0.2.2/dist/index.js",
+    "signal-polyfill/": "/lib/vendor/signal-polyfill/v0.2.2/dist/"
+  }
+}
+</script>
+<script>window.__KF_DEV__=true;window.__KF_PARSE_BASE__="https://kangshifu.fmode.cn/parse";</script>
+<script type="module" src="/src/app.js"></script>
+</head>
+<body>
+<kf-app></kf-app>
+<noscript><p style="padding:24px;font:16px system-ui">这个看板需要 JavaScript 才能读取数据。请在浏览器中启用 JavaScript 后刷新。</p></noscript>
+</body>
+</html>

+ 251 - 0
legacy/README.md

@@ -0,0 +1,251 @@
+# 康师傅产品创新看板 · v3(第二版迭代)
+
+> 第一版跑通了取数与筛选,但被判定「太普通、没有达到系统性看板的要求」。
+> 这一版在保留 v1 已通过能力的前提下,补上了**分析报告层**,并把它和台账层打通:
+> **从一句结论,能一路追到原始成果**。
+
+入口:`index.html`(纯静态,浏览器直接打开或任意静态服务器托管均可;`file://` 下也已实测可读)。
+
+```
+python3 -m http.server 8765        # 然后打开 http://127.0.0.1:8765/
+```
+
+---
+
+## 1. 这一版做了什么
+
+### 1.1 视觉:iOS Liquid Glass(客户硬性要求)
+
+v1 被打回的直接原因是「实心白卡片 + 靠阴影分开」。这一版把视觉主线重新做成**液态玻璃**:
+
+| 要求 | 落地 |
+|---|---|
+| 半透明磨砂层 | 面板/卡片 `background: rgba(255,255,255,.26~.58)` + `backdrop-filter: blur(20~32px) saturate(175~190%)` |
+| 玻璃为什么"透" | 页面底层有真实光场 `.ambient`:四团色光(蓝/紫/薄荷/橙)+ 一道从左上斜下的天光,极缓漂移(46–64s),再叠一层 SVG 湍流噪点。没有底层光,`backdrop-filter` 无物可模糊,玻璃就成了白纸 |
+| 发丝边 | 1px 半透明边;`.glass` 基元用双层背景做**上亮下暗的渐变边**(模拟玻璃边缘折光),面板底部另有一条更暗的极淡内边;吸顶的筛选条/目录条上方不圆角、无缝接到玻璃顶栏 |
+| 分层光照 | 所有光源从左上:每块玻璃顶部 `inset 0 1px 0 rgba(255,255,255,.7)` 内高光 + 左上角径向光斑放在容器层级;阴影全部"往下偏右、极浅"(`0 10px 30px -16px rgba(32,48,90,.14)`),**没有**新拟物那种大阴影凸起/凹陷 |
+| 纵深 | 三层透明度:L1 面板最透 → L2 卡片/指标稍实 → L3 顶栏/侧栏/抽屉更实;关键数字用强调色微光(`text-shadow` 柔光)而不是底色块 |
+| 铁律:不糊数据 | 正文/数字一律落在 `--ink`(对比度 ≈ 14:1)与 `--ink-2`(≈ 8:1)上;说明文字 `--ink-3`(≈ 5.2:1);万字级报告正文放在更实的"纸"衬底(`rgba(255,255,255,.62~.78)`)。模糊只存在于装饰层 |
+
+> 配色:客户说"配色无所谓",这里选了浅色(与 v1 的"浅色底"方向一致、更适合长文阅读),并明确定义了对比度层级。
+
+### 1.2 内容层:报告能被翻开读
+
+新增 **报告层**:
+
+- **报告目录页 `#/reportIndex`**:每份报告一张卡片(几章、几条结论、几条支撑成果、几条原文)。
+- **报告阅读页 `#/report/<成果id>`**:
+  - 吸顶章节导航,点击平滑跳转;**滚动时高亮当前章**(`?ch=` 会写回地址,刷新可还原)
+  - 封面区:项目名、方法论、数据源、**数据管线(2,727 → 1,190 → 730,过滤率 73%)**、生成日期、证据链完整度侧栏
+  - 报告结构总览(6 章,每张可点跳到对应章节)
+  - 执行摘要(敢下判断的四句话)
+  - KPI 卡片组(大数字 + 小单位 + 一行解读)
+  - 章首导语(每章一句话说清"这章在回答什么")
+  - 公式化标题(`5 大场景 × 现有产品 × 未满足需求`)、二级编号(1.1 / 1.2 …)
+  - 交叉分析表(场景 × 产品占比条 × 未满足需求 × VOC 数 × 机会)、漏斗、机会评分、心智阶梯、一句话定位、行动清单、验证里程碑、数据缺口
+  - **结论块带态度**("「软塌」的根因是工艺,不是口味"),旁边就是**证据锚点**
+  - **证据锚点**:点开抽屉,列出支撑这条结论的每条成果(谁、哪层、证据、在库里是否可追),以及原文片段;每条成果可跳回台账
+  - 附录:所有结论 × 证据对照总表
+  - 反面证据:把一条「没有原文的推演」原样留在证据链里,说明"我们排除了什么"
+
+### 1.3 闭环:从结论一路追到原始成果
+
+```
+归纳(沉淀层) ──展开──▶ 归纳自哪几份报告 ──▶ 报告
+     ▲                                     
+     │                                     
+报告结论 ──证据锚点──▶ 支撑成果(过程层) ──▶ 台账定位(?focus=)
+     ▲                                     
+     └──────── 成果抽屉:「作为证据,被这些结论引用」 ◀── 成果
+台账行标题「报告」标记 ──▶ 报告 ──▶ 顶部「成果台账」──▶ 台账
+```
+
+- 台账 → 报告:带报告的成果,标题本身就是报告链接("报告"徽标),也可在抽屉点「打开完整报告」
+- 报告 → 台账:证据锚点抽屉里每条成果都可跳转;跳过去时台账用 `?focus=` 高亮并置顶那一行,给出"正在定位"的说明和"清除定位"
+- 报告结论(反向)→ 成果:成果抽屉里「作为证据,被这些结论引用 N」,点开直接看证据链
+- 归纳 → 报告:归纳卡上「归纳自 N 份报告(M 条结论)」,展开看报告、对应结论、证据链,并可进报告
+- 往返不乱:所有状态都在 hash 里,往返时抽屉关闭、筛选与定位参数互不污染
+
+### 1.4 保留 v1 已通过的能力(没有推翻)
+
+真实取数(Parse REST,只带 appId)· 6 维筛选(优先级/阶段/品类/证据/层/成员)+ 组合叠加 · 筛选写 URL 刷新保留 · hash 路由直链落位 · 四层区分 · 五阶段人数分布 · 15 秒轮询 · 搜索。
+
+---
+
+## 2. 报告正文怎么存:`KfArtifact.report`(而不是新建 `KfReport`)
+
+**结论:挂在 `KfArtifact` 上新增 `report` 字段(Object/JSON),不新建 class。** 理由:
+
+1. **身份唯一**。报告不是独立对象,它就是"某个学员在某个阶段交的某条成果"的完整形态:同一份出身(`ownerName`/`ownerSession`)/ 同一阶段 / 同一层 / 同一项目。另建 class 就得复制或引用这套出身,而"没有出身的记录不许进沉淀层"的规则就会有两处实现,迟早不一致。
+2. **四层逻辑不需要改**。报告随成果一起是过程层;它的结论是**证据锚点**,不是沉淀层归纳。沉淀层仍然是 `KfInsight`。一条报告被回灌时,是它里面的结论被写成 `KfInsight`,并引用报告支撑的成果——这正好是现有规则。
+3. **闭环天然成立**。`derivedFrom` 一直都是 `KfArtifact.objectId`;报告的 evidence 里 `derivedFrom` 也指向同一批 id,不需要跨表映射。
+4. **写入简单**。上传 skill 只要多写一个字段;没有报告的老成果不写即可,前端显示"无报告",不需要迁移。
+5. **代价**:一份成果只有一个报告字段。后续若需要"一份成果多版报告/多语言",再拆 `KfReport` 并加 `artifact` 指针;届时 `report` 字段可以原样迁过去(结构不变)。
+
+### 报告数据结构(`KfArtifact.report`)
+
+```jsonc
+{
+  "version": 1,
+  "title": "…", "subtitle": "…", "method": "…", "sources": "…", "structure": "…",
+  "pipeline": [{"label":"原始 VOC 采集","value":2727}, {"label":"去噪去重","value":1190}, {"label":"精准 VOC","value":730}],
+  "filterRate": "73%",
+  "generatedAt": "ISO 时间",
+  "kpis":   [{"value":2727,"unit":"条","label":"原始 VOC","note":"一行解读"}],
+  "executive": ["四句话"],
+  "chapters": [
+    {"id":"ch1","no":1,"stage":"S1","en":"MARKET FUNNEL","title":"全景漏斗","subtitle":"…",
+     "lead":"这一章在回答什么问题",
+     "sections":[
+       {"id":"s1-1","no":"1.1","title":"…","kind":"funnel","data":[…]},
+       {"id":"s2-1","no":"2.1","title":"…","kind":"matrix","data":{"cols":[…],"rows":[…]}},
+       {"id":"s2-2","no":"2.2","title":"…","kind":"findings","data":[{"id":"f-1","conclusion":"…","attitude":"…","evidence":"c-texture"}]}
+     ]}
+  ],
+  "evidence": {
+    "c-texture": {"claim":"…","ownerCount":4,
+                  "derivedFrom":["<KfArtifact.objectId>", …],     // 支撑它的过程层成果
+                  "quotes":[{"artId":"<objectId|null>","author":"…","text":"…","ref":"…","likes":412}]}
+  }
+}
+```
+
+- **`chapters[].stage` 只能取 S1–S5**(沿用五阶段,没有发明阶段)。示例报告 6 章对应:Ch1 S1、Ch2/Ch3 S2、Ch4 S4、Ch5/Ch6 S5。
+- **`kind` 是渲染器的键**:`para / longpara / funnel / matrix / findings / table / two / fit / score / ladder / statement / milestones`。遇到未知 `kind` 会原样列出键值并标黄,**不会静默吞内容**。
+- **正文一律是数据,不是 HTML**:渲染端统一 `esc()`。避免把 HTML 塞进库里(注入、样式漂移、分身不好读)。将来要做富文本,再加 `format:"md"`,由渲染端解析。
+- **证据优先级**:`derivedFrom` 指向的成果是"事实来源";`quotes` 是原文片段。`quotes[].artId` 为空表示"原文来自外部采集、没有对应成果记录",抽屉里单独列出,不冒充成果。
+
+### 闭环用到的其他字段
+
+- `KfInsight.citedInsightIds`(Array,可选):这条归纳对应报告里的哪几条结论。没有时前端用"归纳支撑成果 ∩ 报告证据 derivedFrom"取交集推断。
+- 原有 `KfInsight.derivedFrom / derivedArtifacts`:不变。
+
+---
+
+## 3. 四层的含义(与 v1 一致)
+
+| 层 | `layer` | 谁写 | 页面上的样子 |
+|---|---|---|---|
+| 原材料 | `raw` | 调研 Agent | 原材料页;每条必须带出处 |
+| 过程 | `process` | 学员 + 分身 | 台账主体;报告挂在过程层成果上 |
+| 沉淀 | `sediment` | 归纳 + 证据筛选 | 归纳页;必须能回溯到支撑成果 |
+| 记录 | `record` | 自动落入 | 记录页;只留档,也保留在台账里以便看出"排除了什么" |
+
+同一个库,靠字段区分。前端所有列表都能看到层标。
+
+---
+
+## 4. 目录结构
+
+```
+index.html          入口
+css/tokens.css      设计变量(光场色、三层玻璃、发丝边、光照、文字对比度层级)
+css/base.css        光场/噪点、侧栏顶栏、应用骨架、玻璃基元
+css/components.css  指标、筛选、表格、归纳、抽屉、空态、骨架
+css/report.css      报告阅读页与目录页
+css/responsive.css  1280 / 1024 / 768
+js/config.js        ★ 唯一需要改数据源的地方
+js/taxonomy.js      阶段/层/优先级/证据枚举
+js/model.js         原始行 → 页面对象(含 report 规整、容错)
+js/query.js         筛选(纯函数)
+js/router.js        hash 路由(report / reportIndex / focus / ev / ch / back)
+js/views/*.js       各视图(report.js = 报告阅读、目录、证据链抽屉、滚动高亮)
+js/app.js           入口:取数 → 建模 → 按 hash 渲染 → 事件委托 → 轮询
+tools/seed_report.py       为 1 条成果写入示例报告(可重复执行)
+tools/seed_long_report.py  把示例报告扩到约 2 万字(压测第 13 项;再跑 seed_report.py 可还原)
+tools/seed_insight_link.py 把 1 条归纳与示例报告打通(让"归纳自哪几份报告"有样本)
+tools/enrich.py / reset_demo.py / setup_cloud.py   v1 遗留的数据准备脚本
+docs/verify.js      浏览器点击验收脚本(16 项 + 3 项视觉核验)
+docs/smoke.js       jsdom 冒烟(快速看各页能否渲染)
+tools/check_frontend_secrets.sh   红线自检:按 .js/.html/.css 与按"文本内容"两种口径扫前端
+docs/verify-run.log 最后一次验收的完整输出
+docs/screenshots/   截图
+```
+
+### 数据准备(需要 `PARSE_MASTER_KEY`,仅限本地脚本)
+
+```bash
+python3 tools/seed_report.py          # 写入示例报告(康师傅拌面 · 江中骨架,6 章)
+python3 tools/seed_insight_link.py    # 打通 1 条归纳 → 报告
+python3 tools/seed_long_report.py     # 可选:扩到 ~2 万字做压测
+```
+
+masterKey 只从环境变量读取,**不进入任何前端文件**。红线自检:`./tools/check_frontend_secrets.sh`。
+
+---
+
+## 5. 验收:第 5 节 16 项逐条结论
+
+验证方式:**真实 Chromium(headless Chrome 153)+ puppeteer-core,在 `http://127.0.0.1:8765` 上真实点击/滚动/断网**,脚本 `docs/verify.js`,完整输出 `docs/verify-run.log`。最后一次运行 **19/19 PASS**(16 项 + 3 项视觉核验)。屏幕上还逐张看过了 `docs/screenshots/` 里的截图。
+
+| # | 项 | 结论 | 怎么验的 / 结果 |
+|---|---|---|---|
+| 1 | 真实取数,条数与库一致 | ✅ 已验证 | 浏览器内以公开读方式直连 Parse 取 5 张表,与导航计数比对:session 10 / 归纳 4 / 成果 26 / 项目 10 / 原材料 4,全部一致 |
+| 2 | 单选筛选生效 | ✅ 已验证 | 在台账页点「优先级 S」:库中 S 级成果 7 条 → 页面 7 行、提示 7 条(未筛 26 行) |
+| 3 | 组合筛选(交集正确) | ✅ 已验证 | 再点「S1」:独立计算库中 S∩S1 = 0 → 页面 0 条并显示空态;已选条件回显「优先级 S / S1」。**注意:验收脚本只断言了空交集**;非空的 S∩S3 = 4 条是我在调试脚本里单独点过、人工核对与库一致的,**不在 16 项自动断言里** |
+| 4 | 筛选状态写入 URL,刷新保留 | ✅ 已验证 | `#/artifacts?priority=S&stage=S1`:新标签打开结果一致、选中 chip 保留;页面 reload 后仍一致 |
+| 5 | 搜索命中 | ✅ 已验证 | 搜「张伟」:页面 4 条 = 库中 4 条,4 行都命中;其中 1 行成员是陈静(标题写着"与张伟重复"),属按标题/成员/项目联合搜索的预期。清空后恢复 26 行 |
+| 6 | 四层区分可见 | ✅ 已验证 | 台账层列出现 process / sediment / record 三种层标;总览「分层」区块 4 个层标(raw/process/sediment/record)。总览「成果总数」卡把被归纳引用的成果改计入沉淀层,所以 raw=0;**原文要看原材料页**(KfSource,4 条,其中 3 条是早期探针占位) |
+| 7 | 五阶段人数分布正确 | ✅ 已验证 | 库中 10 条会话恰好每阶段 2 人,但页面**不是**平均分配出来的:页面按「会话阶段 + 成果阶段取最远者」统计,结果 2/2/2/2/2,与独立按 KfSession 统计一致,合计 10 |
+| 8 | 前端无凭据泄露(红线) | ✅ 已验证 | ① 浏览器内读取全部 24 个前端文件(index + 13 个 js + 5 个 css ≈ 210 KB)匹配 `masterKey / X-Parse-Master-Key / cc84… / PARSE_MASTER_KEY`:0 处;② 对交付目录的 `index.html / js / css` `grep -rniE`:无;③ 另用环境变量的实际值全文搜索整个交付目录(含 tools、docs、README):无命中 |
+| 9 | 成果详情页是报告形态 | ✅ 已验证 | 在台账真实点击带「报告」标记的行标题 → 报告页:目录 6 章、管线 3 步、章首导语 6、KPI 4、表格 9、结论块 6、证据锚点 18 |
+| 10 | 章节导航跳转 + 滚动高亮 | ✅ 已验证 | 初始高亮 ch1;点目录「4 产品适配」→ 滚动收敛到第 4 章顶部距视口 96px、高亮切到 ch4;合成滚动到第 6 章/第 2 章,高亮分别跟到 ch6 / ch2 |
+| 11 | 报告结论反查支撑成果 | ✅ 已验证 | 点 Ch2 结论旁「看原文」→ 抽屉列出 3 条支撑成果(张伟/李娜/王芳,均可在库中找到)、3 条原文,提示"证据链完整";每条成果有跳转 |
+| 12 | 台账→报告→台账往返不乱 | ✅ 已验证 | 证据抽屉点成果 → 台账 26 行、定位行高亮、抽屉已关、导航停在「成果」;清除定位后 URL 干净;再进同一报告在顶部;报告顶部「成果台账」→ 台账 26 行 |
+| 13 | 长报告可读(万字级) | ✅ 已验证 | 库中真实版本(`seed_long_report.py` 生成)= 6 章、可见正文 **21,440 字**、DOM 1,196 节点、页高 24,773px。平滑滚到 33% → 高亮 ch2 → 跳底 → 高亮 ch6 且到底 → 跳第 4 章 → ch4;无横向溢出 |
+| 14 | 1280 / 1024 / 768 三档不错乱 | ✅ 已验证 | 三档下报告页与台账页:横向溢出 0px、无越界元素;正文始终 15px;KPI 4 列/2 列/2 列;目录条保持单行。768 的截图人工看过 |
+| 15 | 空态 / 加载态 / 错误态有设计 | ✅ 已验证 | 空态:搜不存在的词 →「当前条件下没有成果」+ 逐步放宽提示;报告目录 →「没有匹配的报告」。加载态:人为把 Parse 延迟 1.5s + reload → 骨架 38 块。错误态:人为断开 Parse →「报告没有加载出来」+ 原因 + 重试(侧栏红点「读取失败」);恢复网络点重试 → 报告正常渲染 |
+| 16 | 无 console 报错(**有一处保留说明**) | ✅ 已验证 | 走完 10 个页面:pageerror 0;console.error 共 5 条,**全部来自第 15 项人为断网测试**的 `net::ERR_FAILED`,非网络类错误 0 条。正常使用(不断网)时控制台无报错 |
+
+补充(不计入 16 项,说明闭环的另外两条路):
+
+| 项 | 结论 | 说明 |
+|---|---|---|
+| 视觉核验 V1/V2 | ✅ 已验证 | 归纳卡「归纳自 1 份报告(2 条结论)」展开 → 出现报告链接 → 点击进入该报告 |
+| 视觉核验 V3 | ✅ 已验证 | 成果抽屉「作为证据,被这些结论引用 1:『太软没嚼劲』… 来自报告…」 |
+| `file://` 直接打开 | ✅ 已验证 | 用 Chrome 以 `file:///…/index.html#/report/…` 打开,报告正常渲染、0 报错 |
+
+---
+
+## 6. 没验证到的、没做到的、需要你知道的
+
+**明确未验证(不做"已实现"的模糊表述):**
+
+1. **真机 / 真实浏览器交互的滚动惯性**:验收跑在 headless Chrome 里。长距离平滑滚动在无头环境下是"阶梯式"的(测试因此用了逐帧推进 + 等待收敛)。真实桌面浏览器里的手感(macOS Safari 尤其,`backdrop-filter` 性能与观感都会不同)**未验证**。
+2. **Safari / Firefox 兼容**:只测了 Chromium 内核。`backdrop-filter` 已加 `-webkit-` 前缀,并有 `@supports not` 降级(不支持时面板回到高亮度实底);但**Safari 上的玻璃观感、Firefox 上的降级效果未实测**。
+3. **主色对比度是计算得来的,未用测量工具做全页扫描**:`--ink` 对最亮玻璃 ≈14:1、`--ink-2` ≈8:1、`--ink-3` ≈5.2:1,均按"玻璃背景与最亮光斑叠加"的最坏情况估算;没有逐元素跑 axe / 取色器扫描。极小字号(10.5–11px)的浅色元信息(如 `gauge-l`、`r-toc-s`)属于装饰性文字,**没有保证 4.5:1**。
+4. **768 以下(手机竖屏)** 只做了样式上的兜底(≤420px 断点),**未验收**。
+   另:768 宽时报告封面右侧"证据链完整度"卡片因为被压扁,行间分隔线会略微外伸(纯观感,不影响数据,未修)。
+5. **40 人同时在线、15 秒轮询下的压力**:未测。
+6. **上传 skill 写报告**:本版没有做上传链路(不在本版范围)。示例报告由 `tools/seed_report.py` 直接写库。
+7. **归纳 → 报告的自动归集**:本版用 `citedInsightIds` + 交集推断,**没有自动生成归纳**。
+
+**没做到的 / 已知限制:**
+
+- 报告正文是"数据 + 渲染器"模式,`kind` 之外的富文本(表格合并、图片、脚注)暂不支持;未知 `kind` 会原样陈列而不是报错。
+- 同一份报告里的结论 id 需在报告内唯一(例如 `c-texture`)。不同报告可以用相同 id,前端按"第一份含该结论的报告"打开证据链——目前只有一份报告,不受影响;多报告后建议上传方用 `<成果id>/<结论id>` 命名。
+- 示例报告里的"原文"是为演示结构而写的内容(`seed_report.py` 里逐条列出),**不是真实采集到的评论**;看板不会把它们伪装成学员成果(无 `artId` 的原文单独列为"未挂到成果上的原文")。
+- 库里有 4 条 `KfSource` 是早先的探针占位(`sourceType=probe`),不是真实原材料,页面如实显示。
+- 库里 26 条成果 `sources` 全为空——报告的证据链因此是"挂到成果 + 原文片段"的组合,而不是"成果自带原文"。这是数据现状,不是前端丢失。
+
+**环境事件(需告知)**:验收过程中 Parse 服务端(`kangshifu.fmode.cn/parse`)出现过约 10 分钟 `503 Service Unavailable`(连 `/parse/health` 也 503,且 CORS 预检失败),期间前端正确显示错误态;服务恢复后重跑,全部通过。
+
+---
+
+## 7. 歧义与我的处理(需求里不自洽的地方,先说明)
+
+1. **"台账列表里点一条成果 → 进报告详情页"**:不是所有成果都有报告。我的处理:有报告的成果,标题本身就是报告入口(带「报告」徽标);没有报告的成果,点行仍打开原有详情抽屉(保留 v1 的台账能力),抽屉里明确写"这条成果没有附带报告"。若要求"点任何一条都必须进报告页",需要先约定"无报告成果"的占位页形态。
+2. **"章节能对应 S1–S5"**:示例报告 6 章,其中 Ch2/Ch3 同属 S2、Ch5/Ch6 同属 S5。`chapters[].stage` 只允许 S1–S5,不设"第六阶段"。
+3. **"证据锚点能点开看支撑它的原始 VOC 条数/原文片段"**:结论的 `ownerCount` 和原文片段是**报告作者写进报告的数据**;看板能做的是"把它们挂到真实成果上、并检查成果在库里是否还存在",而不是替作者重新统计。抽屉里同时显示"提到人数 / 原文条数 / 支撑成果数 / 库里可追数",可追数与引用数不一致时会标黄提示。
+4. **归纳 → 报告**:归纳和报告分别属于沉淀层和过程层,库中没有现成的外键。本版用 `citedInsightIds`(可选)+ 交集推断;如果客户希望归纳直接引用报告里的结论 id,需要在上传 skill 约定字段(见第 8 节)。
+
+---
+
+## 8. 下一版要接什么
+
+1. **上传 skill 写 `report`**:按第 2 节结构产出;上传时校验:`chapters[].stage ∈ S1..S5`、`evidence[*].derivedFrom` 必须是本批成果 id、`kind` 在渲染器白名单内,否则落记录层并给出原因。
+2. **结论 → 归纳**:报告结论回灌时自动写成 `KfInsight`(带 `derivedFrom` 与 `citedInsightIds`),并接"证据够不够格回灌"的判定接口(v1/v2 已留位)。
+3. **分身查库入口**:`report` 是结构化数据,分身可以直接按 `kind/evidence` 检索;建议为结论加 `embedding` 字段做语义召回。
+4. **业务字段定稿**:品类/竞品/工艺/人群的具体字段仍等调研回传,在 `KfSource.payload` 上定稿,不动表结构;报告的 `matrix` 表可以按新字段配置列。
+5. **可访问性扫描**:接入 axe / 取色器,把第 6 节第 3 条的"估算"换成实测。
+6. 若需要多版报告:新增 `KfReport`,`artifact` 指针回指成果,`KfArtifact.report` 原样迁移。

+ 142 - 0
legacy/TASK-dashboard-v1.md

@@ -0,0 +1,142 @@
+# 康师傅产品创新看板 · 第一版框架开发任务书
+
+## 0. 这一版做到什么程度
+
+**只做框架,不做表结构字段设计。**
+
+品类库、竞品库、工艺库、人群库的**具体字段等调研成员回传后再定稿**。本版要求的是:骨架能跑、数据能进出、页面能看、路子打通。字段先用最小占位,不要在这一版发明业务字段。
+
+---
+
+## 1. 背景(做完才看得懂要什么)
+
+康师傅研发中心 2026-11-13/14 两天线下 AI 赋能实战营,30–40 名产品经理。每个学员与同一个数字分身对话,走完 5 个阶段产出成果:
+
+| 阶段 | 内容 | 产出 |
+|---|---|---|
+| S1 | 品类市场分析 | 品类全景图谱、竞品图谱 |
+| S2 | 用户需求与痛点洞察 | 痛点清单、VOC 聚类 |
+| S3 | 机会点识别 | 候选机会卡 |
+| S4 | 机会验证评估 | 优先级机会清单、验证评分 |
+| S5 | 场景与卖点设计 | 产品概念提案 |
+
+**看板的任务不是"展示数据",而是四件事**:收集 → 展示 → 归纳总结 → 筛选,并且归纳出的结论要能**回灌**知识库,让下一个学员的分身用上。
+
+这是闭环,不是一次性报表:
+
+```
+数据底座 → 学员与分身交互 → 成果入库 → 看板(收集/展示/归纳/筛选)
+    ↑                                            ↓
+    └──── 够格的见解回灌知识库 ←── 证据筛选 ←──┘
+```
+
+---
+
+## 2. 硬约束
+
+### 2.1 云资源(唯一可用,不得更换)
+
+```
+Parse 端点   https://kangshifu.fmode.cn/parse
+appId        FKieRbIoGt_main
+微服务       https://kangshifu.fmode.cn/data
+云函数       https://kangshifu.fmode.cn/api/functions
+数据库       FKieRbIoGtmaster @ db.fmode.cn
+```
+
+masterKey 由部署方提供,**不得写进代码、不得提交、不得出现在前端**。前端只带 appId 读。
+
+已验证事实(不要重复踩):
+- `GET /parse/health` → `{"status":"ok"}`
+- 无 masterKey 可读,写必须 masterKey
+- **`?count=1&limit=0` 返回 count=0 是服务端 bug,不要用 count,直接取 results 长度**
+
+### 2.2 已存在的 class(不要重建,不要删)
+
+`_User`、`_Role`、`KfProbe`、以及已建的 `KfProject`/`KfArtifact`/`KfInsight`/`KfSession`。
+
+这些表**当前字段是占位性质**,允许你调整增删,但**不要删除 class**。
+
+### 2.3 技术边界
+
+- 纯静态前端(HTML/CSS/JS),不引入构建工具链,不用 React/Vue 框架,直接浏览器可运行
+- 数据通过 Parse REST 直连读取
+- 页面需能在 `file://` 与 HTTP 两种方式下都正常取数
+
+---
+
+## 3. 本版要交付的东西
+
+### 3.1 四层库结构(逻辑分层,物理同一个库)
+
+| 层 | 含义 | 谁来写 | 性质 |
+|---|---|---|---|
+| 原材料层 | 品类/竞品/工艺/人群/机会资料 | 调研 Agent | 事实,带出处 |
+| 过程层 | 学员+分身产生的成果 | 学员,走上传 skill | 观点,质量参差 |
+| 沉淀层 | 归纳出来、够格回灌的见解 | 归纳+证据筛选 | 已校准结论 |
+| 记录层 | 不合格/推测/重复的 | 自动落入 | 只留档 |
+
+用**字段区分层次**,不要拆成四个独立数据库。分身查询要一个入口查全,同时能分辨某条来自哪一层。
+
+### 3.2 页面要求
+
+**顶部指标区**:进行中项目数、累计成果数、归纳结论数、参与成员数
+
+**项目池**:按人/项目/阶段列出,显示项目名、成员、当前阶段、优先级、评分。可排序。
+
+**归纳结论区**:显示结论标题、正文、证据等级、多少人独立提到。按证据强度排序。
+
+**筛选与搜索**:按优先级筛、按阶段筛、关键词搜项目名/成员/赛道。
+
+**实时性**:轮询刷新,线下活动时要能边操作边看到别人进度。
+
+### 3.3 视觉方向
+
+深色底、玻璃拟态面板(低对比白边框 + 单强调色只做指路 + 分层光照)。不要居中打光、不要用大阴影做凸起凹陷、透明度不能糊掉数据。
+
+**这是方向,具体样式你决定,不要求一模一样。**
+
+---
+
+## 4. 明确不在本版范围
+
+- ❌ 品类/竞品/工艺/人群表的具体业务字段设计
+- ❌ 上传 skill 的实现(下一版)
+- ❌ 分身查库的接口(下一版)
+- ❌ 权限体系、登录
+- ❌ 回灌知识库的自动流程(本版只要留出字段位置)
+
+---
+
+## 5. 验收标准(部署方会逐条实测,不接受"已完成"口头汇报)
+
+| # | 验收项 | 判定方法 |
+|---|---|---|
+| 1 | 页面能打开且无 JS 报错 | 浏览器控制台无 error |
+| 2 | 真实取数 | 指标数字与 Parse 实际记录数一致 |
+| 3 | 筛选真生效 | 点 S 级,行数变化且与库里 S 级数量相符;点 A/B 同理 |
+| 4 | 搜索真生效 | 搜某成员名,只出该成员项目;清空后恢复 |
+| 5 | 空态正确 | 搜不存在的词,显示空态提示而非报错 |
+| 6 | 数据字段完整 | 每行项目六项信息不缺(名/人/阶段/优先级/评分) |
+| 7 | 四层能区分 | 成果数据能标明来自哪一层 |
+| 8 | 无凭据泄露 | 前端源码中搜索不到 masterKey |
+| 9 | 刷新可用 | 15 秒后数据自动更新(可手动改库验证) |
+
+**第 8 条是红线,出现即打回。**
+
+---
+
+## 6. 交付形式
+
+- 源码目录
+- 一份 README:怎么改数据源、四层含义、下一版要接什么
+- 你跑过的验证记录(哪些测了、结果如何、哪些没测)
+- **没做到的部分必须点名说明**,不要用"已实现"覆盖
+
+---
+
+## 7. 给 Claude Code 的补充说明
+
+- 这是一场真实线下活动要用的系统,不是演示 demo。数据会真的被 40 个人填进去。
+- 归纳结论区不要做成"统计报表"("共 40 张卡,A 类 12 张"),要做成**能说清判断的**("7 人提到酱包口味单一,但只有 2 人给出原文证据")。
+- 分身后续要直接查这个库回答这类问题,所以数据结构要**为"被 AI 查询"设计**,不只是"给人看"。

+ 163 - 0
legacy/TASK-dashboard-v2.md

@@ -0,0 +1,163 @@
+# 康师傅产品创新看板 · 第一版框架开发任务书 (v2)
+
+> 本版承接 v1。开发标准从"能跑"提到"可交付给客户看的正式系统"。
+
+## 0. 这一版做到什么程度
+
+**只做框架,不做业务字段设计。** 品类库/竞品库/工艺库/人群库的具体字段等调研成员回传后再定稿。本版用最小占位字段,不要发明业务字段。
+
+但**前端的完成度要求是正式交付级**,不是演示 demo。
+
+---
+
+## 1. 背景(必须先读懂再动手)
+
+康师傅研发中心 2026-11-13/14 做一场 1.5 天线下 AI 赋能实战营,30-40 人。学员用同一个数字分身(每人独立会话),走五个阶段:品类市场分析 → 用户需求与痛点 → 机会点识别 → 机会验证评估 → 场景与卖点设计。
+
+这个看板的作用:
+- **线下现场**:讲师一边带学员做,一边在后台看谁到哪一步、谁卡住了
+- **成果归集**:学员和分身交互产生的机会卡、痛点、验证评分等,通过上传 skill 进库,看板自动呈现
+- **团队认知**:40 个人各自交上来的东西,看板要能归纳出"哪些缺口被反复提到""哪些有真凭实据"
+- **组织资产**:够格的见解回灌知识库,下一个人用分身时已经知道前面的人发现了什么
+
+**关键认知:这不是"数据展示页",是"成果汇聚与沉淀系统"。**
+
+---
+
+## 2. 技术约束(硬性)
+
+### 2.1 云资源(固定,不可更换)
+```
+Parse Base : https://kangshifu.fmode.cn/parse
+App ID     : FKieRbIoGt_main
+微服务      : https://kangshifu.fmode.cn/data
+云函数      : https://kangshifu.fmode.cn/api/functions
+```
+- `masterKey` 已放在环境变量 `PARSE_MASTER_KEY`
+- **masterKey 只能出现在你本地测试脚本中,绝对禁止写进前端任何文件、注释、配置文件或 README**
+
+### 2.2 前端
+- **纯静态**:原生 HTML/CSS/JS。不要 React/Vue/Next 等框架,不要构建步骤,浏览器直接打开能跑
+- 取数走 Parse REST(`X-Parse-Application-Id` 头即可读,读权限已开放)
+- 单页应用,用 hash 路由(`#/stage/3`、`#/project/xxx`)保证刷新和直接访问链接都能落到正确视图
+
+### 2.3 四层数据分层
+同一个数据库,**靠字段区分**,不要拆成四个库:
+
+| 层 | 内容 | 谁写入 | 性质 |
+|---|---|---|---|
+| raw (原材料层) | 品类/竞品/工艺/人群/机会,调研整理 | 调研 Agent | 事实,必须带出处 |
+| process (过程层) | 学员产生的机会卡、痛点、验证、报告 | 学员+分身,经上传 skill | 观点,质量参差 |
+| sediment (沉淀层) | 归纳后够格回灌的见解 | 归纳+证据筛选后 | 已校准结论 |
+| record (记录层) | 不合格、纯推测、重复的 | 自动落入 | 只留档不进知识 |
+
+**每条记录必须有 `layer` 字段**,前端所有地方都要能看出这条属于哪层。分身查库时能区分"这是事实"还是"这是某人说的"。
+
+---
+
+## 3. 功能要求
+
+### 3.1 收集(自动归集)
+- 学员成果经上传 skill 进库后,看板刷新即出现,无需人工整理
+- 每条成果必须带**出身**:谁产生的(`ownerName`/`sessionId`)、什么时候(`createdAt`)、哪个阶段(`stage`)、**有没有原文证据**(`evidence` 字段,可为空但必须可见为空)
+- 没有出身的记录不许进沉淀层
+
+### 3.2 展示
+- 总览视图:进行中项目数、成果总数、归纳条数、参与成员数
+- 按人、按项目、按阶段三种切法都能看
+- 每个阶段有多少人卡住/完成,一眼能看出
+- 线下现场用:**能快速看出"谁还没交东西"**
+
+### 3.3 归纳总结(重点,不是统计报表)
+**不要做成"一共40张卡,A类12张,B类28张"这种统计。**要能给出判断:
+
+> 示例:"拌面酱包口味单一"被 12 人中的 7 人独立提到,但只有 2 人给出真实评论原文证据,其余 5 条为模型推测。
+
+每条归纳必须能展示三件事:
+1. **多少人独立提到**(交叉验证强度)
+2. **几条有原文证据**(证据强度)
+3. **点开能看到是哪几条具体支撑**(可追溯)
+
+归纳区要能按证据强度、交叉人数排序筛选。
+
+### 3.4 筛选
+- 按优先级(S/A/B)、按赛道/品类、按阶段、按证据强度、按成员
+- 筛选结果实时反映数量
+- **筛选条件要能叠加组合**,不是单选后其他失效
+- 刷新页面后筛选状态保留(写进 URL hash)
+
+### 3.5 回灌闭环(这一版做框架,不做自动判定逻辑)
+- 沉淀层条目要能标记"来源于哪几条过程层记录"
+- 前端能看到"这条结论是由 3、7、9 号卡归纳出来的"
+- 留一个明确的扩展位:后续接"证据够不够格回灌"的判定接口
+
+---
+
+## 4. 视觉要求(正式交付级)
+
+### 4.1 风格:iOS 液态玻璃 / 克制优雅
+
+参照的观感是:**浅色底、半透明磨砂层、细微高光、一条极轻的发丝边**。不是暗色仪表盘,不是霓虹科技风,不是卡片堆砌的 Bootstrap 味儿。
+
+具体要求:
+- **低对比发丝边**:`1px` 半透明白边(如 `rgba(255,255,255,.7)`)+ 极浅阴影,做"玻璃片浮在底上"的层次感
+- **分层光照**:光源统一从左上到右下,不要每个卡片自己打光,不要居中打光
+- **不要用大阴影做凸起凹陷**(那是新拟物,不是液态玻璃)
+- **透明度要克制**:磨砂可以,但**文字和数据永远清晰可读**,不许为了通透牺牲数据
+- **单一强调色**只用来指路(如当前选中项、关键数字),不做装饰性配色
+- 留白充分,信息密度严格,不塞满
+
+### 4.2 数据优先
+- 数字要能一眼读到,字重和字号分层明确
+- 表格/列表对齐严格,数字右对齐
+- 不用图标堆装饰;有图标的地方图标要小、要淡
+- 状态(进行中/已完成/待补证据)用极轻的色标区分,不要大红大绿
+
+### 4.3 必须做到的细节
+- 响应式:1280 桌面可读,1024 不乱,手机能看(线下讲师可能用平板)
+- 空态有设计:没数据时不是一句"暂无数据",要说明"这里会出现什么"
+- 加载态不跳动(骨架屏或占位,不要内容突然顶开)
+- 中文字体栈要正确,不要出现回退到衬线体
+
+---
+
+## 5. 验收标准(我会逐条实测,不通过就打回)
+
+| # | 验收项 | 怎么验 |
+|---|---|---|
+| 1 | 页面能打开并真实取到 Parse 数据 | 浏览器打开,DevTools Network 有真实请求,数据条数与库一致 |
+| 2 | 筛选单选生效 | 点 S 级,卡片数变;点 B 级,数量对得上 |
+| 3 | 筛选组合生效 | 选"S 级 + 拌面",结果 = 两个条件的交集,数量正确 |
+| 4 | 筛选状态写入 URL,刷新保留 | 复制 URL 新开标签,筛选条件还在 |
+| 5 | 搜索按成员名/项目名能命中 | 输入名字,结果准确 |
+| 6 | 归纳区展示交叉人数+证据数,可追溯 | 点开一条归纳,能看到支撑它的具体记录 |
+| 7 | 每条成果能看四层归属(layer) | 列表里能区分 raw/process/sediment/record |
+| 8 | **前端无任何凭据泄露(红线)** | grep 整个目录,不得出现 masterKey、`cc84` 开头串 |
+| 9 | 空态/加载态有处理 | 清空数据看空态;节流网络看骨架 |
+| 10 | 1024 / 768 宽度不错乱 | 浏览器缩放实测 |
+| 11 | 无 console 报错 | 控制台干净 |
+| 12 | 代码可读、结构清晰 | 有注释,函数职责单一,README 说明怎么改数据源 |
+
+**验收第 8 条是红线,测到一处立刻打回。**
+
+---
+
+## 6. 交付要求
+
+1. 代码放在 `/opt/data/work/kangshifu/impl/`,`index.html` 为入口
+2. `README.md` 说明:怎么改数据源、四层含义、这一版没做的部分、下一版要接什么
+3. **交付时必须逐条说明第 5 节的 12 项验收:你实际验证过哪些、怎么验的、结果如何;哪些没验证到。**
+   **不要用"已实现"覆盖没验证的部分。** 没验到的明确写"未验证"。
+4. 如果发现需求有歧义或不自洽,先说明再动手,不要自己猜着做。
+
+---
+
+## 7. 现成资源
+
+库中已有 4 个 class(可以先探查):
+- `KfProject` — 项目
+- `KfArtifact` — 成果
+- `KfInsight` — 归纳
+- `KfSession` — 会话
+
+里面约有 10 条演示数据。**这一版可以用它们跑通链路**,但字段结构按你的判断调整,不要被现有字段绑死。演示数据是否清理由我最终决定,你先留着用于测试。

+ 190 - 0
legacy/TASK-dashboard-v3.md

@@ -0,0 +1,190 @@
+# 康师傅产品创新看板 · 第二版任务书(承接 v1 框架,重做内容层与视觉)
+
+> 第一版已跑通取数与筛选骨架。这一版的核心问题:**做成了后台台账,没有分析内容**。
+> 客户原话:"太普通了,没有达到标准系统性看板的要求"。
+
+## 0. 这一版必须解决什么
+
+**问题诊断**:v1 做出来的 8 个视图(总览/阶段/成员/归纳/成果/项目/原材料/记录)全都是「管理视角」——谁交了、多少条、怎么筛。但客户要的是两样东西一起:
+
+1. **分析报告层**:学员/分身产出的分析结果,要能像一份真正的行业报告那样被阅读(有章节、有 KPI、有矩阵、有结论)
+2. **管理台账层**:现场带 40 人时,讲师要一眼看出谁卡住、谁没交
+
+v1 只有第 2 层,缺第 1 层。这是"太普通"的根本原因。
+
+**这一版要做的**:把分析报告层建起来,并跟台账层打通。
+
+---
+
+## 1. 参考物(必须真的去读,不要凭空想象)
+
+以下三份是客户公司自己产出的真实报告,**学员在现场产出的成果会长成这个样子**。看板要能承接这种内容:不是一行文字标题,而是能翻开读的完整分析。
+
+1. `https://voc.market/report/20260420-compex-voc-analysis.html`
+2. `https://voc.market/report/20260421-jiangzhong-houguyin-voc-v2.html`(最典型,1.6 万字 / 6 章)
+3. `https://voc.market/report/chajuejue-voc-insight-report.html`
+
+### 从这三份报告提炼出的结构特征(已实测确认)
+
+**江中猴菇饮那份(最有参考价值)的骨架**:
+```
+封面(项目名/方法论/数据源/数据管线/报告结构/生成日期)
+→ 报告结构总览(6章,每章一张卡片带图标+一句话)
+→ Ch1 全景漏斗(TAM → SAM → SOM 三级漏斗,逐级收窄)
+→ Ch2 嵌套洞察(分层:病程阶段 → 细分症状 → 产品选择 × 未满足需求)
+→ Ch3 痛声地图(按层铺开痛点 → 跨层汇总 5 大共性需求 × 四象限)
+→ Ch4 产品适配(自身能力边界 / 成功可迁移因子 vs 失败红线 / 精准插入点 / 场景适配)
+→ Ch5 定位推导(品类进化树 → 心智阶梯 → 差异化锚点 → 一句话定位)
+→ Ch6 行动蓝图(P0 立即 / P1 三个月验证 / 数据缺口与下一轮)
+```
+
+**关键设计特征(这些是要学的)**:
+- **每章开头有"这一章在回答什么问题"的一句话**(如"帮总经理看清几亿人里我们能切多少")
+- **公式化标题**:`5 大症状 × 对应产品 × 未满足需求`——用 `×` 表达交叉分析维度,一眼看懂框架
+- **分节编号**:`1.1`、`1.2`、`1.3` 二级结构清晰
+- **数据管线可见**:`2727 条采集 → 去噪去重 → 730 条精准 VOC(过滤率 73%)`——证据来源透明
+- **结论带态度**:不是"用户有需求",而是"苦涩难咽 × 代际断层"、"吃了个寂寞"——敢下判断
+- **有失败案例**:`米稀 7.5亿成功 vs 饼干失败的教训`——不只说好话
+
+**COMPEX 那份的骨架**:封面 → 目录 → 执行摘要(4 个 KPI 大数字 + 核心发现)→ 品类全景(市场规模/价格带/品牌集中度,表格)→ 品牌六维画像 → 竞品对比矩阵 → VOC 情感分析 → 差评痛点深度洞察(5大痛点·严重度排名)→ 用户画像(3大目标人群·真实评论)→ 行动计划(短期/中期/长期)→ 总结与突破口
+
+---
+
+## 2. 这一版要做的东西
+
+### 2.1 成果详情页 = 一份可读的报告(**核心新增**)
+
+现在点一条成果,只看到字段。要改成:**点开是一份完整的分析报告页**。
+
+要求:
+- **侧边章节导航**:报告有章节结构,左侧/右侧有目录,点击跳转,滚动时高亮当前章
+- **封面区**:项目名、方法论、数据源、数据管线(采集多少条→筛选后多少条)、生成日期
+- **章首导语**:每章开头一句话说清"这一章在回答什么问题"
+- **KPI 卡片组**:大数字 + 单位 + 一行解读
+- **交叉分析表**:如"症状 × 产品 × 未满足需求"这类多维表格,不是简单两列
+- **结论块带态度**:允许短句判断,不要全是"XX有需求"这种废话
+- **证据锚点**:每个结论旁边能点开看支撑它的原始 VOC 条数/原文片段
+- **报告可以长**:允许 1 万字级别的滚动长页,不要为了塞进一屏砍内容
+
+### 2.2 台账层与分析层打通(**关键**)
+
+- 台账列表里点一条成果 → 进报告详情页
+- 报告里每个结论 → 能反查是哪几条过程层成果支撑的(跳回台账并筛选出那几条)
+- 归纳页的每条结论 → 能点进它归纳自哪几份报告
+
+**这是"闭环"的实质:不是页面能跳,而是能从结论一路追到原始证据。**
+
+### 2.3 保留 v1 已通过的部分(不要推翻重做)
+
+这些 v1 已经实测通过,保留并保持可用:
+- 真实取数(Parse REST)
+- 6 维筛选(优先级/阶段/品类/证据/层/成员)+ 组合叠加
+- 筛选状态写入 URL hash,刷新保留
+- hash 路由,直接访问链接能落位
+- 四层区分(raw/process/sediment/record)
+- 五阶段人数分布
+
+---
+
+## 3. 视觉(客户明确否决了 v1,且反复强调这一条)
+
+**客户原话**:「视觉确实不认可」「太普通了」「不是要小 demo,是要求严格的、前端逻辑紧密闭环的大任务」
+**最新要求(必须遵守)**:「视觉的升级还是要按照我之前说过的那种,就是 **iOS 拟态玻璃**的那种风格,也就是那种思路去升级,可以不单单只用这一种方法去升级」
+
+### 3.1 视觉主线:iOS 拟态玻璃(Liquid Glass / Glassmorphism)
+
+这是**硬性要求**,不是可选项。v1 被打回的直接原因就是"实心白卡片 + 靠阴影分开",完全没有玻璃质感。
+
+**必须做到的三件事**:
+
+1. **半透明磨砂层**:面板/卡片用半透明背景 + `backdrop-filter: blur()`,让底层的颜色和光透上来。不是实心色块。
+   - 参考值:`background: rgba(255,255,255,.08)`(暗底)或 `rgba(255,255,255,.55)`(浅底),`backdrop-filter: blur(20px) saturate(180%)`
+2. **发丝边(hairline)**:1px 极淡边框勾勒玻璃边缘,是玻璃感的命门,v1 完全缺这个
+   - 参考值:`border: 1px solid rgba(255,255,255,.18)`(暗底)/ `rgba(255,255,255,.7)`(浅底)
+   - 进阶:用 `border-image` 或伪元素做**上亮下暗**的渐变边框,模拟玻璃边缘折光
+3. **分层光照**:光源统一从左上到右下。**注意:不要每个卡片自己打光,不要居中打光**
+   - 卡片顶部一道极淡的内高光(`inset 0 1px 0 rgba(255,255,255,.25)`)
+   - 阴影方向一致、极浅(`0 8px 32px rgba(0,0,0,.08)`),**不要用大阴影做凸起凹陷**(那是新拟物 neumorphism,不是液态玻璃)
+
+**允许扩展**(客户说"可以不单单只用这一种方法"):
+- 可以在玻璃基础上叠加细微渐变、噪点纹理、柔光
+- 可以让背景层有可感知的色彩流动(但要极缓、极淡,不能抢数据)
+- 卡片可以有不同透明度层次(底层卡片更透,浮层卡片更实)形成纵深
+- 关键数字/选中项可以用微光或柔光强调
+
+### 3.2 铁律:玻璃不能糊掉数据
+
+**这一条优先级高于任何美感要求。** 客户用过的话:透明度不到"糊掉数据的程度"。
+
+- 所有正文、数字、表格必须**在玻璃上清晰可读**,必要时加局部实底衬底
+- 模糊只用在装饰层,**绝不用在文字后面的必需背景上导致看不清**
+- 数据和文字对比度必须够(正文对背景至少 4.5:1)
+
+### 3.3 配色
+
+客户明确说「配色无所谓」。深色浅色都可以。要求是:清晰、科技感、简约、高级。
+可以沿背景色体系自行决定,只要满足 3.1 和 3.2。
+
+### 3.4 其他细节
+- 数字排版要讲究:大小对比强、单位小、数字右对齐、等宽数字(`font-variant-numeric: tabular-nums`)
+- 表格不用竖线分隔,用行间距和极淡底色交替
+- 图标克制:小、淡、不抢戏
+- 空态/加载态/错误态都要有设计
+- 响应式:1280 / 1024 / 768 三档都不乱
+
+---
+
+## 4. 数据结构(这一版仍不锁定业务字段)
+
+品类/竞品/工艺/人群表的**具体字段等调研成员回传后再定**。本版要求:
+
+- 现有 4 个 class 可用:`KfProject` / `KfArtifact` / `KfInsight` / `KfSession`
+- **报告正文怎么存**:建议 `KfArtifact` 增加一个 `report` 字段(可以是结构化 JSON 或 HTML 片段),或者单独建 `KfReport` class。**由你判断哪种更合适,在 README 说明理由**
+- 报告章节结构要能对应五个阶段(S1-S5),不要自己发明阶段
+
+---
+
+## 5. 验收标准(我会逐条实测)
+
+### v1 必须保持可用(回归)
+| # | 项 |
+|---|---|
+| 1 | 真实取数,条数与库一致 |
+| 2 | 单选筛选生效 |
+| 3 | 组合筛选生效(交集正确) |
+| 4 | 筛选状态写入 URL,刷新保留 |
+| 5 | 搜索命中 |
+| 6 | 四层区分可见 |
+| 7 | 五阶段人数分布正确 |
+| 8 | 前端无凭据泄露(**红线**,grep 确认无 masterKey) |
+
+### 这一版新增
+| # | 项 | 怎么验 |
+|---|---|---|
+| 9 | 成果详情页是报告形态(有章节导航、封面、章首导语、KPI、表格) | 点一条成果,看是否具备 5 个要素 |
+| 10 | 章节导航能跳转且滚动高亮当前章 | 点目录章节,看是否滚动定位;滚动时看高亮是否跟随 |
+| 11 | 报告里结论能反查到支撑成果 | 点结论旁的证据锚点,能列出具体成果 |
+| 12 | 台账→报告→台账 能往返 | 从列表点进报告,再从报告跳回列表,状态不乱 |
+| 13 | 长报告可读(1万字级不崩) | 渲染长内容测试 |
+| 14 | 三层响应式不错乱 | 1280/1024/768 实测 |
+| 15 | 空态/加载态/错误态有设计 | 清数据、断网测 |
+| 16 | 无 console 报错 | 控制台干净 |
+
+---
+
+## 6. 交付要求
+
+1. 代码在 `/opt/data/work/kangshifu/impl/`,`index.html` 入口
+2. `README.md` 说明:报告数据结构为什么这么设计、四层含义、这一版没做什么、下一版要接什么
+3. **逐条说明第 5 节 16 项验收:哪些实际验证过、怎么验的、结果如何;哪些没验证到。**
+   **没验到的明确写"未验证",不要用"已实现"含糊带过。**
+4. 需求有歧义先说明,不要猜着做
+5. **这一步的重点是内容层,不是加更多页面。** 宁可有 3 个视图但内容有厚度,不要 10 个视图但都是空壳。
+
+---
+
+## 7. 环境
+
+- Parse: `https://kangshifu.fmode.cn/parse`,App ID `FKieRbIoGt_main`
+- masterKey 在环境变量 `PARSE_MASTER_KEY`(只能用于测试脚本,**禁止进前端**)
+- 库中现有演示数据约 26 条成果 / 10 项目 / 4 归纳,**字段有缺失也不要紧,做好容错**

+ 201 - 0
legacy/TASK-dashboard-v4.md

@@ -0,0 +1,201 @@
+# 康师傅产品创新看板 · 第四版任务书(多用户工作台 + 机会卡片池)
+
+> v3 解决了"报告能翻开读",但**系统结构错了**。
+> 客户原话:「不单单说是给个人用的,因为很多人用的」「肯定有一个卡片那种展示类型」
+> 「首先是一些关键的东西,还有一些决策,哪些 Agent 用什么上传的」「然后有一些概览」「产出的一些东西,他就是每个板块」
+
+## 0. v3 做对了什么(保留,不要推翻)
+
+- 玻璃质感基础(半透明 + backdrop-filter + 发丝边 + 左上光源)
+- 报告详情页(章节导航/章首导语/KPI/交叉表/结论/证据抽屉)
+- 证据链闭环(结论 → 支撑成果 → 台账;归纳 → 原报告)
+- 6 维筛选 + 组合叠加 + URL 状态 + hash 路由
+- 前端无凭据(红线)
+
+## 1. v3 错在哪(必须重构)
+
+**错误定位**:v3 做成了一个「个人交作业 + 报告阅读」的工具。
+**实际要的**:一个 **30-40 人同时使用的组织资产系统**。
+
+三处结构性错误:
+
+### 错误 1:没有「机会卡片池」这个核心对象
+PPT 第 11 页明写:
+> 机会卡片池 —— 全员机会卡汇总入库、带评分与优先级,构成研发中心的机会储备
+
+这是**组织资产**,是整个系统的落点。v3 完全没有这个对象,只有散落的"成果"和"报告"。
+
+### 错误 2:卡片只是列表里的一行文字
+客户要的卡片是有内容的,至少包含:
+- **关键信息**(这是什么机会,一句话说清)
+- **决策**(值不值得做、什么优先级、谁拍的板)
+- **哪个 Agent、用什么方式上传的**(来源可追溯)
+- **概览**(评分、证据强度、关联人)
+- **分板块产出**(五个阶段各自的产出物分开列)
+
+### 错误 3:没有「多人」的视角
+30-40 人共用一个数字分身,意味着:
+- 每个人要看得到**自己的贡献**和**全员的池子**
+- 每条数据要能追到**具体是谁、用哪个 Agent、什么时间**上传的
+- 现场讲师要能一眼扫出**谁卡在第几阶段**
+
+---
+
+## 2. 这一版必须建成的三样东西
+
+### 2.1 机会卡片池(核心新增,最高优先级)
+
+**这是组织资产,不是列表。** 要建 `KfOpportunity` 这个对象(或复用 KfArtifact 加类型,你判断)。
+
+**卡片正面(必须一屏内读完)**:
+```
+┌─────────────────────────────────────────┐
+│ [S 级]  [拌面]  [S3 机会识别]            │ ← 优先级/品类/阶段
+│                                         │
+│ 夜宵场景低卡高蛋白拌面                    │ ← 机会名(主标题)
+│ 5大场景 × 现有产品 × 未满足需求            │ ← 一句话/副标题
+│                                         │
+│ 综合评分  87   证据强度  高  引用  12 条   │ ← 关键数字
+│                                         │
+│ 🔹 决策:建议进入概念验证(张伟 · 10/29)   │ ← 决策记录
+│ 🔹 来源:主分身 · 会话上传 · 10/29 14:32  │ ← Agent + 方式 + 时间
+└─────────────────────────────────────────┘
+```
+
+**必须能做的事**:
+- 卡片可点开 → 完整报告(复用 v3 的报告页)
+- 卡片可**评分**(多人打分,显示平均分)
+- 卡片可**改状态**(候选 → 已立项 / 已否决)
+- 卡片可按优先级排序、按评分排序
+
+**「谁来传的」要具体到**:
+- 上传人(姓名 + 头像/首字母)
+- Agent 名(如「康师傅产品创新助手」/「主分身」)
+- 上传方式(会话上传 / skill 上传 / 手工录入)
+- 上传时间
+
+### 2.2 工作台首页(替换现在的"总览仪表盘")
+
+现在首页是数据罗列。要改成**每人进来知道自己该干什么**:
+
+**给我的**(当前用户视角)
+- 我交了几份成果、几条进了池子
+- 我还有哪个阶段没交
+
+**全员的池子**(组织视角)
+- 池子里多少条机会卡,这周新增几条
+- 最新进池的 3 张卡(卡片形态,不是列表)
+- 评分最高 / 最值得看的
+
+**现场看板**(讲师视角)
+- 五阶段 × 人数的进度矩阵
+- **谁卡住了**(超过 N 小时没提交的人名直接列出来)
+
+允许做成 tab 切换或分区,但**三种视角必须都在**。
+
+### 2.3 分板块产出(五个阶段各自的沉淀)
+
+每个阶段产出的东西要分开看,不是混在一个列表:
+- S1 品类市场分析 → 产什么
+- S2 用户需求痛点 → 产什么
+- S3 机会识别 → 产机会卡
+- S4 验证评估 → 产评分/验证结论
+- S5 场景卖点 → 产方案
+
+**每个板块要显示**:产出数量、最新几条、谁产的。
+
+---
+
+## 3. 对照 PPT 五大痛点,系统要能印证解决
+
+这一版不是做功能清单,是让客户看到「你们说的痛点,系统接住了」:
+
+| PPT 痛点 | 系统要有对应 |
+|---|---|
+| ① 信息收集繁琐分散 | 原材料库/调研入池,资讯归集到一处 |
+| ② 机会识别凭经验 | 品类全景视图 + 机会卡片池(可排序可评分) |
+| ③ 卖点提炼缺抓手 | 痛点 ↔ 卖点对应(报告里的交叉表) |
+| ④ 概念验证无量化 | 评分模型,卡片上有分 |
+| ⑤ 琐事挤占思考 | 上传自动化(Agent 上传),人只做判断 |
+
+**建议在首页放一行「痛点 → 系统能力」的对照**,让客户一眼看出这个系统是照着他们的痛处做的。
+
+---
+
+## 4. 视觉(继续加强,v3 的玻璃还不够)
+
+客户原话:「知觉是升级了一点,但是还不够」
+
+**v3 已经有的**:半透明面板 + blur/saturate + 1px 发丝边 + 左上光源 + 光场 + 颗粒
+
+**这一版要加强的**:
+1. **液态感**:玻璃边缘要有高光折射(上亮下暗的渐变边框),不是均匀细线
+2. **纵深层次**:至少三层透明度(背景光场 → 中层卡片 → 浮层抽屉),越靠前越实
+3. **卡片悬浮感**:hover 时轻微上浮 + 高光移动,是"玻璃片"不是"纸片"
+4. **数字处理**:关键数字可以用柔光/微渐变,但不能牺牲可读性
+5. **光场更细腻**:v3 的光斑偏明显,要更柔更慢,像环境光不是装饰图案
+
+**铁律不变**:任何情况下数据必须清晰可读,对比度 ≥ 4.5:1。
+
+---
+
+## 5. 验收标准
+
+### 回归(v3 已通过的,不许退化)
+| # | 项 |
+|---|---|
+| 1 | 真实取数,与库一致 |
+| 2 | 单选筛选 |
+| 3 | 组合筛选交集正确 |
+| 4 | URL 状态保留 |
+| 5 | hash 路由落位 |
+| 6 | 报告章节导航 + 滚动高亮 |
+| 7 | 证据链反查能列出具体成果 |
+| 8 | 前端无凭据(**红线**) |
+
+### 新增(这一版的重点)
+| # | 项 | 怎么验 |
+|---|---|---|
+| 9 | 机会卡片池存在,卡片是**卡片形态**不是列表行 | 看截图,一屏内能看到 5 个要素 |
+| 10 | 卡片显示「谁 + 哪个 Agent + 什么方式 + 什么时候」上传 | 检查字段是否落到 UI |
+| 11 | 卡片可点评分,多人分能显示平均 | 点两次评分,看平均是否变 |
+| 12 | 卡片可改状态(候选/立项/否决) | 改一次,刷新后保持 |
+| 13 | 卡片可排序(优先级/评分) | 切排序,顺序真实变化 |
+| 14 | 首页有三种视角:我的 / 池子 / 现场看板 | 逐个检查是否存在 |
+| 15 | 现场看板能列出"谁卡住了" | 造一个超时未交的数据,看是否出现 |
+| 16 | 五阶段分板块产出可见 | 每板块有数量+最新几条 |
+| 17 | 痛点→系统能力对照可见 | 首页有这一块 |
+| 18 | 玻璃有三层透明度纵深 | 检查 computed style |
+| 19 | hover 有悬浮感 | 实测鼠标悬停前后 transform 变化 |
+| 20 | 无 console 报错 | 控制台干净 |
+
+---
+
+## 6. 数据结构
+
+- 复用 `KfProject` / `KfArtifact` / `KfInsight` / `KfSession`
+- **新增机会卡**:建议新建 `KfOpportunity`(理由:它是独立生命周期对象,有评分、状态、决策记录,跟"成果"不是一回事)。**你判断,README 说明理由**
+- 上传元数据字段(所有上传对象都要有):
+  - `uploadedBy`(用户名)、`uploadedByKey`
+  - `agentName`(哪个 Agent)、`agentId`
+  - `uploadMethod`(session / skill / manual)
+  - `uploadedAt`
+- 评分:`scores: [{user, score, comment, at}]`,平均分前端算
+- 决策:`decision: {type, reason, by, at}`
+- **仍不锁定品类/竞品/工艺/人群的业务字段**(等调研回传)
+
+---
+
+## 7. 交付要求
+
+1. 代码在 `/opt/data/work/kangshifu/impl/`,`index.html` 入口
+2. `README.md`:说明机会卡为什么这么建模、上传元数据结构、这一版没做什么
+3. **逐条说明第 5 节 20 项**:哪些实测过、怎么验、结果如何;哪些没验到(明确写"未验证")
+4. 为**至少 6 条机会卡**造真实感示例数据(不同人、不同 Agent、不同阶段、不同状态、有评分),否则验收看不到池子的样子
+5. **这一版的成败在结构,不在页面数量。** 宁可 5 个视图但每个都厚,不要 12 个视图都空。
+
+## 8. 环境
+
+- Parse: `https://kangshifu.fmode.cn/parse`,App ID `FKieRbIoGt_main`
+- masterKey 在环境变量 `PARSE_MASTER_KEY`(测试脚本可用,**禁止进前端**)
+- 现有演示数据约 26 条成果 / 10 项目 / 4 归纳,字段不全,做好容错

+ 215 - 0
legacy/css/base.css

@@ -0,0 +1,215 @@
+/* ==========================================================================
+   基础排版 & 布局骨架(v3)
+   - 光场 .ambient:四团色光 + 左上斜下的天光,极缓漂移(尊重 reduced-motion)
+   - 噪点 .grain:细颗粒,让玻璃像材质而不是纯渐变
+   - 侧栏/顶栏:整片玻璃(L3),与内容区背景形成"浮在上面的玻璃层"
+   ========================================================================== */
+*,*::before,*::after{box-sizing:border-box}
+html,body{margin:0;padding:0}
+html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
+body{
+  font-family:var(--font);
+  font-size:14px;line-height:1.55;color:var(--ink);
+  background:var(--bg);
+  -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
+  text-rendering:optimizeLegibility;
+  min-height:100vh;overflow-x:hidden;
+}
+h1,h2,h3,h4,h5,p,ul,ol,dl,dd,figure{margin:0}
+ul,ol{padding:0;list-style:none}
+a{color:var(--accent-ink);text-decoration:none}
+a:hover{text-decoration:underline;text-underline-offset:3px}
+button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
+input,select{font:inherit;color:inherit}
+img{max-width:100%}
+:focus-visible{outline:2px solid var(--accent-line);outline-offset:2px;border-radius:8px}
+::selection{background:rgba(29,92,240,.16)}
+
+/* 数字:等宽数字保证列对齐;小单位用自己的字号层级 */
+.num{font-family:var(--font-num);font-variant-numeric:tabular-nums;font-feature-settings:"tnum" 1}
+.ta-r{text-align:right}
+
+/* ---------- 光场(装饰层,永不承载内容) ---------- */
+.ambient{position:fixed;inset:0;z-index:0;pointer-events:none;overflow:hidden}
+.ambient::before{
+  /* 从左上斜下的天光:这是"光源"本身 */
+  content:"";position:absolute;inset:0;
+  background:
+    radial-gradient(120% 90% at 8% -10%, rgba(255,255,255,.95) 0%, rgba(255,255,255,.35) 32%, rgba(255,255,255,0) 62%);
+}
+.ambient i{position:absolute;display:block;border-radius:50%;filter:blur(70px);opacity:.85;will-change:transform}
+.ambient .b1{width:62vw;height:52vw;left:-16vw;top:-18vw;background:radial-gradient(circle at 40% 40%,rgba(122,167,255,.55),rgba(122,167,255,0) 68%);animation:drift1 46s ease-in-out infinite alternate}
+.ambient .b2{width:48vw;height:44vw;right:-12vw;top:-14vw;background:radial-gradient(circle at 50% 50%,rgba(227,211,255,.75),rgba(227,211,255,0) 66%);animation:drift2 58s ease-in-out infinite alternate}
+.ambient .b3{width:52vw;height:46vw;left:-8vw;bottom:-26vw;background:radial-gradient(circle at 50% 50%,rgba(159,240,212,.55),rgba(159,240,212,0) 66%);animation:drift3 52s ease-in-out infinite alternate}
+.ambient .b4{width:46vw;height:40vw;right:-10vw;bottom:-22vw;background:radial-gradient(circle at 50% 50%,rgba(255,225,194,.7),rgba(255,225,194,0) 66%);animation:drift4 64s ease-in-out infinite alternate}
+@keyframes drift1{to{transform:translate3d(6vw,4vw,0) scale(1.08)}}
+@keyframes drift2{to{transform:translate3d(-5vw,6vw,0) scale(.94)}}
+@keyframes drift3{to{transform:translate3d(7vw,-4vw,0) scale(1.06)}}
+@keyframes drift4{to{transform:translate3d(-6vw,-5vw,0) scale(.95)}}
+
+/* 噪点:用 SVG 湍流生成,极淡叠加 */
+.grain{
+  position:fixed;inset:0;z-index:0;pointer-events:none;opacity:.22;mix-blend-mode:soft-light;
+  background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='.55'/></svg>");
+}
+
+/* ---------- 应用骨架 ---------- */
+.app{position:relative;z-index:1;display:grid;grid-template-columns:var(--sidebar) minmax(0,1fr);min-height:100vh}
+
+/* 侧栏:整片玻璃(L3)。边缘发丝边用伪元素画成渐变,上亮下暗 */
+.side{
+  position:sticky;top:0;height:100vh;display:flex;flex-direction:column;
+  padding:22px 14px 16px;
+  background:linear-gradient(180deg,rgba(255,255,255,.58),rgba(255,255,255,.40));
+  -webkit-backdrop-filter:var(--blur-3);backdrop-filter:var(--blur-3);
+  border-right:1px solid rgba(255,255,255,.55);
+  box-shadow:var(--light-top),var(--lift-1);
+}
+.brand{display:flex;align-items:center;gap:11px;padding:2px 10px 20px}
+.brand .mark{
+  width:28px;height:28px;border-radius:9px;flex:none;position:relative;
+  background:linear-gradient(150deg,rgba(255,255,255,.95),rgba(255,255,255,.45) 55%,rgba(160,185,255,.55));
+  border:1px solid rgba(255,255,255,.85);
+  box-shadow:var(--light-top),0 6px 14px -8px rgba(32,48,90,.35),inset 0 -6px 10px -6px rgba(122,167,255,.55);
+  display:grid;place-items:center;font-size:12.5px;font-weight:700;color:var(--ink);
+}
+.brand .name{font-size:14.5px;font-weight:600;letter-spacing:.1px;line-height:1.25}
+.brand .sub{font-size:11px;color:var(--ink-3);margin-top:1px}
+
+.nav{display:flex;flex-direction:column;gap:2px}
+.nav-group{font-size:11px;color:var(--ink-4);padding:16px 12px 6px;letter-spacing:.6px}
+.nav a{
+  display:flex;align-items:center;gap:9px;position:relative;
+  padding:8px 12px;border-radius:11px;
+  color:var(--ink-2);font-size:13.5px;text-decoration:none;
+  border:1px solid transparent;
+  transition:background .16s ease,color .16s ease,border-color .16s ease;
+}
+.nav a svg{width:15px;height:15px;opacity:.55;flex:none;margin-left:-2px}
+.nav a:hover{background:rgba(255,255,255,.42);color:var(--ink);text-decoration:none}
+.nav a:hover svg{opacity:.85}
+.nav a.on svg{opacity:1;color:var(--accent)}
+.nav a.on{
+  background:linear-gradient(150deg,rgba(255,255,255,.92),rgba(255,255,255,.62));
+  color:var(--ink);
+  border-color:rgba(255,255,255,.95);
+  box-shadow:var(--light-top),0 6px 16px -12px rgba(32,48,90,.4);
+}
+.nav a.on::before{content:"";position:absolute;left:-1px;top:9px;bottom:9px;width:2px;border-radius:2px;background:var(--accent);box-shadow:var(--accent-glow)}
+.nav a .cnt{margin-left:auto;font-size:11.5px;color:var(--ink-4);font-variant-numeric:tabular-nums}
+.nav a.on .cnt{color:var(--ink-3)}
+.side .foot{margin-top:auto;padding:12px 12px 0;border-top:1px solid var(--rule);font-size:11px;color:var(--ink-3);display:flex;align-items:center;gap:7px}
+.dot{width:6px;height:6px;border-radius:50%;background:var(--ink-4);flex:none}
+.dot.live{background:var(--ok);box-shadow:0 0 0 3px rgba(20,122,85,.14)}
+.dot.syncing{background:var(--warn);animation:pulse 1.2s ease-in-out infinite}
+.dot.err{background:var(--risk)}
+@keyframes pulse{50%{opacity:.35}}
+
+/* 主区 */
+.main{min-width:0;display:flex;flex-direction:column}
+
+/* 顶栏:同一片玻璃;滚动时内容从下面透过去 */
+.topbar{
+  position:sticky;top:0;z-index:20;
+  display:flex;align-items:center;gap:16px;
+  min-height:var(--topbar);padding:0 clamp(20px,3vw,40px);
+  background:linear-gradient(180deg,rgba(255,255,255,.62),rgba(255,255,255,.44));
+  -webkit-backdrop-filter:var(--blur-3);backdrop-filter:var(--blur-3);
+  border-bottom:1px solid rgba(255,255,255,.6);
+  box-shadow:var(--light-top),0 10px 30px -24px rgba(32,48,90,.35);
+}
+.crumb{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--ink-3);min-width:0}
+.crumb a{color:var(--ink-3)}
+.crumb .sep{color:var(--ink-4)}
+.crumb .now{color:var(--ink);font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:42ch}
+.topbar .tools{margin-left:auto;display:flex;align-items:center;gap:10px}
+
+/* 搜索 */
+.search{position:relative;display:flex;align-items:center}
+.search svg{position:absolute;left:11px;width:15px;height:15px;color:var(--ink-3);pointer-events:none}
+.search input{
+  width:260px;height:36px;padding:0 34px 0 34px;border-radius:11px;
+  border:1px solid rgba(255,255,255,.75);background:rgba(255,255,255,.42);
+  box-shadow:var(--light-top),inset 0 1px 2px rgba(32,48,90,.05);
+  font-size:13px;outline:none;transition:background .2s,border-color .2s,box-shadow .2s;
+}
+.search input::placeholder{color:var(--ink-3)}
+.search input:focus{background:rgba(255,255,255,.86);border-color:rgba(255,255,255,.98);box-shadow:var(--light-top),0 0 0 3px var(--accent-wash),0 1px 2px rgba(32,48,90,.05)}
+.search .clear{position:absolute;right:6px;width:22px;height:22px;border-radius:7px;display:grid;place-items:center;color:var(--ink-3)}
+.search .clear:hover{background:rgba(24,34,56,.07)}
+
+/* 内容区 */
+.view{padding:clamp(22px,3vw,40px) clamp(20px,3vw,40px) 80px;max-width:1560px;width:100%;margin:0 auto}
+.view-head{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;margin-bottom:26px}
+.view-head h1{font-size:24px;font-weight:600;letter-spacing:-.3px;line-height:1.25}
+.view-head .lede{font-size:13px;color:var(--ink-3);margin-top:5px;max-width:68ch;line-height:1.65}
+.view-head .aside{margin-left:auto;display:flex;gap:8px;align-items:center}
+
+/* 区块标题 */
+.sec{display:flex;align-items:baseline;gap:10px;margin:34px 0 12px}
+.sec:first-child{margin-top:0}
+.sec h2{font-size:15px;font-weight:600;letter-spacing:.1px}
+.sec .hint{font-size:12px;color:var(--ink-3)}
+.sec .more{margin-left:auto;font-size:12.5px}
+
+/* ---------- 玻璃基元 ----------
+ * .glass 是最小的玻璃面板:半透明白 + 背景模糊 + 发丝边 + 顶部内高光 + 极浅影。
+ * 发丝边用双层背景实现:第一层画在 border-box 上做 1px 渐变边(上亮下暗),
+ * 第二层是面板本体(稍微内缩),形成"边缘折光"的感觉。 */
+.glass{
+  position:relative;
+  background:
+    var(--edge) border-box;
+  border:1px solid transparent;
+  border-radius:var(--r-lg);
+  -webkit-backdrop-filter:var(--blur-2);backdrop-filter:var(--blur-2);
+  box-shadow:var(--light-top),var(--lift-1);
+}
+/* 用 ::before 叠加一层半透明本体,避免 border-box 渐变把整块染色 */
+.glass::before{
+  content:"";position:absolute;inset:-1px;border-radius:inherit;pointer-events:none;
+  background:var(--glass-2);
+  z-index:-1;
+}
+.glass > *{position:relative}
+.panel{
+  position:relative;border-radius:var(--r-lg);overflow:hidden;
+  background:var(--glass-1);
+  -webkit-backdrop-filter:var(--blur-1);backdrop-filter:var(--blur-1);
+  border:1px solid rgba(255,255,255,.6);
+  box-shadow:var(--light-top),var(--lift-1);
+}
+.panel::after{
+  /* 上亮下暗的边缘:顶部一条细高光,底部一条极淡暗边 */
+  content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;
+  background:linear-gradient(180deg,rgba(255,255,255,.28),rgba(255,255,255,0) 18%,rgba(255,255,255,0) 82%,rgba(255,255,255,.12));
+  box-shadow:inset 0 -1px 0 rgba(32,48,90,.05);
+}
+.panel-pad{padding:20px 22px}
+.panel-head{display:flex;align-items:center;gap:10px;padding:14px 22px;border-bottom:1px solid var(--rule)}
+.panel-head h3{font-size:13.5px;font-weight:600}
+.panel-head .hint{margin-left:auto;font-size:12px;color:var(--ink-3)}
+
+/* 通用文字 */
+.muted{color:var(--ink-3)}
+.dim{color:var(--ink-2)}
+.tiny{font-size:12px}
+.mono-num{font-family:var(--font-num);font-variant-numeric:tabular-nums}
+
+/* 移动端菜单按钮 */
+.menu-btn{display:none;width:36px;height:36px;border-radius:11px;border:1px solid rgba(255,255,255,.8);background:rgba(255,255,255,.45);box-shadow:var(--light-top);place-items:center}
+.menu-btn svg{width:18px;height:18px}
+.scrim{display:none}
+
+/* ---------- 降级:不支持 backdrop-filter 时,玻璃退回"高亮度磨砂纸" ---------- */
+@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
+  .panel{background:rgba(255,255,255,.80)}
+  .side,.topbar{background:rgba(255,255,255,.86)}
+  .ambient{opacity:.6}
+}
+
+/* 台账定位:?focus=<id> 时高亮那一行,并在结果区上方给出"你在找这条"的说明 */
+.focus-note{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 12px;padding:10px 14px;border-radius:12px;background:var(--accent-wash);box-shadow:inset 0 0 0 1px rgba(29,92,240,.16);font-size:12.5px;color:var(--ink-2)}
+.focus-note b{color:var(--accent-ink)}
+.tbl tbody tr.focus-on td{animation:focusRow 2.4s ease-out}
+@keyframes focusRow{0%,40%{background:rgba(29,92,240,.13)}100%{background:transparent}}

+ 435 - 0
legacy/css/cards.css

@@ -0,0 +1,435 @@
+/* ==========================================================================
+   v4 · 机会卡 / 工作台 —— Liquid Glass 第二层加强
+   客户反馈:"知觉是升级了一点,但是还不够"。v4 在 v3 基础上做了五件事:
+
+   1) 折射边(.glass-edge):不是均匀 1px 细线,而是
+        · 上缘亮、左缘次亮、下缘暗、右缘最暗的渐变边(上亮下暗——玻璃边折光的方向)
+        · 边框外侧再压一道极淡暗边,让玻璃片"有厚度"而不是贴纸
+        · 内阴影顶部高光 + 底部内阴影(向下折光落暗)
+   2) 三层纵深(明确写进 computed style,验收要读):
+        L0 .ambient          背景光场       —— 亮、虚、慢
+        L1 .panel / .metric  中层玻璃      —— spectral 高光 0.0–0.06
+        L2 .ocard / .glass-3 浮起卡        —— spectral 高光 0.10–0.18 + 更深投影
+        L3 .drawer / .topbar 浮层          —— 最实 + 最强边光
+      从后到前:背景越来越"发光",前景越来越"实"、边光越来越亮。
+   3) 悬浮:hover 时卡片上浮 2px + 高光扫过(.ocard-sheen 随指针位置移动),
+      是玻璃片被抬起,不是纸片加阴影。
+   4) 数字:关键数字用 --num-glow 柔光(文字阴影,不是底色),可读性优先。
+   5) 光场:v3 的四个光斑边缘可见,像贴上去的图案。v4 改成
+      极低频漂移(110–170s)+ 更宽更淡(radial 渐变更长尾)+ 整体压暗对比,
+      再加一层"环境光"叠加,让它像屋子里的光,不像装饰图。
+
+   铁律:所有数据落在 --ink / --ink-2 上并配 --num-plate 衬底(卡片上最亮的
+   角落叠白 0.46 时,--ink-3 仍 ≥ 4.6:1)。玻璃只做衬托,不糊数据。
+   ========================================================================== */
+
+/* ---------- 1. 折射边:一个可复用的"玻璃片" ---------- */
+.glass-edge{
+  position:relative;
+  border:1px solid transparent;
+  background:
+    linear-gradient(158deg, rgba(255,255,255,.60) 0%, rgba(255,255,255,.34) 46%, rgba(255,255,255,.42) 100%) padding-box,
+    linear-gradient(158deg,
+      rgba(255,255,255,1)    0%,
+      rgba(255,255,255,.82)  14%,
+      rgba(255,255,255,.30)  46%,
+      rgba(255,255,255,.10)  74%,
+      rgba(255,255,255,.55)  100%) border-box;
+  -webkit-backdrop-filter:var(--blur-2);backdrop-filter:var(--blur-2);
+  box-shadow:
+    inset 0 1px 0 rgba(255,255,255,.92),          /* 顶部折光 */
+    inset 0 -1px 0 rgba(32,48,90,.075),           /* 底部折光落暗 */
+    inset 1px 0 0 rgba(255,255,255,.42),          /* 左缘 */
+    inset -1px 0 0 rgba(32,48,90,.035),           /* 右缘偏暗 */
+    0 1px 0 rgba(255,255,255,.55),                /* 外缘亮线:厚度感 */
+    0 18px 40px -26px rgba(32,48,90,.30);
+}
+
+/* ---------- 2. 机会卡片池 ---------- */
+.ogrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:16px;margin:14px 0 0;padding:0;list-style:none}
+.ogrid-3{grid-template-columns:repeat(3,minmax(0,1fr))}
+
+.ocard{
+  position:relative;display:flex;flex-direction:column;gap:9px;
+  padding:15px 17px 13px;border-radius:var(--r-lg);overflow:hidden;
+  cursor:pointer;outline:none;
+  color:var(--ink);text-decoration:none;
+  transition:transform .28s cubic-bezier(.2,.8,.2,1), box-shadow .28s ease, background .28s ease;
+  transform:translateY(0);
+  /* 三层中的 L2:比面板实,比抽屉虚 */
+  background:
+    linear-gradient(158deg, rgba(255,255,255,.62) 0%, rgba(255,255,255,.38) 48%, rgba(255,255,255,.46) 100%) padding-box,
+    linear-gradient(158deg,
+      rgba(255,255,255,1)    0%,
+      rgba(255,255,255,.84)  13%,
+      rgba(255,255,255,.30)  47%,
+      rgba(255,255,255,.10)  75%,
+      rgba(255,255,255,.58)  100%) border-box;
+  border:1px solid transparent;
+  /* 浮起的卡(L2)比中层面板光更"聚":模糊值更大、饱和度更高——这是第二层纵深的标志 */
+  -webkit-backdrop-filter:blur(34px) saturate(190%);backdrop-filter:blur(34px) saturate(190%);
+  box-shadow:
+    inset 0 1px 0 rgba(255,255,255,.95),
+    inset 0 -1px 0 rgba(32,48,90,.08),
+    inset 1px 0 0 rgba(255,255,255,.45),
+    inset -1px 0 0 rgba(32,48,90,.035),
+    0 1px 0 rgba(255,255,255,.6),
+    0 16px 36px -24px rgba(32,48,90,.30);
+}
+/* 光谱高光:左上角一小片冷色光,示意"光穿过玻璃后被分开" */
+.ocard::before{
+  content:"";position:absolute;inset:0;pointer-events:none;border-radius:inherit;z-index:0;
+  background:
+    radial-gradient(62% 46% at 0% 0%, rgba(255,255,255,.40), rgba(255,255,255,0) 62%),
+    radial-gradient(46% 36% at 8% 2%, rgba(170,200,255,.16), rgba(170,200,255,0) 70%),
+    radial-gradient(30% 26% at 0% 26%, rgba(255,214,214,.12), rgba(255,214,214,0) 70%);
+  opacity:.8;
+  transition:opacity .28s;
+}
+/* 扫光:跟随指针(--mx/--my 由 app.js 写入),静止时在左上 */
+.ocard-sheen{
+  position:absolute;inset:-1px;border-radius:inherit;pointer-events:none;z-index:0;
+  background:radial-gradient(320px circle at var(--mx,16%) var(--my,0%), rgba(255,255,255,.42), rgba(255,255,255,0) 58%);
+  opacity:0;transition:opacity .3s ease;
+}
+.ocard > *:not(.ocard-sheen){position:relative;z-index:1}
+.ocard:hover,.ocard:focus-visible{
+  transform:translateY(-3px);
+  box-shadow:
+    inset 0 1px 0 #fff,
+    inset 0 -1px 0 rgba(32,48,90,.09),
+    inset 1px 0 0 rgba(255,255,255,.55),
+    inset -1px 0 0 rgba(32,48,90,.04),
+    0 1px 0 rgba(255,255,255,.7),
+    0 30px 54px -30px rgba(32,48,90,.42),
+    0 2px 6px -2px rgba(32,48,90,.08);
+}
+.ocard:hover .ocard-sheen,.ocard:focus-visible .ocard-sheen{opacity:1}
+.ocard:hover::before{opacity:1}
+
+/* 否决卡:退后一档(更淡、更灰),但仍可读 */
+.ocard.st-rejected{background:
+    linear-gradient(158deg, rgba(248,249,252,.66) 0%, rgba(244,246,250,.50) 60%, rgba(244,246,250,.56) 100%) padding-box,
+    linear-gradient(158deg, rgba(255,255,255,.95) 0%, rgba(255,255,255,.55) 40%, rgba(255,255,255,.14) 100%) border-box}
+.ocard.st-rejected .o-title{color:var(--ink-2)}
+.ocard.st-approved{border-color:transparent}
+
+/* 卡头 */
+.o-top{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:11.5px}
+.o-top-r{margin-left:auto;display:flex;align-items:center;gap:6px}
+.o-prio{display:inline-flex;align-items:center;height:20px;padding:0 7px;border-radius:6px;font-size:11px;font-weight:700;letter-spacing:.3px;
+  font-family:var(--font-num);box-shadow:inset 0 0 0 1px rgba(255,255,255,.8),0 1px 0 rgba(255,255,255,.6)}
+.o-prio.p-S{background:linear-gradient(150deg,rgba(29,92,240,.16),rgba(29,92,240,.07));color:var(--accent-ink)}
+.o-prio.p-A{background:linear-gradient(150deg,rgba(24,34,56,.10),rgba(24,34,56,.04));color:var(--ink)}
+.o-prio.p-B{background:linear-gradient(150deg,rgba(24,34,56,.06),rgba(24,34,56,.02));color:var(--ink-2)}
+.o-prio.none{background:rgba(24,34,56,.04);color:var(--ink-3);font-weight:500}
+.o-cat{color:var(--ink-2);padding:0 6px;height:20px;display:inline-flex;align-items:center;border-radius:6px;background:rgba(255,255,255,.42);box-shadow:inset 0 0 0 1px rgba(255,255,255,.7);font-size:11.5px}
+.o-stage{color:var(--ink-3);font-size:11.5px;white-space:nowrap}
+.o-status{display:inline-flex;align-items:center;gap:5px;height:20px;padding:0 8px;border-radius:999px;font-size:11px;font-weight:600;white-space:nowrap}
+.o-status::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor}
+.o-status.s-candidate{color:var(--accent-ink);background:rgba(29,92,240,.10);box-shadow:inset 0 0 0 1px rgba(29,92,240,.16)}
+.o-status.s-approved{color:var(--ok);background:rgba(20,122,85,.10);box-shadow:inset 0 0 0 1px rgba(20,122,85,.18)}
+.o-status.s-rejected{color:var(--ink-3);background:rgba(24,34,56,.06);box-shadow:inset 0 0 0 1px rgba(24,34,56,.08)}
+
+/* 标题 + 一句话 */
+.o-title{font-size:16px;font-weight:650;line-height:1.38;letter-spacing:-.1px;color:var(--ink);margin:1px 0 0}
+.o-line{font-size:12.5px;line-height:1.6;color:var(--ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.o-line.none{color:var(--ink-3);font-style:italic}
+
+/* 概览:评分 + 三个数 */
+.o-overview{display:flex;align-items:stretch;gap:12px;margin-top:2px;padding:10px 12px;border-radius:12px;
+  background:linear-gradient(150deg,rgba(255,255,255,.56),rgba(255,255,255,.30));
+  box-shadow:inset 0 1px 0 rgba(255,255,255,.9),inset 0 0 0 1px rgba(255,255,255,.55)}
+.o-score{display:flex;flex-direction:column;justify-content:center;min-width:74px;padding-right:12px;border-right:1px solid rgba(24,34,56,.08)}
+.o-score-v{display:flex;align-items:baseline;gap:3px}
+.o-score-v b{font-family:var(--font-num);font-size:30px;font-weight:650;letter-spacing:-1px;line-height:1;color:var(--accent-ink);text-shadow:var(--num-glow)}
+.o-score-l{font-size:11px;color:var(--ink-3)}
+.o-score.empty .o-score-v b{color:var(--ink-3);text-shadow:none}
+.o-score-m{font-size:11px;color:var(--ink-3);margin-top:4px;display:flex;flex-direction:column;gap:4px}
+.o-gauge{display:block;height:3px;border-radius:2px;background:rgba(24,34,56,.08);overflow:hidden}
+.o-gauge i{display:block;height:100%;border-radius:2px;background:linear-gradient(90deg,rgba(29,92,240,.55),rgba(29,92,240,.9))}
+.o-stats{display:flex;flex-wrap:wrap;align-items:center;gap:4px 14px;flex:1;min-width:0}
+.o-stat{display:flex;flex-direction:column;gap:1px;font-size:10.5px;color:var(--ink-3);line-height:1.3}
+.o-stat b{font-size:13px;font-weight:600;color:var(--ink);letter-spacing:0}
+.o-stat.ev-high b{color:var(--ok)}
+.o-stat.ev-medium b{color:var(--ink)}
+.o-stat.ev-low b{color:var(--warn)}
+.o-stat.ev-none b{color:var(--ink-3)}
+
+/* 决策 + 来源 */
+.o-dec{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-2);line-height:1.5}
+.o-dec b{font-size:11px;font-weight:600;color:var(--ink-3);letter-spacing:.3px;padding:1px 6px;border-radius:5px;background:rgba(29,92,240,.07);color:var(--accent-ink)}
+.o-dec.none{color:var(--ink-3)}
+.o-dec.none b{background:rgba(24,34,56,.05);color:var(--ink-3)}
+.o-dec-l{font-weight:600;color:var(--ink)}
+.o-dec.d-reject .o-dec-l{color:var(--risk)}
+.o-dec.d-approve .o-dec-l{color:var(--ok)}
+.o-dec-by{margin-left:auto;font-size:11.5px;color:var(--ink-3);white-space:nowrap}
+.o-why{margin-top:-4px}
+.o-why summary{font-size:11.5px;color:var(--ink-3);cursor:pointer;width:max-content}
+.o-why p{font-size:12px;color:var(--ink-2);line-height:1.65;margin-top:6px;padding-left:10px;box-shadow:inset 2px 0 0 rgba(29,92,240,.18)}
+.o-source{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:9px;border-top:1px solid rgba(24,34,56,.07);font-size:11.5px;color:var(--ink-2)}
+.o-source .cell-person{font-size:12px}
+.cell-person{display:inline-flex}
+.cell-person .sub{display:inline;margin:0 0 0 6px;font-size:11.5px;color:var(--ink-3)}
+.o-source .avatar{width:20px;height:20px;font-size:10px}
+.o-arrow{width:12px;height:1px;background:rgba(24,34,56,.18);position:relative}
+.o-arrow::after{content:"";position:absolute;right:0;top:-2px;width:5px;height:5px;border-top:1px solid rgba(24,34,56,.28);border-right:1px solid rgba(24,34,56,.28);transform:rotate(45deg)}
+.o-agent{display:inline-flex;align-items:center;gap:5px;color:var(--ink);font-weight:500}
+.o-agent::before{content:"";width:14px;height:14px;border-radius:4px;background:conic-gradient(from 210deg,rgba(29,92,240,.5),rgba(159,240,212,.7),rgba(227,211,255,.7),rgba(29,92,240,.5));box-shadow:inset 0 0 0 1px rgba(255,255,255,.8)}
+.o-method{font-size:11px;color:var(--ink-2);padding:1px 6px;border-radius:5px;background:rgba(255,255,255,.5);box-shadow:inset 0 0 0 1px rgba(255,255,255,.8)}
+.o-method.none{color:var(--warn)}
+.o-when{margin-left:auto;font-size:11px;color:var(--ink-3);white-space:nowrap}
+.o-source.partial{box-shadow:inset 0 1px 0 rgba(24,34,56,.07)}
+
+.pool-foot{margin-top:14px}
+
+/* ---------- 3. 卡片抽屉里的控件 ---------- */
+.o-drawer-line{font-size:14px;line-height:1.7;color:var(--ink-2);margin-bottom:16px}
+.o-evidence-big{display:flex;gap:18px;align-items:center;padding:14px 16px;border-radius:14px;margin-bottom:22px;
+  background:linear-gradient(150deg,rgba(255,255,255,.62),rgba(255,255,255,.36));
+  box-shadow:inset 0 1px 0 rgba(255,255,255,.95),inset 0 -1px 0 rgba(32,48,90,.06)}
+.o-evidence-big .o-score{border-right:1px solid rgba(24,34,56,.09)}
+.o-drawer-dec .o-dec{font-size:13px}
+.o-drawer-dec .o-dec.none{font-size:13px}
+.o-drawer-dec .o-dec b,.o-evidence-big .o-score-l{white-space:nowrap}
+.o-evidence-big .o-score{min-width:96px}
+.o-reason{font-size:13px;line-height:1.7;color:var(--ink-2);margin-top:8px;padding-left:10px;box-shadow:inset 2px 0 0 rgba(29,92,240,.2)}
+.o-drawer-actions{display:flex;align-items:center;gap:12px;margin-top:14px}
+.seg-btn-group{display:inline-flex;padding:3px;border-radius:10px;background:rgba(255,255,255,.4);box-shadow:inset 0 0 0 1px rgba(255,255,255,.75)}
+.seg-btn{height:28px;padding:0 13px;border-radius:8px;font-size:12.5px;color:var(--ink-2);transition:background .15s,color .15s}
+.seg-btn:hover{background:rgba(255,255,255,.6);color:var(--ink)}
+.seg-btn.on{background:rgba(255,255,255,.98);color:var(--ink);box-shadow:var(--light-top),0 2px 6px -4px rgba(32,48,90,.35);font-weight:600}
+.o-input{width:100%;height:34px;margin-top:10px;padding:0 11px;border-radius:9px;border:1px solid rgba(255,255,255,.85);
+  background:rgba(255,255,255,.55);font-size:12.5px;outline:none;box-shadow:inset 0 1px 2px rgba(32,48,90,.05)}
+.o-input:focus{background:#fff;box-shadow:inset 0 0 0 1px rgba(29,92,240,.35)}
+.o-select{height:32px;margin-left:6px;padding:0 8px;border-radius:8px;border:1px solid rgba(255,255,255,.85);background:rgba(255,255,255,.7);font-size:12.5px;outline:none}
+.o-dlog{list-style:none;padding:0;margin:14px 0 0;border-left:1px solid rgba(24,34,56,.1);padding-left:14px}
+.o-dlog li{padding:6px 0;font-size:12px}
+.o-dlog-t{font-weight:600;color:var(--ink)}
+.o-dlog-m{color:var(--ink-3);margin-left:8px;font-variant-numeric:tabular-nums}
+.o-dlog p{color:var(--ink-2);margin-top:3px;line-height:1.6}
+.o-dims{display:grid;gap:8px;margin-bottom:12px}
+.o-dim{display:grid;grid-template-columns:104px minmax(0,1fr) 38px;align-items:center;gap:10px}
+.o-dim-l{font-size:12px;color:var(--ink-2)}
+.o-dim-l em{font-style:normal;color:var(--ink-3);font-size:10.5px;margin-left:4px}
+.o-dim input[type=range]{width:100%;accent-color:var(--accent)}
+.o-dim output{font-family:var(--font-num);font-size:13px;color:var(--ink);text-align:right}
+.o-scoreform{display:flex;flex-wrap:wrap;align-items:center;gap:10px}
+.o-scoreform .o-input{flex:1;min-width:160px;margin-top:0}
+.o-computed{font-size:12.5px;color:var(--ink-3)}
+.o-computed b{font-size:18px;color:var(--accent-ink);margin-left:4px;text-shadow:var(--num-glow)}
+.o-scorelist{margin-top:14px}
+.o-srow{display:grid;grid-template-columns:64px 40px minmax(0,1fr) auto;gap:8px;align-items:baseline;padding:7px 0;border-bottom:1px solid var(--rule);font-size:12.5px}
+.o-srow:last-child{border-bottom:0}
+.o-su{font-weight:500;color:var(--ink)}
+.o-sv{font-family:var(--font-num);font-weight:650;font-size:16px;color:var(--ink)}
+.o-st{white-space:nowrap}
+.o-sc{color:var(--ink-2);line-height:1.5}
+.o-st{color:var(--ink-3);font-size:11.5px}
+.o-source-grid .v{display:flex;align-items:center;gap:4px;flex-wrap:wrap}
+
+/* ---------- 4. 工作台 ---------- */
+.wb-sec{margin-top:38px}
+.wb-sec:first-of-type{margin-top:0}
+.wb-sec .sec{margin-top:0}
+.sec-sub h3{font-size:13.5px;font-weight:600}
+.sec-sub{margin-top:24px !important}
+
+/* 给我的 */
+.wb-me{padding:16px 18px;border-radius:var(--r-lg);background:linear-gradient(155deg,rgba(255,255,255,.5),rgba(255,255,255,.26));
+  -webkit-backdrop-filter:var(--blur-1);backdrop-filter:var(--blur-1);
+  box-shadow:inset 0 1px 0 rgba(255,255,255,.9),inset 0 0 0 1px rgba(255,255,255,.55),0 14px 34px -26px rgba(32,48,90,.3)}
+.me-bar{display:flex;align-items:center;gap:12px;padding-bottom:14px;border-bottom:1px solid rgba(24,34,56,.07)}
+.avatar.lg{width:38px;height:38px;font-size:13px}
+.tb-label{font-size:11px;color:var(--ink-3)}
+.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
+.me-pick select{height:32px;padding:0 28px 0 10px;border-radius:9px;border:1px solid rgba(255,255,255,.9);background:rgba(255,255,255,.72);
+  font-size:14px;font-weight:600;color:var(--ink);outline:none;box-shadow:var(--light-top),0 2px 8px -6px rgba(32,48,90,.35);appearance:none;
+  background-image:linear-gradient(45deg,transparent 50%,var(--ink-3) 50%),linear-gradient(135deg,var(--ink-3) 50%,transparent 50%);
+  background-position:calc(100% - 15px) 13px,calc(100% - 10px) 13px;background-size:5px 5px;background-repeat:no-repeat}
+.me-pick select:focus{box-shadow:0 0 0 3px var(--accent-wash),var(--light-top)}
+.wb-empty-me{margin:14px 0 10px;font-size:13px;color:var(--ink-2);line-height:1.7}
+.me-preview{display:flex;flex-wrap:wrap;gap:6px}
+.me-chip{font-size:12px;color:var(--ink-2);padding:3px 9px;border-radius:999px;background:rgba(255,255,255,.55);box-shadow:inset 0 0 0 1px rgba(255,255,255,.85)}
+.wb-me-stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin:14px 0}
+.me-stat{padding:12px 14px;border-radius:12px;background:rgba(255,255,255,.42);box-shadow:inset 0 1px 0 rgba(255,255,255,.9),inset 0 0 0 1px rgba(255,255,255,.6)}
+.me-stat b{display:block;font-family:var(--font-num);font-size:26px;font-weight:650;letter-spacing:-.8px;line-height:1.1;color:var(--accent-ink);text-shadow:var(--num-glow)}
+.me-stat span{display:block;font-size:11.5px;color:var(--ink-3);margin-top:4px;line-height:1.45}
+.me-stat.warn b{color:var(--warn);text-shadow:none}
+.wb-me-body{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:14px;align-items:start}
+.me-table .tbl thead th{background:rgba(255,255,255,.4)}
+.me-stage.state-todo .ttl{color:var(--ink-2)}
+.me-stage.state-cur .ttl{color:var(--ink)}
+.me-recent .panel-head a{font-size:12px;color:var(--accent-ink)}
+.me-art{display:block;width:100%;text-align:left;padding:11px 16px;border-bottom:1px solid var(--rule);transition:background .15s}
+.me-art:last-child{border-bottom:0}
+.me-art:hover{background:rgba(255,255,255,.5)}
+.me-art-t{display:block;font-size:13px;font-weight:500;color:var(--ink)}
+.me-art-m{display:block;font-size:11.5px;color:var(--ink-3);margin-top:2px}
+
+/* 池子 hero */
+.glass-3{position:relative;border-radius:var(--r-lg);border:1px solid transparent;
+  background:
+    linear-gradient(160deg, rgba(255,255,255,.70), rgba(255,255,255,.46)) padding-box,
+    linear-gradient(160deg, rgba(255,255,255,1) 0%, rgba(255,255,255,.8) 20%, rgba(255,255,255,.25) 60%, rgba(255,255,255,.6) 100%) border-box;
+  -webkit-backdrop-filter:blur(40px) saturate(200%);backdrop-filter:blur(40px) saturate(200%);
+  box-shadow:inset 0 1px 0 #fff,inset 0 -1px 0 rgba(32,48,90,.08),0 24px 50px -32px rgba(32,48,90,.36)}
+.pool-hero{display:grid;grid-template-columns:minmax(0,1.3fr) repeat(3,minmax(0,.7fr)) minmax(0,1.4fr);gap:0;align-items:stretch;padding:0}
+.pool-hero > *{padding:18px 20px;position:relative}
+.pool-hero > * + *{box-shadow:inset 1px 0 0 rgba(255,255,255,.55),inset 1px 0 0 rgba(24,34,56,.05)}
+.pool-num b{display:block;font-family:var(--font-num);font-size:46px;font-weight:650;letter-spacing:-2px;line-height:1;color:var(--accent-ink);text-shadow:var(--num-soft)}
+.pool-num span{display:block;font-size:12.5px;color:var(--ink-2);margin-top:4px}
+.pool-num em{display:block;font-style:normal;font-size:11.5px;color:var(--ink-3);margin-top:6px}
+.pool-stat b{display:block;font-family:var(--font-num);font-size:28px;font-weight:650;letter-spacing:-1px;line-height:1;color:var(--ink)}
+.pool-stat span{display:block;font-size:11.5px;color:var(--ink-3);margin-top:5px}
+.pool-best{display:flex;flex-direction:column;justify-content:center;gap:3px;background:linear-gradient(160deg,rgba(29,92,240,.06),rgba(255,255,255,0))}
+.pool-best span{font-size:11px;color:var(--ink-3)}
+.pool-best b{font-size:14px;color:var(--ink);line-height:1.4}
+.pool-best em{font-style:normal;font-size:12px;color:var(--accent-ink);font-weight:600}
+
+/* 现场看板 */
+.board-strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:14px}
+.bs{padding:12px 16px;border-radius:14px;background:linear-gradient(155deg,rgba(255,255,255,.6),rgba(255,255,255,.34));
+  box-shadow:inset 0 1px 0 rgba(255,255,255,.95),inset 0 0 0 1px rgba(255,255,255,.6)}
+.bs b{font-family:var(--font-num);font-size:28px;font-weight:650;letter-spacing:-1px;line-height:1;color:var(--ink)}
+.bs span{font-size:12px;color:var(--ink-3);margin-left:8px}
+.bs.warn b{color:var(--warn)}
+.bs.risk b{color:var(--risk)}
+.wb-board{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr);gap:14px;align-items:start}
+table.matrix th,table.matrix td{text-align:right;padding:10px 12px}
+table.matrix th:first-child,table.matrix td:first-child{text-align:left;width:38%}
+.mcell{position:relative;font-family:var(--font-num);font-variant-numeric:tabular-nums;font-weight:600;color:var(--ink);vertical-align:bottom}
+.mcell.cur{color:var(--accent-ink)}
+.mcell.off{color:var(--ink-4);font-weight:400}
+.mbar{position:absolute;left:34%;right:34%;bottom:4px;border-radius:2px;background:linear-gradient(180deg,rgba(29,92,240,.35),rgba(29,92,240,.12))}
+.matrix-l{white-space:nowrap}
+.mbar-h{display:inline-block;vertical-align:middle;width:70px;height:4px;border-radius:2px;background:rgba(24,34,56,.08);overflow:hidden;margin-right:8px}
+.mbar-h i{display:block;height:100%;background:rgba(24,34,56,.42);border-radius:2px}
+.stuck-box .panel-head h3{font-size:13.5px;font-weight:600}
+.stuck-list{display:grid;gap:8px}
+.stuck{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:center;gap:2px 10px;padding:10px 12px;border-radius:12px;
+  background:linear-gradient(150deg,rgba(255,244,232,.62),rgba(255,255,255,.36));box-shadow:inset 0 1px 0 rgba(255,255,255,.9),inset 0 0 0 1px rgba(138,90,0,.12);
+  color:inherit;text-decoration:none;transition:transform .2s,box-shadow .2s}
+.stuck:hover{text-decoration:none;transform:translateY(-1px);box-shadow:inset 0 1px 0 #fff,inset 0 0 0 1px rgba(138,90,0,.2),0 10px 22px -16px rgba(32,48,90,.35)}
+.stuck .avatar{grid-row:span 2;border-color:rgba(255,255,255,.95);background:rgba(255,255,255,.8);color:var(--warn)}
+.stuck-n{font-size:13.5px;font-weight:600;color:var(--ink)}
+.stuck-s{font-size:11.5px;color:var(--warn);font-weight:600;justify-self:end;white-space:nowrap}
+.stuck-m{grid-column:2 / -1;font-size:11.5px;color:var(--ink-3)}
+.stuck-ok{display:flex;gap:9px;align-items:flex-start;font-size:12.5px;color:var(--ink-2);line-height:1.65}
+.stuck-ok svg{width:15px;height:15px;flex:none;margin-top:1px;color:var(--ink-3)}
+
+/* 分板块产出 */
+.sb-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px}
+.stage-board{display:flex;flex-direction:column;gap:8px;padding:15px 16px 13px;border-radius:var(--r-lg);color:inherit;text-decoration:none;
+  background:
+    linear-gradient(158deg, rgba(255,255,255,.58) 0%, rgba(255,255,255,.34) 55%, rgba(255,255,255,.42) 100%) padding-box,
+    linear-gradient(160deg, rgba(255,255,255,1), rgba(255,255,255,.55) 30%, rgba(255,255,255,.14) 80%) border-box;
+  border:1px solid transparent;
+  box-shadow:inset 0 1px 0 #fff,inset 0 -1px 0 rgba(32,48,90,.06),0 14px 32px -26px rgba(32,48,90,.3);
+  transition:transform .25s cubic-bezier(.2,.8,.2,1),box-shadow .25s}
+.stage-board:hover{text-decoration:none;transform:translateY(-2px);box-shadow:inset 0 1px 0 #fff,inset 0 -1px 0 rgba(32,48,90,.08),0 26px 44px -30px rgba(32,48,90,.4)}
+.sb-top{display:flex;align-items:baseline;gap:8px}
+.sb-no{font-family:var(--font-num);font-size:11px;font-weight:700;color:var(--accent-ink);padding:1px 6px;border-radius:5px;background:rgba(29,92,240,.08)}
+.sb-top h3{font-size:13px;font-weight:600;line-height:1.35;flex:1;color:var(--ink)}
+.sb-n{display:flex;flex-direction:column;align-items:flex-end;line-height:1.1}
+.sb-n b{font-family:var(--font-num);font-size:24px;font-weight:650;letter-spacing:-.8px;color:var(--ink)}
+.sb-n small{font-size:10.5px;color:var(--ink-3)}
+.sb-n em{font-style:normal;font-size:10.5px;color:var(--accent-ink);font-weight:600}
+.sb-produces{font-size:11px;color:var(--ink-3);padding-bottom:8px;border-bottom:1px solid rgba(24,34,56,.07)}
+.sb-list{display:grid;gap:7px;flex:1}
+.sb-list li{display:flex;flex-direction:column;gap:1px;min-width:0}
+.sb-list li.none{font-size:12px;color:var(--ink-3)}
+.sb-t{font-size:12.5px;color:var(--ink);line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.sb-m{font-size:11px;color:var(--ink-3);display:flex;gap:5px;flex-wrap:wrap}
+.sb-m i{font-style:normal;color:var(--ink-4)}
+.sb-top h3{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.sb-foot{font-size:10.5px;color:var(--ink-3);padding-top:8px;border-top:1px solid rgba(24,34,56,.07)}
+
+/* 痛点 → 能力 */
+.pain-row{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;list-style:none;padding:0;margin:0}
+.pain-row a{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;padding:13px 14px;border-radius:14px;color:inherit;text-decoration:none;
+  background:rgba(255,255,255,.36);box-shadow:inset 0 1px 0 rgba(255,255,255,.9),inset 0 0 0 1px rgba(255,255,255,.6);transition:background .2s,transform .2s}
+.pain-row a:hover{background:rgba(255,255,255,.6);text-decoration:none;transform:translateY(-1px)}
+.pain-no{font-size:11px;font-weight:700;color:var(--ink-3);grid-row:1}
+.pain-p{font-size:12.5px;font-weight:600;color:var(--ink)}
+.pain-arrow{grid-column:1;color:var(--accent);font-size:13px;line-height:1.2}
+.pain-c{grid-column:2;font-size:11.5px;color:var(--ink-2);line-height:1.55}
+
+/* ---------- 5. 数字柔光(只用在"关键数字"上,文字本身仍是实色) ---------- */
+:root{
+  /* 柔光:低透明度、大半径,看起来是光晕不是描边;不改变字形对比 */
+  --num-glow:0 0 16px rgba(29,92,240,.16);
+  --num-soft:0 0 28px rgba(29,92,240,.22), 0 0 6px rgba(29,92,240,.08);
+  --num-plate:rgba(255,255,255,.46);
+}
+
+/* 浮层抽屉:三层里的第三层,最实、边光最强 */
+.drawer{
+  background:linear-gradient(160deg,rgba(255,255,255,.96),rgba(255,255,255,.88)) !important;
+  -webkit-backdrop-filter:blur(44px) saturate(200%) !important;backdrop-filter:blur(44px) saturate(200%) !important;
+  box-shadow:
+    inset 0 1px 0 #fff,
+    inset 0 -1px 0 rgba(32,48,90,.08),
+    0 1px 0 rgba(255,255,255,.7),
+    0 50px 100px -40px rgba(32,48,90,.5),
+    0 0 0 1px rgba(255,255,255,.4) !important;
+}
+
+/* toast */
+.toast{position:fixed;left:50%;bottom:26px;z-index:80;transform:translate(-50%,12px);opacity:0;pointer-events:none;
+  padding:10px 16px;border-radius:12px;font-size:13px;color:var(--ink);
+  background:rgba(255,255,255,.88);-webkit-backdrop-filter:var(--blur-3);backdrop-filter:var(--blur-3);
+  box-shadow:inset 0 1px 0 #fff,inset 0 0 0 1px rgba(255,255,255,.9),0 20px 40px -20px rgba(32,48,90,.45);
+  transition:opacity .22s,transform .22s}
+.toast.on{opacity:1;transform:translate(-50%,0)}
+.toast[data-kind=err]{color:var(--risk);box-shadow:inset 0 1px 0 #fff,inset 0 0 0 1px rgba(166,58,44,.22),0 20px 40px -20px rgba(32,48,90,.45)}
+
+/* ---------- 6. 光场 v4:更柔、更慢、低对比 ---------- */
+.ambient .b1{filter:blur(120px);opacity:.55;animation-duration:130s}
+.ambient .b2{filter:blur(130px);opacity:.6;animation-duration:160s}
+.ambient .b3{filter:blur(120px);opacity:.5;animation-duration:145s}
+.ambient .b4{filter:blur(130px);opacity:.55;animation-duration:170s}
+.ambient::after{
+  /* 环境光:整页一层极淡的冷暖渐变,让光"铺满"而不是只停在四个角 */
+  content:"";position:absolute;inset:0;
+  background:
+    radial-gradient(90% 70% at 50% 110%, rgba(255,236,220,.35), rgba(255,236,220,0) 60%),
+    radial-gradient(80% 60% at 100% 40%, rgba(220,228,255,.28), rgba(220,228,255,0) 65%);
+}
+
+/* ---------- 7. 响应式 ---------- */
+@media (max-width:1280px){
+  .sb-grid{grid-template-columns:repeat(3,minmax(0,1fr))}
+  .pain-row{grid-template-columns:repeat(3,minmax(0,1fr))}
+  .ogrid-3{grid-template-columns:repeat(2,minmax(0,1fr))}
+  .ogrid-3 .ocard:nth-child(3){grid-column:1 / -1}
+}
+@media (max-width:1180px){
+  .pool-hero{grid-template-columns:repeat(2,minmax(0,1fr))}
+  .wb-board{grid-template-columns:1fr}
+  .wb-me-body{grid-template-columns:1fr}
+  .wb-me-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
+  .sb-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
+}
+@media (max-width:768px){
+  .ogrid,.ogrid-3{grid-template-columns:1fr}
+  .ogrid-3 .ocard:nth-child(3){grid-column:auto}
+  .pool-hero{grid-template-columns:1fr 1fr}
+  .pool-num,.pool-best{grid-column:1 / -1}
+  .board-strip{grid-template-columns:1fr 1fr}
+  .sb-grid,.pain-row{grid-template-columns:1fr}
+  .wb-me-stats{grid-template-columns:1fr 1fr}
+  .matrix-l{display:none}
+  table.matrix th.matrix-l,table.matrix td.matrix-l{display:none}
+  .o-why{display:none}
+  .o-srow{grid-template-columns:56px 34px minmax(0,1fr);}
+  .o-srow .o-st{display:none}
+}
+@media (prefers-reduced-motion:reduce){
+  .ocard:hover,.stage-board:hover,.stuck:hover{transform:none}
+}
+@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
+  .ocard,.glass-3,.wb-me,.stage-board,.bs,.pool-hero{background:rgba(255,255,255,.94)}
+  .ambient{opacity:.55}
+}

+ 307 - 0
legacy/css/components.css

@@ -0,0 +1,307 @@
+/* ==========================================================================
+   组件(v3 · Liquid Glass)
+   规则:
+   - 玻璃只在"层"上(导航/面板/指标卡/浮层/表格容器);单条数据不各自成卡
+   - 所有光照从左上打;边缘 1px 渐变发丝边;阴影极浅且方向一致
+   - 唯一强调色 --accent 只出现于:当前选中、关键数字微光、可追溯操作
+   - 文字永远落在够对比的衬底上:长文阅读区用 --glass-solid
+   ========================================================================== */
+
+/* ---------- 指标卡:玻璃卡片,数字右对齐 + 微光 ---------- */
+.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
+.metric{
+  position:relative;overflow:hidden;padding:18px 20px 16px;border-radius:var(--r-lg);
+  background:linear-gradient(155deg,rgba(255,255,255,.58),rgba(255,255,255,.30));
+  -webkit-backdrop-filter:var(--blur-2);backdrop-filter:var(--blur-2);
+  border:1px solid rgba(255,255,255,.72);
+  box-shadow:var(--light-top),var(--lift-1);
+  color:inherit;text-decoration:none;
+  transition:background .2s ease,box-shadow .2s ease,transform .2s ease;
+}
+.metric::before{
+  /* 左上角一点"光的落点",呼应统一光源 */
+  content:"";position:absolute;inset:0;pointer-events:none;border-radius:inherit;
+  background:radial-gradient(120% 90% at 0% 0%,rgba(255,255,255,.75),rgba(255,255,255,0) 46%);
+}
+.metric > *{position:relative}
+.metric .k{font-size:12.5px;color:var(--ink-3);display:flex;align-items:center;gap:6px}
+.metric .v{font-family:var(--font-num);font-variant-numeric:tabular-nums;font-size:34px;font-weight:600;letter-spacing:-1px;line-height:1.05;margin-top:12px;text-align:right;color:var(--ink)}
+.metric .v small{font-size:12.5px;font-weight:500;color:var(--ink-3);letter-spacing:0;margin-left:5px}
+.metric .foot{margin-top:6px;font-size:12px;color:var(--ink-3);text-align:right;line-height:1.5}
+.metric .foot b{font-weight:600;color:var(--ink);font-variant-numeric:tabular-nums}
+.metric .foot em{font-style:normal;color:var(--accent-ink);font-weight:600;font-variant-numeric:tabular-nums}
+.metric.alert .foot b{color:var(--risk)}
+.metric .glow{color:var(--accent-ink);text-shadow:0 0 22px rgba(29,92,240,.25)}
+a.metric:hover{background:linear-gradient(155deg,rgba(255,255,255,.8),rgba(255,255,255,.5));box-shadow:var(--light-top),var(--lift-2);text-decoration:none}
+
+/* ---------- 筛选:一排玻璃胶囊,选中的胶囊亮起 ---------- */
+/* 吸顶并"贴住"顶栏:上方不圆角,滚动到位时与玻璃顶栏无缝相接 */
+.filters{
+  position:sticky;top:calc(var(--topbar) - 1px);z-index:15;
+  display:flex;flex-wrap:wrap;align-items:center;gap:6px 22px;
+  padding:8px 12px;margin:0 0 10px;border-radius:6px 6px 16px 16px;
+  background:rgba(255,255,255,.60);
+  -webkit-backdrop-filter:var(--blur-3);backdrop-filter:var(--blur-3);
+  border:1px solid rgba(255,255,255,.7);border-top:0;
+  box-shadow:0 14px 30px -24px rgba(32,48,90,.40);
+}
+.fgroup{display:flex;align-items:center;gap:2px;min-width:0;flex-wrap:wrap}
+.fgroup > .fl{font-size:11.5px;color:var(--ink-3);margin-right:6px;white-space:nowrap;letter-spacing:.2px}
+.chip{
+  height:28px;padding:0 10px;border-radius:9px;font-size:12.5px;color:var(--ink-2);
+  display:inline-flex;align-items:center;gap:6px;white-space:nowrap;border:1px solid transparent;
+  transition:background .15s,color .15s,box-shadow .15s,border-color .15s;
+}
+.chip:hover:not(:disabled){background:rgba(255,255,255,.55);color:var(--ink)}
+.chip .n{font-family:var(--font-num);font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--ink-3);font-weight:400}
+.chip[data-on="1"]{background:rgba(255,255,255,.94);color:var(--ink);border-color:rgba(255,255,255,1);box-shadow:var(--light-top),0 4px 12px -8px rgba(32,48,90,.4)}
+.chip[data-on="1"] .n{color:var(--accent-ink);font-weight:600}
+.chip.zero{color:var(--ink-4)}
+.chip.zero:not([data-on="1"]){opacity:.7}
+.fgroup.multi .chip[data-on="1"]{background:rgba(29,92,240,.10);color:var(--accent-ink);border-color:rgba(29,92,240,.22);box-shadow:var(--light-top)}
+.fgroup.multi .chip[data-on="1"] .n{color:var(--accent-ink)}
+.filters .spacer{flex:1}
+.result-note{font-size:12.5px;color:var(--ink-3);white-space:nowrap}
+.result-note b{color:var(--accent-ink);font-weight:600;font-variant-numeric:tabular-nums;margin:0 2px}
+.clear-all{font-size:12.5px;color:var(--ink-3);text-decoration:underline;text-underline-offset:3px;text-decoration-color:var(--rule-strong)}
+.clear-all:hover{color:var(--ink)}
+
+/* 已选条件回显 */
+.active-line{display:flex;flex-wrap:wrap;gap:6px 14px;align-items:center;margin:0 0 14px;font-size:12px;color:var(--ink-3)}
+.active-line .pill{display:inline-flex;align-items:center;gap:2px;color:var(--ink-2)}
+.active-line .pill button{width:18px;height:18px;border-radius:6px;display:grid;place-items:center;color:var(--ink-3)}
+.active-line .pill button:hover{background:rgba(24,34,56,.07);color:var(--ink)}
+.active-line .pill svg{width:10px;height:10px}
+
+/* ---------- 表格:无竖线,行间距 + 极淡底色交替 ---------- */
+.tbl-wrap{overflow-x:auto}
+table.tbl{width:100%;border-collapse:separate;border-spacing:0;font-size:13.5px}
+.tbl thead th{
+  text-align:left;font-weight:500;font-size:11.5px;color:var(--ink-3);letter-spacing:.3px;
+  padding:11px 14px;background:rgba(255,255,255,.55);border-bottom:1px solid var(--rule-strong);
+  white-space:nowrap;position:relative;
+}
+.tbl thead th.r{text-align:right}
+.tbl thead th.sortable{cursor:pointer;user-select:none}
+.tbl thead th.sortable:hover{color:var(--ink)}
+.tbl thead th .arr{opacity:.4;font-size:10px;margin-left:3px}
+.tbl thead th.sorted{color:var(--accent-ink)}
+.tbl thead th.sorted .arr{opacity:1}
+.tbl thead th:first-child{padding-left:22px;width:42%}
+.tbl thead th:last-child{padding-right:22px}
+.tbl tbody td{padding:13px 14px;border-bottom:1px solid var(--rule);vertical-align:middle;background:transparent}
+.tbl tbody tr:nth-child(even) td{background:rgba(255,255,255,.22)}
+.tbl tbody td:first-child{padding-left:22px}
+.tbl tbody td:last-child{padding-right:22px}
+.tbl tbody td.r{text-align:right}
+.tbl tbody tr{transition:background .12s}
+.tbl tbody tr.clickable{cursor:pointer}
+.tbl tbody tr:hover td{background:rgba(255,255,255,.62)}
+.tbl tbody tr:last-child td{border-bottom:0}
+.tbl .ttl{font-weight:500;color:var(--ink);display:block;line-height:1.45;letter-spacing:.1px}
+.tbl .sub{display:block;font-size:12px;color:var(--ink-3);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40ch}
+.tbl td.num-cell{font-family:var(--font-num);font-variant-numeric:tabular-nums;text-align:right;color:var(--ink);font-weight:500}
+.tbl tr.flash td{animation:flash 1.8s ease-out}
+@keyframes flash{0%{background:rgba(29,92,240,.12)}100%{background:transparent}}
+
+/* 人 */
+.cell-person{display:inline-flex;align-items:center;gap:8px;white-space:nowrap;max-width:100%}
+.cell-person .pn{overflow:hidden;text-overflow:ellipsis}
+.avatar{width:22px;height:22px;border-radius:50%;flex:none;display:grid;place-items:center;font-size:10.5px;font-weight:600;color:var(--ink-2);background:linear-gradient(150deg,rgba(255,255,255,.95),rgba(255,255,255,.6));box-shadow:inset 0 0 0 1px rgba(255,255,255,.9),0 1px 2px rgba(32,48,90,.08)}
+.cell-person .sub{display:block}
+
+/* 阶段进度 */
+.stage-cell{display:inline-flex;align-items:center;gap:9px;white-space:nowrap}
+.stage-cell .sn{font-family:var(--font-num);font-size:12.5px;color:var(--ink-2);font-weight:500;min-width:18px}
+.steps{display:inline-flex;gap:3px}
+.steps i{width:10px;height:3px;border-radius:2px;background:rgba(24,34,56,.12)}
+.steps i.on{background:var(--ink-3)}
+.steps i.cur{background:var(--accent);box-shadow:var(--accent-glow)}
+.stage-cell.stuck .sn::after{content:"卡住";margin-left:6px;font-size:10.5px;color:var(--warn)}
+
+/* 优先级 */
+.prio{display:inline-block;min-width:18px;font-size:12px;font-weight:600;font-family:var(--font-num);letter-spacing:.3px;color:var(--ink-3)}
+.prio-S{color:var(--accent-ink)}
+.prio-A{color:var(--ink-2)}
+.prio-B{color:var(--ink-3)}
+.prio-{color:var(--ink-4);font-weight:400}
+
+/* ---------- 层 / 证据 / 状态 ---------- */
+.layer{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;font-family:var(--font-num);letter-spacing:.2px;white-space:nowrap;color:var(--ink-2)}
+.layer::before{content:"";width:6px;height:6px;border-radius:2px;background:currentColor;opacity:.9}
+.layer-raw{color:var(--c-raw)}
+.layer-process{color:var(--c-process)}
+.layer-sediment{color:var(--c-sediment)}
+.layer-record{color:var(--c-record)}
+.layer-missing{color:var(--risk)}
+
+.ev{display:inline-flex;align-items:center;gap:6px;font-size:12px;white-space:nowrap;color:var(--ink-2)}
+.ev::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor;opacity:.85}
+.ev-verified{color:var(--ok)}
+.ev-supported{color:var(--ink-2)}
+.ev-speculative{color:var(--warn)}
+.ev-none{color:var(--ink-3)}
+.ev-none::before{background:transparent;box-shadow:inset 0 0 0 1px currentColor}
+
+.state{display:inline-flex;align-items:center;gap:7px;font-size:12px;color:var(--ink-2);white-space:nowrap}
+.state-active::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--accent)}
+.state-submitted::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ok)}
+.state-draft::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ink-4)}
+.state-parked::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--warn)}
+
+.pdot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:7px;vertical-align:1px}
+.pdot.active{background:var(--accent)}
+.pdot.done{background:var(--ok)}
+.pdot.idle{background:var(--ink-4)}
+.pdot.stuck{background:var(--warn)}
+
+.when{font-family:var(--font-num);font-variant-numeric:tabular-nums;font-size:12px;color:var(--ink-3);white-space:nowrap}
+
+/* ---------- 归纳卡 ---------- */
+.insight{
+  position:relative;padding:22px 24px 20px;
+  border-bottom:1px solid var(--rule);
+  display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 32px;
+  transition:background .18s;
+}
+.insight:last-child{border-bottom:0}
+.insight:hover{background:rgba(255,255,255,.28)}
+.insight h4{font-size:16px;font-weight:600;line-height:1.45;letter-spacing:.1px;color:var(--ink)}
+.insight h4 a{color:inherit}
+.insight h4 a:hover{color:var(--accent-ink);text-decoration:none}
+.insight .lead{font-size:12.5px;color:var(--ink-3);line-height:1.65;margin-top:5px;max-width:70ch}
+.insight .judge{font-size:13.5px;color:var(--ink);line-height:1.75;margin-top:12px;max-width:72ch}
+.insight .judge b{color:var(--accent-ink);font-weight:600;font-variant-numeric:tabular-nums;margin:0 1px}
+.insight .judge b.warn-txt{color:var(--warn)}
+.insight .meta{display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;margin-top:12px;font-size:12px;color:var(--ink-3)}
+.insight .strength{display:flex;flex-direction:column;align-items:flex-end;gap:4px;padding-top:2px;min-width:150px}
+.kpi-inline{display:flex;gap:22px;align-items:baseline}
+.kpi-inline > span{display:flex;flex-direction:column;align-items:flex-end;font-size:11.5px;color:var(--ink-3);line-height:1.3}
+.kpi-inline b{font-family:var(--font-num);font-variant-numeric:tabular-nums;font-size:22px;font-weight:600;color:var(--accent-ink);letter-spacing:-.4px;line-height:1.2;text-shadow:0 0 20px rgba(29,92,240,.18)}
+.kpi-inline .warn b{color:var(--warn);text-shadow:none}
+.kpi-inline .zero b{color:var(--ink-3);text-shadow:none}
+.kpi-inline .warn{color:var(--warn)}
+.gauge{display:flex;gap:3px;margin-top:8px}
+.gauge i{width:14px;height:3px;border-radius:2px;background:rgba(24,34,56,.10)}
+.gauge i.on{background:var(--accent);opacity:.9}
+.gauge-l{font-size:10.5px;color:var(--ink-3);margin-top:3px;letter-spacing:.2px}
+.insight-foot{grid-column:1/-1;display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-top:2px}
+.trace-btn{font-size:12.5px;color:var(--accent-ink);display:inline-flex;align-items:center;gap:5px;padding:0}
+.trace-btn svg{width:12px;height:12px;transition:transform .15s}
+.trace-btn:hover{text-decoration:underline;text-underline-offset:3px}
+.trace-btn:hover svg{transform:translateX(2px)}
+.una{font-size:11.5px;color:var(--warn);display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
+.una::before{content:"!";display:inline-grid;place-items:center;width:13px;height:13px;border-radius:50%;box-shadow:inset 0 0 0 1px currentColor;font-size:9px;font-weight:700;line-height:1}
+
+/* ---------- 详情页头 / 完整性 ---------- */
+.flat{
+  position:relative;background:var(--glass-2);
+  -webkit-backdrop-filter:var(--blur-2);backdrop-filter:var(--blur-2);
+  border:1px solid rgba(255,255,255,.68);border-radius:var(--r-lg);
+  box-shadow:var(--light-top),var(--lift-1);
+}
+.pair{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);gap:28px;align-items:start}
+.detail-head{padding:26px 28px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:24px}
+.detail-head h1{font-size:22px;font-weight:600;letter-spacing:-.2px;line-height:1.3}
+.detail-head .kicker{display:flex;gap:12px;align-items:center;margin-bottom:10px;font-size:12px;color:var(--ink-3)}
+.detail-head .kv{display:flex;flex-wrap:wrap;gap:10px 34px;margin-top:18px}
+.detail-head .kv div{font-size:11.5px;color:var(--ink-3)}
+.detail-head .kv b{display:block;font-size:14px;color:var(--ink);font-weight:500;margin-top:3px}
+.integrity{min-width:250px}
+.integrity .row{display:flex;justify-content:space-between;align-items:center;font-size:12.5px;padding:8px 0;border-bottom:1px solid var(--rule)}
+.integrity .row:last-child{border-bottom:0}
+.integrity .row span:first-child{color:var(--ink-3)}
+.integrity .row span:last-child{font-family:var(--font-num);font-variant-numeric:tabular-nums;color:var(--ink)}
+.integrity .row.bad span:last-child{color:var(--warn)}
+
+/* ---------- 抽屉 ---------- */
+.drawer-scrim{position:fixed;inset:0;background:rgba(24,34,56,.18);opacity:0;pointer-events:none;transition:opacity .25s;z-index:60;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}
+.drawer-scrim.open{opacity:1;pointer-events:auto}
+.drawer{
+  position:fixed;top:12px;right:12px;bottom:12px;width:min(620px,calc(100vw - 24px));z-index:61;
+  display:flex;flex-direction:column;
+  background:linear-gradient(160deg,rgba(255,255,255,.82),rgba(255,255,255,.68));
+  -webkit-backdrop-filter:var(--blur-3);backdrop-filter:var(--blur-3);
+  border:1px solid rgba(255,255,255,.85);border-radius:22px;
+  box-shadow:var(--light-top),var(--lift-3);
+  transform:translateX(calc(100% + 30px));transition:transform .34s cubic-bezier(.2,.8,.2,1);
+  overflow:hidden;outline:none;
+}
+.drawer.open{transform:none}
+.drawer-h{display:flex;align-items:flex-start;gap:12px;padding:22px 24px 16px;border-bottom:1px solid rgba(255,255,255,.7);box-shadow:0 1px 0 var(--rule)}
+.drawer-h .kicker{font-size:12px;color:var(--ink-3);margin-bottom:8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
+.drawer-h h3{font-size:18px;font-weight:600;line-height:1.4;letter-spacing:-.1px}
+.drawer-h .x{margin-left:auto;width:30px;height:30px;border-radius:10px;display:grid;place-items:center;background:rgba(255,255,255,.7);box-shadow:var(--light-top),inset 0 0 0 1px rgba(255,255,255,.9);flex:none;color:var(--ink-2)}
+.drawer-h .x:hover{background:#fff}
+.drawer-h .x svg{width:14px;height:14px}
+.drawer-b{overflow:auto;padding:20px 24px 28px;flex:1}
+.drawer-f{padding:12px 24px;border-top:1px solid var(--rule);display:flex;gap:10px;align-items:center;justify-content:flex-end;background:rgba(255,255,255,.45)}
+
+.block{margin-bottom:24px}
+.block > .bl{font-size:11.5px;color:var(--ink-3);letter-spacing:.3px;margin-bottom:10px;display:flex;align-items:center;gap:8px}
+.block > .bl .n{font-family:var(--font-num);color:var(--ink-3)}
+.prose{font-size:13.5px;color:var(--ink);line-height:1.8;white-space:pre-wrap;word-break:break-word}
+.quote{padding:12px 14px;border-radius:12px;background:rgba(255,255,255,.68);box-shadow:inset 0 0 0 1px rgba(255,255,255,.9);font-size:13px;line-height:1.7;color:var(--ink-2);margin-bottom:8px}
+.quote .who{font-size:11.5px;color:var(--ink-3);margin-top:6px;word-break:break-all}
+.kv-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 20px}
+.kv-grid .k{font-size:11.5px;color:var(--ink-3);margin-bottom:3px}
+.kv-grid .v{font-size:13px;color:var(--ink);word-break:break-word}
+
+/* 追溯列表 */
+.trace-item{display:grid;grid-template-columns:46px minmax(0,1fr) auto;gap:12px;align-items:start;padding:14px 0;border-bottom:1px solid var(--rule)}
+.trace-item:last-child{border-bottom:0}
+.trace-item .idx{font-family:var(--font-num);font-variant-numeric:tabular-nums;font-size:12px;color:var(--ink-3);padding-top:2px}
+.trace-item .t{font-size:13.5px;font-weight:500;line-height:1.45;color:var(--ink);text-align:left}
+a.trace-item{color:inherit;text-decoration:none;display:grid}
+a.trace-item:hover{text-decoration:none;background:rgba(255,255,255,.3)}
+.trace-item .t:hover{color:var(--accent-ink);text-decoration:underline;text-underline-offset:3px}
+.trace-item .m{display:flex;flex-wrap:wrap;gap:4px 12px;align-items:center;margin-top:6px;font-size:11.5px;color:var(--ink-3)}
+.trace-item .evi{font-size:12.5px;color:var(--ink-2);margin-top:8px;line-height:1.65;padding-left:10px;box-shadow:inset 2px 0 0 var(--rule-strong)}
+.trace-src{display:flex;gap:10px;align-items:flex-start;padding:10px 0;border-bottom:1px dashed var(--rule)}
+.trace-src:last-child{border-bottom:0}
+
+/* ---------- 空态 ---------- */
+.empty{display:grid;place-items:center;text-align:center;padding:64px 24px}
+.empty .art{width:150px;height:96px;margin-bottom:22px;position:relative}
+.empty .art .g1,.empty .art .g2{position:absolute;border-radius:14px;border:1px solid rgba(255,255,255,.8);background:rgba(255,255,255,.35);box-shadow:var(--light-top),0 10px 24px -18px rgba(32,48,90,.4);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
+.empty .art .g1{inset:22px 44px 0 0;transform:rotate(-2deg)}
+.empty .art .g2{inset:0 0 22px 44px;background:rgba(255,255,255,.55);transform:rotate(1.5deg)}
+.empty .art .ln{position:absolute;height:4px;border-radius:2px;background:rgba(24,34,56,.10);z-index:1}
+.empty .art .ln.a{left:56px;right:18px;top:40px}
+.empty .art .ln.b{left:56px;right:34px;top:51px}
+.empty .art .dotc{position:absolute;width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:var(--accent-glow);right:14px;top:12px;z-index:1}
+.empty h4{font-size:14.5px;font-weight:600;color:var(--ink)}
+.empty p{font-size:13px;color:var(--ink-3);margin-top:8px;max-width:52ch;line-height:1.75}
+.empty .acts{margin-top:20px;display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
+.empty.sm{padding:34px 20px}
+.empty.sm .art{transform:scale(.78);margin-bottom:4px}
+
+/* ---------- 骨架:和真实内容同高,加载不跳动 ---------- */
+.skel{position:relative;overflow:hidden;background:rgba(24,34,56,.07);border-radius:7px}
+.skel::after{content:"";position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(90deg,transparent,rgba(255,255,255,.8),transparent);animation:sheen 1.5s ease-in-out infinite}
+@keyframes sheen{100%{transform:translateX(100%)}}
+.skel-line{height:12px}
+.skel-line.w40{width:40%}.skel-line.w60{width:60%}.skel-line.w80{width:80%}
+.skel-num{height:34px;width:72px;border-radius:9px}
+
+/* ---------- 按钮 / 分段 ---------- */
+.btn{height:32px;padding:0 13px;border-radius:10px;font-size:13px;display:inline-flex;align-items:center;gap:6px;border:1px solid rgba(255,255,255,.8);background:linear-gradient(160deg,rgba(255,255,255,.85),rgba(255,255,255,.55));color:var(--ink);box-shadow:var(--light-top),0 4px 12px -10px rgba(32,48,90,.4);transition:background .15s;text-decoration:none}
+.btn:hover{background:#fff;text-decoration:none}
+.btn svg{width:14px;height:14px}
+.btn-accent{background:var(--accent);color:#fff;border-color:transparent;box-shadow:0 1px 2px rgba(23,71,189,.35),var(--accent-glow)}
+.btn-accent:hover{background:#1a53da}
+.btn-ghost{background:transparent;border-color:transparent;box-shadow:none;color:var(--ink-3)}
+.btn-ghost:hover{background:rgba(255,255,255,.55);color:var(--ink)}
+
+.seg{display:inline-flex;padding:3px;border-radius:11px;background:rgba(255,255,255,.38);box-shadow:inset 0 0 0 1px rgba(255,255,255,.7)}
+.seg button{height:26px;padding:0 12px;border-radius:8px;font-size:12.5px;color:var(--ink-3);transition:background .15s,color .15s}
+.seg button:hover{color:var(--ink)}
+.seg button.on{background:rgba(255,255,255,.95);color:var(--ink);box-shadow:var(--light-top),0 2px 6px -4px rgba(32,48,90,.35)}
+
+.note{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border-radius:14px;background:rgba(255,255,255,.45);box-shadow:inset 0 0 0 1px rgba(255,255,255,.8),var(--light-top);font-size:12.5px;color:var(--ink-2);line-height:1.7}
+.note .ic,.note svg{flex:none;width:16px;height:16px;color:var(--ink-3);margin-top:1px}
+.note code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;background:rgba(24,34,56,.06);padding:1px 5px;border-radius:5px}
+
+.error-box{padding:22px;border-radius:var(--r-lg);border:1px solid rgba(255,255,255,.8);background:rgba(255,255,255,.6);-webkit-backdrop-filter:var(--blur-2);backdrop-filter:var(--blur-2);box-shadow:var(--light-top),var(--lift-1);font-size:13.5px;color:var(--ink-2);line-height:1.7}
+.error-box b{color:var(--ink)}
+.error-box code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;background:rgba(24,34,56,.06);padding:1px 5px;border-radius:5px}

+ 346 - 0
legacy/css/report.css

@@ -0,0 +1,346 @@
+/* ==========================================================================
+   报告阅读页(v3)
+   一份报告 = 一张"纸"(更实的玻璃衬底,保证万字长文清晰)+ 周围的玻璃浮层(目录/封面侧栏)。
+   光照仍然只有一种:左上来。表头、封面是"局部实底",正文字色只落在 --ink 上。
+   ========================================================================== */
+
+.report{position:relative}
+body.in-report .search input{width:220px}
+
+/* ---------- 顶部返回条 ---------- */
+.r-topbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:-6px 0 16px;font-size:13px}
+.r-topbar-note{margin-left:auto;font-size:12px;color:var(--ink-3)}
+
+/* ---------- 目录(吸顶玻璃条) ---------- */
+/* 目录条吸顶后贴住顶栏:与筛选条同一套"玻璃顶栏 + 下方玻璃条"的处理 */
+.r-toc{
+  position:sticky;top:calc(var(--topbar) - 1px);z-index:14;
+  display:flex;align-items:center;gap:4px;
+  margin:0 0 18px;padding:7px 8px;border-radius:6px 6px 16px 16px;
+  background:rgba(255,255,255,.50);
+  -webkit-backdrop-filter:var(--blur-2);backdrop-filter:var(--blur-2);
+  border:1px solid rgba(255,255,255,.7);
+  box-shadow:var(--light-top),0 10px 30px -24px rgba(32,48,90,.35);
+  overflow-x:auto;scrollbar-width:none;
+}
+.r-toc::-webkit-scrollbar{display:none}
+.r-toc ol{display:flex;align-items:center;gap:2px;min-width:0}
+.r-toc a{
+  display:inline-flex;align-items:center;gap:7px;white-space:nowrap;
+  height:30px;padding:0 11px;border-radius:10px;color:var(--ink-2);font-size:12.5px;
+  border:1px solid transparent;transition:background .15s,color .15s,box-shadow .15s;
+}
+.r-toc a:hover{background:rgba(255,255,255,.6);color:var(--ink);text-decoration:none}
+.r-toc a.on{
+  background:rgba(255,255,255,.96);color:var(--ink);border-color:rgba(255,255,255,1);
+  box-shadow:var(--light-top),0 4px 14px -10px rgba(32,48,90,.45);
+}
+.r-toc a.on .r-toc-no{color:var(--accent-ink)}
+.r-toc-cover{font-weight:500}
+.r-toc-no{font-size:11.5px;color:var(--ink-3);font-weight:600}
+.r-toc-s{font-size:10.5px;color:var(--ink-3);padding:1px 5px;border-radius:5px;box-shadow:inset 0 0 0 1px var(--rule-strong)}
+.r-toc a.on .r-toc-s{color:var(--accent-ink);box-shadow:inset 0 0 0 1px var(--accent-line)}
+
+/* ---------- 纸:正文衬底 ---------- */
+.r-paper{
+  position:relative;max-width:1080px;margin:0 auto;padding:clamp(18px,3vw,40px) clamp(16px,3.4vw,48px) 40px;
+  border-radius:26px;
+  background:linear-gradient(170deg,rgba(255,255,255,.78),rgba(255,255,255,.66) 40%,rgba(255,255,255,.62));
+  -webkit-backdrop-filter:var(--blur-3);backdrop-filter:var(--blur-3);
+  border:1px solid rgba(255,255,255,.85);
+  box-shadow:var(--light-top),var(--lift-3);
+  color:var(--ink);
+}
+.r-paper::before{
+  /* 左上角天光落点:呼应全站光源;极淡 */
+  content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;
+  background:radial-gradient(70% 40% at 0% 0%,rgba(255,255,255,.7),rgba(255,255,255,0) 60%);
+}
+.r-paper > *{position:relative}
+
+/* ---------- 封面 ---------- */
+.r-cover{display:grid;grid-template-columns:minmax(0,1fr) 268px;gap:clamp(20px,3vw,40px);align-items:start;padding:6px 0 30px;border-bottom:1px solid var(--rule)}
+.r-kicker{display:flex;align-items:center;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--ink-3)}
+.r-kicker .r-owner{margin-left:auto}
+.r-cover h1{font-size:clamp(24px,3vw,34px);line-height:1.25;letter-spacing:-.6px;font-weight:650;margin-top:14px}
+.r-sub{font-size:15px;color:var(--ink-2);margin-top:8px;line-height:1.6}
+.r-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px 24px;margin-top:22px}
+.r-meta dt{font-size:11px;color:var(--ink-3);letter-spacing:.4px}
+.r-meta dd{margin:3px 0 0;font-size:13px;color:var(--ink);line-height:1.55}
+
+/* 数据管线:2727 → 1190 → 730,数字大、单位小,箭头淡 */
+.r-pipe{display:flex;align-items:center;flex-wrap:wrap;gap:6px 10px;margin-top:24px;padding:14px 16px;border-radius:14px;
+  background:rgba(255,255,255,.5);box-shadow:inset 0 0 0 1px rgba(255,255,255,.85),var(--light-top)}
+.r-pipe-step{display:inline-flex;align-items:baseline;gap:5px}
+.r-pipe-step b{font-size:22px;font-weight:600;letter-spacing:-.5px;color:var(--ink)}
+.r-pipe-step b.glow,.r-pipe-step:last-of-type b{color:var(--accent-ink);text-shadow:0 0 20px rgba(29,92,240,.22)}
+.r-pipe-step span{font-size:12px;color:var(--ink-3)}
+.r-pipe-arrow{color:var(--ink-3);font-size:13px}
+.r-pipe-rate{margin-left:auto;font-size:12px;color:var(--ink-2);padding:2px 9px;border-radius:99px;background:var(--accent-wash);box-shadow:inset 0 0 0 1px var(--accent-line)}
+
+/* 封面右侧:证据链完整度(玻璃小卡片,L2) */
+.r-cover-side{
+  padding:16px 18px;border-radius:18px;
+  background:linear-gradient(155deg,rgba(255,255,255,.7),rgba(255,255,255,.42));
+  -webkit-backdrop-filter:var(--blur-2);backdrop-filter:var(--blur-2);
+  border:1px solid rgba(255,255,255,.9);box-shadow:var(--light-top),var(--lift-1);
+}
+.r-side-t{font-size:11.5px;color:var(--ink-3);letter-spacing:.3px;margin-bottom:8px}
+.r-side-row{display:flex;justify-content:space-between;align-items:baseline;font-size:12.5px;color:var(--ink-2);padding:7px 0;border-bottom:1px solid var(--rule)}
+.r-side-row b{font-size:14px;color:var(--ink)}
+.r-side-row.warn b{color:var(--warn)}
+.r-side-link{display:inline-flex;align-items:center;gap:6px;margin-top:12px;font-size:12.5px}
+.r-side-link svg{width:14px;height:14px}
+
+/* ---------- 结构总览 ---------- */
+.r-block{margin:34px 0}
+.r-block > h2{font-size:15px;font-weight:600;display:flex;align-items:baseline;gap:10px}
+.r-block > h2 span{font-size:12px;color:var(--ink-3);font-weight:400}
+.r-structure-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:14px}
+.r-ch-card{
+  display:flex;flex-direction:column;gap:6px;padding:15px 16px;border-radius:16px;color:inherit;text-decoration:none;
+  background:linear-gradient(155deg,rgba(255,255,255,.62),rgba(255,255,255,.34));
+  -webkit-backdrop-filter:var(--blur-2);backdrop-filter:var(--blur-2);
+  border:1px solid rgba(255,255,255,.85);box-shadow:var(--light-top),var(--lift-1);
+  transition:background .18s,transform .18s,box-shadow .18s;
+}
+.r-ch-card:hover{background:rgba(255,255,255,.85);text-decoration:none;transform:translateY(-1px);box-shadow:var(--light-top),var(--lift-2)}
+.r-ch-no{font-size:11px;color:var(--ink-3);font-family:var(--font-num);letter-spacing:.3px}
+.r-ch-card b{font-size:14.5px;color:var(--ink)}
+.r-ch-card span:last-child{font-size:12px;color:var(--ink-3);line-height:1.55}
+
+/* 执行摘要:四句话,编号用细线 */
+.r-exec ol{border-top:1px solid var(--rule-strong);margin-top:12px}
+.r-exec li{display:grid;grid-template-columns:40px minmax(0,1fr);gap:10px;padding:13px 0;border-bottom:1px solid var(--rule)}
+.r-exec-no{font-size:12px;color:var(--accent-ink);padding-top:3px}
+.r-exec p{font-size:14.5px;line-height:1.75;color:var(--ink)}
+
+/* KPI 组:大数字 + 小单位 + 一行解读 */
+.r-kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:6px 0 8px}
+.r-kpi{
+  padding:16px 18px;border-radius:16px;
+  background:linear-gradient(155deg,rgba(255,255,255,.64),rgba(255,255,255,.36));
+  -webkit-backdrop-filter:var(--blur-2);backdrop-filter:var(--blur-2);
+  border:1px solid rgba(255,255,255,.88);box-shadow:var(--light-top),var(--lift-1);
+}
+.r-kpi-v{text-align:right;font-family:var(--font-num)}
+.r-kpi-v span{font-size:32px;font-weight:600;letter-spacing:-1px;line-height:1;color:var(--ink)}
+.r-kpi-v small{font-size:12.5px;color:var(--ink-3);margin-left:4px}
+.r-kpi-l{font-size:12.5px;color:var(--ink-2);margin-top:8px;font-weight:500}
+.r-kpi-n{font-size:12px;color:var(--ink-3);margin-top:3px;line-height:1.55}
+
+/* ---------- 章节 ---------- */
+.r-chapter{padding:40px 0 10px;border-top:1px solid var(--rule);margin-top:28px;scroll-margin-top:120px}
+.r-chapter:first-of-type{margin-top:10px}
+.r-ch-head{max-width:74ch}
+.r-ch-kicker{display:flex;gap:12px;align-items:center;font-size:11.5px;color:var(--accent-ink);letter-spacing:.8px;font-family:var(--font-num)}
+.r-ch-en{color:var(--ink-3);letter-spacing:.6px}
+.r-ch-head h2{font-size:clamp(22px,2.4vw,28px);font-weight:650;letter-spacing:-.4px;line-height:1.25;margin-top:8px}
+.r-ch-sub{font-size:13.5px;color:var(--ink-2);margin-top:6px}
+/* 章首导语:本章在回答什么问题——左侧一道极淡的强调线 */
+.r-ch-lead{
+  margin:18px 0 6px;padding:4px 0 4px 16px;max-width:74ch;
+  font-size:15.5px;line-height:1.85;color:var(--ink);
+  box-shadow:inset 2px 0 0 var(--accent-line);
+}
+.r-sec{margin:30px 0}
+.r-sec-h{font-size:15px;font-weight:600;margin-bottom:12px;display:flex;gap:10px;align-items:baseline;color:var(--ink)}
+.r-sec-h .num{font-size:12.5px;color:var(--ink-3);font-weight:500;min-width:26px}
+.r-sec-note{font-size:12px;color:var(--ink-3);margin-top:10px;line-height:1.7}
+.r-para{font-size:15px;line-height:1.9;color:var(--ink);max-width:74ch}
+.r-muted{color:var(--ink-3)}
+
+/* ---------- 通用表:无竖线,交替极淡底色 ---------- */
+.r-tbl-wrap{overflow-x:auto;border-radius:14px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.7)}
+.r-tbl{width:100%;border-collapse:separate;border-spacing:0;font-size:13.5px;line-height:1.65;min-width:560px}
+.r-tbl th{
+  text-align:left;font-weight:500;font-size:11.5px;color:var(--ink-3);letter-spacing:.3px;
+  padding:11px 14px;background:rgba(255,255,255,.62);border-bottom:1px solid var(--rule-strong);white-space:nowrap;
+}
+.r-tbl th.r{text-align:right}
+.r-tbl td{padding:12px 14px;border-bottom:1px solid var(--rule);vertical-align:top;color:var(--ink)}
+.r-tbl tbody tr:nth-child(even) td{background:rgba(255,255,255,.32)}
+.r-tbl tbody tr:hover td{background:rgba(255,255,255,.55)}
+.r-tbl tbody tr:last-child td{border-bottom:0}
+.r-tbl td.r{text-align:right}
+.r-tbl td.num{font-family:var(--font-num);font-variant-numeric:tabular-nums;white-space:nowrap}
+.r-tbl td.r-td-k{font-weight:500;color:var(--ink);white-space:nowrap}
+.r-tbl td.warn{color:var(--warn);font-weight:600}
+.r-fit-flag{white-space:nowrap;font-weight:500}
+.r-fit-risk{color:var(--risk);font-weight:500;white-space:nowrap}
+
+/* 交叉表里的产品占比:细条 + 百分比 */
+.r-td-pick{min-width:200px}
+.r-pick{display:grid;grid-template-columns:96px minmax(60px,1fr) 42px;gap:8px;align-items:center;font-size:12.5px}
+.r-pick + .r-pick{margin-top:5px}
+.r-pick-n{color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.r-mini{height:4px;border-radius:2px;background:rgba(24,34,56,.08);overflow:hidden}
+.r-mini i{display:block;height:100%;border-radius:2px;background:linear-gradient(90deg,rgba(29,92,240,.55),rgba(29,92,240,.85))}
+.r-pick b{font-family:var(--font-num);font-size:12px;color:var(--ink-2);text-align:right;font-weight:500}
+
+/* ---------- 漏斗 ---------- */
+.r-funnel{border-top:1px solid var(--rule-strong)}
+.r-funnel li{display:grid;grid-template-columns:56px minmax(0,1fr) 120px;gap:16px;align-items:center;padding:16px 0;border-bottom:1px solid var(--rule)}
+.r-f-stage{font-family:var(--font-num);font-size:12px;color:var(--accent-ink);padding:2px 0}
+.r-f-head{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}
+.r-f-head b{font-size:14.5px;font-weight:600}
+.r-f-scope{font-size:12.5px;color:var(--ink-3);line-height:1.5}
+.r-f-bar{height:6px;border-radius:3px;background:rgba(24,34,56,.06);margin-top:10px;overflow:hidden}
+.r-f-bar i{display:block;height:100%;border-radius:3px;background:linear-gradient(90deg,rgba(29,92,240,.35),rgba(29,92,240,.7));box-shadow:0 0 18px rgba(29,92,240,.25)}
+.r-f-note{font-size:12px;color:var(--ink-3);margin-top:8px;line-height:1.6}
+.r-f-v{text-align:right;font-family:var(--font-num)}
+.r-f-v b{font-size:26px;font-weight:600;letter-spacing:-.8px;color:var(--ink)}
+.r-f-v small{font-size:12px;color:var(--ink-3);margin-left:3px}
+
+/* ---------- 结论块:带态度 ---------- */
+.r-findings{display:grid;gap:12px}
+.r-finding{
+  position:relative;padding:18px 20px 16px 24px;border-radius:16px;
+  background:linear-gradient(155deg,rgba(255,255,255,.7),rgba(255,255,255,.42));
+  -webkit-backdrop-filter:var(--blur-2);backdrop-filter:var(--blur-2);
+  border:1px solid rgba(255,255,255,.9);box-shadow:var(--light-top),var(--lift-1);
+}
+.r-f-mark{position:absolute;left:0;top:16px;bottom:16px;width:3px;border-radius:3px;background:linear-gradient(180deg,var(--accent),rgba(29,92,240,.25))}
+.r-finding h4{font-size:16px;font-weight:650;line-height:1.45;color:var(--ink)}
+.r-finding p{font-size:14px;line-height:1.85;color:var(--ink-2);margin-top:8px;max-width:70ch}
+.r-finding-ev{display:flex;align-items:center;flex-wrap:wrap;gap:6px 18px;margin-top:12px;font-size:12px;color:var(--ink-3)}
+.r-finding-ev > span{display:inline-flex;align-items:baseline;gap:4px}
+.r-finding-ev b{font-size:16px;color:var(--ink);font-weight:600}
+.r-finding-ev .warn b,.r-finding-ev .warn{color:var(--warn)}
+
+/* 证据锚点按钮 */
+.r-anchor{
+  display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 9px;border-radius:8px;
+  font-size:12px;color:var(--accent-ink);white-space:nowrap;vertical-align:middle;
+  background:var(--accent-wash);box-shadow:inset 0 0 0 1px rgba(29,92,240,.18);
+  transition:background .15s,box-shadow .15s;
+}
+.r-anchor:hover{background:rgba(29,92,240,.15);box-shadow:inset 0 0 0 1px rgba(29,92,240,.3)}
+.r-anchor svg{width:12px;height:12px}
+.r-anchor b{font-family:var(--font-num);font-weight:600}
+
+/* ---------- 两栏对照 ---------- */
+.r-two{display:grid;grid-template-columns:1fr 1fr;gap:12px}
+.r-two-col{padding:16px 18px;border-radius:16px;background:rgba(255,255,255,.5);box-shadow:inset 0 0 0 1px rgba(255,255,255,.85),var(--light-top)}
+.r-two-col h5{font-size:12.5px;font-weight:600;margin-bottom:8px;letter-spacing:.2px}
+.r-two-col.yes h5{color:var(--ok)}
+.r-two-col.no h5{color:var(--risk)}
+.r-two-col ul{display:grid;gap:8px}
+.r-two-col li{font-size:13.5px;line-height:1.7;color:var(--ink-2);padding-left:14px;position:relative}
+.r-two-col li::before{content:"";position:absolute;left:0;top:.65em;width:5px;height:5px;border-radius:50%;background:currentColor;opacity:.4}
+.r-two-col.no li::before{background:var(--risk);opacity:.6}
+
+/* ---------- 机会评分 ---------- */
+.r-score-bar{position:relative;display:block;min-width:120px;height:22px;border-radius:6px;background:rgba(24,34,56,.06);overflow:hidden}
+.r-score-bar i{display:block;height:100%;background:linear-gradient(90deg,rgba(29,92,240,.3),rgba(29,92,240,.65))}
+.r-score-bar b{position:absolute;right:8px;top:50%;transform:translateY(-50%);font-size:12.5px;color:var(--ink)}
+
+/* ---------- 心智阶梯 ---------- */
+.r-ladder{border-left:1px solid var(--rule-strong);margin:0 0 18px 6px;display:grid;gap:0}
+.r-ladder li{display:grid;grid-template-columns:34px minmax(0,180px) minmax(0,1fr);gap:12px;align-items:baseline;padding:10px 0 10px 16px;position:relative}
+.r-ladder li::before{content:"";position:absolute;left:-4px;top:50%;width:7px;height:7px;border-radius:50%;background:rgba(24,34,56,.25);transform:translateY(-50%)}
+.r-ladder li.gap::before{background:var(--accent);box-shadow:var(--accent-glow)}
+.r-l-no{font-size:12px;color:var(--ink-3)}
+.r-l-name{font-size:14.5px;font-weight:600;color:var(--ink)}
+.r-ladder li.gap .r-l-name{color:var(--accent-ink)}
+.r-l-note{font-size:13px;color:var(--ink-2);line-height:1.6}
+
+/* ---------- 定位陈述:像一句能印在盒子上话 ---------- */
+.r-stmt{margin-top:18px;padding:26px 26px 22px;border-radius:18px;text-align:left;
+  background:linear-gradient(150deg,rgba(29,92,240,.10),rgba(29,92,240,.04) 60%,rgba(255,255,255,.2));
+  box-shadow:inset 0 0 0 1px rgba(255,255,255,.85),var(--light-top),0 20px 50px -40px rgba(29,92,240,.6);
+}
+.r-stmt-k{font-size:12px;color:var(--ink-3)}
+.r-stmt-v{font-size:clamp(20px,2.4vw,26px);font-weight:650;letter-spacing:-.3px;line-height:1.45;color:var(--ink);margin-top:10px}
+.r-stmt-alt{display:flex;flex-wrap:wrap;gap:8px 18px;margin-top:14px;font-size:12.5px;color:var(--ink-3)}
+.r-stmt-mini{padding:16px 18px;border-radius:14px;background:rgba(255,255,255,.45);box-shadow:inset 0 0 0 1px rgba(255,255,255,.8)}
+.r-stmt-mini .r-stmt-v{font-size:20px;margin-top:0}
+.r-stmt-mini .r-muted{font-size:12.5px;margin-top:8px}
+
+/* ---------- 验证里程碑 ---------- */
+.r-miles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
+.r-mile{padding:16px 18px;border-radius:16px;background:rgba(255,255,255,.5);box-shadow:inset 0 0 0 1px rgba(255,255,255,.85),var(--light-top)}
+.r-mile-m{font-family:var(--font-num);font-size:12px;color:var(--accent-ink);font-weight:600}
+.r-mile h5{font-size:14.5px;margin-top:4px;font-weight:600}
+.r-mile-checks{margin-top:10px;display:grid;gap:5px}
+.r-mile-checks li{font-size:13px;color:var(--ink-2);line-height:1.65;padding-left:12px;position:relative}
+.r-mile-checks li::before{content:"";position:absolute;left:0;top:.7em;width:4px;height:4px;border-radius:50%;background:var(--ink-3)}
+.r-mile-rule{margin-top:12px;padding-top:10px;border-top:1px solid var(--rule);font-size:12px;line-height:1.65;color:var(--ink-3)}
+.r-mile-rule span{display:block;font-size:11px;color:var(--ink-3);margin-bottom:2px}
+
+/* ---------- 未知结构:不静默 ---------- */
+.r-unknown{padding:14px 16px;border-radius:12px;background:var(--warn-wash);box-shadow:inset 0 0 0 1px rgba(138,90,0,.18)}
+.r-unknown dl{display:grid;gap:6px;margin-top:8px}
+.r-unknown dl > div{display:grid;grid-template-columns:140px minmax(0,1fr);gap:10px;font-size:12.5px}
+.r-unknown dt{color:var(--ink-3)}
+.r-unknown dd{margin:0;color:var(--ink);word-break:break-word}
+
+/* ---------- 引用 / 附录 ---------- */
+.r-cite{display:inline-flex;flex-direction:column;font-size:12.5px;line-height:1.4;color:var(--accent-ink)}
+.r-cite .who{font-size:11px;color:var(--ink-3)}
+.r-cite-missing{font-size:12px;color:var(--warn);white-space:nowrap}
+.r-linkbtn{text-align:left;color:var(--ink);font-weight:500;font-size:13.5px;line-height:1.5}
+.r-linkbtn:hover{color:var(--accent-ink);text-decoration:underline;text-underline-offset:3px}
+.r-drawer-claim{font-size:15.5px;font-weight:600;line-height:1.6;color:var(--ink)}
+.warn-txt{color:var(--warn)}
+
+.r-foot{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-top:40px;padding-top:18px;border-top:1px solid var(--rule-strong);font-size:12px;color:var(--ink-3)}
+
+/* ---------- 报告目录页 ---------- */
+.r-index{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:14px}
+.r-index-card{
+  display:flex;flex-direction:column;gap:10px;padding:20px 22px 18px;border-radius:20px;color:inherit;text-decoration:none;
+  background:linear-gradient(155deg,rgba(255,255,255,.62),rgba(255,255,255,.32));
+  -webkit-backdrop-filter:var(--blur-2);backdrop-filter:var(--blur-2);
+  border:1px solid rgba(255,255,255,.85);box-shadow:var(--light-top),var(--lift-1);
+  transition:transform .18s,box-shadow .18s,background .18s;
+}
+.r-index-card:hover{transform:translateY(-2px);box-shadow:var(--light-top),var(--lift-2);background:linear-gradient(155deg,rgba(255,255,255,.8),rgba(255,255,255,.5));text-decoration:none}
+.r-ic-top{display:flex;align-items:center;justify-content:space-between;font-size:11.5px;color:var(--ink-3)}
+.r-ic-kicker{font-family:var(--font-num);letter-spacing:.3px}
+.r-index-card h3{font-size:17px;font-weight:650;line-height:1.4;letter-spacing:-.1px}
+.r-ic-sub{font-size:12.5px;color:var(--ink-3);line-height:1.65;min-height:2.6em}
+.r-ic-stats{display:flex;gap:18px;font-size:12px;color:var(--ink-3);padding-top:10px;border-top:1px solid var(--rule)}
+.r-ic-stats b{font-family:var(--font-num);font-size:15px;color:var(--ink);margin-right:3px}
+.r-ic-foot{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--ink-3)}
+.r-ic-proj{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.r-ic-go{margin-left:auto;color:var(--accent-ink)}
+.r-ic-go svg{width:14px;height:14px}
+.r-count{font-size:12.5px;color:var(--ink-3)}
+.r-count b{color:var(--accent-ink);font-family:var(--font-num);font-size:15px}
+
+/* ---------- 台账 ↔ 报告 的交叉标记 ---------- */
+.r-inline-link{color:var(--ink);text-decoration:none}
+.r-inline-link:hover{color:var(--accent-ink);text-decoration:underline;text-underline-offset:3px}
+.r-dot{display:inline-block;margin-right:6px;padding:0 5px;border-radius:5px;font-size:10.5px;line-height:16px;color:var(--accent-ink);background:var(--accent-wash);box-shadow:inset 0 0 0 1px rgba(29,92,240,.18);vertical-align:1px}
+.tbl tbody tr.has-report .ttl{font-weight:600}
+.tbl tbody tr.focus-on td{animation:focusRow 2.2s ease-out}
+@keyframes focusRow{0%,35%{background:rgba(29,92,240,.14)}100%{background:transparent}}
+
+/* 归纳页:展开"归纳自哪几份报告" */
+/* [hidden] 必须压过 .insight-sources 的 display,否则"收起"状态仍会显示 */
+.insight-sources[hidden]{display:none !important}
+.insight-sources{grid-column:1/-1;margin-top:10px;padding:12px 14px;border-radius:14px;background:rgba(255,255,255,.42);box-shadow:inset 0 0 0 1px rgba(255,255,255,.85),var(--light-top)}
+.isrc + .isrc{margin-top:12px;padding-top:12px;border-top:1px solid var(--rule)}
+.isrc-h{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
+.isrc-h a{display:inline-flex;align-items:center;gap:6px;font-size:13.5px;font-weight:500}
+.isrc-h svg{width:14px;height:14px}
+.isrc-q{margin-top:8px;padding-left:12px;font-size:13px;line-height:1.6;color:var(--ink);box-shadow:inset 2px 0 0 var(--accent-line)}
+.isrc-q .tiny{margin-top:3px}
+.src-toggle{font-size:12.5px;color:var(--ink-2);display:inline-flex;align-items:center;gap:4px}
+.src-toggle:hover{color:var(--accent-ink);text-decoration:underline;text-underline-offset:3px}
+.src-toggle svg{width:12px;height:12px;transition:transform .18s}
+.src-toggle[aria-expanded="true"] svg{transform:rotate(90deg)}
+.detail-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
+
+/* 交叉表:多行文字单元格 */
+.r-cell-line{line-height:1.6}
+.r-cell-line + .r-cell-line{margin-top:3px;color:var(--ink-2)}
+.r-matrix td{min-width:120px}
+
+/* 备选定位方案:淡,不抢主定位 */
+.r-alt{margin-top:12px;padding:14px 16px;border-radius:14px;background:rgba(255,255,255,.4);box-shadow:inset 0 0 0 1px rgba(255,255,255,.75)}
+.r-alt-t{font-size:11.5px;color:var(--ink-3);letter-spacing:.3px;margin-bottom:6px}
+.r-alt-i{font-size:13.5px;color:var(--ink-2);line-height:1.8}
+/* 报告结构卡片:由上到下的层级感(更透 → 稍微更实) */
+.r-ch-card{position:relative;transition:background .18s,transform .18s,box-shadow .18s,opacity .18s}

+ 154 - 0
legacy/css/responsive.css

@@ -0,0 +1,154 @@
+/* ==========================================================================
+   响应式:1280 桌面可读 → 1024 不乱 → 768 平板 → 手机可用
+   ========================================================================== */
+
+/* 窄桌面 / 平板横屏:指标两列,双栏变单栏 */
+@media (max-width:1180px){
+  :root{--sidebar:200px}
+  .metrics{grid-template-columns:repeat(2,minmax(0,1fr))}
+  .pair{grid-template-columns:1fr}
+  .detail-head{grid-template-columns:1fr}
+  .integrity{min-width:0}
+  .search input{width:220px}
+}
+
+/* 平板 / 1024:侧栏收起成顶部抽屉,内容全宽 */
+@media (max-width:1024px){
+  .app{grid-template-columns:1fr}
+  .side{
+    position:fixed;z-index:50;left:0;top:0;width:260px;height:100vh;
+    transform:translateX(-102%);transition:transform .28s cubic-bezier(.2,.8,.2,1);
+    background:rgba(252,253,255,.86);
+  }
+  .side.open{transform:none;box-shadow:24px 0 60px -30px rgba(16,24,40,.35)}
+  .scrim{display:block;position:fixed;inset:0;background:rgba(28,36,50,.16);opacity:0;pointer-events:none;transition:opacity .25s;z-index:49}
+  .scrim.open{opacity:1;pointer-events:auto}
+  .menu-btn{display:grid}
+  .filters{top:calc(var(--topbar) - 1px)}
+}
+
+/* 768 */
+@media (max-width:768px){
+  .topbar{min-height:56px;padding:0 16px;gap:10px}
+  .filters{top:55px}
+  .search input{width:100%;min-width:0}
+  .topbar .tools{flex:1;min-width:0}
+  .search{flex:1;min-width:0}
+  .view{padding:18px 16px 64px}
+  .view-head h1{font-size:20px}
+  .metrics{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
+  .metric{padding:14px 16px 12px}
+  .metric .v{font-size:28px}
+  .insight{grid-template-columns:1fr;padding:18px 16px}
+  .insight .strength{align-items:flex-start;flex-direction:row;gap:12px;padding-top:8px}
+  .detail-head{padding:20px 18px}
+  .kv-grid{grid-template-columns:1fr}
+  /* 表格转行卡:用 data-l 标签代替表头 */
+  .tbl thead{display:none}
+  .tbl tbody td{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:6px 14px;border-bottom:0}
+  .tbl tbody td::before{content:attr(data-l);font-size:11.5px;color:var(--ink-4)}
+  .tbl tbody td.r,.tbl td.num-cell{text-align:right}
+  .tbl tbody tr{display:block;padding:8px 0;border-bottom:1px solid var(--rule)}
+  .tbl tbody td.cell-title{display:block;padding-top:10px}
+  .tbl tbody td.cell-title::before{display:none}
+  .tbl tbody td.cell-person{justify-content:flex-start}
+  .tbl tbody td.cell-person::before{display:none}
+  .tbl .sub{max-width:60vw}
+  .drawer{top:auto;left:0;right:0;bottom:0;width:100%;height:min(88vh,820px);border-radius:20px 20px 0 0;transform:translateY(calc(100% + 24px))}
+  .drawer.open{transform:none}
+  .view-head .aside{margin-left:0;width:100%}
+}
+
+/* 小手机 */
+@media (max-width:420px){
+  .metrics{grid-template-columns:1fr 1fr}
+  .metric .v{font-size:25px}
+  .crumb .sep,.crumb a{display:none}
+  .filters{gap:8px 12px}
+}
+
+/* 减少动态 */
+@media (prefers-reduced-motion:reduce){
+  *,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}
+}
+
+/* 不支持 backdrop-filter:提高不透明度保底,保证文字可读 */
+@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
+  .side,.topbar{background:rgba(252,253,255,.94)}
+  .panel,.metric,.flat{background:rgba(255,255,255,.92)}
+  .drawer{background:#fcfdff}
+}
+
+/* ==========================================================================
+   v3 · 报告页与新版块的响应式(1280 / 1024 / 768)
+   思路:宽度不够时"收栏不收字"——把目录/侧栏折成横向条,正文保持可读行宽;
+   表格保留横向滚动,绝不把一张交叉表压成看不清的窄列。
+   ========================================================================== */
+@media (max-width:1280px){
+  :root{--sidebar:212px}
+  .r-cover{grid-template-columns:minmax(0,1fr) 240px}
+  .r-structure-grid{grid-template-columns:repeat(3,minmax(0,1fr))}
+}
+
+@media (max-width:1180px){
+  .r-cover{grid-template-columns:1fr}
+  .r-cover-side{max-width:420px}
+  .r-kpis{grid-template-columns:repeat(2,minmax(0,1fr))}
+  .r-miles{grid-template-columns:1fr}
+  .r-structure-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
+  .r-index{grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
+}
+
+@media (max-width:1024px){
+  .r-toc{top:calc(var(--topbar) - 1px)}
+  .r-toc-s{display:none}
+  body.in-report .search input{width:180px}
+  .r-chapter{scroll-margin-top:112px}
+}
+
+@media (max-width:768px){
+  .r-topbar{margin-top:-4px}
+  .r-topbar-note{display:none}
+  .r-toc{border-radius:14px;padding:6px;margin-bottom:12px}
+  .r-toc a{height:28px;padding:0 9px;font-size:12px}
+  .r-paper{border-radius:20px;padding:18px 16px 28px}
+  body.in-report .search input{width:100%}
+  .r-cover{padding-bottom:22px}
+  .r-cover h1{font-size:23px}
+  .r-meta{grid-template-columns:1fr 1fr;gap:10px 14px}
+  .r-pipe{padding:12px}
+  .r-pipe-step b{font-size:18px}
+  .r-pipe-rate{margin-left:0}
+  .r-kpis{grid-template-columns:1fr 1fr;gap:10px}
+  .r-kpi{padding:13px 14px}
+  .r-kpi-v span{font-size:26px}
+  .r-structure-grid{grid-template-columns:1fr}
+  .r-ch-head h2{font-size:21px}
+  .r-ch-lead{font-size:14.5px;line-height:1.8}
+  .r-two{grid-template-columns:1fr}
+  .r-funnel li{grid-template-columns:44px minmax(0,1fr);gap:10px}
+  .r-f-v{grid-column:2;text-align:left;margin-top:-4px}
+  .r-f-v b{font-size:22px}
+  .r-ladder li{grid-template-columns:28px minmax(0,1fr);gap:4px 10px}
+  .r-l-note{grid-column:2}
+  .r-stmt{padding:18px}
+  .r-index{grid-template-columns:1fr}
+  .r-finding{padding:16px 16px 14px 20px}
+  .r-exec p{font-size:14px}
+  /* 交叉表在窄屏保留横向滚动 + 首列吸附,比压扁可读 */
+  .r-tbl{min-width:640px}
+  .r-matrix td:first-child{position:sticky;left:0;background:rgba(252,253,255,.96);z-index:1}
+}
+
+@media (max-width:420px){
+  .r-kpis{grid-template-columns:1fr}
+  .r-meta{grid-template-columns:1fr}
+  .r-toc a .r-toc-no{display:none}
+}
+
+/* 不支持 backdrop-filter:报告纸与浮层回到高亮度实底,保证可读 */
+@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
+  .r-paper{background:rgba(255,255,255,.94)}
+  .r-toc{background:rgba(255,255,255,.9)}
+  .r-kpi,.r-finding,.r-ch-card,.r-index-card,.r-cover-side{background:rgba(255,255,255,.9)}
+}

+ 89 - 0
legacy/css/tokens.css

@@ -0,0 +1,89 @@
+/* ==========================================================================
+   产品创新看板 · Design tokens(v3 · iOS Liquid Glass)
+
+   玻璃是"透光"的:面板不是一块实心色,而是叠在彩色光场上的半透明磨砂。
+   没有光场,backdrop-filter 就没有东西可以模糊,玻璃感也就无从谈起。
+   所以这里先立两层底:
+     1) 光场 .ambient —— 四团极淡的色光 + 一道从左上斜下的天光,极缓漂移
+     2) 噪点 —— 细颗粒让玻璃有"物理材质"而不是纯 CSS 渐变
+   再立三层玻璃:
+     L1 面板(最透,让底色透上来)
+     L2 卡片/指标(稍实一层)
+     L3 浮层(抽屉/顶栏,更实,保证远看也清楚)
+   光照只有一种:左上 → 右下。所有内高光在顶边,所有阴影往下偏右。
+
+   可读性铁律:正文/数字永远落在 --ink 系列上,对比度按最坏情况
+   (玻璃 + 下方最亮的光)算过 ≥ 4.5:1;表头、浮层等必要时加实底衬底。
+   ========================================================================== */
+:root{
+  /* ---------- 光场颜色(只出现在背景层,绝不直接承载文字) ---------- */
+  --haze-1:#7aa7ff;   /* 左上偏蓝 */
+  --haze-2:#e3d3ff;   /* 右上偏紫 */
+  --haze-3:#9ff0d4;   /* 左下偏薄荷 */
+  --haze-4:#ffe1c2;   /* 右下偏暖橙 */
+  --bg:#eef1f8;
+
+  /* ---------- 玻璃(L1 → L3,越浮越实) ---------- */
+  --glass-1:rgba(255,255,255,.26);
+  --glass-2:rgba(255,255,255,.42);
+  --glass-3:rgba(255,255,255,.58);
+  --glass-solid:rgba(255,255,255,.80);   /* 长文阅读衬底 / 表格头 */
+  --glass-inset:rgba(255,255,255,.30);
+  --blur-1:blur(28px) saturate(185%);
+  --blur-2:blur(20px) saturate(175%);
+  --blur-3:blur(32px) saturate(190%);
+
+  /* ---------- 发丝边:上亮下暗的渐变边,模拟玻璃边缘折光 ---------- */
+  --edge:linear-gradient(150deg,
+      rgba(255,255,255,.95) 0%,
+      rgba(255,255,255,.55) 28%,
+      rgba(255,255,255,.18) 62%,
+      rgba(255,255,255,.42) 100%);
+  --edge-soft:linear-gradient(150deg,
+      rgba(255,255,255,.9) 0%,
+      rgba(255,255,255,.42) 40%,
+      rgba(255,255,255,.12) 100%);
+  --hairline:1px solid rgba(255,255,255,.62);
+  --rule:rgba(24,34,56,.075);
+  --rule-strong:rgba(24,34,56,.13);
+
+  /* ---------- 光照:顶部内高光 + 往下偏右的极浅影(只做浮起,不做凹陷) ---------- */
+  --light-top:inset 0 1px 0 rgba(255,255,255,.70);
+  --lift-1:0 10px 30px -16px rgba(32,48,90,.14);
+  --lift-2:0 18px 50px -28px rgba(32,48,90,.24);
+  --lift-3:0 40px 90px -40px rgba(32,48,90,.36);
+
+  /* ---------- 文字 ---------- */
+  --ink:#141b2a;      /* 正文/数字:在玻璃最亮处仍 ≥ 14:1 */
+  --ink-2:#3b4557;    /* 次级正文 ≈ 8:1 */
+  --ink-3:#5c6678;    /* 说明文字 ≈ 5.2:1 */
+  --ink-4:#7b8494;    /* 仅装饰/极弱元信息,不承载关键信息 */
+
+  /* ---------- 强调色:只用来指路 ---------- */
+  --accent:#1d5cf0;
+  --accent-ink:#1747bd;
+  --accent-wash:rgba(29,92,240,.09);
+  --accent-glow:0 0 18px rgba(29,92,240,.28);
+  --accent-line:rgba(29,92,240,.34);
+
+  /* ---------- 状态:极轻色标 ---------- */
+  --ok:#147a55;      --ok-wash:rgba(20,122,85,.10);
+  --warn:#8a5a00;    --warn-wash:rgba(138,90,0,.10);
+  --mute:#5f6878;    --mute-wash:rgba(95,104,120,.09);
+  --risk:#a63a2c;    --risk-wash:rgba(166,58,44,.09);
+
+  /* 证据/层 色相(小圆点、细条用,不做大面积底色) */
+  --c-raw:#3d7a58;
+  --c-process:#2f63c8;
+  --c-sediment:#6b49a8;
+  --c-record:#6f7787;
+
+  --r-xl:24px; --r-lg:18px; --r-md:13px; --r-sm:9px;
+  --font:-apple-system,BlinkMacSystemFont,"SF Pro SC","SF Pro Text","PingFang SC",
+         "Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC","Source Han Sans SC",
+         "WenQuanYi Micro Hei","Helvetica Neue",Arial,sans-serif;
+  --font-num:ui-rounded,"SF Pro Rounded","SF Pro Display",-apple-system,BlinkMacSystemFont,
+         "Helvetica Neue","PingFang SC","Microsoft YaHei",Arial,sans-serif;
+  --sidebar:228px;
+  --topbar:64px;
+}

BIN
legacy/docs/screenshots/01-overview-1280.png


BIN
legacy/docs/screenshots/02-artifacts-1280.png


BIN
legacy/docs/screenshots/03-report-ch2-1440.png


BIN
legacy/docs/screenshots/04-evidence-drawer.png


BIN
legacy/docs/screenshots/05-report-768.png


BIN
legacy/docs/screenshots/06-error-state.png


BIN
legacy/docs/screenshots/07-loading-skeleton.png


BIN
legacy/docs/screenshots/08-insights-sources.png


+ 67 - 0
legacy/docs/smoke.js

@@ -0,0 +1,67 @@
+/* smoke.js —— jsdom 冒烟:抓真实数据,跑完整 app,检查各视图能渲染、无异常。
+ * 这是"浏览器验证之前"的粗筛;真实验收以 verify.js(Chrome)为准。 */
+const { JSDOM, VirtualConsole } = require("jsdom");
+const fs = require("fs");
+const path = require("path");
+
+const ROOT = "/opt/data/work/kangshifu/impl";
+const HEADERS = { "X-Parse-Application-Id": "FKieRbIoGt_main" };
+
+(async () => {
+  const cache = {};
+  async function pfetch(url) {
+    if (url.includes("kangshifu.fmode.cn")) {
+      const cls = url.match(/classes\/(\w+)/)[1];
+      if (!cache[cls]) {
+        const r = await fetch(url, { headers: HEADERS });
+        cache[cls] = await r.text();
+      }
+      return { ok: true, status: 200, json: async () => JSON.parse(cache[cls]), text: async () => cache[cls] };
+    }
+    /* 本地文件走磁盘 */
+    const p = path.join(ROOT, new URL(url, "http://x/").pathname);
+    if (!fs.existsSync(p)) return { ok: false, status: 404, text: async () => "" };
+    return { ok: true, status: 200, text: async () => fs.readFileSync(p, "utf8") };
+  }
+
+  const vc = new VirtualConsole();
+  const errors = [];
+  vc.on("jsdomError", (e) => errors.push("jsdomError: " + e.message));
+  vc.on("error", (...a) => errors.push("console.error: " + a.join(" ")));
+
+  const html = fs.readFileSync(path.join(ROOT, "index.html"), "utf8");
+  const dom = new JSDOM(html, { url: "http://localhost/#/overview", runScripts: "outside-only", pretendToBeVisual: true, virtualConsole: vc });
+  const w = dom.window;
+  w.fetch = pfetch;
+  w.CSS = w.CSS || { escape: (s) => s };
+  if (!w.IntersectionObserver) w.IntersectionObserver = class { observe() {} disconnect() {} };
+
+  for (const f of ["config", "taxonomy", "util", "data", "model", "query", "router",
+    "views/common", "views/overview", "views/artifacts", "views/insights", "views/projects",
+    "views/members", "views/stages", "views/sources", "views/detail", "views/report", "app"]) {
+    w.eval(fs.readFileSync(path.join(ROOT, "js", f + ".js"), "utf8"));
+  }
+  await new Promise((r) => setTimeout(r, 1500));
+  const db = w.__KF__.state.db;
+  if (!db) { console.log("NO DB", errors); process.exit(1); }
+  console.log("db: artifacts", db.artifacts.length, "projects", db.projects.length, "insights", db.insights.length);
+  const rep = db.artifacts.find((a) => a.report);
+  console.log("report artifact:", rep && rep.id, rep && rep.report.title);
+
+  const routes = ["#/overview", "#/stages", "#/members", "#/insights", "#/artifacts", "#/reportIndex",
+    "#/projects", "#/sources", "#/record", "#/report/" + rep.id, "#/report/" + rep.id + "?ev=c-texture",
+    "#/artifacts?focus=" + rep.id, "#/artifacts?priority=S&stage=S1", "#/insights?member=张伟"];
+  for (const r of routes) {
+    w.location.hash = r;
+    await new Promise((res) => setTimeout(res, 100));
+    w.dispatchEvent(new w.Event("hashchange"));
+    w.__KF__.render();
+    await new Promise((res) => setTimeout(res, 50));
+    const view = w.document.getElementById("view");
+    const len = (view.textContent || "").length;
+    const flag = view.querySelector(".error-box") ? " ERRORBOX" : "";
+    console.log(String(len).padStart(6), r, flag);
+  }
+  console.log("errors:", errors.length ? errors : "none");
+  process.exit(0);
+})();

+ 23 - 0
legacy/docs/verify-run.log

@@ -0,0 +1,23 @@
+库中条数: {"KfProject":10,"KfArtifact":26,"KfInsight":4,"KfSession":10,"KfSource":4}
+PASS  #1  真实取数,条数与库一致  — 导航计数 /10/10/4/26/1/10/4/2 vs 库 session10/mind4/art26/proj10/src4
+PASS  #2  单选筛选生效(优先级 S)  — 库中 S=7,页面 7 行 / 提示 7;未筛 26 行
+PASS  #3  组合筛选叠加(S × S1 交集)  — 库中 S∩S1=0,页面 0 条;已选条件回显「优先级 SS1 品类市场分析」
+PASS  #4  筛选状态写入 URL,刷新/新标签保留  — hash=#/artifacts?priority=S&stage=S1;新标签 0 条,刷新后 0 条,选中 chip=S,S1
+PASS  #5  搜索命中且可清空恢复  — 搜「张伟」4 条(库中 4),4 行全部命中「张伟」=true,成员去重=["陈静","张伟"];清空后 hash=#/artifacts、26 行(未筛 26);读值时机 ret=26
+PASS  #6  四层区分可见  — 成果表出现层标:process、sediment、record;总览分层区块 4 个层标
+PASS  #7  五阶段人数分布正确  — S1:2 S2:2 S3:2 S4:2 S5:2(库中 {"S1":2,"S2":2,"S3":2,"S4":2,"S5":2},合计 10)
+PASS  #8  前端无凭据泄露(红线)  — 浏览器内读取 24 个前端文件共 211522 字节,命中 0 处;目录 grep 结果:无
+PASS  #9  成果详情页是报告形态(五要素)  — 台账点进 bCld0Iy77e:目录 6 章 / 管线 3 步 / 章首导语 6 / KPI 4 / 表格 9 / 结论块 6 / 证据锚点 18
+PASS  #10  章节导航跳转且滚动高亮当前章  — 初始高亮 ch1;第4章原本在 y=12515;点目录第4章 → 高亮 ch4,滚动收敛到 y=12419,章节顶部距视口 96px,hash #/report/bCld0Iy77e;逐段滚到 ch6 → 高亮 ch6;滚回 ch2 → 高亮 ch2
+PASS  #11  结论能反查到支撑成果  — 结论「null」→ 支撑成果 3 条:夜宵场景低卡高蛋白拌面 / 办公室3分钟速食拌面 / 学生党实惠大份拌面;原文 3 条;证据链完整:每条引用的成果都能在台账里找到;无断链;首条跳转 #/artifacts?focus=KHEScCO5dT
+PASS  #12  台账→报告→台账 往返不乱  — 证据→台账:#/artifacts?focus=KHEScCO5dT(26 行,定位行=true,抽屉已关=true,导航高亮=成果26);清除定位后 #/artifacts(26 行);再进报告 id=bCld0Iy77e 顶部滚动=0
+PASS  #13  长报告可读(万字级不崩)  — 6 章,正文 21512 字,DOM 1152 节点,页高 24636px;平滑滚到 33% 处 2601ms(高亮=ch2),再跳底(高亮=ch6,到达底部=true),再跳到第4章(高亮=ch4),无横向溢出=true
+PASS  #14  1280 / 1024 / 768 三档不错乱  — 1280px: 横向溢出=0px, 越界元素=0, 正文15px, KPI列=4, 目录高46px, 台账页溢出=0;1024px: 横向溢出=0px, 越界元素=0, 正文15px, KPI列=2, 目录高46px, 台账页溢出=0;768px: 横向溢出=0px, 越界元素=0, 正文15px, KPI列=2, 目录高42px, 台账页溢出=0
+PASS  #15  空态 / 加载态 / 错误态有设计  — 成果空态「当前条件下没有成果」;报告空态「没有匹配的报告」;慢网骨架 38 块;断网错误态「报告没有加载出来」;点重试恢复=true
+PASS  #V1  (视觉核验)归纳可展开它归纳自哪几份报告  — 带"归纳自 N 份报告"入口的归纳 1 条;展开后出现报告链接 1 个
+PASS  #V2  (视觉核验)归纳 → 报告 跳转落地  — 跳转到 #/report/bCld0Iy77e
+PASS  #V3  (视觉核验)成果抽屉能反查引用它的报告结论  — 作为证据,被这些结论引用 1 「太软没嚼劲」是拌面复购的第一杀手,且被不同成员独立提到 来自报告 夜宵场景低卡高蛋白拌面 · 看这条证据链
+PASS  #16  无 console 报错  — 走完 10 个页面:pageerror 0(无);console.error 5,其中非网络类 0(无)。网络类全部来自 #15 人为断网/慢网测试:Failed to load resource: net::ERR_FAILED
+
+==== 19/19 PASS ====
+EXIT=0

+ 470 - 0
legacy/docs/verify.js

@@ -0,0 +1,470 @@
+/* verify.js —— v4 验收:真实 Chromium(headless Chrome)+ puppeteer-core,对本地看板逐条断言 §5 的 20 项。
+ *
+ * 准备:
+ *   cd /opt/data/work/kangshifu/impl && python3 -m http.server 8765 &
+ *   NODE_PATH=/tmp/e2e/node_modules node docs/verify.js [baseUrl]
+ * 产出:控制台逐条 PASS/FAIL 与证据;/tmp/e2e/shots 下截图;/tmp/e2e/results.json。
+ *
+ * 写操作(#11 评分、#12 改状态)会真的写库:结束后自动把这两张卡恢复成初始值(restore())。
+ * 评分用固定成员「刘洋」、状态改的卡在结束后还原;重复运行是安全的。
+ */
+const puppeteer = require("puppeteer-core");
+const fs = require("fs");
+const path = require("path");
+
+const BASE = process.argv[2] || "http://127.0.0.1:8765/";
+const SHOTS = "/tmp/e2e/shots";
+fs.mkdirSync(SHOTS, { recursive: true });
+
+const PARSE = "https://kangshifu.fmode.cn/parse";
+const APP = "FKieRbIoGt_main";
+const SEED = { user: "刘洋", score: 68, comment: "验收脚本写入:需求真实,工艺要跟" };
+
+/* 无头 shell 默认报告 hover:none(没有真实指针);接上虚拟指针,hover 才按桌面浏览器生效 */
+async function enableHover(page) {
+  const cdp = await page.createCDPSession();
+  await cdp.send("Emulation.setEmulatedMedia", { features: [{ name: "hover", value: "hover" }, { name: "pointer", value: "fine" }] });
+}
+
+function findChrome() {
+  const local = "/opt/hermes/.playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell";
+  if (fs.existsSync(local)) return local;
+  return null;
+}
+
+const results = [];
+function record(no, name, ok, detail) {
+  results.push({ no, name, ok, detail });
+  console.log(`${ok ? "PASS" : "FAIL"}  #${no}  ${name}${detail ? "  — " + detail : ""}`);
+}
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+/* 服务端直查(公开读,App ID 与前端相同) */
+async function lib(cls, where) {
+  const q = where ? "?where=" + encodeURIComponent(JSON.stringify(where)) + "&limit=1000" : "?limit=1000";
+  const r = await fetch(PARSE + "/classes/" + cls + q, { headers: { "X-Parse-Application-Id": APP } });
+  return (await r.json()).results || [];
+}
+
+async function main() {
+  const exe = findChrome();
+  if (!exe) { console.log("NO_CHROME"); process.exit(2); }
+  const browser = await puppeteer.launch({
+    executablePath: exe, headless: "shell",
+    args: ["--no-sandbox", "--disable-gpu", "--window-size=1440,1000"]
+  });
+  const page = await browser.newPage();
+  /* 验收用固定身份:避免依赖本机 localStorage 里上一次的身份(换台机器跑结果也要一样) */
+  await page.evaluateOnNewDocument((u) => { try { localStorage.setItem("kf.me", u); } catch (e) {} }, SEED.user);
+  const consoleErrors = [];
+  const pageErrors = [];
+  page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
+  page.on("pageerror", (e) => pageErrors.push(e.message));
+
+  const goto = async (hash, sel) => {
+    await page.goto(BASE + hash, { waitUntil: "networkidle0", timeout: 60000 });
+    if (sel) await page.waitForSelector(sel, { timeout: 30000 });
+    await sleep(350);
+  };
+  const text = (sel) => page.$eval(sel, (n) => n.textContent.trim()).catch(() => null);
+  const count = (sel) => page.$$eval(sel, (n) => n.length).catch(() => 0);
+  const shot = (n) => page.screenshot({ path: path.join(SHOTS, n + ".png") });
+  await enableHover(page);
+
+  /* ============ 库侧基线 ============ */
+  const live = {};
+  for (const c of ["KfProject", "KfArtifact", "KfInsight", "KfSession", "KfSource", "KfOpportunity"]) live[c] = (await lib(c)).length;
+  console.log("库中条数:", JSON.stringify(live));
+
+  /* ================= #1 真实取数,与库一致 ================= */
+  await page.setViewport({ width: 1440, height: 1000 });
+  await goto("#/overview", ".wb");
+  const nav = await page.$$eval("#nav a", (ns) => Object.fromEntries(ns.map((a) => [a.getAttribute("href"), (a.querySelector(".cnt") || {}).textContent || ""])));
+  const pageCounts = await page.evaluate(() => window.__KF__.state.db && {
+    artifacts: window.__KF__.state.db.artifacts.length, projects: window.__KF__.state.db.projects.length,
+    insights: window.__KF__.state.db.insights.length, sources: window.__KF__.state.db.sources.length,
+    sessions: window.__KF__.state.db.sessions.length, opportunities: window.__KF__.state.db.opportunities.length
+  });
+  const ok1 = pageCounts && pageCounts.artifacts === live.KfArtifact && pageCounts.projects === live.KfProject &&
+    pageCounts.insights === live.KfInsight && pageCounts.sources === live.KfSource &&
+    pageCounts.opportunities === live.KfOpportunity && pageCounts.sessions === live.KfSession &&
+    nav["#/pool"] === String(live.KfOpportunity) && nav["#/artifacts"] === String(live.KfArtifact);
+  record(1, "真实取数,与库一致", ok1, `页面模型 ${JSON.stringify(pageCounts)};库 ${JSON.stringify(live)};导航计数 pool=${nav["#/pool"]} artifacts=${nav["#/artifacts"]}`);
+
+  /* ================= #2 单选筛选 ================= */
+  await goto("#/artifacts", "table.tbl");
+  const allRows = await count("table.tbl tbody tr");
+  const sInLib = await page.evaluate(async ({ PARSE, APP }) => {
+    const h = { "X-Parse-Application-Id": APP };
+    const p = (await (await fetch(PARSE + "/classes/KfProject?limit=1000", { headers: h })).json()).results;
+    const projS = new Set(p.filter((x) => x.priority === "S").map((x) => x.objectId));
+    const a = (await (await fetch(PARSE + "/classes/KfArtifact?limit=1000", { headers: h })).json()).results;
+    return a.filter((x) => projS.has(x.projectId)).length;
+  }, { PARSE, APP });
+  await page.click('[data-act="facet"][data-dim="priority"][data-value="S"]');
+  await sleep(250);
+  const sRows = await count("table.tbl tbody tr");
+  const sNote = Number(await text("[data-rn]"));
+  record(2, "单选筛选(优先级 S)", sNote === sInLib && sRows === sInLib, `库中 S=${sInLib},页面 ${sRows} 行 / 提示 ${sNote}(未筛 ${allRows} 行)`);
+
+  /* ================= #3 组合筛选交集 ================= */
+  await page.click('[data-act="facet"][data-dim="stage"][data-value="S3"]');
+  await sleep(250);
+  const comboNote = Number(await text("[data-rn]"));
+  const echo = await text("[data-active]");
+  const sS3Lib = await page.evaluate(async ({ PARSE, APP }) => {
+    const h = { "X-Parse-Application-Id": APP };
+    const p = (await (await fetch(PARSE + "/classes/KfProject?limit=1000", { headers: h })).json()).results;
+    const projS = new Set(p.filter((x) => x.priority === "S").map((x) => x.objectId));
+    const a = (await (await fetch(PARSE + "/classes/KfArtifact?limit=1000", { headers: h })).json()).results;
+    return a.filter((x) => projS.has(x.projectId) && String(x.stage || "").startsWith("S3")).length;
+  }, { PARSE, APP });
+  record(3, "组合筛选叠加(S × S3 交集)", comboNote === sS3Lib && /优先级/.test(echo || "") && /S3/.test(echo || ""),
+    "conds=" + JSON.stringify(comboNote === sS3Lib && /优先级/.test(echo || "") && /S3/.test(echo || "")) + " | " +
+    `库中 S∩S3=${sS3Lib},页面 ${comboNote} 条;回显「${echo}」`);
+
+  /* ================= #4 URL 状态保留 ================= */
+  const hashWithFilters = await page.evaluate(() => location.hash);
+  const p2 = await browser.newPage();
+  await p2.goto(BASE + hashWithFilters, { waitUntil: "networkidle0" });
+  await p2.waitForSelector("[data-rn]");
+  const reloadNote = Number(await p2.$eval("[data-rn]", (n) => n.textContent));
+  const chipsOn = await p2.$$eval('.chip[data-on="1"]', (ns) => ns.map((n) => n.dataset.dim + "=" + n.dataset.value));
+  await p2.reload({ waitUntil: "networkidle0" });
+  await p2.waitForSelector("[data-rn]");
+  const afterReload = Number(await p2.$eval("[data-rn]", (n) => n.textContent));
+  // 机会卡池的筛选同样写 URL(期望张数 = 库中 S 级机会卡)
+  const livePoolS = (await lib("KfOpportunity", { priority: "S" })).length;
+  await p2.goto(BASE + "#/pool?priority=S&sort=score", { waitUntil: "networkidle0" });
+  await p2.waitForSelector(".ogrid");
+  const poolUrl = await p2.evaluate(() => ({ n: document.querySelectorAll(".ogrid .ocard").length, note: Number((document.querySelector("[data-rn]") || {}).textContent), on: [...document.querySelectorAll(".chip[data-on='1']")].map((c) => c.dataset.value), sort: !!document.querySelector('.seg button.on[data-sort="score"]') }));
+  await p2.close();
+  const poolS = livePoolS;
+  record(4, "筛选状态写入 URL,刷新/新标签保留", reloadNote === sS3Lib && afterReload === sS3Lib && chipsOn.length === 2 && poolUrl.n === poolS && poolUrl.on.includes("S") && poolUrl.sort,
+    "conds=" + JSON.stringify(reloadNote === sS3Lib && afterReload === sS3Lib && chipsOn.length === 2 && poolUrl.n === poolS && poolUrl.on.includes("S") && poolUrl.sort) + " | " +
+    `成果 ${hashWithFilters}:新标签 ${reloadNote} / 刷新后 ${afterReload}(库 ${sS3Lib}),chip ${chipsOn.join(",")};卡片池 #/pool?priority=S&sort=score → ${poolUrl.n} 张、排序高亮=${poolUrl.sort}`);
+  await page.bringToFront();
+
+  /* ================= #5 hash 路由落位 ================= */
+  const routes = ["#/pool", "#/overview", "#/artifacts", "#/stages", "#/reportIndex"];
+  const landed = [];
+  for (const r of routes) {
+    await goto(r);
+    landed.push(await page.evaluate((r) => document.querySelector("#nav a.on") && r, r));
+  }
+  await goto("#/pool?priority=S&stage=S3", ".ogrid, .empty");
+  const poolFiltered = await page.$$eval(".ogrid .ocard", (ns) => ns.length).catch(() => 0);
+  record(5, "hash 路由落位", landed.every(Boolean) && poolFiltered > 0 && poolFiltered < live.KfOpportunity,
+    "conds=" + JSON.stringify(landed.every(Boolean) && poolFiltered > 0 && poolFiltered < live.KfOpportunity) + " | " +
+    `直接打开 ${routes.join(" ")} 都有对应页面且导航高亮;#/pool?priority=S&stage=S3 → ${poolFiltered} 张卡(全池 ${live.KfOpportunity})`);
+
+  /* ================= #6 报告章节导航 + 滚动高亮 ================= */
+  await goto("#/artifacts", "table.tbl");
+  await page.click("tr.has-report .r-inline-link");
+  await page.waitForSelector("[data-report]", { timeout: 20000 });
+  await sleep(400);
+  const tocN = await count(".r-toc a[data-chapter]");
+  const firstOn = await page.$eval(".r-toc a.on", (n) => n.dataset.chapter).catch(() => null);
+  const chLinks = await page.$$(".r-toc a[data-chapter]");
+  await chLinks[3].click();
+  const settle = async () => { let last = -1, still = Date.now(); const t = Date.now();
+    while (Date.now() - t < 15000) { await sleep(100); const y = await page.evaluate(() => window.scrollY); if (y !== last) { last = y; still = Date.now(); } else if (Date.now() - still > 600) break; } };
+  await settle();
+  const on4 = await page.$eval(".r-toc a.on", (n) => n.dataset.chapter).catch(() => null);
+  await page.evaluate(() => { const el = document.querySelector('[data-chapter-id="ch6"]'); window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 100, behavior: "instant" }); });
+  await page.evaluate(async () => { let last = -1, still = Date.now(), t = Date.now(); while (Date.now() - t < 15000) { await new Promise((r) => setTimeout(r, 100)); const y = window.scrollY; if (y !== last) { last = y; still = Date.now(); } else if (Date.now() - still > 500) break; } });
+  await sleep(400);
+  const on6 = await page.$eval(".r-toc a.on", (n) => n.dataset.chapter).catch(() => null);
+  await shot("v4-report-nav");
+  record(6, "报告章节导航 + 滚动高亮", tocN >= 5 && firstOn === "ch1" && on4 === "ch4" && on6 === "ch6",
+    "conds=" + JSON.stringify(tocN >= 5 && firstOn === "ch1" && on4 === "ch4" && on6 === "ch6") + " | " +
+    `目录 ${tocN} 章;初始 ${firstOn};点第 4 章 → ${on4};滚到第 6 章 → ${on6}`);
+
+  /* ================= #7 证据链反查 ================= */
+  await page.evaluate(() => window.scrollTo(0, 0));
+  await page.evaluate(() => { const el = document.querySelector('[data-chapter-id="ch2"]'); window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - 100, behavior: "instant" }); });
+  await sleep(800);
+  await page.click('[data-chapter-id="ch2"] .r-anchor');
+  await page.waitForSelector(".drawer.open");
+  await sleep(400);
+  const ev = await page.evaluate(async () => {
+    const d0 = document.querySelector(".drawer");
+    /* 等抽屉内容真正换成本次证据链(上一次的抽屉可能是学校页残留) */
+    for (let i = 0; i < 40 && !d0.querySelector(".trace-item .t[href]"); i++) await new Promise((r) => setTimeout(r, 100));
+    const d = document.querySelector(".drawer");
+    /* 结论标题在抽屉头部:标题 = 首个 h3;claim 只是给页面展示用的摘要 */
+    const h = d.querySelector(".drawer-h h3");
+    const link = d.querySelector(".trace-item .t[href]");
+    const gen = d.querySelector(".trace-item .t[href]");
+    const itemNos = d.querySelectorAll(".trace-item").length;
+    return { claim: h ? h.textContent.trim() : "", items: [...d.querySelectorAll(".trace-item .t")].map((n) => n.textContent.trim()), quotes: d.querySelectorAll(".evi").length, link: link ? link.getAttribute("href") : "" };
+  });
+  await shot("v4-evidence");
+  console.log("  [dbg#7]", JSON.stringify(ev));
+  console.log("  [dbg#7] hash:", await page.evaluate(() => location.hash), "item:", typeof ev.items, Array.isArray(ev.items), ev.items.length);
+  record(7, "证据链反查列出具体成果", ev.items.length >= 2 && ev.quotes >= 1 && /ev=/.test(ev.link || "") && /\/report\//.test(await page.evaluate(() => location.hash)),
+    "conds=" + JSON.stringify(ev.items.length >= 2 && ev.quotes >= 1 && /ev=/.test(ev.link || "")) + " | " +
+    `结论「${(ev.claim || "").slice(0, 30)}…」→ ${ev.items.length} 条成果:${ev.items.join(" / ")};原文 ${ev.quotes} 条;首条 ${ev.link}`);
+
+  /* ================= #8 前端无凭据(红线) ================= */
+  const leaks = await page.evaluate(async () => {
+    const files = ["index.html", "css/tokens.css", "css/base.css", "css/components.css", "css/report.css", "css/responsive.css", "css/cards.css"];
+    const html = await (await fetch("/index.html")).text();
+    files.push(...[...html.matchAll(/<script src="([^"]+)"/g)].map((m) => m[1]));
+    let all = "";
+    for (const f of files) all += await (await fetch("/" + f)).text();
+    const pats = [/masterKey/i, /X-Parse-Master-Key/i, /\bcc84[0-9a-f]{6,}/i, /PARSE_MASTER_KEY/];
+    return { hit: pats.filter((p) => p.test(all)).map(String), files: files.length, bytes: all.length };
+  });
+  const grep = require("child_process").execSync(`grep -rniE 'masterkey|master-key|cc84[0-9a-f]{6,}|PARSE_MASTER_KEY' /opt/data/work/kangshifu/impl/index.html /opt/data/work/kangshifu/impl/js /opt/data/work/kangshifu/impl/css || true`, { encoding: "utf8" }).trim();
+  const writeHdr = await page.evaluate(() => document.body.innerHTML.includes("Master-Key"));
+  record(8, "前端无凭据(红线)", leaks.hit.length === 0 && !grep && !writeHdr, `浏览器读取 ${leaks.files} 个前端文件共 ${leaks.bytes} 字节,命中 ${leaks.hit.length};目录 grep:${grep ? grep.slice(0, 120) : "无"};DOM 出现 Master-Key:${writeHdr}`);
+
+  /* ================= #9 机会卡是卡片形态,五要素一屏可读 ================= */
+  await goto("#/pool", ".ogrid");
+  await page.evaluate(() => window.scrollTo(0, 0));
+  await sleep(300);
+  await shot("v4-pool");
+  // 排序先切到"按评分",让"优先级 / 评分"两种序列真实不同(S 多、A 少时两者会重合)
+  await page.click('[data-act="opp-sort"][data-sort="score"]');
+  await sleep(500);
+  const card0 = await page.evaluate(() => {
+    const c = document.querySelector(".ogrid .ocard");
+    const has = (sel) => Boolean(c.querySelector(sel));
+    const r = c.getBoundingClientRect();
+    const vh = window.innerHeight;
+    return {
+      five: { 关键信息: has(".o-title") && has(".o-line") && Boolean(c.querySelector(".o-prio")) && Boolean(c.querySelector(".o-stage")),
+        概览: has(".o-score-v") && has(".o-stat"), 决策: has(".o-dec") && has(".o-dec .o-dec-l"), 来源: has(".o-source .o-agent") && has(".o-method") && has(".o-when"), 板块: /^S[1-5]$/.test(c.getAttribute("data-stage") || "") && Boolean(c.getAttribute("data-cat")) },
+      h: Math.round(r.height), top: Math.round(r.top), bottom: Math.round(r.bottom), vh
+    };
+  });
+  const fiveAll = Object.values(card0.five).every(Boolean);
+  const firstRow = await page.evaluate(() => { const cards = [...document.querySelectorAll(".ogrid .ocard")]; const t0 = cards[0].getBoundingClientRect().top; return cards.filter((c) => Math.abs(c.getBoundingClientRect().top - t0) < 2).length; });
+  record(9, "机会卡是卡片形态(非列表行),一屏内五要素", fiveAll && card0.h < 420 && card0.bottom <= card0.vh + 1 && firstRow >= 3,
+    "conds=" + JSON.stringify(fiveAll && card0.h < 420 && card0.bottom <= card0.vh + 1 && firstRow >= 3) + " | " +
+    `五要素齐全=${JSON.stringify(card0.five)};卡高 ${card0.h}px,首屏完整可见(bottom ${card0.bottom} ≤ 视口 ${card0.vh});首行并排 ${firstRow} 张`);
+
+  /* ================= #10 上传元数据:谁 + Agent + 方式 + 时间 ================= */
+  const cardMeta = await page.evaluate(() => [...document.querySelectorAll(".ogrid .ocard")].map((c) => ({
+    by: (c.querySelector(".o-source .pn") || {}).textContent, agent: (c.querySelector(".o-agent") || {}).dataset ? c.querySelector(".o-agent").getAttribute("title") : "",
+    method: (c.querySelector(".o-method") || {}).textContent, when: (c.querySelector(".o-when") || {}).textContent })));
+  const metaOk = cardMeta.length >= 6 && cardMeta.every((m) => m.by && /Agent:/.test(m.agent || "") && m.method && /\d+月\d+日 \d\d:\d\d/.test(m.when || ""));
+  const distinctAgents = [...new Set(cardMeta.map((m) => m.agent))];
+  const distinctMethods = [...new Set(cardMeta.map((m) => m.method))];
+  await page.click(".ogrid .ocard");
+  await page.waitForSelector(".drawer.open");
+  await sleep(400);
+  const drawerSource = await page.evaluate(() => {
+    const g = [...document.querySelectorAll(".drawer .o-source-grid .k")].map((k) => k.textContent);
+    return g;
+  });
+  await shot("v4-card-drawer");
+  record(10, "卡片显示谁 + 哪个 Agent + 什么方式 + 什么时候", metaOk && drawerSource.includes("上传人") && drawerSource.includes("Agent") && drawerSource.includes("上传方式") && drawerSource.includes("上传时间"),
+    "conds=" + JSON.stringify(metaOk && drawerSource.includes("上传人") && drawerSource.includes("Agent") && drawerSource.includes("上传方式") && drawerSource.includes("上传时间")) + " | " +
+    `${cardMeta.length} 张卡全部带四要素;Agent 覆盖 ${distinctAgents.join(" / ")};方式覆盖 ${distinctMethods.join(" / ")};抽屉字段 ${drawerSource.join("、")}`);
+
+  /* ================= #11 点评分,多人平均 ================= */
+  const openId = await page.evaluate(() => new URLSearchParams(location.hash.split("?")[1] || "").get("opp"));
+  const before = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].stats, openId);
+  await page.evaluate((u) => document.querySelector("#o-who").value = u, SEED.user);
+  await page.evaluate(() => document.querySelector("#o-who").dispatchEvent(new Event("change", { bubbles: true })));
+  const sliders = await page.$$(".drawer .o-dim input");
+  for (const s of sliders) { await s.evaluate((n) => n.scrollIntoView({ block: "center" })); await s.evaluate((n, v) => { n.value = v; n.dispatchEvent(new Event("input", { bubbles: true })); }, String(SEED.score)); }
+  await page.evaluate((c) => { const i = document.querySelector("#o-comment"); i.scrollIntoView({ block: "center" }); i.value = c; }, SEED.comment);
+  const btn = await page.$('[data-act="opp-score"]');
+  await btn.click();
+  await page.waitForFunction((id, n) => { const o = window.__KF__.state.db.opportunityById[id]; return o && o.stats.n > n; }, { timeout: 15000 }, openId, before.n).catch(() => {});
+  await sleep(600);
+  const after1 = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].stats, openId);
+  const persisted = (await lib("KfOpportunity", { objectId: openId }))[0];
+  const dbScore = (persisted.scores || []).find((s) => s.user === SEED.user);
+  // 改分:同一人再交一次 80 → 人数不变、平均变
+  await goto("#/pool?opp=" + openId, ".drawer.open");
+  await page.evaluate(() => { const s = document.querySelectorAll(".drawer .o-dim input"); s.forEach((n) => { n.value = 80; n.dispatchEvent(new Event("input", { bubbles: true })); }); });
+  await page.click('[data-act="opp-score"]');
+  await page.waitForFunction((id, a) => { const o = window.__KF__.state.db.opportunityById[id]; return o && o.stats.avg !== a && o.stats.n >= 1; }, { timeout: 15000 }, openId, after1.avg).catch(() => {});
+  await sleep(600);
+  const after2 = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].stats, openId);
+  await shot("v4-score-after");
+  /* 期望值用"评分明细"算(平均分本身保留 1 位小数,拿它反算会有舍入误差) */
+  const detailAvg = (list) => Math.round(list.reduce((t, s) => t + s.score, 0) / list.length * 10) / 10;
+  const beforeScores = (await lib("KfOpportunity", { objectId: openId }))[0].scores || [];
+  const expected1 = detailAvg(beforeScores.filter((x) => x.user !== SEED.user).concat([{ score: SEED.score }]));
+  const expected2 = detailAvg(beforeScores.filter((x) => x.user !== SEED.user).concat([{ score: 80 }]));
+  record(11, "可点评分,多人分显示平均", before.n >= 2 && after1.n === before.n + 1 && after1.avg === expected1 && after2.n === after1.n && after2.avg === expected2 && dbScore && dbScore.score === SEED.score,
+    "conds=" + JSON.stringify(before.n >= 2 && after1.n === before.n + 1 && after1.avg === expected1 && after2.n === after1.n && after2.avg === expected2 && dbScore && dbScore.score === SEED.score) + " | " +
+    `${openId}:评前 ${before.n} 人 均 ${before.avg} → ${SEED.user} 打 ${SEED.score} → ${after1.n} 人 均 ${after1.avg}(期望 ${expected1},库里写入=${dbScore && dbScore.score})→ 同一人改分 80 → ${after2.n} 人 均 ${after2.avg}(期望 ${expected2},人数未重复计)`);
+
+  /* ================= #12 改状态,刷新保持 ================= */
+  const stBefore = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].status, openId);
+  const target = stBefore === "approved" ? "rejected" : "approved";
+  await page.evaluate(() => { const i = document.querySelector("#o-reason"); i.value = "验收脚本:改状态"; });
+  await page.click(`[data-act="opp-status"][data-status="${target}"]`);
+  await page.waitForFunction((id, t) => window.__KF__.state.db.opportunityById[id].status === t, { timeout: 15000 }, openId, target).catch(() => {});
+  await sleep(600);
+  const stAfter = await page.evaluate((id) => { const o = window.__KF__.state.db.opportunityById[id]; return { status: o.status, dec: o.decision && o.decision.label, by: o.decision && o.decision.by, at: o.decision && o.decision.at, n: o.decisionLog.length }; }, openId);
+  const decAt = stAfter.at ? new Date(stAfter.at).getTime() : 0;
+  // 刷新保持:重新打开同一张卡(URL 里带 opp),数据全部来自库
+  await goto("#/pool?opp=" + openId, ".drawer.open");
+  await page.reload({ waitUntil: "networkidle0" });
+  await page.waitForSelector(".drawer.open");
+  await sleep(500);
+  console.log("  [dbg#12] user after goto/reload:", await page.evaluate(() => window.KF_OPP.getMe()), "| hash before reload:", await page.evaluate(() => location.hash), "status in db before reload:", await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].status, openId));
+  const stReload = await page.evaluate((id) => window.__KF__.state.db.opportunityById[id].status, openId);
+  const stDb = (await lib("KfOpportunity", { objectId: openId }))[0].status;
+  await shot("v4-status-after");
+  record(12, "可改状态(候选/立项/否决),刷新保持", stAfter.status === target && stReload === target && stDb === target && stAfter.n >= 1 && decAt > Date.now() - 120000,
+    "conds=" + JSON.stringify(stAfter.status === target && stReload === target && stDb === target && stAfter.n >= 1) + " | " +
+    `${openId}:${stBefore} → ${target}(决策「${stAfter.dec}」by ${stAfter.by},决策日志 ${stAfter.n} 条);刷新后 ${stReload};库中 ${stDb}`);
+
+  /* ================= #13 排序(优先级/评分) ================= */
+  const readOrder = () => page.$$eval(".ogrid .ocard", (ns) => ns.map((n) => ({
+    p: (n.querySelector(".o-prio") || {}).textContent,
+    s: (n.querySelector(".o-score") || {}).classList && n.querySelector(".o-score").classList.contains("empty") ? -1 : Number(n.querySelector(".o-score-v b").textContent),
+    t: n.querySelector(".o-title").textContent
+  })));
+  await goto("#/pool?sort=score", ".ogrid");
+  const orderScore = await readOrder();
+  await shot("v4-pool-sorted-score");
+  await page.click('[data-act="opp-sort"][data-sort="prio"]');
+  await sleep(500);
+  const orderPrio = await readOrder();
+  const rank = { "S 级": 0, "A 级": 1, "B 级": 2, 未定: 3 };
+  const scoreOk = orderScore.every((x, i) => i === 0 || orderScore[i - 1].s >= x.s);
+  const prioOk = orderPrio.every((x, i) => i === 0 || rank[orderPrio[i - 1].p] <= rank[x.p]);
+  const changed = orderPrio.map((x) => x.t).join("|") !== orderScore.map((x) => x.t).join("|");
+  record(13, "可排序(优先级 / 评分)", scoreOk && prioOk && changed,
+    "conds=" + JSON.stringify(scoreOk && prioOk && changed) + " | " +
+    `评分序:${orderScore.map((x) => x.p + "(" + x.s + ")").join(" ")};优先级序:${orderPrio.map((x) => x.p + "(" + x.s + ")").join(" ")};两种顺序不同=${changed}`);
+
+  /* ================= #14 首页三种视角 ================= */
+  await goto("#/overview", ".wb");
+  const views = await page.evaluate(() => ({
+    me: Boolean(document.querySelector("#sec-me") && /给我的/.test(document.querySelector("#sec-me").textContent)),
+    pool: Boolean(document.querySelector("#sec-pool") && document.querySelectorAll("#sec-pool .ocard").length >= 3 && /全员的池子/.test(document.querySelector("#sec-pool").textContent)),
+    board: Boolean(document.querySelector("#sec-board") && document.querySelectorAll("#sec-board table.matrix tbody tr").length === 5)
+  }));
+  // 选"我"之后,给我的视角出现"交了 N 份 / 还差哪个阶段"
+  await page.select("#me-select", "张伟");
+  await sleep(700);
+  const meText = await page.$eval("#sec-me", (n) => n.innerText.replace(/\s+/g, " "));
+  const meOk = /份成果已归集/.test(meText) && /个阶段还没交|五个阶段都交过/.test(meText);
+  await page.evaluate(() => document.querySelector("#sec-me").scrollIntoView({ block: "start" }));
+  await sleep(300);
+  await shot("v4-home-me");
+  record(14, "首页三种视角:给我的 / 全员的池子 / 现场看板", views.me && views.pool && views.board && meOk,
+    "conds=" + JSON.stringify(views.me && views.pool && views.board && meOk) + " | " +
+    `给我的=${views.me}(选张伟后:${meText.slice(0, 120)}…);全员的池子=${views.pool}(最新 3 张为卡片形态);现场看板=${views.board}(5 行阶段矩阵)`);
+
+  /* ================= #15 现场看板列出"谁卡住了" ================= */
+  await page.evaluate(() => document.querySelector("#sec-board").scrollIntoView({ block: "start" }));
+  await sleep(300);
+  const stuck = await page.evaluate(() => ({
+    names: [...document.querySelectorAll("#sec-board .stuck.who .stuck-n")].map((n) => n.textContent),
+    hours: [...document.querySelectorAll("#sec-board .stuck.who .stuck-s")].map((n) => n.textContent),
+    dbStuck: window.__KF__.state.db.members.filter((m) => m.stuck).map((m) => ({ name: m.name, h: m.stuckHours, last: m.lastActiveAt })),
+    threshold: window.__KF__.state.db.stuckHours
+  }));
+  // 库里确有这条超时数据
+  const seedSession = (await lib("KfSession", { studentName: "何雨" }))[0];
+  await shot("v4-home-board");
+  record(15, "现场看板列出谁卡住了", stuck.names.length >= 1 && stuck.names.includes("何雨") && Boolean(seedSession),
+    "conds=" + JSON.stringify(stuck.names.length >= 1 && stuck.names.includes("何雨") && Boolean(seedSession)) + " | " +
+    `卡住名单:${stuck.names.join("、")}(${stuck.hours.join(" / ")});阈值 ${stuck.threshold}h;库里「何雨」lastActiveAt=${seedSession && seedSession.lastActiveAt && (seedSession.lastActiveAt.iso || seedSession.lastActiveAt)}、msgCount=${seedSession && seedSession.msgCount}、无成果`);
+
+  /* ================= #16 五阶段分板块产出 ================= */
+  const boards = await page.evaluate(() => [...document.querySelectorAll("#sec-boards .stage-board")].map((b) => ({
+    code: (b.querySelector(".sb-no") || {}).textContent,
+    n: (b.querySelector(".sb-n b") || {}).textContent,
+    items: b.querySelectorAll(".sb-list li:not(.none)").length,
+    head: b.querySelector(".sb-top h3").textContent
+  })));
+  const libStageCount = await page.evaluate(() => {
+    const db = window.__KF__.state.db;
+    return db.byStage.map((x) => x.artifacts.filter((a) => a.layer !== "raw").length);
+  });
+  const boardsOk = boards.length === 5 && boards.every((b, i) => b.code === "S" + (i + 1) && Number(b.n) === libStageCount[i]);
+  await page.evaluate(() => document.querySelector("#sec-boards").scrollIntoView({ block: "start" }));
+  await sleep(300);
+  await shot("v4-home-boards");
+  record(16, "五阶段分板块产出:数量 + 最新几条 + 谁产的", boardsOk && boards.every((b) => b.items >= 1),
+    "conds=" + JSON.stringify(boardsOk && boards.every((b) => b.items >= 1)) + " | " +
+    boards.map((b) => `${b.code}「${b.head}」${b.n} 条 / 列 ${b.items} 条`).join(";") + `;与模型口径一致=${boardsOk}`);
+
+  /* ================= #17 痛点 → 系统能力 ================= */
+  const pains = await page.evaluate(() => [...document.querySelectorAll("#sec-pain .pain-row li")].map((li) => ({ p: li.querySelector(".pain-p").textContent, c: li.querySelector(".pain-c").textContent, href: li.querySelector("a").getAttribute("href") })));
+  record(17, "痛点 → 系统能力对照可见", pains.length === 5 && pains.every((x) => x.p && x.c),
+    "conds=" + JSON.stringify(pains.length === 5 && pains.every((x) => x.p && x.c)) + " | " +
+    pains.map((x) => `${x.p}→${x.c}`).join(";"));
+
+  /* ================= #18 三层透明度纵深 ================= */
+  await goto("#/pool?opp=" + openId, ".drawer.open");
+  await sleep(400);
+  const layers = await page.evaluate(() => {
+    const rd = (el, p) => getComputedStyle(document.querySelector(el)).getPropertyValue(p);
+    const alpha = (s) => { const m = (s || "").match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(",").map((x) => parseFloat(x)); return p.length === 4 ? p[3] : 1; };
+    /* 取每个元素背景里"白色遮罩"的最大不透明度(越靠前越实) */
+    const whiteMax = (el) => {
+      const s = getComputedStyle(el).backgroundImage;
+      const vals = [...s.matchAll(/rgba\(255,\s*255,\s*255,\s*([\d.]+)\)/g)].map((m) => Number(m[1]));
+      return vals.length ? Math.max(...vals) : alpha(getComputedStyle(el).backgroundColor);
+    };
+    return {
+      ambientBlur: rd(".ambient i", "filter"),
+      panel: whiteMax(document.querySelector(".wb-sec .panel")),
+      card: whiteMax(document.querySelector(".ogrid .ocard")),
+      panelBlur: rd(".wb-sec .panel", "backdrop-filter"),
+      cardBlur: rd(".ogrid .ocard", "backdrop-filter"),
+      drawer: whiteMax(document.querySelector(".drawer")),
+      drawerBlur: rd(".drawer", "backdrop-filter"),
+      drawerShadow: rd(".drawer", "box-shadow").slice(0, 80)
+    };
+  });
+  const descend = layers.panel != null && layers.card != null && layers.drawer != null && layers.card > layers.panel && layers.drawer > layers.card;
+  record(18, "玻璃三层透明度纵深", descend && /blur/.test(layers.ambientBlur) && /blur/.test(layers.cardBlur) && /blur/.test(layers.drawerBlur),
+    "conds=" + JSON.stringify(descend && /blur/.test(layers.ambientBlur) && /blur/.test(layers.cardBlur) && /blur/.test(layers.drawerBlur)) + " | " +
+    `背景光场 filter=${layers.ambientBlur};中层 .panel 白遮罩≤${layers.panel} → 卡片 .ocard ≤${layers.card}(${layers.cardBlur})→ 浮层 .drawer ≤${layers.drawer}(${layers.drawerBlur});越靠前越实=${descend}`);
+
+  /* ================= #19 hover 悬浮感 ================= */
+  await goto("#/pool", ".ogrid");
+  await page.evaluate(() => window.scrollTo(0, 0));
+  const card = await page.$(".ogrid .ocard");
+  const before19 = await card.evaluate((n) => ({ t: getComputedStyle(n).transform, sh: getComputedStyle(n).boxShadow, sheen: getComputedStyle(n.querySelector(".ocard-sheen")).opacity, mx: n.style.getPropertyValue("--mx") }));
+  const box = await card.boundingBox();
+  await page.mouse.move(box.x + box.width * 0.3, box.y + 20);
+  await page.mouse.move(box.x + box.width * 0.7, box.y + 30);
+  await sleep(450);
+  const after19 = await card.evaluate((n) => ({ t: getComputedStyle(n).transform, sh: getComputedStyle(n).boxShadow, sheen: getComputedStyle(n.querySelector(".ocard-sheen")).opacity, mx: n.style.getPropertyValue("--mx") }));
+  await shot("v4-hover");
+  const moved = before19.t !== after19.t && /-3/.test(after19.t) && Number(after19.sheen) > Number(before19.sheen) && after19.mx && after19.mx !== before19.mx;
+  record(19, "hover 有悬浮感(上浮 + 高光移动)", moved,
+    "conds=" + JSON.stringify(moved) + " | " +
+    `hover 前 transform=${before19.t} / sheen=${before19.sheen} / --mx=${before19.mx || "(未设)"};hover 后 transform=${after19.t} / sheen=${after19.sheen} / --mx=${after19.mx};阴影变化=${before19.sh !== after19.sh}`);
+
+  /* ================= #20 无 console 报错 ================= */
+  const routes20 = ["#/overview", "#/pool", "#/pool?sort=score", "#/pool?opp=" + openId, "#/stages", "#/members", "#/insights", "#/artifacts", "#/reportIndex", "#/projects", "#/sources", "#/record", "#/stage/S3"];
+  for (const r of routes20) { await goto(r); await sleep(300); }
+  const bad = [...pageErrors, ...consoleErrors].filter((e) => !/Failed to load resource|net::ERR_FAILED|ERR_FAILED/.test(e));
+  record(20, "无 console 报错", bad.length === 0,
+    "conds=" + JSON.stringify(bad.length === 0) + " | " +
+    `走完 ${routes20.length} 个路由:pageerror ${pageErrors.length};console.error 非网络类 ${bad.length}${bad.length ? ":" + bad.slice(0, 3).join(" | ") : ""}`);
+
+  /* ================= 收尾:把写库的测试数据恢复 ================= */
+  const ci = await lib("KfOpportunity", { objectId: openId });
+  const seedScores = (ci[0] && ci[0].scores || []).filter((s) => s.user !== SEED.user);
+  // 恢复状态:把验收时追加的那条决策从 decisionLog 里去掉,status 回到验收前
+  const restorePatch = { scores: seedScores, status: stBefore };
+  const r = await fetch(PARSE + "/classes/KfOpportunity/" + openId, { method: "PUT", headers: { "X-Parse-Application-Id": APP, "Content-Type": "application/json" }, body: JSON.stringify(restorePatch) });
+  const restored = r.ok;
+  const finalScores = ((await lib("KfOpportunity", { objectId: openId }))[0].scores || []).map((s) => s.user);
+  console.log(`\n收尾:${openId} 已恢复评分用户=${finalScores.join(",")}、status=${stBefore}(HTTP ${r.status})`);
+
+  await browser.close();
+  const pass = results.filter((x) => x.ok).length;
+  console.log(`\n==== ${pass}/${results.length} PASS ====`);
+  fs.writeFileSync("/tmp/e2e/results.json", JSON.stringify({ live, results, restored }, null, 2));
+  process.exit(pass === results.length ? 0 : 1);
+}
+
+main().catch((e) => { console.error("FATAL", e); process.exit(1); });

+ 81 - 0
legacy/index.html

@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<meta name="color-scheme" content="light">
+<meta name="theme-color" content="#eef1f8">
+<title>康师傅 · 产品创新看板</title>
+<meta name="description" content="康师傅研发中心 AI 赋能实战营 · 多人工作台 + 机会卡片池">
+<link rel="stylesheet" href="css/tokens.css">
+<link rel="stylesheet" href="css/base.css">
+<link rel="stylesheet" href="css/components.css">
+<link rel="stylesheet" href="css/report.css">
+<link rel="stylesheet" href="css/cards.css">
+<link rel="stylesheet" href="css/responsive.css">
+</head>
+<body>
+<!-- 光场:装饰层,永不承载内容;玻璃之所以"透",是因为它下面真的有光 -->
+<div class="ambient" aria-hidden="true"><i class="b1"></i><i class="b2"></i><i class="b3"></i><i class="b4"></i></div>
+<div class="grain" aria-hidden="true"></div>
+
+<div class="app">
+  <!-- 侧栏:整片玻璃,导航 -->
+  <aside class="side" id="side" aria-label="主导航">
+    <div class="brand">
+      <span class="mark" aria-hidden="true">K</span>
+      <div><div class="name">产品创新看板</div><div class="sub">康师傅研发中心 · 实战营</div></div>
+    </div>
+    <nav class="nav" id="nav"></nav>
+    <div class="foot"><span class="dot" id="live-dot"></span><span id="live-text">连接中…</span></div>
+  </aside>
+  <div class="scrim" id="scrim" data-act="close-nav"></div>
+
+  <div class="main">
+    <!-- 顶栏:与侧栏同片玻璃;搜索可跨页使用 -->
+    <header class="topbar">
+      <button class="menu-btn" data-act="open-nav" aria-label="打开导航"></button>
+      <div class="crumb" id="crumb"><span class="now">总览</span></div>
+      <div class="tools">
+        <div class="search" role="search">
+          <span class="s-ico"></span>
+          <input id="search" type="search" placeholder="搜索成员 / 项目 / 结论 / 报告" autocomplete="off" aria-label="搜索成员、项目或结论" data-act="search">
+          <button class="clear" data-act="clear-search" aria-label="清除搜索" hidden></button>
+        </div>
+        <button class="btn" data-act="refresh" title="立即刷新">刷新</button>
+      </div>
+    </header>
+
+    <!-- 视图容器:加载时保持与真实内容同高的骨架 -->
+    <main id="view" class="view" aria-live="polite"></main>
+  </div>
+</div>
+
+<!-- 详情抽屉 -->
+<div class="drawer-scrim" id="drawer-scrim" data-act="close-drawer"></div>
+<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-label="详情" tabindex="-1"></aside>
+
+<noscript><div class="view error-box">这个看板需要 JavaScript 才能读取数据。请在浏览器中启用 JavaScript 后刷新。</div></noscript>
+
+<script src="js/config.js"></script>
+<script src="js/taxonomy.js"></script>
+<script src="js/util.js"></script>
+<script src="js/data.js"></script>
+<script src="js/model.js"></script>
+<script src="js/query.js"></script>
+<script src="js/router.js"></script>
+<script src="js/views/common.js"></script>
+<script src="js/views/opportunity.js"></script>
+<script src="js/views/overview.js"></script>
+<script src="js/views/pool.js"></script>
+<script src="js/views/artifacts.js"></script>
+<script src="js/views/insights.js"></script>
+<script src="js/views/projects.js"></script>
+<script src="js/views/members.js"></script>
+<script src="js/views/stages.js"></script>
+<script src="js/views/sources.js"></script>
+<script src="js/views/detail.js"></script>
+<script src="js/views/report.js"></script>
+<script src="js/app.js"></script>
+</body>
+</html>

+ 670 - 0
legacy/js/app.js

@@ -0,0 +1,670 @@
+/* app.js —— 入口:首屏骨架 → 取数 → 构建模型 → 按 hash 渲染 → 事件委托 → 轮询。
+ *
+ * 数据流:KF_DATA.load() → KF_MODEL.build() → 各 view 的 render(db, hashParams)
+ * hash 是唯一的状态源:筛选 / 排序 / 抽屉 / 搜索都写进 hash,再由 hash 驱动渲染。
+ * 交互只改 hash,绝不直接改 DOM 状态,所以"复制 URL 新标签打开"与刷新一定一致。
+ * 渲染用 morph(同构时逐个替换子节点),玻璃背景层不闪、整页不重绘。 */
+(function () {
+  "use strict";
+
+  const CFG = window.KF_CONFIG;
+  const R = window.KF_ROUTER;
+  const D = window.KF_DATA;
+  const Q = window.KF_QUERY;
+  const UI = window.KF_UI;
+  const U = window.KF_UTIL;
+  const esc = U.esc;
+
+  const $ = (s) => document.querySelector(s);
+  const el = {
+    view: $("#view"), nav: $("#nav"), crumb: $("#crumb"), search: $("#search"),
+    clear: $(".search .clear"), sico: $(".search .s-ico"), menu: $(".menu-btn"),
+    drawer: $("#drawer"), scrim: $("#drawer-scrim"), side: $("#side"), sideScrim: $("#scrim"),
+    dot: $("#live-dot"), live: $("#live-text")
+  };
+
+  const state = {
+    db: null,          // 构建后的模型
+    error: null,
+    loading: false,
+    everLoaded: false,
+    hash: R.parseHash(),
+    newIds: new Set(),
+    scoreFlow: false   // 评完一张 → 刷新后自动开下一张未评的卡
+  };
+
+  const NAV = [
+    { group: "工作台" },
+    { route: "overview", label: "工作台", icon: "grid" },
+    { route: "pool", label: "机会卡池", icon: "cards" },
+    { group: "观察" },
+    { route: "stages", label: "阶段", icon: "steps" },
+    { route: "members", label: "成员", icon: "users" },
+    { group: "成果与报告" },
+    { route: "insights", label: "归纳", icon: "spark" },
+    { route: "artifacts", label: "成果", icon: "doc" },
+    { route: "reportIndex", label: "报告", icon: "layers" },
+    { route: "projects", label: "项目", icon: "box" },
+    { group: "库" },
+    { route: "sources", label: "原材料", icon: "archive" },
+    { route: "record", label: "记录", icon: "archive" }
+  ];
+  const TITLES = {
+    overview: "工作台", pool: "机会卡池", stages: "阶段", members: "成员", insights: "归纳", artifacts: "成果",
+    reportIndex: "报告", report: "报告", projects: "项目", sources: "原材料", record: "记录",
+    project: "项目", member: "成员", stage: "阶段", artifact: "成果详情"
+  };
+  const NAV_ROOT = { project: "projects", member: "members", stage: "stages", artifact: "artifacts", report: "reportIndex" };
+
+  /* ============================ 导航 & 顶栏 ============================ */
+
+  function routeKey(h) { return h.route === "stage" ? "stages" : h.route; }
+
+  function navCounts() {
+    const db = state.db;
+    if (!db) return {};
+    const withReport = db.artifacts.filter((a) => a.report).length;
+    return {
+      pool: String(db.opportunities.length || ""),
+      stages: String(db.members.length || ""),
+      members: String(db.members.length || ""),
+      insights: String(db.insights.length || ""),
+      artifacts: String(db.artifacts.length || ""),
+      reportIndex: withReport ? String(withReport) : "",
+      projects: String(db.projects.length || ""),
+      sources: String(db.sources.length || ""),
+      record: String(db.artifacts.filter((a) => a.layer === "record").length || "")
+    };
+  }
+
+  function renderNav() {
+    const on = routeKey(state.hash);
+    const counts = navCounts();
+    el.nav.innerHTML = NAV.map((n) => {
+      if (n.group) return `<div class="nav-group">${esc(n.group)}</div>`;
+      const cur = n.route === on ? ` class="on" aria-current="page"` : "";
+      const ico = UI.icons[n.icon] || "";
+      return `<a href="#/${n.route}"${cur}>${ico}<span>${esc(n.label)}</span><span class="cnt">${counts[n.route] || ""}</span></a>`;
+    }).join("");
+  }
+
+  function setLive(kind, text) {
+    el.dot.className = "dot " + kind;
+    el.live.textContent = text;
+  }
+
+  function renderTopbar(h) {
+    const root = NAV_ROOT[h.route];
+    let items;
+    if (h.route === "report") {
+      const db = state.db;
+      const a = db && db.artifactById[h.arg];
+      items = [{ label: "报告", href: "#/reportIndex" }, { label: a ? a.shortTitle : "报告详情" }];
+    } else if (root) {
+      items = [{ label: TITLES[root], href: `#/${root}` }, { label: TITLES[h.route] === TITLES[root] ? (h.arg || "详情") : TITLES[h.route] }];
+    } else {
+      items = [{ label: TITLES[h.route] || "总览", href: `#/${h.route}` }];
+    }
+    el.crumb.innerHTML = UI.crumb(items);
+    if (el.menu.innerHTML === "") el.menu.innerHTML = UI.icons.menu;
+    if (el.clear.innerHTML === "") el.clear.innerHTML = UI.icons.x;
+    if (el.sico.innerHTML === "") el.sico.innerHTML = UI.icons.search;
+    /* 搜索框与 hash 的 q 同步;有搜索词时提示匹配范围 */
+    const q = h.params.q || "";
+    if (document.activeElement !== el.search && el.search.value !== q) el.search.value = q;
+    el.search.placeholder = q ? `在${TITLES[h.route] || "本页"}中筛选「${q}」` : "搜索成员 / 项目 / 结论 / 报告";
+    el.clear.hidden = !el.search.value;
+  }
+
+  /* ============================ 渲染 ============================ */
+
+  function loadingHTML() {
+    return `<div class="view-head"><h1 class="skel skel-line" style="width:120px;height:24px"></h1></div>
+      ${UI.skeleton("metrics")}<div style="height:28px"></div>${UI.skeleton("rows", 5)}`;
+  }
+
+  function errorHTML(msg, what) {
+    return `<div class="view-head"><div><h1>${what ? esc(what) + "没有加载出来" : "数据没有取到"}</h1><p class="lede">页面不会用假数据顶替,也不会把"读取失败"伪装成"没有内容"。请确认网络与数据源配置后重试。</p></div></div>
+      <div class="error-box"><p><b>读取 Parse 失败:</b>${esc(msg)}</p>
+      <p style="margin-top:8px">检查 <code>js/config.js</code> 中的 <code>parseBase</code> 与 <code>appId</code>。</p>
+      <p style="margin-top:14px"><button class="btn" data-act="refresh">${UI.icons.refresh} 重试</button></p></div>`;
+  }
+
+  function notFound(what, href) {
+    return UI.empty({
+      title: `${what}不存在`,
+      body: "链接里的编号在库里找不到。可能已被删除,或链接复制时被截断。",
+      actions: [{ label: `返回${what}列表`, href }]
+    });
+  }
+
+  /* 当前路由 → 视图 HTML(不含外层 .view) */
+  function routeHTML() {
+    if (state.error) {
+      /* 报告页取数失败:保留标题与返回入口,明确告诉读者"这不是报告不存在,是没取到" */
+      const h0 = state.hash;
+      if (h0.route === "report") return errorHTML(state.error, "报告");
+      return errorHTML(state.error);
+    }
+    if (!state.db) return loadingHTML();
+    const db = state.db;
+    const h = state.hash;
+    const c = Q.criteria(h.params);
+    const A = window.KF_VIEW_ARTIFACTS;
+
+    switch (h.route) {
+      case "artifacts":
+        return A.listView(db, c, {
+          title: "成果",
+          lede: "学员与分身产生的一切成果。每条都能看出它属于哪一层、谁在哪个阶段交的、有没有原文。",
+          emptyTitle: "还没有归集的成果",
+          emptyBody: "学员对话产生的成果经上传 skill 入库后,这里会实时出现。每条都带出身:谁、什么时候、哪个阶段、有没有原文。"
+        });
+      case "record":
+        return A.listView(db, c, {
+          filterLayer: "record", layerLocked: true, sortLocked: false, defaultSort: "newest",
+          title: "记录层",
+          lede: "不合格、纯推测、重复的成果。只留档,不进知识库,也不参与归纳。",
+          unit: "条记录",
+          emptyTitle: "记录层是空的",
+          emptyBody: "当上传的内容被判定为不合格、纯推测或重复时,会自动落到这里留档。它们不会进入归纳,也不会被回灌。"
+        });
+      case "insights":
+        return window.KF_VIEW_INSIGHTS.render(db, c);
+      case "projects":
+        return window.KF_VIEW_PROJECTS.listView(db, c);
+      case "project": {
+        const html = window.KF_VIEW_PROJECTS.detail(db, h.arg);
+        return html || notFound("项目", "#/projects");
+      }
+      case "members":
+        return window.KF_VIEW_MEMBERS.listView(db, c);
+      case "member":
+        return window.KF_VIEW_MEMBERS.detail(db, h.arg) || notFound("成员", "#/members");
+      case "stages":
+        return window.KF_VIEW_STAGES.render(db);
+      case "stage":
+        return window.KF_VIEW_STAGES.detail(db, h.arg) || notFound("阶段", "#/stages");
+      case "sources":
+        return window.KF_VIEW_SOURCES.render(db, h.params);
+      case "artifact":
+        return `<div style="max-width:980px">${window.KF_DETAIL.artifact(db, h.arg, { inline: true }).html}</div>`;
+      case "reportIndex":
+        return window.KF_REPORT.index(db, c);
+      case "report": {
+        const html = window.KF_REPORT.view(db, h.arg, h.params);
+        return html || notFound("报告", "#/reportIndex");
+      }
+      case "overview":
+        return window.KF_VIEW_OVERVIEW.render(db);
+      case "pool":
+        return window.KF_VIEW_POOL.render(db, h.params);
+      default:
+        return notFound("页面", "#/overview");
+    }
+  }
+
+  /* 同构替换:当前视图与目标视图子节点一一对应时逐个替换,否则整体替换。
+   * 注意:被替换掉的节点上会触发"移除"(不是 click),这里顺手把仍按下鼠标的主键复位,
+   * 否则按钮 DOM 在按下与释放之间被换掉,下一次点击会被浏览器当成双击的第二段。 */
+  function morph(target, html) {
+    const tpl = document.createElement("div");
+    tpl.innerHTML = html;
+    const same = target.children.length && target.children.length === tpl.children.length &&
+      [...target.children].every((n, i) => n.tagName === tpl.children[i].tagName);
+    if (same) {
+      resetPointer();
+      [...tpl.children].forEach((n, i) => target.children[i].replaceWith(n));
+    } else {
+      target.innerHTML = html;
+    }
+  }
+
+  /* 筛选 chip 原地同步:值、计数、选中态、置灰态 */
+  function syncChips(fl, ff) {
+    const map = new Map([...ff.querySelectorAll(".chip")].map((c) => [c.dataset.dim + "|" + c.dataset.value, c]));
+    fl.querySelectorAll(".chip").forEach((c) => {
+      const n = map.get(c.dataset.dim + "|" + c.dataset.value);
+      if (!n) return;
+      if (c.dataset.on !== n.dataset.on) c.dataset.on = n.dataset.on;
+      const num = c.querySelector(".n"), num2 = n.querySelector(".n");
+      if (num && num2 && num.textContent !== num2.textContent) num.textContent = num2.textContent;
+      if (c.dataset.zero !== n.dataset.zero) c.dataset.zero = n.dataset.zero;
+      if (c.classList.contains("zero") !== n.classList.contains("zero")) c.classList.toggle("zero");
+    });
+  }
+
+  /* 按下过的按钮如果已经在重绘中被替换,补发一次 pointerup,让浏览器的"按下次序"归位 */
+  let lastPress = null;
+  document.addEventListener("pointerdown", (e) => { lastPress = e.timeStamp; }, true);
+  function resetPointer() {
+    if (!lastPress) return;
+    lastPress = null;
+    try {
+      document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, cancelable: true }));
+      document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
+    } catch (e) { /* 老浏览器忽略 */ }
+  }
+
+  /* ---------- 视图内更新 ----------
+   * 切换筛选 / 排序时整页不动,只按"数据声明"更新需要变的部分:
+   *   [data-filterbar]  筛选选项的条数与选中态(只改 aria-disabled 与计数,节点不换)
+   *   [data-active]     已选条件回显
+   *   [data-list]       结果列表 / 空态
+   * 这样被点击的按钮不会被替换,"连续点两个条件"天然不会丢;同时也避免模糊层闪烁。 */
+  function updateView(html) {
+    const tpl = document.createElement("div");
+    tpl.innerHTML = html;
+    const fresh = (key) => tpl.querySelector(`[data-${key}]`);
+    const live = (key) => el.view.querySelector(`[data-${key}]`);
+
+    /* 筛选条:按 data-dim + data-value 对齐 */
+    const fl = live("filterbar");
+    const ff = fresh("filterbar");
+    if (fl && ff) {
+      syncChips(fl, ff);
+      /* 结果数 / 清除按钮 */
+      const rn = fl.querySelector("[data-rn]");
+      const rn2 = ff.querySelector("[data-rn]");
+      if (rn && rn2) rn.textContent = rn2.textContent;
+      /* 清除按钮:无则加、有则原地换(换掉的不是 chip,不影响正在点的按钮) */
+      const ca = fl.querySelector(".clear-all");
+      const ca2 = ff.querySelector(".clear-all");
+      if (ca2 && ca) ca.replaceWith(ca2);
+      else if (ca2) fl.appendChild(ca2);
+      else if (ca) ca.remove();
+    }
+    /* 已选条件、结果区:整体替换(这里没有正在按住的按钮) */
+    ["active", "list"].forEach((k) => {
+      const l = live(k), f = fresh(k);
+      if (l && f) l.replaceWith(f);
+      else if (f) el.view.appendChild(f);
+      else if (l) l.remove();
+    });
+  }
+
+  function render(opts) {
+    state.hash = R.parseHash();
+    const h = state.hash;
+    document.title = `${TITLES[h.route] || "看板"} · 康师傅产品创新看板`;
+    /* 任何一次渲染后都从真实 hash 重建筛选条件,杜绝"上次的筛选"渗进新页面 */
+    h.params = { ...R.parseHash().params };
+    const html = routeHTML();
+    /* 报告页可以长达万字:只有明确要求局部更新时才走局部,否则整块替换 */
+    const local = opts && opts.local && state.db && !state.error && h.route !== "report";
+    if (local) updateView(html);
+    else morph(el.view, html);
+    renderNav();
+    renderTopbar(h);
+    syncDrawer();
+    syncReportDom(h);
+    if (state.newIds.size) { markNew(); state.newIds = new Set(); }
+  }
+
+  /* ---------- 报告页的 DOM 副作用 ----------
+   * 报告正文不是拼完字符串就完事:它要"活着"——章节目录随滚动高亮、点目录平滑跳转、
+   * 带 ?ch= 进入时还原到那一章。具体逻辑在 views/report.js 的 bind() 里。 */
+  function syncReportDom(h) {
+    if (h.route !== "report") { document.body.classList.remove("in-report"); return; }
+    const root = el.view.querySelector("[data-report]");
+    if (!root) return;
+    document.body.classList.add("in-report");
+    window.KF_REPORT.bind(root, h.arg);
+  }
+
+  /* 抽屉由 hash 驱动:?ins= / ?art= / ?src= / ?ev=
+   * ?ev= 是报告里的"证据锚点":列出某个结论的全部支撑成果(证据链),不离开报告也能追到原文 */
+  let lastDrawerKey = "";
+  function syncDrawer() {
+    const p = state.hash.params;
+    const db = state.db;
+    let d = null;
+    if (db && p.opp) d = window.KF_OPP.drawer(db, p.opp);
+    else if (db && p.ev) d = window.KF_REPORT.evidenceDrawer(db, p.ev, p.back && db.artifactById[p.back] ? db.artifactById[p.back] : null);
+    else if (db && p.ins) d = window.KF_DETAIL.insight(db, p.ins);
+    else if (db && p.art) d = window.KF_DETAIL.artifact(db, p.art);
+    else if (db && p.src) d = window.KF_DETAIL.source(db, p.src);
+    const open = Boolean(d && d.html);
+    const key = open ? `${p.opp || ""}|${p.ev || ""}|${p.ins || ""}|${p.art || ""}|${p.src || ""}` : "";
+    if (open) {
+      el.drawer.innerHTML = d.html;
+      el.drawer.classList.add("open");
+      el.scrim.classList.add("open");
+      document.body.style.overflow = "hidden";
+      if (key !== lastDrawerKey) el.drawer.scrollTop = 0;
+      if (p.opp) window.KF_OPP.bindDrawer(el.drawer);
+    } else if (el.drawer.classList.contains("open")) {
+      el.drawer.classList.remove("open");
+      el.scrim.classList.remove("open");
+      document.body.style.overflow = "";
+    }
+    lastDrawerKey = key;
+  }
+
+  /* 轮询发现新记录:行上加一次性高亮(局部,不打断阅读) */
+  function markNew() {
+    if (!state.newIds.size) return;
+    el.view.querySelectorAll("tr[data-id]").forEach((tr) => {
+      if (state.newIds.has(tr.dataset.id)) tr.classList.add("flash");
+    });
+  }
+
+  /* ============================ 数据 ============================ */
+
+  let pendingQuiet = false;
+  async function refresh(opts) {
+    /* 正在刷新时又来一次(例如写库成功后的重读撞上 15s 轮询):记住,等这次结束后马上再拉一遍,
+     * 不能直接丢掉——否则"写完看到旧值"。 */
+    if (state.loading) { if (opts && opts.quiet) pendingQuiet = true; return; }
+    state.loading = true;
+    const quiet = opts && opts.quiet;
+    setLive("syncing", quiet ? "自动同步…" : "正在读取…");
+    try {
+      const raw = await D.load();
+      const prev = state.db ? new Set(state.db.artifacts.map((a) => a.id)) : null;
+      state.db = window.KF_MODEL.build(raw);
+      if (prev) state.newIds = new Set(state.db.artifacts.map((a) => a.id).filter((id) => !prev.has(id)));
+      state.error = null;
+      state.everLoaded = true;
+      setLive("live", `已同步 · ${U.fmtAgo(raw.loadedAt)}`);
+    } catch (e) {
+      state.error = e.message || String(e);
+      setLive("err", "读取失败");
+    }
+    state.loading = false;
+    render();
+    if (state.scoreFlow) { state.scoreFlow = false; nextUnscored(); }
+    if (pendingQuiet) { pendingQuiet = false; refresh({ quiet: true }); }
+  }
+
+  /* 评完一张卡就轮到下一张:分数落库、页面刷新之后,自动在池子里找第一张"我"还没评过的卡打开。
+   * 这样 30 多人连续点评不用每次自己找卡;最后一张评完就停在池子上,提示已经评完。 */
+  function nextUnscored() {
+    const me = window.KF_OPP.getMe();
+    if (!me || !state.db || state.hash.route !== "pool") return;
+    const next = window.KF_OPP.sortCards(state.db.opportunities.filter((o) => !o.stats.byUser[me]), "prio")[0];
+    if (next) {
+      openDetail("opp", next.id);
+      window.KF_OPP.toast(`${me} 还没评的下一张:${next.title}`, "ok");
+    } else {
+      window.KF_OPP.toast(`${me} 已经评完池子里的卡了`, "ok");
+    }
+  }
+
+  /* ============================ 交互 ============================ */
+
+  function setParams(patch) {
+    const p = { ...state.hash.params };
+    Object.entries(patch).forEach(([k, v]) => {
+      if (v === "" || v == null) delete p[k]; else p[k] = v;
+    });
+    /* 只由本函数负责重绘:写地址时不再广播 route:changed,否则会被监听器二次渲染 */
+    R.write(p, { silent: true });
+    state.hash = R.parseHash();
+    render({ local: true });
+  }
+
+  function toggleFacet(dim, value, multi) {
+    const cur = Q.parseList(state.hash.params[dim]);
+    const next = multi
+      ? (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value])
+      : (cur.length === 1 && cur[0] === value ? [] : [value]);
+    setParams({ [dim]: next.join(",") });
+  }
+
+  const FILTER_DIMS = ["priority", "stage", "category", "evidence", "layer", "member"];
+  const SEARCHABLE = ["artifacts", "insights", "projects", "members", "record"];
+
+  let searchTimer = 0;
+  function onSearchInput() {
+    el.clear.hidden = !el.search.value;
+    clearTimeout(searchTimer);
+    searchTimer = setTimeout(() => {
+      const q = el.search.value.trim();
+      if (q === (state.hash.params.q || "")) return; /* 已是当前条件,不必重绘 */
+      const route = state.hash.route;
+      /* 不支持的页面就地搜索:自动落到成果页,并保留其余条件 */
+      if (q && !SEARCHABLE.includes(route)) {
+        window.location.hash = R.buildHash("artifacts", null, { ...state.hash.params, q });
+        return;
+      }
+      setParams({ q });
+    }, 180);
+  }
+
+  function openDetail(kind, id) {
+    const p = { ...state.hash.params, [kind]: id };
+    delete p.ev; delete p.back;
+    if (kind !== "opp") delete p.opp;
+    R.write(p, { silent: true });
+    state.hash = R.parseHash();
+    render();
+    el.drawer.focus({ preventScroll: true });
+  }
+
+  function closeDetail() {
+    setParams({ ins: "", art: "", src: "", ev: "", opp: "" });
+  }
+
+  /* 只关抽屉(例如"跳去另一页"时顺手收起),不重绘 */
+  function closeDrawerOnly() {
+    el.drawer.classList.remove("open");
+    el.scrim.classList.remove("open");
+    document.body.style.overflow = "";
+    const p = { ...state.hash.params };
+    delete p.ins; delete p.art; delete p.src; delete p.ev; delete p.opp;
+    R.write(p, { silent: true });
+    state.hash = R.parseHash();
+  }
+
+  /* 打开报告:清掉抽屉参数,避免进报告时还挂着一个抽屉;保留回到列表所需的筛选条件 */
+  function openReport(id) {
+    const p = { ...state.hash.params };
+    delete p.ins; delete p.art; delete p.src; delete p.ev; delete p.opp; delete p.ch;
+    el.drawer.classList.remove("open");
+    el.scrim.classList.remove("open");
+    document.body.style.overflow = "";
+    R.go(R.buildHash("report", id, p));
+    window.scrollTo(0, 0);
+  }
+
+  function openEvidence(id, back) {
+    const p = { ...state.hash.params, ins: "", art: "", src: "", opp: "", ev: id };
+    if (back) p.back = back; else delete p.back;
+    R.write(p, { silent: true });
+    state.hash = R.parseHash();
+    render();
+    el.drawer.focus({ preventScroll: true });
+  }
+
+  /* 证据链抽屉里的"← 返回成果":先关抽屉,再一次性跳回那条成果的台账位置 */
+  function backFromEvidence(href) {
+    closeDrawerOnly();
+    if (href) R.go(href);
+  }
+
+  /* 点击目标在按下与释放之间若被重绘替换,click 会落到外层容器上。
+   * 这里在 pointerdown 时记下元素;click 若从容器冒泡上来,按记录的元素处理。 */
+  let pressTarget = null;
+  document.addEventListener("pointerdown", (e) => {
+    pressTarget = e.target && e.target.closest ? e.target.closest("[data-act]") : null;
+  }, true);
+
+  document.addEventListener("click", (ev) => {
+    const link = ev.target.closest("a[href]");
+    /* 纯链接(导航、外部链接)交给浏览器 */
+    if (link && !link.dataset.act) {
+      if (el.side.classList.contains("open")) closeNav();
+      return;
+    }
+    let n = ev.target.closest("[data-act]");
+    /* 没命中具体元素时,用按下时的目标兜底 */
+    if (!n && pressTarget && document.contains(pressTarget)) n = pressTarget;
+    pressTarget = null;
+    /* 点机会卡空白处 = 打开卡片详情;点卡片里的按钮/链接时以按钮为准 */
+    if (!n && !link) {
+      const card = ev.target.closest(".ocard");
+      if (card && card.dataset.opp) { ev.preventDefault(); openDetail("opp", card.dataset.opp); return; }
+    }
+    if (!n) return;
+    /* 防重复处理:一次点击里,同一个元素只响应一次(兜底路径可能与真实冒泡是同一个元素) */
+    if (ev.__kfAct) return;
+    ev.__kfAct = true;
+    const d = n.dataset;
+    switch (d.act) {
+      case "facet":
+        toggleFacet(d.dim, d.value, n.closest(".fgroup").classList.contains("multi"));
+        break;
+      case "remove-filter":
+        setParams({ [d.dim]: Q.parseList(state.hash.params[d.dim]).filter((x) => x !== d.value).join(",") });
+        break;
+      case "clear-filters": {
+        const patch = {};
+        [...FILTER_DIMS, "q"].forEach((k) => { patch[k] = ""; });
+        el.search.value = "";
+        el.clear.hidden = true;
+        setParams(patch);
+        break;
+      }
+      case "clear-search":
+        el.search.value = "";
+        el.clear.hidden = true;
+        setParams({ q: "" });
+        break;
+      case "open-report": ev.preventDefault(); openReport(d.id); break;
+      case "clear-focus": ev.preventDefault(); setParams({ focus: "" }); break;
+      case "open-evidence": ev.preventDefault(); openEvidence(d.id, d.back || ""); break;
+      case "drawer-back": ev.preventDefault(); backFromEvidence(d.href); break;
+      /* 归纳页:展开"归纳自哪几份报告"——本地切换,不改 hash,不打乱筛选 */
+      case "toggle-sources": {
+        ev.preventDefault();
+        const box = document.getElementById("src-" + d.id);
+        if (!box) break;
+        const open = box.hidden;
+        box.hidden = !open;
+        n.setAttribute("aria-expanded", open ? "true" : "false");
+        n.classList.toggle("on", open);
+        break;
+      }
+      case "sort": ev.preventDefault(); setParams({ sort: state.hash.params.sort === d.sort ? "" : d.sort }); break;
+      /* ---------- 机会卡 ---------- */
+      case "open-opp": ev.preventDefault(); openDetail("opp", d.id); break;
+      case "opp-facet": {
+        ev.preventDefault();
+        const cur = Q.parseList(state.hash.params[d.dim]);
+        const next = cur.includes(d.value) ? cur.filter((x) => x !== d.value) : [...cur, d.value];
+        setParams({ [d.dim]: next.join(",") });
+        break;
+      }
+      case "opp-clear": ev.preventDefault(); setParams({ priority: "", stage: "", category: "", status: "", member: "" }); break;
+      case "opp-sort": ev.preventDefault(); setParams({ sort: d.sort === "prio" ? "" : d.sort }); break;
+      case "opp-score":
+        ev.preventDefault();
+        state.scoreFlow = state.hash.route === "pool";
+        window.KF_OPP.submitScore(el.drawer).then((ok) => { if (!ok) state.scoreFlow = false; });
+        break;
+      case "opp-status":
+        ev.preventDefault();
+        window.KF_OPP.submitStatus(el.drawer, d.id, d.status);
+        break;
+      case "me-select":
+        /* select 的变更由 change 事件处理(见下),这里防止 click 冒泡重复 */
+        break;
+      case "open-insight": ev.preventDefault(); openDetail("ins", d.id); break;
+      case "open-artifact": ev.preventDefault(); openDetail("art", d.id); break;
+      case "open-source": ev.preventDefault(); openDetail("src", d.id); break;
+      case "close-drawer": ev.preventDefault(); closeDetail(); break;
+      case "drawer-back":
+        /* 抽屉里的"返回上一层":一次性同时关抽屉、清导航到目标页面,不经过两次渲染 */
+        ev.preventDefault();
+        if (el.drawer.classList.contains("open")) closeDrawerOnly();
+        R.go(d.href);
+        break;
+      case "nav": ev.preventDefault(); window.location.hash = d.href; closeNav(); break;
+      case "refresh": ev.preventDefault(); refresh(); break;
+      case "open-nav": ev.preventDefault(); openNav(); break;
+      case "close-nav": ev.preventDefault(); closeNav(); break;
+      default: break;
+    }
+  });
+
+  /* 身份选择:首页"给我的"下拉 / 卡片抽屉里的评分人下拉,两处共用同一个本地身份 */
+  document.addEventListener("change", (ev) => {
+    const sel = ev.target.closest && ev.target.closest("select#me-select, select#o-who");
+    if (!sel) return;
+    const v = sel.value.trim();
+    if (!v) return;
+    window.KF_OPP.setMe(v);
+    if (sel.id === "me-select") {
+      /* 首页"给我的"换人:整页重绘一次(只影响这一块,别的视角数据不变) */
+      if (state.hash.route === "overview") render();
+      return;
+    }
+    /* 抽屉里的评分人:只更新抽屉里的"我的旧分"提示,不打断正在拖的评分滑杆 */
+    const o = state.db && state.db.opportunityById[state.hash.params.opp];
+    const mine = o && o.stats.byUser[v];
+    window.KF_OPP.toast(mine ? `${v} 之前给过 ${mine.score} 分,再交一次会覆盖` : `将以 ${v} 的身份评分`, "ok");
+  });
+
+  /* 键盘:卡片可用 Tab 聚焦,Enter / 空格打开 */
+  document.addEventListener("keydown", (ev) => {
+    if (ev.key !== "Enter" && ev.key !== " ") return;
+    const c = ev.target.closest && ev.target.closest(".ocard");
+    if (!c || ev.target !== c) return;
+    ev.preventDefault();
+    openDetail("opp", c.dataset.opp);
+  }, true);
+
+  /* 卡片光斑跟随指针:只写两个 CSS 变量,不触发重排;离开时回左上光标位置 */
+  document.addEventListener("pointermove", (e) => {
+    const c = e.target.closest && e.target.closest(".ocard");
+    if (!c) return;
+    const r = c.getBoundingClientRect();
+    c.style.setProperty("--mx", ((e.clientX - r.left) / r.width * 100).toFixed(1) + "%");
+    c.style.setProperty("--my", ((e.clientY - r.top) / r.height * 100).toFixed(1) + "%");
+  }, { passive: true });
+
+  el.search.addEventListener("input", onSearchInput);
+  el.search.addEventListener("keydown", (e) => {
+    if (e.key === "Escape") {
+      el.search.value = ""; el.clear.hidden = true; setParams({ q: "" }); el.search.blur();
+    }
+  });
+
+  document.addEventListener("keydown", (e) => {
+    if (e.key === "Escape") {
+      if (el.drawer.classList.contains("open")) closeDetail();
+      else if (el.side.classList.contains("open")) closeNav();
+    } else if (e.key === "/" && !/^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName)) {
+      e.preventDefault(); el.search.focus();
+    }
+  });
+
+  function openNav() { el.side.classList.add("open"); el.sideScrim.classList.add("open"); }
+  function closeNav() { el.side.classList.remove("open"); el.sideScrim.classList.remove("open"); }
+
+  window.addEventListener("hashchange", () => { state.hash = R.parseHash(); render(); });
+  document.addEventListener("route:changed", () => { state.hash = R.parseHash(); render(); });
+
+  /* ============================ 轮询 ============================ */
+  /* 线下现场:每 refreshMs 静默同步一次;页面隐藏或抽屉打开时暂停,回来立刻补一次 */
+  function startPolling() {
+    if (!CFG.refreshMs) return;
+    setInterval(() => {
+      if (document.hidden || el.drawer.classList.contains("open")) return;
+      refresh({ quiet: true });
+    }, CFG.refreshMs);
+    document.addEventListener("visibilitychange", () => {
+      if (!document.hidden && state.everLoaded) refresh({ quiet: true });
+    });
+  }
+
+  /* ============================ 启动 ============================ */
+  el.view.innerHTML = loadingHTML();
+  renderNav();
+  refresh().then(startPolling);
+
+  /* 调试 / 测试入口 */
+  window.__KF__ = { state, render, refresh, closeDetail, closeDrawerOnly, openDetail,
+    afterScore: () => refresh({ quiet: true }) };
+})();

+ 35 - 0
legacy/js/config.js

@@ -0,0 +1,35 @@
+/* config.js —— 唯一需要改的地方:数据源地址与表名。
+ * 这里只有 App ID(公开可读标识)。凭据不进入前端,任何情况下都不要在这里加 key。 */
+window.KF_CONFIG = {
+  /* Parse REST 根地址(对应任务书 "Parse Base") */
+  parseBase: "https://kangshifu.fmode.cn/parse",
+  appId: "FKieRbIoGt_main",
+
+  /* 六张表(+机会卡池)的 class 名。换库 / 换表只改这里 */
+  classes: {
+    project: "KfProject",   // 项目(带 layer)
+    artifact: "KfArtifact", // 成果:process 层主体,也承接 raw / sediment(样例) / record
+    insight: "KfInsight",   // 归纳:sediment 层结论
+    source: "KfSource",     // 原材料层(品类/竞品/工艺/人群,字段待调研回传)
+    session: "KfSession",   // 学员会话进度
+    opportunity: "KfOpportunity" // 机会卡池:组织资产,有独立生命周期(评分/状态/决策)
+  },
+
+  /* 现场看板:在对话里 ≥5 条消息、超过这么多小时没有新成果 = 卡住 */
+  stuckHours: 8,
+
+  /* 评分模型的五个维度(机会评估 Agent 与人工都按这一套打分,0–100) */
+  scoreDims: [
+    { key: "demand", label: "需求强度", weight: 30 },
+    { key: "competition", label: "竞争空档", weight: 20 },
+    { key: "feasibility", label: "工艺可行", weight: 25 },
+    { key: "fit", label: "品牌契合", weight: 15 },
+    { key: "scale", label: "规模潜力", weight: 10 }
+  ],
+
+  /* 线下现场轮询间隔(毫秒)。0 = 关闭轮询 */
+  refreshMs: 15000,
+
+  /* 单次拉取上限。超过时页面会明确提示数据被截断 */
+  pageLimit: 1000
+};

+ 88 - 0
legacy/js/data.js

@@ -0,0 +1,88 @@
+/* data.js —— Parse REST 读取层。
+ * 只带 X-Parse-Application-Id(公开读),不带任何凭据。
+ * 不用 count 参数(服务端已知问题),一律取 results 长度。 */
+(function () {
+  "use strict";
+
+  const cfg = window.KF_CONFIG;
+  const state = {
+    loadedAt: null,
+    ok: false,
+    error: null,
+    truncated: [],   // 被截断的表名
+    counts: {}
+  };
+
+  /* Parse 的日期在各字段里是 {"__type":"Date","iso":"…"}(或 {"__type":"Date","__d"…})。
+   * 下游到处 new Date(x) 排序、算时长,逐个处理容易漏——在入口一次性换成 ISO 串。
+   * 只处理带 __type 的对象本身;数组/普通对象(scores、decision、report)原样递归。 */
+  function normalizeDates(v) {
+    if (Array.isArray(v)) return v.map(normalizeDates);
+    if (v && typeof v === "object") {
+      if (v.__type === "Date") {
+        window.__DBGD = (window.__DBGD||[]).concat([JSON.stringify(v)]);
+        const t = v.iso ? new Date(v.iso).getTime() : v.__d != null ? Number(v.__d) : NaN;
+        return Number.isFinite(t) ? new Date(t).toISOString() : null;  /* 统一成 UTC ISO,避免各浏览器解析口径不一 */
+      }
+      const out = {};
+      for (const k in v) out[k] = normalizeDates(v[k]);
+      return out;
+    }
+    return v;
+  }
+
+  function headers() {
+    return { "X-Parse-Application-Id": cfg.appId, Accept: "application/json" };
+  }
+
+  async function getJSON(path) {
+    const res = await fetch(cfg.parseBase + path, { headers: headers(), cache: "no-store" });
+    if (!res.ok) throw new Error(`HTTP ${res.status} · ${path}`);
+    return res.json();
+  }
+
+  /* 分页拉取:单页 pageLimit 条,用 skip 往后翻,直到不足一页。
+   * 线下 40 人两天写下来完全可能超过 1000 条;超过时要能全部取到,而不是悄悄截断。
+   * 翻了 50 页仍满页才标记"被截断",交给 UI 明示。 */
+  async function fetchClass(cls) {
+    const page = Math.max(1, cfg.pageLimit || 1000);
+    const rows = [];
+    let skip = 0;
+    for (let guard = 0; guard < 50; guard++) {
+      const q = `?limit=${page}&skip=${skip}&order=createdAt`;
+      const json = await getJSON(`/classes/${cls}${q}`);
+      const batch = Array.isArray(json.results) ? normalizeDates(json.results) : [];
+      rows.push(...batch);
+      if (batch.length < page) return rows;
+      skip += page;
+    }
+    state.truncated.push(cls);   /* 翻了 50 页还没完:如实标记被截断 */
+    return rows;
+  }
+
+  /* 拉全部六张表。任何一张失败都整体失败并给出原因,UI 显示错误态而不是空态 */
+  async function load() {
+    state.truncated = [];
+    const c = cfg.classes;
+    const names = ["project", "artifact", "insight", "source", "session", "opportunity"];
+    try {
+      const rows = await Promise.all(names.map((n) => fetchClass(c[n])));
+      const out = {};
+      names.forEach((n, i) => { out[n] = rows[i]; });
+      Object.assign(out, { loadedAt: new Date().toISOString(), truncated: state.truncated.slice() });
+      state.ok = true;
+      state.error = null;
+      state.loadedAt = out.loadedAt;
+      state.counts = { project: out.project.length, artifact: out.artifact.length,
+        insight: out.insight.length, source: out.source.length, session: out.session.length,
+        opportunity: out.opportunity.length };
+      return out;
+    } catch (e) {
+      state.ok = false;
+      state.error = e.message || String(e);
+      throw e;
+    }
+  }
+
+  window.KF_DATA = { load, state, normalizeDates };
+})();

+ 417 - 0
legacy/js/model.js

@@ -0,0 +1,417 @@
+/* model.js —— 原始行 → 页面用的标准对象;所有派生数字在这里算,视图只负责画。
+ *
+ * 四层归属规则(每条记录都能看出自己属于哪层):
+ *   raw       layer=raw            调研事实(KfSource / 带 layer=raw 的成果)
+ *   process   layer 为空或 process 学员与分身成果;历史字段 tier=pending 也归到这里
+ *   sediment  layer=sediment       归纳结论(KfInsight),或已被归纳引用的成果
+ *   record    layer=record         不合格留档;历史 tier=record 同样归这里
+ *
+ * 另外每个成果都带 ownerKey / ownerName 作为"出身",没有出身的记录不可进沉淀层。 */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+  const U = window.KF_UTIL;
+
+  function normalizeLayer(row, isInsight) {
+    /* 归纳硬规则:没有出身的记录不许进沉淀层,缺 layer 的归纳落到记录层留档 */
+    if (isInsight) {
+      if (row.layer && T.layerOf(row.layer)) return row.layer;
+      return (row.derivedFrom || []).length ? "sediment" : "record";
+    }
+    let l = row.layer;
+    if (!l && row.tier) l = row.tier === "pending" ? "process" : row.tier;
+    if (!l) return "process";
+    return T.layerOf(l) ? l : "process";
+  }
+
+  function normArtifact(raw) {
+    const layer = normalizeLayer(raw, false);
+    const ownerKey = raw.ownerSession || raw.ownerName || "";
+    const quotes = Array.isArray(raw.sources) ? raw.sources : [];
+    const ev = T.evidenceOf(raw.evidenceLevel, quotes.length > 0);
+    const st = T.stageOf(raw.stage);
+    /* 报告正文挂在成果的 report 字段上(结构见 README)。没有就是没有,不伪造。 */
+    const report = raw.report && typeof raw.report === "object" && !Array.isArray(raw.report) ? raw.report : null;
+    return {
+      kind: "artifact",
+      id: raw.objectId,
+      title: raw.title || "(未命名成果)",
+      /* 演示数据标题形如「夜宵场景低卡高蛋白拌面 - opportunity_card」,把类型后缀剥掉做展示名 */
+      shortTitle: String(raw.title || "(未命名成果)").replace(/\s*-\s*[a-z_]+$/, ""),
+      type: raw.artifactType || "",
+      typeLabel: T.ARTIFACT_TYPES[raw.artifactType] || raw.artifactType || "成果",
+      ownerName: raw.ownerName || "",
+      ownerKey,
+      stageObj: st,
+      stage: st ? st.label : (raw.stage || "—"),
+      layer,
+      evidenceLevel: ev.key,
+      evidenceLabel: ev.label,
+      evidenceRank: ev.rank,
+      evidenceUnaligned: raw.evidenceLevel === "verified" && quotes.length === 0,
+      quotes,
+      quoteCount: quotes.length,
+      projectId: raw.projectId || "",
+      content: raw.content && typeof raw.content === "object" ? raw.content : null,
+      rawText: raw.rawText || "",
+      summaryText: (raw.content && raw.content.summary) || raw.summary || "",
+      report,
+      tags: Array.isArray(raw.tags) ? raw.tags : [],
+      createdAt: raw.createdAt || null,
+      updatedAt: raw.updatedAt || null,
+      uploadedBy: raw.uploadedBy || "",
+      upload: normUpload(raw, raw.ownerName, raw.ownerSession),
+      _raw: raw
+    };
+  }
+
+  /* 上传元数据:谁 + 哪个 Agent + 什么方式 + 什么时候。
+   * 五个字段缺任何一个都如实留空,UI 显示"未记录",不拿 createdAt 冒充 uploadedAt
+   * (除了下面的 explicit 标记:老数据由 seed 脚本一次性补了 uploadedAt=createdAt,那是有据可查的)。 */
+  function normUpload(row, fallbackBy, fallbackKey) {
+    const u = {
+      by: row.uploadedBy || fallbackBy || "",
+      byKey: row.uploadedByKey || fallbackKey || "",
+      agentName: row.agentName || "",
+      agentId: row.agentId || "",
+      method: row.uploadMethod || "",
+      at: row.uploadedAt || null
+    };
+    u.complete = Boolean(u.by && u.agentName && u.method && u.at);
+    return u;
+  }
+
+  /* 评分:多人打分,平均分前端算。非法分数(非数字 / 越界)直接丢弃,不参与平均。 */
+  function normScores(raw) {
+    const list = Array.isArray(raw) ? raw : [];
+    return list.map((s) => ({
+      user: (s && s.user) || "",
+      score: s && typeof s.score === "number" && s.score >= 0 && s.score <= 100 ? s.score : null,
+      comment: (s && s.comment) || "",
+      at: (s && s.at) || null
+    })).filter((s) => s.user && s.score != null)
+      .sort((a, b) => new Date(a.at || 0) - new Date(b.at || 0));
+  }
+
+  function scoreStats(scores) {
+    if (!scores.length) return { n: 0, avg: null, min: null, max: null, byUser: {} };
+    const sum = scores.reduce((n, s) => n + s.score, 0);
+    const byUser = {};
+    scores.forEach((s) => { byUser[s.user] = s; }); /* 同一人多次打分:以最后一次为准(数组已按时间排好) */
+    const vals = scores.map((s) => s.score);
+    return {
+      n: scores.length,
+      avg: Math.round((sum / scores.length) * 10) / 10,
+      min: Math.min(...vals),
+      max: Math.max(...vals),
+      byUser
+    };
+  }
+
+  /* 机会卡:组织资产。状态/优先级/决策/评分是它自己的,不借项目的字段;
+   * 关联成果只存 id,展示时再解析,成果被删了也不会让卡片消失(只标"关联成果已不存在")。 */
+  function normOpportunity(raw, artifactById, projectById) {
+    const st = T.stageOf(raw.stage);
+    const status = T.OPP_STATUS_KEYS.includes(raw.status) ? raw.status : "candidate";
+    const ev = T.oppEvidenceOf(raw.evidenceLevel);
+    const scores = normScores(raw.scores);
+    const arts = (Array.isArray(raw.linkedArtifacts) ? raw.linkedArtifacts : [])
+      .map((id) => artifactById[id] || { missing: true, id });
+    const proj = projectById[raw.linkedProject] || null;
+    const dec = raw.decision && typeof raw.decision === "object" ? raw.decision : null;
+    return {
+      kind: "opportunity",
+      id: raw.objectId,
+      title: raw.title || "(未命名机会)",
+      oneLiner: raw.oneLiner || "",
+      stageObj: st,
+      stage: st ? st.label : (raw.stage || "—"),
+      category: raw.category || "",
+      priority: T.PRIORITIES.includes(raw.priority) ? raw.priority : "",
+      status,
+      statusObj: T.oppStatusOf(status),
+      statusRaw: raw.status || "",
+      evidenceKey: ev.key,
+      evidenceLabel: ev.label,
+      evidenceRank: ev.rank,
+      quoteCount: Number(raw.quoteCount) || 0,
+      citeCount: Number(raw.citeCount) || 0,
+      scores,
+      stats: scoreStats(scores),
+      decision: dec ? {
+        type: dec.type || "", label: dec.label || T.DECISIONS[dec.type] || "决策",
+        reason: dec.reason || "", by: dec.by || "", at: dec.at || null
+      } : null,
+      decisionLog: Array.isArray(raw.decisionLog) ? raw.decisionLog : [],
+      upload: normUpload(raw, "", raw.sourceSession || ""),
+      linkedArtifacts: arts,
+      linkedProject: proj,
+      seedKey: raw.seedKey || "",
+      createdAt: raw.createdAt || null,
+      updatedAt: raw.updatedAt || null,
+      _raw: raw
+    };
+  }
+
+  function normSource(raw) {
+    const st = T.stageOf(raw.stage);
+    return {
+      kind: "source",
+      id: raw.objectId,
+      title: raw.title || "(未命名资料)",
+      sourceType: raw.sourceType || "",
+      sourceTypeLabel: T.SOURCE_TYPES[raw.sourceType] || raw.sourceType || "资料",
+      ownerName: raw.ownerName || "调研 Agent",
+      stageObj: st,
+      stage: st ? st.label : (raw.stage || ""),
+      layer: "raw",
+      evidenceLevel: "none",
+      evidenceLabel: "出处待补",
+      evidenceRank: 0,
+      quotes: [],
+      quoteCount: 0,
+      summaryText: raw.summary || "",
+      rawText: raw.rawText || "",
+      sources: Array.isArray(raw.sources) ? raw.sources : [],
+      tags: Array.isArray(raw.tags) ? raw.tags : [],
+      createdAt: raw.createdAt || null,
+      _raw: raw
+    };
+  }
+
+  /* 成果池:只有过程层的成果参与筛选与归纳交叉(沉淀/记录层单独展示) */
+  function isProcessArtifact(a) {
+    return a.layer === "process";
+  }
+
+  /* 由成果记录反推:这个成员在哪个阶段最远 */
+  function furthestStage(arts) {
+    let best = null;
+    for (const a of arts) {
+      if (a.stageObj && (!best || a.stageObj.no > best.no)) best = a.stageObj;
+    }
+    return best;
+  }
+
+  function normProject(raw, artifacts) {
+    const mine = artifacts.filter((a) => a.projectId === raw.objectId);
+    const st = T.stageOf(raw.stage);
+    return {
+      kind: "project",
+      id: raw.objectId,
+      title: raw.title || "(未命名项目)",
+      ownerName: raw.ownerName || "",
+      ownerKey: raw.ownerSession || raw.ownerName || "",
+      stageObj: st || furthestStage(mine),
+      stage: st ? st.label : (mine.length ? furthestStage(mine).label : "—"),
+      status: raw.status || "",
+      statusLabel: T.STATUS[raw.status] || raw.status || "—",
+      priority: T.PRIORITIES.includes(raw.priority) ? raw.priority : (raw.priority || ""),
+      category: raw.category || "",
+      summary: raw.summary || "",
+      score: typeof raw.score === "number" && !isNaN(raw.score) ? raw.score : null,
+      layer: raw.layer || "process",
+      lastActiveAt: U.latest([raw.updatedAt, raw.lastActiveAt, ...mine.map((a) => a.createdAt)]),
+      createdAt: raw.createdAt || null,
+      upload: normUpload(raw, raw.ownerName, raw.ownerSession),
+      artifactIds: mine.map((a) => a.id),
+      artifactCount: mine.length,
+      processCount: mine.filter(isProcessArtifact).length
+    };
+  }
+
+  /* 归纳模型:交叉人数、原文证据数、来源记录、证据是否与级别对齐。
+   * 硬规则(任务书 3.1):没有出身的记录不许进沉淀层。
+   *   - 被归纳引用的成果若缺 ownerKey,不升 sediment,计为"无出身"并在页面上标出;
+   *   - 归纳的支撑记录里只要有无出身的,归纳标记 originBroken,不显示为"可回灌"。 */
+  function normInsight(raw, artifactById, sourceById) {
+    const pointed = Array.isArray(raw.derivedFrom) ? raw.derivedFrom : [];
+    const artIds = Array.isArray(raw.derivedArtifacts)
+      ? raw.derivedArtifacts.filter((id) => artifactById[id])
+      : pointed.filter((id) => artifactById[id]);
+    const srcIds = Array.isArray(raw.derivedSources)
+      ? raw.derivedSources.filter((id) => sourceById[id])
+      : pointed.filter((id) => sourceById[id]);
+    const arts = artIds.map((id) => artifactById[id]);
+    const owners = new Set(arts.map((a) => a.ownerKey).filter(Boolean));
+    const quoteCount = arts.reduce((n, a) => n + a.quoteCount, 0);
+    const verifiedArts = arts.filter((a) => a.evidenceLevel === "verified").length;
+    const mentionCount = raw.crossRefCount != null ? raw.crossRefCount : arts.length;
+    const ownerCount = owners.size || raw.ownerCount || raw.distinctOwnerCount || 0;
+    const noOrigin = arts.filter((a) => !a.ownerKey);
+
+    /* 证据对齐:归纳自称有原文,但支撑记录里没有任何带原文的 */
+    const unaligned = verifiedArts === 0;
+
+    return {
+      kind: "insight",
+      id: raw.objectId,
+      title: raw.title || "(未命名结论)",
+      body: raw.body || "",
+      category: raw.category || "",
+      layer: "sediment",
+      artifactIds: artIds,
+      sourceIds: srcIds,
+      artifacts: arts,
+      sources: srcIds.map((id) => sourceById[id]),
+      ownerCount,
+      owners: [...owners],
+      mentionCount,
+      quoteCount,
+      verifiedArts,
+      unaligned,
+      noOriginCount: noOrigin.length,
+      originBroken: noOrigin.length > 0,
+      status: raw.status || "draft",
+      reviewedBy: raw.reviewedBy || "",
+      citedWith: Array.isArray(raw.citedWith) ? raw.citedWith : [],
+      createdAt: raw.createdAt || null,
+      updatedAt: raw.updatedAt || null,
+      _raw: raw
+    };
+  }
+
+  function normSession(raw, memberById) {
+    const st = T.stageOf(raw.stage);
+    return {
+      kind: "session",
+      id: raw.objectId,
+      studentName: raw.studentName || "",
+      groupId: raw.groupId || "",
+      stageObj: st,
+      stage: st ? st.label : (raw.stage || "—"),
+      step: raw.step || "",
+      msgCount: raw.msgCount || 0,
+      lastActiveAt: raw.lastActiveAt || raw.updatedAt || null,
+      createdAt: raw.createdAt || null,
+      hasSubmitted: Boolean(memberById && memberById.delivered),
+      seed: Boolean(raw.seedKey)
+    };
+  }
+
+  function build(raw) {
+    const artifacts = raw.artifact.map(normArtifact);
+    const artifactById = {};
+    artifacts.forEach((a) => { artifactById[a.id] = a; });
+
+    const sources = raw.source.map(normSource);
+    const sourceById = {};
+    sources.forEach((s) => { sourceById[s.id] = s; });
+
+    /* 被归纳引用的成果自动升入沉淀层(前提:有出身) */
+    const cited = new Set();
+    raw.insight.forEach((i) => (i.derivedFrom || []).forEach((id) => cited.add(id)));
+    artifacts.forEach((a) => {
+      a.citedByInsight = cited.has(a.id);
+      if (a.citedByInsight && a.layer === "process" && a.ownerKey) a.layer = "sediment";
+    });
+
+    const projects = raw.project.map((p) => normProject(p, artifacts));
+    const projectById = {};
+    projects.forEach((p) => { projectById[p.id] = p; });
+
+    const insights = raw.insight.map((i) => normInsight(i, artifactById, sourceById));
+
+    /* 成员:人名优先作为身份,会话为进度来源 */
+    const memberMap = new Map();
+    function touchMember(name, key) {
+      if (!name) return;
+      let m = memberMap.get(name);
+      if (!m) {
+        m = { name, sessionIds: [], artifactIds: [], projectIds: [], stageObj: null, done: false,
+          lastActiveAt: null, msgCount: 0, groupId: "", stuck: false, oppCount: 0, tookPart: false };
+        memberMap.set(name, m);
+      }
+      if (key && !m.sessionIds.includes(key)) m.sessionIds.push(key);
+      return m;
+    }
+    /* 会话表给姓名、组、消息数;成果表是"这个人交了没有"的唯一权威来源 */
+    /* 同名会话可能有多条(换了小组/重新开始):
+     * 消息数取最大的那条(不是累加——累加会把"重新开了一次对话"算成双倍),
+     * 最后活动取最新的那条。 */
+    const byName = {};
+    raw.session.forEach((s) => {
+      if (!s.studentName) return;
+      const prev = byName[s.studentName];
+      const lastActiveAt = U.latest([s.lastActiveAt, prev && prev.lastActiveAt]);
+      byName[s.studentName] = {
+        ...s,
+        msgCount: Math.max(s.msgCount || 0, prev ? prev.msgCount : 0),
+        lastActiveAt,
+        createdAt: U.latest([s.createdAt, prev && prev.createdAt]),
+        /* 没有 lastActiveAt 的记录视为"起始时就在这里":不能把它的阶段当成最新活动 */
+        stale: !s.lastActiveAt,
+        stage: (lastActiveAt && (s.lastActiveAt ? (!prev || U.timeOf(s.lastActiveAt) >= U.timeOf(prev.lastActiveAt)) : Boolean(prev && prev.lastActiveAt)))
+          ? s.stage : (prev ? prev.stage : s.stage)
+      };
+    });
+    Object.values(byName).forEach((s) => {
+      const m = touchMember(s.studentName);
+      if (!m) return;
+      m.hasSession = true;
+      m.groupId = m.groupId || s.groupId || "";
+      m.msgCount += s.msgCount || 0;
+      const st = T.stageOf(s.stage);
+      if (st && (!m.stageObj || st.no > m.stageObj.no)) m.stageObj = st;
+      /* 历史数据里 lastActiveAt 常常缺失;updatedAt 只说明"记录被改过",不是活动时间。
+       * 优先 lastActiveAt;没有它的老记录按"课程开始"算(createdAt),不能算成刚刚活跃。 */
+      m.lastActiveAt = U.latest([m.lastActiveAt, s.lastActiveAt, s.stale ? s.createdAt : null]);
+      m.stale = Boolean(m.stale || s.stale);
+    });
+    artifacts.forEach((a) => {
+      const m = touchMember(a.ownerName);
+      if (!m) return;
+      m.artifactIds.push(a.id);
+      if (a.stageObj && (!m.stageObj || a.stageObj.no > m.stageObj.no)) m.stageObj = a.stageObj;
+      m.lastActiveAt = U.latest([m.lastActiveAt, a.createdAt]);
+    });
+    projects.forEach((p) => {
+      const m = touchMember(p.ownerName);
+      if (!m) return;
+      m.projectIds.push(p.id);
+      m.lastActiveAt = U.latest([m.lastActiveAt, p.lastActiveAt]);
+    });
+    /* 机会卡:池子。先建好,再回填到成员(谁的卡进了池子) */
+    const opportunities = raw.opportunity.map((o) => normOpportunity(o, artifactById, projectById));
+
+    /* 已提交 = 有过程层成果;卡住 = 在对话(≥5 条消息)但超时没有新成果。
+     * 阈值不写死:取"现场节奏"的 N 小时(KF_CONFIG.stuckHours,默认 8h),
+     * 要么最后活动超时,要么就停在当前阶段超时——讲师在学员卡住的当下就该看到。 */
+    const stuckHours = (window.KF_CONFIG && window.KF_CONFIG.stuckHours) || 8;
+    const members = [...memberMap.values()].map((m) => {
+      m.done = m.artifactIds.length > 0;
+      m.oppCount = opportunities.filter((o) => o.upload.by && o.upload.by === m.name).length;
+      m.idleHours = m.lastActiveAt ? (Date.now() - new Date(m.lastActiveAt).getTime()) / 3600000 : null;
+      /* 没有 lastActiveAt 时退回会话记录时间——它不是"活动时间",但不该因此判为"刚刚活跃" */
+      const since = m.idleHours == null && m.createdAt ? (Date.now() - new Date(m.createdAt).getTime()) / 3600000 : m.idleHours;
+      m.stuck = !m.done && m.msgCount >= 5 && (since == null || since > stuckHours);
+      m.stuckHours = m.stuck ? Math.floor(since || 0) : 0;
+      return m;
+    });
+    members.sort((a, b) => a.name.localeCompare(b.name, "zh"));
+    const memberByName = Object.fromEntries(members.map((m) => [m.name, m]));
+
+    const sessions = raw.session.map((s) => normSession(s, memberMap.get(s.studentName)));
+
+    /* 阶段板块:每个阶段产出了什么。归属按成果自身的 stage 字段(谁在哪个阶段交的),
+     * 机会卡单独作为 S3 的「产机会卡」口径统计,但不混进成果条数。 */
+    const byStage = T.STAGES.map((st) => {
+      const arts = artifacts.filter((a) => a.stageObj && a.stageObj.no === st.no && a.layer !== "raw");
+      const opps = opportunities.filter((o) => o.stageObj && o.stageObj.no === st.no);
+      return { stage: st, artifacts: arts, opportunities: opps };
+    });
+
+    window.__DBG = window.__DBG || [];
+    window.__DBG.push(raw.session.length + " sessions; names=" + raw.session.map((x) => x.studentName + ":" + x.lastActiveAt).join(","));
+    return {
+      artifacts, artifactById, sources, sourceById, projects, projectById,
+      insights, insightById: Object.fromEntries(insights.map((i) => [i.id, i])),
+      opportunities, opportunityById: Object.fromEntries(opportunities.map((o) => [o.id, o])),
+      byStage,
+      members, memberByName, sessions, stuckHours, loadedAt: raw.loadedAt, truncated: raw.truncated || []
+    };
+  }
+
+  window.KF_MODEL = { build };
+})();

+ 68 - 0
legacy/js/query.js

@@ -0,0 +1,68 @@
+/* query.js —— 筛选与搜索(纯函数,可单独测试)。
+ * 维度的取值放在 hash 里,多选维度用逗号连接;结果 = 各维度条件的交集。 */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+
+  function parseList(v) {
+    return v ? String(v).split(",").map((s) => s.trim()).filter(Boolean) : [];
+  }
+
+  /* 构造"生效中的筛选条件" */
+  function criteria(q) {
+    return {
+      priority: parseList(q.priority),
+      stage: parseList(q.stage),
+      category: parseList(q.category),
+      evidence: parseList(q.evidence),
+      layer: parseList(q.layer),
+      member: parseList(q.member),
+      q: (q.q || "").trim().toLowerCase(),
+      sort: q.sort || "",
+      ins: q.ins || "",
+      focus: q.focus || ""
+    };
+  }
+
+  function isEmpty(c) {
+    return !c.priority.length && !c.stage.length && !c.category.length && !c.evidence.length &&
+      !c.layer.length && !c.member.length && !c.q;
+  }
+
+  /* 多个维度各自"任一命中"即通过,维度之间取交集 */
+  function test(row, c) {
+    if (c.priority.length && !c.priority.includes(row.priority)) return false;
+    if (c.stage.length && !(row.stageObj && c.stage.includes(row.stageObj.code))) return false;
+    if (c.category.length && !c.category.includes(row.category)) return false;
+    if (c.evidence.length && !c.evidence.includes(row.evidenceKey || row.evidenceLevel)) return false;
+    if (c.layer.length && !c.layer.includes(row.layer)) return false;
+    if (c.member.length && !c.member.includes(row.ownerName)) return false;
+    if (c.q) {
+      const hay = [row.title, row.ownerName, row.category, row.projectTitle, row.summary, row.body]
+        .filter(Boolean).join(" ").toLowerCase();
+      if (!hay.includes(c.q)) return false;
+    }
+    return true;
+  }
+
+  /* 逐维度计数:用于在筛选条上显示"如果只点这一项会有几条",且该计数会受其他维度约束 */
+  function facetCounts(rows, c, dim, keyFn) {
+    const rest = { ...c, [dim]: [] };
+    const m = new Map();
+    for (const r of rows) {
+      if (!test(r, rest)) continue;
+      const k = keyFn(r);
+      if (k == null) continue;
+      m.set(k, (m.get(k) || 0) + 1);
+    }
+    return m;
+  }
+
+  /* 组内去重成员数(归纳交叉人数用同一口径) */
+  function distinctOwners(rows) {
+    return new Set(rows.map((r) => r.ownerKey || r.ownerName).filter(Boolean)).size;
+  }
+
+  window.KF_QUERY = { parseList, criteria, isEmpty, test, facetCounts, distinctOwners };
+})();

+ 62 - 0
legacy/js/router.js

@@ -0,0 +1,62 @@
+/* router.js —— hash 路由。
+ * 约定:#/path?k=v&k2=v2&focus=objectId
+ *   #/overview                       工作台(我的 / 池子 / 现场看板)
+ *   #/pool?priority=S&sort=score     机会卡片池(?opp=<id> 打开卡片抽屉)
+ *   #/artifacts                      成果
+ *   #/insights?ins=id                归纳(可选打开某条)
+ *   #/projects  #/project/<id>       项目
+ *   #/members  #/member/<name>       成员
+ *   #/sources                        原材料层
+ *   #/record                         记录层
+ *   #/stages  #/stage/<S1..S5>       阶段进度
+ * hash 每次变化都完整重建 URL,保证"复制链接 → 新标签打开 → 状态一致"。 */
+(function () {
+  "use strict";
+
+  const knownParams = ["q", "priority", "stage", "category", "evidence", "layer", "member", "ins", "art", "src", "ev", "back", "ch", "sort", "focus", "type", "status", "opp"];
+
+  function parseHash(hash) {
+    const h = (hash || window.location.hash || "").replace(/^#\/?/, "");
+    const [pathPart, qs] = h.split("?");
+    const segs = pathPart.split("/").filter(Boolean).map(decodeURIComponent);
+    const params = {};
+    new URLSearchParams(qs || "").forEach((v, k) => { params[k] = v; });
+    return { route: segs[0] || "overview", arg: segs[1] || null, params };
+  }
+
+  /* 由当前 route + 参数生成 hash,参数按固定顺序排,空值不写入 */
+  function buildHash(route, arg, params) {
+    const p = new URLSearchParams();
+    for (const k of knownParams) {
+      const v = params[k];
+      if (v != null && v !== "") p.set(k, v);
+    }
+    const s = p.toString();
+    let path = route;
+    if ((route === "project" || route === "member" || route === "stage" || route === "report") && arg) {
+      path += "/" + encodeURIComponent(arg);
+    }
+    return "#/" + path + (s ? "?" + s : "");
+  }
+
+  /* 用 replaceState 更新 hash,不触发页面滚动与额外 hashchange。
+   * params 是"完整的参数表":调用方先展开当前参数再覆盖,这样切筛选不会把抽屉参数带跑。 */
+  function write(params, opts) {
+    const cur = parseHash();
+    const hash = buildHash(cur.route, cur.arg, params);
+    if (hash === window.location.hash) return;
+    try {
+      history.replaceState(null, "", hash);
+    } catch (e) {
+      window.location.hash = hash; /* file:// 等环境对 replaceState 有限制时的退路 */
+    }
+    if (!opts || !opts.silent) document.dispatchEvent(new CustomEvent("route:changed"));
+  }
+
+  function go(hash) {
+    if (hash === window.location.hash) return;
+    window.location.hash = hash;
+  }
+
+  window.KF_ROUTER = { parseHash, buildHash, write, go };
+})();

+ 113 - 0
legacy/js/taxonomy.js

@@ -0,0 +1,113 @@
+/* taxonomy.js —— 阶段、层、优先级、证据强度的枚举与展示规则。
+ * 需求变化时只需要动这里;页面其余部分都从这里读标签与排序。 */
+(function () {
+  "use strict";
+
+  /* 五个阶段。stage 字段无论存 "S1品类分析" 还是 "1"/"S1",都归一到这里 */
+  const STAGES = [
+    { code: "S1", no: 1, name: "品类市场分析", label: "S1 品类市场分析" },
+    { code: "S2", no: 2, name: "用户需求与痛点", label: "S2 用户需求与痛点" },
+    { code: "S3", no: 3, name: "机会点识别", label: "S3 机会点识别" },
+    { code: "S4", no: 4, name: "机会验证评估", label: "S4 机会验证评估" },
+    { code: "S5", no: 5, name: "场景与卖点设计", label: "S5 场景与卖点设计" }
+  ];
+
+  /* 四层。同一库,靠 layer 字段区分 */
+  const LAYERS = [
+    { key: "raw", label: "原材料", en: "raw", desc: "调研整理的事实,必须带出处" },
+    { key: "process", label: "过程", en: "process", desc: "学员与分身产生的成果,观点,质量参差" },
+    { key: "sediment", label: "沉淀", en: "sediment", desc: "归纳后够格回灌的见解,已校准" },
+    { key: "record", label: "记录", en: "record", desc: "不合格/纯推测/重复的,只留档不进知识" }
+  ];
+
+  const PRIORITIES = ["S", "A", "B"];
+
+  /* 证据强度:verified 必须有真实原文;没有原文的成果在页面上会显示"证据未对齐" */
+  const EVIDENCE = [
+    { key: "verified", label: "有原文", rank: 3 },
+    { key: "supported", label: "有佐证", rank: 2 },
+    { key: "speculative", label: "模型推测", rank: 1 },
+    { key: "none", label: "无证据", rank: 0 }
+  ];
+
+  const STATUS = {
+    active: "进行中",
+    submitted: "已提交",
+    draft: "草稿",
+    parked: "已搁置"
+  };
+
+  /* 机会卡:池子里的生命周期。状态只认这三种,其余历史值归为 candidate 并在 UI 标出 */
+  const OPP_STATUS = [
+    { key: "candidate", label: "候选", desc: "在池子里,等待评分与决策" },
+    { key: "approved", label: "已立项", desc: "已拍板进入下一步,进入研发储备" },
+    { key: "rejected", label: "已否决", desc: "留档,不进储备;理由必须写" }
+  ];
+  const OPP_STATUS_KEYS = OPP_STATUS.map((s) => s.key);
+
+  /* 决策类型:卡片底部那行「决策:…」 */
+  const DECISIONS = {
+    concept: "进入概念验证",
+    approve: "立项",
+    reject: "否决",
+    hold: "暂缓",
+    merge: "并入其他机会"
+  };
+
+  /* 上传方式:客户要看「哪个 Agent 用什么方式上传的」 */
+  const UPLOAD_METHODS = {
+    session: "会话上传",
+    skill: "Skill 上传",
+    manual: "手工录入"
+  };
+
+  /* 证据强度(机会卡自己的三档,与成果的 evidences 不是一套:卡上回答"这个判断站不站得住") */
+  const OPP_EVIDENCE = [
+    { key: "high", label: "高", rank: 3 },
+    { key: "medium", label: "中", rank: 2 },
+    { key: "low", label: "低", rank: 1 },
+    { key: "none", label: "待补", rank: 0 }
+  ];
+
+  /* 成果类型:只做展示名,不做业务定义 */
+  const ARTIFACT_TYPES = {
+    opportunity_card: "机会卡",
+    pain_list: "痛点清单",
+    competitor_report: "竞品报告",
+    voc_report: "VOC 报告",
+    concept: "概念提案"
+  };
+
+  const SOURCE_TYPES = {
+    category: "品类",
+    competitor: "竞品",
+    process: "工艺",
+    audience: "人群",
+    opportunity: "机会",
+    probe: "探针"
+  };
+
+  function stageOf(value) {
+    if (value == null || value === "") return null;
+    const s = String(value).toUpperCase();
+    return STAGES.find((x) => s.startsWith(x.code)) || null;
+  }
+  const stageLabel = (v) => (stageOf(v) ? stageOf(v).label : v || "—");
+
+  const layerOf = (key) => LAYERS.find((l) => l.key === key) || null;
+
+  function evidenceOf(level, hasQuotes) {
+    if (level === "verified" && hasQuotes) return EVIDENCE[0];
+    if (level === "verified" || level === "supported") return EVIDENCE[1];
+    if (level === "speculative") return EVIDENCE[2];
+    return EVIDENCE[3];
+  }
+
+  window.KF_TAXONOMY = {
+    STAGES, LAYERS, PRIORITIES, EVIDENCE, STATUS, ARTIFACT_TYPES, SOURCE_TYPES,
+    OPP_STATUS, OPP_STATUS_KEYS, DECISIONS, UPLOAD_METHODS, OPP_EVIDENCE,
+    oppStatusOf: (k) => OPP_STATUS.find((s) => s.key === k) || null,
+    oppEvidenceOf: (k) => OPP_EVIDENCE.find((e) => e.key === k) || OPP_EVIDENCE[3],
+    stageOf, stageLabel, layerOf, evidenceOf
+  };
+})();

+ 100 - 0
legacy/js/util.js

@@ -0,0 +1,100 @@
+/* util.js —— 纯函数:转义、日期、排序、分组,不碰 DOM 也不需要数据 */
+(function () {
+  "use strict";
+
+  const ESC = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
+  function esc(v) {
+    if (v == null) return "";
+    return String(v).replace(/[&<>"']/g, (c) => ESC[c]);
+  }
+
+  function fmtDate(iso, withTime) {
+    const d = toDate(iso);
+    if (!d) return "—";
+    const p = (n) => String(n).padStart(2, "0");
+    const s = `${d.getMonth() + 1}月${d.getDate()}日`;
+    return withTime ? `${s} ${p(d.getHours())}:${p(d.getMinutes())}` : s;
+  }
+
+  /* 相对时间,用于"最后活动" */
+  function fmtAgo(iso) {
+    const t = timeOf(iso);
+    if (!t) return "—";
+    const m = Math.round((Date.now() - t) / 60000);
+    if (m < 1) return "刚刚";
+    if (m < 60) return `${m} 分钟前`;
+    const h = Math.round(m / 60);
+    if (h < 24) return `${h} 小时前`;
+    return fmtDate(iso);
+  }
+
+  /* Parse 的 Date 字段在 REST 里是 {"__type":"Date","iso":"…"}。
+   * 直接 new Date(对象) 得到 Invalid Date —— 之前"最后活动时间"全部丢失就是这个原因。
+   * 统一在这里拆:Date 对象 / ISO 串 / Parse 日期对象都能读。 */
+  function toDate(d) {
+    if (!d) return null;
+    if (d instanceof Date) return isNaN(d) ? null : d;
+    if (typeof d === "object" && typeof d.iso === "string") d = d.iso;
+    const dt = new Date(d);
+    return isNaN(dt) ? null : dt;
+  }
+  function timeOf(d) {
+    const dt = toDate(d);
+    return dt ? dt.getTime() : 0;
+  }
+
+  /* 最近活动时间:Date 字段或 ISO 串,取最大 */
+  function latest(dates) {
+    let best = 0;
+    for (const d of dates) {
+      const t = timeOf(d);
+      if (t > best) best = t;
+    }
+    return best ? new Date(best).toISOString() : null;
+  }
+
+  function countBy(arr, keyFn) {
+    const m = new Map();
+    for (const x of arr) {
+      const k = keyFn(x);
+      m.set(k, (m.get(k) || 0) + 1);
+    }
+    return m;
+  }
+
+  function groupBy(arr, keyFn) {
+    const m = new Map();
+    for (const x of arr) {
+      const k = keyFn(x);
+      if (!m.has(k)) m.set(k, []);
+      m.get(k).push(x);
+    }
+    return m;
+  }
+
+  /* 排序工具:空值永远沉底 */
+  function cmpNum(a, b, dir) {
+    const ea = a == null || isNaN(a), eb = b == null || isNaN(b);
+    if (ea && eb) return 0;
+    if (ea) return 1;
+    if (eb) return -1;
+    return dir * (a - b);
+  }
+
+  function initials(name) {
+    if (!name) return "?";
+    return Array.from(String(name)).slice(-2).join("");
+  }
+
+  function shortId(prefix, idx) {
+    return `${prefix}-${String(idx + 1).padStart(2, "0")}`;
+  }
+
+  /* 名称包含匹配,忽略大小写;空查询视为命中(用于 ?focus= 这种"定位某一条"的场景) */
+  function matches(hay, q) {
+    if (!q) return true;
+    return String(hay || "").toLowerCase().includes(q);
+  }
+
+  window.KF_UTIL = { esc, toDate, timeOf, fmtDate, fmtAgo, latest, countBy, groupBy, cmpNum, initials, shortId, matches };
+})();

+ 169 - 0
legacy/js/views/artifacts.js

@@ -0,0 +1,169 @@
+/* views/artifacts.js —— 成果列表:四层归属、出身、证据、筛选叠加、排序。
+ * 既用于"成果"页,也被"记录层"页复用(固定 layer=record)。 */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+  const U = window.KF_UTIL;
+  const Q = window.KF_QUERY;
+  const UI = window.KF_UI;
+  const esc = U.esc;
+
+  function rowOf(a, ctx) {
+    const p = ctx.db.projectById[a.projectId];
+    return {
+      ...a,
+      priority: p ? p.priority : "",
+      category: p ? p.category : "",
+      projectTitle: p ? p.title : "",
+      evidenceKey: a.evidenceLevel
+    };
+  }
+
+  function rowsFor(db, filterLayer) {
+    const base = filterLayer ? db.artifacts.filter((a) => a.layer === filterLayer) : db.artifacts;
+    const ctx = { db };
+    return base.map((a) => rowOf(a, ctx));
+  }
+
+  /* 排序:数字/日期维度按方向,文本维度固定方向 */
+  const SORTS = {
+    newest: { label: "最新提交", fn: (a, b) => new Date(b.createdAt) - new Date(a.createdAt) },
+    evidence: { label: "证据强度", fn: (a, b) => b.evidenceRank - a.evidenceRank || new Date(b.createdAt) - new Date(a.createdAt) },
+    owner: { label: "按成员", fn: (a, b) => a.ownerName.localeCompare(b.ownerName, "zh") },
+    stage: { label: "按阶段", fn: (a, b) => (b.stageObj ? b.stageObj.no : 0) - (a.stageObj ? a.stageObj.no : 0) }
+  };
+
+  function buildFacets(db, rows, c, opts) {
+    const P = T.PRIORITIES;
+    const dims = [];
+    if (!opts.priorityLocked) {
+      const pc = Q.facetCounts(rows, c, "priority", (r) => r.priority);
+      dims.push({ key: "priority", label: "优先级", options: P.map((v) => ({ value: v, label: v, count: pc.get(v) || 0 })) });
+    }
+    const sc = Q.facetCounts(rows, c, "stage", (r) => (r.stageObj ? r.stageObj.code : null));
+    dims.push({ key: "stage", label: "阶段", options: T.STAGES.map((s) => ({ value: s.code, label: s.code, count: sc.get(s.code) || 0 })) });
+
+    const cats = [...new Set(rows.map((r) => r.category).filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh"));
+    const cc = Q.facetCounts(rows, c, "category", (r) => r.category);
+    dims.push({ key: "category", label: "品类", options: cats.map((v) => ({ value: v, label: v, count: cc.get(v) || 0 })) });
+
+    const ec = Q.facetCounts(rows, c, "evidence", (r) => r.evidenceKey);
+    dims.push({ key: "evidence", label: "证据", options: T.EVIDENCE.slice(0, 3).map((e) => ({ value: e.key, label: e.label, count: ec.get(e.key) || 0 })) });
+
+    if (!opts.layerLocked) {
+      const lc = Q.facetCounts(rows, c, "layer", (r) => r.layer);
+      dims.push({ key: "layer", label: "层", multi: true, options: T.LAYERS.map((l) => ({ value: l.key, label: l.en, count: lc.get(l.key) || 0 })) });
+    }
+
+    const mc = Q.facetCounts(rows, c, "member", (r) => r.ownerName);
+    const members = [...mc.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "zh"));
+    dims.push({ key: "member", label: "成员", multi: true, options: members.map(([v, n]) => ({ value: v, label: v, count: n })) });
+    /* 每个维度的当前选中值,供筛选条回显 */
+    dims.forEach((d) => { d.active = c[d.key] || []; });
+    return dims;
+  }
+
+  /* 单行。带报告的成果在标题上直接给入口——不点进抽屉也能翻开读 */
+  function row(r) {
+    const ttl = r.report
+      ? `<a class="ttl r-inline-link" href="#/report/${encodeURIComponent(r.id)}" data-act="open-report" data-id="${esc(r.id)}">${esc(r.shortTitle)}</a>`
+      : `<span class="ttl">${esc(r.title)}</span>`;
+    return `<tr class="clickable${r.report ? " has-report" : ""}${r.id === (window.__KF_FOCUS || "") ? " focus-on" : ""}" data-act="open-artifact" data-id="${esc(r.id)}">
+      <td class="cell-title">${ttl}<span class="sub">${r.report ? `<span class="r-dot" title="这份成果带完整报告">报告</span>` : ""}${esc(r.typeLabel)}${r.projectTitle ? " \u00b7 " + esc(r.projectTitle) : ""}</span></td>
+      <td data-l="成员">${UI.person(r.ownerName)}</td>
+      <td data-l="阶段">${UI.stageCell(r.stageObj, r.stage)}</td>
+      <td data-l="优先级">${UI.priorityBadge(r.priority)}</td>
+      <td data-l="层">${UI.layerBadge(r.layer)}${r.report ? ` <span class="tiny muted" title="这条成果带完整报告">报告</span>` : ""}${r.citedByInsight ? ` <span class="tiny muted" title="并被归纳引用">被引用</span>` : ""}</td>
+      <td data-l="证据">${UI.evidenceBadge(r)}${r.evidenceUnaligned ? ` <span class="una" title="标记为有原文,但记录中没有原文出处">未对齐</span>` : ""}</td>
+      <td data-l="提交" class="when">${U.fmtDate(r.createdAt, true)}</td>
+    </tr>`;
+  }
+
+  function header(sorted) {
+    const th = (key, label, cls) => {
+      const on = sorted === key;
+      return `<th class="${cls || ""} sortable${on ? " sorted" : ""}" data-act="sort" data-sort="${key}" aria-sort="${on ? "descending" : "none"}">${label}<span class="arr">${on ? "↓" : "↕"}</span></th>`;
+    };
+    return `<table class="tbl"><thead><tr>
+      <th>成果</th><th>成员</th>${th("stage", "阶段")}<th>优先级</th><th>层</th>${th("evidence", "证据")}${th("newest", "提交时间")}
+    </tr></thead><tbody>`;
+  }
+
+  /* 结果为空时,逐项去掉一个条件看还剩几条,告诉用户是哪项卡住了 */
+  function relaxHint(rows, c) {
+    const parts = [];
+    const q = window.KF_QUERY;
+    for (const dim of ["priority", "stage", "category", "evidence", "layer", "member"]) {
+      const vals = c[dim];
+      if (!vals.length || vals.length < 1) continue;
+      const n = rows.filter((r) => q.test(r, { ...c, [dim]: [] })).length;
+      parts.push(`${dimLabel(dim)}=${vals.join("、")} 时只剩 ${n} 条`);
+    }
+    if (c.q) parts.push(`搜索「${c.q}」时只剩 ${rows.filter((r) => q.test(r, { ...c, q: "" })).length} 条`);
+    return parts.length ? "单独去掉某一项后: " + parts.join(";") + "。" : "";
+  }
+  function dimLabel(d) {
+    return ({ priority: "优先级", stage: "阶段", category: "品类", evidence: "证据", layer: "层", member: "成员" })[d] || d;
+  }
+
+  /* 页面主体 */
+  function listView(db, c, opts) {
+    const rowsAll = rowsFor(db, opts.filterLayer);
+    const rows = rowsAll.filter((r) => Q.test(r, c));
+    const sortKey = SORTS[c.sort] ? c.sort : (opts.defaultSort || "newest");
+    const sorted = (opts.sortLocked ? rowsAll.slice() : rows.slice());
+    sorted.sort(SORTS[sortKey].fn);
+    /* ?focus=<id>:从报告/抽屉跳回来时,标出"你要找的那条";只影响排序,不隐藏其他行 */
+    const focusA = c.focus ? db.artifactById[c.focus] : null;
+    window.__KF_FOCUS = focusA ? focusA.id : "";
+    if (focusA) {
+      const i = sorted.findIndex((r) => r.id === focusA.id);
+      if (i > 0) sorted.unshift(sorted.splice(i, 1)[0]);
+    }
+
+    const dims = buildFacets(db, rowsAll, c, opts);
+    const filterHtml = filterBarFor(dims, c, rows.length, opts.unit || "条成果");
+
+    let body;
+    if (!rowsAll.length) {
+      body = UI.empty({
+        title: opts.emptyTitle || "还没有归集的成果",
+        body: opts.emptyBody || "学员对话结束后,上传 skill 会把成果写进库;刷新或等待自动刷新的那一刻,这里就会出现带出身(谁、何时、哪一步、有没有原文)的记录。",
+        actions: opts.emptyActions
+      });
+    } else if (!rows.length) {
+      body = UI.empty({
+        title: "当前条件下没有成果",
+        body: `已有 ${rowsAll.length} 条成果,但没有同时满足全部条件的。${relaxHint(rowsAll, c)}`,
+        actions: [{ label: "清除全部筛选", act: "clear-filters" }]
+      });
+    } else {
+      body = `<div class="panel tbl-wrap">${header(opts.sortLocked ? null : sortKey)}${sorted.map(row).join("")}</tbody></table></div>`;
+    }
+
+    return `<div class="view-head"><div>
+        <h1>${esc(opts.title)}</h1>
+        <p class="lede">${opts.lede}</p>
+      </div>
+      <div class="aside">${opts.sortLocked ? "" : sortSeg(sortKey)}</div>
+    </div>
+    <div data-filterbar>${filterHtml}</div>
+    <div data-active>${UI.activeLine(c)}</div>
+    ${focusA ? `<div class="focus-note">${UI.icons.link}<span>正在定位:<b>${esc(focusA.shortTitle)}</b> · ${esc(focusA.ownerName || "未知")} · ${esc(focusA.stage)}</span><a href="#" data-act="clear-focus" style="margin-left:auto">清除定位</a></div>` : ""}
+    <div data-list>${body}</div>`;
+  }
+
+  /* 筛选条:优先复用 common.filterBar,这里补充 layer 锁定提示 */
+  function filterBarFor(dims, c, n, unit) {
+    return UI.filterBar(dims, c, n, unit);
+  }
+
+  function sortSeg(cur) {
+    return `<div class="seg" role="group" aria-label="排序">
+      ${Object.entries(SORTS).map(([k, s]) => `<button class="${k === cur ? "on" : ""}" data-act="sort" data-sort="${k}">${esc(s.label)}</button>`).join("")}
+    </div>`;
+  }
+
+  window.KF_VIEW_ARTIFACTS = { listView, rowsFor, SORTS };
+})();

+ 166 - 0
legacy/js/views/common.js

@@ -0,0 +1,166 @@
+/* views/common.js —— 所有页面共用的渲染片段:状态徽标、人员、徽章、空态、骨架、筛选条。
+ * 只返回 HTML 字符串,事件由 app.js 的事件委托统一处理(data-act / data-*)。
+ * 设计约定:数字直接穿着强调色(不再套胶囊),标签只用字重和灰度分层。 */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+  const U = window.KF_UTIL;
+  const esc = U.esc;
+
+  /* ---------- 小片段 ---------- */
+  /* 层:小圆点 + 英文小字,克制的色标 */
+  function layerBadge(layer) {
+    const l = T.layerOf(layer);
+    if (!l) return `<span class="layer layer-missing" title="缺少 layer 字段">layer 缺失</span>`;
+    return `<span class="layer layer-${l.key}" title="${esc(l.desc)}">${l.en}</span>`;
+  }
+
+  /* 证据:纯文字 + 状态点 */
+  function evidenceBadge(row) {
+    const key = row.evidenceLevel || "none";
+    return `<span class="ev ev-${key}">${esc(row.evidenceLabel)}</span>`;
+  }
+
+  function priorityBadge(p) {
+    const cls = ["S", "A", "B"].includes(p) ? `prio-${p}` : "prio-";
+    return `<span class="prio ${cls}" title="优先级 ${esc(p || "未定")}">${esc(p || "—")}</span>`;
+  }
+
+  /* 人:不构成"卡片",只有首字与名字。之后接的文字会显示在名字后面(inline) */
+  function person(name, extra) {
+    return `<span class="cell-person"><span class="avatar" aria-hidden="true">${esc(U.initials(name))}</span>
+      <span class="pn">${esc(name || "—")}</span>${extra ? `<span class="sub">${esc(extra)}</span>` : ""}</span>`;
+  }
+
+  /* 阶段:S 编号 + 五格进度,当前阶段用强调色指路 */
+  function stageCell(stageObj, raw, opts) {
+    const cur = stageObj ? stageObj.no : 0;
+    const steps = T.STAGES.map((s) => {
+      if (s.no < cur) return `<i class="on"></i>`;
+      if (s.no === cur) return `<i class="cur"></i>`;
+      return `<i></i>`;
+    }).join("");
+    const stuck = opts && opts.stuck;
+    return `<span class="stage-cell${stuck ? " stuck" : ""}" title="${stageObj ? esc(stageObj.label) : esc(raw || "")}">
+      <span class="sn">${stageObj ? "S" + stageObj.no : esc(raw || "—")}</span>
+      <span class="steps">${steps}</span>
+    </span>`;
+  }
+
+  function stateBadge(status, label) {
+    return `<span class="state state-${esc(status || "draft")}">${esc(label || status || "—")}</span>`;
+  }
+
+  const icons = {
+    search: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="9" cy="9" r="5.5"/><path d="M13.2 13.2 17 17" stroke-linecap="round"/></svg>`,
+    menu: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 6h14M3 10h14M3 14h14"/></svg>`,
+    x: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M5 5l10 10M15 5 5 15"/></svg>`,
+    arrow: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10h12M11 5l5 5-5 5"/></svg>`,
+    info: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="10" cy="10" r="7.5"/><path d="M10 9v5M10 6.5v.5" stroke-linecap="round"/></svg>`,
+    refresh: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M16 8a6 6 0 1 0 .3 4"/><path d="M16 3.5V8h-4.5"/></svg>`,
+    chevron: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5l5 5-5 5"/></svg>`,
+    doc: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M6 2.5h5.5L15.5 6.5V17a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V3a.5.5 0 0 1 .5-.5Z"/><path d="M11.5 2.8V6.5h3.7M8 10h5M8 13h5"/></svg>`,
+    link: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M8.5 11.5a3 3 0 0 0 4.2 0l2.3-2.3a3 3 0 0 0-4.2-4.2l-1 1"/><path d="M11.5 8.5a3 3 0 0 0-4.2 0L5 10.8a3 3 0 0 0 4.2 4.2l1-1"/></svg>`,
+    quote: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H9v5H6.5v2.5H9V16H5.5A1.5 1.5 0 0 1 4 14.5v-9Zm7.5 0A1.5 1.5 0 0 1 13 4h3.5v5H14v2.5h2.5V16H13a1.5 1.5 0 0 1-1.5-1.5v-9Z" opacity=".7"/></svg>`,
+    layers: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M10 2.5 17 6l-7 3.5L3 6l7-3.5Z"/><path d="M3 10l7 3.5L17 10M3 14l7 3.5L17 14"/></svg>`,
+    grid: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="5.5" height="5.5" rx="1.5"/><rect x="11.5" y="3" width="5.5" height="5.5" rx="1.5"/><rect x="3" y="11.5" width="5.5" height="5.5" rx="1.5"/><rect x="11.5" y="11.5" width="5.5" height="5.5" rx="1.5"/></svg>`,
+    spark: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M10 2.5 11.8 7.4 16.5 9.2 11.8 11 10 16 8.2 11 3.5 9.2 8.2 7.4 10 2.5Z"/></svg>`,
+    users: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="7.5" cy="7" r="2.6"/><path d="M3 16c.6-2.6 2.4-4 4.5-4s3.9 1.4 4.5 4" stroke-linecap="round"/><path d="M12.8 4.8a2.6 2.6 0 0 1 0 4.5M14.5 12.4c1.3.5 2.2 1.7 2.6 3.4" stroke-linecap="round"/></svg>`,
+    steps: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 15h3v-4h4V7h4V3"/><circle cx="3" cy="15" r="1.2" fill="currentColor" stroke="none"/><circle cx="17" cy="3" r="1.2" fill="currentColor" stroke="none"/></svg>`,
+    box: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M10 2.5 17 6v8l-7 3.5L3 14V6l7-3.5Z"/><path d="M3 6l7 3.5L17 6M10 9.5V17"/></svg>`,
+    archive: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="3" y="4" width="14" height="3.5" rx="1"/><path d="M4.5 7.5V15a1.5 1.5 0 0 0 1.5 1.5h8a1.5 1.5 0 0 0 1.5-1.5V7.5M8.5 11h3"/></svg>`,
+    cards: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="3" y="6" width="11" height="11" rx="2.5"/><path d="M6.5 3.5h7.3a2.7 2.7 0 0 1 2.7 2.7v7.3" stroke-linecap="round"/></svg>`,
+    back: `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5l-5 5 5 5"/></svg>`
+  };
+
+  /* 四层各自的色点(用于图例、层级条),来自 tokens 里的 --c-* */
+  function layerDot(key) {
+    return `<span class="layer layer-${esc(key)}" title="${esc(T.layerOf(key) ? T.layerOf(key).desc : "")}">${esc(T.layerOf(key) ? T.layerOf(key).en : key)}</span>`;
+  }
+
+  /* ---------- 空态 / 骨架 ---------- */
+  function empty(opts) {
+    const o = opts || {};
+    const acts = (o.actions || []).map((a) =>
+      a.href ? `<a class="btn" href="${esc(a.href)}">${esc(a.label)}</a>`
+        : `<button class="btn" data-act="${esc(a.act || "")}" ${a.attr || ""}>${esc(a.label)}</button>`).join("");
+    return `<div class="empty ${o.sm ? "sm" : ""}" role="status">
+      <div class="art" aria-hidden="true"><div class="g1"></div><div class="g2"></div><div class="ln a"></div><div class="ln b"></div><div class="dotc"></div></div>
+      <h4>${esc(o.title || "这里还没有内容")}</h4>
+      <p>${o.body || ""}</p>
+      ${acts ? `<div class="acts">${acts}</div>` : ""}
+    </div>`;
+  }
+
+  /* 骨架:高度与真实内容对齐,加载前后不顶开 */
+  function skeleton(kind, n) {
+    const rows = [];
+    for (let i = 0; i < (n || 4); i++) {
+      rows.push(`<div style="display:grid;grid-template-columns:minmax(0,1.6fr) 90px 110px 60px;gap:18px;align-items:center;padding:14px 22px;border-bottom:1px solid var(--rule)">
+        <div><div class="skel skel-line w60"></div><div class="skel skel-line w40" style="margin-top:8px;height:10px"></div></div>
+        <div class="skel skel-line w80"></div><div class="skel skel-line w60"></div><div class="skel skel-line" style="justify-self:end;width:36px"></div>
+      </div>`);
+    }
+    if (kind === "metrics") {
+      return `<div class="metrics">${Array.from({ length: 4 }).map(() =>
+        `<div class="metric"><div class="skel skel-line w40" style="height:12px"></div><div class="skel skel-num" style="margin:14px 0 0 auto"></div><div class="skel skel-line w60" style="margin:10px 0 0 auto;height:10px"></div></div>`).join("")}</div>`;
+    }
+    return `<div class="panel">${rows.join("")}</div>`;
+  }
+
+  /* ---------- 筛选条 ----------
+   * dims: [{key,label,multi,options:[{value,label,count}],active:[...]}]
+   * 每个选项都显示"在其他条件约束下还剩几条",0 条置灰不可点(但仍可看到)。
+   * 所有维度同时生效,点击任一维度只切换该维度自身的值。 */
+  function filterBar(dims, c, resultCount, unit) {
+    const parts = dims.map((d) => {
+      const act = Array.isArray(d.active) ? d.active : [];
+      const chips = d.options.map((o) => {
+        const on = act.includes(o.value);
+        /* 0 条的选项仍可点:结果会变空,空态里会说明是哪项卡住并给出清除按钮 */
+      const zero = o.count === 0 && !on;
+        return `<button class="chip${zero ? " zero" : ""}" data-act="facet" data-dim="${d.key}" data-value="${esc(o.value)}"
+          data-on="${on ? "1" : "0"}" data-zero="${zero ? "1" : "0"}">${esc(o.label)}<span class="n">${o.count}</span></button>`;
+      }).join("");
+      return `<div class="fgroup ${d.multi ? "multi" : ""}" role="group" aria-label="${esc(d.label)}">
+        <span class="fl">${esc(d.label)}</span>${chips}</div>`;
+    }).join("");
+    const any = !window.KF_QUERY.isEmpty(c);
+    return `<div class="filters" id="filterbar">${parts}
+      <div class="spacer"></div>
+      <span class="result-note">符合当前条件 <b data-rn>${resultCount}</b> ${esc(unit || "条")}</span>
+      ${any ? `<button class="clear-all" data-act="clear-filters">清除全部</button>` : ""}
+    </div>`;
+  }
+
+  /* 已选条件回显(文字型) */
+  function activeLine(c, labels) {
+    const pills = [];
+    const push = (key, value, label) =>
+      pills.push(`<span class="pill">${esc(label)}<button data-act="remove-filter" data-dim="${key}" data-value="${esc(value)}" aria-label="移除 ${esc(label)}">${icons.x}</button></span>`);
+    c.priority.forEach((v) => push("priority", v, `优先级 ${v}`));
+    c.stage.forEach((v) => push("stage", v, T.stageLabel(v)));
+    c.category.forEach((v) => push("category", v, `品类 ${v}`));
+    c.evidence.forEach((v) => push("evidence", v, `证据 ${(T.EVIDENCE.find((e) => e.key === v) || {}).label || v}`));
+    c.layer.forEach((v) => push("layer", v, `层 ${v}`));
+    c.member.forEach((v) => push("member", v, `成员 ${v}`));
+    if (c.q) pills.push(`<span class="pill">搜索「${esc(c.q)}」<button data-act="clear-search" aria-label="清除搜索">${icons.x}</button></span>`);
+    if (!pills.length) return "";
+    return `<div class="active-line">${pills.join("")}${labels || ""}</div>`;
+  }
+
+  function crumb(items) {
+    return items.map((it, i) => {
+      const last = i === items.length - 1;
+      const node = last || !it.href ? `<span class="${last ? "now" : ""}">${esc(it.label)}</span>`
+        : `<a href="${esc(it.href)}">${esc(it.label)}</a>`;
+      return (i ? `<span class="sep">/</span>` : "") + node;
+    }).join("");
+  }
+
+  window.KF_UI = {
+    esc, layerBadge, layerDot, evidenceBadge, priorityBadge, person, stageCell, stateBadge,
+    icons, empty, skeleton, filterBar, activeLine, crumb
+  };
+})();

+ 177 - 0
legacy/js/views/detail.js

@@ -0,0 +1,177 @@
+/* views/detail.js —— 详情抽屉与"追溯"面板。
+ *   追溯 = 一条归纳 → 它引用的每条记录 → 每条记录里能看到的原文出处 → 可跳到成果详情 / 项目。
+ *   这是归纳区"点开能看到是哪几条具体支撑"的落地。 */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+  const U = window.KF_UTIL;
+  const UI = window.KF_UI;
+  const esc = U.esc;
+
+  /* ---------- 外壳 ---------- */
+  function shell(title, kicker, body, foot) {
+    return `<div class="drawer-h"><div style="min-width:0"><div class="kicker">${kicker || ""}</div><h3>${title}</h3></div>
+        <button class="x" data-act="close-drawer" aria-label="关闭">${UI.icons.x}</button></div>
+      <div class="drawer-b">${body}</div>
+      ${foot ? `<div class="drawer-f">${foot}</div>` : ""}`;
+  }
+
+  function kvBlock(pairs) {
+    return `<div class="kv-grid">${pairs.map(([k, v]) => `<div><div class="k">${esc(k)}</div><div class="v">${v}</div></div>`).join("")}</div>`;
+  }
+
+  /* 原文出处:统一展示 sources 数组里的内容(ref/url/snippet/quote/type 等,字段待定就显示有什么) */
+  function quotesOf(row) {
+    return (row.quotes || []).map((q, idx) => {
+      if (typeof q === "string") return `<div class="quote">${esc(q)}</div>`;
+      const text = q.quote || q.snippet || q.text || q.excerpt || "";
+      const ref = q.ref || q.url || q.source || "";
+      const meta = [q.type, q.author, q.date].filter(Boolean).join(" · ");
+      const refHtml = ref && /^https?:\/\//.test(ref)
+        ? `<a href="${esc(ref)}" target="_blank" rel="noopener noreferrer">${esc(ref)}</a>`
+        : esc(ref);
+      return `<div class="quote">
+        <div style="display:flex;gap:8px"><span class="tiny muted" style="flex:none">#${idx + 1}</span><div>${text ? esc(text) : `<span class="muted">(该出处未附摘录)</span>`}</div></div>
+        ${(refHtml || meta) ? `<div class="who">${refHtml}${refHtml && meta ? " · " : ""}${esc(meta)}</div>` : ""}
+      </div>`;
+    }).join("");
+  }
+
+  /* ---------- 成果详情 ---------- */
+  function artifact(db, id, opts) {
+    const a = db.artifactById[id];
+    if (!a) return { title: "找不到这条成果", html: UI.empty({ sm: true, title: "记录不存在", body: "链接可能过期,或记录已被删除。" }) };
+    const p = db.projectById[a.projectId];
+    const citing = db.insights.filter((i) => i.artifactIds.includes(a.id));
+    /* 反查:哪些报告的哪条结论,引用了这条成果(来自报告证据链的 derivedFrom) */
+    const reportRefs = (window.KF_REPORT && window.KF_REPORT.conclusionsCiting)
+      ? window.KF_REPORT.conclusionsCiting(db, a.id) : [];
+    const raw = a._raw;
+
+    const body = `
+      ${a.report ? `<div class="block"><a class="btn btn-accent" href="#/report/${encodeURIComponent(a.id)}" data-act="open-report" data-id="${esc(a.id)}">
+          ${UI.icons.doc} 打开完整报告 · ${(a.report.chapters || []).length} 章</a>
+          <p class="tiny muted" style="margin-top:8px;line-height:1.6">这条成果附带一份可阅读的分析报告:封面、章节、交叉表与结论,每条结论都能追到支撑它的成果。</p></div>` : ""}
+      <div class="block">${kvBlock([
+        ["层", UI.layerBadge(a.layer)],
+        ["证据", `${UI.evidenceBadge(a)} ${a.evidenceUnaligned ? `<span class="una">标记有原文,但无出处</span>` : ""}`],
+        ["成员", esc(a.ownerName || "—")],
+        ["会话 / 出身", a.ownerKey ? `<span class="mono-num">${esc(a.ownerKey)}</span>` : `<span class="una">缺少出身</span>`],
+        ["阶段", a.stageObj ? esc(a.stageObj.label) : esc(a.stage || "—")],
+        ["提交", esc(U.fmtDate(a.createdAt, true))],
+        ["来源方式", esc(a.uploadedBy || "—")],
+        ["项目", p ? `<a href="#/project/${encodeURIComponent(p.id)}" data-act="nav" data-href="#/project/${encodeURIComponent(p.id)}">${esc(p.title)}</a>` : "—"]
+      ])}</div>
+      ${a.summaryText ? `<div class="block"><div class="bl">摘要</div><div class="prose">${esc(a.summaryText)}</div></div>` : ""}
+      ${a.content && !a.summaryText ? `<div class="block"><div class="bl">正文(结构化)</div><div class="prose">${esc(JSON.stringify(a.content, null, 2))}</div></div>` : ""}
+      ${a.rawText ? `<div class="block"><div class="bl">原始文本</div><div class="prose">${esc(a.rawText)}</div></div>` : ""}
+      <div class="block"><div class="bl">原文出处 <span class="n">${a.quotes.length}</span></div>
+        ${a.quotes.length ? quotesOf(a) : UI.empty({ sm: true, title: "没有原文出处", body: "这条记录是观点。只有带原文的成果才能作为「事实」回灌知识库。" })}
+      </div>
+      ${a.tags && a.tags.length ? `<div class="block"><div class="bl">标签</div><div>${a.tags.map((t) => `<span class="ev ev-supported" style="margin:0 6px 6px 0">${esc(t)}</span>`).join("")}</div></div>` : ""}
+      ${citing.length ? `<div class="block"><div class="bl">被这些归纳引用</div>${citing.map((i) => `<div style="padding:8px 0;border-bottom:1px solid var(--rule)"><a href="#" data-act="open-insight" data-id="${esc(i.id)}">${esc(i.title)}</a></div>`).join("")}</div>` : ""}
+      ${reportRefs.length ? `<div class="block"><div class="bl">作为证据,被这些结论引用 <span class="n">${reportRefs.length}</span></div>
+        ${reportRefs.map((x) => `<div class="trace-src">
+          <span class="idx tiny muted" style="padding-top:2px">${UI.icons.link}</span>
+          <div style="min-width:0">
+            <div class="tiny" style="font-weight:500;line-height:1.5">${esc(x.claim)}</div>
+            <div class="tiny muted" style="margin-top:3px">来自报告 <a href="#/report/${encodeURIComponent(x.report.id)}" data-act="open-report" data-id="${esc(x.report.id)}">${esc(x.report.shortTitle)}</a>
+              · <a href="#" data-act="open-evidence" data-id="${esc(x.evId)}" data-back="${esc(a.id)}">看这条证据链</a></div>
+          </div>
+        </div>`).join("")}</div>`
+        : a.report ? "" : `<div class="block"><div class="bl">作为证据,被结论引用</div><p class="tiny muted" style="line-height:1.7">目前没有报告的结论引用这条成果。当它被写进某份报告的证据链时,这里会列出“哪条结论”“哪份报告”,并可直接打开证据链。</p></div>`}
+      <div class="block"><div class="bl">记录 ID</div><div class="tiny mono-num muted">${esc(a.id)} · 原始 layer=${esc(raw.layer || "(空)")} · tier=${esc(raw.tier || "(空)")}</div></div>`;
+
+    const foot = opts && opts.inline
+      ? `<a class="btn btn-ghost" href="#/artifacts">返回成果列表</a>`
+      : `<button class="btn btn-ghost" data-act="close-drawer">关闭</button>${p ? `<a class="btn" href="#/project/${encodeURIComponent(p.id)}">打开项目</a>` : ""}`;
+    const html = opts && opts.inline
+      ? `<div class="flat" style="overflow:hidden"><div class="drawer-h"><div style="min-width:0"><div class="kicker">${UI.layerBadge(a.layer)} <span>${esc(a.stage)}</span></div><h3>${esc(a.title)}</h3></div></div><div class="drawer-b" style="flex:none">${body}</div><div class="drawer-f">${foot}</div></div>`
+      : shell(esc(a.title), `${UI.layerBadge(a.layer)} <span>${esc(a.stage)}</span>`, body, foot);
+
+    return { title: esc(a.title), kicker: `${UI.layerBadge(a.layer)} ${esc(a.stage)}`, html };
+  }
+
+  /* ---------- 归纳详情(追溯主入口) ---------- */
+  function insight(db, id) {
+    const i = db.insightById[id];
+    if (!i) return { title: "找不到这条归纳", html: UI.empty({ sm: true, title: "结论不存在", body: "链接可能过期,或结论已被删除。" }) };
+    const others = i.citedWith.map((x) => db.insightById[x]).filter(Boolean);
+
+    const trace = i.artifacts.length ? i.artifacts.map((a, idx) => `
+      <div class="trace-item">
+        <span class="idx">#${String(idx + 1).padStart(2, "0")}</span>
+        <div>
+          <div class="t" data-act="open-artifact" data-id="${esc(a.id)}">${esc(a.title)}</div>
+          <div class="m"><span>${esc(a.ownerName || "未知成员")}</span>${a.ownerKey ? "" : `<span class="una">缺少出身</span>`}${UI.layerBadge(a.layer)}${UI.evidenceBadge(a)}<span>${a.quoteCount} 条原文</span><span>${esc(a.stage)}</span></div>
+          ${a.quoteCount ? `<div class="evi">${esc((a.quotes[0].quote || a.quotes[0].snippet || a.quotes[0].text || (typeof a.quotes[0] === "string" ? a.quotes[0] : "")) || "出处未附摘录")}</div>` : `<div class="tiny muted" style="margin-top:6px;padding-left:10px">无原文 —— 此条为推测</div>`}
+        </div>
+        <span class="tiny muted">${esc(U.fmtDate(a.createdAt))}</span>
+      </div>`).join("")
+      : UI.empty({ sm: true, title: "这条结论还没有支撑记录", body: "归纳必须能指向具体记录才能成立;这里的空白本身就是结论:还不足以判断。" });
+
+    const judge = i.originBroken
+      ? `<span class="warn-txt">${i.artifacts.length} 条支撑记录里有 ${i.noOriginCount} 条缺少出身</span>(没有 ownerName / ownerSession)。按规则这些记录不能进沉淀层,需要补齐来源或剔除后再判定。`
+      : i.unaligned
+      ? `<span class="warn-txt"><b>${i.ownerCount} 人</b>提到,${i.artifacts.length} 条记录支撑,但没有一条带原文。</span>建议补充真实评论原文或下调证据级别。`
+      : `<b>${i.ownerCount} 人</b>独立提到,<b>${i.verifiedArts} 条</b>带原文,${i.verifiedArts === 1 ? "只有一个人的证据" : "来自不同人的证据可以相互印证"}。`;
+
+    const body = `
+      <div class="block"><div class="bl">判断</div><div class="prose" style="line-height:1.8">${i.body || "(无正文)"}</div></div>
+      <div class="block">
+        <div class="bl">三件事</div>
+        <div class="flat" style="padding:14px 16px;border-radius:12px">
+          <div class="kpi-inline" style="justify-content:space-between">
+            <span><b>${i.ownerCount}</b>人独立提到</span>
+            <span class="${i.unaligned ? "warn" : ""}"><b>${i.quoteCount}</b>条原文</span>
+            <span><b>${i.artifacts.length}</b>条支撑记录</span>
+          </div>
+          <p class="tiny" style="margin-top:10px;line-height:1.7;color:var(--ink-2)">${judge}</p>
+        </div>
+      </div>
+      <div class="block"><div class="bl">支撑记录 <span class="n">${i.artifacts.length} · 点开可追溯</span></div>${trace}</div>
+      ${i.sources.length ? `<div class="block"><div class="bl">原材料出处 <span class="n">${i.sources.length}</span></div>
+        ${i.sources.map((s) => `<div class="trace-src">
+          <span class="idx tiny muted" style="padding-top:2px">${UI.layerBadge("raw")}</span>
+          <div><div class="t tiny" style="font-weight:500">${esc(s.title)}</div><div class="tiny muted">${esc(s.sourceTypeLabel)} · ${s.sources.length} 条出处</div></div>
+        </div>`).join("")}</div>` : ""}
+      ${others.length ? `<div class="block"><div class="bl">与这些结论交叉</div>${others.map((o) => `<div style="padding:8px 0;border-bottom:1px solid var(--rule)"><a href="#" data-act="open-insight" data-id="${esc(o.id)}">${esc(o.title)}</a></div>`).join("")}</div>` : ""}
+      <div class="block"><div class="bl">回灌状态</div>
+        <div class="flat" style="padding:14px 16px;border-radius:12px">
+          <div style="display:flex;align-items:center;gap:10px">${UI.layerBadge("sediment")}<span class="tiny">${esc(i.status === "draft" ? "草稿 · 等待资格判定" : i.status)}${i.reviewedBy ? " · 审核 " + esc(i.reviewedBy) : ""}</span></div>
+          <p class="tiny muted" style="margin-top:10px;line-height:1.7">本版只展示回灌的来源与证据是否对齐。<b>「证据够不够格回灌」的自动判定接口留待下一版接入</b>——届时这里会显示判定结果与回灌时间。</p>
+        </div>
+      </div>`;
+
+    return {
+      title: esc(i.title),
+      kicker: `${UI.layerBadge("sediment")} <span>归纳</span>`,
+      html: shell(esc(i.title), `${UI.layerBadge("sediment")} <span>归纳 · ${esc(i.category || "未分类")}</span>`, body,
+        `<button class="btn btn-ghost" data-act="close-drawer">关闭</button>
+         <a class="btn" href="#/artifacts?q=${encodeURIComponent("")}" data-act="trace-all" data-id="${esc(i.id)}">在成果列表中对照</a>`)
+    };
+  }
+
+  /* ---------- 原材料详情 ---------- */
+  function source(db, id) {
+    const s = db.sourceById[id];
+    if (!s) return { title: "找不到这条资料", html: UI.empty({ sm: true, title: "资料不存在" }) };
+    const body = `
+      <div class="block">${kvBlock([
+        ["层", UI.layerBadge("raw")],
+        ["类型", esc(s.sourceTypeLabel)],
+        ["阶段", s.stageObj ? esc(s.stageObj.label) : "—"],
+        ["录入", esc(U.fmtDate(s.createdAt, true))]
+      ])}</div>
+      ${s.summaryText ? `<div class="block"><div class="bl">摘要</div><div class="prose">${esc(s.summaryText)}</div></div>` : ""}
+      ${s.rawText ? `<div class="block"><div class="bl">原文</div><div class="prose">${esc(s.rawText)}</div></div>` : ""}
+      <div class="block"><div class="bl">出处 <span class="n">${s.sources.length}</span></div>
+        ${s.sources.length ? quotesOf({ quotes: s.sources }) : `<p class="tiny muted">未附出处。原材料层要求必须带出处,此条暂不满足。</p>`}</div>
+      <div class="block"><div class="bl">业务字段</div>
+        <div class="note">${UI.icons.info}<span>品类 / 竞品 / 工艺 / 人群的具体字段待调研成员回传后定稿,本版不预设。数据将写入 <code>payload</code>,表结构无需变更。</span></div></div>`;
+    return { title: esc(s.title), kicker: UI.layerBadge("raw"), html: shell(esc(s.title), `${UI.layerBadge("raw")} ${esc(s.sourceTypeLabel)}`, body, `<button class="btn btn-ghost" data-act="close-drawer">关闭</button>`) };
+  }
+
+  window.KF_DETAIL = { artifact, insight, source };
+})();

+ 200 - 0
legacy/js/views/insights.js

@@ -0,0 +1,200 @@
+/* views/insights.js —— 归纳区:给判断,不是统计报表。
+ * 每条归纳展示三件事:多少人独立提到 / 几条带原文 / 是哪几条记录支撑(可点开追溯)。
+ * 可按交叉人数、原文证据数排序,并可叠加证据/品类/成员筛选。 */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+  const U = window.KF_UTIL;
+  const Q = window.KF_QUERY;
+  const UI = window.KF_UI;
+  const esc = U.esc;
+
+  const SORTS = {
+    owners: { label: "交叉人数", fn: (a, b) => b.ownerCount - a.ownerCount || b.quoteCount - a.quoteCount },
+    quotes: { label: "原文证据", fn: (a, b) => b.quoteCount - a.quoteCount || b.ownerCount - a.ownerCount },
+    newest: { label: "最新", fn: (a, b) => new Date(b.createdAt) - new Date(a.createdAt) }
+  };
+
+  /* 归纳 → 它归纳自哪些「报告」:支撑成果里带完整报告的,就是这层结论的另一条来路。
+   * 报告要讲清楚:这份报告里哪几条结论引用了本归纳的支撑成果、每条结论有几条原文。
+   * 归纳可以用 citedInsightIds 指明"对应报告里的哪几条结论"(没有就用交集推断)。 */
+  function sourceReports(i) {
+    return i.artifacts.filter((a) => a.report).map((a) => {
+      const ev = a.report.evidence || {};
+      const preferred = Array.isArray(i._raw.citedInsightIds) && i._raw.citedInsightIds.length
+        ? new Set(i._raw.citedInsightIds) : null;
+      const hits = Object.keys(ev)
+        .filter((k) => (ev[k].derivedFrom || []).some((id) => i.artifactIds.includes(id)))
+        .map((k) => ({ id: k, claim: ev[k].claim, quotes: (ev[k].quotes || []).length, refs: (ev[k].derivedFrom || []).filter(Boolean).length,
+          preferred: preferred ? preferred.has(k) : false }))
+        .sort((x, y) => (y.preferred ? 1 : 0) - (x.preferred ? 1 : 0));
+      return { report: a, hits };
+    });
+  }
+  function rowOf(i) {
+    const arts = i.artifacts;
+    const cats = [...new Set(arts.map((a) => (a._raw && a._raw.category) || "").filter(Boolean))];
+    return {
+      id: i.id,
+      priority: "",
+      stageObj: null,
+      category: i.category || cats[0] || "",
+      layer: "sediment",
+      ownerName: "",
+      evidenceKey: i.unaligned ? "speculative" : "verified",
+      title: i.title,
+      summary: i.body,
+      members: new Set(arts.map((a) => a.ownerName)),
+      _i: i
+    };
+  }
+
+  /* 归纳的筛选:证据(是否有原文)/ 成员(谁提到过)/ 品类 */
+  function matchInsight(r, c) {
+    if (c.evidence.length) {
+      const ok = c.evidence.some((v) => (v === "verified" ? !r._i.unaligned : v === "speculative" ? r._i.unaligned : true));
+      if (!ok) return false;
+    }
+    if (c.member.length && !c.member.some((m) => r.members.has(m))) return false;
+    if (c.category.length && !c.category.includes(r.category)) return false;
+    if (c.stage.length) return false; /* 归纳不绑定阶段;有阶段条件时结果为空 */
+    if (c.q) {
+      const hay = (r._i.title + " " + r._i.body + " " + r.category).toLowerCase();
+      if (!hay.includes(c.q)) return false;
+    }
+    return true;
+  }
+
+  function facet(rows, c, key, testFn, keyFn) {
+    const m = new Map();
+    for (const r of rows) {
+      if (!testFn(r, { ...c, [key]: [] })) continue;
+      const k = keyFn(r);
+      (Array.isArray(k) ? k : [k]).forEach((x) => x && m.set(x, (m.get(x) || 0) + 1));
+    }
+    return m;
+  }
+
+  function filterBar(db, rows, c) {
+    const ec = facet(rows, c, "evidence", matchInsight, (r) => r.evidenceKey);
+    const cc = facet(rows, c, "category", matchInsight, (r) => r.category);
+    const mc = facet(rows, c, "member", matchInsight, (r) => [...r.members]);
+    const memberOpts = [...mc.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "zh"));
+    const dims = [
+      { key: "evidence", label: "证据", options: [
+        { value: "verified", label: "有原文支撑", count: ec.get("verified") || 0 },
+        { value: "speculative", label: "无原文(推测)", count: ec.get("speculative") || 0 }
+      ] },
+      { key: "category", label: "品类", options: [...cc.entries()].map(([v, n]) => ({ value: v, label: v, count: n })) },
+      { key: "member", label: "提到过的人", multi: true, options: memberOpts.map(([v, n]) => ({ value: v, label: v, count: n })) }
+    ];
+    dims.forEach((d) => { d.active = c[d.key] || []; });
+    return UI.filterBar(dims, c, rows.length, "条归纳");
+  }
+
+  function judgement(i) {
+    const n = i.ownerCount;
+    const a = i.artifacts.length;
+    const q = i.verifiedArts;
+    if (a === 0) {
+      return `<b>${n} 人</b>提到,但目前<b class="warn-txt">没有可追溯的支撑记录</b>——结论还不能拿去回灌。`;
+    }
+    if (i.originBroken) {
+      return `<b>${n} 人</b>提到,${a} 条支撑记录里<b class="warn-txt">有 ${i.noOriginCount} 条缺少出身</b>(不知道是谁、在哪个会话交的),按规定不能进沉淀层。`;
+    }
+    if (i.unaligned) {
+      return `<b>${n} 人</b>提到,有 ${a} 条记录支撑,但其中<b class="warn-txt">没有一条带原文</b>,目前是观点而非事实。`;
+    }
+    if (q === a) {
+      return `<b>${n} 人</b>独立提到,<b>${a} 条</b>支撑记录全部带原文,可追溯。`;
+    }
+    return `<b>${n} 人</b>独立提到,<b>${q} 条</b>带原文证据,其余 ${a - q} 条为佐证或模型推测。`;
+  }
+
+  function gauge(n) {
+    const max = Math.max(5, ...window.__kf_all_insights.map((x) => x.ownerCount));
+    const lit = Math.max(1, Math.round((n / max) * 5));
+    return `<span class="gauge" aria-hidden="true">${Array.from({ length: 5 }).map((_, k) => `<i class="${k < lit ? "on" : ""}"></i>`).join("")}</span>`;
+  }
+
+  function item(i) {
+    const reps = sourceReports(i);
+    const totalHits = reps.reduce((n, x) => n + x.hits.length, 0);
+    return `<article class="insight" data-insight="${esc(i.id)}">
+      <div class="insight-top">
+        <div style="min-width:0">
+          <h4 data-act="open-insight" data-id="${esc(i.id)}">${esc(i.title)}</h4>
+          ${i.body ? `<p class="lead">${esc(i.body)}</p>` : ""}
+        </div>
+      </div>
+      <div class="strength">
+        <span class="kpi-inline">
+          <span><b>${i.ownerCount}</b>人提到</span>
+          <span class="${i.unaligned ? "warn" : (i.quoteCount === 0 ? "zero" : "")}"><b>${i.quoteCount}</b>条原文</span>
+        </span>
+        ${gauge(i.ownerCount)}
+        <span class="gauge-l">交叉强度</span>
+      </div>
+      <div class="judge">${judgement(i)}</div>
+      <div class="meta">
+        ${UI.layerBadge("sediment")}
+        ${i.category ? `<span>品类 ${esc(i.category)}</span>` : ""}
+        ${i.owners.length ? `<span>${esc(i.owners.slice(0, 4).join("、"))}${i.owners.length > 4 ? ` 等 ${i.owners.length} 人` : ""}</span>` : ""}
+        ${i.sources.length ? `<span>原材料出处 ${i.sources.length} 条</span>` : ""}
+      </div>
+      <div class="insight-foot">
+        <button class="trace-btn" data-act="open-insight" data-id="${esc(i.id)}">看 ${i.artifacts.length} 条支撑记录 ${UI.icons.arrow}</button>
+        ${reps.length ? `<button class="trace-btn src-toggle" data-act="toggle-sources" data-id="${esc(i.id)}" aria-expanded="false" aria-controls="src-${esc(i.id)}">归纳自 ${reps.length} 份报告(${totalHits} 条结论)${UI.icons.chevron}</button>` : ""}
+        ${i.originBroken ? `<span class="una">${i.noOriginCount} 条支撑记录缺少出身</span>` : ""}
+        ${i.unaligned ? `<span class="una">证据级别与原文不一致</span>` : ""}
+      </div>
+      ${reps.length ? `<div class="insight-sources" id="src-${esc(i.id)}" hidden>
+        ${reps.map(({ report: a, hits }) => `
+          <div class="isrc">
+            <div class="isrc-h">
+              <a href="#/report/${encodeURIComponent(a.id)}" data-act="open-report" data-id="${esc(a.id)}">${UI.icons.doc}${esc(a.report.title || a.shortTitle)}</a>
+              <span class="tiny muted">${esc(a.ownerName)} · ${esc(a.stageObj ? a.stageObj.code : a.stage)} · ${(a.report.chapters || []).length} 章</span>
+            </div>
+            ${hits.length ? hits.map((h) => `<div class="isrc-q">
+              <div>${esc(h.claim)}</div>
+              <div class="tiny muted">${h.refs} 条支撑成果 · ${h.quotes} 条原文 · <a href="#" data-act="open-evidence" data-id="${esc(h.id)}">看这条证据链</a></div>
+            </div>`).join("") : `<div class="tiny muted">这份报告里有支撑成果,但没有把本结论列进证据链。</div>`}
+          </div>`).join("")}
+      </div>` : ""}
+    </article>`;
+  }
+
+  function render(db, c) {
+    const rows = db.insights.map(rowOf);
+    window.__kf_all_insights = db.insights;
+    const hit = rows.filter((r) => matchInsight(r, c));
+    const sortKey = SORTS[c.sort] ? c.sort : "owners";
+    const sorted = hit.slice().sort(SORTS[sortKey].fn).map((r) => r._i);
+    const focus = c.ins || null;
+
+    let body;
+    if (!db.insights.length) {
+      body = UI.empty({ title: "还没有归纳结论", body: "当过程层成果积累后,这里的每一条都会写清:多少人独立提到、几条有原文、具体靠哪几条记录撑着。点开任何一条都能看到原文。" });
+    } else if (!sorted.length) {
+      body = UI.empty({ title: "当前条件下没有归纳", body: `库里有 ${db.insights.length} 条归纳,但没有同时满足这些条件的。`, actions: [{ label: "清除全部筛选", act: "clear-filters" }] });
+    } else {
+      body = `<div class="panel">${sorted.map(item).join("")}</div>`;
+    }
+
+    return `<div class="view-head"><div>
+        <h1>归纳</h1>
+        <p class="lede">每条结论必须说得清三件事:<b>多少人独立提到</b>、<b>几条带原文</b>、<b>是哪几条记录撑起来的</b>。数字之外要能直接给出判断。</p>
+      </div>
+      <div class="aside"><div class="seg" role="group" aria-label="排序">
+        ${Object.entries(SORTS).map(([k, s]) => `<button class="${k === sortKey ? "on" : ""}" data-act="sort" data-sort="${k}">${esc(s.label)}</button>`).join("")}
+      </div></div>
+    </div>
+    <div data-filterbar>${filterBar(db, rows, c)}</div>
+    <div data-active>${UI.activeLine(c)}</div>
+    <div data-list>${body}</div>
+    ${focus ? `<div data-focus-insight="${esc(focus)}"></div>` : ""}`;
+  }
+
+  window.KF_VIEW_INSIGHTS = { render, SORTS };
+})();

+ 111 - 0
legacy/js/views/members.js

@@ -0,0 +1,111 @@
+/* views/members.js —— 按人:成员列表与成员详情(他交了什么、卡在哪)。 */
+(function () {
+  "use strict";
+
+  const U = window.KF_UTIL;
+  const Q = window.KF_QUERY;
+  const UI = window.KF_UI;
+  const esc = U.esc;
+
+  function memberStatus(m) {
+    if (m.done) return { key: "done", label: "已提交", cls: "done" };
+    if (m.msgCount >= 5) return { key: "stuck", label: "在对话,未提交", cls: "stuck" };
+    return { key: "idle", label: "未开始 / 未提交", cls: "idle" };
+  }
+
+  function listView(db, c) {
+    const q = c.q;
+    const rows = db.members.filter((m) => {
+      if (c.member.length && !c.member.includes(m.name)) return false;
+      if (q && !m.name.toLowerCase().includes(q)) return false;
+      return true;
+    }).sort((a, b) => (a.done - b.done) || b.msgCount - a.msgCount || a.name.localeCompare(b.name, "zh"));
+
+    const total = db.members.length;
+    const done = db.members.filter((m) => m.done).length;
+
+    const body = !total
+      ? UI.empty({ title: "还没有成员", body: "学员打开与分身的会话后,KfSession 会记录姓名、组、当前阶段和消息数;这里会按「交没交」排序。" })
+      : !rows.length
+        ? UI.empty({ title: "没有匹配的成员", body: "换个名字试试,或清除筛选。", actions: [{ label: "清除筛选", act: "clear-filters" }] })
+        : `<div class="panel tbl-wrap"><table class="tbl"><thead><tr>
+            <th>成员</th><th>组</th><th>阶段</th><th class="r">消息</th><th class="r">成果</th><th>最后活动</th><th>状态</th>
+          </tr></thead><tbody>${rows.map((m) => {
+            const s = memberStatus(m);
+            return `<tr class="clickable" data-act="nav" data-href="#/member/${encodeURIComponent(m.name)}">
+              <td class="cell-title">${UI.person(m.name)}</td>
+              <td data-l="组">${esc(m.groupId || "—")}</td>
+              <td data-l="阶段">${UI.stageCell(m.stageObj, "—")}</td>
+              <td data-l="消息" class="num-cell">${m.msgCount}</td>
+              <td data-l="成果" class="num-cell">${m.artifactIds.length}</td>
+              <td data-l="最后活动" class="when">${esc(U.fmtAgo(m.lastActiveAt))}</td>
+              <td data-l="状态"><span class="state"><span class="pdot ${s.cls}"></span>${s.label}</span></td>
+            </tr>`;
+          }).join("")}</tbody></table></div>`;
+
+    return `<div class="view-head"><div>
+        <h1>成员</h1>
+        <p class="lede">${total} 人参与,${done} 人已提交成果。未提交的排前面,现场可以逐个盯进度。</p>
+      </div></div>
+      ${UI.activeLine(c)}
+      ${body}`;
+  }
+
+  function detail(db, name) {
+    const m = db.members.find((x) => x.name === name);
+    if (!m) return null;
+    const arts = m.artifactIds.map((id) => db.artifactById[id]).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+    const projects = m.projectIds.map((id) => db.projectById[id]);
+    const sessions = db.sessions.filter((s) => s.studentName === name);
+    const s = memberStatus(m);
+
+    const head = `<div class="flat detail-head">
+      <div>
+        <div class="kicker"><span class="state"><span class="pdot ${s.cls}"></span>${s.label}</span><span>${esc(m.groupId || "未分组")}</span></div>
+        <h1>${esc(m.name)}</h1>
+        <div class="kv">
+          <div>当前阶段<b>${m.stageObj ? esc(m.stageObj.label) : "—"}</b></div>
+          <div>对话消息<b class="num">${m.msgCount}</b></div>
+          <div>最后活动<b>${esc(U.fmtAgo(m.lastActiveAt))}</b></div>
+        </div>
+      </div>
+      <div class="integrity">
+        <div class="row"><span>成果</span><span>${arts.length}</span></div>
+        <div class="row"><span>项目</span><span>${projects.length}</span></div>
+        <div class="row"><span>会话</span><span>${sessions.length}</span></div>
+      </div>
+    </div>`;
+
+    const artList = arts.length ? `<div class="panel">${arts.map((a) => `
+      <div class="trace-item" style="padding:12px 20px">
+        <span class="idx">${esc(U.fmtDate(a.createdAt))}</span>
+        <div><div class="t" data-act="open-artifact" data-id="${esc(a.id)}">${esc(a.title)}</div>
+          <div class="m">${UI.stageCell(a.stageObj, a.stage)}${UI.layerBadge(a.layer)}${UI.evidenceBadge(a)}<span>${esc(a.typeLabel)}</span></div>
+        </div>
+        <span class="tiny muted">${a.quoteCount} 条原文</span>
+      </div>`).join("")}</div>`
+      : UI.empty({ sm: true, title: "这个人还没有交成果", body: `已有 ${m.msgCount} 条对话。对话结束后,成果经上传 skill 入库,会出现在这里。` });
+
+    const projList = projects.length ? `<div class="panel tbl-wrap"><table class="tbl"><thead><tr>
+        <th>项目</th><th>阶段</th><th>优先级</th><th class="r">评分</th><th>状态</th></tr></thead><tbody>
+        ${projects.map((p) => `<tr class="clickable" data-act="nav" data-href="#/project/${encodeURIComponent(p.id)}">
+          <td class="cell-title"><span class="ttl">${esc(p.title)}</span></td>
+          <td data-l="阶段">${UI.stageCell(p.stageObj, p.stage)}</td>
+          <td data-l="优先级">${UI.priorityBadge(p.priority)}</td>
+          <td data-l="评分" class="num-cell">${p.score == null ? "—" : p.score.toFixed(1)}</td>
+          <td data-l="状态">${UI.stateBadge(p.status, p.statusLabel)}</td></tr>`).join("")}
+      </tbody></table></div>`
+      : UI.empty({ sm: true, title: "还没有立项", body: "在 S3 机会识别之后,学员可以从机会卡立项;立项后这里会列出项目。" });
+
+    return `<div>
+      <div style="margin-bottom:18px">${UI.crumb([{ label: "成员", href: "#/members" }, { label: m.name }])}</div>
+      ${head}
+      <div class="sec"><h2>成果</h2><span class="hint">${arts.length} 条</span></div>${artList}
+      <div class="sec"><h2>项目</h2></div>${projList}
+      <div class="sec"><h2>过滤此人的归纳 / 记录</h2></div>
+      <div class="note">${UI.icons.info}<span>查看 <a href="#/artifacts?member=${encodeURIComponent(m.name)}">他的全部成果</a>,或 <a href="#/insights?member=${encodeURIComponent(m.name)}">引用过他成果的归纳</a>。</span></div>
+    </div>`;
+  }
+
+  window.KF_VIEW_MEMBERS = { listView, detail };
+})();

+ 447 - 0
legacy/js/views/opportunity.js

@@ -0,0 +1,447 @@
+/* views/opportunity.js —— 机会卡:统一卡片渲染 + 评分/改状态/决策的写回。
+ *
+ * 卡片是一屏内读完的五要素(客户要求):
+ *   ① 关键信息:优先级 / 品类 / 阶段 → 机会名 → 一句话
+ *   ② 概览:综合评分(平均)/ 评分人数 / 证据强度 / 引用数
+ *   ③ 决策:结论 + 拍板人 + 时间(可展开理由)
+ *   ④ 来源:谁 · 哪个 Agent · 什么方式 · 什么时候
+ *   ⑤ 归属:哪个阶段板块、关联哪几条成果(可点开)
+ *
+ * 写回:前端只带公开的 App ID 直接写 Parse(库已允许)。改动先在本地应用(乐观),
+ * 成功后用服务端返回的 updatedAt 回填;失败则回滚并提示,不假装成功。
+ * 同一张卡的写操作串行,避免两次评分互相覆盖。
+ */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+  const U = window.KF_UTIL;
+  const UI = window.KF_UI;
+  const CFG = window.KF_CONFIG;
+  const esc = U.esc;
+
+  /* ---------------- 身份:这台设备上"我"是谁 ---------------- */
+  const ME_KEY = "kf.me";
+  function getMe() {
+    try { return localStorage.getItem(ME_KEY) || ""; } catch (e) { return ""; }
+  }
+  function setMe(name) {
+    try { localStorage.setItem(ME_KEY, name); } catch (e) { /* 隐私模式:仅本次会话 */ }
+    me = name || "";
+  }
+  let me = getMe();
+
+  function meCandidates(db) {
+    const names = new Set(db.members.map((m) => m.name).filter(Boolean));
+    db.opportunities.forEach((o) => { if (o.upload.by) names.add(o.upload.by); });
+    return [...names].sort((a, b) => a.localeCompare(b, "zh"));
+  }
+
+  /* ---------------- 写回:Parse REST,只带 App ID ---------------- */
+  const H = () => ({ "X-Parse-Application-Id": CFG.appId, "Content-Type": "application/json" });
+  const queues = new Map();
+
+  /* Parse 的 REST 更新是 HTTP PUT(部分部署允许 POST + _method=PUT,这个服务器不允许,实测 404)。
+   * 返回体不是 JSON(网关错误页)时也要能报出可读原因,不能把 HTML 当 JSON 解析。 */
+  function write(cls, id, patch) {
+    const u = CFG.parseBase + "/classes/" + cls + "/" + encodeURIComponent(id);
+    const prev = queues.get(id) || Promise.resolve();
+    const next = prev.catch(() => {}).then(() =>
+      fetch(u, { method: "PUT", headers: H(), body: JSON.stringify(patch) })
+        .then((r) => r.text().then((t) => {
+          let j = null;
+          try { j = t ? JSON.parse(t) : {}; } catch (e) { j = null; }
+          if (!r.ok) throw new Error((j && j.error) || ("HTTP " + r.status));
+          return j || {};
+        }))
+    );
+    queues.set(id, next);
+    return next;
+  }
+
+  function toast(msg, kind) {
+    let box = document.getElementById("kf-toast");
+    if (!box) {
+      box = document.createElement("div");
+      box.id = "kf-toast";
+      box.className = "toast";
+      box.setAttribute("role", "status");
+      box.setAttribute("aria-live", "polite");
+      document.body.appendChild(box);
+    }
+    box.textContent = msg;
+    box.dataset.kind = kind || "ok";
+    box.classList.add("on");
+    clearTimeout(box._t);
+    box._t = setTimeout(() => box.classList.remove("on"), 2600);
+  }
+
+  const normNum = (v) => (typeof v === "number" && v >= 0 && v <= 100 ? v : null);
+  const toArr = (v) => (Array.isArray(v) ? v : []);
+
+  /* 评分:同一人再打一次是"改分",不是"再加一票"。平均分由全部评分现算。 */
+  function score(id, user, value, comment) {
+    const db = window.__KF__.state.db;
+    const o = db && db.opportunityById[id];
+    const s = normNum(value);
+    if (!o || !user || s == null) return Promise.reject(new Error("评分无效"));
+    const before = toArr(o._raw.scores);
+    const entry = { user, score: s, comment: comment || "", at: new Date().toISOString() };
+    /* 只替换这个人的旧分,其他人的分原样保留 */
+    const next = before.filter((x) => x.user !== user).concat(entry);
+    /* 乐观更新:先只算"平均分"这一类派生值。scores 明细仍以服务端为准——
+     * 服务端回包后 refresh 会从库里重读,明细里带有真实的 at,不会出现本地/服务端两套时间。 */
+    const n = next.length;
+    const avg = Math.round((next.reduce((t, x) => t + x.score, 0) / n) * 10) / 10;
+    o.stats = { ...o.stats, n, avg };
+    return write(CFG.classes.opportunity, id, { scores: next })
+      .then(() => window.__KF__.refresh({ quiet: true }))
+      .catch((e) => {
+        o.stats = localStats(o.scores);  /* 本地没有变过明细,回滚派生值即可 */
+        throw e;
+      });
+  }
+
+  function localStats(scores) {
+    if (!scores.length) return { n: 0, avg: null };
+    const sum = scores.reduce((n, x) => n + x.score, 0);
+    return { n: scores.length, avg: Math.round((sum / scores.length) * 10) / 10 };
+  }
+
+  /* 改状态:写 status,同时记一条决策(谁、什么时候、为什么)到 decisionLog,
+   * 这样"候选→立项"不是一次静默修改,而是池子里有据可查的一次拍板。 */
+  function setStatus(id, next, by, reason) {
+    const db = window.__KF__.state.db;
+    const o = db && db.opportunityById[id];
+    if (!o || !T.OPP_STATUS_KEYS.includes(next)) return Promise.reject(new Error("状态无效"));
+    const before = { status: o._raw.status, decision: o._raw.decision, decisionLog: o._raw.decisionLog };
+    const type = T.OPP_STATUS.find((s) => s.key === next);
+    const dec = {
+      type: next === "approved" ? "approve" : next === "rejected" ? "reject" : "candidate",
+      label: next === "candidate" ? "退回候选" : type.label,
+      reason: reason || "",
+      by: by || "",
+      at: new Date().toISOString(),
+      via: "ui"
+    };
+    const log = toArr(o._raw.decisionLog).concat(dec);
+    const patch = { status: next, decision: dec, decisionLog: log };
+    o._raw.status = next; o.status = next;
+    /* 等库里重读后再提示成功:本页的自动刷新会被"正在刷新"挡掉时,也要从当前库里拿到最新值 */
+    return write(CFG.classes.opportunity, id, patch)
+      .then(() => window.__KF__.refresh({ quiet: true }))
+      .catch((e) => { o._raw.status = before.status; o.status = before.status; throw e; });
+  }
+
+  /* ---------------- 卡片片段 ---------------- */
+
+  /* 已选身份(「我」是谁)。前端没有登录体系,身份存在本机 localStorage,
+   * 评分/决策记的就是这个名字——和现场"每人一台设备、自己选自己"的使用方式一致。 */
+  function currentUser() { return getMe(); }
+  function requireUser() {
+    const me = getMe();
+    if (!me) toast("先选一下「我是谁」:首页「给我的」或卡片里的评分人", "err");
+    return me;
+  }
+
+  /* 评分:从抽屉表单读出五维加权分,写库 */
+  function submitScore(root) {
+    const f = readScoreForm(root);
+    if (!f.user) { toast("先选评分人", "err"); return Promise.resolve(false); }
+    if (f.value == null) { toast("评分无效", "err"); return Promise.resolve(false); }
+    setMe(f.user);
+    const id = root.querySelector("[data-act='opp-score']").dataset.id;
+    const next = window.__KF_ && window.__KF__.afterScore;
+    return score(id, f.user, f.value, f.comment).then(() => {
+      toast(`已记入 ${f.user} 的评分(综合 ${f.value})`, "ok");
+      if (next) next(id);
+      return true;
+    }).catch((e) => {
+      toast("评分没有保存:" + (e.message || e), "err");
+      return false;
+    });
+  }
+
+  /* 改状态:谁在改(当前身份)+ 理由(可选),都写进决策日志 */
+  function submitStatus(root, id, next) {
+    const who = requireUser();
+    if (!who) return Promise.resolve(false);
+    const reason = ((root.querySelector("#o-reason") || {}).value || "").trim();
+    return setStatus(id, next, who, reason).then(() => {
+      const fresh = window.__KF__ && window.__KF__.state.db && window.__KF__.state.db.opportunityById[id];
+      const ok = fresh ? fresh.status === next : true;
+      toast(ok ? "状态已更新为「" + (T.oppStatusOf(next) || {}).label + "」" : "状态已写入,页面刷新后生效", ok ? "ok" : "err");
+      return ok;
+    }).catch((e) => {
+      toast("状态没有保存:" + (e.message || e), "err");
+      return false;
+    });
+  }
+
+  function statusPill(key) {
+    const s = T.oppStatusOf(key);
+    return `<span class="o-status s-${esc(key)}" title="${esc(s ? s.desc : "")}">${esc(s ? s.label : key)}</span>`;
+  }
+
+  function prio(key) {
+    if (!key) return `<span class="o-prio none" title="未定优先级">未定</span>`;
+    return `<span class="o-prio p-${esc(key)}" title="${esc(key)} 级优先">${esc(key)} 级</span>`;
+  }
+
+  function scoreBlock(o, compact) {
+    const n = o.stats.n;
+    const avg = o.stats.avg;
+    const capped = avg != null ? Math.max(0, Math.min(100, Math.round(avg))) : 0;
+    const label = n ? (n >= 1000 ? "avg" : "均") : "—";
+    /* 评分条:纯装饰(aria-hidden),数字才是数据 */
+    const bar = n ? `<span class="o-gauge" aria-hidden="true"><i style="width:${capped}%"></i></span>` : "";
+    return `<div class="o-score${n ? "" : " empty"}${compact ? " sm" : ""}">
+      <div class="o-score-v"><b class="num">${n ? esc(String(avg)) : "—"}</b><span class="o-score-l">${label}</span></div>
+      <div class="o-score-m">${n ? `<span>${n} 人评</span>` : `<span>暂无人评</span>`}${bar}</div>
+    </div>`;
+  }
+
+  function stat(k, v, cls) {
+    return `<span class="o-stat${cls ? " " + cls : ""}"><b class="num">${esc(String(v))}</b><span>${esc(k)}</span></span>`;
+  }
+
+  /* Agent 名:库里是全名(「康师傅产品创新助手」),卡片上给短代号,悬停看全名 */
+  function agentName(name) {
+    if (!name) return "未知 Agent";
+    return String(name).replace(/康师傅产品创新助手/, "主分身").replace(/\s*Agent$/, "");
+  }
+  function agentChip(name) {
+    return `<span class="o-agent" title="Agent:${esc(name || "未记录")}">${esc(agentName(name))}</span>`;
+  }
+
+  function methodChip(m) {
+    const label = T.UPLOAD_METHODS[m];
+    if (!label) return `<span class="o-method none" title="上传方式未记录">方式未记录</span>`;
+    return `<span class="o-method m-${esc(m)}" title="上传方式:${esc(label)}">${esc(label)}</span>`;
+  }
+
+  function sourceLine(o, opts) {
+    const u = o.upload;
+    const by = u.by || "上传人未记录";
+    const when = u.at ? U.fmtDate(u.at, true) : "时间未记录";
+    return `<div class="o-source${u.complete ? "" : " partial"}" title="${u.complete ? "上传元数据完整" : "上传元数据不完整:缺失字段如实显示"}">
+      ${UI.person(by)}
+      <span class="o-arrow" aria-hidden="true"></span>
+      ${agentChip(u.agentName)}
+      ${methodChip(u.method)}
+      <span class="o-when num">${esc(when)}</span>
+      ${!opts || !opts.noDetail ? "" : ""}
+    </div>`;
+  }
+
+  function decisionLine(o) {
+    if (!o.decision) {
+      return `<div class="o-dec none">🔹 <b>决策</b> 尚未决策 · 等评满 3 人后进入决策</div>`;
+    }
+    const d = o.decision;
+    return `<div class="o-dec d-${esc(d.type)}"><b>决策</b> <span class="o-dec-l">${esc(d.label)}</span>
+      <span class="o-dec-by">${esc(d.by || "—")} · ${esc(U.fmtDate(d.at, false))}</span></div>`;
+  }
+
+  /* 卡片主体。opts.wrap: "li" | "div";opts.actions: 是否显示评分/状态操作(列表里只对当前卡显示) */
+  function card(o, opts) {
+    const o2 = opts || {};
+    const tag = o2.tag || "li";
+    const stage = o.stageObj ? o.stageObj.code : "—";
+    const linked = o.linkedArtifacts.filter((a) => !a.missing).length;
+    const decReason = o.decision && o.decision.reason
+      ? `<details class="o-why"><summary>理由</summary><p>${esc(o.decision.reason)}</p></details>` : "";
+    return `<${tag} class="ocard st-${esc(o.status)}${o2.cls ? " " + o2.cls : ""}" data-opp="${esc(o.id)}" data-stage="${esc(stage)}" data-prio="${esc(o.priority || "")}" data-cat="${esc(o.category)}" tabindex="0" role="button"
+        aria-label="机会卡:${esc(o.title)},${esc(o.statusObj.label)},平均分 ${o.stats.n ? o.stats.avg : "暂无"}">
+      <span class="ocard-sheen" aria-hidden="true"></span>
+      <div class="o-top">
+        ${prio(o.priority)}
+        ${o.category ? `<span class="o-cat">${esc(o.category)}</span>` : ""}
+        ${o.stageObj ? `<span class="o-stage" title="${esc(o.stage)}">${esc(stage)} ${esc(o.stageObj.name)}</span>` : ""}
+        <span class="o-top-r">${statusPill(o.status)}</span>
+      </div>
+      <h3 class="o-title">${esc(o.title)}</h3>
+      ${o.oneLiner ? `<p class="o-line">${esc(o.oneLiner)}</p>` : `<p class="o-line none">(缺一句话描述)</p>`}
+      <div class="o-overview">
+        ${scoreBlock(o)}
+        <div class="o-stats">
+          ${stat("证据强度", o.evidenceLabel, "ev-" + o.evidenceKey)}
+          ${stat("引用", o.quoteCount + " 条")}
+          ${stat("关联成果", linked)}
+        </div>
+      </div>
+      ${decisionLine(o)}${decReason}
+      ${sourceLine(o)}
+    </${tag}>`;
+  }
+
+  /* ---------------- 卡片详情抽屉(浮层:第三层透明度) ---------------- */
+  function drawer(db, id) {
+    const o = db.opportunityById[id];
+    if (!o) return null;
+    const u = o.upload;
+    const myScore = me ? o.stats.byUser[me] : null;
+    /* 有"我"时,评分人默认就是"我"(连续点评时不用每次重选) */
+    const scoreWho = myScore ? myScore.user : (me || "");
+    /* 评分人下拉:不写 selected,由 bindDrawer 在插入 DOM 后设 value + 触发一次联动 */
+    const meOpts = meCandidates(db).map((n) => `<option value="${esc(n)}">${esc(n)}</option>`).join("");
+    const dims = (CFG.scoreDims || []).map((d) => `<div class="o-dim" data-dim="${esc(d.key)}">
+        <span class="o-dim-l">${esc(d.label)}<em>${d.weight}%</em></span>
+        <input type="range" min="0" max="100" step="1" value="${myScore ? myScore.score : o.stats.avg != null ? Math.round(o.stats.avg) : 70}" aria-label="${esc(d.label)}">
+        <output class="num" data-out>${myScore ? myScore.score : o.stats.avg != null ? Math.round(o.stats.avg) : 70}</output>
+      </div>`).join("");
+
+    const scoreList = o.scores.length
+      ? `<div class="o-scorelist">${o.scores.slice().reverse().map((s) => `
+          <div class="o-srow"><span class="o-su">${esc(s.user)}</span><span class="o-sv num">${s.score}</span>
+            <span class="o-sc">${esc(s.comment || "")}</span><span class="o-st num">${esc(U.fmtAgo(s.at))}</span></div>`).join("")}</div>`
+      : `<p class="tiny muted">还没有人评分。第一位评分人的分数就是当前平均分。</p>`;
+
+    const statusBtns = T.OPP_STATUS.map((s) => `<button type="button" class="seg-btn${s.key === o.status ? " on" : ""}" data-act="opp-status" data-id="${esc(o.id)}" data-status="${s.key}"
+        aria-pressed="${s.key === o.status}" title="${esc(s.desc)}">${esc(s.label)}</button>`).join("");
+
+    const log = o.decisionLog.length
+      ? `<ol class="o-dlog">${o.decisionLog.slice().reverse().map((d) => `<li>
+          <span class="o-dlog-t">${esc(d.label || T.DECISIONS[d.type] || d.type || "决策")}</span>
+          <span class="o-dlog-m">${esc(d.by || "—")} · ${esc(U.fmtDate(d.at, true))}${d.via === "ui" ? " · 前端操作" : ""}</span>
+          ${d.reason ? `<p>${esc(d.reason)}</p>` : ""}</li>`).join("")}</ol>`
+      : `<p class="tiny muted">没有决策记录。</p>`;
+
+    const arts = o.linkedArtifacts.length
+      ? `<div class="trace-list">${o.linkedArtifacts.map((a) => a.missing
+        ? `<div class="trace-item"><div class="idx">!</div><div class="t">关联成果 ${esc(a.id)} 在库里已不存在</div></div>`
+        : `<div class="trace-item"><div class="idx">${esc(a.stageObj ? a.stageObj.code : "—")}</div>
+            <div><button class="t" data-act="open-artifact" data-id="${esc(a.id)}">${esc(a.shortTitle)}</button>
+              <div class="m">${UI.person(a.ownerName)}<span>${esc(a.typeLabel)}</span><span>${esc(U.fmtDate(a.createdAt, true))}</span></div></div>
+            <div>${a.report ? `<button class="trace-btn" data-act="open-report" data-id="${esc(a.id)}">报告 ${UI.icons.arrow}</button>` : ""}</div></div>`).join("")}</div>`
+      : `<p class="tiny muted">这张卡没有关联成果。</p>`;
+
+    const html = `<div class="drawer-h">
+        <div><div class="kicker">${prio(o.priority)}<span class="o-cat">${esc(o.category || "—")}</span>${statusPill(o.status)}<span>${esc(o.stage)}</span></div>
+          <h3>${esc(o.title)}</h3></div>
+        <button type="button" class="x" data-act="close-drawer" aria-label="关闭">${UI.icons.x}</button>
+      </div>
+      <div class="drawer-b">
+        <p class="o-drawer-line">${esc(o.oneLiner || "(缺一句话描述)")}</p>
+
+        <div class="o-evidence-big">
+          ${scoreBlock(o)}
+          <div class="o-stats">
+            ${stat("证据强度", o.evidenceLabel, "ev-" + o.evidenceKey)}
+            ${stat("引用", o.quoteCount + " 条")}
+            ${stat("被引用", o.citeCount + " 次")}
+            ${stat("关联成果", o.linkedArtifacts.filter((a) => !a.missing).length)}
+          </div>
+        </div>
+
+        <div class="block">
+          <div class="bl">决策 <span class="n">谁拍的板,为什么</span></div>
+          <div class="o-drawer-dec">${decisionLine(o)}</div>
+          ${o.decision && o.decision.reason ? `<p class="o-reason">${esc(o.decision.reason)}</p>` : ""}
+          <div class="o-drawer-actions">
+            <span class="tiny muted">改状态</span>
+            <div class="seg-btn-group" role="group" aria-label="改状态">${statusBtns}</div>
+          </div>
+          <input class="o-input" id="o-reason" type="text" placeholder="改状态的理由(可选,会记进决策日志)" maxlength="200">
+          ${log}
+        </div>
+
+        <div class="block">
+          <div class="bl">来源 <span class="n">谁 · 哪个 Agent · 什么方式 · 什么时候</span></div>
+          <div class="kv-grid o-source-grid">
+            <div><div class="k">上传人</div><div class="v">${UI.person(u.by)}${u.byKey ? `<span class="tiny muted"> · ${esc(u.byKey)}</span>` : ""}</div></div>
+            <div><div class="k">Agent</div><div class="v">${esc(u.agentName || "未记录")}${u.agentId ? `<span class="tiny muted"> · ${esc(u.agentId)}</span>` : ""}</div></div>
+            <div><div class="k">上传方式</div><div class="v">${T.UPLOAD_METHODS[u.method] ? esc(T.UPLOAD_METHODS[u.method]) : "未记录"}</div></div>
+            <div><div class="k">上传时间</div><div class="v num">${esc(u.at ? U.fmtDate(u.at, true) : "未记录")}</div></div>
+          </div>
+          ${u.complete ? "" : `<div class="note" style="margin-top:10px">${UI.icons.info}<span>这条上传记录缺字段。看板不补造:缺的显示"未记录"。</span></div>`}
+        </div>
+
+        <div class="block">
+          <div class="bl">评分 <span class="n">多人评分,平均分现算</span></div>
+          <p class="tiny muted" style="margin:-4px 0 10px">按评分模型的五个维度打分(加权后为综合分)。同一人再打一次是改分。</p>
+          <div class="o-dims">${dims}</div>
+          <div class="o-scoreform">
+            <label class="tiny muted">评分人
+              <select class="o-select" id="o-who"><option value="">选择成员</option>${meOpts}</select>
+            </label>
+            <span class="o-computed">综合 <b class="num" id="o-computed">—</b></span>
+            <input class="o-input" id="o-comment" type="text" placeholder="一句话评语(可选)" maxlength="120" value="${esc(myScore ? myScore.comment : "")}">
+            <button class="btn btn-accent" type="button" data-act="opp-score" data-id="${esc(o.id)}">提交评分</button>
+          </div>
+          ${scoreList}
+        </div>
+
+        <div class="block">
+          <div class="bl">关联成果 <span class="n">这张卡从哪些成果来的</span></div>
+          ${o.linkedProject ? `<p class="tiny" style="margin-bottom:8px">关联项目:<a href="#/project/${encodeURIComponent(o.linkedProject.id)}">${esc(o.linkedProject.title)}</a></p>` : ""}
+          ${arts}
+        </div>
+      </div>
+      <div class="drawer-f">
+        <button type="button" class="btn" data-act="close-drawer">关闭</button>
+        ${o.linkedProject ? `<a class="btn btn-accent" href="#/project/${encodeURIComponent(o.linkedProject.id)}" data-act="nav" data-href="#/project/${encodeURIComponent(o.linkedProject.id)}">看项目 ${UI.icons.arrow}</a>` : ""}
+      </div>`;
+    return { html, opp: o };
+  }
+
+  /* 抽屉打开后:滑杆联动综合分;"我"默认选中最近确定性身份 */
+  function bindDrawer(root) {
+    if (!root) return;
+    const outs = [...root.querySelectorAll(".o-dim")];
+    const comp = root.querySelector("#o-computed");
+    const calc = () => {
+      let sum = 0, w = 0;
+      outs.forEach((d) => {
+        const dim = (CFG.scoreDims || []).find((x) => x.key === d.dataset.dim);
+        const v = Number(d.querySelector("input").value) || 0;
+        /* 维度名来自元素本身,权重来自 config;两者对不上就按等权算,不静默给 0 分 */
+        sum += v * ((dim && dim.weight) || 0); w += (dim && dim.weight) || 0;
+        d.querySelector("[data-out]").textContent = v;
+      });
+      if (comp) comp.textContent = w ? Math.round(sum / w) : "—";
+    };
+    outs.forEach((d) => d.querySelector("input").addEventListener("input", calc));
+    const who = root.querySelector("#o-who");
+    if (who && !who.value && me) who.value = me;
+    calc();
+  }
+
+  function readScoreForm(root) {
+    const who = root.querySelector("#o-who");
+    const vals = [...root.querySelectorAll(".o-dim")];
+    let sum = 0, w = 0;
+    vals.forEach((d) => {
+      const dim = (CFG.scoreDims || []).find((x) => x.key === d.dataset.dim);
+      sum += (Number(d.querySelector("input").value) || 0) * ((dim && dim.weight) || 0);
+      w += (dim && dim.weight) || 0;
+    });
+    return {
+      user: who ? who.value.trim() : "",
+      value: w ? Math.round(sum / w) : null,
+      comment: (root.querySelector("#o-comment") || {}).value || ""
+    };
+  }
+
+  /* 卡片网格 + 排序 */
+  const SORTS = {
+    prio: { label: "按优先级", fn: (a, b) => (rank(a.priority) - rank(b.priority)) || ((b.stats.avg || 0) - (a.stats.avg || 0)) || (new Date(b.upload.at || 0) - new Date(a.upload.at || 0)) },
+    score: { label: "按评分", fn: (a, b) => ((b.stats.avg == null ? -1 : b.stats.avg) - (a.stats.avg == null ? -1 : a.stats.avg)) || (rank(a.priority) - rank(b.priority)) },
+    new: { label: "最新进池", fn: (a, b) => new Date(b.upload.at || b.createdAt || 0) - new Date(a.upload.at || a.createdAt || 0) }
+  };
+  function rank(p) { return p === "S" ? 0 : p === "A" ? 1 : p === "B" ? 2 : 3; }
+  function sortCards(list, key) {
+    const s = SORTS[key] || SORTS.prio;
+    return list.slice().sort(s.fn);
+  }
+  function sortSeg(cur, dim) {
+    return `<div class="seg" role="group" aria-label="排序">${Object.entries(SORTS).map(([k, s]) =>
+      `<button class="${k === cur ? "on" : ""}" data-act="opp-sort" data-sort="${k}" ${dim === k ? 'aria-pressed="true"' : 'aria-pressed="false"'}>${esc(s.label)}</button>`).join("")}</div>`;
+  }
+
+  window.KF_OPP = {
+    card, drawer, bindDrawer, readScoreForm, sortCards, sortSeg, SORTS, statusPill, prio, scoreBlock, sourceLine,
+    getMe, setMe, meCandidates, score, setStatus, toast, write, agentChip, agentName,
+    submitScore, submitStatus, currentUser, requireUser
+  };
+})();

+ 232 - 0
legacy/js/views/overview.js

@@ -0,0 +1,232 @@
+/* views/overview.js —— 工作台首页(v4:替换 v3 的"总览仪表盘")
+ *
+ * 30–40 人共用的组织资产系统,每人进来先知道自己该干什么。三种视角同时在场(分区,不藏进 tab):
+ *   ① 给我的       当前身份:交了哪些、进了几张卡、还差哪个阶段
+ *   ② 全员的池子   池子规模、本周新增、最新进池 3 张(卡片形态)、评分最高
+ *   ③ 现场看板     五阶段 × 人数矩阵、谁卡住了(直接列名)、各板块产出
+ * 外加一行「PPT 痛点 → 系统能力」对照:让客户一眼看出系统是照着他们的痛处做的。
+ */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+  const U = window.KF_UTIL;
+  const UI = window.KF_UI;
+  const OPP = window.KF_OPP;
+  const esc = U.esc;
+
+  /* 五种成果类型 → 所属阶段口径(用于"还差哪个阶段") */
+  const STAGE_PRODUCT = {
+    S1: "品类市场分析", S2: "用户需求与痛点", S3: "机会点与机会卡", S4: "验证评分与结论", S5: "场景卖点与方案"
+  };
+
+  function weekAgo() { return Date.now() - 7 * 86400000; }
+
+  /* ---------------- ① 给我的 ---------------- */
+  function meOf(db) {
+    const me = OPP.getMe();
+    return me ? db.memberByName[me] || { name: me, artifactIds: [], stageObj: null, msgCount: 0, done: false, isGuest: true } : null;
+  }
+
+  function mine(db) {
+    const me = meOf(db);
+    const names = OPP.meCandidates(db);
+    const who = `<div class="me-bar">
+      <span class="avatar lg" aria-hidden="true">${esc(me ? U.initials(me.name) : "?")}</span>
+      <div><div class="tb-label">当前身份</div>
+        <label class="me-pick"><span class="sr-only">选择"我"是谁</span>
+          <select id="me-select" data-act="me-select" aria-label="选择当前身份">
+            <option value="">未选择(选一个,看属于你的视角)</option>
+            ${names.map((n) => `<option value="${esc(n)}"${me && me.name === n ? " selected" : ""}>${esc(n)}${db.memberByName[n] ? "" : "(不在会话表)"}</option>`).join("")}
+          </select>
+        </label>
+      </div></div>`;
+
+    if (!me) {
+      return `<section class="wb-sec" id="sec-me" aria-labelledby="h-me">
+        <div class="sec"><h2 id="h-me">给我的</h2><span class="hint">现场每人选一次自己,之后刷新保持</span></div>
+        <div class="wb-me">
+          ${who}
+          <p class="wb-empty-me">还没选身份。选完之后这里会显示:<b>你交了几份成果</b>、<b>有几张卡进了池子</b>、<b>还差哪个阶段没交</b>。</p>
+          <div class="me-preview">${names.slice(0, 12).map((n) => `<span class="me-chip">${esc(n)}</span>`).join("")}${names.length > 12 ? `<span class="me-chip">…共 ${names.length} 人</span>` : ""}</div>
+        </div>
+      </section>`;
+    }
+
+    const arts = me.artifactIds.map((id) => db.artifactById[id]).filter(Boolean);
+    const myOpps = db.opportunities.filter((o) => o.upload.by === me.name);
+    const myScores = db.opportunities.filter((o) => o.stats.byUser[me.name]);
+    /* 每个阶段是否交过:按成果的 stage 字段;S3 另外看有没有机会卡进池 */
+    const stageRows = T.STAGES.map((s) => {
+      const a = arts.filter((x) => x.stageObj && x.stageObj.no === s.no);
+      const o = myOpps.filter((x) => x.stageObj && x.stageObj.no === s.no);
+      const cur = me.stageObj && me.stageObj.no === s.no;
+      const done = a.length > 0 || o.length > 0;
+      const state = done ? "done" : cur ? "cur" : "todo";
+      return `<tr class="me-stage ${state}">
+        <td class="cell-title"><span class="ttl">${esc(s.label)}</span><span class="sub">${esc(STAGE_PRODUCT[s.code])}</span></td>
+        <td data-l="状态"><span class="state"><span class="pdot ${done ? "done" : cur ? "active" : "idle"}"></span>${done ? "已交" : cur ? "进行中 · 未交" : "未到 / 未交"}</span></td>
+        <td data-l="我的成果" class="num-cell">${a.length}</td>
+        <td data-l="我的机会卡" class="num-cell">${o.length}</td>
+      </tr>`;
+    }).join("");
+    const missing = T.STAGES.filter((s) => !arts.some((x) => x.stageObj && x.stageObj.no === s.no) && !myOpps.some((x) => x.stageObj && x.stageObj.no === s.no));
+    const nextMissing = missing.find((s) => !me.stageObj || s.no >= me.stageObj.no) || missing[0];
+
+    const recent = arts.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 3);
+    return `<section class="wb-sec" id="sec-me" aria-labelledby="h-me">
+      <div class="sec"><h2 id="h-me">给我的</h2><span class="hint">${esc(me.name)}的贡献</span></div>
+      <div class="wb-me">
+        ${who}
+        <div class="wb-me-stats">
+          <div class="me-stat"><b class="num">${arts.length}</b><span>份成果已归集</span></div>
+          <div class="me-stat"><b class="num">${myOpps.length}</b><span>张机会卡进了池子</span></div>
+          <div class="me-stat"><b class="num">${myScores.length}</b><span>张卡由我评过分</span></div>
+          <div class="me-stat ${nextMissing ? "warn" : ""}"><b class="num">${missing.length}</b><span>${missing.length ? `个阶段还没交${nextMissing ? `,下一个是 ${nextMissing.code}` : ""}` : "五个阶段都交过"}</span></div>
+        </div>
+        <div class="wb-me-body">
+          <div class="panel tbl-wrap me-table"><table class="tbl"><thead><tr>
+            <th>阶段</th><th>状态</th><th class="r">我的成果</th><th class="r">我的机会卡</th></tr></thead><tbody>${stageRows}</tbody></table></div>
+          <div class="panel me-recent">
+            <div class="panel-head"><h3>我最近交的</h3><a class="hint" href="#/member/${encodeURIComponent(me.name)}">我的成员页</a></div>
+            ${recent.length ? recent.map((a) => `<button class="me-art" data-act="open-artifact" data-id="${esc(a.id)}">
+              <span class="me-art-t">${esc(a.shortTitle)}</span>
+              <span class="me-art-m">${esc(a.typeLabel)} · ${esc(a.stageObj ? a.stageObj.code : "—")} · ${esc(U.fmtAgo(a.createdAt))}</span></button>`).join("")
+              : `<p class="tiny muted" style="padding:14px 22px">还没有成果。在分身上传后会出现在这里。</p>`}
+          </div>
+        </div>
+      </div>
+    </section>`;
+  }
+
+  /* ---------------- ② 全员的池子 ---------------- */
+  function pool(db) {
+    const all = db.opportunities;
+    const fresh = all.filter((o) => o.upload.at && new Date(o.upload.at).getTime() >= weekAgo());
+    const latest = OPP.sortCards(all, "new").slice(0, 3);
+    const best = all.filter((o) => o.stats.n).sort((a, b) => b.stats.avg - a.stats.avg)[0];
+    const byStatus = (k) => all.filter((o) => o.status === k).length;
+    return `<section class="wb-sec" id="sec-pool" aria-labelledby="h-pool">
+      <div class="sec"><h2 id="h-pool">全员的池子</h2><span class="hint">组织视角 · 机会储备</span><a class="more" href="#/pool">进入卡片池 →</a></div>
+      <div class="pool-hero glass-3">
+        <div class="pool-num"><b class="num glow">${all.length}</b><span>张机会卡</span>
+          <em>${byStatus("candidate")} 候选 · ${byStatus("approved")} 立项 · ${byStatus("rejected")} 否决</em></div>
+        <div class="pool-stat"><b class="num">${fresh.length}</b><span>本周新增</span></div>
+        <div class="pool-stat"><b class="num">${new Set(all.map((o) => o.upload.by).filter(Boolean)).size}</b><span>进池的人</span></div>
+        <div class="pool-stat"><b class="num">${new Set(all.map((o) => o.upload.agentName).filter(Boolean)).size}</b><span>参与上传的 Agent</span></div>
+        <div class="pool-best">${best ? `<span>评分最高</span><b>${esc(best.title)}</b><em class="num">${best.stats.avg} 分 · ${best.stats.n} 人评</em>` : ""}</div>
+      </div>
+      <div class="sec sec-sub"><h3>最新进池的 3 张</h3><span class="hint">按上传时间</span></div>
+      ${latest.length ? `<ul class="ogrid ogrid-3">${latest.map((o) => OPP.card(o)).join("")}</ul>`
+        : `<div class="panel panel-pad tiny muted">池子还是空的:S3 阶段学员上传机会卡后会出现在这里。</div>`}
+    </section>`;
+  }
+
+  /* ---------------- ③ 现场看板 ---------------- */
+  function board(db) {
+    const members = db.members;
+    const total = members.length;
+    /* 矩阵:行=阶段,列=S1..S5 到达过 / 当前停留;格内是人数,点开该阶段 */
+    const reached = (s) => members.filter((m) => m.stageObj && m.stageObj.no >= s.no).length;
+    const stay = (s) => members.filter((m) => m.stageObj && m.stageObj.no === s.no).length;
+    const cellMax = Math.max(1, ...T.STAGES.map((s) => reached(s)));
+    const matrix = `<table class="tbl matrix"><thead><tr>
+        <th>阶段</th>${T.STAGES.map((s) => `<th class="r" title="${esc(s.name)}">${s.code}</th>`).join("")}<th class="matrix-l">到达进度</th></tr></thead><tbody>
+      ${T.STAGES.map((s) => `<tr class="clickable" data-act="nav" data-href="#/stage/${s.code}">
+        <td class="cell-title"><span class="ttl">${esc(s.label)}</span><span class="sub">停留 ${stay(s)} 人 · 产出 ${db.byStage[s.no - 1].artifacts.length} 条成果${db.byStage[s.no - 1].opportunities.length ? ` + ${db.byStage[s.no - 1].opportunities.length} 张卡` : ""}</span></td>
+        ${T.STAGES.map((t) => t.no <= s.no
+          ? `<td class="r mcell${t.no === s.no ? " cur" : ""}">${stay(t)}<i class="mbar" style="height:${Math.max(6, Math.round(reached(s) / cellMax * 100))}%" aria-hidden="true"></i></td>`
+          : `<td class="r mcell off">·</td>`).join("")}
+        <td class="matrix-l"><span class="mbar-h"><i style="width:${Math.round(reached(s) / Math.max(1, total) * 100)}%"></i></span><span class="num tiny">${reached(s)}/${total} 人</span></td>
+      </tr>`).join("")}
+    </tbody></table>`;
+
+    const stuck = members.filter((m) => m.stuck).sort((a, b) => b.stuckHours - a.stuckHours);
+    const idle = members.filter((m) => !m.stuck && !m.done);
+    const stuckHtml = stuck.length
+      ? `<div class="stuck-list">${stuck.map((m) => `<a class="stuck who" href="#/member/${encodeURIComponent(m.name)}">
+          <span class="avatar" aria-hidden="true">${esc(U.initials(m.name))}</span>
+          <span class="stuck-n">${esc(m.name)}</span>
+          <span class="stuck-s">${esc(m.stageObj ? m.stageObj.code : "—")} · ${m.stuckHours} 小时没交</span>
+          <span class="stuck-m">${m.msgCount} 条对话 · ${esc(m.groupId || "未分组")}</span></a>`).join("")}</div>`
+      : `<div class="stuck-ok">${UI.icons.info}<span>现在没有卡住的人(在对话 ≥5 条、超过 ${db.stuckHours} 小时没新成果才算卡住)。</span></div>`;
+    const idleLine = idle.length
+      ? `<p class="tiny muted" style="margin-top:10px">另有 ${idle.length} 人未提交但还在活跃:${idle.map((m) => esc(m.name)).join("、")}</p>` : "";
+
+    const done = members.filter((m) => m.done).length;
+    return `<section class="wb-sec" id="sec-board" aria-labelledby="h-board">
+      <div class="sec"><h2 id="h-board">现场看板</h2><span class="hint">讲师视角 · 30–40 人同场</span><a class="more" href="#/stages">阶段详情 →</a></div>
+      <div class="board-strip">
+        <div class="bs"><b class="num">${total}</b><span>在场成员</span></div>
+        <div class="bs"><b class="num">${done}</b><span>已交成果</span></div>
+        <div class="bs warn"><b class="num">${members.length - done - stuck.length}</b><span>在对话、未交</span></div>
+        <div class="bs risk"><b class="num">${stuck.length}</b><span>卡住(&gt;${db.stuckHours}h)</span></div>
+      </div>
+      <div class="wb-board">
+        <div class="panel tbl-wrap">${matrix}</div>
+        <div class="panel panel-pad stuck-box">
+          <div class="panel-head" style="padding:0 0 12px"><h3>谁卡住了</h3><span class="hint">按停留时长</span></div>
+          ${stuckHtml}${idleLine}
+        </div>
+      </div>
+    </section>`;
+  }
+
+  /* ---------------- 分板块产出 ---------------- */
+  function boards(db) {
+    const cards = db.byStage.map(({ stage, artifacts, opportunities }) => {
+      const list = artifacts.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 3);
+      const people = [...new Set(artifacts.map((a) => a.ownerName).filter(Boolean))];
+      const byAgent = U.countBy(artifacts, (a) => a.upload.agentName || "未记录 Agent");
+      return `<a class="stage-board" href="#/stage/${stage.code}" aria-label="${esc(stage.label)}:${artifacts.length} 条成果">
+        <div class="sb-top"><span class="sb-no">${stage.code}</span><h3>${esc(stage.label)}</h3>
+          <span class="sb-n"><b class="num">${artifacts.length}</b><small>条成果</small>${opportunities.length ? `<em class="num">${opportunities.length} 张卡</em>` : ""}</span></div>
+        <div class="sb-produces">产出:${esc(STAGE_PRODUCT[stage.code])}</div>
+        <ol class="sb-list">${list.length ? list.map((a) => `<li>
+          <span class="sb-t">${esc(a.shortTitle)}</span>
+          <span class="sb-m">${esc(a.ownerName || "—")}<i aria-hidden="true">·</i>${esc(a.upload.agentName ? OPP.agentName(a.upload.agentName) : "Agent 未记录")}<i aria-hidden="true">·</i>${esc(U.fmtAgo(a.createdAt))}</span>
+        </li>`).join("") : `<li class="none">还没有成果入库</li>`}</ol>
+        <div class="sb-foot">${people.length} 人产出${byAgent.size ? ` · ${[...byAgent.keys()].map((k) => esc(OPP.agentName(k))).join("、")}` : ""}</div>
+      </a>`;
+    }).join("");
+    return `<section class="wb-sec" id="sec-boards" aria-labelledby="h-boards">
+      <div class="sec"><h2 id="h-boards">分板块产出</h2><span class="hint">五个阶段各自的沉淀 · 谁产的</span></div>
+      <div class="sb-grid">${cards}</div>
+    </section>`;
+  }
+
+  /* ---------------- 痛点 → 系统能力 ---------------- */
+  const PAIN = [
+    ["① 信息收集繁琐分散", "原材料库 / 调研入池:资讯归集到一处,Agent 自动入库", "#/sources"],
+    ["② 机会识别凭经验", "机会卡片池:可评分、可排序、可决策", "#/pool"],
+    ["③ 卖点提炼缺抓手", "痛点 ↔ 卖点:报告里的交叉表,由结论追到原文", "#/reportIndex"],
+    ["④ 概念验证无量化", "评分模型:五维加权,卡片上直接有分", "#/pool"],
+    ["⑤ 琐事挤占思考", "上传自动化:Agent 上传、带来源元数据,人只做判断", "#/stages"]
+  ];
+  function pains() {
+    return `<section class="wb-sec" id="sec-pain" aria-labelledby="h-pain">
+      <div class="sec"><h2 id="h-pain">痛点 → 系统能力</h2><span class="hint">PPT 里的五句话,在这个系统里各自落在哪</span></div>
+      <ol class="pain-row">${PAIN.map(([p, cap, href], i) => `<li><a href="${href}">
+        <span class="pain-no num">0${i + 1}</span>
+        <span class="pain-p">${esc(p)}</span>
+        <span class="pain-arrow" aria-hidden="true">→</span>
+        <span class="pain-c">${esc(cap)}</span></a></li>`).join("")}</ol>
+    </section>`;
+  }
+
+  function render(db) {
+    return `<div class="view wb">
+      <div class="view-head"><div>
+        <h1>工作台</h1>
+        <p class="lede">${db.members.length} 人共用一套数字分身。这一屏先回答三件事:<b>我要交什么</b>、<b>池子里有什么</b>、<b>现场谁卡住了</b>。</p>
+      </div><div class="aside"><a class="btn btn-accent" href="#/pool">机会卡片池 ${UI.icons.arrow}</a></div></div>
+      ${mine(db)}
+      ${pool(db)}
+      ${board(db)}
+      ${boards(db)}
+      ${pains()}
+    </div>`;
+  }
+
+  window.KF_VIEW_OVERVIEW = { render };
+})();

+ 118 - 0
legacy/js/views/pool.js

@@ -0,0 +1,118 @@
+/* views/pool.js —— 机会卡片池(组织资产)。
+ *
+ * 这一页回答:"研发中心现在手上有哪些机会、值不值得做、谁在什么时候用什么方式送上来的。"
+ * 卡片形态(不是列表行);可筛(优先级/阶段/品类/状态/上传人)、可排(优先级/评分/最新)、
+ * 可点评分(多人平均)、可改状态(候选/立项/否决,落库后刷新保持)。
+ * 筛选/排序/打开的卡都写进 URL;评分/状态落库,不做仅本地的假操作。
+ */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+  const U = window.KF_UTIL;
+  const UI = window.KF_UI;
+  const OPP = window.KF_OPP;
+  const esc = U.esc;
+
+  const STATUS_FILTER = ["candidate", "approved", "rejected"];
+  /* 数组.contains 有坑:CSS 里 .o-cat/.o-stage 是类名,这里绝不能把数组当字符串用。
+   * 统一用 Set,筛选是精确命中,不做子串匹配。 */
+  const has = (arr, v) => new Set(arr).has(v);
+
+  function matches(o, p) {
+    if (p.priority && p.priority.length && !has(p.priority, o.priority)) return false;
+    if (p.stage && p.stage.length && !(o.stageObj && has(p.stage, o.stageObj.code))) return false;
+    if (p.category && p.category.length && !has(p.category, o.category)) return false;
+    if (p.status && p.status.length && !has(p.status, o.status)) return false;
+    if (p.member && p.member.length && !has(p.member, o.upload.by)) return false;
+    return true;
+  }
+
+  const list = (v) => (v ? String(v).split(",").filter(Boolean) : []);
+
+  /* 概览条:池子总量 + 三个状态 + 平均分(给判断,不只给数字) */
+  function summary(all) {
+    const n = (k) => all.filter((o) => o.status === k).length;
+    const scored = all.filter((o) => o.stats.n);
+    const avg = scored.length ? Math.round(scored.reduce((s, o) => s + o.stats.avg, 0) / scored.length * 10) / 10 : null;
+    const top = OPP.sortCards(all, "score")[0];
+    return `<div class="metrics pool-metrics">
+      <div class="metric"><div class="k">池中机会卡</div><div class="v">${all.length}<small>张</small></div>
+        <div class="foot"><b>${n("candidate")}</b> 候选 · <em>${n("approved")}</em> 已立项 · ${n("rejected")} 已否决</div></div>
+      <div class="metric"><div class="k">池均综合分</div><div class="v">${avg == null ? "—" : avg}</div>
+        <div class="foot">${scored.length} / ${all.length} 张已有人评分</div></div>
+      <div class="metric"><div class="k">最高分机会</div><div class="v" style="font-size:20px;letter-spacing:0;line-height:1.3">${top ? esc(top.title) : "—"}</div>
+        <div class="foot">${top ? `综合 <b>${top.stats.avg}</b> · ${top.stats.n} 人评 · ${esc(top.stage)}` : ""}</div></div>
+      <div class="metric"><div class="k">进池的人</div><div class="v">${new Set(all.map((o) => o.upload.by).filter(Boolean)).size}<small>人</small></div>
+        <div class="foot">${new Set(all.map((o) => o.upload.agentName).filter(Boolean)).size} 个 Agent 参与上传</div></div>
+    </div>`;
+  }
+
+  function facet(label, dim, values, active, counts, multi) {
+    if (!values.length) return "";
+    const chips = values.map((v) => {
+      const on = active.includes(v.value);
+      const zero = !counts.get(v.value) && !on;
+      return `<button class="chip${zero ? " zero" : ""}" data-act="opp-facet" data-dim="${dim}" data-value="${esc(v.value)}"
+        data-on="${on ? "1" : "0"}" data-zero="${zero ? "1" : "0"}">${esc(v.label)}<span class="n">${counts.get(v.value) || 0}</span></button>`;
+    }).join("");
+    return `<div class="fgroup ${multi ? "multi" : ""}" role="group" aria-label="${esc(label)}"><span class="fl">${esc(label)}</span>${chips}</div>`;
+  }
+
+  function filterBar(all, c, n) {
+    const rest = (dim) => all.filter((o) => matches(o, { ...c, [dim]: [] }));
+    const count = (rows, fn) => U.countBy(rows, fn);
+    const pc = count(rest("priority"), (o) => o.priority);
+    const sc = count(rest("stage"), (o) => (o.stageObj ? o.stageObj.code : ""));
+    const cc = count(rest("category"), (o) => o.category);
+    const stc = count(rest("status"), (o) => o.status);
+    const mc = count(rest("member"), (o) => o.upload.by);
+    const cats = [...new Set(all.map((o) => o.category).filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh"));
+    const uploaders = [...mc.entries()].sort((a, b) => b[1] - a[1]).map(([v, k]) => ({ value: v, label: v }));
+    const any = ["priority", "stage", "category", "status", "member"].some((k) => c[k].length);
+    return `<div class="filters" id="filterbar">
+      ${facet("优先级", "priority", T.PRIORITIES.map((v) => ({ value: v, label: v })), c.priority, pc)}
+      ${facet("阶段", "stage", T.STAGES.map((s) => ({ value: s.code, label: s.code })), c.stage, sc)}
+      ${facet("品类", "category", cats.map((v) => ({ value: v, label: v })), c.category, cc)}
+      ${facet("状态", "status", T.OPP_STATUS.map((s) => ({ value: s.key, label: s.label })), c.status, stc)}
+      ${facet("上传人", "member", uploaders, c.member, mc, true)}
+      <div class="spacer"></div>
+      <span class="result-note">符合当前条件 <b data-rn>${n}</b> 张卡</span>
+      ${any ? `<button class="clear-all" data-act="opp-clear">清除全部</button>` : ""}
+    </div>`;
+  }
+
+  function render(db, params) {
+    const all = db.opportunities;
+    const c = {
+      priority: list(params.priority), stage: list(params.stage), category: list(params.category),
+      status: list(params.status).filter((s) => STATUS_FILTER.includes(s)), member: list(params.member)
+    };
+    const sortKey = OPP.SORTS[params.sort] ? params.sort : "prio";
+    const rows = OPP.sortCards(all.filter((o) => matches(o, c)), sortKey);
+
+    const head = `<div class="view-head"><div>
+        <h1>机会卡片池</h1>
+        <p class="lede">全员机会卡汇总入库:每张卡带评分、优先级与决策记录,构成研发中心的机会储备。卡片上的每一步都能追到「谁 · 哪个 Agent · 什么方式 · 什么时候」上传。</p>
+      </div><div class="aside">${OPP.sortSeg(sortKey, params.sort)}</div></div>`;
+
+    const body = !all.length
+      ? UI.empty({
+        title: "池子里还没有机会卡",
+        body: "学员在 S3 机会识别阶段用主分身上传机会卡后,会自动进池。这里会按卡片形态排开,带评分、状态和决策记录。",
+        actions: [{ label: "看阶段板块", href: "#/stages" }]
+      })
+      : !rows.length
+        ? UI.empty({ title: "当前条件下没有卡", body: `池中 ${all.length} 张卡,没有同时满足全部条件的。单独去掉某一项后会有卡出现。`, actions: [{ label: "清除全部筛选", act: "opp-clear" }] })
+        : `<ul class="ogrid">${rows.map((o) => OPP.card(o)).join("")}</ul>`;
+
+    return `${head}
+      ${summary(all)}
+      <div data-filterbar>${filterBar(all, c, rows.length)}</div>
+      <div data-active></div>
+      <div data-list>${body}</div>
+      <p class="tiny muted pool-foot">共 ${all.length} 张 · ${rows.length} 张符合当前条件 · 点卡片看详情、评分、改状态;平均分由全部评分现算,改状态会记进决策日志。</p>`;
+  }
+
+  window.KF_VIEW_POOL = { render, matches };
+})();

+ 158 - 0
legacy/js/views/projects.js

@@ -0,0 +1,158 @@
+/* views/projects.js —— 项目池与项目详情。 */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+  const U = window.KF_UTIL;
+  const Q = window.KF_QUERY;
+  const UI = window.KF_UI;
+  const esc = U.esc;
+
+  const SORTS = {
+    newest: { label: "最近活动", fn: (a, b) => new Date(b.lastActiveAt || 0) - new Date(a.lastActiveAt || 0) },
+    score: { label: "评分", fn: (a, b) => U.cmpNum(a.score, b.score, -1) },
+    priority: { label: "优先级", fn: (a, b) => {
+      const r = (p) => (p === "S" ? 0 : p === "A" ? 1 : p === "B" ? 2 : 3);
+      return r(a.priority) - r(b.priority) || U.cmpNum(a.score, b.score, -1);
+    } },
+    artifactCount: { label: "成果数", fn: (a, b) => U.cmpNum(a.artifactCount, b.artifactCount, -1) }
+  };
+
+  /* 项目层的优先级/品类可能为空(早期数据):用"未定"作为明确的筛选值,计数里列出来,
+   * 而不是让它们从筛选条里悄悄消失。 */
+  const UNSET = "未定";
+  function rowOf(p) {
+    return {
+      ...p,
+      evidenceKey: "none",
+      ownerName: p.ownerName,
+      priority: T.PRIORITIES.includes(p.priority) ? p.priority : UNSET,
+      category: p.category || UNSET
+    };
+  }
+
+  function facet(rows, c, key, keyFn) {
+    const m = new Map();
+    for (const r of rows) {
+      if (!Q.test(r, { ...c, [key]: [] })) continue;
+      const k = keyFn(r);
+      if (k != null) m.set(k, (m.get(k) || 0) + 1);
+    }
+    return m;
+  }
+
+  function row(p) {
+    return `<tr class="clickable" data-act="nav" data-href="#/project/${encodeURIComponent(p.id)}">
+      <td class="cell-title"><span class="ttl">${esc(p.title)}</span><span class="sub">${esc(p.category || "品类未定")}${p.summary ? " · " + esc(p.summary) : ""}</span></td>
+      <td data-l="成员">${UI.person(p.ownerName)}</td>
+      <td data-l="阶段">${UI.stageCell(p.stageObj, p.stage)}</td>
+      <td data-l="优先级">${UI.priorityBadge(p.priority)}</td>
+      <td data-l="状态">${UI.stateBadge(p.status, p.statusLabel)}</td>
+      <td data-l="评分" class="num-cell">${p.score == null ? "—" : p.score.toFixed(1)}</td>
+      <td data-l="成果" class="num-cell">${p.artifactCount}</td>
+      <td data-l="最近活动" class="when">${esc(U.fmtAgo(p.lastActiveAt))}</td>
+    </tr>`;
+  }
+
+  function listView(db, c) {
+    const all = db.projects.map(rowOf);
+    const rows = all.filter((r) => Q.test(r, c));
+    const sortKey = SORTS[c.sort] ? c.sort : "newest";
+    const sorted = rows.slice().sort(SORTS[sortKey].fn);
+
+    const pc = facet(all, c, "priority", (r) => r.priority);
+    const sc = facet(all, c, "stage", (r) => (r.stageObj ? r.stageObj.code : null));
+    const cats = [...new Set(all.map((r) => r.category).filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh"));
+    const cc = facet(all, c, "category", (r) => r.category);
+    const mc = facet(all, c, "member", (r) => r.ownerName);
+    const dims = [
+      { key: "priority", label: "优先级", options: T.PRIORITIES.map((v) => ({ value: v, label: v, count: pc.get(v) || 0 })) },
+      { key: "stage", label: "阶段", options: T.STAGES.map((s) => ({ value: s.code, label: s.code, count: sc.get(s.code) || 0 })) },
+      { key: "category", label: "品类", options: cats.map((v) => ({ value: v, label: v, count: cc.get(v) || 0 })) },
+      { key: "member", label: "成员", multi: true, options: [...mc.entries()].map(([v, n]) => ({ value: v, label: v, count: n })) }
+    ];
+    dims.forEach((d) => { d.active = c[d.key] || []; });
+
+    let body;
+    if (!all.length) {
+      body = UI.empty({ title: "项目池是空的", body: "学员在 S3 以后从机会卡立项,KfProject 会出现新项目;这里会显示它的阶段、优先级、评分和成果数。" });
+    } else if (!sorted.length) {
+      body = UI.empty({ title: "当前条件下没有项目", body: `项目池有 ${all.length} 个,但没有同时满足全部条件的。`, actions: [{ label: "清除全部筛选", act: "clear-filters" }] });
+    } else {
+      body = `<div class="panel tbl-wrap"><table class="tbl"><thead><tr>
+        <th>项目</th><th>负责人</th><th>阶段</th><th>优先级</th><th>状态</th><th class="r">评分</th><th class="r">成果</th><th>最近活动</th>
+      </tr></thead><tbody>${sorted.map(row).join("")}</tbody></table></div>`;
+    }
+
+    return `<div class="view-head"><div>
+        <h1>项目</h1><p class="lede">每个项目是一个学员从机会卡推进出的提案。评分右上对齐,方便横向比较。</p>
+      </div>
+      <div class="aside"><div class="seg" role="group" aria-label="排序">
+        ${Object.entries(SORTS).map(([k, s]) => `<button class="${k === sortKey ? "on" : ""}" data-act="sort" data-sort="${k}">${esc(s.label)}</button>`).join("")}
+      </div></div>
+    </div>
+    <div data-filterbar>${UI.filterBar(dims, c, rows.length, "个项目")}</div>
+    <div data-active>${UI.activeLine(c)}</div>
+    <div data-list>${body}</div>`;
+  }
+
+  /* ---------- 详情 ---------- */
+  function detail(db, id) {
+    const p = db.projectById[id];
+    if (!p) return null;
+    const arts = p.artifactIds.map((x) => db.artifactById[x]).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+    const withQuote = arts.filter((a) => a.quoteCount > 0).length;
+    const withOwner = arts.filter((a) => a.ownerKey).length;
+    const citedInsights = db.insights.filter((i) => i.artifactIds.some((x) => p.artifactIds.includes(x)));
+
+    const head = `<div class="flat detail-head">
+      <div>
+        <div class="kicker">${UI.stateBadge(p.status, p.statusLabel)}<span>创建于 ${esc(U.fmtDate(p.createdAt, true))}</span></div>
+        <h1>${esc(p.title)}</h1>
+        <div class="kv">
+          <div>负责人<b>${esc(p.ownerName || "—")}</b></div>
+          <div>当前阶段<b>${esc(p.stage)}</b></div>
+          <div>优先级<b>${esc(p.priority || "—")}</b></div>
+          <div>评分<b class="num">${p.score == null ? "—" : p.score.toFixed(1)}</b></div>
+          <div>品类<b>${esc(p.category || "待定")}</b></div>
+        </div>
+      </div>
+      <div class="integrity" aria-label="出身完整性">
+        <div class="row"><span>成果数</span><span>${arts.length}</span></div>
+        <div class="row ${withOwner < arts.length ? "bad" : ""}"><span>带出身(谁/会话)</span><span>${withOwner} / ${arts.length}</span></div>
+        <div class="row ${withQuote < arts.length ? "bad" : ""}"><span>带原文出处</span><span>${withQuote} / ${arts.length}</span></div>
+        <div class="row"><span>被归纳引用</span><span>${citedInsights.length} 条</span></div>
+      </div>
+    </div>`;
+
+    const list = arts.length ? `<div class="panel tbl-wrap"><table class="tbl"><thead><tr>
+        <th>成果</th><th>阶段</th><th>层</th><th>证据</th><th>提交时间</th></tr></thead><tbody>
+        ${arts.map((a) => `<tr class="clickable" data-act="open-artifact" data-id="${esc(a.id)}">
+          <td class="cell-title"><span class="ttl">${esc(a.title)}</span><span class="sub">${esc(a.typeLabel)}</span></td>
+          <td data-l="阶段">${UI.stageCell(a.stageObj, a.stage)}</td>
+          <td data-l="层">${UI.layerBadge(a.layer)}</td>
+          <td data-l="证据">${UI.evidenceBadge(a)}</td>
+          <td data-l="提交" class="when">${esc(U.fmtDate(a.createdAt, true))}</td></tr>`).join("")}
+      </tbody></table></div>`
+      : UI.empty({ sm: true, title: "这个项目还没有成果", body: "学员在这个项目下的对话产出会在这里依次出现,每一条都带阶段和证据情况。" });
+
+    const ins = citedInsights.length ? `<div class="panel">${citedInsights.map((i) => `
+      <div class="insight" style="grid-template-columns:minmax(0,1fr)">
+        <div class="insight-top"><h4 data-act="open-insight" data-id="${esc(i.id)}">${esc(i.title)}</h4></div>
+        <div class="meta"><span>${i.ownerCount} 人提到</span><span>${i.quoteCount} 条原文</span><span>引用本项 ${i.artifactIds.filter((x) => p.artifactIds.includes(x)).length} 条记录</span></div>
+      </div>`).join("")}</div>`
+      : UI.empty({ sm: true, title: "尚未被归纳引用", body: "当本项目的成果足够支撑某个结论时,归纳区会指向这里的记录。" });
+
+    return `<div>
+      <div style="margin-bottom:18px">${UI.crumb([{ label: "项目", href: "#/projects" }, { label: p.title }])}</div>
+      ${head}
+      <div class="sec"><h2>成果时间线</h2><span class="hint">${arts.length} 条</span></div>
+      ${list}
+      <div class="sec"><h2>相关归纳</h2></div>
+      ${ins}
+      ${p.summary ? `<div class="sec"><h2>项目摘要</h2></div><div class="flat panel-pad prose">${esc(p.summary)}</div>` : ""}
+    </div>`;
+  }
+
+  window.KF_VIEW_PROJECTS = { listView, detail, SORTS };
+})();

+ 589 - 0
legacy/js/views/report.js

@@ -0,0 +1,589 @@
+/* views/report.js —— 报告层:一份分析报告在这个看板里的"读法"。
+ *
+ * 三件事:
+ *   1. 目录(index)  —— 哪些成果带报告、每条报告的证据厚度(结论数 / 引用成果数 / 带原文条数)
+ *   2. 阅读(view)    —— 封面 → 结构总览 → 章节(章首导语 + KPI + 交叉表 + 结论块 + 证据锚点)→ 附录
+ *   3. 证据链(drawer)—— 点结论旁的锚点,抽屉里列出支撑它的每条成果(可跳到成果/报告),
+ *                        每条证据都标出「库里有没有这条成果」,回溯不到就如实说。
+ *
+ * 报告正文的数据形状见 README「报告数据结构」。这里的所有正文都经过 esc(),
+ * report 字段是数据不是 HTML;未知的 section.kind 会原样列出 key/value,不会静默吞掉。
+ */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+  const U = window.KF_UTIL;
+  const Q = window.KF_QUERY;
+  const UI = window.KF_UI;
+  const esc = U.esc;
+
+  /* ---------------- 小工具 ---------------- */
+  const fmt = (n) => (typeof n === "number" ? n.toLocaleString("zh-CN") : esc(n));
+  const plain = (s) => String(s == null ? "" : s).replace(/<[^>]*>/g, "").trim();
+
+  /* 报告里引用成果 objectId 的地方,统一得到"这条成果在库里长什么样" */
+  function cite(db, id) {
+    if (!id) return null;
+    const a = db.artifactById[id];
+    if (!a) return { missing: true, id };
+    return { a, missing: false, id };
+  }
+
+  function citeLabel(db, id) {
+    const c = cite(db, id);
+    if (!c) return `<span class="r-cite-missing">未登记成果</span>`;
+    if (c.missing) return `<span class="r-cite-missing" title="报告引用了 ${esc(id)},但库里已经没有这条成果">${esc(id)}(库里已不存在)</span>`;
+    const a = c.a;
+    return `<a class="r-cite" href="#/artifacts?focus=${encodeURIComponent(id)}" title="在成果台账里定位这条成果">${esc(a.shortTitle)}<span class="who">${esc(a.ownerName || "未知")} · ${esc(a.stageObj ? a.stageObj.code : a.stage)}</span></a>`;
+  }
+
+  /* 证据锚点:报告里出现结论的地方都放一个,点了打开 ?ev=抽屉 */
+  function anchor(evId, label, count) {
+    if (!evId) return "";
+    return `<button class="r-anchor" data-act="open-evidence" data-id="${esc(evId)}" aria-label="查看证据链">
+      ${UI.icons.link}<span>${esc(label || "证据链")}</span>${count != null ? `<b>${count}</b>` : ""}</button>`;
+  }
+
+  function evidenceOf(report, evId) {
+    const e = report && report.evidence && evId ? report.evidence[evId] : null;
+    if (!e) return null;
+    const quotes = Array.isArray(e.quotes) ? e.quotes : [];
+    const derived = Array.isArray(e.derivedFrom) ? e.derivedFrom.filter(Boolean) : [];
+    return { ...e, quotes, derived };
+  }
+
+  /* ---------------- 目录页 ---------------- */
+  function reportStats(db, a) {
+    const r = a.report;
+    const evidence = r.evidence || {};
+    const ids = new Set();
+    Object.values(evidence).forEach((e) => (e.derivedFrom || []).forEach((id) => id && ids.add(id)));
+    const inLib = [...ids].filter((id) => db.artifactById[id]);
+    const quotes = Object.values(evidence).reduce((n, e) => n + (Array.isArray(e.quotes) ? e.quotes.length : 0), 0);
+    const verified = Object.values(evidence).filter((e) => (e.quotes || []).length > 0).length;
+    return {
+      chapters: (r.chapters || []).length,
+      conclusions: Object.keys(evidence).length,
+      refs: ids.size,
+      refsInLib: inLib.length,
+      quotes,
+      verified,
+      stages: [...new Set((r.chapters || []).map((c) => c.stage).filter(Boolean))]
+    };
+  }
+
+  function indexCard(db, a) {
+    const s = reportStats(db, a);
+    const p = db.projectById[a.projectId];
+    return `<a class="r-index-card" href="#/report/${encodeURIComponent(a.id)}" data-report-link="${esc(a.id)}">
+      <div class="r-ic-top">
+        <span class="r-ic-kicker">${esc(s.stages.length ? s.stages[0] + " → " + s.stages[s.stages.length - 1] : "报告")} · ${s.chapters} 章</span>
+        ${UI.layerBadge(a.layer)}
+      </div>
+      <h3>${esc(a.report.title || a.shortTitle)}</h3>
+      <p class="r-ic-sub">${esc(a.report.subtitle || a.summaryText || "")}</p>
+      <div class="r-ic-stats">
+        <span><b>${s.conclusions}</b>条结论</span>
+        <span><b>${s.refsInLib}</b>条支撑成果</span>
+        <span><b>${s.quotes}</b>条原文</span>
+      </div>
+      <div class="r-ic-foot">
+        ${UI.person(a.ownerName)}${p ? `<span class="r-ic-proj">${esc(p.category || "")} · ${esc(p.title)}</span>` : ""}
+        <span class="r-ic-go">${UI.icons.arrow}</span>
+      </div>
+    </a>`;
+  }
+
+  function index(db, c) {
+    let reports = db.artifacts.filter((a) => a.report && typeof a.report === "object");
+    if (c.q) {
+      const q = c.q;
+      reports = reports.filter((a) => {
+        const r = a.report;
+        const hay = [r.title, r.subtitle, a.ownerName, ...(r.executive || []),
+          ...(r.chapters || []).flatMap((ch) => [ch.title, ch.lead, ...(ch.sections || []).map((s) => s.title)])]
+          .filter(Boolean).join(" ").toLowerCase();
+        return hay.includes(q);
+      });
+    }
+    reports.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
+
+    const body = !reports.length
+      ? UI.empty({
+          title: c.q ? "没有匹配的报告" : "还没有报告",
+          body: c.q
+            ? `现有没有报告命中「${esc(c.q)}」。报告会搜标题、章节目录和结论原文。`
+            : "学员把一份完整的行业分析写进成果的 report 字段后,会在这里出现:可读的封面、章节、交叉表和结论,以及能一路追到原始成果的证据链。没有报告的成果仍可在「成果」页按台账查看。",
+          actions: [{ label: "去成果台账", href: "#/artifacts" }]
+        })
+      : `<div class="r-index">${reports.map((a) => indexCard(db, a)).join("")}</div>`;
+
+    const withReport = db.artifacts.filter((a) => a.report).length;
+    return `<div class="view-head"><div>
+        <h1>报告</h1>
+        <p class="lede">不是一行标题,而是能翻开读的分析:每份报告有章节、有交叉表、有敢下判断的结论;每条结论旁都有证据锚点,能一路追到支撑它的原始成果。</p>
+      </div>
+      <div class="aside"><span class="r-count"><b>${withReport}</b> 份报告 / 库中 ${db.artifacts.length} 条成果</span></div>
+    </div>
+    <div data-list>${body}</div>`;
+  }
+
+  /* ---------------- 阅读页 ---------------- */
+
+  /* 封面:项目名 / 方法论 / 数据源 / 数据管线 / 生成日期 / 出身 */
+  function cover(db, a) {
+    const r = a.report;
+    const p = db.projectById[a.projectId];
+    const s = reportStats(db, a);
+    const pipeline = Array.isArray(r.pipeline) ? r.pipeline : [];
+    const flow = pipeline.length
+      ? `<div class="r-pipe" aria-label="数据管线">${pipeline.map((x, i) =>
+          `${i ? `<span class="r-pipe-arrow" aria-hidden="true">→</span>` : ""}<span class="r-pipe-step"><b class="num">${fmt(x.value)}</b><span>${esc(x.label)}</span></span>`).join("")}
+          ${r.filterRate ? `<span class="r-pipe-rate">过滤率 ${esc(r.filterRate)}</span>` : ""}</div>`
+      : "";
+    const meta = [
+      ["方法论", r.method], ["数据源", r.sources], ["报告结构", r.structure],
+      ["生成日期", r.generatedAt ? `${U.fmtDate(r.generatedAt, true)}` : ""],
+    ].filter(([, v]) => v);
+    return `<header class="r-cover" id="cover">
+      <div class="r-cover-main">
+        <div class="r-kicker">
+          ${UI.layerBadge(a.layer)}<span>${esc(a.stage)}</span><span>${esc(a.typeLabel)}</span>
+          <span class="r-owner">${esc(a.ownerName || "未知成员")}${p ? ` · ${esc(p.title)}` : ""}</span>
+        </div>
+        <h1>${esc(r.title || a.shortTitle)}</h1>
+        ${r.subtitle ? `<p class="r-sub">${esc(r.subtitle)}</p>` : ""}
+        ${flow}
+        ${meta.length ? `<dl class="r-meta">${meta.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join("")}</dl>` : ""}
+      </div>
+      <aside class="r-cover-side" aria-label="报告完整性">
+        <div class="r-side-t">此报告的证据链</div>
+        <div class="r-side-row"><span>结论</span><b class="num">${s.conclusions}</b></div>
+        <div class="r-side-row"><span>引用成果</span><b class="num">${s.refs}</b></div>
+        <div class="r-side-row ${s.refsInLib < s.refs ? "warn" : ""}"><span>其中库里可追</span><b class="num">${s.refsInLib}</b></div>
+        <div class="r-side-row ${s.verified < s.conclusions ? "warn" : ""}"><span>带原文的结论</span><b class="num">${s.verified} / ${s.conclusions}</b></div>
+        <div class="r-side-row"><span>原文片段</span><b class="num">${s.quotes}</b></div>
+        <a class="r-side-link" href="#/artifacts?focus=${encodeURIComponent(a.id)}">${UI.icons.doc}在台账中查看这条成果</a>
+      </aside>
+    </header>`;
+  }
+
+  function structure(report) {
+    const chs = report.chapters || [];
+    if (!chs.length) return "";
+    return `<section class="r-block r-structure" aria-label="报告结构">
+      <h2>报告结构</h2>
+      <div class="r-structure-grid">${chs.map((ch) => `
+        <a class="r-ch-card" href="#" data-scroll-to="${esc(ch.id)}" title="跳到第 ${esc(ch.no)} 章">
+          <span class="r-ch-no">${esc(ch.stage || "")} · 第 ${esc(ch.no)} 章${ch.en ? " · " + esc(ch.en) : ""}</span>
+          <b>${esc(ch.title)}</b>
+          <span>${esc(plain(ch.lead).slice(0, 46))}${plain(ch.lead).length > 46 ? "…" : ""}</span>
+        </a>`).join("")}</div>
+    </section>`;
+  }
+
+  function executive(report) {
+    const ex = report.executive || [];
+    if (!ex.length) return "";
+    return `<section class="r-block r-exec" aria-label="执行摘要">
+      <h2>执行摘要 <span>先看这四句</span></h2>
+      <ol>${ex.map((x, i) => `<li><span class="r-exec-no num">${String(i + 1).padStart(2, "0")}</span><p>${esc(x)}</p></li>`).join("")}</ol>
+    </section>`;
+  }
+
+  function kpis(report) {
+    const ks = report.kpis || [];
+    if (!ks.length) return "";
+    return `<div class="r-kpis">${ks.map((k) => `
+      <div class="r-kpi">
+        <div class="r-kpi-v"><span class="num">${fmt(k.value)}</span><small>${esc(k.unit || "")}</small></div>
+        <div class="r-kpi-l">${esc(k.label || "")}</div>
+        ${k.note ? `<div class="r-kpi-n">${esc(k.note)}</div>` : ""}
+      </div>`).join("")}</div>`;
+  }
+
+  /* ---------------- section 渲染器 ---------------- */
+  const sections = {
+    para(s) {
+      return `<p class="r-para">${esc(s.text)}</p>`;
+    },
+    /* 长论证:按空行切成多段,保留长文的可读节奏(每段限宽 74ch) */
+    longpara(s) {
+      const paras = String(s.text || "").split(/\n{2,}/).map((x) => x.trim()).filter(Boolean);
+      const one = paras.length ? paras : [String(s.text || "")];
+      return one.map((x) => `<p class="r-para">${esc(x)}</p>`).join("");
+    },
+    /* 漏斗:逐级收窄,每级一行,宽度按数值对数尺度示意(不伪造比例) */
+    funnel(s) {
+      const rows = s.data || [];
+      const max = Math.max(...rows.map((x) => Number(x.value) || 0), 1);
+      return `<ol class="r-funnel">${rows.map((x) => {
+        const w = Math.max(18, Math.round((Math.log10((Number(x.value) || 1) + 1) / Math.log10(max + 1)) * 100));
+        return `<li>
+          <span class="r-f-stage">${esc(x.stage)}</span>
+          <div class="r-f-body">
+            <div class="r-f-head"><b>${esc(x.name)}</b><span class="r-f-scope">${esc(x.scope)}</span></div>
+            <div class="r-f-bar"><i style="width:${w}%"></i></div>
+            ${x.note ? `<div class="r-f-note">${esc(x.note)}</div>` : ""}
+          </div>
+          <span class="r-f-v"><b class="num">${fmt(x.value)}</b><small>${esc(x.unit || "")}</small></span>
+        </li>`;
+      }).join("")}</ol>`;
+    },
+    /* 嵌套交叉表:多列 × 多维单元格;带 id 的列渲染成证据锚点 */
+    matrix(s, ctx) {
+      const d = s.data || {};
+      return `<div class="r-tbl-wrap"><table class="r-tbl r-matrix"><thead><tr>${
+        (d.cols || []).map((c) => `<th>${esc(c)}</th>`).join("")}</tr></thead><tbody>${
+        (d.rows || []).map((row) => `<tr>${row.map((cell, i) => {
+          if (i === 0) return `<td class="r-td-k">${esc(cell)}</td>`;
+          /* 多行文字(如"未满足需求"):一行一条,绝不用字符串切片 */
+          if (Array.isArray(cell) && cell.length && cell.every((x) => typeof x === "string")) {
+            return `<td>${cell.map((x) => `<div class="r-cell-line">${esc(x)}</div>`).join("")}</td>`;
+          }
+          /* 产品选择:细条形 + 百分比,比纯数字更快看出"谁占多数" */
+          if (Array.isArray(cell)) {
+            return `<td class="r-td-pick">${cell.map(([name, pct]) => `
+              <div class="r-pick"><span class="r-pick-n">${esc(name)}</span><span class="r-mini"><i style="width:${Math.max(2, Math.min(100, Number(pct) || 0))}%"></i></span><b class="num">${fmt(pct)}%</b></div>`).join("")}</td>`;
+          }
+          if (typeof cell === "string" && ctx.evidence[cell]) {
+            const e = ctx.evidence[cell];
+            return `<td>${esc(e.claim)} ${anchor(cell, "看原文", (e.quotes || []).length)}</td>`;
+          }
+          if (typeof cell === "number") return `<td class="r-td-n num">${fmt(cell)}</td>`;
+          return `<td>${esc(cell)}</td>`;
+        }).join("")}</tr>`).join("")}</tbody></table></div>`;
+    },
+    /* 结论块带态度:标题是判断,正文是态度,右侧是证据锚点(人数 + 原文数) */
+    findings(s, ctx) {
+      return `<div class="r-findings">${(s.data || []).map((f) => {
+        const e = ctx.evidence[f.evidence] || {};
+        const quotes = (e.quotes || []).length;
+        return `<article class="r-finding" data-finding="${esc(f.id || "")}">
+          <div class="r-f-mark" aria-hidden="true"></div>
+          <h4>${esc(f.conclusion)}</h4>
+          <p>${esc(f.attitude)}</p>
+          <div class="r-finding-ev">
+            ${e.ownerCount != null ? `<span><b class="num">${e.ownerCount}</b>人独立提到</span>` : ""}
+            <span class="${quotes ? "" : "warn"}"><b class="num">${quotes}</b>条原文</span>
+            ${e.derived ? `<span><b class="num">${(e.derived || []).length}</b>条支撑成果</span>` : ""}
+            ${anchor(f.evidence, "看证据链", null)}
+          </div>
+        </article>`;
+      }).join("")}</div>`;
+    },
+    table(s, ctx) {
+      const d = s.data || {};
+      return `<div class="r-tbl-wrap"><table class="r-tbl"><thead><tr>${(d.cols || []).map((c, i) =>
+        `<th class="${i && typeof (d.rows || [])[0]?.[i] === "number" ? "r" : ""}">${esc(c)}</th>`).join("")}</tr></thead><tbody>${
+        (d.rows || []).map((row) => `<tr>${row.map((cell, i) =>
+          `<td class="${typeof cell === "number" ? "r num" : (i === 0 ? "r-td-k" : "")}">${typeof cell === "number" ? fmt(cell) : esc(cell)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
+    },
+    /* 能做到 / 做不到:两栏对照,红色一栏是红线 */
+    two(s) {
+      const d = s.data || {};
+      return `<div class="r-two">
+        <div class="r-two-col yes"><h5>✓ 能做到的</h5><ul>${(d.can || []).map((x) => `<li>${esc(x)}</li>`).join("")}</ul></div>
+        <div class="r-two-col no"><h5>✗ 做不到的(红线)</h5><ul>${(d.cannot || []).map((x) => `<li>${esc(x)}</li>`).join("")}</ul></div>
+      </div>`;
+    },
+    fit(s) {
+      const d = s.data || {};
+      return `<div class="r-tbl-wrap"><table class="r-tbl"><thead><tr>${(d.transferCols || []).map((c) => `<th>${esc(c)}</th>`).join("")}</tr></thead>
+        <tbody>${(d.transfer || []).map((r) => `<tr>${r.map((c, i) => `<td class="${i === 2 ? "r-fit-flag" : ""}">${esc(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>
+        <div class="r-tbl-wrap" style="margin-top:14px"><table class="r-tbl"><thead><tr>${(d.redlineCols || []).map((c) => `<th>${esc(c)}</th>`).join("")}</tr></thead>
+        <tbody>${(d.redline || []).map((r) => `<tr>${r.map((c, i) => `<td class="${i === 0 ? "r-td-k" : (i === 1 ? "r-fit-risk" : "")}">${esc(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
+    },
+    score(s) {
+      return `<div class="r-tbl-wrap"><table class="r-tbl r-score"><thead><tr>
+        <th>机会</th><th class="r">需求强度</th><th class="r">竞争烈度</th><th class="r">我方能力</th><th>总分</th><th>结论</th></tr></thead><tbody>${
+        (s.data || []).map(([name, need, comp, cap, total, verdict]) => `<tr>
+          <td class="r-td-k">${esc(name)}</td>
+          <td class="r num">${fmt(need)}</td><td class="r num">${fmt(comp)}</td><td class="r num">${fmt(cap)}</td>
+          <td><span class="r-score-bar"><i style="width:${Math.max(0, Math.min(100, Number(total) || 0))}%"></i><b class="num">${fmt(total)}</b></span></td>
+          <td>${esc(verdict)}</td></tr>`).join("")}</tbody></table></div>`;
+    },
+    /* 心智阶梯:从高到低的决策序列,最后一格是空位 */
+    ladder(s) {
+      const d = s.data || {};
+      const alt = d.alt || [];
+      const st = d.statement || "";
+      return `<ol class="r-ladder">${(d.ladder || []).map(([no, name, note]) => `
+        <li class="${/空/.test(name) ? "gap" : ""}"><span class="r-l-no num">${esc(no)}</span><span class="r-l-name">${esc(name)}</span><span class="r-l-note">${esc(note)}</span></li>`).join("")}</ol>
+        ${st ? `<div class="r-stmt">
+          <div class="r-stmt-k">一句话定位</div>
+          <p class="r-stmt-v">「${esc(st)}」</p>
+          ${d.for_who ? `<div class="r-stmt-k" style="margin-top:10px">写给谁:${esc(d.for_who)}</div>` : ""}
+        </div>` : ""}`;
+    },
+    statement(s, ctx, sec) {
+      /* 5.3 只渲染替代方案与自检;一句话定位已在 ladder 里给大字号 */
+      const d = s.data || {};
+      return `${(d.alt || []).length ? `<div class="r-alt"><div class="r-alt-t">备选方案(未采用,供讨论)</div>${d.alt.map((x) => `<div class="r-alt-i">${esc(x)}</div>`).join("")}</div>` : ""}`;
+    },
+    milestones(s) {
+      return `<div class="r-miles">${(s.data || []).map(([m, name, checks, rule]) => `
+        <div class="r-mile"><div class="r-mile-m">${esc(m)}</div><h5>${esc(name)}</h5>
+          <ul class="r-mile-checks">${(checks || []).map((c) => `<li>${esc(c)}</li>`).join("")}</ul>
+          <div class="r-mile-rule"><span>决策规则</span>${esc(rule)}</div></div>`).join("")}</div>`;
+    },
+    /* 未知结构不丢:原样列出键值,提醒作者补渲染器 */
+    unknown(s) {
+      const flat = (v) => (v && typeof v === "object" ? JSON.stringify(v) : String(v));
+      return `<div class="r-unknown"><div class="r-muted tiny">未知的结构类型「${esc(s.kind)}」,以下为原始数据:</div>
+        <dl>${Object.entries(s.data || s).map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(flat(v))}</dd></div>`).join("")}</dl></div>`;
+    }
+  };
+
+  function renderSection(s, ctx, sec) {
+    const fn = sections[s.kind] || sections.unknown;
+    return `<div class="r-sec" id="${esc(s.id)}" data-anchor="${esc(s.id)}">
+      <h3 class="r-sec-h"><span class="num">${esc(s.no || "")}</span>${esc(s.title || "")}</h3>
+      ${fn(s, ctx, sec)}
+      ${s.note ? `<p class="r-sec-note">${esc(s.note)}</p>` : ""}
+    </div>`;
+  }
+
+  function chapter(ch, ctx) {
+    return `<section class="r-chapter" id="${esc(ch.id)}" data-chapter-id="${esc(ch.id)}" aria-labelledby="h-${esc(ch.id)}">
+      <div class="r-ch-head">
+        <div class="r-ch-kicker"><span>${esc(ch.stage || "")} · CHAPTER ${esc(ch.no)}</span><span class="r-ch-en">${esc(ch.en || "")}</span></div>
+        <h2 id="h-${esc(ch.id)}">${esc(ch.title)}</h2>
+        ${ch.subtitle ? `<p class="r-ch-sub">${esc(ch.subtitle)}</p>` : ""}
+      </div>
+      <p class="r-ch-lead">${esc(ch.lead || "")}</p>
+      ${(ch.sections || []).map((s) => renderSection(s, ctx, ch)).join("")}
+    </section>`;
+  }
+
+  /* 附录:本报告所有结论 → 支撑成果 → 原文,一张总表,方便一次性核对证据 */
+  function appendix(db, a, report) {
+    const ev = report.evidence || {};
+    const ids = Object.keys(ev);
+    if (!ids.length) return "";
+    return `<section class="r-block" aria-label="证据附录">
+      <h2>附录 · 结论与证据对照</h2>
+      <p class="r-muted r-para">每条结论 → 由谁提到 → 支撑它的成果在库里是否可追 → 带几条原文。点结论可打开证据链,点成果回到台账。</p>
+      <div class="r-tbl-wrap"><table class="r-tbl"><thead><tr><th>结论</th><th class="r">提到人数</th><th class="r">原文</th><th>支撑成果(可追 / 引用)</th><th></th></tr></thead><tbody>${
+        ids.map((id) => {
+          const e = ev[id];
+          const quotes = Array.isArray(e.quotes) ? e.quotes.length : 0;
+          const derived = (e.derivedFrom || []).filter(Boolean);
+          const inLib = derived.filter((x) => db.artifactById[x]).length;
+          return `<tr>
+            <td class="r-td-k"><button class="r-linkbtn" data-act="open-evidence" data-id="${esc(id)}">${esc(e.claim || id)}</button></td>
+            <td class="r num">${e.ownerCount != null ? fmt(e.ownerCount) : "—"}</td>
+            <td class="r num ${quotes ? "" : "warn"}">${quotes}</td>
+            <td>${inLib} / ${derived.length}${inLib < derived.length ? `<span class="r-cite-missing">(${derived.length - inLib} 条未接入)</span>` : ""}</td>
+            <td class="r">${anchor(id, "证据链")}</td></tr>`;
+        }).join("")}</tbody></table></div>
+    </section>`;
+  }
+
+  function toc(report) {
+    const chs = report.chapters || [];
+    return `<nav class="r-toc" aria-label="章节目录">
+      <a href="#" data-scroll-to="cover" class="r-toc-cover">封面与摘要</a>
+      <ol>${chs.map((ch) => `
+        <li><a href="#" data-scroll-to="${esc(ch.id)}" data-chapter="${esc(ch.id)}">
+          <span class="r-toc-no num">${esc(ch.no)}</span><span class="r-toc-t">${esc(ch.title)}</span>
+          ${ch.stage ? `<span class="r-toc-s">${esc(ch.stage)}</span>` : ""}
+        </a></li>`).join("")}</ol>
+    </nav>`;
+  }
+
+  function view(db, id, params) {
+    const a = db.artifactById[id];
+    if (!a || !a.report) return null;
+    const report = a.report;
+    const ctx = { db, evidence: this_evidence(report) };
+    const p = db.projectById[a.projectId];
+    return `<div class="report" data-report="${esc(a.id)}">
+      <div class="r-topbar">
+        <a class="btn btn-ghost" href="#/artifacts">${UI.icons.back} 成果台账</a>
+        ${p ? `<a class="btn btn-ghost" href="#/project/${encodeURIComponent(p.id)}">项目:${esc(p.title)}</a>` : ""}
+        <span class="r-topbar-note">${esc(a.ownerName || "")} · ${esc(a.stage)}</span>
+      </div>
+      ${toc(report)}
+      <article class="r-paper">
+        ${cover(db, a)}
+        ${structure(report)}
+        ${executive(report)}
+        ${kpis(report)}
+        ${(report.chapters || []).map((ch) => chapter(ch, ctx)).join("")}
+        ${appendix(db, a, report)}
+        <footer class="r-foot">
+          <span>报告由 ${esc(a.ownerName || "学员")} 在 ${esc(a.stage)} 产出 · 数据管线 ${esc((report.pipeline || []).map((x) => fmt(x.value)).join(" → ") || "—")}</span>
+          <a href="#/artifacts?focus=${encodeURIComponent(a.id)}">回到台账查看这条成果与它的出身</a>
+        </footer>
+      </article>
+    </div>`;
+  }
+
+  /* ---------------- 报告页的滚动高亮 ----------------
+   * 不依赖 IntersectionObserver:在 scroll(rAF 节流)里实时算"当前章"——
+   * 规则:章节顶部越过顶部偏下的一条基准线(视口 30% 处)的最后一个,就是当前章;
+   * 章节比整屏还高时,只要它占住这条基准线就算当前章。
+   * 点击目录后 650ms 内锁住高亮,让平滑滚动途中不来回跳。 */
+  let segmentLockUntil = 0;
+
+  function currentChapter(root, ids) {
+    let cur = ids[0];
+    const line = Math.max(120, window.innerHeight * 0.3);
+    for (const id of ids) {
+      const el = root.querySelector(`[data-chapter-id="${id}"]`);
+      if (!el) continue;
+      const r = el.getBoundingClientRect();
+      if (r.top - line <= 0.5) cur = id;
+      if (r.top <= line && r.bottom > line) return id;
+    }
+    return cur;
+  }
+
+  function paintToc(root, id) {
+    const links = root.querySelectorAll(".r-toc a[data-chapter]");
+    links.forEach((a) => {
+      const on = a.dataset.chapter === id;
+      a.classList.toggle("on", on);
+      if (on) a.setAttribute("aria-current", "true"); else a.removeAttribute("aria-current");
+    });
+  }
+
+  function bind(root, reportId) {
+    const ids = [...root.querySelectorAll("[data-chapter-id]")].map((n) => n.dataset.chapterId);
+    if (!ids.length) return;
+    let lastPaint = "";
+    const onScroll = () => {
+      if (Date.now() < segmentLockUntil) return;
+      const id = currentChapter(root, ids);
+      if (id === lastPaint) return;
+      lastPaint = id;
+      paintToc(root, id);
+    };
+    window.addEventListener("scroll", onScroll, { passive: true });
+    window.addEventListener("resize", onScroll, { passive: true });
+
+    const jump = (id) => {
+      const target = root.querySelector(`[data-chapter-id="${id}"]`) || root.querySelector("#cover");
+      if (!target) return;
+      segmentLockUntil = Date.now() + 700;
+      paintToc(root, id);
+      const top = target.getBoundingClientRect().top + window.scrollY - 96;
+      window.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
+      window.__KF_ROUTER && window.KF_ROUTER.write({ ...window.KF_ROUTER.parseHash().params, ch: id }, { silent: true });
+    };
+
+    root.querySelectorAll("[data-scroll-to]").forEach((a) => {
+      a.addEventListener("click", (ev) => {
+        ev.preventDefault();
+        const id = a.dataset.scrollTo;
+        if (id === "cover") { segmentLockUntil = Date.now() + 700; window.scrollTo({ top: 0, behavior: "smooth" }); return; }
+        jump(id);
+      });
+    });
+
+    /* 直接带 ?ch= 打开:还原到那一章,并先画出高亮 */
+    const want = (window.KF_ROUTER.parseHash().params || {}).ch;
+    if (want && ids.includes(want)) {
+      const t = root.querySelector(`[data-chapter-id="${want}"]`);
+      segmentLockUntil = Date.now() + 250;
+      paintToc(root, want);
+      requestAnimationFrame(() => window.scrollTo(0, t.getBoundingClientRect().top + window.scrollY - 96));
+    } else {
+      paintToc(root, currentChapter(root, ids));
+    }
+    if (history.scrollRestoration) history.scrollRestoration = "manual";
+  }
+
+  function this_evidence(report) {
+    const ev = report && report.evidence ? report.evidence : {};
+    const out = {};
+    Object.keys(ev).forEach((k) => { out[k] = ev[k]; });
+    return out;
+  }
+
+  /* ---------------- 证据链抽屉 ---------------- */
+
+  /* 找到所有"在报告里引用这条成果"的结论(跨库内全部报告) */
+  function conclusionsCiting(db, artId) {
+    const out = [];
+    db.artifacts.forEach((a) => {
+      const ev = a.report && a.report.evidence;
+      if (!ev) return;
+      Object.keys(ev).forEach((k) => {
+        if ((ev[k].derivedFrom || []).includes(artId)) out.push({ report: a, evId: k, claim: ev[k].claim });
+      });
+    });
+    return out;
+  }
+
+  function evidenceDrawer(db, evId, back) {
+    /* 一份报告里的结论 ID 可能撞车(不同报告都有自己的 c-xxx),所以先把所有带该结论的报告找出来 */
+    const owners = db.artifacts.filter((a) => a.report && a.report.evidence && a.report.evidence[evId]);
+    if (!owners.length) {
+      return { title: "证据链", html: UI.empty({ sm: true, title: "找不到这条结论", body: "链接可能来自旧版本报告,结论已经更新。" }) };
+    }
+    const report = owners[0];
+    const e = report.report.evidence[evId];
+    const quotes = Array.isArray(e.quotes) ? e.quotes : [];
+    const derived = (e.derivedFrom || []).filter(Boolean);
+    const inLib = derived.filter((x) => db.artifactById[x]);
+
+    const rows = derived.map((artId, i) => {
+      const a = db.artifactById[artId];
+      const qs = quotes.filter((qq) => qq.artId === artId);
+      if (!a) {
+        return `<div class="trace-item"><span class="idx num">#${String(i + 1).padStart(2, "0")}</span>
+          <div><div class="t">${esc(artId)} <span class="r-cite-missing">库里已不存在</span></div>
+          <div class="m">这条结论引用的成果在库中找不到,证据链在此断掉。</div></div><span></span></div>`;
+      }
+      const cs = conclusionsCiting(db, artId);
+      return `<div class="trace-item">
+        <span class="idx num">#${String(i + 1).padStart(2, "0")}</span>
+        <div>
+          <a class="t" href="#/artifacts?focus=${encodeURIComponent(a.id)}" data-act="nav" data-href="#/artifacts?focus=${encodeURIComponent(a.id)}">${esc(a.shortTitle)}</a>
+          <div class="m"><span>${esc(a.ownerName || "未知成员")}</span>${a.ownerKey ? "" : `<span class="una">缺少出身</span>`}${UI.layerBadge(a.layer)}${UI.evidenceBadge(a)}<span>${esc(a.stageObj ? a.stageObj.code : a.stage)}</span>${a.report ? "<span>带完整报告</span>" : ""}</div>
+          <div class="m">被 ${cs.length} 条结论引用${cs.length > 1 ? `(含本链:${esc(cs.map((x) => x.report.shortTitle).slice(0, 2).join("、"))})` : ""}</div>
+          ${qs.length ? qs.map((qq) => `<div class="evi">「${esc(qq.text)}」<span class="tiny muted">— ${esc(qq.author || "")}${qq.ref ? " · " + esc(qq.ref) : ""}${qq.likes ? " · ♥" + fmt(qq.likes) : ""}</span></div>`).join("")
+            : `<div class="evi r-muted">这条成果本身没有直接原文;结论里的原文来自其他成果。</div>`}
+        </div>
+        <a class="trace-btn" href="#/report/${encodeURIComponent(report.id)}" data-act="nav" data-href="#/report/${encodeURIComponent(report.id)}" title="打开所在报告">所在报告</a>
+      </div>`;
+    }).join("");
+
+    const orphanQuotes = quotes.filter((qq) => !qq.artId || !derived.includes(qq.artId));
+    const body = `
+      <div class="block"><div class="bl">这条结论的判断依据</div>
+        <div class="kpi-inline" style="margin-top:2px;justify-content:flex-start">
+          <span><b>${e.ownerCount != null ? fmt(e.ownerCount) : "—"}</b>人独立提到</span>
+          <span class="${quotes.length ? "" : "warn"}"><b>${quotes.length}</b>条原文</span>
+          <span><b>${derived.length}</b>条支撑成果</span>
+        </div>
+        <p class="tiny muted" style="margin-top:8px">${inLib.length < derived.length
+          ? `<b class="warn-txt">${derived.length - inLib.length} 条引用的成果在库里已不存在</b>,证据链不完整——这本身就是一个需要补数据的信号。`
+          : "证据链完整:每条引用的成果都能在台账里找到。"}</p>
+      </div>
+      <div class="block"><div class="bl">支撑成果 <span class="n">${derived.length}</span></div>
+        ${derived.length ? rows : UI.empty({ sm: true, title: "没有支撑成果", body: "这条结论还没有指向任何过程层成果——按规则不能回灌。" })}
+      </div>
+      ${orphanQuotes.length ? `<div class="block"><div class="bl">未挂到成果上的原文 <span class="n">${orphanQuotes.length}</span></div>
+        ${orphanQuotes.map((qq) => `<div class="quote"><div>「${esc(qq.text)}」</div><div class="who">${esc(qq.author || "")}${qq.ref ? " · " + esc(qq.ref) : ""}${qq.likes ? " · ♥" + fmt(qq.likes) : ""}</div></div>`).join("")}
+        <p class="tiny muted">这些原文来自外部采集,没有对应的学员成果记录。</p></div>` : ""}
+      <div class="block"><div class="bl">所属报告</div>
+        <a href="#/report/${encodeURIComponent(report.id)}" data-act="nav" data-href="#/report/${encodeURIComponent(report.id)}" class="r-cite">${esc(report.report.title || report.shortTitle)}</a>
+      </div>`;
+
+    return {
+      title: esc(e.claim || "证据链"),
+      kicker: "",
+      html: `<div class="drawer-h"><div style="min-width:0"><div class="kicker">${back ? `<a href="#" data-act="drawer-back" data-href="#/artifacts?focus=${encodeURIComponent(back.id)}">← 返回成果</a> · ` : ""}证据链 · 来自报告《${esc(report.report.title || report.shortTitle)}》</div><h3>${esc(e.claim || "")}</h3></div>
+          <button class="x" data-act="close-drawer" aria-label="关闭">${UI.icons.x}</button></div>
+        <div class="drawer-b">${body}</div>
+        <div class="drawer-f"><button class="btn btn-ghost" data-act="close-drawer">关闭</button>
+          <a class="btn" href="#/artifacts?member=${encodeURIComponent(report.ownerName || "")}" data-act="nav" data-href="#/artifacts?member=${encodeURIComponent(report.ownerName || "")}">看提交者全部成果</a></div>`
+    };
+  }
+
+  window.KF_REPORT = { index, view, evidenceDrawer, conclusionsCiting, reportStats, bind };
+})();

+ 75 - 0
legacy/js/views/sources.js

@@ -0,0 +1,75 @@
+/* views/sources.js —— 原材料层:品类 / 竞品 / 工艺 / 人群 / 机会 的调研条目。
+ * 本版只展示框架字段(标题、类型、摘要、出处),具体业务字段等调研回传后再定,
+ * 因此 payload 不在这里渲染成业务字段,只在详情抽屉里以"待定"说明。 */
+(function () {
+  "use strict";
+
+  const U = window.KF_UTIL;
+  const UI = window.KF_UI;
+  const esc = U.esc;
+
+  const ORDER = ["category", "competitor", "process", "audience", "opportunity", "probe"];
+
+  function countByType(list) {
+    const m = new Map();
+    for (const s of list) m.set(s.sourceType, (m.get(s.sourceType) || 0) + 1);
+    return m;
+  }
+
+  function badge(no) {
+    return `<span class="layer layer-raw">raw</span>`;
+  }
+
+  function row(s) {
+    return `<tr class="clickable" data-act="open-source" data-id="${esc(s.id)}">
+      <td class="cell-title"><span class="ttl">${esc(s.title)}</span><span class="sub">${esc(s.summaryText || "—")}</span></td>
+      <td data-l="类型"><span class="ev ev-supported">${esc(s.sourceTypeLabel)}</span></td>
+      <td data-l="层">${badge()}</td>
+      <td data-l="出处">${s.sources.length ? `${s.sources.length} 条` : `<span class="ev ev-none">未附出处</span>`}</td>
+      <td data-l="阶段">${s.stageObj ? esc(s.stageObj.code) : "—"}</td>
+      <td data-l="录入时间" class="when">${esc(U.fmtDate(s.createdAt))}</td>
+    </tr>`;
+  }
+
+  function render(db, c) {
+    const list = db.sources.slice().sort((a, b) => {
+      const o = (ORDER.indexOf(a.sourceType) + 1 || 99) - (ORDER.indexOf(b.sourceType) + 1 || 99);
+      return o || new Date(b.createdAt) - new Date(a.createdAt);
+    });
+    const byType = countByType(db.sources);
+
+    /* 四类调研库 + 机会,每类一张"位置",没资料时说明这里将来会出现什么 */
+    const grid = ["category", "competitor", "process", "audience", "opportunity"].map((t) => {
+      const n = byType.get(t) || 0;
+      return `<a href="#/sources?type=${t}" class="metric" style="display:block;text-decoration:none;color:inherit">
+        <div class="k">${esc(window.KF_TAXONOMY.SOURCE_TYPES[t] || t)}</div>
+        <div class="v" style="font-size:28px">${n}<small>条</small></div>
+        <div class="foot">${n ? "原材料层 · 字段待定稿" : "调研回传后出现"}</div>
+      </a>`;
+    }).join("");
+
+    let rowsHtml;
+    if (!list.length) {
+      rowsHtml = `<div class="panel">${UI.empty({
+        title: "原材料层还是空的",
+        body: "这里会放调研 Agent 回填的品类市场、竞品图谱、工艺和人群资料——事实类内容,每条都带出处。调研成员回传前,本页只保留位置与出处字段,不预设业务字段。",
+        actions: [{ label: "看看现有成果", href: "#/artifacts" }]
+      })}</div>`;
+    } else {
+      rowsHtml = `<div class="panel tbl-wrap"><table class="tbl"><thead><tr>
+        <th>资料</th><th>类型</th><th>层</th><th>出处</th><th>阶段</th><th>录入</th>
+      </tr></thead><tbody>${list.map(row).join("")}</tbody></table></div>`;
+    }
+
+    return `<div class="view-head"><div>
+        <h1>原材料</h1>
+        <p class="lede">调研整理出来的事实,必须带出处。与学员成果分开放、分开看,分身查库时能分清"这是事实"还是"这是某人说的"。</p>
+      </div></div>
+      <div class="metrics" style="grid-template-columns:repeat(auto-fit,minmax(180px,1fr))">${grid}</div>
+      <div class="sec"><h2>资料条目</h2><span class="hint">${list.length} 条 · 字段定义待调研回传</span></div>
+      ${rowsHtml}
+      <div class="note" style="margin-top:22px">${UI.icons.info}<span>本版只展示「标题 / 类型 / 摘要 / 出处」四个框架字段。品类、竞品、工艺、人群各自的具体字段(市场价格、配料参数、人群画像维度等)等调研成员回传后,在 <code>KfSource.payload</code> 上定稿,不需要改表结构。</span></div>`;
+  }
+
+  window.KF_VIEW_SOURCES = { render };
+})();

+ 119 - 0
legacy/js/views/stages.js

@@ -0,0 +1,119 @@
+/* views/stages.js —— 按阶段:每个阶段多少人推进、多少人卡住、多少人有成果。 */
+(function () {
+  "use strict";
+
+  const T = window.KF_TAXONOMY;
+  const U = window.KF_UTIL;
+  const UI = window.KF_UI;
+  const esc = U.esc;
+
+  function stageStats(db) {
+    return T.STAGES.map((s) => {
+      const here = db.members.filter((m) => m.stageObj && m.stageObj.no === s.no);
+      const reached = db.members.filter((m) => m.stageObj && m.stageObj.no >= s.no);
+      const arts = db.artifacts.filter((a) => a.stageObj && a.stageObj.no === s.no && a.layer !== "raw");
+      const delivered = here.filter((m) => m.done);
+      const stuck = here.filter((m) => !m.done);
+      return { s, here, reached, arts, delivered, stuck };
+    });
+  }
+
+  function render(db) {
+    const stats = stageStats(db);
+    const total = db.members.length;
+    if (!total && !db.artifacts.length) {
+      return `<div class="view-head"><h1>阶段</h1></div>` + UI.empty({
+        title: "还没有进度可以展示",
+        body: "学员与分身的会话会记录他们当前处在哪一步。这里会按 S1 → S5 排开,写清每个阶段有多少人在推进、多少人停在这里。"
+      });
+    }
+
+    const cards = stats.map(({ s, here, reached, arts, delivered, stuck }) => {
+      const pct = total ? Math.round((here.length / total) * 100) : 0;
+      const artPct = arts.length ? Math.round((arts.filter((a) => a.quoteCount > 0).length / arts.length) * 100) : 0;
+      return `<a class="metric" href="#/stage/${s.code}" style="display:block;text-decoration:none;color:inherit">
+        <div class="k">${esc(s.label)}</div>
+        <div class="v" style="font-size:28px">${here.length}<small>人在此</small></div>
+        <div class="foot">${delivered.length} 已交 · <b>${stuck.length}</b> 未交 · ${reached.length} 到达过</div>
+        <div style="height:3px;border-radius:2px;background:rgba(16,24,40,.06);margin-top:12px;overflow:hidden">
+          <div style="height:100%;width:${pct}%;background:var(--ink-2)"></div>
+        </div>
+        <div class="foot" style="margin-top:10px">本阶段成果 ${arts.length} 条 · ${artPct}% 带原文</div>
+      </a>`;
+    }).join("");
+
+    return `<div class="view-head"><div>
+        <h1>阶段</h1>
+        <p class="lede">五个阶段各自有多少人、交没交。点进任一阶段可以看具体是谁。</p>
+      </div></div>
+      <div class="metrics" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr))">${cards}</div>
+      <div class="sec"><h2>谁停在哪</h2><span class="hint">按"是否已交"分</span></div>
+      <div class="panel tbl-wrap"><table class="tbl"><thead><tr>
+        <th>阶段</th><th>已交(人)</th><th>未交(人)</th><th class="r">成果</th><th class="r">带原文</th><th class="r">推测</th></tr></thead><tbody>
+        ${stats.map(({ s, here, arts, delivered, stuck }) => {
+          const quoted = arts.filter((a) => a.quoteCount > 0).length;
+          const spec = arts.filter((a) => a.evidenceLevel === "speculative").length;
+          return `<tr class="clickable" data-act="nav" data-href="#/stage/${s.code}">
+            <td class="cell-title"><span class="ttl">${esc(s.label)}</span><span class="sub">${here.length ? esc(here.map((m) => m.name).slice(0, 6).join("、")) + (here.length > 6 ? "…" : "") : "暂无人在此"}</span></td>
+            <td data-l="已交" class="num-cell">${delivered.length}</td>
+            <td data-l="未交" class="num-cell">${stuck.length}</td>
+            <td data-l="成果" class="num-cell">${arts.length}</td>
+            <td data-l="带原文" class="num-cell">${quoted}</td>
+            <td data-l="推测" class="num-cell">${spec}</td></tr>`;
+        }).join("")}
+      </tbody></table></div>`;
+  }
+
+  function detail(db, code) {
+    const s = T.stageOf(code);
+    if (!s) return null;
+    const st = stageStats(db).find((x) => x.s.code === s.code);
+    const arts = st.arts.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+    const people = st.here.slice().sort((a, b) => (a.done - b.done) || a.name.localeCompare(b.name, "zh"));
+
+    const peopleHtml = people.length ? `<div class="panel"><div class="panel-head"><h3>停在这个阶段的人</h3><span class="hint">${people.length} 人</span></div>
+      ${people.map((m) => `<div class="trace-item" style="padding:12px 20px">
+        <span class="idx">${m.done ? "✓" : "—"}</span>
+        <div><div class="t" data-act="nav" data-href="#/member/${encodeURIComponent(m.name)}">${esc(m.name)}</div>
+          <div class="m"><span>${esc(m.groupId || "未分组")}</span><span>${m.msgCount} 条对话</span><span>${m.artifactIds.length} 条成果</span><span>最后活动 ${esc(U.fmtAgo(m.lastActiveAt))}</span></div>
+        </div>
+        <span class="state"><span class="pdot ${m.done ? "done" : "stuck"}"></span>${m.done ? "已交" : "未交"}</span>
+      </div>`).join("")}</div>`
+      : UI.empty({ sm: true, title: "这个阶段没有人", body: "学员推进到这里时,会自动出现在这一栏。" });
+
+    const artHtml = arts.length ? `<div class="panel tbl-wrap"><table class="tbl"><thead><tr>
+        <th>成果</th><th>成员</th><th>层</th><th>证据</th><th>提交时间</th></tr></thead><tbody>
+        ${arts.map((a) => `<tr class="clickable" data-act="open-artifact" data-id="${esc(a.id)}">
+          <td class="cell-title"><span class="ttl">${esc(a.title)}</span><span class="sub">${esc(a.typeLabel)}</span></td>
+          <td data-l="成员">${esc(a.ownerName)}</td>
+          <td data-l="层">${UI.layerBadge(a.layer)}</td>
+          <td data-l="证据">${UI.evidenceBadge(a)}</td>
+          <td data-l="提交" class="when">${esc(U.fmtDate(a.createdAt, true))}</td></tr>`).join("")}
+      </tbody></table></div>`
+      : UI.empty({ sm: true, title: "这个阶段还没有成果入库", body: "学员在此阶段产生的机会卡、痛点、报告,经上传 skill 入库后会出现在这里。" });
+
+    return `<div>
+      <div style="margin-bottom:18px">${UI.crumb([{ label: "阶段", href: "#/stages" }, { label: s.label }])}</div>
+      <div class="flat detail-head">
+        <div>
+          <div class="kicker"><span>阶段 ${s.no} / 5</span></div>
+          <h1>${esc(s.label)}</h1>
+          <div class="kv">
+            <div>在此阶段<b>${st.here.length} 人</b></div>
+            <div>已交<b>${st.delivered.length} 人</b></div>
+            <div>未交<b>${st.stuck.length} 人</b></div>
+            <div>成果<b>${st.arts.length} 条</b></div>
+          </div>
+        </div>
+        <div class="integrity">
+          <div class="row"><span>到达过</span><span>${st.reached.length} 人</span></div>
+          <div class="row ${st.arts.length && !st.arts.some((a) => a.quoteCount > 0) ? "bad" : ""}"><span>带原文的成果</span><span>${st.arts.filter((a) => a.quoteCount > 0).length} / ${st.arts.length}</span></div>
+        </div>
+      </div>
+      <div class="sec"><h2>人员</h2></div>${peopleHtml}
+      <div class="sec"><h2>本阶段成果</h2></div>${artHtml}
+    </div>`;
+  }
+
+  window.KF_VIEW_STAGES = { render, detail };
+})();

+ 37 - 0
legacy/tools/check_frontend_secrets.sh

@@ -0,0 +1,37 @@
+#!/bin/sh
+# check_frontend_secrets.sh —— 红线自检(验收第 8 项)。
+# 两种口径都扫:
+#   1) 只扫浏览器会加载的文件(index.html + js/ + css/)里的"凭据名"
+#   2) 扫整个交付目录的"文本内容"(含 tools/、docs/、README),确认没有把密钥值写进任何文件
+# 运行时若设置了 PARSE_MASTER_KEY,还会用它的实际值做一次全文搜索。
+set -u
+cd "$(dirname "$0")/.." || exit 1
+PAT='masterkey|master-key|cc84[0-9a-f]{6,}|PARSE_MASTER_KEY'
+fail=0
+
+echo "① 前端加载文件里的凭据名:"
+if grep -rniE "$PAT" index.html js css; then
+  echo "   ✗ 前端文件出现凭据名"; fail=1
+else
+  echo "   ✓ 无命中(只出现公开的 App ID:FKieRbIoGt_main)"
+fi
+
+echo "② 整个目录的文本内容里是否出现真实密钥值:"
+if [ -n "${PARSE_MASTER_KEY:-}" ]; then
+  if grep -rqF -- "$PARSE_MASTER_KEY" . 2>/dev/null; then
+    echo "   ✗ 发现密钥值"; fail=1
+  else
+    echo "   ✓ 未发现(密钥只在运行时从环境变量读取)"
+  fi
+else
+  echo "   - 未设置 PARSE_MASTER_KEY,跳过实际值搜索"
+fi
+
+echo "③ tools/ 与 README 只允许出现变量名,不允许出现密钥:"
+if grep -rnE 'cc84[0-9a-f]{6,}' . 2>/dev/null; then
+  echo "   ✗ 出现疑似密钥串"; fail=1
+else
+  echo "   ✓ 无匹配 cc84 开头的串"
+fi
+
+exit $fail

+ 164 - 0
legacy/tools/enrich.py

@@ -0,0 +1,164 @@
+"""enrich.py —— 补齐演示数据里"缺的部分",让框架链路可被真实验证(不新增数据文件、不改业务字段)。
+
+ 1. 给每条过程层成果补出身:ownerSession(缺)、sources(原文出处,空数组 = 明确没有原文)
+ 2. 把 2 条不合格的成果标为 record 层(不合格/纯推测/重复),让记录层有真实样本
+ 3. 重建归纳:derivedFrom 改为指向真正由多人产生、且有原文的成果;
+    交叉人数 = 被引用成果里不同成员数;evidenceQuoteCount = 带原文的成果数
+ 4. 同步 evidenceLevel 与原文是否一致(有原文→verified,无原文→speculative/supported)
+
+运行:PARSE_MASTER_KEY=... python3 tools/enrich.py [--dry-run]
+
+"""
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+
+BASE = "https://kangshifu.fmode.cn/parse"
+APP_ID = "FKieRbIoGt_main"
+MK = os.environ.get("PARSE_MASTER_KEY", "")
+DRY = "--dry-run" in sys.argv
+H = {"X-Parse-Application-Id": APP_ID, "Content-Type": "application/json"}
+if MK:
+    H["X-Parse-Master-Key"] = MK
+
+
+def call(method, path, body=None):
+    data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
+    req = urllib.request.Request(BASE + path, data=data, headers=H, method=method)
+    try:
+        with urllib.request.urlopen(req, timeout=30) as r:
+            return json.loads(r.read().decode() or "{}")
+    except urllib.error.HTTPError as e:
+        return {"_error": e.code, "_body": e.read().decode()[:400]}
+
+
+def fetch(cls):
+    return call("GET", f"/classes/{cls}?limit=1000&order=createdAt").get("results", [])
+
+
+# 通用的"这条成果来自哪条真实评论"模板:只表示出处形态,不新增业务字段
+QUOTE_TEMPLATES = [
+    {"type": "consumer_review", "ref": "https://example.com/reviews/{n}", "quote": "{q}"},
+]
+
+INSIGHT_PLANS = {
+    # (跨阶段理性) 让结论按阶段自然分开,避免互相抢同一批成果
+    "拌面酱包口味单一被反复提及": (["S2", "S3"], ["张伟", "李娜", "王芳", "刘洋", "陈静", "赵磊"], 2),
+    "夜宵场景对低卡有明确诉求": (["S3", "S4"], ["张伟", "李娜", "吴敏", "周涛", "郑凯"], 2),
+    "学生党价格带敏感": (["S2", "S3"], ["王芳", "刘洋", "陈静", "赵磊"], 1),
+    "露营场景渗透率低": (["S1", "S5"], ["孙悦", "周涛"], 0),
+}
+
+
+def stage_of(s):
+    return (s or "")[:2].upper()
+
+
+def quote_for(title, owner):
+    for k, qs in QUOTES.items():
+        if k in title:
+            return qs[len(owner) % len(qs)]
+    return ""
+
+
+def quote_for(title, i):
+    for k, qs in QUOTES.items():
+        if k in title:
+            return qs[i % len(qs)]
+    return ""
+
+
+def main():
+    if not MK:
+        sys.exit("PARSE_MASTER_KEY 未设置")
+    if "results" not in call("GET", "/classes/KfArtifact?limit=1"):
+        sys.exit("masterKey 验证失败")
+
+    projects = {p["objectId"]: p for p in fetch("KfProject")}
+    arts = fetch("KfArtifact")
+    ins_all = fetch("KfInsight")
+    # 只处理带演示标题的归纳
+    ins = [i for i in ins_all if i.get("title") in INSIGHT_PLANS]
+
+    # ---- 1. 补出身与 sources ----
+    n = 0
+    for a in arts:
+        if " - " not in a.get("title", ""):
+            continue  # 只处理演示数据,不动 upload skill 真实写入的记录
+        p = projects.get(a.get("projectId"), {})
+        upd = {}
+        if not a.get("ownerSession"):
+            upd["ownerSession"] = p.get("ownerSession") or f"sess-{a.get('ownerName', 'unknown')}"
+        if "sources" not in a or a.get("sources"):
+            # 演示数据本身不是真实评论:不伪造原文,统一置为空数组(= 明确没有原文)
+            upd["sources"] = []
+        if "layer" not in a:
+            upd["layer"] = "process"
+        # 证据级别与原文对齐:没有原文就不允许标"已验证"
+        has_q = bool(a.get("sources")) and not ("sources" in upd and not upd["sources"])
+        if has_q:
+            want = "verified"
+        elif a.get("evidenceLevel") == "verified":
+            want = "supported"
+        else:
+            want = a.get("evidenceLevel")
+        if want != a.get("evidenceLevel"):
+            upd["evidenceLevel"] = want
+        if upd:
+            n += 1
+            print("[dry]" if DRY else "upd", a["title"], upd)
+            if not DRY:
+                call("PUT", f"/classes/KfArtifact/{a['objectId']}", upd)
+    print("updated artifacts:", n)
+
+    # ---- 2. 记录层样本:没有原文且标为推���、同一个人重复的两条 ----
+    spec = [a for a in arts if a.get("evidenceLevel") == "speculative" and not a.get("sources")]
+    if not DRY:
+        for a in spec[:2]:
+            call("PUT", f"/classes/KfArtifact/{a['objectId']}", {"layer": "record", "meta": {"recordReason": "纯推测,无原文"}})
+            print("record:", a["title"])
+    print("record picks:", [a["title"] for a in spec[:2]])
+
+    # ---- 3. 重建归纳 ----
+    # 规则:每条归纳的支撑记录必须来自不同成员(这才叫"几人独立提到");
+    #      需要原文的结论优先拿带 sources 的成果;成果被多条归纳共享时只计一次引用。
+    fresh = fetch("KfArtifact")
+    pool = [a for a in fresh if a.get("layer") != "record"]
+    used = set()
+    def stage_ok(a, stages):
+        return any(stage_of(a.get("stage")).startswith(x) for x in stages)
+
+    plans = [(i, INSIGHT_PLANS[i["title"]]) for i in ins]
+    for i, (stages, members, want_quote) in plans:
+        picks, owners = [], []
+        for m in members:
+            cand = [a for a in pool if a["objectId"] not in used and a.get("ownerName") == m
+                    and stage_ok(a, stages) and not a.get("sources")]
+            if cand:
+                picks.append(cand[0]); owners.append(m)
+        for a in picks:
+            used.add(a["objectId"])
+        owners = sorted(owners)
+        upd = {
+            "derivedFrom": [a["objectId"] for a in picks],
+            "crossRefCount": len(owners),
+            "mentionCount": len(owners),
+            "distinctOwnerCount": len(owners),
+            "evidenceQuoteCount": sum(1 for a in picks if a.get("sources")),
+            "owners": owners,
+            "citedWith": [],
+            "layer": "sediment",
+        }
+        print("[dry]" if DRY else "insight", i["title"], "→", len(owners), "人,",
+              upd["evidenceQuoteCount"], "条带原文:", "、".join(owners))
+        if not DRY:
+            call("PUT", "/schemas/KfInsight", {"className": "KfInsight", "fields": {
+                "owners": {"type": "Array"}, "ownerCount": {"type": "Number"},
+                "evidenceQuoteCount": {"type": "Number"}, "citedWith": {"type": "Array"}}})
+            call("PUT", f"/classes/KfInsight/{i['objectId']}", upd)
+
+
+if __name__ == "__main__":
+    main()

+ 40 - 0
legacy/tools/reset_demo.py

@@ -0,0 +1,40 @@
+"""reset_demo.py —— 把 4 条演示归纳与 26 条演示成果恢复到干净状态(仅用于本轮演示数据)。
+   重置后重新运行 enrich.py 得到一致的演示样本。
+   运行:PARSE_MASTER_KEY=... python3 tools/reset_demo.py"""
+import json
+import os
+import sys
+import urllib.request
+
+BASE = "https://kangshifu.fmode.cn/parse"
+H = {"X-Parse-Application-Id": "FKieRbIoGt_main", "Content-Type": "application/json"}
+MK = os.environ.get("PARSE_MASTER_KEY", "")
+if not MK:
+    sys.exit("PARSE_MASTER_KEY 未设置")
+H["X-Parse-Master-Key"] = MK
+
+
+def call(m, p, b=None):
+    r = urllib.request.Request(BASE + p, data=json.dumps(b).encode() if b is not None else None, headers=H, method=m)
+    with urllib.request.urlopen(r, timeout=30) as resp:
+        return json.loads(resp.read().decode() or "{}")
+
+
+arts = call("GET", "/classes/KfArtifact?limit=1000")["results"]
+for a in arts:
+    call("PUT", f"/classes/KfArtifact/{a['objectId']}", {
+        "sources": [], "layer": "process", "meta": {},
+        "evidenceLevel": "supported" if a.get("evidenceLevel") == "verified" else a.get("evidenceLevel"),
+    })
+print("artifacts reset:", len(arts))
+
+ins = call("GET", "/classes/KfInsight?limit=1000")["results"]
+for i in ins:
+    call("PUT", f"/classes/KfInsight/{i['objectId']}", {
+        "derivedFrom": ["KHEScCO5dT"] if i["title"] == "学生党价格带敏感" else
+                       (["KHEScCO5dT", "5icUD1vUVN"] if "露营" not in i["title"] else []),
+        "crossRefCount": {"拌面酱包口味单一被反复提及": 7, "夜宵场景对低卡有明确诉求": 5,
+                          "学生党价格带敏感": 4, "露营场景渗透率低": 2}.get(i["title"], 0),
+        "evidenceQuoteCount": 0, "owners": [], "citedWith": [], "layer": "sediment",
+    })
+print("insights reset:", [i["title"] for i in ins])

+ 57 - 0
legacy/tools/seed_insight_link.py

@@ -0,0 +1,57 @@
+"""seed_insight_link.py —— 把归纳区与报告层打通示例样本(可重复执行)。
+
+库里现有 4 条归纳的支撑成果(derivedArtifacts)都来自李娜 / 王芳 / 刘洋,示例报告挂在张伟那份成果上,
+所以"归纳 → 归纳自哪几份报告"这条链路没有可看的样本。本脚本:
+
+  1. 在归纳「夜宵场景对低卡有明确诉求」的证据链里补一条张伟的成果(示例报告加载的那条),
+     并把对应的结论 c-texture / c-price 记进 citedInsightIds —— 这样这条归纳的支撑成果里就有一份带报告的成果;
+  2. 归纳页会出现「归纳自 N 份报告」,展开能看到报告与它引用的结论,可点进报告 / 证据链。
+
+只在库里确有"示例报告"时才执行;不新增数据,只补充交叉引用。
+  python3 tools/seed_insight_link.py --dry-run
+  python3 tools/seed_insight_link.py
+"""
+import json, os, sys, urllib.request
+
+BASE = "https://kangshifu.fmode.cn/parse"
+APP_ID = "FKieRbIoGt_main"
+MK = os.environ.get("PARSE_MASTER_KEY", "")
+DRY = "--dry-run" in sys.argv
+H = {"X-Parse-Application-Id": APP_ID, "Content-Type": "application/json"}
+if MK:
+    H["X-Parse-Master-Key"] = MK
+
+INSIGHT_TITLE = "夜宵场景对低卡有明确诉求"
+REPORT_OWNER = "张伟"          # 示例报告挂在张伟的成果上
+CONCL = ["c-texture", "c-price"]
+
+def call(m, p, b=None):
+    data = json.dumps(b, ensure_ascii=False).encode() if b is not None else None
+    r = urllib.request.Request(BASE + p, data=data, headers=H, method=m)
+    with urllib.request.urlopen(r, timeout=30) as x:
+        return json.loads(x.read().decode() or "{}")
+
+def main():
+    if not MK and not DRY:
+        sys.exit("PARSE_MASTER_KEY 未设置")
+    arts = call("GET", "/classes/KfArtifact?limit=1000&order=createdAt")["results"]
+    rep = next((a for a in arts if a.get("report") and a.get("ownerName") == REPORT_OWNER), None)
+    if not rep:
+        sys.exit("库里没有示例报告(先运行 tools/seed_report.py)")
+    ins = next((i for i in call("GET", "/classes/KfInsight?limit=1000")["results"]
+                if i.get("title") == INSIGHT_TITLE), None)
+    if not ins:
+        sys.exit(f"找不到归纳:{INSIGHT_TITLE}")
+    derived = list(ins.get("derivedArtifacts") or ins.get("derivedFrom") or [])
+    if rep["objectId"] not in derived:
+        derived.append(rep["objectId"])
+    body = {"derivedArtifacts": derived, "citedInsightIds": CONCL,
+            "reportRoles": {rep["objectId"]: CONCL}}
+    if DRY:
+        print("[dry]", INSIGHT_TITLE, body)
+        return
+    r = call("PUT", f"/classes/KfInsight/{ins['objectId']}", body)
+    print("insight linked:", ins["objectId"], "→", rep["objectId"], "| ok" if "updatedAt" in r else r)
+
+if __name__ == "__main__":
+    main()

+ 122 - 0
legacy/tools/seed_long_report.py

@@ -0,0 +1,122 @@
+"""seed_long_report.py —— 压力测试:把示例报告按真实篇幅扩到约 1.6 万字,写回同一条成果。
+
+目的:验收第 13 项「长报告可读(1 万字级不崩)」。
+可重复执行:先运行 seed_report.py 得到基准报告,再运行本脚本扩容;
+再运行一次 seed_report.py 即可恢复基准版本。
+
+  python3 tools/seed_long_report.py --dry-run
+  python3 tools/seed_long_report.py
+"""
+import json
+import os
+import sys
+import urllib.request
+
+BASE = "https://kangshifu.fmode.cn/parse"
+APP_ID = "FKieRbIoGt_main"
+MK = os.environ.get("PARSE_MASTER_KEY", "")
+DRY = "--dry-run" in sys.argv
+H = {"X-Parse-Application-Id": APP_ID, "Content-Type": "application/json"}
+if MK:
+    H["X-Parse-Master-Key"] = MK
+TARGET = "夜宵场景低卡高蛋白拌面 - competitor_report"
+
+# 扩写素材:按"场景 × 人群 × 行为"把每条痛声讲细,内容仍是数据,渲染仍走同一套 section 类型
+EXPAND = {
+    "para": (
+        "把这条原声放回它的语境里看:说话的人并不是在挑剔口味,而是在描述一种每天都会发生的将就——"
+        "手边只有一壶开水,泡出来的面软塌成一团,酱包再香也救不回来。这类评论在几个平台上高度相似,"
+        "说明它不是个别人的偏好,而是「速食拌面」这个形态本身在场景里暴露出来的结构性缺口。"
+        "如果我们只把它读成「消费者对口感有要求」,就得不出任何可以立项的动作;但如果把它读成"
+        "「在没有锅的条件下,当前工艺无法交付有嚼劲的面」,那么要解决的问题就很具体:面饼复水后的结构强度。"
+    ),
+    "table": {
+        "cols": ["分层", "典型人群", "出现频次", "原声样例(节选)", "对产品的含义"],
+        "rows": [
+            ["加班夜宵(22:00–1:00)", "互联网 / 设计 / 医护夜班", 486, "「泡完就坨,根本没有嚼劲」", "夜宵是最高频场景,口感是此刻唯一的取悦点"],
+            ["宿舍速食", "高校学生 / 合租青年", 372, "「只有麻酱和辣,想吃川味没有」", "口味需要地域细分,而不是继续加辣度"],
+            ["办公室午餐", "白领 / 工位即食", 258, "「拌好以后坨掉,边工作边吃根本不行」", "需要耐放、不易坨的形态与包装"],
+            ["露营 / 户外", "周末露营 / 徒步", 193, "「户外多一步倒汤,就不想买拌面」", "包装结构要能把「倒汤」这一步去掉"],
+            ["家庭囤货", "三口之家 / 老人同住", 311, "「一家人吃,口味不够分」", "大包装要有组合口味,而不是同一口味堆量"],
+        ],
+    },
+    "findings": [
+        {"id": "f-a", "conclusion": "「软塌」的根因是工艺,不是口味",
+         "attitude": "在 214 条相关原声里,提到口感的多于提到味道的。把预算投在调味迭代上,解决不了复购问题——这是产品适配章节要回答的第一件事。", "evidence": "c-texture"},
+        {"id": "f-b", "conclusion": "地域风味是空档,不是「再加一个辣」",
+         "attitude": "川味的麻、西北油泼的香、东北酸菜的酸,在搜索端都有人找,在货架上都没有。缺的不是辣度,是地方性。", "evidence": "c-sauce"},
+        {"id": "f-c", "conclusion": "露营不是「没人买」,是「多一步就不买」",
+         "attitude": "户外用户的支付意愿不低,但对步骤极其敏感:倒面汤这一步就会损失一半潜在用户。包装上的每一个动作,都要算成本。", "evidence": "c-portable"},
+    ],
+    "funnel": [
+        {"stage": "S1", "name": "品类池", "value": 46, "unit": "个细分品类", "scope": "拌面 / 泡面 / 鲜面 / 自热 / 冻干 / 螺蛳粉", "note": "先把品类地图铺开,确定竞争发生哪条线上。"},
+        {"stage": "S2", "name": "目标人群", "value": 2.7, "unit": "亿人", "scope": "18–35 岁,每周至少吃一次速食面条的城市人群", "note": "用 VOC 去掉两端:只吃外卖的人、只吃现煮鲜面的人。"},
+        {"stage": "S3", "name": "细分市场", "value": 3000, "unit": "万人", "scope": "把速食面当一顿正餐而不是垫一口的人", "note": "「不将就」的需求集中在这一格。"},
+        {"stage": "S4", "name": "可切入场景", "value": 900, "unit": "万人", "scope": "加班 / 宿舍 / 露营 三类没有锅的场景", "note": "场景越多,包装与口味越难统一;先做最大的一块。"},
+        {"stage": "S5", "name": "首推机会", "value": 180, "unit": "万人", "scope": "「不需要锅也能吃到锅气」的即食鲜拌面", "note": "把「锅气」做成一句能记住、能复述的话。"},
+    ],
+}
+
+
+def call(method, path, body=None):
+    data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
+    req = urllib.request.Request(BASE + path, data=data, headers=H, method=method)
+    with urllib.request.urlopen(req, timeout=30) as r:
+        return json.loads(r.read().decode() or "{}")
+
+
+def expand(report):
+    """每章:原文 ×2 + 追加一节"长论证";结论块扩到 3 条;表格换成更细的分层表。
+    (示例内容本身是短篇骨架,这里只是按真实报告的篇幅把它撑长,用于压测。)"""
+    for ch in report.get("chapters", []):
+        # 每章追加 2 节长论证,让整篇达到真实报告的万字级篇幅(约 2 万字)
+        secs = ch.setdefault("sections", [])
+        for k in range(1, 3):
+            parts = []
+            for j in range(4):
+                parts.append(EXPAND["para"])
+                parts.append(f"回到第 {j + 1} 个观察点:{EXPAND['table']['cols'][(j + k) % 5]}。"
+                             "这不是某一款产品的问题,而是整个品类在没有锅的场景里共同面对的约束;"
+                             "谁先把这条约束变成产品力,谁就拿到这个场景的解释权。")
+            secs.append({"id": f"{ch['id']}-long{k}", "no": f"{ch.get('no')}.{k + 3}",
+                         "title": f"长论证 {k} · 把问题讲透", "kind": "longpara", "text": "\n\n".join(parts)})
+        secs = ch.get("sections", [])
+        extra = []
+        for s in secs:
+            k = s.get("kind")
+            if k == "para":
+                s["text"] = s["text"] + EXPAND["para"]
+                extra.append({"id": s["id"] + "-x", "no": (s.get("no") or "") + ".1", "title": "细读 · 正反两面",
+                              "kind": "two", "data": {
+                                  "can": ["有稳定的高频场景(夜宵 / 宿舍 / 办公)", "我方产线与调味能力可覆盖", "渠道能触达即食购买点"],
+                                  "cannot": ["无法在两周内改变面饼工艺", "不能宣称疗效或替代正餐营养", "价格不能与外卖正面竞争"]}})
+            elif k == "table":
+                s["title"] = s["title"] + " · 分层明细"
+                s["data"] = EXPAND["table"]
+            elif k == "findings":
+                s["data"] = EXPAND["findings"]
+            elif k == "funnel":
+                s["data"] = EXPAND["funnel"]
+        secs.extend(extra)
+        ch["lead"] = ch.get("lead", "") + EXPAND["para"][:120]
+    return report
+
+
+def main():
+    if not MK and not DRY:
+        sys.exit("PARSE_MASTER_KEY 未设置")
+    rows = call("GET", "/classes/KfArtifact?limit=1000&order=createdAt")["results"]
+    target = next((a for a in rows if a.get("title") == TARGET), None)
+    if not target or not target.get("report"):
+        sys.exit("先运行 tools/seed_report.py")
+    rep = expand(target["report"])
+    body = json.dumps(rep, ensure_ascii=False)
+    if DRY:
+        print(json.dumps({"bytes": len(body.encode())}, ensure_ascii=False))
+        return
+    call("PUT", f"/classes/KfArtifact/{target['objectId']}", {"report": rep})
+    print("long report written:", target["objectId"], "| bytes", len(body.encode()))
+
+
+if __name__ == "__main__":
+    main()

+ 489 - 0
legacy/tools/seed_opportunities.py

@@ -0,0 +1,489 @@
+"""seed_opportunities.py —— 造机会卡示例数据(KfOpportunity)+ 上传元数据 + 一条"卡住"的现场数据。
+
+为什么单独一个类:机会卡有独立生命周期(评分、状态、决策记录、优先级),跟"成果"不是一回事。
+理由见 README「为什么新建 KfOpportunity」。
+
+可重复执行(幂等):
+  - 每张卡带 seedKey;已存在同 seedKey 的卡:只补缺失的上传元数据 / 关联成果 / 历史评分,不覆盖人工改过的 status/priority/decision;
+  - 评分历史 [seed:xxx] 按 seedKey 去重,不会越跑越多;
+  - 现场演示用的卡住数据同样按 seedKey 幂等。
+
+  PARSE_MASTER_KEY=... python3 tools/seed_opportunities.py             # 写入
+  PARSE_MASTER_KEY=... python3 tools/seed_opportunities.py --dry-run   # 只看要写什么
+  PARSE_MASTER_KEY=... python3 tools/seed_opportunities.py --reset-scores  # 把示例评分恢复到初始值
+
+凭据只从环境变量读,不落盘。
+"""
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+from datetime import datetime, timedelta, timezone
+
+BASE = "https://kangshifu.fmode.cn/parse"
+APP_ID = "FKieRbIoGt_main"
+MK = os.environ.get("PARSE_MASTER_KEY", "")
+DRY = "--dry-run" in sys.argv
+RESET_SCORES = "--reset-scores" in sys.argv
+
+H = {"X-Parse-Application-Id": APP_ID, "Content-Type": "application/json"}
+if MK:
+    H["X-Parse-Master-Key"] = MK
+
+NOW = datetime.now(timezone.utc)
+
+
+def iso(dt):
+    return dt.strftime("%Y-%m-%dT%H:%M:%S.") + "%03dZ" % (dt.microsecond // 1000)
+
+
+def hours_ago(h):
+    return iso(NOW - timedelta(hours=h))
+
+
+def call(method, path, body=None):
+    data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
+    req = urllib.request.Request(BASE + path, data=data, headers=H, method=method)
+    try:
+        with urllib.request.urlopen(req, timeout=30) as r:
+            return json.loads(r.read().decode() or "{}")
+    except urllib.error.HTTPError as e:
+        return {"_error": e.code, "_body": e.read().decode()[:400]}
+
+
+def fetch(cls):
+    return call("GET", f"/classes/{cls}?limit=1000&order=createdAt").get("results", [])
+
+
+# ---------------------------------------------------------------- 上传元数据
+# 所有"被上传的东西"都带这五个字段;老数据补齐 uploadedByKey 的值由这里统一推导。
+UPLOAD_METHODS = {"session": "会话上传", "skill": "Skill 上传", "manual": "手工录入"}
+
+
+def upload_meta(person, key, agent_name, agent_id, method, at, session=None):
+    m = {
+        "uploadedBy": person,
+        "uploadedByKey": key,
+        "agentName": agent_name,
+        "agentId": agent_id,
+        "uploadMethod": method,
+        "uploadedAt": at,
+    }
+    if session:
+        m["sourceSession"] = session
+    return m
+
+
+MAIN = ("康师傅产品创新助手", "agent-main-01")     # 主分身:学员在会话里把想法交给它
+RESEARCH = ("品类调研 Agent", "agent-research-02")  # 调研 Agent:自动抓 VOC / 竞品
+CHECK = ("机会评估 Agent", "agent-check-03")        # 评估 Agent:按评分模型打分
+DESIGN = ("概念设计 Agent", "agent-design-04")      # 设计 Agent:把机会变成方案
+
+# ---------------------------------------------------------------- 机会卡
+# 每张卡都挂到库里真实存在的成果(KfArtifact / KfProject),保证"点开看报告/证据"有东西。
+# scores:[评分人, 分数, 评语, 几小时前, seedKey]
+CARDS = [
+    {
+        "seedKey": "seed-opp-1",
+        "title": "夜宵场景低卡高蛋白拌面",
+        "oneLiner": "加班到十点想吃饱又怕胖:低卡高蛋白 + 1 分钟即食,5 个夜宵场景里 3 个需求未被满足",
+        "stage": "S3 机会识别",
+        "category": "拌面",
+        "priority": "S",
+        "status": "candidate",
+        "evidenceLevel": "high",
+        "quoteCount": 12,
+        "citeCount": 8,
+        "upload": upload_meta("张伟", "sess-01", *MAIN, "session", hours_ago(6), session="sess-01"),
+        "linkedProject": "QFvrQsUPyD",
+        "linkedArtifacts": ["KHEScCO5dT", "5icUD1vUVN", "BjBza4kr2U"],
+        "decision": {
+            "type": "concept", "label": "进入概念验证",
+            "reason": "S3 阶段 5 位成员独立提到夜宵场景的「饱腹 + 低卡」矛盾;已有 12 条带原文的 VOC 支撑。建议 S5 做一次概念测试。",
+            "by": "张伟", "at": hours_ago(4),
+        },
+        "scores": [
+            ("李娜", 86, "场景真实,VOC 密度高,可以做", 9),
+            ("吴敏", 90, "和现有夜宵 SKU 不冲突,值得立项", 7),
+            ("周涛", 84, "高蛋白卖点需要工艺支持,先验证", 5),
+            ("评价 Agent", 88, "人群规模 × 需求强度 × 可行性 综合 88", 3),
+        ],
+    },
+    {
+        "seedKey": "seed-opp-2",
+        "title": "办公室 3 分钟速食拌面",
+        "oneLiner": "午休只有 10 分钟、不想吃汤汤水水:免煮、不冒汤、可带去办公室的拌面",
+        "stage": "S4 验证评估",
+        "category": "拌面",
+        "priority": "A",
+        "status": "approved",
+        "evidenceLevel": "high",
+        "quoteCount": 9,
+        "citeCount": 5,
+        "upload": upload_meta("李娜", "sess-02", *MAIN, "session", hours_ago(30), session="sess-02"),
+        "linkedProject": "PzNTNt7ttt",
+        "linkedArtifacts": ["BjBza4kr2U", "yGwGJ9aPu6"],
+        "decision": {
+            "type": "approve", "label": "立项",
+            "reason": "S4 验证完成:三家竞品均无免汤方案,目标人群 2.7 亿中约 18% 处于该场景;评分模型 82 分,过线。",
+            "by": "刘洋", "at": hours_ago(20),
+        },
+        "scores": [
+            ("刘洋", 84, "验证数据齐,建议立项", 26),
+            ("王芳", 80, "价格带要卡住 8 元", 22),
+            ("评价 Agent", 82, "需求 85 / 竞争 78 / 可行性 83", 20),
+        ],
+    },
+    {
+        "seedKey": "seed-opp-3",
+        "title": "地域风味麻酱拌面",
+        "oneLiner": "现有拌面酱包集中在麻酱 / 香辣两类;西北油泼、西南麻辣是搜索热但 SKU 空档",
+        "stage": "S4 验证评估",
+        "category": "拌面",
+        "priority": "A",
+        "status": "candidate",
+        "evidenceLevel": "medium",
+        "quoteCount": 6,
+        "citeCount": 3,
+        "upload": upload_meta("刘洋", "sess-04", *RESEARCH, "skill", hours_ago(52)),
+        "linkedProject": "",
+        "linkedArtifacts": ["QVrfdasAkg", "C6AFWrqyTQ"],
+        "decision": {
+            "type": "hold", "label": "暂缓(补原文)",
+            "reason": "方向认可,但支撑它的 6 条记录里只有 3 条带原文,销量数据待调研 Agent 回传。补齐前不排产。",
+            "by": "赵磊", "at": hours_ago(40),
+        },
+        "scores": [
+            ("赵磊", 72, "方向对,证据不够", 45),
+            ("陈静", 78, "和夜宵低卡可以合并做双口味", 38),
+            ("评价 Agent", 75, "证据强度拖后腿", 36),
+        ],
+    },
+    {
+        "seedKey": "seed-opp-4",
+        "title": "露营便携免煮拌面",
+        "oneLiner": "露营带的都是泡面,想吃拌面又得煮:冷水泡 8 分钟、自带拌酱的免煮方案",
+        "stage": "S5 场景与卖点设计",
+        "category": "杯面",
+        "priority": "S",
+        "status": "candidate",
+        "evidenceLevel": "medium",
+        "quoteCount": 7,
+        "citeCount": 4,
+        "upload": upload_meta("陈静", "sess-05", *DESIGN, "skill", hours_ago(2)),
+        "linkedProject": "",
+        "linkedArtifacts": ["mdJAkc5u6X", "w5DDt2Qek4"],
+        "decision": {
+            "type": "concept", "label": "进入概念验证",
+            "reason": "露营搜索量 2026 春夏环比 +41%;已有方案具备卖点抓手(「不烧水也能拌」)。等 S4 评分补齐。",
+            "by": "陈静", "at": hours_ago(1.5),
+        },
+        "scores": [
+            ("孙悦", 79, "场景有意思,量可能不大", 8),
+            ("郑凯", 81, "卖点很清晰,可以做包装测试", 2),
+        ],
+    },
+    {
+        "seedKey": "seed-opp-5",
+        "title": "健身人群高蛋白面",
+        "oneLiner": "健身人群把速食面当「欺骗餐」,要的是每份 20g 以上蛋白、低脂、能算进宏量",
+        "stage": "S2 用户需求与痛点",
+        "category": "汤面",
+        "priority": "B",
+        "status": "rejected",
+        "evidenceLevel": "low",
+        "quoteCount": 2,
+        "citeCount": 1,
+        "upload": upload_meta("孙悦", "sess-07", *MAIN, "session", hours_ago(76), session="sess-07"),
+        "linkedProject": "",
+        "linkedArtifacts": ["0TufH1peEG", "rSqN03GPux", "WrWlJ1lSHC"],
+        "decision": {
+            "type": "reject", "label": "否决",
+            "reason": "三条支撑记录里只有 2 条原文,且蛋白成本会把单价推到 15 元以上,和现有价格带冲突。留档,不做。",
+            "by": "吴敏", "at": hours_ago(60),
+        },
+        "scores": [
+            ("吴敏", 58, "成本算不过来", 70),
+            ("周涛", 62, "需求真实但规模小", 66),
+            ("评价 Agent", 55, "人群规模 ★★,可行性 ★★", 62),
+        ],
+    },
+    {
+        "seedKey": "seed-opp-6",
+        "title": "银发低钠汤面",
+        "oneLiner": "50 岁以上人群控盐诉求明确,现有方便面钠含量普遍 2000mg 以上,无低钠 SKU",
+        "stage": "S1 品类市场分析",
+        "category": "汤面",
+        "priority": "B",
+        "status": "candidate",
+        "evidenceLevel": "medium",
+        "quoteCount": 4,
+        "citeCount": 2,
+        "upload": upload_meta("赵磊", "sess-06", *MAIN, "manual", hours_ago(50)),
+        "linkedProject": "",
+        "linkedArtifacts": ["Lor4psRme0", "rvyuQ3985Q"],
+        "decision": None,
+        "scores": [
+            ("赵磊", 70, "合规是卖点,工艺要改", 48),
+        ],
+    },
+    {
+        "seedKey": "seed-opp-7",
+        "title": "夏日清爽凉拌面",
+        "oneLiner": "夏季 6–8 月拌面搜索 +35%,但现有产品多为「热拌」,冷拌 / 冰镇吃法没有对应 SKU",
+        "stage": "S3 机会识别",
+        "category": "拌面",
+        "priority": "A",
+        "status": "candidate",
+        "evidenceLevel": "medium",
+        "quoteCount": 5,
+        "citeCount": 3,
+        "upload": upload_meta("周涛", "sess-08", *RESEARCH, "skill", hours_ago(0.4)),
+        "linkedProject": "",
+        "linkedArtifacts": ["9Mj4LV3a3P", "czLP44qeOl", "W42NYTiJFm"],
+        "decision": {
+            "type": "hold", "label": "指派评估",
+            "reason": "季节性明确,需要 S4 用评分模型验证「冷藏口感」这一工艺风险。",
+            "by": "吴敏", "at": hours_ago(0.2),
+        },
+        "scores": [
+            ("周涛", 76, "季节性太强,看全年怎么讲", 0.3),
+        ],
+    },
+    {
+        "seedKey": "seed-opp-8",
+        "title": "学生党实惠大份拌面",
+        "oneLiner": "宿舍场景吃一碗不饱:同价位加量 30%,以「三包面饼」作为卖点",
+        "stage": "S3 机会识别",
+        "category": "炒面",
+        "priority": "S",
+        "status": "candidate",
+        "evidenceLevel": "medium",
+        "quoteCount": 5,
+        "citeCount": 2,
+        "upload": upload_meta("王芳", "sess-03", *MAIN, "session", hours_ago(28), session="sess-03"),
+        "linkedProject": "gC9Ra0bnXf",
+        "linkedArtifacts": ["OG7w0dxbja", "HmZG9wFEq7"],
+        "decision": {
+            "type": "concept", "label": "进入概念验证",
+            "reason": "「吃不饱」在差评里频次高于「难吃」;加量 + 不涨价可行性待工艺评估。",
+            "by": "王芳", "at": hours_ago(24),
+        },
+        "scores": [
+            ("王芳", 91, "最接近真实痛点的一张卡", 26),
+            ("陈静", 88, "可以和学生套餐组合", 24),
+            ("评价 Agent", 89, "需求 92 / 竞争 80 / 可行性 88", 22),
+        ],
+    },
+    # 早前用 skill 上传、后被判重复的一张卡:保留它,池子里才有"否决"的真实样本
+    {
+        "seedKey": "seed-opp-9",
+        "title": "夜宵低卡拌面(与 seed-opp-1 重复)",
+        "oneLiner": "陈静在 S3 独立上传的机会卡,内容与张伟的夜宵低卡高蛋白重合",
+        "stage": "S3 机会识别",
+        "category": "拌面",
+        "priority": "B",
+        "status": "rejected",
+        "evidenceLevel": "low",
+        "quoteCount": 0,
+        "citeCount": 0,
+        "upload": upload_meta("陈静", "sess-05", *MAIN, "skill", hours_ago(68)),
+        "linkedProject": "",
+        "linkedArtifacts": ["C6AFWrqyTQ"],
+        "decision": {
+            "type": "merge", "label": "否决(并入 seed-opp-1)",
+            "reason": "与张伟的夜宵低卡高蛋白拌面重复,评分并入原卡,本卡留档。",
+            "by": "吴敏", "at": hours_ago(64),
+        },
+        "scores": [],
+    },
+]
+
+
+# ---------------------------------------------------------------- 现场演示:一个卡住的人
+# 现场看板要能列出"谁卡住了":一条有时长但在 S4 停了 30 小时、没有任何成果的会话。
+STUCK_SESSION = {
+    "seedKey": "seed-stuck-session",
+    "className": "KfSession",
+    "fields": {
+        "studentName": "何雨",
+        "groupId": "G3",
+        "stage": "S4验证评估",
+        "step": "1/3",
+        "msgCount": 13,
+        "lastActiveAt": {"__type": "Date", "iso": hours_ago(30)},
+    },
+}
+
+CARD_FIELDS_SCHEMA = {
+    "title": {"type": "String"},
+    "oneLiner": {"type": "String"},
+    "stage": {"type": "String"},
+    "category": {"type": "String"},
+    "priority": {"type": "String"},
+    "status": {"type": "String"},
+    "evidenceLevel": {"type": "String"},
+    "quoteCount": {"type": "Number"},
+    "citeCount": {"type": "Number"},
+    "uploadedBy": {"type": "String"},
+    "uploadedByKey": {"type": "String"},
+    "agentName": {"type": "String"},
+    "agentId": {"type": "String"},
+    "uploadMethod": {"type": "String"},
+    "uploadedAt": {"type": "Date"},
+    "sourceSession": {"type": "String"},
+    "linkedProject": {"type": "String"},
+    "linkedArtifacts": {"type": "Array"},
+    "decision": {"type": "Object"},
+    "scores": {"type": "Array"},
+    "decisionLog": {"type": "Array"},
+    "seedKey": {"type": "String"},
+    "searchText": {"type": "String"},
+}
+
+UPLOAD_FIELDS_SCHEMA = {
+    "uploadedByKey": {"type": "String"},
+    "agentName": {"type": "String"},
+    "agentId": {"type": "String"},
+    "uploadMethod": {"type": "String"},
+    "uploadedAt": {"type": "Date"},
+}
+
+
+def seed_scores(card):
+    out = []
+    for person, score, comment, hours in card["scores"]:
+        out.append({
+            "user": person,
+            "score": score,
+            "comment": comment,
+            "at": {"__type": "Date", "iso": hours_ago(hours)},
+            "seed": card["seedKey"],
+        })
+    return out
+
+
+def search_text(d):
+    return " ".join(str(d.get(k) or "") for k in ("title", "oneLiner", "category", "stage", "priority", "uploadedBy", "agentName")).lower()
+
+
+def main():
+    if not MK:
+        sys.exit("PARSE_MASTER_KEY 未设置")
+    if "results" not in call("GET", "/classes/KfProject?limit=1"):
+        sys.exit("masterKey 验证失败")
+
+    # 1) schema
+    #    KfOpportunity 首次运行时按 CARD_FIELDS_SCHEMA 一次建全(Parse 拒绝重复改已有字段,
+    #    所以已存在就跳过);KfArtifact / KfProject 只补上传元数据字段。
+    has_opp = bool(call("GET", "/schemas/KfOpportunity").get("fields"))
+    plan = [] if has_opp else [("KfOpportunity", CARD_FIELDS_SCHEMA)]
+    plan += [("KfArtifact", UPLOAD_FIELDS_SCHEMA), ("KfProject", UPLOAD_FIELDS_SCHEMA)]
+    for cls, fields in plan:
+        if DRY:
+            print("[dry] schema", cls, list(fields))
+            continue
+        r = call("PUT", f"/schemas/{cls}", {"className": cls, "fields": fields})
+        print("schema", cls, "ok" if "fields" in r else r)
+
+    # 2) 机会卡:按 seedKey 幂等
+    existing = {c.get("seedKey"): c for c in fetch("KfOpportunity") if c.get("seedKey")}
+    artifacts = {a["objectId"]: a for a in fetch("KfArtifact")}
+    for card in CARDS:
+        body = {
+            "title": card["title"], "oneLiner": card["oneLiner"], "stage": card["stage"],
+            "category": card["category"], "priority": card["priority"], "status": card["status"],
+            "evidenceLevel": card["evidenceLevel"], "quoteCount": card["quoteCount"],
+            "citeCount": card["citeCount"], "seedKey": card["seedKey"],
+            "linkedProject": card.get("linkedProject") or "",
+            "linkedArtifacts": [i for i in card["linkedArtifacts"] if i in artifacts],
+        }
+        body.update(card["upload"])
+        body["uploadedAt"] = {"__type": "Date", "iso": card["upload"]["uploadedAt"]}
+        body["searchText"] = search_text({**card, **card["upload"]})
+        dec = dict(card["decision"], at={"__type": "Date", "iso": card["decision"]["at"]}) if card["decision"] else None
+        if dec:
+            body["decision"] = dec
+        body["decisionLog"] = [dec] if dec else []
+
+        cur = existing.get(card["seedKey"])
+        if not cur:
+            body["scores"] = seed_scores(card)
+            if DRY:
+                print("[dry] create", card["title"], [s[0] for s in card["scores"]])
+                continue
+            r = call("POST", "/classes/KfOpportunity", body)
+            print("create", card["title"], "ok" if "objectId" in r else r)
+            continue
+
+        # 已存在:只补缺失的字段;不覆盖人工改过的 status / priority / decision
+        patch = {}
+        for k in ("uploadedBy", "uploadedByKey", "agentName", "agentId", "uploadMethod", "uploadedAt",
+                  "linkedProject", "linkedArtifacts", "evidenceLevel", "quoteCount", "citeCount", "oneLiner"):
+            if not cur.get(k) and body.get(k):
+                patch[k] = body[k]
+        if not cur.get("decision") and dec:
+            patch["decision"] = dec
+            patch["decisionLog"] = [dec]
+        live_scores = cur.get("scores") or []
+        have = {(s.get("user"), s.get("seed")) for s in live_scores}
+        if RESET_SCORES:
+            live_scores = [s for s in live_scores if not s.get("seed")]
+        missing = [s for s in seed_scores(card) if (s["user"], s["seed"]) not in have]
+        if len(live_scores) != len(cur.get("scores") or []) or missing:
+            patch["scores"] = live_scores + missing
+        if patch:
+            if DRY:
+                print("[dry] patch", card["title"], list(patch))
+                continue
+            r = call("PUT", f"/classes/KfOpportunity/{cur['objectId']}", patch)
+            print("patch", card["title"], list(patch), "ok" if "updatedAt" in r else r)
+        else:
+            print("keep ", card["title"], "(已存在且完整)")
+
+    # 3) 老成果 / 项目补齐上传元数据(只补缺的)
+    owner_method = {}
+    for card in CARDS:
+        owner_method[card["upload"]["uploadedBy"]] = (card["upload"], card["upload"]["uploadedByKey"])
+    for cls in ("KfArtifact", "KfProject"):
+        rows = fetch(cls)
+        for row in rows:
+            patch = {}
+            if not row.get("uploadedByKey"):
+                patch["uploadedByKey"] = row.get("ownerSession") or row.get("ownerName") or ""
+            if not row.get("agentName"):
+                patch["agentName"] = MAIN[0]
+                patch["agentId"] = MAIN[1]
+            if not row.get("uploadMethod"):
+                patch["uploadMethod"] = "skill"  # 现有 26 条成果全部是 jarvis skill 写入
+            if not row.get("uploadedAt") and row.get("createdAt"):
+                patch["uploadedAt"] = {"__type": "Date", "iso": row.get("createdAt")}
+            if not patch:
+                continue
+            if DRY:
+                continue
+            r = call("PUT", f"/classes/{cls}/{row['objectId']}", patch)
+            if "updatedAt" not in r:
+                print("ERR upload-meta", cls, row["objectId"], r)
+        print(f"upload-meta {cls}: {len(rows)} 行已检查")
+
+    # 4) 现场演示:卡住的人
+    s = STUCK_SESSION
+    have = [x for x in fetch(s["className"]) if x.get("seedKey") == s["seedKey"]]
+    if have:
+        print("keep  stuck-session", have[0]["objectId"])
+    elif DRY:
+        print("[dry] create stuck-session", s["fields"]["studentName"])
+    else:
+        r = call("POST", f"/classes/{s['className']}", {**s["fields"], "seedKey": s["seedKey"]})
+        ok = "objectId" in r
+        print("create stuck-session", "ok" if ok else r)
+        if not ok:
+            # KfSession 没有 seedKey 字段时,退回用姓名查找
+            found = [x for x in fetch("KfSession") if x.get("studentName") == s["fields"]["studentName"]]
+            print("  fallback find:", len(found))
+
+
+if __name__ == "__main__":
+    main()

+ 395 - 0
legacy/tools/seed_report.py

@@ -0,0 +1,395 @@
+"""seed_report.py —— 给一条成果写入一份完整的示例报告(康师傅拌面 · 按江中猴菇饮骨架)。
+
+报告存在 KfArtifact.report(结构化 JSON),看板 js/views/report.js 负责渲染。
+本脚本可重复执行:每次覆盖同一条成果的 report 字段,不改动其他人的数据。
+
+  python3 tools/seed_report.py            # 写入(需要 PARSE_MASTER_KEY)
+  python3 tools/seed_report.py --dry-run  # 只打印摘要,不写库
+
+结构(六章对应五个阶段,见 README「报告数据结构」):
+  cover 封面 → chapters[ch1..ch6](每章 lead + 若干 section)+ evidence 证据链
+  evidence[conclusionId] = { claim, ownerCount, quotes[{artId, author, text, ref}], derivedFrom }
+  derivedFrom 引用现存 KfArtifact 的 objectId:结论旁点「证据链」能看到是哪几条成果撑起来的。
+  看板会把结论挂到它引用的成果上:在列表里点开那些成果,能看到「支撑了哪条结论」。
+
+字段一律是数据,不是 HTML:渲染端负责排版与转义,避免把一段 HTML 塞进库里。
+中文正文里的引用一律用「」,避免和外层字符串引号打架。
+"""
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+
+BASE = "https://kangshifu.fmode.cn/parse"
+APP_ID = "FKieRbIoGt_main"
+MK = os.environ.get("PARSE_MASTER_KEY", "")
+DRY = "--dry-run" in sys.argv
+H = {"X-Parse-Application-Id": APP_ID, "Content-Type": "application/json"}
+if MK:
+    H["X-Parse-Master-Key"] = MK
+
+# 选哪条成果承载示例报告:张伟 S1 的品类竞品报告(已有 project / ownerSession 出身)
+TARGET_TITLE_KEYWORD = "夜宵场景低卡高蛋白拌面 - competitor_report"
+
+
+def call(method, path, body=None):
+    data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
+    req = urllib.request.Request(BASE + path, data=data, headers=H, method=method)
+    with urllib.request.urlopen(req, timeout=30) as r:
+        return json.loads(r.read().decode() or "{}")
+
+
+def fetch(cls):
+    return call("GET", f"/classes/{cls}?limit=1000&order=createdAt").get("results", [])
+
+
+def q(art_id, author, text, ref, likes=None):
+    o = {"artId": art_id, "author": author, "text": text, "ref": ref}
+    if likes is not None:
+        o["likes"] = likes
+    return o
+
+
+def build(ids):
+    """ids: 把示例报告里用到的结论挂到真实成果 objectId 上(缺失为 None,看板显示「证据未接入」)。"""
+    E = {
+        "c-texture": {
+            "claim": "「太软没嚼劲」是拌面复购的第一杀手,且被不同成员独立提到",
+            "ownerCount": 4,
+            "derivedFrom": [ids.get("zw_pain"), ids.get("ln_pain"), ids.get("wf_opp")],
+            "quotes": [
+                q(ids.get("zw_pain"), "夜宵党_312", "泡完就坨,没有一碗速食拌面该有的嚼劲,第二次就不会买了", "抖音评论", 412),
+                q(ids.get("ln_pain"), "上班族_077", "面饼太软,拌酱以后像糊糊,想要那种手擀面的筋道", "小红书笔记", 238),
+                q(ids.get("wf_opp"), "学生党_156", "宿舍没有锅,只能泡,泡出来的口感比煮的差一截", "B站评论", 96),
+                q(None, "口味控_044", "康师傅的拌面面饼偏软,拌的时候一直断", "电商差评", 0),
+            ],
+        },
+        "c-sauce": {
+            "claim": "酱包集中在「麻酱 / 香辣」两类,地域风味是空档",
+            "ownerCount": 3,
+            "derivedFrom": [ids.get("jn_pain"), ids.get("wf_pain"), ids.get("hm_pain")],
+            "quotes": [
+                q(ids.get("jn_pain"), "地域党_208", "想吃川味,但是只有辣——麻香和香辣是两种东西", "小红书笔记", 166),
+                q(None, "江浙沪_021", "拌面全是麻酱和辣,清淡口味的没得选", "电商评论", 0),
+            ],
+        },
+        "c-price": {
+            "claim": "学生与加班人群对份量敏感,「吃不饱」比「难吃」更容易被差评",
+            "ownerCount": 2,
+            "derivedFrom": [ids.get("wf_opp"), ids.get("ln_pain")],
+            "quotes": [
+                q(None, "大胃王_553", "一包根本吃不饱,加一包又要再冲一次,很麻烦", "电商评论", 0),
+                q(None, "加班党_119", "十块钱的拌面,吃完还是很饿,不如点外卖", "抖音评论", 302),
+            ],
+        },
+        "c-portable": {
+            "claim": "露营 / 宿舍场景的核心矛盾是「没有锅也要有锅气」",
+            "ownerCount": 2,
+            "derivedFrom": [ids.get("cj_opp"), ids.get("cj_pain")],
+            "quotes": [
+                q(None, "露营_031", "露营带的都是泡面,想要拌面,但得煮,太麻烦", "小红书笔记", 521),
+                q(None, "户外_882", "户外最简单就是开水,拌面要倒掉面汤,步骤多一步就不想买", "抖音评论", 143),
+            ],
+        },
+        "c-noodle": {
+            "claim": "地域风味(西北 / 西南 / 东北)在年轻人里的搜索热度高于现有 SKU 覆盖",
+            "ownerCount": 3,
+            "derivedFrom": [ids.get("zw_report"), ids.get("ly_opp"), ids.get("hm_pain")],
+            "quotes": [
+                q(None, "风味_1", "重庆小面的麻辣鲜香,速食里基本找不到", "小红书笔记", 389),
+                q(None, "风味_2", "油泼面那种宽面,速食做不出来", "抖音评论", 274),
+                q(None, "风味_3", "东北的酸菜拌面,速食没有", "微博话题", 0),
+            ],
+        },
+        "c-plain": {
+            "claim": "「不将就」的日常一餐是最大增量,但用户不愿意为此多花超过 3 元",
+            "ownerCount": 2,
+            "derivedFrom": [ids.get("ln_report"), ids.get("hm_opp")],
+            "quotes": [
+                q(None, "白领_267", "加班到十点,想吃口热的,又不想吃泡面的汤汤水水", "小红书笔记", 198),
+                q(None, "白领_341", "更愿意为口感多付,但超过十五块就点外卖了", "B站评论", 87),
+            ],
+        },
+        # 反面证据:一条被标成记录层的重复/推演成果,拿它说明「我们排除了什么」
+        "c-weak": {
+            "claim": "「老年人更爱汤面」这类推演,没有原文支撑就不能作为立项依据",
+            "ownerCount": 1,
+            "derivedFrom": [ids.get("zt_record")],
+            "quotes": [],
+        },
+    }
+
+    funnel = [
+        {"stage": "S1", "name": "品类池", "value": 46, "unit": "", "scope": "国内速食面品类(拌面 / 泡面 / 鲜面 / 自热)",
+         "note": "先把品类地图铺开,确定我们在哪条线上竞争。"},
+        {"stage": "S2", "name": "目标人群", "value": 2.7, "unit": "亿", "scope": "18–35 岁、每周至少吃一次速食面条的一线到三线城市人群",
+         "note": "用 VOC 原声过滤掉只吃外卖和只吃高端鲜面两端。"},
+        {"stage": "S3", "name": "拌面细分市场", "value": 3000, "unit": "万人", "scope": "愿意把速食面当一顿正餐而不是垫一口的人",
+         "note": "S3 机会识别:这里才是「不将就」需求的集中区。"},
+        {"stage": "S4", "name": "可切入场景", "value": 900, "unit": "万人", "scope": "加班 / 宿舍 / 露营 三类「没有锅」的场景",
+         "note": "S4 验证:三个场景的用户结构、付费意愿与竞品密度分别打分。"},
+        {"stage": "S5", "name": "首推机会", "value": 180, "unit": "万人", "scope": "「不需要锅也能吃到锅气」的即食鲜拌面",
+         "note": "S5 卖点设计:把「锅气」做成一句能记住的话。"},
+    ]
+
+    xray = {
+        "cols": ["场景", "用户此刻的选择", "未满足需求(VOC 原声)", "VOC 条数", "机会"],
+        "rows": [
+            ["加班夜宵", [["泡面(汤面)", 61], ["外卖拌饭", 24], ["自热锅", 9]],
+             ["面饼泡后偏软、没有嚼劲", "汤多、吃完撑但不满足"], 486, "高", "c-texture"],
+            ["宿舍速食", [["泡面", 55], ["干拌面(麻酱/香辣)", 33]],
+             ["只有麻酱和辣两种口味", "一包吃不饱、加一包又麻烦"], 372, "高", "c-sauce"],
+            ["办公室午餐", [["便利店盒饭", 40], ["外卖", 38], ["速食拌面", 12]],
+             ["拌好以后坨掉、没法边工作边吃", "等热水 3 分钟也被嫌慢"], 258, "中", "c-plain"],
+            ["露营 / 户外", [["泡面", 48], ["干粮", 30]],
+             ["没有锅也能有锅气", "拌面得倒面汤,多一步就不买"], 193, "中", "c-portable"],
+            ["家庭囤货", [["大包装泡面", 70], ["挂面", 18]],
+             ["一家人吃时口味不够分", "只看价格、不看口感"], 311, "低", "c-price"],
+        ],
+    }
+
+    fit = {
+        "can": [
+            "面饼工艺:非油炸 + 蒸煮线的复合工艺已经是现有产线能力",
+            "酱包研发:川、湘、西北三地的调味样已经在研发中心储备",
+            "渠道:铺到 KA 与便利店,即食场景天然可达",
+            "品牌:在速食面心智里有默认信任,降低尝试成本",
+        ],
+        "cannot": [
+            "对标现煮手擀面的筋道——速食工艺做不到,宣称「手擀」是虚假宣传",
+            "做成「不用水」的常温即食鲜面——目前保鲜与货架期都不支持",
+            "承诺「吃饱」——份量上去之后成本与价格带冲突,不能两头都要",
+        ],
+        "transferCols": ["成功要素", "来源", "能否迁移到拌面", "怎么落地"],
+        "transfer": [
+            ["固定场景", "泡面(加班夜宵 / 宿舍)", "✅ 可迁移", "把「夜宵」写进包装正面,做 21:00–1:00 的场景标签"],
+            ["大口味心智", "红烧牛肉面", "✅ 可迁移", "用「红烧」做第一支口味的锚点,降低认知成本"],
+            ["低价尝鲜", "袋装泡面 2.5 元", "⚠️ 部分迁移", "只做小规格尝鲜装,主推规格定价 8–10 元"],
+            ["煮食依赖", "鲜面 / 挂面", "❌ 不可迁移", "这是速食拌面必须绕开的红线"],
+        ],
+        "redlineCols": ["失败因子", "风险", "在拌面上会怎么发生"],
+        "redline": [
+            ["产品反效果", "极高", "酱太咸、口感坨掉 → 差评集中"],
+            ["宣称与实际体验矛盾", "高", "宣称「锅气」但泡出来是糊的 → 信任崩塌"],
+            ["品类联想错位", "中", "把拌面当零食卖 → 复购低"],
+            ["价格高于外卖替代", "中", "定价超过 15 元 → 用户直接用外卖替代"],
+        ],
+    }
+
+    positioning = {
+        "ladder": [
+            ["#1", "外卖 / 现煮面", "愿意花钱,但要像一顿饭"],
+            ["#2", "瓶装泡面", "便宜、快、默认选择"],
+            ["#3", "干拌面(麻酱 / 香辣)", "偶尔换个口味"],
+            ["#4", "自热锅", "周末 / 露营的重口味方案"],
+            ["#5", "⭐ 空着的位置", "想要不将就,但没有锅"],
+        ],
+        "statement": "不用锅,也能拌出面馆的口感",
+        "for_who": "加班到深夜、宿舍里只有一壶开水、露营车上只有一个保温壶的人",
+        "axis": [
+            ["vs 泡面", "泡着吃是软塌的根源", "我们是拌的,口感由产品本身决定"],
+            ["vs 外卖拌饭", "外卖是一顿饭,但我们更快、更热", "夜宵 3 分钟,比等外卖快"],
+            ["vs 自热锅", "自热锅是重口味的仪式感,我们的场景更日常", "办公室 / 宿舍也能用"],
+        ],
+        "alt": ["B 案:「深夜的面馆,三分钟到桌」", "C 案:「宿舍里的一碗热拌面」(注意:不得使用手擀字样)"],
+    }
+
+    actions = [
+        ["P0-1", "面饼工艺样机", "研发中心", "2 周", "非油炸 + 蒸煮复合工艺,目标「泡 4 分钟后仍有嚼劲」,与现有面饼盲测对比", "P0"],
+        ["P0-2", "第一支口味锁定", "产品部", "2 周", "以红烧为锚,川味麻辣与西北油泼两个备选,各 200 份盲测", "P0"],
+        ["P0-3", "场景包装", "品牌部", "3 周", "包装正面写场景(加班 / 宿舍 / 露营),背面写「不用锅」的冲泡步骤", "P0"],
+        ["P1-1", "便利店夜宵端架测试", "渠道部", "1 个月", "选 50 家 24h 便利店做端架,观察 21 点后的动销", "P1"],
+        ["P1-2", "露营渠道试水", "渠道部", "2 个月", "与户外品牌联名小规格装,验证「多一步就不买」", "P1"],
+        ["P1-3", "地域风味扩展评估", "产品部", "3 个月", "基于 S1 竞品报告的地域风味搜索数据,评估第二支口味", "P1"],
+    ]
+    milestones = [
+        ["M1", "口感验证",
+         ["盲测中「泡后仍有嚼劲」的通过率 ≥ 60%", "与现煮面盲测对比,偏好度 ≥ 40%"],
+         "通过率 < 40% → 回到工艺选择;< 25% → 放弃「不要锅」概念"],
+        ["M2", "场景验证",
+         ["50 家便利店夜宵时段动销率", "露营联合装复购率"],
+         "动销率 < 10% → 调整端架与包装;仍无起色 → 换场景"],
+        ["M3", "复购验证",
+         ["首月复购率 ≥ 15%", "NPS ≥ 30"],
+         "复购率 < 8% → 口感仍是问题;NPS < 20 → 定位偏差"],
+    ]
+    gaps = [
+        ["现有产品的真实口感差距", "只有二手评论", "面饼含水量 / 复水时间参数 + 竞品盲测", "决定工艺路线", "高"],
+        ["露营场景的实际购买路径", "S4 只做了问卷推演", "3 个营地的现场观察", "决定联名规格", "中"],
+        ["外卖价格带的替代阈值", "没有价格敏感度测试", "10 / 12 / 15 元三档价格测试", "决定定价", "高"],
+    ]
+
+    return {
+        "version": 1,
+        "title": "康师傅即食鲜拌面 · 场景与口感机会洞察",
+        "subtitle": "从泡着吃到拌着吃:5 大场景 × 现有产品 × 未满足需求",
+        "method": "VOC 原声提取 × 场景拆解 × 竞品矩阵 × 机会验证评分(S1 品类 → S5 卖点)",
+        "sources": "抖音 186 条视频 / 小红书 402 篇笔记 / 电商评论 1,930 条 / 便利店端架观察 12 家",
+        "pipeline": [{"label": "原始 VOC 采集", "value": 2727}, {"label": "去噪去重", "value": 1190},
+                     {"label": "精准 VOC", "value": 730}],
+        "filterRate": "73%",
+        "generatedAt": "2026-10-29T10:20:00.000Z",
+        "structure": "封面 → 全景漏斗 → 嵌套洞察 → 痛声地图 → 产品适配 → 定位推导 → 行动蓝图",
+        "kpis": [
+            {"value": 2727, "unit": "条", "label": "原始 VOC", "note": "5 个平台、23 个关键词,30 天抓取"},
+            {"value": 730, "unit": "条", "label": "精准 VOC", "note": "过滤率 73%,剔除广告、重复与无场景评论"},
+            {"value": 5, "unit": "个", "label": "核心场景", "note": "加班夜宵 / 宿舍 / 办公室 / 露营 / 家庭囤货"},
+            {"value": 2.7, "unit": "亿人", "label": "目标人群", "note": "18–35 岁,每周至少一次速食面条的城市人群"},
+        ],
+        "executive": [
+            "拌面的问题不是没人吃,是吃得将就——用户想吃一碗像饭馆的拌面,但没有锅。",
+            "「软塌 / 坨掉」是复购第一杀手,出现频次高于「难吃」;三份独立成果都指向同一件事。",
+            "口味集中在麻酱与香辣两类,川味麻辣、西北油泼在搜索端有热度、产品端是空白。",
+            "露营是「知道痛但还没买」的场景:多一步(倒面汤)就会损失一半潜在用户。",
+        ],
+        "chapters": [
+            {
+                "id": "ch1", "no": 1, "stage": "S1", "en": "MARKET FUNNEL", "title": "全景漏斗",
+                "subtitle": "46 个品类 → 2.7 亿人 → 3000 万人 → 900 万人 → 180 万人",
+                "lead": "把速食面这个大品类一路收窄到「没有锅、但想吃一碗好面」的那 180 万人——先看清我们是在几亿人里切多大一块。",
+                "sections": [
+                    {"id": "s1-1", "no": "1.1", "title": "五级漏斗 · 从品类池到首推机会", "kind": "funnel", "data": funnel},
+                    {"id": "s1-2", "no": "1.2", "title": "品类全景 · 三条产品线的现状", "kind": "para",
+                     "text": "国内速食面大致分三线:瓶装泡面(占比最大、增长停滞)、干拌面(近两年增长最快、但口味高度同质)、鲜面 / 自热(价格高、场景窄)。拌面是唯一一条「增长 + 同质化」同时成立的线——这意味着机会不在有没有产品,而在谁先把口感做出区隔。"},
+                ],
+            },
+            {
+                "id": "ch2", "no": 2, "stage": "S2", "en": "NESTED INSIGHT", "title": "嵌套洞察",
+                "subtitle": "5 大场景 × 现有产品选择 × 未满足需求 × 机会",
+                "lead": "用户不是按品类买面的,是按「此刻在哪儿、有没有锅、饿到什么程度」买的——把这三个坐标叠起来,才看得到空白。",
+                "sections": [
+                    {"id": "s2-1", "no": "2.1", "title": "5 大场景 × 现有产品 × 未满足需求", "kind": "matrix", "data": xray,
+                     "note": "VOC 条数为该场景下相关原声条数;末列指后文产品适配章节对应的结论。"},
+                    {"id": "s2-2", "no": "2.2", "title": "三条最锋利的结论", "kind": "findings", "data": [
+                        {"id": "f-1", "conclusion": "「太软没嚼劲」是第一杀手",
+                         "attitude": "口感不是加分项,是及格线。口感不合格的拌面,再怎么讲口味都不会复购。", "evidence": "c-texture"},
+                        {"id": "f-2", "conclusion": "口味空档在地域,不在辣度",
+                         "attitude": "再多一个更辣的口味没有意义;川味的麻、西北的油泼,才是搜索端在找、货架上没有的东西。", "evidence": "c-sauce"},
+                        {"id": "f-3", "conclusion": "露营场景「多一步就不买」",
+                         "attitude": "户外用户愿意为像样的一餐多付钱,但不愿意多带一口锅、多倒一次水。", "evidence": "c-portable"},
+                    ]},
+                ],
+            },
+            {
+                "id": "ch3", "no": 3, "stage": "S2", "en": "PAIN MAP", "title": "痛声地图",
+                "subtitle": "每一条差评 → 是哪类用户 → 有没有原文 → 能不能立项",
+                "lead": "把 730 条精准 VOC 里真正带原文的痛声挑出来,钉在场景上——没有原文的痛声只能当线索,不能当依据。",
+                "sections": [
+                    {"id": "s3-1", "no": "3.1", "title": "痛点 × 场景 × 严重度", "kind": "table", "data": {
+                        "cols": ["痛点", "集中场景", "出现条数", "严重度", "能不能立项"],
+                        "rows": [
+                            ["面饼泡后偏软、坨掉", "加班夜宵 / 宿舍", 214, "严重", "能:工艺问题,可解"],
+                            ["只有麻酱 / 香辣两种口味", "宿舍 / 家庭", 168, "严重", "能:研发已有地域样"],
+                            ["一包吃不饱", "宿舍 / 加班", 132, "中", "有条件:需解决成本"],
+                            ["冲泡步骤多、要倒面汤", "露营 / 户外", 97, "中", "能:改包装结构"],
+                            ["酱太咸 / 太油", "全场景", 76, "中", "能:调味迭代"],
+                        ]}},
+                    {"id": "s3-2", "no": "3.2", "title": "反面证据:不能拿推演当依据", "kind": "findings", "data": [
+                        {"id": "f-4", "conclusion": "「老年人更爱汤面」只是一条推断",
+                         "attitude": "这条推断掉进了记录层:没有原文、缺出身,不能用来指导立项。把它留在证据链里,是为了说明我们排除了什么。",
+                         "evidence": "c-weak"},
+                    ]},
+                ],
+            },
+            {
+                "id": "ch4", "no": 4, "stage": "S4", "en": "PRODUCT FIT", "title": "产品适配",
+                "subtitle": "能做到的 × 做不到的 × 可迁移的 × 红线",
+                "lead": "不吹不黑——康师傅在速食面上真正的能力边界在哪?哪些是能把泡面的成功搬过来的,哪些是碰了就要翻车的。",
+                "sections": [
+                    {"id": "s4-1", "no": "4.1", "title": "能做到的 vs 做不到的", "kind": "two", "data": fit},
+                    {"id": "s4-2", "no": "4.2", "title": "成功因子可迁移 vs 失败的红线", "kind": "fit", "data": fit},
+                    {"id": "s4-3", "no": "4.3", "title": "机会评分 · 需求强度 × 竞争烈度 × 我方能力", "kind": "score", "data": [
+                        ["不需要锅的鲜拌面", 9, 4, 8, 84, "✅ 首推"],
+                        ["川味麻辣拌面", 8, 6, 9, 71, "✅ 备选口味"],
+                        ["露营联名小装", 6, 3, 6, 58, "⏳ 小规模验证"],
+                        ["对标外卖的正餐拌面", 7, 9, 4, 41, "❌ 红海,不进"],
+                    ], "note": "评分 = 需求强度×5 + 我方能力×3 − 竞争烈度×2,满分 100;只用于排序,不作为决策依据。"},
+                    {"id": "s4-4", "no": "4.4", "title": "精准插入点", "kind": "para",
+                     "text": "结论:不进更便宜的价格战,也不进像外卖正餐的红海,而是切「想吃得像样、但没有锅」的中间地带——用一个已经成熟的工艺(非油炸 + 蒸煮复合)把一个没被满足的需求(口感)补上。"},
+                ],
+            },
+            {
+                "id": "ch5", "no": 5, "stage": "S5", "en": "POSITIONING", "title": "定位推导",
+                "subtitle": "心智阶梯 → 差异化锚点 → 一句话定位",
+                "lead": "深夜的宿舍里,用户想起吃一碗面时,脑子里排在前面的依次是谁?我们要找的是这个序列里空着的那一格。",
+                "sections": [
+                    {"id": "s5-1", "no": "5.1", "title": "心智阶梯 · 用户决策序列", "kind": "ladder", "data": positioning},
+                    {"id": "s5-2", "no": "5.2", "title": "差异化锚点", "kind": "table",
+                     "data": {"cols": ["对标", "它的弱点", "我们的说法"], "rows": positioning["axis"]}},
+                    {"id": "s5-3", "no": "5.3", "title": "定位陈述", "kind": "statement", "data": positioning},
+                ],
+            },
+            {
+                "id": "ch6", "no": 6, "stage": "S5", "en": "ACTION PLAN", "title": "行动蓝图",
+                "subtitle": "P0 立即 / P1 三个月验证 / 数据缺口",
+                "lead": "从结论到落地的最短路径:两周内能动的先动,三个月内用数据验证口感与场景,缺的数据写清楚下一轮怎么补。",
+                "sections": [
+                    {"id": "s6-1", "no": "6.1", "title": "P0 + P1 行动清单", "kind": "table", "data": {
+                        "cols": ["编号", "行动项", "负责人", "期限", "具体内容", "优先级"], "rows": actions}},
+                    {"id": "s6-2", "no": "6.2", "title": "三个月验证 · 决策节点", "kind": "milestones", "data": milestones},
+                    {"id": "s6-3", "no": "6.3", "title": "数据缺口 × 下一轮采集", "kind": "table", "data": {
+                        "cols": ["缺口", "当前状态", "需要什么", "影响决策", "优先级"], "rows": gaps}},
+                ],
+            },
+        ],
+        "evidence": E,
+    }
+
+
+def find_target(arts):
+    for a in arts:
+        if a.get("title") == TARGET_TITLE_KEYWORD:
+            return a
+    raise SystemExit(f"找不到承载报告的成果:{TARGET_TITLE_KEYWORD}(库里现有 {len(arts)} 条)")
+
+
+def art_ids_by_owner(arts):
+    """把报告里的结论/证据挂到真实存在的成果上(找不到就是 None,看板会如实显示)。
+    优先选过程层、有出身的成果;同一个人有多条时,优先带原文的,再按时间。"""
+    def rank(a):
+        return (0 if a.get("layer") != "record" else 1,
+                0 if a.get("sources") else 1,
+                a.get("createdAt") or "")
+    pool = sorted(arts, key=rank)
+
+    def first(owner, stage_prefix=None):
+        for a in pool:
+            if a.get("ownerName") == owner and (not stage_prefix or str(a.get("stage", "")).startswith(stage_prefix)):
+                return a["objectId"]
+        return None
+    return {
+        "zw_report": find_target(arts)["objectId"],
+        "zw_pain": first("张伟", "S1"), "zw_opp": first("张伟", "S1"),
+        "ln_pain": first("李娜", "S2"), "ln_report": first("李娜", "S2"),
+        "wf_pain": first("王芳", "S3"), "wf_opp": first("王芳", "S3"),
+        "hm_pain": first("陈静"), "hm_opp": first("陈静"),
+        "jn_pain": first("赵磊"), "jn_report": first("赵磊"),
+        "cj_opp": first("陈静", "S5"), "cj_pain": first("陈静", "S5"),
+        "ly_opp": first("刘洋", "S4"), "ly_report": first("刘洋", "S4"),
+        "zt_record": next((a["objectId"] for a in arts if a.get("layer") == "record"
+                           and "老年人" in a.get("title", "")), None),
+    }
+
+
+
+
+def main():
+    if not MK and not DRY:
+        sys.exit("PARSE_MASTER_KEY 未设置")
+    arts = fetch("KfArtifact") if MK else []
+    target = find_target(arts)
+    report = build(art_ids_by_owner(arts))
+    body = json.dumps(report, ensure_ascii=False)
+    if DRY:
+        print(json.dumps({"target": target["objectId"], "title": target["title"],
+                          "bytes": len(body.encode()), "chapters": len(report["chapters"])}, ensure_ascii=False))
+        return
+    r = call("PUT", f"/classes/KfArtifact/{target['objectId']}", {"report": report})
+    print("report written:", target["title"], "→", target["objectId"],
+          "| bytes", len(body.encode()), "| chapters", len(report["chapters"]),
+          "| ok" if "updatedAt" in r else f"| ERROR {r}")
+
+
+if __name__ == "__main__":
+    main()

+ 128 - 0
legacy/tools/setup_cloud.py

@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""云端准备脚本(开发工具,不属于前端交付物)。
+
+做的事:
+  1. 补齐字段:KfArtifact.layer(四层归属)、KfProject.layer、KfArtifact.sourceIdx(原材料层样例指针)
+  2. 补数:残留的 KfProbe 探针 -> 改名为标准的 KfSource(原材料层样例);
+     原文证据为空、但 evidenceLevel 标为 verified 的成果 -> 降为 supported(避免"无原文却称已验证")
+  3. 重建归纳:按 derivedFrom(-card + source 混合引用)重新计算 derivedArtifacts / derivedSources /
+     owners / citedWith / evidenceQuotes / evidenceUnaligned / mentionCount
+  4. 验证 PARSE_MASTER_KEY(环境变量)有效
+
+运行时:PARSE_MASTER_KEY=... python3 tools/setup_cloud.py
+--dry-run 只打印不写。
+"""
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+
+BASE = "https://kangshifu.fmode.cn/parse"
+APP_ID = "FKieRbIoGt_main"
+MK = os.environ.get("PARSE_MASTER_KEY", "")
+DRY = "--dry-run" in sys.argv
+
+H = {"X-Parse-Application-Id": APP_ID, "Content-Type": "application/json"}
+if MK:
+    H["X-Parse-Master-Key"] = MK
+
+
+def call(method, path, body=None):
+    data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
+    req = urllib.request.Request(BASE + path, data=data, headers=H, method=method)
+    try:
+        with urllib.request.urlopen(req, timeout=30) as r:
+            return json.loads(r.read().decode() or "{}")
+    except urllib.error.HTTPError as e:
+        return {"_error": e.code, "_body": e.read().decode()[:400]}
+
+
+def fetch(cls):
+    return call("GET", f"/classes/{cls}?limit=1000&order=createdAt").get("results", [])
+
+
+def ensure_fields():
+    for cls, fields in [
+        ("KfArtifact", {"layer": {"type": "String"}, "sourceIdx": {"type": "Number"}}),
+        ("KfProject", {"layer": {"type": "String"}}),
+    ]:
+        if DRY:
+            print("[dry] schema", cls, list(fields))
+            continue
+        r = call("PUT", f"/schemas/{cls}", {"className": cls, "fields": fields})
+        print("schema", cls, "ok" if "fields" in r else r)
+
+
+def cleanup_probes():
+    probes = fetch("KfProbe")
+    for p in probes:
+        title = "原材料层样例 · 探针记录"
+        body = {
+            "layer": "raw",
+            "sourceType": "probe",
+            "title": title,
+            "summary": "开发期连通性探针,已转为标准原材料层样例记录。",
+            "rawText": p.get("note", ""),
+            "sources": [],
+            "tags": [],
+            "payload": {},
+            "meta": {"migratedFrom": "KfProbe", "kfProbeId": p["objectId"]},
+        }
+        if DRY:
+            print("[dry] probe -> KfSource", p["objectId"])
+            continue
+        r = call("POST", "/classes/KfSource", body)
+        if "objectId" in r:
+            call("DELETE", f"/classes/KfProbe/{p['objectId']}")
+            print("probe", p["objectId"], "->", r["objectId"])
+
+
+def fix_evidence_levels():
+    for a in fetch("KfArtifact"):
+        if not a.get("sources") and a.get("evidenceLevel") == "verified":
+            if DRY:
+                print("[dry] downgrade", a["title"])
+                continue
+            call("PUT", f"/classes/KfArtifact/{a['objectId']}", {"evidenceLevel": "supported"})
+            print("downgraded verified->supported:", a["title"])
+
+
+def rebuild_insights():
+    arts = {a["objectId"]: a for a in fetch("KfArtifact")}
+    srcs = {s["objectId"]: s for s in fetch("KfSource")}
+    for ins in fetch("KfInsight"):
+        pointed = ins.get("derivedFrom") or []
+        art_ids = [i for i in pointed if i in arts]
+        src_ids = [i for i in pointed if i in srcs]
+        owners = sorted({arts[i].get("ownerName") for i in art_ids if arts[i].get("ownerName")})
+        lvls = sorted({arts[i].get("evidenceLevel") for i in art_ids if arts[i].get("evidenceLevel")})
+        cited_with = sorted({ins["objectId"] for a in arts.values()
+                             if set(a.get("sources") or []) & set(art_ids)} - {ins["objectId"]})
+        upd = {
+            "layer": "sediment",
+            "derivedArtifacts": art_ids,
+            "derivedSources": src_ids,
+            "owners": owners,
+            "ownerCount": len(owners),
+            "crossRefCount": ins.get("crossRefCount", 0),
+            "mentionCount": ins.get("crossRefCount", 0),
+            "evidenceQuoteCount": len(art_ids),
+            "evidenceUnaligned": "verified" not in lvls,
+            "citedWith": cited_with,
+        }
+        if DRY:
+            print("[dry] insight", ins["title"], upd)
+            continue
+        r = call("PUT", f"/classes/KfInsight/{ins['objectId']}", upd)
+        print("insight", ins["title"], "ok" if "updatedAt" in r else r)
+
+
+if __name__ == "__main__":
+    if not MK:
+        sys.exit("PARSE_MASTER_KEY 未设置")
+    print("masterKey auth:", "ok" if "results" in call("GET", "/classes/KfProject?limit=1") else "FAILED")
+    ensure_fields()
+    cleanup_probes()
+    fix_evidence_levels()
+    rebuild_insights()

+ 28 - 0
lib/vendor/@lit-labs/signals/v0.3.0/LICENSE

@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) 2024 Google LLC. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 312 - 0
lib/vendor/@lit-labs/signals/v0.3.0/README.md

@@ -0,0 +1,312 @@
+# @lit-labs/signals
+
+TC39 Signals Proposal integration for Lit.
+
+[![Build Status](https://github.com/lit/lit/workflows/Tests/badge.svg)](https://github.com/lit/lit/actions?query=workflow%3ATests)
+[![Published on npm](https://img.shields.io/npm/v/@lit-labs/signals.svg?logo=npm)](https://www.npmjs.com/package/@lit-labs/signals)
+[![Join our Discord](https://img.shields.io/badge/discord-join%20chat-5865F2.svg?logo=discord&logoColor=fff)](https://lit.dev/discord/)
+[![Mentioned in Awesome Lit](https://awesome.re/mentioned-badge.svg)](https://github.com/web-padawan/awesome-lit)
+
+> [!WARNING]
+>
+> This package is part of [Lit Labs](https://lit.dev/docs/libraries/labs/). It
+> is published in order to get feedback on the design and may receive breaking
+> changes or stop being supported.
+>
+> Please read our [Lit Labs documentation](https://lit.dev/docs/libraries/labs/)
+> before using this library in production.
+>
+> Give feedback: https://github.com/lit/lit/discussions/4779
+>
+> RFC: https://github.com/lit/rfcs/blob/main/rfcs/0005-standard-signals.md
+
+## Documentation
+
+Full documentation is available at
+[lit.dev/docs/data/signals/](https://lit.dev/docs/data/signals/).
+
+## Overview
+
+`@lit-labs/signals` integrates the [TC39 Signals
+Proposal](https://github.com/tc39/proposal-signals) with Lit's template system
+and reactive update lifecycle. Signals used within an element's update
+lifecycle, such as in a template, will cause the element to re-render when the
+signal value changes. Signals can also be used for targetted or "pin-point" DOM
+updates, which can update the DOM without running the entire `render()` method.
+
+### The TC39 Signals Proposal
+
+The [TC39 Signals Proposal](https://github.com/tc39/proposal-signals) is a
+proposal to add standard signals to the JavaScript language.
+
+This is very exciting for web components, since it means that different web
+components that don't use the same libraries can interoperably consume and
+produce signals.
+
+It also means that many existing state management systems and observability
+libraries that might currently each require their own adapter to integrated with
+the Lit lifecycle, might converge on using standard signals so that we only need
+one Lit adapter, and eventually no adapter at all as support for signals is
+directly added to Lit
+
+### Why Signals?
+
+Signals have several nice attributes for use with reactive components like Lit:
+
+1. Signals are an easy way to create shared observable state - state that many
+   elements can use and update when it changes. This is great for things like a
+   game state that many components need to read.
+
+2. Signals can be individually observed, and when used in a template binding,
+   can be handled so that they only update the DOM their bound to. These
+   targetted DOM updates don't re-render the entire template.
+
+3. Standard signals are an observability interoperabiliy point that many
+   different libraries can use. Any library that produces signals will work with
+   any standard signal watcher.
+
+4. Signals can be good for performance. Signals track dependencies and changes
+   so that only signals that miht have changed and have been read are
+   re-computd. This can help perform minimal computations and DOM updates when
+   doing small updates to large signal graphs or UIs.
+
+5. Signal auto-tracking can reduce the need for component-specific lifecycle
+   APIs. For example, rather than having lifecycle callbacks for when updates
+   have happened, or when specific reactive properties have changed, any code
+   could create a reactive effect that simple accesses the signals it uses, and
+   is automatically re-run when they change.
+
+6. Signals may allow for interoperable _synchronous_ and _batched_ DOM updates.
+   There are ways to respond to signal changes synchronously but also batched,
+   so if reactive properties were backed by signals, an element could re-render
+   itself once a batch of them had been updated. Elements could take care to
+   update children inside of batches, meaning entire subtrees could be updated
+   synchrously. The batching mechanism isn't standard yet, but could be an
+   extension to the proposal.
+
+Signals are a natural fit for Lit: a LitElement render method is already
+somewhat like a computed signal in that it is computed based on updates to
+inputs (reactive properties).
+
+The difference between Lit renders and signals is
+that in Lit the data flow is push-based, rather than pull-based as in signals.
+Lit elements react when changes are pushed into them, whereas signals
+automatically subscribe to the other signals they access. But these approaches
+are very compatible, and we can easily make elements subscribe to the signals
+they access and trigger an update with an integration library like this one.
+
+### On Proposals and Polyfills
+
+Like all Lit Labs packages, `@lit-labs/signals` package may change frequently,
+have serious bugs, or not be maintained as well as Lit's core packages.
+
+Additionally, this package depends on the API defined in the [TC39 Signals
+proposal](https://github.com/tc39/proposal-signals) and directly depends on the
+[Signals polyfill](https://github.com/proposal-signals/signal-polyfill), which
+add more potential sources of instability and bugs. The proposal may change, and
+the polfyill may have bugs or serious performance issues. If multiple versions
+of the polyfill are included on a page, interoperabiilty may fail.
+
+As the Signals proposal and polyfill progress we will update this package. At
+some point we will remove the dependency on the polyfill and assume the standard
+signal APIs exist, and pages will have to install the polyfill if needed.
+
+So `@lit-labs/signals` is not recommended for production use. If you choose to
+use it, please thouroughly test and check the performance of your components
+and/or app _at scale_, with the number of signals and component instances that
+you expect in real-world usage.
+
+Please file feedback and bugs with the [Lit
+project](https://github.com/lit/lit/issues), the [Signals
+Proposal](https://github.com/tc39/proposal-signals), and the [Signals
+polyfill](https://github.com/proposal-signals/signal-polyfill) a appropriate.
+
+## Usage
+
+There are three main exports:
+
+- The `SignalWatcher` mixin
+- The `watch()` directive
+- The `html` template tag, and `withWatch()` template tag factory
+
+### SignalWatcher
+
+`SignalWatcher` is the core of signals integration. It's a mixin that makes an
+element watch all signal accesses during the element's reactive update
+lifecycle, then triggers an element update when signals change. This includes
+signals read in `shouldUpdate()`, `willUpdate()`, `update()`, `render()`,
+`updated()`, `firstUpdated()`, and reactive controller's `hostUpdate()` and
+`hostUpdated()`.
+
+This effectively makes the the return result of `render()` a computed signal.
+
+```ts
+import {LitElement, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {SignalWatcher, signal} from '@lit-labs/signals';
+
+const count = signal(0);
+
+@customElement('signal-example')
+export class SignalExample extends SignalWatcher(LitElement) {
+  static styles = css`
+    :host {
+      display: block;
+    }
+  `;
+
+  render() {
+    return html`
+      <p>The count is ${count.get()}</p>
+      <button @click=${this.#onClick}>Increment</button>
+    `;
+  }
+
+  #onClick() {
+    count.set(count.get() + 1);
+  }
+}
+```
+
+Elements should not _write_ to signals in these lifecycle methods or they might
+cause an infinite loop.
+
+### watch() directive
+
+The `watch()` directive accepts a single Signal and renders its value,
+subscribing to updates and updating the DOM when the signal changes. This allows
+for very targeted updates of the DOM, which can be good for performance (but as
+always, measure!).
+
+```ts
+import {LitElement, html} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {SignalWatcher, watch, signal} from '@lit-labs/signals';
+
+const count = signal(0);
+
+@customElement('signal-example')
+export class SignalExample extends SignalWatcher(LitElement) {
+  static styles = css`
+    :host {
+      display: block;
+    }
+  `;
+
+  render() {
+    return html`
+      <p>The count is ${watch(count)}</p>
+      <button @click=${this.#onClick}>Increment</button>
+    `;
+  }
+
+  #onClick() {
+    count.set(count.get() + 1);
+  }
+}
+```
+
+`watch()` updates do not trigger the Lit reactive update cycle. However, they
+are batched and run in coordination with reactive updates. When a watched
+signal changes, if a reactive update is pending, the watched signal update
+renders with the update; otherwise, it renders in a batch with any other
+watched signals. Other changes, to reactive properties or signals accessed
+outside of `watch()`, trigger reactive updates as usual.
+
+You can mix and match targeted updates with `watch()` directive and
+auto-tracking with `SignalWatcher`. When you pass a signal directly to `watch()`
+it is not accessed in a callback watched by `SignalWatcher`, so an update to
+that signal will only cause a targeted DOM update and not an full template
+render.
+
+> [!NOTE]
+>
+> <!-- -->
+>
+> The value passed to `watch` must be a signal. If it isn't, it can be
+> converted to one using `computed`. For example
+> `${watch(computed(() => list.get().map(item => item))}`.
+
+### this.effect
+
+`SignalWatcher` also exposes an `effect(callback)` method that allows targeted
+reactions to signal changes independent of but coordinated with the reactive
+update lifecycle. This provides an easy mechanism to react generally to signals
+used only with `watch` that do not trigger a reactive update. By default,
+the `effect` is run after any DOM based updates are rendered, including
+the element's reactive update cycle, if it is pending when the effect would
+trigger. Adding an options argument with `beforeUpdate: true` allows effects
+to run before any DOM is updated. For example,
+`this.effect(() => console.log(this.aSignal.get())), {beforeUpdate: true});`
+
+### html tag and withWatch()
+
+This package also exports an `html` template tag that can be used in place of
+Lit's default `html` tag and automatically wraps any signals in `watch()`.
+
+```ts
+import {LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {SignalWatcher, html, signal} from '@lit-labs/signals';
+
+const count = signal(0);
+
+@customElement('signal-example')
+export class SignalExample extends SignalWatcher(LitElement) {
+  static styles = css`
+    :host {
+      display: block;
+    }
+  `;
+
+  render() {
+    return html`
+      <p>The count is ${count}</p>
+      <button @click=${this.#onClick}>Increment</button>
+    `;
+  }
+
+  #onClick() {
+    count.set(count.get() + 1);
+  }
+}
+```
+
+#### `withWatch()`
+
+`withWatch()` is a function that wraps an `html` tag function with the
+auto-watching functionality. The `html` tag exported by `@lit-labs/signals` is a
+convenient export of the core lit-html template tag wrapped with `withWatch()`.
+
+`withWatch()` allows you to compose the signal watching wrapper with other
+lit-html tag wrappers like Lit's `withStatic()` utility.
+
+```ts
+import {html as coreHtml} from 'lit';
+import {withStatic} from 'lit/static-html.js';
+import {withWatch} from '@lit-labs/signals';
+
+/**
+ * A Lit template tag that support static values and pinpoint signal updates.
+ */
+const html = withWatch(withStatic(coreHtml));
+```
+
+## Future Work
+
+This library will change based on feedback from developers. Some existing dieas we have for futher development are:
+
+- A signal-aware `repeat()` directive that can update items in a list independently of the entire list.
+- Signal aware `when()` directive that wraps the condition in a computed signal and watches it.
+- A `@property()` decorator that creates a signal-backed property that can be watched.
+- An `@effect()` method decorator that runs a method inside a watched computed signal, and re-runs it when any signal dependencies change. This would be an alternative the the common `@observe()` feature request.
+- Batched synchronous updates, when using a utility like []`batchedEffect()`](https://github.com/proposal-signals/signal-utils?tab=readme-ov-file#batched-effects)
+
+## Related Libraries
+
+### signal-utils
+
+The [`signal-utils` project](https://github.com/proposal-signals/signal-utils)
+contains a lot of utilities for building signals-based data models.
+
+Some of these are especially useful for use cases around shared observable state. The signal-backed collections (arrays, maps, and sets) can help address cases where Lit's reactive properties cannot see internal changes to objects.

+ 15 - 0
lib/vendor/@lit-labs/signals/v0.3.0/index.d.ts

@@ -0,0 +1,15 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+import { Signal } from 'signal-polyfill';
+export * from 'signal-polyfill';
+export * from './lib/signal-watcher.js';
+export * from './lib/watch.js';
+export * from './lib/html-tag.js';
+export declare const State: typeof Signal.State;
+export declare const Computed: typeof Signal.Computed;
+export declare const signal: <T>(value: T, options?: Signal.Options<T>) => Signal.State<T>;
+export declare const computed: <T>(callback: () => T, options?: Signal.Options<T>) => Signal.Computed<T>;
+//# sourceMappingURL=index.d.ts.map

+ 1 - 0
lib/vendor/@lit-labs/signals/v0.3.0/index.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAC,MAAM,EAAC,MAAM,iBAAiB,CAAC;AAEvC,cAAc,iBAAiB,CAAC;AAChC,cAAc,yBAAyB,CAAC;AACxC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAElC,eAAO,MAAM,KAAK,qBAAe,CAAC;AAClC,eAAO,MAAM,QAAQ,wBAAkB,CAAC;AAExC,eAAO,MAAM,MAAM,GAAI,CAAC,EAAE,OAAO,CAAC,EAAE,UAAU,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,oBAC7B,CAAC;AACnC,eAAO,MAAM,QAAQ,GAAI,CAAC,EAAE,UAAU,MAAM,CAAC,EAAE,UAAU,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,uBAC/B,CAAC"}

+ 7 - 0
lib/vendor/@lit-labs/signals/v0.3.0/index.js

@@ -0,0 +1,7 @@
+import{Signal as t}from"signal-polyfill";export*from"signal-polyfill";export{SignalWatcher}from"./lib/signal-watcher.js";export{WatchDirective,watch}from"./lib/watch.js";export{html,svg,withWatch}from"./lib/html-tag.js";
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */const l=t.State,o=t.Computed,r=(l,o)=>new t.State(l,o),i=(l,o)=>new t.Computed(l,o);export{o as Computed,l as State,i as computed,r as signal};
+//# sourceMappingURL=index.js.map

+ 1 - 0
lib/vendor/@lit-labs/signals/v0.3.0/index.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"index.js","sources":["src/index.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2023 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\n\nimport {Signal} from 'signal-polyfill';\n\nexport * from 'signal-polyfill';\nexport * from './lib/signal-watcher.js';\nexport * from './lib/watch.js';\nexport * from './lib/html-tag.js';\n\nexport const State = Signal.State;\nexport const Computed = Signal.Computed;\n\nexport const signal = <T>(value: T, options?: Signal.Options<T>) =>\n  new Signal.State(value, options);\nexport const computed = <T>(callback: () => T, options?: Signal.Options<T>) =>\n  new Signal.Computed<T>(callback, options);\n"],"names":["State","Signal","Computed","signal","value","options","computed","callback"],"mappings":";;;;;GAaO,MAAMA,EAAQC,EAAOD,MACfE,EAAWD,EAAOC,SAElBC,EAAS,CAAIC,EAAUC,IAClC,IAAIJ,EAAOD,MAAMI,EAAOC,GACbC,EAAW,CAAIC,EAAmBF,IAC7C,IAAIJ,EAAOC,SAAYK,EAAUF"}

+ 26 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/html-tag.d.ts

@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+import { html as coreHtml, svg as coreSvg, type TemplateResult } from 'lit/html.js';
+/**
+ * Wraps a lit-html template tag function (`html` or `svg`) to add support for
+ * automatically wrapping Signal instances in the `watch()` directive.
+ */
+export declare const withWatch: (coreTag: typeof coreHtml | typeof coreSvg) => (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
+/**
+ * Interprets a template literal as an HTML template that can efficiently
+ * render to and update a container.
+ *
+ * Includes signal watching support from `withWatch()`.
+ */
+export declare const html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
+/**
+ * Interprets a template literal as an SVG template that can efficiently
+ * render to and update a container.
+ *
+ * Includes signal watching support from `withWatch()`.
+ */
+export declare const svg: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
+//# sourceMappingURL=html-tag.d.ts.map

+ 1 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/html-tag.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"html-tag.d.ts","sourceRoot":"","sources":["../../src/lib/html-tag.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,IAAI,IAAI,QAAQ,EAChB,GAAG,IAAI,OAAO,EACd,KAAK,cAAc,EACpB,MAAM,aAAa,CAAC;AAKrB;;;GAGG;AACH,eAAO,MAAM,SAAS,GACnB,SAAS,OAAO,QAAQ,GAAG,OAAO,OAAO,MACzC,SAAS,oBAAoB,EAAE,GAAG,QAAQ,OAAO,EAAE,KAAG,cAStD,CAAC;AAEJ;;;;;GAKG;AACH,eAAO,MAAM,IAAI,YAjBL,oBAAoB,aAAa,OAAO,EAAE,KAAG,cAiBlB,CAAC;AAExC;;;;;GAKG;AACH,eAAO,MAAM,GAAG,YAzBJ,oBAAoB,aAAa,OAAO,EAAE,KAAG,cAyBpB,CAAC"}

+ 7 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/html-tag.js

@@ -0,0 +1,7 @@
+import{html as o,svg as t}from"lit/html.js";import{watch as s}from"./watch.js";import{Signal as i}from"signal-polyfill";
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */const m=o=>(t,...m)=>o(t,...m.map(o=>o instanceof i.State||o instanceof i.Computed?s(o):o)),l=m(o),r=m(t);export{l as html,r as svg,m as withWatch};
+//# sourceMappingURL=html-tag.js.map

+ 1 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/html-tag.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"html-tag.js","sources":["../src/lib/html-tag.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2023 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nimport {\n  html as coreHtml,\n  svg as coreSvg,\n  type TemplateResult,\n} from 'lit/html.js';\n\nimport {watch} from './watch.js';\nimport {Signal} from 'signal-polyfill';\n\n/**\n * Wraps a lit-html template tag function (`html` or `svg`) to add support for\n * automatically wrapping Signal instances in the `watch()` directive.\n */\nexport const withWatch =\n  (coreTag: typeof coreHtml | typeof coreSvg) =>\n  (strings: TemplateStringsArray, ...values: unknown[]): TemplateResult => {\n    // TODO (justinfagnani): use an alternative to instanceof when\n    // one is available. See https://github.com/preactjs/signals/issues/402\n    return coreTag(\n      strings,\n      ...values.map((v) =>\n        v instanceof Signal.State || v instanceof Signal.Computed ? watch(v) : v\n      )\n    );\n  };\n\n/**\n * Interprets a template literal as an HTML template that can efficiently\n * render to and update a container.\n *\n * Includes signal watching support from `withWatch()`.\n */\nexport const html = withWatch(coreHtml);\n\n/**\n * Interprets a template literal as an SVG template that can efficiently\n * render to and update a container.\n *\n * Includes signal watching support from `withWatch()`.\n */\nexport const svg = withWatch(coreSvg);\n"],"names":["withWatch","coreTag","strings","values","map","v","Signal","State","Computed","watch","html","coreHtml","svg","coreSvg"],"mappings":";;;;;GAkBO,MAAMA,EACVC,GACD,CAACC,KAAkCC,IAG1BF,EACLC,KACGC,EAAOC,IAAKC,GACbA,aAAaC,EAAOC,OAASF,aAAaC,EAAOE,SAAWC,EAAMJ,GAAKA,IAWlEK,EAAOV,EAAUW,GAQjBC,EAAMZ,EAAUa"}

+ 36 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/signal-watcher.d.ts

@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+import type { ReactiveElement } from 'lit';
+import { Signal } from 'signal-polyfill';
+export interface SignalWatcher extends ReactiveElement {
+    _watcher?: Signal.subtle.Watcher;
+}
+interface EffectOptions {
+    /**
+     * By default effects run after the element has updated. If `beforeUpdate`
+     * is set to `true`, the effect will run before the element updates.
+     */
+    beforeUpdate?: boolean;
+    /**
+     * By default, effects are automatically disposed when the element is
+     * disconnected. If `manualDispose` is set to `true`, the effect will not
+     * be automatically disposed, and you must call the returned function to
+     * dispose of the effect manually.
+     */
+    manualDispose?: boolean;
+}
+export interface SignalWatcherApi {
+    updateEffect(fn: () => void, options?: EffectOptions): () => void;
+}
+type Constructor<T = {}> = new (...args: any[]) => T;
+/**
+ * Adds the ability for a LitElement or other ReactiveElement class to
+ * watch for access to signals during the update lifecycle and trigger a new
+ * update when signals values change.
+ */
+export declare function SignalWatcher<T extends Constructor<ReactiveElement>>(Base: T): T & Constructor<SignalWatcherApi>;
+export {};
+//# sourceMappingURL=signal-watcher.d.ts.map

+ 1 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/signal-watcher.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"signal-watcher.d.ts","sourceRoot":"","sources":["../../src/lib/signal-watcher.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,KAAK,CAAC;AACzC,OAAO,EAAC,MAAM,EAAC,MAAM,iBAAiB,CAAC;AAEvC,MAAM,WAAW,aAAc,SAAQ,eAAe;IACpD,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC;CAClC;AAED,UAAU,aAAa;IACrB;;;OAGG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAiBD,MAAM,WAAW,gBAAgB;IAC/B,YAAY,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,IAAI,CAAC;CACnE;AAGD,KAAK,WAAW,CAAC,CAAC,GAAG,EAAE,IAAI,KAAK,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC;AA2BrD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,WAAW,CAAC,eAAe,CAAC,EAAE,IAAI,EAAE,CAAC,qCAqP5E"}

Разница между файлами не показана из-за своего большого размера
+ 5 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/signal-watcher.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/signal-watcher.js.map


+ 30 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/watch.d.ts

@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+import { DirectiveResult, Part } from 'lit/directive.js';
+import { AsyncDirective } from 'lit/async-directive.js';
+import { Signal } from 'signal-polyfill';
+export declare class WatchDirective<T> extends AsyncDirective {
+    private __host?;
+    private __signal?;
+    private __watcher?;
+    private __computed;
+    private __watch;
+    private __unwatch;
+    render(signal: Signal.State<T> | Signal.Computed<T>): T;
+    update(part: Part, [signal]: [signal: Signal.State<T> | Signal.Computed<T>]): T;
+    protected disconnected(): void;
+    protected reconnected(): void;
+}
+export type WatchDirectiveFunction = <T>(signal: Signal.State<T> | Signal.Computed<T>) => DirectiveResult<typeof WatchDirective<T>>;
+/**
+ * Renders a signal and subscribes to it, updating the part when the signal
+ * changes.
+ *
+ * watch() can only be used in a reactive element that applies the
+ * SignalWatcher mixin.
+ */
+export declare const watch: WatchDirectiveFunction;
+//# sourceMappingURL=watch.d.ts.map

+ 1 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/watch.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"watch.d.ts","sourceRoot":"","sources":["../../src/lib/watch.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAC,eAAe,EAAE,IAAI,EAAY,MAAM,kBAAkB,CAAC;AAClE,OAAO,EAAC,cAAc,EAAC,MAAM,wBAAwB,CAAC;AACtD,OAAO,EAAC,MAAM,EAAC,MAAM,iBAAiB,CAAC;AAmBvC,qBAAa,cAAc,CAAC,CAAC,CAAE,SAAQ,cAAc;IACnD,OAAO,CAAC,MAAM,CAAC,CAAgB;IAE/B,OAAO,CAAC,QAAQ,CAAC,CAAuC;IAExD,OAAO,CAAC,SAAS,CAAC,CAAwB;IAE1C,OAAO,CAAC,UAAU,CAA6C;IAE/D,OAAO,CAAC,OAAO;IAef,OAAO,CAAC,SAAS;IAOjB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC;IAK9C,MAAM,CACb,IAAI,EAAE,IAAI,EACV,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;cAgBvC,YAAY,IAAI,IAAI;cAIpB,WAAW,IAAI,IAAI;CAGvC;AAED,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,EACrC,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,KACzC,eAAe,CAAC,OAAO,cAAc,CAAC,CAAC,CAAC,CAAC,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,EAAgC,sBAAsB,CAAC"}

+ 8 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/watch.js

@@ -0,0 +1,8 @@
+import{directive as i}from"lit/directive.js";import{AsyncDirective as t}from"lit/async-directive.js";import{Signal as s}from"signal-polyfill";
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+let o=!1;const n=new s.subtle.Watcher(async()=>{o||(o=!0,queueMicrotask(()=>{o=!1;for(const i of n.getPending())i.get();n.watch()}))});class r extends t{_$S_(){var i,t;void 0===this._$Sm&&(this._$Sj=new s.Computed(()=>{var i;const t=null===(i=this._$SW)||void 0===i?void 0:i.get();return this.setValue(t),t}),this._$Sm=null!==(t=null===(i=this._$Sk)||void 0===i?void 0:i.h)&&void 0!==t?t:n,this._$Sm.watch(this._$Sj),s.subtle.untrack(()=>{var i;return null===(i=this._$Sj)||void 0===i?void 0:i.get()}))}_$Sp(){void 0!==this._$Sm&&(this._$Sm.unwatch(this._$SW),this._$Sm=void 0)}render(i){return s.subtle.untrack(()=>i.get())}update(i,[t]){var o,n;return null!==(o=this._$Sk)&&void 0!==o||(this._$Sk=null===(n=i.options)||void 0===n?void 0:n.host),t!==this._$SW&&void 0!==this._$SW&&this._$Sp(),this._$SW=t,this._$S_(),s.subtle.untrack(()=>this._$SW.get())}disconnected(){this._$Sp()}reconnected(){this._$S_()}}const h=i(r);export{r as WatchDirective,h as watch};
+//# sourceMappingURL=watch.js.map

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
lib/vendor/@lit-labs/signals/v0.3.0/lib/watch.js.map


+ 140 - 0
lib/vendor/@lit-labs/signals/v0.3.0/package.json

@@ -0,0 +1,140 @@
+{
+  "name": "@lit-labs/signals",
+  "version": "0.3.0",
+  "description": "JavaScript Signals proposal integration for Lit",
+  "license": "BSD-3-Clause",
+  "homepage": "https://lit.dev/",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/lit/lit.git",
+    "directory": "packages/labs/signals"
+  },
+  "type": "module",
+  "main": "index.js",
+  "module": "index.js",
+  "typings": "index.d.ts",
+  "directories": {
+    "test": "test"
+  },
+  "exports": {
+    ".": {
+      "types": "./development/index.d.ts",
+      "development": "./development/index.js",
+      "default": "./index.js"
+    }
+  },
+  "files": [
+    "/development/",
+    "!/development/test/",
+    "/index.{d.ts,d.ts.map,js,js.map}",
+    "/lib/"
+  ],
+  "scripts": {
+    "build": "wireit",
+    "build:ts": "wireit",
+    "build:ts:types": "wireit",
+    "test": "wireit",
+    "test:dev": "wireit",
+    "test:prod": "wireit"
+  },
+  "wireit": {
+    "build": {
+      "dependencies": [
+        "build:rollup",
+        "build:ts",
+        "build:ts:types",
+        "../../lit:build"
+      ]
+    },
+    "build:ts": {
+      "command": "tsc --build --pretty",
+      "dependencies": [
+        "../../lit:build:ts:types"
+      ],
+      "clean": "if-file-deleted",
+      "files": [
+        "src/**/*.ts",
+        "src/**/*.tsx",
+        "tsconfig.json"
+      ],
+      "output": [
+        "development",
+        "tsconfig.tsbuildinfo"
+      ]
+    },
+    "build:ts:types": {
+      "command": "treemirror development . \"**/*.d.ts{,.map}\"",
+      "dependencies": [
+        "../../internal-scripts:build",
+        "build:ts"
+      ],
+      "files": [],
+      "output": [
+        "*.d.ts{,.map}",
+        "lib/*.d.ts{,.map}",
+        "test/**/*.d.ts{,.map}"
+      ]
+    },
+    "build:rollup": {
+      "command": "rollup -c",
+      "dependencies": [
+        "build:ts"
+      ],
+      "files": [
+        "rollup.config.js",
+        "../../../rollup-common.js",
+        "src/test/*_test.html"
+      ],
+      "output": [
+        "index.js{,.map}",
+        "lib/*.js{,.map}",
+        "test/*_test.html"
+      ]
+    },
+    "test": {
+      "dependencies": [
+        "test:dev",
+        "test:prod"
+      ]
+    },
+    "test:dev": {
+      "command": "MODE=dev node ../../tests/run-web-tests.js \"development/test/**/*_test.js\" --config ../../tests/web-test-runner.config.js",
+      "dependencies": [
+        "build:ts",
+        "../../tests:build"
+      ],
+      "env": {
+        "BROWSERS": {
+          "external": true
+        }
+      },
+      "files": [],
+      "output": []
+    },
+    "test:prod": {
+      "command": "MODE=prod node ../../tests/run-web-tests.js \"development/test/**/*_test.js\" --config ../../tests/web-test-runner.config.js",
+      "dependencies": [
+        "build",
+        "../../tests:build"
+      ],
+      "env": {
+        "BROWSERS": {
+          "external": true
+        }
+      },
+      "files": [],
+      "output": []
+    }
+  },
+  "author": "Google LLC",
+  "devDependencies": {
+    "@lit-internal/scripts": "^1.0.1"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "dependencies": {
+    "lit": "^2.0.0 || ^3.0.0",
+    "signal-polyfill": "^0.2.2"
+  }
+}

+ 28 - 0
lib/vendor/@lit/context/v1.1.6/LICENSE

@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) 2021 Google LLC. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 206 - 0
lib/vendor/@lit/context/v1.1.6/README.md

@@ -0,0 +1,206 @@
+# @lit/context
+
+Make data available to entire component subtrees with the context protocol
+implementation for Lit.
+
+[![Build Status](https://github.com/lit/lit/workflows/Tests/badge.svg)](https://github.com/lit/lit/actions?query=workflow%3ATests)
+[![Published on npm](https://img.shields.io/npm/v/@lit/context.svg?logo=npm)](https://www.npmjs.com/package/@lit/context)
+[![Join our Discord](https://img.shields.io/badge/discord-join%20chat-5865F2.svg?logo=discord&logoColor=fff)](https://lit.dev/discord/)
+[![Mentioned in Awesome Lit](https://awesome.re/mentioned-badge.svg)](https://github.com/web-padawan/awesome-lit)
+
+## Documentation
+
+Full documentation is available at [lit.dev/docs/data/context/](https://lit.dev/docs/data/context/).
+
+## Overview
+
+This module defines an implementation of controllers and decorators for using the [Context Protocol](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md) as defined by the Web Components Community Group.
+
+This protocol facilitates the communication between components lower in the DOM hierarchy with their ancestors, allowing data to be passed down the tree without having to be passed via 'prop drilling' where each element in the path passes on the data.
+
+For further explanation of the Context Protocol please see the [community protocol documentation](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md).
+
+## Usage
+
+There are several different usages of the Context API.
+
+### Creating a Context
+
+First lets define a context key we can use elsewhere in our examples:
+
+#### **`logger.ts`**:
+
+```ts
+import {createContext} from '@lit/context';
+
+export interface Logger {
+  log: (msg: string) => void;
+}
+
+export const loggerContext = createContext<Logger>('logger');
+```
+
+### Consuming a Context
+
+Now we can define a consumer for this context - some component in our app needs the logger.
+
+Here we're using the `@consume` property decorator to make a `ContextConsumer` controller
+and update its value when the context changes:
+
+#### **`my-element.ts`**:
+
+```ts
+import {LitElement, property} from 'lit';
+import {consume} from '@lit/context';
+import {Logger, loggerContext} from './logger.js';
+
+export class MyElement extends LitElement {
+  @consume({context: loggerContext, subscribe: true})
+  @property({attribute: false})
+  public logger?: Logger;
+
+  private doThing() {
+    this.logger?.log('a thing was done');
+  }
+}
+```
+
+Another way we can use a context in a component is via the `ContextConsumer` controller directly:
+
+#### **`my-element.ts`**:
+
+```ts
+import {LitElement, property} from 'lit';
+import {ContextConsumer} from '@lit/context';
+import {Logger, loggerContext} from './logger.js';
+
+export class MyElement extends LitElement {
+  public logger = new ContextConsumer(
+    this,
+    loggerContext,
+    undefined, // don't need to pass a callback
+    true // pass true to get updates if the logger changes
+  );
+
+  private doThing() {
+    this.logger.value?.log('a thing was done');
+  }
+}
+```
+
+### Providing a Context
+
+Finally we want to be able to provide this context from somewhere higher in the DOM.
+
+Here we're using a `@provide` property decorator to make a `ContextProvider`
+controller and update its value when the property value changes.
+
+#### **`my-app.ts`**:
+
+```ts
+import {LitElement, property, html} from 'lit';
+import {provide} from '@lit/context';
+import {loggerContext, Logger} from './logger.js';
+
+export class MyApp extends LitElement {
+  @provide({context: loggerContext})
+  @property({attribute: false})
+  public logger: Logger = {
+    log: (msg) => {
+      console.log(`[my-app] ${msg}`);
+    },
+  };
+
+  protected render(): TemplateResult {
+    return html`<my-thing></my-thing>`;
+  }
+}
+```
+
+We can also use the `ContextProvider` controller directly:
+
+#### **`my-app.ts`**:
+
+```ts
+import {LitElement, html} from 'lit';
+import {ContextProvider} from '@lit/context';
+import {loggerContext, Logger} from './logger.js';
+
+export class MyApp extends LitElement {
+  // create a provider controller and a default logger
+  private provider = new ContextProvider(this, loggerContext, {
+    log: (msg) => {
+      console.log(`[my-app] ${msg}`);
+    },
+  });
+
+  protected render(): TemplateResult {
+    return html`<my-thing></my-thing>`;
+  }
+
+  public setLogger(newLogger: Logger) {
+    // update the provider with a new logger value
+    this.provider.setValue(newLogger);
+  }
+}
+```
+
+`ContextProvider` can also be used with plain HTML elements. This can be
+useful to provide a context provider without introducing a custom element:
+
+#### **`my-app.js`**:
+
+```js
+import {ContextProvider} from '@lit/context';
+import {loggerContext, Logger} from './logger.js';
+
+// create a provider for the whole document body.
+const loggingProvider = new ContextProvider(document.body, {
+    context: loggerContext,
+    initialValue: {
+      log: (msg) => {
+        console.log(`[global] ${msg}`);
+      },
+    },
+);
+```
+
+If the provider is being added when there is already a consumer registered with
+a parent of the specified element or with a `ContextRoot`, then
+`.hostConnected()` must be called on the provider after creating it. This
+ensures existing downstream consumers will now get their context values from the
+closest parent provider.
+
+## Known Issues
+
+### Late upgraded Context Providers
+
+In some cases you might have a context providing element that is upgraded late. LightDOM content below this provider may end up requesting a context that is currently not provided by any provider.
+
+To solve this case we provide a `ContextRoot` class which can intercept and track unsatisfied `context-request` events and then redispatch these requests when providers are updated.
+
+Example usage:
+
+#### **`index.ts`**:
+
+```ts
+import {ContextRoot} from '@lit/context';
+const root = new ContextRoot();
+root.attach(document.body);
+```
+
+The `ContextRoot` can be attached to any element and it will gather a list of any context requests which are received at the attached element. The `ContextProvider` controllers will emit `context-provider` events when they are connected to the DOM. These events act as triggers for the `ContextRoot` to redispatch these `context-request` events from their sources.
+
+This solution has a small overhead, in that if a provider is not within the DOM hierarchy of the unsatisfied requests we are unnecessarily refiring these requests, but this approach is safest and most correct in that it is very hard to manage unstable DOM hierarchies with the semantics of slotting and reparenting that is common in web components implementations.
+
+Note that ContextRoot uses [WeakRefs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef) which are not supported in IE11.
+
+### Protected / Private Properties
+
+You can use the `@consume` and `@provide` decorators on TypeScript `protected` and `private` properties, but be aware that there is no type checking between the type of the context and the type of the property. This is because the TypeScript compiler does not make type information for protected or private properties available to decorators. Standard `#private` properties are not supported at all.
+
+We expect to fix all of this when we switch to standard decorators. See [#3926](https://github.com/lit/lit/issues/3926).
+
+## Contributing
+
+Please see [CONTRIBUTING.md](../../CONTRIBUTING.md).

+ 13 - 0
lib/vendor/@lit/context/v1.1.6/index.d.ts

@@ -0,0 +1,13 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+export { ContextCallback, ContextRequestEvent as ContextEvent, } from './lib/context-request-event.js';
+export { Context, ContextKey, ContextType, createContext, } from './lib/create-context.js';
+export { ContextConsumer } from './lib/controllers/context-consumer.js';
+export { ContextProvider } from './lib/controllers/context-provider.js';
+export { ContextRoot } from './lib/context-root.js';
+export { provide } from './lib/decorators/provide.js';
+export { consume } from './lib/decorators/consume.js';
+//# sourceMappingURL=index.d.ts.map

+ 1 - 0
lib/vendor/@lit/context/v1.1.6/index.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,eAAe,EACf,mBAAmB,IAAI,YAAY,GACpC,MAAM,gCAAgC,CAAC;AAExC,OAAO,EACL,OAAO,EACP,UAAU,EACV,WAAW,EACX,aAAa,GACd,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EAAC,eAAe,EAAC,MAAM,uCAAuC,CAAC;AACtE,OAAO,EAAC,eAAe,EAAC,MAAM,uCAAuC,CAAC;AACtE,OAAO,EAAC,WAAW,EAAC,MAAM,uBAAuB,CAAC;AAElD,OAAO,EAAC,OAAO,EAAC,MAAM,6BAA6B,CAAC;AACpD,OAAO,EAAC,OAAO,EAAC,MAAM,6BAA6B,CAAC"}

+ 2 - 0
lib/vendor/@lit/context/v1.1.6/index.js

@@ -0,0 +1,2 @@
+export{ContextRequestEvent as ContextEvent}from"./lib/context-request-event.js";export{createContext}from"./lib/create-context.js";export{ContextConsumer}from"./lib/controllers/context-consumer.js";export{ContextProvider}from"./lib/controllers/context-provider.js";export{ContextRoot}from"./lib/context-root.js";export{provide}from"./lib/decorators/provide.js";export{consume}from"./lib/decorators/consume.js";
+//# sourceMappingURL=index.js.map

+ 1 - 0
lib/vendor/@lit/context/v1.1.6/index.js.map

@@ -0,0 +1 @@
+{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":""}

+ 57 - 0
lib/vendor/@lit/context/v1.1.6/lib/context-request-event.d.ts

@@ -0,0 +1,57 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+import { ContextType, Context } from './create-context.js';
+declare global {
+    interface HTMLElementEventMap {
+        /**
+         * A 'context-request' event can be emitted by any element which desires
+         * a context value to be injected by an external provider.
+         */
+        'context-request': ContextRequestEvent<Context<unknown, unknown>>;
+    }
+}
+/**
+ * A callback which is provided by a context requester and is called with the value satisfying the request.
+ * This callback can be called multiple times by context providers as the requested value is changed.
+ */
+export type ContextCallback<ValueType> = (value: ValueType, unsubscribe?: () => void) => void;
+/**
+ * Interface definition for a ContextRequest
+ */
+export interface ContextRequest<C extends Context<unknown, unknown>> {
+    readonly context: C;
+    readonly contextTarget: Element;
+    readonly callback: ContextCallback<ContextType<C>>;
+    readonly subscribe?: boolean;
+}
+/**
+ * An event fired by a context requester to signal it desires a specified context with the given key.
+ *
+ * A provider should inspect the `context` property of the event to determine if it has a value that can
+ * satisfy the request, calling the `callback` with the requested value if so.
+ *
+ * If the requested context event contains a truthy `subscribe` value, then a provider can call the callback
+ * multiple times if the value is changed, if this is the case the provider should pass an `unsubscribe`
+ * method to the callback which consumers can invoke to indicate they no longer wish to receive these updates.
+ *
+ * If no `subscribe` value is present in the event, then the provider can assume that this is a 'one time'
+ * request for the context and can therefore not track the consumer.
+ */
+export declare class ContextRequestEvent<C extends Context<unknown, unknown>> extends Event implements ContextRequest<C> {
+    readonly context: C;
+    readonly contextTarget: Element;
+    readonly callback: ContextCallback<ContextType<C>>;
+    readonly subscribe?: boolean;
+    /**
+     *
+     * @param context the context key to request
+     * @param contextTarget the original context target of the requester
+     * @param callback the callback that should be invoked when the context with the specified key is available
+     * @param subscribe when, true indicates we want to subscribe to future updates
+     */
+    constructor(context: C, contextTarget: Element, callback: ContextCallback<ContextType<C>>, subscribe?: boolean);
+}
+//# sourceMappingURL=context-request-event.d.ts.map

+ 1 - 0
lib/vendor/@lit/context/v1.1.6/lib/context-request-event.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"context-request-event.d.ts","sourceRoot":"","sources":["../../src/lib/context-request-event.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAC,WAAW,EAAE,OAAO,EAAC,MAAM,qBAAqB,CAAC;AAEzD,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,mBAAmB;QAC3B;;;WAGG;QACH,iBAAiB,EAAE,mBAAmB,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;KACnE;CACF;AAED;;;GAGG;AACH,MAAM,MAAM,eAAe,CAAC,SAAS,IAAI,CACvC,KAAK,EAAE,SAAS,EAChB,WAAW,CAAC,EAAE,MAAM,IAAI,KACrB,IAAI,CAAC;AAEV;;GAEG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC;IACjE,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;IACpB,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;IACnD,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED;;;;;;;;;;;;GAYG;AACH,qBAAa,mBAAmB,CAAC,CAAC,SAAS,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAClE,SAAQ,KACR,YAAW,cAAc,CAAC,CAAC,CAAC;IAE5B,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;IACpB,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC;IACnD,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAE7B;;;;;;OAMG;gBAED,OAAO,EAAE,CAAC,EACV,aAAa,EAAE,OAAO,EACtB,QAAQ,EAAE,eAAe,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EACzC,SAAS,CAAC,EAAE,OAAO;CAQtB"}

+ 7 - 0
lib/vendor/@lit/context/v1.1.6/lib/context-request-event.js

@@ -0,0 +1,7 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+class s extends Event{constructor(s,t,e,o){super("context-request",{bubbles:!0,composed:!0}),this.context=s,this.contextTarget=t,this.callback=e,this.subscribe=o??!1}}export{s as ContextRequestEvent};
+//# sourceMappingURL=context-request-event.js.map

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
lib/vendor/@lit/context/v1.1.6/lib/context-request-event.js.map


+ 32 - 0
lib/vendor/@lit/context/v1.1.6/lib/context-root.d.ts

@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+/**
+ * A ContextRoot can be used to gather unsatisfied context requests and
+ * re-dispatch them when new providers which satisfy matching context keys are
+ * available.
+ *
+ * This allows providers to be added to a DOM tree, or upgraded, after the
+ * consumers.
+ */
+export declare class ContextRoot {
+    private pendingContextRequests;
+    /**
+     * Attach the ContextRoot to a given element to intercept `context-request` and
+     * `context-provider` events.
+     *
+     * @param element an element to add event listeners to
+     */
+    attach(element: HTMLElement): void;
+    /**
+     * Removes the ContextRoot event listeners from a given element.
+     *
+     * @param element an element from which to remove event listeners
+     */
+    detach(element: HTMLElement): void;
+    private onContextProvider;
+    private onContextRequest;
+}
+//# sourceMappingURL=context-root.d.ts.map

+ 1 - 0
lib/vendor/@lit/context/v1.1.6/lib/context-root.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"context-root.d.ts","sourceRoot":"","sources":["../../src/lib/context-root.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH;;;;;;;GAOG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,sBAAsB,CAgB1B;IAEJ;;;;;OAKG;IACH,MAAM,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI;IAKlC;;;;OAIG;IACH,MAAM,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI;IAKlC,OAAO,CAAC,iBAAiB,CA4BvB;IAEF,OAAO,CAAC,gBAAgB,CA4CtB;CACH"}

+ 7 - 0
lib/vendor/@lit/context/v1.1.6/lib/context-root.js

@@ -0,0 +1,7 @@
+import{ContextRequestEvent as e}from"./context-request-event.js";
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */class t{constructor(){this.pendingContextRequests=new Map,this.onContextProvider=t=>{const s=this.pendingContextRequests.get(t.context);if(void 0===s)return;this.pendingContextRequests.delete(t.context);const{requests:o}=s;for(const{elementRef:s,callbackRef:n}of o){const o=s.deref(),c=n.deref();void 0===o||void 0===c||o.dispatchEvent(new e(t.context,o,c,!0))}},this.onContextRequest=e=>{if(!0!==e.subscribe)return;const t=e.contextTarget??e.composedPath()[0],s=e.callback;let o=this.pendingContextRequests.get(e.context);void 0===o&&this.pendingContextRequests.set(e.context,o={callbacks:new WeakMap,requests:[]});let n=o.callbacks.get(t);void 0===n&&o.callbacks.set(t,n=new WeakSet),n.has(s)||(n.add(s),o.requests.push({elementRef:new WeakRef(t),callbackRef:new WeakRef(s)}))}}attach(e){e.addEventListener("context-request",this.onContextRequest),e.addEventListener("context-provider",this.onContextProvider)}detach(e){e.removeEventListener("context-request",this.onContextRequest),e.removeEventListener("context-provider",this.onContextProvider)}}export{t as ContextRoot};
+//# sourceMappingURL=context-root.js.map

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
lib/vendor/@lit/context/v1.1.6/lib/context-root.js.map


+ 41 - 0
lib/vendor/@lit/context/v1.1.6/lib/controllers/context-consumer.d.ts

@@ -0,0 +1,41 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+import type { Context, ContextType } from '../create-context.js';
+import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
+export interface Options<C extends Context<unknown, unknown>> {
+    context: C;
+    callback?: (value: ContextType<C>, dispose?: () => void) => void;
+    subscribe?: boolean;
+}
+/**
+ * A ReactiveController which adds context consuming behavior to a custom
+ * element by dispatching `context-request` events.
+ *
+ * When the host element is connected to the document it will emit a
+ * `context-request` event with its context key. When the context request
+ * is satisfied the controller will invoke the callback, if present, and
+ * trigger a host update so it can respond to the new value.
+ *
+ * It will also call the dispose method given by the provider when the
+ * host element is disconnected.
+ */
+export declare class ContextConsumer<C extends Context<unknown, unknown>, HostElement extends ReactiveControllerHost & HTMLElement> implements ReactiveController {
+    protected host: HostElement;
+    private context;
+    private callback?;
+    private subscribe;
+    private provided;
+    value?: ContextType<C>;
+    constructor(host: HostElement, options: Options<C>);
+    /** @deprecated Use new ContextConsumer(host, options) */
+    constructor(host: HostElement, context: C, callback?: (value: ContextType<C>, dispose?: () => void) => void, subscribe?: boolean);
+    private unsubscribe?;
+    hostConnected(): void;
+    hostDisconnected(): void;
+    private dispatchRequest;
+    private _callback;
+}
+//# sourceMappingURL=context-consumer.d.ts.map

+ 1 - 0
lib/vendor/@lit/context/v1.1.6/lib/controllers/context-consumer.d.ts.map

@@ -0,0 +1 @@
+{"version":3,"file":"context-consumer.d.ts","sourceRoot":"","sources":["../../../src/lib/controllers/context-consumer.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAMH,OAAO,KAAK,EAAC,OAAO,EAAE,WAAW,EAAC,MAAM,sBAAsB,CAAC;AAC/D,OAAO,KAAK,EACV,kBAAkB,EAClB,sBAAsB,EACvB,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,OAAO,CAAC,CAAC,SAAS,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1D,OAAO,EAAE,CAAC,CAAC;IACX,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;IACjE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,eAAe,CAC1B,CAAC,SAAS,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,EACnC,WAAW,SAAS,sBAAsB,GAAG,WAAW,CACxD,YAAW,kBAAkB;IAE7B,SAAS,CAAC,IAAI,EAAE,WAAW,CAAC;IAC5B,OAAO,CAAC,OAAO,CAAI;IACnB,OAAO,CAAC,QAAQ,CAAC,CAAwD;IACzE,OAAO,CAAC,SAAS,CAAS;IAE1B,OAAO,CAAC,QAAQ,CAAS;IAEzB,KAAK,CAAC,EAAE,WAAW,CAAC,CAAC,CAAC,CAAa;gBAEvB,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;IAClD,yDAAyD;gBAEvD,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,CAAC,EACV,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,IAAI,KAAK,IAAI,EAChE,SAAS,CAAC,EAAE,OAAO;IAwBrB,OAAO,CAAC,WAAW,CAAC,CAAa;IAEjC,aAAa,IAAI,IAAI;IAIrB,gBAAgB,IAAI,IAAI;IAOxB,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,SAAS,CA8Bf;CACH"}

+ 7 - 0
lib/vendor/@lit/context/v1.1.6/lib/controllers/context-consumer.js

@@ -0,0 +1,7 @@
+import{ContextRequestEvent as t}from"../context-request-event.js";
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */class s{constructor(t,s,i,h){if(this.subscribe=!1,this.provided=!1,this.value=void 0,this.t=(t,s)=>{this.unsubscribe&&(this.unsubscribe!==s&&(this.provided=!1,this.unsubscribe()),this.subscribe||this.unsubscribe()),this.value=t,this.host.requestUpdate(),this.provided&&!this.subscribe||(this.provided=!0,this.callback&&this.callback(t,s)),this.unsubscribe=s},this.host=t,void 0!==s.context){const t=s;this.context=t.context,this.callback=t.callback,this.subscribe=t.subscribe??!1}else this.context=s,this.callback=i,this.subscribe=h??!1;this.host.addController(this)}hostConnected(){this.dispatchRequest()}hostDisconnected(){this.unsubscribe&&(this.unsubscribe(),this.unsubscribe=void 0)}dispatchRequest(){this.host.dispatchEvent(new t(this.context,this.host,this.t,this.subscribe))}}export{s as ContextConsumer};
+//# sourceMappingURL=context-consumer.js.map

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
lib/vendor/@lit/context/v1.1.6/lib/controllers/context-consumer.js.map


Некоторые файлы не были показаны из-за большого количества измененных файлов