feat: add repayment discounts and interactive net worth trends

Support positive repayment discounts and negative surcharges with exact decimal conversion. Add independent trend toggles, pointer and keyboard date inspection, tests and Chinese documentation.
This commit is contained in:
陈煜 committed 2026-10-04 22:25:32 +08:00
1 parent 096f1577c2
commit b2c22ca050
16 files changed
+505 -84

No files matched your search

+3 -1
View File
@@ -93,10 +93,12 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json
账户页开启“快速记账”后使用紧凑卡片直接更新余额;转账按钮紧邻模式入口。右侧显示本次保存成功的余额、转账、还款及标记,支持逐项撤回;窄屏记录面板显示在账户列表下方。列表为当前页面会话,刷新或重新进入后清空,已保存账目仍在变化记录中。
欠款账户弹窗提供“还款”,通过付款账户与欠款账户双边记账更新余额。可手动标记“本月已还款”和“当天已记账”,并筛选未还款或隐藏当天已记账的账户。数据库保留最后完成月份/日期,跨月、跨日只按当前 UTC+8 时间判断是否显示,无需定时删除;标记不会改变金额。
欠款账户弹窗提供“还款”,通过付款账户与欠款账户双边记账更新余额。信用卡与独立债务还款支持优惠:正数减少扣款,负数表示手续费,欠款按本金减少。可手动标记“本月已还款”和“当天已记账”,并筛选未还款或隐藏当天已记账的账户。数据库保留最后完成月份/日期,跨月、跨日只按当前 UTC+8 时间判断是否显示,无需定时删除;标记不会改变金额。
账户、独立资产和债务可在详情删除。存在配对往来或定时计划时需先处理依赖,避免破坏另一账户余额。详见 [快速记账更新与验收](docs/update-quick-entry-2026-10-04.md) 和 [项目删除说明](docs/update-position-deletion-2026-10-04.md)。
净资产轨迹支持分别隐藏总资产、总负债和净资产折线;悬停或点击图表查看对应日期的金额,方向键切换日期。缺失汇率或估值时保留断线并提示不完整。详见 [还款优惠与图表更新](docs/update-repayment-trend-2026-10-04.md)。
## 连接 Agent / MCP
设置中的“连接助手”提供可复制的接入提示词、能力说明及权限选择。OAuth 授权和草稿确认使用独立页面;可选择 1、3、7、30 天、1 年或永久授权,并分别控制读取、修改隐藏账户。普通权限分为只读、草稿修改、直接写入,草稿需本人在网页审阅确认后才生效。
+1 -1
View File
@@ -5,7 +5,7 @@
"dev": "node scripts/dev.cjs",
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts",
"db:generate": "prisma generate",
"db:migrate": "node scripts/database.cjs deploy",
"db:status": "node scripts/database.cjs status",
@@ -168,6 +168,58 @@ test('quick entries undo paired repayment and balance, persist monthly marks, pr
assert.equal((await call(a, '/transfers/' + payment.data.id, 'DELETE')).status, 200);
assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000');
assert.equal((await call(a, '/positions/' + card)).data.amount, '200');
for (const [fee, expected] of [
['-5.87654321', '955.87654321'],
['1.86420975', '948.13579025'],
] as const) {
const movement = await call(a, '/transfers', 'POST', {
sourceId: cash,
targetId: card,
amount: '50',
received: '50',
fee,
date: '2026-09-03T11:00',
requestId: randomUUID(),
});
assert.equal(movement.status, 201);
assert.equal((await call(a, '/positions/' + cash)).data.amount, expected);
assert.equal((await call(a, '/positions/' + card)).data.amount, '150');
assert.equal((await call(a, '/transfers/' + movement.data.id, 'DELETE')).status, 200);
assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000');
assert.equal((await call(a, '/positions/' + card)).data.amount, '200');
}
const invalidDiscount = await call(a, '/transfers', 'POST', {
sourceId: cash,
targetId: card,
amount: '30',
received: '30',
fee: '-31',
date: '2026-09-03T11:00',
requestId: randomUUID(),
});
assert.equal(invalidDiscount.status, 400);
assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000');
const loan = await create('测试独立欠款', 'debt', 'liability', 'personal', '200');
for (const [fee, expected] of [
['-5', '975'],
['2', '968'],
] as const) {
const movement = await call(a, '/transfers', 'POST', {
operation: 'repay',
sourceId: cash,
targetId: loan,
amount: '30',
received: '30',
fee,
date: '2026-09-03T12:00',
requestId: randomUUID(),
});
assert.equal(movement.status, 201);
assert.equal((await call(a, '/positions/' + cash)).data.amount, expected);
assert.equal((await call(a, '/positions/' + loan)).data.amount, '170');
assert.equal((await call(a, '/transfers/' + movement.data.id, 'DELETE')).status, 200);
}
const month = today().slice(0, 7),
path = '/positions/' + card + '/repayment-mark';
assert.equal((await call(b, path, 'PATCH', { month, expectedMonth: null })).status, 404);
+3 -71
View File
@@ -46,6 +46,7 @@ import {
type PageResult,
} from './api';
import './style.css';
import { NetWorthChart } from './NetWorthChart';
import { allOverviewCards, overviewCardLabels } from './overview-cards';
import { useToast } from './Toast';
import { GroupOrderContext } from './GroupOrderContext';
@@ -165,76 +166,6 @@ function Empty({ title, body, action }: { title: string; body: string; action?:
</div>
);
}
function Chart({ rows, base }: { rows: Total[]; base: string }) {
const valid = rows.filter((r) => r.complete);
if (valid.length < 2)
return (
<Empty
title={tr('等待更多历史记录')}
body={tr('至少两个日期的完整余额和汇率记录,才能绘制趋势。')}
/>
);
const values = valid.flatMap((r) => [Number(r.assets), Number(r.liabilities), Number(r.net)]),
min = Math.min(0, ...values),
max = Math.max(1, ...values),
w = 720,
h = 210;
const x = (date: string) =>
35 +
((new Date(date).getTime() - new Date(rows[0].date).getTime()) /
(new Date(rows.at(-1)!.date).getTime() - new Date(rows[0].date).getTime() || 1)) *
(w - 65);
const y = (v: string) => 15 + ((max - Number(v)) / (max - min)) * (h - 40);
const series = ['assets', 'liabilities', 'net'] as const;
return (
<>
<div className="legend">
<span className="green">{tr('总资产')}</span>
<span className="danger-text">{tr('总负债')}</span>
<span className="blue">{tr('净资产')}</span>
<small>{base}</small>
</div>
<svg
viewBox={`0 0 ${w} ${h}`}
className="chart"
role="img"
aria-label={tr('资产、负债和净资产历史趋势')}
>
<line x1="35" y1={y('0')} x2="690" y2={y('0')} stroke="#dbe4e2" />
{series.map((s, i) => (
<path
key={s}
d={rows
.map((r, n) =>
r.complete
? `${n === 0 || !rows[n - 1].complete ? 'M' : 'L'}${x(r.date)},${y(r[s])}`
: '',
)
.join(' ')}
fill="none"
stroke={['#199b76', '#c73542', '#457bca'][i]}
strokeWidth="3"
/>
))}
{valid.map((r) => (
<circle key={r.date} cx={x(r.date)} cy={y(r.net)} r="4" fill="#457bca">
<title>
{r.date} {tr('净资产')}
{money(r.net, base)}
</title>
</circle>
))}
</svg>
<div className="chart-dates">
<span>{rows[0].date}</span>
<span>{rows.at(-1)!.date}</span>
</div>
{rows.some((r) => !r.complete) && (
<p className="muted">{tr('缺失汇率的日期未绘制,曲线在该处断开。')}</p>
)}
</>
);
}
export default function App() {
const locale = useLocale();
const sessionGeneration = useRef(0),
@@ -381,6 +312,7 @@ export default function App() {
currency: source?.currency || '',
amount: value.amount,
received: value.received,
fee: value.fee,
targetCurrency: target?.currency,
date: value.date,
},
@@ -1390,7 +1322,7 @@ export default function App() {
'默认最近90天;周一至周日及自然月取期末余额,变化归因累计区间内每日变化,首尾不足周期按实际范围。',
)}
</p>
<Chart rows={overview.trend} base={user.baseCurrency} />
<NetWorthChart rows={overview.trend} base={user.baseCurrency} />
<p className="muted">
{tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')}
</p>
+16 -4
View File
@@ -1,4 +1,5 @@
import { AccountPicker } from './AccountPicker';
import { repaymentFee } from './repayment';
import { useState, type FormEvent } from 'react';
import { type Position, nowMinute, money, requestUuid, positionAmount } from './api';
import { t as tr } from './i18n';
@@ -45,7 +46,12 @@ export function DebtPaymentForm({
const same = debt?.currency === account?.currency;
function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
submit(Object.fromEntries(new FormData(event.currentTarget)));
const data = Object.fromEntries(new FormData(event.currentTarget));
if (operation === 'repay') {
data.fee = repaymentFee(String(data.discount));
delete data.discount;
}
submit(data);
}
if (!accounts.length || !debts.length)
return <p>{tr('请先创建启用的资产账户和借入或借出债务。')}</p>;
@@ -122,9 +128,11 @@ export function DebtPaymentForm({
)}
</label>
<label>
{tr('手续费 / 优惠')} ({account?.currency})
{tr(operation === 'repay' ? '还款优惠(负数表示手续费)' : '手续费 / 优惠')} (
{account?.currency})
<input
name="fee"
name={operation === 'repay' ? 'discount' : 'fee'}
key={operation}
required
defaultValue="0"
inputMode="decimal"
@@ -132,7 +140,11 @@ export function DebtPaymentForm({
/>
</label>
<p className="muted">
{tr('手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。')}
{tr(
operation === 'repay'
? '正数为优惠,负数为手续费,0 表示无优惠;只影响付款账户,优惠不能超过还款金额。跨币种请填写欠款减少金额。'
: '手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。',
)}
</p>
<label>
{tr('记录时间(UTC+8)')}
+209
View File
@@ -0,0 +1,209 @@
import React, { useId, useState, type PointerEvent } from 'react';
import { money, type Total } from './api';
import { t as tr } from './i18n';
const series = [
{ key: 'assets', label: '总资产', color: '#199b76' },
{ key: 'liabilities', label: '总负债', color: '#c73542' },
{ key: 'net', label: '净资产', color: '#457bca' },
] as const;
type SeriesKey = (typeof series)[number]['key'];
const dateValue = (date: string) => Date.parse(date + 'T00:00:00Z');
export function nearestTrendIndex(rows: Pick<Total, 'date'>[], time: number) {
let index = 0,
distance = Infinity;
rows.forEach((row, i) => {
const next = Math.abs(dateValue(row.date) - time);
if (next < distance) {
index = i;
distance = next;
}
});
return index;
}
export function trendPath(
rows: Total[],
key: SeriesKey,
x: (date: string) => number,
y: (value: string) => number,
) {
return rows
.map((r, n) =>
r.complete ? `${n === 0 || !rows[n - 1].complete ? 'M' : 'L'}${x(r.date)},${y(r[key])}` : '',
)
.join(' ');
}
export function NetWorthChart({ rows, base }: { rows: Total[]; base: string }) {
const [visible, setVisible] = useState<Record<SeriesKey, boolean>>({
assets: true,
liabilities: true,
net: true,
});
const [activeDate, setActiveDate] = useState<string | null>(null);
const tooltipId = useId();
const valid = rows.filter((r) => r.complete);
if (valid.length < 2)
return (
<div className="empty">
<h3>{tr('等待更多历史记录')}</h3>
<p>{tr('至少两个日期的完整余额和汇率记录,才能绘制趋势。')}</p>
</div>
);
const shown = series.filter((s) => visible[s.key]);
const values = valid.flatMap((r) => shown.map((s) => Number(r[s.key])));
const min = Math.min(0, ...values),
max = Math.max(1, ...values);
const start = dateValue(rows[0].date),
end = dateValue(rows.at(-1)!.date);
const x = (date: string) => 35 + ((dateValue(date) - start) / (end - start || 1)) * 655;
const y = (value: string) => 15 + ((max - Number(value)) / (max - min)) * 170;
const active = rows.find((r) => r.date === activeDate);
function point(event: PointerEvent<SVGSVGElement>) {
if (!shown.length) return;
const matrix = event.currentTarget.getScreenCTM();
if (!matrix) return;
const position = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse());
const time = start + Math.max(0, Math.min(1, (position.x - 35) / 655)) * (end - start);
setActiveDate(rows[nearestTrendIndex(rows, time)].date);
}
return (
<div className="trend-chart">
<div className="legend trend-legend" aria-label={tr('趋势折线显示设置')}>
{series.map((s) => (
<button
key={s.key}
type="button"
aria-pressed={visible[s.key]}
style={{ color: s.color }}
className={visible[s.key] ? 'trend-series' : 'trend-series is-hidden'}
onClick={() => setVisible((v) => ({ ...v, [s.key]: !v[s.key] }))}
>
<span aria-hidden="true">{visible[s.key] ? '●' : '○'}</span> {tr(s.label)}
</button>
))}
<small>{base}</small>
</div>
{!shown.length ? (
<p className="muted" role="status">
{tr('已隐藏全部折线,点击上方名称可重新显示。')}
</p>
) : (
<>
<p className="muted trend-help" id={tooltipId + '-help'}>
{tr('点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。')}
</p>
<div className="trend-plot">
<svg
viewBox="0 0 720 210"
className="chart"
role="img"
tabIndex={0}
aria-label={tr('资产、负债和净资产历史趋势')}
aria-describedby={tooltipId + '-help'}
onPointerMove={point}
onPointerDown={point}
onPointerLeave={(e) => {
if (e.pointerType === 'mouse' && document.activeElement !== e.currentTarget)
setActiveDate(null);
}}
onFocus={() => setActiveDate((current) => current || rows.at(-1)!.date)}
onBlur={() => setActiveDate(null)}
onKeyDown={(e) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End', 'Escape'].includes(e.key)) return;
e.preventDefault();
if (e.key === 'Escape') {
setActiveDate(null);
return;
}
const current = rows.findIndex((r) => r.date === activeDate);
const next =
e.key === 'Home'
? 0
: e.key === 'End'
? rows.length - 1
: Math.max(
0,
Math.min(
rows.length - 1,
(current < 0 ? rows.length - 1 : current) +
(e.key === 'ArrowLeft' ? -1 : 1),
),
);
setActiveDate(rows[next].date);
}}
>
<line x1="35" y1={y('0')} x2="690" y2={y('0')} stroke="#dbe4e2" />
{shown.map((s) => (
<path
key={s.key}
data-series={s.key}
d={trendPath(rows, s.key, x, y)}
fill="none"
stroke={s.color}
strokeWidth="3"
/>
))}
{active && (
<line
x1={x(active.date)}
x2={x(active.date)}
y1="15"
y2="185"
stroke="#899b94"
strokeDasharray="4 4"
/>
)}
{shown.map((s) =>
valid.map((r) => (
<circle
key={s.key + r.date}
cx={x(r.date)}
cy={y(r[s.key])}
r={activeDate === r.date ? 5 : 2.5}
fill={s.color}
>
<title>{r.date + ' · ' + tr(s.label) + ' ' + money(r[s.key], base)}</title>
</circle>
)),
)}
</svg>
{active && (
<section
className="trend-tooltip"
id={tooltipId}
role="status"
aria-label={tr('当天趋势数据')}
>
<strong>{active.date}</strong>
{active.complete ? (
<dl>
{shown.map((s) => (
<React.Fragment key={s.key}>
<dt>
<span style={{ color: s.color }} aria-hidden="true">
●
</span>{' '}
{tr(s.label)}
</dt>
<dd>{money(active[s.key], base)}</dd>
</React.Fragment>
))}
</dl>
) : (
<p>{tr('当天估值或汇率不完整,无法显示完整总额。')}</p>
)}
</section>
)}
</div>
</>
)}
<div className="chart-dates">
<span>{rows[0].date}</span>
<span>{rows.at(-1)!.date}</span>
</div>
{rows.some((r) => !r.complete) && (
<p className="muted">{tr('缺失汇率的日期未绘制,曲线在该处断开。')}</p>
)}
</div>
);
}
+12
View File
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { repaymentFee } from './repayment';
import { AccountPicker } from './AccountPicker';
import { QuickTransfer } from './QuickTransfer';
import { money, positionAmount, type Position } from './api';
@@ -14,6 +15,7 @@ export type QuickEntry = {
after?: string;
amount?: string;
received?: string;
fee?: string;
targetCurrency?: string;
date: string;
previousMonth?: string | null;
@@ -82,6 +84,15 @@ export function QuickJournal({
)}
</p>
)}
{entry.kind === 'transfer' && entry.fee && !/^-?0(?:\.0+)?$/.test(entry.fee) && (
<p>
{tr(entry.fee.startsWith('-') ? '优惠' : '手续费')} ·{' '}
{money(
entry.fee.startsWith('-') ? repaymentFee(entry.fee) : entry.fee,
entry.currency,
)}
</p>
)}
{entry.kind === 'mark' && (
<p>{tr(entry.month ? '已标记本月还款' : '已取消本月标记')}</p>
)}
@@ -154,6 +165,7 @@ export function QuickRepayment({
changeSource={() => setSourceId('')}
changeTarget={() => {}}
fixedTarget
repayment
actionLabel={tr('确认还款')}
submit={submit}
/>
+26 -4
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { repaymentFee } from './repayment';
import { money, positionAmount, nowMinute, requestUuid, type Position } from './api';
import { t as tr } from './i18n';
export function QuickTransfer({
@@ -10,6 +11,7 @@ export function QuickTransfer({
submit,
actionLabel,
fixedTarget,
repayment,
}: {
source: Position;
target: Position;
@@ -19,6 +21,7 @@ export function QuickTransfer({
submit: (data: unknown) => void;
actionLabel?: string;
fixedTarget?: boolean;
repayment?: boolean;
}) {
const [amount, setAmount] = useState('');
const [requestId] = useState(requestUuid);
@@ -34,7 +37,7 @@ export function QuickTransfer({
targetId: target.id,
amount,
received: same ? amount : String(f.get('received')),
fee: '0',
fee: repayment ? repaymentFee(String(f.get('discount'))) : '0',
notes: '',
date: nowMinute(),
});
@@ -62,7 +65,7 @@ export function QuickTransfer({
</div>
</div>
<label>
{tr('转出金额(')}
{tr(repayment ? '还款金额(' : '转出金额(')}
{source.currency})
<input
name="amount"
@@ -77,7 +80,7 @@ export function QuickTransfer({
</label>
{!same && (
<label>
{tr('实际到账(')}
{tr(repayment ? '欠款减少金额(' : '实际到账(')}
{target.currency})
<input
name="received"
@@ -88,7 +91,26 @@ export function QuickTransfer({
/>
</label>
)}
<p className="muted">{tr('按当前时间记录,手续费为 0。跨币种请填写实际到账金额。')}</p>
{repayment && (
<label>
{tr('还款优惠(负数表示手续费)')} ({source.currency})
<input
name="discount"
required
defaultValue="0"
inputMode="decimal"
pattern="-?(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
disabled={busy}
/>
</label>
)}
<p className="muted">
{tr(
repayment
? '正数为优惠,负数为手续费,0 表示无优惠;只影响付款账户,优惠不能超过还款金额。跨币种请填写欠款减少金额。'
: '按当前时间记录,手续费为 0。跨币种请填写实际到账金额。',
)}
</p>
<button className="primary" disabled={busy}>
{busy ? tr('正在保存…') : actionLabel || tr('确认转账')}
</button>
+11 -1
View File
@@ -607,5 +607,15 @@
"计入总资产": "Included in total assets",
"已标记当天记账": "Marked booked today",
"已取消当天标记": "Removed today’s mark",
"当前筛选下没有账户,可切换筛选查看其他账户。": "No accounts match these filters. Change the filters to view other accounts."
"当前筛选下没有账户,可切换筛选查看其他账户。": "No accounts match these filters. Change the filters to view other accounts.",
"还款金额(": "Repayment amount (",
"欠款减少金额(": "Debt reduction (",
"还款优惠(负数表示手续费)": "Repayment discount (negative for fees)",
"正数为优惠,负数为手续费,0 表示无优惠;只影响付款账户,优惠不能超过还款金额。跨币种请填写欠款减少金额。": "Positive values are discounts; negative values are fees; zero means no discount. Only the payment account is adjusted. Discounts cannot exceed the repayment amount. For different currencies, enter the debt reduction.",
"优惠": "Discount",
"趋势折线显示设置": "Trend series visibility",
"已隐藏全部折线,点击上方名称可重新显示。": "All series are hidden. Click a name above to show it again.",
"点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "Click a name to toggle a series. Hover or tap the chart to inspect a date; use arrow keys to move between dates.",
"当天趋势数据": "Data for selected date",
"当天估值或汇率不完整,无法显示完整总额。": "Valuations or exchange rates are incomplete for this date; full totals are unavailable."
}
+11 -1
View File
@@ -607,5 +607,15 @@
"计入总资产": "計入總資產",
"已标记当天记账": "已標記當天記賬",
"已取消当天标记": "已取消當天標記",
"当前筛选下没有账户,可切换筛选查看其他账户。": "當前篩選下沒有賬戶,可切換篩選查看其他賬戶。"
"当前筛选下没有账户,可切换筛选查看其他账户。": "當前篩選下沒有賬戶,可切換篩選查看其他賬戶。",
"还款金额(": "還款金額(",
"欠款减少金额(": "欠款減少金額(",
"还款优惠(负数表示手续费)": "還款優惠(負數表示手續費)",
"正数为优惠,负数为手续费,0 表示无优惠;只影响付款账户,优惠不能超过还款金额。跨币种请填写欠款减少金额。": "正數為優惠,負數為手續費,0 表示無優惠;隻影響付款賬戶,優惠不能超過還款金額。跨幣種請填寫欠款減少金額。",
"优惠": "優惠",
"趋势折线显示设置": "趨勢折線顯示設置",
"已隐藏全部折线,点击上方名称可重新显示。": "已隱藏全部折線,點擊上方名稱可重新顯示。",
"点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "點擊名稱切換折線;懸停或點擊圖表查看當天數據,方向鍵切換日期。",
"当天趋势数据": "當天趨勢數據",
"当天估值或汇率不完整,无法显示完整总额。": "當天估值或匯率不完整,無法顯示完整總額。"
}
+7
View File
@@ -0,0 +1,7 @@
// UI: positive discount, negative surcharge. API stores the opposite fee sign.
// Flip the decimal string directly to preserve all eight fractional digits.
export function repaymentFee(discount: string) {
if (!/^-?(0|[1-9]\d{0,15})(\.\d{1,8})?$/.test(discount)) throw new Error('还款优惠格式无效');
if (/^-?0(?:\.0+)?$/.test(discount)) return '0';
return discount.startsWith('-') ? discount.slice(1) : '-' + discount;
}
+70
View File
@@ -2422,3 +2422,73 @@ textarea,
top: 225px;
}
}
.trend-legend {
flex-wrap: wrap;
gap: 8px;
}
.trend-legend .trend-series {
border: 1px solid #dbe4df;
background: #fff;
padding: 6px 10px;
border-radius: 8px;
font-size: 13px;
}
.trend-legend .trend-series.is-hidden {
opacity: 0.55;
text-decoration: line-through;
background: #f2f5f3;
}
.trend-legend .trend-series:focus-visible,
.trend-plot svg:focus-visible {
outline: 2px solid #457bca;
outline-offset: 2px;
}
.trend-help {
font-size: 12px;
margin: 8px 0;
}
.trend-plot {
position: relative;
}
.trend-plot .chart {
display: block;
cursor: crosshair;
}
.trend-tooltip {
position: absolute;
top: 12px;
transform: translateX(-50%);
pointer-events: none;
z-index: 2;
width: max-content;
max-width: min(280px, 90%);
padding: 10px 12px;
background: #ffffffee;
border: 1px solid #cbd9d2;
border-radius: 10px;
box-shadow: 0 4px 18px #17374718;
font-size: 12px;
}
.trend-tooltip dl {
display: grid;
grid-template-columns: auto auto;
gap: 5px 14px;
margin: 6px 0 0;
}
.trend-tooltip dd {
margin: 0;
text-align: right;
overflow-wrap: anywhere;
font-variant-numeric: tabular-nums;
}
.trend-tooltip p {
margin: 6px 0 0;
}
.trend-tooltip {
left: auto;
right: 12px;
transform: none;
max-width: calc(100% - 24px);
}
+45
View File
@@ -0,0 +1,45 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { nearestTrendIndex, trendPath, NetWorthChart } from '../src/NetWorthChart';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
const rows = [
{
date: '2026-09-01',
assets: '100.86420975',
liabilities: '20',
net: '80.86420975',
complete: true,
missing: [],
},
{
date: '2026-09-02',
assets: '0',
liabilities: '0',
net: '0',
complete: false,
missing: ['USD'],
},
{ date: '2026-09-10', assets: '120', liabilities: '20', net: '100', complete: true, missing: [] },
];
test('hover dates use time distance across uneven dates and expose missing dates', () => {
assert.equal(nearestTrendIndex(rows, Date.parse('2026-09-02T00:00:00Z')), 1);
assert.equal(nearestTrendIndex(rows, Date.parse('2026-09-09T00:00:00Z')), 2);
assert.equal(nearestTrendIndex(rows, Date.parse('2026-08-01T00:00:00Z')), 0);
});
test('trend paths keep a gap for incomplete dates rather than connecting misleading totals', () => {
const path = trendPath(
rows,
'net',
(d) => Number(d.slice(-2)),
(v) => Number(v),
);
assert.equal(path, 'M1,80.86420975 M10,100');
});
test('trend legend offers three independent toggles and exact monetary data', () => {
const html = renderToStaticMarkup(createElement(NetWorthChart, { rows, base: 'CNY' }));
assert.equal((html.match(/aria-pressed="true"/g) || []).length, 3);
assert.equal((html.match(/data-series=/g) || []).length, 3);
assert.match(html, /80\.86420975/);
assert.match(html, /tabindex="0"/);
});
+9
View File
@@ -1,3 +1,4 @@
import { repaymentFee } from '../src/repayment';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
@@ -76,3 +77,11 @@ test('daily badges expire and filters combine month and day without mutating mar
);
assert.equal(p.lastBookedDate, '2026-10-04');
});
test('repayment discount uses opposite fee sign without decimal precision loss', () => {
assert.equal(repaymentFee('5.87654321'), '-5.87654321');
assert.equal(repaymentFee('-1.86420975'), '1.86420975');
assert.equal(repaymentFee('-0.00000000'), '0');
assert.equal(repaymentFee('0'), '0');
assert.throws(() => repaymentFee('1e3'));
});
+27
View File
@@ -0,0 +1,27 @@
# 还款优惠与净资产轨迹交互(2026-10-04)
## 还款优惠
信用卡/贷款账户的快速还款,以及独立债务“还款”表单,提供“还款优惠(负数表示手续费)”字段。币种为付款账户原币;默认 0。正数减少实际扣款,负数增加实际扣款;欠款按本次本金减少。优惠不能超过还款金额。跨币种时仍填写欠款币种的减少金额,优惠仅作用于付款币种。
例如还款 50,优惠 5.86420975,付款账户实际扣除 44.13579025,欠款减少 50;还款 30、输入 -2,则付款账户扣除 32,欠款减少 30。独立借出债务的收款、借贷本金操作和普通转账表单仍按各自现有手续费说明处理。
前端直接翻转十进制字符串的符号,保留八位小数;提交时将优惠正数转换为既有 fee 负数、手续费负数转换为 fee 正数。金额未经过 JavaScript 浮点运算,不改变现有接口字段、金额规则或备份格式。已有 Transfer.fee 字段及数据库注释保持有效,无新增数据库迁移。
快速记录同时显示本次优惠或手续费,撤回继续使用成对往来删除与历史重放,恢复双方余额。独立债务还款可在变化记录管理并删除。
## 净资产轨迹
点击图例中的“总资产”“总负债”“净资产”,可独立隐藏或恢复对应折线,纵轴范围随可见折线调整。隐藏全部时显示恢复提示;重新点击任何图例可恢复。显示选择仅影响当前页面图表,不改变资产统计、历史或备份。
鼠标悬停图表时显示最接近日期的提示卡,包含日期及当前可见折线金额。点击图表支持触屏查看;聚焦后可使用左右方向键切换日期、Home/End 跳到首尾、Escape 关闭提示。提示金额使用原始十进制字符串格式化,绘图坐标仅作视觉近似。
周/月日期间距按真实日期计算。汇率或估值不完整的日期保留断线,查看这些日期时明确提示总额不完整,不以零替代。少于两个完整日期时保留原有等待提示。
## 验证
- 52 项基础测试通过,包含优惠符号转换与精度、非均匀日期选择、缺失数据断线、三条独立图例及原始金额格式化。
- 扩展快速记账真实 MySQL/REST 测试通过:信用卡和独立债务优惠、手续费、优惠超额拒绝、成对撤回,以及已有隐藏隔离、标记和 ZIP 恢复。
- 临时账号页面验证:还款 50、优惠 5.87654321 后余额从 1000 变为 955.87654321;快速记录正确显示优惠;撤回恢复。独立债务还款 30、手续费 2 后付款余额为 968,欠款从 200 减至 170。
- 图表验证:隐藏总负债后对应路径消失,提示仅显示可见折线;End 显示最后业务日期及精确总额;全部隐藏后可恢复净资产线。
- 前后端类型检查与生产构建通过。测试账号退出并清理,未修改真实用户财务数据;docs 目录无截图。
+3 -1
View File
@@ -90,4 +90,6 @@
- ~~独立资产可以快速点击卡片却认是否计入总资产,与快速记账类似~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~快速记账使用,减小卡片中不重要的信息,并缩小卡片和缩小间距,同时隐藏页面中不重要的信息和按钮,让快速记账更便捷~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~快速记账的转账按钮 和快速记账显示在附近,不在显示在右边~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~快速记账时,可以出现点击当天已完成记账的按钮,点击可以快速设置某个账号当天已完成记账,同时在快速记账中可以显示或隐藏当天已完成记账~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~快速记账时,可以出现点击当天已完成记账的按钮,点击可以快速设置某个账号当天已完成记账,同时在快速记账中可以显示或隐藏当天已完成记账~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~还款可以优惠功能(负数表示手续费)~~ — 已完成并验证:2026-10-04 22:18(UTC+8),见 docs/update-repayment-trend-2026-10-04.md
- ~~净资产轨迹,可以设置隐藏某条折线,鼠标放线线可以查看当天数据~~ — 已完成并验证:2026-10-04 22:18(UTC+8),见 docs/update-repayment-trend-2026-10-04.md