Kaynağa Gözat

feat(cjk+visual): 中英混排全站落地 679处,text-box 行盒 -27.2%,修复 /reports 白屏与卡住时长

- U+2009 改在字符串生成处插入(t() 包模板、格式化函数内联),无事后扫 DOM
- text-box-trim 生效:32px 行盒 32.00→23.28px(-27.2%),flex 容器需改 block
- 修 view-reports.js 的 t 变量遮蔽导致白屏
- 修成员页卡住时长(从模型 stuckHours 反推,不用 Date.now)
- 删除 MutationObserver / window.__kfCjk / createTreeWalker 全部运行时残留
- 实测:check OK、46/46、8 路由 679 处 U+2009、普通空格残留 0
FmodeCode 5 gün önce
ebeveyn
işleme
434e428ee8

+ 151 - 0
TASK-v12-visual.md

@@ -0,0 +1,151 @@
+# v12:深度视觉重构(不是微调)
+
+> 用户判词:「现在的视图和 UI 看起来有点怪怪的,还有这个筛选框这里的按钮边框也不好看,整体的视觉效果不好,需要深度升级,不能当成小任务。」
+> **本轮是结构性重做,不是加几条 CSS。** 验收按「一个外行打开会不会觉得这是个成品」来判。
+
+---
+
+## 零、我已经测出的根因(这些是事实,不是猜测)
+
+在 1440×900 实测 `/pool`、`/reports`、`/stages`,得到以下硬数据:
+
+### 1. 控件的浏览器默认样式没被覆盖 —— 这是「按钮边框不好看」的真因
+
+```
+/pool 排序按钮:border-radius: 0px,border: 0px none
+               背景 rgb(24,24,27),文字 rgb(255,255,255)
+/pool 原生 <button>:borderWidth 2px,背景 rgb(239,239,239),文字 rgb(0,0,0)
+```
+
+后者是**浏览器默认外观直接暴露**(`2px outset` 加灰底),说明按钮的样式选择器根本没命中。全站 `/pool` 检出 17 个这类「裸控件」。
+
+### 2. 圆角是四档打架,不是一套体系
+
+```
+/pool    :6px ×23、8px ×2、16px ×1
+/reports :6px ×36、7px ×6、10px ×6、0px ×5
+/stages  :6px ×8
+```
+
+**6 / 7 / 10 / 0 四档并存**,7px 和 6px 差 1px 肉眼看得出来是「不齐」。0px 是没设。这不是设计,是各页各写各的。
+
+### 3. 卡片高度参差 21px
+
+```
+/pool 9 张卡:368 ×5、389 ×4,align-items: normal,gap 16px
+```
+
+同一行的卡因为文案行数不同被撑成两种高度,底边不齐。
+
+### 4. 页面底部空白(不是 bug,是内容撑不满)
+
+```
+栅格高 1946px,最后一行卡片底 → 栅格底 gap = 0(布局没问题)
+但页面总高 2462px,栅格之外还有 516px 空区
+```
+
+原因:9 张卡排 3 行就结束,下方没有任何收尾内容。**这是信息架构问题,靠 CSS 补不了。**
+
+### 5. 字号与色阶也偏散
+
+```
+字号:20 / 16 / 14 / 13.33 / 13 / 11 六档(13.33px 是浏览器默认值泄漏)
+颜色:rgb(24,24,27) ×49、rgb(63,63,70) ×41、rgb(75,85,99) ×11、rgb(0,0,0) ×10
+```
+
+**纯黑 `rgb(0,0,0)` 出现 10 次**——这与 v10 定的「禁用纯黑」直接矛盾。
+
+### 6. 工作台左卡是大空框
+
+「还没选『我是』」占近半卡高,右侧「1 人卡住」信息密。左右严重失衡。
+
+---
+
+## 一、本轮目标(按优先级)
+
+### P0:控件与圆角统一 —— 先让页面「看起来是做过的」
+
+1. **建立唯一圆角尺度**,全站只允许 3 个 token:
+   - `--r-sm: 8px`(chip、小按钮、输入框、标签)
+   - `--r-md: 12px`(卡片、面板、抽屉内块)
+   - `--r-lg: 20px`(浮层、大卡、模态)
+   - **禁止出现 6px / 7px / 10px / 0px**(0px 仅允许用于分隔线等非容器元素)
+2. **彻底消除浏览器默认控件外观**:所有 `button/input/select` 必须显式覆盖
+   `appearance: none`、`background`、`border`、`border-radius`、`font`、`padding`。
+   **验收脚本要能数出「原生外观控件 = 0」。**
+3. **按钮要有三种明确形态**(现在全是糊的):
+   - `primary`:实底、无边框、白字
+   - `secondary`:透明底、1px 描边(`rgba(255,255,255,.14)`)、浅字
+   - `ghost`:无底无框、只在 hover/active 时给背景
+   - **筛选 chip 用 secondary**,选中态才用实底。
+4. 描边统一 `1px`,**禁止 2px 以上**(现在有 2px 的)。
+
+### P1:筛选栏重做 —— 用户点名的地方
+
+现状:排序按钮是 30px 高、11px 字、无圆角无描边,一排 5 个挤在一起,视觉噪音大。
+
+要求:
+- 筛选区做成**一个统一的容器**(不是一堆散按钮),内部分组:
+  `状态 | 优先级 | 阶段 | 品类 | 上传人 | 排序`,组间用 1px 竖线或间距区分;
+- chip 高度统一 **28–32px**、字号 **12px**、圆角 `--r-sm`;
+- 选中态:实底 + 白字;未选中:透明底 + 1px 描边 + 次级字色;
+- **hover 要有反馈**(现在完全没有);
+- 排序控件做成 **单选段(segmented)**,视觉上是一整块被分割,不是 5 个独立按钮;
+- 右侧搜索框与左侧筛选**底边对齐**、等高。
+
+### P2:卡片等高 + 底部收尾 —— 消灭参差和大空白
+
+1. **同一行卡片必须等高**:用 `align-items: stretch` + 卡内 `display:flex; flex-direction:column`,
+   把「标题区 / 指标区 / 来源区」用 `margin-top:auto` 把来源区压到底,**中间空就用 flex-grow 吃掉**,
+   但**不允许出现可见的大块空白**(空白高度 > 24px 判失败)。
+2. **底线对齐**:同一行卡片的「分隔线 / 按钮 / 来源行」必须落在同一 y 坐标(±2px)。
+3. **底部空白要吃掉**:三选一,你决定并说明理由:
+   - (a) 卡片区下方加「本页统计条」(本期新增/已立项/待评估),
+   - (b) 改成分页 + 页脚操作条(上一页/下一页/每页数),
+   - (c) 让卡片 grid 用 `auto-fill` 撑成 4 列,减少留白。
+   **我倾向 (b)**:既解决空白又符合「操作逻辑更丰富」。
+
+### P3:工作台左卡失衡
+
+- 「还没选『我是』」空状态卡:**压缩到内容高度**(不再撑满),或者放入真实数据(阶段进度条 + 待办数)。
+- 左右两卡**必须等高**,且左卡不得出现 > 32px 的连续空白。
+
+---
+
+## 二、硬性数值规范(验收脚本按这个量)
+
+| 项 | 要求 |
+|---|---|
+| 圆角种类 | **≤3 种**,且必须是 8/12/20 |
+| 原生外观控件 | **= 0** |
+| 2px 以上描边 | **= 0** |
+| 纯黑 `rgb(0,0,0)` | **= 0**(用 `#18181b` 系) |
+| 字号档数 | **≤5 种**,且禁止 13.3333px |
+| 同排卡片高度差 | **≤2px** |
+| 卡内可见空白 | **≤24px** 连续 |
+| 控件 hover 反馈 | 每个可点元素都有 |
+| 页面底部空白 | **≤120px** |
+
+---
+
+## 三、必须交付的东西
+
+1. `scripts/verify-visual-v12.mjs` —— **实测脚本**,跑起来打印上表每一项的实际测量值,任一项不达标 `exit 1`。
+   **不接受「已经改了」的口述,必须打印数字。**
+2. `docs/v12-before-after.md` —— 每项改动的前后实测值对照表(左列「改前实测」、右列「改后实测」)。
+3. 截图:`docs/screenshots/v12/` 下桌面 1440 与手机 393 各 4 页(工作台/卡池/报告中心/阶段)。
+
+## 四、禁止事项
+
+- **不改交互习惯**:按钮位置、筛选逻辑、分页行为一律不动(这是用户的既有要求)。
+- 不加装饰性渐变、发光、玻璃堆叠(v10 已定:克制)。
+- 不为了「看起来丰富」塞无意义图标。
+- 不用 `transition: all`。
+- 不改 `src/core/` 下任何纯逻辑。
+- 单测 `test/*.test.js` 必须保持 46/46。
+
+## 五、Claude Code 执行提示
+
+- 单轮会撞 turn 上限,**按 P0 → P1 → P2 → P3 顺序做,每完成一个 P 就落盘**,不要全做完再存。
+- 先写 `verify-visual-v12.mjs` 的**测量部分**(只打印不改),看清现状再动手改。
+- 每改完一个 P,跑一次脚本,把数字记进 `docs/v12-before-after.md`。

+ 34 - 19
docs/v11b-audit.json

@@ -109,12 +109,12 @@
         },
         },
         {
         {
           "file": "src/layout/kf-app.js",
           "file": "src/layout/kf-app.js",
-          "line": 461,
+          "line": 455,
           "text": "/* 关闭动效:动画本身(不只 0.01ms 的时长)一起关掉 —— prefers-reduced-motion 与 data-motion=\"off\" 都走这条 */"
           "text": "/* 关闭动效:动画本身(不只 0.01ms 的时长)一起关掉 —— prefers-reduced-motion 与 data-motion=\"off\" 都走这条 */"
         },
         },
         {
         {
           "file": "src/layout/kf-app.js",
           "file": "src/layout/kf-app.js",
-          "line": 462,
+          "line": 456,
           "text": "@media (prefers-reduced-motion: reduce) { :host::before, :host::after { animation: none; }"
           "text": "@media (prefers-reduced-motion: reduce) { :host::before, :host::after { animation: none; }"
         },
         },
         {
         {
@@ -141,91 +141,106 @@
       "no": 1,
       "no": 1,
       "name": "装饰性渐变 / 渐变文字标题",
       "name": "装饰性渐变 / 渐变文字标题",
       "hit": false,
       "hit": false,
-      "evidence": "渐变文字(background-clip:text):0 处。装饰渐变只出现在玻璃边(--kf-edge,135/160deg 发丝边)与光场底色,不是标题上的花活;被删过的那处「条形蓝→紫渐变」可在 git 历史里看到。"
+      "evidence": "渐变文字(background-clip:text):0 处。装饰渐变只出现在玻璃边(--kf-edge,135/160deg 发丝边)与光场底色,不是标题上的花活;被删过的那处「条形蓝→紫渐变」可在 git 历史里看到。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 2,
       "no": 2,
       "name": "glow 光晕 / blob 色块 / 条纹纹理 / 网格背景",
       "name": "glow 光晕 / blob 色块 / 条纹纹理 / 网格背景",
       "hit": false,
       "hit": false,
-      "evidence": "blur 光晕(非 0 扩散的发光 box-shadow)运行时 0 处;conic 渐变 0 处;背景光场是 kf-app::before 上 5 个 radial-gradient(项目设计基色,static 动画 64s),不是装饰性 blob。"
+      "evidence": "blur 光晕(非 0 扩散的发光 box-shadow)运行时 0 处;conic 渐变 0 处;背景光场是 kf-app::before 上 5 个 radial-gradient(项目设计基色,static 动画 64s),不是装饰性 blob。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 3,
       "no": 3,
       "name": "玻璃效果用在所有卡片上",
       "name": "玻璃效果用在所有卡片上",
-      "hit": false,
-      "evidence": "内容卡 kf-glass-card 35 张:白面 .72 + blur(8px)(只求「字能压住背景」,不是全页玻璃);浮层 .kf-glass 2 处,其中 2 处在导航/顶栏/抽屉里。"
+      "hit": true,
+      "evidence": "内容卡 kf-glass-card 仍是「白面 .72 + blur(8px)」的玻璃层(view-workbench.js:172-173 的 .grid2 两张卡、各页 .gcard / .filters、机会卡外壳都是它);真正的浮层(导航 aside.side.kf-glass.is-panel、kf-drawer、筛选 sheet)也是玻璃。即:玻璃没有限定在浮层。",
+      "verdict": "可接受(暂不修):要把内容卡换成实白面属于视觉改版,v11c 任务书明确「不是视觉改版」,留到下一轮视觉任务"
     },
     },
     {
     {
       "no": 4,
       "no": 4,
       "name": "彩色左边框告警条",
       "name": "彩色左边框告警条",
       "hit": false,
       "hit": false,
-      "evidence": ".alert 已改成 6% 染色 + 1px 同色描边(tokens.js 的 .alert 规则),运行时 border-left-color ≠ border-top-color 的元素:0 处。"
+      "evidence": ".alert 已改成 6% 染色 + 1px 同色描边(tokens.js 的 .alert 规则),运行时 border-left-color ≠ border-top-color 的元素:0 处。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 5,
       "no": 5,
       "name": "emoji 当图标(应使用 SVG)",
       "name": "emoji 当图标(应使用 SVG)",
       "hit": false,
       "hit": false,
-      "evidence": "图标全部来自 components/icons.js 的 SVG 路径(24×24 stroke 1.9);emoji 命中的元素只算到文字里的「→」「·」这类标点,不算 emoji。"
+      "evidence": "图标全部来自 components/icons.js 的 SVG 路径(24×24 stroke 1.9);emoji 命中的元素只算到文字里的「→」「·」这类标点,不算 emoji。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 6,
       "no": 6,
       "name": "max-height:999px 展开动画",
       "name": "max-height:999px 展开动画",
       "hit": false,
       "hit": false,
-      "evidence": "grep max-height: 9999?px → 0 处(无);展开用 grid-template-rows: 0fr → 1fr(report-card.js)。"
+      "evidence": "grep max-height: 9999?px → 0 处(无);展开用 grid-template-rows: 0fr → 1fr(report-card.js)。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 7,
       "no": 7,
       "name": "transition: all",
       "name": "transition: all",
       "hit": false,
       "hit": false,
-      "evidence": "源码 0 处;运行时 0 个元素 computed transition-property 为 all(共扫描 405 个带过渡的元素)。"
+      "evidence": "源码 0 处;运行时 0 个元素 computed transition-property 为 all(共扫描 405 个带过渡的元素)。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 8,
       "no": 8,
       "name": "阴影超过 3 档",
       "name": "阴影超过 3 档",
       "hit": false,
       "hit": false,
-      "evidence": "令牌层 --elev-1/2/3 共 3 档(--sh-* 只是基本量,--ring-1 是 1px 描边层,组件只引用语义名);运行时静止 + 悬停态的阴影几何共 4 种(已剥掉颜色):C0px1px2px0px | C0px0px0px1px | C0px12px32px-8px | C0px-12px32px-8px;落在三档之外的 0 种。"
+      "evidence": "令牌层 --elev-1/2/3 共 3 档(--sh-* 只是基本量,--ring-1 是 1px 描边层,组件只引用语义名);运行时静止 + 悬停态的阴影几何共 4 种(已剥掉颜色):C0px1px2px0px | C0px0px0px1px | C0px12px32px-8px | C0px-12px32px-8px;落在三档之外的 0 种。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 9,
       "no": 9,
       "name": "纯 #000 / #FFF",
       "name": "纯 #000 / #FFF",
       "hit": false,
       "hit": false,
-      "evidence": "computed color / background 为纯黑 rgb(0,0,0) 的元素:0 处;纯白 #FFF 只用于深色底上的反白文字(--kf-ink-inv),0 处元素背景为纯白。"
+      "evidence": "computed color / background 为纯黑 rgb(0,0,0) 的元素:0 处;纯白 #FFF 只用于深色底上的反白文字(--kf-ink-inv),0 处元素背景为纯白。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 10,
       "no": 10,
       "name": "所有间距都是同一个值(均匀呼吸)",
       "name": "所有间距都是同一个值(均匀呼吸)",
       "hit": false,
       "hit": false,
-      "evidence": "版式距离(卡内 / 卡间 / 区块间)实测 4 种:16 / 12 / 24 / 48 四级,不是同一个值(详见 audit-spacing.mjs 输出)。"
+      "evidence": "版式距离(卡内 / 卡间 / 区块间)实测 4 种:16 / 12 / 24 / 48 四级,不是同一个值(详见 audit-spacing.mjs 输出)。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 11,
       "no": 11,
       "name": "box-shadow 做凸起 / 凹陷(非克制的拟物)",
       "name": "box-shadow 做凸起 / 凹陷(非克制的拟物)",
       "hit": false,
       "hit": false,
-      "evidence": "凸起 / 凹陷拟物:inset 阴影只保留 --kf-hi(两条 1px 内高光,tokens.js:143)与抽屉顶边的柔光;3D 拟物阴影 0 处。"
+      "evidence": "凸起 / 凹陷拟物:inset 阴影只保留 --kf-hi(两条 1px 内高光,tokens.js:143)与抽屉顶边的柔光;3D 拟物阴影 0 处。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 12,
       "no": 12,
       "name": "加班式 hover:跳动 > 2px、放大",
       "name": "加班式 hover:跳动 > 2px、放大",
       "hit": false,
       "hit": false,
-      "evidence": "真实指针悬停后 transform:matrix(1, 0, 0, 1, 0, -1) —— matrix 的平移分量是 -1px,缩放分量是 1(没有放大);静止态 none。"
+      "evidence": "真实指针悬停后 transform:matrix(1, 0, 0, 1, 0, -1) —— matrix 的平移分量是 -1px,缩放分量是 1(没有放大);静止态 none。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 13,
       "no": 13,
       "name": "圆角 > 16px",
       "name": "圆角 > 16px",
       "hit": true,
       "hit": true,
-      "evidence": "运行时圆角最大值 50px(令牌 --kf-radius: 16px;超过 16 的只有圆形头像 / 环这类「必须圆」的元素:4 个)。"
+      "evidence": "运行时圆角最大值 50px(令牌 --kf-radius: 16px;超过 16 的只有圆形头像 / 环这类「必须圆」的元素:4 个)。",
+      "verdict": "可接受(圆形头像 / 进度环,不是设计圆角)"
     },
     },
     {
     {
       "no": 14,
       "no": 14,
       "name": "强调色面积 > 5%",
       "name": "强调色面积 > 5%",
       "hit": false,
       "hit": false,
-      "evidence": "工作台整屏截图里接近强调色(#5B5BD6 系:蓝 >> 红、R≈G)的像素占比 0.36%(采样基准:1440×960 整页首屏)。"
+      "evidence": "工作台整屏截图里接近强调色(#5B5BD6 系:蓝 >> 红、R≈G)的像素占比 0.36%(采样基准:1440×960 整页首屏)。",
+      "verdict": "未命中"
     },
     },
     {
     {
       "no": 15,
       "no": 15,
       "name": "所有卡片同时入场无错峰",
       "name": "所有卡片同时入场无错峰",
       "hit": false,
       "hit": false,
-      "evidence": ".kf-enter 的 animation-delay 实测取值:[[\"0s\",\"0.04s\",\"0.08s\",\"0.12s\",\"0.16s\",\"0.2s\"],[\"0s\"],[\"0s\",\"0.04s\",\"0.08s\",\"0.12s\",\"0.16s\"]](--i × 40ms 阶梯;kf-app 还会把首屏外元素挂 pending,滚到再播)。"
+      "evidence": ".kf-enter 的 animation-delay 实测取值:[[\"0s\",\"0.04s\",\"0.08s\",\"0.12s\",\"0.16s\",\"0.2s\"],[\"0s\"],[\"0s\",\"0.04s\",\"0.08s\",\"0.12s\",\"0.16s\"]](--i × 40ms 阶梯;kf-app 还会把首屏外元素挂 pending,滚到再播)。",
+      "verdict": "未命中"
     }
     }
   ],
   ],
   "meta": {
   "meta": {
@@ -238,6 +253,6 @@
       "/ledger",
       "/ledger",
       "/stages"
       "/stages"
     ],
     ],
-    "filesScanned": 58
+    "filesScanned": 61
   }
   }
 }
 }

+ 84 - 0
docs/v11b-audit.md

@@ -0,0 +1,84 @@
+# v11b 15 条反例自查(补交,v11c P1)
+
+数据来源:`node scripts/audit-examples.mjs --json docs/v11b-audit.json`(v11c 重新跑过一遍,61 个源文件 + 运行时 computed + 工作台整屏像素采样)。
+每条给「命中 / 未命中」、证据(`文件:行号` 或实测数值)、以及命中的判定(可接受 / 已修 / 未修)。
+
+| # | 反例 | 命中 | 判定 |
+|---|---|---|---|
+| 1 | 装饰性渐变 / 渐变文字标题 | 否 | — |
+| 2 | glow 光晕 / blob / 条纹 / 网格背景 | 否 | — |
+| 3 | **玻璃效果用在所有卡片上** | **是** | 可接受(暂不修)—— 换成实白面属于视觉改版 |
+| 4 | 彩色左边框告警条 | 否 | — |
+| 5 | emoji 当图标 | 否 | — |
+| 6 | `max-height:999px` 展开动画 | 否 | — |
+| 7 | `transition: all` | 否 | — |
+| 8 | 阴影超过 3 档 | 否 | — |
+| 9 | 纯 `#000` / `#FFF` | 否 | — |
+| 10 | 所有间距同一个值 | 否 | — |
+| 11 | `box-shadow` 做凸起 / 凹陷 | 否 | — |
+| 12 | hover 跳动 > 2px 或放大 | 否 | — |
+| 13 | 圆角 > 16px | 是(圆形元素) | 可接受(圆形,不是设计圆角) |
+| 14 | 强调色面积 > 5% | 否 | — |
+| 15 | 所有卡片同时入场无错峰 | 否 | — |
+
+命中 2 条,其中 0 条属于「已修」、2 条属于「可接受」。
+
+---
+
+## 逐条
+
+### 1. 装饰性渐变 / 渐变文字标题 —— 未命中
+渐变文字(`background-clip: text`):0 处。源码里出现的 `linear-gradient` 只有两类:玻璃芯片边(`--kf-glass-chip` / `--kf-edge-chip`,`src/pages/card-toolbar.js:53-55`)与背景光场,不是标题上的花活。条形的「蓝→紫渐变」在 v11 已删,改成单色(`src/pages/view-stages.js:64`,`.ov .bar i { background: var(--tone, …) }`)。
+
+### 2. glow 光晕 / blob 色块 / 条纹纹理 / 网格背景 —— 未命中
+运行时非 0 扩散的发光阴影 0 处、`conic-gradient` 0 处、`repeating-linear-gradient` 0 处。背景光场是 `kf-app::before` 上 5 个 `radial-gradient`(项目设计基色,64s 极慢漂移),属于背景层而不是卡片旁的装饰 blob。
+
+### 3. 玻璃效果用在所有卡片上 —— **命中**(可接受,暂不修)
+- 内容卡 `kf-glass-card` 仍是「白面 .72 + `backdrop-filter: blur(8px)`」:工作台 `.grid2` 两张卡(`src/pages/view-workbench.js:172-173`)、成员页 `.gcard`(`src/pages/view-members.js:90`)、机会卡外壳、各页筛选卡都走它。
+- 浮层也是玻璃:导航 `aside.side.kf-glass.is-panel`(`src/layout/kf-nav.js:128`)、`kf-drawer`、手机筛选 sheet(`src/pages/filter-sheet.js:94`)。
+- 即反例描述成立:玻璃没有限定在浮层。**未修**:任务书说「内容卡应为实白面 + 发丝描边」,这是视觉改版;v11c 是架构收尾,明确「不是视觉改版」「不要改任何视觉参数」。留到下一轮视觉任务。玻璃光场依赖宿主透明(见 `kf-glass-needs-transparent-host`),换实白面要连带改 `backdrop-filter` 链路,不能在收尾轮顺手做。
+
+### 4. 彩色左边框告警条 —— 未命中
+`border-left-color ≠ border-top-color` 的运行时元素:0 处。告警样式 `.alert` 改成 6% 同色染色 + 1px 同色描边(`src/styles/tokens.js` 的 `.alert` 规则)。
+
+### 5. emoji 当图标 —— 未命中
+图标全部来自 `src/components/icons.js`(24×24 SVG 路径,stroke 1.9)。文本里出现的「→」「·」「↗」「▲」是排版标点(箭头 / 间隔点 / 增量三角),不是 emoji 字符(U+2192 等,`So` 类)。
+
+### 6. `max-height:999px` 展开动画 —— 未命中
+`grep -rn "max-height: *999" src` → 0 处。展开用 `grid-template-rows: 0fr → 1fr`(`src/pages/report-card.js:73`,运行时实测 `.detail` 闭合 `20px 0px` → 展开 `20px 41.59px`)。
+
+### 7. `transition: all` —— 未命中
+源码 0 处;运行时遍历全部 shadow root,`transition-property` 为 `all` 的元素 0 个(共扫描 405 个带过渡的元素)。
+
+### 8. 阴影超过 3 档 —— 未命中
+令牌层 `--elev-1/2/3` 共 3 档;组件只引用语义名。运行时(静止 + hover)阴影几何去色后共 4 种:`0 1px 2px 0`(1px 描边层 `--ring-1`,不算投影)、`0 12px 32px -8px`(elev-3)、`0 -12px 32px -8px`(抽屉上浮),以及静止态无阴影。投影几何 3 种,未超档。
+
+### 9. 纯 `#000` / `#FFF` —— 未命中
+computed `color` / `background-color` 为 `rgb(0,0,0)` 的元素 0 处;`#FFF` 只在 `--kf-ink-inv`(深色底反白文字)里出现,作为元素背景的纯白 0 处。
+
+### 10. 所有间距同一个值 —— 未命中
+版式距离四档:卡内 padding 16 / 卡内 gap 12 / 卡片之间 24 / 区块之间 48(令牌 `--kf-space-card` / `--kf-space-in` / `--kf-space-card-gap` / `--kf-space-section`,`src/styles/tokens.js`;使用见 `src/pages/shared.js` 的 `.p-sec` 注释)。另用 4 / 8 做行内小缝(4pt 栅格)。详见 `docs/v11b-spacing.txt` 与 `scripts/audit-spacing.mjs`。
+
+### 11. `box-shadow` 做凸起 / 凹陷 —— 未命中
+拟物:3D 凸起 / 凹陷阴影 0 处。`inset` 阴影只有 `--kf-hi`(两条 1px 内高光,`src/styles/tokens.js:143`)与抽屉顶边柔光,是发丝高光不是拟物浮雕。
+
+### 12. hover 跳动 > 2px 或放大 —— 未命中
+真实指针悬停后 `transform = matrix(1, 0, 0, 1, 0, -1)`:平移 -1px,缩放 1(无放大);静止态 `none`。过渡 150ms。
+
+### 13. 圆角 > 16px —— 命中(可接受)
+设计圆角令牌 `--kf-radius: 16px`。运行时最大值 50px,全部来自圆形元素:头像、进度环外框(4 个)。圆形不是设计圆角,判定可接受;没有把圆形改成 16px 方形。
+
+### 14. 强调色面积 > 5% —— 未命中
+工作台 1440×960 首屏截图里接近强调色(`#5B5BD6` 系:蓝 ≫ 红、R≈G)的像素占比 **0.36%**。阈值 5%。
+
+### 15. 所有卡片同时入场无错峰 —— 未命中
+`.kf-enter` 的 `animation-delay` 实测:`["0s","0.04s","0.08s","0.12s","0.16s","0.2s"]` 等阶梯(`--i × 40ms`,`src/styles/tokens.js:438` 的 `.kf-enter`),时长 400ms;`kf-app` 用 IntersectionObserver 把首屏外元素挂住、滚到视口才播。
+
+---
+
+## 复现
+
+```bash
+node scripts/audit-examples.mjs --json docs/v11b-audit.json   # 15 条 + 动效 A1-A7
+node scripts/audit-spacing.mjs                                # 四档间距
+```

+ 149 - 0
docs/v11b-report.md

@@ -0,0 +1,149 @@
+# v11b 交付报告(补交,v11c 实测)
+
+> 本轮(v11c)没有改任何视觉参数。所有数字都是脚本实测(Chrome 153 headless,1440×960),复现命令在每节末尾。
+> 上一轮只有 `docs/v11b-audit.json`,没有报告;本文补上,并标注哪些项在 v11c 重测过。
+
+---
+
+## 0. 架构落点(v11c 主任务)
+
+中英混排 U+2009 只在两处落,不做任何事后 DOM 改写:
+
+| 落点 | 文件 | 管什么 |
+|---|---|---|
+| 数据边界 | `src/services/board-service.js:61` `cjkDeep(buildModel(...))`(v11b 已有,未动) | 模型里所有字符串字段 |
+| 渲染期 | 各模板字符串生成处调 `t()`(= `src/pages/shared.js` 的 `cjkString`),另有 `src/core/kfs/util.js` 的 `fmtDate`/`fmtAgo`/`fmtAgoText` 与 `cjkSpace`、`src/core/taxonomy/taxonomy.js` 的阶段 / 上传方式 label | 模板里**临时拼**的串:`S4 ${name}`、`${n} 小时前`、`${n} 人评分`、`最多 ${n} 份`、`被 ${n} 处支撑` … |
+
+v11c 本轮为 P0 做的改动(`git diff` 可查):
+
+- `shared.js`:导出 `t`;删掉渲染后遍历的 `cjk(node)`(已无调用方,留着会被下一轮误用)
+- `components/cjk.js`:`t()` 的内部规则改为直接调 `cjkString`,与数据边界**同一份** SKIP 规则
+- `core/kfs/util.js`:`fmtDate(…, true)` / `fmtAgo` 在生成处走 `cjkSpace`;新增 `fmtAgoText`(卡住时长)
+- `core/taxonomy/taxonomy.js`:STAGES 的 `label`、`uploadMethodLabel('skill')` 在生成处走 `cjkSpace`
+- `core/library/reports.js`:优先级筛选项 `S 级`、`whenText` 占位句
+- `core/opportunity/opportunity.js`:排序标签 `优先级 S→A→B`
+- 视图 / 组件 / 外壳:`view-workbench`、`view-members`、`view-pool`、`view-ledger`、`view-reports`、`view-stages`、`view-report-type`、`view-report`、`filter-sheet`、`card-toolbar`、`kf-app`、`kf-nav`、`kf-opportunity-card`、`kf-score-panel`、`kf-badge` 各自主持的静态文案逐个包 `t()` 或改到生成函数里
+- `kf-app.js`:删掉抽屉正文的 `requestAnimationFrame` + `createTreeWalker` 事后改写(同类做法,一并移除)
+
+排查中修掉的两个**真 bug**(不是排版问题):
+
+1. `view-reports.js` 里 `c.types.map((t, i) => …)` 的 `t` 把外层的 `t()` 遮蔽了 —— 运行时 `t is not a function`,`/reports` 整页白屏。已把回调参数改名 `ty`。
+2. `view-members.js` 的「卡住」时长原来是 `${m.stuckHours}h`(「卡住 52h」,纯英制混排),且 `stuckHours` 在 `lastActiveAt` 缺失(老数据按会话 createdAt 算)时是 0。改为从模型 `stuckHours` + 模型自带的 `db.now` 反推时刻、经 `fmtAgoText` 生成「62 个小时」/「2 天前」——数字仍来自模型,不从 `Date.now()` 现算。
+
+**仍保留的定时器**:`kf-app.js:79` 的 `setInterval(refreshMs)` 是 v10 就有的**数据轮询**(`boardService.load`),不是全页 DOM 扫描,按任务书不在移除之列。`MutationObserver`、`window.__kfCjk` 全局挂载:`grep` 0 处(只剩注释里说明「为什么不用」)。
+
+---
+
+## 1. 逐项验收表(v11c 重测)
+
+| 项 | 实测值 | 达标线 | 通过 |
+|---|---|---|---|
+| 工作台 U+2009 | 61 | ≥ 20 | ✓ |
+| 8 路由合计 U+2009 | 679 | ≥ 500 | ✓ |
+| 汉字↔拉丁间普通空格残留 | 0 | = 0 | ✓ |
+| `S4 机会验证评估` 第 3 位字符码 | 8201 | 8201 | ✓ |
+| `node scripts/check.mjs` | CHECK OK | CHECK OK | ✓ |
+| `node --test 'test/*.test.js'` | 46 pass / 0 fail | 46/46 | ✓ |
+| 32px 行盒(加 trim) | 23.28px | ≤ 26px | ✓ |
+| 12px / 20px 行盒 | 8.73 / 14.55px | 同比例 | ✓ |
+| 微图表四类出现数 | spark 10 · 环 71 · 对比条 6 · 热力格 5 | 各 ≥ 1 | ✓ |
+| 版式距离档数 | 4(8/12/16/24 在版式层;区块间 48 在令牌) | ≤ 4 | ✓ |
+| 卡面撑高留白 > 24px | 0 处 | 0 | ✓ |
+| `transition: all` | 源码 0 · 运行时 0 | 0 | ✓ |
+| `max-height:999px` | 0 | 0 | ✓ |
+| hover | `translateY(-1px)`,150ms | ≤ 2px,无放大 | ✓ |
+| 数字滚动 | `kf-roll 650ms cubic-bezier(.4,0,.2,1)`,1284→1301 只滚 2 位 | 500–750ms | ✓ |
+| 进场错峰 | `calc(var(--i) * 40ms)`,400ms | 400ms / 40ms | ✓ |
+| reduced-motion | 过渡时长 `1e-05s` | ≈ 0 | ✓ |
+
+逐路由 U+2009(`node scripts/verify-cjk.mjs`):
+
+```
+/                    可见文本节点  143 · U+2009  61 · 普通空格残留 0
+/pool                可见文本节点  368 · U+2009 166 · 普通空格残留 0
+/stages              可见文本节点  555 · U+2009 100 · 普通空格残留 0
+/reports             可见文本节点  337 · U+2009  64 · 普通空格残留 0
+/reports/competitor  可见文本节点  302 · U+2009  41 · 普通空格残留 0
+/reports/voc         可见文本节点  282 · U+2009  53 · 普通空格残留 0
+/members             可见文本节点  176 · U+2009  37 · 普通空格残留 0
+/ledger              可见文本节点  436 · U+2009 157 · 普通空格残留 0
+```
+
+`S4 机会验证评估 · 13 条` → `[83, 52, 8201, …, 49, 51, 8201, 26465]`(S4 后、13 后都是 8201)。
+
+复现:`node scripts/verify-cjk.mjs`、`node scripts/verify-textbox.mjs`、`node scripts/check.mjs`、`node --test 'test/*.test.js'`、`node scripts/verify-viz.mjs`、`node scripts/audit-examples.mjs --json docs/v11b-audit.json`、`node scripts/audit-spacing.mjs`
+
+**单测改了两行期望值**(与行为变更同源,不是放宽门槛):`test/report.test.js:83` `fmtAgo` 的 `3 小时前` → `3 小时前`;`test/model.test.js:78` 阶段 label `S4 机会验证评估` → `S4 机会验证评估`。两项都是「生成处插 U+2009」的直接后果。
+
+---
+
+## 2. text-box 三档对比(`node scripts/verify-textbox.mjs`)
+
+inline-block,`line-height: 1`,文本「1284」:
+
+| 字号 | 不加 trim | 加独立长属性(trim-both / cap alphabetic) | 变化 | 行盒 / 字号 |
+|---|---|---|---|---|
+| 12px 正文 | 12.00px | 8.73px | -27.2% | 1.00em → 0.73em |
+| 20px 小标题 | 20.00px | 14.55px | -27.3% | 1.00em → 0.73em |
+| 32px 主数值 | 32.00px | 23.28px | -27.2% | 1.00em → 0.73em |
+
+关键细节(代码里保留了注释):
+
+- **`display:flex` 容器里的 trim 不生效**:对照实验 32px + trim-both 但容器 flex → computed 仍是 `trim-both`,行盒实测 32.00px。值容器必须 `display:block` + `height:max-content`。
+- **不用简写 `text-box`**:Chrome 153 丢弃简写,只认 `text-box-trim` / `text-box-edge` 两个长属性。
+- 页面真实元素:工作台 4 个 32px KPI、报告类型页 4 个 32px KPI 全部 23.28px;S1–S5 徽标 16px、`A 级` 徽标 24px 均为 trim 后的 cap 高。
+
+---
+
+## 3. 微图表四类:出现位置 + DOM 计数(`node scripts/verify-viz.mjs`)
+
+计数为 7 个路由合计。每类都满足:数字在图表旁(图表本身 `aria-hidden`,读屏读数字)、纯 CSS/SVG、无渐变填充、无第三方库。
+
+| 类型 | 出现位置 | DOM 计数 | 规格实测 |
+|---|---|---|---|
+| sparkline | 工作台 KPI 卡(4 个数字卡右下角,`view-workbench.js` `.kpi kf-spark`,7 日趋势);报告中心 5 张类型片的「份数」右侧(`view-reports.js:151`);报告类型页「份数」KPI(`view-report-type.js:253`) | 10 | 56×16px,历史柱 .35 / 最新 .95 |
+| 进度环 / 进度条 | 环:五阶段页每张阶段卡的「份数」环(`view-stages.js:108`,40px);报告类型页 4 个 KPI 环;机会卡评分面板平均分环(`kf-score-panel.js:89`,44px);报告卡 mini 环(`report-card.js` `.rc .ring`)。条:五阶段页每卡占比条 `.ov .bar` 与混合条 `.ov .mix`(`view-stages.js:63-65`) | 71(环) | 环 40–44px 描边 3px;条高 4px 圆角 2px 单色;环心 / 左侧印数字 |
+| 对比条 | 报告中心「分类」区块 5 张类型片各自的 S1–S5 份数分布(`view-reports.js:153` `kf-bars stack`);成员页「成员产出对比」前 8 位(`view-members.js:91`) | 6 | 最大值占满 100%,数字右对齐(`最多 N 份`) |
+| 热力小格 | 台账页「阶段 × 层交叉分布」(`view-ledger.js:178`,5 行 × 若干列,每行右侧小计);台账 `bar2`(`view-ledger.js:88`)里横向的成员活跃度小格 | 5 | 12px 方形,圆角 2px,4 档不透明度 |
+
+截图:`docs/screenshots/v11b/viz-*.png`。
+
+---
+
+## 4. 间距分级与「我的推进」框
+
+- 四档:卡内 padding 16 / 卡内 gap 12 / 卡片之间 24 / 区块之间 48(`src/styles/tokens.js` 的 `--kf-space-card` / `--kf-space-in` / `--kf-space-card-gap` / `--kf-space-section`)。
+- `node scripts/audit-spacing.mjs`:版式层实测值 8px×2、12px×2、16px×22、24px×4 —— **4 种**(报警线 > 6),四档外 0 种。48 用在区块之间(`.p-sec` margin)。
+- 25% 缩放截图:`docs/screenshots/v11b/spacing-25-*.png`(7 页),块与块之间的缝肉眼可分出层级。
+- 「我的推进」虚线占位框:数据来自 `forMe`,有数据放阶段完成度,`.grid2 { align-items: start }` + `.grid2 > kf-glass-card { align-self: start }`(`view-workbench.js:40,42`),卡收缩到内容高。
+- stretch 扫描(`audit-spacing.mjs` 的「撑高留白」):卡面内容外空白 > 24px 的 **0 处**(`.grid2` 上一轮 220px 的问题已不在)。
+
+---
+
+## 5. 字数复核(v11c 实测)
+
+口径:可见文本节点里的中文字符 + 中文标点(`[一-龥 -〿＀-￯]`,即汉字、CJK 标点、全角符号),排除 SVG 内文本(`textNodesIn` 本来只收 HTML 文本节点,且跳过 `svg` 子树)、`aria-label`(属性不算文本节点)、隐藏元素(用与 verify-cjk 相同的 `visible` 判定),另排除含 URL / 文件名的节点;页外的导航与顶栏不计。
+
+| 页面 | 上限 | 实测 | 通过 |
+|---|---|---|---|
+| 工作台 `/` | 350 | **422** | ✗ |
+| 报告类型页 `/reports/voc` | 300 | **780** | ✗ |
+| 报告中心 `/reports` | 200 | **818** | ✗ |
+
+(参考:`/stages` 1676、`/pool` 1190、`/ledger` 786、`/members` 293,这三页任务书没给上限。)
+
+**未达成**:三页都超。工作台超 72;报告中心 / 类型页超得多,是因为卡片自带来源行(谁 · Agent · 方式 · 时间)、筛选条、每个类型的阶段条旁都有文字。压缩到上限要删信息或改卡片结构,属于视觉改版,v11c 不做,如实列在这里。
+
+---
+
+## 6. 未达成 / 没验到的
+
+1. **字数**:工作台 422/350、报告类型页 780/300、报告中心 818/200,均超(见第 5 节)。未调低口径凑数。
+2. **反例 #3(玻璃用在所有卡片)**:内容卡还是玻璃(白面 + blur)。改成实白面是视觉改版,本轮不做;详见 `docs/v11b-audit.md` 第 3 条。
+3. **反例 #13(圆角 > 16px)**:圆形头像 / 进度环 50px,判定可接受,未改。
+4. **「进度条」单列计数**:`verify-viz.mjs` 的 KIND 只点了 spark / ring / bars / heat 四类,阶段卡上的 4px 进度条是 `kf-bars` 之外的 `.ov .bar`(纯 CSS),没有独立 DOM 计数;本文给的是源码位置,不是脚本数字。规格(高 4px / 圆角 2px)是源码读出来的。
+5. **sparkline 尺寸**:任务书写宽 64–80px,实测 56×16px(`--h: 16px`)。v11b 定的,本轮不改视觉,如实记录不一致。
+6. **数据轮询定时器**:`kf-app.js:79` 的 `setInterval` 仍在(v10 起的数据刷新,不是全页扫描)。
+7. **滚动条 / 25% 截图的人工确认**:截图已生成,但「能看出块在呼吸」是肉眼判断,没有一个可自动化的阈值指标。
+8. **`scripts/v11b-patch.mjs`**:文件仍在磁盘上(未删除),但源码里 0 处引用;按「我提交」的约定没动它,建议你提交前一并删掉。
+9. **verify-viz 的 `阶段 × 类型交叉`**:任务书写的热力格位置之一是「阶段 × 类型交叉」,落地的是「阶段 × 层」(台账页)。类型维度没有做热力格。

+ 5 - 0
scripts/audit-examples.mjs

@@ -311,6 +311,11 @@ console.log(`A7 reduced-motion 下时长 —— ${JSON.stringify(out.motion.redu
 console.log(`\n## 15 条反例\n`);
 console.log(`\n## 15 条反例\n`);
 for (const e of out.examples.filter((x) => x.no && !/^A/.test(x.no))) console.log(`${e.hit ? '✗ 命中' : '✓ 未命中'}  #${e.no} ${e.name}\n    ${e.evidence}`);
 for (const e of out.examples.filter((x) => x.no && !/^A/.test(x.no))) console.log(`${e.hit ? '✗ 命中' : '✓ 未命中'}  #${e.no} ${e.name}\n    ${e.evidence}`);
 
 
+/* 判定备注(v11c 补):脚本只报「命中/未命中」,「可接受 / 已修」由人判,写在这里随 JSON 一起落盘。
+   #3 内容卡仍是玻璃(白面 + blur)—— 命中且未修:改成实白面属于视觉改版,v11c 只做架构收尾。
+   #13 圆角最大值来自圆形头像 / 进度环 —— 命中但可接受:圆形不是设计圆角。 */
+const VERDICT = { 3: '未修(可接受:玻璃只该给浮层,但内容卡换成实白面属于视觉改版,本轮不做,见会审结论)', 13: '可接受(圆形,不是设计圆角)' };
+for (const e of out.examples) if (typeof e.no === 'number') e.verdict = VERDICT[e.no] || '未命中';
 if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify(out, null, 2));
 if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify(out, null, 2));
 await browser.close();
 await browser.close();
 srv?.kill();
 srv?.kill();

+ 2 - 1
src/app.js

@@ -13,4 +13,5 @@ adoptDocument();
 // 启动横幅:验收时在控制台确认版本 / 数据源(不含任何凭据)
 // 启动横幅:验收时在控制台确认版本 / 数据源(不含任何凭据)
 console.info(`[康师傅看板] v5 · ${CONFIG.parseBase} · appId=${CONFIG.appId} · ${new Date().toISOString()}`);
 console.info(`[康师傅看板] v5 · ${CONFIG.parseBase} · appId=${CONFIG.appId} · ${new Date().toISOString()}`);
 
 
-// 中英混排 U+2009(v11b P0-b):路由变化时才跑一次的 cjk() 不改 kf-app 本身与顶栏;
+// 中英混排 U+2009 只在两处落:数据边界(services/board-service.js 的 cjkDeep)与
+// 渲染期字符串生成处(pages/shared.js 的 t = cjkString)。这里不做任何事后 DOM 改写。

+ 5 - 3
src/components/cjk.js

@@ -16,7 +16,8 @@
  *   3. 缓存:同一字符串只算一次(页面大量重复的单位/标签)。
  *   3. 缓存:同一字符串只算一次(页面大量重复的单位/标签)。
  */
  */
 
 
-import { spaceCJK, THIN } from '../utils/type.js';
+import { THIN } from '../utils/type.js';
+import { cjkString } from '../utils/cjk-deep.js';
 
 
 /** URL / 文件路径 / 纯代码串:不动 */
 /** URL / 文件路径 / 纯代码串:不动 */
 const SKIP = /^(?:https?:\/\/|mailto:|\/|\.\/|\.\.\/)|[\w-]+\.(?:js|mjs|json|md|css|png|jpg|jpeg|svg|webp|html?|pdf|sh)\b/;
 const SKIP = /^(?:https?:\/\/|mailto:|\/|\.\/|\.\.\/)|[\w-]+\.(?:js|mjs|json|md|css|png|jpg|jpeg|svg|webp|html?|pdf|sh)\b/;
@@ -41,8 +42,9 @@ export function t(s) {
   const hit = cache.get(s);
   const hit = cache.get(s);
   if (hit !== undefined) return hit;
   if (hit !== undefined) return hit;
 
 
-  // URL / 文件名出现在句子里时逐段跳过:按空白切,坏段原样拼回
-  const out = SKIP.test(s.trim()) ? s : spaceCJK(s);
+  // 规则与数据边界(cjk-deep.js 的 cjkString)完全同一份:URL / 文件名 / ISO / 标识符跳过,其余 spaceCJK。
+  // SKIP 预判只是省一次正则;最终判定以 cjkString 为准。
+  const out = SKIP.test(s.trim()) ? s : cjkString(s);
 
 
   if (cache.size >= CACHE_MAX) cache.clear();
   if (cache.size >= CACHE_MAX) cache.clear();
   cache.set(s, out);
   cache.set(s, out);

+ 5 - 5
src/components/kf-opportunity-card.js

@@ -20,7 +20,7 @@ import './kf-glass-card.js';
 import { fmtDate } from '../core/index.js';
 import { fmtDate } from '../core/index.js';
 import { t } from './cjk.js';
 import { t } from './cjk.js';
 
 
-const METHOD_LABEL = { session: '会话上传', skill: 'Skill 上传', manual: '手工录入' };
+const METHOD_LABEL = { session: '会话上传', skill: t('Skill 上传'), manual: '手工录入' };
 
 
 export class KfOpportunityCard extends LitElement {
 export class KfOpportunityCard extends LitElement {
   static properties = {
   static properties = {
@@ -154,7 +154,7 @@ export class KfOpportunityCard extends LitElement {
           <div class="nums">
           <div class="nums">
             <div class="n score">
             <div class="n score">
               <div class="v ${s.avg == null ? 'none' : ''}">${s.avg == null ? '暂无评分' : s.avg}<small>${s.avg == null ? '' : '分'}</small></div>
               <div class="v ${s.avg == null ? 'none' : ''}">${s.avg == null ? '暂无评分' : s.avg}<small>${s.avg == null ? '' : '分'}</small></div>
-              <div class="l">${s.n ? t(`${s.n} 人评分`) : '待第 1 人'}</div>
+              <div class="l">${s.n ? t(`${s.n} 人评分`) : t('待第 1 人')}</div>
             </div>
             </div>
             <div class="n">
             <div class="n">
               <div class="v">${o.evidenceLabel}<small>证据</small></div>
               <div class="v">${o.evidenceLabel}<small>证据</small></div>
@@ -162,14 +162,14 @@ export class KfOpportunityCard extends LitElement {
             </div>
             </div>
             <div class="n">
             <div class="n">
               <div class="v">${o.quoteCount}<small>条引用</small></div>
               <div class="v">${o.quoteCount}<small>条引用</small></div>
-              <div class="l">被 ${o.citeCount} 处支撑</div>
+              <div class="l">${t(`被 ${o.citeCount} 处支撑`)}</div>
             </div>
             </div>
           </div>
           </div>
 
 
           <div class="dec">
           <div class="dec">
             ${dec
             ${dec
-              ? html`决策:<b>${dec.label}</b>${dec.reason ? html`<span class="kf-muted"> · ${dec.reason}</span>` : ''}<span class="kf-muted">(${dec.by || '未署名'} · ${fmtDate(dec.at, true)})</span>`
-              : html`<span class="kf-muted">决策:尚无决策,${o.status === 'candidate' ? '等待评估' : '等待补充记录'}</span>`}
+              ? html`${t('决策:')}<b>${dec.label}</b>${dec.reason ? html`<span class="kf-muted"> · ${dec.reason}</span>` : ''}<span class="kf-muted">${t(`(${dec.by || '未署名'} · ${fmtDate(dec.at, true)})`)}</span>`
+              : html`<span class="kf-muted">${t('决策:尚无决策,')}${o.status === 'candidate' ? '等待评估' : '等待补充记录'}</span>`}
           </div>
           </div>
 
 
           <div class="src">
           <div class="src">

+ 5 - 5
src/components/kf-score-panel.js

@@ -71,7 +71,7 @@ export class KfScorePanel extends LitElement {
     this._err = '';
     this._err = '';
     const score = Number(this._score);
     const score = Number(this._score);
     if (!this.user) { this._err = '先选「我是谁」(右上角),再评分'; return; }
     if (!this.user) { this._err = '先选「我是谁」(右上角),再评分'; return; }
-    if (!(score >= 0 && score <= 100) || this._score === '') { this._err = '分数必须在 0–100 之间'; return; }
+    if (!(score >= 0 && score <= 100) || this._score === '') { this._err = t('分数必须在 0–100 之间'); return; }
     this.dispatchEvent(new CustomEvent('score-submit', {
     this.dispatchEvent(new CustomEvent('score-submit', {
       detail: { user: this.user, score, comment: this._comment.trim() },
       detail: { user: this.user, score, comment: this._comment.trim() },
       bubbles: true, composed: true,
       bubbles: true, composed: true,
@@ -88,9 +88,9 @@ export class KfScorePanel extends LitElement {
           ? html`<div class="none">暂无评分</div>`
           ? html`<div class="none">暂无评分</div>`
           : html`<div class="avgwrap"><kf-ring size=${44} .pct=${Math.round(s.avg)} label=${String(s.avg)}></kf-ring><div class="avg kf-num">${s.avg}<small>平均</small></div></div>`}
           : html`<div class="avgwrap"><kf-ring size=${44} .pct=${Math.round(s.avg)} label=${String(s.avg)}></kf-ring><div class="avg kf-num">${s.avg}<small>平均</small></div></div>`}
         <div class="meta">
         <div class="meta">
-          <span><b>${s.n || 0}</b> ${t('人评分')}${s.n > 1 ? '(同一人多次取最后一次)' : ''}</span>
-          <span>人工平均 <b>${s.humanAvg ?? '—'}</b> · Agent 平均 <b>${s.agentAvg ?? '—'}</b></span>
-          ${s.max != null ? html`<span>最高 <b>${s.max}</b> · 最低 <b>${s.min}</b> · 分歧 ${s.max - s.min} 分</span>` : ''}
+          <span>${t(`${s.n || 0} 人评分`)}${s.n > 1 ? '(同一人多次取最后一次)' : ''}</span>
+          <span>${t(`人工平均 ${s.humanAvg ?? '—'} · Agent 平均 ${s.agentAvg ?? '—'}`)}</span>
+          ${s.max != null ? html`<span>${t(`最高 ${s.max} · 最低 ${s.min} · 分歧 ${s.max - s.min} 分`)}</span>` : ''}
         </div>
         </div>
       </div>
       </div>
 
 
@@ -100,7 +100,7 @@ export class KfScorePanel extends LitElement {
             <li class=${x.source === 'agent' ? 'agent' : ''}>
             <li class=${x.source === 'agent' ? 'agent' : ''}>
               <span class="who">${x.user}</span>
               <span class="who">${x.user}</span>
               <span class="s kf-num"><kf-stat inline value=${String(x.score)}></kf-stat></span>
               <span class="s kf-num"><kf-stat inline value=${String(x.score)}></kf-stat></span>
-              <span class="c">${x.comment || html`<span class="kf-muted">(无评语)</span>`}<span class="t">${x.at ? fmtAgo(x.at) : ''}${x.source === 'agent' ? ' · Agent' : ''}</span></span>
+              <span class="c">${x.comment || html`<span class="kf-muted">(无评语)</span>`}<span class="t">${x.at ? fmtAgo(x.at) : ''}${x.source === 'agent' ? t(' · Agent') : ''}</span></span>
             </li>`)}
             </li>`)}
         </ul>` : ''}
         </ul>` : ''}
 
 

+ 1 - 1
src/core/index.js

@@ -11,7 +11,7 @@ export * from './library/types.js';
 export * from './library/reports.js';
 export * from './library/reports.js';
 // 卡片视图专用(与台账的 facetCounts 同名会冲突):只导出卡片流用到的
 // 卡片视图专用(与台账的 facetCounts 同名会冲突):只导出卡片流用到的
 export { cardFacetCounts, cardMatches, cardFacts, evidenceTierOf, categoryOf, thumbOf, sortEntries as sortCardEntries, CARD_DIMS, CARD_SORTS } from './library/reports.js';
 export { cardFacetCounts, cardMatches, cardFacts, evidenceTierOf, categoryOf, thumbOf, sortEntries as sortCardEntries, CARD_DIMS, CARD_SORTS } from './library/reports.js';
-export { toTime, toIso, latest, fmtDate, fmtAgo, countBy, groupBy, cmpNum, distinctPeople, clamp, truncate, numFmt, cjkSpace } from './kfs/util.js';
+export { toTime, toIso, latest, fmtDate, fmtAgo, countBy, groupBy, cmpNum, distinctPeople, clamp, truncate, numFmt, cjkSpace, fmtAgoText } from './kfs/util.js';
 export { DIMS, criteriaOf, queryOf, isEmpty as isFilterEmpty, matches, applyFilter, facetCounts, collectValues, toggle as toggleFacet } from './kfs/ledger.js';
 export { DIMS, criteriaOf, queryOf, isEmpty as isFilterEmpty, matches, applyFilter, facetCounts, collectValues, toggle as toggleFacet } from './kfs/ledger.js';
 export { buildModel } from './kfs/build.js';
 export { buildModel } from './kfs/build.js';
 export { forMe, poolOverview, stuckMembers, liveBoard, stageBoards, dataHealth } from './kfs/stats.js';
 export { forMe, poolOverview, stuckMembers, liveBoard, stageBoards, dataHealth } from './kfs/stats.js';

+ 1 - 1
src/core/kfs/index.js

@@ -1,4 +1,4 @@
-export { toTime, toIso, latest, fmtDate, fmtAgo, countBy, groupBy, cmpNum, distinctPeople, clamp, truncate, numFmt, cjkSpace } from './util.js';
+export { toTime, toIso, latest, fmtDate, fmtAgo, countBy, groupBy, cmpNum, distinctPeople, clamp, truncate, numFmt, cjkSpace, fmtAgoText } from './util.js';
 export * from './ledger.js';
 export * from './ledger.js';
 export * from './build.js';
 export * from './build.js';
 export * from './stats.js';
 export * from './stats.js';

+ 9 - 0
src/core/kfs/util.js

@@ -115,3 +115,12 @@ export function numFmt(n) {
   if (n == null || Number.isNaN(Number(n))) return '—';
   if (n == null || Number.isNaN(Number(n))) return '—';
   return Number(n).toLocaleString('zh-CN');
   return Number(n).toLocaleString('zh-CN');
 }
 }
+
+/** 相对时间(v11c):数字与中文单位之间插 U+2009 —— 「52 个小时」「1 天前」 */
+export function fmtAgoText(ms, now = Date.now()) {
+  const at = toTime(ms);
+  if (!at) return '—';
+  const h = Math.floor(Math.max(0, now - at) / 3600000);
+  if (h < 48) return cjkSpace(`${h} 个小时`);
+  return cjkSpace(`${Math.floor(h / 24)} 天前`);
+}

+ 3 - 3
src/core/library/reports.js

@@ -14,7 +14,7 @@
  *      不编一句话。
  *      不编一句话。
  */
  */
 import { STAGES, stageOf, ARTIFACT_TYPES, EVIDENCE_LEVELS, OPP_EVIDENCE, PRIORITIES, UPLOAD_METHODS, STAGE_TONE, evidenceSegments, uploadMethodLabel } from '../taxonomy/taxonomy.js';
 import { STAGES, stageOf, ARTIFACT_TYPES, EVIDENCE_LEVELS, OPP_EVIDENCE, PRIORITIES, UPLOAD_METHODS, STAGE_TONE, evidenceSegments, uploadMethodLabel } from '../taxonomy/taxonomy.js';
-import { toTime, toIso, fmtAgo } from '../kfs/util.js';
+import { toTime, toIso, fmtAgo, cjkSpace } from '../kfs/util.js';
 import { reportOverview, findingsOf, pipelineSummary } from '../report.js';
 import { reportOverview, findingsOf, pipelineSummary } from '../report.js';
 import { REPORT_TYPES, REPORT_TYPE_KEYS, reportTypeOf } from './types.js';
 import { REPORT_TYPES, REPORT_TYPE_KEYS, reportTypeOf } from './types.js';
 
 
@@ -123,7 +123,7 @@ export function thumbOf(e) {
 export const CARD_DIMS = Object.freeze([
 export const CARD_DIMS = Object.freeze([
   { key: 'stage', label: '阶段', values: () => STAGES.map((s) => ({ v: s.code, label: s.code, tone: STAGE_TONE[s.code] })) },
   { key: 'stage', label: '阶段', values: () => STAGES.map((s) => ({ v: s.code, label: s.code, tone: STAGE_TONE[s.code] })) },
   { key: 'cat', label: '品类', values: () => CATEGORIES.map((c) => ({ v: c, label: c })) },
   { key: 'cat', label: '品类', values: () => CATEGORIES.map((c) => ({ v: c, label: c })) },
-  { key: 'pri', label: '优先级', values: () => PRIORITIES.map((p) => ({ v: p, label: `${p} 级` })) },
+  { key: 'pri', label: '优先级', values: () => PRIORITIES.map((p) => ({ v: p, label: cjkSpace(`${p} 级`) })) },
   { key: 'ev', label: '证据', values: () => [{ v: '高', label: '高' }, { v: '中', label: '中' }, { v: '低', label: '低' }, { v: '待补', label: '待补' }] },
   { key: 'ev', label: '证据', values: () => [{ v: '高', label: '高' }, { v: '中', label: '中' }, { v: '低', label: '低' }, { v: '待补', label: '待补' }] },
   { key: 'm', label: '方式', values: () => Object.entries(UPLOAD_METHODS).map(([k, label]) => ({ v: k, label })) },
   { key: 'm', label: '方式', values: () => Object.entries(UPLOAD_METHODS).map(([k, label]) => ({ v: k, label })) },
   { key: 'who', label: '人', values: () => [] },
   { key: 'who', label: '人', values: () => [] },
@@ -836,5 +836,5 @@ export function stageOutcomeSummary(rows) {
 /* ============================================================
 /* ============================================================
  * 时间格式(页面直接用)
  * 时间格式(页面直接用)
  * ============================================================ */
  * ============================================================ */
-export const whenText = (v, now = Date.now()) => (toIso(v) ? fmtAgo(v, now) : '时间未记录');
+export const whenText = (v, now = Date.now()) => (toIso(v) ? fmtAgo(v, now) : cjkSpace('时间未记录')); // fmtAgo 在生成处插 U+2009;占位句同样过一遍,页面一律 ${whenText(...)} 即可
 export const stageCodeOf = (v) => stageOf(v)?.code || '';
 export const stageCodeOf = (v) => stageOf(v)?.code || '';

+ 2 - 2
src/core/opportunity/opportunity.js

@@ -11,7 +11,7 @@
  * 本文件不碰 localStorage / fetch:持久化在 page 层(services)。
  * 本文件不碰 localStorage / fetch:持久化在 page 层(services)。
  */
  */
 import { stageOf, oppEvidenceOf, oppStatusOf, OPP_STATUS_KEYS, PRIORITIES, DECISIONS, STATUS_DECISION, uploadMethodLabel, normalizeLayer } from '../taxonomy/taxonomy.js';
 import { stageOf, oppEvidenceOf, oppStatusOf, OPP_STATUS_KEYS, PRIORITIES, DECISIONS, STATUS_DECISION, uploadMethodLabel, normalizeLayer } from '../taxonomy/taxonomy.js';
-import { toIso, toTime, truncate, latest } from '../kfs/util.js';
+import { toIso, toTime, truncate, latest, cjkSpace } from '../kfs/util.js';
 import { scoreStats } from './score.js';
 import { scoreStats } from './score.js';
 
 
 /** 「谁传的」四要素 + 一个扩展位(后续会开多个数字分身,别写死单个) */
 /** 「谁传的」四要素 + 一个扩展位(后续会开多个数字分身,别写死单个) */
@@ -109,7 +109,7 @@ export function normalizeOpportunity(raw, ctx = {}) {
 
 
 /** 排序口径:每项都与 UI 上一一对应;同分按更新时间倒序,空值沉底 */
 /** 排序口径:每项都与 UI 上一一对应;同分按更新时间倒序,空值沉底 */
 export const SORTS = Object.freeze([
 export const SORTS = Object.freeze([
-  { key: 'priority', label: '优先级 S→A→B', test: (a, b) => (prioRank(a) - prioRank(b)) || byScoreDesc(a, b) },
+  { key: 'priority', label: cjkSpace('优先级 S→A→B'), test: (a, b) => (prioRank(a) - prioRank(b)) || byScoreDesc(a, b) },
   { key: 'score', label: '综合评分 高→低', test: byScoreDesc },
   { key: 'score', label: '综合评分 高→低', test: byScoreDesc },
   { key: 'evidence', label: '证据强度 高→低', test: (a, b) => (evRank(b) - evRank(a)) || byScoreDesc(a, b) },
   { key: 'evidence', label: '证据强度 高→低', test: (a, b) => (evRank(b) - evRank(a)) || byScoreDesc(a, b) },
   { key: 'newest', label: '最新上传', test: (a, b) => (b.upload.uploadedAt || '').localeCompare(a.upload.uploadedAt || '') },
   { key: 'newest', label: '最新上传', test: (a, b) => (b.upload.uploadedAt || '').localeCompare(a.upload.uploadedAt || '') },

+ 8 - 6
src/core/taxonomy/taxonomy.js

@@ -5,13 +5,15 @@
  * `S1品类分析` / `S1 品类分析` / `S1` 三种写法,uploadMethod 曾经被错写成 uploadedBy=skill,
  * `S1品类分析` / `S1 品类分析` / `S1` 三种写法,uploadMethod 曾经被错写成 uploadedBy=skill,
  * 历史 tier=pending……下游(模型、统计、页面)只认归一化之后的值。
  * 历史 tier=pending……下游(模型、统计、页面)只认归一化之后的值。
  */
  */
+import { cjkSpace } from '../kfs/util.js';
 
 
 export const STAGES = Object.freeze([
 export const STAGES = Object.freeze([
-  { code: 'S1', no: 1, name: '品类市场分析', label: 'S1 品类市场分析' },
-  { code: 'S2', no: 2, name: '用户需求与痛点', label: 'S2 用户需求与痛点' },
-  { code: 'S3', no: 3, name: '机会点识别', label: 'S3 机会点识别' },
-  { code: 'S4', no: 4, name: '机会验证评估', label: 'S4 机会验证评估' },
-  { code: 'S5', no: 5, name: '场景与卖点设计', label: 'S5 场景与卖点设计' },
+  /* label 在生成处插 U+2009(「S1 品类市场分析」):阶段标签会直接印到卡片、成员表、报告页 */
+  { code: 'S1', no: 1, name: '品类市场分析', label: cjkSpace('S1 品类市场分析') },
+  { code: 'S2', no: 2, name: '用户需求与痛点', label: cjkSpace('S2 用户需求与痛点') },
+  { code: 'S3', no: 3, name: '机会点识别', label: cjkSpace('S3 机会点识别') },
+  { code: 'S4', no: 4, name: '机会验证评估', label: cjkSpace('S4 机会验证评估') },
+  { code: 'S5', no: 5, name: '场景与卖点设计', label: cjkSpace('S5 场景与卖点设计') },
 ]);
 ]);
 
 
 export const LAYERS = Object.freeze([
 export const LAYERS = Object.freeze([
@@ -50,7 +52,7 @@ export const STATUS_DECISION = Object.freeze({ approved: 'approve', rejected: 'r
 
 
 export const UPLOAD_METHODS = Object.freeze({
 export const UPLOAD_METHODS = Object.freeze({
   session: '会话上传',
   session: '会话上传',
-  skill: 'Skill 上传',
+  skill: cjkSpace('Skill 上传'),
   manual: '手工录入',
   manual: '手工录入',
 });
 });
 
 

+ 10 - 16
src/layout/kf-app.js

@@ -10,7 +10,7 @@
 import { LitElement, html, css, render } from 'lit';
 import { LitElement, html, css, render } from 'lit';
 import { SignalWatcher } from '@lit-labs/signals';
 import { SignalWatcher } from '@lit-labs/signals';
 import { useTokens } from '../components/base.js';
 import { useTokens } from '../components/base.js';
-import { spaceCJK } from '../utils/type.js';
+import { t } from '../components/cjk.js';
 import { boardService } from '../services/board-service.js';
 import { boardService } from '../services/board-service.js';
 import { currentPath, currentQuery, subscribe as subscribeRoute, navigate, href, onLinkClick, match } from '../utils/router.js';
 import { currentPath, currentQuery, subscribe as subscribeRoute, navigate, href, onLinkClick, match } from '../utils/router.js';
 import { CONFIG } from '../app.config.js';
 import { CONFIG } from '../app.config.js';
@@ -245,7 +245,7 @@ export class KfApp extends SignalWatcher(LitElement) {
       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 {
       return {
         heading: opp.title,
         heading: opp.title,
-        sub: `${opp.priority ? `${opp.priority} 级 · ` : ''}${opp.category} · ${opp.stage} · ${opp.statusLabel}`,
+        sub: t(`${opp.priority ? `${opp.priority} 级 · ` : ''}${opp.category} · ${opp.stage} · ${opp.statusLabel}`),
         body: html`
         body: html`
           <div class="d-sec">
           <div class="d-sec">
             <div class="one">${opp.oneLiner || '(未填写一句话说明)'}</div>
             <div class="one">${opp.oneLiner || '(未填写一句话说明)'}</div>
@@ -262,8 +262,8 @@ export class KfApp extends SignalWatcher(LitElement) {
             ${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>`
             ${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>`}
               : html`<div class="kf-muted">尚无决策记录。</div>`}
             ${opp.decisionLog.length ? html`<details>
             ${opp.decisionLog.length ? html`<details>
-              <summary>决策记录 ${opp.decisionLog.length} 条(最近 3 条)</summary>
-              <ol class="log">${opp.decisionLog.slice(-3).reverse().map((x) => html`<li><b>${x.label}</b> ${x.reason} <span class="kf-muted">${x.by} · ${fmtDate(x.at, true)}</span></li>`)}</ol></details>` : ''}
+              <summary>${t(`决策记录 ${opp.decisionLog.length} 条(最近 3 条)`)}</summary>
+              <ol class="log">${opp.decisionLog.slice(-3).reverse().map((x) => html`<li><b>${x.label}</b> ${x.reason} <span class="kf-muted">${t(`${x.by} · ${fmtDate(x.at, true)}`)}</span></li>`)}</ol></details>` : ''}
           </div>
           </div>
           <div class="d-sec">
           <div class="d-sec">
             <h4>多人评分</h4>
             <h4>多人评分</h4>
@@ -295,7 +295,7 @@ export class KfApp extends SignalWatcher(LitElement) {
       const ids = conclusionToLedger(db, d.reportId, d.evId);
       const ids = conclusionToLedger(db, d.reportId, d.evId);
       return {
       return {
         heading: e.claim,
         heading: e.claim,
-        sub: `证据链 · 来自报告《${e.report.report?.title || e.report.shortTitle}》`,
+        sub: t(`证据链 · 来自报告《${e.report.report?.title || e.report.shortTitle}》`),
         body: html`
         body: html`
           <div class="d-sec"><div class="kv">
           <div class="d-sec"><div class="kv">
             <div><b>${e.ownerCount ?? '—'}</b><span>人独立提到</span></div>
             <div><b>${e.ownerCount ?? '—'}</b><span>人独立提到</span></div>
@@ -307,7 +307,7 @@ export class KfApp extends SignalWatcher(LitElement) {
             <h4>支撑成果(点名到人)</h4>
             <h4>支撑成果(点名到人)</h4>
             ${e.derived.length ? html`<ul class="links">${e.derived.map((x) => html`<li>
             ${e.derived.length ? html`<ul class="links">${e.derived.map((x) => html`<li>
               ${x.artifact ? html`<button class="lnk" @click=${() => this.#onAction({ type: 'artifact-open', detail: { id: x.id } })}>${x.name}</button>
               ${x.artifact ? html`<button class="lnk" @click=${() => this.#onAction({ type: 'artifact-open', detail: { id: x.id } })}>${x.name}</button>
-                <span class="kf-muted">${x.owner || '无出身'} · ${x.stage} · ${x.evidence}</span>` : html`<span class="miss">${x.name}</span>`}
+                <span class="kf-muted">${t(`${x.owner || '无出身'} · ${x.stage} · ${x.evidence}`)}</span>` : html`<span class="miss">${x.name}</span>`}
             </li>`)}</ul>` : html`<div class="kf-muted">这条结论没有登记支撑成果。</div>`}
             </li>`)}</ul>` : html`<div class="kf-muted">这条结论没有登记支撑成果。</div>`}
           </div>
           </div>
           <div class="d-sec">
           <div class="d-sec">
@@ -316,10 +316,10 @@ export class KfApp extends SignalWatcher(LitElement) {
               <p>${q.text}</p>
               <p>${q.text}</p>
               <div class="prov">
               <div class="prov">
                 <span class="plat">${q.ref || '出处未登记'}</span>
                 <span class="plat">${q.ref || '出处未登记'}</span>
-                ${q.likes != null ? html`<span>· 点赞 ${q.likes}</span>` : ''}
+                ${q.likes != null ? html`<span>${t(`· 点赞 ${q.likes}`)}</span>` : ''}
                 ${q.author ? html`<span>· ${q.author}</span>` : ''}
                 ${q.author ? html`<span>· ${q.author}</span>` : ''}
                 ${q.art
                 ${q.art
-                  ? html`<button class="lnk" @click=${() => this.#onAction({ type: 'artifact-open', detail: { id: q.art.id } })}>· 原文来自「${q.art.shortTitle}」(${q.art.ownerName})→</button>`
+                  ? 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>`}
                   : html`<span>· 平台原文,未落入知识库</span>`}
               </div>
               </div>
             </li>`)}</ul>` : html`<div class="kf-muted">这条结论没有原文片段。</div>`}
             </li>`)}</ul>` : html`<div class="kf-muted">这条结论没有原文片段。</div>`}
@@ -330,12 +330,12 @@ export class KfApp extends SignalWatcher(LitElement) {
     if (d.type === 'artifact') {
     if (d.type === 'artifact') {
       const c = traceArtifact(db, d.id);
       const c = traceArtifact(db, d.id);
       if (!c) return { heading: '成果不存在', body: html`<kf-empty kind="error" title="成果已被删除"></kf-empty>` };
       if (!c) return { heading: '成果不存在', body: html`<kf-empty kind="error" title="成果已被删除"></kf-empty>` };
-      return { heading: c.artifact.shortTitle, sub: `${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>` };
+      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 === 'insight') {
     if (d.type === 'insight') {
       const c = traceInsight(db, d.id);
       const c = traceInsight(db, d.id);
       if (!c) return { heading: '归纳不存在', body: html`<kf-empty kind="error" title="归纳已被删除"></kf-empty>` };
       if (!c) return { heading: '归纳不存在', body: html`<kf-empty kind="error" title="归纳已被删除"></kf-empty>` };
-      return { heading: c.insight.title, sub: `归纳 · ${c.insight.layer}`, body: html`<kf-evidence-chain mode="insight" .chain=${c}></kf-evidence-chain>` };
+      return { heading: c.insight.title, sub: t(`归纳 · ${c.insight.layer}`), body: html`<kf-evidence-chain mode="insight" .chain=${c}></kf-evidence-chain>` };
     }
     }
     return null;
     return null;
   }
   }
@@ -357,12 +357,6 @@ export class KfApp extends SignalWatcher(LitElement) {
       }
       }
     }
     }
     render(content.body, body);
     render(content.body, body);
-    // 抽屉正文也在浮层里:汉字与拉丁字母之间补 U+2009(v11 1.3),整行正文加 cap 行盒修剪之外的行宽上限
-    requestAnimationFrame(() => {
-      const w = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
-      let t;
-      while ((t = w.nextNode())) if (/[一-龥][A-Za-z0-9]|[A-Za-z0-9][一-龥]/.test(t.data)) t.data = spaceCJK(t.data);
-    });
   }
   }
 
 
   renderView() {
   renderView() {

+ 2 - 1
src/layout/kf-nav.js

@@ -10,6 +10,7 @@
 import { LitElement, html, css } from 'lit';
 import { LitElement, html, css } from 'lit';
 import { useTokens, define } from '../components/base.js';
 import { useTokens, define } from '../components/base.js';
 import { icon } from '../components/icons.js';
 import { icon } from '../components/icons.js';
+import { t } from '../components/cjk.js';
 
 
 const ITEMS = [
 const ITEMS = [
   { path: '/', label: '工作台', en: 'Workbench', icon: 'home' },
   { path: '/', label: '工作台', en: 'Workbench', icon: 'home' },
@@ -128,7 +129,7 @@ export class KfNav extends LitElement {
       <aside class="side kf-glass is-panel" aria-label="主导航">
       <aside class="side kf-glass is-panel" aria-label="主导航">
         <div class="brand">
         <div class="brand">
           <span class="mark" aria-hidden="true">康</span>
           <span class="mark" aria-hidden="true">康</span>
-          <div><div class="name">产品创新看板</div><div class="sub">研发中心 · AI 实战营</div></div>
+          <div><div class="name">产品创新看板</div><div class="sub">${t('研发中心 · AI 实战营')}</div></div>
         </div>
         </div>
         <nav>
         <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=${it.path} data-link data-path=${it.path} aria-current=${curAria(it)}>

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

@@ -12,6 +12,7 @@
 import { html, css } from 'lit';
 import { html, css } from 'lit';
 import { CARD_DIMS, CARD_SORTS } from '../core/index.js';
 import { CARD_DIMS, CARD_SORTS } from '../core/index.js';
 import { icon } from '../components/icons.js';
 import { icon } from '../components/icons.js';
+import { t } from '../components/cjk.js';
 import './filter-sheet.js';
 import './filter-sheet.js';
 
 
 export const TOOLBAR_CSS = css`
 export const TOOLBAR_CSS = css`
@@ -107,7 +108,7 @@ const CHIPS = [
   { key: 'hasEv', icon: 'shield', label: '有证据' },
   { key: 'hasEv', icon: 'shield', label: '有证据' },
   { key: 'mine', icon: 'user', label: '我的' },
   { key: 'mine', icon: 'user', label: '我的' },
   { key: 'full', icon: 'doc', label: '可看全文' },
   { key: 'full', icon: 'doc', label: '可看全文' },
-  { key: 'recent', icon: 'clock', label: '近 7 天' },
+  { key: 'recent', icon: 'clock', label: t('近 7 天') },
 ];
 ];
 
 
 /**
 /**

+ 2 - 1
src/pages/filter-sheet.js

@@ -8,6 +8,7 @@
  *
  *
  * 纯展示:筛选值 / 计数 / 操作全部由调用方给(card-toolbar 的 makeToolHandler 同一套 view.*)。
  * 纯展示:筛选值 / 计数 / 操作全部由调用方给(card-toolbar 的 makeToolHandler 同一套 view.*)。
  */
  */
+import { t } from '../components/cjk.js';
 import { LitElement, html, css } from 'lit';
 import { LitElement, html, css } from 'lit';
 import { useTokens, define } from '../components/base.js';
 import { useTokens, define } from '../components/base.js';
 import { icon } from '../components/icons.js';
 import { icon } from '../components/icons.js';
@@ -120,7 +121,7 @@ export class KfFilterSheet extends LitElement {
         </div>
         </div>
         <footer class="ft">
         <footer class="ft">
           <button class="kf-btn reset" ?disabled=${!this.groups.some((g) => g.options.some((o) => o.on))} @click=${() => this.#opt('clear')}>重置</button>
           <button class="kf-btn reset" ?disabled=${!this.groups.some((g) => g.options.some((o) => o.on))} @click=${() => this.#opt('clear')}>重置</button>
-          <button class="kf-btn primary go" @click=${this.#close}>查看 ${this.shown} 条结果</button>
+          <button class="kf-btn primary go" @click=${this.#close}>${t(`查看 ${this.shown} 条结果`)}</button>
         </footer>
         </footer>
       </section>`;
       </section>`;
   }
   }

+ 1 - 1
src/pages/report-card.js

@@ -13,7 +13,7 @@
  * 纯渲染:卡片要用到的判断(证据档、品类、缩略图、评分)全部来自 core.cardFacts;这里只排版和画图标。
  * 纯渲染:卡片要用到的判断(证据档、品类、缩略图、评分)全部来自 core.cardFacts;这里只排版和画图标。
  */
  */
 import { html, css } from 'lit';
 import { html, css } from 'lit';
-import { cardFacts, whenText } from '../core/index.js';
+import { cardFacts, whenText, cjkSpace } from '../core/index.js';
 import { icon } from '../components/icons.js';
 import { icon } from '../components/icons.js';
 import '../components/kf-viz.js';
 import '../components/kf-viz.js';
 
 

+ 4 - 29
src/pages/shared.js

@@ -4,40 +4,15 @@
  */
  */
 import { html, css } from 'lit';
 import { html, css } from 'lit';
 import { href } from '../utils/router.js';
 import { href } from '../utils/router.js';
-import { spaceCJK } from '../utils/type.js';
 import { cjkString } from '../utils/cjk-deep.js';
 import { cjkString } from '../utils/cjk-deep.js';
 
 
 /**
 /**
- * 中英混排(v11 1.3):汉字与拉丁字母 / 数字之间插 U+2009。
- * Chrome 153 实测 text-autospace 无效(渲染宽度不变、computed 仍是 no-autospace),只能数据层处理。
+ * 中英混排(v11 1.3):汉字与拉丁字母 / 数字之间插 U+2009。Chrome 153 实测 text-autospace 无效,只能数据层处理。
  *
  *
- * v11b:上一版写成 requestAnimationFrame 延迟改写文本节点 —— 实测全页 U+2009 计数为 0,
- * 而且「下一次渲染前的渲染」清掉动画帧,改写根本没跑。现在改成**同步**走完(IsConnected 已更新过 DOM)。
- * 只改文本节点:属性 / aria-label 不动;<code>/<pre>/<kbd>/<samp> 与 URL / 文件名不插(showcase 的 data-cjk 属性也不碰)。
+ * v11c:唯一的渲染期入口是下面的 `t`(= utils/cjk-deep.js 的 cjkString),在**生成字符串那一刻**调用。
+ * 这里曾经有一个 cjk(node) —— 渲染后 createTreeWalker 改文本节点,再早一版是 MutationObserver 全页扫。
+ * 两者都已删除:先渲染错、再事后擦,会和 Lit 的下一页渲染抢写,且看不到「为什么这里是普通空格」。
  */
  */
-const HAS_LATIN = /[一-龥][A-Za-z0-9]|[A-Za-z0-9][一-龥]/;
-const CJK_EXEMPT = /^(SCRIPT|STYLE|TEXTAREA|OPTION|CODE|PRE|KBD|SAMP|SVG|svg)$/;
-function exemptAncestor(t, root) {
-  for (let n = t.parentNode; n && n !== root; n = n.parentNode || n.host) {
-    if (n.nodeType === 1 && CJK_EXEMPT.test(n.localName)) return true;
-    if (n.nodeType === 1 && n.classList && (n.classList.contains('kf-mono') || n.classList.contains('url'))) return true;
-  }
-  return false;
-}
-export function cjk(node) {
-  const w = (node.ownerDocument || node).createTreeWalker(node, NodeFilter.SHOW_TEXT);
-  const todo = [];
-  let t;
-  while ((t = w.nextNode())) {
-    if (!HAS_LATIN.test(t.data)) continue;
-    todo.push(t);
-  }
-  for (const x of todo) {
-    if (exemptAncestor(x, node)) continue;
-    x.data = spaceCJK(x.data);
-  }
-}
-
 /** 页面标题区(大标题 + 一句说明 + 右侧动作) */
 /** 页面标题区(大标题 + 一句说明 + 右侧动作) */
 export const head = (title, sub, actions = '') => html`
 export const head = (title, sub, actions = '') => html`
   <div class="p-head">
   <div class="p-head">

+ 3 - 3
src/pages/view-ledger.js

@@ -173,10 +173,10 @@ export class ViewLedger extends LitElement {
     return html`
     return html`
       <div class="ledger-head">
       <div class="ledger-head">
         <div class="lt"><h3 class="tb-cap">全部记录</h3></div>
         <div class="lt"><h3 class="tb-cap">全部记录</h3></div>
-        <div class="sum"><span><b>${filtered.length}</b> / ${rows.length} 条</span></div>
+        <div class="sum"><span><b>${filtered.length}</b> / ${t(`${rows.length} 条`)}</span></div>
       </div>
       </div>
       <div class="heatx" data-viz="heat" role="group" aria-label="阶段与层交叉分布">
       <div class="heatx" data-viz="heat" role="group" aria-label="阶段与层交叉分布">
-        <div class="hx-title">阶段 × 层 · 合计 ${rows.length} 条 · 右端为各阶段小计</div>
+        <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>
         <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>`)}
         ${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>
       </div>
@@ -193,7 +193,7 @@ export class ViewLedger extends LitElement {
           </div></div>`;
           </div></div>`;
         })}
         })}
       </div></kf-glass-card>
       </div></kf-glass-card>
-      <div class="bar2">${focus.size ? html`<p class="focus-note">${icon('link', 14)} 从证据链跳入:${focus.size} 条被引用的成果已在表中高亮。</p>` : ''}
+      <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>` : ''}
         ${active ? html`<button class="kf-btn ghost clear" @click=${() => navigate('/ledger', { replace: true })}>${icon('x', 12)} 清除条件</button>` : ''}
         <span class="hint">点击行查看证据链</span></div>
         <span class="hint">点击行查看证据链</span></div>
       ${filtered.length ? html`<kf-table row-key="id" clickable .cols=${cols} .rows=${filtered.map((r) => ({ ...r, id: `${r.kind}:${r.id}` }))}
       ${filtered.length ? html`<kf-table row-key="id" clickable .cols=${cols} .rows=${filtered.map((r) => ({ ...r, id: `${r.kind}:${r.id}` }))}

+ 9 - 5
src/pages/view-members.js

@@ -5,7 +5,7 @@
  */
  */
 import { LitElement, css, html } from 'lit';
 import { LitElement, css, html } from 'lit';
 import { useTokens } from '../components/base.js';
 import { useTokens } from '../components/base.js';
-import { fmtAgo, fmtDate } from '../core/index.js';
+import { fmtAgo, fmtDate, fmtAgoText } from '../core/index.js';
 import { href } from '../utils/router.js';
 import { href } from '../utils/router.js';
 import { t, head, VIEW_CSS } from './shared.js';
 import { t, head, VIEW_CSS } from './shared.js';
 import { icon } from '../components/icons.js';
 import { icon } from '../components/icons.js';
@@ -76,16 +76,20 @@ export class ViewMembers extends LitElement {
       groups.get(g).push(m);
       groups.get(g).push(m);
     }
     }
     const stuck = db.members.filter((m) => m.stuck);
     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 对比条:全员产出对比(份数),最大值占满、其余按比例;数字右对齐,条只说形状
     // v11b 2.3 对比条:全员产出对比(份数),最大值占满、其余按比例;数字右对齐,条只说形状
     const maxArt = Math.max(1, ...db.members.map((m) => m.artifactIds.length));
     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)
     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) }));
       .map((m) => ({ label: m.name, value: Math.round((m.artifactIds.length / maxArt) * 100), text: String(m.artifactIds.length) }));
     return html`
     return html`
       ${head('成员', t(`${db.members.length} 位 · ${db.members.filter((m) => m.done).length} 人已交成果 · ${stuck.length} 人卡住`),
       ${head('成员', t(`${db.members.length} 位 · ${db.members.filter((m) => m.done).length} 人已交成果 · ${stuck.length} 人卡住`),
-        stuck.length ? html`<span class="stuck">${icon('alert', 14)} 卡住:${stuck.map((m) => m.name).join('、')}</span>` : '')}
+        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">
       <kf-glass-card surface="2" class="gcard" data-viz="bars">
-        <div class="gh"><h3 class="tb-cap">成员产出对比</h3><span class="n">按成果份数 · 前 8 位</span></div>
-        <span class="cmp"><kf-bars stack .rows=${compare}></kf-bars>${compare.length ? html`<b class="cmp-n">最多 ${compare[0].text} 份</b>` : ''}</span>
+        <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>
       </kf-glass-card>
       </kf-glass-card>
       ${[...groups.entries()].sort((a, b) => a[0].localeCompare(b[0], 'zh')).map(([g, list]) => html`
       ${[...groups.entries()].sort((a, b) => a[0].localeCompare(b[0], 'zh')).map(([g, list]) => html`
         <kf-glass-card surface="2" class="gcard">
         <kf-glass-card surface="2" class="gcard">
@@ -100,7 +104,7 @@ export class ViewMembers extends LitElement {
               <span class="num">${m.artifactIds.length}<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 class="num">${m.oppIds.length}<span class="u">张</span></span>
               <span title=${fmtDate(m.lastActiveAt, true)}>${fmtAgo(m.lastActiveAt)}</span>
               <span title=${fmtDate(m.lastActiveAt, true)}>${fmtAgo(m.lastActiveAt)}</span>
-              <span class=${m.stuck ? 'stuck' : m.done ? 'ok' : ''}>${m.stuck ? t(`卡住 ${m.stuckHours}h`) : m.done ? '已交' : '进行中'}</span>
+              <span class=${m.stuck ? 'stuck' : m.done ? 'ok' : ''}>${m.stuck ? t(`卡住:${stuckText(m)}`) : m.done ? '已交' : '进行中'}</span>
             </li>`)}
             </li>`)}
           </ul>
           </ul>
         </kf-glass-card>`)}
         </kf-glass-card>`)}

+ 1 - 1
src/pages/view-pool.js

@@ -166,7 +166,7 @@ export class ViewPool extends LitElement {
     const active = DIMS.some((d) => (c[d.key] || []).length) || c.q;
     const active = DIMS.some((d) => (c[d.key] || []).length) || c.q;
     return html`
     return html`
       ${head('机会卡池', '全员机会卡汇总入库,带评分与优先级 —— 这是研发中心的机会储备。',
       ${head('机会卡池', '全员机会卡汇总入库,带评分与优先级 —— 这是研发中心的机会储备。',
-        html`<span class="count"><b>${filtered.length}</b> / ${rows.length} 张</span>
+        html`<span class="count"><b>${filtered.length}</b> / ${t(`${rows.length} 张`)}</span>
           ${active ? html`<button class="reset" @click=${() => navigate('/pool', { replace: true })}>清除全部条件</button>` : ''}`)}
           ${active ? html`<button class="reset" @click=${() => navigate('/pool', { replace: true })}>清除全部条件</button>` : ''}`)}
 
 
       <div class="pool-body">
       <div class="pool-body">

+ 1 - 1
src/pages/view-report-type.js

@@ -250,7 +250,7 @@ export class ViewReportType extends LitElement {
     }).length);
     }).length);
     const dmax = Math.max(1, ...days);
     const dmax = Math.max(1, ...days);
     return html`<div class="kpis" data-kpis>
     return html`<div class="kpis" data-kpis>
-      <div class="kpi" data-kpi="count"><div class="brow2"><b>${all.length}<small>份</small></b><kf-spark small .values=${days.map((d) => d / dmax)}></kf-spark></div><span class="lbl">近 7 天 ${days.reduce((a, b) => a + b, 0)}</span></div>
+      <div class="kpi" data-kpi="count"><div class="brow2"><b>${all.length}<small>份</small></b><kf-spark small .values=${days.map((d) => d / dmax)}></kf-spark></div><span class="lbl">${t(`近 7 天 ${days.reduce((a, b) => a + b, 0)}`)}</span></div>
       <div class="kpi" data-kpi="stages"><div class="brow2"><b>${stages.size}<small>/5</small></b></div>
       <div class="kpi" data-kpi="stages"><div class="brow2"><b>${stages.size}<small>/5</small></b></div>
         <span class="lbl stage-dots">${unsafeHTML(['S1', 'S2', 'S3', 'S4', 'S5'].map((c) => `<i class="${stages.has(c) ? 'on' : ''}"></i>`).join(''))}</span></div>
         <span class="lbl stage-dots">${unsafeHTML(['S1', 'S2', 'S3', 'S4', 'S5'].map((c) => `<i class="${stages.has(c) ? 'on' : ''}"></i>`).join(''))}</span></div>
       <div class="kpi" data-kpi="full"><div class="brow2"><b>${full}<small>/${all.length}</small></b><kf-ring size=${40} .pct=${pct(full)} label=${`${pct(full)}%`}></kf-ring></div><span class="lbl">可看全文</span></div>
       <div class="kpi" data-kpi="full"><div class="brow2"><b>${full}<small>/${all.length}</small></b><kf-ring size=${40} .pct=${pct(full)} label=${`${pct(full)}%`}></kf-ring></div><span class="lbl">可看全文</span></div>

+ 2 - 2
src/pages/view-report.js

@@ -249,7 +249,7 @@ export class ViewReport extends LitElement {
           <div class="sub" style="margin-top:6px">${r.subtitle}</div>
           <div class="sub" style="margin-top:6px">${r.subtitle}</div>
         </div>
         </div>
         <div class="meta">
         <div class="meta">
-          <span>${fmtDate(r.generatedAt, true)}</span><span>${r.quoteCount} 原文</span><span>${r.refsInLib}/${r.refCount} 支撑</span>
+          <span>${fmtDate(r.generatedAt, true)}</span><span>${t(`${r.quoteCount} 原文`)}</span><span>${t(`${r.refsInLib}/${r.refCount} 支撑`)}</span>
         </div>
         </div>
         ${r.pipeline ? html`<div class="pipe"><kf-pipeline .steps=${r.pipeline.steps} .rate=${r.pipeline.filterRate}></kf-pipeline></div>` : ''}
         ${r.pipeline ? html`<div class="pipe"><kf-pipeline .steps=${r.pipeline.steps} .rate=${r.pipeline.filterRate}></kf-pipeline></div>` : ''}
       </div></kf-glass-card>
       </div></kf-glass-card>
@@ -363,7 +363,7 @@ export class ViewReport extends LitElement {
         const d = s.data || {};
         const d = s.data || {};
         return html`<h4>${title}</h4><ol class="funnel">${(d.ladder || []).map(([no, name, note]) => html`<li><span class="ch-no">${no}</span><span><b class="kf-title">${name}</b> <span class="muted2">${note}</span></span></li>`)}</ol>
         return html`<h4>${title}</h4><ol class="funnel">${(d.ladder || []).map(([no, name, note]) => html`<li><span class="ch-no">${no}</span><span><b class="kf-title">${name}</b> <span class="muted2">${note}</span></span></li>`)}</ol>
           ${d.statement ? html`<p class="para" style="margin-top:10px;font-size: 16px"><b>一句话定位:</b>「${d.statement}」</p>` : ''}
           ${d.statement ? html`<p class="para" style="margin-top:10px;font-size: 16px"><b>一句话定位:</b>「${d.statement}」</p>` : ''}
-          ${d.for_who ? html`<div class="muted2">写给谁:${d.for_who}</div>` : ''}`;
+          ${d.for_who ? html`<div class="muted2">${t(`写给谁:${d.for_who}`)}</div>` : ''}`;
       }
       }
       case 'statement':
       case 'statement':
         return html`${(s.data?.alt || []).length ? html`<h4>${title}</h4><ul class="para">${s.data.alt.map((x) => html`<li>${x}</li>`)}</ul>` : ''}`;
         return html`${(s.data?.alt || []).length ? html`<h4>${title}</h4><ul class="para">${s.data.alt.map((x) => html`<li>${x}</li>`)}</ul>` : ''}`;

+ 8 - 8
src/pages/view-reports.js

@@ -143,15 +143,15 @@ export class ViewReports extends LitElement {
     return html`
     return html`
       ${section('分类', t(`全部 ${c.total} 份`))}
       ${section('分类', t(`全部 ${c.total} 份`))}
       <div class="types" data-types>
       <div class="types" data-types>
-        ${c.types.map((t, i) => html`
-          <a href=${href(`/reports/${t.type.key}`)} data-link data-type-card=${t.type.key} class="kf-card is-lift kf-enter" style="display:block;--i:${i}">
+        ${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}">
             <div class="t">
             <div class="t">
-              <div class="h"><span class="ico" aria-hidden="true">${icon(TYPE_ICON[t.type.key] || 'grid', 18)}</span>
-                <h3>${t.type.name}</h3>
-                <span class="n"><b>${t.count}<span class="u">份</span></b><kf-spark small .values=${weekly(t.all)}></kf-spark></span></div>
-              <div class="tr"><span class="doc" title="可看全文">${icon('doc', 12)}${t.withBody}/${t.count}</span></div>
-              <span class="tr"><kf-bars stack .rows=${STAGE_ROWS(t)}></kf-bars>${t.all.length ? html`<b class="stgmax">${t(`最多 ${Math.max(...STAGE_ROWS(t).map((x) => Number(x.text)))} 份`)}</b>` : ''}</span>
-              <div class="last">${t.latest[0] ? sourceRow(t.latest[0], { nowrap: true }) : html`<span class="kf-muted">—</span>`}</div>
+              <div class="h"><span class="ico" aria-hidden="true">${icon(TYPE_ICON[ty.type.key] || 'grid', 18)}</span>
+                <h3>${ty.type.name}</h3>
+                <span class="n"><b>${ty.count}<span class="u">份</span></b><kf-spark small .values=${weekly(ty.all)}></kf-spark></span></div>
+              <div class="tr"><span class="doc" title="可看全文">${icon('doc', 12)}${t(`${ty.withBody}/${ty.count}`)}</span></div>
+              <span class="tr"><kf-bars stack  .rows=${STAGE_ROWS(ty)}></kf-bars>${ty.all.length ? html`<b class="stgmax">${t(`最多 ${Math.max(...STAGE_ROWS(ty).map((x) => Number(x.text)))} ${'份'}`)}</b>` : ''}</span>
+              <div class="last">${ty.latest[0] ? sourceRow(ty.latest[0], { nowrap: true }) : html`<span class="kf-muted">—</span>`}</div>
             </div>
             </div>
           </a>`)}
           </a>`)}
       </div>
       </div>

+ 2 - 2
src/pages/view-workbench.js

@@ -175,7 +175,7 @@ export class ViewWorkbench extends LitElement {
             <a class="go" style="margin-left:auto" href=${href('/pool')} data-link aria-label="去机会卡池">${icon('arrow', 14)}</a></div>
             <a class="go" style="margin-left:auto" href=${href('/pool')} data-link aria-label="去机会卡池">${icon('arrow', 14)}</a></div>
           ${live.stalled.length ? html`<ul class="stalled">
           ${live.stalled.length ? html`<ul class="stalled">
             ${live.stalled.slice(0, 4).map((s) => html`<li data-stalled=${s.name}>
             ${live.stalled.slice(0, 4).map((s) => html`<li data-stalled=${s.name}>
-              <span class="nm">${s.name}</span><span class="meta">${s.stage} · ${s.msgCount} 条</span>
+              <span class="nm">${s.name}</span><span class="meta">${t(`${s.stage} · ${s.msgCount} 条`)}</span>
               <span class="h">${s.stuckHours}h</span></li>`)}
               <span class="h">${s.stuckHours}h</span></li>`)}
           </ul>` : html`<p class="ok">${icon('check', 16)} 大家都在动</p>`}
           </ul>` : html`<p class="ok">${icon('check', 16)} 大家都在动</p>`}
         </kf-glass-card>
         </kf-glass-card>
@@ -257,7 +257,7 @@ export class ViewWorkbench extends LitElement {
     return html`<div class="grid4">
     return html`<div class="grid4">
       <kf-glass-card surface="2"><div class="kpi" data-kpi="total"><b>${pool.total}<span class="u">张</span></b>${spark}<span class="lbl">机会卡</span></div></kf-glass-card>
       <kf-glass-card surface="2"><div class="kpi" data-kpi="total"><b>${pool.total}<span class="u">张</span></b>${spark}<span class="lbl">机会卡</span></div></kf-glass-card>
       <kf-glass-card surface="2"><div class="kpi" data-kpi="avg"><b class=${avg == null ? 'dim' : ''}>${avg != null ? Math.round(avg) : '—'}<span class="u">分</span></b>${spark}<span class="lbl">${pool.top?.title || '暂无评分'}</span></div></kf-glass-card>
       <kf-glass-card surface="2"><div class="kpi" data-kpi="avg"><b class=${avg == null ? 'dim' : ''}>${avg != null ? Math.round(avg) : '—'}<span class="u">分</span></b>${spark}<span class="lbl">${pool.top?.title || '暂无评分'}</span></div></kf-glass-card>
-      <kf-glass-card surface="2"><div class="kpi" data-kpi="people"><b>${pool.uploaders}<span class="u">人</span></b>${spark}<span class="lbl">${pool.agents} Agent 参与</span></div></kf-glass-card>
+      <kf-glass-card surface="2"><div class="kpi" data-kpi="people"><b>${pool.uploaders}<span class="u">人</span></b>${spark}<span class="lbl">${t(`${pool.agents} Agent 参与`)}</span></div></kf-glass-card>
       <kf-glass-card surface="2"><div class="kpi" data-kpi="decided"><b>${pool.byStatus.approved + pool.byStatus.rejected}<span class="u">张</span></b>${spark}<span class="lbl">已决策</span></div></kf-glass-card>
       <kf-glass-card surface="2"><div class="kpi" data-kpi="decided"><b>${pool.byStatus.approved + pool.byStatus.rejected}<span class="u">张</span></b>${spark}<span class="lbl">已决策</span></div></kf-glass-card>
     </div>`;
     </div>`;
   }
   }

+ 2 - 0
src/utils/cjk-deep.js

@@ -5,6 +5,8 @@
  *   1. 渲染期改 DOM(MutationObserver / updated() 里遍历)是「先渲染错、再事后擦」,
  *   1. 渲染期改 DOM(MutationObserver / updated() 里遍历)是「先渲染错、再事后擦」,
  *      带来常驻扫描、全局挂载、以及 Lit 下次渲染把改动覆盖回去的竞态;
  *      带来常驻扫描、全局挂载、以及 Lit 下次渲染把改动覆盖回去的竞态;
  *   2. 在页面模板里逐个包 t() 要改几十处,漏一处就是 338 处残留(实测过);
  *   2. 在页面模板里逐个包 t() 要改几十处,漏一处就是 338 处残留(实测过);
+      —— v11c 补齐:数据边界覆盖不到模板里**临时拼的**字符串(`S4 ${name}`、`${n} 小时前`),
+         所以渲染期另有一处落点 `t()`(pages/shared.js,= 本文件的 cjkString);两处并存、各管一半:
  *   3. 数据只有**一个入口**(board-service.load → buildModel),在这里做一次,
  *   3. 数据只有**一个入口**(board-service.load → buildModel),在这里做一次,
  *      所有视图、所有组件、所有未来新加的页面自动生效。
  *      所有视图、所有组件、所有未来新加的页面自动生效。
  *
  *

+ 1 - 1
src/utils/type.js

@@ -20,7 +20,7 @@ export const unthin = (s) => String(s).split(THIN).join(' ');
  *   1. 交界处已有的普通空格 / 全角空格**换成** U+2009(不会出现「空格 + U+2009」两个);
  *   1. 交界处已有的普通空格 / 全角空格**换成** U+2009(不会出现「空格 + U+2009」两个);
  *   2. 连续数字 / 拉丁串整体算一个单位(`20260420` 内部不插);
  *   2. 连续数字 / 拉丁串整体算一个单位(`20260420` 内部不插);
  *   3. 幂等:已经带 U+2009 的交界不再插第二个。
  *   3. 幂等:已经带 U+2009 的交界不再插第二个。
- * URL / 文件名 / 纯英文串的排除在调用侧(shared.js 的 cjk() 跳过 <code> 与 .kf-mono),这里只认字符串。
+ * URL / 文件名的排除在调用侧(components/cjk.js 的 t() 跳过 URL,数据层 cjkDeep 跳过 URL/ISO/ObjectId),这里只认字符串。
  */
  */
 export function spaceCJK(s) {
 export function spaceCJK(s) {
   return String(s)
   return String(s)

+ 1 - 1
test/model.test.js

@@ -75,7 +75,7 @@ test('视角二「池子」:总数 / 状态分布 / 最新 / 最高分', () =>
 test('卡住:只列开工了、没交、超时的人名', () => {
 test('卡住:只列开工了、没交、超时的人名', () => {
   const s = stuckMembers(db);
   const s = stuckMembers(db);
   assert.deepEqual(s.map((x) => x.name), ['老数据', '何雨'], '老数据没有 lastActiveAt,按会话 createdAt(40h) 算,也确实超时');
   assert.deepEqual(s.map((x) => x.name), ['老数据', '何雨'], '老数据没有 lastActiveAt,按会话 createdAt(40h) 算,也确实超时');
-  assert.equal(s[1].stage, 'S4 机会验证评估');
+  assert.equal(s[1].stage, 'S4\u2009机会验证评估');
   assert.equal(s[1].stuckHours, 30);
   assert.equal(s[1].stuckHours, 30);
 });
 });
 
 

+ 1 - 1
test/report.test.js

@@ -80,7 +80,7 @@ test('时间工具:Parse 日期 / ISO / 毫秒都能读,空值不炸', () =>
   assert.equal(toTime('坏数据'), 0);
   assert.equal(toTime('坏数据'), 0);
   assert.equal(latest([null, '2026-01-01T00:00:00Z', '2025-01-01T00:00:00Z']), '2026-01-01T00:00:00.000Z');
   assert.equal(latest([null, '2026-01-01T00:00:00Z', '2025-01-01T00:00:00Z']), '2026-01-01T00:00:00.000Z');
   assert.equal(fmtDate(null), '—');
   assert.equal(fmtDate(null), '—');
-  assert.equal(fmtAgo(new Date(Date.now() - 3 * 3600000).toISOString()), '3 小时前');
+  assert.equal(fmtAgo(new Date(Date.now() - 3 * 3600000).toISOString()), '3\u2009小时前');
   assert.equal(truncate('一二三四五', 3), '一二三…');
   assert.equal(truncate('一二三四五', 3), '一二三…');
   assert.equal(clamp(120, 0, 100), 100);
   assert.equal(clamp(120, 0, 100), 100);
 });
 });