feat: adopt Apple UI and four-tab mobile navigation
This commit is contained in:
1 parent
daf9b458af
commit
fb8cf1c938
20 files changed
+1662
-378
No files matched your search
+7
-1
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -666,5 +666,13 @@
|
||||
"不能删除当前登录的管理员账号": "不能刪除當前登錄的管理員賬號",
|
||||
"仅支持删除管理员账号": "僅支持刪除管理員賬號",
|
||||
"确认账号名称不一致,请重新核对": "確認賬號名稱不一致,請重新核對",
|
||||
"请先设置另一位已完成改密且未封禁的管理员": "請先設置另一位已完成改密且未封禁的管理員"
|
||||
"请先设置另一位已完成改密且未封禁的管理员": "請先設置另一位已完成改密且未封禁的管理員",
|
||||
"总览": "總覽",
|
||||
"日历": "日曆",
|
||||
"设置": "設置",
|
||||
"主导航": "主導航",
|
||||
"页面分类": "頁面分類",
|
||||
"当天有记账记录": "當天有記賬記錄",
|
||||
"显示当天有记录": "顯示當天有記錄",
|
||||
"隐藏当天有记录": "隱藏當天有記錄"
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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']);
|
||||
});
|
||||
Reference in new issue
Block a user