feat: adopt Apple UI and four-tab mobile navigation

This commit is contained in:
陈煜 committed 2026-10-07 18:13:59 +08:00
1 parent daf9b458af
commit fb8cf1c938
20 files changed
+1662 -378

No files matched your search

-2
View File
@@ -10,7 +10,6 @@ import {
credentialChange,
positionInput,
repaymentMarkInput,
bookedMarkInput,
inclusionInput,
positionMeta,
revisionInput,
@@ -46,7 +45,6 @@ export function setupOpenApi(app: INestApplication) {
'POST /api/positions': positionInput,
'PATCH /api/positions/{id}': positionMeta,
'PATCH /api/positions/{id}/repayment-mark': repaymentMarkInput,
'PATCH /api/positions/{id}/booked-mark': bookedMarkInput,
'PATCH /api/positions/{id}/inclusion': inclusionInput,
'POST /api/positions/{id}/revisions': revisionInput,
'PUT /api/positions/{id}/revisions/{revisionId}': revisionInput,
+20 -22
View File
@@ -19,7 +19,6 @@ import { UserRequest } from './auth';
import {
positionInput,
repaymentMarkInput,
bookedMarkInput,
inclusionInput,
positionMeta,
revisionInput,
@@ -28,7 +27,14 @@ import {
pairedReasons,
} from './validation';
import { totals, trend } from './calculation';
import { currentPositions, currentRates, historyPage, trendData, trendInput } from './queries';
import {
currentPositions,
currentRates,
historyPage,
trendData,
trendInput,
accountActivityIds,
} from './queries';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { IconsService } from './icons';
@@ -54,6 +60,11 @@ export class PortfolioBusinessService {
const kind = z.enum(['account', 'asset', 'debt']).optional().parse(inputKind);
return this.db.$transaction(async (tx) => {
const rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind);
const day = today();
const activity =
!kind || kind === 'account'
? await accountActivityIds(tx, r.userId, r.revealed, day)
: new Set<string>();
const user = await tx.user.findUniqueOrThrow({
where: { id: r.userId },
select: { baseCurrency: true, includeIndependentAssets: true },
@@ -70,6 +81,7 @@ export class PortfolioBusinessService {
);
return rows.map(({ revisions, userId, ...p }) => ({
...p,
activityDate: activity.has(p.id) ? day : null,
...metalPerformance(p, revisions[0]?.amount.toString() || '0', !!revisions.length),
amount: revisions[0]?.amount.toString() || '0',
converted: amounts.get(p.id)!.converted,
@@ -162,8 +174,8 @@ export class PortfolioBusinessService {
return { ok: true };
}
async quickMeta(r: UserRequest, id: string, raw: unknown, operation: 'booked' | 'included') {
const v = operation === 'booked' ? bookedMarkInput.parse(raw) : inclusionInput.parse(raw);
async quickMeta(r: UserRequest, id: string, raw: unknown) {
const v = inclusionInput.parse(raw);
return this.db.serial(async (tx) => {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
@@ -172,23 +184,16 @@ export class PortfolioBusinessService {
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
if (!p) throw new NotFoundException('项目不存在');
if (operation === 'booked' && (p.kind !== 'account' || p.archived))
throw new BadRequestException('仅启用账户支持当天记账标记');
if (operation === 'included' && p.kind !== 'asset')
throw new BadRequestException('仅独立资产支持此快速统计开关');
if (p.kind !== 'asset') throw new BadRequestException('仅独立资产支持此快速统计开关');
if (v.expectedUpdatedAt && p.updatedAt.toISOString() !== v.expectedUpdatedAt)
throw new ConflictException('项目已变化,请刷新后重试');
if (
('expectedDate' in v && p.lastBookedDate !== v.expectedDate) ||
('expectedIncluded' in v && p.included !== v.expectedIncluded)
)
if (p.included !== v.expectedIncluded)
throw new ConflictException('标记已变化,请刷新后重试');
const updated = await tx.position.update({
where: { id },
data: 'date' in v ? { lastBookedDate: v.date } : { included: v.included },
data: { included: v.included },
});
return {
lastBookedDate: updated.lastBookedDate,
included: updated.included,
updatedAt: updated.updatedAt,
};
@@ -473,19 +478,12 @@ export class PortfolioController {
) {
return this.service.edit(r, id, b);
}
@Patch('positions/:id/booked-mark') async bookedMark(
@Req() r: UserRequest,
@Param('id') id: string,
@Body() b: unknown,
) {
return this.service.quickMeta(r, id, b, 'booked');
}
@Patch('positions/:id/inclusion') async inclusion(
@Req() r: UserRequest,
@Param('id') id: string,
@Body() b: unknown,
) {
return this.service.quickMeta(r, id, b, 'included');
return this.service.quickMeta(r, id, b);
}
@Patch('positions/:id/repayment-mark') async repaymentMark(
@Req() r: UserRequest,
+19
View File
@@ -31,6 +31,25 @@ export const encodeCursor = (time: Date, key: string | number) =>
Buffer.from(JSON.stringify({ time: time.toISOString(), key: String(key) })).toString('base64url');
export const endOfDay = (d: string) => new Date(+toBusinessDate(d) + 86400000);
// Use record business time, not creation time or the obsolete manual marker.
export async function accountActivityIds(
db: Reader,
userId: string,
revealed: boolean,
day: string,
) {
const rows = await db.position.findMany({
where: {
userId,
kind: 'account',
...(revealed ? {} : { hidden: false }),
revisions: { some: { effectiveDate: { gte: toBusinessDate(day), lt: endOfDay(day) } } },
},
select: { id: true },
});
return new Set(rows.map((p) => p.id));
}
export async function transferPageIds(
db: Reader,
userId: string,
-7
View File
@@ -235,13 +235,6 @@ export const repaymentMarkInput = z
})
.strict();
export const bookedMarkInput = z
.object({
date: date.nullable(),
expectedDate: date.nullable(),
expectedUpdatedAt: z.iso.datetime().optional(),
})
.strict();
export const inclusionInput = z
.object({
included: z.boolean(),
+61 -33
View File
@@ -103,45 +103,21 @@ test('quick entries undo paired repayment and balance, persist monthly marks, pr
).status,
400,
);
const bookedPath = '/positions/' + cash + '/booked-mark',
day = today();
const booked = await call(a, bookedPath, 'PATCH', { date: day, expectedDate: null });
assert.equal(booked.status, 200);
assert.equal((await call(a, '/positions/' + cash)).data.lastBookedDate, day);
assert.equal(
(await call(a, bookedPath, 'PATCH', { date: null, expectedDate: null })).status,
409,
);
assert.equal(
(await call(b, bookedPath, 'PATCH', { date: null, expectedDate: day })).status,
404,
);
assert.equal(
(await call(a, bookedPath, 'PATCH', { date: '2099-01-01', expectedDate: day })).status,
400,
);
const day = today();
// The obsolete marker remains in ZIP v9, but cannot be written through the retired route.
await db.position.update({ where: { id: cash }, data: { lastBookedDate: day } });
assert.equal(
(
await call(a, bookedPath, 'PATCH', {
date: null,
expectedDate: day,
expectedUpdatedAt: booked.data.updatedAt,
})
).status,
200,
);
assert.equal(
(await call(a, bookedPath, 'PATCH', { date: day, expectedDate: null })).status,
200,
);
assert.equal(
(
await call(a, '/positions/' + coin.id + '/booked-mark', 'PATCH', {
await call(a, '/positions/' + cash + '/booked-mark', 'PATCH', {
date: day,
expectedDate: null,
})
).status,
400,
404,
);
assert.equal(
(await call(a, '/positions?kind=account')).data.find((p: any) => p.id === cash).activityDate,
null,
);
const revision = await call(a, '/positions/' + cash + '/revisions', 'POST', {
amount: '1050.87654321',
@@ -301,6 +277,58 @@ test('quick entries undo paired repayment and balance, persist monthly marks, pr
).status,
409,
);
// Derive daily status from surviving effective-time records; cover edits, deletes and both transfer sides.
const dailySource = await create('daily source', 'account', 'asset', 'bank', '100');
const dailyTarget = await create('daily target', 'account', 'asset', 'bank', '0');
const activity = async (id: string) =>
(await call(a, '/positions?kind=account')).data.find((p: any) => p.id === id).activityDate;
const daily = await call(a, '/positions/' + dailySource + '/revisions', 'POST', {
amount: '110',
date: day + 'T00:00',
});
assert.equal(daily.status, 201);
assert.equal(await activity(dailySource), day);
const yesterday = new Date(+new Date(day) - 86400000).toISOString().slice(0, 10);
assert.equal(
(
await call(a, '/positions/' + dailySource + '/revisions/' + daily.data.id, 'PUT', {
amount: '110',
date: yesterday + 'T23:59',
})
).status,
200,
);
assert.equal(await activity(dailySource), null);
assert.equal(
(
await call(a, '/positions/' + dailySource + '/revisions/' + daily.data.id, 'PUT', {
amount: '110',
date: day + 'T00:00',
})
).status,
200,
);
assert.equal(await activity(dailySource), day);
assert.equal(
(await call(a, '/positions/' + dailySource + '/revisions/' + daily.data.id, 'DELETE')).status,
200,
);
assert.equal(await activity(dailySource), null);
const dailyTransfer = await call(a, '/transfers', 'POST', {
sourceId: dailySource,
targetId: dailyTarget,
amount: '5',
received: '5',
fee: '0',
date: day + 'T00:01',
requestId: randomUUID(),
});
assert.equal(dailyTransfer.status, 201);
assert.equal(await activity(dailySource), day);
assert.equal(await activity(dailyTarget), day);
assert.equal((await call(a, '/transfers/' + dailyTransfer.data.id, 'DELETE')).status, 200);
assert.equal(await activity(dailySource), null);
assert.equal(await activity(dailyTarget), null);
const backup = await call(a, '/backup');
assert.equal(backup.status, 200);
assert.equal(backup.data.positions.find((p: any) => p.id === card).lastRepaymentMonth, old);