import { t as tr, useLocale, LanguagePicker } from './i18n'; import { useState, useEffect, useRef, createElement, Fragment, type FormEvent, type ReactNode, } from 'react'; import { LayoutDashboard, CalendarDays, Wallet, Landmark, HandCoins, History as HistoryIcon, Settings, ArrowUpRight, Plus, LogOut, ArrowLeft, X, Download, Upload, RefreshCw, TrendingUp, ShieldCheck, } from 'lucide-react'; import { api, ApiError, currencies, money, positionAmount, accountInputAmount, today, nowMinute, displayTime, type User, type Position, type Overview, type History, type Rate, type Total, type PageResult, } from './api'; import './style.css'; import { GroupOrderContext } from './GroupOrderContext'; import { GroupOrderList } from './GroupOrderList'; import { orderedGroups, mergeGroupOrder } from './group-order'; import { Calendar } from './Calendar'; import { SchedulePanel } from './SchedulePanel'; import { IconPicker } from './IconPicker'; import { TransferForm } from './TransferForm'; import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm'; import type { Transfer } from './api'; import { IconLibrary, iconUrl } from './IconLibrary'; const labels: Record = { overview: '资产总览', account: '账户', asset: '独立资产', debt: '债务', history: '变化记录', calendar: '收支日历', schedules: '定时计划', settings: '设置与备份', }; const categories: Record = { account: [ ['bank', '银行账户'], ['cash', '现金'], ['investment', '投资账户'], ['credit_card', '信用卡'], ['loan', '贷款账户'], ['other', '其他'], ], asset: [ ['property', '房产'], ['vehicle', '车辆'], ['gold', '贵金属'], ['other', '其他资产'], ], debt: [ ['mortgage', '房贷'], ['car_loan', '车贷'], ['personal', '个人借款'], ['other', '其他债务'], ], }; const categoryLabel = (p: Position) => tr(categories[p.kind]?.find(([v]) => v === p.category)?.[1] || p.category); function Currency({ value, onChange }: { value: string; onChange: (v: string) => void }) { return ( ); } function Field({ label, children }: { label: string; children: ReactNode }) { return ( ); } function Modal({ title, close, children, }: { title: string; close: () => void; children: ReactNode; }) { return (
{ if (e.target === e.currentTarget) close(); }} >

{title}

{children}
); } function Empty({ title, body, action }: { title: string; body: string; action?: ReactNode }) { return (

{title}

{body}

{action}
); } function Chart({ rows, base }: { rows: Total[]; base: string }) { const valid = rows.filter((r) => r.complete); if (valid.length < 2) return ( ); 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 ( <>
{tr('总资产')} {tr('总负债')} {tr('净资产')} {base}
{series.map((s, i) => ( 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) => ( {r.date} {tr('净资产')} {money(r.net, base)} ))}
{rows[0].date} {rows.at(-1)!.date}
{rows.some((r) => !r.complete) && (

{tr('缺失汇率的日期未绘制,曲线在该处断开。')}

)} ); } export default function App() { const locale = useLocale(); const sessionGeneration = useRef(0), loadGeneration = useRef(0); const authCheck = useRef | null>(null); const [user, setUser] = useState(null), [boot, setBoot] = useState(true), [register, setRegister] = useState(false), [page, setPage] = useState('overview'), [positions, setPositions] = useState([]), [overview, setOverview] = useState(null), [rates, setRates] = useState([]), [selected, setSelected] = useState(null), [modal, setModal] = useState<{ kind: string; p?: Position; h?: History; transfer?: Transfer; operation?: DebtOperation; groups?: string[]; } | null>(null), [busy, setBusy] = useState(false), [loading, setLoading] = useState(false), [error, setError] = useState(''), [success, setSuccess] = useState(''); const [calendarRefresh, setCalendarRefresh] = useState(0); const [quickMode, setQuickMode] = useState(false), [accountGroup, setAccountGroup] = useState(null); const [fxStatus, setFxStatus] = useState<{ state: string; message: string; attemptedAt: string | null; } | null>(null); const [backup, setBackup] = useState(null), [preview, setPreview] = useState<{ positions: number; revisions: number; rates: number; message: string; baseCurrency: string; currentBaseCurrency: string; } | null>(null); const [clearStep, setClearStep] = useState(0), [clearConfirmation, setClearConfirmation] = useState(0); const [settingsSection, setSettingsSection] = useState('general'), [transfers, setTransfers] = useState([]); const [historyRows, setHistoryRows] = useState([]), [historyCursor, setHistoryCursor] = useState(null), [transferCursor, setTransferCursor] = useState(null), [rangeFrom, setRangeFrom] = useState( new Date(+new Date(today()) - 89 * 86400000).toISOString().slice(0, 10), ), [rangeTo, setRangeTo] = useState(today()), [grain, setGrain] = useState('day'); const [dataVersion, setDataVersion] = useState(0); const previousDataVersion = useRef(0); const viewKey = [page, selected, rangeFrom, rangeTo, grain, settingsSection].join('|'); const viewRef = useRef(viewKey); viewRef.current = viewKey; const userRef = useRef(user); userRef.current = user; const activityAt = useRef(0); function clearAccount() { setQuickMode(false); setAccountGroup(''); setClearStep(0); setClearConfirmation(0); activityAt.current = 0; sessionGeneration.current++; loadGeneration.current++; setUser(null); setPositions([]); setOverview(null); setRates([]); setTransfers([]); setHistoryRows([]); setHistoryCursor(null); setTransferCursor(null); setFxStatus(null); setAccountGroup(null); setModal(null); setSelected(null); setBackup(null); setPreview(null); setPage('overview'); setRegister(false); setLoading(false); setSuccess(''); } const report = (e: unknown) => { setError(e instanceof Error ? e.message : tr('操作失败')); if (e instanceof ApiError && e.status === 401) { clearAccount(); } }; async function load(refreshUser = false, rangeOnly = false, runDue = true) { const session = sessionGeneration.current, request = ++loadGeneration.current, view = viewRef.current; const active = () => session === sessionGeneration.current && request === loadGeneration.current && view === viewRef.current; setLoading(true); try { const s = refreshUser ? await api('/auth/me') : userRef.current; if (!active() || !s) return; const visibilityChanged = !!s.revealed !== !!userRef.current?.revealed; if (visibilityChanged) rangeOnly = false; if (refreshUser) { setUser(s); setPage((current) => (s.hiddenMenus.includes(current) ? 'settings' : current)); if (!s.revealed) { setPositions((rows) => rows.filter((p) => !p.hidden)); setHistoryRows([]); setTransfers([]); if (visibilityChanged) setOverview(null); } } if (page === 'overview' && rangeOnly) { const t = await api>( '/trend?' + new URLSearchParams({ from: rangeFrom, to: rangeTo, grain }), ); if (!active()) return; if (t.revealed !== !!s.revealed) { void load(true); return; } setOverview((o) => (o ? { ...o, trend: t.items } : o)); } else if (page === 'overview') { const [o, t, recent] = await Promise.all([ api>('/overview'), api>( '/trend?' + new URLSearchParams({ from: rangeFrom, to: rangeTo, grain }), ), api>('/history?limit=20'), ]); if (!active()) return; if ([o.revealed, t.revealed, recent.revealed].some((v) => v !== !!s.revealed)) { void load(true); return; } setOverview({ ...o, trend: t.items, recent: recent.items }); } else if (page === 'schedules') { const rows = await api('/positions?kind=account'); if (active()) setPositions(rows); } else if (['account', 'asset', 'debt'].includes(page)) { if (page === 'account' && !rangeOnly && runDue) { const result = await api<{ executed: number; errors: { message: string }[]; hasMore: boolean; }>('/schedules/run', 'POST', {}); if (!active()) return; if (result.errors.length) setError(result.errors[0].message); if (result.executed) setSuccess(tr('到期计划已记账') + ` (${result.executed})`); } const [p, h] = await Promise.all([ api('/positions?kind=' + page), selected ? api>('/positions/' + selected + '/history?limit=50') : Promise.resolve(null), ]); if (!active()) return; setPositions(s.revealed ? p : p.filter((p) => !p.hidden)); setHistoryRows(h?.items || []); setHistoryCursor(h?.nextCursor || null); } else if (page === 'history') { const [h, t] = await Promise.all([ api>( '/history?' + new URLSearchParams({ from: rangeFrom, to: rangeTo, limit: '50' }), ), api>( '/transfers?' + new URLSearchParams({ from: rangeFrom, to: rangeTo, limit: '50' }), ), ]); if (!active()) return; if ([h.revealed, t.revealed].some((v) => v !== !!s.revealed)) { void load(true); return; } setHistoryRows(h.items); setHistoryCursor(h.nextCursor); setTransfers(t.items); setTransferCursor(t.nextCursor); } else if (page === 'calendar') { setCalendarRefresh((value) => value + 1); } else if (page === 'settings') { const settings = await api( '/settings' + (settingsSection === 'rates' ? '?rates=true' : ''), ); if (!active()) return; setUser(settings); setRates(settings.rates); setFxStatus(settings.fxStatus); } } catch (e) { if (active()) report(e); } finally { if (active()) setLoading(false); } } async function more(kind: 'history' | 'transfers') { const session = sessionGeneration.current, view = viewRef.current, request = loadGeneration.current; const cursor = kind === 'history' ? historyCursor : transferCursor; if (!cursor || loading) return; setLoading(true); try { const accountId = ['account', 'asset', 'debt'].includes(page) ? selected : null; const endpoint = kind === 'history' && accountId ? '/positions/' + accountId + '/history' : '/' + kind; const query = new URLSearchParams({ cursor, limit: '50', ...(!accountId ? { from: rangeFrom, to: rangeTo } : {}), }); const result = await api>(endpoint + '?' + query); if ( session !== sessionGeneration.current || view !== viewRef.current || request !== loadGeneration.current ) return; if (result.revealed !== !!userRef.current?.revealed) { void load(true); return; } if (kind === 'history') { setHistoryRows((rows) => [...rows, ...(result.items as History[])]); setHistoryCursor(result.nextCursor); } else { setTransfers((rows) => [...rows, ...(result.items as Transfer[])]); setTransferCursor(result.nextCursor); } } catch (e) { if (session === sessionGeneration.current && view === viewRef.current) report(e); } finally { if (session === sessionGeneration.current && view === viewRef.current) setLoading(false); } } const [movementPositions, setMovementPositions] = useState([]); const [movementLoading, setMovementLoading] = useState(true); useEffect(() => { if (!modal || !['transfer', 'debt-payment'].includes(modal.kind)) { setMovementPositions([]); setMovementLoading(true); return; } let cancelled = false; const session = sessionGeneration.current; setMovementLoading(true); void api('/positions') .then((rows) => { if (!cancelled && session === sessionGeneration.current) { setMovementPositions(rows); setMovementLoading(false); } }) .catch((error) => { if (!cancelled && session === sessionGeneration.current) report(error); }); return () => { cancelled = true; }; }, [modal]); const [linkOptions, setLinkOptions] = useState([]); useEffect(() => { if (modal?.kind !== 'links') { setLinkOptions([]); return; } let cancelled = false; const session = sessionGeneration.current; void api('/positions') .then((rows) => { if (!cancelled && session === sessionGeneration.current) setLinkOptions(rows.filter((p) => p.kind !== 'debt')); }) .catch((e) => { if (!cancelled && session === sessionGeneration.current) report(e); }); return () => { cancelled = true; }; }, [modal]); const previousView = useRef(''); useEffect(() => { if (!user) return; const mutated = previousDataVersion.current !== dataVersion; previousDataVersion.current = dataVersion; const rangeOnly = !mutated && page === 'overview' && previousView.current.startsWith('overview|') && !!overview; previousView.current = viewKey; if (!rangeOnly) setOverview(null); setHistoryRows([]); setHistoryCursor(null); setTransfers([]); setTransferCursor(null); void load(true, rangeOnly, !mutated); return () => { loadGeneration.current++; }; }, [viewKey, !!user, dataVersion]); useEffect(() => { const session = sessionGeneration.current; authCheck.current ||= api('/auth/me'); void authCheck.current .then((u) => { if (session !== sessionGeneration.current) return; setUser(u); if (u.lastActivity) activityAt.current = +new Date(u.lastActivity); }) .catch((e) => { if (session === sessionGeneration.current && !(e instanceof ApiError && e.status === 401)) report(e); }) .finally(() => setBoot(false)); }, []); useEffect(() => { window.scrollTo({ top: 0 }); }, [page, selected]); useEffect(() => { if (!user) return; const session = sessionGeneration.current; let lastSent = 0, pending = false, revealExpired = false; const activity = () => { if (!document.hidden) { activityAt.current = Date.now(); if (Date.now() - lastSent > 15000 && !pending) { pending = true; lastSent = Date.now(); void api('/auth/activity', 'POST') .catch((e) => { if (session === sessionGeneration.current) report(e); }) .finally(() => { pending = false; }); } } }; const check = window.setInterval(() => { if ( user.idleMinutes && activityAt.current && Date.now() - activityAt.current >= user.idleMinutes * 60000 ) { clearAccount(); setError(tr('长时间无操作,已自动退出登录')); void api('/auth/logout', 'POST').catch(() => {}); } else if (!revealExpired && user.revealUntil && Date.now() >= +new Date(user.revealUntil)) { revealExpired = true; setPositions([]); setOverview(null); setSelected(null); setModal(null); setHistoryRows([]); setTransfers([]); void load(true); } }, 1000); const events = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart']; events.forEach((e) => window.addEventListener(e, activity, { passive: true })); return () => { window.clearInterval(check); events.forEach((e) => window.removeEventListener(e, activity)); }; }, [user?.idleMinutes, user?.revealUntil, !!user]); async function act(work: () => Promise, message: string) { const session = sessionGeneration.current; setBusy(true); setError(''); setSuccess(''); try { const result = await work(); if (session !== sessionGeneration.current) return; setSuccess( result && typeof result === 'object' && 'message' in result && typeof result.message === 'string' ? result.message : message, ); const createdKind = modal && ['account', 'asset', 'debt'].includes(modal.kind) ? modal.kind : null; setModal(null); if ( createdKind && result && typeof result === 'object' && 'id' in result && typeof result.id === 'string' ) { setPage(createdKind); setSelected(result.id); } setDataVersion((value) => value + 1); } catch (e) { if (session === sessionGeneration.current) report(e); } finally { setBusy(false); } } async function auth(e: FormEvent) { e.preventDefault(); const f = new FormData(e.currentTarget); const session = ++sessionGeneration.current; setBusy(true); setError(''); try { const u = await api('/auth/' + (register ? 'register' : 'login'), 'POST', { username: f.get('username'), password: f.get('password'), }); if (session !== sessionGeneration.current) return; setPage('overview'); setSelected(null); setModal(null); activityAt.current = Date.now(); setUser(u); } catch (e) { report(e); } finally { setBusy(false); } } async function logout() { sessionGeneration.current++; setBusy(true); try { await api('/auth/logout', 'POST'); clearAccount(); setError(''); setSuccess(''); } catch (e) { report(e); } finally { setBusy(false); } } const p = positions.find((p) => p.id === selected && p.kind === page), newAction = ( ); if (boot) return
{tr('WorthPath · 正在连接你的资产空间…')}
; if (!user) return (
W WorthPath

YOUR WEALTH, IN PERSPECTIVE

{tr('让每一步积累,')}
{tr('都有清晰的方向。')}

{tr('在一个空间里,连接你的账户、资产与债务。')}
{tr('看见当下,也看见净资产的每一次变化。')}

{tr('关注长期积累')}
{tr('从第一份真实资产开始')}
{tr('个人资产管理 · 多币种 · 历史追踪')}

{tr('开始你的资产之路')}

{register ? tr('创建账号') : tr('欢迎回来')}

{register ? tr('建立属于你的私人资产空间') : tr('登录以查看你的资产与负债')}

{error && (
{tr(error)}
)}

{register ? tr('已有账号?') : tr('还没有账号?')}{' '}

{tr('个人数据按登录身份隔离')}

); async function correctHistory(h: History) { if (h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')) { const session = sessionGeneration.current, view = viewRef.current; try { const transfer = await api('/transfers/revision/' + h.id); if (session === sessionGeneration.current && view === viewRef.current) setModal({ kind: 'transfer-edit', transfer }); } catch (e) { if (session === sessionGeneration.current && view === viewRef.current) report(e); } return; } const session = sessionGeneration.current, view = viewRef.current; try { const p = await api('/positions/' + h.positionId); if (session !== sessionGeneration.current || view !== viewRef.current) return; setModal({ kind: 'correction', p, h }); } catch (e) { if (session === sessionGeneration.current && view === viewRef.current) report(e); } } function deleteHistory(h: History) { setError(''); setModal({ kind: 'delete-record', h }); } async function downloadBackup(forClear = false) { const session = sessionGeneration.current; setBusy(true); setError(''); setSuccess(''); try { const response = await fetch('/api/backup', { credentials: 'same-origin' }); if (!response.ok) { const result = await response.json().catch(() => ({})); throw new ApiError(result.message || tr('备份下载失败'), response.status); } if (!response.headers.get('content-type')?.includes('application/zip')) throw new Error(tr('备份文件格式错误')); const blob = await response.blob(); if (!blob.size) throw new Error(tr('备份文件为空')); if (session !== sessionGeneration.current) return; const url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = 'worthpath-' + today() + '.zip'; document.body.appendChild(a); a.click(); a.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 60000); setSuccess(tr('备份已生成,请确认文件已保存')); if (forClear) setClearStep(1); } catch (e) { if (session === sessionGeneration.current) report(e); } finally { if (session === sessionGeneration.current) setBusy(false); } } const nav = [ ['overview', LayoutDashboard], ['account', Wallet], ['asset', Landmark], ['debt', HandCoins], ['history', HistoryIcon], ['calendar', CalendarDays], ['schedules', HistoryIcon], ['settings', Settings], ] as const; const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key)); const groups = orderedGroups( positions.filter((p) => p.kind === 'account').map((p) => p.groupName || ''), user.accountGroupOrder, ); const groupRanks = new Map(groups.map((g, index) => [g, index])); return createElement( GroupOrderContext.Provider, { value: user.accountGroupOrder || [] },
{ }
{tr('个人财务 /')} {tr(labels[page])}
{tr('本位币')} {user.baseCurrency}
{user.revealed ? tr('当前包含隐藏项目 · 验证 5 分钟后自动锁定') : tr('当前不包含隐藏账户、资产和债务,净资产按此范围计算')}

WORTHPATH / {today()}

{p?.iconId && ( {tr('账户图标')} )} {p ? p.name : tr(labels[page])}

{page === 'overview' ? tr('你的财富全貌,从这里开始。') : page === 'settings' ? tr('让数据始终可控,让换算清晰可靠。') : tr('保留原币金额,追踪长期变化。')}

{page === 'account' && !p && ( )} {['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction}
{error && (
{error}
)} {success && (
{tr(success)}
)} {loading && (

{tr('正在刷新数据…')}

)} {page === 'overview' && overview && ( <> {!overview.complete && (
{tr('缺少')} {overview.missing.join('、')} {tr('到')} {user.baseCurrency} {tr('的汇率。以下仅展示已能换算的部分,不能作为完整总额。')}
)}
{[ [tr('总资产'), overview.assets, 'asset'], [tr('总负债'), overview.liabilities, 'debt'], [tr('净资产'), overview.net, 'net'], ].map(([title, value, cls]) => (
{title} {!overview.complete ? tr('(部分)') : ''}
{money(value, user.baseCurrency)} {title === tr('净资产') ? tr('总资产 − 总负债') : title === tr('总资产') ? tr('账户资产 + 独立资产') : tr('负债账户 + 独立债务')}
))}

{tr('净资产轨迹')}

{tr('按业务日期')}

{tr( '默认最近90天;周一至周日及自然月取期末余额,变化归因累计区间内每日变化,首尾不足周期按实际范围。', )}

{tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')}

{tr('资产与负债组成')}

{overview.items.length ? ( ['account', 'asset', 'debt'].map((k) => ( )) ) : ( )}

{tr('关联关系不会重复计入总额')}

{tr('最近变化')}

{ setSelected(id); setPage(overview.recent.find((h) => h.positionId === id)?.kind || 'account'); }} />
{tr('本位币')} {user.baseCurrency} {tr('· 汇率日期:')} {[ ...new Set( overview.items .filter((i) => i.currency !== user.baseCurrency && i.rateDate) .map((i) => i.rateDate), ), ].join(' / ') || tr('尚无外币汇率')}{' '} {tr('· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。')}
{overview.trend.length > 1 && (
{tr('变化归因')}
{overview.trend.slice(-10).map((r) => ( ))}
{tr('日期')} {tr('余额 / 估值变化')} {tr('汇率变化')} {tr('净资产')}
{r.date} {r.balanceChange === null || r.balanceChange === undefined ? '—' : money(r.balanceChange, user.baseCurrency)} {r.fxChange === null || r.fxChange === undefined ? '—' : money(r.fxChange, user.baseCurrency)} {r.complete ? money(r.net, user.baseCurrency) : tr('汇率不完整')}
)} )} {['account', 'asset', 'debt'].includes(page) && (p ? ( <>
{categoryLabel(p)} ·{' '} {p.kind === 'debt' ? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)') : p.side === 'asset' ? tr('资产') : tr( p.kind === 'account' && p.amount.startsWith('-') ? '溢缴存款' : '负债', )} {p.archived ? tr(' · 已归档') : ''}

{money(positionAmount(p), p.currency)}

{p.hidden && {tr('隐藏项目')}}

{tr('本位币:')} {p.converted !== null ? money(positionAmount(p, p.converted || '0'), user.baseCurrency) : tr('缺少汇率')}

{p.notes || tr('暂无备注')}

{tr('币种和资产负债属性创建后固定。归档项目仍计入总览。')}
{p.kind === 'account' && p.side === 'asset' && !p.archived && ( <> )} {p.kind === 'debt' && !p.archived && ( <> )}
{p.kind === 'debt' && (

{tr('关联资产与账户')}

{tr('只展示关系,不改变资产或债务的统计。')}

{p.outgoing.length ? ( p.outgoing.map((l) => ( )) ) : (

{tr('尚未关联')}

)}
)}

{tr('余额与估值历史')}

{tr( '每次更新保留独立历史,同日按记录顺序展示。错误记录可单独更正,后续变化自动重算。', )}

void correctHistory(h)} remove={p.archived ? undefined : deleteHistory} busy={busy} /> {historyCursor && ( )}
) : (
{page === 'account' && ( <>
{quickMode &&

{tr('点击账户卡片,直接填写当前余额。')}

} )} {positions.some((p) => p.kind === page) ? (
{positions .filter( (p) => p.kind === page && (page !== 'account' || accountGroup === null || (p.groupName || '') === accountGroup), ) .sort((a, b) => page === 'account' ? (groupRanks.get(a.groupName || '') || 0) - (groupRanks.get(b.groupName || '') || 0) : 0, ) .map((p, index, rows) => ( {page === 'account' && accountGroup === null && (index === 0 || (rows[index - 1].groupName || '') !== (p.groupName || '')) && (

{p.groupName || tr('未分组')}

)}
))}
) : ( )}
))} {page === 'history' && ( <>

{tr('资金往来')}

{transfers.length ? (
{transfers.map((t) => (
{operationLabel(t.operation || 'transfer')} · {' ↔ '} {displayTime(t.date)}

{money(t.amount, t.sourceCurrency)} →{' '} {money(t.received, t.targetCurrency)} {tr('· 手续费')} {money(t.fee, t.sourceCurrency)}

{t.notes &&

{t.notes}

}
))}
) : (

{tr('暂无账户间转账记录。')}

)} {transferCursor && ( )}

{tr('全部金额变化')}

{tr('原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。')}

void correctHistory(h)} remove={deleteHistory} busy={busy} open={(id) => { setSelected(id); setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account'); }} /> {historyCursor && ( )}
)} {page === 'schedules' && (
p.kind === 'account' && p.side === 'asset' && !p.archived, )} refresh={() => load(false, true)} />
)} {page === 'calendar' && ( )} {page === 'settings' && ( <>
{settingsSection === 'icons' && (
)} {settingsSection === 'security' && (

{tr('账号与密码')}

{tr('修改后其他登录会话将退出,当前会话保持登录。')}

{ e.preventDefault(); const form = e.currentTarget, f = new FormData(form); const username = String(f.get('username')).trim(), newPassword = String(f.get('newPassword')); if (newPassword !== String(f.get('confirmPassword'))) { setError(tr('两次输入的新密码不一致')); return; } void act(async () => { const result = await api('/auth/credentials', 'PATCH', { currentPassword: f.get('currentPassword'), ...(username !== user.username ? { username } : {}), ...(newPassword ? { newPassword } : {}), }); form.reset(); return result; }, tr('账号与密码已更新')); }} >
)}
)}
{tr('WorthPath · 记录你的积累,理解你的财富')}
{clearConfirmation > 0 && ( { if (!busy) setClearConfirmation(0); }} >

{clearConfirmation === 1 ? tr('将清空本账号全部财务数据及私有图标,请确认备份已保存。') : tr('这是最后一次确认。清空后只能通过备份恢复,其他用户的数据不受影响。')}

{tr('登录账号、个人设置和已发布的共享图标会保留。')}

{clearConfirmation === 1 ? ( <> ) : ( <> )}
{error && (

{error}

)}
)} {modal?.kind === 'group-order' && modal.groups && ( !busy && setModal(null)}> setModal((current) => current?.kind === 'group-order' ? { ...current, groups } : current, ) } />
)} {modal?.kind === 'delete-record' && ( { if (!busy) setModal(null); }} >

{modal.h ? modal.h.name : modal.transfer?.source.name + ' ↔ ' + modal.transfer?.target.name}

{displayTime(modal.h?.time || modal.transfer!.date)}

{tr( modal.transfer || modal.h?.reason.startsWith('transfer_') || modal.h?.reason.startsWith('loan_') ? '这笔资金往来的双方记录将一起删除,并重算后续余额。' : '这条记录将被删除,并重算后续余额。', )}

{tr('删除后无法撤销,请确认已保存所需备份。')}

{error && (

{tr(error)}

)}
)} {modal?.kind === 'transfer-edit' && modal.transfer && ( { if (!busy) setModal(null); }} >
{ e.preventDefault(); const t = modal.transfer!, f = Object.fromEntries(new FormData(e.currentTarget)); void act( () => api('/transfers/' + t.id, 'PUT', { ...f, sourceId: t.sourceId, targetId: t.targetId, operation: t.operation, }), tr('记录已修改,余额和日历已更新'), ); }} >

{modal.transfer.source.name} ↔ {modal.transfer.target.name}