feat: add account transfers and refine icons, settings and safety dialogs

This commit is contained in:
陈煜 committed 2026-10-02 10:42:19 +08:00
1 parent a4e9d56b8a
commit da98d02661
33 files changed
+1866 -347

No files matched your search

+443 -286
View File
@@ -33,6 +33,9 @@ import {
type Total,
} from './api';
import './style.css';
import { IconPicker } from './IconPicker';
import { TransferForm } from './TransferForm';
import type { Transfer } from './api';
import { IconLibrary, iconUrl } from './IconLibrary';
const labels: Record<string, string> = {
overview: '资产总览',
@@ -77,7 +80,7 @@ function Currency({ value, onChange }: { value: string; onChange: (v: string) =>
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="field">
<label className={label === '备注' ? 'field note-field' : 'field'}>
<span>{label}</span>
{children}
</label>
@@ -101,7 +104,7 @@ function Modal({
>
<section role="dialog" aria-modal="true" aria-label={title} className="modal">
<header>
<h2>{title}</h2>
<h2 className={title.includes('清空本账号数据') ? 'danger-text' : undefined}>{title}</h2>
<button className="icon" aria-label="关闭" onClick={close}>
<X size={20} />
</button>
@@ -217,10 +220,14 @@ export default function App() {
baseCurrency: string;
currentBaseCurrency: string;
} | null>(null);
const [clearStep, setClearStep] = useState(0);
const [clearStep, setClearStep] = useState(0),
[clearConfirmation, setClearConfirmation] = useState(0);
const [settingsSection, setSettingsSection] = useState('general'),
[transfers, setTransfers] = useState<Transfer[]>([]);
const activityAt = useRef(0);
function clearAccount() {
setClearStep(0);
setClearConfirmation(0);
activityAt.current = 0;
sessionGeneration.current++;
loadGeneration.current++;
@@ -228,6 +235,7 @@ export default function App() {
setPositions([]);
setOverview(null);
setRates([]);
setTransfers([]);
setFxStatus(null);
setModal(null);
setSelected(null);
@@ -249,7 +257,7 @@ export default function App() {
request = ++loadGeneration.current;
setLoading(true);
try {
const [p, o, s] = await Promise.all([
const [p, o, s, t] = await Promise.all([
api<Position[]>('/positions'),
api<Overview>('/overview'),
api<
@@ -258,6 +266,7 @@ export default function App() {
fxStatus: { state: string; message: string; attemptedAt: string | null };
}
>('/settings'),
api<Transfer[]>('/transfers'),
]);
if (session !== sessionGeneration.current || request !== loadGeneration.current) return;
if (o.revealed !== !!s.revealed) {
@@ -272,8 +281,10 @@ export default function App() {
setPositions(s.revealed ? p : p.filter((position) => !position.hidden));
setOverview(o);
setRates(s.rates);
setTransfers(t);
setFxStatus(s.fxStatus);
setUser(s);
setPage((current) => (s.hiddenMenus.includes(current) ? 'settings' : current));
} catch (e) {
if (session === sessionGeneration.current && request === loadGeneration.current) report(e);
} finally {
@@ -514,16 +525,17 @@ export default function App() {
['history', HistoryIcon],
['settings', Settings],
] as const;
const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key));
return (
<div className={'app' + (user.showSidebar === false ? ' sidebar-hidden' : '')}>
{user.showSidebar !== false && (
<div className={user.showNotes === false ? 'app notes-hidden' : 'app'}>
{
<aside className="sidebar">
<div className="brand">
<span>W</span> WorthPath
</div>
<p className="nav-caption">我的资产空间</p>
<nav>
{nav.map(([key, Icon]) => (
<nav style={{ gridTemplateColumns: `repeat(${visibleNav.length}, 1fr)` }}>
{visibleNav.map(([key, Icon]) => (
<button
key={key}
className={page === key ? 'active' : ''}
@@ -553,26 +565,10 @@ export default function App() {
</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
@@ -621,7 +617,14 @@ export default function App() {
: '保留原币金额,追踪长期变化。'}
</p>
</div>
{['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction}
<div className="actions">
{page === 'account' && !p && (
<button className="secondary" onClick={() => setModal({ kind: 'transfer' })}>
账户间转账
</button>
)}
{['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction}
</div>
</div>
{error && (
<div className="notice error" role="alert">
@@ -808,7 +811,7 @@ export default function App() {
)
: '缺少汇率'}
</p>
<p>{p.notes || '暂无备注'}</p>
<p className="notes-display">{p.notes || '暂无备注'}</p>
<small>币种和资产负债属性创建后固定。归档项目仍计入总览。</small>
</div>
<div className="actions">
@@ -936,6 +939,28 @@ export default function App() {
))}
{page === 'history' && (
<section className="panel">
<h2>账户转账</h2>
{transfers.length ? (
<div className="transfer-list">
{transfers.map((t) => (
<article key={t.id}>
<div>
<strong>
{t.source.name} → {t.target.name}
</strong>
<small>{displayTime(t.date)}</small>
</div>
<p>
{money(t.amount, t.sourceCurrency)} → {money(t.received, t.targetCurrency)}{' '}
· 手续费 {money(t.fee, t.sourceCurrency)}
</p>
{t.notes && <p className="muted notes-display">{t.notes}</p>}
</article>
))}
</div>
) : (
<p className="muted">暂无账户间转账记录。</p>
)}
<h2>全部金额变化</h2>
<p className="muted">原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。</p>
<HistoryTable
@@ -951,13 +976,32 @@ export default function App() {
)}
{page === 'settings' && (
<>
<section className="panel">
<IconLibrary />
<section className="settings-switch panel">
<label>
设置分类
<select
value={settingsSection}
onChange={(e) => setSettingsSection(e.target.value)}
>
<option value="general">个人与菜单设置</option>
<option value="icons">图标库</option>
<option value="rates">每日汇率</option>
<option value="backup">备份与恢复</option>
<option className="danger-text" value="clear">
清空本账号数据
</option>
</select>
</label>
</section>
<div className="settings-grid">
{settingsSection === 'icons' && (
<section className="panel">
<IconLibrary />
</section>
)}
<div className="settings-grid">
<section className="panel" hidden={settingsSection !== 'general'}>
<div className="panel-title">
<h2>本位币与个人设置</h2>
<h2>个人与菜单设置</h2>
<button className="text" disabled={busy} onClick={() => void logout()}>
<LogOut size={16} />
退出登录
@@ -974,33 +1018,68 @@ export default function App() {
() =>
api('/settings', 'PATCH', {
baseCurrency: f.get('baseCurrency'),
showSidebar: f.get('showSidebar') === 'true',
hiddenMenus: ['overview', 'account', 'asset', 'debt', 'history'].filter(
(key) => !f.has('menu_' + key),
),
idleMinutes: Number(f.get('idleMinutes')),
showNotes: f.has('showNotes'),
}),
'个人设置已保存',
);
}}
>
<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="无操作自动退出(分钟)">
<p className="muted">
选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。
</p>
<div className="menu-preferences">
{['overview', 'account', 'asset', 'debt', 'history'].map((key) => (
<label key={key} className="switch-row">
<span>{labels[key]}</span>
<input
type="checkbox"
role="switch"
aria-label={labels[key] + '显示'}
name={'menu_' + key}
key={key + String(user.hiddenMenus)}
defaultChecked={!(user.hiddenMenus || []).includes(key)}
/>
<span className="switch-state" aria-hidden="true">
显示
</span>
</label>
))}
</div>
<label className="switch-row">
<span>显示备注</span>
<input
name="idleMinutes"
type="number"
min="0"
max="1440"
required
defaultValue={user.idleMinutes}
key={user.idleMinutes}
type="checkbox"
role="switch"
name="showNotes"
aria-label="显示备注"
key={String(user.showNotes)}
defaultChecked={user.showNotes !== false}
/>
<span className="switch-state" aria-hidden="true">
显示
</span>
</label>
<p className="muted">
关闭后隐藏列表、详情及编辑表单中的备注,已有备注仍会保留在备份中。
</p>
<Field label="无操作自动退出(分钟)">
<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 ? '关闭自动退出' : n + ' 分钟'}
</option>
))}
</select>
</Field>
<p className="muted">
0 为关闭;最长 1440 分钟。到时退出登录并清除页面数据,浏览器标签页保留。
@@ -1021,7 +1100,7 @@ export default function App() {
</button>
</form>
</section>
<section className="panel">
<section className="panel" hidden={settingsSection !== 'rates'}>
<h2>每日汇率</h2>
{fxStatus && (
<p
@@ -1056,7 +1135,7 @@ export default function App() {
<p className="muted">公共参考汇率可能与银行成交价不同;休市日日期可能滞后。</p>
</section>
</div>
<section className="panel">
<section className="panel" hidden={settingsSection !== 'rates'}>
<h2>已保存汇率</h2>
{rates.length ? (
<div className="table-wrap">
@@ -1087,8 +1166,8 @@ export default function App() {
<Empty title="没有保存的外币汇率" body="添加外币项目后更新并保存自动汇率。" />
)}
</section>
<section className="panel">
<h2>清空本账号数据</h2>
<section className="panel" hidden={settingsSection !== 'clear'}>
<h2 className="danger-text">清空本账号数据</h2>
<p className="muted">
清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏项目);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。
</p>
@@ -1122,14 +1201,8 @@ export default function App() {
<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);
}, '本账号数据已清空');
if (new FormData(e.currentTarget).get('confirmation') === '确定清空')
setClearConfirmation(1);
}}
>
<Field label="输入“确定清空”以确认">
@@ -1145,11 +1218,12 @@ export default function App() {
取消清空 / 重新下载
</button>
)}
<hr />
</section>
<section className="panel" hidden={settingsSection !== 'backup'}>
<h2>数据备份与恢复</h2>
<p className="muted">
ZIP 内分文件保存可读
JSON,包括全部账户、资产、债务、历史、关联、币种、设置、汇率及图标,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
JSON,包括全部账户、资产、债务、历史、关联、币种、设置、汇率、图标及配对转账,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
</p>
<div className="actions">
<a
@@ -1246,245 +1320,322 @@ export default function App() {
<footer>WorthPath · 记录你的积累,理解你的财富</footer>
</div>
</main>
{clearConfirmation > 0 && (
<Modal
key={clearConfirmation}
title={
clearConfirmation === 1 ? '第一次确认:清空本账号数据' : '第二次确认:清空本账号数据'
}
close={() => {
if (!busy) setClearConfirmation(0);
}}
>
<p className="danger-text">
{clearConfirmation === 1
? '将清空本账号全部财务数据及私有图标,请确认备份已保存。'
: '这是最后一次确认。清空后只能通过备份恢复,其他用户的数据不受影响。'}
</p>
<p className="muted">登录账号、个人设置和已发布的共享图标会保留。</p>
<div className="modal-actions clear-confirm-actions">
{clearConfirmation === 1 ? (
<>
<button
autoFocus
className="secondary"
disabled={busy}
onClick={() => setClearConfirmation(0)}
>
取消清空
</button>
<button className="danger" disabled={busy} onClick={() => setClearConfirmation(2)}>
继续清空
</button>
</>
) : (
<>
<button
className="danger"
disabled={busy}
onClick={() =>
void act(async () => {
await api('/backup/clear', 'POST', { confirmation: '确定清空' });
setClearConfirmation(0);
setClearStep(0);
setSelected(null);
setBackup(null);
setPreview(null);
}, '本账号数据已清空')
}
>
{busy ? '正在清空…' : '最终确认清空'}
</button>
<button
autoFocus
className="secondary"
disabled={busy}
onClick={() => setClearConfirmation(0)}
>
取消清空
</button>
</>
)}
</div>
{error && (
<p role="alert" className="notice error">
{error}
</p>
)}
</Modal>
)}
{modal && (
<Modal
title={
modal.kind === 'reveal'
? '验证密码以显示隐藏资产'
: modal.kind === 'edit'
? '编辑项目'
: modal.kind === 'revision'
? '更新余额 / 估值'
: modal.kind === 'correction'
? '更正历史记录'
: modal.kind === 'links'
? '管理债务关联'
: '新增' + labels[modal.kind]
modal.kind === 'transfer'
? '账户间转账'
: modal.kind === 'reveal'
? '验证密码以显示隐藏资产'
: modal.kind === 'edit'
? '编辑项目'
: modal.kind === 'revision'
? '更新余额 / 估值'
: modal.kind === 'correction'
? '更正历史记录'
: modal.kind === 'links'
? '管理债务关联'
: '新增' + labels[modal.kind]
}
close={() => {
if (!busy) setModal(null);
}}
>
<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') }),
'隐藏项目已解锁 5 分钟',
);
} else if (kind === 'links') {
void act(
() =>
api('/positions/' + mp!.id + '/links', 'PUT', {
targetIds: f.getAll('targetIds'),
}),
'关联已更新',
);
} 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,
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
archived: mp!.archived,
hidden: f.get('hidden') === 'on',
}),
'项目资料已保存',
);
} else {
const category = String(v.category),
side =
kind === 'debt' ||
(kind === 'account' && ['credit_card', 'loan'].includes(category))
? 'liability'
: 'asset';
void act(
() =>
api('/positions', 'POST', {
...v,
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
kind,
side,
hidden: f.get('hidden') === 'on',
}),
'项目已添加',
);
{modal.kind === 'transfer' ? (
<TransferForm
accounts={positions.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
)}
busy={busy}
submit={(v) =>
void act(() => api('/transfers', 'POST', v), '转账已保存,双方余额已更新')
}
}}
>
{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>
{(modal.kind === 'account' || modal.p?.kind === 'account') && (
<IconLibrary
key={modal.p?.id || 'new-account'}
picker
initialId={modal.p?.iconId}
/>
)}
<Field label="名称">
/>
) : (
<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') }),
'隐藏项目已解锁 5 分钟',
);
} else if (kind === 'links') {
void act(
() =>
api('/positions/' + mp!.id + '/links', 'PUT', {
targetIds: f.getAll('targetIds'),
}),
'关联已更新',
);
} 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,
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
archived: mp!.archived,
hidden: f.get('hidden') === 'on',
}),
'项目资料已保存',
);
} else {
const category = String(v.category),
side =
kind === 'debt' ||
(kind === 'account' && ['credit_card', 'loan'].includes(category))
? 'liability'
: 'asset';
void act(
() =>
api('/positions', 'POST', {
...v,
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
kind,
side,
hidden: f.get('hidden') === 'on',
}),
'项目已添加',
);
}
}}
>
{modal.kind === 'reveal' && (
<Field label="登录密码">
<input
name="name"
defaultValue={modal.p?.name}
name="password"
type="password"
autoComplete="current-password"
required
maxLength={100}
autoFocus
maxLength={72}
/>
</Field>
<Field label="类别">
<select
name="category"
defaultValue={modal.p?.category || categories[modal.kind]?.[0][0]}
>
{categories[modal.p?.kind || modal.kind].map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
)}
{['account', 'asset', 'debt', 'edit'].includes(modal.kind) && (
<>
<label className="check-line">
<input name="hidden" type="checkbox" defaultChecked={modal.p?.hidden} />
隐藏此项目(密码验证后可查看和编辑)
</label>
{(modal.kind === 'account' || modal.p?.kind === 'account') && (
<IconPicker key={modal.p?.id || 'new-account'} initialId={modal.p?.iconId} />
)}
<Field label="名称">
<input
name="name"
defaultValue={modal.p?.name}
required
maxLength={100}
autoFocus
/>
</Field>
<Field label="类别">
<select
name="category"
defaultValue={modal.p?.category || categories[modal.kind]?.[0][0]}
>
{categories[modal.p?.kind || modal.kind].map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</select>
</Field>
{modal.kind === 'account' && (
<p className="muted">信用卡、贷款账户自动计入负债,其余账户计入资产。</p>
)}
</>
)}
{['account', 'asset', 'debt'].includes(modal.kind) && (
<Field label="原币币种">
<select name="currency" defaultValue={user.baseCurrency}>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
{modal.kind === 'account' && (
<p className="muted">信用卡、贷款账户自动计入负债,其余账户计入资产。</p>
)}
</>
)}
{['account', 'asset', 'debt'].includes(modal.kind) && (
<Field label="原币币种">
<select name="currency" defaultValue={user.baseCurrency}>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field label={'变更后的绝对金额' + (modal.p ? '(' + modal.p.currency + ')' : '')}>
<input
name="amount"
inputMode="decimal"
required
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
defaultValue={modal.h?.after || modal.p?.amount || ''}
placeholder="0.00"
/>
</Field>
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field label={'业务时间(北京时间,精确到分钟)'}>
<input
type={'datetime-local'}
name="date"
required
min={'1900-01-01T00:00'}
max={nowMinute()}
defaultValue={modal.h?.time || nowMinute()}
/>
</Field>
)}
{modal.kind === 'revision' && (
<>
<Field label="变更原因">
<select
name="reason"
defaultValue={modal.p?.kind === 'asset' ? 'valuation' : 'balance'}
>
<option value="balance">余额更新</option>
<option value="valuation">估值更新</option>
{modal.p?.side === 'liability' && (
<option value="repayment">还款(填写还款后欠款)</option>
)}
</select>
</Field>
<p className="muted">
填写更新后的余额或剩余欠款,每次保存会新增历史。已有错误记录请在历史中更正。
</p>
</>
)}
{modal.kind === 'correction' && (
<p className="notice warning">
这将修改选中的历史金额并重算后续变化,请确认日期和余额。
</p>
)}
{modal.kind === 'links' ? (
<div className="link-options">
{positions.filter((p) => p.kind !== 'debt').length ? (
positions
.filter((p) => p.kind !== 'debt')
.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} · {labels[p.kind]}
</label>
))
) : (
<p>请先创建账户或独立资产。</p>
)}
</div>
) : (
modal.kind !== 'reveal' && (
<Field label="备注">
<textarea
name="notes"
maxLength={2000}
rows={3}
defaultValue={modal.h?.notes || modal.p?.notes || ''}
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field label={'变更后的绝对金额' + (modal.p ? '(' + modal.p.currency + ')' : '')}>
<input
name="amount"
inputMode="decimal"
required
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
defaultValue={modal.h?.after || modal.p?.amount || ''}
placeholder="0.00"
/>
</Field>
)
)}
{error && (
<div role="alert" className="notice error">
{error}
)}
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
<Field label={'业务时间(北京时间,精确到分钟)'}>
<input
type={'datetime-local'}
name="date"
required
min={'1900-01-01T00:00'}
max={nowMinute()}
defaultValue={modal.h?.time || nowMinute()}
/>
</Field>
)}
{modal.kind === 'revision' && (
<>
<Field label="变更原因">
<select
name="reason"
defaultValue={modal.p?.kind === 'asset' ? 'valuation' : 'balance'}
>
<option value="balance">余额更新</option>
<option value="valuation">估值更新</option>
{modal.p?.side === 'liability' && (
<option value="repayment">还款(填写还款后欠款)</option>
)}
</select>
</Field>
<p className="muted">
填写更新后的余额或剩余欠款,每次保存会新增历史。已有错误记录请在历史中更正。
</p>
</>
)}
{modal.kind === 'correction' && (
<p className="notice warning">
这将修改选中的历史金额并重算后续变化,请确认日期和余额。
</p>
)}
{modal.kind === 'links' ? (
<div className="link-options">
{positions.filter((p) => p.kind !== 'debt').length ? (
positions
.filter((p) => p.kind !== 'debt')
.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} · {labels[p.kind]}
</label>
))
) : (
<p>请先创建账户或独立资产。</p>
)}
</div>
) : (
modal.kind !== 'reveal' && (
<Field label="备注">
<textarea
name="notes"
maxLength={2000}
rows={3}
defaultValue={modal.h?.notes || modal.p?.notes || ''}
/>
</Field>
)
)}
{error && (
<div role="alert" className="notice error">
{error}
</div>
)}
<div className="modal-actions">
<button
className="secondary"
type="button"
disabled={busy}
onClick={() => setModal(null)}
>
取消
</button>
<button className="primary" disabled={busy}>
{busy ? '正在保存…' : modal.kind === 'correction' ? '确认更正' : '保存'}
</button>
</div>
)}
<div className="modal-actions">
<button
className="secondary"
type="button"
disabled={busy}
onClick={() => setModal(null)}
>
取消
</button>
<button className="primary" disabled={busy}>
{busy ? '正在保存…' : modal.kind === 'correction' ? '确认更正' : '保存'}
</button>
</div>
</form>
</form>
)}
</Modal>
)}
</div>
@@ -1508,7 +1659,7 @@ function HistoryTable({
<th>变更前</th>
<th>变更后</th>
<th>变化额</th>
<th>备注</th>
<th className="notes-cell">备注</th>
{correct && <th>操作</th>}
</tr>
</thead>
@@ -1533,6 +1684,8 @@ function HistoryTable({
valuation: '估值更新',
repayment: '还款',
correction: '更正',
transfer_out: '转账转出',
transfer_in: '转账到账',
} as Record<string, string>
)[h.reason]
}
@@ -1544,7 +1697,11 @@ function HistoryTable({
<td className="notes-cell">{h.notes || '—'}</td>
{correct && (
<td>
<button className="text" onClick={() => correct(h)}>
<button
className="text"
disabled={h.reason.startsWith('transfer_')}
onClick={() => correct(h)}
>
更正
</button>
</td>
+7 -1
View File
@@ -5,9 +5,11 @@ export const iconUrl = (id: string) => '/api/icons/' + encodeURIComponent(id) +
export function IconLibrary({
initialId,
picker = false,
onSelect,
}: {
initialId?: string | null;
picker?: boolean;
onSelect?: (id: string) => void;
}) {
const [selected, select] = useState(initialId || ''),
[q, search] = useState(''),
@@ -113,7 +115,6 @@ export function IconLibrary({
</p>
{picker && (
<div className="icon-selection">
<input type="hidden" name="iconId" value={selected} />
{selected ? (
<>
<img src={iconUrl(selected)} alt="当前账户图标" />
@@ -189,6 +190,11 @@ export function IconLibrary({
</div>
</>
)}
{onSelect && (
<button type="button" className="primary" onClick={() => onSelect(selected)}>
应用所选图标
</button>
)}
<details className="icon-upload">
<summary>上传我的图标 / 导入共享图标</summary>
<label>
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { IconLibrary, iconUrl } from './IconLibrary';
export function IconPicker({ initialId }: { initialId?: string | null }) {
const [id, setId] = useState(initialId || ''),
[open, setOpen] = useState(false);
const dialog = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const prior = document.activeElement as HTMLElement;
dialog.current?.querySelector<HTMLInputElement>('input[type=search]')?.focus();
function key(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopImmediatePropagation();
setOpen(false);
}
if (e.key === 'Tab') {
const controls = [
...(dialog.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input, select, summary',
) || []),
].filter((v) => v.offsetParent !== null);
const index = controls.indexOf(document.activeElement as HTMLElement);
if (e.shiftKey && index <= 0) {
e.preventDefault();
controls.at(-1)?.focus();
} else if (!e.shiftKey && index === controls.length - 1) {
e.preventDefault();
controls[0]?.focus();
}
}
}
document.addEventListener('keydown', key, true);
return () => {
document.removeEventListener('keydown', key, true);
prior?.focus();
};
}, [open]);
return (
<div className="account-icon-field">
<input type="hidden" name="iconId" value={id} />
{id && <img src={iconUrl(id)} alt="已选账户图标" />}
<button type="button" className="secondary" onClick={() => setOpen(true)}>
{id ? '更换图标' : '选择图标'}
</button>
{id && (
<button type="button" className="text" onClick={() => setId('')}>
移除
</button>
)}
{open &&
createPortal(
<div
className="icon-picker-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) setOpen(false);
}}
>
<div
ref={dialog}
className="icon-picker-dialog"
role="dialog"
aria-modal="true"
aria-label="选择账户图标"
>
<div className="panel-title">
<h2>账户图标</h2>
<button type="button" className="secondary" onClick={() => setOpen(false)}>
关闭图标选择
</button>
</div>
<IconLibrary
picker
initialId={id}
onSelect={(value) => {
setId(value);
setOpen(false);
}}
/>
</div>
</div>,
document.body,
)}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useState, type FormEvent } from 'react';
import { nowMinute, money, requestUuid, type Position } from './api';
export function TransferForm({
accounts,
busy,
submit,
}: {
accounts: Position[];
busy: boolean;
submit: (data: unknown) => void;
}) {
const [requestId] = useState(() => requestUuid());
const [sourceId, setSource] = useState(accounts[0]?.id || ''),
[targetId, setTarget] = useState(accounts[1]?.id || ''),
[amount, setAmount] = useState('');
const source = accounts.find((p) => p.id === sourceId),
target = accounts.find((p) => p.id === targetId);
const same = source?.currency === target?.currency;
function save(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
submit(Object.fromEntries(new FormData(e.currentTarget)));
}
if (accounts.length < 2)
return <p>请先创建至少两个启用中的资产账户。隐藏账户需验证密码后才能选择。</p>;
return (
<form onSubmit={save} className="transfer-form">
<input type="hidden" name="requestId" value={requestId} />
<p className="muted">
双方余额同时更新。手续费从转出账户额外扣除;转账时间不能早于双方最新余额记录。
</p>
<label>
转出账户
<select name="sourceId" value={sourceId} onChange={(e) => setSource(e.target.value)}>
{accounts.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · {money(p.amount, p.currency)}
</option>
))}
</select>
</label>
<label>
转入账户
<select name="targetId" value={targetId} onChange={(e) => setTarget(e.target.value)}>
{accounts.map((p) => (
<option key={p.id} value={p.id} disabled={p.id === sourceId}>
{p.name} · {p.currency}
</option>
))}
</select>
</label>
<div className="form-columns">
<label>
转出金额({source?.currency})
<input
name="amount"
required
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</label>
<label>
实际到账({target?.currency})
{same ? (
<input name="received" required readOnly value={amount} />
) : (
<input
key={targetId}
name="received"
required
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
/>
)}
</label>
</div>
{!same && (
<p className="notice warning">跨币种请填写银行实际到账金额;本次转账不修改公共参考汇率。</p>
)}
<label>
额外手续费({source?.currency})
<input
name="fee"
required
defaultValue="0"
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
/>
</label>
<label>
转账时间(北京时间)
<input
name="date"
type="datetime-local"
required
defaultValue={nowMinute()}
max={nowMinute()}
/>
</label>
<label>
备注
<textarea className="note-input" name="notes" maxLength={2000} rows={2} />
</label>
<button className="primary" disabled={busy || sourceId === targetId}>
{busy ? '正在转账…' : '确认转账'}
</button>
</form>
);
}
+24 -1
View File
@@ -20,7 +20,8 @@ export async function api<T>(path: string, method = 'GET', data?: unknown): Prom
export type User = {
username: string;
baseCurrency: string;
showSidebar: boolean;
hiddenMenus: string[];
showNotes: boolean;
idleMinutes: number;
revealed?: boolean;
revealUntil?: string | null;
@@ -103,3 +104,25 @@ export function money(v: string, currency: string) {
const [whole, frac] = v.split('.');
return `${currency} ${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${frac !== undefined ? '.' + frac : ''}`;
}
export type Transfer = {
id: string;
source: { name: string };
target: { name: string };
sourceCurrency: string;
targetCurrency: string;
amount: string;
received: string;
fee: string;
date: string;
notes: string;
};
// getRandomValues also works when a development tunnel uses plain HTTP.
export function requestUuid() {
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 15) | 64;
bytes[8] = (bytes[8] & 63) | 128;
const hex = Array.from(bytes, (v) => v.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
+181
View File
@@ -6,6 +6,111 @@
font-weight: 400;
line-height: 1.6;
}
/* Keep native hidden panels hidden even when component classes use grid/flex. */
[hidden] {
display: none !important;
}
.settings-grid {
grid-template-columns: 1fr !important;
}
.settings-switch {
max-width: 560px;
margin-bottom: 20px;
}
.settings-switch label,
.transfer-form label {
display: grid;
gap: 8px;
}
.menu-preferences,
.form-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 18px;
}
.transfer-form {
display: grid;
gap: 16px;
}
.account-icon-field {
display: flex;
align-items: center;
gap: 12px;
margin: 14px 0 22px;
}
.account-icon-field img {
width: 42px;
height: 42px;
object-fit: contain;
}
.icon-picker-backdrop {
position: fixed;
inset: 0;
z-index: 100;
background: #19392d70;
backdrop-filter: blur(5px);
display: grid;
place-items: center;
padding: 20px;
}
.icon-picker-dialog {
width: min(800px, 100%);
max-height: 90vh;
overflow-y: auto;
padding: 28px;
background: #f7faf8;
border-radius: 20px;
box-shadow: 0 20px 60px #14342940;
}
.icon-option {
background: transparent !important;
border: 0 !important;
border-radius: 12px;
transition: background 0.15s;
}
button.icon-option:hover {
background: #e7f1eb !important;
}
.icon-option.selected {
background: #dceee4 !important;
box-shadow: inset 0 0 0 2px #388569;
}
.position-icon {
background: transparent !important;
}
.transfer-list {
display: grid;
gap: 12px;
margin: 20px 0 30px;
}
.transfer-list article {
border: 1px solid #dce5df;
border-radius: 12px;
padding: 16px;
}
.transfer-list article > div {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.transfer-list p {
margin: 8px 0 0;
}
@media (max-width: 600px) {
.menu-preferences,
.form-columns {
grid-template-columns: 1fr;
}
.icon-picker-backdrop {
padding: 10px;
}
.icon-picker-dialog {
padding: 18px;
max-height: 94vh;
border-radius: 14px;
}
}
* {
box-sizing: border-box;
}
@@ -996,3 +1101,79 @@ footer {
vertical-align: middle;
margin-right: 12px;
}
.danger-text {
color: #b42318 !important;
}
.clear-confirm-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.switch-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
border-bottom: 1px solid var(--line);
}
.switch-row > span:first-child {
flex: 1;
}
.switch-row input[role='switch'] {
appearance: none;
width: 44px;
height: 26px;
min-height: 26px;
padding: 0;
border: 0;
border-radius: 20px;
background: #c8d0cd;
cursor: pointer;
position: relative;
margin: 0;
transition: background 0.2s;
flex-shrink: 0;
}
.switch-row input[role='switch']::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
left: 3px;
top: 3px;
background: white;
box-shadow: 0 1px 3px #0002;
transition: transform 0.2s;
}
.switch-row input[role='switch']:checked {
background: #167b5b;
}
.switch-row input[role='switch']:checked::after {
transform: translateX(18px);
}
.switch-row input[role='switch']:focus-visible {
outline: 3px solid #a4d8c1;
outline-offset: 3px;
}
.switch-state {
min-width: 32px;
font-size: 13px;
color: #167b5b;
}
.switch-row input:not(:checked) + .switch-state {
font-size: 0;
color: #68766f;
}
.switch-row input:not(:checked) + .switch-state::after {
content: '隐藏';
font-size: 13px;
}
.notes-hidden .note-field,
.notes-hidden .notes-display,
.notes-hidden .notes-cell,
.notes-hidden label:has(.note-input) {
display: none !important;
}