feat: customize sessions and overview with faster account workflows

This commit is contained in:
陈煜 committed 2026-10-03 18:07:19 +08:00
1 parent 9a133e82e9
commit 425b45c91a
40 files changed
+1656 -335

No files matched your search

+425 -158
View File
@@ -46,12 +46,15 @@ import {
type PageResult,
} from './api';
import './style.css';
import { allOverviewCards, overviewCardLabels } from './overview-cards';
import { useToast } from './Toast';
import { GroupOrderContext } from './GroupOrderContext';
import { GroupOrderList } from './GroupOrderList';
import { orderedGroups, mergeGroupOrder } from './group-order';
import { Calendar } from './Calendar';
import { SchedulePanel } from './SchedulePanel';
import { IconPicker } from './IconPicker';
import { QuickTransfer } from './QuickTransfer';
import { TransferForm } from './TransferForm';
import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm';
import type { Transfer } from './api';
@@ -243,8 +246,42 @@ export default function App() {
[error, setError] = useState(''),
[success, setSuccess] = useState('');
const [calendarRefresh, setCalendarRefresh] = useState(0);
const [quickTransfer, setQuickTransfer] = useState<{
sourceId?: string;
targetId?: string;
} | null>(null);
const [quickMode, setQuickMode] = useState(false),
[accountGroup, setAccountGroup] = useState<string | null>(null);
useEffect(() => {
setQuickTransfer(null);
}, [page, quickMode, selected, user?.revealed]);
useEffect(() => {
setQuickTransfer((current) =>
current &&
[current.sourceId, current.targetId].some(
(id) =>
id &&
!positions.some(
(p) => p.id === id && p.kind === 'account' && p.side === 'asset' && !p.archived,
),
)
? null
: current,
);
}, [positions]);
function chooseQuickAccount(p: Position) {
if (busy || loading) return;
if (p.kind !== 'account' || p.side !== 'asset' || p.archived) {
setError(tr('请选择启用的资产账户'));
return;
}
setQuickTransfer((current) => {
if (!current) return current;
if (!current.sourceId) return { sourceId: p.id };
if (!current.targetId && p.id !== current.sourceId) return { ...current, targetId: p.id };
return current;
});
}
const [fxStatus, setFxStatus] = useState<{
state: string;
message: string;
@@ -261,6 +298,8 @@ export default function App() {
} | null>(null);
const [clearStep, setClearStep] = useState(0),
[clearConfirmation, setClearConfirmation] = useState(0);
useToast(error, 'error');
useToast(success, 'success');
const [settingsSection, setSettingsSection] = useState('general'),
[transfers, setTransfers] = useState<Transfer[]>([]);
const [historyRows, setHistoryRows] = useState<History[]>([]),
@@ -305,6 +344,7 @@ export default function App() {
setRegister(false);
setLoading(false);
setSuccess('');
setQuickTransfer(null);
}
const report = (e: unknown) => {
setError(e instanceof Error ? e.message : tr('操作失败'));
@@ -714,11 +754,7 @@ export default function App() {
<p className="muted">
{register ? tr('建立属于你的私人资产空间') : tr('登录以查看你的资产与负债')}
</p>
{error && (
<div role="alert" className="notice error">
{tr(error)}
</div>
)}
<form onSubmit={auth}>
<Field label={tr('账号')}>
<input
@@ -912,7 +948,7 @@ export default function App() {
<div className="privacy-toolbar">
<span>
{user.revealed
? tr('当前包含隐藏项目 · 验证 5 分钟后自动锁定')
? tr('当前包含隐藏资产 · 5 分钟后自动锁定')
: tr('当前不包含隐藏账户、资产和债务,净资产按此范围计算')}
</span>
<button
@@ -920,11 +956,17 @@ export default function App() {
disabled={busy}
onClick={() =>
user.revealed
? void act(() => api('/auth/lock', 'POST'), tr('隐藏项目已锁定'))
: setModal({ kind: 'reveal' })
? void act(() => api('/auth/lock', 'POST'), tr('隐藏资产已锁定'))
: user.requireHiddenPassword === false
? void act(() => api('/auth/reveal', 'POST', {}), tr('隐藏资产已显示'))
: setModal({ kind: 'reveal' })
}
>
{user.revealed ? tr('锁定隐藏项目') : tr('显示隐藏资产(验证密码)')}
{user.revealed
? tr('锁定隐藏资产')
: user.requireHiddenPassword === false
? tr('显示隐藏资产')
: tr('显示隐藏资产(验证密码)')}
</button>
</div>
<div className="page-heading">
@@ -947,28 +989,13 @@ export default function App() {
<div className="actions">
{page === 'account' && !p && (
<button className="secondary" onClick={() => setModal({ kind: 'transfer' })}>
{tr('账户间转账')}
{tr('转账')}
</button>
)}
{['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction}
</div>
</div>
{error && (
<div className="notice error" role="alert">
{error}
<button className="text" onClick={() => setError('')}>
{tr('关闭')}
</button>
</div>
)}
{success && (
<div className="notice success" role="status">
{tr(success)}
<button className="text" onClick={() => setSuccess('')}>
{tr('关闭')}
</button>
</div>
)}
{loading && (
<p role="status" className="muted">
{tr('正在刷新数据…')}
@@ -976,6 +1003,20 @@ export default function App() {
)}
{page === 'overview' && overview && (
<>
{user.overviewCards?.length === 0 && (
<section className="panel">
<p>{tr('尚未选择总览卡片')}</p>
<button
className="text"
onClick={() => {
setPage('settings');
setSettingsSection('display');
}}
>
{tr('设置显示卡片')}
</button>
</section>
)}
{!overview.complete && (
<div className="notice warning">
{tr('缺少')}
@@ -986,39 +1027,69 @@ export default function App() {
</button>
</div>
)}
<div className="metrics">
<div
className="metrics"
hidden={
!['assets', 'liabilities', 'net'].some((key) =>
(user.overviewCards ?? allOverviewCards).includes(key),
)
}
>
{[
[tr('总资产'), overview.assets, 'asset'],
[tr('总负债'), overview.liabilities, 'debt'],
[tr('净资产'), overview.net, 'net'],
].map(([title, value, cls]) => (
<section className={'metric ' + cls} key={title}>
<div>
<span>
{title}
{!overview.complete ? tr('(部分)') : ''}
</span>
<ArrowUpRight size={18} />
</div>
<strong
className={
cls === 'debt' || value.startsWith('-') ? 'danger-text' : undefined
}
>
{money(value, user.baseCurrency)}
</strong>
<small>
{title === tr('净资产')
? tr('总资产 − 总负债')
: title === tr('总资产')
? tr('账户资产 + 独立资产')
: tr('负债账户 + 独立债务')}
</small>
</section>
))}
]
.filter(([, , cls]) =>
(user.overviewCards ?? allOverviewCards).includes(
cls === 'asset' ? 'assets' : cls === 'debt' ? 'liabilities' : cls,
),
)
.map(([title, value, cls]) => (
<section className={'metric ' + cls} key={title}>
<div>
<span>
{title}
{!overview.complete ? tr('(部分)') : ''}
</span>
<ArrowUpRight size={18} />
</div>
<strong
className={
cls === 'debt' || value.startsWith('-') ? 'danger-text' : undefined
}
>
{money(value, user.baseCurrency)}
</strong>
<small>
{title === tr('净资产')
? tr('总资产 − 总负债')
: title === tr('总资产')
? tr('账户资产 + 独立资产')
: tr('负债账户 + 独立债务')}
</small>
</section>
))}
</div>
<div className="dashboard-grid">
<section className="panel">
<div
className={
'dashboard-grid' +
(!['trend', 'composition'].every((key) =>
(user.overviewCards ?? allOverviewCards).includes(key),
)
? ' single-card'
: '')
}
hidden={
!['trend', 'composition'].some((key) =>
(user.overviewCards ?? allOverviewCards).includes(key),
)
}
>
<section
className="panel"
hidden={!(user.overviewCards ?? allOverviewCards).includes('trend')}
>
<div className="panel-title">
<h2>{tr('净资产轨迹')}</h2>
<span className="badge">{tr('按业务日期')}</span>
@@ -1063,7 +1134,10 @@ export default function App() {
{tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')}
</p>
</section>
<section className="panel">
<section
className="panel"
hidden={!(user.overviewCards ?? allOverviewCards).includes('composition')}
>
<h2>{tr('资产与负债组成')}</h2>
{overview.items.length ? (
['account', 'asset', 'debt'].map((k) => (
@@ -1097,7 +1171,10 @@ export default function App() {
</p>
</section>
</div>
<section className="panel">
<section
className="panel"
hidden={!(user.overviewCards ?? allOverviewCards).includes('recent')}
>
<div className="panel-title">
<h2>{tr('最近变化')}</h2>
<button className="text" onClick={() => setPage('history')}>
@@ -1124,43 +1201,44 @@ export default function App() {
].join(' / ') || tr('尚无外币汇率')}{' '}
{tr('· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。')}
</div>
{overview.trend.length > 1 && (
<details className="panel attribution">
<summary>{tr('变化归因')}</summary>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>{tr('日期')}</th>
<th>{tr('余额 / 估值变化')}</th>
<th>{tr('汇率变化')}</th>
<th>{tr('净资产')}</th>
</tr>
</thead>
<tbody>
{overview.trend.slice(-10).map((r) => (
<tr key={r.date}>
<td>{r.date}</td>
<td>
{r.balanceChange === null || r.balanceChange === undefined
? '—'
: money(r.balanceChange, user.baseCurrency)}
</td>
<td>
{r.fxChange === null || r.fxChange === undefined
? '—'
: money(r.fxChange, user.baseCurrency)}
</td>
<td>
{r.complete ? money(r.net, user.baseCurrency) : tr('汇率不完整')}
</td>
{(user.overviewCards ?? allOverviewCards).includes('attribution') &&
overview.trend.length > 1 && (
<details className="panel attribution">
<summary>{tr('变化归因')}</summary>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>{tr('日期')}</th>
<th>{tr('余额 / 估值变化')}</th>
<th>{tr('汇率变化')}</th>
<th>{tr('净资产')}</th>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</thead>
<tbody>
{overview.trend.slice(-10).map((r) => (
<tr key={r.date}>
<td>{r.date}</td>
<td>
{r.balanceChange === null || r.balanceChange === undefined
? '—'
: money(r.balanceChange, user.baseCurrency)}
</td>
<td>
{r.fxChange === null || r.fxChange === undefined
? '—'
: money(r.fxChange, user.baseCurrency)}
</td>
<td>
{r.complete ? money(r.net, user.baseCurrency) : tr('汇率不完整')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</>
)}
{['account', 'asset', 'debt'].includes(page) &&
@@ -1188,8 +1266,8 @@ export default function App() {
<h2 className={positionAmount(p).startsWith('-') ? 'danger-text' : undefined}>
{money(positionAmount(p), p.currency)}
</h2>
{p.hidden && <span className="badge">{tr('隐藏项目')}</span>}
<p className="muted">
{p.hidden && <span className="badge">{tr('隐藏资产')}</span>}
<p className="muted" hidden={p.currency === user.baseCurrency}>
{tr('本位币:')}
{p.converted !== null
? money(positionAmount(p, p.converted || '0'), user.baseCurrency)
@@ -1205,7 +1283,7 @@ export default function App() {
className="primary"
onClick={() => setModal({ kind: 'transfer', p })}
>
{tr('账户间转账')}
{tr('转账')}
</button>
<button
className="secondary"
@@ -1347,11 +1425,24 @@ export default function App() {
{tr('分组排序')}
</button>
</div>
{quickMode && <p className="muted">{tr('点击账户卡片,直接填写当前余额。')}</p>}
{quickMode && (
<p className="muted">
{tr(
quickTransfer !== null
? '快速转账中:依次点击转出和转入账户卡片。'
: '点击账户卡片,直接填写当前余额。',
)}
</p>
)}
</>
)}
{positions.some((p) => p.kind === page) ? (
<div className="position-grid">
<div
className={
'position-grid' +
(quickTransfer !== null && page === 'account' ? ' quick-transfer-select' : '')
}
>
{positions
.filter(
(p) =>
@@ -1375,12 +1466,29 @@ export default function App() {
<h3 className="account-group-title">{p.groupName || tr('未分组')}</h3>
)}
<button
className="position-card"
className={
'position-card' +
(quickTransfer?.sourceId === p.id
? ' quick-source'
: quickTransfer?.targetId === p.id
? ' quick-target'
: '')
}
aria-pressed={
quickTransfer
? quickTransfer.sourceId === p.id || quickTransfer.targetId === p.id
: undefined
}
disabled={
busy || (!!quickTransfer && (p.side !== 'asset' || p.archived))
}
key={p.id}
onClick={() =>
page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p })
: setSelected(p.id)
quickTransfer !== null && page === 'account' && quickMode
? chooseQuickAccount(p)
: page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p })
: setSelected(p.id)
}
>
<div>
@@ -1419,7 +1527,7 @@ export default function App() {
>
{money(positionAmount(p), p.currency)}
</strong>
<small>
<small hidden={p.currency === user.baseCurrency}>
{tr('折合')}{' '}
{p.converted === null
? tr('缺少汇率')
@@ -1438,6 +1546,74 @@ export default function App() {
)}
</section>
))}
{page === 'account' && quickMode && !selected && (
<aside className="quick-transfer-widget" aria-label={tr('快速转账')}>
{quickTransfer === null ? (
<button
type="button"
className="primary quick-transfer-toggle"
disabled={
busy ||
loading ||
positions.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
).length < 2
}
onClick={() => setQuickTransfer({})}
>
{tr('转账')}
</button>
) : (
<section className="quick-transfer-card">
<div className="panel-title">
<h2>{tr('快速转账')}</h2>
<button
type="button"
className="icon"
aria-label={tr('关闭快速转账')}
disabled={busy}
onClick={() => setQuickTransfer(null)}
>
×
</button>
</div>
{!quickTransfer.sourceId ? (
<p>{tr('点击账户卡片选择转出账户')}</p>
) : !quickTransfer.targetId ? (
<>
<p>
{tr('转出账户:')}
{positions.find((p) => p.id === quickTransfer.sourceId)?.name}
</p>
<p>{tr('点击另一张账户卡片选择转入账户')}</p>
<button type="button" className="text" onClick={() => setQuickTransfer({})}>
{tr('重新选择')}
</button>
</>
) : (
positions.some((p) => p.id === quickTransfer.sourceId) &&
positions.some((p) => p.id === quickTransfer.targetId) && (
<QuickTransfer
key={quickTransfer.sourceId + quickTransfer.targetId}
source={positions.find((p) => p.id === quickTransfer.sourceId)!}
target={positions.find((p) => p.id === quickTransfer.targetId)!}
busy={busy}
changeSource={() => setQuickTransfer({})}
changeTarget={() => setQuickTransfer({ sourceId: quickTransfer.sourceId })}
submit={(data) =>
void act(async () => {
const result = await api('/transfers', 'POST', data);
setQuickTransfer(null);
return result;
}, tr('转账已完成'))
}
/>
)
)}
</section>
)}
</aside>
)}
{page === 'history' && (
<>
<section className="panel">
@@ -1521,7 +1697,7 @@ export default function App() {
))}
</div>
) : (
<p className="muted">{tr('暂无账户间转账记录。')}</p>
<p className="muted">{tr('暂无转账记录。')}</p>
)}
{transferCursor && (
<button disabled={loading} onClick={() => void more('transfers')}>
@@ -1565,24 +1741,28 @@ export default function App() {
)}
{page === 'settings' && (
<>
<section className="settings-switch panel">
<label>
{tr('设置分类')}
<select
value={settingsSection}
onChange={(e) => setSettingsSection(e.target.value)}
<nav className="settings-tabs" aria-label={tr('设置分类')}>
{Object.entries({
general: '常规设置',
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)}
>
<option value="general">{tr('个人与菜单设置')}</option>
<option value="security">{tr('账号与密码')}</option>
<option value="icons">{tr('图标库')}</option>
<option value="rates">{tr('每日汇率')}</option>
<option value="backup">{tr('备份与恢复')}</option>
<option className="danger-text" value="clear">
{tr('清空本账号数据')}
</option>
</select>
</label>
</section>
{tr(label)}
</button>
))}
</nav>
{settingsSection === 'icons' && (
<section className="panel">
<IconLibrary />
@@ -1662,7 +1842,7 @@ export default function App() {
<div className="settings-grid">
<section className="panel" hidden={settingsSection !== 'general'}>
<div className="panel-title">
<h2>{tr('个人与菜单设置')}</h2>
<h2>{tr('常规设置')}</h2>
<button className="text" disabled={busy} onClick={() => void logout()}>
<LogOut size={16} />
{tr('退出登录')}
@@ -1673,6 +1853,34 @@ export default function App() {
{user.username}
{tr('。切换本位币会重新换算整个历史,原币数据保留。')}
</p>
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
void act(
() => api('/settings', 'PATCH', { baseCurrency: f.get('baseCurrency') }),
tr('设置已保存'),
);
}}
>
<Field label={tr('本位币')}>
<select
name="baseCurrency"
defaultValue={user.baseCurrency}
key={user.baseCurrency}
>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<button className="primary" disabled={busy}>
{tr('保存设置')}
</button>
</form>
</section>
<section className="panel" hidden={settingsSection !== 'display'}>
<h2>{tr('显示与菜单')}</h2>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -1680,7 +1888,6 @@ export default function App() {
void act(
() =>
api('/settings', 'PATCH', {
baseCurrency: f.get('baseCurrency'),
hiddenMenus: [
'overview',
'account',
@@ -1690,13 +1897,14 @@ export default function App() {
'calendar',
'schedules',
].filter((key) => !f.has('menu_' + key)),
idleMinutes: Number(f.get('idleMinutes')),
showNotes: f.has('showNotes'),
overviewCards: allOverviewCards.filter((key) => f.has('card_' + key)),
}),
tr('个人设置已保存'),
tr('设置已保存'),
);
}}
>
<h3>{tr('导航菜单')}</h3>
<p className="muted">
{tr('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')}
</p>
@@ -1743,6 +1951,76 @@ export default function App() {
<p className="muted">
{tr('关闭后隐藏列表、详情及编辑表单中的备注,已有备注仍会保留在备份中。')}
</p>
<h3>{tr('总览显示卡片')}</h3>
<p className="muted">
{tr('选择资产总览需要显示的卡片,数据和记录仍会保留。')}
</p>
<div className="menu-preferences">
{allOverviewCards.map((key) => (
<label className="switch-row" key={key}>
<span>{tr(overviewCardLabels[key])}</span>
<input
name={'card_' + key}
type="checkbox"
role="switch"
aria-label={tr(overviewCardLabels[key]) + tr('显示')}
key={key + String(user.overviewCards)}
defaultChecked={(user.overviewCards ?? allOverviewCards).includes(key)}
/>
<span className="switch-state" aria-hidden="true">
{tr('显示')}
</span>
</label>
))}
</div>
<button className="primary" disabled={busy}>
{tr('保存设置')}
</button>
</form>
</section>
<section className="panel" hidden={settingsSection !== 'privacy'}>
<h2>{tr('登录与隐私')}</h2>
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
void act(
() =>
api('/settings', 'PATCH', {
sessionHours: Number(f.get('sessionHours')),
idleMinutes: Number(f.get('idleMinutes')),
requireHiddenPassword: f.has('requireHiddenPassword'),
}),
tr('设置已保存'),
);
}}
>
<Field label={tr('登录有效期(小时)')}>
<input
name="sessionHours"
type="number"
min="1"
max="720"
step="1"
required
defaultValue={user.sessionHours ?? 168}
key={user.sessionHours}
/>
</Field>
<p className="muted">
{tr(
'范围 1 小时至 30 天(720 小时)。保存后当前登录重新计时,其他会话沿用原到期时间;后续登录使用新设置。',
)}
</p>
{user.sessionExpiresAt && (
<p className="muted">
{tr('当前会话到期:')}
{new Date(user.sessionExpiresAt).toLocaleString(locale, {
timeZone: 'Asia/Hong_Kong',
})}
</p>
)}
<Field label={tr('无操作自动退出(分钟)')}>
<select
name="idleMinutes"
@@ -1763,17 +2041,23 @@ export default function App() {
'0 为关闭;最长 1440 分钟。到时退出登录并清除页面数据,浏览器标签页保留。',
)}
</p>
<Field label={tr('本位币')}>
<select
name="baseCurrency"
defaultValue={user.baseCurrency}
key={user.baseCurrency}
>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<label className="switch-row">
<span>{tr('查看隐藏资产需要密码')}</span>
<input
name="requireHiddenPassword"
type="checkbox"
role="switch"
aria-label={tr('查看隐藏资产需要密码')}
key={String(user.requireHiddenPassword)}
defaultChecked={user.requireHiddenPassword !== false}
/>
</label>
<p className="muted">
{tr(
'关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。',
)}
</p>
<button className="primary" disabled={busy}>
{tr('保存设置')}
</button>
@@ -1856,7 +2140,7 @@ export default function App() {
<h2 className="danger-text">{tr('清空本账号数据')}</h2>
<p className="muted">
{tr(
'清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏项目);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。',
'清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏资产);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。',
)}
</p>
{clearStep === 0 ? (
@@ -2080,11 +2364,6 @@ export default function App() {
</>
)}
</div>
{error && (
<p role="alert" className="notice error">
{error}
</p>
)}
</Modal>
)}
{modal?.kind === 'group-order' && modal.groups && (
@@ -2146,11 +2425,7 @@ export default function App() {
)}
</p>
<p className="danger-text">{tr('删除后无法撤销,请确认已保存所需备份。')}</p>
{error && (
<p role="alert" className="notice error">
{tr(error)}
</p>
)}
<div className="modal-actions clear-confirm-actions">
<button autoFocus disabled={busy} onClick={() => setModal(null)}>
{tr('取消')}
@@ -2234,11 +2509,7 @@ export default function App() {
<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>
@@ -2253,7 +2524,7 @@ export default function App() {
? operationLabel(modal.p.side === 'asset' ? 'collect' : 'repay')
: tr('收款 / 还款')
: modal.kind === 'transfer'
? tr('账户间转账')
? tr('转账')
: modal.kind === 'reveal'
? tr('验证密码以显示隐藏资产')
: modal.kind === 'edit'
@@ -2322,7 +2593,7 @@ export default function App() {
if (kind === 'reveal') {
void act(
() => api('/auth/reveal', 'POST', { password: f.get('password') }),
tr('隐藏项目已解锁 5 分钟'),
tr('隐藏资产已解锁 5 分钟'),
);
} else if (kind === 'links') {
void act(
@@ -2571,11 +2842,7 @@ export default function App() {
</Field>
)
)}
{error && (
<div role="alert" className="notice error">
{error}
</div>
)}
<div className="modal-actions">
<button
className="secondary"
+2 -5
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { api, money, today } from './api';
import { useToast } from './Toast';
import { t as tr } from './i18n';
type Day = { date: string; income: string; expense: string; complete: boolean };
type Entry = {
@@ -25,6 +26,7 @@ export function Calendar({
[base, setBase] = useState('CNY'),
[error, setError] = useState(''),
[loading, setLoading] = useState(false);
useToast(error, 'error');
useEffect(() => {
let active = true;
setDays([]);
@@ -158,11 +160,6 @@ export function Calendar({
<p>{tr('当天没有收支记录')}</p>
)}
</div>
{error && (
<p role="alert" className="notice error">
{error}
</p>
)}
</section>
);
}
+3 -6
View File
@@ -1,6 +1,7 @@
import { t as tr } from './i18n';
import { useEffect, useState, useRef } from 'react';
import { api } from './api';
import { useToast } from './Toast';
type Icon = { id: string; name: string; shared: boolean; source: string | null };
export const iconUrl = (id: string) => '/api/icons/' + encodeURIComponent(id) + '/image';
export function IconLibrary({
@@ -26,6 +27,8 @@ export function IconLibrary({
[notice, setNotice] = useState(''),
[refresh, setRefresh] = useState(0);
const file = useRef<HTMLInputElement>(null);
useToast(error, 'error');
useToast(notice, 'success');
useEffect(() => {
let active = true;
setLoading(true);
@@ -236,12 +239,6 @@ export function IconLibrary({
{busy ? tr('正在上传…') : shared ? tr('导入共享图标') : tr('上传私有图标')}
</button>
</details>
{error && (
<p role="alert" className="icon-error">
{tr(error)}
</p>
)}
{notice && <p role="status">{tr(notice)}</p>}
</section>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react';
import { money, nowMinute, requestUuid, type Position } from './api';
import { t as tr } from './i18n';
export function QuickTransfer({
source,
target,
busy,
changeSource,
changeTarget,
submit,
}: {
source: Position;
target: Position;
busy: boolean;
changeSource: () => void;
changeTarget: () => void;
submit: (data: unknown) => void;
}) {
const [amount, setAmount] = useState('');
const [requestId] = useState(requestUuid);
const same = source.currency === target.currency;
return (
<form
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
submit({
requestId,
sourceId: source.id,
targetId: target.id,
amount,
received: same ? amount : String(f.get('received')),
fee: '0',
notes: '',
date: nowMinute(),
});
}}
>
<div className="quick-transfer-pair">
<div>
<small>{tr('转出账户')}</small>
<strong>{source.name}</strong>
<span>{money(source.amount, source.currency)}</span>
<button type="button" className="text" disabled={busy} onClick={changeSource}>
{tr('重选')}
</button>
</div>
<span aria-hidden="true">→</span>
<div>
<small>{tr('转入账户')}</small>
<strong>{target.name}</strong>
<span>{money(target.amount, target.currency)}</span>
<button type="button" className="text" disabled={busy} onClick={changeTarget}>
{tr('重选')}
</button>
</div>
</div>
<label>
{tr('转出金额(')}
{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)}
disabled={busy}
autoFocus
/>
</label>
{!same && (
<label>
{tr('实际到账(')}
{target.currency})
<input
name="received"
required
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
disabled={busy}
/>
</label>
)}
<p className="muted">{tr('按当前时间记录,手续费为 0。跨币种请填写实际到账金额。')}</p>
<button className="primary" disabled={busy}>
{tr(busy ? '正在转账…' : '确认转账')}
</button>
</form>
);
}
+163 -94
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { api, money, nowMinute, type Position } from './api';
import { AccountPicker } from './AccountPicker';
import { useToast } from './Toast';
import { t as tr } from './i18n';
type Plan = {
id: string;
@@ -12,6 +13,9 @@ type Plan = {
enabled: boolean;
completed: boolean;
sourceId: string;
targetId: string | null;
received: string;
notes: string;
};
export function SchedulePanel({
accounts,
@@ -21,10 +25,24 @@ export function SchedulePanel({
refresh: () => Promise<void>;
}) {
const [rows, setRows] = useState<Plan[]>([]),
[open, setOpen] = useState(false),
[editor, setEditor] = useState<Plan | null | undefined>(undefined),
[busy, setBusy] = useState(false),
[error, setError] = useState(''),
[status, setStatus] = useState('');
const dialog = useRef<HTMLDialogElement>(null);
useToast(error, 'error');
useToast(status, 'success');
useEffect(() => {
if (editor !== undefined) dialog.current?.showModal();
}, [editor]);
function openEditor(plan: Plan | null) {
setError('');
setOperation(plan?.operation ?? 'expense');
setSource(plan?.sourceId ?? accounts[0]?.id ?? '');
setTarget(plan?.targetId ?? '');
setAmount(plan?.amount ?? '');
setEditor(plan);
}
const [operation, setOperation] = useState('expense'),
[source, setSource] = useState(''),
[target, setTarget] = useState(''),
@@ -35,6 +53,7 @@ export function SchedulePanel({
const same = sourceAccount?.currency === targetAccount?.currency;
useEffect(() => {
let active = true;
setRows([]);
void api<Plan[]>('/schedules')
.then((v) => {
if (active) setRows(v);
@@ -69,8 +88,8 @@ export function SchedulePanel({
{tr('打开账户页或点击执行时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。')}
</p>
<div className="actions">
<button type="button" disabled={busy || !accounts.length} onClick={() => setOpen(!open)}>
{tr(open ? '取消' : '添加计划')}
<button type="button" disabled={busy || !accounts.length} onClick={() => openEditor(null)}>
{tr('添加计划')}
</button>
<button
disabled={busy}
@@ -93,104 +112,149 @@ export function SchedulePanel({
{tr('执行到期计划')}
</button>
</div>
{open && (
<form
onSubmit={(e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
void act(async () => {
await api('/schedules', 'POST', {
...data,
operation,
sourceId,
targetId: operation === 'transfer' ? target : null,
received: operation === 'transfer' ? (same ? amount : data.received) : '0',
intervalDays: Number(data.intervalDays),
});
setOpen(false);
});
{editor !== undefined && (
<dialog
ref={dialog}
className="picker-dialog schedule-dialog"
aria-label={tr(editor ? '修改计划' : '添加计划')}
onCancel={(e) => {
if (busy) e.preventDefault();
else setEditor(undefined);
}}
onClose={() => setEditor(undefined)}
>
<label>
{tr('计划名称')}
<input name="name" required maxLength={100} />
</label>
<label>
{tr('操作')}
<select value={operation} onChange={(e) => setOperation(e.target.value)}>
<option value="expense">{tr('支出')}</option>
<option value="transfer">{tr('转账')}</option>
</select>
</label>
<label>
{tr('付款账户')}
<AccountPicker
name="sourceId"
accounts={accounts}
value={sourceId}
onChange={(id) => {
setSource(id);
if (target === id) setTarget('');
}}
/>
</label>
{operation === 'transfer' && (
<div className="panel-title">
<h2>{tr(editor ? '修改计划' : '添加计划')}</h2>
<button
type="button"
className="icon"
aria-label={tr('关闭')}
disabled={busy}
onClick={() => setEditor(undefined)}
>
×
</button>
</div>
<form
key={editor?.id ?? 'new'}
onSubmit={(e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
void act(async () => {
await api(
editor ? '/schedules/' + editor.id : '/schedules',
editor ? 'PUT' : 'POST',
{
...data,
operation,
sourceId,
targetId: operation === 'transfer' ? target : null,
received: operation === 'transfer' ? (same ? amount : data.received) : '0',
intervalDays: Number(data.intervalDays),
},
);
setEditor(undefined);
setStatus(tr('计划已保存'));
});
}}
>
<label>
{tr('转入账户')}
{tr('计划名称')}
<input name="name" required maxLength={100} defaultValue={editor?.name} />
</label>
<label>
{tr('操作')}
<select value={operation} onChange={(e) => setOperation(e.target.value)}>
<option value="expense">{tr('支出')}</option>
<option value="transfer">{tr('转账')}</option>
</select>
</label>
<label>
{tr('付款账户')}
<AccountPicker
name="targetId"
name="sourceId"
accounts={accounts}
value={target}
onChange={setTarget}
excludeId={sourceId}
value={sourceId}
onChange={(id) => {
setSource(id);
if (target === id) setTarget('');
}}
/>
</label>
)}
<label>
{tr('金额')} ({sourceAccount?.currency})
<input
name="amount"
required
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
/>
</label>
{operation === 'transfer' && !same && (
{operation === 'transfer' && (
<label>
{tr('转入账户')}
<AccountPicker
name="targetId"
accounts={accounts}
value={target}
onChange={setTarget}
excludeId={sourceId}
/>
</label>
)}
<label>
{tr('实际到账')} ({targetAccount?.currency})
{tr('金额')} ({sourceAccount?.currency})
<input
name="received"
name="amount"
required
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
/>
</label>
)}
<label>
{tr('首次执行时间')}
<input name="nextAt" type="datetime-local" required defaultValue={nowMinute()} />
</label>
<label>
{tr('重复间隔(天,0为仅一次)')}
<input
name="intervalDays"
type="number"
min="0"
max="3650"
required
defaultValue="30"
/>
</label>
<label>
{tr('备注')}
<textarea name="notes" maxLength={1800} />
</label>
<button className="primary" disabled={busy || (operation === 'transfer' && !target)}>
{tr('保存计划')}
</button>
</form>
{operation === 'transfer' && !same && (
<label>
{tr('实际到账')} ({targetAccount?.currency})
<input
name="received"
defaultValue={editor?.received}
required
inputMode="decimal"
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
/>
</label>
)}
<label>
{tr(editor ? '下次执行时间' : '首次执行时间')}
<input
name="nextAt"
type="datetime-local"
required
defaultValue={editor?.nextAt ?? nowMinute()}
/>
</label>
<label>
{tr('重复间隔(天,0为仅一次)')}
<input
name="intervalDays"
type="number"
min="0"
max="3650"
required
defaultValue={editor?.intervalDays ?? 30}
/>
</label>
<label>
{tr('备注')}
<textarea
className="note-input"
name="notes"
maxLength={2000}
defaultValue={editor?.notes}
/>
</label>
<div className="actions">
<button type="button" disabled={busy} onClick={() => setEditor(undefined)}>
{tr('取消')}
</button>
<button className="primary" disabled={busy || (operation === 'transfer' && !target)}>
{tr('保存计划')}
</button>
</div>
</form>
</dialog>
)}
{rows.map((p) => (
<div className="schedule-row" key={p.id}>
@@ -205,6 +269,17 @@ export function SchedulePanel({
{tr(p.completed ? '已完成' : p.enabled ? '启用' : '已暂停')}
</small>
</span>
<button
disabled={
busy ||
p.completed ||
!accounts.some((a) => a.id === p.sourceId) ||
(!!p.targetId && !accounts.some((a) => a.id === p.targetId))
}
onClick={() => openEditor(p)}
>
{tr('修改计划')}
</button>
<button
disabled={busy || p.completed}
onClick={() =>
@@ -221,12 +296,6 @@ export function SchedulePanel({
</button>
</div>
))}
{error && (
<p role="alert" className="notice error">
{error}
</p>
)}
{status && <p role="status">{status}</p>}
</section>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { t as tr } from './i18n';
type Kind = 'success' | 'error';
type Notice = { id: number; message: string; kind: Kind };
let sequence = 0;
const listeners = new Set<(notice: Notice) => void>();
export function useToast(message: string, kind: Kind) {
useEffect(() => {
if (message) {
const notice = { id: ++sequence, message, kind };
listeners.forEach((fn) => fn(notice));
}
}, [message, kind]);
}
function ToastItem({ notice, remove }: { notice: Notice; remove: (id: number) => void }) {
useEffect(() => {
const timer = setTimeout(() => remove(notice.id), notice.kind === 'error' ? 12000 : 6000);
return () => clearTimeout(timer);
}, [notice, remove]);
return (
<div className={'toast ' + notice.kind} 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>
</div>
);
}
export function ToastViewport() {
const [notices, setNotices] = useState<Notice[]>([]);
const panel = useRef<HTMLDivElement>(null);
const [host, setHost] = useState<HTMLElement>(document.body);
useEffect(() => {
// A native modal makes content outside it inert. Keep the live notification
// and its dismiss control within the current dialog, while the popover
// renders them above the dialog in the browser's top layer.
const updateHost = () => {
const dialogs = document.querySelectorAll<HTMLElement>(
'dialog[open], [role="dialog"][aria-modal="true"]',
);
const next = dialogs.item(dialogs.length - 1) ?? document.body;
setHost((current) => (current === next ? current : next));
};
const observer = new MutationObserver(updateHost);
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['open'],
});
updateHost();
return () => observer.disconnect();
}, []);
const remove = useRef((id: number) =>
setNotices((rows) => rows.filter((n) => n.id !== id)),
).current;
useEffect(() => {
const receive = (n: Notice) =>
setNotices((rows) => [...rows.filter((v) => v.message !== n.message).slice(-2), n]);
listeners.add(receive);
return () => {
listeners.delete(receive);
};
}, []);
useEffect(() => {
const el = panel.current;
if (!el) return;
if (notices.length) {
el.hidePopover?.();
el.showPopover?.();
} else el.hidePopover?.();
}, [notices, host]);
return createPortal(
<div ref={panel} popover="manual" className="toast-viewport" aria-label={tr('操作提示')}>
{notices.map((notice) => (
<ToastItem key={notice.id} notice={notice} remove={remove} />
))}
</div>,
host,
);
}
+4
View File
@@ -24,6 +24,10 @@ export type User = {
showNotes: boolean;
idleMinutes: number;
accountGroupOrder?: string[];
sessionHours?: number;
requireHiddenPassword?: boolean;
overviewCards?: string[];
sessionExpiresAt?: string;
revealed?: boolean;
revealUntil?: string | null;
lastActivity?: string;
+44 -2
View File
@@ -374,7 +374,7 @@
"执行到期计划": "Run due plans",
"计划名称": "Plan name",
"支出": "Expense",
"转账": "Transfer",
"转账": "Transfer between accounts",
"金额": "Amount",
"实际到账": "Amount received",
"首次执行时间": "First payment time",
@@ -477,5 +477,47 @@
"拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。": "Drag a handle to reorder, or focus it and use the arrow keys. Save to apply the order to accounts and account selection.",
"账户分组顺序": "Account group order",
"拖动分组": "Drag group",
"已取消拖拽": "Drag cancelled"
"已取消拖拽": "Drag cancelled",
"常规设置": "General",
"显示与菜单": "Display and navigation",
"登录与隐私": "Login and privacy",
"导航菜单": "Navigation",
"设置已保存": "Settings saved",
"总览显示卡片": "Overview cards",
"选择资产总览需要显示的卡片,数据和记录仍会保留。": "Choose the cards to show on your overview. Your data and history are retained.",
"登录有效期(小时)": "Session lifetime (hours)",
"范围 1 小时至 30 天(720 小时)。保存后当前登录重新计时,其他会话沿用原到期时间;后续登录使用新设置。": "From 1 hour to 30 days (720 hours). Saving restarts the current session timer. Other sessions keep their expiry; future logins use this setting.",
"当前会话到期:": "Current session expires: ",
"查看隐藏项目需要密码": "Require a password to reveal hidden items",
"关闭后,已登录账号可直接显示隐藏项目。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏项目。": "When disabled, a signed-in account can reveal hidden items directly for 5 minutes. You can lock them sooner. Saving this setting locks hidden items in all sessions.",
"隐藏项目已显示": "Hidden items revealed",
"显示隐藏项目": "Reveal hidden items",
"当前包含隐藏项目 · 5 分钟后自动锁定": "Hidden items included · Locks automatically in 5 minutes",
"尚未选择总览卡片": "No overview cards selected",
"设置显示卡片": "Choose overview cards",
"修改计划": "Edit plan",
"下次执行时间": "Next execution time",
"计划已保存": "Plan saved",
"操作提示": "Notifications",
"快速转账": "Quick transfer",
"重选": "Change",
"按当前时间记录,手续费为 0。跨币种请填写实际到账金额。": "Recorded at the current time, with zero fees. Enter the actual amount received for a currency conversion.",
"关闭快速转账": "Close quick transfer",
"点击账户卡片选择转出账户": "Click an account card to choose the sending account",
"转出账户:": "Sending account: ",
"点击另一张账户卡片选择转入账户": "Click a different account card to choose the receiving account",
"重新选择": "Start over",
"快速转账中:依次点击转出和转入账户卡片。": "Quick transfer: click the sending account card, then the receiving account card.",
"当前包含隐藏资产 · 验证 5 分钟后自动锁定": "hidden assets included · Access locks automatically after 5 minutes",
"隐藏资产已锁定": "hidden assets locked",
"锁定隐藏资产": "Lock hidden assets",
"隐藏资产": "Hidden item",
"暂无转账记录。": "No transfers yet.",
"清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏资产);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。": "Clear all accounts, assets, debts, history, private icons and exchange rates, including hidden assets. Your login, settings and published shared icons remain. Download and save a backup first.",
"隐藏资产已解锁 5 分钟": "hidden assets unlocked for 5 minutes",
"查看隐藏资产需要密码": "Require a password to reveal hidden assets",
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "When disabled, a signed-in account can reveal hidden assets directly for 5 minutes. You can lock them sooner. Saving this setting locks hidden assets in all sessions.",
"隐藏资产已显示": "hidden assets revealed",
"显示隐藏资产": "Reveal hidden assets",
"当前包含隐藏资产 · 5 分钟后自动锁定": "hidden assets included · Locks automatically in 5 minutes"
}
+43 -1
View File
@@ -477,5 +477,47 @@
"拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。": "拖動手柄調整順序,也可聚焦手柄後使用方向鍵。儲存後同步到帳戶頁和選擇彈窗。",
"账户分组顺序": "帳戶分組順序",
"拖动分组": "拖動分組",
"已取消拖拽": "已取消拖曳"
"已取消拖拽": "已取消拖曳",
"常规设置": "一般設定",
"显示与菜单": "顯示與選單",
"登录与隐私": "登入與私隱",
"导航菜单": "導覽選單",
"设置已保存": "設定已儲存",
"总览显示卡片": "總覽顯示卡片",
"选择资产总览需要显示的卡片,数据和记录仍会保留。": "選擇資產總覽需要顯示的卡片,資料和記錄仍會保留。",
"登录有效期(小时)": "登入有效期(小時)",
"范围 1 小时至 30 天(720 小时)。保存后当前登录重新计时,其他会话沿用原到期时间;后续登录使用新设置。": "範圍 1 小時至 30 天(720 小時)。儲存後目前登入重新計時,其他會話沿用原到期時間;後續登入使用新設定。",
"当前会话到期:": "目前會話到期:",
"查看隐藏项目需要密码": "查看隱藏項目需要密碼",
"关闭后,已登录账号可直接显示隐藏项目。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏项目。": "關閉後,已登入帳號可直接顯示隱藏項目。每次顯示持續 5 分鐘,也可手動鎖定;儲存此設定會鎖定所有會話的隱藏項目。",
"隐藏项目已显示": "隱藏項目已顯示",
"显示隐藏项目": "顯示隱藏項目",
"当前包含隐藏项目 · 5 分钟后自动锁定": "目前包含隱藏項目 · 5 分鐘後自動鎖定",
"尚未选择总览卡片": "尚未選擇總覽卡片",
"设置显示卡片": "設定顯示卡片",
"修改计划": "修改計劃",
"下次执行时间": "下次執行時間",
"计划已保存": "計劃已儲存",
"操作提示": "操作提示",
"快速转账": "快速轉賬",
"重选": "重選",
"按当前时间记录,手续费为 0。跨币种请填写实际到账金额。": "按目前時間記錄,手續費為 0。跨幣種請填寫實際到賬金額。",
"关闭快速转账": "關閉快速轉賬",
"点击账户卡片选择转出账户": "點擊賬戶卡片選擇轉出賬戶",
"转出账户:": "轉出賬戶:",
"点击另一张账户卡片选择转入账户": "點擊另一張賬戶卡片選擇轉入賬戶",
"重新选择": "重新選擇",
"快速转账中:依次点击转出和转入账户卡片。": "快速轉賬中:依次點擊轉出和轉入賬戶卡片。",
"当前包含隐藏资产 · 验证 5 分钟后自动锁定": "當前包含隱藏資產 · 驗證 5 分鐘後自動鎖定",
"隐藏资产已锁定": "隱藏資產已鎖定",
"锁定隐藏资产": "鎖定隱藏資產",
"隐藏资产": "隱藏資產",
"暂无转账记录。": "暫無轉賬記錄。",
"清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏资产);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。": "清除本賬號全部賬戶、資產、債務、歷史、私有圖標和匯率(包括隱藏資產);保留登錄賬號、個人設置和已發佈的共享圖標。請先下載備份並確認文件已保存。",
"隐藏资产已解锁 5 分钟": "隱藏資產已解鎖 5 分鐘",
"查看隐藏资产需要密码": "查看隱藏資產需要密碼",
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "關閉後,已登入帳號可直接顯示隱藏資產。每次顯示持續 5 分鐘,也可手動鎖定;儲存此設定會鎖定所有會話的隱藏資產。",
"隐藏资产已显示": "隱藏資產已顯示",
"显示隐藏资产": "顯示隱藏資產",
"当前包含隐藏资产 · 5 分钟后自动锁定": "目前包含隱藏資產 · 5 分鐘後自動鎖定"
}
+2
View File
@@ -1,8 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { ToastViewport } from './Toast';
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ToastViewport />
<App />
</React.StrictMode>,
);
+11
View File
@@ -0,0 +1,11 @@
export const overviewCardLabels = {
assets: '总资产',
liabilities: '总负债',
net: '净资产',
trend: '净资产轨迹',
composition: '资产与负债组成',
recent: '最近变化',
attribution: '变化归因',
};
export type OverviewCard = keyof typeof overviewCardLabels;
export const allOverviewCards = Object.keys(overviewCardLabels) as OverviewCard[];
+249
View File
@@ -1578,3 +1578,252 @@ textarea,
font-size: 14px;
color: #456352;
}
.settings-tabs {
display: flex;
flex-direction: row;
justify-content: flex-start;
height: auto;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
padding: 12px;
background: #fff;
border: 1px solid var(--line);
border-radius: 14px;
}
.settings-tabs button {
flex: 0 0 auto;
flex-direction: row;
align-items: center;
border-radius: 8px;
font-size: 13px;
border: 0;
background: transparent;
padding: 10px 14px;
}
.settings-tabs button[aria-current='page'] {
background: #e9f3ed;
color: #234c3b;
font-weight: 600;
}
.menu-preferences {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0 24px;
margin-bottom: 20px;
}
.metrics {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.dashboard-grid.single-card {
grid-template-columns: minmax(0, 1fr);
}
.schedule-dialog {
width: min(620px, calc(100vw - 32px));
}
.schedule-dialog form {
display: grid;
gap: 14px;
}
.schedule-dialog label {
display: grid;
gap: 6px;
}
.schedule-dialog .actions {
justify-content: flex-end;
}
.toast-viewport {
position: fixed;
inset: 20px 20px auto auto;
margin: 0;
width: min(380px, calc(100vw - 32px));
max-height: calc(100vh - 40px);
overflow-y: auto;
padding: 0;
border: 0;
background: transparent;
color: var(--ink);
}
.toast-viewport:popover-open {
display: grid;
gap: 10px;
}
.toast-viewport::backdrop {
display: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border: 1px solid #b9d3c3;
border-radius: 12px;
background: #f5fcf7;
box-shadow: 0 8px 32px #163c2526;
animation: toast-enter 0.18s ease-out;
}
.toast.error {
border-color: #e7b9b9;
background: #fff7f7;
}
.toast p {
flex: 1;
margin: 0;
overflow-wrap: anywhere;
line-height: 1.6;
font-size: 14px;
}
.toast-symbol {
display: grid;
place-items: center;
border-radius: 50%;
background: #d7eddf;
color: #246240;
width: 24px;
height: 24px;
flex-shrink: 0;
font-weight: 700;
}
.toast.error .toast-symbol {
background: #f8dcdc;
color: #b42318;
}
.toast button {
padding: 0;
width: 24px;
height: 24px;
min-height: 24px;
flex-shrink: 0;
font-size: 20px;
}
@keyframes toast-enter {
from {
opacity: 0;
transform: translateX(12px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation: none;
}
}
@media (max-width: 600px) {
.metrics {
grid-template-columns: 1fr;
}
.toast-viewport {
inset: 12px 16px auto auto;
}
.settings-tabs button {
flex: 1 0 40%;
}
}
.schedule-dialog form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.schedule-dialog label:has(textarea),
.schedule-dialog form > .actions {
grid-column: 1/-1;
}
.schedule-dialog textarea {
min-height: 64px;
}
@media (max-width: 520px) {
.schedule-dialog form {
grid-template-columns: 1fr;
}
}
.quick-transfer-widget {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
z-index: 30;
max-width: calc(100vw - 32px);
}
.quick-transfer-toggle {
box-shadow: 0 5px 20px #17453130;
}
.quick-transfer-card {
width: 320px;
max-width: 100%;
max-height: 70vh;
overflow-y: auto;
padding: 18px;
background: #fff;
border: 1px solid #bed2c4;
border-radius: 16px;
box-shadow: 0 10px 36px #17453125;
}
.quick-transfer-card h2 {
font-size: 16px;
margin: 0;
}
.quick-transfer-card form,
.quick-transfer-card label {
display: grid;
gap: 10px;
}
.quick-transfer-pair {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
}
.quick-transfer-pair > div {
display: grid;
gap: 4px;
flex: 1;
min-width: 0;
}
.quick-transfer-pair strong {
overflow-wrap: anywhere;
font-size: 14px;
}
.quick-transfer-pair span,
.quick-transfer-card .muted {
font-size: 11px;
}
.position-card.quick-source {
border-color: #277b61;
box-shadow: 0 0 0 2px #277b6133;
}
.position-card.quick-target {
border-color: #587fa8;
box-shadow: 0 0 0 2px #587fa833;
}
@media (max-width: 600px) {
.quick-transfer-widget {
right: 12px;
}
.quick-transfer-card {
width: 230px;
padding: 12px;
}
}
.position-grid.quick-transfer-select {
padding-right: 340px;
}
@media (max-width: 600px) {
.quick-transfer-widget {
top: auto;
bottom: 80px;
transform: none;
}
.quick-transfer-card {
width: calc(100vw - 24px);
max-height: 36vh;
}
.position-grid.quick-transfer-select {
padding-right: 0;
padding-bottom: 300px;
}
}