feat: add privacy controls, minute history and ZIP backups

This commit is contained in:
陈煜 committed 2026-10-01 17:35:11 +08:00
1 parent ba0d5201c9
commit 2a650853ee
21 files changed
+1729 -226

No files matched your search

+310 -108
View File
@@ -23,6 +23,8 @@ import {
currencies,
money,
today,
nowMinute,
displayTime,
type User,
type Position,
type Overview,
@@ -214,7 +216,11 @@ export default function App() {
baseCurrency: string;
currentBaseCurrency: string;
} | null>(null);
const [clearStep, setClearStep] = useState(0);
const activityAt = useRef(0);
function clearAccount() {
setClearStep(0);
activityAt.current = 0;
sessionGeneration.current++;
loadGeneration.current++;
setUser(null);
@@ -253,11 +259,20 @@ export default function App() {
>('/settings'),
]);
if (session !== sessionGeneration.current || request !== loadGeneration.current) return;
setPositions(p);
if (o.revealed !== !!s.revealed) {
setPositions([]);
setOverview(null);
setSelected(null);
setModal(null);
void load();
return;
}
if (!activityAt.current && s.lastActivity) activityAt.current = +new Date(s.lastActivity);
setPositions(s.revealed ? p : p.filter((position) => !position.hidden));
setOverview(o);
setRates(s.rates);
setFxStatus(s.fxStatus);
setUser({ username: s.username, baseCurrency: s.baseCurrency });
setUser(s);
} catch (e) {
if (session === sessionGeneration.current && request === loadGeneration.current) report(e);
} finally {
@@ -281,12 +296,61 @@ export default function App() {
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('长时间无操作,已自动退出登录');
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);
void load();
}
}, 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' &&
@@ -298,7 +362,7 @@ export default function App() {
setModal(null);
await load();
} catch (e) {
report(e);
if (session === sessionGeneration.current) report(e);
} finally {
setBusy(false);
}
@@ -318,6 +382,7 @@ export default function App() {
setPage('overview');
setSelected(null);
setModal(null);
activityAt.current = Date.now();
setUser(u);
await load();
} catch (e) {
@@ -449,46 +514,64 @@ export default function App() {
['settings', Settings],
] as const;
return (
<div className="app">
<aside className="sidebar">
<div className="brand">
<span>W</span> WorthPath
</div>
<p className="nav-caption">我的资产空间</p>
<nav>
{nav.map(([key, Icon]) => (
<button
key={key}
className={page === key ? 'active' : ''}
onClick={() => {
setPage(key);
setSelected(null);
}}
>
<Icon size={19} />
<span>{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>个人账户 · {user.baseCurrency}</small>
<div className={'app' + (user.showSidebar === false ? ' sidebar-hidden' : '')}>
{user.showSidebar !== false && (
<aside className="sidebar">
<div className="brand">
<span>W</span> WorthPath
</div>
<button
className="icon"
aria-label="退出登录"
disabled={busy}
onClick={() => void logout()}
>
<LogOut size={18} />
</button>
</div>
</aside>
<p className="nav-caption">我的资产空间</p>
<nav>
{nav.map(([key, Icon]) => (
<button
key={key}
className={page === key ? 'active' : ''}
onClick={() => {
setPage(key);
setSelected(null);
}}
>
<Icon size={19} />
<span>{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>个人账户 · {user.baseCurrency}</small>
</div>
<button
className="icon"
aria-label="退出登录"
disabled={busy}
onClick={() => void logout()}
>
<LogOut size={18} />
</button>
</div>
</aside>
)}
<main>
<header className="topbar">
<span>个人财务 / {labels[page]}</span>
{user.showSidebar === false && (
<select
aria-label="页面导航"
value={page}
onChange={(e) => {
setPage(e.target.value);
setSelected(null);
}}
>
{nav.map(([key]) => (
<option key={key} value={key}>
{labels[key]}
</option>
))}
</select>
)}
<div>
<span className="currency-pill">本位币 {user.baseCurrency}</span>
<button
@@ -502,6 +585,24 @@ export default function App() {
</div>
</header>
<div className="content">
<div className="privacy-toolbar">
<span>
{overview?.revealed
? '当前包含隐藏项目 · 验证 5 分钟后自动锁定'
: '当前不包含隐藏账户、资产和债务,净资产按此范围计算'}
</span>
<button
className="secondary"
disabled={busy}
onClick={() =>
overview?.revealed
? void act(() => api('/auth/lock', 'POST'), '隐藏项目已锁定')
: setModal({ kind: 'reveal' })
}
>
{overview?.revealed ? '锁定隐藏项目' : '显示隐藏资产(验证密码)'}
</button>
</div>
<div className="page-heading">
<div>
<p className="eyebrow">WORTHPATH / {today()}</p>
@@ -691,6 +792,7 @@ export default function App() {
{p.archived ? ' · 已归档' : ''}
</span>
<h2>{money(p.amount, p.currency)}</h2>
{p.hidden && <span className="badge">隐藏项目</span>}
<p className="muted">
本位币:
{overview?.items.find((i) => i.id === p.id)?.converted !== null
@@ -718,6 +820,7 @@ export default function App() {
category: p.category,
notes: p.notes,
archived: !p.archived,
hidden: p.hidden,
}),
p.archived ? '项目已恢复' : '项目已归档',
)
@@ -830,7 +933,7 @@ export default function App() {
<HistoryTable
rows={positions
.flatMap((p) => p.history)
.sort((a, b) => b.date.localeCompare(a.date) || b.sequence - a.sequence)}
.sort((a, b) => b.time.localeCompare(a.time) || b.sequence - a.sequence)}
open={(id) => {
setSelected(id);
setPage(positions.find((p) => p.id === id)?.kind || 'account');
@@ -857,11 +960,40 @@ export default function App() {
e.preventDefault();
const f = new FormData(e.currentTarget);
void act(
() => api('/settings', 'PATCH', { baseCurrency: f.get('baseCurrency') }),
'本位币已更新,请检查换算汇率',
() =>
api('/settings', 'PATCH', {
baseCurrency: f.get('baseCurrency'),
showSidebar: f.get('showSidebar') === 'true',
idleMinutes: Number(f.get('idleMinutes')),
}),
'个人设置已保存',
);
}}
>
<Field label="左侧菜单栏">
<select
name="showSidebar"
key={String(user.showSidebar)}
defaultValue={String(user.showSidebar)}
>
<option value="true">显示</option>
<option value="false">隐藏(顶部仍可切换页面)</option>
</select>
</Field>
<Field label="无操作自动退出(分钟)">
<input
name="idleMinutes"
type="number"
min="0"
max="1440"
required
defaultValue={user.idleMinutes}
key={user.idleMinutes}
/>
</Field>
<p className="muted">
0 为关闭;最长 1440 分钟。到时退出登录并清除页面数据,浏览器标签页保留。
</p>
<Field label="本位币">
<select
name="baseCurrency"
@@ -895,7 +1027,7 @@ export default function App() {
</p>
)}
<p className="muted">
服务器每小时检查当日是否已尝试更新。外部请求固定下载公共汇率表,不发送个人数据。失败保留已有汇率,可手动录入。
服务器每小时检查当日是否已尝试更新。外部请求固定下载公共汇率表,不发送个人数据。失败保留已有汇率,可稍后重试。
</p>
<button
className="secondary"
@@ -910,9 +1042,6 @@ export default function App() {
<RefreshCw size={16} />
立即更新
</button>
<button className="text" onClick={() => setModal({ kind: 'rate' })}>
手动录入 / 更正
</button>
<p className="muted">公共参考汇率可能与银行成交价不同;休市日日期可能滞后。</p>
</section>
</div>
@@ -937,30 +1066,85 @@ export default function App() {
</td>
<td>{r.rate}</td>
<td>{r.date.slice(0, 10)}</td>
<td>{r.source === 'manual' ? '手动' : 'Frankfurter'}</td>
<td>{r.source === 'manual' ? '历史导入' : 'Frankfurter'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<Empty
title="没有保存的外币汇率"
body="添加外币项目后更新,或手动录入带日期的汇率。"
/>
<Empty title="没有保存的外币汇率" body="添加外币项目后更新并保存自动汇率。" />
)}
</section>
<section className="panel">
<h2>清空本账号数据</h2>
<p className="muted">
清除本账号全部账户、资产、债务、历史和汇率(包括隐藏项目);保留登录账号及个人设置。请先下载备份并确认文件已保存。
</p>
{clearStep === 0 ? (
<a
className="secondary"
href="/api/backup"
download
onClick={() => setClearStep(1)}
>
<Download size={16} />
第一步:下载备份
</a>
) : 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('备份尚未完成,请重新下载');
setClearStep(2);
} catch (e) {
report(e);
}
}}
>
备份已保存,进入下一步
</button>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
const confirmation = new FormData(e.currentTarget).get('confirmation');
void act(async () => {
await api('/backup/clear', 'POST', { confirmation });
setClearStep(0);
setSelected(null);
setBackup(null);
setPreview(null);
}, '本账号数据已清空');
}}
>
<Field label="输入“确定清空”以确认">
<input name="confirmation" required pattern="确定清空" autoComplete="off" />
</Field>
<button className="danger" disabled={busy}>
清空本账号数据
</button>
</form>
)}
{clearStep > 0 && (
<button className="text" onClick={() => setClearStep(0)}>
取消清空 / 重新下载
</button>
)}
<hr />
<h2>数据备份与恢复</h2>
<p className="muted">
可读的版本化
JSON,包括项目、历史、关联、币种和汇率;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
ZIP 内分文件保存可读
JSON,包括全部账户、资产、债务、历史、关联、币种、设置和汇率,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
</p>
<div className="actions">
<a
className="secondary"
href="/api/backup"
download={'worthpath-' + today() + '.json'}
download={'worthpath-' + today() + '.zip'}
onClick={() => setSuccess('备份下载请求已发起,请检查浏览器下载列表')}
>
<Download size={16} />
@@ -971,7 +1155,7 @@ export default function App() {
选择备份并验证
<input
type="file"
accept=".json,application/json"
accept=".zip,application/zip,.json,application/json"
disabled={busy}
onChange={async (e) => {
setBackup(null);
@@ -982,15 +1166,20 @@ export default function App() {
const uploadSession = sessionGeneration.current;
setBusy(true);
try {
if (file.size > 8 * 1024 * 1024) throw new Error('文件不能超过 8 MB');
const b = JSON.parse(await file.text()),
result = await api<NonNullable<typeof preview>>(
'/backup/preview',
'POST',
b,
);
if (file.size > 512 * 1024 * 1024)
throw new Error('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 || 'ZIP 验证失败', response.status);
if (uploadSession !== sessionGeneration.current) return;
setBackup(b);
setBackup(result.token);
setPreview(result);
} catch (err) {
if (uploadSession === sessionGeneration.current) report(err);
@@ -1018,7 +1207,10 @@ export default function App() {
disabled={busy}
onClick={() =>
void act(async () => {
await api('/backup/import', 'POST', { confirmed: true, backup });
await api('/backup/import-file', 'POST', {
confirmed: true,
token: backup,
});
setPreview(null);
setBackup(null);
}, '备份已完整导入')
@@ -1046,14 +1238,14 @@ export default function App() {
{modal && (
<Modal
title={
modal.kind === 'edit'
? '编辑项目'
: modal.kind === 'revision'
? '更新余额 / 估值'
: modal.kind === 'correction'
? '更正历史记录'
: modal.kind === 'rate'
? '保存日汇率'
modal.kind === 'reveal'
? '验证密码以显示隐藏资产'
: modal.kind === 'edit'
? '编辑项目'
: modal.kind === 'revision'
? '更新余额 / 估值'
: modal.kind === 'correction'
? '更正历史记录'
: modal.kind === 'links'
? '管理债务关联'
: '新增' + labels[modal.kind]
@@ -1069,7 +1261,12 @@ export default function App() {
v = Object.fromEntries(f.entries()),
kind = modal.kind,
mp = modal.p;
if (kind === 'links') {
if (kind === 'reveal') {
void act(
() => api('/auth/reveal', 'POST', { password: f.get('password') }),
'隐藏项目已解锁 5 分钟',
);
} else if (kind === 'links') {
void act(
() =>
api('/positions/' + mp!.id + '/links', 'PUT', {
@@ -1077,8 +1274,6 @@ export default function App() {
}),
'关联已更新',
);
} else if (kind === 'rate') {
void act(() => api('/rates', 'PUT', v), '汇率已保存,同日已有汇率已更正');
} else if (kind === 'revision' || kind === 'correction') {
void act(
() =>
@@ -1091,7 +1286,12 @@ export default function App() {
);
} else if (kind === 'edit') {
void act(
() => api('/positions/' + mp!.id, 'PATCH', { ...v, archived: mp!.archived }),
() =>
api('/positions/' + mp!.id, 'PATCH', {
...v,
archived: mp!.archived,
hidden: f.get('hidden') === 'on',
}),
'项目资料已保存',
);
} else {
@@ -1101,12 +1301,36 @@ export default function App() {
(kind === 'account' && ['credit_card', 'loan'].includes(category))
? 'liability'
: 'asset';
void act(() => api('/positions', 'POST', { ...v, kind, side }), '项目已添加');
void act(
() =>
api('/positions', 'POST', {
...v,
kind,
side,
hidden: f.get('hidden') === 'on',
}),
'项目已添加',
);
}
}}
>
{modal.kind === 'reveal' && (
<Field label="登录密码">
<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="hidden" type="checkbox" defaultChecked={modal.p?.hidden} />
隐藏此项目(密码验证后可查看和编辑)
</label>
<Field label="名称">
<input
name="name"
@@ -1133,7 +1357,7 @@ export default function App() {
)}
</>
)}
{['account', 'asset', 'debt', 'rate'].includes(modal.kind) && (
{['account', 'asset', 'debt'].includes(modal.kind) && (
<Field label="原币币种">
<select name="currency" defaultValue={user.baseCurrency}>
{currencies.map((c) => (
@@ -1142,26 +1366,6 @@ export default function App() {
</select>
</Field>
)}
{modal.kind === 'rate' && (
<>
<Field label="换算到本位币">
<select name="baseCurrency" defaultValue={user.baseCurrency}>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<Field label="1 单位原币 = 多少本位币">
<input
name="rate"
inputMode="decimal"
required
pattern="(0|[1-9][0-9]{0,11})(\.[0-9]{1,12})?"
/>
</Field>
<p className="muted">保存将更正同币种、同日期的已有汇率,历史总额会重新计算。</p>
</>
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field label={'变更后的绝对金额' + (modal.p ? '(' + modal.p.currency + ')' : '')}>
<input
@@ -1174,17 +1378,15 @@ export default function App() {
/>
</Field>
)}
{['account', 'asset', 'debt', 'revision', 'correction', 'rate'].includes(
modal.kind,
) && (
<Field label="业务日期">
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field label={'业务时间(北京时间,精确到分钟)'}>
<input
type="date"
type={'datetime-local'}
name="date"
required
min="1900-01-01"
max={today()}
defaultValue={modal.h?.date || today()}
min={'1900-01-01T00:00'}
max={nowMinute()}
defaultValue={modal.h?.time || nowMinute()}
/>
</Field>
)}
@@ -1233,7 +1435,7 @@ export default function App() {
)}
</div>
) : (
modal.kind !== 'rate' && (
modal.kind !== 'reveal' && (
<Field label="备注">
<textarea
name="notes"
@@ -1302,7 +1504,7 @@ function HistoryTable({
h.name
)}
<small>
{h.date} ·{' '}
{displayTime(h.time)} ·{' '}
{
(
{
+14 -1
View File
@@ -17,11 +17,20 @@ export async function api<T>(path: string, method = 'GET', data?: unknown): Prom
if (!res.ok) throw new ApiError(body.message || '操作失败', res.status);
return body;
}
export type User = { username: string; baseCurrency: string };
export type User = {
username: string;
baseCurrency: string;
showSidebar: boolean;
idleMinutes: number;
revealed?: boolean;
revealUntil?: string | null;
lastActivity?: string;
};
export type History = {
id: string;
sequence: number;
createdAt: string | null;
time: string;
positionId: string;
name: string;
kind: string;
@@ -43,6 +52,7 @@ export type Position = {
amount: string;
notes: string;
archived: boolean;
hidden: boolean;
history: History[];
outgoing: { targetId: string }[];
};
@@ -58,6 +68,7 @@ export type Total = {
};
export type Overview = Total & {
baseCurrency: string;
revealed: boolean;
trend: Total[];
recent: History[];
items: {
@@ -85,6 +96,8 @@ export function today() {
day: '2-digit',
}).format(new Date());
}
export const nowMinute = () => new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 16);
export const displayTime = (s: string) => s.replace('T', ' ');
export function money(v: string, currency: string) {
const [whole, frac] = v.split('.');
return `${currency} ${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${frac !== undefined ? '.' + frac : ''}`;
+36
View File
@@ -234,6 +234,42 @@ strong {
display: flex;
min-height: 100vh;
}
.sidebar-hidden main {
margin-left: 0;
padding-bottom: 0;
width: 100%;
}
.privacy-toolbar {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
font-size: 13px;
color: #637b75;
}
.danger {
background: #ae3232;
color: white;
padding: 12px 18px;
border: 0;
border-radius: 10px;
cursor: pointer;
}
.check-line {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.check-line input {
width: auto;
}
.sidebar-hidden .topbar select {
max-width: 150px;
width: auto;
}
.sidebar {
width: 228px;
position: fixed;