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:
1 parent
096f1577c2
commit
b2c22ca050
16 files changed
+505
-84
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -607,5 +607,15 @@
|
||||
"计入总资产": "計入總資產",
|
||||
"已标记当天记账": "已標記當天記賬",
|
||||
"已取消当天标记": "已取消當天標記",
|
||||
"当前筛选下没有账户,可切换筛选查看其他账户。": "當前篩選下沒有賬戶,可切換篩選查看其他賬戶。"
|
||||
"当前筛选下没有账户,可切换筛选查看其他账户。": "當前篩選下沒有賬戶,可切換篩選查看其他賬戶。",
|
||||
"还款金额(": "還款金額(",
|
||||
"欠款减少金额(": "欠款減少金額(",
|
||||
"还款优惠(负数表示手续费)": "還款優惠(負數表示手續費)",
|
||||
"正数为优惠,负数为手续费,0 表示无优惠;只影响付款账户,优惠不能超过还款金额。跨币种请填写欠款减少金额。": "正數為優惠,負數為手續費,0 表示無優惠;隻影響付款賬戶,優惠不能超過還款金額。跨幣種請填寫欠款減少金額。",
|
||||
"优惠": "優惠",
|
||||
"趋势折线显示设置": "趨勢折線顯示設置",
|
||||
"已隐藏全部折线,点击上方名称可重新显示。": "已隱藏全部折線,點擊上方名稱可重新顯示。",
|
||||
"点击名称切换折线;悬停或点击图表查看当天数据,方向键切换日期。": "點擊名稱切換折線;懸停或點擊圖表查看當天數據,方向鍵切換日期。",
|
||||
"当天趋势数据": "當天趨勢數據",
|
||||
"当天估值或汇率不完整,无法显示完整总额。": "當天估值或匯率不完整,無法顯示完整總額。"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
Reference in new issue
Block a user