Pārlūkot izejas kodu

fix(question-card): 修「提交完问卷直接看不见了」(写回后两个解析都没解包标记)

用户反馈:「提交完问卷直接看不见了」——作答后整张卡片消失。这是上一提交的写回改出来的。

根因:写回后卡片内容是 {answers:[…]},但渲染层交给组件的 content 是**带标记的整段**
(切行器 StreamXMLFilter.handleBehavior 对 block 行产出 openTag + inner + closeTag),
而 QuestionCard 里两个解析函数一个剥标记、一个不剥:
  · parseQuestions 剥标记 ✓
  · parseAnswersFromContent 直接 JSON.parse ✗
→ 作答后只读分支解析失败、交互分支也没有 questions → 模板两个分支都不成立 → 卡片渲染成空。

为什么上一版的测试没抓到(教训):[1] 节里我**手工把 <question-cards> 切掉**再断言 JSON 合法
——把"渲染层给的是带标记内容"这个事实自己抹平了。组件解析类的验证必须走**真实切行器**的输出。

修法:
1. 新增 src/utils/question-cards-content.ts:unwrapQuestionCardsContent(统一解包)+
   parseQuestionsFromContent / parseAnswersFromContent,两个解析走同一个解包逻辑;
   QuestionCard.vue 改为引用它(本地两份实现删掉);答案形状 QuestionCardAnswer 也收到该模块
2. verify-question-cards 加【5】完整往返:跑真实切行器 → 取问卷行 content(带标记)→
   两个解析分别断言,并加直接守卫「至少有一个分支能渲染(否则整张卡片是空的)」

验证:verify-question-cards 22 → 29/29;变异测试(解包改成直接返回原文 = 复现原 bug)→
6 项失败(含那条"至少有一个分支能渲染"),还原后 29/29。
回归:内联脚本 6/6、NLS 44/44、附件 57/57、toast 27/27、政策库 29/29、DMS 字段契约 19/19、
build 通过、vue-tsc 无新错、validate-harness 通过。

⚠️ 仍需用户在浏览器确认:提交后卡片变只读(显示所选/所填),而不是消失。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 mēnesi atpakaļ
vecāks
revīzija
1974cb99b1

+ 29 - 0
harness/progress.md

@@ -4280,6 +4280,35 @@ payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照
   切换说明)、`current-api-call-sites.md`(§0 A 表第 6 行、§三 的旧路径标注为已删)、
   `asr-realtime.md`(历史文档里指向已删文件的**链接**改成纯文本,避免 404 链接)
 
+### 续 44:修「提交完问卷直接看不见了」(我续 43 的写回改出来的,并补上真实往返测试)
+
+**用户反馈**:「提交完问卷直接看不见了」——即作答后整张卡片消失。
+
+**根因(我上一版引入)**:写回后卡片内容变成 ,但**渲染层交给组件的 content 是带标记的**
+—— 切行器  对 block 行产出的是 :
+
+而  里两个解析函数**一个剥标记、一个不剥**: 剥、 不剥
+→ 作答后只读分支解析失败、交互分支也解析不出  → 模板两个分支都不成立 → **卡片渲染成空**。
+
+**我的测试为什么没抓到(关键教训)**:续 43 的 [1] 节里,我**手工把标记切掉**再断言 JSON 合法
+—— 把渲染层给的是带标记内容这个事实自己抹平了,于是测试绿着、页面上卡片是空的。
+**教训:组件解析类的验证必须走真实管线(真实切行器的输出),不能自己构造理想输入。**
+
+**修法**:
+1. 新增纯函数模块 [](../src/utils/question-cards-content.ts):
+   (统一解包)+  / ,
+   两个解析**走同一个解包逻辑**; 改为引用它(本地那两份实现删掉)
+2. 答案形状  也收到该模块(原来是 answers 模块自己定义一份)
+3. 验证脚本加【5】完整往返:**跑真实切行器** → 取问卷行 content(带标记)→ 两个解析函数分别断言,
+   并加一条直接守卫:「**至少有一个分支能渲染**(否则整张卡片是空的)」
+
+**运行过的验证**: 22 → **29/29**(新增 6 项往返 + 1 项兼容);
+**变异测试**:把解包改成直接返回原文(=复现原 bug)→ **6 项失败**,其中就有那条至少有一个分支能渲染;
+还原后 29/29。回归:内联脚本 6/6、NLS 44/44、附件 57/57、toast 27/27、政策库 29/29、
+DMS 字段契约 19/19、build 通过、vue-tsc 无新错、validate-harness 通过。
+
+⚠️ 仍需用户在浏览器确认:提交问卷后卡片应变成只读(显示所选/所填),**而不是消失**。
+
 ### 续 43:修问卷两个问题(只读态 / 选项选中样式)
 
 **用户反馈**(附截图):

+ 9 - 0
harness/tools/_entry-question-cards.ts

@@ -4,3 +4,12 @@ export {
   applyQuestionCardsAnswers,
   hasAnsweredQuestionCards,
 } from '../../src/utils/question-cards-answers';
+
+// 真实切行器(渲染层就是把它的输出交给组件的)—— 让验证能跑**完整往返**,
+// 而不是自己手工剥标记(2026-09-24 的教训:手工剥标记的测试放过了"卡片渲染成空")
+export { StreamXMLFilter, RecordRowTypes } from '../../src/utils/stream-xml-filter';
+export {
+  unwrapQuestionCardsContent,
+  parseQuestionsFromContent,
+  parseAnswersFromContent,
+} from '../../src/utils/question-cards-content';

+ 53 - 19
harness/tools/verify-question-cards.mjs

@@ -19,8 +19,15 @@
 
 import { readFileSync } from 'node:fs';
 
-const { buildAnsweredQuestionCardsContent, applyQuestionCardsAnswers, hasAnsweredQuestionCards } =
-  await import('./_question-cards.mjs');
+const {
+  buildAnsweredQuestionCardsContent,
+  applyQuestionCardsAnswers,
+  hasAnsweredQuestionCards,
+  StreamXMLFilter,
+  RecordRowTypes,
+  parseQuestionsFromContent,
+  parseAnswersFromContent,
+} = await import('./_question-cards.mjs');
 
 let pass = 0;
 let fail = 0;
@@ -51,29 +58,25 @@ console.log('\n【1】把答案写回问卷行(写回后才会走只读渲染
   const answers = [{ header: '本次是否需要结合公司情况?', selected: ['不需要公司信息'], freeText: '', skipped: false }];
   const written = applyQuestionCardsAnswers(CONTENT, answers);
 
-  // QuestionCard 的只读分支:parseAnswersFromContent(row.content) → data.answers
-  const rowContent = written.slice(
-    written.indexOf('<question-cards>') + '<question-cards>'.length,
-    written.lastIndexOf('</question-cards>')
-  );
-  let parsed = null;
-  try {
-    parsed = JSON.parse(rowContent);
-  } catch {
-    /* 下面断言会报出来 */
-  }
+  // ⚠️ 这里必须用**组件真正用的那个解析函数**(`parseAnswersFromContent`,它会解包标记)。
+  // 上一版测试是手工把 `<question-cards>` 去掉再 JSON.parse —— 于是"渲染层交给组件的 content
+  // 是带标记的"这个事实被测试掩盖了,放过了真实的「卡片渲染成空」bug(见【5】)。
+  const parsedAnswers = parseAnswersFromContent(written);
 
-  check('写回后仍是合法 JSON(只读分支靠 JSON.parse 读)', !!parsed, JSON.stringify(rowContent)?.slice(0, 120));
-  check('含 answers 数组(只读分支的判定条件)', Array.isArray(parsed?.answers) && parsed.answers.length === 1);
+  check('写回后能被组件的只读分支解析出 answers', Array.isArray(parsedAnswers) && parsedAnswers.length === 1,
+    JSON.stringify(written)?.slice(0, 140));
   check('答案形状与只读渲染一致(header / selected / freeText)',
-    parsed?.answers?.[0]?.header === '本次是否需要结合公司情况?' &&
-      JSON.stringify(parsed?.answers?.[0]?.selected) === JSON.stringify(['不需要公司信息']) &&
-      parsed?.answers?.[0]?.freeText === '',
-    JSON.stringify(parsed?.answers?.[0]));
+    parsedAnswers?.[0]?.header === '本次是否需要结合公司情况?' &&
+      JSON.stringify(parsedAnswers?.[0]?.selected) === JSON.stringify(['不需要公司信息']) &&
+      parsedAnswers?.[0]?.freeText === '',
+    JSON.stringify(parsedAnswers?.[0]));
+  check('写回后不代表"还有题目要作答"(交互分支不会再渲染)',
+    parseQuestionsFromContent(written).length === 0);
   check('问卷外的前后内容没被动(scope 行仍在前、标签成对)',
     written.startsWith('<scope title="思考中">\n</scope>\n<question-cards>') && written.endsWith('</question-cards>'));
   check('hasAnsweredQuestionCards 能识别', hasAnsweredQuestionCards(written) === true);
   check('作答前不算已作答(只读分支不会误触发)', hasAnsweredQuestionCards(CONTENT) === false);
+  check('作答前能解析出题目(交互分支照常渲染)', parseQuestionsFromContent(CONTENT).length === 1);
 }
 
 console.log('\n【2】边界:不能破坏别人的内容');
@@ -133,5 +136,36 @@ console.log('\n【4】接线(源码级:这两处都在 Vue 组件 / hook 里
     /const chosenByInteraction = !!isFreeformActive\.value\[cardIdx\]/.test(card));
 }
 
+console.log('\n【5】完整往返:真实切行器 → 组件拿到的 content → 解析(这条会抓「卡片渲染成空」)');
+{
+  const answers = [{ header: '(单选)本次是否需要结合公司情况?', selected: ['不需要公司信息'], freeText: '', skipped: false }];
+
+  /** 跑真实切行器,取问卷行的 content —— 渲染层就是这么把内容交给 QuestionCard 的 */
+  const questionCardsRowContent = (content) => {
+    const rows = new StreamXMLFilter({}).processChunk(content);
+    const rowsOfType = rows.filter((row) => row.type === RecordRowTypes.QuestionCards);
+    return rowsOfType.length ? rowsOfType[rowsOfType.length - 1].content : '';
+  };
+
+  const rowBefore = questionCardsRowContent(CONTENT);
+  check('切行器产出的问卷行 content **带 `<question-cards>` 标记**(所以解析必须先解包)',
+    rowBefore.startsWith('<question-cards>') && rowBefore.endsWith('</question-cards>'), rowBefore.slice(0, 60));
+  check('未作答:交互分支解析得到题目', parseQuestionsFromContent(rowBefore).length === 1);
+
+  const rowAfter = questionCardsRowContent(applyQuestionCardsAnswers(CONTENT, answers));
+  const parsedAnswers = parseAnswersFromContent(rowAfter);
+  const parsedQuestions = parseQuestionsFromContent(rowAfter);
+  check('作答后:只读分支解析得到答案', Array.isArray(parsedAnswers) && parsedAnswers.length === 1,
+    JSON.stringify(rowAfter)?.slice(0, 140));
+  check('作答后:交互分支不再有题目', parsedQuestions.length === 0);
+  // ★ 这条是"卡片渲染成空"的直接守卫:两个分支至少有一个能渲染出东西
+  //   (组件模板:`v-if="isReadonly && contentAnswers.length > 0"` / `v-else-if="questions.length > 0"`)
+  check('**至少有一个分支能渲染**(否则整张卡片是空的 —— 用户报的「提交完直接看不见了」)',
+    (parsedAnswers?.length || 0) > 0 || parsedQuestions.length > 0,
+    `answers=${parsedAnswers?.length ?? 'null'} questions=${parsedQuestions.length}`);
+  check('直接传「标记内 JSON」也要能解析(兼容不带标记的调用方)',
+    parseAnswersFromContent(JSON.stringify({ answers }))?.length === 1);
+}
+
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 9 - 48
src/components/Chat/QuestionCard.vue

@@ -155,44 +155,14 @@
 <script setup lang="ts">
 import { computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
 import { FREEFORM_INPUT_FOCUS_KEY } from "../business-assistant/freeform-focus-control";
-
-interface QuestionOption {
-  label: string;
-  description?: string;
-  recommended?: boolean;
-}
-
-interface Question {
-  header: string;
-  question: string;
-  multiSelect?: boolean;
-  allowFreeformInput?: boolean;
-  message?: string;
-  /** 输入框常驻并排在最上(用于补充信息),默认 false 保持原有"其他"行为 */
-  freeformInputOnTop?: boolean;
-  /** 上述常驻输入框的 placeholder */
-  freeformPlaceholder?: string;
-  options?: QuestionOption[];
-}
-
-function parseQuestions(content: string): Question[] {
-  try {
-    const data = JSON.parse(content);
-    return data.questions || [];
-  } catch {
-    // Try to unwrap <question-cards>...</question-cards>
-    const match = String(content || "").match(/<question-cards\b[^>]*>([\s\S]*?)<\/question-cards>/i);
-    if (match) {
-      try {
-        const data = JSON.parse(match[1].trim());
-        return data.questions || [];
-      } catch {
-        return [];
-      }
-    }
-    return [];
-  }
-}
+// 解析(含 <question-cards> 标记的解包)抽成纯函数:见 utils/question-cards-content.ts。
+// ⚠️ 两个解析必须走同一个解包逻辑 —— 之前一个剥标记、一个不剥,导致"作答后写回"的内容
+// 两边都解析不出来,整张卡片渲染成空(用户报「提交完问卷直接看不见了」)
+import {
+  parseAnswersFromContent,
+  parseQuestionsFromContent,
+  type Question,
+} from "@/utils/question-cards-content";
 
 const props = defineProps<{
   content: string;
@@ -200,7 +170,7 @@ const props = defineProps<{
   sessionId?: string;
 }>();
 
-const questions = computed(() => parseQuestions(props.content));
+const questions = computed(() => parseQuestionsFromContent(props.content));
 
 // Selected option indices per card (for single select, store one value; for multi, store set)
 const selectedOptions = ref<Record<number, Set<number>>>({});
@@ -217,15 +187,6 @@ const openDropdowns = ref<Record<number, boolean>>({});
 const freeformFocusOwner = Symbol("question-card-freeform");
 const freeformInputFocusControl = inject(FREEFORM_INPUT_FOCUS_KEY, null);
 
-function parseAnswersFromContent(content: string) {
-  try {
-    const data = JSON.parse(content);
-    return Array.isArray(data.answers) ? data.answers : null;
-  } catch {
-    return null;
-  }
-}
-
 const contentAnswers = computed(() => parseAnswersFromContent(props.content));
 const isReadonly = computed(() => !!contentAnswers.value);
 

+ 3 - 9
src/utils/question-cards-answers.ts

@@ -13,15 +13,9 @@
  *   · `saveHistory()` 落盘后,刷新页面仍是已作答态
  */
 
-/** 问卷答案(与 `QuestionCard.vue` 提交时给出的形状一致) */
-export interface QuestionCardAnswer {
-  header?: string;
-  /** 已选选项的**文字**(不是序号) */
-  selected?: string[];
-  /** 自由输入框里的内容 */
-  freeText?: string;
-  skipped?: boolean;
-}
+import type { QuestionCardAnswer } from "./question-cards-content";
+
+export type { QuestionCardAnswer };
 
 const OPEN_TAG = "<question-cards>";
 const CLOSE_TAG = "</question-cards>";

+ 74 - 0
src/utils/question-cards-content.ts

@@ -0,0 +1,74 @@
+/**
+ * 问卷内容(`<question-cards>…</question-cards>` 行)的**解析**——纯函数,可被 harness 直接测。
+ *
+ * 为什么单独抽出来(2026-09-24):渲染层交给 `QuestionCard` 的 `content` 是
+ * **带标记的整段**(切行器 `StreamXMLFilter` 给 block 行产出的就是 `openTag + inner + closeTag`,
+ * 见 `handleBehavior`)。而组件的两个解析函数一个剥标记、一个不剥 ——
+ * 于是"作答后写回 `{answers:…}`"时只读分支解析失败、交互分支也没有 `questions`,
+ * **整张卡片渲染成空**(用户报「提交完问卷直接看不见了」)。
+ * 现在两处都走这里的同一个解包逻辑,并且 harness 会用**真实切行器**跑一遍往返。
+ */
+
+export interface QuestionOption {
+  label: string;
+  description?: string;
+  recommended?: boolean;
+  [key: string]: unknown;
+}
+
+export interface Question {
+  header: string;
+  question: string;
+  multiSelect?: boolean;
+  allowFreeformInput?: boolean;
+  message?: string;
+  /** 输入框常驻并排在最上(用于补充信息),默认 false 保持原有"其他"行为 */
+  freeformInputOnTop?: boolean;
+  /** 上述常驻输入框的 placeholder */
+  freeformPlaceholder?: string;
+  options?: QuestionOption[];
+}
+
+/** 问卷答案(`QuestionCard` 提交时给出的形状,也是只读渲染读的形状) */
+export interface QuestionCardAnswer {
+  header?: string;
+  selected?: string[];
+  freeText?: string;
+  skipped?: boolean;
+}
+
+const MARKER_RE = /<question-cards\b[^>]*>([\s\S]*?)<\/question-cards>/i;
+
+/**
+ * 取出标记内部的 JSON 文本。
+ *
+ * - 内容带 `<question-cards>` 标记 → 取标记内部(两处解析都靠它)
+ * - 不带标记 → 原样返回(兼容直接传 JSON 的调用方)
+ */
+export const unwrapQuestionCardsContent = (content: string): string => {
+  const text = String(content ?? "").trim();
+  const match = text.match(MARKER_RE);
+  return (match ? match[1] : text).trim();
+};
+
+const safeParse = (text: string): any => {
+  try {
+    return JSON.parse(text);
+  } catch {
+    return null;
+  }
+};
+
+/** 解析问卷题目(交互态渲染用);解析不出来返回空数组 */
+export const parseQuestionsFromContent = (content: string): Question[] => {
+  const parsed = safeParse(unwrapQuestionCardsContent(content));
+  const questions = parsed?.questions;
+  return Array.isArray(questions) ? (questions as Question[]) : [];
+};
+
+/** 解析已作答结果(只读态渲染用);没有答案返回 null */
+export const parseAnswersFromContent = (content: string): QuestionCardAnswer[] | null => {
+  const parsed = safeParse(unwrapQuestionCardsContent(content));
+  const answers = parsed?.answers;
+  return Array.isArray(answers) ? (answers as QuestionCardAnswer[]) : null;
+};