paperdepot 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 || '')
@@ -519,14 +919,14 @@ window.__ModuleLoader__.load({
519
919
  }
520
920
 
521
921
  function Overview({ d, onOpen }) {
522
- const tagItems = (d.tags || []).slice(0, 14).map((t) => ({ key: t.tag, label: t.tag, value: t.count }))
922
+ const tagItems = (d.tags || []).slice(0, 14).map((t) => ({ key: t.tag, label: t.tag, value: t.count, color: tagColor(t.tag) }))
523
923
  const yearItems = (d.years || []).slice(-14).map((y) => ({ key: String(y.year), label: String(y.year), value: y.count }))
524
924
  return h(
525
925
  'div',
526
926
  null,
527
927
  h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: SP.md } },
528
928
  h(Card, { label: '论文条目', value: fmt(d.papers) }),
529
- h(Card, { label: '概念页', value: fmt(d.concepts) }),
929
+ h(Card, { label: '概念页', value: fmt(d.concepts), accent: d.concepts ? CONCEPT_COLOR : undefined }),
530
930
  h(Card, { label: '待补元数据', value: fmt(d.pendingMeta), accent: d.pendingMeta ? WARN_TEXT : undefined }),
531
931
  h(Card, { label: '待生成摘要', value: fmt(d.pendingSummary), accent: d.pendingSummary ? WARN_TEXT : undefined, sub: d.pendingSummary ? '导入后未跑摘要' : '全部齐全' }),
532
932
  ),
@@ -557,15 +957,46 @@ window.__ModuleLoader__.load({
557
957
  (d.conceptNames || []).length
558
958
  ? h(Section, { title: `概念层(${d.concepts} 页)` },
559
959
  h('div', { style: { display: 'flex', gap: SP.sm, flexWrap: 'wrap' } },
560
- d.conceptNames.map((n) => h(Chip, { key: n }, n))))
960
+ d.conceptNames.map((n) => h(ConceptChip, { key: n, name: n }))))
561
961
  : null,
562
962
  )
563
963
  }
564
964
 
965
+ // 批量补摘要进度条:贴在内容区底部,任务结束后保留可收起
966
+ function BatchBar({ job, onCancel, onClose }) {
967
+ const failed = (job.failed || []).length
968
+ const ok = Math.max(0, (job.done || 0) - failed)
969
+ const label = job.state === 'running'
970
+ ? `批量补摘要:${job.done || 0}/${job.total || 0}${failed ? `,失败 ${failed}` : ''}`
971
+ : `批量补摘要结束:成功 ${ok}/${job.total || 0}${failed ? `,失败 ${failed}` : ''}${job.state === 'cancelled' ? '(已停止,再点继续)' : ''}`
972
+ return h(
973
+ 'div',
974
+ {
975
+ style: {
976
+ position: 'absolute', left: 20, right: 20, bottom: 24, zIndex: 30,
977
+ background: T.bg1, border: `1px solid ${line}`, borderRadius: R.md,
978
+ boxShadow: '0 10px 30px rgba(0,0,0,0.28)', padding: '10px 12px',
979
+ display: 'flex', flexDirection: 'column', gap: 6,
980
+ },
981
+ },
982
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: SP.md } },
983
+ h('div', { style: { flex: '1 1 auto', fontSize: FS.body, fontWeight: 600 } }, label),
984
+ job.state === 'running'
985
+ ? h(Btn, { small: true, ghost: true, onClick: onCancel, title: '跑完当前这篇后停下,下次再点继续' }, '停止')
986
+ : h(Btn, { small: true, ghost: true, onClick: onClose }, '收起')),
987
+ h('div', {
988
+ style: {
989
+ fontFamily: mono, fontSize: 11.5, color: muted, lineHeight: 1.5,
990
+ maxHeight: 88, overflowY: 'auto', whiteSpace: 'pre-wrap',
991
+ },
992
+ }, (job.lines || []).slice(-5).join('\n')))
993
+ }
994
+
565
995
  function Papers({ papers, loading, onSelect, selected, selectedData, onSummarize, running, vaultName }) {
566
996
  const [q, setQ] = useState('')
567
997
  const [domain, setDomain] = useState('')
568
998
  const [onlyPending, setOnlyPending] = useState(false)
999
+ const [hovered, setHovered] = useState('')
569
1000
  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
1001
  const items = (papers || []).filter((p) => {
571
1002
  if (onlyPending && !p.pending) return false
@@ -573,93 +1004,85 @@ window.__ModuleLoader__.load({
573
1004
  if (!q) return true
574
1005
  return `${p.title} ${p.year} ${p.journal} ${p.tags.join(' ')}`.toLowerCase().includes(q.toLowerCase())
575
1006
  })
576
- const rows = items.map((p) =>
577
- h(
1007
+ const rows = items.map((p) => {
1008
+ const on = selected === p.dir
1009
+ const dom = domainOfPaper(p)
1010
+ const domColor = domainColor(dom, domains)
1011
+ const meta = p.journal || dom || '—'
1012
+ return h(
578
1013
  'div',
579
1014
  {
580
1015
  key: p.dir,
581
1016
  onClick: () => onSelect(p.dir),
1017
+ onMouseEnter: () => setHovered(p.dir),
1018
+ onMouseLeave: () => setHovered(''),
582
1019
  title: p.title,
583
1020
  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}`,
1021
+ position: 'relative', display: 'grid', gridTemplateColumns: '38px 1fr', gap: 10,
1022
+ padding: '10px 10px 11px', borderRadius: 8, cursor: 'pointer',
1023
+ background: on ? 'rgba(77,107,254,0.12)' : hovered === p.dir ? hoverBg : 'transparent',
589
1024
  },
590
1025
  },
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),
1026
+ on ? h('div', { style: { position: 'absolute', left: 0, top: 9, bottom: 9, width: 2, borderRadius: 2, background: ACCENT } }) : null,
1027
+ h('div', { style: { fontFamily: mono, fontSize: 12, color: T.text3, paddingTop: 2, fontVariantNumeric: 'tabular-nums' } }, p.year || '—'),
1028
+ h('div', { style: { minWidth: 0 } },
1029
+ h('div', {
627
1030
  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,
1031
+ fontSize: 13.5, lineHeight: 1.45, fontWeight: 550, display: '-webkit-box',
1032
+ WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
631
1033
  },
1034
+ }, p.title),
1035
+ h('div', { style: { marginTop: 4, display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, fontSize: 12, color: T.caption } },
1036
+ h('span', { style: { flex: '1 1 auto', display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0 } },
1037
+ h('span', { style: { flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 5, color: dom ? domColor : T.caption } },
1038
+ h('span', { style: { width: 5, height: 5, borderRadius: '50%', background: dom ? domColor : 'transparent' } }), dom || '—'),
1039
+ h('span', { style: { flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, p.journal || '')),
1040
+ p.pending
1041
+ ? h('span', { style: { flex: '0 0 auto', color: ORANGE, display: 'inline-flex', alignItems: 'center', gap: 4 } },
1042
+ h('span', { style: { width: 5, height: 5, borderRadius: '50%', background: 'currentColor' } }), '待总结')
1043
+ : null)))
1044
+ })
1045
+ const left = h(
1046
+ 'div',
1047
+ { style: { minHeight: 0, display: 'flex', flexDirection: 'column', background: T.bg1, borderRight: `1px solid ${line}` } },
1048
+ h(RailHead, { title: '论文', count: fmt((papers || []).length) }),
1049
+ h(RailSearch, { value: q, onChange: setQ, placeholder: '搜索标题 / 年份 / 期刊 / 标签' }),
1050
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px 10px' } },
1051
+ h('select', {
1052
+ value: domain,
1053
+ onChange: (e) => setDomain(e.target.value),
1054
+ style: {
1055
+ flex: '1 1 auto', minWidth: 0, height: 26, padding: '0 8px', border: 'none',
1056
+ borderRadius: 6, background: T.bg2, color: T.text2, fontSize: 12.5,
632
1057
  },
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} 篇`),
1058
+ }, [h('option', { key: '', value: '' }, '全部领域')].concat(domains.map((d) => h('option', { key: d, value: d }, d)))),
1059
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 6, color: T.text2, cursor: 'pointer', whiteSpace: 'nowrap', fontSize: 12.5 } },
1060
+ h('input', { type: 'checkbox', checked: onlyPending, onChange: (e) => setOnlyPending(e.target.checked), style: { accentColor: ACCENT, margin: 0 } }),
1061
+ '只看待补')),
1062
+ h('div', { style: { padding: '0 16px 8px', fontSize: 12, color: T.caption, fontVariantNumeric: 'tabular-nums' } }, `显示 ${items.length} / ${(papers || []).length} 篇`),
643
1063
  loading
644
- ? h('div', { style: { color: muted, fontSize: FS.body } }, '加载中…')
645
- : h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } }, rows),
1064
+ ? h('div', { style: { color: muted, fontSize: FS.body, padding: '8px 16px' } }, '加载中…')
1065
+ : h('div', { style: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto', padding: '0 8px 32px' } }, rows),
646
1066
  )
647
1067
  const right = h(
648
1068
  '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,
1069
+ { style: { minHeight: 0, overflowY: 'auto' } },
658
1070
  selected && selectedData
659
- ? h(PaperPreview, { data: selectedData, vaultName })
660
- : h(Empty, null, '从左边选一篇论文,这里显示条目内容'),
1071
+ ? h(ReaderPane, null,
1072
+ selectedData.pending
1073
+ ? h('div', {
1074
+ style: {
1075
+ display: 'flex', alignItems: 'center', gap: SP.md, marginBottom: 20,
1076
+ padding: '10px 14px', borderRadius: 8, background: 'rgba(196,123,30,0.10)', border: '1px solid rgba(196,123,30,0.30)',
1077
+ },
1078
+ },
1079
+ h('span', { style: { fontSize: FS.body, flex: '1 1 auto' } }, '这篇条目还没有摘要与标签'),
1080
+ h(Btn, { small: true, primary: true, disabled: running, onClick: () => onSummarize(selected) }, running ? '处理中…' : '生成摘要与标签'))
1081
+ : null,
1082
+ h(PaperPreview, { data: selectedData, onOpenPaper: onSelect }))
1083
+ : h(ReaderEmpty, { title: '从左侧选一篇论文', sub: '可搜索、按领域筛选、只看待补;条目内容按摘要排版显示' }),
661
1084
  )
662
- return h('div', { style: { display: 'grid', gridTemplateColumns: '348px 1fr', height: '100%', minHeight: 0 } }, left, right)
1085
+ return h('div', { style: { display: 'grid', gridTemplateColumns: '336px 1fr', height: '100%', minHeight: 0 } }, left, right)
663
1086
  }
664
1087
 
665
1088
  // obsidian:// 只能由宿主侧拉起:桌面版对自定义协议的 window.open 一律 deny
@@ -673,9 +1096,10 @@ window.__ModuleLoader__.load({
673
1096
  return await r.json()
674
1097
  }
675
1098
 
676
- function PaperPreview({ data, vaultName }) {
1099
+ function PaperPreview({ data, onOpenPaper }) {
677
1100
  const [msg, setMsg] = useState('')
678
1101
  if (!data) return null
1102
+ const fm = parsePaperFrontmatter(data.index)
679
1103
  const rel = `1-Papers/${data.dir}/index.md`
680
1104
  const copyPath = () => {
681
1105
  try {
@@ -697,61 +1121,37 @@ window.__ModuleLoader__.load({
697
1121
  setMsg('打开失败:' + String(e) + ',已复制条目路径')
698
1122
  }
699
1123
  }
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)
1124
+ const meta = [
1125
+ { label: '年份', value: fm.year },
1126
+ { label: '期刊/会议', value: fm.journal },
1127
+ { label: 'DOI', value: fm.doi || (fm.arxiv ? 'arXiv ' + fm.arxiv : '') },
1128
+ { label: '篇幅', value: [fm.pages && fm.pages + ' 页', fm.figures && fm.figures + ' 图'].filter(Boolean).join(' · ') },
1129
+ { label: '引用键', value: fm.citekey },
1130
+ ]
1131
+ const concepts = data.concepts || []
1132
+ const taxonomy = (data.taxonomy || []).slice().sort((a, b) => {
1133
+ const order = ['领域', '方法', '应用', '仪器']
1134
+ return order.indexOf(tagCategory(a)) - order.indexOf(tagCategory(b))
1135
+ })
702
1136
  return h(
703
1137
  'div',
704
1138
  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
- ),
1139
+ h('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 16 } },
1140
+ h('div', { style: { flex: '1 1 auto', fontSize: FS.page, lineHeight: 1.4, fontWeight: 650 } }, fm.title || data.dir),
1141
+ h(DocActions, null,
1142
+ h(Btn, { small: true, ghost: true, onClick: copyPath, title: '复制库内相对路径' }, '复制路径'),
1143
+ h(Btn, { small: true, onClick: openInObsidian, title: '用 Obsidian 打开这篇笔记' }, '在 Obsidian 打开'))),
1144
+ h(MetaStrip, { items: meta }),
712
1145
  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
- )
1146
+ concepts.length || taxonomy.length
1147
+ ? h('div', { style: { marginTop: 16, display: 'flex', flexWrap: 'wrap', gap: 6 } },
1148
+ concepts.map((c) => h(ConceptChip, { key: 'c' + c, name: c })),
1149
+ taxonomy.map((t) => h(TaxChip, { key: 't' + t, id: t })))
724
1150
  : 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
- ),
1151
+ h('div', { style: { marginTop: 26 } }, h(Markdown, { source: data.index, ledeHeading: '一句话总结', onOpenPaper })),
745
1152
  )
746
1153
  }
747
1154
 
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
1155
  function layoutGraph(data) {
756
1156
  const W = 1000
757
1157
  const H = 660
@@ -883,7 +1283,7 @@ window.__ModuleLoader__.load({
883
1283
  dots.push(h('span', { key: 'tax', style: { display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: FS.caption, color: muted } },
884
1284
  h('span', { style: { width: 8, height: 8, borderRadius: 999, background: '#ffd700' } }), '分类'))
885
1285
  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' } }), '概念'))
1286
+ h('span', { style: { width: 8, height: 8, borderRadius: 999, background: CONCEPT_COLOR } }), '概念'))
887
1287
 
888
1288
  const graph = (data.papers.length + (data.concepts || []).length) === 0
889
1289
  ? h('div', { style: { height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: faint, fontSize: FS.body } }, '还没有论文或概念,导入几篇后这里会出现关系图')
@@ -916,7 +1316,7 @@ window.__ModuleLoader__.load({
916
1316
  ? !domain || ((linked && linked.papers) || []).some((d) => visible(paperById.get(d) || { id: d }))
917
1317
  : visible(p) || (!focus && !domain)
918
1318
  const dim = focusId ? !focusNeighbors.has(n.id) : !vis
919
- const fill = isConcept ? '#a78bfa' : isTax ? '#ffd700' : domainColor(p.domain || '其他', domains)
1319
+ const fill = isConcept ? CONCEPT_COLOR : isTax ? '#d5a94a' : domainColor(p.domain || '其他', domains)
920
1320
  const deg = sim.degree.get(sim.nodes.indexOf(n)) || 0
921
1321
  const r = isConcept ? 10 + Math.min(6, ((n.ref.papers || []).length || 0) / 8)
922
1322
  : isTax ? 6 + Math.min(5, deg / 3)
@@ -983,6 +1383,8 @@ window.__ModuleLoader__.load({
983
1383
  const [active, setActive] = useState('')
984
1384
  const [detail, setDetail] = useState(null)
985
1385
  const [msg, setMsg] = useState('')
1386
+ const [q, setQ] = useState('')
1387
+ const [hovered, setHovered] = useState('')
986
1388
 
987
1389
  const rel = detail ? `2-Concepts/${detail.name}.md` : ''
988
1390
  const openInObsidian = async () => {
@@ -1031,67 +1433,100 @@ window.__ModuleLoader__.load({
1031
1433
  }
1032
1434
  }
1033
1435
 
1436
+ const filtered = (items || []).filter((c) => {
1437
+ if (!q) return true
1438
+ const hay = `${c.name} ${c.title} ${(c.aliases || []).join(' ')} ${c.oneLiner || ''}`.toLowerCase()
1439
+ return hay.includes(q.toLowerCase())
1440
+ })
1441
+
1442
+ const rows = filtered.map((c) => {
1443
+ const on = active === c.name
1444
+ return h(
1445
+ 'div',
1446
+ {
1447
+ key: c.name,
1448
+ onClick: () => open(c.name),
1449
+ onMouseEnter: () => setHovered(c.name),
1450
+ onMouseLeave: () => setHovered(''),
1451
+ title: c.oneLiner || c.title,
1452
+ style: {
1453
+ position: 'relative', display: 'grid', gridTemplateColumns: '1fr auto', gap: 10,
1454
+ padding: '10px 10px 11px', borderRadius: 8, cursor: 'pointer',
1455
+ background: on ? 'rgba(77,107,254,0.12)' : hovered === c.name ? hoverBg : 'transparent',
1456
+ },
1457
+ },
1458
+ on ? h('div', { style: { position: 'absolute', left: 0, top: 9, bottom: 9, width: 2, borderRadius: 2, background: CONCEPT_COLOR } }) : null,
1459
+ h('div', { style: { minWidth: 0 } },
1460
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 } },
1461
+ h('span', { style: { width: 6, height: 6, borderRadius: '50%', background: CONCEPT_COLOR, flex: '0 0 auto' } }),
1462
+ h('div', { style: { fontSize: 14, fontWeight: 550, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, c.title || c.name)),
1463
+ c.oneLiner
1464
+ ? h('div', { style: { marginTop: 3, fontSize: 12, color: T.caption, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingLeft: 13 } }, c.oneLiner)
1465
+ : null),
1466
+ h('div', { style: { fontFamily: mono, fontSize: 12, color: hexA(CONCEPT_COLOR, 0.75), fontVariantNumeric: 'tabular-nums', paddingTop: 3 } }, String(c.papersCount || 0)))
1467
+ })
1468
+
1034
1469
  const left = h(
1035
1470
  'div',
1036
- { style: { flex: '0 0 260px', minWidth: 220, maxHeight: 'calc(100vh - 320px)', overflowY: 'auto' } },
1471
+ { style: { minHeight: 0, display: 'flex', flexDirection: 'column', background: T.bg1, borderRight: `1px solid ${line}` } },
1472
+ h(RailHead, { title: '概念', count: items ? fmt(items.length) : '—' }),
1473
+ h(RailSearch, { value: q, onChange: setQ, placeholder: '搜索概念 / 别名' }),
1474
+ h('div', { style: { padding: '0 16px 8px', fontSize: 12, color: T.caption } }, '按引用论文数排序'),
1037
1475
  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 } }, '加载中…'),
1476
+ ? h('div', { style: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto', padding: '0 8px 32px' } }, rows)
1477
+ : h('div', { style: { color: muted, fontSize: FS.body, padding: '8px 16px' } }, '加载中…'),
1065
1478
  )
1066
1479
 
1480
+ const fm = detail ? parsePaperFrontmatter(detail.markdown) : {}
1481
+ const tags = (fm.tags || []).filter((t) => !t.startsWith('状态/'))
1482
+ const related = (detail && detail.papers ? detail.papers.slice() : []).sort((a, b) => (parseFloat(b.year || '0') || 0) - (parseFloat(a.year || '0') || 0))
1067
1483
  const right = h(
1068
1484
  'div',
1069
- { style: { flex: '1 1 auto', minWidth: 320, maxHeight: 'calc(100vh - 320px)', overflowY: 'auto', borderLeft: `1px solid ${line}`, paddingLeft: 16 } },
1485
+ { style: { minHeight: 0, overflowY: 'auto' } },
1070
1486
  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, '从左边选一个概念,查看页面和相关论文'),
1487
+ ? h(ReaderPane, null,
1488
+ h('div', { style: { display: 'flex', alignItems: 'flex-start', gap: 16 } },
1489
+ h('div', { style: { flex: '1 1 auto', fontSize: FS.page, lineHeight: 1.4, fontWeight: 650 } }, detail.title || detail.name),
1490
+ h(DocActions, null,
1491
+ h(Btn, { small: true, ghost: true, onClick: copyPath, title: '复制库内相对路径' }, '复制路径'),
1492
+ h(Btn, { small: true, onClick: openInObsidian, title: '用 Obsidian 打开概念页' }, '在 Obsidian 打开'))),
1493
+ h(MetaStrip, {
1494
+ items: [
1495
+ { label: '类型', value: '概念页' },
1496
+ { label: '引用', value: (detail.papersCount || 0) + ' 篇论文' },
1497
+ { label: '别名', value: (fm.aliases || detail.aliases || []).slice(0, 4).join(' · ') },
1498
+ { label: '更新', value: fm.updated },
1499
+ ],
1500
+ }),
1501
+ msg ? h('div', { style: { fontSize: FS.caption, color: ERROR_TEXT, marginTop: SP.xs } }, msg) : null,
1502
+ tags.length
1503
+ ? h('div', { style: { marginTop: 16, display: 'flex', flexWrap: 'wrap', gap: 6 } }, tags.map((t) => h(TaxChip, { key: t, id: t })))
1504
+ : null,
1505
+ h('div', { style: { marginTop: 26 } }, h(Markdown, { source: detail.markdown, ledeFirstQuote: true, onOpenPaper })),
1506
+ related.length
1507
+ ? h('div', null,
1508
+ h(H2, null, '相关论文'),
1509
+ h('div', null, related.map((p) => h('div', {
1510
+ key: p.dir,
1511
+ onClick: () => onOpenPaper(p.dir),
1512
+ title: p.title,
1513
+ onMouseEnter: () => setHovered('paper:' + p.dir),
1514
+ onMouseLeave: () => setHovered(''),
1515
+ style: {
1516
+ display: 'grid', gridTemplateColumns: '42px 1fr', gap: 12, padding: '9px 8px', borderRadius: 7,
1517
+ cursor: 'pointer', borderBottom: '1px solid rgba(255,255,255,0.045)',
1518
+ background: hovered === 'paper:' + p.dir ? hoverBg : 'transparent',
1519
+ },
1520
+ },
1521
+ h('div', { style: { fontFamily: mono, fontSize: 12, color: T.text3, paddingTop: 2, fontVariantNumeric: 'tabular-nums' } }, p.year || '—'),
1522
+ h('div', { style: { fontSize: 13.5, lineHeight: 1.5 } }, p.title)))))
1523
+ : null)
1524
+ : h(ReaderEmpty, {
1525
+ title: items && items.length ? '从左侧选一个概念' : '还没有概念页',
1526
+ sub: items && items.length ? '概念页是跨论文综合,条目内容按摘要排版显示' : '概念页由 tools/ 下的素材、校验、索引脚本生成,规范见 config/CONCEPT_SPEC.md',
1527
+ }),
1092
1528
  )
1093
-
1094
- return h('div', { style: { display: 'flex', gap: 16, alignItems: 'flex-start' } }, left, right)
1529
+ return h('div', { style: { display: 'grid', gridTemplateColumns: '300px 1fr', height: '100%', minHeight: 0 } }, left, right)
1095
1530
  }
1096
1531
 
1097
1532
  function PaperDepotView() {
@@ -1107,6 +1542,8 @@ window.__ModuleLoader__.load({
1107
1542
  const [setup, setSetup] = useState(null)
1108
1543
  const [showSetup, setShowSetup] = useState(false)
1109
1544
  const [showImport, setShowImport] = useState(false)
1545
+ const [jobInfo, setJobInfo] = useState(null)
1546
+
1110
1547
 
1111
1548
  const loadSetup = useCallback(async () => {
1112
1549
  try {
@@ -1153,11 +1590,13 @@ window.__ModuleLoader__.load({
1153
1590
 
1154
1591
  const startPolling = useCallback(async () => {
1155
1592
  setRunning(true)
1156
- for (let i = 0; i < 900; i++) {
1593
+ // 批量补摘要可能跑几个小时:轮询上限放到 ~3 小时,任务结束后立即退出
1594
+ for (let i = 0; i < 9000; i++) {
1157
1595
  await new Promise((r) => setTimeout(r, 1200))
1158
1596
  try {
1159
1597
  const j = await (await api('/job')).json()
1160
1598
  if (j.job) {
1599
+ setJobInfo(j.job)
1161
1600
  setLog((j.job.lines || []).slice(-300))
1162
1601
  if (j.job.state !== 'running') {
1163
1602
  setRunning(false)
@@ -1255,6 +1694,33 @@ window.__ModuleLoader__.load({
1255
1694
  }
1256
1695
  }, [startPolling, openPaper, setLog])
1257
1696
 
1697
+ // 批量补摘要:排队把「待生成」的条目跑完;中断后再点会从剩下的继续
1698
+ const onSummarizePending = useCallback(async () => {
1699
+ setJobInfo(null)
1700
+ try {
1701
+ const r = await fetch('/paperdepot/api/summarize-pending', { method: 'POST', credentials: 'same-origin' })
1702
+ const j = await r.json()
1703
+ if (!j.ok) {
1704
+ setLog([`❌ ${j.error || r.status}`])
1705
+ return
1706
+ }
1707
+ setLog([`▶ 批量补摘要:排队 ${j.total} 篇(库里待生成 ${j.pending} 篇)`])
1708
+ await startPolling()
1709
+ } catch (e) {
1710
+ setLog([`❌ ${String(e)}`])
1711
+ }
1712
+ }, [startPolling, setLog])
1713
+
1714
+ const onCancelJob = useCallback(async () => {
1715
+ try {
1716
+ const r = await fetch('/paperdepot/api/job-cancel', { method: 'POST', credentials: 'same-origin' })
1717
+ const j = await r.json()
1718
+ if (!j.ok) setLog((old) => [...old, `❌ ${j.error || r.status}`])
1719
+ } catch (e) {
1720
+ setLog((old) => [...old, `❌ ${String(e)}`])
1721
+ }
1722
+ }, [setLog])
1723
+
1258
1724
  const onSetupSaved = useCallback(async () => {
1259
1725
  try {
1260
1726
  const s = await (await api('/setup/status')).json()
@@ -1301,11 +1767,11 @@ window.__ModuleLoader__.load({
1301
1767
  ['concepts', '概念', d ? String(d.concepts) : ''],
1302
1768
  ]
1303
1769
  const content = setup && !setup.hasKey
1304
- ? h('div', { style: { height: '100%', overflowY: 'auto', padding: '20px 20px 48px' } }, h(SetupCard, { setup, onSaved: onSetupSaved }))
1770
+ ? h('div', { style: { height: '100%', overflowY: 'auto', padding: '20px 20px 72px', boxSizing: 'border-box' } }, h(SetupCard, { setup, onSaved: onSetupSaved }))
1305
1771
  : d
1306
1772
  ? tab === 'papers'
1307
1773
  ? h(Papers, { papers, loading: papersLoading, onSelect: openPaper, selected, selectedData, onSummarize, running, vaultName })
1308
- : h('div', { style: { height: '100%', overflowY: 'auto', padding: '20px 20px 48px' } },
1774
+ : h('div', { style: { height: '100%', overflowY: 'auto', padding: '20px 20px 72px', boxSizing: 'border-box' } },
1309
1775
  tab === 'overview'
1310
1776
  ? h(Overview, { d, onOpen: openFromGraph })
1311
1777
  : h(Concepts, { onOpenPaper: openFromGraph, vaultName }))
@@ -1404,13 +1870,31 @@ window.__ModuleLoader__.load({
1404
1870
  style: { fontSize: 12.5, color: muted, background: T.bg2, padding: '1px 6px', borderRadius: 999, lineHeight: 1.5 },
1405
1871
  }, count) : null)),
1406
1872
  h('div', { style: { flex: '1 1 auto' } }),
1873
+ d && d.pendingSummary
1874
+ ? h(Btn, {
1875
+ small: true,
1876
+ ghost: true,
1877
+ onClick: onSummarizePending,
1878
+ disabled: running,
1879
+ title: '把「待生成」的条目排队跑完;可随时停止,下次再点会跳过已完成的',
1880
+ }, running
1881
+ ? `任务中(${jobInfo && jobInfo.kind === 'summarize-batch' ? `${jobInfo.done || 0}/${jobInfo.total || 0}` : '…'})`
1882
+ : `批量补摘要(${d.pendingSummary})`)
1883
+ : null,
1407
1884
  h(Btn, { small: true, ghost: true, onClick: onRebuildNav, disabled: running, title: '重建论文导航块、分类页与图颜色分组' }, running ? '重建中…' : '重建导航')),
1408
1885
  // 内容
1409
1886
  h('div', { style: { flex: '1 1 auto', minHeight: 0, position: 'relative' } }, content),
1887
+ // 底部安全区:flex 末尾的固定占位(等价 HarmonyOS 的 bottom safe area / Blank),
1888
+ // 让上方内容整体上移,不贴窗口下沿、不被任务栏压住;不放滚动区里,避免随内容滚走
1889
+ h('div', { style: { flex: '0 0 auto', height: 24 } }),
1410
1890
  showSetup && setup && setup.hasKey
1411
1891
  ? h('div', { style: { position: 'absolute', top: 58, right: 20, zIndex: 15, width: 540, maxWidth: '92vw', maxHeight: 'calc(100% - 80px)', overflowY: 'auto' } },
1412
1892
  h(SetupCard, { setup, onSaved: onSetupSaved }))
1413
1893
  : null,
1894
+ // 批量补摘要进度条:任务结束时保留,可收起
1895
+ jobInfo && jobInfo.kind === 'summarize-batch'
1896
+ ? h(BatchBar, { job: jobInfo, onCancel: onCancelJob, onClose: () => setJobInfo(null) })
1897
+ : null,
1414
1898
  h(ImportDialog, { open: showImport, onClose: () => setShowImport(false), running, log, uploadFiles }),
1415
1899
  )
1416
1900
  }