diff --git a/README.md b/README.md index 53a99ed..631ddc8 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,40 @@ # WorthPath -个人资产负债与净资产管理。Node.js 22.12+、pnpm 11、MySQL 8+。 +个人资产负债与净资产管理。NestJS + React/Vite + TypeScript + MySQL,支持手机和电脑。Node.js 22.12+、pnpm 11、MySQL 8+。 ```powershell pnpm install -Copy-Item apps/api/.env.example apps/api/.env +if (!(Test-Path apps/api/.env)) { Copy-Item apps/api/.env.example apps/api/.env } # 仅在本地 .env 设置 DATABASE_URL,先检查数据库是否存在 pnpm db:generate pnpm db:migrate pnpm dev ``` -前端 http://localhost:5173,API http://localhost:3100/api。 +前端 http://localhost:5173,API http://localhost:3100/api。若命令行找不到 pnpm,本机可用项目启动器;它优先使用已安装的 pnpm,随后检查 Codex 随附版本,不修改 PATH: + +```powershell +cd E:\WorthPath +.\scripts\pnpm.ps1 dev +``` + +已有本地环境配置时直接启动,避免重新复制模板。创建数据库前可以在 `apps/api` 中执行 `node scripts/db-preflight.cjs`;脚本先检查同名数据库,仅在不存在时创建空数据库,不删除已有数据。迁移只使用 `migrate deploy`。Windows 上重新生成 Prisma 客户端前需停止 API,以释放其 DLL。 ```powershell pnpm typecheck pnpm build pnpm test +pnpm test:integration # 需先启动 API;只创建并清理随机命名的临时测试用户 +pnpm db:status +pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的文件 ``` -设计见 docs/architecture.md。生产需要 HTTPS、COOKIE_SECURE=true、WEB_ORIGIN 为准确站点地址;前端构建由反向代理托管并代理 /api。不要公开开发服务器。 +当前功能:注册登录和退出、资产/负债账户、独立资产、独立债务和关联、业务日期余额历史与更正、本位币、每日参考汇率与手动汇率、净资产趋势和变化归因、用户 JSON 备份与事务追加恢复。 + +金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额,已有错误记录可单独更正。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。 + +汇率使用 [Frankfurter 公共日汇率 API](https://frankfurter.dev/)。请求使用固定币种表,不发送用户选择、身份或金额。进程运行时每小时检查,每天尝试一次;添加项目、修改本位币会重新触发检查。失败保留原币和历史汇率,显示缺失或实际使用日期,可以手动重试。自动更新不会覆盖同日手动汇率。首次使用不会自动补齐早期历史汇率,缺失日期不绘制完整总额,可手动补录。 + +备份 v1 最多 8 MB / 1000 项目 / 20000 条历史,导入先预览、再明确确认。首版只追加新项目并重建关系,不覆盖已有项目;按项目 ID 和导入来源识别重复;同日汇率冲突会拒绝整次导入。已有本位币保留,空空间恢复备份本位币。文件包含财务数据,应由用户妥善保存。 + +设计见 [数据模型与结构](docs/architecture.md),已验证范围与后续工作见 [首版验收](docs/acceptance.md)。生产需要 HTTPS、COOKIE_SECURE=true、WEB_ORIGIN 为准确站点地址;前端构建由反向代理托管并代理 /api。API 生产启动:在 `apps/api` 中执行 `node dist/main.js`。生产认证限速需迁移到共享存储,数据库用户需最小权限,部署与运行监控尚未配置。 diff --git a/apps/web/index.html b/apps/web/index.html index f83d452..5340c21 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1 +1,12 @@ -WorthPath · 资产之路
\ No newline at end of file + + + + + + WorthPath · 资产之路 + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json index 197bfc0..996ebcc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,4 +19,4 @@ "vite": "^7.1.0", "@vitejs/plugin-react": "^5.0.0" } -} \ No newline at end of file +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..79ca28d --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,1338 @@ +import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react'; +import { + LayoutDashboard, + 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, + today, + type User, + type Position, + type Overview, + type History, + type Rate, + type Total, +} from './api'; +import './style.css'; +const labels: Record = { + overview: '资产总览', + account: '账户', + asset: '独立资产', + debt: '债务', + history: '变化记录', + settings: '设置与备份', +}; +const categories: Record = { + account: [ + ['bank', '银行账户'], + ['cash', '现金'], + ['investment', '投资账户'], + ['credit_card', '信用卡'], + ['loan', '贷款账户'], + ['other', '其他'], + ], + asset: [ + ['property', '房产'], + ['vehicle', '车辆'], + ['gold', '贵金属'], + ['other', '其他资产'], + ], + debt: [ + ['mortgage', '房贷'], + ['car_loan', '车贷'], + ['personal', '个人借款'], + ['other', '其他债务'], + ], +}; +const categoryLabel = (p: Position) => + categories[p.kind]?.find(([v]) => v === p.category)?.[1] || p.category; +function Currency({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( + + ); +} +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} +function Modal({ + title, + close, + children, +}: { + title: string; + close: () => void; + children: ReactNode; +}) { + return ( +
{ + if (e.target === e.currentTarget) close(); + }} + > +
+
+

{title}

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

{title}

+

{body}

+ {action} +
+ ); +} +function Chart({ rows, base }: { rows: Total[]; base: string }) { + const valid = rows.filter((r) => r.complete); + if (valid.length < 2) + return ( + + ); + const values = valid.flatMap((r) => [Number(r.assets), Number(r.liabilities), Number(r.net)]), + min = Math.min(0, ...values), + max = Math.max(1, ...values), + w = 720, + h = 210; + const x = (date: string) => + 35 + + ((new Date(date).getTime() - new Date(rows[0].date).getTime()) / + (new Date(rows.at(-1)!.date).getTime() - new Date(rows[0].date).getTime() || 1)) * + (w - 65); + const y = (v: string) => 15 + ((max - Number(v)) / (max - min)) * (h - 40); + const series = ['assets', 'liabilities', 'net'] as const; + return ( + <> +
+ 总资产 + 总负债 + 净资产 + {base} +
+ + + {series.map((s, i) => ( + + r.complete + ? `${n === 0 || !rows[n - 1].complete ? 'M' : 'L'}${x(r.date)},${y(r[s])}` + : '', + ) + .join(' ')} + fill="none" + stroke={['#199b76', '#dc9463', '#457bca'][i]} + strokeWidth="3" + /> + ))} + {valid.map((r) => ( + + + {r.date} 净资产 {money(r.net, base)} + + + ))} + +
+ {rows[0].date} + {rows.at(-1)!.date} +
+ {rows.some((r) => !r.complete) && ( +

缺失汇率的日期未绘制,曲线在该处断开。

+ )} + + ); +} +export default function App() { + const sessionGeneration = useRef(0), + loadGeneration = useRef(0); + const [user, setUser] = useState(null), + [boot, setBoot] = useState(true), + [register, setRegister] = useState(false), + [page, setPage] = useState('overview'), + [positions, setPositions] = useState([]), + [overview, setOverview] = useState(null), + [rates, setRates] = useState([]), + [selected, setSelected] = useState(null), + [modal, setModal] = useState<{ kind: string; p?: Position; h?: History } | null>(null), + [busy, setBusy] = useState(false), + [loading, setLoading] = useState(false), + [error, setError] = useState(''), + [success, setSuccess] = useState(''); + const [fxStatus, setFxStatus] = useState<{ + state: string; + message: string; + attemptedAt: string | null; + } | null>(null); + const [backup, setBackup] = useState(null), + [preview, setPreview] = useState<{ + positions: number; + revisions: number; + rates: number; + message: string; + baseCurrency: string; + currentBaseCurrency: string; + } | null>(null); + function clearAccount() { + sessionGeneration.current++; + loadGeneration.current++; + setUser(null); + setPositions([]); + setOverview(null); + setRates([]); + setFxStatus(null); + setModal(null); + setSelected(null); + setBackup(null); + setPreview(null); + setPage('overview'); + setRegister(false); + setLoading(false); + setSuccess(''); + } + const report = (e: unknown) => { + setError(e instanceof Error ? e.message : '操作失败'); + if (e instanceof ApiError && e.status === 401) { + clearAccount(); + } + }; + async function load() { + const session = sessionGeneration.current, + request = ++loadGeneration.current; + setLoading(true); + try { + const [p, o, s] = await Promise.all([ + api('/positions'), + api('/overview'), + api< + User & { + rates: Rate[]; + fxStatus: { state: string; message: string; attemptedAt: string | null }; + } + >('/settings'), + ]); + if (session !== sessionGeneration.current || request !== loadGeneration.current) return; + setPositions(p); + setOverview(o); + setRates(s.rates); + setFxStatus(s.fxStatus); + setUser({ username: s.username, baseCurrency: s.baseCurrency }); + } catch (e) { + if (session === sessionGeneration.current && request === loadGeneration.current) report(e); + } finally { + if (session === sessionGeneration.current && request === loadGeneration.current) + setLoading(false); + } + } + useEffect(() => { + const session = sessionGeneration.current; + void api('/auth/me') + .then((u) => { + if (session !== sessionGeneration.current) return; + setUser(u); + return load(); + }) + .catch((e) => { + if (!(e instanceof ApiError && e.status === 401)) report(e); + }) + .finally(() => setBoot(false)); + }, []); + useEffect(() => { + window.scrollTo({ top: 0 }); + }, [page, selected]); + async function act(work: () => Promise, message: string) { + setBusy(true); + setError(''); + setSuccess(''); + try { + const result = await work(); + setSuccess( + result && + typeof result === 'object' && + 'message' in result && + typeof result.message === 'string' + ? result.message + : message, + ); + setModal(null); + await load(); + } catch (e) { + report(e); + } finally { + setBusy(false); + } + } + async function auth(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + const session = ++sessionGeneration.current; + setBusy(true); + setError(''); + try { + const u = await api('/auth/' + (register ? 'register' : 'login'), 'POST', { + username: f.get('username'), + password: f.get('password'), + }); + if (session !== sessionGeneration.current) return; + setPage('overview'); + setSelected(null); + setModal(null); + setUser(u); + await load(); + } 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), + newAction = ( + + ); + if (boot) return
WorthPath · 正在连接你的资产空间…
; + if (!user) + return ( +
+
+
+ W WorthPath +
+
+

YOUR WEALTH, IN PERSPECTIVE

+

+ 让每一步积累, +
+ 都有清晰的方向。 +

+

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

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

开始你的资产之路

+

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

+

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

+ {error && ( +
+ {error} +
+ )} +
+ + + + + + + +
+

+ {register ? '已有账号?' : '还没有账号?'}{' '} + +

+

+ + 个人数据按登录身份隔离 +

+
+
+
+ ); + const nav = [ + ['overview', LayoutDashboard], + ['account', Wallet], + ['asset', Landmark], + ['debt', HandCoins], + ['history', HistoryIcon], + ['settings', Settings], + ] as const; + return ( +
+ +
+
+ 个人财务 / {labels[page]} +
+ 本位币 {user.baseCurrency} + +
+
+
+
+
+

WORTHPATH / {today()}

+

{p ? p.name : labels[page]}

+

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

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

+ 正在刷新数据… +

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

净资产轨迹

+ 按业务日期 +
+ +

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

+
+
+

资产与负债组成

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

+ + 关联关系不会重复计入总额 +

+
+
+
+
+

最近变化

+ +
+ { + setSelected(id); + setPage(positions.find((p) => p.id === id)?.kind || 'account'); + }} + /> +
+
+ 本位币 {user.baseCurrency} · 汇率日期: + {[ + ...new Set( + overview.items + .filter((i) => i.currency !== user.baseCurrency && i.rateDate) + .map((i) => i.rateDate), + ), + ].join(' / ') || '尚无外币汇率'}{' '} + · 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。 +
+ {overview.trend.length > 1 && ( +
+

变化归因

+
+ + + + + + + + + + + {overview.trend.slice(-10).map((r) => ( + + + + + + + ))} + +
日期余额 / 估值变化汇率变化净资产
{r.date} + {r.balanceChange === null || r.balanceChange === undefined + ? '—' + : money(r.balanceChange, user.baseCurrency)} + + {r.fxChange === null || r.fxChange === undefined + ? '—' + : money(r.fxChange, user.baseCurrency)} + {r.complete ? money(r.net, user.baseCurrency) : '汇率不完整'}
+
+
+ )} + + )} + {['account', 'asset', 'debt'].includes(page) && + (p ? ( + <> + +
+
+ + {categoryLabel(p)} · {p.side === 'asset' ? '资产' : '负债'} + {p.archived ? ' · 已归档' : ''} + +

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

+

+ 本位币: + {overview?.items.find((i) => i.id === p.id)?.converted !== null + ? money( + overview?.items.find((i) => i.id === p.id)?.converted || '0', + user.baseCurrency, + ) + : '缺少汇率'} +

+

{p.notes || '暂无备注'}

+ 币种和资产负债属性创建后固定。归档项目仍计入总览。 +
+
+ + + +
+
+ {p.kind === 'debt' && ( +
+
+

关联资产与账户

+ +
+

只展示关系,不改变资产或债务的统计。

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

尚未关联

+ )} +
+ )} +
+

余额与估值历史

+

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

+ setModal({ kind: 'correction', p, h })} + /> +
+ + ) : ( +
+ {positions.some((p) => p.kind === page) ? ( +
+ {positions + .filter((p) => p.kind === page) + .map((p) => ( + + ))} +
+ ) : ( + + )} +
+ ))} + {page === 'history' && ( +
+

全部金额变化

+

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

+ p.history) + .sort((a, b) => b.date.localeCompare(a.date) || b.sequence - a.sequence)} + open={(id) => { + setSelected(id); + setPage(positions.find((p) => p.id === id)?.kind || 'account'); + }} + /> +
+ )} + {page === 'settings' && ( + <> +
+
+
+

本位币与个人设置

+ +
+

+ 账号:{user.username}。切换本位币会重新换算整个历史,原币数据保留。 +

+
{ + e.preventDefault(); + const f = new FormData(e.currentTarget); + void act( + () => api('/settings', 'PATCH', { baseCurrency: f.get('baseCurrency') }), + '本位币已更新,请检查换算汇率', + ); + }} + > + + + + +
+
+
+

每日汇率

+ {fxStatus && ( +

+ {fxStatus.message} + {fxStatus.attemptedAt + ? ' · ' + + new Date(fxStatus.attemptedAt).toLocaleString('zh-CN', { + timeZone: 'Asia/Hong_Kong', + }) + : ''} +

+ )} +

+ 服务器每小时检查当日是否已尝试更新。外部请求固定下载公共汇率表,不发送个人数据。失败保留已有汇率,可手动录入。 +

+ + +

公共参考汇率可能与银行成交价不同;休市日日期可能滞后。

+
+
+
+

已保存汇率

+ {rates.length ? ( +
+ + + + + + + + + + + {rates.slice(0, 100).map((r) => ( + + + + + + + ))} + +
原币 → 本位币汇率日期来源
+ {r.currency} → {r.baseCurrency} + {r.rate}{r.date.slice(0, 10)}{r.source === 'manual' ? '手动' : 'Frankfurter'}
+
+ ) : ( + + )} +
+
+

数据备份与恢复

+

+ 可读的版本化 + JSON,包括项目、历史、关联、币种和汇率;不包含任何认证凭据。备份含个人财务信息,请妥善保管。 +

+
+ setSuccess('备份下载请求已发起,请检查浏览器下载列表')} + > + + 下载我的数据备份 + + +
+ {preview && ( +
+

导入预览

+

+ {preview.positions} 个项目 · {preview.revisions} 条历史 · {preview.rates}{' '} + 条汇率 +

+

+ 备份本位币 {preview.baseCurrency} / 当前本位币 {preview.currentBaseCurrency} +

+

{preview.message}

+ + +
+ )} +
+ + )} +
WorthPath · 记录你的积累,理解你的财富
+
+
+ {modal && ( + { + if (!busy) setModal(null); + }} + > +
{ + e.preventDefault(); + const f = new FormData(e.currentTarget), + v = Object.fromEntries(f.entries()), + kind = modal.kind, + mp = modal.p; + if (kind === 'links') { + void act( + () => + api('/positions/' + mp!.id + '/links', 'PUT', { + targetIds: f.getAll('targetIds'), + }), + '关联已更新', + ); + } else if (kind === 'rate') { + void act(() => api('/rates', 'PUT', v), '汇率已保存,同日已有汇率已更正'); + } else if (kind === 'revision' || kind === 'correction') { + void act( + () => + api( + '/positions/' + mp!.id + '/revisions' + (modal.h ? '/' + modal.h.id : ''), + modal.h ? 'PUT' : 'POST', + v, + ), + '金额历史已保存,总览已重新计算', + ); + } else if (kind === 'edit') { + void act( + () => api('/positions/' + mp!.id, 'PATCH', { ...v, archived: mp!.archived }), + '项目资料已保存', + ); + } else { + const category = String(v.category), + side = + kind === 'debt' || + (kind === 'account' && ['credit_card', 'loan'].includes(category)) + ? 'liability' + : 'asset'; + void act(() => api('/positions', 'POST', { ...v, kind, side }), '项目已添加'); + } + }} + > + {['account', 'asset', 'debt', 'edit'].includes(modal.kind) && ( + <> + + + + + + + {modal.kind === 'account' && ( +

信用卡、贷款账户自动计入负债,其余账户计入资产。

+ )} + + )} + {['account', 'asset', 'debt', 'rate'].includes(modal.kind) && ( + + + + )} + {modal.kind === 'rate' && ( + <> + + + + + + +

保存将更正同币种、同日期的已有汇率,历史总额会重新计算。

+ + )} + {['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && ( + + + + )} + {['account', 'asset', 'debt', 'revision', 'correction', 'rate'].includes( + modal.kind, + ) && ( + + + + )} + {modal.kind === 'revision' && ( + <> + + + +

+ 填写更新后的余额或剩余欠款,每次保存会新增历史。已有错误记录请在历史中更正。 +

+ + )} + {modal.kind === 'correction' && ( +

+ 这将修改选中的历史金额并重算后续变化,请确认日期和余额。 +

+ )} + {modal.kind === 'links' ? ( +
+ {positions.filter((p) => p.kind !== 'debt').length ? ( + positions + .filter((p) => p.kind !== 'debt') + .map((p) => ( + + )) + ) : ( +

请先创建账户或独立资产。

+ )} +
+ ) : ( + modal.kind !== 'rate' && ( + +