policy-card-list-sidebar.md 8.2 KB

卡片区「更多」→ 新侧边栏「政策列表」— 实施计划

状态:✅ 已完成并人工验证(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:<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) 后新增:

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)、cardListTotalcardListPaginated
  • totalPages / paginatedPoliciespanelMode === '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
: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 buildnpx 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):点「更多」→ 标题「政策列表」、有搜索+热门词、无筛选; 搜索/热门词过滤正确;两个不同政策问题后聚合可见且去重;点条目详情一致; 「返回政策事项列表」仍是旧列表;切会话聚合跟随