diff --git a/plugins/loan-calculator/README.md b/plugins/loan-calculator/README.md index b15ac404c..f3ccd63a3 100644 --- a/plugins/loan-calculator/README.md +++ b/plugins/loan-calculator/README.md @@ -1,6 +1,6 @@ # 贷款计算器(loan-calculator) -> 一个面向房贷场景的多功能 ZTools 插件:房贷计算、方案对比、提前还款、浮动利率模拟、真实利率(IRR 反算)、历史记录。 +> 一个面向房贷场景的多功能 ZTools 插件:房贷计算、贷款计算器(通用)、方案对比、提前还款、浮动利率模拟、真实利率(IRR 反算)、历史记录。 本插件为**单文件 HTML 插件**(无需构建),以 `index.html` 为主入口,`preload.js` 提供历史记录的本地持久化能力,所有计算与界面均在渲染进程内完成,开箱即用。 @@ -19,6 +19,14 @@ - 结果展示:月供、利息总额、还款总额;饼图(本金/利息占比)+ 月供趋势图 - 一键**导出 CSV**、**复制结果** +### 💰 贷款计算器(通用) + +- 沿「房贷计算器」框架生成的**通用贷款**页签,**去除**购房套数、贷款类型(LPR)、加点 BP 等抵押专属输入,仅保留:贷款金额(元)、利率类型、利率、期数、还款方式 +- **利率类型**三选,默认**年利率**:年利率 / 月利率 / 日利率(期数单位随类型在「月 / 天」间自动切换) +- **还款方式**三种:等额本息 / 等额本金 / **先息后本**(前 N-1 期仅还利息,末期利息+本金) +- 结果展示:每期还款、利息总额、还款总额;饼图(本金/利息占比)+ 剩余本金趋势图 +- 一键**导出 CSV**、**复制结果**,并支持存入历史记录 + ### ⚖️ 方案对比 - 方案 A / 方案 B 双栏**等宽**并排对比 @@ -99,7 +107,7 @@ loan-calculator/ ## 🚀 安装与使用 -1. 下载构建产物 `loan-calculator-1.9.0.zpx`(见 `releases/`,或 PR 附件)。 +1. 下载构建产物 `loan-calculator-1.9.2.zpx`(见 `releases/`,或 PR 附件)。 2. 在 ZTools 中「导入插件 / 安装本地插件」,选择该 `.zpx` 文件。 3. 通过指令 `贷款计算器` / `房贷` / `loan` 唤起,或在插件列表点击打开。 @@ -133,7 +141,7 @@ node tests/test-variable-rate.js node tests/test-method-switch.js ``` -当前测试覆盖:浮动利率核心计算(32)、中途切换还款方式(12)、导出 CSV / 跨页签交互修复(101)、真实利率 IRR 反算(27),共 **173 条断言全部通过**。 +当前测试覆盖:浮动利率核心计算(32)、中途切换还款方式(12)、导出 CSV / 跨页签交互修复(101)、真实利率 IRR 反算(32)、贷款计算器通用页签(29)、贷款计算器 QA 校验(27),共 **233 条断言全部通过**。 --- @@ -150,7 +158,9 @@ node tests/test-method-switch.js | v1.7.0 | 四项交互修复:①切公积金隐藏商业利率区 ②房贷结果一键加入方案对比(A,原 A→B) ③方案对比 A/B 等宽 ④跨页签自动缓存 | | v1.8.0 | 四项功能优化:①组合贷贯穿对比/提前还款/利率演变三页 ②多页 CSV 导出 + 历史批量管理(搜索/筛选/多选/批量删除/重命名) ③利率联网健壮性(超时/重试/失败区分)+ LPR 走势图 ④DTI 跨页评估 + 等额本息/本金智能建议 | | v1.8.1 | 新增「分期利率」页签:依据 PRD 用 IRR 法反算真实月利率,输出名义年化 APR、实际年化 IRR、总利息与还款明细;内置余额归零校验,支持复制/CSV 导出/历史保存。优化「每期还款」输入提示:根据分期方式(等额本息/等额本金/先息后本)与本金、期数、手续费实时给出每期还款下限,并在参数异常时提示具体阈值 | -| v1.9.0 | 作者改为 Samson;「分期利率」页签更名为「真实利率(IRR)」(更贴合由月供反算真实年化利率的本质),并与「历史记录」页签互换位置(真实利率置第 5、历史记录置第 6);提示文案实时回显实际填入的本金与期数 | +| v1.9.0 | 作者改为 Samson;「分期利率」页签更名为「真实利率(IRR)」(更贴合由月供反算真实年化利率的本质),并与「历史记录」页签互换位置(真实利率置第 5、历史记录置第 6);提示文案实时回显实际填入的本金与期数。新增「贷款计算器」通用页签(沿房贷框架、去除抵押专属输入,支持日/月/年利率与先息后本还款方式),修复校验失败时按钮死锁 | +| v1.9.1 | 10 项体验与稳健性优化:统一金额格式化 `fmtYuan2`、重构贷款明细构建、清理冗余代码、`showLoanErr` 无错误时清空提示、利率类型上限校验、输入无障碍属性补齐、输入实时重算防抖、表格/复制文本重构、结果区防止溢出等 | +| v1.9.2 | 「贷款计算器」切换年利率 / 月利率 / 日利率时自动填入合理默认值(4.9 / 0.4 / 0.013),避免切换后利率为空导致误算;补齐 README 与页面版本号一致性 | --- diff --git a/plugins/loan-calculator/index.html b/plugins/loan-calculator/index.html index 1b26a39dc..8fcc17b77 100644 --- a/plugins/loan-calculator/index.html +++ b/plugins/loan-calculator/index.html @@ -1,8 +1,8 @@ - - - - + + + + 贷款计算器 - -
-
- -

贷款计算器

- 房贷 · 真实利率(IRR) · 方案对比 · 提前还款 · 浮动利率 · 历史记录 - v1.9.0 + +
+
+ +

贷款计算器

+ 房贷 · 贷款计算器 · 方案对比 · 提前还款 · 浮动利率 · 真实利率(IRR) · 历史记录 + v1.9.2
-
- - - - - - +
+ + + + + + +
-
-
-
-

贷款信息

+
+
+
+

贷款信息

-
- - + + -
首套:商业 LPR-20BP · 公积金 2.6%(5年内 2.1%)
+
首套:商业 LPR-20BP · 公积金 2.6%(5年内 2.1%)
-
- -
- - - +
+ +
+ + +
-
- -
+
+ +
-
+ +
+
+
+

贷款信息

+ +
+ +
+
+ +
+ +
+ + + +
+
年利率/月利率按期数(月)计息;日利率按期数(天)计息
+
+ +
+ +
%
+
+ +
+ +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + +
+ +
+ +
+
+
+
每期还款
+
+
+
+
+ +
+
还款总额
+
贷款本金
+
支付利息
+
+ +
+
+
本金 / 利息占比
+
+
+
+
剩余本金趋势
+
+
+
+ +
+
+

还款明细

+
+ + +
+
+
+ + + +
+
+
+ + 1 / 1 + +
+
+
+
+
+
+ -
-
-
-

方案 A

-
-
+
+
+
+

方案 A

+
+
-
-
-
-
-
-
-

原贷款信息

-
-
+
+
+
+
+

原贷款信息

+
+
-
-
-
-
-
-
-

贷款信息

-
-
+
+
+
+
+

贷款信息

+
+
-
-
-
-
-
-

历史记录

-
- - + - +
-
- - - +
+ + +
-
+
-
-
-
-

分期参数

- -
- -
-
+
+
+
+

分期参数

+ +
+ +
+
-
- -
- - - +
+ +
+ + +
-
- -
-
+
+ +
+
-
- -
-
-
+
+ +
+
+
-
- -
-
+
+ +
+
-
- -
- - +
+ +
+ +
- +
-
-
-
-
真实月利率(IRR 反算)
-
+
+
+
+
真实月利率(IRR 反算)
+
-
+
-
-
名义年化 APR(单利)
-
实际年化 IRR(复利)
-
总利息
+
+
名义年化 APR(单利)
+
实际年化 IRR(复利)
+
总利息
-
+
-
-
-

还款明细

-
- + + - +
- - - +
+
+ +
-
- - 1 / 1 -
-
+
+ + 1 / 1 + +
+
@@ -853,46 +977,61 @@

还款明细

/* ===================== 计算核心 ===================== */ const wan = 10000; -function equalInstallment(principal, annualRate, months) { - const r = annualRate / 12; - let monthly; - if (r === 0) monthly = principal / months; - else monthly = principal * r * Math.pow(1 + r, months) / (Math.pow(1 + r, months) - 1); - const schedule = []; - let balance = principal, totalInterest = 0; - for (let i = 1; i <= months; i++) { - const interest = balance * r; - let p = monthly - interest; - let pay = monthly; - if (i === months) { p = balance; pay = p + interest; } - balance -= p; if (balance < 0) balance = 0; - totalInterest += interest; - schedule.push({ period: i, payment: pay, principal: p, interest, balance }); - } - return { monthly, firstMonthly: monthly, schedule, totalInterest, totalPayment: principal + totalInterest, totalPrincipal: principal }; -} - -function equalPrincipal(principal, annualRate, months) { - const r = annualRate / 12; - const base = principal / months; +/* 通用摊销引擎(按「每期」利率计算,支持等额本息 / 等额本金 / 先息后本)。 + * 所有页签(房贷 / 贷款 / 方案对比 / 提前还款 / 浮动利率)共用本函数, + * computeLoan 直接委托本函数,避免双引擎重复实现、保证数值结果一致。 + * 参数 perPeriodRate 为每期十进制利率(非年利率),periods 为期数。 + * 返回 { monthly, firstMonthly, schedule, totalInterest, totalPayment, totalPrincipal }, + * schedule 元素含 { period, payment, principal, interest, balance, rate, method },便于复用 renderPie/renderTrend。 */ +function buildLoanSchedule(principal, perPeriodRate, periods, method) { + const r = perPeriodRate; const schedule = []; - let balance = principal, totalInterest = 0, firstMonthly = 0; - for (let i = 1; i <= months; i++) { - const interest = balance * r; - let p = (i === months) ? balance : base; - const pay = p + interest; + let balance = principal, totalInterest = 0, firstMonthly = 0, monthly = 0; + const pushRow = (i, pay, p, interest) => { balance -= p; if (balance < 0) balance = 0; totalInterest += interest; if (i === 1) firstMonthly = pay; - schedule.push({ period: i, payment: pay, principal: p, interest, balance }); + schedule.push({ period: i, payment: pay, principal: p, interest, balance, rate: r, method }); + }; + if (method === 'equal-principal') { + // 等额本金:每期本金固定,利息按剩余本金计,末期本金=余额 + const base = principal / periods; + for (let i = 1; i <= periods; i++) { + const interest = balance * r; + const p = (i === periods) ? balance : base; + pushRow(i, p + interest, p, interest); + } + monthly = firstMonthly; + } else if (method === 'interest-only') { + // 先息后本:前 periods-1 期只还利息,末期利息+本金;每期利息=剩余本金×r(本金未还前恒定) + for (let i = 1; i <= periods; i++) { + const interest = balance * r; + const p = (i === periods) ? balance : 0; + pushRow(i, interest + p, p, interest); + } + monthly = firstMonthly; + } else { + // 等额本息 + if (r === 0) monthly = principal / periods; + else monthly = principal * r * Math.pow(1 + r, periods) / (Math.pow(1 + r, periods) - 1); + if (!isFinite(monthly) || monthly <= 0) monthly = principal / periods; // P1-1: 极端参数兜底,避免 NaN/非正月供 + for (let i = 1; i <= periods; i++) { + const interest = balance * r; + let p = monthly - interest; + let pay = monthly; + if (i === periods) { p = balance; pay = p + interest; } + pushRow(i, pay, p, interest); + } + if (schedule.length) monthly = schedule[0].payment; } - return { firstMonthly, schedule, totalInterest, totalPayment: principal + totalInterest, totalPrincipal: principal }; + return { monthly, firstMonthly, schedule, totalInterest, totalPayment: principal + totalInterest, totalPrincipal: principal }; } +/* 统一入口:所有页签共用 buildLoanSchedule(按每期利率计算)。 + * 调用方传入的 rate 为「年利率」的十进制(如 0.049),此处 ÷12 转成每期利率, + * 与 buildLoanSchedule 以每期利率计算保持一致,保证数值结果不变。 */ function computeLoan(principal, annualRate, months, method) { - return method === 'equal-principal' - ? equalPrincipal(principal, annualRate, months) - : equalInstallment(principal, annualRate, months); + return buildLoanSchedule(principal, annualRate / 12, months, method); } /* ===================== 工具 ===================== */ @@ -901,6 +1040,21 @@

还款明细

function fmtMoney(y) { return (y / wan).toFixed(2) + '万'; } function fmtYuan(y) { return '¥' + Math.round(y).toLocaleString('zh-CN'); } function f2(y) { return y.toFixed(2); } +/* 两位小写的金额展示(与 CSV toFixed(2) 对齐),用于明细表单元格 */ +function fmtYuan2(y) { return '¥' + (y || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } +/* P2-1: 明细表通用渲染(表头 + 分页 + 行)。各页签 render*Detail 复用,opts 控制列/单位,不改变可见文案与行数 */ +function renderScheduleTable(tbodyId, theadId, slice, opts) { + const tbody = $(tbodyId); if (!tbody) return; + tbody.innerHTML = slice.map(opts.renderRow).join(''); + const thead = $(theadId); if (thead) thead.innerHTML = opts.thead; + const pageEl = $(opts.pageId); if (pageEl) pageEl.textContent = opts.page + ' / ' + opts.total; + const prevEl = $(opts.prevId); if (prevEl) prevEl.disabled = opts.page <= 1; + const nextEl = $(opts.nextId); if (nextEl) nextEl.disabled = opts.page >= opts.total; +} +/* P2-1: 统一的「复制结果」文本拼装,6 个页签复制按钮复用,保证输出格式一致 */ +function buildCopyText(title, lines) { + return '【' + title + '】\n' + lines.join('\n'); +} /* ===================== 通用:Toast / 复制 / 校验 ===================== */ let _toastTimer = null; @@ -1019,11 +1173,12 @@

还款明细

updateEffHints(); } +const EASTMONEY_TOKEN = '894050c76af8597a853f5b408b759f5d'; // 东方财富接口令牌;失效时在此替换一处即可(联网失败自动回退内置值) async function fetchLatestRates() { const status = $('rate-status'); status.textContent = '联网获取中…'; status.className = 'rate-status loading'; - const url = 'https://datacenter.eastmoney.com/api/data/get?type=RPTA_WEB_RATE&sty=ALL&token=894050c76af8597a853f5b408b759f5d&p=1&ps=1&st=TRADE_DATE&sr=-1&var=WPuRCBoA'; + const url = 'https://datacenter.eastmoney.com/api/data/get?type=RPTA_WEB_RATE&sty=ALL&token=' + EASTMONEY_TOKEN + '&p=1&ps=1&st=TRADE_DATE&sr=-1&var=WPuRCBoA'; let lastErr = ''; for (let attempt = 1; attempt <= 2; attempt++) { const ctrl = ('AbortController' in window) ? new AbortController() : null; @@ -1148,7 +1303,7 @@

还款明细

const rate = calcState.type === 'fund' ? effRate('calc-rate-fund', 'calc-bp-fund') : effRate('calc-rate-commercial', 'calc-bp-commercial'); - if (rate <= 0 || rate > 20) errs.push('实际利率需在 0–20% 之间'); + if (rate < 0 || rate > 20) errs.push('实际利率需在 0–20% 之间'); return errs; } function showCalcErr(msgs) { @@ -1194,10 +1349,18 @@

还款明细

}; }); +let _calcRecomputeTimer = null; ['calc-rate-commercial','calc-rate-fund','calc-bp-commercial','calc-bp-fund', 'cmp-a-rate','cmp-a-bp','cmp-b-rate','cmp-b-bp','pp-rate','pp-bp'].forEach(id => { const el = $(id); if (!el) return; - el.oninput = () => { updateEffHints(); if (id.indexOf('calc-') === 0 && calcResult) runCalc(); }; + el.oninput = () => { + updateEffHints(); + if (id.indexOf('calc-') === 0 && calcResult) { + // P2-2: 房贷页利率实时重算加 ~200ms 防抖,避免连续输入时频繁重算;校验提示保持即时 + if (_calcRecomputeTimer) clearTimeout(_calcRecomputeTimer); + _calcRecomputeTimer = setTimeout(runCalc, 200); + } + }; }); // P0-4: 实时校验 @@ -1368,7 +1531,7 @@

还款明细

const y2 = cy - r * Math.cos(pAngle); const largeArc = pAngle > Math.PI ? 1 : 0; $('pie-chart').innerHTML = - '' + + '' + '' + '' + '本金' + @@ -1428,13 +1591,16 @@

还款明细

if (calcPage > total) calcPage = total; const start = (calcPage - 1) * PAGE; const slice = data.slice(start, start + PAGE); - $('calc-tbody').innerHTML = slice.map(r => { - const c0 = mode === 'year' ? r.year + ' 年' : r.period + ' 期'; - return '' + c0 + '' + fmtYuan(r.payment) + '' + fmtYuan(r.principal) + '' + fmtYuan(r.interest) + '' + fmtYuan(r.balance) + ''; - }).join(''); - $('calc-page').textContent = calcPage + ' / ' + total; - $('calc-prev').disabled = calcPage <= 1; - $('calc-next').disabled = calcPage >= total; + renderScheduleTable('calc-tbody', 'calc-thead', slice, { + thead: mode === 'year' + ? '年份年还本息年还本金年还利息年末剩余' + : '期数月供本金利息剩余本金', + page: calcPage, total, pageId: 'calc-page', prevId: 'calc-prev', nextId: 'calc-next', + renderRow: (r) => { + const c0 = mode === 'year' ? (r.year + ' 年') : (r.period + ' 期'); + return '' + c0 + '' + fmtYuan2(r.payment) + '' + fmtYuan2(r.principal) + '' + fmtYuan2(r.interest) + '' + fmtYuan2(r.balance) + ''; + } + }); } /* P1-7 / P2-10: 通用 CSV 下载(带 BOM,失败回退新窗口) */ @@ -1514,6 +1680,214 @@

还款明细

setTimeout(() => { $('calc-save').textContent = '保存到历史'; }, 1500); }; +/* ===================== 贷款计算器(通用,新增) ===================== */ +/* 金额单位用「元」(与「真实利率」页一致,契合通用贷款场景);利率类型 year/month/day 切换期数单位。 */ +const RATE_TYPE_LABEL = { year: '年利率', month: '月利率', day: '日利率' }; +const DEFAULT_RATE = { year: 4.9, month: 0.4, day: 0.013 }; // 切换利率类型时填入的合理默认值 +let loanResult = null; +let loanPage = 1; +const LOAN_PAGE = 12; +let loanRateType = 'year'; // year | month | day +let loanMethod = 'equal-installment'; +let loanPerPeriodRate = 0; // 每期利率(十进制) +let loanRate = 0; // 输入利率(%) +let loanPeriods = 0; // 输入期数 +let loanAmount = 0; // 输入本金(元) +let loanTotalPeriods = 0; // 实际期数 + +const getLoanRateType = bindSeg('loan-rate-type', v => { + const prev = loanRateType; + loanRateType = v; + if (v !== prev) { // 仅在真正切换类型时填入该类型合理默认值,避免重复点击覆盖手填值 + const rateInput = $('loan-rate'); + if (rateInput && DEFAULT_RATE[v] != null) rateInput.value = DEFAULT_RATE[v]; + } + const unit = $('loan-period-unit'); + if (unit) unit.textContent = (v === 'day') ? '天' : '月'; + showLoanErr(validateLoan()); +}); +const getLoanMethod = bindSeg('loan-method', v => { loanMethod = v; showLoanErr(validateLoan()); }); + +function validateLoan() { + const amount = parseFloat($('loan-amount').value); + const rate = parseFloat($('loan-rate').value); + const periods = parseInt($('loan-periods').value, 10); + if (!(amount > 0)) return '贷款金额需大于 0'; + if (isNaN(rate) || rate < 0) return '利率需 ≥ 0'; + const RATE_CAP = { year: 100, month: 10, day: 1 }; // 按利率类型设合理上限,避免误填(如日利率填成 1000%) + if (rate > RATE_CAP[loanRateType]) return '利率过大(' + RATE_TYPE_LABEL[loanRateType] + '上限 ' + RATE_CAP[loanRateType] + '%)'; + if (!(periods >= 1) || !Number.isInteger(periods)) return '还款期数需为 ≥ 1 的整数'; + if (loanRateType === 'day' ? periods > 3650 : periods > 600) return '期数过大,请缩小后重试'; + if (!RATE_TYPE_LABEL[loanRateType]) return '利率类型不合法'; + if (!METHOD_LABELS[loanMethod]) return '还款方式不合法'; + return ''; +} + +function showLoanErr(msg) { + const box = $('loan-err'); + const btn = $('loan-btn'); + if (msg) { box.textContent = '⚠ ' + msg; box.classList.add('show'); if (btn) btn.disabled = true; } + else { box.textContent = ''; box.classList.remove('show'); if (btn) btn.disabled = false; } +} + +function runLoan() { + const err = validateLoan(); + if (err) { showLoanErr(err); return; } + showLoanErr(''); + const amount = parseFloat($('loan-amount').value) || 0; + const rate = parseFloat($('loan-rate').value) || 0; + const periods = parseInt($('loan-periods').value, 10); + // 换算每期利率:year → 年利率/12;month/day → 输入%直接作为每期% + const perPeriodRate = (loanRateType === 'year') ? (rate / 100 / 12) : (rate / 100); + const res = buildLoanSchedule(amount, perPeriodRate, periods, loanMethod); + + loanResult = res; + loanRate = rate; loanPeriods = periods; loanAmount = amount; + loanPerPeriodRate = perPeriodRate; loanTotalPeriods = periods; + markComputed('loan'); + loanPage = 1; + + // highlight 文案 + const label = $('loan-month-label'); + const note = $('loan-month-note'); + if (loanMethod === 'equal-principal') { + label.textContent = '首期还款'; + const dec = res.schedule.length > 1 ? res.schedule[0].payment - res.schedule[1].payment : 0; + note.textContent = '每期递减约 ' + fmtYuan(dec); + } else if (loanMethod === 'interest-only') { + label.textContent = '每期利息(到期还本)'; + note.textContent = '前 ' + (periods - 1) + ' 期仅利息,第 ' + periods + ' 期利息+本金'; + } else { + label.textContent = (loanRateType === 'year') ? '每期还款(按月)' : '每期还款'; + note.textContent = ''; + } + $('loan-month').textContent = fmtYuan(res.firstMonthly); + $('loan-total').textContent = fmtYuan(res.totalPayment); + $('loan-principal').textContent = fmtYuan(res.totalPrincipal); + $('loan-interest').textContent = fmtYuan(res.totalInterest); + + renderPie(res.totalPrincipal, res.totalInterest); + renderTrend(res.schedule); + renderLoanDetail(); +} + +function renderLoanDetail() { + if (!loanResult) return; + const mode = $('loan-detail-mode').value; + const rows = loanResult.schedule; + const perYear = (loanRateType === 'day') ? 365 : 12; // 按年聚合:月利率12期/年,日利率365天/年 + let data = []; + if (mode === 'year') { + const groups = {}; + rows.forEach(r => { + const y = Math.ceil(r.period / perYear); + if (!groups[y]) groups[y] = { year: y, payment: 0, principal: 0, interest: 0, balance: 0, rate: r.rate, method: r.method }; + groups[y].payment += r.payment; groups[y].principal += r.principal; + groups[y].interest += r.interest; groups[y].balance = r.balance; + }); + data = Object.values(groups); + } else { + data = rows; + } + const total = Math.ceil(data.length / LOAN_PAGE); + if (loanPage > total) loanPage = total; + const start = (loanPage - 1) * LOAN_PAGE; + const slice = data.slice(start, start + LOAN_PAGE); + const periodUnit = (loanRateType === 'day') ? '天' : '期'; + const thead = mode === 'year' + ? '年份年还本息(元)年还本金(元)年还利息(元)年末剩余(元)执行利率(%)还款方式' + : '期数每期还款(元)本金(元)利息(元)剩余本金(元)执行利率(%)还款方式'; + renderScheduleTable('loan-tbody', 'loan-thead', slice, { + thead, page: loanPage, total, pageId: 'loan-page', prevId: 'loan-prev', nextId: 'loan-next', + renderRow: (r) => { + const c0 = (mode === 'year') ? (r.year + ' 年') : (r.period + ' ' + periodUnit); + const ratePct = (r.rate * 100).toFixed(4); + return '' + c0 + '' + fmtYuan2(r.payment) + '' + fmtYuan2(r.principal) + '' + fmtYuan2(r.interest) + '' + fmtYuan2(r.balance) + '' + ratePct + '' + METHOD_LABELS[r.method] + ''; + } + }); + const hint = $('loan-detail-hint'); + if (hint) hint.textContent = (mode === 'year') + ? ('按年聚合:' + (loanRateType === 'day' ? '每 365 天' : '每 12 期') + '为 1 年,共 ' + data.length + ' 行') + : ('按期展示,共 ' + data.length + ' 行'); +} + +function loanCsvRows() { + const rows = [['期数', '每期还款(元)', '本金(元)', '利息(元)', '剩余本金(元)', '执行利率(%)', '还款方式']]; + loanResult.schedule.forEach(r => { + rows.push([r.period, r.payment.toFixed(2), r.principal.toFixed(2), r.interest.toFixed(2), r.balance.toFixed(2), (r.rate * 100).toFixed(4), METHOD_LABELS[r.method]]); + }); + return rows; +} +function exportLoanCsv() { + if (!loanResult) { showToast('请先计算'); return; } + downloadCsv('贷款计算器明细.csv', toCsv(loanCsvRows())); +} + +$('loan-btn').onclick = runLoan; +// 数字输入实时校验:校验失败时 #loan-btn 会被禁用,需随输入重新校验以免用户无法恢复 +['loan-amount', 'loan-rate', 'loan-periods'].forEach(id => { + const el = $(id); + if (el) el.addEventListener('input', () => showLoanErr(validateLoan())); +}); +$('loan-detail-mode').onchange = () => { loanPage = 1; renderLoanDetail(); }; +$('loan-prev').onclick = () => { if (loanPage > 1) { loanPage--; renderLoanDetail(); } }; +$('loan-next').onclick = () => { loanPage++; renderLoanDetail(); }; +$('loan-export-csv').onclick = exportLoanCsv; + +$('loan-save').onclick = () => { + if (!loanResult) runLoan(); + if (!loanResult) return; + const rtLabel = RATE_TYPE_LABEL[loanRateType] || loanRateType; + const item = { + ts: Date.now(), + type: 'loan', + title: '贷款计算器·' + METHOD_LABELS[loanMethod] + '·' + rtLabel + ' ' + loanRate + '%', + principal: loanAmount, + amount: loanAmount, + rate: loanRate, + rateType: loanRateType, + periods: loanPeriods, + method: loanMethod, + monthly: loanResult.firstMonthly, + firstMonthly: loanResult.firstMonthly, + totalInterest: loanResult.totalInterest, + totalPayment: loanResult.totalPayment + }; + const ok = saveHistoryItem(item); + $('loan-save').textContent = ok ? '已保存 ✓' : '保存失败'; + setTimeout(() => { $('loan-save').textContent = '保存到历史'; }, 1500); + if (ok) showToast('已保存 ✓'); +}; + +$('loan-copy').onclick = () => { + if (!loanResult) { showToast('请先计算'); return; } + const rtLabel = RATE_TYPE_LABEL[loanRateType] || loanRateType; + const lines = [ + '本金:' + fmtYuan(loanAmount), + '利率类型:' + rtLabel + ' ' + loanRate + '%', + '期数:' + loanPeriods, + '还款方式:' + METHOD_LABELS[loanMethod], + '每期还款:' + fmtYuan(loanResult.firstMonthly), + '还款总额:' + fmtYuan(loanResult.totalPayment), + '支付利息:' + fmtYuan(loanResult.totalInterest) + ]; + copyText(buildCopyText('贷款计算器', lines)); +}; + +$('loan-reset').onclick = () => { + $('loan-amount').value = '200000'; + setSeg('loan-rate-type', 'year'); loanRateType = 'year'; + $('loan-period-unit').textContent = '月'; + $('loan-rate').value = '4.9'; + $('loan-periods').value = '36'; + setSeg('loan-method', 'equal-installment'); loanMethod = 'equal-installment'; + showLoanErr(''); + runLoan(); + showToast('已恢复默认'); +}; + +bindEnter('loan', 'loan-btn'); + /* ===================== 方案对比 ===================== */ const getCmpMethod = { a: bindSeg('cmp-a-method', null), b: bindSeg('cmp-b-method', null) }; /* 组合贷:对比页 A/B 各自贷款类型切换 */ @@ -1555,7 +1929,7 @@

还款明细

function weightedRate(parts) { const tp = parts.reduce((s, p) => s + p.principal, 0); if (!tp) return 0; - return parts.reduce((s, p) => s + p.principal * p.rate, 0) / tp * 100; + return parts.reduce((s, p) => s + p.principal * p.rate, 0) / tp; } function validateCmp() { const errs = []; @@ -1629,8 +2003,8 @@

还款明细

const amtA = sideA.type === 'combo' ? (num('cmp-a-combo-c') + num('cmp-a-combo-f')) : num('cmp-a-amount'); const amtB = sideB.type === 'combo' ? (num('cmp-b-combo-c') + num('cmp-b-combo-f')) : num('cmp-b-amount'); cmpResult = { - a: { amount: amtA, type: sideA.type, years: num('cmp-a-years'), rate: weightedRate(sideA.parts), bp: 0, method: ma, monthly: ra, res: a }, - b: { amount: amtB, type: sideB.type, years: num('cmp-b-years'), rate: weightedRate(sideB.parts), bp: 0, method: mb, monthly: rb, res: b }, + a: { amount: amtA, type: sideA.type, years: num('cmp-a-years'), rate: weightedRate(sideA.parts) * 100, bp: 0, method: ma, monthly: ra, res: a }, + b: { amount: amtB, type: sideB.type, years: num('cmp-b-years'), rate: weightedRate(sideB.parts) * 100, bp: 0, method: mb, monthly: rb, res: b }, diffMonthly, diffInterest, diffPct, winner }; markComputed('cmp'); @@ -1818,7 +2192,7 @@

还款明细

const parts = buildPPParts(); if (!parts.length) { showToast('贷款金额需大于 0'); return; } const principal = parts.reduce((s, p) => s + p.principal, 0); - const rate = weightedRate(parts) / 100; + const rate = weightedRate(parts); const totalMonths = Math.max.apply(null, parts.map(p => p.months)); const paid = Math.round(num('pp-paid')); const entries = prepayEntries.map(e => ({ month: e.month, amount: e.amount })); @@ -2316,13 +2690,16 @@

还款明细

if (vrPage > total) vrPage = total; const start = (vrPage - 1) * PAGE; const slice = data.slice(start, start + PAGE); - $('vr-tbody').innerHTML = slice.map(r => { - const c0 = mode === 'year' ? r.year + ' 年' : r.period + ' 期'; - return '' + c0 + '' + fmtYuan(r.payment) + '' + fmtYuan(r.principal) + '' + fmtYuan(r.interest) + '' + f2(r.rate) + '%' + fmtYuan(r.balance) + '' + (mShort[r.method] || '—') + ''; - }).join(''); - $('vr-page').textContent = vrPage + ' / ' + total; - $('vr-prev').disabled = vrPage <= 1; - $('vr-next').disabled = vrPage >= total; + renderScheduleTable('vr-tbody', 'vr-thead', slice, { + thead: mode === 'year' + ? '年份年还本息年还本金年还利息年末利率年末剩余方式' + : '期数月供本金利息利率剩余本金方式', + page: vrPage, total, pageId: 'vr-page', prevId: 'vr-prev', nextId: 'vr-next', + renderRow: (r) => { + const c0 = mode === 'year' ? r.year + ' 年' : r.period + ' 期'; + return '' + c0 + '' + fmtYuan2(r.payment) + '' + fmtYuan2(r.principal) + '' + fmtYuan2(r.interest) + '' + f2(r.rate) + '%' + fmtYuan2(r.balance) + '' + (mShort[r.method] || '—') + ''; + } + }); } $('vr-detail-mode').onchange = () => { vrPage = 1; renderVrDetail(); }; @@ -2340,7 +2717,7 @@

还款明细

} const TYPE_LABELS = { commercial: '商业贷款', fund: '公积金', combo: '组合贷' }; -const METHOD_LABELS = { 'equal-installment': '等额本息', 'equal-principal': '等额本金' }; +const METHOD_LABELS = { 'equal-installment': '等额本息', 'equal-principal': '等额本金', 'interest-only': '先息后本' }; let histSelected = new Set(); let histFilterState = { text: '', type: 'all' }; @@ -2382,6 +2759,20 @@

还款明细

c.innerHTML = filtered.map(item => { const d = new Date(item.ts); const ds = (d.getMonth() + 1) + '/' + d.getDate() + ' ' + d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0'); + // 贷款计算器(type==='loan'):标题已含方式/利率类型,直接展示本金·期数·每期·总息,避免 undefined 字段 + if (item.type === 'loan') { + const monthVal = item.firstMonthly || item.monthly || 0; + return '
' + + '' + + '
' + + '
' + escapeHtml(item.title || '贷款计算器') + '
' + + '
' + fmtYuan(item.principal || item.amount) + ' · ' + (item.periods || 0) + '期 · 每期 ' + fmtYuan(monthVal) + ' · 总息 ' + fmtYuan(item.totalInterest) + ' · ' + ds + '
' + + '
' + + '' + + '' + + '' + + '
'; + } const isVar = item.mode === 'variable'; const titleLabel = isVar ? '浮动利率' : (TYPE_LABELS[item.type] || item.type); const methodLabel = METHOD_LABELS[item.method || item.initMethod] || (item.method || item.initMethod) || ''; @@ -2464,6 +2855,24 @@

还款明细

showToast('已回填浮动利率配置'); return; } + if (item.type === 'loan') { + document.querySelectorAll('.tab').forEach(x => x.classList.remove('active')); + document.querySelectorAll('.panel').forEach(x => x.classList.remove('active')); + document.querySelector('.tab[data-tab="loan"]').classList.add('active'); + $('loan').classList.add('active'); + $('loan-amount').value = item.amount; + $('loan-rate').value = item.rate; + setSeg('loan-rate-type', item.rateType || 'year'); + $('loan-period-unit').textContent = (item.rateType === 'day') ? '天' : '月'; + $('loan-periods').value = item.periods; + setSeg('loan-method', item.method || 'equal-installment'); + loanRateType = item.rateType || 'year'; + loanMethod = item.method || 'equal-installment'; + showLoanErr(''); + runLoan(); + showToast('已回填贷款计算器'); + return; + } calcState.type = item.type; calcState.method = item.method; document.querySelectorAll('#calc-type button').forEach(b => { b.classList.toggle('active', b.dataset.v === item.type); }); @@ -2535,7 +2944,7 @@

还款明细

/* ===================== 跨页签缓存:自动保存/回填,切换与重载不丢数据 ===================== */ const STATE_KEY = 'loan-calc-state-v1'; let _saveTimer = null; -const computed = { calc: false, cmp: false, pp: false, vr: false, irr: false }; +const computed = { calc: false, cmp: false, pp: false, vr: false, irr: false, loan: false }; function scheduleSave() { if (_saveTimer) clearTimeout(_saveTimer); _saveTimer = setTimeout(saveState, 250); } function saveState() { _saveTimer = null; @@ -2545,20 +2954,9 @@

还款明细

function setSeg(id, val) { document.querySelectorAll('#' + id + ' button').forEach(b => b.classList.toggle('active', b.dataset.v === val)); } -/* 组合贷:求一个等效固定年利率,使首月供与混合贷款一致(用于填入方案对比) */ -function equivalentRate(principal, months, targetMonthly, method) { - if (!(targetMonthly > 0) || months <= 0 || !(principal > 0)) return 0; - let lo = 0, hi = 0.2; - for (let i = 0; i < 50; i++) { - const mid = (lo + hi) / 2; - const m = computeLoan(principal, mid, months, method).firstMonthly; - if (m > targetMonthly) hi = mid; else lo = mid; - } - return (lo + hi) / 2 * 100; -} function collectState() { const inputs = {}; - document.querySelectorAll('#calc input, #calc select, #compare input, #compare select, #prepay input, #prepay select, #variable input, #variable select, #irr input, #irr select').forEach(el => { + document.querySelectorAll('#calc input, #calc select, #compare input, #compare select, #prepay input, #prepay select, #variable input, #variable select, #irr input, #irr select, #loan input, #loan select').forEach(el => { if (el.id) inputs[el.id] = el.value; }); const segs = {}; @@ -2647,17 +3045,18 @@

还款明细

/* 任何输入/选择/点击都自动缓存(防抖) */ document.addEventListener('input', scheduleSave); document.addEventListener('change', scheduleSave); -document.addEventListener('click', scheduleSave); $('calc-copy').onclick = () => { if (!calcResult) { showToast('请先计算'); return; } const method = calcState.method === 'equal-principal' ? '等额本金' : '等额本息'; - const text = '【贷款计算器】\n月供:' + fmtYuan(calcResult.monthly || calcResult.firstMonthly) + - '\n还款总额:' + f2(calcResult.totalPayment / wan) + '万' + - '\n贷款本金:' + f2(calcResult.totalPrincipal / wan) + '万' + - '\n支付利息:' + f2(calcResult.totalInterest / wan) + '万' + - '\n还款方式:' + method; - copyText(text); + const lines = [ + '月供:' + fmtYuan(calcResult.monthly || calcResult.firstMonthly), + '还款总额:' + f2(calcResult.totalPayment / wan) + '万', + '贷款本金:' + f2(calcResult.totalPrincipal / wan) + '万', + '支付利息:' + f2(calcResult.totalInterest / wan) + '万', + '还款方式:' + method + ]; + copyText(buildCopyText('贷款计算器', lines)); }; $('calc-reset').onclick = () => { $('calc-house-type').value = 'first'; @@ -2685,7 +3084,7 @@

还款明细

$('cmp-copy').onclick = () => { if (!cmpResult) { showToast('请先对比'); return; } const grab = (id) => { const el = $(id); return (el.innerText || el.textContent || '').replace(/\n{2,}/g, '\n').trim(); }; - copyText('【方案对比】\n' + grab('cmp-a-out') + '\n' + grab('cmp-b-out') + '\n' + grab('cmp-diff')); + copyText(buildCopyText('方案对比', [grab('cmp-a-out'), grab('cmp-b-out'), grab('cmp-diff')])); }; $('cmp-reset').onclick = () => { setSeg('cmp-a-type', 'commercial'); refreshCmpTypeUI('a', 'commercial'); @@ -2706,7 +3105,7 @@

还款明细

$('pp-copy').onclick = () => { if (!ppResult) { showToast('请先分析'); return; } const grab = (id) => { const el = $(id); return (el.innerText || el.textContent || '').replace(/\n{2,}/g, '\n').trim(); }; - copyText('【提前还款】\n' + grab('pp-shorten-out') + '\n' + grab('pp-reduce-out') + '\n' + grab('pp-invest-out')); + copyText(buildCopyText('提前还款', [grab('pp-shorten-out'), grab('pp-reduce-out'), grab('pp-invest-out')])); }; $('pp-reset').onclick = () => { setSeg('pp-type', 'commercial'); refreshPPTypeUI('commercial'); @@ -2728,11 +3127,13 @@

还款明细

$('vr-copy').onclick = () => { if (!vrResult) { showToast('请先模拟'); return; } - const text = '【浮动利率】\n初始月供:' + fmtYuan(vrResult.firstMonthly) + - '\n最终月供:' + fmtYuan(vrResult.lastMonthly) + - '\n利息总额:' + f2(vrResult.totalInterest / wan) + '万' + - '\n总还款额:' + f2(vrResult.totalPayment / wan) + '万'; - copyText(text); + const lines = [ + '初始月供:' + fmtYuan(vrResult.firstMonthly), + '最终月供:' + fmtYuan(vrResult.lastMonthly), + '利息总额:' + f2(vrResult.totalInterest / wan) + '万', + '总还款额:' + f2(vrResult.totalPayment / wan) + '万' + ]; + copyText(buildCopyText('浮动利率', lines)); }; $('vr-reset').onclick = () => { setSeg('vr-type', 'commercial'); refreshVRTypeUI('commercial'); @@ -2982,6 +3383,7 @@

还款明细

const method = irrState.method || 'equal-installment'; if (!(P > 0)) errs.push('本金需大于 0'); if (!(n >= 1)) errs.push('分期期数需为 ≥ 1 的正整数'); + if (n > 600) errs.push('分期期数过大(600 期内),请缩小后重试'); if (!(pay >= 0)) errs.push('每期还款需 ≥ 0'); if (fee < 0) errs.push('手续费需 ≥ 0'); const C = pay + fee; @@ -3077,18 +3479,17 @@

还款明细

data = rows; head = '期数当期还款本金利息剩余本金'; } - $('irr-thead').innerHTML = head; const total = Math.ceil(data.length / PAGE); if (irrPage > total) irrPage = total; const start = (irrPage - 1) * PAGE; const slice = data.slice(start, start + PAGE); - $('irr-tbody').innerHTML = slice.map(r => { - const c0 = mode === 'year' ? r.year + ' 年' : r.period + ' 期'; - return '' + c0 + '' + fmtCNY(r.payment) + '' + fmtCNY(r.principal) + '' + fmtCNY(r.interest) + '' + fmtCNY(r.balance) + ''; - }).join(''); - $('irr-page').textContent = irrPage + ' / ' + total; - $('irr-prev').disabled = irrPage <= 1; - $('irr-next').disabled = irrPage >= total; + renderScheduleTable('irr-tbody', 'irr-thead', slice, { + thead: head, page: irrPage, total, pageId: 'irr-page', prevId: 'irr-prev', nextId: 'irr-next', + renderRow: (r) => { + const c0 = mode === 'year' ? r.year + ' 年' : r.period + ' 期'; + return '' + c0 + '' + fmtYuan2(r.payment) + '' + fmtYuan2(r.principal) + '' + fmtYuan2(r.interest) + '' + fmtYuan2(r.balance) + ''; + } + }); } $('irr-btn').onclick = runIrr; @@ -3117,17 +3518,19 @@

还款明细

}; $('irr-copy').onclick = () => { if (!irrResult) { showToast('请先计算'); return; } - const text = '【真实利率计算】\n本金:' + fmtCNY(irrResult.P) + - '\n分期方式:' + IRR_METHOD_LABEL[irrResult.method] + - '\n期数:' + irrResult.n + - '\n每期还款:' + fmtCNY(irrResult.pay) + - '\n每期手续费:' + fmtCNY(irrResult.fee) + - '\n真实月利率:' + fmtPct(irrResult.r) + - '\n名义年化 APR:' + fmtPct(irrResult.apr) + - '\n实际年化 IRR:' + fmtPct(irrResult.irr) + - '\n总利息:' + fmtCNY(irrResult.totalInterest) + - '\n校验:' + (irrResult.ok ? (irrResult.n + '期后余额归零 ✓') : '未通过'); - copyText(text); + const lines = [ + '本金:' + fmtCNY(irrResult.P), + '分期方式:' + IRR_METHOD_LABEL[irrResult.method], + '期数:' + irrResult.n, + '每期还款:' + fmtCNY(irrResult.pay), + '每期手续费:' + fmtCNY(irrResult.fee), + '真实月利率:' + fmtPct(irrResult.r), + '名义年化 APR:' + fmtPct(irrResult.apr), + '实际年化 IRR:' + fmtPct(irrResult.irr), + '总利息:' + fmtCNY(irrResult.totalInterest), + '校验:' + (irrResult.ok ? (irrResult.n + '期后余额归零 ✓') : '未通过') + ]; + copyText(buildCopyText('真实利率计算', lines)); }; $('irr-save').onclick = () => { if (!irrResult) runIrr(); @@ -3167,6 +3570,7 @@

还款明细

if (computed.cmp) { try { $('cmp-btn').onclick(); } catch (e) {} } if (computed.pp) { try { $('pp-btn').onclick(); } catch (e) {} } if (computed.vr) { try { $('vr-btn').onclick(); } catch (e) {} } +if (computed.loan) { try { $('loan-btn').onclick(); } catch (e) {} } runIrr(); diff --git a/plugins/loan-calculator/plugin.json b/plugins/loan-calculator/plugin.json index 9103dcdd9..564bdee14 100644 --- a/plugins/loan-calculator/plugin.json +++ b/plugins/loan-calculator/plugin.json @@ -5,7 +5,7 @@ "description": "房贷、方案对比、提前还款、浮动利率、真实利率(IRR 反算)多功能贷款计算器;组合贷贯穿全场景,历史批量管理、LPR 走势、DTI 评估与智能建议", "author": "Samson", "homepage": "https://github.com/ZToolsCenter/ZTools-plugins/tree/main/plugins/loan-calculator", - "version": "1.9.0", + "version": "1.9.2", "main": "index.html", "logo": "logo.png", "preload": "preload.js",