瀏覽代碼

feat(policy): 部门筛选只列 JSON 里实际存在的部门(用户要求)

用户要求:「部门筛选只包含 JSON 里面存在的所有部门。」

背景:这推翻了先前的决定 —— 技术债 #4 记着「部门下拉固定为最初的 15 个、用户明确要求保持、
不要当 bug 修」。换到 v2 数据后固定列表的问题更明显:里面有库里根本没有的部门(选了筛出空),
也可能漏掉库里新增的部门。

改动(PolicyMatch.vue):
- 删掉 DEFAULT_DEPTS(硬编码 15 个),改为 collectDeptOptions():从惠企政策库的「政策所属部门」
  (并集上本会话卡片里出现过的部门)去重、中文排序,前面固定加「全部部门」
- 用 ref + 手动刷新而非 computed:globalThis.policiesPublic 是异步加载、不是响应式依赖
  (getLibraryPolicies 的注释里早写着这个坑),computed 会把首次读到的空列表缓存住 ——
  所以在「打开部门菜单」与「打开列表面板」时各现算一次
- 选中的部门若已不在新清单里 → 自动退回「全部部门」(否则筛空还没提示)
- 匹配仍是字面相等(清单与数据同一口径,所以这次"筛得到"能成立)
- 顺带把该文件三处过期的 policies_public.v1.json 注释改成 v2

验证:verify-policy-library 29 → 35/35(新增第 7 节 6 项:打印库里 10 个部门、断言前端不再有
硬编码部门列表、选项由数据派生、现算时机 ≥3 处、失效回退、匹配口径);回归:政策卡列表 24/24、
问卷 36/36、内联脚本 6/6、NLS 44/44、build 通过、vue-tsc 仅既有 webpack-env、validate-harness 通过。
文档:技术债 #4 标记已还(并注明"不要当 bug 修"的告诫已被本次用户指示取代)。

⚠️ 仍需用户在浏览器确认:部门下拉应只出现库里那 10 个部门,选任一个都能筛出内容。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 月之前
父節點
當前提交
3a60aeb9e7

+ 9 - 0
README.md

@@ -456,6 +456,15 @@
 > 今天两条主线:**语音识别换阿里云 NLS 收尾**(token 改由后端网关签发)+ **一次大清理**
 > (死代码、多余环境、走不到的引导链)。清理过程中出过一次回归,已修并补上守护脚本。
 
+- **部门筛选改为「只列 JSON 里实际存在的部门」**(用户 2026-09-24):
+  - 删掉硬编码的 15 个部门(`DEFAULT_DEPTS`),改为从惠企政策库的 `政策所属部门`
+    现算(去重 + 中文排序,前面固定「全部部门」)
+  - ⚠️ 用 ref + 打开菜单/面板时现算,**不能用 computed** —— `globalThis.policiesPublic`
+    是异步加载、没有响应式依赖,computed 会把首次的空列表缓存住
+  - 选中的部门若不在新清单里 → 自动退回「全部部门」(否则会筛空还没提示)
+  - 这推翻了先前「保持固定列表、不要当 bug 修」的决定 → 技术债 **#4 已还**
+  - 验证:`verify-policy-library` 29 → **35/35**(新增第 7 节:打印库里 10 个部门、
+    断言不再有硬编码列表、选项由数据派生、现算时机、失效回退、匹配口径)
 - **修问卷两个问题**(用户 2026-09-24 反馈,附截图):
   - ①「下方已经新对话了,上方的问卷应该是只读状态」:`QuestionCard` 本来就有只读分支,
     但判定是「内容里带 answers」,而**此前没有任何代码把答案写回内容** → 永远走不到。

+ 15 - 0
harness/docs/exec-plans/tech-debt-tracker.md

@@ -26,6 +26,19 @@
 - **该还的条件**:后端修(推荐,最干净),或前端加去重(权宜:比对首句并剥离,
   但属启发式判断,可能误删)。**需用户拍板走哪条。**
 
+### 4.(2026-09-24 已还)部门筛选在新数据下筛不出结果
+
+> **还清了**:用户 2026-09-24 明确要求「部门筛选只包含 JSON 里面存在的所有部门」——
+> 前端不再用那份**硬编码的部门列表**,改为**从惠企政策库(`政策所属部门`)现算**
+> (打开部门菜单/面板时算一次;选中项若不在新清单里自动退回「全部部门」)。
+> 于是"固定列表 vs 数据取值口径不一致"这个根因消失了:清单里不会再有库里没有的部门,
+> 也不会漏掉库里新增的部门。验证见 `verify-policy-library.mjs` 第 7 节(35/35)。
+>
+> ⚠️ 早先"不要当 bug 修"的告诫**已被这条用户指示取代** —— 别再按老口径改回固定列表。
+
+<details><summary>当初的欠账记录</summary>
+
+
 ### 4. 部门筛选在新数据下筛不出结果
 
 - **是什么**:政策「更多」面板的部门下拉是**固定的最初 15 个部门**,
@@ -35,6 +48,8 @@
   两个方案都被否掉。
 - **该还的条件**:用户明确要求时再动。
 
+</details>
+
 ### 5. `Question.message` 字段未被渲染
 
 - **是什么**:`QuestionCard` 的 `Question` 接口里定义了 `message` 字段,

+ 33 - 0
harness/progress.md

@@ -4280,6 +4280,39 @@ payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照
   切换说明)、`current-api-call-sites.md`(§0 A 表第 6 行、§三 的旧路径标注为已删)、
   `asr-realtime.md`(历史文档里指向已删文件的**链接**改成纯文本,避免 404 链接)
 
+### 续 46:部门筛选改为「只列 JSON 里实际存在的部门」(用户要求)
+
+**用户要求**:「部门筛选只包含 JSON 里面存在的所有部门。」
+
+**背景**:这一条**推翻了先前的决定** —— 技术债 #4 记着"部门下拉是固定的最初 15 个部门、
+用户明确要求保持、不要当 bug 修"。换到 v2 数据后,固定列表的问题更明显:
+里面有库里根本没有的部门(选了筛出空),也可能漏掉库里新增的部门。
+
+**改法**(`PolicyMatch.vue`):
+- 删掉 `DEFAULT_DEPTS`(那份硬编码 15 个),改为 `deptOptions` + `collectDeptOptions()`:
+  从**惠企政策库的 `政策所属部门`**(并集上本会话卡片里出现过的部门)去重、中文排序,
+  前面固定加「全部部门」
+- ⚠️ **用 `ref` + 手动刷新而不是 `computed`**:`globalThis.policiesPublic` 是 index.html
+  异步加载的、**不是响应式依赖**(这个坑 `getLibraryPolicies` 的注释里早就写着),
+  computed 会把首次读到的空列表缓存住。所以在**打开部门菜单**与**打开列表面板**时各现算一次
+- 顺带:选中的部门若已不在新清单里(数据换了)→ 自动退回「全部部门」,
+  否则会筛出空列表且没有任何提示
+- 部门匹配仍是**字面相等**(`item.source === 选中部门`)—— 现在清单与数据同一口径,
+  所以"筛得到"这件事终于成立了
+- 顺带把该文件里三处过期的 `policies_public.v1.json` 注释改成 v2
+
+**运行过的验证**:`verify-policy-library.mjs` 29 → **35/35**,新增第 7 节 6 项:
+打印库里的部门清单(实测 **10 个**:区财政局/区经委/区民政局/区农业农村委/区人才局/区人社局/
+区商务委/区市场监管局/区卫健委/区文旅局)、断言**前端不再有硬编码部门列表**(`DEFAULT_DEPTS` 消失)、
+部门选项由数据派生、打开菜单/面板时现算(refreshDeptOptions ≥3 处调用)、
+选中项失效时退回「全部部门」、匹配口径仍字面相等。
+回归:政策卡列表 24/24、问卷 36/36、内联脚本 6/6、NLS 44/44、build 通过、
+`vue-tsc` 仅既有 webpack-env、`validate-harness` 通过。
+文档同步:技术债 **#4 标记为已还**(并注明"不要当 bug 修"那条告诫已被本次用户指示取代)。
+
+**⚠️ 仍需用户在浏览器确认**:打开政策「更多」→ 部门下拉里应只出现库里那 10 个部门,
+选任一个都能筛出内容(此前固定列表里 区发改委/区科委 等是没有数据的)。
+
 ### 续 45:只读态改成「同一个问卷模板」(用户:选完还是长什么样)
 
 **用户反馈**(附截图:作答后卡片缩水成一个只有「确认公司」+ 一个选项的小框):

+ 21 - 0
harness/tools/verify-policy-library.mjs

@@ -15,6 +15,7 @@
  */
 
 import { createRequire } from 'node:module';
+import { readFileSync } from 'node:fs';
 import {
   normPolicyName,
   buildLibraryNameSet,
@@ -163,5 +164,25 @@ console.log('\n=== 6. 政策详情页地址(由 市级政策id 拼)===');
   check('但每条都有「政策原文地址」(列表里的链接来源)', withLink === library.length, `${withLink}/${library.length}`);
 }
 
+console.log('\n=== 7. 部门筛选项:只列数据里实际存在的部门(用户 2026-09-24 要求)===');
+{
+  const source = readFileSync('src/components/Chat/PolicyMatch.vue', 'utf8');
+  const departments = [...new Set(
+    library.map((p) => String(p?.['政策所属部门'] ?? '').trim()).filter(Boolean)
+  )].sort((a, b) => a.localeCompare(b, 'zh-CN'));
+  console.log('  库里的部门(' + departments.length + ' 个):' + departments.join('、'));
+
+  check('库里的部门取值都非空', departments.length > 0 && library.every((p) => String(p?.['政策所属部门'] ?? '').trim()));
+  // 关键:不再有硬编码部门列表 —— 固定列表会「有库里没有的部门(选了筛空)+ 缺库里新增的部门」
+  check('**前端不再有硬编码部门列表**(DEFAULT_DEPTS 必须消失)', !/DEFAULT_DEPTS/.test(source));
+  check('部门选项由数据派生(读 政策所属部门)', /政策所属部门/.test(source) && /collectDeptOptions/.test(source));
+  check('打开部门菜单/面板时会现算一次(库是异步加载的,不能用 computed 缓存)',
+    /if \(willOpen\) refreshDeptOptions\(\)/.test(source) &&
+      (source.match(/refreshDeptOptions\(\)/g) || []).length >= 3);
+  check('选中的部门若不在新清单里 → 退回「全部部门」(否则会筛出空列表还没提示)',
+    /if \(!next\.includes\(currentDeptFilter\.value\)\)/.test(source));
+  check('部门匹配仍是字面相等(清单与数据取值同一口径)', /!== dept/.test(source));
+}
+
 console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
 process.exit(fail ? 1 : 0);

+ 41 - 29
src/components/Chat/PolicyMatch.vue

@@ -557,32 +557,38 @@ const filterOptions: PolicyListFilterOption[] = ["所有分类", "只看免申
 const currentFilterOption = ref<PolicyListFilterOption>("所有分类");
 const showFilterMenu = ref(false);
 
-// 兜底部门列表(仅在拿不到卡片数据时使用)
-const DEFAULT_DEPTS = [
-  "区发改委",
-  "区经委",
-  "区商务委",
-  "区科委",
-  "区市场监管局",
-  "区人社局",
-  "区文旅局",
-  "区农业农村委",
-  "区体育局",
-  "区人才局",
-  "区财政局",
-  "区教育局",
-  "区卫健委",
-  "区民政局",
-  "区退役军人事务局"
-];
-
 /**
- * 部门筛选项。
- * 对话内(记录模式)用卡片数据里真实出现的部门,
- * 否则原来那 15 个硬编码部门大多筛不到内容,且弹出层过高会被面板裁切。
+ * 部门筛选项:**只列数据里实际存在的部门**(用户 2026-09-24 明确要求)。
+ *
+ * 历史:这里曾经是一份**硬编码的 15 个部门**(用户早先要求"保持最初的样子"),
+ * 于是新数据里出现别的部门就筛不到、固定列表里也可能有库里根本没有的部门(选了空列表)。
+ * 现在改为从「惠企政策库 + 本会话卡片」现算。
+ *
+ * ⚠️ 用 ref + 手动刷新,**不用 computed**:`globalThis.policiesPublic` 由 index.html 异步加载、
+ * 不是响应式依赖(详见 `getLibraryPolicies` 的注释),computed 会把首次读到的空列表缓存住。
+ * 所以在**打开菜单时**现算一次(那时数据早已加载完)。
  */
-// 部门筛选项:固定为最初的那 15 个部门
-const deptOptions = computed(() => ["全部部门", ...DEFAULT_DEPTS]);
+const deptOptions = ref<string[]>(["全部部门"]);
+
+const collectDeptOptions = (): string[] => {
+  const clean = (values: unknown[]) =>
+    values.map((value) => String(value ?? "").trim()).filter(Boolean);
+  const fromLibrary = clean(getLibraryPolicies().map((item) => item?.["政策所属部门"]));
+  const fromCards = clean(policies.value.map((item) => item?.source));
+  const unique = [...new Set([...fromLibrary, ...fromCards])].sort((a, b) =>
+    a.localeCompare(b, "zh-CN")
+  );
+  return ["全部部门", ...unique];
+};
+
+const refreshDeptOptions = () => {
+  const next = collectDeptOptions();
+  deptOptions.value = next;
+  // 选中的部门若已不在数据里(数据换了),退回"全部部门",否则会筛出空列表还没提示
+  if (!next.includes(currentDeptFilter.value)) {
+    currentDeptFilter.value = "全部部门";
+  }
+};
 
 const currentDeptFilter = ref("全部部门");
 const showDeptMenu = ref(false);
@@ -600,8 +606,11 @@ const toggleFilterMenu = () => {
 };
 
 const toggleDeptMenu = () => {
-  showDeptMenu.value = !showDeptMenu.value;
+  const willOpen = !showDeptMenu.value;
+  showDeptMenu.value = willOpen;
   showFilterMenu.value = false;
+  // 打开时才现算部门清单(数据是异步加载的、且没有响应式依赖)
+  if (willOpen) refreshDeptOptions();
 };
 
 const selectFilter = (opt: PolicyListFilterOption) => {
@@ -926,7 +935,7 @@ const listPolicyItems = computed(() => {
 /* ──────────────────────────────────────────────────────────────────────────
  * 惠企政策库相关
  *
- * 惠企政策 = policies_public.v1.json 里的那些政策(262 条申报事项 / 38 个政策,
+ * 惠企政策 = policies_public.v2.json 里的那些政策(170 条申报事项 / 25 个政策,
  * 由 index.html 预加载到 globalThis.policiesPublic)。
  * 判定优先用接口下发的 is_policy_library 字段,缺失时回退到"标题能在库里找到"。
  * ────────────────────────────────────────────────────────────────────────── */
@@ -955,7 +964,7 @@ const selectedIsPolicyLibrary = computed(() =>
 );
 
 /**
- * 构建惠企政策库列表(从 policies_public.v1.json)。
+ * 构建惠企政策库列表(从 policies_public.v2.json)。
  * 本轮匹配到的惠企政策置顶,其余保持库内原顺序。
  */
 const buildLibraryPolicyList = () => {
@@ -1047,7 +1056,7 @@ let policyListRequestId = 0;
 const fetchPolicyList = async () => {
   if (!props.recordMode || !panelVisible.value || panelMode.value !== "list") return;
 
-  // 政策事项列表统一来自惠企政策库(policies_public.v1.json)
+  // 政策事项列表统一来自惠企政策库(policies_public.v2.json)
   if (getLibraryPolicies().length) {
     buildLibraryPolicyList();
     return;
@@ -1262,11 +1271,13 @@ const openDetailByItem = async (item: { originalData?: RawPolicyItem; support_ob
  * `openCardList()` 的「政策列表」侧边栏(本会话卡片聚合、无筛选)。
  * 本条路径现在只由惠企政策详情的「返回政策事项列表」使用。
  *
- * 列表数据统一来自惠企政策库(policies_public.v1.json),本轮匹配到的惠企政策置顶。
+ * 列表数据统一来自惠企政策库(policies_public.v2.json),本轮匹配到的惠企政策置顶。
  */
 const openPolicyList = async () => {
   selectedPolicyOverride.value = null;
   panelMode.value = "list";
+  // 面板打开即刷新部门清单(库是异步加载的,见 deptOptions 的说明)
+  refreshDeptOptions();
 
   // 每次进入都重置筛选:部门 / 分类 / 关键词 / 页码
   // 否则上次的筛选会残留,表现为"条目少"或"筛不出东西"
@@ -1296,6 +1307,7 @@ const openPolicyList = async () => {
 const openCardList = async () => {
   selectedPolicyOverride.value = null;
   panelMode.value = "cardList";
+  refreshDeptOptions();
 
   currentPage.value = 1;
   searchKeyword.value = "";