我花了大量时间探索和迭代,最终在 Obsidian 中搭建了一套个人消费追踪系统

现在,我将毫无保留地分享给你。只需跟着指南操作,你就能拥有一个“一键录入、自动合并、智能排序”的录入模板,以及一个“多维度、可交互”的全功能消费仪表盘。

为什么是 Obsidian?因为它将数据所有权 100% 交还给你,同时通过强大的插件生态,让你的本地笔记库化身为无所不能的个人数据中心。

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

想象一下你理想中的记账流程:

  1. 一键快速录入:无论何时何地消费,只需按下快捷键,输入金额(甚至支持 30+15 这样的快捷计算),选择分类,即可完成记录。整个过程不超过10秒。
  2. 全功能消费仪表盘:在一个专属页面,你可以清晰地看到:
    • 月度/年度可切换视图:轻松回顾上个月或去年的消费状况。
    • 动态图表:消费分类百分比、月度消费趋势折线图,一目了然。
    • 深度数据洞察:自动计算月均消费、消费最高月份、支出冠军类别等关键指标。

这套系统不仅为你省时,更能将枯燥的数据转化为帮你优化消费、实现理财目标的强大动力。

提示:您可以利用 iOS 的快捷指令(Shortcuts)结合 Obsidian 的 Advanced URI 插件,一键调用记账模板,快速完成记账。如果您对 Advanced URI 的用法不熟悉,可以在各大平台搜索相关教程,或参考此视频。(请注意:该视频非本人制作,仅作示例)。

所需工具

  • Obsidian(免费下载自官网)。
  • 插件(在Obsidian设置 > 社区插件中安装并启用):
    • Templater:自动化脚本的核心,负责处理所有的数据录入和文件修改逻辑。
    • Dataview:数据查询与可视化。
    • Charts:图表绘制。
    • Advanced URI:可选。

请确保安装并启用上述插件后,重启 Obsidian。


Part 1: 构建数据核心 (The Core Data System)

我们的系统由三个部分组成:一个智能录入模板,一个纯文本日志文件,以及一个数据仪表盘。首先,我们来创建前两个。

1.1 智能录入模板 (消费记录.md)

在你的 Templater 模板文件夹中(例如 “Templates”),创建一个名为 记账.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
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
<%*
// --- ⚙️ 配置区 ---
const filePath = "记录/消费/消费-log.md";
const dateFormat = "YY-MM-DD";
const expenseCategories = ["餐饮", "交通", "购物", "娱乐", "学习", "生活杂费", "医疗健康"];
const incomeCategories = ["工资", "奖金", "兼职", "投资回报"]; // 新增收入分类
// --- 结束配置 ---

// --- 核心函数区 ---
function safeCalculate(expression) {
try {
const sanitized = String(expression).trim();
if (!/^[0-9\.\+\-\s\*\/\(\)]+$/.test(sanitized)) return NaN;
return new Function('return ' + sanitized)();
} catch (error) {
return NaN;
}
}

// 智能金额解析(支持正负号)
async function promptForValidAmount(promptMessage, placeholder = "") {
let amountInput = await tp.system.prompt(promptMessage, placeholder, true);
if (!amountInput) return null;

// 符号智能处理
const hasExplicitSign = /^[+-]/.test(amountInput);
let amount = safeCalculate(amountInput);

// 未明确符号时:正数视为支出(负),负数视为收入(正)
if (!hasExplicitSign && !isNaN(amount)) {
amount = amount > 0 ? -amount : Math.abs(amount);
}

while (isNaN(amount) || amount === 0) {
const msg = isNaN(amount)
? "❌ 请输入有效的数字金额"
: "❌ 金额不能为零";
new Notice(msg, 4000);
amountInput = await tp.system.prompt(promptMessage, placeholder, true);
if (!amountInput) return null;
amount = safeCalculate(amountInput);

if (!hasExplicitSign && !isNaN(amount)) {
amount = amount > 0 ? -amount : Math.abs(amount);
}
}
return amount;
}

// 动态分类选择(根据金额正负)
async function getCategoryAndRemark(amount) {
const isIncome = amount > 0;
const categories = isIncome ? incomeCategories : expenseCategories;
const amountType = isIncome ? "收入" : "支出";

const category = await tp.system.suggester(
categories,
categories,
false,
${Math.abs(amount)} ${amountType}选择类别`
);
if (!category) return null;

const remark = await tp.system.prompt("备注 (可选)", "", false) || "";
return { category, remark };
}

// --- 主逻辑开始 ---
let transactions = [];
let targetDateString = tp.date.now(dateFormat);
const nowTimeString = tp.date.now("HH:mm"); // 当前时间

const modeInput = await tp.system.prompt("💰 输入金额 (m ▸ 多条, b ▸ 补录)", "", true);
if (!modeInput) return;

const mode = modeInput.trim().toLowerCase();

// --- 模式一:闪电记账 (m) ---
if (mode === 'm') {
new Notice("⚡️ 已进入闪电模式", 2000);
let entryCount = 1;
while (true) {
const amount = await promptForValidAmount(`第 ${entryCount} 笔: 金额 (留空完成)`);
if (amount === null) break;

const details = await getCategoryAndRemark(amount);
if (!details) continue;

// 每条记录获取实时时间
const entryTime = tp.date.now("HH:mm");
transactions.push({
...details,
amount,
date: tp.date.now(dateFormat),
time: entryTime
});
new Notice(`👍 已添加第 ${entryCount} 笔`, 2000);
entryCount++;
}
}
// --- 模式二:补录旧账 (b) ---
else if (mode === 'b') {
const recentDays = [
{ text: `今天 (${tp.date.now(dateFormat)})`, value: tp.date.now(dateFormat) },
{ text: `昨天 (${tp.date.now(dateFormat, -1)})`, value: tp.date.now(dateFormat, -1) }
];
for (let i = 2; i <= 7; i++) {
recentDays.push({ text: `${i}天前 (${tp.date.now(dateFormat, -i)})`, value: tp.date.now(dateFormat, -i) });
}
recentDays.push({ text: "手动指定日期...", value: "manual" });

const selectedDateOption = await tp.system.suggester(
recentDays.map(d => d.text),
recentDays.map(d => d.value),
false,
"请选择补录日期"
);
if (!selectedDateOption) return;

if (selectedDateOption === 'manual') {
const manualDate = await tp.system.prompt(`请输入日期 (格式: ${dateFormat})`, tp.date.now(dateFormat));
if (!manualDate || !/^\d{2}-\d{2}-\d{2}$/.test(manualDate.trim())) {
new Notice("❌ 日期格式错误", 4000);
return;
}
targetDateString = manualDate.trim();
} else {
targetDateString = selectedDateOption;
}

const amount = await promptForValidAmount(`为 [${targetDateString}] 输入金额`);
if (amount !== null) {
const details = await getCategoryAndRemark(amount);
if (details) {
transactions.push({
...details,
amount,
date: targetDateString,
time: nowTimeString
});
}
}
}
// --- 模式三:快速单条 (默认) ---
else {
let amount = safeCalculate(modeInput);
if (isNaN(amount) || amount === 0) {
amount = await promptForValidAmount(`❌ "${modeInput}" 不是有效指令,请重新输入金额`);
}

if (amount !== null) {
// 符号智能处理(无符号时正数转负)
if (!/^[+-]/.test(modeInput) && amount > 0) amount = -amount;

const details = await getCategoryAndRemark(amount);
if (details) {
transactions.push({
...details,
amount,
date: targetDateString,
time: nowTimeString
});
}
}
}

// --- 文件处理 (全新逻辑) ---
if (transactions.length === 0) {
new Notice("操作已取消", 2000);
return;
}

const file = tp.file.find_tfile(filePath);
if (!file) {
new Notice(`❌ 找不到文件: "${filePath}"`, 5000);
return;
}

// 读取现有内容
let content = await app.vault.read(file);
let existingLines = content.split('\n').filter(line =>
line.trim() && line.startsWith('- [')
);

// 转换新交易记录为文本行
const newLines = transactions.map(trans => {
const sign = trans.amount > 0 ? '+' : '';
return `- [${trans.date}][${trans.category}:${trans.remark}|${sign}${trans.amount}][${trans.time}]`;
});

// 合并新旧记录并排序(最新在最前)
const allLines = [...existingLines, ...newLines];
allLines.sort((a, b) => {
// 提取日期和时间
const extractDT = line => {
const [_, date, time] = line.match(/\[(\d{2}-\d{2}-\d{2})\].*?\[(\d{2}:\d{2})\]/);
return { date, time };
};
const dtA = extractDT(a);
const dtB = extractDT(b);

// 先按日期倒序,同日期按时间倒序
return dtB.date.localeCompare(dtA.date) || dtB.time.localeCompare(dtA.time);
});

// 写入文件
await app.vault.modify(file, allLines.join('\n'));
new Notice(`✅ ${transactions.length}笔交易已记录`, 3000);
%>

1.2 消费日志文件 (消费-log.md)

这是你的“数据库”。在你希望存放日志的位置(例如 记录/消费/ 文件夹下),创建一个名为 消费-log.md 的文件。

[!IMPORTANT] 关键一致性原则
请确保智能录入脚本消费仪表盘(见Part 3)顶部的 filePath 路径,与你这个日志文件的真实路径和文件名完全一致!这是整个系统能协同工作的基石。


Part 2: 配置“一键录入”命令

让魔法发生得更简单。

  1. 设置 Templater 模板文件夹

    • 打开 Obsidian 设置 > 社区插件 > Templater。
    • 在“Template folder location”中指定你的模板文件夹路径(如 Templates)。
  2. 绑定快捷键

    • 在 Templater 设置中,找到“Template Hotkeys”部分。
    • 点击“Add new for template”,选择我们刚刚创建的 记账.md 模板。
    • 为它分配一个你顺手的快捷键(例如 ⌥+m),实现真正的一键触发。

现在,试试按下你的快捷键,体验丝滑的录入流程吧!


Part 3: 打造你的专属“消费仪表盘”

最后一步,让沉睡的数据“开口说话”。在你希望展示仪表盘的任何地方(例如你的主页 Homepage.md),创建一个新的笔记(例如 消费仪表盘.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
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
```dataviewjs
// ======================================================================
// Obsidian 消费仪表盘 - v1.0
// ======================================================================
// 作者: [Seitx's Blog](https://setix.xyz/)
// 版本: 1.0
// 描述: 一款注入了“少即是多”设计哲学、无缝交互体验与商业级健壮性的智能财务顾问。
// ======================================================================

const CONFIG = {
// --- 核心配置 ---
filePath: "记录/消费/消费-log.md", // [必填] 修改为您记账日志文件的完整路径
currencySymbol: "¥", // [可选] 货币符号, 可改为 "$" "€" 等

// --- 分类配置 (可按需增删) ---
expenseCategories: [
"餐饮", "学习", "交通", "购物", "娱乐",
"生活杂费", "医疗健康", "未分类"
],
incomeCategories: [
"工资", "奖金", "兼职", "投资回报"
],

// --- 外观配置 (请与上面的分类名保持一致) ---
categoryColors: {
"餐饮": "#A78BFA",
"学习": "#5EEAD4",
"交通": "#FDE047",
"购物": "#F472B6",
"娱乐": "#60A5FA",
"生活杂费": "#FDBA74",
"医疗健康": "#BDBDBD",
"工资": "#34D399",
"奖金": "#A3E635",
"兼职": "#FBBF24",
"投资回报": "#60A5FA",
"未分类": "#E5E7EB"
}
};


// --- 工具函数 ---
function formatMoney(number) { return parseFloat(number.toFixed(2)); }
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 FinanceDashboard {

constructor(container, config) {
this.container = container;
this.config = config;
this.app = window.app;
this.dv = dv;
this.state = {
allTransactions: null, monthOffset: 0, yearOffset: 0, activeView: 'monthly',
searchQuery: '', searchSort: 'time_desc', showIncome: false, openModalInfo: null,
currentViewTransactions: [],
};
this.elements = {
navContainer: null, monthlyContainer: null, annualContainer: null, searchContainer: null,
listContainer: null, analysisContainer: null,
};
this.modalCleanupStack = [];
}

async init() {
this.container.innerHTML = '';
try {
this.state.allTransactions = await this.parseTransactionData();
if (this.state.allTransactions === 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();
}
}
});
}

_calculateGrowth(current, previous, metricType = 'income') {
if (previous === 0) {
return { text: current > 0 ? `+${this.config.currencySymbol}${formatMoney(current)}` : '-', class: 'neutral' };
}
const percentage = ((current - previous) / previous) * 100;
if (Math.abs(percentage) < 0.1) {
return { text: '→ 0.0%', class: 'neutral' };
}

const sign = percentage > 0 ? '+' : '';
let colorClass = percentage > 0 ? 'positive' : 'negative';
if (metricType === 'expense') {
colorClass = percentage > 0 ? 'negative' : 'positive';
}

return {
text: `${sign}${percentage.toFixed(1)}%`,
class: colorClass
};
}

_updateDynamicContent() {
if (this.state.activeView === 'monthly') {
this._updateMonthlyDynamicContent();
} else if (this.state.activeView === 'annual') {
this._updateAnnualDynamicContent();
}
}

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; gap: 8px; margin-bottom: 20px;' } });
this.createNavButton('月度视图', 'monthly');
this.createNavButton('年度视图', 'annual');
this.createNavButton('全局搜索', 'search');
const cardStyleClass = 'content-card';
this.elements.monthlyContainer = this.container.createEl('div', { cls: cardStyleClass });
this.elements.annualContainer = this.container.createEl('div', { cls: cardStyleClass });
this.elements.searchContainer = 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 = () => {
if (this.state.activeView !== viewName) {
this.state.monthOffset = 0;
this.state.yearOffset = 0;
}
this.switchView(viewName);
};
}

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

switch (view) {
case 'monthly': this.renderMonthlyView(); break;
case 'annual': this.renderAnnualView(); break;
case 'search': this.renderSearchView(); break;
}
}

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(); };
}

this.state.currentViewTransactions = this.state.allTransactions.filter(t => t.date.hasSame(targetMonth, 'month'));
const prevMonthTransactions = this.state.allTransactions.filter(t => t.date.hasSame(targetMonth.minus({ months: 1 }), 'month'));

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

const processedData = this.state.currentViewTransactions.reduce((acc, t) => {
if (t.isIncome) { acc.incomes.push(t); acc.totalIncome += t.amount; }
else { acc.expenses.push(t); acc.totalExpense += t.amount; }
return acc;
}, { expenses: [], incomes: [], totalExpense: 0, totalIncome: 0 });

const currentTotalExpense = Math.abs(processedData.totalExpense);
const currentTotalIncome = processedData.totalIncome;
const prevMonthTotalExpense = Math.abs(prevMonthTransactions.filter(t => !t.isIncome).reduce((sum, t) => sum + t.amount, 0));
const prevMonthTotalIncome = prevMonthTransactions.filter(t => t.isIncome).reduce((sum, t) => sum + t.amount, 0);

const summaryContainer = container.createEl('div', { cls: 'summary-container' });
const expenseCell = summaryContainer.createEl('div');
expenseCell.innerHTML = `<div class="summary-label">本月支出</div><div class="summary-value">${this.config.currencySymbol}${formatMoney(currentTotalExpense)}</div>`;
if (prevMonthTransactions.length > 0) {
const growth = this._calculateGrowth(currentTotalExpense, prevMonthTotalExpense, 'expense');
expenseCell.createEl('div', { text: `环比 ${growth.text}`, cls: `growth-indicator ${growth.class}` });
}

const incomeCell = summaryContainer.createEl('div');
incomeCell.innerHTML = `<div class="summary-label">本月收入</div><div class="summary-value income">${this.config.currencySymbol}${formatMoney(currentTotalIncome)}</div>`;
if (prevMonthTransactions.length > 0) {
const growth = this._calculateGrowth(currentTotalIncome, prevMonthTotalIncome, 'income');
incomeCell.createEl('div', { text: `环比 ${growth.text}`, cls: `growth-indicator ${growth.class}` });
}

const weeksInMonth = Math.ceil(targetMonth.endOf('month').day / 7);
const weeklyData = Array.from({ length: weeksInMonth }, () => ({ expense: 0, income: 0 }));

this.state.currentViewTransactions.forEach(t => {
const weekIndex = Math.floor((t.date.day - 1) / 7);
if (weeklyData[weekIndex]) {
t.isIncome ? weeklyData[weekIndex].income += t.amount : weeklyData[weekIndex].expense += Math.abs(t.amount);
}
});

const weeklyLabels = weeklyData.map((_, index) => `第 ${index + 1} 周`);
const weeklyExpenses = weeklyData.map(w => w.expense);
const weeklyIncomes = weeklyData.map(w => w.income);

const chartContainerId = `monthly-trend-chart-${Date.now()}`;
container.createEl('div', { attr: { id: chartContainerId, style: 'width: 100%; height: 280px; margin: 10px auto;' } });
this.renderTrendChart(chartContainerId, weeklyLabels, weeklyExpenses, weeklyIncomes);
this.renderToggleSwitch(container, 'monthly');

this.elements.listContainer = container.createEl('div');
this._updateMonthlyDynamicContent();

container.createEl('hr', { cls: 'divider' });
container.createEl('h3', {text: "本月明细", cls: 'section-title'});
const detailsCard = container.createEl('div', { cls: 'details-card' });
this.state.currentViewTransactions.sort((a, b) => b.date.toMillis() - a.date.toMillis() || b.time.localeCompare(a.time));
this.renderTransactionList(detailsCard.createEl('div'), this.state.currentViewTransactions);
}

_updateMonthlyDynamicContent() {
this.elements.listContainer.innerHTML = '';
const transactions = this.state.currentViewTransactions;
const expenses = transactions.filter(t => !t.isIncome);
const incomes = transactions.filter(t => t.isIncome);
const totalExpense = expenses.reduce((sum, t) => sum + Math.abs(t.amount), 0);
const totalIncome = incomes.reduce((sum, t) => sum + t.amount, 0);
this.state.showIncome ? this.renderCategoryList(this.elements.listContainer, incomes, totalIncome) : this.renderCategoryList(this.elements.listContainer, expenses, totalExpense);
}

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(); };
}

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

const monthlyExpenses = Array(12).fill(0);
const monthlyIncomes = Array(12).fill(0);
this.state.currentViewTransactions.forEach(t => {
if (t.isIncome) {
monthlyIncomes[t.date.month-1] += t.amount;
} else {
monthlyExpenses[t.date.month-1] += Math.abs(t.amount);
}
});

this.renderAnnualSummary(container, monthlyExpenses, monthlyIncomes, targetYear);
const chartContainerId = `annual-trend-chart-${Date.now()}`;
container.createEl('div', { attr: { id: chartContainerId, style: 'width: 100%; height: 280px; margin: 10px auto;' } });
const monthLabels = ['1','2','3','4','5','6','7','8','9','10','11','12'];
this.renderTrendChart(chartContainerId, monthLabels, monthlyExpenses, monthlyIncomes);
this.renderToggleSwitch(container, 'annual');

this.elements.listContainer = container.createEl('div');
container.createEl('hr', { cls: 'divider' });
container.createEl('h3', { text: '您的年度故事板', cls: 'section-title analysis-section-title' });
this.elements.analysisContainer = container.createEl('div', { cls: 'annual-analysis-container' });

this._updateAnnualDynamicContent();
}

_updateAnnualDynamicContent() {
this.elements.listContainer.innerHTML = '';
this.elements.analysisContainer.innerHTML = '';

const transactions = this.state.currentViewTransactions;
const expenses = transactions.filter(t => !t.isIncome);
const incomes = transactions.filter(t => t.isIncome);
const totalExpenseForList = expenses.reduce((sum, t) => sum + Math.abs(t.amount), 0);
const totalIncomeForList = incomes.reduce((sum, t) => sum + t.amount, 0);

if (this.state.showIncome) {
this.renderCategoryList(this.elements.listContainer, incomes, totalIncomeForList);
this.renderIncomeAnalysis(this.elements.analysisContainer, transactions);
} else {
this.renderCategoryList(this.elements.listContainer, expenses, totalExpenseForList);
this.renderSpendingAnalysis(this.elements.analysisContainer, transactions);
}
}

renderSpendingAnalysis(container, transactions) {
const expenses = transactions.filter(t => !t.isIncome);
if (expenses.length < 1) {
container.createEl('p', { text: '本年度无支出记录,无法生成分析报告。', attr: { style: 'text-align: center; color: var(--text-muted);' }});
return;
}

const categoryData = {};
const timeSlotData = {
"凌晨 (0-5点)": { total: 0, count: 0 }, "清晨 (5-9点)": { total: 0, count: 0 },
"上午 (9-12点)": { total: 0, count: 0 }, "中午 (12-14点)": { total: 0, count: 0 },
"下午 (14-18点)": { total: 0, count: 0 }, "晚上 (18-24点)": { total: 0, count: 0 }
};
let largestPurchase = null;
const lifestyleData = { weekday: 0, weekend: 0, total: 0 };

for (const expense of expenses) {
const amount = Math.abs(expense.amount);
lifestyleData.total += amount;

if (!categoryData[expense.category]) categoryData[expense.category] = { total: 0, count: 0 };
categoryData[expense.category].total += amount;
categoryData[expense.category].count += 1;

const hour = expense.date.hour;
if (hour < 5) { timeSlotData["凌晨 (0-5点)"].total += amount; timeSlotData["凌晨 (0-5点)"].count++; }
else if (hour < 9) { timeSlotData["清晨 (5-9点)"].total += amount; timeSlotData["清晨 (5-9点)"].count++; }
else if (hour < 12) { timeSlotData["上午 (9-12点)"].total += amount; timeSlotData["上午 (9-12点)"].count++; }
else if (hour < 14) { timeSlotData["中午 (12-14点)"].total += amount; timeSlotData["中午 (12-14点)"].count++; }
else if (hour < 18) { timeSlotData["下午 (14-18点)"].total += amount; timeSlotData["下午 (14-18点)"].count++; }
else { timeSlotData["晚上 (18-24点)"].total += amount; timeSlotData["晚上 (18-24点)"].count++; }

if (!largestPurchase || amount > Math.abs(largestPurchase.amount)) {
largestPurchase = expense;
}

const dayOfWeek = expense.date.weekday;
if (dayOfWeek >= 1 && dayOfWeek <= 5) {
lifestyleData.weekday += amount;
} else {
lifestyleData.weekend += amount;
}
}

const sortedTimeSlots = Object.entries(timeSlotData).filter(([,d])=>d.total > 0).sort(([,a],[,b]) => b.total - a.total);
const topByCount = Object.entries(categoryData).sort(([,a],[,b]) => b.count - a.count).slice(0, 3);

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

if (sortedTimeSlots.length > 0) {
const card = grid.createEl('div', { cls: 'analysis-card' });
card.createEl('h4', { text: '🕒 消费节律', cls: 'gradient-title-expense' });
const list = card.createEl('ul');
sortedTimeSlots.forEach(([name, data]) => {
const percentage = lifestyleData.total > 0 ? ((data.total / lifestyleData.total) * 100).toFixed(1) : 0;
list.createEl('li').innerHTML = `${name}: <b>${this.config.currencySymbol}${formatMoney(data.total)}</b> (${percentage}%),共 ${data.count} 次`;
});
card.createEl('p', { cls: 'analysis-insight', text: `“${sortedTimeSlots[0][0]}”是您的消费高峰时段,或许可以关注下此时段的非必要开支。` });
}

if (largestPurchase) {
const card = grid.createEl('div', { cls: 'analysis-card' });
card.createEl('h4', { text: '💸 最大单笔', cls: 'gradient-title-expense' });
card.createEl('p').innerHTML = `您在 <b>${largestPurchase.date.toFormat("M月d日")}</b>,因 “${largestPurchase.note || '未备注'}” 在 <b>${largestPurchase.category}</b> 分类下有一笔金额为 <b>${this.config.currencySymbol}${formatMoney(Math.abs(largestPurchase.amount))}</b> 的大额支出。`;
card.createEl('p', { cls: 'analysis-insight', text: '这笔钱花得值吗?它定义了您今年的消费上限。' });
}

const card3 = grid.createEl('div', { cls: 'analysis-card' });
card3.createEl('h4', { text: '🛒 消费活跃度', cls: 'gradient-title-expense' });
const list3 = card3.createEl('ul');
topByCount.forEach(([name, data]) => {
list3.createEl('li').innerHTML = `<b>${name}</b>: 共 ${data.count} 次`;
});
card3.createEl('p', { cls: 'analysis-insight', text: `“${topByCount[0][0]}”是您最频繁的消费习惯,高达 ${topByCount[0][1].count} 次。` });

const card4 = grid.createEl('div', { cls: 'analysis-card' });
card4.createEl('h4', { text: '📅 生活方式分解', cls: 'gradient-title-expense' });
const weekdayPct = lifestyleData.total > 0 ? (lifestyleData.weekday / lifestyleData.total * 100).toFixed(1) : 0;
const weekendPct = lifestyleData.total > 0 ? (lifestyleData.weekend / lifestyleData.total * 100).toFixed(1) : 0;
card4.createEl('p').innerHTML = `工作日: <b>${this.config.currencySymbol}${formatMoney(lifestyleData.weekday)}</b> (${weekdayPct}%)`;
card4.createEl('p').innerHTML = `周末: <b>${this.config.currencySymbol}${formatMoney(lifestyleData.weekend)}</b> (${weekendPct}%)`;
const barContainer = card4.createEl('div', { cls: 'lifestyle-bar-container' });
barContainer.createEl('div', { cls: 'lifestyle-bar-fill', attr: { style: `width: ${weekdayPct}%;` } });
const dominantPeriod = lifestyleData.weekend > lifestyleData.weekday ? '周末' : '工作日';
const dominantPercent = lifestyleData.total > 0 ? (Math.max(lifestyleData.weekday, lifestyleData.weekend) / lifestyleData.total * 100).toFixed(0) : 0;
card4.createEl('p', { cls: 'analysis-insight', text: `您倾向于在${dominantPeriod}花费更多,这占了您总支出的${dominantPercent}%。` });
}

renderIncomeAnalysis(container, transactions) {
const incomes = transactions.filter(t => t.isIncome);
if (incomes.length < 1) {
container.createEl('p', { text: '本年度无收入记录,无法生成分析报告。', attr: { style: 'text-align: center; color: var(--text-muted);' }});
return;
}

let totalIncome = 0;
let largestIncome = null;
const incomeByMonth = {};
const incomeByDay = {};

for (const income of incomes) {
const amount = income.amount;
totalIncome += amount;

if (!largestIncome || amount > largestIncome.amount) {
largestIncome = income;
}

const month = income.date.month;
if (!incomeByMonth[month]) incomeByMonth[month] = 0;
incomeByMonth[month] += amount;

const dayOfWeek = income.date.weekday;
if (!incomeByDay[dayOfWeek]) incomeByDay[dayOfWeek] = 0;
incomeByDay[dayOfWeek] += amount;
}

const peakMonth = Object.entries(incomeByMonth).sort(([,a],[,b]) => b - a)[0];
const weekdayMap = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const topIncomeDays = Object.entries(incomeByDay).sort(([,a],[,b]) => b - a).slice(0, 3);

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

if (largestIncome) {
const card2 = grid.createEl('div', { cls: 'analysis-card' });
card2.createEl('h4', { text: '✨ 最大单笔收入', cls: 'gradient-title-income' });
card2.createEl('p').innerHTML = `今年最亮眼的时刻是 <b>${largestIncome.date.toFormat("M月d日")}</b>,一笔来自 <b>${largestIncome.category}</b>、金额为 <b>${this.config.currencySymbol}${formatMoney(largestIncome.amount)}</b> 的收入,备注为“${largestIncome.note || '一次美好的收获'}”!`;
card2.createEl('p', { cls: 'analysis-insight', text: '这是辛勤工作的结果,也是未来的希望。' });
}

if (peakMonth) {
const card3 = grid.createEl('div', { cls: 'analysis-card' });
card3.createEl('h4', { text: '🚀 收入高峰月份', cls: 'gradient-title-income' });
const monthName = this.dv.luxon.DateTime.fromObject({month: peakMonth[0]}).toFormat('MMMM');
const percentage = totalIncome > 0 ? ((peakMonth[1] / totalIncome) * 100).toFixed(1) : 0;
card3.createEl('p').innerHTML = `您的收入在 <b>${monthName}</b> 达到顶峰,当月总计 <b>${this.config.currencySymbol}${formatMoney(peakMonth[1])}</b>,占全年总收入的 <b>${percentage}%</b>。`;
card3.createEl('p', { cls: 'analysis-insight', text: '回顾一下,是什么让这个月与众不同?' });
}

if(topIncomeDays.length > 0) {
const card4 = grid.createEl('div', { cls: 'analysis-card' });
card4.createEl('h4', { text: '🗓️ 收入节奏', cls: 'gradient-title-income' });
const list4 = card4.createEl('ul');
topIncomeDays.forEach(([day, amount]) => {
const percentage = totalIncome > 0 ? ((amount / totalIncome) * 100).toFixed(1) : 0;
list4.createEl('li').innerHTML = `<b>${weekdayMap[day-1]}</b>: <b>${this.config.currencySymbol}${formatMoney(amount)}</b> (${percentage}%)`;
});
card4.createEl('p', { cls: 'analysis-insight', text: `您的主要收入集中在${topIncomeDays.map(([d]) => `“${weekdayMap[d-1]}”`).join('和')}。` });
}
}

renderAnnualSummary(container, monthlyExpenses, monthlyIncomes, targetYear) {
const totalExpense = monthlyExpenses.reduce((s, m) => s + m, 0);
const totalIncome = monthlyIncomes.reduce((s, m) => s + m, 0);

const prevYearTransactions = this.state.allTransactions.filter(t => t.date.hasSame(targetYear.minus({ years: 1 }), 'year'));
const prevYearTotalExpense = Math.abs(prevYearTransactions.filter(t => !t.isIncome).reduce((sum, t) => sum + t.amount, 0));
const prevYearTotalIncome = prevYearTransactions.filter(t => t.isIncome).reduce((sum, t) => sum + t.amount, 0);

const prevYearActiveMonths = 12; // 假设上一年是完整的12个月
const prevYearAvgExpense = prevYearTransactions.length > 0 ? prevYearTotalExpense / prevYearActiveMonths : 0;
const prevYearNetIncome = prevYearTotalIncome - prevYearTotalExpense;

const activeMonths = targetYear.year === this.dv.luxon.DateTime.now().year ? this.dv.luxon.DateTime.now().month : 12;
const avgExpense = activeMonths > 0 ? totalExpense / activeMonths : 0;
const netIncome = totalIncome - totalExpense;
const summaryEl = container.createEl('div', { cls: 'annual-summary' });
const CS = this.config.currencySymbol;

const createSummaryItem = (label, value, color = 'var(--text-normal)', yoyData = null) => {
const item = summaryEl.createEl('div');
item.createEl('div', { text: label, cls: 'summary-label' });
item.createEl('div', { text: value, cls: 'summary-value large', attr: {style: `color: ${color}`} });
if (yoyData && prevYearTransactions.length > 0) {
const growth = this._calculateGrowth(yoyData.current, yoyData.previous, yoyData.type);
item.createEl('div', { text: `同比 ${growth.text}`, cls: `growth-indicator ${growth.class}`});
}
};

createSummaryItem('年度总支出', `${CS}${formatMoney(totalExpense)}`, 'var(--text-normal)', {current: totalExpense, previous: prevYearTotalExpense, type: 'expense'});
createSummaryItem('年度总收入', `${CS}${formatMoney(totalIncome)}`, 'var(--color-green)', {current: totalIncome, previous: prevYearTotalIncome, type: 'income'});

createSummaryItem('月均消费', `${CS}${formatMoney(avgExpense)}`, 'var(--text-normal)', {current: avgExpense, previous: prevYearAvgExpense, type: 'expense'});
createSummaryItem('年度净收入', `${netIncome >= 0 ? '+' : ''}${CS}${formatMoney(netIncome)}`, netIncome >= 0 ? 'var(--color-green)' : 'var(--color-red)', {current: netIncome, previous: prevYearNetIncome, type: 'income'});
}

renderToggleSwitch(container, viewType) {
const switchContainer = container.createEl('div', { attr: { class: 'toggle-switch-container' } });
const switchEl = switchContainer.createEl('div', { attr: { class: 'toggle-switch' } });
const activeBg = switchEl.createEl('div', { cls: 'toggle-switch-active-bg' });
const expenseOption = switchEl.createEl('div', { text: '支出', attr: { class: 'toggle-switch-option' } });
const incomeOption = switchEl.createEl('div', { text: '收入', attr: { class: 'toggle-switch-option' } });

const setActiveState = (isIncome) => {
expenseOption.classList.toggle('active', !isIncome);
incomeOption.classList.toggle('active', isIncome);
activeBg.style.transform = isIncome ? 'translateX(100%)' : 'translateX(0)';
};

setActiveState(this.state.showIncome);

expenseOption.onclick = () => {
if (this.state.showIncome) {
this.state.showIncome = false;
setActiveState(false);
this._updateDynamicContent();
}
};
incomeOption.onclick = () => {
if (!this.state.showIncome) {
this.state.showIncome = true;
setActiveState(true);
this._updateDynamicContent();
}
};
}

renderSearchView() {
const container = this.elements.searchContainer;
container.innerHTML = '';
const searchWrapper = container.createEl('div');
const searchBarContainer = searchWrapper.createEl('div', { attr: { style: 'display: flex; gap: 10px; margin-bottom: 15px;' } });
const searchInput = searchBarContainer.createEl('input', { type: 'text', placeholder: '🔍 搜索备注、分类、金额...', value: this.state.searchQuery, cls: 'search-input' });
const sortSelect = searchBarContainer.createEl('select', { cls: 'sort-select' });
sortSelect.innerHTML = `<option value="time_desc">时间降序</option><option value="time_asc">时间升序</option><option value="amount_desc">金额降序</option><option value="amount_asc">金额升序</option>`;
sortSelect.value = this.state.searchSort || 'time_desc';
const resultsCard = searchWrapper.createEl('div', { cls: 'details-card', attr: { style: 'padding: 0 15px;' } });
const resultsContainer = resultsCard.createEl('div');
const performSearch = () => {
const query = searchInput.value;
if (!query) {
resultsCard.style.display = 'block';
resultsContainer.innerHTML = `<p style="text-align:center; color: var(--text-muted); padding: 20px 0;">请输入关键词开始搜索</p>`;
return;
}
const lowerQuery = query.toLowerCase();
this.state.searchQuery = query;
this.state.searchSort = sortSelect.value;
resultsCard.style.display = 'block';
let transactions = this.state.allTransactions.filter(t => { const searchableText = `${t.category} ${t.note} ${t.amount} ${t.date.toFormat("M月d日")}`.toLowerCase(); return searchableText.includes(lowerQuery); });
switch (sortSelect.value) {
case 'time_desc': transactions.sort((a,b)=>b.date.toMillis()-a.date.toMillis()||b.time.localeCompare(a.time)); break;
case 'time_asc': transactions.sort((a,b)=>a.date.toMillis()-b.date.toMillis()||a.time.localeCompare(b.time)); break;
case 'amount_desc': transactions.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)); break;
case 'amount_asc': transactions.sort((a, b) => Math.abs(a.amount) - Math.abs(b.amount)); break;
}
this.renderTransactionList(resultsContainer, transactions, query);
};
searchInput.oninput = performSearch;
sortSelect.onchange = performSearch;
performSearch();
searchInput.focus();
}

_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 };
}

showTransactionsModal(title, transactions) {
const categoryName = title.split(' '); this.state.openModalInfo = { type: 'category', category: categoryName[0] };
const { container, close, overlay } = this._createOverlay('transactions-modal');
const header = container.createEl('div', { cls: 'transactions-header' });
header.createEl('div', { text: title, cls: 'transactions-title' });
const closeBtn = header.createEl('button', { text: '×', cls: 'transactions-close' });
const content = container.createEl('div', { cls: 'transactions-content' }); content.id = 'active-modal-content-area';
this.renderTransactionList(content, transactions);
const cleanupAndClose = () => {
if (this.modalCleanupStack[this.modalCleanupStack.length - 1] === cleanupAndClose) { this.modalCleanupStack.pop(); }
this.state.openModalInfo = null;
close();
};
closeBtn.onclick = cleanupAndClose;
overlay.onclick = (e) => { if (e.target === overlay) { cleanupAndClose(); } };
this.modalCleanupStack.push(cleanupAndClose);
}

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 } = 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);
}

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 } = 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);
}

showNoteEditorModal(item) {
const { container, close, overlay } = this._createOverlay();
const initialDate = this.dv.luxon.DateTime.fromObject(item.date.toObject());
const initialAmount = Math.abs(item.amount);
const initialNote = item.note || '';
const initialType = item.isIncome ? 'income' : 'expense';
const initialCategory = item.category;

container.createEl('h3', { text: '编辑交易记录', attr: {style: 'text-align: center;'} });
const form = container.createEl('div', { cls: 'edit-form' });

const amountDateSection = form.createEl('div', { cls: 'form-section', attr: { style: 'display: flex; gap: 10px; align-items: flex-end;' }});
const amountWrapper = amountDateSection.createEl('div', { attr: { style: 'flex-grow: 1;' }});
amountWrapper.createEl('label', { text: '金额' });
const amountInput = amountWrapper.createEl('input', { type: 'number', value: initialAmount.toFixed(2), cls: 'edit-modal-input' });

const dateWrapper = amountDateSection.createEl('div', { attr: { style: 'flex-shrink: 0;' }});
dateWrapper.createEl('label', { text: '日期' });
const dateInput = dateWrapper.createEl('input', { type: 'date', value: initialDate.toFormat('yyyy-MM-dd'), cls: 'edit-modal-input' });

form.createEl('div', { cls: 'form-section' }).innerHTML = `<label>备注(不可换行)</label>`; const noteTextarea = form.lastChild.createEl('textarea', { text: initialNote, cls: 'edit-modal-textarea' });
const typeCategorySection = form.createEl('div', { attr: { style: 'display: flex; gap: 10px;' } });
const typeSection = typeCategorySection.createEl('div', { cls: 'form-section', attr: { style: 'flex: 1;' } }); typeSection.createEl('label', { text: '类型' }); const typeSelect = typeSection.createEl('select', { cls: 'edit-modal-select' }); typeSelect.innerHTML = `<option value="expense">支出</option><option value="income">收入</option>`; typeSelect.value = initialType;
const categorySection = typeCategorySection.createEl('div', { cls: 'form-section', attr: { style: 'flex: 1;' } }); categorySection.createEl('label', { text: '分类' }); const categorySelect = categorySection.createEl('select', { cls: 'edit-modal-select' });

const populateCategoryOptions = () => {
let options = typeSelect.value === 'income'
? this.config.incomeCategories
: this.config.expenseCategories.filter(cat => cat !== '未分类');
if (!options || options.length === 0) options = ["未分类"];
categorySelect.innerHTML = options.map(c => `<option value="${c}">${c}</option>`).join('');
return options;
};
typeSelect.onchange = () => {
const newOptions = populateCategoryOptions();
if (!newOptions.includes(categorySelect.value)) {
categorySelect.value = newOptions[0] || '未分类';
new Notice('⚠️ 类别已重置,因为它不适用于当前类型。', 3000);
}
};
populateCategoryOptions();
categorySelect.value = initialCategory;

const footer = container.createEl('div', { attr: { style: 'display: flex; justify-content: space-between; margin-top: 20px;' } });
const deleteBtn = footer.createEl('button', { text: '删除', cls: 'delete-btn' });
const rightButtons = footer.createEl('div', { cls: 'edit-modal-buttons' });
const cancelBtn = rightButtons.createEl('button', { text: '取消', cls: 'cancel-btn' });
const saveBtn = rightButtons.createEl('button', { text: '保存', cls: 'confirm-btn' });

const handleLocalKeys = (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
if (document.activeElement === saveBtn || document.activeElement === cancelBtn || document.activeElement === deleteBtn) return;
event.preventDefault(); saveAction();
}
};
const cleanupAndClose = () => {
document.removeEventListener('keydown', handleLocalKeys);
if (this.modalCleanupStack[this.modalCleanupStack.length - 1] === cleanupAndClose) { this.modalCleanupStack.pop(); }
close();
};
const saveAction = async () => {
const currentAmount = parseFloat(amountInput.value);
const currentDate = this.dv.luxon.DateTime.fromISO(dateInput.value);
const currentNote = noteTextarea.value.replace(/\n/g, ' ').trim();
const currentType = typeSelect.value;
const currentCategory = categorySelect.value;
const isUnchanged = Math.abs(currentAmount - initialAmount) < 0.001 &&
currentNote === initialNote.trim() &&
currentType === initialType &&
currentCategory === initialCategory &&
currentDate.hasSame(initialDate, 'day');
if (isUnchanged) { cleanupAndClose(); return; }
if (isNaN(currentAmount) || currentAmount <= 0) { new Notice('❌ 金额必须是大于0的数字'); return; }
await this.saveTransaction({ ...item, date: currentDate, amount: currentType === 'income' ? currentAmount : -currentAmount, category: currentCategory, note: currentNote });
cleanupAndClose();
};
saveBtn.onclick = saveAction;
cancelBtn.onclick = cleanupAndClose;
deleteBtn.onclick = async () => { if (await this.showConfirmationModal('确认删除', '您确定要永久删除这条记录吗?')) { await this.deleteTransaction(item.lineNumber); cleanupAndClose(); } };
overlay.onclick = (e) => { if (e.target === overlay) { cleanupAndClose(); } };
document.addEventListener('keydown', handleLocalKeys);
this.modalCleanupStack.push(cleanupAndClose);
}

showConfirmationModal(title, message) {
return new Promise(resolve => {
const { container, close, overlay } = this._createOverlay();
container.classList.add('confirmation-modal-content');
container.createEl('h3', { text: title });
container.createEl('p', { text: message, attr: { style: 'margin: 15px 0;' } });

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

const buttons = container.createEl('div', { cls: 'confirmation-buttons' });
buttons.createEl('button', { text: '取消', cls: 'cancel-btn' }).onclick = () => cleanupAndClose(false);
buttons.createEl('button', { text: '确认', cls: 'confirm-btn' }).onclick = () => cleanupAndClose(true);
overlay.onclick = (e) => { if (e.target === overlay) { cleanupAndClose(false); } };

this.modalCleanupStack.push(() => cleanupAndClose(false));
});
}

renderCategoryList(container, transactions, totalAmount) {
const categoryTotals = {};
transactions.forEach(t => {
if (!categoryTotals[t.category]) categoryTotals[t.category] = { amount: 0, count: 0, items: [] };
const amount = Math.abs(t.amount);
categoryTotals[t.category].amount += amount;
categoryTotals[t.category].count += 1;
categoryTotals[t.category].items.push(t);
});
const sorted = Object.entries(categoryTotals).sort(([,a],[,b]) => b.amount - a.amount);
if (sorted.length === 0) { container.createEl('p', {text: "无相关分类记录", attr: {style: 'text-align: center; color: var(--text-muted); padding: 15px 0;'}}); return; }
const listEl = container.createEl('div', { attr: { style: 'margin-top: 25px; display: flex; flex-direction: column; gap: 22px;' } });
const CS = this.config.currencySymbol;
sorted.forEach(([category, details]) => {
const { amount, count, items } = details;
const percentage = totalAmount > 0 ? (amount / totalAmount) * 100 : 0;
const itemEl = listEl.createEl('div', { attr: { style: 'cursor: pointer;' } });
itemEl.onclick = () => this.showTransactionsModal(`${category} (${count}笔)`, items);
const firstLine = itemEl.createEl('div', { attr: { style: 'display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px;' } });
const sign = this.state.showIncome ? '+' : '-';
const color = this.state.showIncome ? 'var(--color-green)' : 'var(--color-red)';
firstLine.innerHTML = `<div><span style="font-size: 0.95em; font-weight: 500;">${category}</span><span style="font-size: 0.8em; color: var(--text-faint, var(--text-muted)); margin-left: 6px;">${percentage.toFixed(1)}%</span></div><span style="font-size: 0.95em; font-family: monospace; color: ${color};">${sign}${CS}${formatMoney(amount)}</span>`;
const bar = itemEl.createEl('div', { attr: { style: 'width: 100%; background-color: var(--background-modifier-border); border-radius: 4px; height: 6px;' } });
bar.createEl('div', { attr: { style: `width: ${percentage}%; height: 100%; background-color: ${this.config.categoryColors[category] || '#ccc'}; border-radius: 4px;` } });
});
}

renderTransactionList(container, transactions, query = '') {
container.innerHTML = '';
if (transactions.length === 0) { container.innerHTML = '<p style="text-align:center; color: var(--text-muted); padding: 20px 0;">无相关记录</p>'; return; }
transactions.forEach(item => {
const itemEl = container.createEl('div', { cls: 'transaction-item-container', attr: { style: 'padding: 8px 4px; cursor: pointer; border-radius: 4px; transition: background-color 0.2s ease; margin: 2px 0;' } });
itemEl.onclick = () => this.showNoteEditorModal(item); itemEl.onmouseenter = () => { itemEl.style.backgroundColor = 'var(--background-modifier-hover, #f0f0f0)'; }; itemEl.onmouseleave = () => { itemEl.style.backgroundColor = 'transparent'; };
const firstLine = itemEl.createEl('div', { attr: { style: 'display: flex; justify-content: space-between; align-items: center;' } });
firstLine.innerHTML = `<span style="font-size: 0.95em; font-weight: 500;">${highlightText(item.category, query)}</span><span style="font-size: 0.95em; font-family: monospace; color: ${item.isIncome ? 'var(--color-green)':'var(--color-red)'};">${highlightText(`${item.isIncome ? '+':''}${this.config.currencySymbol}${formatMoney(item.amount)}`, query)}</span>`;
const secondLine = itemEl.createEl('div', { attr: { style: 'display: flex; justify-content: space-between; align-items: baseline; margin-top: 2px;' } });
const noteContainer = secondLine.createEl('div', { cls: 'note-container' });
const noteTextEl = noteContainer.createEl('span', { cls: 'note-text', attr: { style: 'font-size: 0.85em; color: var(--text-muted);' } });
const noteContent = item.note || '点击添加备注';
noteTextEl.innerHTML = highlightText(noteContent, query);
noteTextEl.setAttribute('title', noteContent);
secondLine.createEl('span', { attr: { style: 'font-size: 0.85em; color: var(--text-muted); flex-shrink: 0; margin-left: 10px;' } }).innerHTML = `${highlightText(item.date.toFormat("yy年M月d日"), query)} ${item.time}`;
});
}

renderTrendChart(containerId, labels, expenseData, incomeData) {
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;
}
const createGradient = (ctx, chartArea, color) => { if (!chartArea) return null; const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); gradient.addColorStop(0, color.replace(')', ', 0.4)').replace('rgb', 'rgba')); gradient.addColorStop(1, color.replace(')', ', 0)').replace('rgb', 'rgba')); return gradient; };
const data = { type:'line', data:{ labels, datasets:[{label:'支出',data:expenseData,borderColor:'rgb(255,99,132)',backgroundColor:c=>createGradient(c.chart.ctx,c.chart.chartArea,'rgb(255,99,132)'),fill:true,tension:0.4},{label:'收入',data:incomeData,borderColor:'rgb(45,211,111)',backgroundColor:c=>createGradient(c.chart.ctx,c.chart.chartArea,'rgb(45,211,111)'),fill:true,tension:0.4}] }, options:{ responsive:true,maintainAspectRatio:false,scales:{y:{beginAtZero:true}},interaction:{intersect:false,mode:'index'},plugins:{legend:{labels:{usePointStyle:true,boxWidth:8}, onClick: null}}} };
try { renderChart(data, el); } catch (e) { el.setText(`❌ 图表渲染失败: ${e.message}`); }
}, 100);
}

async parseTransactionData() {
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 transactions = [];
const regex = /^- \[(\d{2}-\d{2}-\d{2})\]\s*\[([^:]+?)\s*:\s*([^\|]*?)\s*\|\s*([\+\-]?\d+(?:\.\d+)?)\]\s*\[(\d{2}:\d{2})\]$/;
let malformedLines = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim(); if (!line) continue;
const match = line.match(regex);
if (match) {
const amount = parseFloat(match[4]);
if (isNaN(amount) || amount === 0) {
if (isNaN(amount)) malformedLines++;
console.warn(`[消费仪表盘] 第 ${i+1}行 跳过:无效或为零的金额 "${match[4]}"`);
continue;
}

let year = parseInt(match[1].substring(0, 2), 10);
year += (year < 50) ? 2000 : 1900;
const month = parseInt(match[1].substring(3, 5), 10);
const day = parseInt(match[1].substring(6, 8), 10);
const dateObj = this.dv.luxon.DateTime.fromObject({ year, month, day, hour: parseInt(match[5].substring(0,2)), minute: parseInt(match[5].substring(3,5)) });

transactions.push({ date: dateObj, category:match[2].trim()||'未分类', note:match[3].trim()||'', amount, time:match[5], lineNumber:i, raw:lines[i], isIncome:amount>0 });
} else { malformedLines++; console.warn(`[消费仪表盘] 第 ${i+1}行 格式不匹配: "${line}"`); }
}
if (malformedLines > 0) { new Notice(`${malformedLines} 条记录格式不正确,已被忽略。\n详情请查看开发者控制台。`, 7000); }
return transactions;
}

async saveTransaction(item) {
const { lineNumber, date, category, note, amount, time } = item;
const amountStr = (amount > 0 ? `+${amount.toFixed(2)}` : amount.toFixed(2));
const newLine = `- [${date.toFormat("yy-MM-dd")}][${category}:${note}|${amountStr}][${time}]`;
await this._modifyLineInFile(lineNumber, newLine, "✅ 已更新记录");
}

async deleteTransaction(lineNumber) { await this._modifyLineInFile(lineNumber, null, "✅ 已删除记录"); }

async _refreshDataAndRender() {
this.state.allTransactions = await this.parseTransactionData();

if (this.state.activeView === 'search') {
this.renderSearchView();
} else {
this.switchView(this.state.activeView);
}

if (this.state.openModalInfo && this.state.openModalInfo.type === 'category') {
const modalContentEl = document.getElementById('active-modal-content-area'); const modalTitleEl = document.querySelector('.transactions-modal .transactions-title');
if (modalContentEl && modalTitleEl) {
const categoryName = this.state.openModalInfo.category; let currentViewTransactions;
if (this.state.activeView === 'monthly') { const targetMonth = this.dv.luxon.DateTime.now().plus({ months: this.state.monthOffset }); currentViewTransactions = this.state.allTransactions.filter(t => t.date.hasSame(targetMonth, 'month'));
} else { const targetYear = this.dv.luxon.DateTime.now().plus({ years: this.state.yearOffset }); currentViewTransactions = this.state.allTransactions.filter(t => t.date.hasSame(targetYear, 'year')); }
const newTransactionsForModal = currentViewTransactions.filter(t => t.category === categoryName);
if (newTransactionsForModal.length === 0) { const modal = document.querySelector('.transactions-modal'); if (modal) modal.parentElement.remove(); this.state.openModalInfo = null; return; }
const newTotalAmount = newTransactionsForModal.reduce((sum, t) => sum + t.amount, 0);
const count = newTransactionsForModal.length;
const newTitle = `${categoryName} (${count}笔) - ${this.config.currencySymbol}${formatMoney(Math.abs(newTotalAmount))}`;
modalTitleEl.textContent = newTitle; this.renderTransactionList(modalContentEl, newTransactionsForModal);
}
}
}

async _modifyLineInFile(lineNumber, newLineContent, noticeMsg) {
try {
const file = this.app.vault.getAbstractFileByPath(this.config.filePath); if (!file) throw new Error("日志文件未找到");
let lines = (await this.app.vault.read(file)).split('\n'); if (lineNumber >= lines.length) throw new Error("行号错误");
newLineContent === null ? lines.splice(lineNumber, 1) : lines[lineNumber] = newLineContent;
await this.app.vault.modify(file, lines.join('\n'));
if (noticeMsg) new Notice(noticeMsg); await this._refreshDataAndRender();
} catch (e) { new Notice(`❌ 操作失败: ${e.message}`); console.error(e); }
}

addGlobalStyles() {
const styleId = 'finance-dashboard-global-style-v2.3.0';
document.querySelectorAll('[id^="finance-dashboard-global-style"]').forEach(el => { if (el.id !== styleId) el.remove(); });
if (document.getElementById(styleId)) return;

const style = document.createElement('style');
style.id = styleId;
style.innerHTML = `
:root { --control-radius: 20px; }
.content-card { background-color: var(--background-secondary, #2a2a2a); background-image: linear-gradient(to bottom, var(--background-secondary-alt, #3a3a3a), var(--background-secondary, #2a2a2a)); border-radius: 12px; padding: 25px; border: 1px solid var(--background-modifier-border, #444); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.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: var(--control-radius); 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); }
.toggle-switch-container { display: flex; justify-content: center; margin-top: 15px; margin-bottom: 30px; }
.toggle-switch { position: relative; display: flex; background-color: var(--background-secondary-alt, #3a3a3a); border-radius: var(--control-radius); padding: 4px; border: 1px solid var(--background-modifier-border, #444); }
.toggle-switch-active-bg { position: absolute; top: 4px; left: 4px; width: calc(50% - 4px); height: calc(100% - 8px); background-color: var(--interactive-accent, #4e6f9a); border-radius: 16px; box-shadow: 0 2px 6px rgba(0,0,0,0.15); transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); }
.toggle-switch-option { position: relative; z-index: 1; padding: 6px 16px; font-size: 0.9em; font-weight: 500; border-radius: 16px; cursor: pointer; transition: color 0.3s ease; color: var(--text-muted, #999); }
.toggle-switch-option.active { color: white; }
.search-input { flex: 1; padding: 10px; font-size: 1.1em; border-radius: 8px; border: 1px solid var(--background-modifier-border, #444); background-color: var(--background-primary, #1e1e1e); }
.sort-select { padding: 10px; font-size: 0.9em; border-radius: 8px; border: 1px solid var(--background-modifier-border, #444); background-color: var(--background-primary, #1e1e1e); color: var(--text-muted, #999); }
.search-highlight { background-image: linear-gradient(to right, rgba(255, 223, 0, 0.6), rgba(255, 190, 0, 0.5)); border-radius: 3px; padding: 0 2px; margin: 0 -2px; color: var(--text-normal, #ddd); }
.summary-container { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; justify-content: space-around; text-align: center; margin-bottom: 25px; padding: 12px 0; background-color: var(--background-secondary-alt, #3a3a3a); border-radius: 8px; }
.annual-summary { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; text-align: center; margin-bottom: 25px; }
/* 这里修改“年度总收入”等字样 */
.summary-label { font-size: 0.85em; color: var(--text-muted, #999); margin-bottom: 4px; }
.summary-value { font-size: 1.35em; font-weight: 600; color: var(--text-normal, #ddd); }
.summary-value.large { font-size: 1.35em; }
.summary-value.income { color: var(--color-green, #34d399); }
/* 用户自定义:在这里修改环比/同比指示器的文字大小和颜色 */
.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: 0 15px; }
.transaction-item-container { border-top: 1px solid var(--background-modifier-border, #444); }
.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-container h3 { margin-top: 0; margin-bottom: 25px; text-align: center; }
.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; }
.edit-form { display: flex; flex-direction: column; gap: 15px; }
.form-section label { display: block; font-size: 0.9em; color: var(--text-muted, #999); font-weight: 500; margin-bottom: 4px; }
.edit-modal-input, .edit-modal-select, .edit-modal-textarea { width: 100%; padding: 8px; border-radius: 6px; border: 1px solid var(--background-modifier-border, #444); background-color: var(--background-primary, #1e1e1e) !important; font-size: 1em; color: var(--text-normal); }
.edit-modal-buttons { display: flex; justify-content: flex-end; gap: 10px; }
.edit-modal-buttons button, .delete-btn { padding: 8px 16px; border-radius: 6px; border: none; font-weight: 500; cursor: pointer; }
.edit-modal-buttons .confirm-btn { background-color: var(--interactive-accent, #4e6f9a); color: var(--text-on-accent, white); }
.edit-modal-buttons .cancel-btn { background-color: var(--background-modifier-border, #444); color: var(--text-normal, #ddd); }
.delete-btn { background-color: var(--background-modifier-border, #444) !important; color: var(--text-error, #e57373) !important; transition: background-color 0.2s; }
.delete-btn:hover { background-color: var(--color-red, #e53935) !important; color: white !important; }
.transactions-modal { background-color: var(--background-secondary, #2a2a2a); border-radius: 12px; border: 1px solid var(--background-modifier-border, #444); box-shadow: 0 5px 25px rgba(0,0,0,0.3); display: flex; flex-direction: column; overflow: hidden; width: 90%; max-width: 800px; max-height: 80vh; }
.transactions-header { padding: 15px 20px; background-color: var(--background-secondary-alt, #3a3a3a); border-bottom: 1px solid var(--background-modifier-border, #444); display: flex; justify-content: space-between; align-items: center; }
.transactions-title { font-size: 1.2em; font-weight: 600; }
.transactions-close { background: none; border: none; font-size: 1.5em; cursor: pointer; color: var(--text-muted, #999); }
.transactions-content { padding: 15px; overflow-y: auto; flex-grow: 1; }
.confirmation-buttons { display: flex; justify-content: flex-end; gap: 15px; }
.note-container { min-width: 0; flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.note-text[title] { cursor: help; }
.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); }
.gradient-title-income { background: linear-gradient(90deg, #67e8f9, #6ee7b7, #5eead4); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; color: transparent; }
.gradient-title-expense { background: linear-gradient(90deg, #facc15, #fb923c, #ef4444); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; color: transparent; }
.lifestyle-bar-container { width: 100%; height: 8px; background-color: var(--background-modifier-border); border-radius: 4px; overflow: hidden; margin: 8px 0; }
.lifestyle-bar-fill { height: 100%; background-color: var(--interactive-accent); border-radius: 4px; }
@media (max-width: 450px) { .edit-form > div[style*="display: flex"] { flex-direction: column; } .annual-summary { grid-template-columns: repeat(2, 1fr); gap: 5px; } .annual-summary .summary-value.large { font-size: 1.0em; } .annual-summary .summary-label { font-size: 0.7em; } }
`;
document.head.appendChild(style);
}

}

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

提示:仪表盘代码会自动从网络加载 Chart.js 库来绘制图表。如果图表不显示,请检查网络连接和 Obsidian 的网络权限。

结语

我将自己摸索和调试的经验浓缩在这篇教程里,希望能让你少走弯路,直接享受成果。如果在配置或使用中遇到任何问题,欢迎在评论区留言交流。

如果你觉得这篇文章对你有帮助,不妨分享给同样热爱 Obsidian 和效率生活的朋友们。

✨​温馨提示​✨

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

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

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

感谢阅读,祝你记账愉快!:)

仪表盘效果图(示例)

(这是一个根据描述生成的效果图示例,你的实际仪表盘会实时反映你的数据)

dashboard-demo.png
dashboard-demo.png
dashboard-demo.png
dashboard-demo.png
dashboard-demo.png


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