feat: add account transfers and refine icons, settings and safety dialogs
This commit is contained in:
1 parent
a4e9d56b8a
commit
da98d02661
33 files changed
+1866
-347
No files matched your search
@@ -8,7 +8,8 @@ if (!(Test-Path apps/api/.env)) { Copy-Item apps/api/.env.example apps/api/.env
|
|||||||
# 仅在本地 .env 设置 DATABASE_URL,先检查数据库是否存在
|
# 仅在本地 .env 设置 DATABASE_URL,先检查数据库是否存在
|
||||||
pnpm db:generate
|
pnpm db:generate
|
||||||
pnpm db:migrate
|
pnpm db:migrate
|
||||||
pnpm --filter @worthpath/api icons:seed # 追加预置共享图标,重复执行不覆盖已有记录
|
pnpm --filter @worthpath/api build
|
||||||
|
pnpm --filter @worthpath/api icons:seed # 幂等初始化或更新内置图标,保留用户上传和财务数据
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -30,9 +31,9 @@ pnpm db:status
|
|||||||
pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的文件
|
pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的文件
|
||||||
```
|
```
|
||||||
|
|
||||||
当前功能:注册登录和退出、资产/负债账户、独立资产、独立债务和关联、分钟余额历史与更正、隐藏项目密码核验、本位币和自动日汇率、净资产趋势和变化归因、分文件 ZIP 备份与事务追加恢复、侧栏及无操作退出设置、安全清空本账号数据。
|
当前功能:注册登录和退出、资产/负债账户、独立资产、独立债务和关联、分钟余额历史与更正、隐藏项目密码核验、本位币和自动日汇率、净资产趋势和变化归因、分文件 ZIP 备份与事务追加恢复、各菜单项显示开关、统一备注显示开关及无操作退出设置、安全清空本账号数据。
|
||||||
|
|
||||||
金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额,已有错误记录可单独更正。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。
|
金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额;已有错误记录可单独更正,转账及其之前的历史需通过新增余额调整修正,避免破坏双方一致性。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。
|
||||||
|
|
||||||
汇率使用 [Frankfurter 公共日汇率 API](https://frankfurter.dev/)。请求使用固定币种表,不发送用户选择、身份或金额。进程运行时每小时检查,每天尝试一次;添加项目、修改本位币会重新触发检查。失败保留原币和历史汇率,显示缺失或实际使用日期,可以点击重试。自动更新保留已有同日历史导入汇率。首次使用不会自动补齐早期历史汇率,缺失日期不绘制完整总额;原币和已有汇率始终保留。
|
汇率使用 [Frankfurter 公共日汇率 API](https://frankfurter.dev/)。请求使用固定币种表,不发送用户选择、身份或金额。进程运行时每小时检查,每天尝试一次;添加项目、修改本位币会重新触发检查。失败保留原币和历史汇率,显示缺失或实际使用日期,可以点击重试。自动更新保留已有同日历史导入汇率。首次使用不会自动补齐早期历史汇率,缺失日期不绘制完整总额;原币和已有汇率始终保留。
|
||||||
|
|
||||||
@@ -40,12 +41,34 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的
|
|||||||
|
|
||||||
设计见 [数据模型与结构](docs/architecture.md),已验证范围与后续工作见 [首版验收](docs/acceptance.md)。生产需要 HTTPS、COOKIE_SECURE=true、WEB_ORIGIN 为准确站点地址;前端构建由反向代理托管并代理 /api。API 生产启动:在 `apps/api` 中执行 `node dist/main.js`。生产认证限速需迁移到共享存储,数据库用户需最小权限,部署与运行监控尚未配置。
|
设计见 [数据模型与结构](docs/architecture.md),已验证范围与后续工作见 [首版验收](docs/acceptance.md)。生产需要 HTTPS、COOKIE_SECURE=true、WEB_ORIGIN 为准确站点地址;前端构建由反向代理托管并代理 /api。API 生产启动:在 `apps/api` 中执行 `node dist/main.js`。生产认证限速需迁移到共享存储,数据库用户需最小权限,部署与运行监控尚未配置。
|
||||||
|
|
||||||
备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates、icons 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;旧 JSON 备份继续兼容。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。
|
备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates、icons、transfers 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;旧 JSON 备份继续兼容。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。
|
||||||
|
|
||||||
内网穿透临时测试:本地 `apps/api/.env` 可设置 `WEB_ORIGIN=*` 并重启 API,允许来自任意 HTTP/HTTPS 站点的浏览器写入请求,仍需登录身份并校验数据归属。缺失或无效 Origin 仍拒绝;`NODE_ENV=production` 禁止此通配符。测试结束后恢复准确的站点地址。
|
内网穿透临时测试:本地 `apps/api/.env` 可设置 `WEB_ORIGIN=*` 并重启 API,允许来自任意 HTTP/HTTPS 站点的浏览器写入请求,仍需登录身份并校验数据归属。缺失或无效 Origin 仍拒绝;`NODE_ENV=production` 禁止此通配符。测试结束后恢复准确的站点地址。
|
||||||
|
|
||||||
账户图标:新增或编辑账户时选择可复用图标;设置页面提供图标库及中文名称搜索。直接上传默认私有,仅当前用户能检索、读取和使用;勾选共享并明确确认公开后,所有登录用户均可搜索复用,名称必须包含中文。支持静态 PNG/JPG/WebP,单张最大 2 MB,转为最长边 256 像素的 PNG 并去除图片元数据。同一用户相同图片和可见范围会复用现有图标。账户图标通过外键关联,不复制图片。
|
账户图标:新增或编辑账户时选择可复用图标;设置页面提供图标库及中文名称搜索。直接上传默认私有,仅当前用户能检索、读取和使用;勾选共享并明确确认公开后,所有登录用户均可搜索复用,名称必须包含中文。支持静态 PNG/JPG/WebP,单张最大 2 MB,转为最长边 256 像素的 PNG 并去除图片元数据。同一用户相同图片和可见范围会复用现有图标。账户图标通过外键关联,不复制图片。
|
||||||
|
|
||||||
预置 17 家银行及支付宝、微信、京东金融共 20 个图标,资源及来源清单在 `apps/api/assets/icons`;银行来自公开银行标识库,支付平台来自官方网站资源。图标版权与商标归相应品牌所有,用于识别账户,不代表品牌合作或授权。运行 `pnpm --filter @worthpath/api icons:seed` 初始化共享库,不会覆盖已有图标。可离线使用已提交的 PNG,无需访问外部图标网站。
|
预置 17 家银行及支付宝、微信、京东金融共 20 个图标,资源及来源清单在 `apps/api/assets/icons`;银行来自公开银行标识库,支付平台来自官方网站资源及 Simple Icons。图标版权与商标归相应品牌所有,用于识别账户,不代表品牌合作或授权。运行 `pnpm --filter @worthpath/api icons:seed` 初始化共享库或更新固定 ID 的内置透明图标,不修改用户上传图标或财务数据。可离线使用已提交的 PNG,无需访问外部图标网站。
|
||||||
|
|
||||||
ZIP 格式 v4 增加 icons.json(图标名称、图片、内容校验值),包含自己的全部图标及账户引用的共享图标。导入会重建关联并将图标恢复为私有,相同图片复用,避免自动公开;旧 v3 ZIP 和旧 JSON 仍可导入。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。
|
ZIP 格式 v5 增加 transfers.json(转账双方、金额、手续费及配对历史),包含完整转账恢复关系。icons.json(图标名称、图片、内容校验值),包含自己的全部图标及账户引用的共享图标。导入会重建关联并将图标恢复为私有,相同图片复用,避免自动公开;旧 v3/v4 ZIP 和旧 JSON 仍可导入。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。
|
||||||
|
|
||||||
|
## 账户转账与显示设置
|
||||||
|
|
||||||
|
账户页面提供“账户间转账”,适用于自己启用中的资产账户。双方余额和历史在同一数据库事务中更新;手续费按转出币种额外扣除,同币种到账金额等于转出金额,跨币种填写实际到账金额。转账不调用银行或支付平台,不执行真实资金划转。重复提交使用请求 ID 防止重复记账。转账时间不能早于双方最新余额;后续余额调整不能插入已有转账之前。负债还款继续使用负债余额更新流程。
|
||||||
|
|
||||||
|
设置分类使用下拉菜单;资产总览、账户、独立资产、债务、变化记录分别使用显示开关,设置入口始终保留。顶部重复导航已移除。“显示备注”关闭后隐藏列表、详情与表单备注,原有备注不删除,备份仍完整包含。图标通过弹窗检索、选择或上传,透明背景显示;接近纯白的背景像素会转为透明,彩色背景需上传透明 PNG。
|
||||||
|
|
||||||
|
“清空本账号数据”以红色显示。先下载当前完整备份并确认已保存,再输入“确定清空”,依次完成两次确认弹窗;第一次取消在左、确认在右,第二次确认在左、取消在右,两次默认聚焦取消。服务端仍校验当前登录身份、近期备份凭据及数据完整性,财务数据发生变化后须重新下载备份。
|
||||||
|
|
||||||
|
## OpenAPI / Swagger
|
||||||
|
|
||||||
|
启动开发服务后可访问:
|
||||||
|
|
||||||
|
- Swagger 交互文档:[http://localhost:5173/api/docs](http://localhost:5173/api/docs)
|
||||||
|
- OpenAPI JSON:[http://localhost:5173/api/openapi.json](http://localhost:5173/api/openapi.json)
|
||||||
|
- 后端直连文档:[http://localhost:3100/api/docs](http://localhost:3100/api/docs)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod http://localhost:5173/api/openapi.json
|
||||||
|
```
|
||||||
|
|
||||||
|
接口定义由实际控制器路由和表单校验模式生成。文档本身不包含用户财务数据;除注册、登录及健康检查外,业务接口需登录会话 Cookie。建议先在同一站点登录,再通过前端代理地址使用文档;写入接口仍受来源校验和数据隔离约束。部署时将上述地址中的主机替换为自己的站点,并将 `/api` 代理到后端。实现使用 [NestJS Swagger](https://docs.nestjs.com/openapi/introduction)。
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 926 B After Width: | Height: | Size: 4.6 KiB |
@@ -103,9 +103,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "支付宝",
|
"name": "支付宝",
|
||||||
"source": "https://i.alipayobjects.com/common/favicon/favicon.ico",
|
"source": "https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/alipay.svg",
|
||||||
"file": "18.png",
|
"file": "18.png",
|
||||||
"sha256": "b662de58b15b34d1bf4d2a8bc546f7062a3faaa22acd2ecbbbad70f1e7f37a39"
|
"sha256": "8049dde7772116e573864e0d3fab1fd5078c75f88e90dc01f70f63a789889a1a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "微信",
|
"name": "微信",
|
||||||
|
|||||||
@@ -9,13 +9,14 @@
|
|||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:migrate": "node scripts/database.cjs deploy",
|
"db:migrate": "node scripts/database.cjs deploy",
|
||||||
"db:status": "node scripts/database.cjs status",
|
"db:status": "node scripts/database.cjs status",
|
||||||
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts",
|
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts",
|
||||||
"icons:seed": "node scripts/seed-icons.cjs"
|
"icons:seed": "node scripts/seed-icons.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/swagger": "11.2.6",
|
||||||
"@prisma/client": "6.19.0",
|
"@prisma/client": "6.19.0",
|
||||||
"archiver": "^8.0.0",
|
"archiver": "^8.0.0",
|
||||||
"bcryptjs": "^3.0.0",
|
"bcryptjs": "^3.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE `User` ADD COLUMN `hiddenMenus` VARCHAR(128) NOT NULL DEFAULT '', DROP COLUMN `showSidebar`;
|
||||||
|
CREATE TABLE `Transfer` (
|
||||||
|
`id` CHAR(36) NOT NULL, `importedFromId` CHAR(36) NULL, `userId` CHAR(36) NOT NULL,
|
||||||
|
`sourceId` CHAR(36) NOT NULL, `targetId` CHAR(36) NOT NULL,
|
||||||
|
`sourceRevisionId` CHAR(36) NOT NULL, `targetRevisionId` CHAR(36) NOT NULL,
|
||||||
|
`sourceCurrency` CHAR(3) NOT NULL, `targetCurrency` CHAR(3) NOT NULL,
|
||||||
|
`amount` DECIMAL(24,8) NOT NULL, `received` DECIMAL(24,8) NOT NULL, `fee` DECIMAL(24,8) NOT NULL,
|
||||||
|
`effectiveDate` DATETIME(3) NOT NULL, `notes` TEXT NOT NULL, `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`), UNIQUE INDEX `Transfer_sourceRevisionId_key` (`sourceRevisionId`), UNIQUE INDEX `Transfer_targetRevisionId_key` (`targetRevisionId`),
|
||||||
|
UNIQUE INDEX `Transfer_userId_importedFromId_key` (`userId`,`importedFromId`), INDEX `Transfer_userId_effectiveDate_idx` (`userId`,`effectiveDate`),
|
||||||
|
CONSTRAINT `Transfer_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT `Transfer_sourceId_fkey` FOREIGN KEY (`sourceId`) REFERENCES `Position` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT `Transfer_targetId_fkey` FOREIGN KEY (`targetId`) REFERENCES `Position` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `User` ADD COLUMN `showNotes` BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -10,7 +10,8 @@ model User {
|
|||||||
username String @unique @db.VarChar(64)
|
username String @unique @db.VarChar(64)
|
||||||
passwordHash String @db.VarChar(255)
|
passwordHash String @db.VarChar(255)
|
||||||
baseCurrency String @default("CNY") @db.Char(3)
|
baseCurrency String @default("CNY") @db.Char(3)
|
||||||
showSidebar Boolean @default(true)
|
hiddenMenus String @default("") @db.VarChar(128)
|
||||||
|
showNotes Boolean @default(true)
|
||||||
idleMinutes Int @default(30)
|
idleMinutes Int @default(30)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -18,6 +19,7 @@ model User {
|
|||||||
rates ExchangeRate[]
|
rates ExchangeRate[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
icons Icon[]
|
icons Icon[]
|
||||||
|
transfers Transfer[]
|
||||||
}
|
}
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @db.Char(64)
|
id String @id @db.Char(64)
|
||||||
@@ -50,6 +52,8 @@ model Position {
|
|||||||
revisions Revision[]
|
revisions Revision[]
|
||||||
outgoing PositionLink[] @relation("Source")
|
outgoing PositionLink[] @relation("Source")
|
||||||
incoming PositionLink[] @relation("Target")
|
incoming PositionLink[] @relation("Target")
|
||||||
|
transfersOut Transfer[] @relation("TransferSource")
|
||||||
|
transfersIn Transfer[] @relation("TransferTarget")
|
||||||
@@index([userId,kind])
|
@@index([userId,kind])
|
||||||
@@unique([userId,importedFromId])
|
@@unique([userId,importedFromId])
|
||||||
}
|
}
|
||||||
@@ -100,3 +104,26 @@ model Icon {
|
|||||||
@@unique([ownerId,hash,shared])
|
@@unique([ownerId,hash,shared])
|
||||||
@@index([shared,name])
|
@@index([shared,name])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Transfer {
|
||||||
|
id String @id @default(uuid()) @db.Char(36)
|
||||||
|
importedFromId String? @db.Char(36)
|
||||||
|
userId String @db.Char(36)
|
||||||
|
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||||
|
sourceId String @db.Char(36)
|
||||||
|
targetId String @db.Char(36)
|
||||||
|
source Position @relation("TransferSource",fields:[sourceId],references:[id],onDelete:Cascade)
|
||||||
|
target Position @relation("TransferTarget",fields:[targetId],references:[id],onDelete:Cascade)
|
||||||
|
sourceRevisionId String @unique @db.Char(36)
|
||||||
|
targetRevisionId String @unique @db.Char(36)
|
||||||
|
sourceCurrency String @db.Char(3)
|
||||||
|
targetCurrency String @db.Char(3)
|
||||||
|
amount Decimal @db.Decimal(24,8)
|
||||||
|
received Decimal @db.Decimal(24,8)
|
||||||
|
fee Decimal @db.Decimal(24,8)
|
||||||
|
effectiveDate DateTime @db.DateTime(3)
|
||||||
|
notes String @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
@@unique([userId,importedFromId])
|
||||||
|
@@index([userId,effectiveDate])
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ async function main() {
|
|||||||
source: banks.find((b) => b.name === name + '@3x.png')?.download_url,
|
source: banks.find((b) => b.name === name + '@3x.png')?.download_url,
|
||||||
}));
|
}));
|
||||||
sources.push(
|
sources.push(
|
||||||
{ name: '支付宝', source: 'https://i.alipayobjects.com/common/favicon/favicon.ico' },
|
{ name: '支付宝', source: 'https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/alipay.svg' },
|
||||||
{ name: '微信', source: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico' },
|
{ name: '微信', source: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico' },
|
||||||
{ name: '京东金融', source: 'https://jr.jd.com/logo.png' },
|
{ name: '京东金融', source: 'https://jr.jd.com/logo.png' },
|
||||||
);
|
);
|
||||||
@@ -43,6 +43,9 @@ async function main() {
|
|||||||
const res = await fetch(item.source, { signal: AbortSignal.timeout(20000) });
|
const res = await fetch(item.source, { signal: AbortSignal.timeout(20000) });
|
||||||
if (!res.ok) throw Error('Public icon download failed');
|
if (!res.ok) throw Error('Public icon download failed');
|
||||||
const raw = Buffer.from(await res.arrayBuffer());
|
const raw = Buffer.from(await res.arrayBuffer());
|
||||||
|
// Alipay's catalog SVG is an inverse mark. Mask within the glyph bounds to omit its frame.
|
||||||
|
const glyphPath = item.name === '支付宝' ? raw.toString().match(/<path d="([^"]+)"/)[1] : '';
|
||||||
|
const rasterSource = item.name === '支付宝' ? Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><mask id="glyph"><rect x="1.5" y="3.17" width="22.3" height="17" fill="white"/><path d="${glyphPath}" fill="black"/></mask></defs><rect width="24" height="24" fill="#1677ff" mask="url(#glyph)"/></svg>`) : raw;
|
||||||
const file =
|
const file =
|
||||||
String(manifest.length + 1).padStart(2, '0') +
|
String(manifest.length + 1).padStart(2, '0') +
|
||||||
(item.source.endsWith('.ico') ? '.ico' : '.png');
|
(item.source.endsWith('.ico') ? '.ico' : '.png');
|
||||||
@@ -50,7 +53,7 @@ async function main() {
|
|||||||
else
|
else
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(dir, file),
|
path.join(dir, file),
|
||||||
await sharp(raw)
|
await sharp(rasterSource, { density: 768 })
|
||||||
.resize(256, 256, { fit: 'inside', withoutEnlargement: true })
|
.resize(256, 256, { fit: 'inside', withoutEnlargement: true })
|
||||||
.png()
|
.png()
|
||||||
.toBuffer(),
|
.toBuffer(),
|
||||||
|
|||||||
@@ -3,17 +3,15 @@ const { PrismaClient } = require('@prisma/client');
|
|||||||
const { readFile } = require('node:fs/promises');
|
const { readFile } = require('node:fs/promises');
|
||||||
const { join } = require('node:path');
|
const { join } = require('node:path');
|
||||||
const { createHash } = require('node:crypto');
|
const { createHash } = require('node:crypto');
|
||||||
const sharp = require('sharp');
|
require('reflect-metadata');
|
||||||
|
const { normalizeIcon } = require('../dist/icons');
|
||||||
const db = new PrismaClient();
|
const db = new PrismaClient();
|
||||||
async function main() {
|
async function main() {
|
||||||
const dir = join(__dirname, '../assets/icons');
|
const dir = join(__dirname, '../assets/icons');
|
||||||
const sources = JSON.parse(await readFile(join(dir, 'sources.json'), 'utf8'));
|
const sources = JSON.parse(await readFile(join(dir, 'sources.json'), 'utf8'));
|
||||||
// Deterministic IDs make repeated runs safe, without overwriting existing records.
|
// Update only deterministic built-in image IDs; preserve user uploads and financial data.
|
||||||
for (const item of sources) {
|
for (const item of sources) {
|
||||||
const data = await sharp(await readFile(join(dir, item.file)))
|
const data = await normalizeIcon(await readFile(join(dir, item.file)));
|
||||||
.resize(256, 256, { fit: 'inside', withoutEnlargement: true })
|
|
||||||
.png()
|
|
||||||
.toBuffer();
|
|
||||||
const hex = createHash('sha256')
|
const hex = createHash('sha256')
|
||||||
.update('worthpath-builtin:' + item.name)
|
.update('worthpath-builtin:' + item.name)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
@@ -28,7 +26,7 @@ async function main() {
|
|||||||
source: item.source,
|
source: item.source,
|
||||||
hash: createHash('sha256').update(data).digest('hex'),
|
hash: createHash('sha256').update(data).digest('hex'),
|
||||||
},
|
},
|
||||||
update: {},
|
update: { data, source: item.source, hash: createHash('sha256').update(data).digest('hex') },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(`Shared icon catalog ready: ${sources.length} icons.`);
|
console.log(`Shared icon catalog ready: ${sources.length} icons.`);
|
||||||
|
|||||||
@@ -156,10 +156,17 @@ export class AuthController {
|
|||||||
return { username: user.username, baseCurrency: user.baseCurrency };
|
return { username: user.username, baseCurrency: user.baseCurrency };
|
||||||
}
|
}
|
||||||
@Get('auth/me') async me(@Req() req: UserRequest) {
|
@Get('auth/me') async me(@Req() req: UserRequest) {
|
||||||
return this.db.user.findUniqueOrThrow({
|
const user = await this.db.user.findUniqueOrThrow({
|
||||||
where: { id: req.userId },
|
where: { id: req.userId },
|
||||||
select: { username: true, baseCurrency: true, showSidebar: true, idleMinutes: true },
|
select: {
|
||||||
|
username: true,
|
||||||
|
baseCurrency: true,
|
||||||
|
hiddenMenus: true,
|
||||||
|
showNotes: true,
|
||||||
|
idleMinutes: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
return { ...user, hiddenMenus: user.hiddenMenus.split(',').filter(Boolean) };
|
||||||
}
|
}
|
||||||
@Post('auth/activity') async activity(@Req() r: UserRequest) {
|
@Post('auth/activity') async activity(@Req() r: UserRequest) {
|
||||||
await this.db.session.update({
|
await this.db.session.update({
|
||||||
|
|||||||
+134
-6
@@ -25,7 +25,15 @@ import { Prisma } from '@prisma/client';
|
|||||||
import Decimal from 'decimal.js';
|
import Decimal from 'decimal.js';
|
||||||
import { Database } from './database';
|
import { Database } from './database';
|
||||||
import { UserRequest } from './auth';
|
import { UserRequest } from './auth';
|
||||||
import { positionInput, positionMeta, currency, revisionInput, rateInput } from './validation';
|
import {
|
||||||
|
positionInput,
|
||||||
|
positionMeta,
|
||||||
|
currency,
|
||||||
|
revisionInput,
|
||||||
|
rateInput,
|
||||||
|
hiddenMenus,
|
||||||
|
transferInput,
|
||||||
|
} from './validation';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { toBusinessDate } from './validation';
|
import { toBusinessDate } from './validation';
|
||||||
import { iconName, validateStoredIcon } from './icons';
|
import { iconName, validateStoredIcon } from './icons';
|
||||||
@@ -65,7 +73,12 @@ const backupSchema = z
|
|||||||
baseCurrency: currency,
|
baseCurrency: currency,
|
||||||
currencies: z.array(currency).max(10),
|
currencies: z.array(currency).max(10),
|
||||||
preferences: z
|
preferences: z
|
||||||
.object({ showSidebar: z.boolean(), idleMinutes: z.number().int().min(0).max(1440) })
|
.object({
|
||||||
|
showSidebar: z.boolean().optional(),
|
||||||
|
hiddenMenus: hiddenMenus.optional(),
|
||||||
|
showNotes: z.boolean().optional(),
|
||||||
|
idleMinutes: z.number().int().min(0).max(1440),
|
||||||
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
icons: z
|
icons: z
|
||||||
@@ -81,6 +94,19 @@ const backupSchema = z
|
|||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
transfers: z
|
||||||
|
.array(
|
||||||
|
transferInput.safeExtend({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
importedFromId: z.string().uuid().nullable().optional(),
|
||||||
|
sourceRevisionId: z.string().uuid(),
|
||||||
|
targetRevisionId: z.string().uuid(),
|
||||||
|
sourceCurrency: currency,
|
||||||
|
targetCurrency: currency,
|
||||||
|
createdAt: timestamp,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
positions: z.array(record),
|
positions: z.array(record),
|
||||||
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
|
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
|
||||||
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
|
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
|
||||||
@@ -122,6 +148,57 @@ export function validateBackup(raw: unknown) {
|
|||||||
}
|
}
|
||||||
if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整');
|
if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整');
|
||||||
}
|
}
|
||||||
|
const transferIds = new Set<string>(),
|
||||||
|
usedRevisions = new Set<string>();
|
||||||
|
for (const t of b.transfers || []) {
|
||||||
|
const source = ids.get(t.sourceId),
|
||||||
|
target = ids.get(t.targetId);
|
||||||
|
const origin = t.importedFromId || t.id;
|
||||||
|
if (
|
||||||
|
transferIds.has(origin) ||
|
||||||
|
!source ||
|
||||||
|
!target ||
|
||||||
|
source.kind !== 'account' ||
|
||||||
|
target.kind !== 'account' ||
|
||||||
|
source.side !== 'asset' ||
|
||||||
|
target.side !== 'asset' ||
|
||||||
|
source.currency !== t.sourceCurrency ||
|
||||||
|
target.currency !== t.targetCurrency
|
||||||
|
)
|
||||||
|
throw new BadRequestException('转账关联无效');
|
||||||
|
transferIds.add(origin);
|
||||||
|
if (t.sourceCurrency === t.targetCurrency && !new Decimal(t.amount).eq(t.received))
|
||||||
|
throw new BadRequestException('同币种转账金额不一致');
|
||||||
|
for (const [p, revId, reason, delta] of [
|
||||||
|
[source, t.sourceRevisionId, 'transfer_out', new Decimal(t.amount).plus(t.fee).neg()],
|
||||||
|
[target, t.targetRevisionId, 'transfer_in', new Decimal(t.received)],
|
||||||
|
] as const) {
|
||||||
|
const ordered = [...p.revisions].sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.date.localeCompare(b.date) ||
|
||||||
|
(a.sequence || 0) - (b.sequence || 0) ||
|
||||||
|
a.createdAt.localeCompare(b.createdAt),
|
||||||
|
);
|
||||||
|
const index = ordered.findIndex((r) => r.id === revId),
|
||||||
|
current = ordered[index];
|
||||||
|
if (
|
||||||
|
usedRevisions.has(revId) ||
|
||||||
|
index < 1 ||
|
||||||
|
!current ||
|
||||||
|
current.reason !== reason ||
|
||||||
|
current.date !== t.date ||
|
||||||
|
!new Decimal(current.amount).minus(ordered[index - 1].amount).eq(delta)
|
||||||
|
)
|
||||||
|
throw new BadRequestException('转账历史与双方金额不一致');
|
||||||
|
usedRevisions.add(revId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
b.positions.some((p) =>
|
||||||
|
p.revisions.some((r) => r.reason.startsWith('transfer_') && !usedRevisions.has(r.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new BadRequestException('缺少配对转账记录');
|
||||||
const links = new Set<string>();
|
const links = new Set<string>();
|
||||||
for (const l of b.links) {
|
for (const l of b.links) {
|
||||||
const s = ids.get(l.sourceId),
|
const s = ids.get(l.sourceId),
|
||||||
@@ -187,7 +264,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
const [user, ps, rates] = await Promise.all([
|
const [user, ps, rates] = await Promise.all([
|
||||||
client.user.findUniqueOrThrow({
|
client.user.findUniqueOrThrow({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { baseCurrency: true, showSidebar: true, idleMinutes: true },
|
select: { baseCurrency: true, hiddenMenus: true, showNotes: true, idleMinutes: true },
|
||||||
}),
|
}),
|
||||||
client.position.findMany({
|
client.position.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -234,12 +311,17 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const transfers = await client.transfer.findMany({ where: { userId } });
|
||||||
return backupSchema.parse({
|
return backupSchema.parse({
|
||||||
format: 'worthpath',
|
format: 'worthpath',
|
||||||
version: 2,
|
version: 2,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
baseCurrency: user.baseCurrency,
|
baseCurrency: user.baseCurrency,
|
||||||
preferences: { showSidebar: user.showSidebar, idleMinutes: user.idleMinutes },
|
preferences: {
|
||||||
|
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
|
||||||
|
showNotes: user.showNotes,
|
||||||
|
idleMinutes: user.idleMinutes,
|
||||||
|
},
|
||||||
currencies: [
|
currencies: [
|
||||||
...new Set([
|
...new Set([
|
||||||
user.baseCurrency,
|
user.baseCurrency,
|
||||||
@@ -254,6 +336,14 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
hash: i.hash,
|
hash: i.hash,
|
||||||
image: Buffer.from(i.data).toString('base64'),
|
image: Buffer.from(i.data).toString('base64'),
|
||||||
})),
|
})),
|
||||||
|
transfers: transfers.map(({ userId, effectiveDate, ...t }) => ({
|
||||||
|
...t,
|
||||||
|
amount: t.amount.toString(),
|
||||||
|
received: t.received.toString(),
|
||||||
|
fee: t.fee.toString(),
|
||||||
|
date: businessTime(effectiveDate),
|
||||||
|
createdAt: t.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
positions,
|
positions,
|
||||||
links: ps.flatMap((p) =>
|
links: ps.flatMap((p) =>
|
||||||
p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })),
|
p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })),
|
||||||
@@ -353,6 +443,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
(a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date),
|
(a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date),
|
||||||
);
|
);
|
||||||
data.currencies.sort();
|
data.currencies.sort();
|
||||||
|
data.transfers?.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
data.icons?.sort((a, b) => a.id.localeCompare(b.id));
|
data.icons?.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
||||||
}
|
}
|
||||||
@@ -393,6 +484,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
revisions: b.positions.reduce((n, p) => n + p.revisions.length, 0),
|
revisions: b.positions.reduce((n, p) => n + p.revisions.length, 0),
|
||||||
rates: b.rates.length,
|
rates: b.rates.length,
|
||||||
icons: (b.icons || []).length,
|
icons: (b.icons || []).length,
|
||||||
|
transfers: (b.transfers || []).length,
|
||||||
baseCurrency: b.baseCurrency,
|
baseCurrency: b.baseCurrency,
|
||||||
currentBaseCurrency: existing.baseCurrency,
|
currentBaseCurrency: existing.baseCurrency,
|
||||||
message:
|
message:
|
||||||
@@ -458,7 +550,8 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
});
|
});
|
||||||
iconMapping.set(i.id, row.id);
|
iconMapping.set(i.id, row.id);
|
||||||
}
|
}
|
||||||
const mapping = new Map<string, string>();
|
const mapping = new Map<string, string>(),
|
||||||
|
revisionMapping = new Map<string, string>();
|
||||||
for (const p of b.positions) {
|
for (const p of b.positions) {
|
||||||
const row = await tx.position.create({
|
const row = await tx.position.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -495,7 +588,37 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
mapping.set(p.id, row.id);
|
mapping.set(p.id, row.id);
|
||||||
|
const originals = [...p.revisions].sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.date.localeCompare(b.date) ||
|
||||||
|
(a.sequence || 0) - (b.sequence || 0) ||
|
||||||
|
a.createdAt.localeCompare(b.createdAt),
|
||||||
|
);
|
||||||
|
const restored = await tx.revision.findMany({
|
||||||
|
where: { positionId: row.id },
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
});
|
||||||
|
originals.forEach((v, i) => revisionMapping.set(v.id, restored[i].id));
|
||||||
}
|
}
|
||||||
|
for (const t of b.transfers || [])
|
||||||
|
await tx.transfer.create({
|
||||||
|
data: {
|
||||||
|
userId: r.userId,
|
||||||
|
importedFromId: t.importedFromId || t.id,
|
||||||
|
sourceId: mapping.get(t.sourceId)!,
|
||||||
|
targetId: mapping.get(t.targetId)!,
|
||||||
|
sourceRevisionId: revisionMapping.get(t.sourceRevisionId)!,
|
||||||
|
targetRevisionId: revisionMapping.get(t.targetRevisionId)!,
|
||||||
|
sourceCurrency: t.sourceCurrency,
|
||||||
|
targetCurrency: t.targetCurrency,
|
||||||
|
amount: t.amount,
|
||||||
|
received: t.received,
|
||||||
|
fee: t.fee,
|
||||||
|
effectiveDate: toBusinessDate(t.date),
|
||||||
|
notes: t.notes,
|
||||||
|
createdAt: new Date(t.createdAt),
|
||||||
|
},
|
||||||
|
});
|
||||||
for (const l of b.links)
|
for (const l of b.links)
|
||||||
await tx.positionLink.create({
|
await tx.positionLink.create({
|
||||||
data: { sourceId: mapping.get(l.sourceId)!, targetId: mapping.get(l.targetId)! },
|
data: { sourceId: mapping.get(l.sourceId)!, targetId: mapping.get(l.targetId)! },
|
||||||
@@ -516,7 +639,12 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
if (!ps.length && !rs.length)
|
if (!ps.length && !rs.length)
|
||||||
await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: r.userId },
|
where: { id: r.userId },
|
||||||
data: { baseCurrency: b.baseCurrency, ...b.preferences },
|
data: {
|
||||||
|
baseCurrency: b.baseCurrency,
|
||||||
|
idleMinutes: b.preferences?.idleMinutes,
|
||||||
|
hiddenMenus: b.preferences?.hiddenMenus?.join(','),
|
||||||
|
showNotes: b.preferences?.showNotes,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return { ok: true, positions: b.positions.length };
|
return { ok: true, positions: b.positions.length };
|
||||||
},
|
},
|
||||||
|
|||||||
+13
-2
@@ -32,9 +32,20 @@ export async function normalizeIcon(data: Buffer) {
|
|||||||
const meta = await image.metadata();
|
const meta = await image.metadata();
|
||||||
if (!['png', 'jpeg', 'webp'].includes(meta.format || '') || (meta.pages || 1) > 1)
|
if (!['png', 'jpeg', 'webp'].includes(meta.format || '') || (meta.pages || 1) > 1)
|
||||||
throw Error();
|
throw Error();
|
||||||
return await image
|
const resized = await image
|
||||||
.rotate()
|
.rotate()
|
||||||
.resize(256, 256, { fit: 'inside', withoutEnlargement: true })
|
.resize(256, 256, { fit: 'inside', withoutEnlargement: true })
|
||||||
|
.ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
// Neutral white logo backgrounds become transparent; coloured marks remain unchanged.
|
||||||
|
const { data: pixels, info } = resized;
|
||||||
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
|
const low = Math.min(pixels[i], pixels[i + 1], pixels[i + 2]);
|
||||||
|
const high = Math.max(pixels[i], pixels[i + 1], pixels[i + 2]);
|
||||||
|
if (low >= 245 && high - low <= 8) pixels[i + 3] = 0;
|
||||||
|
}
|
||||||
|
return await sharp(pixels, { raw: { width: info.width, height: info.height, channels: 4 } })
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -95,7 +106,7 @@ export class IconsController {
|
|||||||
if (!icon) throw new NotFoundException('图标不存在');
|
if (!icon) throw new NotFoundException('图标不存在');
|
||||||
res.setHeader('Content-Type', 'image/png');
|
res.setHeader('Content-Type', 'image/png');
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.send(Buffer.from(icon.data));
|
res.send(await normalizeIcon(Buffer.from(icon.data)));
|
||||||
}
|
}
|
||||||
@Post('upload')
|
@Post('upload')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import { setupOpenApi } from './openapi';
|
||||||
import { Module, Catch, ArgumentsHost, ExceptionFilter, HttpException } from '@nestjs/common';
|
import { Module, Catch, ArgumentsHost, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||||
import { NestFactory, APP_GUARD } from '@nestjs/core';
|
import { NestFactory, APP_GUARD } from '@nestjs/core';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { json } from 'express';
|
import { json } from 'express';
|
||||||
import { AuthController, AuthGuard, AuthService } from './auth';
|
import { AuthController, AuthGuard, AuthService } from './auth';
|
||||||
|
import { TransfersController } from './transfers';
|
||||||
import { PortfolioController } from './portfolio';
|
import { PortfolioController } from './portfolio';
|
||||||
import { BackupController } from './backup';
|
import { BackupController } from './backup';
|
||||||
import { IconsController, IconsService } from './icons';
|
import { IconsController, IconsService } from './icons';
|
||||||
@@ -50,6 +52,7 @@ class SafeErrors implements ExceptionFilter {
|
|||||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
|
TransfersController,
|
||||||
IconsController,
|
IconsController,
|
||||||
AuthController,
|
AuthController,
|
||||||
PortfolioController,
|
PortfolioController,
|
||||||
@@ -74,6 +77,7 @@ async function bootstrap() {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
app.useGlobalFilters(new SafeErrors());
|
app.useGlobalFilters(new SafeErrors());
|
||||||
|
setupOpenApi(app);
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
await app.listen(Number(process.env.PORT || 3100), '0.0.0.0');
|
await app.listen(Number(process.env.PORT || 3100), '0.0.0.0');
|
||||||
console.log('WorthPath API ready');
|
console.log('WorthPath API ready');
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
credentials,
|
||||||
|
positionInput,
|
||||||
|
positionMeta,
|
||||||
|
revisionInput,
|
||||||
|
transferInput,
|
||||||
|
currency,
|
||||||
|
hiddenMenus,
|
||||||
|
} from './validation';
|
||||||
|
export function setupOpenApi(app: INestApplication) {
|
||||||
|
const document = SwaggerModule.createDocument(
|
||||||
|
app,
|
||||||
|
new DocumentBuilder()
|
||||||
|
.setTitle('WorthPath API')
|
||||||
|
.setVersion('1.1')
|
||||||
|
.setDescription(
|
||||||
|
'个人资产管理接口。金额使用十进制字符串;登录会话由 HttpOnly Cookie 传递,数据归属由服务端验证。',
|
||||||
|
)
|
||||||
|
.addCookieAuth('wp_session', { type: 'apiKey', in: 'cookie' }, 'session')
|
||||||
|
.addSecurityRequirements('session')
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
const bodies: Record<string, z.ZodType> = {
|
||||||
|
'POST /api/auth/register': credentials,
|
||||||
|
'POST /api/auth/login': credentials,
|
||||||
|
'POST /api/auth/reveal': credentials.pick({ password: true }),
|
||||||
|
'POST /api/positions': positionInput,
|
||||||
|
'PATCH /api/positions/{id}': positionMeta,
|
||||||
|
'POST /api/positions/{id}/revisions': revisionInput,
|
||||||
|
'PUT /api/positions/{id}/revisions/{revisionId}': revisionInput,
|
||||||
|
'PUT /api/positions/{id}/links': z
|
||||||
|
.object({ targetIds: z.array(z.string().uuid()).max(20) })
|
||||||
|
.strict(),
|
||||||
|
'POST /api/transfers': transferInput,
|
||||||
|
'PATCH /api/settings': z
|
||||||
|
.object({
|
||||||
|
baseCurrency: currency.optional(),
|
||||||
|
hiddenMenus: hiddenMenus.optional(),
|
||||||
|
showNotes: z.boolean().optional(),
|
||||||
|
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
'POST /api/backup/clear': z.object({ confirmation: z.literal('确定清空') }),
|
||||||
|
'POST /api/backup/import-file': z.object({
|
||||||
|
token: z.string().uuid(),
|
||||||
|
confirmed: z.literal(true),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
for (const [path, entry] of Object.entries(document.paths)) {
|
||||||
|
for (const method of ['get', 'post', 'patch', 'put'] as const) {
|
||||||
|
const operation = entry[method];
|
||||||
|
if (!operation) continue;
|
||||||
|
operation.summary = `${method.toUpperCase()} ${path}`;
|
||||||
|
if (path === '/api/health' || ['/api/auth/register', '/api/auth/login'].includes(path))
|
||||||
|
operation.security = [];
|
||||||
|
const schema = bodies[method.toUpperCase() + ' ' + path];
|
||||||
|
if (schema)
|
||||||
|
operation.requestBody = {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: z.toJSONSchema(schema, { target: 'openapi-3.0' }) as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
operation.responses['400'] = {
|
||||||
|
description: '请求格式、金额或业务校验失败,返回 { message }',
|
||||||
|
};
|
||||||
|
operation.responses['401'] = { description: '未登录或会话失效' };
|
||||||
|
operation.responses['403'] = { description: '请求来源或访问权限不受信任' };
|
||||||
|
operation.responses['409'] = { description: '数据冲突或业务记录不可修改' };
|
||||||
|
if (path === '/api/backup' && method === 'get')
|
||||||
|
operation.responses['200'] = {
|
||||||
|
description: '该用户全部数据的 ZIP 文件',
|
||||||
|
content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } },
|
||||||
|
};
|
||||||
|
if (path === '/api/backup/upload' || path === '/api/icons/upload') {
|
||||||
|
const properties = {
|
||||||
|
file: { type: 'string', format: 'binary' },
|
||||||
|
...(path.includes('icons')
|
||||||
|
? {
|
||||||
|
name: { type: 'string', description: '共享图标必须包含中文' },
|
||||||
|
shared: { type: 'string', enum: ['false', 'true'], default: 'false' },
|
||||||
|
confirmed: { type: 'string', enum: ['true'], description: '共享发布必须确认' },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
operation.requestBody = {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'multipart/form-data': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: path.includes('icons') ? ['file', 'name'] : ['file'],
|
||||||
|
properties: properties as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (['/api/backup/preview', '/api/backup/import'].includes(path))
|
||||||
|
operation.requestBody = {
|
||||||
|
required: true,
|
||||||
|
description: '旧版 JSON 兼容入口;新版请使用 upload + import-file(支持完整 ZIP 备份)',
|
||||||
|
content: {
|
||||||
|
'application/json': { schema: { type: 'object', additionalProperties: true } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SwaggerModule.setup('api/docs', app, document, {
|
||||||
|
jsonDocumentUrl: 'api/openapi.json',
|
||||||
|
swaggerOptions: { persistAuthorization: false, validatorUrl: null, withCredentials: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
+42
-14
@@ -102,6 +102,7 @@ export class PortfolioController {
|
|||||||
@Body() b: unknown,
|
@Body() b: unknown,
|
||||||
) {
|
) {
|
||||||
const v = revisionInput.parse(b);
|
const v = revisionInput.parse(b);
|
||||||
|
if (v.reason.startsWith('transfer_')) throw new BadRequestException('请使用账户转账接口');
|
||||||
return this.db.$transaction(
|
return this.db.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
const p = await tx.position.findFirst({
|
const p = await tx.position.findFirst({
|
||||||
@@ -109,6 +110,12 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
if (!p) throw new NotFoundException('项目不存在');
|
if (!p) throw new NotFoundException('项目不存在');
|
||||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||||
|
const lastTransfer = await tx.revision.findFirst({
|
||||||
|
where: { positionId: p.id, reason: { in: ['transfer_out', 'transfer_in'] } },
|
||||||
|
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
||||||
|
});
|
||||||
|
if (lastTransfer && toBusinessDate(v.date) < lastTransfer.effectiveDate)
|
||||||
|
throw new ConflictException('余额调整时间不能早于已有转账;请使用当前时间调整');
|
||||||
if (v.reason === 'repayment') {
|
if (v.reason === 'repayment') {
|
||||||
if (p.side !== 'liability') throw new BadRequestException('还款记录只能用于负债');
|
if (p.side !== 'liability') throw new BadRequestException('还款记录只能用于负债');
|
||||||
const prior = await tx.revision.findFirst({
|
const prior = await tx.revision.findFirst({
|
||||||
@@ -137,22 +144,43 @@ export class PortfolioController {
|
|||||||
@Param('revisionId') revisionId: string,
|
@Param('revisionId') revisionId: string,
|
||||||
@Body() b: unknown,
|
@Body() b: unknown,
|
||||||
) {
|
) {
|
||||||
const v = revisionInput.parse(b),
|
const v = revisionInput.parse(b);
|
||||||
p = await this.own(r.userId, id, r.revealed);
|
return this.db.$transaction(
|
||||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
async (tx) => {
|
||||||
if (!p.revisions.some((x) => x.id === revisionId))
|
const p = await tx.position.findFirst({
|
||||||
throw new NotFoundException('历史记录不存在');
|
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||||
await this.db.revision.update({
|
include: { revisions: true },
|
||||||
where: { id: revisionId },
|
});
|
||||||
data: {
|
if (!p) throw new NotFoundException('项目不存在');
|
||||||
amount: v.amount,
|
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||||
effectiveDate: toBusinessDate(v.date),
|
const original = p.revisions.find((h) => h.id === revisionId);
|
||||||
notes: v.notes,
|
if (!original) throw new NotFoundException('历史记录不存在');
|
||||||
reason: 'correction',
|
if (
|
||||||
|
p.revisions.some(
|
||||||
|
(h) =>
|
||||||
|
h.reason.startsWith('transfer_') &&
|
||||||
|
(+h.effectiveDate >= +original.effectiveDate ||
|
||||||
|
toBusinessDate(v.date) < h.effectiveDate),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new ConflictException(
|
||||||
|
'转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致',
|
||||||
|
);
|
||||||
|
await tx.revision.update({
|
||||||
|
where: { id: revisionId },
|
||||||
|
data: {
|
||||||
|
amount: v.amount,
|
||||||
|
effectiveDate: toBusinessDate(v.date),
|
||||||
|
notes: v.notes,
|
||||||
|
reason: 'correction',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
},
|
},
|
||||||
});
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||||
return { ok: true };
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('positions/:id/links') async link(
|
@Put('positions/:id/links') async link(
|
||||||
@Req() r: UserRequest,
|
@Req() r: UserRequest,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
+15
-4
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Database } from './database';
|
import { Database } from './database';
|
||||||
import { UserRequest } from './auth';
|
import { UserRequest } from './auth';
|
||||||
import { currency, date, rateValue, today } from './validation';
|
import { currency, date, rateValue, today, hiddenMenus } from './validation';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import Decimal from 'decimal.js';
|
import Decimal from 'decimal.js';
|
||||||
// Fixed public request; no user currency choices, identifiers or amounts leave the server.
|
// Fixed public request; no user currency choices, identifiers or amounts leave the server.
|
||||||
@@ -168,10 +168,17 @@ export class SettingsController {
|
|||||||
@Get('settings') async settings(@Req() r: UserRequest) {
|
@Get('settings') async settings(@Req() r: UserRequest) {
|
||||||
const u = await this.db.user.findUniqueOrThrow({
|
const u = await this.db.user.findUniqueOrThrow({
|
||||||
where: { id: r.userId },
|
where: { id: r.userId },
|
||||||
select: { username: true, baseCurrency: true, showSidebar: true, idleMinutes: true },
|
select: {
|
||||||
|
username: true,
|
||||||
|
baseCurrency: true,
|
||||||
|
hiddenMenus: true,
|
||||||
|
showNotes: true,
|
||||||
|
idleMinutes: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...u,
|
...u,
|
||||||
|
hiddenMenus: u.hiddenMenus.split(',').filter(Boolean),
|
||||||
lastActivity: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
|
lastActivity: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
|
||||||
.lastActivity,
|
.lastActivity,
|
||||||
revealed: r.revealed,
|
revealed: r.revealed,
|
||||||
@@ -190,13 +197,17 @@ export class SettingsController {
|
|||||||
const data = z
|
const data = z
|
||||||
.object({
|
.object({
|
||||||
baseCurrency: currency.optional(),
|
baseCurrency: currency.optional(),
|
||||||
showSidebar: z.boolean().optional(),
|
hiddenMenus: hiddenMenus.optional(),
|
||||||
|
showNotes: z.boolean().optional(),
|
||||||
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((v) => Object.keys(v).length > 0)
|
.refine((v) => Object.keys(v).length > 0)
|
||||||
.parse(b);
|
.parse(b);
|
||||||
await this.db.user.update({ where: { id: r.userId }, data });
|
await this.db.user.update({
|
||||||
|
where: { id: r.userId },
|
||||||
|
data: { ...data, hiddenMenus: data.hiddenMenus?.join(',') },
|
||||||
|
});
|
||||||
this.fx.invalidate(r.userId);
|
this.fx.invalidate(r.userId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Req,
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import Decimal from 'decimal.js';
|
||||||
|
import { Database } from './database';
|
||||||
|
import { UserRequest } from './auth';
|
||||||
|
import { transferInput, toBusinessDate } from './validation';
|
||||||
|
import { businessTime } from './calculation';
|
||||||
|
@Controller('api/transfers')
|
||||||
|
export class TransfersController {
|
||||||
|
constructor(private db: Database) {}
|
||||||
|
@Get() async list(@Req() r: UserRequest) {
|
||||||
|
const visibility = { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) };
|
||||||
|
const rows = await this.db.transfer.findMany({
|
||||||
|
where: { userId: r.userId, source: visibility, target: visibility },
|
||||||
|
include: { source: { select: { name: true } }, target: { select: { name: true } } },
|
||||||
|
orderBy: [{ effectiveDate: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
});
|
||||||
|
return rows.map(({ userId, importedFromId, effectiveDate, ...v }) => ({
|
||||||
|
...v,
|
||||||
|
date: businessTime(effectiveDate),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||||
|
const v = transferInput.parse(body),
|
||||||
|
when = toBusinessDate(v.date);
|
||||||
|
return this.db.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
if (v.requestId) {
|
||||||
|
const existing = await tx.transfer.findFirst({
|
||||||
|
where: { id: v.requestId, userId: r.userId },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
if (
|
||||||
|
existing.sourceId !== v.sourceId ||
|
||||||
|
existing.targetId !== v.targetId ||
|
||||||
|
!new Decimal(existing.amount.toString()).eq(v.amount) ||
|
||||||
|
!new Decimal(existing.received.toString()).eq(v.received) ||
|
||||||
|
!new Decimal(existing.fee.toString()).eq(v.fee) ||
|
||||||
|
+existing.effectiveDate !== +when ||
|
||||||
|
existing.notes !== v.notes
|
||||||
|
)
|
||||||
|
throw new ConflictException('转账请求标识已使用,请刷新后重试');
|
||||||
|
return { id: existing.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const accounts = await tx.position.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: [v.sourceId, v.targetId] },
|
||||||
|
userId: r.userId,
|
||||||
|
kind: 'account',
|
||||||
|
side: 'asset',
|
||||||
|
archived: false,
|
||||||
|
...(r.revealed ? {} : { hidden: false }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (accounts.length !== 2)
|
||||||
|
throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)');
|
||||||
|
const source = accounts.find((p) => p.id === v.sourceId)!,
|
||||||
|
target = accounts.find((p) => p.id === v.targetId)!;
|
||||||
|
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
|
||||||
|
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
|
||||||
|
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
|
||||||
|
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
|
||||||
|
const debit = new Decimal(v.amount).plus(v.fee),
|
||||||
|
before = new Decimal(source.revisions[0].amount.toString());
|
||||||
|
if (before.lt(debit)) throw new BadRequestException('转出账户余额不足(含手续费)');
|
||||||
|
const after = new Decimal(target.revisions[0].amount.toString()).plus(v.received);
|
||||||
|
if (after.gte('10000000000000000'))
|
||||||
|
throw new BadRequestException('到账后的金额超出支持范围');
|
||||||
|
const outgoing = await tx.revision.create({
|
||||||
|
data: {
|
||||||
|
positionId: source.id,
|
||||||
|
amount: before.minus(debit).toFixed(),
|
||||||
|
effectiveDate: when,
|
||||||
|
notes: v.notes,
|
||||||
|
reason: 'transfer_out',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const incoming = await tx.revision.create({
|
||||||
|
data: {
|
||||||
|
positionId: target.id,
|
||||||
|
amount: after.toFixed(),
|
||||||
|
effectiveDate: when,
|
||||||
|
notes: v.notes,
|
||||||
|
reason: 'transfer_in',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = await tx.transfer.create({
|
||||||
|
data: {
|
||||||
|
id: v.requestId,
|
||||||
|
userId: r.userId,
|
||||||
|
sourceId: source.id,
|
||||||
|
targetId: target.id,
|
||||||
|
sourceRevisionId: outgoing.id,
|
||||||
|
targetRevisionId: incoming.id,
|
||||||
|
sourceCurrency: source.currency,
|
||||||
|
targetCurrency: target.currency,
|
||||||
|
amount: v.amount,
|
||||||
|
received: v.received,
|
||||||
|
fee: v.fee,
|
||||||
|
effectiveDate: when,
|
||||||
|
notes: v.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: row.id };
|
||||||
|
},
|
||||||
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,15 @@ export const revisionInput = z
|
|||||||
date: businessDate,
|
date: businessDate,
|
||||||
notes,
|
notes,
|
||||||
reason: z
|
reason: z
|
||||||
.enum(['initial', 'balance', 'valuation', 'repayment', 'correction'])
|
.enum([
|
||||||
|
'initial',
|
||||||
|
'balance',
|
||||||
|
'valuation',
|
||||||
|
'repayment',
|
||||||
|
'correction',
|
||||||
|
'transfer_out',
|
||||||
|
'transfer_in',
|
||||||
|
])
|
||||||
.default('balance'),
|
.default('balance'),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -108,3 +116,22 @@ export const credentials = z
|
|||||||
.refine((s) => Buffer.byteLength(s, 'utf8') <= 72, '密码最多 72 字节'),
|
.refine((s) => Buffer.byteLength(s, 'utf8') <= 72, '密码最多 72 字节'),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
export const menuKey = z.enum(['overview', 'account', 'asset', 'debt', 'history']);
|
||||||
|
export const hiddenMenus = z
|
||||||
|
.array(menuKey)
|
||||||
|
.max(5)
|
||||||
|
.refine((v) => new Set(v).size === v.length, '菜单不可重复');
|
||||||
|
export const transferInput = z
|
||||||
|
.object({
|
||||||
|
requestId: z.string().uuid().optional(),
|
||||||
|
sourceId: z.string().uuid(),
|
||||||
|
targetId: z.string().uuid(),
|
||||||
|
amount: amount.refine((v) => /[1-9]/.test(v), '转出金额必须大于零'),
|
||||||
|
received: amount.refine((v) => /[1-9]/.test(v), '到账金额必须大于零'),
|
||||||
|
fee: amount.default('0'),
|
||||||
|
date: businessDate,
|
||||||
|
notes,
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((v) => v.sourceId !== v.targetId, '不能向同一账户转账');
|
||||||
+18
-6
@@ -16,6 +16,7 @@ const files = [
|
|||||||
'links.json',
|
'links.json',
|
||||||
'rates.json',
|
'rates.json',
|
||||||
'icons.json',
|
'icons.json',
|
||||||
|
'transfers.json',
|
||||||
] as const;
|
] as const;
|
||||||
const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex');
|
const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex');
|
||||||
export function packBackup(b: Backup) {
|
export function packBackup(b: Backup) {
|
||||||
@@ -32,6 +33,7 @@ export function packBackup(b: Backup) {
|
|||||||
'links.json': b.links,
|
'links.json': b.links,
|
||||||
'rates.json': b.rates,
|
'rates.json': b.rates,
|
||||||
'icons.json': b.icons || [],
|
'icons.json': b.icons || [],
|
||||||
|
'transfers.json': b.transfers || [],
|
||||||
};
|
};
|
||||||
const contents = Object.fromEntries(
|
const contents = Object.fromEntries(
|
||||||
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
|
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
|
||||||
@@ -39,7 +41,7 @@ export function packBackup(b: Backup) {
|
|||||||
contents['manifest.json'] = JSON.stringify(
|
contents['manifest.json'] = JSON.stringify(
|
||||||
{
|
{
|
||||||
format: 'worthpath',
|
format: 'worthpath',
|
||||||
version: 4,
|
version: 5,
|
||||||
exportedAt: b.exportedAt,
|
exportedAt: b.exportedAt,
|
||||||
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
||||||
},
|
},
|
||||||
@@ -114,18 +116,22 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
const manifest = z
|
const manifest = z
|
||||||
.object({
|
.object({
|
||||||
format: z.literal('worthpath'),
|
format: z.literal('worthpath'),
|
||||||
version: z.union([z.literal(3), z.literal(4)]),
|
version: z.union([z.literal(3), z.literal(4), z.literal(5)]),
|
||||||
exportedAt: z.iso.datetime(),
|
exportedAt: z.iso.datetime(),
|
||||||
files: z
|
files: z
|
||||||
.array(
|
.array(
|
||||||
z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
|
z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
|
||||||
)
|
)
|
||||||
.min(files.length - 1)
|
.min(files.length - 2)
|
||||||
.max(files.length),
|
.max(files.length),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.parse(parse('manifest.json'));
|
.parse(parse('manifest.json'));
|
||||||
const expected = manifest.version === 3 ? files.filter((f) => f !== 'icons.json') : [...files];
|
const expected = files.filter(
|
||||||
|
(f) =>
|
||||||
|
(manifest.version >= 4 || f !== 'icons.json') &&
|
||||||
|
(manifest.version >= 5 || f !== 'transfers.json'),
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
contents.size !== expected.length + 1 ||
|
contents.size !== expected.length + 1 ||
|
||||||
new Set(manifest.files.map((f) => f.name)).size !== expected.length ||
|
new Set(manifest.files.map((f) => f.name)).size !== expected.length ||
|
||||||
@@ -137,7 +143,12 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
.object({
|
.object({
|
||||||
baseCurrency: z.string(),
|
baseCurrency: z.string(),
|
||||||
preferences: z
|
preferences: z
|
||||||
.object({ showSidebar: z.boolean(), idleMinutes: z.number().int().min(0).max(1440) })
|
.object({
|
||||||
|
showSidebar: z.boolean().optional(),
|
||||||
|
hiddenMenus: z.array(z.string()).optional(),
|
||||||
|
showNotes: z.boolean().optional(),
|
||||||
|
idleMinutes: z.number().int().min(0).max(1440),
|
||||||
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
@@ -169,7 +180,8 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
positions: positions.map((p) => ({ ...p, revisions: grouped.get(p.id) || [] })),
|
positions: positions.map((p) => ({ ...p, revisions: grouped.get(p.id) || [] })),
|
||||||
links: parse('links.json'),
|
links: parse('links.json'),
|
||||||
rates: parse('rates.json'),
|
rates: parse('rates.json'),
|
||||||
...(manifest.version === 4 ? { icons: parse('icons.json') } : {}),
|
...(manifest.version >= 5 ? { transfers: parse('transfers.json') } : {}),
|
||||||
|
...(manifest.version >= 4 ? { icons: parse('icons.json') } : {}),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Origin: 'https://untrusted.invalid',
|
Origin: 'https://untrusted.invalid',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ showSidebar: true }),
|
body: JSON.stringify({ hiddenMenus: [] }),
|
||||||
})
|
})
|
||||||
).status,
|
).status,
|
||||||
process.env.WEB_ORIGIN === '*' ? 200 : 403,
|
process.env.WEB_ORIGIN === '*' ? 200 : 403,
|
||||||
|
|||||||
@@ -236,11 +236,12 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
|
|||||||
assert.equal(await db.position.count({ where: { userId: b.id } }), 3);
|
assert.equal(await db.position.count({ where: { userId: b.id } }), 3);
|
||||||
assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 200);
|
assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 200);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(await call('/settings', 'PATCH', { showSidebar: false, idleMinutes: 1 }, a.cookie)).status,
|
(await call('/settings', 'PATCH', { hiddenMenus: ['asset'], idleMinutes: 1 }, a.cookie))
|
||||||
|
.status,
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
const prefs = (await call('/settings', 'GET', undefined, a.cookie)).data;
|
const prefs = (await call('/settings', 'GET', undefined, a.cookie)).data;
|
||||||
assert.equal(prefs.showSidebar, false);
|
assert.deepEqual(prefs.hiddenMenus, ['asset']);
|
||||||
assert.equal(prefs.idleMinutes, 1);
|
assert.equal(prefs.idleMinutes, 1);
|
||||||
assert.equal((await call('/settings', 'PATCH', { idleMinutes: -1 }, a.cookie)).status, 400);
|
assert.equal((await call('/settings', 'PATCH', { idleMinutes: -1 }, a.cookie)).status, 400);
|
||||||
await db.session.update({
|
await db.session.update({
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID, randomBytes } from 'node:crypto';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { readBackupZip } from '../src/zip';
|
||||||
|
import { normalizeIcon } from '../src/icons';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
||||||
|
test('transfers are atomic, scoped, retry-safe, decimal exact and included in backups', async () => {
|
||||||
|
const db = new PrismaClient(),
|
||||||
|
names: string[] = [];
|
||||||
|
async function call(path: string, cookie = '', method = 'GET', data?: unknown) {
|
||||||
|
const r = await fetch(base + path, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Cookie: cookie,
|
||||||
|
Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_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()))) as any)
|
||||||
|
: await r.json(),
|
||||||
|
cookie: r.headers.get('set-cookie')?.split(';')[0] || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function account() {
|
||||||
|
const username = 'wp_transfer_' + randomUUID(),
|
||||||
|
password = randomBytes(18).toString('hex');
|
||||||
|
names.push(username);
|
||||||
|
const r = await call('/auth/register', '', 'POST', { username, password });
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
return { ...r, id: (await db.user.findUniqueOrThrow({ where: { username } })).id, password };
|
||||||
|
}
|
||||||
|
async function position(
|
||||||
|
cookie: string,
|
||||||
|
name: string,
|
||||||
|
amount = '100',
|
||||||
|
currency = 'CNY',
|
||||||
|
side = 'asset',
|
||||||
|
) {
|
||||||
|
const r = await call('/positions', cookie, 'POST', {
|
||||||
|
name,
|
||||||
|
kind: 'account',
|
||||||
|
category: side === 'asset' ? 'bank' : 'credit_card',
|
||||||
|
side,
|
||||||
|
currency,
|
||||||
|
amount,
|
||||||
|
date: '2026-09-01T10:35',
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
return r.data.id as string;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const a = await account(),
|
||||||
|
b = await account();
|
||||||
|
const sourceId = await position(a.cookie, '转出账户'),
|
||||||
|
targetId = await position(a.cookie, '转入账户', '10'),
|
||||||
|
other = await position(b.cookie, '他人账户');
|
||||||
|
const request = {
|
||||||
|
sourceId,
|
||||||
|
targetId,
|
||||||
|
amount: '25',
|
||||||
|
received: '25',
|
||||||
|
fee: '1',
|
||||||
|
date: '2026-09-02T10:35',
|
||||||
|
notes: '转账验收',
|
||||||
|
requestId: randomUUID(),
|
||||||
|
};
|
||||||
|
assert.equal((await call('/transfers', '', 'POST', request)).status, 401);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/transfers', a.cookie, 'POST', { ...request, targetId: other })).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/transfers', a.cookie, 'POST', { ...request, sourceId: targetId })).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/transfers', a.cookie, 'POST', { ...request, userId: b.id })).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/transfers', a.cookie, 'POST', { ...request, amount: '100', received: '100' }))
|
||||||
|
.status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(await db.transfer.count({ where: { userId: a.id } }), 0);
|
||||||
|
assert.equal(await db.revision.count({ where: { positionId: sourceId } }), 1);
|
||||||
|
assert.equal((await call('/transfers', a.cookie, 'POST', request)).status, 201);
|
||||||
|
assert.equal((await call('/transfers', a.cookie, 'POST', request)).status, 201);
|
||||||
|
assert.equal(await db.transfer.count({ where: { userId: a.id } }), 1);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/transfers', a.cookie, 'POST', { ...request, amount: '24', received: '24' }))
|
||||||
|
.status,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
const rows = (await call('/positions', a.cookie)).data;
|
||||||
|
assert.equal(rows.find((p: any) => p.id === sourceId).amount, '74');
|
||||||
|
assert.equal(rows.find((p: any) => p.id === targetId).amount, '35');
|
||||||
|
assert.equal((await call('/overview', a.cookie)).data.net, '109.00');
|
||||||
|
assert.equal((await call('/transfers', b.cookie)).data.length, 0);
|
||||||
|
const revision = rows.find((p: any) => p.id === sourceId).history.at(-1);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await call(`/positions/${sourceId}/revisions/${revision.id}`, a.cookie, 'PUT', {
|
||||||
|
amount: '70',
|
||||||
|
date: request.date,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await call(`/positions/${sourceId}/revisions`, a.cookie, 'POST', {
|
||||||
|
amount: '70',
|
||||||
|
date: request.date,
|
||||||
|
reason: 'transfer_out',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await call(`/positions/${sourceId}/revisions`, a.cookie, 'POST', {
|
||||||
|
amount: '80',
|
||||||
|
date: '2026-09-01T12:00',
|
||||||
|
reason: 'balance',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
assert.equal((await call('/settings', a.cookie, 'PATCH', { showNotes: 'false' })).status, 400);
|
||||||
|
const debt = await position(a.cookie, '信用卡', '10', 'CNY', 'liability');
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await call('/transfers', a.cookie, 'POST', {
|
||||||
|
...request,
|
||||||
|
requestId: randomUUID(),
|
||||||
|
targetId: debt,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await call('/transfers', a.cookie, 'POST', {
|
||||||
|
...request,
|
||||||
|
requestId: randomUUID(),
|
||||||
|
date: '2026-09-01T10:34',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
const usd = await position(a.cookie, '美元账户', '20', 'USD');
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await call('/transfers', a.cookie, 'POST', {
|
||||||
|
...request,
|
||||||
|
requestId: randomUUID(),
|
||||||
|
sourceId: usd,
|
||||||
|
amount: '1.00000001',
|
||||||
|
received: '7.10000001',
|
||||||
|
fee: '0',
|
||||||
|
date: '2026-09-02T11:00',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/positions', a.cookie)).data.find((p: any) => p.id === usd).amount,
|
||||||
|
'18.99999999',
|
||||||
|
);
|
||||||
|
const race = await Promise.all(
|
||||||
|
[1, 2].map(() =>
|
||||||
|
call('/transfers', a.cookie, 'POST', {
|
||||||
|
...request,
|
||||||
|
requestId: randomUUID(),
|
||||||
|
sourceId: usd,
|
||||||
|
amount: '15',
|
||||||
|
received: '100',
|
||||||
|
fee: '0',
|
||||||
|
date: '2026-09-03T10:00',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.equal(race.filter((r) => r.status === 201).length, 1);
|
||||||
|
assert.equal(race.filter((r) => [400, 409].includes(r.status)).length, 1);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await call('/settings', a.cookie, 'PATCH', {
|
||||||
|
hiddenMenus: ['asset', 'debt'],
|
||||||
|
showNotes: false,
|
||||||
|
idleMinutes: 15,
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.deepEqual((await call('/settings', a.cookie)).data.hiddenMenus, ['asset', 'debt']);
|
||||||
|
assert.equal((await call('/settings', a.cookie)).data.showNotes, false);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/settings', a.cookie, 'PATCH', { hiddenMenus: ['settings'] })).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal((await call('/settings', a.cookie, 'PATCH', { showSidebar: false })).status, 400);
|
||||||
|
const backup = (await call('/backup', a.cookie)).data;
|
||||||
|
assert.equal(backup.transfers.length, 3);
|
||||||
|
assert.equal(backup.preferences.showNotes, false);
|
||||||
|
assert.deepEqual(backup.preferences.hiddenMenus, ['asset', 'debt']);
|
||||||
|
const broken = structuredClone(backup);
|
||||||
|
broken.transfers[0].amount = '999';
|
||||||
|
assert.equal(
|
||||||
|
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: broken })).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(await db.transfer.count({ where: { userId: b.id } }), 0);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
const restored = (await call('/backup', b.cookie)).data;
|
||||||
|
assert.equal(restored.transfers.length, 3);
|
||||||
|
// Appending into an existing space preserves its display preferences.
|
||||||
|
assert.equal((await call('/settings', b.cookie)).data.showNotes, true);
|
||||||
|
const c = await account();
|
||||||
|
assert.equal(
|
||||||
|
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
assert.equal((await call('/settings', c.cookie)).data.showNotes, false);
|
||||||
|
for (const t of restored.transfers) {
|
||||||
|
assert.ok(
|
||||||
|
restored.positions
|
||||||
|
.find((p: any) => p.id === t.sourceId)
|
||||||
|
?.revisions.some((r: any) => r.id === t.sourceRevisionId),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
restored.positions
|
||||||
|
.find((p: any) => p.id === t.targetId)
|
||||||
|
?.revisions.some((r: any) => r.id === t.targetRevisionId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await call('/positions/' + sourceId, a.cookie, 'PATCH', {
|
||||||
|
name: '转出账户',
|
||||||
|
category: 'bank',
|
||||||
|
hidden: true,
|
||||||
|
});
|
||||||
|
const visibleTransfers = (await call('/transfers', a.cookie)).data;
|
||||||
|
assert.equal(visibleTransfers.length, 2);
|
||||||
|
assert.ok(
|
||||||
|
visibleTransfers.every((t: any) => t.sourceId !== sourceId && t.targetId !== sourceId),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await call('/transfers', a.cookie, 'POST', { ...request, requestId: randomUUID() })).status,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await db.user.deleteMany({ where: { username: { in: names } } });
|
||||||
|
await db.$disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('icon processing creates transparent white cutouts and preserves brand colour', async () => {
|
||||||
|
const pixels = Buffer.from([255, 255, 255, 255, 10, 120, 60, 255]);
|
||||||
|
const input = await sharp(pixels, { raw: { width: 2, height: 1, channels: 4 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const output = await sharp(await normalizeIcon(input))
|
||||||
|
.ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer();
|
||||||
|
assert.equal(output[3], 0);
|
||||||
|
assert.deepEqual([...output.subarray(4)], [10, 120, 60, 255]);
|
||||||
|
});
|
||||||
|
test('OpenAPI exposes authenticated routes and actual transfer request fields', async () => {
|
||||||
|
const r = await fetch(base + '/openapi.json');
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
const doc = await r.json();
|
||||||
|
assert.ok(doc.paths['/api/transfers']);
|
||||||
|
assert.ok(
|
||||||
|
doc.paths['/api/transfers'].post.requestBody.content['application/json'].schema.properties
|
||||||
|
.requestId,
|
||||||
|
);
|
||||||
|
assert.equal(doc.components.securitySchemes.session.in, 'cookie');
|
||||||
|
assert.doesNotMatch(JSON.stringify(doc), /passwordHash|DATABASE_URL|ghp_/);
|
||||||
|
const html = await fetch(base + '/docs');
|
||||||
|
assert.equal(html.status, 200);
|
||||||
|
assert.match(await html.text(), /swagger-ui/);
|
||||||
|
});
|
||||||
@@ -10,9 +10,10 @@ const empty = () =>
|
|||||||
version: 2,
|
version: 2,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
baseCurrency: 'CNY',
|
baseCurrency: 'CNY',
|
||||||
preferences: { showSidebar: false, idleMinutes: 9 },
|
preferences: { hiddenMenus: ['asset'], showNotes: false, idleMinutes: 9 },
|
||||||
currencies: ['CNY'],
|
currencies: ['CNY'],
|
||||||
icons: [],
|
icons: [],
|
||||||
|
transfers: [],
|
||||||
positions: [],
|
positions: [],
|
||||||
rates: [],
|
rates: [],
|
||||||
links: [],
|
links: [],
|
||||||
@@ -43,6 +44,7 @@ test('ZIP contains separate JSON files and restores settings without authenticat
|
|||||||
'manifest.json',
|
'manifest.json',
|
||||||
'rates.json',
|
'rates.json',
|
||||||
'settings.json',
|
'settings.json',
|
||||||
|
'transfers.json',
|
||||||
]);
|
]);
|
||||||
assert.doesNotMatch(JSON.stringify(contents), /password|token|session|userId/i);
|
assert.doesNotMatch(JSON.stringify(contents), /password|token|session|userId/i);
|
||||||
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
|
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
|
||||||
@@ -99,11 +101,28 @@ test('backup accepts over 1000 positions, 10000 revisions per position and 20000
|
|||||||
test('legacy v3 ZIP remains readable without icons', async () => {
|
test('legacy v3 ZIP remains readable without icons', async () => {
|
||||||
const contents = packBackup(empty());
|
const contents = packBackup(empty());
|
||||||
delete contents['icons.json'];
|
delete contents['icons.json'];
|
||||||
|
delete contents['transfers.json'];
|
||||||
const manifest = JSON.parse(contents['manifest.json']);
|
const manifest = JSON.parse(contents['manifest.json']);
|
||||||
manifest.version = 3;
|
manifest.version = 3;
|
||||||
manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'icons.json');
|
manifest.files = manifest.files.filter(
|
||||||
|
(f: { name: string }) => !['icons.json', 'transfers.json'].includes(f.name),
|
||||||
|
);
|
||||||
contents['manifest.json'] = JSON.stringify(manifest);
|
contents['manifest.json'] = JSON.stringify(manifest);
|
||||||
const restored = validateBackup(await readBackupZip(await archive(contents)));
|
const restored = validateBackup(await readBackupZip(await archive(contents)));
|
||||||
assert.equal(restored.icons, undefined);
|
assert.equal(restored.icons, undefined);
|
||||||
assert.deepEqual(restored.positions, []);
|
assert.deepEqual(restored.positions, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('legacy v4 ZIP remains readable without transfers', async () => {
|
||||||
|
const contents = packBackup(empty());
|
||||||
|
delete contents['transfers.json'];
|
||||||
|
const manifest = JSON.parse(contents['manifest.json']);
|
||||||
|
manifest.version = 4;
|
||||||
|
manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'transfers.json');
|
||||||
|
contents['manifest.json'] = JSON.stringify(manifest);
|
||||||
|
const restored = validateBackup(await readBackupZip(await archive(contents)));
|
||||||
|
assert.deepEqual(restored.icons, []);
|
||||||
|
assert.equal(restored.transfers, undefined);
|
||||||
|
assert.equal(restored.preferences?.showNotes, false);
|
||||||
|
assert.deepEqual(restored.preferences?.hiddenMenus, ['asset']);
|
||||||
|
});
|
||||||
+443
-286
@@ -33,6 +33,9 @@ import {
|
|||||||
type Total,
|
type Total,
|
||||||
} from './api';
|
} from './api';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
import { IconPicker } from './IconPicker';
|
||||||
|
import { TransferForm } from './TransferForm';
|
||||||
|
import type { Transfer } from './api';
|
||||||
import { IconLibrary, iconUrl } from './IconLibrary';
|
import { IconLibrary, iconUrl } from './IconLibrary';
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
overview: '资产总览',
|
overview: '资产总览',
|
||||||
@@ -77,7 +80,7 @@ function Currency({ value, onChange }: { value: string; onChange: (v: string) =>
|
|||||||
}
|
}
|
||||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<label className="field">
|
<label className={label === '备注' ? 'field note-field' : 'field'}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{children}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
@@ -101,7 +104,7 @@ function Modal({
|
|||||||
>
|
>
|
||||||
<section role="dialog" aria-modal="true" aria-label={title} className="modal">
|
<section role="dialog" aria-modal="true" aria-label={title} className="modal">
|
||||||
<header>
|
<header>
|
||||||
<h2>{title}</h2>
|
<h2 className={title.includes('清空本账号数据') ? 'danger-text' : undefined}>{title}</h2>
|
||||||
<button className="icon" aria-label="关闭" onClick={close}>
|
<button className="icon" aria-label="关闭" onClick={close}>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -217,10 +220,14 @@ export default function App() {
|
|||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
currentBaseCurrency: string;
|
currentBaseCurrency: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [clearStep, setClearStep] = useState(0);
|
const [clearStep, setClearStep] = useState(0),
|
||||||
|
[clearConfirmation, setClearConfirmation] = useState(0);
|
||||||
|
const [settingsSection, setSettingsSection] = useState('general'),
|
||||||
|
[transfers, setTransfers] = useState<Transfer[]>([]);
|
||||||
const activityAt = useRef(0);
|
const activityAt = useRef(0);
|
||||||
function clearAccount() {
|
function clearAccount() {
|
||||||
setClearStep(0);
|
setClearStep(0);
|
||||||
|
setClearConfirmation(0);
|
||||||
activityAt.current = 0;
|
activityAt.current = 0;
|
||||||
sessionGeneration.current++;
|
sessionGeneration.current++;
|
||||||
loadGeneration.current++;
|
loadGeneration.current++;
|
||||||
@@ -228,6 +235,7 @@ export default function App() {
|
|||||||
setPositions([]);
|
setPositions([]);
|
||||||
setOverview(null);
|
setOverview(null);
|
||||||
setRates([]);
|
setRates([]);
|
||||||
|
setTransfers([]);
|
||||||
setFxStatus(null);
|
setFxStatus(null);
|
||||||
setModal(null);
|
setModal(null);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
@@ -249,7 +257,7 @@ export default function App() {
|
|||||||
request = ++loadGeneration.current;
|
request = ++loadGeneration.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [p, o, s] = await Promise.all([
|
const [p, o, s, t] = await Promise.all([
|
||||||
api<Position[]>('/positions'),
|
api<Position[]>('/positions'),
|
||||||
api<Overview>('/overview'),
|
api<Overview>('/overview'),
|
||||||
api<
|
api<
|
||||||
@@ -258,6 +266,7 @@ export default function App() {
|
|||||||
fxStatus: { state: string; message: string; attemptedAt: string | null };
|
fxStatus: { state: string; message: string; attemptedAt: string | null };
|
||||||
}
|
}
|
||||||
>('/settings'),
|
>('/settings'),
|
||||||
|
api<Transfer[]>('/transfers'),
|
||||||
]);
|
]);
|
||||||
if (session !== sessionGeneration.current || request !== loadGeneration.current) return;
|
if (session !== sessionGeneration.current || request !== loadGeneration.current) return;
|
||||||
if (o.revealed !== !!s.revealed) {
|
if (o.revealed !== !!s.revealed) {
|
||||||
@@ -272,8 +281,10 @@ export default function App() {
|
|||||||
setPositions(s.revealed ? p : p.filter((position) => !position.hidden));
|
setPositions(s.revealed ? p : p.filter((position) => !position.hidden));
|
||||||
setOverview(o);
|
setOverview(o);
|
||||||
setRates(s.rates);
|
setRates(s.rates);
|
||||||
|
setTransfers(t);
|
||||||
setFxStatus(s.fxStatus);
|
setFxStatus(s.fxStatus);
|
||||||
setUser(s);
|
setUser(s);
|
||||||
|
setPage((current) => (s.hiddenMenus.includes(current) ? 'settings' : current));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (session === sessionGeneration.current && request === loadGeneration.current) report(e);
|
if (session === sessionGeneration.current && request === loadGeneration.current) report(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -514,16 +525,17 @@ export default function App() {
|
|||||||
['history', HistoryIcon],
|
['history', HistoryIcon],
|
||||||
['settings', Settings],
|
['settings', Settings],
|
||||||
] as const;
|
] as const;
|
||||||
|
const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key));
|
||||||
return (
|
return (
|
||||||
<div className={'app' + (user.showSidebar === false ? ' sidebar-hidden' : '')}>
|
<div className={user.showNotes === false ? 'app notes-hidden' : 'app'}>
|
||||||
{user.showSidebar !== false && (
|
{
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<span>W</span> WorthPath
|
<span>W</span> WorthPath
|
||||||
</div>
|
</div>
|
||||||
<p className="nav-caption">我的资产空间</p>
|
<p className="nav-caption">我的资产空间</p>
|
||||||
<nav>
|
<nav style={{ gridTemplateColumns: `repeat(${visibleNav.length}, 1fr)` }}>
|
||||||
{nav.map(([key, Icon]) => (
|
{visibleNav.map(([key, Icon]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
className={page === key ? 'active' : ''}
|
className={page === key ? 'active' : ''}
|
||||||
@@ -553,26 +565,10 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
}
|
||||||
<main>
|
<main>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<span>个人财务 / {labels[page]}</span>
|
<span>个人财务 / {labels[page]}</span>
|
||||||
{user.showSidebar === false && (
|
|
||||||
<select
|
|
||||||
aria-label="页面导航"
|
|
||||||
value={page}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPage(e.target.value);
|
|
||||||
setSelected(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{nav.map(([key]) => (
|
|
||||||
<option key={key} value={key}>
|
|
||||||
{labels[key]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<span className="currency-pill">本位币 {user.baseCurrency}</span>
|
<span className="currency-pill">本位币 {user.baseCurrency}</span>
|
||||||
<button
|
<button
|
||||||
@@ -621,7 +617,14 @@ export default function App() {
|
|||||||
: '保留原币金额,追踪长期变化。'}
|
: '保留原币金额,追踪长期变化。'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction}
|
<div className="actions">
|
||||||
|
{page === 'account' && !p && (
|
||||||
|
<button className="secondary" onClick={() => setModal({ kind: 'transfer' })}>
|
||||||
|
账户间转账
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="notice error" role="alert">
|
<div className="notice error" role="alert">
|
||||||
@@ -808,7 +811,7 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
: '缺少汇率'}
|
: '缺少汇率'}
|
||||||
</p>
|
</p>
|
||||||
<p>{p.notes || '暂无备注'}</p>
|
<p className="notes-display">{p.notes || '暂无备注'}</p>
|
||||||
<small>币种和资产负债属性创建后固定。归档项目仍计入总览。</small>
|
<small>币种和资产负债属性创建后固定。归档项目仍计入总览。</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
@@ -936,6 +939,28 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
{page === 'history' && (
|
{page === 'history' && (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
|
<h2>账户转账</h2>
|
||||||
|
{transfers.length ? (
|
||||||
|
<div className="transfer-list">
|
||||||
|
{transfers.map((t) => (
|
||||||
|
<article key={t.id}>
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{t.source.name} → {t.target.name}
|
||||||
|
</strong>
|
||||||
|
<small>{displayTime(t.date)}</small>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{money(t.amount, t.sourceCurrency)} → {money(t.received, t.targetCurrency)}{' '}
|
||||||
|
· 手续费 {money(t.fee, t.sourceCurrency)}
|
||||||
|
</p>
|
||||||
|
{t.notes && <p className="muted notes-display">{t.notes}</p>}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">暂无账户间转账记录。</p>
|
||||||
|
)}
|
||||||
<h2>全部金额变化</h2>
|
<h2>全部金额变化</h2>
|
||||||
<p className="muted">原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。</p>
|
<p className="muted">原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。</p>
|
||||||
<HistoryTable
|
<HistoryTable
|
||||||
@@ -951,13 +976,32 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
{page === 'settings' && (
|
{page === 'settings' && (
|
||||||
<>
|
<>
|
||||||
<section className="panel">
|
<section className="settings-switch panel">
|
||||||
<IconLibrary />
|
<label>
|
||||||
|
设置分类
|
||||||
|
<select
|
||||||
|
value={settingsSection}
|
||||||
|
onChange={(e) => setSettingsSection(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="general">个人与菜单设置</option>
|
||||||
|
<option value="icons">图标库</option>
|
||||||
|
<option value="rates">每日汇率</option>
|
||||||
|
<option value="backup">备份与恢复</option>
|
||||||
|
<option className="danger-text" value="clear">
|
||||||
|
清空本账号数据
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</section>
|
</section>
|
||||||
<div className="settings-grid">
|
{settingsSection === 'icons' && (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
|
<IconLibrary />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<div className="settings-grid">
|
||||||
|
<section className="panel" hidden={settingsSection !== 'general'}>
|
||||||
<div className="panel-title">
|
<div className="panel-title">
|
||||||
<h2>本位币与个人设置</h2>
|
<h2>个人与菜单设置</h2>
|
||||||
<button className="text" disabled={busy} onClick={() => void logout()}>
|
<button className="text" disabled={busy} onClick={() => void logout()}>
|
||||||
<LogOut size={16} />
|
<LogOut size={16} />
|
||||||
退出登录
|
退出登录
|
||||||
@@ -974,33 +1018,68 @@ export default function App() {
|
|||||||
() =>
|
() =>
|
||||||
api('/settings', 'PATCH', {
|
api('/settings', 'PATCH', {
|
||||||
baseCurrency: f.get('baseCurrency'),
|
baseCurrency: f.get('baseCurrency'),
|
||||||
showSidebar: f.get('showSidebar') === 'true',
|
hiddenMenus: ['overview', 'account', 'asset', 'debt', 'history'].filter(
|
||||||
|
(key) => !f.has('menu_' + key),
|
||||||
|
),
|
||||||
idleMinutes: Number(f.get('idleMinutes')),
|
idleMinutes: Number(f.get('idleMinutes')),
|
||||||
|
showNotes: f.has('showNotes'),
|
||||||
}),
|
}),
|
||||||
'个人设置已保存',
|
'个人设置已保存',
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field label="左侧菜单栏">
|
<p className="muted">
|
||||||
<select
|
选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。
|
||||||
name="showSidebar"
|
</p>
|
||||||
key={String(user.showSidebar)}
|
<div className="menu-preferences">
|
||||||
defaultValue={String(user.showSidebar)}
|
{['overview', 'account', 'asset', 'debt', 'history'].map((key) => (
|
||||||
>
|
<label key={key} className="switch-row">
|
||||||
<option value="true">显示</option>
|
<span>{labels[key]}</span>
|
||||||
<option value="false">隐藏(顶部仍可切换页面)</option>
|
<input
|
||||||
</select>
|
type="checkbox"
|
||||||
</Field>
|
role="switch"
|
||||||
<Field label="无操作自动退出(分钟)">
|
aria-label={labels[key] + '显示'}
|
||||||
|
name={'menu_' + key}
|
||||||
|
key={key + String(user.hiddenMenus)}
|
||||||
|
defaultChecked={!(user.hiddenMenus || []).includes(key)}
|
||||||
|
/>
|
||||||
|
<span className="switch-state" aria-hidden="true">
|
||||||
|
显示
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="switch-row">
|
||||||
|
<span>显示备注</span>
|
||||||
<input
|
<input
|
||||||
name="idleMinutes"
|
type="checkbox"
|
||||||
type="number"
|
role="switch"
|
||||||
min="0"
|
name="showNotes"
|
||||||
max="1440"
|
aria-label="显示备注"
|
||||||
required
|
key={String(user.showNotes)}
|
||||||
defaultValue={user.idleMinutes}
|
defaultChecked={user.showNotes !== false}
|
||||||
key={user.idleMinutes}
|
|
||||||
/>
|
/>
|
||||||
|
<span className="switch-state" aria-hidden="true">
|
||||||
|
显示
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="muted">
|
||||||
|
关闭后隐藏列表、详情及编辑表单中的备注,已有备注仍会保留在备份中。
|
||||||
|
</p>
|
||||||
|
<Field label="无操作自动退出(分钟)">
|
||||||
|
<select
|
||||||
|
name="idleMinutes"
|
||||||
|
key={user.idleMinutes}
|
||||||
|
defaultValue={user.idleMinutes}
|
||||||
|
>
|
||||||
|
{[...new Set([0, 5, 15, 30, 60, 120, 1440, user.idleMinutes])]
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n === 0 ? '关闭自动退出' : n + ' 分钟'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
0 为关闭;最长 1440 分钟。到时退出登录并清除页面数据,浏览器标签页保留。
|
0 为关闭;最长 1440 分钟。到时退出登录并清除页面数据,浏览器标签页保留。
|
||||||
@@ -1021,7 +1100,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section className="panel">
|
<section className="panel" hidden={settingsSection !== 'rates'}>
|
||||||
<h2>每日汇率</h2>
|
<h2>每日汇率</h2>
|
||||||
{fxStatus && (
|
{fxStatus && (
|
||||||
<p
|
<p
|
||||||
@@ -1056,7 +1135,7 @@ export default function App() {
|
|||||||
<p className="muted">公共参考汇率可能与银行成交价不同;休市日日期可能滞后。</p>
|
<p className="muted">公共参考汇率可能与银行成交价不同;休市日日期可能滞后。</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<section className="panel">
|
<section className="panel" hidden={settingsSection !== 'rates'}>
|
||||||
<h2>已保存汇率</h2>
|
<h2>已保存汇率</h2>
|
||||||
{rates.length ? (
|
{rates.length ? (
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
@@ -1087,8 +1166,8 @@ export default function App() {
|
|||||||
<Empty title="没有保存的外币汇率" body="添加外币项目后更新并保存自动汇率。" />
|
<Empty title="没有保存的外币汇率" body="添加外币项目后更新并保存自动汇率。" />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="panel">
|
<section className="panel" hidden={settingsSection !== 'clear'}>
|
||||||
<h2>清空本账号数据</h2>
|
<h2 className="danger-text">清空本账号数据</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏项目);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。
|
清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏项目);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。
|
||||||
</p>
|
</p>
|
||||||
@@ -1122,14 +1201,8 @@ export default function App() {
|
|||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const confirmation = new FormData(e.currentTarget).get('confirmation');
|
if (new FormData(e.currentTarget).get('confirmation') === '确定清空')
|
||||||
void act(async () => {
|
setClearConfirmation(1);
|
||||||
await api('/backup/clear', 'POST', { confirmation });
|
|
||||||
setClearStep(0);
|
|
||||||
setSelected(null);
|
|
||||||
setBackup(null);
|
|
||||||
setPreview(null);
|
|
||||||
}, '本账号数据已清空');
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Field label="输入“确定清空”以确认">
|
<Field label="输入“确定清空”以确认">
|
||||||
@@ -1145,11 +1218,12 @@ export default function App() {
|
|||||||
取消清空 / 重新下载
|
取消清空 / 重新下载
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<hr />
|
</section>
|
||||||
|
<section className="panel" hidden={settingsSection !== 'backup'}>
|
||||||
<h2>数据备份与恢复</h2>
|
<h2>数据备份与恢复</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
ZIP 内分文件保存可读
|
ZIP 内分文件保存可读
|
||||||
JSON,包括全部账户、资产、债务、历史、关联、币种、设置、汇率及图标,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
|
JSON,包括全部账户、资产、债务、历史、关联、币种、设置、汇率、图标及配对转账,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。
|
||||||
</p>
|
</p>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<a
|
<a
|
||||||
@@ -1246,245 +1320,322 @@ export default function App() {
|
|||||||
<footer>WorthPath · 记录你的积累,理解你的财富</footer>
|
<footer>WorthPath · 记录你的积累,理解你的财富</footer>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
{clearConfirmation > 0 && (
|
||||||
|
<Modal
|
||||||
|
key={clearConfirmation}
|
||||||
|
title={
|
||||||
|
clearConfirmation === 1 ? '第一次确认:清空本账号数据' : '第二次确认:清空本账号数据'
|
||||||
|
}
|
||||||
|
close={() => {
|
||||||
|
if (!busy) setClearConfirmation(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="danger-text">
|
||||||
|
{clearConfirmation === 1
|
||||||
|
? '将清空本账号全部财务数据及私有图标,请确认备份已保存。'
|
||||||
|
: '这是最后一次确认。清空后只能通过备份恢复,其他用户的数据不受影响。'}
|
||||||
|
</p>
|
||||||
|
<p className="muted">登录账号、个人设置和已发布的共享图标会保留。</p>
|
||||||
|
<div className="modal-actions clear-confirm-actions">
|
||||||
|
{clearConfirmation === 1 ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
autoFocus
|
||||||
|
className="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setClearConfirmation(0)}
|
||||||
|
>
|
||||||
|
取消清空
|
||||||
|
</button>
|
||||||
|
<button className="danger" disabled={busy} onClick={() => setClearConfirmation(2)}>
|
||||||
|
继续清空
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void act(async () => {
|
||||||
|
await api('/backup/clear', 'POST', { confirmation: '确定清空' });
|
||||||
|
setClearConfirmation(0);
|
||||||
|
setClearStep(0);
|
||||||
|
setSelected(null);
|
||||||
|
setBackup(null);
|
||||||
|
setPreview(null);
|
||||||
|
}, '本账号数据已清空')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{busy ? '正在清空…' : '最终确认清空'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
autoFocus
|
||||||
|
className="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setClearConfirmation(0)}
|
||||||
|
>
|
||||||
|
取消清空
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="notice error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
{modal && (
|
{modal && (
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title={
|
||||||
modal.kind === 'reveal'
|
modal.kind === 'transfer'
|
||||||
? '验证密码以显示隐藏资产'
|
? '账户间转账'
|
||||||
: modal.kind === 'edit'
|
: modal.kind === 'reveal'
|
||||||
? '编辑项目'
|
? '验证密码以显示隐藏资产'
|
||||||
: modal.kind === 'revision'
|
: modal.kind === 'edit'
|
||||||
? '更新余额 / 估值'
|
? '编辑项目'
|
||||||
: modal.kind === 'correction'
|
: modal.kind === 'revision'
|
||||||
? '更正历史记录'
|
? '更新余额 / 估值'
|
||||||
: modal.kind === 'links'
|
: modal.kind === 'correction'
|
||||||
? '管理债务关联'
|
? '更正历史记录'
|
||||||
: '新增' + labels[modal.kind]
|
: modal.kind === 'links'
|
||||||
|
? '管理债务关联'
|
||||||
|
: '新增' + labels[modal.kind]
|
||||||
}
|
}
|
||||||
close={() => {
|
close={() => {
|
||||||
if (!busy) setModal(null);
|
if (!busy) setModal(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<form
|
{modal.kind === 'transfer' ? (
|
||||||
onSubmit={(e) => {
|
<TransferForm
|
||||||
e.preventDefault();
|
accounts={positions.filter(
|
||||||
const f = new FormData(e.currentTarget),
|
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
|
||||||
v = Object.fromEntries(f.entries()),
|
)}
|
||||||
kind = modal.kind,
|
busy={busy}
|
||||||
mp = modal.p;
|
submit={(v) =>
|
||||||
if (kind === 'reveal') {
|
void act(() => api('/transfers', 'POST', v), '转账已保存,双方余额已更新')
|
||||||
void act(
|
|
||||||
() => api('/auth/reveal', 'POST', { password: f.get('password') }),
|
|
||||||
'隐藏项目已解锁 5 分钟',
|
|
||||||
);
|
|
||||||
} else if (kind === 'links') {
|
|
||||||
void act(
|
|
||||||
() =>
|
|
||||||
api('/positions/' + mp!.id + '/links', 'PUT', {
|
|
||||||
targetIds: f.getAll('targetIds'),
|
|
||||||
}),
|
|
||||||
'关联已更新',
|
|
||||||
);
|
|
||||||
} else if (kind === 'revision' || kind === 'correction') {
|
|
||||||
void act(
|
|
||||||
() =>
|
|
||||||
api(
|
|
||||||
'/positions/' + mp!.id + '/revisions' + (modal.h ? '/' + modal.h.id : ''),
|
|
||||||
modal.h ? 'PUT' : 'POST',
|
|
||||||
v,
|
|
||||||
),
|
|
||||||
'金额历史已保存,总览已重新计算',
|
|
||||||
);
|
|
||||||
} else if (kind === 'edit') {
|
|
||||||
void act(
|
|
||||||
() =>
|
|
||||||
api('/positions/' + mp!.id, 'PATCH', {
|
|
||||||
...v,
|
|
||||||
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
|
|
||||||
archived: mp!.archived,
|
|
||||||
hidden: f.get('hidden') === 'on',
|
|
||||||
}),
|
|
||||||
'项目资料已保存',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const category = String(v.category),
|
|
||||||
side =
|
|
||||||
kind === 'debt' ||
|
|
||||||
(kind === 'account' && ['credit_card', 'loan'].includes(category))
|
|
||||||
? 'liability'
|
|
||||||
: 'asset';
|
|
||||||
void act(
|
|
||||||
() =>
|
|
||||||
api('/positions', 'POST', {
|
|
||||||
...v,
|
|
||||||
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
|
|
||||||
kind,
|
|
||||||
side,
|
|
||||||
hidden: f.get('hidden') === 'on',
|
|
||||||
}),
|
|
||||||
'项目已添加',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
/>
|
||||||
>
|
) : (
|
||||||
{modal.kind === 'reveal' && (
|
<form
|
||||||
<Field label="登录密码">
|
onSubmit={(e) => {
|
||||||
<input
|
e.preventDefault();
|
||||||
name="password"
|
const f = new FormData(e.currentTarget),
|
||||||
type="password"
|
v = Object.fromEntries(f.entries()),
|
||||||
autoComplete="current-password"
|
kind = modal.kind,
|
||||||
required
|
mp = modal.p;
|
||||||
maxLength={72}
|
if (kind === 'reveal') {
|
||||||
/>
|
void act(
|
||||||
</Field>
|
() => api('/auth/reveal', 'POST', { password: f.get('password') }),
|
||||||
)}
|
'隐藏项目已解锁 5 分钟',
|
||||||
{['account', 'asset', 'debt', 'edit'].includes(modal.kind) && (
|
);
|
||||||
<>
|
} else if (kind === 'links') {
|
||||||
<label className="check-line">
|
void act(
|
||||||
<input name="hidden" type="checkbox" defaultChecked={modal.p?.hidden} />
|
() =>
|
||||||
隐藏此项目(密码验证后可查看和编辑)
|
api('/positions/' + mp!.id + '/links', 'PUT', {
|
||||||
</label>
|
targetIds: f.getAll('targetIds'),
|
||||||
{(modal.kind === 'account' || modal.p?.kind === 'account') && (
|
}),
|
||||||
<IconLibrary
|
'关联已更新',
|
||||||
key={modal.p?.id || 'new-account'}
|
);
|
||||||
picker
|
} else if (kind === 'revision' || kind === 'correction') {
|
||||||
initialId={modal.p?.iconId}
|
void act(
|
||||||
/>
|
() =>
|
||||||
)}
|
api(
|
||||||
<Field label="名称">
|
'/positions/' + mp!.id + '/revisions' + (modal.h ? '/' + modal.h.id : ''),
|
||||||
|
modal.h ? 'PUT' : 'POST',
|
||||||
|
v,
|
||||||
|
),
|
||||||
|
'金额历史已保存,总览已重新计算',
|
||||||
|
);
|
||||||
|
} else if (kind === 'edit') {
|
||||||
|
void act(
|
||||||
|
() =>
|
||||||
|
api('/positions/' + mp!.id, 'PATCH', {
|
||||||
|
...v,
|
||||||
|
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
|
||||||
|
archived: mp!.archived,
|
||||||
|
hidden: f.get('hidden') === 'on',
|
||||||
|
}),
|
||||||
|
'项目资料已保存',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const category = String(v.category),
|
||||||
|
side =
|
||||||
|
kind === 'debt' ||
|
||||||
|
(kind === 'account' && ['credit_card', 'loan'].includes(category))
|
||||||
|
? 'liability'
|
||||||
|
: 'asset';
|
||||||
|
void act(
|
||||||
|
() =>
|
||||||
|
api('/positions', 'POST', {
|
||||||
|
...v,
|
||||||
|
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
|
||||||
|
kind,
|
||||||
|
side,
|
||||||
|
hidden: f.get('hidden') === 'on',
|
||||||
|
}),
|
||||||
|
'项目已添加',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modal.kind === 'reveal' && (
|
||||||
|
<Field label="登录密码">
|
||||||
<input
|
<input
|
||||||
name="name"
|
name="password"
|
||||||
defaultValue={modal.p?.name}
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
maxLength={100}
|
maxLength={72}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="类别">
|
)}
|
||||||
<select
|
{['account', 'asset', 'debt', 'edit'].includes(modal.kind) && (
|
||||||
name="category"
|
<>
|
||||||
defaultValue={modal.p?.category || categories[modal.kind]?.[0][0]}
|
<label className="check-line">
|
||||||
>
|
<input name="hidden" type="checkbox" defaultChecked={modal.p?.hidden} />
|
||||||
{categories[modal.p?.kind || modal.kind].map(([v, label]) => (
|
隐藏此项目(密码验证后可查看和编辑)
|
||||||
<option key={v} value={v}>
|
</label>
|
||||||
{label}
|
{(modal.kind === 'account' || modal.p?.kind === 'account') && (
|
||||||
</option>
|
<IconPicker key={modal.p?.id || 'new-account'} initialId={modal.p?.iconId} />
|
||||||
|
)}
|
||||||
|
<Field label="名称">
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
defaultValue={modal.p?.name}
|
||||||
|
required
|
||||||
|
maxLength={100}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="类别">
|
||||||
|
<select
|
||||||
|
name="category"
|
||||||
|
defaultValue={modal.p?.category || categories[modal.kind]?.[0][0]}
|
||||||
|
>
|
||||||
|
{categories[modal.p?.kind || modal.kind].map(([v, label]) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{modal.kind === 'account' && (
|
||||||
|
<p className="muted">信用卡、贷款账户自动计入负债,其余账户计入资产。</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{['account', 'asset', 'debt'].includes(modal.kind) && (
|
||||||
|
<Field label="原币币种">
|
||||||
|
<select name="currency" defaultValue={user.baseCurrency}>
|
||||||
|
{currencies.map((c) => (
|
||||||
|
<option key={c}>{c}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
{modal.kind === 'account' && (
|
)}
|
||||||
<p className="muted">信用卡、贷款账户自动计入负债,其余账户计入资产。</p>
|
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
|
||||||
)}
|
<Field label={'变更后的绝对金额' + (modal.p ? '(' + modal.p.currency + ')' : '')}>
|
||||||
</>
|
<input
|
||||||
)}
|
name="amount"
|
||||||
{['account', 'asset', 'debt'].includes(modal.kind) && (
|
inputMode="decimal"
|
||||||
<Field label="原币币种">
|
required
|
||||||
<select name="currency" defaultValue={user.baseCurrency}>
|
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
|
||||||
{currencies.map((c) => (
|
defaultValue={modal.h?.after || modal.p?.amount || ''}
|
||||||
<option key={c}>{c}</option>
|
placeholder="0.00"
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
|
|
||||||
<Field label={'变更后的绝对金额' + (modal.p ? '(' + modal.p.currency + ')' : '')}>
|
|
||||||
<input
|
|
||||||
name="amount"
|
|
||||||
inputMode="decimal"
|
|
||||||
required
|
|
||||||
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
|
|
||||||
defaultValue={modal.h?.after || modal.p?.amount || ''}
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
|
|
||||||
<Field label={'业务时间(北京时间,精确到分钟)'}>
|
|
||||||
<input
|
|
||||||
type={'datetime-local'}
|
|
||||||
name="date"
|
|
||||||
required
|
|
||||||
min={'1900-01-01T00:00'}
|
|
||||||
max={nowMinute()}
|
|
||||||
defaultValue={modal.h?.time || nowMinute()}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
{modal.kind === 'revision' && (
|
|
||||||
<>
|
|
||||||
<Field label="变更原因">
|
|
||||||
<select
|
|
||||||
name="reason"
|
|
||||||
defaultValue={modal.p?.kind === 'asset' ? 'valuation' : 'balance'}
|
|
||||||
>
|
|
||||||
<option value="balance">余额更新</option>
|
|
||||||
<option value="valuation">估值更新</option>
|
|
||||||
{modal.p?.side === 'liability' && (
|
|
||||||
<option value="repayment">还款(填写还款后欠款)</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<p className="muted">
|
|
||||||
填写更新后的余额或剩余欠款,每次保存会新增历史。已有错误记录请在历史中更正。
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{modal.kind === 'correction' && (
|
|
||||||
<p className="notice warning">
|
|
||||||
这将修改选中的历史金额并重算后续变化,请确认日期和余额。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{modal.kind === 'links' ? (
|
|
||||||
<div className="link-options">
|
|
||||||
{positions.filter((p) => p.kind !== 'debt').length ? (
|
|
||||||
positions
|
|
||||||
.filter((p) => p.kind !== 'debt')
|
|
||||||
.map((p) => (
|
|
||||||
<label key={p.id}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="targetIds"
|
|
||||||
value={p.id}
|
|
||||||
defaultChecked={modal.p!.outgoing.some((l) => l.targetId === p.id)}
|
|
||||||
/>
|
|
||||||
{p.name} · {labels[p.kind]}
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p>请先创建账户或独立资产。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
modal.kind !== 'reveal' && (
|
|
||||||
<Field label="备注">
|
|
||||||
<textarea
|
|
||||||
name="notes"
|
|
||||||
maxLength={2000}
|
|
||||||
rows={3}
|
|
||||||
defaultValue={modal.h?.notes || modal.p?.notes || ''}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)
|
)}
|
||||||
)}
|
{['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && (
|
||||||
{error && (
|
<Field label={'业务时间(北京时间,精确到分钟)'}>
|
||||||
<div role="alert" className="notice error">
|
<input
|
||||||
{error}
|
type={'datetime-local'}
|
||||||
|
name="date"
|
||||||
|
required
|
||||||
|
min={'1900-01-01T00:00'}
|
||||||
|
max={nowMinute()}
|
||||||
|
defaultValue={modal.h?.time || nowMinute()}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{modal.kind === 'revision' && (
|
||||||
|
<>
|
||||||
|
<Field label="变更原因">
|
||||||
|
<select
|
||||||
|
name="reason"
|
||||||
|
defaultValue={modal.p?.kind === 'asset' ? 'valuation' : 'balance'}
|
||||||
|
>
|
||||||
|
<option value="balance">余额更新</option>
|
||||||
|
<option value="valuation">估值更新</option>
|
||||||
|
{modal.p?.side === 'liability' && (
|
||||||
|
<option value="repayment">还款(填写还款后欠款)</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<p className="muted">
|
||||||
|
填写更新后的余额或剩余欠款,每次保存会新增历史。已有错误记录请在历史中更正。
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{modal.kind === 'correction' && (
|
||||||
|
<p className="notice warning">
|
||||||
|
这将修改选中的历史金额并重算后续变化,请确认日期和余额。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{modal.kind === 'links' ? (
|
||||||
|
<div className="link-options">
|
||||||
|
{positions.filter((p) => p.kind !== 'debt').length ? (
|
||||||
|
positions
|
||||||
|
.filter((p) => p.kind !== 'debt')
|
||||||
|
.map((p) => (
|
||||||
|
<label key={p.id}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="targetIds"
|
||||||
|
value={p.id}
|
||||||
|
defaultChecked={modal.p!.outgoing.some((l) => l.targetId === p.id)}
|
||||||
|
/>
|
||||||
|
{p.name} · {labels[p.kind]}
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p>请先创建账户或独立资产。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
modal.kind !== 'reveal' && (
|
||||||
|
<Field label="备注">
|
||||||
|
<textarea
|
||||||
|
name="notes"
|
||||||
|
maxLength={2000}
|
||||||
|
rows={3}
|
||||||
|
defaultValue={modal.h?.notes || modal.p?.notes || ''}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="notice error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setModal(null)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="primary" disabled={busy}>
|
||||||
|
{busy ? '正在保存…' : modal.kind === 'correction' ? '确认更正' : '保存'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</form>
|
||||||
<div className="modal-actions">
|
)}
|
||||||
<button
|
|
||||||
className="secondary"
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => setModal(null)}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button className="primary" disabled={busy}>
|
|
||||||
{busy ? '正在保存…' : modal.kind === 'correction' ? '确认更正' : '保存'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1508,7 +1659,7 @@ function HistoryTable({
|
|||||||
<th>变更前</th>
|
<th>变更前</th>
|
||||||
<th>变更后</th>
|
<th>变更后</th>
|
||||||
<th>变化额</th>
|
<th>变化额</th>
|
||||||
<th>备注</th>
|
<th className="notes-cell">备注</th>
|
||||||
{correct && <th>操作</th>}
|
{correct && <th>操作</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1533,6 +1684,8 @@ function HistoryTable({
|
|||||||
valuation: '估值更新',
|
valuation: '估值更新',
|
||||||
repayment: '还款',
|
repayment: '还款',
|
||||||
correction: '更正',
|
correction: '更正',
|
||||||
|
transfer_out: '转账转出',
|
||||||
|
transfer_in: '转账到账',
|
||||||
} as Record<string, string>
|
} as Record<string, string>
|
||||||
)[h.reason]
|
)[h.reason]
|
||||||
}
|
}
|
||||||
@@ -1544,7 +1697,11 @@ function HistoryTable({
|
|||||||
<td className="notes-cell">{h.notes || '—'}</td>
|
<td className="notes-cell">{h.notes || '—'}</td>
|
||||||
{correct && (
|
{correct && (
|
||||||
<td>
|
<td>
|
||||||
<button className="text" onClick={() => correct(h)}>
|
<button
|
||||||
|
className="text"
|
||||||
|
disabled={h.reason.startsWith('transfer_')}
|
||||||
|
onClick={() => correct(h)}
|
||||||
|
>
|
||||||
更正
|
更正
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ export const iconUrl = (id: string) => '/api/icons/' + encodeURIComponent(id) +
|
|||||||
export function IconLibrary({
|
export function IconLibrary({
|
||||||
initialId,
|
initialId,
|
||||||
picker = false,
|
picker = false,
|
||||||
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
initialId?: string | null;
|
initialId?: string | null;
|
||||||
picker?: boolean;
|
picker?: boolean;
|
||||||
|
onSelect?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [selected, select] = useState(initialId || ''),
|
const [selected, select] = useState(initialId || ''),
|
||||||
[q, search] = useState(''),
|
[q, search] = useState(''),
|
||||||
@@ -113,7 +115,6 @@ export function IconLibrary({
|
|||||||
</p>
|
</p>
|
||||||
{picker && (
|
{picker && (
|
||||||
<div className="icon-selection">
|
<div className="icon-selection">
|
||||||
<input type="hidden" name="iconId" value={selected} />
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<>
|
<>
|
||||||
<img src={iconUrl(selected)} alt="当前账户图标" />
|
<img src={iconUrl(selected)} alt="当前账户图标" />
|
||||||
@@ -189,6 +190,11 @@ export function IconLibrary({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{onSelect && (
|
||||||
|
<button type="button" className="primary" onClick={() => onSelect(selected)}>
|
||||||
|
应用所选图标
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<details className="icon-upload">
|
<details className="icon-upload">
|
||||||
<summary>上传我的图标 / 导入共享图标</summary>
|
<summary>上传我的图标 / 导入共享图标</summary>
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { IconLibrary, iconUrl } from './IconLibrary';
|
||||||
|
export function IconPicker({ initialId }: { initialId?: string | null }) {
|
||||||
|
const [id, setId] = useState(initialId || ''),
|
||||||
|
[open, setOpen] = useState(false);
|
||||||
|
const dialog = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const prior = document.activeElement as HTMLElement;
|
||||||
|
dialog.current?.querySelector<HTMLInputElement>('input[type=search]')?.focus();
|
||||||
|
function key(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
const controls = [
|
||||||
|
...(dialog.current?.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not(:disabled), input, select, summary',
|
||||||
|
) || []),
|
||||||
|
].filter((v) => v.offsetParent !== null);
|
||||||
|
const index = controls.indexOf(document.activeElement as HTMLElement);
|
||||||
|
if (e.shiftKey && index <= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
controls.at(-1)?.focus();
|
||||||
|
} else if (!e.shiftKey && index === controls.length - 1) {
|
||||||
|
e.preventDefault();
|
||||||
|
controls[0]?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', key, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', key, true);
|
||||||
|
prior?.focus();
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
return (
|
||||||
|
<div className="account-icon-field">
|
||||||
|
<input type="hidden" name="iconId" value={id} />
|
||||||
|
{id && <img src={iconUrl(id)} alt="已选账户图标" />}
|
||||||
|
<button type="button" className="secondary" onClick={() => setOpen(true)}>
|
||||||
|
{id ? '更换图标' : '选择图标'}
|
||||||
|
</button>
|
||||||
|
{id && (
|
||||||
|
<button type="button" className="text" onClick={() => setId('')}>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{open &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="icon-picker-backdrop"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialog}
|
||||||
|
className="icon-picker-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="选择账户图标"
|
||||||
|
>
|
||||||
|
<div className="panel-title">
|
||||||
|
<h2>账户图标</h2>
|
||||||
|
<button type="button" className="secondary" onClick={() => setOpen(false)}>
|
||||||
|
关闭图标选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<IconLibrary
|
||||||
|
picker
|
||||||
|
initialId={id}
|
||||||
|
onSelect={(value) => {
|
||||||
|
setId(value);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { nowMinute, money, requestUuid, type Position } from './api';
|
||||||
|
export function TransferForm({
|
||||||
|
accounts,
|
||||||
|
busy,
|
||||||
|
submit,
|
||||||
|
}: {
|
||||||
|
accounts: Position[];
|
||||||
|
busy: boolean;
|
||||||
|
submit: (data: unknown) => void;
|
||||||
|
}) {
|
||||||
|
const [requestId] = useState(() => requestUuid());
|
||||||
|
const [sourceId, setSource] = useState(accounts[0]?.id || ''),
|
||||||
|
[targetId, setTarget] = useState(accounts[1]?.id || ''),
|
||||||
|
[amount, setAmount] = useState('');
|
||||||
|
const source = accounts.find((p) => p.id === sourceId),
|
||||||
|
target = accounts.find((p) => p.id === targetId);
|
||||||
|
const same = source?.currency === target?.currency;
|
||||||
|
function save(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit(Object.fromEntries(new FormData(e.currentTarget)));
|
||||||
|
}
|
||||||
|
if (accounts.length < 2)
|
||||||
|
return <p>请先创建至少两个启用中的资产账户。隐藏账户需验证密码后才能选择。</p>;
|
||||||
|
return (
|
||||||
|
<form onSubmit={save} className="transfer-form">
|
||||||
|
<input type="hidden" name="requestId" value={requestId} />
|
||||||
|
<p className="muted">
|
||||||
|
双方余额同时更新。手续费从转出账户额外扣除;转账时间不能早于双方最新余额记录。
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
转出账户
|
||||||
|
<select name="sourceId" value={sourceId} onChange={(e) => setSource(e.target.value)}>
|
||||||
|
{accounts.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} · {money(p.amount, p.currency)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
转入账户
|
||||||
|
<select name="targetId" value={targetId} onChange={(e) => setTarget(e.target.value)}>
|
||||||
|
{accounts.map((p) => (
|
||||||
|
<option key={p.id} value={p.id} disabled={p.id === sourceId}>
|
||||||
|
{p.name} · {p.currency}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="form-columns">
|
||||||
|
<label>
|
||||||
|
转出金额({source?.currency})
|
||||||
|
<input
|
||||||
|
name="amount"
|
||||||
|
required
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
实际到账({target?.currency})
|
||||||
|
{same ? (
|
||||||
|
<input name="received" required readOnly value={amount} />
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
key={targetId}
|
||||||
|
name="received"
|
||||||
|
required
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{!same && (
|
||||||
|
<p className="notice warning">跨币种请填写银行实际到账金额;本次转账不修改公共参考汇率。</p>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
额外手续费({source?.currency})
|
||||||
|
<input
|
||||||
|
name="fee"
|
||||||
|
required
|
||||||
|
defaultValue="0"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
转账时间(北京时间)
|
||||||
|
<input
|
||||||
|
name="date"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
defaultValue={nowMinute()}
|
||||||
|
max={nowMinute()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
备注
|
||||||
|
<textarea className="note-input" name="notes" maxLength={2000} rows={2} />
|
||||||
|
</label>
|
||||||
|
<button className="primary" disabled={busy || sourceId === targetId}>
|
||||||
|
{busy ? '正在转账…' : '确认转账'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
+24
-1
@@ -20,7 +20,8 @@ export async function api<T>(path: string, method = 'GET', data?: unknown): Prom
|
|||||||
export type User = {
|
export type User = {
|
||||||
username: string;
|
username: string;
|
||||||
baseCurrency: string;
|
baseCurrency: string;
|
||||||
showSidebar: boolean;
|
hiddenMenus: string[];
|
||||||
|
showNotes: boolean;
|
||||||
idleMinutes: number;
|
idleMinutes: number;
|
||||||
revealed?: boolean;
|
revealed?: boolean;
|
||||||
revealUntil?: string | null;
|
revealUntil?: string | null;
|
||||||
@@ -103,3 +104,25 @@ export function money(v: string, currency: string) {
|
|||||||
const [whole, frac] = v.split('.');
|
const [whole, frac] = v.split('.');
|
||||||
return `${currency} ${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${frac !== undefined ? '.' + frac : ''}`;
|
return `${currency} ${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${frac !== undefined ? '.' + frac : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Transfer = {
|
||||||
|
id: string;
|
||||||
|
source: { name: string };
|
||||||
|
target: { name: string };
|
||||||
|
sourceCurrency: string;
|
||||||
|
targetCurrency: string;
|
||||||
|
amount: string;
|
||||||
|
received: string;
|
||||||
|
fee: string;
|
||||||
|
date: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// getRandomValues also works when a development tunnel uses plain HTTP.
|
||||||
|
export function requestUuid() {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
bytes[6] = (bytes[6] & 15) | 64;
|
||||||
|
bytes[8] = (bytes[8] & 63) | 128;
|
||||||
|
const hex = Array.from(bytes, (v) => v.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
@@ -6,6 +6,111 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
/* Keep native hidden panels hidden even when component classes use grid/flex. */
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.settings-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
.settings-switch {
|
||||||
|
max-width: 560px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.settings-switch label,
|
||||||
|
.transfer-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.menu-preferences,
|
||||||
|
.form-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0 18px;
|
||||||
|
}
|
||||||
|
.transfer-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.account-icon-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 14px 0 22px;
|
||||||
|
}
|
||||||
|
.account-icon-field img {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.icon-picker-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: #19392d70;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.icon-picker-dialog {
|
||||||
|
width: min(800px, 100%);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 28px;
|
||||||
|
background: #f7faf8;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 60px #14342940;
|
||||||
|
}
|
||||||
|
.icon-option {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
button.icon-option:hover {
|
||||||
|
background: #e7f1eb !important;
|
||||||
|
}
|
||||||
|
.icon-option.selected {
|
||||||
|
background: #dceee4 !important;
|
||||||
|
box-shadow: inset 0 0 0 2px #388569;
|
||||||
|
}
|
||||||
|
.position-icon {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.transfer-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 20px 0 30px;
|
||||||
|
}
|
||||||
|
.transfer-list article {
|
||||||
|
border: 1px solid #dce5df;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.transfer-list article > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.transfer-list p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.menu-preferences,
|
||||||
|
.form-columns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.icon-picker-backdrop {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.icon-picker-dialog {
|
||||||
|
padding: 18px;
|
||||||
|
max-height: 94vh;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -996,3 +1101,79 @@ footer {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.danger-text {
|
||||||
|
color: #b42318 !important;
|
||||||
|
}
|
||||||
|
.clear-confirm-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.switch-row > span:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.switch-row input[role='switch'] {
|
||||||
|
appearance: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #c8d0cd;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.switch-row input[role='switch']::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
left: 3px;
|
||||||
|
top: 3px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px #0002;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.switch-row input[role='switch']:checked {
|
||||||
|
background: #167b5b;
|
||||||
|
}
|
||||||
|
.switch-row input[role='switch']:checked::after {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
.switch-row input[role='switch']:focus-visible {
|
||||||
|
outline: 3px solid #a4d8c1;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
.switch-state {
|
||||||
|
min-width: 32px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #167b5b;
|
||||||
|
}
|
||||||
|
.switch-row input:not(:checked) + .switch-state {
|
||||||
|
font-size: 0;
|
||||||
|
color: #68766f;
|
||||||
|
}
|
||||||
|
.switch-row input:not(:checked) + .switch-state::after {
|
||||||
|
content: '隐藏';
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.notes-hidden .note-field,
|
||||||
|
.notes-hidden .notes-display,
|
||||||
|
.notes-hidden .notes-cell,
|
||||||
|
.notes-hidden label:has(.note-input) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -39,3 +39,16 @@
|
|||||||
新增迁移 005_account_icons,不修改既有迁移或财务数据;预置 20 个图标,使用固定 ID 幂等初始化。构建/类型检查和 ZIP v3/v4 单元回归通过。新增真实数据库集成覆盖私有图标跨用户不可检索/读取/赋值、共享中文名称和公开确认、图片规范化与恶意格式拒绝、相同图片多账户复用、ZIP 图标内容/关联恢复、损坏图标整次导入拒绝、清空保留他人引用共享图标。测试仅清理随机验收用户和他们上传的图标。
|
新增迁移 005_account_icons,不修改既有迁移或财务数据;预置 20 个图标,使用固定 ID 幂等初始化。构建/类型检查和 ZIP v3/v4 单元回归通过。新增真实数据库集成覆盖私有图标跨用户不可检索/读取/赋值、共享中文名称和公开确认、图片规范化与恶意格式拒绝、相同图片多账户复用、ZIP 图标内容/关联恢复、损坏图标整次导入拒绝、清空保留他人引用共享图标。测试仅清理随机验收用户和他们上传的图标。
|
||||||
|
|
||||||
桌面浏览器实际验收:中文检索支付宝、选择图标后保存账户、私有图片上传及共享中文名称/公开确认发布成功。390×844 手机布局无横向溢出,全部图标正常加载。原生 ZIP 下载包含 10 个 JSON 文件,manifest v4,图标内容和账户关联均存在。开发进程改为只监听 dist,避免首次加载图片处理依赖导致不必要的自动重启。
|
桌面浏览器实际验收:中文检索支付宝、选择图标后保存账户、私有图片上传及共享中文名称/公开确认发布成功。390×844 手机布局无横向溢出,全部图标正常加载。原生 ZIP 下载包含 10 个 JSON 文件,manifest v4,图标内容和账户关联均存在。开发进程改为只监听 dist,避免首次加载图片处理依赖导致不必要的自动重启。
|
||||||
|
|
||||||
|
## 2026-10-02 转账、图标与设置更新
|
||||||
|
|
||||||
|
- 类型检查与最终前后端生产构建通过;16 项单元测试、6 项集成检查通过。增加显示偏好导出/恢复断言后,转账专项 3 项再次通过。追加导入已有空间保留现有显示偏好;空空间恢复备份偏好。
|
||||||
|
- 006_navigation_transfers 和 007_notes_display 已增量应用,7 个迁移状态最新;未执行 reset、db push 或财务数据清空。
|
||||||
|
- 真实 MySQL 验证转账双方余额/历史原子更新、同币种/跨币种精度、额外手续费、重复请求去重、并发余额不足、跨用户拒绝、隐藏账户隔离,以及完整转账 ZIP 导出和关系重建。禁止插入已有转账之前的余额调整,禁止单独更正转账及之前历史。
|
||||||
|
- ZIP v5 含 11 个 JSON 文件;v3/v4 ZIP 和旧 JSON 兼容通过。备注与菜单偏好进入备份,认证凭据不进入备份。图标白色背景转透明且彩色像素保留的处理测试通过。
|
||||||
|
- 浏览器实际验证各菜单独立开关保存/刷新、设置入口保留、顶部重复导航移除、备注在编辑表单及历史列表隐藏、透明支付宝图标检索与弹窗选择后保存账户。
|
||||||
|
- 临时账号界面转账:100 转出 25、手续费 1 后余额 74;另一个 10 的账户到账后为 35;总额 109,配对历史按分钟显示。未执行银行或支付平台真实资金转移。
|
||||||
|
- 清空标题颜色为红色;真实下载 ZIP 后才能进入文字确认,再出现两次弹窗。第一次取消左/确认右,第二次确认左/取消右,均默认聚焦取消。浏览器在第二次选择取消,账户及转账保留;实际清空 API 仅在随机测试账号中回归,未清空真实用户数据。
|
||||||
|
- Swagger UI 在前端代理地址真实加载,OpenAPI JSON 包含实际转账和设置字段及会话认证说明。README 已记录文档及 JSON 地址。
|
||||||
|
- 390×844 手机设置界面检查:页面内容宽度 375,无横向溢出,开关、下拉菜单与底部导航可用;未验证真实手机硬件和外网隧道端到端链路。
|
||||||
|
- 精确清理本次创建的临时验收账号及其财务数据;共享预置图标及实际用户保留。用户原有 Vite 配置修改保留,未纳入本次功能提交。
|
||||||
@@ -31,3 +31,9 @@ Revision 保存按业务日期生效的绝对金额,每次金额更新新增
|
|||||||
Icon 存储 name、ownerId、shared、SHA-256、规范静态 PNG 的 MediumBlob 和可选公开来源;Position.iconId 外键 SetNull,多个账户共享同一图片。所有图标接口使用已验证身份,读取/检索/赋值均限定 shared=true 或 ownerId=当前用户,响应不返回 ownerId。个人上传默认私有;共享发布须中文名称和明确公开确认。拒绝 SVG、动图、损坏图片、超限像素/文件,重编码移除元数据。内置图标固定 ID 追加初始化;在线请求不会发送用户财务数据。
|
Icon 存储 name、ownerId、shared、SHA-256、规范静态 PNG 的 MediumBlob 和可选公开来源;Position.iconId 外键 SetNull,多个账户共享同一图片。所有图标接口使用已验证身份,读取/检索/赋值均限定 shared=true 或 ownerId=当前用户,响应不返回 ownerId。个人上传默认私有;共享发布须中文名称和明确公开确认。拒绝 SVG、动图、损坏图片、超限像素/文件,重编码移除元数据。内置图标固定 ID 追加初始化;在线请求不会发送用户财务数据。
|
||||||
|
|
||||||
备份逻辑增加可选 icons 数组(旧格式缺失可兼容),v4 ZIP 将图标内容放入 icons.json;内容校验和解码在导入事务前完成,图标在事务内以当前用户私有范围重建,账户关联重映射。清空删除私有图标,公开共享图标不因发布者清空而消失。用户删除时图标 ownerId SetNull,不影响他人已引用的公共图标。
|
备份逻辑增加可选 icons 数组(旧格式缺失可兼容),v4 ZIP 将图标内容放入 icons.json;内容校验和解码在导入事务前完成,图标在事务内以当前用户私有范围重建,账户关联重映射。清空删除私有图标,公开共享图标不因发布者清空而消失。用户删除时图标 ownerId SetNull,不影响他人已引用的公共图标。
|
||||||
|
|
||||||
|
## 转账及显示偏好
|
||||||
|
|
||||||
|
Transfer 归属于 User,关联转出/转入 Position(启用资产账户)及两条 Revision ID。金额、到账金额和额外手续费使用 Decimal(24,8),记录双方原币和业务分钟;账户关系用于配对展示,Transfer 本身不再次计入总额。服务端 Serializable 事务同时创建配对绝对余额及转账记录;请求 UUID 支持相同内容重试。历史修正限制防止改变已配对转账的基准余额。
|
||||||
|
|
||||||
|
User.hiddenMenus 保存受枚举校验的菜单键,settings 不可隐藏;User.showNotes 默认 true,仅控制客户端显示,财务备注和导出保持完整。ZIP v5 新增 transfers.json,追加恢复时映射账户及配对历史 ID,空空间恢复显示偏好,已有空间保留当前设置。
|
||||||
Generated
+90
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.2.6(@nestjs/common@11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.6)
|
version: 11.2.6(@nestjs/common@11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.6)
|
||||||
|
'@nestjs/swagger':
|
||||||
|
specifier: 11.2.6
|
||||||
|
version: 11.2.6(@nestjs/common@11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.6)(reflect-metadata@0.2.2)
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: 6.19.0
|
specifier: 6.19.0
|
||||||
version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)
|
version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)
|
||||||
@@ -554,6 +557,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
'@microsoft/tsdoc@0.16.0':
|
||||||
|
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||||
@@ -592,12 +598,42 @@ packages:
|
|||||||
'@nestjs/websockets':
|
'@nestjs/websockets':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nestjs/mapped-types@2.1.0':
|
||||||
|
resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||||
|
class-transformer: ^0.4.0 || ^0.5.0
|
||||||
|
class-validator: ^0.13.0 || ^0.14.0
|
||||||
|
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
class-transformer:
|
||||||
|
optional: true
|
||||||
|
class-validator:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@nestjs/platform-express@11.2.6':
|
'@nestjs/platform-express@11.2.6':
|
||||||
resolution: {integrity: sha512-jPypI/+aueizkYG2Bw2kg8GaNsWE7ekFBSQ/Rwet7wEdBA/+q2FA364UbDabqjlR7C3VSNHmP/JXh+8+jXyDOw==}
|
resolution: {integrity: sha512-jPypI/+aueizkYG2Bw2kg8GaNsWE7ekFBSQ/Rwet7wEdBA/+q2FA364UbDabqjlR7C3VSNHmP/JXh+8+jXyDOw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@nestjs/common': ^11.0.0
|
'@nestjs/common': ^11.0.0
|
||||||
'@nestjs/core': ^11.0.0
|
'@nestjs/core': ^11.0.0
|
||||||
|
|
||||||
|
'@nestjs/swagger@11.2.6':
|
||||||
|
resolution: {integrity: sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@fastify/static': ^8.0.0 || ^9.0.0
|
||||||
|
'@nestjs/common': ^11.0.1
|
||||||
|
'@nestjs/core': ^11.0.1
|
||||||
|
class-transformer: '*'
|
||||||
|
class-validator: '*'
|
||||||
|
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@fastify/static':
|
||||||
|
optional: true
|
||||||
|
class-transformer:
|
||||||
|
optional: true
|
||||||
|
class-validator:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@prisma/client@6.19.0':
|
'@prisma/client@6.19.0':
|
||||||
resolution: {integrity: sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==}
|
resolution: {integrity: sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
@@ -769,6 +805,9 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@scarf/scarf@1.4.0':
|
||||||
|
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
@@ -870,6 +909,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
|
resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
argparse@2.0.1:
|
||||||
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
|
||||||
async@3.2.6:
|
async@3.2.6:
|
||||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||||
|
|
||||||
@@ -1287,6 +1329,10 @@ packages:
|
|||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
|
js-yaml@4.1.1:
|
||||||
|
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
jsesc@3.1.0:
|
jsesc@3.1.0:
|
||||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1305,6 +1351,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
|
resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==}
|
||||||
engines: {node: '>=13.2.0'}
|
engines: {node: '>=13.2.0'}
|
||||||
|
|
||||||
|
lodash@4.17.23:
|
||||||
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
long@5.3.2:
|
long@5.3.2:
|
||||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||||
|
|
||||||
@@ -1420,6 +1469,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
path-to-regexp@8.3.0:
|
||||||
|
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
|
||||||
|
|
||||||
path-to-regexp@8.4.2:
|
path-to-regexp@8.4.2:
|
||||||
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
||||||
|
|
||||||
@@ -1619,6 +1671,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
swagger-ui-dist@5.31.0:
|
||||||
|
resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==}
|
||||||
|
|
||||||
tar-stream@3.2.1:
|
tar-stream@3.2.1:
|
||||||
resolution: {integrity: sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==}
|
resolution: {integrity: sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==}
|
||||||
|
|
||||||
@@ -2076,6 +2131,8 @@ snapshots:
|
|||||||
|
|
||||||
'@lukeed/csprng@1.1.0': {}
|
'@lukeed/csprng@1.1.0': {}
|
||||||
|
|
||||||
|
'@microsoft/tsdoc@0.16.0': {}
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2104,6 +2161,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/platform-express': 11.2.6(@nestjs/common@11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.6)
|
'@nestjs/platform-express': 11.2.6(@nestjs/common@11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.6)
|
||||||
|
|
||||||
|
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
|
||||||
'@nestjs/platform-express@11.2.6(@nestjs/common@11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.6)':
|
'@nestjs/platform-express@11.2.6(@nestjs/common@11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -2116,6 +2178,18 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@nestjs/swagger@11.2.6(@nestjs/common@11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.6)(reflect-metadata@0.2.2)':
|
||||||
|
dependencies:
|
||||||
|
'@microsoft/tsdoc': 0.16.0
|
||||||
|
'@nestjs/common': 11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.2.6(@nestjs/common@11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/mapped-types': 2.1.0(@nestjs/common@11.2.6(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
|
||||||
|
js-yaml: 4.1.1
|
||||||
|
lodash: 4.17.23
|
||||||
|
path-to-regexp: 8.3.0
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
swagger-ui-dist: 5.31.0
|
||||||
|
|
||||||
'@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)':
|
'@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
prisma: 6.19.0(typescript@5.9.3)
|
prisma: 6.19.0(typescript@5.9.3)
|
||||||
@@ -2228,6 +2302,8 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.63.5':
|
'@rollup/rollup-win32-x64-msvc@4.63.5':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@scarf/scarf@1.4.0': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@tokenizer/inflate@0.4.1':
|
'@tokenizer/inflate@0.4.1':
|
||||||
@@ -2371,6 +2447,8 @@ snapshots:
|
|||||||
- bare-buffer
|
- bare-buffer
|
||||||
- react-native-b4a
|
- react-native-b4a
|
||||||
|
|
||||||
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
async@3.2.6: {}
|
async@3.2.6: {}
|
||||||
|
|
||||||
aws-ssl-profiles@1.1.2: {}
|
aws-ssl-profiles@1.1.2: {}
|
||||||
@@ -2814,6 +2892,10 @@ snapshots:
|
|||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
|
js-yaml@4.1.1:
|
||||||
|
dependencies:
|
||||||
|
argparse: 2.0.1
|
||||||
|
|
||||||
jsesc@3.1.0: {}
|
jsesc@3.1.0: {}
|
||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
@@ -2824,6 +2906,8 @@ snapshots:
|
|||||||
|
|
||||||
load-esm@1.0.3: {}
|
load-esm@1.0.3: {}
|
||||||
|
|
||||||
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
long@5.3.2: {}
|
long@5.3.2: {}
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
@@ -2917,6 +3001,8 @@ snapshots:
|
|||||||
|
|
||||||
parseurl@1.3.3: {}
|
parseurl@1.3.3: {}
|
||||||
|
|
||||||
|
path-to-regexp@8.3.0: {}
|
||||||
|
|
||||||
path-to-regexp@8.4.2: {}
|
path-to-regexp@8.4.2: {}
|
||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
@@ -3192,6 +3278,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
|
|
||||||
|
swagger-ui-dist@5.31.0:
|
||||||
|
dependencies:
|
||||||
|
'@scarf/scarf': 1.4.0
|
||||||
|
|
||||||
tar-stream@3.2.1:
|
tar-stream@3.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
b4a: 1.9.0
|
b4a: 1.9.0
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ allowBuilds:
|
|||||||
'@prisma/engines': true
|
'@prisma/engines': true
|
||||||
prisma: true
|
prisma: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
'@scarf/scarf': false
|
||||||
Reference in new issue
Block a user