feat: streamline portfolio UI and support atomic record edits

This commit is contained in:
陈煜 committed 2026-10-02 18:46:25 +08:00
1 parent 932a864ed6
commit 9adf7fdbc5
26 files changed
+988 -112

No files matched your search

+6 -5
View File
@@ -51,11 +51,12 @@ export function AccountPicker({
onChange={(e) => setQuery(e.target.value)}
/>
<div className="actions">
<button type="button" aria-pressed={!grouped} onClick={() => setGrouped(false)}>
{tr('全部账户')}
</button>
<button type="button" aria-pressed={grouped} onClick={() => setGrouped(true)}>
{tr('按分组显示')}
<button
className="secondary picker-view-toggle"
type="button"
onClick={() => setGrouped(!grouped)}
>
{tr(grouped ? '显示全部账户' : '按分组显示')} <span aria-hidden="true">⇄</span>
</button>
</div>
{groups.map((group) => (
+205 -58
View File
@@ -51,6 +51,7 @@ const labels: Record<string, string> = {
debt: '债务',
history: '变化记录',
calendar: '收支日历',
schedules: '定时计划',
settings: '设置与备份',
};
const categories: Record<string, [string, string][]> = {
@@ -221,6 +222,7 @@ export default function App() {
kind: string;
p?: Position;
h?: History;
transfer?: Transfer;
operation?: DebtOperation;
} | null>(null),
[busy, setBusy] = useState(false),
@@ -340,6 +342,9 @@ export default function App() {
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) {
const result = await api<{
@@ -741,6 +746,29 @@ export default function App() {
</section>
</div>
);
async function correctHistory(h: History) {
if (h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')) {
setSelected(null);
setPage('history');
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) {
if (!window.confirm(tr('删除这条余额记录并重算后续余额?'))) return;
void act(
() => api('/positions/' + h.positionId + '/revisions/' + h.id, 'DELETE'),
tr('记录已删除,余额和日历已更新'),
);
}
const nav = [
['overview', LayoutDashboard],
['account', Wallet],
@@ -748,6 +776,7 @@ export default function App() {
['debt', HandCoins],
['history', HistoryIcon],
['calendar', CalendarDays],
['schedules', HistoryIcon],
['settings', Settings],
] as const;
const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key));
@@ -882,42 +911,6 @@ export default function App() {
{tr('正在刷新数据…')}
</p>
)}
{page === 'overview' && (
<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>
<select 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>
</section>
)}
{page === 'overview' && overview && (
<>
{!overview.complete && (
@@ -967,6 +960,41 @@ export default function App() {
<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>
<Chart rows={overview.trend} base={user.baseCurrency} />
<p className="muted">
{tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')}
@@ -1014,7 +1042,7 @@ export default function App() {
</button>
</div>
<HistoryTable
rows={overview.recent}
rows={overview.recent.slice(0, 6)}
open={(id) => {
setSelected(id);
setPage(overview.recent.find((h) => h.positionId === id)?.kind || 'account');
@@ -1034,8 +1062,8 @@ export default function App() {
{tr('· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。')}
</div>
{overview.trend.length > 1 && (
<section className="panel">
<h2>{tr('变化归因')}</h2>
<details className="panel attribution">
<summary>{tr('变化归因')}</summary>
<div className="table-wrap">
<table>
<thead>
@@ -1068,7 +1096,7 @@ export default function App() {
</tbody>
</table>
</div>
</section>
</details>
)}
</>
)}
@@ -1211,7 +1239,8 @@ export default function App() {
</p>
<HistoryTable
rows={historyRows}
correct={p.archived ? undefined : (h) => setModal({ kind: 'correction', p, h })}
correct={p.archived ? undefined : (h) => void correctHistory(h)}
remove={p.archived ? undefined : deleteHistory}
/>
{historyCursor && (
<button disabled={loading} onClick={() => void more('history')}>
@@ -1252,12 +1281,6 @@ export default function App() {
</select>
</div>
{quickMode && <p className="muted">{tr('点击账户卡片,直接填写当前余额。')}</p>}
<SchedulePanel
accounts={positions.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
)}
refresh={() => load(false, true)}
/>
</>
)}
{positions.some((p) => p.kind === page) ? (
@@ -1392,6 +1415,28 @@ export default function App() {
{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={() => {
if (window.confirm(tr('删除这笔资金往来并重算双方余额?')))
void act(
() => api('/transfers/' + t.id, 'DELETE'),
tr('记录已删除,余额和日历已更新'),
);
}}
>
{tr('删除')}
</button>
</div>
</article>
))}
</div>
@@ -1409,6 +1454,8 @@ export default function App() {
</p>
<HistoryTable
rows={historyRows}
correct={(h) => void correctHistory(h)}
remove={deleteHistory}
open={(id) => {
setSelected(id);
setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account');
@@ -1422,6 +1469,16 @@ export default function App() {
</section>
</>
)}
{page === 'schedules' && (
<section className="panel">
<SchedulePanel
accounts={positions.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
)}
refresh={() => load(false, true)}
/>
</section>
)}
{page === 'calendar' && (
<Calendar revealed={!!user.revealed} refreshVersion={calendarRefresh} />
)}
@@ -1471,9 +1528,15 @@ export default function App() {
() =>
api('/settings', 'PATCH', {
baseCurrency: f.get('baseCurrency'),
hiddenMenus: ['overview', 'account', 'asset', 'debt', 'history'].filter(
(key) => !f.has('menu_' + key),
),
hiddenMenus: [
'overview',
'account',
'asset',
'debt',
'history',
'calendar',
'schedules',
].filter((key) => !f.has('menu_' + key)),
idleMinutes: Number(f.get('idleMinutes')),
showNotes: f.has('showNotes'),
}),
@@ -1485,7 +1548,15 @@ export default function App() {
{tr('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')}
</p>
<div className="menu-preferences">
{['overview', 'account', 'asset', 'debt', 'history'].map((key) => (
{[
'overview',
'account',
'asset',
'debt',
'history',
'calendar',
'schedules',
].map((key) => (
<label key={key} className="switch-row">
<span>{tr(labels[key])}</span>
<input
@@ -1865,7 +1936,76 @@ export default function App() {
)}
</Modal>
)}
{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>
{error && (
<p role="alert" className="notice error">
{error}
</p>
)}
<button className="primary" disabled={busy}>
{tr(busy ? '正在保存…' : '保存')}
</button>
</form>
</Modal>
)}
{modal && modal.kind !== 'transfer-edit' && (
<Modal
title={
modal.kind === 'debt-payment'
@@ -2215,10 +2355,12 @@ function HistoryTable({
rows,
open,
correct,
remove,
}: {
rows: History[];
open?: (id: string) => void;
correct?: (h: History) => void;
remove?: (h: History) => void;
}) {
return rows.length ? (
<div className="table-wrap">
@@ -2307,13 +2449,18 @@ function HistoryTable({
<td className="notes-cell">{h.notes || '—'}</td>
{correct && (
<td>
<button
className="text"
disabled={h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')}
onClick={() => correct(h)}
>
{tr('更正')}
<button className="text" onClick={() => correct(h)}>
{tr(
h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')
? '查看资金往来'
: '更正',
)}
</button>
{remove && !h.reason.startsWith('transfer_') && !h.reason.startsWith('loan_') && (
<button className="text danger-text" onClick={() => remove(h)}>
{tr('删除')}
</button>
)}
</td>
)}
</tr>
+5 -5
View File
@@ -61,12 +61,12 @@ export function SchedulePanel({
}
}
return (
<details className="schedule-panel">
<summary>
<section className="schedule-panel">
<h2>
{tr('定时计划')} ({rows.length})
</summary>
</h2>
<p className="muted">
{tr('打开账户页时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。')}
{tr('打开账户页或点击执行时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。')}
</p>
<div className="actions">
<button type="button" disabled={busy || !accounts.length} onClick={() => setOpen(!open)}>
@@ -227,6 +227,6 @@ export function SchedulePanel({
</p>
)}
{status && <p role="status">{status}</p>}
</details>
</section>
);
}
+19 -1
View File
@@ -426,5 +426,23 @@
"六": "Sat",
"日": "Sun",
"已完成": "Completed",
"一次性计划已完成,请新建计划": "This one-time plan is complete. Create a new plan."
"一次性计划已完成,请新建计划": "This one-time plan is complete. Create a new plan.",
"显示全部账户": "Show all accounts",
"删除这条余额记录并重算后续余额?": "Delete this balance record and recalculate later balances?",
"删除这笔资金往来并重算双方余额?": "Delete this movement and recalculate both balances?",
"记录已删除,余额和日历已更新": "Record deleted. Balances and calendar updated.",
"记录已修改,余额和日历已更新": "Record updated. Balances and calendar updated.",
"修改资金往来": "Edit movement",
"查看资金往来": "View movements",
"打开账户页或点击执行时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。": "Due plans run when opening Accounts or clicking Run. Each batch processes up to 20 plans, including overdue occurrences.",
"配对记录不完整,无法重算": "Paired records are incomplete. Recalculation failed.",
"资金操作之前必须保留一条余额记录": "Keep a balance record before the first movement.",
"修改后债务或资产金额不能为负数": "The edited debt or asset value cannot be negative.",
"重算后的金额超出支持范围": "The recalculated amount exceeds the supported range.",
"请在资金往来中修改完整配对记录": "Edit the complete pair in Movements.",
"请在资金往来中删除完整配对记录": "Delete the complete pair in Movements.",
"资金往来记录不存在": "Movement not found.",
"修改记录不能更换账户或操作类型,请删除后重新创建": "To change accounts or the operation type, delete the movement and create it again.",
"修改": "Edit",
"统计粒度": "Time interval"
}
+19 -1
View File
@@ -426,5 +426,23 @@
"六": "六",
"日": "日",
"已完成": "已完成",
"一次性计划已完成,请新建计划": "一次性計劃已完成,請新建計劃"
"一次性计划已完成,请新建计划": "一次性計劃已完成,請新建計劃",
"显示全部账户": "顯示全部賬戶",
"删除这条余额记录并重算后续余额?": "刪除這條餘額記錄並重算後續餘額?",
"删除这笔资金往来并重算双方余额?": "刪除這筆資金往來並重算雙方餘額?",
"记录已删除,余额和日历已更新": "記錄已刪除,餘額和日曆已更新",
"记录已修改,余额和日历已更新": "記錄已修改,餘額和日曆已更新",
"修改资金往来": "修改資金往來",
"查看资金往来": "查看資金往來",
"打开账户页或点击执行时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。": "打開賬戶頁或點擊執行時處理到期計劃。每次最多執行20項,離線期間的計劃會逐次補記。",
"配对记录不完整,无法重算": "配對記錄不完整,無法重算",
"资金操作之前必须保留一条余额记录": "資金操作之前必須保留一條餘額記錄",
"修改后债务或资产金额不能为负数": "修改後債務或資產金額不能為負數",
"重算后的金额超出支持范围": "重算後的金額超出支援範圍",
"请在资金往来中修改完整配对记录": "請在資金往來中修改完整配對記錄",
"请在资金往来中删除完整配对记录": "請在資金往來中刪除完整配對記錄",
"资金往来记录不存在": "資金往來記錄不存在",
"修改记录不能更换账户或操作类型,请删除后重新创建": "修改記錄不能更換賬戶或操作類型,請刪除後重新建立",
"修改": "修改",
"统计粒度": "統計粒度"
}
+152
View File
@@ -1335,3 +1335,155 @@ footer {
.calendar-panel h2 {
white-space: nowrap;
}
/* Compact cards and a calmer overview hierarchy. */
body {
-webkit-user-select: none;
user-select: none;
}
input,
textarea,
[contenteditable='true'] {
-webkit-user-select: text;
user-select: text;
}
.position-grid {
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 12px;
}
.position-card {
padding: 14px 16px;
position: relative;
}
.position-card > div {
float: left;
margin: 0 12px 8px 0;
}
.position-card > div > svg {
display: none;
}
.position-icon {
padding: 7px;
}
.position-card h3 {
font-size: 14px;
margin: 0 0 2px;
overflow-wrap: anywhere;
}
.position-card p {
margin: 2px 0 8px;
font-size: 11px;
}
.position-card strong {
clear: both;
font-size: 19px;
line-height: 1.3;
}
.position-card small {
margin-top: 3px;
font-size: 11px;
}
.position-card .badge {
font-size: 10px;
padding: 1px 6px;
}
.picker-view-toggle {
display: inline-flex;
align-items: center;
gap: 18px;
border-radius: 8px;
}
.picker-dialog .actions {
margin: 12px 0;
}
.metric {
padding: 20px;
}
.metric.net {
background: #e9f3ed;
border-color: #a9cbb9;
}
.metric strong {
font-size: clamp(22px, 2.5vw, 32px);
}
.dashboard-grid {
grid-template-columns: minmax(0, 2fr) minmax(230px, 1fr);
gap: 16px;
}
.dashboard-grid .panel {
padding: 20px;
}
.dashboard-grid .privacy {
margin-bottom: 0;
}
.attribution summary {
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
.attribution[open] summary {
margin-bottom: 16px;
}
.record-actions {
gap: 16px;
}
@media (max-width: 760px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.position-grid {
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
gap: 8px;
}
.position-card {
padding: 12px;
}
.position-card strong {
font-size: 17px;
}
}
.metric.net {
order: -1;
}
.metrics {
grid-template-columns: 1.2fr 1fr 1fr;
}
@media (max-width: 600px) {
.metrics {
grid-template-columns: 1fr;
}
}
.metric.net {
color: #234c3b;
}
.metric.net > div,
.metric.net small {
color: #557867;
}
.overview-filters {
align-items: end;
gap: 8px;
margin-top: 16px;
}
.overview-filters label {
flex: 1;
min-width: 130px;
font-size: 11px;
}
.overview-filters input {
padding: 8px;
font-size: 12px;
}
.overview-filters select {
width: auto;
flex: 1;
min-width: 130px;
padding: 8px;
font-size: 12px;
}
.overview-filters + .muted {
font-size: 10px;
line-height: 1.5;
}