feat: adopt Apple UI and four-tab mobile navigation

This commit is contained in:
陈煜 committed 2026-10-07 18:13:59 +08:00
1 parent daf9b458af
commit fb8cf1c938
20 files changed
+1662 -378

No files matched your search

-2
View File
@@ -10,7 +10,6 @@ import {
credentialChange,
positionInput,
repaymentMarkInput,
bookedMarkInput,
inclusionInput,
positionMeta,
revisionInput,
@@ -46,7 +45,6 @@ export function setupOpenApi(app: INestApplication) {
'POST /api/positions': positionInput,
'PATCH /api/positions/{id}': positionMeta,
'PATCH /api/positions/{id}/repayment-mark': repaymentMarkInput,
'PATCH /api/positions/{id}/booked-mark': bookedMarkInput,
'PATCH /api/positions/{id}/inclusion': inclusionInput,
'POST /api/positions/{id}/revisions': revisionInput,
'PUT /api/positions/{id}/revisions/{revisionId}': revisionInput,
+20 -22
View File
@@ -19,7 +19,6 @@ import { UserRequest } from './auth';
import {
positionInput,
repaymentMarkInput,
bookedMarkInput,
inclusionInput,
positionMeta,
revisionInput,
@@ -28,7 +27,14 @@ import {
pairedReasons,
} from './validation';
import { totals, trend } from './calculation';
import { currentPositions, currentRates, historyPage, trendData, trendInput } from './queries';
import {
currentPositions,
currentRates,
historyPage,
trendData,
trendInput,
accountActivityIds,
} from './queries';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { IconsService } from './icons';
@@ -54,6 +60,11 @@ export class PortfolioBusinessService {
const kind = z.enum(['account', 'asset', 'debt']).optional().parse(inputKind);
return this.db.$transaction(async (tx) => {
const rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind);
const day = today();
const activity =
!kind || kind === 'account'
? await accountActivityIds(tx, r.userId, r.revealed, day)
: new Set<string>();
const user = await tx.user.findUniqueOrThrow({
where: { id: r.userId },
select: { baseCurrency: true, includeIndependentAssets: true },
@@ -70,6 +81,7 @@ export class PortfolioBusinessService {
);
return rows.map(({ revisions, userId, ...p }) => ({
...p,
activityDate: activity.has(p.id) ? day : null,
...metalPerformance(p, revisions[0]?.amount.toString() || '0', !!revisions.length),
amount: revisions[0]?.amount.toString() || '0',
converted: amounts.get(p.id)!.converted,
@@ -162,8 +174,8 @@ export class PortfolioBusinessService {
return { ok: true };
}
async quickMeta(r: UserRequest, id: string, raw: unknown, operation: 'booked' | 'included') {
const v = operation === 'booked' ? bookedMarkInput.parse(raw) : inclusionInput.parse(raw);
async quickMeta(r: UserRequest, id: string, raw: unknown) {
const v = inclusionInput.parse(raw);
return this.db.serial(async (tx) => {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
@@ -172,23 +184,16 @@ export class PortfolioBusinessService {
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
if (!p) throw new NotFoundException('项目不存在');
if (operation === 'booked' && (p.kind !== 'account' || p.archived))
throw new BadRequestException('仅启用账户支持当天记账标记');
if (operation === 'included' && p.kind !== 'asset')
throw new BadRequestException('仅独立资产支持此快速统计开关');
if (p.kind !== 'asset') throw new BadRequestException('仅独立资产支持此快速统计开关');
if (v.expectedUpdatedAt && p.updatedAt.toISOString() !== v.expectedUpdatedAt)
throw new ConflictException('项目已变化,请刷新后重试');
if (
('expectedDate' in v && p.lastBookedDate !== v.expectedDate) ||
('expectedIncluded' in v && p.included !== v.expectedIncluded)
)
if (p.included !== v.expectedIncluded)
throw new ConflictException('标记已变化,请刷新后重试');
const updated = await tx.position.update({
where: { id },
data: 'date' in v ? { lastBookedDate: v.date } : { included: v.included },
data: { included: v.included },
});
return {
lastBookedDate: updated.lastBookedDate,
included: updated.included,
updatedAt: updated.updatedAt,
};
@@ -473,19 +478,12 @@ export class PortfolioController {
) {
return this.service.edit(r, id, b);
}
@Patch('positions/:id/booked-mark') async bookedMark(
@Req() r: UserRequest,
@Param('id') id: string,
@Body() b: unknown,
) {
return this.service.quickMeta(r, id, b, 'booked');
}
@Patch('positions/:id/inclusion') async inclusion(
@Req() r: UserRequest,
@Param('id') id: string,
@Body() b: unknown,
) {
return this.service.quickMeta(r, id, b, 'included');
return this.service.quickMeta(r, id, b);
}
@Patch('positions/:id/repayment-mark') async repaymentMark(
@Req() r: UserRequest,
+19
View File
@@ -31,6 +31,25 @@ export const encodeCursor = (time: Date, key: string | number) =>
Buffer.from(JSON.stringify({ time: time.toISOString(), key: String(key) })).toString('base64url');
export const endOfDay = (d: string) => new Date(+toBusinessDate(d) + 86400000);
// Use record business time, not creation time or the obsolete manual marker.
export async function accountActivityIds(
db: Reader,
userId: string,
revealed: boolean,
day: string,
) {
const rows = await db.position.findMany({
where: {
userId,
kind: 'account',
...(revealed ? {} : { hidden: false }),
revisions: { some: { effectiveDate: { gte: toBusinessDate(day), lt: endOfDay(day) } } },
},
select: { id: true },
});
return new Set(rows.map((p) => p.id));
}
export async function transferPageIds(
db: Reader,
userId: string,
-7
View File
@@ -235,13 +235,6 @@ export const repaymentMarkInput = z
})
.strict();
export const bookedMarkInput = z
.object({
date: date.nullable(),
expectedDate: date.nullable(),
expectedUpdatedAt: z.iso.datetime().optional(),
})
.strict();
export const inclusionInput = z
.object({
included: z.boolean(),
+61 -33
View File
@@ -103,45 +103,21 @@ test('quick entries undo paired repayment and balance, persist monthly marks, pr
).status,
400,
);
const bookedPath = '/positions/' + cash + '/booked-mark',
day = today();
const booked = await call(a, bookedPath, 'PATCH', { date: day, expectedDate: null });
assert.equal(booked.status, 200);
assert.equal((await call(a, '/positions/' + cash)).data.lastBookedDate, day);
assert.equal(
(await call(a, bookedPath, 'PATCH', { date: null, expectedDate: null })).status,
409,
);
assert.equal(
(await call(b, bookedPath, 'PATCH', { date: null, expectedDate: day })).status,
404,
);
assert.equal(
(await call(a, bookedPath, 'PATCH', { date: '2099-01-01', expectedDate: day })).status,
400,
);
const day = today();
// The obsolete marker remains in ZIP v9, but cannot be written through the retired route.
await db.position.update({ where: { id: cash }, data: { lastBookedDate: day } });
assert.equal(
(
await call(a, bookedPath, 'PATCH', {
date: null,
expectedDate: day,
expectedUpdatedAt: booked.data.updatedAt,
})
).status,
200,
);
assert.equal(
(await call(a, bookedPath, 'PATCH', { date: day, expectedDate: null })).status,
200,
);
assert.equal(
(
await call(a, '/positions/' + coin.id + '/booked-mark', 'PATCH', {
await call(a, '/positions/' + cash + '/booked-mark', 'PATCH', {
date: day,
expectedDate: null,
})
).status,
400,
404,
);
assert.equal(
(await call(a, '/positions?kind=account')).data.find((p: any) => p.id === cash).activityDate,
null,
);
const revision = await call(a, '/positions/' + cash + '/revisions', 'POST', {
amount: '1050.87654321',
@@ -301,6 +277,58 @@ test('quick entries undo paired repayment and balance, persist monthly marks, pr
).status,
409,
);
// Derive daily status from surviving effective-time records; cover edits, deletes and both transfer sides.
const dailySource = await create('daily source', 'account', 'asset', 'bank', '100');
const dailyTarget = await create('daily target', 'account', 'asset', 'bank', '0');
const activity = async (id: string) =>
(await call(a, '/positions?kind=account')).data.find((p: any) => p.id === id).activityDate;
const daily = await call(a, '/positions/' + dailySource + '/revisions', 'POST', {
amount: '110',
date: day + 'T00:00',
});
assert.equal(daily.status, 201);
assert.equal(await activity(dailySource), day);
const yesterday = new Date(+new Date(day) - 86400000).toISOString().slice(0, 10);
assert.equal(
(
await call(a, '/positions/' + dailySource + '/revisions/' + daily.data.id, 'PUT', {
amount: '110',
date: yesterday + 'T23:59',
})
).status,
200,
);
assert.equal(await activity(dailySource), null);
assert.equal(
(
await call(a, '/positions/' + dailySource + '/revisions/' + daily.data.id, 'PUT', {
amount: '110',
date: day + 'T00:00',
})
).status,
200,
);
assert.equal(await activity(dailySource), day);
assert.equal(
(await call(a, '/positions/' + dailySource + '/revisions/' + daily.data.id, 'DELETE')).status,
200,
);
assert.equal(await activity(dailySource), null);
const dailyTransfer = await call(a, '/transfers', 'POST', {
sourceId: dailySource,
targetId: dailyTarget,
amount: '5',
received: '5',
fee: '0',
date: day + 'T00:01',
requestId: randomUUID(),
});
assert.equal(dailyTransfer.status, 201);
assert.equal(await activity(dailySource), day);
assert.equal(await activity(dailyTarget), day);
assert.equal((await call(a, '/transfers/' + dailyTransfer.data.id, 'DELETE')).status, 200);
assert.equal(await activity(dailySource), null);
assert.equal(await activity(dailyTarget), null);
const backup = await call(a, '/backup');
assert.equal(backup.status, 200);
assert.equal(backup.data.positions.find((p: any) => p.id === card).lastRepaymentMonth, old);
+7 -1
View File
@@ -2,7 +2,13 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f5f5f7" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />
<meta name="color-scheme" content="light dark" />
<title>WorthPath · 资产之路</title>
</head>
<body>
+167 -133
View File
@@ -26,6 +26,8 @@ import {
RefreshCw,
TrendingUp,
ShieldCheck,
Eye,
EyeOff,
} from 'lucide-react';
import {
api,
@@ -46,6 +48,14 @@ import {
type PageResult,
} from './api';
import './style.css';
import {
accountPages,
overviewPages,
settingsPages,
navigationGroup,
availablePages,
navigationDestination,
} from './navigation';
import { NetWorthChart } from './NetWorthChart';
import { allOverviewCards, overviewCardLabels } from './overview-cards';
import { useToast } from './Toast';
@@ -140,14 +150,27 @@ function Modal({
close: () => void;
children: ReactNode;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const element = dialog.current;
element?.showModal();
return () => element?.close();
}, []);
return (
<div
className="veil"
<dialog
ref={dialog}
aria-modal="true"
aria-label={title}
onCancel={(event) => {
event.preventDefault();
close();
}}
className="veil native-modal"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<section role="dialog" aria-modal="true" aria-label={title} className="modal">
<section className="modal">
<header>
<h2 className={title.includes(tr('清空本账号数据')) ? 'danger-text' : undefined}>
{title}
@@ -158,7 +181,7 @@ function Modal({
</header>
{children}
</section>
</div>
</dialog>
);
}
function Empty({ title, body, action }: { title: string; body: string; action?: ReactNode }) {
@@ -173,6 +196,23 @@ function Empty({ title, body, action }: { title: string; body: string; action?:
}
export default function App() {
const locale = useLocale();
useEffect(() => {
const root = document.documentElement;
const pointer = () => {
root.dataset.input = 'pointer';
};
const keyboard = () => {
root.dataset.input = 'keyboard';
};
document.addEventListener('pointerdown', pointer, true);
document.addEventListener('keydown', keyboard, true);
return () => {
document.removeEventListener('pointerdown', pointer, true);
document.removeEventListener('keydown', keyboard, true);
delete root.dataset.input;
};
}, []);
const sessionGeneration = useRef(0),
loadGeneration = useRef(0);
const authCheck = useRef<Promise<User> | null>(null);
@@ -209,7 +249,6 @@ export default function App() {
} | null>(null);
const [quickEntries, setQuickEntries] = useState<QuickEntry[]>([]);
const [quickMarkMode, setQuickMarkMode] = useState(false);
const [quickBookedMode, setQuickBookedMode] = useState(false);
const [hideBooked, setHideBooked] = useState(false);
const [unpaidOnly, setUnpaidOnly] = useState(false);
const [assetQuickMode, setAssetQuickMode] = useState(false);
@@ -237,7 +276,6 @@ export default function App() {
quickGeneration.current++;
setQuickEntries([]);
setQuickMarkMode(false);
setQuickBookedMode(false);
setHideBooked(false);
setUnpaidOnly(false);
setAssetQuickMode(false);
@@ -262,7 +300,6 @@ export default function App() {
if (!quickMode) setQuickEntries([]);
setQuickMode(!quickMode);
setQuickMarkMode(false);
setQuickBookedMode(false);
setHideBooked(false);
setUnpaidOnly(false);
setQuickTransfer(null);
@@ -356,39 +393,6 @@ export default function App() {
);
return result;
}
async function markQuickBooked(position: Position) {
const previousDate = position.lastBookedDate ?? null,
day = today();
const nextDate = isBookedToday(position, day) ? null : day;
const generation = quickGeneration.current,
session = sessionGeneration.current;
const result = await api<{ updatedAt: string }>(
'/positions/' + position.id + '/booked-mark',
'PATCH',
{
date: nextDate,
expectedDate: previousDate,
expectedUpdatedAt: position.updatedAt,
},
);
rememberQuick(
{
key: 'booked:' + position.id + ':' + Date.now(),
kind: 'booked',
id: position.id,
positionId: position.id,
title: position.name,
currency: position.currency,
date: nowMinute(),
previousDate,
bookedDate: nextDate,
markUpdatedAt: result.updatedAt,
},
generation,
session,
);
return result;
}
async function toggleAssetInclusion(position: Position) {
return api('/positions/' + position.id + '/inclusion', 'PATCH', {
included: position.included === false,
@@ -401,12 +405,6 @@ export default function App() {
if (entry.kind === 'balance')
await api('/positions/' + entry.positionId + '/revisions/' + entry.id, 'DELETE');
else if (entry.kind === 'transfer') await api('/transfers/' + entry.id, 'DELETE');
else if (entry.kind === 'booked')
await api('/positions/' + entry.positionId + '/booked-mark', 'PATCH', {
date: entry.previousDate ?? null,
expectedDate: entry.bookedDate ?? null,
expectedUpdatedAt: entry.markUpdatedAt,
});
else
await api('/positions/' + entry.positionId + '/repayment-mark', 'PATCH', {
month: entry.previousMonth ?? null,
@@ -467,7 +465,9 @@ export default function App() {
[grain, setGrain] = useState('day');
const [dataVersion, setDataVersion] = useState(0);
const previousDataVersion = useRef(0);
const viewKey = [page, selected, rangeFrom, rangeTo, grain, settingsSection].join('|');
const viewKey = [page, selected, rangeFrom, rangeTo, grain, settingsSection, businessDay].join(
'|',
);
const viewRef = useRef(viewKey);
viewRef.current = viewKey;
const userRef = useRef(user);
@@ -706,19 +706,26 @@ export default function App() {
};
}, [modal]);
const previousView = useRef('');
const previousBusinessDay = useRef(businessDay);
useEffect(() => {
if (!user || user.mustChangePassword) return;
const dayChanged = previousBusinessDay.current !== businessDay;
previousBusinessDay.current = businessDay;
const mutated = previousDataVersion.current !== dataVersion;
previousDataVersion.current = dataVersion;
const rangeOnly =
!mutated && page === 'overview' && previousView.current.startsWith('overview|') && !!overview;
!mutated &&
!dayChanged &&
page === 'overview' &&
previousView.current.startsWith('overview|') &&
!!overview;
previousView.current = viewKey;
if (!rangeOnly) setOverview(null);
setHistoryRows([]);
setHistoryCursor(null);
setTransfers([]);
setTransferCursor(null);
void load(true, rangeOnly, !mutated);
void load(true, rangeOnly, !mutated && !dayChanged);
return () => {
loadGeneration.current++;
};
@@ -1044,19 +1051,27 @@ export default function App() {
);
}
const nav = [
['overview', LayoutDashboard],
['account', Wallet],
['asset', Landmark],
['debt', HandCoins],
['history', HistoryIcon],
['calendar', CalendarDays],
['schedules', HistoryIcon],
['settings', Settings],
['admin', ShieldCheck],
['overview', LayoutDashboard, '总览'],
['calendar', CalendarDays, '日历'],
['account', Wallet, '账号'],
['settings', Settings, '设置'],
] as const;
const visibleNav = nav.filter(([key]) =>
key === 'admin' ? user.role === 'admin' : !(user.hiddenMenus || []).includes(key),
);
const hiddenMenus = user.hiddenMenus || [];
const activeGroup = navigationGroup(page);
const visibleNav = nav.filter(([key]) => navigationDestination(key, hiddenMenus, user.role));
const sectionPages =
activeGroup === 'account'
? accountPages
: activeGroup === 'overview'
? overviewPages
: activeGroup === 'settings'
? settingsPages
: [];
function navigateTo(next: string) {
setPage(next);
setSelected(null);
setPositions([]);
}
const groups = orderedGroups(
positions.filter((p) => p.kind === 'account').map((p) => p.groupName || ''),
user.accountGroupOrder,
@@ -1072,18 +1087,22 @@ export default function App() {
<span>W</span> WorthPath
</div>
<p className="nav-caption">{tr('我的资产空间')}</p>
<nav style={{ gridTemplateColumns: `repeat(${visibleNav.length}, 1fr)` }}>
{visibleNav.map(([key, Icon]) => (
<nav
className="primary-nav"
aria-label={tr('主导航')}
style={{ gridTemplateColumns: `repeat(${visibleNav.length}, minmax(0, 1fr))` }}
>
{visibleNav.map(([key, Icon, label]) => (
<button
key={key}
className={page === key ? 'active' : ''}
className={activeGroup === key ? 'active' : ''}
aria-current={activeGroup === key ? 'page' : undefined}
onClick={() => {
setPage(key);
setSelected(null);
navigateTo(navigationDestination(key, hiddenMenus, user.role)!);
}}
>
<Icon size={19} />
<span>{tr(labels[key])}</span>
<span>{tr(label)}</span>
</button>
))}
</nav>
@@ -1119,6 +1138,14 @@ export default function App() {
{tr('本位币')}
{user.baseCurrency}
</span>
<button
className="icon mobile-logout"
aria-label={tr('退出登录')}
disabled={busy}
onClick={() => void logout()}
>
<LogOut size={17} />
</button>
<button
className="icon"
aria-label={tr('刷新数据')}
@@ -1134,6 +1161,20 @@ export default function App() {
page === 'account' && quickMode && !selected ? 'content quick-content' : 'content'
}
>
{sectionPages.length > 0 && (
<nav className="section-nav" aria-label={tr('页面分类')}>
{availablePages(sectionPages, hiddenMenus, user.role).map((key) => (
<button
key={key}
type="button"
aria-current={page === key ? 'page' : undefined}
onClick={() => navigateTo(key)}
>
{tr(labels[key])}
</button>
))}
</nav>
)}
<div className="privacy-toolbar">
<span>
{user.revealed
@@ -1151,6 +1192,11 @@ export default function App() {
: setModal({ kind: 'reveal' })
}
>
{user.revealed ? (
<EyeOff size={17} aria-hidden="true" />
) : (
<Eye size={17} aria-hidden="true" />
)}
{user.revealed
? tr('锁定隐藏资产')
: user.requireHiddenPassword === false
@@ -1636,7 +1682,6 @@ export default function App() {
aria-pressed={quickMarkMode}
onClick={() => {
setQuickMarkMode(!quickMarkMode);
setQuickBookedMode(false);
setQuickTransfer(null);
}}
>
@@ -1655,7 +1700,6 @@ export default function App() {
}
onClick={() => {
setQuickMarkMode(false);
setQuickBookedMode(false);
setHideBooked(false);
setUnpaidOnly(false);
setQuickTransfer({});
@@ -1663,18 +1707,6 @@ export default function App() {
>
{tr('转账')}
</button>
<button
className={quickBookedMode ? 'primary' : 'secondary'}
disabled={busy}
aria-pressed={quickBookedMode}
onClick={() => {
setQuickBookedMode(!quickBookedMode);
setQuickMarkMode(false);
setQuickTransfer(null);
}}
>
{tr(quickBookedMode ? '结束记账标记' : '标记当天已记账')}
</button>
<button
className="secondary"
aria-pressed={unpaidOnly}
@@ -1684,6 +1716,11 @@ export default function App() {
setQuickTransfer(null);
}}
>
{unpaidOnly ? (
<Eye size={17} aria-hidden="true" />
) : (
<EyeOff size={17} aria-hidden="true" />
)}
{tr(unpaidOnly ? '显示全部账户' : '仅看本月未还款')}
</button>
<button
@@ -1695,7 +1732,12 @@ export default function App() {
setQuickTransfer(null);
}}
>
{tr(hideBooked ? '显示当天已记账' : '隐藏当天已记账')}
{hideBooked ? (
<Eye size={17} aria-hidden="true" />
) : (
<EyeOff size={17} aria-hidden="true" />
)}
{tr(hideBooked ? '显示当天有记录' : '隐藏当天有记录')}
</button>
</>
)}
@@ -1727,13 +1769,11 @@ export default function App() {
{quickMode && (
<p className="muted">
{tr(
quickBookedMode
? '点击账户卡片,标记或取消当天已记账。'
: quickMarkMode
? '点击欠款账户卡片,标记或取消本月已还款。'
: quickTransfer !== null
? '快速转账中:依次点击转出和转入账户卡片。'
: '点击账户卡片,直接填写当前余额。',
quickMarkMode
? '点击欠款账户卡片,标记或取消本月已还款。'
: quickTransfer !== null
? '快速转账中:依次点击转出和转入账户卡片。'
: '点击账户卡片,直接填写当前余额。',
)}
</p>
)}
@@ -1835,6 +1875,10 @@ export default function App() {
<button
className={
'position-card' +
(p.name.length > 40 ||
money(positionAmount(p), p.currency).length > 18
? ' position-wide'
: '') +
(quickTransfer?.sourceId === p.id
? ' quick-source'
: quickTransfer?.targetId === p.id
@@ -1852,10 +1896,7 @@ export default function App() {
assetValueMode &&
(p.archived || p.category === 'gold')) ||
(!!quickTransfer && p.archived) ||
(quickMode &&
quickMarkMode &&
(p.side !== 'liability' || p.archived)) ||
(quickMode && quickBookedMode && p.archived)
(quickMode && quickMarkMode && (p.side !== 'liability' || p.archived))
}
key={p.id}
onClick={() =>
@@ -1863,22 +1904,15 @@ export default function App() {
? setModal({ kind: 'revision', p })
: page === 'asset' && assetQuickMode
? void act(() => toggleAssetInclusion(p), tr('统计设置已保存'))
: quickMode && quickBookedMode && page === 'account'
? void act(() => markQuickBooked(p), tr('记账标记已保存'))
: quickMode && quickMarkMode && page === 'account'
? p.kind === 'account' &&
p.side === 'liability' &&
!p.archived
? void act(
() => markQuickRepayment(p),
tr('还款标记已保存'),
)
: undefined
: quickTransfer !== null && page === 'account' && quickMode
? chooseQuickAccount(p)
: page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p, quick: true })
: setSelected(p.id)
: quickMode && quickMarkMode && page === 'account'
? p.kind === 'account' && p.side === 'liability' && !p.archived
? void act(() => markQuickRepayment(p), tr('还款标记已保存'))
: undefined
: quickTransfer !== null && page === 'account' && quickMode
? chooseQuickAccount(p)
: page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p, quick: true })
: setSelected(p.id)
}
>
<div>
@@ -1900,7 +1934,7 @@ export default function App() {
<span className="badge repayment-paid">{tr('本月已还款')}</span>
)}
{quickMode && page === 'account' && isBookedToday(p, businessDay) && (
<span className="badge repayment-paid">{tr('当天已记账')}</span>
<span className="badge repayment-paid">{tr('当天有记账记录')}</span>
)}
{assetQuickMode && page === 'asset' && p.included !== false && (
<span className="badge repayment-paid">{tr('计入总资产')}</span>
@@ -2134,29 +2168,29 @@ export default function App() {
{page === 'admin' && user.role === 'admin' && <AdminPanel user={user} report={report} />}
{page === 'settings' && (
<>
<nav className="settings-tabs" aria-label={tr('设置分类')}>
{Object.entries({
general: '常规设置',
agent: '连接 Agent',
display: '显示与菜单',
privacy: '登录与隐私',
security: '账号与密码',
icons: '图标库',
rates: '每日汇率',
backup: '备份与恢复',
clear: '清空本账号数据',
}).map(([key, label]) => (
<button
key={key}
type="button"
aria-current={settingsSection === key ? 'page' : undefined}
className={key === 'clear' ? 'danger-text' : undefined}
onClick={() => setSettingsSection(key)}
>
{tr(label)}
</button>
))}
</nav>
<label className="settings-category">
<span>{tr('设置分类')}</span>
<select
value={settingsSection}
onChange={(event) => setSettingsSection(event.target.value)}
>
{Object.entries({
general: '常规设置',
agent: '连接 Agent',
display: '显示与菜单',
privacy: '登录与隐私',
security: '账号与密码',
icons: '图标库',
rates: '每日汇率',
backup: '备份与恢复',
clear: '清空本账号数据',
}).map(([key, label]) => (
<option key={key} value={key}>
{tr(label)}
</option>
))}
</select>
</label>
{settingsSection === 'icons' && (
<section className="panel">
<IconLibrary />
+3 -8
View File
@@ -6,7 +6,7 @@ import { money, positionAmount, type Position } from './api';
import { t as tr } from './i18n';
export type QuickEntry = {
key: string;
kind: 'balance' | 'transfer' | 'mark' | 'booked';
kind: 'balance' | 'transfer' | 'mark';
id: string;
positionId: string;
title: string;
@@ -21,8 +21,6 @@ export type QuickEntry = {
previousMonth?: string | null;
month?: string | null;
markUpdatedAt?: string;
previousDate?: string | null;
bookedDate?: string | null;
undone?: boolean;
};
export const isPaidForMonth = (
@@ -32,8 +30,8 @@ export const isPaidForMonth = (
position.kind === 'account' &&
position.side === 'liability' &&
position.lastRepaymentMonth === month;
export const isBookedToday = (position: Pick<Position, 'kind' | 'lastBookedDate'>, day: string) =>
position.kind === 'account' && position.lastBookedDate === day;
export const isBookedToday = (position: Pick<Position, 'kind' | 'activityDate'>, day: string) =>
position.kind === 'account' && position.activityDate === day;
export const quickAccountVisible = (
position: Position,
month: string,
@@ -96,9 +94,6 @@ export function QuickJournal({
{entry.kind === 'mark' && (
<p>{tr(entry.month ? '已标记本月还款' : '已取消本月标记')}</p>
)}
{entry.kind === 'booked' && (
<p>{tr(entry.bookedDate ? '已标记当天记账' : '已取消当天标记')}</p>
)}
<small>{entry.date.replace('T', ' ')} · UTC+8</small>
{entry.undone ? (
<span className="badge">{tr('已撤回')}</span>
+18 -9
View File
@@ -17,22 +17,31 @@ export function useToast(message: string, kind: Kind) {
}, [message, kind]);
}
function ToastItem({ notice, remove }: { notice: Notice; remove: (id: number) => void }) {
const [exiting, setExiting] = useState(false);
const dismiss = () => {
if (document.documentElement.dataset.input === 'keyboard') remove(notice.id);
else setExiting(true);
};
useEffect(() => {
const timer = setTimeout(() => remove(notice.id), notice.kind === 'error' ? 12000 : 6000);
const timer = setTimeout(() => setExiting(true), notice.kind === 'error' ? 12000 : 6000);
return () => clearTimeout(timer);
}, [notice, remove]);
}, [notice]);
useEffect(() => {
if (!exiting) return;
const timer = setTimeout(() => remove(notice.id), 120);
return () => clearTimeout(timer);
}, [exiting, notice.id, remove]);
return (
<div className={'toast ' + notice.kind} role={notice.kind === 'error' ? 'alert' : 'status'}>
<div
className={'toast ' + notice.kind}
data-ending={exiting || undefined}
role={notice.kind === 'error' ? 'alert' : 'status'}
>
<span className="toast-symbol" aria-hidden="true">
{notice.kind === 'error' ? '!' : '✓'}
</span>
<p>{tr(notice.message)}</p>
<button
type="button"
className="icon"
aria-label={tr('关闭')}
onClick={() => remove(notice.id)}
>
<button type="button" className="icon" aria-label={tr('关闭')} onClick={dismiss}>
×
</button>
</div>
+1 -1
View File
@@ -55,7 +55,7 @@ export type History = {
};
export type Position = {
lastRepaymentMonth?: string | null;
lastBookedDate?: string | null;
activityDate?: string | null;
updatedAt?: string;
included?: boolean;
metalType?: string | null;
+10 -2
View File
@@ -45,7 +45,7 @@
"欢迎回来": "Welcome back",
"建立属于你的私人资产空间": "Create your private asset workspace",
"登录以查看你的资产与负债": "Sign in to view your assets and liabilities",
"账号": "Username",
"账号": "Accounts",
"输入账号(至少 3 个字符)": "Enter a username (at least 3 characters)",
"密码": "Password",
"至少 10 个字符,最多 72 字节": "At least 10 characters, up to 72 bytes",
@@ -666,5 +666,13 @@
"不能删除当前登录的管理员账号": "You cannot delete the currently signed-in administrator.",
"仅支持删除管理员账号": "Only administrator accounts can be deleted.",
"确认账号名称不一致,请重新核对": "The confirmation username does not match. Check it again.",
"请先设置另一位已完成改密且未封禁的管理员": "First set up another unbanned administrator who has changed their initial password."
"请先设置另一位已完成改密且未封禁的管理员": "First set up another unbanned administrator who has changed their initial password.",
"总览": "Overview",
"日历": "Calendar",
"设置": "Settings",
"主导航": "Main navigation",
"页面分类": "Page sections",
"当天有记账记录": "Records today",
"显示当天有记录": "Show today’s recorded accounts",
"隐藏当天有记录": "Hide today’s recorded accounts"
}
+9 -1
View File
@@ -666,5 +666,13 @@
"不能删除当前登录的管理员账号": "不能刪除當前登錄的管理員賬號",
"仅支持删除管理员账号": "僅支持刪除管理員賬號",
"确认账号名称不一致,请重新核对": "確認賬號名稱不一致,請重新核對",
"请先设置另一位已完成改密且未封禁的管理员": "請先設置另一位已完成改密且未封禁的管理員"
"请先设置另一位已完成改密且未封禁的管理员": "請先設置另一位已完成改密且未封禁的管理員",
"总览": "總覽",
"日历": "日曆",
"设置": "設置",
"主导航": "主導航",
"页面分类": "頁面分類",
"当天有记账记录": "當天有記賬記錄",
"显示当天有记录": "顯示當天有記錄",
"隐藏当天有记录": "隱藏當天有記錄"
}
+28
View File
@@ -0,0 +1,28 @@
export const accountPages = ['account', 'asset', 'debt'] as const;
export const overviewPages = ['overview', 'history'] as const;
export const settingsPages = ['settings', 'schedules', 'admin'] as const;
export function navigationGroup(page: string) {
if ((accountPages as readonly string[]).includes(page)) return 'account';
if ((overviewPages as readonly string[]).includes(page)) return 'overview';
if ((settingsPages as readonly string[]).includes(page)) return 'settings';
return page;
}
export function availablePages(pages: readonly string[], hidden: string[], role?: string) {
return pages.filter((page) =>
page === 'settings' ? true : page === 'admin' ? role === 'admin' : !hidden.includes(page),
);
}
export function navigationDestination(group: string, hidden: string[], role?: string) {
const pages =
group === 'account'
? accountPages
: group === 'overview'
? overviewPages
: group === 'settings'
? settingsPages
: [group];
return availablePages(pages, hidden, role)[0];
}
+867 -144
View File
File diff suppressed because it is too large. Load diff
+26 -3
View File
@@ -57,13 +57,14 @@ test('session journal displays exact balances and distinguishes saved versus und
assert.doesNotMatch(undone, /<button/);
});
test('daily badges expire and filters combine month and day without mutating markers', () => {
test('actual record badges expire and filters ignore obsolete manual markers', () => {
const p = {
id: 'test',
kind: 'account',
side: 'liability',
lastRepaymentMonth: '2026-10',
lastBookedDate: '2026-10-04',
activityDate: '2026-10-04',
lastBookedDate: '2026-10-05',
archived: false,
} as any;
assert.equal(isBookedToday(p, '2026-10-04'), true);
@@ -75,7 +76,9 @@ test('daily badges expire and filters combine month and day without mutating mar
quickAccountVisible({ ...p, side: 'asset' }, '2026-11', '2026-10-05', true, false),
false,
);
assert.equal(p.lastBookedDate, '2026-10-04');
assert.equal(p.lastBookedDate, '2026-10-05');
assert.equal(isBookedToday({ ...p, activityDate: null }, '2026-10-05'), false);
assert.equal(isBookedToday({ ...p, kind: 'asset' }, '2026-10-04'), false);
});
test('repayment discount uses opposite fee sign without decimal precision loss', () => {
@@ -85,3 +88,23 @@ test('repayment discount uses opposite fee sign without decimal precision loss',
assert.equal(repaymentFee('0'), '0');
assert.throws(() => repaymentFee('1e3'));
});
import {
accountPages,
overviewPages,
settingsPages,
navigationGroup,
availablePages,
navigationDestination,
} from '../src/navigation';
test('navigation retains access to visible children and restricts admin to administrators', () => {
for (const page of accountPages) assert.equal(navigationGroup(page), 'account');
for (const page of overviewPages) assert.equal(navigationGroup(page), 'overview');
for (const page of settingsPages) assert.equal(navigationGroup(page), 'settings');
assert.equal(navigationDestination('account', ['account']), 'asset');
assert.equal(navigationDestination('account', ['account', 'asset', 'debt']), undefined);
assert.equal(navigationDestination('overview', ['overview']), 'history');
assert.equal(navigationDestination('settings', ['settings', 'schedules'], 'user'), 'settings');
assert.deepEqual(availablePages(settingsPages, [], 'user'), ['settings', 'schedules']);
assert.deepEqual(availablePages(settingsPages, [], 'admin'), ['settings', 'schedules', 'admin']);
});