feat: streamline portfolio UI and support atomic record edits

This commit is contained in:
陈煜 committed 2026-10-02 18:46:25 +08:00
1 parent 932a864ed6
commit 9adf7fdbc5
26 files changed
+988 -112

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+18
View File
@@ -184,5 +184,23 @@
"source": "https://bin.bnbstatic.com/static/images/common/favicon.ico", "source": "https://bin.bnbstatic.com/static/images/common/favicon.ico",
"file": "31.png", "file": "31.png",
"sha256": "8318ebbcb1cb4729eb0f78bb058dc618c3b63f9f9f0070a1a7a3265fdc79b833" "sha256": "8318ebbcb1cb4729eb0f78bb058dc618c3b63f9f9f0070a1a7a3265fdc79b833"
},
{
"name": "ZA BANK 众安银行",
"source": "https://cdn.za.group/if/za-group-portal-web/assets/images/share-logo.png",
"file": "32.png",
"sha256": "e67a4839b9f81bdd94a104f15ec8c1fc9fad1df1949b4e216662c2089c84246e"
},
{
"name": "微众银行 WeBank",
"source": "https://tctp.tctpwebankcdn.net/owb-res/owb-res/assets/mainGenerator.811f2078.js",
"file": "33.png",
"sha256": "ec3be0bf19bbb8c633ee7e13636f96d5c70584cefe0febfb8da294bcdaec6104"
},
{
"name": "大象银行 EleBank",
"file": "34.png",
"source": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/d4/bc/60/d4bc6004-3898-c9c3-3571-f3ffe4b46b48/Bank-Airstar-0-0-1x_U007epad-0-1-sRGB-85-220.png/512x512bb.jpg",
"sha256": "fa7306514308f0a8cc73fa3847002d768094cec25aceeca324da440fbbee2934"
} }
] ]
+1 -1
View File
@@ -9,7 +9,7 @@
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "node scripts/database.cjs deploy", "db:migrate": "node scripts/database.cjs deploy",
"db:status": "node scripts/database.cjs status", "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: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:performance": "tsx scripts/performance.ts after", "test:performance": "tsx scripts/performance.ts after",
"icons:seed": "node scripts/seed-icons.cjs" "icons:seed": "node scripts/seed-icons.cjs"
}, },
@@ -0,0 +1,15 @@
ALTER TABLE `Schedule` COMMENT = '按需执行的定时支出或转账计划',
MODIFY `id` CHAR(36) NOT NULL COMMENT '唯一标识',
MODIFY `userId` CHAR(36) NOT NULL COMMENT '所属用户标识,用于数据隔离',
MODIFY `importedFromId` CHAR(36) NULL COMMENT '备份来源计划标识,用于重复导入识别',
MODIFY `name` VARCHAR(100) NOT NULL COMMENT '计划显示名称',
MODIFY `operation` VARCHAR(16) NOT NULL COMMENT '操作类型:expense 支出或 transfer 转账',
MODIFY `sourceId` CHAR(36) NOT NULL COMMENT '支出或转出资产账户标识',
MODIFY `targetId` CHAR(36) NULL COMMENT '转入账户标识;支出计划为空',
MODIFY `amount` DECIMAL(24,8) NOT NULL COMMENT '原币支出或转出金额',
MODIFY `received` DECIMAL(24,8) NOT NULL COMMENT '原币到账金额;支出计划为零',
MODIFY `nextAt` DATETIME(3) NOT NULL COMMENT '下次到期业务时间对应的 UTC',
MODIFY `intervalDays` INTEGER NOT NULL COMMENT '重复间隔天数;零表示仅执行一次',
MODIFY `enabled` BOOLEAN NOT NULL DEFAULT true COMMENT '是否启用计划',
MODIFY `completed` BOOLEAN NOT NULL DEFAULT false COMMENT '一次性计划是否已执行完毕',
MODIFY `notes` TEXT NOT NULL COMMENT '用户备注,原文保留';
+14
View File
@@ -216,20 +216,34 @@ model Transfer {
/// 按需执行的定时支出或转账计划 /// 按需执行的定时支出或转账计划
model Schedule { model Schedule {
/// 唯一标识
id String @id @default(uuid()) @db.Char(36) id String @id @default(uuid()) @db.Char(36)
/// 所属用户标识,用于数据隔离
userId String @db.Char(36) userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade) user User @relation(fields:[userId],references:[id],onDelete:Cascade)
/// 备份来源计划标识,用于重复导入识别
importedFromId String? @db.Char(36) importedFromId String? @db.Char(36)
/// 计划显示名称
name String @db.VarChar(100) name String @db.VarChar(100)
/// 操作类型:expense 支出或 transfer 转账
operation String @db.VarChar(16) operation String @db.VarChar(16)
/// 支出或转出资产账户标识
sourceId String @db.Char(36) sourceId String @db.Char(36)
/// 转入账户标识;支出计划为空
targetId String? @db.Char(36) targetId String? @db.Char(36)
/// 原币支出或转出金额
amount Decimal @db.Decimal(24,8) amount Decimal @db.Decimal(24,8)
/// 原币到账金额;支出计划为零
received Decimal @db.Decimal(24,8) received Decimal @db.Decimal(24,8)
/// 下次到期业务时间对应的 UTC
nextAt DateTime @db.DateTime(3) nextAt DateTime @db.DateTime(3)
/// 重复间隔天数;零表示仅执行一次
intervalDays Int intervalDays Int
/// 是否启用计划
enabled Boolean @default(true) enabled Boolean @default(true)
/// 一次性计划是否已执行完毕
completed Boolean @default(false) completed Boolean @default(false)
/// 用户备注,原文保留
notes String @db.Text notes String @db.Text
@@index([userId,enabled,nextAt]) @@index([userId,enabled,nextAt])
@@unique([userId,importedFromId]) @@unique([userId,importedFromId])
+20
View File
@@ -0,0 +1,20 @@
require('dotenv/config');
const { PrismaClient } = require('@prisma/client');
const { writeFileSync } = require('node:fs');
const { resolve } = require('node:path');
const db = new PrismaClient();
(async () => {
const tables = await db.$queryRaw`SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME <> '_prisma_migrations' ORDER BY TABLE_NAME`;
const columns = await db.$queryRaw`SELECT TABLE_NAME, COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME <> '_prisma_migrations' ORDER BY TABLE_NAME, ORDINAL_POSITION`;
if (tables.some((t) => !t.TABLE_COMMENT) || columns.some((c) => !c.COLUMN_COMMENT))
throw Error('Application tables or columns are missing comments.');
writeFileSync(resolve(__dirname, '../../../docs/database-comments.json'), JSON.stringify({
tableCount: tables.length, columnCount: columns.length, tables, columns,
}, null, 2) + '\n');
console.log(`Database comments verified: ${tables.length} tables, ${columns.length} columns.`);
})().catch(() => {
console.error('Comment verification failed. Check database access and table/column comments.');
process.exitCode = 1;
}).finally(() => db.$disconnect());
+13 -3
View File
@@ -1,16 +1,26 @@
"""Fetch public brand icons from the listed official websites; keep existing catalog.""" """Fetch public brand icons from the listed official websites; keep existing catalog."""
import hashlib, io, json, re, urllib.request, urllib.parse import hashlib, io, json, re, sys, urllib.request, urllib.parse
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
folder = Path(__file__).resolve().parent.parent / 'assets' / 'icons' folder = Path(__file__).resolve().parent.parent / 'assets' / 'icons'
catalog = json.loads((folder / 'sources.json').read_text(encoding='utf-8')) catalog = json.loads((folder / 'sources.json').read_text(encoding='utf-8'))
brands = [('汇丰香港', 'https://www.hsbc.com.hk'), ('恒生银行', 'https://www.hangseng.com'), ('中银香港', 'https://www.bochk.com/en'), ('渣打香港', 'https://www.sc.com/hk'), ('东亚银行', 'https://www.hkbea.com'), ('星展香港', 'https://www.dbs.com.hk'), ('花旗香港', 'https://www.citibank.com.hk'), ('币安 Binance', 'https://www.binance.com'), ('Gate', 'https://www.gate.com'), ('Bybit', 'https://www.bybit.com'), ('Bitget', 'https://www.bitget.com')] brands = [('汇丰香港', 'https://www.hsbc.com.hk'), ('恒生银行', 'https://www.hangseng.com'), ('中银香港', 'https://www.bochk.com/en'), ('渣打香港', 'https://www.sc.com/hk'), ('东亚银行', 'https://www.hkbea.com'), ('星展香港', 'https://www.dbs.com.hk'), ('花旗香港', 'https://www.citibank.com.hk'), ('币安 Binance', 'https://www.binance.com'), ('Gate', 'https://www.gate.com'), ('Bybit', 'https://www.bybit.com'), ('Bitget', 'https://www.bitget.com')]
brands += [('ZA BANK 众安银行', 'https://bank.za.group'), ('大象银行 EleBank', 'https://www.elebank.com'), ('微众银行 WeBank', 'https://www.webank.com')]
if len(sys.argv) > 1:
brands = [item for item in brands if item[0] in sys.argv[1:]]
def get(url): def get(url):
return urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}), timeout=20).read() return urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}), timeout=20).read()
failed = [] failed = []
for name, site in brands: for name, site in brands:
if any(v['name'] == name for v in catalog): continue if any(v['name'] == name for v in catalog): continue
urls = {'币安 Binance': ['https://bin.bnbstatic.com/static/images/common/favicon.ico', 'https://bin.bnbstatic.com/static/images/common/favicon.png'], '中银香港': ['https://www.bochk.com/dam/bochk/desktop/top/favicon.ico', 'https://www.bochk.com/favicon.ico']}.get(name, []) urls = {'币安 Binance': ['https://bin.bnbstatic.com/static/images/common/favicon.ico', 'https://bin.bnbstatic.com/static/images/common/favicon.png'], '中银香港': ['https://www.bochk.com/dam/bochk/desktop/top/favicon.ico', 'https://www.bochk.com/favicon.ico'], '大象银行 EleBank': ['https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/d4/bc/60/d4bc6004-3898-c9c3-3571-f3ffe4b46b48/Bank-Airstar-0-0-1x_U007epad-0-1-sRGB-85-220.png/512x512bb.jpg']}.get(name, [])
if name == '微众银行 WeBank':
try:
script = get('https://tctp.tctpwebankcdn.net/owb-res/owb-res/assets/mainGenerator.811f2078.js').decode('utf-8')
logo = re.search(r'Tr="(data:image/png;base64,[^"]+)"', script)
if logo: urls.insert(0, logo.group(1))
except Exception: pass
try: try:
html = get(site).decode('utf-8', errors='replace') html = get(site).decode('utf-8', errors='replace')
for tag in re.findall(r'<link\b[^>]*>', html, re.I): for tag in re.findall(r'<link\b[^>]*>', html, re.I):
@@ -26,7 +36,7 @@ for name, site in brands:
out = io.BytesIO(); im.save(out, 'PNG'); png = out.getvalue() out = io.BytesIO(); im.save(out, 'PNG'); png = out.getvalue()
file = str(len(catalog) + 1).zfill(2) + '.png' file = str(len(catalog) + 1).zfill(2) + '.png'
(folder / file).write_bytes(png) (folder / file).write_bytes(png)
catalog.append({'name':name,'source':url,'file':file,'sha256':hashlib.sha256(raw).hexdigest()}) catalog.append({'name':name,'source':site if url.startswith('data:') else url,'file':file,'sha256':hashlib.sha256(raw).hexdigest()})
print('Added:', name) print('Added:', name)
break break
except Exception: continue except Exception: continue
+2 -1
View File
@@ -36,6 +36,7 @@ export function setupOpenApi(app: INestApplication) {
.object({ targetIds: z.array(z.string().uuid()).max(20) }) .object({ targetIds: z.array(z.string().uuid()).max(20) })
.strict(), .strict(),
'POST /api/transfers': transferInput, 'POST /api/transfers': transferInput,
'PUT /api/transfers/{id}': transferInput,
'POST /api/schedules': scheduleInput, 'POST /api/schedules': scheduleInput,
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(), 'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
'PATCH /api/settings': z 'PATCH /api/settings': z
@@ -53,7 +54,7 @@ export function setupOpenApi(app: INestApplication) {
}), }),
}; };
for (const [path, entry] of Object.entries(document.paths)) { for (const [path, entry] of Object.entries(document.paths)) {
for (const method of ['get', 'post', 'patch', 'put'] as const) { for (const method of ['get', 'post', 'patch', 'put', 'delete'] as const) {
const operation = entry[method]; const operation = entry[method];
if (!operation) continue; if (!operation) continue;
operation.summary = `${method.toUpperCase()} ${path}`; operation.summary = `${method.toUpperCase()} ${path}`;
+28 -17
View File
@@ -4,6 +4,7 @@ import {
Post, Post,
Patch, Patch,
Put, Put,
Delete,
Body, Body,
Req, Req,
Param, Param,
@@ -28,6 +29,7 @@ import { z } from 'zod';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { IconsService } from './icons'; import { IconsService } from './icons';
import { RatesService } from './rates'; import { RatesService } from './rates';
import { captureReplay } from './replay';
@Controller('api') @Controller('api')
export class PortfolioController { export class PortfolioController {
constructor( constructor(
@@ -204,23 +206,9 @@ export class PortfolioController {
throw new BadRequestException('仅资产账户支持负余额'); throw new BadRequestException('仅资产账户支持负余额');
const original = await tx.revision.findFirst({ where: { id: revisionId, positionId: p.id } }); const original = await tx.revision.findFirst({ where: { id: revisionId, positionId: p.id } });
if (!original) throw new NotFoundException('历史记录不存在'); if (!original) throw new NotFoundException('历史记录不存在');
const transfer = await tx.revision.findFirst({ if (pairedReasons.includes(original.reason))
where: { throw new ConflictException('请在资金往来中修改完整配对记录');
positionId: p.id, const replay = await captureReplay(tx, r.userId, [id]);
reason: { in: pairedReasons },
OR: [
{ effectiveDate: { gt: original.effectiveDate } },
{ effectiveDate: original.effectiveDate, sequence: { gte: original.sequence } },
{ effectiveDate: { gt: toBusinessDate(v.date) } },
{ effectiveDate: toBusinessDate(v.date), sequence: { gte: original.sequence } },
],
},
select: { id: true },
});
if (transfer)
throw new ConflictException(
'转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致',
);
await tx.revision.update({ await tx.revision.update({
where: { id: revisionId }, where: { id: revisionId },
data: { data: {
@@ -230,6 +218,29 @@ export class PortfolioController {
reason: 'correction', reason: 'correction',
}, },
}); });
await replay();
return { ok: true };
});
}
@Delete('positions/:id/revisions/:revisionId') async deleteRevision(
@Req() r: UserRequest,
@Param('id') id: string,
@Param('revisionId') revisionId: string,
) {
return this.db.serial(async (tx) => {
const replay = await captureReplay(tx, r.userId, [id]);
const p = await tx.position.findFirst({
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
if (!p) throw new NotFoundException('项目不存在');
if (p.archived) throw new ConflictException('请先恢复归档项目');
const row = await tx.revision.findFirst({ where: { id: revisionId, positionId: id } });
if (!row) throw new NotFoundException('历史记录不存在');
if (pairedReasons.includes(row.reason))
throw new ConflictException('请在资金往来中删除完整配对记录');
await tx.revision.delete({ where: { id: revisionId } });
await replay();
return { ok: true }; return { ok: true };
}); });
} }
+68
View File
@@ -0,0 +1,68 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import Decimal from 'decimal.js';
import { movementDeltas, type Movement } from './movement';
import { pairedReasons } from './validation';
// Lock all affected positions in the same order as movement creation. Absolute
// balance observations remain anchors; transfers and expenses retain their delta.
export async function captureReplay(tx: Prisma.TransactionClient, userId: string, ids: string[]) {
await tx.$queryRaw(Prisma.sql`SELECT id FROM Position WHERE userId = ${userId}
AND id IN (${Prisma.join([...ids].sort())}) ORDER BY id FOR UPDATE`);
const rows = await tx.revision.findMany({
where: { positionId: { in: ids }, position: { userId } },
orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }],
});
const deltas = new Map<string, Decimal>();
const balances = new Map<string, Decimal>();
for (const row of rows) {
if (row.reason === 'scheduled_expense')
deltas.set(
row.id,
new Decimal(row.amount.toString()).minus(balances.get(row.positionId) || 0),
);
balances.set(row.positionId, new Decimal(row.amount.toString()));
}
return async () => {
const transfers = await tx.transfer.findMany({
where: { userId, OR: [{ sourceId: { in: ids } }, { targetId: { in: ids } }] },
});
for (const t of transfers) {
const d = movementDeltas(
t.operation as Movement,
t.amount.toString(),
t.received.toString(),
t.fee.toString(),
);
deltas.set(t.sourceRevisionId, d.source);
deltas.set(t.targetRevisionId, d.target);
}
const positions = await tx.position.findMany({ where: { userId, id: { in: ids } } });
const current = await tx.revision.findMany({
where: { positionId: { in: ids }, position: { userId } },
orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }],
});
balances.clear();
for (const row of current) {
const p = positions.find((v) => v.id === row.positionId)!;
const delta =
row.reason === 'scheduled_expense' || pairedReasons.includes(row.reason)
? deltas.get(row.id)
: undefined;
if (pairedReasons.includes(row.reason) && !delta)
throw new ConflictException('配对记录不完整,无法重算');
if (delta && !balances.has(row.positionId))
throw new ConflictException('资金操作之前必须保留一条余额记录');
const amount = delta
? balances.get(row.positionId)!.plus(delta)
: new Decimal(row.amount.toString());
if (amount.isNegative() && (p.kind !== 'account' || p.side !== 'asset'))
throw new BadRequestException('修改后债务或资产金额不能为负数');
if (amount.abs().gte('10000000000000000'))
throw new BadRequestException('重算后的金额超出支持范围');
balances.set(row.positionId, amount);
if (!amount.eq(row.amount.toString()))
await tx.revision.update({ where: { id: row.id }, data: { amount: amount.toFixed() } });
}
};
}
+60
View File
@@ -2,6 +2,10 @@ import {
Controller, Controller,
Get, Get,
Post, Post,
Put,
Delete,
Param,
NotFoundException,
Body, Body,
Req, Req,
Query, Query,
@@ -15,6 +19,7 @@ import { UserRequest } from './auth';
import { transferInput, toBusinessDate } from './validation'; import { transferInput, toBusinessDate } from './validation';
import { businessTime } from './calculation'; import { businessTime } from './calculation';
import { movementDeltas } from './movement'; import { movementDeltas } from './movement';
import { captureReplay } from './replay';
import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries'; import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries';
@Controller('api/transfers') @Controller('api/transfers')
export class TransfersController { export class TransfersController {
@@ -43,6 +48,61 @@ export class TransfersController {
revealed: r.revealed, revealed: r.revealed,
}; };
} }
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
const v = transferInput.parse(body);
return this.change(r, id, v);
}
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
return this.change(r, id);
}
private async change(r: UserRequest, id: string, v?: ReturnType<typeof transferInput.parse>) {
return this.db.serial(async (tx) => {
const row = await tx.transfer.findFirst({
where: {
id,
userId: r.userId,
...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
},
include: { source: true, target: true },
});
if (!row) throw new NotFoundException('资金往来记录不存在');
if (row.source.archived || row.target.archived)
throw new ConflictException('请先恢复归档项目');
const replay = await captureReplay(tx, r.userId, [row.sourceId, row.targetId]);
if (v) {
if (
v.sourceId !== row.sourceId ||
v.targetId !== row.targetId ||
v.operation !== row.operation
)
throw new BadRequestException('修改记录不能更换账户或操作类型,请删除后重新创建');
if (row.sourceCurrency === row.targetCurrency && !new Decimal(v.amount).eq(v.received))
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
const effectiveDate = toBusinessDate(v.date);
await tx.transfer.update({
where: { id },
data: {
amount: v.amount,
received: v.received,
fee: v.fee,
notes: v.notes,
effectiveDate,
},
});
await tx.revision.updateMany({
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
data: { effectiveDate, notes: v.notes },
});
} else {
await tx.transfer.delete({ where: { id } });
await tx.revision.deleteMany({
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
});
}
await replay();
return { ok: true };
});
}
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) { @Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
const v = transferInput.parse(body); const v = transferInput.parse(body);
return this.db.serial((tx) => executeMovement(tx, r, v)); return this.db.serial((tx) => executeMovement(tx, r, v));
+10 -2
View File
@@ -128,10 +128,18 @@ export const credentials = z
}) })
.strict(); .strict();
export const menuKey = z.enum(['overview', 'account', 'asset', 'debt', 'history', 'calendar']); export const menuKey = z.enum([
'overview',
'account',
'asset',
'debt',
'history',
'calendar',
'schedules',
]);
export const hiddenMenus = z export const hiddenMenus = z
.array(menuKey) .array(menuKey)
.max(6) .max(7)
.refine((v) => new Set(v).size === v.length, '菜单不可重复'); .refine((v) => new Set(v).size === v.length, '菜单不可重复');
export const transferInput = z export const transferInput = z
.object({ .object({
+198
View File
@@ -0,0 +1,198 @@
import 'reflect-metadata';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { Database } from '../src/database';
import { TransfersController } from '../src/transfers';
import { PortfolioController } from '../src/portfolio';
import { CalendarController } from '../src/calendar';
import { toBusinessDate } from '../src/validation';
test('record edits replay paired movements, expense deltas, anchors and calendar atomically', async () => {
const db = new Database();
const users: string[] = [];
const movements = new TransfersController(db);
const portfolio = new PortfolioController(db, {} as any, {} as any);
const calendar = new CalendarController(db);
try {
const user = await db.user.create({
data: { username: 'wp_edit_' + randomUUID(), passwordHash: 'unused' },
});
users.push(user.id);
const r = { userId: user.id, revealed: false } as any;
const foreign = { userId: randomUUID(), revealed: false } as any;
const create = async (amount: string, kind = 'account', side = 'asset') =>
db.position.create({
data: {
userId: user.id,
name: randomUUID(),
kind,
side,
category: 'other',
currency: 'CNY',
notes: '',
revisions: {
create: {
amount,
effectiveDate: toBusinessDate('2026-09-01T00:00'),
notes: '',
reason: 'initial',
},
},
},
include: { revisions: true },
});
const a = await create('100'),
b = await create('0');
const input = {
sourceId: a.id,
targetId: b.id,
amount: '20',
received: '20',
fee: '2',
date: '2026-09-02T10:00',
operation: 'transfer',
notes: '',
};
const transfer = await movements.create(r, input);
const expense = await db.revision.create({
data: {
positionId: a.id,
amount: '73',
effectiveDate: toBusinessDate('2026-09-03T10:00'),
reason: 'scheduled_expense',
notes: '',
},
});
const later = await movements.create(r, {
...input,
amount: '10',
received: '10',
fee: '0',
date: '2026-09-04T10:00',
});
const balance = async (id: string) => (await portfolio.detail(r, id)).amount;
assert.equal(await balance(a.id), '63');
await assert.rejects(movements.remove(foreign, transfer.id));
await assert.rejects(portfolio.deleteRevision(foreign, a.id, a.revisions[0].id));
await movements.edit(r, transfer.id, { ...input, amount: '30', received: '30', fee: '3' });
assert.equal(await balance(a.id), '52');
assert.equal(await balance(b.id), '40');
assert.equal(
(await db.revision.findUniqueOrThrow({ where: { id: expense.id } })).amount.toString(),
'62',
);
const month = await calendar.month(r, '2026-09');
assert.equal(month.items.find((v) => v.date === '2026-09-03')!.expense, '5.00');
await portfolio.correct(r, a.id, a.revisions[0].id, {
amount: '200',
date: '2026-09-01T00:00',
notes: '',
});
assert.equal(await balance(a.id), '152');
await movements.remove(r, transfer.id);
assert.equal(await balance(a.id), '185');
assert.equal(await balance(b.id), '10');
assert.equal(
await db.revision.count({
where: {
id: {
in: [
(await db.transfer.findUniqueOrThrow({ where: { id: later.id } })).sourceRevisionId,
],
},
},
}),
1,
);
await portfolio.correct(r, a.id, expense.id, {
amount: '193',
date: '2026-09-03T10:00',
notes: '',
});
assert.equal(await balance(a.id), '183');
assert.equal(
(await calendar.month(r, '2026-09')).items.find((v) => v.date === '2026-09-03')!.expense,
'7.00',
);
await portfolio.deleteRevision(r, a.id, expense.id);
assert.equal(await balance(a.id), '190');
assert.equal((await calendar.day(r, '2026-09-03')).items.length, 0);
await assert.rejects(portfolio.deleteRevision(r, a.id, a.revisions[0].id));
assert.equal(await balance(a.id), '190');
// An observed absolute balance is an anchor, including after a moved transfer.
await portfolio.revise(r, a.id, {
amount: '300',
date: '2026-09-05T00:00',
reason: 'balance',
notes: '',
});
await movements.edit(r, later.id, {
...input,
amount: '15',
received: '15',
fee: '0',
date: '2026-09-02T00:00',
});
assert.equal(await balance(a.id), '300');
assert.equal(await balance(b.id), '15');
const initial = a.revisions[0].id;
await db.position.update({ where: { id: a.id }, data: { hidden: true } });
await assert.rejects(movements.remove(r, later.id));
await assert.rejects(
portfolio.correct(r, a.id, initial, { amount: '1', date: '2026-09-01T00:00', notes: '' }),
);
await db.position.update({ where: { id: a.id }, data: { hidden: false } });
const debt = await create('20', 'debt', 'liability');
const repayment = await movements.create(r, {
...input,
targetId: debt.id,
amount: '10',
received: '10',
fee: '0',
operation: 'repay',
date: '2026-09-06T00:00',
});
await assert.rejects(
movements.edit(r, repayment.id, {
...input,
targetId: debt.id,
amount: '25',
received: '25',
fee: '0',
operation: 'repay',
date: '2026-09-06T00:00',
}),
);
assert.equal(await balance(debt.id), '10');
assert.equal(await balance(a.id), '290');
await movements.remove(r, repayment.id);
assert.equal(await balance(debt.id), '20');
assert.equal(await balance(a.id), '300');
// Cross-currency values and signed fees retain exact decimal precision.
await db.position.update({ where: { id: b.id }, data: { currency: 'USD' } });
const fx = await movements.create(r, {
...input,
amount: '0.12435678',
received: '0.01423567',
fee: '-0.00000001',
date: '2026-09-07T00:00',
});
await movements.edit(r, fx.id, {
...input,
amount: '0.22345678',
received: '0.02234567',
fee: '-0.00000002',
date: '2026-09-07T00:00',
});
assert.equal(await balance(a.id), '299.77654324');
assert.equal(await balance(b.id), '15.02234567');
await movements.remove(r, fx.id);
assert.equal(await balance(a.id), '300');
assert.equal(await balance(b.id), '15');
} finally {
await db.user.deleteMany({ where: { id: { in: users } } });
await db.$disconnect();
}
});
+6 -5
View File
@@ -51,11 +51,12 @@ export function AccountPicker({
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
/> />
<div className="actions"> <div className="actions">
<button type="button" aria-pressed={!grouped} onClick={() => setGrouped(false)}> <button
{tr('全部账户')} className="secondary picker-view-toggle"
</button> type="button"
<button type="button" aria-pressed={grouped} onClick={() => setGrouped(true)}> onClick={() => setGrouped(!grouped)}
{tr('按分组显示')} >
{tr(grouped ? '显示全部账户' : '按分组显示')} <span aria-hidden="true">⇄</span>
</button> </button>
</div> </div>
{groups.map((group) => ( {groups.map((group) => (
+205 -58
View File
@@ -51,6 +51,7 @@ const labels: Record<string, string> = {
debt: '债务', debt: '债务',
history: '变化记录', history: '变化记录',
calendar: '收支日历', calendar: '收支日历',
schedules: '定时计划',
settings: '设置与备份', settings: '设置与备份',
}; };
const categories: Record<string, [string, string][]> = { const categories: Record<string, [string, string][]> = {
@@ -221,6 +222,7 @@ export default function App() {
kind: string; kind: string;
p?: Position; p?: Position;
h?: History; h?: History;
transfer?: Transfer;
operation?: DebtOperation; operation?: DebtOperation;
} | null>(null), } | null>(null),
[busy, setBusy] = useState(false), [busy, setBusy] = useState(false),
@@ -340,6 +342,9 @@ export default function App() {
return; return;
} }
setOverview({ ...o, trend: t.items, recent: recent.items }); setOverview({ ...o, trend: t.items, recent: recent.items });
} else if (page === 'schedules') {
const rows = await api<Position[]>('/positions?kind=account');
if (active()) setPositions(rows);
} else if (['account', 'asset', 'debt'].includes(page)) { } else if (['account', 'asset', 'debt'].includes(page)) {
if (page === 'account' && !rangeOnly) { if (page === 'account' && !rangeOnly) {
const result = await api<{ const result = await api<{
@@ -741,6 +746,29 @@ export default function App() {
</section> </section>
</div> </div>
); );
async function correctHistory(h: History) {
if (h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')) {
setSelected(null);
setPage('history');
return;
}
const session = sessionGeneration.current,
view = viewRef.current;
try {
const p = await api<Position>('/positions/' + h.positionId);
if (session !== sessionGeneration.current || view !== viewRef.current) return;
setModal({ kind: 'correction', p, h });
} catch (e) {
if (session === sessionGeneration.current && view === viewRef.current) report(e);
}
}
function deleteHistory(h: History) {
if (!window.confirm(tr('删除这条余额记录并重算后续余额?'))) return;
void act(
() => api('/positions/' + h.positionId + '/revisions/' + h.id, 'DELETE'),
tr('记录已删除,余额和日历已更新'),
);
}
const nav = [ const nav = [
['overview', LayoutDashboard], ['overview', LayoutDashboard],
['account', Wallet], ['account', Wallet],
@@ -748,6 +776,7 @@ export default function App() {
['debt', HandCoins], ['debt', HandCoins],
['history', HistoryIcon], ['history', HistoryIcon],
['calendar', CalendarDays], ['calendar', CalendarDays],
['schedules', HistoryIcon],
['settings', Settings], ['settings', Settings],
] as const; ] as const;
const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key)); const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key));
@@ -882,42 +911,6 @@ export default function App() {
{tr('正在刷新数据…')} {tr('正在刷新数据…')}
</p> </p>
)} )}
{page === 'overview' && (
<section className="panel">
{' '}
<div className="actions">
<label>
{tr('开始')}
<input
type="date"
value={rangeFrom}
max={rangeTo}
onChange={(e) => setRangeFrom(e.target.value)}
/>
</label>
<label>
{tr('结束')}
<input
type="date"
value={rangeTo}
min={rangeFrom}
max={today()}
onChange={(e) => setRangeTo(e.target.value)}
/>
</label>
<select value={grain} onChange={(e) => setGrain(e.target.value)}>
<option value="day">{tr('日(最多366天)')}</option>
<option value="week">{tr('周(最多3年)')}</option>
<option value="month">{tr('月(最多10年)')}</option>
</select>
</div>
<p className="muted">
{tr(
'默认最近90天;周一至周日及自然月取期末余额,变化归因累计区间内每日变化,首尾不足周期按实际范围。',
)}
</p>
</section>
)}
{page === 'overview' && overview && ( {page === 'overview' && overview && (
<> <>
{!overview.complete && ( {!overview.complete && (
@@ -967,6 +960,41 @@ export default function App() {
<h2>{tr('净资产轨迹')}</h2> <h2>{tr('净资产轨迹')}</h2>
<span className="badge">{tr('按业务日期')}</span> <span className="badge">{tr('按业务日期')}</span>
</div> </div>
<div className="actions overview-filters">
<label>
{tr('开始')}
<input
type="date"
value={rangeFrom}
max={rangeTo}
onChange={(e) => setRangeFrom(e.target.value)}
/>
</label>
<label>
{tr('结束')}
<input
type="date"
value={rangeTo}
min={rangeFrom}
max={today()}
onChange={(e) => setRangeTo(e.target.value)}
/>
</label>
<select
aria-label={tr('统计粒度')}
value={grain}
onChange={(e) => setGrain(e.target.value)}
>
<option value="day">{tr('日(最多366天)')}</option>
<option value="week">{tr('周(最多3年)')}</option>
<option value="month">{tr('月(最多10年)')}</option>
</select>
</div>
<p className="muted">
{tr(
'默认最近90天;周一至周日及自然月取期末余额,变化归因累计区间内每日变化,首尾不足周期按实际范围。',
)}
</p>
<Chart rows={overview.trend} base={user.baseCurrency} /> <Chart rows={overview.trend} base={user.baseCurrency} />
<p className="muted"> <p className="muted">
{tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')} {tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')}
@@ -1014,7 +1042,7 @@ export default function App() {
</button> </button>
</div> </div>
<HistoryTable <HistoryTable
rows={overview.recent} rows={overview.recent.slice(0, 6)}
open={(id) => { open={(id) => {
setSelected(id); setSelected(id);
setPage(overview.recent.find((h) => h.positionId === id)?.kind || 'account'); setPage(overview.recent.find((h) => h.positionId === id)?.kind || 'account');
@@ -1034,8 +1062,8 @@ export default function App() {
{tr('· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。')} {tr('· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。')}
</div> </div>
{overview.trend.length > 1 && ( {overview.trend.length > 1 && (
<section className="panel"> <details className="panel attribution">
<h2>{tr('变化归因')}</h2> <summary>{tr('变化归因')}</summary>
<div className="table-wrap"> <div className="table-wrap">
<table> <table>
<thead> <thead>
@@ -1068,7 +1096,7 @@ export default function App() {
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </details>
)} )}
</> </>
)} )}
@@ -1211,7 +1239,8 @@ export default function App() {
</p> </p>
<HistoryTable <HistoryTable
rows={historyRows} rows={historyRows}
correct={p.archived ? undefined : (h) => setModal({ kind: 'correction', p, h })} correct={p.archived ? undefined : (h) => void correctHistory(h)}
remove={p.archived ? undefined : deleteHistory}
/> />
{historyCursor && ( {historyCursor && (
<button disabled={loading} onClick={() => void more('history')}> <button disabled={loading} onClick={() => void more('history')}>
@@ -1252,12 +1281,6 @@ export default function App() {
</select> </select>
</div> </div>
{quickMode && <p className="muted">{tr('点击账户卡片,直接填写当前余额。')}</p>} {quickMode && <p className="muted">{tr('点击账户卡片,直接填写当前余额。')}</p>}
<SchedulePanel
accounts={positions.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
)}
refresh={() => load(false, true)}
/>
</> </>
)} )}
{positions.some((p) => p.kind === page) ? ( {positions.some((p) => p.kind === page) ? (
@@ -1392,6 +1415,28 @@ export default function App() {
{money(t.fee, t.sourceCurrency)} {money(t.fee, t.sourceCurrency)}
</p> </p>
{t.notes && <p className="muted notes-display">{t.notes}</p>} {t.notes && <p className="muted notes-display">{t.notes}</p>}
<div className="actions record-actions">
<button
className="text"
disabled={busy}
onClick={() => setModal({ kind: 'transfer-edit', transfer: t })}
>
{tr('修改')}
</button>
<button
className="text danger-text"
disabled={busy}
onClick={() => {
if (window.confirm(tr('删除这笔资金往来并重算双方余额?')))
void act(
() => api('/transfers/' + t.id, 'DELETE'),
tr('记录已删除,余额和日历已更新'),
);
}}
>
{tr('删除')}
</button>
</div>
</article> </article>
))} ))}
</div> </div>
@@ -1409,6 +1454,8 @@ export default function App() {
</p> </p>
<HistoryTable <HistoryTable
rows={historyRows} rows={historyRows}
correct={(h) => void correctHistory(h)}
remove={deleteHistory}
open={(id) => { open={(id) => {
setSelected(id); setSelected(id);
setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account'); setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account');
@@ -1422,6 +1469,16 @@ export default function App() {
</section> </section>
</> </>
)} )}
{page === 'schedules' && (
<section className="panel">
<SchedulePanel
accounts={positions.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
)}
refresh={() => load(false, true)}
/>
</section>
)}
{page === 'calendar' && ( {page === 'calendar' && (
<Calendar revealed={!!user.revealed} refreshVersion={calendarRefresh} /> <Calendar revealed={!!user.revealed} refreshVersion={calendarRefresh} />
)} )}
@@ -1471,9 +1528,15 @@ export default function App() {
() => () =>
api('/settings', 'PATCH', { api('/settings', 'PATCH', {
baseCurrency: f.get('baseCurrency'), baseCurrency: f.get('baseCurrency'),
hiddenMenus: ['overview', 'account', 'asset', 'debt', 'history'].filter( hiddenMenus: [
(key) => !f.has('menu_' + key), 'overview',
), 'account',
'asset',
'debt',
'history',
'calendar',
'schedules',
].filter((key) => !f.has('menu_' + key)),
idleMinutes: Number(f.get('idleMinutes')), idleMinutes: Number(f.get('idleMinutes')),
showNotes: f.has('showNotes'), showNotes: f.has('showNotes'),
}), }),
@@ -1485,7 +1548,15 @@ export default function App() {
{tr('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')} {tr('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')}
</p> </p>
<div className="menu-preferences"> <div className="menu-preferences">
{['overview', 'account', 'asset', 'debt', 'history'].map((key) => ( {[
'overview',
'account',
'asset',
'debt',
'history',
'calendar',
'schedules',
].map((key) => (
<label key={key} className="switch-row"> <label key={key} className="switch-row">
<span>{tr(labels[key])}</span> <span>{tr(labels[key])}</span>
<input <input
@@ -1865,7 +1936,76 @@ export default function App() {
)} )}
</Modal> </Modal>
)} )}
{modal && ( {modal?.kind === 'transfer-edit' && modal.transfer && (
<Modal
title={tr('修改资金往来')}
close={() => {
if (!busy) setModal(null);
}}
>
<form
onSubmit={(e) => {
e.preventDefault();
const t = modal.transfer!,
f = Object.fromEntries(new FormData(e.currentTarget));
void act(
() =>
api('/transfers/' + t.id, 'PUT', {
...f,
sourceId: t.sourceId,
targetId: t.targetId,
operation: t.operation,
}),
tr('记录已修改,余额和日历已更新'),
);
}}
>
<p>
{modal.transfer.source.name} ↔ {modal.transfer.target.name}
</p>
<Field label={tr('转出金额(') + modal.transfer.sourceCurrency + ')'}>
<input
name="amount"
required
inputMode="decimal"
defaultValue={modal.transfer.amount}
/>
</Field>
<Field label={tr('实际到账(') + modal.transfer.targetCurrency + ')'}>
<input
name="received"
required
inputMode="decimal"
defaultValue={modal.transfer.received}
/>
</Field>
<Field label={tr('手续费 / 优惠(') + modal.transfer.sourceCurrency + ')'}>
<input name="fee" required inputMode="decimal" defaultValue={modal.transfer.fee} />
</Field>
<Field label={tr('记录时间(UTC+8)')}>
<input
name="date"
type="datetime-local"
required
max={nowMinute()}
defaultValue={modal.transfer.date}
/>
</Field>
<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>
</form>
</Modal>
)}
{modal && modal.kind !== 'transfer-edit' && (
<Modal <Modal
title={ title={
modal.kind === 'debt-payment' modal.kind === 'debt-payment'
@@ -2215,10 +2355,12 @@ function HistoryTable({
rows, rows,
open, open,
correct, correct,
remove,
}: { }: {
rows: History[]; rows: History[];
open?: (id: string) => void; open?: (id: string) => void;
correct?: (h: History) => void; correct?: (h: History) => void;
remove?: (h: History) => void;
}) { }) {
return rows.length ? ( return rows.length ? (
<div className="table-wrap"> <div className="table-wrap">
@@ -2307,13 +2449,18 @@ function HistoryTable({
<td className="notes-cell">{h.notes || '—'}</td> <td className="notes-cell">{h.notes || '—'}</td>
{correct && ( {correct && (
<td> <td>
<button <button className="text" onClick={() => correct(h)}>
className="text" {tr(
disabled={h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')} h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')
onClick={() => correct(h)} ? '查看资金往来'
> : '更正',
{tr('更正')} )}
</button> </button>
{remove && !h.reason.startsWith('transfer_') && !h.reason.startsWith('loan_') && (
<button className="text danger-text" onClick={() => remove(h)}>
{tr('删除')}
</button>
)}
</td> </td>
)} )}
</tr> </tr>
+5 -5
View File
@@ -61,12 +61,12 @@ export function SchedulePanel({
} }
} }
return ( return (
<details className="schedule-panel"> <section className="schedule-panel">
<summary> <h2>
{tr('定时计划')} ({rows.length}) {tr('定时计划')} ({rows.length})
</summary> </h2>
<p className="muted"> <p className="muted">
{tr('打开账户页时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。')} {tr('打开账户页或点击执行时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。')}
</p> </p>
<div className="actions"> <div className="actions">
<button type="button" disabled={busy || !accounts.length} onClick={() => setOpen(!open)}> <button type="button" disabled={busy || !accounts.length} onClick={() => setOpen(!open)}>
@@ -227,6 +227,6 @@ export function SchedulePanel({
</p> </p>
)} )}
{status && <p role="status">{status}</p>} {status && <p role="status">{status}</p>}
</details> </section>
); );
} }
+19 -1
View File
@@ -426,5 +426,23 @@
"六": "Sat", "六": "Sat",
"日": "Sun", "日": "Sun",
"已完成": "Completed", "已完成": "Completed",
"一次性计划已完成,请新建计划": "This one-time plan is complete. Create a new plan." "一次性计划已完成,请新建计划": "This one-time plan is complete. Create a new plan.",
"显示全部账户": "Show all accounts",
"删除这条余额记录并重算后续余额?": "Delete this balance record and recalculate later balances?",
"删除这笔资金往来并重算双方余额?": "Delete this movement and recalculate both balances?",
"记录已删除,余额和日历已更新": "Record deleted. Balances and calendar updated.",
"记录已修改,余额和日历已更新": "Record updated. Balances and calendar updated.",
"修改资金往来": "Edit movement",
"查看资金往来": "View movements",
"打开账户页或点击执行时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。": "Due plans run when opening Accounts or clicking Run. Each batch processes up to 20 plans, including overdue occurrences.",
"配对记录不完整,无法重算": "Paired records are incomplete. Recalculation failed.",
"资金操作之前必须保留一条余额记录": "Keep a balance record before the first movement.",
"修改后债务或资产金额不能为负数": "The edited debt or asset value cannot be negative.",
"重算后的金额超出支持范围": "The recalculated amount exceeds the supported range.",
"请在资金往来中修改完整配对记录": "Edit the complete pair in Movements.",
"请在资金往来中删除完整配对记录": "Delete the complete pair in Movements.",
"资金往来记录不存在": "Movement not found.",
"修改记录不能更换账户或操作类型,请删除后重新创建": "To change accounts or the operation type, delete the movement and create it again.",
"修改": "Edit",
"统计粒度": "Time interval"
} }
+19 -1
View File
@@ -426,5 +426,23 @@
"六": "六", "六": "六",
"日": "日", "日": "日",
"已完成": "已完成", "已完成": "已完成",
"一次性计划已完成,请新建计划": "一次性計劃已完成,請新建計劃" "一次性计划已完成,请新建计划": "一次性計劃已完成,請新建計劃",
"显示全部账户": "顯示全部賬戶",
"删除这条余额记录并重算后续余额?": "刪除這條餘額記錄並重算後續餘額?",
"删除这笔资金往来并重算双方余额?": "刪除這筆資金往來並重算雙方餘額?",
"记录已删除,余额和日历已更新": "記錄已刪除,餘額和日曆已更新",
"记录已修改,余额和日历已更新": "記錄已修改,餘額和日曆已更新",
"修改资金往来": "修改資金往來",
"查看资金往来": "查看資金往來",
"打开账户页或点击执行时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。": "打開賬戶頁或點擊執行時處理到期計劃。每次最多執行20項,離線期間的計劃會逐次補記。",
"配对记录不完整,无法重算": "配對記錄不完整,無法重算",
"资金操作之前必须保留一条余额记录": "資金操作之前必須保留一條餘額記錄",
"修改后债务或资产金额不能为负数": "修改後債務或資產金額不能為負數",
"重算后的金额超出支持范围": "重算後的金額超出支援範圍",
"请在资金往来中修改完整配对记录": "請在資金往來中修改完整配對記錄",
"请在资金往来中删除完整配对记录": "請在資金往來中刪除完整配對記錄",
"资金往来记录不存在": "資金往來記錄不存在",
"修改记录不能更换账户或操作类型,请删除后重新创建": "修改記錄不能更換賬戶或操作類型,請刪除後重新建立",
"修改": "修改",
"统计粒度": "統計粒度"
} }
+152
View File
@@ -1335,3 +1335,155 @@ footer {
.calendar-panel h2 { .calendar-panel h2 {
white-space: nowrap; white-space: nowrap;
} }
/* Compact cards and a calmer overview hierarchy. */
body {
-webkit-user-select: none;
user-select: none;
}
input,
textarea,
[contenteditable='true'] {
-webkit-user-select: text;
user-select: text;
}
.position-grid {
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 12px;
}
.position-card {
padding: 14px 16px;
position: relative;
}
.position-card > div {
float: left;
margin: 0 12px 8px 0;
}
.position-card > div > svg {
display: none;
}
.position-icon {
padding: 7px;
}
.position-card h3 {
font-size: 14px;
margin: 0 0 2px;
overflow-wrap: anywhere;
}
.position-card p {
margin: 2px 0 8px;
font-size: 11px;
}
.position-card strong {
clear: both;
font-size: 19px;
line-height: 1.3;
}
.position-card small {
margin-top: 3px;
font-size: 11px;
}
.position-card .badge {
font-size: 10px;
padding: 1px 6px;
}
.picker-view-toggle {
display: inline-flex;
align-items: center;
gap: 18px;
border-radius: 8px;
}
.picker-dialog .actions {
margin: 12px 0;
}
.metric {
padding: 20px;
}
.metric.net {
background: #e9f3ed;
border-color: #a9cbb9;
}
.metric strong {
font-size: clamp(22px, 2.5vw, 32px);
}
.dashboard-grid {
grid-template-columns: minmax(0, 2fr) minmax(230px, 1fr);
gap: 16px;
}
.dashboard-grid .panel {
padding: 20px;
}
.dashboard-grid .privacy {
margin-bottom: 0;
}
.attribution summary {
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
.attribution[open] summary {
margin-bottom: 16px;
}
.record-actions {
gap: 16px;
}
@media (max-width: 760px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.position-grid {
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
gap: 8px;
}
.position-card {
padding: 12px;
}
.position-card strong {
font-size: 17px;
}
}
.metric.net {
order: -1;
}
.metrics {
grid-template-columns: 1.2fr 1fr 1fr;
}
@media (max-width: 600px) {
.metrics {
grid-template-columns: 1fr;
}
}
.metric.net {
color: #234c3b;
}
.metric.net > div,
.metric.net small {
color: #557867;
}
.overview-filters {
align-items: end;
gap: 8px;
margin-top: 16px;
}
.overview-filters label {
flex: 1;
min-width: 130px;
font-size: 11px;
}
.overview-filters input {
padding: 8px;
font-size: 12px;
}
.overview-filters select {
width: auto;
flex: 1;
min-width: 130px;
padding: 8px;
font-size: 12px;
}
.overview-filters + .muted {
font-size: 10px;
line-height: 1.5;
}
+6
View File
@@ -56,3 +56,9 @@
## 2026-10-02 账户分组、计划与日历 ## 2026-10-02 账户分组、计划与日历
本轮 28 项单元测试、类型检查和生产构建通过。浏览器模拟数据验证与实际数据库未验证部分详见 [本轮验收](update-2026-10-02.md)。首轮连接数据库发生 PROTOCOL_CONNECTION_LOST;2026-10-02 17:54 补充验收已完成迁移、31 个图库图标初始化及全部 9 项真实 MySQL 集成检查。前文并发余额不足的旧断言已随允许透支需求调整。 本轮 28 项单元测试、类型检查和生产构建通过。浏览器模拟数据验证与实际数据库未验证部分详见 [本轮验收](update-2026-10-02.md)。首轮连接数据库发生 PROTOCOL_CONNECTION_LOST;2026-10-02 17:54 补充验收已完成迁移、31 个图库图标初始化及全部 9 项真实 MySQL 集成检查。前文并发余额不足的旧断言已随允许透支需求调整。
## 2026-10-02 页面精简、记录编辑与补充图库
类型检查、生产构建、28 项单元测试通过。新增真实数据库记录编辑专项涵盖双边余额、历史重放、计划支出更正、日历联动、精确金额、债务回滚及权限边界。13 个迁移已应用;9 张表与 88 个字段均有注释;内置图标扩充至 34 项。浏览器虚构数据检查与限制见 [更新说明](update-2026-10-02.md)。
最终完整真实数据库回归 10 项全部通过;连续测试触发限流后的重启复验与新增记录专项见更新说明。
+2
View File
@@ -41,3 +41,5 @@ Icon 存储 name、ownerId、shared、SHA-256、规范静态 PNG 的 MediumBlob
Transfer 归属于 User,关联转出/转入 Position(启用资产账户)及两条 Revision ID。金额、到账金额和额外手续费使用 Decimal(24,8),记录双方原币和业务分钟;账户关系用于配对展示,Transfer 本身不再次计入总额。服务端 Serializable 事务同时创建配对绝对余额及转账记录;请求 UUID 支持相同内容重试。历史修正限制防止改变已配对转账的基准余额。 Transfer 归属于 User,关联转出/转入 Position(启用资产账户)及两条 Revision ID。金额、到账金额和额外手续费使用 Decimal(24,8),记录双方原币和业务分钟;账户关系用于配对展示,Transfer 本身不再次计入总额。服务端 Serializable 事务同时创建配对绝对余额及转账记录;请求 UUID 支持相同内容重试。历史修正限制防止改变已配对转账的基准余额。
User.hiddenMenus 保存受枚举校验的菜单键,settings 不可隐藏;User.showNotes 默认 true,仅控制客户端显示,财务备注和导出保持完整。ZIP v5 新增 transfers.json,追加恢复时映射账户及配对历史 ID,空空间恢复显示偏好,已有空间保留当前设置。 User.hiddenMenus 保存受枚举校验的菜单键,settings 不可隐藏;User.showNotes 默认 true,仅控制客户端显示,财务备注和导出保持完整。ZIP v5 新增 transfers.json,追加恢复时映射账户及配对历史 ID,空空间恢复显示偏好,已有空间保留当前设置。
2026-10-02 记录编辑更新:允许更正转账之前的独立余额历史。`replay.ts` 重放涉及项目的历史,绝对余额保留为锚点,配对资金往来与定时支出按增量更新;整笔修改/删除与两侧余额在同一事务提交。单侧配对历史仍禁止直接更正/删除。日历读取保存后的账户历史重算。完整边界见 [更新说明](update-2026-10-02.md)。
+81 -2
View File
@@ -1,6 +1,6 @@
{ {
"tableCount": 8, "tableCount": 9,
"columnCount": 73, "columnCount": 88,
"tables": [ "tables": [
{ {
"TABLE_NAME": "exchangerate", "TABLE_NAME": "exchangerate",
@@ -22,6 +22,10 @@
"TABLE_NAME": "revision", "TABLE_NAME": "revision",
"TABLE_COMMENT": "余额历史:金额为变更后的绝对余额" "TABLE_COMMENT": "余额历史:金额为变更后的绝对余额"
}, },
{
"TABLE_NAME": "schedule",
"TABLE_COMMENT": "按需执行的定时支出或转账计划"
},
{ {
"TABLE_NAME": "session", "TABLE_NAME": "session",
"TABLE_COMMENT": "登录会话与隐藏项目查看授权" "TABLE_COMMENT": "登录会话与隐藏项目查看授权"
@@ -181,6 +185,11 @@
"COLUMN_NAME": "iconId", "COLUMN_NAME": "iconId",
"COLUMN_COMMENT": "关联账户图标标识" "COLUMN_COMMENT": "关联账户图标标识"
}, },
{
"TABLE_NAME": "position",
"COLUMN_NAME": "groupName",
"COLUMN_COMMENT": "自定义账户分组"
},
{ {
"TABLE_NAME": "positionlink", "TABLE_NAME": "positionlink",
"COLUMN_NAME": "id", "COLUMN_NAME": "id",
@@ -241,6 +250,76 @@
"COLUMN_NAME": "sequence", "COLUMN_NAME": "sequence",
"COLUMN_COMMENT": "数据库自增唯一顺序,同业务时间按此排序" "COLUMN_COMMENT": "数据库自增唯一顺序,同业务时间按此排序"
}, },
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "id",
"COLUMN_COMMENT": "唯一标识"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "userId",
"COLUMN_COMMENT": "所属用户标识,用于数据隔离"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "importedFromId",
"COLUMN_COMMENT": "备份来源计划标识,用于重复导入识别"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "name",
"COLUMN_COMMENT": "计划显示名称"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "operation",
"COLUMN_COMMENT": "操作类型:expense 支出或 transfer 转账"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "sourceId",
"COLUMN_COMMENT": "支出或转出资产账户标识"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "targetId",
"COLUMN_COMMENT": "转入账户标识;支出计划为空"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "amount",
"COLUMN_COMMENT": "原币支出或转出金额"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "received",
"COLUMN_COMMENT": "原币到账金额;支出计划为零"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "nextAt",
"COLUMN_COMMENT": "下次到期业务时间对应的 UTC"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "intervalDays",
"COLUMN_COMMENT": "重复间隔天数;零表示仅执行一次"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "enabled",
"COLUMN_COMMENT": "是否启用计划"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "completed",
"COLUMN_COMMENT": "一次性计划是否已执行完毕"
},
{
"TABLE_NAME": "schedule",
"COLUMN_NAME": "notes",
"COLUMN_COMMENT": "用户备注,原文保留"
},
{ {
"TABLE_NAME": "session", "TABLE_NAME": "session",
"COLUMN_NAME": "id", "COLUMN_NAME": "id",
+22 -1
View File
@@ -51,7 +51,7 @@ pnpm test:integration
- 新增 `test/update-integration.test.ts`,覆盖真实 MySQL 中计划并发执行一次、透支、补记、分组恢复、隐藏账户与日历隔离,纳入 `pnpm test:integration`。 - 新增 `test/update-integration.test.ts`,覆盖真实 MySQL 中计划并发执行一次、透支、补记、分组恢复、隐藏账户与日历隔离,纳入 `pnpm test:integration`。
- 首轮连接数据库发生 `PROTOCOL_CONNECTION_LOST` 时,数据库验收暂未执行;该阻塞已在下面的补充验收中解决。 - 首轮连接数据库发生 `PROTOCOL_CONNECTION_LOST` 时,数据库验收暂未执行;该阻塞已在下面的补充验收中解决。
## 数据库恢复后的补充验收(2026-10-02 17:54,香港时间) ## 数据库恢复后的补充验收(2026-10-02 17:54)
- 已确认 MySQL 连接恢复并通过 SELECT 1;增量应用 `20261002000400_groups_schedules` 成功,`pnpm db:status` 确认全部 12 个迁移完成,schema 最新。 - 已确认 MySQL 连接恢复并通过 SELECT 1;增量应用 `20261002000400_groups_schedules` 成功,`pnpm db:status` 确认全部 12 个迁移完成,schema 最新。
- 已生成 Prisma 客户端并编译后端,运行图库初始化;数据库检查确认 31 个固定内置图标 ID 均有图片数据且为共享图标。 - 已生成 Prisma 客户端并编译后端,运行图库初始化;数据库检查确认 31 个固定内置图标 ID 均有图片数据且为共享图标。
@@ -60,3 +60,24 @@ pnpm test:integration
- 修复债务集成测试中仍断言 manifest v6 的旧预期,当前应为 v7。连续重跑触发临时服务的正常登录限流后,重启本次临时测试服务再执行,未修改限流规则。 - 修复债务集成测试中仍断言 manifest v6 的旧预期,当前应为 v7。连续重跑触发临时服务的正常登录限流后,重启本次临时测试服务再执行,未修改限流规则。
- 本轮 `pnpm test` 28 项通过,`pnpm typecheck` 通过;后端编译通过,前端生产构建在此前实现验收已通过,本轮未修改前端代码。 - 本轮 `pnpm test` 28 项通过,`pnpm typecheck` 通过;后端编译通过,前端生产构建在此前实现验收已通过,本轮未修改前端代码。
- 上一轮遗留的数据库迁移、图标初始化及真实 MySQL 集成验收现已完成。浏览器布局验收仍沿用此前模拟数据结果,没有新增真实浏览器端到端验收。 - 上一轮遗留的数据库迁移、图标初始化及真实 MySQL 集成验收现已完成。浏览器布局验收仍沿用此前模拟数据结果,没有新增真实浏览器端到端验收。
## 页面精简、记录编辑与补充图库(2026-10-02 18:43)
- 定时计划移到独立菜单页面,支持添加、暂停、删除及执行到期计划。账户页继续按需执行到期计划,计划页面仅点击执行才运行。设置支持隐藏日历与定时计划入口。
- 页面展示文字不可选,输入框与备注保留选字及编辑能力。账户选择弹窗只显示一个切换按钮,文字表示下一种视图。账户/资产/债务卡片缩小间距与图标,总览突出净资产,筛选移入趋势区域,最近记录展示六条,变化归因默认折叠。
- 余额历史可更正和删除;资金往来可修改金额、到账金额、手续费、日期和备注,或整笔删除。账户与操作类型保持原绑定,若需更换请删除后重建。转账及借贷配对记录禁止单侧更正或删除,历史入口可跳转资金往来;删除前有确认。
- `replay.ts` 在 Serializable 事务中按固定顺序锁定相关项目并重放历史。手动余额/估值保留为绝对金额锚点;资金往来按本金与手续费更新双方,定时支出保留原增量。更正定时支出记录时,填写的新金额转为绝对余额锚点。后续真实余额快照不会随更早的转账修改被平移。
- 债务为负、金额溢出或首笔资金操作失去前置余额锚点时,整个修改回滚。隐藏项目须解锁,归档项目须恢复;跨用户操作返回不存在。日历、历史与总览读取保存后的统一记录,成功操作刷新当前页面,进入日历重新查询。
- 日历沿用账户余额差额估算口径,内部转账和借贷本金不作为收支;删除或更正余额历史会改变对应日期估算值。删除定时计划已生成的记录不会回退计划进度或再次执行。
- 新增 ZA BANK 众安银行、大象银行 EleBank、微众银行 WeBank,共 34 个内置图标。ZA 使用官网公开品牌资源,微众从官网页眉资源获取真实 logo,大象使用官方 App Store 当前应用图标。PNG 与来源清单随代码保存,固定 ID 初始化不覆盖上传图标。
- 注释迁移 `20261002000500_schedule_comments` 已应用,补齐 Schedule 表及字段注释,13 个迁移状态最新。新增 `node scripts/check-comments.cjs`(在 apps/api 执行)核验 9 张业务表、88 个字段均有注释,并更新 `docs/database-comments.json`。
### 验证与边界
类型检查、生产构建及 28 项单元测试通过。新增 `test/record-edit.test.ts` 真实数据库专项,覆盖双边余额编辑/删除、早期余额更正、定时支出增量与更正、日期调整、绝对余额锚点、八位小数与负手续费、债务原子回滚、跨用户/隐藏账户隔离、日历联动及起始锚点保护。
浏览器使用本轮专用虚构账号检查卡片、总览层级/对比度、全部/分组的单按钮切换、定时计划独立页面。虚构账号已精确清理;未验证真实手机硬件、外网隧道或正式部署。
沙箱内 Node 系统用户查询返回 ENOMEM;测试、迁移及临时服务在获准的宿主环境运行成功。连续完整验收触发临时服务内存登录限流(429),重启该临时服务后再次执行,不修改限流规则。用户原有 Vite 配置保留,本轮提交不包含该文件;按 update.md 仅做本地提交,不 push。
最终完整数据库回归:2026-10-02 18:44,10 项全部通过。临时服务与预览资源已清理;仅保留本轮新增代码、文档和图库。
+24 -15
View File
@@ -4,24 +4,33 @@
> 完成一项任务后添加为这项目任务添加删除线,并写上完成时间 > 完成一项任务后添加为这项目任务添加删除线,并写上完成时间
> 这份文档可能随时更新,在完成所有任务之后应判断文档是否加入新的更新要求 > 这份文档可能随时更新,在完成所有任务之后应判断文档是否加入新的更新要求
> 同时需要继续更新docs文件夹或里面的文档 > 同时需要继续更新docs文件夹或里面的文档
> 数据库的表和字段需要有注释
更新要求: 更新要求:
- ~~转出账户余额不足时直接变成负的而不是拒绝转账~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~转出账户余额不足时直接变成负的而不是拒绝转账~~ — 已完成代码更新:2026-10-02 17:37
- ~~在债务中欠债显示负数~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~在债务中欠债显示负数~~ — 已完成代码更新:2026-10-02 17:37
- ~~在债务中改成显示(还款和收款),并且同时只显示一个~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~在债务中改成显示(还款和收款),并且同时只显示一个~~ — 已完成代码更新:2026-10-02 17:37
- ~~在账号中可以自定义分组(比如设置日常账号组,消费账号组,理财账号组)~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~在账号中可以自定义分组(比如设置日常账号组,消费账号组,理财账号组)~~ — 已完成代码更新:2026-10-02 17:37
- ~~在选择账号时(如还款,收款,转账)用弹窗选择,并在弹窗中设置显示显示全部账号还是分组显示~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~在选择账号时(如还款,收款,转账)用弹窗选择,并在弹窗中设置显示显示全部账号还是分组显示~~ — 已完成代码更新:2026-10-02 17:37
- ~~优化所有文字描述~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~优化所有文字描述~~ — 已完成代码更新:2026-10-02 17:37
- ~~CNY等在部分地方可以用图标替代~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~CNY等在部分地方可以用图标替代~~ — 已完成代码更新:2026-10-02 17:37
- ~~优化净资产轨迹,一开始数据没这么多,不显示没有数据日期~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~优化净资产轨迹,一开始数据没这么多,不显示没有数据日期~~ — 已完成代码更新:2026-10-02 17:37
- ~~可以快速开启记账模式,在账号页点击即可设置金额~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~可以快速开启记账模式,在账号页点击即可设置金额~~ — 已完成代码更新:2026-10-02 17:37
- ~~添加定时计划(有想办法优化性能,可以在用户需要的时候在执行,不一定要真实的定时执行),可以设置定时支出,转账。~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~添加定时计划(有想办法优化性能,可以在用户需要的时候在执行,不一定要真实的定时执行),可以设置定时支出,转账。~~ — 已完成代码更新:2026-10-02 17:37
- ~~丰富图标库,添加香港的主要部分银行,热门虚拟货币交易所的图标(如币安、gate、bybit、bitget)等等~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~丰富图标库,添加香港的主要部分银行,热门虚拟货币交易所的图标(如币安、gate、bybit、bitget)等等~~ — 已完成代码更新:2026-10-02 17:37
- ~~欠债、负债、支出显示红色~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~欠债、负债、支出显示红色~~ — 已完成代码更新:2026-10-02 17:37
- ~~添加日历功能:显示一个月每天的总收入或支出,点击看查看某一天的收入或支出(默认今天)两个应在同一个页面上下显示~~ — 已完成代码更新:2026-10-02 17:37(香港时间) - ~~添加日历功能:显示一个月每天的总收入或支出,点击看查看某一天的收入或支出(默认今天)两个应在同一个页面上下显示~~ — 已完成代码更新:2026-10-02 17:37
- - ~~定时任务是单独的页面~~ — 已完成并验证:2026-10-02 18:34
- ~~优化页面,让文字变得不可选~~ — 已完成并验证:2026-10-02 18:34
- ~~选择账号时全部账号和按分组显示的按钮不好看,同一时间应只显示一个~~ — 已完成并验证:2026-10-02 18:34
- ~~优化账号等卡片显示,太占位置了~~ — 已完成并验证:2026-10-02 18:34
- ~~优化资产总览的UI,看着太乱了~~ — 已完成并验证:2026-10-02 18:34
- ~~余额和转账记录都可以删除,修改(收支日历要与记录联动)~~ — 已完成并验证:2026-10-02 18:34
- ~~图标新增ZA BANK、大象银行、微众银行等~~ — 已完成并验证:2026-10-02 18:43
验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。 验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。
数据库验收完成:2026-10-02 17:54(香港时间)。12 个迁移状态最新,31 个内置图标已初始化;28 项单元测试、9 项真实数据库集成检查通过。 数据库验收完成:2026-10-02 17:54。12 个迁移状态最新,31 个内置图标已初始化;28 项单元测试、9 项真实数据库集成检查通过。
本轮补充验收完成:2026-10-02 18:44。13 个迁移状态最新,34 个内置图标已初始化;28 项单元测试、10 项真实数据库集成检查通过,9 张表与 88 个字段注释已核验。重新检查文档,无新增未完成要求。