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

fix: keep opportunity badges inline and move member picker to workbench

pengzhikang 15 часов назад
Родитель
Сommit
4baed6f609

+ 60 - 0
deploy/board-ui-20261112.json

@@ -0,0 +1,60 @@
+{
+  "version": "ksf.board-local.publish.v1",
+  "release": "ksf-board-ui-20261112-r1",
+  "origin": "https://kangshifu.fmode.cn/",
+  "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+  "files": [
+    {
+      "path": "src/components/kf-badge.js",
+      "sha256": "e4241502f789951bfc8bb9703f84ce5e00bf24b05e17266a17caca45614b48d6",
+      "priorNormalizedSha256": [
+        "e3ceae48f866067de873233d094e50bfc7707b7b6bb48cba63006293f1a910ae"
+      ],
+      "allowMissing": false,
+      "changed": true
+    },
+    {
+      "path": "src/components/kf-member-picker.js",
+      "sha256": "edfd3155acc80bbd1e8f5357f5e256da668213de16039b064df38e1e9925cb4a",
+      "priorNormalizedSha256": [],
+      "allowMissing": true,
+      "changed": true
+    },
+    {
+      "path": "src/components/kf-opportunity-card.js",
+      "sha256": "2acf969ca65c87a9dc9766a2facb50c2e6a6effced474961b35d781930082f4b",
+      "priorNormalizedSha256": [
+        "29894343e14a8c946b1553cc2cb2f966bb8007e5808ad486d0e8236ed2f72271"
+      ],
+      "allowMissing": false,
+      "changed": true
+    },
+    {
+      "path": "src/layout/kf-topbar.js",
+      "sha256": "a80c5d0a07f58980d587cd6bd17875d8e0560af3f9f8a5d1e9d5f653b87e94c0",
+      "priorNormalizedSha256": [
+        "c77c5375cbc0f5ebcf3a7d59854752d744da6e5fc5b2fc6b5c70ed7e651302d6"
+      ],
+      "allowMissing": false,
+      "changed": true
+    },
+    {
+      "path": "src/pages/view-workbench.js",
+      "sha256": "03343cae4466a46b334b55120c6be4042553f2c230a67e63146ad798868f87d9",
+      "priorNormalizedSha256": [
+        "67ad236087e058925239e25c7434f81efb1056325a70c6d102bdbfea421355d5"
+      ],
+      "allowMissing": false,
+      "changed": true
+    },
+    {
+      "path": "src/layout/kf-app.js",
+      "sha256": "e01c4f4d5702910831f2388eea0c6ecd42585dbfd85cd52080ce0e76b1d5864d",
+      "priorNormalizedSha256": [
+        "6b681d70e54e949229f9fd3c37668175fd2117b724a10756f08f392772e2c17d"
+      ],
+      "allowMissing": false,
+      "changed": true
+    }
+  ]
+}

+ 54 - 0
docs/board-ui-release-20261112/README.md

@@ -0,0 +1,54 @@
+# 机会卡片与工作台成员选择更新
+
+发行:`ksf-board-ui-20261112-r1`。本档案记录本地验收与部署准备;正式上线仍需服务器发布和浏览器回执。
+
+机会卡片的优先级、品类、完整阶段名和决策状态保持在第一行。成员选择从顶部移到工作台个人进度区,支持姓名/小组搜索、键盘操作和手机触控。展开列表采用用户图标,选中后的标题保留姓名末字头像。查看成员继续使用原有状态与本地记忆,不切换登录账号。
+
+## 发布范围
+
+以 `deploy/board-ui-20261112.json` 为准,仅更新以下六个运行文件:
+
+- `src/components/kf-badge.js`
+- `src/components/kf-member-picker.js`(新增)
+- `src/components/kf-opportunity-card.js`
+- `src/layout/kf-topbar.js`
+- `src/pages/view-workbench.js`
+- `src/layout/kf-app.js`
+
+生产报告页存在独立更新,因此本次使用六文件发布器,保留报告页面和其他运行文件。不要使用旧的 `deploy-board-local.sh` 全量发布本次变更。本次不需要 Parse 认证、不变更接口或数据库、不重启服务。
+
+## 本地验证
+
+| 验证 | 结果 | 留档 |
+|---|---|---|
+| 前端单测 `node --test 'test/*.test.js'` | 203/203 | 本次干净检出目录执行 |
+| 发布器和 MIME 单测 | 12/12 | 4 原发布器 + 3 六文件发布器 + 5 MIME |
+| `node scripts/check.mjs` | CHECK OK | Windows 下 core 层匹配数为 0,不视为覆盖该层 |
+| 机会卡片浏览器 | 15 项,12 档宽度,每档 12 卡 | `opportunity-browser.json` |
+| 成员下拉浏览器 | 19 项,9 档视口及触控模拟 | `member-picker-browser.json` |
+| 生产基线只读检查 | 5 件已存在文件匹配,新组件未部署 | `baseline-check.json` |
+| 生产文件本地副本演练 | plan 6 → apply 6 → noop 0,报告页字节不变 | `local-simulation.json` |
+
+浏览器验收为本地预览,未向正式业务提交评分或状态。机会卡片验证包含整卡打开对应报告;成员验证包含个人/全员数据与原实现一致、刷新及跨页记忆、搜索/键盘/外部点击收起、浮层位置和横向溢出。截图保存在 `screenshots/`,截图与回执时间为实际测试时间(UTC)。
+
+## 站点机执行
+
+从新检出目录切到交付的精确提交后,在该目录运行:
+
+```bash
+bash scripts/deploy-board-ui-20261112.sh /opt/kangshifu-web
+```
+
+需要 Node.js 20 或以上、站点写入权限以及读取现有 nginx MIME 配置的权限。脚本依次执行 MIME 只读预检、12 项发布测试、plan、apply、noop、六模块公网 SHA/MIME 核验和整站模块链检查,回执保存在输出的 `/opt/ksf-board-ui-20261112-results-*` 目录。首页应保持 SHA-256 `dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3`。
+
+未知现场文件、首页或载荷哈希不符时脚本停止;保留原始错误与现场,不修改清单基线强行继续。发布器写入前完整备份,文件采用 0644,写入失败会尝试恢复本轮已替换文件并删除本轮新增组件。成功发布的备份路径与逐文件 before/after SHA 在 `frontend-apply.json` 和备份 `journal.json` 中。需要回退时先按 journal 核实现网仍是本次 after SHA,再恢复旧件、移除新增件;不覆盖其他窗口的后续变更。
+
+## 上线浏览器验收
+
+在正式域名使用真实交互,至少检查 320、390、1440 三档 CSS 视口:
+
+1. 工作台能展开成员下拉,搜索姓名/小组、切换个人与全部成员;列表用用户图标,选择后的标题显示末字头像,进度/成果随选择更新,刷新与跨页返回仍保留。
+2. 机会卡片四类标签均在首行,完整显示阶段和状态,无重叠、裁字或页面横向溢出;整卡点击仍打开对应完整报告。
+3. 原报告、顶部刷新/登录、资料库入口正常,控制台无未捕获异常。验收不提交真实评分、决策或成果。
+
+回传精确提交、回执目录、plan/apply/noop 原文、备份路径、公网检查结果及三档截图;将部署成功与浏览器验收结果分别写明。

+ 60 - 0
docs/board-ui-release-20261112/baseline-check.json

@@ -0,0 +1,60 @@
+{
+  "ok": true,
+  "checkedAt": "2026-11-11T17:02:09.613Z",
+  "release": "ksf-board-ui-20261112-r1",
+  "readOnly": true,
+  "productionWrites": 0,
+  "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+  "files": 6,
+  "existingFilesVerified": 5,
+  "changed": [
+    "src/components/kf-badge.js",
+    "src/components/kf-member-picker.js",
+    "src/components/kf-opportunity-card.js",
+    "src/layout/kf-topbar.js",
+    "src/pages/view-workbench.js",
+    "src/layout/kf-app.js"
+  ],
+  "observations": [
+    {
+      "path": "src/components/kf-badge.js",
+      "status": 200,
+      "contentType": "application/javascript; charset=utf-8",
+      "rawSha256": "7e7214cbed12493924b577a929bb225254b6aff9af1843c97acba59809469b90",
+      "normalizedSha256": "e3ceae48f866067de873233d094e50bfc7707b7b6bb48cba63006293f1a910ae",
+      "changed": true
+    },
+    {
+      "path": "src/layout/kf-topbar.js",
+      "status": 200,
+      "contentType": "application/javascript; charset=utf-8",
+      "rawSha256": "e35af35166798db289c30924763e520ebba2ab3fd8b1e23cd74f232e8b9760da",
+      "normalizedSha256": "c77c5375cbc0f5ebcf3a7d59854752d744da6e5fc5b2fc6b5c70ed7e651302d6",
+      "changed": true
+    },
+    {
+      "path": "src/components/kf-opportunity-card.js",
+      "status": 200,
+      "contentType": "application/javascript; charset=utf-8",
+      "rawSha256": "3730d134f335f70055f00690ec8a0eaf68edc01b98903ad9d95fcd4721805682",
+      "normalizedSha256": "29894343e14a8c946b1553cc2cb2f966bb8007e5808ad486d0e8236ed2f72271",
+      "changed": true
+    },
+    {
+      "path": "src/layout/kf-app.js",
+      "status": 200,
+      "contentType": "application/javascript; charset=utf-8",
+      "rawSha256": "413c1f3a4364a710cb6c27343f3fbd02a3666834d77563cac519ab12fa417a1a",
+      "normalizedSha256": "6b681d70e54e949229f9fd3c37668175fd2117b724a10756f08f392772e2c17d",
+      "changed": true
+    },
+    {
+      "path": "src/pages/view-workbench.js",
+      "status": 200,
+      "contentType": "application/javascript; charset=utf-8",
+      "rawSha256": "67ad236087e058925239e25c7434f81efb1056325a70c6d102bdbfea421355d5",
+      "normalizedSha256": "67ad236087e058925239e25c7434f81efb1056325a70c6d102bdbfea421355d5",
+      "changed": true
+    }
+  ]
+}

+ 20 - 0
docs/board-ui-release-20261112/deploy-tests.txt

@@ -0,0 +1,20 @@
+✔ 完整前端发布预检只读,apply可重复且不替换首页 (1220.2774ms)
+✔ 未知现场拒绝,写入失败恢复,新静态文件保持0644 (888.3847ms)
+✔ 成员接口未知版本在写入前停止 (0.6218ms)
+✔ 成员接口已发布版本只读和apply均不重复写入 (0.2965ms)
+✔ six-file UI release applies and is idempotent while preserving an independent report update (112.7003ms)
+✔ UI release refuses an unexpected file list and an unknown baseline before writing (21.703ms)
+✔ UI release rolls back both replaced files and its newly added component on a write failure (63.2831ms)
+✔ 有效配置与真实模块响应均通过,预检保持只读 (34.8416ms)
+✔ 旧配置缺少mjs映射时,在请求前停止,注释不能冒充映射 (85.3293ms)
+✔ 配置正确但响应为octet-stream时拒绝,复现白屏原因 (6.3425ms)
+✔ SPA返回HTML或内容不符不能因200和JavaScript响应头而通过 (6.1992ms)
+✔ 首次发布尚不存在的模块明确留待发布后验证 (6.2563ms)
+ℹ tests 12
+ℹ suites 0
+ℹ pass 12
+ℹ fail 0
+ℹ cancelled 0
+ℹ skipped 0
+ℹ todo 0
+ℹ duration_ms 2210.928

+ 106 - 0
docs/board-ui-release-20261112/local-simulation.json

@@ -0,0 +1,106 @@
+{
+  "ok": true,
+  "checkedAt": "2026-11-11T17:04:39.428Z",
+  "target": "local copy of production files",
+  "productionWrites": 0,
+  "plan": {
+    "ok": true,
+    "operation": "read-only-plan",
+    "readOnly": true,
+    "targetDir": "F:\\kangshifu-board\\work\\board-ui-release-20261112\\fixture-site",
+    "release": "ksf-board-ui-20261112-r1",
+    "filesChecked": 6,
+    "changes": [
+      "src/components/kf-badge.js",
+      "src/components/kf-member-picker.js",
+      "src/components/kf-opportunity-card.js",
+      "src/layout/kf-topbar.js",
+      "src/pages/view-workbench.js",
+      "src/layout/kf-app.js"
+    ],
+    "unchanged": [],
+    "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+    "homepageModified": false,
+    "networkRequests": 0,
+    "cdnCacheRefreshed": false,
+    "publicSiteVerified": false,
+    "modeRepairs": [],
+    "backup": null,
+    "filesystemVerified": false,
+    "businessRecordWrites": 0,
+    "serviceRestarted": false
+  },
+  "apply": {
+    "ok": true,
+    "operation": "filesystem-update",
+    "readOnly": false,
+    "targetDir": "F:\\kangshifu-board\\work\\board-ui-release-20261112\\fixture-site",
+    "release": "ksf-board-ui-20261112-r1",
+    "filesChecked": 6,
+    "changes": [
+      "src/components/kf-badge.js",
+      "src/components/kf-member-picker.js",
+      "src/components/kf-opportunity-card.js",
+      "src/layout/kf-topbar.js",
+      "src/pages/view-workbench.js",
+      "src/layout/kf-app.js"
+    ],
+    "unchanged": [],
+    "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+    "homepageModified": false,
+    "networkRequests": 0,
+    "cdnCacheRefreshed": false,
+    "publicSiteVerified": false,
+    "backup": "F:\\kangshifu-board\\work\\board-ui-release-20261112\\ksf-industry-ui-backup-nI4ERx",
+    "filesystemVerified": true,
+    "publicVerificationRequired": true,
+    "businessRecordWrites": 0,
+    "serviceRestarted": false
+  },
+  "noop": {
+    "ok": true,
+    "operation": "read-only-plan",
+    "readOnly": true,
+    "targetDir": "F:\\kangshifu-board\\work\\board-ui-release-20261112\\fixture-site",
+    "release": "ksf-board-ui-20261112-r1",
+    "filesChecked": 6,
+    "changes": [],
+    "unchanged": [
+      "src/components/kf-badge.js",
+      "src/components/kf-member-picker.js",
+      "src/components/kf-opportunity-card.js",
+      "src/layout/kf-topbar.js",
+      "src/pages/view-workbench.js",
+      "src/layout/kf-app.js"
+    ],
+    "homepageSha256": "dfeb6a6d0b63e8e1190f9308bf6619a5ca523a5a2f91af5f7ca5ad5fc9642db3",
+    "homepageModified": false,
+    "networkRequests": 0,
+    "cdnCacheRefreshed": false,
+    "publicSiteVerified": false,
+    "modeRepairs": [
+      "src/components/kf-badge.js",
+      "src/components/kf-member-picker.js",
+      "src/components/kf-opportunity-card.js",
+      "src/layout/kf-topbar.js",
+      "src/pages/view-workbench.js",
+      "src/layout/kf-app.js"
+    ],
+    "backup": null,
+    "filesystemVerified": false,
+    "businessRecordWrites": 0,
+    "serviceRestarted": false
+  },
+  "protectedFiles": [
+    {
+      "path": "src/pages/report-styles.js",
+      "beforeSha256": "1dd7c76f670afc9d9cb900c5e234125d5a76fd34e29638f496b5900f9e57a630",
+      "afterSha256": "1dd7c76f670afc9d9cb900c5e234125d5a76fd34e29638f496b5900f9e57a630"
+    },
+    {
+      "path": "src/pages/view-report.js",
+      "beforeSha256": "3c01f1e40a56bbb507e806056734dc13ad88e8e05017fa0cd4c29b71d6ee6fa3",
+      "afterSha256": "3c01f1e40a56bbb507e806056734dc13ad88e8e05017fa0cd4c29b71d6ee6fa3"
+    }
+  ]
+}

+ 214 - 0
docs/board-ui-release-20261112/member-picker-browser.json

@@ -0,0 +1,214 @@
+{
+  "checkedAt": "2026-11-11T16:57:02.186Z",
+  "checks": [
+    {
+      "name": "成员与全部成员选项完整;展开聚焦搜索,Escape收起并恢复焦点",
+      "options": 41
+    },
+    {
+      "name": "点击选择后正确更新进度、成果、待评分,与原数据一致且保存在本机",
+      "member": "陈静"
+    },
+    {
+      "name": "点击选择后正确更新进度、成果、待评分,与原数据一致且保存在本机",
+      "member": "包玉刚"
+    },
+    {
+      "name": "点击选择后正确更新进度、成果、待评分,与原数据一致且保存在本机",
+      "member": "全部成员"
+    },
+    {
+      "name": "点击选择后正确更新进度、成果、待评分,与原数据一致且保存在本机",
+      "member": "包玉刚"
+    },
+    {
+      "name": "姓名与小组搜索、无结果状态、清空搜索、Enter选中均正常"
+    },
+    {
+      "name": "键盘打开、上下键、Home/End与Enter选择有效"
+    },
+    {
+      "name": "点外部、Tab离开与页面滚动可收起;列表自身滚动不误收起"
+    },
+    {
+      "name": "刷新及跨页返回保留查看人员"
+    },
+    {
+      "name": "浮层完整可见、不被卡片裁切;内部列表可滚动、无页面横向溢出",
+      "width": 320,
+      "height": 568,
+      "innerWidth": 320,
+      "clientWidth": 320,
+      "scrollWidth": 320,
+      "menu": {
+        "x": 12,
+        "y": 116,
+        "right": 308,
+        "bottom": 437.21875,
+        "width": 296,
+        "height": 321.21875
+      },
+      "optionHeight": 48,
+      "scrollable": true,
+      "topLayerVisible": true
+    },
+    {
+      "name": "浮层完整可见、不被卡片裁切;内部列表可滚动、无页面横向溢出",
+      "width": 360,
+      "height": 800,
+      "innerWidth": 360,
+      "clientWidth": 360,
+      "scrollWidth": 360,
+      "menu": {
+        "x": 20,
+        "y": 234.78125,
+        "right": 348,
+        "bottom": 674.78125,
+        "width": 328,
+        "height": 440
+      },
+      "optionHeight": 48,
+      "scrollable": true,
+      "topLayerVisible": true
+    },
+    {
+      "name": "浮层完整可见、不被卡片裁切;内部列表可滚动、无页面横向溢出",
+      "width": 390,
+      "height": 844,
+      "innerWidth": 390,
+      "clientWidth": 390,
+      "scrollWidth": 390,
+      "menu": {
+        "x": 25,
+        "y": 234.78125,
+        "right": 353,
+        "bottom": 674.78125,
+        "width": 328,
+        "height": 440
+      },
+      "optionHeight": 48,
+      "scrollable": true,
+      "topLayerVisible": true
+    },
+    {
+      "name": "浮层完整可见、不被卡片裁切;内部列表可滚动、无页面横向溢出",
+      "width": 430,
+      "height": 932,
+      "innerWidth": 430,
+      "clientWidth": 430,
+      "scrollWidth": 430,
+      "menu": {
+        "x": 25,
+        "y": 234.78125,
+        "right": 353,
+        "bottom": 674.78125,
+        "width": 328,
+        "height": 440
+      },
+      "optionHeight": 48,
+      "scrollable": true,
+      "topLayerVisible": true
+    },
+    {
+      "name": "浮层完整可见、不被卡片裁切;内部列表可滚动、无页面横向溢出",
+      "width": 844,
+      "height": 390,
+      "innerWidth": 844,
+      "clientWidth": 844,
+      "scrollWidth": 829,
+      "menu": {
+        "x": 229,
+        "y": 12,
+        "right": 557,
+        "bottom": 252,
+        "width": 328,
+        "height": 240
+      },
+      "optionHeight": 46,
+      "scrollable": true,
+      "topLayerVisible": true
+    },
+    {
+      "name": "浮层完整可见、不被卡片裁切;内部列表可滚动、无页面横向溢出",
+      "width": 768,
+      "height": 1024,
+      "innerWidth": 768,
+      "clientWidth": 768,
+      "scrollWidth": 753,
+      "menu": {
+        "x": 229,
+        "y": 240.78125,
+        "right": 557,
+        "bottom": 680.78125,
+        "width": 328,
+        "height": 440
+      },
+      "optionHeight": 46,
+      "scrollable": true,
+      "topLayerVisible": true
+    },
+    {
+      "name": "浮层完整可见、不被卡片裁切;内部列表可滚动、无页面横向溢出",
+      "width": 1024,
+      "height": 768,
+      "innerWidth": 1024,
+      "clientWidth": 1024,
+      "scrollWidth": 1009,
+      "menu": {
+        "x": 273,
+        "y": 242.78125,
+        "right": 601,
+        "bottom": 682.78125,
+        "width": 328,
+        "height": 440
+      },
+      "optionHeight": 46,
+      "scrollable": true,
+      "topLayerVisible": true
+    },
+    {
+      "name": "浮层完整可见、不被卡片裁切;内部列表可滚动、无页面横向溢出",
+      "width": 1440,
+      "height": 900,
+      "innerWidth": 1440,
+      "clientWidth": 1440,
+      "scrollWidth": 1425,
+      "menu": {
+        "x": 273,
+        "y": 242.78125,
+        "right": 601,
+        "bottom": 682.78125,
+        "width": 328,
+        "height": 440
+      },
+      "optionHeight": 46,
+      "scrollable": true,
+      "topLayerVisible": true
+    },
+    {
+      "name": "浮层完整可见、不被卡片裁切;内部列表可滚动、无页面横向溢出",
+      "width": 2048,
+      "height": 1000,
+      "innerWidth": 2048,
+      "clientWidth": 2048,
+      "scrollWidth": 2033,
+      "menu": {
+        "x": 273,
+        "y": 242.78125,
+        "right": 601,
+        "bottom": 682.78125,
+        "width": 328,
+        "height": 440
+      },
+      "optionHeight": 46,
+      "scrollable": true,
+      "topLayerVisible": true
+    },
+    {
+      "name": "手机触控模拟可展开、搜索并点击选中"
+    }
+  ],
+  "errors": [],
+  "blocked": [],
+  "businessWrites": 0
+}

+ 118 - 0
docs/board-ui-release-20261112/opportunity-browser.json

@@ -0,0 +1,118 @@
+{
+  "checkedAt": "2026-11-11T16:21:18.387Z",
+  "checks": [
+    {
+      "name": "所有卡片内容、标签和业务对象保持一致",
+      "cards": 12
+    },
+    {
+      "name": "复现上一版阶段标签换行问题",
+      "affectedCardsAndWidths": 11
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 320,
+      "cards": 12,
+      "innerWidth": 320,
+      "clientWidth": 320,
+      "scrollWidth": 320
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 360,
+      "cards": 12,
+      "innerWidth": 360,
+      "clientWidth": 360,
+      "scrollWidth": 360
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 390,
+      "cards": 12,
+      "innerWidth": 390,
+      "clientWidth": 390,
+      "scrollWidth": 390
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 430,
+      "cards": 12,
+      "innerWidth": 430,
+      "clientWidth": 430,
+      "scrollWidth": 430
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 437,
+      "cards": 12,
+      "innerWidth": 437,
+      "clientWidth": 437,
+      "scrollWidth": 437
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 768,
+      "cards": 12,
+      "innerWidth": 768,
+      "clientWidth": 768,
+      "scrollWidth": 753
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 800,
+      "cards": 12,
+      "innerWidth": 800,
+      "clientWidth": 800,
+      "scrollWidth": 785
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 1024,
+      "cards": 12,
+      "innerWidth": 1024,
+      "clientWidth": 1024,
+      "scrollWidth": 1009
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 1200,
+      "cards": 12,
+      "innerWidth": 1200,
+      "clientWidth": 1200,
+      "scrollWidth": 1185
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 1280,
+      "cards": 12,
+      "innerWidth": 1280,
+      "clientWidth": 1280,
+      "scrollWidth": 1265
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 1440,
+      "cards": 12,
+      "innerWidth": 1440,
+      "clientWidth": 1440,
+      "scrollWidth": 1425
+    },
+    {
+      "name": "优先级、品类、完整阶段、决策状态全部在第一行,无重叠、裁字或页面横向溢出",
+      "width": 1536,
+      "cards": 12,
+      "innerWidth": 1536,
+      "clientWidth": 1536,
+      "scrollWidth": 1521
+    },
+    {
+      "name": "整卡点击仍正常打开对应报告",
+      "opportunityId": "UFIngQqa7H",
+      "reportId": "W42NYTiJFm",
+      "url": "http://127.0.0.1:4181/report/W42NYTiJFm?returnTo=%2Fpool&opportunity=UFIngQqa7H"
+    }
+  ],
+  "errors": [],
+  "blocked": [],
+  "businessWrites": 0
+}

BIN
docs/board-ui-release-20261112/screenshots/dropdown-1440.png


BIN
docs/board-ui-release-20261112/screenshots/dropdown-320.png


BIN
docs/board-ui-release-20261112/screenshots/dropdown-390.png


BIN
docs/board-ui-release-20261112/screenshots/opportunity-1440.png


BIN
docs/board-ui-release-20261112/screenshots/opportunity-320.png


BIN
docs/board-ui-release-20261112/screenshots/opportunity-390.png


+ 18 - 0
scripts/deploy-board-ui-20261112.sh

@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+if [[ $# -ne 1 || "$1" != /opt/kangshifu-web ]]; then
+  echo 'Usage: bash scripts/deploy-board-ui-20261112.sh /opt/kangshifu-web' >&2
+  exit 2
+fi
+cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
+umask 022
+node -e 'if(Number(process.versions.node.split(".")[0])<20)process.exit(1)'
+results_dir="$(mktemp -d /opt/ksf-board-ui-20261112-results-XXXXXXXX)"
+node scripts/preflight-mime-mjs.mjs --origin https://kangshifu.fmode.cn --target-dir "$1" | tee "$results_dir/mime-preflight.json"
+node --test test/board-local-deploy.test.mjs test/board-ui-deploy.test.mjs test/mjs-mime-preflight.test.mjs | tee "$results_dir/deploy-tests.txt"
+node scripts/publish-board-ui-20261112.mjs --target-dir "$1" | tee "$results_dir/frontend-plan.json"
+node scripts/publish-board-ui-20261112.mjs --target-dir "$1" --apply | tee "$results_dir/frontend-apply.json"
+node scripts/publish-board-ui-20261112.mjs --target-dir "$1" | tee "$results_dir/frontend-noop.json"
+node scripts/verify-board-local-public.mjs --manifest deploy/board-ui-20261112.json | tee "$results_dir/public-verification.json"
+node scripts/verify-modules.mjs https://kangshifu.fmode.cn/ --json | tee "$results_dir/module-verification.json"
+echo "UI deployment complete. Browser acceptance remains required. Receipts: $results_dir"

+ 8 - 6
scripts/publish-board-local.mjs

@@ -5,7 +5,8 @@ import {fileURLToPath} from 'node:url';
 
 
 const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
 const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
 const manifestFile=path.join(root,'deploy/board-local-20261111.json');
 const manifestFile=path.join(root,'deploy/board-local-20261111.json');
-export const FILES=Object.freeze(JSON.parse(fs.readFileSync(manifestFile,'utf8')).files.map(f=>f.path));
+const releaseManifest=JSON.parse(fs.readFileSync(manifestFile,'utf8'));
+export const FILES=Object.freeze(releaseManifest.files.map(f=>f.path));
 const sha=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
 const sha=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
 const normalized=bytes=>sha(bytes.includes(0)?bytes:Buffer.from(bytes.toString('utf8').replaceAll('\r\n','\n')));
 const normalized=bytes=>sha(bytes.includes(0)?bytes:Buffer.from(bytes.toString('utf8').replaceAll('\r\n','\n')));
 const fail=(code,file)=>{throw Object.assign(Error(code),{code,...(file?{file}:{})});};
 const fail=(code,file)=>{throw Object.assign(Error(code),{code,...(file?{file}:{})});};
@@ -30,18 +31,19 @@ function atomicWrite(file,bytes,mode){
 }
 }
 
 
 /** Filesystem adapter only. Target is supplied by the site maintainer, never inferred from HTTP. */
 /** Filesystem adapter only. Target is supplied by the site maintainer, never inferred from HTTP. */
-export function publishFiles({sourceDir,manifestPath,targetDir,apply=false},dependencies={}){
+export function publishFiles({sourceDir,manifestPath,targetDir,apply=false,releaseDefinition=releaseManifest},dependencies={}){
  if(!path.isAbsolute(sourceDir)||!path.isAbsolute(targetDir))fail('ABSOLUTE_PATH_REQUIRED');
  if(!path.isAbsolute(sourceDir)||!path.isAbsolute(targetDir))fail('ABSOLUTE_PATH_REQUIRED');
  sourceDir=path.resolve(sourceDir);targetDir=path.resolve(targetDir);
  sourceDir=path.resolve(sourceDir);targetDir=path.resolve(targetDir);
  if(fs.realpathSync(targetDir)!==targetDir||targetDir===path.parse(targetDir).root)fail('UNSAFE_TARGET_DIRECTORY');
  if(fs.realpathSync(targetDir)!==targetDir||targetDir===path.parse(targetDir).root)fail('UNSAFE_TARGET_DIRECTORY');
  if(sourceDir===targetDir||sourceDir.startsWith(targetDir+path.sep)||targetDir.startsWith(sourceDir+path.sep))fail('SOURCE_TARGET_OVERLAP');
  if(sourceDir===targetDir||sourceDir.startsWith(targetDir+path.sep)||targetDir.startsWith(sourceDir+path.sep))fail('SOURCE_TARGET_OVERLAP');
  const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));
  const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));
- if(manifest.version!=='ksf.board-local.publish.v1'||manifest.release!=='ksf-board-local-20261111-r1'||!/^[a-f0-9]{64}$/.test(manifest.homepageSha256))fail('BAD_MANIFEST');
- if(!Array.isArray(manifest.files)||JSON.stringify(manifest.files.map(f=>f.path).sort())!==JSON.stringify([...FILES].sort())||new Set(FILES).size!==FILES.length||FILES.some(p=>!(/^(?:src\/(?!dev\/)[a-zA-Z0-9_./-]+\.(?:js|woff2|json)|lib\/vendor\/[a-zA-Z0-9_@./-]+\.js|skills\/ksf-workshop-runtime\/scripts\/report-contract\.mjs)$/.test(p))))fail('UNEXPECTED_FILE_LIST');
+ if(manifest.version!=='ksf.board-local.publish.v1'||manifest.release!==releaseDefinition.release||!/^[a-f0-9]{64}$/.test(manifest.homepageSha256))fail('BAD_MANIFEST');
+ const expectedFiles=releaseDefinition.files.map(f=>f.path);
+ if(!Array.isArray(manifest.files)||JSON.stringify(manifest.files.map(f=>f.path).sort())!==JSON.stringify([...expectedFiles].sort())||new Set(expectedFiles).size!==expectedFiles.length||expectedFiles.some(p=>!(/^(?:src\/(?!dev\/)[a-zA-Z0-9_./-]+\.(?:js|woff2|json)|lib\/vendor\/[a-zA-Z0-9_@./-]+\.js|skills\/ksf-workshop-runtime\/scripts\/report-contract\.mjs)$/.test(p))))fail('UNEXPECTED_FILE_LIST');
  const index=safePath(targetDir,'index.html');plain(index);const indexHash=sha(fs.readFileSync(index));
  const index=safePath(targetDir,'index.html');plain(index);const indexHash=sha(fs.readFileSync(index));
  if(indexHash!==manifest.homepageSha256)fail('HOMEPAGE_MISMATCH','index.html');
  if(indexHash!==manifest.homepageSha256)fail('HOMEPAGE_MISMATCH','index.html');
  const byName=new Map(manifest.files.map(f=>[f.path,f]));
  const byName=new Map(manifest.files.map(f=>[f.path,f]));
- const entries=FILES.map(relative=>{
+ const entries=expectedFiles.map(relative=>{
   const def=byName.get(relative),source=safePath(sourceDir,relative),target=safePath(targetDir,relative);plain(source);
   const def=byName.get(relative),source=safePath(sourceDir,relative),target=safePath(targetDir,relative);plain(source);
   if(!/^[a-f0-9]{64}$/.test(def.sha256)||!Array.isArray(def.priorNormalizedSha256)||def.priorNormalizedSha256.some(h=>!/^[a-f0-9]{64}$/.test(h)))fail('BAD_MANIFEST');
   if(!/^[a-f0-9]{64}$/.test(def.sha256)||!Array.isArray(def.priorNormalizedSha256)||def.priorNormalizedSha256.some(h=>!/^[a-f0-9]{64}$/.test(h)))fail('BAD_MANIFEST');
   const bytes=fs.readFileSync(source);if(sha(bytes)!==def.sha256&&normalized(bytes)!==def.sha256)fail('PAYLOAD_HASH_MISMATCH',relative);
   const bytes=fs.readFileSync(source);if(sha(bytes)!==def.sha256&&normalized(bytes)!==def.sha256)fail('PAYLOAD_HASH_MISMATCH',relative);
@@ -53,7 +55,7 @@ export function publishFiles({sourceDir,manifestPath,targetDir,apply=false},depe
  });
  });
  const modeRepairs=entries.filter(e=>e.same&&fs.existsSync(e.target)&&(fs.statSync(e.target).mode&0o777)!==0o644);
  const modeRepairs=entries.filter(e=>e.same&&fs.existsSync(e.target)&&(fs.statSync(e.target).mode&0o777)!==0o644);
  const changes=entries.filter(e=>!e.same);
  const changes=entries.filter(e=>!e.same);
- const base={operation:apply?'filesystem-update':'read-only-plan',readOnly:!apply,targetDir,release:manifest.release,filesChecked:FILES.length,changes:changes.map(e=>e.relative),unchanged:entries.filter(e=>e.same).map(e=>e.relative),homepageSha256:indexHash,homepageModified:false,networkRequests:0,cdnCacheRefreshed:false,publicSiteVerified:false};
+ const base={operation:apply?'filesystem-update':'read-only-plan',readOnly:!apply,targetDir,release:manifest.release,filesChecked:expectedFiles.length,changes:changes.map(e=>e.relative),unchanged:entries.filter(e=>e.same).map(e=>e.relative),homepageSha256:indexHash,homepageModified:false,networkRequests:0,cdnCacheRefreshed:false,publicSiteVerified:false};
  if(!apply||!changes.length){if(apply)for(const e of modeRepairs)fs.chmodSync(e.target,0o644);return{ok:true,...base,modeRepairs:modeRepairs.map(e=>e.relative),backup:null,filesystemVerified:apply};}
  if(!apply||!changes.length){if(apply)for(const e of modeRepairs)fs.chmodSync(e.target,0o644);return{ok:true,...base,modeRepairs:modeRepairs.map(e=>e.relative),backup:null,filesystemVerified:apply};}
 
 
  const lock=path.join(path.dirname(targetDir),'.'+path.basename(targetDir)+'.ksf-industry-ui.lock');
  const lock=path.join(path.dirname(targetDir),'.'+path.basename(targetDir)+'.ksf-industry-ui.lock');

+ 44 - 0
scripts/publish-board-ui-20261112.mjs

@@ -0,0 +1,44 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { publishFiles } from './publish-board-local.mjs';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+export const MANIFEST_PATH = path.join(root, 'deploy/board-ui-20261112.json');
+export const UI_FILES = Object.freeze([
+  'src/components/kf-badge.js',
+  'src/components/kf-member-picker.js',
+  'src/components/kf-opportunity-card.js',
+  'src/layout/kf-topbar.js',
+  'src/pages/view-workbench.js',
+  'src/layout/kf-app.js',
+]);
+
+export function publishBoardUi({ targetDir, sourceDir = root, manifestPath = MANIFEST_PATH, apply = false }, dependencies = {}) {
+  const definition = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
+  if (definition.release !== 'ksf-board-ui-20261112-r1'
+    || JSON.stringify(definition.files.map(file => file.path)) !== JSON.stringify(UI_FILES)) {
+    throw Object.assign(Error('UNEXPECTED_UI_RELEASE'), { code: 'UNEXPECTED_UI_RELEASE' });
+  }
+  const result = publishFiles({ sourceDir, manifestPath, targetDir, apply, releaseDefinition: definition }, dependencies);
+  return { ...result, businessRecordWrites: 0, serviceRestarted: false };
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+  try {
+    const args = process.argv.slice(2);
+    let targetDir, apply = false;
+    for (let index = 0; index < args.length; index++) {
+      if (args[index] === '--target-dir' && !targetDir && args[index + 1] && !args[index + 1].startsWith('--')) targetDir = args[++index];
+      else if (args[index] === '--apply' && !apply) apply = true;
+      else throw Error('USAGE');
+    }
+    if (targetDir !== '/opt/kangshifu-web') throw Error('TARGET_REQUIRED');
+    console.log(JSON.stringify(publishBoardUi({ targetDir, apply }), null, 2));
+  } catch (error) {
+    console.error(JSON.stringify({ ok: false, error: { code: error.code || 'UI_RELEASE_STOPPED',
+      ...(UI_FILES.includes(error.file) || error.file === 'index.html' ? { file: error.file } : {}) },
+      ...(error.backup ? { backup: error.backup, rollbackComplete: error.rollbackComplete } : {}), businessRecordWrites: 0 }, null, 2));
+    process.exitCode = 1;
+  }
+}

+ 5 - 3
scripts/verify-board-local-public.mjs

@@ -1,12 +1,14 @@
 import fs from 'node:fs/promises';
 import fs from 'node:fs/promises';
 import crypto from 'node:crypto';
 import crypto from 'node:crypto';
-const base='https://kangshifu.fmode.cn/',manifest=JSON.parse(await fs.readFile(new URL('../deploy/board-local-20261111.json',import.meta.url)));
+const args=process.argv.slice(2);
+if(args.length&&(args.length!==2||args[0]!=='--manifest'||!/^deploy\/[a-zA-Z0-9_.-]+\.json$/.test(args[1])))throw Error('USAGE');
+const base='https://kangshifu.fmode.cn/',manifest=JSON.parse(await fs.readFile(new URL('../'+(args[1]||'deploy/board-local-20261111.json'),import.meta.url)));
 const sha=b=>crypto.createHash('sha256').update(b).digest('hex');
 const sha=b=>crypto.createHash('sha256').update(b).digest('hex');
 const normal=b=>b.includes(0)?b:Buffer.from(b.toString('utf8').replaceAll('\r\n','\n'));
 const normal=b=>b.includes(0)?b:Buffer.from(b.toString('utf8').replaceAll('\r\n','\n'));
-const receipt={ok:false,checkedAt:new Date().toISOString(),modules:[],checks:[],businessWrites:0};
+const receipt={ok:false,release:manifest.release,checkedAt:new Date().toISOString(),modules:[],checks:[],businessWrites:0};
 let next=0;
 let next=0;
 try{
 try{
- await Promise.all(Array.from({length:6},async()=>{while(next<manifest.files.length){const file=manifest.files[next++],url=new URL(file.path,base);url.searchParams.set('boardRelease','20261111-'+Date.now());
+ await Promise.all(Array.from({length:6},async()=>{while(next<manifest.files.length){const file=manifest.files[next++],url=new URL(file.path,base);url.searchParams.set('boardRelease',manifest.release+'-'+Date.now());
   const r=await fetch(url,{headers:{'cache-control':'no-cache'},signal:AbortSignal.timeout(45000)}),bytes=Buffer.from(await r.arrayBuffer());
   const r=await fetch(url,{headers:{'cache-control':'no-cache'},signal:AbortSignal.timeout(45000)}),bytes=Buffer.from(await r.arrayBuffer());
   const contentType=r.headers.get('content-type')||'',javascript=/\.m?js$/.test(file.path);
   const contentType=r.headers.get('content-type')||'',javascript=/\.m?js$/.test(file.path);
   const mimeMatches=!javascript||/^(?:application|text)\/javascript(?:\s*;|$)/i.test(contentType);
   const mimeMatches=!javascript||/^(?:application|text)\/javascript(?:\s*;|$)/i.test(contentType);

+ 5 - 1
scripts/verify-board-login-browser.mjs

@@ -101,7 +101,11 @@ try {
   await page.waitForURL(origin + '/workshop?mode=all');
   await page.waitForURL(origin + '/workshop?mode=all');
   await page.locator('kf-topbar .account-name').waitFor();
   await page.locator('kf-topbar .account-name').waitFor();
   assert.match(await page.locator('kf-topbar .account-name').textContent(), /登录测试成员/);
   assert.match(await page.locator('kf-topbar .account-name').textContent(), /登录测试成员/);
-  await page.locator('kf-topbar select').selectOption({ label: '测试乙' });
+  await page.goto(origin + '/');
+  await page.locator('view-workbench kf-member-picker .member-picker').click();
+  await page.locator('view-workbench kf-member-picker [data-member="测试乙"]').click();
+  assert.equal(await page.locator('kf-topbar select').count(), 0);
+  assert.equal(await page.locator('view-workbench .member-name .name').textContent(), '测试乙');
   assert.match(await page.locator('kf-topbar .account-name').textContent(), /登录测试成员/);
   assert.match(await page.locator('kf-topbar .account-name').textContent(), /登录测试成员/);
   await page.screenshot({ path: path.join(out, 'logged-in-filter-390.png') });
   await page.screenshot({ path: path.join(out, 'logged-in-filter-390.png') });
   checks.push('登录返回原页面;切换查看成员保留真实登录账号');
   checks.push('登录返回原页面;切换查看成员保留真实登录账号');

+ 1 - 1
src/components/kf-badge.js

@@ -87,7 +87,7 @@ export class KfBadge extends LitElement {
 
 
   render() {
   render() {
     // 中英混排(v11 1.3):「S1 市场分析」里 S1 与汉字之间插 U+2009(页面从文本节点里读到)
     // 中英混排(v11 1.3):「S1 市场分析」里 S1 与汉字之间插 U+2009(页面从文本节点里读到)
-    return html`<span class="b ${this.#tone} ${this.#dot ? '' : 'no-dot'}" data-cjk=${unthin(spaceCJK(this.#text))}>${spaceCJK(this.#text)}</span>`;
+    return html`<span part="badge" class="b ${this.#tone} ${this.#dot ? '' : 'no-dot'}" data-cjk=${unthin(spaceCJK(this.#text))}>${spaceCJK(this.#text)}</span>`;
   }
   }
 }
 }
 
 

+ 237 - 0
src/components/kf-member-picker.js

@@ -0,0 +1,237 @@
+/** 工作台成员浏览选择器;仅派发原有 me-change,不切换登录账号。 */
+import { LitElement, html, css, nothing } from 'lit';
+import { useTokens, define } from './base.js';
+import { icon } from './icons.js';
+
+export class KfMemberPicker extends LitElement {
+  static properties = {
+    members: { type: Array },
+    value: { type: String },
+    summary: { type: String },
+    _open: { state: true },
+    _query: { state: true },
+    _active: { state: true },
+  };
+
+  constructor() {
+    super();
+    this.members = [];
+    this.value = '';
+    this.summary = '';
+    this._open = false;
+    this._query = '';
+    this._active = 0;
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    useTokens(this);
+    window.addEventListener('resize', this.#positionMenu);
+    window.addEventListener('scroll', this.#onScroll, true);
+    window.visualViewport?.addEventListener('resize', this.#positionMenu);
+  }
+
+  disconnectedCallback() {
+    window.removeEventListener('resize', this.#positionMenu);
+    window.removeEventListener('scroll', this.#onScroll, true);
+    window.visualViewport?.removeEventListener('resize', this.#positionMenu);
+    super.disconnectedCallback();
+  }
+
+  static styles = css`
+    :host { display: block; justify-self: start; min-width: 0; max-width: 100%; }
+    button, input { font-family: var(--kf-font); }
+    button { cursor: pointer; }
+    .member-picker { display: flex; align-items: center; gap: 12px; padding: 6px 8px; margin: -6px -8px;
+      max-width: calc(100% + 16px); min-height: 54px; box-sizing: border-box; border: 1px solid transparent;
+      border-radius: 10px; background: transparent; color: var(--kf-ink); text-align: left; transition: var(--kf-t-colors); }
+    .member-picker:hover, .member-picker[aria-expanded="true"] { background: var(--kf-surface-btn); border-color: var(--kf-line-soft); }
+    .member-picker:focus-visible, .search-clear:focus-visible { outline: 2px solid var(--kf-focus); outline-offset: 2px; }
+    .avatar { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; flex: none;
+      background: var(--kf-ink); color: #fff; font-size: 20px; line-height: 1; font-weight: 600; }
+    .avatar.all { background: var(--kf-fill); color: var(--kf-ink-3); }
+    .me-title { display: grid; gap: 2px; min-width: 0; }
+    .member-name { display: flex; align-items: center; gap: 8px; font-size: 16px; line-height: 1.4; font-weight: 600; }
+    .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+    .chevron { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 6px;
+      color: var(--kf-ink-3); background: var(--kf-fill); flex: none; transition: var(--kf-t-colors); }
+    .member-picker[aria-expanded="true"] .chevron { background: var(--kf-accent-soft); color: var(--kf-accent-ink); }
+    .chevron .i { transition: transform 160ms ease; }
+    .member-picker[aria-expanded="true"] .chevron .i { transform: rotate(180deg); }
+    .sub { font-size: 12px; font-weight: 400; line-height: 1.5; color: var(--kf-ink-3); }
+
+    /* top layer 避免卡片的 overflow 裁切列表,位置按实际可用视口计算。 */
+    .member-menu { position: fixed; inset: auto; margin: 0; padding: 8px; width: 328px; box-sizing: border-box;
+      max-width: calc(100vw - 24px); max-height: 440px; border: 1px solid var(--kf-line-soft); border-radius: 14px;
+      background: var(--kf-surface); color: var(--kf-ink); overflow: hidden;
+      box-shadow: 0 16px 48px rgba(24, 24, 39, .13), 0 3px 10px rgba(24, 24, 39, .06); }
+    .member-menu:popover-open { display: flex; flex-direction: column; }
+    .menu-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 8px 12px; flex: none; }
+    .menu-heading strong { font-size: 14px; font-weight: 600; }
+    .menu-heading small { font-size: 12px; color: var(--kf-ink-3); font-variant-numeric: tabular-nums; }
+    .search { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 10px; margin: 0 4px 8px;
+      border: 1px solid var(--kf-line-soft); border-radius: 8px; background: var(--kf-surface-btn); color: var(--kf-ink-3); flex: none; }
+    .search:focus-within { border-color: color-mix(in srgb, var(--kf-accent) 50%, transparent); background: var(--kf-surface);
+      box-shadow: 0 0 0 3px var(--kf-accent-soft); }
+    .search input { width: 100%; min-width: 0; height: 38px; padding: 0; border: 0; outline: none;
+      background: transparent; color: var(--kf-ink); font-size: 14px; }
+    .search input::placeholder { color: var(--kf-ink-3); }
+    .search .i { flex: none; }
+    .search-clear { display: grid; place-items: center; flex: none; width: 26px; height: 26px; padding: 0;
+      border: 0; border-radius: 5px; background: transparent; color: var(--kf-ink-3); }
+    .search-clear:hover { background: var(--kf-fill); color: var(--kf-ink); }
+    .options { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scroll-behavior: auto; padding: 2px;
+      scrollbar-width: thin; scrollbar-color: var(--kf-line-strong) transparent; }
+    .option { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 46px; padding: 7px 10px;
+      box-sizing: border-box; border: 0; border-radius: 8px; background: transparent; color: var(--kf-ink); text-align: left;
+      transition: var(--kf-t-colors); }
+    .option + .option { margin-top: 2px; }
+    .option:hover, .option.active { background: var(--kf-surface-btn); }
+    .option[aria-selected="true"] { background: var(--kf-accent-soft); color: var(--kf-accent-ink); }
+    .option[aria-selected="true"].active { background: color-mix(in srgb, var(--kf-accent) 14%, var(--kf-surface)); }
+    .option-icon { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; flex: none;
+      background: var(--kf-fill); color: var(--kf-ink-2); }
+    .option[aria-selected="true"] .option-icon { background: color-mix(in srgb, var(--kf-accent) 14%, var(--kf-surface)); color: var(--kf-accent-ink); }
+    .option .name { font-size: 14px; font-weight: 500; }
+    .option .group { margin-left: auto; padding-left: 6px; color: var(--kf-ink-3); font-size: 12px; white-space: nowrap; }
+    .option .check { display: grid; place-items: center; width: 18px; margin-left: auto; color: var(--kf-accent-ink); flex: none; }
+    .option .group + .check { margin-left: 0; }
+    .all-option { margin-bottom: 6px; }
+    .no-match { margin: 8px 10px 12px; color: var(--kf-ink-3); font-size: 13px; line-height: 1.6; text-align: center; }
+    @media (max-width: 767px) { .search input { font-size: 16px; } .option { min-height: 48px; } }
+    @media (prefers-reduced-motion: reduce) { .chevron .i { transition: none; } }
+  `;
+
+  get #options() {
+    const query = this._query.trim().toLocaleLowerCase();
+    return [{ name: '', label: '全部成员' }, ...this.members.filter(member =>
+      !query || `${member.name} ${member.groupId || ''}`.toLocaleLowerCase().includes(query)
+    ).map(member => ({ ...member, label: member.name }))];
+  }
+
+  #onScroll = event => {
+    const menu = this.renderRoot.querySelector('.member-menu');
+    if (menu?.matches(':popover-open') && !event.composedPath().includes(menu)) this.#closeMenu(false);
+  };
+
+  #positionMenu = () => {
+    const menu = this.renderRoot.querySelector('.member-menu');
+    if (!menu?.matches(':popover-open')) return;
+    const anchor = this.renderRoot.querySelector('.member-picker').getBoundingClientRect();
+    const viewport = window.visualViewport;
+    const left = viewport?.offsetLeft || 0, top = viewport?.offsetTop || 0;
+    const width = viewport?.width || document.documentElement.clientWidth, height = viewport?.height || innerHeight;
+    const menuWidth = Math.min(328, width - 24);
+    const below = top + height - anchor.bottom - 20, above = anchor.top - top - 20;
+    const upwards = below < 260 && above > below;
+    menu.style.width = `${menuWidth}px`;
+    menu.style.maxHeight = `${Math.min(440, height - 24, Math.max(240, upwards ? above : below))}px`;
+    menu.style.left = `${Math.max(left + 12, Math.min(anchor.left, left + width - menuWidth - 12))}px`;
+    const menuHeight = menu.getBoundingClientRect().height;
+    const y = upwards ? anchor.top - menuHeight - 8 : anchor.bottom + 8;
+    menu.style.top = `${Math.max(top + 12, Math.min(y, top + height - menuHeight - 12))}px`;
+    this.#scrollActive();
+  };
+
+  async #openMenu() {
+    this._query = '';
+    this._active = Math.max(0, this.#options.findIndex(option => option.name === this.value));
+    await this.updateComplete;
+    if (!this.isConnected) return;
+    const menu = this.renderRoot.querySelector('.member-menu');
+    menu.showPopover();
+    this.#positionMenu();
+    this.renderRoot.querySelector('.search input').focus({ preventScroll: true });
+  }
+
+  #closeMenu(focus = true) {
+    const menu = this.renderRoot.querySelector('.member-menu');
+    if (menu?.matches(':popover-open')) menu.hidePopover();
+    if (focus) this.renderRoot.querySelector('.member-picker')?.focus({ preventScroll: true });
+  }
+
+  async #onToggle(event) {
+    this._open = event.newState === 'open';
+    if (this._open) {
+      await this.updateComplete;
+      this.#positionMenu();
+    }
+  }
+
+  #toggleMenu() {
+    if (this.renderRoot.querySelector('.member-menu').matches(':popover-open')) this.#closeMenu();
+    else this.#openMenu();
+  }
+
+  #choose(name) {
+    this.#closeMenu();
+    this.dispatchEvent(new CustomEvent('me-change', { detail: { name }, bubbles: true, composed: true }));
+  }
+
+  async #scrollActive() {
+    await this.updateComplete;
+    const list = this.renderRoot.querySelector('.options');
+    const option = this.renderRoot.querySelector(`#member-option-${this._active}`);
+    if (!list || !option) return;
+    const bounds = list.getBoundingClientRect(), row = option.getBoundingClientRect();
+    if (row.top < bounds.top) list.scrollTop += row.top - bounds.top;
+    else if (row.bottom > bounds.bottom) list.scrollTop += row.bottom - bounds.bottom;
+  }
+
+  #filter(event) {
+    this._query = event.target.value;
+    this._active = this._query.trim() && this.#options.length > 1 ? 1 : 0;
+    this.#scrollActive();
+  }
+
+  #onKeydown(event) {
+    const options = this.#options;
+    if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); this.#closeMenu(); }
+    else if (event.key === 'Tab') this.#closeMenu(false);
+    else if (event.key === 'Enter') { event.preventDefault(); this.#choose(options[this._active]?.name || ''); }
+    else if (['ArrowDown', 'ArrowUp'].includes(event.key) || (!this._query && ['Home', 'End'].includes(event.key))) {
+      event.preventDefault();
+      this._active = event.key === 'Home' ? 0 : event.key === 'End' ? options.length - 1
+        : (this._active + (event.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length;
+      this.#scrollActive();
+    }
+  }
+
+  render() {
+    const options = this.#options;
+    return html`
+      <button type="button" class="member-picker" aria-label="查看成员" aria-haspopup="dialog"
+        aria-expanded=${String(this._open)} aria-controls="member-menu" @click=${this.#toggleMenu}
+        @keydown=${event => { if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); this.#openMenu(); } }}>
+        <span class="avatar ${this.value ? '' : 'all'}" aria-hidden="true">${this.value ? Array.from(this.value).at(-1) : icon('users', 22)}</span>
+        <span class="me-title"><span class="member-name"><span class="name">${this.value || '全部成员'}</span><span class="chevron">${icon('chev', 14)}</span></span>
+          <span class="sub">${this.summary}</span></span>
+      </button>
+      <div id="member-menu" class="member-menu" popover="auto" role="dialog" aria-labelledby="member-menu-title"
+        @toggle=${this.#onToggle}
+        @focusout=${event => { if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) this.#closeMenu(false); }}>
+        <div class="menu-heading"><strong id="member-menu-title">查看成员</strong><small>${options.length - 1} 位</small></div>
+        <div class="search">${icon('search', 16)}
+          <input type="text" placeholder="搜索姓名或小组" aria-label="搜索成员" role="combobox" aria-autocomplete="list"
+            aria-expanded=${String(this._open)} aria-controls="member-options" aria-activedescendant=${this._open ? `member-option-${this._active}` : nothing}
+            .value=${this._query} @input=${this.#filter} @keydown=${this.#onKeydown} autocomplete="off">
+          ${this._query ? html`<button type="button" class="search-clear" aria-label="清空搜索" @click=${() => {
+            this._query = ''; this._active = 0; this.renderRoot.querySelector('input').focus(); this.#scrollActive();
+          }}>${icon('x', 14)}</button>` : ''}
+        </div>
+        <div id="member-options" class="options" role="listbox" aria-label="成员列表">
+          ${options.map((option, index) => html`<button type="button" role="option" tabindex="-1" id=${`member-option-${index}`}
+            class="option ${index === this._active ? 'active' : ''} ${index === 0 ? 'all-option' : ''}" data-member=${option.name}
+            aria-selected=${String(option.name === this.value)} @click=${() => this.#choose(option.name)}>
+            <span class="option-icon" aria-hidden="true">${icon(option.name ? 'user' : 'users', 16)}</span>
+            <span class="name" title=${option.label}>${option.label}</span>
+            ${option.groupId ? html`<span class="group">${option.groupId}</span>` : ''}
+            ${option.name === this.value ? html`<span class="check">${icon('check', 16)}</span>` : ''}
+          </button>`)}
+          ${this._query && options.length === 1 ? html`<p class="no-match" role="status">未找到匹配成员</p>` : ''}
+        </div>
+      </div>`;
+  }
+}
+
+define('kf-member-picker', KfMemberPicker);

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

@@ -43,9 +43,16 @@ export class KfOpportunityCard extends LitElement {
   static styles = css`
   static styles = css`
     /* 决策区移入报告后,卡面按实际内容收缩,不预留空描述或操作区。 */
     /* 决策区移入报告后,卡面按实际内容收缩,不预留空描述或操作区。 */
     :host { display: block; min-width: 0; align-self: start; }
     :host { display: block; min-width: 0; align-self: start; }
-    .wrap { min-width: 0; box-sizing: border-box; display: flex; flex-direction: column; gap: 12px; }
-    .top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
-    .top .right { margin-left: auto; display: flex; gap: 6px; }
+    .wrap { min-width: 0; box-sizing: border-box; display: flex; flex-direction: column; gap: 12px; container: opportunity-card / inline-size; }
+    .top { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 4px; }
+    .tags { min-width: 0; display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; }
+    .top .right { display: flex; align-items: center; justify-self: end; white-space: nowrap; }
+    .top kf-badge { flex: none; }
+    .top kf-badge::part(badge) { padding-inline: 6px; }
+    /* 按卡片实际宽度紧凑显示,桌面多列和窄屏都让四项完整留在一行。 */
+    @container opportunity-card (max-width: 300px) {
+      .top kf-badge::part(badge) { padding-inline: 5px; gap: 3px; font-size: 11px; letter-spacing: 0; }
+    }
     /* 长标题不截断(5.6):折两行;正文行高 1.6、字距 0(1.5) */
     /* 长标题不截断(5.6):折两行;正文行高 1.6、字距 0(1.5) */
     .title { margin: 0; font-size: 16px; line-height: 1.35; color: var(--kf-ink); font-weight: 600; letter-spacing: var(--ls-cn-display);
     .title { margin: 0; font-size: 16px; line-height: 1.35; color: var(--kf-ink); font-weight: 600; 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; }
       word-break: keep-all; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-width: 0; }
@@ -106,10 +113,12 @@ export class KfOpportunityCard extends LitElement {
       <kf-glass-card lift surface="2" tone=${o.status === 'rejected' ? 'danger' : o.status === 'approved' ? 'ok' : ''} @card-activate=${this.#open}>
       <kf-glass-card lift surface="2" tone=${o.status === 'rejected' ? 'danger' : o.status === 'approved' ? 'ok' : ''} @card-activate=${this.#open}>
         <div class="wrap">
         <div class="wrap">
           <div class="top">
           <div class="top">
-            ${o.priority ? html`<kf-badge kind="priority" .value=${o.priority}></kf-badge>` : ''}
-            ${o.category ? html`<kf-badge kind="plain" .value=${o.category}></kf-badge>` : ''}
-            ${o.stageObj ? html`<kf-badge kind="stage" .value=${o.stageObj.code} label=${`${o.stageObj.code} ${o.stageObj.name}`}></kf-badge>` : ''}
-            <span class="right" style="align-items:center">
+            <div class="tags">
+              ${o.priority ? html`<kf-badge kind="priority" .value=${o.priority}></kf-badge>` : ''}
+              ${o.category ? html`<kf-badge kind="plain" .value=${o.category}></kf-badge>` : ''}
+              ${o.stageObj ? html`<kf-badge kind="stage" .value=${o.stageObj.code} label=${`${o.stageObj.code} ${o.stageObj.name}`}></kf-badge>` : ''}
+            </div>
+            <span class="right">
               <kf-badge kind="status" .value=${o.status}></kf-badge>
               <kf-badge kind="status" .value=${o.status}></kf-badge>
             </span>
             </span>
           </div>
           </div>

+ 3 - 3
src/layout/kf-app.js

@@ -4,7 +4,7 @@
  * 持有:布局 / 路由 / 服务订阅 / 全局证据抽屉。
  * 持有:布局 / 路由 / 服务订阅 / 全局证据抽屉。
  * 视图组件只做渲染,通过属性拿到数据、通过事件上报动作,跳转与写库都在这里。
  * 视图组件只做渲染,通过属性拿到数据、通过事件上报动作,跳转与写库都在这里。
  *
  *
- * 布局:氛围光场(装饰层)→ 左侧导航玻璃 → 主区。主区顶栏显示「我是谁」,
+ * 布局:氛围光场(装饰层)→ 左侧导航玻璃 → 主区。主区顶栏显示登录账号,
  * 机会卡深链接 /pool/:id 直接阅读报告,兼容历史入口。
  * 机会卡深链接 /pool/:id 直接阅读报告,兼容历史入口。
  */
  */
 import { LitElement, html, css } from 'lit';
 import { LitElement, html, css } from 'lit';
@@ -472,9 +472,9 @@ export class KfApp extends SignalWatcher(LitElement) {
         <kf-nav class="nav" .current=${this._route.path} .query=${this._query} .connection=${this.#svc.status.get() === 'error' ? 'error' : this.#svc.status.get() === 'loading' ? 'loading' : '已同步'}
         <kf-nav class="nav" .current=${this._route.path} .query=${this._query} .connection=${this.#svc.status.get() === 'error' ? 'error' : this.#svc.status.get() === 'loading' ? 'loading' : '已同步'}
           .counts=${{ pool: db?.opportunities.length || 0, stuck: db?.members.filter((m) => m.stuck).length || 0 }}></kf-nav>
           .counts=${{ pool: db?.opportunities.length || 0, stuck: db?.members.filter((m) => m.stuck).length || 0 }}></kf-nav>
         <div class="main">
         <div class="main">
-          <kf-topbar data-route=${this._route.name} .title=${title} .sub=${sub} .me=${this.#svc.me.get()} .members=${db?.members || []}
+          <kf-topbar data-route=${this._route.name} .title=${title} .sub=${sub}
             .user=${auth.user} .authStatus=${auth.status} .returnTo=${currentUrl()}
             .user=${auth.user} .authStatus=${auth.status} .returnTo=${currentUrl()}
-            .status=${this.#svc.status.get()} .loadedAt=${db?.loadedAt || null} .meBusy=${false}></kf-topbar>
+            .status=${this.#svc.status.get()} .loadedAt=${db?.loadedAt || null}></kf-topbar>
           <main class="view" id="view">
           <main class="view" id="view">
             ${this.renderView()}
             ${this.renderView()}
           </main>
           </main>

+ 2 - 30
src/layout/kf-topbar.js

@@ -1,6 +1,6 @@
 /**
 /**
- * layout/kf-topbar.js —— 登录账号、成员浏览筛选、同步状态与刷新。
- * 当前账号来自平台会话核验;成员下拉只控制查看视角,不建立或切换登录身份。
+ * layout/kf-topbar.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';
@@ -11,8 +11,6 @@ export class KfTopbar extends LitElement {
   static properties = {
   static properties = {
     title: { type: String },
     title: { type: String },
     sub: { type: String },
     sub: { type: String },
-    me: { type: String },
-    members: { type: Array },
     status: { type: String },
     status: { type: String },
     loadedAt: { type: String },
     loadedAt: { type: String },
     route: { type: String, attribute: 'data-route' },
     route: { type: String, attribute: 'data-route' },
@@ -25,8 +23,6 @@ export class KfTopbar extends LitElement {
     super();
     super();
     this.title = '';
     this.title = '';
     this.sub = '';
     this.sub = '';
-    this.me = '';
-    this.members = [];
     this.status = 'idle';
     this.status = 'idle';
     this.loadedAt = null;
     this.loadedAt = null;
     this.route = '';
     this.route = '';
@@ -49,9 +45,6 @@ export class KfTopbar extends LitElement {
     .sync .dot { width: 7px; height: 7px; border-radius: 50%; background: #2e7d5b; }
     .sync .dot { width: 7px; height: 7px; border-radius: 50%; background: #2e7d5b; }
     .sync.warn .dot { background: #b27a00; }
     .sync.warn .dot { background: #b27a00; }
     .sync.err .dot { background: var(--kf-s); }
     .sync.err .dot { background: var(--kf-s); }
-    .me { display: flex; align-items: center; gap: 8px; padding: 2px 2px 2px 12px; border-radius: var(--r-sm); border: 1px solid var(--kf-line-soft); background: var(--kf-surface-btn); }
-    .me label { font-size: 12px; color: var(--kf-ink-3); }
-    select { height: 28px; border-radius: var(--r-sm); border: 0; background: transparent; font: 600 14px var(--kf-font); color: var(--kf-ink); padding: 0 8px 0 2px; cursor: pointer; }
     .who { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 600; color: #fff;
     .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); }
       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; }
     button.refresh { height: 32px; padding: 0 12px; border-radius: var(--r-sm); font: 500 14px var(--kf-font); color: var(--kf-ink); cursor: pointer; }
@@ -63,26 +56,12 @@ export class KfTopbar extends LitElement {
     @media (max-width: 1023px) { .bar { padding: 10px 20px 0; } .sync { display: none; } }
     @media (max-width: 1023px) { .bar { padding: 10px 20px 0; } .sync { display: none; } }
     @media (max-width: 767px) {
     @media (max-width: 767px) {
       .bar { padding: calc(8px + env(safe-area-inset-top, 0px)) 16px 0; gap: 8px; }
       .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; }
       .who { width: 32px; height: 32px; font-size: 14px; margin-left: -8px; }
-      select { height: 44px; min-width: 44px; font-size: 14px; font-weight: 600; max-width: 128px; }
-      .me:active { background: var(--kf-surface-btn); }
       button.refresh { height: 44px; min-width: 44px; padding: 0 14px; font-size: 14px; border-radius: var(--r-sm); }
       button.refresh { height: 44px; min-width: 44px; padding: 0 14px; font-size: 14px; border-radius: var(--r-sm); }
       .auth-link,.logout{min-height:44px}.account-name{max-width:112px}.account .who{margin-left:0}
       .auth-link,.logout{min-height:44px}.account-name{max-width:112px}.account .who{margin-left:0}
     }
     }
   `;
   `;
 
 
-  /** 改浏览筛选:冒泡到 kf-app → boardService.setMe;不会更改登录账号。 */
-  #change(e) {
-    this.dispatchEvent(new CustomEvent('me-change', { detail: { name: e.target.value }, bubbles: true, composed: true }));
-  }
-
-  updated() {
-    const sel = this.renderRoot.querySelector('select');
-    if (sel && sel.value !== this.me) sel.value = this.me || '';
-  }
-
   render() {
   render() {
     const initials = this.user ? Array.from(this.user.displayName).slice(-1).join('') : '';
     const initials = this.user ? Array.from(this.user.displayName).slice(-1).join('') : '';
     const back = this.returnTo.split(/[?#]/)[0] === '/login' ? '/' : safeReturnTo(this.returnTo, '/');
     const back = this.returnTo.split(/[?#]/)[0] === '/login' ? '/' : safeReturnTo(this.returnTo, '/');
@@ -95,13 +74,6 @@ export class KfTopbar extends LitElement {
           ${this.loadedAt ? html`<span class="ago">${fmtAgo(this.loadedAt)}</span>` : ''}
           ${this.loadedAt ? html`<span class="ago">${fmtAgo(this.loadedAt)}</span>` : ''}
         </div>
         </div>
         <button class="refresh kf-btn" @click=${() => this.dispatchEvent(new CustomEvent('refresh', { bubbles: true, composed: true }))} aria-label="立即刷新数据">刷新</button>
         <button class="refresh kf-btn" @click=${() => this.dispatchEvent(new CustomEvent('refresh', { bubbles: true, composed: true }))} aria-label="立即刷新数据">刷新</button>
-        <div class="me" title="仅筛选看板,不更改登录账号">
-          <label for="me">查看成员</label>
-          <select id="me" @change=${this.#change} aria-label="查看成员">
-            <option value="" ?selected=${!this.me}>全部成员</option>
-            ${this.members.map((m) => html`<option value=${m.name} ?selected=${m.name === this.me}>${m.name}${m.groupId ? ` · ${m.groupId}` : ''}</option>`)}
-          </select>
-        </div>
         <div class="account" aria-label="登录账号">
         <div class="account" aria-label="登录账号">
           ${this.user ? html`<span class="who" aria-hidden="true">${initials}</span>
           ${this.user ? html`<span class="who" aria-hidden="true">${initials}</span>
             <span class="account-name" title=${this.user.username + ' · ' + this.user.companyName}><small>已登录</small> ${this.user.displayName}</span>
             <span class="account-name" title=${this.user.username + ' · ' + this.user.companyName}><small>已登录</small> ${this.user.displayName}</span>

+ 17 - 31
src/pages/view-workbench.js

@@ -20,6 +20,7 @@ import { href, opportunityUrl, currentUrl } from '../utils/router.js';
 import { t, VIEW_CSS, head } from './shared.js';
 import { t, VIEW_CSS, head } from './shared.js';
 import { cardFooter, playCard, CARD_CSS } from './report-card.js';
 import { cardFooter, playCard, CARD_CSS } from './report-card.js';
 import { icon } from '../components/icons.js';
 import { icon } from '../components/icons.js';
+import '../components/kf-member-picker.js';
 
 
 const TONE = { S1: 'blue', S2: 'violet', S3: 'teal', S4: 'amber', S5: 'rose' };
 const TONE = { S1: 'blue', S2: 'violet', S3: 'teal', S4: 'amber', S5: 'rose' };
 const DOT = { approved: '#F4F4F5', rejected: 'rgba(255,255,255,0.28)', candidate: '#B4B4BC' };
 const DOT = { approved: '#F4F4F5', rejected: 'rgba(255,255,255,0.28)', candidate: '#B4B4BC' };
@@ -88,14 +89,7 @@ export class ViewWorkbench extends LitElement {
 
 
     /* ---------- A 卡:我的推进(单列列表,dr6 Onboarding / dr1 Project 的形态) ---------- */
     /* ---------- A 卡:我的推进(单列列表,dr6 Onboarding / dr1 Project 的形态) ---------- */
     .me { display: flex; flex-direction: column; gap: 24px; }
     .me { display: flex; flex-direction: column; gap: 24px; }
-    .me-id { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 2px 12px; }
-    .me-id .me-title { display: grid; align-content: center; min-width: 0; }
-    .avatar { width: 40px; height: 40px; border-radius: var(--r-md); display: grid; place-items: center; font-size: 20px; font-weight: 600; color: #fff;
-      background: var(--kf-ink); flex: none; }
-    .avatar.q { background: var(--kf-fill); color: var(--kf-ink-3); border: 1px dashed color-mix(in srgb, var(--kf-ink-3) 34%, transparent); }
-    .me-id h3 { margin: 0; font-size: 16px; line-height: var(--lh-title); font-weight: 600; color: var(--kf-ink); letter-spacing: var(--ls-cn-display); }
-    .me-id .sub { font-size: 12px; line-height: 1.5; color: var(--kf-ink-3); }
-    .me-id .go { margin-left: auto; }
+    .me-id { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px 24px; }
     .me-nums { display: flex; gap: 24px; justify-self: end; }
     .me-nums { display: flex; gap: 24px; justify-self: end; }
     .me-nums div { display: flex; align-items: baseline; gap: 4px; }
     .me-nums div { display: flex; align-items: baseline; gap: 4px; }
     /* 大数字 28/500(dr1 数字 28、字重比标题细) */
     /* 大数字 28/500(dr1 数字 28、字重比标题细) */
@@ -231,10 +225,7 @@ export class ViewWorkbench extends LitElement {
       .stat .note .t { white-space: normal; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
       .stat .note .t { white-space: normal; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
       .bento > .row.stats > kf-glass-card { grid-column: span 6; }
       .bento > .row.stats > kf-glass-card { grid-column: span 6; }
       .me { gap: 16px; }
       .me { gap: 16px; }
-      .me-id { grid-template-columns: auto minmax(0, 1fr) !important; gap: 2px 10px; }
-      /* 「选我」放回标题那一行右侧(v16 实测:桌面那条 !important 覆盖把手机上的按钮挤到了下一行独立一行) */
-      .me-id > .go { grid-column: 3; grid-row: 1; margin-left: 0; }
-      .me-id .avatar { width: 40px; height: 40px; }
+      .me-id { grid-template-columns: minmax(0, 1fr); gap: 16px; }
       .me-nums { grid-column: 1 / -1; justify-self: start; margin-left: 0; width: 100%; gap: 24px; }
       .me-nums { grid-column: 1 / -1; justify-self: start; margin-left: 0; width: 100%; gap: 24px; }
       .me-nums div { display: flex; align-items: baseline; gap: 4px; }
       .me-nums div { display: flex; align-items: baseline; gap: 4px; }
       /* 五阶段:两行三列(第五格占满第二行左半) */
       /* 五阶段:两行三列(第五格占满第二行左半) */
@@ -250,7 +241,7 @@ export class ViewWorkbench extends LitElement {
       .feed li { grid-template-columns: 12px minmax(0, 1fr); }
       .feed li { grid-template-columns: 12px minmax(0, 1fr); }
       .feed li a { grid-column: 2; white-space: normal; }
       .feed li a { grid-column: 2; white-space: normal; }
       .feed .tm { grid-column: 2; grid-row: 3; }
       .feed .tm { grid-column: 2; grid-row: 3; }
-      .go, .hd .go, .me-id .go { min-height: 44px; min-width: 44px; justify-content: center; }
+      .go, .hd .go { min-height: 44px; min-width: 44px; justify-content: center; }
       .bento > .row.cards-row { grid-template-columns: minmax(0, 1fr); gap: var(--kf-space-card); }
       .bento > .row.cards-row { grid-template-columns: minmax(0, 1fr); gap: var(--kf-space-card); }
       .cards-main, .side-stack { grid-column: auto; }
       .cards-main, .side-stack { grid-column: auto; }
       .cards { grid-template-columns: minmax(0, 1fr); gap: var(--kf-space-card); }
       .cards { grid-template-columns: minmax(0, 1fr); gap: var(--kf-space-card); }
@@ -331,34 +322,29 @@ export class ViewWorkbench extends LitElement {
     </div>`;
     </div>`;
   }
   }
 
 
-  /** A 卡:选了「我是」→ 我的进度;没选 → 全员进度(v12 定稿:空态也放真实内容,不摆空框) */
+  #memberPicker(summary) {
+    return html`<kf-member-picker .members=${this.db?.members || []} .value=${this.me} .summary=${summary}></kf-member-picker>`;
+  }
+
+  /** A 卡:选了成员 → 个人进度;选全部成员 → 全员进度。 */
   #mine(me, pool, live) {
   #mine(me, pool, live) {
     this.#pending = me?.pendingScores || [];
     this.#pending = me?.pendingScores || [];
     this.#zero = Boolean(this.me) && me.count === 0;
     this.#zero = Boolean(this.me) && me.count === 0;
-    if (!this.me) {
-      const maxSub = Math.max(1, ...live.stages.map((r) => r.submitted));
-      return html`<div class="me" data-empty="no-me">
-        <div class="me-id"><div class="avatar q">?</div>
-          <div class="me-title"><h3>还没选「我是」</h3><span class="sub">${t(`先看全员:${live.memberCount} 人 · 已交 ${live.submittedPeople} 人`)}</span></div>
-          <a class="go" href=${href('/members')} data-link>${icon('user', 14)} ${t('选我')}</a></div>
-        ${this.#stagebar(live.stages, maxSub, null)}
-        ${this.#meList(pool)}
-      </div>`;
-    }
     const done = new Set(me.stagesDone);
     const done = new Set(me.stagesDone);
-    const rows = STAGES.map((s) => ({ stage: s, submitted: me.mine.filter((a) => a.stageObj?.code === s.code).length }));
+    const rows = this.me ? STAGES.map((s) => ({ stage: s, submitted: me.mine.filter((a) => a.stageObj?.code === s.code).length })) : live.stages;
     const maxSub = Math.max(1, ...rows.map((r) => r.submitted));
     const maxSub = Math.max(1, ...rows.map((r) => r.submitted));
-    return html`<div class="me" data-empty=${me.count === 0 ? 'no-result' : 'none'}>
+    const summary = this.me ? t(`已完成 ${me.stagesDone.length} / 5 个阶段 · 累计 ${me.count} 份成果`)
+      : t(`全员:${live.memberCount} 人 · 已交 ${live.submittedPeople} 人`);
+    return html`<div class="me" data-empty=${!this.me ? 'no-me' : me.count === 0 ? 'no-result' : 'none'}>
       <div class="me-id">
       <div class="me-id">
-        <div class="avatar">${Array.from(me.me).slice(-1).join('')}</div>
-        <div class="me-title"><h3>${me.me}</h3><span class="sub">${t(`已完成 ${me.stagesDone.length} / 5 个阶段 · 累计 ${me.count} 份成果`)}</span></div>
-        <div class="me-nums">
+        ${this.#memberPicker(summary)}
+        ${this.me ? html`<div class="me-nums">
           <div><b>${me.count}</b><span>份成果</span></div>
           <div><b>${me.count}</b><span>份成果</span></div>
           <div><b>${me.opps.length}</b><span>张卡</span></div>
           <div><b>${me.opps.length}</b><span>张卡</span></div>
           <div><b>${me.pendingScores.length}</b><span>待评分</span></div>
           <div><b>${me.pendingScores.length}</b><span>待评分</span></div>
-        </div>
+        </div>` : ''}
       </div>
       </div>
-      ${this.#stagebar(rows, maxSub, done)}
+      ${this.#stagebar(rows, maxSub, this.me ? done : null)}
 
 
       ${this.#meList(pool)}
       ${this.#meList(pool)}
     </div>`;
     </div>`;

+ 1 - 1
test/board-local-deploy.test.mjs

@@ -12,7 +12,7 @@ function fixture(){
  const base=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-board-deploy-test-')),targetDir=path.join(base,'site');fs.mkdirSync(targetDir);
  const base=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-board-deploy-test-')),targetDir=path.join(base,'site');fs.mkdirSync(targetDir);
  const manifest=JSON.parse(fs.readFileSync('deploy/board-local-20261111.json'));manifest.homepageSha256=sha('fixture homepage');
  const manifest=JSON.parse(fs.readFileSync('deploy/board-local-20261111.json'));manifest.homepageSha256=sha('fixture homepage');
  fs.writeFileSync(path.join(targetDir,'index.html'),'fixture homepage');
  fs.writeFileSync(path.join(targetDir,'index.html'),'fixture homepage');
- for(const f of manifest.files){const to=path.join(targetDir,f.path);fs.mkdirSync(path.dirname(to),{recursive:true});fs.copyFileSync(f.path,to);}
+ for(const f of manifest.files){const to=path.join(targetDir,f.path);fs.mkdirSync(path.dirname(to),{recursive:true});fs.copyFileSync(f.path,to);const bytes=fs.readFileSync(f.path);f.sha256=sha(bytes.includes(0)?bytes:Buffer.from(bytes.toString('utf8').replaceAll('\r\n','\n')));}
  const manifestPath=path.join(base,'manifest.json');fs.writeFileSync(manifestPath,JSON.stringify(manifest));
  const manifestPath=path.join(base,'manifest.json');fs.writeFileSync(manifestPath,JSON.stringify(manifest));
  return{base,manifest,targetDir,manifestPath,sourceDir:root};
  return{base,manifest,targetDir,manifestPath,sourceDir:root};
 }
 }

+ 74 - 0
test/board-ui-deploy.test.mjs

@@ -0,0 +1,74 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { publishBoardUi, MANIFEST_PATH, UI_FILES } from '../scripts/publish-board-ui-20261112.mjs';
+
+const sha = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
+function fixture() {
+  const base = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-board-ui-test-'));
+  const targetDir = path.join(base, 'site');
+  fs.mkdirSync(targetDir);
+  fs.writeFileSync(path.join(targetDir, 'index.html'), 'fixture homepage');
+  const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH));
+  manifest.homepageSha256 = sha('fixture homepage');
+  for (const file of manifest.files) {
+    if (file.allowMissing) continue;
+    const target = path.join(targetDir, file.path), bytes = Buffer.from('fixture prior: ' + file.path);
+    fs.mkdirSync(path.dirname(target), { recursive: true });
+    fs.writeFileSync(target, bytes);
+    file.priorNormalizedSha256 = [sha(bytes)];
+  }
+  const reportPath = path.join(targetDir, 'src/pages/view-report.js');
+  fs.writeFileSync(reportPath, 'independent report update');
+  const manifestPath = path.join(base, 'manifest.json');
+  fs.writeFileSync(manifestPath, JSON.stringify(manifest));
+  return { base, targetDir, manifestPath, reportPath, manifest };
+}
+
+test('six-file UI release applies and is idempotent while preserving an independent report update', () => {
+  const f = fixture();
+  try {
+    const plan = publishBoardUi(f);
+    assert.equal(plan.readOnly, true);
+    assert.deepEqual(plan.changes, UI_FILES);
+    assert.equal(fs.existsSync(path.join(f.targetDir, 'src/components/kf-member-picker.js')), false);
+    const result = publishBoardUi({ ...f, apply: true });
+    assert.equal(result.filesystemVerified, true);
+    assert.equal(result.filesChecked, 6);
+    assert.equal(result.homepageModified, false);
+    assert.deepEqual(publishBoardUi(f).changes, []);
+    assert.equal(fs.readFileSync(f.reportPath, 'utf8'), 'independent report update');
+    assert.equal(fs.readFileSync(path.join(f.targetDir, 'index.html'), 'utf8'), 'fixture homepage');
+  } finally { fs.rmSync(f.base, { recursive: true, force: true }); }
+});
+
+test('UI release refuses an unexpected file list and an unknown baseline before writing', () => {
+  const f = fixture();
+  try {
+    const original = fs.readFileSync(f.manifestPath);
+    f.manifest.files.push({ path: 'src/pages/view-report.js' });
+    fs.writeFileSync(f.manifestPath, JSON.stringify(f.manifest));
+    assert.throws(() => publishBoardUi({ ...f, apply: true }), { code: 'UNEXPECTED_FILE_LIST' });
+    fs.writeFileSync(f.manifestPath, original);
+    fs.writeFileSync(path.join(f.targetDir, UI_FILES[0]), 'unknown concurrent update');
+    assert.throws(() => publishBoardUi({ ...f, apply: true }), { code: 'UNKNOWN_TARGET_CONTENT' });
+    assert.equal(fs.existsSync(path.join(f.targetDir, 'src/components/kf-member-picker.js')), false);
+    assert.equal(fs.readFileSync(f.reportPath, 'utf8'), 'independent report update');
+  } finally { fs.rmSync(f.base, { recursive: true, force: true }); }
+});
+
+test('UI release rolls back both replaced files and its newly added component on a write failure', () => {
+  const f = fixture();
+  try {
+    const original = fs.readFileSync(path.join(f.targetDir, UI_FILES[0]));
+    assert.throws(() => publishBoardUi({ ...f, apply: true }, { beforeWrite(file) {
+      if (file === 'src/pages/view-workbench.js') throw Error('fixture failure');
+    } }), error => error.rollbackComplete === true);
+    assert.deepEqual(fs.readFileSync(path.join(f.targetDir, UI_FILES[0])), original);
+    assert.equal(fs.existsSync(path.join(f.targetDir, 'src/components/kf-member-picker.js')), false);
+    assert.equal(fs.readFileSync(f.reportPath, 'utf8'), 'independent report update');
+  } finally { fs.rmSync(f.base, { recursive: true, force: true }); }
+});