From 1af5edbadc0bcd9028814fd2056d24b48eb933da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Sat, 3 Oct 2026 12:29:35 +0800 Subject: [PATCH] feat: persist account group display order and backup preferences --- apps/api/package.json | 2 +- .../migration.sql | 1 + apps/api/prisma/schema.prisma | 2 + apps/api/src/auth.ts | 2 + apps/api/src/backup.ts | 12 +- apps/api/src/openapi.ts | 2 + apps/api/src/rates.ts | 5 +- apps/api/src/validation.ts | 5 + apps/api/src/zip.ts | 2 + apps/api/test/group-order.test.ts | 107 +++++++++++++++ apps/api/test/update.test.ts | 30 +++-- apps/api/test/zip.test.ts | 9 ++ apps/web/src/AccountPicker.tsx | 16 ++- apps/web/src/App.tsx | 126 ++++++++++++++---- apps/web/src/GroupOrderContext.ts | 2 + apps/web/src/api.ts | 1 + apps/web/src/group-order.ts | 8 ++ apps/web/src/locales/en.json | 8 +- apps/web/src/locales/zh-Hant.json | 8 +- apps/web/src/style.css | 24 ++++ docs/acceptance.md | 4 + docs/architecture.md | 2 + docs/database-comments.json | 7 +- docs/group-order-preview.png | Bin 0 -> 32886 bytes docs/update-credit-balance-2026-10-03.md | 2 +- docs/update-group-order-2026-10-03.md | 26 ++++ scripts/check-staged.mjs | 8 +- update.md | 5 +- 28 files changed, 379 insertions(+), 47 deletions(-) create mode 100644 apps/api/prisma/migrations/20261003000100_account_group_order/migration.sql create mode 100644 apps/api/test/group-order.test.ts create mode 100644 apps/web/src/GroupOrderContext.ts create mode 100644 apps/web/src/group-order.ts create mode 100644 docs/group-order-preview.png create mode 100644 docs/update-group-order-2026-10-03.md diff --git a/apps/api/package.json b/apps/api/package.json index fbfb05d..f94f970 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" }, diff --git a/apps/api/prisma/migrations/20261003000100_account_group_order/migration.sql b/apps/api/prisma/migrations/20261003000100_account_group_order/migration.sql new file mode 100644 index 0000000..ef3eb18 --- /dev/null +++ b/apps/api/prisma/migrations/20261003000100_account_group_order/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `User` ADD COLUMN `accountGroupOrder` JSON NULL COMMENT '账户分组显示顺序;空值表示沿用默认顺序'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a25e5e1..8f6923c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 表示关闭 diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 35f4ffa..8b95002 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -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, diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 1099823..22ade2d 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -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 }; diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index fb602e6..ff7668f 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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(), }) diff --git a/apps/api/src/rates.ts b/apps/api/src/rates.ts index 2e83bdc..5e4c9ae 100644 --- a/apps/api/src/rates.ts +++ b/apps/api/src/rates.ts @@ -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(), }) diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index 30316b8..104a567 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -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, '分组不可重复'); diff --git a/apps/api/src/zip.ts b/apps/api/src/zip.ts index 88c2475..948b56c 100644 --- a/apps/api/src/zip.ts +++ b/apps/api/src/zip.ts @@ -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 { .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), }) diff --git a/apps/api/test/group-order.test.ts b/apps/api/test/group-order.test.ts new file mode 100644 index 0000000..03439df --- /dev/null +++ b/apps/api/test/group-order.test.ts @@ -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(); + } +}); diff --git a/apps/api/test/update.test.ts b/apps/api/test/update.test.ts index 8a143a5..8ebd59b 100644 --- a/apps/api/test/update.test.ts +++ b/apps/api/test/update.test.ts @@ -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(['日常', '理财'], ['理财', '隐藏']), ['日常', '理财', '隐藏']); +}); diff --git a/apps/api/test/zip.test.ts b/apps/api/test/zip.test.ts index 6b844d2..9f59ff1 100644 --- a/apps/api/test/zip.test.ts +++ b/apps/api/test/zip.test.ts @@ -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); +}); diff --git a/apps/web/src/AccountPicker.tsx b/apps/web/src/AccountPicker.tsx index 2bd59c1..19558bb 100644 --- a/apps/web/src/AccountPicker.tsx +++ b/apps/web/src/AccountPicker.tsx @@ -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 (
@@ -61,9 +69,9 @@ export function AccountPicker({
{groups.map((group) => (
-

{tr(group)}

+

{grouped ? group || tr('未分组') : tr('全部账户')}

{rows - .filter((p) => !grouped || (p.groupName || '未分组') === group) + .filter((p) => !grouped || (p.groupName || '') === group) .map((p) => ( + {quickMode &&

{tr('点击账户卡片,直接填写当前余额。')}

} @@ -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) => ( + ))} + + + ))} + +
+ + +
+ + )} {modal?.kind === 'delete-record' && ( )} - {modal && !['transfer-edit', 'delete-record'].includes(modal.kind) && ( + {modal && !['transfer-edit', 'delete-record', 'group-order'].includes(modal.kind) && ( - {[...new Set(positions.map((p) => p.groupName).filter(Boolean))].map( - (g) => ( - @@ -2524,7 +2602,7 @@ export default function App() { )} )} - + , ); } function HistoryTable({ diff --git a/apps/web/src/GroupOrderContext.ts b/apps/web/src/GroupOrderContext.ts new file mode 100644 index 0000000..d1e7a9a --- /dev/null +++ b/apps/web/src/GroupOrderContext.ts @@ -0,0 +1,2 @@ +import { createContext } from 'react'; +export const GroupOrderContext = createContext([]); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 27feb08..9580add 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -23,6 +23,7 @@ export type User = { hiddenMenus: string[]; showNotes: boolean; idleMinutes: number; + accountGroupOrder?: string[]; revealed?: boolean; revealUntil?: string | null; lastActivity?: string; diff --git a/apps/web/src/group-order.ts b/apps/web/src/group-order.ts new file mode 100644 index 0000000..01542cd --- /dev/null +++ b/apps/web/src/group-order.ts @@ -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))]; +} diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index 309b8f9..ff9dc4a 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -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." } diff --git a/apps/web/src/locales/zh-Hant.json b/apps/web/src/locales/zh-Hant.json index df6b946..5e1e815 100644 --- a/apps/web/src/locales/zh-Hant.json +++ b/apps/web/src/locales/zh-Hant.json @@ -467,5 +467,11 @@ "溢缴存款": "溢繳存款", "信用卡、贷款账户:负数为欠款,正数为溢缴存款;其余账户按实际余额填写。": "信用卡、貸款賬戶:負數為欠款,正數為溢繳存款;其餘賬戶按實際餘額填寫。", "负数表示欠款,正数表示溢缴存款;溢缴部分计入总资产。": "負數表示欠款,正數表示溢繳存款;溢繳部分計入總資產。", - "仅账户支持负余额": "僅賬戶支援負餘額" + "仅账户支持负余额": "僅賬戶支援負餘額", + "分组排序": "分組排序", + "上移": "上移", + "下移": "下移", + "分组顺序已保存": "分組順序已儲存", + "分组不可重复": "分組不可重複", + "调整账户分组顺序,账户页和选择弹窗会同步使用。": "調整帳戶分組順序,帳戶頁和選擇彈窗會同步使用。" } diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 7cea79e..9671961 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -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; +} diff --git a/docs/acceptance.md b/docs/acceptance.md index f50144c..74441ae 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -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)。 diff --git a/docs/architecture.md b/docs/architecture.md index 0eff0a7..9f08b2f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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)。 diff --git a/docs/database-comments.json b/docs/database-comments.json index d5678d5..3caaa25 100644 --- a/docs/database-comments.json +++ b/docs/database-comments.json @@ -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": "账户分组显示顺序;空值表示沿用默认顺序" } ] } diff --git a/docs/group-order-preview.png b/docs/group-order-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..8c95a523f8ede12ea4c83ca3c01a451607d0e0ec GIT binary patch literal 32886 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 /\.(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]; diff --git a/update.md b/update.md index 3dced8b..1bde619 100644 --- a/update.md +++ b/update.md @@ -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)。