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
| ```dataviewjs // --- 消费仪表盘 (V4.0) --- // --- ⚙️ 配置区 --- const FILE_PATH = "记录/消费/消费-log.md"; const budgets = { "餐饮": 1500, "购物": 1000, "娱乐": 500, "交通": 800 }; // --- 结束配置 --- // 全局状态管理 let state = { monthOffset: 0, yearOffset: 0 }; // 全局数据缓存 let allTransactions = null; /** * 主渲染函数 */ async function renderDashboard(container) { container.innerHTML = ''; if (allTransactions === null) { allTransactions = await parseTransactionData(); if (allTransactions === null) { container.createEl("p", { text: "❌ **错误:** 无法加载或解析消费日志文件。" }); return; } } renderMonthlyView(container, allTransactions, state.monthOffset); container.createEl('hr'); renderAnnualView(container, allTransactions, state.yearOffset); } /** * 渲染月度视图 */ function renderMonthlyView(container, data, offset) { const view = container.createEl('div', { cls: 'monthly-view' }); const targetMonth = dv.luxon.DateTime.now().plus({ months: offset }); const nav = view.createEl('div', { attr: { style: 'display: flex; align-items: center; justify-content: center; gap: 15px; margin-bottom: 20px;' }}); // --- 按钮 --- const prevButton = nav.createEl('button', { text: '<< 上个月' }); prevButton.onclick = () => { state.monthOffset--; renderDashboard(container); }; nav.createEl('h3', { text: targetMonth.toFormat("yyyy年MM月"), attr: { style: 'margin: 0;' }}); const nextButton = nav.createEl('button', { text: '下个月 >>' }); nextButton.disabled = offset >= 0; nextButton.onclick = () => { state.monthOffset++; renderDashboard(container); }; const homeButton = nav.createEl('button', { text: '回到本月' }); homeButton.disabled = offset === 0; homeButton.onclick = () => { state.monthOffset = 0; renderDashboard(container); }; // --- 月度数据聚合与渲染 --- const transactions = data.filter(t => t.date.hasSame(targetMonth, 'month')); if (transactions.length === 0) { view.createEl("p", { text: `✅ 在 **${targetMonth.toFormat("yyyy年MM月")}** 没有找到任何消费记录。` }); return; } const total = transactions.reduce((sum, t) => sum + t.amount, 0); const categoryTotals = {}; transactions.forEach(t => { categoryTotals[t.category] = (categoryTotals[t.category] || 0) + t.amount; }); view.createEl('h2', { text: "月度消费概览" }); if (offset === 0) { let warnings = []; for (const cat in budgets) { const spent = categoryTotals[cat] || 0; if (spent > budgets[cat]) warnings.push(`- **${cat}** 已超支 <span style="color:red;">¥${(spent - budgets[cat]).toFixed(2)}</span>`); } if (warnings.length > 0) dv.markdown(view, `> [!WARNING] 预算超支提醒\n> ${warnings.join('\n> ')}`); } let summary = `本月总支出: **¥${total.toFixed(2)}**`; if (offset === 0) { const weekTotal = transactions.filter(t => t.date.hasSame(dv.luxon.DateTime.now(), 'week')).reduce((s, t) => s + t.amount, 0); summary += ` | 本周总支出: **¥${weekTotal.toFixed(2)}**`; } dv.paragraph(summary); const chartContainer = view.createEl('div', { attr: { style: 'width: 300px; max-height: 300px; margin: 20px auto; text-align: center;' } }); renderPieChart(chartContainer, categoryTotals, `${targetMonth.toFormat("yyyy年MM月")}消费分类`); view.createEl('h4', { text: "详细分类统计" }); dv.table(["消费类别", "总金额 (¥)", "占比"], Object.entries(categoryTotals).sort((a, b) => b[1] - a[1]).map(([cat, amount]) => [cat, `¥${amount.toFixed(2)}`, `${((amount / total) * 100).toFixed(1)}%`])); } /** * 渲染年度视图 */ function renderAnnualView(container, data, offset) { const view = container.createEl('div', { cls: 'annual-view' }); const targetYear = dv.luxon.DateTime.now().plus({ years: offset }); const nav = view.createEl('div', { attr: { style: 'display: flex; align-items: center; justify-content: center; gap: 15px; margin-top: 20px; margin-bottom: 20px;' }}); const prevButton = nav.createEl('button', { text: '<< 上一年' }); prevButton.onclick = () => { state.yearOffset--; renderDashboard(container); }; nav.createEl('h3', { text: `${targetYear.toFormat("yyyy")}年回顾`, attr: { style: 'margin: 0;' }}); const nextButton = nav.createEl('button', { text: '下一年 >>' }); nextButton.disabled = offset >= 0; nextButton.onclick = () => { state.yearOffset++; renderDashboard(container); }; const homeButton = nav.createEl('button', { text: '回到今年' }); homeButton.disabled = offset === 0; homeButton.onclick = () => { state.yearOffset = 0; renderDashboard(container); }; const transactions = data.filter(t => t.date.hasSame(targetYear, 'year')); if (transactions.length === 0) { view.createEl("p", { text: `✅ 在 **${targetYear.toFormat("yyyy")}年** 没有找到任何消费记录。` }); return; } const monthlyTotals = Array(12).fill(0); const categoryTotals = {}; transactions.forEach(t => { monthlyTotals[t.date.month - 1] += t.amount; categoryTotals[t.category] = (categoryTotals[t.category] || 0) + t.amount; }); const total = monthlyTotals.reduce((sum, m) => sum + m, 0); const activeMonths = targetYear.year === dv.luxon.DateTime.now().year ? dv.luxon.DateTime.now().month : 12; const avg = total / activeMonths; const maxMonthValue = Math.max(...monthlyTotals); const maxMonthIndex = monthlyTotals.indexOf(maxMonthValue); const topCategory = Object.entries(categoryTotals).sort((a, b) => b[1] - a[1])[0]; view.createEl('h2', { text: "年度财务摘要" }); const summaryEl = view.createEl('div', { attr: { style: 'display: flex; justify-content: space-around; text-align: center; margin-bottom: 20px;' }}); summaryEl.createEl('div').innerHTML = `<strong>年度总支出</strong><br>¥${total.toFixed(2)}`; summaryEl.createEl('div').innerHTML = `<strong>月均消费</strong><br>¥${avg.toFixed(2)}`; summaryEl.createEl('div').innerHTML = `<strong>消费最高月份</strong><br>${maxMonthIndex + 1}月 (¥${maxMonthValue.toFixed(2)})`; summaryEl.createEl('div').innerHTML = `<strong>支出冠军类别</strong><br>${topCategory[0]} (¥${topCategory[1].toFixed(2)})`; const chartContainer = view.createEl('div'); renderLineChart(chartContainer, monthlyTotals, `${targetYear.year}年月度消费趋势`); } /** * 数据解析函数 */ async function parseTransactionData() { const file = app.vault.getAbstractFileByPath(FILE_PATH); if (!file) return null; const content = await app.vault.read(file); const lines = content.trim().split('\n').filter(line => line.trim()); if (lines.length === 0) return []; const transactions = []; const regex = /^-\s*\[(\d{2}-\d{2}-\d{2})\]-\[(\d+\.?\d*)\]-\[(.*?)\]$/; for (const line of lines) { const match = line.match(regex); if (!match) continue; const date = dv.luxon.DateTime.fromFormat(match[1], "yy-MM-dd", { zone: 'local' }); if (!date.isValid) continue; const totalAmount = parseFloat(match[2]); if (isNaN(totalAmount) || totalAmount <= 0) continue; const remarksStr = match[3].trim(); const remarks = remarksStr.split('→').map(r => r.trim()).filter(r => r); if (remarks.length === 0) continue; const averageAmount = totalAmount / remarks.length; let hasSubAmounts = false, subSum = 0; const tempSubs = []; for (const r of remarks) { const parts = r.split('@'); if (parts.length > 1) { const sub = parseFloat(parts.pop().trim()); if (!isNaN(sub)) { tempSubs.push(sub); subSum += sub; hasSubAmounts = true; } else { tempSubs.push(null); } } else { tempSubs.push(null); } } const useSubAmounts = hasSubAmounts && tempSubs.every(s => s !== null) && Math.abs(subSum - totalAmount) < 0.01; for (let i = 0; i < remarks.length; i++) { const r = remarks[i]; const parts = r.split('@'); const catNote = parts.length > 1 ? parts.slice(0, -1).join('@') : r; const itemAmount = useSubAmounts ? tempSubs[i] : averageAmount; const [category, ...noteParts] = catNote.split(':').map(p => p.trim()); transactions.push({ date, amount: itemAmount, category: category.replace(/[`#]/g, '') || '未分类', note: noteParts.join(':').trim() }); } } return transactions; } /** * 图表渲染函数(仅修复饼图) */ function renderPieChart(container, data, title) { const chartData = { type: 'pie', data: { labels: Object.keys(data), datasets: [{ data: Object.values(data).map(v => parseFloat(v.toFixed(2))), backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56", "#4BC0C0", "#9966FF", "#FF9F40"] }] }, options: { responsive: true, maintainAspectRatio: true, plugins: { title: { display: true, text: title } } } }; if (typeof Chart !== 'undefined') { try { window.renderChart(chartData, container); console.log(`饼图渲染成功: ${title}`); } catch (e) { container.createEl('p', { text: `❌ 饼图渲染失败: ${e.message}` }); console.error('饼图渲染错误:', e); } } else { container.createEl('p', { text: '📈 Chart.js 未加载,饼图无法显示。' }); } } function renderLineChart(container, data, title) { const chartData = { type: 'line', data: { labels: ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'], datasets: [{ label: '月度消费', data: data.map(v => v.toFixed(2)), fill: false, borderColor: 'rgb(75, 192, 192)', tension: 0.1 }] }, options: { plugins: { title: { display: true, text: title } } } }; if (typeof Chart !== 'undefined') window.renderChart(chartData, container); } /** * 启动逻辑 */ function initialize() { if (typeof Chart === 'undefined') { dv.paragraph("⚠️ Chart.js 未加载,尝试动态加载..."); const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/chart.js'; document.head.appendChild(script); script.onload = () => { console.log('Chart.js 加载成功'); renderDashboard(this.container); }; script.onerror = () => { dv.paragraph("❌ 无法加载 Chart.js 库,请检查网络连接。"); renderDashboard(this.container); // 继续渲染(无图表) }; } else { console.log('Chart.js 已加载'); renderDashboard(this.container); } } if (typeof dv.luxon === 'undefined') { dv.paragraph("❌ **错误:** Luxon 未加载,Dataview 环境异常。"); } else { initialize.call(this); }
|