Ver código fonte

fix(question-card): 只读态改成"同一个问卷模板"(用户:选完还是长什么样,样式不能变)

用户反馈(截图:作答后卡片缩水成只有标题 + 一个选项的小框):
「现在选择完变成这样了,这不对,问卷长什么样选完后还是长什么样,样式不能变」。

根因:只读态走的是**另一套模板**(那个 v-if="isReadonly && contentAnswers.length > 0" 分支)——
标记比交互态简陋得多:没有 (单选) 标签、没有完整选项列表、没有分隔线与提交按钮;
再加上续 44 的写回把整块替换成只有 answers 的 JSON,连渲染问卷本体的 questions 都没了。

修法:
1. question-cards-answers.ts:applyQuestionCardsAnswers 改成把 answers **合并进原有 JSON**
   (保留 questions),不再整块替换
2. QuestionCard.vue:删掉单独的只读模板分支,只读开关挂在同一模板上
   (:class="{ submitted: submitted || isReadonly, readonly: isReadonly }");
   新增 hydrateFromAnswers(按选项文字反查下标、填 freeformTexts)把 answers 还原成界面选中态;
   7 处交互守卫加 || isReadonly、canSubmit 加 isReadonly(提交按钮保持可见但禁用)、
   两处输入框加 :readonly="isReadonly"

验证:verify-question-cards 29 → 36/36(新增 7 条源码级断言钉住"只读复用同一模板",
往返断言改为「questions 与 answers 都在」);变异测试:写回改回"只有 answers" → 3 项失败
(含"只读态能渲染出问卷本体"),还原后 36/36。回归:内联脚本 6/6、NLS 44/44、附件 57/57、
toast 27/27、政策库 29/29、build 通过、vue-tsc 仅既有 webpack-env 报错。

⚠️ 仍需用户在浏览器确认:作答后应与作答前长得一模一样,只是改不动、选中项保持高亮。

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

+ 34 - 0
harness/progress.md

@@ -4280,6 +4280,40 @@ payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照
   切换说明)、`current-api-call-sites.md`(§0 A 表第 6 行、§三 的旧路径标注为已删)、
   `asr-realtime.md`(历史文档里指向已删文件的**链接**改成纯文本,避免 404 链接)
 
+### 续 45:只读态改成「同一个问卷模板」(用户:选完还是长什么样)
+
+**用户反馈**(附截图:作答后卡片缩水成一个只有「确认公司」+ 一个选项的小框):
+「现在选择完变成这样了,这不对,问卷长什么样选完后还是长什么样,样式不能变」。
+
+**根因**:只读态走的是**另一套模板**(`v-if="isReadonly && contentAnswers.length > 0"` 那个分支)
+—— 那套标记比交互态简陋得多:没有 `(单选)` 标签、没有完整选项列表、没有分隔线与提交按钮,
+只按 answers 渲染「已选项/已填文本」。再加上续 44 的写回把整块替换成只有 `answers` 的 JSON,
+连渲染问卷本体的 `questions` 都没了,于是"缩水"得更彻底。
+
+**修法**(两处):
+1. **写回保留原字段**([`question-cards-answers.ts`](../src/utils/question-cards-answers.ts)):
+   `applyQuestionCardsAnswers` 改成把 `answers` **合并进原有 JSON**(保留 `questions`),
+   不再整块替换
+2. **只读态复用同一个模板**(`QuestionCard.vue`):
+   - 删掉那套单独的只读模板分支,模板只剩一个 `question-card-shell`
+   - 只读开关挂在同一模板上:`:class="{ submitted: submitted || isReadonly, readonly: isReadonly }"`
+     (`submitted` 的样式本来就是"选项不再有 hover 反馈",正合只读)
+   - **把 answers 还原成界面选中态**(`hydrateFromAnswers`:按选项文字反查下标填 `selectedOptions`、
+     填 `freeformTexts`)→ 渲染出来就是"刚才那份问卷",选中项照样高亮
+   - 禁交互:7 处手写守卫加 `|| isReadonly`、`canSubmit` 加 `isReadonly`(提交按钮保持可见但禁用,
+     `.qc-submit-btn:disabled` 的淡紫样式与截图里那个按钮一致)、两处输入框加 `:readonly="isReadonly"`
+
+**运行过的验证**:`verify-question-cards` 29 → **36/36**:新增 7 条源码级断言钉住"只读复用同一模板"
+(没有单独的只读分支、只有一处 `question-card-shell`、只读开关在同一模板上、hydration 存在、
+守卫含 isReadonly、提交按钮禁用、输入框 readonly);并把往返断言改成
+「**questions 与 answers 都在** → 既是一样的样式、又是只读」。
+**变异测试**:把写回改回"只有 answers"(复现缩水)→ **3 项失败**(含"只读态能渲染出问卷本体");
+还原后 36/36。回归:内联脚本 6/6、NLS 44/44、附件 57/57、toast 27/27、政策库 29/29、
+build 通过、`vue-tsc` 仅既有 webpack-env 报错。
+
+**⚠️ 仍需用户在浏览器确认**:作答后卡片应与作答前**长得一模一样**(同样的 `(单选)`、选项、提交按钮),
+只是不能改、选中项保持高亮。
+
 ### 续 44:修「提交完问卷直接看不见了」(我续 43 的写回改出来的,并补上真实往返测试)
 
 **用户反馈**:「提交完问卷直接看不见了」——即作答后整张卡片消失。

+ 32 - 9
harness/tools/verify-question-cards.mjs

@@ -70,8 +70,11 @@ console.log('\n【1】把答案写回问卷行(写回后才会走只读渲染
       JSON.stringify(parsedAnswers?.[0]?.selected) === JSON.stringify(['不需要公司信息']) &&
       parsedAnswers?.[0]?.freeText === '',
     JSON.stringify(parsedAnswers?.[0]));
-  check('写回后不代表"还有题目要作答"(交互分支不会再渲染)',
-    parseQuestionsFromContent(written).length === 0);
+  // ⚠️ 写回**保留 questions**(只读态复用同一个问卷模板 → 「样式不能变」),
+  // 只读的开关是"有 answers",不是"没有 questions"
+  check('**写回后 questions 仍在**(只读态复用同一模板,样式不变)',
+    parseQuestionsFromContent(written).length === 1,
+    JSON.stringify(written).slice(0, 140));
   check('问卷外的前后内容没被动(scope 行仍在前、标签成对)',
     written.startsWith('<scope title="思考中">\n</scope>\n<question-cards>') && written.endsWith('</question-cards>'));
   check('hasAnsweredQuestionCards 能识别', hasAnsweredQuestionCards(written) === true);
@@ -87,10 +90,12 @@ console.log('\n【2】边界:不能破坏别人的内容');
 
   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));
+  const firstBlock = applied.slice(0, applied.indexOf('正文'));
+  const secondBlock = applied.slice(applied.indexOf('正文'));
+  check('**只改最后一个**问卷块(多轮时不会改错行)',
+    firstBlock.includes('{"questions":[{"header":"第一轮"}]}') && !firstBlock.includes('"answers"') &&
+      secondBlock.includes('"answers"'),
+    applied.slice(0, 200));
   check('只替换一次(幂等)', applyQuestionCardsAnswers(applied, [{ header: '第二轮', selected: ['A'] }]) === applied);
 }
 
@@ -134,6 +139,22 @@ console.log('\n【4】接线(源码级:这两处都在 Vue 组件 / hook 里
     /const handleAlwaysOnFreeformInteract[\s\S]{0,600}!card\.multiSelect &&[\s\S]{0,200}new Set\(\)/.test(card));
   check('选中判定里包含"被交互过"这一项(chosenByInteraction)',
     /const chosenByInteraction = !!isFreeformActive\.value\[cardIdx\]/.test(card));
+
+  // ③ 第三轮(2026-09-24):「问卷长什么样,选完后还是长什么样,样式不能变」
+  //    ⇒ 只读态**不许**再另起一套模板,必须复用同一个问卷模板 + 只读开关
+  check('**没有单独的只读模板分支**(旧的 v-if="isReadonly && contentAnswers.length > 0" 必须消失)',
+    !/v-if="isReadonly && contentAnswers\.length > 0"/.test(card));
+  check('模板只有一处 question-card-shell(一套样式)',
+    (card.match(/class="question-card-shell"/g) || []).length === 1);
+  check('只读开关挂在同一模板上(submitted / readonly)',
+    /:class="\{ submitted: submitted \|\| isReadonly, readonly: isReadonly \}"/.test(card));
+  check('只读态把答案还原成界面选中态(hydrateFromAnswers)',
+    /const hydrateFromAnswers = \(\) => \{/.test(card) && /if \(isReadonly\.value\) hydrateFromAnswers\(\)/.test(card));
+  check('只读态禁交互(守卫含 isReadonly)', /if \(submitted\.value \|\| isReadonly\.value\) return;/.test(card));
+  check('只读态提交按钮禁用(canSubmit 含 isReadonly)',
+    /if \(submitted\.value \|\| isReadonly\.value\) return false;/.test(card));
+  check('只读态输入框可读不可改(:readonly="isReadonly")',
+    (card.match(/:readonly="isReadonly"/g) || []).length >= 2);
 }
 
 console.log('\n【5】完整往返:真实切行器 → 组件拿到的 content → 解析(这条会抓「卡片渲染成空」)');
@@ -157,11 +178,13 @@ console.log('\n【5】完整往返:真实切行器 → 组件拿到的 content
   const parsedQuestions = parseQuestionsFromContent(rowAfter);
   check('作答后:只读分支解析得到答案', Array.isArray(parsedAnswers) && parsedAnswers.length === 1,
     JSON.stringify(rowAfter)?.slice(0, 140));
-  check('作答后:交互分支不再有题目', parsedQuestions.length === 0);
+  // ⚠️ 写回保留 questions(只读态复用同一模板),所以断言的是**两者都在**:
+  // questions 负责「长得和原来一样」,answers 负责「只读」
+  check('作答后:questions 仍在(同一模板渲染 → 样式不变)', parsedQuestions.length === 1);
   // ★ 这条是"卡片渲染成空"的直接守卫:两个分支至少有一个能渲染出东西
   //   (组件模板:`v-if="isReadonly && contentAnswers.length > 0"` / `v-else-if="questions.length > 0"`)
-  check('**至少有一个分支能渲染**(否则整张卡片是空的 —— 用户报的「提交完直接看不见了」)',
-    (parsedAnswers?.length || 0) > 0 || parsedQuestions.length > 0,
+  check('**只读态能渲染出问卷本体**(questions 在 → 与作答前同一套样式;answers 在 → 只读)',
+    (parsedAnswers?.length || 0) > 0 && parsedQuestions.length > 0,
     `answers=${parsedAnswers?.length ?? 'null'} questions=${parsedQuestions.length}`);
   check('直接传「标记内 JSON」也要能解析(兼容不带标记的调用方)',
     parseAnswersFromContent(JSON.stringify({ answers }))?.length === 1);

+ 63 - 40
src/components/Chat/QuestionCard.vue

@@ -1,35 +1,10 @@
 <template>
-  <div v-if="isReadonly && contentAnswers.length > 0" class="question-card-shell readonly">
-    <div v-for="(answer, idx) in contentAnswers" :key="idx" class="question-card">
-      <div class="qc-body">
-        <div class="qc-question">
-          <span v-if="contentAnswers.length > 1" class="qc-question-seq">{{ idx + 1 }}. </span>
-          {{ answer.header }}
-        </div>
-        <div class="qc-options">
-          <div v-if="answer.freeText" class="qc-option selected">
-            <div class="qc-option-content">
-              <span class="qc-option-text">{{ answer.freeText }}</span>
-            </div>
-          </div>
-          <template v-else-if="(answer.selected || []).length > 0">
-            <div v-for="(label, labelIdx) in (answer.selected || [])" :key="labelIdx" class="qc-option selected">
-              <span class="qc-option-label">{{ getOptionLabel(labelIdx) }}</span>
-              <div class="qc-option-content">
-                <span class="qc-option-text">{{ label }}</span>
-              </div>
-            </div>
-          </template>
-          <div v-else class="qc-option readonly-empty">
-            <div class="qc-option-content">
-              <span class="qc-option-text">未选择</span>
-            </div>
-          </div>
-        </div>
-      </div>
-    </div>
-  </div>
-  <div v-else-if="questions.length > 0" class="question-card-shell" :class="{ submitted }">
+  <!-- 只读态**不再另起一套模板**:同一个问卷模板 + 只读开关(禁用交互、还原选中样式)。
+      用户 2026-09-24:「问卷长什么样,选完后还是长什么样,样式不能变」——
+      之前只读态走的是另一套更简陋的标记(没有 (单选) 标签、没有选项列表、没有提交按钮),
+      作答后卡片会"缩水"成一个小框,看起来像坏了。 -->
+  <div v-if="questions.length > 0" class="question-card-shell"
+    :class="{ submitted: submitted || isReadonly, readonly: isReadonly }">
     <!-- Progress dots outside questions -->
     <div class="qc-dots-row">
       <div v-for="(card, cardIdx) in questions" :key="cardIdx" class="qc-dot"
@@ -104,7 +79,7 @@
                 <textarea :ref="(el) => setFreeformRef(cardIdx, el)"
                   class="qc-freeform-input inline"
                   :placeholder="card.freeformPlaceholder || '请输入补充信息...'"
-                  v-model="freeformTexts[cardIdx]" rows="1"
+                  v-model="freeformTexts[cardIdx]" rows="1" :readonly="isReadonly"
                   @focus="handleAlwaysOnFreeformInteract(cardIdx)"
                   @click="handleAlwaysOnFreeformInteract(cardIdx)"
                   @blur="handleFreeformBlur"></textarea>
@@ -130,6 +105,7 @@
                   class="qc-option-text qc-other-text" :class="{ 'has-text': freeformTexts[cardIdx] }">{{ freeformTexts[cardIdx] || "其他" }}</span>
                 <textarea v-if="isFreeformSelected(cardIdx)" :ref="(el) => setFreeformRef(cardIdx, el)"
                   class="qc-freeform-input inline" :placeholder="'请输入补充信息...'" v-model="freeformTexts[cardIdx]" rows="1"
+                  :readonly="isReadonly"
                   @focus="handleFreeformFocus" @blur="handleFreeformBlur" @click.stop></textarea>
               </div>
             </div>
@@ -190,8 +166,54 @@ const freeformInputFocusControl = inject(FREEFORM_INPUT_FOCUS_KEY, null);
 const contentAnswers = computed(() => parseAnswersFromContent(props.content));
 const isReadonly = computed(() => !!contentAnswers.value);
 
+/**
+ * 只读态:把写回内容里的 answers **还原成界面上的选中状态**,让它渲染成"就是刚才那份问卷"。
+ *
+ * 为什么需要:只读态现在复用同一个问卷模板(用户 2026-09-24:「问卷长什么样,选完后还是
+ * 长什么样,样式不能变」),而这个模板的选中样式来自 `selectedOptions` / `freeformTexts`。
+ * 写回的 answers 只有选项**文字**,所以这里按文字反查选项下标。
+ */
+const hydrateFromAnswers = () => {
+  const answers = contentAnswers.value;
+  if (!answers) return;
+  const nextSelected: Record<number, Set<number>> = {};
+  const nextTexts: Record<number, string> = {};
+  const nextActive: Record<number, boolean> = {};
+
+  questions.value.forEach((card, cardIdx) => {
+    const answer = answers[cardIdx];
+    if (!answer) return;
+    const labels = (Array.isArray(answer.selected) ? answer.selected : []).map((item) => String(item ?? ''));
+    if (labels.length) {
+      const indices = new Set<number>();
+      (card.options || []).forEach((opt, optIdx) => {
+        if (opt && labels.includes(String(opt.label ?? ''))) indices.add(optIdx);
+      });
+      if (indices.size) nextSelected[cardIdx] = indices;
+    }
+    const freeText = String(answer.freeText ?? '');
+    if (freeText.trim()) {
+      nextTexts[cardIdx] = freeText;
+      // 答案出自自由输入 → 那一行也要显示成选中
+      if (!nextSelected[cardIdx]) nextActive[cardIdx] = true;
+    }
+  });
+
+  selectedOptions.value = nextSelected;
+  freeformTexts.value = { ...freeformTexts.value, ...nextTexts };
+  isFreeformActive.value = nextActive;
+};
+
+watch(
+  [() => props.content, () => questions.value.length],
+  () => {
+    if (isReadonly.value) hydrateFromAnswers();
+  },
+  { immediate: true }
+);
+
 function handleFreeformFocus() {
-  if (submitted.value) return;
+  if (submitted.value || isReadonly.value) return;
   freeformInputFocusControl?.focus(freeformFocusOwner);
 }
 
@@ -278,7 +300,7 @@ function closeAllDropdowns() {
 }
 
 function toggleDropdown(cardIdx: number) {
-  if (submitted.value) return;
+  if (submitted.value || isReadonly.value) return;
   const isOpen = openDropdowns.value[cardIdx];
   closeAllDropdowns();
   if (!isOpen) {
@@ -323,7 +345,7 @@ const getSelectedDropdownText = (cardIdx: number): string => {
 };
 
 function selectDropdownOption(cardIdx: number, optIdx: number) {
-  if (submitted.value) return;
+  if (submitted.value || isReadonly.value) return;
   const card = questions.value[cardIdx];
   if (!card) return;
   isFreeformActive.value[cardIdx] = false;
@@ -359,7 +381,7 @@ const getMultiDropdownText = (cardIdx: number): string => {
 };
 
 function toggleMultiDropdownOption(cardIdx: number, optIdx: number) {
-  if (submitted.value) return;
+  if (submitted.value || isReadonly.value) return;
   const card = questions.value[cardIdx];
   if (!card) return;
   isFreeformActive.value[cardIdx] = false;
@@ -418,7 +440,7 @@ const isFreeformSelected = (cardIdx: number): boolean => {
  *   · **单选卡片顺手清掉已选选项** —— 这正是"一次只有一个选项是选中样式"
  */
 const handleAlwaysOnFreeformInteract = (cardIdx: number) => {
-  if (submitted.value) return;
+  if (submitted.value || isReadonly.value) return;
   const card = questions.value[cardIdx];
   if (!card) return;
   if (!isFreeformActive.value[cardIdx]) {
@@ -441,7 +463,7 @@ const isQuestionAnswered = (cardIdx: number): boolean => {
 };
 
 const toggleOption = (cardIdx: number, optIdx: number) => {
-  if (submitted.value) return;
+  if (submitted.value || isReadonly.value) return;
   const card = questions.value[cardIdx];
   if (!card) return;
 
@@ -472,7 +494,7 @@ const toggleOption = (cardIdx: number, optIdx: number) => {
 };
 
 const toggleFreeform = (cardIdx: number) => {
-  if (submitted.value) return;
+  if (submitted.value || isReadonly.value) return;
   const card = questions.value[cardIdx];
   if (!card) return;
 
@@ -492,7 +514,8 @@ const toggleFreeform = (cardIdx: number) => {
 };
 
 const canSubmit = computed(() => {
-  if (submitted.value) return false;
+  // 只读态(已作答、内容已写回)不允许再提交 —— 提交按钮保持可见但禁用(样式与问卷一致)
+  if (submitted.value || isReadonly.value) return false;
   return questions.value.some((_card, cardIdx) => {
     const selected = selectedOptions.value[cardIdx];
     const hasOption = selected && selected.size > 0;

+ 22 - 6
src/utils/question-cards-answers.ts

@@ -35,7 +35,12 @@ export const buildAnsweredQuestionCardsContent = (answers: QuestionCardAnswer[])
   JSON.stringify({ answers: (Array.isArray(answers) ? answers : []).map(normalizeAnswer) });
 
 /**
- * 把内容里**最后一个** `<question-cards>…</question-cards>` 替换成已作答的内容。
+ * 把内容里**最后一个** `<question-cards>…</question-cards>` 改成"已作答"的内容。
+ *
+ * ⚠️ **保留原有字段(尤其 `questions`),只补/换 `answers`**:
+ * 只读态要求"选完还是问卷本来的样子",而那份样式(`(单选)` 标签、选项 A/B、提交按钮…)
+ * 全靠 `questions` 渲染出来 —— 上一版把整块替换成只有 `answers` 的 JSON,
+ * 结果只读渲染只能退化成一个简陋的小框(用户 2026-09-24:「问卷长什么样选完后还是长什么样」)。
  *
  * 找不到标记(或标记不成对)时**原样返回** —— 宁可不动,也不要破坏内容里的其它行。
  */
@@ -49,11 +54,22 @@ export const applyQuestionCardsAnswers = (
   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)
-  );
+  const inner = text.slice(openIdx + OPEN_TAG.length, closeIdx);
+  let payload: Record<string, unknown> = {};
+  try {
+    const parsed = JSON.parse(inner);
+    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+      payload = parsed as Record<string, unknown>;
+    }
+  } catch {
+    // 原内容不是 JSON(理论上不会发生):只写 answers,至少让只读态能渲染出答案
+  }
+  const next = JSON.stringify({
+    ...payload,
+    answers: (Array.isArray(answers) ? answers : []).map(normalizeAnswer),
+  });
+
+  return text.slice(0, openIdx + OPEN_TAG.length) + next + text.slice(closeIdx);
 };
 
 /** 内容里是否已经有作答结果(用于断言 / 幂等判断) */