feat: customize sessions and overview with faster account workflows
No files matched your search
@@ -47,7 +47,7 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的
|
||||
|
||||
账户图标:新增或编辑账户时选择可复用图标;设置页面提供图标库及中文名称搜索。直接上传默认私有,仅当前用户能检索、读取和使用;勾选共享并明确确认公开后,所有登录用户均可搜索复用,名称必须包含中文。支持静态 PNG/JPG/WebP,单张最大 2 MB,转为最长边 256 像素的 PNG 并去除图片元数据。同一用户相同图片和可见范围会复用现有图标。账户图标通过外键关联,不复制图片。
|
||||
|
||||
预置 17 家银行及支付宝、微信、京东金融共 20 个图标,资源及来源清单在 `apps/api/assets/icons`;银行来自公开银行标识库,支付平台来自官方网站资源及 Simple Icons。图标版权与商标归相应品牌所有,用于识别账户,不代表品牌合作或授权。运行 `pnpm --filter @worthpath/api icons:seed` 初始化共享库或更新固定 ID 的内置透明图标,不修改用户上传图标或财务数据。可离线使用已提交的 PNG,无需访问外部图标网站。
|
||||
预置银行、支付平台与交易所等共 36 个图标,资源及来源清单在 `apps/api/assets/icons`;银行来自公开银行标识库,支付平台来自官方网站资源及 Simple Icons。图标版权与商标归相应品牌所有,用于识别账户,不代表品牌合作或授权。运行 `pnpm --filter @worthpath/api icons:seed` 初始化共享库或更新固定 ID 的内置透明图标,不修改用户上传图标或财务数据。可离线使用已提交的 PNG,无需访问外部图标网站。
|
||||
|
||||
ZIP 格式 v5 增加 transfers.json(转账双方、金额、手续费及配对历史),包含完整转账恢复关系。icons.json(图标名称、图片、内容校验值),包含自己的全部图标及账户引用的共享图标。导入会重建关联并将图标恢复为私有,相同图片复用,避免自动公开;旧 v3/v4 ZIP 和旧 JSON 仍可导入。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。
|
||||
|
||||
@@ -84,3 +84,5 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json
|
||||
“定时计划”支持支出及转账,打开账户页时按需执行,每批最多 20 项;仅在本应用内记账。新增“收支日历”默认今天,月历与当日明细在同页上下展示,按账户余额变化估算,排除内部转账、借贷本金及初始余额。
|
||||
|
||||
本次需要新增数据库迁移并运行图库初始化,备份导出升级至 ZIP v7。操作步骤、31 个内置图标与验证边界见 [更新说明](docs/update-2026-10-02.md)。
|
||||
|
||||
2026-10-03 设置与交互更新:支持 1 小时至 30 天登录有效期、隐藏项目密码开关、自选总览卡片、计划编辑弹窗、右上角操作提示及账户卡片快速转账。详见 [更新说明](docs/update-settings-interaction-2026-10-03.md)。
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 182 KiB |
@@ -115,9 +115,10 @@
|
||||
},
|
||||
{
|
||||
"name": "京东金融",
|
||||
"source": "https://jr.jd.com/logo.png",
|
||||
"source": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/7d/74/af/7d74af96-af89-ae14-cbf5-5bbd89859ab7/AppIcon-0-1x_U007epad-0-1-0-85-220-0.png/512x512bb.jpg",
|
||||
"file": "20.png",
|
||||
"sha256": "8dc9703f571e605df552dc7eb14ae074d9ddab6b27a9140b610e87b1c2a5f693"
|
||||
"sha256": "9274fde6f793cf49f239fe3995cc959f33d71a02f93866e416ae5f5a533b05de",
|
||||
"preserveWhite": true
|
||||
},
|
||||
{
|
||||
"name": "汇丰香港",
|
||||
|
||||
@@ -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/group-order.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/settings-plans.test.ts",
|
||||
"test:performance": "tsx scripts/performance.ts after",
|
||||
"icons:seed": "node scripts/seed-icons.cjs"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE User
|
||||
ADD COLUMN sessionHours INTEGER NOT NULL DEFAULT 168 COMMENT '登录有效时长(小时),范围 1 至 720',
|
||||
ADD COLUMN requireHiddenPassword BOOLEAN NOT NULL DEFAULT true COMMENT '查看隐藏项目是否需要再次验证密码',
|
||||
ADD COLUMN overviewCards JSON NULL COMMENT '总览显示的卡片键列表,NULL 表示默认全部显示';
|
||||
@@ -17,6 +17,12 @@ model User {
|
||||
baseCurrency String @default("CNY") @db.Char(3)
|
||||
/// 隐藏菜单标识列表
|
||||
hiddenMenus String @default("") @db.VarChar(128)
|
||||
/// 登录有效时长,单位小时,范围 1 至 720
|
||||
sessionHours Int @default(168)
|
||||
/// 查看隐藏项目是否需要再次验证密码
|
||||
requireHiddenPassword Boolean @default(true)
|
||||
/// 总览显示的卡片键列表,空值表示默认全部显示
|
||||
overviewCards Json?
|
||||
/// 账户分组显示顺序;空值表示沿用默认顺序
|
||||
accountGroupOrder Json?
|
||||
/// 是否显示备注
|
||||
|
||||
@@ -11,7 +11,10 @@ async function main() {
|
||||
const sources = JSON.parse(await readFile(join(dir, 'sources.json'), 'utf8'));
|
||||
// Update only deterministic built-in image IDs; preserve user uploads and financial data.
|
||||
for (const item of sources) {
|
||||
const data = await normalizeIcon(await readFile(join(dir, item.file)));
|
||||
const data = await normalizeIcon(
|
||||
await readFile(join(dir, item.file)),
|
||||
item.preserveWhite === true,
|
||||
);
|
||||
const hex = createHash('sha256')
|
||||
.update('worthpath-builtin:' + item.name)
|
||||
.digest('hex');
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Request, Response } from 'express';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { hash, compare } from 'bcryptjs';
|
||||
import { Database } from './database';
|
||||
import { credentials, credentialChange } from './validation';
|
||||
import { credentials, credentialChange, defaultOverviewCards } from './validation';
|
||||
import { Prisma } from '@prisma/client';
|
||||
export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean };
|
||||
const Public = () => SetMetadata('public', true);
|
||||
@@ -58,8 +58,9 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
async issue(userId: string, res: Response) {
|
||||
const user = await this.db.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const token = randomBytes(32).toString('hex'),
|
||||
expiresAt = new Date(Date.now() + 7 * 86400000);
|
||||
expiresAt = new Date(Date.now() + user.sessionHours * 3600000);
|
||||
await this.db.session.create({ data: { id: digest(token), userId, expiresAt } });
|
||||
this.cookie(token, expiresAt, res);
|
||||
}
|
||||
@@ -171,12 +172,17 @@ export class AuthController {
|
||||
showNotes: true,
|
||||
idleMinutes: true,
|
||||
accountGroupOrder: true,
|
||||
sessionHours: true,
|
||||
requireHiddenPassword: true,
|
||||
overviewCards: true,
|
||||
},
|
||||
});
|
||||
const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } });
|
||||
return {
|
||||
...user,
|
||||
accountGroupOrder: user.accountGroupOrder || [],
|
||||
overviewCards: user.overviewCards ?? [...defaultOverviewCards],
|
||||
sessionExpiresAt: session.expiresAt,
|
||||
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
|
||||
revealed: req.revealed,
|
||||
revealUntil: session.revealUntil,
|
||||
@@ -197,7 +203,7 @@ export class AuthController {
|
||||
throw new BadRequestException('请填写新的账号或密码');
|
||||
const passwordHash = v.newPassword ? await hash(v.newPassword, 12) : user.passwordHash;
|
||||
const token = randomBytes(32).toString('hex'),
|
||||
expiresAt = new Date(Date.now() + 7 * 86400000);
|
||||
expiresAt = new Date(Date.now() + user.sessionHours * 3600000);
|
||||
await this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
|
||||
const current = await tx.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
@@ -222,11 +228,17 @@ export class AuthController {
|
||||
}
|
||||
@Post('auth/reveal') async reveal(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
this.auth.limit(r);
|
||||
const { password } = credentials.pick({ password: true }).parse(b);
|
||||
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (!(await compare(password, u.passwordHash))) throw new ForbiddenException('密码错误');
|
||||
const revealUntil = new Date(Date.now() + 5 * 60000);
|
||||
await this.db.session.update({ where: { id: r.sessionId }, data: { revealUntil } });
|
||||
const revealUntil = await this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
|
||||
const u = await tx.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (u.requireHiddenPassword) {
|
||||
const { password } = credentials.pick({ password: true }).parse(b);
|
||||
if (!(await compare(password, u.passwordHash))) throw new ForbiddenException('密码错误');
|
||||
}
|
||||
const until = new Date(Date.now() + 5 * 60000);
|
||||
await tx.session.update({ where: { id: r.sessionId }, data: { revealUntil: until } });
|
||||
return until;
|
||||
});
|
||||
return { revealUntil };
|
||||
}
|
||||
@Post('auth/lock') async lock(@Req() r: UserRequest) {
|
||||
|
||||
@@ -36,6 +36,9 @@ import {
|
||||
rateInput,
|
||||
hiddenMenus,
|
||||
accountGroupOrder,
|
||||
sessionHours,
|
||||
overviewCards,
|
||||
defaultOverviewCards,
|
||||
transferInput,
|
||||
} from './validation';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -79,6 +82,9 @@ const backupSchema = z
|
||||
showSidebar: z.boolean().optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
sessionHours: sessionHours.optional(),
|
||||
requireHiddenPassword: z.boolean().optional(),
|
||||
overviewCards: overviewCards.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440),
|
||||
})
|
||||
@@ -309,6 +315,9 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
showNotes: true,
|
||||
idleMinutes: true,
|
||||
accountGroupOrder: true,
|
||||
sessionHours: true,
|
||||
requireHiddenPassword: true,
|
||||
overviewCards: true,
|
||||
},
|
||||
}),
|
||||
client.position.findMany({
|
||||
@@ -369,6 +378,9 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
showNotes: user.showNotes,
|
||||
idleMinutes: user.idleMinutes,
|
||||
accountGroupOrder: accountGroupOrder.parse(user.accountGroupOrder || []),
|
||||
sessionHours: user.sessionHours,
|
||||
requireHiddenPassword: user.requireHiddenPassword,
|
||||
overviewCards: overviewCards.parse(user.overviewCards ?? [...defaultOverviewCards]),
|
||||
},
|
||||
currencies: [
|
||||
...new Set([
|
||||
@@ -717,8 +729,13 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
hiddenMenus: b.preferences?.hiddenMenus?.join(','),
|
||||
showNotes: b.preferences?.showNotes,
|
||||
accountGroupOrder: b.preferences?.accountGroupOrder,
|
||||
sessionHours: b.preferences?.sessionHours,
|
||||
requireHiddenPassword: b.preferences?.requireHiddenPassword,
|
||||
overviewCards: b.preferences?.overviewCards,
|
||||
},
|
||||
});
|
||||
if (!ps.length && !rs.length && b.preferences?.requireHiddenPassword !== undefined)
|
||||
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
|
||||
return { ok: true, positions: b.positions.length };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
|
||||
|
||||
@@ -24,7 +24,7 @@ import { UserRequest } from './auth';
|
||||
|
||||
export const iconName = z.string().trim().min(1).max(100);
|
||||
export const iconHash = (data: Buffer) => createHash('sha256').update(data).digest('hex');
|
||||
export async function normalizeIcon(data: Buffer) {
|
||||
export async function normalizeIcon(data: Buffer, preserveWhite = false) {
|
||||
if (!data.length || data.length > 2 * 1024 * 1024)
|
||||
throw new BadRequestException('图标不能超过 2 MB');
|
||||
try {
|
||||
@@ -43,7 +43,7 @@ export async function normalizeIcon(data: Buffer) {
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
const low = Math.min(pixels[i], pixels[i + 1], pixels[i + 2]);
|
||||
const high = Math.max(pixels[i], pixels[i + 1], pixels[i + 2]);
|
||||
if (low >= 245 && high - low <= 8) pixels[i + 3] = 0;
|
||||
if (!preserveWhite && low >= 245 && high - low <= 8) pixels[i + 3] = 0;
|
||||
}
|
||||
return await sharp(pixels, { raw: { width: info.width, height: info.height, channels: 4 } })
|
||||
.png()
|
||||
@@ -106,7 +106,9 @@ export class IconsController {
|
||||
if (!icon) throw new NotFoundException('图标不存在');
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.send(await normalizeIcon(Buffer.from(icon.data)));
|
||||
// Uploads, built-in seeding and imports already validate stored PNG data.
|
||||
// Preserve essential white artwork rather than applying the cutout twice.
|
||||
res.send(Buffer.from(icon.data));
|
||||
}
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
currency,
|
||||
hiddenMenus,
|
||||
accountGroupOrder,
|
||||
settingsInput,
|
||||
} from './validation';
|
||||
export function setupOpenApi(app: INestApplication) {
|
||||
const document = SwaggerModule.createDocument(
|
||||
@@ -30,7 +31,7 @@ export function setupOpenApi(app: INestApplication) {
|
||||
'POST /api/auth/register': credentials,
|
||||
'POST /api/auth/login': credentials,
|
||||
'PATCH /api/auth/credentials': credentialChange,
|
||||
'POST /api/auth/reveal': credentials.pick({ password: true }),
|
||||
'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(),
|
||||
'POST /api/positions': positionInput,
|
||||
'PATCH /api/positions/{id}': positionMeta,
|
||||
'POST /api/positions/{id}/revisions': revisionInput,
|
||||
@@ -41,16 +42,9 @@ export function setupOpenApi(app: INestApplication) {
|
||||
'POST /api/transfers': transferInput,
|
||||
'PUT /api/transfers/{id}': transferInput,
|
||||
'POST /api/schedules': scheduleInput,
|
||||
'PUT /api/schedules/{id}': scheduleInput,
|
||||
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
|
||||
'PATCH /api/settings': z
|
||||
.object({
|
||||
baseCurrency: currency.optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
||||
})
|
||||
.strict(),
|
||||
'PATCH /api/settings': settingsInput,
|
||||
'POST /api/backup/clear': z.object({ confirmation: z.literal('确定清空') }),
|
||||
'POST /api/backup/import-file': z.object({
|
||||
token: z.string().uuid(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Res,
|
||||
Post,
|
||||
Req,
|
||||
Body,
|
||||
@@ -12,8 +13,16 @@ import {
|
||||
BadGatewayException,
|
||||
} from '@nestjs/common';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { currency, date, rateValue, today, hiddenMenus, accountGroupOrder } from './validation';
|
||||
import { AuthService, UserRequest } from './auth';
|
||||
import { Response } from 'express';
|
||||
import {
|
||||
currency,
|
||||
date,
|
||||
rateValue,
|
||||
today,
|
||||
settingsInput,
|
||||
defaultOverviewCards,
|
||||
} from './validation';
|
||||
import { z } from 'zod';
|
||||
import Decimal from 'decimal.js';
|
||||
// Fixed public request; no user currency choices, identifiers or amounts leave the server.
|
||||
@@ -165,6 +174,7 @@ export class SettingsController {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private fx: RatesService,
|
||||
private auth: AuthService,
|
||||
) {}
|
||||
@Get('settings') async settings(@Req() r: UserRequest, @Query('rates') includeRates?: string) {
|
||||
const showRates = z.enum(['true', 'false']).optional().parse(includeRates) === 'true';
|
||||
@@ -177,11 +187,17 @@ export class SettingsController {
|
||||
showNotes: true,
|
||||
idleMinutes: true,
|
||||
accountGroupOrder: true,
|
||||
sessionHours: true,
|
||||
requireHiddenPassword: true,
|
||||
overviewCards: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...u,
|
||||
accountGroupOrder: u.accountGroupOrder || [],
|
||||
overviewCards: u.overviewCards ?? [...defaultOverviewCards],
|
||||
sessionExpiresAt: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
|
||||
.expiresAt,
|
||||
hiddenMenus: u.hiddenMenus.split(',').filter(Boolean),
|
||||
lastActivity: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
|
||||
.lastActivity,
|
||||
@@ -200,22 +216,26 @@ export class SettingsController {
|
||||
: [],
|
||||
};
|
||||
}
|
||||
@Patch('settings') async update(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
const data = z
|
||||
.object({
|
||||
baseCurrency: currency.optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => Object.keys(v).length > 0)
|
||||
.parse(b);
|
||||
await this.db.user.update({
|
||||
where: { id: r.userId },
|
||||
data: { ...data, hiddenMenus: data.hiddenMenus?.join(',') },
|
||||
@Patch('settings') async update(
|
||||
@Req() r: UserRequest,
|
||||
@Body() b: unknown,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const data = settingsInput.parse(b);
|
||||
const expiresAt =
|
||||
data.sessionHours === undefined
|
||||
? undefined
|
||||
: new Date(Date.now() + data.sessionHours * 3600000);
|
||||
await this.db.serial(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: r.userId },
|
||||
data: { ...data, hiddenMenus: data.hiddenMenus?.join(',') },
|
||||
});
|
||||
if (data.requireHiddenPassword !== undefined)
|
||||
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
|
||||
if (expiresAt) await tx.session.update({ where: { id: r.sessionId }, data: { expiresAt } });
|
||||
});
|
||||
if (expiresAt) this.auth.cookie(r.cookies.wp_session, expiresAt, res);
|
||||
this.fx.invalidate(r.userId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
@@ -86,35 +87,67 @@ export class SchedulesController {
|
||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
const v = scheduleInput.parse(body);
|
||||
return this.db.serial(async (tx) => {
|
||||
const ids = [v.sourceId, ...(v.targetId ? [v.targetId] : [])].sort();
|
||||
await tx.$queryRaw(
|
||||
Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId} AND id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`,
|
||||
);
|
||||
const accounts = await tx.position.findMany({
|
||||
where: {
|
||||
userId: r.userId,
|
||||
id: { in: ids },
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
archived: false,
|
||||
...(r.revealed ? {} : { hidden: false }),
|
||||
},
|
||||
});
|
||||
if (accounts.length !== ids.length) throw new BadRequestException('请选择启用的资产账户');
|
||||
if (v.operation === 'expense' && v.targetId)
|
||||
throw new BadRequestException('支出计划无需转入账户');
|
||||
if (
|
||||
v.operation === 'transfer' &&
|
||||
accounts[0].currency === accounts[1].currency &&
|
||||
!new Decimal(v.amount).eq(v.received)
|
||||
)
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致');
|
||||
await this.validateAccounts(tx, r, v);
|
||||
return tx.schedule.create({
|
||||
data: { ...v, userId: r.userId, nextAt: new Date(v.nextAt + ':00+08:00') },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
private async validateAccounts(
|
||||
tx: Prisma.TransactionClient,
|
||||
r: UserRequest,
|
||||
v: z.infer<typeof scheduleInput>,
|
||||
) {
|
||||
const ids = [v.sourceId, ...(v.targetId ? [v.targetId] : [])].sort();
|
||||
await tx.$queryRaw(
|
||||
Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId} AND id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`,
|
||||
);
|
||||
const accounts = await tx.position.findMany({
|
||||
where: {
|
||||
userId: r.userId,
|
||||
id: { in: ids },
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
archived: false,
|
||||
...(r.revealed ? {} : { hidden: false }),
|
||||
},
|
||||
});
|
||||
if (accounts.length !== ids.length) throw new BadRequestException('请选择启用的资产账户');
|
||||
if (v.operation === 'expense' && v.targetId)
|
||||
throw new BadRequestException('支出计划无需转入账户');
|
||||
if (
|
||||
v.operation === 'transfer' &&
|
||||
accounts[0].currency === accounts[1].currency &&
|
||||
!new Decimal(v.amount).eq(v.received)
|
||||
)
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致');
|
||||
}
|
||||
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
|
||||
const v = scheduleInput.parse(body);
|
||||
return this.db.serial(async (tx) => {
|
||||
await tx.$queryRaw(
|
||||
Prisma.sql`SELECT id FROM Schedule WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
|
||||
);
|
||||
const ids = await this.visible(r, tx);
|
||||
const row = await tx.schedule.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: r.userId,
|
||||
sourceId: { in: ids },
|
||||
OR: [{ targetId: null }, { targetId: { in: ids } }],
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('计划不存在');
|
||||
if (row.completed) throw new BadRequestException('一次性计划已完成,请新建计划');
|
||||
await this.validateAccounts(tx, r, v);
|
||||
await tx.schedule.update({
|
||||
where: { id },
|
||||
data: { ...v, nextAt: new Date(v.nextAt + ':00+08:00') },
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@Patch(':id') async toggle(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -187,3 +187,31 @@ export const accountGroupOrder = z
|
||||
.array(z.string().trim().max(60))
|
||||
.max(1000)
|
||||
.refine((v) => new Set(v).size === v.length, '分组不可重复');
|
||||
|
||||
export const defaultOverviewCards = [
|
||||
'assets',
|
||||
'liabilities',
|
||||
'net',
|
||||
'trend',
|
||||
'composition',
|
||||
'recent',
|
||||
'attribution',
|
||||
] as const;
|
||||
export const overviewCards = z
|
||||
.array(z.enum(defaultOverviewCards))
|
||||
.max(7)
|
||||
.refine((v) => new Set(v).size === v.length, '卡片不可重复');
|
||||
export const sessionHours = z.number().int().min(1).max(720);
|
||||
export const settingsInput = z
|
||||
.object({
|
||||
baseCurrency: currency.optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
||||
sessionHours: sessionHours.optional(),
|
||||
requireHiddenPassword: z.boolean().optional(),
|
||||
overviewCards: overviewCards.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => Object.keys(v).length > 0);
|
||||
@@ -4,7 +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';
|
||||
import { accountGroupOrder, sessionHours, overviewCards } from './validation';
|
||||
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
|
||||
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
|
||||
const files = [
|
||||
@@ -151,6 +151,9 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
||||
showSidebar: z.boolean().optional(),
|
||||
hiddenMenus: z.array(z.string()).optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
sessionHours: sessionHours.optional(),
|
||||
requireHiddenPassword: z.boolean().optional(),
|
||||
overviewCards: overviewCards.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440),
|
||||
})
|
||||
|
||||
@@ -277,7 +277,10 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
||||
const backup = (await call('/backup', 'GET', undefined, a.cookie)).data;
|
||||
assert.equal(backup.positions.length, 4);
|
||||
assert.equal(backup.links.length, 2);
|
||||
assert.doesNotMatch(JSON.stringify(backup), /password|token|userId|session/i);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(backup),
|
||||
/"(?:password|passwordHash|token|userId|session|sessionId|sessions|cookie)"\s*:/i,
|
||||
);
|
||||
assert.equal((await call('/backup/preview', 'POST', backup, a.cookie)).status, 409);
|
||||
assert.equal((await call('/backup/preview', 'POST', backup, b.cookie)).status, 201);
|
||||
assert.equal(
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import { request } from 'node:http';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { readBackupZip } from '../src/zip';
|
||||
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
||||
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
||||
const password = 'Fixture-session-only-42!';
|
||||
const db = new PrismaClient();
|
||||
async function fixture() {
|
||||
const u = await db.user.create({
|
||||
data: {
|
||||
username: 'wp_settings_' + randomUUID(),
|
||||
passwordHash: await hash(password, 4),
|
||||
idleMinutes: 0,
|
||||
},
|
||||
});
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const id = createHash('sha256').update(token).digest('hex');
|
||||
await db.session.create({
|
||||
data: { id, userId: u.id, expiresAt: new Date(Date.now() + 86400000) },
|
||||
});
|
||||
return { ...u, sessionId: id, cookie: 'wp_session=' + token };
|
||||
}
|
||||
// Give this fixture suite its own loopback source address so independent auth
|
||||
// scenarios do not consume the existing suite's per-IP production rate limit.
|
||||
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
|
||||
const data = body === undefined ? undefined : JSON.stringify(body);
|
||||
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
|
||||
const req = request(
|
||||
new URL(base + path),
|
||||
{
|
||||
method,
|
||||
localAddress: '127.0.0.2',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
Origin: origin,
|
||||
...(data === undefined
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('error', reject);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({
|
||||
status: res.statusCode!,
|
||||
data: JSON.parse(Buffer.concat(chunks).toString()),
|
||||
cookie: res.headers['set-cookie']?.[0] ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(data);
|
||||
});
|
||||
}
|
||||
test('session duration boundaries, privacy isolation and card settings survive backups', async () => {
|
||||
const a = await fixture(),
|
||||
b = await fixture(),
|
||||
c = await fixture();
|
||||
try {
|
||||
const initial = await call('/auth/me', a.cookie);
|
||||
assert.equal(initial.data.sessionHours, 168);
|
||||
assert.equal(initial.data.requireHiddenPassword, true);
|
||||
assert.equal(initial.data.overviewCards.length, 7);
|
||||
for (const sessionHours of [0, 721, 1.5, '24'])
|
||||
assert.equal((await call('/settings', a.cookie, 'PATCH', { sessionHours })).status, 400);
|
||||
for (const overviewCards of [['unknown'], ['net', 'net'], [1]])
|
||||
assert.equal((await call('/settings', a.cookie, 'PATCH', { overviewCards })).status, 400);
|
||||
assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 400);
|
||||
assert.equal(
|
||||
(await call('/auth/reveal', a.cookie, 'POST', { password: 'Incorrect-password-42' })).status,
|
||||
403,
|
||||
);
|
||||
assert.equal((await call('/auth/reveal', a.cookie, 'POST', { password })).status, 201);
|
||||
const secondToken = randomBytes(32).toString('hex');
|
||||
const secondId = createHash('sha256').update(secondToken).digest('hex');
|
||||
const oldExpiry = new Date(Date.now() + 36000000);
|
||||
await db.session.create({
|
||||
data: {
|
||||
id: secondId,
|
||||
userId: a.id,
|
||||
expiresAt: oldExpiry,
|
||||
revealUntil: new Date(Date.now() + 60000),
|
||||
},
|
||||
});
|
||||
const result = await call('/settings', a.cookie, 'PATCH', {
|
||||
sessionHours: 1,
|
||||
requireHiddenPassword: false,
|
||||
overviewCards: ['net', 'recent'],
|
||||
});
|
||||
assert.equal(result.status, 200);
|
||||
assert.match(result.cookie!, /HttpOnly/);
|
||||
assert.match(result.cookie!, /Expires=/);
|
||||
const current = await db.session.findUniqueOrThrow({ where: { id: a.sessionId } });
|
||||
assert.ok(Math.abs(+current.expiresAt - Date.now() - 3600000) < 5000);
|
||||
assert.equal(current.revealUntil, null);
|
||||
const other = await db.session.findUniqueOrThrow({ where: { id: secondId } });
|
||||
assert.ok(Math.abs(+other.expiresAt - +oldExpiry) < 1000);
|
||||
assert.equal(other.revealUntil, null);
|
||||
assert.equal((await call('/auth/me', b.cookie)).data.requireHiddenPassword, true);
|
||||
assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 201);
|
||||
assert.equal((await call('/auth/me', a.cookie)).data.revealed, true);
|
||||
await call('/settings', a.cookie, 'PATCH', { requireHiddenPassword: true });
|
||||
assert.equal((await call('/auth/me', a.cookie)).data.revealed, false);
|
||||
assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 400);
|
||||
await call('/settings', a.cookie, 'PATCH', { sessionHours: 720, requireHiddenPassword: false });
|
||||
const login = await call('/auth/login', '', 'POST', { username: a.username, password });
|
||||
assert.equal(login.status, 201);
|
||||
const freshCookie = login.cookie!.split(';')[0];
|
||||
const me = (await call('/auth/me', freshCookie)).data;
|
||||
assert.ok(Math.abs(+new Date(me.sessionExpiresAt) - Date.now() - 720 * 3600000) < 5000);
|
||||
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.overviewCards, ['net', 'recent']);
|
||||
assert.equal(backup.preferences.sessionHours, 720);
|
||||
assert.equal(backup.preferences.requireHiddenPassword, false);
|
||||
assert.equal(
|
||||
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
|
||||
201,
|
||||
);
|
||||
const imported = (await call('/settings', b.cookie)).data;
|
||||
assert.equal(imported.sessionHours, 720);
|
||||
assert.equal(imported.requireHiddenPassword, false);
|
||||
assert.deepEqual(imported.overviewCards, ['net', 'recent']);
|
||||
delete backup.preferences.sessionHours;
|
||||
delete backup.preferences.requireHiddenPassword;
|
||||
delete backup.preferences.overviewCards;
|
||||
assert.equal(
|
||||
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
|
||||
201,
|
||||
);
|
||||
const legacy = (await call('/settings', c.cookie)).data;
|
||||
assert.equal(legacy.sessionHours, 168);
|
||||
assert.equal(legacy.requireHiddenPassword, true);
|
||||
assert.equal(legacy.overviewCards.length, 7);
|
||||
assert.equal((await call('/settings', a.cookie, 'PATCH', { overviewCards: [] })).status, 200);
|
||||
assert.deepEqual((await call('/auth/me', a.cookie)).data.overviewCards, []);
|
||||
await db.session.update({
|
||||
where: { id: a.sessionId },
|
||||
data: { expiresAt: new Date(Date.now() - 1000) },
|
||||
});
|
||||
assert.equal((await call('/auth/me', a.cookie)).status, 401);
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { id: { in: [a.id, b.id, c.id] } } });
|
||||
}
|
||||
});
|
||||
test('plan edits validate accounts, preserve history and reject hidden or foreign plans', async () => {
|
||||
const a = await fixture(),
|
||||
b = await fixture();
|
||||
try {
|
||||
const position = async (name: string, hidden = false) => {
|
||||
const r = await call('/positions', a.cookie, 'POST', {
|
||||
name,
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
category: 'cash',
|
||||
currency: 'CNY',
|
||||
amount: '100',
|
||||
date: '2026-09-01T10:00',
|
||||
hidden,
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
return r.data.id;
|
||||
};
|
||||
const source = await position('Source'),
|
||||
target = await position('Target'),
|
||||
hidden = await position('Hidden', true);
|
||||
const payload = {
|
||||
name: 'Original',
|
||||
operation: 'expense',
|
||||
sourceId: source,
|
||||
targetId: null,
|
||||
amount: '10',
|
||||
received: '0',
|
||||
nextAt: '2026-10-01T10:00',
|
||||
intervalDays: 30,
|
||||
notes: 'initial',
|
||||
};
|
||||
const created = await call('/schedules', a.cookie, 'POST', payload);
|
||||
assert.equal(created.status, 201);
|
||||
const id = created.data.id;
|
||||
const run = await call('/schedules/run', a.cookie, 'POST', {});
|
||||
assert.equal(run.data.executed, 1);
|
||||
const records = await db.revision.findMany({
|
||||
where: { positionId: source },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
const updated = {
|
||||
...payload,
|
||||
name: 'Edited transfer',
|
||||
operation: 'transfer',
|
||||
targetId: target,
|
||||
amount: '20.00000001',
|
||||
received: '20.00000001',
|
||||
nextAt: '2027-01-01T09:00',
|
||||
intervalDays: 7,
|
||||
notes: 'new memo',
|
||||
};
|
||||
assert.equal((await call('/schedules/' + id, b.cookie, 'PUT', updated)).status, 404);
|
||||
assert.equal(
|
||||
(await call('/schedules/' + id, a.cookie, 'PUT', { ...updated, targetId: hidden })).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/schedules/' + id, a.cookie, 'PUT', { ...updated, received: '21' })).status,
|
||||
400,
|
||||
);
|
||||
assert.equal((await call('/schedules/' + id, a.cookie, 'PUT', updated)).status, 200);
|
||||
const plans = (await call('/schedules', a.cookie)).data;
|
||||
const plan = plans.find((p: any) => p.id === id);
|
||||
assert.equal(plan.name, updated.name);
|
||||
assert.equal(plan.nextAt, updated.nextAt);
|
||||
assert.equal(plan.received, updated.received);
|
||||
assert.equal(plan.notes, updated.notes);
|
||||
assert.equal(plan.enabled, true);
|
||||
assert.deepEqual(
|
||||
await db.revision.findMany({ where: { positionId: source }, orderBy: { sequence: 'asc' } }),
|
||||
records,
|
||||
);
|
||||
const done = await call('/schedules', a.cookie, 'POST', {
|
||||
...payload,
|
||||
name: 'Once',
|
||||
intervalDays: 0,
|
||||
});
|
||||
await call('/schedules/run', a.cookie, 'POST', {});
|
||||
assert.equal((await call('/schedules/' + done.data.id, a.cookie, 'PUT', updated)).status, 400);
|
||||
await db.position.update({ where: { id: source }, data: { hidden: true } });
|
||||
assert.equal((await call('/schedules/' + id, a.cookie, 'PUT', updated)).status, 404);
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { id: { in: [a.id, b.id] } } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
@@ -285,6 +285,11 @@ test('icon processing creates transparent white cutouts and preserves brand colo
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const preserved = await sharp(await normalizeIcon(input, true))
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
assert.deepEqual([...preserved], [...pixels]);
|
||||
assert.equal(output[3], 0);
|
||||
assert.deepEqual([...output.subarray(4)], [10, 120, 60, 255]);
|
||||
});
|
||||
|
||||
@@ -46,12 +46,15 @@ import {
|
||||
type PageResult,
|
||||
} from './api';
|
||||
import './style.css';
|
||||
import { allOverviewCards, overviewCardLabels } from './overview-cards';
|
||||
import { useToast } from './Toast';
|
||||
import { GroupOrderContext } from './GroupOrderContext';
|
||||
import { GroupOrderList } from './GroupOrderList';
|
||||
import { orderedGroups, mergeGroupOrder } from './group-order';
|
||||
import { Calendar } from './Calendar';
|
||||
import { SchedulePanel } from './SchedulePanel';
|
||||
import { IconPicker } from './IconPicker';
|
||||
import { QuickTransfer } from './QuickTransfer';
|
||||
import { TransferForm } from './TransferForm';
|
||||
import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm';
|
||||
import type { Transfer } from './api';
|
||||
@@ -243,8 +246,42 @@ export default function App() {
|
||||
[error, setError] = useState(''),
|
||||
[success, setSuccess] = useState('');
|
||||
const [calendarRefresh, setCalendarRefresh] = useState(0);
|
||||
const [quickTransfer, setQuickTransfer] = useState<{
|
||||
sourceId?: string;
|
||||
targetId?: string;
|
||||
} | null>(null);
|
||||
const [quickMode, setQuickMode] = useState(false),
|
||||
[accountGroup, setAccountGroup] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
setQuickTransfer(null);
|
||||
}, [page, quickMode, selected, user?.revealed]);
|
||||
useEffect(() => {
|
||||
setQuickTransfer((current) =>
|
||||
current &&
|
||||
[current.sourceId, current.targetId].some(
|
||||
(id) =>
|
||||
id &&
|
||||
!positions.some(
|
||||
(p) => p.id === id && p.kind === 'account' && p.side === 'asset' && !p.archived,
|
||||
),
|
||||
)
|
||||
? null
|
||||
: current,
|
||||
);
|
||||
}, [positions]);
|
||||
function chooseQuickAccount(p: Position) {
|
||||
if (busy || loading) return;
|
||||
if (p.kind !== 'account' || p.side !== 'asset' || p.archived) {
|
||||
setError(tr('请选择启用的资产账户'));
|
||||
return;
|
||||
}
|
||||
setQuickTransfer((current) => {
|
||||
if (!current) return current;
|
||||
if (!current.sourceId) return { sourceId: p.id };
|
||||
if (!current.targetId && p.id !== current.sourceId) return { ...current, targetId: p.id };
|
||||
return current;
|
||||
});
|
||||
}
|
||||
const [fxStatus, setFxStatus] = useState<{
|
||||
state: string;
|
||||
message: string;
|
||||
@@ -261,6 +298,8 @@ export default function App() {
|
||||
} | null>(null);
|
||||
const [clearStep, setClearStep] = useState(0),
|
||||
[clearConfirmation, setClearConfirmation] = useState(0);
|
||||
useToast(error, 'error');
|
||||
useToast(success, 'success');
|
||||
const [settingsSection, setSettingsSection] = useState('general'),
|
||||
[transfers, setTransfers] = useState<Transfer[]>([]);
|
||||
const [historyRows, setHistoryRows] = useState<History[]>([]),
|
||||
@@ -305,6 +344,7 @@ export default function App() {
|
||||
setRegister(false);
|
||||
setLoading(false);
|
||||
setSuccess('');
|
||||
setQuickTransfer(null);
|
||||
}
|
||||
const report = (e: unknown) => {
|
||||
setError(e instanceof Error ? e.message : tr('操作失败'));
|
||||
@@ -714,11 +754,7 @@ export default function App() {
|
||||
<p className="muted">
|
||||
{register ? tr('建立属于你的私人资产空间') : tr('登录以查看你的资产与负债')}
|
||||
</p>
|
||||
{error && (
|
||||
<div role="alert" className="notice error">
|
||||
{tr(error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={auth}>
|
||||
<Field label={tr('账号')}>
|
||||
<input
|
||||
@@ -912,7 +948,7 @@ export default function App() {
|
||||
<div className="privacy-toolbar">
|
||||
<span>
|
||||
{user.revealed
|
||||
? tr('当前包含隐藏项目 · 验证 5 分钟后自动锁定')
|
||||
? tr('当前包含隐藏资产 · 5 分钟后自动锁定')
|
||||
: tr('当前不包含隐藏账户、资产和债务,净资产按此范围计算')}
|
||||
</span>
|
||||
<button
|
||||
@@ -920,11 +956,17 @@ export default function App() {
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
user.revealed
|
||||
? void act(() => api('/auth/lock', 'POST'), tr('隐藏项目已锁定'))
|
||||
: setModal({ kind: 'reveal' })
|
||||
? void act(() => api('/auth/lock', 'POST'), tr('隐藏资产已锁定'))
|
||||
: user.requireHiddenPassword === false
|
||||
? void act(() => api('/auth/reveal', 'POST', {}), tr('隐藏资产已显示'))
|
||||
: setModal({ kind: 'reveal' })
|
||||
}
|
||||
>
|
||||
{user.revealed ? tr('锁定隐藏项目') : tr('显示隐藏资产(验证密码)')}
|
||||
{user.revealed
|
||||
? tr('锁定隐藏资产')
|
||||
: user.requireHiddenPassword === false
|
||||
? tr('显示隐藏资产')
|
||||
: tr('显示隐藏资产(验证密码)')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="page-heading">
|
||||
@@ -947,28 +989,13 @@ export default function App() {
|
||||
<div className="actions">
|
||||
{page === 'account' && !p && (
|
||||
<button className="secondary" onClick={() => setModal({ kind: 'transfer' })}>
|
||||
{tr('账户间转账')}
|
||||
{tr('转账')}
|
||||
</button>
|
||||
)}
|
||||
{['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction}
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="notice error" role="alert">
|
||||
{error}
|
||||
<button className="text" onClick={() => setError('')}>
|
||||
{tr('关闭')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="notice success" role="status">
|
||||
{tr(success)}
|
||||
<button className="text" onClick={() => setSuccess('')}>
|
||||
{tr('关闭')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<p role="status" className="muted">
|
||||
{tr('正在刷新数据…')}
|
||||
@@ -976,6 +1003,20 @@ export default function App() {
|
||||
)}
|
||||
{page === 'overview' && overview && (
|
||||
<>
|
||||
{user.overviewCards?.length === 0 && (
|
||||
<section className="panel">
|
||||
<p>{tr('尚未选择总览卡片')}</p>
|
||||
<button
|
||||
className="text"
|
||||
onClick={() => {
|
||||
setPage('settings');
|
||||
setSettingsSection('display');
|
||||
}}
|
||||
>
|
||||
{tr('设置显示卡片')}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
{!overview.complete && (
|
||||
<div className="notice warning">
|
||||
{tr('缺少')}
|
||||
@@ -986,39 +1027,69 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="metrics">
|
||||
<div
|
||||
className="metrics"
|
||||
hidden={
|
||||
!['assets', 'liabilities', 'net'].some((key) =>
|
||||
(user.overviewCards ?? allOverviewCards).includes(key),
|
||||
)
|
||||
}
|
||||
>
|
||||
{[
|
||||
[tr('总资产'), overview.assets, 'asset'],
|
||||
[tr('总负债'), overview.liabilities, 'debt'],
|
||||
[tr('净资产'), overview.net, 'net'],
|
||||
].map(([title, value, cls]) => (
|
||||
<section className={'metric ' + cls} key={title}>
|
||||
<div>
|
||||
<span>
|
||||
{title}
|
||||
{!overview.complete ? tr('(部分)') : ''}
|
||||
</span>
|
||||
<ArrowUpRight size={18} />
|
||||
</div>
|
||||
<strong
|
||||
className={
|
||||
cls === 'debt' || value.startsWith('-') ? 'danger-text' : undefined
|
||||
}
|
||||
>
|
||||
{money(value, user.baseCurrency)}
|
||||
</strong>
|
||||
<small>
|
||||
{title === tr('净资产')
|
||||
? tr('总资产 − 总负债')
|
||||
: title === tr('总资产')
|
||||
? tr('账户资产 + 独立资产')
|
||||
: tr('负债账户 + 独立债务')}
|
||||
</small>
|
||||
</section>
|
||||
))}
|
||||
]
|
||||
.filter(([, , cls]) =>
|
||||
(user.overviewCards ?? allOverviewCards).includes(
|
||||
cls === 'asset' ? 'assets' : cls === 'debt' ? 'liabilities' : cls,
|
||||
),
|
||||
)
|
||||
.map(([title, value, cls]) => (
|
||||
<section className={'metric ' + cls} key={title}>
|
||||
<div>
|
||||
<span>
|
||||
{title}
|
||||
{!overview.complete ? tr('(部分)') : ''}
|
||||
</span>
|
||||
<ArrowUpRight size={18} />
|
||||
</div>
|
||||
<strong
|
||||
className={
|
||||
cls === 'debt' || value.startsWith('-') ? 'danger-text' : undefined
|
||||
}
|
||||
>
|
||||
{money(value, user.baseCurrency)}
|
||||
</strong>
|
||||
<small>
|
||||
{title === tr('净资产')
|
||||
? tr('总资产 − 总负债')
|
||||
: title === tr('总资产')
|
||||
? tr('账户资产 + 独立资产')
|
||||
: tr('负债账户 + 独立债务')}
|
||||
</small>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<section className="panel">
|
||||
<div
|
||||
className={
|
||||
'dashboard-grid' +
|
||||
(!['trend', 'composition'].every((key) =>
|
||||
(user.overviewCards ?? allOverviewCards).includes(key),
|
||||
)
|
||||
? ' single-card'
|
||||
: '')
|
||||
}
|
||||
hidden={
|
||||
!['trend', 'composition'].some((key) =>
|
||||
(user.overviewCards ?? allOverviewCards).includes(key),
|
||||
)
|
||||
}
|
||||
>
|
||||
<section
|
||||
className="panel"
|
||||
hidden={!(user.overviewCards ?? allOverviewCards).includes('trend')}
|
||||
>
|
||||
<div className="panel-title">
|
||||
<h2>{tr('净资产轨迹')}</h2>
|
||||
<span className="badge">{tr('按业务日期')}</span>
|
||||
@@ -1063,7 +1134,10 @@ export default function App() {
|
||||
{tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')}
|
||||
</p>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<section
|
||||
className="panel"
|
||||
hidden={!(user.overviewCards ?? allOverviewCards).includes('composition')}
|
||||
>
|
||||
<h2>{tr('资产与负债组成')}</h2>
|
||||
{overview.items.length ? (
|
||||
['account', 'asset', 'debt'].map((k) => (
|
||||
@@ -1097,7 +1171,10 @@ export default function App() {
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<section className="panel">
|
||||
<section
|
||||
className="panel"
|
||||
hidden={!(user.overviewCards ?? allOverviewCards).includes('recent')}
|
||||
>
|
||||
<div className="panel-title">
|
||||
<h2>{tr('最近变化')}</h2>
|
||||
<button className="text" onClick={() => setPage('history')}>
|
||||
@@ -1124,43 +1201,44 @@ export default function App() {
|
||||
].join(' / ') || tr('尚无外币汇率')}{' '}
|
||||
{tr('· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。')}
|
||||
</div>
|
||||
{overview.trend.length > 1 && (
|
||||
<details className="panel attribution">
|
||||
<summary>{tr('变化归因')}</summary>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{tr('日期')}</th>
|
||||
<th>{tr('余额 / 估值变化')}</th>
|
||||
<th>{tr('汇率变化')}</th>
|
||||
<th>{tr('净资产')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{overview.trend.slice(-10).map((r) => (
|
||||
<tr key={r.date}>
|
||||
<td>{r.date}</td>
|
||||
<td>
|
||||
{r.balanceChange === null || r.balanceChange === undefined
|
||||
? '—'
|
||||
: money(r.balanceChange, user.baseCurrency)}
|
||||
</td>
|
||||
<td>
|
||||
{r.fxChange === null || r.fxChange === undefined
|
||||
? '—'
|
||||
: money(r.fxChange, user.baseCurrency)}
|
||||
</td>
|
||||
<td>
|
||||
{r.complete ? money(r.net, user.baseCurrency) : tr('汇率不完整')}
|
||||
</td>
|
||||
{(user.overviewCards ?? allOverviewCards).includes('attribution') &&
|
||||
overview.trend.length > 1 && (
|
||||
<details className="panel attribution">
|
||||
<summary>{tr('变化归因')}</summary>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{tr('日期')}</th>
|
||||
<th>{tr('余额 / 估值变化')}</th>
|
||||
<th>{tr('汇率变化')}</th>
|
||||
<th>{tr('净资产')}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{overview.trend.slice(-10).map((r) => (
|
||||
<tr key={r.date}>
|
||||
<td>{r.date}</td>
|
||||
<td>
|
||||
{r.balanceChange === null || r.balanceChange === undefined
|
||||
? '—'
|
||||
: money(r.balanceChange, user.baseCurrency)}
|
||||
</td>
|
||||
<td>
|
||||
{r.fxChange === null || r.fxChange === undefined
|
||||
? '—'
|
||||
: money(r.fxChange, user.baseCurrency)}
|
||||
</td>
|
||||
<td>
|
||||
{r.complete ? money(r.net, user.baseCurrency) : tr('汇率不完整')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{['account', 'asset', 'debt'].includes(page) &&
|
||||
@@ -1188,8 +1266,8 @@ export default function App() {
|
||||
<h2 className={positionAmount(p).startsWith('-') ? 'danger-text' : undefined}>
|
||||
{money(positionAmount(p), p.currency)}
|
||||
</h2>
|
||||
{p.hidden && <span className="badge">{tr('隐藏项目')}</span>}
|
||||
<p className="muted">
|
||||
{p.hidden && <span className="badge">{tr('隐藏资产')}</span>}
|
||||
<p className="muted" hidden={p.currency === user.baseCurrency}>
|
||||
{tr('本位币:')}
|
||||
{p.converted !== null
|
||||
? money(positionAmount(p, p.converted || '0'), user.baseCurrency)
|
||||
@@ -1205,7 +1283,7 @@ export default function App() {
|
||||
className="primary"
|
||||
onClick={() => setModal({ kind: 'transfer', p })}
|
||||
>
|
||||
{tr('账户间转账')}
|
||||
{tr('转账')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
@@ -1347,11 +1425,24 @@ export default function App() {
|
||||
{tr('分组排序')}
|
||||
</button>
|
||||
</div>
|
||||
{quickMode && <p className="muted">{tr('点击账户卡片,直接填写当前余额。')}</p>}
|
||||
{quickMode && (
|
||||
<p className="muted">
|
||||
{tr(
|
||||
quickTransfer !== null
|
||||
? '快速转账中:依次点击转出和转入账户卡片。'
|
||||
: '点击账户卡片,直接填写当前余额。',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{positions.some((p) => p.kind === page) ? (
|
||||
<div className="position-grid">
|
||||
<div
|
||||
className={
|
||||
'position-grid' +
|
||||
(quickTransfer !== null && page === 'account' ? ' quick-transfer-select' : '')
|
||||
}
|
||||
>
|
||||
{positions
|
||||
.filter(
|
||||
(p) =>
|
||||
@@ -1375,12 +1466,29 @@ export default function App() {
|
||||
<h3 className="account-group-title">{p.groupName || tr('未分组')}</h3>
|
||||
)}
|
||||
<button
|
||||
className="position-card"
|
||||
className={
|
||||
'position-card' +
|
||||
(quickTransfer?.sourceId === p.id
|
||||
? ' quick-source'
|
||||
: quickTransfer?.targetId === p.id
|
||||
? ' quick-target'
|
||||
: '')
|
||||
}
|
||||
aria-pressed={
|
||||
quickTransfer
|
||||
? quickTransfer.sourceId === p.id || quickTransfer.targetId === p.id
|
||||
: undefined
|
||||
}
|
||||
disabled={
|
||||
busy || (!!quickTransfer && (p.side !== 'asset' || p.archived))
|
||||
}
|
||||
key={p.id}
|
||||
onClick={() =>
|
||||
page === 'account' && quickMode && !p.archived
|
||||
? setModal({ kind: 'revision', p })
|
||||
: setSelected(p.id)
|
||||
quickTransfer !== null && page === 'account' && quickMode
|
||||
? chooseQuickAccount(p)
|
||||
: page === 'account' && quickMode && !p.archived
|
||||
? setModal({ kind: 'revision', p })
|
||||
: setSelected(p.id)
|
||||
}
|
||||
>
|
||||
<div>
|
||||
@@ -1419,7 +1527,7 @@ export default function App() {
|
||||
>
|
||||
{money(positionAmount(p), p.currency)}
|
||||
</strong>
|
||||
<small>
|
||||
<small hidden={p.currency === user.baseCurrency}>
|
||||
{tr('折合')}{' '}
|
||||
{p.converted === null
|
||||
? tr('缺少汇率')
|
||||
@@ -1438,6 +1546,74 @@ export default function App() {
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{page === 'account' && quickMode && !selected && (
|
||||
<aside className="quick-transfer-widget" aria-label={tr('快速转账')}>
|
||||
{quickTransfer === null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="primary quick-transfer-toggle"
|
||||
disabled={
|
||||
busy ||
|
||||
loading ||
|
||||
positions.filter(
|
||||
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
|
||||
).length < 2
|
||||
}
|
||||
onClick={() => setQuickTransfer({})}
|
||||
>
|
||||
{tr('转账')}
|
||||
</button>
|
||||
) : (
|
||||
<section className="quick-transfer-card">
|
||||
<div className="panel-title">
|
||||
<h2>{tr('快速转账')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="icon"
|
||||
aria-label={tr('关闭快速转账')}
|
||||
disabled={busy}
|
||||
onClick={() => setQuickTransfer(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{!quickTransfer.sourceId ? (
|
||||
<p>{tr('点击账户卡片选择转出账户')}</p>
|
||||
) : !quickTransfer.targetId ? (
|
||||
<>
|
||||
<p>
|
||||
{tr('转出账户:')}
|
||||
{positions.find((p) => p.id === quickTransfer.sourceId)?.name}
|
||||
</p>
|
||||
<p>{tr('点击另一张账户卡片选择转入账户')}</p>
|
||||
<button type="button" className="text" onClick={() => setQuickTransfer({})}>
|
||||
{tr('重新选择')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
positions.some((p) => p.id === quickTransfer.sourceId) &&
|
||||
positions.some((p) => p.id === quickTransfer.targetId) && (
|
||||
<QuickTransfer
|
||||
key={quickTransfer.sourceId + quickTransfer.targetId}
|
||||
source={positions.find((p) => p.id === quickTransfer.sourceId)!}
|
||||
target={positions.find((p) => p.id === quickTransfer.targetId)!}
|
||||
busy={busy}
|
||||
changeSource={() => setQuickTransfer({})}
|
||||
changeTarget={() => setQuickTransfer({ sourceId: quickTransfer.sourceId })}
|
||||
submit={(data) =>
|
||||
void act(async () => {
|
||||
const result = await api('/transfers', 'POST', data);
|
||||
setQuickTransfer(null);
|
||||
return result;
|
||||
}, tr('转账已完成'))
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{page === 'history' && (
|
||||
<>
|
||||
<section className="panel">
|
||||
@@ -1521,7 +1697,7 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">{tr('暂无账户间转账记录。')}</p>
|
||||
<p className="muted">{tr('暂无转账记录。')}</p>
|
||||
)}
|
||||
{transferCursor && (
|
||||
<button disabled={loading} onClick={() => void more('transfers')}>
|
||||
@@ -1565,24 +1741,28 @@ export default function App() {
|
||||
)}
|
||||
{page === 'settings' && (
|
||||
<>
|
||||
<section className="settings-switch panel">
|
||||
<label>
|
||||
{tr('设置分类')}
|
||||
<select
|
||||
value={settingsSection}
|
||||
onChange={(e) => setSettingsSection(e.target.value)}
|
||||
<nav className="settings-tabs" aria-label={tr('设置分类')}>
|
||||
{Object.entries({
|
||||
general: '常规设置',
|
||||
display: '显示与菜单',
|
||||
privacy: '登录与隐私',
|
||||
security: '账号与密码',
|
||||
icons: '图标库',
|
||||
rates: '每日汇率',
|
||||
backup: '备份与恢复',
|
||||
clear: '清空本账号数据',
|
||||
}).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-current={settingsSection === key ? 'page' : undefined}
|
||||
className={key === 'clear' ? 'danger-text' : undefined}
|
||||
onClick={() => setSettingsSection(key)}
|
||||
>
|
||||
<option value="general">{tr('个人与菜单设置')}</option>
|
||||
<option value="security">{tr('账号与密码')}</option>
|
||||
<option value="icons">{tr('图标库')}</option>
|
||||
<option value="rates">{tr('每日汇率')}</option>
|
||||
<option value="backup">{tr('备份与恢复')}</option>
|
||||
<option className="danger-text" value="clear">
|
||||
{tr('清空本账号数据')}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
{tr(label)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{settingsSection === 'icons' && (
|
||||
<section className="panel">
|
||||
<IconLibrary />
|
||||
@@ -1662,7 +1842,7 @@ export default function App() {
|
||||
<div className="settings-grid">
|
||||
<section className="panel" hidden={settingsSection !== 'general'}>
|
||||
<div className="panel-title">
|
||||
<h2>{tr('个人与菜单设置')}</h2>
|
||||
<h2>{tr('常规设置')}</h2>
|
||||
<button className="text" disabled={busy} onClick={() => void logout()}>
|
||||
<LogOut size={16} />
|
||||
{tr('退出登录')}
|
||||
@@ -1673,6 +1853,34 @@ export default function App() {
|
||||
{user.username}
|
||||
{tr('。切换本位币会重新换算整个历史,原币数据保留。')}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget);
|
||||
void act(
|
||||
() => api('/settings', 'PATCH', { baseCurrency: f.get('baseCurrency') }),
|
||||
tr('设置已保存'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Field label={tr('本位币')}>
|
||||
<select
|
||||
name="baseCurrency"
|
||||
defaultValue={user.baseCurrency}
|
||||
key={user.baseCurrency}
|
||||
>
|
||||
{currencies.map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<button className="primary" disabled={busy}>
|
||||
{tr('保存设置')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section className="panel" hidden={settingsSection !== 'display'}>
|
||||
<h2>{tr('显示与菜单')}</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -1680,7 +1888,6 @@ export default function App() {
|
||||
void act(
|
||||
() =>
|
||||
api('/settings', 'PATCH', {
|
||||
baseCurrency: f.get('baseCurrency'),
|
||||
hiddenMenus: [
|
||||
'overview',
|
||||
'account',
|
||||
@@ -1690,13 +1897,14 @@ export default function App() {
|
||||
'calendar',
|
||||
'schedules',
|
||||
].filter((key) => !f.has('menu_' + key)),
|
||||
idleMinutes: Number(f.get('idleMinutes')),
|
||||
showNotes: f.has('showNotes'),
|
||||
overviewCards: allOverviewCards.filter((key) => f.has('card_' + key)),
|
||||
}),
|
||||
tr('个人设置已保存'),
|
||||
tr('设置已保存'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<h3>{tr('导航菜单')}</h3>
|
||||
<p className="muted">
|
||||
{tr('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')}
|
||||
</p>
|
||||
@@ -1743,6 +1951,76 @@ export default function App() {
|
||||
<p className="muted">
|
||||
{tr('关闭后隐藏列表、详情及编辑表单中的备注,已有备注仍会保留在备份中。')}
|
||||
</p>
|
||||
|
||||
<h3>{tr('总览显示卡片')}</h3>
|
||||
<p className="muted">
|
||||
{tr('选择资产总览需要显示的卡片,数据和记录仍会保留。')}
|
||||
</p>
|
||||
<div className="menu-preferences">
|
||||
{allOverviewCards.map((key) => (
|
||||
<label className="switch-row" key={key}>
|
||||
<span>{tr(overviewCardLabels[key])}</span>
|
||||
<input
|
||||
name={'card_' + key}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label={tr(overviewCardLabels[key]) + tr('显示')}
|
||||
key={key + String(user.overviewCards)}
|
||||
defaultChecked={(user.overviewCards ?? allOverviewCards).includes(key)}
|
||||
/>
|
||||
<span className="switch-state" aria-hidden="true">
|
||||
{tr('显示')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button className="primary" disabled={busy}>
|
||||
{tr('保存设置')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section className="panel" hidden={settingsSection !== 'privacy'}>
|
||||
<h2>{tr('登录与隐私')}</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget);
|
||||
void act(
|
||||
() =>
|
||||
api('/settings', 'PATCH', {
|
||||
sessionHours: Number(f.get('sessionHours')),
|
||||
idleMinutes: Number(f.get('idleMinutes')),
|
||||
requireHiddenPassword: f.has('requireHiddenPassword'),
|
||||
}),
|
||||
tr('设置已保存'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Field label={tr('登录有效期(小时)')}>
|
||||
<input
|
||||
name="sessionHours"
|
||||
type="number"
|
||||
min="1"
|
||||
max="720"
|
||||
step="1"
|
||||
required
|
||||
defaultValue={user.sessionHours ?? 168}
|
||||
key={user.sessionHours}
|
||||
/>
|
||||
</Field>
|
||||
<p className="muted">
|
||||
{tr(
|
||||
'范围 1 小时至 30 天(720 小时)。保存后当前登录重新计时,其他会话沿用原到期时间;后续登录使用新设置。',
|
||||
)}
|
||||
</p>
|
||||
{user.sessionExpiresAt && (
|
||||
<p className="muted">
|
||||
{tr('当前会话到期:')}
|
||||
{new Date(user.sessionExpiresAt).toLocaleString(locale, {
|
||||
timeZone: 'Asia/Hong_Kong',
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<Field label={tr('无操作自动退出(分钟)')}>
|
||||
<select
|
||||
name="idleMinutes"
|
||||
@@ -1763,17 +2041,23 @@ export default function App() {
|
||||
'0 为关闭;最长 1440 分钟。到时退出登录并清除页面数据,浏览器标签页保留。',
|
||||
)}
|
||||
</p>
|
||||
<Field label={tr('本位币')}>
|
||||
<select
|
||||
name="baseCurrency"
|
||||
defaultValue={user.baseCurrency}
|
||||
key={user.baseCurrency}
|
||||
>
|
||||
{currencies.map((c) => (
|
||||
<option key={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<label className="switch-row">
|
||||
<span>{tr('查看隐藏资产需要密码')}</span>
|
||||
<input
|
||||
name="requireHiddenPassword"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label={tr('查看隐藏资产需要密码')}
|
||||
key={String(user.requireHiddenPassword)}
|
||||
defaultChecked={user.requireHiddenPassword !== false}
|
||||
/>
|
||||
</label>
|
||||
<p className="muted">
|
||||
{tr(
|
||||
'关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。',
|
||||
)}
|
||||
</p>
|
||||
<button className="primary" disabled={busy}>
|
||||
{tr('保存设置')}
|
||||
</button>
|
||||
@@ -1856,7 +2140,7 @@ export default function App() {
|
||||
<h2 className="danger-text">{tr('清空本账号数据')}</h2>
|
||||
<p className="muted">
|
||||
{tr(
|
||||
'清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏项目);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。',
|
||||
'清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏资产);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。',
|
||||
)}
|
||||
</p>
|
||||
{clearStep === 0 ? (
|
||||
@@ -2080,11 +2364,6 @@ export default function App() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="notice error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
{modal?.kind === 'group-order' && modal.groups && (
|
||||
@@ -2146,11 +2425,7 @@ export default function App() {
|
||||
)}
|
||||
</p>
|
||||
<p className="danger-text">{tr('删除后无法撤销,请确认已保存所需备份。')}</p>
|
||||
{error && (
|
||||
<p role="alert" className="notice error">
|
||||
{tr(error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="modal-actions clear-confirm-actions">
|
||||
<button autoFocus disabled={busy} onClick={() => setModal(null)}>
|
||||
{tr('取消')}
|
||||
@@ -2234,11 +2509,7 @@ export default function App() {
|
||||
<Field label={tr('备注')}>
|
||||
<textarea name="notes" maxLength={2000} defaultValue={modal.transfer.notes} />
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="notice error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button className="primary" disabled={busy}>
|
||||
{tr(busy ? '正在保存…' : '保存')}
|
||||
</button>
|
||||
@@ -2253,7 +2524,7 @@ export default function App() {
|
||||
? operationLabel(modal.p.side === 'asset' ? 'collect' : 'repay')
|
||||
: tr('收款 / 还款')
|
||||
: modal.kind === 'transfer'
|
||||
? tr('账户间转账')
|
||||
? tr('转账')
|
||||
: modal.kind === 'reveal'
|
||||
? tr('验证密码以显示隐藏资产')
|
||||
: modal.kind === 'edit'
|
||||
@@ -2322,7 +2593,7 @@ export default function App() {
|
||||
if (kind === 'reveal') {
|
||||
void act(
|
||||
() => api('/auth/reveal', 'POST', { password: f.get('password') }),
|
||||
tr('隐藏项目已解锁 5 分钟'),
|
||||
tr('隐藏资产已解锁 5 分钟'),
|
||||
);
|
||||
} else if (kind === 'links') {
|
||||
void act(
|
||||
@@ -2571,11 +2842,7 @@ export default function App() {
|
||||
</Field>
|
||||
)
|
||||
)}
|
||||
{error && (
|
||||
<div role="alert" className="notice error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="secondary"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, money, today } from './api';
|
||||
import { useToast } from './Toast';
|
||||
import { t as tr } from './i18n';
|
||||
type Day = { date: string; income: string; expense: string; complete: boolean };
|
||||
type Entry = {
|
||||
@@ -25,6 +26,7 @@ export function Calendar({
|
||||
[base, setBase] = useState('CNY'),
|
||||
[error, setError] = useState(''),
|
||||
[loading, setLoading] = useState(false);
|
||||
useToast(error, 'error');
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setDays([]);
|
||||
@@ -158,11 +160,6 @@ export function Calendar({
|
||||
<p>{tr('当天没有收支记录')}</p>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="notice error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t as tr } from './i18n';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { api } from './api';
|
||||
import { useToast } from './Toast';
|
||||
type Icon = { id: string; name: string; shared: boolean; source: string | null };
|
||||
export const iconUrl = (id: string) => '/api/icons/' + encodeURIComponent(id) + '/image';
|
||||
export function IconLibrary({
|
||||
@@ -26,6 +27,8 @@ export function IconLibrary({
|
||||
[notice, setNotice] = useState(''),
|
||||
[refresh, setRefresh] = useState(0);
|
||||
const file = useRef<HTMLInputElement>(null);
|
||||
useToast(error, 'error');
|
||||
useToast(notice, 'success');
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
@@ -236,12 +239,6 @@ export function IconLibrary({
|
||||
{busy ? tr('正在上传…') : shared ? tr('导入共享图标') : tr('上传私有图标')}
|
||||
</button>
|
||||
</details>
|
||||
{error && (
|
||||
<p role="alert" className="icon-error">
|
||||
{tr(error)}
|
||||
</p>
|
||||
)}
|
||||
{notice && <p role="status">{tr(notice)}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { money, nowMinute, requestUuid, type Position } from './api';
|
||||
import { t as tr } from './i18n';
|
||||
export function QuickTransfer({
|
||||
source,
|
||||
target,
|
||||
busy,
|
||||
changeSource,
|
||||
changeTarget,
|
||||
submit,
|
||||
}: {
|
||||
source: Position;
|
||||
target: Position;
|
||||
busy: boolean;
|
||||
changeSource: () => void;
|
||||
changeTarget: () => void;
|
||||
submit: (data: unknown) => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState('');
|
||||
const [requestId] = useState(requestUuid);
|
||||
const same = source.currency === target.currency;
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget);
|
||||
submit({
|
||||
requestId,
|
||||
sourceId: source.id,
|
||||
targetId: target.id,
|
||||
amount,
|
||||
received: same ? amount : String(f.get('received')),
|
||||
fee: '0',
|
||||
notes: '',
|
||||
date: nowMinute(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="quick-transfer-pair">
|
||||
<div>
|
||||
<small>{tr('转出账户')}</small>
|
||||
<strong>{source.name}</strong>
|
||||
<span>{money(source.amount, source.currency)}</span>
|
||||
<button type="button" className="text" disabled={busy} onClick={changeSource}>
|
||||
{tr('重选')}
|
||||
</button>
|
||||
</div>
|
||||
<span aria-hidden="true">→</span>
|
||||
<div>
|
||||
<small>{tr('转入账户')}</small>
|
||||
<strong>{target.name}</strong>
|
||||
<span>{money(target.amount, target.currency)}</span>
|
||||
<button type="button" className="text" disabled={busy} onClick={changeTarget}>
|
||||
{tr('重选')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
{tr('转出金额(')}
|
||||
{source.currency})
|
||||
<input
|
||||
name="amount"
|
||||
required
|
||||
inputMode="decimal"
|
||||
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
{!same && (
|
||||
<label>
|
||||
{tr('实际到账(')}
|
||||
{target.currency})
|
||||
<input
|
||||
name="received"
|
||||
required
|
||||
inputMode="decimal"
|
||||
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<p className="muted">{tr('按当前时间记录,手续费为 0。跨币种请填写实际到账金额。')}</p>
|
||||
<button className="primary" disabled={busy}>
|
||||
{tr(busy ? '正在转账…' : '确认转账')}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { api, money, nowMinute, type Position } from './api';
|
||||
import { AccountPicker } from './AccountPicker';
|
||||
import { useToast } from './Toast';
|
||||
import { t as tr } from './i18n';
|
||||
type Plan = {
|
||||
id: string;
|
||||
@@ -12,6 +13,9 @@ type Plan = {
|
||||
enabled: boolean;
|
||||
completed: boolean;
|
||||
sourceId: string;
|
||||
targetId: string | null;
|
||||
received: string;
|
||||
notes: string;
|
||||
};
|
||||
export function SchedulePanel({
|
||||
accounts,
|
||||
@@ -21,10 +25,24 @@ export function SchedulePanel({
|
||||
refresh: () => Promise<void>;
|
||||
}) {
|
||||
const [rows, setRows] = useState<Plan[]>([]),
|
||||
[open, setOpen] = useState(false),
|
||||
[editor, setEditor] = useState<Plan | null | undefined>(undefined),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState(''),
|
||||
[status, setStatus] = useState('');
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useToast(error, 'error');
|
||||
useToast(status, 'success');
|
||||
useEffect(() => {
|
||||
if (editor !== undefined) dialog.current?.showModal();
|
||||
}, [editor]);
|
||||
function openEditor(plan: Plan | null) {
|
||||
setError('');
|
||||
setOperation(plan?.operation ?? 'expense');
|
||||
setSource(plan?.sourceId ?? accounts[0]?.id ?? '');
|
||||
setTarget(plan?.targetId ?? '');
|
||||
setAmount(plan?.amount ?? '');
|
||||
setEditor(plan);
|
||||
}
|
||||
const [operation, setOperation] = useState('expense'),
|
||||
[source, setSource] = useState(''),
|
||||
[target, setTarget] = useState(''),
|
||||
@@ -35,6 +53,7 @@ export function SchedulePanel({
|
||||
const same = sourceAccount?.currency === targetAccount?.currency;
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setRows([]);
|
||||
void api<Plan[]>('/schedules')
|
||||
.then((v) => {
|
||||
if (active) setRows(v);
|
||||
@@ -69,8 +88,8 @@ export function SchedulePanel({
|
||||
{tr('打开账户页或点击执行时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。')}
|
||||
</p>
|
||||
<div className="actions">
|
||||
<button type="button" disabled={busy || !accounts.length} onClick={() => setOpen(!open)}>
|
||||
{tr(open ? '取消' : '添加计划')}
|
||||
<button type="button" disabled={busy || !accounts.length} onClick={() => openEditor(null)}>
|
||||
{tr('添加计划')}
|
||||
</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
@@ -93,104 +112,149 @@ export function SchedulePanel({
|
||||
{tr('执行到期计划')}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(e.currentTarget));
|
||||
void act(async () => {
|
||||
await api('/schedules', 'POST', {
|
||||
...data,
|
||||
operation,
|
||||
sourceId,
|
||||
targetId: operation === 'transfer' ? target : null,
|
||||
received: operation === 'transfer' ? (same ? amount : data.received) : '0',
|
||||
intervalDays: Number(data.intervalDays),
|
||||
});
|
||||
setOpen(false);
|
||||
});
|
||||
{editor !== undefined && (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="picker-dialog schedule-dialog"
|
||||
aria-label={tr(editor ? '修改计划' : '添加计划')}
|
||||
onCancel={(e) => {
|
||||
if (busy) e.preventDefault();
|
||||
else setEditor(undefined);
|
||||
}}
|
||||
onClose={() => setEditor(undefined)}
|
||||
>
|
||||
<label>
|
||||
{tr('计划名称')}
|
||||
<input name="name" required maxLength={100} />
|
||||
</label>
|
||||
<label>
|
||||
{tr('操作')}
|
||||
<select value={operation} onChange={(e) => setOperation(e.target.value)}>
|
||||
<option value="expense">{tr('支出')}</option>
|
||||
<option value="transfer">{tr('转账')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{tr('付款账户')}
|
||||
<AccountPicker
|
||||
name="sourceId"
|
||||
accounts={accounts}
|
||||
value={sourceId}
|
||||
onChange={(id) => {
|
||||
setSource(id);
|
||||
if (target === id) setTarget('');
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{operation === 'transfer' && (
|
||||
<div className="panel-title">
|
||||
<h2>{tr(editor ? '修改计划' : '添加计划')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="icon"
|
||||
aria-label={tr('关闭')}
|
||||
disabled={busy}
|
||||
onClick={() => setEditor(undefined)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
key={editor?.id ?? 'new'}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(e.currentTarget));
|
||||
void act(async () => {
|
||||
await api(
|
||||
editor ? '/schedules/' + editor.id : '/schedules',
|
||||
editor ? 'PUT' : 'POST',
|
||||
{
|
||||
...data,
|
||||
operation,
|
||||
sourceId,
|
||||
targetId: operation === 'transfer' ? target : null,
|
||||
received: operation === 'transfer' ? (same ? amount : data.received) : '0',
|
||||
intervalDays: Number(data.intervalDays),
|
||||
},
|
||||
);
|
||||
setEditor(undefined);
|
||||
setStatus(tr('计划已保存'));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
{tr('转入账户')}
|
||||
{tr('计划名称')}
|
||||
<input name="name" required maxLength={100} defaultValue={editor?.name} />
|
||||
</label>
|
||||
<label>
|
||||
{tr('操作')}
|
||||
<select value={operation} onChange={(e) => setOperation(e.target.value)}>
|
||||
<option value="expense">{tr('支出')}</option>
|
||||
<option value="transfer">{tr('转账')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{tr('付款账户')}
|
||||
<AccountPicker
|
||||
name="targetId"
|
||||
name="sourceId"
|
||||
accounts={accounts}
|
||||
value={target}
|
||||
onChange={setTarget}
|
||||
excludeId={sourceId}
|
||||
value={sourceId}
|
||||
onChange={(id) => {
|
||||
setSource(id);
|
||||
if (target === id) setTarget('');
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{tr('金额')} ({sourceAccount?.currency})
|
||||
<input
|
||||
name="amount"
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
|
||||
/>
|
||||
</label>
|
||||
{operation === 'transfer' && !same && (
|
||||
{operation === 'transfer' && (
|
||||
<label>
|
||||
{tr('转入账户')}
|
||||
<AccountPicker
|
||||
name="targetId"
|
||||
accounts={accounts}
|
||||
value={target}
|
||||
onChange={setTarget}
|
||||
excludeId={sourceId}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{tr('实际到账')} ({targetAccount?.currency})
|
||||
{tr('金额')} ({sourceAccount?.currency})
|
||||
<input
|
||||
name="received"
|
||||
name="amount"
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{tr('首次执行时间')}
|
||||
<input name="nextAt" type="datetime-local" required defaultValue={nowMinute()} />
|
||||
</label>
|
||||
<label>
|
||||
{tr('重复间隔(天,0为仅一次)')}
|
||||
<input
|
||||
name="intervalDays"
|
||||
type="number"
|
||||
min="0"
|
||||
max="3650"
|
||||
required
|
||||
defaultValue="30"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{tr('备注')}
|
||||
<textarea name="notes" maxLength={1800} />
|
||||
</label>
|
||||
<button className="primary" disabled={busy || (operation === 'transfer' && !target)}>
|
||||
{tr('保存计划')}
|
||||
</button>
|
||||
</form>
|
||||
{operation === 'transfer' && !same && (
|
||||
<label>
|
||||
{tr('实际到账')} ({targetAccount?.currency})
|
||||
<input
|
||||
name="received"
|
||||
defaultValue={editor?.received}
|
||||
required
|
||||
inputMode="decimal"
|
||||
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{tr(editor ? '下次执行时间' : '首次执行时间')}
|
||||
<input
|
||||
name="nextAt"
|
||||
type="datetime-local"
|
||||
required
|
||||
defaultValue={editor?.nextAt ?? nowMinute()}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{tr('重复间隔(天,0为仅一次)')}
|
||||
<input
|
||||
name="intervalDays"
|
||||
type="number"
|
||||
min="0"
|
||||
max="3650"
|
||||
required
|
||||
defaultValue={editor?.intervalDays ?? 30}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{tr('备注')}
|
||||
<textarea
|
||||
className="note-input"
|
||||
name="notes"
|
||||
maxLength={2000}
|
||||
defaultValue={editor?.notes}
|
||||
/>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" disabled={busy} onClick={() => setEditor(undefined)}>
|
||||
{tr('取消')}
|
||||
</button>
|
||||
<button className="primary" disabled={busy || (operation === 'transfer' && !target)}>
|
||||
{tr('保存计划')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
)}
|
||||
{rows.map((p) => (
|
||||
<div className="schedule-row" key={p.id}>
|
||||
@@ -205,6 +269,17 @@ export function SchedulePanel({
|
||||
{tr(p.completed ? '已完成' : p.enabled ? '启用' : '已暂停')}
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
disabled={
|
||||
busy ||
|
||||
p.completed ||
|
||||
!accounts.some((a) => a.id === p.sourceId) ||
|
||||
(!!p.targetId && !accounts.some((a) => a.id === p.targetId))
|
||||
}
|
||||
onClick={() => openEditor(p)}
|
||||
>
|
||||
{tr('修改计划')}
|
||||
</button>
|
||||
<button
|
||||
disabled={busy || p.completed}
|
||||
onClick={() =>
|
||||
@@ -221,12 +296,6 @@ export function SchedulePanel({
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{error && (
|
||||
<p role="alert" className="notice error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{status && <p role="status">{status}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { t as tr } from './i18n';
|
||||
type Kind = 'success' | 'error';
|
||||
type Notice = { id: number; message: string; kind: Kind };
|
||||
let sequence = 0;
|
||||
const listeners = new Set<(notice: Notice) => void>();
|
||||
export function useToast(message: string, kind: Kind) {
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
const notice = { id: ++sequence, message, kind };
|
||||
listeners.forEach((fn) => fn(notice));
|
||||
}
|
||||
}, [message, kind]);
|
||||
}
|
||||
function ToastItem({ notice, remove }: { notice: Notice; remove: (id: number) => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => remove(notice.id), notice.kind === 'error' ? 12000 : 6000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [notice, remove]);
|
||||
return (
|
||||
<div className={'toast ' + notice.kind} role={notice.kind === 'error' ? 'alert' : 'status'}>
|
||||
<span className="toast-symbol" aria-hidden="true">
|
||||
{notice.kind === 'error' ? '!' : '✓'}
|
||||
</span>
|
||||
<p>{tr(notice.message)}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="icon"
|
||||
aria-label={tr('关闭')}
|
||||
onClick={() => remove(notice.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function ToastViewport() {
|
||||
const [notices, setNotices] = useState<Notice[]>([]);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const [host, setHost] = useState<HTMLElement>(document.body);
|
||||
useEffect(() => {
|
||||
// A native modal makes content outside it inert. Keep the live notification
|
||||
// and its dismiss control within the current dialog, while the popover
|
||||
// renders them above the dialog in the browser's top layer.
|
||||
const updateHost = () => {
|
||||
const dialogs = document.querySelectorAll<HTMLElement>(
|
||||
'dialog[open], [role="dialog"][aria-modal="true"]',
|
||||
);
|
||||
const next = dialogs.item(dialogs.length - 1) ?? document.body;
|
||||
setHost((current) => (current === next ? current : next));
|
||||
};
|
||||
const observer = new MutationObserver(updateHost);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['open'],
|
||||
});
|
||||
updateHost();
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const remove = useRef((id: number) =>
|
||||
setNotices((rows) => rows.filter((n) => n.id !== id)),
|
||||
).current;
|
||||
useEffect(() => {
|
||||
const receive = (n: Notice) =>
|
||||
setNotices((rows) => [...rows.filter((v) => v.message !== n.message).slice(-2), n]);
|
||||
listeners.add(receive);
|
||||
return () => {
|
||||
listeners.delete(receive);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const el = panel.current;
|
||||
if (!el) return;
|
||||
if (notices.length) {
|
||||
el.hidePopover?.();
|
||||
el.showPopover?.();
|
||||
} else el.hidePopover?.();
|
||||
}, [notices, host]);
|
||||
return createPortal(
|
||||
<div ref={panel} popover="manual" className="toast-viewport" aria-label={tr('操作提示')}>
|
||||
{notices.map((notice) => (
|
||||
<ToastItem key={notice.id} notice={notice} remove={remove} />
|
||||
))}
|
||||
</div>,
|
||||
host,
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,10 @@ export type User = {
|
||||
showNotes: boolean;
|
||||
idleMinutes: number;
|
||||
accountGroupOrder?: string[];
|
||||
sessionHours?: number;
|
||||
requireHiddenPassword?: boolean;
|
||||
overviewCards?: string[];
|
||||
sessionExpiresAt?: string;
|
||||
revealed?: boolean;
|
||||
revealUntil?: string | null;
|
||||
lastActivity?: string;
|
||||
|
||||
@@ -374,7 +374,7 @@
|
||||
"执行到期计划": "Run due plans",
|
||||
"计划名称": "Plan name",
|
||||
"支出": "Expense",
|
||||
"转账": "Transfer",
|
||||
"转账": "Transfer between accounts",
|
||||
"金额": "Amount",
|
||||
"实际到账": "Amount received",
|
||||
"首次执行时间": "First payment time",
|
||||
@@ -477,5 +477,47 @@
|
||||
"拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。": "Drag a handle to reorder, or focus it and use the arrow keys. Save to apply the order to accounts and account selection.",
|
||||
"账户分组顺序": "Account group order",
|
||||
"拖动分组": "Drag group",
|
||||
"已取消拖拽": "Drag cancelled"
|
||||
"已取消拖拽": "Drag cancelled",
|
||||
"常规设置": "General",
|
||||
"显示与菜单": "Display and navigation",
|
||||
"登录与隐私": "Login and privacy",
|
||||
"导航菜单": "Navigation",
|
||||
"设置已保存": "Settings saved",
|
||||
"总览显示卡片": "Overview cards",
|
||||
"选择资产总览需要显示的卡片,数据和记录仍会保留。": "Choose the cards to show on your overview. Your data and history are retained.",
|
||||
"登录有效期(小时)": "Session lifetime (hours)",
|
||||
"范围 1 小时至 30 天(720 小时)。保存后当前登录重新计时,其他会话沿用原到期时间;后续登录使用新设置。": "From 1 hour to 30 days (720 hours). Saving restarts the current session timer. Other sessions keep their expiry; future logins use this setting.",
|
||||
"当前会话到期:": "Current session expires: ",
|
||||
"查看隐藏项目需要密码": "Require a password to reveal hidden items",
|
||||
"关闭后,已登录账号可直接显示隐藏项目。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏项目。": "When disabled, a signed-in account can reveal hidden items directly for 5 minutes. You can lock them sooner. Saving this setting locks hidden items in all sessions.",
|
||||
"隐藏项目已显示": "Hidden items revealed",
|
||||
"显示隐藏项目": "Reveal hidden items",
|
||||
"当前包含隐藏项目 · 5 分钟后自动锁定": "Hidden items included · Locks automatically in 5 minutes",
|
||||
"尚未选择总览卡片": "No overview cards selected",
|
||||
"设置显示卡片": "Choose overview cards",
|
||||
"修改计划": "Edit plan",
|
||||
"下次执行时间": "Next execution time",
|
||||
"计划已保存": "Plan saved",
|
||||
"操作提示": "Notifications",
|
||||
"快速转账": "Quick transfer",
|
||||
"重选": "Change",
|
||||
"按当前时间记录,手续费为 0。跨币种请填写实际到账金额。": "Recorded at the current time, with zero fees. Enter the actual amount received for a currency conversion.",
|
||||
"关闭快速转账": "Close quick transfer",
|
||||
"点击账户卡片选择转出账户": "Click an account card to choose the sending account",
|
||||
"转出账户:": "Sending account: ",
|
||||
"点击另一张账户卡片选择转入账户": "Click a different account card to choose the receiving account",
|
||||
"重新选择": "Start over",
|
||||
"快速转账中:依次点击转出和转入账户卡片。": "Quick transfer: click the sending account card, then the receiving account card.",
|
||||
"当前包含隐藏资产 · 验证 5 分钟后自动锁定": "hidden assets included · Access locks automatically after 5 minutes",
|
||||
"隐藏资产已锁定": "hidden assets locked",
|
||||
"锁定隐藏资产": "Lock hidden assets",
|
||||
"隐藏资产": "Hidden item",
|
||||
"暂无转账记录。": "No transfers yet.",
|
||||
"清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏资产);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。": "Clear all accounts, assets, debts, history, private icons and exchange rates, including hidden assets. Your login, settings and published shared icons remain. Download and save a backup first.",
|
||||
"隐藏资产已解锁 5 分钟": "hidden assets unlocked for 5 minutes",
|
||||
"查看隐藏资产需要密码": "Require a password to reveal hidden assets",
|
||||
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "When disabled, a signed-in account can reveal hidden assets directly for 5 minutes. You can lock them sooner. Saving this setting locks hidden assets in all sessions.",
|
||||
"隐藏资产已显示": "hidden assets revealed",
|
||||
"显示隐藏资产": "Reveal hidden assets",
|
||||
"当前包含隐藏资产 · 5 分钟后自动锁定": "hidden assets included · Locks automatically in 5 minutes"
|
||||
}
|
||||
@@ -477,5 +477,47 @@
|
||||
"拖动手柄调整顺序,也可聚焦手柄后使用方向键。保存后同步到账户页和选择弹窗。": "拖動手柄調整順序,也可聚焦手柄後使用方向鍵。儲存後同步到帳戶頁和選擇彈窗。",
|
||||
"账户分组顺序": "帳戶分組順序",
|
||||
"拖动分组": "拖動分組",
|
||||
"已取消拖拽": "已取消拖曳"
|
||||
"已取消拖拽": "已取消拖曳",
|
||||
"常规设置": "一般設定",
|
||||
"显示与菜单": "顯示與選單",
|
||||
"登录与隐私": "登入與私隱",
|
||||
"导航菜单": "導覽選單",
|
||||
"设置已保存": "設定已儲存",
|
||||
"总览显示卡片": "總覽顯示卡片",
|
||||
"选择资产总览需要显示的卡片,数据和记录仍会保留。": "選擇資產總覽需要顯示的卡片,資料和記錄仍會保留。",
|
||||
"登录有效期(小时)": "登入有效期(小時)",
|
||||
"范围 1 小时至 30 天(720 小时)。保存后当前登录重新计时,其他会话沿用原到期时间;后续登录使用新设置。": "範圍 1 小時至 30 天(720 小時)。儲存後目前登入重新計時,其他會話沿用原到期時間;後續登入使用新設定。",
|
||||
"当前会话到期:": "目前會話到期:",
|
||||
"查看隐藏项目需要密码": "查看隱藏項目需要密碼",
|
||||
"关闭后,已登录账号可直接显示隐藏项目。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏项目。": "關閉後,已登入帳號可直接顯示隱藏項目。每次顯示持續 5 分鐘,也可手動鎖定;儲存此設定會鎖定所有會話的隱藏項目。",
|
||||
"隐藏项目已显示": "隱藏項目已顯示",
|
||||
"显示隐藏项目": "顯示隱藏項目",
|
||||
"当前包含隐藏项目 · 5 分钟后自动锁定": "目前包含隱藏項目 · 5 分鐘後自動鎖定",
|
||||
"尚未选择总览卡片": "尚未選擇總覽卡片",
|
||||
"设置显示卡片": "設定顯示卡片",
|
||||
"修改计划": "修改計劃",
|
||||
"下次执行时间": "下次執行時間",
|
||||
"计划已保存": "計劃已儲存",
|
||||
"操作提示": "操作提示",
|
||||
"快速转账": "快速轉賬",
|
||||
"重选": "重選",
|
||||
"按当前时间记录,手续费为 0。跨币种请填写实际到账金额。": "按目前時間記錄,手續費為 0。跨幣種請填寫實際到賬金額。",
|
||||
"关闭快速转账": "關閉快速轉賬",
|
||||
"点击账户卡片选择转出账户": "點擊賬戶卡片選擇轉出賬戶",
|
||||
"转出账户:": "轉出賬戶:",
|
||||
"点击另一张账户卡片选择转入账户": "點擊另一張賬戶卡片選擇轉入賬戶",
|
||||
"重新选择": "重新選擇",
|
||||
"快速转账中:依次点击转出和转入账户卡片。": "快速轉賬中:依次點擊轉出和轉入賬戶卡片。",
|
||||
"当前包含隐藏资产 · 验证 5 分钟后自动锁定": "當前包含隱藏資產 · 驗證 5 分鐘後自動鎖定",
|
||||
"隐藏资产已锁定": "隱藏資產已鎖定",
|
||||
"锁定隐藏资产": "鎖定隱藏資產",
|
||||
"隐藏资产": "隱藏資產",
|
||||
"暂无转账记录。": "暫無轉賬記錄。",
|
||||
"清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏资产);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。": "清除本賬號全部賬戶、資產、債務、歷史、私有圖標和匯率(包括隱藏資產);保留登錄賬號、個人設置和已發佈的共享圖標。請先下載備份並確認文件已保存。",
|
||||
"隐藏资产已解锁 5 分钟": "隱藏資產已解鎖 5 分鐘",
|
||||
"查看隐藏资产需要密码": "查看隱藏資產需要密碼",
|
||||
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "關閉後,已登入帳號可直接顯示隱藏資產。每次顯示持續 5 分鐘,也可手動鎖定;儲存此設定會鎖定所有會話的隱藏資產。",
|
||||
"隐藏资产已显示": "隱藏資產已顯示",
|
||||
"显示隐藏资产": "顯示隱藏資產",
|
||||
"当前包含隐藏资产 · 5 分钟后自动锁定": "目前包含隱藏資產 · 5 分鐘後自動鎖定"
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { ToastViewport } from './Toast';
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ToastViewport />
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
export const overviewCardLabels = {
|
||||
assets: '总资产',
|
||||
liabilities: '总负债',
|
||||
net: '净资产',
|
||||
trend: '净资产轨迹',
|
||||
composition: '资产与负债组成',
|
||||
recent: '最近变化',
|
||||
attribution: '变化归因',
|
||||
};
|
||||
export type OverviewCard = keyof typeof overviewCardLabels;
|
||||
export const allOverviewCards = Object.keys(overviewCardLabels) as OverviewCard[];
|
||||
@@ -1578,3 +1578,252 @@ textarea,
|
||||
font-size: 14px;
|
||||
color: #456352;
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
height: auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.settings-tabs button {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.settings-tabs button[aria-current='page'] {
|
||||
background: #e9f3ed;
|
||||
color: #234c3b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.menu-preferences {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.metrics {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
.dashboard-grid.single-card {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.schedule-dialog {
|
||||
width: min(620px, calc(100vw - 32px));
|
||||
}
|
||||
.schedule-dialog form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.schedule-dialog label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.schedule-dialog .actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.toast-viewport {
|
||||
position: fixed;
|
||||
inset: 20px 20px auto auto;
|
||||
margin: 0;
|
||||
width: min(380px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
.toast-viewport:popover-open {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.toast-viewport::backdrop {
|
||||
display: none;
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid #b9d3c3;
|
||||
border-radius: 12px;
|
||||
background: #f5fcf7;
|
||||
box-shadow: 0 8px 32px #163c2526;
|
||||
animation: toast-enter 0.18s ease-out;
|
||||
}
|
||||
.toast.error {
|
||||
border-color: #e7b9b9;
|
||||
background: #fff7f7;
|
||||
}
|
||||
.toast p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
.toast-symbol {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #d7eddf;
|
||||
color: #246240;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.toast.error .toast-symbol {
|
||||
background: #f8dcdc;
|
||||
color: #b42318;
|
||||
}
|
||||
.toast button {
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
@keyframes toast-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.toast-viewport {
|
||||
inset: 12px 16px auto auto;
|
||||
}
|
||||
.settings-tabs button {
|
||||
flex: 1 0 40%;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-dialog form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.schedule-dialog label:has(textarea),
|
||||
.schedule-dialog form > .actions {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.schedule-dialog textarea {
|
||||
min-height: 64px;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.schedule-dialog form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-transfer-widget {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 30;
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
.quick-transfer-toggle {
|
||||
box-shadow: 0 5px 20px #17453130;
|
||||
}
|
||||
.quick-transfer-card {
|
||||
width: 320px;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
background: #fff;
|
||||
border: 1px solid #bed2c4;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 36px #17453125;
|
||||
}
|
||||
.quick-transfer-card h2 {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
.quick-transfer-card form,
|
||||
.quick-transfer-card label {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.quick-transfer-pair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.quick-transfer-pair > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.quick-transfer-pair strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 14px;
|
||||
}
|
||||
.quick-transfer-pair span,
|
||||
.quick-transfer-card .muted {
|
||||
font-size: 11px;
|
||||
}
|
||||
.position-card.quick-source {
|
||||
border-color: #277b61;
|
||||
box-shadow: 0 0 0 2px #277b6133;
|
||||
}
|
||||
.position-card.quick-target {
|
||||
border-color: #587fa8;
|
||||
box-shadow: 0 0 0 2px #587fa833;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.quick-transfer-widget {
|
||||
right: 12px;
|
||||
}
|
||||
.quick-transfer-card {
|
||||
width: 230px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.position-grid.quick-transfer-select {
|
||||
padding-right: 340px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.quick-transfer-widget {
|
||||
top: auto;
|
||||
bottom: 80px;
|
||||
transform: none;
|
||||
}
|
||||
.quick-transfer-card {
|
||||
width: calc(100vw - 24px);
|
||||
max-height: 36vh;
|
||||
}
|
||||
.position-grid.quick-transfer-select {
|
||||
padding-right: 0;
|
||||
padding-bottom: 300px;
|
||||
}
|
||||
}
|
||||
@@ -49,3 +49,5 @@ User.hiddenMenus 保存受枚举校验的菜单键,settings 不可隐藏;Use
|
||||
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)。
|
||||
|
||||
2026-10-03 设置与交互:User.sessionHours 控制 1 至 720 小时的绝对会话有效期,requireHiddenPassword 控制隐藏项目再次验证,overviewCards 保存总览卡片键。设置、JSON/ZIP 备份和带注释的迁移同步;PUT schedules/:id 在计划锁内验证权限并更新未来安排。前端将设置分类、计划原生弹窗、可访问的右上角提示及账户卡片点选快捷转账集中提供。详见 [更新说明](update-settings-interaction-2026-10-03.md)。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"tableCount": 9,
|
||||
"columnCount": 89,
|
||||
"columnCount": 92,
|
||||
"tables": [
|
||||
{
|
||||
"TABLE_NAME": "exchangerate",
|
||||
@@ -484,6 +484,21 @@
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "accountGroupOrder",
|
||||
"COLUMN_COMMENT": "账户分组显示顺序;空值表示沿用默认顺序"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "sessionHours",
|
||||
"COLUMN_COMMENT": "登录有效时长(小时),范围 1 至 720"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "requireHiddenPassword",
|
||||
"COLUMN_COMMENT": "查看隐藏项目是否需要再次验证密码"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "overviewCards",
|
||||
"COLUMN_COMMENT": "总览显示的卡片键列表,NULL 表示默认全部显示"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,53 @@
|
||||
# 2026-10-03 设置与交互更新
|
||||
|
||||
## 登录、隐私与显示
|
||||
|
||||
登录有效期可设置为 1 至 720 小时(30 天),默认 168 小时。注册、登录及修改账号密码时创建的会话都使用该设置。保存有效期后,当前会话从保存时间重新计时并更新 HttpOnly Cookie;其他已登录会话保留原到期时间。无操作自动退出为独立设置,仍优先生效,活动请求不会延长绝对到期时间。
|
||||
|
||||
查看隐藏项目默认需要再次验证密码;可关闭为已登录用户直接点击显示。两种方式都只解锁当前会话 5 分钟,可提前锁定。保存这个开关会锁定该用户所有会话的隐藏项目。服务端保留登录、归属和隐藏项目过滤,不接受客户端指定用户身份。
|
||||
|
||||
总览可选择总资产、总负债、净资产、轨迹、组成、最近变化及变化归因。默认全部显示,可全部关闭;关闭卡片不删除财务数据,布局按可见卡片调整。设置重新分为常规、显示与菜单、登录与隐私、账号与密码、图标、汇率、备份和清空。
|
||||
|
||||
新增 User.sessionHours、requireHiddenPassword 和 overviewCards,迁移包含中文字段注释。JSON / ZIP 备份包含这些设置,旧备份缺少字段时保留默认值;只在空财务空间恢复设置,已有空间保留当前偏好。备份不包含密码、令牌或会话记录。通过备份恢复的登录有效期用于后续登录,不重新设置导入时的会话 Cookie。
|
||||
|
||||
## 计划与提示
|
||||
|
||||
新增和修改计划使用原生弹窗,支持关闭、取消、Esc 和账户选择弹窗。修改接口 PUT /api/schedules/:id 与执行使用同一计划锁,验证账户归属、隐藏授权、资产属性、归档状态和精确金额;同币种到账必须与转出一致。修改仅影响后续执行,历史余额记录保留,已完成的一次性计划需新建。
|
||||
|
||||
操作成功和错误统一在右上角弹出,可关闭,成功默认 6 秒、错误 12 秒,最多保留 3 条。原生弹窗打开时提示仍可读、可关闭,并保留辅助技术的 alert / status 语义。汇率缺失等持续数据状态仍在相关内容旁显示;清空等确认流程保持明确确认。
|
||||
|
||||
## 京东金融图标
|
||||
|
||||
采用 [京东金融 App Store 正式条目](https://apps.apple.com/cn/app/id895682747) 的应用图标:金色背景、白色吉祥物和红色铜钱图案,无“京东金融”文字。资源具体地址和 SHA-256 保存在 apps/api/assets/icons/sources.json。保留固定内置 ID,已使用它的账户自动显示新版。京东金融图案中的白色主体保留,其他图标沿用原有背景处理;已验证的存储 PNG 读取时不再次去白。商标与版权归所属品牌,仅用于账户识别。
|
||||
|
||||
## 快速转账
|
||||
|
||||
开启快速记账后,右侧显示悬浮“转账”按钮。点击打开小卡片,依次点击账户卡片选择转出和转入,再输入金额确认;可重选或关闭。选中卡片高亮,两个账户必须不同且均为启用的资产账户;隐藏账户必须处于解锁可见范围。筛选分组不会限制跨组选择。退出快速记账、离开账户列表、锁定隐藏项目或退出账号会取消此选择流程。
|
||||
|
||||
同币种自动使用相同到账金额;跨币种单独填写实际到账金额。快捷方式按当前北京时间记录,手续费为 0、备注为空;手续费、备注和指定时间仍可通过普通“转账”入口填写。复用现有原子转账接口及唯一请求 ID,允许余额透支,记录、总览和日历使用已有规则。不同币种不会修改公共参考汇率。所有“账户间转账”按钮文字统一为“转账”。
|
||||
|
||||
## 文案与本位币金额
|
||||
|
||||
界面统一使用“隐藏资产”文案,实际隐藏范围仍包含账户、资产和债务。同本位币的卡片和详情只显示原币金额,移除重复“折合”或本位币金额;外币项目仍显示折合金额,缺汇率时明确提示。浏览器已分别检查人民币和美元账户,以及人民币账户详情。
|
||||
|
||||
## 验收
|
||||
|
||||
- 33 项单元检查通过,前后端类型检查和生产构建通过。
|
||||
- 完整 15 项真实 MySQL 集成检查通过,新增覆盖登录时长上下限、Cookie 与会话到期、隐藏密码开关、跨用户隔离、卡片输入、旧 ZIP 与设置恢复、计划权限及历史保留。新增验收使用独立回环源地址,避免消耗原有测试集的每 IP 登录限流;生产限流规则保留。
|
||||
- 图标最终修正后,4 项图标读取、颜色保留、转账与 OpenAPI 专项检查再次通过。
|
||||
- 15 个迁移已应用,9 张表与 92 个字段的注释核验通过。
|
||||
- 浏览器专用虚构账号验证卡片选择实际影响总览、计划新增与修改成功、错误提示在弹窗内可关闭、京东金融图案显示。
|
||||
- 实际点击卡片完成快捷同币种转账 12.34:2000 → 1987.66,300 → 312.34;跨币种转出 7.77、到账 1.11:1987.66 → 1979.89,USD 100 → 101.11。未使用真实用户数据或执行真实资金交易。
|
||||
- 桌面浏览器验收完成;已实现窄屏布局,未在真实手机上实测。临时账号、脚本和隔离 API 清理。保留用户原有 Vite 配置改动,只有本地 Git 提交,不 push。
|
||||
|
||||
完成时间:2026-10-03 17:54。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
@@ -37,6 +37,16 @@
|
||||
- ~~“抖音月付”“东方财富”添加图标~~ — 已完成并验证:2026-10-03 12:36
|
||||
- ~~优化分组排序UI,使用拖拽排序~~ — 已完成并验证:2026-10-03 17:04
|
||||
- ~~在全部账号时,也要按照分组顺序显示,并且不太组不要在同一行~~ — 已完成并验证:2026-10-03 17:04
|
||||
- ~~可以设置账号登入有效期1h-30day,可以设置查看隐藏账号需不需要密码~~ — 已完成并验证:2026-10-03 17:54
|
||||
- ~~设置可以资产总览页面显示的卡片~~ — 已完成并验证:2026-10-03 17:54
|
||||
- ~~整理设置的内容,分布位置有点混乱~~ — 已完成并验证:2026-10-03 17:54
|
||||
- ~~添加,修改计划使用弹窗~~ — 已完成并验证:2026-10-03 17:54
|
||||
- ~~更换京东金融的图标,不需要显示京东金融这几个字~~ — 已完成并验证:2026-10-03 17:54
|
||||
- ~~修改提示框,不在按照之前的方法提示,直接在右上角出现弹出提示框~~ — 已完成并验证:2026-10-03 17:54
|
||||
- ~~在快速记账时, 有一个转账按钮显示在右屏幕中间,点击后出现一个快速转账小卡片,点击后需要点击一个账号做为转出,任何在点击一个账号做为转入~~ — 已完成并验证:2026-10-03 17:54
|
||||
- ~~账号间转账两个字改成转账,隐藏项目改成隐藏资产~~ — 已完成并验证:2026-10-03 18:06
|
||||
- ~~相同本位币不需要显示折合~~ — 已完成并验证:2026-10-03 18:06
|
||||
|
||||
|
||||
验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。
|
||||
|
||||
@@ -53,3 +63,5 @@
|
||||
图库补充验收完成:2026-10-03 12:36。新增“抖音月付”和“东方财富”图标,36 项内置图库已初始化,两个图标的已认证搜索和图片读取通过。详见 [更新说明](docs/update-icons-2026-10-03.md)。
|
||||
|
||||
分组界面验收完成:2026-10-03 17:04。支持拖拽与键盘调整顺序,全部账户按分组顺序分行显示;33 项单元测试、前端类型检查、生产构建和真实数据库分组排序专项检查通过。详见 [更新说明](docs/update-group-drag-2026-10-03.md)。
|
||||
|
||||
设置与交互验收完成:2026-10-03 17:54。33 项单元检查、15 项真实 MySQL 集成检查、前后端类型检查和生产构建通过;京东金融图标修正后另通过 4 项图标与转账专项回归。15 个迁移已应用,9 张表与 92 个字段注释齐全。登录与隐私、总览卡片、计划弹窗、右上角提示、京东金融 App 图标和快速转账详情见 [更新说明](docs/update-settings-interaction-2026-10-03.md)。复查未发现新增未完成要求。
|
||||