paperdepot 0.1.1 → 0.1.3

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/lib/client.js CHANGED
@@ -41,6 +41,41 @@ window.__ModuleLoader__.load({
41
41
  const SP = { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 }
42
42
  const R = { sm: 6, md: 10, lg: 14 }
43
43
 
44
+ // 分类语义色:四类受控标签固定色,概念页单独一色。
45
+ // 与 DOMAIN_COLORS(按领域名轮转的识别色)分工:分类色用于 chip / 标签,领域色用于列表里的领域点。
46
+ const CAT_COLOR = { 领域: '#6ea8fe', 方法: '#4ec9a5', 应用: '#e6b450', 仪器: '#c792ea', 状态: '#8b90a0' }
47
+ const CONCEPT_COLOR = '#ff8fab'
48
+ const DOMAIN_COLORS = ['#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#38bdf8', '#fb923c', '#4ade80', '#e879f9', '#22d3ee', '#f87171', '#a3e635', '#f472b6']
49
+
50
+ function hexA(hex, a) {
51
+ const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || ''))
52
+ if (!m) return hex
53
+ const n = parseInt(m[1], 16)
54
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`
55
+ }
56
+
57
+ function tagCategory(tag) {
58
+ const s = String(tag || '')
59
+ const i = s.indexOf('/')
60
+ if (i > 0) return s.slice(0, i)
61
+ const j = s.indexOf('-')
62
+ return j > 0 ? s.slice(0, j) : ''
63
+ }
64
+
65
+ function tagColor(tag) {
66
+ return CAT_COLOR[tagCategory(tag)] || T.text3
67
+ }
68
+
69
+ function domainOfPaper(p) {
70
+ const t = (p.tags || []).find((x) => x.startsWith('领域/'))
71
+ return t ? t.slice(3) : ''
72
+ }
73
+
74
+ function domainColor(domain, domains) {
75
+ const i = domains.indexOf(domain)
76
+ return DOMAIN_COLORS[(i < 0 ? 0 : i) % DOMAIN_COLORS.length]
77
+ }
78
+
44
79
  const fmt = (n) => (typeof n === 'number' ? n.toLocaleString('zh-CN') : '—')
45
80
  const api = (p) => fetch('/paperdepot/api' + p, { credentials: 'same-origin' })
46
81
 
@@ -126,7 +161,7 @@ window.__ModuleLoader__.load({
126
161
  { key: x.key, style: { display: 'flex', alignItems: 'center', gap: 10, fontSize: FS.caption } },
127
162
  h('div', { style: { width: 132, textAlign: 'right', color: muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: '0 0 auto' }, title: x.key }, x.label),
128
163
  h('div', { style: { flex: '1 1 auto', height: 10, background: 'rgba(127,127,127,0.10)', borderRadius: 999, overflow: 'hidden' } },
129
- h('div', { style: { width: `${Math.round((x.value / max) * 100)}%`, height: '100%', background: color, borderRadius: 999 } })),
164
+ h('div', { style: { width: `${Math.round((x.value / max) * 100)}%`, height: '100%', background: x.color || color, borderRadius: 999 } })),
130
165
  h('div', { style: { width: 44, color: faint, flex: '0 0 auto', textAlign: 'right' } }, x.value),
131
166
  ),
132
167
  ),
@@ -149,6 +184,371 @@ window.__ModuleLoader__.load({
149
184
  return h('div', { style: { padding: '28px 16px', textAlign: 'center', color: faint, fontSize: FS.body, border: `1px dashed ${line}`, borderRadius: R.lg } }, children)
150
185
  }
151
186
 
187
+ // ---------------- 条目排版(论文 index.md / 概念页共用,无外部依赖) ----------------
188
+ // 面板里直接塞 <pre> 会把 YAML、导航块、表格原文全部倒出来;这里做一层轻量渲染:
189
+ // 去 frontmatter / 导航注释 / 由面板另行呈现的尾部小节,再按标题、列表、表格、引用排版。
190
+
191
+ const MD_FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
192
+ const MD_HIDDEN_SECTIONS = ['标签', '相关文件', '全文', '原始文件', '所属概念', '分类', '相关论文']
193
+ const MD_EMOJI_HEAD = /^[^\p{L}\p{N}\u4e00-\u9fff]+/u
194
+ const MD_BLOCK_START = /^(#{1,6}\s|>\s?|\s*(?:[-*+]|\d+\.)\s+|\||!\[)/
195
+
196
+ const LATEX_MAP = [
197
+ [/\\times/g, '×'], [/\\cdot/g, '·'], [/\\pm/g, '±'], [/\\approx/g, '≈'], [/\\sim/g, '~'],
198
+ [/\\leq?/g, '≤'], [/\\geq?/g, '≥'], [/\\neq?/g, '≠'], [/\\to/g, '→'], [/\\infty/g, '∞'],
199
+ [/\\theta/g, 'θ'], [/\\phi/g, 'φ'], [/\\varphi/g, 'φ'], [/\\alpha/g, 'α'], [/\\beta/g, 'β'],
200
+ [/\\gamma/g, 'γ'], [/\\delta/g, 'δ'], [/\\Delta/g, 'Δ'], [/\\lambda/g, 'λ'], [/\\mu/g, 'μ'],
201
+ [/\\sigma/g, 'σ'], [/\\Sigma/g, 'Σ'], [/\\omega/g, 'ω'], [/\\Omega/g, 'Ω'], [/\\tau/g, 'τ'],
202
+ [/\\rho/g, 'ρ'], [/\\nu/g, 'ν'], [/\\epsilon/g, 'ε'], [/\\psi/g, 'ψ'], [/\\circ/g, '°'],
203
+ [/\\tan/g, 'tan'], [/\\log/g, 'log'], [/\\exp/g, 'exp'], [/\\sin/g, 'sin'], [/\\cos/g, 'cos'],
204
+ [/\\mathrm\{([^}]*)\}/g, '$1'], [/\\text\{([^}]*)\}/g, '$1'],
205
+ [/\\left[([|.]/g, '$1'], [/\\right[)\]|.]/g, '$1'],
206
+ [/\\[,;: ]/g, ' '], [/\\!/g, ''], [/\\%/g, '%'], [/\\_/g, '_'], [/\\/g, ''],
207
+ ]
208
+
209
+ const LATEX_SUP = { 0: '⁰', 1: '¹', 2: '²', 3: '³', 4: '⁴', 5: '⁵', 6: '⁶', 7: '⁷', 8: '⁸', 9: '⁹', '-': '⁻', '+': '⁺' }
210
+ const LATEX_SUB = { 0: '₀', 1: '₁', 2: '₂', 3: '₃', 4: '₄', 5: '₅', 6: '₆', 7: '₇', 8: '₈', 9: '₉', '-': '₋' }
211
+ const mapsTo = (m) => [...m].map((x) => LATEX_SUP[x] || x).join('')
212
+ const mapsToSub = (m) => [...m].map((x) => LATEX_SUB[x] || x).join('')
213
+
214
+ // 正文里也常出现未包 $ 的 10^{-6}、s^{-1},顺手换成上/下标,避免花括号漏出来
215
+ function prettyPlain(s) {
216
+ return String(s || '')
217
+ .replace(/\^\{([-+0-9]+)\}/g, (m, g) => mapsTo(g))
218
+ .replace(/\^(-?[0-9]+)/g, (m, g) => mapsTo(g))
219
+ .replace(/_\{([0-9]+)\}/g, (m, g) => mapsToSub(g))
220
+ }
221
+
222
+ function prettyMath(s) {
223
+ let out = String(s || '')
224
+ for (const [re, rep] of LATEX_MAP) out = out.replace(re, rep)
225
+ out = out
226
+ .replace(/\^\{([-+0-9]+)\}/g, (m, g) => mapsTo(g))
227
+ .replace(/\^(-?[0-9]+)/g, (m, g) => mapsTo(g))
228
+ .replace(/_\{([0-9]+)\}/g, (m, g) => mapsToSub(g))
229
+ .replace(/_([0-9]+)/g, (m, g) => mapsToSub(g))
230
+ .replace(/[{}]/g, '')
231
+ .replace(/([µμ])\s+(?=[a-zA-Z])/g, '$1')
232
+ .replace(/[ \t]+/g, ' ')
233
+ .trim()
234
+ return out
235
+ }
236
+
237
+ function parsePaperFrontmatter(md) {
238
+ const m = MD_FRONTMATTER.exec(String(md || ''))
239
+ const block = m ? m[1] : ''
240
+ const scalar = (k) => {
241
+ const r = new RegExp('^' + k + ':\\s*(.+?)\\s*$', 'm').exec(block)
242
+ return r ? r[1].trim().replace(/^["']|["']$/g, '') : ''
243
+ }
244
+ const list = (k) => {
245
+ const r = new RegExp('^' + k + ':\\s*\\n((?:[ \\t]*-[^\\n]*\\n?)+)', 'm').exec(block)
246
+ if (!r) return []
247
+ return r[1]
248
+ .split(/\r?\n/)
249
+ .map((x) => x.trim().replace(/^-\s*/, '').replace(/^["']|["']$/g, ''))
250
+ .filter(Boolean)
251
+ }
252
+ const fm = {}
253
+ for (const k of ['title', 'year', 'journal', 'doi', 'arxiv', 'pages', 'figures', 'status', 'citekey', 'updated']) fm[k] = scalar(k)
254
+ fm.tags = list('tags')
255
+ fm.aliases = list('aliases')
256
+ return fm
257
+ }
258
+
259
+ function stripEntryChrome(md) {
260
+ let s = String(md || '')
261
+ s = s.replace(/<!--\s*[\w-]+:nav:start\s*-->[\s\S]*?<!--\s*[\w-]+:nav:end\s*-->/g, '')
262
+ s = s.replace(/<!--[\s\S]*?-->/g, '')
263
+ s = s.replace(MD_FRONTMATTER, '')
264
+ const out = []
265
+ let skip = false
266
+ for (const line of s.split(/\r?\n/)) {
267
+ const m = /^##\s+(.+?)\s*$/.exec(line)
268
+ if (m) skip = MD_HIDDEN_SECTIONS.some((n) => m[1].includes(n))
269
+ if (!skip) out.push(line)
270
+ }
271
+ return out.join('\n')
272
+ }
273
+
274
+ function splitTableRow(line) {
275
+ return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim())
276
+ }
277
+
278
+ function renderInline(text, keyBase, opts) {
279
+ const s = String(text == null ? '' : text)
280
+ const re = /(\[\[[^\]\n]+\]\]|\*\*[^*\n]+\*\*|`[^`\n]+`|\$[^$\n]+\$|\[[^\]\n]+\]\([^)\n]+\)|\*[^*\n]+\*)/g
281
+ const out = []
282
+ let last = 0
283
+ let n = 0
284
+ let m
285
+ while ((m = re.exec(s))) {
286
+ if (m.index > last) out.push(prettyPlain(s.slice(last, m.index)))
287
+ const tok = m[0]
288
+ const key = keyBase + '-' + n++
289
+ if (tok.startsWith('[[')) {
290
+ const inner = tok.slice(2, -2)
291
+ const cut = inner.indexOf('|')
292
+ const target = (cut === -1 ? inner : inner.slice(0, cut)).trim()
293
+ const alias = (cut === -1 ? inner : inner.slice(cut + 1)).trim()
294
+ const open = opts && opts.onOpenPaper && /^1-Papers\//.test(target)
295
+ if (target.startsWith('2-Concepts/')) {
296
+ out.push(h(ConceptChip, { key, name: alias || target.slice('2-Concepts/'.length) }))
297
+ } else if (target.startsWith('3-Taxonomy/')) {
298
+ out.push(h(TaxChip, { key, id: (target.split('/')[1] || '').replace(/\.md$/, '') }))
299
+ } else {
300
+ out.push(
301
+ h('span', {
302
+ key,
303
+ title: target,
304
+ onClick: open ? () => opts.onOpenPaper(target.replace(/^1-Papers\//, '').replace(/\/index.*$/, '')) : undefined,
305
+ style: { color: T.text2, borderBottom: `1px dotted ${T.b2}`, cursor: open ? 'pointer' : 'default' },
306
+ }, alias || target),
307
+ )
308
+ }
309
+ } else if (tok.startsWith('**')) {
310
+ out.push(h('strong', { key, style: { fontWeight: 600, color: T.text } }, tok.slice(2, -2)))
311
+ } else if (tok.startsWith('`')) {
312
+ out.push(h('code', { key, style: { fontFamily: mono, fontSize: '0.92em', background: 'rgba(255,255,255,0.06)', padding: '1px 5px', borderRadius: 4 } }, tok.slice(1, -1)))
313
+ } else if (tok.startsWith('$')) {
314
+ out.push(h('span', { key, style: { fontFamily: mono, fontSize: '0.92em', color: '#c8c9d4' } }, prettyMath(tok.slice(1, -1))))
315
+ } else if (tok.startsWith('[')) {
316
+ const mm = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(tok)
317
+ out.push(h('a', { key, href: mm[2], target: '_blank', rel: 'noreferrer', style: { color: '#8fa3ff', textDecoration: 'none' } }, mm[1]))
318
+ } else {
319
+ out.push(h('em', { key, style: { fontStyle: 'normal', color: T.text2 } }, tok.slice(1, -1)))
320
+ }
321
+ last = m.index + tok.length
322
+ }
323
+ if (last < s.length) out.push(prettyPlain(s.slice(last)))
324
+ return out.length ? out : ['']
325
+ }
326
+
327
+ function mdTable(header, rows, keyBase) {
328
+ const cell = (text, i) => {
329
+ const isNum = /^[\d.,%×+\-–—\s/]+$/.test(text) && /\d/.test(text)
330
+ return h('td', {
331
+ key: keyBase + '-c' + i,
332
+ style: {
333
+ padding: '8px 10px', borderBottom: '1px solid rgba(255,255,255,0.05)', lineHeight: 1.6, verticalAlign: 'top',
334
+ fontFamily: isNum ? mono : 'inherit', fontSize: isNum ? 12.5 : 13,
335
+ fontVariantNumeric: isNum ? 'tabular-nums' : undefined, color: isNum ? T.text2 : 'inherit',
336
+ },
337
+ }, renderInline(text, keyBase + '-c' + i))
338
+ }
339
+ return h('table', { key: keyBase, style: { width: '100%', borderCollapse: 'collapse', margin: '10px 0 18px', fontSize: 13 } },
340
+ h('thead', null, h('tr', null, header.map((c, i) => h('th', {
341
+ key: keyBase + '-h' + i,
342
+ style: { textAlign: 'left', fontSize: 12, fontWeight: 600, color: T.text3, padding: '7px 10px', borderBottom: '1px solid rgba(255,255,255,0.1)', whiteSpace: 'nowrap' },
343
+ }, renderInline(c, keyBase + '-h' + i))))),
344
+ h('tbody', null, rows.map((cells, r) => h('tr', { key: keyBase + '-r' + r }, cells.map(cell)))))
345
+ }
346
+
347
+ function mdList(items, ordered, keyBase) {
348
+ return h('div', { key: keyBase, style: { margin: '0 0 14px' } },
349
+ items.map((it, i) => h('div', { key: keyBase + '-' + i, style: { display: 'flex', gap: 8, marginBottom: 5 } },
350
+ h('span', {
351
+ style: {
352
+ flex: '0 0 auto', minWidth: ordered ? 17 : 10, color: T.caption, lineHeight: 1.8,
353
+ fontFamily: ordered ? mono : 'inherit', fontSize: ordered ? 12 : 13,
354
+ },
355
+ }, ordered ? (i + 1) + '.' : '–'),
356
+ h('span', { style: { flex: '1 1 auto', lineHeight: 1.8 } }, renderInline(it, keyBase + '-' + i)))))
357
+ }
358
+
359
+ function mdLede(text, keyBase) {
360
+ const clean = String(text || '').replace(/^(\*\*)?一句话(\*\*)?\s*[::]\s*/, '')
361
+ return h('div', {
362
+ key: keyBase,
363
+ style: { margin: '0 0 26px', padding: '2px 0 2px 18px', borderLeft: `2px solid ${ACCENT}`, fontSize: 16.5, lineHeight: 1.72, color: T.text },
364
+ }, renderInline(clean, keyBase, null))
365
+ }
366
+
367
+ function mdQuote(text, keyBase) {
368
+ const clean = String(text || '').replace(/^(\*\*)?一句话(\*\*)?\s*[::]\s*/, '')
369
+ return h('div', {
370
+ key: keyBase,
371
+ style: { margin: '0 0 14px', padding: '2px 0 2px 14px', borderLeft: `2px solid ${T.b2}`, color: T.text2, lineHeight: 1.8 },
372
+ }, renderInline(clean, keyBase, null))
373
+ }
374
+
375
+ function Markdown({ source, ledeHeading, ledeFirstQuote, onOpenPaper }) {
376
+ const src = stripEntryChrome(source)
377
+ const lines = src.split(/\r?\n/)
378
+ const blocks = []
379
+ const opts = { onOpenPaper }
380
+ let key = 0
381
+ let ledeDone = false
382
+ let sawSection = false
383
+ const k = () => 'md' + key++
384
+ let i = 0
385
+ while (i < lines.length) {
386
+ const line = lines[i]
387
+ if (!line.trim()) {
388
+ i += 1
389
+ continue
390
+ }
391
+ if (line.trim().startsWith('|') && i + 1 < lines.length && /^\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1].trim())) {
392
+ // 条目开头的基础信息表(页数/图数/作者…)与面板的元数据条重复,跳过
393
+ if (!sawSection) {
394
+ i += 2
395
+ while (i < lines.length && lines[i].trim().startsWith('|')) i += 1
396
+ continue
397
+ }
398
+ const header = splitTableRow(line)
399
+ const rows = []
400
+ i += 2
401
+ while (i < lines.length && lines[i].trim().startsWith('|')) {
402
+ rows.push(splitTableRow(lines[i]))
403
+ i += 1
404
+ }
405
+ blocks.push(mdTable(header, rows, k()))
406
+ continue
407
+ }
408
+ const hm = /^(#{1,6})\s+(.+?)\s*$/.exec(line)
409
+ if (hm) {
410
+ const level = hm[1].length
411
+ const text = hm[2].replace(MD_EMOJI_HEAD, '').trim()
412
+ i += 1
413
+ if (level === 1) continue
414
+ if (level === 2) sawSection = true
415
+ if (level === 2 && ledeHeading && text.includes(ledeHeading) && !ledeDone) {
416
+ const quote = []
417
+ while (i < lines.length && (!lines[i].trim() || /^>\s?/.test(lines[i]))) {
418
+ if (lines[i].trim()) quote.push(lines[i].replace(/^>\s?/, ''))
419
+ i += 1
420
+ }
421
+ blocks.push(mdLede(quote.join(' '), k()))
422
+ ledeDone = true
423
+ continue
424
+ }
425
+ if (level === 2) {
426
+ blocks.push(h('div', {
427
+ key: k(),
428
+ style: { margin: '30px 0 10px', paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: FS.sub, fontWeight: 600, color: T.text },
429
+ }, text))
430
+ } else {
431
+ blocks.push(h('div', { key: k(), style: { margin: '20px 0 6px', fontSize: 13.5, fontWeight: 600, color: T.text2 } }, text))
432
+ }
433
+ continue
434
+ }
435
+ if (/^>\s?/.test(line)) {
436
+ const quote = []
437
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
438
+ quote.push(lines[i].replace(/^>\s?/, ''))
439
+ i += 1
440
+ }
441
+ if (ledeFirstQuote && !ledeDone) {
442
+ blocks.push(mdLede(quote.join(' '), k()))
443
+ ledeDone = true
444
+ } else {
445
+ blocks.push(mdQuote(quote.join(' '), k()))
446
+ }
447
+ continue
448
+ }
449
+ if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) {
450
+ const ordered = /^\s*\d+\./.test(line)
451
+ const items = []
452
+ while (i < lines.length && /^\s*(?:[-*+]|\d+\.)\s+/.test(lines[i])) {
453
+ items.push(lines[i].replace(/^\s*(?:[-*+]|\d+\.)\s+/, ''))
454
+ i += 1
455
+ while (i < lines.length && /^\s{2,}\S/.test(lines[i]) && !/^\s*(?:[-*+]|\d+\.)\s+/.test(lines[i])) {
456
+ items[items.length - 1] += ' ' + lines[i].trim()
457
+ i += 1
458
+ }
459
+ }
460
+ blocks.push(mdList(items, ordered, k()))
461
+ continue
462
+ }
463
+ const img = /^!\[([^\]]*)\]\(([^)]+)\)\s*$/.exec(line.trim())
464
+ if (img) {
465
+ blocks.push(h('div', { key: k(), style: { margin: '6px 0', fontSize: 12.5, color: T.caption } }, img[1] ? '图:' + img[1] : '[图]'))
466
+ i += 1
467
+ continue
468
+ }
469
+ if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
470
+ blocks.push(h('div', { key: k(), style: { height: 1, background: 'rgba(255,255,255,0.06)', margin: '22px 0' } }))
471
+ i += 1
472
+ continue
473
+ }
474
+ const para = [line]
475
+ i += 1
476
+ while (i < lines.length && lines[i].trim() && !MD_BLOCK_START.test(lines[i])) {
477
+ para.push(lines[i])
478
+ i += 1
479
+ }
480
+ blocks.push(h('p', { key: k(), style: { margin: '0 0 12px', lineHeight: 1.85 } }, renderInline(para.join(' '), k(), opts)))
481
+ }
482
+ return h('div', { style: { color: '#dcdce2' } }, blocks)
483
+ }
484
+
485
+ // ---------------- 阅读区共用小组件 ----------------
486
+
487
+ function MetaStrip({ items }) {
488
+ const shown = (items || []).filter((x) => x && x.value)
489
+ if (!shown.length) return null
490
+ return h('div', { style: { marginTop: 16, display: 'flex', flexWrap: 'wrap', gap: '14px 30px' } },
491
+ shown.map((x) => h('div', { key: x.label, style: { display: 'flex', flexDirection: 'column', gap: 3 } },
492
+ h('div', { style: { fontSize: 11.5, color: T.caption } }, x.label),
493
+ h('div', { style: { fontFamily: mono, fontSize: 12.5, color: T.text2, fontVariantNumeric: 'tabular-nums' } }, x.value))))
494
+ }
495
+
496
+ function ConceptChip({ name, title }) {
497
+ return h('span', {
498
+ title: title || '概念 · ' + name,
499
+ style: { display: 'inline-flex', alignItems: 'center', gap: 6, height: 25, padding: '0 9px', borderRadius: 6, background: hexA(CONCEPT_COLOR, 0.13), color: CONCEPT_COLOR, fontSize: 12, whiteSpace: 'nowrap' },
500
+ }, h('span', { style: { width: 5, height: 5, borderRadius: '50%', background: CONCEPT_COLOR } }), name)
501
+ }
502
+
503
+ function TaxChip({ id }) {
504
+ const color = tagColor(id)
505
+ const label = String(id).replace(/^([^/-]+)[/-]/, '$1 · ')
506
+ return h('span', {
507
+ title: String(id),
508
+ style: { display: 'inline-flex', alignItems: 'center', gap: 6, height: 25, padding: '0 9px', borderRadius: 6, background: hexA(color, 0.13), color, fontSize: 12, whiteSpace: 'nowrap' },
509
+ }, h('span', { style: { width: 5, height: 5, borderRadius: '50%', background: color } }), label)
510
+ }
511
+
512
+ function RailHead({ title, count }) {
513
+ return h('div', { style: { padding: '16px 16px 10px', display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' } },
514
+ h('div', { style: { fontSize: 13, fontWeight: 600, color: T.text2 } }, title),
515
+ h('span', { style: { fontFamily: mono, fontSize: 12, color: T.caption, fontVariantNumeric: 'tabular-nums' } }, count))
516
+ }
517
+
518
+ function RailSearch({ value, onChange, placeholder }) {
519
+ return h('div', {
520
+ style: { margin: '0 12px 8px', height: 36, display: 'flex', alignItems: 'center', gap: 8, padding: '0 11px', background: T.bg2, border: '1px solid transparent', borderRadius: 8 },
521
+ },
522
+ h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', style: { flex: '0 0 auto', opacity: 0.55 } },
523
+ h('circle', { cx: 7, cy: 7, r: 4.6, stroke: 'currentColor', strokeWidth: 1.5 }),
524
+ h('path', { d: 'M10.5 10.5 14 14', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' })),
525
+ h('input', {
526
+ value,
527
+ onChange: (e) => onChange(e.target.value),
528
+ placeholder,
529
+ style: { flex: '1 1 auto', minWidth: 0, border: 'none', background: 'transparent', color: T.text, fontSize: 13.5, fontFamily: 'inherit', caretColor: ACCENT },
530
+ }))
531
+ }
532
+
533
+ function ReaderEmpty({ title, sub }) {
534
+ return h('div', { style: { height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, padding: 24 } },
535
+ h('div', { style: { fontSize: 14.5, fontWeight: 600, color: T.text2 } }, title),
536
+ sub ? h('div', { style: { fontSize: 12.5, color: T.caption, textAlign: 'center' } }, sub) : null)
537
+ }
538
+
539
+ function ReaderPane({ children }) {
540
+ return h('div', { style: { minHeight: 0, overflowY: 'auto' } },
541
+ h('div', { style: { maxWidth: 760, padding: '30px 32px 72px', boxSizing: 'border-box' } }, children))
542
+ }
543
+
544
+ function DocActions({ children }) {
545
+ return h('div', { style: { flex: '0 0 auto', display: 'flex', gap: 6, marginTop: 2 } }, children)
546
+ }
547
+
548
+ function H2({ children }) {
549
+ return h('div', { style: { margin: '30px 0 10px', paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: FS.sub, fontWeight: 600, color: T.text } }, children)
550
+ }
551
+
152
552
  function SetupCard({ setup, onSaved }) {
153
553
  const [key, setKey] = useState('')
154
554
  const [vault, setVault] = useState(setup.vault || '')
@@ -301,6 +701,18 @@ window.__ModuleLoader__.load({
301
701
  `Python:${setup.python && setup.python.ok ? '✅ ' + (setup.python.version || '可用') : '❌ 未找到(' + ((setup.python && setup.python.exe) || 'python') + ')'}`,
302
702
  h('br'),
303
703
  `MinerU key:${setup.keySource === 'env' ? '来自环境变量 MINERU_API_KEY' : setup.hasKey ? '已保存(…' + setup.keyTail + ')' : '未配置'}`,
704
+ setup.hasKey && setup.pythonKey
705
+ ? h(
706
+ 'div',
707
+ { style: { marginTop: 6, color: setup.pythonKey.ok === false ? ERROR_TEXT : muted, fontWeight: setup.pythonKey.ok === false ? 600 : 400 } },
708
+ setup.pythonKey.ok === true
709
+ ? '✓ Python 流水线能读到同一个 key'
710
+ : setup.pythonKey.ok === false
711
+ ? '⚠ Python 流水线读不到这个 key,导入会失败:' + (setup.pythonKey.detail || '未知原因') +
712
+ '。请把插件升级到含修复的版本,或给 DSH 进程设置环境变量 MINERU_API_KEY 后重启。'
713
+ : '(Python key 自检已跳过:' + (setup.pythonKey.detail || '流水线/解释器不可用') + ')',
714
+ )
715
+ : null,
304
716
  h('br'),
305
717
  `密钥文件:${setup.secretsPath}`,
306
718
  ),
@@ -519,14 +931,14 @@ window.__ModuleLoader__.load({
519
931
  }
520
932
 
521
933
  function Overview({ d, onOpen }) {
522
- const tagItems = (d.tags || []).slice(0, 14).map((t) => ({ key: t.tag, label: t.tag, value: t.count }))
934
+ const tagItems = (d.tags || []).slice(0, 14).map((t) => ({ key: t.tag, label: t.tag, value: t.count, color: tagColor(t.tag) }))
523
935
  const yearItems = (d.years || []).slice(-14).map((y) => ({ key: String(y.year), label: String(y.year), value: y.count }))
524
936
  return h(
525
937
  'div',
526
938
  null,
527
939
  h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: SP.md } },
528
940
  h(Card, { label: '论文条目', value: fmt(d.papers) }),
529
- h(Card, { label: '概念页', value: fmt(d.concepts) }),
941
+ h(Card, { label: '概念页', value: fmt(d.concepts), accent: d.concepts ? CONCEPT_COLOR : undefined }),
530
942
  h(Card, { label: '待补元数据', value: fmt(d.pendingMeta), accent: d.pendingMeta ? WARN_TEXT : undefined }),
531
943
  h(Card, { label: '待生成摘要', value: fmt(d.pendingSummary), accent: d.pendingSummary ? WARN_TEXT : undefined, sub: d.pendingSummary ? '导入后未跑摘要' : '全部齐全' }),
532
944
  ),
@@ -557,15 +969,46 @@ window.__ModuleLoader__.load({
557
969
  (d.conceptNames || []).length
558
970
  ? h(Section, { title: `概念层(${d.concepts} 页)` },
559
971
  h('div', { style: { display: 'flex', gap: SP.sm, flexWrap: 'wrap' } },
560
- d.conceptNames.map((n) => h(Chip, { key: n }, n))))
972
+ d.conceptNames.map((n) => h(ConceptChip, { key: n, name: n }))))
561
973
  : null,
562
974
  )
563
975
  }
564
976
 
977
+ // 批量补摘要进度条:贴在内容区底部,任务结束后保留可收起
978
+ function BatchBar({ job, onCancel, onClose }) {
979
+ const failed = (job.failed || []).length
980
+ const ok = Math.max(0, (job.done || 0) - failed)
981
+ const label = job.state === 'running'
982
+ ? `批量补摘要:${job.done || 0}/${job.total || 0}${failed ? `,失败 ${failed}` : ''}`
983
+ : `批量补摘要结束:成功 ${ok}/${job.total || 0}${failed ? `,失败 ${failed}` : ''}${job.state === 'cancelled' ? '(已停止,再点继续)' : ''}`
984
+ return h(
985
+ 'div',
986
+ {
987
+ style: {
988
+ position: 'absolute', left: 20, right: 20, bottom: 24, zIndex: 30,
989
+ background: T.bg1, border: `1px solid ${line}`, borderRadius: R.md,
990
+ boxShadow: '0 10px 30px rgba(0,0,0,0.28)', padding: '10px 12px',
991
+ display: 'flex', flexDirection: 'column', gap: 6,
992
+ },
993
+ },
994
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: SP.md } },
995
+ h('div', { style: { flex: '1 1 auto', fontSize: FS.body, fontWeight: 600 } }, label),
996
+ job.state === 'running'
997
+ ? h(Btn, { small: true, ghost: true, onClick: onCancel, title: '跑完当前这篇后停下,下次再点继续' }, '停止')
998
+ : h(Btn, { small: true, ghost: true, onClick: onClose }, '收起')),
999
+ h('div', {
1000
+ style: {
1001
+ fontFamily: mono, fontSize: 11.5, color: muted, lineHeight: 1.5,
1002
+ maxHeight: 88, overflowY: 'auto', whiteSpace: 'pre-wrap',
1003
+ },
1004
+ }, (job.lines || []).slice(-5).join('\n')))
1005
+ }
1006
+
565
1007
  function Papers({ papers, loading, onSelect, selected, selectedData, onSummarize, running, vaultName }) {
566
1008
  const [q, setQ] = useState('')
567
1009
  const [domain, setDomain] = useState('')
568
1010
  const [onlyPending, setOnlyPending] = useState(false)
1011
+ const [hovered, setHovered] = useState('')
569
1012
  const domains = [...new Set((papers || []).flatMap((p) => p.tags.filter((t) => t.startsWith('领域/')).map((t) => t.slice(3))))].sort((a, b) => a.localeCompare(b, 'zh'))
570
1013
  const items = (papers || []).filter((p) => {
571
1014
  if (onlyPending && !p.pending) return false
@@ -573,93 +1016,85 @@ window.__ModuleLoader__.load({
573
1016
  if (!q) return true
574
1017
  return `${p.title} ${p.year} ${p.journal} ${p.tags.join(' ')}`.toLowerCase().includes(q.toLowerCase())
575
1018
  })
576
- const rows = items.map((p) =>
577
- h(
1019
+ const rows = items.map((p) => {
1020
+ const on = selected === p.dir
1021
+ const dom = domainOfPaper(p)
1022
+ const domColor = domainColor(dom, domains)
1023
+ const meta = p.journal || dom || '—'
1024
+ return h(
578
1025
  'div',
579
1026
  {
580
1027
  key: p.dir,
581
1028
  onClick: () => onSelect(p.dir),
1029
+ onMouseEnter: () => setHovered(p.dir),
1030
+ onMouseLeave: () => setHovered(''),
582
1031
  title: p.title,
583
1032
  style: {
584
- padding: '10px 12px',
585
- borderRadius: R.md,
586
- cursor: 'pointer',
587
- background: selected === p.dir ? 'rgba(77,107,254,0.16)' : card,
588
- border: `1px solid ${selected === p.dir ? 'rgba(77,107,254,0.45)' : line}`,
1033
+ position: 'relative', display: 'grid', gridTemplateColumns: '38px 1fr', gap: 10,
1034
+ padding: '10px 10px 11px', borderRadius: 8, cursor: 'pointer',
1035
+ background: on ? 'rgba(77,107,254,0.12)' : hovered === p.dir ? hoverBg : 'transparent',
589
1036
  },
590
1037
  },
591
- h(
592
- 'div',
593
- { style: { display: 'flex', gap: SP.sm, alignItems: 'baseline' } },
594
- h('span', { style: { color: faint, flex: '0 0 auto', fontSize: FS.caption, fontVariantNumeric: 'tabular-nums' } }, p.year || '—'),
595
- h('span', { style: { flex: '1 1 auto', fontSize: FS.body, fontWeight: 550, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, p.title),
596
- p.pending ? h('span', { style: { color: WARN_TEXT, flex: '0 0 auto', fontSize: FS.caption } }, '待总结') : null,
597
- ),
598
- h('div', {
599
- style: {
600
- marginTop: 4, fontSize: FS.caption, color: muted, lineHeight: 1.5,
601
- display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
602
- },
603
- }, (p.summary || '').replace(/^>\s*/, '') || (p.tags || []).slice(0, 5).join(' · ')),
604
- ),
605
- )
606
- const left = h(
607
- 'div',
608
- { style: { borderRight: `1px solid ${line}`, overflowY: 'auto', minHeight: 0, padding: '14px 10px 16px' } },
609
- h('input', {
610
- value: q,
611
- onChange: (e) => setQ(e.target.value),
612
- placeholder: '搜索标题 / 年份 / 期刊 / 标签…',
613
- style: {
614
- width: '100%', boxSizing: 'border-box', height: 34, padding: '0 11px',
615
- borderRadius: R.sm, border: `1px solid ${line}`, background: T.bg1,
616
- color: 'inherit', fontSize: FS.body, marginBottom: SP.sm, outline: 'none',
617
- },
618
- }),
619
- h(
620
- 'div',
621
- { style: { display: 'flex', gap: SP.sm, alignItems: 'center', marginBottom: SP.sm } },
622
- h(
623
- 'select',
624
- {
625
- value: domain,
626
- onChange: (e) => setDomain(e.target.value),
1038
+ on ? h('div', { style: { position: 'absolute', left: 0, top: 9, bottom: 9, width: 2, borderRadius: 2, background: ACCENT } }) : null,
1039
+ h('div', { style: { fontFamily: mono, fontSize: 12, color: T.text3, paddingTop: 2, fontVariantNumeric: 'tabular-nums' } }, p.year || '—'),
1040
+ h('div', { style: { minWidth: 0 } },
1041
+ h('div', {
627
1042
  style: {
628
- flex: '1 1 auto', minWidth: 0, height: 32, padding: '0 8px',
629
- borderRadius: R.sm, border: `1px solid ${line}`, background: card,
630
- color: 'inherit', fontSize: FS.caption,
1043
+ fontSize: 13.5, lineHeight: 1.45, fontWeight: 550, display: '-webkit-box',
1044
+ WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
631
1045
  },
1046
+ }, p.title),
1047
+ h('div', { style: { marginTop: 4, display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, fontSize: 12, color: T.caption } },
1048
+ h('span', { style: { flex: '1 1 auto', display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0 } },
1049
+ h('span', { style: { flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 5, color: dom ? domColor : T.caption } },
1050
+ h('span', { style: { width: 5, height: 5, borderRadius: '50%', background: dom ? domColor : 'transparent' } }), dom || '—'),
1051
+ h('span', { style: { flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, p.journal || '')),
1052
+ p.pending
1053
+ ? h('span', { style: { flex: '0 0 auto', color: ORANGE, display: 'inline-flex', alignItems: 'center', gap: 4 } },
1054
+ h('span', { style: { width: 5, height: 5, borderRadius: '50%', background: 'currentColor' } }), '待总结')
1055
+ : null)))
1056
+ })
1057
+ const left = h(
1058
+ 'div',
1059
+ { style: { minHeight: 0, display: 'flex', flexDirection: 'column', background: T.bg1, borderRight: `1px solid ${line}` } },
1060
+ h(RailHead, { title: '论文', count: fmt((papers || []).length) }),
1061
+ h(RailSearch, { value: q, onChange: setQ, placeholder: '搜索标题 / 年份 / 期刊 / 标签' }),
1062
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px 10px' } },
1063
+ h('select', {
1064
+ value: domain,
1065
+ onChange: (e) => setDomain(e.target.value),
1066
+ style: {
1067
+ flex: '1 1 auto', minWidth: 0, height: 26, padding: '0 8px', border: 'none',
1068
+ borderRadius: 6, background: T.bg2, color: T.text2, fontSize: 12.5,
632
1069
  },
633
- [h('option', { key: '', value: '' }, '全部领域')].concat(domains.map((d) => h('option', { key: d, value: d }, d))),
634
- ),
635
- h(
636
- 'label',
637
- { style: { display: 'flex', alignItems: 'center', gap: 4, color: muted, cursor: 'pointer', whiteSpace: 'nowrap', fontSize: FS.caption } },
638
- h('input', { type: 'checkbox', checked: onlyPending, onChange: (e) => setOnlyPending(e.target.checked) }),
639
- '只看待补',
640
- ),
641
- ),
642
- h('div', { style: { fontSize: FS.caption, color: faint, marginBottom: SP.sm } }, `显示 ${items.length} / ${(papers || []).length} 篇`),
1070
+ }, [h('option', { key: '', value: '' }, '全部领域')].concat(domains.map((d) => h('option', { key: d, value: d }, d)))),
1071
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 6, color: T.text2, cursor: 'pointer', whiteSpace: 'nowrap', fontSize: 12.5 } },
1072
+ h('input', { type: 'checkbox', checked: onlyPending, onChange: (e) => setOnlyPending(e.target.checked), style: { accentColor: ACCENT, margin: 0 } }),
1073
+ '只看待补')),
1074
+ h('div', { style: { padding: '0 16px 8px', fontSize: 12, color: T.caption, fontVariantNumeric: 'tabular-nums' } }, `显示 ${items.length} / ${(papers || []).length} 篇`),
643
1075
  loading
644
- ? h('div', { style: { color: muted, fontSize: FS.body } }, '加载中…')
645
- : h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } }, rows),
1076
+ ? h('div', { style: { color: muted, fontSize: FS.body, padding: '8px 16px' } }, '加载中…')
1077
+ : h('div', { style: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto', padding: '0 8px 32px' } }, rows),
646
1078
  )
647
1079
  const right = h(
648
1080
  'div',
649
- { style: { overflowY: 'auto', minWidth: 0, minHeight: 0, padding: '0 24px 48px' } },
650
- selectedData && selectedData.pending
651
- ? h(
652
- 'div',
653
- { style: { display: 'flex', alignItems: 'center', gap: SP.md, marginBottom: SP.md, padding: '10px 12px', borderRadius: R.md, background: 'rgba(196,123,30,0.12)', border: '1px solid rgba(196,123,30,0.35)' } },
654
- h('span', { style: { fontSize: FS.body, flex: '1 1 auto' } }, '这篇条目还没有摘要与标签'),
655
- h(Btn, { primary: true, disabled: running, onClick: () => onSummarize(selected) }, running ? '处理中…' : '生成摘要与标签'),
656
- )
657
- : null,
1081
+ { style: { minHeight: 0, overflowY: 'auto' } },
658
1082
  selected && selectedData
659
- ? h(PaperPreview, { data: selectedData, vaultName })
660
- : h(Empty, null, '从左边选一篇论文,这里显示条目内容'),
1083
+ ? h(ReaderPane, null,
1084
+ selectedData.pending
1085
+ ? h('div', {
1086
+ style: {
1087
+ display: 'flex', alignItems: 'center', gap: SP.md, marginBottom: 20,
1088
+ padding: '10px 14px', borderRadius: 8, background: 'rgba(196,123,30,0.10)', border: '1px solid rgba(196,123,30,0.30)',
1089
+ },
1090
+ },
1091
+ h('span', { style: { fontSize: FS.body, flex: '1 1 auto' } }, '这篇条目还没有摘要与标签'),
1092
+ h(Btn, { small: true, primary: true, disabled: running, onClick: () => onSummarize(selected) }, running ? '处理中…' : '生成摘要与标签'))
1093
+ : null,
1094
+ h(PaperPreview, { data: selectedData, onOpenPaper: onSelect }))
1095
+ : h(ReaderEmpty, { title: '从左侧选一篇论文', sub: '可搜索、按领域筛选、只看待补;条目内容按摘要排版显示' }),
661
1096
  )
662
- return h('div', { style: { display: 'grid', gridTemplateColumns: '348px 1fr', height: '100%', minHeight: 0 } }, left, right)
1097
+ return h('div', { style: { display: 'grid', gridTemplateColumns: '336px 1fr', height: '100%', minHeight: 0 } }, left, right)
663
1098
  }
664
1099
 
665
1100
  // obsidian:// 只能由宿主侧拉起:桌面版对自定义协议的 window.open 一律 deny
@@ -673,9 +1108,10 @@ window.__ModuleLoader__.load({
673
1108
  return await r.json()
674
1109
  }
675
1110
 
676
- function PaperPreview({ data, vaultName }) {
1111
+ function PaperPreview({ data, onOpenPaper }) {
677
1112
  const [msg, setMsg] = useState('')
678
1113
  if (!data) return null
1114
+ const fm = parsePaperFrontmatter(data.index)
679
1115
  const rel = `1-Papers/${data.dir}/index.md`
680
1116
  const copyPath = () => {
681
1117
  try {
@@ -697,61 +1133,37 @@ window.__ModuleLoader__.load({
697
1133
  setMsg('打开失败:' + String(e) + ',已复制条目路径')
698
1134
  }
699
1135
  }
700
- const readout = [data.year, data.journal, data.doi ? 'doi:' + data.doi : data.arxiv ? 'arXiv:' + data.arxiv : '', data.pages ? `${data.pages} 页` : '', data.figures ? `${data.figures} 图` : '']
701
- .filter(Boolean)
1136
+ const meta = [
1137
+ { label: '年份', value: fm.year },
1138
+ { label: '期刊/会议', value: fm.journal },
1139
+ { label: 'DOI', value: fm.doi || (fm.arxiv ? 'arXiv ' + fm.arxiv : '') },
1140
+ { label: '篇幅', value: [fm.pages && fm.pages + ' 页', fm.figures && fm.figures + ' 图'].filter(Boolean).join(' · ') },
1141
+ { label: '引用键', value: fm.citekey },
1142
+ ]
1143
+ const concepts = data.concepts || []
1144
+ const taxonomy = (data.taxonomy || []).slice().sort((a, b) => {
1145
+ const order = ['领域', '方法', '应用', '仪器']
1146
+ return order.indexOf(tagCategory(a)) - order.indexOf(tagCategory(b))
1147
+ })
702
1148
  return h(
703
1149
  'div',
704
1150
  null,
705
- h(
706
- 'div',
707
- { style: { display: 'flex', gap: SP.sm, alignItems: 'flex-start', flexWrap: 'wrap' } },
708
- h('div', { style: { fontSize: FS.page, fontWeight: 650, lineHeight: 1.35, flex: '1 1 260px', margin: 0 } }, data.title || data.dir),
709
- h(Btn, { small: true, onClick: openInObsidian, title: '用 Obsidian 打开这篇笔记' }, '在 Obsidian 打开'),
710
- h(Btn, { small: true, ghost: true, onClick: copyPath, title: '复制库内相对路径' }, '复制路径'),
711
- ),
1151
+ h('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 16 } },
1152
+ h('div', { style: { flex: '1 1 auto', fontSize: FS.page, lineHeight: 1.4, fontWeight: 650 } }, fm.title || data.dir),
1153
+ h(DocActions, null,
1154
+ h(Btn, { small: true, ghost: true, onClick: copyPath, title: '复制库内相对路径' }, '复制路径'),
1155
+ h(Btn, { small: true, onClick: openInObsidian, title: '用 Obsidian 打开这篇笔记' }, '在 Obsidian 打开'))),
1156
+ h(MetaStrip, { items: meta }),
712
1157
  msg ? h('div', { style: { fontSize: FS.caption, color: ERROR_TEXT, marginTop: SP.xs } }, msg) : null,
713
- h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6, fontFamily: mono, fontSize: 12.5, color: faint } },
714
- readout.map((m, i) => h('span', { key: i, style: { display: 'inline-flex', alignItems: 'center', gap: 8 } },
715
- i > 0 ? h('span', { style: { color: line } }, '/') : null, m))),
716
- data.concepts && data.concepts.length
717
- ? h(
718
- 'div',
719
- { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: SP.sm } },
720
- data.concepts.map((c) =>
721
- h('span', { key: c, style: { fontSize: FS.caption, padding: '3px 10px', borderRadius: 999, border: `1px solid ${line}`, background: 'rgba(167,139,250,0.10)' } }, '概念 · ' + c),
722
- ),
723
- )
1158
+ concepts.length || taxonomy.length
1159
+ ? h('div', { style: { marginTop: 16, display: 'flex', flexWrap: 'wrap', gap: 6 } },
1160
+ concepts.map((c) => h(ConceptChip, { key: 'c' + c, name: c })),
1161
+ taxonomy.map((t) => h(TaxChip, { key: 't' + t, id: t })))
724
1162
  : null,
725
- data.taxonomy && data.taxonomy.length
726
- ? h(
727
- 'div',
728
- { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 6 } },
729
- data.taxonomy.map((c) =>
730
- h('span', { key: c, style: { fontSize: FS.caption, padding: '3px 10px', borderRadius: 999, border: '1px solid rgba(255,215,0,0.35)', background: 'rgba(255,215,0,0.06)' } }, '分类 · ' + c),
731
- ),
732
- )
733
- : null,
734
- h(
735
- 'pre',
736
- {
737
- style: {
738
- marginTop: SP.md, fontSize: FS.body, lineHeight: 1.85, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
739
- background: card, border: `1px solid ${line}`, borderRadius: R.lg, padding: '16px 18px',
740
- fontFamily: 'inherit', color: 'inherit', maxHeight: 'none',
741
- },
742
- },
743
- data.index || '',
744
- ),
1163
+ h('div', { style: { marginTop: 26 } }, h(Markdown, { source: data.index, ledeHeading: '一句话总结', onOpenPaper })),
745
1164
  )
746
1165
  }
747
1166
 
748
- const DOMAIN_COLORS = ['#f472b6', '#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#38bdf8', '#fb923c', '#4ade80', '#e879f9', '#22d3ee', '#f87171', '#a3e635']
749
-
750
- function domainColor(domain, domains) {
751
- const i = Math.max(0, domains.indexOf(domain))
752
- return DOMAIN_COLORS[i % DOMAIN_COLORS.length]
753
- }
754
-
755
1167
  function layoutGraph(data) {
756
1168
  const W = 1000
757
1169
  const H = 660
@@ -883,7 +1295,7 @@ window.__ModuleLoader__.load({
883
1295
  dots.push(h('span', { key: 'tax', style: { display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: FS.caption, color: muted } },
884
1296
  h('span', { style: { width: 8, height: 8, borderRadius: 999, background: '#ffd700' } }), '分类'))
885
1297
  dots.push(h('span', { key: 'con', style: { display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: FS.caption, color: muted } },
886
- h('span', { style: { width: 8, height: 8, borderRadius: 999, background: '#a78bfa' } }), '概念'))
1298
+ h('span', { style: { width: 8, height: 8, borderRadius: 999, background: CONCEPT_COLOR } }), '概念'))
887
1299
 
888
1300
  const graph = (data.papers.length + (data.concepts || []).length) === 0
889
1301
  ? h('div', { style: { height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: faint, fontSize: FS.body } }, '还没有论文或概念,导入几篇后这里会出现关系图')
@@ -916,7 +1328,7 @@ window.__ModuleLoader__.load({
916
1328
  ? !domain || ((linked && linked.papers) || []).some((d) => visible(paperById.get(d) || { id: d }))
917
1329
  : visible(p) || (!focus && !domain)
918
1330
  const dim = focusId ? !focusNeighbors.has(n.id) : !vis
919
- const fill = isConcept ? '#a78bfa' : isTax ? '#ffd700' : domainColor(p.domain || '其他', domains)
1331
+ const fill = isConcept ? CONCEPT_COLOR : isTax ? '#d5a94a' : domainColor(p.domain || '其他', domains)
920
1332
  const deg = sim.degree.get(sim.nodes.indexOf(n)) || 0
921
1333
  const r = isConcept ? 10 + Math.min(6, ((n.ref.papers || []).length || 0) / 8)
922
1334
  : isTax ? 6 + Math.min(5, deg / 3)
@@ -983,6 +1395,8 @@ window.__ModuleLoader__.load({
983
1395
  const [active, setActive] = useState('')
984
1396
  const [detail, setDetail] = useState(null)
985
1397
  const [msg, setMsg] = useState('')
1398
+ const [q, setQ] = useState('')
1399
+ const [hovered, setHovered] = useState('')
986
1400
 
987
1401
  const rel = detail ? `2-Concepts/${detail.name}.md` : ''
988
1402
  const openInObsidian = async () => {
@@ -1031,67 +1445,100 @@ window.__ModuleLoader__.load({
1031
1445
  }
1032
1446
  }
1033
1447
 
1448
+ const filtered = (items || []).filter((c) => {
1449
+ if (!q) return true
1450
+ const hay = `${c.name} ${c.title} ${(c.aliases || []).join(' ')} ${c.oneLiner || ''}`.toLowerCase()
1451
+ return hay.includes(q.toLowerCase())
1452
+ })
1453
+
1454
+ const rows = filtered.map((c) => {
1455
+ const on = active === c.name
1456
+ return h(
1457
+ 'div',
1458
+ {
1459
+ key: c.name,
1460
+ onClick: () => open(c.name),
1461
+ onMouseEnter: () => setHovered(c.name),
1462
+ onMouseLeave: () => setHovered(''),
1463
+ title: c.oneLiner || c.title,
1464
+ style: {
1465
+ position: 'relative', display: 'grid', gridTemplateColumns: '1fr auto', gap: 10,
1466
+ padding: '10px 10px 11px', borderRadius: 8, cursor: 'pointer',
1467
+ background: on ? 'rgba(77,107,254,0.12)' : hovered === c.name ? hoverBg : 'transparent',
1468
+ },
1469
+ },
1470
+ on ? h('div', { style: { position: 'absolute', left: 0, top: 9, bottom: 9, width: 2, borderRadius: 2, background: CONCEPT_COLOR } }) : null,
1471
+ h('div', { style: { minWidth: 0 } },
1472
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 } },
1473
+ h('span', { style: { width: 6, height: 6, borderRadius: '50%', background: CONCEPT_COLOR, flex: '0 0 auto' } }),
1474
+ h('div', { style: { fontSize: 14, fontWeight: 550, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, c.title || c.name)),
1475
+ c.oneLiner
1476
+ ? h('div', { style: { marginTop: 3, fontSize: 12, color: T.caption, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingLeft: 13 } }, c.oneLiner)
1477
+ : null),
1478
+ h('div', { style: { fontFamily: mono, fontSize: 12, color: hexA(CONCEPT_COLOR, 0.75), fontVariantNumeric: 'tabular-nums', paddingTop: 3 } }, String(c.papersCount || 0)))
1479
+ })
1480
+
1034
1481
  const left = h(
1035
1482
  'div',
1036
- { style: { flex: '0 0 260px', minWidth: 220, maxHeight: 'calc(100vh - 320px)', overflowY: 'auto' } },
1483
+ { style: { minHeight: 0, display: 'flex', flexDirection: 'column', background: T.bg1, borderRight: `1px solid ${line}` } },
1484
+ h(RailHead, { title: '概念', count: items ? fmt(items.length) : '—' }),
1485
+ h(RailSearch, { value: q, onChange: setQ, placeholder: '搜索概念 / 别名' }),
1486
+ h('div', { style: { padding: '0 16px 8px', fontSize: 12, color: T.caption } }, '按引用论文数排序'),
1037
1487
  items
1038
- ? h(
1039
- 'div',
1040
- { style: { display: 'flex', flexDirection: 'column', gap: 2 } },
1041
- items.map((c) =>
1042
- h(
1043
- 'div',
1044
- {
1045
- key: c.name,
1046
- onClick: () => open(c.name),
1047
- title: c.oneLiner || c.title,
1048
- style: {
1049
- padding: '9px 11px',
1050
- borderRadius: R.sm,
1051
- cursor: 'pointer',
1052
- fontSize: FS.body,
1053
- background: active === c.name ? 'rgba(77,107,254,0.16)' : card,
1054
- border: `1px solid ${active === c.name ? 'rgba(77,107,254,0.45)' : line}`,
1055
- },
1056
- },
1057
- h('div', { style: { display: 'flex', gap: 6 } },
1058
- h('span', { style: { flex: '1 1 auto' } }, c.title),
1059
- h('span', { style: { color: muted, flex: '0 0 auto', fontSize: FS.caption } }, c.papersCount + ' 篇')),
1060
- h('div', { style: { fontSize: FS.caption, color: muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginTop: 2 } }, c.oneLiner || ''),
1061
- ),
1062
- ),
1063
- )
1064
- : h('div', { style: { color: muted, fontSize: FS.body } }, '加载中…'),
1488
+ ? h('div', { style: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto', padding: '0 8px 32px' } }, rows)
1489
+ : h('div', { style: { color: muted, fontSize: FS.body, padding: '8px 16px' } }, '加载中…'),
1065
1490
  )
1066
1491
 
1492
+ const fm = detail ? parsePaperFrontmatter(detail.markdown) : {}
1493
+ const tags = (fm.tags || []).filter((t) => !t.startsWith('状态/'))
1494
+ const related = (detail && detail.papers ? detail.papers.slice() : []).sort((a, b) => (parseFloat(b.year || '0') || 0) - (parseFloat(a.year || '0') || 0))
1067
1495
  const right = h(
1068
1496
  'div',
1069
- { style: { flex: '1 1 auto', minWidth: 320, maxHeight: 'calc(100vh - 320px)', overflowY: 'auto', borderLeft: `1px solid ${line}`, paddingLeft: 16 } },
1497
+ { style: { minHeight: 0, overflowY: 'auto' } },
1070
1498
  detail
1071
- ? h(
1072
- 'div',
1073
- null,
1074
- h(
1075
- 'div',
1076
- { style: { display: 'flex', gap: 8, alignItems: 'flex-start', flexWrap: 'wrap' } },
1077
- h('div', { style: { fontSize: FS.title, fontWeight: 650, flex: '1 1 220px' } }, detail.title),
1078
- h(Btn, { small: true, onClick: openInObsidian, title: '用 Obsidian 打开概念页' }, '在 Obsidian 打开'),
1079
- h(Btn, { small: true, ghost: true, onClick: copyPath, title: '复制库内相对路径' }, '复制路径'),
1080
- ),
1081
- h('div', { style: { fontSize: FS.caption, color: muted, marginTop: 4 } }, `${detail.papersCount} 篇论文 · ${(detail.tags || []).join(' ') || ''}`),
1082
- msg ? h('div', { style: { fontSize: FS.caption, color: ERROR_TEXT, marginTop: 4 } }, msg) : null,
1083
- h('pre', { style: { marginTop: SP.md, fontSize: FS.body, lineHeight: 1.85, whiteSpace: 'pre-wrap', wordBreak: 'break-word', background: card, border: `1px solid ${line}`, borderRadius: R.lg, padding: '16px 18px', fontFamily: 'inherit', color: 'inherit' } }, detail.markdown || ''),
1084
- h('div', { style: { fontSize: FS.sub, fontWeight: 600, marginTop: SP.lg } }, '相关论文'),
1085
- h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, marginTop: SP.sm } },
1086
- (detail.papers || []).map((p) =>
1087
- h('div', { key: p.dir, onClick: () => onOpenPaper(p.dir), title: p.title, style: { fontSize: FS.body, padding: '6px 8px', borderRadius: R.sm, cursor: 'pointer', background: card } },
1088
- h('span', { style: { color: muted, marginRight: 8 } }, p.year || '—'),
1089
- p.title))),
1090
- )
1091
- : h(Empty, null, '从左边选一个概念,查看页面和相关论文'),
1499
+ ? h(ReaderPane, null,
1500
+ h('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 16 } },
1501
+ h('div', { style: { flex: '1 1 auto', fontSize: FS.page, lineHeight: 1.4, fontWeight: 650 } }, detail.title || detail.name),
1502
+ h(DocActions, null,
1503
+ h(Btn, { small: true, ghost: true, onClick: copyPath, title: '复制库内相对路径' }, '复制路径'),
1504
+ h(Btn, { small: true, onClick: openInObsidian, title: '用 Obsidian 打开概念页' }, '在 Obsidian 打开'))),
1505
+ h(MetaStrip, {
1506
+ items: [
1507
+ { label: '类型', value: '概念页' },
1508
+ { label: '引用', value: (detail.papersCount || 0) + ' 篇论文' },
1509
+ { label: '别名', value: (fm.aliases || detail.aliases || []).slice(0, 4).join(' · ') },
1510
+ { label: '更新', value: fm.updated },
1511
+ ],
1512
+ }),
1513
+ msg ? h('div', { style: { fontSize: FS.caption, color: ERROR_TEXT, marginTop: SP.xs } }, msg) : null,
1514
+ tags.length
1515
+ ? h('div', { style: { marginTop: 16, display: 'flex', flexWrap: 'wrap', gap: 6 } }, tags.map((t) => h(TaxChip, { key: t, id: t })))
1516
+ : null,
1517
+ h('div', { style: { marginTop: 26 } }, h(Markdown, { source: detail.markdown, ledeFirstQuote: true, onOpenPaper })),
1518
+ related.length
1519
+ ? h('div', null,
1520
+ h(H2, null, '相关论文'),
1521
+ h('div', null, related.map((p) => h('div', {
1522
+ key: p.dir,
1523
+ onClick: () => onOpenPaper(p.dir),
1524
+ title: p.title,
1525
+ onMouseEnter: () => setHovered('paper:' + p.dir),
1526
+ onMouseLeave: () => setHovered(''),
1527
+ style: {
1528
+ display: 'grid', gridTemplateColumns: '42px 1fr', gap: 12, padding: '9px 8px', borderRadius: 7,
1529
+ cursor: 'pointer', borderBottom: '1px solid rgba(255,255,255,0.045)',
1530
+ background: hovered === 'paper:' + p.dir ? hoverBg : 'transparent',
1531
+ },
1532
+ },
1533
+ h('div', { style: { fontFamily: mono, fontSize: 12, color: T.text3, paddingTop: 2, fontVariantNumeric: 'tabular-nums' } }, p.year || '—'),
1534
+ h('div', { style: { fontSize: 13.5, lineHeight: 1.5 } }, p.title)))))
1535
+ : null)
1536
+ : h(ReaderEmpty, {
1537
+ title: items && items.length ? '从左侧选一个概念' : '还没有概念页',
1538
+ sub: items && items.length ? '概念页是跨论文综合,条目内容按摘要排版显示' : '概念页由 tools/ 下的素材、校验、索引脚本生成,规范见 config/CONCEPT_SPEC.md',
1539
+ }),
1092
1540
  )
1093
-
1094
- return h('div', { style: { display: 'flex', gap: 16, alignItems: 'flex-start' } }, left, right)
1541
+ return h('div', { style: { display: 'grid', gridTemplateColumns: '300px 1fr', height: '100%', minHeight: 0 } }, left, right)
1095
1542
  }
1096
1543
 
1097
1544
  function PaperDepotView() {
@@ -1107,6 +1554,8 @@ window.__ModuleLoader__.load({
1107
1554
  const [setup, setSetup] = useState(null)
1108
1555
  const [showSetup, setShowSetup] = useState(false)
1109
1556
  const [showImport, setShowImport] = useState(false)
1557
+ const [jobInfo, setJobInfo] = useState(null)
1558
+
1110
1559
 
1111
1560
  const loadSetup = useCallback(async () => {
1112
1561
  try {
@@ -1153,11 +1602,13 @@ window.__ModuleLoader__.load({
1153
1602
 
1154
1603
  const startPolling = useCallback(async () => {
1155
1604
  setRunning(true)
1156
- for (let i = 0; i < 900; i++) {
1605
+ // 批量补摘要可能跑几个小时:轮询上限放到 ~3 小时,任务结束后立即退出
1606
+ for (let i = 0; i < 9000; i++) {
1157
1607
  await new Promise((r) => setTimeout(r, 1200))
1158
1608
  try {
1159
1609
  const j = await (await api('/job')).json()
1160
1610
  if (j.job) {
1611
+ setJobInfo(j.job)
1161
1612
  setLog((j.job.lines || []).slice(-300))
1162
1613
  if (j.job.state !== 'running') {
1163
1614
  setRunning(false)
@@ -1255,6 +1706,33 @@ window.__ModuleLoader__.load({
1255
1706
  }
1256
1707
  }, [startPolling, openPaper, setLog])
1257
1708
 
1709
+ // 批量补摘要:排队把「待生成」的条目跑完;中断后再点会从剩下的继续
1710
+ const onSummarizePending = useCallback(async () => {
1711
+ setJobInfo(null)
1712
+ try {
1713
+ const r = await fetch('/paperdepot/api/summarize-pending', { method: 'POST', credentials: 'same-origin' })
1714
+ const j = await r.json()
1715
+ if (!j.ok) {
1716
+ setLog([`❌ ${j.error || r.status}`])
1717
+ return
1718
+ }
1719
+ setLog([`▶ 批量补摘要:排队 ${j.total} 篇(库里待生成 ${j.pending} 篇)`])
1720
+ await startPolling()
1721
+ } catch (e) {
1722
+ setLog([`❌ ${String(e)}`])
1723
+ }
1724
+ }, [startPolling, setLog])
1725
+
1726
+ const onCancelJob = useCallback(async () => {
1727
+ try {
1728
+ const r = await fetch('/paperdepot/api/job-cancel', { method: 'POST', credentials: 'same-origin' })
1729
+ const j = await r.json()
1730
+ if (!j.ok) setLog((old) => [...old, `❌ ${j.error || r.status}`])
1731
+ } catch (e) {
1732
+ setLog((old) => [...old, `❌ ${String(e)}`])
1733
+ }
1734
+ }, [setLog])
1735
+
1258
1736
  const onSetupSaved = useCallback(async () => {
1259
1737
  try {
1260
1738
  const s = await (await api('/setup/status')).json()
@@ -1301,11 +1779,11 @@ window.__ModuleLoader__.load({
1301
1779
  ['concepts', '概念', d ? String(d.concepts) : ''],
1302
1780
  ]
1303
1781
  const content = setup && !setup.hasKey
1304
- ? h('div', { style: { height: '100%', overflowY: 'auto', padding: '20px 20px 48px' } }, h(SetupCard, { setup, onSaved: onSetupSaved }))
1782
+ ? h('div', { style: { height: '100%', overflowY: 'auto', padding: '20px 20px 72px', boxSizing: 'border-box' } }, h(SetupCard, { setup, onSaved: onSetupSaved }))
1305
1783
  : d
1306
1784
  ? tab === 'papers'
1307
1785
  ? h(Papers, { papers, loading: papersLoading, onSelect: openPaper, selected, selectedData, onSummarize, running, vaultName })
1308
- : h('div', { style: { height: '100%', overflowY: 'auto', padding: '20px 20px 48px' } },
1786
+ : h('div', { style: { height: '100%', overflowY: 'auto', padding: '20px 20px 72px', boxSizing: 'border-box' } },
1309
1787
  tab === 'overview'
1310
1788
  ? h(Overview, { d, onOpen: openFromGraph })
1311
1789
  : h(Concepts, { onOpenPaper: openFromGraph, vaultName }))
@@ -1404,13 +1882,31 @@ window.__ModuleLoader__.load({
1404
1882
  style: { fontSize: 12.5, color: muted, background: T.bg2, padding: '1px 6px', borderRadius: 999, lineHeight: 1.5 },
1405
1883
  }, count) : null)),
1406
1884
  h('div', { style: { flex: '1 1 auto' } }),
1885
+ d && d.pendingSummary
1886
+ ? h(Btn, {
1887
+ small: true,
1888
+ ghost: true,
1889
+ onClick: onSummarizePending,
1890
+ disabled: running,
1891
+ title: '把「待生成」的条目排队跑完;可随时停止,下次再点会跳过已完成的',
1892
+ }, running
1893
+ ? `任务中(${jobInfo && jobInfo.kind === 'summarize-batch' ? `${jobInfo.done || 0}/${jobInfo.total || 0}` : '…'})`
1894
+ : `批量补摘要(${d.pendingSummary})`)
1895
+ : null,
1407
1896
  h(Btn, { small: true, ghost: true, onClick: onRebuildNav, disabled: running, title: '重建论文导航块、分类页与图颜色分组' }, running ? '重建中…' : '重建导航')),
1408
1897
  // 内容
1409
1898
  h('div', { style: { flex: '1 1 auto', minHeight: 0, position: 'relative' } }, content),
1899
+ // 底部安全区:flex 末尾的固定占位(等价 HarmonyOS 的 bottom safe area / Blank),
1900
+ // 让上方内容整体上移,不贴窗口下沿、不被任务栏压住;不放滚动区里,避免随内容滚走
1901
+ h('div', { style: { flex: '0 0 auto', height: 24 } }),
1410
1902
  showSetup && setup && setup.hasKey
1411
1903
  ? h('div', { style: { position: 'absolute', top: 58, right: 20, zIndex: 15, width: 540, maxWidth: '92vw', maxHeight: 'calc(100% - 80px)', overflowY: 'auto' } },
1412
1904
  h(SetupCard, { setup, onSaved: onSetupSaved }))
1413
1905
  : null,
1906
+ // 批量补摘要进度条:任务结束时保留,可收起
1907
+ jobInfo && jobInfo.kind === 'summarize-batch'
1908
+ ? h(BatchBar, { job: jobInfo, onCancel: onCancelJob, onClose: () => setJobInfo(null) })
1909
+ : null,
1414
1910
  h(ImportDialog, { open: showImport, onClose: () => setShowImport(false), running, log, uploadFiles }),
1415
1911
  )
1416
1912
  }