@sendevia/pi-ask-author 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/component.d.ts +322 -0
- package/{src/format.ts → dist/format.d.ts} +12 -118
- package/dist/index.d.ts +51 -0
- package/dist/index.js +38 -0
- package/dist/model.d.ts +260 -0
- package/dist/novel-markdown.d.ts +28 -0
- package/dist/sanitize.d.ts +18 -0
- package/dist/schema.d.ts +129 -0
- package/dist/texts.d.ts +494 -0
- package/{src/theme.ts → dist/theme.d.ts} +0 -2
- package/dist/view-rows.d.ts +69 -0
- package/dist/view-summary.d.ts +27 -0
- package/package.json +9 -10
- package/src/component.ts +0 -1219
- package/src/index.ts +0 -419
- package/src/model.ts +0 -748
- package/src/novel-markdown.ts +0 -673
- package/src/sanitize.ts +0 -279
- package/src/schema.ts +0 -108
- package/src/texts.ts +0 -636
- package/src/view-rows.ts +0 -217
- package/src/view-summary.ts +0 -110
- package/tsconfig.json +0 -16
package/src/model.ts
DELETED
|
@@ -1,748 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file model.ts
|
|
3
|
-
* @description 问卷数据模型与状态机:题目归一化、作答状态流转、答卷编译与封套输出
|
|
4
|
-
*
|
|
5
|
-
* 核心架构特性:
|
|
6
|
-
* - 索引约定:内部作答状态 0-based,对外答卷编号 1-based
|
|
7
|
-
* - 两层清洗:`sanitize.ts` 边界归一化 + 本层对文本字段幂等兜底(绕过 sanitize 直接构造模型时同样安全)
|
|
8
|
-
* - 封套防止击穿:插值文本经由 `sanitizeEnvelopeText` 归一化,定界符不可被作者文本击穿
|
|
9
|
-
* - 封套语言单一:自动生成的占位题目/标签在封套编译期替换为英文占位(`Question N` / `Option N`),作者界面保留中文占位
|
|
10
|
-
* - 答卷完整性:显式跳过与仅批注提交均产出条目,多选题目恒产出条目,问题不在结果中静默消失
|
|
11
|
-
*
|
|
12
|
-
* 依赖方向:model.ts → format.ts / schema.ts / texts.ts(纯函数工具、Schema 单一类型源与文案字典,无回边);不引用 `Theme` 与 TUI 组件,渲染细节全部上移组件层
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { cleanOptional, cleanText, clamp, compressDraftPreview, toSingleLine } from "./format.js";
|
|
16
|
-
import { TEXTS } from "./texts.js";
|
|
17
|
-
import type { QuestionInput, QuestionOption, RawParams } from "./schema.js";
|
|
18
|
-
|
|
19
|
-
/** 归一化后的问题对象(字段已兜底,区间已收敛) */
|
|
20
|
-
export interface NormalizedQuestion {
|
|
21
|
-
/** 题目唯一标识符 */
|
|
22
|
-
id: string;
|
|
23
|
-
/** 题目标题 */
|
|
24
|
-
title: string;
|
|
25
|
-
/** 题目背景描述(可选) */
|
|
26
|
-
description?: string;
|
|
27
|
-
/** 是否多选(默认 false,即单选) */
|
|
28
|
-
multiSelect: boolean;
|
|
29
|
-
/** 收敛后的最少勾选数(`0 ~ options.length`) */
|
|
30
|
-
minSelect: number;
|
|
31
|
-
/** 是否显式给出 `minSelect`(显式即强制下限,隐式为缺省下限) */
|
|
32
|
-
minSelectExplicit: boolean;
|
|
33
|
-
/** 收敛后的最多勾选数(`minSelect ~ options.length`) */
|
|
34
|
-
maxSelect: number;
|
|
35
|
-
/** 是否允许 0 勾选提交(默认 false;单选与多选均生效) */
|
|
36
|
-
allowEmpty: boolean;
|
|
37
|
-
/** 归一化后的候选选项列表(至少一项) */
|
|
38
|
-
options: QuestionOption[];
|
|
39
|
-
/** 是否允许自由补充说明(默认 true) */
|
|
40
|
-
allowCustom: boolean;
|
|
41
|
-
/** 补充说明输入框占位提示词 */
|
|
42
|
-
customPlaceholder: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** 单题作答状态(内部数据结构,选项索引 0-based) */
|
|
46
|
-
export interface QuestionAnswerState {
|
|
47
|
-
/** 单选选中项索引(0-based;未选中时缺省) */
|
|
48
|
-
selectedIndex?: number;
|
|
49
|
-
/** 多选选中项索引集合(0-based) */
|
|
50
|
-
selectedIndices: Set<number>;
|
|
51
|
-
/** 题目级整体补充说明(可选) */
|
|
52
|
-
customText?: string;
|
|
53
|
-
/** 选项批注映射(0-based `optionIndex` → 批注内容) */
|
|
54
|
-
optionNotes: Map<number, string>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** 单题最终答卷条目(索引 1-based 便于人类阅读) */
|
|
58
|
-
export interface AuthorAnswerItem {
|
|
59
|
-
/** 题目唯一标识符 */
|
|
60
|
-
questionId: string;
|
|
61
|
-
/** 题目标题 */
|
|
62
|
-
questionTitle: string;
|
|
63
|
-
/** 是否为多选模式 */
|
|
64
|
-
multiSelect: boolean;
|
|
65
|
-
/** 单选选中项(含专属批注,可选) */
|
|
66
|
-
selectedOption?: QuestionOption & { customText?: string };
|
|
67
|
-
/** 单选选中项序号(1-based;未选中时缺省) */
|
|
68
|
-
selectedOptionIndex?: number;
|
|
69
|
-
/** 多选选中项列表(每项含专属批注,可选) */
|
|
70
|
-
selectedOptions?: Array<QuestionOption & { customText?: string }>;
|
|
71
|
-
/** 多选选中项序号列表(1-based,升序) */
|
|
72
|
-
selectedIndices?: number[];
|
|
73
|
-
/** 题目整体补充说明(可选) */
|
|
74
|
-
customText?: string;
|
|
75
|
-
/** 选项批注映射(1-based `optionIndex` → 批注内容,可选;含未选中选项上的孤立批注) */
|
|
76
|
-
optionNotes?: Record<number, string>;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** 工具执行完成返回的持久化结果结构 */
|
|
80
|
-
export interface AskAuthorResult {
|
|
81
|
-
/** 问卷总标题 */
|
|
82
|
-
formTitle: string;
|
|
83
|
-
/** 问卷全局背景描述(可选) */
|
|
84
|
-
formDescription?: string;
|
|
85
|
-
/** 归一化的问题列表 */
|
|
86
|
-
questions: NormalizedQuestion[];
|
|
87
|
-
/** 收集到的答卷条目列表 */
|
|
88
|
-
answers: AuthorAnswerItem[];
|
|
89
|
-
/** 用户是否主动取消了本次请示 */
|
|
90
|
-
cancelled: boolean;
|
|
91
|
-
/** 执行过程中的错误信息(可选) */
|
|
92
|
-
error?: string;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** 批注编辑目标:`question` 题目整体 / `option` 指定选项专属(`optionIndex` 为 0-based,越界不写入状态) */
|
|
96
|
-
export type EditTarget = { type: "question" } | { type: "option"; optionIndex: number };
|
|
97
|
-
|
|
98
|
-
/** 题目视图左侧菜单项的统一抽象模型 */
|
|
99
|
-
export interface MenuItem {
|
|
100
|
-
/** 菜单项类型:预设选项 / 题目补充入口 / 确认按钮 */
|
|
101
|
-
type: "option" | "custom_note" | "confirm_btn";
|
|
102
|
-
/** 预设选项索引(0-based,仅 option 类型有效) */
|
|
103
|
-
optionIndex?: number;
|
|
104
|
-
/** 菜单项显示文本 */
|
|
105
|
-
label: string;
|
|
106
|
-
/** 菜单项背景描述推演 */
|
|
107
|
-
description?: string;
|
|
108
|
-
/** 菜单项正文草稿/分镜头预览 */
|
|
109
|
-
preview?: string;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** 勾选 toggle 操作语义(三个操作语义类型均不导出,由组件层消费并映射为状态栏提示) */
|
|
113
|
-
type ToggleResult = "selected" | "deselected" | "limit";
|
|
114
|
-
|
|
115
|
-
/** 全选/清空操作语义 */
|
|
116
|
-
type SelectAllResult = "cleared" | "filled";
|
|
117
|
-
|
|
118
|
-
/** 批注保存操作语义 */
|
|
119
|
-
type NoteSaveResult = "saved" | "cleared" | "savedUnselected" | "savedAtLimit";
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* 判定是否存在非空题目级批注
|
|
123
|
-
* @param st - 题目作答状态
|
|
124
|
-
* @returns `customText` 去空白后非空时为 true
|
|
125
|
-
*/
|
|
126
|
-
export function hasQuestionNote(st: QuestionAnswerState): boolean {
|
|
127
|
-
return Boolean(st.customText?.trim());
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* 判定是否存在非空选项批注
|
|
132
|
-
* @param st - 题目作答状态
|
|
133
|
-
* @returns 任一选项批注去空白后非空时为 true
|
|
134
|
-
*/
|
|
135
|
-
function hasAnyOptionNote(st: QuestionAnswerState): boolean {
|
|
136
|
-
for (const n of st.optionNotes.values()) {
|
|
137
|
-
if (n?.trim()) return true;
|
|
138
|
-
}
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* 判定是否存在任意非空批注(题目级或选项级)
|
|
144
|
-
* @param st - 题目作答状态
|
|
145
|
-
* @returns 题目级或任一选项级批注非空时为 true
|
|
146
|
-
*/
|
|
147
|
-
function hasAnyNote(st: QuestionAnswerState): boolean {
|
|
148
|
-
return hasQuestionNote(st) || hasAnyOptionNote(st);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* 只读兜底空作答状态(供 `isQuestionAnswered` 读取尚未懒加载的题目)
|
|
153
|
-
* 约定式只读:本对象的 `selectedIndices` / `optionNotes` 永不写入,可写状态经 `getState` 懒加载
|
|
154
|
-
*/
|
|
155
|
-
const EMPTY_STATE: QuestionAnswerState = { selectedIndices: new Set<number>(), optionNotes: new Map<number, string>() };
|
|
156
|
-
|
|
157
|
-
/** 多选区间收敛结构模型 */
|
|
158
|
-
interface SelectBounds {
|
|
159
|
-
/** 收敛后的最少勾选数下限 */
|
|
160
|
-
minSelect: number;
|
|
161
|
-
/** 是否显式给出最少勾选数(区分缺省下限与强制下限) */
|
|
162
|
-
minSelectExplicit: boolean;
|
|
163
|
-
/** 收敛后的最多勾选数上限 */
|
|
164
|
-
maxSelect: number;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* 依据选项实际数量收敛 min/max 边界
|
|
169
|
-
* @param minRaw - 原始最少勾选数
|
|
170
|
-
* @param maxRaw - 原始最多勾选数
|
|
171
|
-
* @param optionCount - 题目选项总数
|
|
172
|
-
* @returns 收敛区间:`minSelect` 取值范围为 `[0, optionCount]`、`maxSelect` 取值范围为 `[minSelect, optionCount]`(0 选项题目恒设置为 `0/0`);`minSelectExplicit` 标记入参是否显式给出下限
|
|
173
|
-
*/
|
|
174
|
-
function clampSelectBounds(minRaw: number | undefined, maxRaw: number | undefined, optionCount: number): SelectBounds {
|
|
175
|
-
const minSelectExplicit = minRaw !== undefined;
|
|
176
|
-
const minSelect = clamp(minRaw ?? 1, 0, optionCount);
|
|
177
|
-
const maxSelect = clamp(maxRaw ?? optionCount, minSelect, optionCount);
|
|
178
|
-
return { minSelect, minSelectExplicit, maxSelect };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* 规整单个题目为标准数据结构
|
|
183
|
-
* 全程对文本字段应用幂等 `cleanOptional` 兜底,使绕过 sanitize 钩子直接构造模型时同样安全
|
|
184
|
-
* @param q - 原始题目输入(可能未经 sanitize 层清洗)
|
|
185
|
-
* @param index - 题目顺序索引(0-based),仅用于派生缺省 id 与标题
|
|
186
|
-
* @returns id/title/label 为空时回退 `TEXTS.fallbacks`;`minSelect` / `maxSelect` 收敛至 `[0, options.length]` 且保证 `minSelect <= maxSelect`
|
|
187
|
-
*/
|
|
188
|
-
function buildQuestion(q: QuestionInput, index: number): NormalizedQuestion {
|
|
189
|
-
return {
|
|
190
|
-
id: cleanOptional(q.id) ?? TEXTS.fallbacks.autoQuestionId(index + 1),
|
|
191
|
-
title: cleanOptional(q.title) ?? TEXTS.fallbacks.autoQuestionTitle(index + 1),
|
|
192
|
-
description: cleanOptional(q.description),
|
|
193
|
-
multiSelect: Boolean(q.multiSelect),
|
|
194
|
-
...clampSelectBounds(q.minSelect, q.maxSelect, q.options.length),
|
|
195
|
-
options: q.options.map((o) => ({
|
|
196
|
-
label: cleanOptional(o.label) ?? TEXTS.fallbacks.unnamedOptionLabel,
|
|
197
|
-
description: cleanOptional(o.description),
|
|
198
|
-
preview: cleanOptional(o.preview),
|
|
199
|
-
customPlaceholder: cleanOptional(o.customPlaceholder),
|
|
200
|
-
})),
|
|
201
|
-
allowEmpty: Boolean(q.allowEmpty),
|
|
202
|
-
allowCustom: q.allowCustom !== false,
|
|
203
|
-
customPlaceholder: cleanOptional(q.customPlaceholder) ?? TEXTS.fallbacks.defaultCustomPlaceholder,
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* 去重题目 ID(LLM 跨题重复 id 会导致 `formatAnswers` 按 id 归属查询时错误归题;重复项回退为顺序自动生成 id,仍冲突追加序号后缀)
|
|
209
|
-
* @param questions - 归一化题目列表
|
|
210
|
-
* @returns ID 全局唯一的题目列表
|
|
211
|
-
*/
|
|
212
|
-
function dedupeQuestionIds(questions: NormalizedQuestion[]): NormalizedQuestion[] {
|
|
213
|
-
const used = new Set<string>();
|
|
214
|
-
return questions.map((q, index) => {
|
|
215
|
-
if (!used.has(q.id)) {
|
|
216
|
-
used.add(q.id);
|
|
217
|
-
return q;
|
|
218
|
-
}
|
|
219
|
-
let candidate = TEXTS.fallbacks.autoQuestionId(index + 1);
|
|
220
|
-
for (let suffix = 2; used.has(candidate); suffix++) {
|
|
221
|
-
candidate = `${candidate}_${suffix}`;
|
|
222
|
-
}
|
|
223
|
-
used.add(candidate);
|
|
224
|
-
return { ...q, id: candidate };
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* 封套注入消毒:折叠单行并归一化全部可击穿封套定界符的字符(封套以 ASCII 方括号与 `;` 承载结构语义,插值文本须先归一,否则 LLM 无法定位批注边界,亦可能被伪造令牌误导)
|
|
230
|
-
* 替换顺序:折叠单行 → 弯引号族(`“”„‟«»`)转换为 `'` → ASCII 方括号转换为全角 `[]`(`answerLine` 的 `[标题]` 与 `[Author note: …]` / `[Draft directive: …]` 令牌定界符全由模板注入)→ 剥除 `【】`(封套选项标签专属定界符)→ ASCII `;` 转换为全角 `;`(封套以 `; ` 分隔选项片段)
|
|
231
|
-
* @param text - 原始批注/描述/标签文本
|
|
232
|
-
* @returns 归一化后的单行文本;半角 `[` / `]` / `;` 与换行均已消除,令牌边界不可能被插值文本提前闭合或伪造
|
|
233
|
-
*/
|
|
234
|
-
function sanitizeEnvelopeText(text: string): string {
|
|
235
|
-
return toSingleLine(text)
|
|
236
|
-
.replace(/[“”„‟«»]/g, "'")
|
|
237
|
-
.replace(/[\[\]]/g, (bracket) => (bracket === "[" ? "[" : "]"))
|
|
238
|
-
.replace(/[【】]/g, "")
|
|
239
|
-
.replace(/;/g, ";");
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* 把自动生成的占位题目标题替换为英文占位(非占位标题原样返回)
|
|
244
|
-
* 语言归属:占位文案在 `sanitize.ts` / {@link buildQuestion} 阶段即以中文写入题目对象,结构上无法保留身份标记,故按生成函数的返回值比对;作者或模型写出同名字面量时,封套仅替换为英文占位
|
|
245
|
-
* @param title - 答卷条目的题目标题(可能为 `问题 N` 或 `请选择接下来的创作方案`)
|
|
246
|
-
* @param questionIndex - 题目顺序索引(0-based);缺省表示无法定位,按非占位原样返回
|
|
247
|
-
* @returns 非占位标题保持原样;占位标题替换为 `TEXTS.markdown.fallbackQuestionTitle(questionIndex + 1)`
|
|
248
|
-
*/
|
|
249
|
-
function envelopeQuestionTitle(title: string, questionIndex: number | undefined): string {
|
|
250
|
-
if (questionIndex === undefined) return title;
|
|
251
|
-
const isPlaceholder =
|
|
252
|
-
title === TEXTS.fallbacks.defaultQuestionTitle || title === TEXTS.fallbacks.autoQuestionTitle(questionIndex + 1);
|
|
253
|
-
return isPlaceholder ? TEXTS.markdown.fallbackQuestionTitle(questionIndex + 1) : title;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* 把自动生成的占位选项标签替换为英文占位(非占位标签原样返回)
|
|
258
|
-
* 占位来源三选一:`sanitize.ts` 的 `选项 N` / `默认方案`,{@link buildQuestion} 的 `未命名方案`;比对理由同 {@link envelopeQuestionTitle}
|
|
259
|
-
* @param label - 选项标签(可能为自动生成的占位)
|
|
260
|
-
* @param optionIndex1 - 选项序号(1-based,与占位标签的编号一致);缺省表示无法定位,按非占位原样返回
|
|
261
|
-
* @returns 非占位标签保持原样;占位标签替换为 `TEXTS.markdown.fallbackOptionLabel(optionIndex1)`
|
|
262
|
-
*/
|
|
263
|
-
function envelopeOptionLabel(label: string, optionIndex1: number | undefined): string {
|
|
264
|
-
if (optionIndex1 === undefined) return label;
|
|
265
|
-
const isPlaceholder =
|
|
266
|
-
label === TEXTS.fallbacks.unnamedOptionLabel ||
|
|
267
|
-
label === TEXTS.fallbacks.defaultOptionLabel ||
|
|
268
|
-
label === TEXTS.fallbacks.autoOptionLabel(optionIndex1);
|
|
269
|
-
return isPlaceholder ? TEXTS.markdown.fallbackOptionLabel(optionIndex1) : label;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* 问卷数据模型:题目归一化、作答状态流转与答卷编译;全部状态突变经本类 mutation 方法完成并自动推进 `revision`,组件层只读
|
|
274
|
-
* 只读访问面:`revision` / `questionCount` / `getQuestion` / `getState` / `getSelectedIndices`(0-based 升序,排序不变量唯一实现)/ `getMenuItems` / `isQuestionAnswered` / `areAllQuestionsAnswered` / `compileAnswers` / `formatAnswers`
|
|
275
|
-
*/
|
|
276
|
-
export class AskAuthorFormModel {
|
|
277
|
-
/** 问卷总标题 */
|
|
278
|
-
readonly formTitle: string;
|
|
279
|
-
/** 问卷全局背景阐述(可选) */
|
|
280
|
-
readonly formDescription?: string;
|
|
281
|
-
/** 归一化后的题目列表 */
|
|
282
|
-
readonly questions: NormalizedQuestion[];
|
|
283
|
-
|
|
284
|
-
/** 作答状态版本号:单调递增,作为组件层派生缓存的失效键 */
|
|
285
|
-
private revisionValue = 0;
|
|
286
|
-
|
|
287
|
-
/** 各题作答状态映射(题目索引 0-based) */
|
|
288
|
-
private readonly states = new Map<number, QuestionAnswerState>();
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* 构造问卷数据模型实例(单题平铺与批量问卷两模式归一化)
|
|
292
|
-
* @param rawParams - 经 prepareArguments 规整的入参;`questions` 缺省或为空数组时回退单题平铺模式,`options` 亦为空则题目列表为空
|
|
293
|
-
*/
|
|
294
|
-
constructor(rawParams: RawParams) {
|
|
295
|
-
const rawQuestions = rawParams.questions ?? [];
|
|
296
|
-
const isBatch = rawQuestions.length > 0;
|
|
297
|
-
this.formTitle = cleanOptional(rawParams.formTitle) ?? TEXTS.fallbacks.defaultFormTitle;
|
|
298
|
-
this.formDescription = cleanOptional(rawParams.description);
|
|
299
|
-
|
|
300
|
-
if (isBatch) {
|
|
301
|
-
this.questions = dedupeQuestionIds(rawQuestions.map((q, i) => buildQuestion(q, i)));
|
|
302
|
-
} else if (rawParams.options?.length) {
|
|
303
|
-
this.questions = [
|
|
304
|
-
buildQuestion(
|
|
305
|
-
{
|
|
306
|
-
title: cleanOptional(rawParams.question) ?? TEXTS.fallbacks.defaultQuestionTitle,
|
|
307
|
-
description: cleanOptional(rawParams.description),
|
|
308
|
-
multiSelect: rawParams.multiSelect,
|
|
309
|
-
minSelect: rawParams.minSelect,
|
|
310
|
-
maxSelect: rawParams.maxSelect,
|
|
311
|
-
allowEmpty: rawParams.allowEmpty,
|
|
312
|
-
options: rawParams.options,
|
|
313
|
-
allowCustom: rawParams.allowCustom,
|
|
314
|
-
customPlaceholder: rawParams.customPlaceholder,
|
|
315
|
-
},
|
|
316
|
-
0,
|
|
317
|
-
),
|
|
318
|
-
];
|
|
319
|
-
} else {
|
|
320
|
-
this.questions = [];
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/** 当前作答状态版本号(组件层派生缓存失效键) */
|
|
325
|
-
get revision(): number {
|
|
326
|
-
return this.revisionValue;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** 问卷题目总数 */
|
|
330
|
-
get questionCount(): number {
|
|
331
|
-
return this.questions.length;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* 获取指定索引的归一化题目
|
|
336
|
-
* @param index - 题目顺序索引(0-based)
|
|
337
|
-
* @returns 归一化题目对象;索引越界时为 undefined
|
|
338
|
-
*/
|
|
339
|
-
getQuestion(index: number): NormalizedQuestion | undefined {
|
|
340
|
-
return this.questions[index];
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* 获取指定题目的作答状态(懒加载单例初始化)
|
|
345
|
-
* 不校验题目边界:越界索引同样创建并缓存一个空状态对象(调用方须先经 {@link getQuestion} 校验)
|
|
346
|
-
* @param index - 题目顺序索引(0-based)
|
|
347
|
-
* @returns 该题目的状态引用,可写入,同一索引恒返回同一对象;首次创建仅初始化内部缓存,不改变任何可观察作答语义、不推进 `revision`
|
|
348
|
-
*/
|
|
349
|
-
getState(index: number): QuestionAnswerState {
|
|
350
|
-
let st = this.states.get(index);
|
|
351
|
-
if (!st) {
|
|
352
|
-
st = { selectedIndices: new Set<number>(), optionNotes: new Map<number, string>() };
|
|
353
|
-
this.states.set(index, st);
|
|
354
|
-
}
|
|
355
|
-
return st;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* 获取已勾选选项索引(0-based 升序,未作答为空数组;排序不变量唯一实现在此)
|
|
360
|
-
* @param qIndex - 题目顺序索引(0-based)
|
|
361
|
-
* @returns 全新数组(调用方可安全持有而不影响内部状态);题目越界或状态未创建时为空数组
|
|
362
|
-
*/
|
|
363
|
-
getSelectedIndices(qIndex: number): number[] {
|
|
364
|
-
const st = this.states.get(qIndex);
|
|
365
|
-
return st ? Array.from(st.selectedIndices).sort((a, b) => a - b) : [];
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** 推进作答状态版本号(全部 mutation 方法的统一出口) */
|
|
369
|
-
private bumpRevision(): void {
|
|
370
|
-
this.revisionValue++;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* 派生指定题目的左侧菜单项列表(预设选项 + 自由补充入口 + 确认推进按钮)
|
|
375
|
-
* 菜单项标签与确认按钮明细随当前作答状态派生:多选展示已选数/下限,单选展示已选项标签、自定义方案名或「未选」
|
|
376
|
-
* @param qIndex - 题目顺序索引(0-based)
|
|
377
|
-
* @returns 顺序固定的菜单项数组(选项按声明顺序在前、补充入口居中、确认按钮殿后);题目越界为空数组;`allowCustom` 为 false 时省略补充入口
|
|
378
|
-
*/
|
|
379
|
-
getMenuItems(qIndex: number): MenuItem[] {
|
|
380
|
-
const q = this.getQuestion(qIndex);
|
|
381
|
-
if (!q) return [];
|
|
382
|
-
const st = this.getState(qIndex);
|
|
383
|
-
const noted = hasAnyNote(st);
|
|
384
|
-
|
|
385
|
-
const items: MenuItem[] = q.options.map((opt, optionIndex) => ({
|
|
386
|
-
type: "option",
|
|
387
|
-
optionIndex,
|
|
388
|
-
label: opt.label,
|
|
389
|
-
description: opt.description,
|
|
390
|
-
preview: opt.preview,
|
|
391
|
-
}));
|
|
392
|
-
|
|
393
|
-
if (q.allowCustom) {
|
|
394
|
-
items.push({
|
|
395
|
-
type: "custom_note",
|
|
396
|
-
label: hasQuestionNote(st) ? TEXTS.menu.customNoteLabelFilled : TEXTS.menu.customNoteLabel,
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const confirmDetail = q.multiSelect
|
|
401
|
-
? TEXTS.menu.confirmDetailMulti(st.selectedIndices.size, q.minSelect)
|
|
402
|
-
: st.selectedIndex !== undefined
|
|
403
|
-
? (q.options[st.selectedIndex]?.label ?? TEXTS.fallbacks.unselectedOption)
|
|
404
|
-
: noted
|
|
405
|
-
? TEXTS.fallbacks.defaultCustomChoiceName
|
|
406
|
-
: TEXTS.fallbacks.unselectedOption;
|
|
407
|
-
|
|
408
|
-
items.push({
|
|
409
|
-
type: "confirm_btn",
|
|
410
|
-
label: TEXTS.menu.confirmLabel(confirmDetail, noted),
|
|
411
|
-
description: TEXTS.menu.confirmDesc,
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
return items;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* 判定指定题目是否满足作答下限提交要求
|
|
419
|
-
* 单选:已选一项、`allowEmpty`,或 `allowCustom` 开启且存在任意非空批注;多选:勾选数达 `minSelect`、`allowEmpty` 下 0 选,
|
|
420
|
-
* 或缺省下限未勾选(`minSelect` 未显式给出时收敛为 `min(1, 选项数)`,故此时 `count < minSelect` 等价于 `count === 0`)且 `allowCustom` 开启并存在任意非空批注
|
|
421
|
-
* @param qIndex - 题目顺序索引(0-based)
|
|
422
|
-
* @returns 题目越界恒为 false;作答状态尚未懒加载时按只读空状态({@link EMPTY_STATE})判定,不写入任何状态
|
|
423
|
-
*/
|
|
424
|
-
isQuestionAnswered(qIndex: number): boolean {
|
|
425
|
-
const q = this.questions[qIndex];
|
|
426
|
-
if (!q) return false;
|
|
427
|
-
const st = this.states.get(qIndex) ?? EMPTY_STATE;
|
|
428
|
-
|
|
429
|
-
if (!q.multiSelect) {
|
|
430
|
-
return st.selectedIndex !== undefined || q.allowEmpty || (q.allowCustom && hasAnyNote(st));
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
const count = st.selectedIndices.size;
|
|
434
|
-
if (count >= q.minSelect) return true;
|
|
435
|
-
if (count === 0 && q.allowEmpty) return true;
|
|
436
|
-
// 缺省下限(未显式指定 minSelect)未勾选时,填写批注可直接提交
|
|
437
|
-
if (!q.minSelectExplicit && count === 0 && q.allowCustom && hasAnyNote(st)) return true;
|
|
438
|
-
|
|
439
|
-
return false;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* 判定问卷中的全部题目是否均已作答完毕
|
|
444
|
-
* @returns 每题均满足 {@link isQuestionAnswered} 时为 true;题目列表为空时恒为 true(`every` 的空集语义)
|
|
445
|
-
*/
|
|
446
|
-
areAllQuestionsAnswered(): boolean {
|
|
447
|
-
return this.questions.every((_, idx) => this.isQuestionAnswered(idx));
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// ===== 作答状态 mutation API(组件层唯一合法入口,全部自动推进 revision) =====
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* 切换指定选项勾选状态(空格键主路径;单选再次点击取消,多选达上限拒绝新增)
|
|
454
|
-
* @param qIndex - 题目顺序索引(0-based)
|
|
455
|
-
* @param optionIndex - 目标选项索引(0-based)
|
|
456
|
-
* @returns `selected` / `deselected` / `limit`(题目越界、选项不存在、多选已达上限且未勾选时返回 `limit` 且不推进 `revision`)
|
|
457
|
-
*/
|
|
458
|
-
toggleOption(qIndex: number, optionIndex: number): ToggleResult {
|
|
459
|
-
const q = this.getQuestion(qIndex);
|
|
460
|
-
if (!q || !q.options[optionIndex]) return "limit";
|
|
461
|
-
const st = this.getState(qIndex);
|
|
462
|
-
|
|
463
|
-
if (!q.multiSelect) {
|
|
464
|
-
st.selectedIndex = st.selectedIndex === optionIndex ? undefined : optionIndex;
|
|
465
|
-
this.bumpRevision();
|
|
466
|
-
return st.selectedIndex === undefined ? "deselected" : "selected";
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if (st.selectedIndices.has(optionIndex)) {
|
|
470
|
-
st.selectedIndices.delete(optionIndex);
|
|
471
|
-
this.bumpRevision();
|
|
472
|
-
return "deselected";
|
|
473
|
-
}
|
|
474
|
-
if (st.selectedIndices.size >= q.maxSelect) return "limit";
|
|
475
|
-
st.selectedIndices.add(optionIndex);
|
|
476
|
-
this.bumpRevision();
|
|
477
|
-
return "selected";
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* 确认路径下的选项写入(Enter/Ctrl+S 推进确认;单选直接更改选择,多选未达上限时补充勾选,已满未勾选静默保持原状,仅真实变更推进 `revision`)
|
|
482
|
-
* @param qIndex - 题目顺序索引(0-based)
|
|
483
|
-
* @param optionIndex - 目标选项索引(0-based)
|
|
484
|
-
*/
|
|
485
|
-
commitOption(qIndex: number, optionIndex: number): void {
|
|
486
|
-
const q = this.getQuestion(qIndex);
|
|
487
|
-
if (!q || !q.options[optionIndex]) return;
|
|
488
|
-
const st = this.getState(qIndex);
|
|
489
|
-
|
|
490
|
-
if (!q.multiSelect) {
|
|
491
|
-
if (st.selectedIndex !== optionIndex) {
|
|
492
|
-
st.selectedIndex = optionIndex;
|
|
493
|
-
this.bumpRevision();
|
|
494
|
-
}
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
if (st.selectedIndices.has(optionIndex) || st.selectedIndices.size >= q.maxSelect) return;
|
|
498
|
-
st.selectedIndices.add(optionIndex);
|
|
499
|
-
this.bumpRevision();
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* 多选全选或一键清空(`a` 键):已满或已全选时清空,否则按选项顺序填充至上限(保留既有勾选,恒保持 `size <= maxSelect`)
|
|
504
|
-
* @param qIndex - 题目顺序索引(0-based)
|
|
505
|
-
* @returns `cleared` / `filled`(题目越界或非多选同样返回 `cleared` 且不推进 `revision`)
|
|
506
|
-
*/
|
|
507
|
-
selectAllOrClear(qIndex: number): SelectAllResult {
|
|
508
|
-
const q = this.getQuestion(qIndex);
|
|
509
|
-
if (!q || !q.multiSelect) return "cleared";
|
|
510
|
-
const st = this.getState(qIndex);
|
|
511
|
-
|
|
512
|
-
if (st.selectedIndices.size >= q.maxSelect) {
|
|
513
|
-
st.selectedIndices.clear();
|
|
514
|
-
this.bumpRevision();
|
|
515
|
-
return "cleared";
|
|
516
|
-
}
|
|
517
|
-
for (let i = 0; i < q.options.length && st.selectedIndices.size < q.maxSelect; i++) {
|
|
518
|
-
st.selectedIndices.add(i);
|
|
519
|
-
}
|
|
520
|
-
this.bumpRevision();
|
|
521
|
-
return "filled";
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* 保存或清空选项专属批注(空文本视为清除;单选无选中项时随批注勾选,已有其他选中项仅附加批注不更改选择;多选已满且未勾选仅保存批注)
|
|
526
|
-
* @param qIndex - 题目顺序索引(0-based)
|
|
527
|
-
* @param optionIndex - 目标选项索引(0-based)
|
|
528
|
-
* @param text - 批注内容(空视为清除)
|
|
529
|
-
* @returns `saved` / `cleared` / `savedUnselected` / `savedAtLimit`(守卫路径返回 `cleared` 且不推进 `revision`;其余路径即使未实际改变批注集合——如原本无批注时清除——同样推进 `revision`)
|
|
530
|
-
*/
|
|
531
|
-
setOptionNote(qIndex: number, optionIndex: number, text: string): NoteSaveResult {
|
|
532
|
-
const q = this.getQuestion(qIndex);
|
|
533
|
-
if (!q || !q.options[optionIndex]) return "cleared";
|
|
534
|
-
const st = this.getState(qIndex);
|
|
535
|
-
const cleaned = cleanOptional(text);
|
|
536
|
-
this.bumpRevision();
|
|
537
|
-
|
|
538
|
-
if (!cleaned) {
|
|
539
|
-
st.optionNotes.delete(optionIndex);
|
|
540
|
-
return "cleared";
|
|
541
|
-
}
|
|
542
|
-
st.optionNotes.set(optionIndex, cleaned);
|
|
543
|
-
|
|
544
|
-
if (q.multiSelect) {
|
|
545
|
-
if (st.selectedIndices.has(optionIndex)) return "saved";
|
|
546
|
-
if (st.selectedIndices.size >= q.maxSelect) return "savedAtLimit";
|
|
547
|
-
st.selectedIndices.add(optionIndex);
|
|
548
|
-
return "saved";
|
|
549
|
-
}
|
|
550
|
-
if (st.selectedIndex === undefined) {
|
|
551
|
-
st.selectedIndex = optionIndex;
|
|
552
|
-
return "saved";
|
|
553
|
-
}
|
|
554
|
-
return st.selectedIndex === optionIndex ? "saved" : "savedUnselected";
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* 保存或清空题目整体补充说明(空文本视为清空)
|
|
559
|
-
* 本方法不校验题目边界:越界索引同样经 {@link getState} 创建并写入状态对象
|
|
560
|
-
* @param qIndex - 题目顺序索引(0-based)
|
|
561
|
-
* @param text - 批注内容(经 `cleanOptional` 归一,空串或全空白视为清空)
|
|
562
|
-
* @returns `saved`(存入非空文本)/ `cleared`(清空);无论是否发生实际变更均推进 `revision`
|
|
563
|
-
*/
|
|
564
|
-
setQuestionNote(qIndex: number, text: string): "saved" | "cleared" {
|
|
565
|
-
const st = this.getState(qIndex);
|
|
566
|
-
const cleaned = cleanOptional(text);
|
|
567
|
-
st.customText = cleaned;
|
|
568
|
-
this.bumpRevision();
|
|
569
|
-
return cleaned ? "saved" : "cleared";
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* 清空指定选项的专属批注(`x` 键路径;不存在时状态不变)
|
|
574
|
-
* @param qIndex - 题目顺序索引(0-based)
|
|
575
|
-
* @param optionIndex - 目标选项索引(0-based)
|
|
576
|
-
* @returns 实际删除到条目时为 true(并推进 `revision`);题目越界、状态未创建或该选项无批注时为 false(状态与 `revision` 均不变)
|
|
577
|
-
*/
|
|
578
|
-
clearOptionNote(qIndex: number, optionIndex: number): boolean {
|
|
579
|
-
const st = this.states.get(qIndex);
|
|
580
|
-
if (!st?.optionNotes.has(optionIndex)) return false;
|
|
581
|
-
st.optionNotes.delete(optionIndex);
|
|
582
|
-
this.bumpRevision();
|
|
583
|
-
return true;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/**
|
|
587
|
-
* 清空题目整体补充说明(`x` 键路径;不存在时状态不变)
|
|
588
|
-
* @param qIndex - 题目顺序索引(0-based)
|
|
589
|
-
* @returns 题目级批注存在且去空白后非空并被清空时为 true(并推进 `revision`);题目越界、状态未创建或批注为空时为 false(状态与 `revision` 均不变)
|
|
590
|
-
*/
|
|
591
|
-
clearQuestionNote(qIndex: number): boolean {
|
|
592
|
-
const st = this.states.get(qIndex);
|
|
593
|
-
if (!st?.customText?.trim()) return false;
|
|
594
|
-
st.customText = undefined;
|
|
595
|
-
this.bumpRevision();
|
|
596
|
-
return true;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* 收集全部非空选项批注(含未选中选项上的孤立批注),键转换为 1-based;与总览页展示保持一致
|
|
601
|
-
* @param q - 归一化题目对象
|
|
602
|
-
* @param st - 题目作答状态(可选)
|
|
603
|
-
* @returns 1-based 选项索引 → 批注内容的映射表
|
|
604
|
-
*/
|
|
605
|
-
private collectOptionNotes(q: NormalizedQuestion, st: QuestionAnswerState | undefined): Record<number, string> {
|
|
606
|
-
const optNotes: Record<number, string> = {};
|
|
607
|
-
if (!st) return optNotes;
|
|
608
|
-
for (const [idx, note] of st.optionNotes) {
|
|
609
|
-
const cleaned = cleanOptional(note);
|
|
610
|
-
if (cleaned && q.options[idx]) {
|
|
611
|
-
optNotes[idx + 1] = cleaned;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
return optNotes;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/**
|
|
618
|
-
* 编译全部作答状态为结果答案条目列表
|
|
619
|
-
* 产出规则:多选题目恒产出条目(未作答时 `selectedOptions` / `selectedIndices` 为空);单选题目仅在已作答时才产出条目(已选一项、`allowEmpty` 主动跳过或仅批注提交)
|
|
620
|
-
* 两种模式均杜绝问题在结果封套中静默消失;0-based 内部索引统一转换为 1-based
|
|
621
|
-
* @returns 与题目顺序一致的答卷条目列表(未产出的单选题目不占位);题干无任何作答且非多选时为空数组
|
|
622
|
-
*/
|
|
623
|
-
compileAnswers(): AuthorAnswerItem[] {
|
|
624
|
-
const answers: AuthorAnswerItem[] = [];
|
|
625
|
-
|
|
626
|
-
for (const [i, q] of this.questions.entries()) {
|
|
627
|
-
const st = this.states.get(i);
|
|
628
|
-
const customText = st?.customText?.trim() ? cleanText(st.customText.trim()) : undefined;
|
|
629
|
-
const base = { questionId: q.id, questionTitle: q.title, customText };
|
|
630
|
-
|
|
631
|
-
if (q.multiSelect) {
|
|
632
|
-
const selectedIdxs = this.getSelectedIndices(i);
|
|
633
|
-
const optNotes = this.collectOptionNotes(q, st);
|
|
634
|
-
const selectedOptions: Array<QuestionOption & { customText?: string }> = [];
|
|
635
|
-
|
|
636
|
-
for (const idx of selectedIdxs) {
|
|
637
|
-
const opt = q.options[idx];
|
|
638
|
-
if (!opt) continue;
|
|
639
|
-
selectedOptions.push({ ...opt, customText: optNotes[idx + 1] });
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
const hasNotes = Object.keys(optNotes).length > 0;
|
|
643
|
-
answers.push({
|
|
644
|
-
...base,
|
|
645
|
-
multiSelect: true,
|
|
646
|
-
selectedOptions,
|
|
647
|
-
selectedIndices: selectedIdxs.map((idx) => idx + 1),
|
|
648
|
-
optionNotes: hasNotes ? optNotes : undefined,
|
|
649
|
-
});
|
|
650
|
-
} else if (st?.selectedIndex !== undefined) {
|
|
651
|
-
const opt = q.options[st.selectedIndex];
|
|
652
|
-
const optNotes = this.collectOptionNotes(q, st);
|
|
653
|
-
|
|
654
|
-
answers.push({
|
|
655
|
-
...base,
|
|
656
|
-
multiSelect: false,
|
|
657
|
-
selectedOption: opt ? { ...opt, customText: optNotes[st.selectedIndex + 1] } : undefined,
|
|
658
|
-
selectedOptionIndex: st.selectedIndex + 1,
|
|
659
|
-
optionNotes: Object.keys(optNotes).length > 0 ? optNotes : undefined,
|
|
660
|
-
});
|
|
661
|
-
} else if (this.isQuestionAnswered(i)) {
|
|
662
|
-
// 无选项勾选的显式决策(仅批注提交或 allowEmpty 主动跳过)必须产出条目,否则 LLM 无法区分「作者主动跳过」与「问题未曾提出」
|
|
663
|
-
const optNotes = this.collectOptionNotes(q, st);
|
|
664
|
-
const hasNotes = Object.keys(optNotes).length > 0;
|
|
665
|
-
answers.push({ ...base, multiSelect: false, optionNotes: hasNotes ? optNotes : undefined });
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
return answers;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/**
|
|
673
|
-
* 编译答案为确定性标准 Markdown 封套文本(固定前缀与收尾指令最大化 Prompt Cache 命中率并驱动下游工作流)
|
|
674
|
-
* 全部插值文本经 {@link sanitizeEnvelopeText} 消毒(半角 `[` / `]` / `;` 与换行一并归一,模板注入的令牌边界不可被插值文本提前闭合或伪造)
|
|
675
|
-
* 确定性来源:片段顺序固定为「已选项(`getSelectedIndices` 升序)→ 未选中选项的孤立批注(`Record` 整数键升序)→ 题目级批注」,不依赖哈希序与时间
|
|
676
|
-
* 语言归属:自动生成的占位题目/标签经由 {@link envelopeQuestionTitle} / {@link envelopeOptionLabel} 替换为英文占位,其余插值文本原样保留(作者文本不翻译)
|
|
677
|
-
* @param answers - 答卷列表(通常来自 {@link compileAnswers})
|
|
678
|
-
* @returns `envelopePrefix` + 逐题一行(1-based 序号 + 标题 + `; ` 连接的片段)+ `envelopeSuffix`;`answers` 为空时退化为空答卷封套
|
|
679
|
-
*/
|
|
680
|
-
formatAnswers(answers: AuthorAnswerItem[]): string {
|
|
681
|
-
if (answers.length === 0) {
|
|
682
|
-
return `${TEXTS.markdown.envelopePrefix}\n${TEXTS.markdown.envelopeEmpty}\n${TEXTS.markdown.envelopeSuffix}`;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/** 包装批注为 `[Author note: …]` 令牌(空串返回空串,先经 `sanitizeEnvelopeText` 消毒) */
|
|
686
|
-
const noteToken = (text: string): string =>
|
|
687
|
-
text ? `${TEXTS.markdown.notePrefix}${sanitizeEnvelopeText(text)}${TEXTS.markdown.noteSuffix}` : "";
|
|
688
|
-
/** 包装已压缩草稿为 `[Draft directive: …]` 令牌(缺省或空串返回空串,先经 `sanitizeEnvelopeText` 消毒) */
|
|
689
|
-
const directiveToken = (draft?: string): string =>
|
|
690
|
-
draft
|
|
691
|
-
? `${TEXTS.markdown.draftDirectivePrefix}${sanitizeEnvelopeText(draft)}${TEXTS.markdown.draftDirectiveSuffix}`
|
|
692
|
-
: "";
|
|
693
|
-
|
|
694
|
-
const questionsById = new Map(this.questions.map((q) => [q.id, q]));
|
|
695
|
-
const questionIndexById = new Map(this.questions.map((q, index) => [q.id, index]));
|
|
696
|
-
|
|
697
|
-
const answerLines = answers.map((ans, i) => {
|
|
698
|
-
const questionIndex = questionIndexById.get(ans.questionId);
|
|
699
|
-
const selected = ans.multiSelect ? (ans.selectedOptions ?? []) : ans.selectedOption ? [ans.selectedOption] : [];
|
|
700
|
-
// 封套逐行编号要求单行结构:标签/推演/批注统一折叠换行,杜绝多行内容破坏封套格式
|
|
701
|
-
const parts = selected.map((o, j) => {
|
|
702
|
-
const compressedDraft = compressDraftPreview(o.preview);
|
|
703
|
-
const optNote = noteToken(cleanOptional(o.customText) ?? "");
|
|
704
|
-
const optionIndex1 = ans.multiSelect ? ans.selectedIndices?.[j] : ans.selectedOptionIndex;
|
|
705
|
-
const label = sanitizeEnvelopeText(envelopeOptionLabel(o.label, optionIndex1));
|
|
706
|
-
const desc = sanitizeEnvelopeText(o.description ?? "");
|
|
707
|
-
return desc
|
|
708
|
-
? TEXTS.markdown.optionWithDesc(label, desc, directiveToken(compressedDraft), optNote)
|
|
709
|
-
: TEXTS.markdown.optionWithoutDesc(label, directiveToken(compressedDraft), optNote);
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
// 附带未选中选项上的孤立批注(与总览页展示一致)
|
|
713
|
-
const q = questionsById.get(ans.questionId);
|
|
714
|
-
if (q && ans.optionNotes) {
|
|
715
|
-
const selectedIdxSet = new Set(
|
|
716
|
-
ans.multiSelect
|
|
717
|
-
? (ans.selectedIndices ?? [])
|
|
718
|
-
: ans.selectedOptionIndex !== undefined
|
|
719
|
-
? [ans.selectedOptionIndex]
|
|
720
|
-
: [],
|
|
721
|
-
);
|
|
722
|
-
for (const [idxStr, note] of Object.entries(ans.optionNotes)) {
|
|
723
|
-
const idx1 = Number(idxStr);
|
|
724
|
-
if (selectedIdxSet.has(idx1)) continue;
|
|
725
|
-
const rawLabel = q.options[idx1 - 1]?.label;
|
|
726
|
-
if (rawLabel && note) {
|
|
727
|
-
parts.push(
|
|
728
|
-
TEXTS.markdown.unselectedOptionNote(
|
|
729
|
-
sanitizeEnvelopeText(envelopeOptionLabel(rawLabel, idx1)),
|
|
730
|
-
noteToken(note),
|
|
731
|
-
),
|
|
732
|
-
);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
if (ans.customText) parts.push(noteToken(ans.customText));
|
|
738
|
-
const body = parts.join(TEXTS.markdown.partSeparator) || TEXTS.markdown.noSelection;
|
|
739
|
-
return TEXTS.markdown.answerLine(
|
|
740
|
-
i + 1,
|
|
741
|
-
sanitizeEnvelopeText(envelopeQuestionTitle(ans.questionTitle, questionIndex)),
|
|
742
|
-
body,
|
|
743
|
-
);
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
return `${TEXTS.markdown.envelopePrefix}\n${answerLines.join("\n")}\n${TEXTS.markdown.envelopeSuffix}`;
|
|
747
|
-
}
|
|
748
|
-
}
|