feat: streamline portfolio UI and support atomic record edits
This commit is contained in:
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 |
@@ -184,5 +184,23 @@
|
||||
"source": "https://bin.bnbstatic.com/static/images/common/favicon.ico",
|
||||
"file": "31.png",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
@@ -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: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",
|
||||
"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 '用户备注,原文保留';
|
||||
@@ -216,20 +216,34 @@ model Transfer {
|
||||
|
||||
/// 按需执行的定时支出或转账计划
|
||||
model Schedule {
|
||||
/// 唯一标识
|
||||
id String @id @default(uuid()) @db.Char(36)
|
||||
/// 所属用户标识,用于数据隔离
|
||||
userId String @db.Char(36)
|
||||
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||
/// 备份来源计划标识,用于重复导入识别
|
||||
importedFromId String? @db.Char(36)
|
||||
/// 计划显示名称
|
||||
name String @db.VarChar(100)
|
||||
/// 操作类型:expense 支出或 transfer 转账
|
||||
operation String @db.VarChar(16)
|
||||
/// 支出或转出资产账户标识
|
||||
sourceId String @db.Char(36)
|
||||
/// 转入账户标识;支出计划为空
|
||||
targetId String? @db.Char(36)
|
||||
/// 原币支出或转出金额
|
||||
amount Decimal @db.Decimal(24,8)
|
||||
/// 原币到账金额;支出计划为零
|
||||
received Decimal @db.Decimal(24,8)
|
||||
/// 下次到期业务时间对应的 UTC
|
||||
nextAt DateTime @db.DateTime(3)
|
||||
/// 重复间隔天数;零表示仅执行一次
|
||||
intervalDays Int
|
||||
/// 是否启用计划
|
||||
enabled Boolean @default(true)
|
||||
/// 一次性计划是否已执行完毕
|
||||
completed Boolean @default(false)
|
||||
/// 用户备注,原文保留
|
||||
notes String @db.Text
|
||||
@@index([userId,enabled,nextAt])
|
||||
@@unique([userId,importedFromId])
|
||||
|
||||
@@ -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());
|
||||
@@ -1,16 +1,26 @@
|
||||
"""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 PIL import Image
|
||||
folder = Path(__file__).resolve().parent.parent / 'assets' / 'icons'
|
||||
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 += [('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):
|
||||
return urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}), timeout=20).read()
|
||||
failed = []
|
||||
for name, site in brands:
|
||||
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:
|
||||
html = get(site).decode('utf-8', errors='replace')
|
||||
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()
|
||||
file = str(len(catalog) + 1).zfill(2) + '.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)
|
||||
break
|
||||
except Exception: continue
|
||||
|
||||
@@ -36,6 +36,7 @@ export function setupOpenApi(app: INestApplication) {
|
||||
.object({ targetIds: z.array(z.string().uuid()).max(20) })
|
||||
.strict(),
|
||||
'POST /api/transfers': transferInput,
|
||||
'PUT /api/transfers/{id}': transferInput,
|
||||
'POST /api/schedules': scheduleInput,
|
||||
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
|
||||
'PATCH /api/settings': z
|
||||
@@ -53,7 +54,7 @@ export function setupOpenApi(app: INestApplication) {
|
||||
}),
|
||||
};
|
||||
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];
|
||||
if (!operation) continue;
|
||||
operation.summary = `${method.toUpperCase()} ${path}`;
|
||||
|
||||
+28
-17
@@ -4,6 +4,7 @@ import {
|
||||
Post,
|
||||
Patch,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Req,
|
||||
Param,
|
||||
@@ -28,6 +29,7 @@ import { z } from 'zod';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IconsService } from './icons';
|
||||
import { RatesService } from './rates';
|
||||
import { captureReplay } from './replay';
|
||||
@Controller('api')
|
||||
export class PortfolioController {
|
||||
constructor(
|
||||
@@ -204,23 +206,9 @@ export class PortfolioController {
|
||||
throw new BadRequestException('仅资产账户支持负余额');
|
||||
const original = await tx.revision.findFirst({ where: { id: revisionId, positionId: p.id } });
|
||||
if (!original) throw new NotFoundException('历史记录不存在');
|
||||
const transfer = await tx.revision.findFirst({
|
||||
where: {
|
||||
positionId: p.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(
|
||||
'转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致',
|
||||
);
|
||||
if (pairedReasons.includes(original.reason))
|
||||
throw new ConflictException('请在资金往来中修改完整配对记录');
|
||||
const replay = await captureReplay(tx, r.userId, [id]);
|
||||
await tx.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: {
|
||||
@@ -230,6 +218,29 @@ export class PortfolioController {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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() } });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
NotFoundException,
|
||||
Body,
|
||||
Req,
|
||||
Query,
|
||||
@@ -15,6 +19,7 @@ import { UserRequest } from './auth';
|
||||
import { transferInput, toBusinessDate } from './validation';
|
||||
import { businessTime } from './calculation';
|
||||
import { movementDeltas } from './movement';
|
||||
import { captureReplay } from './replay';
|
||||
import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries';
|
||||
@Controller('api/transfers')
|
||||
export class TransfersController {
|
||||
@@ -43,6 +48,61 @@ export class TransfersController {
|
||||
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) {
|
||||
const v = transferInput.parse(body);
|
||||
return this.db.serial((tx) => executeMovement(tx, r, v));
|
||||
|
||||
@@ -128,10 +128,18 @@ export const credentials = z
|
||||
})
|
||||
.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
|
||||
.array(menuKey)
|
||||
.max(6)
|
||||
.max(7)
|
||||
.refine((v) => new Set(v).size === v.length, '菜单不可重复');
|
||||
export const transferInput = z
|
||||
.object({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -51,11 +51,12 @@ export function AccountPicker({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="actions">
|
||||
<button type="button" aria-pressed={!grouped} onClick={() => setGrouped(false)}>
|
||||
{tr('全部账户')}
|
||||
</button>
|
||||
<button type="button" aria-pressed={grouped} onClick={() => setGrouped(true)}>
|
||||
{tr('按分组显示')}
|
||||
<button
|
||||
className="secondary picker-view-toggle"
|
||||
type="button"
|
||||
onClick={() => setGrouped(!grouped)}
|
||||
>
|
||||
{tr(grouped ? '显示全部账户' : '按分组显示')} <span aria-hidden="true">⇄</span>
|
||||
</button>
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
|
||||
+205
-58
@@ -51,6 +51,7 @@ const labels: Record<string, string> = {
|
||||
debt: '债务',
|
||||
history: '变化记录',
|
||||
calendar: '收支日历',
|
||||
schedules: '定时计划',
|
||||
settings: '设置与备份',
|
||||
};
|
||||
const categories: Record<string, [string, string][]> = {
|
||||
@@ -221,6 +222,7 @@ export default function App() {
|
||||
kind: string;
|
||||
p?: Position;
|
||||
h?: History;
|
||||
transfer?: Transfer;
|
||||
operation?: DebtOperation;
|
||||
} | null>(null),
|
||||
[busy, setBusy] = useState(false),
|
||||
@@ -340,6 +342,9 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
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)) {
|
||||
if (page === 'account' && !rangeOnly) {
|
||||
const result = await api<{
|
||||
@@ -741,6 +746,29 @@ export default function App() {
|
||||
</section>
|
||||
</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 = [
|
||||
['overview', LayoutDashboard],
|
||||
['account', Wallet],
|
||||
@@ -748,6 +776,7 @@ export default function App() {
|
||||
['debt', HandCoins],
|
||||
['history', HistoryIcon],
|
||||
['calendar', CalendarDays],
|
||||
['schedules', HistoryIcon],
|
||||
['settings', Settings],
|
||||
] as const;
|
||||
const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key));
|
||||
@@ -882,42 +911,6 @@ export default function App() {
|
||||
{tr('正在刷新数据…')}
|
||||
</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 && (
|
||||
<>
|
||||
{!overview.complete && (
|
||||
@@ -967,6 +960,41 @@ export default function App() {
|
||||
<h2>{tr('净资产轨迹')}</h2>
|
||||
<span className="badge">{tr('按业务日期')}</span>
|
||||
</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} />
|
||||
<p className="muted">
|
||||
{tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')}
|
||||
@@ -1014,7 +1042,7 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
<HistoryTable
|
||||
rows={overview.recent}
|
||||
rows={overview.recent.slice(0, 6)}
|
||||
open={(id) => {
|
||||
setSelected(id);
|
||||
setPage(overview.recent.find((h) => h.positionId === id)?.kind || 'account');
|
||||
@@ -1034,8 +1062,8 @@ export default function App() {
|
||||
{tr('· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。')}
|
||||
</div>
|
||||
{overview.trend.length > 1 && (
|
||||
<section className="panel">
|
||||
<h2>{tr('变化归因')}</h2>
|
||||
<details className="panel attribution">
|
||||
<summary>{tr('变化归因')}</summary>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -1068,7 +1096,7 @@ export default function App() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1211,7 +1239,8 @@ export default function App() {
|
||||
</p>
|
||||
<HistoryTable
|
||||
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 && (
|
||||
<button disabled={loading} onClick={() => void more('history')}>
|
||||
@@ -1252,12 +1281,6 @@ export default function App() {
|
||||
</select>
|
||||
</div>
|
||||
{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) ? (
|
||||
@@ -1392,6 +1415,28 @@ export default function App() {
|
||||
{money(t.fee, t.sourceCurrency)}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
@@ -1409,6 +1454,8 @@ export default function App() {
|
||||
</p>
|
||||
<HistoryTable
|
||||
rows={historyRows}
|
||||
correct={(h) => void correctHistory(h)}
|
||||
remove={deleteHistory}
|
||||
open={(id) => {
|
||||
setSelected(id);
|
||||
setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account');
|
||||
@@ -1422,6 +1469,16 @@ export default function App() {
|
||||
</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' && (
|
||||
<Calendar revealed={!!user.revealed} refreshVersion={calendarRefresh} />
|
||||
)}
|
||||
@@ -1471,9 +1528,15 @@ export default function App() {
|
||||
() =>
|
||||
api('/settings', 'PATCH', {
|
||||
baseCurrency: f.get('baseCurrency'),
|
||||
hiddenMenus: ['overview', 'account', 'asset', 'debt', 'history'].filter(
|
||||
(key) => !f.has('menu_' + key),
|
||||
),
|
||||
hiddenMenus: [
|
||||
'overview',
|
||||
'account',
|
||||
'asset',
|
||||
'debt',
|
||||
'history',
|
||||
'calendar',
|
||||
'schedules',
|
||||
].filter((key) => !f.has('menu_' + key)),
|
||||
idleMinutes: Number(f.get('idleMinutes')),
|
||||
showNotes: f.has('showNotes'),
|
||||
}),
|
||||
@@ -1485,7 +1548,15 @@ export default function App() {
|
||||
{tr('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')}
|
||||
</p>
|
||||
<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">
|
||||
<span>{tr(labels[key])}</span>
|
||||
<input
|
||||
@@ -1865,7 +1936,76 @@ export default function App() {
|
||||
)}
|
||||
</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
|
||||
title={
|
||||
modal.kind === 'debt-payment'
|
||||
@@ -2215,10 +2355,12 @@ function HistoryTable({
|
||||
rows,
|
||||
open,
|
||||
correct,
|
||||
remove,
|
||||
}: {
|
||||
rows: History[];
|
||||
open?: (id: string) => void;
|
||||
correct?: (h: History) => void;
|
||||
remove?: (h: History) => void;
|
||||
}) {
|
||||
return rows.length ? (
|
||||
<div className="table-wrap">
|
||||
@@ -2307,13 +2449,18 @@ function HistoryTable({
|
||||
<td className="notes-cell">{h.notes || '—'}</td>
|
||||
{correct && (
|
||||
<td>
|
||||
<button
|
||||
className="text"
|
||||
disabled={h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')}
|
||||
onClick={() => correct(h)}
|
||||
>
|
||||
{tr('更正')}
|
||||
<button className="text" onClick={() => correct(h)}>
|
||||
{tr(
|
||||
h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')
|
||||
? '查看资金往来'
|
||||
: '更正',
|
||||
)}
|
||||
</button>
|
||||
{remove && !h.reason.startsWith('transfer_') && !h.reason.startsWith('loan_') && (
|
||||
<button className="text danger-text" onClick={() => remove(h)}>
|
||||
{tr('删除')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
|
||||
@@ -61,12 +61,12 @@ export function SchedulePanel({
|
||||
}
|
||||
}
|
||||
return (
|
||||
<details className="schedule-panel">
|
||||
<summary>
|
||||
<section className="schedule-panel">
|
||||
<h2>
|
||||
{tr('定时计划')} ({rows.length})
|
||||
</summary>
|
||||
</h2>
|
||||
<p className="muted">
|
||||
{tr('打开账户页时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。')}
|
||||
{tr('打开账户页或点击执行时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。')}
|
||||
</p>
|
||||
<div className="actions">
|
||||
<button type="button" disabled={busy || !accounts.length} onClick={() => setOpen(!open)}>
|
||||
@@ -227,6 +227,6 @@ export function SchedulePanel({
|
||||
</p>
|
||||
)}
|
||||
{status && <p role="status">{status}</p>}
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -426,5 +426,23 @@
|
||||
"六": "Sat",
|
||||
"日": "Sun",
|
||||
"已完成": "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"
|
||||
}
|
||||
@@ -426,5 +426,23 @@
|
||||
"六": "六",
|
||||
"日": "日",
|
||||
"已完成": "已完成",
|
||||
"一次性计划已完成,请新建计划": "一次性計劃已完成,請新建計劃"
|
||||
"一次性计划已完成,请新建计划": "一次性計劃已完成,請新建計劃",
|
||||
"显示全部账户": "顯示全部賬戶",
|
||||
"删除这条余额记录并重算后续余额?": "刪除這條餘額記錄並重算後續餘額?",
|
||||
"删除这笔资金往来并重算双方余额?": "刪除這筆資金往來並重算雙方餘額?",
|
||||
"记录已删除,余额和日历已更新": "記錄已刪除,餘額和日曆已更新",
|
||||
"记录已修改,余额和日历已更新": "記錄已修改,餘額和日曆已更新",
|
||||
"修改资金往来": "修改資金往來",
|
||||
"查看资金往来": "查看資金往來",
|
||||
"打开账户页或点击执行时处理到期计划。每次最多执行20项,离线期间的计划会逐次补记。": "打開賬戶頁或點擊執行時處理到期計劃。每次最多執行20項,離線期間的計劃會逐次補記。",
|
||||
"配对记录不完整,无法重算": "配對記錄不完整,無法重算",
|
||||
"资金操作之前必须保留一条余额记录": "資金操作之前必須保留一條餘額記錄",
|
||||
"修改后债务或资产金额不能为负数": "修改後債務或資產金額不能為負數",
|
||||
"重算后的金额超出支持范围": "重算後的金額超出支援範圍",
|
||||
"请在资金往来中修改完整配对记录": "請在資金往來中修改完整配對記錄",
|
||||
"请在资金往来中删除完整配对记录": "請在資金往來中刪除完整配對記錄",
|
||||
"资金往来记录不存在": "資金往來記錄不存在",
|
||||
"修改记录不能更换账户或操作类型,请删除后重新创建": "修改記錄不能更換賬戶或操作類型,請刪除後重新建立",
|
||||
"修改": "修改",
|
||||
"统计粒度": "統計粒度"
|
||||
}
|
||||
@@ -1335,3 +1335,155 @@ footer {
|
||||
.calendar-panel h2 {
|
||||
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;
|
||||
}
|
||||
@@ -56,3 +56,9 @@
|
||||
## 2026-10-02 账户分组、计划与日历
|
||||
|
||||
本轮 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 项全部通过;连续测试触发限流后的重启复验与新增记录专项见更新说明。
|
||||
@@ -41,3 +41,5 @@ Icon 存储 name、ownerId、shared、SHA-256、规范静态 PNG 的 MediumBlob
|
||||
Transfer 归属于 User,关联转出/转入 Position(启用资产账户)及两条 Revision ID。金额、到账金额和额外手续费使用 Decimal(24,8),记录双方原币和业务分钟;账户关系用于配对展示,Transfer 本身不再次计入总额。服务端 Serializable 事务同时创建配对绝对余额及转账记录;请求 UUID 支持相同内容重试。历史修正限制防止改变已配对转账的基准余额。
|
||||
|
||||
User.hiddenMenus 保存受枚举校验的菜单键,settings 不可隐藏;User.showNotes 默认 true,仅控制客户端显示,财务备注和导出保持完整。ZIP v5 新增 transfers.json,追加恢复时映射账户及配对历史 ID,空空间恢复显示偏好,已有空间保留当前设置。
|
||||
|
||||
2026-10-02 记录编辑更新:允许更正转账之前的独立余额历史。`replay.ts` 重放涉及项目的历史,绝对余额保留为锚点,配对资金往来与定时支出按增量更新;整笔修改/删除与两侧余额在同一事务提交。单侧配对历史仍禁止直接更正/删除。日历读取保存后的账户历史重算。完整边界见 [更新说明](update-2026-10-02.md)。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"tableCount": 8,
|
||||
"columnCount": 73,
|
||||
"tableCount": 9,
|
||||
"columnCount": 88,
|
||||
"tables": [
|
||||
{
|
||||
"TABLE_NAME": "exchangerate",
|
||||
@@ -22,6 +22,10 @@
|
||||
"TABLE_NAME": "revision",
|
||||
"TABLE_COMMENT": "余额历史:金额为变更后的绝对余额"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "schedule",
|
||||
"TABLE_COMMENT": "按需执行的定时支出或转账计划"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "session",
|
||||
"TABLE_COMMENT": "登录会话与隐藏项目查看授权"
|
||||
@@ -181,6 +185,11 @@
|
||||
"COLUMN_NAME": "iconId",
|
||||
"COLUMN_COMMENT": "关联账户图标标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "groupName",
|
||||
"COLUMN_COMMENT": "自定义账户分组"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "positionlink",
|
||||
"COLUMN_NAME": "id",
|
||||
@@ -241,6 +250,76 @@
|
||||
"COLUMN_NAME": "sequence",
|
||||
"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",
|
||||
"COLUMN_NAME": "id",
|
||||
|
||||
@@ -51,7 +51,7 @@ pnpm test:integration
|
||||
- 新增 `test/update-integration.test.ts`,覆盖真实 MySQL 中计划并发执行一次、透支、补记、分组恢复、隐藏账户与日历隔离,纳入 `pnpm test:integration`。
|
||||
- 首轮连接数据库发生 `PROTOCOL_CONNECTION_LOST` 时,数据库验收暂未执行;该阻塞已在下面的补充验收中解决。
|
||||
|
||||
## 数据库恢复后的补充验收(2026-10-02 17:54,香港时间)
|
||||
## 数据库恢复后的补充验收(2026-10-02 17:54)
|
||||
|
||||
- 已确认 MySQL 连接恢复并通过 SELECT 1;增量应用 `20261002000400_groups_schedules` 成功,`pnpm db:status` 确认全部 12 个迁移完成,schema 最新。
|
||||
- 已生成 Prisma 客户端并编译后端,运行图库初始化;数据库检查确认 31 个固定内置图标 ID 均有图片数据且为共享图标。
|
||||
@@ -60,3 +60,24 @@ pnpm test:integration
|
||||
- 修复债务集成测试中仍断言 manifest v6 的旧预期,当前应为 v7。连续重跑触发临时服务的正常登录限流后,重启本次临时测试服务再执行,未修改限流规则。
|
||||
- 本轮 `pnpm test` 28 项通过,`pnpm typecheck` 通过;后端编译通过,前端生产构建在此前实现验收已通过,本轮未修改前端代码。
|
||||
- 上一轮遗留的数据库迁移、图标初始化及真实 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 项全部通过。临时服务与预览资源已清理;仅保留本轮新增代码、文档和图库。
|
||||
@@ -4,24 +4,33 @@
|
||||
> 完成一项任务后添加为这项目任务添加删除线,并写上完成时间
|
||||
> 这份文档可能随时更新,在完成所有任务之后应判断文档是否加入新的更新要求
|
||||
> 同时需要继续更新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(香港时间)
|
||||
- ~~CNY等在部分地方可以用图标替代~~ — 已完成代码更新: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(香港时间)
|
||||
- ~~欠债、负债、支出显示红色~~ — 已完成代码更新: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
|
||||
- ~~优化净资产轨迹,一开始数据没这么多,不显示没有数据日期~~ — 已完成代码更新:2026-10-02 17:37
|
||||
- ~~可以快速开启记账模式,在账号页点击即可设置金额~~ — 已完成代码更新:2026-10-02 17:37
|
||||
- ~~添加定时计划(有想办法优化性能,可以在用户需要的时候在执行,不一定要真实的定时执行),可以设置定时支出,转账。~~ — 已完成代码更新: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 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)。
|
||||
|
||||
数据库验收完成: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 个字段注释已核验。重新检查文档,无新增未完成要求。
|
||||
Reference in new issue
Block a user