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 0000000..8c95a52 Binary files /dev/null and b/docs/group-order-preview.png differ diff --git a/docs/update-credit-balance-2026-10-03.md b/docs/update-credit-balance-2026-10-03.md index a36eb02..4808066 100644 --- a/docs/update-credit-balance-2026-10-03.md +++ b/docs/update-credit-balance-2026-10-03.md @@ -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) diff --git a/docs/update-group-order-2026-10-03.md b/docs/update-group-order-2026-10-03.md new file mode 100644 index 0000000..857b043 --- /dev/null +++ b/docs/update-group-order-2026-10-03.md @@ -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) diff --git a/scripts/check-staged.mjs b/scripts/check-staged.mjs index 4054367..d460438 100644 --- a/scripts/check-staged.mjs +++ b/scripts/check-staged.mjs @@ -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]; 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)。