feat: improve quick bookkeeping and asset inclusion

Add commemorative assets, session undo, paired repayment, month/day markers and filters. Compact account cards, move transfer entry, persist backup metadata, and update Chinese documentation.
This commit is contained in:
陈煜 committed 2026-10-04 16:01:24 +08:00
1 parent ad6466cb6b
commit 096f1577c2
22 files changed
+1464 -72

No files matched your search

+26 -10
View File
@@ -31,7 +31,7 @@ pnpm db:status
pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的文件
```
当前功能:注册登录和退出、资产/负债账户、独立资产、独立债务和关联、分钟余额历史与更正、隐藏项目密码核验、本位币和自动日汇率、净资产趋势和变化归因、分文件 ZIP 备份与事务追加恢复、各菜单项显示开关、统一备注显示开关及无操作退出设置、安全清空本账号数据。
当前功能:注册登录、资产与负债账户、独立资产及债务、账户分组、快速记账与撤回、双边还款及转账、月份还款和当天记账标记、单项统计开关、余额历史管理、收支日历、按需执行的定时计划、隐藏资产、净资产趋势、多币种换算、贵金属按克估价、ZIP v9 备份恢复,以及 OAuth/MCP 助手接入。
金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额;已有错误记录可单独更正,转账及其之前的历史需通过新增余额调整修正,避免破坏双方一致性。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。
@@ -43,17 +43,17 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的
备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates、icons、transfers 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;仅接受当前 ZIP v9,所有 JSON 备份和旧 ZIP 均不支持。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。
内网穿透临时测试:本地 `apps/api/.env` 可设置 `WEB_ORIGIN=*` 并重启 API,允许来自任意 HTTP/HTTPS 站点的浏览器写入请求,仍需登录身份并校验数据归属。缺失或无效 Origin 仍拒绝;`NODE_ENV=production` 禁止此通配符。测试结束后恢复准确的站点地址。
网络配置集中在 `apps/api/.env`,API 与 Vite 开发服务共同读取;[配置模板](apps/api/.env.example) 逐项用中文说明作用。当前本地配置支持 HTTP、所有监听网卡及有效 HTTP/HTTPS 来源;登录、权限与数据归属校验仍生效。上线需按域名收紧 Host/Origin、关闭开放开关、启用 HTTPS 与 Secure Cookie。显式配置优先于 NODE_ENV,仅改成 production 不会覆盖已设置的开放开关。修改后重启 API 和网页开发服务,详见 [网络配置](docs/network-settings.md)。
账户图标:新增或编辑账户时选择可复用图标;设置页面提供图标库及中文名称搜索。直接上传默认私有,仅当前用户能检索、读取和使用;勾选共享并明确确认公开后,所有登录用户均可搜索复用,名称必须包含中文。支持静态 PNG/JPG/WebP,单张最大 2 MB,转为最长边 256 像素的 PNG 并去除图片元数据。同一用户相同图片和可见范围会复用现有图标。账户图标通过外键关联,不复制图片。
预置银行、支付平台与交易所等共 36 个图标,资源及来源清单在 `apps/api/assets/icons`;银行来自公开银行标识库,支付平台来自官方网站资源及 Simple Icons。图标版权与商标归相应品牌所有,用于识别账户,不代表品牌合作或授权。运行 `pnpm --filter @worthpath/api icons:seed` 初始化共享库或更新固定 ID 的内置透明图标,不修改用户上传图标或财务数据。可离线使用已提交的 PNG,无需访问外部图标网站。
ZIP 格式 v5 增加 transfers.json(转账双方、金额、手续费及配对历史),包含完整转账恢复关系。icons.json(图标名称、图片、内容校验值),包含自己的全部图标及账户引用的共享图标。导入会重建关联并将图标恢复为私有,相同图片复用,避免自动公开;旧 v3/v4 ZIP 和旧 JSON 仍可导入。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。
当前 ZIP v9 的 transfers.json 保存转账双方、金额、手续费及配对历史,恢复时重建关系;icons.json 保存图标名称、图片和内容校验值。恢复图标为私有,相同图片复用,避免自动公开;不支持旧 ZIP 或单文件 JSON。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。
## 账户转账与显示设置
账户页面和账户详情提供“账户间转账”,详情自动选择当前账户。双方余额和历史在同一数据库事务中更新;正手续费额外扣除,负手续费表示优惠(绝对值不超过本金),同币种到账金额等于转出金额,跨币种填写实际到账金额。转账不调用银行或支付平台,不执行真实资金划转。重复提交使用请求 ID 防止重复记账,并发写冲突有限重试。转账时间不能早于双方最新余额;后续余额调整不能插入已有配对操作之前。
账户页面和账户详情提供“转账”,详情自动选择当前账户。双方余额和历史在同一数据库事务中更新;正手续费额外扣除,负手续费表示优惠(绝对值不超过本金),同币种到账金额等于转出金额,跨币种填写实际到账金额。转账不调用银行或支付平台,不执行真实资金划转。重复提交使用请求 ID 防止重复记账,并发写冲突有限重试。转账时间不能早于双方最新余额;后续余额调整不能插入已有配对操作之前。
债务分为借入(应付负债)和借出(应收资产),支持借入到账、借出付款、收回应收和偿还应付。账户与债务在同一事务中记账并自动关联;双方详情和往来记录可互相导航。已有债务可录入剩余余额,新发生借贷可先建零余额债务再使用联动操作。当前 ZIP v9 保留操作类型,不保留任何旧备份兼容。
@@ -83,11 +83,28 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json
“定时计划”支持支出及转账,打开账户页时按需执行,每批最多 20 项;仅在本应用内记账。新增“收支日历”默认今天,月历与当日明细在同页上下展示,按账户余额变化估算,排除内部转账、借贷本金及初始余额。
本次需要新增数据库迁移并运行图库初始化,备份导出升级至 ZIP v7。操作步骤、31 个内置图标与验证边界见 [更新说明](docs/update-2026-10-02.md)。
2026-10-02 历史更新的迁移和验收见 [更新说明](docs/update-2026-10-02.md);该文档中的 ZIP v7 为当时版本,当前仅支持 ZIP v9。
2026-10-03 设置与交互更新:支持 1 小时至 30 天登录有效期、隐藏项目密码开关、自选总览卡片、计划编辑弹窗、右上角操作提示及账户卡片快速转账。详见 [更新说明](docs/update-settings-interaction-2026-10-03.md)。
## 快速记账与独立资产
独立资产支持房产、车辆、贵金属、纪念币和纪念钞。普通独立资产手动录入估值;贵金属按克数自动估价,可选填每克买入成本。独立资产页可开启“快速设置计入总资产”,点击卡片切换统计状态;独立资产总开关仍以个人设置为准。
账户页开启“快速记账”后使用紧凑卡片直接更新余额;转账按钮紧邻模式入口。右侧显示本次保存成功的余额、转账、还款及标记,支持逐项撤回;窄屏记录面板显示在账户列表下方。列表为当前页面会话,刷新或重新进入后清空,已保存账目仍在变化记录中。
欠款账户弹窗提供“还款”,通过付款账户与欠款账户双边记账更新余额。可手动标记“本月已还款”和“当天已记账”,并筛选未还款或隐藏当天已记账的账户。数据库保留最后完成月份/日期,跨月、跨日只按当前 UTC+8 时间判断是否显示,无需定时删除;标记不会改变金额。
账户、独立资产和债务可在详情删除。存在配对往来或定时计划时需先处理依赖,避免破坏另一账户余额。详见 [快速记账更新与验收](docs/update-quick-entry-2026-10-04.md) 和 [项目删除说明](docs/update-position-deletion-2026-10-04.md)。
## 连接 Agent / MCP
设置中的“连接助手”提供可复制的接入提示词、能力说明及权限选择。OAuth 授权和草稿确认使用独立页面;可选择 1、3、7、30 天、1 年或永久授权,并分别控制读取、修改隐藏账户。普通权限分为只读、草稿修改、直接写入,草稿需本人在网页审阅确认后才生效。
密码修改、清空数据、备份恢复、共享图标发布、汇率及贵金属报价修改在网站完成,MCP 不提供这些敏感操作。远程接入必须配置客户端可访问的 MCP_PUBLIC_URL 与 MCP_WEB_URL;localhost 仅代表客户端本机。详见 [连接与授权更新](docs/update-agent-accounts-2026-10-04.md)。
## git仓库目录
```shell
git remote add github https://github.com/chyuovo/WorthPath.git
git remote add wyh https://git.mashiroart.xyz/chyuovo/WorthPath.git
@@ -98,7 +115,6 @@ git config remote.pushDefault github
'github','gitee','wyh' | ForEach-Object { git push $_ main }
```
## 文档维护
文档维护约定:`docs` 目录仅保存文字文档和结构化验收数据,不保存图片。界面验收结果以文字记录,Markdown 不引用已删除的截图;应用使用的图标素材仍保存在 `apps/api/assets/icons`。
@@ -107,9 +123,9 @@ git config remote.pushDefault github
### 运行时第三方接口
| 服务 | 实际请求地址 | 用途与更新方式 | 密钥与发送数据 | 失败处理 |
| --- | --- | --- | --- | --- |
| [Frankfurter](https://frankfurter.dev/) | [USD 固定币种日汇率](https://api.frankfurter.dev/v2/rates?base=USD&quotes=CNY,HKD,EUR,GBP,JPY,AUD,CAD,CHF,SGD) | 外币换算;同时将贵金属美元报价换算为持仓原币。每小时检查、每日尝试一次,也支持手动刷新。 | 无需密钥;仅发送固定 USD 和九种公开币种,不发送用户身份、账户、金额或持仓重量。 | 12 秒超时;保留原币、已有汇率和估值,显示错误状态。 |
| [Gold API](https://gold-api.com/docs) | [黄金 XAU](https://api.gold-api.com/price/XAU)、[白银 XAG](https://api.gold-api.com/price/XAG) | 美元/金衡盎司参考价;以 31.1034768 克/金衡盎司换算为每克价格。已配置贵金属每日尝试更新,可手动刷新或录价。 | 无需密钥;请求固定 XAU/XAG 品种,不发送个人数据和持仓。 | 12 秒超时;格式、时间或汇率异常时保留之前的报价与估值;同日手动价格优先。 |
| 服务 | 实际请求地址 | 用途与更新方式 | 密钥与发送数据 | 失败处理 |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| [Frankfurter](https://frankfurter.dev/) | [USD 固定币种日汇率](https://api.frankfurter.dev/v2/rates?base=USD&quotes=CNY,HKD,EUR,GBP,JPY,AUD,CAD,CHF,SGD) | 外币换算;同时将贵金属美元报价换算为持仓原币。每小时检查、每日尝试一次,也支持手动刷新。 | 无需密钥;仅发送固定 USD 和九种公开币种,不发送用户身份、账户、金额或持仓重量。 | 12 秒超时;保留原币、已有汇率和估值,显示错误状态。 |
| [Gold API](https://gold-api.com/docs) | [黄金 XAU](https://api.gold-api.com/price/XAU)、[白银 XAG](https://api.gold-api.com/price/XAG) | 美元/金衡盎司参考价;以 31.1034768 克/金衡盎司换算为每克价格。已配置贵金属每日尝试更新,可在网站手动刷新。 | 无需密钥;请求固定 XAU/XAG 品种,不发送个人数据和持仓。 | 12 秒超时;格式、时间或汇率异常时保留之前的报价与估值;已有历史导入报价保留。 |
上述接口由后端访问,浏览器业务请求使用本站 `/api`,第三方不会收到本应用登录 Cookie。Gold API 返回的参考价只用于持仓估值,不包含饰品工费、买卖价差或回收折价。外币价格使用最新可用公共日汇率,休市时两类报价时间可能不同;报价日期会显示在界面并随备份保存。自动估价为可选项,开启后新增估值历史,不改写历史记录。
+2 -2
View File
@@ -5,11 +5,11 @@
"dev": "node scripts/dev.cjs",
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts",
"db:generate": "prisma generate",
"db:migrate": "node scripts/database.cjs deploy",
"db:status": "node scripts/database.cjs status",
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts test/account-deletion-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/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts test/account-deletion-integration.test.ts test/quick-entry-integration.test.ts",
"test:performance": "tsx scripts/performance.ts after",
"test:mcp": "tsx --test --test-concurrency=1 test/mcp.test.ts test/oauth-duration.test.ts",
"mcp:probe": "tsx scripts/mcp-probe.ts",
@@ -0,0 +1 @@
ALTER TABLE `Position` ADD COLUMN `lastRepaymentMonth` CHAR(7) NULL COMMENT '最后手动标记已完成还款的业务月份,UTC+8 YYYY-MM;空表示未标记';
@@ -0,0 +1 @@
ALTER TABLE `Position` ADD COLUMN `lastBookedDate` CHAR(10) NULL COMMENT '最后手动标记已完成记账的业务日期,UTC+8 YYYY-MM-DD;空表示未标记';
+4
View File
@@ -218,6 +218,10 @@ model Position {
hidden Boolean @default(false)
/// 是否参与资产负债总额及净资产轨迹
included Boolean @default(true)
/// 最后手动标记已完成还款的业务月份,UTC+8 YYYY-MM;空表示未标记
lastRepaymentMonth String? @db.Char(7)
/// 最后手动标记已完成记账的业务日期,UTC+8 YYYY-MM-DD;空表示未标记
lastBookedDate String? @db.Char(10)
/// 贵金属品种:gold 黄金或 silver 白银
metalType String? @db.VarChar(12)
/// 贵金属总重量,单位克
+11
View File
@@ -1,3 +1,4 @@
import { repaymentMonth, date } from './validation';
import { BACKUP_ZIP_VERSION } from './backup-format';
import { Injectable } from '@nestjs/common';
import { metalConfig, metalPriceInput, storedMetalPurity } from './metals';
@@ -63,6 +64,8 @@ const record = positionMeta
notes: z.string().max(2000),
archived: z.boolean(),
hidden: z.boolean(),
lastRepaymentMonth: repaymentMonth.optional(),
lastBookedDate: date.nullable().optional(),
metalType: z.enum(['gold', 'silver']).nullable(),
metalGrams: amount.nullable(),
metalCostPerGram: metalConfig.shape.metalCostPerGram.nonoptional(),
@@ -172,6 +175,10 @@ export function validateBackup(raw: unknown) {
throw new BadRequestException('图标关联无效');
const revisionIds = new Set<string>();
for (const p of b.positions) {
if (p.lastBookedDate && p.kind !== 'account')
throw new BadRequestException('仅账户支持当天记账标记');
if (p.lastRepaymentMonth && (p.kind !== 'account' || p.side !== 'liability'))
throw new BadRequestException('还款月份仅适用于欠款账户');
if (p.metalType || p.metalGrams || p.autoValuation || p.metalCostPerGram != null) {
if (p.kind !== 'asset' || p.category !== 'gold')
throw new BadRequestException('贵金属估价关联无效');
@@ -393,6 +400,8 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
archived: p.archived,
hidden: p.hidden,
included: p.included,
lastRepaymentMonth: p.lastRepaymentMonth,
lastBookedDate: p.lastBookedDate,
metalType: p.metalType,
metalGrams: p.metalGrams?.toString() ?? null,
metalCostPerGram: p.metalCostPerGram?.toString() ?? null,
@@ -700,6 +709,8 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
archived: p.archived,
hidden: p.hidden,
included: p.included,
lastRepaymentMonth: p.lastRepaymentMonth ?? null,
lastBookedDate: p.lastBookedDate ?? null,
metalType: p.metalType,
metalGrams: p.metalGrams,
metalCostPerGram: p.metalCostPerGram,
+6
View File
@@ -7,6 +7,9 @@ import {
credentials,
credentialChange,
positionInput,
repaymentMarkInput,
bookedMarkInput,
inclusionInput,
positionMeta,
revisionInput,
transferInput,
@@ -35,6 +38,9 @@ export function setupOpenApi(app: INestApplication) {
'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(),
'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,
'PUT /api/positions/{id}/links': z
+86
View File
@@ -18,6 +18,9 @@ import { Database } from './database';
import { UserRequest } from './auth';
import {
positionInput,
repaymentMarkInput,
bookedMarkInput,
inclusionInput,
positionMeta,
revisionInput,
today,
@@ -158,6 +161,68 @@ 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);
return this.db.serial(async (tx) => {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
);
const p = await tx.position.findFirst({
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 (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)
)
throw new ConflictException('标记已变化,请刷新后重试');
const updated = await tx.position.update({
where: { id },
data: 'date' in v ? { lastBookedDate: v.date } : { included: v.included },
});
return {
lastBookedDate: updated.lastBookedDate,
included: updated.included,
updatedAt: updated.updatedAt,
};
});
}
async repaymentMark(r: UserRequest, id: string, raw: unknown) {
const input = repaymentMarkInput.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`,
);
const p = await tx.position.findFirst({
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
if (!p) throw new NotFoundException('账户不存在');
if (p.kind !== 'account' || p.side !== 'liability')
throw new BadRequestException('仅欠款账户支持还款月份标记');
if (p.archived) throw new ConflictException('请先恢复归档账户');
if (
p.lastRepaymentMonth !== input.expectedMonth ||
(input.expectedUpdatedAt && p.updatedAt.toISOString() !== input.expectedUpdatedAt)
)
throw new ConflictException('还款标记已变化,请刷新后重试');
const updated = await tx.position.update({
where: { id },
data: { lastRepaymentMonth: input.month },
});
return {
lastRepaymentMonth: input.month,
previousMonth: p.lastRepaymentMonth,
updatedAt: updated.updatedAt.toISOString(),
};
});
}
async deletion(r: UserRequest, id: string) {
const p = await this.own(r.userId, id, r.revealed);
const [historyCount, movementCount, scheduleCount, linkCount] = await Promise.all([
@@ -408,6 +473,27 @@ 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');
}
@Patch('positions/:id/repayment-mark') async repaymentMark(
@Req() r: UserRequest,
@Param('id') id: string,
@Body() b: unknown,
) {
return this.service.repaymentMark(r, id, b);
}
@Get('positions/:id/deletion') async deletion(@Req() r: UserRequest, @Param('id') id: string) {
return this.service.deletion(r, id);
}
+28
View File
@@ -217,3 +217,31 @@ export const settingsInput = z
})
.strict()
.refine((v) => Object.keys(v).length > 0);
export const repaymentMonth = z
.string()
.regex(/^(19|[2-9]\d)\d{2}-(0[1-9]|1[0-2])$/)
.refine((v) => v <= today().slice(0, 7), '不能标记未来还款月份')
.nullable();
export const repaymentMarkInput = z
.object({
month: repaymentMonth,
expectedMonth: repaymentMonth,
expectedUpdatedAt: z.iso.datetime().optional(),
})
.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(),
expectedIncluded: z.boolean(),
expectedUpdatedAt: z.iso.datetime().optional(),
})
.strict();
@@ -0,0 +1,269 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import { today } from '../src/validation';
import { readBackupZip } from '../src/zip';
import { fixtureFetch } from './backup-fixture';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
test('quick entries undo paired repayment and balance, persist monthly marks, protect privacy and survive ZIP restore', async () => {
const db = new PrismaClient(),
users: string[] = [];
async function user() {
const u = await db.user.create({
data: { username: 'quick_' + randomUUID(), passwordHash: 'unused', idleMinutes: 0 },
});
users.push(u.id);
const token = randomBytes(32).toString('hex'),
sid = createHash('sha256').update(token).digest('hex');
await db.session.create({
data: { id: sid, userId: u.id, expiresAt: new Date(Date.now() + 3600000) },
});
return { id: u.id, cookie: 'wp_session=' + token, sid };
}
async function call(u: any, path: string, method = 'GET', data?: unknown) {
const r = await fixtureFetch(base + path, {
method,
headers: {
Cookie: u.cookie,
Origin: origin,
...(data ? { 'Content-Type': 'application/json' } : {}),
},
body: data ? JSON.stringify(data) : undefined,
});
return {
status: r.status,
data: r.headers.get('content-type')?.includes('application/zip')
? await readBackupZip(Buffer.from(await r.arrayBuffer()))
: await r.json(),
};
}
try {
const a = await user(),
b = await user();
const create = async (
name: string,
kind: string,
side: string,
category: string,
amount: string,
hidden = false,
) => {
const r = await call(a, '/positions', 'POST', {
name,
kind,
side,
category,
amount,
hidden,
currency: 'CNY',
date: '2026-09-01T10:00',
});
assert.equal(r.status, 201);
return r.data.id as string;
};
const cash = await create('付款账户', 'account', 'asset', 'bank', '1000');
const card = await create('欠款账户', 'account', 'liability', 'credit_card', '200');
for (const category of ['commemorative_coin', 'commemorative_note']) {
const id = await create(category, 'asset', 'asset', category, '50');
assert.equal((await call(a, '/positions/' + id)).data.category, category);
}
const coin = (await db.position.findFirst({
where: { userId: a.id, category: 'commemorative_coin' },
}))!;
const inclusionPath = '/positions/' + coin.id + '/inclusion';
assert.equal(
(await call(b, inclusionPath, 'PATCH', { included: false, expectedIncluded: true })).status,
404,
);
assert.equal(
(
await call(a, inclusionPath, 'PATCH', {
included: false,
expectedIncluded: true,
expectedUpdatedAt: coin.updatedAt.toISOString(),
})
).status,
200,
);
assert.equal((await call(a, '/positions/' + coin.id)).data.included, false);
assert.equal(
(await call(a, inclusionPath, 'PATCH', { included: true, expectedIncluded: true })).status,
409,
);
assert.equal(
(
await call(a, '/positions/' + cash + '/inclusion', 'PATCH', {
included: false,
expectedIncluded: true,
})
).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,
);
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', {
date: day,
expectedDate: null,
})
).status,
400,
);
const revision = await call(a, '/positions/' + cash + '/revisions', 'POST', {
amount: '1050.87654321',
date: '2026-09-02T10:00',
});
assert.equal(revision.status, 201);
assert.equal(
(await call(a, '/positions/' + cash + '/revisions/' + revision.data.id, 'DELETE')).status,
200,
);
assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000');
const payment = await call(a, '/transfers', 'POST', {
sourceId: cash,
targetId: card,
amount: '30',
received: '30',
fee: '0',
date: '2026-09-03T10:00',
requestId: randomUUID(),
});
assert.equal(payment.status, 201);
assert.equal((await call(a, '/positions/' + cash)).data.amount, '970');
assert.equal((await call(a, '/positions/' + card)).data.amount, '170');
assert.equal((await call(a, '/transfers/' + payment.data.id, 'DELETE')).status, 200);
assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000');
assert.equal((await call(a, '/positions/' + card)).data.amount, '200');
const month = today().slice(0, 7),
path = '/positions/' + card + '/repayment-mark';
assert.equal((await call(b, path, 'PATCH', { month, expectedMonth: null })).status, 404);
assert.equal(
(
await call(a, '/positions/' + cash + '/repayment-mark', 'PATCH', {
month,
expectedMonth: null,
})
).status,
400,
);
assert.equal(
(await call(a, path, 'PATCH', { month: '2099-01', expectedMonth: null })).status,
400,
);
const before = await call(a, '/positions/' + card);
const marked = await call(a, path, 'PATCH', {
month,
expectedMonth: null,
expectedUpdatedAt: before.data.updatedAt,
});
assert.equal(marked.status, 200);
assert.equal((await call(a, '/positions/' + card)).data.lastRepaymentMonth, month);
assert.equal((await call(a, '/positions/' + card)).data.amount, '200');
assert.equal((await call(a, path, 'PATCH', { month: null, expectedMonth: null })).status, 409);
const restored = await call(a, path, 'PATCH', {
month: null,
expectedMonth: month,
expectedUpdatedAt: marked.data.updatedAt,
});
assert.equal(restored.status, 200);
assert.equal((await call(a, '/positions/' + card)).data.lastRepaymentMonth, null);
await call(a, path, 'PATCH', { month, expectedMonth: null });
assert.equal(
(
await call(a, path, 'PATCH', {
month: null,
expectedMonth: month,
expectedUpdatedAt: marked.data.updatedAt,
})
).status,
409,
);
const old = '2026-09';
const changed = await call(a, path, 'PATCH', { month: old, expectedMonth: month });
assert.equal(changed.status, 200);
assert.equal((await call(a, '/positions/' + card)).data.lastRepaymentMonth, old);
const hidden = await create('隐藏欠款', 'account', 'liability', 'loan', '5', true);
assert.equal(
(
await call(a, '/positions/' + hidden + '/repayment-mark', 'PATCH', {
month,
expectedMonth: null,
})
).status,
404,
);
await db.session.update({
where: { id: a.sid },
data: { revealUntil: new Date(Date.now() + 600000) },
});
assert.equal(
(
await call(a, '/positions/' + hidden + '/repayment-mark', 'PATCH', {
month,
expectedMonth: null,
})
).status,
200,
);
await db.position.update({ where: { id: hidden }, data: { archived: true } });
assert.equal(
(
await call(a, '/positions/' + hidden + '/repayment-mark', 'PATCH', {
month: null,
expectedMonth: month,
})
).status,
409,
);
const backup = await call(a, '/backup');
assert.equal(backup.status, 200);
assert.equal(backup.data.positions.find((p: any) => p.id === card).lastRepaymentMonth, old);
const imported = await call(b, '/backup/restore-fixture', 'POST', {
confirmed: true,
backup: backup.data,
});
assert.equal(imported.status, 201);
const rows = await db.position.findMany({ where: { userId: b.id } });
assert.equal(rows.find((p) => p.name === '欠款账户')?.lastRepaymentMonth, old);
assert.equal(rows.find((p) => p.name === '隐藏欠款')?.lastRepaymentMonth, month);
assert.equal(rows.find((p) => p.name === '付款账户')?.lastBookedDate, day);
assert.equal(rows.find((p) => p.category === 'commemorative_coin')?.included, false);
} finally {
await db.user.deleteMany({ where: { id: { in: users } } });
await db.$disconnect();
}
});
+457 -53
View File
@@ -59,6 +59,15 @@ import { PositionDeletion, deletionLabel } from './PositionDeletion';
import { IconPicker } from './IconPicker';
import { MetalPanel } from './MetalPanel';
import { QuickTransfer } from './QuickTransfer';
import {
QuickJournal,
QuickRepayment,
isPaidForMonth,
isBookedToday,
quickAccountVisible,
canQuickRepay,
type QuickEntry,
} from './QuickJournal';
import { TransferForm } from './TransferForm';
import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm';
import type { Transfer } from './api';
@@ -86,6 +95,8 @@ const categories: Record<string, [string, string][]> = {
['property', '房产'],
['vehicle', '车辆'],
['gold', '贵金属'],
['commemorative_coin', '纪念币'],
['commemorative_note', '纪念钞'],
['other', '其他资产'],
],
debt: [
@@ -248,6 +259,7 @@ export default function App() {
h?: History;
transfer?: Transfer;
operation?: DebtOperation;
quick?: boolean;
groups?: string[];
} | null>(null),
[busy, setBusy] = useState(false),
@@ -263,6 +275,36 @@ export default function App() {
sourceId?: string;
targetId?: string;
} | null>(null);
const [quickEntries, setQuickEntries] = useState<QuickEntry[]>([]);
const [quickMarkMode, setQuickMarkMode] = useState(false);
const [quickBookedMode, setQuickBookedMode] = useState(false);
const [hideBooked, setHideBooked] = useState(false);
const [unpaidOnly, setUnpaidOnly] = useState(false);
const [assetQuickMode, setAssetQuickMode] = useState(false);
const [businessDay, setBusinessDay] = useState(today());
const [repaymentMonth, setRepaymentMonth] = useState(today().slice(0, 7));
const quickGeneration = useRef(0);
useEffect(() => {
const updateDay = () => {
setBusinessDay(today());
setRepaymentMonth(today().slice(0, 7));
};
const timer = setInterval(updateDay, 60000);
window.addEventListener('focus', updateDay);
return () => {
clearInterval(timer);
window.removeEventListener('focus', updateDay);
};
}, []);
useEffect(() => {
quickGeneration.current++;
setQuickEntries([]);
setQuickMarkMode(false);
setQuickBookedMode(false);
setHideBooked(false);
setUnpaidOnly(false);
setAssetQuickMode(false);
}, [user?.username, user?.revealed]);
const [quickMode, setQuickMode] = useState(false),
[accountGroup, setAccountGroup] = useState<string | null>(null);
useEffect(() => {
@@ -278,6 +320,166 @@ export default function App() {
: current,
);
}, [positions]);
function toggleQuickMode() {
quickGeneration.current++;
if (!quickMode) setQuickEntries([]);
setQuickMode(!quickMode);
setQuickMarkMode(false);
setQuickBookedMode(false);
setHideBooked(false);
setUnpaidOnly(false);
setQuickTransfer(null);
}
function rememberQuick(entry: QuickEntry, generation: number, session: number) {
if (generation === quickGeneration.current && session === sessionGeneration.current)
setQuickEntries((rows) => [entry, ...rows]);
}
async function saveQuickBalance(
position: Position,
data: Record<string, unknown>,
track: boolean,
) {
const generation = quickGeneration.current,
session = sessionGeneration.current;
const result = await api<{ id: string }>(
'/positions/' + position.id + '/revisions',
'POST',
data,
);
if (track)
rememberQuick(
{
key: 'balance:' + result.id,
kind: 'balance',
id: result.id,
positionId: position.id,
title: position.name,
currency: position.currency,
before: positionAmount(position),
after: positionAmount(position, String(data.amount)),
date: String(data.date),
},
generation,
session,
);
return result;
}
async function saveQuickMovement(data: unknown, label: string) {
const value = data as Record<string, string>,
generation = quickGeneration.current,
session = sessionGeneration.current;
const result = await api<{ id: string }>('/transfers', 'POST', data);
const source = positions.find((p) => p.id === value.sourceId),
target = positions.find((p) => p.id === value.targetId);
rememberQuick(
{
key: 'transfer:' + result.id,
kind: 'transfer',
id: result.id,
positionId: value.sourceId,
title: label + ' · ' + (source?.name || '') + ' → ' + (target?.name || ''),
currency: source?.currency || '',
amount: value.amount,
received: value.received,
targetCurrency: target?.currency,
date: value.date,
},
generation,
session,
);
return result;
}
async function markQuickRepayment(position: Position) {
const month = today().slice(0, 7),
previousMonth = position.lastRepaymentMonth ?? null;
const nextMonth = isPaidForMonth(position, month) ? null : month;
const generation = quickGeneration.current,
session = sessionGeneration.current;
const result = await api<{ updatedAt: string }>(
'/positions/' + position.id + '/repayment-mark',
'PATCH',
{ month: nextMonth, expectedMonth: previousMonth, expectedUpdatedAt: position.updatedAt },
);
rememberQuick(
{
key: 'mark:' + position.id + ':' + Date.now(),
kind: 'mark',
id: position.id,
positionId: position.id,
title: position.name,
currency: position.currency,
date: nowMinute(),
previousMonth,
month: nextMonth,
markUpdatedAt: result.updatedAt,
},
generation,
session,
);
return result;
}
async function markQuickBooked(position: Position) {
const previousDate = position.lastBookedDate ?? null,
day = today();
const nextDate = isBookedToday(position, day) ? null : day;
const generation = quickGeneration.current,
session = sessionGeneration.current;
const result = await api<{ updatedAt: string }>(
'/positions/' + position.id + '/booked-mark',
'PATCH',
{
date: nextDate,
expectedDate: previousDate,
expectedUpdatedAt: position.updatedAt,
},
);
rememberQuick(
{
key: 'booked:' + position.id + ':' + Date.now(),
kind: 'booked',
id: position.id,
positionId: position.id,
title: position.name,
currency: position.currency,
date: nowMinute(),
previousDate,
bookedDate: nextDate,
markUpdatedAt: result.updatedAt,
},
generation,
session,
);
return result;
}
async function toggleAssetInclusion(position: Position) {
return api('/positions/' + position.id + '/inclusion', 'PATCH', {
included: position.included === false,
expectedIncluded: position.included !== false,
expectedUpdatedAt: position.updatedAt,
});
}
function undoQuick(entry: QuickEntry) {
void act(async () => {
if (entry.kind === 'balance')
await api('/positions/' + entry.positionId + '/revisions/' + entry.id, 'DELETE');
else if (entry.kind === 'transfer') await api('/transfers/' + entry.id, 'DELETE');
else if (entry.kind === 'booked')
await api('/positions/' + entry.positionId + '/booked-mark', 'PATCH', {
date: entry.previousDate ?? null,
expectedDate: entry.bookedDate ?? null,
expectedUpdatedAt: entry.markUpdatedAt,
});
else
await api('/positions/' + entry.positionId + '/repayment-mark', 'PATCH', {
month: entry.previousMonth ?? null,
expectedMonth: entry.month ?? null,
expectedUpdatedAt: entry.markUpdatedAt,
});
setQuickEntries((rows) =>
rows.map((row) => (row.key === entry.key ? { ...row, undone: true } : row)),
);
}, tr('记录已撤回,相关余额已更新'));
}
function chooseQuickAccount(p: Position) {
if (busy || loading) return;
if (p.kind !== 'account' || p.archived) {
@@ -335,6 +537,9 @@ export default function App() {
const activityAt = useRef(0);
function clearAccount() {
setQuickMode(false);
quickGeneration.current++;
setQuickEntries([]);
setQuickMarkMode(false);
setAccountGroup('');
setClearStep(0);
setClearConfirmation(0);
@@ -987,7 +1192,11 @@ export default function App() {
</button>
</div>
</header>
<div className="content">
<div
className={
page === 'account' && quickMode && !selected ? 'content quick-content' : 'content'
}
>
<div className="privacy-toolbar">
<span>
{user.revealed
@@ -1030,12 +1239,15 @@ export default function App() {
</p>
</div>
<div className="actions">
{page === 'account' && !p && (
{page === 'account' && !p && !quickMode && (
<button className="secondary" onClick={() => setModal({ kind: 'transfer' })}>
{tr('转账')}
</button>
)}
{['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction}
{['overview', 'account', 'asset', 'debt'].includes(page) &&
!p &&
!(page === 'account' && quickMode) &&
newAction}
</div>
</div>
@@ -1458,17 +1670,90 @@ export default function App() {
</section>
</>
) : (
<section className="panel">
<section
className={page === 'account' && quickMode ? 'panel quick-account-panel' : 'panel'}
>
{page === 'account' && (
<>
<div className="actions account-tools">
<button
className={quickMode ? 'primary' : 'secondary'}
aria-pressed={quickMode}
onClick={() => setQuickMode(!quickMode)}
disabled={busy}
onClick={toggleQuickMode}
>
{tr(quickMode ? '退出快速记账' : '快速记账')}
</button>
{quickMode && (
<button
className={quickMarkMode ? 'primary' : 'secondary'}
disabled={busy}
aria-pressed={quickMarkMode}
onClick={() => {
setQuickMarkMode(!quickMarkMode);
setQuickBookedMode(false);
setQuickTransfer(null);
}}
>
{tr(quickMarkMode ? '结束还款标记' : '标记本月还款')}
</button>
)}
{quickMode && (
<>
<button
type="button"
className="secondary"
disabled={
busy ||
positions.filter((p) => p.kind === 'account' && !p.archived).length <
2
}
onClick={() => {
setQuickMarkMode(false);
setQuickBookedMode(false);
setHideBooked(false);
setUnpaidOnly(false);
setQuickTransfer({});
}}
>
{tr('转账')}
</button>
<button
className={quickBookedMode ? 'primary' : 'secondary'}
disabled={busy}
aria-pressed={quickBookedMode}
onClick={() => {
setQuickBookedMode(!quickBookedMode);
setQuickMarkMode(false);
setQuickTransfer(null);
}}
>
{tr(quickBookedMode ? '结束记账标记' : '标记当天已记账')}
</button>
<button
className="secondary"
aria-pressed={unpaidOnly}
disabled={busy}
onClick={() => {
setUnpaidOnly(!unpaidOnly);
setQuickTransfer(null);
}}
>
{tr(unpaidOnly ? '显示全部账户' : '仅看本月未还款')}
</button>
<button
className="secondary"
aria-pressed={hideBooked}
disabled={busy}
onClick={() => {
setHideBooked(!hideBooked);
setQuickTransfer(null);
}}
>
{tr(hideBooked ? '显示当天已记账' : '隐藏当天已记账')}
</button>
</>
)}
<select
aria-label={tr('账户分组')}
value={accountGroup === null ? 'all' : JSON.stringify(accountGroup)}
@@ -1487,6 +1772,7 @@ export default function App() {
</select>
<button
className="secondary"
hidden={quickMode}
disabled={busy || groups.length < 2}
onClick={() => setModal({ kind: 'group-order', groups: [...groups] })}
>
@@ -1496,18 +1782,58 @@ export default function App() {
{quickMode && (
<p className="muted">
{tr(
quickTransfer !== null
? '快速转账中:依次点击转出和转入账户卡片。'
: '点击账户卡片,直接填写当前余额。',
quickBookedMode
? '点击账户卡片,标记或取消当天已记账。'
: quickMarkMode
? '点击欠款账户卡片,标记或取消本月已还款。'
: quickTransfer !== null
? '快速转账中:依次点击转出和转入账户卡片。'
: '点击账户卡片,直接填写当前余额。',
)}
</p>
)}
</>
)}
{page === 'asset' && (
<>
<div className="actions account-tools">
<button
className={assetQuickMode ? 'primary' : 'secondary'}
disabled={busy}
aria-pressed={assetQuickMode}
onClick={() => setAssetQuickMode(!assetQuickMode)}
>
{tr(assetQuickMode ? '结束统计设置' : '快速设置计入总资产')}
</button>
</div>
{assetQuickMode && (
<p className="muted">{tr('点击资产卡片,切换是否计入总资产。')}</p>
)}
{assetQuickMode && user.includeIndependentAssets === false && (
<p className="muted">
{tr('独立资产总开关已关闭;单项设置已保存,开启总开关后生效。')}
</p>
)}
</>
)}
{page === 'account' &&
quickMode &&
positions.some((p) => p.kind === 'account') &&
!positions.some(
(p) =>
p.kind === 'account' &&
(accountGroup === null || (p.groupName || '') === accountGroup) &&
quickAccountVisible(p, repaymentMonth, businessDay, unpaidOnly, hideBooked),
) && (
<p className="muted" role="status">
{tr('当前筛选下没有账户,可切换筛选查看其他账户。')}
</p>
)}
{positions.some((p) => p.kind === page) ? (
<div
className={
'position-grid' +
(page === 'account' && quickMode ? ' quick-dense-grid' : '') +
(quickTransfer !== null && page === 'account' ? ' quick-transfer-select' : '')
}
>
@@ -1517,7 +1843,16 @@ export default function App() {
p.kind === page &&
(page !== 'account' ||
accountGroup === null ||
(p.groupName || '') === accountGroup),
(p.groupName || '') === accountGroup) &&
(page !== 'account' ||
!quickMode ||
quickAccountVisible(
p,
repaymentMonth,
businessDay,
unpaidOnly,
hideBooked,
)),
)
.sort((a, b) =>
page === 'account'
@@ -1547,14 +1882,29 @@ export default function App() {
? quickTransfer.sourceId === p.id || quickTransfer.targetId === p.id
: undefined
}
disabled={busy || (!!quickTransfer && p.archived)}
disabled={
busy ||
(!!quickTransfer && p.archived) ||
(quickMode &&
quickMarkMode &&
(p.side !== 'liability' || p.archived)) ||
(quickMode && quickBookedMode && p.archived)
}
key={p.id}
onClick={() =>
quickTransfer !== null && page === 'account' && quickMode
? chooseQuickAccount(p)
: page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p })
: setSelected(p.id)
page === 'asset' && assetQuickMode
? void act(() => toggleAssetInclusion(p), tr('统计设置已保存'))
: quickMode && quickBookedMode && page === 'account'
? void act(() => markQuickBooked(p), tr('记账标记已保存'))
: quickMode && quickMarkMode && page === 'account'
? p.kind === 'account' && p.side === 'liability' && !p.archived
? void act(() => markQuickRepayment(p), tr('还款标记已保存'))
: undefined
: quickTransfer !== null && page === 'account' && quickMode
? chooseQuickAccount(p)
: page === 'account' && quickMode && !p.archived
? setModal({ kind: 'revision', p, quick: true })
: setSelected(p.id)
}
>
<div>
@@ -1572,11 +1922,20 @@ export default function App() {
<ArrowUpRight size={18} />
</div>
<h3>{p.name}</h3>
{isPaidForMonth(p, repaymentMonth) && (
<span className="badge repayment-paid">{tr('本月已还款')}</span>
)}
{quickMode && page === 'account' && isBookedToday(p, businessDay) && (
<span className="badge repayment-paid">{tr('当天已记账')}</span>
)}
{assetQuickMode && page === 'asset' && p.included !== false && (
<span className="badge repayment-paid">{tr('计入总资产')}</span>
)}
{(p.included === false ||
(p.kind === 'asset' && user.includeIndependentAssets === false)) && (
<span className="badge">{tr('不计入总览')}</span>
)}
<p>
<p className="position-secondary">
{categoryLabel(p)} ·{' '}
{p.kind === 'debt'
? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)')
@@ -1599,7 +1958,10 @@ export default function App() {
: money(positionAmount(p), p.currency)}
{p.metalGrams && <small> · {p.metalGrams} g</small>}
</strong>
<small hidden={p.currency === user.baseCurrency}>
<small
className="position-secondary"
hidden={p.currency === user.baseCurrency}
>
{tr('折合')}{' '}
{p.converted === null
? tr(p.valuationAvailable === false ? '待估值' : '缺少汇率')
@@ -1619,21 +1981,8 @@ export default function App() {
</section>
))}
{page === 'account' && quickMode && !selected && (
<aside className="quick-transfer-widget" aria-label={tr('快速转账')}>
{quickTransfer === null ? (
<button
type="button"
className="primary quick-transfer-toggle"
disabled={
busy ||
loading ||
positions.filter((p) => p.kind === 'account' && !p.archived).length < 2
}
onClick={() => setQuickTransfer({})}
>
{tr('转账')}
</button>
) : (
<aside className="quick-session-panel panel" aria-label={tr('快速记账操作')}>
{quickTransfer !== null && (
<section className="quick-transfer-card">
<div className="panel-title">
<h2>{tr('快速转账')}</h2>
@@ -1672,7 +2021,7 @@ export default function App() {
changeTarget={() => setQuickTransfer({ sourceId: quickTransfer.sourceId })}
submit={(data) =>
void act(async () => {
const result = await api('/transfers', 'POST', data);
const result = await saveQuickMovement(data, tr('转账'));
setQuickTransfer(null);
return result;
}, tr('转账已完成'))
@@ -1682,6 +2031,7 @@ export default function App() {
)}
</section>
)}
<QuickJournal entries={quickEntries} busy={busy} undo={undoQuick} />
</aside>
)}
{page === 'history' && (
@@ -2605,21 +2955,23 @@ export default function App() {
? modal.p?.kind === 'debt'
? operationLabel(modal.p.side === 'asset' ? 'collect' : 'repay')
: tr('收款 / 还款')
: modal.kind === 'delete-position'
? '删除' + deletionLabel(modal.p!)
: modal.kind === 'transfer'
? tr('转账')
: modal.kind === 'reveal'
? tr('验证密码以显示隐藏资产')
: modal.kind === 'edit'
? tr('编辑项目')
: modal.kind === 'revision'
? tr('更新余额 / 估值')
: modal.kind === 'correction'
? tr('更正历史记录')
: modal.kind === 'links'
? tr('管理债务关联')
: tr('新增') + tr(labels[modal.kind])
: modal.kind === 'quick-repayment'
? tr('账户还款')
: modal.kind === 'delete-position'
? '删除' + deletionLabel(modal.p!)
: modal.kind === 'transfer'
? tr('转账')
: modal.kind === 'reveal'
? tr('验证密码以显示隐藏资产')
: modal.kind === 'edit'
? tr('编辑项目')
: modal.kind === 'revision'
? tr('更新余额 / 估值')
: modal.kind === 'correction'
? tr('更正历史记录')
: modal.kind === 'links'
? tr('管理债务关联')
: tr('新增') + tr(labels[modal.kind])
}
close={() => {
if (!busy) setModal(null);
@@ -2640,6 +2992,18 @@ export default function App() {
)
}
/>
) : modal.kind === 'quick-repayment' && modal.p ? (
<QuickRepayment
target={modal.p}
accounts={positions}
busy={busy}
submit={(data) =>
void act(
() => saveQuickMovement(data, tr('还款')),
tr('还款已保存,双方余额已更新'),
)
}
/>
) : ['transfer', 'debt-payment'].includes(modal.kind) ? (
movementLoading ? (
<p>{tr('正在刷新数据…')}</p>
@@ -2703,11 +3067,16 @@ export default function App() {
} else if (kind === 'revision' || kind === 'correction') {
void act(
() =>
api(
'/positions/' + mp!.id + '/revisions' + (modal.h ? '/' + modal.h.id : ''),
modal.h ? 'PUT' : 'POST',
{ ...v, amount: accountInputAmount(mp!, String(v.amount)) },
),
modal.h
? api('/positions/' + mp!.id + '/revisions/' + modal.h.id, 'PUT', {
...v,
amount: accountInputAmount(mp!, String(v.amount)),
})
: saveQuickBalance(
mp!,
{ ...v, amount: accountInputAmount(mp!, String(v.amount)) },
!!modal.quick,
),
tr('金额历史已保存,总览已重新计算'),
);
} else if (kind === 'edit') {
@@ -2943,6 +3312,41 @@ export default function App() {
{tr('负数表示欠款,正数表示溢缴存款;溢缴部分计入总资产。')}
</p>
)}
{modal.kind === 'revision' && modal.quick && modal.p?.side === 'liability' && (
<>
<div className="actions quick-repayment-actions">
{canQuickRepay(modal.p) && (
<button
type="button"
className="primary"
disabled={busy}
onClick={() =>
setModal({ kind: 'quick-repayment', p: modal.p, quick: true })
}
>
{tr('还款')}
</button>
)}
<button
type="button"
className="secondary"
disabled={busy}
onClick={() =>
void act(() => markQuickRepayment(modal.p!), tr('还款标记已保存'))
}
>
{tr(
isPaidForMonth(modal.p, repaymentMonth)
? '取消本月已还款'
: '标记本月已还款',
)}
</button>
</div>
<p className="muted">
{tr('还款会更新双方余额;月份标记只记录本月已处理,不改变金额。')}
</p>
</>
)}
{modal.kind === 'revision' && (
<>
<Field label={tr('变更原因')}>
+165
View File
@@ -0,0 +1,165 @@
import React, { useState } from 'react';
import { AccountPicker } from './AccountPicker';
import { QuickTransfer } from './QuickTransfer';
import { money, positionAmount, type Position } from './api';
import { t as tr } from './i18n';
export type QuickEntry = {
key: string;
kind: 'balance' | 'transfer' | 'mark' | 'booked';
id: string;
positionId: string;
title: string;
currency: string;
before?: string;
after?: string;
amount?: string;
received?: string;
targetCurrency?: string;
date: string;
previousMonth?: string | null;
month?: string | null;
markUpdatedAt?: string;
previousDate?: string | null;
bookedDate?: string | null;
undone?: boolean;
};
export const isPaidForMonth = (
position: Pick<Position, 'kind' | 'side' | 'lastRepaymentMonth'>,
month: string,
) =>
position.kind === 'account' &&
position.side === 'liability' &&
position.lastRepaymentMonth === month;
export const isBookedToday = (position: Pick<Position, 'kind' | 'lastBookedDate'>, day: string) =>
position.kind === 'account' && position.lastBookedDate === day;
export const quickAccountVisible = (
position: Position,
month: string,
day: string,
unpaidOnly: boolean,
hideBooked: boolean,
) =>
(!unpaidOnly ||
(position.side === 'liability' && !position.archived && !isPaidForMonth(position, month))) &&
(!hideBooked || !isBookedToday(position, day));
export const canQuickRepay = (position: Pick<Position, 'kind' | 'side' | 'amount' | 'archived'>) =>
position.kind === 'account' &&
position.side === 'liability' &&
!position.archived &&
!position.amount.startsWith('-') &&
!/^0(?:\.0+)?$/.test(position.amount);
export function QuickJournal({
entries,
busy,
undo,
}: {
entries: QuickEntry[];
busy: boolean;
undo: (entry: QuickEntry) => void;
}) {
return (
<section className="quick-journal" aria-label={tr('本次快速记账')}>
<h2>{tr('本次快速记账')}</h2>
<p className="muted">{tr('本次保存成功的记录,可逐项撤回。')}</p>
{!entries.length ? (
<p className="muted">{tr('还没有保存记录')}</p>
) : (
<ol>
{entries.map((entry) => (
<li key={entry.key} className={entry.undone ? 'undone' : ''}>
<strong>{entry.title}</strong>
{entry.kind === 'balance' && (
<p>
{money(entry.before || '0', entry.currency)} →{' '}
{money(entry.after || '0', entry.currency)}
</p>
)}
{entry.kind === 'transfer' && (
<p>
{money(entry.amount || '0', entry.currency)}
{entry.targetCurrency !== entry.currency && (
<> → {money(entry.received || '0', entry.targetCurrency || entry.currency)}</>
)}
</p>
)}
{entry.kind === 'mark' && (
<p>{tr(entry.month ? '已标记本月还款' : '已取消本月标记')}</p>
)}
{entry.kind === 'booked' && (
<p>{tr(entry.bookedDate ? '已标记当天记账' : '已取消当天标记')}</p>
)}
<small>{entry.date.replace('T', ' ')} · UTC+8</small>
{entry.undone ? (
<span className="badge">{tr('已撤回')}</span>
) : (
<button
type="button"
className="secondary"
disabled={busy}
onClick={() => undo(entry)}
>
{tr('撤回')}
</button>
)}
</li>
))}
</ol>
)}
</section>
);
}
export function QuickRepayment({
target,
accounts,
busy,
submit,
}: {
target: Position;
accounts: Position[];
busy: boolean;
submit: (data: unknown) => void;
}) {
const choices = accounts.filter(
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived && p.id !== target.id,
);
const [sourceId, setSourceId] = useState(choices[0]?.id || '');
const source = choices.find((p) => p.id === sourceId);
return (
<section>
<p>
{tr('还款账户')}:{target.name} · {money(positionAmount(target), target.currency)}
</p>
<p className="muted">
{tr('从付款账户转入欠款账户,双方余额同步更新;这里填写本次还款金额。')}
</p>
{!choices.length ? (
<p>{tr('请先添加启用的资产账户作为付款账户。')}</p>
) : (
<>
<label>
{tr('付款账户')}
<AccountPicker
name="quick-payment-source"
accounts={choices}
value={sourceId}
onChange={setSourceId}
/>
</label>
{source && (
<QuickTransfer
key={source.id + target.id}
source={source}
target={target}
busy={busy}
changeSource={() => setSourceId('')}
changeTarget={() => {}}
fixedTarget
actionLabel={tr('确认还款')}
submit={submit}
/>
)}
</>
)}
</section>
);
}
+10 -4
View File
@@ -8,6 +8,8 @@ export function QuickTransfer({
changeSource,
changeTarget,
submit,
actionLabel,
fixedTarget,
}: {
source: Position;
target: Position;
@@ -15,6 +17,8 @@ export function QuickTransfer({
changeSource: () => void;
changeTarget: () => void;
submit: (data: unknown) => void;
actionLabel?: string;
fixedTarget?: boolean;
}) {
const [amount, setAmount] = useState('');
const [requestId] = useState(requestUuid);
@@ -50,9 +54,11 @@ export function QuickTransfer({
<small>{tr('转入账户')}</small>
<strong>{target.name}</strong>
<span>{money(positionAmount(target), target.currency)}</span>
<button type="button" className="text" disabled={busy} onClick={changeTarget}>
{tr('重选')}
</button>
{!fixedTarget && (
<button type="button" className="text" disabled={busy} onClick={changeTarget}>
{tr('重选')}
</button>
)}
</div>
</div>
<label>
@@ -84,7 +90,7 @@ export function QuickTransfer({
)}
<p className="muted">{tr('按当前时间记录,手续费为 0。跨币种请填写实际到账金额。')}</p>
<button className="primary" disabled={busy}>
{tr(busy ? '正在转账…' : '确认转账')}
{busy ? tr('正在保存…') : actionLabel || tr('确认转账')}
</button>
</form>
);
+3
View File
@@ -51,6 +51,9 @@ export type History = {
reason: string;
};
export type Position = {
lastRepaymentMonth?: string | null;
lastBookedDate?: string | null;
updatedAt?: string;
included?: boolean;
metalType?: string | null;
metalCostPerGram?: string | null;
+48 -1
View File
@@ -560,5 +560,52 @@
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "Add by grams with automatic market valuation. Missing quotes mean awaiting valuation, not a known zero value.",
"缺少报价,当前总额不完整。": "Quotes are missing; current totals are incomplete.",
"黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "Gold and silver are valued by weight and the reference price per gram. Daily updates retain prior prices on failure; fees and resale discounts are excluded.",
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "Purchase cost uses total grams. New holdings use grams and reference prices for valuation. Clearing purchase price hides profit and loss."
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "Purchase cost uses total grams. New holdings use grams and reference prices for valuation. Clearing purchase price hides profit and loss.",
"纪念币": "Commemorative coins",
"纪念钞": "Commemorative banknotes",
"本次快速记账": "Current quick entry session",
"本次保存成功的记录,可逐项撤回。": "Successfully saved entries from this session can be undone individually.",
"还没有保存记录": "No saved entries yet",
"已标记本月还款": "Marked as repaid this month",
"已取消本月标记": "Monthly repayment mark removed",
"已撤回": "Undone",
"撤回": "Undo",
"还款账户": "Repayment account",
"确认还款": "Confirm repayment",
"从付款账户转入欠款账户,双方余额同步更新;这里填写本次还款金额。": "Transfer from the paying account to the liability account. Both balances update; enter the amount paid.",
"请先添加启用的资产账户作为付款账户。": "Add an active asset account to pay from.",
"记录已撤回,相关余额已更新": "Entry undone; related balances updated.",
"还款标记已保存": "Repayment mark saved",
"结束还款标记": "Finish marking repayments",
"标记本月还款": "Mark monthly repayments",
"点击欠款账户卡片,标记或取消本月已还款。": "Click a liability account to mark or unmark repayment for this month.",
"本月已还款": "Repaid this month",
"快速记账操作": "Quick entry actions",
"账户还款": "Account repayment",
"还款已保存,双方余额已更新": "Repayment saved; both balances updated.",
"取消本月已还款": "Unmark repayment this month",
"标记本月已还款": "Mark as repaid this month",
"还款会更新双方余额;月份标记只记录本月已处理,不改变金额。": "Repayment updates both balances. A monthly mark only records completion and does not change amounts.",
"不能标记未来还款月份": "Cannot mark a future repayment month",
"仅欠款账户支持还款月份标记": "Only liability accounts support monthly repayment marks",
"请先恢复归档账户": "Restore the archived account first",
"还款标记已变化,请刷新后重试": "The repayment mark changed. Refresh and try again.",
"还款月份仅适用于欠款账户": "Repayment months only apply to liability accounts",
"结束记账标记": "Finish bookkeeping marks",
"标记当天已记账": "Mark booked today",
"仅看本月未还款": "Only unpaid this month",
"显示当天已记账": "Show booked today",
"隐藏当天已记账": "Hide booked today",
"点击账户卡片,标记或取消当天已记账。": "Click an account to mark or unmark it as booked today.",
"结束统计设置": "Finish inclusion settings",
"快速设置计入总资产": "Quick asset inclusion",
"点击资产卡片,切换是否计入总资产。": "Click an asset to toggle its inclusion in total assets.",
"独立资产总开关已关闭;单项设置已保存,开启总开关后生效。": "Independent assets are globally excluded. Item settings are saved and apply when the global setting is enabled.",
"记账标记已保存": "Bookkeeping mark saved",
"统计设置已保存": "Inclusion settings saved",
"当天已记账": "Booked today",
"计入总资产": "Included in total assets",
"已标记当天记账": "Marked booked today",
"已取消当天标记": "Removed today’s mark",
"当前筛选下没有账户,可切换筛选查看其他账户。": "No accounts match these filters. Change the filters to view other accounts."
}
+48 -1
View File
@@ -560,5 +560,52 @@
"按克数添加,参考价自动估值;缺少报价时显示待估值,不计为已知零价值。": "按克數添加,參考價自動估值;缺少報價時顯示待估值,不計為已知零價值。",
"缺少报价,当前总额不完整。": "缺少報價,當前總額不完整。",
"黄金与白银按重量及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "黃金與白銀按重量及每克參考價估值。每日嘗試更新,失敗保留舊價;參考價不含工費與回收折價。",
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "買入成本按總克數計算;新持倉按克數和參考價估值。清空買入價後不顯示盈虧。"
"买入成本按总克数计算;新持仓按克数和参考价估值。清空买入价后不显示盈亏。": "買入成本按總克數計算;新持倉按克數和參考價估值。清空買入價後不顯示盈虧。",
"纪念币": "紀念幣",
"纪念钞": "紀念鈔",
"本次快速记账": "本次快速記賬",
"本次保存成功的记录,可逐项撤回。": "本次保存成功的記錄,可逐項撤回。",
"还没有保存记录": "還沒有保存記錄",
"已标记本月还款": "已標記本月還款",
"已取消本月标记": "已取消本月標記",
"已撤回": "已撤回",
"撤回": "撤回",
"还款账户": "還款賬戶",
"确认还款": "確認還款",
"从付款账户转入欠款账户,双方余额同步更新;这里填写本次还款金额。": "從付款賬戶轉入欠款賬戶,雙方餘額同步更新;這裡填寫本次還款金額。",
"请先添加启用的资产账户作为付款账户。": "請先添加啟用的資產賬戶作為付款賬戶。",
"记录已撤回,相关余额已更新": "記錄已撤回,相關餘額已更新",
"还款标记已保存": "還款標記已保存",
"结束还款标记": "結束還款標記",
"标记本月还款": "標記本月還款",
"点击欠款账户卡片,标记或取消本月已还款。": "點擊欠款賬戶卡片,標記或取消本月已還款。",
"本月已还款": "本月已還款",
"快速记账操作": "快速記賬操作",
"账户还款": "賬戶還款",
"还款已保存,双方余额已更新": "還款已保存,雙方餘額已更新",
"取消本月已还款": "取消本月已還款",
"标记本月已还款": "標記本月已還款",
"还款会更新双方余额;月份标记只记录本月已处理,不改变金额。": "還款會更新雙方餘額;月份標記只記錄本月已處理,不改變金額。",
"不能标记未来还款月份": "不能標記未來還款月份",
"仅欠款账户支持还款月份标记": "僅欠款賬戶支持還款月份標記",
"请先恢复归档账户": "請先恢復歸檔賬戶",
"还款标记已变化,请刷新后重试": "還款標記已變化,請刷新後重試",
"还款月份仅适用于欠款账户": "還款月份僅適用於欠款賬戶",
"结束记账标记": "結束記賬標記",
"标记当天已记账": "標記當天已記賬",
"仅看本月未还款": "僅看本月未還款",
"显示当天已记账": "顯示當天已記賬",
"隐藏当天已记账": "隱藏當天已記賬",
"点击账户卡片,标记或取消当天已记账。": "點擊賬戶卡片,標記或取消當天已記賬。",
"结束统计设置": "結束統計設置",
"快速设置计入总资产": "快速設置計入總資產",
"点击资产卡片,切换是否计入总资产。": "點擊資產卡片,切換是否計入總資產。",
"独立资产总开关已关闭;单项设置已保存,开启总开关后生效。": "獨立資產總開關已關閉;單項設置已保存,開啟總開關後生效。",
"记账标记已保存": "記賬標記已保存",
"统计设置已保存": "統計設置已保存",
"当天已记账": "當天已記賬",
"计入总资产": "計入總資產",
"已标记当天记账": "已標記當天記賬",
"已取消当天标记": "已取消當天標記",
"当前筛选下没有账户,可切换筛选查看其他账户。": "當前篩選下沒有賬戶,可切換篩選查看其他賬戶。"
}
+154
View File
@@ -2268,3 +2268,157 @@ textarea,
height: 18px;
flex: 0 0 18px;
}
.quick-account-panel {
margin-right: 364px;
}
.quick-account-panel .quick-transfer-select {
padding-right: 0;
}
.quick-session-panel {
position: fixed;
right: 24px;
top: 114px;
bottom: 24px;
width: 340px;
overflow: auto;
z-index: 25;
display: flex;
flex-direction: column;
gap: 18px;
box-shadow: 0 12px 40px #17374718;
}
.quick-session-panel .quick-transfer-card {
width: auto;
box-shadow: none;
border: 1px solid var(--line, #dce5df);
padding: 14px;
}
.quick-session-panel h2 {
font-size: 18px;
margin: 0;
}
.quick-journal ol {
list-style: none;
padding: 0;
margin: 12px 0 0;
display: grid;
gap: 10px;
}
.quick-journal li {
display: grid;
gap: 7px;
border: 1px solid var(--line, #dce5df);
border-radius: 12px;
padding: 12px;
overflow-wrap: anywhere;
}
.quick-journal li p {
margin: 0;
font-size: 13px;
}
.quick-journal li small {
color: var(--text-muted, #617068);
}
.quick-journal li .secondary {
justify-self: end;
padding: 5px 12px;
}
.quick-journal .undone {
opacity: 0.6;
}
.repayment-paid {
background: #e6f6ef;
color: #137852;
}
.quick-repayment-actions {
flex-wrap: wrap;
margin: 12px 0;
}
@media (max-width: 1100px) {
.quick-account-panel {
margin-right: 0;
}
.quick-session-panel {
position: static;
width: auto;
max-height: none;
margin-top: 16px;
}
}
.quick-content .page-heading {
margin-bottom: 10px;
}
.quick-content .page-heading .eyebrow,
.quick-content .page-heading .muted {
display: none;
}
.quick-content .privacy-toolbar > span {
font-size: 12px;
}
.quick-content .account-tools {
gap: 6px;
flex-wrap: wrap;
}
.quick-content .account-tools button,
.quick-content .account-tools select {
font-size: 13px;
padding: 6px 10px;
min-height: 34px;
}
.quick-dense-grid {
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(155px, 1fr));
}
.quick-dense-grid .position-card {
padding: 12px;
min-height: 0;
gap: 4px;
}
.quick-dense-grid .position-card > div {
margin-bottom: 4px;
}
.quick-dense-grid .position-card .position-icon {
width: 28px;
height: 28px;
}
.quick-dense-grid .position-card .position-icon img {
width: 28px;
height: 28px;
}
.quick-dense-grid .position-card h3 {
margin: 2px 0;
font-size: 14px;
}
.quick-dense-grid .position-card strong {
font-size: 18px;
}
.quick-dense-grid .position-secondary,
.quick-dense-grid .position-card > div > svg {
display: none;
}
.quick-dense-grid .account-group-title {
margin: 8px 0 0;
font-size: 14px;
}
.quick-dense-grid .badge {
font-size: 11px;
padding: 2px 6px;
}
.quick-session-panel > * {
flex: 0 0 auto;
}
.quick-session-panel .quick-transfer-card {
max-height: none;
overflow: visible;
}
.quick-account-panel .quick-transfer-select {
padding-bottom: 0;
}
@media (min-width: 1101px) {
.quick-session-panel {
top: 225px;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isPaidForMonth,
isBookedToday,
quickAccountVisible,
canQuickRepay,
QuickJournal,
} from '../src/QuickJournal';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
test('repayment badges expire by business month without clearing stored data', () => {
const p = { kind: 'account', side: 'liability', lastRepaymentMonth: '2026-10' };
assert.equal(isPaidForMonth(p, '2026-10'), true);
assert.equal(isPaidForMonth(p, '2026-11'), false);
assert.equal(p.lastRepaymentMonth, '2026-10');
assert.equal(isPaidForMonth({ ...p, kind: 'debt' }, '2026-10'), false);
});
test('quick repayment only applies to active liability accounts with an outstanding balance', () => {
const p = { kind: 'account', side: 'liability', amount: '200.00000001', archived: false };
assert.equal(canQuickRepay(p), true);
for (const change of [
{ amount: '-5' },
{ amount: '0.00000000' },
{ archived: true },
{ kind: 'debt' },
{ side: 'asset' },
])
assert.equal(canQuickRepay({ ...p, ...change }), false);
});
test('session journal displays exact balances and distinguishes saved versus undone entries', () => {
const entry = {
key: 'one',
kind: 'balance' as const,
id: 'test',
positionId: 'account',
title: '测试',
currency: 'CNY',
before: '100',
after: '125.87654321',
date: '2026-10-04T12:00',
};
const html = renderToStaticMarkup(
createElement(QuickJournal, { entries: [entry], busy: false, undo: () => {} }),
);
assert.match(html, /125\.87654321/);
assert.match(html, /撤回/);
const undone = renderToStaticMarkup(
createElement(QuickJournal, {
entries: [{ ...entry, undone: true }],
busy: false,
undo: () => {},
}),
);
assert.match(undone, /已撤回/);
assert.doesNotMatch(undone, /<button/);
});
test('daily badges expire and filters combine month and day without mutating markers', () => {
const p = {
id: 'test',
kind: 'account',
side: 'liability',
lastRepaymentMonth: '2026-10',
lastBookedDate: '2026-10-04',
archived: false,
} as any;
assert.equal(isBookedToday(p, '2026-10-04'), true);
assert.equal(isBookedToday(p, '2026-10-05'), false);
assert.equal(quickAccountVisible(p, '2026-10', '2026-10-05', true, false), false);
assert.equal(quickAccountVisible(p, '2026-11', '2026-10-04', true, true), false);
assert.equal(quickAccountVisible(p, '2026-11', '2026-10-05', true, true), true);
assert.equal(
quickAccountVisible({ ...p, side: 'asset' }, '2026-11', '2026-10-05', true, false),
false,
);
assert.equal(p.lastBookedDate, '2026-10-04');
});
+4
View File
@@ -51,3 +51,7 @@ User.hiddenMenus 保存受枚举校验的菜单键,settings 不可隐藏;Use
2026-10-03 分组排序:User.accountGroupOrder 保存每个用户的账户分组顺序,空值沿用当前默认顺序。前端共享排序上下文,账户卡片、筛选及所有 AccountPicker 使用同一顺序;只显示当前可见分组,保存保留未出现在当前视图的既有偏好。settings 与备份使用相同分组名称和唯一性验证。新增可选 JSON 字段通过带中文注释的增量迁移应用;JSON/ZIP 空空间恢复偏好,已有空间保留当前设置。见 [更新说明](update-group-order-2026-10-03.md)。
2026-10-03 设置与交互:User.sessionHours 控制 1 至 720 小时的绝对会话有效期,requireHiddenPassword 控制隐藏项目再次验证,overviewCards 保存总览卡片键。设置、JSON/ZIP 备份和带注释的迁移同步;PUT schedules/:id 在计划锁内验证权限并更新未来安排。前端将设置分类、计划原生弹窗、可访问的右上角提示及账户卡片点选快捷转账集中提供。详见 [更新说明](update-settings-interaction-2026-10-03.md)。
## 快速记账标记与统计开关(2026-10-04)
Position.lastRepaymentMonth 和 lastBookedDate 分别记录 UTC+8 最后已还款月份与已记账日期,可空且仅保存最后一次人工标记。页面根据当前业务年月/日期显示状态,不通过定时任务删除旧值。标记接口使用用户归属、隐藏可见性、行锁及期望状态检查;网页使用 updatedAt 防止覆盖新操作。资产快速统计开关仅修改 included,不覆盖其他项目元数据。ZIP v9 保存新字段,金额历史不受标记影响。详细操作、接口和验收见 [快速记账更新](update-quick-entry-2026-10-04.md)。
+11 -1
View File
@@ -1,6 +1,6 @@
{
"tableCount": 17,
"columnCount": 159,
"columnCount": 161,
"tables": [
{
"TABLE_NAME": "_prisma_migrations",
@@ -866,6 +866,16 @@
"TABLE_NAME": "user",
"COLUMN_NAME": "includeIndependentAssets",
"COLUMN_COMMENT": "独立资产是否参与资产总览和净资产轨迹"
},
{
"TABLE_NAME": "position",
"COLUMN_NAME": "lastRepaymentMonth",
"COLUMN_COMMENT": "最后手动标记已完成还款的业务月份,UTC+8 YYYY-MM;空表示未标记"
},
{
"TABLE_NAME": "position",
"COLUMN_NAME": "lastBookedDate",
"COLUMN_COMMENT": "最后手动标记已完成记账的业务日期,UTC+8 YYYY-MM-DD;空表示未标记"
}
]
}
+43
View File
@@ -0,0 +1,43 @@
# 快速记账与资产统计更新(2026-10-04)
## 使用方式
- 独立资产新增“纪念币”和“纪念钞”分类,保留原币、估值历史和当前 ZIP v9 备份。
- 账户页开启“快速记账”,点击卡片录入余额。界面缩小卡片、间距及图标,隐藏类别、折合说明、新增按钮、分组排序等次要信息;退出后恢复。
- “转账”入口位于快速记账按钮附近,依次点击转出、转入卡片后填写金额。宽屏右侧显示本次保存成功的记录;窄屏改为列表下方,避免遮挡卡片。
- 点击有欠款的信用卡或贷款账户,在弹窗选择“还款”,指定启用的资产账户付款,填写本次还款金额。付款及欠款余额在同一事务内更新;负债减少,溢缴余额仍按现有规则显示为存款。
- 本次余额记录、转账、还款及手动标记可逐项“撤回”。余额/转账撤回调用已有记录删除与重放流程,同时更新历史、日历和总览;标记撤回只恢复先前标记。撤回失败不会显示“已撤回”。
- “标记本月还款”开启后点击欠款账户可标记/取消;卡片显示“本月已还款”。“仅看本月未还款”仅展示未标记本月的启用负债账户,支持与分组和当天筛选组合。
- “标记当天已记账”开启后点击启用账户可标记/取消;“隐藏当天已记账”隐藏当日已标记的卡片,可随时恢复显示。实际余额更新不会自动替用户标记完成。
- 独立资产页开启“快速设置计入总资产”,点击卡片切换单项 included,刷新总览与轨迹。独立资产总开关关闭时说明单项配置将在总开关开启后生效。
月份和当天标记仅表示人工确认,不自动生成余额记录、还款或资金往来;实际还款也不会自动标记本月完成。日期采用 UTC+8。跨月或跨日通过比较当前月份/日期停止显示旧标记,旧值仍保留在数据库,不执行定时清理。页面每分钟及重新获得焦点时刷新业务日期,只更新显示状态。
本次记录列表是浏览器内当前快速记账会话的临时列表,刷新、重新开启模式、退出登录或隐藏资产锁定后清空;已经保存的账目仍保留,可从变化记录管理。清空列表不会删除账目。
## 数据与接口
Position 新增两个可空字段,已执行增量迁移:
- lastRepaymentMonth:CHAR(7),最后手动确认已还款月份 YYYY-MM。
- lastBookedDate:CHAR(10),最后手动确认已完成记账日期 YYYY-MM-DD。
字段和实际 MySQL 列均附中文注释;数据库注释清单更新至 17 张表、161 个字段。
接口:
- PATCH /api/positions/:id/repayment-mark:month、expectedMonth,及可选 expectedUpdatedAt。
- PATCH /api/positions/:id/booked-mark:date、expectedDate,及可选 expectedUpdatedAt。
- PATCH /api/positions/:id/inclusion:included、expectedIncluded,及可选 expectedUpdatedAt。
接口检查用户归属及隐藏项目可见性,使用行锁与期望状态防止覆盖其他操作;网页同时发送 updatedAt,保护先修改、再改回的并发场景。仅账户可以标记当天记账,仅负债账户可以标记还款月份,仅独立资产可以使用此快速统计接口。归档账户不可标记;未来日期/月、无效日期和意外字段拒绝。
当前 ZIP v9 导出及事务恢复保存月份、日期与单项统计状态。当前 v9 文件缺少这两个新可选元数据时恢复为空;仍拒绝所有历史 ZIP 版本和单文件 JSON。OpenAPI 已包含三项接口。
## 验证
- 48 项基础测试通过,覆盖跨月/跨日标记到期、组合筛选、精确十进制记录显示及还款入口条件。
- 22 项真实 MySQL/REST 集成测试通过,含余额撤回、成对还款/转账撤回、隐藏账户隔离、跨用户拒绝、并发标记保护、统计开关和 ZIP 恢复。
- 使用独立临时测试账号验证余额保存和撤回、双边还款和撤回、本月未还款筛选、当天记账隐藏、独立资产统计切换、纪念币/钞分类,以及快捷转账入口。
- 768 像素与 1440 像素页面检查通过。长记录列表下转账表单不被压缩,宽屏记录面板与账户列表不重叠。
- 测试用户已退出并清理,不修改真实用户账目。docs 目录仅保存文字和结构化数据。
+9
View File
@@ -6,6 +6,7 @@
> * 同时需要继续更新docs文件夹或里面的文档
> * 数据库的表和字段需要有注释
> * 在docs目录不要保存任何图片
> * 完成任务后完善本项目的README.md
更新要求:
@@ -82,3 +83,11 @@
- ~~将网络安全配置提取到 .env,当前允许所有网络地址访问,后续上线通过配置收紧~~ — 已完成并验证:2026-10-04 11:25(UTC+8),见 docs/network-settings.md
- ~~在 .env.example 用中文介绍每个配置项的作用~~ — 已完成:2026-10-04 11:25(UTC+8)
- ~~独立资产,和债务都可以删除~~ — 已完成并验证:2026-10-04 12:24(UTC+8),见 docs/update-position-deletion-2026-10-04.md
- ~~独立资产添加纪念币\纪念钞~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~快速记账时,这右边会出现本次快速记账的记录,可以快速撤回~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~快速记账时,点击欠款账号后,会在弹窗出现还款按钮~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~快速记账时,会出现一个快速标记本月已还款账号,账号卡片会标记上本月已还款,但是到下一个月这个标记要自己删除,不要使用定时任务完成,直接在数据库中记录最后已完成还款的月份,再加一个仅查看未还款账号的按钮~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~独立资产可以快速点击卡片却认是否计入总资产,与快速记账类似~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~快速记账使用,减小卡片中不重要的信息,并缩小卡片和缩小间距,同时隐藏页面中不重要的信息和按钮,让快速记账更便捷~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~快速记账的转账按钮 和快速记账显示在附近,不在显示在右边~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md
- ~~快速记账时,可以出现点击当天已完成记账的按钮,点击可以快速设置某个账号当天已完成记账,同时在快速记账中可以显示或隐藏当天已完成记账~~ — 已完成并验证:2026-10-04 16:00(UTC+8),见 docs/update-quick-entry-2026-10-04.md