feat: persist account group display order and backup preferences
This commit is contained in:
1 parent
20643b366f
commit
1af5edbadc
28 files changed
+379
-47
No files matched your search
@@ -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
@@ -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({
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { createContext } from 'react';
|
||||
export const GroupOrderContext = createContext<string[]>([]);
|
||||
@@ -23,6 +23,7 @@ export type User = {
|
||||
hiddenMenus: string[];
|
||||
showNotes: boolean;
|
||||
idleMinutes: number;
|
||||
accountGroupOrder?: string[];
|
||||
revealed?: boolean;
|
||||
revealUntil?: string | null;
|
||||
lastActivity?: string;
|
||||
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -467,5 +467,11 @@
|
||||
"溢缴存款": "溢繳存款",
|
||||
"信用卡、贷款账户:负数为欠款,正数为溢缴存款;其余账户按实际余额填写。": "信用卡、貸款賬戶:負數為欠款,正數為溢繳存款;其餘賬戶按實際餘額填寫。",
|
||||
"负数表示欠款,正数表示溢缴存款;溢缴部分计入总资产。": "負數表示欠款,正數表示溢繳存款;溢繳部分計入總資產。",
|
||||
"仅账户支持负余额": "僅賬戶支援負餘額"
|
||||
"仅账户支持负余额": "僅賬戶支援負餘額",
|
||||
"分组排序": "分組排序",
|
||||
"上移": "上移",
|
||||
"下移": "下移",
|
||||
"分组顺序已保存": "分組順序已儲存",
|
||||
"分组不可重复": "分組不可重複",
|
||||
"调整账户分组顺序,账户页和选择弹窗会同步使用。": "調整帳戶分組順序,帳戶頁和選擇彈窗會同步使用。"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user