Files
WorthPath/apps/web/src/App.tsx
T

3525 lines
140 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
overview: '资产总览',
account: '账户',
asset: '独立资产',
debt: '债务',
history: '变化记录',
calendar: '收支日历',
schedules: '定时计划',
settings: '设置与备份',
admin: '管理员后台',
};
const categories: Record<string, [string, string][]> = {
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 (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className={label === tr('备注') ? 'field note-field' : 'field'}>
<span>{tr(label)}</span>
{children}
</label>
);
}
function Modal({
title,
close,
children,
}: {
title: string;
close: () => void;
children: ReactNode;
}) {
return (
<div
className="veil"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<section role="dialog" aria-modal="true" aria-label={title} className="modal">
<header>
<h2 className={title.includes(tr('清空本账号数据')) ? 'danger-text' : undefined}>
{title}
</h2>
<button className="icon" aria-label={tr('关闭')} onClick={close}>
<X size={20} />
</button>
</header>
{children}
</section>
</div>
);
}
function Empty({ title, body, action }: { title: string; body: string; action?: ReactNode }) {
return (
<div className="empty">
<Wallet size={32} />
<h3>{title}</h3>
<p>{body}</p>
{action}
</div>
);
}
export default function App() {
const locale = useLocale();
const sessionGeneration = useRef(0),
loadGeneration = useRef(0);
const authCheck = useRef<Promise<User> | null>(null);
const [user, setUser] = useState<User | null>(null),
[boot, setBoot] = useState(true),
[page, setPage] = useState(
new URLSearchParams(location.search).has('agent_authorization') ? 'agent-review' : 'overview',
),
[positions, setPositions] = useState<Position[]>([]),
[overview, setOverview] = useState<Overview | null>(null),
[rates, setRates] = useState<Rate[]>([]),
[selected, setSelected] = useState<string | null>(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<QuickEntry[]>([]);
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<string | null>(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<string, unknown>,
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<string, string>,
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<unknown>(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<Transfer[]>([]);
const [historyRows, setHistoryRows] = useState<History[]>([]),
[historyCursor, setHistoryCursor] = useState<string | null>(null),
[transferCursor, setTransferCursor] = useState<string | null>(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<User>('/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<PageResult<Total>>(
'/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<Omit<Overview, 'trend' | 'recent'>>('/overview'),
api<PageResult<Total>>(
'/trend?' + new URLSearchParams({ from: rangeFrom, to: rangeTo, grain }),
),
api<PageResult<History>>('/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<Position[]>('/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<Position[]>('/positions?kind=' + page),
selected
? api<PageResult<History>>('/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<PageResult<History>>(
'/history?' + new URLSearchParams({ from: rangeFrom, to: rangeTo, limit: '50' }),
),
api<PageResult<Transfer>>(
'/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<User & { rates: Rate[]; fxStatus: typeof fxStatus }>(
'/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<PageResult<History | Transfer>>(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<Position[]>([]);
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<Position[]>('/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<Position[]>([]);
useEffect(() => {
if (modal?.kind !== 'links') {
setLinkOptions([]);
return;
}
let cancelled = false;
const session = sessionGeneration.current;
void api<Position[]>('/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<User>('/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<unknown>, 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<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
const session = ++sessionGeneration.current;
setBusy(true);
setError('');
try {
const u = await api<User>('/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 = (
<button
className="primary"
onClick={() => setModal({ kind: page === 'overview' ? 'account' : page })}
>
<Plus size={17} />
{tr('新增')}
{page === 'overview' ? tr('账户') : tr(labels[page])}
</button>
);
if (boot) return <div className="splash">{tr('WorthPath · 正在连接你的资产空间…')}</div>;
if (!user)
return (
<div className="auth-layout">
<div className="auth-language">
<LanguagePicker />
</div>
<section className="auth-story">
<div className="brand">
<span>W</span> WorthPath
</div>
<div>
<p className="eyebrow">YOUR WEALTH, IN PERSPECTIVE</p>
<h1>
{tr('让每一步积累,')}
<br />
{tr('都有清晰的方向。')}
</h1>
<p>
{tr('在一个空间里,连接你的账户、资产与债务。')}
<br />
{tr('看见当下,也看见净资产的每一次变化。')}
</p>
<div className="story-line">
<TrendingUp size={32} />
<span>
{tr('关注长期积累')}
<br />
<small>{tr('从第一份真实资产开始')}</small>
</span>
</div>
</div>
<small>{tr('个人资产管理 · 多币种 · 历史追踪')}</small>
</section>
<section className="auth-form">
<div>
<p className="eyebrow">{tr('开始你的资产之路')}</p>
<h2>{tr('欢迎回来')}</h2>
<p className="muted">{tr('登录以查看你的资产与负债')}</p>
<form onSubmit={auth} autoComplete="on">
<Field label={tr('账号')}>
<input
name="username"
defaultValue={savedLogin().username}
autoComplete="username"
required
minLength={3}
maxLength={64}
placeholder={tr('输入账号(至少 3 个字符)')}
/>
</Field>
<Field label={tr('密码')}>
<input
name="password"
type="password"
autoComplete="current-password"
required
minLength={1}
maxLength={72}
placeholder={tr('输入密码')}
/>
</Field>
{
<>
<label className="check-line">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
{tr('记住账号密码')}
</label>
<p className="muted">{tr('密码由浏览器密码管理器保存,下次登录可自动填充。')}</p>
</>
}
<button className="primary full" disabled={busy}>
{busy ? tr('正在处理…') : tr('登录')}
<ArrowUpRight size={18} />
</button>
</form>
<p className="muted">{tr('账号由管理员创建,请联系管理员获取账号。')}</p>
<p className="privacy">
<ShieldCheck size={16} />
{tr('个人数据按登录身份隔离')}
</p>
</div>
</section>
</div>
);
if (user.mustChangePassword)
return <FirstPassword changed={setUser} logout={() => 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<Transfer>('/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<Position>('/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 (
<main className="agent-review-page">
<header className="agent-review-header">
<a href="/">WorthPath</a>
<span>{user.username}</span>
<button className="text" disabled={busy} onClick={() => void logout()}>
退出登录
</button>
</header>
<AgentAuthorization id={agentParams.get('agent_authorization')} />
</main>
);
}
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 || [] },
<div className={user.showNotes === false ? 'app notes-hidden' : 'app'}>
{
<aside className="sidebar">
<div className="brand">
<span>W</span> WorthPath
</div>
<p className="nav-caption">{tr('我的资产空间')}</p>
<nav style={{ gridTemplateColumns: `repeat(${visibleNav.length}, 1fr)` }}>
{visibleNav.map(([key, Icon]) => (
<button
key={key}
className={page === key ? 'active' : ''}
onClick={() => {
setPage(key);
setSelected(null);
}}
>
<Icon size={19} />
<span>{tr(labels[key])}</span>
</button>
))}
</nav>
<div className="side-bottom">
<div className="avatar">{user.username.slice(0, 1).toUpperCase()}</div>
<div>
<strong>{user.username}</strong>
<small>
{tr('个人账户 ·')}
{user.baseCurrency}
</small>
</div>
<button
className="icon"
aria-label={tr('退出登录')}
disabled={busy}
onClick={() => void logout()}
>
<LogOut size={18} />
</button>
</div>
</aside>
}
<main>
<header className="topbar">
<span>
{tr('个人财务 /')}
{tr(labels[page])}
</span>
<div>
<LanguagePicker />
<span className="currency-pill">
{tr('本位币')}
{user.baseCurrency}
</span>
<button
className="icon"
aria-label={tr('刷新数据')}
disabled={loading}
onClick={() => void load()}
>
<RefreshCw size={17} />
</button>
</div>
</header>
<div
className={
page === 'account' && quickMode && !selected ? 'content quick-content' : 'content'
}
>
<div className="privacy-toolbar">
<span>
{user.revealed
? tr('当前包含隐藏资产 · 5 分钟后自动锁定')
: tr('当前不包含隐藏账户、资产和债务,净资产按此范围计算')}
</span>
<button
className="secondary"
disabled={busy}
onClick={() =>
user.revealed
? void act(() => api('/auth/lock', 'POST'), tr('隐藏资产已锁定'))
: user.requireHiddenPassword === false
? void act(() => api('/auth/reveal', 'POST', {}), tr('隐藏资产已显示'))
: setModal({ kind: 'reveal' })
}
>
{user.revealed
? tr('锁定隐藏资产')
: user.requireHiddenPassword === false
? tr('显示隐藏资产')
: tr('显示隐藏资产(验证密码)')}
</button>
</div>
<div className="page-heading">
<div>
<p className="eyebrow">WORTHPATH / {today()}</p>
<h1>
{p?.iconId && (
<img className="detail-icon" src={iconUrl(p.iconId)} alt={tr('账户图标')} />
)}
{p ? p.name : tr(labels[page])}
</h1>
<p className="muted">
{page === 'overview'
? tr('你的财富全貌,从这里开始。')
: page === 'settings'
? tr('让数据始终可控,让换算清晰可靠。')
: tr('保留原币金额,追踪长期变化。')}
</p>
</div>
<div className="actions">
{user.role !== 'readonly' && page === 'account' && !p && !quickMode && (
<button className="secondary" onClick={() => setModal({ kind: 'transfer' })}>
{tr('转账')}
</button>
)}
{user.role !== 'readonly' &&
['overview', 'account', 'asset', 'debt'].includes(page) &&
!p &&
!(page === 'account' && quickMode) &&
newAction}
</div>
</div>
{user.role === 'readonly' && (
<p className="muted" role="status">
{tr('当前为只读账号,无法修改业务数据。')}
</p>
)}
{loading && (
<p role="status" className="muted">
{tr('正在刷新数据…')}
</p>
)}
{page === 'overview' && overview && (
<>
{user.overviewCards?.length === 0 && (
<section className="panel">
<p>{tr('尚未选择总览卡片')}</p>
<button
className="text"
onClick={() => {
setPage('settings');
setSettingsSection('display');
}}
>
{tr('设置显示卡片')}
</button>
</section>
)}
{!!overview.missing.length && (
<div className="notice warning">
{tr('缺少')}
{overview.missing.join('、')} {tr('到')}
{user.baseCurrency} {tr('的汇率。以下仅展示已能换算的部分,不能作为完整总额。')}
<button className="text" onClick={() => setPage('settings')}>
{tr('设置汇率')}
</button>
</div>
)}
{!!overview.missingValuations?.length && (
<div className="notice warning">
{tr('待估值')}:{overview.missingValuations.map((p) => p.name).join('、')} ·{' '}
{tr('缺少报价,当前总额不完整。')}
</div>
)}
<div
className="metrics"
hidden={
!['assets', 'liabilities', 'net'].some((key) =>
(user.overviewCards ?? allOverviewCards).includes(key),
)
}
>
{[
[tr('总资产'), overview.assets, 'asset'],
[tr('总负债'), overview.liabilities, 'debt'],
[tr('净资产'), overview.net, 'net'],
]
.filter(([, , cls]) =>
(user.overviewCards ?? allOverviewCards).includes(
cls === 'asset' ? 'assets' : cls === 'debt' ? 'liabilities' : cls,
),
)
.map(([title, value, cls]) => (
<section className={'metric ' + cls} key={title}>
<div>
<span>
{title}
{!overview.complete ? tr('(部分)') : ''}
</span>
<ArrowUpRight size={18} />
</div>
<strong
className={
cls === 'debt' || value.startsWith('-') ? 'danger-text' : 'income-text'
}
>
{money(value, user.baseCurrency)}
</strong>
<small>
{title === tr('净资产')
? tr('总资产 − 总负债')
: title === tr('总资产')
? tr('账户资产 + 独立资产')
: tr('负债账户 + 独立债务')}
</small>
</section>
))}
</div>
<div
className={
'dashboard-grid' +
(!['trend', 'composition'].every((key) =>
(user.overviewCards ?? allOverviewCards).includes(key),
)
? ' single-card'
: '')
}
hidden={
!['trend', 'composition'].some((key) =>
(user.overviewCards ?? allOverviewCards).includes(key),
)
}
>
<section
className="panel"
hidden={!(user.overviewCards ?? allOverviewCards).includes('trend')}
>
<div className="panel-title">
<h2>{tr('净资产轨迹')}</h2>
<span className="badge">{tr('按业务日期')}</span>
</div>
<div className="actions overview-filters">
<label>
{tr('开始')}
<input
type="date"
value={rangeFrom}
max={rangeTo}
onChange={(e) => setRangeFrom(e.target.value)}
/>
</label>
<label>
{tr('结束')}
<input
type="date"
value={rangeTo}
min={rangeFrom}
max={today()}
onChange={(e) => setRangeTo(e.target.value)}
/>
</label>
<select
aria-label={tr('统计粒度')}
value={grain}
onChange={(e) => setGrain(e.target.value)}
>
<option value="day">{tr('日(最多366天)')}</option>
<option value="week">{tr('周(最多3年)')}</option>
<option value="month">{tr('月(最多10年)')}</option>
</select>
</div>
<p className="muted">
{tr(
'默认最近90天;周一至周日及自然月取期末余额,变化归因累计区间内每日变化,首尾不足周期按实际范围。',
)}
</p>
<NetWorthChart rows={overview.trend} base={user.baseCurrency} />
<p className="muted">
{tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')}
</p>
</section>
<section
className="panel"
hidden={!(user.overviewCards ?? allOverviewCards).includes('composition')}
>
<h2>{tr('资产与负债组成')}</h2>
{overview.items.length ? (
['account', 'asset', 'debt'].map((k) => (
<button
className="composition"
key={k}
onClick={() => {
setPage(k);
setSelected(null);
}}
>
<span>
{tr(labels[k])}
<small>
{overview.items.filter((p) => p.included && p.kind === k).length}{' '}
{tr('个项目')}
</small>
</span>
<ArrowUpRight size={18} />
</button>
))
) : (
<Empty
title={tr('从一个账户开始')}
body={tr('添加银行、现金或投资账户,建立你的资产全貌。')}
action={newAction}
/>
)}
<p className="privacy">
<ShieldCheck size={15} />
{tr('关联关系不会重复计入总额')}
</p>
</section>
</div>
<section
className="panel"
hidden={!(user.overviewCards ?? allOverviewCards).includes('recent')}
>
<div className="panel-title">
<h2>{tr('最近变化')}</h2>
<button className="text" onClick={() => setPage('history')}>
{tr('查看全部 →')}
</button>
</div>
<HistoryTable
rows={overview.recent.slice(0, 6)}
open={(id) => {
setSelected(id);
setPage(overview.recent.find((h) => h.positionId === id)?.kind || 'account');
}}
/>
</section>
<div className="fx-caption">
{tr('本位币')}
{user.baseCurrency} {tr('· 汇率日期:')}
{[
...new Set(
overview.items
.filter((i) => i.currency !== user.baseCurrency && i.rateDate)
.map((i) => i.rateDate),
),
].join(' / ') || tr('尚无外币汇率')}{' '}
{tr('· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。')}
</div>
{(user.overviewCards ?? allOverviewCards).includes('attribution') &&
overview.trend.length > 1 && (
<details className="panel attribution">
<summary>{tr('变化归因')}</summary>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>{tr('日期')}</th>
<th>{tr('余额 / 估值变化')}</th>
<th>{tr('汇率变化')}</th>
<th>{tr('净资产')}</th>
</tr>
</thead>
<tbody>
{overview.trend.slice(-10).map((r) => (
<tr key={r.date}>
<td>{r.date}</td>
<td>
{r.balanceChange === null || r.balanceChange === undefined
? '—'
: money(r.balanceChange, user.baseCurrency)}
</td>
<td>
{r.fxChange === null || r.fxChange === undefined
? '—'
: money(r.fxChange, user.baseCurrency)}
</td>
<td>
{r.complete ? money(r.net, user.baseCurrency) : tr('汇率不完整')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</>
)}
{['account', 'asset', 'debt'].includes(page) &&
(p ? (
<>
<button className="text back" onClick={() => setSelected(null)}>
<ArrowLeft size={16} />
{tr('返回列表')}
</button>
<section className="panel detail-summary">
<div>
<span className="badge">
{categoryLabel(p)} ·{' '}
{p.kind === 'debt'
? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')
: p.side === 'asset'
? tr('资产')
: tr(
p.kind === 'account' && p.amount.startsWith('-')
? '溢缴存款'
: '负债',
)}
{p.archived ? tr(' · 已归档') : ''}
</span>
<h2
className={positionAmount(p).startsWith('-') ? 'danger-text' : 'income-text'}
>
{p.valuationAvailable === false
? tr('待估值')
: money(positionAmount(p), p.currency)}
{p.metalGrams && <small> · {p.metalGrams} g</small>}
</h2>
{p.hidden && <span className="badge">{tr('隐藏资产')}</span>}
<p className="muted" hidden={p.currency === user.baseCurrency}>
{tr('本位币:')}
{p.converted !== null
? money(positionAmount(p, p.converted || '0'), user.baseCurrency)
: tr(p.valuationAvailable === false ? '待估值' : '缺少汇率')}
</p>
<p className="notes-display">{p.notes || tr('暂无备注')}</p>
{(p.included === false ||
(p.kind === 'asset' && user.includeIndependentAssets === false)) && (
<span className="badge">{tr('不计入总览')}</span>
)}
<small>{tr('币种和资产负债属性创建后固定。归档项目仍计入总览。')}</small>
</div>
<div className="actions">
{p.kind === 'account' && !p.archived && (
<>
<button
className="primary"
onClick={() => setModal({ kind: 'transfer', p })}
>
{tr('转账')}
</button>
<button
hidden={p.side !== 'asset'}
className="secondary"
onClick={() => setModal({ kind: 'debt-payment', p })}
>
{tr('收款 / 还款')}
</button>
</>
)}
{p.kind === 'debt' && !p.archived && (
<>
<button
className="primary"
onClick={() =>
setModal({
kind: 'debt-payment',
p,
operation: p.side === 'asset' ? 'collect' : 'repay',
})
}
>
{operationLabel(p.side === 'asset' ? 'collect' : 'repay')}
</button>
</>
)}
<button
className="secondary danger-text"
disabled={busy}
onClick={() => setModal({ kind: 'delete-position', p })}
>
删除{deletionLabel(p)}
</button>
<button className="secondary" onClick={() => setModal({ kind: 'edit', p })}>
{tr('编辑资料')}
</button>
<button
className="secondary"
disabled={busy}
onClick={() =>
void act(
() =>
api('/positions/' + p.id, 'PATCH', {
name: p.name,
category: p.category,
groupName: p.groupName,
notes: p.notes,
archived: !p.archived,
hidden: p.hidden,
}),
p.archived ? tr('项目已恢复') : tr('项目已归档'),
)
}
>
{p.archived ? tr('恢复项目') : tr('归档项目')}
</button>
<button
className="primary"
disabled={p.archived}
onClick={() => setModal({ kind: 'revision', p })}
>
{tr('更新')}
{p.kind === 'asset' ? tr('估值') : tr('余额')}
</button>
</div>
</section>
{p.kind === 'asset' && p.category === 'gold' && (
<MetalPanel position={p} refresh={() => load(false, true)} />
)}
{p.kind === 'debt' && (
<section className="panel">
<div className="panel-title">
<h2>{tr('关联资产与账户')}</h2>
<button className="text" onClick={() => setModal({ kind: 'links', p })}>
{tr('管理关联')}
</button>
</div>
<p className="muted">{tr('只展示关系,不改变资产或债务的统计。')}</p>
{p.outgoing.length ? (
p.outgoing.map((l) => (
<button
className="secondary"
key={l.targetId}
onClick={() => {
setSelected(l.targetId);
setPage(l.target.kind);
}}
>
{l.target.name}
</button>
))
) : (
<p>{tr('尚未关联')}</p>
)}
</section>
)}
<section className="panel">
<h2>{tr('余额与估值历史')}</h2>
<p className="muted">
{tr(
'每次更新保留独立历史,同日按记录顺序展示。错误记录可单独更正,后续变化自动重算。',
)}
</p>
<HistoryTable
rows={historyRows}
correct={p.archived ? undefined : (h) => void correctHistory(h)}
remove={p.archived ? undefined : deleteHistory}
busy={busy}
/>
{historyCursor && (
<button disabled={loading} onClick={() => void more('history')}>
{tr('加载更早记录')}
</button>
)}
</section>
</>
) : (
<section
className={page === 'account' && quickMode ? 'panel quick-account-panel' : 'panel'}
>
{page === 'account' && (
<>
<div className="actions account-tools">
<button
className={quickMode ? 'primary' : 'secondary'}
aria-pressed={quickMode}
disabled={busy}
onClick={toggleQuickMode}
>
{tr(quickMode ? '退出快速记账' : '快速记账')}
</button>
{quickMode && (
<button
className={quickMarkMode ? 'primary' : 'secondary'}
disabled={busy}
aria-pressed={quickMarkMode}
onClick={() => {
setQuickMarkMode(!quickMarkMode);
setQuickBookedMode(false);
setQuickTransfer(null);
}}
>
{tr(quickMarkMode ? '结束还款标记' : '标记本月还款')}
</button>
)}
{quickMode && (
<>
<button
type="button"
className="secondary"
disabled={
busy ||
positions.filter((p) => p.kind === 'account' && !p.archived).length <
2
}
onClick={() => {
setQuickMarkMode(false);
setQuickBookedMode(false);
setHideBooked(false);
setUnpaidOnly(false);
setQuickTransfer({});
}}
>
{tr('转账')}
</button>
<button
className={quickBookedMode ? 'primary' : 'secondary'}
disabled={busy}
aria-pressed={quickBookedMode}
onClick={() => {
setQuickBookedMode(!quickBookedMode);
setQuickMarkMode(false);
setQuickTransfer(null);
}}
>
{tr(quickBookedMode ? '结束记账标记' : '标记当天已记账')}
</button>
<button
className="secondary"
aria-pressed={unpaidOnly}
disabled={busy}
onClick={() => {
setUnpaidOnly(!unpaidOnly);
setQuickTransfer(null);
}}
>
{tr(unpaidOnly ? '显示全部账户' : '仅看本月未还款')}
</button>
<button
className="secondary"
aria-pressed={hideBooked}
disabled={busy}
onClick={() => {
setHideBooked(!hideBooked);
setQuickTransfer(null);
}}
>
{tr(hideBooked ? '显示当天已记账' : '隐藏当天已记账')}
</button>
</>
)}
<select
aria-label={tr('账户分组')}
value={accountGroup === null ? 'all' : JSON.stringify(accountGroup)}
onChange={(e) =>
setAccountGroup(
e.target.value === 'all' ? null : JSON.parse(e.target.value),
)
}
>
<option value="all">{tr('全部账户')}</option>
{groups.map((g) => (
<option key={g} value={JSON.stringify(g)}>
{g || tr('未分组')}
</option>
))}
</select>
<button
className="secondary"
hidden={quickMode}
disabled={busy || groups.length < 2}
onClick={() => setModal({ kind: 'group-order', groups: [...groups] })}
>
{tr('分组排序')}
</button>
</div>
{quickMode && (
<p className="muted">
{tr(
quickBookedMode
? '点击账户卡片,标记或取消当天已记账。'
: quickMarkMode
? '点击欠款账户卡片,标记或取消本月已还款。'
: quickTransfer !== null
? '快速转账中:依次点击转出和转入账户卡片。'
: '点击账户卡片,直接填写当前余额。',
)}
</p>
)}
</>
)}
{page === 'asset' && (
<>
<div className="actions account-tools">
<button
className={assetQuickMode ? 'primary' : 'secondary'}
disabled={busy}
aria-pressed={assetQuickMode}
onClick={() => {
setAssetQuickMode(!assetQuickMode);
setAssetValueMode(false);
}}
>
{tr(assetQuickMode ? '结束统计设置' : '快速设置计入总资产')}
</button>
<button
className={assetValueMode ? 'primary' : 'secondary'}
disabled={busy}
aria-pressed={assetValueMode}
onClick={() => {
setAssetValueMode(!assetValueMode);
setAssetQuickMode(false);
}}
>
{tr(assetValueMode ? '结束快速估值' : '快速设置估值')}
</button>
</div>
{assetValueMode && (
<p className="muted">
{tr('点击资产卡片填写最新估值;贵金属按报价自动估值。')}
</p>
)}
{assetQuickMode && (
<p className="muted">{tr('点击资产卡片,切换是否计入总资产。')}</p>
)}
{assetQuickMode && user.includeIndependentAssets === false && (
<p className="muted">
{tr('独立资产总开关已关闭;单项设置已保存,开启总开关后生效。')}
</p>
)}
</>
)}
{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),
) && (
<p className="muted" role="status">
{tr('当前筛选下没有账户,可切换筛选查看其他账户。')}
</p>
)}
{positions.some((p) => p.kind === page) ? (
<div
className={
'position-grid' +
(page === 'account' && quickMode ? ' quick-dense-grid' : '') +
(quickTransfer !== null && page === 'account' ? ' quick-transfer-select' : '')
}
>
{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) => (
<Fragment key={p.id}>
{page === 'account' &&
accountGroup === null &&
(index === 0 ||
(rows[index - 1].groupName || '') !== (p.groupName || '')) && (
<h3 className="account-group-title">{p.groupName || tr('未分组')}</h3>
)}
<button
className={
'position-card' +
(quickTransfer?.sourceId === p.id
? ' quick-source'
: quickTransfer?.targetId === p.id
? ' quick-target'
: '')
}
aria-pressed={
quickTransfer
? quickTransfer.sourceId === p.id || quickTransfer.targetId === p.id
: undefined
}
disabled={
busy ||
(page === 'asset' &&
assetValueMode &&
(p.archived || p.category === 'gold')) ||
(!!quickTransfer && p.archived) ||
(quickMode &&
quickMarkMode &&
(p.side !== 'liability' || p.archived)) ||
(quickMode && quickBookedMode && p.archived)
}
key={p.id}
onClick={() =>
page === 'asset' && assetValueMode
? setModal({ kind: 'revision', p })
: page === 'asset' && assetQuickMode
? void act(() => toggleAssetInclusion(p), tr('统计设置已保存'))
: quickMode && quickBookedMode && page === 'account'
? void act(() => markQuickBooked(p), tr('记账标记已保存'))
: quickMode && quickMarkMode && page === 'account'
? p.kind === 'account' &&
p.side === 'liability' &&
!p.archived
? void act(
() => markQuickRepayment(p),
tr('还款标记已保存'),
)
: undefined
: quickTransfer !== null && page === 'account' && quickMode
? chooseQuickAccount(p)
: page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p, quick: true })
: setSelected(p.id)
}
>
<div>
<span className="position-icon">
{p.iconId ? (
<img src={iconUrl(p.iconId)} alt={tr('账户图标')} />
) : p.kind === 'asset' ? (
<Landmark />
) : p.kind === 'debt' ? (
<HandCoins />
) : (
<Wallet />
)}
</span>
<ArrowUpRight size={18} />
</div>
<h3>{p.name}</h3>
{isPaidForMonth(p, repaymentMonth) && (
<span className="badge repayment-paid">{tr('本月已还款')}</span>
)}
{quickMode && page === 'account' && isBookedToday(p, businessDay) && (
<span className="badge repayment-paid">{tr('当天已记账')}</span>
)}
{assetQuickMode && page === 'asset' && p.included !== false && (
<span className="badge repayment-paid">{tr('计入总资产')}</span>
)}
{(p.included === false ||
(p.kind === 'asset' && user.includeIndependentAssets === false)) && (
<span className="badge">{tr('不计入总览')}</span>
)}
<p className="position-secondary">
{categoryLabel(p)} ·{' '}
{p.kind === 'debt'
? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')
: p.side === 'asset'
? tr('资产')
: tr(
p.kind === 'account' && p.amount.startsWith('-')
? '溢缴存款'
: '负债',
)}
{p.archived ? tr(' · 已归档') : ''}
</p>
<strong
className={
positionAmount(p).startsWith('-') ? 'danger-text' : 'income-text'
}
>
{p.valuationAvailable === false
? tr('待估值')
: money(positionAmount(p), p.currency)}
{p.metalGrams && <small> · {p.metalGrams} g</small>}
</strong>
<small
className="position-secondary"
hidden={p.currency === user.baseCurrency}
>
{tr('折合')}{' '}
{p.converted === null
? tr(p.valuationAvailable === false ? '待估值' : '缺少汇率')
: money(positionAmount(p, p.converted || '0'), user.baseCurrency)}
</small>
</button>
</Fragment>
))}
</div>
) : (
<Empty
title={tr('还没有') + tr(labels[page])}
body={tr('添加真实数据后,这里将展示原币余额和历史变化。')}
action={newAction}
/>
)}
</section>
))}
{page === 'account' && quickMode && !selected && (
<aside className="quick-session-panel panel" aria-label={tr('快速记账操作')}>
{quickTransfer !== null && (
<section className="quick-transfer-card">
<div className="panel-title">
<h2>{tr('快速转账')}</h2>
<button
type="button"
className="icon"
aria-label={tr('关闭快速转账')}
disabled={busy}
onClick={() => setQuickTransfer(null)}
>
×
</button>
</div>
{!quickTransfer.sourceId ? (
<p>{tr('点击账户卡片选择转出账户')}</p>
) : !quickTransfer.targetId ? (
<>
<p>
{tr('转出账户:')}
{positions.find((p) => p.id === quickTransfer.sourceId)?.name}
</p>
<p>{tr('点击另一张账户卡片选择转入账户')}</p>
<button type="button" className="text" onClick={() => setQuickTransfer({})}>
{tr('重新选择')}
</button>
</>
) : (
positions.some((p) => p.id === quickTransfer.sourceId) &&
positions.some((p) => p.id === quickTransfer.targetId) && (
<QuickTransfer
key={quickTransfer.sourceId + quickTransfer.targetId}
source={positions.find((p) => p.id === quickTransfer.sourceId)!}
target={positions.find((p) => p.id === quickTransfer.targetId)!}
busy={busy}
changeSource={() => setQuickTransfer({})}
changeTarget={() => setQuickTransfer({ sourceId: quickTransfer.sourceId })}
submit={(data) =>
void act(async () => {
const result = await saveQuickMovement(data, tr('转账'));
setQuickTransfer(null);
return result;
}, tr('转账已完成'))
}
/>
)
)}
</section>
)}
<QuickJournal entries={quickEntries} busy={busy} undo={undoQuick} />
</aside>
)}
{page === 'history' && (
<>
<section className="panel">
<div className="actions">
<label>
{tr('开始')}
<input
type="date"
value={rangeFrom}
max={rangeTo}
onChange={(e) => setRangeFrom(e.target.value)}
/>
</label>
<label>
{tr('结束')}
<input
type="date"
value={rangeTo}
min={rangeFrom}
max={today()}
onChange={(e) => setRangeTo(e.target.value)}
/>
</label>
</div>
<h2>{tr('资金往来')}</h2>
{transfers.length ? (
<div className="transfer-list">
{transfers.map((t) => (
<article key={t.id}>
<div>
<strong>
{operationLabel(t.operation || 'transfer')} ·
<button
className="text"
onClick={() => {
setSelected(t.sourceId);
setPage(t.source.kind);
}}
>
{t.source.name}
</button>
{' ↔ '}
<button
className="text"
onClick={() => {
setSelected(t.targetId);
setPage(t.target.kind);
}}
>
{t.target.name}
</button>
</strong>
<small>{displayTime(t.date)}</small>
</div>
<p>
{money(t.amount, t.sourceCurrency)} →{' '}
{money(t.received, t.targetCurrency)} {tr('· 手续费')}
{money(t.fee, t.sourceCurrency)}
</p>
{t.notes && <p className="muted notes-display">{t.notes}</p>}
<div className="actions record-actions">
<button
className="text"
disabled={busy}
onClick={() => setModal({ kind: 'transfer-edit', transfer: t })}
>
{tr('修改')}
</button>
<button
className="text danger-text"
disabled={busy}
onClick={() => {
setError('');
setModal({ kind: 'delete-record', transfer: t });
}}
>
{tr('删除')}
</button>
</div>
</article>
))}
</div>
) : (
<p className="muted">{tr('暂无转账记录。')}</p>
)}
{transferCursor && (
<button disabled={loading} onClick={() => void more('transfers')}>
{tr('加载更早转账')}
</button>
)}
<h2>{tr('全部金额变化')}</h2>
<p className="muted">
{tr('原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。')}
</p>
<HistoryTable
rows={historyRows}
correct={(h) => void correctHistory(h)}
remove={deleteHistory}
busy={busy}
open={(id) => {
setSelected(id);
setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account');
}}
/>
{historyCursor && (
<button disabled={loading} onClick={() => void more('history')}>
{tr('加载更早记录')}
</button>
)}
</section>
</>
)}
{page === 'schedules' && (
<section className="panel">
<SchedulePanel
accounts={positions.filter((p) => p.kind === 'account' && !p.archived)}
refresh={() => load(false, true)}
/>
</section>
)}
{page === 'calendar' && (
<Calendar revealed={!!user.revealed} refreshVersion={calendarRefresh} />
)}
{page === 'admin' && user.role === 'admin' && <AdminPanel user={user} report={report} />}
{page === 'settings' && (
<>
<nav className="settings-tabs" aria-label={tr('设置分类')}>
{Object.entries({
general: '常规设置',
agent: '连接 Agent',
display: '显示与菜单',
privacy: '登录与隐私',
security: '账号与密码',
icons: '图标库',
rates: '每日汇率',
backup: '备份与恢复',
clear: '清空本账号数据',
}).map(([key, label]) => (
<button
key={key}
type="button"
aria-current={settingsSection === key ? 'page' : undefined}
className={key === 'clear' ? 'danger-text' : undefined}
onClick={() => setSettingsSection(key)}
>
{tr(label)}
</button>
))}
</nav>
{settingsSection === 'icons' && (
<section className="panel">
<IconLibrary />
</section>
)}
{settingsSection === 'agent' && <AgentConnections changed={() => void load()} />}
{settingsSection === 'security' && (
<section className="panel">
<h2>{tr('账号与密码')}</h2>
<p className="muted">{tr('修改后其他登录会话将退出,当前会话保持登录。')}</p>
<form
key={user.username}
onSubmit={(e) => {
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('账号与密码已更新'));
}}
>
<Field label={tr('账号')}>
<input
name="username"
autoComplete="username"
required
minLength={3}
maxLength={64}
defaultValue={user.username}
/>
</Field>
<Field label={tr('当前密码')}>
<input
name="currentPassword"
type="password"
autoComplete="current-password"
required
minLength={10}
maxLength={72}
/>
</Field>
<Field label={tr('新密码(不修改请留空)')}>
<input
name="newPassword"
type="password"
autoComplete="new-password"
minLength={10}
maxLength={72}
/>
</Field>
<Field label={tr('确认新密码')}>
<input
name="confirmPassword"
type="password"
autoComplete="new-password"
minLength={10}
maxLength={72}
/>
</Field>
<button className="primary" disabled={busy}>
{tr('保存修改')}
</button>
</form>
</section>
)}
<div className="settings-grid">
<section className="panel" hidden={settingsSection !== 'general'}>
<div className="panel-title">
<h2>{tr('常规设置')}</h2>
<button className="text" disabled={busy} onClick={() => void logout()}>
<LogOut size={16} />
{tr('退出登录')}
</button>
</div>
<p className="muted">
{tr('账号:')}
{user.username}
{tr('。切换本位币会重新换算整个历史,原币数据保留。')}
</p>
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
void act(
() => api('/settings', 'PATCH', { baseCurrency: f.get('baseCurrency') }),
tr('设置已保存'),
);
}}
>
<Field label={tr('本位币')}>
<select
name="baseCurrency"
defaultValue={user.baseCurrency}
key={user.baseCurrency}
>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<button className="primary" disabled={busy}>
{tr('保存设置')}
</button>
</form>
</section>
<section className="panel" hidden={settingsSection !== 'display'}>
<h2>{tr('显示与菜单')}</h2>
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
void act(
() =>
api('/settings', 'PATCH', {
hiddenMenus: [
'overview',
'account',
'asset',
'debt',
'history',
'calendar',
'schedules',
].filter((key) => !f.has('menu_' + key)),
showNotes: f.has('showNotes'),
includeIndependentAssets: f.has('includeIndependentAssets'),
overviewCards: allOverviewCards.filter((key) => f.has('card_' + key)),
}),
tr('设置已保存'),
);
}}
>
<label className="switch-row">
<span>{tr('独立资产计入总览')}</span>
<input
type="checkbox"
role="switch"
name="includeIndependentAssets"
defaultChecked={user.includeIndependentAssets !== false}
key={String(user.includeIndependentAssets)}
/>
</label>
<p className="muted">
{tr('关闭后,独立资产不参与总额和净资产轨迹,资料与历史仍保留。')}
</p>
<h3>{tr('导航菜单')}</h3>
<p className="muted">
{tr('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')}
</p>
<div className="menu-preferences">
{[
'overview',
'account',
'asset',
'debt',
'history',
'calendar',
'schedules',
].map((key) => (
<label key={key} className="switch-row">
<span>{tr(labels[key])}</span>
<input
type="checkbox"
role="switch"
aria-label={tr(labels[key]) + tr('显示')}
name={'menu_' + key}
key={key + String(user.hiddenMenus)}
defaultChecked={!(user.hiddenMenus || []).includes(key)}
/>
<span className="switch-state" aria-hidden="true">
{tr('显示')}
</span>
</label>
))}
</div>
<label className="switch-row">
<span>{tr('显示备注')}</span>
<input
type="checkbox"
role="switch"
name="showNotes"
aria-label={tr('显示备注')}
key={String(user.showNotes)}
defaultChecked={user.showNotes !== false}
/>
<span className="switch-state" aria-hidden="true">
{tr('显示')}
</span>
</label>
<p className="muted">
{tr('关闭后隐藏列表、详情及编辑表单中的备注,已有备注仍会保留在备份中。')}
</p>
<h3>{tr('总览显示卡片')}</h3>
<p className="muted">
{tr('选择资产总览需要显示的卡片,数据和记录仍会保留。')}
</p>
<div className="menu-preferences">
{allOverviewCards.map((key) => (
<label className="switch-row" key={key}>
<span>{tr(overviewCardLabels[key])}</span>
<input
name={'card_' + key}
type="checkbox"
role="switch"
aria-label={tr(overviewCardLabels[key]) + tr('显示')}
key={key + String(user.overviewCards)}
defaultChecked={(user.overviewCards ?? allOverviewCards).includes(key)}
/>
<span className="switch-state" aria-hidden="true">
{tr('显示')}
</span>
</label>
))}
</div>
<button className="primary" disabled={busy}>
{tr('保存设置')}
</button>
</form>
</section>
<section className="panel" hidden={settingsSection !== 'privacy'}>
<h2>{tr('登录与隐私')}</h2>
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
void act(
() =>
api('/settings', 'PATCH', {
sessionHours: Number(f.get('sessionHours')),
idleMinutes: Number(f.get('idleMinutes')),
requireHiddenPassword: f.has('requireHiddenPassword'),
}),
tr('设置已保存'),
);
}}
>
<Field label={tr('登录有效期(小时)')}>
<input
name="sessionHours"
type="number"
min="1"
max="720"
step="1"
required
defaultValue={user.sessionHours ?? 168}
key={user.sessionHours}
/>
</Field>
<p className="muted">
{tr(
'范围 1 小时至 30 天(720 小时)。保存后当前登录重新计时,其他会话沿用原到期时间;后续登录使用新设置。',
)}
</p>
{user.sessionExpiresAt && (
<p className="muted">
{tr('当前会话到期:')}
{new Date(user.sessionExpiresAt).toLocaleString(locale, {
timeZone: 'Asia/Hong_Kong',
})}
</p>
)}
<Field label={tr('无操作自动退出(分钟)')}>
<select
name="idleMinutes"
key={user.idleMinutes}
defaultValue={user.idleMinutes}
>
{[...new Set([0, 5, 15, 30, 60, 120, 1440, user.idleMinutes])]
.sort((a, b) => a - b)
.map((n) => (
<option key={n} value={n}>
{n === 0 ? tr('关闭自动退出') : n + tr(' 分钟')}
</option>
))}
</select>
</Field>
<p className="muted">
{tr(
'0 为关闭;最长 1440 分钟。到时退出登录并清除页面数据,浏览器标签页保留。',
)}
</p>
<label className="switch-row">
<span>{tr('查看隐藏资产需要密码')}</span>
<input
name="requireHiddenPassword"
type="checkbox"
role="switch"
aria-label={tr('查看隐藏资产需要密码')}
key={String(user.requireHiddenPassword)}
defaultChecked={user.requireHiddenPassword !== false}
/>
</label>
<p className="muted">
{tr(
'关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。',
)}
</p>
<button className="primary" disabled={busy}>
{tr('保存设置')}
</button>
</form>
</section>
<section className="panel" hidden={settingsSection !== 'rates'}>
<h2>{tr('每日汇率')}</h2>
{fxStatus && (
<p className="muted" role="status">
{tr(fxStatus.message)}
{fxStatus.attemptedAt
? ' · ' +
new Date(fxStatus.attemptedAt).toLocaleString(locale, {
timeZone: 'Asia/Hong_Kong',
})
: ''}
</p>
)}
<p className="muted">
{tr(
'服务器每小时检查当日是否已尝试更新。外部请求固定下载公共汇率表,不发送个人数据。失败保留已有汇率,可稍后重试。',
)}
</p>
<button
className="secondary"
disabled={busy}
onClick={() =>
void act(
() => api('/rates/refresh', 'POST'),
tr('汇率已更新至数据源最新可用日期'),
)
}
>
<RefreshCw size={16} />
{tr('立即更新')}
</button>
<p className="muted">
{tr('公共参考汇率可能与银行成交价不同;休市日日期可能滞后。')}
</p>
</section>
</div>
<section className="panel" hidden={settingsSection !== 'rates'}>
<h2>{tr('已保存汇率')}</h2>
{rates.length ? (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>{tr('原币 → 本位币')}</th>
<th>{tr('汇率')}</th>
<th>{tr('日期')}</th>
<th>{tr('来源')}</th>
</tr>
</thead>
<tbody>
{rates.slice(0, 100).map((r) => (
<tr key={r.currency + r.baseCurrency + r.date}>
<td>
{r.currency} → {r.baseCurrency}
</td>
<td>{r.rate}</td>
<td>{r.date.slice(0, 10)}</td>
<td>{r.source === 'manual' ? tr('历史导入') : 'Frankfurter'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<Empty
title={tr('没有保存的外币汇率')}
body={tr('添加外币项目后更新并保存自动汇率。')}
/>
)}
</section>
{settingsSection === 'rates' && <MetalPanel refresh={() => load(false, true)} />}
<section className="panel" hidden={settingsSection !== 'clear'}>
<h2 className="danger-text">{tr('清空本账号数据')}</h2>
<p className="muted">
{tr(
'清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏资产);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。',
)}
</p>
{clearStep === 0 ? (
<button
className="secondary"
disabled={busy}
onClick={() => void downloadBackup(true)}
>
<Download size={16} />
{tr('第一步:下载备份')}
</button>
) : clearStep === 1 ? (
<button
className="secondary"
disabled={busy}
onClick={async () => {
try {
const s = await api<{ ready: boolean }>('/backup/clear-status');
if (!s.ready) throw new Error(tr('备份尚未完成,请重新下载'));
setClearStep(2);
} catch (e) {
report(e);
}
}}
>
{tr('备份已保存,进入下一步')}
</button>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
if (new FormData(e.currentTarget).get('confirmation') === tr('确定清空'))
setClearConfirmation(1);
}}
>
<Field label={tr('输入“确定清空”以确认')}>
<input
name="confirmation"
required
pattern={tr('确定清空')}
autoComplete="off"
/>
</Field>
<button className="danger" disabled={busy}>
{tr('清空本账号数据')}
</button>
</form>
)}
{clearStep > 0 && (
<button className="text" onClick={() => setClearStep(0)}>
{tr('取消清空 / 重新下载')}
</button>
)}
</section>
<section className="panel" hidden={settingsSection !== 'backup'}>
<h2>{tr('数据备份与恢复')}</h2>
<p className="muted">
{tr(
'ZIP 内分文件保存可读 JSON,包括全部账户、资产、债务、历史、关联、币种、设置、汇率、图标及配对转账,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。',
)}
</p>
<div className="actions">
<button
className="secondary"
disabled={busy}
onClick={() => void downloadBackup()}
>
<Download size={16} />
{tr('下载我的数据备份')}
</button>{' '}
<label className="secondary file-button">
<Upload size={16} />
{tr('选择备份并验证')}
<input
type="file"
accept=".zip,application/zip"
disabled={busy}
onChange={async (e) => {
setBackup(null);
setPreview(null);
setError('');
const file = e.target.files?.[0];
if (!file) return;
const uploadSession = sessionGeneration.current;
setBusy(true);
try {
if (file.size > 512 * 1024 * 1024)
throw new Error(tr('ZIP 文件不能超过 512 MB(不限制记录条数)'));
const form = new FormData();
form.append('file', file);
const response = await fetch('/api/backup/upload', {
method: 'POST',
body: form,
credentials: 'same-origin',
});
const result = await response.json();
if (!response.ok)
throw new ApiError(
result.message || tr('ZIP 验证失败'),
response.status,
);
if (uploadSession !== sessionGeneration.current) return;
setBackup(result.token);
setPreview(result);
} catch (err) {
if (uploadSession === sessionGeneration.current) report(err);
} finally {
setBusy(false);
e.target.value = '';
}
}}
/>
</label>
</div>
{preview && (
<div className="import-preview">
<h3>{tr('导入预览')}</h3>
<p>
{preview.positions} {tr('个项目 ·')}
{preview.revisions} {tr('条历史 ·')}
{preview.rates} {tr('条汇率')}
</p>
<p>
{tr('备份本位币')}
{preview.baseCurrency} {tr('/ 当前本位币')}
{preview.currentBaseCurrency}
</p>
<p>{tr(preview.message)}</p>
<button
className="primary"
disabled={busy}
onClick={() =>
void act(async () => {
await api('/backup/import-file', 'POST', {
confirmed: true,
token: backup,
});
setPreview(null);
setBackup(null);
}, tr('备份已完整导入'))
}
>
{tr('确认追加导入')}
</button>
<button
className="text"
onClick={() => {
setPreview(null);
setBackup(null);
}}
>
{tr('取消')}
</button>
</div>
)}
</section>
</>
)}
<footer>{tr('WorthPath · 记录你的积累,理解你的财富')}</footer>
</div>
</main>
{clearConfirmation > 0 && (
<Modal
key={clearConfirmation}
title={
clearConfirmation === 1
? tr('第一次确认:清空本账号数据')
: tr('第二次确认:清空本账号数据')
}
close={() => {
if (!busy) setClearConfirmation(0);
}}
>
<p className="danger-text">
{clearConfirmation === 1
? tr('将清空本账号全部财务数据及私有图标,请确认备份已保存。')
: tr('这是最后一次确认。清空后只能通过备份恢复,其他用户的数据不受影响。')}
</p>
<p className="muted">{tr('登录账号、个人设置和已发布的共享图标会保留。')}</p>
<div className="modal-actions clear-confirm-actions">
{clearConfirmation === 1 ? (
<>
<button
autoFocus
className="secondary"
disabled={busy}
onClick={() => setClearConfirmation(0)}
>
{tr('取消清空')}
</button>
<button className="danger" disabled={busy} onClick={() => setClearConfirmation(2)}>
{tr('继续清空')}
</button>
</>
) : (
<>
<button
className="danger"
disabled={busy}
onClick={() =>
void act(async () => {
await api('/backup/clear', 'POST', { confirmation: tr('确定清空') });
setClearConfirmation(0);
setClearStep(0);
setSelected(null);
setBackup(null);
setPreview(null);
}, tr('本账号数据已清空'))
}
>
{busy ? tr('正在清空…') : tr('最终确认清空')}
</button>
<button
autoFocus
className="secondary"
disabled={busy}
onClick={() => setClearConfirmation(0)}
>
{tr('取消清空')}
</button>
</>
)}
</div>
</Modal>
)}
{modal?.kind === 'group-order' && modal.groups && (
<Modal title={tr('分组排序')} close={() => !busy && setModal(null)}>
<GroupOrderList
groups={modal.groups}
busy={busy}
onChange={(groups) =>
setModal((current) =>
current?.kind === 'group-order' ? { ...current, groups } : current,
)
}
/>
<div className="modal-actions">
<button className="secondary" disabled={busy} onClick={() => setModal(null)}>
{tr('取消')}
</button>
<button
className="primary"
disabled={busy}
onClick={() =>
void act(
() =>
api('/settings', 'PATCH', {
accountGroupOrder: mergeGroupOrder(
modal.groups!,
user.accountGroupOrder || [],
),
}),
tr('分组顺序已保存'),
)
}
>
{tr(busy ? '正在保存…' : '保存')}
</button>
</div>
</Modal>
)}
{modal?.kind === 'delete-record' && (
<Modal
title={tr('删除记录')}
close={() => {
if (!busy) setModal(null);
}}
>
<p>
{modal.h
? modal.h.name
: modal.transfer?.source.name + ' ↔ ' + modal.transfer?.target.name}
</p>
<p className="muted">{displayTime(modal.h?.time || modal.transfer!.date)}</p>
<p>
{tr(
modal.transfer ||
modal.h?.reason.startsWith('transfer_') ||
modal.h?.reason.startsWith('loan_')
? '这笔资金往来的双方记录将一起删除,并重算后续余额。'
: '这条记录将被删除,并重算后续余额。',
)}
</p>
<p className="danger-text">{tr('删除后无法撤销,请确认已保存所需备份。')}</p>
<div className="modal-actions clear-confirm-actions">
<button autoFocus disabled={busy} onClick={() => setModal(null)}>
{tr('取消')}
</button>
<button
className="danger"
disabled={busy}
onClick={() =>
void act(
() =>
api(
modal.h
? '/positions/' + modal.h.positionId + '/revisions/' + modal.h.id
: '/transfers/' + modal.transfer!.id,
'DELETE',
),
tr('记录已删除,余额和日历已更新'),
)
}
>
{busy ? tr('正在处理…') : tr('确认删除')}
</button>
</div>
</Modal>
)}
{modal?.kind === 'transfer-edit' && modal.transfer && (
<Modal
title={tr('修改资金往来')}
close={() => {
if (!busy) setModal(null);
}}
>
<form
onSubmit={(e) => {
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('记录已修改,余额和日历已更新'),
);
}}
>
<p>
{modal.transfer.source.name} ↔ {modal.transfer.target.name}
</p>
<Field label={tr('转出金额(') + modal.transfer.sourceCurrency + ')'}>
<input
name="amount"
required
inputMode="decimal"
defaultValue={modal.transfer.amount}
/>
</Field>
<Field label={tr('实际到账(') + modal.transfer.targetCurrency + ')'}>
<input
name="received"
required
inputMode="decimal"
defaultValue={modal.transfer.received}
/>
</Field>
<Field label={tr('手续费 / 优惠(') + modal.transfer.sourceCurrency + ')'}>
<input name="fee" required inputMode="decimal" defaultValue={modal.transfer.fee} />
</Field>
<Field label={tr('记录时间(UTC+8)')}>
<input
name="date"
type="datetime-local"
required
max={nowMinute()}
defaultValue={modal.transfer.date}
/>
</Field>
<Field label={tr('备注')}>
<textarea name="notes" maxLength={2000} defaultValue={modal.transfer.notes} />
</Field>
<button className="primary" disabled={busy}>
{tr(busy ? '正在保存…' : '保存')}
</button>
</form>
</Modal>
)}
{modal && !['transfer-edit', 'delete-record', 'group-order'].includes(modal.kind) && (
<Modal
title={
modal.kind === 'debt-payment'
? modal.p?.kind === 'debt'
? operationLabel(modal.p.side === 'asset' ? 'collect' : 'repay')
: tr('收款 / 还款')
: modal.kind === 'quick-repayment'
? tr('账户还款')
: modal.kind === 'delete-position'
? '删除' + deletionLabel(modal.p!)
: modal.kind === 'transfer'
? tr('转账')
: modal.kind === 'reveal'
? tr('验证密码以显示隐藏资产')
: modal.kind === 'edit'
? tr('编辑项目')
: modal.kind === 'revision'
? tr('更新余额 / 估值')
: modal.kind === 'correction'
? tr('更正历史记录')
: modal.kind === 'links'
? tr('管理债务关联')
: tr('新增') + tr(labels[modal.kind])
}
close={() => {
if (!busy) setModal(null);
}}
>
{modal.kind === 'delete-position' && modal.p ? (
<PositionDeletion
position={modal.p}
busy={busy}
cancel={() => setModal(null)}
remove={(confirmation) =>
void act(
async () => {
await api('/positions/' + modal.p!.id, 'DELETE', { confirmation });
setSelected(null);
},
deletionLabel(modal.p!) + '已删除',
)
}
/>
) : modal.kind === 'quick-repayment' && modal.p ? (
<QuickRepayment
target={modal.p}
accounts={positions}
busy={busy}
submit={(data) =>
void act(
() => saveQuickMovement(data, tr('还款')),
tr('还款已保存,双方余额已更新'),
)
}
/>
) : ['transfer', 'debt-payment'].includes(modal.kind) ? (
movementLoading ? (
<p>{tr('正在刷新数据…')}</p>
) : modal.kind === 'transfer' ? (
<TransferForm
accounts={movementPositions.filter((p) => p.kind === 'account' && !p.archived)}
initialSourceId={modal.p?.kind === 'account' ? modal.p.id : undefined}
busy={busy}
submit={(v) =>
void act(() => api('/transfers', 'POST', v), tr('转账已保存,双方余额已更新'))
}
/>
) : (
<DebtPaymentForm
accounts={movementPositions.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
)}
debts={movementPositions.filter((p) => p.kind === 'debt' && !p.archived)}
initialAccount={
modal.p?.kind === 'account'
? modal.p.id
: modal.p?.outgoing.find((link) =>
movementPositions.some(
(p) =>
p.id === link.targetId &&
p.kind === 'account' &&
p.side === 'asset' &&
!p.archived,
),
)?.targetId
}
initialDebt={modal.p?.kind === 'debt' ? modal.p.id : undefined}
initialOperation={modal.operation}
busy={busy}
submit={(v) =>
void act(() => api('/transfers', 'POST', v), tr('账户与债务已同步更新'))
}
/>
)
) : (
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget),
v = Object.fromEntries(f.entries()),
kind = modal.kind,
mp = modal.p;
if (kind === 'reveal') {
void act(
() => api('/auth/reveal', 'POST', { password: f.get('password') }),
tr('隐藏资产已解锁 5 分钟'),
);
} else if (kind === 'links') {
void act(
() =>
api('/positions/' + mp!.id + '/links', 'PUT', {
targetIds: f.getAll('targetIds'),
}),
tr('关联已更新'),
);
} else if (kind === 'revision' || kind === 'correction') {
void act(
() =>
modal.h
? api('/positions/' + mp!.id + '/revisions/' + modal.h.id, 'PUT', {
...v,
amount: accountInputAmount(mp!, String(v.amount)),
})
: saveQuickBalance(
mp!,
{ ...v, amount: accountInputAmount(mp!, String(v.amount)) },
!!modal.quick,
),
tr('金额历史已保存,总览已重新计算'),
);
} else if (kind === 'edit') {
void act(
() =>
api('/positions/' + mp!.id, 'PATCH', {
...v,
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
archived: mp!.archived,
hidden: f.get('hidden') === 'on',
included: f.get('included') === 'on',
}),
tr('项目资料已保存'),
);
} else if (kind === 'asset' && v.category === 'gold') {
void act(
() =>
api('/metals/holdings', 'POST', {
name: v.name,
currency: v.currency,
notes: v.notes,
date: v.date,
metalType: v.metalType,
metalGrams: v.metalGrams,
metalCostPerGram: v.metalCostPerGram || null,
autoValuation: true,
hidden: f.has('hidden'),
included: f.has('included'),
}),
tr('项目已添加'),
);
} else {
const category = String(v.category),
side =
kind === 'account' && ['credit_card', 'loan'].includes(category)
? 'liability'
: kind === 'debt'
? String(v.side)
: 'asset';
void act(
() =>
api('/positions', 'POST', {
...v,
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
kind,
side,
amount: accountInputAmount({ kind, side }, String(v.amount)),
hidden: f.get('hidden') === 'on',
included: f.get('included') === 'on',
}),
tr('项目已添加'),
);
}
}}
>
{modal.kind === 'reveal' && (
<Field label={tr('登录密码')}>
<input
name="password"
type="password"
autoComplete="current-password"
required
maxLength={72}
/>
</Field>
)}
{['account', 'asset', 'debt', 'edit'].includes(modal.kind) && (
<>
<label className="check-line">
<input
name="included"
type="checkbox"
defaultChecked={modal.p?.included !== false}
/>
{tr('计入资产总览')}
</label>
<p className="muted">
{tr('取消后不参与资产、负债及净资产轨迹,仍可管理和转账。')}
</p>
<label className="check-line">
<input name="hidden" type="checkbox" defaultChecked={modal.p?.hidden} />
{tr('隐藏此项目(密码验证后可查看和编辑)')}
</label>
{(modal.kind === 'account' || modal.p?.kind === 'account') && (
<>
<IconPicker key={modal.p?.id || 'new-account'} initialId={modal.p?.iconId} />
<Field label={tr('账户分组')}>
<input
name="groupName"
maxLength={60}
defaultValue={modal.p?.groupName || ''}
list="account-groups"
placeholder={tr('例如:日常、消费、理财')}
/>
<datalist id="account-groups">
{groups.filter(Boolean).map((g) => (
<option key={g} value={g} />
))}
</datalist>
</Field>
</>
)}
<Field label={tr('名称')}>
<input
name="name"
defaultValue={modal.p?.name}
required
maxLength={100}
autoFocus
/>
</Field>
<Field label={tr('类别')}>
<select
name="category"
onChange={(e) => setNewAssetCategory(e.target.value)}
defaultValue={modal.p?.category || categories[modal.kind]?.[0][0]}
>
{categories[modal.p?.kind || modal.kind].map(([v, label]) => (
<option key={v} value={v}>
{tr(label)}
</option>
))}
</select>
</Field>
{modal.kind === 'account' && (
<p className="muted">
{tr('信用卡、贷款账户:负数为欠款,正数为溢缴存款;其余账户按实际余额填写。')}
</p>
)}
</>
)}
{modal.kind === 'debt' && (
<>
<Field label={tr('债务方向')}>
<select name="side" defaultValue="liability">
<option value="liability">{tr('欠款(待还)')}</option>
<option value="asset">{tr('借款(待收)')}</option>
</select>
</Field>
<p className="muted">
{tr(
'已有债务填写剩余本金;新借入或借出可先填0,保存后通过账户联动操作记录资金。',
)}
</p>
</>
)}
{['account', 'asset', 'debt'].includes(modal.kind) && (
<Field label={tr('原币币种')}>
<select name="currency" defaultValue={user.baseCurrency}>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) &&
!(modal.kind === 'asset' && newAssetCategory === 'gold') && (
<Field
label={
tr(
modal.p?.kind === 'debt'
? '剩余本金'
: modal.p?.kind === 'asset'
? '当前估值'
: '当前余额',
) + (modal.p ? '(' + modal.p.currency + ')' : '')
}
>
<input
name="amount"
inputMode="decimal"
required
pattern={
modal.p?.kind === 'account' || modal.kind === 'account'
? '-?(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?'
: '(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?'
}
defaultValue={
modal.p
? accountInputAmount(modal.p, modal.h?.after || modal.p.amount)
: modal.kind === 'debt'
? '0'
: ''
}
placeholder="0.00"
/>
</Field>
)}
{modal.kind === 'asset' && newAssetCategory === 'gold' && (
<>
<Field label={tr('贵金属品种')}>
<select name="metalType">
<option value="gold">{tr('黄金')}</option>
<option value="silver">{tr('白银')}</option>
</select>
</Field>
<Field label={tr('重量(克)')}>
<input
name="metalGrams"
required
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
/>
</Field>
<Field label={tr('买入价(每克,选填)')}>
<input
name="metalCostPerGram"
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,11})([.][0-9]{1,12})?"
/>
</Field>
<p className="muted">
{tr('按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。')}
</p>
</>
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field label={tr('记录时间(UTC+8)')}>
<input
type={'datetime-local'}
name="date"
required
min={'1900-01-01T00:00'}
max={nowMinute()}
defaultValue={modal.h?.time || nowMinute()}
/>
</Field>
)}
{['revision', 'correction'].includes(modal.kind) &&
modal.p?.kind === 'account' &&
modal.p.side === 'liability' && (
<p className="muted">
{tr('负数表示欠款,正数表示溢缴存款;溢缴部分计入总资产。')}
</p>
)}
{modal.kind === 'revision' && modal.quick && modal.p?.side === 'liability' && (
<>
<div className="actions quick-repayment-actions">
{canQuickRepay(modal.p) && (
<button
type="button"
className="primary"
disabled={busy}
onClick={() =>
setModal({ kind: 'quick-repayment', p: modal.p, quick: true })
}
>
{tr('还款')}
</button>
)}
<button
type="button"
className="secondary"
disabled={busy}
onClick={() =>
void act(() => markQuickRepayment(modal.p!), tr('还款标记已保存'))
}
>
{tr(
isPaidForMonth(modal.p, repaymentMonth)
? '取消本月已还款'
: '标记本月已还款',
)}
</button>
</div>
<p className="muted">
{tr('还款会更新双方余额;月份标记只记录本月已处理,不改变金额。')}
</p>
</>
)}
{modal.kind === 'revision' && (
<>
<Field label={tr('变更原因')}>
<select
name="reason"
defaultValue={modal.p?.kind === 'asset' ? 'valuation' : 'balance'}
>
<option value="balance">{tr('余额更新')}</option>
<option value="valuation">{tr('估值更新')}</option>
{modal.p?.side === 'liability' && (
<option value="repayment">{tr('还款(填写还款后欠款)')}</option>
)}
</select>
</Field>
<p className="muted">
{tr(
'填写更新后的余额或剩余欠款,每次保存会新增历史。已有错误记录请在历史中更正。',
)}
</p>
</>
)}
{modal.kind === 'correction' && (
<p className="notice warning">
{tr('这将修改选中的历史金额并重算后续变化,请确认日期和余额。')}
</p>
)}
{modal.kind === 'links' ? (
<div className="link-options">
{linkOptions.length ? (
linkOptions.map((p) => (
<label key={p.id}>
<input
type="checkbox"
name="targetIds"
value={p.id}
defaultChecked={modal.p!.outgoing.some((l) => l.targetId === p.id)}
/>
{p.name} · {tr(labels[p.kind])}
</label>
))
) : (
<p>{tr('请先创建账户或独立资产。')}</p>
)}
</div>
) : (
modal.kind !== 'reveal' && (
<Field label={tr('备注')}>
<textarea
name="notes"
maxLength={2000}
rows={3}
defaultValue={modal.h?.notes || modal.p?.notes || ''}
/>
</Field>
)
)}
<div className="modal-actions">
<button
className="secondary"
type="button"
disabled={busy}
onClick={() => setModal(null)}
>
{tr('取消')}
</button>
<button className="primary" disabled={busy}>
{busy
? tr('正在保存…')
: modal.kind === 'correction'
? tr('确认更正')
: tr('保存')}
</button>
</div>
</form>
)}
</Modal>
)}
{draftId && (
<AgentDrafts
initialId={draftId}
close={() => {
setDraftId(null);
const url = new URL(location.href);
url.searchParams.delete('agent_operation');
if (url.pathname === '/agent/operation') url.pathname = '/';
history.replaceState(null, '', url.pathname + url.search + url.hash);
}}
changed={() => void load()}
/>
)}
</div>,
);
}
function HistoryTable({
rows,
open,
correct,
remove,
busy = false,
}: {
rows: History[];
open?: (id: string) => void;
correct?: (h: History) => void;
remove?: (h: History) => void;
busy?: boolean;
}) {
return rows.length ? (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>{tr('项目 / 日期')}</th>
<th>{tr('变更前')}</th>
<th>{tr('变更后')}</th>
<th>{tr('变化额')}</th>
<th className="notes-cell">{tr('备注')}</th>
{correct && <th>{tr('操作')}</th>}
</tr>
</thead>
<tbody>
{rows.map((h) => (
<tr key={h.id}>
<td>
{open ? (
<button className="text" onClick={() => open(h.positionId)}>
{h.name}
</button>
) : (
h.name
)}
<small>
{displayTime(h.time)} ·{' '}
{
(
{
initial: tr('初始记录'),
scheduled_expense: tr('计划支出'),
balance: tr('余额更新'),
valuation: tr('估值更新'),
repayment: tr('还款'),
correction: tr('更正'),
transfer_out: tr('转账转出'),
transfer_in: tr('转账到账'),
loan_borrow: tr('借入到账'),
loan_lend: tr('借出付款'),
loan_collect: tr('收回借款'),
loan_repay: tr('偿还债务'),
} as Record<string, string>
)[h.reason]
}
</small>
</td>
<td
className={
positionAmount(h, h.before).startsWith('-') ? 'danger-text' : 'income-text'
}
>
{money(positionAmount(h, h.before), h.currency)}
</td>
<td
className={
positionAmount(h, h.after).startsWith('-') ? 'danger-text' : 'income-text'
}
>
{money(positionAmount(h, h.after), h.currency)}
</td>
<td
className={
positionAmount(h, h.delta).startsWith('-') ? 'danger-text' : 'income-text'
}
>
{money(positionAmount(h, h.delta), h.currency)}
</td>
<td className="notes-cell">{h.notes || '—'}</td>
{correct && (
<td className="record-actions">
<button className="text" disabled={busy} onClick={() => correct(h)}>
{tr(
h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')
? '修改资金往来'
: '更正',
)}
</button>
{remove && (
<button className="text danger-text" disabled={busy} onClick={() => remove(h)}>
{tr('删除')}
</button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
) : (
<Empty title={tr('暂无变化记录')} body={tr('添加项目或更新金额后,历史会自动保存在这里。')} />
);
}