夜雨聆风学习资料网

ARTICLE · 1060263

芯灵个人资金规划(源码)

芯灵个人资金规划(源码)

芯灵个人资金规划系统 V4测试版(V2内核)

这是一套完全浏览器本地运行的个人财务管理单页Web应用,全部数据存储在浏览器  IndexedDB  本地数据库,不上传任何数据到云端,打开HTML文件即可直接使用,不需要安装软件、不需要服务器,支持PC/手机移动端适配,主打月度预算规划、账户管理、债务借款跟踪,同时具备数据导入导出、撤销、深色模式等完整能力。

技术栈:原生HTML+CSS+JavaScript,图表使用Chart.js,本地存储IndexedDB,同时兼容旧版本localStorage自动迁移。内部金额统一以分为单位整数存储,避免浮点数小数精度bug。

✨ 核心功能模块

1. 月度规划(核心模块)

1. 月度项目体系:以「年+月」作为独立资金项目,每个月份独立保存收入、预算支出;侧边栏展示全部历史月份项目,支持搜索、按年份折叠分组。

2. 收入管理:录入工资、奖金、兼职、投资收益等收入,可标记定期项,新建下月项目时可以一键自动继承定期收入。

3. 支出预算管理

- 内置分类:住房、餐饮、交通、购物、娱乐、健康、教育、储蓄、投资、其他

- 同时填写预算金额 + 实际支出,自动展示预算消耗进度条,超支自动标红预警

- 支持标记定期支出,下月自动填充

4. 自动统计指标:总收入、总支出、结余资金、支出占收入比例;超支时状态标签自动变更。

5. 可视化图表

- 支出分类环形饼图:看各类开销占比

- 年度结余折线图:展示本年度1‑12月每月结余变化

6. 项目操作:单项目导出JSON、导出CSV表格;修改项目年月、删除项目;新建月份自动识别上月定期收支。

2. 年度汇总统计

- 单年份查看:展示一整年每个月的结余,计算年度总盈余/亏损

- 年份区间统计:支持选择多年范围,计算跨年份资金总和,批量展示各月份收支结余

- 数据实时计算,记录更新时间戳

3. 账户管理:银行卡 + 储蓄钱包

管理你真实的资金账户,区分两种账户类型:

1. 银行卡账户:填写账户名、卡号后四位、开户行、余额、备注

2. 储蓄钱包:微信、支付宝等线上钱包账户

3. 账户转账功能:在不同账户之间做资金划转,自动修改双方余额,保存转账操作记录

4. 侧边栏实时展示全部账户总余额、账户列表,移动端自适应布局。

4. 还款·借款管家(债务模块)

📅 还款计划(你需要还的钱)

- 支持一次性还款、每月固定、每年固定、自定义周期

- 设置还款日、债务来源,自动倒计时:正常/即将到期/逾期标色提醒

💸 对外借款(别人欠你的钱)

- 记录借款人、总借款金额、借款日期、预计还款日

- 可多次录入对方的还款记录,自动计算「已还金额、剩余未还金额」

- 到期倒计时,逾期高亮提示

配套两张统计图表:待还款VS借出资金饼图;借款收回状态柱状图,同时统计总资金、待还总额、借出总额、已收回总额。

🛡️ 数据能力

1. 本地IndexedDB持久化:全部数据保存在你的浏览器,不会上传网络;浏览器清除数据会丢失记录。

2. 撤销操作 Ctrl+Z:最多保存20步撤销栈,误删除可以回滚。

3. 完整导入/导出备份

- 全部数据导出为JSON备份文件;支持导入,导入可以选择「完全覆盖」或者「合并追加」

- 单个月度项目可以导出JSON,也可以导出CSV(Excel直接打开)

4. 旧版本自动迁移:如果曾经使用localStorage旧版本,打开程序自动迁移全部数据到IndexedDB。

5. 防止精度错误:内部金额全部换算成整数分存储,界面再转回元展示,规避JS浮点数0.1+0.2类的计算bug。

🎨 UI与体验特性

1. 深色/浅色主题一键切换,图表颜色会跟随主题自动适配。

2. 完整移动端响应式:手机访问会自动重排布局,表单、列表适配小屏幕。

3. 弹窗通知提示、空状态引导、确认删除保护,防止误操作。

4. 搜索历史项目:可以按年份、月份快速筛选历史月度计划。

5. 表单校验,非法输入拦截。

📋 使用方式

1. 将完整HTML文件保存到本地电脑;直接双击用Chrome/Edge等现代浏览器打开,不需要联网(仅首次加载Chart.js需要网络;离线可以把Chart.js下载到本地替换CDN地址)

2. 优先新建月度项目;录入本月收入,再录入各项预算支出,填写实际花销看预算消耗;

3. 在账户模块录入你手上真实银行卡、钱包余额;

4. 债务页面录入贷款、对外借出的钱款,到期会倒计时提醒。

⚠️ 局限

1. 数据存在浏览器:更换浏览器、无痕模式、清除浏览器存储会丢失数据,务必定期导出JSON备份。

2. 外部CDN依赖Chart.js图表库,完全离线使用需要修改HTML把CDN替换成本地js文件。

3. 没有云同步,不能多设备自动同步,多设备只能靠导入导出备份文件手动同步。

适合人群

适合希望把预算放在本地、不希望个人财务数据上传第三方网站的用户;做月度预算分配、记录债务借款、跟踪真实账户资产,轻量替代各类记账APP。

以下为源码
复制粘贴即可使用,txt后缀改为html,电脑端浏览器打开——手机端也能打开不过布局就不合理了。

<!DOCTYPE html>

<html lang="zh-CN">

<head>

<meta charset="UTF-8">

<meta name="viewport" content="width=device-width, initial-scale=1.0">

<meta name="theme-color" content="#3b82f6">

<link rel="manifest" href='data:application/manifest+json,{"name":"芯灵资金规划","short_name":"芯灵","display":"standalone","background_color":"%23f0f9ff","theme_color":"%233b82f6","start_url":"."}'>

<title>芯灵资金规划 V2 · IndexedDB</title>

<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>

<style>

:root{

  --primary:#3b82f6;--primary-light:#eff6ff;--primary-dark:#2563eb;

  --success:#10b981;--success-light:#ecfdf5;--warning:#f59e0b;--warning-light:#fffbeb;

  --danger:#ef4444;--danger-light:#fef2f2;--income:#8b5cf6;--income-light:#f5f3ff;

  --bank:#06b6d4;--bank-light:#ecfeff;--wallet:#8b5cf6;--wallet-light:#f5f3ff;

  --dark:#1e293b;--gray-100:#f8fafc;--gray-200:#e2e8f0;--gray-300:#cbd5e1;

  --gray-400:#94a3b8;--gray-500:#64748b;--gray-600:#475569;--gray-700:#334155;

  --card-bg:#fff;--bg-main:linear-gradient(135deg,#f0f9ff 0%,#e6f7ff 100%);

  --shadow-sm:0 2px 6px rgba(0,0,0,.05);--shadow:0 4px 12px rgba(0,0,0,.08);

  --shadow-md:0 8px 24px rgba(0,0,0,.12);

  --radius-sm:6px;--radius:10px;--radius-lg:12px;

  --transition:all .3s cubic-bezier(.4,0,.2,1);--transition-fast:all .2s ease;

}

body.dark{

  --primary:#60a5fa;--primary-light:#1e3a8a;--primary-dark:#3b82f6;

  --success:#34d399;--success-light:#064e3b;--warning:#fbbf24;--warning-light:#78350f;

  --danger:#f87171;--danger-light:#7f1d1d;--income:#a78bfa;--income-light:#4c1d95;

  --bank:#22d3ee;--bank-light:#164e63;--wallet:#a78bfa;--wallet-light:#4c1d95;

  --dark:#f1f5f9;--gray-100:#1e293b;--gray-200:#334155;--gray-300:#475569;

  --gray-400:#64748b;--gray-500:#94a3b8;--gray-600:#cbd5e1;--gray-700:#e2e8f0;

  --card-bg:#1e293b;--bg-main:linear-gradient(135deg,#0f172a 0%,#1e293b 100%);

  --shadow-sm:0 2px 6px rgba(0,0,0,.4);--shadow:0 4px 12px rgba(0,0,0,.5);

  --shadow-md:0 8px 24px rgba(0,0,0,.6);

}

*{box-sizing:border-box;margin:0;padding:0}

body{font-family:'Inter','Segoe UI',Tahoma,Geneva,Verdana,sans-serif;line-height:1.6;

  padding:20px;background:var(--bg-main);color:var(--dark);min-height:100vh;

  display:flex;gap:20px;transition:background .3s,color .3s}

.fade-in{animation:fadeIn .5s ease forwards}

.slide-up{animation:slideUp .4s ease forwards}

@keyframes fadeIn{from{opacity:0}to{opacity:1}}

@keyframes slideUp{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}

@keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.03)}}

@keyframes progressAnimate{from{width:0}to{width:var(--progress-width)}}

.sidebar{width:280px;background:var(--card-bg);border-radius:var(--radius-lg);box-shadow:var(--shadow);

  padding:24px;height:fit-content;align-self:flex-start;transition:var(--transition)}

.sidebar-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;

  padding-bottom:15px;border-bottom:1px solid var(--gray-200);gap:8px;flex-wrap:wrap}

.sidebar-title{font-size:1.2rem;color:var(--dark);font-weight:600}

.sidebar-actions{display:flex;gap:6px;flex-wrap:wrap}

.create-project-btn,.edit-project-date-btn,.csv-project-btn{border:none;border-radius:var(--radius-sm);

  padding:6px 10px;font-size:.8rem;font-weight:500;cursor:pointer;transition:var(--transition)}

.create-project-btn{background:linear-gradient(135deg,var(--primary),var(--primary-dark));color:#fff}

.create-project-btn:hover{box-shadow:0 4px 10px rgba(59,130,246,.3);transform:scale(1.05)}

.edit-project-date-btn{background:var(--warning-light);color:var(--warning)}

.edit-project-date-btn:hover{background:var(--warning);color:#fff;transform:scale(1.05)}

.edit-project-date-btn:disabled{background:var(--gray-200);color:var(--gray-500);cursor:not-allowed;transform:none}

.csv-project-btn{background:var(--success-light);color:var(--success)}

.csv-project-btn:hover{background:var(--success);color:#fff;transform:scale(1.05)}

.search-input{width:100%;padding:8px 12px;border:1px solid var(--gray-300);border-radius:var(--radius-sm);

  font-size:.85rem;background:var(--card-bg);color:var(--dark);margin-bottom:12px;outline:none}

.search-input:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(59,130,246,.15)}

.accounts-overview{margin-bottom:20px;padding-bottom:18px;border-bottom:1px solid var(--gray-200)}

.accounts-overview-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}

.accounts-overview-title{font-size:1rem;color:var(--dark);font-weight:600}

.accounts-total{font-size:1.1rem;font-weight:700;color:var(--bank)}

.account-item-small{display:flex;justify-content:space-between;align-items:center;padding:8px 10px;

  margin-bottom:6px;border-radius:var(--radius-sm);background:var(--gray-100);transition:var(--transition)}

.account-item-small:hover{background:var(--primary-light)}

.account-name-small{font-size:.85rem;font-weight:500;color:var(--dark)}

.account-balance-small{font-size:.85rem;font-weight:600;color:var(--bank)}

.account-type-badge{font-size:.68rem;padding:2px 6px;border-radius:10px;font-weight:500;margin-right:6px}

.badge-bank{background:var(--bank-light);color:var(--bank)}

.badge-wallet{background:var(--wallet-light);color:var(--wallet)}

.project-group{margin-bottom:10px}

.project-group summary{cursor:pointer;padding:6px 8px;font-size:.85rem;font-weight:600;

  color:var(--gray-600);background:var(--gray-100);border-radius:var(--radius-sm);

  list-style:none;user-select:none}

.project-group summary::-webkit-details-marker{display:none}

.project-group summary::before{content:'▸ ';display:inline-block;transition:transform .2s}

.project-group[open] summary::before{transform:rotate(90deg)}

.project-list{max-height:340px;overflow-y:auto;padding-right:4px}

.project-item{padding:10px 12px;border-radius:var(--radius);margin-bottom:6px;cursor:pointer;

  transition:var(--transition);display:flex;justify-content:space-between;align-items:center;

  background:var(--gray-100);border:1px solid transparent}

.project-item:hover{background:var(--primary-light);border-color:var(--primary-light)}

.project-item.active{background:var(--primary-light);color:var(--primary-dark);font-weight:500;

  border-color:var(--primary);box-shadow:0 2px 4px rgba(59,130,246,.15)}

.project-info{display:flex;flex-direction:column}

.project-date{font-size:.9rem;font-weight:inherit}

.project-update-time{font-size:.68rem;color:var(--gray-500);margin-top:2px}

.project-actions{display:flex;gap:4px}

.delete-project-btn,.export-project-btn,.csv-export-btn{background:none;border:none;

  color:var(--gray-500);cursor:pointer;padding:3px 6px;font-size:.85rem;border-radius:4px;transition:var(--transition-fast)}

.delete-project-btn:hover{color:var(--danger);background:var(--danger-light)}

.export-project-btn:hover,.csv-export-btn:hover{color:var(--success);background:var(--success-light)}

.container{flex:1;max-width:1200px;margin:0 auto;background:var(--card-bg);padding:30px;

  border-radius:var(--radius-lg);box-shadow:var(--shadow);transition:var(--transition)}

.tabs{display:flex;border-bottom:2px solid var(--gray-200);margin-bottom:25px;gap:2px;flex-wrap:wrap}

.tab-btn{padding:10px 20px;background:var(--gray-100);border:none;border-radius:var(--radius-sm) var(--radius-sm) 0 0;

  cursor:pointer;font-size:.92rem;font-weight:500;transition:var(--transition);color:var(--gray-600)}

.tab-btn:hover{background:var(--primary-light);color:var(--primary-dark)}

.tab-btn.active{background:var(--card-bg);color:var(--primary-dark);font-weight:600;

  border-top:3px solid var(--primary);border-left:1px solid var(--gray-200);border-right:1px solid var(--gray-200);margin-bottom:-2px}

.tab-panel{display:none;animation:fadeIn .3s ease forwards}

.tab-panel.active{display:block}

header{text-align:center;margin-bottom:30px;padding-bottom:20px;border-bottom:1px solid var(--gray-200)}

h1{color:var(--dark);font-size:2.1rem;font-weight:700;letter-spacing:-.5px}

.subtitle{color:var(--gray-600);font-size:1rem;margin-top:5px}

.time-selector{display:flex;justify-content:center;gap:16px;margin:22px 0;align-items:center;flex-wrap:wrap}

.time-selector label{font-size:.9rem;color:var(--gray-600);font-weight:500;margin-right:6px}

.time-selector select{padding:10px 14px;border:1px solid var(--gray-300);border-radius:var(--radius-sm);

  font-size:.9rem;min-width:120px;background:var(--card-bg);color:var(--dark);transition:var(--transition)}

.time-selector select:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(59,130,246,.15)}

.theme-toggle-btn{background:var(--gray-100);border:none;border-radius:50%;width:36px;height:36px;

  cursor:pointer;font-size:1.1rem;transition:var(--transition)}

.theme-toggle-btn:hover{transform:scale(1.1);background:var(--gray-200)}

.data-controls{display:flex;justify-content:center;gap:14px;margin:14px 0 26px;flex-wrap:wrap}

.data-btn{background:linear-gradient(135deg,var(--gray-600),#475569);color:#fff;padding:9px 18px;

  border:none;border-radius:var(--radius-sm);cursor:pointer;font-size:.88rem;font-weight:500;transition:var(--transition)}

.data-btn:hover{background:linear-gradient(135deg,#475569,var(--gray-600));box-shadow:0 4px 10px rgba(0,0,0,.1);transform:scale(1.05)}

.current-project-card{background:var(--primary-light);border-radius:var(--radius);padding:16px 22px;

  margin-bottom:24px;display:flex;justify-content:space-between;align-items:center;

  border-left:4px solid var(--primary);box-shadow:var(--shadow-sm)}

.project-title{font-size:1rem;color:var(--primary-dark);font-weight:500}

.project-title span{font-weight:600;color:var(--dark)}

.project-status{display:inline-block;padding:5px 12px;border-radius:20px;font-size:.85rem;font-weight:500}

.status-completed{background:var(--success-light);color:var(--success)}

.status-in-progress{background:var(--warning-light);color:var(--warning)}

.summary{background:linear-gradient(120deg,var(--card-bg),var(--gray-100));padding:26px;border-radius:var(--radius);

  margin-bottom:30px;border:1px solid var(--gray-200);display:flex;justify-content:space-around;flex-wrap:wrap;gap:20px}

.summary-item{padding:0 12px;min-width:140px;transition:var(--transition)}

.summary-item:hover{transform:scale(1.05)}

.summary-item h3{font-size:.9rem;color:var(--gray-600);margin-bottom:8px;font-weight:500;text-align:center}

.summary-value{font-size:1.65rem;font-weight:700;text-align:center;letter-spacing:-.5px;padding:4px 0}

.income{color:var(--income)}.remaining{color:var(--success)}.negative{color:var(--danger)!important}

.progress-container{width:100%;background:var(--gray-200);border-radius:20px;height:14px;margin:20px 0 12px;overflow:hidden}

.progress-bar{height:100%;background:linear-gradient(90deg,var(--primary),var(--primary-dark));

  border-radius:20px;animation:progressAnimate .8s ease forwards}

.chart-pair{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:22px 0}

.chart-card{background:var(--card-bg);border-radius:var(--radius);padding:16px;border:1px solid var(--gray-200);box-shadow:var(--shadow-sm)}

.chart-card canvas{max-height:220px;width:100%}

.chart-caption{text-align:center;font-size:.75rem;color:var(--gray-500);margin-top:6px}

.type-toggle{display:flex;justify-content:center;gap:10px;margin:18px 0}

.type-toggle-btn{background:var(--gray-200);color:var(--gray-700);padding:7px 16px;border:none;

  border-radius:var(--radius-sm);cursor:pointer;font-weight:500;transition:var(--transition);font-size:.88rem}

.type-toggle-btn.active{background:var(--primary);color:#fff}

.type-toggle-btn.income-active{background:var(--income);color:#fff}

.allocations{margin-bottom:28px}

.section-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px;

  padding-bottom:10px;border-bottom:2px solid var(--gray-200);flex-wrap:wrap;gap:10px}

.section-title h2{color:var(--dark);font-size:1.3rem;font-weight:600}

.allocation-stats{background:var(--primary-light);color:var(--primary-dark);padding:5px 12px;

  border-radius:20px;font-size:.85rem;font-weight:500}

.income-stats{background:var(--income-light);color:var(--income);padding:5px 12px;border-radius:20px;font-size:.85rem;font-weight:500}

.allocation-item,.income-item{display:flex;justify-content:space-between;padding:14px 16px;

  border-radius:var(--radius-sm);margin-bottom:8px;background:var(--card-bg);border:1px solid var(--gray-200);

  align-items:center;gap:12px;flex-wrap:wrap;transition:var(--transition)}

.allocation-item:hover,.income-item:hover{background:var(--gray-100);transform:translateY(-1px);box-shadow:var(--shadow-sm)}

.income-item{background:var(--income-light);border-left:4px solid var(--income)}

.allocation-info{flex:1;min-width:140px}

.allocation-name{font-weight:600;font-size:1rem;color:var(--dark);display:flex;align-items:center;gap:8px;flex-wrap:wrap}

.allocation-category{font-size:.78rem;color:var(--gray-500);margin-top:3px}

.allocation-amount,.income-amount{font-weight:700;font-size:1.15rem;min-width:110px;text-align:right;color:var(--primary-dark)}

.income-amount{color:var(--income)}

.actions{display:flex;gap:8px;margin-left:auto}

button{padding:8px 14px;border:none;border-radius:var(--radius-sm);cursor:pointer;font-size:.85rem;font-weight:500;transition:var(--transition)}

button:hover{transform:scale(1.05)}

.edit-btn{background:var(--warning);color:#fff}

.save-btn{background:var(--success);color:#fff}

.delete-btn{background:var(--danger);color:#fff}

.income-btn{background:var(--income);color:#fff}

.bank-btn{background:var(--bank);color:#fff}

.transfer-btn{background:var(--primary);color:#fff}

.add-btn{background:linear-gradient(135deg,var(--primary),var(--primary-dark));color:#fff;padding:11px 22px;

  margin:20px auto;display:block;font-size:1rem;font-weight:600;border-radius:var(--radius)}

.add-income-btn{background:linear-gradient(135deg,var(--income),#7c3aed);color:#fff;padding:11px 22px;

  margin:20px auto;display:block;font-size:1rem;font-weight:600;border-radius:var(--radius)}

.add-btn:disabled,.add-income-btn:disabled{background:linear-gradient(135deg,var(--gray-300),var(--gray-400));cursor:not-allowed;transform:none}

.form-container{background:var(--gray-100);padding:24px;border-radius:var(--radius);margin-top:24px;border:1px solid var(--gray-200)}

.form-title{font-size:1.2rem;color:var(--dark);margin-bottom:20px;font-weight:600;padding-bottom:8px;border-bottom:1px solid var(--gray-200)}

.form-group{margin-bottom:18px}

label{display:block;margin-bottom:8px;font-weight:600;color:var(--gray-700);font-size:.9rem}

input,select,textarea{width:100%;padding:11px 14px;border:1px solid var(--gray-300);border-radius:var(--radius-sm);

  font-size:.92rem;transition:var(--transition);background:var(--card-bg);color:var(--dark)}

input:focus,select:focus,textarea:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(59,130,246,.15)}

.form-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}

.cancel-btn{background:var(--gray-200);color:var(--gray-700)}

.hidden{display:none!important}

.empty-state{text-align:center;padding:40px 20px;color:var(--gray-500);background:var(--gray-100);

  border-radius:var(--radius);margin-bottom:16px;border:1px dashed var(--gray-300)}

.empty-state p{font-size:1rem;margin-bottom:10px}

.empty-state .guide-text{font-size:.85rem;color:var(--gray-600);max-width:400px;margin:0 auto}

.notification{position:fixed;top:26px;right:26px;padding:14px 22px;border-radius:var(--radius);

  background:var(--success);color:#fff;font-weight:500;box-shadow:0 6px 15px rgba(0,0,0,.15);

  transform:translateX(120%);transition:transform .4s cubic-bezier(.34,1.56,.64,1);z-index:1000;max-width:340px}

.notification.show{transform:translateX(0)}

.notification.error{background:var(--danger)}

.budget-row{display:flex;align-items:center;gap:10px;margin-top:8px;font-size:.78rem;color:var(--gray-500)}

.budget-bar{flex:1;height:6px;background:var(--gray-200);border-radius:3px;overflow:hidden}

.budget-fill{height:100%;transition:width .4s}

.budget-ok{background:var(--success)}

.budget-warn{background:var(--warning)}

.budget-over{background:var(--danger)}

.undo-indicator{position:fixed;bottom:20px;left:20px;background:var(--card-bg);border:1px solid var(--gray-200);

  border-radius:var(--radius-sm);padding:6px 12px;font-size:.75rem;color:var(--gray-500);box-shadow:var(--shadow-sm);z-index:60}

.undo-indicator kbd{background:var(--gray-200);padding:1px 5px;border-radius:3px;font-family:monospace;font-size:.85em}

.accounts-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px;flex-wrap:wrap;gap:10px}

.accounts-total-balance{font-size:1.2rem;color:var(--bank);font-weight:700}

.account-list{margin-bottom:32px}

.account-item{display:flex;justify-content:space-between;padding:18px 22px;border-radius:var(--radius-sm);

  margin-bottom:10px;background:var(--card-bg);border:1px solid var(--gray-200);align-items:center;gap:12px;flex-wrap:wrap}

.account-item:hover{background:var(--gray-100);box-shadow:var(--shadow-sm)}

.account-item.bank{border-left:4px solid var(--bank)}

.account-item.wallet{border-left:4px solid var(--wallet)}

.account-name{font-weight:600;font-size:1.1rem;color:var(--dark);margin-bottom:4px}

.account-details{font-size:.82rem;color:var(--gray-500);display:flex;gap:14px;flex-wrap:wrap}

.account-balance{font-weight:700;font-size:1.25rem;min-width:110px;text-align:right;color:var(--bank)}

.wallet-balance{color:var(--wallet)}

.amount-hidden{color:var(--gray-400);letter-spacing:2px}

.toggle-hidden-btn{background:none;border:none;cursor:pointer;font-size:1.05rem;padding:4px 6px;color:var(--gray-500)}

.toggle-hidden-btn:hover{transform:scale(1.2);color:var(--primary)}

.transfer-section{background:var(--gray-100);padding:22px;border-radius:var(--radius);border:1px solid var(--gray-200)}

.transfer-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin-top:16px}

.debt-container{max-width:1100px;margin:0 auto}

.debt-header{text-align:center;margin-bottom:24px}

.debt-header h1{font-size:1.6rem;font-weight:700;background:linear-gradient(135deg,var(--dark),var(--gray-600));

  -webkit-background-clip:text;background-clip:text;color:transparent}

.debt-subtitle{color:var(--gray-600);font-size:.85rem;margin-top:6px}

.card{background:var(--card-bg);border-radius:var(--radius);padding:22px;margin-bottom:20px;border:1px solid var(--gray-200);box-shadow:var(--shadow-sm)}

.card-title{font-size:1.15rem;font-weight:600;display:flex;justify-content:space-between;

  align-items:center;margin-bottom:16px;padding-bottom:10px;border-bottom:2px solid var(--gray-200);color:var(--dark);gap:10px;flex-wrap:wrap}

.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:14px;margin-bottom:20px}

.summary-card{background:var(--card-bg);border-radius:var(--radius);padding:16px;text-align:center;border:1px solid var(--gray-200)}

.summary-card:hover{transform:translateY(-3px);box-shadow:var(--shadow)}

.summary-label{font-size:.78rem;color:var(--gray-600);display:flex;align-items:center;justify-content:center;gap:6px}

.summary-card .summary-value{font-size:1.5rem;font-weight:700;margin-top:8px;line-height:1.2}

.edit-funds-icon{cursor:pointer;font-size:.75rem;background:var(--primary-light);padding:2px 6px;border-radius:20px;color:var(--primary)}

.repayment-item,.loan-item{background:var(--gray-100);border-radius:var(--radius);padding:14px;margin-bottom:10px;

  border:1px solid var(--gray-200);display:flex;flex-wrap:wrap;align-items:center;gap:12px}

.item-info{flex:2;min-width:160px}

.item-name{font-weight:700;font-size:1rem;display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--dark)}

.item-desc{font-size:.75rem;color:var(--gray-500);margin-top:4px}

.item-amount{font-weight:700;font-size:1.15rem;min-width:100px;text-align:right}

.repayment-item .item-amount{color:var(--danger)}

.loan-item .item-amount{color:var(--success)}

.item-countdown{text-align:center;min-width:70px}

.countdown-days{font-size:1.4rem;font-weight:800}

.overdue{color:#dc2626}.warning{color:#f59e0b}.normal{color:#22c55e}

.item-actions{display:flex;gap:8px}

.badge-status{font-size:.68rem;padding:2px 8px;border-radius:20px;background:var(--primary-light);color:var(--primary)}

.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.55);display:flex;

  justify-content:center;align-items:center;z-index:1001;opacity:0;visibility:hidden;transition:var(--transition);padding:16px}

.modal-overlay.show{opacity:1;visibility:visible}

.modal{background:var(--card-bg);border-radius:var(--radius);padding:24px;width:90%;max-width:500px;

  box-shadow:var(--shadow-md);transform:translateY(-20px);transition:var(--transition);max-height:90vh;overflow-y:auto;color:var(--dark)}

.modal-overlay.show .modal{transform:translateY(0)}

.modal-title{font-size:1.2rem;color:var(--dark);margin-bottom:20px;font-weight:600;text-align:center}

.modal-form-group{margin-bottom:18px}

.modal-form-group label{display:block;margin-bottom:8px;font-weight:500;color:var(--gray-700)}

.modal-form-group select,.modal-form-group input{width:100%;padding:11px 14px;border:1px solid var(--gray-300);

  border-radius:var(--radius-sm);font-size:.95rem;background:var(--card-bg);color:var(--dark)}

.modal-actions{display:flex;gap:12px;margin-top:26px}

.modal-btn{flex:1;padding:11px;border:none;border-radius:var(--radius-sm);font-size:.95rem;font-weight:500;cursor:pointer;transition:var(--transition)}

.modal-confirm-btn{background:var(--primary);color:#fff}

.modal-cancel-btn{background:var(--gray-200);color:var(--gray-700)}

footer{text-align:center;margin-top:30px;color:var(--gray-600);font-size:.85rem;padding-top:16px;border-top:1px solid var(--gray-200)}

@media(max-width:992px){

  body{flex-direction:column;padding:14px}

  .sidebar{width:100%}

  .chart-pair{grid-template-columns:1fr}

}

@media(max-width:768px){

  .container{padding:20px 14px}

  h1{font-size:1.7rem}

  .tabs{flex-direction:column;border-bottom:none}

  .tab-btn{border-radius:var(--radius-sm);margin-bottom:6px}

  .tab-btn.active{margin-bottom:6px;border:1px solid var(--gray-200)}

  .time-selector{flex-direction:column;align-items:stretch;gap:12px}

  .summary{flex-direction:column}

  .summary-item{min-width:100%}

  .allocation-item,.income-item{flex-direction:column;align-items:flex-start}

  .actions{width:100%;justify-content:flex-end}

  .add-btn,.add-income-btn{width:100%}

  .account-item{flex-direction:column;align-items:flex-start}

  .account-balance{width:100%;text-align:left;min-width:auto}

  .transfer-form{grid-template-columns:1fr}

  .repayment-item,.loan-item{flex-direction:column;align-items:stretch}

  .item-amount,.item-countdown{text-align:left;width:100%}

  .item-actions{justify-content:flex-end}

}

</style>

</head>

<body>

<!-- ============ 侧边栏 ============ -->

<div class="sidebar">

  <div class="accounts-overview">

    <div class="accounts-overview-header">

      <h3 class="accounts-overview-title">账户概览</h3>

      <div class="accounts-total" id="accounts-total-sidebar">0.00 元</div>

    </div>

    <div id="accounts-sidebar-list">

      <div class="empty-state" style="padding:12px 0;margin-bottom:0">

        <p style="font-size:.85rem">暂无账户</p>

      </div>

    </div>

  </div>

  <div class="sidebar-header">

    <h3 class="sidebar-title">历史项目</h3>

    <div class="sidebar-actions">

      <button id="create-project-btn" class="create-project-btn">新建</button>

      <button id="edit-project-date-btn" class="edit-project-date-btn" disabled>改年月</button>

      <button id="csv-project-btn" class="csv-project-btn" disabled>CSV</button>

    </div>

  </div>

  <input type="text" id="project-search" class="search-input" placeholder="🔍 搜索项目(如 2025、03月)">

  <div class="project-list" id="project-list">

    <div class="empty-state" id="project-empty-state">

      <p>暂无历史项目</p>

      <p class="guide-text">点击「新建」创建月度资金计划</p>

    </div>

  </div>

</div>

<!-- ============ 主容器 ============ -->

<div class="container">

  <header>

    <h1>芯灵资金规划系统 V2</h1>

    <p class="subtitle">IndexedDB 存储 · 整数分记账 · 智能规划 · 全功能财务管家</p>

  </header>

  <div class="tabs">

    <button class="tab-btn active" data-tab="monthly">月度规划</button>

    <button class="tab-btn" data-tab="annual">年度汇总</button>

    <button class="tab-btn" data-tab="accounts">账户管理</button>

    <button class="tab-btn" data-tab="debt">还款借款</button>

    <div style="margin-left:auto;display:flex;align-items:center">

      <button class="theme-toggle-btn" id="theme-toggle" title="切换深色模式">🌙</button>

    </div>

  </div>

  <!-- ============ 月度规划 ============ -->

  <div class="tab-panel active" id="monthly-panel">

    <div class="time-selector">

      <div><label>年份</label><select id="year-select"></select></div>

      <div><label>月份</label><select id="month-select">

        <option value="1">1月</option><option value="2">2月</option><option value="3">3月</option>

        <option value="4">4月</option><option value="5">5月</option><option value="6">6月</option>

        <option value="7">7月</option><option value="8">8月</option><option value="9">9月</option>

        <option value="10">10月</option><option value="11">11月</option><option value="12">12月</option>

      </select></div>

    </div>

    <div class="current-project-card">

      <div class="project-title">当前项目:<span id="current-project-text">未选择项目</span></div>

      <span id="project-status" class="project-status status-in-progress hidden">规划中</span>

    </div>

    <div class="data-controls">

      <button id="export-btn" class="data-btn">📤 导出全部</button>

      <label for="import-input" class="data-btn" style="cursor:pointer">📥 导入全部</label>

      <input type="file" id="import-input" accept=".json" style="display:none">

      <button id="undo-btn" class="data-btn">↶ 撤销</button>

    </div>

    <div class="summary">

      <div class="summary-item">

        <h3>总收入</h3>

        <div class="summary-value income" id="total-income">0.00 元</div>

        <button id="add-income-btn" class="income-btn" style="margin-top:10px;padding:5px 12px;font-size:.85rem;display:block;margin-left:auto;margin-right:auto">添加收入</button>

      </div>

      <div class="summary-item"><h3>总支出</h3><div class="summary-value" id="total-expense">0.00 元</div></div>

      <div class="summary-item"><h3>结余资金</h3><div class="summary-value remaining" id="remaining-funds">0.00 元</div></div>

      <div class="summary-item"><h3>支出占比</h3><div class="summary-value" id="expense-rate">0%</div></div>

    </div>

    <div class="progress-container"><div class="progress-bar" id="expense-progress" style="--progress-width:0%"></div></div>

    <div class="chart-pair">

      <div class="chart-card"><canvas id="categoryPie"></canvas><div class="chart-caption">支出分类占比</div></div>

      <div class="chart-card"><canvas id="yearTrend"></canvas><div class="chart-caption">年度结余趋势</div></div>

    </div>

    <div class="type-toggle" id="type-toggle">

      <button class="type-toggle-btn active" data-type="expense">支出明细</button>

      <button class="type-toggle-btn" data-type="income">收入明细</button>

    </div>

    <div class="allocations" id="expense-section">

      <div class="section-title"><h2>支出分配明细</h2><div class="allocation-stats" id="expense-stats">0 个支出项</div></div>

      <div id="expense-list"></div>

      <button id="add-expense-btn" class="add-btn" disabled>添加支出项</button>

    </div>

    <div class="allocations hidden" id="income-section">

      <div class="section-title"><h2>收入明细</h2><div class="income-stats" id="income-stats">0 个收入项</div></div>

      <div id="income-list"></div>

      <button id="show-add-income-btn" class="add-income-btn" disabled>添加收入项</button>

    </div>

    <!-- 支出/收入表单 -->

    <div id="expense-form-container" class="form-container hidden">

      <h3 id="expense-form-title" class="form-title">添加支出项</h3>

      <form id="expense-form">

        <input type="hidden" id="expense-edit-index">

        <div class="form-group"><label>支出名称</label><input type="text" id="expense-name" required placeholder="房租、餐饮等"></div>

        <div class="form-group"><label>类别</label><select id="expense-category">

          <option value="house">住房</option><option value="food">餐饮</option><option value="transport">交通</option>

          <option value="shopping">购物</option><option value="entertainment">娱乐</option><option value="health">健康</option>

          <option value="education">教育</option><option value="saving">储蓄</option><option value="investment">投资</option>

          <option value="other">其他</option>

        </select></div>

        <div class="form-group"><label>预算金额 (元)</label><input type="number" id="expense-amount" min="0.01" step="0.01" required></div>

        <div class="form-group"><label>实际支出 (元) <span style="font-weight:400;color:var(--gray-500);font-size:.8rem">— 可选,用于预算对比</span></label>

          <input type="number" id="expense-actual" min="0" step="0.01" placeholder="留空表示未开始执行"></div>

        <div class="form-group"><label><input type="checkbox" id="expense-recurring" style="width:auto;margin-right:6px"> 标记为定期项(下月可自动填充)</label></div>

        <div class="form-actions">

          <button type="button" id="cancel-expense-btn" class="cancel-btn">取消</button>

          <button type="submit" class="save-btn">保存支出项</button>

        </div>

      </form>

    </div>

    <div id="income-form-container" class="form-container hidden">

      <h3 id="income-form-title" class="form-title">添加收入项</h3>

      <form id="income-form">

        <input type="hidden" id="income-edit-index">

        <div class="form-group"><label>收入名称</label><input type="text" id="income-name" required placeholder="工资、奖金等"></div>

        <div class="form-group"><label>收入类别</label><select id="income-category">

          <option value="salary">工资</option><option value="bonus">奖金</option><option value="investment">投资</option>

          <option value="freelance">兼职</option><option value="rental">租金</option><option value="gift">礼金</option>

          <option value="other">其他</option>

        </select></div>

        <div class="form-group"><label>收入金额 (元)</label><input type="number" id="income-amount" min="0.01" step="0.01" required></div>

        <div class="form-group"><label><input type="checkbox" id="income-recurring" style="width:auto;margin-right:6px"> 标记为定期项</label></div>

        <div class="form-actions">

          <button type="button" id="cancel-income-btn" class="cancel-btn">取消</button>

          <button type="submit" class="save-btn">保存收入项</button>

        </div>

      </form>

    </div>

  </div>

  <!-- ============ 年度汇总 ============ -->

  <div class="tab-panel" id="annual-panel">

    <div class="section-title"><h2>年度剩余资金汇总</h2></div>

    <div class="annual-filter" style="display:flex;flex-wrap:wrap;gap:14px;align-items:center;justify-content:center;margin-bottom:22px">

      <label>单年份:</label><select id="annual-year" style="padding:8px 12px;min-width:110px;border-radius:6px;border:1px solid var(--gray-300);background:var(--card-bg);color:var(--dark)"></select>

      <div style="display:flex;gap:4px">

        <button class="tab-btn active" data-view="single">单年份</button>

        <button class="tab-btn" data-view="range">年份区间</button>

      </div>

      <div class="year-range-filter hidden" style="display:flex;gap:8px;align-items:center">

        <select id="start-year" style="padding:8px 12px;border-radius:6px;border:1px solid var(--gray-300);background:var(--card-bg);color:var(--dark)"></select>

        <span>至</span>

        <select id="end-year" style="padding:8px 12px;border-radius:6px;border:1px solid var(--gray-300);background:var(--card-bg);color:var(--dark)"></select>

        <button id="calc-range-btn" class="data-btn">计算</button>

      </div>

    </div>

    <div class="range-total-card hidden">

      <div style="background:linear-gradient(135deg,var(--primary-light),#dbeafe);border-radius:var(--radius);

        padding:22px;text-align:center;border:1px solid var(--primary-light);margin-bottom:20px">

        <div style="font-size:1.1rem;color:var(--gray-600);margin-bottom:10px"><span id="range-display-text">区间</span> 剩余资金总和</div>

        <div style="font-size:2rem;font-weight:700;color:var(--primary-dark)" id="range-total-amount">¥ 0.00</div>

      </div>

    </div>

    <div id="monthly-balance-container"></div>

    <div id="single-year-total-card" style="background:linear-gradient(135deg,var(--primary-light),#dbeafe);

      border-radius:var(--radius);padding:22px;text-align:center;border:1px solid var(--primary-light);margin-top:20px">

      <div style="font-size:1.1rem;color:var(--gray-600);margin-bottom:10px" id="single-year-title">年度剩余资金总计</div>

      <div style="font-size:2rem;font-weight:700;color:var(--primary-dark)" id="single-year-total-amount">¥ 0.00</div>

    </div>

    <footer style="margin-top:20px"><p>数据实时计算 | 最后更新: <span id="annual-last-updated"></span></p></footer>

  </div>

  <!-- ============ 账户管理 ============ -->

  <div class="tab-panel" id="accounts-panel">

    <div class="section-title"><h2>银行卡与储蓄钱包管理</h2></div>

    <div class="accounts-header">

      <h3>账户总览</h3><div class="accounts-total-balance" id="accounts-total-balance">0.00 元</div>

    </div>

    <div class="account-list" id="bank-account-section">

      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">

        <h3 style="margin:0;font-size:1rem">银行卡账户</h3>

        <button id="toggle-bank-accounts-btn" class="btn" style="font-size:.78rem;background:var(--gray-100)">收起</button>

      </div>

      <div id="bank-account-content">

        <div id="bank-accounts-list"></div>

        <button id="add-bank-account-btn" class="add-btn bank-btn">添加银行卡账户</button>

        <div id="bank-form-wrapper" style="display:none;margin-top:14px;border-top:1px solid var(--gray-200);padding-top:14px">

          <form id="bank-account-form">

            <input type="hidden" id="bank-edit-id">

            <div class="form-group"><label>账户名称</label><input type="text" id="bank-name" required placeholder="招商银行储蓄卡"></div>

            <div class="form-group"><label>账户号码</label><input type="text" id="bank-number" placeholder="后四位或完整卡号"></div>

            <div class="form-group"><label>开户银行</label><input type="text" id="bank-bank" placeholder="银行名称"></div>

            <div class="form-group"><label>余额 (元)</label><input type="number" id="bank-balance" min="0" step="0.01" required></div>

            <div class="form-group"><label>备注</label><textarea id="bank-notes" rows="2"></textarea></div>

            <div class="form-actions">

              <button type="button" class="cancel-account-btn cancel-btn" data-target="bank">取消</button>

              <button type="submit" class="save-btn">保存银行卡</button>

            </div>

          </form>

        </div>

      </div>

    </div>

    <div class="account-list">

      <h3 style="font-size:1rem;margin-bottom:12px">储蓄钱包</h3>

      <div id="wallet-accounts-list"></div>

      <button id="add-wallet-account-btn" class="add-btn add-income-btn">添加储蓄钱包</button>

      <div id="wallet-form-wrapper" style="display:none;margin-top:14px;border-top:1px solid var(--gray-200);padding-top:14px">

        <form id="wallet-account-form">

          <input type="hidden" id="wallet-edit-id">

          <div class="form-group"><label>钱包名称</label><input type="text" id="wallet-name" required placeholder="微信零钱"></div>

          <div class="form-group"><label>账户号码</label><input type="text" id="wallet-number" placeholder="账号/ID"></div>

          <div class="form-group"><label>平台/银行</label><input type="text" id="wallet-bank" placeholder="平台名称"></div>

          <div class="form-group"><label>余额 (元)</label><input type="number" id="wallet-balance" min="0" step="0.01" required></div>

          <div class="form-group"><label>备注</label><textarea id="wallet-notes" rows="2"></textarea></div>

          <div class="form-actions">

            <button type="button" class="cancel-account-btn cancel-btn" data-target="wallet">取消</button>

            <button type="submit" class="save-btn">保存钱包</button>

          </div>

        </form>

      </div>

    </div>

    <div class="transfer-section">

      <div class="section-title"><h2>资金转账</h2></div>

      <form id="transfer-form" class="transfer-form">

        <div class="form-group"><label>从账户转出</label><select id="transfer-from" required><option value="">选择转出账户</option></select></div>

        <div class="form-group"><label>转入账户</label><select id="transfer-to" required><option value="">选择转入账户</option></select></div>

        <div class="form-group"><label>转账金额 (元)</label><input type="number" id="transfer-amount" min="0.01" step="0.01" required></div>

        <div class="form-group"><label>转账备注</label><input type="text" id="transfer-notes"></div>

        <div class="form-actions" style="grid-column:1/-1">

          <button type="button" id="cancel-transfer-btn" class="cancel-btn">重置</button>

          <button type="submit" class="transfer-btn">执行转账</button>

        </div>

      </form>

    </div>

    <footer><p>账户数据本地保存 | 最后更新: <span id="accounts-last-updated"></span></p></footer>

  </div>

  <!-- ============ 还款借款 ============ -->

  <div class="tab-panel" id="debt-panel">

    <div class="debt-container">

      <div class="debt-header">

        <h1>💰 还款·借款管家</h1>

        <div class="debt-subtitle">债务提醒 · 借款跟踪 · 资金全景</div>

      </div>

      <div class="summary-grid" id="summaryGrid"></div>

      <div class="chart-pair">

        <div class="chart-card"><canvas id="fundChart"></canvas><div class="chart-caption">待还款 vs 借出金额</div></div>

        <div class="chart-card"><canvas id="loanStatusChart"></canvas><div class="chart-caption">借款还款状态</div></div>

      </div>

      <div class="card">

        <div class="card-title">📅 还款计划 <button id="addRepaymentBtn" class="btn save-btn">+ 新增还款</button></div>

        <div id="repaymentListContainer"></div>

      </div>

      <div class="card">

        <div class="card-title">💸 对外借款 <button id="addLoanBtn" class="btn income-btn">+ 新增借款</button></div>

        <div id="loanListContainer"></div>

      </div>

    </div>

  </div>

  <footer><p>当前项目数据已保存到浏览器 IndexedDB | 最后更新: <span id="last-updated"></span></p></footer>

</div>

<!-- 年月修改弹窗 -->

<div class="modal-overlay" id="edit-date-modal">

  <div class="modal">

    <h3 class="modal-title">修改当前项目年月</h3>

    <div class="modal-form-group"><label>年份</label><select id="modal-year-select"></select></div>

    <div class="modal-form-group"><label>月份</label><select id="modal-month-select">

      <option value="1">1月</option><option value="2">2月</option><option value="3">3月</option>

      <option value="4">4月</option><option value="5">5月</option><option value="6">6月</option>

      <option value="7">7月</option><option value="8">8月</option><option value="9">9月</option>

      <option value="10">10月</option><option value="11">11月</option><option value="12">12月</option>

    </select></div>

    <div class="modal-actions">

      <button class="modal-btn modal-cancel-btn" id="modal-cancel-btn">取消</button>

      <button class="modal-btn modal-confirm-btn" id="modal-confirm-btn">确认修改</button>

    </div>

  </div>

</div>

<!-- 通用输入弹窗 -->

<div class="modal-overlay" id="ask-modal">

  <div class="modal">

    <h3 class="modal-title" id="ask-title">输入</h3>

    <div class="modal-form-group">

      <input type="number" id="ask-input" step="0.01">

    </div>

    <div class="modal-actions">

      <button class="modal-btn modal-cancel-btn" id="ask-cancel">取消</button>

      <button class="modal-btn modal-confirm-btn" id="ask-confirm">确定</button>

    </div>

  </div>

</div>

<!-- 还款弹窗 -->

<div id="repaymentModal" class="modal-overlay">

  <div class="modal">

    <div class="modal-title" id="repayModalTitle">添加还款计划</div>

    <form id="repaymentForm">

      <input type="hidden" id="repayEditIndex">

      <div class="modal-form-group"><label>还款名称</label><input id="repayName" required placeholder="房贷/车贷"></div>

      <div style="display:flex;gap:12px;flex-wrap:wrap">

        <div class="modal-form-group" style="flex:1;min-width:140px"><label>金额(¥)</label><input type="number" id="repayAmount" step="0.01" required></div>

        <div class="modal-form-group" style="flex:1;min-width:140px"><label>周期</label><select id="repayCycle">

          <option value="once">一次性</option><option value="monthly">每月固定</option>

          <option value="yearly">每年固定</option><option value="custom">自定义阶段</option>

        </select></div>

      </div>

      <div style="display:flex;gap:12px;flex-wrap:wrap">

        <div class="modal-form-group" style="flex:1;min-width:140px"><label>起始/还款日</label><input type="date" id="repayDate" required></div>

        <div class="modal-form-group" style="flex:1;min-width:140px"><label>类型/来源</label><input id="repayType" required placeholder="银行/网贷/朋友"></div>

      </div>

      <div class="modal-form-group"><label>备注</label><input id="repayNotes"></div>

      <div class="modal-actions">

        <button type="button" id="closeRepayModal" class="modal-btn modal-cancel-btn">取消</button>

        <button type="submit" class="modal-btn modal-confirm-btn">保存</button>

      </div>

    </form>

  </div>

</div>

<!-- 借款弹窗 -->

<div id="loanModal" class="modal-overlay">

  <div class="modal">

    <div class="modal-title" id="loanModalTitle">添加对外借款</div>

    <form id="loanForm">

      <input type="hidden" id="loanEditIndex">

      <div class="modal-form-group"><label>借款人</label><input id="loanName" required></div>

      <div style="display:flex;gap:12px;flex-wrap:wrap">

        <div class="modal-form-group" style="flex:1;min-width:140px"><label>借款总额(¥)</label><input type="number" id="loanAmount" step="0.01" required></div>

        <div class="modal-form-group" style="flex:1;min-width:140px"><label>还款方式</label><select id="loanTypeSelect">

          <option value="fixed">每月固定日期还</option><option value="estimated">预计大概日期还</option>

        </select></div>

      </div>

      <div style="display:flex;gap:12px;flex-wrap:wrap">

        <div class="modal-form-group" style="flex:1;min-width:140px"><label>借款日期</label><input type="date" id="loanStartDate" required></div>

        <div class="modal-form-group" style="flex:1;min-width:140px"><label>预计/固定还款日</label><input type="date" id="loanDueDate" required></div>

      </div>

      <div class="modal-form-group"><label>备注</label><input id="loanNotes"></div>

      <div style="margin-top:14px;border-top:1px solid var(--gray-200);padding-top:14px">

        <label>📋 还款记录</label>

        <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;margin-top:6px">

          <div class="modal-form-group" style="flex:1;min-width:110px;margin-bottom:0"><input type="number" id="recordAmountInput" placeholder="还款金额" step="0.01"></div>

          <div class="modal-form-group" style="flex:1;min-width:130px;margin-bottom:0"><input type="date" id="recordDateInput"></div>

          <button type="button" id="addRecordBtn" class="btn" style="background:var(--gray-100)">+ 添加</button>

        </div>

        <div id="loanRecordsList" style="max-height:150px;overflow-y:auto;margin:10px 0;background:var(--gray-100);border-radius:10px;padding:6px"></div>

        <div style="display:flex;justify-content:space-between;margin-top:8px;font-size:.82rem">

          <span>已还: <strong id="totalPaidDisplay">¥0.00</strong></span>

          <span>未还: <strong id="remainingDisplay" style="color:var(--danger)">¥0.00</strong></span>

        </div>

      </div>

      <div class="modal-actions">

        <button type="button" id="closeLoanModal" class="modal-btn modal-cancel-btn">取消</button>

        <button type="submit" class="modal-btn modal-confirm-btn">保存借款</button>

      </div>

    </form>

  </div>

</div>

<div class="notification" id="notification"></div>

<div class="undo-indicator">按 <kbd>Ctrl</kbd>+<kbd>Z</kbd> 可撤销</div>

<script>

'use strict';

/* ================================================================

 *  1. 工具函数 & 金额工具

 * ================================================================ */

const $ = id => document.getElementById(id);

const now = new Date();

const currentYear = now.getFullYear();

const currentMonth = now.getMonth() + 1;

function getCurrentTime(){

  const d = new Date();

  const pad = n => String(n).padStart(2,'0');

  return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;

}

function generateId(){ return Date.now().toString(36) + Math.random().toString(36).slice(2,7); }

/* 金额:内部统一整数“分” */

function toFen(yuan){ const n = parseFloat(yuan); return isNaN(n) ? 0 : Math.round(n * 100); }

function toYuan(fen){ return (fen / 100); }

function fmtYuan(fen){ return (fen / 100).toFixed(2) + ' 元'; }

function fmtYuanShort(fen){ return '¥' + (fen / 100).toFixed(2); }

function sumFen(arr, field){

  if(!Array.isArray(arr)) return 0;

  return arr.reduce((s, x) => s + (x[field] || 0), 0);

}

function showNotification(msg, err){

  const el = $('notification');

  el.textContent = msg;

  el.classList.add('show');

  if(err) el.classList.add('error'); else el.classList.remove('error');

  clearTimeout(el._t);

  el._t = setTimeout(() => el.classList.remove('show'), 2800);

}

/* 通用输入弹窗(替代 prompt) */

function askNumber(title, defaultValue, onConfirm){

  const modal = $('ask-modal');

  $('ask-title').textContent = title;

  $('ask-input').value = defaultValue;

  modal.classList.add('show');

  setTimeout(() => $('ask-input').focus(), 100);

  const cleanup = () => {

    modal.classList.remove('show');

    $('ask-confirm').onclick = null;

    $('ask-cancel').onclick = null;

    $('ask-input').onkeydown = null;

  };

  $('ask-confirm').onclick = () => {

    const v = parseFloat($('ask-input').value);

    cleanup();

    if(!isNaN(v)) onConfirm(v);

  };

  $('ask-cancel').onclick = cleanup;

  $('ask-input').onkeydown = e => { if(e.key === 'Enter') $('ask-confirm').click(); };

}

function askText(title, defaultValue, onConfirm){

  const modal = $('ask-modal');

  const input = $('ask-input');

  $('ask-title').textContent = title;

  input.type = 'text';

  input.value = defaultValue;

  modal.classList.add('show');

  setTimeout(() => input.focus(), 100);

  const cleanup = () => {

    modal.classList.remove('show');

    input.type = 'number';

    $('ask-confirm').onclick = null;

    $('ask-cancel').onclick = null;

    input.onkeydown = null;

  };

  $('ask-confirm').onclick = () => { const v = input.value.trim(); cleanup(); if(v) onConfirm(v); };

  $('ask-cancel').onclick = cleanup;

  input.onkeydown = e => { if(e.key === 'Enter') $('ask-confirm').click(); };

}

/* ================================================================

 *  2. IndexedDB 存储层

 * ================================================================ */

const IDB = {

  db: null,

  async open(){

    if(this.db) return this.db;

    return new Promise((res, rej) => {

      const req = indexedDB.open('xinling_fund_v2', 1);

      req.onupgradeneeded = e => {

        const db = e.target.result;

        if(!db.objectStoreNames.contains('state')) db.createObjectStore('state');

      };

      req.onsuccess = () => { this.db = req.result; res(this.db); };

      req.onerror = () => rej(req.error);

    });

  },

  async get(key){

    const db = await this.open();

    return new Promise((res, rej) => {

      const tx = db.transaction('state', 'readonly');

      const r = tx.objectStore('state').get(key);

      r.onsuccess = () => res(r.result);

      r.onerror = () => rej(r.error);

    });

  },

  async set(key, value){

    const db = await this.open();

    return new Promise((res, rej) => {

      const tx = db.transaction('state', 'readwrite');

      tx.objectStore('state').put(value, key);

      tx.oncomplete = res;

      tx.onerror = () => rej(tx.error);

    });

  }

};

/* ================================================================

 *  3. 单一应用状态 APP

 * ================================================================ */

let APP = {

  version: 2,

  projectIndexes: [],          // ['202501', '202502', ...]

  projects: {},                // { key: { incomes:[], expenses:[], lastUpdated } }

  accounts: [],

  transactions: [],

  repayments: [],

  loans: [],

  actualFunds: 0,              // 单位:分

  settings: { dark: false, bankCollapsed: false },

  // 当前会话状态(不持久化)

  _currentKey: ''

};

/* 数据规范化(补齐字段 + 兼容旧数据) */

function normalizeApp(){

  APP.projectIndexes = APP.projectIndexes || [];

  APP.projects = APP.projects || {};

  APP.accounts = APP.accounts || [];

  APP.transactions = APP.transactions || [];

  APP.repayments = APP.repayments || [];

  APP.loans = APP.loans || [];

  APP.settings = APP.settings || { dark: false, bankCollapsed: false };

  // 金额字段规范化:旧数据可能是元(float),若绝对值很小则视为“元”,转成分

  // 判定:若字段名是 amount/balance 且 < 1e6 且非整数 → 视为元

  const fixMoney = (obj, field) => {

    if(typeof obj[field] === 'number' && obj[field] < 1e7 && !Number.isInteger(obj[field])){

      obj[field] = Math.round(obj[field] * 100);

    } else if(typeof obj[field] === 'number' && Number.isInteger(obj[field]) && obj[field] < 100000){

      // 极小整数,可能是旧版"元"的整数,例如 5000元 → 需 ×100

      // 但由于无法100%确定,这里采用保守规则:只有 < 1000 且不是新数据才转换

      // 简单策略:检查 APP.version < 2 时才转换整数

      if(APP.version < 2) obj[field] = obj[field] * 100;

    } else if(typeof obj[field] === 'number'){

      obj[field] = Math.round(obj[field]); // 已是分

    } else {

      obj[field] = 0;

    }

  };

  Object.keys(APP.projects).forEach(key => {

    const p = APP.projects[key] || {};

    p.incomes = p.incomes || [];

    p.expenses = p.expenses || [];

    p.incomes.forEach(i => {

      fixMoney(i, 'amount');

      i.recurring = !!i.recurring;

    });

    p.expenses.forEach(e => {

      fixMoney(e, 'amount');

      if(e.actual !== undefined && e.actual !== null && e.actual !== ''){

        fixMoney(e, 'actual');

      } else {

        e.actual = null;

      }

      e.recurring = !!e.recurring;

    });

    APP.projects[key] = p;

  });

  APP.accounts.forEach(a => fixMoney(a, 'balance'));

  APP.transactions.forEach(t => fixMoney(t, 'amount'));

  APP.repayments.forEach(r => fixMoney(r, 'amount'));

  APP.loans.forEach(l => {

    fixMoney(l, 'amount');

    (l.records || []).forEach(rec => fixMoney(rec, 'amount'));

  });

  if(typeof APP.actualFunds !== 'number') APP.actualFunds = toFen(APP.actualFunds || 0);

  APP.version = 2;

}

/* 保存 + 撤销栈 */

const undoStack = [];

const MAX_UNDO = 20;

let autoSaveTimer = null;

function saveApp(opts){

  opts = opts || {};

  if(!opts.skipUndo){

    undoStack.push(JSON.stringify(APP));

    if(undoStack.length > MAX_UNDO) undoStack.shift();

  }

  IDB.set('main', APP).catch(e => {

    console.error('[saveApp]', e);

    if(e && e.name === 'QuotaExceededError'){

      alert('⚠️ 浏览器存储空间已满,请导出备份后清理。');

    } else {

      showNotification('保存失败:' + (e && e.message || '未知错误'), true);

    }

  });

}

function autoSave(){

  clearTimeout(autoSaveTimer);

  autoSaveTimer = setTimeout(() => saveApp(), 3000);

}

function undo(){

  if(undoStack.length < 2){ showNotification('没有可撤销的操作', true); return; }

  undoStack.pop();

  APP = JSON.parse(undoStack[undoStack.length - 1]);

  IDB.set('main', APP).catch(() => {});

  applyTheme();

  renderAll();

  showNotification('已撤销');

}

/* ================================================================

 *  4. 从旧 localStorage 迁移

 * ================================================================ */

async function migrateFromLocalStorage(){

  const oldIndexes = localStorage.getItem('fundAllocationProjectIndexes');

  if(!oldIndexes) return null;

  console.log('[迁移] 检测到旧版 localStorage 数据,开始迁移...');

  try{

    const idx = JSON.parse(oldIndexes) || [];

    const projects = {};

    idx.forEach(k => {

      const raw = localStorage.getItem('fundAllocation_' + k);

      if(raw){

        try{ projects[k] = JSON.parse(raw); }catch(e){}

      }

    });

    const migrated = {

      version: 2,

      projectIndexes: idx,

      projects,

      accounts: JSON.parse(localStorage.getItem('fundAllocationAccounts') || '[]'),

      transactions: JSON.parse(localStorage.getItem('fundAllocationAccountTransactions') || '[]'),

      repayments: JSON.parse(localStorage.getItem('repaymentPlans') || '[]'),

      loans: JSON.parse(localStorage.getItem('loanPlans') || '[]'),

      actualFunds: parseFloat(localStorage.getItem('actualFunds') || '0'),

      settings: {

        dark: localStorage.getItem('xinling_dark') === 'true',

        bankCollapsed: localStorage.getItem('bankAccountsCollapsed') === 'true'

      },

      _currentKey: ''

    };

    // 关键:迁移前设 version=1 让 normalizeApp 知道要 ×100

    APP = migrated;

    APP.version = 1;

    normalizeApp();

    // 清理旧 key

    const keysToRemove = ['fundAllocationProjectIndexes','fundAllocationAccounts',

      'fundAllocationAccountTransactions','repaymentPlans','loanPlans','actualFunds'];

    keysToRemove.forEach(k => localStorage.removeItem(k));

    idx.forEach(k => localStorage.removeItem('fundAllocation_' + k));

    console.log('[迁移] 完成,共', idx.length, '个项目');

    return APP;

  }catch(e){

    console.error('[迁移] 失败', e);

    return null;

  }

}

/* ================================================================

 *  5. 主题

 * ================================================================ */

function applyTheme(){

  if(APP.settings.dark){

    document.body.classList.add('dark');

    $('theme-toggle').textContent = '☀️';

  } else {

    document.body.classList.remove('dark');

    $('theme-toggle').textContent = '🌙';

  }

}

function toggleTheme(){

  APP.settings.dark = !APP.settings.dark;

  applyTheme();

  saveApp();

  // 重绘图表以适配深色

  setTimeout(() => { renderCategoryPie(); renderYearTrend(); }, 50);

}

/* ================================================================

 *  6. 侧边栏:项目列表 + 搜索 + 年折叠

 * ================================================================ */

let projectFilter = '';

function renderProjectList(){

  const container = $('project-list');

  const empty = $('project-empty-state');

  if(APP.projectIndexes.length === 0){

    empty.style.display = 'block';

    // 保留 empty 节点

    container.innerHTML = '';

    container.appendChild(empty);

    return;

  }

  empty.style.display = 'none';

  const filter = projectFilter.trim().toLowerCase();

  // 按年份分组

  const groups = {};

  APP.projectIndexes.forEach(k => {

    const y = k.slice(0,4);

    if(!groups[y]) groups[y] = [];

    groups[y].push(k);

  });

  const years = Object.keys(groups).sort((a,b) => b - a); // 降序

  let html = '';

  years.forEach(y => {

    const months = groups[y].sort((a,b) => b.localeCompare(a));

    // 过滤

    const filtered = months.filter(k => {

      if(!filter) return true;

      const month = k.slice(4,6);

      return (y + '年').includes(filter) ||

             (y + '-' + month).includes(filter) ||

             (y + month).includes(filter) ||

             (parseInt(month) + '月').includes(filter) ||

             k.includes(filter);

    });

    if(filtered.length === 0) return;

    html += `<details class="project-group" ${filter || y == currentYear ? 'open' : ''}>

      <summary>${y}年 · ${filtered.length}个项目</summary>`;

    filtered.forEach(k => {

      const m = k.slice(4,6);

      const data = APP.projects[k];

      const lu = data ? (data.lastUpdated || '') : '';

      const isActive = k === APP._currentKey ? ' active' : '';

      html += `<div class="project-item${isActive}" data-key="${k}">

        <div class="project-info">

          <div class="project-date">${y}年${m}月</div>

          <div class="project-update-time">${lu}</div>

        </div>

        <div class="project-actions">

          <button class="export-project-btn" title="导出JSON">📤</button>

          <button class="csv-export-btn" title="导出CSV">📊</button>

          <button class="delete-project-btn" title="删除">🗑️</button>

        </div>

      </div>`;

    });

    html += '</details>';

  });

  container.innerHTML = html;

  // 绑定事件

  container.querySelectorAll('.project-item').forEach(item => {

    item.addEventListener('click', e => {

      if(e.target.closest('.delete-project-btn')) return;

      if(e.target.closest('.export-project-btn')) return;

      if(e.target.closest('.csv-export-btn')) return;

      switchProject(item.dataset.key);

    });

    item.querySelector('.delete-project-btn').addEventListener('click', e => {

      e.stopPropagation();

      deleteProject(item.dataset.key);

    });

    item.querySelector('.export-project-btn').addEventListener('click', e => {

      e.stopPropagation();

      exportProject(item.dataset.key);

    });

    item.querySelector('.csv-export-btn').addEventListener('click', e => {

      e.stopPropagation();

      exportProjectCSV(item.dataset.key);

    });

  });

}

function deleteProject(key){

  const m = key.slice(4,6), y = key.slice(0,4);

  if(!confirm(`确定删除 ${y}年${m}月 的项目?此操作不可恢复。`)) return;

  delete APP.projects[key];

  APP.projectIndexes = APP.projectIndexes.filter(k => k !== key);

  if(APP._currentKey === key) resetProjectView();

  saveApp();

  renderProjectList();

  showNotification('已删除');

}

function exportProject(key){

  const data = APP.projects[key];

  if(!data){ showNotification('无数据', true); return; }

  const blob = new Blob([JSON.stringify({ key, data }, null, 2)], { type: 'application/json' });

  const url = URL.createObjectURL(blob);

  const a = document.createElement('a');

  a.href = url; a.download = `资金项目_${key}.json`;

  document.body.appendChild(a); a.click(); document.body.removeChild(a);

  URL.revokeObjectURL(url);

  showNotification('已导出 JSON');

}

function exportProjectCSV(key){

  const p = APP.projects[key];

  if(!p){ showNotification('无数据', true); return; }

  let csv = '\ufeff';  // BOM 防中文乱码

  csv += `项目,${key.slice(0,4)}年${key.slice(4,6)}月\n`;

  csv += `导出时间,${getCurrentTime()}\n\n`;

  csv += '类型,名称,类别,预算金额(元),实际金额(元),定期\n';

  (p.incomes || []).forEach(i => {

    csv += `收入,"${i.name}",${getIncomeCategoryText(i.category)},${toYuan(i.amount).toFixed(2)},,${i.recurring ? '是' : '否'}\n`;

  });

  (p.expenses || []).forEach(e => {

    const actual = e.actual !== null && e.actual !== undefined ? toYuan(e.actual).toFixed(2) : '';

    csv += `支出,"${e.name}",${getExpenseCategoryText(e.category)},${toYuan(e.amount).toFixed(2)},${actual},${e.recurring ? '是' : '否'}\n`;

  });

  csv += '\n汇总\n';

  const inc = sumFen(p.incomes, 'amount');

  const exp = sumFen(p.expenses, 'amount');

  csv += `总收入,${toYuan(inc).toFixed(2)}\n`;

  csv += `总支出,${toYuan(exp).toFixed(2)}\n`;

  csv += `结余,${toYuan(inc - exp).toFixed(2)}\n`;

  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });

  const url = URL.createObjectURL(blob);

  const a = document.createElement('a');

  a.href = url; a.download = `资金明细_${key}.csv`;

  document.body.appendChild(a); a.click(); document.body.removeChild(a);

  URL.revokeObjectURL(url);

  showNotification('已导出 CSV');

}

function switchProject(key){

  if(!key) return;

  APP._currentKey = key;

  const y = key.slice(0,4), m = key.slice(4,6);

  if(!APP.projects[key]){

    // 新建项目

    const newProj = { incomes: [], expenses: [], lastUpdated: getCurrentTime() };

    // 定期项自动填充

    const prevKey = findPrevProjectKey(key);

    if(prevKey && APP.projects[prevKey]){

      const prev = APP.projects[prevKey];

      const recInc = (prev.incomes || []).filter(x => x.recurring);

      const recExp = (prev.expenses || []).filter(x => x.recurring);

      if(recInc.length + recExp.length > 0){

        if(confirm(`检测到 ${recInc.length} 个定期收入、${recExp.length} 个定期支出,是否自动填充?`)){

          newProj.incomes = recInc.map(x => ({ ...x }));

          newProj.expenses = recExp.map(x => ({ ...x, actual: null }));

        }

      }

    }

    APP.projects[key] = newProj;

    if(!APP.projectIndexes.includes(key)) APP.projectIndexes.push(key);

    saveApp();

  }

  $('current-project-text').textContent = `${y}年${m}月`;

  $('project-status').classList.remove('hidden');

  $('add-expense-btn').disabled = false;

  $('show-add-income-btn').disabled = false;

  $('add-income-btn').disabled = false;

  $('edit-project-date-btn').disabled = false;

  $('csv-project-btn').disabled = false;

  // 隐藏可能打开的编辑表单

  $('expense-form-container').classList.add('hidden');

  $('income-form-container').classList.add('hidden');

  renderMonthly();

  renderProjectList();

}

function findPrevProjectKey(key){

  const sorted = APP.projectIndexes.slice().sort();

  const idx = sorted.indexOf(key);

  if(idx > 0) return sorted[idx - 1];

  // 若未在列表中,找小于它的最近一个

  const less = sorted.filter(k => k < key);

  return less.length ? less[less.length - 1] : null;

}

function resetProjectView(){

  APP._currentKey = '';

  $('current-project-text').textContent = '未选择项目';

  $('project-status').classList.add('hidden');

  $('add-expense-btn').disabled = true;

  $('show-add-income-btn').disabled = true;

  $('add-income-btn').disabled = true;

  $('edit-project-date-btn').disabled = true;

  $('csv-project-btn').disabled = true;

  renderMonthly();

}

/* ================================================================

 *  7. 月度规划渲染

 * ================================================================ */

function getExpenseCategoryText(c){

  const map = { house:'住房', food:'餐饮', transport:'交通', shopping:'购物',

    entertainment:'娱乐', health:'健康', education:'教育', saving:'储蓄',

    investment:'投资', other:'其他' };

  return map[c] || '其他';

}

function getIncomeCategoryText(c){

  const map = { salary:'工资', bonus:'奖金', investment:'投资', freelance:'兼职',

    rental:'租金', gift:'礼金', other:'其他' };

  return map[c] || '其他';

}

function renderMonthly(){

  const key = APP._currentKey;

  const data = key ? APP.projects[key] : null;

  if(!data){

    $('total-income').textContent = '0.00 元';

    $('total-expense').textContent = '0.00 元';

    $('remaining-funds').textContent = '0.00 元';

    $('expense-rate').textContent = '0%';

    $('expense-progress').style.setProperty('--progress-width', '0%');

    $('expense-list').innerHTML = '<div class="empty-state"><p>暂无项目</p></div>';

    $('income-list').innerHTML = '<div class="empty-state"><p>暂无项目</p></div>';

    $('expense-stats').textContent = '0 个支出项';

    $('income-stats').textContent = '0 个收入项';

    $('last-updated').textContent = '';

    renderCategoryPie();

    renderYearTrend();

    return;

  }

  const inc = sumFen(data.incomes, 'amount');

  const exp = sumFen(data.expenses, 'amount');

  const rem = inc - exp;

  const rate = inc > 0 ? Math.min(100, (exp / inc) * 100) : 0;

  $('total-income').textContent = fmtYuan(inc);

  $('total-expense').textContent = fmtYuan(exp);

  $('remaining-funds').textContent = fmtYuan(rem);

  $('expense-rate').textContent = rate.toFixed(1) + '%';

  $('expense-progress').style.setProperty('--progress-width', rate + '%');

  if(rem < 0){

    $('remaining-funds').classList.add('negative');

    $('project-status').textContent = '超支';

    $('project-status').className = 'project-status status-completed';

  } else if(rate >= 100){

    $('remaining-funds').classList.remove('negative');

    $('project-status').textContent = '预算已满';

    $('project-status').className = 'project-status status-completed';

  } else {

    $('remaining-funds').classList.remove('negative');

    $('project-status').textContent = '规划中';

    $('project-status').className = 'project-status status-in-progress';

  }

  $('expense-stats').textContent = data.expenses.length + ' 个支出项';

  $('income-stats').textContent = data.incomes.length + ' 个收入项';

  $('last-updated').textContent = data.lastUpdated || '';

  $('annual-last-updated').textContent = getCurrentTime();

  renderExpenseList();

  renderIncomeList();

  renderCategoryPie();

  renderYearTrend();

}

function renderExpenseList(){

  const data = APP.projects[APP._currentKey];

  const box = $('expense-list');

  if(!data || data.expenses.length === 0){

    box.innerHTML = '<div class="empty-state"><p>暂无支出项</p><p class="guide-text">点击「添加支出项」开始规划</p></div>';

    return;

  }

  let html = '';

  data.expenses.forEach((e, i) => {

    // 预算对比进度

    let budgetHtml = '';

    if(e.actual !== null && e.actual !== undefined && e.amount > 0){

      const pct = Math.min(200, (e.actual / e.amount) * 100);

      const cls = pct < 80 ? 'budget-ok' : (pct <= 100 ? 'budget-warn' : 'budget-over');

      budgetHtml = `<div class="budget-row">

        <span>实际 ${toYuan(e.actual).toFixed(2)}</span>

        <div class="budget-bar"><div class="budget-fill ${cls}" style="width:${Math.min(100,pct)}%"></div></div>

        <span>${pct.toFixed(0)}%</span>

      </div>`;

    }

    html += `<div class="allocation-item">

      <div class="allocation-info">

        <div class="allocation-name">${escapeHtml(e.name)}${e.recurring ? ' <span class="badge-status">定期</span>' : ''}</div>

        <div class="allocation-category">${getExpenseCategoryText(e.category)}</div>

        ${budgetHtml}

      </div>

      <div class="allocation-amount">${toYuan(e.amount).toFixed(2)} 元</div>

      <div class="actions">

        <button class="edit-btn" data-index="${i}" data-type="expense">编辑</button>

        <button class="delete-btn" data-index="${i}" data-type="expense">删除</button>

      </div>

    </div>`;

  });

  box.innerHTML = html;

  box.querySelectorAll('.edit-btn[data-type="expense"]').forEach(b => b.addEventListener('click', () => editExpense(+b.dataset.index)));

  box.querySelectorAll('.delete-btn[data-type="expense"]').forEach(b => b.addEventListener('click', () => deleteExpense(+b.dataset.index)));

}

function renderIncomeList(){

  const data = APP.projects[APP._currentKey];

  const box = $('income-list');

  if(!data || data.incomes.length === 0){

    box.innerHTML = '<div class="empty-state"><p>暂无收入项</p><p class="guide-text">点击「添加收入项」记录</p></div>';

    return;

  }

  let html = '';

  data.incomes.forEach((i, idx) => {

    html += `<div class="income-item">

      <div class="allocation-info">

        <div class="allocation-name">${escapeHtml(i.name)}${i.recurring ? ' <span class="badge-status">定期</span>' : ''}</div>

        <div class="allocation-category">${getIncomeCategoryText(i.category)}</div>

      </div>

      <div class="income-amount">${toYuan(i.amount).toFixed(2)} 元</div>

      <div class="actions">

        <button class="edit-btn" data-index="${idx}" data-type="income">编辑</button>

        <button class="delete-btn" data-index="${idx}" data-type="income">删除</button>

      </div>

    </div>`;

  });

  box.innerHTML = html;

  box.querySelectorAll('.edit-btn[data-type="income"]').forEach(b => b.addEventListener('click', () => editIncome(+b.dataset.index)));

  box.querySelectorAll('.delete-btn[data-type="income"]').forEach(b => b.addEventListener('click', () => deleteIncome(+b.dataset.index)));

}

function escapeHtml(s){

  return String(s == null ? '' : s)

    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')

    .replace(/"/g,'&quot;').replace(/'/g,'&#39;');

}

/* ================================================================

 *  8. 图表:分类饼图 + 年度趋势

 * ================================================================ */

let catPieChart, yearLineChart;

function renderCategoryPie(){

  const data = APP.projects[APP._currentKey];

  const ctx = $('categoryPie');

  if(!ctx) return;

  if(catPieChart){ catPieChart.destroy(); catPieChart = null; }

  if(!data || data.expenses.length === 0){

    // 空状态画布清空

    const c2d = ctx.getContext('2d');

    c2d.clearRect(0, 0, ctx.width, ctx.height);

    return;

  }

  const cats = {};

  data.expenses.forEach(e => { cats[e.category] = (cats[e.category] || 0) + e.amount; });

  const labels = Object.keys(cats).map(getExpenseCategoryText);

  const values = Object.values(cats).map(v => toYuan(v));

  const isDark = APP.settings.dark;

  const textColor = isDark ? '#cbd5e1' : '#475569';

  catPieChart = new Chart(ctx, {

    type: 'doughnut',

    data: {

      labels,

      datasets: [{

        data: values,

        backgroundColor: ['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6',

                          '#06b6d4','#ec4899','#84cc16','#f97316','#6b7280']

      }]

    },

    options: {

      responsive: true,

      maintainAspectRatio: false,

      plugins: {

        legend: { position: 'right', labels: { color: textColor, font: { size: 11 }, boxWidth: 12 } },

        tooltip: { callbacks: { label: c => `${c.label}: ¥${c.parsed.toFixed(2)}` } }

      }

    }

  });

}

function renderYearTrend(){

  const ctx = $('yearTrend');

  if(!ctx) return;

  if(yearLineChart){ yearLineChart.destroy(); yearLineChart = null; }

  const year = APP._currentKey ? APP._currentKey.slice(0,4) : currentYear;

  const labels = [];

  const values = [];

  for(let m = 1; m <= 12; m++){

    const key = `${year}${String(m).padStart(2,'0')}`;

    const p = APP.projects[key];

    const bal = p ? sumFen(p.incomes, 'amount') - sumFen(p.expenses, 'amount') : 0;

    labels.push(m + '月');

    values.push(toYuan(bal));

  }

  const isDark = APP.settings.dark;

  const textColor = isDark ? '#cbd5e1' : '#475569';

  const gridColor = isDark ? 'rgba(148,163,184,.15)' : 'rgba(100,116,139,.12)';

  yearLineChart = new Chart(ctx, {

    type: 'line',

    data: {

      labels,

      datasets: [{

        label: `${year}年结余`,

        data: values,

        borderColor: '#3b82f6',

        backgroundColor: 'rgba(59,130,246,.15)',

        fill: true,

        tension: 0.35,

        pointRadius: 3,

        pointBackgroundColor: '#3b82f6'

      }]

    },

    options: {

      responsive: true,

      maintainAspectRatio: false,

      plugins: { legend: { labels: { color: textColor, font: { size: 11 } } } },

      scales: {

        x: { ticks: { color: textColor, font: { size: 10 } }, grid: { color: gridColor } },

        y: { ticks: { color: textColor, font: { size: 10 } }, grid: { color: gridColor } }

      }

    }

  });

}

/* ================================================================

 *  9. 支出/收入 编辑

 * ================================================================ */

function editExpense(i){

  const e = APP.projects[APP._currentKey].expenses[i];

  $('expense-form-title').textContent = '编辑支出项';

  $('expense-edit-index').value = i;

  $('expense-name').value = e.name;

  $('expense-category').value = e.category;

  $('expense-amount').value = toYuan(e.amount);

  $('expense-actual').value = (e.actual !== null && e.actual !== undefined) ? toYuan(e.actual) : '';

  $('expense-recurring').checked = !!e.recurring;

  $('expense-form-container').classList.remove('hidden');

}

function deleteExpense(i){

  if(!confirm('确定删除这个支出项?')) return;

  APP.projects[APP._currentKey].expenses.splice(i, 1);

  APP.projects[APP._currentKey].lastUpdated = getCurrentTime();

  saveApp();

  renderMonthly();

  showNotification('已删除');

}

function editIncome(i){

  const inc = APP.projects[APP._currentKey].incomes[i];

  $('income-form-title').textContent = '编辑收入项';

  $('income-edit-index').value = i;

  $('income-name').value = inc.name;

  $('income-category').value = inc.category;

  $('income-amount').value = toYuan(inc.amount);

  $('income-recurring').checked = !!inc.recurring;

  $('income-form-container').classList.remove('hidden');

}

function deleteIncome(i){

  if(!confirm('确定删除这个收入项?')) return;

  APP.projects[APP._currentKey].incomes.splice(i, 1);

  APP.projects[APP._currentKey].lastUpdated = getCurrentTime();

  saveApp();

  renderMonthly();

  showNotification('已删除');

}

/* ================================================================

 *  10. 账户管理

 * ================================================================ */

function renderAccounts(){

  // 银行卡列表

  const banks = APP.accounts.filter(a => a.type === 'bank');

  const wallets = APP.accounts.filter(a => a.type === 'wallet');

  let bHtml = banks.length === 0 ? '<div class="empty-state"><p>暂无银行卡</p></div>' : '';

  banks.forEach(a => { bHtml += accountItemHtml(a); });

  $('bank-accounts-list').innerHTML = bHtml;

  let wHtml = wallets.length === 0 ? '<div class="empty-state"><p>暂无钱包</p></div>' : '';

  wallets.forEach(a => { wHtml += accountItemHtml(a); });

  $('wallet-accounts-list').innerHTML = wHtml;

  // 绑定事件

  document.querySelectorAll('.toggle-hidden-btn').forEach(btn => {

    btn.addEventListener('click', e => {

      e.stopPropagation();

      toggleAccountHidden(btn.dataset.id);

    });

  });

  document.querySelectorAll('.edit-btn[data-type="account"]').forEach(b => {

    b.addEventListener('click', e => { e.stopPropagation(); editAccount(b.dataset.id); });

  });

  document.querySelectorAll('.delete-btn[data-type="account"]').forEach(b => {

    b.addEventListener('click', e => { e.stopPropagation(); deleteAccount(b.dataset.id); });

  });

  // 总额

  const total = APP.accounts.reduce((s, a) => s + a.balance, 0);

  $('accounts-total-balance').textContent = fmtYuan(total);

  $('accounts-total-sidebar').textContent = fmtYuan(total);

  $('accounts-last-updated').textContent = getCurrentTime();

  // 侧边栏

  const visible = APP.accounts;

  if(visible.length === 0){

    $('accounts-sidebar-list').innerHTML = '<div class="empty-state" style="padding:12px 0"><p style="font-size:.85rem">暂无账户</p></div>';

  } else {

    let html = '';

    visible.slice(0, 10).forEach(a => {

      const t = a.type === 'bank' ? 'badge-bank' : 'badge-wallet';

      const tt = a.type === 'bank' ? '卡' : '包';

      html += `<div class="account-item-small">

        <div class="account-name-small"><span class="account-type-badge ${t}">${tt}</span>${escapeHtml(a.name)}</div>

        <div class="account-balance-small">${toYuan(a.balance).toFixed(2)}</div>

      </div>`;

    });

    $('accounts-sidebar-list').innerHTML = html;

  }

  // 转账下拉

  let fOpts = '<option value="">转出账户</option>';

  let tOpts = '<option value="">转入账户</option>';

  APP.accounts.forEach(a => {

    const label = `${a.name} (${toYuan(a.balance).toFixed(2)})`;

    fOpts += `<option value="${a.id}">${escapeHtml(label)}</option>`;

    tOpts += `<option value="${a.id}">${escapeHtml(label)}</option>`;

  });

  $('transfer-from').innerHTML = fOpts;

  $('transfer-to').innerHTML = tOpts;

}

function accountItemHtml(a){

  const balance = toYuan(a.balance).toFixed(2);

  return `<div class="account-item ${a.type}">

    <div class="account-info">

      <div class="account-name">${escapeHtml(a.name)}</div>

      <div class="account-details">

        ${a.bank ? '<span>' + escapeHtml(a.bank) + '</span>' : ''}

        ${a.number ? '<span>' + escapeHtml(a.number) + '</span>' : ''}

      </div>

    </div>

    <div class="account-balance ${a.type === 'wallet' ? 'wallet-balance' : ''}">${balance} 元</div>

    <div class="actions">

      <button class="edit-btn" data-id="${a.id}" data-type="account">编辑</button>

      <button class="delete-btn" data-id="${a.id}" data-type="account">删除</button>

    </div>

  </div>`;

}

function toggleAccountHidden(id){

  const acc = APP.accounts.find(a => a.id === id);

  if(!acc) return;

  acc.hidden = !acc.hidden;

  saveApp();

  renderAccounts();

}

function editAccount(id){

  const acc = APP.accounts.find(a => a.id === id);

  if(!acc) return;

  // 隐藏另一个表单

  $('bank-form-wrapper').style.display = 'none';

  $('wallet-form-wrapper').style.display = 'none';

  if(acc.type === 'bank'){

    $('bank-form-wrapper').style.display = 'block';

    $('bank-edit-id').value = acc.id;

    $('bank-name').value = acc.name;

    $('bank-number').value = acc.number || '';

    $('bank-bank').value = acc.bank || '';

    $('bank-balance').value = toYuan(acc.balance);

    $('bank-notes').value = acc.notes || '';

  } else {

    $('wallet-form-wrapper').style.display = 'block';

    $('wallet-edit-id').value = acc.id;

    $('wallet-name').value = acc.name;

    $('wallet-number').value = acc.number || '';

    $('wallet-bank').value = acc.bank || '';

    $('wallet-balance').value = toYuan(acc.balance);

    $('wallet-notes').value = acc.notes || '';

  }

}

function deleteAccount(id){

  const a = APP.accounts.find(x => x.id === id);

  if(!a) return;

  if(!confirm(`删除账户「${a.name}」?`)) return;

  APP.accounts = APP.accounts.filter(x => x.id !== id);

  saveApp();

  renderAccounts();

  showNotification('已删除');

}

function handleAccountSubmit(e, type){

  e.preventDefault();

  const name = $(type + '-name').value.trim();

  const number = $(type + '-number').value.trim();

  const bank = $(type + '-bank').value.trim();

  const balanceFen = toFen($(type + '-balance').value);

  const notes = $(type + '-notes').value.trim();

  const editId = $(type + '-edit-id').value;

  if(!name || balanceFen < 0){

    showNotification('请填写完整信息', true);

    return;

  }

  if(editId){

    const idx = APP.accounts.findIndex(a => a.id === editId);

    if(idx !== -1){

      APP.accounts[idx] = { ...APP.accounts[idx], name, number, bank, balance: balanceFen, notes, updated: getCurrentTime() };

    }

  } else {

    APP.accounts.push({

      id: generateId(), name, type, number, bank, balance: balanceFen, notes,

      created: getCurrentTime(), updated: getCurrentTime()

    });

  }

  saveApp();

  renderAccounts();

  $(type + '-form-wrapper').style.display = 'none';

  $(type + '-account-form').reset();

  $(type + '-edit-id').value = '';

  showNotification(editId ? '已更新' : '已添加');

}

function executeTransfer(fid, tid, amtFen, notes){

  if(fid === tid){ showNotification('不能转到同一个账户', true); return false; }

  const f = APP.accounts.find(x => x.id === fid);

  const t = APP.accounts.find(x => x.id === tid);

  if(!f || !t){ showNotification('账户不存在', true); return false; }

  if(f.balance < amtFen){ showNotification('转出账户余额不足', true); return false; }

  f.balance -= amtFen;

  t.balance += amtFen;

  APP.transactions.push({

    id: generateId(), from: fid, fromName: f.name, to: tid, toName: t.name,

    amount: amtFen, notes: notes || '', date: getCurrentTime()

  });

  saveApp();

  renderAccounts();

  return true;

}

/* ================================================================

 *  11. 债务管理

 * ================================================================ */

let debtFundChart, debtStatusChart;

let tempLoanRecords = [];

function formatMoney(fen){ return '¥' + toYuan(fen).toFixed(2); }

function formatDateStr(s){

  if(!s) return '';

  const d = new Date(s);

  return `${d.getFullYear()}.${d.getMonth()+1}.${d.getDate()}`;

}

function getRepayDaysLeft(item){

  const today = new Date(); today.setHours(0,0,0,0);

  if(item.cycle === 'once'){

    const target = new Date(item.date); target.setHours(0,0,0,0);

    return Math.ceil((target - today) / 86400000);

  }

  if(item.cycle === 'monthly'){

    const dayOfMonth = new Date(item.date).getDate();

    const current = new Date(today);

    let targetDate = new Date(current.getFullYear(), current.getMonth(), dayOfMonth);

    if(targetDate < today) targetDate = new Date(current.getFullYear(), current.getMonth()+1, dayOfMonth);

    if(targetDate.getDate() !== dayOfMonth) targetDate = new Date(targetDate.getFullYear(), targetDate.getMonth()+1, 0);

    return Math.ceil((targetDate - today) / 86400000);

  }

  return 999;

}

function getLoanDaysLeft(loan){

  const today = new Date(); today.setHours(0,0,0,0);

  if(loan.type === 'fixed'){

    const targetDay = new Date(loan.expectedDate).getDate();

    const current = new Date(today);

    let due = new Date(current.getFullYear(), current.getMonth(), targetDay);

    if(due < today) due = new Date(current.getFullYear(), current.getMonth()+1, targetDay);

    if(due.getDate() !== targetDay) due = new Date(due.getFullYear(), due.getMonth()+1, 0);

    return Math.ceil((due - today) / 86400000);

  }

  const due = new Date(loan.expectedDate); due.setHours(0,0,0,0);

  return Math.ceil((due - today) / 86400000);

}

function renderRepaymentList(){

  const box = $('repaymentListContainer');

  if(APP.repayments.length === 0){

    box.innerHTML = '<div class="empty-state">📭 暂无还款计划</div>';

    return;

  }

  let html = '';

  APP.repayments.forEach((r, idx) => {

    const days = getRepayDaysLeft(r);

    const cls = days < 0 ? 'overdue' : (days <= 7 ? 'warning' : 'normal');

    const statusText = days < 0 ? '逾期' : (days === 0 ? '今日' : `${days}天`);

    html += `<div class="repayment-item">

      <div class="item-info">

        <div class="item-name">${escapeHtml(r.name)} <span class="badge-status">${escapeHtml(r.type)}</span></div>

        <div class="item-desc">还款日: ${formatDateStr(r.date)}</div>

      </div>

      <div class="item-amount">${formatMoney(r.amount)}</div>

      <div class="item-countdown"><div class="countdown-days ${cls}">${Math.abs(days)}</div><div style="font-size:.7rem;color:var(--gray-500)">${statusText}</div></div>

      <div class="item-actions">

        <button class="btn" style="background:var(--warning-light);color:var(--warning)" onclick="editRepayment(${idx})">编辑</button>

        <button class="btn" style="background:var(--danger-light);color:var(--danger)" onclick="deleteRepayment(${idx})">删除</button>

      </div>

    </div>`;

  });

  box.innerHTML = html;

}

function renderLoanList(){

  const box = $('loanListContainer');

  if(APP.loans.length === 0){

    box.innerHTML = '<div class="empty-state">💬 暂无对外借款</div>';

    return;

  }

  let html = '';

  APP.loans.forEach((loan, idx) => {

    const total = loan.amount;

    const paid = (loan.records || []).reduce((s, r) => s + r.amount, 0);

    const remaining = total - paid;

    const days = getLoanDaysLeft(loan);

    const cls = days < 0 ? 'overdue' : (days <= 7 ? 'warning' : 'normal');

    html += `<div class="loan-item">

      <div class="item-info">

        <div class="item-name">${escapeHtml(loan.name)}</div>

        <div class="item-desc">已还 ${formatMoney(paid)} · 剩 ${formatMoney(remaining)}</div>

      </div>

      <div class="item-amount">${formatMoney(total)}</div>

      <div class="item-countdown"><div class="countdown-days ${cls}">${Math.abs(days)}</div></div>

      <div class="item-actions">

        <button class="btn" style="background:var(--warning-light);color:var(--warning)" onclick="editLoan(${idx})">编辑</button>

        <button class="btn" style="background:var(--danger-light);color:var(--danger)" onclick="deleteLoan(${idx})">删除</button>

      </div>

    </div>`;

  });

  box.innerHTML = html;

}

function updateDebtSummary(){

  const totalRepay = APP.repayments.reduce((s, r) => s + r.amount, 0);

  const totalLoanOut = APP.loans.reduce((s, l) => s + l.amount, 0);

  const totalFunds = APP.actualFunds + totalLoanOut;

  const totalPaidBack = APP.loans.reduce((s, l) => {

    return s + (l.records || []).reduce((ss, r) => ss + r.amount, 0);

  }, 0);

  $('summaryGrid').innerHTML = `

    <div class="summary-card">

      <div class="summary-label">💰 总资金 <span class="edit-funds-icon" id="editActualFundsBtn">✎</span></div>

      <div class="summary-value">${formatMoney(totalFunds)}</div>

    </div>

    <div class="summary-card">

      <div class="summary-label">📉 待还款</div>

      <div class="summary-value" style="color:var(--danger)">${formatMoney(totalRepay)}</div>

    </div>

    <div class="summary-card">

      <div class="summary-label">📤 借出</div>

      <div class="summary-value" style="color:var(--success)">${formatMoney(totalLoanOut)}</div>

    </div>

    <div class="summary-card">

      <div class="summary-label">✅ 已收回</div>

      <div class="summary-value" style="color:var(--primary)">${formatMoney(totalPaidBack)}</div>

    </div>`;

  const isDark = APP.settings.dark;

  const textColor = isDark ? '#cbd5e1' : '#475569';

  // 图1:待还 vs 借出

  const ctx1 = $('fundChart');

  if(debtFundChart) debtFundChart.destroy();

  debtFundChart = new Chart(ctx1, {

    type: 'doughnut',

    data: {

      labels: ['待还款', '借出'],

      datasets: [{ data: [toYuan(totalRepay), toYuan(totalLoanOut)], backgroundColor: ['#e11d48', '#10b981'] }]

    },

    options: { plugins: { legend: { labels: { color: textColor } } } }

  });

  // 图2:借款状态(未还 / 已收回)

  const ctx2 = $('loanStatusChart');

  if(debtStatusChart) debtStatusChart.destroy();

  debtStatusChart = new Chart(ctx2, {

    type: 'bar',

    data: {

      labels: ['未收回', '已收回'],

      datasets: [{

        data: [toYuan(totalLoanOut - totalPaidBack), toYuan(totalPaidBack)],

        backgroundColor: ['#f59e0b', '#10b981']

      }]

    },

    options: {

      plugins: { legend: { display: false } },

      scales: {

        x: { ticks: { color: textColor }, grid: { display: false } },

        y: { ticks: { color: textColor } }

      }

    }

  });

}

window.editRepayment = (idx) => {

  const r = APP.repayments[idx];

  $('repayEditIndex').value = idx;

  $('repayName').value = r.name;

  $('repayAmount').value = toYuan(r.amount);

  $('repayCycle').value = r.cycle;

  $('repayDate').value = r.date;

  $('repayType').value = r.type;

  $('repaymentModal').classList.add('show');

};

window.deleteRepayment = (idx) => {

  if(!confirm('确定删除这条还款计划?')) return;

  APP.repayments.splice(idx, 1);

  saveApp();

  renderRepaymentList();

  updateDebtSummary();

  showNotification('已删除');

};

window.editLoan = (idx) => {

  const loan = APP.loans[idx];

  $('loanEditIndex').value = idx;

  $('loanName').value = loan.name;

  $('loanAmount').value = toYuan(loan.amount);

  $('loanTypeSelect').value = loan.type;

  $('loanStartDate').value = loan.date;

  $('loanDueDate').value = loan.expectedDate;

  tempLoanRecords = (loan.records || []).map(r => ({ amount: r.amount, date: r.date }));

  renderTempRecords();

  $('loanModal').classList.add('show');

};

window.deleteLoan = (idx) => {

  if(!confirm('确定删除这条借款?')) return;

  APP.loans.splice(idx, 1);

  saveApp();

  renderLoanList();

  updateDebtSummary();

  showNotification('已删除');

};

function renderTempRecords(){

  const box = $('loanRecordsList');

  if(tempLoanRecords.length === 0){

    box.innerHTML = '<div style="text-align:center;color:var(--gray-500);padding:10px;font-size:.85rem">暂无还款记录</div>';

  } else {

    let html = '';

    tempLoanRecords.forEach((rec, i) => {

      html += `<div style="display:flex;justify-content:space-between;align-items:center;padding:4px 6px;border-bottom:1px solid var(--gray-200)">

        <span style="font-size:.85rem">${formatMoney(rec.amount)} · ${formatDateStr(rec.date)}</span>

        <button class="btn" style="background:var(--danger-light);color:var(--danger);padding:2px 8px;font-size:.72rem" onclick="deleteTempRecord(${i})">删除</button>

      </div>`;

    });

    box.innerHTML = html;

  }

  const totalPaid = tempLoanRecords.reduce((s, r) => s + r.amount, 0);

  const loanAmt = toFen($('loanAmount').value) || 0;

  $('totalPaidDisplay').textContent = formatMoney(totalPaid);

  $('remainingDisplay').textContent = formatMoney(loanAmt - totalPaid);

}

window.deleteTempRecord = (i) => {

  tempLoanRecords.splice(i, 1);

  renderTempRecords();

};

/* ================================================================

 *  12. 年度汇总

 * ================================================================ */

function getAllAvailableYears(){

  const years = new Set();

  APP.projectIndexes.forEach(k => years.add(parseInt(k.slice(0,4))));

  const arr = Array.from(years);

  if(arr.length === 0) arr.push(currentYear);

  return arr.sort((a,b) => a - b);

}

function getMonthlyBalancesByYear(year){

  const list = [];

  for(let m = 1; m <= 12; m++){

    const key = `${year}${String(m).padStart(2,'0')}`;

    const p = APP.projects[key];

    const bal = p ? (sumFen(p.incomes, 'amount') - sumFen(p.expenses, 'amount')) : 0;

    list.push({ month: m, balance: bal });

  }

  return list;

}

function renderBalanceList(items){

  const container = $('monthly-balance-container');

  if(items.every(it => it.balance === 0)){

    container.innerHTML = '<div class="empty-state"><p>暂无月度数据</p></div>';

    return;

  }

  let html = '';

  items.forEach(it => {

    const cls = it.balance > 0 ? 'status-surplus' : (it.balance < 0 ? 'status-deficit' : 'status-zero');

    const txt = it.balance > 0 ? '盈余' : (it.balance < 0 ? '超支' : '持平');

    const color = it.balance > 0 ? 'var(--success)' : (it.balance < 0 ? 'var(--danger)' : 'var(--gray-600)');

    html += `<div class="allocation-item">

      <div class="allocation-info"><div class="allocation-name">${it.month}月</div></div>

      <div class="allocation-amount" style="color:${color}">${toYuan(it.balance).toFixed(2)} 元</div>

      <span class="badge-status" style="background:${cls === 'status-surplus' ? 'var(--success-light);color:var(--success)' : cls === 'status-deficit' ? 'var(--danger-light);color:var(--danger)' : 'var(--gray-200);color:var(--gray-600)'}">${txt}</span>

    </div>`;

  });

  container.innerHTML = html;

}

function renderSingleYear(year){

  const items = getMonthlyBalancesByYear(year);

  const total = items.reduce((s, i) => s + i.balance, 0);

  renderBalanceList(items);

  $('single-year-title').textContent = `${year}年 剩余资金总计`;

  $('single-year-total-amount').textContent = '¥ ' + toYuan(total).toFixed(2);

  $('annual-last-updated').textContent = getCurrentTime();

}

function renderRangeTotal(){

  const s = parseInt($('start-year').value), e = parseInt($('end-year').value);

  if(s > e){ showNotification('起始年份不能大于结束年份', true); return; }

  let total = 0; const all = [];

  for(let y = s; y <= e; y++){

    const items = getMonthlyBalancesByYear(y);

    all.push(...items);

    total += items.reduce((sum, it) => sum + it.balance, 0);

  }

  $('range-display-text').textContent = `${s}-${e}年`;

  $('range-total-amount').textContent = '¥ ' + toYuan(total).toFixed(2);

  renderBalanceList(all);

  $('annual-last-updated').textContent = getCurrentTime();

}

function initAnnualPanel(){

  const years = getAllAvailableYears();

  const fill = (sel, defVal) => {

    sel.innerHTML = '';

    years.forEach(y => {

      const opt = document.createElement('option');

      opt.value = y; opt.textContent = y + '年';

      sel.appendChild(opt);

    });

    if(defVal) sel.value = defVal;

  };

  fill($('annual-year'), currentYear);

  fill($('start-year'), years[0]);

  fill($('end-year'), currentYear);

  renderSingleYear(currentYear);

}

/* ================================================================

 *  13. 数据导入导出

 * ================================================================ */

function exportAllData(){

  const payload = {

    exportTime: getCurrentTime(),

    app: '芯灵资金规划 V2',

    version: 2,

    data: APP

  };

  const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });

  const url = URL.createObjectURL(blob);

  const a = document.createElement('a');

  a.href = url;

  a.download = `芯灵资金规划_备份_${Date.now()}.json`;

  document.body.appendChild(a); a.click(); document.body.removeChild(a);

  URL.revokeObjectURL(url);

  showNotification('已导出全部数据');

}

function importAllData(json){

  if(!json) return;

  // 兼容新格式 { data: APP } 和旧格式 { projects, projectIndexes }

  let imported;

  if(json.data && json.data.projects){

    imported = json.data;

  } else if(json.projects && json.projectIndexes){

    imported = {

      version: 1,

      projectIndexes: json.projectIndexes,

      projects: json.projects,

      accounts: json.accounts || [],

      transactions: json.transactions || [],

      repayments: json.repayments || [],

      loans: json.loans || [],

      actualFunds: json.actualFunds || 0,

      settings: { dark: false, bankCollapsed: false },

      _currentKey: ''

    };

  } else {

    showNotification('文件格式错误', true);

    return;

  }

  if(!confirm(`将导入 ${(imported.projectIndexes || []).length} 个项目。\n\n点「确定」= 覆盖当前全部数据\n点「取消」= 合并追加`)){

    // 合并:项目按 key 去重,账户/债务追加

    const existingKeys = new Set(APP.projectIndexes);

    (imported.projectIndexes || []).forEach(k => {

      if(!existingKeys.has(k)) APP.projectIndexes.push(k);

      APP.projects[k] = imported.projects[k]; // 覆盖同 key 项目

    });

    (imported.accounts || []).forEach(a => {

      if(!APP.accounts.find(x => x.id === a.id)) APP.accounts.push(a);

    });

    (imported.repayments || []).forEach(r => APP.repayments.push(r));

    (imported.loans || []).forEach(l => APP.loans.push(l));

    saveApp();

    renderAll();

    showNotification('已合并导入');

    return;

  }

  // 覆盖

  APP = imported;

  APP._currentKey = '';

  APP.version = 1;

  normalizeApp();

  applyTheme();

  saveApp();

  renderAll();

  showNotification('已覆盖导入');

}

/* ================================================================

 *  14. 全局渲染

 * ================================================================ */

function renderAll(){

  renderProjectList();

  if(APP._currentKey) switchProject(APP._currentKey);

  else renderMonthly();

  renderAccounts();

  renderRepaymentList();

  renderLoanList();

  updateDebtSummary();

  initAnnualPanel();

  // 年月份选择器状态同步

  if(APP._currentKey){

    const y = APP._currentKey.slice(0,4), m = APP._currentKey.slice(4,6);

    $('year-select').value = y;

    $('month-select').value = parseInt(m);

  }

}

/* ================================================================

 *  15. 事件绑定

 * ================================================================ */

function bindEvents(){

  // 标签页

  document.querySelectorAll('.tab-btn').forEach(btn => {

    btn.addEventListener('click', () => {

      if(btn.closest('.view-type-toggle') || btn.dataset.view) return;

      document.querySelectorAll('.tabs .tab-btn').forEach(b => b.classList.remove('active'));

      document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));

      btn.classList.add('active');

      const tab = btn.dataset.tab;

      if(tab) $(tab + '-panel').classList.add('active');

      if(tab === 'annual') initAnnualPanel();

      if(tab === 'debt') updateDebtSummary();

    });

  });

  // 主题

  $('theme-toggle').addEventListener('click', toggleTheme);

  // 年月切换

  $('year-select').addEventListener('change', () => {

    const k = `${$('year-select').value}${String($('month-select').value).padStart(2,'0')}`;

    switchProject(k);

  });

  $('month-select').addEventListener('change', () => {

    const k = `${$('year-select').value}${String($('month-select').value).padStart(2,'0')}`;

    switchProject(k);

  });

  // 新建项目

  $('create-project-btn').addEventListener('click', () => {

    const k = `${$('year-select').value}${String($('month-select').value).padStart(2,'0')}`;

    switchProject(k);

  });

  // 改年月

  $('edit-project-date-btn').addEventListener('click', () => {

    if(!APP._currentKey) return;

    $('modal-year-select').value = APP._currentKey.slice(0,4);

    $('modal-month-select').value = parseInt(APP._currentKey.slice(4,6));

    $('edit-date-modal').classList.add('show');

  });

  $('modal-cancel-btn').addEventListener('click', () => $('edit-date-modal').classList.remove('show'));

  $('modal-confirm-btn').addEventListener('click', () => {

    if(!APP._currentKey) return;

    const ny = $('modal-year-select').value;

    const nm = String($('modal-month-select').value).padStart(2,'0');

    const nk = ny + nm;

    if(nk === APP._currentKey){ $('edit-date-modal').classList.remove('show'); return; }

    if(APP.projects[nk] && !confirm('目标月份已存在项目,是否覆盖?')) return;

    APP.projects[nk] = APP.projects[APP._currentKey];

    APP.projects[nk].lastUpdated = getCurrentTime();

    delete APP.projects[APP._currentKey];

    APP.projectIndexes = APP.projectIndexes.filter(k => k !== APP._currentKey);

    if(!APP.projectIndexes.includes(nk)) APP.projectIndexes.push(nk);

    saveApp();

    $('edit-date-modal').classList.remove('show');

    switchProject(nk);

    showNotification('已修改');

  });

  // 支出/收入表单

  $('add-expense-btn').addEventListener('click', () => {

    $('expense-form').reset();

    $('expense-edit-index').value = '';

    $('expense-form-title').textContent = '添加支出项';

    $('expense-form-container').classList.remove('hidden');

    $('expense-actual').value = '';

    $('expense-recurring').checked = false;

  });

  $('add-income-btn').addEventListener('click', () => {

    $('income-form').reset();

    $('income-edit-index').value = '';

    $('income-form-title').textContent = '添加收入项';

    $('income-form-container').classList.remove('hidden');

    $('income-recurring').checked = false;

  });

  $('show-add-income-btn').addEventListener('click', () => {

    $('add-income-btn').click();

  });

  $('cancel-expense-btn').addEventListener('click', () => {

    $('expense-form-container').classList.add('hidden');

  });

  $('cancel-income-btn').addEventListener('click', () => {

    $('income-form-container').classList.add('hidden');

  });

  $('expense-form').addEventListener('submit', e => {

    e.preventDefault();

    if(!APP._currentKey){ showNotification('请先选择项目', true); return; }

    const n = $('expense-name').value.trim();

    const c = $('expense-category').value;

    const amtFen = toFen($('expense-amount').value);

    const actualRaw = $('expense-actual').value.trim();

    const actualFen = actualRaw === '' ? null : toFen(actualRaw);

    const recurring = $('expense-recurring').checked;

    if(!n || amtFen <= 0){ showNotification('输入无效', true); return; }

    const i = $('expense-edit-index').value;

    const data = APP.projects[APP._currentKey];

    const item = { name: n, category: c, amount: amtFen, actual: actualFen, recurring };

    if(i !== '') data.expenses[+i] = item;

    else data.expenses.push(item);

    data.lastUpdated = getCurrentTime();

    saveApp();

    renderMonthly();

    $('expense-form-container').classList.add('hidden');

    showNotification(i !== '' ? '已更新' : '已添加');

  });

  $('income-form').addEventListener('submit', e => {

    e.preventDefault();

    if(!APP._currentKey){ showNotification('请先选择项目', true); return; }

    const n = $('income-name').value.trim();

    const c = $('income-category').value;

    const amtFen = toFen($('income-amount').value);

    const recurring = $('income-recurring').checked;

    if(!n || amtFen <= 0){ showNotification('输入无效', true); return; }

    const i = $('income-edit-index').value;

    const data = APP.projects[APP._currentKey];

    const item = { name: n, category: c, amount: amtFen, recurring };

    if(i !== '') data.incomes[+i] = item;

    else data.incomes.push(item);

    data.lastUpdated = getCurrentTime();

    saveApp();

    renderMonthly();

    $('income-form-container').classList.add('hidden');

    showNotification(i !== '' ? '已更新' : '已添加');

  });

  // 收支切换

  document.querySelectorAll('.type-toggle-btn').forEach(btn => {

    btn.addEventListener('click', () => {

      document.querySelectorAll('.type-toggle-btn').forEach(b => {

        b.classList.remove('active', 'income-active');

      });

      if(btn.dataset.type === 'income') btn.classList.add('income-active');

      else btn.classList.add('active');

      if(btn.dataset.type === 'expense'){

        $('expense-section').classList.remove('hidden');

        $('income-section').classList.add('hidden');

      } else {

        $('expense-section').classList.add('hidden');

        $('income-section').classList.remove('hidden');

      }

    });

  });

  // 导出/导入

  $('export-btn').addEventListener('click', exportAllData);

  $('import-input').addEventListener('change', e => {

    const f = e.target.files[0];

    if(!f) return;

    const r = new FileReader();

    r.onload = ev => {

      try{ importAllData(JSON.parse(ev.target.result)); }

      catch(ex){ showNotification('解析失败', true); }

    };

    r.readAsText(f);

    e.target.value = '';

  });

  // 撤销

  $('undo-btn').addEventListener('click', undo);

  document.addEventListener('keydown', e => {

    if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z'){

      e.preventDefault();

      undo();

    }

  });

  // 搜索

  $('project-search').addEventListener('input', e => {

    projectFilter = e.target.value;

    renderProjectList();

  });

  // 账户:银行卡折叠

  $('toggle-bank-accounts-btn').addEventListener('click', () => {

    const content = $('bank-account-content');

    const collapsed = content.style.display === 'none';

    if(collapsed){

      content.style.display = '';

      $('toggle-bank-accounts-btn').textContent = '收起';

      APP.settings.bankCollapsed = false;

    } else {

      content.style.display = 'none';

      $('toggle-bank-accounts-btn').textContent = '展开';

      APP.settings.bankCollapsed = true;

    }

    saveApp();

  });

  // 添加银行卡/钱包 折叠表单

  $('add-bank-account-btn').addEventListener('click', () => {

    const w = $('bank-form-wrapper');

    if(w.style.display === 'block'){

      w.style.display = 'none';

    } else {

      $('wallet-form-wrapper').style.display = 'none';

      $('bank-account-form').reset();

      $('bank-edit-id').value = '';

      w.style.display = 'block';

    }

  });

  $('add-wallet-account-btn').addEventListener('click', () => {

    const w = $('wallet-form-wrapper');

    if(w.style.display === 'block'){

      w.style.display = 'none';

    } else {

      $('bank-form-wrapper').style.display = 'none';

      $('wallet-account-form').reset();

      $('wallet-edit-id').value = '';

      w.style.display = 'block';

    }

  });

  document.querySelectorAll('.cancel-account-btn').forEach(btn => {

    btn.addEventListener('click', () => {

      const t = btn.dataset.target;

      $(t + '-form-wrapper').style.display = 'none';

    });

  });

  $('bank-account-form').addEventListener('submit', e => handleAccountSubmit(e, 'bank'));

  $('wallet-account-form').addEventListener('submit', e => handleAccountSubmit(e, 'wallet'));

  // 转账

  $('transfer-form').addEventListener('submit', e => {

    e.preventDefault();

    const f = $('transfer-from').value, t = $('transfer-to').value;

    const amtFen = toFen($('transfer-amount').value);

    const notes = $('transfer-notes').value.trim();

    if(!f || !t || amtFen <= 0){ showNotification('输入无效', true); return; }

    if(executeTransfer(f, t, amtFen, notes)){

      showNotification('转账成功');

      $('transfer-form').reset();

    }

  });

  $('cancel-transfer-btn').addEventListener('click', () => $('transfer-form').reset());

  // 债务:还款

  $('addRepaymentBtn').addEventListener('click', () => {

    $('repaymentForm').reset();

    $('repayEditIndex').value = '';

    $('repayDate').value = new Date().toISOString().slice(0,10);

    $('repaymentModal').classList.add('show');

  });

  $('closeRepayModal').addEventListener('click', () => $('repaymentModal').classList.remove('show'));

  $('repaymentForm').addEventListener('submit', e => {

    e.preventDefault();

    const idx = $('repayEditIndex').value;

    const newPlan = {

      name: $('repayName').value.trim(),

      amount: toFen($('repayAmount').value),

      cycle: $('repayCycle').value,

      date: $('repayDate').value,

      type: $('repayType').value.trim()

    };

    if(idx !== '') APP.repayments[+idx] = newPlan;

    else APP.repayments.push(newPlan);

    saveApp();

    $('repaymentModal').classList.remove('show');

    renderRepaymentList();

    updateDebtSummary();

    showNotification('已保存');

  });

  // 债务:借款

  $('addLoanBtn').addEventListener('click', () => {

    $('loanForm').reset();

    $('loanEditIndex').value = '';

    tempLoanRecords = [];

    renderTempRecords();

    $('loanStartDate').value = new Date().toISOString().slice(0,10);

    $('loanDueDate').value = new Date().toISOString().slice(0,10);

    $('loanModal').classList.add('show');

  });

  $('closeLoanModal').addEventListener('click', () => $('loanModal').classList.remove('show'));

  $('addRecordBtn').addEventListener('click', () => {

    const amt = $('recordAmountInput').value;

    const date = $('recordDateInput').value;

    if(!amt || !date){ showNotification('请填写金额和日期', true); return; }

    tempLoanRecords.push({ amount: toFen(amt), date });

    $('recordAmountInput').value = '';

    $('recordDateInput').value = '';

    renderTempRecords();

  });

  $('loanForm').addEventListener('submit', e => {

    e.preventDefault();

    const idx = $('loanEditIndex').value;

    const loanObj = {

      name: $('loanName').value.trim(),

      amount: toFen($('loanAmount').value),

      type: $('loanTypeSelect').value,

      date: $('loanStartDate').value,

      expectedDate: $('loanDueDate').value,

      records: [...tempLoanRecords]

    };

    if(idx !== '') APP.loans[+idx] = loanObj;

    else APP.loans.push(loanObj);

    saveApp();

    $('loanModal').classList.remove('show');

    renderLoanList();

    updateDebtSummary();

    showNotification('已保存');

  });

  // 点击 ✎ 修改总资金

  document.addEventListener('click', e => {

    if(e.target && e.target.id === 'editActualFundsBtn'){

      askNumber('编辑实际可用资金(元)', toYuan(APP.actualFunds).toFixed(2), v => {

        APP.actualFunds = toFen(v);

        saveApp();

        updateDebtSummary();

        showNotification('已更新');

      });

    }

  });

  // 年度汇总视图切换

  document.querySelectorAll('.view-type-toggle .tab-btn').forEach(btn => {

    btn.addEventListener('click', () => {

      document.querySelectorAll('.view-type-toggle .tab-btn').forEach(b => b.classList.remove('active'));

      btn.classList.add('active');

      if(btn.dataset.view === 'single'){

        document.querySelector('.year-range-filter').classList.add('hidden');

        $('single-year-total-card').classList.remove('hidden');

        document.querySelector('.range-total-card').classList.add('hidden');

        renderSingleYear($('annual-year').value);

      } else {

        document.querySelector('.year-range-filter').classList.remove('hidden');

        $('single-year-total-card').classList.add('hidden');

        document.querySelector('.range-total-card').classList.remove('hidden');

        renderRangeTotal();

      }

    });

  });

  $('annual-year').addEventListener('change', () => renderSingleYear($('annual-year').value));

  $('calc-range-btn').addEventListener('click', renderRangeTotal);

  // 弹窗遮罩关闭

  document.querySelectorAll('.modal-overlay').forEach(ov => {

    ov.addEventListener('click', e => {

      if(e.target === ov) ov.classList.remove('show');

    });

  });

}

/* ================================================================

 *  16. 启动

 * ================================================================ */

async function boot(){

  try{

    // 1. 尝试从 IndexedDB 读取

    let stored = await IDB.get('main');

    if(stored){

      APP = stored;

      APP.version = 2;

      normalizeApp();

    } else {

      // 2. 尝试迁移旧 localStorage

      const migrated = await migrateFromLocalStorage();

      if(migrated){

        saveApp();

        showNotification('已从旧版数据自动迁移');

      } else {

        // 3. 全新用户

        normalizeApp();

        saveApp();

      }

    }

    // 应用设置

    applyTheme();

    if(APP.settings.bankCollapsed){

      const content = $('bank-account-content');

      if(content){

        content.style.display = 'none';

        $('toggle-bank-accounts-btn').textContent = '展开';

      }

    }

    // 生成年份选项

    const yearSelectEl = $('year-select');

    let html = '';

    for(let y = currentYear - 5; y <= currentYear + 5; y++){

      html += `<option value="${y}" ${y === currentYear ? 'selected' : ''}>${y}年</option>`;

    }

    yearSelectEl.innerHTML = html;

    $('month-select').value = currentMonth;

    // 模态框年份

    let mHtml = '';

    for(let y = currentYear - 5; y <= currentYear + 5; y++){

      mHtml += `<option value="${y}">${y}年</option>`;

    }

    $('modal-year-select').innerHTML = mHtml;

    // 默认项目

    const defKey = `${currentYear}${String(currentMonth).padStart(2,'0')}`;

    if(APP.projectIndexes.includes(defKey)){

      APP._currentKey = defKey;

    } else if(APP.projectIndexes.length > 0){

      APP._currentKey = APP.projectIndexes[APP.projectIndexes.length - 1];

    }

    // 绑定事件

    bindEvents();

    // 首次渲染

    renderAll();

    if(APP._currentKey) switchProject(APP._currentKey);

    console.log('%c💎 芯灵资金规划 V2 已就绪', 'color:#3b82f6;font-weight:bold;font-size:14px');

    console.log('  · IndexedDB 存储 | 整数分记账 | 撤销栈 | 定期项 | 图表 | 深色模式');

  }catch(e){

    console.error('启动失败', e);

    document.body.insertAdjacentHTML('afterbegin',

      `<div style="padding:20px;background:#fee2e2;color:#991b1b;font-family:monospace">

        启动失败:${e.message}<br>请检查浏览器是否支持 IndexedDB

      </div>`);

  }

}

window.addEventListener('DOMContentLoaded', boot);

</script>

</body>

</html>

相关学习资料