visual-spec 0.1.10 → 0.1.12

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.
@@ -0,0 +1,912 @@
1
+ <!doctype html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>VSpec 问答助手</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0b1220;
10
+ --panel: #111b2e;
11
+ --muted: #8aa0c3;
12
+ --text: #e6eefc;
13
+ --border: rgba(255, 255, 255, 0.08);
14
+ --primary: #3b82f6;
15
+ --danger: #ef4444;
16
+ --ok: #22c55e;
17
+ --warn: #f59e0b;
18
+ }
19
+ body {
20
+ margin: 0;
21
+ font-family:
22
+ -apple-system,
23
+ BlinkMacSystemFont,
24
+ "Segoe UI",
25
+ Roboto,
26
+ Helvetica,
27
+ Arial,
28
+ "Apple Color Emoji",
29
+ "Segoe UI Emoji";
30
+ background: linear-gradient(180deg, var(--bg), #060a13);
31
+ color: var(--text);
32
+ }
33
+ .wrap {
34
+ max-width: 1120px;
35
+ margin: 0 auto;
36
+ padding: 24px 16px 80px;
37
+ }
38
+ h1 {
39
+ margin: 6px 0 6px;
40
+ font-size: 22px;
41
+ letter-spacing: 0.2px;
42
+ }
43
+ .sub {
44
+ margin: 0 0 16px;
45
+ color: var(--muted);
46
+ font-size: 13px;
47
+ line-height: 1.4;
48
+ }
49
+ .row {
50
+ display: grid;
51
+ grid-template-columns: 1fr;
52
+ gap: 12px;
53
+ }
54
+ @media (min-width: 980px) {
55
+ .row {
56
+ grid-template-columns: 1fr 1fr;
57
+ align-items: start;
58
+ }
59
+ }
60
+ .card {
61
+ background: rgba(17, 27, 46, 0.72);
62
+ border: 1px solid var(--border);
63
+ border-radius: 14px;
64
+ padding: 14px;
65
+ backdrop-filter: blur(8px);
66
+ }
67
+ .card h2 {
68
+ margin: 0 0 10px;
69
+ font-size: 14px;
70
+ color: #cfe0ff;
71
+ }
72
+ .grid2 {
73
+ display: grid;
74
+ grid-template-columns: 1fr;
75
+ gap: 10px;
76
+ }
77
+ @media (min-width: 640px) {
78
+ .grid2 {
79
+ grid-template-columns: 1fr 1fr;
80
+ }
81
+ }
82
+ label {
83
+ display: block;
84
+ font-size: 12px;
85
+ color: var(--muted);
86
+ margin-bottom: 6px;
87
+ }
88
+ input[type="text"],
89
+ input[type="datetime-local"],
90
+ textarea,
91
+ select {
92
+ width: 100%;
93
+ box-sizing: border-box;
94
+ border-radius: 10px;
95
+ border: 1px solid var(--border);
96
+ background: rgba(10, 16, 28, 0.75);
97
+ color: var(--text);
98
+ padding: 10px 10px;
99
+ font-size: 13px;
100
+ outline: none;
101
+ }
102
+ textarea {
103
+ min-height: 70px;
104
+ resize: vertical;
105
+ }
106
+ .btnbar {
107
+ display: flex;
108
+ flex-wrap: wrap;
109
+ gap: 8px;
110
+ margin-top: 10px;
111
+ }
112
+ button {
113
+ border: 1px solid var(--border);
114
+ background: rgba(255, 255, 255, 0.06);
115
+ color: var(--text);
116
+ border-radius: 10px;
117
+ padding: 9px 12px;
118
+ cursor: pointer;
119
+ font-size: 13px;
120
+ }
121
+ button.primary {
122
+ background: rgba(59, 130, 246, 0.18);
123
+ border-color: rgba(59, 130, 246, 0.45);
124
+ }
125
+ button.danger {
126
+ background: rgba(239, 68, 68, 0.12);
127
+ border-color: rgba(239, 68, 68, 0.4);
128
+ }
129
+ button:disabled {
130
+ opacity: 0.5;
131
+ cursor: not-allowed;
132
+ }
133
+ .hint {
134
+ margin-top: 10px;
135
+ color: var(--muted);
136
+ font-size: 12px;
137
+ line-height: 1.45;
138
+ }
139
+ .pill {
140
+ display: inline-flex;
141
+ align-items: center;
142
+ gap: 6px;
143
+ padding: 6px 10px;
144
+ border-radius: 999px;
145
+ border: 1px solid var(--border);
146
+ background: rgba(255, 255, 255, 0.04);
147
+ color: var(--muted);
148
+ font-size: 12px;
149
+ }
150
+ .pill.ok {
151
+ color: rgba(34, 197, 94, 0.9);
152
+ border-color: rgba(34, 197, 94, 0.28);
153
+ }
154
+ .pill.warn {
155
+ color: rgba(245, 158, 11, 0.95);
156
+ border-color: rgba(245, 158, 11, 0.28);
157
+ }
158
+ .pill.bad {
159
+ color: rgba(239, 68, 68, 0.95);
160
+ border-color: rgba(239, 68, 68, 0.28);
161
+ }
162
+ .list {
163
+ display: flex;
164
+ flex-direction: column;
165
+ gap: 10px;
166
+ margin-top: 12px;
167
+ }
168
+ .q {
169
+ border: 1px solid var(--border);
170
+ background: rgba(8, 12, 20, 0.6);
171
+ border-radius: 14px;
172
+ padding: 12px;
173
+ }
174
+ .qhead {
175
+ display: flex;
176
+ flex-wrap: wrap;
177
+ gap: 8px;
178
+ justify-content: space-between;
179
+ align-items: baseline;
180
+ margin-bottom: 8px;
181
+ }
182
+ .qid {
183
+ font-weight: 600;
184
+ font-size: 13px;
185
+ color: #d9e6ff;
186
+ }
187
+ .qctx {
188
+ color: var(--muted);
189
+ font-size: 12px;
190
+ }
191
+ .qtitle {
192
+ font-size: 13px;
193
+ line-height: 1.5;
194
+ margin: 4px 0 10px;
195
+ white-space: pre-wrap;
196
+ }
197
+ .opt {
198
+ display: flex;
199
+ flex-wrap: wrap;
200
+ gap: 8px;
201
+ margin: 6px 0 10px;
202
+ }
203
+ .opt label {
204
+ margin: 0;
205
+ font-size: 13px;
206
+ color: var(--text);
207
+ }
208
+ .opt .o {
209
+ display: inline-flex;
210
+ gap: 8px;
211
+ align-items: center;
212
+ border: 1px solid var(--border);
213
+ border-radius: 999px;
214
+ padding: 6px 10px;
215
+ background: rgba(255, 255, 255, 0.03);
216
+ }
217
+ .k {
218
+ color: var(--muted);
219
+ font-size: 12px;
220
+ margin-top: 8px;
221
+ }
222
+ .sep {
223
+ height: 1px;
224
+ background: var(--border);
225
+ margin: 14px 0;
226
+ }
227
+ .mono {
228
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
229
+ monospace;
230
+ }
231
+ </style>
232
+ </head>
233
+ <body>
234
+ <div class="wrap">
235
+ <h1>VSpec 问答助手(HTML 交互 + 回写 Markdown)</h1>
236
+ <p class="sub">
237
+ 作用:把 <span class="mono">original.md</span> 与 <span class="mono">questions.md</span> 里的提问渲染成表单(尽量按选择题),并在填写后回写到对应的
238
+ markdown 文件。支持两种保存方式:若浏览器支持文件写入 API,则直接覆盖写回;否则导出下载文件,由你手动替换。
239
+ </p>
240
+
241
+ <div class="row">
242
+ <div class="card">
243
+ <h2>1) 选择要回答的文件</h2>
244
+ <div class="grid2">
245
+ <div>
246
+ <label>original.md(待确认问题)</label>
247
+ <div class="btnbar">
248
+ <button class="primary" id="pickOriginal">选择文件(推荐)</button>
249
+ <input id="fallbackOriginal" type="file" accept=".md,text/markdown,text/plain" style="display:none" />
250
+ <button id="pickOriginalFallback">选择文件(兼容模式)</button>
251
+ </div>
252
+ <div class="hint">
253
+ 目标路径通常是:<span class="mono">/specs/background/original.md</span>
254
+ </div>
255
+ <div class="hint" id="originalStatus"></div>
256
+ </div>
257
+ <div>
258
+ <label>questions.md(问答清单)</label>
259
+ <div class="btnbar">
260
+ <button class="primary" id="pickQuestions">选择文件(推荐)</button>
261
+ <input id="fallbackQuestions" type="file" accept=".md,text/markdown,text/plain" style="display:none" />
262
+ <button id="pickQuestionsFallback">选择文件(兼容模式)</button>
263
+ </div>
264
+ <div class="hint">
265
+ 目标路径通常是:<span class="mono">/specs/background/questions.md</span>
266
+ </div>
267
+ <div class="hint" id="questionsStatus"></div>
268
+ </div>
269
+ </div>
270
+
271
+ <div class="sep"></div>
272
+
273
+ <div class="grid2">
274
+ <div>
275
+ <label>回答者(写入 Answered By/回答者/回答者)</label>
276
+ <input id="answeredBy" type="text" placeholder="例如:业务负责人/产品经理/张三" />
277
+ </div>
278
+ <div>
279
+ <label>回答时间(默认现在)</label>
280
+ <input id="answeredAt" type="datetime-local" />
281
+ </div>
282
+ </div>
283
+
284
+ <div class="btnbar">
285
+ <button id="reload" class="primary" disabled>重新解析并渲染</button>
286
+ <button id="saveOriginal" disabled>保存 original.md</button>
287
+ <button id="saveQuestions" disabled>保存 questions.md</button>
288
+ <button id="downloadOriginal" disabled>下载 original.md(兼容模式)</button>
289
+ <button id="downloadQuestions" disabled>下载 questions.md(兼容模式)</button>
290
+ </div>
291
+
292
+ <div class="hint">
293
+ 提示:若你只想回答 <span class="mono">questions.md</span>,可以只选择 questions.md 文件并保存。original.md 的“待确认问题”通常偏方向性澄清;questions.md 偏可追踪 Q&A。
294
+ </div>
295
+ </div>
296
+
297
+ <div class="card">
298
+ <h2>2) 当前解析结果</h2>
299
+ <div class="btnbar">
300
+ <span class="pill" id="statsOriginal">original.md:未加载</span>
301
+ <span class="pill" id="statsQuestions">questions.md:未加载</span>
302
+ </div>
303
+ <div class="hint">
304
+ 选择题解析规则:
305
+ <span class="mono">选项:</span>/<span class="mono">Options:</span>/<span class="mono">選択肢:</span>
306
+ 之后的内容会被拆成选项(支持 <span class="mono">;</span>/<span class="mono">;</span> 分隔,或 <span class="mono">A.</span>/<span class="mono">1)</span> 列表)。
307
+ 若无法解析到选项,则提供自由文本回答框。
308
+ </div>
309
+ <div class="hint">
310
+ 保存规则(questions.md):当填写了回答,将自动写入 Answer 字段,填充 Answered By/At,并把 Status 置为已回答(按文件语言自动识别)。
311
+ </div>
312
+ </div>
313
+ </div>
314
+
315
+ <div class="row" style="margin-top: 14px">
316
+ <div class="card">
317
+ <h2>original.md:待确认问题</h2>
318
+ <div id="originalList" class="list"></div>
319
+ </div>
320
+ <div class="card">
321
+ <h2>questions.md:问答清单</h2>
322
+ <div id="questionsList" class="list"></div>
323
+ </div>
324
+ </div>
325
+ </div>
326
+
327
+ <script>
328
+ const state = {
329
+ original: { handle: null, name: null, text: null, items: [] },
330
+ questions: { handle: null, name: null, text: null, items: [], lang: null, format: null },
331
+ };
332
+
333
+ function setPill(el, kind, text) {
334
+ el.classList.remove("ok", "warn", "bad");
335
+ if (kind) el.classList.add(kind);
336
+ el.textContent = text;
337
+ }
338
+
339
+ function nowLocalInputValue() {
340
+ const d = new Date();
341
+ const pad = (n) => String(n).padStart(2, "0");
342
+ const yyyy = d.getFullYear();
343
+ const mm = pad(d.getMonth() + 1);
344
+ const dd = pad(d.getDate());
345
+ const hh = pad(d.getHours());
346
+ const mi = pad(d.getMinutes());
347
+ return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
348
+ }
349
+
350
+ function detectOptions(text) {
351
+ if (!text) return [];
352
+ const m = text.match(/(?:选项|Options|選択肢)\s*[::]\s*([\s\S]+)$/i);
353
+ if (!m) return [];
354
+ let raw = m[1].trim();
355
+ raw = raw.replace(/\s+/g, " ");
356
+ const partsBySep = raw.split(/[;;]\s*/).map((s) => s.trim()).filter(Boolean);
357
+ if (partsBySep.length >= 2) {
358
+ return partsBySep.map((p) => p.replace(/^[A-Z]\.\s*/i, "").replace(/^\d+\)\s*/, ""));
359
+ }
360
+ const partsByList = raw
361
+ .split(/(?:\s*[A-Z]\.\s+|\s*\d+\)\s+)/)
362
+ .map((s) => s.trim())
363
+ .filter(Boolean);
364
+ if (partsByList.length >= 2) return partsByList;
365
+ return [];
366
+ }
367
+
368
+ function parseQuestionsMd(md) {
369
+ const lines = md.split(/\r?\n/);
370
+ const items = [];
371
+ let current = null;
372
+ const startRe = /^(\d+)\.\s*(编号|ID|番号)[::]\s*(\d+)\s*$/;
373
+ for (let i = 0; i < lines.length; i++) {
374
+ const line = lines[i];
375
+ const m = line.match(startRe);
376
+ if (m) {
377
+ if (current) items.push(current);
378
+ current = {
379
+ index: Number(m[1]),
380
+ id: Number(m[3]),
381
+ headerKey: m[2],
382
+ startLine: i,
383
+ endLine: i,
384
+ fields: {},
385
+ };
386
+ continue;
387
+ }
388
+ if (!current) continue;
389
+ current.endLine = i;
390
+ const fm = line.match(/^\s*-\s*([^::]+)[::]\s*(.*)\s*$/);
391
+ if (fm) {
392
+ const key = fm[1].trim();
393
+ const val = fm[2] ?? "";
394
+ current.fields[key] = val;
395
+ }
396
+ }
397
+ if (current) items.push(current);
398
+
399
+ const lang = items.length
400
+ ? items[0].headerKey === "ID"
401
+ ? "en"
402
+ : items[0].headerKey === "番号"
403
+ ? "ja"
404
+ : "zh"
405
+ : null;
406
+
407
+ const keyset = lang === "en"
408
+ ? { context: "Context", question: "Question", answer: "Answer", answeredBy: "Answered By", answeredAt: "Answered At", status: "Status", asker: "Asker", askedAt: "Asked At" }
409
+ : lang === "ja"
410
+ ? { context: "背景", question: "質問", answer: "回答", answeredBy: "回答者", answeredAt: "回答日時", status: "状態", asker: "質問者", askedAt: "質問日時" }
411
+ : { context: "背景", question: "提问", answer: "回答", answeredBy: "回答者", answeredAt: "回答时间", status: "状态", asker: "提问者", askedAt: "提问时间" };
412
+
413
+ for (const it of items) {
414
+ it.lang = lang;
415
+ it.keys = keyset;
416
+ it.context = it.fields[keyset.context] ?? "";
417
+ it.question = it.fields[keyset.question] ?? "";
418
+ it.answer = it.fields[keyset.answer] ?? "";
419
+ it.status = it.fields[keyset.status] ?? "";
420
+ it.options = detectOptions(it.question);
421
+ it.answerPick = "";
422
+ it.answerFree = it.answer || "";
423
+ }
424
+ return { items, lang };
425
+ }
426
+
427
+ function parseOriginalOpenQuestions(md) {
428
+ const lines = md.split(/\r?\n/);
429
+ const topRe = /^#\s*(待确认问题|Open Questions|要確認事項)\s*$/;
430
+ let start = -1;
431
+ for (let i = 0; i < lines.length; i++) {
432
+ if (topRe.test(lines[i])) {
433
+ start = i;
434
+ break;
435
+ }
436
+ }
437
+ if (start === -1) return { items: [], found: false };
438
+ let end = lines.length;
439
+ for (let i = start + 1; i < lines.length; i++) {
440
+ if (/^#\s+/.test(lines[i])) {
441
+ end = i;
442
+ break;
443
+ }
444
+ }
445
+ const slice = lines.slice(start, end);
446
+ const items = [];
447
+ let group = "";
448
+ for (let i = 0; i < slice.length; i++) {
449
+ const line = slice[i];
450
+ const gh = line.match(/^##\s+(.*)\s*$/);
451
+ if (gh) {
452
+ group = gh[1].trim();
453
+ continue;
454
+ }
455
+ const qm = line.match(/^\s*-\s+(.*)\s*$/);
456
+ if (!qm) continue;
457
+ const text = qm[1].trim();
458
+ if (!text) continue;
459
+ if (text.startsWith("Answer:") || text.startsWith("回答:") || text.startsWith("回答:")) continue;
460
+ const item = {
461
+ group,
462
+ qLineInSlice: i,
463
+ qText: text,
464
+ options: [],
465
+ answerLineOffset: null,
466
+ answer: "",
467
+ answerPick: "",
468
+ answerFree: "",
469
+ };
470
+ let j = i + 1;
471
+ while (j < slice.length) {
472
+ const l = slice[j];
473
+ if (/^##\s+/.test(l) || /^#\s+/.test(l)) break;
474
+ const opt = l.match(/^\s{2,}-\s+(?:选项|Options|選択肢)\s*[::]\s*(.*)\s*$/i);
475
+ if (opt) {
476
+ item.options = detectOptions(`选项:${opt[1]}`);
477
+ j++;
478
+ continue;
479
+ }
480
+ const ans = l.match(/^\s{2,}-\s*(Answer|回答)\s*[::]\s*(.*)\s*$/i);
481
+ if (ans) {
482
+ item.answerLineOffset = j;
483
+ item.answer = ans[2] ?? "";
484
+ item.answerFree = item.answer;
485
+ j++;
486
+ continue;
487
+ }
488
+ const subOpt = l.match(/^\s{2,}-\s+(.*)\s*$/);
489
+ if (subOpt && !item.options.length) {
490
+ const t = subOpt[1].trim();
491
+ if (/^[A-Z]\./.test(t) || /^\d+\)/.test(t)) {
492
+ item.options.push(t.replace(/^[A-Z]\.\s*/, "").replace(/^\d+\)\s*/, ""));
493
+ }
494
+ }
495
+ if (/^\s*-\s+/.test(l)) break;
496
+ j++;
497
+ }
498
+ if (item.options.length === 1) item.options = [];
499
+ items.push(item);
500
+ }
501
+ return { items, found: true, start, end };
502
+ }
503
+
504
+ function renderQuestions() {
505
+ const answeredBy = document.getElementById("answeredBy").value.trim();
506
+ const list = document.getElementById("questionsList");
507
+ list.innerHTML = "";
508
+ for (const it of state.questions.items) {
509
+ const el = document.createElement("div");
510
+ el.className = "q";
511
+ el.dataset.qid = String(it.id);
512
+ const head = document.createElement("div");
513
+ head.className = "qhead";
514
+ const left = document.createElement("div");
515
+ left.innerHTML = `<div class="qid">#${it.id}</div><div class="qctx">${escapeHtml(it.context || "")}</div>`;
516
+ const right = document.createElement("div");
517
+ right.className = "pill";
518
+ right.textContent = it.answerFree && it.answerFree.trim() ? "已填写" : "未填写";
519
+ head.appendChild(left);
520
+ head.appendChild(right);
521
+
522
+ const title = document.createElement("div");
523
+ title.className = "qtitle";
524
+ title.textContent = it.question || "";
525
+
526
+ const opt = document.createElement("div");
527
+ opt.className = "opt";
528
+ if (it.options && it.options.length) {
529
+ const name = `q_${it.id}`;
530
+ for (const o of it.options) {
531
+ const wrap = document.createElement("label");
532
+ wrap.className = "o";
533
+ const radio = document.createElement("input");
534
+ radio.type = "radio";
535
+ radio.name = name;
536
+ radio.value = o;
537
+ radio.checked = it.answerPick === o;
538
+ radio.addEventListener("change", () => {
539
+ it.answerPick = o;
540
+ if (!it.answerFree.trim()) it.answerFree = o;
541
+ renderQuestions();
542
+ });
543
+ const span = document.createElement("span");
544
+ span.textContent = o;
545
+ wrap.appendChild(radio);
546
+ wrap.appendChild(span);
547
+ opt.appendChild(wrap);
548
+ }
549
+ } else {
550
+ const k = document.createElement("div");
551
+ k.className = "k";
552
+ k.textContent = "未解析到选项:使用自由文本回答";
553
+ opt.appendChild(k);
554
+ }
555
+
556
+ const answerBox = document.createElement("div");
557
+ const aLabel = document.createElement("label");
558
+ aLabel.textContent = "回答(可编辑)";
559
+ const ta = document.createElement("textarea");
560
+ ta.value = it.answerFree || "";
561
+ ta.placeholder = it.options && it.options.length ? "可直接选上方选项,或在此补充“原因/备注/补充口径”" : "请输入回答";
562
+ ta.addEventListener("input", (e) => {
563
+ it.answerFree = e.target.value;
564
+ renderQuestions();
565
+ });
566
+ answerBox.appendChild(aLabel);
567
+ answerBox.appendChild(ta);
568
+
569
+ const meta = document.createElement("div");
570
+ meta.className = "k";
571
+ meta.textContent = answeredBy ? `回答者:${answeredBy}` : "回答者未填写(保存时会留空)";
572
+
573
+ el.appendChild(head);
574
+ el.appendChild(title);
575
+ el.appendChild(opt);
576
+ el.appendChild(answerBox);
577
+ el.appendChild(meta);
578
+ list.appendChild(el);
579
+ }
580
+ }
581
+
582
+ function renderOriginal() {
583
+ const list = document.getElementById("originalList");
584
+ list.innerHTML = "";
585
+ for (let idx = 0; idx < state.original.items.length; idx++) {
586
+ const it = state.original.items[idx];
587
+ const el = document.createElement("div");
588
+ el.className = "q";
589
+ const head = document.createElement("div");
590
+ head.className = "qhead";
591
+ const left = document.createElement("div");
592
+ left.innerHTML = `<div class="qid">${escapeHtml(it.group || "未分组")}</div><div class="qctx">Q${idx + 1}</div>`;
593
+ const right = document.createElement("div");
594
+ right.className = "pill";
595
+ right.textContent = it.answerFree && it.answerFree.trim() ? "已填写" : "未填写";
596
+ head.appendChild(left);
597
+ head.appendChild(right);
598
+
599
+ const title = document.createElement("div");
600
+ title.className = "qtitle";
601
+ title.textContent = it.qText || "";
602
+
603
+ const opt = document.createElement("div");
604
+ opt.className = "opt";
605
+ if (it.options && it.options.length) {
606
+ const name = `oq_${idx}`;
607
+ for (const o of it.options) {
608
+ const wrap = document.createElement("label");
609
+ wrap.className = "o";
610
+ const radio = document.createElement("input");
611
+ radio.type = "radio";
612
+ radio.name = name;
613
+ radio.value = o;
614
+ radio.checked = it.answerPick === o;
615
+ radio.addEventListener("change", () => {
616
+ it.answerPick = o;
617
+ if (!it.answerFree.trim()) it.answerFree = o;
618
+ renderOriginal();
619
+ });
620
+ const span = document.createElement("span");
621
+ span.textContent = o;
622
+ wrap.appendChild(radio);
623
+ wrap.appendChild(span);
624
+ opt.appendChild(wrap);
625
+ }
626
+ } else {
627
+ const k = document.createElement("div");
628
+ k.className = "k";
629
+ k.textContent = "未解析到选项:使用自由文本回答";
630
+ opt.appendChild(k);
631
+ }
632
+
633
+ const answerBox = document.createElement("div");
634
+ const aLabel = document.createElement("label");
635
+ aLabel.textContent = "回答(写回到该问题下的 “- 回答:...” 行)";
636
+ const ta = document.createElement("textarea");
637
+ ta.value = it.answerFree || "";
638
+ ta.placeholder = it.options && it.options.length ? "可直接选上方选项,或在此补充原因/备注" : "请输入回答";
639
+ ta.addEventListener("input", (e) => {
640
+ it.answerFree = e.target.value;
641
+ renderOriginal();
642
+ });
643
+ answerBox.appendChild(aLabel);
644
+ answerBox.appendChild(ta);
645
+
646
+ el.appendChild(head);
647
+ el.appendChild(title);
648
+ el.appendChild(opt);
649
+ el.appendChild(answerBox);
650
+ list.appendChild(el);
651
+ }
652
+ }
653
+
654
+ function escapeHtml(str) {
655
+ return String(str)
656
+ .replaceAll("&", "&amp;")
657
+ .replaceAll("<", "&lt;")
658
+ .replaceAll(">", "&gt;")
659
+ .replaceAll('"', "&quot;")
660
+ .replaceAll("'", "&#039;");
661
+ }
662
+
663
+ function updateStats() {
664
+ const o = document.getElementById("statsOriginal");
665
+ if (!state.original.text) {
666
+ setPill(o, null, "original.md:未加载");
667
+ } else if (!state.original.items.length) {
668
+ setPill(o, "warn", "original.md:未解析到待确认问题");
669
+ } else {
670
+ const answered = state.original.items.filter((x) => (x.answerFree || "").trim()).length;
671
+ setPill(o, answered === state.original.items.length ? "ok" : "warn", `original.md:${answered}/${state.original.items.length} 已填写`);
672
+ }
673
+
674
+ const q = document.getElementById("statsQuestions");
675
+ if (!state.questions.text) {
676
+ setPill(q, null, "questions.md:未加载");
677
+ } else if (!state.questions.items.length) {
678
+ setPill(q, "warn", "questions.md:未解析到条目");
679
+ } else {
680
+ const answered = state.questions.items.filter((x) => (x.answerFree || "").trim()).length;
681
+ setPill(q, answered === state.questions.items.length ? "ok" : "warn", `questions.md:${answered}/${state.questions.items.length} 已填写`);
682
+ }
683
+ }
684
+
685
+ function rebuild() {
686
+ if (state.original.text != null) {
687
+ const parsed = parseOriginalOpenQuestions(state.original.text);
688
+ state.original.items = parsed.items;
689
+ }
690
+ if (state.questions.text != null) {
691
+ const parsed = parseQuestionsMd(state.questions.text);
692
+ state.questions.items = parsed.items;
693
+ state.questions.lang = parsed.lang;
694
+ }
695
+ renderOriginal();
696
+ renderQuestions();
697
+ updateStats();
698
+ document.getElementById("saveOriginal").disabled = !state.original.text;
699
+ document.getElementById("saveQuestions").disabled = !state.questions.text;
700
+ document.getElementById("downloadOriginal").disabled = !state.original.text;
701
+ document.getElementById("downloadQuestions").disabled = !state.questions.text;
702
+ document.getElementById("reload").disabled = !(state.original.text || state.questions.text);
703
+ }
704
+
705
+ function applyQuestionsEdits(md) {
706
+ const lines = md.split(/\r?\n/);
707
+ const parsed = parseQuestionsMd(md);
708
+ const items = parsed.items;
709
+ const lang = parsed.lang;
710
+ const answeredBy = document.getElementById("answeredBy").value.trim();
711
+ const answeredAtInput = document.getElementById("answeredAt").value.trim();
712
+ const answeredAt = answeredAtInput ? answeredAtInput.replace("T", " ") : "";
713
+ const statusAnswered = lang === "en" ? "Answered" : lang === "ja" ? "回答済み" : "已回答";
714
+
715
+ const keyset = items.length ? items[0].keys : null;
716
+ if (!keyset) return md;
717
+
718
+ for (const it of state.questions.items) {
719
+ const target = items.find((x) => x.id === it.id);
720
+ if (!target) continue;
721
+ const answerText = (it.answerFree || "").trim();
722
+ if (!answerText) continue;
723
+ const start = target.startLine;
724
+ const end = target.endLine;
725
+ for (let i = start; i <= end; i++) {
726
+ const line = lines[i];
727
+ const fm = line.match(/^\s*-\s*([^::]+)[::]\s*(.*)\s*$/);
728
+ if (!fm) continue;
729
+ const key = fm[1].trim();
730
+ if (key === keyset.answer) lines[i] = ` - ${keyset.answer}:${answerText}`;
731
+ if (key === keyset.answeredBy && answeredBy) lines[i] = ` - ${keyset.answeredBy}:${answeredBy}`;
732
+ if (key === keyset.answeredAt && answeredAt) lines[i] = ` - ${keyset.answeredAt}:${answeredAt}`;
733
+ if (key === keyset.status) lines[i] = ` - ${keyset.status}:${statusAnswered}`;
734
+ }
735
+ }
736
+ return lines.join("\n");
737
+ }
738
+
739
+ function applyOriginalEdits(md) {
740
+ const lines = md.split(/\r?\n/);
741
+ const parsed = parseOriginalOpenQuestions(md);
742
+ if (!parsed.found) return md;
743
+ const start = parsed.start;
744
+ const end = parsed.end;
745
+ const slice = lines.slice(start, end);
746
+ const items = parseOriginalOpenQuestions(md).items;
747
+ let cursor = 0;
748
+ for (let i = 0; i < slice.length && cursor < state.original.items.length; i++) {
749
+ const line = slice[i];
750
+ const qm = line.match(/^\s*-\s+(.*)\s*$/);
751
+ if (!qm) continue;
752
+ const qText = qm[1].trim();
753
+ if (!qText) continue;
754
+ if (qText.startsWith("Answer:") || qText.startsWith("回答:") || qText.startsWith("回答:")) continue;
755
+ const uiItem = state.original.items[cursor];
756
+ cursor++;
757
+ const answerText = (uiItem.answerFree || "").trim();
758
+ if (!answerText) continue;
759
+ let inserted = false;
760
+ for (let j = i + 1; j < slice.length; j++) {
761
+ if (/^##\s+/.test(slice[j]) || /^#\s+/.test(slice[j]) || /^\s*-\s+/.test(slice[j])) break;
762
+ const ans = slice[j].match(/^\s{2,}-\s*(Answer|回答)\s*[::].*$/i);
763
+ if (ans) {
764
+ slice[j] = ` - 回答:${answerText}`;
765
+ inserted = true;
766
+ break;
767
+ }
768
+ }
769
+ if (!inserted) {
770
+ slice.splice(i + 1, 0, ` - 回答:${answerText}`);
771
+ i++;
772
+ }
773
+ }
774
+ const out = [...lines.slice(0, start), ...slice, ...lines.slice(end)].join("\n");
775
+ return out;
776
+ }
777
+
778
+ async function writeToHandle(handle, text) {
779
+ const writable = await handle.createWritable();
780
+ await writable.write(text);
781
+ await writable.close();
782
+ }
783
+
784
+ function downloadText(filename, text) {
785
+ const blob = new Blob([text], { type: "text/markdown;charset=utf-8" });
786
+ const url = URL.createObjectURL(blob);
787
+ const a = document.createElement("a");
788
+ a.href = url;
789
+ a.download = filename;
790
+ document.body.appendChild(a);
791
+ a.click();
792
+ a.remove();
793
+ URL.revokeObjectURL(url);
794
+ }
795
+
796
+ async function pickFile(kind) {
797
+ const supports = "showOpenFilePicker" in window;
798
+ if (!supports) throw new Error("浏览器不支持文件写入 API,请使用兼容模式");
799
+ const [handle] = await window.showOpenFilePicker({
800
+ multiple: false,
801
+ types: [{ description: "Markdown", accept: { "text/markdown": [".md"], "text/plain": [".md", ".txt"] } }],
802
+ });
803
+ const file = await handle.getFile();
804
+ const text = await file.text();
805
+ state[kind].handle = handle;
806
+ state[kind].name = file.name;
807
+ state[kind].text = text;
808
+ }
809
+
810
+ async function pickFileFallback(kind, file) {
811
+ const text = await file.text();
812
+ state[kind].handle = null;
813
+ state[kind].name = file.name;
814
+ state[kind].text = text;
815
+ }
816
+
817
+ function setStatus(kind, msg, level) {
818
+ const el = document.getElementById(kind === "original" ? "originalStatus" : "questionsStatus");
819
+ el.textContent = msg;
820
+ el.className = "hint";
821
+ if (level === "ok") el.style.color = "rgba(34, 197, 94, 0.95)";
822
+ if (level === "warn") el.style.color = "rgba(245, 158, 11, 0.95)";
823
+ if (level === "bad") el.style.color = "rgba(239, 68, 68, 0.95)";
824
+ }
825
+
826
+ document.getElementById("answeredAt").value = nowLocalInputValue();
827
+
828
+ document.getElementById("pickOriginal").addEventListener("click", async () => {
829
+ try {
830
+ await pickFile("original");
831
+ setStatus("original", `已加载:${state.original.name}`, "ok");
832
+ rebuild();
833
+ } catch (e) {
834
+ setStatus("original", `加载失败:${e.message || e}`, "bad");
835
+ }
836
+ });
837
+ document.getElementById("pickQuestions").addEventListener("click", async () => {
838
+ try {
839
+ await pickFile("questions");
840
+ setStatus("questions", `已加载:${state.questions.name}`, "ok");
841
+ rebuild();
842
+ } catch (e) {
843
+ setStatus("questions", `加载失败:${e.message || e}`, "bad");
844
+ }
845
+ });
846
+
847
+ document.getElementById("pickOriginalFallback").addEventListener("click", () => {
848
+ document.getElementById("fallbackOriginal").click();
849
+ });
850
+ document.getElementById("pickQuestionsFallback").addEventListener("click", () => {
851
+ document.getElementById("fallbackQuestions").click();
852
+ });
853
+ document.getElementById("fallbackOriginal").addEventListener("change", async (e) => {
854
+ const file = e.target.files && e.target.files[0];
855
+ if (!file) return;
856
+ await pickFileFallback("original", file);
857
+ setStatus("original", `已加载(兼容模式):${state.original.name}`, "warn");
858
+ rebuild();
859
+ });
860
+ document.getElementById("fallbackQuestions").addEventListener("change", async (e) => {
861
+ const file = e.target.files && e.target.files[0];
862
+ if (!file) return;
863
+ await pickFileFallback("questions", file);
864
+ setStatus("questions", `已加载(兼容模式):${state.questions.name}`, "warn");
865
+ rebuild();
866
+ });
867
+
868
+ document.getElementById("reload").addEventListener("click", () => rebuild());
869
+
870
+ document.getElementById("saveQuestions").addEventListener("click", async () => {
871
+ try {
872
+ const out = applyQuestionsEdits(state.questions.text || "");
873
+ state.questions.text = out;
874
+ if (state.questions.handle) {
875
+ await writeToHandle(state.questions.handle, out);
876
+ setStatus("questions", `已写回:${state.questions.name}`, "ok");
877
+ } else {
878
+ setStatus("questions", "当前为兼容模式:请点击“下载 questions.md”后手动替换文件", "warn");
879
+ }
880
+ rebuild();
881
+ } catch (e) {
882
+ setStatus("questions", `写回失败:${e.message || e}`, "bad");
883
+ }
884
+ });
885
+ document.getElementById("saveOriginal").addEventListener("click", async () => {
886
+ try {
887
+ const out = applyOriginalEdits(state.original.text || "");
888
+ state.original.text = out;
889
+ if (state.original.handle) {
890
+ await writeToHandle(state.original.handle, out);
891
+ setStatus("original", `已写回:${state.original.name}`, "ok");
892
+ } else {
893
+ setStatus("original", "当前为兼容模式:请点击“下载 original.md”后手动替换文件", "warn");
894
+ }
895
+ rebuild();
896
+ } catch (e) {
897
+ setStatus("original", `写回失败:${e.message || e}`, "bad");
898
+ }
899
+ });
900
+ document.getElementById("downloadQuestions").addEventListener("click", () => {
901
+ const out = applyQuestionsEdits(state.questions.text || "");
902
+ const name = state.questions.name || "questions.md";
903
+ downloadText(name, out);
904
+ });
905
+ document.getElementById("downloadOriginal").addEventListener("click", () => {
906
+ const out = applyOriginalEdits(state.original.text || "");
907
+ const name = state.original.name || "original.md";
908
+ downloadText(name, out);
909
+ });
910
+ </script>
911
+ </body>
912
+ </html>