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 { NetWorthChart } from './NetWorthChart'; import { allOverviewCards, overviewCardLabels } from './overview-cards'; import { useToast } from './Toast'; import { GroupOrderContext } from './GroupOrderContext'; import { GroupOrderList } from './GroupOrderList'; import { orderedGroups, mergeGroupOrder } from './group-order'; import { Calendar } from './Calendar'; import { SchedulePanel } from './SchedulePanel'; import { AgentConnections } from './AgentConnections'; import { AgentAuthorization } from './AgentReview'; import { AgentDrafts } from './AgentDrafts'; import { savedLogin, rememberLogin } from './login-preferences'; import { AdminPanel, FirstPassword } from './AdminPanel'; import { PositionDeletion, deletionLabel } from './PositionDeletion'; import { IconPicker } from './IconPicker'; import { MetalPanel } from './MetalPanel'; import { QuickTransfer } from './QuickTransfer'; import { QuickJournal, QuickRepayment, isPaidForMonth, isBookedToday, quickAccountVisible, canQuickRepay, type QuickEntry, } from './QuickJournal'; 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: '设置与备份', admin: '管理员后台', }; const categories: Record = { account: [ ['bank', '银行账户'], ['cash', '现金'], ['investment', '投资账户'], ['credit_card', '信用卡'], ['loan', '贷款账户'], ['other', '其他'], ], asset: [ ['cash', '现金'], ['property', '房产'], ['vehicle', '车辆'], ['gold', '贵金属'], ['commemorative_coin', '纪念币'], ['commemorative_note', '纪念钞'], ['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}
); } 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), [page, setPage] = useState( new URLSearchParams(location.search).has('agent_authorization') ? 'agent-review' : '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; quick?: boolean; groups?: string[]; } | null>(null), [busy, setBusy] = useState(false), [loading, setLoading] = useState(false), [error, setError] = useState(''), [success, setSuccess] = useState(''); const [calendarRefresh, setCalendarRefresh] = useState(0); const [newAssetCategory, setNewAssetCategory] = useState(categories.asset[0][0]); useEffect(() => { setNewAssetCategory(categories.asset[0][0]); }, [modal?.kind]); const [quickTransfer, setQuickTransfer] = useState<{ sourceId?: string; targetId?: string; } | null>(null); const [quickEntries, setQuickEntries] = useState([]); const [quickMarkMode, setQuickMarkMode] = useState(false); const [quickBookedMode, setQuickBookedMode] = useState(false); const [hideBooked, setHideBooked] = useState(false); const [unpaidOnly, setUnpaidOnly] = useState(false); const [assetQuickMode, setAssetQuickMode] = useState(false); const [assetValueMode, setAssetValueMode] = useState(false); const [remember, setRemember] = useState(() => savedLogin().remember); const [draftId, setDraftId] = useState(() => new URLSearchParams(location.search).get('agent_operation'), ); const [businessDay, setBusinessDay] = useState(today()); const [repaymentMonth, setRepaymentMonth] = useState(today().slice(0, 7)); const quickGeneration = useRef(0); useEffect(() => { const updateDay = () => { setBusinessDay(today()); setRepaymentMonth(today().slice(0, 7)); }; const timer = setInterval(updateDay, 60000); window.addEventListener('focus', updateDay); return () => { clearInterval(timer); window.removeEventListener('focus', updateDay); }; }, []); useEffect(() => { quickGeneration.current++; setQuickEntries([]); setQuickMarkMode(false); setQuickBookedMode(false); setHideBooked(false); setUnpaidOnly(false); setAssetQuickMode(false); }, [user?.username, user?.revealed]); const [quickMode, setQuickMode] = useState(false), [accountGroup, setAccountGroup] = useState(null); useEffect(() => { setQuickTransfer(null); }, [page, quickMode, selected, user?.revealed]); useEffect(() => { setQuickTransfer((current) => current && [current.sourceId, current.targetId].some( (id) => id && !positions.some((p) => p.id === id && p.kind === 'account' && !p.archived), ) ? null : current, ); }, [positions]); function toggleQuickMode() { quickGeneration.current++; if (!quickMode) setQuickEntries([]); setQuickMode(!quickMode); setQuickMarkMode(false); setQuickBookedMode(false); setHideBooked(false); setUnpaidOnly(false); setQuickTransfer(null); } function rememberQuick(entry: QuickEntry, generation: number, session: number) { if (generation === quickGeneration.current && session === sessionGeneration.current) setQuickEntries((rows) => [entry, ...rows]); } async function saveQuickBalance( position: Position, data: Record, track: boolean, ) { const generation = quickGeneration.current, session = sessionGeneration.current; const result = await api<{ id: string }>( '/positions/' + position.id + '/revisions', 'POST', data, ); if (track) rememberQuick( { key: 'balance:' + result.id, kind: 'balance', id: result.id, positionId: position.id, title: position.name, currency: position.currency, before: positionAmount(position), after: positionAmount(position, String(data.amount)), date: String(data.date), }, generation, session, ); return result; } async function saveQuickMovement(data: unknown, label: string) { const value = data as Record, generation = quickGeneration.current, session = sessionGeneration.current; const result = await api<{ id: string }>('/transfers', 'POST', data); const source = positions.find((p) => p.id === value.sourceId), target = positions.find((p) => p.id === value.targetId); rememberQuick( { key: 'transfer:' + result.id, kind: 'transfer', id: result.id, positionId: value.sourceId, title: label + ' · ' + (source?.name || '') + ' → ' + (target?.name || ''), currency: source?.currency || '', amount: value.amount, received: value.received, fee: value.fee, targetCurrency: target?.currency, date: value.date, }, generation, session, ); return result; } async function markQuickRepayment(position: Position) { const month = today().slice(0, 7), previousMonth = position.lastRepaymentMonth ?? null; const nextMonth = isPaidForMonth(position, month) ? null : month; const generation = quickGeneration.current, session = sessionGeneration.current; const result = await api<{ updatedAt: string }>( '/positions/' + position.id + '/repayment-mark', 'PATCH', { month: nextMonth, expectedMonth: previousMonth, expectedUpdatedAt: position.updatedAt }, ); rememberQuick( { key: 'mark:' + position.id + ':' + Date.now(), kind: 'mark', id: position.id, positionId: position.id, title: position.name, currency: position.currency, date: nowMinute(), previousMonth, month: nextMonth, markUpdatedAt: result.updatedAt, }, generation, session, ); return result; } async function markQuickBooked(position: Position) { const previousDate = position.lastBookedDate ?? null, day = today(); const nextDate = isBookedToday(position, day) ? null : day; const generation = quickGeneration.current, session = sessionGeneration.current; const result = await api<{ updatedAt: string }>( '/positions/' + position.id + '/booked-mark', 'PATCH', { date: nextDate, expectedDate: previousDate, expectedUpdatedAt: position.updatedAt, }, ); rememberQuick( { key: 'booked:' + position.id + ':' + Date.now(), kind: 'booked', id: position.id, positionId: position.id, title: position.name, currency: position.currency, date: nowMinute(), previousDate, bookedDate: nextDate, markUpdatedAt: result.updatedAt, }, generation, session, ); return result; } async function toggleAssetInclusion(position: Position) { return api('/positions/' + position.id + '/inclusion', 'PATCH', { included: position.included === false, expectedIncluded: position.included !== false, expectedUpdatedAt: position.updatedAt, }); } function undoQuick(entry: QuickEntry) { void act(async () => { if (entry.kind === 'balance') await api('/positions/' + entry.positionId + '/revisions/' + entry.id, 'DELETE'); else if (entry.kind === 'transfer') await api('/transfers/' + entry.id, 'DELETE'); else if (entry.kind === 'booked') await api('/positions/' + entry.positionId + '/booked-mark', 'PATCH', { date: entry.previousDate ?? null, expectedDate: entry.bookedDate ?? null, expectedUpdatedAt: entry.markUpdatedAt, }); else await api('/positions/' + entry.positionId + '/repayment-mark', 'PATCH', { month: entry.previousMonth ?? null, expectedMonth: entry.month ?? null, expectedUpdatedAt: entry.markUpdatedAt, }); setQuickEntries((rows) => rows.map((row) => (row.key === entry.key ? { ...row, undone: true } : row)), ); }, tr('记录已撤回,相关余额已更新')); } function chooseQuickAccount(p: Position) { if (busy || loading) return; if (p.kind !== 'account' || p.archived) { setError(tr('请选择启用的账户')); return; } setQuickTransfer((current) => { if (!current) return current; if (!current.sourceId) return { sourceId: p.id }; if (!current.targetId && p.id !== current.sourceId) return { ...current, targetId: p.id }; return current; }); } 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); useToast(error, 'error'); useToast(success, 'success'); useToast(fxStatus?.state === 'error' ? fxStatus.message : '', 'error'); const [settingsSection, setSettingsSection] = useState( new URLSearchParams(location.search).has('agent_authorization') || new URLSearchParams(location.search).has('agent_operation') ? 'agent' : '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); quickGeneration.current++; setQuickEntries([]); setQuickMarkMode(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'); setLoading(false); setSuccess(''); setQuickTransfer(null); } 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; if (s.mustChangePassword) { setUser(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 && s.role !== 'readonly') { 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 || user.mustChangePassword) 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, user?.mustChangePassword, 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/login', 'POST', { username: f.get('username'), password: f.get('password'), }); if (session !== sessionGeneration.current) return; void rememberLogin(String(f.get('username')), String(f.get('password')), remember); const agentReturn = new URLSearchParams(location.search).has('agent_authorization'); setPage(agentReturn ? 'agent-review' : 'overview'); if (agentReturn) setSettingsSection('agent'); 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('开始你的资产之路')}

{tr('欢迎回来')}

{tr('登录以查看你的资产与负债')}

{ <>

{tr('密码由浏览器密码管理器保存,下次登录可自动填充。')}

}

{tr('账号由管理员创建,请联系管理员获取账号。')}

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

); if (user.mustChangePassword) return void logout()} report={report} />; 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 agentParams = new URLSearchParams(location.search); if (agentParams.has('agent_authorization') || location.pathname === '/agent/authorize') { return (
WorthPath {user.username}
); } const nav = [ ['overview', LayoutDashboard], ['account', Wallet], ['asset', Landmark], ['debt', HandCoins], ['history', HistoryIcon], ['calendar', CalendarDays], ['schedules', HistoryIcon], ['settings', Settings], ['admin', ShieldCheck], ] as const; const visibleNav = nav.filter(([key]) => key === 'admin' ? user.role === 'admin' : !(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('保留原币金额,追踪长期变化。')}

{user.role !== 'readonly' && page === 'account' && !p && !quickMode && ( )} {user.role !== 'readonly' && ['overview', 'account', 'asset', 'debt'].includes(page) && !p && !(page === 'account' && quickMode) && newAction}
{user.role === 'readonly' && (

{tr('当前为只读账号,无法修改业务数据。')}

)} {loading && (

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

)} {page === 'overview' && overview && ( <> {user.overviewCards?.length === 0 && (

{tr('尚未选择总览卡片')}

)} {!!overview.missing.length && (
{tr('缺少')} {overview.missing.join('、')} {tr('到')} {user.baseCurrency} {tr('的汇率。以下仅展示已能换算的部分,不能作为完整总额。')}
)} {!!overview.missingValuations?.length && (
{tr('待估值')}:{overview.missingValuations.map((p) => p.name).join('、')} ·{' '} {tr('缺少报价,当前总额不完整。')}
)}
(user.overviewCards ?? allOverviewCards).includes(key), ) ? ' single-card' : '') } hidden={ !['trend', 'composition'].some((key) => (user.overviewCards ?? allOverviewCards).includes(key), ) } >
{tr('本位币')} {user.baseCurrency} {tr('· 汇率日期:')} {[ ...new Set( overview.items .filter((i) => i.currency !== user.baseCurrency && i.rateDate) .map((i) => i.rateDate), ), ].join(' / ') || tr('尚无外币汇率')}{' '} {tr('· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。')}
{(user.overviewCards ?? allOverviewCards).includes('attribution') && 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(' · 已归档') : ''}

{p.valuationAvailable === false ? tr('待估值') : money(positionAmount(p), p.currency)} {p.metalGrams && · {p.metalGrams} g}

{p.hidden && {tr('隐藏资产')}}

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

{(p.included === false || (p.kind === 'asset' && user.includeIndependentAssets === false)) && ( {tr('不计入总览')} )} {tr('币种和资产负债属性创建后固定。归档项目仍计入总览。')}
{p.kind === 'account' && !p.archived && ( <> )} {p.kind === 'debt' && !p.archived && ( <> )}
{p.kind === 'asset' && p.category === 'gold' && ( load(false, true)} /> )} {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 && ( )} {quickMode && ( <> )}
{quickMode && (

{tr( quickBookedMode ? '点击账户卡片,标记或取消当天已记账。' : quickMarkMode ? '点击欠款账户卡片,标记或取消本月已还款。' : quickTransfer !== null ? '快速转账中:依次点击转出和转入账户卡片。' : '点击账户卡片,直接填写当前余额。', )}

)} )} {page === 'asset' && ( <>
{assetValueMode && (

{tr('点击资产卡片填写最新估值;贵金属按报价自动估值。')}

)} {assetQuickMode && (

{tr('点击资产卡片,切换是否计入总资产。')}

)} {assetQuickMode && user.includeIndependentAssets === false && (

{tr('独立资产总开关已关闭;单项设置已保存,开启总开关后生效。')}

)} )} {page === 'account' && quickMode && positions.some((p) => p.kind === 'account') && !positions.some( (p) => p.kind === 'account' && (accountGroup === null || (p.groupName || '') === accountGroup) && quickAccountVisible(p, repaymentMonth, businessDay, unpaidOnly, hideBooked), ) && (

{tr('当前筛选下没有账户,可切换筛选查看其他账户。')}

)} {positions.some((p) => p.kind === page) ? (
{positions .filter( (p) => p.kind === page && (page !== 'account' || accountGroup === null || (p.groupName || '') === accountGroup) && (page !== 'account' || !quickMode || quickAccountVisible( p, repaymentMonth, businessDay, unpaidOnly, hideBooked, )), ) .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 === 'account' && quickMode && !selected && ( )} {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.archived)} refresh={() => load(false, true)} />
)} {page === 'calendar' && ( )} {page === 'admin' && user.role === 'admin' && } {page === 'settings' && ( <> {settingsSection === 'icons' && (
)} {settingsSection === 'agent' && void load()} />} {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('账号与密码已更新')); }} >
)}
{settingsSection === 'rates' && load(false, true)} />} )}
{tr('WorthPath · 记录你的积累,理解你的财富')}
{clearConfirmation > 0 && ( { if (!busy) setClearConfirmation(0); }} >

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

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

{clearConfirmation === 1 ? ( <> ) : ( <> )}
)} {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('删除后无法撤销,请确认已保存所需备份。')}

)} {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}