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;
}