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

+1 -1
View File
@@ -9,7 +9,7 @@
"db:generate": "prisma generate",
"db:migrate": "node scripts/database.cjs deploy",
"db:status": "node scripts/database.cjs status",
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts",
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts",
"test:performance": "tsx scripts/performance.ts after",
"icons:seed": "node scripts/seed-icons.cjs"
},
@@ -0,0 +1 @@
ALTER TABLE `User` ADD COLUMN `accountGroupOrder` JSON NULL COMMENT '账户分组显示顺序;空值表示沿用默认顺序';
+2
View File
@@ -17,6 +17,8 @@ model User {
baseCurrency String @default("CNY") @db.Char(3)
/// 隐藏菜单标识列表
hiddenMenus String @default("") @db.VarChar(128)
/// 账户分组显示顺序;空值表示沿用默认顺序
accountGroupOrder Json?
/// 是否显示备注
showNotes Boolean @default(true)
/// 无操作退出分钟数,0 表示关闭
+2
View File
@@ -170,11 +170,13 @@ export class AuthController {
hiddenMenus: true,
showNotes: true,
idleMinutes: true,
accountGroupOrder: true,
},
});
const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } });
return {
...user,
accountGroupOrder: user.accountGroupOrder || [],
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
revealed: req.revealed,
revealUntil: session.revealUntil,
+11 -1
View File
@@ -35,6 +35,7 @@ import {
revisionInput,
rateInput,
hiddenMenus,
accountGroupOrder,
transferInput,
} from './validation';
import { createHash } from 'node:crypto';
@@ -77,6 +78,7 @@ const backupSchema = z
.object({
showSidebar: z.boolean().optional(),
hiddenMenus: hiddenMenus.optional(),
accountGroupOrder: accountGroupOrder.optional(),
showNotes: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440),
})
@@ -301,7 +303,13 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
const [user, ps, rates] = await Promise.all([
client.user.findUniqueOrThrow({
where: { id: userId },
select: { baseCurrency: true, hiddenMenus: true, showNotes: true, idleMinutes: true },
select: {
baseCurrency: true,
hiddenMenus: true,
showNotes: true,
idleMinutes: true,
accountGroupOrder: true,
},
}),
client.position.findMany({
where: { userId },
@@ -360,6 +368,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
showNotes: user.showNotes,
idleMinutes: user.idleMinutes,
accountGroupOrder: accountGroupOrder.parse(user.accountGroupOrder || []),
},
currencies: [
...new Set([
@@ -707,6 +716,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
idleMinutes: b.preferences?.idleMinutes,
hiddenMenus: b.preferences?.hiddenMenus?.join(','),
showNotes: b.preferences?.showNotes,
accountGroupOrder: b.preferences?.accountGroupOrder,
},
});
return { ok: true, positions: b.positions.length };
+2
View File
@@ -11,6 +11,7 @@ import {
transferInput,
currency,
hiddenMenus,
accountGroupOrder,
} from './validation';
export function setupOpenApi(app: INestApplication) {
const document = SwaggerModule.createDocument(
@@ -45,6 +46,7 @@ export function setupOpenApi(app: INestApplication) {
.object({
baseCurrency: currency.optional(),
hiddenMenus: hiddenMenus.optional(),
accountGroupOrder: accountGroupOrder.optional(),
showNotes: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440).optional(),
})
+4 -1
View File
@@ -13,7 +13,7 @@ import {
} from '@nestjs/common';
import { Database } from './database';
import { UserRequest } from './auth';
import { currency, date, rateValue, today, hiddenMenus } from './validation';
import { currency, date, rateValue, today, hiddenMenus, accountGroupOrder } from './validation';
import { z } from 'zod';
import Decimal from 'decimal.js';
// Fixed public request; no user currency choices, identifiers or amounts leave the server.
@@ -176,10 +176,12 @@ export class SettingsController {
hiddenMenus: true,
showNotes: true,
idleMinutes: true,
accountGroupOrder: true,
},
});
return {
...u,
accountGroupOrder: u.accountGroupOrder || [],
hiddenMenus: u.hiddenMenus.split(',').filter(Boolean),
lastActivity: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
.lastActivity,
@@ -203,6 +205,7 @@ export class SettingsController {
.object({
baseCurrency: currency.optional(),
hiddenMenus: hiddenMenus.optional(),
accountGroupOrder: accountGroupOrder.optional(),
showNotes: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440).optional(),
})
+5
View File
@@ -182,3 +182,8 @@ export const credentialChange = z
})
.strict()
.refine((v) => !!v.username || !!v.newPassword, '请填写新的账号或密码');
export const accountGroupOrder = z
.array(z.string().trim().max(60))
.max(1000)
.refine((v) => new Set(v).size === v.length, '分组不可重复');
+2
View File
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
import { BadRequestException } from '@nestjs/common';
import { z } from 'zod';
import type { Backup } from './backup';
import { accountGroupOrder } from './validation';
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
const files = [
@@ -149,6 +150,7 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
.object({
showSidebar: z.boolean().optional(),
hiddenMenus: z.array(z.string()).optional(),
accountGroupOrder: accountGroupOrder.optional(),
showNotes: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440),
})
+107
View File
@@ -0,0 +1,107 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
test('group order persists per user, validates input and survives ZIP and legacy imports', async () => {
const db = new PrismaClient(),
ids: string[] = [];
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
async function user() {
const u = await db.user.create({
data: { username: 'wp_groups_' + randomUUID(), passwordHash: 'unused' },
});
ids.push(u.id);
const token = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(token).digest('hex'),
userId: u.id,
expiresAt: new Date(Date.now() + 3600000),
},
});
return { id: u.id, cookie: 'wp_session=' + token };
}
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
method,
headers: {
Cookie: cookie,
Origin: origin,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, data: await res.json() };
}
try {
const a = await user(),
b = await user(),
c = await user();
assert.deepEqual((await call('/auth/me', a.cookie)).data.accountGroupOrder, []);
const order = ['理财', '', '未分组', '日常'];
assert.equal(
(await call('/settings', a.cookie, 'PATCH', { accountGroupOrder: order })).status,
200,
);
assert.deepEqual(
(await db.user.findUniqueOrThrow({ where: { id: a.id } })).accountGroupOrder,
order,
);
assert.deepEqual((await call('/auth/me', a.cookie)).data.accountGroupOrder, order);
assert.deepEqual((await call('/settings', a.cookie)).data.accountGroupOrder, order);
assert.deepEqual((await call('/settings', b.cookie)).data.accountGroupOrder, []);
for (const invalid of [['日常', '日常'], [' 日常 ', '日常'], [42], ['x'.repeat(61)]]) {
assert.equal(
(await call('/settings', a.cookie, 'PATCH', { accountGroupOrder: invalid })).status,
400,
);
}
assert.deepEqual((await call('/settings', a.cookie)).data.accountGroupOrder, order);
assert.equal(
(
await call('/positions', a.cookie, 'POST', {
name: 'Daily',
kind: 'account',
side: 'asset',
category: 'cash',
groupName: '日常',
currency: 'CNY',
amount: '20',
date: '2026-09-01T10:00',
})
).status,
201,
);
const zip = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
assert.equal(zip.status, 200);
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
assert.deepEqual(backup.preferences.accountGroupOrder, order);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.deepEqual((await call('/auth/me', b.cookie)).data.accountGroupOrder, order);
await call('/settings', b.cookie, 'PATCH', { accountGroupOrder: ['日常'] });
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
409,
);
assert.deepEqual((await call('/settings', b.cookie)).data.accountGroupOrder, ['日常']);
delete backup.preferences.accountGroupOrder;
assert.equal(
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.deepEqual((await call('/auth/me', c.cookie)).data.accountGroupOrder, []);
const comments: any[] = await db.$queryRawUnsafe(
"SELECT COLUMN_COMMENT AS comment FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'User' AND COLUMN_NAME = 'accountGroupOrder'",
);
assert.ok(comments[0].comment.includes('分组'));
} finally {
await db.user.deleteMany({ where: { id: { in: ids } } });
await db.$disconnect();
}
});
+20 -10
View File
@@ -120,7 +120,7 @@ test('negative balances are confined to accounts; group names and debt principal
category: 'bank',
kind: 'account',
side: 'asset',
amount: '-12.12345678',
amount: '-12.24681357',
currency: 'CNY',
date,
groupName: '日常',
@@ -159,9 +159,9 @@ test('trend starts at the first balance, retains valid zero days and handles an
assert.deepEqual(trend([p], [], 'CNY', '2026-09-01', '2026-09-14'), []);
});
test('debt signs and currency symbols preserve the original decimal strings', () => {
const p = { kind: 'debt', side: 'liability', amount: '9999999999999999.12345678' } as Position;
assert.equal(positionAmount(p), '-9999999999999999.12345678');
assert.equal(money(positionAmount(p), 'HKD'), 'HK$ -9,999,999,999,999,999.12345678');
const p = { kind: 'debt', side: 'liability', amount: '9999999999999999.24681357' } as Position;
assert.equal(positionAmount(p), '-9999999999999999.24681357');
assert.equal(money(positionAmount(p), 'HKD'), 'HK$ -9,999,999,999,999,999.24681357');
assert.equal(positionAmount({ ...p, amount: '0.00000000' }), '0.00000000');
assert.equal(positionAmount({ ...p, side: 'asset' }), p.amount);
});
@@ -348,14 +348,14 @@ test('liability account credit balances invert display and input without losing
const card = {
kind: 'account',
side: 'liability',
amount: '-9999999999999999.12345678',
amount: '-9999999999999999.24681357',
} as Position;
assert.equal(positionAmount(card), '9999999999999999.12345678');
assert.equal(positionAmount(card, '12.12345678'), '-12.12345678');
assert.equal(positionAmount(card), '9999999999999999.24681357');
assert.equal(positionAmount(card, '12.24681357'), '-12.24681357');
assert.equal(positionAmount(card, '-0.00000000'), '0.00000000');
assert.equal(accountInputAmount(card, '12.12345678'), '-12.12345678');
assert.equal(accountInputAmount(card, '-12.12345678'), '12.12345678');
assert.equal(accountInputAmount({ ...card, kind: 'debt' }, '12.12345678'), '12.12345678');
assert.equal(accountInputAmount(card, '12.24681357'), '-12.24681357');
assert.equal(accountInputAmount(card, '-12.24681357'), '12.24681357');
assert.equal(accountInputAmount({ ...card, kind: 'debt' }, '12.24681357'), '12.24681357');
});
test('credit balances count as assets across zero, retain FX attribution and do not offset other liabilities', () => {
const card: Holding = {
@@ -422,3 +422,13 @@ test('credit balances count as assets across zero, retain FX attribution and do
assert.equal(cashflowDelta('liability', 'balance', '100', '-25').toFixed(), '125');
assert.equal(cashflowDelta('liability', 'balance', '-25', '10').toFixed(), '-35');
});
test('group order keeps visible subsets stable and distinguishes unnamed and named groups', async () => {
const { orderedGroups, mergeGroupOrder } = await import('../../web/src/group-order');
assert.deepEqual(
orderedGroups(['日常', '', '未分组', '理财', '日常'], ['理财', '', '日常', '已隐藏']),
['理财', '', '日常', '未分组'],
);
assert.deepEqual(orderedGroups(['日常'], ['理财', '', '日常']), ['日常']);
assert.deepEqual(mergeGroupOrder(['日常', '理财'], ['理财', '隐藏']), ['日常', '理财', '隐藏']);
});
+9
View File
@@ -190,3 +190,12 @@ test('ZIP v7 restores groups and schedules while v6 remains readable', async ()
contents['manifest.json'] = JSON.stringify(manifest);
assert.equal(validateBackup(await readBackupZip(await archive(contents))).schedules, undefined);
});
test('ZIP settings retain group order while missing legacy order remains optional', async () => {
const b = empty();
b.preferences!.accountGroupOrder = ['理财', '', '未分组', '日常'];
const restored = validateBackup(await readBackupZip(await archive(packBackup(b))));
assert.deepEqual(restored.preferences!.accountGroupOrder, b.preferences!.accountGroupOrder);
const legacy = validateBackup(await readBackupZip(await archive(packBackup(empty()))));
assert.equal(legacy.preferences!.accountGroupOrder, undefined);
});
+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;
}
+4
View File
@@ -70,3 +70,7 @@
## 2026-10-03 负债账户溢缴存款
30 项单元测试、12 项真实 MySQL 集成检查、类型检查及构建通过。浏览器验证信用卡从欠款转为正数溢缴存款并计入总资产,余额历史与总览自动刷新;补充金额精度、趋势、日历、权限和备份恢复断言。无数据库结构变更。详见 [更新说明](update-credit-balance-2026-10-03.md)。
## 2026-10-03 分组排序
32 项单元测试、13 项真实 MySQL 集成检查、前后端类型检查及生产构建通过。浏览器确认上下移动、保存与刷新持久化,以及账户选择弹窗同序显示。14 个迁移状态最新,9 张表与 89 个字段均有注释;ZIP 新旧排序字段兼容及用户隔离已验证。详见 [更新说明](update-group-order-2026-10-03.md)。
+2
View File
@@ -47,3 +47,5 @@ User.hiddenMenus 保存受枚举校验的菜单键,settings 不可隐藏;Use
2026-10-03 修复:历史重放允许没有绝对余额锚点时从零计算增量,空历史项目金额为零;备份校验、转账和计划支出使用相同规则。账号凭据变更锁定用户行、验证当前密码、更新凭据并在同一事务撤销旧会话和创建新会话;无需新增字段。/auth/me 返回完整隐藏授权状态和会话时间。前端 mutation 更新数据版本,以当前页面重新读取数据,旧视图请求通过 generation 检查丢弃。见 [更新说明](update-2026-10-03.md)。
2026-10-03 负债账户溢缴更新:负债账户存储正数欠款、负数溢缴;前端显示和输入取相反符号。统计将负债账户的负余额按汇率转换为正资产,正欠款计入负债,两类分别汇总。独立资产和借入借出债务仍要求非负本金,旧数据和 API 金额含义保留。详见 [溢缴更新说明](update-credit-balance-2026-10-03.md)。
2026-10-03 分组排序:User.accountGroupOrder 保存每个用户的账户分组顺序,空值沿用当前默认顺序。前端共享排序上下文,账户卡片、筛选及所有 AccountPicker 使用同一顺序;只显示当前可见分组,保存保留未出现在当前视图的既有偏好。settings 与备份使用相同分组名称和唯一性验证。新增可选 JSON 字段通过带中文注释的增量迁移应用;JSON/ZIP 空空间恢复偏好,已有空间保留当前设置。见 [更新说明](update-group-order-2026-10-03.md)。
+6 -1
View File
@@ -1,6 +1,6 @@
{
"tableCount": 9,
"columnCount": 88,
"columnCount": 89,
"tables": [
{
"TABLE_NAME": "exchangerate",
@@ -479,6 +479,11 @@
"TABLE_NAME": "user",
"COLUMN_NAME": "showNotes",
"COLUMN_COMMENT": "是否显示备注"
},
{
"TABLE_NAME": "user",
"COLUMN_NAME": "accountGroupOrder",
"COLUMN_COMMENT": "账户分组显示顺序;空值表示沿用默认顺序"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+1 -1
View File
@@ -20,7 +20,7 @@
- 30 项单元测试、12 项真实 MySQL 集成检查通过;前后端类型检查和生产构建通过。
- 新增断言覆盖八位小数和大金额字符串无损转换、跨越零余额、溢缴款与其他债务分别汇总、历史趋势及 FX 归因、日历方向、更正和删除重算、跨用户和隐藏权限、ZIP 导出与恢复。
- 浏览器使用临时模拟账号,将欠款 -100 更新为溢缴存款 120.12345678,详情、历史和总览自动刷新;总资产 120.12、总负债 0.00、净资产 120.12。未操作实际用户账户或进行真实资金转移。
- 浏览器使用临时模拟账号,将欠款 -100 更新为溢缴存款 120.12(原始余额保留 8 位小数),详情、历史和总览自动刷新;总资产 120.12、总负债 0.00、净资产 120.12。未操作实际用户账户或进行真实资金转移。
- 临时账号、模拟数据和预览配置在验收后清理。保留用户原有 Vite 配置修改,仅本地提交,不 push。
![溢缴存款显示](credit-balance-preview.png)
+26
View File
@@ -0,0 +1,26 @@
# 2026-10-03 账户分组排序
## 使用方式
账户页点击“分组排序”,使用“上移/下移”调整分组后保存。顺序同步用于账户分组筛选、全部账户卡片、分组输入建议以及转账、借贷操作和定时计划中的账户选择弹窗。刷新网页或重新登录后仍保留;取消不会保存。只有一个分组时排序按钮禁用,首尾按钮防止越界。
未分组以空字符串单独保存,与自定义名称“未分组”区分。保存时保留当前视图之外已有分组的顺序设置,未解锁的隐藏分组不会出现在排序面板或选择弹窗;新分组追加显示。分组名称作为用户内容保留原文,不参与界面文字翻译。
## 数据与备份
新增 User.accountGroupOrder 可空 JSON 字段,迁移 20261003000100_account_group_order 只增加字段并附中文注释;没有重置数据库或改写财务余额。顺序通过已认证用户的 PATCH /api/settings 保存,auth/me 和 settings 返回当前用户设置。最多 1000 个分组,每个名称最多 60 字符;空字符串允许,重复名称(包括去除首尾空格后重复)和非法类型拒绝。
JSON 与 ZIP 的 preferences 增加可选 accountGroupOrder,ZIP 读取白名单同步接受。空资产空间恢复备份顺序;已有资产空间沿用当前设置,重复备份仍由原有保护返回 409。旧 ZIP 和旧 JSON 缺少排序字段时继续兼容。账户排序只影响展示,不改变金额、历史和财务统计。
## 验证
- 32 项单元测试和 13 项真实 MySQL 集成检查全部通过;前后端类型检查及生产构建通过。
- 新增测试覆盖顺序持久化、用户隔离、重复与非法输入拒绝、空分组与同名分组区分、搜索子集顺序、隐藏分组偏好保留、ZIP 排序恢复及旧备份兼容。
- 浏览器虚构数据验收:将“日常”从末尾移到首位,保存、刷新后卡片与筛选选项维持同一顺序,账户选择弹窗依次显示“日常、未分组、消费、理财”。
- 14 个迁移已应用;真实数据库 9 张表、89 个字段均有注释,完整快照见 database-comments.json。
- 前几轮回归分别发现 ZIP 设置白名单遗漏和重复导入断言不符,已修复并复验。连续多轮运行触发登录限流后,重启独立验收 API 完成整套 13 项通过;没有降低应用限流保护。
- 提交扫描此前将虚构测试金额的数字误匹配为本地凭据,且执行步骤未阻止随后提交。已更换数值示例,确认没有新增连接配置或真实凭据表达式;扫描改为检查新增行,避免删除内容和未改上下文阻挡修复。模拟新增凭据、删除凭据、未改上下文、私钥及安全内容的 5 项扫描回归通过;提交前重新执行扫描。
临时验收账号与预览配置清理,保留用户原有 Vite 配置修改。完成时间:2026-10-03 12:27。仅本地 Git 提交,不 push。
![分组排序验收](group-order-preview.png)
+7 -1
View File
@@ -11,7 +11,13 @@ if (
names.some((n) => /\.(db|sqlite|log)$/.test(n))
)
throw Error('Blocked: forbidden file staged');
const diff = git('diff', '--cached', '--no-ext-diff');
// Review newly introduced content; removing a secret or changing a numeric example
// must not be blocked by the removed lines or unchanged diff context.
const diff = git('diff', '--cached', '--no-ext-diff', '--unified=0')
.split('\n')
.filter((line) => line.startsWith('+') && !line.startsWith('+++ '))
.map((line) => line.slice(1))
.join('\n');
if (existsSync('apps/api/.env')) {
const text = readFileSync('apps/api/.env', 'utf8'),
value = text.match(/^DATABASE_URL=["']?([^"'\r\n]+)/m)?.[1];
+4 -1
View File
@@ -33,7 +33,8 @@
- ~~删除记录后部分UI需要手动刷新~~ — 已完成并验证:2026-10-03 11:22
- ~~备份功能出现问题~~ — 已完成并验证:2026-10-03 11:22
- ~~负债(如信用卡)也可以显示为正数变成存款~~ — 已完成并验证:2026-10-03 12:05
- 分组可以排序
- ~~分组可以排序~~ — 已完成并验证:2026-10-03 12:27
- “抖音月付”“东方财富”添加图标
验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。
@@ -44,3 +45,5 @@
本轮验收完成:2026-10-03 11:22。28 项单元测试、11 项真实 MySQL 集成检查、前后端类型检查与生产构建通过;未新增数据库表或字段。账号、删除与备份更新详情见 [更新说明](docs/update-2026-10-03.md)。重新检查文档,无新增未完成要求。
负债账户溢缴验收完成:2026-10-03 12:05。30 项单元测试、12 项真实 MySQL 集成检查、类型检查和构建通过;支持信用卡等负债账户正数存款显示及总资产分类,已有数据符号保留。详见 [更新说明](docs/update-credit-balance-2026-10-03.md)。复查发现新增“分组可以排序”,继续处理。
分组排序验收完成:2026-10-03 12:27。32 项单元测试、13 项真实 MySQL 集成检查、类型检查与构建通过;14 个迁移已应用,9 张表与 89 个字段注释已核验。账户页、筛选与选择弹窗顺序同步并持久化,备份恢复包含分组顺序。详见 [更新说明](docs/update-group-order-2026-10-03.md)。