feat: support credit transfers and precious metal valuations

This commit is contained in:
陈煜 committed 2026-10-03 21:10:26 +08:00
1 parent 425b45c91a
commit 518aea1e59
39 files changed
+1572 -77

No files matched your search

+51 -21
View File
@@ -54,6 +54,7 @@ import { orderedGroups, mergeGroupOrder } from './group-order';
import { Calendar } from './Calendar';
import { SchedulePanel } from './SchedulePanel';
import { IconPicker } from './IconPicker';
import { MetalPanel } from './MetalPanel';
import { QuickTransfer } from './QuickTransfer';
import { TransferForm } from './TransferForm';
import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm';
@@ -259,11 +260,7 @@ export default function App() {
setQuickTransfer((current) =>
current &&
[current.sourceId, current.targetId].some(
(id) =>
id &&
!positions.some(
(p) => p.id === id && p.kind === 'account' && p.side === 'asset' && !p.archived,
),
(id) => id && !positions.some((p) => p.id === id && p.kind === 'account' && !p.archived),
)
? null
: current,
@@ -271,8 +268,8 @@ export default function App() {
}, [positions]);
function chooseQuickAccount(p: Position) {
if (busy || loading) return;
if (p.kind !== 'account' || p.side !== 'asset' || p.archived) {
setError(tr('请选择启用的资产账户'));
if (p.kind !== 'account' || p.archived) {
setError(tr('请选择启用的账户'));
return;
}
setQuickTransfer((current) => {
@@ -1152,7 +1149,8 @@ export default function App() {
<span>
{tr(labels[k])}
<small>
{overview.items.filter((p) => p.kind === k).length} {tr('个项目')}
{overview.items.filter((p) => p.included && p.kind === k).length}{' '}
{tr('个项目')}
</small>
</span>
<ArrowUpRight size={18} />
@@ -1274,10 +1272,14 @@ export default function App() {
: tr('缺少汇率')}
</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.side === 'asset' && !p.archived && (
{p.kind === 'account' && !p.archived && (
<>
<button
className="primary"
@@ -1286,6 +1288,7 @@ export default function App() {
{tr('转账')}
</button>
<button
hidden={p.side !== 'asset'}
className="secondary"
onClick={() => setModal({ kind: 'debt-payment', p })}
>
@@ -1342,6 +1345,9 @@ export default function App() {
</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">
@@ -1479,9 +1485,7 @@ export default function App() {
? quickTransfer.sourceId === p.id || quickTransfer.targetId === p.id
: undefined
}
disabled={
busy || (!!quickTransfer && (p.side !== 'asset' || p.archived))
}
disabled={busy || (!!quickTransfer && p.archived)}
key={p.id}
onClick={() =>
quickTransfer !== null && page === 'account' && quickMode
@@ -1507,6 +1511,10 @@ export default function App() {
</div>
<h3>{p.name}</h3>
{p.groupName && <span className="badge">{p.groupName}</span>}
{(p.included === false ||
(p.kind === 'asset' && user.includeIndependentAssets === false)) && (
<span className="badge">{tr('不计入总览')}</span>
)}
<p>
{categoryLabel(p)} ·{' '}
{p.kind === 'debt'
@@ -1555,9 +1563,7 @@ export default function App() {
disabled={
busy ||
loading ||
positions.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
).length < 2
positions.filter((p) => p.kind === 'account' && !p.archived).length < 2
}
onClick={() => setQuickTransfer({})}
>
@@ -1729,9 +1735,7 @@ export default function App() {
{page === 'schedules' && (
<section className="panel">
<SchedulePanel
accounts={positions.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
)}
accounts={positions.filter((p) => p.kind === 'account' && !p.archived)}
refresh={() => load(false, true)}
/>
</section>
@@ -1898,12 +1902,26 @@ export default function App() {
'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('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')}
@@ -2136,6 +2154,7 @@ export default function App() {
/>
)}
</section>
{settingsSection === 'rates' && <MetalPanel refresh={() => load(false, true)} />}
<section className="panel" hidden={settingsSection !== 'clear'}>
<h2 className="danger-text">{tr('清空本账号数据')}</h2>
<p className="muted">
@@ -2546,9 +2565,7 @@ export default function App() {
<p>{tr('正在刷新数据…')}</p>
) : modal.kind === 'transfer' ? (
<TransferForm
accounts={movementPositions.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
)}
accounts={movementPositions.filter((p) => p.kind === 'account' && !p.archived)}
initialSourceId={modal.p?.kind === 'account' ? modal.p.id : undefined}
busy={busy}
submit={(v) =>
@@ -2621,6 +2638,7 @@ export default function App() {
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
archived: mp!.archived,
hidden: f.get('hidden') === 'on',
included: f.get('included') === 'on',
}),
tr('项目资料已保存'),
);
@@ -2641,6 +2659,7 @@ export default function App() {
side,
amount: accountInputAmount({ kind, side }, String(v.amount)),
hidden: f.get('hidden') === 'on',
included: f.get('included') === 'on',
}),
tr('项目已添加'),
);
@@ -2660,6 +2679,17 @@ export default function App() {
)}
{['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('隐藏此项目(密码验证后可查看和编辑)')}
+256
View File
@@ -0,0 +1,256 @@
import { useEffect, useState } from 'react';
import { api, money, type Position, currencies } from './api';
import { t as tr } from './i18n';
import { useToast } from './Toast';
type Quote = { metalType: string; currency: string; price: string; date: string; source: string };
type Data = { status: { state: string; message: string }; prices: Quote[] };
export function MetalPanel({
position,
refresh,
}: {
position?: Position;
refresh: () => Promise<void>;
}) {
const [data, setData] = useState<Data>(),
[busy, setBusy] = useState(false),
[error, setError] = useState(''),
[message, setMessage] = useState('');
useToast(error, 'error');
useToast(message, 'success');
async function load() {
setData(await api<Data>('/metals'));
}
useEffect(() => {
let active = true;
void api<Data>('/metals')
.then((v) => {
if (active) setData(v);
})
.catch((e) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [position]);
useEffect(() => {
if (data?.status.state !== 'updating') return;
let active = true;
const timer = setInterval(() => {
void api<Data>('/metals')
.then((v) => {
if (active) setData(v);
})
.catch((e) => {
if (active) {
setError(e.message);
clearInterval(timer);
}
});
}, 2000);
return () => {
active = false;
clearInterval(timer);
};
}, [data?.status.state]);
async function act(work: () => Promise<unknown>, message: string) {
setBusy(true);
setError('');
setMessage('');
try {
await work();
await load();
await refresh();
setMessage(tr(message));
} catch (e) {
setError((e as Error).message);
await load().catch(() => {});
} finally {
setBusy(false);
}
}
const quote = data?.prices.find(
(q) => q.metalType === position?.metalType && q.currency === position?.currency,
);
return (
<section className="panel">
<h2>{tr('贵金属估价')}</h2>
<p className="muted">
{tr(
'黄金与白银按重量、纯度及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。',
)}
</p>
{position && (
<form
key={
position.id +
String(position.metalGrams) +
String(position.autoValuation) +
String(position.metalPurity) +
String(position.metalType)
}
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
void act(
() =>
api('/metals/' + position.id, 'PUT', {
metalType: f.get('metalType'),
metalGrams: f.get('metalGrams'),
metalPurity: f.get('metalPurity'),
autoValuation: f.has('autoValuation'),
}),
'贵金属估价设置已保存',
);
}}
>
<label>
{tr('贵金属品种')}
<select name="metalType" defaultValue={position.metalType || 'gold'}>
<option value="gold">{tr('黄金')}</option>
<option value="silver">{tr('白银')}</option>
</select>
</label>
<label>
{tr('重量(克)')}
<input
name="metalGrams"
required
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
defaultValue={position.metalGrams || ''}
/>
</label>
<label>
{tr('纯度(0–1,例如 0.999)')}
<input
name="metalPurity"
required
type="number"
min="0.00000001"
max="1"
step="0.00000001"
defaultValue={position.metalPurity || '1'}
/>
</label>
<label className="check-line">
<input type="checkbox" name="autoValuation" defaultChecked={position.autoValuation} />
{tr('随参考价自动记录估值')}
</label>
<button className="primary" disabled={busy || position.archived}>
{tr('保存估价设置')}
</button>
<p className="muted">
{tr('开启后,每日价格更新会新增估值历史;已有历史不会改写。关闭后可手动应用估价。')}
</p>
</form>
)}
<div className="actions">
<button
className="secondary"
disabled={busy}
onClick={() => void act(() => api('/metals/refresh', 'POST', {}), '贵金属参考价已更新')}
>
{tr('刷新贵金属价格')}
</button>
{position && (
<button
className="primary"
disabled={busy || !quote || position.archived}
onClick={() =>
void act(() => api('/metals/' + position.id + '/value', 'POST', {}), '估价已记入历史')
}
>
{tr('应用估价')}
</button>
)}
</div>
{quote && (
<p>
{tr('参考价')}:{money(quote.price, quote.currency)}/{tr('克')} ·{' '}
{quote.date.slice(0, 10)} · {quote.source === 'manual' ? tr('手动') : 'Gold API'}
</p>
)}
<p className={data?.status.state === 'error' ? 'danger-text' : 'muted'}>
{tr(data?.status.message || '尚未尝试更新')}
</p>
<details>
<summary>{tr('手动录入贵金属价格')}</summary>
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
void act(
() => api('/metals/prices', 'POST', Object.fromEntries(f)),
'贵金属价格已保存',
);
}}
>
<label>
{tr('贵金属品种')}
<select name="metalType" defaultValue={position?.metalType || 'gold'}>
<option value="gold">{tr('黄金')}</option>
<option value="silver">{tr('白银')}</option>
</select>
</label>
<label>
{tr('币种')}
<select name="currency" defaultValue={position?.currency || 'CNY'}>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</label>
<label>
{tr('每克纯金属价格')}
<input
name="price"
required
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,11})([.][0-9]{1,12})?"
/>
</label>
<label>
{tr('报价日期')}
<input
name="date"
type="date"
required
max={new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)}
defaultValue={new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)}
/>
</label>
<button className="primary" disabled={busy}>
{tr('保存参考价')}
</button>
</form>
</details>
{!position && (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>{tr('贵金属品种')}</th>
<th>{tr('参考价')}</th>
<th>{tr('报价日期')}</th>
<th>{tr('来源')}</th>
</tr>
</thead>
<tbody>
{data?.prices.map((q) => (
<tr key={q.metalType + q.currency + q.date}>
<td>{tr(q.metalType === 'gold' ? '黄金' : '白银')}</td>
<td>
{money(q.price, q.currency)}/{tr('克')}
</td>
<td>{q.date.slice(0, 10)}</td>
<td>{q.source === 'manual' ? tr('手动') : 'Gold API'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { money, nowMinute, requestUuid, type Position } from './api';
import { money, positionAmount, nowMinute, requestUuid, type Position } from './api';
import { t as tr } from './i18n';
export function QuickTransfer({
source,
@@ -40,7 +40,7 @@ export function QuickTransfer({
<div>
<small>{tr('转出账户')}</small>
<strong>{source.name}</strong>
<span>{money(source.amount, source.currency)}</span>
<span>{money(positionAmount(source), source.currency)}</span>
<button type="button" className="text" disabled={busy} onClick={changeSource}>
{tr('重选')}
</button>
@@ -49,7 +49,7 @@ export function QuickTransfer({
<div>
<small>{tr('转入账户')}</small>
<strong>{target.name}</strong>
<span>{money(target.amount, target.currency)}</span>
<span>{money(positionAmount(target), target.currency)}</span>
<button type="button" className="text" disabled={busy} onClick={changeTarget}>
{tr('重选')}
</button>
+6 -2
View File
@@ -47,7 +47,11 @@ export function SchedulePanel({
[source, setSource] = useState(''),
[target, setTarget] = useState(''),
[amount, setAmount] = useState('');
const sourceId = accounts.some((p) => p.id === source) ? source : accounts[0]?.id || '';
const sourceAccounts =
operation === 'expense' ? accounts.filter((p) => p.side === 'asset') : accounts;
const sourceId = sourceAccounts.some((p) => p.id === source)
? source
: sourceAccounts[0]?.id || '';
const sourceAccount = accounts.find((p) => p.id === sourceId),
targetAccount = accounts.find((p) => p.id === target);
const same = sourceAccount?.currency === targetAccount?.currency;
@@ -173,7 +177,7 @@ export function SchedulePanel({
{tr('付款账户')}
<AccountPicker
name="sourceId"
accounts={accounts}
accounts={sourceAccounts}
value={sourceId}
onChange={(id) => {
setSource(id);
+1 -1
View File
@@ -27,7 +27,7 @@ export function TransferForm({
submit(Object.fromEntries(new FormData(e.currentTarget)));
}
if (accounts.length < 2)
return <p>{tr('请先创建至少两个启用中的资产账户。隐藏账户需验证密码后才能选择。')}</p>;
return <p>{tr('请先创建至少两个启用中的账户。隐藏账户需验证密码后才能选择。')}</p>;
return (
<form onSubmit={save} className="transfer-form">
<input type="hidden" name="requestId" value={requestId} />
+7
View File
@@ -27,6 +27,7 @@ export type User = {
sessionHours?: number;
requireHiddenPassword?: boolean;
overviewCards?: string[];
includeIndependentAssets?: boolean;
sessionExpiresAt?: string;
revealed?: boolean;
revealUntil?: string | null;
@@ -50,6 +51,11 @@ export type History = {
reason: string;
};
export type Position = {
included?: boolean;
metalType?: string | null;
metalGrams?: string | null;
metalPurity?: string;
autoValuation?: boolean;
groupName?: string;
iconId?: string | null;
id: string;
@@ -83,6 +89,7 @@ export type Overview = Total & {
recent: History[];
items: {
id: string;
included: boolean;
kind: string;
converted: string | null;
rateDate: string | null;
+33 -1
View File
@@ -519,5 +519,37 @@
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "When disabled, a signed-in account can reveal hidden assets directly for 5 minutes. You can lock them sooner. Saving this setting locks hidden assets in all sessions.",
"隐藏资产已显示": "hidden assets revealed",
"显示隐藏资产": "Reveal hidden assets",
"当前包含隐藏资产 · 5 分钟后自动锁定": "hidden assets included · Locks automatically in 5 minutes"
"当前包含隐藏资产 · 5 分钟后自动锁定": "hidden assets included · Locks automatically in 5 minutes",
"独立资产计入总览": "Include standalone assets in overview",
"关闭后,独立资产不参与总额和净资产轨迹,资料与历史仍保留。": "Exclude standalone assets from totals and net worth history while keeping their records.",
"计入资产总览": "Include in portfolio totals",
"取消后不参与资产、负债及净资产轨迹,仍可管理和转账。": "Exclude from assets, liabilities and net worth history; management and transfers remain available.",
"不计入总览": "Excluded from totals",
"贵金属估价": "Precious metal valuation",
"黄金与白银按重量、纯度及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "Value gold and silver by weight, purity and reference price per gram. Daily updates keep previous prices on failure; workmanship and resale discounts are excluded.",
"贵金属品种": "Metal",
"黄金": "Gold",
"白银": "Silver",
"重量(克)": "Weight (grams)",
"纯度(0–1,例如 0.999)": "Purity (0–1, e.g. 0.999)",
"随参考价自动记录估值": "Record valuations automatically with price updates",
"保存估价设置": "Save valuation settings",
"开启后,每日价格更新会新增估值历史;已有历史不会改写。关闭后可手动应用估价。": "Automatic updates add valuation history and preserve earlier records. You can also apply valuations manually.",
"刷新贵金属价格": "Refresh metal prices",
"应用估价": "Apply valuation",
"参考价": "Reference price",
"克": "g",
"手动录入贵金属价格": "Enter a metal price manually",
"每克纯金属价格": "Pure metal price per gram",
"报价日期": "Quote date",
"保存参考价": "Save reference price",
"贵金属估价设置已保存": "Valuation settings saved",
"贵金属参考价已更新": "Metal prices updated",
"估价已记入历史": "Valuation recorded",
"贵金属价格已保存": "Metal price saved",
"当前没有已设置重量的贵金属资产": "No configured precious metal holdings",
"正在更新贵金属参考价": "Updating reference prices",
"贵金属价格更新失败,已有价格与估值已保留,可手动录价": "Price update failed. Previous prices and values are preserved; you can enter prices manually.",
"请先创建至少两个启用中的账户。隐藏账户需验证密码后才能选择。": "Create at least two active accounts. Unlock hidden accounts before selecting them.",
"请选择启用的账户": "Choose an active account"
}
+33 -1
View File
@@ -519,5 +519,37 @@
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "關閉後,已登入帳號可直接顯示隱藏資產。每次顯示持續 5 分鐘,也可手動鎖定;儲存此設定會鎖定所有會話的隱藏資產。",
"隐藏资产已显示": "隱藏資產已顯示",
"显示隐藏资产": "顯示隱藏資產",
"当前包含隐藏资产 · 5 分钟后自动锁定": "目前包含隱藏資產 · 5 分鐘後自動鎖定"
"当前包含隐藏资产 · 5 分钟后自动锁定": "目前包含隱藏資產 · 5 分鐘後自動鎖定",
"独立资产计入总览": "獨立資產計入總覽",
"关闭后,独立资产不参与总额和净资产轨迹,资料与历史仍保留。": "關閉後,獨立資產不參與總額和淨資產軌跡,資料與歷史仍保留。",
"计入资产总览": "計入資產總覽",
"取消后不参与资产、负债及净资产轨迹,仍可管理和转账。": "取消後不參與資產、負債及淨資產軌跡,仍可管理和轉賬。",
"不计入总览": "不計入總覽",
"贵金属估价": "貴金屬估價",
"黄金与白银按重量、纯度及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "黃金與白銀按重量、純度及每克參考價估值。每日嘗試更新,失敗保留舊價;參考價不含工費與回收折價。",
"贵金属品种": "貴金屬品種",
"黄金": "黃金",
"白银": "白銀",
"重量(克)": "重量(克)",
"纯度(0–1,例如 0.999)": "純度(0–1,例如 0.999)",
"随参考价自动记录估值": "隨參考價自動記錄估值",
"保存估价设置": "儲存估價設定",
"开启后,每日价格更新会新增估值历史;已有历史不会改写。关闭后可手动应用估价。": "開啟後,每日價格更新會新增估值歷史;已有歷史不會改寫。關閉後可手動應用估價。",
"刷新贵金属价格": "更新貴金屬價格",
"应用估价": "應用估價",
"参考价": "參考價",
"克": "克",
"手动录入贵金属价格": "手動輸入貴金屬價格",
"每克纯金属价格": "每克純金屬價格",
"报价日期": "報價日期",
"保存参考价": "儲存參考價",
"贵金属估价设置已保存": "貴金屬估價設定已儲存",
"贵金属参考价已更新": "貴金屬參考價已更新",
"估价已记入历史": "估價已記入歷史",
"贵金属价格已保存": "貴金屬價格已儲存",
"当前没有已设置重量的贵金属资产": "目前沒有已設定重量的貴金屬資產",
"正在更新贵金属参考价": "正在更新貴金屬參考價",
"贵金属价格更新失败,已有价格与估值已保留,可手动录价": "貴金屬價格更新失敗,已有價格與估值已保留,可手動輸入價格",
"请先创建至少两个启用中的账户。隐藏账户需验证密码后才能选择。": "請先建立至少兩個啟用中的賬戶。隱藏賬戶需驗證密碼後才能選擇。",
"请选择启用的账户": "請選擇啟用的賬戶"
}