daily-question 1.0.6 → 2.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/client.js CHANGED
@@ -109,351 +109,119 @@ window.__ModuleLoader__.load({
109
109
  return { text: '未作答', tone: 'idle' }
110
110
  }
111
111
 
112
- /* ============================== LaTeX ============================== */
113
- const LATEX_SYMBOLS = {
114
- alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', varepsilon: 'ε', zeta: 'ζ', eta: 'η',
115
- theta: 'θ', vartheta: 'ϑ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π',
116
- rho: 'ρ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω',
117
- Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ', Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ', Upsilon: 'Υ',
118
- Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
119
- infty: '∞', cdot: '·', times: '×', div: '÷', pm: '±', mp: '∓', ast: '∗', star: '⋆', oplus: '⊕', otimes: '⊗',
120
- leq: '≤', le: '≤', geq: '≥', ge: '≥', neq: '≠', ne: '≠', approx: '≈', equiv: '≡', sim: '∼',
121
- propto: '∝', ll: '≪', gg: '≫', leqslant: '≤', geqslant: '≥',
122
- to: '→', rightarrow: '→', leftarrow: '←', leftrightarrow: '↔', Rightarrow: '⇒', Leftarrow: '⇐',
123
- Leftrightarrow: '⇔', mapsto: '↦', implies: '⟹', iff: '⟺',
124
- partial: '∂', nabla: '∇', forall: '∀', exists: '∃', nexists: '∄', neg: '¬', in: '∈', notin: '∉',
125
- subset: '⊂', subseteq: '⊆', supset: '⊃', supseteq: '⊇', cup: '∪', cap: '∩', emptyset: '∅',
126
- varnothing: '∅', setminus: '∖', land: '∧', lor: '∨',
127
- mid: '∣', nmid: '∤', vert: '|', lvert: '|', rvert: '|',
128
- Vert: '‖', lVert: '‖', rVert: '‖', colon: ':', '|': '‖',
129
- ldots: '…', cdots: '⋯', vdots: '⋮', ddots: '⋱', dots: '…',
130
- angle: '∠', perp: '⊥', parallel: '∥', circ: '∘', degree: '°', prime: '′',
131
- lceil: '⌈', rceil: '⌉', lfloor: '⌊', rfloor: '⌋', langle: '⟨', rangle: '⟩',
132
- ell: 'ℓ', Re: 'ℜ', Im: 'ℑ', hbar: 'ℏ',
133
- therefore: '∴', because: '∵', triangle: '△', square: '□',
134
- bullet: '∙', ni: '∋', backslash: '\\', Box: '□', bigcirc: '○', dagger: '†', ddagger: '‡',
135
- quad: '\u2003', qquad: '\u2003\u2003',
136
- '%': '%', '&': '&', '#': '#', '_': '_', '{': '{', '}': '}',
137
- ' ': ' ', ',': '\u2009', ';': '\u2005', ':': '\u2005', '!': '',
138
- }
139
- const LATEX_FUNCS = new Set(['sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'arcsin', 'arccos', 'arctan',
140
- 'sinh', 'cosh', 'tanh', 'ln', 'log', 'lg', 'exp', 'max', 'min', 'sup', 'inf', 'det', 'gcd', 'lcm',
141
- 'deg', 'dim', 'ker', 'arg', 'Pr'])
142
- const LATEX_BIGOPS = {
143
- sum: '∑', prod: '∏', int: '∫', iint: '∬', iiint: '∭', oint: '∮',
144
- lim: 'lim', limsup: 'lim sup', liminf: 'lim inf', bigcup: '⋃', bigcap: '⋂',
145
- }
146
- const DOUBLE_STRUCK = { R: 'ℝ', N: 'ℕ', Z: 'ℤ', Q: 'ℚ', C: 'ℂ', P: 'ℙ' }
147
-
148
- /** 连写撇号对应的 prime 字符(1~4 阶,更多阶用 4 阶 + 单撇补)。 */
149
- const PRIME_CHARS = { 1: '\u2032', 2: '\u2033', 3: '\u2034', 4: '\u2057' }
150
-
151
- /**
152
- * 数学模式里的字符修正。只处理公式内部的裸文本(\text / \mathrm 走原样,不受影响):
153
- *
154
- * 1. 连字符 `-` 在 LaTeX 数学模式里是**减号**,换成 U+2212 MINUS SIGN。
155
- * 留成 U+002D 的话,Latin Modern Math 会画成一个短横,看着像分隔符。
156
- * 2. 撇号 `'`(含中文输入法常见的 ’)是**导数记号**,换成 U+2032 起的 prime
157
- * 连写:' → ′、'' → ″、''' → ‴、'''' → ⁗。
158
- * 3. ASCII 字母换成 Unicode 数学斜体码位(U+1D434… 大写、U+1D44E… 小写,
159
- * 例外 h → U+210E),Latin Modern Math 与 Cambria Math 都完整覆盖,
160
- * 因此变量是真斜体,而数字、运算符、函数名(\sin 等)保持直立。
161
- */
162
- function toMathText(text) {
163
- return String(text)
164
- .replace(/['\u2018\u2019]+/g, (run) => {
165
- const count = [...run].length
166
- if (count <= 4) return PRIME_CHARS[count]
167
- return '\u2057' + '\u2032'.repeat(count - 4)
168
- })
169
- .replace(/-/g, '\u2212')
170
- .replace(/\*/g, '\u2217')
171
- .replace(/\.{3,}/g, '\u2026')
172
- .replace(/[A-Za-z]/g, (ch) => {
173
- if (ch === 'h') return '\u210E'
174
- const code = ch.charCodeAt(0)
175
- if (code >= 97 && code <= 122) return String.fromCodePoint(0x1d44e + (code - 97))
176
- return String.fromCodePoint(0x1d434 + (code - 65))
177
- })
178
- }
179
-
180
- function readGroup(cur) {
181
- // cur.src[cur.i] === '{'
182
- let depth = 0
183
- let j = cur.i
184
- const start = cur.i + 1
185
- while (j < cur.src.length) {
186
- const c = cur.src[j]
187
- if (c === '\\') { j += 2; continue }
188
- if (c === '{') depth += 1
189
- else if (c === '}') {
190
- depth -= 1
191
- if (depth === 0) {
192
- cur.i = j + 1
193
- return cur.src.slice(start, j)
194
- }
195
- }
196
- j += 1
112
+ /* ============================== 公式(MathJax 3) ============================== */
113
+ // MathJax 的 SVG 输出自带字形路径,不依赖任何字体文件;单文件 bundle 随包分发,
114
+ // 由宿主在 /api/daily-question/vendor/tex-svg.js 提供。
115
+ const MATHJAX_SRC = '/api/daily-question/vendor/tex-svg.js'
116
+ const mathState = { status: 'idle' }
117
+ const mathWatchers = new Set()
118
+
119
+ function notifyMath() {
120
+ for (const watcher of [...mathWatchers]) {
121
+ try { watcher() } catch (error) { /* 单个订阅出错不影响其它 */ }
197
122
  }
198
- cur.i = cur.src.length
199
- return cur.src.slice(start)
200
123
  }
201
124
 
202
- function readOptionalGroup(cur) {
203
- while (cur.i < cur.src.length && cur.src[cur.i] === ' ') cur.i += 1
204
- if (cur.src[cur.i] !== '[') return null
205
- const end = cur.src.indexOf(']', cur.i)
206
- if (end < 0) return null
207
- const body = cur.src.slice(cur.i + 1, end)
208
- cur.i = end + 1
209
- return body
125
+ function subscribeMath(fn) {
126
+ mathWatchers.add(fn)
127
+ return () => { mathWatchers.delete(fn) }
210
128
  }
211
129
 
212
- function readArg(cur) {
213
- while (cur.i < cur.src.length && cur.src[cur.i] === ' ') cur.i += 1
214
- if (cur.src[cur.i] === '{') return latexNodes(readGroup(cur))
215
- const rest = cur.src.slice(cur.i)
216
- const command = /^\\[A-Za-z]+/.exec(rest)
217
- if (command) {
218
- cur.i += command[0].length
219
- return latexNodes(command[0])
130
+ function ensureMathJax() {
131
+ if (mathState.status !== 'idle') return
132
+ mathState.status = 'loading'
133
+ window.MathJax = {
134
+ startup: { typeset: false },
135
+ // 每条公式自带 <defs>,注入 innerHTML 后不依赖全局字体缓存
136
+ svg: { fontCache: 'local', scale: 1, matchFontHeight: false },
220
137
  }
221
- if (rest[0] === '\\') {
222
- cur.i += 2
223
- return latexNodes(rest.slice(0, 2))
138
+ const script = document.createElement('script')
139
+ script.src = MATHJAX_SRC
140
+ script.async = true
141
+ script.onload = () => {
142
+ const startup = window.MathJax && window.MathJax.startup
143
+ const done = () => {
144
+ mathState.status = 'ready'
145
+ ensureMathStylesheet()
146
+ notifyMath()
147
+ }
148
+ const failed = () => { mathState.status = 'failed'; notifyMath() }
149
+ if (startup && startup.promise) startup.promise.then(done, failed)
150
+ else done()
224
151
  }
225
- cur.i += 1
226
- return latexNodes(rest.slice(0, 1))
152
+ script.onerror = () => { mathState.status = 'failed'; notifyMath() }
153
+ document.head.appendChild(script)
227
154
  }
228
155
 
229
- function readLimits(cur) {
230
- let sub = null
231
- let sup = null
232
- for (let guard = 0; guard < 4; guard += 1) {
233
- const save = cur.i
234
- while (cur.i < cur.src.length && cur.src[cur.i] === ' ') cur.i += 1
235
- const c = cur.src[cur.i]
236
- if (c === '_' && !sub) { cur.i += 1; sub = readArg(cur); continue }
237
- if (c === '^' && !sup) { cur.i += 1; sup = readArg(cur); continue }
238
- cur.i = save
239
- break
156
+ /** SVG 输出依赖 MathJax 自己生成的样式表;手动调用 tex2svg 时不会自动插,补一次。 */
157
+ function ensureMathStylesheet() {
158
+ try {
159
+ const mathJax = window.MathJax
160
+ if (!mathJax || typeof mathJax.svgStylesheet !== 'function') return
161
+ if (document.getElementById('MJX-SVG-styles')) return
162
+ const sheet = mathJax.svgStylesheet()
163
+ if (sheet) document.head.appendChild(sheet)
164
+ } catch (error) {
165
+ // 没有样式表也能显示,只是对齐略差,不阻断渲染
240
166
  }
241
- return { sub, sup }
242
167
  }
243
168
 
244
- function readMatrix(cur, env) {
245
- const marker = `\\end{${env}}`
246
- const end = cur.src.indexOf(marker, cur.i)
247
- const body = end < 0 ? cur.src.slice(cur.i) : cur.src.slice(cur.i, end)
248
- cur.i = end < 0 ? cur.src.length : end + marker.length
249
- const rows = body.split(/\\\\/).map((row) => row.trim()).filter((row) => row.length)
250
- const cells = rows.map((row) => row.split('&').map((cell) => latexNodes(cell.trim())))
251
- const table = h('table', { className: 'kd-matrix', key: nextKey('mx') },
252
- h('tbody', null, cells.map((row, rowIndex) => h('tr', { key: rowIndex },
253
- row.map((cell, cellIndex) => h('td', { key: cellIndex }, cell))))))
254
- const wrapCls = env === 'cases' ? 'kd-cases'
255
- : env === 'bmatrix' ? 'kd-bmatrix'
256
- : env === 'vmatrix' ? 'kd-vmatrix'
257
- : env === 'pmatrix' ? 'kd-pmatrix' : 'kd-matrixwrap'
258
- const open = env === 'cases' ? '{' : env === 'bmatrix' ? '[' : env === 'pmatrix' ? '(' : ''
259
- const close = env === 'bmatrix' ? ']' : env === 'pmatrix' ? ')' : ''
260
- const children = []
261
- if (open) children.push(h('span', { className: 'kd-delim', key: 'open' }, open))
262
- children.push(table)
263
- if (close) children.push(h('span', { className: 'kd-delim', key: 'close' }, close))
264
- return h('span', { className: `kd-matrixbox ${wrapCls}`, key: nextKey('mb') }, children)
265
- }
266
-
267
- function latexNodes(src) {
268
- const cur = { src: String(src || ''), i: 0 }
269
- return latexSeq(cur, '')
270
- }
271
-
272
- function latexSeq(cur, stopChars) {
273
- const out = []
274
- let buf = ''
275
- const flush = () => {
276
- if (buf) {
277
- out.push(toMathText(buf))
278
- buf = ''
279
- }
280
- }
281
- while (cur.i < cur.src.length) {
282
- const c = cur.src[cur.i]
283
- if (stopChars && stopChars.includes(c)) break
284
- if (c === '}') { buf += c; cur.i += 1; continue }
285
- if (c === '{') {
286
- flush()
287
- out.push(...latexNodes(readGroup(cur)))
288
- continue
289
- }
290
- if (c === '^' || c === '_') {
291
- flush()
292
- cur.i += 1
293
- const arg = readArg(cur)
294
- out.push(h(c === '^' ? 'sup' : 'sub', { className: c === '^' ? 'kd-sup' : 'kd-sub', key: nextKey('sc') }, arg))
295
- continue
296
- }
297
- if (c === '&') {
298
- flush()
299
- cur.i += 1
300
- out.push(h('span', { className: 'kd-colsep', key: nextKey('cs') }, '\u2003'))
301
- continue
302
- }
303
- if (c === '~') {
304
- buf += '\u00a0'
305
- cur.i += 1
306
- continue
307
- }
308
- if (c === '\\') {
309
- const rest = cur.src.slice(cur.i)
310
- const command = /^\\([A-Za-z]+)/.exec(rest)
311
- if (command) {
312
- flush()
313
- cur.i += command[0].length
314
- const node = latexCommand(cur, command[1])
315
- if (node !== null && node !== undefined) out.push(node)
316
- continue
317
- }
318
- const symbol = rest[1]
319
- cur.i += symbol === undefined ? 1 : 2
320
- if (symbol === '\\') {
321
- flush()
322
- out.push(h('br', { key: nextKey('br') }))
323
- continue
324
- }
325
- if (LATEX_SYMBOLS[symbol] !== undefined) buf += LATEX_SYMBOLS[symbol]
326
- else if (symbol !== undefined) buf += symbol
327
- continue
328
- }
329
- if (c === ' ') { buf += ' '; cur.i += 1; continue }
330
- buf += c
331
- cur.i += 1
332
- }
333
- flush()
334
- return out
169
+ /** 让组件跟着公式引擎的加载状态重渲染。 */
170
+ function useMathJaxStatus() {
171
+ const [status, setStatus] = useState(mathState.status)
172
+ useEffect(() => {
173
+ ensureMathJax()
174
+ const update = () => setStatus(mathState.status)
175
+ update()
176
+ return subscribeMath(update)
177
+ }, [])
178
+ return status
335
179
  }
336
180
 
337
- function latexCommand(cur, name) {
338
- if (LATEX_BIGOPS[name]) {
339
- const { sub, sup } = readLimits(cur)
340
- const symbol = LATEX_BIGOPS[name]
341
- return h('span', { className: 'kd-op', key: nextKey('op') },
342
- sup ? h('span', { className: 'kd-op-lim', key: 'up' }, sup) : null,
343
- h('span', { className: 'kd-op-sym', key: 'sym' }, symbol),
344
- sub ? h('span', { className: 'kd-op-lim', key: 'down' }, sub) : null)
345
- }
346
- if (name === 'frac' || name === 'dfrac' || name === 'tfrac' || name === 'cfrac') {
347
- const numerator = readArg(cur)
348
- const denominator = readArg(cur)
349
- return h('span', { className: 'kd-frac', key: nextKey('fr') },
350
- h('span', { className: 'kd-frac-n', key: 'n' }, numerator),
351
- h('span', { className: 'kd-frac-d', key: 'd' }, denominator))
352
- }
353
- // \boxed{...}(amsmath 常用的“把答案框起来”)画一个方框
354
- if (name === 'boxed' || name === 'fbox' || name === 'framebox') {
355
- return h('span', { className: 'kd-boxed', key: nextKey('bx') }, readArg(cur))
356
- }
357
- // \binom{n}{k}:无分数线的上下堆叠 + 圆括号
358
- if (name === 'binom' || name === 'dbinom' || name === 'tbinom') {
359
- const top = readArg(cur)
360
- const bottom = readArg(cur)
361
- return h('span', { className: 'kd-binom', key: nextKey('bn') },
362
- h('span', { className: 'kd-delim', key: 'l' }, '('),
363
- h('span', { className: 'kd-frac kd-binom-body', key: 'b' },
364
- h('span', { className: 'kd-frac-n', key: 'n' }, top),
365
- h('span', { className: 'kd-frac-d', key: 'd' }, bottom)),
366
- h('span', { className: 'kd-delim', key: 'r' }, ')'))
367
- }
368
- if (name === 'sqrt') {
369
- const index = readOptionalGroup(cur)
370
- const body = readArg(cur)
371
- return h('span', { className: 'kd-sqrt', key: nextKey('sq') },
372
- h('span', { className: 'kd-sqrt-sign', key: 's' }, '√'),
373
- index ? h('sup', { className: 'kd-sqrt-index', key: 'i' }, latexNodes(index)) : null,
374
- h('span', { className: 'kd-sqrt-body', key: 'b' }, body))
375
- }
376
- if (name === 'text' || name === 'textrm' || name === 'mbox' || name === 'mathrm' || name === 'operatorname') {
377
- const raw = readGroup(cur)
378
- return h('span', { className: 'kd-roman', key: nextKey('rm') }, raw.replace(/\\([,;: !])/g, ' '))
379
- }
380
- if (name === 'mathbf' || name === 'boldsymbol' || name === 'bm' || name === 'textbf') {
381
- return h('strong', { className: 'kd-bold', key: nextKey('bd') }, latexNodes(readGroup(cur)))
382
- }
383
- if (name === 'mathbb' || name === 'mathcal' || name === 'mathscr') {
384
- const raw = readGroup(cur).trim()
385
- return h('span', { className: 'kd-blackboard', key: nextKey('bb') }, DOUBLE_STRUCK[raw] || raw)
386
- }
387
- if (name === 'overline' || name === 'bar' || name === 'underline' || name === 'vec'
388
- || name === 'hat' || name === 'widehat' || name === 'tilde' || name === 'dot' || name === 'ddot') {
389
- const body = readArg(cur)
390
- return h('span', { className: `kd-accent kd-accent-${name}`, key: nextKey('ac') }, body)
391
- }
392
- if (name === 'overrightarrow' || name === 'overleftarrow') {
393
- return h('span', { className: `kd-accent kd-accent-${name}`, key: nextKey('ac') }, readArg(cur))
394
- }
395
- // \underbrace{x}_{说明} / \overbrace{x}^{说明}:花括号画不出来,保留内容与标记
396
- if (name === 'underbrace' || name === 'overbrace') {
397
- return h('span', { className: 'kd-group', key: nextKey('grp') }, readArg(cur))
398
- }
399
- // \overset{上}{基} / \underset{下}{基}:上下堆叠
400
- if (name === 'overset' || name === 'underset') {
401
- const mark = readArg(cur)
402
- const base = readArg(cur)
403
- return h('span', {
404
- className: `kd-stack kd-stack-${name === 'overset' ? 'over' : 'under'}`,
405
- key: nextKey('st'),
406
- },
407
- name === 'overset' ? h('span', { className: 'kd-stack-mark', key: 'm' }, mark) : null,
408
- h('span', { className: 'kd-stack-base', key: 'b' }, base),
409
- name === 'underset' ? h('span', { className: 'kd-stack-mark', key: 'm' }, mark) : null)
410
- }
411
- if (name === 'left' || name === 'right' || name === 'middle') {
412
- while (cur.i < cur.src.length && cur.src[cur.i] === ' ') cur.i += 1
413
- const rest = cur.src.slice(cur.i)
414
- const command = /^\\[A-Za-z]+/.exec(rest)
415
- let token
416
- if (command) { token = command[0]; cur.i += command[0].length } else { token = rest[0] || ''; cur.i += 1 }
417
- const display = token === '.' ? '' : (LATEX_SYMBOLS[token.replace(/^\\/, '')] || token.replace(/^\\/, ''))
418
- return h('span', { className: 'kd-delim', key: nextKey('dl') }, display)
419
- }
420
- if (name === 'begin') {
421
- const env = readGroup(cur).trim()
422
- return readMatrix(cur, env)
423
- }
424
- if (name === 'displaystyle' || name === 'textstyle' || name === 'limits' || name === 'nolimits' || name === 'notag'
425
- || name === 'nonumber' || name === 'label' || name === 'tag') {
426
- // \tag{...} 之类的排版修饰我们不实现,但也不能把内容原样吐到公式里
427
- if (name === 'tag' || name === 'label') readGroup(cur)
428
- return null
429
- }
430
- // \big( \Big[ \bigl\{ … 只是放大定界符,实际字符由后面的记号自己渲染
431
- if (/^(?:big|Big|bigg|Bigg)[lrm]?$/.test(name)) return null
432
- if (name === 'bmod') return h('span', { className: 'kd-roman', key: nextKey('bm') }, 'mod')
433
- if (name === 'pmod') {
434
- return h('span', { className: 'kd-roman', key: nextKey('pm') }, '(', 'mod ',
435
- ...readArg(cur), ')')
181
+ /**
182
+ * 规范化模型输出的 TeX:
183
+ * - 去掉自带的 $ / $$ 包裹;
184
+ * - 过滤能改页面结构或注入链接的宏(公式来自模型输出,不当作可信内容);
185
+ * - 公式块里的裸 \\ 换行在 MathJax 3 需要放进 \displaylines(Typora 也是这么处理的);
186
+ * - 其余交给 MathJax,不再自己做子集解析。
187
+ */
188
+ const RISKY_TEX = [
189
+ /\\href\s*\{[^{}]*\}/g,
190
+ /\\url\s*\{[^{}]*\}/g,
191
+ /\\(?:style|class|cssId|htmlId|htmlClass|htmlStyle)\s*\{[^{}]*\}/g,
192
+ /\\require\s*\{[^{}]*\}/g,
193
+ /\\includegraphics\s*(?:\[[^\]]*\])?\s*\{[^{}]*\}/g,
194
+ ]
195
+
196
+ function normalizeTeX(source, display) {
197
+ let tex = String(source || '').trim()
198
+ tex = tex.replace(/^\${1,2}/, '').replace(/\${1,2}$/, '')
199
+ for (const pattern of RISKY_TEX) tex = tex.replace(pattern, '')
200
+ if (display && /\\\\/.test(tex) && !/\\displaylines/.test(tex) && !/\\begin\{/.test(tex)) {
201
+ tex = `\\displaylines{${tex}}`
436
202
  }
437
- // \hline 只用于表格横线;矩阵里没有对应结构,直接忽略而不是吐字面量
438
- if (name === 'hline') return null
439
- if (name === 'space' || name === 'newline') return h('br', { key: nextKey('br') })
440
- if (LATEX_FUNCS.has(name)) return h('span', { className: 'kd-roman', key: nextKey('fn') }, name)
441
- if (LATEX_SYMBOLS[name] !== undefined) return LATEX_SYMBOLS[name]
442
- // 未知命令:保留原始内容并提示,绝不丢弃。
443
- return h('span', { className: 'kd-latex-unknown', title: `未知的 LaTeX 命令:\\${name}`, key: nextKey('un') }, `\\${name}`)
203
+ return tex
444
204
  }
445
205
 
446
206
  function renderMath(source, display) {
447
- let nodes
207
+ const tex = normalizeTeX(source, display)
208
+ const key = nextKey('math')
209
+ if (mathState.status !== 'ready') {
210
+ // 引擎还没就绪(或加载失败):先显示原文,就绪后组件会重渲染
211
+ return h('span', { className: 'kd-math-pending', key, title: '公式引擎正在加载' }, tex)
212
+ }
448
213
  try {
449
- nodes = latexNodes(source)
214
+ const node = window.MathJax.tex2svg(tex, { display: Boolean(display) })
215
+ const html = node && node.outerHTML
216
+ if (!html) throw new Error('empty svg')
217
+ return h(display ? 'div' : 'span', {
218
+ className: display ? 'kd-mathblock' : 'kd-math',
219
+ key,
220
+ dangerouslySetInnerHTML: { __html: html },
221
+ })
450
222
  } catch (error) {
451
- nodes = [source]
223
+ return h('span', { className: 'kd-math-error', key, title: '公式无法渲染' }, tex)
452
224
  }
453
- return h(display ? 'div' : 'span', {
454
- className: display ? 'kd-mathblock' : 'kd-math',
455
- key: nextKey('math'),
456
- }, nodes)
457
225
  }
458
226
 
459
227
  /* ============================== Markdown ============================== */
@@ -484,6 +252,10 @@ window.__ModuleLoader__.load({
484
252
  })
485
253
  }
486
254
 
255
+ /** Markdown 里有转义意义的字符:只有这些才允许把反斜杠吃掉。 */
256
+ const MD_ESCAPABLE = new Set(['\\', '`', '*', '_', '{', '}', '[', ']', '(', ')',
257
+ '#', '+', '-', '.', '!', '>', '|', '~', '<', '$', '"', "'"])
258
+
487
259
  function inlineNodes(text) {
488
260
  const out = []
489
261
  let buffer = ''
@@ -504,8 +276,16 @@ window.__ModuleLoader__.load({
504
276
  }
505
277
  }
506
278
  if (c === '\\' && index + 1 < text.length) {
507
- buffer += text[index + 1]
508
- index += 2
279
+ const next = text[index + 1]
280
+ // 只有 Markdown 里有意义的字符才去掉反斜杠;其余原样保留,
281
+ // 免得把 \nabla、\neq 这类命令悄悄变成 "nabla"、"neq"。
282
+ if (MD_ESCAPABLE.has(next)) {
283
+ buffer += next
284
+ index += 2
285
+ continue
286
+ }
287
+ buffer += c
288
+ index += 1
509
289
  continue
510
290
  }
511
291
  if (c === '`') {
@@ -634,7 +414,16 @@ window.__ModuleLoader__.load({
634
414
  }
635
415
 
636
416
  function renderBlocks(source) {
637
- const lines = String(source || '').replace(/\r\n?/g, '\n').split('\n')
417
+ // 模型偶尔把换行写成字面量 `\n`(JSON 里多转义了一层),这里先还原成真换行,
418
+ // 否则会渲染成一串多余的字母 n,段落结构也会塌成一行。
419
+ // 用 (?!\w) 保证不会误伤 \nabla / \neq / \theta / \times 这类命令。
420
+ const normalized = String(source || '')
421
+ .replace(/\\r\\n/g, '\n')
422
+ .replace(/\\n(?!\w)/g, '\n')
423
+ .replace(/\\r(?!\w)/g, '\n')
424
+ .replace(/\\t(?!\w)/g, ' ')
425
+ .replace(/\r\n?/g, '\n')
426
+ const lines = normalized.split('\n')
638
427
  const out = []
639
428
  let index = 0
640
429
  while (index < lines.length) {
@@ -738,13 +527,15 @@ window.__ModuleLoader__.load({
738
527
  }
739
528
 
740
529
  function Markdown({ text, className }) {
530
+ // 公式引擎加载完成后要重渲染一次,否则首次绘制出来的还是 TeX 原文
531
+ const mathStatus = useMathJaxStatus()
741
532
  const nodes = useMemo(() => {
742
533
  try {
743
534
  return renderBlocks(text)
744
535
  } catch (error) {
745
536
  return [h('pre', { className: 'kd-code', key: 'raw' }, String(text || ''))]
746
537
  }
747
- }, [text])
538
+ }, [text, mathStatus])
748
539
  return h('div', { className: `kd-md ${className || ''}` }, nodes)
749
540
  }
750
541
 
@@ -752,245 +543,273 @@ window.__ModuleLoader__.load({
752
543
  /** 由 apply 用 styles.insert 注入时置位;否则由组件渲染 <style> 兜底。 */
753
544
  let styleInjected = false
754
545
  const CSS = `
755
- @font-face {
756
- font-family: 'KD Latin Modern Math';
757
- src: local('Latin Modern Math'), local('LatinModernMath'), local('LatinModernMath-Regular'),
758
- url('/api/daily-question/font/latinmodern-math.otf') format('opentype');
759
- font-display: swap;
760
- }
761
- .kd-page { height:100%; min-height:0; display:flex; flex-direction:column; position:relative;
762
- background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary);
546
+ .kd-page {
547
+ --kd-bg-base:var(--dsw-alias-bg-base, #ffffff);
548
+ --kd-bg-layer-1:var(--dsw-alias-bg-layer-1, #ffffff);
549
+ --kd-bg-layer-2:color-mix(in srgb, var(--kd-ink) 5%, var(--kd-surface));
550
+ --kd-bg-overlay:var(--dsw-alias-bg-overlay, #ffffff);
551
+ --kd-label-primary:var(--dsw-alias-label-primary, #24332f);
552
+ --kd-label-secondary:var(--dsw-alias-label-secondary, #66736e);
553
+ --kd-border-l1:color-mix(in srgb, var(--kd-ink) 9%, var(--kd-surface));
554
+ --kd-border-l2:color-mix(in srgb, var(--kd-ink) 17%, var(--kd-surface));
555
+ --kd-brand-primary:#4d6bfe;
556
+ --kd-state-success-primary:#078443;
557
+ --kd-state-error-primary:#db3144;
558
+ --kd-state-warn-primary:#95702b;
559
+ --kd-state-idle-primary:#85918b;
560
+ --kd-ink:var(--kd-label-primary); --kd-surface:var(--kd-bg-base);
561
+ font-family:inherit; isolation:isolate; height:100%; min-height:0; display:flex; flex-direction:column; position:relative;
562
+ background:color-mix(in srgb, var(--kd-brand-primary) 2%, var(--kd-surface)); color: var(--kd-label-primary);
763
563
  font-size:14px; line-height:1.65; -webkit-font-smoothing:antialiased; box-sizing:border-box; }
764
564
  .kd-page *, .kd-page *::before, .kd-page *::after { box-sizing:border-box; }
765
- .kd-page :focus-visible { outline:2px solid var(--dsw-alias-brand-primary); outline-offset:2px; }
766
- .kd-header { padding:18px 24px 14px; border-bottom:1px solid var(--dsw-alias-border-l1);
565
+ .kd-page :focus-visible { outline:2px solid var(--kd-brand-primary); outline-offset:2px; }
566
+ .kd-header { padding:20px 28px 12px; background:var(--kd-surface); border-bottom:1px solid var(--kd-border-l1);
767
567
  display:flex; flex-direction:column; gap:12px; }
768
- .kd-header-top { display:flex; gap:24px; justify-content:space-between; align-items:flex-start; flex-wrap:wrap; }
769
- .kd-header-left { min-width:0; flex:1 1 320px; display:flex; gap:12px; align-items:flex-start; }
770
- .kd-brand { flex:0 0 34px; width:34px; height:34px; border-radius:10px; display:flex; align-items:center;
771
- justify-content:center; border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-1);
772
- color:var(--dsw-alias-brand-primary); }
773
- .kd-title { margin:0; font-size:18px; font-weight:650; letter-spacing:.01em; line-height:1.4; }
774
- .kd-sub { margin-top:3px; font-size:12px; color:var(--dsw-alias-label-secondary); line-height:1.6; }
568
+ .kd-header-top { display:flex; gap:20px; justify-content:space-between; align-items:center; flex-wrap:wrap; }
569
+ .kd-header-left { min-width:0; flex:1 1 320px; display:flex; gap:12px; align-items:center; }
570
+ .kd-brand { flex:0 0 40px; width:40px; height:40px; border-radius:12px; display:flex; align-items:center;
571
+ justify-content:center; border:0; background:color-mix(in srgb, var(--kd-brand-primary) 10%, var(--kd-surface));
572
+ color:var(--kd-brand-primary); }
573
+ .kd-title { margin:0; font-size:19px; font-weight:650; letter-spacing:.01em; line-height:1.4; }
574
+ .kd-sub { margin-top:3px; font-size:12px; color:var(--kd-label-secondary); line-height:1.6; }
775
575
  .kd-select { height:30px; padding:0 10px; border-radius:8px; font-size:13px; font-family:inherit;
776
- border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-1);
777
- color:var(--dsw-alias-label-primary); transition:border-color .15s; }
778
- .kd-select:hover { border-color:var(--dsw-alias-brand-primary); }
576
+ border:1px solid var(--kd-border-l2); background:var(--kd-bg-layer-1);
577
+ color:var(--kd-label-primary); transition:border-color .15s; }
578
+ .kd-select:hover { border-color:var(--kd-brand-primary); }
779
579
  .kd-select-sm { height:26px; font-size:12px; padding:0 6px; }
780
580
  .kd-cd-lines { min-width:0; flex:0 1 auto; display:flex; flex-direction:column; align-items:flex-end;
781
581
  gap:2px; text-align:right; }
782
- .kd-cd-primary { font-size:24px; font-weight:700; letter-spacing:.01em; line-height:1.25; }
783
- .kd-cd-secondary { font-size:12px; color:var(--dsw-alias-label-secondary); }
784
- .kd-cd-target { font-size:13.5px; font-weight:500; }
785
- .kd-exam-strip { display:flex; align-items:center; gap:6px 14px; flex-wrap:wrap; padding:8px 12px;
786
- border:1px solid var(--dsw-alias-border-l1); border-radius:10px; background:var(--dsw-alias-bg-layer-1);
787
- font-size:11.5px; color:var(--dsw-alias-label-secondary); }
582
+ .kd-cd-primary { font-size:21px; font-weight:650; color:var(--kd-brand-primary); font-variant-numeric:tabular-nums; letter-spacing:.01em; line-height:1.25; }
583
+ .kd-cd-secondary { font-size:12px; color:var(--kd-label-secondary); }
584
+ .kd-cd-target { font-size:11px; font-weight:400; color:var(--kd-label-secondary); }
585
+ .kd-exam-strip { display:flex; align-items:center; gap:6px 14px; flex-wrap:wrap; padding:8px 0 0;
586
+ border:0; border-top:1px solid var(--kd-border-l1); background:transparent;
587
+ font-size:11.5px; color:var(--kd-label-secondary); }
788
588
  .kd-strip-item { display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
789
- .kd-strip-warn { color:var(--dsw-alias-state-warn-primary); white-space:normal; text-align:left; }
589
+ .kd-strip-warn { color:var(--kd-state-warn-primary); white-space:normal; text-align:left; }
790
590
  .kd-shell { flex:1 1 auto; min-height:0; display:flex; }
791
- .kd-nav { width:212px; flex:0 0 212px; border-right:1px solid var(--dsw-alias-border-l1);
792
- padding:16px 12px; overflow:auto; display:flex; flex-direction:column; gap:18px; }
591
+ .kd-nav { width:204px; flex:0 0 204px; background:var(--kd-surface); border-right:1px solid var(--kd-border-l1);
592
+ padding:24px 14px; overflow:auto; display:flex; flex-direction:column; gap:18px; }
793
593
  .kd-nav-group { display:flex; flex-direction:column; gap:4px; }
794
- .kd-nav-group-label { font-size:10.5px; letter-spacing:.1em; color:var(--dsw-alias-label-secondary);
594
+ .kd-nav-group-label { font-size:10.5px; letter-spacing:.1em; color:var(--kd-label-secondary);
795
595
  padding:0 8px 4px; }
796
- .kd-nav-item { position:relative; display:flex; align-items:center; justify-content:space-between; gap:8px;
797
- width:100%; padding:8px 10px; border-radius:9px; border:1px solid transparent; background:transparent;
798
- cursor:pointer; color:var(--dsw-alias-label-primary); font:inherit; font-size:13px; text-align:left;
596
+ .kd-nav-item { position:relative; display:flex; align-items:center; justify-content:space-between; gap:7px; flex-direction:column; align-items:flex-start;
597
+ width:100%; padding:12px; border-radius:9px; border:1px solid transparent; background:transparent;
598
+ cursor:pointer; color:var(--kd-label-primary); font:inherit; font-size:13px; text-align:left;
799
599
  transition:background-color .15s, border-color .15s, color .15s; }
800
- .kd-nav-item:hover { background:var(--dsw-alias-bg-layer-2); }
801
- .kd-nav-item > span:first-child { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
802
- .kd-nav-item.is-active { background:var(--dsw-alias-bg-layer-2); border-color:var(--dsw-alias-border-l2);
803
- font-weight:600; box-shadow:inset 2px 0 0 var(--dsw-alias-brand-primary); }
600
+ .kd-nav-item:hover { background:var(--kd-bg-layer-2); }
601
+ .kd-nav-item > span:first-child { min-width:0; overflow-wrap:anywhere; line-height:1.6; }
602
+ .kd-nav-item.is-active { background:color-mix(in srgb, var(--kd-brand-primary) 9%, var(--kd-surface)); border-color:transparent;
603
+ color:var(--kd-brand-primary); font-weight:600; }
804
604
  .kd-nav-item .kd-nav-count { flex:0 0 auto; white-space:nowrap; font-size:11px;
805
- color:var(--dsw-alias-label-secondary); }
605
+ color:var(--kd-label-secondary); }
806
606
  .kd-nav-status { flex:0 0 auto; display:inline-flex; align-items:center; gap:4px; white-space:nowrap;
807
- font-size:10.5px; color:var(--dsw-alias-label-secondary); }
808
- .kd-dot { width:6px; height:6px; border-radius:50%; background:var(--dsw-alias-state-success-primary); }
809
- .kd-dot-idle { background:var(--dsw-alias-state-idle-primary); }
607
+ font-size:10.5px; color:var(--kd-label-secondary); }
608
+ .kd-dot { width:6px; height:6px; border-radius:50%; background:var(--kd-state-success-primary); }
609
+ .kd-dot-idle { background:var(--kd-state-idle-primary); }
810
610
  .kd-nav-spacer { flex:1 1 auto; }
811
- .kd-main { flex:1 1 auto; min-width:0; overflow:auto; padding:20px 24px 48px; }
812
- .kd-main-inner { max-width:900px; margin:0 auto; }
813
- .kd-thread { width:340px; flex:0 0 340px; border-left:1px solid var(--dsw-alias-border-l1);
814
- display:flex; flex-direction:column; min-height:0; background:var(--dsw-alias-bg-layer-1); }
815
- .kd-thread-head { padding:13px 16px; border-bottom:1px solid var(--dsw-alias-border-l1);
611
+ .kd-main { flex:1 1 auto; min-width:0; overflow:hidden; display:flex; flex-direction:column; }
612
+ .kd-main-toolbar { flex:none; padding:24px 28px 20px; display:flex; align-items:center; gap:12px; }
613
+ .kd-main-toolbar > .kd-btn { flex:none; align-self:stretch; aspect-ratio:1; padding:0; min-height:0; white-space:nowrap; border-radius:12px; }
614
+ .kd-main-scroll { flex:1; min-height:0; overflow:auto; padding:0 28px 48px; scrollbar-gutter:stable; }
615
+ .kd-main-inner { width:100%; margin:0; }
616
+ .kd-thread { position:relative; width:clamp(280px,calc((100% - 204px) * var(--kd-thread-ratio, .33)),55%); flex:0 0 auto; border-left:1px solid var(--kd-border-l1);
617
+ display:flex; flex-direction:column; min-height:0; background:var(--kd-bg-layer-1); }
618
+ .kd-thread-resize { position:absolute; left:-5px; top:0; bottom:0; width:10px; cursor:col-resize; touch-action:none; z-index:2; }
619
+ .kd-thread-resize:hover, .kd-thread-resize:focus-visible { background:color-mix(in srgb,var(--kd-brand-primary) 20%,transparent); }
620
+ .kd-thread-head { padding:20px; border-bottom:1px solid var(--kd-border-l1);
816
621
  display:flex; align-items:center; justify-content:space-between; gap:8px; }
817
622
  .kd-thread-title { font-size:13px; font-weight:600; }
818
- .kd-thread-hint { padding:10px 16px 0; font-size:11px; line-height:1.6;
819
- color:var(--dsw-alias-label-secondary); }
820
- .kd-thread-scroll { flex:1 1 auto; min-height:0; overflow:auto; padding:14px 16px; display:flex;
623
+ .kd-thread-hint { padding:12px 20px 0; font-size:11px; line-height:1.6;
624
+ color:var(--kd-label-secondary); }
625
+ .kd-thread-scroll { flex:1 1 auto; min-height:0; overflow:auto; padding:18px 20px; display:flex;
821
626
  flex-direction:column; gap:12px; }
822
627
  .kd-msg { max-width:100%; border-radius:12px; padding:10px 12px; font-size:13px; line-height:1.72; }
823
- .kd-msg-user { align-self:flex-end; background:color-mix(in srgb, var(--dsw-alias-brand-primary) 9%, transparent);
824
- border:1px solid color-mix(in srgb, var(--dsw-alias-brand-primary) 26%, transparent); }
825
- .kd-msg-assistant { align-self:stretch; border:1px solid var(--dsw-alias-border-l1);
826
- background:var(--dsw-alias-bg-base); }
827
- .kd-msg-meta { font-size:10px; color:var(--dsw-alias-label-secondary); margin-top:6px; }
828
- .kd-msg-failed { border-color:var(--dsw-alias-state-error-primary); }
829
- .kd-thread-foot { border-top:1px solid var(--dsw-alias-border-l1); padding:12px 16px; }
830
- .kd-composer { border:1px solid var(--dsw-alias-border-l2); border-radius:12px;
831
- background:var(--dsw-alias-bg-base); padding:6px; transition:border-color .15s, box-shadow .15s; }
832
- .kd-composer:focus-within { border-color:var(--dsw-alias-brand-primary);
833
- box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent); }
628
+ .kd-msg-user { align-self:flex-end; background:color-mix(in srgb, var(--kd-brand-primary) 9%, transparent);
629
+ border:1px solid color-mix(in srgb, var(--kd-brand-primary) 26%, transparent); }
630
+ .kd-msg-assistant { align-self:stretch; padding:0; border:0; background:transparent; }
631
+ .kd-msg-meta { font-size:10px; color:var(--kd-label-secondary); margin-top:6px; }
632
+ .kd-msg-failed { border-color:var(--kd-state-error-primary); }
633
+ .kd-thread-foot { border-top:1px solid var(--kd-border-l1); padding:12px 16px; }
634
+ .kd-composer { border:1px solid var(--kd-border-l2); border-radius:12px;
635
+ background:var(--kd-bg-base); padding:6px; transition:border-color .15s, box-shadow .15s; }
636
+ .kd-composer:focus-within { border-color:var(--kd-brand-primary);
637
+ box-shadow:0 0 0 3px color-mix(in srgb, var(--kd-brand-primary) 14%, transparent); }
834
638
  .kd-textarea { width:100%; min-height:58px; max-height:180px; resize:vertical; padding:6px 8px;
835
- border:0; background:transparent; color:var(--dsw-alias-label-primary); font:inherit; font-size:13px; }
639
+ border:0; background:transparent; color:var(--kd-label-primary); font:inherit; font-size:13px; }
836
640
  .kd-textarea:focus { outline:none; }
837
- .kd-tabs { display:flex; gap:2px; margin-bottom:16px; border-bottom:1px solid var(--dsw-alias-border-l1); }
838
- .kd-tab { position:relative; display:inline-flex; align-items:center; gap:6px; padding:8px 12px 11px;
839
- border:0; background:transparent; color:var(--dsw-alias-label-secondary); font:inherit; font-size:13px;
840
- cursor:pointer; border-radius:8px 8px 0 0; transition:color .15s, background-color .15s; }
841
- .kd-tab:hover { color:var(--dsw-alias-label-primary); background:var(--dsw-alias-bg-layer-2); }
842
- .kd-tab.is-active { color:var(--dsw-alias-label-primary); font-weight:600; }
843
- .kd-tab.is-active::after { content:''; position:absolute; left:10px; right:10px; bottom:-1px; height:2px;
844
- border-radius:2px; background:var(--dsw-alias-brand-primary); }
641
+ .kd-tabs { display:flex; gap:4px; width:fit-content; max-width:100%; min-width:0; overflow-x:auto; padding:4px; border:1px solid var(--kd-border-l1); border-radius:12px; background:var(--kd-surface); }
642
+ .kd-tab { position:relative; display:inline-flex; align-items:center; gap:6px; padding:7px 14px; white-space:nowrap;
643
+ border:0; background:transparent; color:var(--kd-label-secondary); font:inherit; font-size:13px;
644
+ cursor:pointer; border-radius:8px; transition:color .15s, background-color .15s; }
645
+ .kd-tab:hover { color:var(--kd-label-primary); background:var(--kd-bg-layer-2); }
646
+ .kd-tab.is-active { color:var(--kd-brand-primary); font-weight:600; background:color-mix(in srgb, var(--kd-brand-primary) 10%, var(--kd-surface)); }
647
+
845
648
  .kd-tab-count { min-width:17px; padding:0 5px; border-radius:999px; font-size:10.5px; line-height:17px;
846
- text-align:center; background:var(--dsw-alias-bg-layer-2); color:var(--dsw-alias-label-secondary); }
847
- .kd-tab.is-active .kd-tab-count { color:var(--dsw-alias-brand-primary); }
848
- .kd-card { border:1px solid var(--dsw-alias-border-l1); border-radius:14px;
849
- background:var(--dsw-alias-bg-layer-1); padding:20px 22px; }
649
+ text-align:center; background:var(--kd-bg-layer-2); color:var(--kd-label-secondary); }
650
+ .kd-tab.is-active .kd-tab-count { color:var(--kd-brand-primary); }
651
+ .kd-card { border:1px solid var(--kd-border-l1); border-radius:14px;
652
+ background:var(--kd-bg-layer-1); padding:28px; box-shadow:0 4px 24px rgb(20 45 35 / 3%); }
850
653
  .kd-card + .kd-card { margin-top:14px; }
851
654
  .kd-meta { display:flex; gap:8px; flex-wrap:wrap; align-items:center; font-size:12px;
852
- color:var(--dsw-alias-label-secondary); }
655
+ color:var(--kd-label-secondary); }
853
656
  .kd-meta-dot::before { content:'·'; margin-right:8px; }
854
657
  .kd-chips { display:flex; gap:6px; flex-wrap:wrap; align-items:center; }
855
658
  .kd-tag { display:inline-flex; align-items:center; gap:5px; padding:2px 8px; border-radius:6px;
856
659
  font-size:11.5px; line-height:1.7; white-space:nowrap;
857
- color:var(--dsw-alias-label-secondary); background:var(--dsw-alias-bg-layer-2); }
858
- .kd-tag-brand { color:var(--dsw-alias-brand-primary);
859
- background:color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent); }
660
+ color:var(--kd-label-secondary); background:var(--kd-bg-layer-2); }
661
+ .kd-tag-brand { color:var(--kd-brand-primary);
662
+ background:color-mix(in srgb, var(--kd-brand-primary) 10%, transparent); }
860
663
  .kd-badge { display:inline-flex; align-items:center; gap:4px; padding:2px 8px; border-radius:999px;
861
- font-size:11px; line-height:1.6; border:1px solid var(--dsw-alias-border-l2);
862
- color:var(--dsw-alias-label-secondary); background:var(--dsw-alias-bg-layer-1); white-space:nowrap; }
863
- .kd-badge-ok { color:var(--dsw-alias-state-success-primary);
864
- border-color:color-mix(in srgb, var(--dsw-alias-state-success-primary) 34%, transparent);
865
- background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); }
866
- .kd-badge-bad { color:var(--dsw-alias-state-error-primary);
867
- border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 34%, transparent);
868
- background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); }
869
- .kd-badge-warn { color:var(--dsw-alias-state-warn-primary);
870
- border-color:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 34%, transparent);
871
- background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent); }
872
- .kd-badge-brand { color:var(--dsw-alias-brand-primary);
873
- border-color:color-mix(in srgb, var(--dsw-alias-brand-primary) 34%, transparent);
874
- background:color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent); }
875
- .kd-stem { margin:14px 0 18px; font-size:15px; line-height:1.85; }
664
+ font-size:11px; line-height:1.6; border:1px solid var(--kd-border-l2);
665
+ color:var(--kd-label-secondary); background:var(--kd-bg-layer-1); white-space:nowrap; }
666
+ .kd-badge-ok { color:var(--kd-state-success-primary);
667
+ border-color:color-mix(in srgb, var(--kd-state-success-primary) 34%, transparent);
668
+ background:color-mix(in srgb, var(--kd-state-success-primary) 10%, transparent); }
669
+ .kd-badge-bad { color:var(--kd-state-error-primary);
670
+ border-color:color-mix(in srgb, var(--kd-state-error-primary) 34%, transparent);
671
+ background:color-mix(in srgb, var(--kd-state-error-primary) 10%, transparent); }
672
+ .kd-badge-warn { color:var(--kd-state-warn-primary);
673
+ border-color:color-mix(in srgb, var(--kd-state-warn-primary) 34%, transparent);
674
+ background:color-mix(in srgb, var(--kd-state-warn-primary) 12%, transparent); }
675
+ .kd-badge-brand { color:var(--kd-brand-primary);
676
+ border-color:color-mix(in srgb, var(--kd-brand-primary) 34%, transparent);
677
+ background:color-mix(in srgb, var(--kd-brand-primary) 10%, transparent); }
678
+ .kd-stem { margin:24px 0; font-size:16px; line-height:1.85; }
876
679
  .kd-options { display:flex; flex-direction:column; gap:8px; margin:0 0 4px; }
877
- .kd-option { display:flex; gap:12px; align-items:flex-start; padding:12px 14px; border-radius:10px;
878
- border:1px solid var(--dsw-alias-border-l1); background:var(--dsw-alias-bg-base); cursor:pointer;
879
- text-align:left; color:var(--dsw-alias-label-primary); font:inherit; font-size:14px;
680
+ .kd-option { display:flex; gap:12px; align-items:flex-start; padding:14px 16px; border-radius:12px;
681
+ border:1px solid var(--kd-border-l1); background:var(--kd-bg-base); cursor:pointer;
682
+ text-align:left; color:var(--kd-label-primary); font:inherit; font-size:14px;
880
683
  transition:background-color .15s, border-color .15s; }
881
- .kd-option:hover:not(:disabled) { border-color:var(--dsw-alias-border-l2);
882
- background:var(--dsw-alias-bg-layer-2); }
684
+ .kd-option:hover:not(:disabled) { border-color:var(--kd-border-l2);
685
+ background:var(--kd-bg-layer-2); }
883
686
  .kd-option:disabled { cursor:default; }
884
- .kd-option.is-selected { border-color:var(--dsw-alias-brand-primary);
885
- background:color-mix(in srgb, var(--dsw-alias-brand-primary) 7%, transparent); }
886
- .kd-option.is-correct { border-color:var(--dsw-alias-state-success-primary);
887
- background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); }
888
- .kd-option.is-wrong { border-color:var(--dsw-alias-state-error-primary);
889
- background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); }
890
- .kd-option-key { flex:0 0 auto; width:24px; height:24px; border-radius:999px; display:inline-flex;
687
+ .kd-option.is-selected { border-color:var(--kd-brand-primary);
688
+ background:color-mix(in srgb, var(--kd-brand-primary) 7%, transparent); }
689
+ .kd-option.is-correct { border-color:color-mix(in srgb, var(--kd-state-success-primary) 45%, var(--kd-surface));
690
+ background:color-mix(in srgb, var(--kd-state-success-primary) 8%, transparent); }
691
+ .kd-option.is-wrong { border-color:var(--kd-state-error-primary);
692
+ background:color-mix(in srgb, var(--kd-state-error-primary) 8%, transparent); }
693
+ .kd-option-key { flex:0 0 auto; width:24px; height:24px; border-radius:8px; display:inline-flex;
891
694
  align-items:center; justify-content:center; font-size:12px; font-weight:600;
892
- border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-base); }
893
- .kd-option.is-selected .kd-option-key { color:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary); }
894
- .kd-option.is-correct .kd-option-key { color:var(--dsw-alias-state-success-primary); border-color:var(--dsw-alias-state-success-primary); }
895
- .kd-option.is-wrong .kd-option-key { color:var(--dsw-alias-state-error-primary); border-color:var(--dsw-alias-state-error-primary); }
695
+ border:1px solid var(--kd-border-l2); background:var(--kd-bg-base); }
696
+ .kd-option.is-selected .kd-option-key { color:var(--kd-brand-primary); border-color:var(--kd-brand-primary); }
697
+ .kd-option.is-correct .kd-option-key { color:var(--kd-state-success-primary); border-color:var(--kd-state-success-primary); }
698
+ .kd-option.is-wrong .kd-option-key { color:var(--kd-state-error-primary); border-color:var(--kd-state-error-primary); }
896
699
  .kd-option-text { min-width:0; flex:1 1 auto; }
897
700
  .kd-actions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:16px; }
898
- .kd-btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; min-height:32px;
701
+ .kd-btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; min-height:36px;
899
702
  padding:0 14px; border-radius:8px; font-family:inherit; font-size:13px; font-weight:500; cursor:pointer;
900
- border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-1);
901
- color:var(--dsw-alias-label-primary);
703
+ border:1px solid var(--kd-border-l2); background:var(--kd-bg-layer-1);
704
+ color:var(--kd-label-primary);
902
705
  transition:background-color .15s, border-color .15s, color .15s; }
903
- .kd-btn:hover:not(:disabled) { background:var(--dsw-alias-bg-layer-2); }
706
+ .kd-btn:hover:not(:disabled) { background:var(--kd-bg-layer-2); }
904
707
  .kd-btn:disabled { opacity:.5; cursor:not-allowed; }
905
- .kd-btn-primary { background:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary); color:#fff; }
906
- .kd-btn-primary:hover:not(:disabled) { background:var(--dsw-alias-brand-primary); filter:brightness(1.07); }
907
- .kd-btn-ghost { border-color:transparent; background:transparent; color:var(--dsw-alias-label-secondary); }
908
- .kd-btn-ghost:hover:not(:disabled) { background:var(--dsw-alias-bg-layer-2); color:var(--dsw-alias-label-primary); }
909
- .kd-btn-danger { color:var(--dsw-alias-state-error-primary); background:transparent;
910
- border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 40%, transparent); }
911
- .kd-btn-danger:hover:not(:disabled) { background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); }
708
+ .kd-btn-primary { background:var(--kd-brand-primary); border-color:var(--kd-brand-primary); color:#fff; }
709
+ .kd-btn-primary:hover:not(:disabled) { background:var(--kd-brand-primary); filter:brightness(1.07); }
710
+ .kd-btn-ghost { border-color:transparent; background:transparent; color:var(--kd-label-secondary); }
711
+ .kd-btn-ghost:hover:not(:disabled) { background:var(--kd-bg-layer-2); color:var(--kd-label-primary); }
712
+ .kd-btn-danger { color:var(--kd-state-error-primary); background:transparent;
713
+ border-color:color-mix(in srgb, var(--kd-state-error-primary) 40%, transparent); }
714
+ .kd-btn-danger:hover:not(:disabled) { background:color-mix(in srgb, var(--kd-state-error-primary) 10%, transparent); }
912
715
  .kd-btn-sm { min-height:26px; padding:0 9px; font-size:12px; }
913
716
  .kd-iconbtn { display:inline-flex; align-items:center; justify-content:center; width:26px; height:26px;
914
- border-radius:6px; border:0; background:transparent; color:var(--dsw-alias-label-secondary); cursor:pointer;
717
+ border-radius:6px; border:0; background:transparent; color:var(--kd-label-secondary); cursor:pointer;
915
718
  transition:background-color .15s, color .15s; }
916
- .kd-iconbtn:hover { background:var(--dsw-alias-bg-layer-2); color:var(--dsw-alias-label-primary); }
719
+ .kd-iconbtn:hover { background:var(--kd-bg-layer-2); color:var(--kd-label-primary); }
917
720
  .kd-answer { margin-top:6px; }
918
- .kd-answer-panel { margin-top:18px; border:1px solid var(--dsw-alias-border-l1); border-radius:14px;
919
- background:var(--dsw-alias-bg-layer-1); overflow:hidden; }
721
+ .kd-answer-panel { margin-top:18px; border:1px solid var(--kd-border-l1); border-radius:14px;
722
+ background:var(--kd-bg-layer-1); overflow:hidden; }
920
723
  .kd-answer-panel-head { display:flex; align-items:center; justify-content:space-between; gap:10px;
921
- flex-wrap:wrap; padding:11px 18px; border-bottom:1px solid var(--dsw-alias-border-l1);
922
- background:var(--dsw-alias-bg-layer-2); font-size:13px; font-weight:600; }
923
- .kd-answer-panel-head .kd-tag { background:var(--dsw-alias-bg-layer-1); font-weight:400; }
724
+ flex-wrap:wrap; padding:11px 18px; border-bottom:1px solid var(--kd-border-l1);
725
+ background:color-mix(in srgb, var(--kd-brand-primary) 5%, var(--kd-surface)); font-size:13px; font-weight:600; }
726
+ .kd-answer-panel-head .kd-tag { background:var(--kd-bg-layer-1); font-weight:400; }
924
727
  .kd-answer-panel-body { padding:18px; }
925
728
  .kd-answer-block + .kd-answer-block { margin-top:18px; padding-top:16px;
926
- border-top:1px solid var(--dsw-alias-border-l1); }
927
- .kd-answer-label { font-size:10.5px; letter-spacing:.1em; color:var(--dsw-alias-label-secondary);
729
+ border-top:1px solid var(--kd-border-l1); }
730
+ .kd-answer-label { font-size:10.5px; letter-spacing:.1em; color:var(--kd-label-secondary);
928
731
  margin-bottom:6px; }
929
732
  .kd-verify { margin-top:14px; padding:11px 13px; border-radius:10px; font-size:12px; line-height:1.7;
930
- border:1px solid var(--dsw-alias-border-l1); background:var(--dsw-alias-bg-layer-2);
931
- color:var(--dsw-alias-label-secondary); }
733
+ border:1px solid var(--kd-border-l1); background:var(--kd-bg-layer-2);
734
+ color:var(--kd-label-secondary); }
932
735
  .kd-verify ul { margin:6px 0 0; padding-left:18px; }
933
736
  .kd-list-row { display:flex; gap:14px; align-items:flex-start; width:100%; text-align:left;
934
- padding:14px 16px; border-radius:12px; border:1px solid var(--dsw-alias-border-l1);
935
- background:var(--dsw-alias-bg-layer-1); cursor:pointer; color:var(--dsw-alias-label-primary);
737
+ padding:14px 16px; border-radius:12px; border:1px solid var(--kd-border-l1);
738
+ background:var(--kd-bg-layer-1); color:var(--kd-label-primary);
936
739
  font:inherit; transition:background-color .15s, border-color .15s; }
740
+ .kd-list-row > .kd-btn { flex:none; white-space:nowrap; }
741
+ .kd-list-row[role=button] { cursor:pointer; }
742
+ .kd-list-open { flex:none; display:flex; align-items:center; gap:6px; font-size:12px; color:var(--kd-label-secondary); white-space:nowrap; }
937
743
  .kd-list-row + .kd-list-row { margin-top:8px; }
938
- .kd-list-row:hover { border-color:var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-2); }
744
+ .kd-list-row:hover { border-color:var(--kd-border-l2); background:var(--kd-bg-layer-2); }
939
745
  .kd-list-body { min-width:0; flex:1 1 auto; }
940
- .kd-list-summary { font-size:13.5px; line-height:1.7; overflow:hidden; display:-webkit-box;
941
- -webkit-line-clamp:2; -webkit-box-orient:vertical; }
746
+ .kd-list-summary { --kd-preview-line-height:28px; font-size:14px; line-height:var(--kd-preview-line-height); min-width:0;
747
+ max-height:calc(2 * var(--kd-preview-line-height)); overflow:hidden; }
748
+ .kd-list-summary .kd-md { line-height:inherit; }
749
+ .kd-list-summary .kd-md > * { display:inline; }
750
+ .kd-list-summary .kd-p, .kd-list-summary .kd-h, .kd-list-summary .kd-mathblock { margin:0; }
751
+ .kd-list-summary .kd-p::after { content:' '; }
752
+ .kd-list-summary .kd-mathblock { display:inline; padding:0; text-align:left; vertical-align:baseline; }
753
+ .kd-list-summary mjx-container[display=true] { margin:0; }
754
+ .kd-list-summary .kd-code-wrap { margin:0; border:0; }
755
+ .kd-list-summary .kd-code-lang { display:none; }
756
+ .kd-list-summary .kd-code { display:inline; padding:0; line-height:inherit; white-space:pre-wrap; }
757
+ .kd-list-summary.is-truncated { mask-image:linear-gradient(#000,#000),linear-gradient(to right,#000 var(--kd-preview-fade-start),transparent var(--kd-preview-fade-end));
758
+ mask-size:100% var(--kd-preview-fade-top),100% calc(100% - var(--kd-preview-fade-top)); mask-position:top,bottom; mask-repeat:no-repeat; }
942
759
  .kd-list-meta { margin-top:7px; display:flex; gap:6px; flex-wrap:wrap; font-size:11px;
943
- color:var(--dsw-alias-label-secondary); align-items:center; }
944
- .kd-empty { padding:56px 24px; text-align:center; color:var(--dsw-alias-label-secondary); }
760
+ color:var(--kd-label-secondary); align-items:center; }
761
+ .kd-empty { padding:56px 24px; text-align:center; color:var(--kd-label-secondary); }
945
762
  .kd-empty-icon { width:44px; height:44px; margin:0 auto 14px; border-radius:50%; display:flex;
946
- align-items:center; justify-content:center; border:1px solid var(--dsw-alias-border-l1);
947
- background:var(--dsw-alias-bg-layer-1); color:var(--dsw-alias-label-secondary); }
948
- .kd-empty-title { font-size:15px; font-weight:600; color:var(--dsw-alias-label-primary); margin-bottom:6px; }
763
+ align-items:center; justify-content:center; border:1px solid var(--kd-border-l1);
764
+ background:var(--kd-bg-layer-1); color:var(--kd-label-secondary); }
765
+ .kd-empty-title { font-size:15px; font-weight:600; color:var(--kd-label-primary); margin-bottom:6px; }
949
766
  .kd-empty-body { font-size:13px; max-width:460px; margin:0 auto; line-height:1.8; }
950
767
  .kd-empty .kd-actions { justify-content:center; }
951
768
  .kd-overlay { position:absolute; inset:0; background:rgba(0,0,0,.38); display:flex; align-items:center;
952
769
  justify-content:center; padding:24px; z-index:40; backdrop-filter:blur(2px); }
953
770
  .kd-modal { width:min(580px,100%); max-height:100%; display:flex; flex-direction:column;
954
- background:var(--dsw-alias-bg-overlay); color:var(--dsw-alias-label-primary);
955
- border:1px solid var(--dsw-alias-border-l2); border-radius:16px; overflow:hidden;
771
+ background:var(--kd-bg-overlay); color:var(--kd-label-primary);
772
+ border:1px solid var(--kd-border-l2); border-radius:16px; overflow:hidden;
956
773
  box-shadow:0 16px 48px rgba(0,0,0,.24); }
957
774
  .kd-modal-wide { width:min(880px,100%); }
958
- .kd-modal-head { padding:16px 20px; border-bottom:1px solid var(--dsw-alias-border-l1);
775
+ .kd-modal-head { padding:16px 20px; border-bottom:1px solid var(--kd-border-l1);
959
776
  display:flex; align-items:center; justify-content:space-between; gap:10px; }
960
777
  .kd-modal-title { font-size:15px; font-weight:650; letter-spacing:.01em; }
961
778
  .kd-modal-body { padding:18px 20px; overflow:auto; min-height:0; }
962
- .kd-modal-foot { padding:14px 20px; border-top:1px solid var(--dsw-alias-border-l1);
779
+ .kd-modal-foot { padding:14px 20px; border-top:1px solid var(--kd-border-l1);
963
780
  display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap; }
964
781
  .kd-field { display:flex; flex-direction:column; gap:6px; margin-bottom:14px; }
965
- .kd-field-label { font-size:12px; font-weight:500; color:var(--dsw-alias-label-secondary); }
966
- .kd-input { height:34px; padding:0 11px; border-radius:8px; border:1px solid var(--dsw-alias-border-l2);
967
- background:var(--dsw-alias-bg-base); color:var(--dsw-alias-label-primary); font:inherit; font-size:13px;
782
+ .kd-field-label { font-size:12px; font-weight:500; color:var(--kd-label-secondary); }
783
+ .kd-input { height:34px; padding:0 11px; border-radius:8px; border:1px solid var(--kd-border-l2);
784
+ background:var(--kd-bg-base); color:var(--kd-label-primary); font:inherit; font-size:13px;
968
785
  transition:border-color .15s, box-shadow .15s; }
969
- .kd-input:focus { outline:none; border-color:var(--dsw-alias-brand-primary);
970
- box-shadow:0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent); }
971
- .kd-input::placeholder { color:var(--dsw-alias-label-secondary); }
786
+ .kd-input:focus { outline:none; border-color:var(--kd-brand-primary);
787
+ box-shadow:0 0 0 3px color-mix(in srgb, var(--kd-brand-primary) 14%, transparent); }
788
+ .kd-input::placeholder { color:var(--kd-label-secondary); }
972
789
  .kd-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
973
790
  .kd-row-between { display:flex; gap:10px; align-items:center; justify-content:space-between; flex-wrap:wrap; }
974
- .kd-divider { height:1px; background:var(--dsw-alias-border-l1); margin:16px 0; }
975
- .kd-preview-item { border:1px solid var(--dsw-alias-border-l1); border-radius:12px; padding:12px 14px;
976
- background:var(--dsw-alias-bg-base); }
791
+ .kd-divider { height:1px; background:var(--kd-border-l1); margin:16px 0; }
792
+ .kd-preview-item { border:1px solid var(--kd-border-l1); border-radius:12px; padding:12px 14px;
793
+ background:var(--kd-bg-base); }
977
794
  .kd-preview-item + .kd-preview-item { margin-top:8px; }
978
- .kd-warn-text { color:var(--dsw-alias-state-warn-primary); font-size:12px; line-height:1.7; }
979
- .kd-error-text { color:var(--dsw-alias-state-error-primary); font-size:12px; line-height:1.7; }
795
+ .kd-warn-text { color:var(--kd-state-warn-primary); font-size:12px; line-height:1.7; }
796
+ .kd-error-text { color:var(--kd-state-error-primary); font-size:12px; line-height:1.7; }
980
797
  .kd-toast { position:absolute; left:50%; bottom:24px; transform:translateX(-50%); z-index:60;
981
798
  padding:9px 18px; border-radius:999px; font-size:13px; max-width:80%;
982
- background:var(--dsw-alias-bg-overlay); border:1px solid var(--dsw-alias-border-l2);
799
+ background:var(--kd-bg-overlay); border:1px solid var(--kd-border-l2);
983
800
  box-shadow:0 8px 24px rgba(0,0,0,.18); }
984
- .kd-toast-bad { color:var(--dsw-alias-state-error-primary); }
985
- .kd-toast-ok { color:var(--dsw-alias-state-success-primary); }
986
- .kd-toast-warn { color:var(--dsw-alias-state-warn-primary); }
987
- .kd-loading { padding:48px 0; text-align:center; color:var(--dsw-alias-label-secondary); font-size:13px; }
801
+ .kd-toast-bad { color:var(--kd-state-error-primary); }
802
+ .kd-toast-ok { color:var(--kd-state-success-primary); }
803
+ .kd-toast-warn { color:var(--kd-state-warn-primary); }
804
+ .kd-loading { padding:48px 0; text-align:center; color:var(--kd-label-secondary); font-size:13px; }
988
805
  .kd-spinner { display:inline-block; width:14px; height:14px; border-radius:50%; vertical-align:-2px;
989
- border:2px solid var(--dsw-alias-border-l2); border-top-color:var(--dsw-alias-brand-primary);
806
+ border:2px solid var(--kd-border-l2); border-top-color:var(--kd-brand-primary);
990
807
  animation:kd-spin .8s linear infinite; margin-right:8px; }
991
808
  @keyframes kd-spin { to { transform:rotate(360deg); } }
992
- .kd-mobilebar { display:none; gap:8px; padding:10px 14px; border-bottom:1px solid var(--dsw-alias-border-l1);
809
+ .kd-mobilebar { display:none; gap:8px; padding:10px 14px; border-bottom:1px solid var(--kd-border-l1);
993
810
  overflow:auto; }
811
+ .kd-mobilebar > * { flex:0 0 auto; white-space:nowrap; }
812
+ .kd-mobilebar .kd-select { max-width:180px; }
994
813
  .kd-md { line-height:1.78; overflow-wrap:anywhere; }
995
814
  .kd-md > *:first-child { margin-top:0; }
996
815
  .kd-md > *:last-child { margin-bottom:0; }
@@ -1000,114 +819,105 @@ window.__ModuleLoader__.load({
1000
819
  .kd-h4, .kd-h5, .kd-h6 { font-size:14px; }
1001
820
  .kd-list { margin:8px 0; padding-left:22px; }
1002
821
  .kd-list li { margin:3px 0; }
1003
- .kd-quote { margin:10px 0; padding:6px 12px; border-left:3px solid var(--dsw-alias-border-l2);
1004
- color:var(--dsw-alias-label-secondary); }
1005
- .kd-hr { border:0; border-top:1px solid var(--dsw-alias-border-l1); margin:14px 0; }
1006
- .kd-link { color:var(--dsw-alias-brand-primary); text-decoration:underline; text-underline-offset:2px; }
822
+ .kd-quote { margin:10px 0; padding:6px 12px; border-left:3px solid var(--kd-border-l2);
823
+ color:var(--kd-label-secondary); }
824
+ .kd-hr { border:0; border-top:1px solid var(--kd-border-l1); margin:14px 0; }
825
+ .kd-link { color:var(--kd-brand-primary); text-decoration:underline; text-underline-offset:2px; }
1007
826
  .kd-inline-code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:.9em;
1008
- padding:1px 5px; border-radius:5px; background:var(--dsw-alias-bg-layer-2);
1009
- border:1px solid var(--dsw-alias-border-l1); }
1010
- .kd-code-wrap { margin:10px 0; border-radius:10px; overflow:hidden; border:1px solid var(--dsw-alias-border-l1);
1011
- background:var(--dsw-alias-bg-layer-2); }
1012
- .kd-code-lang { padding:3px 10px; font-size:11px; color:var(--dsw-alias-label-secondary);
1013
- border-bottom:1px solid var(--dsw-alias-border-l1); }
827
+ padding:1px 5px; border-radius:5px; background:var(--kd-bg-layer-2);
828
+ border:1px solid var(--kd-border-l1); }
829
+ .kd-code-wrap { margin:10px 0; border-radius:10px; overflow:hidden; border:1px solid var(--kd-border-l1);
830
+ background:var(--kd-bg-layer-2); }
831
+ .kd-code-lang { padding:3px 10px; font-size:11px; color:var(--kd-label-secondary);
832
+ border-bottom:1px solid var(--kd-border-l1); }
1014
833
  .kd-code { margin:0; padding:10px 12px; overflow:auto; font-family:ui-monospace,SFMono-Regular,Consolas,monospace;
1015
834
  font-size:12.5px; line-height:1.6; white-space:pre; tab-size:2; }
1016
835
  .kd-code code { font:inherit; }
1017
836
  .kd-table-wrap { overflow:auto; margin:10px 0; }
1018
837
  .kd-table { border-collapse:collapse; font-size:13px; min-width:0; }
1019
- .kd-table th, .kd-table td { border:1px solid var(--dsw-alias-border-l1); padding:5px 10px; text-align:left; }
1020
- .kd-table th { background:var(--dsw-alias-bg-layer-2); font-weight:600; }
838
+ .kd-table th, .kd-table td { border:1px solid var(--kd-border-l1); padding:5px 10px; text-align:left; }
839
+ .kd-table th { background:var(--kd-bg-layer-2); font-weight:600; }
1021
840
  .kd-image { max-width:100%; height:auto; border-radius:8px; display:block; margin:8px 0; }
1022
841
  .kd-image-fallback { display:inline-block; font-family:ui-monospace,monospace; font-size:12px;
1023
- color:var(--dsw-alias-state-warn-primary); border:1px dashed var(--dsw-alias-state-warn-primary);
842
+ color:var(--kd-state-warn-primary); border:1px dashed var(--kd-state-warn-primary);
1024
843
  border-radius:6px; padding:2px 6px; }
1025
- .kd-math { display:inline-block; vertical-align:middle; }
1026
- .kd-mathblock { display:block; margin:10px 0; overflow-x:auto; overflow-y:hidden; text-align:center; }
1027
- .kd-math, .kd-mathblock {
1028
- font-family: 'KD Latin Modern Math', 'Latin Modern Math', 'Cambria Math', 'STIX Two Math',
1029
- 'TeX Gyre Termes Math', 'Times New Roman', serif;
1030
- font-feature-settings: 'ss01' 0;
1031
- letter-spacing: 0;
1032
- }
1033
- /* 分式:整块按“垂直中线”对齐到父级数学轴线(约基线上方 .25em),
1034
- 而不是下压半个字高;分子分母固定行高,保证分数线正好落在中线上。 */
1035
- .kd-frac { display:inline-flex; flex-direction:column; align-items:stretch; vertical-align:middle;
1036
- text-align:center; margin:0 .18em; line-height:1.15; }
1037
- .kd-frac-n { padding:0 .25em .03em; }
1038
- .kd-frac-d { border-top:1px solid currentColor; padding:.03em .25em 0; }
1039
- /* boxed:把最终答案框起来 */
1040
- .kd-boxed { display:inline-flex; align-items:center; padding:.06em .38em; margin:0 .12em;
1041
- border:1px solid currentColor; border-radius:3px; }
1042
- /* overset / underset:在基座上方或下方叠一行小字 */
1043
- .kd-stack { display:inline-flex; flex-direction:column; align-items:center; vertical-align:middle;
1044
- line-height:1.1; }
1045
- .kd-stack-mark { font-size:.72em; }
1046
- .kd-stack-base { line-height:1.2; }
1047
- /* underbrace / overbrace 的花括号暂不绘制,只保留内容分组 */
1048
- .kd-group { display:inline; }
1049
- /* binom:没有分数线的上下堆叠 */
1050
- .kd-binom { display:inline-flex; align-items:center; vertical-align:middle; margin:0 .1em; }
1051
- .kd-binom .kd-frac-d { border-top:0; padding-top:0; }
1052
- .kd-sup, .kd-sub { font-size:.72em; }
1053
- .kd-sup { vertical-align:super; }
1054
- .kd-sub { vertical-align:sub; }
1055
- .kd-sqrt { display:inline-flex; align-items:flex-start; }
1056
- .kd-sqrt-sign { font-size:1.05em; }
1057
- .kd-sqrt-index { font-size:.6em; margin-left:-.35em; }
1058
- .kd-sqrt-body { border-top:1px solid currentColor; padding:0 .12em; }
1059
- .kd-op { display:inline-flex; flex-direction:column; align-items:center; vertical-align:middle;
1060
- margin:0 .2em; line-height:1.15; }
1061
- .kd-op-sym { font-size:1.35em; line-height:1; }
1062
- .kd-op-lim { font-size:.62em; }
1063
- .kd-roman { font-style:normal; }
1064
- .kd-bold { font-weight:700; font-style:normal; }
1065
- .kd-blackboard { font-style:normal; }
1066
- .kd-colsep { display:inline-block; width:.5em; }
1067
- .kd-matrixbox { display:inline-flex; align-items:center; vertical-align:middle; }
1068
- .kd-matrix { border-collapse:collapse; }
1069
- .kd-matrix td { padding:2px 7px; text-align:center; border:0; }
1070
- .kd-delim { font-size:1.5em; line-height:1; }
1071
- .kd-cases .kd-matrix td { text-align:left; }
1072
- .kd-accent-overline, .kd-accent-bar { border-top:1px solid currentColor; }
1073
- .kd-accent-underline { border-bottom:1px solid currentColor; }
1074
- .kd-accent-vec { position:relative; }
1075
- .kd-accent-vec::before { content:'→'; position:absolute; top:-.75em; left:50%; transform:translateX(-50%); font-size:.7em; }
1076
- .kd-accent-overrightarrow { position:relative; }
1077
- .kd-accent-overrightarrow::before { content:'→'; position:absolute; top:-.85em; left:0; right:0;
1078
- text-align:center; font-size:.72em; line-height:1; }
1079
- .kd-accent-overleftarrow { position:relative; }
1080
- .kd-accent-overleftarrow::before { content:'←'; position:absolute; top:-.85em; left:0; right:0;
1081
- text-align:center; font-size:.72em; line-height:1; }
1082
- .kd-accent-hat::before, .kd-accent-widehat::before { content:'^'; position:absolute; top:-.5em; left:50%; transform:translateX(-50%); font-size:.8em; }
1083
- .kd-accent-hat, .kd-accent-widehat { position:relative; }
1084
- .kd-accent-tilde { position:relative; }
1085
- .kd-accent-tilde::before { content:'~'; position:absolute; top:-.55em; left:50%; transform:translateX(-50%); }
1086
- .kd-accent-dot { position:relative; }
1087
- .kd-accent-dot::before { content:'·'; position:absolute; top:-.6em; left:50%; transform:translateX(-50%); }
1088
- .kd-accent-ddot { position:relative; }
1089
- .kd-accent-ddot::before { content:'··'; position:absolute; top:-.6em; left:50%; transform:translateX(-50%); }
1090
- .kd-latex-unknown { color:var(--dsw-alias-state-warn-primary); font-size:.92em; }
844
+ .kd-math, .kd-mathblock { font-size:17px; }
845
+ .kd-math { display:inline-flex; vertical-align:baseline; align-items:baseline; max-width:100%; padding:.25em .1em;
846
+ overflow-x:auto; overflow-y:hidden; }
847
+ .kd-math mjx-container { color:currentColor; flex:none; }
848
+ .kd-mathblock { display:block; max-width:100%; min-width:0; margin:10px 0; padding:.35em .2em; overflow-x:auto; overflow-y:hidden;
849
+ text-align:center; }
850
+ .kd-page .kd-math mjx-container, .kd-page .kd-mathblock mjx-container {
851
+ display:inline-block; font-size:100% !important; max-width:none; }
852
+ .kd-page .kd-math svg, .kd-page .kd-mathblock svg { max-width:none !important; }
853
+ .kd-math-pending { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:.9em;
854
+ opacity:.55; white-space:pre-wrap; }
855
+ .kd-math-error { color:var(--kd-state-error-primary);
856
+ font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:.9em; }
1091
857
  .kd-checkline { display:flex; gap:8px; align-items:flex-start; padding:5px 0; font-size:13px; }
1092
858
  .kd-chip { display:inline-flex; align-items:center; gap:2px; padding:0 2px 0 8px; border-radius:999px;
1093
- border:1px solid var(--dsw-alias-border-l2); background:var(--dsw-alias-bg-layer-1); font-size:12px; }
859
+ border:1px solid var(--kd-border-l2); background:var(--kd-bg-layer-1); font-size:12px; }
1094
860
  .kd-chip .kd-checkline { padding:0; }
1095
861
  .kd-chip .kd-iconbtn { width:20px; height:20px; }
862
+ .kd-option.is-correct { border-color:#22c55e; }
863
+ .kd-option.is-wrong { border-color:#f35464; }
864
+ .kd-page input[type=checkbox] { accent-color:var(--kd-brand-primary); width:16px; height:16px; flex:none; }
865
+ .kd-btn, .kd-iconbtn, .kd-tab, .kd-nav-item, .kd-option, .kd-list-row {
866
+ transition:background-color .16s, border-color .16s, color .16s, box-shadow .16s, transform .16s; }
867
+ .kd-btn:active:not(:disabled), .kd-iconbtn:active, .kd-tab:active, .kd-nav-item:active { transform:scale(.97); }
868
+ .kd-option:active:not(:disabled) { transform:translateY(1px); }
869
+ .kd-view { animation:kd-enter .2s ease-out; }
870
+ .kd-presence { display:contents; }
871
+ .kd-overlay { animation:kd-fade .16s ease-out; background:rgb(18 27 52 / 28%); backdrop-filter:blur(5px); }
872
+ .kd-modal { animation:kd-enter .18s ease-out; box-shadow:0 24px 80px rgb(12 25 60 / 18%); border-radius:20px; }
873
+ .kd-presence.is-leaving .kd-overlay { animation:kd-fade .16s ease-in reverse both; pointer-events:none; }
874
+ .kd-presence.is-leaving .kd-modal { animation:kd-enter .16s ease-in reverse both; }
875
+ .kd-thread { animation:kd-drawer .2s ease-out; }
876
+ .kd-presence.is-leaving .kd-thread { animation:kd-drawer .16s ease-in reverse both; pointer-events:none; }
877
+ .kd-modal-head { padding:22px 24px; }
878
+ .kd-modal-title { font-size:18px; }
879
+ .kd-modal-body { padding:20px 24px; background:color-mix(in srgb, var(--kd-brand-primary) 2%, var(--kd-surface)); }
880
+ .kd-modal-foot { padding:16px 24px; }
881
+ .kd-manage-card { padding:16px; border:1px solid var(--kd-border-l1); border-radius:14px; background:var(--kd-bg-base); margin-bottom:10px; }
882
+ .kd-manage-card.is-enabled { border-color:color-mix(in srgb, var(--kd-brand-primary) 25%, var(--kd-surface)); }
883
+ .kd-manage-card .kd-checkline { font-weight:550; }
884
+ .kd-manage-card .kd-msg-meta { font-weight:400; line-height:1.7; }
885
+ .kd-custom-subject { padding:20px; border-radius:14px; border:1px solid var(--kd-border-l1); background:var(--kd-bg-base); }
886
+ .kd-field-label { font-size:13px; }
887
+ .kd-input { height:38px; }
888
+ .kd-bulkbar { position:sticky; top:0; z-index:5; display:flex; flex-direction:column; gap:10px; padding:14px;
889
+ margin-bottom:14px; border-radius:14px; border:1px solid color-mix(in srgb, var(--kd-brand-primary) 25%, var(--kd-surface));
890
+ background:var(--kd-bg-base); box-shadow:0 5px 20px rgb(20 35 70 / 5%); animation:kd-enter .18s ease-out; }
891
+ .kd-list-row.is-selected { border-color:var(--kd-brand-primary); background:color-mix(in srgb, var(--kd-brand-primary) 5%, var(--kd-surface)); }
892
+ .kd-list-row input { margin-top:5px; }
893
+ @keyframes kd-fade { from { opacity:0; } to { opacity:1; } }
894
+ @keyframes kd-enter { from { opacity:0; transform:translateY(8px) scale(.99); } to { opacity:1; transform:none; } }
895
+ @keyframes kd-drawer { from { opacity:0; transform:translateX(16px); } to { opacity:1; transform:none; } }
1096
896
  @media (max-width: 1180px) {
1097
- .kd-thread { position:absolute; right:0; top:0; bottom:0; width:min(370px,92vw); z-index:30;
897
+ .kd-thread { position:absolute; right:0; top:0; bottom:0; width:min(370px,92vw); flex-basis:auto; z-index:30;
1098
898
  box-shadow:-12px 0 32px rgba(0,0,0,.22); }
899
+ .kd-thread-resize { display:none; }
1099
900
  }
1100
901
  @media (max-width: 860px) {
1101
902
  .kd-nav { display:none; }
1102
903
  .kd-mobilebar { display:flex; }
1103
- .kd-main { padding:16px 14px 40px; }
904
+ .kd-main-toolbar { padding:16px 14px; }
905
+ .kd-main-scroll { padding:0 14px 40px; }
1104
906
  .kd-header { padding:14px 14px 12px; gap:10px; }
907
+ .kd-header-left { flex-basis:100%; }
1105
908
  .kd-cd-lines { align-items:flex-start; text-align:left; }
909
+ .kd-cd-primary { font-size:19px; }
910
+ .kd-tabs { flex:1; }
911
+ .kd-tab { flex:1 0 auto; justify-content:center; padding:7px 10px; }
1106
912
  .kd-strip-item { white-space:normal; }
1107
913
  .kd-card { padding:16px 14px; }
1108
914
  .kd-answer-panel-body { padding:14px; }
1109
915
  .kd-exam-strip { padding:8px 10px; }
1110
916
  }
917
+ @media (prefers-reduced-motion: reduce) {
918
+ .kd-page *, .kd-page *::before, .kd-page *::after { transition:none !important; animation:none !important; }
919
+ }
920
+
1111
921
  `
1112
922
 
1113
923
  /* ============================== 接口 ============================== */
@@ -1168,6 +978,7 @@ window.__ModuleLoader__.load({
1168
978
  check: 'M4 12.5l5 5L20 6.5',
1169
979
  x: 'M6 6l12 12M18 6L6 18',
1170
980
  chevron: 'M9 6l6 6-6 6',
981
+ 'chevron-left': 'M15 6l-6 6 6 6',
1171
982
  send: 'M4 12l16-8-6 8 6 8z',
1172
983
  book: 'M4 5.5A2.5 2.5 0 0 1 6.5 3H20v16H6.5A2.5 2.5 0 0 0 4 21.5z',
1173
984
  plus: 'M12 5v14M5 12h14',
@@ -1186,13 +997,14 @@ window.__ModuleLoader__.load({
1186
997
  }, h('path', { d: path }))
1187
998
  }
1188
999
 
1189
- function Btn({ children, onClick, variant = 'default', disabled, title, small, type = 'button' }) {
1000
+ function Btn({ children, onClick, variant = 'default', disabled, title, small, type = 'button', ariaLabel }) {
1190
1001
  return h('button', {
1191
1002
  type,
1192
1003
  className: `kd-btn kd-btn-${variant}${small ? ' kd-btn-sm' : ''}`,
1193
1004
  onClick,
1194
1005
  disabled,
1195
1006
  title,
1007
+ 'aria-label': ariaLabel,
1196
1008
  }, children)
1197
1009
  }
1198
1010
 
@@ -1209,6 +1021,17 @@ window.__ModuleLoader__.load({
1209
1021
  actions ? h('div', { className: 'kd-actions' }, actions) : null)
1210
1022
  }
1211
1023
 
1024
+ function Presence({ children }) {
1025
+ const active = Array.isArray(children) ? children.some(Boolean) : Boolean(children)
1026
+ const [retained, setRetained] = useState(children)
1027
+ useEffect(() => {
1028
+ if (active) { setRetained(children); return undefined }
1029
+ const timer = setTimeout(() => setRetained([]), 160)
1030
+ return () => clearTimeout(timer)
1031
+ }, [children, active])
1032
+ return h('div', { className: 'kd-presence' + (active ? '' : ' is-leaving') }, active ? children : retained)
1033
+ }
1034
+
1212
1035
  function Modal({ title, children, footer, onClose, wide }) {
1213
1036
  useEffect(() => {
1214
1037
  const onKey = (event) => {
@@ -1248,12 +1071,12 @@ window.__ModuleLoader__.load({
1248
1071
  return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
1249
1072
  }
1250
1073
 
1251
- /** 严格的三行倒计时。 */
1074
+ /** 剩余不超过 30 天时省略中间的时长行。 */
1252
1075
  function CountdownLines({ countdown }) {
1253
1076
  const lines = (countdown || {}).lines || {}
1254
1077
  return h('div', { className: 'kd-cd-lines' },
1255
1078
  h('div', { className: 'kd-cd-primary' }, lines.primary || ''),
1256
- h('div', { className: 'kd-cd-secondary' }, lines.secondary || ''),
1079
+ lines.secondary ? h('div', { className: 'kd-cd-secondary' }, lines.secondary) : null,
1257
1080
  h('div', { className: 'kd-cd-target' }, lines.target || ''))
1258
1081
  }
1259
1082
 
@@ -1349,7 +1172,7 @@ window.__ModuleLoader__.load({
1349
1172
  }, label, count ? h('span', { className: 'kd-tab-count' }, String(count)) : null)))
1350
1173
  }
1351
1174
 
1352
- function MobileBar({ boot, subjectId, kind, onPick, onTab, onManage, onBanks }) {
1175
+ function MobileBar({ boot, subjectId, onPick, onManage, onBanks }) {
1353
1176
  const enabled = (boot.subjects || []).filter((subject) => subject.enabled)
1354
1177
  return h('div', { className: 'kd-mobilebar' },
1355
1178
  h('select', {
@@ -1358,13 +1181,6 @@ window.__ModuleLoader__.load({
1358
1181
  'aria-label': '切换科目',
1359
1182
  onChange: (event) => onPick(event.target.value),
1360
1183
  }, enabled.map((subject) => h('option', { key: subject.id, value: subject.id }, subject.name))),
1361
- ['today', 'all', 'favorite', 'wrong'].map((value) => h('button', {
1362
- key: value,
1363
- type: 'button',
1364
- className: `kd-nav-item${kind === value ? ' is-active' : ''}`,
1365
- style: { width: 'auto' },
1366
- onClick: () => onTab(value),
1367
- }, T.tabs[value])),
1368
1184
  // 窄屏隐藏了左侧栏,科目的增删与题库入口必须在这里保留。
1369
1185
  h(Btn, { small: true, variant: 'ghost', onClick: onManage }, h(Icon, { name: 'plus', size: 13 }), T.manageSubjects),
1370
1186
  h(Btn, { small: true, variant: 'ghost', onClick: onBanks }, h(Icon, { name: 'book', size: 13 }), T.manageBanks))
@@ -1456,7 +1272,7 @@ window.__ModuleLoader__.load({
1456
1272
 
1457
1273
  function QuestionPane({
1458
1274
  q, busy, choice, onSelect, onReveal, onJudge, onFavorite, onRedo, localRedo,
1459
- onRevise, revising, onToggleThread, threadOpen, narrow, onRemoveWrong,
1275
+ onRevise, revising, onToggleThread, threadOpen, onRemoveWrong,
1460
1276
  }) {
1461
1277
  if (!q) return null
1462
1278
  const revealed = !localRedo && q.answerState === 'revealed' && q.attemptCount > 0
@@ -1527,9 +1343,7 @@ window.__ModuleLoader__.load({
1527
1343
  revealed
1528
1344
  ? h(Btn, { variant: 'ghost', onClick: onRedo, disabled: busy === 'reveal' }, h(Icon, { name: 'refresh', size: 13 }), T.redo)
1529
1345
  : null,
1530
- narrow
1531
- ? h(Btn, { variant: 'ghost', onClick: onToggleThread }, h(Icon, { name: 'book' }), threadOpen ? '收起追问' : T.followup)
1532
- : null),
1346
+ revealed ? h(Btn, { variant: 'ghost', onClick: onToggleThread }, h(Icon, { name: 'book' }), threadOpen ? '收起追问' : T.followup) : null),
1533
1347
  revealed ? h(AnswerBlock, { question: q, onRevise, revising, onRemoveWrong }) : null,
1534
1348
  revealed && last?.result === 'pending'
1535
1349
  ? h('div', { className: 'kd-actions', style: { marginTop: '12px' } },
@@ -1542,7 +1356,7 @@ window.__ModuleLoader__.load({
1542
1356
  h(Badge, { tone: q.publishVerification.status === 'passed' ? 'ok' : 'warn' },
1543
1357
  q.publishVerification.status === 'passed' ? '试题发布前已通过独立校验' : '试题发布状态:待核验'),
1544
1358
  // 校验结论里通常写着答案与关键推导,提交答案前不能展示,否则等于泄题。
1545
- q.answerState === 'revealed'
1359
+ revealed
1546
1360
  ? ` ${q.publishVerification.summary}`
1547
1361
  : ' 结论在提交答案后显示',
1548
1362
  `(${methodLabel(q.publishVerification.method)}${q.publishVerification.at ? `,${formatDateTime(q.publishVerification.at)}` : ''})`)
@@ -1550,7 +1364,59 @@ window.__ModuleLoader__.load({
1550
1364
  }
1551
1365
 
1552
1366
  /* ============================== 列表 ============================== */
1553
- function ListPane({ list, kind, subjectName, onOpen }) {
1367
+ function previewFadePosition(element, lineHeight) {
1368
+ const bounds = element.getBoundingClientRect()
1369
+ const rects = []
1370
+ const document = element.ownerDocument
1371
+ const walker = document.createTreeWalker(element, 4) // 只测量正文文字,公式由 SVG 的外框参与定位。
1372
+ const range = document.createRange()
1373
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
1374
+ if (!node.textContent.trim() || node.parentElement.closest('mjx-container,.kd-code-lang')) continue
1375
+ range.setStart(node, 0)
1376
+ range.setEnd(node, node.textContent.trimEnd().length)
1377
+ rects.push(...range.getClientRects())
1378
+ }
1379
+ for (const node of element.querySelectorAll('svg,img')) rects.push(node.getBoundingClientRect())
1380
+ const visible = rects.filter((rect) => rect.width > 0 && rect.height > 0
1381
+ && rect.top < bounds.bottom - 1 && rect.bottom > bounds.top
1382
+ && rect.left < bounds.right && rect.right > bounds.left)
1383
+ if (!visible.length) return null
1384
+ const lineOf = (rect) => Math.max(0, Math.min(1, Math.floor(((rect.top + rect.bottom) / 2 - bounds.top) / lineHeight)))
1385
+ const lastLine = Math.max(...visible.map(lineOf))
1386
+ const end = Math.min(bounds.width, Math.max(...visible.filter((rect) => lineOf(rect) === lastLine).map((rect) => rect.right - bounds.left)))
1387
+ return { top: lastLine * lineHeight, start: Math.max(0, end - 72), end }
1388
+ }
1389
+
1390
+ function QuestionPreview({ text }) {
1391
+ const ref = useRef(null)
1392
+ const [truncated, setTruncated] = useState(false)
1393
+ const mathStatus = useMathJaxStatus()
1394
+ useEffect(() => {
1395
+ const element = ref.current
1396
+ if (!element) return undefined
1397
+ const measure = () => {
1398
+ let lineHeight = 28
1399
+ for (const svg of element.querySelectorAll('svg')) lineHeight = Math.max(lineHeight, Math.ceil(svg.getBoundingClientRect().height + 16))
1400
+ element.style.setProperty('--kd-preview-line-height', `${lineHeight}px`)
1401
+ const content = element.firstElementChild
1402
+ const clipped = element.scrollHeight > element.clientHeight + 1 || element.scrollWidth > element.clientWidth + 1
1403
+ || (content && content.scrollHeight > content.clientHeight + 1)
1404
+ const fade = clipped && previewFadePosition(element, lineHeight)
1405
+ if (fade) {
1406
+ for (const [key, value] of Object.entries(fade)) element.style.setProperty(`--kd-preview-fade-${key}`, `${value}px`)
1407
+ }
1408
+ setTruncated(Boolean(fade))
1409
+ }
1410
+ measure()
1411
+ const observer = new ResizeObserver(measure)
1412
+ observer.observe(element)
1413
+ if (element.firstElementChild) observer.observe(element.firstElementChild)
1414
+ return () => observer.disconnect()
1415
+ }, [text, mathStatus])
1416
+ return h('div', { ref, className: 'kd-list-summary' + (truncated ? ' is-truncated' : '') }, h(Markdown, { text }))
1417
+ }
1418
+
1419
+ function ListPane({ list, kind, subjectName, onOpen, managing, selected, onManage, onToggle, onSelectAll, onBulk, busy }) {
1554
1420
  if (!list) return h('div', { className: 'kd-loading' }, h('span', { className: 'kd-spinner' }), '加载中…')
1555
1421
  if (!list.items.length) {
1556
1422
  const icon = kind === 'favorite' ? 'star' : kind === 'wrong' ? 'check' : kind === 'all' ? 'book' : 'spark'
@@ -1562,33 +1428,49 @@ window.__ModuleLoader__.load({
1562
1428
  }
1563
1429
  return h('div', null,
1564
1430
  h('div', { className: 'kd-meta', style: { marginBottom: '10px' } },
1565
- h('span', null, `${subjectName} · ${T.tabs[kind]} · 共 ${list.items.length} 道`)),
1566
- list.items.map((item) => h('button', {
1431
+ h('span', null, `${subjectName} · ${T.tabs[kind]} · 共 ${list.items.length} 道`),
1432
+ h(Btn, { small: true, onClick: onManage, disabled: busy }, managing ? '完成管理' : '批量管理')),
1433
+ managing ? h('div', { className: 'kd-bulkbar', 'aria-label': '批量管理' },
1434
+ h('div', { className: 'kd-row' },
1435
+ h('strong', { 'aria-live': 'polite' }, `已选 ${selected.length} 道`),
1436
+ h(Btn, { small: true, disabled: busy, onClick: () => onSelectAll(true) }, '全选'),
1437
+ h(Btn, { small: true, disabled: busy || !selected.length, onClick: () => onSelectAll(false) }, '全不选')),
1438
+ h('div', { className: 'kd-row' },
1439
+ ...[['favorite', '收藏'], ['unfavorite', '取消收藏'], ['wrong', '加入错题本'], ['unwrong', '移出错题本'], ['delete', '删除题目']].map(([action, label]) =>
1440
+ h(Btn, { key: action, small: true, variant: action === 'delete' ? 'danger' : 'default', disabled: busy || !selected.length, onClick: () => onBulk(action) }, label)))) : null,
1441
+ list.items.map((item) => h('div', {
1567
1442
  key: item.id,
1568
- type: 'button',
1569
- className: 'kd-list-row',
1570
- onClick: () => onOpen(item.id),
1443
+ className: 'kd-list-row' + (managing && selected.includes(item.id) ? ' is-selected' : ''),
1444
+ role: managing ? undefined : 'button',
1445
+ tabIndex: managing ? undefined : 0,
1446
+ onClick: managing ? undefined : (event) => { if (!event.target.closest('a,input')) onOpen(item.id) },
1447
+ onKeyDown: managing ? undefined : (event) => {
1448
+ if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); onOpen(item.id) }
1449
+ },
1571
1450
  },
1451
+ managing ? h('input', { type: 'checkbox', checked: selected.includes(item.id), disabled: busy,
1452
+ 'aria-label': '选择题目:' + (item.summary || item.id), onChange: () => onToggle(item.id) }) : null,
1572
1453
  h('div', { className: 'kd-list-body' },
1573
- h('div', { className: 'kd-list-summary' }, item.summary || '(无题干)'),
1454
+ h(QuestionPreview, { text: item.stem || item.summary || '(无题干)' }),
1574
1455
  h('div', { className: 'kd-list-meta' },
1575
1456
  h(Badge, null, item.typeLabel),
1576
1457
  h('span', null, formatDate(item.assignedDate)),
1577
1458
  h('span', null, `作答 ${item.attemptCount} 次`),
1578
1459
  item.favorite ? h(Badge, { tone: 'brand' }, '收藏') : null,
1579
- item.wrongEver ? h(Badge, { tone: 'bad' }, item.mastered ? '曾答错 · 已掌握' : '错题') : null,
1460
+ item.wrongEver ? h(Badge, { tone: 'bad' }, item.mastered ? '错题 · 已掌握' : '错题') : null,
1580
1461
  item.answerStatus !== 'verified' ? h(Badge, { tone: 'warn' }, T.unverified) : null,
1581
1462
  h('span', null, `难度 ${item.difficulty}/5`))),
1582
- h('span', { className: 'kd-iconbtn' }, h(Icon, { name: 'chevron' })))))
1463
+ !managing ? h('span', { className: 'kd-list-open', 'aria-hidden': true }, '查看题目', h(Icon, { name: 'chevron' })) : null)))
1583
1464
  }
1584
1465
 
1585
1466
  /* ============================== 追问侧栏 ============================== */
1586
- function ThreadPane({ thread, questionId, busy, onSend, onClear, onClose, narrow }) {
1467
+ function ThreadPane({ thread, questionId, busy, onSend, onClear, onClose, narrow, ratio = 33, onRatioChange }) {
1587
1468
  const [draft, setDraft] = useState('')
1588
1469
  const [sending, setSending] = useState(false)
1589
1470
  const [localError, setLocalError] = useState('')
1590
1471
  const [pending, setPending] = useState(null)
1591
1472
  const scrollRef = useRef(null)
1473
+ const sendingRef = useRef(false)
1592
1474
  const messages = thread?.messages || []
1593
1475
 
1594
1476
  useEffect(() => {
@@ -1598,7 +1480,8 @@ window.__ModuleLoader__.load({
1598
1480
 
1599
1481
  const send = useCallback(async (text, messageId) => {
1600
1482
  const value = String(text || '').trim()
1601
- if (!value) return
1483
+ if (!value || sendingRef.current) return
1484
+ sendingRef.current = true
1602
1485
  setSending(true)
1603
1486
  setLocalError('')
1604
1487
  try {
@@ -1609,6 +1492,7 @@ window.__ModuleLoader__.load({
1609
1492
  setLocalError(error.message)
1610
1493
  setPending({ id: messageId, text: value })
1611
1494
  } finally {
1495
+ sendingRef.current = false
1612
1496
  setSending(false)
1613
1497
  }
1614
1498
  }, [onSend])
@@ -1644,11 +1528,30 @@ window.__ModuleLoader__.load({
1644
1528
  h('span', { className: 'kd-spinner' }), T.sending))
1645
1529
  }
1646
1530
 
1647
- return h('aside', { className: 'kd-thread', 'aria-label': T.followup },
1531
+ const resize = (value) => onRatioChange(Math.max(20, Math.min(55, value)))
1532
+ return h('aside', { className: 'kd-thread', 'aria-label': T.followup, style: { '--kd-thread-ratio': ratio / 100 } },
1533
+ !narrow ? h('div', { className: 'kd-thread-resize', role: 'separator', tabIndex: 0,
1534
+ 'aria-label': '调整题目与追问的宽度比例', 'aria-orientation': 'vertical',
1535
+ 'aria-valuemin': 20, 'aria-valuemax': 55, 'aria-valuenow': Math.round(ratio),
1536
+ onPointerDown: (event) => { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId) },
1537
+ onPointerMove: (event) => {
1538
+ if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
1539
+ const pane = event.currentTarget.parentElement
1540
+ const left = pane.closest('.kd-shell').querySelector('.kd-main').getBoundingClientRect().left
1541
+ const right = pane.getBoundingClientRect().right
1542
+ resize((right - event.clientX) / (right - left) * 100)
1543
+ },
1544
+ onPointerUp: (event) => event.currentTarget.releasePointerCapture(event.pointerId),
1545
+ onPointerCancel: (event) => event.currentTarget.releasePointerCapture(event.pointerId),
1546
+ onKeyDown: (event) => {
1547
+ const value = { ArrowLeft: ratio + 2, ArrowRight: ratio - 2, Home: 20, End: 55 }[event.key]
1548
+ if (value !== undefined) { event.preventDefault(); resize(value) }
1549
+ },
1550
+ }) : null,
1648
1551
  h('div', { className: 'kd-thread-head' },
1649
1552
  h('div', null,
1650
1553
  h('div', { className: 'kd-thread-title' }, T.followup),
1651
- h('div', { className: 'kd-msg-meta' }, `题目 ${String(questionId || '').slice(0, 14)}`)),
1554
+ h('div', { className: 'kd-msg-meta' }, '围绕当前题目,一起理清思路')),
1652
1555
  h('div', { className: 'kd-row' },
1653
1556
  h(Btn, {
1654
1557
  small: true,
@@ -1657,9 +1560,7 @@ window.__ModuleLoader__.load({
1657
1560
  onClick: onClear,
1658
1561
  title: '只清空本题的追问记录',
1659
1562
  }, T.clearThread),
1660
- narrow
1661
- ? h('button', { className: 'kd-iconbtn', onClick: onClose, 'aria-label': T.close }, h(Icon, { name: 'close' }))
1662
- : null)),
1563
+ h('button', { className: 'kd-iconbtn', onClick: onClose, 'aria-label': T.close }, h(Icon, { name: 'close' })))),
1663
1564
  h('div', { className: 'kd-thread-hint' }, T.followupHint),
1664
1565
  h('div', { className: 'kd-thread-scroll', ref: scrollRef },
1665
1566
  rows.length ? rows : h('div', { className: 'kd-empty-body' }, '还没有追问。可以问某一步推导为什么成立、某个知识点怎么理解,或有没有别的解法。')),
@@ -1669,13 +1570,13 @@ window.__ModuleLoader__.load({
1669
1570
  h('textarea', {
1670
1571
  className: 'kd-textarea',
1671
1572
  value: draft,
1672
- placeholder: '围绕这道题继续追问…(Ctrl+Enter 发送)',
1573
+ placeholder: '围绕这道题继续追问…(Enter 发送,Shift+Enter 换行)',
1673
1574
  disabled: sending,
1674
1575
  onChange: (event) => setDraft(event.target.value),
1675
1576
  onKeyDown: (event) => {
1676
- if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
1577
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && !event.nativeEvent?.isComposing && event.keyCode !== 229) {
1677
1578
  event.preventDefault()
1678
- send(draft, newAttemptId())
1579
+ if (!event.repeat) send(draft, newAttemptId())
1679
1580
  }
1680
1581
  },
1681
1582
  }),
@@ -1689,12 +1590,22 @@ window.__ModuleLoader__.load({
1689
1590
  }
1690
1591
 
1691
1592
  /* ============================== 弹窗 ============================== */
1692
- function SubjectsModal({ boot, onClose, onAdd, onRemove, onSource, onPick }) {
1593
+ function SubjectsModal({ boot, onClose, onDone }) {
1594
+ const [draft, setDraft] = useState(() => ({ subjects: [...boot.settings.subjects],
1595
+ subjectSource: { ...boot.settings.subjectSource }, customSubjects: structuredClone(boot.customSubjects || []) }))
1596
+ const [saving, setSaving] = useState(false)
1597
+ const [saveError, setSaveError] = useState('')
1693
1598
  const [name, setName] = useState('')
1694
1599
  const [types, setTypes] = useState([])
1695
1600
  const [customTypes, setCustomTypes] = useState([])
1696
1601
  const [typeDraft, setTypeDraft] = useState('')
1697
- const enabled = new Set(boot.settings.subjects)
1602
+ const enabled = new Set(draft.subjects)
1603
+ const subjects = [...(boot.subjects || []), ...draft.customSubjects.filter((item) => !(boot.subjects || []).some((s) => s.id === item.id)).map((item) => ({
1604
+ ...item, typeLabels: item.types.map((type) => ({ single: '单项选择题', multiple: '多项选择题', fill: '填空题', solution: '解答题' })[type] || type), bankItemCount: 0,
1605
+ }))]
1606
+ const close = () => { if (!saving) onClose() }
1607
+ const toggleSubject = (id) => setDraft((current) => ({ ...current,
1608
+ subjects: current.subjects.includes(id) ? current.subjects.filter((value) => value !== id) : [...current.subjects, id] }))
1698
1609
  const toggleType = (type) => setTypes((current) => current.includes(type)
1699
1610
  ? current.filter((item) => item !== type)
1700
1611
  : [...current, type])
@@ -1709,10 +1620,28 @@ window.__ModuleLoader__.load({
1709
1620
  setCustomTypes((current) => current.filter((item) => item !== value))
1710
1621
  setTypes((current) => current.filter((item) => item !== value))
1711
1622
  }
1712
- const canAdd = Boolean(name.trim()) && types.length > 0
1623
+ const selectedTypes = [...new Set([...types, ...(typeDraft.trim() ? [typeDraft.trim().slice(0, 24)] : [])])]
1624
+ const canAdd = Boolean(name.trim()) && selectedTypes.length > 0
1625
+ const withCustomSubject = (current) => {
1626
+ const subject = { id: 'custom_' + newAttemptId(), name: name.trim(), types: selectedTypes }
1627
+ return { ...current, subjects: [...current.subjects, subject.id], customSubjects: [...current.customSubjects, subject] }
1628
+ }
1629
+ const finish = async () => {
1630
+ if (saving) return
1631
+ if ((name.trim() || types.length || typeDraft.trim()) && !canAdd) {
1632
+ setSaveError('请填写自定义科目名称并至少选择一个题型,或清空未完成的输入。')
1633
+ return
1634
+ }
1635
+ const next = canAdd ? withCustomSubject(draft) : draft
1636
+ setSaving(true)
1637
+ setSaveError('')
1638
+ try { await onDone(next) }
1639
+ catch (error) { setSaveError(error.message || '保存失败,请重试') }
1640
+ finally { setSaving(false) }
1641
+ }
1713
1642
  const submit = () => {
1714
1643
  if (!canAdd) return
1715
- onAdd({ name: name.trim(), types })
1644
+ setDraft(withCustomSubject)
1716
1645
  setName('')
1717
1646
  setTypes([])
1718
1647
  setCustomTypes([])
@@ -1720,24 +1649,26 @@ window.__ModuleLoader__.load({
1720
1649
  }
1721
1650
  return h(Modal, {
1722
1651
  title: T.manageSubjects,
1723
- onClose,
1652
+ onClose: close,
1724
1653
  wide: true,
1725
1654
  footer: [
1726
- h(Btn, { key: 'close', onClick: onClose }, T.close),
1655
+ h(Btn, { key: 'cancel', onClick: close, disabled: saving }, T.cancel),
1656
+ h(Btn, { key: 'done', variant: 'primary', onClick: finish, disabled: saving }, saving ? '保存中…' : '完成'),
1727
1657
  ],
1728
1658
  },
1659
+ saveError ? h('div', { className: 'kd-error-text', role: 'alert' }, saveError) : null,
1660
+ h('fieldset', { disabled: saving, style: { border: 0, padding: 0, margin: 0, minWidth: 0 } },
1729
1661
  h('div', { className: 'kd-meta', style: { marginBottom: '12px' } },
1730
- '勾选要每天出题的科目。取消勾选只会移出每日出题,该科目已出过的题目、收藏、错题与作答记录都会保留。'),
1731
- (boot.subjects || []).map((subject) => h('div', {
1662
+ '修改后点击右下角「完成」统一保存;取消或关闭会放弃修改。停用科目不会删除历史题目、作答、收藏和追问。'),
1663
+ subjects.map((subject) => h('div', {
1732
1664
  key: subject.id,
1733
- className: 'kd-row-between',
1734
- style: { padding: '8px 0', borderBottom: '1px solid var(--dsw-alias-border-l1)' },
1665
+ className: 'kd-row-between kd-manage-card' + (enabled.has(subject.id) ? ' is-enabled' : ''),
1735
1666
  },
1736
1667
  h('label', { className: 'kd-checkline', style: { flex: '1 1 240px', cursor: 'pointer' } },
1737
1668
  h('input', {
1738
1669
  type: 'checkbox',
1739
1670
  checked: enabled.has(subject.id),
1740
- onChange: () => (enabled.has(subject.id) ? onRemove(subject) : onAdd(subject)),
1671
+ onChange: () => toggleSubject(subject.id),
1741
1672
  }),
1742
1673
  h('span', null,
1743
1674
  h('span', null, subject.name),
@@ -1747,24 +1678,19 @@ window.__ModuleLoader__.load({
1747
1678
  ? h('div', { className: 'kd-row' },
1748
1679
  h('select', {
1749
1680
  className: 'kd-select',
1750
- value: subject.source,
1681
+ value: draft.subjectSource[subject.id] || 'auto',
1751
1682
  'aria-label': `${subject.name} 出题来源`,
1752
- onChange: (event) => onSource(subject.id, event.target.value),
1683
+ onChange: (event) => { const source = event.target.value; setDraft((current) => ({ ...current, subjectSource: { ...current.subjectSource, [subject.id]: source } })) },
1753
1684
  },
1754
1685
  h('option', { value: 'auto' }, '自动(优先题库)'),
1755
1686
  h('option', { value: 'online' }, '在线出题'),
1756
1687
  h('option', { value: 'bank' }, '题库出题')),
1757
1688
  subject.bankItemCount
1758
1689
  ? h('span', { className: 'kd-msg-meta' }, `题库 ${subject.bankItemCount} 题`)
1759
- : h('span', { className: 'kd-msg-meta' }, '题库为空'),
1760
- h(Btn, {
1761
- small: true,
1762
- variant: 'ghost',
1763
- onClick: () => { onClose(); onPick(subject.id) },
1764
- }, '查看'))
1690
+ : h('span', { className: 'kd-msg-meta' }, '题库为空'))
1765
1691
  : null)),
1766
1692
  h('div', { className: 'kd-divider' }),
1767
- h('div', { className: 'kd-field' },
1693
+ h('div', { className: 'kd-field kd-custom-subject' },
1768
1694
  h('div', { className: 'kd-field-label' }, '添加自定义科目'),
1769
1695
  h('div', { className: 'kd-row' },
1770
1696
  h('input', {
@@ -1815,7 +1741,7 @@ window.__ModuleLoader__.load({
1815
1741
  }),
1816
1742
  h(Btn, { small: true, disabled: !typeDraft.trim(), onClick: addCustomType }, '添加题型')),
1817
1743
  h('div', { className: 'kd-msg-meta', style: { marginTop: '6px' } },
1818
- canAdd ? `将添加:${name.trim()}(${types.join(' / ')})` : '填写科目名称并至少选择一个题型后即可添加')))
1744
+ canAdd ? `将添加:${name.trim()}(${types.join(' / ')})` : '填写科目名称并至少选择一个题型后即可添加'))))
1819
1745
  }
1820
1746
 
1821
1747
  function BanksModal({ boot, importResult, onClose, onPickFile, onDelete, onCommit, onCancelImport }) {
@@ -1873,8 +1799,7 @@ window.__ModuleLoader__.load({
1873
1799
  (boot.banks || []).length
1874
1800
  ? boot.banks.map((bank) => h('div', {
1875
1801
  key: bank.id,
1876
- className: 'kd-row-between',
1877
- style: { padding: '9px 0', borderBottom: '1px solid var(--dsw-alias-border-l1)' },
1802
+ className: 'kd-row-between kd-manage-card',
1878
1803
  },
1879
1804
  h('div', { style: { minWidth: 0 } },
1880
1805
  h('div', null, bank.name),
@@ -1919,13 +1844,13 @@ window.__ModuleLoader__.load({
1919
1844
  h('div', { className: 'kd-msg-meta' }, '确认后才会替换正式版本;旧版本会保留在版本历史中。'))
1920
1845
  }
1921
1846
 
1922
- function ConfirmModal({ title, message, confirmLabel, onConfirm, onClose, danger }) {
1847
+ function ConfirmModal({ title, message, confirmLabel, onConfirm, onClose, danger, busy }) {
1923
1848
  return h(Modal, {
1924
1849
  title,
1925
1850
  onClose,
1926
1851
  footer: [
1927
- h(Btn, { key: 'cancel', onClick: onClose }, T.cancel),
1928
- h(Btn, { key: 'ok', variant: danger ? 'danger' : 'primary', onClick: onConfirm }, confirmLabel || T.confirm),
1852
+ h(Btn, { key: 'cancel', onClick: onClose, disabled: busy }, T.cancel),
1853
+ h(Btn, { key: 'ok', variant: danger ? 'danger' : 'primary', onClick: onConfirm, disabled: busy }, busy ? '正在处理…' : (confirmLabel || T.confirm)),
1929
1854
  ],
1930
1855
  }, h('div', { className: 'kd-md' }, message))
1931
1856
  }
@@ -1942,15 +1867,27 @@ window.__ModuleLoader__.load({
1942
1867
  const [thread, setThread] = useState(null)
1943
1868
  const [list, setList] = useState(null)
1944
1869
  const [busy, setBusy] = useState('')
1870
+ const [managing, setManaging] = useState(false)
1871
+ const [selected, setSelected] = useState([])
1872
+ const bulkLock = useRef(false)
1945
1873
  const [fail, setFail] = useState('')
1946
1874
  const [toast, setToast] = useState(null)
1947
1875
  const [modal, setModal] = useState(null)
1948
1876
  const [choice, setChoice] = useState('')
1949
1877
  const [localRedo, setLocalRedo] = useState(false)
1950
1878
  const [threadOpen, setThreadOpen] = useState(false)
1879
+ const [threadRatio, setThreadRatio] = useState(33)
1951
1880
  const fileRef = useRef(null)
1952
1881
  const attemptRef = useRef(newAttemptId())
1953
1882
  const toastTimer = useRef(null)
1883
+ const mainScroll = useRef(null)
1884
+ const listScroll = useRef(0)
1885
+ const redoQuestions = useRef(new Set())
1886
+
1887
+ useEffect(() => {
1888
+ if (view === 'list' && list && mainScroll.current) mainScroll.current.scrollTop = listScroll.current
1889
+ if (view === 'detail' && mainScroll.current) mainScroll.current.scrollTop = 0
1890
+ }, [view, list])
1954
1891
 
1955
1892
  const notify = useCallback((message, tone = 'ok') => {
1956
1893
  setToast({ message, tone })
@@ -1974,6 +1911,7 @@ window.__ModuleLoader__.load({
1974
1911
  if (!sid) return
1975
1912
  // 高亮必须跟着视图走:从「全部 / 收藏 / 错题本」切回今日题目时要一起复位
1976
1913
  setKind('today')
1914
+ setThreadOpen(false)
1977
1915
  setView('detail')
1978
1916
  setBusy('daily')
1979
1917
  setFail('')
@@ -1985,6 +1923,7 @@ window.__ModuleLoader__.load({
1985
1923
  try {
1986
1924
  const result = await rpc('ensureDaily', { subjectId: sid })
1987
1925
  setQuestion(result.question)
1926
+ setLocalRedo(redoQuestions.current.has(result.question.id))
1988
1927
  setThread(result.thread || null)
1989
1928
  } catch (issue) {
1990
1929
  setFail(issue.message)
@@ -1995,7 +1934,9 @@ window.__ModuleLoader__.load({
1995
1934
 
1996
1935
  const loadList = useCallback(async (sid, nextKind) => {
1997
1936
  if (!sid) return
1937
+ listScroll.current = 0
1998
1938
  setKind(nextKind)
1939
+ setThreadOpen(false)
1999
1940
  setView('list')
2000
1941
  setBusy('list')
2001
1942
  setList(null)
@@ -2009,6 +1950,8 @@ window.__ModuleLoader__.load({
2009
1950
  }
2010
1951
  }, [notify])
2011
1952
 
1953
+ useEffect(() => { setSelected([]); setManaging(false) }, [subjectId, kind, view])
1954
+
2012
1955
  const pickSubject = useCallback((sid) => {
2013
1956
  if (!sid || sid === subjectId) return
2014
1957
  setSubjectId(sid)
@@ -2018,13 +1961,17 @@ window.__ModuleLoader__.load({
2018
1961
  }, [subjectId, loadToday])
2019
1962
 
2020
1963
  const openQuestion = useCallback(async (qid) => {
1964
+ listScroll.current = mainScroll.current?.scrollTop || 0
2021
1965
  setBusy('question')
2022
1966
  try {
2023
1967
  const result = await rpc('getQuestion', { qid })
1968
+ const canResume = result.isToday && result.question.answerState === 'revealed'
1969
+ && result.question.attemptCount > 0 && !redoQuestions.current.has(qid)
1970
+ if (!canResume) redoQuestions.current.add(qid)
2024
1971
  setQuestion(result.question)
2025
1972
  setThread(result.thread || null)
2026
1973
  setView('detail')
2027
- setLocalRedo(false)
1974
+ setLocalRedo(!canResume)
2028
1975
  setChoice('')
2029
1976
  setThreadOpen(false)
2030
1977
  attemptRef.current = newAttemptId()
@@ -2035,6 +1982,15 @@ window.__ModuleLoader__.load({
2035
1982
  }
2036
1983
  }, [notify])
2037
1984
 
1985
+ const backToList = async () => {
1986
+ setThreadOpen(false)
1987
+ setView('list')
1988
+ try {
1989
+ const result = await rpc('listQuestions', { subjectId, kind })
1990
+ setList(result)
1991
+ } catch (issue) { notify(issue.message, 'bad') }
1992
+ }
1993
+
2038
1994
  useEffect(() => {
2039
1995
  let cancelled = false
2040
1996
  rpc('bootstrap').then((result) => {
@@ -2070,6 +2026,7 @@ window.__ModuleLoader__.load({
2070
2026
  attemptId: attemptRef.current,
2071
2027
  })
2072
2028
  setQuestion(result.question)
2029
+ redoQuestions.current.delete(question.id)
2073
2030
  setLocalRedo(false)
2074
2031
  refreshBoot()
2075
2032
  } catch (issue) {
@@ -2210,67 +2167,18 @@ window.__ModuleLoader__.load({
2210
2167
  }
2211
2168
  }, [boot, notify, refreshBoot])
2212
2169
 
2213
- const addSubject = useCallback(async (subject) => {
2214
- try {
2215
- let targetId = subject.id
2216
- if (!targetId) {
2217
- targetId = `custom_${Date.now().toString(36)}`
2218
- await rpc('saveSettings', {
2219
- customSubjects: [...(boot?.customSubjects || []), {
2220
- id: targetId,
2221
- name: subject.name,
2222
- types: subject.types,
2223
- }],
2224
- })
2225
- }
2226
- const result = await rpc('addSubject', { subjectId: targetId })
2227
- const fresh = await refreshBoot()
2228
- notify(`${subject.name} 已启用,系统会为它分配今天的题目`)
2229
- if (fresh && result) {
2230
- setSubjectId(targetId)
2231
- loadToday(targetId)
2232
- }
2233
- setModal(null)
2234
- } catch (issue) {
2235
- notify(issue.message, 'bad')
2236
- }
2237
- }, [boot, notify, refreshBoot, loadToday])
2238
-
2239
- const removeSubject = useCallback((subject) => {
2240
- setModal({
2241
- kind: 'confirm',
2242
- title: `${T.deleteSubject}:${subject.name}`,
2243
- message: `确认把「${subject.name}」移出每日出题吗?\n\n- 该科目已出过的题目、收藏、错题与作答记录都会保留在“全部”里。\n- 今天已经分配的题目不会丢失,重新启用后当天仍会复用同一道题。`,
2244
- confirmLabel: T.deleteSubject,
2245
- danger: true,
2246
- onConfirm: async () => {
2247
- setModal(null)
2248
- try {
2249
- const result = await rpc('removeSubject', { subjectId: subject.id })
2250
- const fresh = await refreshBoot()
2251
- notify(result.message || '已移出每日出题')
2252
- if (fresh) {
2253
- const next = fresh.settings.subjects[0] || null
2254
- setSubjectId(next)
2255
- if (next) loadToday(next)
2256
- else { setQuestion(null); setView('empty') }
2257
- }
2258
- } catch (issue) {
2259
- notify(issue.message, 'bad')
2260
- }
2261
- },
2262
- })
2263
- }, [notify, refreshBoot, loadToday])
2264
-
2265
- const changeSource = useCallback(async (id, source) => {
2266
- try {
2267
- await rpc('saveSettings', { subjectSource: { [id]: source } })
2268
- await refreshBoot()
2269
- notify('出题来源已更新')
2270
- } catch (issue) {
2271
- notify(issue.message, 'bad')
2170
+ const saveSubjects = async (draft) => {
2171
+ const next = draft.subjects.includes(subjectId) ? subjectId : (draft.subjects[0] || null)
2172
+ const result = await rpc('saveSettings', { ...draft, activeSubject: next })
2173
+ setBoot((current) => ({ ...current, ...result, customSubjects: draft.customSubjects }))
2174
+ setModal(null)
2175
+ notify('科目设置已保存')
2176
+ if (next !== subjectId) {
2177
+ setSubjectId(next)
2178
+ if (next) loadToday(next)
2179
+ else { setQuestion(null); setThread(null); setThreadOpen(false); setView('empty') }
2272
2180
  }
2273
- }, [notify, refreshBoot])
2181
+ }
2274
2182
 
2275
2183
  const pickFile = useCallback(() => {
2276
2184
  if (fileRef.current) {
@@ -2335,6 +2243,38 @@ window.__ModuleLoader__.load({
2335
2243
  })
2336
2244
  }, [notify, refreshBoot])
2337
2245
 
2246
+ const bulkQuestions = async (action) => {
2247
+ if (bulkLock.current || !selected.length) return
2248
+ const qids = [...selected]
2249
+ const execute = async (confirmProtected = false) => {
2250
+ if (bulkLock.current) return
2251
+ bulkLock.current = true
2252
+ setBusy('bulk')
2253
+ try {
2254
+ const result = await rpc('bulkQuestions', { qids, action, confirmProtected })
2255
+ if (result.requiresConfirmation) {
2256
+ setModal({ kind: 'confirm', title: '再次确认删除收藏或错题', danger: true,
2257
+ message: `选中的题目中有 ${result.protectedCount} 道已收藏或在错题本中。继续将永久删除所选 ${qids.length} 道题及其作答和追问,无法恢复。确定仍要删除吗?`,
2258
+ confirmLabel: '仍要永久删除', onConfirm: () => execute(true) })
2259
+ return
2260
+ }
2261
+ setModal(null)
2262
+ setSelected([])
2263
+ await loadList(subjectId, kind)
2264
+ await refreshBoot()
2265
+ notify(`已处理 ${result.affected} 道题目`)
2266
+ } catch (issue) {
2267
+ setModal(null)
2268
+ notify(issue.message, 'bad')
2269
+ } finally { bulkLock.current = false; setBusy('') }
2270
+ }
2271
+ if (action === 'delete') {
2272
+ setModal({ kind: 'confirm', title: '删除所选题目', danger: true,
2273
+ message: `将永久删除 ${qids.length} 道题及其作答、收藏、错题标记和追问,无法恢复。题库原文件仍会保留,未来可能重新抽到其中的题目。`,
2274
+ confirmLabel: '确认删除', onConfirm: () => execute() })
2275
+ } else await execute()
2276
+ }
2277
+
2338
2278
  const counts = subjectId ? boot?.counts?.[subjectId] : null
2339
2279
  const narrow = useMediaQuery('(max-width: 1180px)')
2340
2280
 
@@ -2399,6 +2339,11 @@ window.__ModuleLoader__.load({
2399
2339
  kind,
2400
2340
  subjectName: currentSubject?.name || '',
2401
2341
  onOpen: openQuestion,
2342
+ managing, selected, busy: busy === 'bulk',
2343
+ onManage: () => { setManaging(!managing); setSelected([]) },
2344
+ onToggle: (id) => setSelected((ids) => ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id]),
2345
+ onSelectAll: (value) => setSelected(value ? list.items.map((item) => item.id) : []),
2346
+ onBulk: bulkQuestions,
2402
2347
  })
2403
2348
  }
2404
2349
  if (view === 'detail' && question) {
@@ -2407,7 +2352,6 @@ window.__ModuleLoader__.load({
2407
2352
  busy,
2408
2353
  choice,
2409
2354
  localRedo,
2410
- narrow,
2411
2355
  threadOpen,
2412
2356
  onSelect: (key) => {
2413
2357
  setChoice((current) => {
@@ -2422,7 +2366,9 @@ window.__ModuleLoader__.load({
2422
2366
  onJudge: judge,
2423
2367
  onFavorite: favorite,
2424
2368
  onRedo: () => {
2369
+ redoQuestions.current.add(question.id)
2425
2370
  setLocalRedo(true)
2371
+ setThreadOpen(false)
2426
2372
  setChoice('')
2427
2373
  attemptRef.current = newAttemptId()
2428
2374
  },
@@ -2449,7 +2395,13 @@ window.__ModuleLoader__.load({
2449
2395
  })
2450
2396
  })()
2451
2397
 
2452
- const showThread = view === 'detail' && question && (!narrow || threadOpen)
2398
+ const pickTab = (value) => {
2399
+ if (value === kind && (value === 'today' || view === 'list')) return
2400
+ if (value === 'today') loadToday(subjectId)
2401
+ else loadList(subjectId, value)
2402
+ }
2403
+ const showThread = view === 'detail' && question && threadOpen && !localRedo
2404
+ && question.answerState === 'revealed' && question.attemptCount > 0
2453
2405
 
2454
2406
  return h('div', { className: 'kd-page' },
2455
2407
  styleInjected ? null : h('style', null, CSS),
@@ -2459,10 +2411,10 @@ window.__ModuleLoader__.load({
2459
2411
  h('div', { className: 'kd-brand' }, h(SidebarIcon, { size: 18 })),
2460
2412
  h('div', { style: { minWidth: 0 } },
2461
2413
  h('h1', { className: 'kd-title' }, T.title),
2462
- h('div', { className: 'kd-sub' },
2414
+ h('div', { className: 'kd-sub', title: T.subtitle },
2463
2415
  boot?.today ? `${formatDate(boot.today)} · ` : '',
2464
2416
  boot ? `已启用 ${boot.settings.subjects.length} 个科目 · ` : '',
2465
- T.subtitle))),
2417
+ '每天一道,稳步向前'))),
2466
2418
  boot ? h(CountdownLines, { countdown: boot.countdown }) : null),
2467
2419
  boot ? h(ExamStrip, {
2468
2420
  boot,
@@ -2473,14 +2425,9 @@ window.__ModuleLoader__.load({
2473
2425
  boot ? h(MobileBar, {
2474
2426
  boot,
2475
2427
  subjectId,
2476
- kind,
2477
2428
  onPick: pickSubject,
2478
2429
  onManage: () => setModal({ kind: 'subjects' }),
2479
2430
  onBanks: () => setModal({ kind: 'banks' }),
2480
- onTab: (value) => {
2481
- if (value === 'today') loadToday(subjectId)
2482
- else loadList(subjectId, value)
2483
- },
2484
2431
  }) : null,
2485
2432
  h('div', { className: 'kd-shell' },
2486
2433
  boot ? h(SubjectNav, {
@@ -2491,27 +2438,30 @@ window.__ModuleLoader__.load({
2491
2438
  onBanks: () => setModal({ kind: 'banks' }),
2492
2439
  }) : null,
2493
2440
  h('main', { className: 'kd-main' },
2494
- h('div', { className: 'kd-main-inner' },
2441
+ h('div', { className: 'kd-main-toolbar' },
2495
2442
  boot ? h(TabBar, {
2496
2443
  kind,
2497
2444
  counts,
2498
- onPick: (value) => {
2499
- if (value === 'today') loadToday(subjectId)
2500
- else loadList(subjectId, value)
2501
- },
2445
+ onPick: pickTab,
2502
2446
  }) : null,
2503
- mainContent)),
2504
- showThread
2447
+ kind !== 'today' && view === 'detail'
2448
+ ? h(Btn, { small: true, onClick: backToList, title: '返回列表', ariaLabel: '返回列表' }, h(Icon, { name: 'chevron-left', size: 16 })) : null),
2449
+ h('div', { className: 'kd-main-scroll', ref: mainScroll },
2450
+ h('div', { className: 'kd-main-inner' },
2451
+ h('div', { className: 'kd-view', key: `${subjectId}|${kind}|${view}|${question?.id || ''}` }, mainContent)))),
2452
+ h(Presence, null, showThread
2505
2453
  ? h(ThreadPane, {
2506
2454
  thread,
2507
2455
  questionId: question.id,
2508
2456
  busy,
2509
2457
  narrow,
2458
+ ratio: threadRatio,
2459
+ onRatioChange: setThreadRatio,
2510
2460
  onSend: sendFollowup,
2511
2461
  onClear: clearThread,
2512
2462
  onClose: () => setThreadOpen(false),
2513
2463
  })
2514
- : null),
2464
+ : null)),
2515
2465
  h('input', {
2516
2466
  ref: fileRef,
2517
2467
  type: 'file',
@@ -2519,14 +2469,12 @@ window.__ModuleLoader__.load({
2519
2469
  style: { display: 'none' },
2520
2470
  onChange: onFile,
2521
2471
  }),
2472
+ h(Presence, null,
2522
2473
  modal?.kind === 'subjects' && boot
2523
2474
  ? h(SubjectsModal, {
2524
2475
  boot,
2525
2476
  onClose: () => setModal(null),
2526
- onAdd: addSubject,
2527
- onRemove: removeSubject,
2528
- onSource: changeSource,
2529
- onPick: (id) => { setModal(null); pickSubject(id) },
2477
+ onDone: saveSubjects,
2530
2478
  })
2531
2479
  : null,
2532
2480
  modal?.kind === 'banks' && boot
@@ -2548,13 +2496,16 @@ window.__ModuleLoader__.load({
2548
2496
  onDiscard: discardRevision,
2549
2497
  }) : null,
2550
2498
  modal?.kind === 'confirm' ? h(ConfirmModal, {
2499
+ key: modal.title,
2551
2500
  title: modal.title,
2552
2501
  message: modal.message,
2553
2502
  confirmLabel: modal.confirmLabel,
2554
2503
  danger: modal.danger,
2504
+ busy: busy === 'bulk',
2555
2505
  onConfirm: modal.onConfirm,
2556
- onClose: () => setModal(null),
2506
+ onClose: () => { if (!bulkLock.current) setModal(null) },
2557
2507
  }) : null,
2508
+ ),
2558
2509
  toast ? h('div', { className: `kd-toast kd-toast-${toast.tone}` }, toast.message) : null,
2559
2510
  busy === 'import' || busy === 'commit'
2560
2511
  ? h('div', { className: 'kd-loading', style: { position: 'absolute', inset: 'auto 0 60px 0' } },