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,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''');
}
/* ================================================================
* 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>