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

feat(board): add industry library and improve board interactions

Add filtered industry-library browsing, product details, and read-only database integration. Improve member group management, centered detail dialogs, reports, routing, and responsive layouts.
pengzhikang 6 часов назад
Родитель
Сommit
bb38e3732e
85 измененных файлов с 14096 добавлено и 545 удалено
  1. 14 0
      .gitignore
  2. 9 1
      README.md
  3. 107 0
      docs/industry-library-20261105.md
  4. 13 0
      docs/local-preview.md
  5. 344 0
      scripts/verify-library-browser.py
  6. 203 0
      scripts/verify-member-groups-browser.py
  7. 164 0
      scripts/verify-opportunity-links.mjs
  8. 154 0
      scripts/verify-unified-preview-browser.py
  9. 44 0
      server/library-api.mjs
  10. 7281 0
      server/references/runtime-data.json
  11. 21 0
      server/scripts/build-member-groups.mjs
  12. 52 0
      server/src/board-actions.mjs
  13. 40 0
      server/src/bootstrap.mjs
  14. 30 0
      server/src/confirmation.mjs
  15. 331 0
      server/src/core.mjs
  16. 60 0
      server/src/files.mjs
  17. 61 0
      server/src/member-groups.mjs
  18. 62 0
      server/src/postgres.mjs
  19. 223 0
      server/src/workshop.mjs
  20. 67 0
      server/test/board-actions.test.mjs
  21. 65 0
      server/test/member-groups.test.mjs
  22. 176 0
      server/voc-library/board_library.py
  23. 11 0
      server/voc-library/query_voc.py
  24. 359 0
      server/voc-library/retrieval.py
  25. 64 0
      server/voc-library/schema.sql
  26. 48 0
      server/voc-library/taxonomy.json
  27. 138 0
      server/voc-library/test_board_library.py
  28. 144 0
      server/voc-library/test_retrieval.py
  29. 169 0
      server/voc-library/test_voc_db.py
  30. 177 0
      server/voc-library/update_library.py
  31. 75 0
      server/voc-library/verify_corpus.py
  32. 83 0
      server/voc-library/verify_retrieval.py
  33. 766 0
      server/voc-library/voc_db.py
  34. 14 0
      server/web/confirm.html
  35. 3 0
      src/app.config.js
  36. 2 0
      src/components/icons.js
  37. 68 30
      src/components/kf-drawer.js
  38. 3 3
      src/components/kf-empty.js
  39. 14 5
      src/components/kf-evidence-chain.js
  40. 1 1
      src/components/kf-glass-card.js
  41. 3 7
      src/components/kf-opportunity-card.js
  42. 39 0
      src/components/kf-source-detail.js
  43. 9 6
      src/components/kf-viz.js
  44. 1 0
      src/core/evidence/chain.js
  45. 31 26
      src/core/kfs/build.js
  46. 51 0
      src/core/kfs/employee.js
  47. 69 0
      src/core/library.js
  48. 11 2
      src/core/library/reports.js
  49. 94 0
      src/core/member-groups.js
  50. 1 0
      src/core/opportunity/opportunity.js
  51. 15 1
      src/core/report.js
  52. 52 0
      src/dev/member-groups.mjs
  53. 31 2
      src/dev/serve.mjs
  54. 60 0
      src/layout/detail-styles.js
  55. 168 111
      src/layout/kf-app.js
  56. 8 5
      src/layout/kf-nav.js
  57. 7 18
      src/layout/kf-topbar.js
  58. 2 1
      src/pages/card-toolbar.js
  59. 118 0
      src/pages/library-product-detail.js
  60. 228 0
      src/pages/library-styles.js
  61. 21 13
      src/pages/report-card.js
  62. 28 0
      src/pages/report-pager.js
  63. 50 29
      src/pages/view-ledger.js
  64. 368 0
      src/pages/view-library.js
  65. 318 118
      src/pages/view-members.js
  66. 7 4
      src/pages/view-pool.js
  67. 16 45
      src/pages/view-report-type.js
  68. 116 41
      src/pages/view-report.js
  69. 17 24
      src/pages/view-reports.js
  70. 3 2
      src/pages/view-stages.js
  71. 5 4
      src/pages/view-workbench.js
  72. 25 16
      src/services/board-service.js
  73. 8 0
      src/services/library-service.js
  74. 14 0
      src/services/member-groups.js
  75. 15 1
      src/services/workshop-data.js
  76. 9 13
      src/styles/tokens.js
  77. 9 2
      src/utils/card-view.js
  78. 2 0
      src/utils/http.js
  79. 77 14
      src/utils/router.js
  80. 29 0
      test/card-view.test.js
  81. 45 0
      test/library.test.js
  82. 63 0
      test/member-groups.test.js
  83. 24 0
      test/report.test.js
  84. 157 0
      test/router.test.js
  85. 12 0
      test/showcase.test.js

+ 14 - 0
.gitignore

@@ -30,6 +30,20 @@ docs/screenshots/**/*.png
 .DS_Store
 .vscode/
 .idea/
+work/
+server/dist/
+server/evidence/
+skills/ksf-workshop-runtime/credential-binding.json
+
+# 本地研究数据库与 Python 缓存
+__pycache__/
+*.pyc
+*.sqlite
+*.sqlite-shm
+*.sqlite-wal
+*.db
+*.db-shm
+*.db-wal
 *.swp
 docs/v15-before.json
 .v15-base/

+ 9 - 1
README.md

@@ -1,4 +1,12 @@
-# 康师傅产品创新看板 · v6(展示逻辑重构:五类报告页 + 阶段产出 + 液态玻璃)
+# 康师傅产品创新看板
+
+## 当前运行与功能(2026-11-06)
+
+在仓库根目录执行 `npm run serve`,统一访问 http://127.0.0.1:4181/ 。当前包含工作台、机会卡、阶段产出、报告、证据台账、成员分组弹窗及行业资料库;详情使用居中窗口,报告支持独立阅读和深链接。
+
+行业资料库以商品为入口,在商品内查看规格、价格、评价与榜单线索;页面仅保留筛选,分类横向排列、概览默认收起。接口读取独立 SQLite 研究库,需要 Python 3.11+ 与 SQLite FTS5。数据库及原始采集包通过私有交付配置,保留在 Git 之外;可通过 `KSF_LIBRARY_REGISTRY` 或 `KSF_VOC_DB` 指定数据库,通过 `KSF_LIBRARY_PYTHON` 指定 Python。生产接入需要服务端授权配置,推送代码不会自动部署。
+
+启动说明见 [本地预览](docs/local-preview.md),资料接入与更新见 [行业资料库](docs/industry-library-20261105.md)。以下保留早期 v6 设计与验证记录。
 
 > 研发中心 AI 赋能实战营的**组织资产系统**:30–40 位学员共用一个机会卡池、一份证据台账、一组分析报告。
 > 本版按 `fmode-code-lit` 规范**重写骨架**,并把前四轮谈定的业务全部落进来 —— 是「用新规范做出旧需求」,不是缩水重做。

+ 107 - 0
docs/industry-library-20261105.md

@@ -0,0 +1,107 @@
+# 行业资料库:页面、接入与后续更新
+
+新增入口 `/library`,详情深链接 `/library/:entity/:id`。本地预览 http://127.0.0.1:4181/library。
+
+## 已实现
+
+- 主目录按商品研究、消费洞察、政策与膳食依据、研究沉淀组织,分别进入商品、社媒、政策文件和已有成果。
+- 商品是浏览单位:规格、价格、消费者评价和榜单线索收进同一商品详情;政策条款从所属文件进入。底层仍保留九类结构实体和独立来源记录,已有成果来自现有工作坊公开业务投影,跳回原报告页面。
+- 品类父子目录;平台、品牌、品类、店铺、时间、质量口径等联动筛选;条件同时满足。切换资料类型会显式进入该类型的全范围,不在后台自动放宽当前请求。
+- 全库概览与当前结果分开;分面分布受到其他筛选维度约束,不受本维度限制,图表注明口径。分面最多100项、图表前5项,不冒称完整目录;商品和评价列表完整游标分页。
+- 商品详情内查看关联资料,政策详情阅读全文、条款摘录和应用提示,并可打开原始来源。分类规则、内部编号、校验指纹和文件定位不在阅读界面展示,后台仍保留完整核查信息。
+- 查询失败、未连接、零命中分开显示;路由保留筛选和页游标;检测数据版本变化后自动更新,并将旧版游标退回第一页。
+- 原有采集 ZIP、r2 交付包及生产业务记录未修改。页面本地读取真实研究库,不把它当作已部署的生产接口。
+
+## 浏览体验更新(2026-11-05)
+
+- 资料结果前置:压缩全库概览,平台/品牌/品类与查看口径优先显示;店铺、日期等收在“更多筛选”,当前值始终展示为可单独移除的标签。移除一个条件会保留其他条件并回到第一页,不自动放宽请求。
+- 卡片/紧凑列表切换;覆盖统计按需展开,点击分布项仍使用原有精确过滤。视图切换与统计展开不额外发起查询。
+- 商品列表展示已入库评价、规格和价格数量;点入商品,在“商品概况/包装规格/价格记录/消费者评价/榜单线索”间切换。价格直接展示人民币观察价及口径,规格仅展示已有结构化字段,不从标题推算克重。政策、社媒保留各自的阅读形式。
+- 评价在当前商品内按日期及查看范围筛选,并完整游标翻页,长评价与追加评价可展开。关联查询始终固定精确商品ID,不按标题或同名商品归并;未知日期不会用采集日补齐。
+- 商品栏目、栏目筛选和分页均可保存为深链接,刷新仍停留原商品。返回商品列表保留原列表筛选和页游标,移除栏目内部参数。资料版本更新时清理旧游标,同时保持当前商品和栏目。旧的按商品查看评价/价格等链接会转入商品内对应栏目。
+- 阅读页优先展示原文、文件全文、摘录与应用提示;侧栏支持章节定位和资料信息。平台、店铺、时间等字段统一放在“资料信息”;“资料来源”只保留原始页面与资料阅读链接,不重复字段或关联商品标题。引用复制为标题、业务信息及阅读链接。
+- 游标分页增加已访问页的“上一页”,刷新后的深链接仍可回到第一页;详情返回保留原筛选与游标。无结果时提供一键清空,连接失败继续明确区分。
+- 手机端概览默认收起,目录改为类型选择,资料信息可展开,优先保留正文阅读空间。已检查1440/1024/390/320宽度布局。
+- 本次修改本地前端和只读展示投影,复用既有 browse/detail 接口;不改数据库结构、Agent查询合同或已入库资料,未发布生产。
+- 员工页面不展示本地/生产连接状态、资料入库更新时间、更新记录和后台自动更新通知;分类规则的悬浮说明及入库/业务库等内部措辞一并移除。后台版本检测、刷新和来源核查继续工作;资料自身的观察日期、政策生效日期和价格口径保留。
+
+## 紧凑筛选布局(2026-11-06)
+
+- 取消资料、商品评价和研究成果的文本搜索入口。旧搜索链接自动移除关键词及其绑定的页游标,保留显式筛选条件,避免出现看不见的搜索约束。Agent的全文检索合同和服务端能力继续保留。
+- 桌面将左侧目录改为顶部四类导航,释放完整内容宽度;手机使用分类下拉选择。删除重复的分类标题和导读区,概览收进页头按钮,所有宽度均默认收起。
+- 平台、品牌、品类和查看口径集中在紧凑的一行;更多筛选收纳店铺和日期,已选条件仍可单独移除。研究成果改用阶段与成员筛选,成员按现有稳定成员ID匹配。
+- 商品卡片按可用宽度自动排布,常见桌面三至四列、平板两列、手机单列。缩小卡片、筛选和详情间距,保留正文可读性和分页;不变更数据库或生产配置。
+
+## 阅读界面信息边界
+
+- 页面按资料类型选择业务字段,不直接遍历数据库全部列:商品展示品牌、品类、店铺;评价展示原文、购买规格、评分与日期;价格展示人民币元金额、价格口径和观察时间;政策展示状态与生效日期。未知字段不会自动进入页面。
+- 分类依据、规则代码、问题代码、技术编号、JSONL行号、校验指纹、处理版本及压缩包名称留给后台核查,不在列表、详情、更新记录或复制引用中展示。校验指纹用于判断文件内容是否改变,属于运维核验信息。
+- 保留影响研究判断的业务说明:政策状态截至日期、摘要与法规原文的区别、价格口径、资料覆盖与时间未知。移除内部字段不代表对资料完整性或真实性作额外承诺。
+- 看板只读投影中的商品摘要改为店铺信息,并补齐条款对应的政策标题和原始链接。数据库原件、Agent查询规则与内部来源指纹保持原样。
+
+## 架构与授权
+
+页面 → 同源 `POST /api/library` → Node只读服务 → Python `board_library.py` → 当前代际 SQLite。
+
+浏览器只允许 summary / browse / detail / version,复用 Agent 的 retrieval.prepare/search/aggregate,禁止传 SQL、磁盘路径和写命令。Node服务并发上限4、输入24KB、超时25秒,读取数据库时使用 mode=ro、immutable 和 query_only。
+
+本地 src/dev/serve.mjs 仅监听127.0.0.1,接口以 localPreview=true 标识预览环境,员工页面不显示环境与连接状态。正式服务 `server/library-api.mjs` 仅监听127.0.0.1,启动要求 KSF_LIBRARY_API_TOKEN;生产默认拒绝匿名直连。
+
+生产同源网关必须先校验当前访问者的资料授权,再在服务端转发 Bearer;不得把令牌放前端、URL或静态文件,不应对全互联网无条件注入令牌。项目当前没有已经核实的资料库网关身份接入,因此本次没有修改生产网关或公开研究底料。服务通过 KSF_LIBRARY_REGISTRY 指向私有代际目录,KSF_LIBRARY_PYTHON 指向Python3。无需新增Parse云函数。
+
+## 首次接入
+
+在受控宿主准备私有目录(目录0700、文件0600、运行用户属主),复用已经核验的r2包中的 library/library.sqlite。以下路径为宿主示例,不在聊天中传任何凭据。
+
+```sh
+python3 -B server/voc-library/update_library.py \
+  --incoming /private/r2/library/library.sqlite --registry /private/industry-library --label '首批资料接入'
+
+# 上一步为只读计划;核对后应用,保存stdout作为回执。
+python3 -B server/voc-library/update_library.py \
+  --incoming /private/r2/library/library.sqlite --registry /private/industry-library --label '首批资料接入' --apply
+```
+
+目录 current.json 是原子切换指针,versions/ 保存不可变数据库;不是把SQLite放到站点静态目录。
+
+## 后续新增资料
+
+1. 保留采集原件,先用现有受控导入器将新批次构建为独立候选数据库;新格式首次补字段映射,新数据类型首次补结构。不能把任意文件改扩展名就导入。
+2. `update_library.py --incoming <候选.sqlite> --registry <目录>` 输出只读计划,核结构、完整性、外键和检索投影数量。
+3. 加 `--apply` 合并到一个新代际:已有商品/评价等沿稳定ID关联,相同内容复用;发生业务字段变更时将旧/新记录存入 record_versions,旧代际仍保留。新价格观察不覆盖旧观察。来源路径或来源ID发生冲突时重映射并保留原批次指纹;字典同code定义冲突直接停止。
+4. 新代际重建全文与字段索引、校验后原子切换 current.json。导入异常时不切换;同一已导入代际再次提交返回 already-imported。变更回执含各表新增/变更/重复数,更新记录保留在后台供维护核查,员工页面不展示。
+5. 页面每30秒核版本,可手动刷新。Agent通过更新后的 query_voc.py/voc_db.py 使用相同 `--registry` 或 `KSF_LIBRARY_REGISTRY`,每次查询解析同一个指针;尚未更新脚本的旧安装版不能直接声称已同步。发布查询模块时同步更新其清单,保留旧Skill备份,不能改冻结r2压缩包。
+
+```sh
+python3 -B server/voc-library/update_library.py --incoming /private/new/library.sqlite \
+  --registry /private/industry-library --label '第二批京东商品资料' --apply
+
+python3 -B server/voc-library/query_voc.py contract --registry /private/industry-library
+
+# 回退也先不带 --apply 核对;只切换指针,不删除任一代际。
+python3 -B server/voc-library/update_library.py --registry /private/industry-library \
+  --rollback <旧generation> --apply
+```
+
+合并功能针对当前v2九类结构。新格式需要字段映射;新实体类型需要版本升级。评价ID不是独立消费者ID,社媒评论数不是已采评论数,榜单标识不是完整Top100/400。未知时间不能用入库日补,政策状态只是截至原资料日期的声明。
+
+## 发布文件范围
+
+前端:src/pages/view-library.js、src/pages/library-product-detail.js、src/pages/library-styles.js、src/core/library.js、src/services/library-service.js、src/app.config.js、src/layout/kf-app.js、src/layout/kf-nav.js、src/utils/router.js、src/pages/view-workbench.js、src/components/icons.js。
+
+服务端:server/library-api.mjs;server/voc-library/board_library.py、update_library.py、voc_db.py、retrieval.py、schema.sql、taxonomy.json、query_voc.py。dev/serve仅本地用,不作为生产服务。
+
+正式静态发布前需先核对同名文件现网版本,合并并保留现网员工称呼、机会卡和成员分组等既有功能,不能用整个旧站点覆盖。后端接通并验收授权后再发布前端入口;保留原文件备份可回退。
+
+## 本地验证
+
+```sh
+node scripts/check.mjs
+node --test test/*.test.js server/test/member-groups.test.mjs server/test/board-actions.test.mjs
+cd server/voc-library
+python3 -B -m unittest test_board_library test_retrieval test_voc_db -v
+```
+
+`test_retrieval` 与 `test_voc_db` 使用测试生成的数据。`test_board_library` 需要受控交付的真实研究库,可通过环境变量 `KSF_TEST_VOC_DB` 指定其绝对路径;缺少该库时会明确跳过,不将私有数据库放入Git。
+
+浏览器验收 scripts/verify-library-browser.py;证据位于 work/library-preview/browser-validation.json 与截图。同一组真实数据下“京东+康师傅+拌面”命中96个商品。测试单独构建的新增样本仅位于临时目录,不进入本地展示库或生产。

+ 13 - 0
docs/local-preview.md

@@ -0,0 +1,13 @@
+# 本地统一预览
+
+唯一入口:http://127.0.0.1:4181/ 。在 `F:\kangshifu-board` 执行 `npm run serve`,默认使用 4181;已有服务时复用该入口。
+
+全部页面从主目录的 `src/` 加载,后续前端修改均在主目录进行。`work/five-views-delivery-20261103` 是历史交付工作区,不再启动为另一个预览版本。当前应用只提供实际看板,包含居中详情、报告分页、资料库和分组管理弹窗。
+
+- 工作看板:`/`、`/pool`、`/reports`、`/ledger`、`/members`。
+- 真实研究资料库:`/library`,读取本机已入库结构数据,更新仍沿用资料库版本注册表。
+- “方案演示”与五个专题模拟页面已从导航、路由和应用加载中移除。旧的 `/showcase`(含介绍子页)、`/cockpit`、`/intel`、`/coop`、`/review`、`/class` 链接自动返回工作台 `/`。
+- 成员分组的本地修改共用 `work/member-groups-preview/state.json`,不会写入生产分组。
+- 只读 `/__dev/info` 可核对当前服务的目录和端口。
+
+旧端口 4185、4177 已停用。访问统一入口后刷新旧标签页;不同端口的浏览器本地偏好不会自动共享。

+ 344 - 0
scripts/verify-library-browser.py

@@ -0,0 +1,344 @@
+"""Read-only browser acceptance against the real local industry library."""
+import argparse
+import base64
+import json
+from pathlib import Path
+import subprocess
+import tempfile
+import time
+from urllib.request import urlopen
+import websocket
+
+ROOT=Path(__file__).resolve().parents[1]
+WORK=ROOT/'work/library-preview'
+WORK.mkdir(parents=True,exist_ok=True)
+parser=argparse.ArgumentParser();parser.add_argument('--url',default='http://127.0.0.1:4181/library');args=parser.parse_args()
+profile=Path(tempfile.mkdtemp(prefix='browser-',dir=WORK))
+process=subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe','--headless=new','--disable-gpu',
+ '--remote-debugging-port=0','--disable-background-networking','--disable-component-update','--disable-sync','--disable-extensions',
+ '--no-first-run','--no-default-browser-check','--user-data-dir='+str(profile),'about:blank'],
+ stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,creationflags=subprocess.CREATE_NO_WINDOW)
+serial=0;ws=None;events=[]
+try:
+    deadline=time.monotonic()+20
+    while not (profile/'DevToolsActivePort').exists():
+        assert time.monotonic()<deadline and process.poll() is None
+        time.sleep(.1)
+    port=(profile/'DevToolsActivePort').read_text().splitlines()[0]
+    page=next(x for x in json.load(urlopen('http://127.0.0.1:'+port+'/json/list',timeout=5)) if x['type']=='page')
+    ws=websocket.create_connection(page['webSocketDebuggerUrl'],timeout=35,suppress_origin=True,http_no_proxy=['127.0.0.1','localhost'])
+    def call(method,params=None):
+        global serial
+        serial+=1;rid=serial;ws.send(json.dumps(dict(id=rid,method=method,params=params or {})))
+        while True:
+            response=json.loads(ws.recv())
+            if response.get('id')==rid:
+                assert 'error' not in response,response
+                return response.get('result',{})
+            events.append(response)
+    def evaluate(expression,promise=False):
+        response=call('Runtime.evaluate',dict(expression=expression,returnByValue=True,awaitPromise=promise))
+        assert 'exceptionDetails' not in response,response
+        return response['result'].get('value')
+    def wait(expression,timeout=25):
+        evaluate('new Promise((resolve,reject)=>{const end=Date.now()+'+str(timeout*1000)+';const tick=()=>{try{if('+expression+'){resolve(true);return;}}catch{}if(Date.now()>end)reject(new Error("Timed out: "+'+json.dumps(expression)+'));else setTimeout(tick,60)};tick()})',True)
+    def action(body):
+        return evaluate('(()=>{const v=document.querySelector("kf-app").shadowRoot.querySelector("view-library");'+body+'})()')
+    V='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-library")'
+    P=V+'.renderRoot.querySelector("library-product-detail")'
+    def product_action(body):
+        return evaluate('(()=>{const p='+P+';'+body+'})()')
+    def product_ready():wait(P+' && !'+P+'._loading && ('+P+'.panel==="overview" || '+P+'._data)')
+    def product_tab(panel):
+        product_action('p.renderRoot.querySelector("#tab-'+panel+'").click();')
+        wait(P+'.panel==='+json.dumps(panel)+' && !'+P+'._loading && ('+P+'.panel==="overview" || '+P+'._data)')
+    def ready():wait(V+'?._summary && !'+V+'?._loading && ('+V+'?._data || '+V+'?._detail || '+V+'?._type==="reports")')
+    def shot(name):
+        image=call('Page.captureScreenshot',dict(format='png',captureBeyondViewport=False))
+        (WORK/(name+'.png')).write_bytes(base64.b64decode(image['data']))
+    def reader_surface():
+        text=action('return v.renderRoot.textContent+"\\n"+Array.from(v.renderRoot.querySelectorAll("[title]")).map(el=>el.title).join("\\n");')
+        forbidden=['本地资料库','资料库已连接','资料更新','更新记录','已入库','业务库','元数据','分类依据','品牌依据','品类依据','分类状态','待核对规则','SHA256','校验指纹','平台接口族','来源编号','来源文件','JSONL','数据版本','rule_title_','source_attribute']
+        assert not any(token in text for token in forbidden),text[:1500]
+        assert not action('return !!v.renderRoot.querySelector(".version,.updates,.fingerprints,.record-number");')
+        assert not action('return !!v.renderRoot.querySelector("input:not([type=date]),form.search");')
+        # Facts belong to the information panel; source actions must not repeat them.
+        assert not action('return !!v.renderRoot.querySelector(".source .metadata,.source .source-title");')
+        labels=action('return Array.from(v.renderRoot.querySelectorAll(".detail-grid .metadata dt")).map(el=>el.textContent);')
+        assert len(labels)==len(set(labels)),labels
+    call('Page.enable');call('Runtime.enable');call('Network.enable')
+    call('Emulation.setDeviceMetricsOverride',dict(width=1440,height=1100,deviceScaleFactor=1,mobile=False))
+    call('Page.navigate',dict(url=args.url));ready()
+    assert action('return v._summary.counts.products;')==4639
+    assert action('return v._data.totalMatched;')==4639
+    assert action('return Array.from(v.renderRoot.querySelectorAll(".tree-link")).map(a=>new URL(a.href).searchParams.get("type"));')==['products','social','documents','reports']
+    assert action('return Array.from(v.renderRoot.querySelectorAll(".stat")).map(a=>new URL(a.href).searchParams.get("type"));')==['products','social','documents','reports']
+    reader_surface()
+    assert action('return v.renderRoot.getElementById("advanced-filters").hidden;')
+    assert action('return v.renderRoot.getElementById("coverage-insights").hidden;')
+    assert action('return v.renderRoot.querySelector("a[data-record-id]").getBoundingClientRect().top;')<350
+    assert action('return getComputedStyle(v.renderRoot.querySelector(".stats")).display;')=='none'
+    assert action('return getComputedStyle(v.renderRoot.querySelector(".record-list")).gridTemplateColumns.split(" ").length;')>=3
+    # Display preferences reveal existing data without changing the query or fetching again.
+    resources=evaluate('performance.getEntriesByType("resource").length')
+    action('v.renderRoot.querySelector("button[aria-label=列表视图]").click();')
+    wait(V+'.renderRoot.querySelector(".record-list.list")')
+    assert action('return v._data.records.length;')==20
+    shot('library-list-view')
+    action('v.renderRoot.querySelector("button[aria-label=卡片视图]").click();v.renderRoot.querySelector(".insights-toggle").click();')
+    wait('!'+V+'.renderRoot.getElementById("coverage-insights").hidden')
+    assert action('return v.renderRoot.querySelectorAll(".bar").length;')>0
+    assert evaluate('performance.getEntriesByType("resource").length')==resources
+    action('v.renderRoot.querySelector(".insights-toggle").click();v.renderRoot.querySelector(".advanced-toggle").click();')
+    wait('!'+V+'.renderRoot.getElementById("advanced-filters").hidden')
+    action('const i=v.renderRoot.querySelector("input[aria-label=日期起]");i.value="2026-11-01";i.dispatchEvent(new Event("change",{bubbles:true}));')
+    wait(V+'.routeQuery.from==="2026-11-01" && !'+V+'._loading')
+    action('v.renderRoot.querySelector(".advanced-toggle").click();')
+    wait(V+'.renderRoot.getElementById("advanced-filters").hidden')
+    assert action('return !!v.renderRoot.querySelector("button[aria-label^=移除日期起]");')
+    action('v.renderRoot.querySelector("button[aria-label^=移除日期起]").click();')
+    wait('!'+V+'.routeQuery.from && !'+V+'._loading')
+    def select(label,value):
+        selector=json.dumps('select[aria-label="'+label+'"]',ensure_ascii=False)
+        action('const s=v.renderRoot.querySelector('+selector+');s.value='+json.dumps(value)+';s.dispatchEvent(new Event("change",{bubbles:true}));')
+        wait(V+'._loading===true || '+V+'._data?.effectiveQuery.filters['+json.dumps({'平台':'platform','品牌':'brand','品类':'category'}.get(label,label))+']')
+        ready()
+    select('平台','jd')
+    wait(V+'.routeQuery.platform==="jd" && !'+V+'._loading')
+    select('品牌','ksf')
+    wait(V+'.routeQuery.brand==="ksf" && !'+V+'._loading')
+    select('品类','banmian')
+    wait(V+'.routeQuery.category==="banmian" && !'+V+'._loading')
+    scope=action('return {total:v._data.totalMatched,rows:v._data.records.map(r=>[r.platform,r.brand,r.category])};')
+    assert scope['total']>0 and all(r==['jd','ksf','banmian'] for r in scope['rows']),scope
+    assert action('return v.renderRoot.querySelectorAll(".filter-chip").length;')==3
+    action('v.renderRoot.querySelector("button[aria-label^=移除品牌]").click();')
+    wait('!'+V+'.routeQuery.brand && !'+V+'._loading')
+    assert action('return [v.routeQuery.platform,v.routeQuery.category];')==['jd','banmian']
+    select('品牌','ksf')
+    wait(V+'.routeQuery.brand==="ksf" && !'+V+'._loading')
+    shot('library-filtered-desktop')
+    action('v.renderRoot.querySelector("a[data-record-id]").click();')
+    wait(V+'._detail && !'+V+'._loading')
+    assert action('return v._detail.record.source.archiveSha256.length;')==64
+    assert action('return v._detail.relations.length;')==5
+    product_ready()
+    assert product_action('return p.product.id;')==action('return v._detail.record.id;')
+    assert product_action('return p.renderRoot.querySelectorAll(".product-resources button").length;')==4
+    product_tab('prices')
+    assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
+    assert action('return v.routeQuery.brand;')=='ksf'
+    product_tab('overview')
+    reader_surface()
+    action('navigator.clipboard.writeText=async text=>{window.__libraryCitation=text;};v.renderRoot.querySelector(".detail-actions .primary").click();')
+    wait('!!window.__libraryCitation')
+    citation=evaluate('window.__libraryCitation')
+    assert '资料链接:' in citation and '原始页面:https://' in citation,citation
+    assert not any(x in citation for x in ['SHA256','来源编号','来源文件','JSONL','rule_title_']),citation
+    shot('library-product-detail')
+    detail_url=evaluate('location.href')
+    call('Page.reload');ready();assert evaluate('location.href')==detail_url
+    assert action('return v._detail.record.id.startsWith("jd:");')
+    action('v.renderRoot.querySelector(".breadcrumb a").click();')
+    wait(V+'._data && !'+V+'._loading && !'+V+'.recordId')
+    assert action('return v.routeQuery.brand;')=='ksf'
+    # Category navigation and cards operate on URL state, not a local mock dataset.
+    def go_type(entity):
+        if entity in ['products','social','documents','reports']:
+            action('Array.from(v.renderRoot.querySelectorAll(".tree-link")).find(a=>new URL(a.href).searchParams.get("type")==='+json.dumps(entity)+').click();')
+        else:
+            # Old bookmarked entity URLs remain readable, but have no standalone navigation.
+            call('Page.navigate',dict(url=args.url+'?type='+entity))
+        wait(V+'._type==='+json.dumps(entity)+' && !'+V+'._loading && ('+V+'._data || '+V+'._type==="reports")')
+    # A product with many reviews validates isolated search, complete text, pagination and deep URLs.
+    product_id='taobao:1040511334739'
+    call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&brand=ksf&panel=reviews&relatedQuality=audit'))
+    ready();product_ready()
+    assert product_action('return p._data.totalMatched;')==1000
+    assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
+    assert product_action('return p.renderRoot.querySelectorAll(".review").length;')==20
+    assert product_action('return !/SHA256|分类依据|rule_title_|source_attribute/.test(p.renderRoot.textContent);')
+    shot('library-product-reviews')
+    review_first=product_action('return p._data.records[0].record_id;')
+    product_action('Array.from(p.renderRoot.querySelectorAll(".pagination button")).find(b=>b.textContent.includes("下一页")).click();')
+    wait(P+'.query.relatedCursor && !'+P+'._loading && '+P+'._data')
+    assert product_action('return p._data.records[0].record_id;')!=review_first
+    assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
+    review_page_url=evaluate('location.href')
+    call('Page.reload');ready();product_ready()
+    assert evaluate('location.href')==review_page_url
+    assert product_action('return p.query.relatedQuality;')=='audit'
+    product_action('Array.from(p.renderRoot.querySelectorAll(".pagination button")).find(b=>b.textContent.includes("第一页")).click();')
+    wait('!'+P+'.query.relatedCursor && !'+P+'._loading && '+P+'._data')
+    assert product_action('return p._data.records[0].record_id;')==review_first
+    assert product_action('return !p.renderRoot.querySelector("input:not([type=date]),form");')
+    product_action('const i=p.renderRoot.querySelector("input[aria-label=商品资料日期起]");i.value="2099-01-01";i.dispatchEvent(new Event("change",{bubbles:true}));')
+    wait(P+'.query.relatedFrom==="2099-01-01" && !'+P+'._loading && '+P+'._data?.totalMatched===0')
+    assert product_action('return p.product.id;')==product_id
+    product_action('Array.from(p.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("清空当前筛选")).click();')
+    wait('!'+P+'.query.relatedFrom && !'+P+'._loading && '+P+'._data')
+    product_tab('prices')
+    assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
+    assert product_action('return !!p.renderRoot.querySelector(".price-heading strong");')
+    shot('library-product-prices')
+    product_tab('specs')
+    assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
+    product_tab('rankings')
+    assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
+    # Failed tab reads stay in the product and must never be called empty data.
+    call('Network.setBlockedURLs',dict(urls=['*/api/library']))
+    product_action('p.renderRoot.querySelector("#tab-reviews").click();')
+    wait(P+'._error && !'+P+'._loading')
+    assert product_action('return !!p.renderRoot.querySelector("[role=alert]");')
+    call('Network.setBlockedURLs',dict(urls=[]))
+    product_action('p.renderRoot.querySelector(".error button").click();');product_ready()
+    assert product_action('return p._data.records.every(row=>row.product_id===p.product.id);')
+    action('v.renderRoot.querySelector(".breadcrumb a").click();')
+    wait(V+'._data && !'+V+'._loading && !'+V+'.recordId')
+    assert action('return v.routeQuery.brand;')=='ksf'
+    assert not evaluate('new URL(location.href).searchParams.has("relatedQ") || new URL(location.href).searchParams.has("panel")')
+    # Legacy per-product links now land inside the same product instead of a detached list.
+    call('Page.navigate',dict(url=args.url+'?type=prices&productId='+product_id))
+    wait(V+'.recordEntity==="products" && '+V+'._detail && !'+V+'._loading');product_ready()
+    assert product_action('return p.panel;')=='prices'
+    assert product_action('return p.product.id;')==product_id
+    # Old search bookmarks shed keyword-bound cursors and preserve explicit filters.
+    call('Page.navigate',dict(url=args.url+'?type=products&brand=ksf&q=旧搜索&cursor=old-search-cursor'))
+    ready()
+    assert action('return v.routeQuery.brand==="ksf" && !v.routeQuery.q && !v.routeQuery.cursor && !v._error;')
+    call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&panel=reviews&relatedQuality=audit&relatedQ=旧搜索&relatedCursor=old-review-cursor'))
+    ready();product_ready()
+    assert product_action('return p.product.id===p._data.records[0].product_id && !p.query.relatedQ && !p.query.relatedCursor;')
+    call('Page.navigate',dict(url=args.url));ready()
+    go_type('reviews')
+    assert action('return v._data.totalMatched;')==771
+    action('const s=v.renderRoot.querySelector("select[aria-label=查看口径]");s.value="audit";s.dispatchEvent(new Event("change",{bubbles:true}));')
+    wait(V+'._data?.totalMatched===2360 && !'+V+'._loading')
+    go_type('documents')
+    assert action('return v._data.totalMatched;')==25
+    shot('library-policy-browse')
+    action('const s=v.renderRoot.querySelector("select[aria-label=政策状态]");s.value=s.options[1].value;s.dispatchEvent(new Event("change",{bubbles:true}));')
+    wait(V+'.routeQuery.policyStatus && !'+V+'._loading')
+    assert action('return v._data.totalMatched;')>0
+    action('v.renderRoot.querySelector("a[data-record-id]").click();')
+    wait(V+'._detail && !'+V+'._loading')
+    assert action('return v._detail.record.full_text.length;')>100
+    assert action('return v.renderRoot.querySelector(".detail-main").firstElementChild.id;')=='full_text'
+    reader_surface()
+    assert action('return !!v.renderRoot.querySelector(".source a[target=_blank]");')
+    shot('library-policy-detail')
+    call('Page.navigate',dict(url=args.url+'?type=clauses'));ready()
+    assert action('return v._data.totalMatched;')==31
+    action('v.renderRoot.querySelector("a[data-record-id]").click();')
+    wait(V+'._detail && !'+V+'._loading')
+    assert action('return v.renderRoot.textContent.includes("应用提示(非官方原话)");')
+    assert action('return v._detail.record.parentDocument.title;')
+    assert action('return v.renderRoot.querySelector(".source a[target=_blank]").href;').startswith('http')
+    reader_surface()
+    call('Page.navigate',dict(url=args.url+'?type=products'));ready()
+    first=action('return v._data.records[0].record_id;')
+    action('Array.from(v.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("下一页")).click();')
+    wait(V+'.routeQuery.cursor && !'+V+'._loading')
+    assert action('return v._data.records[0].record_id;')!=first
+    second=action('return v._data.records[0].record_id;')
+    action('Array.from(v.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("上一页")).click();')
+    wait('!'+V+'.routeQuery.cursor && !'+V+'._loading')
+    assert action('return v._data.records[0].record_id;')==first
+    action('Array.from(v.renderRoot.querySelectorAll("button")).find(b=>b.textContent.includes("下一页")).click();')
+    wait(V+'.routeQuery.cursor && !'+V+'._loading')
+    assert action('return v._data.records[0].record_id;')==second
+    call('Page.reload');ready();assert action('return !!v.routeQuery.cursor;')
+    # Read all remaining categories, retaining a real zero-result state for absent evidence.
+    for entity in ['social','prices','specs','rankings','coverage','reports']:
+        go_type(entity)
+        if entity!='reports':assert action('return v._data.totalMatched;')>0
+        if entity=='prices':
+            price=action('return {raw:v._data.records[0].price_minor,shown:v.renderRoot.querySelector(".price-fact strong").textContent};')
+            assert price['shown']=='¥'+format(price['raw']/100,'.2f'),price
+            assert action('return !v.renderRoot.querySelector(".record").textContent.includes("search_listing_snapshot");')
+            shot('library-price-browse')
+        if entity=='specs':
+            assert action('return v._data.records[0].net_weight_g===null && !v.renderRoot.querySelector(".spec-facts").textContent.includes("净含量");')
+        if entity=='coverage':
+            assert action('return !v.renderRoot.querySelector(".record").textContent.includes("not_attempted_service_blocked");')
+        reader_surface()
+        if entity=='reports':
+            action('const s=v.renderRoot.querySelector("select[aria-label=成果阶段]");s.value=s.options[1].value;s.dispatchEvent(new Event("change",{bubbles:true}));')
+            wait(V+'.routeQuery.reportStage && !'+V+'._loading')
+            assert action('return v.renderRoot.querySelectorAll(".report-record").length===v.db.artifacts.filter(r=>r.stageObj?.code===v.routeQuery.reportStage).length;')
+            call('Page.reload');ready()
+            assert action('return !!v.routeQuery.reportStage && !v.renderRoot.querySelector("input");')
+        if entity!='reports':
+            action('v.renderRoot.querySelector("a[data-record-id]").click();')
+            wait(V+'._detail && !'+V+'._loading')
+            reader_surface()
+            action('v.renderRoot.querySelector(".breadcrumb a").click();')
+            wait(V+'._data && !'+V+'._loading && !'+V+'.recordId')
+    go_type('products')
+    action('v.renderRoot.querySelector(".advanced-toggle").click();')
+    wait('!'+V+'.renderRoot.getElementById("advanced-filters").hidden')
+    action('const i=v.renderRoot.querySelector("input[aria-label=日期起]");i.value="2099-01-01";i.dispatchEvent(new Event("change",{bubbles:true}));')
+    wait(V+'.routeQuery.from==="2099-01-01" && !'+V+'._loading')
+    assert action('return v._data.totalMatched;')==0
+    action('v.renderRoot.querySelector(".empty button").click();')
+    wait('!'+V+'.routeQuery.from && !'+V+'._loading && '+V+'._data?.totalMatched===4639')
+    # A failed connection must stay distinct from a genuine zero-result query.
+    call('Network.setBlockedURLs',dict(urls=['*/api/library']))
+    action('v.renderRoot.querySelector(".refresh").click();')
+    wait(V+'._error && !'+V+'._loading')
+    assert action('return !!v.renderRoot.querySelector("[role=alert]") && !v.renderRoot.querySelector(".empty");')
+    call('Network.setBlockedURLs',dict(urls=[]))
+    action('v.renderRoot.querySelector(".error button").click();')
+    ready()
+    call('Page.navigate',dict(url=args.url));ready()
+    layouts=[]
+    for width,height in [(1440,1100),(1024,1000),(390,844),(320,740)]:
+        call('Emulation.setDeviceMetricsOverride',dict(width=width,height=height,deviceScaleFactor=1,mobile=width<700))
+        time.sleep(.15)
+        metrics=action('return {width:innerWidth,documentWidth:document.documentElement.scrollWidth,viewWidth:v.clientWidth,cards:v.renderRoot.querySelectorAll(".stat").length,records:v.renderRoot.querySelectorAll("a[data-record-id]").length,columns:getComputedStyle(v.renderRoot.querySelector(".record-list")).gridTemplateColumns.split(" ").length,resultTop:Math.round(v.renderRoot.querySelector("a[data-record-id]").getBoundingClientRect().top),visibleRecords:Array.from(v.renderRoot.querySelectorAll("a[data-record-id]")).filter(r=>r.getBoundingClientRect().bottom<innerHeight-70).length};')
+        assert metrics['documentWidth']<=width,metrics
+        assert metrics['cards']==4 and metrics['records']==20,metrics
+        assert metrics['resultTop']<(350 if width>=1000 else 450),metrics
+        if width==1440:assert metrics['columns']>=3 and metrics['visibleRecords']>=9,metrics
+        if width==1024:assert metrics['columns']>=2,metrics
+        shot('library-'+str(width));layouts.append(metrics)
+    assert action('return getComputedStyle(v.renderRoot.querySelector(".stats")).display;')=='none'
+    action('v.renderRoot.querySelector(".overview-toggle").click();')
+    wait(V+'.renderRoot.querySelector(".overview-toggle").getAttribute("aria-expanded")==="true"')
+    assert action('return getComputedStyle(v.renderRoot.querySelector(".stats")).display;')=='grid'
+    action('v.renderRoot.querySelector(".overview-toggle").click();')
+    # Product tabs scroll within their own row; long reviews fit the mobile viewport.
+    call('Page.navigate',dict(url=args.url+'/products/'+product_id+'?type=products&panel=reviews&relatedQuality=audit'))
+    ready();product_ready()
+    assert evaluate('document.documentElement.scrollWidth<=innerWidth')
+    assert product_action('return p.renderRoot.querySelector(".product-tabs").scrollWidth>p.renderRoot.querySelector(".product-tabs").clientWidth;')
+    product_action('p.renderRoot.querySelector(".section-tools").scrollIntoView({block:"start"});')
+    shot('library-product-reviews-mobile')
+    product_tab('specs')
+    assert evaluate('document.documentElement.scrollWidth<=innerWidth')
+    call('Page.navigate',dict(url=args.url));ready()
+    # New tab remains reachable on mobile, and active state covers detail routes.
+    assert evaluate('document.querySelector("kf-app").shadowRoot.querySelector("kf-nav").shadowRoot.querySelector(".tabbar a[data-path=\\"/library\\"]").getAttribute("aria-current")')=='page'
+    action('const s=v.renderRoot.querySelector("select[aria-label=资料分类]");s.value="documents";s.dispatchEvent(new Event("change",{bubbles:true}));')
+    wait(V+'._type==="documents" && !'+V+'._loading && '+V+'._data?.totalMatched===25')
+    action('v.renderRoot.querySelector(".filter").scrollIntoView({block:"start"});')
+    shot('library-mobile-browse')
+    action('v.renderRoot.querySelector("a[data-record-id]").click();')
+    wait(V+'._detail && !'+V+'._loading')
+    assert evaluate('document.documentElement.scrollWidth<=innerWidth')
+    assert action('return getComputedStyle(v.renderRoot.getElementById("record-facts")).display;')=='none'
+    action('v.renderRoot.querySelector(".facts-toggle").click();')
+    wait(V+'.renderRoot.querySelector(".facts-toggle").getAttribute("aria-expanded")==="true"')
+    assert action('return getComputedStyle(v.renderRoot.getElementById("record-facts")).display;')=='block'
+    action('v.renderRoot.querySelector(".facts-toggle").click();')
+    shot('library-mobile-detail')
+    failures=[x for x in events if x.get('method')=='Runtime.exceptionThrown']
+    assert not failures,failures
+    result=dict(ok=True,scope=scope,layouts=layouts,tests=['product-catalog-navigation','real-data-counts','exact-filters','active-filter-removal','advanced-filters','display-mode-no-refetch','coverage-toggle','product-inline-panels','product-review-date-filter','no-search-controls','legacy-search-link-normalization','report-stage-filter','compact-layout','product-review-pagination-refresh','product-isolation','product-panel-error-recovery','legacy-product-link','deep-refresh','back-preserves-filters','review-quality','policy-prose-first','reader-source-without-internal-fields','reader-citation','clause-original-source','previous-next-pagination','zero-result-reset','connection-error-recovery','typed-price-spec-coverage-cards','legacy-category-details','mobile-overview','mobile-product-tabs','mobile-navigation','mobile-facts'],productionWrites=0,consoleExceptions=0)
+    (WORK/'browser-validation.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
+    print(json.dumps(result,ensure_ascii=False,indent=2))
+finally:
+    if ws:ws.close()
+    process.terminate()
+    try:process.wait(timeout=8)
+    except subprocess.TimeoutExpired:process.kill()

+ 203 - 0
scripts/verify-member-groups-browser.py

@@ -0,0 +1,203 @@
+"""Browser acceptance against real public reads and local-only group writes."""
+import argparse
+import base64
+import json
+from pathlib import Path
+import subprocess
+import tempfile
+import time
+from urllib.request import urlopen
+import websocket
+
+ROOT=Path(__file__).resolve().parents[1]
+WORK=ROOT/'work/member-groups-preview'
+WORK.mkdir(parents=True,exist_ok=True)
+parser=argparse.ArgumentParser();parser.add_argument('--url',default='http://127.0.0.1:4181/members');args=parser.parse_args()
+profile=Path(tempfile.mkdtemp(prefix='browser-',dir=WORK))
+process=subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe','--headless=new','--disable-gpu',
+ '--remote-debugging-port=0','--disable-background-networking','--disable-component-update','--disable-sync','--disable-extensions',
+ '--no-first-run','--no-default-browser-check','--user-data-dir='+str(profile),'about:blank'],
+ stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,creationflags=subprocess.CREATE_NO_WINDOW)
+serial=0;ws=None;events=[]
+state_existed=(WORK/'state.json').exists()
+try:
+    deadline=time.monotonic()+20
+    while not (profile/'DevToolsActivePort').exists():
+        assert time.monotonic()<deadline and process.poll() is None
+        time.sleep(.1)
+    port=(profile/'DevToolsActivePort').read_text().splitlines()[0]
+    page=next(x for x in json.load(urlopen('http://127.0.0.1:'+port+'/json/list',timeout=5)) if x['type']=='page')
+    ws=websocket.create_connection(page['webSocketDebuggerUrl'],timeout=35,suppress_origin=True,http_no_proxy=['127.0.0.1','localhost'])
+    def call(method,params=None):
+        global serial
+        serial+=1;rid=serial;ws.send(json.dumps(dict(id=rid,method=method,params=params or {})))
+        while True:
+            response=json.loads(ws.recv())
+            if response.get('id')==rid:
+                assert 'error' not in response,response
+                return response.get('result',{})
+            events.append(response)
+    def evaluate(expression,promise=False):
+        response=call('Runtime.evaluate',dict(expression=expression,returnByValue=True,awaitPromise=promise))
+        assert 'exceptionDetails' not in response,response
+        return response['result'].get('value')
+    def wait(expression,timeout=25):
+        evaluate('new Promise((resolve,reject)=>{const end=Date.now()+'+str(timeout*1000)+';const tick=()=>{try{if('+expression+'){resolve(true);return;}}catch{}if(Date.now()>end)reject(new Error("Timed out: "+'+json.dumps(expression)+'));else setTimeout(tick,60)};tick()})',True)
+    def action(body):
+        return evaluate('(()=>{const v=document.querySelector("kf-app").shadowRoot.querySelector("view-members");'+body+'})()')
+    V='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-members")'
+    def ready():wait(V+'?._groups && !'+V+'?._loading && !'+V+'?._busy')
+    def shot(name):
+        image=call('Page.captureScreenshot',dict(format='png',captureBeyondViewport=False))
+        (WORK/(name+'.png')).write_bytes(base64.b64decode(image['data']))
+    call('Page.enable');call('Runtime.enable');call('Network.enable')
+    call('Emulation.setDeviceMetricsOverride',dict(width=1440,height=1100,deviceScaleFactor=1,mobile=False))
+    call('Page.navigate',dict(url=args.url));ready()
+    baseline=action('return structuredClone(v._groups);')
+    initial=action('return {members:v.db.members.length,revision:v._groups.revision,groups:v._groups.groups.length};')
+    assert initial['members']>=2,initial
+    assert action('return !v.renderRoot.querySelector("input[type=checkbox], select, .group-actions");')
+    def click(selector):
+        action('v.renderRoot.querySelector('+json.dumps(selector)+').click();')
+    def open_manager():
+        click('#manage-groups');wait(V+'.renderRoot.querySelector("dialog").open')
+    def submit_name(name):
+        action('const i=v.renderRoot.querySelector("input[name=groupName]");i.value='+json.dumps(name)+';i.dispatchEvent(new Event("input",{bubbles:true}));v.renderRoot.querySelector("form.editor").requestSubmit();')
+    def select_group(gid):
+        action('Array.from(v.renderRoot.querySelectorAll("[data-manage-group]")).find(b=>b.dataset.manageGroup==='+json.dumps(gid)+').click();')
+    def assignments_unchanged():
+        assert action('return Object.entries('+json.dumps(baseline['assignments'])+').every(([id,g])=>v._groups.assignments[id]===g);')
+    name='界面验收组-'+str(int(time.time()))
+    open_manager();click('#create-group');submit_name(name)
+    wait('!'+V+'._edit && '+V+'._groups.groups.some(g=>g.name==='+json.dumps(name)+')');ready()
+    gid=action('return v._groups.groups.find(g=>g.name==='+json.dumps(name)+').id;')
+    assert action('return v._dialog && v._managedGroup==='+json.dumps(gid)+';')
+    click('#create-group');submit_name(name)
+    wait(V+'._formError && !'+V+'._busy')
+    assert '同名' in action('return v._formError;')
+    click('.editor button[type=button]')
+    # Candidate selection comes only from the actually ungrouped stable identities.
+    candidates=action('return v._membersOf("__ungrouped").map(m=>({id:m.id,name:m.name}));')
+    assert candidates,'Need at least one initially ungrouped member for a non-destructive local test'
+    assert set(action('return [...v.renderRoot.querySelectorAll("[data-candidate]")].map(e=>e.dataset.candidate);'))=={m['id'] for m in candidates}
+    selected=candidates[:2]
+    # The complete ungrouped list is directly selectable without a search field.
+    assert action('return !v.renderRoot.querySelector("dialog input[type=search]");')
+    click('input[aria-label=全选未分组成员]')
+    wait(V+'._selected.size==='+str(len(candidates)))
+    click('input[aria-label=全选未分组成员]')
+    wait(V+'._selected.size===0')
+    for m in selected:
+        action('Array.from(v.renderRoot.querySelectorAll("[data-candidate]")).find(e=>e.dataset.candidate==='+json.dumps(m['id'])+').querySelector("input").click();')
+    click('#add-members')
+    wait('!'+V+'._busy && '+V+'._selected.size===0 && '+V+'._groups.assignments['+json.dumps(selected[0]['id'])+']==='+json.dumps(gid))
+    assert action('return v._membersOf('+json.dumps(gid)+').length;')==len(selected)
+    assert not action('return v._membersOf("__ungrouped").some(m=>m.id==='+json.dumps(selected[0]['id'])+');')
+    assignments_unchanged();shot('manager-members-added')
+    # Removing a member returns it to the candidate list; adding again keeps the dialog open.
+    action('Array.from(v.renderRoot.querySelectorAll("[data-managed-member]")).find(e=>e.dataset.managedMember==='+json.dumps(selected[0]['id'])+').querySelector("button.remove").click();')
+    wait('!'+V+'._busy && !'+V+'._groups.assignments['+json.dumps(selected[0]['id'])+']')
+    assert action('return v._membersOf("__ungrouped").some(m=>m.id==='+json.dumps(selected[0]['id'])+');')
+    action('Array.from(v.renderRoot.querySelectorAll("[data-candidate]")).find(e=>e.dataset.candidate==='+json.dumps(selected[0]['id'])+').querySelector("input").click();')
+    click('#add-members');wait('!'+V+'._busy && '+V+'._groups.assignments['+json.dumps(selected[0]['id'])+']==='+json.dumps(gid))
+    renamed=name+'-改名'
+    click('button[aria-label="重命名'+name+'"]');submit_name(renamed)
+    wait('!'+V+'._edit && '+V+'._groups.groups.find(g=>g.id==='+json.dumps(gid)+')?.name==='+json.dumps(renamed))
+    click('button[aria-label=关闭分组管理]')
+    # Main page filtering has no management controls and updates immediately.
+    action('Array.from(v.renderRoot.querySelectorAll("button[data-group-filter]")).find(b=>b.dataset.groupFilter==='+json.dumps(gid)+').click();')
+    wait(V+'._visibleMembers.length==='+str(len(selected)))
+    assert action('return !v.renderRoot.querySelector("input[type=checkbox], select, .group-actions");')
+    call('Page.reload');ready()
+    assert action('return v._groups.groups.find(g=>g.id==='+json.dumps(gid)+')?.name;')==renamed
+    assert action('return Object.values(v._groups.assignments).filter(id=>id==='+json.dumps(gid)+').length;')==len(selected)
+    assignments_unchanged()
+    open_manager();select_group(gid)
+    # A real stale revision still rejects the write without overriding the result.
+    conflict=evaluate('(async()=>{const v='+V+';const r=await fetch("/__dev/member-groups",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path:"/ksfManagePublicMemberGroups",params:{requestId:"browser-stale-"+crypto.randomUUID(),expectedRevision:v._groups.revision-1,action:"rename",groupId:'+json.dumps(gid)+',name:"不应保存"}})});return await r.json();})()',True)
+    assert conflict['result']['errors'][0]['code']=='VERSION_CONFLICT',conflict
+    click('button[aria-label="删除'+renamed+'"]')
+    wait(V+'._edit?.action==="delete"');shot('manager-delete-confirmation')
+    action('v.renderRoot.querySelector("form.editor").requestSubmit();')
+    wait('!'+V+'._edit && !'+V+'._groups.groups.some(g=>g.id==='+json.dumps(gid)+')')
+    assert action('return !Object.values(v._groups.assignments).includes('+json.dumps(gid)+');')
+    assert action('return v._dialog;')
+    assert action('return JSON.stringify(v._groups.assignments);')==json.dumps(baseline['assignments'],ensure_ascii=False,separators=(',',':'))
+    click('button[aria-label=关闭分组管理]')
+    call('Page.reload');ready()
+    layouts=[]
+    for width,height in [(1440,1100),(1024,1000),(390,844),(320,740)]:
+        call('Emulation.setDeviceMetricsOverride',dict(width=width,height=height,deviceScaleFactor=1,mobile=width<700))
+        evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))',True)
+        metrics=action('return {width:innerWidth,documentWidth:document.documentElement.scrollWidth,viewWidth:v.clientWidth,members:v.db.members.length,rows:v.renderRoot.querySelectorAll("li[data-member-id]").length};')
+        assert metrics['documentWidth']<=width,metrics
+        assert metrics['rows']==metrics['members'],metrics
+        shot('members-'+str(width));open_manager()
+        bounds=action('const d=v.renderRoot.querySelector("dialog"),r=d.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom,scrollWidth:d.scrollWidth,clientWidth:d.clientWidth};')
+        assert bounds['left']>=0 and bounds['right']<=width and bounds['top']>=0 and bounds['bottom']<=height,bounds
+        assert bounds['scrollWidth']<=bounds['clientWidth'],bounds
+        shot('manager-'+str(width));click('#create-group');shot('manager-new-group-'+str(width))
+        # Escape closes the manager even from an inline editor.
+        call('Input.dispatchKeyEvent',dict(type='keyDown',key='Escape',code='Escape',windowsVirtualKeyCode=27))
+        call('Input.dispatchKeyEvent',dict(type='keyUp',key='Escape',code='Escape',windowsVirtualKeyCode=27));wait('!'+V+'._dialog')
+        layouts.append(dict(**metrics,dialog=bounds))
+    # Close on the actual backdrop; clicking inside the surface leaves it open.
+    open_manager();action('v.renderRoot.querySelector(".dialog-head").click();')
+    assert action('return v._dialog;')
+    call('Input.dispatchMouseEvent',dict(type='mousePressed',x=2,y=2,button='left',clickCount=1))
+    call('Input.dispatchMouseEvent',dict(type='mouseReleased',x=2,y=2,button='left',clickCount=1));wait('!'+V+'._dialog')
+    # All main routes share the title-free ordinary tools row, including detail routes.
+    call('Emulation.setDeviceMetricsOverride',dict(width=1440,height=1000,deviceScaleFactor=1,mobile=False))
+    artifact=action('return v.db.artifacts.find(a=>a.report)?.id || v.db.artifacts[0]?.id;')
+    opportunity=action('return v.db.opportunities[0]?.id;')
+    routes=['/','/pool','/stages','/reports','/reports/category','/ledger','/members','/library']
+    if artifact:routes.append('/report/'+artifact)
+    if opportunity:routes.append('/pool/'+opportunity)
+    route_checks=[]
+    for route in routes:
+        evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps(route)+'))',True)
+        wait('document.querySelector("kf-app")?.shadowRoot?.querySelector("[data-path]")?.dataset.path==='+json.dumps(route))
+        info=evaluate('(()=>{const a=document.querySelector("kf-app"),t=a.shadowRoot.querySelector("kf-topbar"),r=t.shadowRoot;return {route:location.pathname,titleCount:r.querySelectorAll("h1,.t,.s").length,position:getComputedStyle(t).position,refresh:!!r.querySelector("button.refresh"),identity:!!r.querySelector("select#me"),width:document.documentElement.scrollWidth};})()')
+        assert info['titleCount']==0 and info['position']=='static' and info['refresh'] and info['identity'],info
+        route_checks.append(info)
+    # Sticky navigation now starts at the viewport edge, with no old title-bar offset.
+    evaluate('import("/src/utils/router.js").then(m=>m.navigate("/library"))',True)
+    L='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-library")'
+    wait(L+'?._data && !'+L+'?._loading')
+    evaluate('scrollTo(0,600)');evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))',True)
+    tree=evaluate('(()=>{const v='+L+',r=v.renderRoot.querySelector(".tree").getBoundingClientRect();return {top:r.top};})()')
+    assert 14<=tree['top']<=18,tree
+    shot('library-without-titlebar')
+    if artifact:
+        evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps('/report/'+artifact)+'))',True)
+        R='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-report")'
+        wait(R+'?.renderRoot.querySelector(".toc")')
+        evaluate('scrollTo(0,600)');evaluate('new Promise(r=>setTimeout(r,120))',True)
+        toc=evaluate('(()=>{const v='+R+',e=v.renderRoot.querySelector(".toc"),r=e.getBoundingClientRect(),s=getComputedStyle(e);return {top:r.top,height:r.height,position:s.position,inset:s.top};})()')
+        assert toc['position']=='sticky' and toc['inset']=='12px' and toc['top']>=10,toc
+        shot('report-without-titlebar')
+    failures=[x for x in events if x.get('method')=='Runtime.exceptionThrown']
+    assert not failures,failures
+    result=dict(ok=True,initial=initial,tests=['clean-member-page','centered-manager','create','duplicate-name-error','ungrouped-candidates-only','candidate-list-without-search','select-all-ungrouped','add-members','remove-and-readd','rename','filter','reload-persistence','stale-version-rejected','delete-to-ungrouped','original-assignments-preserved','responsive-manager','escape-close','backdrop-close','titlebar-removed-all-routes','library-sticky-offset','report-sticky-offset'],layouts=layouts,routes=route_checks,localPreview=True,productionWrites=0,consoleExceptions=0)
+    (WORK/'browser-validation.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
+    # Archive only state wholly created by this browser, never a concurrent user's state.
+    state_file=WORK/'state.json'
+    if not state_existed and state_file.exists():
+        state=json.loads(state_file.read_text(encoding='utf-8'))
+        browser_request_ids=set()
+        for event in events:
+            request=event.get('params',{}).get('request',{})
+            if event.get('method')=='Network.requestWillBeSent' and '/__dev/member-groups' in request.get('url',''):
+                try:browser_request_ids.add(json.loads(request.get('postData','{}')).get('params',{}).get('requestId'))
+                except (ValueError,TypeError):pass
+        if set(state['operations']).issubset(browser_request_ids) and state['config']['value']['assignments']==baseline['assignments'] and state['config']['value']['groups']==baseline['groups']:
+            state_file.rename(WORK/('validated-modal-state-'+str(int(time.time()))+'.json'))
+            result['testStateArchived']=True
+    (WORK/'browser-validation.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
+    print(json.dumps(result,ensure_ascii=False,indent=2))
+
+finally:
+    if ws:ws.close()
+    process.terminate()
+    try:process.wait(timeout=8)
+    except subprocess.TimeoutExpired:process.kill()

+ 164 - 0
scripts/verify-opportunity-links.mjs

@@ -0,0 +1,164 @@
+// Fresh anonymous browser regression for opportunity card details and linked reports.
+import fs from 'node:fs';
+import path from 'node:path';
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+
+const base = process.argv[2] || 'http://127.0.0.1:4178';
+const profile = fs.mkdtempSync(path.resolve('work/opportunity-browser-'));
+const browser = spawn('C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', [
+  '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
+  '--disable-background-networking', '--remote-debugging-address=127.0.0.1',
+  '--remote-debugging-port=0', '--user-data-dir=' + profile, 'about:blank',
+], { windowsHide: true, stdio: 'ignore' });
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+let socket;
+const requests = [];
+const checks = [];
+const errors = [];
+const screenshots = [];
+const runId = Date.now();
+const output = process.env.KF_OPPORTUNITY_RESULT || path.join(profile, 'result.json');
+try {
+  let port;
+  for (let i = 0; i < 100; i++) {
+    try { port = Number(fs.readFileSync(path.join(profile, 'DevToolsActivePort'), 'utf8').split('\n')[0]); } catch {}
+    if (port) break;
+    await sleep(100);
+  }
+  assert.ok(port, 'Edge CDP unavailable');
+  const tabs = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
+  socket = new WebSocket(tabs.find((tab) => tab.type === 'page').webSocketDebuggerUrl);
+  await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }); });
+  let seq = 0;
+  const pending = new Map();
+  socket.addEventListener('message', ({ data }) => {
+    const msg = JSON.parse(data);
+    if (msg.id && pending.has(msg.id)) {
+      const call = pending.get(msg.id);
+      clearTimeout(call.timer);
+      pending.delete(msg.id);
+      msg.error ? call.reject(Error(msg.error.message)) : call.resolve(msg.result);
+    }
+    if (msg.method === 'Network.requestWillBeSent') requests.push({ method: msg.params.request.method, url: msg.params.request.url, body: msg.params.request.postData, headerNames: Object.keys(msg.params.request.headers || {}) });
+    if (msg.method === 'Runtime.exceptionThrown') errors.push({ kind: 'runtime', text: msg.params.exceptionDetails.text });
+    if (msg.method === 'Log.entryAdded' && msg.params.entry.level === 'error' && !msg.params.entry.url?.endsWith('/favicon.ico')) errors.push({ kind: 'console', text: msg.params.entry.text });
+    if (msg.method === 'Network.loadingFailed' && msg.params.errorText !== 'net::ERR_ABORTED') errors.push({ kind: 'network', text: msg.params.errorText });
+  });
+  const cdp = (method, params = {}) => new Promise((resolve, reject) => {
+    const id = ++seq;
+    const timer = setTimeout(() => { pending.delete(id); reject(Error(`${method} timed out`)); }, 30000);
+    pending.set(id, { resolve, reject, timer });
+    socket.send(JSON.stringify({ id, method, params }));
+  });
+  const evaluate = async (expression) => {
+    const result = await cdp('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
+    if (result.exceptionDetails) throw Error(result.exceptionDetails.text);
+    return result.result.value;
+  };
+  const find = `function find(root, selector) { const hit=root.querySelector(selector); if(hit)return hit; for(const node of root.querySelectorAll('*')) if(node.shadowRoot){const nested=find(node.shadowRoot,selector);if(nested)return nested;} return null; }`;
+  const shot = async (label) => {
+    const result = await cdp('Page.captureScreenshot', { format: 'png' });
+    const file = `work/live-opportunity-ui-${label}-${runId}.png`;
+    fs.writeFileSync(file, Buffer.from(result.data, 'base64'), { flag: 'wx' }); screenshots.push(file);
+  };
+  const waitFor = async (expression) => {
+    for (let i = 0; i < 80; i++) {
+      const value = await evaluate(expression).catch(() => false);
+      if (value) return value;
+      await sleep(150);
+    }
+    throw Error(`Condition not reached: ${expression.slice(0, 80)}`);
+  };
+  await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable'); await cdp('Log.enable');
+  await cdp('Network.setCacheDisabled', { cacheDisabled: true });
+  await cdp('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false });
+  await cdp('Page.navigate', { url: base + '/pool' });
+  await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready')`);
+  const card = await waitFor(`(()=>{${find}const c=find(document,'kf-opportunity-card[key=\"7TnUg76JOI\"]');return c?.shadowRoot.querySelector('a.report')?.href})()`);
+  assert.equal(new URL(card).pathname, '/report/mX1FapeOVB');
+  await evaluate(`(()=>{${find}find(document,'kf-opportunity-card[key=\"7TnUg76JOI\"]').shadowRoot.querySelector('a.report').click();return true})()`);
+  await waitFor(`location.pathname==='/report/mX1FapeOVB'`);
+  const report = await waitFor(`(()=>{${find}const v=find(document,'view-report');return v?.shadowRoot?.textContent.includes('【样例】')||v?.shadowRoot?.textContent.includes('拌面')})()`);
+  assert.ok(report);
+  await shot('report-desktop');
+  await cdp('Page.navigate', { url: base + '/pool/mX1FapeOVB' });
+  await waitFor(`location.pathname==='/report/mX1FapeOVB'`);
+  checks.push('旧的 /pool/:报告编号 链接可恢复为正确报告');
+  await cdp('Page.navigate', { url: base + '/pool' });
+  await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready')`);
+  await evaluate(`(()=>{${find}find(document,'kf-opportunity-card[key=\"7TnUg76JOI\"]').shadowRoot.querySelector('.acts .kf-btn').click();return true})()`);
+  await waitFor(`location.pathname==='/pool/7TnUg76JOI'`);
+  const heading = await waitFor(`(()=>{${find}const d=find(document,'kf-drawer');return d?.open&&d.heading})()`);
+  assert.ok(heading.includes('拌面方向待验证'));
+  await shot('drawer-desktop');
+  checks.push('关联报告路由正确,真实报告可读', '详情使用机会卡编号,标题匹配');
+  const cards = await evaluate(`import('/src/services/board-service.js').then(m=>m.boardService.db.get().opportunities.map(o=>({id:o.id,title:o.title,reportId:o.reportArtifact?.id})))`);
+  for (const card of cards) {
+    await evaluate(`import('/src/utils/router.js').then(m=>m.navigate('/pool/${card.id}'))`);
+    await waitFor(`(()=>{${find}const d=find(document,'kf-drawer');return d?.open&&d.heading===${JSON.stringify(card.title)}})()`);
+    if (card.reportId) {
+      await evaluate(`(()=>{${find}find(document,'kf-opportunity-card[key="${card.id}"]').shadowRoot.querySelector('a.report').click();return true})()`);
+      await waitFor(`location.pathname==='/report/${card.reportId}'`);
+      await waitFor(`(()=>{${find}const v=find(document,'view-report');return v?.artifactId==='${card.reportId}'&&Boolean(v?.shadowRoot?.querySelector('.cover h1'))})()`);
+    }
+    checks.push(`机会卡 ${card.id} 详情${card.reportId ? '/关联报告' : ''}可读`);
+  }
+  if (process.env.KF_ACTION_TEST_ID) {
+    const id = process.env.KF_ACTION_TEST_ID;
+    for (const width of [1440, 390]) {
+      await cdp('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: width === 390 });
+      const signature = width === 1440 ? '' : '公开操作验收样例';
+      await evaluate(`import('/src/services/board-service.js').then(m=>m.boardService.setMe(${JSON.stringify(signature)}))`);
+      for (const status of ['approved', 'rejected', 'candidate']) {
+        await cdp('Page.navigate', { url: base + '/pool' });
+        await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready')`);
+        const index = ['candidate', 'approved', 'rejected'].indexOf(status);
+        const before = requests.length;
+        await evaluate(`(()=>{${find}const card=find(document,'kf-opportunity-card[key="${id}"]');if(!card)throw Error('Test card absent');card.scrollIntoView();card.shadowRoot.querySelectorAll('.st button')[${index}].click();return true})()`);
+        await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.db.get().opportunityById['${id}']?.status==='${status}')`);
+        await sleep(150);
+        const writes = requests.slice(before).filter((r) => r.url.endsWith('/api/functions') && JSON.parse(r.body || '{}').path === '/ksfSetPublicOpportunityStatus');
+        assert.equal(writes.length, 1, 'One click must send exactly one state action');
+        assert.equal(JSON.parse(writes[0].body).params.opportunityId, id);
+        await cdp('Page.reload', { ignoreCache: true });
+        await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready'&&m.boardService.db.get().opportunityById['${id}']?.status==='${status}')`);
+        const persistedBy = await evaluate(`import('/src/services/board-service.js').then(m=>m.boardService.db.get().opportunityById['${id}']?.decision?.by)`);
+        assert.equal(persistedBy, signature || '匿名访客');
+        checks.push(`${width}px ${status} 单次点击/真实保存/刷新一致`);
+      }
+      await evaluate(`import('/src/utils/router.js').then(m=>m.navigate('/pool/${id}'))`);
+      await waitFor(`(()=>{${find}return find(document,'kf-drawer')?.open})()`);
+      assert.ok(await evaluate('document.documentElement.scrollWidth<=innerWidth'), 'Viewport overflow');
+      await shot('state-drawer-' + width);
+    }
+    await evaluate(`import('/src/utils/router.js').then(m=>m.navigate('/pool/${id}'))`);
+    await waitFor(`(()=>{${find}return find(document,'kf-drawer')?.open})()`);
+    await evaluate(`import('/src/services/board-service.js').then(m=>m.boardService.setMe('公开操作验收样例'))`);
+    await sleep(150);
+    const before = requests.length;
+    await evaluate(`(()=>{${find}const panel=find(document,'kf-score-panel');const input=panel.shadowRoot.querySelector('input[type=number]');for(let i=0;i<2;i++){input.value='76';input.dispatchEvent(new Event('input',{bubbles:true}));panel.shadowRoot.querySelector('form').requestSubmit();}return true})()`);
+    await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.db.get().opportunityById['${id}']?.scores.some(s=>s.user==='公开操作验收样例'&&s.score===76))`);
+    assert.equal(requests.slice(before).filter((r) => r.url.endsWith('/api/functions') && JSON.parse(r.body || '{}').path === '/ksfRatePublicOpportunity').length, 1);
+    await cdp('Page.reload', { ignoreCache: true });
+    await waitFor(`import('/src/services/board-service.js').then(m=>m.boardService.status.get()==='ready'&&m.boardService.db.get().opportunityById['${id}']?.scores.some(s=>s.user==='公开操作验收样例'&&s.score===76))`);
+    checks.push('抽屉评分快速双提交只有一次写入,刷新回读一致');
+    await shot('score-mobile');
+  }
+  assert.ok(!requests.some((r) => r.method === 'PUT' || r.method === 'DELETE'), 'Browser must not write raw tables');
+  assert.ok(!requests.some((r) => r.headerNames.some((key) => /master|authorization|session.token|ksf.service.token/i.test(key))), 'Browser must remain anonymous');
+  assert.ok(!requests.some((r) => r.url.includes('/parse/classes/')), 'Browser must use public projection/actions only');
+  assert.equal(errors.length, 0, 'Browser runtime/network errors');
+  checks.push('浏览器无凭据、无直接业务表访问、无运行或网络错误');
+  const result = { ok: true, at: new Date().toISOString(), base, checks, errors, screenshots, productionFrontendBrowserVerified: base === 'https://kangshifu.fmode.cn', moduleOverrides: false, reportPath: '/report/mX1FapeOVB', opportunityPath: '/pool/7TnUg76JOI', drawerHeading: heading, rawTableWrites: 0, testOpportunityId: process.env.KF_ACTION_TEST_ID || null };
+  fs.writeFileSync(output, JSON.stringify(result, null, 2));
+  console.log(JSON.stringify(result));
+} catch (error) {
+  const failure = { ok: false, checks, errors, screenshots, error: error.message };
+  fs.writeFileSync(output, JSON.stringify(failure, null, 2));
+  console.error(JSON.stringify(failure));
+  process.exitCode = 1;
+} finally {
+  socket?.close();
+  browser.kill();
+}

+ 154 - 0
scripts/verify-unified-preview-browser.py

@@ -0,0 +1,154 @@
+"""Browser acceptance against real public reads and local-only group writes."""
+import argparse
+import base64
+import json
+from pathlib import Path
+import subprocess
+import tempfile
+import time
+from urllib.request import urlopen
+import websocket
+
+ROOT=Path(__file__).resolve().parents[1]
+WORK=ROOT/'work/preview-unification-20261105/browser'
+WORK.mkdir(parents=True,exist_ok=True)
+parser=argparse.ArgumentParser();parser.add_argument('--url',default='http://127.0.0.1:4181/members');args=parser.parse_args()
+profile=Path(tempfile.mkdtemp(prefix='browser-',dir=WORK))
+process=subprocess.Popen(['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe','--headless=new','--disable-gpu',
+ '--remote-debugging-port=0','--disable-background-networking','--disable-component-update','--disable-sync','--disable-extensions',
+ '--no-first-run','--no-default-browser-check','--user-data-dir='+str(profile),'about:blank'],
+ stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,creationflags=subprocess.CREATE_NO_WINDOW)
+serial=0;ws=None;events=[]
+
+try:
+    deadline=time.monotonic()+20
+    while not (profile/'DevToolsActivePort').exists():
+        assert time.monotonic()<deadline and process.poll() is None
+        time.sleep(.1)
+    port=(profile/'DevToolsActivePort').read_text().splitlines()[0]
+    page=next(x for x in json.load(urlopen('http://127.0.0.1:'+port+'/json/list',timeout=5)) if x['type']=='page')
+    ws=websocket.create_connection(page['webSocketDebuggerUrl'],timeout=35,suppress_origin=True,http_no_proxy=['127.0.0.1','localhost'])
+    def call(method,params=None):
+        global serial
+        serial+=1;rid=serial;ws.send(json.dumps(dict(id=rid,method=method,params=params or {})))
+        while True:
+            response=json.loads(ws.recv())
+            if response.get('id')==rid:
+                assert 'error' not in response,response
+                return response.get('result',{})
+            events.append(response)
+    def evaluate(expression,promise=False):
+        response=call('Runtime.evaluate',dict(expression=expression,returnByValue=True,awaitPromise=promise))
+        assert 'exceptionDetails' not in response,response
+        return response['result'].get('value')
+    def wait(expression,timeout=25):
+        evaluate('new Promise((resolve,reject)=>{const end=Date.now()+'+str(timeout*1000)+';const tick=()=>{try{if('+expression+'){resolve(true);return;}}catch{}if(Date.now()>end)reject(new Error("Timed out: "+'+json.dumps(expression)+'));else setTimeout(tick,60)};tick()})',True)
+    def action(body):
+        return evaluate('(()=>{const v=document.querySelector("kf-app").shadowRoot.querySelector("view-members");'+body+'})()')
+    V='document.querySelector("kf-app")?.shadowRoot?.querySelector("view-members")'
+    def ready():wait(V+'?._groups && !'+V+'?._loading && !'+V+'?._busy')
+    def shot(name):
+        image=call('Page.captureScreenshot',dict(format='png',captureBeyondViewport=False))
+        (WORK/(name+'.png')).write_bytes(base64.b64decode(image['data']))
+    call('Page.enable');call('Runtime.enable');call('Network.enable')
+    call('Emulation.setDeviceMetricsOverride',dict(width=1440,height=1100,deviceScaleFactor=1,mobile=False))
+    call('Page.navigate',dict(url=args.url));ready()
+    A='document.querySelector("kf-app")'
+    def app(body):return evaluate('(()=>{const a='+A+';'+body+'})()')
+    def settle():evaluate('new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))',True)
+    def go(route,tag):
+        evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps(route)+'))',True)
+        wait(A+'?._route.path==='+json.dumps(route.split('?')[0])+' && '+A+'?.renderRoot.querySelector('+json.dumps(tag)+')')
+        settle()
+    def escape():
+        call('Input.dispatchKeyEvent',dict(type='keyDown',key='Escape',code='Escape',windowsVirtualKeyCode=27))
+        call('Input.dispatchKeyEvent',dict(type='keyUp',key='Escape',code='Escape',windowsVirtualKeyCode=27))
+    checks=[]
+    assert evaluate('fetch("/__dev/info").then(r=>r.json())',True)['preview']=='unified'
+    wait(V+'?.db?.members.length>0')
+    db=action('return {members:v.db.members.length,artifacts:v.db.artifacts.length,opportunities:v.db.opportunities.length};')
+    assert app('return ![...a.renderRoot.querySelector("kf-nav").renderRoot.querySelectorAll("a")].some(e=>new URL(e.href).pathname==="/showcase");')
+    checks.append('single-source-preview-with-board-only-navigation')
+    # Real ledger stage/layer grid has four horizontal cells instead of the old stacked grid.
+    go('/ledger','view-ledger')
+    heat=app('const v=a.renderRoot.querySelector("view-ledger"),h=v.renderRoot.querySelector("kf-heat");return {layout:h.getAttribute("layout"),cells:h.days.length,style:getComputedStyle(v.renderRoot.querySelector(".ledger-overview")).gridTemplateColumns};')
+    assert heat['layout']=='row' and heat['cells']==4,heat
+    shot('unified-ledger');checks.append('ledger-horizontal-stage-layer-cells')
+    # A genuine opportunity card opens in a centered window; report and source links round-trip.
+    go('/pool?sort=newest','view-pool')
+    wait(A+'.renderRoot.querySelector("view-pool").renderRoot.querySelector("kf-opportunity-card")')
+    card=app('const c=a.renderRoot.querySelector("view-pool").renderRoot.querySelector("kf-opportunity-card");return {id:c.opp.id,report:c.opp.reportArtifact?.id};')
+    app('a.renderRoot.querySelector("view-pool").renderRoot.querySelector("kf-opportunity-card").shadowRoot.querySelector("kf-glass-card").shadowRoot.querySelector(".frame").click();')
+    wait(A+'._route.name==="mode" && '+A+'.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector("dialog").open')
+    centered=app('const d=a.renderRoot.querySelector("kf-drawer"),r=d.shadowRoot.querySelector("dialog").getBoundingClientRect();return {layout:d.layout,left:r.left,right:r.right,width:r.width,top:r.top};')
+    assert centered['layout']=='center' and centered['left']>50 and centered['right']<1440,centered
+    shot('unified-opportunity-center');checks.append('opportunity-centered')
+    linked=app('const d=a.renderRoot.querySelector("kf-drawer");return !!d.shadowRoot.querySelector(".linked-title button.lnk");')
+    if linked:
+        app('a.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector(".linked-title button.lnk").click();')
+        wait(A+'._drawer.type==="artifact" && '+A+'.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector("dialog").open')
+        assert app('return a.renderRoot.querySelector("kf-drawer").layout;')=='center'
+        shot('unified-linked-artifact');escape()
+        wait(A+'._drawer?.type==="opp"')
+        checks.append('linked-artifact-centered-and-return')
+    # Report links carry an explicit return path and chapter anchors avoid the removed bar.
+    full=app('const d=a.renderRoot.querySelector("kf-drawer"),link=d.shadowRoot.querySelector("a.report-cta");return link?link.href:null;')
+    if full:
+        app('a.renderRoot.querySelector("kf-drawer").shadowRoot.querySelector("a.report-cta").click();')
+        wait(A+'._route.name==="report" && '+A+'.renderRoot.querySelector("view-report")?.renderRoot.querySelector("article.ch")')
+        report=app('const v=a.renderRoot.querySelector("view-report");return {id:v.artifactId,ch:v.renderRoot.querySelector("article.ch").id,back:v.renderRoot.querySelector("a.report-back")?.href};')
+        assert report['back'] and '/pool/' in report['back'],report
+        app('a.renderRoot.querySelector("view-report").renderRoot.querySelector("[data-toc] a").click();')
+        wait('new URL(location.href).searchParams.has("ch")');evaluate('new Promise(r=>setTimeout(r,500))',True)
+        chapter=app('const v=a.renderRoot.querySelector("view-report"),t=v.renderRoot.querySelector("[data-toc]").getBoundingClientRect(),c=v.renderRoot.querySelector("article.ch").getBoundingClientRect();return {chapterTop:c.top,tocBottom:t.bottom};')
+        assert chapter['chapterTop']>=chapter['tocBottom']-2,chapter
+        shot('unified-report-chapter');call('Page.reload')
+        wait(A+'?.renderRoot.querySelector("view-report")?.renderRoot.querySelector("article.ch")')
+        app('a.renderRoot.querySelector("view-report").renderRoot.querySelector("a.report-back").click();')
+        wait(A+'._route.name==="mode" && '+A+'._drawer?.type==="opp"')
+        checks.append('report-reading-chapter-reload-and-return')
+    escape();wait(A+'._route.name==="pool" && !'+A+'._drawer')
+    assert evaluate('new URL(location.href).searchParams.get("sort")')=='newest'
+    checks.append('opportunity-close-preserves-pool-filters')
+    # Live screens and research library remain available at the same origin.
+    for route,tag in [('/','view-workbench'),('/stages','view-stages'),('/reports','view-reports'),('/reports/category','view-report-type'),('/members','view-members'),('/library','view-library')]:
+        go(route,tag)
+        if route=='/members':
+            ready();assert action('return !!v.renderRoot.querySelector("#manage-groups") && !v.renderRoot.querySelector("input[type=checkbox]");')
+        if route=='/library':
+            wait(A+'.renderRoot.querySelector("view-library")._data && !'+A+'.renderRoot.querySelector("view-library")._loading')
+            assert app('return a.renderRoot.querySelector("view-library")._summary.counts.products;')==4639
+        assert app('return !a.renderRoot.querySelector("kf-topbar").renderRoot.querySelector("h1");')
+        shot('unified-'+tag)
+    checks.extend(['group-modal-preserved','research-library-preserved','titlebar-removal-preserved'])
+    # Retired introduction and sample URLs return to the actual workbench.
+    for route in ['/showcase','/showcase/leadership','/showcase/research','/showcase/collaboration','/showcase/validation','/showcase/classroom','/cockpit','/intel','/coop','/review','/class']:
+        evaluate('import("/src/utils/router.js").then(m=>m.navigate('+json.dumps(route)+'))',True)
+        wait(A+'?._route.name==="workbench" && location.pathname==="/" && '+A+'.renderRoot.querySelector("view-workbench")')
+        assert app('return !a.renderRoot.querySelector("sample-shell,view-showcase");')
+    call('Page.navigate',dict(url=args.url.split('/members')[0]+'/showcase/research?case=A'))
+    wait(A+'?._route.name==="workbench" && location.pathname==="/" && !location.search && '+A+'.renderRoot.querySelector("view-workbench")')
+    call('Page.reload')
+    wait(A+'?.renderRoot.querySelector("view-workbench")')
+    assert not evaluate('performance.getEntriesByType("resource").some(e=>/\\/(?:assets\\/showcase|sample-shell|view-showcase)/.test(e.name))')
+    checks.extend(['retired-demo-links-return-to-workbench','retired-demo-deep-link','workbench-reload','no-demo-resources-loaded'])
+    # Mobile and desktop share the same route table and retained navigation.
+    layouts=[]
+    for width,height in [(1440,1000),(390,844),(320,740)]:
+        call('Emulation.setDeviceMetricsOverride',dict(width=width,height=height,deviceScaleFactor=1,mobile=width<700))
+        for route,tag in [('/members','view-members'),('/ledger','view-ledger'),('/','view-workbench'),('/library','view-library')]:
+            go(route,tag)
+            info=evaluate('({route:location.pathname,width:innerWidth,scrollWidth:document.documentElement.scrollWidth})')
+            assert info['scrollWidth']<=width,info
+            layouts.append(info)
+        shot('unified-overview-'+str(width))
+    failures=[e for e in events if e.get('method')=='Runtime.exceptionThrown']
+    assert not failures,failures
+    result=dict(ok=True,base=args.url,checks=checks,db=db,layouts=layouts,consoleExceptions=0,productionWrites=0)
+    (WORK/'acceptance.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8');print(json.dumps(result,ensure_ascii=False,indent=2))
+
+finally:
+    if ws:ws.close()
+    process.terminate()
+    try:process.wait(timeout=8)
+    except subprocess.TimeoutExpired:process.kill()

+ 44 - 0
server/library-api.mjs

@@ -0,0 +1,44 @@
+/** Same-origin read service. Production requires a gateway token, kept server-side. */
+import { spawn } from 'node:child_process';
+import { createServer } from 'node:http';
+import { timingSafeEqual } from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+
+const ROOT=path.dirname(fileURLToPath(import.meta.url));
+export function createLibraryApi({db,registry,python=process.env.KSF_LIBRARY_PYTHON||'python',localPreview=false,token=process.env.KSF_LIBRARY_API_TOKEN}={}) {
+  let active=0;
+  return async (req,res)=>{
+    const send=(status,data)=>{res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(data));};
+    if(req.method!=='POST')return send(405,{ok:false,error:{code:'METHOD_NOT_ALLOWED'}});
+    const incoming=Buffer.from(req.headers.authorization||''),expected=Buffer.from('Bearer '+(token||''));
+    if(!localPreview&&(!token||incoming.length!==expected.length||!timingSafeEqual(incoming,expected)))return send(401,{ok:false,error:{code:'AUTH_REQUIRED'}});
+    if(active>=4)return send(429,{ok:false,error:{code:'LIBRARY_BUSY'}});
+    let counted=false;
+    try {
+      let size=0;const chunks=[];
+      for await(const part of req){size+=part.length;if(size>24000)throw Error('REQUEST_TOO_LARGE');chunks.push(part);}
+      const request=JSON.parse(Buffer.concat(chunks).toString('utf8'));
+      if(!request||typeof request!=='object'||Array.isArray(request))throw Error('INVALID_REQUEST');
+      if(!db&&!registry)return send(503,{ok:false,error:{code:'DATABASE_NOT_INSTALLED'}});
+      if(active>=4)return send(429,{ok:false,error:{code:'LIBRARY_BUSY'}});
+      active++;counted=true;
+      const data=await new Promise((resolve,reject)=>{
+        const child=spawn(python,['-B','-X','utf8',path.join(ROOT,'voc-library/board_library.py')],{windowsHide:true,stdio:['pipe','pipe','pipe']});
+        let output='',bytes=0;const timeout=setTimeout(()=>{child.kill();reject(Error('LIBRARY_TIMEOUT'));},25000);
+        child.stdout.setEncoding('utf8');
+        child.stdout.on('data',chunk=>{bytes+=Buffer.byteLength(chunk);if(bytes>5000000){child.kill();reject(Error('RESPONSE_TOO_LARGE'));}else output+=chunk;});
+        child.stderr.resume();child.stdin.on('error',()=>{});
+        child.on('error',()=>{clearTimeout(timeout);reject(Error('LIBRARY_RUNTIME_UNAVAILABLE'));});
+        child.on('close',()=>{clearTimeout(timeout);try{resolve(JSON.parse(output));}catch{reject(Error('LIBRARY_READ_FAILED'));}});
+        child.stdin.end(JSON.stringify({config:{db,registry},request}));
+      });
+      return send(data.ok?200:400,{...data,localPreview});
+    }catch(e){const code=['REQUEST_TOO_LARGE','INVALID_REQUEST','LIBRARY_TIMEOUT','LIBRARY_RUNTIME_UNAVAILABLE','RESPONSE_TOO_LARGE'].includes(e.message)?e.message:'LIBRARY_READ_FAILED';return send(400,{ok:false,error:{code}});}
+    finally{if(counted)active--;}
+  };
+}
+if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
+  if(!process.env.KSF_LIBRARY_API_TOKEN)throw Error('KSF_LIBRARY_API_TOKEN_REQUIRED');
+  createServer(createLibraryApi({db:process.env.KSF_VOC_DB,registry:process.env.KSF_LIBRARY_REGISTRY})).listen(Number(process.env.KSF_LIBRARY_PORT||4180),'127.0.0.1');
+}

+ 7281 - 0
server/references/runtime-data.json

@@ -0,0 +1,7281 @@
+{
+  "appId": "FKieRbIoGt_main",
+  "schemaVersion": "ksf.v0.7",
+  "contractRevision": "ksf.functions.v0.4",
+  "release": "ksf.cloud.v0.1",
+  "tables": {
+    "_User": {
+      "objectId": {
+        "type": "String",
+        "required": false,
+        "label": "系统记录编号"
+      },
+      "createdAt": {
+        "type": "Date",
+        "required": false,
+        "label": "创建时间"
+      },
+      "updatedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "更新时间"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "ACL": {
+        "type": "ACL",
+        "required": false,
+        "label": "记录访问控制"
+      },
+      "username": {
+        "type": "String",
+        "required": false,
+        "label": "登录名"
+      },
+      "password": {
+        "type": "String",
+        "required": false,
+        "label": "平台密码字段"
+      },
+      "email": {
+        "type": "String",
+        "required": false,
+        "label": "电子邮件"
+      },
+      "emailVerified": {
+        "type": "Boolean",
+        "required": false,
+        "label": "邮件验证状态"
+      },
+      "authData": {
+        "type": "Object",
+        "required": false,
+        "label": "认证信息"
+      }
+    },
+    "_Role": {
+      "objectId": {
+        "type": "String",
+        "required": false,
+        "label": "系统记录编号"
+      },
+      "createdAt": {
+        "type": "Date",
+        "required": false,
+        "label": "创建时间"
+      },
+      "updatedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "更新时间"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "ACL": {
+        "type": "ACL",
+        "required": false,
+        "label": "记录访问控制"
+      },
+      "name": {
+        "type": "String",
+        "required": false,
+        "label": "名称"
+      },
+      "users": {
+        "type": "Relation",
+        "targetClass": "_User",
+        "required": false,
+        "label": "用户关系"
+      },
+      "roles": {
+        "type": "Relation",
+        "targetClass": "_Role",
+        "required": false,
+        "label": "角色关系"
+      }
+    },
+    "KfProbe": {
+      "objectId": {
+        "type": "String",
+        "required": false,
+        "label": "系统记录编号"
+      },
+      "createdAt": {
+        "type": "Date",
+        "required": false,
+        "label": "创建时间"
+      },
+      "updatedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "更新时间"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "ACL": {
+        "type": "ACL",
+        "required": false,
+        "label": "记录访问控制"
+      },
+      "note": {
+        "type": "String",
+        "required": false,
+        "label": "备注"
+      }
+    },
+    "KfSource": {
+      "objectId": {
+        "type": "String",
+        "required": false,
+        "label": "系统记录编号"
+      },
+      "createdAt": {
+        "type": "Date",
+        "required": false,
+        "label": "创建时间"
+      },
+      "updatedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "更新时间"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "ACL": {
+        "type": "ACL",
+        "required": false,
+        "label": "记录访问控制"
+      },
+      "meta": {
+        "type": "Object",
+        "required": false,
+        "label": "扩展元数据"
+      },
+      "tags": {
+        "type": "Array",
+        "required": false,
+        "label": "标签"
+      },
+      "layer": {
+        "type": "String",
+        "required": false,
+        "label": "数据分层"
+      },
+      "title": {
+        "type": "String",
+        "required": false,
+        "label": "标题"
+      },
+      "payload": {
+        "type": "Object",
+        "required": false,
+        "label": "原始结构化载荷"
+      },
+      "rawText": {
+        "type": "String",
+        "required": false,
+        "label": "原始正文"
+      },
+      "sources": {
+        "type": "Array",
+        "required": false,
+        "label": "旧版来源数组"
+      },
+      "summary": {
+        "type": "String",
+        "required": false,
+        "label": "摘要"
+      },
+      "searchText": {
+        "type": "String",
+        "required": false,
+        "label": "检索文本"
+      },
+      "sourceType": {
+        "type": "String",
+        "required": false,
+        "label": "旧版来源类型"
+      },
+      "sourceKind": {
+        "type": "String",
+        "required": true,
+        "label": "标准来源类型"
+      },
+      "sourceUrl": {
+        "type": "String",
+        "required": false,
+        "label": "来源网址"
+      },
+      "file": {
+        "type": "File",
+        "required": false,
+        "label": "原始附件"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "原文发布时间"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "取得时间"
+      },
+      "contentHash": {
+        "type": "String",
+        "required": true,
+        "label": "原文摘要哈希"
+      },
+      "verificationState": {
+        "type": "String",
+        "required": true,
+        "label": "来源核验状态"
+      },
+      "platform": {
+        "type": "String",
+        "required": false,
+        "label": "来源平台"
+      },
+      "externalObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "上游对象编号"
+      },
+      "sourceInterface": {
+        "type": "Object",
+        "required": false,
+        "label": "接口与调用口径"
+      },
+      "sourceRecordTime": {
+        "type": "Date",
+        "required": false,
+        "label": "上游数据时间"
+      },
+      "sourceRecordTimeRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始时间文本"
+      },
+      "freshness": {
+        "type": "Object",
+        "required": false,
+        "label": "时效判断"
+      },
+      "mediaManifest": {
+        "type": "Object",
+        "required": false,
+        "label": "媒体交接清单"
+      },
+      "toolOutcome": {
+        "type": "Object",
+        "required": false,
+        "label": "VOC响应摘要"
+      },
+      "providerName": {
+        "type": "String",
+        "required": false,
+        "label": "材料提供方"
+      },
+      "vocRunRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "VOC执行记录"
+      }
+    },
+    "KfSession": {
+      "objectId": {
+        "type": "String",
+        "required": false,
+        "label": "系统记录编号"
+      },
+      "createdAt": {
+        "type": "Date",
+        "required": false,
+        "label": "创建时间"
+      },
+      "updatedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "更新时间"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "ACL": {
+        "type": "ACL",
+        "required": false,
+        "label": "记录访问控制"
+      },
+      "meta": {
+        "type": "Object",
+        "required": false,
+        "label": "扩展元数据"
+      },
+      "step": {
+        "type": "String",
+        "required": false,
+        "label": "当前操作步骤"
+      },
+      "stage": {
+        "type": "String",
+        "required": false,
+        "label": "旧版阶段值"
+      },
+      "groupId": {
+        "type": "String",
+        "required": false,
+        "label": "旧版小组标识"
+      },
+      "seedKey": {
+        "type": "String",
+        "required": false,
+        "label": "旧版业务键"
+      },
+      "msgCount": {
+        "type": "Number",
+        "required": false,
+        "label": "消息数量"
+      },
+      "studentName": {
+        "type": "String",
+        "required": false,
+        "label": "学员显示名"
+      },
+      "lastActiveAt": {
+        "type": "Date",
+        "required": false,
+        "label": "最后活跃时间"
+      },
+      "workshopKey": {
+        "type": "String",
+        "required": true,
+        "label": "工作坊标识"
+      },
+      "userRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "真实账号"
+      },
+      "stageCode": {
+        "type": "String",
+        "required": false,
+        "label": "标准阶段代码"
+      }
+    },
+    "KfInsight": {
+      "objectId": {
+        "type": "String",
+        "required": false,
+        "label": "系统记录编号"
+      },
+      "createdAt": {
+        "type": "Date",
+        "required": false,
+        "label": "创建时间"
+      },
+      "updatedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "更新时间"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "ACL": {
+        "type": "ACL",
+        "required": false,
+        "label": "记录访问控制"
+      },
+      "body": {
+        "type": "String",
+        "required": false,
+        "label": "洞察正文"
+      },
+      "meta": {
+        "type": "Object",
+        "required": false,
+        "label": "扩展元数据"
+      },
+      "layer": {
+        "type": "String",
+        "required": false,
+        "label": "数据分层"
+      },
+      "title": {
+        "type": "String",
+        "required": false,
+        "label": "标题"
+      },
+      "owners": {
+        "type": "Array",
+        "required": false,
+        "label": "归属数组"
+      },
+      "status": {
+        "type": "String",
+        "required": false,
+        "label": "旧版状态值"
+      },
+      "category": {
+        "type": "String",
+        "required": false,
+        "label": "旧版品类文本"
+      },
+      "citedWith": {
+        "type": "Array",
+        "required": false,
+        "label": "共同引用数组"
+      },
+      "ownerCount": {
+        "type": "Number",
+        "required": false,
+        "label": "归属计数"
+      },
+      "reviewedBy": {
+        "type": "String",
+        "required": false,
+        "label": "旧版审核人文本"
+      },
+      "searchText": {
+        "type": "String",
+        "required": false,
+        "label": "检索文本"
+      },
+      "derivedFrom": {
+        "type": "Array",
+        "required": false,
+        "label": "派生来源数组"
+      },
+      "reportRoles": {
+        "type": "Object",
+        "required": false,
+        "label": "报告角色对象"
+      },
+      "mentionCount": {
+        "type": "Number",
+        "required": false,
+        "label": "提及数量"
+      },
+      "crossRefCount": {
+        "type": "Number",
+        "required": false,
+        "label": "交叉引用数量"
+      },
+      "evidenceLevel": {
+        "type": "String",
+        "required": false,
+        "label": "旧版证据等级"
+      },
+      "derivedSources": {
+        "type": "Array",
+        "required": false,
+        "label": "派生资料数组"
+      },
+      "citedInsightIds": {
+        "type": "Array",
+        "required": false,
+        "label": "引用洞察编号数组"
+      },
+      "derivedArtifacts": {
+        "type": "Array",
+        "required": false,
+        "label": "派生成果数组"
+      },
+      "evidenceUnaligned": {
+        "type": "Boolean",
+        "required": false,
+        "label": "证据不一致标志"
+      },
+      "distinctOwnerCount": {
+        "type": "Number",
+        "required": false,
+        "label": "去重归属计数"
+      },
+      "evidenceQuoteCount": {
+        "type": "Number",
+        "required": false,
+        "label": "证言计数"
+      },
+      "insightKind": {
+        "type": "String",
+        "required": true,
+        "label": "洞察类型"
+      },
+      "targetAudience": {
+        "type": "String",
+        "required": false,
+        "label": "目标人群"
+      },
+      "usageScenario": {
+        "type": "String",
+        "required": false,
+        "label": "使用场景"
+      },
+      "jtbd": {
+        "type": "String",
+        "required": false,
+        "label": "用户想完成的任务"
+      },
+      "kanoClass": {
+        "type": "String",
+        "required": false,
+        "label": "需求分级"
+      },
+      "categoryRef": {
+        "type": "Pointer",
+        "targetClass": "KfCategory",
+        "required": false,
+        "label": "规范品类"
+      },
+      "claimKind": {
+        "type": "String",
+        "required": true,
+        "label": "结论性质"
+      },
+      "countBasis": {
+        "type": "Object",
+        "required": false,
+        "label": "统计口径"
+      }
+    },
+    "KfArtifact": {
+      "objectId": {
+        "type": "String",
+        "required": false,
+        "label": "系统记录编号"
+      },
+      "createdAt": {
+        "type": "Date",
+        "required": false,
+        "label": "创建时间"
+      },
+      "updatedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "更新时间"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "ACL": {
+        "type": "ACL",
+        "required": false,
+        "label": "记录访问控制"
+      },
+      "meta": {
+        "type": "Object",
+        "required": false,
+        "label": "扩展元数据"
+      },
+      "tags": {
+        "type": "Array",
+        "required": false,
+        "label": "标签"
+      },
+      "tier": {
+        "type": "String",
+        "required": false,
+        "label": "旧版层级值"
+      },
+      "layer": {
+        "type": "String",
+        "required": false,
+        "label": "数据分层"
+      },
+      "stage": {
+        "type": "String",
+        "required": false,
+        "label": "旧版阶段值"
+      },
+      "title": {
+        "type": "String",
+        "required": false,
+        "label": "标题"
+      },
+      "report": {
+        "type": "Object",
+        "required": false,
+        "label": "旧版报告对象"
+      },
+      "agentId": {
+        "type": "String",
+        "required": false,
+        "label": "数字员工标识"
+      },
+      "content": {
+        "type": "Object",
+        "required": false,
+        "label": "旧版内容对象"
+      },
+      "rawText": {
+        "type": "String",
+        "required": false,
+        "label": "原始正文"
+      },
+      "sources": {
+        "type": "Array",
+        "required": false,
+        "label": "旧版来源数组"
+      },
+      "agentName": {
+        "type": "String",
+        "required": false,
+        "label": "数字员工名称"
+      },
+      "ownerName": {
+        "type": "String",
+        "required": false,
+        "label": "负责人显示名"
+      },
+      "projectId": {
+        "type": "String",
+        "required": false,
+        "label": "旧版项目编号文本"
+      },
+      "promotion": {
+        "type": "Object",
+        "required": false,
+        "label": "晋级信息对象"
+      },
+      "sourceIdx": {
+        "type": "Number",
+        "required": false,
+        "label": "来源序号"
+      },
+      "searchText": {
+        "type": "String",
+        "required": false,
+        "label": "检索文本"
+      },
+      "uploadedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "旧版上传时间"
+      },
+      "uploadedBy": {
+        "type": "String",
+        "required": false,
+        "label": "旧版上传人文本"
+      },
+      "artifactType": {
+        "type": "String",
+        "required": false,
+        "label": "旧版成果类型"
+      },
+      "ownerSession": {
+        "type": "String",
+        "required": false,
+        "label": "旧版会话编号文本"
+      },
+      "uploadMethod": {
+        "type": "String",
+        "required": false,
+        "label": "上传方式"
+      },
+      "evidenceCount": {
+        "type": "Number",
+        "required": false,
+        "label": "证据数量"
+      },
+      "evidenceLevel": {
+        "type": "String",
+        "required": false,
+        "label": "旧版证据等级"
+      },
+      "uploadedByKey": {
+        "type": "String",
+        "required": false,
+        "label": "旧版上传身份键"
+      },
+      "reportType": {
+        "type": "String",
+        "required": true,
+        "label": "标准成果类型"
+      },
+      "version": {
+        "type": "Number",
+        "required": true,
+        "label": "成果版本"
+      },
+      "previousVersionRef": {
+        "type": "Pointer",
+        "targetClass": "KfArtifact",
+        "required": false,
+        "label": "上一版本"
+      },
+      "projectRef": {
+        "type": "Pointer",
+        "targetClass": "KfProject",
+        "required": false,
+        "label": "所属项目"
+      },
+      "sessionRef": {
+        "type": "Pointer",
+        "targetClass": "KfSession",
+        "required": false,
+        "label": "提交会话"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": false,
+        "label": "原始报告资料"
+      },
+      "stageCode": {
+        "type": "String",
+        "required": true,
+        "label": "标准阶段"
+      },
+      "contentSchema": {
+        "type": "String",
+        "required": true,
+        "label": "内容结构版本"
+      },
+      "reportSeriesKey": {
+        "type": "String",
+        "required": true,
+        "label": "成果序列键"
+      },
+      "artifactSubtype": {
+        "type": "String",
+        "required": false,
+        "label": "附属成果细分"
+      },
+      "workshopKey": {
+        "type": "String",
+        "required": false,
+        "label": "所属工作坊"
+      },
+      "groupId": {
+        "type": "String",
+        "required": false,
+        "label": "提交小组"
+      },
+      "reportContentHash": {
+        "type": "String",
+        "required": false,
+        "label": "报告确认内容指纹"
+      },
+      "contentConfirmedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "报告内容确认人"
+      },
+      "contentConfirmedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "报告内容确认时间"
+      },
+      "contentConfirmationRef": {
+        "type": "String",
+        "required": false,
+        "label": "内容确认事件编号"
+      },
+      "dashboardState": {
+        "type": "String",
+        "required": false,
+        "label": "看板发布状态"
+      },
+      "dashboardPublishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "看板发布时间"
+      },
+      "dashboardPublishedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "看板发布经办身份"
+      },
+      "dashboardCardId": {
+        "type": "String",
+        "required": false,
+        "label": "看板卡片编号"
+      },
+      "dashboardPublication": {
+        "type": "Object",
+        "required": false,
+        "label": "发布回执与错误"
+      }
+    },
+    "KfProject": {
+      "objectId": {
+        "type": "String",
+        "required": false,
+        "label": "系统记录编号"
+      },
+      "createdAt": {
+        "type": "Date",
+        "required": false,
+        "label": "创建时间"
+      },
+      "updatedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "更新时间"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "ACL": {
+        "type": "ACL",
+        "required": false,
+        "label": "记录访问控制"
+      },
+      "meta": {
+        "type": "Object",
+        "required": false,
+        "label": "扩展元数据"
+      },
+      "layer": {
+        "type": "String",
+        "required": false,
+        "label": "数据分层"
+      },
+      "score": {
+        "type": "Number",
+        "required": false,
+        "label": "旧版汇总分"
+      },
+      "stage": {
+        "type": "String",
+        "required": false,
+        "label": "旧版阶段值"
+      },
+      "title": {
+        "type": "String",
+        "required": false,
+        "label": "标题"
+      },
+      "track": {
+        "type": "String",
+        "required": false,
+        "label": "赛道"
+      },
+      "status": {
+        "type": "String",
+        "required": false,
+        "label": "旧版状态值"
+      },
+      "agentId": {
+        "type": "String",
+        "required": false,
+        "label": "数字员工标识"
+      },
+      "summary": {
+        "type": "String",
+        "required": false,
+        "label": "摘要"
+      },
+      "category": {
+        "type": "String",
+        "required": false,
+        "label": "旧版品类文本"
+      },
+      "priority": {
+        "type": "String",
+        "required": false,
+        "label": "旧版优先级"
+      },
+      "scenario": {
+        "type": "String",
+        "required": false,
+        "label": "旧版场景文本"
+      },
+      "agentName": {
+        "type": "String",
+        "required": false,
+        "label": "数字员工名称"
+      },
+      "ownerName": {
+        "type": "String",
+        "required": false,
+        "label": "负责人显示名"
+      },
+      "targetUser": {
+        "type": "String",
+        "required": false,
+        "label": "旧版目标人群文本"
+      },
+      "uploadedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "旧版上传时间"
+      },
+      "lastActiveAt": {
+        "type": "Date",
+        "required": false,
+        "label": "最后活跃时间"
+      },
+      "ownerSession": {
+        "type": "String",
+        "required": false,
+        "label": "旧版会话编号文本"
+      },
+      "uploadMethod": {
+        "type": "String",
+        "required": false,
+        "label": "上传方式"
+      },
+      "artifactCount": {
+        "type": "Number",
+        "required": false,
+        "label": "成果计数"
+      },
+      "opportunityId": {
+        "type": "String",
+        "required": false,
+        "label": "旧版机会编号文本"
+      },
+      "uploadedByKey": {
+        "type": "String",
+        "required": false,
+        "label": "旧版上传身份键"
+      },
+      "opportunityRef": {
+        "type": "Pointer",
+        "targetClass": "KfOpportunity",
+        "required": true,
+        "label": "选择的机会"
+      },
+      "sessionRef": {
+        "type": "Pointer",
+        "targetClass": "KfSession",
+        "required": false,
+        "label": "负责人会话"
+      },
+      "workshopKey": {
+        "type": "String",
+        "required": true,
+        "label": "所属工作坊"
+      },
+      "groupId": {
+        "type": "String",
+        "required": false,
+        "label": "小组代码"
+      },
+      "stageCode": {
+        "type": "String",
+        "required": true,
+        "label": "标准当前阶段"
+      },
+      "categoryRef": {
+        "type": "Pointer",
+        "targetClass": "KfCategory",
+        "required": false,
+        "label": "规范品类"
+      },
+      "currentConceptRef": {
+        "type": "Pointer",
+        "targetClass": "KfConcept",
+        "required": false,
+        "label": "当前方案版本"
+      },
+      "progressState": {
+        "type": "String",
+        "required": true,
+        "label": "推进状态"
+      },
+      "memberRefs": {
+        "type": "Relation",
+        "targetClass": "_User",
+        "required": false,
+        "label": "参与成员"
+      }
+    },
+    "KfOpportunity": {
+      "objectId": {
+        "type": "String",
+        "required": false,
+        "label": "系统记录编号"
+      },
+      "createdAt": {
+        "type": "Date",
+        "required": false,
+        "label": "创建时间"
+      },
+      "updatedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "更新时间"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "ACL": {
+        "type": "ACL",
+        "required": false,
+        "label": "记录访问控制"
+      },
+      "stage": {
+        "type": "String",
+        "required": false,
+        "label": "旧版阶段值"
+      },
+      "title": {
+        "type": "String",
+        "required": false,
+        "label": "标题"
+      },
+      "scores": {
+        "type": "Array",
+        "required": false,
+        "label": "旧版评分数组"
+      },
+      "status": {
+        "type": "String",
+        "required": false,
+        "label": "旧版状态值"
+      },
+      "agentId": {
+        "type": "String",
+        "required": false,
+        "label": "数字员工标识"
+      },
+      "seedKey": {
+        "type": "String",
+        "required": false,
+        "label": "旧版业务键"
+      },
+      "category": {
+        "type": "String",
+        "required": false,
+        "label": "旧版品类文本"
+      },
+      "decision": {
+        "type": "Object",
+        "required": false,
+        "label": "决策对象"
+      },
+      "oneLiner": {
+        "type": "String",
+        "required": false,
+        "label": "一句话机会描述"
+      },
+      "priority": {
+        "type": "String",
+        "required": false,
+        "label": "旧版优先级"
+      },
+      "agentName": {
+        "type": "String",
+        "required": false,
+        "label": "数字员工名称"
+      },
+      "citeCount": {
+        "type": "Number",
+        "required": false,
+        "label": "引用数量"
+      },
+      "quoteCount": {
+        "type": "Number",
+        "required": false,
+        "label": "证言数量"
+      },
+      "searchText": {
+        "type": "String",
+        "required": false,
+        "label": "检索文本"
+      },
+      "uploadedAt": {
+        "type": "String",
+        "required": false,
+        "label": "旧版上传时间"
+      },
+      "uploadedBy": {
+        "type": "String",
+        "required": false,
+        "label": "旧版上传人文本"
+      },
+      "decisionLog": {
+        "type": "Array",
+        "required": false,
+        "label": "旧版决策日志数组"
+      },
+      "uploadMethod": {
+        "type": "String",
+        "required": false,
+        "label": "上传方式"
+      },
+      "evidenceLevel": {
+        "type": "String",
+        "required": false,
+        "label": "旧版证据等级"
+      },
+      "linkedProject": {
+        "type": "String",
+        "required": false,
+        "label": "旧版关联项目文本"
+      },
+      "sourceSession": {
+        "type": "String",
+        "required": false,
+        "label": "旧版来源会话文本"
+      },
+      "uploadedByKey": {
+        "type": "String",
+        "required": false,
+        "label": "旧版上传身份键"
+      },
+      "linkedArtifacts": {
+        "type": "Array",
+        "required": false,
+        "label": "旧版关联成果数组"
+      },
+      "scoringMethodVersion": {
+        "type": "String",
+        "required": false,
+        "label": "机会评分方法版本"
+      },
+      "categoryRef": {
+        "type": "Pointer",
+        "targetClass": "KfCategory",
+        "required": false,
+        "label": "规范品类"
+      },
+      "targetAudience": {
+        "type": "String",
+        "required": true,
+        "label": "目标人群"
+      },
+      "usageScenario": {
+        "type": "String",
+        "required": true,
+        "label": "使用场景"
+      },
+      "claimKind": {
+        "type": "String",
+        "required": true,
+        "label": "机会性质"
+      },
+      "version": {
+        "type": "Number",
+        "required": true,
+        "label": "卡片版本"
+      },
+      "assessmentRef": {
+        "type": "Pointer",
+        "targetClass": "KfAssessment",
+        "required": false,
+        "label": "当前有效验证"
+      },
+      "sessionRef": {
+        "type": "Pointer",
+        "targetClass": "KfSession",
+        "required": false,
+        "label": "来源会话"
+      },
+      "fusionDefinition": {
+        "type": "String",
+        "required": true,
+        "label": "三源口径"
+      },
+      "opportunitySeriesKey": {
+        "type": "String",
+        "required": true,
+        "label": "机会序列键"
+      },
+      "previousVersionRef": {
+        "type": "Pointer",
+        "targetClass": "KfOpportunity",
+        "required": false,
+        "label": "上一版机会"
+      },
+      "demandSummary": {
+        "type": "String",
+        "required": true,
+        "label": "核心需求"
+      },
+      "trendSummary": {
+        "type": "String",
+        "required": false,
+        "label": "趋势依据摘要"
+      },
+      "matrixGap": {
+        "type": "String",
+        "required": false,
+        "label": "竞争矩阵空白"
+      },
+      "insightRefs": {
+        "type": "Relation",
+        "targetClass": "KfInsight",
+        "required": false,
+        "label": "所依据洞察"
+      },
+      "metricRefs": {
+        "type": "Relation",
+        "targetClass": "KfMarketMetric",
+        "required": false,
+        "label": "所依据指标"
+      }
+    },
+    "KfCategory": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "code": {
+        "type": "String",
+        "required": true,
+        "label": "品类代码"
+      },
+      "name": {
+        "type": "String",
+        "required": true,
+        "label": "品类名称"
+      },
+      "parentRef": {
+        "type": "Pointer",
+        "targetClass": "KfCategory",
+        "required": false,
+        "label": "上级品类"
+      },
+      "taxonomyVersion": {
+        "type": "String",
+        "required": true,
+        "label": "分类版本"
+      },
+      "axis": {
+        "type": "String",
+        "required": true,
+        "label": "分类维度"
+      },
+      "scopeNote": {
+        "type": "String",
+        "required": true,
+        "label": "范围说明"
+      }
+    },
+    "KfCategoryRelation": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "fromCategoryRef": {
+        "type": "Pointer",
+        "targetClass": "KfCategory",
+        "required": true,
+        "label": "起点品类"
+      },
+      "toCategoryRef": {
+        "type": "Pointer",
+        "targetClass": "KfCategory",
+        "required": true,
+        "label": "终点品类"
+      },
+      "relationType": {
+        "type": "String",
+        "required": true,
+        "label": "关系类型"
+      },
+      "taxonomyVersion": {
+        "type": "String",
+        "required": true,
+        "label": "图谱版本"
+      },
+      "rationale": {
+        "type": "String",
+        "required": true,
+        "label": "关系说明"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": false,
+        "label": "关系依据"
+      },
+      "claimKind": {
+        "type": "String",
+        "required": true,
+        "label": "结论性质"
+      }
+    },
+    "KfProduct": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "name": {
+        "type": "String",
+        "required": true,
+        "label": "产品名"
+      },
+      "brandName": {
+        "type": "String",
+        "required": true,
+        "label": "品牌"
+      },
+      "categoryRef": {
+        "type": "Pointer",
+        "targetClass": "KfCategory",
+        "required": true,
+        "label": "所属品类"
+      },
+      "flavor": {
+        "type": "String",
+        "required": false,
+        "label": "口味"
+      },
+      "netWeightG": {
+        "type": "Number",
+        "required": false,
+        "label": "净含量(克)"
+      },
+      "packCount": {
+        "type": "Number",
+        "required": false,
+        "label": "包装件数"
+      },
+      "packaging": {
+        "type": "String",
+        "required": false,
+        "label": "包装形态"
+      },
+      "isOwnBrand": {
+        "type": "Boolean",
+        "required": true,
+        "label": "是否自有品牌"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "产品来源"
+      },
+      "platformProductId": {
+        "type": "String",
+        "required": false,
+        "label": "平台商品标识"
+      },
+      "observedRetailPrice": {
+        "type": "Number",
+        "required": false,
+        "label": "零售观察价"
+      },
+      "currency": {
+        "type": "String",
+        "required": false,
+        "label": "币种"
+      },
+      "retailPriceBasis": {
+        "type": "String",
+        "required": false,
+        "label": "零售计价单位"
+      },
+      "priceObservedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "观察时间"
+      },
+      "productLevel": {
+        "type": "String",
+        "required": true,
+        "label": "产品粒度"
+      },
+      "skuId": {
+        "type": "String",
+        "required": false,
+        "label": "SKU编号"
+      },
+      "shopName": {
+        "type": "String",
+        "required": false,
+        "label": "店铺名称"
+      },
+      "attributes": {
+        "type": "Object",
+        "required": false,
+        "label": "规格属性"
+      },
+      "priceKind": {
+        "type": "String",
+        "required": false,
+        "label": "零售价口径"
+      },
+      "platform": {
+        "type": "String",
+        "required": false,
+        "label": "商品平台"
+      },
+      "sellingPoints": {
+        "type": "Array",
+        "required": false,
+        "label": "产品特色与卖点"
+      },
+      "targetAudience": {
+        "type": "String",
+        "required": false,
+        "label": "服务人群"
+      },
+      "usageScenarios": {
+        "type": "Array",
+        "required": false,
+        "label": "消费场景"
+      },
+      "launchDate": {
+        "type": "Date",
+        "required": false,
+        "label": "上市时间"
+      }
+    },
+    "KfEvidence": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "原始资料"
+      },
+      "artifactRef": {
+        "type": "Pointer",
+        "targetClass": "KfArtifact",
+        "required": false,
+        "label": "提取自哪份报告"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": false,
+        "label": "对应现有产品"
+      },
+      "quote": {
+        "type": "String",
+        "required": true,
+        "label": "原文片段"
+      },
+      "locator": {
+        "type": "Object",
+        "required": true,
+        "label": "原文定位"
+      },
+      "evidenceKind": {
+        "type": "String",
+        "required": true,
+        "label": "证据类别"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "证据取得时间"
+      },
+      "verificationState": {
+        "type": "String",
+        "required": true,
+        "label": "证据核验"
+      },
+      "platform": {
+        "type": "String",
+        "required": false,
+        "label": "证言平台"
+      },
+      "externalCommentId": {
+        "type": "String",
+        "required": false,
+        "label": "稳定评论编号"
+      },
+      "parentObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "所属帖子或视频编号"
+      },
+      "quoteForm": {
+        "type": "String",
+        "required": true,
+        "label": "原文完整性"
+      },
+      "upstreamUrlFlag": {
+        "type": "Boolean",
+        "required": false,
+        "label": "插件链接标志"
+      },
+      "metrics": {
+        "type": "Object",
+        "required": false,
+        "label": "互动快照"
+      },
+      "authorLabel": {
+        "type": "String",
+        "required": false,
+        "label": "作者公开显示名"
+      },
+      "authorExternalId": {
+        "type": "String",
+        "required": false,
+        "label": "作者平台标识"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "证言发布时间"
+      },
+      "rawRecordTable": {
+        "type": "String",
+        "required": false,
+        "label": "原始记录表名"
+      },
+      "rawRecordId": {
+        "type": "String",
+        "required": false,
+        "label": "原始记录编号"
+      }
+    },
+    "KfEvidenceLink": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "evidenceRef": {
+        "type": "Pointer",
+        "targetClass": "KfEvidence",
+        "required": true,
+        "label": "证据"
+      },
+      "targetClass": {
+        "type": "String",
+        "required": true,
+        "label": "被支持对象的表"
+      },
+      "targetId": {
+        "type": "String",
+        "required": true,
+        "label": "被支持对象编号"
+      },
+      "claimPath": {
+        "type": "String",
+        "required": false,
+        "label": "支持的具体字段"
+      },
+      "stance": {
+        "type": "String",
+        "required": true,
+        "label": "证据关系"
+      },
+      "rationale": {
+        "type": "String",
+        "required": true,
+        "label": "关联说明"
+      },
+      "targetVersion": {
+        "type": "Number",
+        "required": false,
+        "label": "被支持对象版本"
+      }
+    },
+    "KfMarketMetric": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "categoryRef": {
+        "type": "Pointer",
+        "targetClass": "KfCategory",
+        "required": false,
+        "label": "对应品类"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": false,
+        "label": "对应产品"
+      },
+      "metricCode": {
+        "type": "String",
+        "required": true,
+        "label": "指标代码"
+      },
+      "value": {
+        "type": "Number",
+        "required": true,
+        "label": "指标值"
+      },
+      "unit": {
+        "type": "String",
+        "required": true,
+        "label": "单位"
+      },
+      "numerator": {
+        "type": "Number",
+        "required": false,
+        "label": "分子"
+      },
+      "denominator": {
+        "type": "Number",
+        "required": false,
+        "label": "分母"
+      },
+      "periodStart": {
+        "type": "Date",
+        "required": false,
+        "label": "统计开始时间"
+      },
+      "periodEnd": {
+        "type": "Date",
+        "required": false,
+        "label": "统计结束时间"
+      },
+      "platform": {
+        "type": "String",
+        "required": false,
+        "label": "来源平台"
+      },
+      "sampleDefinition": {
+        "type": "String",
+        "required": true,
+        "label": "样本范围与去重"
+      },
+      "method": {
+        "type": "String",
+        "required": true,
+        "label": "计算方法"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "指标依据"
+      },
+      "artifactRef": {
+        "type": "Pointer",
+        "targetClass": "KfArtifact",
+        "required": false,
+        "label": "提取自哪份报告"
+      },
+      "observedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "观察时间"
+      }
+    },
+    "KfAssessment": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "opportunityRef": {
+        "type": "Pointer",
+        "targetClass": "KfOpportunity",
+        "required": true,
+        "label": "被验证机会"
+      },
+      "projectRef": {
+        "type": "Pointer",
+        "targetClass": "KfProject",
+        "required": false,
+        "label": "对应项目"
+      },
+      "targetVersion": {
+        "type": "Number",
+        "required": true,
+        "label": "对象版本"
+      },
+      "modelDefinition": {
+        "type": "Object",
+        "required": true,
+        "label": "评分模型快照"
+      },
+      "dimensionScores": {
+        "type": "Array",
+        "required": true,
+        "label": "逐项判断"
+      },
+      "totalScore": {
+        "type": "Number",
+        "required": false,
+        "label": "汇总分"
+      },
+      "priority": {
+        "type": "String",
+        "required": false,
+        "label": "建议优先级"
+      },
+      "feasibility": {
+        "type": "Object",
+        "required": true,
+        "label": "可行性判断"
+      },
+      "costAssumptions": {
+        "type": "Object",
+        "required": false,
+        "label": "成本假设"
+      },
+      "crossChecks": {
+        "type": "Array",
+        "required": false,
+        "label": "交叉审查记录"
+      },
+      "conclusion": {
+        "type": "String",
+        "required": true,
+        "label": "验证结论"
+      },
+      "targetKind": {
+        "type": "String",
+        "required": true,
+        "label": "验证对象类型"
+      },
+      "conceptRef": {
+        "type": "Pointer",
+        "targetClass": "KfConcept",
+        "required": false,
+        "label": "被验证的方案"
+      }
+    },
+    "KfConcept": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "projectRef": {
+        "type": "Pointer",
+        "targetClass": "KfProject",
+        "required": true,
+        "label": "所属项目"
+      },
+      "version": {
+        "type": "Number",
+        "required": true,
+        "label": "方案版本"
+      },
+      "previousVersionRef": {
+        "type": "Pointer",
+        "targetClass": "KfConcept",
+        "required": false,
+        "label": "上一版方案"
+      },
+      "name": {
+        "type": "String",
+        "required": true,
+        "label": "拟定产品名"
+      },
+      "targetAudience": {
+        "type": "String",
+        "required": true,
+        "label": "目标人群"
+      },
+      "usageScenario": {
+        "type": "String",
+        "required": true,
+        "label": "使用场景"
+      },
+      "flavor": {
+        "type": "String",
+        "required": false,
+        "label": "风味方向"
+      },
+      "specification": {
+        "type": "Object",
+        "required": true,
+        "label": "产品规格"
+      },
+      "valueProposition": {
+        "type": "String",
+        "required": true,
+        "label": "用户买点"
+      },
+      "sellingPoints": {
+        "type": "Array",
+        "required": false,
+        "label": "卖点列表"
+      },
+      "productionSteps": {
+        "type": "Array",
+        "required": false,
+        "label": "生产路线"
+      },
+      "assessmentRef": {
+        "type": "Pointer",
+        "targetClass": "KfAssessment",
+        "required": false,
+        "label": "对应可行性验证"
+      },
+      "proposalArtifactRef": {
+        "type": "Pointer",
+        "targetClass": "KfArtifact",
+        "required": false,
+        "label": "最终提案文件"
+      },
+      "insightRefs": {
+        "type": "Relation",
+        "targetClass": "KfInsight",
+        "required": false,
+        "label": "方案对应需求"
+      }
+    },
+    "KfStageEvent": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "projectRef": {
+        "type": "Pointer",
+        "targetClass": "KfProject",
+        "required": true,
+        "label": "项目"
+      },
+      "sessionRef": {
+        "type": "Pointer",
+        "targetClass": "KfSession",
+        "required": false,
+        "label": "操作会话"
+      },
+      "fromStage": {
+        "type": "String",
+        "required": false,
+        "label": "原阶段代码"
+      },
+      "toStage": {
+        "type": "String",
+        "required": true,
+        "label": "目标阶段代码"
+      },
+      "eventType": {
+        "type": "String",
+        "required": true,
+        "label": "事件类型"
+      },
+      "artifactRef": {
+        "type": "Pointer",
+        "targetClass": "KfArtifact",
+        "required": false,
+        "label": "对应成果"
+      },
+      "occurredAt": {
+        "type": "Date",
+        "required": true,
+        "label": "发生时间"
+      },
+      "note": {
+        "type": "String",
+        "required": false,
+        "label": "事件说明"
+      },
+      "sequence": {
+        "type": "Number",
+        "required": true,
+        "label": "项目事件序号"
+      }
+    },
+    "KfImportJob": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "jobKey": {
+        "type": "String",
+        "required": true,
+        "label": "幂等批次键"
+      },
+      "inputSourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "输入原件"
+      },
+      "producer": {
+        "type": "Object",
+        "required": true,
+        "label": "生产者与技能"
+      },
+      "status": {
+        "type": "String",
+        "required": true,
+        "label": "批次状态"
+      },
+      "expectedCount": {
+        "type": "Number",
+        "required": true,
+        "label": "计划条数"
+      },
+      "successCount": {
+        "type": "Number",
+        "required": true,
+        "label": "成功条数"
+      },
+      "failureCount": {
+        "type": "Number",
+        "required": true,
+        "label": "失败条数"
+      },
+      "startedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "开始时间"
+      },
+      "finishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "结束时间"
+      },
+      "summary": {
+        "type": "String",
+        "required": false,
+        "label": "结果摘要"
+      },
+      "vocRunRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "来源VOC执行"
+      }
+    },
+    "KfImportItem": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "jobRef": {
+        "type": "Pointer",
+        "targetClass": "KfImportJob",
+        "required": true,
+        "label": "所属批次"
+      },
+      "itemKey": {
+        "type": "String",
+        "required": true,
+        "label": "批内条目键"
+      },
+      "targetClass": {
+        "type": "String",
+        "required": true,
+        "label": "目标表"
+      },
+      "targetId": {
+        "type": "String",
+        "required": false,
+        "label": "写入后编号"
+      },
+      "businessKey": {
+        "type": "String",
+        "required": true,
+        "label": "目标稳定业务键"
+      },
+      "payloadHash": {
+        "type": "String",
+        "required": true,
+        "label": "本次内容哈希"
+      },
+      "dependencies": {
+        "type": "Array",
+        "required": true,
+        "label": "依赖条目键"
+      },
+      "status": {
+        "type": "String",
+        "required": true,
+        "label": "条目状态"
+      },
+      "operation": {
+        "type": "String",
+        "required": false,
+        "label": "操作结果"
+      },
+      "attempts": {
+        "type": "Number",
+        "required": true,
+        "label": "尝试次数"
+      },
+      "errorCode": {
+        "type": "String",
+        "required": false,
+        "label": "错误代码"
+      },
+      "errorMessage": {
+        "type": "String",
+        "required": false,
+        "label": "可理解的错误"
+      }
+    },
+    "KfReviewIssue": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "artifactRef": {
+        "type": "Pointer",
+        "targetClass": "KfArtifact",
+        "required": true,
+        "label": "所属报告或成果"
+      },
+      "targetClass": {
+        "type": "String",
+        "required": false,
+        "label": "需校准的表"
+      },
+      "targetId": {
+        "type": "String",
+        "required": false,
+        "label": "需校准的记录"
+      },
+      "fieldPath": {
+        "type": "String",
+        "required": false,
+        "label": "需校准的字段"
+      },
+      "question": {
+        "type": "String",
+        "required": true,
+        "label": "待确认问题"
+      },
+      "assignedRole": {
+        "type": "String",
+        "required": true,
+        "label": "建议回答方"
+      },
+      "blocking": {
+        "type": "Boolean",
+        "required": true,
+        "label": "是否阻塞推进"
+      },
+      "status": {
+        "type": "String",
+        "required": true,
+        "label": "问题状态"
+      },
+      "answer": {
+        "type": "String",
+        "required": false,
+        "label": "反馈原文"
+      },
+      "answerSourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": false,
+        "label": "反馈材料"
+      },
+      "confirmedBy": {
+        "type": "String",
+        "required": false,
+        "label": "确认人"
+      },
+      "confirmedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "feedbackKind": {
+        "type": "String",
+        "required": false,
+        "label": "反馈性质"
+      },
+      "resolution": {
+        "type": "Object",
+        "required": false,
+        "label": "采纳与变更记录"
+      },
+      "applyState": {
+        "type": "String",
+        "required": true,
+        "label": "数据修订状态"
+      },
+      "appliedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "数据修订时间"
+      },
+      "knowledgeSync": {
+        "type": "Object",
+        "required": false,
+        "label": "知识库同步回执"
+      }
+    },
+    "KfSupplier": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "name": {
+        "type": "String",
+        "required": true,
+        "label": "供应商名称"
+      },
+      "supplierType": {
+        "type": "String",
+        "required": false,
+        "label": "供应方类别"
+      },
+      "region": {
+        "type": "String",
+        "required": false,
+        "label": "供应地区"
+      },
+      "materialRefs": {
+        "type": "Relation",
+        "targetClass": "KfMaterial",
+        "required": false,
+        "label": "可供应素材"
+      },
+      "supplyScope": {
+        "type": "String",
+        "required": false,
+        "label": "供应范围"
+      },
+      "leadTimeDays": {
+        "type": "Number",
+        "required": false,
+        "label": "交期天数"
+      },
+      "qualification": {
+        "type": "Object",
+        "required": false,
+        "label": "资质信息"
+      },
+      "approvalState": {
+        "type": "String",
+        "required": true,
+        "label": "客户准入状态"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "信息依据"
+      },
+      "platformBadge": {
+        "type": "String",
+        "required": false,
+        "label": "平台认证标签"
+      },
+      "platformBadgeObservedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "平台标签观察时间"
+      }
+    },
+    "KfMaterial": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "name": {
+        "type": "String",
+        "required": true,
+        "label": "原料名"
+      },
+      "materialType": {
+        "type": "String",
+        "required": true,
+        "label": "素材类别"
+      },
+      "specification": {
+        "type": "String",
+        "required": false,
+        "label": "规格说明"
+      },
+      "function": {
+        "type": "String",
+        "required": false,
+        "label": "用途"
+      },
+      "allergens": {
+        "type": "Array",
+        "required": false,
+        "label": "过敏原信息"
+      },
+      "applicableCategoryRefs": {
+        "type": "Relation",
+        "targetClass": "KfCategory",
+        "required": false,
+        "label": "适用品类"
+      },
+      "constraints": {
+        "type": "Object",
+        "required": false,
+        "label": "使用限制"
+      },
+      "aliases": {
+        "type": "Array",
+        "required": false,
+        "label": "别名"
+      },
+      "sensoryProfile": {
+        "type": "Object",
+        "required": false,
+        "label": "口感与风味属性"
+      },
+      "composition": {
+        "type": "Array",
+        "required": false,
+        "label": "复合素材组成"
+      },
+      "standardRefs": {
+        "type": "Relation",
+        "targetClass": "KfStandard",
+        "required": false,
+        "label": "相关标准"
+      },
+      "storageConditions": {
+        "type": "String",
+        "required": false,
+        "label": "储运条件"
+      },
+      "shelfLifeDays": {
+        "type": "Number",
+        "required": false,
+        "label": "保质期天数"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "原料资料来源"
+      }
+    },
+    "KfProcess": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "name": {
+        "type": "String",
+        "required": true,
+        "label": "工艺名称"
+      },
+      "processType": {
+        "type": "String",
+        "required": true,
+        "label": "工艺类型"
+      },
+      "description": {
+        "type": "String",
+        "required": true,
+        "label": "工艺描述"
+      },
+      "parameters": {
+        "type": "Array",
+        "required": false,
+        "label": "参数及条件"
+      },
+      "equipmentRequirements": {
+        "type": "String",
+        "required": false,
+        "label": "设备要求"
+      },
+      "applicableCategoryRefs": {
+        "type": "Relation",
+        "targetClass": "KfCategory",
+        "required": false,
+        "label": "适用品类"
+      },
+      "standardRefs": {
+        "type": "Relation",
+        "targetClass": "KfStandard",
+        "required": false,
+        "label": "相关标准"
+      },
+      "processKind": {
+        "type": "String",
+        "required": true,
+        "label": "工艺记录粒度"
+      },
+      "inputMaterialRefs": {
+        "type": "Relation",
+        "targetClass": "KfMaterial",
+        "required": false,
+        "label": "投入素材"
+      },
+      "outputMaterialRefs": {
+        "type": "Relation",
+        "targetClass": "KfMaterial",
+        "required": false,
+        "label": "产出素材"
+      },
+      "steps": {
+        "type": "Array",
+        "required": false,
+        "label": "工序顺序"
+      },
+      "factoryContext": {
+        "type": "Object",
+        "required": false,
+        "label": "工厂适用条件"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "工艺资料来源"
+      }
+    },
+    "KfStandard": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "standardCode": {
+        "type": "String",
+        "required": true,
+        "label": "标准编号"
+      },
+      "edition": {
+        "type": "String",
+        "required": true,
+        "label": "版本或年份"
+      },
+      "title": {
+        "type": "String",
+        "required": true,
+        "label": "标准标题"
+      },
+      "status": {
+        "type": "String",
+        "required": true,
+        "label": "有效性状态"
+      },
+      "effectiveDate": {
+        "type": "Date",
+        "required": false,
+        "label": "实施日期"
+      },
+      "scope": {
+        "type": "String",
+        "required": true,
+        "label": "适用范围"
+      },
+      "clauses": {
+        "type": "Array",
+        "required": false,
+        "label": "相关条款"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "权威来源"
+      },
+      "verifiedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "核验时间"
+      },
+      "standardType": {
+        "type": "String",
+        "required": true,
+        "label": "标准类别"
+      },
+      "issuer": {
+        "type": "String",
+        "required": false,
+        "label": "发布机构"
+      },
+      "replacedByRef": {
+        "type": "Pointer",
+        "targetClass": "KfStandard",
+        "required": false,
+        "label": "替代标准"
+      }
+    },
+    "KfSupplyQuote": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "materialRef": {
+        "type": "Pointer",
+        "targetClass": "KfMaterial",
+        "required": true,
+        "label": "报价原料或包装"
+      },
+      "supplierName": {
+        "type": "String",
+        "required": true,
+        "label": "供应方"
+      },
+      "price": {
+        "type": "Number",
+        "required": true,
+        "label": "报价"
+      },
+      "currency": {
+        "type": "String",
+        "required": true,
+        "label": "币种"
+      },
+      "unit": {
+        "type": "String",
+        "required": true,
+        "label": "计价单位"
+      },
+      "specification": {
+        "type": "String",
+        "required": true,
+        "label": "对应规格"
+      },
+      "quantityBasis": {
+        "type": "Object",
+        "required": true,
+        "label": "数量及起订条件"
+      },
+      "quotedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "报价时间"
+      },
+      "validUntil": {
+        "type": "Date",
+        "required": false,
+        "label": "报价有效期"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "报价依据"
+      },
+      "supplierRef": {
+        "type": "Pointer",
+        "targetClass": "KfSupplier",
+        "required": true,
+        "label": "规范供应商"
+      },
+      "quoteKind": {
+        "type": "String",
+        "required": true,
+        "label": "报价性质"
+      },
+      "leadTimeDays": {
+        "type": "Number",
+        "required": false,
+        "label": "本次报价交期"
+      },
+      "standardRefs": {
+        "type": "Relation",
+        "targetClass": "KfStandard",
+        "required": false,
+        "label": "执行标准"
+      },
+      "rawOfferRef": {
+        "type": "Pointer",
+        "targetClass": "KfRaw1688Offer",
+        "required": false,
+        "label": "1688原始货源"
+      },
+      "moq": {
+        "type": "Number",
+        "required": false,
+        "label": "起订量"
+      },
+      "moqUnit": {
+        "type": "String",
+        "required": false,
+        "label": "起订单位"
+      },
+      "priceTiers": {
+        "type": "Array",
+        "required": false,
+        "label": "阶梯价"
+      }
+    },
+    "KfFormulaItem": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "conceptRef": {
+        "type": "Pointer",
+        "targetClass": "KfConcept",
+        "required": true,
+        "label": "所属方案版本"
+      },
+      "lineNo": {
+        "type": "Number",
+        "required": true,
+        "label": "配料序号"
+      },
+      "materialRef": {
+        "type": "Pointer",
+        "targetClass": "KfMaterial",
+        "required": true,
+        "label": "对应素材"
+      },
+      "component": {
+        "type": "String",
+        "required": true,
+        "label": "产品组成部分"
+      },
+      "amount": {
+        "type": "Number",
+        "required": false,
+        "label": "用量"
+      },
+      "unit": {
+        "type": "String",
+        "required": false,
+        "label": "用量单位"
+      },
+      "basis": {
+        "type": "String",
+        "required": true,
+        "label": "配方计算基准"
+      },
+      "quoteRef": {
+        "type": "Pointer",
+        "targetClass": "KfSupplyQuote",
+        "required": false,
+        "label": "成本报价"
+      },
+      "note": {
+        "type": "String",
+        "required": false,
+        "label": "条件说明"
+      }
+    },
+    "KfVocRun": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "采集平台"
+      },
+      "toolName": {
+        "type": "String",
+        "required": true,
+        "label": "VOC工具名称"
+      },
+      "interfaceId": {
+        "type": "String",
+        "required": false,
+        "label": "上游接口标识"
+      },
+      "requestContext": {
+        "type": "Object",
+        "required": true,
+        "label": "查询条件快照"
+      },
+      "collectionMode": {
+        "type": "String",
+        "required": true,
+        "label": "采集模式"
+      },
+      "usageTraceId": {
+        "type": "String",
+        "required": false,
+        "label": "调用追踪号"
+      },
+      "status": {
+        "type": "String",
+        "required": true,
+        "label": "执行状态"
+      },
+      "startedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "开始时间"
+      },
+      "finishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "结束时间"
+      },
+      "resultSummary": {
+        "type": "Object",
+        "required": false,
+        "label": "结果摘要"
+      },
+      "usage": {
+        "type": "Object",
+        "required": false,
+        "label": "调用用量"
+      },
+      "checkpoint": {
+        "type": "Object",
+        "required": false,
+        "label": "续跑检查点"
+      },
+      "warnings": {
+        "type": "Array",
+        "required": false,
+        "label": "警告摘要"
+      },
+      "errors": {
+        "type": "Array",
+        "required": false,
+        "label": "错误摘要"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": false,
+        "label": "完整VOC响应"
+      }
+    },
+    "KfRawXhsNote": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "runRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "VOC执行记录"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "完整响应来源"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "实际平台"
+      },
+      "externalObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "平台对象ID"
+      },
+      "idOrigin": {
+        "type": "String",
+        "required": true,
+        "label": "对象ID来源"
+      },
+      "keyword": {
+        "type": "String",
+        "required": false,
+        "label": "采集关键词"
+      },
+      "sourceUrl": {
+        "type": "String",
+        "required": false,
+        "label": "原始链接"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "内容发布时间"
+      },
+      "publishedAtRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始发布时间文本"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "本次取得时间"
+      },
+      "recordIndex": {
+        "type": "Number",
+        "required": true,
+        "label": "本次返回序号"
+      },
+      "payloadHash": {
+        "type": "String",
+        "required": true,
+        "label": "逐条内容指纹"
+      },
+      "rawPayload": {
+        "type": "Object",
+        "required": true,
+        "label": "逐条VOC返回对象"
+      },
+      "payloadLevel": {
+        "type": "String",
+        "required": true,
+        "label": "原文层级"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": false,
+        "label": "归一产品"
+      },
+      "evidenceRef": {
+        "type": "Pointer",
+        "targetClass": "KfEvidence",
+        "required": false,
+        "label": "主要归一证据"
+      },
+      "title": {
+        "type": "String",
+        "required": false,
+        "label": "笔记标题"
+      },
+      "content": {
+        "type": "String",
+        "required": false,
+        "label": "笔记正文"
+      },
+      "authorId": {
+        "type": "String",
+        "required": false,
+        "label": "平台作者ID"
+      },
+      "authorName": {
+        "type": "String",
+        "required": false,
+        "label": "作者显示名"
+      },
+      "likeCount": {
+        "type": "Number",
+        "required": false,
+        "label": "点赞数"
+      },
+      "collectCount": {
+        "type": "Number",
+        "required": false,
+        "label": "收藏数"
+      },
+      "commentCount": {
+        "type": "Number",
+        "required": false,
+        "label": "评论数"
+      },
+      "shareCount": {
+        "type": "Number",
+        "required": false,
+        "label": "分享数"
+      },
+      "tags": {
+        "type": "Array",
+        "required": false,
+        "label": "标签"
+      },
+      "candidateUrl": {
+        "type": "String",
+        "required": false,
+        "label": "候选链接"
+      },
+      "sourceUrlVerified": {
+        "type": "Boolean",
+        "required": true,
+        "label": "链接已核验"
+      },
+      "mediaManifest": {
+        "type": "Object",
+        "required": false,
+        "label": "本地媒体清单"
+      },
+      "isNoise": {
+        "type": "Boolean",
+        "required": false,
+        "label": "是否为噪声数据"
+      },
+      "noiseReason": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定原因"
+      },
+      "noiseScope": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定适用范围"
+      },
+      "noiseRuleVersion": {
+        "type": "String",
+        "required": false,
+        "label": "噪声筛选规则版本"
+      },
+      "noiseMethod": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定方式"
+      },
+      "noiseAssessedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "噪声判定记录时间"
+      },
+      "noiseAssessedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "噪声判定操作人"
+      },
+      "noiseContentHash": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定内容指纹"
+      },
+      "noiseHistory": {
+        "type": "Array",
+        "required": false,
+        "label": "分范围噪声判定历史"
+      }
+    },
+    "KfRawXhsComment": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "runRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "VOC执行记录"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "完整响应来源"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "实际平台"
+      },
+      "externalObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "平台对象ID"
+      },
+      "idOrigin": {
+        "type": "String",
+        "required": true,
+        "label": "对象ID来源"
+      },
+      "keyword": {
+        "type": "String",
+        "required": false,
+        "label": "采集关键词"
+      },
+      "sourceUrl": {
+        "type": "String",
+        "required": false,
+        "label": "原始链接"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "内容发布时间"
+      },
+      "publishedAtRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始发布时间文本"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "本次取得时间"
+      },
+      "recordIndex": {
+        "type": "Number",
+        "required": true,
+        "label": "本次返回序号"
+      },
+      "payloadHash": {
+        "type": "String",
+        "required": true,
+        "label": "逐条内容指纹"
+      },
+      "rawPayload": {
+        "type": "Object",
+        "required": true,
+        "label": "逐条VOC返回对象"
+      },
+      "payloadLevel": {
+        "type": "String",
+        "required": true,
+        "label": "原文层级"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": false,
+        "label": "归一产品"
+      },
+      "evidenceRef": {
+        "type": "Pointer",
+        "targetClass": "KfEvidence",
+        "required": false,
+        "label": "主要归一证据"
+      },
+      "parentExternalId": {
+        "type": "String",
+        "required": true,
+        "label": "所属笔记平台ID"
+      },
+      "parentRef": {
+        "type": "Pointer",
+        "targetClass": "KfRawXhsNote",
+        "required": false,
+        "label": "所属笔记记录"
+      },
+      "text": {
+        "type": "String",
+        "required": true,
+        "label": "评论原文"
+      },
+      "authorId": {
+        "type": "String",
+        "required": false,
+        "label": "评论者平台ID"
+      },
+      "authorName": {
+        "type": "String",
+        "required": false,
+        "label": "评论者显示名"
+      },
+      "likeCount": {
+        "type": "Number",
+        "required": false,
+        "label": "评论点赞数"
+      },
+      "replyCount": {
+        "type": "Number",
+        "required": false,
+        "label": "子评论数"
+      },
+      "ipLocation": {
+        "type": "String",
+        "required": false,
+        "label": "显示IP属地"
+      },
+      "isNoise": {
+        "type": "Boolean",
+        "required": false,
+        "label": "是否为噪声数据"
+      },
+      "noiseReason": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定原因"
+      },
+      "noiseScope": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定适用范围"
+      },
+      "noiseRuleVersion": {
+        "type": "String",
+        "required": false,
+        "label": "噪声筛选规则版本"
+      },
+      "noiseMethod": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定方式"
+      },
+      "noiseAssessedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "噪声判定记录时间"
+      },
+      "noiseAssessedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "噪声判定操作人"
+      },
+      "noiseContentHash": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定内容指纹"
+      },
+      "noiseHistory": {
+        "type": "Array",
+        "required": false,
+        "label": "分范围噪声判定历史"
+      }
+    },
+    "KfRawDouyinVideo": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "runRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "VOC执行记录"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "完整响应来源"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "实际平台"
+      },
+      "externalObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "平台对象ID"
+      },
+      "idOrigin": {
+        "type": "String",
+        "required": true,
+        "label": "对象ID来源"
+      },
+      "keyword": {
+        "type": "String",
+        "required": false,
+        "label": "采集关键词"
+      },
+      "sourceUrl": {
+        "type": "String",
+        "required": false,
+        "label": "原始链接"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "内容发布时间"
+      },
+      "publishedAtRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始发布时间文本"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "本次取得时间"
+      },
+      "recordIndex": {
+        "type": "Number",
+        "required": true,
+        "label": "本次返回序号"
+      },
+      "payloadHash": {
+        "type": "String",
+        "required": true,
+        "label": "逐条内容指纹"
+      },
+      "rawPayload": {
+        "type": "Object",
+        "required": true,
+        "label": "逐条VOC返回对象"
+      },
+      "payloadLevel": {
+        "type": "String",
+        "required": true,
+        "label": "原文层级"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": false,
+        "label": "归一产品"
+      },
+      "evidenceRef": {
+        "type": "Pointer",
+        "targetClass": "KfEvidence",
+        "required": false,
+        "label": "主要归一证据"
+      },
+      "content": {
+        "type": "String",
+        "required": false,
+        "label": "视频文案"
+      },
+      "authorId": {
+        "type": "String",
+        "required": false,
+        "label": "平台作者ID"
+      },
+      "authorName": {
+        "type": "String",
+        "required": false,
+        "label": "作者显示名"
+      },
+      "authorSecUid": {
+        "type": "String",
+        "required": false,
+        "label": "作者安全标识"
+      },
+      "likeCount": {
+        "type": "Number",
+        "required": false,
+        "label": "点赞数"
+      },
+      "collectCount": {
+        "type": "Number",
+        "required": false,
+        "label": "收藏数"
+      },
+      "commentCount": {
+        "type": "Number",
+        "required": false,
+        "label": "评论数"
+      },
+      "shareCount": {
+        "type": "Number",
+        "required": false,
+        "label": "分享数"
+      },
+      "playCount": {
+        "type": "Number",
+        "required": false,
+        "label": "播放数"
+      },
+      "tags": {
+        "type": "Array",
+        "required": false,
+        "label": "话题标签"
+      },
+      "sourceUrlVerified": {
+        "type": "Boolean",
+        "required": true,
+        "label": "链接已核验"
+      },
+      "mediaManifest": {
+        "type": "Object",
+        "required": false,
+        "label": "本地媒体清单"
+      },
+      "isNoise": {
+        "type": "Boolean",
+        "required": false,
+        "label": "是否为噪声数据"
+      },
+      "noiseReason": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定原因"
+      },
+      "noiseScope": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定适用范围"
+      },
+      "noiseRuleVersion": {
+        "type": "String",
+        "required": false,
+        "label": "噪声筛选规则版本"
+      },
+      "noiseMethod": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定方式"
+      },
+      "noiseAssessedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "噪声判定记录时间"
+      },
+      "noiseAssessedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "噪声判定操作人"
+      },
+      "noiseContentHash": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定内容指纹"
+      },
+      "noiseHistory": {
+        "type": "Array",
+        "required": false,
+        "label": "分范围噪声判定历史"
+      }
+    },
+    "KfRawDouyinComment": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "runRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "VOC执行记录"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "完整响应来源"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "实际平台"
+      },
+      "externalObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "平台对象ID"
+      },
+      "idOrigin": {
+        "type": "String",
+        "required": true,
+        "label": "对象ID来源"
+      },
+      "keyword": {
+        "type": "String",
+        "required": false,
+        "label": "采集关键词"
+      },
+      "sourceUrl": {
+        "type": "String",
+        "required": false,
+        "label": "原始链接"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "内容发布时间"
+      },
+      "publishedAtRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始发布时间文本"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "本次取得时间"
+      },
+      "recordIndex": {
+        "type": "Number",
+        "required": true,
+        "label": "本次返回序号"
+      },
+      "payloadHash": {
+        "type": "String",
+        "required": true,
+        "label": "逐条内容指纹"
+      },
+      "rawPayload": {
+        "type": "Object",
+        "required": true,
+        "label": "逐条VOC返回对象"
+      },
+      "payloadLevel": {
+        "type": "String",
+        "required": true,
+        "label": "原文层级"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": false,
+        "label": "归一产品"
+      },
+      "evidenceRef": {
+        "type": "Pointer",
+        "targetClass": "KfEvidence",
+        "required": false,
+        "label": "主要归一证据"
+      },
+      "parentExternalId": {
+        "type": "String",
+        "required": true,
+        "label": "所属视频平台ID"
+      },
+      "parentRef": {
+        "type": "Pointer",
+        "targetClass": "KfRawDouyinVideo",
+        "required": false,
+        "label": "所属视频记录"
+      },
+      "text": {
+        "type": "String",
+        "required": true,
+        "label": "评论原文"
+      },
+      "authorId": {
+        "type": "String",
+        "required": false,
+        "label": "评论者平台ID"
+      },
+      "authorName": {
+        "type": "String",
+        "required": false,
+        "label": "评论者显示名"
+      },
+      "likeCount": {
+        "type": "Number",
+        "required": false,
+        "label": "评论点赞数"
+      },
+      "replyCount": {
+        "type": "Number",
+        "required": false,
+        "label": "回复总数"
+      },
+      "ipLocation": {
+        "type": "String",
+        "required": false,
+        "label": "显示IP属地"
+      },
+      "isNoise": {
+        "type": "Boolean",
+        "required": false,
+        "label": "是否为噪声数据"
+      },
+      "noiseReason": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定原因"
+      },
+      "noiseScope": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定适用范围"
+      },
+      "noiseRuleVersion": {
+        "type": "String",
+        "required": false,
+        "label": "噪声筛选规则版本"
+      },
+      "noiseMethod": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定方式"
+      },
+      "noiseAssessedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "噪声判定记录时间"
+      },
+      "noiseAssessedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "噪声判定操作人"
+      },
+      "noiseContentHash": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定内容指纹"
+      },
+      "noiseHistory": {
+        "type": "Array",
+        "required": false,
+        "label": "分范围噪声判定历史"
+      }
+    },
+    "KfRawTaobaoItem": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "runRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "VOC执行记录"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "完整响应来源"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "实际平台"
+      },
+      "externalObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "平台对象ID"
+      },
+      "idOrigin": {
+        "type": "String",
+        "required": true,
+        "label": "对象ID来源"
+      },
+      "keyword": {
+        "type": "String",
+        "required": false,
+        "label": "采集关键词"
+      },
+      "sourceUrl": {
+        "type": "String",
+        "required": false,
+        "label": "原始链接"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "内容发布时间"
+      },
+      "publishedAtRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始发布时间文本"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "本次取得时间"
+      },
+      "recordIndex": {
+        "type": "Number",
+        "required": true,
+        "label": "本次返回序号"
+      },
+      "payloadHash": {
+        "type": "String",
+        "required": true,
+        "label": "逐条内容指纹"
+      },
+      "rawPayload": {
+        "type": "Object",
+        "required": true,
+        "label": "逐条VOC返回对象"
+      },
+      "payloadLevel": {
+        "type": "String",
+        "required": true,
+        "label": "原文层级"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": false,
+        "label": "归一产品"
+      },
+      "evidenceRef": {
+        "type": "Pointer",
+        "targetClass": "KfEvidence",
+        "required": false,
+        "label": "主要归一证据"
+      },
+      "shopName": {
+        "type": "String",
+        "required": false,
+        "label": "店铺名称"
+      },
+      "subject": {
+        "type": "String",
+        "required": false,
+        "label": "平台商品标题"
+      },
+      "skuId": {
+        "type": "String",
+        "required": false,
+        "label": "平台SKU编号"
+      },
+      "skuVoList": {
+        "type": "Array",
+        "required": false,
+        "label": "SKU列表"
+      },
+      "price": {
+        "type": "Number",
+        "required": false,
+        "label": "基础零售价快照"
+      },
+      "currency": {
+        "type": "String",
+        "required": false,
+        "label": "币种"
+      },
+      "priceKind": {
+        "type": "String",
+        "required": false,
+        "label": "价格口径"
+      },
+      "recordTime": {
+        "type": "Date",
+        "required": false,
+        "label": "上游采集时间"
+      },
+      "recordTimeRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始上游时间"
+      },
+      "attributes": {
+        "type": "Object",
+        "required": false,
+        "label": "商品属性"
+      },
+      "isNoise": {
+        "type": "Boolean",
+        "required": false,
+        "label": "是否为噪声数据"
+      },
+      "noiseReason": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定原因"
+      },
+      "noiseScope": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定适用范围"
+      },
+      "noiseRuleVersion": {
+        "type": "String",
+        "required": false,
+        "label": "噪声筛选规则版本"
+      },
+      "noiseMethod": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定方式"
+      },
+      "noiseAssessedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "噪声判定记录时间"
+      },
+      "noiseAssessedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "噪声判定操作人"
+      },
+      "noiseContentHash": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定内容指纹"
+      },
+      "noiseHistory": {
+        "type": "Array",
+        "required": false,
+        "label": "分范围噪声判定历史"
+      }
+    },
+    "KfRawTaobaoReview": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "runRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "VOC执行记录"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "完整响应来源"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "实际平台"
+      },
+      "externalObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "平台对象ID"
+      },
+      "idOrigin": {
+        "type": "String",
+        "required": true,
+        "label": "对象ID来源"
+      },
+      "keyword": {
+        "type": "String",
+        "required": false,
+        "label": "采集关键词"
+      },
+      "sourceUrl": {
+        "type": "String",
+        "required": false,
+        "label": "原始链接"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "内容发布时间"
+      },
+      "publishedAtRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始发布时间文本"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "本次取得时间"
+      },
+      "recordIndex": {
+        "type": "Number",
+        "required": true,
+        "label": "本次返回序号"
+      },
+      "payloadHash": {
+        "type": "String",
+        "required": true,
+        "label": "逐条内容指纹"
+      },
+      "rawPayload": {
+        "type": "Object",
+        "required": true,
+        "label": "逐条VOC返回对象"
+      },
+      "payloadLevel": {
+        "type": "String",
+        "required": true,
+        "label": "原文层级"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": false,
+        "label": "归一产品"
+      },
+      "evidenceRef": {
+        "type": "Pointer",
+        "targetClass": "KfEvidence",
+        "required": false,
+        "label": "主要归一证据"
+      },
+      "parentExternalId": {
+        "type": "String",
+        "required": false,
+        "label": "所属商品平台ID"
+      },
+      "skuId": {
+        "type": "String",
+        "required": false,
+        "label": "相关SKU编号"
+      },
+      "text": {
+        "type": "String",
+        "required": false,
+        "label": "评价原文"
+      },
+      "rating": {
+        "type": "Number",
+        "required": false,
+        "label": "评分"
+      },
+      "authorLabel": {
+        "type": "String",
+        "required": false,
+        "label": "评价者显示名"
+      },
+      "mediaManifest": {
+        "type": "Object",
+        "required": false,
+        "label": "评价媒体清单"
+      },
+      "isNoise": {
+        "type": "Boolean",
+        "required": false,
+        "label": "是否为噪声数据"
+      },
+      "noiseReason": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定原因"
+      },
+      "noiseScope": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定适用范围"
+      },
+      "noiseRuleVersion": {
+        "type": "String",
+        "required": false,
+        "label": "噪声筛选规则版本"
+      },
+      "noiseMethod": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定方式"
+      },
+      "noiseAssessedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "噪声判定记录时间"
+      },
+      "noiseAssessedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "噪声判定操作人"
+      },
+      "noiseContentHash": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定内容指纹"
+      },
+      "noiseHistory": {
+        "type": "Array",
+        "required": false,
+        "label": "分范围噪声判定历史"
+      }
+    },
+    "KfRawJdItem": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "runRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "VOC执行记录"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "完整响应来源"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "实际平台"
+      },
+      "externalObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "平台对象ID"
+      },
+      "idOrigin": {
+        "type": "String",
+        "required": true,
+        "label": "对象ID来源"
+      },
+      "keyword": {
+        "type": "String",
+        "required": false,
+        "label": "采集关键词"
+      },
+      "sourceUrl": {
+        "type": "String",
+        "required": false,
+        "label": "原始链接"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "内容发布时间"
+      },
+      "publishedAtRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始发布时间文本"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "本次取得时间"
+      },
+      "recordIndex": {
+        "type": "Number",
+        "required": true,
+        "label": "本次返回序号"
+      },
+      "payloadHash": {
+        "type": "String",
+        "required": true,
+        "label": "逐条内容指纹"
+      },
+      "rawPayload": {
+        "type": "Object",
+        "required": true,
+        "label": "逐条VOC返回对象"
+      },
+      "payloadLevel": {
+        "type": "String",
+        "required": true,
+        "label": "原文层级"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": false,
+        "label": "归一产品"
+      },
+      "evidenceRef": {
+        "type": "Pointer",
+        "targetClass": "KfEvidence",
+        "required": false,
+        "label": "主要归一证据"
+      },
+      "title": {
+        "type": "String",
+        "required": false,
+        "label": "商品标题"
+      },
+      "shopName": {
+        "type": "String",
+        "required": false,
+        "label": "店铺名称"
+      },
+      "skuId": {
+        "type": "String",
+        "required": false,
+        "label": "平台SKU编号"
+      },
+      "price": {
+        "type": "Number",
+        "required": false,
+        "label": "观察价格"
+      },
+      "currency": {
+        "type": "String",
+        "required": false,
+        "label": "币种"
+      },
+      "priceKind": {
+        "type": "String",
+        "required": false,
+        "label": "价格口径"
+      },
+      "recordTime": {
+        "type": "Date",
+        "required": false,
+        "label": "上游采集时间"
+      },
+      "recordTimeRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始上游时间"
+      },
+      "isNoise": {
+        "type": "Boolean",
+        "required": false,
+        "label": "是否为噪声数据"
+      },
+      "noiseReason": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定原因"
+      },
+      "noiseScope": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定适用范围"
+      },
+      "noiseRuleVersion": {
+        "type": "String",
+        "required": false,
+        "label": "噪声筛选规则版本"
+      },
+      "noiseMethod": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定方式"
+      },
+      "noiseAssessedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "噪声判定记录时间"
+      },
+      "noiseAssessedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "噪声判定操作人"
+      },
+      "noiseContentHash": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定内容指纹"
+      },
+      "noiseHistory": {
+        "type": "Array",
+        "required": false,
+        "label": "分范围噪声判定历史"
+      }
+    },
+    "KfRawJdReview": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "runRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "VOC执行记录"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "完整响应来源"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "实际平台"
+      },
+      "externalObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "平台对象ID"
+      },
+      "idOrigin": {
+        "type": "String",
+        "required": true,
+        "label": "对象ID来源"
+      },
+      "keyword": {
+        "type": "String",
+        "required": false,
+        "label": "采集关键词"
+      },
+      "sourceUrl": {
+        "type": "String",
+        "required": false,
+        "label": "原始链接"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "内容发布时间"
+      },
+      "publishedAtRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始发布时间文本"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "本次取得时间"
+      },
+      "recordIndex": {
+        "type": "Number",
+        "required": true,
+        "label": "本次返回序号"
+      },
+      "payloadHash": {
+        "type": "String",
+        "required": true,
+        "label": "逐条内容指纹"
+      },
+      "rawPayload": {
+        "type": "Object",
+        "required": true,
+        "label": "逐条VOC返回对象"
+      },
+      "payloadLevel": {
+        "type": "String",
+        "required": true,
+        "label": "原文层级"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": false,
+        "label": "归一产品"
+      },
+      "evidenceRef": {
+        "type": "Pointer",
+        "targetClass": "KfEvidence",
+        "required": false,
+        "label": "主要归一证据"
+      },
+      "parentExternalId": {
+        "type": "String",
+        "required": false,
+        "label": "所属商品平台ID"
+      },
+      "skuId": {
+        "type": "String",
+        "required": false,
+        "label": "相关SKU编号"
+      },
+      "text": {
+        "type": "String",
+        "required": false,
+        "label": "评价原文"
+      },
+      "rating": {
+        "type": "Number",
+        "required": false,
+        "label": "评分"
+      },
+      "authorLabel": {
+        "type": "String",
+        "required": false,
+        "label": "评价者显示名"
+      },
+      "mediaManifest": {
+        "type": "Object",
+        "required": false,
+        "label": "评价媒体清单"
+      },
+      "isNoise": {
+        "type": "Boolean",
+        "required": false,
+        "label": "是否为噪声数据"
+      },
+      "noiseReason": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定原因"
+      },
+      "noiseScope": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定适用范围"
+      },
+      "noiseRuleVersion": {
+        "type": "String",
+        "required": false,
+        "label": "噪声筛选规则版本"
+      },
+      "noiseMethod": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定方式"
+      },
+      "noiseAssessedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "噪声判定记录时间"
+      },
+      "noiseAssessedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "噪声判定操作人"
+      },
+      "noiseContentHash": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定内容指纹"
+      },
+      "noiseHistory": {
+        "type": "Array",
+        "required": false,
+        "label": "分范围噪声判定历史"
+      }
+    },
+    "KfRawBilibiliPost": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "完整响应或原始文件"
+      },
+      "runRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "VOC采集执行"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "平台"
+      },
+      "externalObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "平台对象ID"
+      },
+      "idOrigin": {
+        "type": "String",
+        "required": true,
+        "label": "对象ID来源"
+      },
+      "keyword": {
+        "type": "String",
+        "required": false,
+        "label": "查询关键词"
+      },
+      "sourceUrl": {
+        "type": "String",
+        "required": false,
+        "label": "对象链接"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "发布时间"
+      },
+      "publishedAtRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始发布时间"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "采集时间"
+      },
+      "recordIndex": {
+        "type": "Number",
+        "required": true,
+        "label": "文件内序号"
+      },
+      "payloadHash": {
+        "type": "String",
+        "required": true,
+        "label": "条目指纹"
+      },
+      "rawPayload": {
+        "type": "Object",
+        "required": true,
+        "label": "逐条原始返回对象"
+      },
+      "payloadLevel": {
+        "type": "String",
+        "required": true,
+        "label": "数据层级"
+      },
+      "evidenceRef": {
+        "type": "Pointer",
+        "targetClass": "KfEvidence",
+        "required": false,
+        "label": "主要归一证据"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": false,
+        "label": "归一产品"
+      },
+      "title": {
+        "type": "String",
+        "required": false,
+        "label": "内容标题"
+      },
+      "description": {
+        "type": "String",
+        "required": false,
+        "label": "内容描述"
+      },
+      "authorName": {
+        "type": "String",
+        "required": false,
+        "label": "作者显示名"
+      },
+      "viewCount": {
+        "type": "Number",
+        "required": false,
+        "label": "播放数"
+      },
+      "likeCount": {
+        "type": "Number",
+        "required": false,
+        "label": "点赞数"
+      },
+      "commentCount": {
+        "type": "Number",
+        "required": false,
+        "label": "评论数"
+      },
+      "tags": {
+        "type": "Array",
+        "required": false,
+        "label": "标签"
+      },
+      "isNoise": {
+        "type": "Boolean",
+        "required": false,
+        "label": "是否为噪声数据"
+      },
+      "noiseReason": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定原因"
+      },
+      "noiseScope": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定适用范围"
+      },
+      "noiseRuleVersion": {
+        "type": "String",
+        "required": false,
+        "label": "噪声筛选规则版本"
+      },
+      "noiseMethod": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定方式"
+      },
+      "noiseAssessedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "噪声判定记录时间"
+      },
+      "noiseAssessedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "噪声判定操作人"
+      },
+      "noiseContentHash": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定内容指纹"
+      },
+      "noiseHistory": {
+        "type": "Array",
+        "required": false,
+        "label": "分范围噪声判定历史"
+      }
+    },
+    "KfRaw1688Offer": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "完整响应或原始文件"
+      },
+      "runRef": {
+        "type": "Pointer",
+        "targetClass": "KfVocRun",
+        "required": false,
+        "label": "VOC采集执行"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "平台"
+      },
+      "externalObjectId": {
+        "type": "String",
+        "required": false,
+        "label": "平台对象ID"
+      },
+      "idOrigin": {
+        "type": "String",
+        "required": true,
+        "label": "对象ID来源"
+      },
+      "keyword": {
+        "type": "String",
+        "required": false,
+        "label": "查询关键词"
+      },
+      "sourceUrl": {
+        "type": "String",
+        "required": false,
+        "label": "对象链接"
+      },
+      "publishedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "发布时间"
+      },
+      "publishedAtRaw": {
+        "type": "String",
+        "required": false,
+        "label": "原始发布时间"
+      },
+      "collectedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "采集时间"
+      },
+      "recordIndex": {
+        "type": "Number",
+        "required": true,
+        "label": "文件内序号"
+      },
+      "payloadHash": {
+        "type": "String",
+        "required": true,
+        "label": "条目指纹"
+      },
+      "rawPayload": {
+        "type": "Object",
+        "required": true,
+        "label": "逐条原始返回对象"
+      },
+      "payloadLevel": {
+        "type": "String",
+        "required": true,
+        "label": "数据层级"
+      },
+      "evidenceRef": {
+        "type": "Pointer",
+        "targetClass": "KfEvidence",
+        "required": false,
+        "label": "主要归一证据"
+      },
+      "supplierRef": {
+        "type": "Pointer",
+        "targetClass": "KfSupplier",
+        "required": false,
+        "label": "归一供应方"
+      },
+      "materialRef": {
+        "type": "Pointer",
+        "targetClass": "KfMaterial",
+        "required": false,
+        "label": "对应原料"
+      },
+      "supplyQuoteRef": {
+        "type": "Pointer",
+        "targetClass": "KfSupplyQuote",
+        "required": false,
+        "label": "归一公开货源记录"
+      },
+      "offerTitle": {
+        "type": "String",
+        "required": false,
+        "label": "货源标题"
+      },
+      "shopName": {
+        "type": "String",
+        "required": false,
+        "label": "店铺名称"
+      },
+      "province": {
+        "type": "String",
+        "required": false,
+        "label": "标示产地"
+      },
+      "priceText": {
+        "type": "String",
+        "required": false,
+        "label": "价格原文"
+      },
+      "price": {
+        "type": "Number",
+        "required": false,
+        "label": "可解释的数值报价"
+      },
+      "currency": {
+        "type": "String",
+        "required": false,
+        "label": "币种"
+      },
+      "unit": {
+        "type": "String",
+        "required": false,
+        "label": "计价单位"
+      },
+      "moq": {
+        "type": "Number",
+        "required": false,
+        "label": "起订量"
+      },
+      "moqUnit": {
+        "type": "String",
+        "required": false,
+        "label": "起订单位"
+      },
+      "priceTiers": {
+        "type": "Array",
+        "required": false,
+        "label": "阶梯报价"
+      },
+      "platformVerifiedFlag": {
+        "type": "Boolean",
+        "required": false,
+        "label": "平台认证标记"
+      },
+      "isNoise": {
+        "type": "Boolean",
+        "required": false,
+        "label": "是否为噪声数据"
+      },
+      "noiseReason": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定原因"
+      },
+      "noiseScope": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定适用范围"
+      },
+      "noiseRuleVersion": {
+        "type": "String",
+        "required": false,
+        "label": "噪声筛选规则版本"
+      },
+      "noiseMethod": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定方式"
+      },
+      "noiseAssessedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "噪声判定记录时间"
+      },
+      "noiseAssessedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "噪声判定操作人"
+      },
+      "noiseContentHash": {
+        "type": "String",
+        "required": false,
+        "label": "噪声判定内容指纹"
+      },
+      "noiseHistory": {
+        "type": "Array",
+        "required": false,
+        "label": "分范围噪声判定历史"
+      }
+    },
+    "KfSku": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "productRef": {
+        "type": "Pointer",
+        "targetClass": "KfProduct",
+        "required": true,
+        "label": "归一产品"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "商品来源"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "电商平台"
+      },
+      "platformSkuId": {
+        "type": "String",
+        "required": false,
+        "label": "平台SKU编号"
+      },
+      "skuName": {
+        "type": "String",
+        "required": false,
+        "label": "SKU名称"
+      },
+      "specText": {
+        "type": "String",
+        "required": false,
+        "label": "规格原文"
+      },
+      "netWeightG": {
+        "type": "Number",
+        "required": false,
+        "label": "单份净重克"
+      },
+      "packCount": {
+        "type": "Number",
+        "required": false,
+        "label": "包装份数"
+      },
+      "totalWeightG": {
+        "type": "Number",
+        "required": false,
+        "label": "销售单元总重克"
+      },
+      "attributes": {
+        "type": "Object",
+        "required": false,
+        "label": "SKU属性"
+      },
+      "scopeDecision": {
+        "type": "String",
+        "required": true,
+        "label": "纳入样本决定"
+      },
+      "scopeReason": {
+        "type": "String",
+        "required": false,
+        "label": "纳入或排除理由"
+      },
+      "classificationMethod": {
+        "type": "String",
+        "required": false,
+        "label": "分类方法版本"
+      }
+    },
+    "KfRetailPriceSnapshot": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "skuRef": {
+        "type": "Pointer",
+        "targetClass": "KfSku",
+        "required": true,
+        "label": "平台SKU"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "价格来源"
+      },
+      "platform": {
+        "type": "String",
+        "required": true,
+        "label": "平台"
+      },
+      "priceKind": {
+        "type": "String",
+        "required": true,
+        "label": "价格口径"
+      },
+      "price": {
+        "type": "Number",
+        "required": true,
+        "label": "页面价格"
+      },
+      "currency": {
+        "type": "String",
+        "required": true,
+        "label": "币种"
+      },
+      "totalWeightG": {
+        "type": "Number",
+        "required": false,
+        "label": "换算用销售单元总重"
+      },
+      "pricePer100g": {
+        "type": "Number",
+        "required": false,
+        "label": "每百克价格"
+      },
+      "normalizationMethod": {
+        "type": "String",
+        "required": false,
+        "label": "重量与价格换算方法"
+      },
+      "validForComparison": {
+        "type": "Boolean",
+        "required": true,
+        "label": "纳入价格比较"
+      },
+      "exclusionReason": {
+        "type": "String",
+        "required": false,
+        "label": "排除理由"
+      },
+      "capturedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "页面采集时间"
+      },
+      "rawRecordTable": {
+        "type": "String",
+        "required": false,
+        "label": "原始记录表"
+      },
+      "rawRecordId": {
+        "type": "String",
+        "required": false,
+        "label": "原始记录编号"
+      }
+    },
+    "KfProcessStep": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "processRef": {
+        "type": "Pointer",
+        "targetClass": "KfProcess",
+        "required": true,
+        "label": "所属工艺路线"
+      },
+      "sequence": {
+        "type": "Number",
+        "required": true,
+        "label": "步骤顺序"
+      },
+      "stepName": {
+        "type": "String",
+        "required": true,
+        "label": "步骤名称"
+      },
+      "parameters": {
+        "type": "Object",
+        "required": false,
+        "label": "参数范围"
+      },
+      "isCcp": {
+        "type": "Boolean",
+        "required": true,
+        "label": "是否关键控制点"
+      },
+      "controlMethod": {
+        "type": "String",
+        "required": false,
+        "label": "控制与检测方法"
+      },
+      "factoryApplicability": {
+        "type": "Object",
+        "required": false,
+        "label": "适用产线或设备"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "步骤依据"
+      }
+    },
+    "KfQuestionnaireQuestion": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "questionnaireKey": {
+        "type": "String",
+        "required": true,
+        "label": "问卷版本键"
+      },
+      "questionCode": {
+        "type": "String",
+        "required": true,
+        "label": "题号"
+      },
+      "section": {
+        "type": "String",
+        "required": true,
+        "label": "主题"
+      },
+      "questionText": {
+        "type": "String",
+        "required": true,
+        "label": "问题原文"
+      },
+      "answerType": {
+        "type": "String",
+        "required": true,
+        "label": "回答类型"
+      },
+      "options": {
+        "type": "Array",
+        "required": false,
+        "label": "选项定义"
+      },
+      "priority": {
+        "type": "Number",
+        "required": false,
+        "label": "问题优先级"
+      },
+      "condition": {
+        "type": "Object",
+        "required": false,
+        "label": "显示或适用条件"
+      },
+      "targetMappings": {
+        "type": "Array",
+        "required": false,
+        "label": "结构化目标字段"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": true,
+        "label": "问卷原件"
+      },
+      "isActive": {
+        "type": "Boolean",
+        "required": true,
+        "label": "是否当前版本"
+      }
+    },
+    "KfQuestionnaireAnswer": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "schemaVersion": {
+        "type": "String",
+        "required": true,
+        "label": "记录结构版本"
+      },
+      "externalKey": {
+        "type": "String",
+        "required": true,
+        "label": "稳定业务键"
+      },
+      "reviewState": {
+        "type": "String",
+        "required": true,
+        "label": "人工确认状态"
+      },
+      "ingestedAt": {
+        "type": "Date",
+        "required": true,
+        "label": "规范入库时间"
+      },
+      "recordState": {
+        "type": "String",
+        "required": true,
+        "label": "生命周期"
+      },
+      "dataMode": {
+        "type": "String",
+        "required": true,
+        "label": "数据取得模式"
+      },
+      "reviewedByRef": {
+        "type": "Pointer",
+        "targetClass": "_User",
+        "required": false,
+        "label": "确认经办人"
+      },
+      "reviewedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "确认时间"
+      },
+      "questionRef": {
+        "type": "Pointer",
+        "targetClass": "KfQuestionnaireQuestion",
+        "required": true,
+        "label": "对应问题"
+      },
+      "responseBatchKey": {
+        "type": "String",
+        "required": true,
+        "label": "本次答卷编号"
+      },
+      "fieldCode": {
+        "type": "String",
+        "required": true,
+        "label": "答案字段代码"
+      },
+      "rawAnswer": {
+        "type": "String",
+        "required": false,
+        "label": "回答原文"
+      },
+      "valueNumber": {
+        "type": "Number",
+        "required": false,
+        "label": "数值答案"
+      },
+      "valueText": {
+        "type": "String",
+        "required": false,
+        "label": "文本答案"
+      },
+      "valueBoolean": {
+        "type": "Boolean",
+        "required": false,
+        "label": "布尔答案"
+      },
+      "valueOptions": {
+        "type": "Array",
+        "required": false,
+        "label": "选项答案"
+      },
+      "unit": {
+        "type": "String",
+        "required": false,
+        "label": "答案单位"
+      },
+      "respondentRole": {
+        "type": "String",
+        "required": false,
+        "label": "答复岗位"
+      },
+      "answeredAt": {
+        "type": "Date",
+        "required": false,
+        "label": "答复时间"
+      },
+      "sourceRef": {
+        "type": "Pointer",
+        "targetClass": "KfSource",
+        "required": false,
+        "label": "答复或附件来源"
+      },
+      "verificationState": {
+        "type": "String",
+        "required": true,
+        "label": "答案核验状态"
+      },
+      "targetClass": {
+        "type": "String",
+        "required": false,
+        "label": "拟更新业务表"
+      },
+      "targetId": {
+        "type": "String",
+        "required": false,
+        "label": "拟更新记录"
+      },
+      "targetField": {
+        "type": "String",
+        "required": false,
+        "label": "拟更新字段"
+      },
+      "applyState": {
+        "type": "String",
+        "required": true,
+        "label": "应用状态"
+      },
+      "appliedAt": {
+        "type": "Date",
+        "required": false,
+        "label": "实际应用时间"
+      }
+    },
+    "KsfServiceCredential": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "label": {
+        "type": "String"
+      },
+      "tokenHash": {
+        "type": "String"
+      },
+      "scopes": {
+        "type": "Array"
+      },
+      "projectIds": {
+        "type": "Array"
+      },
+      "enabled": {
+        "type": "Boolean"
+      },
+      "expiresAt": {
+        "type": "Date"
+      }
+    },
+    "KsfOperation": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "ownerId": {
+        "type": "String"
+      },
+      "requestId": {
+        "type": "String"
+      },
+      "operation": {
+        "type": "String"
+      },
+      "payloadHash": {
+        "type": "String"
+      },
+      "receipt": {
+        "type": "Object"
+      }
+    },
+    "KsfUploadSession": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "ownerId": {
+        "type": "String"
+      },
+      "requestId": {
+        "type": "String"
+      },
+      "tokenHash": {
+        "type": "String"
+      },
+      "fileName": {
+        "type": "String"
+      },
+      "contentType": {
+        "type": "String"
+      },
+      "sizeBytes": {
+        "type": "Number"
+      },
+      "sha256": {
+        "type": "String"
+      },
+      "expiresAt": {
+        "type": "Date"
+      },
+      "state": {
+        "type": "String"
+      },
+      "fileReceipt": {
+        "type": "Object"
+      }
+    },
+    "KsfReportPreview": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "ownerId": {
+        "type": "String"
+      },
+      "identity": {
+        "type": "Object"
+      },
+      "file": {
+        "type": "Object"
+      },
+      "reportType": {
+        "type": "String"
+      },
+      "dataMode": {
+        "type": "String"
+      },
+      "artifactSubtype": {
+        "type": "String"
+      },
+      "expiresAt": {
+        "type": "Date"
+      }
+    },
+    "KsfConfirmationEvent": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "ownerId": {
+        "type": "String"
+      },
+      "employeeId": {
+        "type": "String"
+      },
+      "previewId": {
+        "type": "String"
+      },
+      "identity": {
+        "type": "Object"
+      },
+      "action": {
+        "type": "String"
+      },
+      "confirmedAt": {
+        "type": "Date"
+      }
+    },
+    "KsfRuntimeConfig": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "key": {
+        "type": "String"
+      },
+      "value": {
+        "type": "Object"
+      }
+    },
+    "KsfFileObject": {
+      "objectId": {
+        "type": "String"
+      },
+      "createdAt": {
+        "type": "Date"
+      },
+      "updatedAt": {
+        "type": "Date"
+      },
+      "name": {
+        "type": "String"
+      },
+      "ownerId": {
+        "type": "String"
+      },
+      "contentType": {
+        "type": "String"
+      },
+      "sizeBytes": {
+        "type": "Number"
+      },
+      "sha256": {
+        "type": "String"
+      },
+      "bytesBase64": {
+        "type": "String"
+      }
+    }
+  },
+  "importClasses": [
+    "KfInsight",
+    "KfArtifact",
+    "KfOpportunity",
+    "KfCategory",
+    "KfCategoryRelation",
+    "KfProduct",
+    "KfEvidence",
+    "KfEvidenceLink",
+    "KfMarketMetric",
+    "KfAssessment",
+    "KfConcept",
+    "KfSupplier",
+    "KfMaterial",
+    "KfProcess",
+    "KfStandard",
+    "KfSupplyQuote",
+    "KfFormulaItem",
+    "KfRawXhsNote",
+    "KfRawXhsComment",
+    "KfRawDouyinVideo",
+    "KfRawDouyinComment",
+    "KfRawTaobaoItem",
+    "KfRawTaobaoReview",
+    "KfRawJdItem",
+    "KfRawJdReview",
+    "KfRawBilibiliPost",
+    "KfRaw1688Offer",
+    "KfSku",
+    "KfRetailPriceSnapshot",
+    "KfProcessStep",
+    "KfQuestionnaireQuestion"
+  ],
+  "reportRules": {
+    "revision": "ksf.report-ingest.rules.v0.7-r2",
+    "schemaVersion": "ksf.v0.7",
+    "purpose": "明确报告客户端字段取值;属于业务规则补充,不是生产部署状态或新增数据库字段。",
+    "basis": [
+      "Schema设计稿v0.7.reportTypes",
+      "KfArtifact.reportType与artifactSubtype规则",
+      "common.dataMode",
+      "KfSource.sourceKind"
+    ],
+    "reportTypes": [
+      {
+        "code": "category_map_report",
+        "label": "品类图谱调研初稿"
+      },
+      {
+        "code": "manufacturing_standards_report",
+        "label": "制造工艺与标准初稿"
+      },
+      {
+        "code": "materials_report",
+        "label": "原料素材初稿"
+      },
+      {
+        "code": "flavor_process_report",
+        "label": "口感调味初稿"
+      },
+      {
+        "code": "supply_chain_report",
+        "label": "供应链初稿"
+      },
+      {
+        "code": "market_trend_report",
+        "label": "市场趋势分析报告"
+      },
+      {
+        "code": "scenario_insight_report",
+        "label": "社媒场景需求洞察报告"
+      },
+      {
+        "code": "voc_evidence_pool",
+        "label": "自有与竞品用户证言池"
+      },
+      {
+        "code": "opportunity_cards",
+        "label": "三方机会卡片"
+      },
+      {
+        "code": "validated_opportunity_list",
+        "label": "高概率机会清单"
+      },
+      {
+        "code": "value_proposition_plan",
+        "label": "场景化用户买点方案"
+      },
+      {
+        "code": "product_concept_proposal",
+        "label": "产品概念提案"
+      },
+      {
+        "code": "integrated_banmian_rd_report",
+        "label": "拌面研发综合报告"
+      },
+      {
+        "code": "workshop_auxiliary",
+        "label": "附属成果,artifactSubtype必填"
+      }
+    ],
+    "reportTypePolicy": {
+      "requiredForImport": true,
+      "invalidDefault": "integrated_research_report",
+      "recommendedForWangYongReport": "integrated_banmian_rd_report",
+      "default": null,
+      "cli": "--report-type",
+      "api": "opts.reportType"
+    },
+    "dataModes": [
+      "sample",
+      "live",
+      "manual",
+      "mixed",
+      "unknown"
+    ],
+    "dataModePolicy": {
+      "meaning": "数据取得模式,按上游证据与来源继承,不表示文件格式。",
+      "missingDefault": "unknown",
+      "cli": "--data-mode",
+      "api": "opts.dataMode",
+      "invalidValues": [
+        "file",
+        "report"
+      ],
+      "samplePolicy": "演示数据及其衍生结论不得冒充live;混合来源须保留各来源取得模式及关联。"
+    },
+    "sourceKinds": [
+      "web",
+      "ecommerce",
+      "social",
+      "interview",
+      "standard",
+      "client_document",
+      "dataset",
+      "report_artifact"
+    ],
+    "sourceKindSupplement": {
+      "added": "report_artifact",
+      "isNewBusinessCode": true,
+      "meaning": "助手生成的报告原件。原v0.7来源码表未覆盖此情况,本次正式补充。",
+      "scope": "仅生成报告原件;上游电商/社媒/标准材料仍保留各自来源类型和证据关联。",
+      "databaseChange": "已有String字段可承载,不新增表字段;云函数与Skill采用同一码表。"
+    },
+    "auxiliaryPolicy": {
+      "reportType": "workshop_auxiliary",
+      "required": "artifactSubtype",
+      "examples": [
+        "participant_survey",
+        "leader_interview",
+        "pain_hypothesis_list",
+        "validation_config",
+        "night_research_task",
+        "research_gap_list",
+        "voc_cluster_table",
+        "scenario_matrix",
+        "acceptance_record"
+      ]
+    },
+    "enforcement": "Parse字段的String类型不会自动实施业务枚举;Skill导入前校验,云函数执行最终校验。"
+  },
+  "registry": {
+    "schemaVersion": "ksf.v0.7",
+    "contractRevision": "ksf.functions.v0.4",
+    "deploymentStatus": "design_only_not_deployed",
+    "noiseScopes": [
+      {
+        "id": "ksf.instant_noodles.consumer.v1",
+        "label": "即食面消费者研究",
+        "scopeRevision": 1,
+        "status": "defined",
+        "include": [
+          "面向消费者的即食/方便面制品消费体验与需求"
+        ],
+        "exclude": [
+          "饮料",
+          "堂食服务",
+          "作为原材料的鲜面条"
+        ],
+        "focus": "拌面可作为子范围,不能把所有非拌面内容都当全品类噪声",
+        "allowedRuleVersions": [
+          "ksf.noise.consumer-text.v1"
+        ]
+      },
+      {
+        "id": "ksf.banmian.consumer.v1",
+        "label": "拌面消费者研究",
+        "scopeRevision": 1,
+        "status": "defined",
+        "include": [
+          "即食/方便拌面消费体验与需求"
+        ],
+        "exclude": [
+          "饮料",
+          "堂食服务",
+          "作为原材料的鲜面条"
+        ],
+        "focus": "仅适用于明确以拌面为对象的研究;上下文不足保持未判断",
+        "allowedRuleVersions": [
+          "ksf.noise.consumer-text.v1"
+        ]
+      }
+    ],
+    "noiseRuleVersions": [
+      {
+        "id": "ksf.noise.consumer-text.v1",
+        "label": "消费者文本相关性判断第一版",
+        "status": "defined",
+        "scopeIds": [
+          "ksf.instant_noodles.consumer.v1",
+          "ksf.banmian.consumer.v1"
+        ],
+        "allowedClasses": [
+          "KfRawXhsNote",
+          "KfRawXhsComment",
+          "KfRawDouyinVideo",
+          "KfRawDouyinComment",
+          "KfRawTaobaoReview",
+          "KfRawJdReview",
+          "KfRawBilibiliPost"
+        ],
+        "fingerprintSpecId": "ksf.noise.text.nfc.v1",
+        "allowedMethods": [
+          "manual",
+          "rule",
+          "model",
+          "reused"
+        ],
+        "rules": [
+          "负面、投诉、不支持产品假设不能作为噪声依据",
+          "纯表情/标点或空正文不直接自动判true,先看已登记输入;缺上下文保持未判断",
+          "只有明确与当前研究范围无关或有可复核排除依据时判true,并填写具体原因",
+          "明确可用时保存false;信息不足为null/缺失",
+          "本规则投影仅含当前记录正文,父评论/视频若参与判断须新增投影与规则版本后使用"
+        ],
+        "execution": "判断方法和执行来源须服务验证;本注册不声称已经实现具体分类器"
+      }
+    ],
+    "fingerprintSpecs": [
+      {
+        "id": "ksf.noise.text.nfc.v1",
+        "digest": "sha256",
+        "encoding": "utf8",
+        "output": "64 lowercase hex digits",
+        "projectionByClass": {
+          "KfRawXhsNote": [
+            "title",
+            "content"
+          ],
+          "KfRawXhsComment": [
+            "text"
+          ],
+          "KfRawDouyinVideo": [
+            "content"
+          ],
+          "KfRawDouyinComment": [
+            "text"
+          ],
+          "KfRawTaobaoReview": [
+            "text"
+          ],
+          "KfRawJdReview": [
+            "text"
+          ],
+          "KfRawBilibiliPost": [
+            "title",
+            "description"
+          ]
+        },
+        "canonicalJson": "recursive Object.keys(value).sort() UTF-16 ordering; ECMAScript JSON.stringify scalar encoding; ordered arrays; reject non-JSON values",
+        "stringNormalization": "CRLF/CR → LF, Unicode NFC; preserve spaces, punctuation, case, full/half-width characters and emoji",
+        "emptyValues": "missing or null → null; empty string remains empty string; no drop/trim",
+        "hashInput": "ksf.noise.text.nfc.v1 + LF + canonicalJson({className,fields})",
+        "prohibitedFields": "all engagement counters, collectedAt, noise*, server IDs and timestamps; preserve rawPayload separately",
+        "sourceFile": "shared-fingerprints.mjs"
+      }
+    ],
+    "payloadFingerprintSpec": {
+      "id": "ksf.payload.json.v1",
+      "hashInput": "ksf.payload.json.v1 + LF + canonicalJson(original rawPayload)",
+      "normalization": "no text or Unicode normalization; retain original JSON content; use recursive ordered keys",
+      "note": "两类hash的用途和输入独立;验收不采用“数值必须不相等”"
+    },
+    "supportedNoiseClasses": [
+      "KfRawXhsNote",
+      "KfRawXhsComment",
+      "KfRawDouyinVideo",
+      "KfRawDouyinComment",
+      "KfRawTaobaoReview",
+      "KfRawJdReview",
+      "KfRawBilibiliPost"
+    ],
+    "unsupportedNoiseClasses": [
+      "KfRawTaobaoItem",
+      "KfRawJdItem",
+      "KfRaw1688Offer"
+    ],
+    "enabledAtRuntime": false
+  },
+  "functionNames": [
+    "ksfGetIngestContract",
+    "ksfPrepareFileUpload",
+    "ksfRegisterSource",
+    "ksfRecordVocRun",
+    "ksfValidateImport",
+    "ksfSubmitImport",
+    "ksfGetImportResult",
+    "ksfRetryImport",
+    "ksfGetRecord",
+    "ksfSearchRecords",
+    "ksfGetDashboardView",
+    "ksfCreateProjectFromOpportunity",
+    "ksfTransitionProject",
+    "ksfApplyReviewFeedback",
+    "ksfSyncKnowledge",
+    "ksfConfirmReport",
+    "ksfPublishToDashboard",
+    "ksfSetNoiseAssessment"
+  ],
+  "runtimeSchemas": {
+    "KsfServiceCredential": {
+      "label": "String",
+      "tokenHash": "String",
+      "scopes": "Array",
+      "projectIds": "Array",
+      "enabled": "Boolean",
+      "expiresAt": "Date"
+    },
+    "KsfOperation": {
+      "ownerId": "String",
+      "requestId": "String",
+      "operation": "String",
+      "payloadHash": "String",
+      "receipt": "Object"
+    },
+    "KsfUploadSession": {
+      "ownerId": "String",
+      "requestId": "String",
+      "tokenHash": "String",
+      "fileName": "String",
+      "contentType": "String",
+      "sizeBytes": "Number",
+      "sha256": "String",
+      "expiresAt": "Date",
+      "state": "String",
+      "fileReceipt": "Object"
+    },
+    "KsfReportPreview": {
+      "ownerId": "String",
+      "identity": "Object",
+      "file": "Object",
+      "reportType": "String",
+      "dataMode": "String",
+      "artifactSubtype": "String",
+      "expiresAt": "Date"
+    },
+    "KsfConfirmationEvent": {
+      "ownerId": "String",
+      "employeeId": "String",
+      "previewId": "String",
+      "identity": "Object",
+      "action": "String",
+      "confirmedAt": "Date"
+    },
+    "KsfRuntimeConfig": {
+      "key": "String",
+      "value": "Object"
+    },
+    "KsfFileObject": {
+      "name": "String",
+      "ownerId": "String",
+      "contentType": "String",
+      "sizeBytes": "Number",
+      "sha256": "String",
+      "bytesBase64": "String"
+    }
+  },
+  "limits": {
+    "maxItems": 200,
+    "maxFileBytes": 5242880,
+    "defaultPageSize": 50,
+    "maxPageSize": 200,
+    "maxReadScan": 10000
+  }
+}

+ 21 - 0
server/scripts/build-member-groups.mjs

@@ -0,0 +1,21 @@
+// Build only the two new functions; do not rebuild/deploy the existing identity functions.
+import fs from 'node:fs';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const data = JSON.parse(fs.readFileSync(path.join(root, 'references/runtime-data.json')));
+const out = path.join(root, 'dist/member-groups');fs.mkdirSync(out, { recursive: true });
+const source = [path.join(root, '../src/core/member-groups.js'), ...['postgres.mjs', 'files.mjs', 'core.mjs', 'confirmation.mjs',
+  'workshop.mjs', 'board-actions.mjs', 'member-groups.mjs', 'bootstrap.mjs'].map((f) => path.join(root, 'src', f))]
+  .map((p) => fs.readFileSync(p, 'utf8').replace(/^import .*;\r?\n/gm, '').replace(/^export /gm, '')).join('\n');
+const html = fs.readFileSync(path.join(root, 'web/confirm.html'), 'utf8');
+const functions = [];
+for (const name of ['ksfGetPublicMemberGroups', 'ksfManagePublicMemberGroups']) {
+  const code = source + '\nasync function handler(request,response){return runDatabaseFunction(' + JSON.stringify(name) + ',request,response,' + JSON.stringify(data) + ',' + JSON.stringify(html) + ');}\n';
+  new Function(code);
+  fs.writeFileSync(path.join(out, name + '.js'), code);
+  functions.push({ name, path: '/' + name, file: name + '.js', sha256: crypto.createHash('sha256').update(code).digest('hex') });
+}
+fs.writeFileSync(path.join(out, 'manifest.json'), JSON.stringify({ release: 'ksf.member-groups.20261105.v1', existingFunctionsModified: false, functions }, null, 2));
+console.log(JSON.stringify({ built: functions.length, out, existingFunctionsModified: false }));

+ 52 - 0
server/src/board-actions.mjs

@@ -0,0 +1,52 @@
+// Anonymous actions on cards already exposed by the public board. No project approval side effects.
+export function attachBoardActionHandlers(app, { store }) {
+  const { fail, required } = app;
+  const publicRow = (row) => row && row.recordState !== 'archived' && (
+    row.meta?.workshop?.visibility === 'public' || row.externalKey?.startsWith('workshop:opportunity:') ||
+    !row.schemaVersion && !row.dashboardState
+  );
+  const actor = { id: 'public-board', kind: 'anonymous', scopes: [], projectIds: [] };
+  const labels = { candidate: '候选', approved: '已立项', rejected: '已否决' };
+  const types = { candidate: 'hold', approved: 'approve', rejected: 'reject' };
+  const clean = (row) => Object.fromEntries(['objectId', 'status', 'scores', 'decision', 'decisionLog', 'updatedAt'].filter((key) => row[key] !== undefined).map((key) => [key, row[key]]));
+  function validate(p, extra) {
+    if (Object.keys(p).some((key) => !['requestId', 'opportunityId', 'expectedUpdatedAt', 'by', ...extra].includes(key))) fail('VALIDATION_FAILED', '存在未允许字段');
+    required(p.requestId, 'requestId'); required(p.opportunityId, 'opportunityId');
+    if (!/^[A-Za-z0-9_-]{5,120}$/.test(p.opportunityId)) fail('VALIDATION_FAILED', '机会卡编号无效');
+    if (typeof p.expectedUpdatedAt !== 'string' || !Number.isFinite(Date.parse(p.expectedUpdatedAt))) fail('VALIDATION_FAILED', '缺少有效记录版本');
+    if (p.by != null && (typeof p.by !== 'string' || p.by.length > 100)) fail('VALIDATION_FAILED', '署名过长');
+  }
+  async function change(name, p, fn) {
+    return app.operation(actor, name, p, async (s) => {
+      const row = await s.get('KfOpportunity', p.opportunityId, { forUpdate: true });
+      if (!publicRow(row)) fail('REFERENCE_NOT_FOUND', '公开看板中没有这张机会卡');
+      if (Date.parse(row.updatedAt) !== Date.parse(p.expectedUpdatedAt)) fail('VERSION_CONFLICT', '卡片已更新,请刷新后再操作');
+      const result = await fn(row);
+      if (!result) return { changed: false, opportunity: clean(row) };
+      const saved = await s.update('KfOpportunity', row.objectId, result, { expectedUpdatedAt: row.updatedAt });
+      return { changed: true, opportunity: clean(saved), identityVerified: false, projectCreated: false, enterpriseApproval: false };
+    });
+  }
+  app.handlers.ksfSetPublicOpportunityStatus = async (p) => {
+    validate(p, ['status', 'reason']);
+    if (!Object.hasOwn(labels, p.status)) fail('VALIDATION_FAILED', '状态必须为候选、已立项或已否决');
+    if (p.reason != null && (typeof p.reason !== 'string' || p.reason.length > 1000)) fail('VALIDATION_FAILED', '理由过长');
+    return change('public-opportunity-status', p, async (row) => {
+      if (row.status === p.status) return null;
+      const entry = { type: types[p.status], label: labels[p.status], reason: p.reason?.trim() || '公开看板状态调整',
+        by: p.by?.trim() || '匿名访客', via: 'public_board', identityVerified: false, at: new Date().toISOString() };
+      return { status: p.status, decision: entry, decisionLog: [...(Array.isArray(row.decisionLog) ? row.decisionLog : []), entry] };
+    });
+  };
+  app.handlers.ksfRatePublicOpportunity = async (p) => {
+    validate(p, ['score', 'comment']);
+    if (typeof p.score !== 'number' || !Number.isFinite(p.score) || p.score < 0 || p.score > 100) fail('VALIDATION_FAILED', '分数须在 0–100 之间');
+    if (typeof p.by !== 'string' || !p.by.trim()) fail('VALIDATION_FAILED', '评分须填写署名');
+    if (p.comment != null && (typeof p.comment !== 'string' || p.comment.length > 1000)) fail('VALIDATION_FAILED', '评语过长');
+    return change('public-opportunity-score', p, async (row) => {
+      const scores = Array.isArray(row.scores) ? row.scores : [];
+      const entry = { user: p.by.trim(), score: p.score, comment: p.comment?.trim() || '', at: new Date().toISOString(), via: 'public_board', identityVerified: false };
+      return { scores: [...scores.filter((score) => score.user !== entry.user), entry] };
+    });
+  };
+}

+ 40 - 0
server/src/bootstrap.mjs

@@ -0,0 +1,40 @@
+export async function runDatabaseFunction(name,request,response,DATA,confirmationHtml){
+  response.setHeader('Cache-Control','no-store');response.setHeader('X-Content-Type-Options','nosniff');
+  try{
+    const crypto=await import('node:crypto');
+    if(Parse.applicationId!==DATA.appId)throw Object.assign(new Error('应用身份不匹配'),{code:'CAPABILITY_UNAVAILABLE'});
+    const module=await import('parse-server/lib/Config.js');const Config=module.default?.default||module.default;const config=Config.get(DATA.appId);const adapter=config.database?.adapter;
+    if(adapter?.constructor?.name!=='PostgresStorageAdapter'||typeof adapter._client?.tx!=='function')throw Object.assign(new Error('数据库适配器不兼容,已停止写入'),{code:'CAPABILITY_UNAVAILABLE'});
+    const store=createPostgresStore(adapter._client,DATA.tables,crypto);
+    const [runtimeConfig]=await store.list('KsfRuntimeConfig',{where:{key:'runtime'},limit:1});
+    if(!runtimeConfig?.value?.signingSecret)throw Object.assign(new Error('运行配置尚未安装'),{code:'CAPABILITY_UNAVAILABLE'});
+    const settings={...runtimeConfig.value,masterKey:Parse.masterKey,functionsUrl:'https://kangshifu.fmode.cn/api/functions',parseUrl:'https://kangshifu.fmode.cn/parse'};
+    const files=createFileAdapter({store,crypto,Parse,data:DATA,settings});
+    const app=createApplication({store,crypto,Parse,data:DATA,settings,files});attachConfirmationHandlers(app,{store,files,settings,data:DATA});
+    attachWorkshopHandlers(app,{store,data:DATA});
+    attachBoardActionHandlers(app,{store});
+    if (typeof attachMemberGroupHandlers === 'function') attachMemberGroupHandlers(app, { store,
+      loadBoard: () => readMemberGroupBoard({ url: settings.functionsUrl, appId: DATA.appId }) });
+    if(name==='ksfConfirmationPage'){
+      const nonce=crypto.randomBytes(24).toString('base64');
+      response.setHeader('Content-Security-Policy',"default-src 'none'; script-src 'nonce-"+nonce+"'; style-src 'unsafe-inline'; connect-src 'self'; frame-src blob:; img-src data: blob:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'");
+      return response.type('html').send(confirmationHtml.replace('__NONCE__',nonce));
+    }
+    if(name==='ksfUploadFile')return response.json(await files.upload(request));
+    if(name==='ksfReadFile'){
+      const actor=await app.authenticate(request.headers);const file=await files.readFile(request,actor);
+      response.setHeader('Content-Security-Policy',"default-src 'none'; sandbox");
+      response.setHeader('Content-Disposition',"attachment; filename*=UTF-8''"+encodeURIComponent(file.name));
+      return response.type(file.contentType).send(file.bytes);
+    }
+    const params=request.body?.params??request.query?.params??{};
+    if(!params||typeof params!=='object'||Array.isArray(params))app.fail('VALIDATION_FAILED','params 必须是对象');
+    const actor=await app.authenticate(request.headers);
+    return response.json({result:await app.dispatch(name,params,actor)});
+  }catch(e){
+    const known=typeof e.code==='string'&&/^[A-Z][A-Z0-9_]+$/.test(e.code);
+    const code=known?e.code:'INTERNAL_ERROR';
+    const status=['AUTH_REQUIRED'].includes(code)?401:code==='FORBIDDEN'?403:known?400:500;
+    return response.status(status).json({result:{requestId:typeof request.body?.params?.requestId==='string'?request.body.params.requestId:null,schemaVersion:DATA.schemaVersion,contractRevision:DATA.contractRevision,ok:false,status:'failed',data:null,errors:[{code,message:known?e.message:'服务执行失败,请携请求编号排查',...(e.field?{field:e.field}:{}),retryable:['INTERNAL_ERROR','VERSION_CONFLICT'].includes(code)}]}});
+  }
+}

+ 30 - 0
server/src/confirmation.mjs

@@ -0,0 +1,30 @@
+export function attachConfirmationHandlers(app,{store,files,settings,data}){
+  app.handlers.ksfCreateReportPreview=async(p,actor)=>{app.need(actor,'report:ingest');return app.operation(actor,'preview',p,async s=>{
+    const identity=p.reportIdentity;if(!identity||!Number.isInteger(identity.version)||identity.version<1||typeof p.html!=='string'||!p.html.trim())app.fail('VALIDATION_FAILED','报告预览内容或版本缺失');
+    app.required(identity.reportSeriesKey,'reportSeriesKey');if(identity.projectId&&!actor.projectIds.includes('*')&&!actor.projectIds.includes(identity.projectId))app.fail('FORBIDDEN');
+    if(app.hash(Buffer.from(p.html,'utf8'))!==identity.reportContentHash)app.fail('REPORT_CONTENT_CHANGED','预览与声明 hash 不一致');
+    if(!data.reportRules.reportTypes.some(x=>x.code===p.reportType)||!data.reportRules.dataModes.includes(p.dataMode))app.fail('VALIDATION_FAILED','报告业务取值不合法');
+    if(p.reportType==='workshop_auxiliary')app.required(p.artifactSubtype,'artifactSubtype');
+    const file=await files.createPreviewFile(Buffer.from(p.html,'utf8'),'ksf-preview.html',actor.id,s);
+    const row=await s.create('KsfReportPreview',{ownerId:actor.id,identity:{reportSeriesKey:identity.reportSeriesKey,version:identity.version,reportContentHash:identity.reportContentHash,projectId:identity.projectId||null},file,reportType:p.reportType,dataMode:p.dataMode,...(p.artifactSubtype?{artifactSubtype:p.artifactSubtype}:{}),expiresAt:new Date(Date.now()+7*86400000).toISOString()});
+    return{previewId:row.objectId,confirmationUrl:settings.functionsUrl+'?path='+encodeURIComponent('/ksf/confirm')+'&previewId='+row.objectId,reportIdentity:row.identity,status:'awaiting_employee'};
+  });};
+  app.handlers.ksfGetReportPreview=async(p,actor)=>{app.need(actor,'report:confirm');const row=await store.get('KsfReportPreview',app.required(p.previewId,'previewId'));
+    if(!row||Date.parse(row.expiresAt)<Date.now())app.fail('REFERENCE_NOT_FOUND','预览不存在或已过期');
+    if(row.identity.projectId&&!actor.projectIds.includes('*')&&!actor.projectIds.includes(row.identity.projectId))app.fail('FORBIDDEN');
+    const bytes=await files.retrieve(row.file);if(app.hash(bytes)!==row.identity.reportContentHash)app.fail('REPORT_CONTENT_CHANGED');
+    return{previewId:row.objectId,html:bytes.toString('utf8'),reportIdentity:row.identity,reportType:row.reportType,dataMode:row.dataMode,...(row.artifactSubtype?{artifactSubtype:row.artifactSubtype}:{})};
+  };
+  app.handlers.ksfEmployeeConfirmation=async(p,actor)=>{app.need(actor,'report:confirm');if(actor.kind!=='employee')app.fail('REPORT_NOT_CONFIRMED','服务身份不能代替员工确认');
+    return app.operation(actor,'employee-confirm',p,async s=>{const preview=await s.get('KsfReportPreview',app.required(p.previewId,'previewId'),{forUpdate:true});
+      if(!preview||Date.parse(preview.expiresAt)<Date.now())app.fail('REFERENCE_NOT_FOUND','报告预览已过期');
+      if(p.action!=='confirm_report'||p.reportContentHash!==preview.identity.reportContentHash)app.fail('REPORT_CONTENT_CHANGED','员工确认须绑定当前展示内容');
+      if(preview.identity.projectId&&!actor.projectIds.includes('*')&&!actor.projectIds.includes(preview.identity.projectId))app.fail('FORBIDDEN');
+      const event=await s.create('KsfConfirmationEvent',{ownerId:preview.ownerId,employeeId:actor.id,previewId:preview.objectId,identity:preview.identity,action:'confirm_report',confirmedAt:new Date().toISOString()});
+      return{eventId:event.objectId,confirmedAt:event.confirmedAt,reportIdentity:event.identity,status:'confirmed'};
+    });
+  };
+  app.handlers.ksfGetPreviewConfirmation=async(p,actor)=>{app.need(actor,'report:ingest');const preview=await store.get('KsfReportPreview',app.required(p.previewId,'previewId'));if(!preview||preview.ownerId!==actor.id&&!actor.scopes.includes('*'))app.fail('FORBIDDEN');
+    const events=await store.list('KsfConfirmationEvent',{where:{previewId:preview.objectId},limit:100});const event=events.filter(x=>x.action==='confirm_report').sort((a,b)=>b.confirmedAt.localeCompare(a.confirmedAt))[0];return{status:event?'confirmed':'awaiting_employee',...(event?{eventId:event.objectId,reportIdentity:event.identity}:{})};
+  };
+}

+ 331 - 0
server/src/core.mjs

@@ -0,0 +1,331 @@
+export function createApplication({store,crypto,Parse,data,settings,files}) {
+  const SCHEMA=data.schemaVersion,REV=data.contractRevision;
+  const modes=new Set(data.reportRules.dataModes),reportTypes=new Set(data.reportRules.reportTypes.map(x=>x.code));
+  const noiseFields={KfRawXhsNote:['title','content'],KfRawXhsComment:['text'],KfRawDouyinVideo:['content'],KfRawDouyinComment:['text'],KfRawTaobaoReview:['text'],KfRawJdReview:['text'],KfRawBilibiliPost:['title','description']};
+  const rawClasses=Object.keys(data.tables).filter(x=>x.startsWith('KfRaw'));
+  const fail=(code,message=code,field)=>{const e=new Error(message);e.code=code;e.field=field;throw e;};
+  const required=(v,name)=>{if(typeof v!=='string'||!v.trim()||v.length>500)fail('VALIDATION_FAILED',name+' 必须是非空字符串',name);return v;};
+  const hash=value=>crypto.createHash('sha256').update(value).digest('hex');
+  function canonical(value){
+    if(value===null||typeof value==='boolean'||typeof value==='string')return JSON.stringify(value);
+    if(typeof value==='number'&&Number.isFinite(value))return JSON.stringify(value);
+    if(Array.isArray(value))return'['+value.map(canonical).join(',')+']';
+    if(value&&typeof value==='object')return'{'+Object.keys(value).sort().map(k=>JSON.stringify(k)+':'+canonical(value[k])).join(',')+'}';
+    fail('VALIDATION_FAILED','不是有效 JSON');
+  }
+  const pointer=(className,objectId)=>({__type:'Pointer',className,objectId});
+  const metadata=(externalKey,dataMode='unknown')=>({schemaVersion:SCHEMA,externalKey,recordState:'active',reviewState:'pending',ingestedAt:new Date().toISOString(),dataMode});
+  const envelope=(p,payload,status='ok')=>({requestId:p.requestId||crypto.randomUUID(),schemaVersion:SCHEMA,contractRevision:REV,ok:true,status,data:payload,errors:[]});
+  function need(actor,scope){if(!actor)fail('AUTH_REQUIRED','需要登录或受限服务身份');if(!actor.scopes.includes('*')&&!actor.scopes.includes(scope))fail('FORBIDDEN','无操作权限:'+scope);}
+  function noiseHash(className,row){const keys=noiseFields[className];if(!keys)fail('CAPABILITY_UNAVAILABLE','该表尚未启用正文判断规则');const fields={};for(const k of keys){const v=row[k];if(v!=null&&typeof v!=='string')fail('VALIDATION_FAILED','正文字段类型错误');fields[k]=v==null?null:v.replace(/\r\n?/g,'\n').normalize('NFC');}return hash('ksf.noise.text.nfc.v1\n'+canonical({className,fields}));}
+  const safeEq=(a,b)=>{if(typeof a!=='string'||typeof b!=='string'||a.length!==b.length)return false;return crypto.timingSafeEqual(Buffer.from(a),Buffer.from(b));};
+  async function authenticate(headers){
+    const h=Object.fromEntries(Object.entries(headers||{}).map(([k,v])=>[k.toLowerCase(),v]));
+    if(h['x-parse-master-key']){if(!safeEq(h['x-parse-master-key'],settings.masterKey))fail('AUTH_REQUIRED','管理凭据无效');return{id:'deployment-admin',kind:'service',scopes:['*'],projectIds:['*']};}
+    if(h['x-ksf-service-token']){const tokenHash=hash(h['x-ksf-service-token']);const [row]=await store.list('KsfServiceCredential',{where:{tokenHash},limit:1});if(!row||!row.enabled||!row.expiresAt||Date.parse(row.expiresAt)<=Date.now())fail('AUTH_REQUIRED','服务凭据无效或过期');return{id:row.objectId,kind:'service',scopes:row.scopes||[],projectIds:row.projectIds||[]};}
+    if(h['x-parse-session-token']){
+      let user;try{const response=await fetch(settings.parseUrl+'/users/me',{headers:{'X-Parse-Application-Id':data.appId,'X-Parse-Session-Token':h['x-parse-session-token']},signal:AbortSignal.timeout(10000)});if(!response.ok)throw Error('session');const info=await response.json();user=Parse.User.createWithoutData(info.objectId);}catch{fail('AUTH_REQUIRED','员工会话失效');}
+      const roles=await new Parse.Query('_Role').equalTo('users',user).find({useMasterKey:true});
+      const names=roles.map(r=>r.get('name'));if(!names.includes('KsfEmployee')&&!names.includes('KsfAdmin'))fail('FORBIDDEN','此账号尚未获员工授权');
+      return{id:user.id,kind:'employee',scopes:names.includes('KsfAdmin')?['*']:['data:read','report:confirm','noise:write','noise:manual','noise:read'],projectIds:['*']};
+    }
+    return null;
+  }
+  function projectAccess(actor,projectId){if(projectId&&!actor.projectIds.includes('*')&&!actor.projectIds.includes(projectId))fail('FORBIDDEN','不在授权项目范围');}
+  async function accessSource(s,id,actor){const row=await s.get('KfSource',id);if(!row)fail('REFERENCE_NOT_FOUND','来源不存在');if(row.meta?.ownerId&&row.meta.ownerId!==actor.id&&actor.kind!=='employee'&&!actor.scopes.includes('*')&&!actor.scopes.includes('data:read-all'))fail('FORBIDDEN','不能访问其他身份的来源');return row;}
+  async function accessRow(s,className,row,actor){
+    if(!row)fail('REFERENCE_NOT_FOUND','目标记录不存在');projectAccess(actor,row.projectRef?.objectId||row.projectId);
+    if(className==='KfSource')return accessSource(s,row.objectId,actor);
+    if(className==='KfImportJob')await accessSource(s,row.inputSourceRef?.objectId,actor);
+    if(className==='KfImportItem')await accessRow(s,'KfImportJob',await s.get('KfImportJob',row.jobRef?.objectId),actor);
+    if(className==='KfVocRun'&&!row.sourceRef&&actor.kind==='service'&&!actor.scopes.includes('*')&&!row.externalKey?.startsWith(actor.id+':'))fail('FORBIDDEN');
+    if(row.sourceRef?.objectId)await accessSource(s,row.sourceRef.objectId,actor);
+    return row;
+  }
+  async function operation(actor,name,p,fn){required(p.requestId,'requestId');const payloadHash=hash(canonical(p));
+    return store.transaction(async s=>{await s.lock('ksf:operation:'+actor.id+':'+name+':'+p.requestId);const [prior]=await s.list('KsfOperation',{where:{ownerId:actor.id,operation:name,requestId:p.requestId},limit:1});
+      if(prior){if(prior.payloadHash!==payloadHash)fail('IDEMPOTENCY_CONFLICT','同一 requestId 对应不同内容');return prior.receipt;}
+      const result=await fn(s);await s.create('KsfOperation',{ownerId:actor.id,operation:name,requestId:p.requestId,payloadHash,receipt:result});return result;
+    });
+  }
+  function validateValue(def,value,field){if(value===null)return;
+    const types={String:()=>typeof value==='string',Number:()=>typeof value==='number'&&Number.isFinite(value),Boolean:()=>typeof value==='boolean',Array:()=>Array.isArray(value),Object:()=>value&&typeof value==='object'&&!Array.isArray(value)&&!value.__op,Date:()=>typeof(value?.iso||value)==='string'&&Number.isFinite(Date.parse(value?.iso||value)),Pointer:()=>value&&value.__type==='Pointer'&&value.className===def.targetClass&&typeof value.objectId==='string'&&/^[A-Za-z0-9_-]{5,120}$/.test(value.objectId),File:()=>value&&value.__type==='File'&&typeof value.name==='string'};
+    if(!types[def.type]?.())fail('VALIDATION_FAILED','字段类型不符:'+field,field);
+  }
+  const serverFields=new Set(['objectId','createdAt','updatedAt','ACL','__className','ingestedAt','reviewedAt','reviewedByRef','noiseHistory','noiseAssessedAt','noiseAssessedByRef','noiseContentHash','dashboardState','dashboardCardId','dashboardPublication','dashboardPublishedAt','dashboardPublishedByRef','contentConfirmedAt','contentConfirmedByRef','contentConfirmationRef','artifactType','promotion']);
+  async function validate(s,p,actor){
+    if(p.schemaVersion!==SCHEMA)fail('SCHEMA_VERSION_UNSUPPORTED','不支持该结构版本');required(p.sourceId,'sourceId');await accessSource(s,p.sourceId,actor);
+    if(!Array.isArray(p.items)||p.items.length<1||p.items.length>data.limits.maxItems)fail('VALIDATION_FAILED','items 数量超限');
+    const keys=new Set();const results=[];
+    for(const[i,item]of p.items.entries()){
+      try{
+        required(item.itemKey,'items['+i+'].itemKey');required(item.externalKey,'items['+i+'].externalKey');
+        if(keys.has(item.itemKey))fail('VALIDATION_FAILED','itemKey 重复');keys.add(item.itemKey);
+        if(!data.importClasses.includes(item.className))fail('VALIDATION_FAILED','不允许导入此表');
+        const defs=data.tables[item.className],record=item.data;if(!record||typeof record!=='object'||Array.isArray(record))fail('VALIDATION_FAILED','data 必须是对象');
+        if(record.externalKey&&record.externalKey!==item.externalKey)fail('VALIDATION_FAILED','业务键不一致');
+        for(const[k,v]of Object.entries(record)){if(serverFields.has(k)||!Object.hasOwn(defs,k))fail('VALIDATION_FAILED','不可写字段:'+k,'items['+i+'].data.'+k);validateValue(defs[k],v,k);}
+        if(record.schemaVersion&&record.schemaVersion!==SCHEMA)fail('SCHEMA_VERSION_UNSUPPORTED','条目版本不匹配');
+        if(!modes.has(record.dataMode))fail('VALIDATION_FAILED','dataMode 必须明确指定');
+        if(!['pending','draft',null,undefined].includes(record.reviewState))fail('VALIDATION_FAILED','不能通过导入设置人工确认状态');
+        if(record.recordState&&!['active','archived'].includes(record.recordState))fail('VALIDATION_FAILED','recordState 无效');
+        for(const[k,def]of Object.entries(defs)){if(def.required&&!serverFields.has(k)&&!['schemaVersion','externalKey','reviewState','recordState','collectedAt','artifactType','workshopKey'].includes(k)&&record[k]==null)fail('VALIDATION_FAILED','缺少必填字段:'+k,k);}
+        if(record.sourceRef?.objectId&&record.sourceRef.objectId!==p.sourceId)fail('VALIDATION_FAILED','sourceRef 必须绑定当前来源');
+        for(const[k,v]of Object.entries(record)){if(v&&defs[k]?.type==='Pointer'){const target=await s.get(v.className,v.objectId);await accessRow(s,v.className,target,actor);}}
+        if(rawClasses.includes(item.className)){
+          if(!Number.isInteger(record.recordIndex)||record.recordIndex<0)fail('VALIDATION_FAILED','recordIndex 无效');
+          if(!['upstream','plugin_generated','missing'].includes(record.idOrigin))fail('VALIDATION_FAILED','idOrigin 无效');
+          if(!['upstream_raw','voc_normalized'].includes(record.payloadLevel))fail('VALIDATION_FAILED','payloadLevel 无效');
+          const platform=item.className.includes('Douyin')?'douyin':item.className.includes('Xhs')?'xiaohongshu':item.className.includes('Bilibili')?'bilibili':item.className.includes('1688')?'1688':item.className.includes('Taobao')?'taobao':'jd';
+          if(record.platform!==platform&&!(platform==='xiaohongshu'&&record.platform==='xhs'))fail('VALIDATION_FAILED','platform 与表不匹配');
+          if(record.payloadHash!==hash('ksf.payload.json.v1\n'+canonical(record.rawPayload)))fail('VALIDATION_FAILED','payloadHash 与原始数据不符');
+          if(record.isNoise!=null)fail('CAPABILITY_UNAVAILABLE','首次自动判断须先接通可信执行事件;当前请先入库再人工判断');
+        }
+        if(item.className==='KfArtifact'){
+          if(!reportTypes.has(record.reportType))fail('VALIDATION_FAILED','reportType 不在码表内');
+          if(record.reportType==='workshop_auxiliary')required(record.artifactSubtype,'artifactSubtype');
+          else if(record.artifactSubtype!=null)fail('VALIDATION_FAILED','此报告类型不接受 artifactSubtype');
+          if(!Number.isInteger(record.version)||record.version<1)fail('VALIDATION_FAILED','version 必须为正整数');
+          required(record.reportSeriesKey,'reportSeriesKey');if(!/^[a-f0-9]{64}$/.test(record.reportContentHash||''))fail('VALIDATION_FAILED','报告 hash 无效');
+          if(!record.title||!record.rawText||!record.content)fail('VALIDATION_FAILED','报告缺少可检索标题、正文或结构化内容');
+          if(record.contentSchema!=='ksf.html-outline.v1'||!Array.isArray(record.content.headings)||typeof record.content.summary!=='string')fail('VALIDATION_FAILED','当前报告适配只支持 ksf.html-outline.v1(summary + headings)');
+          const token=(p.reportConfirmations||[]).find(x=>x.itemKey===item.itemKey)?.confirmationReceipt;
+          await verifyConfirmation(s,token,record,actor);
+          const source=await accessSource(s,p.sourceId,actor);if(source.contentHash!==record.reportContentHash||!source.file||source.sourceKind!=='report_artifact'||source.dataMode!==record.dataMode)fail('REPORT_CONTENT_CHANGED','报告与已登记原件或取得模式不一致');
+          const bytes=await files.retrieve(source.file);if(hash(bytes)!==record.reportContentHash)fail('REPORT_CONTENT_CHANGED','报告原件已变化');
+          const extracted=extractReport(bytes.toString('utf8'),record.reportSeriesKey);
+          if(record.title!==extracted.title||record.rawText!==extracted.rawText||record.content.summary!==extracted.summary||canonical(record.content.headings)!==canonical(extracted.headings))fail('REPORT_CONTENT_CHANGED','报告可检索正文、摘要或标题与原件不一致');
+        }
+        results.push({itemKey:item.itemKey,valid:true,errors:[],warnings:record.collectedAt==null&&rawClasses.includes(item.className)?['采集时间未知,保持空值']:[]});
+      }catch(e){results.push({itemKey:item.itemKey,valid:false,errors:[{code:e.code||'VALIDATION_FAILED',message:e.code?e.message:'条目校验失败',field:e.field||null}],warnings:[]});}
+    }
+    for(const item of p.items)for(const dependency of item.dependsOn||[])if(!keys.has(dependency))fail('VALIDATION_FAILED','依赖条目不存在');
+    return{valid:results.every(x=>x.valid),itemResults:results,writePlan:p.items.map(x=>x.itemKey)};
+  }
+  function signedReceipt(payload){const text=Buffer.from(canonical(payload)).toString('base64url');return text+'.'+crypto.createHmac('sha256',settings.signingSecret).update(text).digest('base64url');}
+  function readReceipt(token){if(typeof token!=='string')fail('REPORT_NOT_CONFIRMED','缺少可信确认凭据');const[a,b,...rest]=token.split('.');if(rest.length||!a||!safeEq(b,crypto.createHmac('sha256',settings.signingSecret).update(a).digest('base64url')))fail('REPORT_NOT_CONFIRMED','确认签名无效');let payload;try{payload=JSON.parse(Buffer.from(a,'base64url').toString('utf8'));}catch{fail('REPORT_NOT_CONFIRMED','确认凭据格式无效');}return payload;}
+  async function verifyConfirmation(s,token,report,actor){const payload=readReceipt(token);if(payload.type!=='report-confirmation'||payload.expiresAt<Date.now())fail('REPORT_NOT_CONFIRMED','确认凭据已过期');
+    const event=await s.get('KsfConfirmationEvent',payload.eventId);if(!event||event.action!=='confirm_report'||event.employeeId!==payload.employeeId)fail('REPORT_NOT_CONFIRMED','确认事件不存在');
+    const expected={reportSeriesKey:report.reportSeriesKey,version:report.version,reportContentHash:report.reportContentHash,projectId:report.projectRef?.objectId||report.projectId||null};
+    if(canonical(event.identity)!==canonical(expected))fail('REPORT_CONTENT_CHANGED','确认绑定的报告版本、内容或项目不一致');
+    const preview=await s.get('KsfReportPreview',event.previewId);
+    if(!preview||preview.reportType!==report.reportType||preview.dataMode!==report.dataMode||(preview.artifactSubtype||null)!==(report.artifactSubtype||null))fail('REPORT_CONTENT_CHANGED','报告类型、取得模式或子类型与员工确认预览不一致');
+    if(actor.id!==event.ownerId&&actor.id!==event.employeeId&&!actor.scopes.includes('*'))fail('FORBIDDEN','该确认凭据不属于当前提交身份');
+    projectAccess(actor,expected.projectId);return event;
+  }
+  function snapshot(className,row){const result={className,objectId:row.objectId,updatedAt:row.updatedAt,observedAt:new Date().toISOString()};if(noiseFields[className]){result.noiseContentHash=noiseHash(className,row);result.fingerprintSpecId='ksf.noise.text.nfc.v1';}return result;}
+  function latestAssessment(row,context,className){if(!noiseFields[className])return null;const fingerprint=noiseHash(className,row);return[...(row.noiseHistory||[])].reverse().find(x=>x.scope===context.scope&&x.ruleVersion===context.ruleVersion&&x.contentHash===fingerprint)||null;}
+  function checkNoiseContext(context){if(!context)fail('NOISE_CONTEXT_REQUIRED','需要指定研究范围和规则');const scope=data.registry.noiseScopes.find(x=>x.id===context.scope),rule=data.registry.noiseRuleVersions.find(x=>x.id===context.ruleVersion);if(!scope||!rule||!scope.allowedRuleVersions.includes(rule.id)||!rule.scopeIds.includes(scope.id))fail('NOISE_CONTEXT_REQUIRED','研究范围与规则不匹配');if(!['usable','noise','pending','all',undefined].includes(context.mode))fail('VALIDATION_FAILED','noiseContext.mode 无效');return{...context,mode:context.mode||'usable'};}
+  const dedupVersion='ksf.ingest-dedup.v1';
+  // Strict equality only. Never infer identical people/products from similar text.
+  async function sourceDuplicate(s,input,actualHash,actor){
+    const rows=await s.list('KfSource',{where:{contentHash:actualHash,dataMode:input.dataMode,sourceKind:input.sourceKind,recordState:'active'},limit:data.limits.maxReadScan+1});
+    if(rows.length>data.limits.maxReadScan)fail('DEDUP_SCAN_LIMIT','查重超出扫描上限,未跳过检查');
+    return rows.find(r=>r.meta?.ownerId===actor.id&&(r.sourceUrl||null)===(input.sourceUrl||null)&&(r.platform||null)===(input.platform||null)&&Boolean(r.file)===Boolean(input.fileReceipt))||null;
+  }
+  function importContent(className,row){
+    const excluded=new Set([...serverFields,'externalKey','schemaVersion','reviewState','recordState']);
+    if(rawClasses.includes(className))for(const k of ['sourceRef','runRef','recordIndex','collectedAt','isNoise','noiseReason','noiseScope','noiseRuleVersion','noiseMethod'])excluded.add(k);
+    // PostgreSQL returns unwritten optional columns as null; absence and null are unknown.
+    return Object.fromEntries(Object.entries(row).filter(([k,v])=>!excluded.has(k)&&v!==null&&v!==undefined));
+  }
+  async function importDuplicate(s,item,actor){
+    // Reports/projects/concepts retain their own version and confirmation rules.
+    if(['KfArtifact','KfOpportunity','KfAssessment','KfConcept','KfFormulaItem','KfInsight'].includes(item.className))return null;
+    const raw=rawClasses.includes(item.className),r=item.data;if(r.recordState==='archived')return null;
+    if(item.className==='KfCategory'){
+      const rows=await s.list('KfCategory',{where:{taxonomyVersion:r.taxonomyVersion,code:r.code,dataMode:r.dataMode,recordState:'active'},limit:data.limits.maxReadScan+1});
+      if(rows.length>data.limits.maxReadScan)fail('DEDUP_SCAN_LIMIT','查重超出扫描上限,未跳过检查');
+      if(rows.length>1)fail('CATEGORY_IDENTITY_AMBIGUOUS','相同分类版本与代码存在多条记录,需维护人先核实');
+      if(rows.length===1){
+        const prior=rows[0];await accessRow(s,item.className,prior,actor);
+        if(canonical(importContent(item.className,prior))!==canonical(importContent(item.className,r)))fail('CATEGORY_DEFINITION_CONFLICT','相同分类版本与代码已存在不同定义;请核实后引用现有分类,不自动创建第二条');
+        return prior;
+      }
+    }
+    const where={dataMode:r.dataMode,recordState:'active'};
+    if(raw&&r.idOrigin==='upstream'&&r.externalObjectId){Object.assign(where,{platform:r.platform,idOrigin:'upstream',externalObjectId:r.externalObjectId});}
+    else if(data.tables[item.className].sourceRef){if(!r.sourceRef?.objectId)return null;where.sourceRef=r.sourceRef;}
+    const rows=await s.list(item.className,{where,limit:data.limits.maxReadScan+1});
+    if(rows.length>data.limits.maxReadScan)fail('DEDUP_SCAN_LIMIT','查重超出扫描上限,未跳过检查');
+    const wanted=importContent(item.className,r);
+    for(const row of rows){
+      try{await accessRow(s,item.className,row,actor);}catch(e){if(e.code==='FORBIDDEN')continue;throw e;}
+      if(raw){
+        const source=await s.get('KfSource',row.sourceRef?.objectId);
+        if(source?.meta?.ownerId!==actor.id)continue;
+        // Without a stable upstream ID, identical text from different authors is not proof.
+        if(r.idOrigin!=='upstream'&&(row.recordIndex!==r.recordIndex||row.payloadHash!==r.payloadHash))continue;
+      }
+      const existing=importContent(item.className,row);
+      if(Object.keys(existing).length===Object.keys(wanted).length&&canonical(existing)===canonical(wanted))return row;
+    }return null;
+  }
+  const handlers={};
+  handlers.ksfGetIngestContract=async(p,actor)=>{
+    if(p.clientSchemaVersion&&p.clientSchemaVersion!==SCHEMA)fail('SCHEMA_VERSION_UNSUPPORTED');
+    if(p.clientContractRevision&&p.clientContractRevision!==REV)fail('SCHEMA_VERSION_UNSUPPORTED');
+    return{schemaVersion:SCHEMA,contractRevision:REV,release:data.release,allowedClasses:actor?data.importClasses.map(className=>({className,fields:Object.entries(data.tables[className]).filter(([k])=>!serverFields.has(k)).map(([name,def])=>({name,...def}))})):[],limits:data.limits,
+      capabilities:{databaseFunctions:true,transport:'fmode-database-function',fileUpload:true,reportConfirmation:true,employeeChannel:'authenticated-confirmation-page',employeeAccountsProvisioned:settings.employeeAccountsProvisioned||false,reportPublication:true,asynchronousJobs:false,synchronousImport:true,transactions:true,atomicNoiseUpdate:true,noiseAssessment:true,automaticNoiseExecution:false,projectTransition:false,feedbackApply:false,knowledgeSync:false,reportContentSchemas:['ksf.html-outline.v1'],marketMetricNoiseRecalculation:false},
+      deduplication:{schema:dedupVersion,preflightFunction:'ksfCheckDuplicates',sourceExact:true,recordExact:true,semanticMerge:false,existingDuplicatesDeleted:false,auditRecordsRetained:true},
+      transport:{url:settings.functionsUrl,method:'POST',body:{path:'/{functionName}',params:'业务参数对象'},responseEnvelope:'{result:{requestId,schemaVersion,contractRevision,ok,status,data,errors}}'},
+      noiseScopes:data.registry.noiseScopes.map(x=>({...x,status:'active'})),noiseRuleVersions:data.registry.noiseRuleVersions.map(x=>({...x,status:'active',allowedMethods:['manual','reused']})),fingerprintSpecs:data.registry.fingerprintSpecs,reportTypes:data.reportRules.reportTypes,dataModes:[...modes],sourceKinds:data.reportRules.sourceKinds};
+  };
+  handlers.ksfRegisterSource=async(p,actor)=>{need(actor,'source:write');return operation(actor,'source',p,async s=>{
+    const input=p.source;if(!input||typeof input!=='object')fail('VALIDATION_FAILED','source 必填');required(input.title,'source.title');
+    if(!data.reportRules.sourceKinds.includes(input.sourceKind)||!modes.has(input.dataMode))fail('VALIDATION_FAILED','来源类型或 dataMode 不合法');
+    const allowed=['title','sourceKind','dataMode','fileReceipt','rawText','payload','sourceUrl','platform','collectedAt'];for(const k of Object.keys(input))if(!allowed.includes(k))fail('VALIDATION_FAILED','来源含不可写字段:'+k);
+    let file=null,actualHash,fileUploadId=null;
+    if(input.fileReceipt){const u=await files.verify(s,input.fileReceipt,actor);file=u.file;actualHash=u.sha256;fileUploadId=u.fileId;}
+    else if(typeof input.rawText==='string'&&input.rawText.length)actualHash=hash(Buffer.from(input.rawText,'utf8'));
+    else if(input.payload&&typeof input.payload==='object')actualHash=hash(canonical(input.payload));
+    else fail('VALIDATION_FAILED','来源必须有实际内容;单一 URL 不能代替已抓取资料');
+    if(actualHash!==p.contentHash)fail('VALIDATION_FAILED','来源内容 hash 不一致');
+    await s.lock('ksf:dedup:source:'+actor.id+':'+actualHash);
+    const prior=await sourceDuplicate(s,input,actualHash,actor);
+    if(prior)return{sourceId:prior.objectId,operation:'unchanged',deduplication:dedupVersion,...(prior.file?{file:prior.file}:{})};
+    const {fileReceipt,...content}=input;const row=await s.create('KfSource',{...metadata(actor.id+':'+p.requestId,input.dataMode),...content,...(file?{file}:{}),contentHash:actualHash,meta:{ownerId:actor.id,requestId:p.requestId,verifiedFile:file?{sha256:actualHash,fileId:fileUploadId}:null}});
+    return{sourceId:row.objectId,operation:'created',...(file?{file}: {})};
+  });};
+  handlers.ksfCheckDuplicates=async(p,actor)=>{
+    need(actor,'data:read');const input=p.source;
+    if(!input||!modes.has(input.dataMode)||!data.reportRules.sourceKinds.includes(input.sourceKind)||typeof input.rawText!=='string'||!input.rawText||input.payload||input.fileReceipt)fail('VALIDATION_FAILED','只读查重需要实际rawText来源;文件仍按上传原件流程');
+    if(!Array.isArray(p.records)||p.records.length<1||p.records.length>data.limits.maxItems)fail('VALIDATION_FAILED','records数量无效');
+    const prior=await sourceDuplicate(store,input,hash(Buffer.from(input.rawText,'utf8')),actor);const items=[];
+    for(const item of p.records){
+      if(!data.importClasses.includes(item.className)||!item.data||item.data.dataMode!==input.dataMode)fail('VALIDATION_FAILED','查重条目类型或模式无效');
+      const unresolved=v=>v&&typeof v==='object'&&(Object.hasOwn(v,'$item')||Object.values(v).some(unresolved));
+      if(unresolved(item.data)){items.push({itemKey:item.itemKey,decision:'deferred',reason:'DEPENDENCY_NOT_RESOLVED'});continue;}
+      const record={...item.data};if(data.tables[item.className].sourceRef){if(!prior){items.push({itemKey:item.itemKey,decision:'deferred',reason:'SOURCE_NOT_REGISTERED'});continue;}record.sourceRef=pointer('KfSource',prior.objectId);}
+      const found=await importDuplicate(store,{...item,data:record},actor);
+      items.push({itemKey:item.itemKey,decision:found?'reuse':'no-exact-match',...(found?{objectId:found.objectId}:{})});
+    }return{readOnly:true,schema:dedupVersion,source:{operation:prior?'unchanged':'no-exact-match',...(prior?{sourceId:prior.objectId}:{})},items,validationStillRequired:true};
+  };
+  handlers.ksfRecordVocRun=async(p,actor)=>{need(actor,'ingest:write');return operation(actor,'voc-run',p,async s=>{
+    required(p.runKey,'runKey');const run=p.run;if(!run||!modes.has(run.dataMode)||!['pending','running','succeeded','partial','failed','stale'].includes(run.status))fail('VALIDATION_FAILED','采集执行状态或模式无效');
+    const allowed=Object.keys(data.tables.KfVocRun).filter(x=>!serverFields.has(x)&&!['externalKey','schemaVersion','reviewState','recordState'].includes(x));for(const k of Object.keys(run)){if(!allowed.includes(k))fail('VALIDATION_FAILED','采集执行含未允许字段:'+k);validateValue(data.tables.KfVocRun[k],run[k],k);}
+    if(run.sourceRef)await accessSource(s,run.sourceRef.objectId,actor);
+    const externalKey=actor.id+':'+p.runKey;await s.lock('ksf:run:'+externalKey);const[prior]=await s.list('KfVocRun',{where:{externalKey},limit:1});
+    const row=prior?await s.update('KfVocRun',prior.objectId,run):await s.create('KfVocRun',{...metadata(externalKey,run.dataMode),...run});return{runId:row.objectId,status:row.status};
+  });};
+  handlers.ksfValidateImport=async(p,actor)=>{need(actor,'ingest:write');return validate(store,p,actor);};
+  handlers.ksfSubmitImport=async(p,actor)=>{need(actor,'ingest:write');return operation(actor,'import',p,async s=>{
+    required(p.producer?.skillName,'producer.skillName');required(p.producer?.skillVersion,'producer.skillVersion');projectAccess(actor,p.projectId);
+    const checked=await validate(s,p,actor);if(!checked.valid){const errors=checked.itemResults.flatMap(x=>x.errors);fail(errors[0]?.code||'VALIDATION_FAILED',errors.map(x=>x.message).join(';'));}
+    // One transaction lock per class; sorted order prevents cross-batch lock inversion.
+    for(const name of [...new Set(p.items.map(x=>x.className))].sort())await s.lock('ksf:dedup:class:'+name);
+    const job=await s.create('KfImportJob',{...metadata(actor.id+':'+p.requestId,'mixed'),jobKey:actor.id+':'+p.requestId,inputSourceRef:pointer('KfSource',p.sourceId),producer:p.producer,status:'writing',expectedCount:p.items.length,successCount:0,failureCount:0,startedAt:new Date().toISOString(),...(p.vocRunId?{vocRunRef:pointer('KfVocRun',p.vocRunId)}:{})});
+    const counts={created:0,updated:0,unchanged:0,skipped:0,failed:0,pending:0},recordRefs=[],done=new Set(),remaining=p.items.slice();
+    while(remaining.length){let progress=false;for(let i=0;i<remaining.length;i++){
+      const item=remaining[i];if((item.dependsOn||[]).some(x=>!done.has(x)))continue;remaining.splice(i--,1);progress=true;
+      await s.lock('ksf:business:'+item.className+':'+item.externalKey);
+      const payloadHash=hash(canonical(item.data));const[prior]=await s.list(item.className,{where:{externalKey:item.externalKey},limit:2});
+      let row,op;
+      if(prior){await accessRow(s,item.className,prior,actor);let equivalent;
+        if(rawClasses.includes(item.className))equivalent=prior.payloadHash===item.data.payloadHash&&prior.sourceRef?.objectId===item.data.sourceRef?.objectId;
+        else equivalent=Object.entries(item.data).every(([k,v])=>canonical(prior[k]??null)===canonical(v));
+        if(!equivalent)fail('IDEMPOTENCY_CONFLICT','业务键已经存在不同内容,请提交新版本');row=prior;op='unchanged';
+      }else if((row=await importDuplicate(s,item,actor))){op='unchanged';
+      }else{
+        const record={...metadata(item.externalKey,item.data.dataMode),...item.data};
+        if(item.className==='KfArtifact'){
+          const token=p.reportConfirmations.find(x=>x.itemKey===item.itemKey).confirmationReceipt;const event=await verifyConfirmation(s,token,record,actor);
+          Object.assign(record,{dashboardState:'unpublished',contentConfirmedAt:event.confirmedAt,contentConfirmedByRef:pointer('_User',event.employeeId),contentConfirmationRef:event.objectId});
+        }
+        row=await s.create(item.className,record);op='created';
+      }
+      counts[op]++;recordRefs.push({className:item.className,objectId:row.objectId,operation:op});
+      await s.create('KfImportItem',{...metadata(job.objectId+':'+item.itemKey,item.data.dataMode),jobRef:pointer('KfImportJob',job.objectId),itemKey:item.itemKey,targetClass:item.className,targetId:row.objectId,businessKey:item.externalKey,payloadHash,dependencies:item.dependsOn||[],status:'succeeded',operation:op,attempts:1});done.add(item.itemKey);
+    }if(!progress)fail('VALIDATION_FAILED','存在循环依赖');}
+    await s.update('KfImportJob',job.objectId,{status:'succeeded',successCount:p.items.length,finishedAt:new Date().toISOString(),summary:JSON.stringify({counts,ownerId:actor.id})});
+    return{jobId:job.objectId,status:'succeeded',counts,recordRefs};
+  });};
+  handlers.ksfGetImportResult=async(p,actor)=>{need(actor,'data:read');const job=await store.get('KfImportJob',required(p.jobId,'jobId'));if(!job)fail('REFERENCE_NOT_FOUND');await accessSource(store,job.inputSourceRef.objectId,actor);
+    const size=pageSize(p.limit);const cursor=readCursor(p.cursor,{jobId:p.jobId});const rows=await store.list('KfImportItem',{where:{jobRef:pointer('KfImportJob',job.objectId)},afterId:cursor?.afterId,limit:size+1});
+    const items=[];for(const row of rows.slice(0,size)){const target=await store.get(row.targetClass,row.targetId);let targetSnapshot;try{await accessRow(store,row.targetClass,target,actor);targetSnapshot=snapshot(row.targetClass,target);}catch{}
+      items.push({itemKey:row.itemKey,status:row.status,targetClass:row.targetClass,targetId:row.targetId,objectId:row.targetId,operation:row.operation,snapshotState:targetSnapshot?'available':'unavailable',...(targetSnapshot?{updatedAt:target.updatedAt,targetSnapshot}:{}),...(row.errorCode?{errorCode:row.errorCode,errorMessage:row.errorMessage}:{})});}
+    let summary;try{summary=JSON.parse(job.summary);}catch{summary={};}
+    return{job:{jobId:job.objectId,status:job.status,counts:summary.counts,expectedCount:job.expectedCount},items,nextCursor:rows.length>size?writeCursor({jobId:p.jobId},{afterId:rows[size-1].objectId}):null};
+  };
+  handlers.ksfRetryImport=async(p,actor)=>{need(actor,'ingest:write');return operation(actor,'retry',p,async s=>{const job=await s.get('KfImportJob',required(p.jobId,'jobId'));if(!job)fail('REFERENCE_NOT_FOUND');await accessSource(s,job.inputSourceRef.objectId,actor);if(job.status!=='succeeded')fail('CAPABILITY_UNAVAILABLE','首版事务入库无部分成功;请重新校验原输入后重试');return{jobId:job.objectId,status:job.status,retryItemKeys:[]};});};
+  function pageSize(size){if(size===undefined)return data.limits.defaultPageSize;if(!Number.isInteger(size)||size<1||size>data.limits.maxPageSize)fail('VALIDATION_FAILED','分页条数超限');return size;}
+  function writeCursor(context,payload){return signedReceipt({type:'cursor',contextHash:hash(canonical(context)),...payload});}
+  function readCursor(token,context){if(!token)return null;const payload=readReceipt(token);if(payload.type!=='cursor'||payload.contextHash!==hash(canonical(context)))fail('VALIDATION_FAILED','游标不匹配查询');return payload;}
+  async function decorateFile(row){if(row.file?.name){const [file]=await store.list('KsfFileObject',{where:{name:row.file.name},limit:1});if(file)row.file={...row.file,url:settings.functionsUrl+'?path='+encodeURIComponent('/ksf/file')+'&fileId='+file.objectId};}return row;}
+  handlers.ksfGetRecord=async(p,actor)=>{need(actor,'data:read');if(!data.tables[p.className]||!p.className.startsWith('Kf')||p.className.startsWith('Ksf'))fail('VALIDATION_FAILED','禁止读取内部表');const id=p.objectId||p.recordId;const row=await accessRow(store,p.className,await store.get(p.className,required(id,'objectId')),actor);await decorateFile(row);return{record:row,...(noiseFields[p.className]?{targetSnapshot:snapshot(p.className,row)}:{})};};
+  handlers.ksfSearchRecords=async(p,actor)=>{need(actor,'data:read');if(!Object.hasOwn(data.tables,p.className)||!p.className.startsWith('Kf')||p.className.startsWith('Ksf'))fail('VALIDATION_FAILED','不允许检索此表');
+    const filters={...(p.filters||{})},size=pageSize(p.limit);for(const k of Object.keys(filters))if(!['reportType','dataMode','platform','projectId','recordState','category','stageCode','dashboardState'].includes(k)||!data.tables[p.className][k])fail('VALIDATION_FAILED','筛选字段不允许');
+    if(p.sort||p.include)fail('CAPABILITY_UNAVAILABLE','首版按 objectId 分页;include 和自定义 sort 暂未开放,请用 ksfGetRecord 读取引用');
+    if(!Object.hasOwn(filters,'recordState'))filters.recordState='active';
+    const context={className:p.className,filters,keyword:p.keyword||null,noiseContext:p.noiseContext||null};const cursor=readCursor(p.cursor,context);
+    const rows=await store.list(p.className,{where:filters,limit:data.limits.maxReadScan+1,search:p.keyword||null});if(rows.length>data.limits.maxReadScan)fail('CAPABILITY_UNAVAILABLE','结果超过首版扫描上限,请缩小筛选范围');
+    const authorized=[];for(const row of rows){try{await accessRow(store,p.className,row,actor);if(row.dataMode==='sample'&&filters.dataMode!=='sample')continue;authorized.push(row);}catch(e){if(e.code!=='FORBIDDEN')throw e;}}
+    let selected=authorized,noiseSummary;
+    if(rawClasses.includes(p.className)||p.className==='KfEvidence'){
+      const nc=checkNoiseContext(p.noiseContext);if(['all','noise'].includes(nc.mode))need(actor,'noise:read');const classified=authorized.map(row=>({row,assessment:latestAssessment(row,nc,p.className)}));
+      if(p.className==='KfEvidence')for(const entry of classified){const row=entry.row;if(row.rawRecordTable&&row.rawRecordId&&noiseFields[row.rawRecordTable]){const origin=await store.get(row.rawRecordTable,row.rawRecordId);if(origin){await accessRow(store,row.rawRecordTable,origin,actor);entry.assessment=latestAssessment(origin,nc,row.rawRecordTable);}}}
+      noiseSummary={rawCount:classified.length,usableCount:classified.filter(x=>x.assessment?.isNoise===false).length,noiseCount:classified.filter(x=>x.assessment?.isNoise===true).length,pendingCount:classified.filter(x=>!x.assessment).length,scope:nc.scope,ruleVersion:nc.ruleVersion,countingBasis:'授权完整筛选范围;按当前正文指纹和同一研究范围规则组合的最新判断'};
+      selected=classified.filter(x=>nc.mode==='all'||(nc.mode==='usable'?x.assessment?.isNoise===false:nc.mode==='noise'?x.assessment?.isNoise===true:!x.assessment)).map(x=>x.row);
+    }
+    selected=selected.filter(x=>!cursor?.afterId||x.objectId>cursor.afterId);
+    return{records:selected.slice(0,size),nextCursor:selected.length>size?writeCursor(context,{afterId:selected[size-1].objectId}):null,asOf:new Date().toISOString(),...(noiseSummary?{noiseSummary}:{})};
+  };
+  handlers.ksfGetDashboardView=async(p,actor)=>{need(actor,'data:read');const classes={reports:'KfArtifact',opportunities:'KfOpportunity',projects:'KfProject',insights:'KfInsight',resources:'KfSource',issues:'KfReviewIssue'};const className=classes[p.view];if(!className)fail('VALIDATION_FAILED','未知看板区域');const filters={...(p.filters||{})};
+    if(p.view==='reports')filters.dashboardState='published';
+    const result=await handlers.ksfSearchRecords({...p,className,filters},actor);const records=result.records;
+    return{cards:records.map(r=>({id:r.dashboardCardId||r.objectId,entityClass:className,entityId:r.objectId,title:r.title||r.name||r.question,summary:r.content?.summary||r.summary||null,status:r.dashboardState||r.status||r.reviewState,version:r.version||null,updatedAt:r.updatedAt,detailRef:{className,objectId:r.objectId}})),nextCursor:result.nextCursor,asOf:result.asOf};
+  };
+  handlers.ksfConfirmReport=async(p,actor)=>{need(actor,'report:ingest');return operation(actor,'confirm-receipt',p,async s=>{
+    const id=p.confirmationEvent?.eventId;const event=await s.get('KsfConfirmationEvent',required(id,'confirmationEvent.eventId'));if(!event||event.action!=='confirm_report')fail('REPORT_NOT_CONFIRMED','确认事件不存在');
+    if(actor.id!==event.ownerId&&actor.id!==event.employeeId&&!actor.scopes.includes('*'))fail('FORBIDDEN');
+    const identity={...p.reportIdentity,projectId:p.reportIdentity?.projectId||null};if(canonical(identity)!==canonical(event.identity))fail('REPORT_CONTENT_CHANGED');
+    const receipt=signedReceipt({type:'report-confirmation',eventId:event.objectId,employeeId:event.employeeId,expiresAt:Date.now()+24*3600*1000});return{confirmationReceipt:receipt,confirmedIdentity:{eventId:event.objectId,confirmedBy:event.employeeId,confirmedAt:event.confirmedAt,...event.identity}};
+  });};
+  handlers.ksfPublishToDashboard=async(p,actor)=>{need(actor,'report:publish');return operation(actor,'publish',p,async s=>{
+    const row=await accessRow(s,'KfArtifact',await s.get('KfArtifact',required(p.artifactId,'artifactId'),{forUpdate:true}),actor);
+    if(row.version!==p.version||row.reportContentHash!==p.reportContentHash)fail('REPORT_CONTENT_CHANGED');
+    if(!row.contentConfirmationRef||!row.contentConfirmedByRef)fail('REPORT_NOT_CONFIRMED');
+    const event=await s.get('KsfConfirmationEvent',row.contentConfirmationRef);if(!event||event.identity.reportContentHash!==row.reportContentHash||event.identity.version!==row.version)fail('REPORT_NOT_CONFIRMED');
+    if(row.dataMode==='sample')fail('REPORT_NOT_READY','样例报告不能发布到正式看板');
+    const publication=row.dashboardState==='published'?row:await s.update('KfArtifact',row.objectId,{dashboardState:'published',dashboardCardId:'report:'+row.objectId,dashboardPublishedAt:new Date().toISOString(),dashboardPublication:{requestId:p.requestId,actorId:actor.id,reportContentHash:p.reportContentHash},...(actor.kind==='employee'?{dashboardPublishedByRef:pointer('_User',actor.id)}:{})});
+    const reread=await s.get('KfArtifact',row.objectId);return{artifactId:row.objectId,publicationStatus:'published',cardId:publication.dashboardCardId,publishedAt:publication.dashboardPublishedAt,readable:reread?.dashboardState==='published'};
+  });};
+  handlers.ksfSetNoiseAssessment=async(p,actor)=>{need(actor,'noise:write');return operation(actor,'noise',p,async s=>{
+    if(!rawClasses.includes(p.className))fail('VALIDATION_FAILED','只能更新平台原始表');const nc=checkNoiseContext({scope:p.noiseScope,ruleVersion:p.noiseRuleVersion});
+    const row=await accessRow(s,p.className,await s.get(p.className,required(p.recordId,'recordId'),{forUpdate:true}),actor);
+    if(!p.expectedUpdatedAt||row.updatedAt!==p.expectedUpdatedAt)fail('VERSION_CONFLICT','原始记录已更新,请重新读取后复核');
+    const d=p.decision;if(!d||typeof d.isNoise!=='boolean')fail('VALIDATION_FAILED','isNoise 必须为 true 或 false');if(d.isNoise&&!d.noiseReason?.trim())fail('NOISE_REASON_REQUIRED');
+    const fingerprint=noiseHash(p.className,row);if(d.contentHash!==fingerprint)fail('NOISE_CONTENT_CHANGED');
+    if(!d.provenance||typeof d.provenance!=='object')fail('VALIDATION_FAILED','provenance 必填');
+    let sourceActor;
+    if(d.method==='manual'){if(actor.kind!=='employee')fail('NOISE_REVIEW_REQUIRED','人工判断必须由员工登录操作');need(actor,'noise:manual');}
+    else if(d.method==='reused'){
+      const from=p.reuseFrom;if(!from)fail('VALIDATION_FAILED','缺少 reuseFrom');
+      if(!rawClasses.includes(from.className)||from.className!==p.className)fail('VALIDATION_FAILED','复用表不匹配');const origin=await accessRow(s,from.className,await s.get(from.className,from.recordId),actor);
+      const old=(origin.noiseHistory||[]).find(x=>x.requestId===from.assessmentRequestId);
+      if(row.idOrigin!=='upstream'||origin.idOrigin!=='upstream'||!row.externalObjectId||origin.externalObjectId!==row.externalObjectId||origin.platform!==row.platform||!old||old.contentHash!==fingerprint||old.scope!==nc.scope||old.ruleVersion!==nc.ruleVersion||old.isNoise!==d.isNoise||noiseHash(from.className,origin)!==fingerprint)fail('NOISE_REVIEW_REQUIRED','源判断不满足跨采集复用条件');
+      sourceActor=old.actor;
+    }else fail('CAPABILITY_UNAVAILABLE','规则或模型判断尚未接通可信执行事件');
+    if(d.method!=='reused'&&p.reuseFrom)fail('VALIDATION_FAILED','reuseFrom 仅用于 reused');
+    const previous=latestAssessment(row,nc,p.className);if(previous?.method==='manual'&&d.method!=='manual')fail('NOISE_REVIEW_REQUIRED','自动复用不能覆盖有效人工判断');
+    const at=new Date().toISOString();const history={requestId:p.requestId,scope:nc.scope,ruleVersion:nc.ruleVersion,contentHash:fingerprint,isNoise:d.isNoise,reason:d.noiseReason||null,method:d.method,assessedAt:at,actor:{id:actor.id,kind:actor.kind},provenance:{...d.provenance,...(p.reuseFrom?{reuseFrom:p.reuseFrom,sourceActor}:{})},...(previous?{supersedesRequestId:previous.requestId}:{})};
+    const updated=await s.update(p.className,row.objectId,{isNoise:d.isNoise,noiseReason:d.noiseReason||null,noiseScope:nc.scope,noiseRuleVersion:nc.ruleVersion,noiseMethod:d.method,noiseAssessedAt:at,noiseContentHash:fingerprint,noiseHistory:[...(row.noiseHistory||[]),history],noiseAssessedByRef:actor.kind==='employee'?pointer('_User',actor.id):null},{expectedUpdatedAt:p.expectedUpdatedAt});
+    const evidence=await s.list('KfEvidence',{where:{rawRecordTable:p.className,rawRecordId:p.recordId},limit:1000});const issues=[];
+    for(const e of evidence){const issue=await s.create('KfReviewIssue',{...metadata('noise:'+p.requestId+':'+e.objectId,'unknown'),question:'原始样本筛选判断已变化,请核对衍生证据及报告统计',status:'open',targetClass:'KfEvidence',targetId:e.objectId,blocking:false,feedbackKind:'noise_assessment_changed',answerSourceRef:row.sourceRef||null});issues.push(issue.objectId);}
+    return{recordRef:{className:p.className,objectId:row.objectId,updatedAt:updated.updatedAt},assessment:{requestId:p.requestId,noiseScope:nc.scope,noiseRuleVersion:nc.ruleVersion,isNoise:d.isNoise,noiseReason:d.noiseReason||null,noiseMethod:d.method,noiseContentHash:fingerprint,noiseAssessedAt:at,...(actor.kind==='employee'?{noiseAssessedByRef:pointer('_User',actor.id)}:{})},affectedReviewIssues:issues};
+  });};
+  handlers.ksfCreateProjectFromOpportunity=async()=>fail('CAPABILITY_UNAVAILABLE','新品立项条件尚未配置,不能自动创建项目');
+  handlers.ksfTransitionProject=async()=>fail('CAPABILITY_UNAVAILABLE','阶段准入条件尚未配置,不能自动推进项目');
+  handlers.ksfApplyReviewFeedback=async(p,actor)=>{need(actor,'feedback:write');if(p.action!=='record_answer')fail('CAPABILITY_UNAVAILABLE','业务字段修订准入尚未配置;可先保存真实答复');return operation(actor,'feedback',p,async s=>{await accessSource(s,required(p.answerSourceId,'answerSourceId'),actor);if(!Array.isArray(p.answers)||!p.answers.length)fail('VALIDATION_FAILED','请提供真实答复');const ids=[];for(const a of p.answers){const question=await s.get('KfQuestionnaireQuestion',a.questionId);if(!question)fail('REFERENCE_NOT_FOUND');required(a.rawAnswer,'rawAnswer');const row=await s.create('KfQuestionnaireAnswer',{...metadata(actor.id+':'+p.requestId+':'+ids.length,'manual'),questionRef:pointer('KfQuestionnaireQuestion',question.objectId),responseBatchKey:required(a.responseBatchKey,'responseBatchKey'),fieldCode:required(a.fieldCode,'fieldCode'),rawAnswer:a.rawAnswer,...Object.fromEntries(['valueNumber','valueText','valueBoolean','valueOptions','unit'].filter(k=>Object.hasOwn(a,k)).map(k=>[k,a[k]])),sourceRef:pointer('KfSource',p.answerSourceId),answeredAt:new Date().toISOString(),applyState:'pending'});ids.push(row.objectId);}return{answerIds:ids,applyResults:[],knowledgeSync:{status:'not_required'}};});};
+  handlers.ksfSyncKnowledge=async(p,actor)=>{need(actor,'data:read');return{status:'not_required',target:{reason:'当前助手直接检索业务数据库;未配置独立知识库'}};};
+  handlers.ksfPrepareFileUpload=async(p,actor)=>{need(actor,'source:write');return files.prepare(p,actor,{operation,required,fail,store,hash});};
+  async function dispatch(name,p,actor){if(!Object.hasOwn(handlers,name))fail('FUNCTION_NOT_FOUND');return envelope(p,await handlers[name](p,actor));}
+  return{dispatch,authenticate,operation,handlers,hash,canonical,noiseHash,need,fail,pointer,metadata,signedReceipt,readReceipt,verifyConfirmation,required,accessSource};
+}
+
+// 与冻结的 r7 客户端一致;此提取结果只用作检索,原 HTML 文件完整保留。
+export function extractReport(html,seriesKey){
+  const rawText=html.replace(/<script[\s\S]*?<\/script>/gi,' ').replace(/<style[\s\S]*?<\/style>/gi,' ').replace(/<[^>]+>/g,' ').replace(/&nbsp;/g,' ').replace(/\s+/g,' ').trim();
+  const headingOf=tag=>{const m=html.match(new RegExp('<'+tag+'[^>]*>([\\s\\S]*?)<\\/'+tag+'>','i'));return m?m[1].replace(/<[^>]+>/g,'').trim():null;};
+  return{title:headingOf('h1')||headingOf('title')||seriesKey,rawText,summary:rawText.slice(0,300),headings:(html.match(/<h[12][^>]*>[\s\S]*?<\/h[12]>/gi)||[]).map(h=>h.replace(/<[^>]+>/g,'').trim()).filter(Boolean)};
+}

+ 60 - 0
server/src/files.mjs

@@ -0,0 +1,60 @@
+export function createFileAdapter({store,crypto,Parse,data,settings}){
+  const digest=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
+  const reject=(code,message)=>{const e=new Error(message||code);e.code=code;throw e;};
+  const secretMatch=(a,b)=>typeof a==='string'&&typeof b==='string'&&a.length===b.length&&crypto.timingSafeEqual(Buffer.from(a),Buffer.from(b));
+  async function retrieve(file){
+    const [row]=await store.list('KsfFileObject',{where:{name:file.name},limit:1});
+    if(!row)reject('UPLOAD_INCOMPLETE','文件对象不存在');
+    const bytes=Buffer.from(row.bytesBase64,'base64');if(bytes.length!==row.sizeBytes||digest(bytes)!==row.sha256)reject('UPLOAD_INCOMPLETE','存储文件的字节或 hash 已损坏');return bytes;
+  }
+  async function saveBytes(s,bytes,fileName,contentType,ownerId){
+    const extension=fileName.match(/\.[A-Za-z0-9]{1,10}$/)?.[0]||'.bin';
+    const row=await s.create('KsfFileObject',{name:'ksf-'+crypto.randomBytes(16).toString('hex')+extension,ownerId,contentType,sizeBytes:bytes.length,sha256:digest(bytes),bytesBase64:bytes.toString('base64')});
+    const reread=await s.get('KsfFileObject',row.objectId);const actual=Buffer.from(reread.bytesBase64,'base64');
+    if(actual.length!==bytes.length||digest(actual)!==row.sha256)reject('UPLOAD_INCOMPLETE','保存后的文件回读校验失败');
+    return{__type:'File',name:row.name,url:settings.functionsUrl+'?path='+encodeURIComponent('/ksf/file')+'&fileId='+row.objectId};
+  }
+  async function readBytes(request){
+    if(Buffer.isBuffer(request.body))return request.body;
+    if(typeof request.body==='string')return Buffer.from(request.body,'utf8');
+    if(request.body&&typeof request.body==='object'&&Object.keys(request.body).length)reject('VALIDATION_FAILED','文件通道要求原始字节,不能发送 JSON 对象');
+    const chunks=[];let size=0;for await(const chunk of request){const buf=Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk);size+=buf.length;if(size>data.limits.maxFileBytes)reject('VALIDATION_FAILED','上传文件过大');chunks.push(buf);}return Buffer.concat(chunks);
+  }
+  return{
+    async prepare(p,actor,{operation,required,fail}){required(p.fileName,'fileName');if(!Number.isInteger(p.sizeBytes)||p.sizeBytes<0||p.sizeBytes>data.limits.maxFileBytes)fail('VALIDATION_FAILED','文件大小超限');
+      if(!/^[a-f0-9]{64}$/.test(p.sha256||''))fail('VALIDATION_FAILED','sha256 无效');
+      if(!['text/html','application/json','text/plain','text/markdown','application/pdf'].includes(p.contentType))fail('VALIDATION_FAILED','暂不支持该文件格式');
+      return operation(actor,'prepare-upload',p,async s=>{const token=crypto.randomBytes(32).toString('base64url'),expiresAt=new Date(Date.now()+3600000).toISOString();
+        const row=await s.create('KsfUploadSession',{ownerId:actor.id,requestId:p.requestId,tokenHash:digest(token),fileName:p.fileName.replace(/[^\p{L}\p{N}._-]/gu,'_').slice(-150),contentType:p.contentType,sizeBytes:p.sizeBytes,sha256:p.sha256,expiresAt,state:'prepared'});
+        return{upload:{mode:'ksf-postgres-file',method:'POST',url:settings.functionsUrl+'?path='+encodeURIComponent('/ksf/upload')+'&uploadId='+row.objectId,headers:{'X-Ksf-Upload-Token':token,'Content-Type':'application/octet-stream'},expiresAt,receiptRequirements:{sizeBytes:p.sizeBytes,sha256:p.sha256}},limits:{maxBytes:data.limits.maxFileBytes}};
+      });
+    },
+    async upload(request){
+      const id=request.query?.uploadId;const token=request.headers?.['x-ksf-upload-token'];if(!id||typeof token!=='string')reject('AUTH_REQUIRED','缺少上传凭证');
+      const initial=await store.get('KsfUploadSession',id);if(!initial||!secretMatch(initial.tokenHash,digest(token))||Date.parse(initial.expiresAt)<=Date.now())reject('AUTH_REQUIRED','上传凭证无效或已过期');
+      const bytes=await readBytes(request);if(bytes.length!==initial.sizeBytes||digest(bytes)!==initial.sha256)reject('UPLOAD_INCOMPLETE','文件字节数或 hash 与准备信息不一致');
+      return store.transaction(async s=>{const row=await s.get('KsfUploadSession',id,{forUpdate:true});if(row.state==='uploaded')return{ok:true,...row.fileReceipt,sizeBytes:row.sizeBytes};
+        const file=await saveBytes(s,bytes,row.fileName,row.contentType,row.ownerId);
+        const fileReceipt={fileId:row.objectId,file,sha256:row.sha256,sizeBytes:row.sizeBytes};await s.update('KsfUploadSession',id,{state:'uploaded',fileReceipt});return{ok:true,...fileReceipt};
+      });
+    },
+    async verify(s,receipt,actor){
+      let id=receipt.fileId;
+      if(!id&&receipt.url){let u;try{u=new URL(receipt.url);}catch{reject('UPLOAD_INCOMPLETE','文件回执 URL 无效');}if(u.origin!==new URL(settings.functionsUrl).origin||u.pathname!==new URL(settings.functionsUrl).pathname||u.searchParams.get('path')!=='/ksf/upload')reject('UPLOAD_INCOMPLETE','不是当前文件通道回执');id=u.searchParams.get('uploadId');}
+      if(!id)reject('UPLOAD_INCOMPLETE','缺少可回查的文件传输编号');
+      const row=await s.get('KsfUploadSession',id);if(!row||row.ownerId!==actor.id||row.state!=='uploaded'||!row.fileReceipt?.file)reject('UPLOAD_INCOMPLETE','文件上传未完成或不属于当前身份');
+      if(receipt.sha256!==row.sha256||(receipt.bytes??receipt.sizeBytes)!==row.sizeBytes)reject('UPLOAD_INCOMPLETE','文件回执与服务端记录不一致');
+      const bytes=await retrieve(row.fileReceipt.file);if(digest(bytes)!==row.sha256||bytes.length!==row.sizeBytes)reject('UPLOAD_INCOMPLETE','文件已变化');
+      return row.fileReceipt;
+    },
+    async createPreviewFile(bytes,fileName,ownerId,s=store){if(bytes.length>data.limits.maxFileBytes)reject('VALIDATION_FAILED','预览文件过大');return saveBytes(s,bytes,fileName,'text/html',ownerId);},
+    async readFile(request,actor){
+      if(!actor)reject('AUTH_REQUIRED','文件下载需要登录');
+      if(!actor.scopes.includes('*')&&!actor.scopes.includes('data:read')&&!actor.scopes.includes('report:confirm'))reject('FORBIDDEN');
+      const row=await store.get('KsfFileObject',request.query?.fileId);if(!row)reject('REFERENCE_NOT_FOUND');
+      if(row.ownerId!==actor.id&&actor.kind!=='employee'&&!actor.scopes.includes('*')&&!actor.scopes.includes('data:read-all'))reject('FORBIDDEN');
+      return{bytes:await retrieve({name:row.name}),contentType:row.contentType,name:row.name};
+    },
+    retrieve,
+  };
+}

+ 61 - 0
server/src/member-groups.mjs

@@ -0,0 +1,61 @@
+import { indexBoardMembers, seedMemberGroups, changeMemberGroups, MEMBER_GROUP_SCHEMA } from '../../src/core/member-groups.js';
+
+/** Read exactly the existing public projection, including deployed employee identity overlays. */
+export async function readMemberGroupBoard({ url, appId, fetchImpl = fetch }) {
+  const raw = { session: [], artifact: [], project: [], opportunity: [] };
+  let cursor;
+  for (let page = 0; page < 500; page++) {
+    const response = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': appId },
+      body: JSON.stringify({ path: '/ksfGetPublicBoard', params: { limit: 500, ...(cursor ? { cursor } : {}) } }), signal: AbortSignal.timeout(30000) });
+    const result = (await response.json())?.result;
+    if (!response.ok || !result?.ok || !result.data) throw Object.assign(new Error('成员读取失败,请刷新后重试'), { code: 'MEMBER_READ_FAILED' });
+    for (const key of Object.keys(raw)) {
+      if (!Array.isArray(result.data[key])) throw Object.assign(new Error('成员数据不完整'), { code: 'MEMBER_READ_FAILED' });
+      raw[key].push(...result.data[key]);
+    }
+    cursor = result.data.nextCursor;
+    if (!cursor) return raw;
+  }
+  throw Object.assign(new Error('成员分页超限'), { code: 'MEMBER_READ_FAILED' });
+}
+
+/** Shared presentation groups only. Never mutate workshop groups or access grants. */
+export function attachMemberGroupHandlers(app, { store, loadBoard }) {
+  const { fail } = app, configKey = 'public-board-member-groups';
+  const actor = { id: 'public-board', kind: 'anonymous', scopes: [], projectIds: [] };
+  const current = (row, members) => {
+    if (!row) return seedMemberGroups(members);
+    const state = row.value;
+    if (state?.schema !== MEMBER_GROUP_SCHEMA || !Number.isSafeInteger(state.revision) || state.revision < 1 || !Array.isArray(state.groups) ||
+      !state.assignments || typeof state.assignments !== 'object' || Array.isArray(state.assignments)) fail('GROUP_CONFIG_INVALID', '分组数据异常,请联系维护人');
+    return structuredClone(state);
+  };
+  const read = async (s, members, lock = false) => {
+    const rows = await s.list('KsfRuntimeConfig', { where: { key: configKey }, limit: 2, forUpdate: lock });
+    if (rows.length > 1) fail('GROUP_CONFIG_INVALID', '分组配置重复,请联系维护人');
+    return { row: rows[0], state: current(rows[0], members) };
+  };
+  app.handlers.ksfGetPublicMemberGroups = async (p) => {
+    if (Object.keys(p).some((key) => key !== 'requestId')) fail('VALIDATION_FAILED', '查询包含未允许字段');
+    const members = indexBoardMembers(await loadBoard()).members;
+    const { state } = await read(store, members);
+    return { ...state, readOnly: true, memberCount: members.length };
+  };
+  app.handlers.ksfManagePublicMemberGroups = async (p) => {
+    const fields = { create: ['name'], rename: ['groupId', 'name'], delete: ['groupId'], assign: ['groupId', 'memberIds'] };
+    if (!Object.hasOwn(fields, p.action) || Object.keys(p).some((key) => !['requestId', 'expectedRevision', 'action', ...fields[p.action]].includes(key))) fail('VALIDATION_FAILED', '操作包含未允许字段');
+    if (!Number.isSafeInteger(p.expectedRevision) || p.expectedRevision < 0) fail('VALIDATION_FAILED', '缺少有效分组版本,请刷新');
+    const members = indexBoardMembers(await loadBoard()).members;
+    const allowed = new Set(members.map((m) => m.id));
+    return app.operation(actor, 'public-member-groups', p, async (s) => {
+      await s.lock('ksf:public-member-groups');
+      const { row, state } = await read(s, members, true);
+      if (state.revision !== p.expectedRevision) fail('VERSION_CONFLICT', '分组已被其他人更新,请刷新后再操作');
+      const next = changeMemberGroups(state, p, allowed, 'group:' + app.hash(p.requestId).slice(0,24));
+      next.updatedAt = new Date().toISOString();
+      if (row) await s.update('KsfRuntimeConfig', row.objectId, { value: next });
+      else await s.create('KsfRuntimeConfig', { key: configKey, value: next });
+      return { ...next, readOnly: false, groupingOnly: true, businessRecordsChanged: 0 };
+    });
+  };
+}

+ 62 - 0
server/src/postgres.mjs

@@ -0,0 +1,62 @@
+export function createPostgresStore(pg, definitions, crypto) {
+  const checkTable = name => { if (!Object.hasOwn(definitions,name)) throw new Error('TABLE_NOT_ALLOWED'); return name; };
+  const checkField = (name,key) => { checkTable(name); if (!Object.hasOwn(definitions[name],key)) throw new Error('FIELD_NOT_ALLOWED'); return key; };
+  const decode = (name,row) => {
+    if (!row) return null;
+    const out={};
+    for (const [k,v] of Object.entries(row)) {
+      if (k.startsWith('_') || k==='ACL' || !Object.hasOwn(definitions[name],k)) continue;
+      const type=definitions[name][k];
+      out[k]=v instanceof Date?v.toISOString():v;
+      if(v!=null&&type.type==='Pointer')out[k]={__type:'Pointer',className:type.targetClass,objectId:v};
+      if(v!=null&&type.type==='File')out[k]={__type:'File',name:v};
+    }
+    return out;
+  };
+  function encode(name,key,value){
+    const {type}=definitions[name][checkField(name,key)];
+    if(value===null)return{value:null,cast:''};
+    if(type==='Pointer')return{value:value.objectId,cast:''};
+    if(type==='File')return{value:value.name,cast:''};
+    if(type==='Date')return{value:value instanceof Date?value.toISOString():(value?.iso||value),cast:'::timestamptz'};
+    if(type==='Object'||type==='Array')return{value:JSON.stringify(value),cast:'::jsonb'};
+    return{value,cast:''};
+  }
+  function make(connection){
+    const store={
+      async transaction(fn){return connection.tx(t=>fn(make(t)));},
+      async lock(key){await connection.one('SELECT pg_advisory_xact_lock(hashtextextended($1,0))',[key]);},
+      async get(name,id,{forUpdate=false}={}){
+        checkTable(name);const row=await connection.oneOrNone('SELECT * FROM $1:name WHERE "objectId"=$2'+(forUpdate?' FOR UPDATE':''),[name,id]);return decode(name,row);
+      },
+      async list(name,{where={},afterId=null,limit=200,search=null,forUpdate=false}={}){
+        checkTable(name);const values=[name],conditions=[];
+        for(const[k,v]of Object.entries(where)){checkField(name,k);values.push(k);const key='$'+values.length+':name';
+          if(v===null)conditions.push(key+' IS NULL');else{const e=encode(name,k,v);values.push(e.value);conditions.push(key+'=$'+values.length+e.cast);}}
+        if(afterId){values.push(afterId);conditions.push('"objectId">$'+values.length);}
+        if(search){const fields=['searchText','title','name','rawText','content','text'].filter(k=>definitions[name][k]?.type==='String');
+          if(fields.length){values.push('%'+search.replace(/[\\%_]/g,'\\$&')+'%');const val='$'+values.length;conditions.push('('+fields.map(k=>{values.push(k);return'$'+values.length+':name ILIKE '+val;}).join(' OR ')+')');}}
+        values.push(limit);const sql='SELECT * FROM $1:name'+(conditions.length?' WHERE '+conditions.join(' AND '):'')+' ORDER BY "objectId" LIMIT $'+values.length+(forUpdate?' FOR UPDATE':'');
+        return(await connection.any(sql,values)).map(r=>decode(name,r));
+      },
+      async count(name,where={}){return(await store.list(name,{where,limit:10001})).length;},
+      async create(name,data){
+        checkTable(name);const now=new Date().toISOString();const item={...data,objectId:crypto.randomBytes(9).toString('base64url').slice(0,10),createdAt:now,updatedAt:now};
+        const values=[name],columns=[],placeholders=[];
+        for(const[k,v]of Object.entries(item)){if(v===undefined)continue;const e=encode(name,k,v);values.push(k);columns.push('$'+values.length+':name');values.push(e.value);placeholders.push('$'+values.length+e.cast);}
+        // Objects are private by default; read and write access goes through authenticated functions.
+        columns.push('"_rperm"','"_wperm"');placeholders.push("ARRAY[]::text[]","ARRAY[]::text[]");
+        const row=await connection.one('INSERT INTO $1:name ('+columns.join(',')+') VALUES ('+placeholders.join(',')+') RETURNING *',values);return decode(name,row);
+      },
+      async update(name,id,changes,{expectedUpdatedAt}={}){
+        checkTable(name);const data={...changes,updatedAt:new Date().toISOString()};const values=[name,id],sets=[];
+        for(const[k,v]of Object.entries(data)){if(v===undefined)continue;const e=encode(name,k,v);values.push(k);const key='$'+values.length+':name';values.push(e.value);const value='$'+values.length+e.cast;sets.push(key+'='+(k==='updatedAt'?'GREATEST('+value+',"updatedAt" + interval \'1 millisecond\')':value));}
+        let condition='"objectId"=$2';if(expectedUpdatedAt){values.push(expectedUpdatedAt);condition+=' AND "updatedAt"=$'+values.length+'::timestamptz';}
+        const row=await connection.oneOrNone('UPDATE $1:name SET '+sets.join(',')+' WHERE '+condition+' RETURNING *',values);
+        if(!row){const e=new Error('VERSION_CONFLICT');e.code='VERSION_CONFLICT';throw e;}return decode(name,row);
+      },
+      async remove(name,id){checkTable(name);await connection.none('DELETE FROM $1:name WHERE "objectId"=$2',[name,id]);},
+    };return store;
+  }
+  return make(pg);
+}

+ 223 - 0
server/src/workshop.mjs

@@ -0,0 +1,223 @@
+// Classroom work is visible without login; formal report confirmation/publication remains separate.
+export function attachWorkshopHandlers(app,{store,data}) {
+  const {fail,required,need,pointer,metadata,hash,canonical}=app;
+  const stages=['S1','S2','S3','S4','S5'];
+  const stageNames=['品类市场分析','用户需求与痛点','机会点识别','机会验证评估','场景与卖点设计'];
+  const label=code=>code+' '+stageNames[stages.indexOf(code)];
+  const object=x=>x&&typeof x==='object'&&!Array.isArray(x);
+  const exact=(p,keys)=>{if(!object(p)||Object.keys(p).some(k=>!keys.includes(k)))fail('VALIDATION_FAILED','存在未允许字段');};
+  const stage=code=>{if(!stages.includes(code))fail('VALIDATION_FAILED','stageCode 必须是 S1–S5');return code;};
+  const version=v=>{if(!Number.isInteger(v)||v<1)fail('VALIDATION_FAILED','version 必须为正整数');return v;};
+  const mode=v=>{if(!data.reportRules.dataModes.includes(v))fail('VALIDATION_FAILED','dataMode 无效');return v;};
+  const sampleTitle=(text,m)=>m==='sample'?'【样例】'+text.replace(/^【样例】/,''):text;
+  const now=()=>new Date().toISOString();
+  const short=(value,key)=>{required(value,key);if(value.length>200)fail('VALIDATION_FAILED',key+' 超长');return value;};
+  function account(value,kind){
+    if(value===undefined)return undefined;
+    const scoped=kind==='participant'&&object(value)&&(Object.hasOwn(value,'identifierType')||Object.hasOwn(value,'scopeId'));
+    const keys=kind==='agent'?['platform','tenantId','accountId','accountName','runtimeAgentId','wecomAppAgentId']:['platform','tenantId','accountId','accountName','employeeNumber',...(scoped?['identifierType','scopeId']:[])];
+    exact(value,keys);
+    if(typeof value.platform!=='string'||!value.platform.trim())fail('VALIDATION_FAILED','账号必须声明 platform');
+    for(const [key,text]of Object.entries(value))if(text!==null&&(typeof text!=='string'||!text.trim()||text.length>200||/[\u0000-\u001f]/.test(text)))fail('VALIDATION_FAILED','账号标识格式无效:'+key);
+    if(scoped&&(value.platform!=='wecom'||value.identifierType!=='wecom_aibot_sender'||!value.scopeId||!value.accountId))fail('VALIDATION_FAILED','缺少智能机器人发送者标识或机器人作用域');
+    return Object.fromEntries(keys.map(key=>[key,value[key]??null]));
+  }
+  const own=(row,actor)=>{if(!row||row.meta?.ownerId!==actor.id&&!actor.scopes.includes('*'))fail('FORBIDDEN','记录不属于当前服务身份');return row;};
+  async function session(s,id,actor){return own(await s.get('KfSession',required(id,'sessionId')),actor);}
+  function attribution(row){const a=row.meta.attribution;return{ownerName:a.participantName,uploadedByKey:a.participantKey,agentName:a.agentName,agentId:a.agentKey,ownerSession:row.objectId,uploadMethod:'session',workshopKey:row.workshopKey,groupId:row.groupId||'',sessionRef:pointer('KfSession',row.objectId)};}
+  async function project(s,id,ctx,actor){
+    if(!id)return null;
+    if(!actor.projectIds.includes('*')&&!actor.projectIds.includes(id))fail('FORBIDDEN','无项目授权');
+    const p=own(await s.get('KfProject',id),actor);
+    if(p.workshopKey!==ctx.workshopKey||p.dataMode!==ctx.dataMode)fail('FORBIDDEN','工作坊或数据模式不匹配');
+    if(p.meta.participantKey!==ctx.meta.attribution.participantKey&&(!p.groupId||p.groupId!==ctx.groupId))fail('FORBIDDEN','不属于该学员或小组');
+    return p;
+  }
+  async function event(s,p,ctx,type,toStage,artifactId,note){
+    const seq=await s.list('KfStageEvent',{where:{projectRef:pointer('KfProject',p.objectId)},limit:10001});
+    if(seq.length>10000)fail('CAPABILITY_UNAVAILABLE','项目事件数量超限');
+    return s.create('KfStageEvent',{...metadata('workshop:event:'+p.objectId+':'+(seq.length+1),ctx.dataMode),projectRef:pointer('KfProject',p.objectId),sessionRef:pointer('KfSession',ctx.objectId),fromStage:p.stageCode||null,toStage,eventType:type,...(artifactId?{artifactRef:pointer('KfArtifact',artifactId)}:{}),occurredAt:now(),sequence:seq.length+1,note:note||type});
+  }
+  async function touch(s,ctx,code,step){return s.update('KfSession',ctx.objectId,{stageCode:code,stage:label(code),step,lastActiveAt:now()});}
+  const contract=app.handlers.ksfGetIngestContract;
+  app.handlers.ksfGetIngestContract=async(p,actor)=>{const r=await contract(p,actor);return{...r,capabilities:{...r.capabilities,publicDashboard:true,workshopWorkflow:true,workshopDrafts:true,workshopProjectTransition:true},workshop:{version:'ksf.workshop.v1',identitySchema:'ksf.agent-self.v1',channelIdentitySchema:'ksf.wecom-aibot-sender.v1',publicReadFunction:'ksfGetPublicBoard',formalReportConfirmationUnchanged:true}};};
+
+  app.handlers.ksfStartWorkshopSession=async(p,actor)=>{
+    need(actor,'ingest:write');exact(p,['requestId','workshopKey','participant','agent','conversation','groupId','dataMode']);
+    exact(p.participant,['key','name','identityBasis','userId','account']);exact(p.agent,['key','name','identityBasis','account','role','runtimePlatform','channel','collectedAt']);exact(p.conversation,['key','channel','messageCount']);
+    short(p.workshopKey,'workshopKey');short(p.participant.key,'participant.key');short(p.participant.name,'participant.name');short(p.agent.name,'agent.name');if(p.agent.key!==undefined)short(p.agent.key,'agent.key');short(p.conversation.key,'conversation.key');short(p.conversation.channel,'conversation.channel');mode(p.dataMode);
+    if(!['channel_context','participant_registration','existing_record'].includes(p.participant.identityBasis))fail('VALIDATION_FAILED','必须记录身份信息来源');
+    const participantAccount=account(p.participant.account,'participant'),agentAccount=account(p.agent.account,'agent');
+    if(participantAccount?.identifierType==='wecom_aibot_sender'&&(p.participant.identityBasis!=='channel_context'||participantAccount.scopeId!==agentAccount?.accountId||agentAccount?.platform!=='wecom'||p.conversation.channel!=='wecom'||p.participant.userId))fail('IDENTITY_CONFLICT','智能机器人发送者与执行机器人作用域不匹配');
+    const agentBasis=p.agent.identityBasis||(p.agent.key?'channel_context':'name_only');
+    if(!['runtime_config','channel_context','operator_registration','name_only'].includes(agentBasis))fail('VALIDATION_FAILED','Agent 身份来源无效');
+    if(agentBasis!=='name_only')short(p.agent.key,'agent.key');
+    if(p.agent.role!==undefined&&p.agent.role!=='workshop')fail('VALIDATION_FAILED','运维身份不能执行工作坊写入');
+    for(const key of ['runtimePlatform','channel'])if(p.agent[key]!=null)short(p.agent[key],'agent.'+key);
+    if(p.agent.channel!=null&&p.agent.channel!==p.conversation.channel)fail('IDENTITY_CONFLICT','Agent 渠道与当前对话不一致');
+    if(p.agent.collectedAt!==undefined&&(typeof p.agent.collectedAt!=='string'||!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(p.agent.collectedAt)||!Number.isFinite(Date.parse(p.agent.collectedAt))))fail('VALIDATION_FAILED','Agent 身份采集时间无效');
+    return app.operation(actor,'workshop-session',p,async s=>{
+      const key='workshop:session:'+hash(canonical([actor.id,p.workshopKey,p.participant.key,p.agent.key||p.agent.name,p.conversation.key]));
+      await s.lock(key);const [prior]=await s.list('KfSession',{where:{externalKey:key},limit:1});
+      if(prior){
+        const priorIdentity=prior.meta.attribution;
+        if(prior.studentName!==p.participant.name||prior.dataMode!==p.dataMode||prior.groupId!==(p.groupId||''))fail('IDEMPOTENCY_CONFLICT','会话已有不同姓名、小组或模式');
+        if(priorIdentity.identityBasis!==p.participant.identityBasis||priorIdentity.agentName!==p.agent.name||priorIdentity.agentKey!==(p.agent.key||'name:'+p.agent.name)||priorIdentity.agentKeyOrigin!==agentBasis||priorIdentity.channel!==p.conversation.channel||(prior.userRef?.objectId??null)!==(p.participant.userId??null)||canonical(priorIdentity.participantAccount??null)!==canonical(participantAccount??null)||canonical(priorIdentity.agentAccount??null)!==canonical(agentAccount??null))fail('IDENTITY_CONFLICT','会话已绑定其他身份资料,不能静默覆盖');
+        if((priorIdentity.agentRole??null)!==(p.agent.role??null)||(priorIdentity.agentRuntimePlatform??null)!==(p.agent.runtimePlatform??null)||(priorIdentity.agentChannel??null)!==(p.agent.channel??null))fail('IDENTITY_CONFLICT','会话已绑定其他 Agent 执行环境');
+        return{sessionId:prior.objectId,participantKey:priorIdentity.participantKey,agentName:priorIdentity.agentName,reused:true};
+      }
+      if(p.participant.userId&&!await s.get('_User',p.participant.userId))fail('REFERENCE_NOT_FOUND','实际账号不存在;不能创建模拟员工');
+      if(p.conversation.messageCount!==undefined&&(!Number.isInteger(p.conversation.messageCount)||p.conversation.messageCount<0))fail('VALIDATION_FAILED','messageCount 必须为实际消息计数');
+      const row=await s.create('KfSession',{...metadata(key,p.dataMode),studentName:p.participant.name,groupId:p.groupId||'',workshopKey:p.workshopKey,stageCode:'S1',stage:label('S1'),step:'started',lastActiveAt:now(),...(p.conversation.messageCount!==undefined?{msgCount:p.conversation.messageCount}:{}),...(p.participant.userId?{userRef:pointer('_User',p.participant.userId)}:{}),meta:{ownerId:actor.id,workshop:{visibility:'public'},attribution:{participantName:p.participant.name,participantKey:'participant:'+hash(canonical([p.workshopKey,p.participant.key])),identityBasis:p.participant.identityBasis,...(participantAccount?{participantAccount}:{}),agentName:p.agent.name,agentKey:p.agent.key||'name:'+p.agent.name,agentKeyOrigin:agentBasis,...(agentAccount?{agentAccount}:{}),agentRole:p.agent.role??null,agentRuntimePlatform:p.agent.runtimePlatform??null,agentChannel:p.agent.channel??null,agentCollectedAt:p.agent.collectedAt??null,agentAccountVerified:false,conversationKey:p.conversation.key,channel:p.conversation.channel}}});
+      return{sessionId:row.objectId,participantKey:row.meta.attribution.participantKey,agentName:p.agent.name,reused:false};
+    });
+  };
+
+  function reportOf(p,ctx){
+    if(!object(p.report)||!Array.isArray(p.report.chapters)||!Array.isArray(p.report.executive))fail('VALIDATION_FAILED','需要 executive 和 chapters 结构化报告');
+    if(p.report.chapters.length<1||p.report.chapters.length>30)fail('VALIDATION_FAILED','报告章节数量无效');
+    const r=JSON.parse(JSON.stringify(p.report));
+    for(const ch of r.chapters){if(typeof ch.title!=='string'||!Array.isArray(ch.sections))fail('VALIDATION_FAILED','章节必须有标题及 sections');}
+    if(JSON.stringify(r).length>500000)fail('VALIDATION_FAILED','报告内容过大');
+    r.title=sampleTitle(p.title,ctx.dataMode);r.generatedAt=now();
+    const warning=ctx.dataMode==='sample'?'技术样例,无真实消费者研究依据;课堂展示,未经员工正式确认。':'课堂过程成果,未经员工正式确认;待验证项不作为市场结论。';
+    r.executive=[warning,...r.executive.map(String)];return r;
+  }
+  app.handlers.ksfSaveWorkshopTask=async(p,actor)=>{
+    need(actor,'ingest:write');exact(p,['requestId','sessionId','projectId','taskKey','version','stageCode','title','artifactType','category','report','rawText','sourceIds','pendingQuestions','opportunity']);
+    stage(p.stageCode);version(p.version);short(p.taskKey,'taskKey');short(p.title,'title');
+    if(!['category_report','competitor_report','voc_report','pain_list','concept','opportunity_card','assessment_report'].includes(p.artifactType))fail('VALIDATION_FAILED','artifactType 无效');
+    if(!Array.isArray(p.sourceIds)||p.sourceIds.length>100||!Array.isArray(p.pendingQuestions)||p.pendingQuestions.some(x=>typeof x!=='string'))fail('VALIDATION_FAILED','sourceIds / pendingQuestions 必须显式提供数组');
+    return app.operation(actor,'workshop-task',p,async s=>{
+      const ctx=await session(s,p.sessionId,actor);let proj=null;
+      if(p.projectId){await s.lock('workshop-project:'+p.projectId);proj=await project(s,p.projectId,ctx,actor);}
+      if(proj&&p.stageCode!==proj.stageCode)fail('STAGE_REQUIREMENTS_NOT_MET','成果阶段必须与项目当前阶段一致');
+      if(typeof p.rawText!=='string'||!p.rawText.trim()||p.rawText.length>500000)fail('VALIDATION_FAILED','rawText 必须为非空正文,最多 500000 字符');const raw=p.rawText;
+      const source=await s.create('KfSource',{...metadata('workshop:source:'+actor.id+':'+p.requestId,ctx.dataMode),title:sampleTitle(p.title,ctx.dataMode),sourceKind:'dataset',rawText:raw,contentHash:hash(raw),meta:{ownerId:actor.id,attribution:ctx.meta.attribution,workshop:{visibility:'public',sessionId:ctx.objectId,role:'conversation_output'}}});
+      for(const id of p.sourceIds){const source=await app.accessSource(s,id,actor);if(source.dataMode==='sample'&&ctx.dataMode!=='sample')fail('VALIDATION_FAILED','真实成果不得引用样例作为真实依据');}
+      const r=reportOf(p,ctx),a=attribution(ctx),key='workshop:artifact:'+hash(canonical([actor.id,ctx.workshopKey,ctx.meta.attribution.participantKey,p.taskKey,p.version]));
+      const verifiedQuotes=[];
+      for(const entry of Object.values(r.evidence||{})){
+        if(!p.sourceIds.includes(entry.sourceId))fail('VALIDATION_FAILED','报告证据必须绑定 sourceIds 中的来源');
+        const origin=await app.accessSource(s,entry.sourceId,actor);
+        for(const quote of entry.quotes||[]){if(typeof quote.text!=='string'||!origin.rawText?.includes(quote.text))fail('VALIDATION_FAILED','引文必须逐字存在于已保存的原文中');verifiedQuotes.push({text:quote.text,ref:quote.ref||origin.sourceUrl||origin.title,sourceId:origin.objectId});}
+      }
+      await s.lock(key);if((await s.list('KfArtifact',{where:{externalKey:key},limit:1})).length)fail('IDEMPOTENCY_CONFLICT','同一任务版本已存在;重试沿用 requestId,修改内容则升级 version');
+      const artifact=await s.create('KfArtifact',{...metadata(key,ctx.dataMode),...a,title:r.title,artifactType:p.artifactType,reportType:'workshop_auxiliary',artifactSubtype:'classroom_task',reportSeriesKey:p.taskKey,version:p.version,stageCode:p.stageCode,stage:label(p.stageCode),tags:[p.category||'',ctx.dataMode==='sample'?'样例':'课堂过程成果'].filter(Boolean),sourceRef:pointer('KfSource',source.objectId),...(proj?{projectRef:pointer('KfProject',proj.objectId),projectId:proj.objectId}:{}),rawText:raw,contentSchema:'ksf.workshop-report.v1',content:{summary:r.executive.join('\n'),headings:r.chapters.map(x=>x.title)},report:r,reportContentHash:hash(canonical(p.report)),evidenceLevel:'none',sources:[],uploadedAt:now(),uploadedBy:ctx.studentName,dashboardState:'workshop',meta:{ownerId:actor.id,attribution:ctx.meta.attribution,workshop:{visibility:'public',sourceIds:p.sourceIds,pendingQuestions:p.pendingQuestions,formalConfirmation:false}}});
+      if(verifiedQuotes.length&&ctx.dataMode!=='sample')await s.update('KfArtifact',artifact.objectId,{sources:verifiedQuotes,evidenceLevel:'supported',evidenceCount:verifiedQuotes.length});
+      let opportunityId=null;
+      if(p.opportunity){
+        exact(p.opportunity,['key','title','demandSummary','targetAudience','usageScenario','fusionDefinition']);
+        for(const key of ['key','title','demandSummary','targetAudience','usageScenario','fusionDefinition'])short(p.opportunity[key],'opportunity.'+key);
+        const okey='workshop:opportunity:'+hash(canonical([actor.id,ctx.workshopKey,ctx.meta.attribution.participantKey,p.opportunity.key,p.version]));await s.lock(okey);
+        if((await s.list('KfOpportunity',{where:{externalKey:okey},limit:1})).length)fail('IDEMPOTENCY_CONFLICT','机会卡版本已存在');
+        const o=await s.create('KfOpportunity',{...metadata(okey,ctx.dataMode),...Object.fromEntries(['title','demandSummary','targetAudience','usageScenario','fusionDefinition'].map(k=>[k,p.opportunity[k]])),title:sampleTitle(p.opportunity.title,ctx.dataMode),claimKind:'hypothesis',version:p.version,opportunitySeriesKey:p.opportunity.key,stage:label(p.stageCode),status:'candidate',category:p.category||'',oneLiner:p.opportunity.demandSummary,evidenceLevel:'none',quoteCount:0,citeCount:0,uploadedBy:ctx.studentName,uploadedByKey:a.uploadedByKey,agentName:a.agentName,agentId:a.agentId,sessionRef:a.sessionRef,sourceSession:ctx.objectId,uploadMethod:'session',uploadedAt:now(),linkedArtifacts:[artifact.objectId],linkedProject:proj?.objectId||''});opportunityId=o.objectId;
+      }
+      if(proj){await event(s,proj,ctx,'task_submitted',proj.stageCode,artifact.objectId,p.title);await s.update('KfProject',proj.objectId,{lastActiveAt:now(),artifactCount:(proj.artifactCount||0)+1});}
+      await touch(s,ctx,p.stageCode,'submitted');
+      return{sessionId:ctx.objectId,artifactId:artifact.objectId,sourceId:source.objectId,opportunityId,projectId:proj?.objectId||null,version:p.version,stageCode:p.stageCode,dataMode:ctx.dataMode,boardVisible:true,formalReportPublished:false,employeeConfirmed:false};
+    });
+  };
+
+  app.handlers.ksfCreateProjectFromOpportunity=async(p,actor)=>{
+    need(actor,'ingest:write');exact(p,['requestId','sessionId','opportunityId','projectKey','title']);short(p.projectKey,'projectKey');short(p.title,'title');
+    return app.operation(actor,'workshop-project',p,async s=>{
+      const ctx=await session(s,p.sessionId,actor),opp=await s.get('KfOpportunity',required(p.opportunityId,'opportunityId'));
+      if(!opp)fail('REFERENCE_NOT_FOUND','机会卡不存在');
+      if(opp.dataMode!==ctx.dataMode)fail('VALIDATION_FAILED','机会与项目数据模式不一致;历史卡须先明确来源和模式');
+      if(opp.sessionRef){const origin=await session(s,opp.sessionRef.objectId,actor);if(origin.workshopKey!==ctx.workshopKey)fail('FORBIDDEN','机会不属于当前工作坊');}
+      else fail('VALIDATION_FAILED','请先建立带工作坊归属的机会卡');
+      if(opp.status==='rejected')fail('VALIDATION_FAILED','已否决机会不能立项');
+      const key='workshop:project:'+hash(canonical([actor.id,ctx.workshopKey,ctx.groupId||ctx.meta.attribution.participantKey,p.projectKey]));await s.lock(key);
+      if((await s.list('KfProject',{where:{externalKey:key},limit:1})).length)fail('IDEMPOTENCY_CONFLICT','项目键已存在;请使用原编号');
+      const row=await s.create('KfProject',{...metadata(key,ctx.dataMode),...attribution(ctx),title:sampleTitle(p.title,ctx.dataMode),opportunityRef:pointer('KfOpportunity',opp.objectId),opportunityId:opp.objectId,stageCode:'S3',stage:label('S3'),progressState:'in_progress',status:'workshop',category:opp.category||'',targetUser:opp.targetAudience,scenario:opp.usageScenario,summary:opp.demandSummary,artifactCount:0,lastActiveAt:now(),meta:{ownerId:actor.id,participantKey:ctx.meta.attribution.participantKey,attribution:ctx.meta.attribution,workshop:{visibility:'public'},projectKey:p.projectKey}});
+      await s.update('KfOpportunity',opp.objectId,{...(!opp.linkedProject?{linkedProject:row.objectId}:{}),status:'approved'});
+      const ev=await event(s,{...row,stageCode:null},ctx,'project_created','S3',null,'课堂项目已建立;不代表企业正式立项审批');await touch(s,ctx,'S3','project_created');
+      return{projectId:row.objectId,opportunityId:opp.objectId,eventId:ev.objectId,stageCode:'S3',dataMode:ctx.dataMode,enterpriseApproval:false};
+    });
+  };
+
+  app.handlers.ksfTransitionProject=async(p,actor)=>{
+    need(actor,'ingest:write');exact(p,['requestId','sessionId','projectId','toStage','state','note']);stage(p.toStage);required(p.note,'note');
+    if(!['in_progress','blocked','completed'].includes(p.state))fail('VALIDATION_FAILED','state 无效');
+    return app.operation(actor,'workshop-transition',p,async s=>{
+      const ctx=await session(s,p.sessionId,actor);await s.lock('workshop-project:'+required(p.projectId,'projectId'));const proj=await project(s,p.projectId,ctx,actor);
+      const from=stages.indexOf(proj.stageCode),to=stages.indexOf(p.toStage);if(to<from||to>from+1)fail('VALIDATION_FAILED','不能跳阶段或倒退');
+      if(proj.progressState==='completed')fail('VERSION_CONFLICT','项目已经完成');
+      if(p.state==='completed'&&p.toStage!=='S5')fail('VALIDATION_FAILED','只能在 S5 完成项目');
+      if(to>from||p.state==='completed'){
+        const artifacts=await s.list('KfArtifact',{where:{projectRef:pointer('KfProject',proj.objectId)},limit:1001});
+        if(!artifacts.some(a=>a.stageCode===proj.stageCode))fail('STAGE_REQUIREMENTS_NOT_MET','当前阶段尚无项目成果');
+        if(p.state==='completed'&&!proj.currentConceptRef)fail('STAGE_REQUIREMENTS_NOT_MET','尚未保存新品方案');
+      }
+      const ev=await event(s,proj,ctx,p.state==='blocked'?'blocked':p.state==='completed'?'completed':'stage_changed',p.toStage,null,p.note);
+      await s.update('KfProject',proj.objectId,{stageCode:p.toStage,stage:label(p.toStage),progressState:p.state,lastActiveAt:now()});await touch(s,ctx,p.toStage,p.state);
+      return{projectId:proj.objectId,eventId:ev.objectId,stageCode:p.toStage,progressState:p.state,enterpriseApproval:false};
+    });
+  };
+
+  app.handlers.ksfSaveWorkshopConcept=async(p,actor)=>{
+    need(actor,'ingest:write');exact(p,['requestId','sessionId','projectId','version','concept','formulaItems']);version(p.version);
+    exact(p.concept,['name','targetAudience','usageScenario','specification','valueProposition','flavor','sellingPoints','productionSteps','pendingQuestions']);
+    for(const k of ['name','targetAudience','usageScenario','valueProposition'])short(p.concept[k],'concept.'+k);
+    if(!object(p.concept.specification)||!Object.keys(p.concept.specification).length||!Array.isArray(p.formulaItems)||!Array.isArray(p.concept.productionSteps)||!Array.isArray(p.concept.pendingQuestions))fail('VALIDATION_FAILED','方案必须明确规格、生产路线、配料与待验证项(无资料时数组可为空)');
+    return app.operation(actor,'workshop-concept',p,async s=>{
+      const ctx=await session(s,p.sessionId,actor);await s.lock('workshop-project:'+required(p.projectId,'projectId'));const proj=await project(s,p.projectId,ctx,actor);
+      if(!['S4','S5'].includes(proj.stageCode))fail('STAGE_REQUIREMENTS_NOT_MET','请先进入验证或方案阶段');
+      const previous=proj.currentConceptRef?await s.get('KfConcept',proj.currentConceptRef.objectId):null;
+      if(p.version!==(previous?.version||0)+1)fail('VERSION_CONFLICT','方案版本必须连续递增');
+      const items=[];
+      for(const step of p.concept.productionSteps){
+        if(!object(step))fail('VALIDATION_FAILED','生产路线必须为对象数组');
+        if(step.processId){const process=await s.get('KfProcess',step.processId);if(!process)fail('REFERENCE_NOT_FOUND','工艺不存在');if(process.sourceRef)await app.accessSource(s,process.sourceRef.objectId,actor);if(process.dataMode==='sample'&&ctx.dataMode!=='sample')fail('VALIDATION_FAILED','真实方案不能引用样例工艺');}
+      }
+      for(const item of p.formulaItems){exact(item,['materialId','component','amount','unit','basis','note']);required(item.component,'component');required(item.basis,'basis');
+        if(item.amount!=null&&(!Number.isFinite(item.amount)||item.amount<=0||typeof item.unit!=='string'||!item.unit))fail('VALIDATION_FAILED','配料用量必须有正数及单位');
+        const material=await s.get('KfMaterial',required(item.materialId,'materialId'));if(!material)fail('REFERENCE_NOT_FOUND','原料不存在');if(material.sourceRef)await app.accessSource(s,material.sourceRef.objectId,actor);if(material.dataMode==='sample'&&ctx.dataMode!=='sample')fail('VALIDATION_FAILED','真实方案不能引用样例原料');items.push(item);
+      }
+      const c=await s.create('KfConcept',{...metadata('workshop:concept:'+proj.objectId+':'+p.version,ctx.dataMode),projectRef:pointer('KfProject',proj.objectId),version:p.version,...(previous?{previousVersionRef:pointer('KfConcept',previous.objectId)}:{}),...Object.fromEntries(['targetAudience','usageScenario','specification','valueProposition','flavor','sellingPoints','productionSteps'].filter(k=>p.concept[k]!==undefined).map(k=>[k,p.concept[k]])),name:sampleTitle(p.concept.name,ctx.dataMode)});
+      const ids=[];for(const [i,item]of items.entries()){const row=await s.create('KfFormulaItem',{...metadata('workshop:formula:'+c.objectId+':'+i,ctx.dataMode),conceptRef:pointer('KfConcept',c.objectId),lineNo:i+1,materialRef:pointer('KfMaterial',item.materialId),component:item.component,basis:item.basis,...Object.fromEntries(['amount','unit','note'].filter(k=>item[k]!==undefined).map(k=>[k,item[k]]))});ids.push(row.objectId);}
+      await s.update('KfProject',proj.objectId,{currentConceptRef:pointer('KfConcept',c.objectId),lastActiveAt:now(),meta:{...proj.meta,pendingQuestions:p.concept.pendingQuestions}});
+      const ev=await event(s,proj,ctx,'concept_saved',proj.stageCode,null,'方案版本 '+p.version+';待验证 '+p.concept.pendingQuestions.length+' 项');await touch(s,ctx,proj.stageCode,'concept_saved');
+      return{projectId:proj.objectId,conceptId:c.objectId,version:p.version,formulaItemIds:ids,eventId:ev.objectId,pendingQuestions:p.concept.pendingQuestions,enterpriseApproval:false};
+    });
+  };
+
+  app.handlers.ksfGetWorkshopProject=async(p,actor)=>{
+    need(actor,'data:read');exact(p,['requestId','sessionId','projectId']);const ctx=await session(store,p.sessionId,actor),proj=await project(store,p.projectId,ctx,actor);if(!proj)fail('VALIDATION_FAILED','缺少 projectId');
+    const events=await store.list('KfStageEvent',{where:{projectRef:pointer('KfProject',proj.objectId)},limit:1001});
+    const artifacts=await store.list('KfArtifact',{where:{projectRef:pointer('KfProject',proj.objectId)},limit:1001});
+    const concept=proj.currentConceptRef?await store.get('KfConcept',proj.currentConceptRef.objectId):null;
+    const formulaItems=concept?await store.list('KfFormulaItem',{where:{conceptRef:pointer('KfConcept',concept.objectId)},limit:201}):[];
+    return{project:proj,artifacts,events:events.sort((a,b)=>a.sequence-b.sequence),concept,formulaItems};
+  };
+
+  const fields={
+    project:'objectId createdAt updatedAt title ownerName ownerSession uploadedByKey agentName agentId stage stageCode category summary targetUser scenario score artifactCount opportunityId opportunityRef sessionRef currentConceptRef progressState groupId workshopKey dataMode lastActiveAt',
+    artifact:'objectId createdAt updatedAt title artifactType reportType rawText report content stage stageCode ownerName ownerSession uploadedByKey agentName agentId uploadMethod uploadedAt projectId projectRef sessionRef category tags sources evidenceLevel evidenceCount severity layer tier sourceRef dataMode version dashboardState workshopKey groupId',
+    insight:'objectId createdAt updatedAt title body category layer derivedFrom derivedArtifacts derivedSources owners ownerCount mentionCount crossRefCount quoteCount evidenceQuoteCount evidenceLevel citedWith reportRoles reviewedBy dataMode',
+    source:'objectId createdAt updatedAt title sourceType sourceKind summary tags dataMode sourceUrl platform',
+    session:'objectId createdAt updatedAt studentName stage stageCode groupId msgCount lastActiveAt workshopKey step',
+    opportunity:'objectId createdAt updatedAt title oneLiner stage category priority status scores decision decisionLog evidenceLevel quoteCount citeCount searchText uploadedAt uploadedBy uploadedByKey agentName agentId sourceSession sessionRef uploadMethod linkedArtifacts linkedProject seedKey dataMode targetAudience usageScenario demandSummary claimKind version'
+  };
+  const classes={project:'KfProject',artifact:'KfArtifact',insight:'KfInsight',source:'KfSource',session:'KfSession',opportunity:'KfOpportunity'};
+  app.handlers.ksfGetPublicBoard=async p=>{
+    exact(p,['requestId','cursor','limit']);const size=p.limit??200;if(!Number.isInteger(size)||size<1||size>500)fail('VALIDATION_FAILED','limit 为 1–500');
+    let positions={};if(p.cursor){const cur=app.readReceipt(p.cursor);if(cur.type!=='public-board-cursor'||cur.expiresAt<Date.now())fail('VALIDATION_FAILED','游标已失效');positions=cur.positions;}
+    const result={},next={};
+    for(const [key,name]of Object.entries(classes)){
+      if(positions[key]==='DONE'){result[key]=[];next[key]='DONE';continue;}
+      const scan=await store.list(name,{afterId:positions[key]||null,limit:size+1}),batch=scan.slice(0,size);next[key]=scan.length>size?batch.at(-1).objectId:'DONE';
+      result[key]=[];for(const row of batch){
+        const workshop=row.meta?.workshop?.visibility==='public'||row.externalKey?.startsWith('workshop:opportunity:');
+        const legacy=!row.schemaVersion&&!row.dashboardState;
+        if(!(workshop||legacy||key==='artifact'&&row.dashboardState==='published')||row.recordState==='archived')continue;
+        const clean=Object.fromEntries(fields[key].split(' ').filter(k=>row[k]!==undefined).map(k=>[k,row[k]]));
+        if(workshop&&key==='session')clean.meta={participantKey:row.meta.attribution.participantKey,agentName:row.meta.attribution.agentName,agentId:row.meta.attribution.agentKey};
+        if(workshop&&key==='project')clean.meta={pendingQuestions:row.meta.pendingQuestions||[]};
+        result[key].push(clean);
+      }
+    }
+    return{...result,asOf:now(),nextCursor:Object.values(next).some(x=>x!=='DONE')?app.signedReceipt({type:'public-board-cursor',positions:next,expiresAt:Date.now()+15*60000}):null};
+  };
+}

+ 67 - 0
server/test/board-actions.test.mjs

@@ -0,0 +1,67 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import crypto from 'node:crypto';
+import { createApplication } from '../src/core.mjs';
+import { attachBoardActionHandlers } from '../src/board-actions.mjs';
+const data = JSON.parse(fs.readFileSync(new URL('../references/runtime-data.json', import.meta.url)));
+function fixture() {
+  let rows = {}, seq = 0;
+  const clone = (x) => structuredClone(x);
+  const store = {
+    async transaction(fn) { const before = clone(rows); try { return await fn(store); } catch (error) { rows = before; throw error; } },
+    async lock() {},
+    async get(name, id) { return clone(rows[name]?.find((row) => row.objectId === id) || null); },
+    async list(name, { where = {} } = {}) { return clone((rows[name] || []).filter((row) => Object.entries(where).every(([key, value]) => row[key] === value))); },
+    async create(name, value) { const row = { ...clone(value), objectId: 'test' + ++seq, updatedAt: new Date(1800000000000 + seq).toISOString() }; (rows[name] ||= []).push(row); return clone(row); },
+    async update(name, id, changes) { const row = rows[name].find((row) => row.objectId === id); Object.assign(row, clone(changes), { updatedAt: new Date(1800000000000 + ++seq).toISOString() }); return clone(row); },
+  };
+  const app = createApplication({ store, crypto, Parse: {}, data, settings: { signingSecret: 'test-only' }, files: {} });
+  attachBoardActionHandlers(app, { store });
+  return { store, app };
+}
+async function card(f, extra = {}) { return f.store.create('KfOpportunity', { title: 'test', status: 'candidate', externalKey: 'workshop:opportunity:test', schemaVersion: 'ksf.v0.7', linkedProject: 'kept-project', linkedArtifacts: ['kept-report'], ...extra }); }
+const params = (row, extra = {}) => ({ requestId: 'request-' + crypto.randomUUID(), opportunityId: row.objectId, expectedUpdatedAt: row.updatedAt, by: '自报姓名', ...extra });
+test('匿名三状态持久化,保留互链与旧决策,不产生项目或企业批准', async () => {
+  const f = fixture(); let row = await card(f);
+  for (const status of ['approved', 'rejected', 'candidate']) {
+    const result = (await f.app.dispatch('ksfSetPublicOpportunityStatus', params(row, { status }), null)).data;
+    assert.equal(result.opportunity.status, status); assert.equal(result.identityVerified, false); assert.equal(result.projectCreated, false); assert.equal(result.enterpriseApproval, false);
+    assert.equal(result.opportunity.decision.via, 'public_board'); assert.equal(result.opportunity.decision.identityVerified, false);
+    row = await f.store.get('KfOpportunity', row.objectId); assert.equal(row.status, status);
+    assert.equal(row.linkedProject, 'kept-project'); assert.deepEqual(row.linkedArtifacts, ['kept-report']);
+  }
+  assert.equal(row.decisionLog.length, 3); assert.equal((await f.store.list('KfProject')).length, 0);
+  assert.equal((await f.store.list('KsfOperation')).length, 3);
+});
+test('重复请求复用回执,改内容复用请求键、过期版本均不写入', async () => {
+  const f = fixture(), row = await card(f), p = params(row, { status: 'approved' });
+  const first = await f.app.dispatch('ksfSetPublicOpportunityStatus', p, null);
+  assert.deepEqual((await f.app.dispatch('ksfSetPublicOpportunityStatus', p, null)).data, first.data);
+  await assert.rejects(() => f.app.dispatch('ksfSetPublicOpportunityStatus', { ...p, status: 'rejected' }, null), { code: 'IDEMPOTENCY_CONFLICT' });
+  await assert.rejects(() => f.app.dispatch('ksfSetPublicOpportunityStatus', params(row, { status: 'rejected' }), null), { code: 'VERSION_CONFLICT' });
+  assert.equal((await f.store.list('KsfOperation')).length, 1);
+  assert.equal((await f.store.get('KfOpportunity', row.objectId)).decisionLog.length, 1);
+});
+test('私有/归档/未知记录、越权字段与坏状态被拒绝,回执不泄露私有字段', async () => {
+  const f = fixture();
+  for (const extra of [{ externalKey: 'private:test' }, { recordState: 'archived' }]) {
+    const row = await card(f, extra);
+    await assert.rejects(() => f.app.dispatch('ksfSetPublicOpportunityStatus', params(row, { status: 'approved' }), null), { code: 'REFERENCE_NOT_FOUND' });
+  }
+  const row = await card(f, { privateInfo: 'must-not-leak' });
+  for (const extra of [{ status: 'completed' }, { status: 'approved', linkedProject: 'hack' }, { status: 'approved', expectedUpdatedAt: null }]) {
+    await assert.rejects(() => f.app.dispatch('ksfSetPublicOpportunityStatus', params(row, extra), null), { code: 'VALIDATION_FAILED' });
+  }
+  const result = await f.app.dispatch('ksfSetPublicOpportunityStatus', params(row, { status: 'approved', by: '' }), null);
+  assert.equal(result.data.opportunity.privateInfo, undefined); assert.equal(result.data.opportunity.decision.by, '匿名访客');
+});
+test('匿名评分校验与同署名覆盖,保留其他评分', async () => {
+  const f = fixture(); let row = await card(f, { scores: [{ user: '其他人', score: 60 }] });
+  for (const score of [70, 80]) {
+    await f.app.dispatch('ksfRatePublicOpportunity', params(row, { score, comment: 'test' }), null);
+    row = await f.store.get('KfOpportunity', row.objectId);
+  }
+  assert.equal(row.scores.length, 2); assert.equal(row.scores.find((s) => s.user === '自报姓名').score, 80);
+  for (const extra of [{ score: 101 }, { score: '80' }, { score: 50, by: '' }]) await assert.rejects(() => f.app.dispatch('ksfRatePublicOpportunity', params(row, extra), null), { code: 'VALIDATION_FAILED' });
+});

+ 65 - 0
server/test/member-groups.test.mjs

@@ -0,0 +1,65 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import crypto from 'node:crypto';
+import { createApplication } from '../src/core.mjs';
+import { attachMemberGroupHandlers, readMemberGroupBoard } from '../src/member-groups.mjs';
+const data = JSON.parse(fs.readFileSync(new URL('../references/runtime-data.json',import.meta.url)));
+
+function fixture() {
+  let rows={KsfRuntimeConfig:[{objectId:'runtime',key:'runtime',value:{signingSecret:'PRIVATE',wecomDirectory:{secret:'PRIVATE'}}}]},seq=0,queue=Promise.resolve();
+  const clone=structuredClone;
+  const store={
+    async transaction(fn) { const exec=async()=>{const before=clone(rows);try{return await fn(store);}catch(e){rows=before;throw e;}};const result=queue.then(exec);queue=result.catch(()=>{});return result; },
+    async lock(){},
+    async list(name,{where={}}={}) {return clone((rows[name]||[]).filter((r)=>Object.entries(where).every(([k,v])=>r[k]===v)));},
+    async create(name,value){const row={...clone(value),objectId:'object'+ ++seq};(rows[name]||=[]).push(row);return clone(row);},
+    async update(name,id,changes){const row=rows[name].find((r)=>r.objectId===id);Object.assign(row,clone(changes));return clone(row);},
+  };
+  const board={session:[{objectId:'s1',studentName:'同名',groupId:'G1',meta:{participantKey:'participant:one'}},{objectId:'s2',studentName:'同名',groupId:'G2',meta:{participantKey:'participant:two'}}],artifact:[],project:[],opportunity:[]};
+  const app=createApplication({store,crypto,Parse:{},data,settings:{signingSecret:'test'},files:{}});
+  attachMemberGroupHandlers(app,{store,loadBoard:async()=>board});
+  const call=async(name,input={})=>(await app.dispatch(name,input,null)).data;
+  const change=(revision,command,requestId=crypto.randomUUID())=>call('ksfManagePublicMemberGroups',{requestId,expectedRevision:revision,...command});
+  return {store,board,call,change};
+}
+
+test('公开分组只读不会写库或泄露运行配置,首次沿用现有组号',async()=>{
+  const f=fixture(),r=await f.call('ksfGetPublicMemberGroups');
+  assert.equal(r.revision,0);assert.equal(r.groups.length,2);assert.equal(r.memberCount,2);
+  assert.equal((await f.store.list('KsfRuntimeConfig')).length,1);assert.ok(!JSON.stringify(r).includes('PRIVATE'));
+});
+test('共享分组创建/批量分配/重命名/删除持久化且不改业务会话',async()=>{
+  const f=fixture(),before=JSON.stringify(f.board);
+  let r=await f.change(0,{action:'create',name:'研发组'});const id=r.groups.at(-1).id;
+  r=await f.change(r.revision,{action:'assign',groupId:id,memberIds:['participant:one','participant:two']});
+  assert.equal(Object.values(r.assignments).filter((v)=>v===id).length,2);
+  r=await f.change(r.revision,{action:'rename',groupId:id,name:'创新组'});assert.equal(r.groups.at(-1).name,'创新组');
+  r=await f.change(r.revision,{action:'delete',groupId:id});assert.deepEqual(r.assignments,{});
+  assert.equal(JSON.stringify(f.board),before);assert.equal((await f.call('ksfGetPublicMemberGroups')).revision,4);
+  assert.equal((await f.store.list('KsfOperation')).length,4);assert.equal((await f.store.list('KsfRuntimeConfig')).find((x)=>x.key==='runtime').value.signingSecret,'PRIVATE');
+});
+test('同请求幂等、旧版本和并发更新不会覆盖其他人的更改',async()=>{
+  const f=fixture(),command={action:'create',name:'研发组'};
+  const first=await f.change(0,command,'same');assert.deepEqual(await f.change(0,command,'same'),first);
+  await assert.rejects(()=>f.change(0,{...command,name:'另一组'},'same'),{code:'IDEMPOTENCY_CONFLICT'});
+  const results=await Promise.allSettled([f.change(1,{action:'create',name:'A组'}),f.change(1,{action:'create',name:'B组'})]);
+  assert.equal(results.filter((r)=>r.status==='fulfilled').length,1);assert.equal(results.find((r)=>r.status==='rejected').reason.code,'VERSION_CONFLICT');
+  assert.equal((await f.store.list('KsfOperation')).length,2);
+});
+test('隐藏成员、越权参数、重复名称和部分无效批次全部拒绝且不留半批结果',async()=>{
+  const f=fixture();
+  for(const command of [{action:'create',name:'G1'},{action:'create',name:'G1',groupId:'legacy-group:G1'},
+    {action:'assign',groupId:'legacy-group:G1',memberIds:['participant:one','private:unknown']},
+    {action:'assign',groupId:null,memberIds:['participant:one'],role:'admin'}]) await assert.rejects(()=>f.change(0,command),{code:'VALIDATION_FAILED'});
+  assert.equal((await f.call('ksfGetPublicMemberGroups')).revision,0);assert.equal((await f.store.list('KsfOperation')).length,0);
+});
+test('公开成员读取完整分页且不带服务凭据,读取失败不当空成员',async()=>{
+  let count=0;
+  const board=await readMemberGroupBoard({url:'/fixture',appId:'public',fetchImpl:async(url,options)=>{
+    count++;assert.deepEqual(Object.keys(options.headers).sort(),['Content-Type','X-Parse-Application-Id']);
+    const cursor=JSON.parse(options.body).params.cursor;
+    return {ok:true,json:async()=>({result:{ok:true,data:{session:[{objectId:cursor?'s2':'s1'}],project:[],artifact:[],opportunity:[],nextCursor:cursor?null:'next'}}})};
+  }});assert.equal(count,2);assert.equal(board.session.length,2);
+  await assert.rejects(()=>readMemberGroupBoard({url:'/fixture',appId:'public',fetchImpl:async()=>({ok:false,json:async()=>({})})}),{code:'MEMBER_READ_FAILED'});
+});

+ 176 - 0
server/voc-library/board_library.py

@@ -0,0 +1,176 @@
+"""Read-only, bounded browser projection using the same retrieval rules as Agents."""
+import sys
+sys.dont_write_bytecode = True
+import json
+from pathlib import Path
+import voc_db
+import retrieval
+
+LABELS = {'products':'商品与竞品','reviews':'电商评价','social':'社媒内容','documents':'政策文件',
+          'clauses':'条款与应用','prices':'价格快照','specs':'商品规格','rankings':'榜单线索','coverage':'采集覆盖'}
+
+def active_database(config):
+    if config.get('registry'):
+        root = Path(config['registry']).resolve()
+        pointer = json.loads((root/'current.json').read_text(encoding='utf-8'))
+        p = (root/pointer['file']).resolve()
+        voc_db.require(p.is_relative_to(root) and p.is_file(), 'INVALID_LIBRARY_POINTER')
+        return p, pointer
+    return Path(config['db']).resolve(), None
+
+def query_request(p):
+    entity = p.get('entity','products')
+    voc_db.require(entity in LABELS, 'UNKNOWN_ENTITY')
+    filters = p.get('filters', {})
+    voc_db.require(isinstance(filters,dict), 'INVALID_FILTERS')
+    q = {'entities':[entity], 'filters':filters, 'quality':p.get('quality','analysis'), 'limit':p.get('limit',20)}
+    if p.get('keyword'): q['text'] = {'all':[p['keyword']]}
+    if p.get('cursor'): q['cursor'] = p['cursor']
+    return q
+
+def summary(c, pointer):
+    meta = dict(c.execute('SELECT * FROM meta'))
+    counts = voc_db.table_counts(c)
+    totals = {}
+    for entity in LABELS:
+        where,args,_ = retrieval.prepare(c, {'entities':[entity]})
+        totals[entity] = c.execute('SELECT count(*) FROM retrieval_documents d WHERE '+where,args).fetchone()[0]
+    quality = {
+        'defaultReviews':c.execute('SELECT count(*) FROM reviews WHERE is_default=1').fetchone()[0],
+        'pendingProducts':c.execute("SELECT count(*) FROM products WHERE classification_state='pending'").fetchone()[0],
+        'verifiedSpecs':c.execute("SELECT count(*) FROM specs WHERE verification='source_attribute'").fetchone()[0],
+        'datedPrices':c.execute('SELECT count(*) FROM prices WHERE source_calendar_date IS NOT NULL').fetchone()[0],
+        'socialFulltext':c.execute('SELECT count(*) FROM social_posts WHERE fulltext_available=1').fetchone()[0],
+        'documentFulltext':c.execute("SELECT count(*) FROM policy_documents WHERE length(trim(coalesce(full_text,'')))>0").fetchone()[0],
+        'brands':c.execute('SELECT count(DISTINCT brand_code) FROM products').fetchone()[0],
+        'shops':c.execute("SELECT count(DISTINCT marketplace||':'||shop_id) FROM products WHERE shop_id IS NOT NULL").fetchone()[0],
+    }
+    updates=[]
+    for item in (pointer.get('history',[]) if pointer else []):
+        entry=dict(item);delta=entry.get('delta',{})
+        if delta:
+            entry['summary']='、'.join(LABELS[e]+'新增'+str(delta.get('added',{}).get(t,0))+'/变更'+str(delta.get('changed',{}).get(t,0)) for e,t in retrieval.ENTITIES.items() if delta.get('added',{}).get(t,0) or delta.get('changed',{}).get(t,0)) or '重复资料,无实体新增'
+        updates.append(entry)
+    return {'schema':meta['schema'],'generation':meta['generation'], 'counts':counts,'analysisCounts':totals,'quality':quality,
+            'taxonomy':json.loads(meta['taxonomy']), 'entities':[{'id':k,'name':v,'count':counts[retrieval.ENTITIES[k]],'analysisCount':totals[k]} for k,v in LABELS.items()],
+            'batches':[dict(r) for r in c.execute('SELECT * FROM batches ORDER BY imported_at DESC')],
+            'updates':updates,'updatedAt':pointer.get('updatedAt') if pointer else None}
+
+def browse(c,p):
+    q = query_request(p)
+    result = retrieval.search(c,q)
+    # Display prose only, never raw upstream payloads, author IDs, or private filesystem paths.
+    for row in result['records']:
+        row.pop('citation',None)
+        if row['entity']=='reviews':
+            raw=c.execute('SELECT content,purchased_sku_text,rating_raw,review_date_raw FROM reviews WHERE id=?',(row['record_id'],)).fetchone()
+            row.update(dict(raw));row['preview']=raw['content'][:230]
+            body=c.execute("SELECT body FROM retrieval_documents WHERE entity='reviews' AND record_id=?",(row['record_id'],)).fetchone()[0]
+            row['append_content']=body[len(raw['content']):].strip() if body.startswith(raw['content']) else ''
+        elif row['entity']=='products':
+            raw=c.execute('SELECT shop_name FROM products WHERE id=?',(row['record_id'],)).fetchone()
+            row['shop_name']=raw['shop_name']
+            row['preview']='店铺:'+raw['shop_name'] if raw['shop_name'] else ''
+        elif row['entity']=='social' and not row['fulltext_available']:row['preview']=''
+        else: row['preview']=row['match']['text'][:230]
+        if row['entity'] in ('specs','prices','rankings','coverage'):
+            fields={'specs':'spec_text,net_weight_g,pack_count,total_weight_g',
+                    'prices':'amount_minor,raw_amount,price_basis,price_per_100g,unit_price_verification,source_time_raw',
+                    'rankings':'title,rank_number,scope,source_time_raw',
+                    'coverage':'status,available_reviews,reported_total,historical_total_complete'}[row['entity']]
+            raw=c.execute('SELECT '+fields+' FROM '+retrieval.ENTITIES[row['entity']]+' WHERE id=?',(row['record_id'],)).fetchone()
+            row.update(dict(raw))
+    if p.get('entity','products')=='products' and result['records']:
+        # One grouped read per page, scoped by exact product IDs; titles never join records.
+        ids=[row['record_id'] for row in result['records']]
+        counts={pid:{} for pid in ids}
+        for row in c.execute('SELECT product_id,entity,count(*) AS n FROM retrieval_documents WHERE product_id IN ('+','.join('?' for _ in ids)+') GROUP BY product_id,entity',ids):
+            counts[row['product_id']][row['entity']]=row['n']
+        for row in result['records']:row['relatedCounts']=counts[row['record_id']]
+    fields=[f for f in ('platform','brand','category','shopId','policyStatus','contentScope','specVerification','priceBasis') if f in retrieval.ENTITY_FIELDS[p.get('entity','products')]]
+    facets={}
+    for field in fields:
+        fq={k:v for k,v in q.items() if k not in ('cursor','limit')}
+        fq['filters']={k:v for k,v in q['filters'].items() if k!=field}
+        # Social brands are mentions, not product ownership; enumerate distinct mentions explicitly.
+        if field=='brand' and p.get('entity')=='social':
+            where,args,_=retrieval.prepare(c,fq)
+            rows=[dict(r) for r in c.execute('SELECT m.brand_code AS brand,count(DISTINCT d.record_id) AS recordCount FROM retrieval_documents d JOIN social_brand_mentions m ON m.post_id=d.record_id WHERE '+where+' GROUP BY m.brand_code ORDER BY recordCount DESC LIMIT 101',args)]
+            facets[field]={'rows':rows[:100],'truncated':len(rows)>100}
+        else:
+            fq.update(groupBy=[field],limit=100)
+            facets[field]=retrieval.aggregate(c,fq,facets=True)['groups'][field]
+        if field=='shopId':
+            for row in facets[field]['rows']:
+                names=[r[0] for r in c.execute('SELECT DISTINCT shop_name FROM products WHERE shop_id=? AND shop_name IS NOT NULL',(row.get('shop_id'),))]
+                row['shop_name']=' / '.join(names)
+    result['facets']=facets
+    where,args,_=retrieval.prepare(c,q)
+    result['coverage']={}
+    for field in ('sourceDate','reviewDate','publishedDate'):
+        if field in retrieval.ENTITY_FIELDS[p.get('entity','products')]:
+            col=retrieval.FIELDS[field][0]
+            r=c.execute('SELECT min('+col+'),max('+col+'),sum('+col+' IS NULL) FROM retrieval_documents d WHERE '+where,args).fetchone()
+            result['coverage'][field]={'min':r[0],'max':r[1],'unknown':r[2] or 0}
+    result['pendingCount']=None
+    if q['quality']=='analysis':
+        aq={**q,'quality':'audit'}
+        aw,av,_=retrieval.prepare(c,aq)
+        result['pendingCount']=c.execute('SELECT count(*) FROM retrieval_documents d WHERE '+aw,av).fetchone()[0]-result['totalMatched']
+    return result
+
+def detail(c,p):
+    entity=p.get('entity');rid=p.get('id')
+    voc_db.require(entity in LABELS and isinstance(rid,str) and len(rid)<=300,'INVALID_RECORD')
+    record=voc_db.get_record(c,entity,rid)['record']
+    if entity=='reviews':
+        body=c.execute("SELECT body FROM retrieval_documents WHERE entity='reviews' AND record_id=?",(rid,)).fetchone()[0]
+        record['append_content_json']=body[len(record['content']):].strip() if body.startswith(record['content']) else ''
+    citation=record.pop('citation')
+    record.pop('productCitation',None)
+    for row in record.get('classification',[]):row.pop('evidence_record_id',None)
+    # Keep source locator and hashes; omit private upstream paths and raw JSON.
+    record['source']={k:citation[k] for k in ('id','kind','asset_path','line_number','payload_sha256','assetSha256','archiveSha256','archiveSha256s','locatorType') if k in citation}
+    if 'asset_path' in record['source']: record['source']['asset_path']=record['source']['asset_path'].replace('\\','/').split('/')[-1]
+    issues=[dict(r) for r in c.execute('SELECT code,detail FROM quality_issues WHERE entity_id=?',(rid,))]
+    # Issue detail can contain original private paths. Show the rule code only.
+    record['issues']=[r['code'] for r in issues]
+    relations=[]
+    if entity=='products':
+        for related in ('reviews','prices','specs','rankings','coverage'):
+            q={'entities':[related],'filters':{'productId':rid},'quality':'audit'}
+            where,args,_=retrieval.prepare(c,q)
+            n=c.execute('SELECT count(*) FROM retrieval_documents d WHERE '+where,args).fetchone()[0]
+            relations.append({'entity':related,'productId':rid,'count':n,'name':LABELS[related]})
+    if entity=='documents':
+        clauses=[{'id':r['id'],'name':r['clause']} for r in c.execute('SELECT id,clause FROM policy_clauses WHERE document_id=? ORDER BY id',(rid,))]
+        record['clauses']=clauses
+    if record.get('product_id'):
+        parent=c.execute('SELECT id,title,url,marketplace,shop_name,brand_code,category_code FROM products WHERE id=?',(record['product_id'],)).fetchone()
+        if parent:record['parentProduct']=dict(parent)
+    if entity=='clauses':
+        parent=c.execute('SELECT id,title,source_url FROM policy_documents WHERE id=?',(record['document_id'],)).fetchone()
+        if parent:record['parentDocument']=dict(parent)
+    return {'record':record,'relations':relations}
+
+def run(config,p):
+    voc_db.require(isinstance(p,dict) and p.get('op') in ('summary','browse','detail','version'),'READ_COMMAND_REQUIRED')
+    voc_db.require(not set(p)-{'op','entity','filters','quality','limit','keyword','cursor','id','generation'},'UNKNOWN_REQUEST_FIELD')
+    db,pointer=active_database(config)
+    with voc_db.readonly(db) as c:
+        generation=c.execute("SELECT value FROM meta WHERE key='generation'").fetchone()[0]
+        voc_db.require(not pointer or pointer['generation']==generation,'POINTER_VERSION_MISMATCH')
+        voc_db.require(not p.get('generation') or p['generation']==generation,'LIBRARY_VERSION_CHANGED')
+        if p['op']=='summary':data=summary(c,pointer)
+        elif p['op']=='version':data={'updatedAt':pointer.get('updatedAt') if pointer else None}
+        elif p['op']=='browse':data=browse(c,p)
+        else:data=detail(c,p)
+        return {'ok':True,'generation':generation,'readOnly':True,'databaseWrites':0,**data}
+
+if __name__=='__main__':
+    try:
+        payload=json.loads(sys.stdin.read())
+        print(json.dumps(run(payload['config'],payload['request']),ensure_ascii=False))
+    except Exception as e:
+        code=str(e) if isinstance(e,voc_db.LibraryError) else 'LIBRARY_READ_FAILED'
+        print(json.dumps({'ok':False,'error':{'code':code}},ensure_ascii=False));sys.exit(1)

+ 11 - 0
server/voc-library/query_voc.py

@@ -0,0 +1,11 @@
+"""Agent entry: accepts read operations only; uses the private local library."""
+import sys
+# Keep the installed, checksummed skill immutable during normal queries.
+sys.dont_write_bytecode = True
+import voc_db
+
+if __name__ == '__main__':
+    if len(sys.argv) < 2 or sys.argv[1] not in ('contract','query','search','aggregate','facets','get','source','verify'):
+        print('{"ok":false,"error":{"code":"READ_COMMAND_REQUIRED"}}', file=sys.stderr)
+        sys.exit(1)
+    sys.exit(voc_db.main())

+ 359 - 0
server/voc-library/retrieval.py

@@ -0,0 +1,359 @@
+"""Typed retrieval across VOC entities; literal evidence + bounded term expansion.
+
+No natural-language/semantic understanding is claimed here: the Agent translates
+the question using the discoverable contract. FTS generates indexed candidates;
+literal phrases are checked again and returned with original-text snippets.
+"""
+import base64
+import collections
+import hashlib
+import json
+import math
+import re
+import unicodedata
+
+VERSION = 'ksf.voc-retrieval.v2'
+ENTITIES = {'products':'products', 'reviews':'reviews', 'prices':'prices', 'specs':'specs',
+            'social':'social_posts', 'documents':'policy_documents', 'clauses':'policy_clauses',
+            'rankings':'ranking_signals', 'coverage':'coverage_observations'}
+DEFAULT_ENTITIES = ['products','reviews','social','documents','clauses']
+CONCEPTS = {
+    'portion_size': {'name':'份量', 'terms':['份量','分量','面量','量少','不够吃','吃不饱','量太少']},
+    'preparation': {'name':'制作便利', 'terms':['煮面','冲泡','不能泡','必须煮','沥水','免煮','煮不熟','煮了']},
+    'taste': {'name':'口味体验', 'terms':['口味','味道','太辣','太咸','太淡','好吃','难吃','口感']},
+    'packaging': {'name':'包装体验', 'terms':['包装','漏油','破损','破包','封口','餐具']},
+    'value': {'name':'价格与价值', 'terms':['价格','性价比','太贵','便宜','划算','涨价']},
+    'nutrition_label': {'name':'营养与标签', 'terms':['营养','标签','配料','钠含量','脂肪','能量','蛋白质']},
+}
+FIELDS = {
+    'platform':('platform','enum'), 'brand':('brand','enum'), 'category':('category','enum'),
+    'shopId':('shop_id','id'), 'productId':('product_id','id'), 'itemId':('item_id','id'), 'recordId':('record_id','id'),
+    'sourceId':('source_record_id','id'), 'sourceDate':('source_day','date'), 'reviewDate':('review_day','date'),
+    'publishedDate':('published_day','date'), 'priceMinor':('price_minor','number'), 'priceBasis':('price_basis','text'),
+    'netWeightG':('net_weight_g','number'), 'packCount':('pack_count','number'), 'specVerification':('spec_verification','text'),
+    'policyStatus':('policy_status','text'), 'documentId':('document_id','id'), 'contentScope':('content_scope','text'),
+    'fulltextAvailable':('fulltext_available','bool'), 'isDefault':('is_default','bool'), 'identityState':('identity_state','text'),
+    'ratingValue':('rating_value','number'), 'ratingScale':('rating_scale','text'),
+    'categoryState':('category_state','text'), 'classificationState':('classification_state','text'),
+    'rankNumber':('rank_number','number'), 'rankScope':('rank_scope','text'),
+}
+ENTITY_FIELDS = {
+    'products':{'platform','brand','category','shopId','productId','itemId','sourceDate','classificationState'},
+    'reviews':{'platform','brand','category','shopId','productId','itemId','sourceDate','reviewDate','isDefault','identityState','categoryState','classificationState','ratingValue','ratingScale'},
+    'prices':{'platform','brand','category','shopId','productId','itemId','sourceDate','priceMinor','priceBasis','classificationState'},
+    'specs':{'platform','brand','category','shopId','productId','itemId','sourceDate','netWeightG','packCount','specVerification','classificationState'},
+    'social':{'platform','brand','publishedDate','contentScope','fulltextAvailable'},
+    'documents':{'sourceDate','documentId','policyStatus','fulltextAvailable'},
+    'clauses':{'sourceDate','documentId','policyStatus'},
+    'rankings':{'platform','brand','category','shopId','productId','itemId','sourceDate','rankNumber','rankScope','classificationState'},
+    'coverage':{'platform','brand','category','shopId','productId','itemId','classificationState'},
+}
+COMMON_FIELDS = {'recordId','sourceId'}
+PROJECTION_COLUMNS = ['entity','record_id','title','body','normalized_text','tokens','content_fingerprint','platform','brand','category','shop_id','product_id','item_id','source_record_id',
+                      'source_day','review_day','published_day','price_minor','price_basis','net_weight_g','pack_count','spec_verification','policy_status','document_id',
+                      'content_scope','fulltext_available','is_default','identity_state','category_state','classification_state','rank_number','rank_scope','rating_value','rating_scale']
+
+
+def norm(text):
+    return unicodedata.normalize('NFKC', str(text or '')).casefold()
+
+
+def tokens(text):
+    out=set()
+    for word in re.findall(r'[\u3400-\u9fff]+|[^\W_]+', norm(text), re.UNICODE):
+        if re.fullmatch(r'[\u3400-\u9fff]+',word):
+            out.update(word)
+            out.update(word[i:i+2] for i in range(len(word)-1))
+        else:
+            out.add(word)
+    return sorted(out)
+
+
+def canonical(value):
+    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False)
+
+
+def require(value, code):
+    if not value:
+        from voc_db import LibraryError
+        raise LibraryError(code)
+
+
+def create_index(c):
+    c.executescript('''
+    CREATE TABLE retrieval_documents(
+      doc_id INTEGER PRIMARY KEY, entity TEXT NOT NULL, record_id TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL,
+      normalized_text TEXT NOT NULL, tokens TEXT NOT NULL, content_fingerprint TEXT NOT NULL, platform TEXT, brand TEXT, category TEXT, shop_id TEXT, product_id TEXT, item_id TEXT,
+      source_record_id TEXT NOT NULL REFERENCES source_records(id), source_day TEXT, review_day TEXT, published_day TEXT,
+      price_minor INTEGER, price_basis TEXT, net_weight_g REAL, pack_count INTEGER, spec_verification TEXT, policy_status TEXT, document_id TEXT,
+      content_scope TEXT, fulltext_available INTEGER, is_default INTEGER, identity_state TEXT, category_state TEXT, classification_state TEXT,
+      rank_number INTEGER, rank_scope TEXT, rating_value REAL, rating_scale TEXT, UNIQUE(entity,record_id));
+    CREATE INDEX idx_retrieval_scope ON retrieval_documents(entity,platform,brand,category,doc_id);
+    CREATE INDEX idx_retrieval_brand ON retrieval_documents(brand,entity,doc_id);
+    CREATE INDEX idx_retrieval_category ON retrieval_documents(category,entity,doc_id);
+    CREATE INDEX idx_retrieval_product ON retrieval_documents(product_id,entity,doc_id);
+    CREATE INDEX idx_retrieval_item ON retrieval_documents(item_id,entity,doc_id);
+    CREATE INDEX idx_retrieval_shop ON retrieval_documents(shop_id,entity,doc_id);
+    CREATE INDEX idx_retrieval_document ON retrieval_documents(document_id,entity,doc_id);
+    CREATE INDEX idx_retrieval_policy ON retrieval_documents(policy_status,entity,doc_id);
+    CREATE INDEX idx_retrieval_content ON retrieval_documents(entity,content_fingerprint);
+    CREATE INDEX idx_retrieval_review_date ON retrieval_documents(entity,review_day,doc_id);
+    CREATE INDEX idx_retrieval_source_date ON retrieval_documents(entity,source_day,doc_id);
+    CREATE INDEX idx_retrieval_published_date ON retrieval_documents(entity,published_day,doc_id);
+    CREATE VIRTUAL TABLE retrieval_fts USING fts5(tokens,content='retrieval_documents',content_rowid='doc_id',tokenize='unicode61');
+    ''')
+    c.row_factory = __import__('sqlite3').Row
+    products = {r['id']:dict(r) for r in c.execute('SELECT * FROM products')}
+    docs = {r['id']:dict(r) for r in c.execute('SELECT * FROM policy_documents')}
+    from voc_db import calendar_date
+
+    def add(entity, row, parent=None):
+        p=parent or {}
+        data={col:None for col in PROJECTION_COLUMNS}
+        data.update(entity=entity,record_id=row['id'],title='',body='',source_record_id=row['source_record_id'])
+        if entity=='products':p=row
+        if entity in ('products','reviews','prices','specs','rankings','coverage'):
+            data.update(platform=p.get('marketplace'),brand=p.get('brand_code'),category=p.get('category_code'),shop_id=p.get('shop_id'),product_id=p.get('id'),item_id=p.get('item_id'),classification_state=p.get('classification_state'),title=p.get('title',''))
+        if entity=='products':
+            data['body']=row['title']
+        elif entity=='reviews':
+            # Initial review and append are separate; only decoded textual append
+            # values are indexed, never usernames/ratings/whole JSON as prose.
+            appendix=json.loads(row.get('append_content_json') or 'null')
+            parts=[]
+            if isinstance(appendix,str):parts.append(appendix)
+            elif isinstance(appendix,dict):parts.extend(str(appendix[k]) for k in ('content','comment','appendComment') if isinstance(appendix.get(k),str))
+            elif isinstance(appendix,list):
+                for part in appendix:
+                    if isinstance(part,dict):parts.extend(part[k] for k in ('content','comment') if isinstance(part.get(k),str))
+            data.update(body=row['content']+'\n'+'\n'.join(parts),review_day=row.get('review_calendar_date'),is_default=row.get('is_default'),identity_state=row['identity_state'],category_state=row['category_state'])
+            raw_rating=str(row.get('rating_raw') or '')
+            data.update(rating_value=float(raw_rating) if re.fullmatch(r'-?\d+(?:\.\d+)?',raw_rating) else None,rating_scale=row.get('rating_scale'))
+        elif entity=='prices':data.update(body=row['raw_amount']+' '+row['price_basis'],price_minor=row['amount_minor'],price_basis=row['price_basis'])
+        elif entity=='specs':data.update(body=row.get('spec_text') or '',net_weight_g=row['net_weight_g'],pack_count=row['pack_count'],spec_verification=row['verification'])
+        elif entity=='social':
+            data.update(platform=row['platform'],title=row.get('title') or '',body=row.get('content') or '',content_scope=row['content_scope'],published_day=calendar_date(row['published_at_raw']),fulltext_available=row['fulltext_available'])
+        elif entity=='documents':
+            data.update(title=row['title'],body=row.get('full_text') or '',document_id=row['id'],policy_status=row['declared_status'],fulltext_available=int(bool(row.get('full_text'))))
+        elif entity=='clauses':
+            data.update(title=(p.get('title') or '')+' '+row['clause'],body=row['excerpt']+'\n'+str(row.get('application_note') or ''),document_id=row['document_id'],policy_status=p.get('declared_status'))
+        elif entity=='rankings':data.update(body=str(row.get('title') or ''),rank_number=row.get('rank_number'),rank_scope=row['scope'])
+        elif entity=='coverage':data.update(body=str(row.get('status') or ''))
+        data['source_day']=calendar_date(row.get('source_time_raw'))
+        if entity=='reviews':
+            # Avoid a product title making a review falsely match a pain point.
+            searchable=data['body']
+        else:
+            searchable=data['title']+'\n'+data['body']
+        data['normalized_text']=norm(searchable)
+        data['content_fingerprint']=hashlib.sha256(data['normalized_text'].encode('utf-8')).hexdigest()
+        data['tokens']=' '.join(tokens(searchable))
+        c.execute('INSERT INTO retrieval_documents('+','.join(PROJECTION_COLUMNS)+') VALUES('+','.join('?' for _ in PROJECTION_COLUMNS)+')',[data[k] for k in PROJECTION_COLUMNS])
+
+    for entity,table in ENTITIES.items():
+        for row in c.execute('SELECT * FROM '+table+' ORDER BY id').fetchall():
+            row=dict(row)
+            parent=products.get(row.get('product_id')) if entity not in ('products','documents','clauses','social') else docs.get(row.get('document_id')) if entity=='clauses' else None
+            add(entity,row,parent)
+    c.execute("INSERT INTO retrieval_fts(retrieval_fts) VALUES('rebuild')")
+    c.execute("INSERT INTO retrieval_fts(retrieval_fts) VALUES('optimize')")
+
+
+def catalog(c):
+    tax=json.loads(c.execute("SELECT value FROM meta WHERE key='taxonomy'").fetchone()[0])
+    return {'ok':True,'retrievalContract':VERSION,'readOnly':True,'databaseWrites':0,
+      'entities':{e:{'count':c.execute('SELECT count(*) FROM '+t).fetchone()[0],'fields':sorted(ENTITY_FIELDS[e]|COMMON_FIELDS)} for e,t in ENTITIES.items()},
+      'fields':{k:{'type':typ,'operators':['eq','in','exists']+(['min','max'] if typ in ('number','date') else []),'unit':'CNY分' if k=='priceMinor' else 'g' if k=='netWeightG' else '原文日历日期;时区未统一' if typ=='date' else None} for k,(_,typ) in FIELDS.items()},
+      'taxonomy':tax,'concepts':CONCEPTS,
+      'relationships':['products.id → reviews/prices/specs/rankings/coverage.product_id','documents.id → clauses.document_id','all.source_record_id → source_records.id → source_links/assets/collection_requests','social品牌仅为提及关系,不能推为商品归属'],
+      'questionRoutes':[
+        {'question':'查某品牌全部资料/找某个商品','entities':['products','reviews','prices','specs'],'method':'只填问题实际约束;商品/平台ID优先精确关联;品牌未知、品类未知不影响未限定该维度的查询'},
+        {'question':'份量/便利性/口味/包装等消费反馈','entities':['reviews','social'],'method':'正文概念检索+同义表达+原句回读;平台与商品条件继续AND;社媒与购买评价分层计数'},
+        {'question':'价格带/指定SKU价格/规格/每100g价格','entities':['prices','specs'],'method':'按商品关联、时间和价格口径筛选;价格快照数≠商品数;未核净重不比较单位价格'},
+        {'question':'政策依据/适用条款/营养标签要求','entities':['documents','clauses'],'method':'标题+正文+条款检索;读官方原文;应用提示与法规分开;状态按声明截至日'},
+        {'question':'榜单、新品、增长趋势','entities':['rankings','products','coverage'],'method':'检查是否只有详情页标识及抽样;无完整榜单或连续快照时明确无法证明排名/增长趋势'},
+        {'question':'是否采过、缺什么、来源能否复用','entities':['coverage','products'],'method':'查覆盖与实际记录,并回溯来源请求;失败与无记录分开'},
+        {'question':'已做报告/机会卡/项目/配方/工艺','entities':[],'method':'转ksf-workshop-runtime查询业务库;当前资料缺结构化配方/工艺事实时不得由营销词补造'}],
+      'unsupported':['不提供自然语言自动理解保证或任意语义向量检索;Agent负责问题拆解,后端返回实际展开词','不执行网络采集、不证明全行业覆盖、不从标题猜配方/净重/功效','现网业务库与公开看板未连接本库'],
+      'commands':['search','aggregate','facets','get','source'],'defaultEntities':DEFAULT_ENTITIES,
+      'rules':['仅约束用户实际指定的维度;不得擅自添加/删除条件','正文检索与字段筛选同时满足;概念同义组内部OR,组间AND','facets只枚举可用数据,不能用其结果自动改变原需求','跨类型返回分开计数;不把社媒评论量、观点数、来源数、商品数混算','来源指纹一致才复用,时效不足标缺口']}
+
+
+def normalize_scalar(c, field, value):
+    col,typ=FIELDS[field]
+    if typ=='bool':require(type(value) is bool,'BOOLEAN_REQUIRED:'+field);return int(value)
+    if typ=='number':require(type(value) in (int,float) and math.isfinite(value),'NUMBER_REQUIRED:'+field);return value
+    require(isinstance(value,str) and bool(value.strip()),'TEXT_REQUIRED:'+field)
+    value=value.strip()
+    if typ=='date':
+        from voc_db import calendar_date
+        require(bool(re.fullmatch(r'\d{4}-\d{2}-\d{2}',value)) and calendar_date(value)==value,'ISO_DATE_REQUIRED:'+field)
+    elif field in ('brand','category'):
+        from voc_db import resolve_code
+        tax=json.loads(c.execute("SELECT value FROM meta WHERE key='taxonomy'").fetchone()[0])
+        value=resolve_code(value,tax['brands' if field=='brand' else 'categories'])
+        require(value!='all','OMIT_UNCONSTRAINED_FIELD_INSTEAD_OF_ALL:'+field)
+    elif field=='platform':
+        aliases={'京东':'jd','天猫':'tmall','淘宝或待核':'taobao_unverified','小红书':'xiaohongshu','抖音':'douyin','哔哩哔哩':'bilibili'}
+        value=aliases.get(value,value)
+        require(value in ('jd','tmall','taobao_unverified','xiaohongshu','douyin','bilibili'),'UNKNOWN_PLATFORM')
+    return value
+
+
+def text_groups(request):
+    text=request.get('text',{})
+    require(isinstance(text,dict) and not set(text)-{'all','any','exclude','concepts'},'INVALID_TEXT_QUERY')
+    groups=[];expanded={};excluded=[]
+    for section in ('all','any','exclude','concepts'):
+        require(isinstance(text.get(section,[]),list) and len(text.get(section,[]))<=12,'INVALID_TEXT_GROUPS')
+    for entry in text.get('all',[]):
+        group=[entry] if isinstance(entry,str) else entry.get('any') if isinstance(entry,dict) and set(entry)=={'any'} else None
+        require(isinstance(group,list) and 1<=len(group)<=20,'INVALID_TEXT_GROUP')
+        groups.append(group)
+    if text.get('any'):groups.append(text['any'])
+    for concept in text.get('concepts',[]):
+        require(isinstance(concept,str) and concept in CONCEPTS,'UNKNOWN_CONCEPT')
+        group=CONCEPTS[concept]['terms'];groups.append(group);expanded[concept]=group
+    for group in groups+[text.get('exclude',[])]:
+        for term in group:require(isinstance(term,str) and 0<len(term.strip())<=80 and bool(tokens(term)),'INVALID_LITERAL_TERM')
+    return [[norm(t.strip()) for t in group] for group in groups],[norm(t.strip()) for t in text.get('exclude',[])],expanded
+
+
+def prepare(c,request):
+    require(isinstance(request,dict),'INVALID_SEARCH_REQUEST')
+    allowed={'entities','filters','text','quality','limit','cursor','groupBy'}
+    require(not set(request)-allowed,'UNKNOWN_SEARCH_FIELD')
+    entities=request.get('entities',DEFAULT_ENTITIES)
+    require(isinstance(entities,list) and 0<len(entities)<=len(ENTITIES) and all(isinstance(e,str) and e in ENTITIES for e in entities) and len(entities)==len(set(entities)),'UNKNOWN_OR_DUPLICATE_ENTITY')
+    filters=request.get('filters',{})
+    require(isinstance(filters,dict) and not set(filters)-set(FIELDS),'UNKNOWN_FILTER')
+    quality=request.get('quality','analysis')
+    require(quality in ('analysis','audit'),'UNKNOWN_QUALITY_MODE')
+    if quality=='analysis':require(not set(filters)&{'isDefault','identityState','categoryState'},'USE_AUDIT_MODE_FOR_REVIEW_QUALITY_FILTERS')
+    if 'ratingValue' in filters:require('ratingScale' in filters and 'platform' in filters,'RATING_PLATFORM_AND_SCALE_REQUIRED')
+    clauses=['d.entity IN ('+','.join('?' for _ in entities)+')'];values=list(entities);normalized={}
+    for field,spec in filters.items():
+        applicable=[e for e in entities if field in ENTITY_FIELDS[e]|COMMON_FIELDS]
+        require(bool(applicable),'FILTER_NOT_APPLICABLE:'+field)
+        # A hard filter never gets silently ignored for an entity without it.
+        col,typ=FIELDS[field];column='d.'+col
+        op=spec if isinstance(spec,dict) else {'in':spec} if isinstance(spec,list) else {'eq':spec}
+        require(bool(op) and not set(op)-{'eq','in','exists','min','max'},'UNKNOWN_FILTER_OPERATOR:'+field)
+        require(not ('eq' in op and 'in' in op),'CONFLICTING_FILTER_OPERATOR:'+field)
+        if 'min' in op or 'max' in op:require(typ in ('date','number'),'RANGE_NOT_SUPPORTED:'+field)
+        n={}
+        for operator,value in op.items():
+            if operator=='exists':
+                require(type(value) is bool,'BOOLEAN_REQUIRED:'+field)
+                if field=='brand' and 'social' in applicable:
+                    exists="EXISTS(SELECT 1 FROM social_brand_mentions m WHERE m.post_id=d.record_id)"
+                    clauses.append('('+column+' IS NOT NULL'+" OR (d.entity='social' AND "+exists+'))' if value else '('+column+" IS NULL AND (d.entity!='social' OR NOT "+exists+'))')
+                else:clauses.append(column+(' IS NOT NULL' if value else ' IS NULL'))
+                n[operator]=value
+            elif operator=='in':
+                require(isinstance(value,list) and 1<=len(value)<=100,'INVALID_IN_FILTER:'+field)
+                vals=[normalize_scalar(c,field,v) for v in value]
+                if field=='brand' and 'social' in applicable:
+                    marks=','.join('?' for _ in vals)
+                    clauses.append('('+column+' IN ('+marks+") OR (d.entity='social' AND EXISTS(SELECT 1 FROM social_brand_mentions m WHERE m.post_id=d.record_id AND m.brand_code IN ("+marks+'))))');values+=vals+vals
+                else:clauses.append(column+' IN ('+','.join('?' for _ in vals)+')');values+=vals
+                n[operator]=vals
+            else:
+                val=normalize_scalar(c,field,value)
+                operator_sql={'eq':'=','min':'>=','max':'<='}[operator]
+                if field=='brand' and operator=='eq' and 'social' in applicable:
+                    clauses.append('('+column+"=? OR (d.entity='social' AND EXISTS(SELECT 1 FROM social_brand_mentions m WHERE m.post_id=d.record_id AND m.brand_code=?)))");values += [val,val]
+                else:clauses.append(column+operator_sql+'?');values.append(val)
+                n[operator]=val
+        require(not ('min' in n and 'max' in n) or n['min']<=n['max'],'INVALID_RANGE:'+field)
+        # Restrict to applicable entity types, including exists:false.
+        clauses.append('d.entity IN ('+','.join('?' for _ in applicable)+')');values+=applicable
+        normalized[field]=n
+    if quality=='analysis':
+        clauses.append("(d.entity!='reviews' OR (d.is_default=0 AND d.identity_state='matched' AND d.category_state!='conflict'))")
+        if 'category' in filters:
+            clauses.append("(d.entity!='reviews' OR d.category_state='consistent')")
+        if any(e in entities for e in ('prices',)):
+            clauses.append("(d.entity!='prices' OR d.price_minor IS NOT NULL)")
+    groups,excluded,expansion=text_groups(request)
+    if groups:
+        fts_groups=[]
+        for group in groups:
+            alternatives=[]
+            for term in group:
+                alternatives.append('('+' AND '.join('"'+t.replace('"','""')+'"' for t in tokens(term))+')')
+            fts_groups.append('('+' OR '.join(alternatives)+')')
+        clauses.append('d.doc_id IN (SELECT rowid FROM retrieval_fts WHERE retrieval_fts MATCH ?)');values.append(' AND '.join(fts_groups))
+        for group in groups:
+            clauses.append('('+' OR '.join('instr(d.normalized_text,?)>0' for _ in group)+')');values+=group
+    for term in excluded:clauses.append('instr(d.normalized_text,?)=0');values.append(term)
+    effective={'entities':entities,'filters':normalized,'text':{'groupsAll':groups,'exclude':excluded,'conceptExpansion':expansion},'quality':quality,
+               'defaultReviewPolicy':'非默认+商品身份匹配;指定品类时购买规格也须明确,未指定品类时不强制猜品类' if quality=='analysis' else '审计模式,包含默认/待核/冲突;不得当有效观点样本'}
+    return ' AND '.join(clauses),values,effective
+
+
+def snippet(text,groups):
+    # Normalize only for locating; return the original string and an explicit
+    # matching field. No generative summaries masquerade as source quotations.
+    text=str(text or '');n=norm(text);hits=[t for g in groups for t in g if t in n]
+    index=min((n.find(t) for t in hits),default=0)
+    return {'text':text[max(0,index-55):index+160],'matchedTerms':sorted(set(hits)),'literalMatchOnly':True}
+
+
+def search(c,request,explain=False):
+    where,values,effective=prepare(c,request)
+    limit=request.get('limit',20)
+    require(type(limit) is int and 1<=limit<=100,'INVALID_LIMIT')
+    generation=c.execute("SELECT value FROM meta WHERE key='generation'").fetchone()[0]
+    context=hashlib.sha256(canonical([generation,effective]).encode()).hexdigest()
+    total=c.execute('SELECT count(*) FROM retrieval_documents d WHERE '+where,values).fetchone()[0]
+    totals={r[0]:r[1] for r in c.execute('SELECT entity,count(*) FROM retrieval_documents d WHERE '+where+' GROUP BY entity',values)}
+    text_counts={r[0]:r[1] for r in c.execute('SELECT entity,count(DISTINCT content_fingerprint) FROM retrieval_documents d WHERE '+where+' GROUP BY entity',values)}
+    matched_products=c.execute('SELECT count(DISTINCT product_id) FROM retrieval_documents d WHERE '+where,values).fetchone()[0]
+    if request.get('cursor'):
+        try:
+            cursor=json.loads(base64.urlsafe_b64decode(request['cursor']))
+            require(set(cursor)=={'context','after'} and cursor['context']==context and type(cursor['after']) is int,'CURSOR_SCOPE_MISMATCH')
+        except (ValueError,TypeError,KeyError):
+            require(False,'CURSOR_SCOPE_MISMATCH')
+        where+=' AND d.doc_id>?';values.append(cursor['after'])
+    sql='SELECT d.* FROM retrieval_documents d WHERE '+where+' ORDER BY d.doc_id LIMIT ?'
+    rows=c.execute(sql,values+[limit+1]).fetchall();has_more=len(rows)>limit;rows=rows[:limit]
+    next_cursor=base64.urlsafe_b64encode(canonical(dict(context=context,after=rows[-1]['doc_id'])).encode()).decode() if has_more else None
+    from voc_db import citation
+    records=[]
+    for row in rows:
+        r=dict(row);r.pop('tokens');r.pop('normalized_text');body=r.pop('body')
+        r['match']=snippet(body if r['entity']=='reviews' else r['title']+'\n'+body,effective['text']['groupsAll'])
+        if r['entity']=='clauses':
+            raw=c.execute('SELECT excerpt,application_note,excerpt_type FROM policy_clauses WHERE id=?',(r['record_id'],)).fetchone()
+            r['match']['matchedFields']=[name for name in ('excerpt','application_note') if any(term in norm(raw[name]) for group in effective['text']['groupsAll'] for term in group)]
+            r['match']['excerptType']=raw['excerpt_type']
+            r['match']['applicationNoteIsRegulation']=False
+        if r['entity']=='documents':r['policyStatusAsOf']='2026-11-04'
+        r['citation']=citation(c,r['source_record_id']);r['detailCommand']={'entity':r['entity'],'id':r['record_id']}
+        if r['entity']=='social':r['brandRelationship']='text_mention_only_not_product_ownership'
+        records.append(r)
+    result={'ok':True,'retrievalContract':VERSION,'generation':generation,'readOnly':True,'databaseWrites':0,
+            'effectiveQuery':effective,'totalMatched':total,'countsByEntity':totals,'distinctTextFingerprintsByEntity':text_counts,'distinctLinkedProducts':matched_products,'records':records,'nextCursor':next_cursor,
+            'coverageNotice':'仅为当前资料及实际筛选命中;同义词为显示的有限展开,不能保证所有语义表达均命中。未知的未限定维度保留;指定维度未知则无法满足该条件。'}
+    if explain:result['queryPlan']=[r['detail'] for r in c.execute('EXPLAIN QUERY PLAN '+sql,values+[limit+1])]
+    return result
+
+
+def aggregate(c,request,facets=False):
+    where,values,effective=prepare(c,request)
+    fields=request.get('groupBy',['platform','brand','category'] if facets else ['platform'])
+    require(isinstance(fields,list) and 1<=len(fields)<=4 and all(isinstance(f,str) and f in FIELDS for f in fields) and len(fields)==len(set(fields)),'INVALID_GROUP_BY')
+    require(not ('brand' in fields and 'social' in effective['entities']),'SOCIAL_BRAND_IS_MENTION_NOT_GROUPABLE_OWNERSHIP')
+    limit=request.get('limit',50);require(type(limit) is int and 1<=limit<=100,'INVALID_LIMIT')
+    out={}
+    groups=[[field] for field in fields] if facets else [fields]
+    for group in groups:
+        columns=[FIELDS[f][0] for f in group]
+        select=','.join('d.'+col for col in columns)
+        sql='SELECT '+select+',count(*) AS recordCount,count(DISTINCT d.product_id) AS linkedProductCount,count(DISTINCT d.content_fingerprint) AS distinctTextCount FROM retrieval_documents d WHERE '+where+' GROUP BY '+select+' ORDER BY recordCount DESC,'+select+' LIMIT ?'
+        rows=[dict(row) for row in c.execute(sql,values+[limit+1])]
+        out[','.join(group)]={'rows':rows[:limit],'truncated':len(rows)>limit}
+    return {'ok':True,'retrievalContract':VERSION,'readOnly':True,'databaseWrites':0,'effectiveQuery':effective,'groups':out,
+            'countingBasis':'recordCount是记录数;linkedProductCount是关联商品去重数;distinctTextCount按归一化文字去重,不等于独立消费者/来源或真实购买数;社媒品牌仅提及。'}

+ 64 - 0
server/voc-library/schema.sql

@@ -0,0 +1,64 @@
+PRAGMA foreign_keys=ON;
+CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
+CREATE TABLE batches(id TEXT PRIMARY KEY, archive_name TEXT NOT NULL, archive_sha256 TEXT NOT NULL UNIQUE, imported_at TEXT NOT NULL, rule_version TEXT NOT NULL);
+CREATE TABLE assets(path TEXT PRIMARY KEY, sha256 TEXT NOT NULL, byte_size INTEGER NOT NULL CHECK(byte_size>=0));
+CREATE TABLE collection_requests(asset_path TEXT PRIMARY KEY REFERENCES assets(path), endpoint_id TEXT NOT NULL, run_key TEXT,
+ safe_parameters_json TEXT NOT NULL, collector_status TEXT, http_status INTEGER, upstream_code TEXT, upstream_request_id TEXT,
+ started_at_raw TEXT, finished_at_raw TEXT, record_time_raw TEXT);
+CREATE TABLE source_records(id TEXT PRIMARY KEY, kind TEXT NOT NULL, asset_path TEXT NOT NULL REFERENCES assets(path), line_number INTEGER NOT NULL,
+ payload_sha256 TEXT NOT NULL, payload_json TEXT NOT NULL CHECK(json_valid(payload_json)), UNIQUE(asset_path,line_number));
+CREATE TABLE source_links(record_id TEXT NOT NULL REFERENCES source_records(id), role TEXT NOT NULL, original_path TEXT NOT NULL,
+ asset_path TEXT REFERENCES assets(path), resolution TEXT NOT NULL CHECK(resolution IN ('matched','missing','ambiguous')), PRIMARY KEY(record_id,role,original_path));
+CREATE TABLE brands(code TEXT PRIMARY KEY, name TEXT NOT NULL, aliases_json TEXT NOT NULL);
+CREATE TABLE categories(code TEXT PRIMARY KEY, name TEXT NOT NULL, parent_code TEXT REFERENCES categories(code), definition TEXT NOT NULL);
+CREATE TABLE products(id TEXT PRIMARY KEY, platform_family TEXT NOT NULL CHECK(platform_family IN ('jd','taobao')), item_id TEXT NOT NULL,
+ marketplace TEXT NOT NULL CHECK(marketplace IN ('jd','tmall','taobao_unverified')), marketplace_basis TEXT NOT NULL,
+ shop_id TEXT, shop_name TEXT, title TEXT NOT NULL, url TEXT, brand_code TEXT REFERENCES brands(code), category_code TEXT REFERENCES categories(code),
+ classification_state TEXT NOT NULL CHECK(classification_state IN ('supported','pending')), brand_basis TEXT NOT NULL, category_basis TEXT NOT NULL,
+ source_record_id TEXT NOT NULL REFERENCES source_records(id), source_time_raw TEXT, collected_at_raw TEXT, coverage_type TEXT,
+ UNIQUE(platform_family,item_id));
+CREATE INDEX idx_products_scope ON products(marketplace,brand_code,category_code,classification_state,id);
+CREATE INDEX idx_products_shop ON products(marketplace,shop_id,id);
+CREATE TABLE classification_assertions(id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), dimension TEXT NOT NULL,
+ value TEXT, method TEXT NOT NULL, rule_version TEXT NOT NULL, evidence_record_id TEXT NOT NULL REFERENCES source_records(id), evidence_field TEXT NOT NULL, note TEXT NOT NULL);
+CREATE TABLE product_observations(id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), source_record_id TEXT NOT NULL REFERENCES source_records(id),
+ source_file TEXT, source_time_raw TEXT, collected_at_raw TEXT, query TEXT, acquisition TEXT);
+CREATE TABLE specs(id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), upstream_sku_id TEXT, spec_text TEXT,
+ net_weight_g REAL CHECK(net_weight_g>0), pack_count INTEGER CHECK(pack_count>0), total_weight_g REAL CHECK(total_weight_g>0),
+ verification TEXT NOT NULL CHECK(verification IN ('source_attribute','title_candidate','unknown')), source_record_id TEXT NOT NULL REFERENCES source_records(id),
+ basis TEXT NOT NULL);
+CREATE INDEX idx_specs_product ON specs(product_id,verification,id);
+CREATE TABLE prices(id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), currency TEXT NOT NULL CHECK(currency='CNY'), amount_minor INTEGER CHECK(amount_minor>0),
+ raw_amount TEXT NOT NULL, price_basis TEXT NOT NULL, source_time_raw TEXT, source_time_utc TEXT, source_calendar_date TEXT, collected_at_raw TEXT, collected_at_utc TEXT,
+ price_per_100g REAL, unit_price_verification TEXT NOT NULL CHECK(unit_price_verification IN ('verified','unavailable')),
+ source_record_id TEXT NOT NULL REFERENCES source_records(id));
+CREATE INDEX idx_prices_product_time ON prices(product_id,source_time_utc,id);
+CREATE TABLE price_sources(price_id TEXT NOT NULL REFERENCES prices(id), source_record_id TEXT NOT NULL REFERENCES source_records(id), PRIMARY KEY(price_id,source_record_id));
+CREATE TABLE reviews(id TEXT PRIMARY KEY, platform_family TEXT NOT NULL, item_id TEXT NOT NULL, review_id TEXT NOT NULL, id_method TEXT NOT NULL,
+ product_id TEXT REFERENCES products(id), marketplace_claim TEXT NOT NULL, content TEXT NOT NULL, append_content_json TEXT NOT NULL,
+ rating_raw TEXT, rating_scale TEXT, review_date_raw TEXT, review_date_utc TEXT, review_calendar_date TEXT, purchased_sku_text TEXT,
+ is_default INTEGER CHECK(is_default IN (0,1)), identity_state TEXT NOT NULL CHECK(identity_state IN ('matched','unverified','mismatch','orphan')),
+ category_state TEXT NOT NULL CHECK(category_state IN ('consistent','listing_only','conflict','unknown')), source_time_raw TEXT, collected_at_raw TEXT,
+ source_record_id TEXT NOT NULL REFERENCES source_records(id), UNIQUE(platform_family,item_id,review_id));
+CREATE INDEX idx_reviews_product ON reviews(product_id,is_default,identity_state,category_state,id);
+CREATE INDEX idx_reviews_time ON reviews(review_date_utc,id);
+CREATE TABLE social_posts(id TEXT PRIMARY KEY, platform TEXT NOT NULL, post_id TEXT NOT NULL, title TEXT, content TEXT, url TEXT,
+ content_scope TEXT NOT NULL, published_at_raw TEXT, published_at_utc TEXT, collected_at_raw TEXT, likes INTEGER, comment_count INTEGER, collects INTEGER, shares INTEGER,
+ fulltext_available INTEGER NOT NULL CHECK(fulltext_available IN (0,1)), source_record_id TEXT NOT NULL REFERENCES source_records(id), UNIQUE(platform,post_id));
+CREATE INDEX idx_social_scope ON social_posts(platform,content_scope,id);
+CREATE TABLE social_brand_mentions(post_id TEXT NOT NULL REFERENCES social_posts(id), brand_code TEXT NOT NULL REFERENCES brands(code), basis TEXT NOT NULL,
+ PRIMARY KEY(post_id,brand_code));
+CREATE TABLE policy_documents(id TEXT PRIMARY KEY, title TEXT NOT NULL, declared_status TEXT NOT NULL, status_as_of TEXT NOT NULL,
+ issued_date TEXT, effective_date TEXT, source_url TEXT, expected_sha256 TEXT, attachment_hash_state TEXT NOT NULL, hash_basis TEXT, coverage TEXT, full_text TEXT,
+ source_record_id TEXT NOT NULL REFERENCES source_records(id));
+CREATE INDEX idx_policy_status ON policy_documents(declared_status,id);
+CREATE TABLE policy_clauses(id TEXT PRIMARY KEY, document_id TEXT NOT NULL REFERENCES policy_documents(id), clause TEXT NOT NULL, pdf_page TEXT,
+ excerpt TEXT NOT NULL, excerpt_type TEXT NOT NULL CHECK(excerpt_type IN ('paraphrase_or_transcription','unverified_excerpt')), application_note TEXT,
+ excerpt_method TEXT, source_record_id TEXT NOT NULL REFERENCES source_records(id));
+CREATE INDEX idx_clauses_document ON policy_clauses(document_id,id);
+CREATE TABLE ranking_signals(id TEXT PRIMARY KEY, product_id TEXT REFERENCES products(id), rank_id TEXT, rank_number INTEGER, title TEXT, scope TEXT NOT NULL,
+ source_time_raw TEXT, source_record_id TEXT NOT NULL REFERENCES source_records(id));
+CREATE TABLE coverage_observations(id TEXT PRIMARY KEY, product_id TEXT REFERENCES products(id), status TEXT, available_reviews INTEGER, reported_total TEXT,
+ historical_total_complete INTEGER NOT NULL CHECK(historical_total_complete IN (0,1)), source_record_id TEXT NOT NULL REFERENCES source_records(id));
+CREATE TABLE quality_issues(id TEXT PRIMARY KEY, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, code TEXT NOT NULL, detail TEXT NOT NULL);
+CREATE INDEX idx_issues_entity ON quality_issues(entity_type,entity_id,code);

+ 48 - 0
server/voc-library/taxonomy.json

@@ -0,0 +1,48 @@
+{
+  "version": "ksf.voc-taxonomy.20261105.v1",
+  "brands": [
+    {"code":"ksf","name":"康师傅","aliases":["康师傅","康師傅","Master Kong"]},
+    {"code":"tongyi","name":"统一","aliases":["统一","統一","统一(President)"]},
+    {"code":"baixiang","name":"白象","aliases":["白象"]},
+    {"code":"jinmailang","name":"今麦郎","aliases":["今麦郎"]},
+    {"code":"wugudaochang","name":"五谷道场","aliases":["五谷道场","五谷道場"]},
+    {"code":"akuan","name":"阿宽","aliases":["阿宽"]},
+    {"code":"riqing","name":"日清","aliases":["日清","NISSIN"]},
+    {"code":"nongxin","name":"农心","aliases":["农心","农心(NONGSHIM)","NONGSHIM"]},
+    {"code":"samyang","name":"三养","aliases":["三养","SAMYANG"]},
+    {"code":"youniyimian","name":"有你一面","aliases":["有你一面"]},
+    {"code":"cailinji","name":"蔡林记","aliases":["蔡林记"]},
+    {"code":"yingduo","name":"营多","aliases":["营多","Indomie"]},
+    {"code":"shoutao","name":"寿桃","aliases":["寿桃"]},
+    {"code":"haifusheng","name":"海福盛","aliases":["海福盛"]},
+    {"code":"gudasao","name":"顾大嫂","aliases":["顾大嫂"]},
+    {"code":"zhongjing","name":"仲景","aliases":["仲景"]},
+    {"code":"shizuren","name":"食族人","aliases":["食族人"]},
+    {"code":"xiangnian","name":"想念","aliases":["想念"]},
+    {"code":"chenkeming","name":"陈克明","aliases":["陈克明"]},
+    {"code":"jinshahe","name":"金沙河","aliases":["金沙河"]},
+    {"code":"nanjiecun","name":"南街村","aliases":["南街村"]}
+  ],
+  "categories": [
+    {"code":"noodle_food","name":"面食品类","parent":null,"definition":"仅作父级;分析具体子类必须指定 category,父级不隐含展开。"},
+    {"code":"banmian","name":"拌面","parent":"noodle_food","definition":"标题明确标注拌面/干拌面/干拌手擀面,且没有酱料、原料面或跨品类冲突;热干面、炒面、火鸡面不得仅凭名称自动等同拌面。"},
+    {"code":"reganmian","name":"热干面","parent":"noodle_food","definition":"明确热干面,单独子类;查询拌面不自动包含。"},
+    {"code":"mianpi","name":"面皮凉皮","parent":"noodle_food","definition":"明确面皮/凉皮;即使出现干拌面营销词也优先单列。"},
+    {"code":"fried_noodle","name":"炒面","parent":"noodle_food","definition":"明确炒面;不自动包含在拌面。"},
+    {"code":"soup_noodle","name":"汤面","parent":"noodle_food","definition":"明确汤面/汤达人/汤好喝,不能只凭牛肉口味推定。"},
+    {"code":"instant_unspecified","name":"方便面待细分","parent":"noodle_food","definition":"明确方便面/泡面/杯面/桶面但无可靠食用方式细分。"},
+    {"code":"dry_snack_noodle","name":"干脆面","parent":"noodle_food","definition":"干脆面/干吃面,与冲泡或煮拌不同。"},
+    {"code":"raw_noodle","name":"原料面条","parent":"noodle_food","definition":"挂面/生面等;即使使用建议为拌面仍不当作速食拌面。"},
+    {"code":"sauce","name":"酱料调料","parent":null,"definition":"拌面酱/拌面汁/调味料等;绝不混进拌面商品。"},
+    {"code":"other_food","name":"其他食品","parent":null,"definition":"米饭/火腿/饮料等明确非目标商品,保留源记录。"}
+  ],
+  "marketplaceAliases":{"京东":"jd","jd":"jd","天猫":"tmall","tmall":"tmall","淘宝或待核":"taobao_unverified","taobao_unverified":"taobao_unverified"},
+  "rules": {
+    "classification":"上游品牌字段优先;否则品牌必须出现在去促销前缀后的标题开头,且全标题只出现一个已登记品牌。店名、搜索词、采集任务名不是品牌证据。标题推导记录为 rule_title,不冒称人工核验。",
+    "unknown":"未知、混合、冲突均进 quality_issues;不参与严格三维查询。字典未覆盖的品牌不猜。",
+    "marketplace":"taobao 是来源接口族,不等于天猫。tmall 依赖采集包中的渠道识别证据;taobao_unverified 不能归为已确认淘宝或天猫。",
+    "specs":"标题克重/件数只入 title_candidate,不能直接用于每100克价格;物流 weight 不是食品净含量。",
+    "reviews":"按 platform_family+item_id+review_id 去重并关联同族商品。购买规格存在其他品类信号时排除;规格不明确单列 listing_only。",
+    "time":"无时区的 recordTime 保留原值,UTC 列为空;不能用入库日/文件时间替代来源观察日。"
+  }
+}

+ 138 - 0
server/voc-library/test_board_library.py

@@ -0,0 +1,138 @@
+import sys
+sys.dont_write_bytecode=True
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import sqlite3
+import tempfile
+import unittest
+import board_library as board
+import retrieval
+import update_library as updater
+import voc_db
+
+ROOT=Path(__file__).resolve().parents[2]
+DB=Path(os.environ.get('KSF_TEST_VOC_DB',str(ROOT/'artifacts/康师傅VOC结构化入库-20261105/ksf-voc-library-20261105-r2/library/library.sqlite')))
+
+@unittest.skipUnless(DB.is_file(),'requires private VOC corpus; set KSF_TEST_VOC_DB')
+class BoardLibraryTests(unittest.TestCase):
+    def setUp(self):self.config={'db':str(DB)}
+    def test_real_totals_and_read_only(self):
+        before=updater.digest(DB)
+        r=board.run(self.config,{'op':'summary'})
+        self.assertEqual(r['counts']['products'],4639);self.assertEqual(r['counts']['policy_documents'],25)
+        self.assertEqual(r['counts']['policy_clauses'],31);self.assertEqual(r['analysisCounts']['reviews'],771)
+        self.assertEqual(updater.digest(DB),before);self.assertEqual(r['databaseWrites'],0)
+    def test_exact_scope_facets_and_text(self):
+        p={'op':'browse','entity':'products','filters':{'platform':'jd','brand':'ksf','category':'banmian'}}
+        r=board.run(self.config,p);self.assertGreater(r['totalMatched'],0)
+        self.assertTrue(all(x['platform']=='jd' and x['brand']=='ksf' and x['category']=='banmian' for x in r['records']))
+        p['keyword']='不可能命中的独特测试文字81743';self.assertEqual(board.run(self.config,p)['totalMatched'],0)
+    def test_cursor_bound_to_query_and_version(self):
+        r=board.run(self.config,{'op':'browse','entity':'products'})
+        with self.assertRaises(voc_db.LibraryError):board.run(self.config,{'op':'browse','entity':'products','filters':{'brand':'ksf'},'cursor':r['nextCursor']})
+        with self.assertRaises(voc_db.LibraryError):board.run(self.config,{'op':'summary','generation':'stale'})
+    def test_every_entity_and_source_projection(self):
+        for entity in board.LABELS:
+            with self.subTest(entity=entity):
+                r=board.run(self.config,{'op':'browse','entity':entity})
+                self.assertGreater(r['totalMatched'],0);self.assertNotIn('citation',r['records'][0])
+                d=board.run(self.config,{'op':'detail','entity':entity,'id':r['records'][0]['record_id']})
+                self.assertNotIn('sourcePayload',d);self.assertNotIn('upstreamSources',d['record']['source'])
+                self.assertNotIn('/',d['record']['source']['asset_path'])
+    def test_reader_product_preview_and_clause_source(self):
+        r=board.run(self.config,{'op':'browse','entity':'products'})
+        self.assertTrue(all('分类依据' not in x['preview'] and '分类依据' not in (x.get('shop_name') or '') for x in r['records']))
+        clause=board.run(self.config,{'op':'browse','entity':'clauses'})['records'][0]
+        detail=board.run(self.config,{'op':'detail','entity':'clauses','id':clause['record_id']})['record']
+        self.assertEqual(detail['parentDocument']['id'],detail['document_id'])
+        self.assertTrue(detail['parentDocument']['title']);self.assertTrue(detail['parentDocument']['source_url'])
+    def test_review_quality_and_unsupported_conditions(self):
+        a=board.run(self.config,{'op':'browse','entity':'reviews'})
+        raw=board.run(self.config,{'op':'browse','entity':'reviews','quality':'audit'})
+        self.assertEqual(raw['totalMatched'],2360);self.assertEqual(a['pendingCount'],2360-771)
+        with self.assertRaises(voc_db.LibraryError):board.run(self.config,{'op':'browse','entity':'documents','filters':{'brand':'ksf'}})
+        with self.assertRaises(voc_db.LibraryError):board.run(self.config,{'op':'source','id':'anything'})
+    def test_related_product_counts_are_exact(self):
+        r=board.run(self.config,{'op':'browse','entity':'products','filters':{'brand':'ksf'}})['records'][0]
+        d=board.run(self.config,{'op':'detail','entity':'products','id':r['record_id']})
+        for relation in d['relations']:
+            rows=board.run(self.config,{'op':'browse','entity':relation['entity'],'filters':{'productId':r['record_id']},'quality':'audit'})
+            self.assertEqual(relation['count'],rows['totalMatched'])
+    def test_product_cards_counts_and_inline_details_keep_exact_product_scope(self):
+        before=updater.digest(DB)
+        cards=board.run(self.config,{'op':'browse','entity':'products','filters':{'brand':'ksf','category':'banmian'}})
+        for card in cards['records'][:3]:
+            detail=board.run(self.config,{'op':'detail','entity':'products','id':card['record_id']})
+            for relation in detail['relations']:
+                self.assertEqual(card['relatedCounts'].get(relation['entity'],0),relation['count'])
+        with voc_db.readonly(DB) as c:
+            pid=c.execute('SELECT product_id FROM reviews WHERE product_id IS NOT NULL GROUP BY product_id ORDER BY count(*) DESC LIMIT 1').fetchone()[0]
+        for entity in ('reviews','specs','prices','rankings'):
+            p={'op':'browse','entity':entity,'filters':{'productId':pid},'quality':'audit'}
+            result=board.run(self.config,p)
+            self.assertTrue(all(row['product_id']==pid for row in result['records']))
+            if entity=='reviews':
+                self.assertGreater(result['totalMatched'],20)
+                row=result['records'][0]
+                detail=board.run(self.config,{'op':'detail','entity':entity,'id':row['record_id']})['record']
+                self.assertEqual(row['content'],detail['content'])
+                self.assertIn('purchased_sku_text',row)
+                if result['nextCursor']:
+                    second=board.run(self.config,{**p,'cursor':result['nextCursor']})
+                    self.assertTrue(all(row['product_id']==pid for row in second['records']))
+                    self.assertFalse({r['record_id'] for r in result['records']} & {r['record_id'] for r in second['records']})
+                    with self.assertRaises(voc_db.LibraryError):board.run(self.config,{**p,'filters':{'productId':'jd:100000283767'},'cursor':result['nextCursor']})
+        self.assertEqual(updater.digest(DB),before)
+
+@unittest.skipUnless(DB.is_file(),'requires private VOC corpus; set KSF_TEST_VOC_DB')
+class UpdateTests(unittest.TestCase):
+    def setUp(self):self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name);self.registry=self.root/'registry'
+    def tearDown(self):self.tmp.cleanup()
+    def incoming(self):
+        p=self.root/'incoming.sqlite';shutil.copyfile(DB,p)
+        c=sqlite3.connect(p);c.row_factory=sqlite3.Row
+        c.execute("UPDATE meta SET value='new-generation-test' WHERE key='generation'")
+        c.execute("UPDATE meta SET value=? WHERE key='archive_sha256'",('a'*64,))
+        c.execute("DELETE FROM batches")
+        c.execute('INSERT INTO batches VALUES(?,?,?,?,?)',('a'*64,'新增测试批次.zip','a'*64,'2026-11-06T01:00:00Z','test'))
+        product=dict(c.execute('SELECT * FROM products LIMIT 1').fetchone());product.update(id='jd:test-added',item_id='test-added',title='新增拌面测试商品',marketplace='jd')
+        c.execute('INSERT INTO products('+','.join(product)+') VALUES('+','.join('?' for _ in product)+')',list(product.values()))
+        c.execute("UPDATE reviews SET content='更新后的评价正文' WHERE id=(SELECT id FROM reviews LIMIT 1)")
+        price=dict(c.execute('SELECT * FROM prices WHERE source_time_raw IS NOT NULL LIMIT 1').fetchone())
+        for pid,observed in [('test-reexport-price',price['source_time_raw']),('test-new-price','2026-11-06T10:00:00+08:00')]:
+            row={**price,'id':pid,'source_time_raw':observed}
+            if pid=='test-new-price':row.update(source_time_utc='2026-11-06T02:00:00Z',source_calendar_date='2026-11-06')
+            c.execute('INSERT INTO prices('+','.join(row)+') VALUES('+','.join('?' for _ in row)+')',list(row.values()))
+        for table in ('retrieval_fts','retrieval_documents'):c.execute('DROP TABLE '+table)
+        retrieval.create_index(c);c.commit();c.close();return p
+    def test_dry_run_persistence_merge_provenance_and_rollback(self):
+        updater.publish(DB,self.registry)
+        self.assertFalse(self.registry.exists())
+        first=updater.publish(DB,self.registry,True,'首批');before=(self.registry/first['file']).read_bytes()
+        repeat=updater.publish(DB,self.registry,True);self.assertEqual(repeat['operation'],'already-imported')
+        incoming=self.incoming();second=updater.publish(incoming,self.registry,True,'新增一批')
+        self.assertNotEqual(first['generation'],second['generation']);self.assertEqual((self.registry/first['file']).read_bytes(),before)
+        cfg={'registry':str(self.registry)};s=board.run(cfg,{'op':'summary'})
+        self.assertEqual(voc_db.registry_database(self.registry),self.registry/second['file'])
+        self.assertEqual(s['counts']['products'],4640);self.assertEqual(s['counts']['reviews'],2360);self.assertEqual(len(s['updates']),2)
+        self.assertEqual(s['counts']['prices'],4071)
+        self.assertEqual(second['delta']['added']['products'],1);self.assertEqual(second['delta']['changed']['reviews'],1)
+        with voc_db.readonly(self.registry/second['file']) as c:
+            self.assertGreater(c.execute('SELECT count(*) FROM record_versions').fetchone()[0],0)
+            origin=voc_db.citation(c,c.execute("SELECT source_record_id FROM products WHERE id='jd:test-added'").fetchone()[0])
+            self.assertTrue(origin['archiveSha256s'])
+        updater.publish(None,self.registry,True,rollback=first['generation'])
+        self.assertEqual(board.run(cfg,{'op':'summary'})['counts']['products'],4639)
+    def test_invalid_batch_keeps_current(self):
+        first=updater.publish(DB,self.registry,True)
+        pointer=(self.registry/'current.json').read_bytes()
+        bad=self.root/'bad.sqlite';bad.write_text('bad',encoding='utf-8')
+        with self.assertRaises(sqlite3.DatabaseError):updater.publish(bad,self.registry,True)
+        self.assertEqual((self.registry/'current.json').read_bytes(),pointer)
+        self.assertFalse((self.registry/'.update.lock').exists())
+        self.assertEqual(board.run({'registry':str(self.registry)},{'op':'version'})['generation'],first['generation'])
+
+if __name__=='__main__':unittest.main()

+ 144 - 0
server/voc-library/test_retrieval.py

@@ -0,0 +1,144 @@
+import json
+import unittest
+import test_voc_db as fixture
+import retrieval as r
+
+
+class RetrievalTests(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        fixture.VocLibraryTests.setUpClass()
+        cls.db=fixture.VocLibraryTests.db
+
+    @classmethod
+    def tearDownClass(cls):fixture.VocLibraryTests.tearDownClass()
+
+    def search(self,**request):
+        with fixture.v.readonly(self.db) as c:return r.search(c,request,True)
+
+    def test_no_compulsory_three_dimensions(self):
+        result=self.search(entities=['products'],filters={'brand':'康师傅'})
+        self.assertTrue(any(x['category'] is None for x in result['records']))
+        self.assertTrue(all(x['brand']=='ksf' for x in result['records']))
+        self.assertNotIn('platform',result['effectiveQuery']['filters'])
+
+    def test_category_only_can_find_unknown_brand(self):
+        result=self.search(entities=['products'],filters={'category':'拌面'})
+        self.assertIn('jd:unknown',{x['record_id'] for x in result['records']})
+        self.assertTrue(all(x['category']=='banmian' for x in result['records']))
+
+    def test_product_id_is_sufficient_and_cross_platform_safe(self):
+        result=self.search(entities=['reviews'],filters={'productId':'jd:same'})
+        self.assertEqual({x['product_id'] for x in result['records']},{'jd:same'})
+        self.assertEqual(result['totalMatched'],2)  # includes unclear SKU absent a category constraint
+        self.assertEqual(self.search(entities=['reviews'],filters={'productId':'taobao:same'})['totalMatched'],0)
+
+    def test_actual_constraints_are_preserved(self):
+        result=self.search(entities=['products'],filters={'platform':'京东','brand':'白象','category':'拌面'})
+        self.assertEqual([x['record_id'] for x in result['records']],['jd:other-brand'])
+
+    def test_literal_review_body_not_product_title(self):
+        result=self.search(entities=['reviews'],text={'all':['面量']})
+        self.assertGreater(result['totalMatched'],0)
+        self.assertTrue(all('面量' in x['match']['text'] for x in result['records']))
+        self.assertEqual(self.search(entities=['reviews'],text={'all':['110g']})['totalMatched'],0)
+
+    def test_cjk_two_character_fulltext_and_fts_plan(self):
+        result=self.search(entities=['reviews'],text={'all':['面量']})
+        self.assertTrue(any('VIRTUAL TABLE INDEX' in step for step in result['queryPlan']))
+        self.assertTrue(all('面量' in x['match']['matchedTerms'] for x in result['records']))
+
+    def test_synonyms_visible_and_not_a_new_fact(self):
+        result=self.search(entities=['reviews'],text={'concepts':['portion_size']})
+        self.assertIn('面量',result['effectiveQuery']['text']['conceptExpansion']['portion_size'])
+        self.assertTrue(result['records'][0]['match']['literalMatchOnly'])
+
+    def test_or_within_group_and_between_groups(self):
+        result=self.search(entities=['products'],text={'all':[{'any':['康师傅','白象']},'干拌面']})
+        self.assertTrue(all('干拌面' in x['match']['text'] and ('康师傅' in x['match']['text'] or '白象' in x['match']['text']) for x in result['records']))
+        result=self.search(entities=['products'],text={'all':['干拌面'],'exclude':['白象']})
+        self.assertTrue(all('白象' not in x['match']['text'] for x in result['records']))
+
+    def test_multientity_counts_and_social_mention_relationship(self):
+        result=self.search(entities=['products','social'],filters={'brand':'康师傅'})
+        self.assertIn('social',result['countsByEntity'])
+        social=next(x for x in result['records'] if x['entity']=='social')
+        self.assertEqual(social['brandRelationship'],'text_mention_only_not_product_ownership')
+
+    def test_nonapplicable_filter_never_ignored(self):
+        result=self.search(entities=['reviews','social'],filters={'productId':'jd:same'})
+        self.assertNotIn('social',result['countsByEntity'])
+        with self.assertRaises(ValueError):self.search(entities=['social'],filters={'productId':'jd:same'})
+
+    def test_unknown_dimension_audit_and_not_forced_drop(self):
+        result=self.search(entities=['products'],filters={'brand':{'exists':False}})
+        self.assertGreater(result['totalMatched'],0)
+        self.assertTrue(all(x['brand'] is None for x in result['records']))
+        with self.assertRaises(ValueError):self.search(entities=['reviews'],filters={'isDefault':True})
+        audit=self.search(entities=['reviews'],filters={'isDefault':True},quality='audit')
+        self.assertEqual(audit['totalMatched'],1)
+
+    def test_price_and_spec_range_units(self):
+        result=self.search(entities=['prices'],filters={'priceMinor':{'min':1900,'max':2000},'productId':'jd:same'})
+        self.assertEqual(result['totalMatched'],1)
+        specs=self.search(entities=['specs'],filters={'netWeightG':{'min':100,'max':120},'specVerification':'title_candidate'})
+        self.assertGreater(specs['totalMatched'],0)
+        self.assertTrue(all(x['spec_verification']=='title_candidate' for x in specs['records']))
+
+    def test_dates_filters_are_real_fields_not_import_time(self):
+        result=self.search(entities=['reviews'],filters={'reviewDate':{'min':'2026-11-04','max':'2026-11-04'}})
+        self.assertEqual(result['totalMatched'],2)
+        with self.assertRaises(ValueError):self.search(entities=['reviews'],filters={'reviewDate':{'min':'2026-02-31'}})
+
+    def test_aggregate_distinguishes_records_products(self):
+        with fixture.v.readonly(self.db) as c:
+            result=r.aggregate(c,dict(entities=['reviews'],groupBy=['platform']))
+            row=result['groups']['platform']['rows'][0]
+            self.assertEqual(row['recordCount'],2)
+            self.assertEqual(row['linkedProductCount'],1)
+            self.assertEqual(row['distinctTextCount'],1)
+
+    def test_facets_in_scope(self):
+        with fixture.v.readonly(self.db) as c:
+            result=r.aggregate(c,dict(entities=['products'],filters={'platform':'jd'},groupBy=['brand']),True)
+            self.assertTrue(all(x['recordCount']>0 for x in result['groups']['brand']['rows']))
+
+    def test_pagination_scope_generation(self):
+        first=self.search(entities=['products'],limit=2)
+        second=self.search(entities=['products'],limit=2,cursor=first['nextCursor'])
+        self.assertFalse({x['record_id'] for x in first['records']}&{x['record_id'] for x in second['records']})
+        with self.assertRaises(ValueError):self.search(entities=['products'],filters={'brand':'ksf'},cursor=first['nextCursor'])
+
+    def test_no_sql_or_fts_query_language_execution(self):
+        self.assertEqual(self.search(entities=['reviews'],text={'all':['面量 OR 1=1']})['totalMatched'],0)
+        self.assertEqual(self.search(entities=['products'],filters={'itemId':"same' OR 1=1 --"})['totalMatched'],0)
+        with self.assertRaises(ValueError):self.search(entities=['products'],filters={'customSql':'1=1'})
+
+    def test_catalog_and_detail_path_for_new_entities(self):
+        with fixture.v.readonly(self.db) as c:
+            catalog=r.catalog(c)
+            self.assertIn('specs',catalog['entities']);self.assertIn('rankings',catalog['entities'])
+            result=r.search(c,dict(entities=['specs'],filters={'productId':'jd:same'}))
+            row=fixture.v.get_record(c,'specs',result['records'][0]['record_id'])
+            self.assertEqual(row['record']['verification'],'title_candidate')
+
+    def test_rating_requires_platform_and_original_scale(self):
+        with self.assertRaises(ValueError):self.search(entities=['reviews'],filters={'ratingValue':{'max':2}})
+        result=self.search(entities=['reviews'],filters={'ratingValue':{'min':5},'ratingScale':'jd原始星级','platform':'jd'})
+        self.assertEqual(result['totalMatched'],2)
+
+    def test_policy_analyst_note_is_labelled(self):
+        result=self.search(entities=['clauses'],text={'all':['应用建议']})
+        self.assertEqual(result['totalMatched'],1)
+        match=result['records'][0]['match']
+        self.assertEqual(match['matchedFields'],['application_note'])
+        self.assertFalse(match['applicationNoteIsRegulation'])
+
+    def test_social_brand_existence_is_mentions_not_null_projection(self):
+        self.assertEqual(self.search(entities=['social'],filters={'brand':{'exists':True}})['totalMatched'],1)
+        self.assertEqual(self.search(entities=['social'],filters={'brand':{'exists':False}})['totalMatched'],0)
+        with fixture.v.readonly(self.db) as c:
+            with self.assertRaises(ValueError):r.aggregate(c,dict(entities=['social'],groupBy=['brand']))
+
+
+if __name__=='__main__':unittest.main()

+ 169 - 0
server/voc-library/test_voc_db.py

@@ -0,0 +1,169 @@
+import contextlib
+import importlib.util
+import io
+import json
+from pathlib import Path
+import sqlite3
+import tempfile
+import unittest
+import zipfile
+
+spec = importlib.util.spec_from_file_location('voc_db', Path(__file__).with_name('voc_db.py'))
+v = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(v)
+
+
+class VocLibraryTests(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.temp = tempfile.TemporaryDirectory()
+        cls.base = Path(cls.temp.name)
+        cls.archive = cls.base / 'fixture.zip'
+        cls.db = cls.base / 'library.sqlite'
+        product = lambda iid, title, platform='jd', market='jd': dict(platform=platform, item_id=iid, title=title, marketplace=market, source_file='raw/items.json', source_time='2026-11-05T10:00:00', collected_at='2026-11-05T02:00:00Z', price='19.90', price_type='search_listing_snapshot', shop_id='shop-1')
+        products = [product('same','康师傅干拌面110g*5袋'), product('other-brand','白象干拌面110g*5袋'),
+                    product('same','康师傅干拌面110g*5袋','taobao','tmall'), product('soup','康师傅汤面'),
+                    product('sauce','仲景葱油拌面酱'), product('unknown','这是一款干拌面'),
+                    product('mixed','康师傅统一拌面组合'), product('mixed-category','康师傅拌面汤面组合'),
+                    product('unverified-channel','康师傅拌面','taobao','taobao_unverified')]
+        def review(rid, default=False, match=True, sku='香辣干拌面'):
+            return dict(platform='jd',marketplace='jd',item_id='same',review_id=rid,content='面量有点少',sku=sku,is_default_text=default,
+                        item_identity_match=match,observed_item_id='same',rating='5',rating_scale='jd原始星级',review_date='2026-11-04 10:30:00',source_file='raw/reviews.json')
+        reviews = [review('106582280238337296'),review('default',True),review('bad-identity',match=False),review('wrong-category',sku='红烧汤面'), review('unclear-sku',sku='红烧牛肉5袋')]
+        rows = dict(products=products,reviews=reviews,social=[dict(platform='xiaohongshu',id='post-1',title='康师傅与白象拌面对比',content='',content_scope='packaged_noodles',comments=1000)],
+                    documents=[dict(document_id='standard',title='方便面标准',status_on_20261104='current',source_file='raw/policy.txt',sha256=v.sha(b'policy'))],
+                    clauses=[dict(document_id='standard',clause='2.1',source_excerpt='摘要内容',application_note='应用建议',excerpt_method='人工摘要,非逐字')])
+        with zipfile.ZipFile(cls.archive,'w') as z:
+            for kind,name in v.PRIMARY.items():
+                z.writestr('test/01_最新底库/'+name,'\n'.join(v.canonical(r) for r in rows[kind]))
+            for f in ['items.json','reviews.json']:
+                z.writestr('test/02_采集档案/raw/'+f,'{}')
+            z.writestr('test/02_采集档案/raw/policy.txt','policy')
+        cls.built = v.build(cls.archive,cls.db)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.temp.cleanup()
+
+    def q(self,**overrides):
+        request = dict(entity='products',marketplace='京东',brand='康师傅',category='拌面',limit=10)
+        request.update(overrides)
+        with v.readonly(self.db) as c:
+            return v.query(c,request,explain=True)
+
+    def test_exact_three_dimensions_and_cross_platform_id(self):
+        self.assertEqual([r['id'] for r in self.q()['records']],['jd:same'])
+        self.assertEqual([r['id'] for r in self.q(marketplace='天猫')['records']],['taobao:same'])
+        self.assertEqual([r['id'] for r in self.q(brand='白象')['records']],['jd:other-brand'])
+
+    def test_sauce_soup_mixed_unknown_do_not_enter_banmian(self):
+        allbrands = self.q(brand='all')
+        self.assertEqual({r['id'] for r in allbrands['records']},{'jd:same','jd:other-brand'})
+        self.assertEqual(self.q(category='汤面')['records'][0]['id'],'jd:soup')
+        self.assertEqual(self.q(brand='仲景',category='酱料调料')['records'][0]['id'],'jd:sauce')
+
+    def test_brand_never_comes_from_shop_or_search(self):
+        tax=v.taxonomy()
+        self.assertIsNone(v.classify_brand('干拌面促销',tax)[0])
+        self.assertIsNone(v.classify_brand('康师傅统一拌面',tax)[0])
+        self.assertEqual(v.classify_brand('拌面',tax,'康师傅')[0],'ksf')
+        self.assertIsNone(v.classify_brand('白象拌面',tax,'康师傅')[0])
+
+    def test_tmall_is_not_unverified_taobao(self):
+        self.assertEqual(self.q(marketplace='tmall')['totalMatched'],1)
+        self.assertEqual(self.q(marketplace='taobao_unverified')['records'][0]['id'],'taobao:unverified-channel')
+
+    def test_review_default_identity_and_sku_filters(self):
+        r=self.q(entity='reviews')
+        self.assertEqual([x['review_id'] for x in r['records']],['106582280238337296'])
+        self.assertIsInstance(r['records'][0]['review_id'],str)
+        self.assertEqual(self.q(entity='reviews',includeDefault=True)['totalMatched'],2)
+        self.assertEqual(self.q(entity='reviews',reviewScope='listing')['totalMatched'],2)
+        self.assertEqual(self.q(entity='reviews',marketplace='tmall')['totalMatched'],0)
+
+    def test_zero_is_not_permission_or_query_error(self):
+        self.assertEqual(self.q(brand='白象',category='汤面')['totalMatched'],0)
+        with self.assertRaisesRegex(v.LibraryError,'UNKNOWN_OR_AMBIGUOUS'):
+            self.q(brand='不存在的品牌')
+
+    def test_missing_scope_and_unsupported_filters_rejected(self):
+        with self.assertRaises(v.LibraryError):self.q(brand=None)
+        with self.assertRaisesRegex(v.LibraryError,'UNKNOWN_QUERY_FIELD'):self.q(filters={'brand':'ksf'})
+        with self.assertRaises(v.LibraryError):self.q(**{'from':'2026-11-01','timeBasis':'review_calendar_date'})
+        with self.assertRaises(v.LibraryError):self.q(limit=True)
+
+    def test_cursor_bound_to_scope_and_generation(self):
+        first=self.q(brand='all',limit=1)
+        self.assertIsNotNone(first['nextCursor'])
+        second=self.q(brand='all',limit=1,cursor=first['nextCursor'])
+        self.assertNotEqual(first['records'][0]['id'],second['records'][0]['id'])
+        self.assertIsNone(second['nextCursor'])
+        with self.assertRaisesRegex(v.LibraryError,'CURSOR_SCOPE_MISMATCH'):
+            self.q(brand='ksf',cursor=first['nextCursor'])
+
+    def test_parameterized_sql_injection(self):
+        self.assertEqual(self.q(keyword="' OR 1=1 --")['totalMatched'],0)
+        self.assertEqual(self.q(itemId="same' OR 1=1 --")['totalMatched'],0)
+        with self.assertRaises(v.LibraryError):self.q(entity='products;DROP TABLE products')
+
+    def test_time_not_fabricated_and_date_boundaries(self):
+        r=self.q(entity='reviews')['records'][0]
+        self.assertIsNone(r['review_date_utc'])
+        self.assertEqual(r['review_calendar_date'],'2026-11-04')
+        self.assertEqual(self.q(entity='reviews',timeBasis='review_calendar_date',**{'from':'2026-11-04','to':'2026-11-05'})['totalMatched'],1)
+        self.assertEqual(self.q(entity='reviews',timeBasis='review_calendar_date',**{'from':'2026-11-03','to':'2026-11-04'})['totalMatched'],0)
+        self.assertEqual(self.q(entity='reviews',timeBasis='review_utc',**{'from':'2026-11-01T00:00:00Z'})['totalMatched'],0)
+        with self.assertRaises(v.LibraryError):self.q(entity='reviews',timeBasis='review_utc',**{'from':'2026-11-01T00:00:00'})
+
+    def test_spec_candidates_do_not_create_false_unit_prices(self):
+        s=self.q()['records'][0]['specs'][0]
+        self.assertEqual(s['verification'],'title_candidate')
+        self.assertEqual(s['total_weight_g'],550)
+        p=self.q(entity='prices')['records'][0]
+        self.assertEqual(p['amount_minor'],1990)
+        self.assertIsNone(p['price_per_100g'])
+        self.assertIsNone(p['source_time_utc'])
+        self.assertIsNone(v.money('1?'))
+        self.assertIsNone(v.money('19-29'))
+
+    def test_source_citation_is_resolvable(self):
+        s=self.q()['records'][0]['citation']
+        self.assertEqual(s['locatorType'],'jsonl_line_1_based')
+        self.assertTrue(all(r['resolution']=='matched' for r in s['upstreamSources']))
+        with zipfile.ZipFile(self.archive) as z:
+            self.assertEqual(v.sha(z.read(s['asset_path'])),s['assetSha256'])
+            self.assertEqual(v.sha(z.read(s['asset_path']).decode().splitlines()[s['line_number']-1]),s['payload_sha256'])
+
+    def test_social_mentions_and_counters_are_separate(self):
+        with v.readonly(self.db) as c:
+            r=v.query(c,dict(entity='social',marketplace='xiaohongshu',brand='ksf'))
+            self.assertEqual(r['records'][0]['comment_count'],1000)
+            self.assertEqual(r['records'][0]['fulltext_available'],0)
+            self.assertEqual(c.execute('select count(*) from reviews').fetchone()[0],5)
+            self.assertEqual(c.execute('select count(*) from social_brand_mentions').fetchone()[0],2)
+
+    def test_policy_paraphrase_not_a_quote_and_status_is_dated(self):
+        with v.readonly(self.db) as c:
+            row=v.query(c,dict(entity='clauses',documentId='standard'))['records'][0]
+            self.assertEqual(row['excerpt_type'],'paraphrase_or_transcription')
+            self.assertEqual(row['status_as_of'],'2026-11-04')
+
+    def test_readonly_and_idempotent_build(self):
+        before=v.sha(self.db.read_bytes())
+        with v.readonly(self.db) as c:
+            with self.assertRaises(sqlite3.OperationalError):c.execute('delete from products')
+            self.assertFalse(c.execute('pragma foreign_key_check').fetchall())
+        receipt=v.build(self.archive,self.db)
+        self.assertEqual(receipt['operation'],'unchanged')
+        self.assertEqual(v.sha(self.db.read_bytes()),before)
+
+    def test_foreign_keys_and_index_plan(self):
+        self.assertTrue(any('idx_products_scope' in x for x in self.q()['queryPlan']))
+        # With five fixture reviews SQLite may reasonably prefer a tiny scan.
+        # The full-corpus acceptance separately checks the actual join plan.
+        with v.readonly(self.db) as c:
+            self.assertIsNotNone(c.execute("select 1 from sqlite_master where type='index' and name='idx_reviews_product'").fetchone())
+
+
+if __name__=='__main__':
+    unittest.main()

+ 177 - 0
server/voc-library/update_library.py

@@ -0,0 +1,177 @@
+"""Operator-only batch merge into a new immutable generation, then atomic pointer switch.
+
+Incoming databases must first be built with the existing validated VOC importer.
+Default is a dry plan. Readers continue using the old generation until --apply succeeds.
+"""
+import sys
+sys.dont_write_bytecode=True
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import sqlite3
+import tempfile
+import uuid
+import voc_db
+import retrieval
+
+ORDER=['batches','assets','collection_requests','brands','categories','source_records','source_links','products',
+       'classification_assertions','product_observations','specs','prices','price_sources','reviews','social_posts',
+       'social_brand_mentions','policy_documents','policy_clauses','ranking_signals','coverage_observations','quality_issues']
+STABLE={'products','reviews','social_posts','policy_documents','policy_clauses'}
+def digest(p):
+    with Path(p).open('rb') as f:return hashlib.file_digest(f,'sha256').hexdigest()
+def canonical(v):return json.dumps(v,ensure_ascii=False,sort_keys=True,separators=(',',':'))
+def verified(p):
+    with voc_db.readonly(p) as c:
+        voc_db.require(c.execute('PRAGMA integrity_check').fetchone()[0]=='ok' and not c.execute('PRAGMA foreign_key_check').fetchall(),'DATABASE_CHECK_FAILED')
+        voc_db.require(c.execute('SELECT count(*) FROM retrieval_documents').fetchone()[0]==sum(c.execute('SELECT count(*) FROM '+t).fetchone()[0] for t in retrieval.ENTITIES.values()),'RETRIEVAL_COUNT_MISMATCH')
+        for table in ORDER:
+            voc_db.require(c.execute('SELECT sql FROM sqlite_master WHERE type=? AND name=?',('table',table)).fetchone() is not None,'MISSING_TABLE:'+table)
+        return dict(c.execute('SELECT * FROM meta'))
+
+def merge(current,incoming,dest):
+    im=verified(incoming)
+    if not current:shutil.copyfile(incoming,dest)
+    else:
+        with voc_db.readonly(current) as old:
+            con=sqlite3.connect(dest);old.backup(con);con.close()
+    c=sqlite3.connect(dest);c.row_factory=sqlite3.Row;c.execute('PRAGMA foreign_keys=ON')
+    maps={};delta={'added':{},'changed':{},'duplicate':{}}
+    try:
+        c.execute('CREATE TABLE IF NOT EXISTS source_origins(source_record_id TEXT NOT NULL REFERENCES source_records(id),archive_sha256 TEXT NOT NULL,PRIMARY KEY(source_record_id,archive_sha256))')
+        c.execute('CREATE TABLE IF NOT EXISTS record_versions(entity TEXT NOT NULL,record_id TEXT NOT NULL,batch_sha256 TEXT NOT NULL,record_json TEXT NOT NULL,PRIMARY KEY(entity,record_id,batch_sha256))')
+        baseline=dict(c.execute('SELECT * FROM meta'))
+        c.execute('INSERT OR IGNORE INTO source_origins SELECT id,? FROM source_records',(baseline['archive_sha256'],))
+        if current:
+            with voc_db.readonly(incoming) as inc:
+                for table in ORDER:
+                    info=c.execute('PRAGMA table_info('+table+')').fetchall()
+                    pk=[r['name'] for r in sorted(info,key=lambda r:r['pk']) if r['pk']]
+                    fks=[dict(r) for r in c.execute('PRAGMA foreign_key_list('+table+')')]
+                    mapping={};maps[table]=mapping
+                    for original in inc.execute('SELECT * FROM '+table):
+                        row=dict(original)
+                        for fk in fks:
+                            value=row[fk['from']]
+                            if value is not None:row[fk['from']]=maps.get(fk['table'],{}).get(value,value)
+                        if table=='quality_issues' and row['entity_type']=='source':
+                            row['entity_id']=maps.get('source_records',{}).get(row['entity_id'],row['entity_id'])
+                        columns=list(row)
+                        old=c.execute('SELECT * FROM '+table+' WHERE '+' AND '.join(k+'=?' for k in pk),[row[k] for k in pk]).fetchone()
+                        if table=='batches' and old and old['archive_sha256']==row['archive_sha256']:
+                            mapping[original['id']]=old['id'];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
+                        # The same timestamped price observation may be re-exported under a new source locator.
+                        # Unknown-time observations only deduplicate with identical source bytes.
+                        if table=='prices' and not old:
+                            fields=[k for k in columns if k not in ('id','source_record_id','collected_at_raw','collected_at_utc')]
+                            sql='SELECT p.id FROM prices p JOIN source_records s ON s.id=p.source_record_id WHERE '+' AND '.join('p.'+k+' IS ?' for k in fields)
+                            vals=[row[k] for k in fields]
+                            if not row.get('source_time_raw'):
+                                sql+=' AND s.payload_sha256=(SELECT payload_sha256 FROM source_records WHERE id=?)';vals.append(row['source_record_id'])
+                            same_price=c.execute(sql+' LIMIT 1',vals).fetchone()
+                            if same_price:
+                                mapping[original['id']]=same_price['id'];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
+                        if table in ('brands','categories') and old:
+                            voc_db.require(dict(old)==row,'TAXONOMY_CONFLICT:'+table+':'+str(row[pk[0]]))
+                        same=old and all(old[k]==v for k,v in row.items() if k!='source_record_id')
+                        if same:
+                            delta['duplicate'][table]=delta['duplicate'].get(table,0)+1
+                            if len(pk)==1:mapping[original[pk[0]]]=row[pk[0]]
+                            continue
+                        if old and table not in STABLE:
+                            voc_db.require(len(pk)==1 and pk[0] in ('id','path','asset_path'),'CONFLICTING_RELATION:'+table)
+                            col=pk[0]
+                            if table=='assets':row[col]='batches/'+im['archive_sha256'][:16]+'/'+row[col]
+                            elif table=='collection_requests':raise ValueError('CONFLICTING_COLLECTION_REQUEST')
+                            else:row[col]=hashlib.sha256((im['generation']+':'+str(original[col])).encode()).hexdigest()
+                            existing=c.execute('SELECT * FROM '+table+' WHERE '+col+'=?',(row[col],)).fetchone()
+                            voc_db.require(existing is None or dict(existing)==row,'BATCH_ID_COLLISION')
+                            if existing:
+                                mapping[original[col]]=row[col];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
+                            old=None
+                        if old:
+                            for version,batch in [(dict(old),baseline['archive_sha256']),(row,im['archive_sha256'])]:
+                                c.execute('INSERT OR IGNORE INTO record_versions VALUES(?,?,?,?)',(table,str(row[pk[0]]),batch,canonical(version)))
+                            c.execute('UPDATE '+table+' SET '+','.join(k+'=?' for k in columns if k not in pk)+' WHERE '+' AND '.join(k+'=?' for k in pk),[row[k] for k in columns if k not in pk]+[row[k] for k in pk])
+                            delta['changed'][table]=delta['changed'].get(table,0)+1
+                        else:
+                            c.execute('INSERT INTO '+table+'('+','.join(columns)+') VALUES('+','.join('?' for _ in columns)+')',[row[k] for k in columns])
+                            delta['added'][table]=delta['added'].get(table,0)+1
+                        if len(pk)==1:mapping[original[pk[0]]]=row[pk[0]]
+                for sid in maps['source_records'].values():c.execute('INSERT OR IGNORE INTO source_origins VALUES(?,?)',(sid,im['archive_sha256']))
+            # Merge dictionaries from the actual validated rows, retaining prior definitions.
+            tax=json.loads(baseline['taxonomy'])
+            for key in ('brands','categories'):
+                known={r['code'] for r in tax[key]}
+                tax[key].extend(r for r in json.loads(im['taxonomy'])[key] if r['code'] not in known)
+            c.execute("UPDATE meta SET value=? WHERE key='taxonomy'",(canonical(tax),))
+            for name in ('retrieval_fts','retrieval_documents'):c.execute('DROP TABLE '+name)
+            retrieval.create_index(c)
+            generation=hashlib.sha256(canonical([baseline['generation'],im['generation'],digest(Path(__file__)),delta]).encode()).hexdigest()
+            c.execute("UPDATE meta SET value=? WHERE key='generation'",(generation,))
+        else:
+            delta['added']={t:c.execute('SELECT count(*) FROM '+t).fetchone()[0] for t in ORDER}
+        c.commit()
+    finally:c.close()
+    verified(dest)
+    return delta
+
+def publish(incoming,registry,apply=False,label='资料更新',rollback=None):
+    root=Path(registry).resolve();source=Path(incoming).resolve() if incoming else None
+    pointer=root/'current.json'
+    prior=json.loads(pointer.read_text(encoding='utf-8')) if pointer.exists() else None
+    if not apply:
+        if rollback:
+            voc_db.require(prior and any(x['generation']==rollback for x in prior['history']),'VERSION_NOT_FOUND')
+            return {'ok':True,'readOnly':True,'rollbackGeneration':rollback}
+        meta=verified(source)
+        return {'ok':True,'readOnly':True,'incomingGeneration':meta['generation'],'currentGeneration':prior['generation'] if prior else None,'mode':'merge-batch' if prior else 'initial','label':label}
+    root.mkdir(parents=True,exist_ok=True);os.chmod(root,0o700)
+    lock=root/'.update.lock';fd=os.open(lock,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
+    temp=None;tmp=None
+    try:
+        # Re-read after the exclusive lock. Query readers never need this lock.
+        prior=json.loads(pointer.read_text(encoding='utf-8')) if pointer.exists() else None
+        if rollback:
+            entry=next((x for x in (prior or {}).get('history',[]) if x['generation']==rollback),None)
+            voc_db.require(entry is not None,'VERSION_NOT_FOUND')
+            target=(root/entry['file']).resolve();voc_db.require(target.is_relative_to(root),'INVALID_VERSION_PATH')
+            voc_db.require(digest(target)==entry['databaseSha256'],'ROLLBACK_HASH_MISMATCH');verified(target)
+            result={**entry,'label':'回退至 '+rollback[:12],'updatedAt':voc_db.now(),'history':prior['history']}
+        else:
+            meta=verified(source)
+            if prior and any(x.get('incomingGeneration')==meta['generation'] for x in prior['history']):
+                return {'ok':True,'applied':False,'operation':'already-imported','generation':prior['generation']}
+            current=(root/prior['file']).resolve() if prior else None
+            if current:
+                voc_db.require(current.is_relative_to(root) and digest(current)==prior['databaseSha256'],'CURRENT_HASH_MISMATCH')
+            temp=root/('.build-'+str(uuid.uuid4())+'.sqlite')
+            delta=merge(current,source,temp)
+            finalmeta=verified(temp);generation=finalmeta['generation']
+            versions=root/'versions';versions.mkdir(exist_ok=True);os.chmod(versions,0o700)
+            target=versions/(generation+'.sqlite')
+            if target.exists():
+                voc_db.require(digest(target)==digest(temp),'VERSION_ALREADY_EXISTS_DIFFERENT_CONTENT');temp.unlink();temp=None
+            else:
+                os.chmod(temp,0o600);temp.rename(target);temp=None
+            names={'products':'商品','reviews':'评价','prices':'价格观察','specs':'规格','social_posts':'社媒内容','policy_documents':'政策文件','policy_clauses':'条款','ranking_signals':'榜单线索','coverage_observations':'覆盖记录'}
+            summary='、'.join(names.get(t,t)+'新增'+str(delta['added'].get(t,0))+'/变更'+str(delta['changed'].get(t,0)) for t in retrieval.ENTITIES.values() if delta['added'].get(t,0) or delta['changed'].get(t,0)) or '重复资料,无实体新增'
+            entry={'generation':generation,'incomingGeneration':meta['generation'],'file':target.relative_to(root).as_posix(),'databaseSha256':digest(target),'updatedAt':voc_db.now(),'label':label,'summary':summary,'delta':delta}
+            result={**entry,'history':[entry,*((prior or {}).get('history',[]))]}
+        tmp=root/('.pointer-'+str(uuid.uuid4())+'.json')
+        with tmp.open('x',encoding='utf-8') as f:f.write(canonical(result)+'\n');f.flush();os.fsync(f.fileno())
+        os.chmod(tmp,0o600);os.replace(tmp,pointer);tmp=None
+        return {'ok':True,'applied':True,**result}
+    finally:
+        os.close(fd);lock.unlink()
+        if temp and temp.exists():temp.unlink()
+        if tmp and tmp.exists():tmp.unlink()
+
+if __name__=='__main__':
+    p=argparse.ArgumentParser(description=__doc__);p.add_argument('--incoming');p.add_argument('--registry',required=True);p.add_argument('--label',default='资料更新');p.add_argument('--apply',action='store_true');p.add_argument('--rollback')
+    args=p.parse_args()
+    try:print(json.dumps(publish(args.incoming,args.registry,args.apply,args.label,args.rollback),ensure_ascii=False,indent=2))
+    except Exception as e:print(json.dumps({'ok':False,'error':str(e)},ensure_ascii=False),file=sys.stderr);sys.exit(1)

+ 75 - 0
server/voc-library/verify_corpus.py

@@ -0,0 +1,75 @@
+"""Acceptance checks against this actual, frozen Wang Yong collection."""
+import argparse
+import json
+import statistics
+import time
+from pathlib import Path
+import voc_db as v
+
+
+def verify(db):
+    before = v.sha(Path(db).read_bytes())
+    with v.readonly(db) as c:
+        counts = v.table_counts(c)
+        assert {k:counts[k] for k in ('products','reviews','social_posts','policy_documents','policy_clauses')} == dict(products=4639,reviews=2360,social_posts=3814,policy_documents=25,policy_clauses=31)
+        assert c.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
+        assert not c.execute('PRAGMA foreign_key_check').fetchall()
+        assert c.execute("SELECT count(*) FROM products WHERE coverage_type='recovered_review_reference_partial_catalog'").fetchone()[0] == 4
+        assert c.execute("SELECT count(*) FROM reviews WHERE product_id IS NULL").fetchone()[0] == 0
+        assert c.execute("SELECT count(*) FROM source_links WHERE resolution!='matched'").fetchone()[0] == 0
+        assert c.execute("SELECT count(*) FROM policy_documents WHERE attachment_hash_state='matched'").fetchone()[0] == 25
+        assert c.execute('SELECT sum(is_default=0),sum(is_default=1) FROM reviews').fetchone()[:] == (789,1571)
+        examples, timings = [], []
+        for market in ('jd','tmall'):
+            for entity in ('products','reviews','prices'):
+                request = dict(entity=entity,marketplace=market,brand='ksf',category='banmian',limit=20)
+                result = v.query(c,request,True)
+                assert result['scopeCombination']=='AND'
+                assert any('idx_products_scope' in p for p in result['queryPlan'])
+                if entity=='reviews':assert any('idx_reviews_product' in p for p in result['queryPlan'])
+                if entity=='prices':assert any('idx_prices_product_time' in p for p in result['queryPlan'])
+                cursor, seen = None, set()
+                while True:
+                    page = v.query(c,{**request, **({'cursor':cursor} if cursor else {})})
+                    for row in page['records']:
+                        assert (row['marketplace'],row['brand_code'],row['category_code']) == (market,'ksf','banmian')
+                        assert row['id'] not in seen
+                        assert row['citation']['archiveSha256']
+                        seen.add(row['id'])
+                        if entity=='reviews':
+                            assert row['is_default']==0 and row['identity_state']=='matched' and row['category_state']=='consistent'
+                            assert row['product_id'].startswith('jd:' if market=='jd' else 'taobao:')
+                    cursor=page['nextCursor']
+                    if cursor is None:break
+                assert len(seen)==result['totalMatched']
+                for _ in range(8):
+                    started=time.perf_counter();v.query(c,request);timings.append((time.perf_counter()-started)*1000)
+                examples.append({'filters':request,'totalMatched':result['totalMatched'],'queryPlan':result['queryPlan'],'paginationComplete':True})
+        # All registered brands remain isolated at the SQL layer.
+        for market in ('jd','tmall'):
+            for brand in c.execute('SELECT code FROM brands'):
+                result=v.query(c,dict(entity='products',marketplace=market,brand=brand[0],category='banmian',limit=100))
+                assert all(r['marketplace']==market and r['brand_code']==brand[0] and r['category_code']=='banmian' for r in result['records'])
+        stats={
+            'classification':dict(c.execute('SELECT classification_state,count(*) FROM products GROUP BY 1')),
+            'marketplaces':[dict(r) for r in c.execute('SELECT marketplace,count(*) AS count FROM products GROUP BY 1')],
+            'reviews':[dict(r) for r in c.execute('SELECT marketplace_claim,identity_state,category_state,is_default,count(*) AS count FROM reviews GROUP BY 1,2,3,4')],
+            'quality':dict(c.execute('SELECT code,count(*) FROM quality_issues GROUP BY code')),
+            'validNumericPrices':c.execute('SELECT count(*) FROM prices WHERE amount_minor IS NOT NULL').fetchone()[0],
+            'verifiedUnitPrices':c.execute("SELECT count(*) FROM prices WHERE unit_price_verification='verified'").fetchone()[0],
+            'titleSpecCandidates':c.execute("SELECT count(*) FROM specs WHERE verification='title_candidate'").fetchone()[0],
+            'socialWithoutFulltext':c.execute('SELECT count(*) FROM social_posts WHERE fulltext_available=0').fetchone()[0]
+        }
+        generation=c.execute("SELECT value FROM meta WHERE key='generation'").fetchone()[0]
+    assert before==v.sha(Path(db).read_bytes())
+    return dict(ok=True,schema=v.VERSION,generation=generation,databaseSha256=before,counts=counts,statistics=stats,examples=examples,
+                queryBenchmark=dict(samples=len(timings),medianMs=round(statistics.median(timings),3),maxMs=round(max(timings),3),scope='本机已有连接,查询20条并附来源;不含生产网络/Agent启动耗时'),
+                checks=['integrity','foreign_keys','primary_counts','4_recovered_references','no_orphan_reviews','all_source_paths_matched','25_policy_hashes_with_explicit_basis','default_review_counts','exact_scope','review_sku_identity','all_brand_isolation','complete_keyset_pagination','compound_indexes','readonly_hash_unchanged'],
+                productionDatabaseChanged=False,agentInstalled=False,publicBoardConnected=False)
+
+
+if __name__=='__main__':
+    p=argparse.ArgumentParser();p.add_argument('--db',required=True);p.add_argument('--out',required=True);a=p.parse_args()
+    result=verify(a.db)
+    Path(a.out).write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
+    print(json.dumps({k:val for k,val in result.items() if k not in ('examples','statistics')},ensure_ascii=False,indent=2))

+ 83 - 0
server/voc-library/verify_retrieval.py

@@ -0,0 +1,83 @@
+"""Acceptance for distinct research intents using the actual Wang Yong corpus."""
+import argparse
+import json
+from pathlib import Path
+import statistics
+import time
+import voc_db as v
+import retrieval as r
+
+
+CASES = [
+ ('某品牌的全部商品',dict(entities=['products'],filters={'brand':'康师傅'},limit=20)),
+ ('跨品牌的拌面',dict(entities=['products'],filters={'category':'拌面'},limit=20)),
+ ('京东某品牌拌面',dict(entities=['products'],filters={'platform':'京东','brand':'康师傅','category':'拌面'},limit=20)),
+ ('评价中的份量主题',dict(entities=['reviews'],text={'concepts':['portion_size']},limit=20)),
+ ('品牌份量相关的评价和社媒',dict(entities=['reviews','social'],filters={'brand':'康师傅'},text={'concepts':['portion_size']},limit=20)),
+ ('按商品关联规格、价格和评价',dict(entities=['specs','prices','reviews'],filters={'productId':'jd:100005702440'},limit=20)),
+ ('价格区间与口径',dict(entities=['prices'],filters={'priceMinor':{'min':2000,'max':4000},'priceBasis':'search_listing_snapshot'},limit=20)),
+ ('有标题规格候选的记录',dict(entities=['specs'],filters={'specVerification':'title_candidate','netWeightG':{'min':100,'max':150}},limit=20)),
+ ('营养标签政策全文',dict(entities=['documents'],text={'any':['营养标签','营养成分表']},limit=20)),
+ ('政策条款与原文',dict(entities=['clauses'],filters={'documentId':'GB17400-2015'},limit=20)),
+ ('榜单标识与范围',dict(entities=['rankings'],filters={'rankNumber':{'max':10}},limit=20)),
+ ('采集覆盖',dict(entities=['coverage'],filters={'brand':'康师傅'},limit=20)),
+ ('品牌未知资料审计',dict(entities=['products'],filters={'brand':{'exists':False}},limit=20)),
+]
+
+
+def verify(db):
+    before=v.sha(Path(db).read_bytes());examples=[];timings=[]
+    with v.readonly(db) as c:
+        assert c.execute('PRAGMA integrity_check').fetchone()[0]=='ok'
+        assert not c.execute('PRAGMA foreign_key_check').fetchall()
+        assert c.execute('SELECT count(*) FROM retrieval_documents').fetchone()[0]==21108
+        assert c.execute('SELECT count(*) FROM policy_documents WHERE length(full_text)>0').fetchone()[0]==25
+        for title,q in CASES:
+            result=r.search(c,q,True)
+            assert result['totalMatched']>0,(title,result)
+            for row in result['records']:
+                assert row['citation']['archiveSha256']
+                if row['entity']=='reviews':assert row['is_default']==0 and row['identity_state']=='matched' and row['category_state']!='conflict'
+                if 'platform' in q.get('filters',{}):assert row['platform']=='jd'
+                if 'category' in q.get('filters',{}):assert row['category']=='banmian'
+                if row['entity']!='social' and 'brand' in q.get('filters',{}) and isinstance(q['filters']['brand'],str):assert row['brand']=='ksf'
+                # Detail for every returned entity type must resolve exactly.
+                detail=v.get_record(c,row['entity'],row['record_id'])
+                assert detail['record']['source_record_id']==row['source_record_id']
+            if q.get('text'):
+                assert any('VIRTUAL TABLE INDEX' in step for step in result['queryPlan'])
+                assert all(row['match']['matchedTerms'] for row in result['records'])
+            for _ in range(4):
+                start=time.perf_counter();r.search(c,q);timings.append((time.perf_counter()-start)*1000)
+            examples.append(dict(question=title,request=q,totalMatched=result['totalMatched'],countsByEntity=result['countsByEntity'],
+                distinctTextFingerprintsByEntity=result['distinctTextFingerprintsByEntity'],queryPlan=result['queryPlan'],firstRefs=[dict(entity=x['entity'],id=x['record_id'],sourceId=x['source_record_id']) for x in result['records'][:2]]))
+        # Brand-only must not discard a product merely because category is unknown.
+        brands=r.search(c,dict(entities=['products'],filters={'brand':'ksf'}))
+        expected=c.execute("SELECT count(*) FROM products WHERE brand_code='ksf'").fetchone()[0]
+        assert brands['totalMatched']==expected
+        assert c.execute("SELECT count(*) FROM products WHERE brand_code='ksf' AND category_code IS NULL").fetchone()[0]>0
+        # Entire matching feedback scope pages once; no repeated IDs or silent scope change.
+        q=dict(entities=['reviews'],text={'concepts':['portion_size']},limit=15);seen=set();cursor=None
+        while True:
+            page=r.search(c,{**q,**({'cursor':cursor} if cursor else {})})
+            for x in page['records']:
+                assert x['record_id'] not in seen;seen.add(x['record_id'])
+            cursor=page['nextCursor']
+            if not cursor:break
+        assert len(seen)==page['totalMatched']
+        groups=r.aggregate(c,dict(entities=['reviews'],filters={'brand':'ksf'},groupBy=['platform','category']))
+        assert all(x['recordCount']>=x['distinctTextCount'] and x['recordCount']>=x['linkedProductCount'] for x in groups['groups']['platform,category']['rows'])
+        facets=r.aggregate(c,dict(entities=['products'],filters={'brand':'ksf'},groupBy=['platform','category']),True)
+        generation=c.execute("SELECT value FROM meta WHERE key='generation'").fetchone()[0]
+        counts=v.table_counts(c)
+    assert v.sha(Path(db).read_bytes())==before
+    return dict(ok=True,schema=v.VERSION,generation=generation,databaseSha256=before,counts=counts,scenarios=examples,
+        benchmark=dict(samples=len(timings),medianMs=round(statistics.median(timings),3),maxMs=round(max(timings),3),scope='本地SQLite已有连接;含来源/全文匹配统计,不含Agent或网络耗时'),
+        assertions=['13种真实研究查询','按实际条件而非强制三维','未知未限定字段不导致漏掉相关记录','中文正文全文索引','有限同义词可见','跨类型不混计','关联回读','25份政策全文','条款/应用提示区分','组合索引','范围绑定分页','统计口径分开','查询前后数据库SHA不变'],
+        productionInstalled=False,realMessageVerified=False,publicBoardConnected=False)
+
+
+if __name__=='__main__':
+    p=argparse.ArgumentParser();p.add_argument('--db',required=True);p.add_argument('--out',required=True);a=p.parse_args()
+    result=verify(a.db);Path(a.out).write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
+    print(json.dumps(dict(ok=result['ok'],scenarios=len(result['scenarios']),benchmark=result['benchmark'],generation=result['generation']),ensure_ascii=False,indent=2))

+ 766 - 0
server/voc-library/voc_db.py

@@ -0,0 +1,766 @@
+"""Versioned, offline VOC corpus. Python 3.11+, SQLite, no third-party modules.
+
+Builds ONLY a new database from an immutable archive. Query/get/contract open it
+mode=ro and query_only. This is a research library, not the workshop business DB.
+"""
+import argparse
+import base64
+import collections
+import contextlib
+import datetime as dt
+import decimal
+import hashlib
+import json
+import os
+from pathlib import Path, PurePosixPath
+import re
+import sqlite3
+import sys
+import tempfile
+import time
+import uuid
+import zipfile
+
+VERSION = 'ksf.voc-library.v2'
+HERE = Path(__file__).resolve().parent
+PRIMARY = {'products': '底库商品_4635条.jsonl', 'reviews': '底库评价_2360条.jsonl',
+           'social': '社媒内容_3814条.jsonl', 'documents': '政策与标准_25份.jsonl',
+           'clauses': '政策条款与应用提示_31条.jsonl'}
+TABLES = {'products': 'products', 'reviews': 'reviews', 'prices': 'prices',
+          'social': 'social_posts', 'documents': 'policy_documents', 'clauses': 'policy_clauses'}
+
+
+class LibraryError(ValueError):
+    pass
+
+
+def require(test, code):
+    if not test:
+        raise LibraryError(code)
+
+
+def canonical(value):
+    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False)
+
+
+def sha(value):
+    return hashlib.sha256(value if isinstance(value, bytes) else value.encode('utf-8')).hexdigest()
+
+
+def key(*parts):
+    return sha(canonical(parts))
+
+
+def now():
+    return dt.datetime.now(dt.timezone.utc).isoformat()
+
+
+def utc(value):
+    try:
+        stamp = dt.datetime.fromisoformat(str(value).replace('Z', '+00:00'))
+        if stamp.tzinfo is not None:
+            return stamp.astimezone(dt.timezone.utc).isoformat()
+    except (ValueError, TypeError):
+        pass
+    return None
+
+
+def calendar_date(value):
+    # Calendar component only: deliberately makes no timezone assumption.
+    m = re.match(r'^(\d{4})[-年](\d{1,2})[-月](\d{1,2})', str(value or ''))
+    if m:
+        try:
+            return dt.date(*map(int, m.groups())).isoformat()
+        except ValueError:
+            pass
+    return None
+
+
+def money(value):
+    if value is None or isinstance(value, bool):
+        return None
+    text = str(value).strip()
+    if not re.fullmatch(r'\d+(?:\.\d{1,2})?', text):
+        return None  # No '?', ranges, currency strings or coupon guesses.
+    amount = decimal.Decimal(text)
+    return int(amount * 100) if 0 < amount < 100000000 else None
+
+
+def family(value):
+    return 'taobao' if value in ('taobao', 'taobao_tmall', 'tmall') else value
+
+
+def identifier(value):
+    require(isinstance(value, (str, int)) and not isinstance(value, bool), 'ID_MUST_BE_STRING_OR_INTEGER')
+    # JSON integers retain arbitrary precision; never route through float/JS Number.
+    result = str(value).strip()
+    require(bool(result), 'EMPTY_ID')
+    return result
+
+
+def taxonomy():
+    return json.loads((HERE / 'taxonomy.json').read_text(encoding='utf-8'))
+
+
+def brand_hits(text, tax):
+    text = str(text or '').casefold()
+    return [b for b in tax['brands'] if any(a.casefold() in text for a in b['aliases'])]
+
+
+def classify_brand(title, tax, upstream=None):
+    hits = brand_hits(title, tax)
+    clean = re.sub(r'^(?:\s|【[^】]*】|\[[^\]]*\])*', '', title)
+    prefix = [b for b in hits if any(clean.casefold().startswith(a.casefold()) for a in b['aliases'])]
+    if upstream:
+        direct = [b for b in tax['brands'] if upstream.casefold() in [a.casefold() for a in b['aliases']]]
+        if len(direct) == 1 and (not hits or {b['code'] for b in hits} == {direct[0]['code']}):
+            return direct[0]['code'], 'source_attribute', None
+        return None, 'conflicting_or_unregistered_attribute', 'BRAND_CONFLICT_OR_UNKNOWN'
+    if len(hits) == len(prefix) == 1:
+        return prefix[0]['code'], 'rule_title_prefix', None
+    return None, 'unresolved_title', 'BRAND_AMBIGUOUS' if len(hits) > 1 else 'BRAND_UNCONFIRMED'
+
+
+def classify_category(title):
+    # Conservative business taxonomy; platform catalog codes are NOT this taxonomy.
+    text = str(title or '')
+    if re.search(r'补邮|邮费|差价|运费|购物袋|餐具', text) and not re.search(r'方便面|拌面|泡面', text):
+        return None, 'non_product_link', 'NON_PRODUCT_LINK'
+    if re.search(r'拌面酱|拌面汁|葱油酱|调味料|面条酱|拌面专用酱|拌面调料|炸酱罐头', text):
+        return 'sauce', 'rule_title_sauce', None
+    if re.search(r'挂面|生面条|生鲜面条', text):
+        return 'raw_noodle', 'rule_title_raw_noodle', None
+    if re.search(r'火腿|午餐肉|烤肠|拌饭|米饭|饮料|蛋糕|饼干', text) and not re.search(r'方便面|拌面|泡面|拉面', text):
+        return 'other_food', 'rule_title_other', None
+    labels = []
+    for code, pattern in [('mianpi', r'面皮|凉皮'), ('reganmian', r'热干面'),
+                          ('soup_noodle', r'汤面|汤达人|汤好喝'), ('dry_snack_noodle', r'干脆面|干吃面'),
+                          ('fried_noodle', r'炒面'), ('banmian', r'拌面|干拌手擀面')]:
+        if re.search(pattern, text):
+            labels.append(code)
+    # Explicit subtypes take precedence over the generic marketing term 拌面.
+    if 'banmian' in labels and any(x in labels for x in ('mianpi', 'reganmian', 'fried_noodle')):
+        labels.remove('banmian')
+    if len(labels) == 1:
+        return labels[0], 'rule_title_explicit', None
+    if len(labels) > 1:
+        return None, 'mixed_category_title', 'CATEGORY_CONFLICT'
+    if re.search(r'方便面|泡面|桶面|杯面|碗面|袋面', text):
+        return 'instant_unspecified', 'rule_title_unspecified', None
+    return None, 'unresolved_title', 'CATEGORY_UNCONFIRMED'
+
+
+def insert(c, table, row):
+    # table/column names originate from code only, never from a query request.
+    c.execute('INSERT INTO ' + table + '(' + ','.join(row) + ') VALUES(' + ','.join('?' for _ in row) + ')', list(row.values()))
+
+
+class Archive:
+    def __init__(self, path):
+        self.path = Path(path)
+        self.z = zipfile.ZipFile(path)
+        self.files = {}
+        for info in self.z.infolist():
+            if info.is_dir():
+                continue
+            parts = PurePosixPath(info.filename).parts
+            require(not info.filename.startswith('/') and '..' not in parts and '\\' not in info.filename, 'UNSAFE_ARCHIVE_PATH')
+            require(info.filename not in self.files, 'DUPLICATE_ARCHIVE_MEMBER')
+            self.files[info.filename] = info
+        roots = {n.split('/')[0] for n in self.files}
+        require(len(roots) == 1, 'ARCHIVE_ROOT_AMBIGUOUS')
+        self.root = next(iter(roots)) + '/'
+        self.latest = self.root + '01_最新底库/'
+        self.cache = {}
+
+    def resolve(self, value):
+        if not value:
+            return None, 'missing'
+        text = str(value).replace('\\', '/').lstrip('./')
+        if text in self.files:
+            return text, 'matched'
+        if '/voc-research/' in text:
+            text = 'voc-research/' + text.split('/voc-research/', 1)[1]
+        if re.match(r'^[A-Za-z]:/', text):
+            return None, 'missing'  # Never guess by basename of an unknown path.
+        if text not in self.cache:
+            hits = [p for p in self.files if p.endswith('/' + text)]
+            self.cache[text] = (hits[0], 'matched') if len(hits) == 1 else (None, 'ambiguous' if hits else 'missing')
+        return self.cache[text]
+
+
+def build(archive_path, db_path):
+    path, dest = Path(archive_path).resolve(), Path(db_path).resolve()
+    require(path.is_file() and path != dest, 'ARCHIVE_NOT_FOUND_OR_BAD_TARGET')
+    with path.open('rb') as f:
+        digest = hashlib.file_digest(f, 'sha256').hexdigest()
+    tax = taxonomy()
+    generation = key(VERSION, digest, sha((HERE / 'schema.sql').read_bytes()), sha((HERE / 'taxonomy.json').read_bytes()), sha(Path(__file__).read_bytes()), sha((HERE / 'retrieval.py').read_bytes()))
+    if dest.exists():
+        with readonly(dest) as c:
+            require(dict(c.execute('SELECT key,value FROM meta')).get('generation') == generation, 'TARGET_EXISTS_DIFFERENT_GENERATION')
+            return {'ok': True, 'operation': 'unchanged', 'generation': generation, 'archiveSha256': digest, 'counts': table_counts(c)}
+    dest.parent.mkdir(parents=True, exist_ok=True)
+    temp = dest.with_name(dest.name + '.build-' + str(uuid.uuid4()))
+    a = Archive(path)
+    started = time.perf_counter()
+    try:
+        c = sqlite3.connect(temp)
+        c.executescript((HERE / 'schema.sql').read_text(encoding='utf-8'))
+        with c:
+            for k, v in {'schema': VERSION, 'generation': generation, 'archive_sha256': digest, 'taxonomy': canonical(tax), 'status': 'building'}.items():
+                insert(c, 'meta', {'key': k, 'value': v})
+            insert(c, 'batches', {'id': digest, 'archive_name': path.name, 'archive_sha256': digest, 'imported_at': now(), 'rule_version': tax['version']})
+            for name, info in a.files.items():
+                with a.z.open(name) as f:
+                    h = hashlib.file_digest(f, 'sha256').hexdigest()
+                insert(c, 'assets', {'path': name, 'sha256': h, 'byte_size': info.file_size})
+                if name.endswith('.json') and ('/raw/' in name or '/raw-' in name):
+                    try:
+                        request = json.loads(a.z.read(name))
+                    except (ValueError, UnicodeError):
+                        continue
+                    if isinstance(request, dict) and isinstance(request.get('id'), str) and isinstance(request.get('params'), dict):
+                        response = request.get('result', {})
+                        data = response.get('data', {}) if isinstance(response, dict) else {}
+                        data = data if isinstance(data, dict) else {}
+                        safe = {k:v for k,v in request['params'].items() if k in ('keyword','q','page','pageSize','shopId','itemId','tmall','sort','orderType','maxPages','rankId')}
+                        insert(c, 'collection_requests', dict(asset_path=name, endpoint_id=request['id'], run_key=request.get('usageTraceId'),
+                               safe_parameters_json=canonical(safe), collector_status=str(request.get('status') or 'unknown'),
+                               http_status=request.get('httpStatus') if type(request.get('httpStatus')) is int else None,
+                               upstream_code=str(data['code']) if data.get('code') is not None else None, upstream_request_id=data.get('requestId'),
+                               started_at_raw=request.get('startedAt'), finished_at_raw=request.get('finishedAt'), record_time_raw=data.get('recordTime')))
+            for b in tax['brands']:
+                insert(c, 'brands', {'code': b['code'], 'name': b['name'], 'aliases_json': canonical(b['aliases'])})
+            for cat in tax['categories']:
+                insert(c, 'categories', {'code': cat['code'], 'name': cat['name'], 'parent_code': cat['parent'], 'definition': cat['definition']})
+
+            def issue(entity, eid, code, detail):
+                c.execute('INSERT OR IGNORE INTO quality_issues VALUES(?,?,?,?,?)', (key(entity, eid, code, detail), entity, eid, code, detail))
+
+            def source(kind, asset, line, value, raw=None):
+                rid = key(asset, line)
+                insert(c, 'source_records', {'id': rid, 'kind': kind, 'asset_path': asset, 'line_number': line,
+                       'payload_sha256': sha(raw if raw is not None else canonical(value)), 'payload_json': canonical(value)})
+                for role in ('source_file', 'text_file', 'pdf_file'):
+                    if value.get(role):
+                        link(rid, role, value[role])
+                for p in value.get('provenance', []):
+                    if p.get('source_file'):
+                        link(rid, 'provenance', p['source_file'])
+                return rid
+
+            def link(rid, role, original):
+                target, resolution = a.resolve(original)
+                c.execute('INSERT OR IGNORE INTO source_links VALUES(?,?,?,?,?)', (rid, role, original, target, resolution))
+                if resolution != 'matched':
+                    issue('source', rid, 'SOURCE_PATH_' + resolution.upper(), role + ': ' + original)
+
+            def primary(kind):
+                asset = a.latest + PRIMARY[kind]
+                require(asset in a.files, 'PRIMARY_FILE_MISSING:' + kind)
+                for line, raw in enumerate(a.z.read(asset).decode('utf-8-sig').splitlines(), 1):
+                    if raw.strip():
+                        value = json.loads(raw)
+                        require(isinstance(value, dict), 'INVALID_RECORD')
+                        yield value, source(kind, asset, line, value, raw)
+
+            legacy = {}
+            dbasset = a.latest + '方便面VOC扩充底库.sqlite'
+            if dbasset in a.files:
+                # Treat supplied SQL/scripts as data, never execute their instructions.
+                with tempfile.TemporaryDirectory(prefix='ksf-voc-source-', dir=dest.parent) as td:
+                    original = Path(td) / 'source.sqlite'
+                    original.write_bytes(a.z.read(dbasset))
+                    with readonly(original, check_version=False) as old:
+                        for table in ('product_details', 'prices', 'ranking_signals', 'review_coverage', 'partial_catalog'):
+                            exists = old.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
+                            legacy[table] = [(line, json.loads(raw)) for line, raw in old.execute('SELECT row_id,payload FROM ' + table)] if exists else []
+            details = collections.defaultdict(list)
+            for line, row in legacy.get('product_details', []):
+                sid = source('legacy_product_detail', dbasset, line * 10 + 1, row)
+                details[(family(row.get('platform')), str(row.get('item_id')))].append((row, sid))
+            product_rows = list(primary('products'))
+            # Four reviewed listings are absent from the latest product export.
+            # Recover only exact referenced IDs from the preserved partial catalog,
+            # count these separately, and retain its partial-coverage designation.
+            required_review_ids = {(family(r.get('platform')), str(r.get('item_id'))) for r in map(json.loads, a.z.read(a.latest + PRIMARY['reviews']).decode('utf-8-sig').splitlines()) if r}
+            known_ids = {(family(r.get('platform')), str(r.get('item_id'))) for r, _ in product_rows}
+            for line, row in legacy.get('partial_catalog', []):
+                pair = (family(row.get('platform')), str(row.get('item_id')))
+                if pair in required_review_ids and pair not in known_ids:
+                    sid = source('legacy_partial_catalog', dbasset, line * 10 + 5, row)
+                    # The original catalog explicitly identifies this channel;
+                    # don't use the taobao_tmall alias alone as proof.
+                    market = 'tmall' if '天猫' in str(row.get('shop_name')) else 'taobao_unverified'
+                    recovered = {**row, 'marketplace':market, 'marketplace_evidence':'preserved_partial_catalog_explicit_tmall_shop' if market == 'tmall' else 'unverified_partial_catalog', 'coverage_type':'recovered_review_reference_partial_catalog'}
+                    product_rows.append((recovered, sid))
+                    known_ids.add(pair)
+            for row, sid in product_rows:
+                fam, iid = family(row.get('platform')), identifier(row['item_id'])
+                require(fam in ('jd', 'taobao'), 'UNKNOWN_PRODUCT_PLATFORM')
+                pid = fam + ':' + iid
+                market = row.get('marketplace')
+                require(market in (('jd',) if fam == 'jd' else ('tmall', 'taobao_unverified')), 'MARKETPLACE_FAMILY_CONFLICT')
+                title = str(row.get('title') or '')
+                detail_rows = details.get((fam, iid), [])
+                upstream_brands = {r.get('product', {}).get('brandName') for r, _ in detail_rows if r.get('product', {}).get('brandName')}
+                upstream = next(iter(upstream_brands)) if len(upstream_brands) == 1 else None
+                brand, bbasis, bissue = classify_brand(title, tax, upstream)
+                if len(upstream_brands) > 1:
+                    brand, bbasis, bissue = None, 'conflicting_attributes', 'BRAND_CONFLICT'
+                category, cbasis, cissue = classify_category(title)
+                state = 'supported' if brand and category else 'pending'
+                insert(c, 'products', dict(id=pid, platform_family=fam, item_id=iid, marketplace=market, marketplace_basis=str(row.get('marketplace_evidence') or 'collector_marketplace_assertion'),
+                       shop_id=str(row.get('shop_id') or '') or None, shop_name=row.get('shop_name'), title=title, url=row.get('url'), brand_code=brand,
+                       category_code=category, classification_state=state, brand_basis=bbasis, category_basis=cbasis, source_record_id=sid,
+                       source_time_raw=row.get('source_time'), collected_at_raw=row.get('collected_at'), coverage_type=row.get('coverage_type')))
+                for dimension, value, basis, evidence, field in [('brand', brand, bbasis, detail_rows[0][1] if upstream and detail_rows else sid, 'product.brandName' if upstream else 'title'),
+                                                                ('category', category, cbasis, sid, 'title')]:
+                    insert(c, 'classification_assertions', dict(id=key(pid, dimension), product_id=pid, dimension=dimension, value=value, method=basis,
+                           rule_version=tax['version'], evidence_record_id=evidence, evidence_field=field, note='来源属性或保守标题规则;不是人工复核,也不是品牌真伪验证。'))
+                for code in (bissue, cissue):
+                    if code:
+                        issue('product', pid, code, title)
+                prov = row.get('provenance') or [row]
+                seen = set()
+                for p in prov:
+                    oid = key(pid, {k: p.get(k) for k in ('source_file', 'source_time', 'collected_at', 'query', 'acquisition')})
+                    if oid not in seen:
+                        insert(c, 'product_observations', dict(id=oid, product_id=pid, source_record_id=sid, source_file=p.get('source_file'), source_time_raw=p.get('source_time'), collected_at_raw=p.get('collected_at'), query=p.get('query'), acquisition=p.get('acquisition')))
+                        seen.add(oid)
+                matches = list(re.finditer(r'(\d+(?:\.\d+)?)\s*(?:g|克)\s*[*×xX]\s*(\d+)\s*(?:包|袋|桶|碗|盒|杯)', title, re.I))
+                match = matches[0] if len(matches) == 1 else None
+                insert(c, 'specs', dict(id=key(pid, 'title_spec'), product_id=pid, upstream_sku_id=iid if fam == 'jd' and detail_rows else None,
+                       spec_text=match.group(0) if match else None, net_weight_g=float(match[1]) if match and float(match[1]) > 0 else None,
+                       pack_count=int(match[2]) if match and int(match[2]) > 0 else None, total_weight_g=float(match[1]) * int(match[2]) if match and float(match[1]) * int(match[2]) > 0 else None,
+                       verification='title_candidate' if match else 'unknown', source_record_id=sid, basis='标题声明,未核净含量/赠品/面饼与调料组成;不生成每100g价格。'))
+                if row.get('price') is not None:
+                    add_price(c, pid, row.get('price'), row.get('price_type') or 'unknown', row, sid, issue)
+                if row.get('promotional_price') is not None:
+                    issue('product', pid, 'PROMOTIONAL_PRICE_UNRESOLVED', '促销价格口径/适用条件未映射,保留原记录,不混作搜索展示价。')
+
+            for row, sid in primary('reviews'):
+                fam, iid, reviewid = family(row.get('platform')), identifier(row['item_id']), identifier(row['review_id'])
+                require(fam in ('jd', 'taobao'), 'UNKNOWN_REVIEW_PLATFORM')
+                rid, pid = fam + ':' + iid + ':' + reviewid, fam + ':' + iid
+                product = c.execute('SELECT marketplace,category_code FROM products WHERE id=?', (pid,)).fetchone()
+                identity = 'orphan' if not product else 'mismatch' if row.get('item_identity_match') is False or row.get('observed_item_id') and str(row['observed_item_id']) != iid or product[0] != row.get('marketplace') else 'matched' if row.get('item_identity_match') is True else 'unverified'
+                sku = str(row.get('sku') or '')
+                scategory, _, sku_issue = classify_category(sku)
+                category_state = 'unknown' if not product or not product[1] else 'conflict' if sku_issue == 'CATEGORY_CONFLICT' or scategory and scategory != 'instant_unspecified' and scategory != product[1] else 'consistent' if scategory == product[1] else 'listing_only'
+                default = int(row['is_default_text']) if isinstance(row.get('is_default_text'), bool) else None
+                insert(c, 'reviews', dict(id=rid, platform_family=fam, item_id=iid, review_id=reviewid, id_method=row.get('review_id_method') or 'unspecified_not_verified',
+                       product_id=pid if product else None, marketplace_claim=row.get('marketplace') or 'unknown', content=str(row.get('content') or ''),
+                       append_content_json=canonical(row.get('append_content')), rating_raw=str(row['rating']) if row.get('rating') is not None else None,
+                       rating_scale=row.get('rating_scale'), review_date_raw=row.get('review_date'), review_date_utc=utc(row.get('review_date')), review_calendar_date=calendar_date(row.get('review_date')),
+                       purchased_sku_text=sku, is_default=default, identity_state=identity, category_state=category_state, source_time_raw=row.get('source_time'), collected_at_raw=row.get('collected_at'), source_record_id=sid))
+                for code, enabled in [('REVIEW_IDENTITY_' + identity.upper(), identity != 'matched'), ('REVIEW_CATEGORY_' + category_state.upper(), category_state in ('conflict', 'unknown')), ('REVIEW_DEFAULT_UNKNOWN', default is None)]:
+                    if enabled:
+                        issue('review', rid, code, '不得按标题/昵称模糊关联其他商品或平台。')
+
+            for row, sid in primary('social'):
+                platform, postid = str(row['platform']), identifier(row['id'])
+                soid = platform + ':' + postid
+                counts = {name: row.get(field) if isinstance(row.get(field), int) and not isinstance(row.get(field), bool) and row[field] >= 0 else None for name, field in [('likes','likes'), ('comment_count','comments'), ('collects','collects'), ('shares','shares')]}
+                insert(c, 'social_posts', dict(id=soid, platform=platform, post_id=postid, title=row.get('title'), content=row.get('content'), url=row.get('url'),
+                       content_scope=row.get('content_scope') or 'unknown', published_at_raw=row.get('published_at'), published_at_utc=utc(row.get('published_at')),
+                       collected_at_raw=row.get('collected_at'), fulltext_available=int(bool(str(row.get('content') or '').strip())), source_record_id=sid, **counts))
+                for b in brand_hits(str(row.get('title') or '') + '\n' + str(row.get('content') or ''), tax):
+                    insert(c, 'social_brand_mentions', dict(post_id=soid, brand_code=b['code'], basis='text_mention_only_not_product_ownership'))
+
+            for row, sid in primary('documents'):
+                matches = [p for p in (row.get('pdf_file'), row.get('text_file'), row.get('source_file')) if p]
+                hashes = [c.execute('SELECT sha256 FROM assets WHERE path=?', (a.resolve(p)[0],)).fetchone() for p in matches]
+                hash_basis = next((a.resolve(p)[0] + '#bytes' for p, h in zip(matches, hashes) if h and h[0] == row.get('sha256')), None)
+                if not hash_basis and row.get('source_file'):
+                    source_path, _ = a.resolve(row['source_file'])
+                    if source_path and source_path.endswith('.json'):
+                        doc = json.loads(a.z.read(source_path))
+                        # The expansion collector hashes the decoded `text`,
+                        # whereas earlier collectors hash a complete JSON/PDF.
+                        if isinstance(doc, dict) and isinstance(doc.get('text'), str) and sha(doc['text']) == row.get('sha256'):
+                            hash_basis = source_path + '#$.text (UTF-8 decoded text)'
+                hash_state = 'matched' if hash_basis else 'unresolved'
+                full_text = None
+                text_path, _ = a.resolve(row.get('text_file'))
+                if text_path:
+                    full_text = a.z.read(text_path).decode('utf-8-sig')
+                elif row.get('source_file'):
+                    json_path, _ = a.resolve(row['source_file'])
+                    if json_path and json_path.endswith('.json'):
+                        document_json = json.loads(a.z.read(json_path))
+                        if isinstance(document_json, dict) and isinstance(document_json.get('text'), str):
+                            full_text = document_json['text']
+                insert(c, 'policy_documents', dict(id=identifier(row['document_id']), title=str(row['title']), declared_status=str(row.get('status_on_20261104') or 'unknown'), status_as_of='2026-11-04',
+                       issued_date=row.get('issued_date'), effective_date=row.get('effective_date'), source_url=row.get('source_url'), expected_sha256=row.get('sha256'),
+                       attachment_hash_state=hash_state, hash_basis=hash_basis, coverage=row.get('coverage'), full_text=full_text, source_record_id=sid))
+                if hash_state != 'matched':
+                    issue('document', row['document_id'], 'ATTACHMENT_HASH_UNRESOLVED', '需核对声明的 SHA 对应哪份附件;不能声称完整原文已逐字核验。')
+            for row, sid in primary('clauses'):
+                method = str(row.get('excerpt_method') or '')
+                insert(c, 'policy_clauses', dict(id=key(row['document_id'], row['clause']), document_id=row['document_id'], clause=str(row['clause']), pdf_page=str(row['pdf_page']) if row.get('pdf_page') is not None else None,
+                       excerpt=str(row.get('source_excerpt') or ''), excerpt_type='paraphrase_or_transcription' if '非逐字' in method or '摘要' in method else 'unverified_excerpt',
+                       application_note=row.get('application_note'), excerpt_method=method or None, source_record_id=sid))
+
+            for kind in ('prices', 'ranking_signals', 'review_coverage'):
+                for line, row in legacy.get(kind, []):
+                    sid = source('legacy_' + kind, dbasset, line * 10 + {'prices': 2, 'ranking_signals': 3, 'review_coverage': 4}[kind], row)
+                    pid = str(family(row.get('platform'))) + ':' + str(row.get('item_id'))
+                    exists = c.execute('SELECT 1 FROM products WHERE id=?', (pid,)).fetchone()
+                    if kind == 'prices':
+                        if exists:
+                            add_price(c, pid, row.get('price_raw', row.get('price_cny')), str(row.get('price_type') or 'unknown'), row, sid, issue)
+                        else:
+                            issue('source', sid, 'LEGACY_ORPHAN_PRODUCT', pid)
+                    elif kind == 'ranking_signals':
+                        insert(c, 'ranking_signals', dict(id=key(sid, kind), product_id=pid if exists else None, rank_id=str(row.get('rank_id') or ''), rank_number=row.get('rank'),
+                               title=row.get('rank_title'), scope='detail_page_badge_not_complete_ranking', source_time_raw=row.get('source_time'), source_record_id=sid))
+                    else:
+                        insert(c, 'coverage_observations', dict(id=key(sid, kind), product_id=pid if exists else None, status=row.get('status'), available_reviews=row.get('available_reviews'),
+                               reported_total=str(row.get('reported_total')) if row.get('reported_total') is not None else None, historical_total_complete=int(row.get('historical_total_complete') is True), source_record_id=sid))
+            from retrieval import create_index
+            create_index(c)
+            c.execute("UPDATE meta SET value='ready' WHERE key='status'")
+        require(c.execute('PRAGMA integrity_check').fetchone()[0] == 'ok', 'INTEGRITY_CHECK_FAILED')
+        require(not c.execute('PRAGMA foreign_key_check').fetchall(), 'FOREIGN_KEY_CHECK_FAILED')
+        c.execute('ANALYZE')
+        c.commit()
+        counts = table_counts(c)
+        quality = dict(c.execute('SELECT code,count(*) FROM quality_issues GROUP BY code'))
+        c.close()
+        os.chmod(temp, 0o600)
+        # No overwriting an existing database, including one created concurrently.
+        os.link(temp, dest)
+        temp.unlink()
+        return {'ok': True, 'operation': 'created', 'schema': VERSION, 'generation': generation, 'archiveSha256': digest,
+                'counts': counts, 'quality': quality, 'durationSeconds': round(time.perf_counter() - started, 3),
+                'productionImported': False, 'note': '已生成本地结构库;部署到授权 Agent 后才能查询,未写生产业务库。'}
+    finally:
+        a.z.close()
+        with contextlib.suppress(Exception):
+            c.close()
+        if temp.exists():
+            temp.unlink()
+
+
+def add_price(c, pid, raw, basis, row, sid, issue):
+    amount = money(raw)
+    if amount is None:
+        issue('source', sid, 'PRICE_NOT_SINGLE_NUMERIC', str(raw))
+    # Repeated observations of the same response are lineage, not new prices.
+    basis = {'搜索展示价':'search_listing_snapshot','详情展示价':'detail_listing_snapshot'}.get(basis, basis)
+    asset = c.execute("SELECT asset_path FROM source_links WHERE record_id=? AND role='source_file' AND resolution='matched'", (sid,)).fetchone()
+    priceid = key(pid, basis, amount if amount is not None else str(raw), row.get('source_time'), asset[0] if asset else row.get('source_file'))
+    c.execute('INSERT OR IGNORE INTO prices VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
+              (priceid, pid, 'CNY', amount, str(raw), basis, row.get('source_time'), utc(row.get('source_time')), calendar_date(row.get('source_time')),
+               row.get('collected_at'), utc(row.get('collected_at')), None, 'unavailable', sid))
+    c.execute('INSERT OR IGNORE INTO price_sources VALUES(?,?)', (priceid,sid))
+
+
+@contextlib.contextmanager
+def readonly(path, check_version=True):
+    p = Path(path).resolve()
+    require(p.is_file(), 'DATABASE_NOT_INSTALLED')
+    c = sqlite3.connect(p.as_uri() + '?mode=ro&immutable=1', uri=True)
+    c.execute('PRAGMA query_only=ON')
+    c.execute('PRAGMA trusted_schema=OFF')
+    c.row_factory = sqlite3.Row
+    try:
+        if check_version:
+            values = dict(c.execute('SELECT key,value FROM meta'))
+            require(values.get('schema') == VERSION and values.get('status') == 'ready', 'DATABASE_VERSION_OR_STATE_MISMATCH')
+        yield c
+    finally:
+        c.close()
+
+
+def table_counts(c):
+    names = ['products','reviews','social_posts','policy_documents','policy_clauses','prices','specs','product_observations','source_records','source_links','assets','collection_requests','quality_issues','ranking_signals','coverage_observations']
+    return {t: c.execute('SELECT count(*) FROM ' + t).fetchone()[0] for t in names}
+
+
+def contract(c):
+    meta = dict(c.execute('SELECT key,value FROM meta'))
+    from retrieval import catalog
+    return {'ok': True, 'schema': VERSION, 'generation': meta['generation'], 'readOnly': True, 'databaseWrites': 0,
+            'retrieval':catalog(c), 'recommendedCommands':['search','aggregate','facets','get','source'], 'legacyQueryNotice':'旧 query 保留三维模板兼容;一般问题请用 search,按实际需求设置条件,不要求补齐三个维度。',
+            'taxonomy': json.loads(meta['taxonomy']), 'entities': list(TABLES), 'queryFields': ['entity','marketplace','brand','category','shopId','itemId','keyword','limit','cursor','from','to','timeBasis','reviewScope','includeDefault','contentScope','documentId','policyStatus'],
+            'limits': {'default': 20, 'max': 100}, 'counts': table_counts(c),
+            'rules': ['优先用 search 按问题实际指定的维度过滤;不要求填齐平台/品牌/品类;旧 query 的三维模板仅保留兼容。',
+                      '全部条件 AND;不自动包含子类;同族商品精确关联评论;不按标题/昵称跨商品合并。',
+                      'reviewScope=sku_confirmed 默认,仅用购买规格明确匹配品类的评价;listing 单独查询并标识规格未证实。',
+                      '默认排除默认评价、身份待核/错配、品类冲突;默认文本数量不等于观点数量。',
+                      'timeBasis 必填才能用 from/to;calendar 日期含起点、不含终点,来源时区未证实;UTC 不含无时区数据。',
+                      'social 的 brand 是文本提及,绝不是商品品牌归属;comment_count 不是已采集评论。',
+                      '政策状态仅为 2026-11-04 的声明快照;条款均未自动认定逐字引文。'],
+            'capabilities': {'exactScopeQuery': True, 'getCitation': True, 'upstreamCollection': False, 'businessDatabaseProjection': False, 'publicBoardConnected': False}}
+
+
+def resolve_code(value, rows, code_field='code'):
+    if value == 'all':
+        return value
+    require(isinstance(value, str) and bool(value.strip()), 'EXPLICIT_SCOPE_REQUIRED')
+    hits = [r[code_field] for r in rows if value.casefold() in [str(r.get(code_field)).casefold(), str(r.get('name')).casefold(), *[a.casefold() for a in r.get('aliases', [])]]]
+    require(len(hits) == 1, 'UNKNOWN_OR_AMBIGUOUS_TAXONOMY:' + value)
+    return hits[0]
+
+
+def query_plan(c, request):
+    require(isinstance(request, dict), 'INVALID_QUERY')
+    allowed = {'entity','marketplace','brand','category','shopId','itemId','keyword','limit','cursor','from','to','timeBasis','reviewScope','includeDefault','contentScope','documentId','policyStatus'}
+    require(not set(request) - allowed, 'UNKNOWN_QUERY_FIELD')
+    entity = request.get('entity')
+    require(entity in TABLES, 'UNKNOWN_ENTITY')
+    size = request.get('limit', 20)
+    require(type(size) is int and 1 <= size <= 100, 'INVALID_LIMIT')
+    tax = json.loads(c.execute("SELECT value FROM meta WHERE key='taxonomy'").fetchone()[0])
+    normalized = dict(request)
+    conditions, values = [], []
+    entity_id = 'p.id' if entity == 'products' else 'r.id' if entity == 'reviews' else 'v.id'
+    scope_conditions, scope_values = [], []
+    excluded = {}
+    if entity in ('products','reviews','prices'):
+        market = request.get('marketplace')
+        require(market == 'all' or market in tax['marketplaceAliases'], 'MARKETPLACE_REQUIRED_OR_UNKNOWN')
+        market = 'all' if market == 'all' else tax['marketplaceAliases'][market]
+        normalized.update(marketplace=market, brand=resolve_code(request.get('brand'), tax['brands']), category=resolve_code(request.get('category'), tax['categories']))
+        for col, val in [('marketplace', market), ('brand_code', normalized['brand']), ('category_code', normalized['category'])]:
+            if val != 'all':
+                conditions.append('p.' + col + '=?')
+                values.append(val)
+        conditions.append("p.classification_state='supported'")
+        for field, column in [('shopId','shop_id'),('itemId','item_id')]:
+            if field in request:
+                conditions.append('p.' + column + '=?')
+                values.append(identifier(request[field]))
+        if 'keyword' in request:
+            require(isinstance(request['keyword'], str) and 0 < len(request['keyword']) <= 100, 'INVALID_KEYWORD')
+            conditions.append('instr(p.title,?)>0')
+            values.append(request['keyword'])
+        scope_conditions, scope_values = conditions[:], values[:]
+        scoped_products = c.execute('SELECT count(*) FROM products p WHERE ' + ' AND '.join(conditions), values).fetchone()[0]
+        excluded['scopeProducts'] = scoped_products
+        excluded['unclassifiedProductsInMarketplace'] = c.execute("SELECT count(*) FROM products WHERE classification_state='pending'" + (' AND marketplace=?' if market != 'all' else ''), [market] if market != 'all' else []).fetchone()[0]
+        if entity == 'products':
+            require(not set(request) & {'reviewScope','includeDefault','contentScope','documentId','policyStatus','from','to','timeBasis'}, 'FILTER_NOT_SUPPORTED_FOR_ENTITY')
+            from_sql = 'products p'
+            select = 'p.*'
+        elif entity == 'reviews':
+            require(not set(request) & {'contentScope','documentId','policyStatus'}, 'FILTER_NOT_SUPPORTED_FOR_ENTITY')
+            scope = request.get('reviewScope', 'sku_confirmed')
+            require(scope in ('sku_confirmed','listing'), 'INVALID_REVIEW_SCOPE')
+            require(type(request.get('includeDefault', False)) is bool, 'INVALID_INCLUDE_DEFAULT')
+            normalized.update(reviewScope=scope, includeDefault=request.get('includeDefault', False))
+            base = ' FROM reviews r JOIN products p ON p.id=r.product_id WHERE ' + ' AND '.join(conditions)
+            excluded['reviewCountsBeforeQualityFilters'] = {str(x[0]): x[1] for x in c.execute('SELECT r.identity_state || ":" || r.category_state || ":default=" || coalesce(r.is_default,"unknown"),count(*)' + base + ' GROUP BY 1', values)}
+            conditions.append("r.identity_state='matched'")
+            conditions.append("r.category_state='consistent'" if scope == 'sku_confirmed' else "r.category_state IN ('consistent','listing_only')")
+            if not normalized['includeDefault']:
+                conditions.append('r.is_default=0')
+            from_sql = 'products p JOIN reviews r ON r.product_id=p.id'
+            select = 'r.*,p.marketplace,p.brand_code,p.category_code,p.title AS product_title,p.brand_basis,p.category_basis'
+        else:
+            require(not set(request) & {'reviewScope','includeDefault','contentScope','documentId','policyStatus'}, 'FILTER_NOT_SUPPORTED_FOR_ENTITY')
+            conditions.append('v.amount_minor IS NOT NULL')
+            from_sql = 'products p JOIN prices v ON v.product_id=p.id'
+            select = 'v.*,p.marketplace,p.brand_code,p.category_code,p.title AS product_title,p.brand_basis,p.category_basis'
+    elif entity == 'social':
+        require(not set(request) & {'category','shopId','itemId','reviewScope','includeDefault','documentId','policyStatus'}, 'FILTER_NOT_SUPPORTED_FOR_ENTITY')
+        platform = request.get('marketplace')
+        require(platform in ('bilibili','douyin','xiaohongshu','all'), 'SOCIAL_PLATFORM_REQUIRED')
+        if platform != 'all':
+            conditions.append('v.platform=?'); values.append(platform)
+        scope = request.get('contentScope', 'packaged_noodles')
+        require(scope in ('packaged_noodles','adjacent_noodle_food','unknown','all'), 'UNKNOWN_CONTENT_SCOPE')
+        normalized['contentScope'] = scope
+        if scope != 'all':
+            conditions.append('v.content_scope=?'); values.append(scope)
+        if 'brand' in request:
+            brand = resolve_code(request['brand'], tax['brands'])
+            normalized['brand'] = brand
+            if brand != 'all':
+                conditions.append('EXISTS(SELECT 1 FROM social_brand_mentions m WHERE m.post_id=v.id AND m.brand_code=?)'); values.append(brand)
+        if 'keyword' in request:
+            require(isinstance(request['keyword'], str) and 0 < len(request['keyword']) <= 100, 'INVALID_KEYWORD')
+            conditions.append("instr(coalesce(v.title,'') || coalesce(v.content,''),?)>0"); values.append(request['keyword'])
+        from_sql, select = 'social_posts v', 'v.*'
+    else:
+        require(not set(request) & {'marketplace','brand','category','shopId','itemId','reviewScope','includeDefault','contentScope','from','to','timeBasis'}, 'FILTER_NOT_SUPPORTED_FOR_ENTITY')
+        from_sql, select = ('policy_documents v', 'v.*') if entity == 'documents' else ('policy_clauses v JOIN policy_documents d ON d.id=v.document_id', 'v.*,d.declared_status,d.status_as_of,d.source_url')
+        if 'documentId' in request:
+            conditions.append(('v.id' if entity == 'documents' else 'v.document_id') + '=?'); values.append(identifier(request['documentId']))
+        if 'policyStatus' in request:
+            require(request['policyStatus'] in {r[0] for r in c.execute('SELECT DISTINCT declared_status FROM policy_documents')}, 'UNKNOWN_POLICY_STATUS')
+            conditions.append(('v' if entity == 'documents' else 'd') + '.declared_status=?'); values.append(request['policyStatus'])
+        if 'keyword' in request:
+            require(isinstance(request['keyword'], str) and 0 < len(request['keyword']) <= 100, 'INVALID_KEYWORD')
+            conditions.append(('instr(v.title,?)>0' if entity == 'documents' else 'instr(v.excerpt || coalesce(v.application_note,\'\'),?)>0')); values.append(request['keyword'])
+    if set(request) & {'from','to','timeBasis'}:
+        time_fields = {'reviews': {'review_calendar_date':'r.review_calendar_date','review_utc':'r.review_date_utc'},
+                       'prices': {'source_calendar_date':'v.source_calendar_date','source_utc':'v.source_time_utc'},
+                       'social': {'published_utc':'v.published_at_utc'}}
+        basis = request.get('timeBasis')
+        require(basis in time_fields.get(entity, {}) and bool(set(request) & {'from','to'}), 'EXPLICIT_SUPPORTED_TIME_BASIS_REQUIRED')
+        column = time_fields[entity][basis]
+        before = ' AND '.join(conditions) if conditions else '1=1'
+        excluded['unknownTimeExcluded'] = c.execute('SELECT count(*) FROM ' + from_sql + ' WHERE ' + before + ' AND ' + column + ' IS NULL', values).fetchone()[0]
+        normalized_times = {}
+        for field, op in [('from','>='),('to','<')]:
+            if field in request:
+                value = request[field]
+                require(isinstance(value, str), 'INVALID_TIME')
+                if basis.endswith('calendar_date'):
+                    require(bool(re.fullmatch(r'\d{4}-\d{2}-\d{2}', value)) and calendar_date(value) == value, 'INVALID_DATE')
+                else:
+                    value = utc(value); require(value is not None, 'TIMEZONE_REQUIRED')
+                normalized_times[field] = value
+                conditions.append(column + op + '?'); values.append(value)
+        require(not ('from' in normalized_times and 'to' in normalized_times) or normalized_times['from'] < normalized_times['to'], 'INVALID_TIME_RANGE')
+        normalized.update(normalized_times)
+    where = ' AND '.join(conditions) if conditions else '1=1'
+    total = c.execute('SELECT count(*) FROM ' + from_sql + ' WHERE ' + where, values).fetchone()[0]
+    generation = c.execute("SELECT value FROM meta WHERE key='generation'").fetchone()[0]
+    context = key(generation, {k:v for k,v in normalized.items() if k not in ('cursor','limit')})
+    if request.get('cursor'):
+        try:
+            cursor = json.loads(base64.urlsafe_b64decode(request['cursor']))
+            require(set(cursor) == {'context','after'} and cursor['context'] == context and isinstance(cursor['after'], str), 'CURSOR_SCOPE_MISMATCH')
+        except (ValueError, TypeError, KeyError):
+            raise LibraryError('CURSOR_SCOPE_MISMATCH') from None
+        where += ' AND ' + entity_id + '>?'; values.append(cursor['after'])
+    sql = 'SELECT ' + select + ' FROM ' + from_sql + ' WHERE ' + where + ' ORDER BY ' + entity_id + ' LIMIT ?'
+    return sql, values + [size + 1], normalized, context, total, excluded
+
+
+def citation(c, sid, expand=False):
+    s = c.execute('SELECT id,kind,asset_path,line_number,payload_sha256 FROM source_records WHERE id=?', (sid,)).fetchone()
+    require(s is not None, 'SOURCE_NOT_FOUND')
+    result = dict(s)
+    result['locatorType'] = 'sqlite_legacy_row_encoded' if s['kind'].startswith('legacy_') else 'jsonl_line_1_based'
+    if s['kind'].startswith('legacy_'):
+        result['legacyRowId'] = s['line_number'] // 10
+        result['legacyTable'] = {'legacy_product_detail':'product_details','legacy_prices':'prices','legacy_ranking_signals':'ranking_signals','legacy_review_coverage':'review_coverage','legacy_partial_catalog':'partial_catalog'}[s['kind']]
+    result['assetSha256'] = c.execute('SELECT sha256 FROM assets WHERE path=?', (s['asset_path'],)).fetchone()[0]
+    result['archiveSha256'] = c.execute("SELECT value FROM meta WHERE key='archive_sha256'").fetchone()[0]
+    if c.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='source_origins'").fetchone():
+        origins=[r[0] for r in c.execute('SELECT archive_sha256 FROM source_origins WHERE source_record_id=? ORDER BY archive_sha256',(sid,))]
+        if origins:
+            result['archiveSha256']=origins[0]
+            result['archiveSha256s']=origins
+    result['upstreamSourceCount'] = c.execute('SELECT count(*) FROM source_links WHERE record_id=?', (sid,)).fetchone()[0]
+    result['upstreamSources'] = [dict(r) for r in c.execute('SELECT l.role,l.original_path,l.asset_path,l.resolution,a.sha256 FROM source_links l LEFT JOIN assets a ON a.path=l.asset_path WHERE l.record_id=? ORDER BY CASE WHEN l.role=\'source_file\' THEN 0 ELSE 1 END,l.role,l.original_path' + ('' if expand else ' LIMIT 3'), (sid,))]
+    for link in result['upstreamSources']:
+        request = c.execute('SELECT endpoint_id,run_key,safe_parameters_json,collector_status,http_status,upstream_code,upstream_request_id,started_at_raw,finished_at_raw,record_time_raw FROM collection_requests WHERE asset_path=?', (link['asset_path'],)).fetchone()
+        if request:
+            link['collectionRequest'] = dict(request)
+    result['upstreamSourcesTruncated'] = len(result['upstreamSources']) < result['upstreamSourceCount']
+    return result
+
+
+def decorate(c, row, entity):
+    result = dict(row)
+    result['citation'] = citation(c, row['source_record_id'])
+    if entity == 'products':
+        result['classification'] = [dict(r) for r in c.execute('SELECT dimension,value,method,rule_version,evidence_record_id,evidence_field,note FROM classification_assertions WHERE product_id=? ORDER BY dimension', (row['id'],))]
+        result['specs'] = [dict(r) for r in c.execute('SELECT upstream_sku_id,spec_text,net_weight_g,pack_count,total_weight_g,verification,basis,source_record_id FROM specs WHERE product_id=?', (row['id'],))]
+    if entity in ('reviews', 'prices') and row['product_id']:
+        product = c.execute('SELECT source_record_id FROM products WHERE id=?', (row['product_id'],)).fetchone()
+        result['productCitation'] = citation(c, product[0])
+    return result
+
+
+def query(c, request, explain=False):
+    started = time.perf_counter()
+    sql, values, normalized, context, total, excluded = query_plan(c, request)
+    rows = c.execute(sql, values).fetchall()
+    size = normalized.get('limit', 20)
+    more = len(rows) > size
+    rows = rows[:size]
+    cursor = base64.urlsafe_b64encode(canonical({'context':context, 'after':rows[-1]['id']}).encode()).decode() if more else None
+    result = {'ok':True, 'schema':VERSION, 'readOnly':True, 'databaseWrites':0, 'effectiveFilters':{k:v for k,v in normalized.items() if k != 'cursor'},
+              'scopeCombination':'AND', 'totalMatched':total, 'records':[decorate(c, row, normalized['entity']) for row in rows], 'nextCursor':cursor,
+              'coverage':excluded, 'classificationNotice':'品牌/品类含保守标题规则,并非全部人工复核;未知/冲突排除,详见 basis 与证据。',
+              'elapsedMs':round((time.perf_counter()-started)*1000,3)}
+    if explain:
+        result['queryPlan'] = [r['detail'] for r in c.execute('EXPLAIN QUERY PLAN ' + sql, values)]
+    return result
+
+
+def get_record(c, entity, recordid):
+    from retrieval import ENTITIES
+    require(entity in ENTITIES, 'UNKNOWN_ENTITY')
+    row = c.execute('SELECT * FROM ' + ENTITIES[entity] + ' WHERE id=?', (recordid,)).fetchone()
+    require(row is not None, 'RECORD_NOT_FOUND')
+    return {'ok':True, 'schema':VERSION, 'readOnly':True, 'databaseWrites':0, 'record':decorate(c, row, entity)}
+
+
+def get_source(c, recordid):
+    row = c.execute('SELECT payload_json FROM source_records WHERE id=?', (recordid,)).fetchone()
+    require(row is not None, 'SOURCE_NOT_FOUND')
+    return {'ok':True, 'schema':VERSION, 'readOnly':True, 'databaseWrites':0,
+            'citation':citation(c, recordid, expand=True), 'sourcePayload':json.loads(row[0])}
+
+
+def registry_database(root):
+    """Resolve the same atomic active pointer used by the browser service."""
+    root=Path(root).resolve()
+    pointer=json.loads((root/'current.json').read_text(encoding='utf-8'))
+    db=(root/pointer['file']).resolve()
+    require(db.is_relative_to(root) and db.is_file(),'INVALID_LIBRARY_POINTER')
+    return db
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('command', choices=['build','contract','query','search','aggregate','facets','get','source','verify'])
+    parser.add_argument('--db', default=os.environ.get('KSF_VOC_DB', '/opt/data/ksf-voc-library/library.sqlite'))
+    parser.add_argument('--registry', default=os.environ.get('KSF_LIBRARY_REGISTRY'))
+    parser.add_argument('--archive')
+    parser.add_argument('--input')
+    from retrieval import ENTITIES
+    parser.add_argument('--entity', choices=list(ENTITIES))
+    parser.add_argument('--id')
+    parser.add_argument('--receipt')
+    parser.add_argument('--explain', action='store_true')
+    args = parser.parse_args()
+    try:
+        if args.command == 'build':
+            require(not args.registry, 'BUILD_REJECTS_ACTIVE_REGISTRY')
+            require(args.archive, 'ARCHIVE_REQUIRED')
+            result = build(args.archive, args.db)
+        else:
+            require(not args.archive and not args.receipt, 'READ_ONLY_COMMAND_REJECTS_WRITE_OPTIONS')
+            require(not (args.registry and '--db' in sys.argv), 'AMBIGUOUS_DATABASE_LOCATION')
+            with readonly(registry_database(args.registry) if args.registry else args.db) as c:
+                if args.command == 'contract':
+                    result = contract(c)
+                elif args.command in ('query','search','aggregate','facets'):
+                    require(args.input, 'INPUT_REQUIRED')
+                    request = json.loads(sys.stdin.read() if args.input == '-' else Path(args.input).read_text(encoding='utf-8-sig'))
+                    if args.command=='query':result = query(c, request, args.explain)
+                    else:
+                        from retrieval import search, aggregate
+                        result = search(c,request,args.explain) if args.command=='search' else aggregate(c,request,facets=args.command=='facets')
+                elif args.command == 'get':
+                    require(args.entity and args.id, 'ENTITY_AND_ID_REQUIRED')
+                    result = get_record(c, args.entity, args.id)
+                elif args.command == 'source':
+                    require(args.id, 'SOURCE_ID_REQUIRED')
+                    result = get_source(c, args.id)
+                else:
+                    require(c.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' and not c.execute('PRAGMA foreign_key_check').fetchall(), 'DATABASE_CHECK_FAILED')
+                    result = {'ok':True, 'schema':VERSION, 'readOnly':True, 'databaseWrites':0, 'counts':table_counts(c)}
+        if args.command == 'build' and args.receipt:
+            # An operator may create a new receipt; never overwrite arbitrary files.
+            with Path(args.receipt).open('x', encoding='utf-8') as f:
+                f.write(canonical(result) + '\n')
+        print(json.dumps(result, ensure_ascii=False, indent=2))
+    except (LibraryError, sqlite3.Error, ValueError, OSError, zipfile.BadZipFile) as error:
+        print(json.dumps({'ok':False, 'error':{'code':str(error)}, 'readOnly':args.command != 'build', 'databaseWrites':0 if args.command != 'build' else None}, ensure_ascii=False), file=sys.stderr)
+        return 1
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 14 - 0
server/web/confirm.html

@@ -0,0 +1,14 @@
+<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>康师傅 · 报告确认</title>
+<style>*{box-sizing:border-box}body{font:15px/1.6 system-ui;background:#f5f6fa;color:#18212e;margin:0}main{max-width:1140px;margin:auto;padding:28px}header{display:flex;justify-content:space-between;align-items:center;gap:20px}h1{font-size:23px;margin:0}small{color:#6b7480}.box{background:white;border:1px solid #e5e9ef;border-radius:14px;padding:22px;margin-top:20px;box-shadow:0 8px 26px #16223808}input{width:240px;max-width:100%;padding:11px;border:1px solid #d7dde7;border-radius:7px;margin:5px}button{padding:11px 18px;border:0;border-radius:7px;background:#be2334;color:#fff;cursor:pointer}button:disabled{opacity:.45;cursor:default}iframe{width:100%;height:65vh;border:1px solid #e5e9ef;border-radius:9px;background:white}#message{white-space:pre-wrap;color:#7c2833}#identity{overflow-wrap:anywhere}label{display:block;margin:14px 0}#preview{display:none}</style>
+<main><header><div><small>康师傅 · 产品创新助手</small><h1>查看并确认报告</h1></div><small>确认仅针对当前版本</small></header>
+<section id="login" class="box"><h2>员工登录</h2><p>请使用已获授权的员工账号。确认记录会保存登录身份和当前报告版本。</p><form id="form"><input id="username" autocomplete="username" placeholder="员工账号" required><input id="password" type="password" autocomplete="current-password" placeholder="密码" required><button>登录并查看</button></form></section>
+<section id="preview" class="box"><p id="identity"></p><iframe id="frame" sandbox="" referrerpolicy="no-referrer" title="待确认报告"></iframe><label><input type="checkbox" id="ack" style="width:auto">我已查看当前报告,同意将此版本提交入库。</label><button id="confirm" disabled>确认当前版本</button><p><small>报告内容变化后,需要重新确认。确认报告不代表其中每一项推断均已核验。</small></p></section><p id="message" role="status"></p></main>
+<script nonce="__NONCE__">'use strict';
+const API='/api/functions',APP='FKieRbIoGt_main',previewId=new URLSearchParams(location.search).get('previewId');let token='',report=null,objectUrl=null;
+const el=id=>document.getElementById(id),say=s=>{el('message').textContent=s;};
+async function call(name,params){const r=await fetch(API,{method:'POST',headers:{'content-type':'application/json','X-Parse-Application-Id':APP,'X-Parse-Session-Token':token},body:JSON.stringify({path:'/'+name,params})});const j=await r.json(),v=j.result;if(!r.ok||!v?.ok)throw Error(v?.errors?.map(e=>e.message).join(';')||j.error||'请求失败');return v.data;}
+el('form').addEventListener('submit',async e=>{e.preventDefault();say('正在核验登录身份…');try{if(!previewId)throw Error('确认链接缺少报告编号');const r=await fetch('/parse/login',{method:'POST',headers:{'content-type':'application/json','X-Parse-Application-Id':APP},body:JSON.stringify({username:el('username').value,password:el('password').value})});const j=await r.json();el('password').value='';if(!r.ok||!j.sessionToken)throw Error('登录失败,请核对账号和密码');token=j.sessionToken;report=await call('ksfGetReportPreview',{previewId});objectUrl=URL.createObjectURL(new Blob([report.html],{type:'text/html'}));el('frame').src=objectUrl;el('identity').textContent='报告:'+report.reportIdentity.reportSeriesKey+' · 版本 '+report.reportIdentity.version+' · 内容指纹 '+report.reportIdentity.reportContentHash;el('login').hidden=true;el('preview').style.display='block';say('请查看报告后确认。');}catch(e){say(e.message);}});
+el('ack').addEventListener('change',()=>el('confirm').disabled=!el('ack').checked);
+el('confirm').addEventListener('click',async()=>{el('confirm').disabled=true;try{const r=await call('ksfEmployeeConfirmation',{requestId:'employee-confirm:'+previewId+':'+crypto.randomUUID(),previewId,action:'confirm_report',reportContentHash:report.reportIdentity.reportContentHash});say('已确认当前版本,助手可继续入库。确认事件编号:'+r.eventId);el('ack').disabled=true;}catch(e){say(e.message);el('confirm').disabled=false;}});
+addEventListener('pagehide',()=>{if(objectUrl)URL.revokeObjectURL(objectUrl);token='';});
+</script></html>

+ 3 - 0
src/app.config.js

@@ -16,6 +16,9 @@ export const CONFIG = Object.freeze({
   parseBase: String(LOCAL_OVERRIDE || '/parse').replace(/\/+$/, ''),
   appId: 'FKieRbIoGt_main',
   functionsUrl: 'https://kangshifu.fmode.cn/api/functions',
+  libraryUrl: '/api/library',
+  memberGroupsUrl: typeof location !== 'undefined' && ['127.0.0.1', 'localhost'].includes(location.hostname)
+    ? '/__dev/member-groups' : 'https://kangshifu.fmode.cn/api/functions',
   classes: Object.freeze({
     project: 'KfProject',
     artifact: 'KfArtifact',

+ 2 - 0
src/components/icons.js

@@ -32,6 +32,7 @@ export const ICON_PATHS = {
   layers: 'M12 3l9 5-9 5-9-5zM3 13l9 5 9-5',
   users: 'M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7M2.5 20a6.5 6.5 0 0 1 13 0M17 8a3 3 0 0 1 0 6M21.5 20a5 5 0 0 0-3.5-4.6',
   inbox: 'M4 7l8-4 8 4v10l-8 4-8-4zM4 7l8 4 8-4M12 21V11',
+  inboxSolid: 'M4 7l8-4 8 4v10l-8 4-8-4zM4 7l8 4 8-4M12 21V11',
   table: 'M4 5h16v14H4zM4 10h16M4 14h16M10 5v14',
   home: 'M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-5v-6h-4v6H5a1 1 0 0 1-1-1z',
   pool: 'M4 6h16M4 12h16M4 18h10',
@@ -41,6 +42,7 @@ export const ICON_PATHS = {
   check: 'M5 12.5l4.5 4.5L19 7.5',
   star: 'M12 3l2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1L3.2 9.5l6.1-.9z',
   plus: 'M12 5v14M5 12h14',
+  search: 'M10.5 17a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13M15.5 15.5 21 21',
   dot: 'M12 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2',
   down: 'M17 10l-5 5-5-5',
   up: 'M7 14l5-5 5 5',

+ 68 - 30
src/components/kf-drawer.js

@@ -2,7 +2,8 @@
  * components/kf-drawer.js —— <kf-drawer> 浮层抽屉(第三层玻璃,最实)。
  *
  * 打开时:焦点移入、Esc 关闭、遮罩点击关闭、关闭后焦点还原;body 滚动锁。
- * 内容:宿主把 lit 模板 render 进 shadow root 里的 [part=body](不经 light DOM,避免 light DOM 排版影响浮层)。
+ * 内容:content 接受 Lit 模板,由弹窗在自己的 Shadow DOM 内渲染。
+ * 原生 dialog 负责焦点限制和背景 inert;layout=center 为居中窗口。
  * 事件:drawer-close
  */
 import { LitElement, html, css } from 'lit';
@@ -17,6 +18,9 @@ export class KfDrawer extends LitElement {
     heading: { type: String },
     sub: { type: String },
     width: { type: String },
+    layout: { type: String, reflect: true },
+    label: { type: String },
+    content: { attribute: false },
   };
 
   constructor() {
@@ -25,10 +29,14 @@ export class KfDrawer extends LitElement {
     this.heading = '';
     this.sub = '';
     this.width = 'min(640px, 94vw)';
+    this.layout = 'side';
+    this.label = '详情';
+    this.content = null;
   }
 
   /** 打开前的焦点元素(关闭后还原) */
   #lastFocus = null;
+  #closing = false;
 
   connectedCallback() {
     super.connectedCallback();
@@ -37,29 +45,30 @@ export class KfDrawer extends LitElement {
 
   disconnectedCallback() {
     super.disconnectedCallback();
-    window.removeEventListener('keydown', this.#onKey);
+    this.renderRoot.querySelector('.panel')?.close();
     this.#unlock();
   }
 
   updated(changed) {
+    if (changed.has('open') || changed.has('content')) this.#closing = false;
+    if (this.open && (changed.has('heading') || changed.has('label'))) this.renderRoot.querySelector('.body')?.scrollTo({ top: 0 });
     if (!changed.has('open')) return;
+    const panel = this.renderRoot.querySelector('.panel');
     if (this.open) {
       this.#lastFocus = document.activeElement;
-      window.addEventListener('keydown', this.#onKey);
+      while (this.#lastFocus?.shadowRoot?.activeElement) this.#lastFocus = this.#lastFocus.shadowRoot.activeElement;
       if (!this.#locked) { document.documentElement.classList.add('kf-drawer-open'); openCount++; this.#locked = true; }
-      this.renderRoot.querySelector('.scrim')?.addEventListener('touchmove', this.#block, { passive: false });
-      requestAnimationFrame(() => this.renderRoot.querySelector('.panel')?.focus());
+      panel?.showModal();
+      this.renderRoot.querySelector('.x')?.focus({ preventScroll: true });
     } else {
-      window.removeEventListener('keydown', this.#onKey);
-      this.renderRoot.querySelector('.scrim')?.removeEventListener('touchmove', this.#block);
+      panel?.close();
       this.#unlock();
-      if (this.#lastFocus?.focus) this.#lastFocus.focus({ preventScroll: true });
+      if (this.#lastFocus?.isConnected) this.#lastFocus.focus({ preventScroll: true });
     }
   }
 
   /** 只在「本次打开」时解锁一次:v9 实测同一抽屉 open 切换两次会把计数减到 0,背景页面被解开滚动 */
   #locked = false;
-  #block = (e) => { e.preventDefault(); };
 
   #unlock() {
     if (!this.#locked) return;
@@ -68,29 +77,48 @@ export class KfDrawer extends LitElement {
     if (!openCount) document.documentElement.classList.remove('kf-drawer-open');
   }
 
-  /** 用箭头函数字段绑定 this(私有方法不可重新赋值) */
-  #onKey = (e) => {
-    if (e.key === 'Escape') {
-      e.stopPropagation();
-      this.#close();
-    }
+  #cancel = (e) => { e.preventDefault(); this.#close(); };
+  #tab = (e) => {
+    if (e.key !== 'Tab' || e.ctrlKey || e.metaKey || e.altKey) return;
+    const panel = this.renderRoot.querySelector('.panel');
+    const controls = [];
+    const walk = (root) => {
+      for (const el of root.children) {
+        if (el.matches('a[href], button, input, select, textarea, summary, [tabindex]') && el.tabIndex >= 0 && !el.matches(':disabled') && el.getClientRects().length) controls.push(el);
+        walk(el.shadowRoot || el);
+      }
+    };
+    walk(panel);
+    let active = document.activeElement;
+    while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
+    const index = controls.indexOf(active);
+    const next = e.shiftKey ? (index > 0 ? index - 1 : controls.length - 1) : (index + 1) % controls.length;
+    e.preventDefault();
+    (controls[next] || panel).focus({ preventScroll: false });
+  };
+  #backdrop = (e) => {
+    if (e.target !== e.currentTarget) return;
+    const r = e.currentTarget.getBoundingClientRect();
+    if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) this.#close();
   };
 
   #close() {
+    if (!this.open || this.#closing) return;
+    this.#closing = true;
     this.dispatchEvent(new CustomEvent('drawer-close', { bubbles: true, composed: true }));
   }
 
   static styles = css`
     /* 浮层不占布局:fixed 定位 + 空尺寸;拔掉 dialog 默认的 margin / border / padding */
     :host { position: fixed; inset: 0; width: 0; height: 0; overflow: visible; margin: 0; padding: 0; border: 0; z-index: 40; }
-    .scrim {
-      position: fixed; inset: 0; z-index: 40; background: rgba(12, 20, 40, 0.28);
+    .panel::backdrop {
+      background: rgba(12, 20, 40, 0.28);
       -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
-      opacity: 0; transition: opacity var(--kf-dur) var(--kf-ease); pointer-events: none;
     }
     .panel {
-      position: fixed; top: 0; right: 0; bottom: 0; z-index: 41; width: var(--drawer-w, min(640px, 94vw));
-      display: flex; flex-direction: column;
+      position: fixed; inset: 0 0 0 auto; margin: 0; padding: 0; box-sizing: border-box;
+      max-width: none; max-height: none; height: 100dvh; z-index: 41; width: var(--drawer-w, min(640px, 94vw));
+      display: none; flex-direction: column; color: var(--kf-ink);
       /* 上层玻璃(任务书 2.1):.72 + blur(32px),和内容卡 .42 / blur(28px) 拉开纵深 */
       border: 1px solid transparent; border-right: 0; border-radius: var(--kf-radius-lg) 0 0 var(--kf-radius-lg);
       background:
@@ -103,19 +131,29 @@ export class KfDrawer extends LitElement {
       transition: transform var(--kf-dur) var(--kf-ease), opacity var(--kf-dur) var(--kf-ease);
       outline: none;
     }
-    :host([open]) .scrim { opacity: 1; pointer-events: auto; }
-    :host([open]) .panel { transform: none; opacity: 1; pointer-events: auto; }
+    .panel[open] { display: flex; transform: none; opacity: 1; pointer-events: auto; }
+    :host([layout="center"]) .panel {
+      inset: 0; margin: auto; width: min(1000px, calc(100vw - 48px)); height: fit-content;
+      max-height: min(880px, calc(100dvh - 48px)); border: 1px solid var(--kf-line);
+      border-radius: var(--kf-radius-lg); background: var(--kf-surface);
+    }
+    :host([layout="center"]) .head { padding: 22px 28px 18px; }
+    :host([layout="center"]) .body { padding: 24px 28px; }
     .head { display: flex; gap: 12px; align-items: flex-start; padding: 18px 20px 14px; border-bottom: 1px solid var(--kf-line); }
     .head .k { flex: 1; min-width: 0; }
     .kicker { font-size: 12px; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-weight: 600; }
-    h2 { margin: 4px 0 0; font-size: 20px; line-height: 1.35; color: var(--kf-ink); font-weight: 600; }
+    h2 { margin: 4px 0 0; font-size: 20px; line-height: 1.45; color: var(--kf-ink); font-weight: 600; overflow-wrap: anywhere; }
     .sub { margin-top: 4px; color: var(--kf-ink-3); font-size: 14px; }
     .x { width: 32px; height: 32px; flex: none; border-radius: var(--r-md); display: grid; place-items: center;
       cursor: pointer; color: var(--kf-ink); }
-    .body { flex: 1; overflow: auto; padding: 16px 20px 24px; overscroll-behavior: contain; }
+    .body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 16px 20px 24px; overscroll-behavior: contain; }
+    .head { flex: none; }
     @media (max-width: 767px) {
       /* width 100% + box-sizing: border-box:1px 边框不会再顶出视口右侧(v9 实测右边溢出 24px 的根因) */
       .panel { width: 100%; box-sizing: border-box; border-radius: var(--r-md) 0 0 var(--r-md); }
+      :host([layout="center"]) .panel { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); border-radius: var(--r-md); }
+      :host([layout="center"]) .head { padding: 16px; }
+      :host([layout="center"]) .body { padding: 18px 16px; }
       .head { padding: 18px 14px 14px; padding-top: calc(14px + env(safe-area-inset-top, 0px)); }
       .x { width: 44px; height: 44px; }
       .body { padding: 16px 16px calc(24px + env(safe-area-inset-bottom, 0px)); -webkit-overflow-scrolling: touch; }
@@ -125,18 +163,18 @@ export class KfDrawer extends LitElement {
 
   render() {
     return html`
-      <div class="scrim" @click=${this.#close}></div>
-      <aside class="panel is-overlay" style=${`--drawer-w:${this.width}`} tabindex="-1" role="dialog" aria-modal="true" aria-label=${this.heading}>
+      <dialog class="panel is-overlay" style=${`--drawer-w:${this.width}`} tabindex="-1" aria-modal="true" aria-labelledby="detail-heading"
+        @cancel=${this.#cancel} @click=${this.#backdrop} @keydown=${this.#tab}>
         <div class="head">
           <div class="k">
-            <div class="kicker">${this.sub ? '详情' : ''}</div>
-            <h2>${this.heading}<slot name="title"></slot></h2>
+            <div class="kicker">${this.label}</div>
+            <h2 id="detail-heading">${this.heading}<slot name="title"></slot></h2>
             ${this.sub ? html`<div class="sub">${this.sub}</div>` : ''}
           </div>
           <button class="x kf-btn" @click=${this.#close} aria-label="关闭">${icon('x', 16)}</button>
         </div>
-        <div class="body" part="body"></div>
-      </aside>
+        <div class="body" part="body">${this.content}</div>
+      </dialog>
     `;
   }
 }

+ 3 - 3
src/components/kf-empty.js

@@ -31,7 +31,7 @@ export class KfEmpty extends LitElement {
   }
 
   static styles = css`
-    :host { display: block; }
+    :host { display: block; min-width: 0; }
     /* 空态(v10 任务书 2.9):图标 24px opacity .6 + 一句 13px 说明 + 次级按钮;
        最小高度 = 正常内容高度(168px),切换筛选时页面不跳。 */
     .e { min-height: 168px; box-sizing: border-box; display: grid; justify-items: center; align-content: center; justify-content: center;
@@ -39,8 +39,8 @@ export class KfEmpty extends LitElement {
     .ico { display: grid; place-items: center; width: 24px; height: 24px; opacity: 0.6; }
     .ico svg { width: 24px; height: 24px; stroke: var(--kf-ink-3); }
     :host([kind="error"]) .ico svg { stroke: var(--kf-danger); }
-    .t { color: var(--kf-ink); font-weight: 500; font-size: 14px; line-height: 1.4; letter-spacing: 0; }
-    .h { color: var(--kf-ink-3); font-size: 14px; max-width: 46ch; line-height: 1.6; }
+    .t { color: var(--kf-ink); font-weight: 500; font-size: 14px; line-height: 1.4; letter-spacing: 0; overflow-wrap: anywhere; }
+    .h { color: var(--kf-ink-3); font-size: 14px; max-width: 46ch; min-width: 0; line-height: 1.6; overflow-wrap: anywhere; }
     .e .kf-btn { margin-top: 8px; height: 28px; font-size: 14px; }
     /* 加载骨架:与空态同高,数据回来不跳 */
     .e.load { justify-items: stretch; justify-content: stretch; gap: 12px; padding: 24px; }

+ 14 - 5
src/components/kf-evidence-chain.js

@@ -33,7 +33,7 @@ export class KfEvidenceChain extends LitElement {
     :host { display: block; }
     .seg { padding: 12px 0; border-top: 1px solid var(--kf-line); }
     .seg:first-child { border-top: 0; padding-top: 0; }
-    .seg h4 { margin: 0 0 8px; font-size: 12px; line-height: 1.4; color: var(--kf-ink-3); font-weight: 600; letter-spacing: var(--ls-cn-small); display: flex; gap: 8px; align-items: center; }
+    .seg h4 { margin: 0 0 8px; font-size: 14px; line-height: 1.6; color: var(--kf-ink-3); font-weight: 600; letter-spacing: var(--ls-cn-small); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; overflow-wrap: anywhere; }
     .seg h4 .cnt { background: rgba(20,36,70,0.08); border-radius: var(--r-sm); padding: 1px 7px; color: var(--kf-ink-2); font-variant-numeric: tabular-nums; }
     ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
     li { display: grid; gap: 2px; }
@@ -44,6 +44,13 @@ export class KfEvidenceChain extends LitElement {
     .empty { font-size: 14px; color: var(--kf-ink-3); }
     .warn { color: var(--kf-warn-text); }
     .miss { color: var(--kf-warn-text); font-size: 12px; }
+    .seg { overflow-wrap: anywhere; padding: 20px 0; }
+    .artifact-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
+    .artifact-head > div { flex: 1 1 260px; min-width: 0; }
+    .artifact-head .kf-btn { min-height: 40px; flex: none; }
+    button.link { line-height: 1.7; text-align: left; }
+    button.link:focus-visible { outline: 2px solid var(--kf-focus); outline-offset: 3px; }
+    .raw { margin: 0; white-space: pre-wrap; font-size: 14px; line-height: 1.9; padding: 16px; border-radius: var(--r-sm); background: var(--kf-fill); }
   `;
 
   #emit(name, detail) {
@@ -61,11 +68,13 @@ export class KfEvidenceChain extends LitElement {
   #artifact(c) {
     const a = c.artifact || {};
     return html`
-      <div class="seg">
-        <h4>本成果<span class="cnt">${a.shortTitle || ''}</span></h4>
+      <div class="seg artifact-head">
+        <div><h4>本成果<span class="cnt">${a.shortTitle || ''}</span></h4>
         <div class="sub">${a.ownerName ? html`作者 <b>${a.ownerName}</b>` : html`<span class="warn">缺少出身(无上传人)</span>`}
-          ${a.stageObj ? html` · 阶段 <b>${a.stageObj.code}</b>` : ''} · 证据 <kf-badge kind="plain" .value=${a.evidenceLabel || '—'}></kf-badge></div>
+          ${a.stageObj ? html` · 阶段 <b>${a.stageObj.code}</b>` : ''} · 证据 <kf-badge kind="plain" .value=${a.evidenceLabel || '—'}></kf-badge></div></div>
+        ${a.report ? html`<button class="kf-btn primary" data-full-report @click=${() => this.#emit('report-open', { id: a.id })}>查看完整报告 ↗</button>` : ''}
       </div>
+      ${a.rawText ? html`<section class="seg"><h4>成果正文</h4><p class="raw">${a.rawText}</p></section>` : ''}
       <div class="seg">
         <h4>被这些结论引用<span class="cnt">${c.citing?.length || 0}</span></h4>
         ${c.citing?.length ? html`<ul>${c.citing.map((x) => html`
@@ -116,7 +125,7 @@ export class KfEvidenceChain extends LitElement {
       </div>
       ${c.sources?.length ? html`<div class="seg">
         <h4>原材料<span class="cnt">${c.sources.length}</span></h4>
-        <ul>${c.sources.map((s) => html`<li><span class="sub">${s.title}</span></li>`)}</ul>
+        <ul>${c.sources.map((s) => html`<li><button class="link" @click=${() => this.#emit('source-open', { id: s.id })}>${s.title}</button></li>`)}</ul>
       </div>` : ''}
     `;
   }

+ 1 - 1
src/components/kf-glass-card.js

@@ -62,7 +62,7 @@ export class KfGlassCard extends LitElement {
     .frame { box-sizing: border-box; isolation: isolate; overflow: clip; min-height: 0; transition-property: none; }
     /* v15:默认「内容多高卡就多高」—— 卡面收缩到内容,宿主被栅格拉高时多出的高度留在透明宿主上。
        fill-to:卡面吃满宿主(.face 撑满、slot 撑满),同一行的卡等高时用它。 */
-    .frame > .face { position: relative; z-index: 2; box-sizing: border-box; padding: var(--kf-card-pad, 20px); border-radius: var(--r-md); flex: 0 1 auto; align-self: flex-start; min-height: 0; display: flex; flex-direction: column; }
+    .frame > .face { position: relative; z-index: 2; box-sizing: border-box; width: 100%; min-width: 0; padding: var(--kf-card-pad, 20px); border-radius: var(--r-md); flex: 0 1 auto; align-self: flex-start; min-height: 0; display: flex; flex-direction: column; }
     :host([fill-to]) .frame > .face { flex: 1 1 auto; align-self: stretch; }
     /* v14 定稿:slot 必须是 flex column 且撑满 .face,否则 slotted 子元素(机会卡 .wrap)
        的 height/min-height:100% 解析不出包含块高度 → 无描述卡片内容只有 303px,底部差 53.6px。

+ 3 - 7
src/components/kf-opportunity-card.js

@@ -19,6 +19,7 @@ import './kf-badge.js';
 import './kf-glass-card.js';
 import { fmtDate } from '../core/index.js';
 import { t } from './cjk.js';
+import { href, currentUrl, opportunityUrl, reportUrl } from '../utils/router.js';
 
 const METHOD_LABEL = { session: '会话上传', skill: t('Skill 上传'), manual: '手工录入' };
 
@@ -125,11 +126,6 @@ export class KfOpportunityCard extends LitElement {
     }
   `;
 
-  /** 内嵌链接:action 选「报告」/「台账」,由 page 通过 link-href 事件问 router 要完整 URL */
-  #linkHref(kind, id) {
-    return `${this.base}/${encodeURIComponent(id)}`;
-  }
-
   #open = (e) => {
     e?.stopPropagation();
     if (!this.opp) return;
@@ -158,7 +154,7 @@ export class KfOpportunityCard extends LitElement {
     const s = o.stats || { avg: null, n: 0 };
     const u = o.upload || {};
     const dec = o.decision;
-    const reportHref = o.reportArtifact ? `${this.base || ''}/report/${encodeURIComponent(o.reportArtifact.id)}` : '';
+    const reportHref = o.reportArtifact ? href(reportUrl(o.reportArtifact.id, opportunityUrl(o.id, currentUrl()))) : '';
     return html`
       <kf-glass-card lift fill-to surface="2" tone=${o.status === 'rejected' ? 'danger' : o.status === 'approved' ? 'ok' : ''} @card-activate=${this.#open}>
         <div class="wrap">
@@ -215,7 +211,7 @@ export class KfOpportunityCard extends LitElement {
             </div>
             <span class="sp"></span>
             ${o.reportArtifact
-              ? html`<a class="report" href=${this.#linkHref('report', o.reportArtifact.id)} data-link data-report=${o.reportArtifact.id}
+              ? html`<a class="report" href=${reportHref} data-link data-report=${o.reportArtifact.id}
                   @click=${(e) => e.stopPropagation()} title="打开关联的完整报告">完整报告 ↗</a>`
               : ''}
             <button class="kf-btn ghost" @click=${this.#open}>详情 →</button>

+ 39 - 0
src/components/kf-source-detail.js

@@ -0,0 +1,39 @@
+import { LitElement, html, css } from 'lit';
+import { define, useTokens } from './base.js';
+import { fmtDate } from '../core/index.js';
+
+const webUrl = value => {
+  try { const url = new URL(value); return ['https:', 'http:'].includes(url.protocol) ? url.href : ''; }
+  catch { return ''; }
+};
+
+/** 只展示公开看板已提供的来源,不发起额外查询。 */
+export class KfSourceDetail extends LitElement {
+  static properties = { source: { type: Object } };
+  connectedCallback() { super.connectedCallback(); useTokens(this); }
+  render() {
+    const s = this.source;
+    if (!s) return '';
+    return html`<dl><div><dt>来源类型</dt><dd>${s.sourceType || '未记录'}</dd></div><div><dt>入库时间</dt><dd>${fmtDate(s.createdAt, true) || '未记录'}</dd></div></dl>
+      ${s.summary ? html`<section><h3>资料摘要</h3><p>${s.summary}</p></section>` : ''}
+      <section><h3>原文</h3>${s.rawText ? html`<div class="raw" data-source-text>${s.rawText}</div>` : html`<p class="muted">这条来源尚未提供原文。</p>`}</section>
+      ${s.tags?.length ? html`<div class="tags">${s.tags.map(tag => html`<span>${tag}</span>`)}</div>` : ''}
+      ${s.sources?.length ? html`<section><h3>出处</h3><ul>${s.sources.map(ref => {
+        const label = typeof ref === 'string' ? ref : ref.ref || ref.url || ref.title || '来源记录';
+        const url = webUrl(typeof ref === 'string' ? ref : ref.url || ref.ref);
+        return html`<li>${url ? html`<a href=${url} target="_blank" rel="noopener noreferrer">${label} ↗</a>` : label}</li>`;
+      })}</ul></section>` : ''}`;
+  }
+  static styles = css`
+    :host { display: block; font-size: 14px; line-height: 1.8; overflow-wrap: anywhere; }
+    dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin: 0 0 24px; }
+    dt,.muted { color: var(--kf-ink-3); } dt { font-size: 12px; } dd { margin: 4px 0 0; }
+    section { padding: 20px 0; border-top: 1px solid var(--kf-line); }
+    h3 { margin: 0 0 12px; font-size: 14px; } p { margin: 0; white-space: pre-wrap; }
+    .raw { white-space: pre-wrap; background: var(--kf-fill); padding: 18px; border-radius: var(--r-sm); }
+    .tags { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; } .tags span { padding: 2px 8px; background: var(--kf-fill); border-radius: 4px; }
+    ul { padding-left: 20px; margin: 0; } li+li { margin-top: 8px; } a { color: var(--kf-accent-ink); }
+    a:focus-visible { outline: 2px solid var(--kf-focus); outline-offset: 3px; }
+  `;
+}
+define('kf-source-detail', KfSourceDetail);

+ 9 - 6
src/components/kf-viz.js

@@ -42,6 +42,8 @@ const VIZ_CSS = css`
     transition: width 500ms cubic-bezier(0.4, 0, 0.2, 1); }
   .brow .n { text-align: right; font-variant-numeric: tabular-nums; color: var(--kf-ink); }
   .heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, var(--cell, 12px)); grid-auto-columns: var(--cell, 12px); gap: 2px; }
+  .heat.row { grid-template-rows: var(--cell, 12px); grid-auto-columns: var(--heat-cell-width, var(--cell, 12px)); }
+  .heat.row i { width: var(--heat-cell-width, var(--cell, 12px)); }
   .heat i { width: var(--cell, 12px); height: var(--cell, 12px); border-radius: 2px;
     background: color-mix(in oklab, var(--kf-accent) calc(var(--v) * 100%), var(--gray-5)); }
 `;
@@ -91,24 +93,25 @@ export class KfBars extends LitElement {
  * 热力小格(v11b 2.4):单元格 12×12、圆角 2px、缝 2px、**4 档**不透明度。
  * 数据可以是 0–1 的数(颜色深浅),也可以是 { v, label }(label 给原生 title / 读屏)。
  * 一列 7 行 = 一周;起点支持 `start`(Date / ISO 串,通常是周一 00:00),
- * 没有 start 时按「最后一格 = 今天」倒推,并在前面补到整周(空格透明),保证周一在第一行。
+ * layout="row" 用于分类对比:横排实际条目,不补日历空格;start 仅对默认日历布局生效。
  */
 export class KfHeat extends LitElement {
-  static properties = { days: { type: Array }, start: { type: String } };
-  constructor() { super(); this.days = []; this.start = ''; }
+  static properties = { days: { type: Array }, start: { type: String }, layout: { type: String } };
+  constructor() { super(); this.days = []; this.start = ''; this.layout = 'calendar'; }
   connectedCallback() { super.connectedCallback(); useTokens(this); }
   static styles = VIZ_CSS;
   render() {
     const items = this.days.map((d) => (d && typeof d === 'object' ? d : { v: d }));
+    const row = this.layout === 'row';
     let cells = items;
-    if (this.start) {
+    if (!row && this.start) {
       const DAY = 86400000;
       const s0 = new Date(this.start).setHours(0, 0, 0, 0);
       const weekday = (new Date(s0).getDay() + 6) % 7; // 周一 = 0
       cells = [...Array.from({ length: weekday }, () => null), ...items];
     }
-    while (cells.length % 7) cells.push(null);
-    return html`<span class="heat" aria-hidden="true">${cells.map((d) => {
+    if (!row) while (cells.length % 7) cells.push(null);
+    return html`<span class="heat ${row ? 'row' : ''}" aria-hidden="true">${cells.map((d) => {
       if (!d) return html`<i style="background:transparent"></i>`;
       const v = Math.max(0, Math.min(1, Number(d.v) || 0));
       // 4 档:0 / 1/3 / 2/3 / 1(0 档用最浅灰,不透明色阶在 oklab 里近似等距)

+ 1 - 0
src/core/evidence/chain.js

@@ -23,6 +23,7 @@ export function reportEvidence(db, artifactId, evId) {
       ref: String(q.ref || ''),
       author: String(q.author || ''),
       likes: q.likes == null ? null : Number(q.likes),
+      sourceId: q.sourceId || ev.sourceId || null,
       artId: q.artId || null,
       art: q.artId ? db.artifactById[q.artId] || null : null,
     })),

+ 31 - 26
src/core/kfs/build.js

@@ -7,9 +7,11 @@
 import { stageOf, evidenceOf, normalizeLayer, uploadMethodLabel, STAGES } from '../taxonomy/taxonomy.js';
 import { toIso, toTime, latest } from './util.js';
 import { normalizeOpportunity } from '../opportunity/opportunity.js';
+import { indexBoardMembers } from '../member-groups.js';
+import { resolveEmployeeRows } from './employee.js';
 
 export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckHours: 8 } } = {}) {
-  const rows = {
+  const input = {
     project: Array.isArray(raw.project) ? raw.project : [],
     artifact: Array.isArray(raw.artifact) ? raw.artifact : [],
     insight: Array.isArray(raw.insight) ? raw.insight : [],
@@ -17,21 +19,15 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
     session: Array.isArray(raw.session) ? raw.session : [],
     opportunity: Array.isArray(raw.opportunity) ? raw.opportunity : [],
   };
-  // New sessions have stable participant keys. Only disambiguate display names when needed.
-  const identities = new Map();
-  for (const s of rows.session) {
-    const key=s.meta?.participantKey;
-    if(key){if(!identities.has(s.studentName))identities.set(s.studentName,new Map());identities.get(s.studentName).set(key,s.groupId||key.slice(-6));}
-  }
-  const displayName=(name,key)=>{
-    const people=identities.get(name);if(!people||people.size<2)return name;
-    const group=people.get(key);
-    const repeated=group&&[...people.values()].filter(g=>g===group).length>1;
-    return `${name}(${group||key?.slice(-8)||'未绑定'}${repeated?' · '+key.slice(-8):''})`;
-  };
-  rows.session=rows.session.map(s=>({...s,studentName:displayName(s.studentName,s.meta?.participantKey)}));
-  for(const k of ['artifact','project'])rows[k]=rows[k].map(r=>({...r,ownerName:displayName(r.ownerName,r.uploadedByKey)}));
-  rows.opportunity=rows.opportunity.map(r=>({...r,uploadedBy:displayName(r.uploadedBy,r.uploadedByKey)}));
+  // Group writes and the public reader resolve the original records identically.
+  // Employee captions may enrich presentation, but must never change a member ID.
+  const directory = indexBoardMembers(input);
+  const rows = resolveEmployeeRows(input);
+  for (const kind of ['session', 'artifact', 'project', 'opportunity']) rows[kind] = rows[kind].map((r) => {
+    const id = directory.memberId(kind, r), member = directory.memberById[id];
+    const field = kind === 'session' ? 'studentName' : kind === 'opportunity' ? 'uploadedBy' : 'ownerName';
+    return { ...r, __memberId: id, ...(member ? { [field]: member.displayName } : {}) };
+  });
 
   /* ---------- 成果 ---------- */
   const citedIds = new Set();
@@ -62,6 +58,8 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
       id: r.objectId,
       title: String(r.title || '(未命名项目)'),
       ownerName: String(r.ownerName || ''),
+      memberId: r.__memberId,
+      employee: r.employee || null,
       progressState: String(r.progressState || ''),
       currentConceptId: String(r.currentConceptRef?.objectId || ''),
       pendingQuestions: r.meta?.pendingQuestions || [],
@@ -132,24 +130,26 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
   const opportunities = rows.opportunity.map((r) => normalizeOpportunity(r, { artifactById, projectById, toTime }));
   const opportunityById = Object.fromEntries(opportunities.map((o) => [o.id, o]));
 
-  /* ---------- 成员:姓名是身份,会话是进度,成果是「交没交」的权威 ---------- */
+  /* ---------- 成员:稳定身份决定归属,名称只用于展示 ---------- */
   const members = new Map();
-  const touch = (name, key = '') => {
+  const touch = (name, key = '', memberId = '') => {
     const n = String(name || '').trim();
     if (!n) return null;
-    if (!members.has(n)) {
-      members.set(n, { name: n, keys: [], sessionIds: [], artifactIds: [], projectIds: [], oppIds: [],
+    const id = memberId || 'legacy-name:' + encodeURIComponent(n);
+    if (!members.has(id)) {
+      members.set(id, { id, name: n, keys: [], sessionIds: [], artifactIds: [], projectIds: [], oppIds: [],
         stageObj: null, msgCount: 0, groupId: '', lastActiveAt: null, lastMsgAt: null, done: false,
         stuck: false, stuckHours: 0, stale: false, stagesSubmitted: new Set() });
     }
-    const m = members.get(n);
+    const m = members.get(id);
     if (key && !m.keys.includes(key)) m.keys.push(key);
     return m;
   };
   for (const s of rows.session) {
-    const m = touch(s.studentName, s.meta?.participantKey || '');
+    const m = touch(s.studentName, s.meta?.participantKey || '', s.__memberId);
     if (!m) continue;
     m.sessionIds.push(s.objectId);
+    if (s.employee) m.employee = s.employee;
     m.groupId = m.groupId || String(s.groupId || '');
     m.msgCount = Math.max(m.msgCount, Number(s.msgCount) || 0);
     const st = stageOf(s.stageCode || s.stage);
@@ -160,24 +160,26 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
     m.lastActiveAt = latest([m.lastActiveAt, s.lastActiveAt, s.lastActiveAt ? null : s.createdAt]);
   }
   for (const a of artifacts) {
-    const m = touch(a.ownerName, a.ownerKey);
+    const m = touch(a.ownerName, a.ownerKey, a.memberId);
     if (!m) continue;
     m.artifactIds.push(a.id);
+    if (a.employee) m.employee = a.employee;
     if (a.stageObj) m.stagesSubmitted.add(a.stageObj.code);
     if (a.stageObj && (!m.stageObj || a.stageObj.no > m.stageObj.no)) m.stageObj = a.stageObj;
     m.lastActiveAt = latest([m.lastActiveAt, a.createdAt]);
   }
   for (const p of projects) {
-    const m = touch(p.ownerName, p.ownerKey);
+    const m = touch(p.ownerName, p.ownerKey, p.memberId);
     if (!m) continue;
     m.projectIds.push(p.id);
+    if (p.employee) m.employee = p.employee;
     if(p.progressState==='blocked')m.blocked=true;
     if(p.stage&&(!m.stageObj||p.stage.no>m.stageObj.no))m.stageObj=p.stage;
     m.lastActiveAt = latest([m.lastActiveAt, p.lastActiveAt]);
   }
   for (const o of opportunities) {
-    const m = touch(o.upload.uploadedBy, o.upload.uploadedByKey);
-    if (m) m.oppIds.push(o.id);
+    const m = touch(o.upload.uploadedBy, o.upload.uploadedByKey, rows.opportunity.find((r) => r.objectId === o.id)?.__memberId);
+    if (m) { m.oppIds.push(o.id); if (o.upload.employee) m.employee = o.upload.employee; }
   }
   const stuckMs = (Number(stuck.stuckHours) || 8) * 3600000;
   const memberList = [...members.values()].map((m) => {
@@ -194,6 +196,7 @@ export function buildModel(raw, { now = Date.now(), stuck = { minMsgs: 5, stuckH
   return {
     artifacts, artifactById, projects, projectById, insights, insightById,
     sources, sourceById, opportunities, opportunityById, members: memberList, memberByName,
+    memberById: Object.fromEntries(memberList.map((m) => [m.id, m])),
     loadedAt: raw.loadedAt || null, truncated: raw.truncated || [], now,
   };
 }
@@ -211,6 +214,8 @@ function normArtifact(r, citedIds, toTimeFn) {
     shortTitle: String(r.title || '(未命名成果)').replace(/\s*-\s*[a-z_]+$/, ''),
     type: String(r.artifactType || ({market_trend_report:'category_report',competitor_analysis_report:'competitor_report',voc_report:'voc_report',pain_point_report:'pain_list',product_concept_report:'concept'}[r.reportType]) || ''),
     ownerName: String(r.ownerName || ''),
+    memberId: r.__memberId,
+    employee: r.employee || null,
     ownerKey,
     stageObj: st,
     stage: st ? st.label : r.stage || '—',

+ 51 - 0
src/core/kfs/employee.js

@@ -0,0 +1,51 @@
+/** Public employee profiles carry a stable, opaque key; names never establish identity. */
+export function resolveEmployeeRows(input) {
+  const rows = Object.fromEntries(Object.entries(input).map(([kind, list]) => [kind, [...list]]));
+  const sessions = new Map(rows.session.map((s) => [s.objectId, s]));
+  const participants = new Map(rows.session.filter((s) => s.meta?.participantKey).map((s) => [s.meta.participantKey, s]));
+  const profile = (r) => {
+    const p = r?.employee;
+    if (!p || !/^(?:employee|channel-employee):[a-f0-9]{64}$/.test(p.key)) return null;
+    return p;
+  };
+  const sessionOf = (r) => sessions.get(r.sessionRef?.objectId || r.ownerSession || r.sourceSession) || participants.get(r.uploadedByKey);
+  const nameOf = (r, kind) => kind === 'session' ? r.studentName : kind === 'opportunity' ? r.uploadedBy : r.ownerName;
+  const identity = (r, kind) => {
+    const s = kind === 'session' ? r : sessionOf(r), employee = profile(r) || profile(s);
+    const key = employee?.key || s?.meta?.participantKey || r.uploadedByKey || '';
+    const name = employee?.name || employee?.displayName || nameOf(r, kind) || '';
+    return { key, name, employee, group: r.groupId || s?.groupId || '', session: s };
+  };
+  const names = new Map();
+  for (const kind of ['session', 'artifact', 'project', 'opportunity']) {
+    for (const r of rows[kind]) {
+      const p = identity(r, kind);
+      if (!p.name || !p.key) continue;
+      if (!names.has(p.name)) names.set(p.name, new Map());
+      const prior = names.get(p.name).get(p.key);
+      names.get(p.name).set(p.key, { ...p, group: prior?.group || p.group });
+    }
+  }
+  for (const kind of ['session', 'artifact', 'project', 'opportunity']) {
+    rows[kind] = rows[kind].map((r) => {
+      const p = identity(r, kind), sameName = names.get(p.name);
+      let name = p.name;
+      if (sameName?.size > 1) {
+        const person = sameName.get(p.key), label = person?.employee?.departmentNames?.join('、') || person?.group || p.key.slice(-6) || '未绑定';
+        const repeated = [...sameName.values()].filter((x) => (x.employee?.departmentNames?.join('、') || x.group || x.key.slice(-6)) === label).length > 1;
+        name += `(${label}${repeated ? ' · ' + p.key.slice(-8) : ''})`;
+      }
+      const field = kind === 'session' ? 'studentName' : kind === 'opportunity' ? 'uploadedBy' : 'ownerName';
+      return { ...r, [field]: name, ...(p.employee ? { employee: p.employee } : {}) };
+    });
+  }
+  return rows;
+}
+
+export function employeeCaption(employee) {
+  if (!employee) return '';
+  const parts = [employee.organizationName, ...(employee.departmentNames || []), employee.position].filter(Boolean);
+  if (employee.nameVerified && employee.displayName && employee.displayName !== employee.name) parts.push('显示名:' + employee.displayName);
+  parts.push(employee.nameVerified ? '企业微信通讯录姓名' : employee.source === 'self_reported' && employee.displayName ? '本人提供的称呼 · 未经通讯录核验' : employee.displayName ? '企业微信显示名 · 实名待同步' : '企业微信发送者 · 姓名待同步');
+  return [...new Set(parts)].join(' · ');
+}

+ 69 - 0
src/core/library.js

@@ -0,0 +1,69 @@
+/** Shared presentation and URL filters; data remains in the read-only service. */
+export const LIBRARY_ENTITIES={products:'商品与竞品',reviews:'电商评价',social:'社媒内容',documents:'政策文件',clauses:'条款与应用',prices:'价格快照',specs:'商品规格',rankings:'榜单线索',coverage:'采集覆盖',reports:'已有研究成果'};
+export const LIBRARY_GROUPS=[{name:'商品研究',icon:'inbox',entities:['products']},{name:'消费洞察',icon:'chat',entities:['social']},{name:'政策与膳食依据',icon:'shield',entities:['documents']},{name:'研究沉淀',icon:'doc',entities:['reports']}];
+export const PRODUCT_PANELS={overview:'商品概况',specs:'包装规格',prices:'价格记录',reviews:'消费者评价',rankings:'榜单线索'};
+const PRODUCT_QUERY_KEYS=['panel','relatedQ','relatedQuality','relatedCursor','relatedFrom','relatedTo'];
+export function libraryCatalogQuery(query={}){return Object.fromEntries(Object.entries(query).filter(([key])=>!PRODUCT_QUERY_KEYS.includes(key)));}
+export function libraryProductRequest(productId,query={}){
+  const entity=Object.hasOwn(PRODUCT_PANELS,query.panel)?query.panel:'overview';
+  if(entity==='overview')return null;
+  const filters={productId};
+  const dateField=entity==='reviews'?'reviewDate':entity==='prices'?'sourceDate':null;
+  if(dateField&&(query.relatedFrom||query.relatedTo))filters[dateField]={...(query.relatedFrom?{min:query.relatedFrom}:{}),...(query.relatedTo?{max:query.relatedTo}:{})};
+  return {op:'browse',entity,filters,quality:query.relatedQuality==='audit'?'audit':'analysis',limit:20,
+    ...(query.relatedQ?{keyword:query.relatedQ}:{}),...(query.relatedCursor?{cursor:query.relatedCursor}:{})};
+}
+export const LIBRARY_FILTERS={platform:'平台',brand:'品牌',category:'品类',shopId:'店铺',policyStatus:'政策状态',contentScope:'内容范围',specVerification:'规格依据',priceBasis:'价格口径'};
+export const FACET_COLUMNS={platform:'platform',brand:'brand',category:'category',shopId:'shop_id',policyStatus:'policy_status',contentScope:'content_scope',specVerification:'spec_verification',priceBasis:'price_basis'};
+export const LIBRARY_LABELS={jd:'京东',tmall:'天猫',taobao_unverified:'淘宝/渠道待核',xiaohongshu:'小红书',douyin:'抖音',bilibili:'哔哩哔哩',current:'现行(采集时声明)',future:'待实施',draft:'征求意见',source_attribute:'来源属性',title_candidate:'标题声明,待核对',unknown:'未知',matched:'商品关联一致',unverified:'关联待核对',mismatch:'关联不符',orphan:'未关联商品',consistent:'购买规格符合',listing_only:'仅关联商品链接',conflict:'规格冲突',supported:'有分类依据',pending:'待核对',unavailable:'无法计算',verified:'已核对',search_listing_snapshot:'搜索展示价',detail_snapshot:'详情观察价',detail_page_signal:'详情页榜单标识'};
+Object.assign(LIBRARY_LABELS,{announcement:'公告',dietary_guidance:'膳食指导',draft_consultation:'征求意见',future_standard_explanation:'待实施标准解读',historical_call_closed:'历史征集(已结束)',policy_guidance:'政策指导',adjacent_noodle_food:'面食相关内容',packaged_noodles:'包装面食内容',partial_blocked:'部分采集,后续受阻',attempted_failed:'已尝试,采集失败',not_attempted_service_blocked:'服务受阻,未采集',api_terminated_history_unverified:'接口终止,历史覆盖未核实',detail_page_badge_not_complete_ranking:'商品详情页标识,非完整榜单',rule_title_prefix:'标题首部品牌规则',rule_title_explicit:'标题明确分类规则',paraphrase_or_transcription:'摘要或转写',unverified_excerpt:'摘录待逐字核对'});
+Object.assign(LIBRARY_LABELS,{'priceFloor.price':'商品详情价格字段','priceFloor.ext.jdPrice':'京东参考价字段',price:'上游价格(口径待核对)',reservePrice:'上游参考价(口径待核对)'});
+export function libraryDate(value){if(!value)return '未知';if(/(?:Z|[+-]\d\d:\d\d)$/.test(value)){const d=new Date(value);if(!Number.isNaN(d.valueOf()))return new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).format(d);}return String(value).replace('T',' ').replace(/\.\d+$/,'');}
+export function libraryLabel(value,summary){if(value===null||value===undefined||value==='')return '未知/待核对';return summary?.taxonomy?.brands.find(x=>x.code===value)?.name||summary?.taxonomy?.categories.find(x=>x.code===value)?.name||LIBRARY_LABELS[value]||String(value);}
+export function libraryRequest(query={}){
+  const entity=Object.hasOwn(LIBRARY_ENTITIES,query.type)?query.type:'products';
+  const filters={};for(const key of [...Object.keys(LIBRARY_FILTERS),'productId','documentId'])if(query[key])filters[key]=query[key]==='__unknown'?{exists:false}:query[key];
+  const timeField=entity==='reviews'?'reviewDate':entity==='social'?'publishedDate':['products','prices','specs','rankings','documents','clauses'].includes(entity)?'sourceDate':null;
+  if(timeField&&(query.from||query.to))filters[timeField]={...(query.from?{min:query.from}:{}),...(query.to?{max:query.to}:{})};
+  return {op:'browse',entity,filters,quality:query.quality==='audit'?'audit':'analysis',limit:20,...(query.q?{keyword:query.q}:{}),...(query.cursor?{cursor:query.cursor}:{})};
+}
+export function libraryPath(query={},detail=null){const q=new URLSearchParams(Object.entries(query).filter(([,v])=>v!==null&&v!==undefined&&v!==''));return (detail?`/library/${encodeURIComponent(detail.entity)}/${encodeURIComponent(detail.id)}`:'/library')+(q.size?'?'+q:'');}
+export const LIBRARY_FIELDS={title:'标题',content:'原文',full_text:'文件全文',clause:'条款定位',excerpt:'条款摘录',application_note:'应用提示(非官方原话)',excerpt_type:'摘录口径',platform_family:'平台接口族',marketplace:'销售平台',platform:'内容平台',item_id:'平台商品编号',shop_name:'店铺',shop_id:'店铺编号',brand_code:'品牌',category_code:'品类',classification_state:'分类状态',brand_basis:'品牌依据',category_basis:'品类依据',spec_text:'规格原文',net_weight_g:'净含量(克)',pack_count:'件数',total_weight_g:'总重量(克)',verification:'规格核对依据',price_basis:'价格口径',raw_amount:'原始价格',amount_minor:'金额(人民币分)',price_per_100g:'每100克价格(元)',unit_price_verification:'单位价格依据',source_time_raw:'来源观察时间',collected_at_raw:'采集时间',review_date_raw:'评价日期',purchased_sku_text:'购买规格',rating_raw:'原始评分',rating_scale:'评分体系',identity_state:'商品关联状态',category_state:'购买规格状态',is_default:'是否默认评价',append_content_json:'追加评价',published_at_raw:'发表时间',content_scope:'内容覆盖范围',fulltext_available:'是否有正文',declared_status:'政策状态(采集时声明)',status_as_of:'政策状态截至日期',issued_date:'发布日期',effective_date:'实施日期',coverage:'内容覆盖',rank_number:'排名标识',rank_id:'榜单编号',scope:'榜单覆盖口径',available_reviews:'可采评价数',reported_total:'上游报告评价数',historical_total_complete:'历史评价是否采全',status:'覆盖状态'};
+
+// The reader sees business facts, not an automatic dump of database columns.
+const READER_FIELDS = {
+  products: ['marketplace', 'shop_name', 'brand_code', 'category_code', 'source_time_raw'],
+  reviews: ['platform_family', 'purchased_sku_text', 'rating_raw', 'review_date_raw'],
+  social: ['platform', 'published_at_raw', 'likes', 'comment_count', 'collects', 'shares'],
+  documents: ['declared_status', 'status_as_of', 'issued_date', 'effective_date'],
+  clauses: ['clause', 'pdf_page'],
+  specs: ['spec_text', 'net_weight_g', 'pack_count', 'total_weight_g'],
+  prices: ['observed_price', 'price_basis', 'price_per_100g', 'source_time_raw'],
+  rankings: ['rank_number', 'scope', 'source_time_raw'],
+  coverage: ['status', 'available_reviews', 'reported_total'],
+};
+const READER_LABELS = {
+  platform_family: '来源平台', source_time_raw: '观察时间', published_at_raw: '发表时间',
+  rating_raw: '评分', pdf_page: '所在页码', spec_text: '包装标注', observed_price: '观察价格',
+  likes: '平台点赞数', comment_count: '平台显示评论量', collects: '平台收藏数', shares: '平台分享数',
+  scope: '榜单说明', available_reviews: '已获取评价数', reported_total: '平台显示评价量', status: '资料覆盖情况',
+};
+const READER_VALUES = {
+  'priceFloor.price': '商品页面标价', 'priceFloor.ext.jdPrice': '商品页面参考价',
+  price: '展示价(具体口径待确认)', reservePrice: '参考价(具体口径待确认)',
+};
+export function libraryRecordInfo(record, entity, summary) {
+  const fields = READER_FIELDS[entity] || [];
+  return fields.flatMap(key => {
+    let value = record[key];
+    if (key === 'observed_price') {
+      value = record.amount_minor != null && Number.isFinite(Number(record.amount_minor))
+        ? `¥${(Number(record.amount_minor) / 100).toFixed(2)}` : record.raw_amount;
+    }
+    if (key === 'price_per_100g' && record.unit_price_verification !== 'verified') return [];
+    if (value === null || value === undefined || value === '' || typeof value === 'object') return [];
+    const label = READER_LABELS[key] || LIBRARY_FIELDS[key];
+    const text = key.endsWith('_raw') && key !== 'rating_raw' ? libraryDate(value) : READER_VALUES[value] || libraryLabel(value, summary);
+    return [[label, text]];
+  });
+}

+ 11 - 2
src/core/library/reports.js

@@ -770,14 +770,23 @@ export function reportCenter(db, { now = Date.now() } = {}) {
   });
   const mapped = new Set(Object.keys(TYPE_FOR_ARTIFACT));
   const unclassified = db.artifacts.filter((a) => !mapped.has(a.type)).map((a) => ({ id: a.id, title: a.title, type: a.type || '(空)', owner: a.ownerName, stage: a.stageObj?.code || '', at: a.createdAt }));
+  // 类型页允许同一机会卡被不同视角引用;中心清单每条只出现一次,未归类成果也可打开。
+  const unique = new Map();
+  for (const group of types) for (const entry of group.all) {
+    const key = `${entry.kind}:${entry.id}`;
+    if (!unique.has(key)) unique.set(key, { ...entry, typeName: entry.kind === 'opportunity' ? '机会卡' : group.type.name });
+  }
+  for (const item of unclassified) unique.set(`artifact:${item.id}`, { ...entryOfArtifact(db, db.artifactById[item.id], 'other'), typeName: '未归类' });
+  const entries = [...unique.values()];
   const all = types.reduce((n, t) => n + t.count, 0);
   const max = Math.max(...types.map((t) => t.count), 1);
   return {
     types: types.map((t) => ({ ...t, share: all ? Math.round((t.count / all) * 100) : 0, relative: Math.round((t.count / max) * 100) })),
-    total: all,
+    total: entries.length,
+    entries,
     max,
     unclassified,
-    stageTally: STAGES.map((s) => ({ stage: s, count: types.reduce((n, t) => n + t.stageTally.byStage[s.code], 0) })),
+    stageTally: STAGES.map((s) => ({ stage: s, count: entries.filter(e => e.stage?.code === s.code).length })),
     _reportKeys: REPORT_TYPE_KEYS,
   };
 }

+ 94 - 0
src/core/member-groups.js

@@ -0,0 +1,94 @@
+/** Public member identity and presentation groups. These groups grant no access. */
+export const MEMBER_GROUP_SCHEMA = 'ksf.member-groups.v1';
+const memberText = (v) => typeof v === 'string' ? v.trim() : '';
+const stableMemberKey = (v) => /^(?:channel-employee|employee|participant(?:-account|-registration)?):/.test(memberText(v));
+
+/** Resolve stable identities first; names only identify legacy rows without identity. */
+export function indexBoardMembers(raw) {
+  const kinds = ['session', 'artifact', 'project', 'opportunity'];
+  const aliases = new Map(), entries = new Map(), refs = new Map();
+  const rowName = (row, kind) => memberText(row.employee?.name || row.employee?.displayName ||
+    (kind === 'session' ? row.studentName : kind === 'opportunity' ? row.uploadedBy : row.ownerName));
+  const rowKey = (row, kind) => memberText(row.employee?.key || (kind === 'session' ? row.meta?.participantKey : row.uploadedByKey));
+  const sessionRef = (row, kind) => memberText(kind === 'session' ? row.objectId : row.sessionRef?.objectId || row.ownerSession || row.sourceSession);
+  // Employee identities override older workshop participant aliases across sessions.
+  for (const kind of kinds) for (const row of raw[kind] || []) {
+    const key = memberText(row.employee?.key);
+    if (!key) continue;
+    if (stableMemberKey(row.uploadedByKey)) aliases.set(row.uploadedByKey, key);
+    if (stableMemberKey(row.meta?.participantKey)) aliases.set(row.meta.participantKey, key);
+    if (sessionRef(row, kind)) aliases.set('session:' + sessionRef(row, kind), key);
+  }
+  for (const row of raw.session || []) {
+    const key = rowKey(row, 'session');
+    if (stableMemberKey(key)) aliases.set('session:' + row.objectId, aliases.get(key) || key);
+  }
+  for (const kind of kinds) for (const row of raw[kind] || []) {
+    const name = rowName(row, kind), key = rowKey(row, kind), ref = sessionRef(row, kind);
+    if (!name) continue;
+    const id = aliases.get(key) || aliases.get('session:' + ref) || (stableMemberKey(key) ? key : 'legacy-name:' + encodeURIComponent(name));
+    if (!entries.has(id)) entries.set(id, { id, name, groupId: '', sessionIds: [] });
+    const entry = entries.get(id);
+    if (row.employee?.name || row.employee?.displayName) entry.name = name;
+    if (kind === 'session') {
+      entry.sessionIds.push(row.objectId);
+      entry.groupId ||= memberText(row.groupId);
+    }
+    refs.set(kind + ':' + row.objectId, id);
+  }
+  const counts = new Map();
+  for (const m of entries.values()) counts.set(m.name, (counts.get(m.name) || 0) + 1);
+  const labels = new Set();
+  for (const m of entries.values()) {
+    let label = m.name;
+    if (counts.get(m.name) > 1) {
+      const sameGroup = [...entries.values()].filter((x) => x.name === m.name && x.groupId === m.groupId).length > 1;
+      label += `(${m.groupId || m.id.slice(-8)}${m.groupId && sameGroup ? ' · ' + m.id.slice(-8) : ''})`;
+    }
+    if (labels.has(label)) label += ` · ${m.id}`;
+    labels.add(label);m.displayName = label;
+  }
+  return { members: [...entries.values()], memberById: Object.fromEntries(entries),
+    memberId: (kind, row) => refs.get(kind + ':' + row.objectId) || '' };
+}
+
+export function seedMemberGroups(members) {
+  const names = [...new Set(members.map((m) => m.groupId).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh'));
+  const groups = names.map((name) => ({ id: 'legacy-group:' + encodeURIComponent(name), name }));
+  return { schema: MEMBER_GROUP_SCHEMA, revision: 0, groups,
+    assignments: Object.fromEntries(members.filter((m) => m.groupId).map((m) => [m.id, 'legacy-group:' + encodeURIComponent(m.groupId)])), updatedAt: null };
+}
+
+/** Deterministic mutation used by the server and local preview, never changes members. */
+export function changeMemberGroups(current, command, memberIds, createdId) {
+  const invalid = (message) => { throw Object.assign(new Error(message), { code: 'VALIDATION_FAILED' }); };
+  const state = structuredClone(current), { action, groupId } = command;
+  if (!['create', 'rename', 'delete', 'assign'].includes(action)) invalid('不支持的分组操作');
+  const group = state.groups.find((g) => g.id === groupId);
+  if (['rename', 'delete'].includes(action) && !group) invalid('分组已不存在,请刷新');
+  if (action === 'create' || action === 'rename') {
+    if (typeof command.name !== 'string') invalid('请输入分组名称');
+    const name = command.name.normalize('NFKC').trim().replace(/\s+/gu, ' ');
+    if (!name || [...name].length > 30 || /[\u0000-\u001f\u007f]/u.test(name)) invalid('分组名称须为 1–30 个字符');
+    if (['未分组', '全部成员'].includes(name)) invalid('请使用其他分组名称');
+    if (state.groups.some((g) => g.id !== groupId && g.name.toLocaleLowerCase() === name.toLocaleLowerCase())) invalid('已存在同名分组');
+    if (action === 'create') {
+      if (state.groups.length >= 100) invalid('最多创建 100 个分组');
+      state.groups.push({ id: createdId, name });
+    } else group.name = name;
+  }
+  if (action === 'delete') {
+    state.groups = state.groups.filter((g) => g.id !== groupId);
+    for (const [key, value] of Object.entries(state.assignments)) if (value === groupId) delete state.assignments[key];
+  }
+  if (action === 'assign') {
+    if (groupId !== null && !group) invalid('请选择有效分组');
+    if (!Array.isArray(command.memberIds) || !command.memberIds.length || command.memberIds.length > 500 || new Set(command.memberIds).size !== command.memberIds.length) invalid('请选择 1–500 位成员');
+    for (const key of command.memberIds) {
+      if (typeof key !== 'string' || !memberIds.has(key)) invalid('成员已不在当前看板中,请刷新');
+      if (groupId === null) delete state.assignments[key]; else state.assignments[key] = groupId;
+    }
+  }
+  state.revision++;
+  return state;
+}

+ 1 - 0
src/core/opportunity/opportunity.js

@@ -17,6 +17,7 @@ import { scoreStats } from './score.js';
 /** 「谁传的」四要素 + 一个扩展位(后续会开多个数字分身,别写死单个) */
 export function normUpload(row, { by, key } = {}) {
   const u = {
+    employee: row.employee || null,
     uploadedBy: String(row.uploadedBy && row.uploadedBy !== 'skill' && row.uploadedBy !== 'assistant' ? row.uploadedBy : by || ''),
     uploadedByKey: String(row.uploadedByKey || key || ''),
     agentName: String(row.agentName || ''),

+ 15 - 1
src/core/report.js

@@ -73,7 +73,7 @@ export function reportOverview(db, artifactId) {
   };
 }
 
-/** 结论块:从 findings 小节里取(同一证据锚点只取第一次出现),并附上它的证据厚度 */
+/** 结论与资料引用:保留 findings 顺序,并补齐没有 findings 小节的证据锚点。 */
 export function findingsOf(db, artifactId) {
   const a = db.artifactById[artifactId];
   if (!a?.report) return [];
@@ -96,6 +96,20 @@ export function findingsOf(db, artifactId) {
       });
     }
   }
+  // Table/paragraph reports also carry source-linked quotations. Keep them reachable.
+  for (const [evId, e] of Object.entries(a.report.evidence || {})) {
+    if (seen.has(evId)) continue;
+    seen.add(evId);
+    out.push({
+      id: evId,
+      evId,
+      conclusion: String(e.claim || '资料引用'),
+      attitude: '',
+      ownerCount: e.ownerCount ?? null,
+      quoteCount: (Array.isArray(e.quotes) ? e.quotes : []).length,
+      derivedCount: (Array.isArray(e.derivedFrom) ? e.derivedFrom : []).filter(Boolean).length,
+    });
+  }
   return out;
 }
 

+ 52 - 0
src/dev/member-groups.mjs

@@ -0,0 +1,52 @@
+/** Local preview persists presentation groups on this computer; never writes production. */
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { attachMemberGroupHandlers, readMemberGroupBoard } from '../../server/src/member-groups.mjs';
+
+export function createDevMemberGroupApi({ file, loadBoard = () => readMemberGroupBoard({ url: 'https://kangshifu.fmode.cn/api/functions', appId: 'FKieRbIoGt_main' }) }) {
+  let queue = Promise.resolve();
+  const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
+  const hash = (s) => crypto.createHash('sha256').update(s).digest('hex');
+  const read = async () => { try { return JSON.parse(await fs.readFile(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return { config: null, operations: {} };throw e; } };
+  const app = { fail, hash, handlers: {} };
+  const storeFor = (state) => ({
+    async lock() {},
+    async list(_name, { where }) { return state.config?.key === where.key ? [structuredClone(state.config)] : []; },
+    async create(_name, row) { state.config = { objectId: 'local-groups', ...structuredClone(row) };return structuredClone(state.config); },
+    async update(_name, _id, row) { Object.assign(state.config, structuredClone(row));return structuredClone(state.config); },
+  });
+  const store = { async list(...args) { await queue;return storeFor(await read()).list(...args); } };
+  app.operation = async (_actor, _name, input, fn) => {
+    const execute = async () => {
+      if (typeof input.requestId !== 'string' || !input.requestId.trim() || input.requestId.length > 500) fail('VALIDATION_FAILED', '缺少请求编号');
+      const state = await read(), payload = hash(JSON.stringify(input)), prior = state.operations[input.requestId];
+      if (prior) { if (prior.payload !== payload) fail('IDEMPOTENCY_CONFLICT', '请求编号对应不同操作');return prior.receipt; }
+      const receipt = await fn(storeFor(state));state.operations[input.requestId] = { payload, receipt };
+      await fs.mkdir(path.dirname(file), { recursive: true });
+      const temp = file + '.' + crypto.randomUUID();
+      try { await fs.writeFile(temp, JSON.stringify(state), { mode: 0o600, flag: 'wx' });await fs.rename(temp, file); }
+      finally { await fs.rm(temp, { force: true }); }
+      return receipt;
+    };
+    const pending = queue.then(execute);queue = pending.catch(() => {});return pending;
+  };
+  attachMemberGroupHandlers(app, { store, loadBoard });
+  return async (req, res) => {
+    try {
+      if (req.method !== 'POST') { res.writeHead(405);res.end();return; }
+      const chunks = [];let size = 0;
+      for await (const chunk of req) { size += chunk.length;if (size > 256000) fail('VALIDATION_FAILED', '请求过大');chunks.push(chunk); }
+      const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+      const name = input.path?.replace(/^\//, '');
+      if (!Object.hasOwn(app.handlers, name)) fail('FUNCTION_NOT_FOUND', '接口不存在');
+      if (!input.params || typeof input.params !== 'object' || Array.isArray(input.params)) fail('VALIDATION_FAILED', '请求参数无效');
+      const data = await app.handlers[name](input.params);
+      res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
+      res.end(JSON.stringify({ result: { ok: true, data: { ...data, localPreview: true } } }));
+    } catch (e) {
+      res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
+      res.end(JSON.stringify({ result: { ok: false, errors: [{ code: e.code || 'GROUP_REQUEST_FAILED', message: e.code ? e.message : '分组操作失败,请刷新核对' }] } }));
+    }
+  };
+}

+ 31 - 2
src/dev/serve.mjs

@@ -11,20 +11,31 @@
  * 用法:
  *   node src/dev/serve.mjs                    # 根部署(先 node scripts/config.mjs local)
  *   node src/dev/serve.mjs --mount /kangshifu/dev/inno-board/   # 子路径(先 config.mjs local-sub)
- *   --port 4173  --parse https://kangshifu.fmode.cn/parse
+ *   默认统一预览端口 4181;不再从历史交付目录启动预览。
+ *   --port 4181  --parse https://kangshifu.fmode.cn/parse
  */
 import { createServer } from 'node:http';
+import { existsSync } from 'node:fs';
 import { readFile, stat } from 'node:fs/promises';
 import { extname, join, normalize, sep } from 'node:path';
 import { dirname } from 'node:path';
 import { fileURLToPath } from 'node:url';
+import { createDevMemberGroupApi } from './member-groups.mjs';
+import { createLibraryApi } from '../../server/library-api.mjs';
 
 const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
 const argv = process.argv.slice(2);
 const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d);
-const PORT = Number(arg('--port', process.env.PORT || 4173));
+const PORT = Number(arg('--port', process.env.PORT || 4181));
 const MOUNT = (arg('--mount', '/') || '/').replace(/\/+$/, '');
 const PARSE = arg('--parse', 'https://kangshifu.fmode.cn/parse').replace(/\/+$/, '');
+const memberGroupsApi = createDevMemberGroupApi({ file: join(ROOT, 'work/member-groups-preview/state.json') });
+const libraryApi = createLibraryApi({
+  registry: process.env.KSF_LIBRARY_REGISTRY || (existsSync(join(ROOT,'work/library-preview/registry/current.json')) ? join(ROOT,'work/library-preview/registry') : undefined),
+  db: process.env.KSF_VOC_DB || join(ROOT, 'artifacts/康师傅VOC结构化入库-20261105/ksf-voc-library-20261105-r2/library/library.sqlite'),
+  python: process.env.KSF_LIBRARY_PYTHON || (process.platform === 'win32' ? 'C:/Users/JSHY/AppData/Local/Python/pythoncore-3.14-64/python.exe' : 'python3'),
+  localPreview: true,
+});
 
 const MIME = {
   '.html': 'text/html; charset=utf-8',
@@ -51,6 +62,13 @@ async function file(path) {
 
 const server = createServer(async (req, res) => {
   const url = new URL(req.url, `http://${req.headers.host}`);
+  if (url.pathname === '/__dev/info') {
+    res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
+    res.end(JSON.stringify({ project: 'kangshifu-board', root: ROOT, port: PORT, mount: MOUNT || '/', preview: 'unified' }));
+    return;
+  }
+  if (url.pathname === '/__dev/member-groups') return memberGroupsApi(req, res);
+  if (url.pathname === '/api/library') return libraryApi(req, res);
 
   // Parse 同源代理(只转发 GET/POST/PUT,带 App ID 头;凭据头一律不透传)
   if (url.pathname === '/parse' || url.pathname.startsWith('/parse/')) {
@@ -85,6 +103,10 @@ const server = createServer(async (req, res) => {
     res.end('forbidden');
     return;
   }
+  // Research databases and operator files are never static browser downloads.
+  if (/\.(sqlite|sqlite3|db|zip)$/i.test(abs) || /(?:^|[\\/])(?:\.credentials|\.env)(?:[.\\/]|$)/i.test(abs)) {
+    res.writeHead(403);res.end('forbidden');return;
+  }
   const hit = await file(abs);
   if (hit) {
     const ext = extname(hit);
@@ -105,8 +127,15 @@ const server = createServer(async (req, res) => {
   res.end(await readFile(join(ROOT, 'index.html')));
 });
 
+server.on('error', (error) => {
+  if (error.code === 'EADDRINUSE') {
+    console.error(`端口 ${PORT} 已有预览服务。请使用现有入口 http://127.0.0.1:${PORT}/,不要另开端口。`);
+    process.exitCode = 1;
+  } else throw error;
+});
 server.listen(PORT, '127.0.0.1', () => {
   const base = `http://127.0.0.1:${PORT}${MOUNT}/`;
   console.log(`KF dev server → ${base}`);
+  console.log(`  source=${ROOT}  preview=unified`);
   console.log(`  mount=${MOUNT}/  parse=${PARSE}`);
 });

+ 60 - 0
src/layout/detail-styles.js

@@ -0,0 +1,60 @@
+/** 详情正文在弹窗的 Shadow DOM 内渲染,样式与正文保持同一作用域。 */
+import { css } from 'lit';
+
+export const DETAIL_STYLES = css`
+    /* 抽屉内部排版:段落之间用发丝线分隔,不再是卡片套卡片 */
+    .d-sec { min-width: 0; overflow-wrap: anywhere; padding: 16px 0; border-top: 1px solid var(--kf-line); }
+    .d-sec:first-child { border-top: 0; padding-top: 0; }
+    .d-sec h4 { margin: 0 0 8px; font-size: 12px; line-height: 1.5; color: var(--kf-ink-3); font-weight: 600; letter-spacing: var(--ls-cn-small); }
+    .one { font-size: 14px; line-height: var(--lh-body); letter-spacing: var(--ls-cn-body); max-width: 48em; color: var(--kf-ink-2); }
+    .chips { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
+    .st-row { display: flex; gap: 8px; flex-wrap: wrap; }
+    .dec { margin-top: 10px; font-size: 14px; color: var(--kf-ink-2); }
+    .log { margin: 8px 0 0; padding-left: 18px; font-size: 14px; color: var(--kf-ink-2); display: grid; gap: 4px; }
+    .src { font-size: 14px; color: var(--kf-ink-2); }
+    .src.miss, .miss { color: var(--kf-warn-text); font-size: 14px; }
+    .links { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
+    .links li { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 14px; line-height: 1.6; }
+    .lnk { all: unset; cursor: pointer; color: var(--kf-ink); font-weight: 600; border-bottom: 1px dashed var(--kf-line-strong); }
+    .lnk:focus-visible { outline: 2px solid var(--kf-focus); outline-offset: 2px; }
+    .lnk:focus { outline: 2px solid var(--kf-focus); outline-offset: 2px; }
+    .lnk:hover { color: var(--kf-accent); border-bottom-color: var(--kf-accent); }
+    .kv { display: flex; gap: 24px; align-items: center; flex-wrap: wrap; }
+    .kv > div { display: grid; gap: 2px; padding-right: 24px; border-right: 1px solid var(--kf-line); }
+    .kv b { font-size: 20px; line-height: 1.1; font-weight: 600; color: var(--kf-ink); font-variant-numeric: tabular-nums; font-family: var(--kf-font-num); }
+    .kv span { font-size: 12px; line-height: 1.5; color: var(--kf-ink-3); }
+    .kv .kf-btn { margin-left: auto; }
+    .prov { display: flex; flex-wrap: wrap; gap: 4px; align-items: baseline; font-size: 12px; color: var(--kf-ink-3); }
+    .prov .plat { color: var(--kf-ink); font-weight: 600; }
+    .quotes { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
+    .quotes li { min-width: 0; padding: 12px 16px; border-radius: var(--r-md); background: rgba(255,255,255,0.45); border: 1px solid var(--kf-line); }
+    .quotes p { margin: 0 0 4px; font-size: 14px; line-height: var(--lh-body); max-width: 48em; color: var(--kf-ink); }
+
+    .opp-detail { display: grid; gap: 0; overflow-wrap: anywhere; }
+    .opp-hero { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 20px; padding: 0 0 24px; }
+    .opp-hero .one { font-size: 16px; line-height: 1.75; }
+    .report-cta { text-decoration: none; white-space: nowrap; min-height: 42px; }
+    .opp-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; border-top: 1px solid var(--kf-line); padding: 24px 0; }
+    .opp-grid .d-sec { border: 0; padding: 0; min-width: 0; }
+    .d-sec h4 { font-size: 14px; color: var(--kf-ink); margin-bottom: 14px; }
+    .st-row .kf-btn { min-height: 36px; }
+    .dec, .src { line-height: 1.75; }
+    .provenance { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
+    .provenance > div { display: grid; align-content: start; gap: 6px; }
+    .provenance dt { color: var(--kf-ink-3); font-size: 12px; }
+    .provenance dd { margin: 0; color: var(--kf-ink); font-size: 14px; line-height: 1.6; }
+    .provenance { margin: 0; }
+    .links li { justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--kf-line-soft); }
+    .links li:last-child { border-bottom: 0; }
+    .linked-title { display: grid; gap: 4px; min-width: 0; flex: 1 1 200px; }
+    .project-note { margin-top: 12px; line-height: 1.6; }
+    .empty-report { color: var(--kf-ink-3); font-size: 13px; }
+    @media (max-width: 767px) {
+      .opp-hero { grid-template-columns: minmax(0, 1fr); gap: 14px; padding-bottom: 20px; }
+      .report-cta { justify-self: start; }
+      .opp-grid { grid-template-columns: minmax(0, 1fr); gap: 24px; padding: 20px 0; }
+      .provenance { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
+      .lnk { display: inline-flex; align-items: center; min-height: 36px; }
+      .st-row .kf-btn { min-height: 44px; }
+    }
+`;

+ 168 - 111
src/layout/kf-app.js

@@ -7,15 +7,17 @@
  * 布局:氛围光场(装饰层)→ 左侧导航玻璃 → 主区。主区顶栏显示「我是谁」,
  * 深链接进 /pool/:id 时抽屉直接打开(刷新后保持)。
  */
-import { LitElement, html, css, render } from 'lit';
+import { LitElement, html, css } from 'lit';
 import { SignalWatcher } from '@lit-labs/signals';
 import { useTokens } from '../components/base.js';
 import { t } from '../components/cjk.js';
 import { boardService } from '../services/board-service.js';
-import { currentPath, currentQuery, subscribe as subscribeRoute, navigate, href, onLinkClick, match } from '../utils/router.js';
+import { currentUrl, subscribe as subscribeRoute, navigate, href, onLinkClick, safeReturnTo, opportunityUrl, reportUrl, detailUrl, returnToRoute } from '../utils/router.js';
+import { DETAIL_STYLES } from './detail-styles.js';
 import { CONFIG } from '../app.config.js';
-import { traceArtifact, traceInsight, reportEvidence, conclusionToLedger, fmtDate, fmtAgo, STAGES, OPP_STATUS } from '../core/index.js';
+import { traceArtifact, traceInsight, reportEvidence, conclusionToLedger, fmtDate, fmtAgo, STAGES, OPP_STATUS, reportTypeOf } from '../core/index.js';
 import '../components/index.js';
+import '../components/kf-source-detail.js';
 import './kf-nav.js';
 import './kf-topbar.js';
 import '../pages/view-workbench.js';
@@ -26,6 +28,7 @@ import '../pages/view-report-type.js';
 import '../pages/view-report.js';
 import '../pages/view-ledger.js';
 import '../pages/view-members.js';
+import '../pages/view-library.js';
 
 const TITLES = {
   workbench: ['工作台', ''],
@@ -37,6 +40,8 @@ const TITLES = {
   report: ['报告阅读', ''],
   ledger: ['证据台账', '六维筛选:优先级 · 品类 · 阶段 · 人 · 层 · 状态'],
   members: ['成员', ''],
+  library: ['行业资料库', ''],
+  'library-detail': ['资料详情', ''],
   notfound: ['页面不存在', ''],
 };
 
@@ -57,29 +62,43 @@ export class KfApp extends SignalWatcher(LitElement) {
   connectedCallback() {
     super.connectedCallback();
     useTokens(this);
-    this.#unsub = subscribeRoute(({ name, params, path, query }) => {
+    this.#nativeScrollRestoration = history.scrollRestoration;
+    history.scrollRestoration = 'manual';
+    window.addEventListener('pagehide', this.#savePageScroll);
+    this.#unsub = subscribeRoute(({ name, params, path, query, navigation, historyState }) => {
+      // 旧方案介绍与模拟演示链接统一回到实际看板。
+      if (/^\/(?:showcase(?:\/.*)?|cockpit|intel|coop|review|class)\/?$/.test(path)) {
+        navigate('/', { replace: true });
+        return;
+      }
+      const previous = this._route;
       this._route = { name, params, path };
       this._query = query;
       this.#syncDrawerFromRoute(name, params, query);
-      // 换页回到顶部;只改查询串(筛选 / 当前章 / 抽屉)不跳(由最近一次触发者自行滚动)
-      const raw = (path || '/').split('?')[0];
-      const key = raw.replace(/^\/pool\/[^/]+$/, '/pool/:id').replace(/^\/reports\/[^/]+$/, '/reports/:type').replace(/^\/report\/[^/]+$/, '/report/:id');
-      const prevKey = this.#prevKey;
-      this.#prevKey = key;
-      if (prevKey && prevKey !== key && typeof window.scrollTo === 'function' && !window.__KF_NO_SCROLL__) window.scrollTo({ top: 0 });
+      this.#syncLivePolling();
+      // 详情开关不滚动底层列表;报告换编号是换页。历史返回恢复离开时的位置。
+      const samePool = ['pool', 'mode'].includes(previous.name) && ['pool', 'mode'].includes(name);
+      if (['traverse', 'initial'].includes(navigation) && Number.isFinite(historyState?.kfScrollY) && !query.ch) {
+        this.#restoreScroll(historyState.kfScrollY);
+      } else if (navigation !== 'initial' && previous.path !== path && !samePool && name !== 'mode' && !query.ch) {
+        this.#restoreScroll(0);
+      }
+      if (previous.name === 'mode' && name === 'pool') {
+        this.updateComplete.then(() => requestAnimationFrame(() => {
+          const cards = this.renderRoot.querySelector('view-pool')?.shadowRoot?.querySelectorAll('kf-opportunity-card') || [];
+          const card = [...cards].find(c => c.opp?.id === previous.params.id);
+          card?.shadowRoot?.querySelector('kf-glass-card')?.shadowRoot?.querySelector('.frame')?.focus({ preventScroll: true });
+        }));
+      }
     });
     window.addEventListener('click', onLinkClick, true);
     window.addEventListener('pointerover', this.#onPointer, { passive: true });
     // 交互事件统一在 kf-app 上监听(一次绑定,所有视图 / 抽屉共用),detail 在 e.detail 里
     this.#boundAction = (e) => { this.#onAction(e).catch((err) => console.error(err)); };
-    for (const t of ['card-open', 'status-change', 'score-submit', 'evidence-open', 'artifact-open', 'insight-open',
+    for (const t of ['card-open', 'status-change', 'score-submit', 'evidence-open', 'artifact-open', 'insight-open', 'source-open',
       'opportunity-open', 'report-open', 'ledger-jump', 'me-change', 'refresh', 'nav-close']) {
       this.addEventListener(t, this.#boundAction);
     }
-    this.#poll = setInterval(() => {
-      if (document.visibilityState === 'visible') boardService.load({ force: true }).catch(() => {});
-    }, CONFIG.refreshMs);
-    boardService.load().catch(() => {});
     // 入场错峰(任务书 2.6):进入视口的 .kf-enter 才播放;已在首屏的立即播放
     this.#io = new IntersectionObserver((entries) => {
       for (const e of entries) if (e.isIntersecting) { e.target.removeAttribute('data-enter'); this.#io.unobserve(e.target); }
@@ -127,7 +146,14 @@ export class KfApp extends SignalWatcher(LitElement) {
 
   /** 新渲染出来的 .kf-enter:屏外的先挂 pending,滚到时再播 */
   updated() {
-    this.#renderDrawerBody();
+    // 旧卡片把报告链接写成 /pool/:artifactId;兼容已有书签,避免误报机会卡被删除。
+    const id = this._route.params?.id;
+    const db = this.#svc.db.get();
+    if (this._route.name === 'mode' && db?.artifactById[id] && !db.opportunityById[id]) {
+      const q = new URLSearchParams(this._query).toString();
+      navigate(`/report/${encodeURIComponent(id)}${q ? `?${q}` : ''}`, { replace: true });
+      return;
+    }
     this.#armEntrance();
   }
 
@@ -152,23 +178,89 @@ export class KfApp extends SignalWatcher(LitElement) {
     this.#unsub?.();
     window.removeEventListener('click', onLinkClick, true);
     window.removeEventListener('pointerover', this.#onPointer);
-    for (const t of ['card-open', 'status-change', 'score-submit', 'evidence-open', 'artifact-open', 'insight-open',
+    window.removeEventListener('pagehide', this.#savePageScroll);
+    history.scrollRestoration = this.#nativeScrollRestoration;
+    for (const t of ['card-open', 'status-change', 'score-submit', 'evidence-open', 'artifact-open', 'insight-open', 'source-open',
       'opportunity-open', 'report-open', 'ledger-jump', 'me-change', 'refresh', 'nav-close']) {
       this.removeEventListener(t, this.#boundAction);
     }
     clearInterval(this.#poll);
+    this.#liveActive = false;
     this.#io?.disconnect();
+    cancelAnimationFrame(this.#scrollFrame);
+    clearTimeout(this.__relT);
   }
 
   #unsub = null;
   #poll = null;
-  #prevKey = null;
+  #scrollFrame = null;
   #boundAction = null;
+  #liveActive = false;
+  #nativeScrollRestoration = 'auto';
+  #savePageScroll = () => history.replaceState({ ...history.state, kfScrollY: window.scrollY || 0 }, '');
+
+  /** 看板首次载入时启动实时数据同步。 */
+  #syncLivePolling() {
+    if (this.#liveActive) return;
+    this.#liveActive = true;
+    boardService.load().catch(() => {});
+    if (CONFIG.refreshMs > 0) {
+      this.#poll = setInterval(() => {
+        if (document.visibilityState === 'visible') {
+          boardService.load({ force: true }).catch(() => {});
+        }
+      }, CONFIG.refreshMs);
+    }
+  }
 
   #syncDrawerFromRoute(name, params, query) {
-    if (name === 'mode') this._drawer = { type: 'opp', id: params.id };
-    else if (query.ev && query.ev.includes(':') && query.report) this._drawer = { type: 'evidence', reportId: query.report, evId: query.ev };
-    else if (this._drawer && (this._drawer.type === 'opp' || this._drawer.type === 'evidence')) this._drawer = null;
+    if (query.artifact) this._drawer = { type: 'artifact', id: query.artifact };
+    else if (query.insight) this._drawer = { type: 'insight', id: query.insight };
+    else if (query.source) this._drawer = { type: 'source', id: query.source };
+    else if (query.evidence && query.evidenceReport) this._drawer = { type: 'evidence', reportId: query.evidenceReport, evId: query.evidence };
+    else if (query.ev && query.report) this._drawer = { type: 'evidence', reportId: query.report, evId: query.ev };
+    else if (name === 'mode') this._drawer = { type: 'opp', id: params.id };
+    else this._drawer = null;
+  }
+
+  #restoreScroll(top) {
+    cancelAnimationFrame(this.#scrollFrame);
+    this.updateComplete.then(() => {
+      this.#scrollFrame = requestAnimationFrame(() => {
+        this.#scrollFrame = requestAnimationFrame(() => {
+          if (!window.__KF_NO_SCROLL__) window.scrollTo({ top, behavior: 'instant' });
+        });
+      });
+    });
+  }
+
+  #openOpportunity(id) {
+    if (!id) return;
+    const inModal = this._route.name === 'mode' && this._drawer?.type === 'opp';
+    const back = inModal ? safeReturnTo(this._query.returnTo) : currentUrl();
+    navigate(opportunityUrl(id, back), {
+      replace: inModal,
+      state: inModal ? {} : { kfOverlay: { returnTo: back } },
+    });
+  }
+
+  #closeDetail() {
+    if (this._drawer?.type !== 'opp' && this._drawer) {
+      const back = history.state?.kfDetail?.returnTo;
+      if (back) returnToRoute(back, { canGoBack: true });
+      else navigate(detailUrl('', ''), { replace: true });
+    } else if (this._route.name === 'mode') {
+      const fallbackQuery = new URLSearchParams(this._query);
+      fallbackQuery.delete('returnTo');
+      const fallback = '/pool' + (fallbackQuery.size ? `?${fallbackQuery}` : '');
+      const back = safeReturnTo(this._query.returnTo, fallback);
+      returnToRoute(back, { canGoBack: history.state?.kfOverlay?.returnTo === back });
+    } else this._drawer = null;
+  }
+
+  #openDetail(type, id, reportId = '') {
+    const back = currentUrl();
+    navigate(detailUrl(type, id, back, reportId), { state: { kfDetail: { returnTo: back } } });
   }
 
   get #svc() {
@@ -181,34 +273,30 @@ export class KfApp extends SignalWatcher(LitElement) {
    * 箭头函数字段会在 super() 之前初始化,event 选项读字段时可能拿到 undefined,交互事件会静默丢失。
    */
   async #onAction(e) {
-    const t = e.target;
+    if (/^(DEMO-|SIM-)/.test(String(e.detail?.id || ''))) return;
     const detail = e.detail || {};
     switch (e.type) {
       case 'card-open':
-        if (this._route.name === 'mode') this._drawer = { type: 'opp', id: detail.id }; // 已在详情路由:只换卡
-        else navigate(`/pool/${encodeURIComponent(detail.id)}`);
-        break;
       case 'opportunity-open':
-        this._drawer = { type: 'opp', id: detail.id };
+        this.#openOpportunity(detail.id);
         break;
       case 'evidence-open':
-        this._drawer = { type: 'evidence', reportId: detail.reportId, evId: detail.evId };
+        this.#openDetail('evidence', detail.evId, detail.reportId);
         break;
       case 'artifact-open':
-        this._drawer = { type: 'artifact', id: detail.id };
+        this.#openDetail('artifact', detail.id);
         break;
       case 'insight-open':
-        this._drawer = { type: 'insight', id: detail.id };
+        this.#openDetail('insight', detail.id);
+        break;
+      case 'source-open':
+        this.#openDetail('source', detail.id);
         break;
       case 'report-open':
-        navigate(`/report/${encodeURIComponent(detail.id)}`);
+        navigate(reportUrl(detail.id, currentUrl()));
         break;
       case 'status-change': {
-        const me = this.#svc.me.get();
-        if (!me) {
-          this.#svc.notify('warn', '先在右上角选「我是谁」,改状态要记录操作人');
-          break;
-        }
+        const me = this.#svc.me.get() || '匿名访客';
         try {
           await this.#svc.setOpportunityStatus(detail.id, detail.status, { by: me, reason: detail.reason || '' });
           if (this._drawer?.type === 'opp') this._drawer = { ...this._drawer };
@@ -219,7 +307,7 @@ export class KfApp extends SignalWatcher(LitElement) {
       }
       case 'score-submit': {
         try {
-          await this.#svc.rateOpportunity(this._currentOppId(), detail);
+          await this.#svc.rateOpportunity(this.#currentOppId(), detail);
           if (this._drawer?.type === 'opp') this._drawer = { ...this._drawer };
         } catch (err) {
           this.#svc.notify('err', `评分失败:${err.message}`);
@@ -227,7 +315,7 @@ export class KfApp extends SignalWatcher(LitElement) {
         break;
       }
       case 'nav-close':
-        navigate('/pool', { replace: true });
+        this.#closeDetail();
         break;
       case 'me-change':
         this.#svc.setMe(detail.name);
@@ -241,7 +329,6 @@ export class KfApp extends SignalWatcher(LitElement) {
       default:
         break;
     }
-    void t;
   }
 
   #currentOppId() {
@@ -255,22 +342,28 @@ export class KfApp extends SignalWatcher(LitElement) {
     if (!db || !d) return null;
     if (d.type === 'opp') {
       const opp = db.opportunityById[d.id];
-      if (!opp) return { heading: '机会卡不存在', body: html`<kf-empty kind="error" title="难道这张卡已被删除?" hint=${`库里找不到 ${d.id}`}></kf-empty>` };
+      if (!opp) return { heading: '未找到机会卡', body: html`<kf-empty kind="error" title="未找到这张机会卡" hint=${`请刷新或返回机会卡池核对编号 ${d.id}`}></kf-empty>` };
       return {
         heading: opp.title,
         sub: t(`${opp.priority ? `${opp.priority} 级 · ` : ''}${opp.category} · ${opp.stage} · ${opp.statusLabel}`),
-        body: html`
-          <div class="d-sec">
+        body: html`<div class="opp-detail">
+          <div class="opp-hero">
+            <div>
             ${opp.oneLiner ? html`<div class="one">${opp.oneLiner}</div>` : ''}
             <div class="chips">
               ${opp.stageObj ? html`<kf-badge kind="stage" .value=${opp.stageObj.code} label=${`${opp.stageObj.code} ${opp.stageObj.name}`}></kf-badge>` : ''}
               ${opp.seedKey ? html`<kf-badge kind="sample" label="示例数据"></kf-badge>` : ''}
             </div>
+            </div>
+            ${opp.reportArtifact
+              ? html`<a class="kf-btn primary report-cta" href=${href(reportUrl(opp.reportArtifact.id, currentUrl()))} data-link>查看完整报告 ↗</a>`
+              : html`<span class="empty-report">暂无完整报告</span>`}
           </div>
+          <div class="opp-grid">
           <div class="d-sec">
             <h4>决策与状态</h4>
             <div class="st-row" data-no-activate>
-              ${OPP_STATUS.map((s) => html`<button class="kf-btn ${opp.status === s.key ? 'primary' : ''}" @click=${() => this.#onAction({ type: 'status-change', detail: { id: opp.id, status: s.key } })}>${s.label}</button>`)}
+              ${OPP_STATUS.map((s) => html`<button class="kf-btn ${opp.status === s.key ? 'primary' : ''}" aria-pressed=${opp.status === s.key} @click=${() => this.#onAction({ type: 'status-change', detail: { id: opp.id, status: s.key } })}>${s.label}</button>`)}
             </div>
             ${opp.decision ? html`<div class="dec"><b>${opp.decision.label}</b> · ${opp.decision.reason}<div class="kf-muted">${opp.decision.by} · ${fmtDate(opp.decision.at, true)}</div></div>`
               : html`<div class="kf-muted">尚无决策记录。</div>`}
@@ -282,24 +375,31 @@ export class KfApp extends SignalWatcher(LitElement) {
             <h4>多人评分</h4>
             <kf-score-panel .stats=${opp.stats} .user=${this.#svc.me.get()}></kf-score-panel>
           </div>
+          </div>
           <div class="d-sec">
-            <h4>来源(谁 · 哪个 Agent · 什么方式 · 什么时候)</h4>
+            <h4>成果来源</h4>
+            <dl class="provenance">
+              <div><dt>提交人</dt><dd>${opp.upload.uploadedBy || '未记录'}</dd></div>
+              <div><dt>数字人</dt><dd>${opp.upload.agentName || '未记录'}</dd></div>
+              <div><dt>上传方式</dt><dd>${opp.upload.methodLabel || '未记录'}</dd></div>
+              <div><dt>上传时间</dt><dd>${fmtDate(opp.upload.uploadedAt, true) || '未记录'}</dd></div>
+            </dl>
             ${opp.upload.complete
-              ? html`<div class="src">${opp.upload.uploadedBy} · ${opp.upload.agentName}(${opp.upload.agentId || '无 id'}) · ${opp.upload.methodLabel} · ${fmtDate(opp.upload.uploadedAt, true)}</div>`
-              : html`<div class="src miss">记录不全:缺 ${opp.upload.missing.join('、')}</div>`}
+              ? '' : html`<div class="src miss">记录不全:缺 ${opp.upload.missing.join('、')}</div>`}
           </div>
           <div class="d-sec">
             <h4>关联成果与报告</h4>
             <ul class="links">
               ${opp.linkedArtifacts.map((a) => html`<li>
-                ${a.missing ? html`<span class="miss">${a.title}</span>` : html`<button class="lnk" @click=${() => this.#onAction({ type: 'artifact-open', detail: { id: a.id } })}>${a.shortTitle}</button>
+                ${a.missing ? html`<span class="miss">${a.title}</span>` : html`<div class="linked-title"><button class="lnk" @click=${() => this.#onAction({ type: 'artifact-open', detail: { id: a.id } })}>${a.shortTitle}</button>
                   <span class="kf-muted">${a.ownerName} · ${a.stageObj?.code || ''} · ${a.evidenceLabel}</span>
-                  ${a.report ? html`<a class="lnk" href=${href(`/report/${a.id}`)} data-link @click=${(e) => e.stopPropagation()}>打开报告 ↗</a>` : ''}`}
+                  </div>${a.report ? html`<a class="lnk" href=${href(reportUrl(a.id, currentUrl()))} data-link>打开报告 ↗</a>` : ''}`}
               </li>`)}
             </ul>
-            ${opp.linkedProject ? html`<div class="kf-muted">所属项目:${opp.linkedProject.title}</div>` : ''}
+            ${!opp.linkedArtifacts.length ? html`<div class="kf-muted">尚未关联成果。</div>` : ''}
+            ${opp.linkedProject ? html`<div class="kf-muted project-note">所属项目:${opp.linkedProject.title}</div>` : ''}
           </div>
-        `,
+        </div>`,
       };
     }
     if (d.type === 'evidence') {
@@ -333,7 +433,9 @@ export class KfApp extends SignalWatcher(LitElement) {
                 ${q.author ? html`<span>· ${q.author}</span>` : ''}
                 ${q.art
                   ? html`<button class="lnk" @click=${() => this.#onAction({ type: 'artifact-open', detail: { id: q.art.id } })}>${t(`· 原文来自「${q.art.shortTitle}」(${q.art.ownerName})→`)}</button>`
-                  : html`<span>· 平台原文,未落入知识库</span>`}
+                  : q.sourceId
+                    ? html`<button class="lnk" @click=${() => this.#onAction({ type: 'source-open', detail: { id: q.sourceId } })}>· 查看原始资料 →</button>`
+                    : html`<span>· 未关联来源记录</span>`}
               </div>
             </li>`)}</ul>` : html`<div class="kf-muted">这条结论没有原文片段。</div>`}
           </div>
@@ -345,6 +447,11 @@ export class KfApp extends SignalWatcher(LitElement) {
       if (!c) return { heading: '成果不存在', body: html`<kf-empty kind="error" title="成果已被删除"></kf-empty>` };
       return { heading: c.artifact.shortTitle, sub: t(`${c.artifact.ownerName || '无出身'} · ${c.artifact.stageObj?.label || c.artifact.stage} · ${c.artifact.evidenceLabel}`), body: html`<kf-evidence-chain mode="artifact" .chain=${c}></kf-evidence-chain>` };
     }
+    if (d.type === 'source') {
+      const source = db.sourceById[d.id];
+      if (!source) return { heading: '未找到原始资料', body: html`<kf-empty title="未找到这条资料" hint="当前公开看板中没有对应来源,请核对编号或返回清单。"></kf-empty>` };
+      return { heading: source.title, sub: '原始资料', body: html`<kf-source-detail .source=${source}></kf-source-detail>` };
+    }
     if (d.type === 'insight') {
       const c = traceInsight(db, d.id);
       if (!c) return { heading: '归纳不存在', body: html`<kf-empty kind="error" title="归纳已被删除"></kf-empty>` };
@@ -353,31 +460,13 @@ export class KfApp extends SignalWatcher(LitElement) {
     return null;
   }
 
-  /**
-   * 抽屉正文:渲染进 <kf-drawer> 的 shadow root 的 [part=body],保证它真的在浮层里。
-   * 正文里的事件用「组合事件 + 冒泡到 document」的方式回到本组件:给每个事件加一个 composed 监听。
-   * (之前只 render 正文、没接事件 —— 证据抽屉、评分面板里的按钮点了没反应。)
-   */
-  #renderDrawerBody() {
-    const drawerEl = this.renderRoot.querySelector('kf-drawer');
-    const content = this.#drawerContent();
-    if (!drawerEl || !content) return;
-    const body = drawerEl.shadowRoot.querySelector('.body');
-    if (!drawerEl.__kfBound) {
-      drawerEl.__kfBound = true;
-      for (const t of ['status-change', 'score-submit', 'evidence-open', 'artifact-open', 'insight-open', 'ledger-jump']) {
-        drawerEl.addEventListener(t, this.#boundAction);
-      }
-    }
-    render(content.body, body);
-  }
-
   renderView() {
     const r = this._route;
     const db = this.#svc.db.get();
     const status = this.#svc.status.get();
     const err = this.#svc.error.get();
     const q = this._query;
+    if (r.name === 'library' || r.name === 'library-detail') return html`<view-library .db=${db} .routeQuery=${q} .recordEntity=${r.params.entity || ''} .recordId=${r.params.id || ''}></view-library>`;
     if (status === 'error' && !db) {
       return html`<kf-empty kind="error" title="数据源连接失败" hint=${`${err?.message || ''} · 地址 ${CONFIG.parseBase}`}
         action-label="重试" @empty-action=${() => boardService.load({ force: true })}></kf-empty>`;
@@ -385,8 +474,8 @@ export class KfApp extends SignalWatcher(LitElement) {
     if (!db) return this.#loading();
     switch (r.name) {
       case 'workbench': return html`<view-workbench .db=${db} .me=${this.#svc.me.get()}></view-workbench>`;
-      case 'pool': return html`<view-pool .db=${db} .me=${this.#svc.me.get()} .routeQuery=${q}></view-pool>`;
-      case 'mode': return html`<view-pool .db=${db} .me=${this.#svc.me.get()} .routeQuery=${q} .focusId=${r.params.id}></view-pool>`;
+      case 'pool':
+      case 'mode': return html`<view-pool .db=${db} .me=${this.#svc.me.get()} .routeQuery=${q} .focusId=${r.name === 'mode' ? r.params.id : ''}></view-pool>`;
       case 'stages': return html`<view-stages .db=${db} .routeQuery=${q} .me=${this.#svc.me.get()}></view-stages>`;
       case 'reports':
         return r.params.type
@@ -403,8 +492,8 @@ export class KfApp extends SignalWatcher(LitElement) {
   #loading() {
     /* 骨架屏:和真实版面同形(标题 / 工具栏 / KPI / 卡片网格),骨架块本身是玻璃 */
     return html`<div class="skel-page" aria-busy="true" aria-live="polite">
-      <div class="kf-skel" style="width:240px;height:28px"></div>
-      <div class="kf-skel" style="width:360px;height:14px;margin-top:8px"></div>
+      <div class="kf-skel" style="width:min(240px,100%);height:28px"></div>
+      <div class="kf-skel" style="width:min(360px,100%);height:14px;margin-top:8px"></div>
       <div class="kpi-row">${[1, 2, 3, 4].map(() => html`<div class="kf-skel kpi-ph"></div>`)}</div>
       <div class="kf-skel" style="height:88px;margin-top:16px;border-radius:var(--r-md)"></div>
       <div class="skel-row">${[1, 2, 3, 4, 5, 6].map(() => html`<div class="kf-skel card-ph">
@@ -413,7 +502,8 @@ export class KfApp extends SignalWatcher(LitElement) {
   }
 
   render() {
-    const [title, sub] = TITLES[this._route.name] || TITLES.notfound;
+    const reportType = this._route.name === 'reports' ? reportTypeOf(this._route.params.type) : null;
+    const [title, sub] = reportType ? [reportType.name, '报告中心'] : TITLES[this._route.name] || TITLES.notfound;
     const db = this.#svc.db.get();
     const drawer = this.#drawerContent();
     const flash = this.#svc.flash.get();
@@ -432,7 +522,9 @@ export class KfApp extends SignalWatcher(LitElement) {
       </div>
 
       <kf-drawer .open=${Boolean(drawer)} .heading=${drawer?.heading || ''} .sub=${drawer?.sub || ''}
-        @drawer-close=${() => { if (this._route.name === 'mode') navigate('/pool', { replace: true }); else this._drawer = null; }}>
+        layout="center" .label=${{ opp: '机会卡详情', artifact: '成果详情', evidence: '证据详情', insight: '归纳详情', source: '原始资料' }[this._drawer?.type] || '详情'}
+        .content=${drawer ? html`<style>${DETAIL_STYLES.cssText}</style>${drawer.body}` : null}
+        @drawer-close=${() => this.#closeDetail()}>
       </kf-drawer>
 
       <kf-toast .open=${Boolean(flash)} .message=${flash?.text || ''} .kind=${flash?.kind || 'ok'}></kf-toast>
@@ -465,35 +557,6 @@ export class KfApp extends SignalWatcher(LitElement) {
     .skel-page .card-ph .w70 { width: 70%; }
     .skel-page .card-ph .w55 { width: 55%; }
     .skel-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-top: 16px; }
-    /* 抽屉内部排版:段落之间用发丝线分隔,不再是卡片套卡片 */
-    .d-sec { padding: 16px 0; border-top: 1px solid var(--kf-line); }
-    .d-sec:first-child { border-top: 0; padding-top: 0; }
-    .d-sec h4 { margin: 0 0 8px; font-size: 12px; line-height: 1.5; color: var(--kf-ink-3); font-weight: 600; letter-spacing: var(--ls-cn-small); }
-    .one { font-size: 14px; line-height: var(--lh-body); letter-spacing: var(--ls-cn-body); max-width: 48em; color: var(--kf-ink-2); }
-    .chips { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
-    .st-row { display: flex; gap: 8px; flex-wrap: wrap; }
-    .dec { margin-top: 10px; font-size: 14px; color: var(--kf-ink-2); }
-    .log { margin: 8px 0 0; padding-left: 18px; font-size: 14px; color: var(--kf-ink-2); display: grid; gap: 4px; }
-    .src { font-size: 14px; color: var(--kf-ink-2); }
-    .src.miss, .miss { color: var(--kf-warn-text); font-size: 14px; }
-    .links { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
-    .links li { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 14px; line-height: 1.6; }
-    .lnk { all: unset; cursor: pointer; color: var(--kf-ink); font-weight: 600; border-bottom: 1px dashed var(--kf-line-strong); }
-    .lnk:focus-visible { outline: 2px solid var(--kf-focus); outline-offset: 2px; }
-    .lnk:focus { outline: 2px solid var(--kf-focus); outline-offset: 2px; }
-    .lnk:hover { color: var(--kf-accent); border-bottom-color: var(--kf-accent); }
-    .kv { display: flex; gap: 24px; align-items: center; flex-wrap: wrap; }
-    .kv > div { display: grid; gap: 2px; padding-right: 24px; border-right: 1px solid var(--kf-line); }
-    .kv b { font-size: 20px; line-height: 1.1; font-weight: 600; color: var(--kf-ink); font-variant-numeric: tabular-nums; font-family: var(--kf-font-num); }
-    .kv span { font-size: 12px; line-height: 1.5; color: var(--kf-ink-3); }
-    .kv .kf-btn { margin-left: auto; }
-    .prov { display: flex; flex-wrap: wrap; gap: 4px; align-items: baseline; font-size: 12px; color: var(--kf-ink-3); }
-    .prov .plat { color: var(--kf-ink); font-weight: 600; }
-    .prov { display: flex; flex-wrap: wrap; gap: 4px; align-items: baseline; font-size: 12px; color: var(--kf-ink-3); }
-    .prov .plat { color: var(--kf-ink); font-weight: 600; }
-    .quotes { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
-    .quotes li { padding: 12px 16px; border-radius: var(--r-md); background: rgba(255,255,255,0.45); border: 1px solid var(--kf-line); }
-    .quotes p { margin: 0 0 4px; font-size: 14px; line-height: var(--lh-body); max-width: 48em; color: var(--kf-ink); }
     /* ===== 断点(任务书第 1 节,全站统一) ===== */
     /* 平板:导航收窄,仍然在左侧 */
     @media (min-width: 768px) and (max-width: 1023px) {
@@ -507,15 +570,9 @@ export class KfApp extends SignalWatcher(LitElement) {
       .view { padding: 8px 16px calc(76px + env(safe-area-inset-bottom)); overflow-x: clip; }
       /* 手机上所有文字落在玻璃上都要看得清:原 14px 正文升到 15,行高 1.55 */
       .view { font-size: 14px; line-height: 1.55; }
-      .one { font-size: 14px; line-height: 1.55; }
-      .prov, .links li, .src, .dec { font-size: 14px; }
-      .d-sec h4 { font-size: 12px; }
-      .quotes li, .d-sec, .links li { min-height: 0; }
-      .st-row .kf-btn { min-height: 44px; padding: 0 18px; }
-      .lnk { display: inline-flex; align-items: center; min-height: 44px; }
     }
   `;
 }
 
 if (!customElements.get('kf-app')) customElements.define('kf-app', KfApp);
-void match; void fmtAgo; void STAGES;
+void fmtAgo; void STAGES;

+ 8 - 5
src/layout/kf-nav.js

@@ -11,13 +11,15 @@ import { LitElement, html, css } from 'lit';
 import { useTokens, define } from '../components/base.js';
 import { icon } from '../components/icons.js';
 import { t } from '../components/cjk.js';
+import { href } from '../utils/router.js';
 
 const ITEMS = [
   { path: '/', label: '工作台', en: 'Workbench', icon: 'home' },
   { path: '/pool', label: '机会卡池', en: 'Opportunity pool', icon: 'pool', badge: 'pool', short: '卡池' },
   { path: '/stages', label: '五阶段产出', en: 'Stages S1–S5', icon: 'bars', short: '阶段' },
-  { path: '/report', label: '分析报告', en: 'Reports', icon: 'doc', short: '报告' },
+  { path: '/reports', label: '分析报告', en: 'Reports', icon: 'doc', short: '报告' },
   { path: '/ledger', label: '证据台账', en: 'Ledger', icon: 'ledger', mobileHidden: true },
+  { path: '/library', label: '行业资料库', en: 'Research library', icon: 'inbox', short: '资料库' },
   { path: '/members', label: '成员', en: 'Members', icon: 'users', badge: 'stuck' },
 ];
 
@@ -79,7 +81,7 @@ export class KfNav extends LitElement {
     @media (max-width: 767px) {
       .side { display: none; }
       .tabbar {
-        display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); align-items: start;
+        display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); align-items: start;
         box-sizing: border-box; padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px));
         border-radius: var(--r-lg) var(--r-lg) 0 0; border-bottom: 0; border-left: 0; border-right: 0;
         background:
@@ -112,7 +114,8 @@ export class KfNav extends LitElement {
   `;
 
   #cur() {
-    return this.current === '/' ? '/' : `/${this.current.split('/')[1] || ''}`;
+    const root = this.current === '/' ? '/' : `/${this.current.split('/')[1] || ''}`;
+    return root === '/report' ? '/reports' : root;
   }
 
   render() {
@@ -131,7 +134,7 @@ export class KfNav extends LitElement {
           <div><div class="name">产品创新看板</div><div class="sub">${t('研发中心 · AI 实战营')}</div></div>
         </div>
         <nav>
-          ${ITEMS.map((it) => html`<a href=${it.path} data-link data-path=${it.path} aria-current=${curAria(it)}>
+          ${ITEMS.map((it) => html`<a href=${href(it.path)} data-link data-path=${it.path} aria-current=${curAria(it)}>
               ${icon(it.icon, 18)}
               <span class="lbl">${it.label}<span class="en">${it.en}</span></span>
               ${badge(it, '')}
@@ -143,7 +146,7 @@ export class KfNav extends LitElement {
         </div>
       </aside>
       <nav class="tabbar" aria-label="主导航(手机)" data-tabbar>
-        ${ITEMS.filter((it) => !it.mobileHidden).map((it) => html`<a href=${it.path} data-link data-path=${it.path} aria-current=${curAria(it)}
+        ${ITEMS.filter((it) => !it.mobileHidden).map((it) => html`<a href=${href(it.path)} data-link data-path=${it.path} aria-current=${curAria(it)}
             aria-label=${it.label}>
             <span class="ico">${icon(it.icon, 22, 'ln')}${icon(`${it.icon}Solid`, 22, 'sl')}${badge(it, '')}</span>
             <span class="short">${it.short || it.label}</span>

+ 7 - 18
src/layout/kf-topbar.js

@@ -1,5 +1,5 @@
 /**
- * layout/kf-topbar.js —— <kf-topbar> 顶栏:页面标题 + 「我是谁」+ 同步状态 + 刷新。
+ * layout/kf-topbar.js —— <kf-topbar> 页面工具:身份选择、同步状态与刷新。
  *
  * 「我是谁」是多人共用的关键:30–40 人同一套系统,评分的名字、改状态的操作人、
  * 工作台「给我的」都取决于它。选择只存本机(localStorage),不共享。
@@ -36,14 +36,9 @@ export class KfTopbar extends LitElement {
   }
 
   static styles = css`
-    :host { display: block; position: sticky; top: 0; z-index: 4; }
-    /* 顶栏(v15):实底白 + 下缘 1px 发丝线。sticky 浮在内容之上,给与卡片一致的实底,不用玻璃。 */
-    .bar { display: flex; align-items: center; gap: 14px; padding: 12px 28px; background: var(--kf-surface); border: 1px solid var(--kf-line-soft); border-top: 0; border-radius: 0 0 var(--r-md) var(--r-md); }
-    .t { min-width: 0; flex: 1; }
-    h1 { margin: 0; font-size: 20px; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
-    .s { font-size: 12px; color: var(--kf-ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+    :host { display: block; }
+    .bar { display: flex; justify-content: flex-end; align-items: center; flex-wrap: wrap; gap: 12px; padding: 12px 28px 0; }
     .sync { font-size: 12px; color: var(--kf-ink-3); display: flex; align-items: center; gap: 6px; }
-    /* 视图可以把「页面级切换」放进顶栏(typescript 类型页的类型 tab):省掉一整行,首屏直接是内容 */
     .acts { display: none; align-items: center; min-width: 0; flex: 1 1 auto; justify-content: flex-end; }
     .acts:has(slot[name="actions"]:not(:empty)) { display: flex; }
     .sync .dot { width: 7px; height: 7px; border-radius: 50%; background: #2e7d5b; }
@@ -55,14 +50,9 @@ export class KfTopbar extends LitElement {
     .who { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 600; color: #fff;
       background: linear-gradient(135deg, #2457d6, #5a3fd6); }
     button.refresh { height: 32px; padding: 0 12px; border-radius: var(--r-sm); font: 500 14px var(--kf-font); color: var(--kf-ink); cursor: pointer; }
-    @media (max-width: 1023px) { .bar { padding: 10px 20px; } .sync { display: none; } }
-    /* 手机:状态栏让开(safe-area-top),标题 17/600,一行装下「我是 + 刷新」 */
+    @media (max-width: 1023px) { .bar { padding: 10px 20px 0; } .sync { display: none; } }
     @media (max-width: 767px) {
-      :host { top: 0; }
-      .bar { padding: calc(8px + env(safe-area-inset-top, 0px)) 16px 8px; gap: 8px; border-radius: 0 0 20px 20px; }
-      h1 { font-size: 16px; font-weight: 600; }
-      .s { display: none; }
-      :host([data-route="ledger"]) .s { display: block; font-size: 12px; }
+      .bar { padding: calc(8px + env(safe-area-inset-top, 0px)) 16px 0; gap: 8px; }
       .me { padding: 1px 1px 1px 10px; min-height: 44px; }
       .me label { display: none; }
       .who { width: 32px; height: 32px; font-size: 14px; margin-left: -8px; }
@@ -85,8 +75,7 @@ export class KfTopbar extends LitElement {
   render() {
     const initials = this.me ? Array.from(this.me).slice(-1).join('') : '?';
     return html`
-      <header class="bar">
-        <div class="t"><h1>${this.title}</h1>${this.sub ? html`<div class="s">${this.sub}</div>` : ''}</div>
+      <div class="bar" role="group" aria-label="数据与身份工具">
         <div class="acts"><slot name="actions"></slot></div>
         <div class="sync ${this.status === 'error' ? 'err' : this.status === 'loading' ? 'warn' : ''}" role="status">
           <span class="dot"></span>
@@ -102,7 +91,7 @@ export class KfTopbar extends LitElement {
             ${this.members.map((m) => html`<option value=${m.name} ?selected=${m.name === this.me}>${m.name}${m.groupId ? ` · ${m.groupId}` : ''}</option>`)}
           </select>
         </div>
-      </header>
+      </div>
     `;
   }
 }

+ 2 - 1
src/pages/card-toolbar.js

@@ -97,12 +97,13 @@ export const TOOLBAR_CSS = css`
 export function makeToolHandler(page) {
   const on = (action, value) => page.onCardTool?.(action, value);
   return {
-    toggle: (v) => on('toggle', v),
+    toggle: (v) => v === 'clear' ? on('clear') : on('toggle', v),
     chip: (k) => on(k),
     clear: () => on('clear'),
     sort: (s) => on('sort', s),
     view: (v) => on('view', v),
     page: (p) => page.onCardPage?.(p),
+    open: () => { const sheet = page.renderRoot.querySelector('kf-filter-sheet'); if (sheet) sheet.open = true; },
   };
 }
 

Разница между файлами не показана из-за своего большого размера
+ 118 - 0
src/pages/library-product-detail.js


+ 228 - 0
src/pages/library-styles.js

@@ -0,0 +1,228 @@
+import { css } from 'lit';
+
+/** Library-only styles: keep the browsing surface and long-form reading independent. */
+export const LIBRARY_CSS = css`
+  *{box-sizing:border-box}
+  button,input,select{font:inherit}
+  button,a,select{touch-action:manipulation}
+  button{cursor:pointer}
+  button:disabled{opacity:.45;cursor:default}
+  button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--kf-accent);outline-offset:3px}
+  [hidden]{display:none!important}
+  a{text-decoration:none}
+  h3,h4,p{margin:0}
+  small{font-size:11px;color:var(--kf-ink-3);line-height:1.6}
+  h3{font-size:14px;font-weight:600;color:var(--kf-ink)}
+  h3 small{font-weight:400;margin-left:6px}
+  .button{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:7px 10px;border:1px solid var(--kf-line);background:var(--kf-surface);color:var(--kf-ink-2);border-radius:8px;font-size:11px;white-space:nowrap;min-height:32px}
+  .button:hover:not(:disabled){border-color:var(--kf-line-strong);background:var(--kf-surface-btn)}
+  .primary{background:var(--kf-accent);color:white;border-color:var(--kf-accent)}
+  .primary:hover:not(:disabled){background:var(--kf-accent-strong);border-color:var(--kf-accent-strong)}
+  .text-button{display:inline-flex;align-items:center;justify-content:center;gap:5px;border:0;background:none;color:var(--kf-accent-ink);font-size:12px;padding:5px}
+  .text-button:hover{text-decoration:underline}
+  .panel{padding:16px;background:var(--kf-surface);border:1px solid var(--kf-line-soft);border-radius:11px;min-width:0}
+
+  /* Small global overview; results remain the focus of the first screen. */
+  .p-head{align-items:center;padding:8px 0 12px;gap:12px}
+  .p-head h2{font-size:20px}
+  .p-actions{gap:7px}
+  summary{cursor:pointer}
+  summary::-webkit-details-marker{display:none}
+  .stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:12px}
+  .stat{display:flex;flex-direction:column;gap:5px;padding:12px 14px;background:var(--kf-surface);border:1px solid var(--kf-line-soft);border-radius:10px;transition:border-color .16s,background .16s}
+  .stat:hover{border-color:var(--kf-accent);background:var(--kf-surface-raised)}
+  .stat-label{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--kf-ink-3)}
+  .stat-label svg{color:var(--kf-accent)}
+  .stat strong{font-size:23px;font-weight:600;letter-spacing:-.7px;font-variant-numeric:tabular-nums;color:var(--kf-ink);line-height:1.2}
+  .stat small{font-size:10px}
+
+  .library-layout{display:block;min-width:0}
+  .directory{padding:4px;background:var(--kf-surface);border:1px solid var(--kf-line-soft);border-radius:10px;margin-bottom:12px}
+  .directory-links{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px}
+  .tree-link{display:flex;align-items:center;gap:8px;padding:9px 12px;border-radius:7px;font-size:12px;color:var(--kf-ink-2);min-height:36px;white-space:nowrap}
+  .tree-link span{font-size:11px;color:var(--kf-ink-3);font-variant-numeric:tabular-nums;margin-left:auto}
+  .tree-link svg{flex:none}
+  .chosen{background:var(--kf-accent-soft)!important;color:var(--kf-accent-ink)!important}
+  .tree-link.chosen{font-weight:600}
+  .tree-link.chosen span{color:var(--kf-accent-ink)}
+  .tree-link:hover{background:var(--kf-surface-btn)}
+  .mobile-type{display:none}
+  .content{min-width:0;display:grid;gap:12px}
+
+  .filter{padding:12px 14px}
+  .filter-main{display:flex;align-items:flex-end;gap:12px}
+  .filter-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(135px,1fr));gap:8px;min-width:0}
+  .primary-filters{flex:1}
+  .field{display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--kf-ink-3);min-width:0}
+  .field small{font-size:10px}
+  select,input[type=date]{appearance:auto;-webkit-appearance:auto;padding:6px 8px;min-width:0;width:100%;border:1px solid var(--kf-line);border-radius:6px;background:var(--kf-surface);font-size:12px;color:var(--kf-ink-2);min-height:32px}
+  .advanced-toggle{white-space:nowrap;font-size:11px;min-height:32px}
+  .advanced-toggle b{font-size:10px;border-radius:4px;padding:0 4px;background:var(--kf-accent-soft);line-height:1.6}
+  .advanced-filters{border-top:1px solid var(--kf-line);margin-top:10px;padding-top:10px}
+  .active-filters{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:9px;padding-top:9px;border-top:1px solid var(--kf-line-soft)}
+  .selected-label{font-size:11px;color:var(--kf-ink-3);margin-right:1px}
+  .filter-chip{display:inline-flex;align-items:center;gap:5px;max-width:100%;border:1px solid color-mix(in srgb,var(--kf-accent) 15%,transparent);background:var(--kf-accent-soft);border-radius:6px;padding:5px 7px;color:var(--kf-accent-ink);font-size:10px;line-height:1.5}
+  .filter-chip b{font-weight:500;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .filter-chip svg{flex:none}
+  .filter-chip:hover{border-color:var(--kf-accent)}
+  .reset{font-size:11px}
+
+  .result-head{display:flex;gap:12px;align-items:center;justify-content:space-between;margin:0 0 9px}
+  .result-head h3{display:flex;gap:7px;align-items:baseline;font-size:14px}
+  .result-head h3 strong{font-family:var(--kf-font-num);font-size:21px;line-height:1.3;letter-spacing:-.5px;font-weight:600;color:var(--kf-ink)}
+  .result-head h3 small{margin:0}
+  .result-head p{font-size:10px;color:var(--kf-ink-3);margin-top:4px;line-height:1.6}
+  .result-tools{display:flex;gap:9px;align-items:center;flex:none}
+  .insights-toggle{font-size:11px;padding:7px 10px;min-height:33px}
+  .insights-toggle.active{border-color:var(--kf-accent);color:var(--kf-accent-ink)}
+  .view-switch{display:flex;gap:2px;align-items:center;background:var(--kf-surface);border:1px solid var(--kf-line);border-radius:8px;padding:3px}
+  .view-switch button{display:flex;align-items:center;justify-content:center;width:29px;height:25px;background:none;border:0;border-radius:5px;color:var(--kf-ink-3)}
+  .view-switch button:hover{background:var(--kf-surface-btn)}
+  .insights{margin-bottom:14px;padding:18px 20px}
+  .distributions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:25px}
+  .distribution h3{font-size:12px;margin-bottom:12px}
+  .distribution h3 small{display:block;margin-top:5px;font-size:10px}
+  .bar{display:grid;grid-template-columns:90px minmax(0,1fr) 40px;gap:9px;align-items:center;border:0;background:transparent;width:100%;font-size:10px;color:var(--kf-ink-2);padding:6px 0;text-align:left}
+  .bar>span{overflow-wrap:anywhere}
+  .bar:hover>span{color:var(--kf-accent-ink)}
+  .bar i{height:5px;background:var(--kf-line-soft);border-radius:3px;overflow:hidden}
+  .bar b{display:block;height:100%;background:var(--kf-accent);border-radius:3px;opacity:.65}
+  .bar strong{text-align:right;font-size:10px;font-weight:500}
+  .coverage{margin-top:16px;padding-top:16px;border-top:1px solid var(--kf-line)}
+  .distributions:empty+.coverage{border-top:0;margin-top:0;padding-top:0}
+  .coverage h3{margin-bottom:10px;font-size:12px}
+  .coverage p{display:flex;gap:12px;justify-content:space-between;font-size:11px;color:var(--kf-ink-2);margin:8px 0;flex-wrap:wrap}
+  .coverage b{font-weight:500}
+  .coverage small{display:block;margin-top:10px;font-size:10px}
+
+  .record-list.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:10px}
+  .record{display:flex;align-items:flex-start;gap:10px;padding:14px;background:var(--kf-surface);border:1px solid var(--kf-line-soft);border-radius:10px;min-width:0;position:relative;transition:border-color .16s,box-shadow .16s}
+  .record:hover{border-color:color-mix(in srgb,var(--kf-accent) 45%,var(--kf-line));box-shadow:0 3px 12px rgba(24,24,27,.03)}
+  .record-symbol{width:34px;height:34px;border:1px solid var(--kf-line-soft);border-radius:9px;display:flex;align-items:center;justify-content:center;flex:none;color:var(--kf-accent-ink);background:var(--kf-surface-raised)}
+  .cards .record-symbol{display:none}
+  .record-main{min-width:0;flex:1;display:flex;flex-direction:column;height:100%}
+  .record h4{font-size:14px;font-weight:600;line-height:1.75;color:var(--kf-ink);overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+  .record:hover h4{color:var(--kf-accent-ink)}
+  .record p{font-size:12px;line-height:1.8;color:var(--kf-ink-3);margin-top:7px;margin-bottom:12px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+  .record .muted{color:var(--kf-ink-3)}
+  .product-summary{display:flex;gap:8px 12px;flex-wrap:wrap;margin:0 0 8px;color:var(--kf-ink-3);font-size:10px;line-height:1.8}
+  .product-summary>span{display:flex;align-items:center;gap:4px}
+  .price-fact{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;margin:12px 0}
+  .price-fact strong{font-family:var(--kf-font-num);font-size:21px;font-weight:600;color:var(--kf-ink);letter-spacing:-.4px}
+  .price-fact span{font-size:10px;color:var(--kf-ink-3)}
+  .spec-facts{display:flex;align-items:center;gap:8px 14px;flex-wrap:wrap;margin:12px 0;font-size:11px;color:var(--kf-ink-3);line-height:1.8}
+  .spec-facts b{font-weight:500;color:var(--kf-ink-2)}
+  .tags{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:8px}
+  .tags span{padding:2px 6px;border-radius:4px;font-size:10px;line-height:1.5;background:var(--kf-surface-btn);color:var(--kf-ink-3)}
+  .tags .platform-tag{background:var(--kf-accent-soft);color:var(--kf-accent-ink)}
+  .record-meta{display:flex;gap:6px 12px;flex-wrap:wrap;justify-content:space-between;color:var(--kf-ink-3);font-size:10px;line-height:1.6;padding-top:10px;margin-top:auto;border-top:1px solid var(--kf-line-soft)}
+  .record-meta>span:first-child{display:inline-flex;align-items:center;gap:5px}
+  .open-arrow{flex:none;align-self:center;color:var(--kf-ink-3)}
+  .cards .open-arrow{display:none}
+  .list{display:grid;gap:8px}
+  .list .record{padding:11px 14px}
+  .list .record p{margin-bottom:8px;-webkit-line-clamp:1}
+  .list .record-meta{border-top:0;padding-top:0;justify-content:flex-start}
+  .list .record h4{-webkit-line-clamp:1}
+  .scope-note{font-size:11px;line-height:1.8;color:var(--kf-ink-3);padding:10px 12px;background:var(--kf-surface);border:1px solid var(--kf-line-soft);border-radius:8px;margin:0 0 12px}
+  .pending-note{display:flex;align-items:center;gap:6px;background:#fcf8f0}
+  .pending-note>svg{color:var(--kf-warn);flex:none}
+  .pending-note .text-button{font-size:11px;margin-left:auto;white-space:nowrap}
+  .pagination{display:flex;gap:12px;justify-content:space-between;align-items:center;font-size:11px;color:var(--kf-ink-3);margin-top:18px}
+  .pagination>div{display:flex;gap:6px}
+  .pagination .button{font-size:11px}
+  .empty{padding:44px 20px;text-align:center;font-size:12px;color:var(--kf-ink-3);line-height:1.9}
+  .empty-icon{display:flex;justify-content:center;align-items:center;margin:0 auto 14px;width:46px;height:46px;background:var(--kf-accent-soft);border-radius:50%;color:var(--kf-accent-ink)}
+  .empty p{margin:8px 0 18px}
+  .notice{background:#edf5f1;border:1px solid #dceae1;border-radius:9px;padding:8px 13px;color:var(--kf-ok-text);font-size:12px;margin:14px 0;display:flex;align-items:center;justify-content:space-between;gap:12px}
+  .notice>span{display:flex;align-items:center;gap:7px}
+  .error{display:flex;gap:14px;margin:20px 0;color:var(--kf-ink-2)}
+  .error>svg{color:var(--kf-warn);flex:none}
+  .error p{font-size:12px;margin:10px 0 16px}
+  .loading{display:flex;gap:10px;align-items:center;justify-content:center;min-height:230px;color:var(--kf-ink-3);font-size:12px}
+  .loading-dot{width:8px;height:8px;border-radius:50%;background:var(--kf-accent);animation:pulse 1.1s ease-in-out infinite alternate}
+  @keyframes pulse{to{opacity:.25}}
+
+  /* Prose first, with structured facts in a secondary column. */
+  .detail-page{display:grid;gap:12px;margin-top:10px}
+  .breadcrumb{display:flex;align-items:center;justify-content:space-between;gap:12px;color:var(--kf-ink-3);font-size:12px}
+  .breadcrumb a{display:flex;align-items:center;gap:5px;color:var(--kf-accent-ink);min-height:28px}
+  .detail-title{padding:18px 22px}
+  .eyebrow{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--kf-accent-ink)}
+  .detail-title h2{font-size:20px;font-weight:600;line-height:1.65;color:var(--kf-ink);margin:8px 0 12px;overflow-wrap:anywhere;max-width:55em}
+  .detail-actions{display:flex;gap:10px;flex-wrap:wrap}
+  .detail-grid{display:grid;grid-template-columns:minmax(0,1fr) 240px;gap:20px;align-items:start}
+  .detail-main,.detail-aside{display:grid;gap:18px;min-width:0}
+  .detail-aside{position:sticky;top:16px}
+  .detail-main section{scroll-margin-top:18px}
+  .detail-main h3{display:flex;align-items:center;gap:7px}
+  .prose{padding:28px 32px}
+  .prose h3{font-size:15px;padding-bottom:16px;border-bottom:1px solid var(--kf-line)}
+  .prose .scope-note{margin:16px 0 0}
+  .full-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:2.15;color:var(--kf-ink-2);margin-top:20px;max-width:76em}
+  .application-note{border-left:3px solid var(--kf-accent);background:#fafaff}
+  .metadata{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 24px;margin:12px 0 0}
+  .metadata>div{padding:11px 0;border-bottom:1px solid var(--kf-line-soft);min-width:0}
+  .metadata dt{font-size:10px;color:var(--kf-ink-3);margin-bottom:5px}
+  .metadata dd{margin:0;font-size:12px;line-height:1.85;overflow-wrap:anywhere;color:var(--kf-ink-2)}
+  .structured-aside{max-height:calc(100dvh - 250px);overflow:auto;scrollbar-width:thin}
+  .structured-aside .metadata{grid-template-columns:1fr}
+  .structured-aside .metadata dd{font-size:11px}
+  .facts-toggle{display:none}
+  .reading-nav{padding:18px}
+  .reading-nav h3{font-size:12px;margin-bottom:12px}
+  .reading-nav button{border:0;border-left:2px solid var(--kf-line);background:transparent;width:100%;padding:8px 10px;text-align:left;font-size:11px;color:var(--kf-ink-3);line-height:1.7}
+  .reading-nav button:hover{color:var(--kf-accent-ink);border-left-color:var(--kf-accent);background:var(--kf-accent-soft)}
+  .relations{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin:16px 0 10px}
+  .relations a{display:flex;gap:8px;align-items:center;justify-content:space-between;background:var(--kf-surface-raised);border:1px solid var(--kf-line);color:var(--kf-ink-2);border-radius:8px;padding:12px;font-size:12px;overflow-wrap:anywhere}
+  .relations a>span{display:flex;align-items:center;gap:6px}
+  .relations a:hover{border-color:var(--kf-accent);color:var(--kf-accent-ink)}
+  .relations b{margin-left:auto;font-weight:600}
+  .clause-links{grid-template-columns:1fr}
+  .inline-link{display:block;color:var(--kf-accent-ink);font-size:13px;line-height:1.9;margin-top:14px}
+  .inline-link:hover{text-decoration:underline}
+  .source-links{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:16px}
+  .source-links .scope-note{width:100%;margin:0}
+  .report-filters .filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
+  .report-filters>.text-button{margin-top:6px}
+  .report-results{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
+  .report-results>.result-head,.report-results>.empty{grid-column:1/-1}
+  .report-record{display:flex;gap:10px;align-items:center;padding:12px 14px;border:1px solid var(--kf-line-soft);border-radius:10px;background:var(--kf-surface);color:var(--kf-ink-2);min-width:0}
+  .report-record>div{min-width:0;flex:1}
+  .report-record h4{font-size:13px;line-height:1.8;overflow-wrap:anywhere}
+  .report-record:hover h4{color:var(--kf-accent-ink)}
+
+  @media(max-width:1199px){
+    .stats{gap:8px}.stat{padding:13px 12px}.stat-label{font-size:10px}.stat strong{font-size:24px}.stat small{font-size:9px}
+    .tree-link{font-size:11px;padding:9px;gap:6px}.tree-link span{font-size:10px}
+    .distributions{grid-template-columns:1fr;gap:18px}.detail-grid{grid-template-columns:minmax(0,1fr) 210px}.prose{padding:22px}.detail-title h2{font-size:21px}
+  }
+  @media(max-width:900px){
+    .stats{grid-template-columns:repeat(2,minmax(0,1fr))}.detail-grid{grid-template-columns:1fr}.detail-aside{position:static;grid-row:1}.reading-nav{display:none}
+    .structured-aside{max-height:none}.structured-aside .metadata{grid-template-columns:repeat(2,minmax(0,1fr))}
+    .detail-grid:not(.reading) .detail-aside{display:none}
+    .facts-toggle{display:flex;align-items:center;justify-content:space-between;border:0;background:none;width:100%;padding:0;min-height:28px;font-size:12px;color:var(--kf-ink);text-align:left}
+    .facts-toggle span{display:flex;gap:5px;align-items:center;color:var(--kf-accent-ink);font-size:11px}
+    .structured-aside>h3{display:none}.structured-aside #record-facts{display:none}.structured-aside.expanded #record-facts{display:block}
+  }
+  @media(max-width:767px){
+    .p-head{gap:8px;padding:8px 0 10px}.p-head h2{font-size:19px}.p-actions{flex:none;gap:5px}.p-actions .button{padding:7px 8px;font-size:10px;min-height:34px}.p-actions .button>svg{width:12px;height:12px}
+    .stats{grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-bottom:10px}.stat{padding:10px;gap:4px}.stat-label{font-size:10px;gap:4px}.stat-label svg{width:11px;height:11px}.stat strong{font-size:22px}.stat small{font-size:9px;line-height:1.7}
+    .directory{padding:7px 10px;margin-bottom:10px}.directory-links{display:none}.mobile-type{display:flex;align-items:center;gap:12px;font-size:11px;color:var(--kf-ink-3);white-space:nowrap}.mobile-type select{flex:1;font-size:12px;min-width:0}
+    .content{gap:10px}.panel{padding:14px}.filter{padding:11px 12px}.filter-main{display:block}.filter-main>.advanced-toggle{display:flex;margin:5px 0 0 auto;padding:4px 0 0 8px;min-height:28px}
+    .filter-grid{gap:9px;grid-template-columns:repeat(2,minmax(0,1fr))}.field{font-size:10px}select,input[type=date]{font-size:12px;min-height:34px}
+    .advanced-toggle{font-size:10px}.filter-chip{font-size:10px;padding:5px}.filter-chip b{max-width:170px}.active-filters{gap:5px;padding-top:8px;margin-top:7px}
+    .result-head{gap:8px;margin-bottom:9px;align-items:flex-start}.result-head h3{font-size:13px;gap:5px}.result-head h3 strong{font-size:20px}.result-head p{font-size:9px;max-width:22em}
+    .result-tools{gap:6px;align-items:center}.insights-toggle{font-size:10px;padding:6px 7px;min-height:32px}.view-switch{padding:3px}.view-switch button{width:26px;height:24px}
+    .record-list.cards{grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:8px}.cards .record-symbol{display:none}.record,.list .record{padding:12px;gap:8px}.record h4{font-size:13px}.record p{font-size:12px;margin-bottom:8px}.record-meta{font-size:10px;padding-top:8px}.record-number{max-width:47%}
+    .list .record-symbol{display:none}.list .record h4{-webkit-line-clamp:2}.tags span{font-size:9px}.scope-note{font-size:10px;padding:9px 10px}.pending-note{flex-wrap:wrap;gap:3px 6px}.pending-note .text-button{font-size:10px;padding:3px 0}
+    .pagination{gap:6px;font-size:10px;margin-top:15px}.pagination .button{font-size:10px;padding:8px;min-height:34px}.pagination>div{gap:5px}.page-limit{display:none}.bar{grid-template-columns:85px minmax(0,1fr) 38px}.coverage p{font-size:10px;gap:6px}.coverage small{font-size:9px}
+    .detail-page{gap:10px;margin-top:8px}.breadcrumb{font-size:11px}.detail-title h2{font-size:18px;margin:8px 0 12px;line-height:1.7}.detail-actions{gap:7px}.detail-actions .button{font-size:11px;padding:7px 9px}.detail-actions .text-button{font-size:11px}
+    .detail-grid{gap:14px}.detail-main,.detail-aside{gap:14px}.prose{padding:20px 18px}.full-text{font-size:13px;line-height:2.1;margin-top:16px}.prose h3{font-size:14px}.metadata{gap:0 16px}.metadata dt{font-size:10px}.metadata dd{font-size:11px}.relations{grid-template-columns:1fr}.relations a{font-size:11px}.notice{font-size:11px}.source .metadata{grid-template-columns:1fr}.structured-aside .metadata{grid-template-columns:1fr 1fr}
+    .report-results{grid-template-columns:1fr}.report-record{padding:12px}.report-record h4{font-size:12px}
+  }
+  @media(max-width:360px){
+    .p-head h2{font-size:17px}.p-actions .button{font-size:10px;padding:6px}.p-actions .button>svg{display:none}.stat-label{font-size:9px}.stat-label svg{display:none}.stat strong{font-size:21px}.stat small{font-size:8px}.result-head{flex-wrap:wrap}.result-tools{margin-left:auto}.filter-chip b{max-width:130px}.structured-aside .metadata{grid-template-columns:1fr}.record-number{max-width:42%}.record-meta{gap:6px}.pagination>div{gap:4px}
+  }
+  @media(prefers-reduced-motion:reduce){.loading-dot{animation:none}.record,.stat{transition:none}}
+`;

+ 21 - 13
src/pages/report-card.js

@@ -32,12 +32,8 @@ export const CARD_CSS = css`
   .rc.fit { display: flex; flex-direction: column; gap: var(--kf-space-in); height: 100%; box-sizing: border-box; }
   .rc.fit > * + * { margin-top: 0; }
   .rc.fit > .src { margin-top: auto; }
-  /* v13 二:没有描述(lead)的卡,多出来的高度不进「标题 → 指标区」这一条缝(那就是一块空到 66px 的空白),
-     整张卡重新均分到卡内各块之间。无 lead 的卡挂 .nolead。
-     列宽必须 minmax(0, 1fr):.src 里「上传人未记录 · Agent 未记录 · 方式未记录 · 时间未记录」四项 nowrap,
-     最小内容宽 140px;默认 min-width:auto 时轨道被撑成两个 140px 再换行,行高翻倍溢出去(实测卡多出 44px)。
-     有描述的卡不挂 .nolead,不受影响(仍是 .rc.fit 的 flex 布局,来源行钉底边)。 */
-  .rc.fit.nolead { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 25% 30% 30% auto 15%; row-gap: var(--kf-space-in); }
+  /* 没有描述时按内容高度排列,再分配剩余空隙。百分比行高加上 gap 会超过卡面,挤掉底部来源。 */
+  .rc.fit.nolead { justify-content: space-between; }
   .rc.fit.nolead > * { margin-top: 0; min-width: 0; }
   .rc.fit.nolead > .src { margin-top: 0; }
   /* 卡内元素间距 12:用相邻兄弟给,块布局下卡片被拉高也不会把间隙撑开 */
@@ -152,9 +148,10 @@ export const CARD_CSS = css`
     .rc .m > div.score-cell .ring { justify-self: start; }
     .rc .q { font-size: 14px; line-height: 1.55; }
     .rc .badge { height: 24px; padding: 0 8px; font-size: 12px; border-radius: var(--r-sm); }
-    .rc .src { display: flex; flex-direction: column; align-items: stretch; gap: 4px; font-size: 12px; line-height: 1.4; padding-top: 12px; }
-    .rc .src .l1 { display: flex; align-items: center; gap: 6px; min-width: 0; }
-    .rc .src .l2 { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--kf-ink-2); }
+    .rc .src { display: flex; flex-direction: column; flex-wrap: nowrap; flex-shrink: 0; align-items: stretch; gap: 4px; font-size: 12px; line-height: 1.4; padding-top: 12px; }
+    .rc .src .l1 { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; max-width: 100%; }
+    .rc .src .l2 { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; max-width: 100%; color: var(--kf-ink-2); }
+    .rc .src .who { min-width: 0; max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
     .rc .src .l1 .dot { display: none; }
     .rc .src .tm { margin-left: 0; }
     .rc .src .it.agent { max-width: none; }
@@ -165,6 +162,8 @@ export const CARD_CSS = css`
   .row { display: grid; grid-template-columns: 48px minmax(0, 1.5fr) 96px 88px minmax(0, 1fr) 80px; gap: 12px; align-items: center;
     padding: 8px 12px; border-radius: var(--r-sm); color: var(--kf-ink-2); font-size: 12px; line-height: 1.5; cursor: pointer;
     transition: background var(--kf-dur) var(--kf-ease); }
+  .row > * { min-width: 0; }
+  .row svg { flex: none; }
   .row + .row { border-top: 1px solid var(--kf-line); }
   .row:hover { background: rgba(255,255,255,0.5); }
   .row .t { color: var(--kf-ink); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -173,6 +172,17 @@ export const CARD_CSS = css`
   .row .sc b { color: var(--kf-ink); }
   .row .mono.nowrap { white-space: nowrap; }
   .row .up { display: inline-flex; color: var(--kf-ok-text); vertical-align: -0.15em; }
+  @media (max-width: 1100px) {
+    .row { grid-template-columns: 40px minmax(0, 1fr) auto; grid-template-areas: "stage title score" "stage owner time"; gap: 8px; padding: 14px 0; }
+    .row > :nth-child(1) { grid-area: stage; align-self: start; }
+    .row > :nth-child(2) { grid-area: title; }
+    .row > :nth-child(3) { display: none; }
+    .row > :nth-child(4) { grid-area: score; text-align: right; }
+    .row > :nth-child(5) { grid-area: owner; }
+    .row > :nth-child(6) { grid-area: time; font-size: 12px; }
+    .row .who { flex-wrap: wrap; white-space: normal; overflow-wrap: anywhere; font-size: 12px; }
+    .listhead { display: none; }
+  }
   /* 页脚(v12 P2):卡片区下面的一条「到此为止」收尾线 —— 横跨整行、居中,只写条数。
      具体的操作(调整筛选 / 回到顶部)不在这里重复:筛选栏一直在吸顶位置,上滚一下就是它。 */
   .cfoot { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 8px; margin: 48px 0 0; padding: 20px 4px 12px;
@@ -373,7 +383,7 @@ export function reportRow({ e, recent = false, onOpen = null }) {
   const f = cardFacts(e);
   return html`
     <div class="row" data-entry=${e.id} data-kind=${e.kind} role="button" tabindex="0"
-      @click=${onOpen || null} @keydown=${onOpen ? (ev) => { if (ev.key === 'Enter') onOpen(ev); } : null}>
+      @click=${onOpen || null} @keydown=${onOpen ? (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); onOpen(ev); } } : null}>
       <span>${f.stage ? html`<span class="badge stage t-${f.stageTone}">${f.stage}</span>` : ''}</span>
       <span class="t" title=${e.title}>${e.title}</span>
       <span>${segBar(f.evidenceSeg, f.evidence)}</span>
@@ -382,7 +392,5 @@ export function reportRow({ e, recent = false, onOpen = null }) {
         ${icon('user', 12)} ${e.owner || '—'} ${icon('bot', 12)} ${e.agent || '—'} ${icon(METHOD_ICON[e.methodKey] || 'doc', 12)}
       </span>
       <span class="mono nowrap">${recent ? html`<span class="up">${icon('up', 11)}</span>` : ''}${whenText(e.at)}</span>
-    </div>
-  }
-`;
+    </div>`;
 }

+ 28 - 0
src/pages/report-pager.js

@@ -0,0 +1,28 @@
+import { html, css } from 'lit';
+
+/** 卡片和列表共用分页;中间页省略,手机也不会撑出屏幕。 */
+export function reportPager(pg, onPage) {
+  if (pg.pages <= 1) return '';
+  const pages = [...new Set([1, pg.page - 1, pg.page, pg.page + 1, pg.pages])]
+    .filter(n => n >= 1 && n <= pg.pages).sort((a, b) => a - b);
+  return html`<nav class="report-pager" data-pager aria-label="成果清单分页">
+    <div class="page-controls">
+      <button data-page=${pg.page - 1} ?disabled=${pg.page === 1} @click=${() => onPage(pg.page - 1)}>上一页</button>
+      ${pages.map((n, i) => html`${i && n > pages[i - 1] + 1 ? html`<span aria-hidden="true">…</span>` : ''}<button class="page-number" data-page=${n} aria-label=${`第 ${n} 页`} aria-current=${n === pg.page ? 'page' : 'false'} @click=${() => onPage(n)}>${n}</button>`)}
+      <button data-page=${pg.page + 1} ?disabled=${pg.page === pg.pages} @click=${() => onPage(pg.page + 1)}>下一页</button>
+    </div>
+    <span class="page-summary" aria-live="polite">第 ${pg.page} / ${pg.pages} 页 · 共 ${pg.total} 条</span>
+  </nav>`;
+}
+
+export const PAGER_CSS = css`
+  .report-pager { display: grid; justify-items: center; gap: 10px; margin: 24px 0 12px; }
+  .page-controls { display: flex; justify-content: center; align-items: center; gap: 4px; flex-wrap: wrap; }
+  .report-pager button { font: inherit; font-size: 13px; min-width: 36px; min-height: 40px; padding: 6px 10px; border: 1px solid var(--kf-line); border-radius: var(--r-sm); color: var(--kf-ink-2); background: var(--kf-surface); cursor: pointer; }
+  .report-pager button:hover:not(:disabled) { background: var(--kf-surface-btn); }
+  .report-pager button[aria-current="page"] { background: var(--kf-accent); color: white; border-color: var(--kf-accent); }
+  .report-pager button:disabled { opacity: .4; cursor: default; }
+  .report-pager button:focus-visible { outline: 2px solid var(--kf-focus); outline-offset: 2px; }
+  .page-summary { color: var(--kf-ink-3); font-size: 12px; }
+  @media (max-width: 767px) { .report-pager button { min-height: 44px; } .page-number:not([aria-current="page"]) { display: none; } }
+`;

+ 50 - 29
src/pages/view-ledger.js

@@ -22,12 +22,16 @@ const LAYERS = [['raw', '原材料'], ['process', '过程'], ['sediment', '沉
 const stageHeat = (rows) => {
   const code = (r) => r.stageObj?.code || '';
   const layer = (r) => r.layer || '';
-  return ['S1', 'S2', 'S3', 'S4', 'S5'].map((c) => {
+  const stages = ['S1', 'S2', 'S3', 'S4', 'S5'].map((c) => {
     const n = rows.filter((r) => code(r) === c).length;
-    const cells = LAYERS.map(([k, label]) => ({ v: rows.filter((r) => code(r) === c && layer(r) === k).length, label: `${c} · ${label}` }));
-    const max = Math.max(1, ...cells.map((x) => x.v));
-    return { code: c, total: n, cells: cells.map((x) => ({ ...x, v: x.v / max })) };
+    const cells = LAYERS.map(([k, label]) => {
+      const count = rows.filter((r) => code(r) === c && layer(r) === k).length;
+      return { count, label: `${c} · ${label}:${count} 条` };
+    });
+    return { code: c, total: n, cells };
   });
+  const max = Math.max(1, ...stages.flatMap((s) => s.cells.map((x) => x.count)));
+  return stages.map((s) => ({ ...s, cells: s.cells.map((x) => ({ ...x, v: x.count / max })) }));
 };
 
 const DIMS = [
@@ -56,24 +60,34 @@ export class ViewLedger extends LitElement {
     useTokens(this);
   }
 
-  updated() {
-    const first = this.renderRoot.querySelector('tr.hl');
-    if (first && !this.#scrolled) {
-      this.#scrolled = true;
-      first.scrollIntoView({ block: 'center', behavior: 'smooth' });
-    }
+  async updated(changed) {
+    if (!changed.has('routeQuery') && !changed.has('db')) return;
+    const focus = String(this.routeQuery?.focus || '');
+    if (!focus) { this.#scrolled = ''; return; }
+    if (focus === this.#scrolled) return;
+    const table = this.renderRoot.querySelector('kf-table');
+    if (table) await table.updateComplete;
+    if (focus !== String(this.routeQuery?.focus || '')) return;
+    const first = [...(table?.shadowRoot.querySelectorAll('tr.hl') || []),
+      ...this.renderRoot.querySelectorAll('.mledger li.hl')].find((el) => el.getClientRects().length);
+    if (!first) return;
+    this.#scrolled = focus;
+    first.scrollIntoView({ block: 'center', behavior: 'smooth' });
   }
 
-  #scrolled = false;
+  #scrolled = '';
 
   static styles = [VIEW_CSS, css`
     /* 视图宿主显式 block:自定义元素默认 inline,整个视图会被排成一个行盒,块级子元素互相叠、互相撑高 */
     :host { display: block; }
-    .filters { display: block; padding: 12px 16px 16px; }
+    .ledger-overview { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 16px; align-items: start; }
+    .filters { display: block; min-width: 0; height: auto; --kf-card-pad: 16px; }
     .flist { display: grid; gap: 8px; }
     .frow { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
     .frow .lbl { width: 56px; font-size: 12px; color: var(--kf-ink-3); flex: none; }
-    .chips { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; }
+    .chips { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; min-width: 0; }
+    .chips .kf-chip { max-width: 100%; height: auto; min-height: 28px; white-space: normal; overflow-wrap: anywhere; text-align: left; }
+    .chips .kf-chip .n { flex: none; }
     .kf-chip[data-zero="1"] { opacity: 0.42; }
     .sum { display: flex; gap: 16px; align-items: baseline; font-size: 14px; color: var(--kf-ink-3); }
     .sum { font-family: var(--kf-font-num); font-variant-numeric: tabular-nums; }
@@ -85,33 +99,35 @@ export class ViewLedger extends LitElement {
     .ledger-head h2 { margin: 0; font-size: 20px; line-height: 1.3; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; }
     .ledger-head p { margin: 4px 0 0; font-size: 14px; line-height: 1.6; color: var(--kf-ink-3); }
     .ledger-head .sum { margin-left: auto; display: flex; gap: 16px; align-items: baseline; font-size: 14px; color: var(--kf-ink-3); }
-    .bar2 { display: flex; align-items: center; gap: 12px; margin: 16px 0 12px; min-height: 24px; }
+    .bar2 { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 16px 0 12px; min-height: 24px; }
     tbody tr { cursor: pointer; }
     .focus-note { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--kf-warn-text); margin: 0; }
     .bar2 .clear { height: 28px; }
     .bar2 .hint { margin-left: auto; font-size: 12px; color: var(--kf-ink-3); }
     .bar2 kf-heat { align-self: center; }
-    /* 阶段 × 层 热力:浮在筛选卡上方的独立小块,不和标题抢行 */
-    .heatx { display: inline-grid; justify-items: start; gap: 2px; margin: 0 0 12px; padding: 10px 14px; border-radius: 10px; background: rgba(255,255,255,0.55); box-shadow: var(--elev-1); }
-    .hx-title { font-size: 12px; font-weight: 500; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); margin-bottom: 6px; }
-    .hx-row { display: flex; align-items: center; gap: 2px; }
+    /* 分类格横排,与四个层名逐列对齐;桌面与筛选并排,窄屏上下排列。 */
+    .heatx { --heat-cell-width: 40px; display: grid; gap: 10px; padding: 16px; min-width: 0; box-sizing: border-box;
+      border-radius: var(--r-md); background: var(--kf-surface); border: 1px solid var(--kf-line-soft); }
+    .hx-title { font-size: 12px; font-weight: 500; line-height: 1.6; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); }
+    .hx-row, .hx-head { display: grid; grid-template-columns: 28px 166px minmax(28px, 1fr); align-items: center; gap: 8px; }
+    .hx-head { font-size: 10px; color: var(--kf-ink-3); }
+    .hx-head > :last-child { text-align: right; }
     .hx-row kf-heat { flex: none; }
-    .hx-row .hx-n { margin-left: 4px; }
     .hx-n .u { margin-left: 1px; font-size: 10px; color: var(--kf-ink-3); font-weight: 500; }
-    .hx-legend { display: flex; gap: 2px; font-size: 10px; color: var(--kf-ink-3); line-height: 12px; padding-left: 24px; }
-    /* 四个层名竖排:每格 12px,两个字上下叠(行高 12px,和热力格同高) */
-    .hx-legend span { display: block; box-sizing: content-box; width: 12px; flex: none; text-align: center; line-height: 12px; word-break: break-all; }
+    .hx-legend { display: grid; grid-template-columns: repeat(4, 40px); gap: 2px; line-height: 16px; }
+    .hx-legend span { text-align: center; white-space: nowrap; }
     .hx-s { font: 600 12px/1 var(--kf-font-num); color: var(--kf-ink-3); }
     .hx-n { font: 600 12px/1 var(--kf-font-num); color: var(--kf-ink); text-align: right; font-variant-numeric: tabular-nums; }
     .ledger-head .lt h3 { font-size: 16px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
     /* ---- 手机:台账不再横滚 9 列表格,改「一条一张小玻璃卡」(名称 / 类型·阶段·人·层 / 优先级·状态·时间) ---- */
     .mledger { display: none; }
+    @media (max-width: 1199px) { .ledger-overview { grid-template-columns: minmax(0, 1fr); } }
     @media (max-width: 767px) {
       .ledger-head { flex-wrap: wrap; gap: 4px; padding: 0 0 12px; }
       .ledger-head h2 { font-size: 20px; font-weight: 600; }
       .ledger-head p { font-size: 14px; color: var(--kf-ink-2); }
       .ledger-head .sum { margin-left: 0; width: 100%; font-size: 14px; }
-      .filters { padding: 12px; border-radius: var(--r-md); }
+      .filters { --kf-card-pad: 12px; }
       .frow { gap: 8px; }
       .frow .lbl { width: 44px; font-size: 14px; }
       .chips { gap: 8px; }
@@ -123,6 +139,7 @@ export class ViewLedger extends LitElement {
         background: var(--kf-surface); border: 1px solid var(--kf-line-soft);
         -webkit-backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%); box-shadow: var(--kf-shadow); }
       .mledger li:active { transform: scale(0.985); transition: transform 80ms cubic-bezier(0.4, 0, 0.2, 1); }
+      .mledger li:focus-visible { outline: 2px solid var(--kf-focus); outline-offset: 2px; }
       .mledger .t { font-size: 14px; font-weight: 600; line-height: 1.4; color: var(--kf-ink); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
       .mledger .m { display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 14px; color: var(--kf-ink-2); font-variant-numeric: tabular-nums; }
       .mledger .m .k { color: var(--kf-ink-3); }
@@ -161,24 +178,25 @@ export class ViewLedger extends LitElement {
     const active = DIMS.some((d) => (c[d.key] || []).length) || c.q;
     const cols = [
       { key: 'title', label: '对象' },
-      { key: 'kind', label: '类型' },
+      { key: 'kind', label: '类型', render: (r) => r.kind === 'opportunity' ? '机会卡' : '成果' },
       { key: 'stage', label: '阶段', render: (r) => (r.stageObj ? `${r.stageObj.code}` : '—') },
       { key: 'ownerName', label: '人' },
       { key: 'layer', label: '层', render: (r) => ({ raw: '原材料', process: '过程', sediment: '沉淀', record: '记录' }[r.layer] || r.layer || '—') },
       { key: 'priority', label: '优先级', render: (r) => r.priority || '—' },
       { key: 'status', label: '状态', render: (r) => (r.kind === 'opportunity' ? { candidate: '候选', approved: '已立项', rejected: '已否决' }[r.status] : '成果') },
       { key: 'evidenceLabel', label: '证据' },
-      { key: 'createdAt', label: '时间', render: (r) => fmtDate(r.kind === 'opportunity' ? r.upload.uploadedAt : r.createdAt, true) },
+      { key: 'createdAt', label: '时间', render: (r) => fmtDate(r.kind === 'opportunity' ? r.upload?.uploadedAt : r.createdAt, true) },
     ];
     return html`
       <div class="ledger-head">
         <div class="lt"><h3 class="tb-cap">全部记录</h3></div>
         <div class="sum"><span><b>${filtered.length}</b> / ${t(`${rows.length} 条`)}</span></div>
       </div>
+      <div class="ledger-overview">
       <div class="heatx" data-viz="heat" role="group" aria-label="阶段与层交叉分布">
         <div class="hx-title">阶段 × 层 · ${t(`合计 ${rows.length} 条 · 右端为各阶段小计`)}</div>
-        <div class="hx-legend"><span class="hx-s"></span>${LAYERS.map(([, l]) => html`<span>${l}</span>`)}</div>
-        ${stageHeat(rows).map((r) => html`<div class="hx-row"><span class="hx-s">${r.code}</span><kf-heat .days=${r.cells}></kf-heat><b class="hx-n">${r.total}<span class="u">条</span></b></div>`)}
+        <div class="hx-head"><span>阶段</span><div class="hx-legend">${LAYERS.map(([, l]) => html`<span>${l}</span>`)}</div><span>合计</span></div>
+        ${stageHeat(rows).map((r) => html`<div class="hx-row" role="group" aria-label=${r.cells.map((x) => x.label).join(';')}><span class="hx-s">${r.code}</span><kf-heat layout="row" .days=${r.cells}></kf-heat><b class="hx-n">${r.total}<span class="u">条</span></b></div>`)}
       </div>
       <kf-glass-card surface="2" class="filters" data-no-activate><div class="flist">
         ${DIMS.map((d) => {
@@ -193,6 +211,7 @@ export class ViewLedger extends LitElement {
           </div></div>`;
         })}
       </div></kf-glass-card>
+      </div>
       <div class="bar2">${focus.size ? html`<p class="focus-note">${icon('link', 14)} ${t(`从证据链跳入:${focus.size} 条被引用的成果已在表中高亮。`)}</p>` : ''}
         ${active ? html`<button class="kf-btn ghost clear" @click=${() => navigate('/ledger', { replace: true })}>${icon('x', 12)} 清除条件</button>` : ''}
         <span class="hint">点击行查看证据链</span></div>
@@ -201,10 +220,12 @@ export class ViewLedger extends LitElement {
         .caption=${active ? '已按条件过滤' : ''}
         @row-click=${(e) => this.#row(e)}></kf-table>`
         : html`<kf-glass-card surface="2"><kf-empty title="没有符合条件的记录" hint="六维条件是取交集的 —— 去掉一项再试。" action-label="清除条件" @empty-action=${() => navigate('/ledger', { replace: true })}></kf-empty></kf-glass-card>`}
-      ${filtered.length ? html`<ul class="mledger" data-mledger>${filtered.map((r) => html`<li data-key=${`${r.kind}:${r.id}`} class=${focus.has(r.id) && r.kind === 'artifact' ? 'hl' : ''} @click=${() => this.#row({ detail: { key: `${r.kind}:${r.id}` } })}>
+      ${filtered.length ? html`<ul class="mledger" data-mledger>${filtered.map((r) => html`<li data-key=${`${r.kind}:${r.id}`} role="button" tabindex="0" class=${focus.has(r.id) && r.kind === 'artifact' ? 'hl' : ''}
+        @click=${() => this.#row({ detail: { key: `${r.kind}:${r.id}` } })}
+        @keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.#row({ detail: { key: `${r.kind}:${r.id}` } }); } }}>
         <span class="t">${r.title}</span>
         <span class="m"><span>${r.kind === 'opportunity' ? '机会卡' : '成果'}</span><span class="k">·</span><span>${r.stageObj?.code || '—'}</span><span class="k">·</span><span>${r.ownerName || '—'}</span><span class="k">·</span><span>${({ raw: '原材料', process: '过程', sediment: '沉淀', record: '记录' }[r.layer]) || r.layer || '—'}</span></span>
-        <span class="m"><span>${r.priority ? t(`${r.priority} 级`) : '—'}</span><span class="k">·</span><span>${r.kind === 'opportunity' ? ({ candidate: '候选', approved: '已立项', rejected: '已否决' }[r.status] || '候选') : '成果'}</span><span class="k">·</span><span>${fmtDate(r.kind === 'opportunity' ? r.upload.uploadedAt : r.createdAt, true)}</span></span>
+        <span class="m"><span>${r.priority ? t(`${r.priority} 级`) : '—'}</span><span class="k">·</span><span>${r.kind === 'opportunity' ? ({ candidate: '候选', approved: '已立项', rejected: '已否决' }[r.status] || '候选') : '成果'}</span><span class="k">·</span><span>${fmtDate(r.kind === 'opportunity' ? r.upload?.uploadedAt : r.createdAt, true)}</span></span>
       </li>`)}</ul>` : ''}
     `;
   }

+ 368 - 0
src/pages/view-library.js

@@ -0,0 +1,368 @@
+import { LitElement, html } from 'lit';
+import { useTokens } from '../components/base.js';
+import { icon } from '../components/icons.js';
+import { head, VIEW_CSS } from './shared.js';
+import { LIBRARY_CSS } from './library-styles.js';
+import './library-product-detail.js';
+import { href, navigate } from '../utils/router.js';
+import { readLibrary } from '../services/library-service.js';
+import { LIBRARY_ENTITIES, LIBRARY_GROUPS, LIBRARY_FILTERS, FACET_COLUMNS, LIBRARY_FIELDS, libraryLabel, libraryRequest, libraryPath, libraryDate, libraryRecordInfo, libraryCatalogQuery } from '../core/library.js';
+
+const number = n => Number(n || 0).toLocaleString('zh-CN');
+const safeUrl = value => {
+  try { const u = new URL(value); return ['http:', 'https:'].includes(u.protocol) ? u.href : null; }
+  catch { return null; }
+};
+const TYPE_INFO = {
+  products: ['inbox', '找到商品后,在商品详情内查看它的包装规格、价格和消费者评价。'],
+  specs: ['table', '查看商品包装、净含量与件数,了解具体规格。'],
+  prices: ['bars', '比较历史价格观察,先核对价格口径、时间与商品规格。'],
+  reviews: ['chat', '阅读消费者原始评价,按关联商品和购买规格定位反馈。'],
+  social: ['quote', '从社媒正文中寻找使用场景、体验与消费线索。'],
+  documents: ['shield', '查阅政策、标准与膳食指导,阅读全文并追溯原始依据。'],
+  clauses: ['doc', '定位具体条款与应用提示,为产品创新找到可引用的依据。'],
+  rankings: ['bars', '浏览已采到的榜单线索,回到原始资料核实榜单口径。'],
+  coverage: ['layers', '了解采集范围与覆盖情况,识别可用资料和采集缺口。'],
+  reports: ['doc', '复用工作坊已有研究成果,进入完整报告继续阅读。'],
+};
+const SCOPE_NOTES = {
+  social: '品牌筛选表示内容提及,不代表商品归属;平台评论量不等于已采到的评价。',
+  rankings: '这些是实际采到的榜单线索,不代表完整 Top100/400,也不能证明增长趋势。',
+  documents: '政策状态按原资料截至日期展示;应用提示与摘要不作为逐字法规原文。',
+  clauses: '政策状态按原资料截至日期展示;应用提示与摘要不作为逐字法规原文。',
+  prices: '价格为历史观察,展示价不等于实付价;规格未核对时不计算单位价格。',
+};
+
+export class ViewLibrary extends LitElement {
+  static properties = {
+    db: { type: Object }, routeQuery: { type: Object }, recordId: { type: String }, recordEntity: { type: String },
+    _summary: { state: true }, _data: { state: true }, _detail: { state: true }, _loading: { state: true },
+    _error: { state: true }, _notice: { state: true },
+    _advanced: { state: true }, _insights: { state: true }, _layout: { state: true },
+    _overview: { state: true }, _factsExpanded: { state: true },
+  };
+  constructor() {
+    super();
+    this.db = null; this.routeQuery = {}; this.recordId = ''; this.recordEntity = '';
+    this._summary = null; this._data = null; this._detail = null; this._loading = true;
+    this._error = ''; this._notice = ''; this._ticket = 0;
+    this._advanced = false; this._insights = false; this._layout = 'cards';
+    this._overview = false; this._factsExpanded = false;
+    this._previousCursors = new Map();
+  }
+  connectedCallback() { super.connectedCallback(); useTokens(this); this._poll = setInterval(() => this._checkVersion(), 30000); }
+  disconnectedCallback() { super.disconnectedCallback(); clearInterval(this._poll); this._ticket++; }
+  updated(changed) {
+    if (changed.has('routeQuery') || changed.has('recordId') || changed.has('recordEntity')) {
+      const sameProduct = this.recordEntity === 'products' && this._detail?.record.id === this.recordId
+        && !changed.has('recordId') && !changed.has('recordEntity')
+        && JSON.stringify(libraryCatalogQuery(changed.get('routeQuery') || {})) === JSON.stringify(libraryCatalogQuery(this.routeQuery));
+      if (!sameProduct || ['q', 'relatedQ'].some(key => Object.hasOwn(this.routeQuery, key))) this._load();
+    }
+    if (changed.has('_loading') && !this._loading && this._scrollResults) {
+      this._scrollResults = false;
+      this.renderRoot.querySelector('.results')?.scrollIntoView({ block: 'start' });
+    }
+  }
+  get _type() { return libraryRequest(this.routeQuery).entity; }
+  get _catalogType() { return ['specs', 'prices', 'reviews', 'rankings', 'coverage'].includes(this._type) ? 'products' : this._type === 'clauses' ? 'documents' : this._type; }
+  _label(value) { return libraryLabel(value, this._summary); }
+  _patch(patch, { reset = false } = {}) {
+    const q = reset ? {} : { ...this.routeQuery };
+    delete q.cursor;
+    for (const [k, v] of Object.entries(patch)) v == null || v === '' ? delete q[k] : q[k] = v;
+    navigate(libraryPath(q));
+  }
+  _clear() { this._patch({ type: this._type }, { reset: true }); }
+  async _load(refresh = false) {
+    const ticket = ++this._ticket;
+    this._loading = true; this._error = ''; this._detail = null; this._data = null;
+    try {
+      // Saved search links become filter-only links; their cursors belong to a different query.
+      if (['q', 'relatedQ'].some(key => Object.hasOwn(this.routeQuery, key))) {
+        const query = { ...this.routeQuery };
+        if (Object.hasOwn(query, 'q')) delete query.cursor;
+        if (Object.hasOwn(query, 'relatedQ')) delete query.relatedCursor;
+        delete query.q; delete query.relatedQ;
+        navigate(libraryPath(query, this.recordId ? { entity: this.recordEntity, id: this.recordId } : null), { replace: true });
+        return;
+      }
+      const oldGeneration = this._summary?.generation;
+      const summary = (!this._summary || refresh) ? await readLibrary({ op: 'summary' }) : this._summary;
+      if (ticket !== this._ticket) return;
+      this._summary = summary;
+      if (oldGeneration && oldGeneration !== summary.generation) this._previousCursors.clear();
+      if (!this.recordId && this.routeQuery.productId && ['specs', 'prices', 'reviews', 'rankings'].includes(this._type)) {
+        const { productId, type, quality, cursor, from, to, ...filters } = this.routeQuery;
+        navigate(libraryPath({ ...filters, type: 'products', panel: type, relatedQuality: quality, relatedFrom: from, relatedTo: to }, { entity: 'products', id: productId }), { replace: true });
+        return;
+      }
+      if ((this.routeQuery.cursor || this.routeQuery.relatedCursor) && oldGeneration && oldGeneration !== summary.generation) {
+        const query = { ...this.routeQuery }; delete query.cursor; delete query.relatedCursor;
+        navigate(libraryPath(query, this.recordId ? { entity: this.recordEntity, id: this.recordId } : null), { replace: true });
+        return;
+      }
+      if (this.recordId) {
+        const data = await readLibrary({ op: 'detail', entity: decodeURIComponent(this.recordEntity), id: decodeURIComponent(this.recordId), generation: summary.generation });
+        if (ticket === this._ticket) this._detail = data;
+      } else if (this._type !== 'reports') {
+        const data = await readLibrary({ ...libraryRequest(this.routeQuery), generation: summary.generation });
+        if (ticket === this._ticket) this._data = data;
+      }
+    } catch (e) { if (ticket === this._ticket) this._error = e.message || '资料读取失败'; }
+    finally { if (ticket === this._ticket) this._loading = false; }
+  }
+  async _checkVersion() {
+    if (document.visibilityState !== 'visible' || this._loading || !this._summary) return;
+    try {
+      const v = await readLibrary({ op: 'version' });
+      if (v.generation !== this._summary.generation) {
+        this._previousCursors.clear();
+        this._load(true);
+      }
+    } catch { /* Keep the last verified data; explicit refresh still reports errors. */ }
+  }
+  async _copy(record) {
+    const link = this._originalLink(record);
+    const facts = this._recordFacts(record);
+    const citation = [
+      record.title || record.parentDocument?.title || record.parentProduct?.title || LIBRARY_ENTITIES[this.recordEntity],
+      ...facts.map(([label, value]) => `${label}:${value}`),
+      ...(link ? [`原始页面:${link}`] : []),
+      `资料链接:${href(libraryPath(this.routeQuery, { entity: this.recordEntity, id: record.id }))}`,
+    ].join('\n');
+    try {
+      await navigator.clipboard.writeText(citation);
+      this._notice = '资料标题、来源与阅读链接已复制';
+    } catch { this._notice = '复制未完成,可在资料来源中打开或复制阅读链接'; }
+  }
+  _originalLink(record) { return safeUrl(record.url || record.source_url || record.parentProduct?.url || record.parentDocument?.source_url); }
+  _page(cursor, next = false) {
+    if (next) this._previousCursors.set(cursor, this.routeQuery.cursor || '');
+    this._scrollResults = true;
+    this._patch({ cursor });
+  }
+  _stats() {
+    const s = this._summary;
+    if (!s) return '';
+    const items = [
+      ['商品与竞品', s.counts.products, `含 ${number(s.counts.reviews)} 条评价与 ${number(s.counts.prices)} 条价格记录`, 'products', 'inbox'],
+      ['社媒正文', s.quality.socialFulltext, `内容线索 ${number(s.counts.social_posts)} 条`, 'social', 'quote'],
+      ['政策依据', s.counts.policy_documents, `${number(s.counts.policy_clauses)} 条摘录与应用提示`, 'documents', 'shield'],
+      ['研究成果', this.db?.artifacts.length, '工作坊已有分析报告', 'reports', 'doc'],
+    ];
+    return html`<section id="library-overview" class="stats" aria-label="资料概览" ?hidden=${!this._overview}>
+      ${items.map(([name, value, note, type, glyph]) => html`<a class="stat" href=${href(libraryPath({ type }))} data-link>
+        <span class="stat-label">${icon(glyph, 14)}${name}</span><strong>${number(value)}</strong><small>${note}</small>
+      </a>`)}
+    </section>`;
+  }
+  _tree() {
+    const count = type => type === 'reports' ? number(this.db?.artifacts.length) : number(this._summary?.entities.find(e => e.id === type)?.count);
+    return html`<nav class="directory" aria-label="资料分类">
+      <label class="mobile-type">浏览资料<select aria-label="资料分类" .value=${this._catalogType} @change=${e => this._patch({ type: e.target.value }, { reset: true })}>
+        ${LIBRARY_GROUPS.flatMap(g => g.entities).map(t => html`<option value=${t} ?selected=${this._catalogType === t}>${LIBRARY_ENTITIES[t]} · ${count(t)}</option>`)}
+      </select></label>
+      <div class="directory-links">${LIBRARY_GROUPS.map(g => html`
+        ${g.entities.map(type => html`<a class=${this._catalogType === type ? 'tree-link chosen' : 'tree-link'} href=${href(libraryPath({ type }))} data-link aria-current=${this._catalogType === type ? 'page' : 'false'}>
+          ${icon(g.icon, 16)}${LIBRARY_ENTITIES[type]}<span>${count(type)}</span>
+        </a>`)}
+      `)}</div>
+    </nav>`;
+  }
+  _facet(field, facet) {
+    const col = FACET_COLUMNS[field], selected = this.routeQuery[field] || '', rows = [...facet.rows];
+    if (selected && !rows.some(r => (r[col] ?? '__unknown') === selected)) rows.unshift({ [col]: selected === '__unknown' ? null : selected });
+    return html`<label class="field">${LIBRARY_FILTERS[field]}<select aria-label=${LIBRARY_FILTERS[field]} .value=${selected} @change=${e => this._patch({ [field]: e.target.value })}>
+      <option value="">全部${LIBRARY_FILTERS[field]}</option>
+      ${rows.map(r => html`<option value=${r[col] ?? '__unknown'} ?selected=${(r[col] ?? '__unknown') === selected}>${field === 'shopId' ? (r.shop_name || r[col] || '未知店铺') : this._label(r[col])}${r.recordCount != null ? ' · ' + number(r.recordCount) : ''}</option>`)}
+    </select>${facet.truncated ? html`<small>仅列前100项,更多店铺可从商品详情定位</small>` : ''}</label>`;
+  }
+  _chips() {
+    const q = this.routeQuery, items = [];
+    for (const [key, label] of Object.entries(LIBRARY_FILTERS)) {
+      if (!q[key]) continue;
+      const row = this._data?.facets[key]?.rows.find(r => (r[FACET_COLUMNS[key]] ?? '__unknown') === q[key]);
+      items.push([key, label, q[key] === '__unknown' ? '未知/待核对' : key === 'shopId' ? row?.shop_name || q[key] : this._label(q[key])]);
+    }
+    if (q.productId) items.push(['productId', '关联商品', '当前商品']);
+    if (q.documentId) items.push(['documentId', '关联文件', '当前文件']);
+    for (const [key, label] of Object.entries({ from: '日期起', to: '日期止' })) {
+      if (q[key]) items.push([key, label, q[key]]);
+    }
+    if (q.quality === 'audit') items.push(['quality', '口径', '全部原始记录']);
+    return items.length ? html`<div class="active-filters" aria-label="已选筛选条件"><span class="selected-label">已选</span>
+      ${items.map(([key, label, value]) => html`<button class="filter-chip" title=${`${label}:${value},点击移除`} aria-label=${`移除${label}筛选:${value}`} @click=${() => this._patch({ [key]: null })}>${label}<b>${value}</b>${icon('x', 11)}</button>`)}
+      <button class="text-button reset" @click=${() => this._clear()}>清空全部</button>
+    </div>` : '';
+  }
+  _filter() {
+    const d = this._data, q = this.routeQuery;
+    const primary = ['platform', 'brand', 'category', 'policyStatus'].filter(k => d.facets[k]);
+    const more = Object.keys(d.facets).filter(k => !primary.includes(k) && k !== 'specVerification');
+    const advancedCount = more.filter(k => q[k]).length + (q.from ? 1 : 0) + (q.to ? 1 : 0);
+    return html`<section class="panel filter" aria-label="资料筛选">
+      <div class="filter-main"><div class="filter-grid primary-filters">
+        ${primary.map(k => this._facet(k, d.facets[k]))}
+        <label class="field">查看口径<select aria-label="查看口径" .value=${q.quality || 'analysis'} @change=${e => this._patch({ quality: e.target.value })}>
+          <option value="analysis">推荐用于分析的资料</option><option value="audit">全部资料(含待确认)</option>
+        </select></label>
+      </div>
+        <button class=${this._advanced ? 'text-button advanced-toggle active' : 'text-button advanced-toggle'} aria-expanded=${this._advanced} aria-controls="advanced-filters" @click=${() => this._advanced = !this._advanced}>
+          ${icon('filter', 14)}${this._advanced ? '收起筛选' : '更多筛选'}${advancedCount ? html`<b>${advancedCount}</b>` : ''}${icon(this._advanced ? 'up' : 'down', 11)}
+        </button>
+      </div>
+      <div id="advanced-filters" class="advanced-filters filter-grid" ?hidden=${!this._advanced}>
+        ${more.map(k => this._facet(k, d.facets[k]))}
+        ${this._type !== 'coverage' ? html`<label class="field">${this._type === 'reviews' ? '评价' : this._type === 'social' ? '发表' : '来源'}日期起<input aria-label="日期起" type="date" .value=${q.from || ''} @change=${e => this._patch({ from: e.target.value })}></label>
+          <label class="field">日期止<input aria-label="日期止" type="date" .value=${q.to || ''} @change=${e => this._patch({ to: e.target.value })}></label>` : ''}
+      </div>${this._chips()}
+    </section>`;
+  }
+  _distribution() {
+    const d = this._data;
+    if (!d) return '';
+    const dims = ['platform', 'category'].filter(k => d.facets[k]);
+    return html`<div class="distributions">${dims.map(dim => {
+      const rows = d.facets[dim].rows.slice(0, 5), max = Math.max(1, ...rows.map(r => r.recordCount));
+      return html`<section class="distribution"><h3>${LIBRARY_FILTERS[dim]}覆盖 <small>当前筛选范围 · 前5项</small></h3>
+        ${rows.map(r => html`<button class="bar" title=${`筛选${LIBRARY_FILTERS[dim]}:${this._label(r[dim])}`} @click=${() => this._patch({ [dim]: r[dim] ?? '__unknown' })}><span>${this._label(r[dim])}</span><i><b style=${`width:${Math.round(r.recordCount / max * 100)}%`}></b></i><strong>${number(r.recordCount)}</strong></button>`)}
+      </section>`;
+    })}</div>`;
+  }
+  _coverage() {
+    return html`<section class="coverage"><h3>时间覆盖与资料缺口</h3>
+      ${Object.entries(this._data?.coverage || {}).map(([key, c]) => html`<p><b>${({ sourceDate: '来源观察日期', reviewDate: '评价日期', publishedDate: '发表日期' })[key]}</b><span>${c.min ? `${c.min} 至 ${c.max}` : '暂无已知日期'}</span><span>${number(c.unknown)} 条时间未知</span></p>`)}
+      <small>评价条数不等于独立消费者人数;缺失日期保留为未知。</small>
+    </section>`;
+  }
+  _record(r) {
+    const title = r.title || r.preview || '无标题资料';
+    let preview = (r.preview || '').trim();
+    if (r.title && preview.startsWith(r.title)) preview = preview.slice(r.title.length).trim();
+    if (r.entity === 'prices' || r.entity === 'specs') preview = '';
+    else if (r.entity === 'coverage') preview = preview ? this._label(preview) : '';
+    else if (r.entity === 'documents') preview = preview.replace(/--- PDF page \d+ ---/g, '').replace(/\s+/g, ' ').trim();
+    const recordDate = r.review_day ? `评价 ${r.review_day}` : r.published_day ? `发表 ${r.published_day}` : r.source_day ? `观察 ${r.source_day}` : r.policyStatusAsOf ? `状态截至 ${r.policyStatusAsOf}` : '来源日期未知';
+    return html`<a class="record" href=${href(libraryPath(this.routeQuery, { entity: r.entity, id: r.record_id }))} data-link data-record-id=${r.record_id}>
+      <div class="record-symbol" aria-hidden="true">${icon(TYPE_INFO[r.entity]?.[0] || 'doc', 18)}</div>
+      <div class="record-main"><div class="tags">
+        ${r.platform || r.policy_status ? html`<span class="platform-tag">${this._label(r.platform || r.policy_status)}</span>` : ''}
+        ${r.brand ? html`<span>${this._label(r.brand)}</span>` : ''}${r.category ? html`<span>${this._label(r.category)}</span>` : ''}
+        ${r.entity === 'social' ? html`<span>${r.fulltext_available ? '正文可读' : '仅标题线索'}</span>` : ''}
+      </div><h4>${title}</h4>
+        ${r.entity === 'prices' ? html`<div class="price-fact"><strong>${r.price_minor != null && Number.isFinite(Number(r.price_minor)) ? `¥${(Number(r.price_minor) / 100).toFixed(2)}` : '价格待核对'}</strong><span>${this._label(r.price_basis)}</span></div>` : ''}
+        ${r.entity === 'specs' ? html`<div class="spec-facts">
+          ${r.net_weight_g != null ? html`<span>净含量 <b>${r.net_weight_g}g</b></span>` : ''}
+          ${r.pack_count != null ? html`<span>件数 <b>${r.pack_count}</b></span>` : ''}
+          ${r.net_weight_g == null && r.pack_count == null ? html`<span>暂无详细规格</span>` : ''}
+        </div>` : ''}
+        ${preview ? html`<p>${preview}</p>` : this._type === 'social' && !r.fulltext_available ? html`<p class="muted">仅有标题,暂无正文</p>` : ''}
+        ${r.entity === 'products' ? html`<div class="product-summary"><span>${icon('chat', 11)}评价 ${number(r.relatedCounts?.reviews)}</span><span>${icon('bars', 11)}价格 ${number(r.relatedCounts?.prices)}</span><span>${icon('table', 11)}规格 ${number(r.relatedCounts?.specs)}</span></div>` : ''}
+        <div class="record-meta"><span>${icon('clock', 11)}${recordDate}</span><span>查看详情 ${icon('right', 11)}</span></div>
+      </div><span class="open-arrow">${icon('right', 14)}</span>
+    </a>`;
+  }
+  _records() {
+    const d = this._data;
+    if (!d) return '';
+    return html`<section class="results" aria-label="资料结果">
+      <div class="result-head"><div><h3>资料结果 <strong role="status">${number(d.totalMatched)}</strong><small>条</small></h3>
+        <p>${this.routeQuery.quality === 'audit' ? '全部资料(含待确认)' : '推荐用于分析的资料'}${this._type === 'reviews' ? ` · ${number(d.distinctTextFingerprintsByEntity.reviews)} 条去重文本 · ${number(d.distinctLinkedProducts)} 个关联商品` : ''}</p>
+      </div><div class="result-tools"><button class=${this._insights ? 'button insights-toggle active' : 'button insights-toggle'} aria-expanded=${this._insights} aria-controls="coverage-insights" @click=${() => this._insights = !this._insights}>${icon('bars', 14)}覆盖统计</button>
+        <div class="view-switch" role="group" aria-label="资料显示方式">${[['cards', 'grid', '卡片'], ['list', 'list', '列表']].map(([value, glyph, label]) => html`<button class=${this._layout === value ? 'chosen' : ''} aria-label=${`${label}视图`} aria-pressed=${this._layout === value} title=${`${label}视图`} @click=${() => this._layout = value}>${icon(glyph, 14)}</button>`)}</div>
+      </div></div>
+      <div id="coverage-insights" class="panel insights" ?hidden=${!this._insights}>${this._distribution()}${this._coverage()}</div>
+      ${d.pendingCount > 0 ? html`<div class="scope-note pending-note">${icon('alert', 14)}<span>另有 ${number(d.pendingCount)} 条资料需确认后使用。</span><button class="text-button" @click=${() => this._patch({ quality: 'audit' })}>查看全部资料 ${icon('right', 11)}</button></div>` : ''}
+      ${SCOPE_NOTES[this._type] ? html`<p class="scope-note">${SCOPE_NOTES[this._type]}</p>` : ''}
+      <div class=${`record-list ${this._layout}`}>${d.records.map(r => this._record(r))}</div>
+      ${!d.records.length ? html`<div class="panel empty"><div class="empty-icon">${icon('filter', 22)}</div><h3>没有找到符合条件的资料</h3><p>可减少筛选条件或调整日期范围。</p><button class="button" @click=${() => this._clear()}>清空筛选,查看全部</button></div>` : ''}
+      <div class="pagination"><span>本页 ${d.records.length} 条 <span class="page-limit">· 每页最多20条</span></span><div>
+        ${this.routeQuery.cursor ? html`<button class="button" @click=${() => this._page(null)}>第一页</button>${this._previousCursors.has(this.routeQuery.cursor) ? html`<button class="button" @click=${() => this._page(this._previousCursors.get(this.routeQuery.cursor))}>${icon('left', 14)}上一页</button>` : ''}` : ''}
+        <button class="button" ?disabled=${!d.nextCursor} @click=${() => this._page(d.nextCursor, true)}>下一页 ${icon('right', 14)}</button>
+      </div></div>
+    </section>`;
+  }
+  _recordFacts(record) {
+    const facts = libraryRecordInfo(record, this.recordEntity, this._summary);
+    const hasPlatform = facts.some(([label]) => ['销售平台', '内容平台', '来源平台'].includes(label));
+    const context = record.parentProduct ? libraryRecordInfo(record.parentProduct, 'products', this._summary)
+      .filter(([label]) => !(hasPlatform && label === '销售平台') && !facts.some(([name]) => name === label)) : [];
+    return [...context, ...facts];
+  }
+  _metadata(record) {
+    const facts = this._recordFacts(record);
+    return facts.length ? html`<dl class="metadata">${facts.map(([label, value]) => html`<div><dt>${label}</dt><dd>${value}</dd></div>`)}</dl>` : html`<p class="scope-note">暂无更多资料信息。</p>`;
+  }
+  _source(r) {
+    const original = this._originalLink(r);
+    return html`<section id="source" class="panel source"><h3>${icon('link', 16)}资料来源</h3>
+      <div class="source-links">${original ? html`<a class="button" href=${original} target="_blank" rel="noopener noreferrer">打开原始页面 ${icon('link', 14)}</a>` : html`<p class="scope-note">原始页面链接暂未提供,可通过资料链接引用本条内容。</p>`}
+        <a class="text-button" href=${href(libraryPath(this.routeQuery, { entity: this.recordEntity, id: r.id }))} data-link>本条资料链接 ${icon('right', 11)}</a>
+      </div>
+    </section>`;
+  }
+  _jump(id) { this.renderRoot.getElementById(id)?.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth', block: 'start' }); }
+  _detailView() {
+    const d = this._detail;
+    if (!d) return '';
+    const r = d.record, prose = ['content', 'full_text', 'excerpt', 'application_note'].filter(k => r[k]);
+    return html`<div class="detail-page"><div class="breadcrumb"><a href=${href(libraryPath(libraryCatalogQuery(this.routeQuery)))} data-link>${icon('left', 14)}返回筛选结果</a><span>${LIBRARY_ENTITIES[this.recordEntity]}</span></div>
+      <section class="panel detail-title"><p class="eyebrow">${icon(TYPE_INFO[this.recordEntity]?.[0] || 'doc', 14)}${LIBRARY_ENTITIES[this.recordEntity]}</p>
+        <h2>${r.title || r.parentDocument?.title || r.parentProduct?.title || r.clause || LIBRARY_ENTITIES[this.recordEntity]}</h2>
+        <div class="detail-actions"><button class="button primary" @click=${() => this._copy(r)}>${icon('quote', 14)}复制资料引用</button>
+          ${this._originalLink(r) ? html`<a class="button" href=${this._originalLink(r)} target="_blank" rel="noopener noreferrer">原始链接 ${icon('link', 14)}</a>` : ''}
+          <button class="text-button" @click=${() => this._jump('source')}>查看来源 ${icon('down', 11)}</button>
+        </div>
+      </section>
+      ${this.recordEntity === 'products' ? html`<library-product-detail .product=${r} .relations=${d.relations} .summary=${this._summary} .query=${this.routeQuery} .generation=${this._summary.generation}></library-product-detail>${this._source(r)}` : html`<div class=${`detail-grid ${prose.length ? 'reading' : ''}`}><div class="detail-main">
+        ${!prose.length ? html`<section id="structured" class="panel"><h3>资料信息</h3>${this._metadata(r)}</section>` : ''}
+        ${prose.map(k => html`<section id=${k} class=${`panel prose ${k === 'application_note' ? 'application-note' : ''}`}><h3>${LIBRARY_FIELDS[k]}</h3>
+          ${k === 'excerpt' ? html`<p class="scope-note">摘录口径:${r.excerpt_type === 'paraphrase_or_transcription' ? '摘要或转写' : '未经逐字核验的摘录'},引用前请核原文件。</p>` : ''}
+          <p class="full-text">${String(r[k]).replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, '\n\n')}</p>
+        </section>`)}
+        ${this.recordEntity === 'social' && !r.content ? html`<section class="panel empty">这条社媒内容仅有标题,暂无可阅读正文。</section>` : ''}
+        ${r.parentDocument ? html`<section class="panel"><h3>所属文件</h3><a class="inline-link" href=${href(libraryPath(this.routeQuery, { entity: 'documents', id: r.parentDocument.id }))} data-link>${r.parentDocument.title} ${icon('right', 14)}</a></section>` : ''}
+        ${r.parentProduct ? html`<section class="panel"><h3>关联商品</h3><a class="inline-link" href=${href(libraryPath(this.routeQuery, { entity: 'products', id: r.parentProduct.id }))} data-link>${r.parentProduct.title} ${icon('right', 14)}</a></section>` : ''}
+        ${d.relations.length ? html`<section id="relations" class="panel"><h3>关联资料</h3><div class="relations">${d.relations.map(x => html`<a href=${href(libraryPath({ type: x.entity, productId: x.productId, quality: 'audit' }))} data-link><span>${icon(TYPE_INFO[x.entity]?.[0] || 'doc', 14)}${x.name}</span><b>${number(x.count)}</b>${icon('right', 14)}</a>`)}</div><small>与本商品相关的资料,含待确认内容。</small></section>` : ''}
+        ${r.clauses?.length ? html`<section id="clauses" class="panel"><h3>条款与应用提示 <small>${r.clauses.length} 条</small></h3><div class="relations clause-links">${r.clauses.map(x => html`<a href=${href(libraryPath(this.routeQuery, { entity: 'clauses', id: x.id }))} data-link>${x.name}${icon('right', 14)}</a>`)}</div></section>` : ''}
+        ${this._source(r)}
+      </div><aside class="detail-aside">
+        <section class="panel reading-nav"><h3>本页内容</h3>
+          ${!prose.length ? html`<button @click=${() => this._jump('structured')}>资料信息</button>` : prose.map(k => html`<button @click=${() => this._jump(k)}>${LIBRARY_FIELDS[k]}</button>`)}
+          ${d.relations.length ? html`<button @click=${() => this._jump('relations')}>关联资料</button>` : ''}
+          ${r.clauses?.length ? html`<button @click=${() => this._jump('clauses')}>条款与应用提示</button>` : ''}
+          <button @click=${() => this._jump('source')}>资料来源</button>
+        </section>
+        ${prose.length ? html`<section class=${this._factsExpanded ? 'panel structured-aside expanded' : 'panel structured-aside'}><h3>资料信息</h3>
+          <button class="facts-toggle" aria-expanded=${this._factsExpanded} aria-controls="record-facts" @click=${() => this._factsExpanded = !this._factsExpanded}>资料信息 <span>${this._factsExpanded ? '收起' : '展开'}${icon(this._factsExpanded ? 'up' : 'down', 11)}</span></button>
+          <div id="record-facts">${this._metadata(r)}</div>
+        </section>` : ''}
+      </aside></div>`}
+    </div>`;
+  }
+  _reports() {
+    const all = this.db?.artifacts || [], q = this.routeQuery;
+    const stages = [...new Map(all.filter(r => r.stageObj).map(r => [r.stageObj.code, r.stageObj])).values()].sort((a, b) => a.no - b.no);
+    const members = [...new Map(all.filter(r => r.memberId).map(r => [r.memberId, r.ownerName || '称呼待补充'])).entries()];
+    const rows = all.filter(r => (!q.reportStage || r.stageObj?.code === q.reportStage) && (!q.reportMember || r.memberId === q.reportMember));
+    return html`<section class="panel filter report-filters" aria-label="成果筛选"><div class="filter-grid">
+      <label class="field">工作坊阶段<select aria-label="成果阶段" .value=${q.reportStage || ''} @change=${e => this._patch({ reportStage: e.target.value })}><option value="">全部阶段</option>${stages.map(s => html`<option value=${s.code} ?selected=${q.reportStage === s.code}>${s.code} · ${s.label}</option>`)}</select></label>
+      <label class="field">成员<select aria-label="成果成员" .value=${q.reportMember || ''} @change=${e => this._patch({ reportMember: e.target.value })}><option value="">全部成员</option>${members.map(([id, name]) => html`<option value=${id} ?selected=${q.reportMember === id}>${name}</option>`)}</select></label>
+    </div>${q.reportStage || q.reportMember ? html`<button class="text-button" @click=${() => this._patch({ reportStage: null, reportMember: null })}>清空筛选</button>` : ''}</section>
+    <section class="report-results"><div class="result-head"><h3>研究成果 <strong>${number(rows.length)}</strong><small>份</small></h3><a class="text-button" href=${href('/reports')} data-link>完整报告中心 ${icon('right', 14)}</a></div>
+      ${!this.db ? html`<p class="panel empty">研究成果暂时无法读取,可先浏览其他资料。</p>` : !rows.length ? html`<p class="panel empty">没有符合当前筛选的研究成果,可调整阶段或成员。</p>` : rows.map(r => html`<a class="report-record" href=${href(`/report/${encodeURIComponent(r.id)}`)} data-link><span class="record-symbol">${icon('doc', 18)}</span><div><h4>${r.title}</h4><small>${r.stageObj?.code || ''} · ${r.ownerName || '归属待同步'}</small></div>${icon('right', 14)}</a>`)}
+    </section>`;
+  }
+  _skeleton() { return html`<section class="panel loading" aria-busy="true" role="status"><span class="loading-dot"></span><span>${this.recordId ? '正在读取资料原文与来源…' : '正在读取资料…'}</span></section>`; }
+  render() {
+    return html`${head('行业资料库', '', html`${!this.recordId ? html`<button class="button overview-toggle" aria-expanded=${this._overview} aria-controls="library-overview" @click=${() => this._overview = !this._overview}>${icon('bars', 14)}资料概览${icon(this._overview ? 'up' : 'down', 11)}</button>` : ''}<button class="button refresh" ?disabled=${this._loading} @click=${() => this._load(true)}>${icon('clock', 14)}刷新资料</button>`)}
+      ${this._notice ? html`<div class="notice" role="status"><span>${icon('check', 14)}${this._notice}</span><button class="text-button" aria-label="关闭提示" @click=${() => this._notice = ''}>${icon('x', 14)}</button></div>` : ''}
+      ${this._error ? html`<div class="panel error" role="alert">${icon('alert', 22)}<div><h3>${this._error}</h3><p>资料暂时无法读取,请稍后重试。</p><button class="button" @click=${() => this._load(true)}>重试</button></div></div>` : ''}
+      ${this.recordId ? (this._loading ? this._skeleton() : this._detailView()) : html`${this._stats()}${this._tree()}<div class="library-layout"><div class="content">
+        ${this._type === 'reports' ? this._reports() : this._loading ? this._skeleton() : this._data ? html`${this._filter()}${this._records()}` : ''}
+      </div></div>`}`;
+  }
+  static styles = [VIEW_CSS, LIBRARY_CSS];
+}
+if (!customElements.get('view-library')) customElements.define('view-library', ViewLibrary);

+ 318 - 118
src/pages/view-members.js

@@ -1,136 +1,336 @@
-/**
- * pages/view-members.js —— <view-members> 成员。
- * 每个人的:组、当前阶段、会话消息数、成果数、机会卡数、最后活动、是否卡住。
- * 点成员行 → 按「人」筛台账(?member=名字)。
- */
+/** Member groups are shared presentation metadata; member identity and reports remain intact. */
 import { LitElement, css, html } from 'lit';
 import { useTokens } from '../components/base.js';
 import { fmtAgo, fmtDate, fmtAgoText } from '../core/index.js';
-import { href } from '../utils/router.js';
+import { seedMemberGroups } from '../core/member-groups.js';
+import { memberGroupService } from '../services/member-groups.js';
+import { href, reportUrl, currentUrl } from '../utils/router.js';
+import { employeeCaption } from '../core/kfs/employee.js';
 import { t, head, VIEW_CSS } from './shared.js';
 import { icon } from '../components/icons.js';
 
+const ALL = '__all', UNGROUPED = '__ungrouped';
 export class ViewMembers extends LitElement {
   static properties = {
-    db: { type: Object },
-    me: { type: String },
+    db: { type: Object }, me: { type: String }, _groups: { state: true }, _loading: { state: true },
+    _error: { state: true }, _busy: { state: true }, _selected: { state: true }, _filter: { state: true },
+    _query: { state: true }, _dialog: { state: true }, _draft: { state: true }, _formError: { state: true },
+    _managedGroup: { state: true }, _edit: { state: true }, _notice: { state: true },
   };
-
   constructor() {
-    super();
-    this.db = null;
-    this.me = '';
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    useTokens(this);
+    super();this.db = null;this.me = '';this._groups = null;this._loading = true;this._busy = false;
+    this._error = '';this._selected = new Set();this._filter = ALL;this._query = '';this._dialog = null;
+    this._draft = '';this._formError = '';this._managedGroup = UNGROUPED;this._edit = null;this._notice = '';
   }
-
-
-  static styles = [VIEW_CSS, css`
-    /* 组与组 24(卡与卡),组内元素 12 —— 跨级规则(3.1) */
-    .gcard { padding: var(--kf-space-card); margin: 0 0 var(--kf-space-card-gap); }
-    .gh { display: flex; align-items: baseline; gap: 8px; margin-bottom: var(--kf-space-in); }
-    .gh h3 { margin: 0; font-size: 16px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
-    .num .u { margin-left: 2px; font-size: 12px; color: var(--kf-ink-3); font-weight: 400; }
-    .gh .n { margin-left: auto; font-size: 12px; color: var(--kf-ink-3); letter-spacing: var(--ls-cn-small); }
-    .list { list-style: none; margin: 0; padding: 0; display: grid; }
-    .list li { display: grid; grid-template-columns: 40px minmax(96px, 1fr) 180px 80px 72px 72px 112px 80px; gap: 12px; align-items: center;
-      padding: 8px 12px; border-radius: var(--r-sm); font-size: 14px; line-height: 1.5; color: var(--kf-ink-2);
-      font-variant-numeric: tabular-nums; transition: background var(--kf-dur) var(--kf-ease); }
-    .list li + li { border-top: 1px solid var(--kf-line); }
-    .list li:not(.head):hover { background: var(--kf-surface-btn); }
-    .list li.head { box-shadow: none; color: var(--kf-ink-3); font-size: 12px; letter-spacing: var(--ls-cn-small); font-weight: 600; padding: 0 12px 8px; border-bottom: 1px solid var(--kf-line); border-radius: 0; }
-    .av { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-weight: 600; font-size: 12px; color: #fff; background: linear-gradient(135deg, var(--kf-s1), var(--kf-s2)); }
-    .nm { font-weight: 600; color: var(--kf-ink); }
-    .nm a { color: inherit; text-decoration: none; }
-    .nm a:hover { color: var(--kf-accent); }
-    .me-tag { display: inline-block; font-size: 12px; line-height: 16px; color: var(--kf-accent-ink); border: 1px solid rgba(91, 91, 214, 0.30); border-radius: var(--r-sm); padding: 0 4px; margin-left: 6px; }
-    .stuck { display: inline-flex; align-items: center; gap: 4px; color: var(--kf-s); font-weight: 600; }
-    .ok { color: var(--kf-ok-text); }
-    kf-bars { display: block; width: 100%; color: var(--kf-accent); }
-    .cmp-n { display: block; margin-top: 4px; font-size: 12px; font-weight: 500; color: var(--kf-ink-3); text-align: right; }
-    @media (max-width: 1000px) { .list li { grid-template-columns: 40px 1fr 1fr; } .list li > :nth-child(n+4) { display: none; } .list li.head { display: none; } }
-    /* 手机:一行一个人 —— 头像 + 名字/阶段 + 状态;姓名为 44px 触摸行;整卡内边距 16 */
-    @media (max-width: 767px) {
-      .gcard { padding: 0; }
-      .gh { flex-wrap: wrap; padding: 16px 16px 8px; margin-bottom: 0; gap: 4px 8px; }
-      .gh h3 { font-size: 16px; font-weight: 600; }
-      .list li { grid-template-columns: 36px minmax(0, 1fr) auto; grid-template-areas: "a n s" "a t s"; column-gap: 10px; row-gap: 2px; padding: 8px 16px; min-height: 60px; font-size: 14px; box-sizing: border-box; }
-      .list li > :nth-child(1) { grid-area: a; }
-      .list li > :nth-child(2) { grid-area: n; }
-      .list li > :nth-child(3) { grid-area: t; font-size: 14px; color: var(--kf-ink-3); }
-      .list li > :nth-child(8) { grid-area: s; font-size: 14px; justify-self: end; }
-      .nm a { display: inline-flex; align-items: center; min-height: 24px; }
+  connectedCallback() { super.connectedCallback();useTokens(this);this._readGroups(); }
+  updated(changed) {
+    if (changed.has('db') && this.db) {
+      this._syncSelection();
+      if (changed.get('db') && !this._busy && !this._dialog) this._readGroups();
     }
-  `];
-
+    const dialog = this.renderRoot.querySelector('dialog');
+    if (this._dialog && dialog && !dialog.open) dialog.showModal();
+    if (!this._dialog && dialog?.open) dialog.close();
+    if (changed.has('_edit') && this._edit) dialog?.querySelector(this._edit.action === 'delete' ? '.editor button[type=submit]' : 'input[name=groupName]')?.focus();
+  }
+  async _readGroups() {
+    if (this._reading) return;
+    this._reading = true;this._loading = true;
+    try {
+      this._groups = await memberGroupService.read();this._error = '';
+      if (this._filter !== ALL && this._filter !== UNGROUPED && !this._groups.groups.some((g) => g.id === this._filter)) this._filter = ALL;
+      this._syncSelection();
+    } catch (error) { this._error = error.message || '分组读取失败,请重试'; }
+    finally { this._reading = false;this._loading = false; }
+  }
+  get _state() { return this._groups || seedMemberGroups(this.db?.members || []); }
+  _groupOf(member) { return this._state.assignments[member.id] || UNGROUPED; }
+  _membersOf(groupId) { return (this.db?.members || []).filter((m) => this._groupOf(m) === groupId); }
+  _syncSelection() {
+    this._selected = new Set([...this._selected].filter((id) => this.db?.memberById[id] && this._groupOf(this.db.memberById[id]) === UNGROUPED));
+    if (this._managedGroup !== UNGROUPED && !this._state.groups.some((g) => g.id === this._managedGroup)) this._managedGroup = this._state.groups[0]?.id || UNGROUPED;
+  }
+  get _visibleMembers() {
+    const q = this._query.trim().toLocaleLowerCase();
+    return (this.db?.members || []).filter((m) => (this._filter === ALL || this._groupOf(m) === this._filter) &&
+      (!q || m.name.toLocaleLowerCase().includes(q)));
+  }
+  _select(ids, checked) {
+    const selected = new Set(this._selected);
+    for (const id of ids) checked ? selected.add(id) : selected.delete(id);
+    this._selected = selected;
+  }
+  _setFilter(value) { this._filter = value; }
+  _open() {
+    this._managedGroup = this._state.groups.some((g) => g.id === this._filter) ? this._filter : this._state.groups[0]?.id || UNGROUPED;
+    this._chooseGroup(this._managedGroup);this._notice = '';this._dialog = true;
+  }
+  _chooseGroup(id) { this._managedGroup = id;this._edit = null;this._selected = new Set();this._formError = ''; }
+  _openEditor(action, group = null) { this._edit = { action, groupId: group?.id };this._draft = group?.name || '';this._formError = ''; }
+  _close() { if (!this._busy) { this._dialog = null;this._edit = null;this._selected = new Set(); } }
+  async _save(command) {
+    if (this._busy || !this._groups || this._loading) return false;
+    this._busy = true;this._error = '';this._notice = '';this._formError = '';
+    try {
+      const previousIds = new Set(this._groups.groups.map((g) => g.id));
+      const saved = await memberGroupService.change(this._groups, command);
+      this._groups = { ...saved, localPreview: this._groups.localPreview };
+      if (command.action === 'create') this._managedGroup = saved.groups.find((g) => !previousIds.has(g.id))?.id || this._managedGroup;
+      if (command.action === 'assign') this._selected = new Set();
+      if (command.action === 'delete' && this._filter === command.groupId) this._filter = ALL;
+      this._syncSelection();
+      this._notice = command.action === 'create' ? '分组已创建' : command.action === 'rename' ? '分组名称已更新' : command.action === 'delete' ? '分组已删除,成员已移至未分组' : '成员分组已保存';
+      return true;
+    } catch (error) {
+      const message = error.message || '保存结果未确认,请刷新核对后再操作';
+      if (this._dialog) this._formError = message; else this._error = message;
+      // Refresh the shared revision after a stale write; never retry the write itself.
+      if (error.code === 'VERSION_CONFLICT') {
+        try { this._groups = await memberGroupService.read();this._syncSelection(); } catch { /* Keep the visible error. */ }
+      }
+      return false;
+    } finally { this._busy = false; }
+  }
+  async _submit(event) {
+    event.preventDefault();
+    if (!this._edit) return;
+    const { action, groupId } = this._edit;
+    if (await this._save({ action, ...(groupId ? { groupId } : {}), ...(action !== 'delete' ? { name: this._draft } : {}) })) this._edit = null;
+  }
+  _addMembers() {
+    const groupId = this._managedGroup;
+    if (groupId === UNGROUPED) return;
+    const memberIds = this._membersOf(UNGROUPED).filter((m) => this._selected.has(m.id)).map((m) => m.id);
+    if (memberIds.length) this._save({ action: 'assign', groupId, memberIds });
+  }
+  _member(member) {
+    const status = member.stuck ? `卡住:${fmtAgoText((this.db.now || Date.now()) - member.stuckHours * 3600000, this.db.now || Date.now())}` : member.done ? '已交成果' : '进行中';
+    return html`<li class="member" data-member-id=${member.id}>
+      <span class="av">${Array.from(member.name).slice(-1).join('')}</span>
+      <div class="identity"><a href=${href(`/ledger?member=${encodeURIComponent(member.name)}`)} data-link title="查看该成员的证据台账">${member.name}</a>${member.name === this.me ? html`<span class="me-tag">我</span>` : ''}${member.employee ? html`<small class="employee-caption">${employeeCaption(member.employee)}</small>` : ''}</div>
+      <div class="progress"><span>${member.stageObj ? t(`${member.stageObj.code} ${member.stageObj.name}`) : '未开始'}</span><small class=${member.stuck ? 'stuck' : member.done ? 'ok' : ''}>${t(status)}</small></div>
+      <div class="counts"><span>${member.artifactIds.length}<small>份成果</small></span><span>${member.oppIds.length}<small>张机会卡</small></span><span>${member.msgCount}<small>条会话</small></span></div>
+      <span class="active" title=${fmtDate(member.lastActiveAt, true)}>${fmtAgo(member.lastActiveAt)}</span>
+    </li>`;
+  }
+  _manager() {
+    const groups = this._state.groups, group = groups.find((g) => g.id === this._managedGroup);
+    const current = this._membersOf(this._managedGroup), candidates = this._membersOf(UNGROUPED);
+    const editGroup = groups.find((g) => g.id === this._edit?.groupId);
+    const disabled = this._busy || this._loading || !this._groups;
+    const selectedAll = candidates.length > 0 && candidates.every((m) => this._selected.has(m.id));
+    return html`
+      <div class="dialog-head"><div><h3 id="group-dialog-title">分组管理</h3><p class="hint">整理小组与成员,成果和项目归属保持关联。</p></div><button type="button" class="text-button close" aria-label="关闭分组管理" ?disabled=${this._busy} @click=${() => this._close()}>${icon('x', 18)}</button></div>
+      <div class="manager-toolbar"><button type="button" class="button primary" id="create-group" ?disabled=${disabled} @click=${() => this._openEditor('create')}>${icon('plus', 16)}新增分组</button><span class="hint">${t(`${groups.length} 个分组 · ${this._membersOf(UNGROUPED).length} 位未分组成员`)}</span></div>
+      ${this._edit ? html`<form class="editor" @submit=${(e) => this._submit(e)}>
+        ${this._edit.action === 'delete' ? html`<div class="delete-note"><strong>删除「${editGroup?.name || '此分组'}」?</strong><p>${t(`${this._membersOf(this._edit.groupId).length} 位成员将回到未分组,已有成果与项目会保留。`)}</p></div>` : html`<label class="field"><span>${this._edit.action === 'create' ? '新增分组' : '修改分组名称'}</span><input name="groupName" aria-label="分组名称" placeholder="例如:产品研发组" maxlength="60" required .value=${this._draft} ?disabled=${disabled} @input=${(e) => { this._draft = e.target.value; }}><small>1–30 个字符,名称不能重复</small></label>`}
+        <div class="editor-actions"><button type="button" class="button" ?disabled=${disabled} @click=${() => { this._edit = null;this._formError = ''; }}>取消</button><button type="submit" class=${this._edit.action === 'delete' ? 'button delete' : 'button primary'} ?disabled=${disabled}>${this._busy ? '正在保存…' : this._edit.action === 'delete' ? '确认删除分组' : '保存分组'}</button></div>
+      </form>` : ''}
+      ${this._formError || this._error ? html`<p class="message error" role="alert">${this._formError || this._error}</p>` : ''}
+      ${this._notice ? html`<p class="message success" role="status">${this._notice}</p>` : ''}
+      <div class="manager-layout">
+        <nav class="group-list" aria-label="选择管理的分组">${[...groups, { id: UNGROUPED, name: '未分组' }].map((g) => html`<button type="button" class=${this._managedGroup === g.id ? 'group-tab chosen' : 'group-tab'} aria-pressed=${this._managedGroup === g.id} data-manage-group=${g.id} ?disabled=${disabled} @click=${() => this._chooseGroup(g.id)}><span>${g.name}</span><b>${this._membersOf(g.id).length}</b></button>`)}</nav>
+        <div class=${group ? 'manager-content has-group' : 'manager-content'}>
+          <section class="group-section" aria-label="组内成员"><div class="section-head"><div><h4>${group?.name || '未分组'}</h4><p class="hint">${t(`${current.length} 位成员`)}</p></div>${group ? html`<div class="group-actions"><button class="text-button" aria-label=${`重命名${group.name}`} ?disabled=${disabled} @click=${() => this._openEditor('rename', group)}>重命名</button><button class="text-button danger" aria-label=${`删除${group.name}`} ?disabled=${disabled} @click=${() => this._openEditor('delete', group)}>删除分组</button></div>` : ''}</div>
+            ${current.length ? html`<ul class="managed-members">${current.map((m) => html`<li data-managed-member=${m.id}><span class="av">${Array.from(m.name).slice(-1).join('')}</span><div class="managed-identity"><strong>${m.name}</strong><small>${t(`${m.artifactIds.length} 份成果 · ${m.oppIds.length} 张机会卡`)}</small></div>${group ? html`<button type="button" class="text-button remove" aria-label=${`将${m.name}移出小组`} ?disabled=${disabled} @click=${() => this._save({ action: 'assign', groupId: null, memberIds: [m.id] })}>移出小组</button>` : ''}</li>`)}</ul>` : html`<p class="empty compact">${group ? '小组还没有成员,请从待添加列表选择未分组成员。' : '所有成员都已加入小组。'}</p>`}
+            ${!group ? html`<p class="ungrouped-note">选择左侧的小组即可添加成员,也可以在上方新增分组。</p>` : ''}
+          </section>
+          ${group ? html`<section class="candidate-section" aria-label="添加未分组成员"><div class="section-head"><div><h4>添加未分组成员</h4><p class="hint">其他小组的成员须先移出原小组。</p></div></div>
+            ${candidates.length ? html`<div class="candidate-tools"><label class="check"><input type="checkbox" aria-label="全选未分组成员" .checked=${selectedAll} .indeterminate=${!selectedAll && candidates.some((m) => this._selected.has(m.id))} ?disabled=${disabled} @change=${(e) => this._select(candidates.map((m) => m.id), e.target.checked)}>全选未分组成员</label><span class="hint">${t(`已选 ${this._selected.size} 位`)}</span></div><ul class="candidates">${candidates.map((m) => html`<li><label class="candidate" data-candidate=${m.id}><input type="checkbox" aria-label=${`选择${m.name}`} .checked=${this._selected.has(m.id)} ?disabled=${disabled} @change=${(e) => this._select([m.id], e.target.checked)}><span class="av">${Array.from(m.name).slice(-1).join('')}</span><span>${m.name}</span></label></li>`)}</ul>` : html`<p class="empty compact">暂无未分组成员。</p>`}
+            <div class="add-footer"><span class="hint">${this._selected.size ? t(`将 ${this._selected.size} 位成员加入「${group.name}」`) : '选择成员后添加到当前小组'}</span><button type="button" class="button primary" id="add-members" ?disabled=${disabled || !this._selected.size} @click=${() => this._addMembers()}>${icon('plus', 16)}添加所选成员${this._selected.size ? `(${this._selected.size})` : ''}</button></div>
+          </section>` : ''}
+        </div>
+      </div>`;
+  }
+  _projectProgress() {
+    const projects = this.db.projects.filter((p) => p.progressState);
+    if (!projects.length) return '';
+    return html`<kf-glass-card surface="2" fill-to class="gcard"><div class="gh"><h3>项目进展</h3><span class="hint">课堂任务进度</span></div><div class="projects">${projects.map((p) => {
+      const report = p.artifactIds.map((id) => this.db.artifactById[id]).filter(Boolean).sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
+      return html`<div class="project"><span>${report ? html`<a href=${href(reportUrl(report.id, currentUrl()))} data-link>${p.title}</a>` : p.title}</span><span>${p.ownerName}</span><span>${p.stage?.code || '未开始'}</span><span class=${p.progressState === 'blocked' ? 'stuck' : ''}>${p.progressState === 'blocked' ? '卡住' : p.progressState === 'completed' ? '课堂完成' : '进行中'}</span></div>`;
+    })}</div></kf-glass-card>`;
+  }
   render() {
-    const db = this.db;
-    if (!db) return html`<kf-empty kind="loading" title="正在读取成员…"></kf-empty>`;
-    const groups = new Map();
-    for (const m of db.members) {
-      const g = m.groupId || '未分组';
-      if (!groups.has(g)) groups.set(g, []);
-      groups.get(g).push(m);
-    }
-    const stuck = db.members.filter((m) => m.stuck);
-    // 卡住时长的数字来自模型(stuckHours),单位在**生成处**拼并过 cjkSpace:52 个小时 / 2 天前。
-    // 先从模型取一个基准时刻 now,再算(不从 Date.now() 里减,避免逐人渲染时刻不同导致时长跳变)
-    const now = db.now || Date.now(); // 模型带 now(构建时刻),同一次数据同一基准
-    const stuckText = (m) => fmtAgoText(now - m.stuckHours * 3600000, now);
-    // v11b 2.3 对比条:全员产出对比(份数),最大值占满、其余按比例;数字右对齐,条只说形状
-    const maxArt = Math.max(1, ...db.members.map((m) => m.artifactIds.length));
-    const compare = [...db.members].sort((a, b) => b.artifactIds.length - a.artifactIds.length).slice(0, 8)
-      .map((m) => ({ label: m.name, value: Math.round((m.artifactIds.length / maxArt) * 100), text: String(m.artifactIds.length) }));
+    if (!this.db) return html`<kf-empty kind="loading" title="正在读取成员…"></kf-empty>`;
+    const members = this.db.members, visible = this._visibleMembers, state = this._state;
+    const groups = [...state.groups, { id: UNGROUPED, name: '未分组' }];
+    const count = (id) => members.filter((m) => this._groupOf(m) === id).length;
+    const disabled = !this._groups || this._busy || this._loading;
     return html`
-      ${head('成员', t(`${db.members.length} 位 · ${db.members.filter((m) => m.done).length} 人已交成果 · ${stuck.length} 人卡住`),
-        stuck.length ? html`<span class="stuck">${icon('alert', 14)} ${t(`卡住:${stuck.map((m) => m.name).join('、')}`)}</span>` : '')}
-      <kf-glass-card surface="2" class="gcard" data-viz="bars">
-        <div class="gh"><h3 class="tb-cap">成员产出对比</h3><span class="n">${t('按成果份数 · 前 8 位')}</span></div>
-        <span class="cmp"><kf-bars stack .rows=${compare}></kf-bars>${compare.length ? html`<b class="cmp-n">${t(`最多 ${compare[0].text} ${'份'}`)}</b>` : ''}</span>
+      ${head('成员', t(`${members.length} 位成员 · ${state.groups.length} 个分组 · ${members.filter((m) => m.done).length} 人已交成果`), html`<button class="button" id="manage-groups" ?disabled=${disabled} @click=${() => this._open()}>${icon('users', 16)}分组管理</button>`)}
+      ${this._groups?.localPreview ? html`<p class="preview">本地预览:分组保存在本机,暂未同步正式看板。</p>` : ''}
+      ${this._error ? html`<div class="message error" role="alert"><span>${this._error}</span><button class="button" ?disabled=${this._busy || this._loading} @click=${() => this._readGroups()}>重新读取分组</button></div>` : ''}
+      ${this._notice && !this._dialog ? html`<p class="message success" role="status">${this._notice}</p>` : ''}
+      <kf-glass-card surface="2" fill-to class="toolbar">
+        <div class="search-row"><label class="search">${icon('search', 16)}<span class="sr">搜索成员</span><input type="search" placeholder="搜索成员姓名或昵称" aria-label="搜索成员" .value=${this._query} @input=${(e) => { this._query = e.target.value; }}></label><span class="hint">${this._loading ? '正在同步分组…' : t(`当前显示 ${visible.length} 位成员`)}</span></div>
+        <div class="filters" role="group" aria-label="按成员分组筛选"><button class=${this._filter === ALL ? 'chip chosen' : 'chip'} aria-pressed=${this._filter === ALL} @click=${() => this._setFilter(ALL)}>全部成员 <b>${members.length}</b></button>${groups.map((g) => html`<button class=${this._filter === g.id ? 'chip chosen' : 'chip'} aria-pressed=${this._filter === g.id} data-group-filter=${g.id} @click=${() => this._setFilter(g.id)}>${g.name}<b>${count(g.id)}</b></button>`)}</div>
       </kf-glass-card>
-      ${[...groups.entries()].sort((a, b) => a[0].localeCompare(b[0], 'zh')).map(([g, list]) => html`
-        <kf-glass-card surface="2" class="gcard">
-          <div class="gh"><h3 class="tb-cap">${g}</h3><span class="n">${t(`${list.length} 人 · ${list.filter((m) => m.done).length} 人已交`)}</span></div>
-          <ul class="list">
-            <li class="head"><span></span><span>成员</span><span>当前阶段</span><span>会话</span><span>成果</span><span>机会卡</span><span>最后活动</span><span>状态</span></li>
-            ${list.map((m) => html`<li data-member=${m.name}>
-              <span class="av">${Array.from(m.name).slice(-1).join('')}</span>
-              <span class="nm"><a href=${href(`/ledger?member=${encodeURIComponent(m.name)}`)} data-link title="在台账里按这个人筛选">${m.name}</a>${m.name === this.me ? html`<span class="me-tag">我</span>` : ''}</span>
-              <span>${m.stageObj ? t(`${m.stageObj.code} ${m.stageObj.name}`) : '未开始'}</span>
-              <span class="num">${m.msgCount}<span class="u">条</span></span>
-              <span class="num">${m.artifactIds.length}<span class="u">份</span></span>
-              <span class="num">${m.oppIds.length}<span class="u">张</span></span>
-              <span title=${fmtDate(m.lastActiveAt, true)}>${fmtAgo(m.lastActiveAt)}</span>
-              <span class=${m.stuck ? 'stuck' : m.done ? 'ok' : ''}>${m.stuck ? t(`卡住:${stuckText(m)}`) : m.done ? '已交' : '进行中'}</span>
-            </li>`)}
-          </ul>
-        </kf-glass-card>`)}
-      ${db.projects.some((p) => p.progressState) ? html`
-        <kf-glass-card surface="2" class="gcard">
-          <div class="gh"><h3 class="tb-cap">项目进展</h3><span class="n">课堂任务进度 · 不代表企业审批</span></div>
-          <ul class="list">
-            <li class="head"><span></span><span>项目</span><span>当前阶段</span><span>负责人</span><span>成果</span><span>方案</span><span>最后活动</span><span>状态</span></li>
-            ${db.projects.filter((p) => p.progressState).map((p, i) => {
-              const report = p.artifactIds.map((id) => db.artifactById[id]).sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
-              const state = p.progressState === 'blocked' ? '卡住' : p.progressState === 'completed' ? '课堂完成' : '进行中';
-              return html`<li data-project=${p.id}>
-                <span class="av">${i + 1}</span>
-                <span class="nm">${report ? html`<a href=${href(`/report/${report.id}`)} data-link title=${p.title}>${p.title}</a>` : p.title}</span>
-                <span>${p.stage ? t(`${p.stage.code} ${p.stage.name} · ${state}`) : `未开始 · ${state}`}</span>
-                <span>${p.ownerName}</span>
-                <span class="num">${p.artifactIds.length}<span class="u">份</span></span>
-                <span title=${p.pendingQuestions.join(';')}>${p.currentConceptId ? '已保存' : '待整理'}</span>
-                <span title=${fmtDate(p.lastActiveAt, true)}>${fmtAgo(p.lastActiveAt)}</span>
-                <span class=${p.progressState === 'blocked' ? 'stuck' : p.progressState === 'completed' ? 'ok' : ''}>${state}</span>
-              </li>`;
-            })}
-          </ul>
-        </kf-glass-card>` : ''}
-    `;
+      ${groups.filter((g) => this._filter === ALL || this._filter === g.id).map((g) => {
+        const list = visible.filter((m) => this._groupOf(m) === g.id);
+        if (this._query.trim() && !list.length) return '';
+        if (!list.length && this._filter === ALL) return '';
+        return html`<kf-glass-card surface="2" fill-to class="gcard" data-member-group=${g.id}>
+          <div class="gh"><h3>${g.name}</h3><span class="hint">${t(`${list.length} 人 · ${list.filter((m) => m.done).length} 人已交成果`)}</span></div>
+          ${list.length ? html`<div class="member-scroll"><ul class="list"><li class="member table-head" aria-hidden="true"><span></span><span>成员</span><span>当前进展</span><span>产出与会话</span><span>最后活动</span></li>${list.map((m) => this._member(m))}</ul></div>` : html`<p class="empty">此分组暂无成员,可通过「分组管理」添加。</p>`}
+        </kf-glass-card>`;
+      })}
+      ${this._query.trim() && !visible.length ? html`<kf-empty title="没有匹配的成员" hint="试试其他姓名、昵称或分组"></kf-empty>` : ''}
+      <details class="comparison"><summary>成员产出对比</summary><kf-glass-card surface="2" fill-to class="gcard"><kf-bars stack .rows=${[...members].sort((a,b) => b.artifactIds.length - a.artifactIds.length).slice(0,8).map((m) => ({ label: m.name, value: Math.round(m.artifactIds.length / Math.max(1,...members.map((x) => x.artifactIds.length)) * 100), text: String(m.artifactIds.length) }))}></kf-bars><p class="hint">按成果份数 · 前 8 位</p></kf-glass-card></details>
+      ${this._projectProgress()}
+      <dialog @cancel=${(e) => { e.preventDefault();this._close(); }} @click=${(e) => { if (e.target === e.currentTarget && (e.offsetX < 0 || e.offsetY < 0 || e.offsetX > e.target.clientWidth || e.offsetY > e.target.clientHeight)) this._close(); }} aria-labelledby="group-dialog-title">
+        ${this._dialog ? this._manager() : ''}
+      </dialog>`;
   }
+  static styles = [VIEW_CSS, css`
+    * { box-sizing: border-box; }
+    button, input { font: inherit; }
+    button, input[type=checkbox] { cursor: pointer; }
+    button:disabled { cursor: default; opacity: .5; }
+    button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--kf-accent); outline-offset: 3px; }
+    input[type=checkbox] { appearance: auto; -webkit-appearance: auto; accent-color: var(--kf-accent); width: 16px; height: 16px; flex: none; margin: 0; }
+    .toolbar, .gcard { display: block; height: auto; --kf-card-pad: 20px; padding: 0; margin-bottom: 24px; }
+    kf-glass-card::part(frame) { height: auto; }
+    .button { display: inline-flex; gap: 6px; align-items: center; justify-content: center; min-height: 38px; padding: 7px 14px; border: 1px solid var(--kf-line); border-radius: var(--r-md); background: var(--kf-surface); color: var(--kf-ink); font-size: 14px; white-space: nowrap; }
+    .primary { background: var(--kf-accent); border-color: var(--kf-accent); color: #fff; }
+    .delete { background: #b33b40; border-color: #b33b40; color: #fff; }
+    .text-button { display: inline-flex; align-items: center; justify-content: center; border: 0; background: transparent; color: var(--kf-accent); font-size: 13px; padding: 7px 8px; border-radius: var(--r-sm); min-height: 34px; white-space: nowrap; }
+    .text-button:hover { background: var(--kf-surface-btn); }
+    .danger, .stuck { color: var(--kf-s, #b33b40); }
+    .ok { color: var(--kf-ok-text); }
+    .hint { font-size: 12px; color: var(--kf-ink-3); line-height: 1.6; }
+    .search-row { display: flex; gap: 14px; align-items: center; justify-content: space-between; }
+    .search { display: flex; gap: 8px; align-items: center; min-width: 0; max-width: 420px; flex: 1; border: 1px solid var(--kf-line); background: var(--kf-surface); padding: 0 12px; border-radius: var(--r-md); color: var(--kf-ink-3); }
+    .search input { min-width: 0; width: 100%; border: 0; background: transparent; outline: none; color: var(--kf-ink); padding: 10px 0; font-size: 14px; }
+    .search:focus-within { border-color: var(--kf-accent); }
+    .filters { display: flex; gap: 8px; flex-wrap: wrap; padding: 16px 0 0; }
+    .chip { display: inline-flex; gap: 8px; align-items: center; border: 1px solid var(--kf-line); border-radius: 20px; padding: 7px 12px; color: var(--kf-ink-2); background: transparent; font-size: 13px; overflow-wrap: anywhere; max-width: 100%; }
+    .chip b { font-size: 12px; opacity: .7; font-weight: 500; }
+    .chip.chosen { background: var(--kf-accent); color: white; border-color: var(--kf-accent); }
+    .gh { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
+    .gh h3 { font-size: 16px; font-weight: 600; color: var(--kf-ink); overflow-wrap: anywhere; }
+    .member-scroll { overflow-x: auto; max-width: 100%; }
+    .list { list-style: none; padding: 0; margin: 0; min-width: 630px; }
+    .member { display: grid; grid-template-columns: 32px minmax(140px,1fr) 130px 175px 90px; gap: 12px; align-items: center; padding: 13px 8px; font-size: 14px; color: var(--kf-ink-2); }
+    .member + .member { border-top: 1px solid var(--kf-line); }
+    .member:hover { background: var(--kf-surface-btn); }
+    .member.table-head { font-size: 12px; color: var(--kf-ink-3); padding-top: 0; padding-bottom: 10px; }
+    .av { width: 30px; height: 30px; flex: none; border-radius: 50%; background: linear-gradient(135deg,var(--kf-s1),var(--kf-s2)); color: white; display: grid; place-items: center; font-size: 13px; font-weight: 600; }
+    .identity { font-weight: 600; overflow-wrap: anywhere; color: var(--kf-ink); }
+    .identity a { text-decoration: none; }
+    .identity a:hover { color: var(--kf-accent); }
+    .employee-caption { display: block; margin-top: 5px; font-size: 11px; line-height: 1.6; font-weight: 400; color: var(--kf-ink-3); }
+    .me-tag { font-size: 11px; padding: 2px 4px; margin-left: 5px; border: 1px solid var(--kf-line); border-radius: 4px; color: var(--kf-accent); }
+    .progress { display: grid; gap: 5px; font-size: 13px; }
+    .progress small { font-size: 12px; color: var(--kf-ink-3); }
+    .progress .stuck { color: var(--kf-s); }
+    .progress .ok { color: var(--kf-ok-text); }
+    .counts { display: flex; gap: 16px; flex-wrap: wrap; font-variant-numeric: tabular-nums; }
+    .counts span { white-space: nowrap; font-size: 14px; }
+    .counts small { display: block; font-size: 11px; color: var(--kf-ink-3); }
+    .active { font-size: 12px; color: var(--kf-ink-3); }
+    .empty { padding: 25px 0; text-align: center; color: var(--kf-ink-3); font-size: 14px; line-height: 1.8; }
+    .message { padding: 10px 14px; border-radius: var(--r-md); display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 16px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
+    .error { color: #a6343b; background: #fff0f0; }
+    .success { color: #24754a; background: #eef8f1; }
+    .preview { font-size: 12px; color: var(--kf-ink-3); margin: 0 0 14px; }
+    .comparison { margin: 20px 0; }
+    .comparison summary { cursor: pointer; padding: 8px 0 14px; font-size: 14px; color: var(--kf-ink-2); }
+    kf-bars { display: block; }
+    .project { display: grid; grid-template-columns: minmax(0,1fr) 100px 80px 90px; gap: 12px; padding: 12px 0; font-size: 14px; color: var(--kf-ink-2); border-top: 1px solid var(--kf-line); }
+    .project span { overflow-wrap: anywhere; }
+    .sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
+    dialog { position: fixed; inset: 0; margin: auto; width: min(1120px,calc(100vw - 48px)); max-height: min(860px,calc(100dvh - 48px)); overflow: auto; overscroll-behavior: contain; border: 1px solid var(--kf-line); border-radius: 20px; padding: 24px; background: var(--kf-surface,#fff); color: var(--kf-ink); box-shadow: var(--kf-shadow-pop); font: inherit; }
+    dialog::backdrop { background: rgba(15,24,45,.32); backdrop-filter: blur(5px); }
+    .dialog-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 20px; }
+    .dialog-head h3 { font-size: 20px; font-weight: 600; margin: 0 0 5px; }
+    .close { color: var(--kf-ink-2); min-width: 34px; }
+    .manager-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
+    .manager-layout { display: grid; grid-template-columns: 160px minmax(0,1fr); gap: 24px; align-items: start; }
+    .group-list { display: flex; flex-direction: column; gap: 5px; max-height: 530px; overflow: auto; padding-right: 12px; border-right: 1px solid var(--kf-line); }
+    .group-tab { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; text-align: left; border: 0; background: transparent; padding: 12px; border-radius: 9px; color: var(--kf-ink-2); font-size: 13px; }
+    .group-tab span { overflow-wrap: anywhere; }
+    .group-tab b { font-size: 12px; font-weight: 500; opacity: .7; }
+    .group-tab:hover { background: var(--kf-surface-btn); }
+    .group-tab.chosen { background: color-mix(in srgb,var(--kf-accent) 9%,transparent); color: var(--kf-accent); font-weight: 600; }
+    .manager-content { display: grid; gap: 24px; min-width: 0; align-items: start; }
+    .manager-content.has-group { grid-template-columns: repeat(2,minmax(0,1fr)); }
+    .group-section, .candidate-section { min-width: 0; }
+    .section-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
+    .section-head h4 { font-size: 15px; font-weight: 600; overflow-wrap: anywhere; margin: 0 0 4px; }
+    .group-actions { display: flex; gap: 4px; margin-left: auto; }
+    .managed-members, .candidates { list-style: none; padding: 0; margin: 0; }
+    .managed-members { max-height: min(440px,max(180px,calc(100dvh - 380px))); overflow: auto; }
+    .managed-members li { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-bottom: 1px solid var(--kf-line-soft); }
+    .managed-identity { display: grid; gap: 4px; flex: 1; min-width: 0; overflow-wrap: anywhere; }
+    .managed-identity strong { font-size: 13px; font-weight: 500; }
+    .managed-identity small { font-size: 11px; color: var(--kf-ink-3); }
+    .remove { margin-left: auto; }
+    .candidate-section { border-left: 1px solid var(--kf-line); padding-left: 24px; }
+    .candidate-tools { display: flex; gap: 12px; justify-content: space-between; align-items: center; padding: 14px 0 8px; }
+    .check { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--kf-ink-2); }
+    .candidates { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 4px 10px; max-height: min(440px,max(180px,calc(100dvh - 380px))); overflow: auto; }
+    .candidate { display: flex; align-items: center; gap: 10px; padding: 10px 8px; font-size: 13px; border-radius: 8px; cursor: pointer; min-height: 50px; }
+    .candidate:hover, .candidate:has(input:checked) { background: var(--kf-surface-btn); }
+    .candidate > span:last-child { min-width: 0; overflow-wrap: anywhere; }
+    .candidate .av { width: 26px; height: 26px; font-size: 12px; }
+    .add-footer { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 18px; }
+    .add-footer .hint { flex: 1; min-width: 110px; overflow-wrap: anywhere; }
+    .compact { padding: 20px 8px; font-size: 13px; }
+    .ungrouped-note { font-size: 13px; line-height: 1.8; color: var(--kf-ink-3); padding: 16px 0; }
+    .editor { display: flex; align-items: flex-end; flex-wrap: wrap; gap: 16px; padding: 16px; margin-bottom: 18px; background: var(--kf-paper); border: 1px solid var(--kf-line); border-radius: 12px; }
+    .field { display: grid; flex: 1; min-width: 160px; gap: 8px; font-size: 13px; }
+    .field input { width: 100%; min-width: 0; padding: 10px 12px; border: 1px solid var(--kf-line); border-radius: var(--r-md); background: var(--kf-surface); color: var(--kf-ink); }
+    .field small { color: var(--kf-ink-3); font-size: 11px; }
+    .editor-actions { display: flex; gap: 8px; margin-left: auto; }
+    .delete-note { flex: 1; min-width: 160px; line-height: 1.8; font-size: 13px; overflow-wrap: anywhere; }
+    .delete-note strong { font-weight: 600; }
+    .delete-note p { margin-top: 6px; color: var(--kf-ink-2); }
+    @media(max-width:1023px) {
+      .manager-layout { grid-template-columns: minmax(0,1fr); gap: 20px; }
+      .group-list { flex-direction: row; padding: 0 0 12px; border-right: 0; border-bottom: 1px solid var(--kf-line); max-height: none; }
+      .group-tab { width: auto; flex: none; padding: 9px 12px; max-width: 240px; }
+      .group-tab span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+      .candidates { grid-template-columns: minmax(0,1fr); }
+    }
+    @media(max-width:767px) {
+      .p-head { align-items: flex-start; flex-wrap: wrap; gap: 12px; }
+      .toolbar,.gcard { --kf-card-pad: 16px; }
+      .search-row { align-items: stretch; flex-direction: column; gap: 8px; }
+      .search { max-width: none; width: 100%; flex: auto; }
+      .button { min-height: 42px; }
+      .text-button { min-height: 40px; }
+      .gh { gap: 6px 10px; }
+      .list { min-width: 0; }
+      .member { grid-template-columns: 32px minmax(0,1fr); gap: 5px 10px; padding: 14px 0; }
+      .member.table-head { display: none; }
+      .av { grid-column: 1; grid-row: 1; }
+      .identity { grid-column: 2; grid-row: 1; min-height: 30px; }
+      .progress { grid-column: 2; display: flex; gap: 8px; flex-wrap: wrap; margin: 3px 0; }
+      .counts { grid-column: 2; gap: 15px; margin: 4px 0; }
+      .counts small { display: inline; margin-left: 3px; }
+      .active { display: none; }
+      .member-scroll { overflow: visible; }
+      .message { align-items: flex-start; flex-direction: column; }
+      .project { grid-template-columns: minmax(0,1fr) 70px; gap: 8px; }
+      .project span:first-child { grid-column: 1/-1; }
+      .chip { min-height: 38px; }
+      dialog { width: calc(100vw - 24px); max-height: calc(100dvh - 32px); padding: 18px; border-radius: 16px; }
+      .dialog-head { margin-bottom: 16px; }
+      .dialog-head h3 { font-size: 18px; }
+      .manager-toolbar { gap: 10px; margin-bottom: 16px; }
+      .manager-content.has-group { grid-template-columns: minmax(0,1fr); }
+      .candidate-section { border-left: 0; padding-left: 0; border-top: 1px solid var(--kf-line); padding-top: 20px; }
+      .candidates { max-height: 210px; }
+      .add-footer { align-items: stretch; }
+      .add-footer .button { width: 100%; }
+      .editor { padding: 12px; }
+      .editor-actions { flex-wrap: wrap; }
+      .section-head { gap: 5px; }
+      .group-actions { gap: 0; }
+      .managed-members li { gap: 8px; }
+      .managed-members { max-height: 180px; }
+      .managed-members .av { display: none; }
+    }
+  `];
 }
-
 if (!customElements.get('view-members')) customElements.define('view-members', ViewMembers);

+ 7 - 4
src/pages/view-pool.js

@@ -157,7 +157,9 @@ export class ViewPool extends LitElement {
   }
 
   get #criteria() {
-    return criteriaOf(this.routeQuery || {});
+    const c = criteriaOf(this.routeQuery || {});
+    if (!SORTS.some(s => s.key === c.sort)) c.sort = '';
+    return c;
   }
 
   #apply(c, { sort } = {}) {
@@ -182,8 +184,9 @@ export class ViewPool extends LitElement {
     this.#apply(c);
   }
 
-  #changeStatus(e, id) {
-    this.dispatchEvent(new CustomEvent('status-change', { detail: { id, status: e.detail.status }, bubbles: true, composed: true }));
+  #changeStatus(e) {
+    e.stopPropagation();
+    this.dispatchEvent(new CustomEvent('status-change', { detail: e.detail, bubbles: true, composed: true }));
   }
 
   render() {
@@ -245,7 +248,7 @@ export class ViewPool extends LitElement {
 
       ${filtered.length ? html`<div class="cards kf-enter" data-list-ids=${filtered.map((o) => o.id).join(',')}>
         ${filtered.map((o) => html`<kf-opportunity-card key=${o.id} class=${o.id === this.focusId ? 'is-focus' : ''} .opp=${o} base="/pool"
-          @card-open=${(e) => this.dispatchEvent(new CustomEvent('card-open', { detail: e.detail, bubbles: true, composed: true }))}
+          @card-open=${(e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent('card-open', { detail: e.detail, bubbles: true, composed: true })); }}
           @status-change=${this.#changeStatus}></kf-opportunity-card>`)}
       </div>` : html`<kf-glass-card surface="2"><kf-empty title="没有符合条件的卡" hint="试试点上面的「清除全部条件」,或换一个排序看看。" action-label="清除全部条件" @empty-action=${() => navigate('/pool', { replace: true })}></kf-empty></kf-glass-card>`}
       ${/* 筛选栏已收起页脚(排序行末尾:本页条数 + 回到顶部),卡片流之后不再重复 */ ''}

+ 16 - 45
src/pages/view-report-type.js

@@ -15,6 +15,7 @@ import { useTokens } from '../components/base.js';
 import { navigate, setQuery, href } from '../utils/router.js';
 import { readCardQuery, patchOf, applyCardTool, paginate } from '../utils/card-view.js';
 import { reportTypeOf, REPORT_TYPES, entriesForType, hasEvidence, cardMatches, cardFacetCounts, sortCardEntries } from '../core/index.js';
+import { reportPager, PAGER_CSS } from './report-pager.js';
 import { reportCard, reportRow, cardFooter, CARD_CSS } from './report-card.js';
 import { cardToolbar, makeToolHandler, TOOLBAR_CSS } from './card-toolbar.js';
 import { t, VIEW_CSS, section } from './shared.js';
@@ -43,9 +44,9 @@ export class ViewReportType extends LitElement {
     useTokens(this);
   }
 
-  static styles = [VIEW_CSS, CARD_CSS, TOOLBAR_CSS, css`
+  static styles = [VIEW_CSS, CARD_CSS, TOOLBAR_CSS, PAGER_CSS, css`
     /* 顶栏里的类型切换:小 chip,当前项反白实底;横向可滑(顶栏右端) */
-    .tabs { display: flex; gap: 6px; flex-wrap: nowrap; margin: 0; max-width: min(52vw, 640px); overflow-x: auto; scrollbar-width: none; }
+    .tabs { display: flex; gap: 6px; flex-wrap: nowrap; margin: 0; max-width: 100%; overflow-x: auto; scrollbar-width: none; }
     .tabs::-webkit-scrollbar { display: none; }
     /* 类型切换 = 点缀层 chip(.55 + blur 12);当前项反白实底 */
     .tabs a { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: var(--r-sm); text-decoration: none;
@@ -61,7 +62,7 @@ export class ViewReportType extends LitElement {
     .head .ico { width: 40px; height: 40px; flex: none; border-radius: var(--r-md); display: grid; place-items: center;
       background: var(--kf-accent-soft); color: var(--kf-accent-ink); }
     .head .ico svg { stroke: var(--kf-accent-ink); }
-    .head h2 { margin: 0; font-size: 20px; line-height: 1.3; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.02em; text-box-trim: trim-both; text-box-edge: cap alphabetic; }
+    .head h2 { margin: 0; font-size: 20px; line-height: 1.5; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.02em; text-box-trim: none; text-box-edge: auto; }
     .kpis { margin-left: auto; display: flex; gap: 8px; }
     /* 数据卡 = 内容卡同一层玻璃(v11 板块六:.72 + 1px 描边走阴影 + blur(8px)) */
     .kpi { min-width: 112px; padding: 8px 12px; border-radius: var(--kf-radius-sm); display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; gap: 6px 10px; align-items: end;
@@ -88,20 +89,12 @@ export class ViewReportType extends LitElement {
     .kpi .stage-dots i.on { background: var(--kf-accent); }
     .kpi .tr { align-self: center; height: 4px; border-radius: 2px; background: rgba(16,24,40,0.07); overflow: hidden; margin-top: 6px; }
     .kpi .tr i { display: block; height: 100%; border-radius: 2px; background: var(--kf-accent); }
-    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--kf-space-in); margin-top: var(--kf-space-in); }
+    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr)); gap: var(--kf-space-in); margin-top: var(--kf-space-in); }
     .cards > kf-glass-card { cursor: default; }
     .listwrap { overflow: hidden; }
+    @media (max-width: 1100px) { .listhead { display: none !important; } }
     .listhead { display: grid; grid-template-columns: 48px minmax(0, 1.5fr) 96px 88px minmax(0, 1fr) 80px; gap: 12px; padding: 8px 12px;
       font-size: 12px; line-height: 1.3; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-weight: 500; border-bottom: 1px solid var(--kf-line); }
-    .pager { display: flex; align-items: center; justify-content: center; gap: 4px; margin: 24px 0 8px; flex-wrap: wrap; }
-    .pager button { all: unset; cursor: pointer; min-width: 32px; height: 32px; padding: 0 8px; display: grid; place-items: center; border-radius: var(--r-sm);
-      border: 1px solid transparent;
-      background: var(--kf-surface);
-      font: 600 14px var(--kf-font-num); color: var(--kf-ink-2); font-variant-numeric: tabular-nums; transition: var(--kf-t); }
-    .pager button:hover { background: var(--kf-surface-btn); }
-    .pager button[aria-current="page"] { background: var(--kf-ink); color: #fff; border-color: var(--kf-ink); }
-    .pager button[disabled] { opacity: 0.35; cursor: default; }
-    .pager .sz { margin-left: 8px; font: 500 12px var(--kf-font-num); color: var(--kf-ink-3); font-variant-numeric: tabular-nums; }
     @media (max-width: 1100px) { .kpis { flex-wrap: wrap; } .kpi { min-width: 88px; } }
     @media (min-width: 768px) and (max-width: 1023px) { .cards { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
 
@@ -122,6 +115,8 @@ export class ViewReportType extends LitElement {
         -webkit-backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%); box-shadow: none; }
       .kpi { min-width: 0; padding: 0 4px 0 12px; display: grid; grid-template-columns: 1fr; gap: 0; border-left: 1px solid var(--kf-line); border-radius: 0; background: none; border: 0; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; }
       .kpi:first-child { border-left: 0; }
+      .kpi { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto; grid-template-areas: "b" "l"; }
+      .brow2 { flex-wrap: wrap; }
       .kpi .ic { display: none; }
       .kpi b { font-size: 20px; font-weight: 600; line-height: 1.1; letter-spacing: -0.02em; }
       .kpi b small { font-size: 12px; font-weight: 500; }
@@ -134,20 +129,7 @@ export class ViewReportType extends LitElement {
       .cards:where(.cards) { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
       .cards > kf-glass-card { width: 100%; }
 
-      /* 紧凑列表:手机上收成两行(阶段 + 标题 / 引用·分 + 人 + 时间),不横滚 */
-      .listwrap { border-radius: var(--r-md); }
-      .listhead { display: none; }
-      .row { grid-template-columns: 40px minmax(0, 1fr) auto; grid-template-areas: "s t x" "s w w"; row-gap: 2px; column-gap: 8px; padding: 12px 14px; min-height: 64px; font-size: 14px; box-sizing: border-box; }
-      .row > :nth-child(1) { grid-area: s; align-self: start; }
-      .row > :nth-child(2) { grid-area: t; }
-      .row > :nth-child(3) { display: none; }
-      .row > :nth-child(4) { grid-area: x; text-align: right; font-size: 14px; }
-      .row > :nth-child(5) { grid-area: w; font-size: 14px; color: var(--kf-ink-3); }
-      .row > :nth-child(6) { display: none; }
       .row:active { background: rgba(18, 32, 64, 0.06); transform: scale(0.985); transition: transform 80ms cubic-bezier(0.4, 0, 0.2, 1); }
-      .pager button { min-width: 44px; height: 44px; font-size: 14px; }
-      .pager { gap: 6px; }
-      .pager .sz { font-size: 14px; }
     }
   `];
 
@@ -163,14 +145,15 @@ export class ViewReportType extends LitElement {
     this.#write(q);
   }
 
-  onCardPage(p) { this.#write(this.#q(), p); }
+  onCardPage(p) { this.#write(this.#q(), p); this.updateComplete.then(() => this.renderRoot.querySelector('[data-toolbar]')?.scrollIntoView({ block: 'start' })); }
 
   /** 手机「筛选」按钮:打开本页的底部弹层 */
   openSheet() { this.renderRoot.querySelector('kf-filter-sheet').open = true; }
 
   #open(e) {
     if (e.kind === 'opportunity') { this.dispatchEvent(new CustomEvent('opportunity-open', { detail: { id: e.id }, bubbles: true, composed: true })); return; }
-    if (e.href) { navigate(e.href); return; }
+    if (e.kind === 'source') { this.dispatchEvent(new CustomEvent('source-open', { detail: { id: e.id }, bubbles: true, composed: true })); return; }
+    if (e.href) { this.dispatchEvent(new CustomEvent('report-open', { detail: { id: e.id }, bubbles: true, composed: true })); return; }
     this.dispatchEvent(new CustomEvent('artifact-open', { detail: { id: e.id }, bubbles: true, composed: true }));
   }
 
@@ -199,14 +182,11 @@ export class ViewReportType extends LitElement {
     const test = (e) => cardMatches(e, { ...q, who: q.who, mine: false }) && (!q.mine || e.owner === this.me);
     const matched = sortCardEntries(all.filter(test), q.sort);
     const pg = paginate(matched, q.page);
-    const needPager = matched.length > 12;
     const recent = (e) => e.at && Date.now() - Date.parse(e.at) <= 7 * 86400000;
 
     return html`
-      <!-- 类型切换进顶栏(v11 3.4:页面顶 → 首个区块 16px,不再先占一行 tab 再占一行标题);
-           slot 进 <kf-topbar> 的 actions,DOM 仍在视图里(链接与横向滑动逻辑不变) -->
-      <nav slot="actions" class="tabs" aria-label="报告类型" ${ref((el) => { this._tabsEl = el; })}>
-        ${REPORT_TYPES.map((t) => html`<a class="tab ${t.key === type.key ? 'cur' : ''}" href=${href(`/reports/${t.key}`)} data-link ?aria-current=${t.key === type.key ? 'page' : 'false'}>${t.name}<span class="n">${counts[t.key]}</span></a>`)}
+      <nav class="tabs" aria-label="报告类型" ${ref((el) => { this._tabsEl = el; })}>
+        ${REPORT_TYPES.map((t) => html`<a class="tab ${t.key === type.key ? 'cur' : ''}" href=${href(`/reports/${t.key}`)} data-link aria-current=${t.key === type.key ? 'page' : 'false'}>${t.name}<span class="n">${counts[t.key]}</span></a>`)}
       </nav>
 
       <div class="head">
@@ -230,8 +210,8 @@ export class ViewReportType extends LitElement {
           : html`<div class="cards" data-cards>
               ${pg.items.map((e) => html`<kf-glass-card surface="2" lift @card-activate=${() => this.#open(e)}>${reportCard({ e, recent: recent(e), fit: true })}</kf-glass-card>`)}
             </div>`}
-      ${/* v12 P2 (b):底部收尾 —— 条数 + 回筛选栏 + 回顶部(试用版不分页,页码是死的,不铺分页条) */ ''}
-      ${cardFooter({ shown: pg.items.length, all: all.length, filtered: matched.length !== all.length, label: '条成果',
+      ${reportPager(pg, p => this.onCardPage(p))}
+      ${cardFooter({ shown: pg.items.length, all: matched.length, filtered: matched.length !== all.length, label: '条成果',
         onFilter: () => this.renderRoot.querySelector('[data-toolbar]')?.scrollIntoView({ behavior: 'smooth', block: 'center' }),
         onTop: () => window.scrollTo({ top: 0, behavior: 'smooth' }) })}
     `;
@@ -270,16 +250,7 @@ export class ViewReportType extends LitElement {
     return { category: '还没有品类分析', competitor: '还没有竞品分析', voc: '还没有用户洞察', pain: '还没有痛点清单', concept: '还没有概念提案' }[key] || '还空着';
   }
 
-  #pager(pg) {
-    const nums = [];
-    for (let i = 1; i <= pg.pages; i++) nums.push(i);
-    return html`<div class="pager" data-pager role="navigation" aria-label="分页">
-      <button data-page=${pg.page - 1} ?disabled=${pg.page === 1} @click=${() => this.onCardPage(pg.page - 1)} aria-label="上一页">${icon('left', 14)}</button>
-      ${nums.map((n) => html`<button data-page=${n} aria-current=${n === pg.page ? 'page' : 'false'} @click=${() => this.onCardPage(n)}>${n}</button>`)}
-      <button data-page=${pg.page + 1} ?disabled=${pg.page === pg.pages} @click=${() => this.onCardPage(pg.page + 1)} aria-label="下一页">${icon('right', 14)}</button>
-      <span class="sz">${pg.page}/${pg.pages} · ${pg.total}</span>
-    </div>`;
-  }
+
 }
 
 if (!customElements.get('view-report-type')) customElements.define('view-report-type', ViewReportType);

+ 116 - 41
src/pages/view-report.js

@@ -13,7 +13,7 @@
 import { LitElement, css, html } from 'lit';
 import { useTokens } from '../components/base.js';
 import { reportOverview, findingsOf, fmtDate, chapterDigests, TYPE_FOR_ARTIFACT, reportTypeOf } from '../core/index.js';
-import { navigate, href } from '../utils/router.js';
+import { navigate, href, safeReturnTo } from '../utils/router.js';
 import { t, VIEW_CSS } from './shared.js';
 import { icon } from '../components/icons.js';
 
@@ -26,8 +26,12 @@ export class ViewReport extends LitElement {
   };
 
   #r = null;
-  #restored = false;
   #type = null;
+  #resize = null;
+  #chapterFrame = null;
+  #smoothJump = false;
+  #lastChapterTarget = '';
+  #chapterVersion = 0;
 
   constructor() {
     super();
@@ -45,12 +49,63 @@ export class ViewReport extends LitElement {
   disconnectedCallback() {
     super.disconnectedCallback();
     this.#io?.disconnect();
+    this.#resize?.disconnect();
+    cancelAnimationFrame(this.#chapterFrame);
+    this.#chapterVersion++;
   }
 
   #io = null;
 
-  updated() {
-    if (!this.db || this.#io) return;
+  willUpdate(changed) {
+    if (changed.has('artifactId')) this._active = '';
+  }
+
+  updated(changed) {
+    if (!this.db) return;
+    if (!this.#resize && typeof ResizeObserver !== 'undefined') {
+      this.#resize = new ResizeObserver(() => this.#measureOffsets());
+      const toc = this.renderRoot.querySelector('[data-toc]');
+      if (toc) this.#resize.observe(toc);
+    }
+    if (changed.has('artifactId') || changed.has('db')) this.#measureOffsets();
+    const chapter = String(this.routeQuery?.ch || '').replace(/^ch-/, '');
+    const target = chapter ? `${this.artifactId}:${chapter}` : '';
+    if (chapter && target !== this.#lastChapterTarget) {
+      const smooth = this.#smoothJump;
+      this.#smoothJump = false;
+      this.#restoreChapter(`ch-${chapter}`, smooth ? 'smooth' : 'instant');
+    } else if (target !== this.#lastChapterTarget) {
+      this.#chapterVersion++;
+      cancelAnimationFrame(this.#chapterFrame);
+    }
+    this.#lastChapterTarget = target;
+  }
+
+  #measureOffsets() {
+    const top = window.matchMedia('(max-width: 767px)').matches ? 0 : 12;
+    const tocHeight = Math.ceil(this.renderRoot.querySelector('[data-toc]')?.getBoundingClientRect().height || 44);
+    this.style.setProperty('--report-topbar', `${top}px`);
+    this.style.setProperty('--report-anchor', `${top + tocHeight + 16}px`);
+    this.#observeChapters(top + tocHeight + 8);
+  }
+
+  async #restoreChapter(id, behavior) {
+    const version = ++this.#chapterVersion;
+    // 卡片的 Shadow DOM 和字体都会改变章节位置,等待实际布局稳定后再恢复锚点。
+    await Promise.all([...this.renderRoot.querySelectorAll('*')].map(el => el.updateComplete));
+    if (document.fonts) await document.fonts.ready;
+    if (!this.isConnected || version !== this.#chapterVersion) return;
+    cancelAnimationFrame(this.#chapterFrame);
+    this.#chapterFrame = requestAnimationFrame(() => {
+      this.#measureOffsets();
+      this.#chapterFrame = requestAnimationFrame(() => {
+        if (version === this.#chapterVersion) this.#scrollChapter(id, behavior);
+      });
+    });
+  }
+
+  #observeChapters(offset) {
+    this.#io?.disconnect();
     // 滚动高亮是增强能力:环境不支持 IntersectionObserver 时整页照常可读,只是不自动高亮
     if (typeof IntersectionObserver === 'undefined') return;
     const root = this.renderRoot;
@@ -58,18 +113,18 @@ export class ViewReport extends LitElement {
     this.#io = new IntersectionObserver((entries) => {
       const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
       if (visible[0]) this._active = visible[0].target.id;
-    }, { rootMargin: '-96px 0px -60% 0px', threshold: 0 });
+    }, { rootMargin: `-${offset}px 0px -45% 0px`, threshold: 0 });
     chapters.forEach((c) => this.#io.observe(c));
   }
 
   static styles = [VIEW_CSS, css`
     /* kf-glass-card 的插槽内容必须包一层块容器:自定义元素宿主是 display:block,直接排多个块级子元素会和卡片的行盒互相叠 */
-    .cover { padding: 24px; }
+    .cover { --kf-card-pad: 24px; height: auto; margin-bottom: 20px; }
     .cover-in { display: grid; gap: 16px; min-width: 0; }
     .cover:has(.dgrid) .cover-in { gap: 12px; }
     .rail .r-in { display: grid; gap: 8px; min-width: 0; }
     .cover .top { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
-    .cover h1 { margin: 4px 0 0; font-size: 20px; line-height: 1.3; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; }
+    .cover h1 { margin: 4px 0 0; font-size: 26px; line-height: 1.45; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; overflow-wrap: anywhere; }
     .cover .sub { font-size: 14px; line-height: 1.6; color: var(--kf-ink-2); }
     .cover .meta { font-size: 12px; line-height: 1.5; color: var(--kf-ink-3); display: flex; gap: 16px; flex-wrap: wrap; font-variant-numeric: tabular-nums; }
     .cover .pipe { padding: 16px 0 0; border-top: 1px solid var(--kf-line); }
@@ -77,17 +132,18 @@ export class ViewReport extends LitElement {
     .exec li { display: grid; grid-template-columns: 34px 1fr; gap: 10px; font-size: 14px; color: var(--kf-ink); line-height: 1.6; }
     .exec li::before { counter-increment: e; content: counter(e, decimal-leading-zero); font-family: var(--kf-font-num); font-size: 12px; color: var(--kf-accent-ink); padding-top: 4px; }
     /* 章节目录 = 上层玻璃(.72 + blur 32),和正文卡片拉开层次 */
-    .toc { position: sticky; top: 72px; z-index: 6; display: flex; gap: 4px; overflow-x: auto; padding: 6px; margin: 0 0 16px;
+    .toc[data-toc] { position: sticky; top: var(--report-topbar, 12px); z-index: 3; display: flex; gap: 4px; overflow-x: auto; padding: 6px; margin: 0 0 20px;
       border-radius: var(--kf-radius); background: var(--kf-surface); border: 1px solid var(--kf-line-soft); }
-    .dgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; margin-top: 8px; }
+    .dgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); gap: 10px; }
     a.d { display: grid; grid-template-columns: 32px minmax(0,1fr); gap: 2px 8px; padding: 12px; border-radius: var(--r-sm); text-decoration: none;
       background: rgba(255,255,255,0.4); border: 1px solid var(--kf-line); color: inherit; transition: var(--kf-t); }
     a.d:hover { background: rgba(255,255,255,0.7); box-shadow: var(--kf-shadow-chip); }
     a.d .dn { font: 600 12px/1.4 var(--kf-font-num); color: var(--kf-accent-ink); grid-row: span 2; padding-top: 2px; font-variant-numeric: tabular-nums; }
     a.d .dt { font-size: 14px; font-weight: 600; color: var(--kf-ink); }
     a.d .ds { font-size: 12px; color: var(--kf-ink-2); grid-column: 2; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
-    .toc a.jump, aside.rail a.jump { flex: none; font-size: 14px; line-height: 1; color: var(--kf-ink-2); text-decoration: none;
-      padding: 6px 10px; border-radius: var(--r-sm); display: inline-block; transition: background var(--kf-dur) var(--kf-ease); }
+    .toc a.jump, aside.rail a.jump { flex: none; font-size: 14px; color: var(--kf-ink-2); text-decoration: none;
+      padding: 10px 12px; border-radius: var(--r-sm); display: inline-block; line-height: 1.4; transition: background var(--kf-dur) var(--kf-ease); }
+    .toc a.jump { white-space: nowrap; }
     .toc a.jump:hover, aside.rail a.jump:hover { background: rgba(255,255,255,0.6); color: var(--kf-ink); }
     .toc a.jump[aria-current="true"] { background: var(--kf-ink); color: #fff; }
     aside.rail ol li + li { margin-top: 2px; }
@@ -97,8 +153,10 @@ export class ViewReport extends LitElement {
     /* 视图宿主与列容器显式 block:否则自定义元素被排成行盒,块级子元素互相叠、互相撑高 */
     :host { display: block; }
     .col, .main-col { display: block; min-width: 0; }
-    article.ch { padding: var(--kf-space-card) var(--kf-space-card-gap); margin-bottom: var(--kf-space-card); scroll-margin-top: 144px; }
-    .ch-h { display: flex; gap: 12px; align-items: baseline; }
+    article.ch { padding: 24px; margin-bottom: 20px; scroll-margin-top: var(--report-anchor, 84px); min-width: 0; box-sizing: border-box; overflow-wrap: anywhere; }
+    .ch-h { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 12px; align-items: baseline; }
+    .ch-h .en { grid-column: 1 / -1; }
+    .ch-h .en:empty { display: none; }
     .ch-no { font-family: var(--kf-font-num); font-size: 12px; line-height: 20px; color: var(--kf-accent-ink); border: 1px solid rgba(91, 91, 214, 0.30); border-radius: var(--r-sm); padding: 0 6px; font-variant-numeric: tabular-nums; }
     .ch-h h2 { margin: 0; font-size: 20px; line-height: 1.3; font-weight: 600; color: var(--kf-ink); letter-spacing: -0.01em; }
     .en { font-size: 12px; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-weight: 600; text-transform: uppercase; }
@@ -130,13 +188,18 @@ export class ViewReport extends LitElement {
     .anchor { display: inline-flex; gap: 4px; align-items: center; height: 26px; padding: 0 10px; border-radius: var(--r-sm); border: 1px solid rgba(91, 91, 214, 0.22);
       background: rgba(91, 91, 214, 0.10); color: var(--kf-accent-ink); font: 600 12px/1 var(--kf-font); cursor: pointer; transition: var(--kf-t); }
     .anchor:hover { background: rgba(91, 91, 214, 0.10); border-color: rgba(91, 91, 214, 0.45); }
-    .two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+    .two { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); gap: 12px; }
     .two > div { padding: 12px; border-radius: var(--r-sm); border: 1px solid var(--kf-line); font-size: 14px; line-height: 1.6; }
     .two .yes { background: rgba(31,107,69,0.06); border-color: rgba(31,107,69,0.25); }
     .two .no { background: rgba(164,36,28,0.05); border-color: rgba(164,36,28,0.22); }
     .two ul { margin: 6px 0 0; padding-left: 18px; }
-    aside.rail { position: sticky; top: 128px; display: grid; gap: 12px; }
-    .rail .r { padding: 16px; }
+    aside.rail { position: sticky; top: var(--report-anchor, 84px); display: grid; gap: 12px; min-width: 0; }
+    .rail .r { --kf-card-pad: 16px; height: auto; }
+    .reading-nav { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 20px; margin: 0 0 16px; font-size: 13px; }
+    .reading-nav .report-back { margin-right: auto; font-weight: 600; }
+    .reading-nav a { text-decoration: none; padding: 6px 0; color: var(--kf-ink-2); }
+    .reading-nav a:hover { color: var(--kf-accent-ink); }
+    .section-label { font-size: 14px; font-weight: 600; color: var(--kf-ink); }
     .cover.digest .kf-kicker { color: var(--kf-ink-3); }
     .rail .r .k { font-size: 12px; color: var(--kf-ink-3); letter-spacing: var(--ls-cn-small); font-weight: 600; }
     .rail ol { margin: 6px 0 0; padding-left: 18px; font-size: 14px; color: var(--kf-ink-2); display: grid; gap: 4px; }
@@ -149,12 +212,18 @@ export class ViewReport extends LitElement {
     .rail .stat b { color: var(--kf-ink); font-variant-numeric: tabular-nums; }
     .muted2 { font-size: 12px; color: var(--kf-ink-3); }
     @media (max-width: 1100px) { .layout { grid-template-columns: 1fr; } aside.rail { position: static; } }
-    /* 手机(任务书 1.3 / 2.4):阅读稿单栏;章节导航吸在顶栏下、左右通到屏幕边;封面标题 26→20;来源收成两行 */
+    /* 手机(任务书 1.3 / 2.4):阅读稿单栏;章节导航吸在视口上方、左右通到屏幕边;封面标题 26→20;来源收成两行 */
     @media (max-width: 767px) {
-      .toc { top: calc(80px + env(safe-area-inset-top, 0px)); margin: 0 -16px 12px; border-radius: 0 0 var(--r-md) var(--r-md); padding: 6px 12px; scrollbar-width: none; }
+      .toc[data-toc] { margin: 0 0 16px; padding: 4px; scrollbar-width: none; }
       .toc::-webkit-scrollbar { display: none; }
       .toc a.jump { font-size: 14px; padding: 10px 12px; }
-      .cover { padding: 16px; }
+      .cover { --kf-card-pad: 16px; margin-bottom: 16px; }
+      article.ch { padding: 18px 16px; margin-bottom: 16px; }
+      .ch-h h2 { font-size: 18px; }
+      .reading-nav { gap: 4px 16px; }
+      .reading-nav .report-back { flex: 1 1 100%; }
+      .funnel li { grid-template-columns: 36px minmax(0, 1fr); }
+      .funnel li > :last-child { grid-column: 2; }
       .cover .top { row-gap: 6px; }
       .cover .top > span:last-child { margin-left: 0 !important; width: 100%; justify-content: flex-start; }
       /* 类型名已在顶栏下方标题区,封面不再重复(任务书 0.5:同一信息在 tab / 标题 / 数据卡各出现一次) */
@@ -170,12 +239,19 @@ export class ViewReport extends LitElement {
   #jump(id) {
     // 应用内路径 + 相对章节 id(ch3 而不是 ch-ch3)
     const key = id.replace(/^ch-/, '');
-    const el = this.renderRoot.querySelector(`#${CSS.escape(id)}`);
-    el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    if (String(this.routeQuery?.ch || '').replace(/^ch-/, '') === key) return this.#scrollChapter(id, 'smooth');
+    this.#smoothJump = true;
     // 当前章写回 URL(?ch=3),刷新时还原到同一章;只改查询串,不新增历史
     const q = new URLSearchParams(this.routeQuery || {});
     q.set('ch', key);
-    navigate(`/report/${this.artifactId}?${q.toString()}`, { replace: true });
+    navigate(`/report/${encodeURIComponent(this.artifactId)}?${q.toString()}`, { replace: true });
+  }
+
+  #scrollChapter(id, behavior) {
+    const el = this.renderRoot.querySelector(`#${CSS.escape(id)}`);
+    if (!el) return;
+    this._active = id;
+    el.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : behavior, block: 'start' });
   }
 
   get typeInfo() { return this.#type; }
@@ -209,14 +285,9 @@ export class ViewReport extends LitElement {
     if (!db) return html`<kf-empty kind="loading" ></kf-empty>`;
     const r = reportOverview(db, this.artifactId);
     this.#r = r;
-    const chParam = String(this.routeQuery?.ch || '');
-    if (chParam && !this.#restored) {
-      this.#restored = true;
-      requestAnimationFrame(() => this.renderRoot.querySelector(`#${CSS.escape(chParam.startsWith('ch-') ? chParam : `ch-${chParam}`)}`)?.scrollIntoView({ block: 'start' }));
-    }
     if (!r) {
       return html`<kf-empty kind="error" title="这份报告不存在或没有正文" hint=${`成果 ${this.artifactId} 在库里找不到 report 字段。`}
-        action-label="返回" @empty-action=${() => navigate('/report')}></kf-empty>`;
+        action-label="返回" @empty-action=${() => navigate(safeReturnTo(this.routeQuery?.returnTo, '/reports'))}></kf-empty>`;
     }
     const findings = findingsOf(db, this.artifactId);
     const report = r.artifact.report;
@@ -227,22 +298,20 @@ export class ViewReport extends LitElement {
       r.executive = digests.slice(0, 3).map((d) => `〔${d.title}〕${d.summary || ''}`);
     }
     const active = this._active || (this.routeQuery?.ch ? `ch-${String(this.routeQuery.ch).replace(/^ch-/, '')}` : '');
+    const back = safeReturnTo(this.routeQuery?.returnTo, '/reports');
     return html`
-      <div class="toc kf-glass is-top is-overlay" aria-label="章节导航" data-toc>
-        ${r.chapters.map((c) => html`<a class="jump" href=${`#ch-${c.id}`} data-jump=${c.id} aria-current=${active === `ch-${c.id}` ? 'true' : 'false'}
-          @click=${(e) => { e.preventDefault(); this.#jump(`ch-${c.id}`); }}>${c.no}. ${c.title}</a>`)}
-      </div>
-
+      <nav class="reading-nav" aria-label="报告导航">
+        <a class="report-back" href=${href(back)} data-link data-back>${back.startsWith('/pool/') ? '← 返回机会卡' : '← 返回列表'}</a>
+        ${this.typeInfo ? html`<a href=${href('/reports/' + this.typeInfo.key)} data-link>${this.typeInfo.name}</a>` : ''}
+        <a href=${href('/reports')} data-link>报告中心</a>
+      </nav>
       <kf-glass-card surface="2" class="cover"><div class="cover-in">
         <div class="top">
           <kf-badge kind="stage" .value=${r.stages[0] || '—'} label=${r.stages.join(' · ') || '跨阶段'}></kf-badge>
           <kf-badge kind="plain" .value=${`${r.artifact.ownerName || '无出身'} · ${r.artifact.upload.methodLabel || '方式未记录'}`}></kf-badge>
           ${r.artifact.upload.agentName ? html`<kf-badge kind="method" .value=${r.artifact.upload.agentName}></kf-badge>` : ''}
           ${this.typeInfo ? html`<kf-badge class="typeb" kind="method" .value=${this.typeInfo.name} label=${'类型:' + this.typeInfo.name}></kf-badge>` : ''}
-          <span style="margin-left:auto;display:flex;gap:10px;align-items:center">
-            ${this.typeInfo ? html`<a class="lnk" style="font-size: 12px" href=${href('/reports/' + this.typeInfo.key)} data-link>← ${this.typeInfo.name}</a>` : ''}
-            <a class="lnk" style="font-size: 12px" href=${href('/reports')} data-link>报告中心</a>
-          </span>
+
         </div>
         <div>
           <h1>${r.title}</h1>
@@ -254,26 +323,32 @@ export class ViewReport extends LitElement {
         ${r.pipeline ? html`<div class="pipe"><kf-pipeline .steps=${r.pipeline.steps} .rate=${r.pipeline.filterRate}></kf-pipeline></div>` : ''}
       </div></kf-glass-card>
 
+      <div class="toc kf-glass is-top is-overlay" aria-label="章节导航" data-toc>
+        ${r.chapters.map((c) => html`<a class="jump" href=${`#ch-${c.id}`} data-jump=${c.id} aria-current=${active === `ch-${c.id}` ? 'true' : 'false'}
+          @click=${(e) => { e.preventDefault(); this.#jump(`ch-${c.id}`); }}>${c.no}. ${c.title}</a>`)}
+      </div>
+
       <div class="main-col">
       <div class="layout">
         <div class="col">
           ${r.executive.length ? html`<kf-glass-card surface="2" class="cover exec-card" style="margin-bottom:16px" data-exec><div class="cover-in">
-            <div class="kf-kicker">${report.executive?.length ? '' : '摘要'}</div>
+            <div class="kf-kicker">${report.executive?.length ? '报告摘要' : '章节摘要'}</div>
             <ol class="exec" data-exec-list>${r.executive.map((x, i) => html`<li data-exec-item=${String(i + 1)}>${x}</li>`)}</ol>
           </div></kf-glass-card>` : ''}
 
           <kf-glass-card surface="1" class="cover digest" style="margin-bottom:16px" data-digest><div class="cover-in">
-            <div class="dgrid">${digests.map((d) => this.#digest(d))}</div>
+            <div class="section-label">章节导读</div><div class="dgrid">${digests.map((d) => this.#digest(d))}</div>
           </div></kf-glass-card>
           ${r.chapters.map((c) => html`<article class="ch kf-card" id="ch-${c.id}" data-chapter=${c.id}>
               <div class="ch-h"><span class="en">${c.en}</span><span class="ch-no">${c.no}</span><h2>${c.title}</h2></div>
               ${c.subtitle ? html`<div class="muted2">${c.subtitle}</div>` : ''}
-              ${c.lead ? html`<div class="lead">${String(c.lead).length > 160 ? String(c.lead).slice(0, 160) + '…' : c.lead}</div>` : ''}
+              ${c.lead ? html`<div class="lead">${c.lead}</div>` : ''}
               ${c.stage ? html`<div class="muted2" style="margin-bottom:6px">${t(`对应阶段 ${c.stage.code} ${c.stage.name}`)}</div>` : ''}
               ${c.sections.map((s) => html`<section class="sec" id=${s.id || ''}>${this.#section(s, db)}</section>`)}
 </article>`)}
 
           <kf-glass-card surface="2" class="cover" style="margin-top:8px"><div class="cover-in">
+            <div class="section-label">证据对照</div>
             <div class="tbl-wrap"><table class="rt">
               <thead><tr><th>结论</th><th>人</th><th>原文</th><th>成果</th><th></th></tr></thead>
               <tbody>${findings.map((f) => html`<tr>
@@ -313,7 +388,7 @@ export class ViewReport extends LitElement {
       case 'para':
         return html`<h4>${title}</h4><p class="para">${s.text}</p>`;
       case 'longpara':
-        return html`<h4>${title}</h4>${String(s.text || '').split(/\n{2,}/).filter(Boolean).slice(0, 3).map((p) => html`<p class="para">${p}</p>`)}`;
+        return html`<h4>${title}</h4>${String(s.text || '').split(/\n{2,}/).filter(Boolean).map((p) => html`<p class="para">${p}</p>`)}`;
       case 'funnel':
         return html`<h4>${title}</h4><ol class="funnel">${(s.data || []).map((x) => {
           const max = Math.max(...(s.data || []).map((d) => Number(d.value) || 0), 1);

+ 17 - 24
src/pages/view-reports.js

@@ -9,7 +9,8 @@ import { LitElement, css, html } from 'lit';
 import { useTokens } from '../components/base.js';
 import { reportCenter, REPORT_TYPES, cardMatches, cardFacetCounts, sortCardEntries } from '../core/index.js';
 import { readCardQuery, patchOf, applyCardTool, paginate } from '../utils/card-view.js';
-import { setQuery, navigate, href } from '../utils/router.js';
+import { setQuery, href } from '../utils/router.js';
+import { reportPager, PAGER_CSS } from './report-pager.js';
 import { reportCard, reportRow, sourceRow, cardFooter, CARD_CSS } from './report-card.js';
 import { cardToolbar, makeToolHandler, TOOLBAR_CSS } from './card-toolbar.js';
 import { t, section, VIEW_CSS } from './shared.js';
@@ -56,7 +57,7 @@ export class ViewReports extends LitElement {
   }
 
 
-  static styles = [VIEW_CSS, CARD_CSS, TOOLBAR_CSS, css`
+  static styles = [VIEW_CSS, CARD_CSS, TOOLBAR_CSS, PAGER_CSS, css`
     /* 不按 5 等分铺满:等分列在本页实测被渲染成 ~217px(< 内容最小宽),数字被压出格。
        改用可换行的自动列,宽了 5 张一行,窄了自然折成 3 + 2,每个格子都保证内容读得清。 */
     .types { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--kf-space-in); margin: var(--kf-space-in) 0 0; align-items: stretch; }
@@ -70,9 +71,9 @@ export class ViewReports extends LitElement {
     .t .h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; row-gap: 4px; }
     .t .ico { width: 32px; height: 32px; border-radius: var(--r-md); display: grid; place-items: center; color: var(--kf-accent-ink);
       background: var(--kf-accent-soft); flex: none; }
-    /* 长标签不截断(5.6):两行折行,不再 nowrap + 省略号 */
-    .t h3 { margin: 0; font-size: 14px; line-height: 1.35; font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display);
-      word-break: keep-all; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-width: 0; }
+    /* 分类名较短,按中文完整行盒自然换行,避免裁掉上下笔画。 */
+    .t h3 { margin: 0; font-size: 14px; line-height: 1.5; font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display);
+      word-break: keep-all; overflow-wrap: anywhere; min-width: 0; text-box-trim: none; text-box-edge: auto; }
     /* 份数在基线对齐的行里会「沉底」:给整片一个固定行为基准,数值行与阶段条行同一网格 */
     .t .n { margin-left: auto; display: flex; align-items: baseline; gap: 6px; }
     .t .n kf-spark { align-self: center; }
@@ -93,32 +94,23 @@ export class ViewReports extends LitElement {
     .t .last { padding-top: 8px; border-top: 1px solid var(--kf-line); font-size: 12px; font-weight: 500; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); min-height: 32px; margin-top: auto; }
     .t .last .src { border-top: 0; padding-top: 0; margin-top: 0; }
     .t .last .src .av { display: none; }
-    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--kf-space-in); margin-top: var(--kf-space-in); }
+    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr)); gap: var(--kf-space-in); margin-top: var(--kf-space-in); }
     .listwrap { overflow: hidden; }
+    @media (max-width: 1100px) { .listhead { display: none !important; } }
     .listhead { display: grid; grid-template-columns: 48px minmax(0, 1.5fr) 96px 88px minmax(0, 1fr) 80px; gap: 12px; padding: 8px 12px;
       font-size: 12px; line-height: 1.3; letter-spacing: var(--ls-cn-small); font-weight: 500; color: var(--kf-ink-3); border-bottom: 1px solid var(--kf-line); }
-    .pager { display: flex; align-items: center; justify-content: center; gap: 4px; margin: 24px 0 8px; }
-    .pager button { all: unset; cursor: pointer; min-width: 32px; height: 32px; padding: 0 8px; display: grid; place-items: center; border-radius: var(--r-md);
-      border: 1px solid transparent;
-      background: var(--kf-surface);
-      font-size: 14px; font-weight: 500; color: var(--kf-ink-2); font-variant-numeric: tabular-nums; transition: var(--kf-t); }
-    .pager button:hover { background: var(--kf-surface-btn); }
-    .pager button[aria-current="page"] { background: var(--kf-accent); color: #fff; border-color: var(--kf-accent); }
-    .pager button[disabled] { opacity: 0.35; cursor: default; }
-    .pager .sz { margin-left: 8px; font-size: 12px; font-weight: 500; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-variant-numeric: tabular-nums; }
+    .scope-note { margin: 8px 0 0; font-size: 12px; line-height: 1.6; color: var(--kf-ink-3); }
     /* 5 张类型片:宽了 5 张一行(每张 ≥ 212px,够放「类型名 + 份数」),窄了 3 + 2 */
     .types { grid-template-columns: repeat(5, minmax(0, 1fr)); }
     @media (max-width: 1180px) { .types { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
     @media (max-width: 760px) { .types { grid-template-columns: 1fr; } }
-    @media (max-width: 767px) {
-      .pager button { width: 44px; min-width: 44px; height: 44px; padding: 0; font-size: 14px; }
-      .pager .sz { font-size: 14px; }
-    }
+
   `];
 
   #open(e) {
     if (e.kind === 'opportunity') { this.dispatchEvent(new CustomEvent('opportunity-open', { detail: { id: e.id }, bubbles: true, composed: true })); return; }
-    if (e.href) { navigate(e.href); return; }
+    if (e.kind === 'source') { this.dispatchEvent(new CustomEvent('source-open', { detail: { id: e.id }, bubbles: true, composed: true })); return; }
+    if (e.href) { this.dispatchEvent(new CustomEvent('report-open', { detail: { id: e.id }, bubbles: true, composed: true })); return; }
     this.dispatchEvent(new CustomEvent('artifact-open', { detail: { id: e.id }, bubbles: true, composed: true }));
   }
 
@@ -128,7 +120,7 @@ export class ViewReports extends LitElement {
     this.#write(q);
   }
 
-  onCardPage(p) { this.#write(readCardQuery(this.routeQuery), p); }
+  onCardPage(p) { this.#write(readCardQuery(this.routeQuery), p); this.updateComplete.then(() => this.renderRoot.querySelector('[data-toolbar]')?.scrollIntoView({ block: 'start' })); }
 
   #write(q, page = 1) { setQuery(patchOf(q, page), { force: true }); }
 
@@ -138,13 +130,14 @@ export class ViewReports extends LitElement {
     const c = reportCenter(db);
     const q = readCardQuery(this.routeQuery);
     if (!this.me && q.mine) q.mine = false;
-    const all = c.types.flatMap((t) => t.all.map((e) => ({ ...e, typeName: t.type.name })));
+    const all = c.entries;
     const test = (e) => cardMatches(e, q) && (!q.mine || e.owner === this.me);
     const matched = sortCardEntries(all.filter(test), q.sort);
     const pg = paginate(matched, q.page);
     const recent = (e) => e.at && Date.now() - Date.parse(e.at) <= 7 * 86400000;
     return html`
       ${section('分类', t(`全部 ${c.total} 份`))}
+      <p class="scope-note">同一成果可出现在多个分类中,总清单只显示一次。</p>
       <div class="types" data-types>
         ${c.types.map((ty, i) => html`
           <a href=${href(`/reports/${ty.type.key}`)} data-link data-type-card=${ty.type.key} class="kf-card is-lift kf-enter" style="display:block;--i:${i}">
@@ -172,8 +165,8 @@ export class ViewReports extends LitElement {
           : html`<div class="cards" data-cards>
               ${pg.items.map((e) => html`<kf-glass-card surface="2" lift @card-activate=${() => this.#open(e)}>${reportCard({ e, recent: recent(e), typeName: e.typeName, fit: true })}</kf-glass-card>`)}
             </div>`}
-      ${/* v12 P2 (b):底部收尾 —— 条数 + 回筛选栏 + 回顶部。条数 < 一页时只留一条轻页脚,不铺分页 */ ''}
-      ${cardFooter({ shown: pg.items.length, all: all.length, filtered: matched.length !== all.length, label: '条成果',
+      ${reportPager(pg, p => this.onCardPage(p))}
+      ${cardFooter({ shown: pg.items.length, all: matched.length, filtered: matched.length !== all.length, label: '条成果',
         onFilter: () => this.renderRoot.querySelector('[data-toolbar]')?.scrollIntoView({ behavior: 'smooth', block: 'center' }),
         onTop: () => window.scrollTo({ top: 0, behavior: 'smooth' }) })}
     `;

+ 3 - 2
src/pages/view-stages.js

@@ -71,7 +71,7 @@ export class ViewStages extends LitElement {
     .tool .grow { flex: 1; }
     .tool .cnt { font: 500 12px var(--kf-font-num); color: var(--kf-ink); font-variant-numeric: tabular-nums; }
     /* v12 P2:同一排的卡等高(reportCard 的 fit 布局把来源行钉在底边) */
-    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: var(--kf-space-in); align-items: stretch; }
+    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr)); gap: var(--kf-space-in); align-items: stretch; }
     [data-stage="S1"] { --tone: var(--kf-s1); } [data-stage="S2"] { --tone: var(--kf-s2); } [data-stage="S3"] { --tone: var(--kf-s3); }
     [data-stage="S4"] { --tone: var(--kf-s4); } [data-stage="S5"] { --tone: var(--kf-s5); }
     @media (max-width: 1100px) { .overview { grid-template-columns: repeat(2, 1fr); } }
@@ -82,7 +82,8 @@ export class ViewStages extends LitElement {
 
   #open(e) {
     if (e.kind === 'opportunity') { this.dispatchEvent(new CustomEvent('opportunity-open', { detail: { id: e.id }, bubbles: true, composed: true })); return; }
-    if (e.href) { navigate(e.href); return; }
+    if (e.kind === 'source') { this.dispatchEvent(new CustomEvent('source-open', { detail: { id: e.id }, bubbles: true, composed: true })); return; }
+    if (e.href) { this.dispatchEvent(new CustomEvent('report-open', { detail: { id: e.id }, bubbles: true, composed: true })); return; }
     this.dispatchEvent(new CustomEvent('artifact-open', { detail: { id: e.id }, bubbles: true, composed: true }));
   }
 

+ 5 - 4
src/pages/view-workbench.js

@@ -16,7 +16,7 @@ import { useTokens } from '../components/base.js';
 import { forMe, poolOverview, liveBoard, STAGES, sortOpportunities } from '../core/index.js';
 
 const sortByNewest = (rows) => sortOpportunities(rows, 'newest');
-import { href } from '../utils/router.js';
+import { href, opportunityUrl, currentUrl } from '../utils/router.js';
 import { t, VIEW_CSS, head } from './shared.js';
 import { cardFooter, playCard, CARD_CSS } from './report-card.js';
 import { icon } from '../components/icons.js';
@@ -311,6 +311,7 @@ export class ViewWorkbench extends LitElement {
       <!-- 只有手机显示:证据台账从底部 Tab 移到工作台页脚(真实链接,部署前缀同源) -->
       <nav class="foot-links" aria-label="更多入口">
         <a href=${href('/ledger')} data-link>${icon('ledger', 16)} 证据台账</a>
+        <a href=${href('/library')} data-link>${icon('inbox', 16)} 行业资料库</a>
       </nav>
     `;
   }
@@ -394,9 +395,9 @@ export class ViewWorkbench extends LitElement {
       <div class="sec">
         <div class="lbl">待评分${pending.length ? ` ${pending.length}` : ''}</div>
         ${pending.length ? pending.slice(0, 4).map((o) => html`<div class="row">
-          <a class="t" href=${href(`/pool/${o.id}`)} data-link title=${o.title}>${t(o.title)}</a>
+          <a class="t" href=${href(opportunityUrl(o.id, currentUrl()))} data-link title=${o.title}>${t(o.title)}</a>
           <span class="meta">${o.stats.n ? t(`${o.stats.n} 人 · 均 ${o.stats.avg != null ? Math.round(o.stats.avg) : '—'}`) : '尚未评分'}</span>
-          <a class="pill" href=${href(`/pool/${o.id}`)} data-link aria-label="去评分">${o.stats.avg != null ? Math.round(o.stats.avg) : '去评'}</a></div>`)
+          <a class="pill" href=${href(opportunityUrl(o.id, currentUrl()))} data-link aria-label="去评分">${o.stats.avg != null ? Math.round(o.stats.avg) : '去评'}</a></div>`)
           : html`<div class="me-none">没有等你评分的卡</div>`}
         ${pending.length > 4 ? html`<a class="go" style="margin-top:8px" href=${href('/pool')} data-link>还有 ${pending.length - 4} 张待评分 →</a>` : ''}
       </div>
@@ -481,7 +482,7 @@ export class ViewWorkbench extends LitElement {
     return html`<div data-feed>
       ${latest.length ? html`<ul class="feed">${latest.map((o) => html`<li data-status=${o.status}>
         <i class="fd" style=${`background:${DOT[o.status] || DOT.candidate}`} aria-hidden="true"></i>
-        <a href=${href(`/pool/${o.id}`)} data-link>${o.title}</a>
+        <a href=${href(opportunityUrl(o.id, currentUrl()))} data-link>${o.title}</a>
         <span class="st">${t((o.upload?.uploadedBy || '') + (o.stageObj ? ' · ' + o.stageObj.name : ''))}</span>
         <span class="tm">${ago(o.upload?.uploadedAt || o.createdAt)}</span></li>`)}</ul>
         <a class="feed-more go" href=${href('/pool')} data-link>看全部 ${pool.total} 张机会卡 →</a>`

+ 25 - 16
src/services/board-service.js

@@ -12,8 +12,7 @@
  */
 import { signal } from '@lit-labs/signals';
 import { CONFIG } from '../app.config.js';
-import { createHttp } from '../utils/http.js';
-import { loadPublicBoard } from './workshop-data.js';
+import { loadPublicBoard, publicBoardAction } from './workshop-data.js';
 import { createStorage } from '../utils/storage.js';
 import {
   buildModel, forMe as coreForMe, STATUS_DECISION, statusPatch, scorePatch, scoreStats, toTime, oppStatusOf,
@@ -28,7 +27,7 @@ export class BoardService {
   /** 当前「我是谁」——多人共用一台电脑时,每人可选自己的名字(只存本机) */
   me = signal('');
 
-  #http = createHttp({ base: CONFIG.parseBase, appId: CONFIG.appId });
+  #mutating = new Set();
   #store = createStorage('kf-board');
   #inflight = null;
 
@@ -85,21 +84,17 @@ export class BoardService {
    * actor = 当前操作人(页面传入,必填)。
    */
   async setOpportunityStatus(id, status, { by, reason = '' } = {}) {
+    if (this.#mutating.has(id)) return { changed: false };
     const db = this.db.get();
     const opp = db?.opportunityById[id];
     if (!opp) throw new Error('机会卡不存在');
     const r = statusPatch(opp, status, { by, reason, at: new Date().toISOString() });
     if (!r.changed) return { changed: false };
     const type = STATUS_DECISION[status];
-    const body = {
-      status,
-      decision: r.entry,
-      decisionLog: { __op: 'AddUnique', objects: [r.entry] },
-    };
-    await this.#http.put(`/classes/${CONFIG.classes.opportunity}/${encodeURIComponent(id)}`, body);
+    const saved = await this.#writeOpportunity('ksfSetPublicOpportunityStatus', opp, { status, by, reason });
     // 局部合并:不阻塞用户,后台再整表刷新拿到服务器最新形态
     this.#patchOpportunity(id, (o) => ({ ...o, status, statusLabel: oppStatusOf(status).label,
-      decision: r.entry, decisionLog: [...o.decisionLog, r.entry] }));
+      decision: saved.decision, decisionLog: saved.decisionLog || [], updatedAt: saved.updatedAt }));
     this.notify('ok', `已把「${opp.title}」改为「${r.entry.label}」`);
     this.load({ force: true }).catch(() => {});
     return { changed: true, type, entry: r.entry };
@@ -107,19 +102,33 @@ export class BoardService {
 
   /** 追加评分:同一人重评覆盖自己上一次 */
   async rateOpportunity(id, { user, score, comment = '' }) {
+    if (this.#mutating.has(id)) return null;
     const db = this.db.get();
     const opp = db?.opportunityById[id];
     if (!opp) throw new Error('机会卡不存在');
-    const { scores } = scorePatch(opp, { user, score, comment, at: new Date().toISOString() });
-    await this.#http.put(`/classes/${CONFIG.classes.opportunity}/${encodeURIComponent(id)}`, {
-      scores: scores.map((s) => ({ user: s.user, score: s.score, comment: s.comment, at: s.at })),
-    });
-    const stats = scoreStats(scores, toTime);
-    this.#patchOpportunity(id, (o) => ({ ...o, scores: stats.scores, stats }));
+    scorePatch(opp, { user, score, comment, at: new Date().toISOString() });
+    const saved = await this.#writeOpportunity('ksfRatePublicOpportunity', opp, { by: user, score: Number(score), comment });
+    const stats = scoreStats(saved.scores, toTime);
+    this.#patchOpportunity(id, (o) => ({ ...o, scores: stats.scores, stats, updatedAt: saved.updatedAt }));
     this.notify('ok', `已记录 ${user} 对「${opp.title}」的评分 ${score}`);
     return stats;
   }
 
+  async #writeOpportunity(name, opp, params) {
+    this.#mutating.add(opp.id);
+    try {
+      const requestId = 'public-board:' + crypto.randomUUID();
+      const result = await publicBoardAction({ url: CONFIG.functionsUrl, appId: CONFIG.appId, name,
+        params: { ...params, opportunityId: opp.id, expectedUpdatedAt: opp.updatedAt, requestId } });
+      return result.opportunity;
+    } catch (error) {
+      if (error.code === 'VERSION_CONFLICT') this.load({ force: true }).catch(() => {});
+      throw error;
+    } finally {
+      this.#mutating.delete(opp.id);
+    }
+  }
+
   #patchOpportunity(id, fn) {
     const db = this.db.get();
     if (!db) return;

+ 8 - 0
src/services/library-service.js

@@ -0,0 +1,8 @@
+import { CONFIG } from '../app.config.js';
+const MESSAGES={AUTH_REQUIRED:'资料库需要授权访问,请联系维护人开通当前看板访问权限',DATABASE_NOT_INSTALLED:'资料库尚未连接',LIBRARY_VERSION_CHANGED:'资料库版本已更新,请重新读取',LIBRARY_BUSY:'资料库正在处理其他查询,请稍后重试',RECORD_NOT_FOUND:'未找到这条资料',LIBRARY_READ_FAILED:'资料读取失败,请重试'};
+export async function readLibrary(request,{url=CONFIG.libraryUrl,fetchImpl=(...args)=>fetch(...args)}={}) {
+  const response=await fetchImpl(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(request),cache:'no-store',signal:AbortSignal.timeout(30000)});
+  let data;try{data=await response.json();}catch{throw new Error('资料库接口尚未接通');}
+  if(!response.ok||!data.ok){const code=data?.error?.code||'LIBRARY_READ_FAILED';throw Object.assign(new Error(MESSAGES[code]||'查询条件不适用或资料读取失败,请调整后重试'),{code});}
+  return data;
+}

+ 14 - 0
src/services/member-groups.js

@@ -0,0 +1,14 @@
+import { CONFIG } from '../app.config.js';
+import { publicBoardAction } from './workshop-data.js';
+
+export class MemberGroupService {
+  constructor({ url = CONFIG.memberGroupsUrl, call = publicBoardAction } = {}) { this.url = url;this.call = call; }
+  async read() {
+    return this.call({ url: this.url, appId: CONFIG.appId, name: 'ksfGetPublicMemberGroups', params: {} });
+  }
+  async change(state, command, requestId = 'member-groups:' + crypto.randomUUID()) {
+    return this.call({ url: this.url, appId: CONFIG.appId, name: 'ksfManagePublicMemberGroups',
+      params: { requestId, expectedRevision: state.revision, ...command } });
+  }
+}
+export const memberGroupService = new MemberGroupService();

+ 15 - 1
src/services/workshop-data.js

@@ -1,5 +1,19 @@
+import { request } from '../utils/http.js';
+
+/** One audited action, no automatic retries after an uncertain write. */
+export async function publicBoardAction({ url, appId, name, params, fetchImpl = request }) {
+  const response = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': appId },
+    body: JSON.stringify({ path: '/' + name, params }), cache: 'no-store', signal: AbortSignal.timeout(30000) });
+  const result = (await response.json())?.result;
+  if (!response.ok || result?.ok !== true || !result.data) {
+    const error = result?.errors?.[0];
+    throw Object.assign(new Error(error?.message || '操作结果未确认,请刷新核对后再操作'), { code: error?.code || 'ACTION_UNCERTAIN', requestId: params.requestId });
+  }
+  return result.data;
+}
+
 /** Public reads require no account. Service credentials are never sent by this module. */
-export async function loadPublicBoard({url='/api/functions',appId,fetchImpl=(...args)=>fetch(...args)}) {
+export async function loadPublicBoard({url='/api/functions',appId,fetchImpl=request}) {
   const names=['project','artifact','insight','source','session','opportunity'];
   const raw=Object.fromEntries(names.map(n=>[n,[]]));raw.truncated=[];
   let cursor=null;

+ 9 - 13
src/styles/tokens.js

@@ -169,17 +169,14 @@ export const TOKENS_CSS = `:root, :host {
   --fw-body: 400; --fw-medium: 500; --fw-bold: 600;
 
   /* ===== 中文排版(v11 板块一)=====
-     1.1 text-box 行盒修剪:中文字体自带半行距,padding 16px 的实际视觉留白是 16 + 半行距(约 0.4em),
-         永远对不准 4pt 栅格。单行文本容器统一裁到 cap→alphabetic(40px 中文行盒 55 → 30px)。
-         禁用于多行正文(会破坏行距)。
+     1.1 中文保留完整行盒。拉丁 cap→alphabetic 的裁剪高度小于汉字字面,
+         与 overflow/line-clamp 叠加时会裁掉上下笔画;间距由 padding 和 line-height 控制。
      1.5 行高层级:正文 1.75(CLReq 行间隙取 50%–100% 中位);标题 1.3;大数字 1。
          字距:正文 0(中文是满格方块,加字距反而散);11–13px 全中文小标题 +0.02em;≥28px 大标题 -0.02em;
          中文负字距上限 -0.03em,超过笔画粘连(西文可用 -4.75%,中文不行)。 */
   --lh-body: 1.75; --lh-card: 1.6; --lh-title: 1.3;
   --ls-cn-small: 0.02em; --ls-cn-body: 0; --ls-cn-display: -0.02em;
-  /* v11b:trim 只写两个**独立长属性**。简写 text-box: trim-both cap alphabetic 在 Chrome 153 被整条丢弃
-     (computed 只剩 cap alphabetic,行盒 32px 不缩);长属性写法实测 .b 行盒 32 → 20.95px。 */
-  --tb-trim: trim-both; --tb-edge: cap alphabetic;
+  --tb-trim: none; --tb-edge: auto;
 
   /* 间距层级(v11b 第三/四节,四档,可按跨级关系校验):卡内 16 / 卡内元素 12 / 卡间 24 / 区块间 48 / 页顶→首块 16 */
   --kf-space-card: 16px; --kf-space-in: 12px; --kf-space-card-gap: 24px; --kf-space-section: 48px; --kf-space-top: 16px;
@@ -369,7 +366,7 @@ summary:focus:not(:focus-visible), [tabindex]:focus:not(:focus-visible) { outlin
   border: 1px solid var(--kf-line-soft);
   background: var(--kf-surface-btn);
   color: var(--kf-ink); font: 500 14px/1 var(--kf-font); cursor: pointer;
-  text-box-trim: trim-both; text-box-edge: cap alphabetic;
+  text-box-trim: none; text-box-edge: auto;
   transition: var(--kf-t);
 }
 .kf-btn:hover { background: #fff; border-color: rgba(0, 0, 0, 0.14); }
@@ -391,7 +388,7 @@ summary:focus:not(:focus-visible), [tabindex]:focus:not(:focus-visible) { outlin
   border-radius: var(--r-sm); border: 1px solid var(--kf-line-soft);
   background: var(--kf-surface);
   font: 500 14px/1 var(--kf-font); color: var(--kf-ink-2); cursor: pointer;
-  text-box-trim: trim-both; text-box-edge: cap alphabetic;
+  text-box-trim: none; text-box-edge: auto;
   transition: var(--kf-t);
 }
 .kf-chip:hover { background: var(--kf-surface-btn); border-color: rgba(0, 0, 0, 0.14); color: var(--kf-ink); }
@@ -404,7 +401,7 @@ summary:focus:not(:focus-visible), [tabindex]:focus:not(:focus-visible) { outlin
 .kf-muted { color: var(--kf-ink-3); }
 .kf-num, .kf-mono { font-variant-numeric: tabular-nums; }
 .kf-mono { font-family: var(--kf-mono); }
-.kf-kicker { font-size: var(--fs-xs); line-height: 1.3; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-weight: var(--fw-medium); text-box-trim: trim-both; text-box-edge: cap alphabetic; }
+.kf-kicker { font-size: var(--fs-xs); line-height: 1.3; letter-spacing: var(--ls-cn-small); color: var(--kf-ink-3); font-weight: var(--fw-medium); }
 .kf-title { color: var(--kf-ink); font-weight: var(--fw-bold); letter-spacing: -0.01em; line-height: 1.3; }
 /* 主数值:32 / 600 / 1.1 / -0.02em / tabular-nums —— 决定「高管级」观感的那一档 */
 .kf-metric { font-size: var(--fs-xl); font-weight: var(--fw-bold); line-height: 1.1; letter-spacing: -0.02em;
@@ -415,11 +412,10 @@ summary:focus:not(:focus-visible), [tabindex]:focus:not(:focus-visible) { outlin
 /* ============================================================
  * 中文排版工具类(v11 板块一)
  * ============================================================ */
-/* 1.1 单行文本容器:行盒修剪(只用于单行;多行正文禁用,会破坏行距)。
-   用 text-box-trim / text-box-edge 两个长属性,不用 text-box 简写(v11b 实测简写被 Chrome 丢弃 trim-both)。 */
-.tb-cap, .kf-title, .kf-metric, .kf-metric-label, .kf-kicker, .kf-btn, .kf-chip,
+/* 中文标题与控件保留字面高度;纯数字的紧凑度由数值组件单独控制。 */
+.tb-cap, .kf-title, .kf-metric-label, .kf-kicker, .kf-btn, .kf-chip,
 h1, h2, h3 {
-  text-box-trim: trim-both; text-box-edge: cap alphabetic;
+  text-box-trim: none; text-box-edge: auto;
 }
 .kf-title { letter-spacing: var(--ls-cn-display); line-height: 1.25; }
 /* 1.2 单位分层:小一号、灰一档、留 0.12em —— 数字成为视觉主角 */

+ 9 - 2
src/utils/card-view.js

@@ -21,7 +21,7 @@ export function readCardQuery(query = {}) {
   for (const k of FLAGS) q[k] = String(query[k] || '') === '1';
   q.sort = ['score', 'new', 'ev'].includes(query.sort) ? query.sort : 'new';
   q.view = query.view === 'list' ? 'list' : 'cards';
-  q.page = Math.max(1, Number(query.page) || 1);
+  q.page = pageNumber(query.page);
   return q;
 }
 
@@ -67,7 +67,14 @@ export function filterCards(entries, q, test) {
 
 /** 分页:返回这一页的切片与页码信息(页码可跳) */
 export function paginate(list, page, size = PAGE_SIZE) {
+  size = pageNumber(size, PAGE_SIZE);
   const pages = Math.max(1, Math.ceil(list.length / size));
-  const p = Math.min(Math.max(1, page), pages);
+  const p = Math.min(pageNumber(page), pages);
   return { items: list.slice((p - 1) * size, p * size), page: p, pages, size, total: list.length };
 }
+
+/** URL 页码只能是有限的正整数,小数向下取整。 */
+export function pageNumber(value, fallback = 1) {
+  const n = Number(value);
+  return Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;
+}

+ 2 - 0
src/utils/http.js

@@ -2,6 +2,8 @@
  * utils/http.js —— 唯一的网络出口(只有 services 调它;视图层不直接 fetch)。
  * 只带公开的 App ID;任何凭据都不进前端。
  */
+export const request = (...args) => fetch(...args);
+
 export class HttpError extends Error {
   constructor(status, body, url) {
     super(`HTTP ${status}${body ? ` · ${body}` : ''}`);

+ 77 - 14
src/utils/router.js

@@ -30,6 +30,8 @@ export const ROUTES = [
   { name: 'report', path: '/reports/:type/:id' },
   { name: 'ledger', path: '/ledger' },
   { name: 'members', path: '/members' },
+  { name: 'library', path: '/library' },
+  { name: 'library-detail', path: '/library/:entity/:id' },
   { name: 'notfound', path: '*' },
 ];
 
@@ -64,16 +66,19 @@ export function resolveUrl(path) {
 export const href = (path) => resolveUrl(path).href;
 
 /** 站内跳转:pushState + 手动派发 popstate(History 原生不触发) */
-export function navigate(path, { replace = false } = {}) {
+export function navigate(path, { replace = false, state = {} } = {}) {
   const url = resolveUrl(path);
   // 只更新 path / query / hash,不与当前相同则不重复入栈
   if (url.href === location.href) return;
-  if (replace) history.replaceState({}, '', url);
-  else history.pushState({}, '', url);
+  if (replace) history.replaceState({ ...history.state, ...state }, '', url);
+  else {
+    history.replaceState({ ...history.state, kfScrollY: window.scrollY || 0 }, '');
+    history.pushState({ kfFrom: currentUrl(), ...state }, '', url);
+  }
   // 订阅者必须拿到**这次导航之后**的路径:浏览器里 pushState 是同步的,但封装环境
   // (测试用的 jsdom 等)会在事件派发前后重新同步 location,事件里再读 location 可能还是旧值。
   // 所以把应用内路径直接随事件带出去;订阅者优先用 detail,拿不到再读 location。
-  emit(url);
+  emit(url, replace ? 'replace' : 'push');
 }
 
 /**
@@ -81,14 +86,14 @@ export function navigate(path, { replace = false } = {}) {
  * 用于「筛选变了但只剩一页(只改 page 参数又恰好没变)」这类需要页面立即重绘的场景。
  */
 export function navigateForce(path) {
-  history.replaceState({}, '', resolveUrl(path));
-  emit(resolveUrl(path));
+  history.replaceState(history.state, '', resolveUrl(path));
+  emit(resolveUrl(path), 'replace');
 }
 
-function emit(url) {
+function emit(url, navigation) {
   const appPath = stripBase(url.pathname);
   const m = match(appPath);
-  const detail = { name: m.name, params: m.params, path: appPath, query: Object.fromEntries(url.searchParams) };
+  const detail = { name: m.name, params: m.params, path: appPath, query: Object.fromEntries(url.searchParams), navigation, historyState: history.state };
   window.dispatchEvent(new PopStateEvent('popstate', { state: detail }));
 }
 
@@ -112,7 +117,53 @@ export function currentQuery() {
 
 /** 当前应用内 URL(path + query),用于生成「刷新后还在同一页」的链接 */
 export function currentUrl() {
-  return currentPath() + location.search;
+  return currentPath() + location.search + location.hash;
+}
+
+/** 只接受已注册的应用内页面作为返回地址。 */
+export function safeReturnTo(value, fallback = '/pool') {
+  if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || /[\\\u0000-\u001f]/.test(value)) return fallback;
+  const path = value.split(/[?#]/, 1)[0];
+  return match(path).name !== 'notfound' ? value : fallback;
+}
+
+/** 机会卡 URL 保留列表筛选,并显式携带关闭后返回的页面;复制/刷新也能使用。 */
+export function opportunityUrl(id, returnTo = currentUrl()) {
+  const back = safeReturnTo(returnTo);
+  const q = back.split('?')[1]?.split('#')[0] || '';
+  const params = back.split(/[?#]/, 1)[0] === '/pool' ? new URLSearchParams(q) : new URLSearchParams();
+  params.set('returnTo', back);
+  return `/pool/${encodeURIComponent(id)}?${params}`;
+}
+
+export function reportUrl(id, returnTo = '') {
+  const q = returnTo ? `?${new URLSearchParams({ returnTo: safeReturnTo(returnTo, '/reports') })}` : '';
+  return `/report/${encodeURIComponent(id)}${q}`;
+}
+
+/** 详情使用独立查询键,保留列表的证据筛选 ev;兼容旧的 ev+report 深链接。 */
+export function detailUrl(type, id, context = currentUrl(), reportId = '') {
+  const url = new URL(resolveUrl(safeReturnTo(context)));
+  if (url.searchParams.has('ev') && url.searchParams.has('report')) {
+    url.searchParams.delete('ev'); url.searchParams.delete('report');
+  }
+  for (const key of ['artifact', 'insight', 'source', 'evidence', 'evidenceReport']) url.searchParams.delete(key);
+  if (['artifact', 'insight', 'source', 'evidence'].includes(type) && id) {
+    url.searchParams.set(type, id);
+    if (type === 'evidence') url.searchParams.set('evidenceReport', reportId);
+  }
+  return stripBase(url.pathname) + url.search + url.hash;
+}
+
+/** 历史条目可能被浏览器裁剪,只有上一条仍是目标页时才后退。 */
+export function returnToRoute(path, { canGoBack = false } = {}) {
+  const target = safeReturnTo(path);
+  const nav = window.navigation;
+  if (nav?.entries && nav.currentEntry) {
+    canGoBack = nav.entries().some(entry => entry.index === nav.currentEntry.index - 1 && entry.url === href(target));
+  }
+  if (canGoBack) history.back();
+  else navigate(target, { replace: true });
 }
 
 /**
@@ -130,7 +181,12 @@ export function match(path = currentPath()) {
     }
     const re = new RegExp('^' + rp.split('/').map((seg) => (seg.startsWith(':') ? `(?<${seg.slice(1)}>[^/]+)` : seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))).join('/') + '$');
     const m = re.exec(clean);
-    if (m) return { name: r.name, params: { ...m.groups }, path: clean };
+    if (m) {
+      try {
+        const params = Object.fromEntries(Object.entries(m.groups).map(([k, v]) => [k, decodeURIComponent(v)]));
+        return { name: r.name, params, path: clean };
+      } catch { return { name: 'notfound', params: {}, path: clean }; }
+    }
   }
   return { name: 'notfound', params: {}, path: clean };
 }
@@ -138,8 +194,8 @@ export function match(path = currentPath()) {
 /** 订阅路由变化(首帧立即回调一次)。返回取消订阅函数。 */
 export function subscribe(fn) {
   const onPop = (e) => {
-    const d = e?.state && typeof e.state === 'object' && typeof e.state.path === 'string' ? e.state : null;
-    fn(d ? { ...d } : { ...match(), query: currentQuery() });
+    const d = e?.state && ['push', 'replace'].includes(e.state.navigation) ? e.state : null;
+    fn(d ? { ...d } : { ...match(), query: currentQuery(), navigation: e ? 'traverse' : 'initial', historyState: history.state });
   };
   window.addEventListener('popstate', onPop);
   onPop();
@@ -162,11 +218,18 @@ export function setQuery(patch, { replace = true, force = false } = {}) {
 /** 内部链接点击的统一拦截(真实 <a> 保留「新标签打开」能力) */
 export function onLinkClick(e) {
   if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
-  const a = e.target instanceof Element ? e.target.closest('a[data-link]') : null;
+  // document 捕获阶段的 target 会被 Shadow DOM 重定向为组件宿主。
+  const a = e.composedPath?.().find((node) => node instanceof HTMLAnchorElement && node.matches('a[data-link]'))
+    || (e.target instanceof Element ? e.target.closest('a[data-link]') : null);
   if (!a) return;
+  if (a.hasAttribute('download') || (a.target && a.target.toLowerCase() !== '_self')) return;
   const url = new URL(a.href, location.href);
   if (url.origin !== location.origin) return;
+  const base = basePath();
+  if (base && url.pathname !== base && !url.pathname.startsWith(base + '/')) return;
   e.preventDefault();
   e.stopPropagation(); // 卡片内嵌链接:不触发外层卡片跳转
-  navigate(url.pathname + url.search + url.hash);
+  const path = stripBase(url.pathname) + url.search + url.hash;
+  if (a.hasAttribute('data-back')) returnToRoute(path, { canGoBack: history.state?.kfFrom === path });
+  else navigate(path);
 }

+ 29 - 0
test/card-view.test.js

@@ -0,0 +1,29 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { paginate, readCardQuery, patchOf, pageNumber } from '../src/utils/card-view.js';
+
+test('报告分页覆盖全部记录且无重复;首尾页与空清单保持有效', () => {
+  const all = Array.from({ length: 38 }, (_, i) => `report-${i}`);
+  const pages = Array.from({ length: paginate(all, 1).pages }, (_, i) => paginate(all, i + 1));
+  assert.deepEqual(pages.flatMap(p => p.items), all);
+  assert.deepEqual(paginate(all, 999).items, all.slice(36));
+  assert.deepEqual(paginate([], 3), { items: [], page: 1, pages: 1, size: 12, total: 0 });
+});
+
+test('损坏或非整数 URL 页码不会漏记录、返回空页或 NaN', () => {
+  const all = Array.from({ length: 25 }, (_, i) => i);
+  for (const page of ['NaN', 'Infinity', '-5', '0', undefined]) {
+    assert.equal(readCardQuery({ page }).page, 1);
+    assert.equal(paginate(all, page).page, 1);
+    assert.equal(paginate(all, page).items.length, 12);
+  }
+  assert.equal(paginate(all, '2.7').page, 2);
+  assert.equal(pageNumber('2.7'), 2);
+  assert.equal(paginate(all, 1, Infinity).size, 12);
+});
+
+test('翻页与刷新保留证据、阶段、排序和列表视图', () => {
+  const q = readCardQuery({ stage: 'S3,S4', ev: '高', sort: 'ev', view: 'list', page: '2' });
+  const restored = readCardQuery(patchOf(q, 3));
+  assert.deepEqual(restored, { ...q, page: 3 });
+});

+ 45 - 0
test/library.test.js

@@ -0,0 +1,45 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { libraryRequest,libraryPath,libraryLabel,libraryRecordInfo,libraryProductRequest,libraryCatalogQuery,LIBRARY_GROUPS } from '../src/core/library.js';
+import { readLibrary } from '../src/services/library-service.js';
+import { createLibraryApi } from '../server/library-api.mjs';
+import { Readable } from 'node:stream';
+test('资料条件同时保留,未知字段和日期不被关键词替代',()=>{
+  const r=libraryRequest({type:'reviews',platform:'jd',brand:'ksf',category:'banmian',q:'份量',from:'2026-10-01',to:'2026-11-05',shopId:'__unknown'});
+  assert.deepEqual(r.filters,{platform:'jd',brand:'ksf',category:'banmian',shopId:{exists:false},reviewDate:{min:'2026-10-01',max:'2026-11-05'}});
+  assert.equal(r.keyword,'份量');assert.equal(libraryRequest({type:'social',from:'2026-10-01'}).filters.publishedDate.min,'2026-10-01');
+});
+test('详情深链接保留查询,标签使用真实字典',()=>{
+  assert.equal(libraryPath({type:'products',brand:'ksf'},{entity:'products',id:'jd:123'}),'/library/products/jd%3A123?type=products&brand=ksf');
+  assert.equal(libraryLabel(null),'未知/待核对');assert.equal(libraryLabel('ksf',{taxonomy:{brands:[{code:'ksf',name:'康师傅'}],categories:[]}}),'康师傅');
+});
+test('阅读信息按资料类型选择业务事实,内部规则、编号和指纹不进入展示',()=>{
+  const record={marketplace:'jd',shop_name:'康师傅旗舰店',category_basis:'rule_title_explicit',brand_basis:'rule_title_prefix',
+    classification_state:'supported',source_record_id:'internal-source',item_id:'123',shop_id:'456',
+    expected_sha256:'a'.repeat(64),issues:['INTERNAL_RULE'],source_time_raw:'2026-11-04'};
+  assert.deepEqual(libraryRecordInfo(record,'products'),[['销售平台','京东'],['店铺','康师傅旗舰店'],['观察时间','2026-11-04']]);
+  assert.deepEqual(libraryRecordInfo({...record,likes:0,comment_count:30},'social'),[['平台点赞数','0'],['平台显示评论量','30']]);
+});
+test('阅读价格使用元且不暴露分单位和技术字段,单位价格只在核对后展示',()=>{
+  const record={amount_minor:2650,raw_amount:'26.5',price_basis:'search_listing_snapshot',price_per_100g:3.5,unit_price_verification:'unavailable'};
+  assert.deepEqual(libraryRecordInfo(record,'prices'),[['观察价格','¥26.50'],['价格口径','搜索展示价']]);
+  assert.ok(libraryRecordInfo({...record,unit_price_verification:'verified'},'prices').some(([k,v])=>k==='每100克价格(元)'&&v==='3.5'));
+});
+test('商品是浏览入口,详情内查询固定商品编号并隔离列表条件和分页',()=>{
+  assert.deepEqual(LIBRARY_GROUPS.flatMap(group=>group.entities),['products','social','documents','reports']);
+  const query={type:'products',platform:'tmall',brand:'other-brand',category:'other-category',q:'旧列表词',cursor:'list-cursor',panel:'reviews',relatedQ:'包装',relatedQuality:'audit',relatedFrom:'2026-11-01',relatedTo:'2026-11-05',relatedCursor:'review-cursor'};
+  assert.deepEqual(libraryProductRequest('jd:123',query),{op:'browse',entity:'reviews',filters:{productId:'jd:123',reviewDate:{min:'2026-11-01',max:'2026-11-05'}},quality:'audit',limit:20,keyword:'包装',cursor:'review-cursor'});
+  assert.deepEqual(libraryCatalogQuery(query),{type:'products',platform:'tmall',brand:'other-brand',category:'other-category',q:'旧列表词',cursor:'list-cursor'});
+  assert.equal(libraryProductRequest('jd:123',{panel:'overview'}),null);
+  assert.equal(libraryProductRequest('jd:123',{panel:'unknown'}),null);
+});
+test('接口失败不作为空数据,浏览器不发送服务凭据',async()=>{
+  await assert.rejects(()=>readLibrary({op:'summary'},{fetchImpl:async(_url,opt)=>{assert.deepEqual(opt.headers,{'content-type':'application/json'});return {ok:false,json:async()=>({ok:false,error:{code:'DATABASE_NOT_INSTALLED'}})};}}),/尚未连接/);
+  await assert.rejects(()=>readLibrary({op:'summary'},{fetchImpl:async()=>({ok:true,json:async()=>{throw Error('HTML')}})}),/尚未接通/);
+});
+test('生产接口默认拒绝匿名及写操作,凭据不进入返回',async()=>{
+  const api=createLibraryApi({token:'test-token'});let status,output;
+  const req=Readable.from(['{"op":"summary"}']);req.method='POST';req.headers={};
+  await api(req,{writeHead(s){status=s;},end(v){output=JSON.parse(v);}});
+  assert.equal(status,401);assert.equal(output.error.code,'AUTH_REQUIRED');assert.ok(!JSON.stringify(output).includes('test-token'));
+});

+ 63 - 0
test/member-groups.test.js

@@ -0,0 +1,63 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { indexBoardMembers, seedMemberGroups, changeMemberGroups } from '../src/core/member-groups.js';
+import { buildModel } from '../src/core/kfs/build.js';
+import { MemberGroupService } from '../src/services/member-groups.js';
+
+const raw = () => ({ session: [
+  { objectId: 's1', studentName: '原昵称', groupId: 'G1', meta: { participantKey: 'participant:old1' }, employee: { key: 'channel-employee:stable', displayName: '小智' } },
+  { objectId: 's2', studentName: '另一昵称', groupId: 'G1', meta: { participantKey: 'participant:old2' }, employee: { key: 'channel-employee:stable', displayName: '小智' } },
+  { objectId: 's3', studentName: '小智', groupId: 'G2', meta: { participantKey: 'participant:other' } },
+], artifact: [{ objectId: 'a1', ownerName: '原昵称', uploadedByKey: 'participant:old1', ownerSession: 's1' }],
+project: [{ objectId: 'p1', ownerName: '原昵称', uploadedByKey: 'participant:old2', ownerSession: 's2' }],
+opportunity: [{ objectId: 'o1', title: '机会', uploadedBy: '原昵称', uploadedByKey: 'participant:old1', sourceSession: 's1' }] });
+
+test('同一员工跨会话、旧键与昵称仍归同人,同名其他身份不合并', () => {
+  const input = raw(), db = buildModel(input);
+  assert.equal(db.members.length, 2);
+  const m = db.memberById['channel-employee:stable'];
+  assert.equal(m.name, '小智(G1)');assert.deepEqual(m.sessionIds, ['s1','s2']);
+  assert.deepEqual(m.artifactIds, ['a1']);assert.deepEqual(m.projectIds, ['p1']);assert.deepEqual(m.oppIds, ['o1']);
+  assert.equal(db.memberById['participant:other'].name, '小智(G2)');
+  const renamed = raw();renamed.session[0].employee.displayName = '新称呼';renamed.session[1].employee.displayName = '新称呼';
+  assert.equal(indexBoardMembers(renamed).members[0].id, m.id);
+  assert.equal(input.artifact[0].ownerName, '原昵称');
+});
+
+test('姓名补充不能改变旧数据的分组编号,前端与服务端成员索引逐项一致', () => {
+  const input = { session: [
+    { objectId: 's1', studentName: '陈静', groupId: 'G1' },
+    { objectId: 's2', studentName: '陈静', groupId: 'G1', meta: { participantKey: 'legacy-account-1' } },
+  ], artifact: [{ objectId: 'a1', ownerName: '陈静', ownerSession: 's1', uploadedByKey: 'legacy-account-2' }],
+  project: [], opportunity: [] };
+  const directory = indexBoardMembers(input), db = buildModel(input);
+  assert.deepEqual(db.members.map((m) => m.id).sort(), directory.members.map((m) => m.id).sort());
+  assert.equal(db.members.length, 1);
+  assert.equal(db.members[0].name, '陈静');
+  assert.equal(seedMemberGroups(db.members).assignments[db.members[0].id], 'legacy-group:G1');
+});
+
+test('分组名独立于成员身份,重命名不改归属,删除只移回未分组', () => {
+  const members = indexBoardMembers(raw()).members, ids = new Set(members.map((m) => m.id));
+  const state = seedMemberGroups(members), before = JSON.stringify(members);
+  const assigned = changeMemberGroups(state, { action: 'assign', groupId: state.groups[1].id, memberIds: [...ids] }, ids);
+  const renamed = changeMemberGroups(assigned, { action: 'rename', groupId: state.groups[1].id, name: '新品研发' }, ids);
+  assert.equal(renamed.groups[1].name, '新品研发');assert.deepEqual(renamed.assignments, assigned.assignments);
+  const deleted = changeMemberGroups(renamed, { action: 'delete', groupId: state.groups[1].id }, ids);
+  assert.equal(Object.keys(deleted.assignments).length, 0);assert.equal(JSON.stringify(members), before);
+  assert.equal(state.revision, 0);
+});
+
+test('重复、空白、保留名、长名及未知成员不会产生有效分组变更', () => {
+  const members = indexBoardMembers(raw()).members, ids = new Set(members.map((m) => m.id)), state = seedMemberGroups(members);
+  for (const name of ['G1',' G1 ','', '未分组','全部成员','字'.repeat(31)]) assert.throws(() => changeMemberGroups(state, { action:'create',name }, ids, 'new'), { code:'VALIDATION_FAILED' });
+  assert.throws(() => changeMemberGroups(state, { action:'assign',groupId:state.groups[0].id,memberIds:[members[0].id,'private-person'] }, ids), { code:'VALIDATION_FAILED' });
+  assert.equal(state.revision, 0);
+});
+
+test('分组服务发送版本与稳定编号,网络异常不自动重试', async () => {
+  let calls=0;
+  const service = new MemberGroupService({ url:'/fixture',call:async (input) => { calls++;assert.equal(input.name,'ksfManagePublicMemberGroups');assert.equal(input.params.expectedRevision,3);assert.equal(input.params.requestId,'same');throw Error('timeout'); } });
+  await assert.rejects(() => service.change({revision:3},{action:'assign',memberIds:['employee:one'],groupId:null},'same'),/timeout/);
+  assert.equal(calls,1);
+});

+ 24 - 0
test/report.test.js

@@ -53,6 +53,30 @@ test('结论块去重(同一证据锚点只出现一次)', () => {
   assert.equal(f[0].ownerCount, 3);
 });
 
+test('表格和段落小结的全部逐字引文都有证据入口,来源关系保留', () => {
+  const report = {
+    chapters: [{ title: '资料小结', sections: [{ kind: 'para', text: '资料范围' }, { kind: 'table', data: { cols: ['产品'], rows: [['拌面']] } }] }],
+    evidence: {
+      competitor: { sourceId: 'source-competitor', claim: '官网口味说明', quotes: [{ text: '官网原文' }] },
+      own: { sourceId: 'source-own', claim: '2022年品牌通告', quotes: [{ text: '品牌升级' }, { text: '辣味选择' }, { text: '滤水口' }] },
+    },
+  };
+  const model = buildModel({ artifact: [{ objectId: 'summary', report }], source: [] });
+  const entries = findingsOf(model, 'summary');
+  assert.deepEqual(entries.map(e => [e.evId, e.quoteCount]), [['competitor', 1], ['own', 3]]);
+  assert.ok(entries.every(e => e.ownerCount === null), '品牌文案不填消费者人数');
+  const own = reportEvidence(model, 'summary', 'own');
+  assert.ok(own.quotes.every(q => q.sourceId === 'source-own' && q.art === null));
+  assert.equal(reportOverview(model, 'summary').quoteCount, 4);
+  report.chapters[0].sections.push({ kind: 'findings', data: [{ conclusion: '原有判断', evidence: 'own' }] });
+  const mixed = findingsOf(model, 'summary');
+  assert.deepEqual(mixed.map(e => e.evId), ['own', 'competitor']);
+  assert.equal(mixed[0].conclusion, '原有判断');
+  assert.equal(mixed[1].conclusion, '官网口味说明');
+  const fallback = reportEvidence(buildModel({ artifact: [{ objectId: 'empty', report: { evidence: { x: { quotes: [{ text: '无来源' }] } } } }] }), 'empty', 'x');
+  assert.equal(fallback.quotes[0].sourceId, null);
+});
+
 test('章节归一:缺 id 用序号补齐', () => {
   const c = chapters({ chapters: [{ title: 'x' }, { id: 'y', title: 'z' }] });
   assert.equal(c[0].id, 'ch1');

+ 157 - 0
test/router.test.js

@@ -0,0 +1,157 @@
+import { test, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+import { match, resolveUrl, navigate, subscribe, currentUrl, opportunityUrl, reportUrl, detailUrl, safeReturnTo, returnToRoute, setQuery, onLinkClick } from '../src/utils/router.js';
+
+let base, entries, cursor;
+
+test('详情的来源和证据链接保留筛选,替换上一种详情,并兼容旧链接', () => {
+  const source = resolveUrl(detailUrl('source', '原文 1', '/reports/voc?ev=高&stage=S3&artifact=a1&page=2'));
+  assert.equal(source.searchParams.get('source'), '原文 1');
+  assert.equal(source.searchParams.get('ev'), '高');
+  assert.equal(source.searchParams.get('artifact'), null);
+  assert.equal(source.searchParams.get('page'), '2');
+  const evidence = resolveUrl(detailUrl('evidence', 'ev:1', '/reports/voc?ev=高&source=s1', 'report1'));
+  assert.equal(evidence.searchParams.get('ev'), '高');
+  assert.equal(evidence.searchParams.get('evidence'), 'ev:1');
+  assert.equal(evidence.searchParams.get('evidenceReport'), 'report1');
+  assert.equal(evidence.searchParams.get('source'), null);
+  const legacy = resolveUrl(detailUrl('', '', '/pool/o1?ev=old&report=r1&returnTo=%2Fmembers'));
+  assert.equal(legacy.searchParams.get('ev'), null);
+  assert.equal(legacy.searchParams.get('report'), null);
+  assert.equal(legacy.searchParams.get('returnTo'), '/members');
+});
+beforeEach(() => {
+  base = '/preview/';
+  entries = [{ url: new URL('https://board.test/preview/pool?stage=S3&sort=latest'), state: null }];
+  cursor = 0;
+  globalThis.location = entries[0].url;
+  globalThis.document = { querySelector: () => ({ getAttribute: () => base }) };
+  globalThis.window = new EventTarget();
+  window.scrollY = 350;
+  globalThis.PopStateEvent = class extends Event {
+    constructor(type, options) { super(type); this.state = options.state; }
+  };
+  globalThis.history = {
+    get state() { return entries[cursor].state; },
+    replaceState(state, _, url) {
+      entries[cursor] = { state, url: url ? new URL(url, location.href) : location };
+      globalThis.location = entries[cursor].url;
+    },
+    pushState(state, _, url) {
+      entries.splice(++cursor, entries.length, { state, url: new URL(url, location.href) });
+      globalThis.location = entries[cursor].url;
+    },
+    back() {
+      if (!cursor) return;
+      globalThis.location = entries[--cursor].url;
+      window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
+    },
+  };
+});
+
+test('机会卡深链接保留筛选、排序和关闭返回地址;报告携带回卡片地址', () => {
+  const opp = opportunityUrl('机会 1');
+  const url = resolveUrl(opp);
+  assert.equal(url.pathname, '/preview/pool/%E6%9C%BA%E4%BC%9A%201');
+  assert.equal(url.searchParams.get('stage'), 'S3');
+  assert.equal(url.searchParams.get('sort'), 'latest');
+  assert.equal(url.searchParams.get('returnTo'), '/pool?stage=S3&sort=latest');
+  const report = resolveUrl(reportUrl('r 1', opp));
+  assert.equal(report.searchParams.get('returnTo'), opp);
+  assert.equal(match(url.pathname.slice('/preview'.length)).params.id, '机会 1');
+});
+
+test('来自其他页面的机会卡返回原页,不将来源页参数误用为卡池筛选', () => {
+  const url = resolveUrl(opportunityUrl('o1', '/members?member=小智'));
+  assert.equal(url.searchParams.get('member'), null);
+  assert.equal(url.searchParams.get('returnTo'), '/members?member=小智');
+});
+
+test('推入历史保存离开页的位置,换卡 replace 保留返回凭据且不多压一层', () => {
+  const back = currentUrl();
+  navigate(opportunityUrl('o1'), { state: { kfOverlay: { returnTo: back } } });
+  assert.equal(entries[0].state.kfScrollY, 350);
+  assert.equal(history.state.kfFrom, back);
+  navigate(opportunityUrl('o2', back), { replace: true });
+  assert.equal(entries.length, 2);
+  assert.equal(history.state.kfOverlay.returnTo, back);
+  assert.equal(match().params.id, 'o2');
+  history.back();
+  assert.equal(currentUrl(), back);
+});
+
+test('订阅分别识别站内导航与浏览器返回,返回读取当前实际路径和位置', () => {
+  const changes = [];
+  const off = subscribe(value => changes.push(value));
+  navigate('/report/r1?ch=limits');
+  history.back();
+  off();
+  assert.deepEqual(changes.map(c => c.navigation), ['initial', 'push', 'traverse']);
+  assert.equal(changes.at(-1).name, 'pool');
+  assert.equal(changes.at(-1).historyState.kfScrollY, 350);
+});
+
+test('章节和浮层查询串更新不丢返回地址和历史状态', () => {
+  navigate(reportUrl('r1', '/pool/o1?stage=S3'));
+  setQuery({ ch: 'limits', ev: 'e1', report: 'r1' });
+  setQuery({ ev: null, report: null });
+  const q = new URLSearchParams(location.search);
+  assert.equal(q.get('returnTo'), '/pool/o1?stage=S3');
+  assert.equal(q.get('ch'), 'limits');
+  assert.equal(q.get('ev'), null);
+  assert.equal(history.state.kfFrom, '/pool?stage=S3&sort=latest');
+});
+
+test('非法或站外返回地址落到应用内兜底,损坏编码匹配为不存在', () => {
+  for (const path of ['https://evil.test/', '//evil.test/', '/\\evil.test/', '/missing', '/pool\n']) {
+    assert.equal(safeReturnTo(path, '/reports'), '/reports');
+  }
+  assert.equal(match('/pool/%E0%A4%A').name, 'notfound');
+  assert.equal(match('/reports/competitor/r1').name, 'report');
+});
+
+test('根部署与子路径导航均保留查询和章节,不重复压入相同 URL', () => {
+  base = '/';
+  assert.equal(resolveUrl('/report/r1?ch=2#top').href, 'https://board.test/report/r1?ch=2#top');
+  base = '/preview/';
+  navigate('/report/r1?ch=2#top');
+  navigate('/report/r1?ch=2#top');
+  assert.equal(entries.length, 2);
+  assert.equal(location.pathname, '/preview/report/r1');
+  assert.equal(currentUrl(), '/report/r1?ch=2#top');
+});
+
+test('报告返回链接使用浏览器历史;新标签与下载链接保留浏览器行为', () => {
+  globalThis.HTMLAnchorElement = class {
+    constructor(path, attrs = {}) { this.href = resolveUrl(path).href; this.attrs = attrs; this.target = attrs.target || ''; }
+    matches() { return true; }
+    hasAttribute(name) { return Object.hasOwn(this.attrs, name); }
+  };
+  globalThis.Element = HTMLAnchorElement;
+  const click = anchor => {
+    const e = { button: 0, composedPath: () => [anchor], preventDefault() { this.prevented = true; }, stopPropagation() {} };
+    onLinkClick(e);
+    return e;
+  };
+  const back = currentUrl();
+  navigate('/report/r1');
+  assert.ok(click(new HTMLAnchorElement(back, { 'data-back': '' })).prevented);
+  assert.equal(currentUrl(), back);
+  assert.equal(cursor, 0);
+  assert.equal(click(new HTMLAnchorElement('/pool/o1', { target: '_blank' })).prevented, undefined);
+  assert.equal(click(new HTMLAnchorElement('/report/r1', { download: '' })).prevented, undefined);
+});
+
+test('历史达到浏览器上限、来源条目被裁剪时,关闭仍回指定页', () => {
+  const origin = currentUrl();
+  navigate(opportunityUrl('o1'));
+  window.navigation = {
+    currentEntry: { index: 4 },
+    entries: () => [{ index: 3, url: hrefForTest('/members') }, { index: 4, url: location.href }],
+  };
+  returnToRoute(origin, { canGoBack: true });
+  assert.equal(cursor, 1);
+  assert.equal(currentUrl(), origin);
+});
+
+function hrefForTest(path) { return resolveUrl(path).href; }

+ 12 - 0
test/showcase.test.js

@@ -37,9 +37,21 @@ test('报告中心:五类都有数,未映射成果如实进「未归类」',
   // 合计 = 成果 3 + 机会卡 2(competitor)+ 机会卡 2(voc)+ 资料 1 = 8;没有未归类(同一张卡可被两个视角引用)
   assert.equal(sum, 8, '已归类合计');
   assert.equal(c.unclassified.length, 0);
+  assert.equal(c.total, 6, '中心按稳定编号去重,类型页可以分别引用同一机会卡');
+  assert.equal(new Set(c.entries.map(e => `${e.kind}:${e.id}`)).size, c.total);
   assert.ok(c.types.every((t) => t.share >= 0 && t.relative >= 0), '每个数字都有参照');
 });
 
+test('中心清单保留未归类成果,可打开正文,不被五个类型过滤掉', () => {
+  const extra = buildModel({ ...raw, artifact: [...raw.artifact, { objectId: 'unknown1', title: '待归类原文', artifactType: 'unknown', rawText: '尚未归类的成果原文。' }] });
+  const c = reportCenter(extra);
+  assert.equal(c.unclassified.length, 1);
+  const entry = c.entries.find(e => e.id === 'unknown1');
+  assert.equal(entry.kind, 'artifact');
+  assert.equal(entry.typeName, '未归类');
+  assert.equal(entry.body, 'text');
+});
+
 test('类型页:阶段二次筛选 + 只看有证据', () => {
   const all = entriesForType(db, 'competitor');
   assert.equal(filterEntries(all, { stage: 'S3' }).length, 2, 'a1 与 o1 都在 S3');

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