Ver Fonte

fix(question-card): 问卷作答后变只读 + 选项选中样式只跟一个(用户反馈两个问题)

用户反馈(附截图):
①「如果下方已经新对话了,上方的问卷应该是只读状态」
②「在问卷中选择具体项时,边框有点问题……左边的边框有可能一直是选中的样式,
   我点击旁边的选项依旧不变,一次应该只有一个选项是选中样式」

①的根因:QuestionCard.vue 本来就有只读渲染分支(isReadonly = 内容里解析出 answers),
但此前没有任何代码把答案写回 content → 只读分支永远走不到,提交过的问卷仍可点可填。
修法:新增纯函数模块 src/utils/question-cards-answers.ts,在 submitQuestionAnswers 里把答案
写回最后一条带 question-cards 标记的 AI 消息(只替换最后一个问卷块的内部,其余内容一字不动)。
写回后卡片立刻变只读(内容变 → BusinessRecord 的 content watcher 整体重解析),saveHistory 后刷新也保留。

②的根因:isFreeformSelected() 写的是「常驻输入框 || 被点开」,而 freeformInputOnTop 的常驻输入框
恒为 true → 那一行永远带选中边框,点旁边的选项也不变。
修法:按“答案出自谁”判定 —— 常驻输入框填了内容、且(单选时)没选中别的选项才算选中;
多选保持“选项与自由输入可同时成立”;普通「其他」输入框行为不变。

验证:新增 harness/tools/verify-question-cards.mjs 18/18(写回形状与只读渲染一致、边界原样返回、
多轮只替换最后一个、幂等、字段归一化、4 条源码级接线断言);变异测试:把“替换最后一个”改成
“替换第一个” → FAIL;拿掉 hook 里的写回 → 源码断言 FAIL;还原后 18/18。
回归:内联脚本 6/6、NLS 44/44、附件 57/57、toast 27/27、政策库 29/29、build 通过、vue-tsc 无新错、
validate-harness 通过。

⚠️ 仍需用户在浏览器确认:提交后卡片变只读、点别的选项边框只跟一个走。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao há 1 mês atrás
pai
commit
aeb6037ebb

+ 9 - 0
README.md

@@ -456,6 +456,15 @@
 > 今天两条主线:**语音识别换阿里云 NLS 收尾**(token 改由后端网关签发)+ **一次大清理**
 > (死代码、多余环境、走不到的引导链)。清理过程中出过一次回归,已修并补上守护脚本。
 
+- **修问卷两个问题**(用户 2026-09-24 反馈,附截图):
+  - ①「下方已经新对话了,上方的问卷应该是只读状态」:`QuestionCard` 本来就有只读分支,
+    但判定是「内容里带 answers」,而**此前没有任何代码把答案写回内容** → 永远走不到。
+    新增 `src/utils/question-cards-answers.ts`(纯函数),提交时把答案写回最后一条带
+    `<question-cards>` 的 AI 消息 → 卡片立刻变只读,刷新后也保留
+  - ②「一次应该只有一个选项是选中样式」:`isFreeformSelected()` 对**常驻输入框恒返回 true**
+    → 那一行永远带选中边框。改成「填了内容、且单选时没选别的选项」才算选中
+  - 验证:新增 `verify-question-cards.mjs` **18/18**(含边界、幂等、多轮只替换最后一个、
+    源码级接线断言),**变异测试**两处都能红;其余回归全绿
 - **NLS 的 token 改由后端网关签发**(用户指定 `http://121.43.55.7:10011/proxy/verify/getALYToken`):
   - 先验证再动前端:网关返回 `{code:200, content:"<token>"}`,**拿它 + 新 appkey 真调通一次识别**才改代码
   - 前端改为调该端点(同源 `/aly-api/…`;端点只有 HTTP、页面是 HTTPS,必须走同源路径,

+ 37 - 0
harness/progress.md

@@ -4280,6 +4280,43 @@ payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照
   切换说明)、`current-api-call-sites.md`(§0 A 表第 6 行、§三 的旧路径标注为已删)、
   `asr-realtime.md`(历史文档里指向已删文件的**链接**改成纯文本,避免 404 链接)
 
+### 续 43:修问卷两个问题(只读态 / 选项选中样式)
+
+**用户反馈**(附截图):
+①「如果下方已经新对话了,上方的问卷应该是只读状态」
+②「在问卷中选择具体项时,边框有点问题……左边的边框有可能一直是选中的样式,
+   我点击旁边的选项依旧不变,一次应该只有一个选项是选中样式」
+
+**① 的根因**:`QuestionCard.vue` **本来就有只读渲染分支**(`isReadonly = 解析 content 得到 answers`
+→ 渲染成"已选项/已填文本"),但**此前没有任何代码把答案写回 content** ——
+所以那个分支永远走不到,提交过的问卷在下一轮出现后仍可点可填。
+**修法**:新增纯函数模块 [`src/utils/question-cards-answers.ts`](../src/utils/question-cards-answers.ts),
+在 `submitQuestionAnswers` 里把答案写回**最后一条带 `<question-cards>` 的 AI 消息**
+(`applyQuestionCardsAnswers` 只替换**最后一个**问卷块的内部,其余内容一字不动)。
+写回后:卡片立刻变只读(内容变了 → BusinessRecord 的 content watcher 会整体重解析),
+`saveHistory()` 之后刷新也仍是已作答态。
+
+**② 的根因**:`isFreeformSelected()` 写的是 `常驻输入框 || 被点开` ——
+**常驻输入框(`freeformInputOnTop`,就是截图里排在最上的 A 输入框)恒为 true**,
+于是那一行永远带着选中边框,点旁边的选项也不会变。
+**修法**:改成按"答案出自谁"判定 —— 常驻输入框**填了内容**、且(单选时)**没有选中别的选项**才算选中;
+多选保持"选项与自由输入可同时成立";普通「其他」输入框行为不变。
+
+**运行过的验证**:新增 `harness/tools/verify-question-cards.mjs` —— **18/18**:
+- 【1】写回后是合法 JSON、含 `answers`、形状与只读渲染一致(header/selected/freeText)、
+  问卷外的内容没被动、作答前后 `hasAnsweredQuestionCards` 判定正确
+- 【2】边界:没有标记 / 标记不成对 → **原样返回**;多轮时**只替换最后一个**问卷块;幂等
+- 【3】字段归一化:selected 去空白过滤空项、freeText 保留、skipped 布尔化
+- 【4】源码级接线断言:hook 里确实调了写回、且取的是"最后一条带 `<question-cards>` 的 AI 消息";
+  QuestionCard 的只读判定仍在;**常驻输入框不再恒判为选中**(防回退)
+- **变异测试**:①把"替换最后一个"改成"替换第一个" → 立刻 FAIL;
+  ②拿掉 hook 里的写回 → 源码断言 FAIL;还原后 18/18
+
+回归:`verify-inline-scripts` 6/6、`verify-nls-realtime` 44/44、附件 57/57、toast 27/27、
+`verify-policy-library` 29/29、`npm run build` 通过、`vue-tsc` 无新错、`validate-harness` 通过。
+
+**⚠️ 仍需用户在浏览器确认**:提交问卷后卡片应变成只读(显示所选/所填),且**点别的选项时边框只跟一个走**。
+
 ### 续 42:用户更换本地政策数据(v1 → v2,字段名也换了)→ 接线 + 适配
 
 **用户说明**:「我更换了本地数据,在 merchant-agent 文件夹下」——

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

@@ -0,0 +1,6 @@
+/** esbuild 入口:问卷作答写回(纯函数)—— 供 verify-question-cards.mjs 直接引用真实现 */
+export {
+  buildAnsweredQuestionCardsContent,
+  applyQuestionCardsAnswers,
+  hasAnsweredQuestionCards,
+} from '../../src/utils/question-cards-answers';

+ 124 - 0
harness/tools/verify-question-cards.mjs

@@ -0,0 +1,124 @@
+/**
+ * 验证「问卷作答后变只读」这条链路 + 选项选中样式那条修(用户 2026-09-24 反馈的两个问题)。
+ *
+ * 用户原话:
+ *   ①「如果下方已经新对话了,上方的问卷应该是只读状态」
+ *   ②「在问卷中选择具体项时,边框有点问题……一次应该只有一个选项是选中样式」
+ *
+ * ①的根因:`QuestionCard.vue` 有只读渲染分支,但判定是「内容里带 answers」——
+ *   而此前**没有任何代码把答案写回内容**,所以永远是可交互态。
+ *   修法:提交时用 `applyQuestionCardsAnswers()` 把答案写回那一行(新增纯函数模块)。
+ * ②的根因:`isFreeformSelected()` 对**常驻输入框恒返回 true** → 那一行永远带选中边框。
+ *   修法:改成"填了内容、且单选时没选别的选项"才算选中。
+ *
+ * 怎么跑(先打包):
+ *   npx esbuild harness/tools/_entry-question-cards.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_question-cards.mjs --alias:@=./src --define:import.meta.env='{}'
+ *   node harness/tools/verify-question-cards.mjs
+ */
+
+import { readFileSync } from 'node:fs';
+
+const { buildAnsweredQuestionCardsContent, applyQuestionCardsAnswers, hasAnsweredQuestionCards } =
+  await import('./_question-cards.mjs');
+
+let pass = 0;
+let fail = 0;
+const check = (name, ok, extra = '') => {
+  if (ok) {
+    pass++;
+    console.log('  ok   ' + name);
+  } else {
+    fail++;
+    console.log('  FAIL ' + name + '  ' + extra);
+  }
+};
+
+const QUESTIONS_JSON = JSON.stringify({
+  questions: [
+    {
+      header: '本次是否需要结合具体公司情况?如需要,请补充公司名称或信用代码。',
+      freeformInputOnTop: true,
+      freeformPlaceholder: '请输入工商注册全名或统一社会信用代码',
+      options: [{ label: '不需要公司信息' }],
+    },
+  ],
+});
+const CONTENT = `<scope title="思考中">\n</scope>\n<question-cards>${QUESTIONS_JSON}</question-cards>`;
+
+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 {
+    /* 下面断言会报出来 */
+  }
+
+  check('写回后仍是合法 JSON(只读分支靠 JSON.parse 读)', !!parsed, JSON.stringify(rowContent)?.slice(0, 120));
+  check('含 answers 数组(只读分支的判定条件)', Array.isArray(parsed?.answers) && parsed.answers.length === 1);
+  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]));
+  check('问卷外的前后内容没被动(scope 行仍在前、标签成对)',
+    written.startsWith('<scope title="思考中">\n</scope>\n<question-cards>') && written.endsWith('</question-cards>'));
+  check('hasAnsweredQuestionCards 能识别', hasAnsweredQuestionCards(written) === true);
+  check('作答前不算已作答(只读分支不会误触发)', hasAnsweredQuestionCards(CONTENT) === false);
+}
+
+console.log('\n【2】边界:不能破坏别人的内容');
+{
+  check('没有问卷标记 → 原样返回', applyQuestionCardsAnswers('<scope>正文</scope>', []) === '<scope>正文</scope>');
+  check('标记不成对 → 原样返回',
+    applyQuestionCardsAnswers('<question-cards>{"questions":[]}', []) === '<question-cards>{"questions":[]}');
+
+  const two = `<question-cards>{"questions":[{"header":"第一轮"}]}</question-cards>\n正文\n<question-cards>{"questions":[{"header":"第二轮"}]}</question-cards>`;
+  const applied = applyQuestionCardsAnswers(two, [{ header: '第二轮', selected: ['A'] }]);
+  check('**只替换最后一个**问卷块(多轮时不会改错行)',
+    applied.indexOf('{"questions":[{"header":"第一轮"}]}') > 0 &&
+      applied.indexOf('{"answers"') > applied.indexOf('第二轮') - 100,
+    applied.slice(0, 160));
+  check('只替换一次(幂等)', applyQuestionCardsAnswers(applied, [{ header: '第二轮', selected: ['A'] }]) === applied);
+}
+
+console.log('\n【3】答案字段归一化(不让无关状态进内容)');
+{
+  const built = buildAnsweredQuestionCardsContent([
+    { header: ' 问题 ', selected: [' A ', '', 'B'], freeText: ' 手输内容 ', skipped: undefined },
+  ]);
+  const parsed = JSON.parse(built);
+  check('selected 去空白并过滤空项', JSON.stringify(parsed.answers[0].selected) === JSON.stringify(['A', 'B']),
+    JSON.stringify(parsed.answers[0].selected));
+  check('freeText 原样保留(不清空内部空白)', parsed.answers[0].freeText === ' 手输内容 ');
+  check('skipped 归一化成布尔', parsed.answers[0].skipped === false);
+}
+
+console.log('\n【4】接线(源码级:这两处都在 Vue 组件 / hook 里,node 跑不起来)');
+{
+  const hook = readFileSync('src/components/business-assistant/useBusinessAssistantChat.ts', 'utf8');
+  check('submitQuestionAnswers 里调用了写回',
+    /applyQuestionCardsAnswers\(questionCardsMessage\.content, answers\)/.test(hook));
+  check('写回的是「最后一条带 <question-cards> 的 AI 消息」',
+    /reverse\(\)[\s\S]{0,220}item\.content\.includes\('<question-cards>'\)/.test(hook));
+
+  const card = readFileSync('src/components/Chat/QuestionCard.vue', 'utf8');
+  check('只读分支的判定仍是「内容里有 answers」',
+    /const isReadonly = computed\(\(\) => !!contentAnswers\.value\)/.test(card));
+  check('**常驻输入框不再恒判为选中**(第二个 bug 的修)',
+    !/const isFreeformSelected = \(cardIdx: number\): boolean => \{\s*return isCardFreeformAlwaysOn\(cardIdx\) \|\|/.test(card) &&
+      /if \(!isCardFreeformAlwaysOn\(cardIdx\)\) \{/.test(card));
+  check('单选时"选了别的选项就不算它选中"', /if \(card\?\.multiSelect\) return true;/.test(card));
+}
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 19 - 1
src/components/Chat/QuestionCard.vue

@@ -424,8 +424,26 @@ const isOptionSelected = (cardIdx: number, optIdx: number): boolean => {
 const isCardFreeformAlwaysOn = (cardIdx: number): boolean =>
   questions.value[cardIdx]?.freeformInputOnTop === true;
 
+/**
+ * 自由输入那一行是否算"选中"(同时决定它的边框样式、以及答案是否算作出自输入框)。
+ *
+ * ⚠️ 2026-09-24 修(用户报「一次应该只有一个选项是选中样式」):
+ * 以前这里是 `常驻输入框 || 被点开` —— **常驻输入框恒为 true**,
+ * 于是那一行永远带着选中边框,点旁边的选项也不会变。
+ * 现在按"答案出自谁"判定:
+ *   · 常驻输入框(freeformInputOnTop):**填了内容**、且(单选时)**没有选中别的选项** → 才算选中
+ *   · 普通「其他」输入框:被点开就算选中(原行为不变)
+ */
 const isFreeformSelected = (cardIdx: number): boolean => {
-  return isCardFreeformAlwaysOn(cardIdx) || !!isFreeformActive.value[cardIdx];
+  const card = questions.value[cardIdx];
+  if (!isCardFreeformAlwaysOn(cardIdx)) {
+    return !!isFreeformActive.value[cardIdx];
+  }
+  const hasText = (freeformTexts.value[cardIdx] || '').trim().length > 0;
+  if (!hasText) return false;
+  if (card?.multiSelect) return true; // 多选:选项与自由输入可以同时成立
+  const selected = selectedOptions.value[cardIdx];
+  return !selected || selected.size === 0;
 };
 
 /** 自由输入是否算作"已作答":需已启用且确实填了内容 */

+ 14 - 0
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -35,6 +35,7 @@ import { fetchRemoteSessionRecords, deleteSessionInfo, type RemoteSession } from
 import { upsertDmsRecord, upsertDmsSession } from '@/network/api/dms/chat-sessions-dms';
 import { syncCompanyInfoFromChat } from '@/network/api/dms/company-info-sync';
 import { hasVisibleMessageContent } from '@/utils/interrupted-message';
+import { applyQuestionCardsAnswers } from '@/utils/question-cards-answers';
 import {
   applyAnswerStreamAbort,
   applyAnswerStreamEnd,
@@ -1808,6 +1809,19 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       return;
     }
 
+    // 把答案**写回那一轮 AI 消息**里的问卷行 → 卡片立刻变只读(显示已选项/已填文本),
+    // 而不是一直停在可点可填的状态(用户 2026-09-24:「下方已经新对话了,上方的问卷应该是只读状态」)。
+    // 取最后一条带 <question-cards> 的 AI 消息:正要作答的就是它。
+    const questionCardsMessage = [...session.messages]
+      .reverse()
+      .find((item) => item.role === 'ai' && typeof item.content === 'string' && item.content.includes('<question-cards>'));
+    if (questionCardsMessage) {
+      const answered = applyQuestionCardsAnswers(questionCardsMessage.content, answers);
+      if (answered !== questionCardsMessage.content) {
+        questionCardsMessage.content = answered;
+      }
+    }
+
     session.messages.push(createUserMessage('已提交企业信息补充'));
     const pendingAiMessage = ensurePendingAiMessage(session.messages);
 

+ 78 - 0
src/utils/question-cards-answers.ts

@@ -0,0 +1,78 @@
+/**
+ * 问卷(`<question-cards>` 行)**作答后**的内容改写 —— 纯函数,便于 harness 直接测。
+ *
+ * 背景(2026-09-24 用户反馈):「如果下方已经新对话了,上方的问卷应该是只读状态」。
+ * `QuestionCard.vue` 本来就有只读渲染分支(显示已选项 / 已填文本),但它的判定是
+ * `isReadonly = 解析 content 得到 answers` —— 而**此前没有任何代码把答案写回 content**,
+ * 于是只读分支永远走不到:提交过的问卷在新一轮对话出现后仍然可点可填。
+ *
+ * 这里负责「把提交的答案写回那一行」:把内容里**最后一个**
+ * `<question-cards>…</question-cards>` 块的内部替换成 `{answers:[…]}`(只读分支认的形状)。
+ * 写回后:
+ *   · 卡片立刻渲染成只读(内容变了 → 渲染层整体重解析,见 BusinessRecord 的 content watcher)
+ *   · `saveHistory()` 落盘后,刷新页面仍是已作答态
+ */
+
+/** 问卷答案(与 `QuestionCard.vue` 提交时给出的形状一致) */
+export interface QuestionCardAnswer {
+  header?: string;
+  /** 已选选项的**文字**(不是序号) */
+  selected?: string[];
+  /** 自由输入框里的内容 */
+  freeText?: string;
+  skipped?: boolean;
+}
+
+const OPEN_TAG = "<question-cards>";
+const CLOSE_TAG = "</question-cards>";
+
+/** 归一化一条答案(只保留只读渲染需要的字段,避免把别的状态写进内容) */
+const normalizeAnswer = (answer: QuestionCardAnswer | null | undefined) => ({
+  header: String(answer?.header ?? ""),
+  selected: Array.isArray(answer?.selected)
+    ? answer.selected.map((item) => String(item ?? "").trim()).filter(Boolean)
+    : [],
+  freeText: String(answer?.freeText ?? ""),
+  skipped: Boolean(answer?.skipped),
+});
+
+/** 生成只读渲染所需的内容(`QuestionCard` 的 readonly 分支按这个形状读) */
+export const buildAnsweredQuestionCardsContent = (answers: QuestionCardAnswer[]): string =>
+  JSON.stringify({ answers: (Array.isArray(answers) ? answers : []).map(normalizeAnswer) });
+
+/**
+ * 把内容里**最后一个** `<question-cards>…</question-cards>` 替换成已作答的内容。
+ *
+ * 找不到标记(或标记不成对)时**原样返回** —— 宁可不动,也不要破坏内容里的其它行。
+ */
+export const applyQuestionCardsAnswers = (
+  content: string,
+  answers: QuestionCardAnswer[]
+): string => {
+  const text = String(content ?? "");
+  const closeIdx = text.lastIndexOf(CLOSE_TAG);
+  if (closeIdx < 0) return text;
+  const openIdx = text.lastIndexOf(OPEN_TAG, closeIdx);
+  if (openIdx < 0) return text;
+
+  return (
+    text.slice(0, openIdx + OPEN_TAG.length) +
+    buildAnsweredQuestionCardsContent(answers) +
+    text.slice(closeIdx)
+  );
+};
+
+/** 内容里是否已经有作答结果(用于断言 / 幂等判断) */
+export const hasAnsweredQuestionCards = (content: string): boolean => {
+  const text = String(content ?? "");
+  const closeIdx = text.lastIndexOf(CLOSE_TAG);
+  const openIdx = closeIdx >= 0 ? text.lastIndexOf(OPEN_TAG, closeIdx) : -1;
+  if (openIdx < 0) return false;
+  const inner = text.slice(openIdx + OPEN_TAG.length, closeIdx);
+  try {
+    const parsed = JSON.parse(inner);
+    return Array.isArray(parsed?.answers) && parsed.answers.length > 0;
+  } catch {
+    return false;
+  }
+};