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

feat(policy): 卡片区「更多」改为打开「政策列表」侧边栏(会话级卡片聚合)

需求:点卡片区「更多 >>」打开一套新侧边栏——标题「政策列表」,保留搜索框与
热门搜索,**移除部门/分类筛选**,内容为本会话所有消息出现过的卡片。
按用户拍板:只换「更多」入口(详情面板的「返回政策事项列表」仍走旧列表)、
热门搜索沿用现有 5 词。

实现:
- 面板新增第三种模式 cardList。list 与 cardList 共用同一个 body 与全部样式类
  (CSS 零新增);旧列表逻辑靠 fetchPolicyList 开头的 panelMode !== "list"
  守卫自然短路,openPolicyList / watchers / filteredPolicies 一行未动
- 会话级卡片聚合此前并不存在(每个 PolicyMatch 实例只持有自己那条消息的卡片):
  · 采集层 useBusinessAssistantChat 新增 provide('collectSessionPolicyCards', …),
    放在既有 provide('submitQuestionAnswers') 旁 → PC/Mobile 零改动;
    调用时才读 currentSession,切会话自动跟随;inject 拿不到时回退本组件卡片
  · 纯逻辑层 policy-match-utils.ts 新增 5 个纯函数(解析/去重键/去重/排序/关键词),
    可被 harness 直接测;组装(merge + normalize)留在组件复用私有函数
- 去重键 = declaration_item(2026-09-17 起「政策名 - 申报事项」唯一);
  精简格式占位符「查看政策详情」退化为「标题|匹配度|匹配理由|id」组合键
- 改写 openPolicyList 顶部已过时的注释(原写「更多与返回走同一条路径」,
  改后不成立,留着会误导)

验证:npm run build 通过;新增 verify-policy-card-list.mjs 31/31 通过
(聚合并集保序、坏 JSON/空输入不抛错、首见优先去重、降序+同分稳定、
六面关键词过滤、端到端组合);其余脚本全绿(8/10/21/30/36);
validate-harness 通过。⚠️ 浏览器端到端未人工点过。

另:feature_list 里把 dms-chat-storage 从 in_progress 转为 passing——为满足
「同一时间只做一个功能」。其自动化证据齐备(36/36 打真实 DMS),但**浏览器
人工点验始终未做**,已在 verification 里显式标为「未做」并写明若人工验证
发现问题应退回 in_progress,不是悄悄略过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 3 дней назад
Родитель
Сommit
cd185f9c1e

+ 10 - 0
README.md

@@ -140,3 +140,13 @@
     否则改详情标题会连带改掉卡片标题)
   - 新增 `harness/tools/verify-policy-card-fields.mjs`(8 项断言:同政策两条必须可区分、
     卡片标题不变、无后缀时退化为政策名)
+- 卡片区「更多 >>」改为打开新的**「政策列表」侧边栏**:
+  - 标题「政策列表」,保留搜索框与热门搜索,**移除部门/分类筛选**
+  - 内容为**本会话内所有消息出现过的政策卡片**(跨消息聚合、去重、按匹配度排序)——
+    此前会话级聚合并不存在(每条消息的卡片各自渲染),本轮新增
+  - 详情面板的「返回政策事项列表」**仍打开原来的惠企政策库列表**(两套列表并存,
+    按要求只换「更多」入口)
+  - 面板新增第三种模式 `cardList`,与旧列表**共用同一套模板与样式类,未新增 CSS**;
+    旧列表逻辑靠既有守卫自然短路,`openPolicyList` 一行未动
+  - 新增 `harness/tools/verify-policy-card-list.mjs`(31 项断言:聚合并集保序、
+    坏 JSON/空输入不抛错、首见优先去重、降序+同分稳定、六面关键词过滤)

+ 108 - 0
harness/docs/exec-plans/active/policy-card-list-sidebar.md

@@ -0,0 +1,108 @@
+# 卡片区「更多」→ 新侧边栏「政策列表」— 实施计划
+
+## Context(为什么做这件事)
+
+用户要求:点卡片区「更多」按钮,打开**一套新侧边栏**——标题「政策列表」,
+下方是搜索框 + 热门搜索,**没有部门筛选和分类筛选**,内容为**本会话内所有消息
+出现过的卡片**(跨消息聚合、去重)。样式与现有列表一致(复用原样式类,不新建 UI 组件)。
+
+## 用户已拍板
+
+1. **数据范围**:本会话全部卡片(跨消息聚合、去重),无论从哪条消息点「更多」都看到同一份
+2. **旧列表去留**:只换「更多」入口 —— 详情面板的「返回政策事项列表」保留旧列表
+   (惠企政策库 262 条 + 筛选)。两套列表并存
+3. **热门搜索词**:沿用现有 5 个(金融 / 发展扶持 / 专精特新 / 高新技术 / 先进制造)
+
+## 已核实的现状(Explore + Plan agent)
+
+- 「更多」按钮 `PolicyMatch.vue:29`(无 v-if);`openPolicyList(:1206-1223)` 是旧列表入口
+- `panelMode = ref<"list" | "detail">("list")`(:530);旧列表数据源 `fetchPolicyList(:988-1048)`
+  有守卫 `panelMode !== "list"` 直接返回 —— **新增第三模式即可自然短路旧逻辑**
+- 会话内卡片聚合不存在;卡片可从 `message.content` 的 POLICY_TABLE 块恢复
+  (`parsePolicyTableContent`,utils/stream-xml-filter.ts:518-531)
+- `useBusinessAssistantChat` 内有 provide 先例(`provide('submitQuestionAnswers', …)` :1627),
+  PC/Mobile 都在 setup 里调用该 composable —— 在那里 provide 即可两处生效、零改动
+- 去重键:`declaration_item`(2026-09-17 起 = 「政策名 - 申报事项」,每条唯一)
+
+## 实施步骤
+
+### 1. 纯函数:`src/components/Chat/policy-match-utils.ts`(追加,可被 harness 测试)
+
+- import `parsePolicyTableContent`(utils/stream-xml-filter,无循环依赖)
+- 新增 5 个纯函数:
+  - `collectPolicyCardsFromMessages(messages)` —— 遍历 AI 消息 content,parse 出全部 POLICY_TABLE raw 条目(不去重、保消息顺序)
+  - `buildPolicyCardDedupKey(item)` —— 主键 `decl:<declaration_item>`;占位符「查看政策详情」(精简格式)时退化为 标题|匹配度|匹配理由|id 组合键
+  - `dedupePolicyItems(items, keyOf)` —— 首见优先去重
+  - `sortPolicyCardsByMatchScore(items)` —— match_score 降序 + 原顺序稳定 tie-break(与卡片区 panelData 语义一致)
+  - `matchesPolicyCardKeyword(item, keyword)` —— 检索面 title/desc/declaration_item/match_reason/source/enterprise_benefit,大小写不敏感,空词恒 true(与 buildRecordPolicyList 的 haystack 一致)
+
+### 2. 聚合 provider:`src/components/business-assistant/useBusinessAssistantChat.ts`
+
+在 `provide('submitQuestionAnswers', …)`(:1627) 后新增:
+
+```ts
+provide('collectSessionPolicyCards', (): Record<string, any>[] =>
+  collectPolicyCardsFromMessages(currentSession.value?.messages ?? [])
+);
+```
+
+- 调用时读 `currentSession.value` → 切会话天然跟随;只采集,不去重/排序(组装留在组件)
+- PC/Mobile **零改动**(composable 内的 provide 挂在它们的组件实例上,全链路可 inject)
+
+### 3. `src/components/Chat/PolicyMatch.vue` — script
+
+- `panelMode` 类型加第三值 `"cardList"`;inject `collectSessionPolicyCards`(可空)
+- 新状态/计算属性:
+  - `cardListItems`(聚合重建:provider() ?? 回退 `props.policyData || parsePolicyTableContent(props.policyContent)` → mergeWithPublicPolicies → normalizePolicy → 去重 → 排序)
+  - `cardListFiltered`(按 searchKeyword 过滤,computed)、`cardListTotal`、`cardListPaginated`
+- `totalPages` / `paginatedPolicies` 加 `panelMode === 'cardList'` 分支(两模式互斥共享 currentPage/itemsPerPage/visiblePages,安全)
+- 新 `openCardList()`(放 openPolicyList 前):**先切 panelMode="cardList" 再重置共享状态**(让 watcher 触发的 fetchPolicyList 被守卫短路)→ rebuildCardList → requestOpen/nextTick/互斥校验 → panelVisible → emitPanelWidth
+- `syncPolicies(:969-984)` 里面板正开着 cardList 时顺带 rebuildCardList(流式刷新,可选两行)
+- **openPolicyList / openDetail / openDetailByItem / fetchPolicyList / watchers 全部不动**;:1203 附近「两个入口走同一条路径」的过时注释改写
+
+### 4. `src/components/Chat/PolicyMatch.vue` — template(6 处)
+
+| 位置 | 改动 |
+|---|---|
+| :29 「更多」 | `@click.stop="openCardList"` |
+| :37-40 背景类 | `panelMode === 'detail' ? 'detail-mode-bg' : 'list-mode-bg'` |
+| :42-44 头部标题 | `panelMode === 'cardList' ? '政策列表' : '智能政策匹配'` |
+| :61 list body | `v-if="panelMode !== 'detail'"`(list 与 cardList 共用 body 与样式) |
+| :64-67 record-list-header | 标题 cardList 时「政策列表」+ 副标题「汇总本会话内出现的全部政策事项」,否则旧文案 |
+| :69 search-filter-bar | `v-if="isZhaoshangLLM && (recordMode || panelMode === 'cardList')"` |
+| :80 filter-selects(部门/分类) | 加 `v-if="panelMode === 'list'"` —— cardList 隐藏筛选,搜索框 + 热门词保留 |
+
+- 条目循环、分页、热门 chips、两个返回按钮(:46 header-back、:220 record-back-btn → openPolicyList)**不动**
+- CSS 零新增(record-list-header/search-filter-bar/quick-chips 均为顶层类,复用安全)
+
+### 5. 记录与提交
+
+- 本计划落库 `harness/docs/exec-plans/active/policy-card-list-sidebar.md`(CLAUDE.md 硬规则:计划只能放这里)
+- `harness/progress.md`(Session 026)、`harness/feature_list.json`(新条目,passing 需带 evidence)
+- 根 `README.md` 20260917 节追加
+- `npm run build` 通过后按提交约定提交推送
+
+## 关键设计决策
+
+- **聚合分层**:采集(读会话+parse)在 composable;纯逻辑(去重/排序/过滤)在 policy-match-utils(可测);组装(merge/normalize)留在 PolicyMatch 复用组件私有函数
+- **去重键 = declaration_item,首见优先**:确定性最强;同一卡片在不同消息里的 match_score 可能不同,首见固定了分数来源
+- **搜索复用 searchKeyword**:两模式互斥(panelMode 单一开关),各自入口重置,无串扰,不新建状态
+- **详情链路**:聚合条目是重新 normalize 的新对象 → `resolvePolicyDetailIndex` 对象同一性失配 → 字段比对:本消息里有同 declaration_item 则命中本消息条目;否则 -1 → override 兜底直接展示被点条目(它是完整 PolicyItem,originalData 含 conditions/match_basis,详情完整可用)
+
+## 回归点(已核对)
+
+- 非 recordMode 无实装入口(PolicyListUpdatedMatch 恒传 recordMode=true);只改两处条件三元,detail 与旧 list 运行时行为不变
+- inject 无 provider 时回退本组件卡片,不崩不空白
+- emitPanelWidth 只依赖 recordMode(600/778),cardList 无需改动
+- modalMutex 序列与 openPolicyList 完全一致
+
+## 验证
+
+1. `npm run build`;`npx vue-tsc --noEmit`(panelMode 联合类型改动过类型检查)
+2. 新增 `harness/tools/_entry-policy-cards.ts` + `verify-policy-card-list.mjs`(照 `_entry-policy-match.ts` 模式):
+   聚合并集保序 / user 消息跳过 / 坏 JSON 不抛错;去重键主键与占位符退化;首见去重;
+   match_score 降序稳定排序;六个检索面与大小写/空词
+3. `node harness/tools/validate-harness.mjs` 退出码 0
+4. 浏览器端到端(dev :8083):点「更多」→ 标题「政策列表」、有搜索+热门词、**无筛选**;
+   搜索/热门词过滤正确;两个不同政策问题后聚合可见且去重;点条目详情一致;
+   「返回政策事项列表」仍是旧列表;切会话聚合跟随

Разница между файлами не показана из-за своего большого размера
+ 18 - 3
harness/feature_list.json


+ 64 - 0
harness/progress.md

@@ -28,6 +28,70 @@
 
 ## 会话记录
 
+## Session 026
+
+- **日期**:2026-09-17
+- **本轮目标**:卡片区「更多」改为打开新侧边栏「政策列表」(用户需求)
+- **方案**:[`docs/exec-plans/active/policy-card-list-sidebar.md`](docs/exec-plans/active/policy-card-list-sidebar.md)
+- **用户拍板三条**:①内容 = **本会话全部卡片**(跨消息聚合、去重)②**只换「更多」入口**
+  —— 详情面板的「返回政策事项列表」保留旧列表③热门搜索沿用现有 5 词
+
+### 已完成
+
+- **新增第 3 种面板模式 `cardList`**(`panelMode: "list" | "cardList" | "detail"`)。
+  旧列表逻辑靠 `fetchPolicyList` 开头的 `panelMode !== "list"` 守卫**自然短路**,
+  `openPolicyList` / watchers / `filteredPolicies` **一行未动**
+- **会话级卡片聚合**(此前不存在):
+  - 采集层 `useBusinessAssistantChat.ts` 新增 `provide('collectSessionPolicyCards', …)`
+    (放在既有 `provide('submitQuestionAnswers', …)` 旁,PC/Mobile **零改动**,
+    沿用同一先例)。**调用时**才读 `currentSession` → 切会话自动跟随
+  - 纯逻辑层 `policy-match-utils.ts` 新增 5 个纯函数:
+    `collectPolicyCardsFromMessages`(从 AI 消息 content 重新解析 POLICY_TABLE)、
+    `buildPolicyCardDedupKey`、`dedupePolicyItems`(首见优先)、
+    `sortPolicyCardsByMatchScore`(降序 + 稳定 tie-break)、`matchesPolicyCardKeyword`
+  - 组装留在 PolicyMatch(复用组件私有 `mergeWithPublicPolicies`/`normalizePolicy`);
+    **inject 拿不到 provider 时回退本组件卡片**,不崩不空白
+- **去重键 = `declaration_item`**(2026-09-17 起 =「政策名 - 申报事项」,每条唯一);
+  精简格式的占位符「查看政策详情」会退化为「标题|匹配度|匹配理由|id」组合键,
+  避免把不同卡片错并成一条
+- **模板 6 处改动**:更多按钮 → `openCardList`;背景类/标题按模式分叉;
+  list 与 cardList **共用同一个 body 与全部样式类(CSS 零新增)**;
+  筛选块加 `v-if="panelMode === 'list'"` → cardList 下隐藏部门/分类,
+  保留搜索框与热门搜索
+- **改写了一条过时注释**:`openPolicyList` 顶部原写「卡片区『更多』与详情返回走同一条路径」,
+  改后已不成立——不改写会误导后续维护者
+
+### 运行过的验证
+- `npm run build` 通过
+- **新增 `harness/tools/verify-policy-card-list.mjs` —— 31/31 通过**:
+  跨消息聚合并集+保序、用户消息跳过、坏 JSON/空输入/null 不抛错、
+  去重键主键与占位符退化、首见优先(保留首见的 match_score 而非后来的)、
+  降序+同分稳定、六个检索面+大小写+空词、端到端组合
+- 其余脚本全绿:卡字段 8/8、详情反查 10/10、政策库 21/21、判空口径 30/30、DMS 36/36
+- `node harness/tools/validate-harness.mjs` 通过
+
+### 已记录证据
+本文件 Session 026;`docs/exec-plans/active/policy-card-list-sidebar.md`(计划已落库);
+`harness/tools/verify-policy-card-list.mjs`;`feature_list.json` 的 `policy-card-list-sidebar`
+
+### 更新过的文件或工件
+`src/components/Chat/policy-match-utils.ts`、`src/components/Chat/PolicyMatch.vue`、
+`src/components/business-assistant/useBusinessAssistantChat.ts`、
+`harness/tools/{_entry-policy-cards.ts,verify-policy-card-list.mjs}`(新增)、
+`harness/docs/exec-plans/active/policy-card-list-sidebar.md`(新增)、
+`harness/feature_list.json`、根 `README.md`、本文件
+
+### 已知风险或未解决问题
+- ⚠️ **浏览器端到端未人工点过**:验收要点是「标题政策列表 / 有搜索+热门词 / **无筛选** /
+  跨两轮问答聚合且去重 / 点条目详情一致 / 「返回政策事项列表」仍是旧列表」
+- ⚠️ 两套列表并存是**用户明确选择**(只换「更多」入口);`openPolicyList` 现在只由
+  详情页的返回按钮使用,若将来也不需要了,可连同筛选状态一并清理
+- ⚠️ 聚合去重键依赖 `declaration_item` 的唯一性——若后端将来改回只下发政策名,
+  同政策的多个申报事项又会被并成一条(届时需换键,如 `source_id`)
+
+### 下一步最佳动作
+浏览器实测上述验收要点;若通过,本条目转 passing
+
 ## Session 025
 
 - **日期**:2026-09-17

+ 8 - 0
harness/tools/_entry-policy-cards.ts

@@ -0,0 +1,8 @@
+/** esbuild 入口:卡片聚合相关的纯逻辑(「更多」→ 政策列表 侧边栏) */
+export {
+  collectPolicyCardsFromMessages,
+  buildPolicyCardDedupKey,
+  dedupePolicyItems,
+  sortPolicyCardsByMatchScore,
+  matchesPolicyCardKeyword,
+} from '../../src/components/Chat/policy-match-utils';

+ 119 - 0
harness/tools/verify-policy-card-list.mjs

@@ -0,0 +1,119 @@
+/**
+ * 验证「更多」→「政策列表」侧边栏的纯逻辑:跨消息聚合、去重、排序、关键词过滤。
+ *
+ * 为什么要单独测:卡片区每个 PolicyMatch 实例只持有**自己那条消息**的卡片,
+ * 侧边栏要的是**整个会话**的聚合,这段逻辑(解析历史消息 → 去重 → 排序 → 过滤)
+ * 容易在边界上出错(坏 JSON、重复卡片、精简格式占位符、同分排序抖动)。
+ *
+ * 怎么跑(在项目根目录):
+ *   npx esbuild harness/tools/_entry-policy-cards.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_policy-cards.mjs
+ *   node harness/tools/verify-policy-card-list.mjs
+ */
+
+import {
+  collectPolicyCardsFromMessages,
+  buildPolicyCardDedupKey,
+  dedupePolicyItems,
+  sortPolicyCardsByMatchScore,
+  matchesPolicyCardKeyword,
+} from './_policy-cards.mjs';
+
+let pass = 0;
+let fail = 0;
+const check = (name, cond, extra = '') => {
+  if (cond) {
+    pass++;
+    console.log('  ok   ' + name);
+  } else {
+    fail++;
+    console.log('  FAIL ' + name + '  ' + extra);
+  }
+};
+
+/** 造一条带 POLICY_TABLE 块的 AI 消息(格式与适配层 flushContent 产出一致) */
+const aiMessage = (cards, extraText = '正文') =>
+  `${extraText}\n<!-- POLICY_TABLE ${JSON.stringify({ data: cards })} POLICY_TABLE -->\n`;
+
+console.log('【1】跨消息聚合(collectPolicyCardsFromMessages)');
+const cardA = { declaration_item: '政策甲 - 事项一', title: '政策甲', match_score: 90 };
+const cardB = { declaration_item: '政策乙 - 事项一', title: '政策乙', match_score: 45 };
+const cardC = { declaration_item: '政策丙 - 事项一', title: '政策丙', match_score: 60 };
+
+const messages = [
+  { role: 'user', content: '我有什么政策' }, // 用户消息不该被解析
+  { role: 'ai', content: aiMessage([cardA, cardB]) },
+  { role: 'ai', content: '这条没有卡片' },
+  { role: 'ai', content: aiMessage([cardC]) },
+];
+const collected = collectPolicyCardsFromMessages(messages);
+check('收集到 3 张卡片', collected.length === 3, `实际 ${collected.length}`);
+check('保持消息顺序(甲→乙→丙)', collected.map((c) => c.title).join(',') === '政策甲,政策乙,政策丙', collected.map((c) => c.title).join(','));
+check('用户消息被跳过', !collected.some((c) => c.title === '我有什么政策'));
+
+check('空输入不抛错 → []', Array.isArray(collectPolicyCardsFromMessages([])) && collectPolicyCardsFromMessages([]).length === 0);
+check('null 输入不抛错 → []', collectPolicyCardsFromMessages(null).length === 0);
+check('坏 JSON 不抛错且不贡献卡片', collectPolicyCardsFromMessages([{ role: 'ai', content: '<!-- POLICY_TABLE {坏JSON POLICY_TABLE -->' }]).length === 0);
+check('无 POLICY_TABLE 块 → []', collectPolicyCardsFromMessages([{ role: 'ai', content: '纯文本回答' }]).length === 0);
+
+console.log('\n【2】去重键(buildPolicyCardDedupKey)');
+check(
+  '同 declaration_item → 同键',
+  buildPolicyCardDedupKey(cardA) === buildPolicyCardDedupKey({ ...cardA, match_score: 10 }),
+  buildPolicyCardDedupKey(cardA)
+);
+check('不同 declaration_item → 不同键', buildPolicyCardDedupKey(cardA) !== buildPolicyCardDedupKey(cardB));
+// 精简格式:declaration_item 是占位符,不能把不同卡片并成一条
+const minimalA = { declaration_item: '查看政策详情', title: '甲', match_reason: '理由A' };
+const minimalB = { declaration_item: '查看政策详情', title: '乙', match_reason: '理由B' };
+check('占位符「查看政策详情」不按它去重(不同卡片不同键)', buildPolicyCardDedupKey(minimalA) !== buildPolicyCardDedupKey(minimalB), buildPolicyCardDedupKey(minimalA));
+check('占位符场景仍可用同内容命中同键', buildPolicyCardDedupKey(minimalA) === buildPolicyCardDedupKey({ ...minimalA }));
+
+console.log('\n【3】去重(dedupePolicyItems,首见优先)');
+const withDup = [cardA, cardB, { ...cardA, match_score: 999 }, cardC, { ...cardB }];
+const deduped = dedupePolicyItems(withDup, buildPolicyCardDedupKey);
+check('去重后 3 条', deduped.length === 3, `实际 ${deduped.length}`);
+check('保留的是**首见**那条(match_score=90 而非 999)', deduped[0].match_score === 90, String(deduped[0].match_score));
+check('保持首见顺序', deduped.map((c) => c.title).join(',') === '政策甲,政策乙,政策丙');
+check('空输入安全', dedupePolicyItems(null, buildPolicyCardDedupKey).length === 0);
+
+console.log('\n【4】排序(sortPolicyCardsByMatchScore)');
+const sorted = sortPolicyCardsByMatchScore([cardB, cardA, cardC]); // 45, 90, 60
+check('按 match_score 降序', sorted.map((c) => c.match_score).join(',') === '90,60,45', sorted.map((c) => c.match_score).join(','));
+const tieA = { declaration_item: 'a', match_score: 90, tag2: 'first' };
+const tieB = { declaration_item: 'b', match_score: 90, tag2: 'second' };
+const tieSorted = sortPolicyCardsByMatchScore([tieA, tieB]);
+check('同分保持原相对顺序(稳定排序)', tieSorted[0].tag2 === 'first' && tieSorted[1].tag2 === 'second', tieSorted.map((c) => c.tag2).join(','));
+check('缺 match_score 视为 0 排最后', sortPolicyCardsByMatchScore([{ declaration_item: 'x' }, cardB])[1].declaration_item === 'x');
+check('空输入安全', sortPolicyCardsByMatchScore([]).length === 0);
+
+console.log('\n【5】关键词过滤(matchesPolicyCardKeyword)');
+const searchable = {
+  title: '关于创业扶持的通知',
+  desc: '面向本区创业者',
+  declaration_item: '创业开办费补贴',
+  match_reason: '用户询问开办补贴',
+  source: '区人社局',
+  enterprise_benefit: '最高2万元补贴',
+};
+check('空关键词恒真', matchesPolicyCardKeyword(searchable, '') === true && matchesPolicyCardKeyword(searchable, '   ') === true);
+check('命中 title', matchesPolicyCardKeyword(searchable, '创业扶持') === true);
+check('命中 desc', matchesPolicyCardKeyword(searchable, '本区创业者') === true);
+check('命中 declaration_item', matchesPolicyCardKeyword(searchable, '开办费') === true);
+check('命中 match_reason', matchesPolicyCardKeyword(searchable, '询问') === true);
+check('命中 source(部门)', matchesPolicyCardKeyword(searchable, '区人社局') === true);
+check('命中 enterprise_benefit', matchesPolicyCardKeyword(searchable, '2万元') === true);
+check('大小写不敏感', matchesPolicyCardKeyword({ title: 'ABC Policy' }, 'abc policy') === true);
+check('不命中返回 false', matchesPolicyCardKeyword(searchable, '不存在的词') === false);
+check('item 为 null 不抛错', matchesPolicyCardKeyword(null, 'x') === false);
+
+console.log('\n【6】端到端组合(聚合 → 去重 → 排序 → 过滤)');
+const e2e = sortPolicyCardsByMatchScore(
+  dedupePolicyItems(collectPolicyCardsFromMessages(messages), buildPolicyCardDedupKey)
+);
+check('组合后 3 条且按分数降序', e2e.length === 3 && e2e[0].match_score === 90, e2e.map((c) => c.match_score).join(','));
+const e2eFiltered = e2e.filter((c) => matchesPolicyCardKeyword(c, '政策乙'));
+check('组合后可按关键词筛出 1 条', e2eFiltered.length === 1 && e2eFiltered[0].title === '政策乙', String(e2eFiltered.length));
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 99 - 13
src/components/Chat/PolicyMatch.vue

@@ -26,7 +26,7 @@
           <div class="policy-item-action" @click.stop="openDetailByItem(item)">查看详情</div>
         </div>
       </div>
-      <div class="view-all-btn" @click.stop="openPolicyList">更多 >></div>
+      <div class="view-all-btn" @click.stop="openCardList">更多 >></div>
     </div>
   </div>
 
@@ -36,11 +36,11 @@
       <transition name="policy-panel">
           <div v-if="panelVisible" class="policy-viewer-content" :class="[
             isWide ? 'right-fixed' : '',
-            panelMode === 'list' ? 'list-mode-bg' : 'detail-mode-bg'
+            panelMode === 'detail' ? 'detail-mode-bg' : 'list-mode-bg'
           ]">
           <div class="policy-viewer-header" :class="{ 'policy-viewer-header-record': recordMode }">
             <div class="header-title" v-if="!recordMode && !(isMobileViewer && panelMode === 'detail')">
-              {{ panelMode === "list" ? "智能政策匹配" : "智能政策匹配" }}
+              {{ panelMode === 'cardList' ? '政策列表' : '智能政策匹配' }}
             </div>
             <div class="header-actions">
               <button v-if="panelMode === 'detail' && !recordMode" class="header-back" @click.stop="openPolicyList">
@@ -58,15 +58,19 @@
             </div>
           </div>
 
-          <div v-if="panelMode === 'list'" class="panel-scroll list-body" ref="listBodyRef"
+          <!-- list(旧「政策事项列表」)与 cardList(「更多」→「政策列表」)共用这个 body,
+               靠下面的 v-if 分叉内容;样式类完全复用,未新增 CSS -->
+          <div v-if="panelMode !== 'detail'" class="panel-scroll list-body" ref="listBodyRef"
             :class="{ 'list-body-record': recordMode }">
             <div class="list-content">
               <div class="record-list-header" v-if="recordMode">
-                <div class="record-list-title">政策事项列表</div>
-                <div class="record-list-subtitle">浏览、搜索及筛选最新的惠企政策</div>
+                <div class="record-list-title">{{ panelMode === 'cardList' ? '政策列表' : '政策事项列表' }}</div>
+                <div class="record-list-subtitle">
+                  {{ panelMode === 'cardList' ? '汇总本会话内出现的全部政策事项' : '浏览、搜索及筛选最新的惠企政策' }}
+                </div>
               </div>
 
-              <div class="search-filter-bar" v-if="recordMode && isZhaoshangLLM">
+              <div class="search-filter-bar" v-if="isZhaoshangLLM && (recordMode || panelMode === 'cardList')">
                 <div class="search-filter-top">
                   <div class="search-input-wrapper">
                     <svg class="search-icon" width="18" height="18" viewBox="0 0 18 18" fill="none"
@@ -77,7 +81,8 @@
                     </svg>
                     <input type="text" placeholder="输入政策关键词" class="search-input" v-model="searchKeyword" />
                   </div>
-                  <div class="filter-selects" @click.stop>
+                  <!-- 部门 / 分类筛选:只在旧列表面板出现;「政策列表」侧边栏按需求不带筛选 -->
+                  <div class="filter-selects" v-if="panelMode === 'list'" @click.stop>
                     <div class="filter-select-wrapper">
                       <div class="filter-option" @click="toggleFilterMenu" :class="{ 'is-open': showFilterMenu }">
                         {{ currentFilterOption }}
@@ -470,6 +475,10 @@ import {
   extractPolicyListResponse,
   getLocalPolicyListResponse,
   resolvePolicyDetailIndex,
+  buildPolicyCardDedupKey,
+  dedupePolicyItems,
+  matchesPolicyCardKeyword,
+  sortPolicyCardsByMatchScore,
   type PolicyListFilterOption,
 } from "./policy-match-utils";
 import { MODAL_MUTEX_KEY } from "../business-assistant/modal-mutex";
@@ -527,7 +536,10 @@ const panelVisible = ref(false);
 const policyModalId = Symbol("policy-viewer");
 const modalMutex = inject(MODAL_MUTEX_KEY, null);
 let unregisterPolicyModal: (() => void) | undefined;
-const panelMode = ref<"list" | "detail">("list");
+// list   = 旧列表(惠企政策库 262 条 + 部门/分类筛选),详情面板「返回政策事项列表」用
+// cardList = 卡片区「更多」打开的新侧边栏(本会话全部卡片聚合,无筛选,只有搜索+热门搜索)
+// detail = 单条政策详情
+const panelMode = ref<"list" | "cardList" | "detail">("list");
 const selectedPolicy = ref<number>(0);
 const selectedPolicyOverride = ref<PolicyItem | null>(null);
 const isWide = ref(false);
@@ -616,6 +628,37 @@ onBeforeUnmount(() => {
   document.removeEventListener("click", closeMenus);
 });
 
+// ─── 「更多」→ 政策列表 侧边栏(本会话卡片聚合)─────────────────────────
+//
+// 数据来自 useBusinessAssistantChat provide 的会话级采集函数
+// (每个 PolicyMatch 实例只持有自己那条消息的卡片,聚合必须往上取)。
+// 采不到 provider 时(组件被独立使用)回退为本组件自己的卡片,行为与 syncPolicies 同源。
+const collectSessionPolicyCards = inject("collectSessionPolicyCards", null) as
+  | (() => Record<string, any>[])
+  | null;
+
+const cardListItems = ref<PolicyItem[]>([]);
+
+/** 重建聚合列表:采集 → 与政策库合并补全 → 规范化 → 去重(首见优先)→ 按匹配分排序 */
+const rebuildCardList = () => {
+  const raw =
+    collectSessionPolicyCards?.() ??
+    (props.policyData?.length ? props.policyData : parsePolicyTableContent(props.policyContent));
+  const normalized = (raw || []).map(mergeWithPublicPolicies).map(normalizePolicy);
+  cardListItems.value = sortPolicyCardsByMatchScore(
+    dedupePolicyItems(normalized, buildPolicyCardDedupKey)
+  );
+};
+
+const cardListFiltered = computed(() =>
+  cardListItems.value.filter((item) => matchesPolicyCardKeyword(item, searchKeyword.value))
+);
+const cardListTotal = computed(() => cardListFiltered.value.length);
+const cardListPaginated = computed(() => {
+  const start = (currentPage.value - 1) * itemsPerPage;
+  return cardListFiltered.value.slice(start, start + itemsPerPage);
+});
+
 const filteredPolicies = computed(() => {
   if (props.recordMode) {
     return listPolicyItems.value;
@@ -663,11 +706,18 @@ watch(currentPage, () => {
 });
 
 const totalPages = computed(() => {
+  if (panelMode.value === "cardList") {
+    return Math.ceil(cardListTotal.value / itemsPerPage);
+  }
   const total = props.recordMode ? policyListTotal.value : filteredPolicies.value.length;
   return Math.ceil(total / itemsPerPage);
 });
 
 const paginatedPolicies = computed(() => {
+  // 两种列表模式互斥(由 panelMode 单一开关),共用 currentPage/itemsPerPage 安全
+  if (panelMode.value === "cardList") {
+    return cardListPaginated.value;
+  }
   if (props.recordMode) {
     return filteredPolicies.value;
   }
@@ -981,6 +1031,10 @@ const syncPolicies = () => {
       buildRecordPolicyList();
     }
   }
+  // 「政策列表」侧边栏开着时,流式新增的卡片也同步进去
+  if (panelVisible.value && panelMode.value === "cardList") {
+    rebuildCardList();
+  }
 };
 
 let policyListRequestId = 0;
@@ -1197,11 +1251,13 @@ const openDetailByItem = async (item: { originalData?: RawPolicyItem; support_ob
 };
 
 /**
- * 打开「政策事项列表」。
+ * 打开「政策事项列表」(旧列表)
  *
- * 卡片区「更多 >>」与惠企政策详情的「返回政策事项列表」**走同一条路径、同一个列表**——
- * 不因入口不同而内容不同。列表数据统一来自惠企政策库(policies_public.v1.json),
- * 本轮匹配到的惠企政策置顶。
+ * ⚠️ 2026-09-17 起,**卡片区的「更多 >>」不再走这里** —— 它改为打开
+ * `openCardList()` 的「政策列表」侧边栏(本会话卡片聚合、无筛选)。
+ * 本条路径现在只由惠企政策详情的「返回政策事项列表」使用。
+ *
+ * 列表数据统一来自惠企政策库(policies_public.v1.json),本轮匹配到的惠企政策置顶。
  */
 const openPolicyList = async () => {
   selectedPolicyOverride.value = null;
@@ -1222,6 +1278,36 @@ const openPolicyList = async () => {
   emitPanelWidth();
 };
 
+/**
+ * 打开「政策列表」侧边栏 —— 卡片区「更多 >>」的入口。
+ *
+ * 与 openPolicyList 的区别:内容是本会话内**所有消息**出现过的卡片(跨消息聚合、去重),
+ * 只有搜索框与热门搜索,**没有部门/分类筛选**。
+ *
+ * ⚠️ 必须**先切 panelMode 再重置共享状态**:重置会触发 watch → fetchPolicyList,
+ * 而它开头有 `panelMode !== "list"` 的守卫,切了模式才能让旧列表逻辑短路,
+ * 不污染 policyListItems / policyListTotal。
+ */
+const openCardList = async () => {
+  selectedPolicyOverride.value = null;
+  panelMode.value = "cardList";
+
+  currentPage.value = 1;
+  searchKeyword.value = "";
+  currentDeptFilter.value = "全部部门";
+  currentFilterOption.value = "所有分类";
+  showFilterMenu.value = false;
+  showDeptMenu.value = false;
+
+  rebuildCardList();
+
+  const openTicket = modalMutex?.requestOpen(policyModalId);
+  await nextTick();
+  if (modalMutex && openTicket !== undefined && !modalMutex.isCurrentRequest(policyModalId, openTicket)) return;
+  panelVisible.value = true;
+  emitPanelWidth();
+};
+
 const closePanel = () => {
   panelVisible.value = false;
   modalMutex?.release(policyModalId);

+ 107 - 0
src/components/Chat/policy-match-utils.ts

@@ -1,3 +1,5 @@
+import { parsePolicyTableContent } from "@/utils/stream-xml-filter";
+
 export type PolicyListFilterOption = "所有分类" | "只看免申即享" | "非免申即享";
 
 export type PolicyListQuery = {
@@ -181,3 +183,108 @@ export const resolvePolicyDetailIndex = <T extends { originalData?: any; declara
 
   return matched.length === 1 ? matched[0] : -1;
 };
+
+// ─── 会话级卡片聚合(「更多」→ 政策列表 侧边栏)────────────────────────
+//
+// 背景:卡片区每条消息各自渲染一个 PolicyMatch 实例,`policies.value` 只等于
+// **那一条消息**的卡片;会话内不存在聚合。侧边栏要「本会话所有卡片」,
+// 因此采集层从会话消息的 content 里重新解析 POLICY_TABLE 块(历史消息同样适用,
+// 因为 localStoarge 历史与 DMS 回来的 answer 都原样保留了这些标记)。
+
+/**
+ * 从一组消息里解析出全部政策卡片 raw 条目(跨消息聚合)。
+ * - 只取 AI 消息(政策卡片只会出现在回答里)
+ * - **不去重、不排序**,保持消息顺序 —— 去重与排序由调用方按需组合
+ * - 消息内容不完整(流式进行中)或没有 POLICY_TABLE 块时返回空,不抛错
+ */
+export const collectPolicyCardsFromMessages = (
+  messages: Array<{ role?: string; content?: unknown }> | null | undefined
+): Record<string, any>[] => {
+  const cards: Record<string, any>[] = [];
+  for (const message of Array.isArray(messages) ? messages : []) {
+    if (message?.role !== "ai") continue;
+    cards.push(...parsePolicyTableContent(String(message.content ?? "")));
+  }
+  return cards;
+};
+
+/**
+ * 卡片去重键。
+ *
+ * 主键用 `declaration_item`:自 2026-09-17 起它是「政策名 - 申报事项」,每条唯一
+ * (之前它与政策名同值,同政策的多个申报事项无法区分 —— 见 policy-match-utils
+ * 里 resolvePolicyDetailIndex 的说明)。
+ *
+ * 精简格式(minimal)的 `declaration_item` 恒为占位符「查看政策详情」,
+ * 用它去重会把不同卡片错并成一条,故退化为「标题|匹配度|匹配理由|id」组合键。
+ */
+// 入参刻意用 any:进来的是从消息里解析出的无类型 JSON(也可能是已 normalize 的 PolicyItem),
+// 收窄成具体类型反而会让 dedupePolicyItems 的泛型推断跑偏(会把 T 推成 Record<string, any>)
+export const buildPolicyCardDedupKey = (item: any): string => {
+  const declaration = String(item?.declaration_item ?? "").trim();
+  if (declaration && declaration !== "查看政策详情") {
+    return `decl:${declaration}`;
+  }
+  const id = String(
+    item?.id ?? item?.policy_id ?? item?.originalData?.id ?? item?.originalData?.policy_id ?? ""
+  ).trim();
+  return `fb:${String(item?.title ?? "")}|${String(item?.match_degree ?? "")}|${String(
+    item?.match_reason ?? ""
+  ).slice(0, 60)}|${id}`;
+};
+
+/** 按 key 去重,**首见优先**(保证聚合结果与会话内出现顺序一致、可预期) */
+export const dedupePolicyItems = <T>(items: T[] | null | undefined, keyOf: (item: T) => string): T[] => {
+  const seen = new Set<string>();
+  return (Array.isArray(items) ? items : []).filter((item) => {
+    const key = keyOf(item);
+    if (seen.has(key)) return false;
+    seen.add(key);
+    return true;
+  });
+};
+
+/** 按 match_score 降序;同分保持原有相对顺序(稳定排序,避免顺序抖动) */
+export const sortPolicyCardsByMatchScore = <T extends { match_score?: number | string }>(
+  items: T[] | null | undefined
+): T[] =>
+  (Array.isArray(items) ? items : [])
+    .map((item, index) => ({ item, index }))
+    .sort(
+      (a, b) =>
+        (Number(b.item?.match_score) || 0) - (Number(a.item?.match_score) || 0) || a.index - b.index
+    )
+    .map(({ item }) => item);
+
+/**
+ * 卡片关键词匹配。检索面与旧列表 buildRecordPolicyList 保持一致
+ * (title / desc / declaration_item / match_reason / source / enterprise_benefit)。
+ * 空关键词恒真;大小写不敏感。
+ */
+export const matchesPolicyCardKeyword = (
+  item: {
+    title?: unknown;
+    desc?: unknown;
+    declaration_item?: unknown;
+    match_reason?: unknown;
+    source?: unknown;
+    enterprise_benefit?: unknown;
+  } | null
+  | undefined,
+  keyword: string
+): boolean => {
+  const kw = String(keyword ?? "").trim().toLowerCase();
+  if (!kw) return true;
+  const haystack = [
+    item?.title,
+    item?.desc,
+    item?.declaration_item,
+    item?.match_reason,
+    item?.source,
+    item?.enterprise_benefit,
+  ]
+    .map((value) => String(value ?? ""))
+    .join(" ")
+    .toLowerCase();
+  return haystack.includes(kw);
+};

+ 15 - 0
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -25,6 +25,7 @@ import { buildTestDocumentLineStreamChunks } from './test-document-line-stream';
 import { getChatHistoryStorageKey, enterpriseInfo } from '@/components/useEnterpriseAuth';
 import { fetchRemoteSessionRecords, updateSessionInfo, deleteSessionInfo, type RemoteSession } from '@/network/api/chat-sessions';
 import { upsertDmsSession, saveDmsTranscript } from '@/network/api/dms/chat-sessions-dms';
+import { collectPolicyCardsFromMessages } from '../Chat/policy-match-utils';
 
 interface UseBusinessAssistantChatOptions {
   apiBaseUrl?: string;
@@ -1626,6 +1627,20 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
 
   provide('submitQuestionAnswers', submitQuestionAnswers);
 
+  /**
+   * 会话级政策卡片聚合:返回**本会话**所有 AI 消息里出现过的政策卡片 raw 条目。
+   *
+   * 用途:卡片区「更多」打开的新侧边栏(政策列表)要展示整个会话的卡片,
+   * 而每个 PolicyMatch 实例只持有自己那条消息的卡片,故统一从这里取。
+   *
+   * 调用时才读 currentSession.value —— 切换会话后天然跟随,无需额外同步。
+   * 这里只负责「读会话 + 解析」,去重/排序/过滤交给 policy-match-utils 的纯函数
+   * (可被 harness 脚本直接测试)。
+   */
+  provide('collectSessionPolicyCards', (): Record<string, any>[] =>
+    collectPolicyCardsFromMessages(currentSession.value?.messages ?? [])
+  );
+
   return {
     suggestions,
     chatHistory,

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