大家好!如果你也在为记录睡眠时间而烦恼——每天手动输入、计算时长,还得费力地分析数据——那么这篇文章就是为你量身打造的。我曾经花了数小时踩遍了各种坑,最终搭建出这套“智能”的Obsidian睡眠追踪系统。

现在,我将一步步带你实现它。只需15分钟,你就能享受到“一个按键,搞定睡觉、起床与失眠修正”的极致便利,以及自动生成的精美睡眠数据可视化图表。

为什么选择Obsidian?它免费、开源、支持插件扩展,能将你的笔记库转化为强大的个人数据管理系统。跟随我的指南,你将学会如何真正让工具为你服务。准备好了吗?让我们开始吧!

最终效果预览(效果图在底部)

想象一下这样的场景:

  1. 晚上准备睡觉时:按一个快捷键,系统自动在你的睡眠日记中添加一行记录,如 - [date:: 2025-09-08], [bed:: 23:58], [wake:: ]。它甚至能智能判断凌晨睡觉(例如凌晨3点),并将日期归为前一天。
  2. 失眠或晚睡时:上床后过了1、2个小时才睡着?没关系,再次按下同一个快捷键,系统会自动判断时间差过短,将你的入睡时间静默修正为当前时间。
  3. 早上起床时:再次按下同一个快捷键,系统会自动判断你已睡了足够长的时间,找到昨晚的记录,补全起床时间,并精确计算出睡眠时长,如 - [date:: 2025-09-08], [bed:: 23:58], [wake:: 08:15], [duration:: 08:17]
  4. 数据可视化:在任意笔记中,都能看到自动生成的睡眠时长趋势图、平均入睡/起床时间分布图,以及按月/年统计表格。所有数据实时更新,一目了然。

这不仅仅是记录,更是帮助你优化睡眠、提升生活质量的强大工具。

所需工具

  • Obsidian(免费下载自官网)。
  • 插件(在Obsidian设置 > 社区插件中安装并启用):
    • Templater:核心插件,用于运行我们的“智能脚本”,实现自动化记录。
    • Dataview:用于查询数据并生成图表,支持动态可视化。
    • Charts:图表绘制。
    • Advanced URI:可选。

安装插件后,重启Obsidian以确保生效。

Part 1: 配置“智能睡眠打卡”模板

首先,在Obsidian设置中配置Templater的模板文件夹(例如创建一个名为“Templates”的文件夹)。

智能睡眠脚本 (Smart-Sleep.md)

在你的模板文件夹下创建Smart-Sleep.md文件,粘贴以下完整代码

⚙️ 点击查看/折叠“智能消费录入脚本”代码
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
```javascript
<%*
// --- ⚙️ 配置区 ---
const filePath = "记录/睡眠/睡眠-log.md";
const morningCutoffHour = 5;
const insomniaThresholdHours = 3; // 睡眠时长小于等于 3 小时,都将自动修正入睡时间
// --- 结束配置 ---

// --- 核心功能 ---
const file = tp.file.find_tfile(filePath);
if (!file) {
new Notice(`❌ 错误:找不到文件 "${filePath}"`, 5000);
return;
}
const content = await app.vault.read(file);
const lines = content.trim().split('\n');
const lastLine = lines[lines.length - 1] || "";

if (lastLine.includes('[bed::') && lastLine.includes('[wake:: ]')) {
await handleUnfinishedSleep(lastLine, lines.length - 1);
} else {
await recordBedTime();
}

// --- 函数定义 ---

// 【已优化】处理未完成睡眠的函数
async function handleUnfinishedSleep(line, lineIndex) {
const bedMoment = getBedMoment(line);
if (!bedMoment) return;

const nowMoment = moment();
const durationSinceBed = moment.duration(nowMoment.diff(bedMoment));

// 【核心改动】不再弹出选择框,直接进行判断
if (durationSinceBed.asHours() <= insomniaThresholdHours) {
// --- 场景一:时间太短,自动判定为修正入睡时间 ---
await correctBedTime(line, lineIndex, nowMoment);
} else {
// --- 场景二:时间足够长,自动判定为记录起床 ---
await recordWakeUp(line, lineIndex, bedMoment, nowMoment);
}
}

async function recordBedTime() {
const now = tp.date.now();
const hour = parseInt(tp.date.now("H"));
const dateString = (hour < morningCutoffHour) ? tp.date.now("YYYY-MM-DD", -1) : tp.date.now("YYYY-MM-DD");
const bedTime = tp.date.now("HH:mm");
const newEntry = `\n- [date:: ${dateString}], [bed:: ${bedTime}], [wake:: ]`;

await app.vault.append(file, newEntry);
new Notice(`🛌 已记录上床时间: ${bedTime}`, 3000);
}

async function recordWakeUp(line, lineIndex, bedMoment, wakeMoment) {
const duration = moment.duration(wakeMoment.diff(bedMoment));
const hours = Math.floor(duration.asHours());
const minutes = duration.minutes();

const wakeTimeFormatted = wakeMoment.format('HH:mm');
const durationFormatted = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;

const updatedLine = line.replace('[wake:: ]', `[wake:: ${wakeTimeFormatted}], [duration:: ${durationFormatted}]`);

lines[lineIndex] = updatedLine;
await app.vault.modify(file, lines.join('\n'));
new Notice(`起床成功!🎉\n 睡眠时长: ${hours} 小时 ${minutes} 分钟`, 6000);
}

async function correctBedTime(line, lineIndex, newBedMoment) {
const newBedTime = newBedMoment.format("HH:mm");
const updatedLine = line.replace(/(\[bed:: )(\d{2}:\d{2})(\])/, `$1${newBedTime}$3`);

lines[lineIndex] = updatedLine;
await app.vault.modify(file, lines.join('\n'));
new Notice(`⏰ 已将入睡时间修正为: ${newBedTime}`, 4000);
}

function getBedMoment(line) {
const match = line.match(/\[date:: (.*?)\].*\[bed:: (.*?)\]/);
if (!match || !match[1] || !match[2]) {
new Notice("❌ 错误:无法解析最新的睡眠记录。", 5000);
return null;
}
const dateStr = match[1];
const bedTimeStr = match[2];

const bedHour = parseInt(bedTimeStr.split(':')[0], 10);
const bedDateAnchor = moment(dateStr, "YYYY-MM-DD");

if (bedHour < morningCutoffHour) {
bedDateAnchor.add(1, 'day');
}

return moment(`${bedDateAnchor.format('YYYY-MM-DD')} ${bedTimeStr}`, "YYYY-MM-DD HH:mm");
}
%>
```
> 提示:测试前,先在你的仓库根目录创建一个空的`睡眠日记-2025.md`文件,确保路径正确。

### 模板2:创建你的日志文件 (睡眠日记-2025.md)

这个文件既是你的原始数据存储地,也是一个快速预览和导航的入口。

````markdown
```dataviewjs
// --- 配置区 ---
const displayCount = 5; // 定义显示的行数,可任意修改!
// --- 配置区结束 ---

const currentPage = dv.current();
if (currentPage && currentPage.file.lists.length > 0) {
const recordCount = currentPage.file.lists.length;
dv.paragraph(`🛌 睡眠记录共有 **${recordCount}** 条`);

const clickableHeader = dv.el("h3", "最近记录 ⏬");
clickableHeader.style.cursor = "pointer";
clickableHeader.onclick = () => {
let scrollableContainer = dv.container;
while (scrollableContainer && scrollableContainer.scrollHeight <= scrollableContainer.clientHeight) {
scrollableContainer = scrollableContainer.parentElement;
}
if (scrollableContainer) {
scrollableContainer.scrollTo({ top: scrollableContainer.scrollHeight, behavior: 'smooth' });
}
};

const recentRecords = currentPage.file.lists.slice(-displayCount);
dv.list(recentRecords.map(item => item.text));
} else {
dv.paragraph("❌ 暂无睡眠记录数据。");
}
```

- [date:: 2025-08-01], [bed:: 23:30], [wake:: 09:50], [duration:: 10:20](这是格式示例,你不用手动输入)

重要事项:请确保你的智能睡眠脚本数据可视化报告(见Part 3)顶部的filePath路径,与你这个日志文件的真实路径和文件名完全一致!这是整个系统能运作起来的关键。


Part 2: 配置快捷命令——一键触发所有操作

让这个智能脚本变得易用。我们将为它绑定一个命令和快捷键。

  1. 设置Templater模板文件夹

    • 打开Obsidian设置 > 社区插件 > Templater。
    • 在“Template folder location”中输入你的模板文件夹路径(如“Templates/”)。
  2. 添加快捷命令

    • 在Templater设置中,滚动到“Template Hotkeys”。
    • 点击“Add new”,选择我们创建的Smart-Sleep.md模板。
    • 为它分配一个你喜欢的快捷键(如⌥+S),实现真正的一键操作。
    • 现在,按⌘+P打开命令面板,搜索“Templater: Insert Smart-Sleep”,或直接按你的快捷键,即可运行脚本。

提示:你可以前往“设置”>“命令面板”,使用置顶功能,让你的睡眠命令永远出现在最前面。


Part 3: 数据可视化——用Dataview生成图表

最后一步:让数据“活”起来!在你的主页或任何笔记中插入以下DataviewJS代码块,它会自动生成多种图表和统计。

📊 点击查看/折叠睡眠统计报告代码
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
```dataviewjs
// ======================================================================
// Obsidian 睡眠仪表盘 - v1.0
// ======================================================================
// 作者: [Seitx's Blog](https://setix.xyz/)
// 版本: 1.0
// 描述: 一款能够应对任何真实世界数据格式、具备无懈可击逻辑核心的智能睡眠顾问。
// ======================================================================

const CONFIG = {
// --- 核心配置 ---
filePath: "6-记录/睡眠/睡眠log.md", // [必填] 修改为您的睡眠日志文件路径

diaryPreviewLength: 2000,
// --- 视觉配置 ---
colors: {
bedtime: 'rgb(255, 159, 64)', // 橙色
waketime: 'rgb(75, 192, 192)', // 青色
duration: 'rgb(153, 102, 255)',// 紫色
}
};


// --- 工具函数 ---
function formatMoney(number) { return parseFloat(number.toFixed(2)); } // Kept for legacy
function highlightText(text, query) {
if (!query || typeof text !== 'string') return text;
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(escapedQuery, 'gi');
return text.replace(regex, (match) => `<span class="search-highlight">${match}</span>`);
}

class SleepSanctuary {

constructor(container, config) {
this.container = container;
this.config = config;
this.app = window.app;
this.dv = dv;
this.state = {
allSleepData: null, monthOffset: 0, yearOffset: 0, activeView: 'monthly',
activeMetric: 'bedtime', // 'bedtime', 'waketime', or 'recent'
};
this.elements = {
navContainer: null, monthlyContainer: null, annualContainer: null,
chartContainer: null, listContainer: null, analysisContainer: null,
};
this.modalCleanupStack = [];
}

async init() {
this.container.innerHTML = '';
try {
this.state.allSleepData = await this.parseSleepData();
if (this.state.allSleepData === null) {
this.renderError("❌ **错误:** 无法加载或解析睡眠日志文件。请检查 `filePath` 配置是否正确。");
return;
}
this.addGlobalStyles();
this.renderLayout();
this.switchView(this.state.activeView);
this._setupGlobalKeyListener();
} catch (error) {
console.error("仪表盘初始化失败:", error);
this.renderError(`❌ **脚本发生严重错误:** ${error.message}`);
}
}

_setupGlobalKeyListener() {
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && this.modalCleanupStack.length > 0) {
event.preventDefault();
const cleanupLastModal = this.modalCleanupStack.pop();
if (cleanupLastModal) {
cleanupLastModal();
}
}
});
}

_minutesToHM(minutes) {
const h = Math.floor(minutes / 60);
const m = Math.round(minutes % 60);
if (h > 0 && m > 0) return `${h}h${m}m`;
if (h > 0) return `${h}h`;
return `${m}m`;
}

_averageTime(luxonTimes) {
if (!luxonTimes || luxonTimes.length === 0) return null;
const totalMinutes = luxonTimes.reduce((sum, dt) => {
let minutes = dt.hour * 60 + dt.minute;
if (dt.hour < 4) minutes += 24 * 60;
return sum + minutes;
}, 0);

let avgMinutes = totalMinutes / luxonTimes.length;
if (avgMinutes >= 24 * 60) avgMinutes -= 24 * 60;

const avgHour = Math.floor(avgMinutes / 60);
const avgMinute = Math.round(avgMinutes % 60);
return this.dv.luxon.DateTime.fromObject({ hour: avgHour, minute: avgMinute });
}

_calculateGrowth(current, previous, metricType) {
if (previous === null || current === null) return { text: '--', class: 'neutral' };

let diff = current - previous;
if (metricType === 'bedtime' || metricType === 'waketime') {
if (Math.abs(diff) > 12 * 60) {
diff = diff > 0 ? diff - 24 * 60 : diff + 24 * 60;
}
}

if (Math.abs(diff) < 1) return { text: '→ 无变化', class: 'neutral' };

const sign = diff > 0 ? '+' : '-';
let isPositiveChange;
switch (metricType) {
case 'bedtime':
case 'waketime':
isPositiveChange = diff < 0;
break;
case 'duration':
isPositiveChange = diff > 0;
break;
default:
isPositiveChange = diff > 0;
}

const colorClass = isPositiveChange ? 'positive' : 'negative';

return {
text: `${sign}${this._minutesToHM(Math.abs(diff))}`,
class: colorClass
};
}

renderError(message) {
this.container.innerHTML = `<div style="padding: 20px; background-color: var(--background-secondary, #2a2a2a); border-radius: 8px;">${message}</div>`;
}

renderLayout() {
this.elements.navContainer = this.container.createEl('div', { attr: { style: 'display: flex; justify-content: center; margin-bottom: 20px;' } });
this.createNavButton('周度节律', 'monthly');
this.createNavButton('月度趋势', 'annual');
const cardStyleClass = 'content-card';
this.elements.monthlyContainer = this.container.createEl('div', { cls: cardStyleClass });
this.elements.annualContainer = this.container.createEl('div', { cls: cardStyleClass });
}

createNavButton(text, viewName) {
const button = this.elements.navContainer.createEl('button', { text: text, attr: { 'data-view': viewName, class: 'main-nav-button' } });
button.onclick = () => this.switchView(viewName);
}

switchView(view) {
this.state.activeView = view;
const { monthlyContainer, annualContainer, navContainer } = this.elements;
monthlyContainer.style.display = view === 'monthly' ? 'block' : 'none';
annualContainer.style.display = view === 'annual' ? 'block' : 'none';
navContainer.querySelectorAll('.main-nav-button').forEach(btn => btn.classList.toggle('active', btn.getAttribute('data-view') === view));

if (view === 'monthly') this.renderMonthlyView();
else if (view === 'annual') this.renderAnnualView();
}

renderMonthlyView() {
const container = this.elements.monthlyContainer;
container.innerHTML = '';
const targetMonth = this.dv.luxon.DateTime.now().plus({ months: this.state.monthOffset });

const nav = container.createEl('div', { attr: { style: 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;' } });
const monthSelector = nav.createEl('div', { text: targetMonth.toFormat("yyyy年MM月"), cls: 'control-button' });
monthSelector.onclick = () => this.showMonthPicker(targetMonth);

const navButton = nav.createEl('button', { cls: 'control-button secondary' });
if (this.state.monthOffset === 0) {
navButton.setText('上个月');
navButton.onclick = () => { this.state.monthOffset--; this.renderMonthlyView(); };
} else {
navButton.setText('返回本月');
navButton.onclick = () => { this.state.monthOffset = 0; this.renderMonthlyView(); };
}

const sleepData = this.state.allSleepData.filter(t => t.date.hasSame(targetMonth, 'month'));
const prevMonthData = this.state.allSleepData.filter(t => t.date.hasSame(targetMonth.minus({ months: 1 }), 'month'));

if (sleepData.length === 0) {
container.createEl("p", { text: `✅ 在 ${targetMonth.toFormat("yyyy年MM月")} 没有找到睡眠记录。` });
return;
}

const avgBedtime = this._averageTime(sleepData.map(s => s.bedTime));
const avgWaketime = this._averageTime(sleepData.map(s => s.wakeTime));
const avgDuration = sleepData.reduce((sum, s) => sum + s.durationMinutes, 0) / sleepData.length;

const prevAvgBedtime = this._averageTime(prevMonthData.map(s => s.bedTime));
const prevAvgWaketime = this._averageTime(prevMonthData.map(s => s.wakeTime));
const prevAvgDuration = prevMonthData.length > 0 ? prevMonthData.reduce((sum, s) => sum + s.durationMinutes, 0) / prevMonthData.length : null;

const summaryContainer = container.createEl('div', { cls: 'summary-container-sleep' });
const createSummaryItem = (label, value, comparisonData) => {
const item = summaryContainer.createEl('div');
item.createEl('div', { text: label, cls: 'summary-label' });
item.createEl('div', { text: value, cls: 'summary-value' });
if (comparisonData.prev !== null) {
const growth = this._calculateGrowth(comparisonData.current, comparisonData.prev, comparisonData.type);
item.createEl('div', { text: `环比 ${growth.text}`, cls: `growth-indicator ${growth.class}`});
}
};

createSummaryItem("平均入睡", avgBedtime.toFormat('HH:mm'), { current: avgBedtime.hour * 60 + avgBedtime.minute, prev: prevAvgBedtime ? prevAvgBedtime.hour * 60 + prevAvgBedtime.minute : null, type: 'bedtime' });
createSummaryItem("平均起床", avgWaketime.toFormat('HH:mm'), { current: avgWaketime.hour * 60 + avgWaketime.minute, prev: prevAvgWaketime ? prevAvgWaketime.hour * 60 + prevAvgWaketime.minute : null, type: 'waketime' });
createSummaryItem("平均时长", this._minutesToHM(avgDuration), { current: avgDuration, prev: prevAvgDuration, type: 'duration' });

this.renderToggleSwitch(container);
this.elements.chartContainer = container.createEl('div', { cls: 'chart-wrapper', attr: { style: 'width: 100%; height: 280px; margin: 10px auto;' } });
this._updateMonthlyChart(targetMonth);

container.createEl('hr', { cls: 'divider' });
container.createEl('h3', {text: "本月每日记录", cls: 'section-title'});
const detailsCard = container.createEl('div', { cls: 'details-card' });
this.renderSleepList(detailsCard.createEl('div'), sleepData.sort((a,b) => b.date - a.date));
}

renderAnnualView() {
const container = this.elements.annualContainer;
container.innerHTML = '';
const targetYear = this.dv.luxon.DateTime.now().plus({ years: this.state.yearOffset });

const nav = container.createEl('div', { attr: { style: 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px;' } });
const yearSelector = nav.createEl('div', { text: targetYear.toFormat("yyyy年"), cls: 'control-button' });
yearSelector.onclick = () => this.showYearPicker(targetYear);

const navButton = nav.createEl('button', { cls: 'control-button secondary' });
if (this.state.yearOffset === 0) {
navButton.setText('上一年');
navButton.onclick = () => { this.state.yearOffset--; this.renderAnnualView(); };
} else {
navButton.setText('返回本年');
navButton.onclick = () => { this.state.yearOffset = 0; this.renderAnnualView(); };
}

const sleepData = this.state.allSleepData.filter(t => t.date.hasSame(targetYear, 'year'));
if (sleepData.length === 0) {
container.createEl("p", { text: `✅ 在 ${targetYear.toFormat("yyyy'年'")} 没有找到睡眠记录。` });
return;
}

const prevYearData = this.state.allSleepData.filter(t => t.date.hasSame(targetYear.minus({years: 1}), 'year'));
const avgBedtime = this._averageTime(sleepData.map(s => s.bedTime));
const avgWaketime = this._averageTime(sleepData.map(s => s.wakeTime));
const avgDuration = sleepData.reduce((sum, s) => sum + s.durationMinutes, 0) / sleepData.length;

const prevAvgBedtime = this._averageTime(prevYearData.map(s => s.bedTime));
const prevAvgWaketime = this._averageTime(prevYearData.map(s => s.wakeTime));
const prevAvgDuration = prevYearData.length > 0 ? prevYearData.reduce((sum, s) => sum + s.durationMinutes, 0) / prevYearData.length : null;

const summaryContainer = container.createEl('div', { cls: 'summary-container-sleep' });
const createSummaryItem = (label, value, comparisonData) => {
const item = summaryContainer.createEl('div');
item.createEl('div', { text: label, cls: 'summary-label' });
item.createEl('div', { text: value, cls: 'summary-value' });
if (comparisonData.prev !== null) {
const growth = this._calculateGrowth(comparisonData.current, comparisonData.prev, comparisonData.type);
item.createEl('div', { text: `同比 ${growth.text}`, cls: `growth-indicator ${growth.class}`});
}
};

createSummaryItem("平均入睡", avgBedtime.toFormat('HH:mm'), { current: avgBedtime.hour * 60 + avgBedtime.minute, prev: prevAvgBedtime ? prevAvgBedtime.hour * 60 + prevAvgBedtime.minute : null, type: 'bedtime' });
createSummaryItem("平均起床", avgWaketime.toFormat('HH:mm'), { current: avgWaketime.hour * 60 + avgWaketime.minute, prev: prevAvgWaketime ? prevAvgWaketime.hour * 60 + prevAvgWaketime.minute : null, type: 'waketime' });
createSummaryItem("平均时长", this._minutesToHM(avgDuration), { current: avgDuration, prev: prevAvgDuration, type: 'duration' });

this.renderToggleSwitch(container);

const monthlyBedtimes = Array.from({ length: 12 }, () => []);
const monthlyWaketimes = Array.from({ length: 12 }, () => []);
const monthlyDurations = Array.from({ length: 12 }, () => []);
sleepData.forEach(s => {
monthlyBedtimes[s.date.month - 1].push(s.bedTime);
monthlyWaketimes[s.date.month - 1].push(s.wakeTime);
monthlyDurations[s.date.month - 1].push(s.durationMinutes);
});

const monthlyAvgBedtimes = monthlyBedtimes.map(month => this._averageTime(month));
const monthlyAvgWaketimes = monthlyWaketimes.map(month => this._averageTime(month));
const monthlyAvgDurations = monthlyDurations.map(month => month.length > 0 ? month.reduce((a,b)=>a+b,0) / month.length : null);

this.elements.chartContainer = container.createEl('div', { cls: 'chart-wrapper', attr: { style: 'width: 100%; height: 280px; margin: 10px auto;' } });
this._updateAnnualChart(monthlyAvgBedtimes, monthlyAvgWaketimes, monthlyAvgDurations);

container.createEl('hr', { cls: 'divider' });
container.createEl('h3', { text: '您的年度睡眠故事板', cls: 'section-title analysis-section-title' });
this.renderSleepStoryboard(container.createEl('div'), sleepData);
}

renderSleepStoryboard(container, sleepData) {
if(sleepData.length < 7) {
container.createEl('p', { text: '年度睡眠记录过少,无法生成故事板。', attr: {style: 'text-align: center; color: var(--text-muted);'} });
return;
}

const grid = container.createEl('div', { cls: 'analysis-grid' });

const monthlyBedtimes = Array.from({length: 12}, () => []);
sleepData.forEach(s => monthlyBedtimes[s.date.month - 1].push(s.bedTime));
const monthlyAvgBedtimes = monthlyBedtimes.map(m => this._averageTime(m));

let latestMonthIndex = -1, latestBedtime = -1;
monthlyAvgBedtimes.forEach((bt, i) => {
if(bt) {
let minutes = bt.hour * 60 + bt.minute;
if (bt.hour < 4) minutes += 24 * 60;
if(minutes > latestBedtime) {
latestBedtime = minutes;
latestMonthIndex = i;
}
}
});

if (latestMonthIndex > -1) {
const card1 = grid.createEl('div', { cls: 'analysis-card' });
card1.createEl('h4', {text: '🌙 熬夜冠军月'});
const monthName = this.dv.luxon.DateTime.fromObject({month: latestMonthIndex + 1}).toFormat('MMMM');
card1.createEl('p').innerHTML = `您的睡眠在 <b>${monthName}</b> 最为放纵,平均入睡时间达到了 <b>${monthlyAvgBedtimes[latestMonthIndex].toFormat('HH:mm')}</b>。`;
card1.createEl('p', {cls: 'analysis-insight', text: '是项目冲刺,还是沉迷夜生活?这个月值得您特别回顾。'});
}

const weekdaySleep = [];
const weekendSleep = [];
sleepData.forEach(s => {
if (s.date.weekday >= 1 && s.date.weekday <= 5) weekdaySleep.push(s.durationMinutes);
else weekendSleep.push(s.durationMinutes);
});

if (weekdaySleep.length > 0 && weekendSleep.length > 0) {
const avgWeekday = weekdaySleep.reduce((a,b)=>a+b,0) / weekdaySleep.length;
const avgWeekend = weekendSleep.reduce((a,b)=>a+b,0) / weekendSleep.length;
const debt = avgWeekend - avgWeekday;

const card2 = grid.createEl('div', { cls: 'analysis-card' });
card2.createEl('h4', {text: ' debt 睡眠负债'});
card2.createEl('p').innerHTML = `您平均在周末比工作日多睡 <b>${this._minutesToHM(Math.abs(debt))}</b>。`;
const insightText = debt > 0
? '这说明您可能在用周末“补偿”工作日的睡眠不足。'
: '恭喜!您的作息非常规律,甚至周末睡得更少。';
card2.createEl('p', {cls: 'analysis-insight', text: insightText});
}
}

renderToggleSwitch(container) {
const switchContainer = container.createEl('div', { attr: { class: 'toggle-switch-container-sleep' } });
const recentOption = switchContainer.createEl('div', {text: '近期睡眠', cls: 'toggle-switch-option-sleep'});
const bedtimeOption = switchContainer.createEl('div', { text: '入睡时间', cls: 'toggle-switch-option-sleep' });
const waketimeOption = switchContainer.createEl('div', { text: '起床时间', cls: 'toggle-switch-option-sleep' });

const setActiveState = (metric) => {
recentOption.classList.toggle('active', metric === 'recent');
bedtimeOption.classList.toggle('active', metric === 'bedtime');
waketimeOption.classList.toggle('active', metric === 'waketime');
};

setActiveState(this.state.activeMetric);

const updateChart = () => {
if (this.state.activeView === 'monthly') {
const targetMonth = this.dv.luxon.DateTime.now().plus({ months: this.state.monthOffset });
this._updateMonthlyChart(targetMonth);
} else {
this.renderAnnualView();
}
};

recentOption.onclick = () => {
if (this.state.activeMetric !== 'recent') {
this.state.activeMetric = 'recent';
setActiveState('recent');
updateChart();
}
};
bedtimeOption.onclick = () => {
if (this.state.activeMetric !== 'bedtime') {
this.state.activeMetric = 'bedtime';
setActiveState('bedtime');
updateChart();
}
};
waketimeOption.onclick = () => {
if (this.state.activeMetric !== 'waketime') {
this.state.activeMetric = 'waketime';
setActiveState('waketime');
updateChart();
}
};
}

_createOverlay(className = 'picker-container') {
const overlay = document.body.createEl('div', { cls: 'picker-overlay' });
const container = overlay.createEl('div', { cls: className });
const close = () => { overlay.classList.remove('visible'); setTimeout(() => overlay.remove(), 200); };
setTimeout(() => overlay.classList.add('visible'), 10);
return { overlay, container, close };
}

showMonthPicker(currentDate) {
let pickerYear = currentDate.year;
const update = (title, grid, closeFn) => {
title.textContent = `${pickerYear}年`; grid.className = 'picker-grid'; grid.innerHTML = '';
for (let i = 1; i <= 12; i++) {
const item = grid.createEl('div', { text: `${i}月`, cls: 'picker-grid-item' });
if (pickerYear === currentDate.year && i === currentDate.month) item.classList.add('selected');
item.onclick = () => { const now = this.dv.luxon.DateTime.now(); this.state.monthOffset = (pickerYear - now.year) * 12 + (i - now.month); this.renderMonthlyView(); closeFn(); };
}
};
const { container, close, overlay } = this._createOverlay();
const header = container.createEl('div', { cls: 'picker-header' });
const prevBtn = header.createEl('button', { text: '‹' }); const title = header.createEl('div', { cls: 'picker-title' }); const nextBtn = header.createEl('button', { text: '›' });
const grid = container.createEl('div');
prevBtn.onclick = () => { pickerYear--; update(title, grid, close); }; nextBtn.onclick = () => { pickerYear++; update(title, grid, close); };
update(title, grid, close);

const cleanupAndClose = () => {
if (this.modalCleanupStack[this.modalCleanupStack.length - 1] === cleanupAndClose) { this.modalCleanupStack.pop(); }
close();
};

overlay.onclick = (e) => { if (e.target === overlay) { cleanupAndClose(); } };
this.modalCleanupStack.push(cleanupAndClose);
}

showYearPicker(currentDate) {
let centralYear = currentDate.year;
const update = (title, grid, closeFn) => {
const startYear = centralYear - 5; title.textContent = `${startYear} - ${startYear + 6}`;
grid.className = 'picker-grid year-grid'; grid.innerHTML = '';
for (let i = 0; i < 12; i++) {
const year = startYear + i; const item = grid.createEl('div', { text: year, cls: 'picker-grid-item' });
if (year === currentDate.year) item.classList.add('selected');
item.onclick = () => { this.state.yearOffset = year - this.dv.luxon.DateTime.now().year; this.renderAnnualView(); closeFn(); };
}
};
const { container, close, overlay } = this._createOverlay();
const header = container.createEl('div', { cls: 'picker-header' });
const prevBtn = header.createEl('button', { text: '‹' }); const title = header.createEl('div', { cls: 'picker-title' }); const nextBtn = header.createEl('button', { text: '›' });
const grid = container.createEl('div');
prevBtn.onclick = () => { centralYear -= 12; update(title, grid, close); }; nextBtn.onclick = () => { centralYear += 12; update(title, grid, close); };
update(title, grid, close);

const cleanupAndClose = () => {
if (this.modalCleanupStack[this.modalCleanupStack.length - 1] === cleanupAndClose) { this.modalCleanupStack.pop(); }
close();
};

overlay.onclick = (e) => { if (e.target === overlay) { cleanupAndClose(); } };
this.modalCleanupStack.push(cleanupAndClose);
}

async showFilePreviewModal(files) {
const { container, close, overlay } = this._createOverlay('file-preview-modal');

const contentEl = container.createEl('div', { cls: 'file-preview-content' });

for(const file of files) {
const content = await this.app.vault.cachedRead(file);
let previewText = content.replace(/---[\s\S]*?---/, '').trim();

// [架构升华] 使用 this.config.diaryPreviewLength 替代硬编码的 "100"
const previewLength = this.config.diaryPreviewLength;
previewText = previewText.substring(0, previewLength).replace(/\n/g, ' ') + (previewText.length > previewLength ? '...' : '');

const fileItem = contentEl.createEl('div', { cls: 'file-preview-item' });
fileItem.createEl('div', { text: file.path, cls: 'file-preview-path' });
fileItem.createEl('p', { text: previewText, cls: 'file-preview-summary' });

fileItem.onclick = () => {
this.app.workspace.getLeaf(false).openFile(file);
cleanupAndClose();
};
}

const cleanupAndClose = () => {
if (this.modalCleanupStack[this.modalCleanupStack.length - 1] === cleanupAndClose) { this.modalCleanupStack.pop(); }
close();
};

overlay.onclick = (e) => { if (e.target === overlay) { cleanupAndClose(); } };
this.modalCleanupStack.push(cleanupAndClose);
}

renderSleepList(container, sleepData) {
container.innerHTML = '';
if (sleepData.length === 0) { container.innerHTML = '<p style="text-align:center; color: var(--text-muted); padding: 20px 0;">无记录</p>'; return; }
sleepData.forEach(item => {
const itemEl = container.createEl('div', { cls: 'transaction-item-container', attr: { 'data-date': item.date.toFormat('yy-MM-dd') } });
itemEl.style.cursor = 'pointer';
itemEl.onclick = async () => {
const targetDate = item.date;
const dateStr = targetDate.toFormat('yy-MM-dd');

// 策略一:文件名匹配
const filesByName = this.app.vault.getMarkdownFiles().filter(f => f.name.includes(dateStr));

// 策略二:创建日期匹配
const filesByCreationDate = this.app.vault.getMarkdownFiles().filter(f => {
const fileDate = this.dv.luxon.DateTime.fromMillis(f.stat.ctime);
return fileDate.hasSame(targetDate, 'day');
});

// [关键] 数据融合与去重协议
const combinedFiles = [...filesByName, ...filesByCreationDate];
const uniqueFiles = Array.from(new Set(combinedFiles.map(f => f.path)))
.map(path => combinedFiles.find(f => f.path === path));

if (uniqueFiles.length >= 1) {
this.showFilePreviewModal(uniqueFiles);
}
};

const firstLine = itemEl.createEl('div', { attr: { style: 'display: flex; justify-content: space-between; align-items: center;' } });
firstLine.createEl('span', { text: item.date.toFormat("yyyy年M月d日, cccc"), attr: { style: 'font-size: 0.95em; font-weight: 500;'}});
firstLine.createEl('span', { text: this._minutesToHM(item.durationMinutes), attr: { style: 'font-size: 0.95em; font-family: monospace; color: var(--text-accent);'}});
const secondLine = itemEl.createEl('div', { attr: { style: 'display: flex; justify-content: space-between; align-items: baseline; margin-top: 2px;' } });
secondLine.createEl('span', { text: `${item.bedTime.toFormat('HH:mm')}-${item.wakeTime.toFormat('HH:mm')}`, attr: {style: 'font-size: 0.85em; color: var(--text-muted);'}});
});
}

showFilePickerModal(files) {
const { container, close, overlay } = this._createOverlay('file-picker-modal');
container.createEl('h3', { text: '选择要打开的文件' });
const listEl = container.createEl('ul', { cls: 'file-picker-list' });
files.forEach(file => {
const li = listEl.createEl('li');
li.setText(file.path);
li.onclick = () => {
this.app.workspace.getLeaf(false).openFile(file);
close();
};
});

const cleanupAndClose = () => {
if (this.modalCleanupStack[this.modalCleanupStack.length - 1] === cleanupAndClose) { this.modalCleanupStack.pop(); }
close();
};

overlay.onclick = (e) => { if (e.target === overlay) { cleanupAndClose(); } };
this.modalCleanupStack.push(cleanupAndClose);
}

_updateMonthlyChart(targetMonth) {
this.elements.chartContainer.innerHTML = '';
const chartId = `chart-${Date.now()}`;
this.elements.chartContainer.createEl('div', { attr: { id: chartId, style: 'width: 100%; height: 100%;' }});

const sleepData = this.state.allSleepData.filter(t => t.date.hasSame(targetMonth, 'month'));

if (this.state.activeMetric === 'recent') {
const recentSleep = this.state.allSleepData.sort((a,b) => b.date - a.date).slice(0, 7).reverse();
this.renderSleepChart(chartId, null, null, false, recentSleep);
} else {
const weeksInMonth = Math.ceil(targetMonth.endOf('month').day / 7);
const weeklyBedtimes = Array.from({ length: weeksInMonth }, () => []);
const weeklyWaketimes = Array.from({ length: weeksInMonth }, () => []);
sleepData.forEach(s => {
const weekIndex = Math.floor((s.date.day - 1) / 7);
if(weeklyBedtimes[weekIndex]) {
weeklyBedtimes[weekIndex].push(s.bedTime);
weeklyWaketimes[weekIndex].push(s.wakeTime);
}
});
const weeklyAvgBedtimes = weeklyBedtimes.map(week => this._averageTime(week));
const weeklyAvgWaketimes = weeklyWaketimes.map(week => this._averageTime(week));
this.renderSleepChart(chartId, weeklyAvgBedtimes, weeklyAvgWaketimes, false);
}
}

_updateAnnualChart(monthlyAvgBedtimes, monthlyAvgWaketimes, monthlyAvgDurations){
this.elements.chartContainer.innerHTML = '';
const chartId = `chart-${Date.now()}`;
this.elements.chartContainer.createEl('div', { attr: { id: chartId, style: 'width: 100%; height: 100%;' }});
this.renderSleepChart(chartId, monthlyAvgBedtimes, monthlyAvgWaketimes, true, null, monthlyAvgDurations);
}

renderSleepChart(containerId, bedtimes, waketimes, isAnnual = false, recentData = null, durationData = null) {
setTimeout(() => {
const el = document.getElementById(containerId);
if (!el) return;
if (typeof renderChart === 'undefined') {
el.innerHTML = `<div style="text-align:center; padding: 20px; color: var(--text-muted);">⚠️ **警告:** 未安装或启用 <code>Obsidian Charts</code> 插件,无法渲染图表。</div>`;
return;
}
let chartConfig;
const createGradient = (ctx, chartArea, color) => { if (!chartArea) return null; const g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); g.addColorStop(0, color.replace(')', ', 0.4)').replace('rgb', 'rgba')); g.addColorStop(1, color.replace(')', ', 0)').replace('rgb', 'rgba')); return g; };

if (this.state.activeMetric === 'recent') {
const label = isAnnual ? '月平均睡眠时长' : '近期睡眠时长';
const dataPoints = isAnnual ? durationData : recentData.map(s => s.durationMinutes);

chartConfig = {
type: 'line',
data: {
labels: isAnnual ? ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'] : recentData.map(s => s.date.toFormat('MM-dd')),
datasets: [{
label: label,
data: dataPoints,
borderColor: this.config.colors.duration,
backgroundColor: c => createGradient(c.chart.ctx, c.chart.chartArea, this.config.colors.duration),
fill: true,
tension: 0.4
}]
},
options: {
responsive: true, maintainAspectRatio: false,
interaction: { intersect: false, mode: 'index' },
scales: { y: { ticks: { callback: val => this._minutesToHM(val) } } },
plugins: {
legend: { display: true, labels:{ usePointStyle:true, boxWidth:8 } },
tooltip: { callbacks: { label: ctx => ` ${ctx.dataset.label}: ${this._minutesToHM(ctx.parsed.y)}` } }
}
}
};
} else {
const activeData = this.state.activeMetric === 'bedtime' ? bedtimes : waketimes;
const label = this.state.activeMetric === 'bedtime' ? '平均入睡时间' : '平均起床时间';
const color = this.state.activeMetric === 'bedtime' ? this.config.colors.bedtime : this.config.colors.waketime;

const chartDataPoints = activeData.map(dt => dt ? (dt.hour * 60 + dt.minute + (dt.hour < 4 ? 24*60 : 0)) : null);
const validMinutes = chartDataPoints.filter(m => m !== null);
if(validMinutes.length === 0) return;

const minTime = Math.min(...validMinutes);
const maxTime = Math.max(...validMinutes);
const buffer = 30;
const yMin = Math.max(0, minTime - buffer);
const yMax = maxTime + buffer;

chartConfig = {
type:'line',
data:{
labels: isAnnual ? ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'] : bedtimes.map((_,i) => `第${i+1}周`),
datasets:[{
label: label, data: chartDataPoints, borderColor: color,
backgroundColor: c => createGradient(c.chart.ctx,c.chart.chartArea,color),
fill:true, tension:0.4
}]
},
options:{
responsive:true, maintainAspectRatio:false,
interaction: { intersect: false, mode: 'index' },
scales:{
y: {
min: yMin, max: yMax,
ticks: {
stepSize: 12,
callback: (v) => { let h = Math.floor(v/60)%24, m = v%60; return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; }
}
}
},
plugins:{
legend:{ display: true, labels: { usePointStyle: true, boxWidth: 8 } },
tooltip: {
callbacks: {
label: (c) => { const v = c.parsed.y, h = Math.floor(v/60)%24, m=v%60; return ` ${c.dataset.label}: ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; }
}
}
}
}
};
}
try { renderChart(chartConfig, el); } catch (e) { el.setText(`❌ 图表渲染失败: ${e.message}`); }
}, 50);
}

async parseSleepData() {
const file = this.app.vault.getAbstractFileByPath(this.config.filePath); if (!file) return null;
const content = await this.app.vault.read(file);
const lines = content.split('\n');
const sleepData = [];

for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line.startsWith('- ')) continue;

const dateMatch = line.match(/\[date:: (.*?)\]/);
const bedMatch = line.match(/\[bed:: (.*?)\]/);
const wakeMatch = line.match(/\[wake:: (.*?)\]/);

if (dateMatch && bedMatch && wakeMatch) {
try {
const date = this.dv.luxon.DateTime.fromISO(dateMatch[1].trim());
const bedTimeStr = bedMatch[1].trim();
const wakeTimeStr = wakeMatch[1].trim();

const [bedH, bedM] = bedTimeStr.split(':').map(Number);
const [wakeH, wakeM] = wakeTimeStr.split(':').map(Number);

let bedDateTime = date.set({ hour: bedH, minute: bedM });
let wakeDateTime = date.set({ hour: wakeH, minute: wakeM });

if (wakeDateTime < bedDateTime) {
wakeDateTime = wakeDateTime.plus({ days: 1 });
}

const duration = wakeDateTime.diff(bedDateTime, 'minutes');
const durationMinutes = duration.as('minutes');

sleepData.push({
date: date,
bedTime: bedDateTime,
wakeTime: wakeDateTime,
durationMinutes: durationMinutes,
lineNumber: i,
raw: line
});

} catch(e) {
console.warn(`[睡眠圣殿] 第 ${i+1}行 解析失败: "${line}"`, e);
}
}
}
return sleepData;
}

addGlobalStyles() {
const styleId = 'sleep-sanctuary-global-style-v3.1.0';
document.querySelectorAll('[id^="sleep-sanctuary-global-style"]').forEach(el => el.remove());
if (document.getElementById(styleId)) return;

const style = document.createElement('style');
style.id = styleId;
style.innerHTML = `
.content-card { background-color: var(--background-secondary, #2a2a2a); border-radius: 12px; padding: 25px; border: 1px solid var(--background-modifier-border, #444); }
.divider { border: none; border-top: 1px solid var(--background-modifier-border, #444); margin: 30px 0 20px; }
.section-title { margin-bottom: 15px; font-size: 1.1em; font-weight: 600; }
.main-nav-button { background-color: var(--background-secondary-alt, #3a3a3a); border: 1px solid var(--background-modifier-border, #444); border-radius: 8px; padding: 8px 14px; font-size: 0.9em; cursor: pointer; flex-grow: 1; text-align: center; font-weight: 500; transition: all 0.2s ease; }
.main-nav-button:hover { background-color: var(--background-modifier-hover, #4a4a4a); }
.main-nav-button.active { background-color: var(--interactive-accent, #4e6f9a) !important; color: var(--text-on-accent, white) !important; border-color: var(--interactive-accent-hover, #587db3); }
.control-button { padding: 7px 16px; font-size: 0.9em; font-weight: 500; border-radius: 20px; cursor: pointer; transition: all 0.2s ease; background-color: var(--background-secondary-alt, #3a3a3a); color: var(--text-normal, #ddd); border: 1px solid var(--background-modifier-border, #444); }
.control-button:hover { background-color: var(--background-modifier-hover, #4a4a4a); border-color: var(--background-modifier-border-hover, #555); }
.control-button.secondary { background-color: transparent; color: var(--text-muted, #999); border-color: transparent; }
.control-button.secondary:hover { background-color: var(--background-secondary-alt, #3a3a3a); color: var(--text-normal, #ddd); }
.summary-container-sleep { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; text-align: center; margin-bottom: 25px; padding: 12px 0; background-color: var(--background-secondary-alt, #3a3a3a); border-radius: 8px; }
.summary-label { font-size: 0.85em; color: var(--text-muted, #999); margin-bottom: 4px; }
.summary-value { font-size: 1.35em; font-weight: 600; font-family: monospace; color: var(--text-normal, #ddd); }
.growth-indicator { font-size: 0.8em; margin-top: 4px; font-weight: 500; }
.growth-indicator.positive { color: var(--color-green); }
.growth-indicator.negative { color: var(--color-red); }
.growth-indicator.neutral { color: var(--text-faint); }
.details-card { background-color: var(--background-secondary-alt, #3a3a3a); border-radius: 8px; padding: 5px 15px; }
.transaction-item-container { border-top: 1px solid var(--background-modifier-border, #444); padding: 8px 4px; }
.details-card > div > .transaction-item-container:first-child { border-top: none; }
.picker-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); display: flex; justify-content: center; align-items: center; z-index: 1000; opacity: 0; transition: opacity 0.2s ease; }
.picker-overlay.visible { opacity: 1; }
.picker-container { background-color: var(--background-secondary, #2a2a2a); border: 1px solid var(--background-modifier-border, #444); border-radius: 12px; padding: 15px 20px 25px; width: 90%; max-width: 400px; box-shadow: 0 5px 15px rgba(0,0,0,0.2); transform: scale(0.9); transition: transform 0.2s ease; }
.picker-overlay.visible .picker-container { transform: scale(1); }
.picker-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.picker-header button { font-size: 1.2em; background: none; border: none; cursor: pointer; color: var(--text-normal, #ddd); }
.picker-title { font-size: 1.2em; font-weight: bold; text-align: center; }
.picker-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.picker-grid.year-grid { grid-template-columns: repeat(4, 1fr); }
.picker-grid-item { padding: 12px; text-align: center; border-radius: 8px; background-color: var(--background-secondary-alt, #3a3a3a); cursor: pointer; transition: background-color 0.2s; }
.picker-grid-item:hover { background-color: var(--background-modifier-hover, #4a4a4a); }
.picker-grid-item.selected { background-color: var(--interactive-accent, #4e6f9a); color: var(--text-on-accent, white); font-weight: bold; }
.analysis-section-title { text-align: center; font-size: 1.5em; margin-top: 20px; margin-bottom: 25px; }
.analysis-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
.analysis-card { background-color: var(--background-secondary-alt); border-radius: 10px; padding: 20px; border: 1px solid var(--background-modifier-border); display: flex; flex-direction: column; gap: 10px; }
.analysis-card h4 { margin: 0 0 5px 0; font-size: 1.1em; font-weight: 600; }
.analysis-card ul { padding-inline-start: 20px; margin: 0; flex-grow: 1; }
.analysis-card li { margin-bottom: 5px; }
.analysis-card b { font-weight: 600; color: inherit; }
.analysis-insight { font-size: 0.85em; color: var(--text-muted); margin-top: auto; padding-top: 10px; border-top: 1px solid var(--background-modifier-border); }
.toggle-switch-container-sleep { display: flex; justify-content: center; margin: 20px 0; background-color: var(--background-secondary); padding: 5px; border-radius: 25px; width: fit-content; margin-left: auto; margin-right: auto; }
.toggle-switch-option-sleep { padding: 8px 20px; font-size: 0.9em; font-weight: 500; cursor: pointer; color: var(--text-muted); border-radius: 20px; transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); }
.toggle-switch-option-sleep.active { color: var(--text-normal); background-color: var(--background-modifier-hover); }
.file-picker-list { list-style: none; padding: 0; max-height: 200px; overflow-y: auto; }
.file-picker-list li { padding: 10px; border-radius: 6px; cursor: pointer; }
.file-picker-list li:hover { background-color: var(--background-modifier-hover); }
.file-preview-modal {
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 12px;
padding: 0;
width: 90%;
max-width: 600px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.file-preview-content {
max-height: 400px;
overflow-y: auto;
padding: 10px;
}
.file-preview-item {
padding: 12px 15px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s ease;
border-bottom: 1px solid var(--background-modifier-border);
}
.file-preview-item:last-child {
border-bottom: none;
}
.file-preview-item:hover {
background-color: var(--background-modifier-hover);
}
.file-preview-path {
font-weight: 500;
font-size: 0.9em;
color: var(--text-normal);
}
.file-preview-summary {
font-size: 0.8em;
color: var(--text-muted);
margin-top: 5px;

/* [核心修复] 允许多行文本截断 */
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3; /* 您可以在这里调整默认显示3行,如果需要更多,可以增大此数字 */
line-height: 1.5; /* 确保行间距舒适 */
}
`;
document.head.appendChild(style);
}

}

try {
const dashboard = new SleepSanctuary(dv.container, CONFIG);
dashboard.init();
} catch (e) {
dv.container.setText("❌ Dataview 脚本执行失败: " + e.message);
}
```

提示:代码会从CDN加载Chart.js,确保你的Obsidian有网络权限。如果图表不显示,检查文件路径和数据格式。

结语

恭喜!你现在拥有了一套完整的Obsidian睡眠自动化系统。从手动记录的烦恼,到一键操作和智能图表的便利,这不仅仅是工具,更是生活优化的一部分。我的6小时调试经历,就是为了让你避开所有坑,直接上手。如果你遇到问题,欢迎在评论区交流——或许我们能一起完善它。

作为新手博主,我希望这篇文章能帮助更多人。如果你喜欢,分享给朋友吧!未来,我计划录制视频教程,进一步传播这个idea。

✨​温馨提示​✨

以上所有代码均为纯粹的本地化脚本,所有的数据读取、处理和计算都在你自己的设备上完成。
​最关键的一点:它不会将你的任何数据上传到任何服务器!​​你的所有数据,永远只属于你。

如果你遇到了程序错误,或者灵光一现有了超棒的想法,随时欢迎告诉我!

📧 邮件:Socrates.02zx@Gmail.com

感谢阅读,下次见!:)

sleep-log仪表盘效果图(示例)

dashboard-demo.png
dashboard-demo.png


本站由 Setix 使用 Stellar 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
本站总访问量