question-cards-answers.ts 4.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
  1. /**
  2. * 问卷(`<question-cards>` 行)**作答后**的内容改写 —— 纯函数,便于 harness 直接测。
  3. *
  4. * 背景(2026-09-24 用户反馈):「如果下方已经新对话了,上方的问卷应该是只读状态」。
  5. * `QuestionCard.vue` 本来就有只读渲染分支(显示已选项 / 已填文本),但它的判定是
  6. * `isReadonly = 解析 content 得到 answers` —— 而**此前没有任何代码把答案写回 content**,
  7. * 于是只读分支永远走不到:提交过的问卷在新一轮对话出现后仍然可点可填。
  8. *
  9. * 这里负责「把提交的答案写回那一行」:把内容里**最后一个**
  10. * `<question-cards>…</question-cards>` 块的内部替换成 `{answers:[…]}`(只读分支认的形状)。
  11. * 写回后:
  12. * · 卡片立刻渲染成只读(内容变了 → 渲染层整体重解析,见 BusinessRecord 的 content watcher)
  13. * · `saveHistory()` 落盘后,刷新页面仍是已作答态
  14. */
  15. import type { QuestionCardAnswer } from "./question-cards-content";
  16. export type { QuestionCardAnswer };
  17. const OPEN_TAG = "<question-cards>";
  18. const CLOSE_TAG = "</question-cards>";
  19. /** 归一化一条答案(只保留只读渲染需要的字段,避免把别的状态写进内容) */
  20. const normalizeAnswer = (answer: QuestionCardAnswer | null | undefined) => ({
  21. header: String(answer?.header ?? ""),
  22. selected: Array.isArray(answer?.selected)
  23. ? answer.selected.map((item) => String(item ?? "").trim()).filter(Boolean)
  24. : [],
  25. freeText: String(answer?.freeText ?? ""),
  26. skipped: Boolean(answer?.skipped),
  27. });
  28. /** 生成只读渲染所需的内容(`QuestionCard` 的 readonly 分支按这个形状读) */
  29. export const buildAnsweredQuestionCardsContent = (answers: QuestionCardAnswer[]): string =>
  30. JSON.stringify({ answers: (Array.isArray(answers) ? answers : []).map(normalizeAnswer) });
  31. /**
  32. * 把内容里**最后一个** `<question-cards>…</question-cards>` 改成"已作答"的内容。
  33. *
  34. * ⚠️ **保留原有字段(尤其 `questions`),只补/换 `answers`**:
  35. * 只读态要求"选完还是问卷本来的样子",而那份样式(`(单选)` 标签、选项 A/B、提交按钮…)
  36. * 全靠 `questions` 渲染出来 —— 上一版把整块替换成只有 `answers` 的 JSON,
  37. * 结果只读渲染只能退化成一个简陋的小框(用户 2026-09-24:「问卷长什么样选完后还是长什么样」)。
  38. *
  39. * 找不到标记(或标记不成对)时**原样返回** —— 宁可不动,也不要破坏内容里的其它行。
  40. */
  41. export const applyQuestionCardsAnswers = (
  42. content: string,
  43. answers: QuestionCardAnswer[]
  44. ): string => {
  45. const text = String(content ?? "");
  46. const closeIdx = text.lastIndexOf(CLOSE_TAG);
  47. if (closeIdx < 0) return text;
  48. const openIdx = text.lastIndexOf(OPEN_TAG, closeIdx);
  49. if (openIdx < 0) return text;
  50. const inner = text.slice(openIdx + OPEN_TAG.length, closeIdx);
  51. let payload: Record<string, unknown> = {};
  52. try {
  53. const parsed = JSON.parse(inner);
  54. if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
  55. payload = parsed as Record<string, unknown>;
  56. }
  57. } catch {
  58. // 原内容不是 JSON(理论上不会发生):只写 answers,至少让只读态能渲染出答案
  59. }
  60. const next = JSON.stringify({
  61. ...payload,
  62. answers: (Array.isArray(answers) ? answers : []).map(normalizeAnswer),
  63. });
  64. return text.slice(0, openIdx + OPEN_TAG.length) + next + text.slice(closeIdx);
  65. };
  66. /** 内容里是否已经有作答结果(用于断言 / 幂等判断) */
  67. export const hasAnsweredQuestionCards = (content: string): boolean => {
  68. const text = String(content ?? "");
  69. const closeIdx = text.lastIndexOf(CLOSE_TAG);
  70. const openIdx = closeIdx >= 0 ? text.lastIndexOf(OPEN_TAG, closeIdx) : -1;
  71. if (openIdx < 0) return false;
  72. const inner = text.slice(openIdx + OPEN_TAG.length, closeIdx);
  73. try {
  74. const parsed = JSON.parse(inner);
  75. return Array.isArray(parsed?.answers) && parsed.answers.length > 0;
  76. } catch {
  77. return false;
  78. }
  79. };