Repository navigation
Expand file tree
/
Copy pathclient.js
More file actions
2667 lines (2512 loc) · 143 KB
/
Copy pathclient.js
File metadata and controls
2667 lines (2512 loc) · 143 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-study-desk —— 学习工作台(Client 半边)
*
* 以 dsh.client bundle 格式加载(跟 dsh-sticker / dsh-meme 同一套 ModuleLoader 机制,
* 不需要打包器)。注册 id 必须等于 loader entry 名(dsh-study-desk),否则 ModuleLoader
* 报 "loaded without registering dsh-study-desk"。
*
* 视觉约定:一个硬编码颜色都没有——整条 --dsw-alias-* / --dsw-static-* 阶梯都只被消费,
* 不被覆写。当前主题(catppuccin-mocha + mica 玻璃)换掉,这里所有颜色自动跟着变,
* 所以看板是「融进主题」而不是「自带一套皮肤」。
*
* 挂载点:
* sidebar.panellist → 左侧栏图标(id 与 main 的 key 同名,侧栏自动 dispatch)
* main (keyed) → 整页工作台
* shell.overlay → 右下角常驻迷你计时条(跨面板存活)
* settings.section → 「学习工作台」设置页
*/
window.__ModuleLoader__.load({
id: 'dsh-study-desk',
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
const React = require('react')
const h = React.createElement
// 迷你条要跳出 shell.overlay 那一层(宿主给它的是 z-index:20 + pointer-events:none,
// 层内再高的 z-index 也只跟层内比),所以挂到 body 上当作根层叠上下文里的浮层。
let createPortal = null
try {
const ReactDOM = require('react-dom')
if (ReactDOM && typeof ReactDOM.createPortal === 'function') createPortal = ReactDOM.createPortal
} catch (error) { /* 拿不到 react-dom 就在槽位里原地渲染 */ }
function toTopLayer(node) {
if (!createPortal || !document.body) return node
return createPortal(node, document.body)
}
// -----------------------------------------------------------------------
// 常量与工具
// -----------------------------------------------------------------------
const API = '/api/dsh-study-desk'
const PANEL_ID = 'study-desk'
// 单词打卡是第二个独立面板:侧栏一项 + main 一格,id 与 main 的 key 必须同名
const BBCDC_PANEL_ID = 'study-desk-bbdc'
const MINI_POS_KEY = 'dsh-study-desk:mini-pos'
const STATUS_META = [
{ id: 'todo', label: '待办', latin: 'TODO' },
{ id: 'doing', label: '进行中', latin: 'DOING' },
{ id: 'done', label: '已完成', latin: 'DONE' },
]
const GRADES = ['again', 'good', 'easy']
const GRADE_LABEL = { again: '忘了', good: '记得', easy: '很简单' }
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日']
function startOfDay(ts) {
const d = new Date(ts)
d.setHours(0, 0, 0, 0)
return d.getTime()
}
function addDays(ts, n) {
const d = new Date(ts)
d.setDate(d.getDate() + n)
return d.getTime()
}
function dayKey(ts) {
const d = new Date(ts)
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
function parseDayKey(key) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(key || ''))
if (!m) return NaN
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime()
}
function daysUntil(key, now) {
const t = parseDayKey(key)
if (!Number.isFinite(t)) return null
return Math.round((t - startOfDay(now === undefined ? Date.now() : now)) / 86400000)
}
function fmtMinutes(min) {
const n = Math.max(0, Math.round(Number(min) || 0))
if (n < 60) return n + ' 分钟'
const hh = Math.floor(n / 60)
const mm = n % 60
return mm ? `${hh} 小时 ${mm} 分` : `${hh} 小时`
}
function fmtShort(min) {
const n = Math.max(0, Math.round(Number(min) || 0))
if (n < 60) return n + 'm'
const hh = Math.floor(n / 60)
const mm = n % 60
return mm ? `${hh}h${mm}` : `${hh}h`
}
function fmtClock(ms) {
const total = Math.max(0, Math.ceil(ms / 1000))
const m = Math.floor(total / 60)
const s = total % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
/**
* 墙上时间(不是倒计时)。fmtClock 是「还差多久」的 mm:ss 口径,
* 拿它渲时间戳会得到 29865200:00 这种荒唐值 —— 两个别混用。
*/
function fmtStamp(ts) {
const d = new Date(Number(ts) || 0)
if (!Number.isFinite(d.getTime())) return '—'
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
/** 复习排期的人话说法:逾期 N 天 / 今天到期 / 明天 / N 天后。 */
function dueLabel(due, now) {
if (!due) return ''
const d = daysUntil(due, now === undefined ? Date.now() : now)
if (d === null) return due
if (d < 0) return `逾期 ${-d} 天`
if (d === 0) return '今天到期'
if (d === 1) return '明天'
return `${d} 天后`
}
// -----------------------------------------------------------------------
// 全局 store:宿主是唯一真相源,这里只缓存最近一次快照
// -----------------------------------------------------------------------
const store = {
ready: false,
error: null,
state: { tasks: [], sessions: [], reviews: [], journal: {}, timer: null, settings: {} },
minutes: {},
todayMinutes: 0,
streak: 0,
focusRoundsToday: 0,
// 派生数据由宿主算好(desk.js 是唯一的算法出处,浏览器里不再实现一遍)
review: { scheduled: 0, due: 0, dueToday: 0, overdue: 0, doneToday: 0, weekDone: 0 },
due: [],
forecast: { days: [], later: 0, total: 0, max: 0 },
focusPie: null,
plan: { items: [], due: 0, cap: 10, advice: null },
tags: [],
weekly: { weeks: [], goalWeekly: 0 },
journalLog: [],
markdownFile: '',
goal: { label: '', date: '', end: '', note: '', days: null, milestones: [] },
deskFile: '',
version: '',
// 不背单词打卡:宿主只发缓存,刷新是单独一次 op(要拷虚拟机磁盘,不能塞进轮询)
bbdc: null,
bbdcBusy: false,
listeners: new Set(),
subscribe(fn) {
this.listeners.add(fn)
return () => { this.listeners.delete(fn) }
},
emit() {
for (const fn of Array.from(this.listeners)) {
try { fn() } catch (error) { console.warn('[dsh-study-desk] 渲染回调报错:', error) }
}
},
absorb(json) {
if (!json || json.ok === false) {
this.error = (json && json.error) || '接口返回异常'
this.emit()
return
}
this.error = null
this.state = json.state || this.state
this.minutes = json.minutes || {}
this.todayMinutes = json.todayMinutes || 0
this.streak = json.streak || 0
this.focusRoundsToday = json.focusRoundsToday || 0
this.review = json.review || this.review
this.due = json.due || []
this.forecast = json.forecast || this.forecast
this.focusPie = json.focusPie || this.focusPie
this.plan = json.plan || this.plan
this.tags = json.tags || []
this.weekly = json.weekly || this.weekly
this.journalLog = json.journal || []
this.markdownFile = json.markdownFile || ''
this.goal = json.goal || this.goal
this.deskFile = json.deskFile || ''
this.version = json.version || ''
// bbdc 可能是 null(还没采过),此时保留旧值,避免刷新把已有数据抹掉
if (json.bbdc) this.bbdc = json.bbdc
this.ready = true
this.emit()
},
async refresh() {
try {
const res = await fetch(API + '?op=state', { cache: 'no-store' })
this.absorb(await res.json())
} catch (error) {
this.error = error && error.message ? error.message : String(error)
this.emit()
}
},
async apply(op, payload) {
try {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op, ...(payload || {}) }),
})
const json = await res.json()
if (json && json.ok === false) console.warn('[dsh-study-desk]', op, json.error)
this.absorb(json)
return json
} catch (error) {
this.error = error && error.message ? error.message : String(error)
this.emit()
return null
}
},
/**
* 重新采集不背单词打卡。这一步在宿主侧要拷一份 1.3GB 的虚拟机磁盘,
* 所以:① 只由用户点「刷新」或冷却时间到了才触发;② 一次只跑一个;
* ③ 结果由宿主写进缓存,下一次轮询的快照就会带上。
*/
async refreshBbdc() {
if (this.bbdcBusy) return null
this.bbdcBusy = true
this.emit()
try {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op: 'bbdc.refresh' }),
})
const json = await res.json()
this.absorb(json)
return json
} catch (error) {
this.error = error && error.message ? error.message : String(error)
this.emit()
return null
} finally {
this.bbdcBusy = false
this.emit()
}
},
}
// -----------------------------------------------------------------------
// 诊断回传:浏览器里没有能读的控制台,就把异常发回宿主写进磁盘
// 宿主 /api/dsh-study-desk 的 diag.report 会追加到 <DSH_HOME>/study-desk/client-diag.json
// -----------------------------------------------------------------------
const diagSeen = new Set()
let diagSeq = 0
function reportDiag(entry) {
try {
const key = `${entry.kind || ''}|${entry.where || ''}|${entry.message || ''}`
if (diagSeen.has(key)) return
diagSeen.add(key)
diagSeq += 1
fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op: 'diag.report', seq: diagSeq, at: Date.now(), ...entry }),
}).catch(() => { /* 诊断通道坏了不能影响主流程 */ })
} catch (error) { /* 同上 */ }
}
function getLayout() {
if (!clientCtx) return null
try {
if (typeof clientCtx.get === 'function') {
const viaGet = clientCtx.get('layout')
if (viaGet) return viaGet
}
} catch (error) { /* 退到属性访问 */ }
// ctx 是 Proxy:没在 inject 里声明过的服务,连属性访问都会抛
// "cannot get property "layout" without inject",所以这条也必须包一层
try {
return clientCtx.layout || null
} catch (error) {
return null
}
}
// 渲染炸了要看得见,不能只是白屏
class Boundary extends React.Component {
constructor(props) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error) {
return { error }
}
componentDidCatch(error, info) {
reportDiag({
kind: 'render-error',
where: this.props.where || '未知',
message: String((error && error.message) || error),
stack: String((error && error.stack) || '').split('\n').slice(0, 12).join('\n'),
componentStack: String((info && info.componentStack) || '').split('\n').slice(0, 12).join('\n'),
})
}
render() {
if (this.state.error) {
return h('div', { className: 'sd-error' }, [
h('div', { key: 't' }, `工作台渲染出错(${this.props.where || '未知位置'})——细节已写进 client-diag.json`),
h('pre', { key: 's', className: 'sd-error-stack' },
String((this.state.error && this.state.error.stack) || this.state.error)),
])
}
return this.props.children
}
}
function tasksIn(status) {
return store.state.tasks
.filter((t) => t.status === status)
.sort((a, b) => (a.order - b.order) || (a.createdAt - b.createdAt))
}
// -----------------------------------------------------------------------
// 番茄钟:本地只负责「按 endsAt 画倒计时」,归零结账交给宿主
// -----------------------------------------------------------------------
let audioCtx = null
function beep(kind) {
try {
const Ctor = window.AudioContext || window.webkitAudioContext
if (!Ctor) return
if (!audioCtx) audioCtx = new Ctor()
const t = audioCtx.currentTime
const osc = audioCtx.createOscillator()
const gain = audioCtx.createGain()
osc.type = 'sine'
osc.frequency.value = kind === 'break' ? 587 : 880
gain.gain.setValueAtTime(0.0001, t)
gain.gain.exponentialRampToValueAtTime(0.14, t + 0.02)
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.5)
osc.connect(gain)
gain.connect(audioCtx.destination)
osc.start(t)
osc.stop(t + 0.55)
} catch { /* 用户没交互过 / 不支持音频就静默跳过 */ }
}
function timerRemaining(timer, now) {
if (!timer) return 0
if (timer.running) return Math.max(0, timer.endsAt - now)
return Math.max(0, Number(timer.remainingMs) || 0)
}
let watchTimer = 0
let lastPhase = ''
let reconciling = false
function startWatcher() {
if (watchTimer) return
watchTimer = setInterval(async () => {
const timer = store.state.timer
if (timer && timer.running && timer.endsAt <= Date.now() && !reconciling) {
reconciling = true
const wasFocus = timer.kind === 'focus'
try {
// 宿主在这次读取时会顺手结账(记 session + 摆下一段),不用客户端自己算
await store.refresh()
beep(wasFocus ? 'focusDone' : 'breakDone')
} finally {
reconciling = false
}
}
if (timer) {
const phase = `${timer.kind}:${timer.endsAt}`
lastPhase = phase
}
}, 1000)
}
function useTick(ms) {
const [now, force] = React.useState(() => Date.now())
React.useEffect(() => {
const id = setInterval(() => force(Date.now()), ms)
return () => clearInterval(id)
}, [ms])
return now
}
function useStore() {
const [, force] = React.useState(0)
React.useEffect(() => store.subscribe(() => force((n) => n + 1)), [])
return store
}
function usePoll(ms) {
React.useEffect(() => {
store.refresh()
const id = setInterval(() => {
if (document.visibilityState === 'visible') store.refresh()
}, ms)
return () => clearInterval(id)
}, [ms])
}
// -----------------------------------------------------------------------
// 共享小组件
// -----------------------------------------------------------------------
function Icon({ name, size }) {
const s = size || 16
const common = {
width: s, height: s, viewBox: '0 0 20 20', fill: 'none',
stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
'aria-hidden': true,
}
const paths = {
board: ['M3 4.5h14v11H3z', 'M3 8.5h14', 'M7.5 8.5v7'],
clock: ['M10 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13z', 'M10 6.5v4l2.5 1.5'],
play: ['M6.5 4.8v10.4l8-5.2z'],
pause: ['M7 4.8v10.4', 'M13 4.8v10.4'],
stop: ['M6 6h8v8H6z'],
plus: ['M10 4.5v11', 'M4.5 10h11'],
left: ['M12 5.5 7.5 10l4.5 4.5'],
right: ['M8 5.5 12.5 10 8 14.5'],
check: ['M4.5 10.5l3.5 3.5 7.5-8'],
cross: ['M6 6l8 8', 'M14 6l-8 8'],
flame: ['M10 2.5c3 3.2 4.5 5.6 4.5 8a4.5 4.5 0 0 1-9 0c0-1.6.7-3 1.8-4.3', 'M10 16.5a2.4 2.4 0 0 1-1-4.5'],
expand: ['M4 8V4h4', 'M16 12v4h-4', 'M4 12v4h4', 'M16 8V4h-4'],
collapse: ['M8 4v4H4', 'M12 16v-4h4', 'M12 4v4h4', 'M8 16v-4H4'],
repeat: ['M4.5 9A5.5 5.5 0 0 1 15 6.4', 'M15.5 11A5.5 5.5 0 0 1 5 13.6', 'M15 3.2v3.4h-3.4', 'M5 16.8v-3.4h3.4'],
pen: ['M13.4 4.2l2.4 2.4L7.6 14.8l-3.2.8.8-3.2z'],
down: ['M10 3.5v9', 'M6.2 8.8 10 12.6l3.8-3.8', 'M4 16.5h12'],
up: ['M10 12.5v-9', 'M6.2 7.2 10 3.4l3.8 3.8', 'M4 16.5h12'],
tag: ['M10.6 3.5H16v5.4l-8 8-5.4-5.4z', 'M13.1 6.4h.01'],
search: ['M8.8 3.4a5.4 5.4 0 1 0 0 10.8 5.4 5.4 0 0 0 0-10.8z', 'M12.8 12.8l3.7 3.7'],
}
const p = paths[name] || paths.board
return h('svg', common, p.map((d, i) => h('path', { key: i, d })))
}
function Progress({ value, max, tone }) {
const pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0
return h('div', { className: 'sd-bar' + (tone ? ' sd-bar-' + tone : '') },
h('div', { className: 'sd-bar-fill', style: { width: pct + '%' } }))
}
// -----------------------------------------------------------------------
// 顶部:标题 / 倒计时 / 节点 / 今日进度
// -----------------------------------------------------------------------
function Head({ now }) {
const s = useStore()
const goal = s.goal || {}
const days = typeof goal.days === 'number' ? goal.days : null
const daily = Number(s.state.settings.dailyGoalMin) || 180
const pct = daily > 0 ? Math.round((s.todayMinutes / daily) * 100) : 0
// 节点的语气与文案由 desk.js 算好;这里只决定摆哪些(太远的和早就结束的别占地方)
const chips = (goal.milestones || []).filter((m) => m.toEnd >= -3 && m.toStart <= 45)
const subtitle = [goal.note, goal.date
? `${goal.label || '目标'} ${goal.date}${goal.end && goal.end !== goal.date ? ' ~ ' + goal.end : ''}`
: ''].filter(Boolean).join(' · ')
return h('header', { className: 'sd-head' }, [
h('div', { className: 'sd-head-row', key: 'title' }, [
h('div', { className: 'sd-head-main', key: 'l' }, [
h('h1', { className: 'sd-title', key: 't' }, '学习工作台'),
subtitle ? h('div', { className: 'sd-subtitle', key: 's' }, subtitle) : null,
]),
goal.date ? h('div', { className: 'sd-count', key: 'r' }, [
h('span', { className: 'sd-count-num', key: 'n' }, days === null ? '—' : String(Math.max(0, days))),
h('span', { className: 'sd-count-cap', key: 'c' }, '天'),
h('span', { className: 'sd-count-label', key: 'l' }, (days !== null && days < 0 ? '已过 · ' : '距 · ') + (goal.label || '目标')),
]) : null,
]),
chips.length
? h('div', { className: 'sd-chips', key: 'chips' }, chips.map((c) =>
h('span', { className: 'sd-chip sd-chip-' + c.tone, key: c.id, title: `${c.label} ${c.start}${c.end !== c.start ? ' ~ ' + c.end : ''}` }, [
h('b', { key: 'a' }, c.label),
h('span', { key: 'b' }, c.text),
c.approx ? h('i', { key: 'c', className: 'sd-chip-approx', title: '这个日期是估的,以官方通知为准' }, '约') : null,
])))
: null,
h('div', { className: 'sd-head-row sd-head-row-end', key: 'today' }, [
h('div', { className: 'sd-today', key: 't' }, [
h('span', { className: 'sd-today-cap', key: 'c' }, '今日专注'),
h('span', { className: 'sd-today-num', key: 'n' }, fmtMinutes(s.todayMinutes)),
h('span', { className: 'sd-today-goal', key: 'g' }, `/ ${fmtMinutes(daily)} · ${pct}%`),
h('div', { className: 'sd-today-bar', key: 'b' }, h(Progress, { value: s.todayMinutes, max: daily })),
]),
h('div', { className: 'sd-today sd-today-streak', key: 's' }, [
h('span', { className: 'sd-today-cap', key: 'c' }, '连续打卡'),
h('span', { className: 'sd-today-num', key: 'n' }, String(s.streak)),
h('span', { className: 'sd-today-goal', key: 'g' }, '天'),
]),
h(MarkdownRow, { key: 'md' }),
]),
])
}
/**
* Markdown 导出 / 读回:desk.md 落在数据旁边,软链进 Obsidian vault 就能当笔记改,
* 改完再读回来。读回是「合并」不是「覆盖」,md 里没有的卡不动。
*/
function MarkdownRow() {
const s = useStore()
const [busy, setBusy] = React.useState('')
const [note, setNote] = React.useState('')
const fileRef = React.useRef(null)
const run = async (kind, task, done) => {
setBusy(kind)
setNote('')
try {
const res = await task()
setNote(done(res) || '没成功,细节在宿主日志里')
} catch (error) {
setNote(String((error && error.message) || error))
} finally {
setBusy('')
}
}
return h('div', { className: 'sd-mdrow' }, [
h('button', {
key: 'e', className: 'sd-textbtn', disabled: busy !== '',
title: s.markdownFile ? `写到 ${s.markdownFile}` : '把看板导成一份 Markdown',
onClick: () => run('export', () => store.apply('markdown.export', {}),
(res) => (res && res.result ? `已导出:${res.result.file}` : '')),
}, busy === 'export' ? '导出中…' : [h(Icon, { name: 'down', size: 13, key: 'i' }), h('span', { key: 't' }, ' 导出 Markdown')]),
h('button', {
key: 'i', className: 'sd-textbtn', disabled: busy !== '',
title: '选一份 desk.md,按标题与 id 合并回看板',
onClick: () => fileRef.current && fileRef.current.click(),
}, busy === 'import' ? '读回中…' : [h(Icon, { name: 'up', size: 13, key: 'i' }), h('span', { key: 't' }, ' 从 Markdown 读回')]),
h('input', {
key: 'f', ref: fileRef, type: 'file', accept: '.md,.markdown,text/markdown,text/plain',
className: 'sd-hidden-file',
onChange: (e) => {
const file = e.target.files && e.target.files[0]
e.target.value = ''
if (!file) return
run('import', async () => store.apply('markdown.import', { markdown: await file.text() }),
(res) => (res && res.result ? `读回:新增 ${res.result.added} 张、更新 ${res.result.updated} 张` : ''))
},
}),
note ? h('span', { className: 'sd-mdnote', key: 'n', title: note }, note) : null,
])
}
// -----------------------------------------------------------------------
// 番茄钟面板
// -----------------------------------------------------------------------
function TimerPanel({ now }) {
const s = useStore()
useTick(500)
const timer = s.state.timer
const settings = s.state.settings || {}
const remaining = timerRemaining(timer, now)
const total = timer ? timer.minutes * 60000 : (Number(settings.focusMin) || 25) * 60000
const isFocus = !timer || timer.kind === 'focus'
const presets = [25, 45, 60]
const candidates = store.state.tasks.filter((t) => t.status !== 'done')
const [picked, setPicked] = React.useState('')
const [nameDraft, setNameDraft] = React.useState('')
// 番茄ToDo 规矩:起钟必须先报名字。名字来源三选一——打的字 > 挂的卡标题 > 无(禁用起钟)。
const pickedTask = candidates.find((t) => t.id === picked)
const typed = nameDraft.trim().slice(0, 24)
const effectiveName = typed || (pickedTask ? pickedTask.title : '')
const subjectChips = (settings.subjects || []).filter((x) => x && x.label).slice(0, 6)
const start = (minutes) => {
if (!effectiveName) return
store.apply('timer.start', {
minutes,
kind: 'focus',
taskId: picked || undefined,
name: typed || undefined,
})
setNameDraft('')
}
return h('section', { className: 'sd-timer-card' }, [
!timer ? h('div', { className: 'sd-timer-name-row', key: 'nr' }, [
h('input', {
key: 'n',
className: 'sd-input wide' + (effectiveName ? '' : ' sd-name-empty'),
type: 'text',
value: nameDraft,
maxLength: 24,
placeholder: '这段要做什么?如:864 论述练习 / 背单词(起钟必填)',
onChange: (e) => setNameDraft(e.target.value),
onKeyDown: (e) => { if (e.key === 'Enter') start(settings.focusMin || 25) },
}),
...subjectChips.map((x) => h('button', {
key: x.id || x.label,
className: 'sd-pill sm' + (typed === x.label ? ' on' : ''),
title: `用「${x.label}」当这段的名字`,
onClick: () => setNameDraft(typed === x.label ? '' : x.label),
}, x.label)),
]) : null,
h('div', { className: 'sd-timer-row', key: 'r' }, [
h('button', {
key: 'play',
className: 'sd-iconbtn sd-iconbtn-lg',
title: timer ? (timer.running ? '暂停' : '继续') : (effectiveName ? '开始专注' : '先给这段起个名字'),
disabled: !timer && !effectiveName,
onClick: () => {
if (!timer) start(settings.focusMin || 25)
else if (timer.running) store.apply('timer.pause')
else store.apply('timer.resume')
},
}, h(Icon, { name: !timer ? 'play' : timer.running ? 'pause' : 'play', size: 18 })),
h('div', { className: 'sd-timer-main', key: 'm' }, [
h('div', { className: 'sd-timer-clock', key: 'c' }, fmtClock(remaining)),
h('div', { className: 'sd-timer-meta', key: 'k' }, [
h('span', { className: 'sd-phase' + (isFocus ? ' sd-phase-focus' : ' sd-phase-break'), key: 'p' },
!timer ? '未开始' : isFocus ? `专注 · 第 ${timer.round} 轮` : '休息'),
timer && timer.title ? h('span', { className: 'sd-dot', key: 'd' }) : null,
timer && timer.title ? h('span', { className: 'sd-timer-task', key: 't', title: timer.title }, timer.title) : null,
!timer && !effectiveName && s.focusRoundsToday ? h('span', { className: 'sd-timer-sub', key: 's' }, `今天已完成 ${s.focusRoundsToday} 轮`) : null,
!timer && effectiveName ? h('span', { className: 'sd-timer-sub', key: 's2' }, `这段叫「${effectiveName}」`) : null,
]),
h('div', { className: 'sd-timer-bar', key: 'b' },
h(Progress, { value: total - remaining, max: total, tone: isFocus ? 'brand' : 'muted' })),
]),
h('div', { className: 'sd-timer-side', key: 's' }, [
timer ? h('button', {
className: 'sd-iconbtn', title: '结束这一段(按实际时长记账)', onClick: () => store.apply('timer.complete'),
}, h(Icon, { name: 'stop' })) : null,
timer && isFocus && !timer.running ? h('button', {
className: 'sd-textbtn', title: '跳过这一段', onClick: () => store.apply('timer.reset'),
}, '放弃') : null,
]),
]),
h('div', { className: 'sd-timer-foot', key: 'f' }, [
!timer
? h('div', { className: 'sd-row', key: 'p' }, [
h('span', { className: 'sd-mini-cap', key: 'c' }, '时长'),
...presets.map((m) => h('button', {
key: m, className: 'sd-pill' + ((settings.focusMin || 25) === m ? ' on' : ''),
disabled: !effectiveName,
title: effectiveName ? `开始 ${m} 分钟` : '先给这段起个名字',
onClick: () => store.apply('settings.update', { patch: { focusMin: m } }).then(() => start(m)),
}, `${m} 分钟`)),
])
: h('div', { className: 'sd-row', key: 'p' }, [
h('span', { className: 'sd-mini-cap', key: 'c' }, '这一轮第 ' + (timer.round || 1)),
]),
h('div', { className: 'sd-row sd-row-end', key: 'a' }, [
h('span', { className: 'sd-mini-cap', key: 'c' }, '挂到'),
h('select', {
className: 'sd-select',
value: timer && timer.taskId ? timer.taskId : picked,
disabled: !!timer,
onChange: (e) => setPicked(e.target.value),
}, [
h('option', { key: '', value: '' }, '不指定'),
...candidates.map((t) => h('option', { key: t.id, value: t.id }, t.title)),
]),
]),
]),
])
}
// -----------------------------------------------------------------------
// 热力图
// -----------------------------------------------------------------------
function Heatmap({ now }) {
const s = useStore()
const settings = s.state.settings || {}
const weeksCount = Number(settings.heatmapWeeks) || 26
const [range, setRange] = React.useState(weeksCount)
const byDay = React.useMemo(() => {
const map = new Map()
const count = new Map()
for (const x of s.state.sessions) {
if (x.kind !== 'focus') continue
const k = dayKey(x.at)
map.set(k, (map.get(k) || 0) + x.minutes)
count.set(k, (count.get(k) || 0) + 1)
}
return { map, count }
}, [s.state.sessions])
const goal = Number(settings.dailyGoalMin) || 180
const grid = React.useMemo(() => {
const today = startOfDay(now)
const weekday = (new Date(today).getDay() + 6) % 7
const lastMonday = addDays(today, -weekday)
const cols = []
for (let w = range - 1; w >= 0; w -= 1) {
const monday = addDays(lastMonday, -7 * w)
const days = []
for (let d = 0; d < 7; d += 1) {
const ts = addDays(monday, d)
if (ts > today) { days.push(null); continue }
const key = dayKey(ts)
const minutes = byDay.map.get(key) || 0
let level = 0
if (minutes > 0) {
if (minutes < goal * 0.25) level = 1
else if (minutes < goal * 0.5) level = 2
else if (minutes < goal) level = 3
else level = 4
}
days.push({ key, ts, minutes, count: byDay.count.get(key) || 0, level })
}
cols.push({ monday, days })
}
const months = []
let seen = -1
cols.forEach((col, i) => {
const first = col.days.find((d) => d && new Date(d.ts).getDate() === 1)
if (first) {
const m = new Date(first.ts).getMonth()
if (m !== seen) { seen = m; months.push({ i, label: `${m + 1}月` }) }
}
})
let total = 0
let active = 0
for (const col of cols) for (const d of col.days) { if (d) { total += d.minutes; if (d.minutes > 0) active += 1 } }
return { cols, months, total, active }
}, [range, byDay, goal, now])
const template = `22px repeat(${grid.cols.length}, 13px)`
return h('section', { className: 'sd-card-block' }, [
h('div', { className: 'sd-block-head', key: 'h' }, [
h('h2', { className: 'sd-block-title', key: 't' }, '学习热力图'),
h('span', { className: 'sd-block-sub', key: 's' },
`近 ${range} 周 · 共 ${fmtMinutes(grid.total)} · 学习 ${grid.active} 天`),
h('div', { className: 'sd-row sd-row-end', key: 'r' },
[13, 26, 53].map((w) => h('button', {
key: w, className: 'sd-pill sm' + (range === w ? ' on' : ''),
onClick: () => { setRange(w); store.apply('settings.update', { patch: { heatmapWeeks: w } }) },
}, `${w} 周`))),
]),
h('div', { className: 'sd-heat-scroll', key: 'g' },
h('div', { className: 'sd-heat', style: { gridTemplateColumns: template } }, [
h('div', { className: 'sd-heat-corner', key: 'corner', style: { gridColumn: 1, gridRow: 1 } }),
...grid.months.map((m, idx) => h('div', {
key: 'm' + idx,
className: 'sd-heat-month',
style: { gridColumn: m.i + 2, gridRow: 1, gridColumnEnd: `span ${Math.min(4, grid.cols.length - m.i)}` },
}, m.label)),
...WEEKDAYS.map((label, di) => (di % 2 === 0
? h('div', { key: 'w' + di, className: 'sd-heat-day', style: { gridColumn: 1, gridRow: di + 2 } }, label)
: null)),
...grid.cols.flatMap((col, wi) => col.days.map((d, di) => h('div', {
key: `${wi}-${di}`,
className: 'sd-cell' + (d ? ' lv' + d.level + (d.key === dayKey(now) ? ' today' : '') : ' empty'),
style: { gridColumn: wi + 2, gridRow: di + 2 },
title: d ? `${d.key} · 专注 ${d.minutes} 分钟${d.count ? ` · ${d.count} 段` : ''}` : '',
}))),
])),
h('div', { className: 'sd-heat-foot', key: 'f' }, [
h('span', { className: 'sd-heat-note', key: 'n' },
`点亮条件:当天实际复习满 1 分钟;越亮 = 越接近每日 ${fmtMinutes(goal)} 的目标`),
h('span', { className: 'sd-legend', key: 'l' }, [
h('span', { key: 'a' }, '少'),
...[0, 1, 2, 3, 4].map((lv) => h('i', { key: lv, className: 'sd-cell legend lv' + lv })),
h('span', { key: 'b' }, '多'),
]),
]),
])
}
// -----------------------------------------------------------------------
// 单词打卡(不背单词 · 独立面板)
// -----------------------------------------------------------------------
// 月历的排布只在浏览器里做:宿主只发「哪些天打过卡」的日期数组(一次几 KB),
// 日历长什么样随主题与宽度在这里定,宿主不用为画法负责。
function ymOf(ts) {
const d = new Date(ts)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function shiftYm(ym, delta) {
const m = /^(\d{4})-(\d{2})$/.exec(String(ym || ''))
if (!m) return ym
const d = new Date(Number(m[1]), Number(m[2]) - 1 + delta, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
/** 一个月排成「周一开头」的格子,前面空位补 null */
function monthCells(ym, signed, todayKey) {
const m = /^(\d{4})-(\d{2})$/.exec(String(ym || ''))
if (!m) return []
const y = Number(m[1])
const mo = Number(m[2])
const lead = (new Date(y, mo - 1, 1).getDay() + 6) % 7
const days = new Date(y, mo, 0).getDate()
const cells = []
for (let i = 0; i < lead; i += 1) cells.push(null)
for (let d = 1; d <= days; d += 1) {
const key = `${ym}-${String(d).padStart(2, '0')}`
cells.push({
key,
day: d,
on: signed.has(key),
today: key === todayKey,
future: todayKey ? key > todayKey : false,
})
}
while (cells.length % 7 !== 0) cells.push(null)
return cells
}
function BbdcStat({ label, value, unit, tone }) {
return h('div', { className: 'sd-bstat' }, [
h('div', { className: 'sd-bstat-label', key: 'l' }, label),
h('div', { className: 'sd-bstat-value', key: 'v' }, [
h('b', { key: 'n', className: tone ? `sd-bstat-num sd-bstat-${tone}` : 'sd-bstat-num' }, String(value)),
unit ? h('span', { key: 'u', className: 'sd-bstat-unit' }, unit) : null,
]),
])
}
function BbdcPanel({ now }) {
const s = useStore()
usePoll(20000)
const b = s.bbdc
const [ym, setYm] = React.useState(null)
const autoRef = React.useRef(0)
// 采集要拷一份 1.3GB 的虚拟机磁盘,绝不能塞进 20 秒的轮询里。
// 所以这里只做「兜底补采」:缓存超过 15 分钟、且距上次自动尝试满 10 分钟才动一次。
const fetchedAt = b && b.fetchedAt ? b.fetchedAt : 0
React.useEffect(() => {
const stale = !fetchedAt || Date.now() - fetchedAt > 15 * 60 * 1000
if (!stale || s.bbdcBusy) return
if (Date.now() - autoRef.current < 10 * 60 * 1000) return
autoRef.current = Date.now()
s.refreshBbdc()
}, [fetchedAt, s.bbdcBusy])
const head = h('header', { className: 'sd-head', key: 'head' }, [
h('div', { className: 'sd-head-row', key: 'r' }, [
h('div', { className: 'sd-head-main', key: 'm' }, [
h('h1', { className: 'sd-title', key: 't' }, '单词打卡'),
h('p', { className: 'sd-subtitle', key: 's' },
'不背单词 · 从本机 App 里读,不用登录网页端'),
]),
h('div', { className: 'sd-row sd-row-end', key: 'a' }, [
h('button', {
key: 'refresh',
className: 'sd-pill' + (s.bbdcBusy ? ' busy' : ''),
disabled: s.bbdcBusy,
onClick: () => s.refreshBbdc(),
}, s.bbdcBusy ? '采集中…' : '刷新'),
]),
]),
])
if (!b) {
return h('div', { className: 'sd-panel' }, [
h('div', { className: 'sd-scroll', key: 's' },
h('div', { className: 'sd-page', key: 'c' }, [
head,
h('section', { className: 'sd-card-block', key: 'empty' }, [
h('div', { className: 'sd-block-head', key: 'h' },
h('h2', { className: 'sd-block-title', key: 't' }, '还没有采集过')),
h('div', { className: 'sd-empty-block', key: 'e' },
s.bbdcBusy
? '正在读本机的不背单词数据(要拷一份虚拟机磁盘,几秒钟)…'
: '点右上角「刷新」读一次。需要 Androws 里装过、登录过不背单词。'),
]),
])),
])
}
const signed = new Set((b.calendar && b.calendar.signedDates) || [])
const todayKey = dayKey(now)
const effectiveYm = ym || (b.calendar && b.calendar.month) || ymOf(now)
const minYm = (b.calendar && b.calendar.minMonth) || effectiveYm
const maxYm = (b.calendar && b.calendar.maxMonth) || effectiveYm
const cells = monthCells(effectiveYm, signed, todayKey)
const monthSigned = (b.calendar && b.calendar.signedDates || []).filter((d) => d.startsWith(effectiveYm)).length
const cloud = b.cloud || null
const cloudToday = cloud && cloud.today ? cloud.today : null
const todayWords = cloudToday
? (Number(cloudToday.learnNum) || 0) + (Number(cloudToday.reviewNum) || 0)
: ((b.today && b.today.words) || 0)
const todayMin = (b.today && b.today.durationMin) || 0
const totalWords = cloud && cloud.ok && cloud.totalWords !== null && cloud.totalWords !== undefined
? cloud.totalWords : null
const totalMin = (b.total && b.total.durationMin) || 0
const u = b.user || {}
// 最近 7 天的复习次数与时长,按日期把两份列表对齐
const recent = []
if (cloud) {
const dur = new Map((cloud.recentDuration || []).map((x) => [x.date, Number(x.duration) || 0]))
for (const x of cloud.recent || []) {
recent.push({ date: x.date, reviews: Number(x.reviewNum) || 0, minutes: dur.get(x.date) || 0 })
}
}
const maxReviews = recent.reduce((a, x) => Math.max(a, x.reviews), 0)
const sourceLabel = { 'app-local': '本机 App', 'learnywhere-api': '服务端' }
const sourceText = (b.source || []).map((x) => sourceLabel[x] || x).join(' + ')
const whenText = b.fetchedAt ? fmtStamp(b.fetchedAt) : '—'
return h('div', { className: 'sd-panel' }, [
h('div', { className: 'sd-scroll', key: 's' },
h('div', { className: 'sd-page', key: 'c' }, [
head,
!b.ok && b.error
? h('div', { className: 'sd-error', key: 'err' }, '这次没采到:' + b.error)
: null,
cloud && cloud.error
? h('div', { className: 'sd-error', key: 'cerr' }, '服务端数据没拿到:' + cloud.error)
: null,
// 概览:四格,与 App 里「统计」页的排布一致
h('section', { className: 'sd-card-block', key: 'overview' }, [
h('div', { className: 'sd-block-head', key: 'h' }, [
h('h2', { className: 'sd-block-title', key: 't' }, '概览'),
h('span', { className: 'sd-block-sub', key: 's' },
`采集于 ${whenText}${sourceText ? ` · ${sourceText}` : ''}`),
]),
h('div', { className: 'sd-bgrid', key: 'g' }, [
h(BbdcStat, { key: 'tw', label: '今日学习&复习', value: todayWords, unit: '词' }),
h(BbdcStat, { key: 'tm', label: '今日时长', value: todayMin, unit: '分钟' }),
h(BbdcStat, {
key: 'aw',
label: '累计学习',
value: totalWords === null ? '—' : totalWords,
unit: totalWords === null ? null : '词',
tone: 'brand',
}),
h(BbdcStat, { key: 'am', label: '累计时长', value: totalMin, unit: '分钟', tone: 'brand' }),
]),
cloud && cloud.months
? h('div', { className: 'sd-block-sub', key: 'note' },
`累计词数来自 ${cloud.months} 个月的逐月记录相加(接口本身不返回总数);累计复习 ${cloud.totalReviews} 次`)
: null,
]),
// 打卡日历
h('section', { className: 'sd-card-block', key: 'calendar' }, [
h('div', { className: 'sd-block-head', key: 'h' }, [
h('h2', { className: 'sd-block-title', key: 't' }, '打卡日历'),
h('span', { className: 'sd-block-sub', key: 's' },
`本月打卡 ${monthSigned} 天${u.continuousDays ? ` · 连续 ${u.continuousDays} 天` : ''}${u.totalDays ? ` · 累计 ${u.totalDays} 天` : ''}`),
h('div', { className: 'sd-row sd-row-end', key: 'r' }, [
h('button', {
key: 'prev', className: 'sd-pill sm',
disabled: effectiveYm <= minYm,
onClick: () => setYm(shiftYm(effectiveYm, -1)),
}, '‹'),
h('span', { key: 'ym', className: 'sd-bcal-ym' }, effectiveYm),
h('button', {
key: 'next', className: 'sd-pill sm',
disabled: effectiveYm >= maxYm,
onClick: () => setYm(shiftYm(effectiveYm, 1)),
}, '›'),
]),
]),
h('div', { className: 'sd-bcal', key: 'g' }, [
...WEEKDAYS.map((label, i) => h('div', { key: 'h' + i, className: 'sd-bcal-head' }, label)),
...cells.map((c, i) => (c
? h('div', {
key: c.key,
className: 'sd-bcal-cell' + (c.on ? ' on' : '') + (c.today ? ' today' : '') + (c.future ? ' future' : ''),
title: `${c.key}${c.on ? ' · 已打卡' : ' · 未打卡'}`,
}, String(c.day))
: h('div', { key: 'b' + i, className: 'sd-bcal-cell blank' }))),
]),
h('div', { className: 'sd-heat-foot', key: 'f' }, [
h('span', { key: 'n', className: 'sd-heat-note' },
b.checkIn && b.checkIn.lastDate ? `最近一次打卡 ${b.checkIn.lastDate}` : '还没有打卡记录'),
h('span', { key: 'l', className: 'sd-legend' }, [
h('i', { key: 'a', className: 'sd-bcal-cell on legend' }),
h('span', { key: 'b' }, '已打卡'),
h('i', { key: 'c', className: 'sd-bcal-cell legend' }),
h('span', { key: 'd' }, '未打卡'),
]),
]),