feat: persist account group display order and backup preferences

This commit is contained in:
陈煜 committed 2026-10-03 12:29:35 +08:00
1 parent 20643b366f
commit 1af5edbadc
28 files changed
+379 -47

No files matched your search

+12 -4
View File
@@ -1,7 +1,9 @@
import { useId, useRef, useState } from 'react';
import { useId, useRef, useState, useContext } from 'react';
import { type Position, money, positionAmount } from './api';
import { t as tr } from './i18n';
import { iconUrl } from './IconLibrary';
import { GroupOrderContext } from './GroupOrderContext';
import { orderedGroups } from './group-order';
export function AccountPicker({
name,
accounts,
@@ -25,7 +27,13 @@ export function AccountPicker({
p.id !== excludeId &&
`${p.name} ${p.groupName || ''} ${p.currency}`.toLowerCase().includes(query.toLowerCase()),
);
const groups = grouped ? [...new Set(rows.map((p) => p.groupName || '未分组'))] : ['全部账户'];
const order = useContext(GroupOrderContext);
const groups = grouped
? orderedGroups(
rows.map((p) => p.groupName || ''),
order,
)
: ['全部账户'];
return (
<div className="account-picker">
<input name={name} type="hidden" value={value} />
@@ -61,9 +69,9 @@ export function AccountPicker({
</div>
{groups.map((group) => (
<section key={group}>
<h3>{tr(group)}</h3>
<h3>{grouped ? group || tr('未分组') : tr('全部账户')}</h3>
{rows
.filter((p) => !grouped || (p.groupName || '未分组') === group)
.filter((p) => !grouped || (p.groupName || '') === group)
.map((p) => (
<button
type="button"
+102 -24
View File
@@ -1,5 +1,5 @@
import { t as tr, useLocale, LanguagePicker } from './i18n';
import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react';
import { useState, useEffect, useRef, createElement, type FormEvent, type ReactNode } from 'react';
import {
LayoutDashboard,
CalendarDays,
@@ -38,6 +38,8 @@ import {
type PageResult,
} from './api';
import './style.css';
import { GroupOrderContext } from './GroupOrderContext';
import { orderedGroups, mergeGroupOrder } from './group-order';
import { Calendar } from './Calendar';
import { SchedulePanel } from './SchedulePanel';
import { IconPicker } from './IconPicker';
@@ -225,6 +227,7 @@ export default function App() {
h?: History;
transfer?: Transfer;
operation?: DebtOperation;
groups?: string[];
} | null>(null),
[busy, setBusy] = useState(false),
[loading, setLoading] = useState(false),
@@ -232,7 +235,7 @@ export default function App() {
[success, setSuccess] = useState('');
const [calendarRefresh, setCalendarRefresh] = useState(0);
const [quickMode, setQuickMode] = useState(false),
[accountGroup, setAccountGroup] = useState('');
[accountGroup, setAccountGroup] = useState<string | null>(null);
const [fxStatus, setFxStatus] = useState<{
state: string;
message: string;
@@ -284,6 +287,7 @@ export default function App() {
setHistoryCursor(null);
setTransferCursor(null);
setFxStatus(null);
setAccountGroup(null);
setModal(null);
setSelected(null);
setBackup(null);
@@ -823,7 +827,14 @@ export default function App() {
['settings', Settings],
] as const;
const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key));
return (
const groups = orderedGroups(
positions.filter((p) => p.kind === 'account').map((p) => p.groupName || ''),
user.accountGroupOrder,
);
const groupRanks = new Map(groups.map((g, index) => [g, index]));
return createElement(
GroupOrderContext.Provider,
{ value: user.accountGroupOrder || [] },
<div className={user.showNotes === false ? 'app notes-hidden' : 'app'}>
{
<aside className="sidebar">
@@ -1305,22 +1316,27 @@ export default function App() {
</button>
<select
aria-label={tr('账户分组')}
value={accountGroup}
onChange={(e) => setAccountGroup(e.target.value)}
value={accountGroup === null ? 'all' : JSON.stringify(accountGroup)}
onChange={(e) =>
setAccountGroup(
e.target.value === 'all' ? null : JSON.parse(e.target.value),
)
}
>
<option value="">{tr('全部账户')}</option>
{[
...new Set(
positions
.filter((p) => p.kind === 'account')
.map((p) => p.groupName || '未分组'),
),
].map((g) => (
<option key={g} value={g}>
{tr(g)}
<option value="all">{tr('全部账户')}</option>
{groups.map((g) => (
<option key={g} value={JSON.stringify(g)}>
{g || tr('未分组')}
</option>
))}
</select>
<button
className="secondary"
disabled={busy || groups.length < 2}
onClick={() => setModal({ kind: 'group-order', groups: [...groups] })}
>
{tr('分组排序')}
</button>
</div>
{quickMode && <p className="muted">{tr('点击账户卡片,直接填写当前余额。')}</p>}
</>
@@ -1332,8 +1348,14 @@ export default function App() {
(p) =>
p.kind === page &&
(page !== 'account' ||
!accountGroup ||
(p.groupName || '未分组') === accountGroup),
accountGroup === null ||
(p.groupName || '') === accountGroup),
)
.sort((a, b) =>
page === 'account'
? (groupRanks.get(a.groupName || '') || 0) -
(groupRanks.get(b.groupName || '') || 0)
: 0,
)
.map((p) => (
<button
@@ -2048,6 +2070,64 @@ export default function App() {
)}
</Modal>
)}
{modal?.kind === 'group-order' && modal.groups && (
<Modal title={tr('分组排序')} close={() => !busy && setModal(null)}>
<p className="muted">{tr('调整账户分组顺序,账户页和选择弹窗会同步使用。')}</p>
<ol className="group-order-list">
{modal.groups.map((g, index) => (
<li key={g}>
<span>{g || tr('未分组')}</span>
<div>
{[-1, 1].map((offset) => (
<button
key={offset}
type="button"
className="secondary"
disabled={
busy || index + offset < 0 || index + offset >= modal.groups!.length
}
aria-label={(g || tr('未分组')) + ' ' + tr(offset < 0 ? '上移' : '下移')}
onClick={() =>
setModal((current) => {
if (!current?.groups) return current;
const next = [...current.groups];
[next[index], next[index + offset]] = [next[index + offset], next[index]];
return { ...current, groups: next };
})
}
>
{tr(offset < 0 ? '上移' : '下移')}
</button>
))}
</div>
</li>
))}
</ol>
<div className="modal-actions">
<button className="secondary" disabled={busy} onClick={() => setModal(null)}>
{tr('取消')}
</button>
<button
className="primary"
disabled={busy}
onClick={() =>
void act(
() =>
api('/settings', 'PATCH', {
accountGroupOrder: mergeGroupOrder(
modal.groups!,
user.accountGroupOrder || [],
),
}),
tr('分组顺序已保存'),
)
}
>
{tr(busy ? '正在保存…' : '保存')}
</button>
</div>
</Modal>
)}
{modal?.kind === 'delete-record' && (
<Modal
title={tr('删除记录')}
@@ -2170,7 +2250,7 @@ export default function App() {
</form>
</Modal>
)}
{modal && !['transfer-edit', 'delete-record'].includes(modal.kind) && (
{modal && !['transfer-edit', 'delete-record', 'group-order'].includes(modal.kind) && (
<Modal
title={
modal.kind === 'debt-payment'
@@ -2330,11 +2410,9 @@ export default function App() {
placeholder={tr('例如:日常、消费、理财')}
/>
<datalist id="account-groups">
{[...new Set(positions.map((p) => p.groupName).filter(Boolean))].map(
(g) => (
<option key={g} value={g} />
),
)}
{groups.filter(Boolean).map((g) => (
<option key={g} value={g} />
))}
</datalist>
</Field>
</>
@@ -2524,7 +2602,7 @@ export default function App() {
)}
</Modal>
)}
</div>
</div>,
);
}
function HistoryTable({
+2
View File
@@ -0,0 +1,2 @@
import { createContext } from 'react';
export const GroupOrderContext = createContext<string[]>([]);
+1
View File
@@ -23,6 +23,7 @@ export type User = {
hiddenMenus: string[];
showNotes: boolean;
idleMinutes: number;
accountGroupOrder?: string[];
revealed?: boolean;
revealUntil?: string | null;
lastActivity?: string;
+8
View File
@@ -0,0 +1,8 @@
// Only present groups are displayed; saved groups absent from this view stay private.
export function orderedGroups(groups: string[], order: string[] = []) {
const present = new Set(groups);
return [...order.filter((g) => present.delete(g)), ...present];
}
export function mergeGroupOrder(visible: string[], saved: string[]) {
return [...visible, ...saved.filter((g) => !visible.includes(g))];
}
+7 -1
View File
@@ -467,5 +467,11 @@
"溢缴存款": "Credit balance",
"信用卡、贷款账户:负数为欠款,正数为溢缴存款;其余账户按实际余额填写。": "Credit cards and loan accounts: negative means debt, positive means credit. Enter the actual balance for other accounts.",
"负数表示欠款,正数表示溢缴存款;溢缴部分计入总资产。": "Negative means debt, positive means credit. Credit balances count towards total assets.",
"仅账户支持负余额": "Only accounts support negative balances."
"仅账户支持负余额": "Only accounts support negative balances.",
"分组排序": "Group order",
"上移": "Move up",
"下移": "Move down",
"分组顺序已保存": "Group order saved",
"分组不可重复": "Groups must be unique",
"调整账户分组顺序,账户页和选择弹窗会同步使用。": "Change the group order for both accounts and account selection."
}
+7 -1
View File
@@ -467,5 +467,11 @@
"溢缴存款": "溢繳存款",
"信用卡、贷款账户:负数为欠款,正数为溢缴存款;其余账户按实际余额填写。": "信用卡、貸款賬戶:負數為欠款,正數為溢繳存款;其餘賬戶按實際餘額填寫。",
"负数表示欠款,正数表示溢缴存款;溢缴部分计入总资产。": "負數表示欠款,正數表示溢繳存款;溢繳部分計入總資產。",
"仅账户支持负余额": "僅賬戶支援負餘額"
"仅账户支持负余额": "僅賬戶支援負餘額",
"分组排序": "分組排序",
"上移": "上移",
"下移": "下移",
"分组顺序已保存": "分組順序已儲存",
"分组不可重复": "分組不可重複",
"调整账户分组顺序,账户页和选择弹窗会同步使用。": "調整帳戶分組順序,帳戶頁和選擇彈窗會同步使用。"
}
+24
View File
@@ -1494,3 +1494,27 @@ textarea,
.record-actions button + button {
margin-left: 16px;
}
.group-order-list {
list-style: none;
padding: 0;
max-height: 55vh;
overflow-y: auto;
}
.group-order-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #e5e9e5;
}
.group-order-list li > span {
min-width: 0;
overflow-wrap: anywhere;
}
.group-order-list li > div {
display: flex;
gap: 6px;
flex-shrink: 0;
}