Bladeren bron

feat(policy): 政策事项列表统一入口;详情返回按钮按惠企政策显示

- 「政策事项列表」统一为一个列表、两个入口走同一条路径:
  卡片区「更多 >>」与详情的「返回政策事项列表」效果相同,
  数据统一来自 public/merchant-agent/policies_public.v1.json
  (262 条申报事项 / 38 个政策),本轮匹配到的惠企政策置顶
  (此前误做成两套数据源:卡片「更多」看卡片、详情「返回」看库)
- 政策详情面板的「返回政策事项列表」改为仅惠企政策显示;
  惠企政策判定优先用接口字段 is_policy_library,缺失时回退到按标题在库中匹配,
  标题匹配做归一化(容忍《》全半角括号、连接符、空白)
- 按钮文案:「返回政策列表」→「返回政策事项列表」
- 新增 src/components/Chat/policy-library-utils.ts:把判定与置顶排序抽为纯逻辑,
  便于验证脚本引用同一份实现而非复制
- 修一个缓存 bug:库数据一度写成 computed 读 globalThis,
  而该值是异步加载、应用不等待它,computed 会把空值缓存住;
  改为函数现读(globalThis 属性不是响应式依赖)

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 4 dagen geleden
bovenliggende
commit
103e53bfbd
3 gewijzigde bestanden met toevoegingen van 163 en 6 verwijderingen
  1. 7 0
      README.md
  2. 90 6
      src/components/Chat/PolicyMatch.vue
  3. 66 0
      src/components/Chat/policy-library-utils.ts

+ 7 - 0
README.md

@@ -30,6 +30,13 @@
 
 ## 20260916
 
+- 「政策事项列表」统一为**一个列表、一个入口**:
+  - 卡片区「更多 >>」与详情的「返回政策事项列表」**走同一条路径、内容相同**
+  - 列表数据统一来自 `public/merchant-agent/policies_public.v1.json`
+    (262 条申报事项 / 38 个政策),**本轮匹配到的惠企政策置顶**
+- 政策详情面板的「返回政策事项列表」按惠企政策区分:**只有惠企政策才显示该入口**;
+  惠企政策判定优先用接口字段 `is_policy_library`,缺失时回退到按标题在库中匹配
+  (已做归一化,容忍《》全半角括号等差异)
 - 公司补问改为按 `status` 字段判定形态(`found` / `not_found` / `failed` / 无 `status`),
   替掉原先靠"候选数是否为 0"和 `input_help` 文案正则的猜测式判断
   - `failed`(查询服务失败)此前会落进 `not_found` 分支,文案被填成"未查询到匹配的公司",

+ 90 - 6
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="openList">更多 >></div>
+      <div class="view-all-btn" @click.stop="openPolicyList">更多 >></div>
     </div>
   </div>
 
@@ -43,7 +43,7 @@
               {{ panelMode === "list" ? "智能政策匹配" : "智能政策匹配" }}
             </div>
             <div class="header-actions">
-              <button v-if="panelMode === 'detail' && !recordMode" class="header-back" @click.stop="openList">
+              <button v-if="panelMode === 'detail' && !recordMode" class="header-back" @click.stop="openPolicyList">
                 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                   <path d="M15 18L9 12L15 6" stroke="white" stroke-width="2" stroke-linecap="round"
                     stroke-linejoin="round" />
@@ -216,11 +216,12 @@
 
           <div v-else class="panel-scroll detail-body" :class="{ 'detail-body-record': recordMode }">
             <div class="record-header-section" v-if="recordMode">
-              <div class="record-back-btn" @click.stop="openList">
+              <!-- 只有惠企政策才显示「返回政策事项列表」——它通向惠企政策库 -->
+              <div class="record-back-btn" v-if="selectedIsPolicyLibrary" @click.stop="openPolicyList">
                 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
                   <path d="M10.5 12.5L6 8L10.5 3.5L9.5 2.5L4 8L9.5 13.5L10.5 12.5Z" fill="#3525CD" />
                 </svg>
-                <span>返回政策列表</span>
+                <span>返回政策事项列表</span>
               </div>
               <div class="record-main-title">{{ selectedPolicyItem?.declaration_item ||
                 '建立重点产品碳足迹背景数据库,构建碳足迹因子核算和发布工作机制' }}</div>
@@ -454,6 +455,12 @@
 import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
 import { parsePolicyTableContent } from "@/utils/stream-xml-filter";
 import { isZhaoshangLLM as isZhaoshangLLMModel } from "@/utils";
+import {
+  buildLibraryNameSet,
+  collectMatchedLibraryNames,
+  isPolicyLibraryItem,
+  pinMatchedFirst,
+} from "./policy-library-utils";
 import {
   buildPolicyListSearchParams,
   buildPolicyListUrl,
@@ -851,6 +858,70 @@ const listPolicyItems = computed(() => {
  * 顺序与卡片完全一致(不再另行拉取与卡片无关的公共政策库),
  * 部门筛选也一定能筛到内容。
  */
+/* ──────────────────────────────────────────────────────────────────────────
+ * 惠企政策库相关
+ *
+ * 惠企政策 = policies_public.v1.json 里的那些政策(262 条申报事项 / 38 个政策,
+ * 由 index.html 预加载到 globalThis.policiesPublic)。
+ * 判定优先用接口下发的 is_policy_library 字段,缺失时回退到"标题能在库里找到"。
+ * ────────────────────────────────────────────────────────────────────────── */
+
+/**
+ * ⚠️ 这里是**函数**不是 computed,是刻意的。
+ *
+ * `globalThis.policiesPublic` 由 index.html 异步 fetch 加载(失败时退到本地兜底),
+ * 且应用**不等待**它——不是响应式依赖。写成 computed 会把首次读到的值
+ * (很可能是空数组)缓存住,加载完成后也不会更新。
+ * 所以每次调用时现读。
+ */
+const getLibraryPolicies = (): RawPolicyItem[] =>
+  Array.isArray(globalThis.policiesPublic) ? globalThis.policiesPublic : [];
+
+const getLibraryNameSet = (): Set<string> => buildLibraryNameSet(getLibraryPolicies());
+
+/** 本轮卡片里匹配到的惠企政策名(用于在库列表里置顶) */
+const matchedLibraryNames = computed(() =>
+  collectMatchedLibraryNames(policies.value, getLibraryNameSet())
+);
+
+/** 当前详情页正在看的政策是否惠企政策——决定是否显示"返回政策列表" */
+const selectedIsPolicyLibrary = computed(() =>
+  isPolicyLibraryItem(selectedPolicyItem.value, getLibraryNameSet())
+);
+
+/**
+ * 构建惠企政策库列表(从 policies_public.v1.json)。
+ * 本轮匹配到的惠企政策置顶,其余保持库内原顺序。
+ */
+const buildLibraryPolicyList = () => {
+  const keyword = searchKeyword.value.trim().toLowerCase();
+  const dept = currentDeptFilter.value === "全部部门" ? "" : currentDeptFilter.value;
+  const matched = matchedLibraryNames.value;
+
+  const filtered = getLibraryPolicies().map(normalizePolicy).filter((item) => {
+    // 与卡片列表保持同一套筛选逻辑
+    if (dept && String(item.source || "") !== dept) {
+      return false;
+    }
+    const autoGranted = !!item.originalData?.auto_granted;
+    if (currentFilterOption.value === "只看免申即享" && !autoGranted) return false;
+    if (currentFilterOption.value === "非免申即享" && autoGranted) return false;
+    if (keyword) {
+      const haystack = [item.title, item.declaration_item, item.source, item.desc]
+        .join(" ")
+        .toLowerCase();
+      if (!haystack.includes(keyword)) return false;
+    }
+    return true;
+  });
+
+  // 置顶:本轮匹配到的惠企政策在前,其余保持原顺序
+  const ordered = pinMatchedFirst(filtered, matched, (item) => item.title);
+  const start = (currentPage.value - 1) * itemsPerPage;
+  policyListItems.value = ordered.slice(start, start + itemsPerPage);
+  policyListTotal.value = ordered.length;
+};
+
 const buildRecordPolicyList = () => {
   const keyword = searchKeyword.value.trim().toLowerCase();
   const dept = currentDeptFilter.value === "全部部门" ? "" : currentDeptFilter.value;
@@ -907,7 +978,13 @@ let policyListRequestId = 0;
 const fetchPolicyList = async () => {
   if (!props.recordMode || !panelVisible.value || panelMode.value !== "list") return;
 
-  // 有卡片数据时一律本地筛选,保证“更多”与卡片同源同序
+  // 政策事项列表统一来自惠企政策库(policies_public.v1.json)
+  if (getLibraryPolicies().length) {
+    buildLibraryPolicyList();
+    return;
+  }
+
+  // 兜底:库还没加载完时,先列出本轮匹配到的卡片,避免列表空白
   if (policies.value.length) {
     buildRecordPolicyList();
     return;
@@ -1113,7 +1190,14 @@ const openDetailByItem = async (item: { originalData?: RawPolicyItem; support_ob
   emitPanelWidth();
 };
 
-const openList = async () => {
+/**
+ * 打开「政策事项列表」。
+ *
+ * 卡片区「更多 >>」与惠企政策详情的「返回政策事项列表」**走同一条路径、同一个列表**——
+ * 不因入口不同而内容不同。列表数据统一来自惠企政策库(policies_public.v1.json),
+ * 本轮匹配到的惠企政策置顶。
+ */
+const openPolicyList = async () => {
   selectedPolicyOverride.value = null;
   panelMode.value = "list";
   const openTicket = modalMutex?.requestOpen(policyModalId);

+ 66 - 0
src/components/Chat/policy-library-utils.ts

@@ -0,0 +1,66 @@
+/**
+ * 惠企政策库的纯逻辑。
+ *
+ * 惠企政策 = `policies_public.v1.json` 里的那些政策
+ * (由 index.html 预加载到 `globalThis.policiesPublic`)。
+ *
+ * 抽成独立模块的目的:让 `harness/tools/` 里的验证脚本能直接引用同一份实现,
+ * 而不是复制一份——复制会漂移,测的就不是真代码了。
+ */
+
+/** 政策名归一化:库里带《》,卡片标题不带,另有全半角括号、连接符、空白等差异 */
+export const normPolicyName = (text: unknown): string =>
+  String(text || "")
+    .replace(/[《》\s()()·—–\-_]/g, "")
+    .toLowerCase();
+
+/** 从惠企政策库原始条目里取出用于匹配的政策名集合 */
+export const buildLibraryNameSet = (libraryPolicies: unknown[] | null | undefined): Set<string> =>
+  new Set(
+    (Array.isArray(libraryPolicies) ? libraryPolicies : [])
+      .map((p: any) => normPolicyName(p?.name))
+      .filter(Boolean)
+  );
+
+/**
+ * 判断某个条目是否惠企政策。
+ *
+ * 优先用接口下发的 `is_policy_library` 字段(精确、无需匹配);
+ * 字段缺失时回退到"标题能在惠企政策库里找到"(兼容旧数据)。
+ */
+export const isPolicyLibraryItem = (item: any, libraryNameSet: Set<string>): boolean => {
+  if (item?.originalData?.is_policy_library === true) {
+    return true;
+  }
+  const name = normPolicyName(item?.title);
+  return !!name && libraryNameSet.has(name);
+};
+
+/** 收集本轮卡片里匹配到的惠企政策名 */
+export const collectMatchedLibraryNames = (
+  cards: any[] | null | undefined,
+  libraryNameSet: Set<string>
+): Set<string> =>
+  new Set(
+    (Array.isArray(cards) ? cards : [])
+      .filter((item) => isPolicyLibraryItem(item, libraryNameSet))
+      .map((item) => normPolicyName(item?.title))
+      .filter(Boolean)
+  );
+
+/**
+ * 把匹配到的条目置顶,其余保持原顺序。
+ * 用于惠企政策库列表:本轮匹配到的惠企政策排在最前。
+ */
+export const pinMatchedFirst = <T>(
+  items: T[],
+  matchedNames: Set<string>,
+  getName: (item: T) => unknown
+): T[] => {
+  const pinned: T[] = [];
+  const rest: T[] = [];
+  items.forEach((item) => {
+    (matchedNames.has(normPolicyName(getName(item))) ? pinned : rest).push(item);
+  });
+  return [...pinned, ...rest];
+};