export type PolicyListFilterOption = "所有分类" | "只看免申即享" | "非免申即享"; /** * 由「被点击的条目」反查它在 `policies` 数组里的下标;查不到返回 -1。 * * 🐛 为什么需要这个函数(2026-09-17 修的真实 bug): * 同一条政策下的**多个申报事项**会各生成一张卡片,但适配层把卡片的 * `title` 与 `declaration_item` 都取自 `card.name.text`(**只有政策名**), * 而后端又没有下发 `id` / `policy_id`(实测恒为 undefined)。 * 于是这些卡片在「按字段比对」这条路上**完全无法区分**—— * 原先的 findIndex 只要任一条件命中就返回,导致点第二张卡片也跳到第一张, * 表现为「两张卡片的详情内容一模一样」。 * * 匹配顺序(从可靠到不可靠): * 1. **对象同一性**:卡片区传入的就是 policies 里的那个对象,这一条是精确的 * 2. 字段比对(id / policy_id / declaration_item):**只有唯一命中才采用**。 * 多命中说明这组数据本就区分不了(例如同一政策的多个申报事项), * 此时返回 -1 交给调用方用「直接展示被点条目自己」的兜底路径 —— * 宁可走兜底,也不要张冠李戴地展示另一条的内容。 */ export const resolvePolicyDetailIndex = ( policies: T[] | null | undefined, item: { originalData?: any; declaration_item?: string } | null | undefined ): number => { const list = Array.isArray(policies) ? policies : []; if (!list.length || !item) return -1; // 1. 对象同一性 const exact = list.findIndex((policy) => policy?.originalData === item.originalData); if (exact >= 0) return exact; // 2. 字段比对(仅在唯一命中时采信) const targetId = item.originalData?.id || item.originalData?.policy_id; const targetDeclaration = item.originalData?.declaration_item; const matched: number[] = []; list.forEach((policy, index) => { const current = policy?.originalData; const sameId = !!targetId && (current?.id === targetId || current?.policy_id === targetId); const sameDeclaration = !!targetDeclaration && policy?.declaration_item === targetDeclaration; if (sameId || sameDeclaration) matched.push(index); }); return matched.length === 1 ? matched[0] : -1; }; // ─── 「更多」→ 政策列表 侧边栏用的纯逻辑 ──────────────────────────────── // // 侧边栏列的是**本条回答里出现的政策**(用户 2026-09-18 调整;此前一度做成 // 「整个会话跨消息聚合」,已撤回)。所以这里只留去重 / 排序 / 关键词过滤这几个 // 通用零件,数据源由 PolicyMatch 自己提供(props.policyData / policyContent)。 /** * 卡片去重键。 * * 主键用 `declaration_item`:自 2026-09-17 起它是「政策名 - 申报事项」,每条唯一 * (之前它与政策名同值,同政策的多个申报事项无法区分 —— 见 policy-match-utils * 里 resolvePolicyDetailIndex 的说明)。 * * 精简格式(minimal)的 `declaration_item` 恒为占位符「查看政策详情」, * 用它去重会把不同卡片错并成一条,故退化为「标题|匹配度|匹配理由|id」组合键。 */ // 入参刻意用 any:进来的是从消息里解析出的无类型 JSON(也可能是已 normalize 的 PolicyItem), // 收窄成具体类型反而会让 dedupePolicyItems 的泛型推断跑偏(会把 T 推成 Record) 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 = (items: T[] | null | undefined, keyOf: (item: T) => string): T[] => { const seen = new Set(); 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 = ( 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); };