# 卡片区「更多」→ 新侧边栏「政策列表」— 实施计划 > **状态**:✅ **已完成**(2026-09-17 归档到 `completed/`) > **结果**:面板新增 `cardList` 模式,卡片区「更多」打开「政策列表」侧边栏 > (本会话卡片跨消息聚合、去重、按匹配度排序;保留搜索框与热门搜索,无部门/分类筛选); > 详情面板的「返回政策事项列表」仍走旧列表(按要求只换「更多」入口)。 > 回归脚本 `harness/tools/verify-policy-card-list.mjs` 31/31 通过。 > ⚠️ **未做**:浏览器人工点验。 ## 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:`;占位符「查看政策详情」(精简格式)时退化为 标题|匹配度|匹配理由|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[] => 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):点「更多」→ 标题「政策列表」、有搜索+热门词、**无筛选**; 搜索/热门词过滤正确;两个不同政策问题后聚合可见且去重;点条目详情一致; 「返回政策事项列表」仍是旧列表;切会话聚合跟随