Repository navigation
Expand file tree
/
Copy pathclient.js
More file actions
2268 lines (2112 loc) · 133 KB
/
Copy pathclient.js
File metadata and controls
2268 lines (2112 loc) · 133 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* dsh-baleen —— 客户端半
*
* 以 dsh.client bundle 格式加载(宿主 ModuleLoader 直接吃单文件,无打包器)。
* 注册 id 必须等于 loader entry 名(dsh-baleen),否则宿主报
* "loaded without registering dsh-baleen"。
*
* 视觉约定:一个硬编码颜色都没有 —— 只消费 --dsw-alias-* / --dsw-static-*,
* fallback 用 light-dark() 同时给明暗两值。换主题时这里自动跟着变,
* 是「融进主题」而不是「自带一套皮肤」。
*
* 挂载点(M2):
* sidebar.panellist → 左侧栏入口,id 与 main 的 key 同为 baleen(不同名宿主会抛)
* main → 整页面板:顶部五项导航 + 视图 + 抽屉
* 设置页 settings.section(M5 起已注册,四页签)。
*/
window.__ModuleLoader__.load({
id: 'dsh-baleen',
factory: (require) => {
const module = { exports: {} }
const exports = module.exports
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
const React = require('react')
const h = React.createElement
const PANEL_ID = 'baleen'
/** 视图状态存这个键,数据快照存 SNAPSHOT_KEY(view-api 里)——两份别混 */
const VIEW_KEY = 'baleen.view'
// ---- src/client/view-state.js ----
/**
* 面板视图状态。纯函数,不碰 window、不发请求——所以能在 Node 里测完。
* 每个视图的筛选各存各的:从视频区带着"档位=值得看"切去文章区,
* 回来时那个筛选还在,但不会污染文章区的筛选。
*/
const VIEWS = ['today', 'video', 'article', 'collections', 'digest', 'library']
const DEFAULT_STATE = () => ({ view: 'today', filters: {}, drawerItemId: null, selectedId: null, scroll: 0 })
/** 首屏状态。持久化的读法在 decodeState(装配层把 localStorage 里的原文交给它)。 */
function initialView() {
return decodeState()
}
function reduce(state, action) {
const s = state ?? DEFAULT_STATE()
switch (action?.type) {
case 'pick-view':
return VIEWS.includes(action.view) ? { ...s, view: action.view } : s
case 'filter': {
if (!action.view || !action.key) return s
const perView = { ...(s.filters[action.view] ?? {}) }
if (action.value === '' || action.value === null || action.value === undefined) delete perView[action.key]
else perView[action.key] = action.value
return { ...s, filters: { ...s.filters, [action.view]: perView } }
}
case 'facet': {
// 「库」的多选筛选:组内 OR,勾满再取消就删键(等价于"不筛"),不留空数组占位
if (!action.view || !action.key || action.value == null) return s
const perView = { ...(s.filters[action.view] ?? {}) }
const cur = Array.isArray(perView[action.key]) ? perView[action.key] : []
const next = action.on ? [...new Set([...cur, action.value])] : cur.filter((v) => v !== action.value)
if (next.length) perView[action.key] = next
else delete perView[action.key]
return { ...s, filters: { ...s.filters, [action.view]: perView } }
}
case 'open-drawer':
return action.id == null ? s : { ...s, drawerItemId: action.id }
case 'select': {
const id = action.id == null ? null : Number(action.id)
return { ...s, selectedId: Number.isInteger(id) ? id : null }
}
case 'close-drawer':
return { ...s, drawerItemId: null }
case 'scroll':
return { ...s, scroll: Number(action.top) || 0 }
default:
return s
}
}
function encodeState(state) {
try {
return JSON.stringify({ v: 1, view: state.view, filters: state.filters, drawerItemId: state.drawerItemId, selectedId: state.selectedId, scroll: state.scroll })
} catch { return '' }
}
/**
* localStorage 里可能是上次崩溃留下的半成品、也可能是旧版本写的结构,
* 所以一律回落默认而不是抛——抛在 apply() 里会炸整个客户端装配。
*/
function decodeState(text) {
const base = DEFAULT_STATE()
if (!text) return base
let obj
try { obj = JSON.parse(text) } catch { return base }
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return base
const filters = {}
if (obj.filters && typeof obj.filters === 'object' && !Array.isArray(obj.filters)) {
for (const [k, v] of Object.entries(obj.filters)) {
if (v && typeof v === 'object' && !Array.isArray(v)) filters[k] = { ...v }
}
}
return {
view: VIEWS.includes(obj.view) ? obj.view : base.view,
filters,
drawerItemId: Number.isInteger(obj.drawerItemId) ? obj.drawerItemId : null,
selectedId: Number.isInteger(obj.selectedId) ? obj.selectedId : null,
scroll: Number.isFinite(obj.scroll) ? obj.scroll : 0
}
}
// ---- src/client/view-api.js ----
/**
* 面板取数层。三条规矩:
* 1. 失败绝不抛到渲染层——回 `{ ok:false, stale:true, data:上次快照 }`,顶栏据此出灰条;
* 2. 快照只存"能渲染的状态",不存正文(真相在 sqlite 里,快照是为了宿主没起来时不白屏);
* 3. feedback 的动作是白名单,不接收调用方传来的 delta——
* 否则一个坏掉的(或被改过的)前端就能把来源权重推到任意值。
*/
const API_BASE = '/api/dsh-baleen'
const SNAPSHOT_KEY = 'baleen.snapshot'
function createApi({ fetch: fetchImpl = globalThis.fetch, storage = globalThis.localStorage, actions = { dismiss: -0.1, save: 0.1 } } = {}) {
function write(obj) {
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(obj)) } catch { /* 隐私模式或配额满,记不上就算了 */ }
}
function readCached() {
try {
const t = storage.getItem(SNAPSHOT_KEY)
const p = t ? JSON.parse(t) : null
return p && typeof p === 'object' && !Array.isArray(p) ? p : null
} catch { return null }
}
async function call(url, init) {
try {
const res = await fetchImpl(url, init)
if (!res || res.ok === false) throw new Error('HTTP ' + ((res && res.status) ?? '?'))
return { json: await res.json(), httpOk: true }
} catch (e) {
return { error: String((e && e.message) || e) }
}
}
async function get(url) {
const r = await call(url, { cache: 'no-store' })
if (r.httpOk) { write(r.json); return { ok: true, stale: false, data: r.json } }
return { ok: false, stale: true, data: readCached(), error: r.error }
}
/**
* 写请求专用。与 get() 的分别:4xx/5xx 也要把响应体读出来——
* folder_gone 带的是"哪几个夹没了",丢了就等于面板只能说"没记上",
* 而用户真正需要知道的是那个夹已经被删了,换个夹再收一次。
*/
async function post(urlPath, payload) {
return send('POST', urlPath, payload)
}
/** 设置页的 PUT(preference / credential):与 post 同一套"错误体也要读回来"的规矩。 */
async function put(urlPath, payload) {
return send('PUT', urlPath, payload)
}
async function send(method, urlPath, payload) {
try {
const res = await fetchImpl(API_BASE + urlPath, {
method,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
})
const status = (res && res.status) ?? 0
let json = null
try { json = res && await res.json() } catch { json = null }
if (res && res.ok === false) {
return {
ok: false,
error: (json && json.error) || ('HTTP ' + status),
missing: (json && json.missing) || undefined,
data: json
}
}
return { ok: true, stale: false, data: json }
} catch (e) {
return { ok: false, stale: true, error: String((e && e.message) || e), data: null }
}
}
/**
* 设置页的只读 GET。绝不走 get():那会把应答写进共享快照(baleen.snapshot),
* 而 /state 的失败回落靠的是它——preference 的应答盖上去,面板顶栏就会
* 拿偏好数据冒充面板状态。
*/
async function plainGet(urlPath) {
try {
const res = await fetchImpl(API_BASE + urlPath, { cache: 'no-store' })
if (!res || res.ok === false) return { ok: false, stale: true, data: null }
return { ok: true, stale: false, data: await res.json() }
} catch { return { ok: false, stale: true, data: null } }
}
return {
snapshot: readCached,
state: () => get(API_BASE + '/state'),
items: (q = {}) => get(API_BASE + '/items?' + new URLSearchParams(q).toString()),
async run() {
const r = await call(API_BASE + '/run', { method: 'POST' })
if (!r.httpOk) return { ok: false, stale: true, data: readCached(), error: r.error }
const next = r.json && r.json.state ? r.json.state : r.json
if (next) write(next)
return { ok: true, stale: false, data: r.json }
},
async feedback(sourceId, action) {
if (!Object.prototype.hasOwnProperty.call(actions, action)) throw new Error('未知动作:' + action)
if (!sourceId) throw new Error('feedback 需要 source_id')
const r = await call(API_BASE + '/feedback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ source_id: sourceId, action })
})
if (!r.httpOk) return { ok: false, stale: true, data: readCached(), error: r.error }
return { ok: true, stale: false, data: r.json }
},
folders: async () => {
try {
const res = await fetchImpl(API_BASE + '/folders', { cache: 'no-store' })
if (!res || res.ok === false) return { ok: false, stale: true, data: null }
return { ok: true, stale: false, data: await res.json() }
} catch { return { ok: false, stale: true, data: null } }
},
collect: (itemId, folderIds, newFolder) => post('/collect', {
item_id: itemId,
folder_ids: Array.isArray(folderIds) ? folderIds : [],
...(typeof newFolder === 'string' && newFolder.trim() ? { new_folder: newFolder.trim() } : {})
}),
folderOp: (action, { id, name } = {}) => post('/folder', { action, ...(id != null ? { id } : {}), ...(name != null ? { name } : {}) }),
/* 设置页(settings.section)的取数与动作:GET 走 plainGet(不碰共享快照),写走 post/put */
preferenceGet: () => plainGet('/preference'),
preferencePut: (section, value) => put('/preference', { section, value }),
tuningReset: () => post('/tuning-reset', {}),
credentialStatus: () => plainGet('/credential-status'),
credentialPut: (which, content) => put('/credential', { which, content }),
envCheck: () => plainGet('/env-check'),
diagTail: (lines) => plainGet('/diagnostics-tail?lines=' + (Number.isInteger(lines) ? lines : 50)),
exportMd: () => post('/export', {}),
cleanup: (days) => post('/cleanup', { days }),
wipe: (confirmText) => post('/wipe', { confirm: confirmText }),
}
}
// ---- src/client/vnode.js ----
/**
* 视图产出的节点形状:{type, props, children},纯数据。
*
* 视图不直接调 React.createElement,是为了能在 Node 里把渲染结果当普通对象断言
* (test/helpers/tree.mjs 压文本、tools/preview.mjs 出静态 HTML 都吃这个形状)。
* 装配层用 toElement() 把这棵树交给真 React。
*/
const vnode = (type, props, children) => ({ type, props: props ?? {}, children: children ?? [] })
// ---- src/client/match.js ----
/**
* 命中理由的读法。预筛入库时把"为什么给你这条"写进 items.match_json,
* 面板只读不改;坏 JSON 与缺字段一律显式说"没写下理由"——
* 留空看起来像"这条没有理由",那是两种不同的事实。
*/
function reasonOf(item) {
return matchInfo(item).text || '(无理由)'
}
/** 抽屉里要分开渲染的三样:那句话、命中了哪些词、属于哪个主题组。 */
function matchInfo(item) {
let m
try { m = JSON.parse(item?.match_json ?? '{}') } catch { m = {} }
if (!m || typeof m !== 'object' || Array.isArray(m)) m = {}
return {
text: typeof m.reason === 'string' ? m.reason : '',
keywords: Array.isArray(m.hitKeywords) ? m.hitKeywords : [],
groups: Array.isArray(m.groups) ? m.groups : []
}
}
// ---- src/client/format.js ----
/** 面板里共用的格式化。视图不各写一份,否则"同一件事两种写法"又会回来。 */
/** 秒 → 1:02:03 / 18:22;0、负数、非数字一律空串(徽章上留空比显示"0:0"诚实)。 */
function fmtDuration(sec) {
const s = Number(sec)
if (!Number.isFinite(s) || s <= 0) return ''
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = Math.floor(s % 60)
return h ? `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}` : `${m}:${String(r).padStart(2, '0')}`
}
/** 时间戳 → 本地可读时间;坏值说"时间未知"而不是显示 Invalid Date。 */
function fmtTime(ms) {
const t = Number(ms)
if (!Number.isFinite(t) || t <= 0) return '(时间未知)'
try {
return new Date(t).toLocaleString('zh-CN', { hour12: false })
} catch {
return new Date(t).toISOString()
}
}
/**
* 「读回失败」的统一话术:请求发出去了(甚至 2xx 了),但该读回来的内容没在应答里。
* 抽屉、收藏页、夹操作三处共用这一句骨架——话术只写一遍,就不会各处漂移成三种说法。
* 诚实底线:必须明说"没读回来",不许写成"没有夹""你还没收藏过"那种把失败说成空的谎。
*/
const readbackMiss = (what) => `${what}没读回来`
/** 收藏这套界面里最常见的一次读回失败:夹列表读不回来,于是整页的收藏关系都无从显示。 */
const foldersUnreadNote = `读不到夹(${readbackMiss('夹列表')})`
/**
* 判断的入口只有一句话:「今天」的待判提示与汇报空态共用这一句——
* 两处各写一遍的话,迟早漂移成两种说法,用户就得猜哪个才是对的。
*/
const judgeHint = '对会话说一句「把今天的候选判一下」'
/**
* 只认 http/https 的绝对地址,其余(空、非字符串、别的协议、javascript:)一律 null。
* 库里 url/thumb 都是平台给的原样字段,直接塞进 href/src 等于把外来的字符串当可执行入口。
*/
function httpUrl(v) {
const s = typeof v === 'string' ? v.trim() : ''
return /^https?:\/\//i.test(s) ? s : null
}
/**
* 封面地址:和链接同一套判定。视图拿它决定画不画 <img>——坏值不许变成
* "空 src 的裂图标",那比灰占位更难看也更可疑。
*/
const thumbSrc = (thumb) => httpUrl(thumb)
// ---- src/client/css.js ----
/**
* 面板样式。**这份 CSS 里一个颜色字面量都没有**:所有颜色都从宿主 token 取,
* 换主题(catppuccin / mocha / mica)这里自动跟着变——是"融进主题",不是"自带皮肤"。
*
* token 名不是猜的:每个都在本机 dsh-study-desk(已在 0.2.1-rc.2 跑通的插件)里出现过,
* test/css.test.js 拿那份清单卡住,写错名字等于在宿主里裸奔。
* 也不写 `var(--x, light-dark(…))` 那种内联兜底:兜底色会在这份文件里重新引入硬编码颜色,
* 而脱离宿主的样子是预览器的事——tools/preview.mjs 自己声明一张替身 token 表并标明是替身。
*/
const CSS = `
.bl-root{display:flex;flex-direction:column;width:100%;min-width:0;height:100%;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}
.bl-root *{box-sizing:border-box;font-weight:400}
.bl-icon{font-size:13px;line-height:1}
.bl-nav{display:flex;align-items:center;gap:8px;padding:9px 14px;background:var(--dsw-alias-bg-layer-1);border-bottom:1px solid var(--dsw-alias-border-l1);overflow-x:auto;scrollbar-width:thin}
.bl-brand{font-size:13px;font-weight:500;white-space:nowrap;color:var(--dsw-alias-label-primary)}
.bl-navlist{display:flex;gap:2px;flex:1;min-width:0}
.bl-navitem{appearance:none;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:13px;padding:6px 10px;min-height:32px;border-radius:7px;cursor:pointer;white-space:nowrap}
.bl-navitem:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
.bl-navitem[aria-current="page"]{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font-weight:500}
.bl-band{display:flex;flex-wrap:wrap;gap:10px;padding:6px 14px;font-size:12px;background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary)}
.bl-body{padding:14px 16px 20px;min-width:0;flex:1;overflow-y:auto}
.bl-empty,.bl-note,.bl-more,.bl-count,.bl-gaps,.bl-pending{font-size:12px;color:var(--dsw-alias-label-secondary)}
.bl-summary{font-size:13px;color:var(--dsw-alias-label-primary)}
.bl-issues{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-state-error-primary)}
.bl-pending{font-size:12px;color:var(--dsw-alias-label-secondary)}
.bl-actions{display:flex;gap:8px;margin:10px 0 2px}
.bl-actions button,.bl-detail{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}
.bl-actions button:hover,.bl-detail:hover{background:var(--dsw-alias-interactive-bg-hover)}
.bl-actions button:disabled{color:var(--dsw-alias-label-dimmed);cursor:progress}
.bl-today,.bl-video,.bl-article,.bl-digest{display:flex;flex-direction:column;gap:8px;min-width:0}
.bl-rail{margin-top:18px;min-width:0}
.bl-rail h3{font-size:13px;font-weight:500;margin:0 0 8px;color:var(--dsw-alias-label-primary)}
.bl-toolbar{display:flex;flex-wrap:wrap;gap:7px;align-items:center;margin-bottom:12px}
.bl-toolbar select,.bl-toolbar input{font-family:inherit;font-size:12px;padding:5px 8px;min-height:32px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
.bl-toolbar input{flex:1;min-width:130px}
.bl-toolbar button{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap}
.bl-toolbar button:hover{background:var(--dsw-alias-interactive-bg-hover)}
.bl-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(260px, 1fr));gap:12px;min-width:0}
.bl-card,.bl-row{appearance:none;text-align:left;font-family:inherit;color:inherit;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:9px 10px;cursor:pointer;display:flex;flex-direction:column;gap:5px;min-height:32px;min-width:0}
.bl-card:hover,.bl-row:hover,.bl-row[aria-selected="true"]{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2)}
.bl-card:focus-visible,.bl-row:focus-visible,.bl-navitem:focus-visible,.bl-actions button:focus-visible,.bl-detail:focus-visible,.bl-dbtns button:focus-visible,.bl-dbar button:focus-visible,.bl-facets input:focus-visible,.bl-collect input:focus-visible,.bl-newfolder input:focus-visible,.bl-newfolder button:focus-visible,.bl-toolbar button:focus-visible,.bl-foldertab:focus-visible,.bl-openbtns button:focus-visible,.bl-url:focus-visible,.bl-confirm button:focus-visible,.bl-setsave:focus-visible,.bl-setflag input:focus-visible,.bl-srcrow-head button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
.bl-card-title,.bl-row-title{font-size:13px;line-height:1.4;color:var(--dsw-alias-label-primary)}
.bl-card-meta,.bl-row-meta{font-size:11px;color:var(--dsw-alias-label-tertiary);display:flex;gap:6px;flex-wrap:wrap}
.bl-why,.bl-row-why{font-size:12px;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-border-l2);padding-left:7px;margin-top:auto}
.bl-tier{font-size:11px;color:var(--dsw-alias-label-primary)}
.bl-play{font-size:11px;color:var(--dsw-alias-label-dimmed)}
.bl-thumb{position:relative;aspect-ratio:16/9;border-radius:6px;background:var(--dsw-alias-bg-layer-3);display:block;overflow:hidden}
.bl-thumb img{width:100%;height:100%;object-fit:cover;display:block}
.bl-dur{position:absolute;right:5px;bottom:5px;font-size:11px;padding:1px 5px;border-radius:4px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary)}
.bl-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:14px;align-items:start;min-width:0}
.bl-list{display:flex;flex-direction:column;gap:8px;min-width:0}
.bl-reader{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:13px 15px;min-height:200px;min-width:0;display:flex;flex-direction:column;gap:8px}
.bl-reader h4{margin:0;font-size:13px;font-weight:500;line-height:1.4;color:var(--dsw-alias-label-primary)}
.bl-reader-by,.bl-reader-hint,.bl-reader-link{font-size:11px;color:var(--dsw-alias-label-secondary);margin:0}
.bl-reader-body{font-size:13px;white-space:pre-wrap;word-break:break-word;color:var(--dsw-alias-label-primary)}
.bl-library{display:grid;grid-template-columns:150px minmax(0,1fr);gap:14px;align-items:start;min-width:0}
.bl-library-body{display:flex;flex-direction:column;gap:10px;min-width:0}
.bl-facets{display:flex;flex-direction:column;gap:12px;font-size:12px;min-width:0}
.bl-facet{display:flex;flex-direction:column;gap:2px;min-width:0}
.bl-facets h4{margin:0 0 4px;font-size:12px;font-weight:500;color:var(--dsw-alias-label-primary)}
.bl-facets label{display:flex;gap:6px;align-items:center;min-height:32px;font-size:12px;color:var(--dsw-alias-label-secondary)}
.bl-collections{display:grid;grid-template-columns:150px minmax(0,1fr);gap:14px;align-items:start;min-width:0}
.bl-folderlist{display:flex;flex-direction:column;gap:4px;min-width:0}
.bl-foldertab{appearance:none;font-family:inherit;text-align:left;font-size:12px;min-height:32px;padding:6px 9px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
.bl-foldertab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
.bl-foldertab[aria-current="page"]{background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font-weight:500}
.bl-collection-body{display:flex;flex-direction:column;gap:10px;min-width:0}
.bl-in{font-size:11px;color:var(--dsw-alias-label-tertiary)}
.bl-confirm{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:9px 11px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);font-size:12px;color:var(--dsw-alias-label-primary)}
.bl-confirm p{margin:0;flex:1;min-width:180px}
.bl-confirm button{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 10px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}
.bl-confirm button:hover{background:var(--dsw-alias-interactive-bg-hover)}
.bl-empty-state{padding:18px 16px;border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}
.bl-dsum{padding:12px 14px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);display:flex;flex-direction:column;gap:5px}
.bl-dsum h3{margin:0;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
.bl-dhead{font-size:13px;color:var(--dsw-alias-label-primary);margin:0}
.bl-dtiers{font-size:11px;color:var(--dsw-alias-label-tertiary);margin:0}
.bl-drawer{position:sticky;top:0;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;display:flex;flex-direction:column;max-height:80vh;overflow:hidden}
.bl-dbar{display:flex;align-items:center;gap:10px;padding:9px 12px;border-bottom:1px solid var(--dsw-alias-border-l1)}
.bl-dtitle{flex:1;min-width:0;font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
.bl-badge{font-size:11px;color:var(--dsw-alias-brand-primary);white-space:nowrap}
.bl-dbar button{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:4px 9px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
.bl-dbar button:hover{background:var(--dsw-alias-interactive-bg-hover)}
.bl-dmain{padding:12px 14px;display:flex;flex-direction:column;gap:11px;overflow-y:auto;font-size:12px;min-width:0}
.bl-dmain section{display:flex;flex-direction:column;gap:4px}
.bl-dmain h5{margin:0 0 3px;font-size:11px;font-weight:500;color:var(--dsw-alias-label-tertiary)}
.bl-dmain p{margin:0;word-break:break-word;color:var(--dsw-alias-label-primary)}
.bl-kv{display:flex;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--dsw-alias-label-secondary)}
.bl-missing{color:var(--dsw-alias-state-error-primary)}
.bl-collect{display:flex;flex-wrap:wrap;gap:2px 12px;font-size:12px}
.bl-collect label{display:flex;gap:6px;align-items:center;min-height:32px;cursor:pointer;color:var(--dsw-alias-label-secondary)}
.bl-collect label:hover{color:var(--dsw-alias-label-primary)}
.bl-collect input{width:14px;height:14px;flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary)}
.bl-collect input:disabled{cursor:progress}
.bl-newfolder{display:flex;gap:6px;align-items:center}
.bl-newfolder input{flex:1;min-width:0;font-family:inherit;font-size:12px;padding:5px 8px;min-height:32px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
.bl-newfolder button{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap}
.bl-newfolder button:hover{background:var(--dsw-alias-interactive-bg-hover)}
.bl-newfolder button:disabled{color:var(--dsw-alias-label-dimmed);cursor:progress}
.bl-kw{font-size:11px;color:var(--dsw-alias-label-secondary)}
.bl-url{font-size:12px;color:var(--dsw-alias-brand-primary);word-break:break-all;text-decoration:underline}
.bl-openbtns{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
.bl-openbtns button{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap}
.bl-openbtns button:hover{background:var(--dsw-alias-interactive-bg-hover)}
.bl-feedback{margin:0;padding:7px 14px;font-size:12px;background:var(--dsw-alias-bg-layer-2);border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary)}
.bl-dbtns{display:flex;gap:8px;padding:11px 14px;border-top:1px solid var(--dsw-alias-border-l1)}
.bl-dbtns button{flex:1;appearance:none;font-family:inherit;font-size:12px;min-height:32px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}
.bl-dbtns button:hover{background:var(--dsw-alias-interactive-bg-hover)}
.bl-dbtns .bl-save{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}
/* 设置页(settings.section,四页签)。tab 条复用收藏页的 folderlist/foldertab 那套 */
.bl-settings{display:grid;grid-template-columns:150px minmax(0,1fr);gap:14px;align-items:start;min-width:0}
.bl-settings-body{display:flex;flex-direction:column;gap:14px;min-width:0}
.bl-setblock{display:flex;flex-direction:column;gap:8px;min-width:0}
.bl-setblock h4{margin:6px 0 0;font-size:12px;font-weight:500;color:var(--dsw-alias-label-primary)}
.bl-setfield{display:flex;flex-direction:column;gap:4px;min-width:0}
.bl-setlabel{font-size:12px;color:var(--dsw-alias-label-secondary)}
.bl-sethint{font-size:11px;color:var(--dsw-alias-label-tertiary);margin:0}
.bl-setfield textarea,.bl-setfield input,.bl-setfield select,.bl-setrow input,.bl-setrow select{font-family:inherit;font-size:12px;padding:5px 8px;min-height:32px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
.bl-setfield textarea{resize:vertical;min-height:56px}
.bl-setrow{display:flex;flex-wrap:wrap;gap:8px;align-items:center;min-width:0}
.bl-setrow input{width:120px;min-width:0}
.bl-setrow input[type="text"]{flex:1;min-width:140px;width:auto}
.bl-setsave{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;align-self:flex-start}
.bl-setsave:hover{background:var(--dsw-alias-interactive-bg-hover)}
.bl-setsave:disabled{color:var(--dsw-alias-label-dimmed);cursor:progress}
.bl-setflag{display:flex;gap:6px;align-items:center;flex-wrap:wrap;min-height:32px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}
.bl-setflag:hover{color:var(--dsw-alias-label-primary)}
.bl-setflag input{width:14px;height:14px;flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary)}
.bl-seterr{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:3px;font-size:12px;color:var(--dsw-alias-state-error-primary)}
.bl-seterr li{border-left:2px solid var(--dsw-alias-state-error-primary);padding-left:7px}
/* danger 不再只属于确认框:库页签的「清空库」按钮与说明行共用这枚红字 */
.bl-danger{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
.bl-srcrow{display:flex;flex-direction:column;gap:5px;min-width:0;padding:9px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}
.bl-srcrow-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;min-width:0}
.bl-srctoggle{display:flex;gap:7px;align-items:center;font-size:13px;color:var(--dsw-alias-label-primary);cursor:pointer;min-width:0}
.bl-srctoggle input{width:14px;height:14px;flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary)}
.bl-srcrow-head button{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 10px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}
.bl-srcrow-head button:hover{background:var(--dsw-alias-interactive-bg-hover)}
.bl-srcform{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px;align-items:center;margin-top:6px}
.bl-srcform input,.bl-srcform select{font-family:inherit;font-size:12px;padding:5px 8px;min-height:32px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);min-width:0;width:100%}
.bl-diagtext{margin:0;padding:9px 10px;max-height:220px;overflow:auto;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word;font-family:inherit;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;color:var(--dsw-alias-label-secondary)}
@media (max-width:820px){.bl-two,.bl-library,.bl-collections,.bl-settings{grid-template-columns:minmax(0,1fr)}}
@media (prefers-reduced-motion:reduce){.bl-root *{transition:none;animation:none}}
`
let injected = false
/** 只注入一次;宿主可能多次装载同一个客户端模块,重复注入会让样式表越堆越长。 */
function injectStyles() {
if (injected) return false
if (typeof document === 'undefined' || !document.createElement) return false
const el = document.createElement('style')
el.setAttribute('data-baleen', '')
el.textContent = CSS
const parent = document.head || document.body
if (!parent || typeof parent.appendChild !== 'function') return false
parent.appendChild(el)
injected = true
return true
}
// ---- src/client/views/shell.js ----
/**
* 面板外壳:顶部导航(门户式六格)+ 全局异常带 + 渲染失败的降级块。
*
* 导航是"像网站那样"的主要载体,所以它和视图一样是可测的纯函数——
* 预览器(tools/preview.mjs)渲染的就是这同一个 Nav,
* 她看预览时看到的导航布局与真机一致,不是预览里另画一条。
*/
/** id 与 view-state 的 VIEWS 一字不差;顺序就是点击顺序。 */
const NAV_ITEMS = [['today', '今天'], ['video', '视频'], ['article', '文章'], ['collections', '收藏'], ['digest', '汇报'], ['library', '库']]
function Nav({ view, pending = 0, onPick }) {
return vnode('nav', { className: 'bl-nav', 'aria-label': '主导航' }, [
vnode('span', { className: 'bl-brand' }, ['Baleen']),
vnode('div', { className: 'bl-navlist' }, NAV_ITEMS.map(([id, label]) => vnode('button', {
type: 'button',
key: id,
className: 'bl-navitem',
'aria-current': view === id ? 'page' : 'false',
onClick: () => onPick?.(id)
}, [label + (id === 'today' && pending ? ` ·待判${pending}` : '')])))
])
}
/** 全局异常带:偏好文件坏了 / 接口没答上 / 手动那轮失败。没问题时这一格什么都不渲染。 */
function TopBar({ stale, runError, preferenceErrors }) {
const bits = []
if (preferenceErrors && preferenceErrors.length) {
bits.push(`偏好文件有 ${preferenceErrors.length} 处问题:${preferenceErrors.slice(0, 2).join(';')}`)
}
if (stale) bits.push('宿主接口没答上,下面是上次成功时的快照')
if (runError) bits.push('这一轮没跑成:' + runError)
if (!bits.length) return null
return vnode('div', { className: 'bl-band', role: 'alert' }, bits)
}
function NotReady({ text }) {
return vnode('div', { className: 'bl-empty' }, [text])
}
// ---- src/client/views/today.js ----
/**
* 「今天」= 门户头版。这一屏承担全部的诚实责任:
* 过筛多少、候选多少、待判多少、为什么被丢、哪个源挂了,都得看得见。
* 纯函数:数据从参数进,不读 window、不发请求。
*/
const GAP_LABELS = {
'theme-reject': '命中排除词',
'no-body': '读不到内容',
stale: '超龄',
'too-short': '时长过短',
'skip-kind': '形态被跳过',
'below-score': '得分不够',
'no-match': '没命中主题'
}
const FLAG_LABELS = { burned_in: '硬字幕读不到', missing: '无语音' }
const REASONS_READABLE = GAP_LABELS
const n = (v) => Number(v) || 0
/** 一轮的结局 → 面板上的说法。'interrupted' 是进程被打断,不是抓取失败,得单独说。 */
const RUN_LABELS = {
done: '跑完',
partial: '部分源失败',
failed: '失败',
interrupted: '被打断',
running: '在跑',
}
function summaryLine(state) {
const c = state?.counts ?? {}
const run = state?.last_run
const head = run
? `上一轮 ${RUN_LABELS[run.status] ?? '失败'} · 过筛 ${n(c.total)} → 候选 ${n(run.candidates)} → 待判 ${n(c.new)}`
: `库里 ${n(c.total)} 条 · 还没有跑过一轮`
return `${head}|已判 ${n(c.judged) + n(c.digest)}`
}
function gapText(gaps) {
// gaps 缺席(state 里没有这轮的记录)才说"还没有";跑过轮但零丢弃零豁免时诚实显示"丢弃 0"——
// M3 真机验收见过:定时空配置一轮抓 0 条,旧文案把"跑过但空"谎报成"还没有一轮记录"。
if (gaps == null || typeof gaps !== 'object') return '(还没有一轮记录)'
const by = gaps.by_reason ?? {}
const kept = Object.entries(gaps.kept_with_flag ?? {})
const held = n(gaps.held)
// 收藏钉住(D15):豁免的丢弃没发生,不进 by_reason,单独一枚 chip,大于零才显示。
return [`丢弃 ${n(gaps.total_dropped)}`, ...Object.entries(by).map(([k, v]) => `${GAP_LABELS[k] ?? k} ${v}`),
...(kept.length ? [`保留待读:${kept.map(([k, v]) => `${FLAG_LABELS[k] ?? k} ${v}`).join(' / ')}`] : []),
...(held ? [`收藏钉住 ${held}`] : [])].join(' · ')
}
function sourceIssues(state) {
return (state?.sources ?? []).filter((s) => s.ok === false).map((s) => ({
id: s.id,
text: s.error_class === 'needs_login' ? `${s.id} 需要重新登录`
: s.error_class === 'unavailable' ? `${s.id} 缺依赖,去设置页装`
: s.error_class === 'empty' ? `${s.id} 本轮取到 0 条`
: `${s.id} 本轮失败(${s.error_class ?? '未知'}),游标没推进,下轮自动补`
}))
}
function Card({ item, onOpen }) {
return vnode('button', { type: 'button', className: 'bl-card', onClick: () => onOpen?.(item.id) }, [
item.kind === 'video' ? vnode('span', { className: 'bl-thumb' }, [
thumbSrc(item.thumb) ? vnode('img', { src: thumbSrc(item.thumb), alt: '', loading: 'lazy' }) : null,
vnode('span', { className: 'bl-dur' }, [fmtDuration(item.duration_sec) || '—'])
].filter(Boolean)) : null,
vnode('span', { className: 'bl-card-title' }, [item.title || '(无标题)']),
vnode('span', { className: 'bl-card-meta' }, [item.source_id, item.kind === 'note' ? '图文笔记' : item.kind === 'video' ? '视频' : '文章']),
vnode('span', { className: 'bl-tier' }, [item.verdict_tier ? `已判:${item.verdict_tier}` : '待判']),
vnode('span', { className: 'bl-why' }, [reasonOf(item)])
].filter(Boolean))
}
function TodayView({ state, items = [], onPickView, onOpen, onRun, busy }) {
const videos = items.filter((i) => i.kind === 'video')
const writings = items.filter((i) => i.kind !== 'video')
const pending = n(state?.counts?.new)
const issues = sourceIssues(state)
return vnode('div', { className: 'bl-today' }, [
vnode('div', { className: 'bl-summary' }, [summaryLine(state)]),
vnode('div', { className: 'bl-gaps' }, [gapText(state?.gaps)]),
issues.length ? vnode('div', { className: 'bl-issues', role: 'status' }, issues.map((s) => s.text)) : null,
vnode('div', { className: 'bl-pending' }, [
pending
? `待判 ${pending} 条——判断由会话来做,${judgeHint}就行`
: '没有待判条目'
]),
vnode('div', { className: 'bl-actions' }, [
vnode('button', { type: 'button', disabled: Boolean(busy), onClick: () => onRun?.() }, [busy ? '跑着…' : '现在跑一轮']),
vnode('button', { type: 'button', onClick: () => onPickView?.('library') }, ['去库'])
]),
vnode('section', { className: 'bl-rail' }, [
vnode('h3', {}, [`视频区 · ${videos.length}`]),
videos.length ? vnode('div', { className: 'bl-grid' }, videos.map((it) => Card({ item: it, onOpen })))
: vnode('p', { className: 'bl-empty' }, ['这个库里还没有视频。订阅一个 B 站或 YouTube 源,或者去设置页加一个。'])
]),
vnode('section', { className: 'bl-rail' }, [
vnode('h3', {}, [`文章区 · ${writings.length}`]),
writings.length ? vnode('div', { className: 'bl-grid' }, writings.map((it) => Card({ item: it, onOpen })))
: vnode('p', { className: 'bl-empty' }, ['文章区是空的。可能是刚清过库,或者筛选把东西都丢了。'])
])
].filter(Boolean))
}
// ---- src/client/views/video.js ----
/**
* 「视频」视图。这一屏的诚实点在两处:
* 1. 档位只有三个词,没判过的就是"待判",不许用预筛得分冒充结论;
* 2. 能不能播按源说清楚——只有 B 站打算内嵌,其余一律跳转,不画播放键骗人。
*/
const TIER_LABELS = { '值得看': 'good', '可以看': 'ok', '不值当': 'bad' }
const STATE_LABELS = {
full: '全文', summary_only: '仅摘要', subtitle: '字幕',
transcribed: '转写', missing: '无语音', burned_in: '硬字幕读不到'
}
function visibleVideos(items, filters = {}) {
const q = String(filters.q ?? '').trim().toLowerCase()
return (items ?? []).filter((i) => i.kind === 'video')
.filter((i) => !filters.tier || i.verdict_tier === filters.tier)
.filter((i) => !filters.source || i.source_id === filters.source)
.filter((i) => !q || `${i.title ?? ''} ${i.author ?? ''}`.toLowerCase().includes(q))
.sort((a, b) => (b.prefilter_score ?? 0) - (a.prefilter_score ?? 0))
}
function Picker({ label, filterKey, view, options, value, onFilter }) {
return vnode('select', {
'aria-label': label,
value: value ?? '',
onChange: (e) => onFilter?.({ view, key: filterKey, value: e?.target?.value ?? '' })
}, [
vnode('option', { value: '' }, [`${label}:全部`]),
...options.map((o) => vnode('option', { key: o, value: o }, [o]))
])
}
function VideoCard({ item, onOpen }) {
return vnode('button', { type: 'button', className: 'bl-card', key: item.id, onClick: () => onOpen?.(item.id) }, [
vnode('span', { className: 'bl-thumb' }, [
thumbSrc(item.thumb) ? vnode('img', { src: thumbSrc(item.thumb), alt: '', loading: 'lazy' }) : null,
vnode('span', { className: 'bl-dur' }, [fmtDuration(item.duration_sec) || '—'])
].filter(Boolean)),
vnode('span', { className: 'bl-card-title' }, [item.title || '(无标题)']),
vnode('span', { className: 'bl-card-meta' }, [
item.author || item.source_id,
STATE_LABELS[item.body_state] ?? item.body_state ?? ''
]),
vnode('span', { className: 'bl-tier' }, [item.verdict_tier ? `${item.verdict_tier}:${item.verdict_why || '(这条没写理由)'}` : '待判']),
vnode('span', { className: 'bl-why' }, [reasonOf(item)]),
// M4a 裁定:内嵌播放器 V5 才评估,哪个平台都还没法在抽屉里播——卡片统一只说跳转,
// 不许再对 B 站单独许诺"可在抽屉内播"(抽屉的能力边界同一时刻在说反话)。
vnode('span', { className: 'bl-play' }, ['只能跳转'])
])
}
function VideoView({ items = [], filters = {}, onFilter, onOpen }) {
const all = (items ?? []).filter((i) => i.kind === 'video')
const list = visibleVideos(items, filters)
const sources = [...new Set(all.map((i) => i.source_id))]
const judged = list.filter((i) => i.status === 'judged').length
return vnode('div', { className: 'bl-video' }, [
vnode('div', { className: 'bl-toolbar' }, [
Picker({ label: '档位', filterKey: 'tier', view: 'video', options: Object.keys(TIER_LABELS), value: filters.tier, onFilter }),
Picker({ label: '平台', filterKey: 'source', view: 'video', options: sources, value: filters.source, onFilter }),
vnode('input', {
type: 'search', 'aria-label': '关键词', placeholder: '在这批视频里找',
value: filters.q ?? '',
onChange: (e) => onFilter?.({ view: 'video', key: 'q', value: e?.target?.value ?? '' })
})
]),
vnode('div', { className: 'bl-count' }, [`显示 ${list.length} / ${all.length} 条(判过 ${judged} 条)`]),
list.length
? vnode('div', { className: 'bl-grid' }, list.map((i) => VideoCard({ item: i, onOpen })))
: vnode('div', { className: 'bl-empty' }, [all.length
? '这个筛选下没有东西。库里有货但没显示出来时,通常是档位或平台筛过了——把筛选清一下。'
: '库里还没有视频。订阅一个 B 站或 YouTube 源试试;现在订阅的都是文章源的话,这里就该是空的。'])
])
}
// ---- src/client/views/article.js ----
/**
* 「文章」视图:左列表右正文。这一屏要处理的核心事实是——**"只有摘要"是常态不是故障**:
* 很多源本来就不吐全文,插件没坏。所以每种 body_state 都要有自己的说法,
* 尤其不能把"源只给摘要"和"读不到内容"混成一句话(那是两种完全不同的责任)。
*/
const STATE_HINT = {
full: '',
summary_only: '这个源只给摘要,全文在原文里',
subtitle: '来自字幕',
transcribed: '来自本地转写',
missing: '读不到内容(没有语音,也没抓到正文)',
burned_in: '硬字幕读不到(字烧在画面上,不是插件偷懒)'
}
const STATE_OPTIONS = [
['full', '全文'], ['summary_only', '仅摘要'], ['subtitle', '字幕'],
['transcribed', '转写'], ['missing', '读不到'], ['burned_in', '硬字幕']
]
function visibleArticles(items, filters = {}) {
const q = String(filters.q ?? '').trim().toLowerCase()
return (items ?? []).filter((i) => i.kind === 'article' || i.kind === 'note')
.filter((i) => !filters.source || i.source_id === filters.source)
.filter((i) => !filters.state || i.body_state === filters.state)
.filter((i) => !q || `${i.title ?? ''} ${i.body_text ?? ''}`.toLowerCase().includes(q))
.sort((a, b) => (b.prefilter_score ?? 0) - (a.prefilter_score ?? 0))
}
/** 面板能读的那部分原文:抓到的是正文就读正文,只有摘要就读摘要,两个都没有就是空。 */
function readableBody(item) {
const t = item?.body_text ?? item?.summary_hint ?? ''
return typeof t === 'string' ? t.trim() : ''
}
function ArticleView({ items = [], filters = {}, selectedId, onFilter, onSelect, onOpen }) {
const list = visibleArticles(items, filters)
const cur = list.find((i) => i.id === selectedId) ?? list[0] ?? null
const body = cur ? readableBody(cur) : ''
const sources = [...new Set(items.filter((i) => i.kind !== 'video').map((i) => i.source_id))]
return vnode('div', { className: 'bl-article' }, [
vnode('div', { className: 'bl-toolbar' }, [
vnode('select', {
'aria-label': '来源', value: filters.source ?? '',
onChange: (e) => onFilter?.({ view: 'article', key: 'source', value: e?.target?.value ?? '' })
}, [vnode('option', { value: '' }, ['来源:全部']), ...sources.map((s) => vnode('option', { key: s, value: s }, [s]))]),
vnode('select', {
'aria-label': '正文', value: filters.state ?? '',
onChange: (e) => onFilter?.({ view: 'article', key: 'state', value: e?.target?.value ?? '' })
}, [vnode('option', { value: '' }, ['正文:全部']), ...STATE_OPTIONS.map(([v, l]) => vnode('option', { key: v, value: v }, [l]))]),
vnode('input', {
type: 'search', 'aria-label': '关键词', placeholder: '在这批文章里找',
value: filters.q ?? '',
onChange: (e) => onFilter?.({ view: 'article', key: 'q', value: e?.target?.value ?? '' })
})
]),
vnode('div', { className: 'bl-two' }, [
vnode('div', { className: 'bl-list', role: 'list' }, list.map((i) => vnode('button', {
type: 'button', key: i.id, className: 'bl-row',
'aria-selected': cur && cur.id === i.id ? 'true' : 'false',
onClick: () => onSelect?.(i.id)
}, [
vnode('span', { className: 'bl-row-meta' }, [i.source_id, i.kind === 'note' ? '图文笔记' : '文章']),
vnode('span', { className: 'bl-row-title' }, [i.title || '(无标题)']),
vnode('span', { className: 'bl-row-why' }, [reasonOf(i)])
]))),
vnode('article', { className: 'bl-reader', 'aria-live': 'polite' }, cur ? [
vnode('h4', {}, [cur.title || '(无标题)']),
vnode('p', { className: 'bl-reader-by' }, [
`${cur.source_id} · ${cur.author || '未知作者'}`,
Number(cur.time_estimated) > 0 ? `约 ${Math.round(Number(cur.time_estimated))} 分钟` : ''
]),
STATE_HINT[cur.body_state] ? vnode('p', { className: 'bl-reader-hint' }, [STATE_HINT[cur.body_state]]) : null,
vnode('div', { className: 'bl-reader-body' }, [body || '(没有正文,只能看原文)']),
vnode('p', { className: 'bl-reader-link' }, ['读原文:' + (cur.url || '(这条没带链接)')]),
vnode('button', { type: 'button', className: 'bl-detail', onClick: () => onOpen?.(cur.id) }, ['详情与反馈'])
].filter(Boolean) : [
vnode('p', { className: 'bl-empty' }, ['左边没有条目。多半是这个筛选太窄,或者这一轮还没抓到东西。'])
])
])
])
}
// ---- src/client/views/digest.js ----
/**
* 「汇报」视图。M3 起这里展示判断轮真实落库的汇报(最近 5 条,倒序);
* 没有内容时唯一的责任仍是**把"为什么没有"说清楚**:
* 是没跑过、还是接入了但还没判过、还是库里明明有汇报但面板没读到。
* 三种情况写法不同,绝不拿假摘要填版面。
*/
/**
* @param state 宿主 /state 的返回(要 digest_count、counts.new、last_run)
* @param loaded 面板手里真正拿到的汇报份数
*/
function digestEmptyReason(state, loaded) {
if (loaded > 0) return ''
const total = Number(state?.digest_count) || 0
if (total > 0) return `库里有 ${total} 份汇报,但面板没读到汇报内容——这一格现在数得出,读不出来。`
if (!state || !state.last_run) return '还没跑过一轮:库是空的。去「今天」点"现在跑一轮",或者等定时窗口自己起。'
const pending = Number(state?.counts?.new) || 0
return `判断轮已接入,但还没有跑过判断${pending ? `:库里待判 ${pending} 条` : ''}。${judgeHint},判完这里就出汇报。`
}
/**
* tiers 计数行:数字与 headline 同源(都出自 saveVerdicts 落的 stats)。
* stats 缺了或没解出来就明说"没读出来",不装作没有这一栏——那是把读回失败说成空。
*/
function digestTierLine(stats) {
const t = stats?.tiers
if (!t) return '(这份汇报的计数没读出来)'
return `值得看 ${Number(t['值得看']) || 0} · 可以看 ${Number(t['可以看']) || 0} · 不值当 ${Number(t['不值当']) || 0}`
}
function DigestView({ digests = [], state }) {
const reason = digestEmptyReason(state, digests.length)
if (reason) return vnode('div', { className: 'bl-digest bl-empty-state' }, [reason])
const sorted = [...digests].sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
return vnode('div', { className: 'bl-digest' }, sorted.map((d) => vnode('section', { className: 'bl-dsum', key: d.id }, [
vnode('h3', {}, [fmtTime(d.created_at)]),
vnode('p', { className: 'bl-dhead' }, [d.headline || '(这份简报没写标题)']),
vnode('p', { className: 'bl-dtiers' }, [digestTierLine(d.stats)])
])))
}
// ---- src/client/views/library.js ----
/**
* 「库」= 全量检索,含已归档。与另外三格不同,这里的筛选是多选(facet 勾选),
* 因为查库的人通常在问"判过的里面哪些是不值当的"——单选取一个维度不够。
* 组内 OR、组间 AND;一个组都没勾 = 不筛。
*/
const FACETS = [
{ key: 'status', label: '状态', options: ['new', 'judged', 'digest', 'archived'] },
{ key: 'kind', label: '形态', options: ['article', 'video', 'note'] },
{ key: 'tier', label: '档位', options: ['值得看', '可以看', '不值当'] },
{ key: 'source', label: '来源', options: null }
]
const PAGE = 100
function libraryRows(items, filters = {}) {
const allowed = (key, val) => {
const sel = filters[key]
return !Array.isArray(sel) || sel.length === 0 || sel.includes(val)
}
return (items ?? [])
.filter((i) => allowed('status', i.status))
.filter((i) => allowed('kind', i.kind))
.filter((i) => allowed('tier', i.verdict_tier))
.filter((i) => allowed('source', i.source_id))
.sort((a, b) => (b.published_at ?? 0) - (a.published_at ?? 0))
}
function Facet({ def, options, filters, onFacet }) {
const sel = Array.isArray(filters[def.key]) ? filters[def.key] : []
return vnode('div', { className: 'bl-facet', 'data-facet': def.key, key: def.key }, [
vnode('h4', {}, [def.label]),
...options.map((o) => vnode('label', { key: o }, [
vnode('input', {
type: 'checkbox',
'aria-label': `${def.label}:${o}`,
checked: sel.includes(o),
onChange: (e) => onFacet?.({ view: 'library', key: def.key, value: o, on: Boolean(e?.target?.checked) })
}),
o
]))
])
}
function Row({ item, onOpen }) {
const bits = [item.source_id, item.kind === 'note' ? '图文笔记' : item.kind === 'video' ? '视频' : '文章', item.status]
if (item.status === 'archived') bits.push(item.archived_reason ? `归档原因:${item.archived_reason}` : '归档原因没记')
return vnode('button', { type: 'button', className: 'bl-card', key: item.id, onClick: () => onOpen?.(item.id) }, [
vnode('span', { className: 'bl-row-meta' }, bits),
vnode('span', { className: 'bl-row-title' }, [item.title || '(无标题)']),
item.verdict_tier ? vnode('span', { className: 'bl-tier' }, [`已判:${item.verdict_tier}`]) : null
].filter(Boolean))
}
function LibraryView({ items = [], filters = {}, onFacet, onOpen }) {
const sources = [...new Set(items.map((i) => i.source_id))]
const rows = libraryRows(items, filters)
const shown = rows.slice(0, PAGE)
return vnode('div', { className: 'bl-library' }, [
vnode('aside', { className: 'bl-facets' }, FACETS.map((def) => Facet({ def, options: def.options || sources, filters, onFacet }))),
vnode('div', { className: 'bl-library-body' }, [
vnode('div', { className: 'bl-count' }, [`${rows.length} 条`]),
rows.length
? vnode('div', { className: 'bl-grid' }, shown.map((i) => Row({ item: i, onOpen })))
: vnode('p', { className: 'bl-empty' }, [items.length
? '这些勾选下没有条目。库里是有东西的——把勾选清一下。'
: '库里还没有条目。先去「今天」跑一轮。']),
rows.length > PAGE ? vnode('p', { className: 'bl-more' }, [`还有 ${rows.length - PAGE} 条,翻分页看(这一屏只放 ${PAGE} 条)`]) : null,
vnode('p', { className: 'bl-note' }, ['改判由会话通过 baleen_save_verdicts 做,这里不放改判按钮;批量归档还没做。'])
].filter(Boolean))
])
}
// ---- src/client/views/drawer.js ----
/**
* 条目抽屉:这条"为什么给你"的落点,也是唯一会改偏好的交互面。
*
* 两个按钮的语义必须写在界面上:**只动来源权重(tuning),不动主题词(themes)**。
* 结果一定要回显——点了不知道有没有记上,等于把偏好系统的黑盒交给了用户。
*/
const DELTA = { dismiss: '-0.1', save: '+0.1' }
/** 每种 body_state 一句说法:正文区与能力边界共用,别两处各写一遍再各自漂移 */
const STATE_NOTE = {
full: '',
summary_only: '只有摘要:这个源只给摘要,全文在原文里——不是我们读不到。',
subtitle: '字幕全文已存,正文区就是字幕。',
transcribed: '这段来自本地转写。',
missing: '没有语音也没有字幕轨,读不到内容。',
burned_in: '硬字幕:字烧在画面上,读不到内容。'
}
/** 这三种是"能力边界",其余只是内容来源说明 */
const LIMITS = new Set(['summary_only', 'missing', 'burned_in'])
function feedbackHint(action) {
return action === 'dismiss'
? '不感兴趣:只改来源权重(-0.1,累计限幅 ±0.5),不改你的主题词'
: '这源多来点:只改来源权重(+0.1,累计限幅 ±0.5),不改你的主题词'
}
/**
* 能不能播(按源)与能不能读(按 body_state)是两件事,各自说一句。
* 早先只回一句的话,B 站硬字幕条目会被播放器的说法盖掉,看不出内容其实读不到。
* 播放器 M4a 没做(V5 先探后定),所以这里不许预告任何接入时间——跳转就是现状。
*/
function boundaryNote(item) {
const bits = []
if (item?.kind === 'video') {
bits.push(item.source_id === 'bili'
? 'B 站:抽屉内嵌播放器还没定下来,现在跳到浏览器里看。'
: '这个平台没有可靠的嵌入方案,只能跳到浏览器里看。')
}
if (LIMITS.has(item?.body_state)) bits.push(STATE_NOTE[item.body_state])
return bits.join(' ')
}
const kindLabel = (kind) => (kind === 'video' ? '视频' : kind === 'note' ? '图文笔记' : '文章')
function Section({ title, children }) {
return vnode('section', {}, [vnode('h5', {}, [title]), ...children.filter(Boolean)])
}
/**
* 勾上/取消某个夹之后该提交的集合。规矩是"原有在前、新勾追加":
* 不按夹列表的顺序反推——那个顺序是面板右侧的展示顺序,不是这条的归属历史;
* 条目上已有的、夹列表里认不出的 id 一律原样留着——那多半是"这一秒刚被另一格删掉",
* 静默丢弃等于替用户做了他没做的决定。
* folders 参数暂不参与排序,留着是为了签名与夹列表同源(调用方不必自己先换算)。
*/
function collectSetOf(item, folders, toggleId, on) {
const cur = Array.isArray(item?.folders) ? item.folders.filter((x) => Number.isInteger(x)) : []
void folders
return on ? [...new Set([...cur, toggleId])] : cur.filter((x) => x !== toggleId)
}
/**
* 装配层的"过期归属"覆盖:抽屉里那条来自不带 folders 字段的查询时(今天的三格),
* /collect 成功读回的归属按条目 id 覆盖回抽屉这一条——只改抽屉的展示,不动列表真相。
* 没有这层,连点两个勾各自从"没读到"算起,第二次全量替换会把第一次的放进整个抹掉。
* 只在 found 存在、自身没有 folders、knownFolders 指的是同一条时才覆盖,其余原样返回。
*/
function drawerItemWithFolders(found, knownFolders) {
return found && !Array.isArray(found.folders) && knownFolders && knownFolders.itemId === found.id
? { ...found, folders: knownFolders.folder_ids }
: found
}
/** 夹 id 说回人能认的名字;列表里查不到就明写"这个夹已经没了",不编一个 */
const namesOf = (item, folders, ids) => (ids ?? []).map((id) =>