lite-questionnaire 1.0.0

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/core.ts ADDED
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Questionnaire 核心状态管理
3
+ *
4
+ * 管理问题列表、Tab 导航、答案 Map、进度点计算和状态持久化。
5
+ */
6
+
7
+ import type {
8
+ Answer,
9
+ Constraint,
10
+ FlatQuestion,
11
+ ProgressColor,
12
+ Question,
13
+ QuestionnaireResult,
14
+ QuestionUIState,
15
+ } from "./types";
16
+
17
+ // ─── 核心类 ────────────────────────────────────────────
18
+
19
+ export class Core {
20
+ /** 扁平化问题列表(子问题已展开插入) */
21
+ questions: FlatQuestion[] = [];
22
+
23
+ /** 当前问题在 flat list 中的索引 */
24
+ currentIndex = 0;
25
+
26
+ /** 持久化答案(已提交的) */
27
+ answers = new Map<string, Answer>();
28
+
29
+ /** 已访问过的问题 id 集合(仅保留访问历史,不再直接决定进度颜色) */
30
+ visited = new Set<string>();
31
+
32
+ /** 每个问题的进度点状态:none=未访问/未判定,red=已访问无值,green=已完成 */
33
+ private progress = new Map<string, ProgressColor>();
34
+
35
+ /** 每个问题的 UI 瞬时状态 */
36
+ private uiStates = new Map<string, QuestionUIState>();
37
+
38
+ /** 是否处于文本编辑模式(自定义选项编辑) */
39
+ inputMode = false;
40
+
41
+ /** 当前编辑对应的问题 id */
42
+ inputQuestionId: string | null = null;
43
+
44
+ /** 当前校验错误消息(显示在问题底部) */
45
+ errorMessage: string | null = null;
46
+
47
+ // ─── 构建方法 ──────────────────────────────────────────
48
+
49
+ /**
50
+ * 从原始问题列表 + 已有答案构建扁平化列表。
51
+ * 根据父问题答案展开条件子问题,插入到父问题之后。
52
+ */
53
+ static expand(questions: Question[], answers: Map<string, Answer>): FlatQuestion[] {
54
+ const result: FlatQuestion[] = [];
55
+
56
+ function walk(qs: Question[], depth: number, parentId: string | null) {
57
+ for (const q of qs) {
58
+ result.push({ ...q, _depth: depth, _parentId: parentId });
59
+
60
+ // 检查条件子问题是否应展开
61
+ if (q.children && q.children.length > 0) {
62
+ const answer = answers.get(q.id);
63
+ if (answer) {
64
+ const parentValues: string[] = [];
65
+ if ('value' in answer && typeof answer.value === 'string') {
66
+ parentValues.push(answer.value);
67
+ } else if ('values' in answer) {
68
+ parentValues.push(...answer.values);
69
+ }
70
+ if (parentValues.length > 0) {
71
+ const matching = q.children.filter(
72
+ (c) => !c.showIf || parentValues.includes(c.showIf.value),
73
+ );
74
+ if (matching.length > 0) {
75
+ walk(matching, depth + 1, q.id);
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ walk(questions, 0, null);
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * 初始化:从原始问题列表构建扁平列表
89
+ */
90
+ init(questions: Question[]) {
91
+ this.questions = Core.expand(questions, this.answers);
92
+ this.currentIndex = 0;
93
+ this.visited.clear();
94
+ this.progress.clear();
95
+ this.answers.clear();
96
+ this.uiStates.clear();
97
+ this.inputMode = false;
98
+ this.inputQuestionId = null;
99
+ this.errorMessage = null;
100
+ }
101
+
102
+ /**
103
+ * 在父问题提交后重新展开子问题。
104
+ * 返回 true 表示列表有变化(需要刷新 UI)。
105
+ */
106
+ reexpand(originalQuestions: Question[]): boolean {
107
+ const before = this.questions.map((q) => q.id).join(",");
108
+ this.questions = Core.expand(originalQuestions, this.answers);
109
+ this.pruneInactiveState();
110
+ const after = this.questions.map((q) => q.id).join(",");
111
+ return before !== after;
112
+ }
113
+
114
+ // ─── 当前问题访问 ──────────────────────────────────────
115
+
116
+ /** 获取当前问题 */
117
+ currentQuestion(): FlatQuestion | undefined {
118
+ return this.questions[this.currentIndex];
119
+ }
120
+
121
+ /** 是否在提交页 */
122
+ isSubmitTab(): boolean {
123
+ return this.currentIndex >= this.questions.length;
124
+ }
125
+
126
+ /** 总 Tab 数(问题 + 提交页) */
127
+ totalTabs(): number {
128
+ return this.questions.length + 1;
129
+ }
130
+
131
+ // ─── UI 状态管理 ──────────────────────────────────────
132
+
133
+ /** 获取或创建当前问题的 UI 状态 */
134
+ getUIState(): QuestionUIState {
135
+ const q = this.currentQuestion();
136
+ if (!q) return this.defaultUIState();
137
+ let state = this.uiStates.get(q.id);
138
+ if (!state) {
139
+ state = this.defaultUIState(q);
140
+ this.uiStates.set(q.id, state);
141
+ }
142
+ return state;
143
+ }
144
+
145
+ /** 保存当前问题的 UI 状态 */
146
+ saveUIState(state: QuestionUIState) {
147
+ const q = this.currentQuestion();
148
+ if (q) this.uiStates.set(q.id, { ...state });
149
+ }
150
+
151
+ private defaultUIState(q?: FlatQuestion): QuestionUIState {
152
+ const ratingMiddle = q && q.type === "rating"
153
+ ? 3
154
+ : 0;
155
+ return {
156
+ optionIndex: 0,
157
+ selectedIndices: [],
158
+ customText: null,
159
+ confirmValue: true,
160
+ ratingValue: ratingMiddle,
161
+ textDraft: "",
162
+ };
163
+ }
164
+
165
+ // ─── 答案管理 ─────────────────────────────────────────
166
+
167
+ private answerHasValue(a: Answer | undefined): boolean {
168
+ if (!a) return false;
169
+ if ('values' in a) return a.values.length > 0; // MultiSelectAnswer
170
+ if ('value' in a && typeof a.value === 'string') return a.value.trim().length > 0; // SelectAnswer
171
+ if ('text' in a) return a.text.trim().length > 0; // TextAnswer
172
+ // ConfirmAnswer (confirmed) / RatingAnswer (value: number) — 始终有值
173
+ return true;
174
+ }
175
+
176
+ /** 问题是否已有有效的持久化答案 */
177
+ hasAnswer(questionId: string): boolean {
178
+ return this.answerHasValue(this.answers.get(questionId));
179
+ }
180
+
181
+ /**
182
+ * 统一的值存在性判定。
183
+ * 优先检查持久化答案;没有答案时再检查当前 UI 草稿/默认值。
184
+ */
185
+ hasAnyValue(q: FlatQuestion, state?: QuestionUIState): boolean {
186
+ const answer = this.answers.get(q.id);
187
+ if (this.answerHasValue(answer)) return true;
188
+
189
+ const s = state || this.uiStates.get(q.id) || this.defaultUIState(q);
190
+ switch (q.type) {
191
+ case "confirm":
192
+ case "rating":
193
+ return true;
194
+ case "text":
195
+ return (s.textDraft || "").trim().length > 0;
196
+ case "select": {
197
+ const idx = s.selectedIndices[0];
198
+ if (idx === undefined) return false;
199
+ if (idx >= 0 && idx < q.options.length) return true;
200
+ return idx === q.options.length && (s.customText || "").trim().length > 0;
201
+ }
202
+ case "multiSelect":
203
+ return s.selectedIndices.some((idx) => {
204
+ if (idx >= 0 && idx < q.options.length) return true;
205
+ return idx === q.options.length && (s.customText || "").trim().length > 0;
206
+ });
207
+ }
208
+ return false;
209
+ }
210
+
211
+ /** 问题是否已完成:状态机判定为绿色,或已有有效持久化答案 */
212
+ isComplete(q: FlatQuestion): boolean {
213
+ return this.progress.get(q.id) === "green" || this.hasAnswer(q.id);
214
+ }
215
+
216
+ /** 保存答案 */
217
+ saveAnswer(questionId: string, answer: Answer) {
218
+ this.answers.set(questionId, answer);
219
+ if (this.answerHasValue(answer)) {
220
+ this.progress.set(questionId, "green");
221
+ }
222
+ }
223
+
224
+ /** 删除答案(回退修改后跳过时使用) */
225
+ deleteAnswer(questionId: string) {
226
+ this.answers.delete(questionId);
227
+ this.progress.delete(questionId);
228
+ }
229
+
230
+ /** 移除当前展开列表中已经不可见的问题状态,避免隐藏子问题污染结果 */
231
+ pruneInactiveState() {
232
+ const activeIds = new Set(this.questions.map((q) => q.id));
233
+ for (const id of Array.from(this.answers.keys())) {
234
+ if (!activeIds.has(id)) this.answers.delete(id);
235
+ }
236
+ for (const id of Array.from(this.visited)) {
237
+ if (!activeIds.has(id)) this.visited.delete(id);
238
+ }
239
+ for (const id of Array.from(this.progress.keys())) {
240
+ if (!activeIds.has(id)) this.progress.delete(id);
241
+ }
242
+ for (const id of Array.from(this.uiStates.keys())) {
243
+ if (!activeIds.has(id)) this.uiStates.delete(id);
244
+ }
245
+ if (this.currentIndex > this.questions.length) {
246
+ this.currentIndex = this.questions.length;
247
+ }
248
+ }
249
+
250
+ /** 所有问题是否均可提交 */
251
+ allAnswered(): boolean {
252
+ return this.questions.every((q) => this.isComplete(q));
253
+ }
254
+
255
+ /** 获取所有未完成的问题 id */
256
+ incompleteQuestions(): string[] {
257
+ return this.questions.filter((q) => !this.isComplete(q)).map((q) => q.id);
258
+ }
259
+
260
+ // ─── 进度点 ───────────────────────────────────────────
261
+
262
+ /** 离开当前问题时,根据是否有值统一更新进度点颜色 */
263
+ leaveCurrentQuestion() {
264
+ const q = this.currentQuestion();
265
+ if (!q) return;
266
+ const state = this.getUIState();
267
+ this.visited.add(q.id);
268
+ this.progress.set(q.id, this.hasAnyValue(q, state) ? "green" : "red");
269
+ }
270
+
271
+ /** 获取指定索引问题的进度点颜色 */
272
+ getProgress(index: number): ProgressColor {
273
+ const q = this.questions[index];
274
+ if (!q) return "none";
275
+ return this.progress.get(q.id) || "none";
276
+ }
277
+
278
+ // ─── 导航 ─────────────────────────────────────────────
279
+
280
+ /** 标记当前问题为已访问 */
281
+ markVisited() {
282
+ const q = this.currentQuestion();
283
+ if (q) this.visited.add(q.id);
284
+ }
285
+
286
+ /** 前进到下一个问题(或提交页) */
287
+ advance() {
288
+ this.errorMessage = null;
289
+ if (this.currentIndex < this.questions.length - 1) {
290
+ this.currentIndex++;
291
+ } else {
292
+ this.currentIndex = this.questions.length; // 提交页
293
+ }
294
+ // 重置新问题的 UI 状态(内部标记 visited)
295
+ this.resetUIStateForCurrent();
296
+ }
297
+
298
+ /** 回退到上一个问题 */
299
+ goBack() {
300
+ this.errorMessage = null;
301
+ if (this.currentIndex > 0) {
302
+ this.currentIndex--;
303
+ this.resetUIStateForCurrent();
304
+ }
305
+ }
306
+
307
+ /** 跳转到指定问题索引 */
308
+ goTo(index: number) {
309
+ if (index >= 0 && index <= this.questions.length) {
310
+ this.currentIndex = index;
311
+ this.resetUIStateForCurrent();
312
+ }
313
+ }
314
+
315
+ /** ← 方向切换问题(含提交页) */
316
+ prevTab() {
317
+ const total = this.totalTabs();
318
+ this.currentIndex = (this.currentIndex - 1 + total) % total;
319
+ this.errorMessage = null;
320
+ this.resetUIStateForCurrent();
321
+ }
322
+
323
+ /** → 方向切换问题(含提交页) */
324
+ nextTab() {
325
+ const total = this.totalTabs();
326
+ this.currentIndex = (this.currentIndex + 1) % total;
327
+ this.errorMessage = null;
328
+ this.resetUIStateForCurrent();
329
+ }
330
+
331
+
332
+ private resetUIStateForCurrent() {
333
+ const q = this.currentQuestion();
334
+ if (q) {
335
+ this.visited.add(q.id);
336
+ const fresh = this.defaultUIState(q);
337
+ // 保留之前的 draft,以便回头继续编辑
338
+ const prev = this.uiStates.get(q.id);
339
+ if (prev) {
340
+ fresh.textDraft = prev.textDraft;
341
+ fresh.customText = prev.customText;
342
+ fresh.selectedIndices = [...prev.selectedIndices];
343
+ fresh.ratingValue = prev.ratingValue;
344
+ fresh.confirmValue = prev.confirmValue;
345
+ fresh.optionIndex = prev.optionIndex;
346
+ }
347
+ this.uiStates.set(q.id, fresh);
348
+ }
349
+ }
350
+
351
+ // ─── 约束校验 ─────────────────────────────────────────
352
+
353
+ /**
354
+ * 对当前问题的答案进行约束校验。
355
+ * 返回 { valid, message }。
356
+ */
357
+ validate(questionId: string, values: string[], labels: string[]): { valid: boolean; message?: string } {
358
+ const q = this.questions.find((q) => q.id === questionId);
359
+ if (!q) return { valid: true };
360
+ if (!q.constraints || q.constraints.length === 0) return { valid: true };
361
+
362
+ for (const c of q.constraints) {
363
+ const result = this.checkConstraint(c, values, labels, q);
364
+ if (!result.valid) return result;
365
+ }
366
+ return { valid: true };
367
+ }
368
+
369
+ private checkConstraint(
370
+ c: Constraint,
371
+ values: string[],
372
+ labels: string[],
373
+ _q: FlatQuestion,
374
+ ): { valid: boolean; message?: string } {
375
+ switch (c.type) {
376
+ case "required":
377
+ if (values.length === 0 || values.every((v) => v.trim() === "")) {
378
+ return { valid: false, message: c.message };
379
+ }
380
+ break;
381
+ case "minSelect":
382
+ if (values.length < (c.value as number)) {
383
+ return { valid: false, message: c.message };
384
+ }
385
+ break;
386
+ case "maxSelect":
387
+ if (values.length > (c.value as number)) {
388
+ return { valid: false, message: c.message };
389
+ }
390
+ break;
391
+ case "minLength": {
392
+ const text = values.join(" ").trim();
393
+ if (text.length < (c.value as number)) {
394
+ return { valid: false, message: c.message };
395
+ }
396
+ break;
397
+ }
398
+ case "maxLength": {
399
+ const text = values.join(" ").trim();
400
+ if (text.length > (c.value as number)) {
401
+ return { valid: false, message: c.message };
402
+ }
403
+ break;
404
+ }
405
+ case "pattern": {
406
+ const text = values.join(" ").trim();
407
+ const regex = new RegExp(c.value as string);
408
+ if (!regex.test(text)) {
409
+ return { valid: false, message: c.message };
410
+ }
411
+ break;
412
+ }
413
+ }
414
+ return { valid: true };
415
+ }
416
+
417
+ // ─── 序列化(用于会话持久化) ─────────────────────────
418
+
419
+ serialize(): QuestionnaireSnapshot {
420
+ return {
421
+ currentIndex: this.currentIndex,
422
+ answers: Array.from(this.answers.entries()).map(([id, a]) => [id, a]),
423
+ visited: Array.from(this.visited),
424
+ progress: Array.from(this.progress.entries()),
425
+ uiStates: Array.from(this.uiStates.entries()).map(([id, s]) => [
426
+ id,
427
+ {
428
+ ...s,
429
+ selectedIndices: [...s.selectedIndices],
430
+ },
431
+ ]),
432
+ };
433
+ }
434
+
435
+ restore(snapshot: QuestionnaireSnapshot) {
436
+ this.currentIndex = snapshot.currentIndex;
437
+ this.answers = new Map(snapshot.answers);
438
+ this.visited = new Set(snapshot.visited);
439
+ this.progress = new Map(snapshot.progress || []);
440
+ for (const [id, answer] of this.answers) {
441
+ if (this.answerHasValue(answer)) {
442
+ this.progress.set(id, "green");
443
+ }
444
+ }
445
+ for (const id of this.visited) {
446
+ if (!this.progress.has(id) && !this.hasAnswer(id)) {
447
+ this.progress.set(id, "red");
448
+ }
449
+ }
450
+ this.uiStates = new Map(
451
+ snapshot.uiStates.map(([id, s]) => [id, { ...s, selectedIndices: [...(s.selectedIndices ?? [])] }]),
452
+ );
453
+ }
454
+
455
+ // ─── 导出结果 ─────────────────────────────────────────
456
+
457
+ toResult(): QuestionnaireResult {
458
+ const answers: Record<string, Answer> = {};
459
+ for (const [id, answer] of this.answers) {
460
+ answers[id] = answer;
461
+ }
462
+ return {
463
+ answers,
464
+ submittedAt: new Date().toISOString(),
465
+ };
466
+ }
467
+ }
468
+
469
+ /** 快照,用于会话持久化 */
470
+ export interface QuestionnaireSnapshot {
471
+ key?: string;
472
+ currentIndex: number;
473
+ answers: [string, Answer][];
474
+ visited: string[];
475
+ progress?: [string, ProgressColor][];
476
+ uiStates: [string, QuestionUIState][];
477
+ }