diff --git a/.gitignore b/.gitignore index 938b48c..539d0f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.pnpm-store/ dist/ data/ playwright-report/ @@ -7,6 +8,7 @@ test-results/ .DS_Store *.log release/ +src/.umi-production/ release-signing.key *.key *.pem diff --git a/README.md b/README.md index 117e6d5..383e6da 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ pnpm build pnpm start ``` +默认开发和生产构建都使用腾讯 TDesign React 前端。需要单独检查或构建前端时,可以使用: + +```bash +pnpm check:next +pnpm build:next +``` + +`build:next` 与 `pnpm build` 一样输出到 `dist/web`,可直接由生产 Fastify 服务提供。 + 首次初始化会要求交互式输入管理员密码。也可以使用 `pnpm admin:init -- --username admin --display-name 管理员 --generate` 生成一次性临时密码。 默认地址为 `http://127.0.0.1:3000`,开发界面为 `http://127.0.0.1:5173`。配置项见 `.env.example`。 @@ -47,7 +56,7 @@ pnpm start ```bash curl --proto '=https' --tlsv1.2 -fsSL \ https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \ - | sudo bash -s -- --apply --version 1.0.1 \ + | sudo bash -s -- --apply --version 1.1.0 \ --signing-key /root/tallynote-update.pub \ --update-public-key-file /root/tallynote-update.pub ``` @@ -73,17 +82,17 @@ curl --proto '=https' --tlsv1.2 -fsSL \ ```bash pnpm install --frozen-lockfile -pnpm release:build 1.0.1 ./release +pnpm release:build 1.1.0 ./release ``` 将生成的 `tallynote-<版本>-linux-<架构>-.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS` 与 `SHA256SUMS.sig`;当前仓库还没有首个 tag/release 时,后台会明确显示不可用,不会下载未验证文件。CI 需要 `GITEA_TOKEN` 和 `TALLYNOTE_RELEASE_SIGNING_KEY` secrets。 -版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.0.1` 与 `v1.0.1`),workflow 会在构建前拒绝不一致的 tag。发布一个版本: +版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.0` 与 `v1.1.0`),workflow 会在构建前拒绝不一致的 tag。发布一个版本: ```bash git add . -git commit -m "release: 1.0.1" -git tag -a v1.0.1 -m "TallyNote 1.0.1" +git commit -m "release: 1.1.0" +git tag -a v1.1.0 -m "TallyNote 1.1.0" git push origin main --follow-tags ``` diff --git a/docs/release.md b/docs/release.md index 3995ca9..f67d6e9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -6,7 +6,7 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3` ## 自动发布 -向 Gitea 推送符合 SemVer 的 tag(例如 `v1.0.1`)会触发 `.gitea/workflows/release.yml`: +向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.0`)会触发 `.gitea/workflows/release.yml`: 1. 在 Linux runner 上安装依赖,执行 `pnpm check`、`pnpm test` 和 `pnpm release:build`。 2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。 @@ -22,14 +22,14 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3` ```bash pnpm install --frozen-lockfile pnpm check && pnpm test -pnpm release:build 1.0.1 ./release +pnpm release:build 1.1.0 ./release GITHUB_REPOSITORY=awaioi/TallyNote \ GITEA_TOKEN=... \ TALLYNOTE_RELEASE_SIGNING_KEY_FILE=/root/secrets/tallynote-release.key \ - ./scripts/publish-gitea-release.sh v1.0.1 ./release + ./scripts/publish-gitea-release.sh v1.1.0 ./release ``` -发布资产名称必须包含当前平台,例如 `tallynote-1.0.1-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS` 和一个 `SHA256SUMS.sig`,清单签名覆盖其完整原文。 +发布资产名称必须包含当前平台,例如 `tallynote-1.1.0-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS` 和一个 `SHA256SUMS.sig`,清单签名覆盖其完整原文。 ## curl 安装 @@ -38,7 +38,7 @@ TALLYNOTE_RELEASE_SIGNING_KEY_FILE=/root/secrets/tallynote-release.key \ ```bash curl --proto '=https' --tlsv1.2 -fsSL \ https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh \ - | sudo bash -s -- --apply --version 1.0.1 \ + | sudo bash -s -- --apply --version 1.1.0 \ --signing-key /root/tallynote-update.pub \ --update-public-key-file /root/tallynote-update.pub ``` diff --git a/package.json b/package.json index 55fa1dc..4405a12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.0.1", + "version": "1.1.0", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", @@ -9,13 +9,16 @@ }, "scripts": { "dev": "concurrently -k -n server,web -c cyan,magenta \"tsx watch server/index.ts\" \"vite\"", + "dev:next": "vite --config vite.next.config.ts", "build": "tsc -p tsconfig.server.json && vite build", + "build:next": "tsc -p tsconfig.web-next.json --noEmit && vite build --config vite.next.config.ts", "start": "node dist/server/index.js", "admin:init": "tsx server/cli/admin-init.ts", "release:build": "bash scripts/build-release.sh", "release:publish": "bash scripts/publish-gitea-release.sh", "db:generate": "drizzle-kit generate", - "check": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web.json --noEmit", + "check": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.web-next.json --noEmit", + "check:next": "tsc -p tsconfig.web-next.json --noEmit", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test" @@ -25,18 +28,26 @@ "@fastify/helmet": "^13.0.2", "@fastify/multipart": "^9.2.1", "@fastify/static": "^10.1.3", + "@fontsource-variable/plus-jakarta-sans": "5.3.0", + "@reduxjs/toolkit": "2.12.0", "archiver": "^8.0.0", "argon2": "^0.44.0", "better-sqlite3": "^12.2.0", "drizzle-orm": "^0.45.2", + "echarts": "6.1.0", + "echarts-for-react": "3.0.6", "exceljs": "^4.4.0", "fast-xml-parser": "^5.2.5", "fastify": "^5.4.0", + "less": "4.4.1", "lucide-react": "^0.542.0", "pdf-lib": "^1.17.1", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-redux": "9.2.0", + "react-router-dom": "7.18.3", "sharp": "^0.35.4", + "tdesign-react": "1.18.2", "yauzl": "^3.2.0", "zod": "^4.1.5" }, diff --git a/playwright.config.ts b/playwright.config.ts index 3dfaa3b..e0f8811 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,25 +1,33 @@ import { defineConfig, devices } from "@playwright/test"; +import { mkdtempSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Every E2E run must start from an uninitialized ledger. A unique temporary +// directory keeps the login smoke test independent of previous runs. +const e2eDataDir = mkdtempSync(path.join(os.tmpdir(), "tallynote-e2e-")); +const e2ePort = Number(process.env.TALLYNOTE_E2E_PORT ?? 3400); export default defineConfig({ testDir: "./tests/e2e", timeout: 30_000, use: { - baseURL: "http://127.0.0.1:3400", + baseURL: `http://127.0.0.1:${e2ePort}`, trace: "retain-on-failure", ...devices["Desktop Chrome"], }, webServer: { command: "node dist/server/index.js", - url: "http://127.0.0.1:3400/health", + url: `http://127.0.0.1:${e2ePort}/health`, reuseExistingServer: false, timeout: 120_000, env: { NODE_ENV: "production", TALLYNOTE_HOST: "127.0.0.1", - TALLYNOTE_PORT: "3400", - TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3400", + TALLYNOTE_PORT: String(e2ePort), + TALLYNOTE_PUBLIC_ORIGIN: `http://127.0.0.1:${e2ePort}`, TALLYNOTE_COOKIE_SECURE: "false", - TALLYNOTE_DATA_DIR: "/tmp/tallynote-e2e", + TALLYNOTE_DATA_DIR: e2eDataDir, }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a774ca..8705934 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,12 @@ importers: '@fastify/static': specifier: ^10.1.3 version: 10.1.3 + '@fontsource-variable/plus-jakarta-sans': + specifier: 5.3.0 + version: 5.3.0 + '@reduxjs/toolkit': + specifier: 2.12.0 + version: 2.12.0(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8) archiver: specifier: ^8.0.0 version: 8.0.0 @@ -35,6 +41,12 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1) + echarts: + specifier: 6.1.0 + version: 6.1.0 + echarts-for-react: + specifier: 3.0.6 + version: 3.0.6(echarts@6.1.0)(react@19.2.8) exceljs: specifier: ^4.4.0 version: 4.4.0 @@ -44,6 +56,9 @@ importers: fastify: specifier: ^5.4.0 version: 5.12.1 + less: + specifier: 4.4.1 + version: 4.4.1 lucide-react: specifier: ^0.542.0 version: 0.542.0(react@19.2.8) @@ -56,9 +71,18 @@ importers: react-dom: specifier: ^19.1.1 version: 19.2.8(react@19.2.8) + react-redux: + specifier: 9.2.0 + version: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + react-router-dom: + specifier: 7.18.3 + version: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) sharp: specifier: ^0.35.4 version: 0.35.4(@types/node@24.13.3) + tdesign-react: + specifier: 1.18.2 + version: 1.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) yauzl: specifier: ^3.2.0 version: 3.4.0 @@ -89,7 +113,7 @@ importers: version: 2.10.3 '@vitejs/plugin-react': specifier: ^5.0.2 - version: 5.2.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.12)) + version: 5.2.0(vite@7.3.6(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12)) concurrently: specifier: ^9.2.1 version: 9.2.4 @@ -104,10 +128,10 @@ importers: version: 5.9.3 vite: specifier: ^7.1.3 - version: 7.3.6(@types/node@24.13.3)(tsx@4.23.12) + version: 7.3.6(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/node@24.13.3)(tsx@4.23.12) + version: 3.2.7(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12) packages: @@ -182,6 +206,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.26.10': + resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -703,6 +731,9 @@ packages: '@fastify/static@10.1.3': resolution: {integrity: sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==} + '@fontsource-variable/plus-jakarta-sans@5.3.0': + resolution: {integrity: sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -913,6 +944,20 @@ packages: engines: {node: '>=20'} hasBin: true + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -1054,6 +1099,12 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@types/archiver@8.0.0': resolution: {integrity: sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A==} @@ -1098,6 +1149,15 @@ packages: '@types/readdir-glob@1.1.5': resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/sortablejs@1.15.9': + resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -1343,6 +1403,9 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1385,6 +1448,9 @@ packages: resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} engines: {node: '>=22'} + copy-anything@2.0.6: + resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -1413,6 +1479,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + dayjs@1.11.10: + resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} + dayjs@1.11.23: resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} @@ -1449,6 +1518,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -1548,6 +1620,15 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + echarts-for-react@3.0.6: + resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==} + peerDependencies: + echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 + react: ^15.0.0 || >=16.0.0 + + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + electron-to-chromium@1.5.415: resolution: {integrity: sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==} @@ -1557,6 +1638,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + errno@0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1725,16 +1810,31 @@ packages: resolution: {integrity: sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==} engines: {node: '>=18.0.0'} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + image-size@0.5.5: + resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} + engines: {node: '>=0.10.0'} + hasBin: true + immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@11.1.18: + resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -1760,6 +1860,9 @@ packages: is-unsafe@2.0.2: resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} + is-what@3.14.1: + resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -1795,6 +1898,11 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + less@4.4.1: + resolution: {integrity: sha512-X9HKyiXPi0f/ed0XhgUlBeFfxrlDP3xR4M7768Zl+WXLUViuL9AOPPJP4nCV0tgRWvTYvpNmN0SFhZOQzy16PA==} + engines: {node: '>=14'} + hasBin: true + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -1804,6 +1912,9 @@ packages: listenercount@1.0.1: resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -1844,6 +1955,10 @@ packages: lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -1862,6 +1977,15 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -1889,6 +2013,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -1907,6 +2034,11 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + needle@3.5.0: + resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==} + engines: {node: '>= 4.4.x'} + hasBin: true + node-abi@3.95.0: resolution: {integrity: sha512-T9iGctuocf0qIWFFOTxPzjT5q0SILqaBYXt272tlBHvTKC5+3JnkMirLxNJNkXHtFyBjU2Jx+NL4Zipr0B/c6Q==} engines: {node: '>=10'} @@ -1927,6 +2059,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -1937,6 +2073,10 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-node-version@1.0.1: + resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} + engines: {node: '>= 0.10'} + path-expression-matcher@1.6.2: resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} engines: {node: '>=14.0.0'} @@ -1966,6 +2106,9 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1973,6 +2116,10 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} @@ -2016,12 +2163,21 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + prr@1.0.1: + resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -2031,10 +2187,54 @@ packages: peerDependencies: react: ^19.2.8 + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} + react-router-dom@7.18.3: + resolution: {integrity: sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.3: + resolution: {integrity: sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -2064,6 +2264,17 @@ packages: real-require@1.0.0: resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2072,6 +2283,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2113,6 +2327,13 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + saxes@5.0.1: resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} engines: {node: '>=10'} @@ -2123,6 +2344,10 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2171,9 +2396,15 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + size-sensor@1.0.3: + resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + sortablejs@1.15.7: + resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2244,6 +2475,18 @@ packages: tar-stream@3.2.1: resolution: {integrity: sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==} + tdesign-icons-react@0.6.11: + resolution: {integrity: sha512-zH9Wr7QvX5y3WRF9hvm25ci04SLaPHB9SPN2FfWMHQf9f5ITmli+3abJkGwcePTeTYE4vmNBnAy6dxRSMCui1g==} + peerDependencies: + react: '>=16.13.1' + react-dom: '>=16.13.1' + + tdesign-react@1.18.2: + resolution: {integrity: sha512-cjM9qVbMZpguigWkScOG27b8+T7WIx5r4M8z4da5nnPrn38vLUwMMwYBeP+VHJUKEdlHjcPUb8rSiXUCivslKw==} + peerDependencies: + react: '>=16.13.1' + react-dom: '>=16.13.1' + teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -2298,6 +2541,12 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + tslib@2.3.1: + resolution: {integrity: sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2326,6 +2575,11 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2333,6 +2587,10 @@ packages: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2460,6 +2718,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + snapshots: '@babel/code-frame@7.29.7': @@ -2551,6 +2812,10 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.26.10': + dependencies: + regenerator-runtime: 0.14.1 + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -2899,6 +3164,8 @@ snapshots: fastq: 1.20.2 glob: 13.0.6 + '@fontsource-variable/plus-jakarta-sans@5.3.0': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.35.4': @@ -3047,6 +3314,20 @@ snapshots: dependencies: playwright: 1.62.1 + '@popperjs/core@2.11.8': {} + + '@reduxjs/toolkit@2.12.0(react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.18 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.3.0 + optionalDependencies: + react: 19.2.8 + react-redux: 9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.63.0': @@ -3124,6 +3405,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.0': optional: true + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + '@types/archiver@8.0.0': dependencies: '@types/node': 24.13.3 @@ -3181,11 +3466,17 @@ snapshots: dependencies: '@types/node': 24.13.3 + '@types/sortablejs@1.15.9': {} + + '@types/use-sync-external-store@0.0.6': {} + + '@types/validator@13.15.10': {} + '@types/yauzl@2.10.3': dependencies: '@types/node': 24.13.3 - '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.12))': + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -3193,7 +3484,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.12) + vite: 7.3.6(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12) transitivePeerDependencies: - supports-color @@ -3205,13 +3496,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.12))': + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.12) + vite: 7.3.6(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12) '@vitest/pretty-format@3.2.7': dependencies: @@ -3463,6 +3754,8 @@ snapshots: chownr@1.1.4: {} + classnames@2.5.1: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -3509,6 +3802,10 @@ snapshots: cookie@2.0.1: {} + copy-anything@2.0.6: + dependencies: + is-what: 3.14.1 + core-util-is@1.0.3: {} crc-32@1.2.2: {} @@ -3536,6 +3833,8 @@ snapshots: csstype@3.2.3: {} + dayjs@1.11.10: {} + dayjs@1.11.23: {} debug@4.4.3: @@ -3556,6 +3855,11 @@ snapshots: detect-libc@2.1.2: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.26.10 + csstype: 3.2.3 + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -3572,6 +3876,18 @@ snapshots: dependencies: readable-stream: 2.3.8 + echarts-for-react@3.0.6(echarts@6.1.0)(react@19.2.8): + dependencies: + echarts: 6.1.0 + fast-deep-equal: 3.1.3 + react: 19.2.8 + size-sensor: 1.0.3 + + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + electron-to-chromium@1.5.415: {} emoji-regex@8.0.0: {} @@ -3580,6 +3896,11 @@ snapshots: dependencies: once: 1.4.0 + errno@0.1.8: + dependencies: + prr: 1.0.1 + optional: true + es-module-lexer@1.7.0: {} esbuild@0.18.20: @@ -3831,6 +4152,10 @@ snapshots: helmet@8.3.0: {} + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -3839,10 +4164,20 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + ieee754@1.2.1: {} + image-size@0.5.5: + optional: true + immediate@3.0.6: {} + immer@11.1.18: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -3860,6 +4195,8 @@ snapshots: is-unsafe@2.0.2: {} + is-what@3.14.1: {} + isarray@1.0.0: {} isexe@2.0.0: {} @@ -3889,6 +4226,20 @@ snapshots: dependencies: readable-stream: 2.3.8 + less@4.4.1: + dependencies: + copy-anything: 2.0.6 + parse-node-version: 1.0.1 + tslib: 2.8.1 + optionalDependencies: + errno: 0.1.8 + graceful-fs: 4.2.11 + image-size: 0.5.5 + make-dir: 2.1.0 + mime: 1.6.0 + needle: 3.5.0 + source-map: 0.6.1 + lie@3.3.0: dependencies: immediate: 3.0.6 @@ -3901,6 +4252,8 @@ snapshots: listenercount@1.0.1: {} + lodash-es@4.18.1: {} + lodash.defaults@4.2.0: {} lodash.difference@4.5.0: {} @@ -3927,6 +4280,10 @@ snapshots: lodash.uniq@4.5.0: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.2.1: {} lru-cache@11.5.2: {} @@ -3943,6 +4300,15 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@2.1.0: + dependencies: + pify: 4.0.1 + semver: 5.7.2 + optional: true + + mime@1.6.0: + optional: true + mime@3.0.0: {} mimic-response@3.1.0: {} @@ -3963,6 +4329,8 @@ snapshots: minipass@7.1.3: {} + mitt@3.0.1: {} + mkdirp-classic@0.5.3: {} mkdirp@0.5.6: @@ -3975,6 +4343,12 @@ snapshots: napi-build-utils@2.0.0: {} + needle@3.5.0: + dependencies: + iconv-lite: 0.6.3 + sax: 1.6.1 + optional: true + node-abi@3.95.0: dependencies: semver: 7.8.5 @@ -3987,6 +4361,8 @@ snapshots: normalize-path@3.0.0: {} + object-assign@4.1.1: {} + on-exit-leak-free@2.1.2: {} once@1.4.0: @@ -3995,6 +4371,8 @@ snapshots: pako@1.0.11: {} + parse-node-version@1.0.1: {} + path-expression-matcher@1.6.2: {} path-is-absolute@1.0.1: {} @@ -4019,10 +4397,15 @@ snapshots: pend@1.2.0: {} + performance-now@2.1.0: {} + picocolors@1.1.1: {} picomatch@4.0.7: {} + pify@4.0.1: + optional: true + pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 @@ -4080,6 +4463,15 @@ snapshots: process@0.11.10: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + prr@1.0.1: + optional: true + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -4087,6 +4479,10 @@ snapshots: quick-format-unescaped@4.0.4: {} + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -4099,8 +4495,46 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 + react-fast-compare@3.2.2: {} + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-redux@9.2.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.8 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + redux: 5.0.1 + react-refresh@0.18.0: {} + react-router-dom@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-router: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + + react-router@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.1.1 + react: 19.2.8 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react-transition-group@4.4.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@babel/runtime': 7.26.10 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react@19.2.8: {} readable-stream@2.3.8: @@ -4139,10 +4573,20 @@ snapshots: real-require@1.0.0: {} + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + regenerator-runtime@0.14.1: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} + reselect@5.3.0: {} + resolve-pkg-maps@1.0.0: {} ret@0.5.0: {} @@ -4201,6 +4645,12 @@ snapshots: safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: + optional: true + + sax@1.6.1: + optional: true + saxes@5.0.1: dependencies: xmlchars: 2.2.0 @@ -4209,6 +4659,9 @@ snapshots: secure-json-parse@4.1.0: {} + semver@5.7.2: + optional: true + semver@6.3.1: {} semver@7.8.5: {} @@ -4270,10 +4723,14 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + size-sensor@1.0.3: {} + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 + sortablejs@1.15.7: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -4362,6 +4819,35 @@ snapshots: - bare-buffer - react-native-b4a + tdesign-icons-react@0.6.11(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@babel/runtime': 7.26.10 + classnames: 2.5.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + tdesign-react@1.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@babel/runtime': 7.26.10 + '@popperjs/core': 2.11.8 + '@types/sortablejs': 1.15.9 + '@types/validator': 13.15.10 + classnames: 2.5.1 + dayjs: 1.11.10 + hoist-non-react-statics: 3.3.2 + lodash-es: 4.18.1 + mitt: 3.0.1 + raf: 3.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-fast-compare: 3.2.2 + react-is: 18.3.1 + react-transition-group: 4.4.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + sortablejs: 1.15.7 + tdesign-icons-react: 0.6.11(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tslib: 2.3.1 + validator: 13.15.35 + teex@1.0.1: dependencies: streamx: 2.28.1 @@ -4406,6 +4892,10 @@ snapshots: tslib@1.14.1: {} + tslib@2.3.0: {} + + tslib@2.3.1: {} + tslib@2.8.1: {} tsx@4.23.12: @@ -4441,17 +4931,23 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + util-deprecate@1.0.2: {} uuid@11.1.1: {} - vite-node@3.2.4(@types/node@24.13.3)(tsx@4.23.12): + validator@13.15.35: {} + + vite-node@3.2.4(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.12) + vite: 7.3.6(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12) transitivePeerDependencies: - '@types/node' - jiti @@ -4466,7 +4962,7 @@ snapshots: - tsx - yaml - vite@7.3.6(@types/node@24.13.3)(tsx@4.23.12): + vite@7.3.6(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.7) @@ -4477,13 +4973,14 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 fsevents: 2.3.3 + less: 4.4.1 tsx: 4.23.12 - vitest@3.2.7(@types/node@24.13.3)(tsx@4.23.12): + vitest@3.2.7(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.12)) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -4501,8 +4998,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.12) - vite-node: 3.2.4(@types/node@24.13.3)(tsx@4.23.12) + vite: 7.3.6(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12) + vite-node: 3.2.4(@types/node@24.13.3)(less@4.4.1)(tsx@4.23.12) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 @@ -4574,3 +5071,7 @@ snapshots: readable-stream: 4.7.0 zod@4.4.3: {} + + zrender@6.1.0: + dependencies: + tslib: 2.3.0 diff --git a/server/app.ts b/server/app.ts index 13acd35..33310e1 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { lstat, rm, stat, unlink } from "node:fs/promises"; +import { rm, stat, unlink } from "node:fs/promises"; import path from "node:path"; import { randomUUID } from "node:crypto"; import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; @@ -393,8 +393,8 @@ function filteredExpenses(database: DatabaseContext, config: AppConfig, query: z `).all(query.status, start, end, `%${escaped}%`) as ExpenseRow[]; } -function enqueueFileDeletion(database: DatabaseContext, storagePath: string, reason: string): void { - database.sqlite.prepare(` +function enqueueFileDeletion(sqlite: DatabaseContext["sqlite"], storagePath: string, reason: string): void { + sqlite.prepare(` INSERT INTO file_deletions(id, storage_path, reason, status, attempts, created_at) VALUES (?, ?, ?, 'pending', 0, ?) `).run(randomUUID(), storagePath, reason, Date.now()); @@ -426,13 +426,14 @@ function clearReauthFailures(database: DatabaseContext, request: FastifyRequest, database.sqlite.prepare("DELETE FROM login_attempts WHERE key_hash=?").run(reauthKey(request, adminId)); } -async function parseExpenseMultipart(request: FastifyRequest, config: AppConfig): Promise<{ fields: Record; files: StagedFile[] }> { - const fields: Record = {}; - const files: StagedFile[] = []; +async function parseExpenseMultipart(request: FastifyRequest, config: AppConfig, options: { allowVersion?: boolean } = {}): Promise<{ fields: Record; files: StagedFile[] }> { + const fields: Record = {}; + const files: StagedFile[] = []; + const allowedFields = new Set(["paidAt", "amount", "note", "invoiceMissingReason", ...(options.allowVersion ? ["version"] : [])]); try { for await (const part of request.parts()) { if (part.type === "field") { - if (!["paidAt", "amount", "note", "invoiceMissingReason"].includes(part.fieldname)) { + if (!allowedFields.has(part.fieldname)) { throw new AppError(400, "UNKNOWN_FIELD", "存在未知表单字段"); } if (part.fieldname in fields) { @@ -456,18 +457,127 @@ async function parseExpenseMultipart(request: FastifyRequest, config: AppConfig) } } -async function promoteAll(config: AppConfig, files: StagedFile[]): Promise> { +function promotedRelativePath(file: StagedFile): string { + return path.join(file.id.slice(0, 2), `${file.id}.${file.extension}`); +} + +async function cleanupPromotedFiles(sqlite: DatabaseContext["sqlite"] | undefined, config: AppConfig, files: Array): Promise { + await Promise.all(files.map(async (file) => { + const relative = file.storagePath || promotedRelativePath(file); + try { + await unlink(safeStoragePath(config.filesDir, relative)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return; + if (sqlite) { + try { enqueueFileDeletion(sqlite, relative, "attachment_rollback"); } catch { /* database may already be closing */ } + } + } + })); +} + +async function promoteAll(config: AppConfig, files: StagedFile[], sqlite?: DatabaseContext["sqlite"]): Promise> { const promoted: Array = []; try { for (const file of files) promoted.push({ ...file, storagePath: await promoteStagedFile(config, file) }); return promoted; } catch (error) { - await Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined))); + // Include the file currently being promoted: rename() may have succeeded + // before a directory sync/close error was raised. + await cleanupPromotedFiles(sqlite, config, files); await discardStaged(files); throw error; } } +async function updateExpenseMultipart(database: DatabaseContext, config: AppConfig, request: FastifyRequest, id: string) { + const { fields, files } = await parseExpenseMultipart(request, config, { allowVersion: true }); + let input: z.infer; + try { + input = expenseUpdateSchema.parse({ + paidAt: fields.paidAt, + amount: fields.amount, + note: fields.note ?? "", + invoiceMissingReason: fields.invoiceMissingReason, + version: fields.version === undefined ? undefined : Number(fields.version), + }); + } catch (error) { + await discardStaged(files); + throw error; + } + + const before = getExpense(database, id); + if (!before) { await discardStaged(files); notFound("账目不存在"); } + if (before.version !== input.version) { await discardStaged(files); conflict(database, id); } + const paymentProofs = files.filter((file) => file.kind === "payment_proof"); + const invoiceFiles = files.filter((file) => file.kind === "invoice"); + // Adding an invoice supersedes the previous no-invoice explanation. This + // mirrors the standalone attachment endpoint and keeps the two states + // mutually exclusive even when a client omits the optional field. + const requestedReason = invoiceFiles.length > 0 + ? null + : input.invoiceMissingReason === undefined + ? before.invoiceMissingReason + : normalizeInvoiceMissingReason(input.invoiceMissingReason); + const nextInvoiceCount = Number(before.invoiceCount) + invoiceFiles.length; + const nextProofCount = Number(before.paymentProofCount) + paymentProofs.length; + if (nextProofCount < 1) { await discardStaged(files); throw new AppError(400, "PAYMENT_PROOF_REQUIRED", "至少需要一张付款凭证"); } + try { assertInvoiceCoverage(nextInvoiceCount, requestedReason); } catch (error) { await discardStaged(files); throw error; } + const addedBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0); + const currentBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments WHERE expense_id=?").get(id) as { total: number }).total; + if (currentBytes + addedBytes > config.maxRecordBytes) { await discardStaged(files); throw new AppError(413, "RECORD_ATTACHMENTS_TOO_LARGE", "该记录的附件总大小超过限制"); } + const globalBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments").get() as { total: number }).total; + if (globalBytes + addedBytes > config.maxTotalBytes) { await discardStaged(files); throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据"); } + const paidAt = Date.parse(input.paidAt); + if (!Number.isFinite(paidAt)) { await discardStaged(files); throw new AppError(400, "VALIDATION_ERROR", "支付时间无效"); } + let amountCents: number; + try { + amountCents = amountToCents(input.amount); + } catch { + await discardStaged(files); + throw new AppError(400, "VALIDATION_ERROR", "金额必须为大于零且最多两位小数"); + } + const promoted = await promoteAll(config, files, database.sqlite); + const now = Date.now(); + try { + database.sqlite.transaction(() => { + const current = getExpense(database, id); + if (!current) notFound("账目不存在"); + if (current.version !== input.version) conflict(database, id); + const invoiceCount = Number(current.invoiceCount) + invoiceFiles.length; + const proofCount = Number(current.paymentProofCount) + paymentProofs.length; + if (proofCount < 1) throw new AppError(400, "PAYMENT_PROOF_REQUIRED", "至少需要一张付款凭证"); + const invoiceMissingReason = invoiceFiles.length > 0 ? null : (input.invoiceMissingReason === undefined ? current.invoiceMissingReason : normalizeInvoiceMissingReason(input.invoiceMissingReason)); + assertInvoiceCoverage(invoiceCount, invoiceMissingReason); + const liveBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments WHERE expense_id=?").get(id) as { total: number }).total; + if (liveBytes + addedBytes > config.maxRecordBytes) throw new AppError(413, "RECORD_ATTACHMENTS_TOO_LARGE", "该记录的附件总大小超过限制"); + const allBytes = (database.sqlite.prepare("SELECT COALESCE(SUM(size_bytes),0) AS total FROM attachments").get() as { total: number }).total; + if (allBytes + addedBytes > config.maxTotalBytes) throw new AppError(413, "TOTAL_STORAGE_LIMIT", "附件存储空间已达到上限,请先清理旧数据"); + const updated = database.sqlite.prepare("UPDATE expenses SET paid_at=?, amount_cents=?, note=?, invoice_missing_reason=?, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL") + .run(paidAt, amountCents, input.note, invoiceMissingReason, now, request.auth!.admin.id, id, input.version); + if (updated.changes !== 1) conflict(database, id); + const insert = database.sqlite.prepare("INSERT INTO attachments(id, expense_id, kind, storage_path, original_name, mime_type, size_bytes, sha256, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + for (const file of promoted) insert.run(file.id, id, file.kind, file.storagePath, file.originalName, file.mimeType, file.sizeBytes, file.sha256, now, request.auth!.admin.id); + writeAudit(database.sqlite, { + requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, + action: "expense.updated", targetType: "expense", targetId: id, + before: { paidAt: current.paidAt, amountCents: current.amountCents, note: current.note, invoiceMissingReason: current.invoiceMissingReason, version: current.version }, + after: { paidAt, amountCents, note: input.note, invoiceMissingReason, version: input.version + 1, attachmentCount: promoted.length }, + }); + if (promoted.length > 0) writeAudit(database.sqlite, { + requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, + action: "expense.attachments_added", targetType: "expense", targetId: id, + before: { invoiceCount: Number(current.invoiceCount), paymentProofCount: Number(current.paymentProofCount), version: current.version }, + after: { invoiceCount, paymentProofCount: proofCount, version: input.version + 1, files: promoted.map((file) => ({ id: file.id, name: file.originalName, size: file.sizeBytes })) }, + }); + }).immediate(); + } catch (error) { + await cleanupPromotedFiles(database.sqlite, config, promoted); + throw error; + } + return { expense: publicExpense(database, getExpense(database, id)!, true) }; +} + function conflict(database: DatabaseContext, id: string): never { const current = getExpense(database, id, true); if (!current) notFound("账目不存在"); @@ -836,7 +946,11 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { app.post("/api/update/check", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => { try { - enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply); + // Disabled/dev installs do not contact a release endpoint, so repeated + // checks are local status reads and should remain immediately usable. + if (config.updateStrategy !== "disabled") { + enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "check", reply); + } const result = await checkForUpdate(database.sqlite, config); writeAudit(database.sqlite, { requestId: request.id, @@ -1067,7 +1181,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { await discardStaged(files); throw new AppError(400, "VALIDATION_ERROR", "金额必须为大于零且最多两位小数"); } - const promoted = await promoteAll(config, files); + const promoted = await promoteAll(config, files, database.sqlite); const id = randomUUID(); const now = Date.now(); try { @@ -1110,7 +1224,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { }); }).immediate(); } catch (error) { - await Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined))); + await cleanupPromotedFiles(database.sqlite, config, promoted); throw error; } const created = getExpense(database, id)!; @@ -1119,6 +1233,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { app.patch("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => { const id = z.string().uuid().parse((request.params as { id: string }).id); + if (request.isMultipart()) { + return updateExpenseMultipart(database, config, request, id); + } const input = expenseUpdateSchema.parse(request.body); const paidAt = Date.parse(input.paidAt); if (!Number.isFinite(paidAt)) throw new AppError(400, "VALIDATION_ERROR", "支付时间无效"); @@ -1228,7 +1345,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { throw error; } if (staged.length < 1) throw new AppError(400, "FILE_REQUIRED", "请选择至少一个附件"); - const promoted = await promoteAll(config, staged); + const promoted = await promoteAll(config, staged, database.sqlite); const now = Date.now(); try { database.sqlite.transaction(() => { @@ -1276,7 +1393,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { }); }).immediate(); } catch (error) { - await Promise.all(promoted.map((file) => unlink(safeStoragePath(config.filesDir, file.storagePath)).catch(() => undefined))); + await cleanupPromotedFiles(database.sqlite, config, promoted); throw error; } return { expense: publicExpense(database, getExpense(database, id)!, true) }; @@ -1315,7 +1432,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { const updated = database.sqlite.prepare("UPDATE expenses SET invoice_missing_reason=?, version=version+1, updated_at=?, updated_by=? WHERE id=? AND version=? AND deleted_at IS NULL") .run(nextInvoiceMissingReason, now, request.auth!.admin.id, expense.id, input.version); if (updated.changes !== 1) conflict(database, expense.id); - enqueueFileDeletion(database, attachment.storagePath, "attachment_deleted"); + enqueueFileDeletion(database.sqlite, attachment.storagePath, "attachment_deleted"); writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, @@ -1355,10 +1472,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { WHERE a.id=? AND e.deleted_at IS NULL `).get(id) as AttachmentRow | undefined; if (!attachment) notFound("附件不存在"); + let stream: Awaited>; try { - const fileInfo = await lstat(safeStoragePath(config.filesDir, attachment.storagePath)); - if (!fileInfo.isFile() || fileInfo.isSymbolicLink()) throw new Error("attachment type"); - } catch { + stream = await fileReadStream(config, attachment.storagePath); + } catch (error) { writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, @@ -1367,9 +1484,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { targetType: "expense", targetId: attachment.expenseId, outcome: "failure", - metadata: { attachmentId: attachment.id, mode: "missing" }, + metadata: { attachmentId: attachment.id, mode: "unavailable" }, }); - throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用"); + throw error; } const download = (request.query as { download?: string }).download === "1"; const inline = !download && (attachment.mimeType.startsWith("image/") || attachment.mimeType === "application/pdf"); @@ -1393,7 +1510,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { reply.header("X-Frame-Options", "SAMEORIGIN"); } reply.header("Content-Disposition", `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`); - return reply.send(await fileReadStream(config, attachment.storagePath)); + return reply.send(stream); }); app.delete("/api/expenses/:id", { preHandler: guard(database, config) }, async (request) => { @@ -1484,7 +1601,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { const current = database.sqlite.prepare("SELECT version, deleted_at AS deletedAt FROM expenses WHERE id=?").get(id) as { version: number; deletedAt: number | null } | undefined; if (!current || !current.deletedAt) notFound("回收站中没有该账目"); if (current.version !== input.version) conflict(database, id); - for (const attachment of attachmentRows) enqueueFileDeletion(database, attachment.storagePath, "expense_purged"); + for (const attachment of attachmentRows) enqueueFileDeletion(database.sqlite, attachment.storagePath, "expense_purged"); const jobs = database.sqlite.prepare("SELECT id, status, file_path AS filePath, snapshot_json AS snapshotJson FROM export_jobs WHERE status IN ('queued','building','ready')").all() as Array<{ id: string; status: string; filePath: string | null; snapshotJson: string }>; for (const job of jobs) { const snapshot = JSON.parse(job.snapshotJson) as ExportSnapshot; @@ -1642,7 +1759,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { const hasWeb = existsSync(config.webDir); if (hasWeb) { - await app.register(fastifyStatic, { root: config.webDir, wildcard: false }); + // Serve the Vite asset graph as well as the SPA entry. API routes are + // registered above and remain authoritative for /api/* paths. + await app.register(fastifyStatic, { root: config.webDir, wildcard: true, index: "index.html" }); } // Keep API errors structured even when the production frontend has not been // built yet (for example in a clean CI checkout or an API-only process). diff --git a/server/exporter.ts b/server/exporter.ts index af19160..08d5859 100644 --- a/server/exporter.ts +++ b/server/exporter.ts @@ -89,7 +89,7 @@ async function workbookBuffer(snapshot: ExportSnapshot, config: AppConfig): Prom { header: "无发票原因", key: "invoiceMissingReason", width: 44 }, ]; sheet.getRow(1).font = { bold: true, color: { argb: "FFFFFFFF" } }; - sheet.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1F4D43" } }; + sheet.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF175CD3" } }; sheet.getRow(1).height = 24; snapshot.expenses.forEach((expense, index) => { const row = sheet.addRow({ @@ -210,7 +210,8 @@ async function buildExportJobOnce(sqlite: Database.Database, config: AppConfig, // Never expose filesystem paths, attachment IDs, or raw OS errors through // the export status API. Keep a small allowlist of actionable messages. const raw = error instanceof Error ? error.message : ""; - const safe = raw.startsWith("附件校验失败") || raw.includes("ENOENT") + const safe = (error as { code?: unknown } | null)?.code === "ATTACHMENT_MISSING" + || raw.startsWith("附件校验失败") || raw.includes("ENOENT") ? "导出失败:附件文件缺失或校验不通过" : "导出失败:服务器无法生成导出文件"; sqlite.prepare("UPDATE export_jobs SET status='failed', error_message=? WHERE id=? AND status='building'").run(safe, jobId); diff --git a/server/files.ts b/server/files.ts index 56e7eae..4e441cd 100644 --- a/server/files.ts +++ b/server/files.ts @@ -226,26 +226,47 @@ export async function fileReadStream(config: AppConfig, storagePath: string) { return safeReadStream(config.filesDir, storagePath); } +function mapReadError(error: unknown): unknown { + if (error instanceof AppError) return error; + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP") { + return new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用"); + } + return error; +} + /** Open a private file by descriptor and keep the no-follow guarantee through * the subsequent read. Used for both attachment and export downloads. */ export async function safeReadStream(root: string, relativePath: string) { - const handle = await open(safeStoragePath(root, relativePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)); + let handle: Awaited>; + try { + handle = await open(safeStoragePath(root, relativePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)); + } catch (error) { + throw mapReadError(error); + } try { const info = await handle.stat(); if (!info.isFile()) throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用"); return handle.createReadStream({ autoClose: true }); } catch (error) { await handle.close().catch(() => undefined); - throw error; + throw mapReadError(error); } } export async function readStorageFile(config: AppConfig, storagePath: string): Promise { - const handle = await open(safeStoragePath(config.filesDir, storagePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)); + let handle: Awaited>; + try { + handle = await open(safeStoragePath(config.filesDir, storagePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0)); + } catch (error) { + throw mapReadError(error); + } try { const info = await handle.stat(); if (!info.isFile()) throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用"); return await handle.readFile(); + } catch (error) { + throw mapReadError(error); } finally { await handle.close(); } diff --git a/shared/contracts.ts b/shared/contracts.ts index edfee52..a286567 100644 --- a/shared/contracts.ts +++ b/shared/contracts.ts @@ -95,7 +95,9 @@ export type UpdateJobStatus = z.infer; /** The browser never supplies release URLs or filesystem paths. */ export const updateApplySchema = z.object({ - version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/), + // Keep the browser contract aligned with server/update.ts' SemVer parser, + // including optional prerelease and build metadata segments. + version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/), confirm: z.literal(true), }).strict(); diff --git a/tests/api.test.ts b/tests/api.test.ts index 022e3d6..576c980 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -392,4 +392,98 @@ describe("TallyNote API", () => { expect(deleted.json().expense.invoiceCount).toBe(0); expect(deleted.json().expense.invoiceMissingReason).toBe("原始发票文件已丢失,无法重新取得"); }); + + it("组合 multipart 编辑一次提交字段和附件,并只递增一次版本", async () => { + const session = await login(); + const initial = multipart([ + { name: "paidAt", value: "2026-08-27T12:00:00.000Z" }, + { name: "amount", value: "12.34" }, + { name: "note", value: "组合编辑前" }, + { name: "invoiceMissingReason", value: "供应商暂未开票" }, + { name: "paymentProofs", filename: "proof-a.png", contentType: "image/png", data: tinyPng }, + ]); + const created = await app.inject({ + method: "POST", + url: "/api/expenses", + headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": initial.contentType }, + payload: initial.body, + }); + expect(created.statusCode).toBe(201); + const before = created.json().expense as { id: string; version: number; paymentProofCount: number; invoiceCount: number }; + + const edit = multipart([ + { name: "paidAt", value: "2026-08-28T13:30:00.000Z" }, + { name: "amount", value: "18.90" }, + { name: "note", value: "组合编辑后" }, + { name: "version", value: String(before.version) }, + { name: "paymentProofs", filename: "proof-b.png", contentType: "image/png", data: tinyPng }, + { name: "invoices", filename: "invoice.xml", contentType: "application/xml", data: Buffer.from("") }, + ]); + const updated = await app.inject({ + method: "PATCH", + url: `/api/expenses/${before.id}`, + headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": edit.contentType }, + payload: edit.body, + }); + expect(updated.statusCode).toBe(200); + const result = updated.json().expense as { version: number; amountCents: number; note: string; invoiceMissingReason: string | null; paymentProofCount: number; invoiceCount: number; attachments: Array<{ originalName: string }> }; + expect(result).toMatchObject({ version: before.version + 1, amountCents: 1890, note: "组合编辑后", invoiceMissingReason: null, paymentProofCount: 2, invoiceCount: 1 }); + expect(result.attachments.map((item) => item.originalName)).toEqual(expect.arrayContaining(["proof-a.png", "proof-b.png", "invoice.xml"])); + const auditCount = (database.sqlite.prepare("SELECT COUNT(*) AS count FROM audit_events WHERE target_id=? AND action='expense.updated'").get(before.id) as { count: number }).count; + expect(auditCount).toBe(1); + }); + + it("组合编辑版本冲突或金额非法时不落附件也不改变账目", async () => { + const session = await login(); + const initial = multipart([ + { name: "paidAt", value: "2026-08-27T12:00:00.000Z" }, + { name: "amount", value: "12.34" }, + { name: "invoiceMissingReason", value: "暂时无法取得" }, + { name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng }, + ]); + const created = await app.inject({ + method: "POST", + url: "/api/expenses", + headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": initial.contentType }, + payload: initial.body, + }); + const before = created.json().expense as { id: string; version: number; amountCents: number; paymentProofCount: number }; + const conflictForm = multipart([ + { name: "paidAt", value: "2026-08-29T12:00:00.000Z" }, + { name: "amount", value: "20.00" }, + { name: "note", value: "不应保存" }, + { name: "invoiceMissingReason", value: "暂时无法取得" }, + { name: "version", value: String(before.version + 1) }, + { name: "paymentProofs", filename: "orphan.png", contentType: "image/png", data: tinyPng }, + ]); + const conflictResponse = await app.inject({ + method: "PATCH", + url: `/api/expenses/${before.id}`, + headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": conflictForm.contentType }, + payload: conflictForm.body, + }); + expect(conflictResponse.statusCode).toBe(409); + const afterConflict = app.inject({ method: "GET", url: `/api/expenses/${before.id}`, headers: { cookie: session.cookies } }); + const current = (await afterConflict).json().expense; + expect(current).toMatchObject({ version: before.version, amountCents: before.amountCents, paymentProofCount: before.paymentProofCount }); + expect(current.attachments.some((item: { originalName: string }) => item.originalName === "orphan.png")).toBe(false); + + const invalidForm = multipart([ + { name: "paidAt", value: "2026-08-29T12:00:00.000Z" }, + { name: "amount", value: "1000000000000.00" }, + { name: "invoiceMissingReason", value: "暂时无法取得" }, + { name: "version", value: String(before.version) }, + { name: "paymentProofs", filename: "invalid.png", contentType: "image/png", data: tinyPng }, + ]); + const invalidResponse = await app.inject({ + method: "PATCH", + url: `/api/expenses/${before.id}`, + headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": invalidForm.contentType }, + payload: invalidForm.body, + }); + expect(invalidResponse.statusCode).toBe(400); + const afterInvalid = (await app.inject({ method: "GET", url: `/api/expenses/${before.id}`, headers: { cookie: session.cookies } })).json().expense; + expect(afterInvalid).toMatchObject({ version: before.version, amountCents: before.amountCents, paymentProofCount: before.paymentProofCount }); + expect(afterInvalid.attachments.some((item: { originalName: string }) => item.originalName === "invalid.png")).toBe(false); + }); }); diff --git a/tests/core.test.ts b/tests/core.test.ts index ed90e43..bfd10f3 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { amountToCents, centsToAmount, exportRequestSchema } from "../shared/contracts.js"; +import { amountToCents, centsToAmount, exportRequestSchema, updateApplySchema } from "../shared/contracts.js"; import { zonedMonthBounds } from "../server/app.js"; import { safeExcelText } from "../server/exporter.js"; import { safeStoragePath, sanitizeOriginalName } from "../server/files.js"; @@ -30,6 +30,13 @@ describe("导出选项", () => { }); }); +describe("更新版本", () => { + it("接受合法的 prerelease 和 build metadata", () => { + expect(updateApplySchema.parse({ version: "1.2.3+build.5", confirm: true }).version).toBe("1.2.3+build.5"); + expect(updateApplySchema.parse({ version: "v1.2.3-alpha.1+build.5", confirm: true }).version).toBe("v1.2.3-alpha.1+build.5"); + }); +}); + describe("文件和导出安全", () => { it("不让用户文件名参与路径", () => { expect(sanitizeOriginalName("../../秘密\u0000.png")).toBe("秘密.png"); diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 7fedfba..26117f4 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -2,8 +2,29 @@ import { expect, test } from "@playwright/test"; test("未登录时显示中文登录入口", async ({ page }) => { await page.goto("/"); - await expect(page.getByText("TallyNote")).toBeVisible(); - await expect(page.getByLabel("用户名")).toBeVisible(); - await expect(page.getByLabel("密码")).toBeVisible(); + await expect(page.getByRole("heading", { name: "登录到 TallyNote", exact: true })).toBeVisible(); + await expect(page.locator(".tn-login-header")).toHaveCount(0); + await expect(page.getByLabel("用户名", { exact: true })).toBeVisible(); + await expect(page.getByLabel("密码", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "登录" })).toBeVisible(); }); + +for (const viewport of [ + { width: 320, height: 800 }, + { width: 375, height: 812 }, + { width: 768, height: 1024 }, +]) { + test(`未登录入口适配 ${viewport.width}px`, async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "登录到 TallyNote", exact: true })).toBeVisible(); + await expect(page.getByLabel("用户名", { exact: true })).toBeVisible(); + await expect(page.getByLabel("密码", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "登录" })).toBeVisible(); + const width = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect(width.scrollWidth).toBeLessThanOrEqual(width.clientWidth); + }); +} diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index 6442ddf..2519a38 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -59,10 +59,10 @@ describe("更新 API", () => { function mockRelease() { const digest = "c".repeat(64); - const asset = `tallynote-1.1.0-${detectPlatform().target}-glibc.tar.gz`; + const asset = `tallynote-1.1.1-${detectPlatform().target}-glibc.tar.gz`; globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS") ? new Response(`${digest} ${asset}\n`, { status: 200 }) - : new Response(JSON.stringify({ tag_name: "v1.1.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch; + : new Response(JSON.stringify({ tag_name: "v1.1.1", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch; } it("检查 release、创建受保护请求文件并拒绝重复任务", async () => { @@ -70,21 +70,21 @@ describe("更新 API", () => { mockRelease(); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); - expect(checked.json().latest).toMatchObject({ version: "1.1.0", compatible: true, integrityReady: true, isNewer: true }); + expect(checked.json().latest).toMatchObject({ version: "1.1.1", compatible: true, integrityReady: true, isNewer: true }); expect(checked.headers["cache-control"]).toBe("no-store"); const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(tooSoon.statusCode).toBe(429); expect(tooSoon.headers["retry-after"]).toBeDefined(); - const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.0", confirm: true } }); + const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } }); expect(applied.statusCode).toBe(202); const jobId = applied.json().job.id as string; const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string }; - expect(request).toMatchObject({ jobId, expectedSha256: "c".repeat(64), currentLink: config.currentLink }); + expect(request).toMatchObject({ jobId, version: "1.1.1", expectedSha256: "c".repeat(64), currentLink: config.currentLink }); expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600); mockRelease(); - const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.0", confirm: true } }); + const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } }); expect(duplicate.statusCode).toBe(409); expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS"); const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); @@ -95,7 +95,7 @@ describe("更新 API", () => { it("缺少确认或未启用 systemd 时不接受更新", async () => { const session = await login(); - const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.0" } }); + const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1" } }); expect(invalid.statusCode).toBe(400); process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled"; const disabledConfig = loadConfig(); @@ -108,7 +108,7 @@ describe("更新 API", () => { mockRelease(); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); - const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.0", confirm: true } }); + const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.1", confirm: true } }); expect(applied.statusCode).toBe(202); const jobId = applied.json().job.id as string; database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId); @@ -126,7 +126,7 @@ describe("更新 API", () => { it("应用前重新校验失败时写入失败审计", async () => { const session = await login("update-audit"); globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch; - const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.0", confirm: true } }); + const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.1", confirm: true } }); expect(response.statusCode).toBe(502); const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined; expect(audit?.outcome).toBe("failure"); diff --git a/tests/update.test.ts b/tests/update.test.ts index 38c6eac..2a1e0ef 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -273,17 +273,17 @@ describe("更新元数据缓存", () => { prepareDataDirectories(config); const database = openDatabase(config); const digest = "b".repeat(64); - const platformAsset = `tallynote-1.1.0-${detectPlatform().target}-glibc.tar.gz`; + const platformAsset = `tallynote-1.1.1-${detectPlatform().target}-glibc.tar.gz`; const sums = `${digest} ${platformAsset}\n`; const signature = sign(null, Buffer.from(sums), privateKey); globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig") ? new Response(signature) : input.toString().endsWith("SHA256SUMS") ? new Response(sums) - : new Response(JSON.stringify({ tag_name: "v1.1.0", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch; + : new Response(JSON.stringify({ tag_name: "v1.1.1", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch; try { const result = await checkForUpdate(database.sqlite, config); - expect(result.latest).toMatchObject({ version: "1.1.0", compatible: true, integrityReady: true, signatureReady: true, isNewer: true }); + expect(result.latest).toMatchObject({ version: "1.1.1", compatible: true, integrityReady: true, signatureReady: true, isNewer: true }); const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string }; expect(JSON.parse(cached.value).asset.sha256).toBe(digest); } finally { diff --git a/tests/web-foundation.test.ts b/tests/web-foundation.test.ts new file mode 100644 index 0000000..5329794 --- /dev/null +++ b/tests/web-foundation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { ApiError, api } from "../web-next/src/services/api"; +import { DEFAULT_ROUTE_ID, routeIdFromPath, routePath } from "../web-next/src/router"; +import sessionReducer, { bootstrapSession, isSessionBootstrapping, loginThunk } from "../web-next/src/store/sessionSlice"; +import type { Admin } from "../web-next/src/types/auth"; + +describe("前端基础契约", () => { + it("将页面路由映射为稳定的 URL,并容忍尾斜杠", () => { + expect(routePath(DEFAULT_ROUTE_ID)).toBe("/"); + expect(routeIdFromPath("/")).toBe("dashboard"); + expect(routeIdFromPath("/dashboard")).toBe("dashboard"); + expect(routeIdFromPath("/expenses/")).toBe("expenses"); + expect(routeIdFromPath("/unknown")).toBeUndefined(); + }); + + it("请求超时会中止并返回可识别的错误", async () => { + vi.stubGlobal("fetch", vi.fn((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); + }))); + await expect(api("/api/slow", { timeoutMs: 10 })).rejects.toMatchObject({ status: 408, code: "REQUEST_TIMEOUT" }); + vi.unstubAllGlobals(); + }); + + it("忽略过期的会话初始化结果,避免覆盖较新的认证状态", () => { + const admin: Admin = { + id: "11111111-1111-4111-8111-111111111111", + username: "admin", + displayName: "管理员", + status: "active", + mustChangePassword: false, + version: 1, + createdAt: 1, + lastLoginAt: null, + disabledAt: null, + }; + let state = sessionReducer(undefined, bootstrapSession.pending("older", undefined)); + state = sessionReducer(state, bootstrapSession.pending("newer", undefined)); + state = sessionReducer(state, bootstrapSession.fulfilled({ initialized: true, timezone: "Asia/Shanghai", admin: null }, "older", undefined)); + expect(state.status).toBe("loading"); + expect(state.bootstrapRequestId).toBe("newer"); + state = sessionReducer(state, loginThunk.fulfilled(admin, "login", { username: "admin", password: "test" })); + expect(state.status).toBe("authenticated"); + expect(state.admin?.id).toBe(admin.id); + state = sessionReducer(state, bootstrapSession.fulfilled({ initialized: true, timezone: "Asia/Shanghai", admin: null }, "newer", undefined)); + expect(state.status).toBe("authenticated"); + expect(state.admin?.id).toBe(admin.id); + }); + + it("登录提交时保留登录页面,不误显示启动连接占位", () => { + let state = sessionReducer(undefined, bootstrapSession.pending("boot", undefined)); + expect(isSessionBootstrapping(state)).toBe(true); + state = sessionReducer(state, bootstrapSession.fulfilled({ initialized: true, timezone: "Asia/Shanghai", admin: null }, "boot", undefined)); + state = sessionReducer(state, loginThunk.pending("login", { username: "admin", password: "test" })); + expect(state.status).toBe("loading"); + expect(state.initialized).toBe(true); + expect(state.bootstrapRequestId).toBeNull(); + expect(isSessionBootstrapping(state)).toBe(false); + }); + + it("启动连接失败时显示可恢复的错误状态", () => { + let state = sessionReducer(undefined, bootstrapSession.pending("boot", undefined)); + state = sessionReducer(state, bootstrapSession.rejected(new Error("服务不可用"), "boot", undefined)); + expect(state.status).toBe("error"); + expect(state.initialized).toBeNull(); + expect(isSessionBootstrapping(state)).toBe(false); + }); +}); diff --git a/tsconfig.web-next.json b/tsconfig.web-next.json new file mode 100644 index 0000000..c0d3937 --- /dev/null +++ b/tsconfig.web-next.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "noEmit": true, + "exactOptionalPropertyTypes": false, + "types": ["vite/client"], + "baseUrl": ".", + "paths": { "@/*": ["web-next/src/*"] } + }, + "include": ["web-next/src/**/*.ts", "web-next/src/**/*.tsx", "shared/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts index 18c7ff6..6898b49 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,14 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import path from "node:path"; const apiPort = Number(process.env.TALLYNOTE_PORT ?? 3000); export default defineConfig({ - root: "web", + root: "web-next", + resolve: { + alias: { "@": path.resolve(process.cwd(), "web-next/src") }, + }, plugins: [react()], server: { host: "127.0.0.1", diff --git a/vite.next.config.ts b/vite.next.config.ts new file mode 100644 index 0000000..27b8a3f --- /dev/null +++ b/vite.next.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; + +const apiPort = Number(process.env.TALLYNOTE_PORT ?? 3000); + +export default defineConfig({ + root: 'web-next', + plugins: [react()], + resolve: { + alias: { '@': path.resolve(process.cwd(), 'web-next/src') }, + }, + server: { + host: '127.0.0.1', + port: 5173, + proxy: { + '/api': { + target: `http://127.0.0.1:${apiPort}`, + // Preserve the browser Origin so Fastify can validate the configured + // public origin and its explicit development origins. + changeOrigin: false, + }, + '/health': { + target: `http://127.0.0.1:${apiPort}`, + changeOrigin: false, + }, + }, + }, + build: { + outDir: '../dist/web', + emptyOutDir: true, + cssCodeSplit: true, + sourcemap: false, + }, +}); diff --git a/web-next/index.html b/web-next/index.html new file mode 100644 index 0000000..37f53fe --- /dev/null +++ b/web-next/index.html @@ -0,0 +1,10 @@ + + + + + + + TallyNote 账目台 + +
+ diff --git a/web-next/src/assets/login-background-tech.webp b/web-next/src/assets/login-background-tech.webp new file mode 100644 index 0000000..523b497 Binary files /dev/null and b/web-next/src/assets/login-background-tech.webp differ diff --git a/web-next/src/components/AccessibleInput.tsx b/web-next/src/components/AccessibleInput.tsx new file mode 100644 index 0000000..da086e9 --- /dev/null +++ b/web-next/src/components/AccessibleInput.tsx @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; +import { Input, type InputProps, type InputRef } from "tdesign-react"; + +export interface AccessibleInputProps extends InputProps { + inputAriaLabel?: string; + inputAriaInvalid?: boolean; + inputAriaDescribedby?: string; +} + +/** + * TDesign places unknown aria props on its wrapper. This adapter mirrors the + * important field attributes onto the native input so labels, errors and + * keyboard tooling target the actual editable control. + */ +export default function AccessibleInput({ inputAriaLabel, inputAriaInvalid, inputAriaDescribedby, ...props }: AccessibleInputProps) { + const ref = useRef(null); + useEffect(() => { + const input = ref.current?.inputElement; + if (!input) return; + if (inputAriaLabel) input.setAttribute("aria-label", inputAriaLabel); + else input.removeAttribute("aria-label"); + if (inputAriaInvalid) input.setAttribute("aria-invalid", "true"); + else input.removeAttribute("aria-invalid"); + if (inputAriaDescribedby) input.setAttribute("aria-describedby", inputAriaDescribedby); + else input.removeAttribute("aria-describedby"); + }, [inputAriaDescribedby, inputAriaInvalid, inputAriaLabel]); + return ; +} diff --git a/web-next/src/contexts/UnsavedChanges.tsx b/web-next/src/contexts/UnsavedChanges.tsx new file mode 100644 index 0000000..9463940 --- /dev/null +++ b/web-next/src/contexts/UnsavedChanges.tsx @@ -0,0 +1,85 @@ +import { useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; +import { Dialog } from "tdesign-react"; +import { useBlocker } from "react-router-dom"; +import { UnsavedContext } from "./UnsavedChangesContext"; + +// Keep navigation protection state local to the mounted route tree. +// The context identity itself is kept in the dedicated registry module. + +export function UnsavedChangesProvider({ children }: { children: ReactNode }) { + const [warning, setWarning] = useState(null); + const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); + const warnings = useRef(new Map()); + const warningRef = useRef(null); + const blocker = useBlocker(Boolean(warning)); + + useEffect(() => { + if (!warning) return; + const preventUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; }; + window.addEventListener("beforeunload", preventUnload); + return () => window.removeEventListener("beforeunload", preventUnload); + }, [warning]); + + const registerWarning = useCallback((id: string, message: string | null) => { + if (message) warnings.current.set(id, message); else warnings.current.delete(id); + const nextWarning = warnings.current.values().next().value ?? null; + warningRef.current = nextWarning; + if (!nextWarning) setPendingAction(null); + setWarning(nextWarning); + }, []); + const clearWarnings = useCallback(() => { + warnings.current.clear(); + warningRef.current = null; + setWarning(null); + }, []); + const requestDiscard = useCallback((action: () => void) => { + if (!warningRef.current) { action(); return; } + setPendingAction(() => action); + }, []); + + const value = useMemo(() => ({ registerWarning, requestDiscard }), [registerWarning, requestDiscard]); + const visible = blocker.state === "blocked" || Boolean(pendingAction); + useEffect(() => { + // A dirty form can disappear for reasons unrelated to navigation (for + // example session expiry). Do not leave its stale close callback attached + // to the confirmation dialog that belongs to the new screen. + if (!warning && pendingAction) setPendingAction(null); + }, [pendingAction, warning]); + const confirm = () => { + const action = pendingAction; + setPendingAction(null); + if (blocker.state === "blocked") { + blocker.proceed(); + } else { + clearWarnings(); + action?.(); + } + }; + const cancel = () => { + setPendingAction(null); + if (blocker.state === "blocked") blocker.reset(); + }; + + return + {children} + + {warning || "当前页面有未保存的内容,离开后将无法恢复。"} + + ; +} + +export function useUnsavedChanges(active: boolean, message = "当前表单有未保存的内容,离开后这些内容会丢失。") { + const context = useContext(UnsavedContext); + if (!context) throw new Error("useUnsavedChanges must be used inside UnsavedChangesProvider"); + const id = useId(); + useEffect(() => { + context.registerWarning(id, active ? message : null); + return () => context.registerWarning(id, null); + }, [active, context, id, message]); +} + +export function useUnsavedActions() { + const context = useContext(UnsavedContext); + if (!context) throw new Error("useUnsavedActions must be used inside UnsavedChangesProvider"); + return context; +} diff --git a/web-next/src/contexts/UnsavedChangesContext.ts b/web-next/src/contexts/UnsavedChangesContext.ts new file mode 100644 index 0000000..672ab97 --- /dev/null +++ b/web-next/src/contexts/UnsavedChangesContext.ts @@ -0,0 +1,19 @@ +import { createContext, type Context } from "react"; + +export type UnsavedContextValue = { + registerWarning: (id: string, message: string | null) => void; + requestDiscard: (action: () => void) => void; +}; + +/* + * Vite Fast Refresh can re-evaluate a module while an existing route tree is + * still mounted. Keep the context identity on the browser window so the old + * provider and the refreshed consumers continue to refer to the same object. + */ +type ContextRegistry = Window & { + __tallynoteUnsavedContext?: Context; +}; + +const registry = typeof window === "undefined" ? null : window as ContextRegistry; +export const UnsavedContext: Context = registry?.__tallynoteUnsavedContext ?? createContext(null); +if (registry && !registry.__tallynoteUnsavedContext) registry.__tallynoteUnsavedContext = UnsavedContext; diff --git a/web-next/src/hooks/useDialogAccessibility.ts b/web-next/src/hooks/useDialogAccessibility.ts new file mode 100644 index 0000000..b9acf7d --- /dev/null +++ b/web-next/src/hooks/useDialogAccessibility.ts @@ -0,0 +1,142 @@ +import { useEffect } from "react"; + +const FOCUSABLE = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "textarea:not([disabled])", + "select:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +let dialogTitleSequence = 0; + +function isVisible(node: HTMLElement): boolean { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + return rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; +} + +/** Normalizes the semantics and keyboard behavior missing from TDesign Dialog. */ +export function useDialogAccessibility() { + useEffect(() => { + let lastFocusOutside: HTMLElement | null = document.activeElement instanceof HTMLElement ? document.activeElement : null; + let scheduledFrame = 0; + const enhanced = new Map void>(); + + const onFocusIn = (event: FocusEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement) || target.closest(".t-dialog__ctx")) return; + lastFocusOutside = target; + }; + + const focusableItems = (dialog: HTMLElement) => Array.from(dialog.querySelectorAll(FOCUSABLE)).filter(isVisible); + + const enhance = (dialog: HTMLElement) => { + if (enhanced.has(dialog)) return; + const context = dialog.closest(".t-dialog__ctx") ?? dialog; + const returnFocus = lastFocusOutside; + dialogTitleSequence += 1; + const dialogSequence = dialogTitleSequence; + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + if (!dialog.hasAttribute("tabindex")) dialog.setAttribute("tabindex", "-1"); + + const header = dialog.querySelector(".t-dialog__header-content, .t-dialog__header"); + if (header) { + if (!header.id) { + header.id = `tn-dialog-title-${dialogSequence}`; + } + dialog.setAttribute("aria-labelledby", header.id); + } else { + dialog.setAttribute("aria-label", "确认操作"); + } + + const body = dialog.querySelector(".t-dialog__body"); + if (body?.textContent?.trim()) { + if (!body.id) body.id = `tn-dialog-description-${dialogSequence}`; + dialog.setAttribute("aria-describedby", body.id); + } + + const closeButton = dialog.querySelector(".t-dialog__close"); + const onCloseKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + closeButton?.click(); + }; + if (closeButton) { + closeButton.setAttribute("role", "button"); + closeButton.setAttribute("tabindex", "0"); + closeButton.setAttribute("aria-label", "关闭弹窗"); + closeButton.addEventListener("keydown", onCloseKeyDown); + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Tab") return; + const items = focusableItems(dialog); + if (!items.length) { + event.preventDefault(); + event.stopPropagation(); + dialog.focus(); + return; + } + event.preventDefault(); + event.stopPropagation(); + const activeIndex = items.indexOf(document.activeElement as HTMLElement); + const nextIndex = event.shiftKey + ? activeIndex <= 0 ? items.length - 1 : activeIndex - 1 + : activeIndex < 0 || activeIndex === items.length - 1 ? 0 : activeIndex + 1; + items[nextIndex]!.focus(); + }; + context.addEventListener("keydown", onKeyDown, true); + + const focusInitial = () => { + if (!isVisible(dialog) || dialog.contains(document.activeElement)) return; + const preferred = dialog.querySelector(".t-dialog__cancel:not([disabled])") + ?? dialog.querySelector(".t-dialog__close[tabindex='0']"); + (preferred && isVisible(preferred) ? preferred : focusableItems(dialog)[0] ?? dialog).focus(); + }; + const frame = window.requestAnimationFrame(focusInitial); + const timer = window.setTimeout(focusInitial, 80); + + enhanced.set(dialog, () => { + window.cancelAnimationFrame(frame); + window.clearTimeout(timer); + context.removeEventListener("keydown", onKeyDown, true); + closeButton?.removeEventListener("keydown", onCloseKeyDown); + window.setTimeout(() => { + const anotherDialogOpen = Array.from(document.querySelectorAll(".t-dialog")).some(candidate => candidate !== dialog && isVisible(candidate)); + const active = document.activeElement; + if (!anotherDialogOpen && returnFocus?.isConnected && (!active || active === document.body || context.contains(active))) returnFocus.focus(); + }, 0); + }); + }; + + const sync = () => { + scheduledFrame = 0; + const visibleDialogs = new Set(Array.from(document.querySelectorAll(".t-dialog")).filter(isVisible)); + visibleDialogs.forEach(enhance); + enhanced.forEach((cleanup, dialog) => { + if (visibleDialogs.has(dialog)) return; + cleanup(); + enhanced.delete(dialog); + }); + }; + const scheduleSync = () => { + if (scheduledFrame) return; + scheduledFrame = window.requestAnimationFrame(sync); + }; + + document.addEventListener("focusin", onFocusIn); + const observer = new MutationObserver(scheduleSync); + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] }); + scheduleSync(); + return () => { + document.removeEventListener("focusin", onFocusIn); + observer.disconnect(); + window.cancelAnimationFrame(scheduledFrame); + enhanced.forEach(cleanup => cleanup()); + enhanced.clear(); + }; + }, []); +} diff --git a/web-next/src/hooks/useDrawerFocus.ts b/web-next/src/hooks/useDrawerFocus.ts new file mode 100644 index 0000000..96fe33e --- /dev/null +++ b/web-next/src/hooks/useDrawerFocus.ts @@ -0,0 +1,152 @@ +import { useEffect, useRef } from "react"; + +const FOCUSABLE = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "textarea:not([disabled])", + "select:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +let titleSequence = 0; + +/** Adds the small amount of focus management TDesign Drawer does not expose. */ +export function useDrawerFocus(active: boolean, selector: string) { + const returnFocusRef = useRef(null); + + useEffect(() => { + if (!active) return; + const previous = document.activeElement; + if (previous instanceof HTMLElement && !previous.closest(".t-drawer")) { + returnFocusRef.current = previous; + } + + let disposed = false; + let root: HTMLElement | null = null; + let cleanupRoot: (() => void) | undefined; + let frame = 0; + let timer: number | undefined; + let focusTimer: number | undefined; + let initialized = false; + const restoreTimers: number[] = []; + + const focusFirst = () => { + if (disposed || !root) return; + const current = document.activeElement; + if (current && root.contains(current) && current !== root) return; + const first = Array.from(root.querySelectorAll(FOCUSABLE)).find(node => { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; + }); + (first || root).focus(); + }; + + const setup = () => { + if (disposed) return; + root = document.querySelector(`${selector}.t-drawer--open`) || document.querySelector(selector); + if (!root) { + timer = window.setTimeout(setup, 40); + return; + } + + if (!root.hasAttribute("tabindex")) root.setAttribute("tabindex", "-1"); + if (initialized) { + // Lazy Drawer content can be inserted after the first animation frame. + // Re-run only the initial focus lookup without duplicating listeners. + focusTimer = window.setTimeout(focusFirst, 80); + return; + } + initialized = true; + root.setAttribute("role", "dialog"); + root.setAttribute("aria-modal", "true"); + const header = root.querySelector(".t-drawer__header"); + if (header) { + if (!header.id) { + titleSequence += 1; + header.id = `tn-drawer-title-${titleSequence}`; + } + root.setAttribute("aria-labelledby", header.id); + } else if (!root.getAttribute("aria-label")) { + root.setAttribute("aria-label", "抽屉"); + } + + const closeButton = root.querySelector(".t-drawer__close-btn"); + const onCloseKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + closeButton?.click(); + }; + if (closeButton) { + closeButton.setAttribute("role", "button"); + closeButton.setAttribute("tabindex", "0"); + closeButton.setAttribute("aria-label", "关闭"); + closeButton.addEventListener("keydown", onCloseKeyDown); + } + + const isNestedDialogOpen = () => Array.from(document.querySelectorAll(".t-dialog, .t-dialog__wrapper")).some(dialog => { + if (dialog === root || root?.contains(dialog)) return false; + const style = window.getComputedStyle(dialog); + const rect = dialog.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + }); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Tab" || !root || isNestedDialogOpen()) return; + const items = Array.from(root.querySelectorAll(FOCUSABLE)).filter(node => { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + return (rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none") || node === document.activeElement; + }); + if (!items.length) { + event.preventDefault(); + event.stopPropagation(); + root.focus(); + return; + } + event.preventDefault(); + event.stopPropagation(); + const activeIndex = items.indexOf(document.activeElement as HTMLElement); + const nextIndex = event.shiftKey + ? activeIndex <= 0 ? items.length - 1 : activeIndex - 1 + : activeIndex < 0 || activeIndex === items.length - 1 ? 0 : activeIndex + 1; + items[nextIndex]!.focus(); + }; + root.addEventListener("keydown", onKeyDown, true); + cleanupRoot = () => { + root?.removeEventListener("keydown", onKeyDown, true); + closeButton?.removeEventListener("keydown", onCloseKeyDown); + }; + + frame = window.requestAnimationFrame(() => { + focusFirst(); + focusTimer = window.setTimeout(focusFirst, 90); + }); + }; + + frame = window.requestAnimationFrame(setup); + timer = window.setTimeout(setup, 180); + return () => { + disposed = true; + window.cancelAnimationFrame(frame); + if (timer !== undefined) window.clearTimeout(timer); + if (focusTimer !== undefined) window.clearTimeout(focusTimer); + cleanupRoot?.(); + const restore = () => { + if (!disposed) return; + if (document.querySelector(`${selector}.t-drawer--open`)) return; + const target = returnFocusRef.current; + const current = document.activeElement; + if (!target?.isConnected) return; + // Do not steal focus if the user has already moved to another page + // control while the close animation was running. + if (current && current !== document.body && !current.closest(".t-drawer")) return; + target.focus(); + }; + [0, 180, 380].forEach(delay => { + restoreTimers.push(window.setTimeout(restore, delay)); + }); + restore(); + }; + }, [active, selector]); +} diff --git a/web-next/src/layouts/AppLayout.tsx b/web-next/src/layouts/AppLayout.tsx new file mode 100644 index 0000000..973d914 --- /dev/null +++ b/web-next/src/layouts/AppLayout.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Button, Drawer, Layout } from "tdesign-react"; +import { X } from "lucide-react"; +import { DEFAULT_ROUTE_ID, type RouteId } from "../router"; +import AppContent from "./Content"; +import AppHeader from "./Header"; +import AppMenu from "./Menu"; +import { useDrawerFocus } from "../hooks/useDrawerFocus"; + +export interface AppLayoutProps { + activeId?: RouteId; + adminName?: string; + adminUsername?: string; + children: ReactNode; + onNavigate: (id: RouteId) => void; + onLogout?: () => void; + onOpenPassword?: () => void; +} + +export default function AppLayout({ activeId = DEFAULT_ROUTE_ID, adminName, adminUsername, children, onNavigate, onLogout, onOpenPassword }: AppLayoutProps) { + const [desktopVisible, setDesktopVisible] = useState(true); + const [isMobile, setIsMobile] = useState(false); + const [mobileVisible, setMobileVisible] = useState(false); + const menuButtonRef = useRef(null); + useDrawerFocus(mobileVisible, ".tn-mobile-drawer"); + + useEffect(() => { + const media = typeof window.matchMedia === "function" ? window.matchMedia("(max-width: 900px)") : null; + const sync = () => { + const mobile = media?.matches ?? window.innerWidth <= 900; + setIsMobile(mobile); + if (!mobile) setMobileVisible(false); + }; + sync(); + if (!media) return; + if (typeof media.addEventListener === "function") { + media.addEventListener("change", sync); + return () => media.removeEventListener("change", sync); + } + media.addListener?.(sync); + return () => media.removeListener?.(sync); + }, []); + + const restoreMenuFocus = () => { + window.requestAnimationFrame(() => menuButtonRef.current?.focus()); + }; + + const closeMobileMenu = () => { + setMobileVisible(false); + restoreMenuFocus(); + }; + + const choose = (id: RouteId) => { + onNavigate(id); + closeMobileMenu(); + }; + + const toggleMenu = () => { + if (isMobile) { + setMobileVisible(value => !value); + return; + } + setMobileVisible(false); + setDesktopVisible(value => !value); + }; + + return ( + + + + + {children} + + 主导航; + const userOptions: DropdownOption[] = [ + ...(onOpenPassword ? [{ value: "password", content: menuItem("修改密码"), prefixIcon: }] : []), + ...(onLogout ? [{ value: "logout", content: menuItem("退出登录"), theme: "error" as const, prefixIcon: }] : []), + ]; + + useEffect(() => { + if (!userMenuOpen) return; + let disposed = false; + const focusMenu = () => { + if (disposed) return; + const menu = document.querySelector(".tn-user-dropdown"); + if (!menu) return; + menu.setAttribute("role", "menu"); + menu.setAttribute("aria-label", "账号操作"); + menu.querySelector("[role='menuitem']")?.focus(); + }; + const frame = window.requestAnimationFrame(focusMenu); + const timer = window.setTimeout(focusMenu, 80); + return () => { disposed = true; window.cancelAnimationFrame(frame); window.clearTimeout(timer); }; + }, [userMenuOpen]); + + function handleMenuKeyDown(event: KeyboardEvent) { + const items = Array.from(document.querySelectorAll(".tn-user-dropdown [role='menuitem']")); + const index = items.indexOf(event.currentTarget); + if (event.key === "Escape") { + event.preventDefault(); + userMenuButtonRef.current?.click(); + userMenuButtonRef.current?.focus(); + return; + } + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key) || !items.length) return; + event.preventDefault(); + const nextIndex = event.key === "Home" ? 0 + : event.key === "End" ? items.length - 1 + : event.key === "ArrowDown" ? (index + 1) % items.length + : (index - 1 + items.length) % items.length; + items[nextIndex]?.focus(); + } + + const handleUserAction = (item: DropdownOption) => { + if (item.value === "password") onOpenPassword?.(); + if (item.value === "logout") onLogout?.(); + }; + + return ( + + + + + } + + + ); +} diff --git a/web-next/src/layouts/Menu.tsx b/web-next/src/layouts/Menu.tsx new file mode 100644 index 0000000..b5cfd4e --- /dev/null +++ b/web-next/src/layouts/Menu.tsx @@ -0,0 +1,46 @@ +import { Menu as TMenu } from "tdesign-react"; +import type { MenuValue } from "tdesign-react"; +import { APP_ROUTES, routePath, type RouteId } from "../router"; + +export interface AppMenuProps { + activeId: RouteId; + collapsed?: boolean; + onChange: (id: RouteId) => void; + className?: string; + showLogo?: boolean; + width?: [string, string]; +} + +const { MenuItem } = TMenu; + +export default function AppMenu({ activeId, collapsed = false, onChange, className, showLogo = true, width = ["232px", "64px"] }: AppMenuProps) { + const handleChange = (value: MenuValue) => { + if (typeof value === "string") onChange(value as RouteId); + }; + + return ( + : undefined} + onChange={handleChange} + > + {APP_ROUTES.map(({ id, title, icon: Icon }) => ( + e.preventDefault()} icon={}> + {title} + + ))} + + ); +} + +function MenuLogo({ collapsed }: { collapsed: boolean }) { + return ( +
+ {collapsed ? "TN" : "TallyNote"} +
+ ); +} diff --git a/web-next/src/layouts/index.ts b/web-next/src/layouts/index.ts new file mode 100644 index 0000000..6658b38 --- /dev/null +++ b/web-next/src/layouts/index.ts @@ -0,0 +1,8 @@ +export { default as AppLayout } from "./AppLayout"; +export { default as AppContent } from "./Content"; +export { default as AppHeader } from "./Header"; +export { default as AppMenu } from "./Menu"; +export type { AppLayoutProps } from "./AppLayout"; +export type { AppContentProps } from "./Content"; +export type { AppHeaderProps } from "./Header"; +export type { AppMenuProps } from "./Menu"; diff --git a/web-next/src/main.tsx b/web-next/src/main.tsx new file mode 100644 index 0000000..d1df993 --- /dev/null +++ b/web-next/src/main.tsx @@ -0,0 +1,130 @@ +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import "tdesign-react/es/_util/react-19-adapter"; +import { Button, Loading, NotificationPlugin } from "tdesign-react"; +import { Provider } from "react-redux"; +import { createBrowserRouter, RouterProvider, useLocation, useNavigate, useRouteError } from "react-router-dom"; +import { AUTH_EXPIRED_EVENT } from "./services/api"; +import { store, useAppDispatch, useAppSelector, bootstrapSession, isSessionBootstrapping, logoutThunk, sessionExpired } from "./store"; +import { setAppTimezone } from "./utils/date"; +import { AppLayout } from "./layouts"; +import { DEFAULT_ROUTE_ID, isRouteId, routeIdFromPath, routePath, routeTitle, type RouteId } from "./router"; +import { LoginPage, ChangePasswordPage } from "./pages/auth"; +const ExpensesPage = lazy(() => import("./pages/expenses")); +const DashboardPage = lazy(() => import("./pages/dashboard")); +const TrashPage = lazy(() => import("./pages/trash").then(module => ({ default: module.TrashPage }))); +const AdminsPage = lazy(() => import("./pages/admins").then(module => ({ default: module.AdminsPage }))); +const AuditPage = lazy(() => import("./pages/audit").then(module => ({ default: module.AuditPage }))); +const UpdatePage = lazy(() => import("./pages/update").then(module => ({ default: module.UpdatePage }))); +import { UnsavedChangesProvider, useUnsavedActions } from "./contexts/UnsavedChanges"; +import { useDialogAccessibility } from "./hooks/useDialogAccessibility"; +import "./styles/theme.css"; + +function App() { + const dispatch = useAppDispatch(); + const session = useAppSelector(state => state.session); + const location = useLocation(); + const navigate = useNavigate(); + const { requestDiscard } = useUnsavedActions(); + const page = routeIdFromPath(location.pathname) ?? DEFAULT_ROUTE_ID; + const [passwordOpen, setPasswordOpen] = useState(false); + const logoutInFlight = useRef(false); + useDialogAccessibility(); + const notify = useCallback((message: string, kind: "success" | "error" | "info" = "info") => { + // The placement container owns the responsive right inset. Keeping the + // item offset at zero avoids pushing narrow-screen notices off canvas. + const options = { content: message, duration: 4200, placement: "top-right" as const, offset: [0, 76] as [number, number], zIndex: 5000 }; + const show = kind === "success" ? NotificationPlugin.success : kind === "error" ? NotificationPlugin.error : NotificationPlugin.info; + void show(options); + }, []); + + useEffect(() => { void dispatch(bootstrapSession()); }, [dispatch]); + useEffect(() => { + const handleExpired = (event: Event) => { dispatch(sessionExpired((event as CustomEvent).detail)); setPasswordOpen(false); }; + window.addEventListener(AUTH_EXPIRED_EVENT, handleExpired); + return () => window.removeEventListener(AUTH_EXPIRED_EVENT, handleExpired); + }, [dispatch]); + useEffect(() => { if (session.timezone) setAppTimezone(session.timezone); }, [session.timezone]); + useEffect(() => { setPasswordOpen(false); }, [location.pathname]); + useEffect(() => { + if (!routeIdFromPath(location.pathname)) navigate(routePath(DEFAULT_ROUTE_ID), { replace: true }); + }, [location.pathname, navigate]); + useEffect(() => { + const title = session.status === "error" + ? "连接失败" + : !session.admin + ? "登录" + : session.admin.mustChangePassword + ? "设置密码" + : passwordOpen + ? "修改密码" + : routeTitle(page); + document.title = `${title} | TallyNote`; + }, [page, passwordOpen, session.admin, session.status]); + + const onLogout = useCallback(() => { + requestDiscard(() => { + if (logoutInFlight.current) return; + logoutInFlight.current = true; + void dispatch(logoutThunk()).then(() => { + navigate(routePath(DEFAULT_ROUTE_ID), { replace: true }); + notify("已退出登录", "info"); + }).finally(() => { logoutInFlight.current = false; }); + }); + }, [dispatch, navigate, notify, requestDiscard]); + const onNavigate = useCallback((id: RouteId, search = "") => { + if (!isRouteId(id)) return; + const targetPath = routePath(id); + const targetSearch = search && !search.startsWith("?") ? `?${search}` : search; + if (location.pathname === targetPath && !search) { + if (passwordOpen) requestDiscard(() => setPasswordOpen(false)); + return; + } + if (location.pathname !== targetPath || location.search !== targetSearch) navigate(`${targetPath}${targetSearch}`); + }, [location.pathname, location.search, navigate, passwordOpen, requestDiscard]); + + // `status` is also used while a login or password request is in flight. + // Keep the login form mounted for those requests so the user sees the + // button's busy state instead of losing the entire form to a bootstrap + // spinner. `bootstrapRequestId` is only set by the initial session check. + if (isSessionBootstrapping(session)) return
; + if (session.status === "error") return

无法连接 TallyNote

{session.error || "请确认本地服务正在运行。"}

; + if (!session.admin) return setPasswordOpen(false)} + />; + if (session.admin.mustChangePassword) return notify("密码已更新", "success")} />; + + const content = passwordOpen + ? requestDiscard(() => setPasswordOpen(false))} onSuccess={() => { setPasswordOpen(false); notify("密码已更新", "success"); }} /> + : page === "dashboard" ? + : page === "expenses" ? + : page === "trash" ? + : page === "admins" ? + : page === "audit" ? + : ; + + return { if (!passwordOpen) requestDiscard(() => setPasswordOpen(true)); }}>}>
{content}
; +} + +function RouteErrorPage() { + const error = useRouteError(); + useEffect(() => { document.title = "页面出现问题 | TallyNote"; }, []); + const message = error instanceof Error && error.message.includes("网络") + ? "服务连接暂时不可用。" + : "页面暂时无法打开,请重新加载后再试。"; + return

页面出现问题

{message}

; +} + +const router = createBrowserRouter([{ path: "*", element: , errorElement: }]); +// Vite can re-evaluate this module during Fast Refresh while the existing DOM +// root is still mounted. Reusing the root prevents duplicate createRoot calls +// and the removeChild errors that follow a hot update. +type RootRegistry = Window & { __tallynoteReactRoot?: Root }; +const rootRegistry = window as RootRegistry; +const rootElement = document.getElementById("root"); +if (!rootElement) throw new Error("TallyNote root element is missing"); +const reactRoot = rootRegistry.__tallynoteReactRoot ?? createRoot(rootElement); +// Keep the same root across route and component hot updates. +rootRegistry.__tallynoteReactRoot = reactRoot; +reactRoot.render(); diff --git a/web-next/src/pages/admins/AdminsPage.tsx b/web-next/src/pages/admins/AdminsPage.tsx new file mode 100644 index 0000000..4bdde79 --- /dev/null +++ b/web-next/src/pages/admins/AdminsPage.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from "react"; +import { Copy, KeyRound, Plus, RotateCcw, ShieldCheck, UserRound, UserRoundCheck, UserRoundX } from "lucide-react"; +import { Button, Dialog, Drawer, Form, Space, Table, Tooltip } from "tdesign-react"; +import { api } from "../../services/api"; +import AccessibleInput from "../../components/AccessibleInput"; +import { apiFieldErrors } from "../../services/formErrors"; +import type { Admin } from "../../types/auth"; +import { dateText } from "../expenses/date"; +import { AsyncState, BusyIcon, ErrorBanner, Page, StatusTag } from "../common"; +import type { Notify } from "../expenses/types"; +import { useUnsavedActions, useUnsavedChanges } from "../../contexts/UnsavedChanges"; +import { useDrawerFocus } from "../../hooks/useDrawerFocus"; + +export default function AdminsPage({ currentAdmin, timezone = "Asia/Shanghai", notify }: { currentAdmin: Admin; timezone?: string; notify?: Notify }) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [showCreate, setShowCreate] = useState(false); + const [form, setForm] = useState({ username: "", displayName: "" }); + const [formError, setFormError] = useState(""); + const [formFields, setFormFields] = useState<{ username?: string; displayName?: string }>({}); + const [action, setAction] = useState<{ kind: "toggle" | "reset"; admin: Admin } | null>(null); + const [secret, setSecret] = useState(""); + const loadSequence = useRef(0); + const createInFlight = useRef(false); + const { requestDiscard } = useUnsavedActions(); + useDrawerFocus(showCreate, ".tn-admin-drawer"); + useUnsavedChanges(showCreate && Boolean(form.username || form.displayName)); + + const load = async () => { + const sequence = ++loadSequence.current; + setLoading(true); setError(""); + try { const result = await api<{ items: Admin[] }>("/api/admins"); if (sequence === loadSequence.current) setItems(result.items); } + catch (e) { if (sequence === loadSequence.current) setError((e as Error).message); } + finally { if (sequence === loadSequence.current) setLoading(false); } + }; + useEffect(() => { void load(); }, []); + + const create = async (event?: React.FormEvent) => { + if (event && typeof event.preventDefault === "function") event.preventDefault(); + if (busy || createInFlight.current) return; + setFormError(""); + setFormFields({}); + const username = form.username.normalize("NFKC").trim(); + const displayName = form.displayName.trim(); + const nextFields: typeof formFields = {}; + if ([...username].length < 3) nextFields.username = "用户名至少需要 3 个字符"; + if (!displayName) nextFields.displayName = "请输入显示名"; + if (Object.keys(nextFields).length) { setFormFields(nextFields); focusAdminField(nextFields); return; } + createInFlight.current = true; + setBusy(true); + try { const result = await api<{ temporaryPassword: string }>("/api/admins", { method: "POST", body: JSON.stringify({ username, displayName }) }); setShowCreate(false); setForm({ username: "", displayName: "" }); setSecret(result.temporaryPassword); notify?.("管理员已创建", "success"); await load(); } + catch (caught) { + const mapped = apiFieldErrors(caught); + if (Object.keys(mapped).length) { const fields = { username: mapped.username, displayName: mapped.displayName }; setFormFields(fields); focusAdminField(fields); } + else setFormError((caught as Error).message); + } + finally { createInFlight.current = false; setBusy(false); } + }; + const toggle = async () => { + if (!action) return; setBusy(true); + try { const next = action.admin.status === "active" ? "disabled" : "active"; await api(`/api/admins/${action.admin.id}/status`, { method: "PUT", body: JSON.stringify({ status: next, version: action.admin.version }) }); setAction(null); notify?.(next === "active" ? "管理员已启用" : "管理员已停用", "success"); await load(); } + catch (e) { setError((e as Error).message); setAction(null); } + finally { setBusy(false); } + }; + const reset = async () => { + if (!action) return; setBusy(true); + try { const result = await api<{ temporaryPassword: string }>(`/api/admins/${action.admin.id}/reset-password`, { method: "POST", body: JSON.stringify({ version: action.admin.version }) }); setAction(null); setSecret(result.temporaryPassword); notify?.("密码已重置,现有会话已失效", "success"); await load(); } + catch (e) { setError((e as Error).message); setAction(null); } + finally { setBusy(false); } + }; + + const columns = [ + { colKey: "username", title: "用户名", cell: ({ row }: any) => {row.username} }, + { colKey: "displayName", title: "显示名" }, + { colKey: "status", title: "状态", cell: ({ row }: any) => }, + { colKey: "lastLoginAt", title: "最近登录", cell: ({ row }: any) => row.lastLoginAt ? dateText(row.lastLoginAt, timezone) : "从未登录" }, + { colKey: "version", title: "版本", cell: ({ row }: any) => v{row.version} }, + { colKey: "actions", title: "操作", width: 250, cell: ({ row }: any) => }, + ]; + + return }> +

暂无管理员

: undefined} onRetry={() => void load()}>
+ { if (!busy) requestDiscard(() => setShowCreate(false)); }} footer={}> +
{ void create(); }}> { setForm({ ...form, username: value }); setFormFields(current => ({ ...current, username: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={64} autocomplete="off" /> { setForm({ ...form, displayName: value }); setFormFields(current => ({ ...current, displayName: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={80} />{formError &&
{formError}
} +
+ { if (!busy) setAction(null); }} onConfirm={() => void (action?.kind === "reset" ? reset() : toggle())} onCancel={() => { if (!busy) setAction(null); }}> + {action?.kind === "reset" ? <>将生成一次性临时密码,并立即使“{action.admin.displayName}”的现有会话失效。 : action?.admin.status === "active" ? "停用后该管理员的现有会话会立即失效。" : "启用后该管理员可以重新登录。"} + + setSecret("")} onConfirm={() => setSecret("")} onCancel={() => setSecret("")}>
{secret}

请通过安全渠道交给管理员。首次登录必须修改密码。

+ ; + + async function copySecret(value: string) { + try { + if (!navigator.clipboard) throw new Error("clipboard unavailable"); + await navigator.clipboard.writeText(value); + notify?.("临时密码已复制", "info"); + } catch { + notify?.("复制失败,请手动复制临时密码", "error"); + } + } +} + +function focusAdminField(fields: { username?: string; displayName?: string }) { + const selector = fields.username ? "[aria-invalid='true'], [aria-invalid='true'] input" : fields.displayName ? "[aria-invalid='true'], [aria-invalid='true'] input" : ""; + if (!selector) return; + const focus = () => { + const node = document.querySelector(selector); + const target = node?.matches("input") ? node : node?.querySelector("input"); + target?.focus(); + }; + requestAnimationFrame(focus); + window.setTimeout(focus, 60); +} diff --git a/web-next/src/pages/admins/index.ts b/web-next/src/pages/admins/index.ts new file mode 100644 index 0000000..e303b63 --- /dev/null +++ b/web-next/src/pages/admins/index.ts @@ -0,0 +1 @@ +export { default as AdminsPage } from "./AdminsPage"; diff --git a/web-next/src/pages/audit/AuditPage.tsx b/web-next/src/pages/audit/AuditPage.tsx new file mode 100644 index 0000000..2bf540b --- /dev/null +++ b/web-next/src/pages/audit/AuditPage.tsx @@ -0,0 +1,140 @@ +import { useEffect, useRef, useState } from "react"; +import { Archive, RotateCcw, Search } from "lucide-react"; +import { Button, Select, Space, Table, Tag } from "tdesign-react"; +import AccessibleInput from "../../components/AccessibleInput"; +import { useSearchParams } from "react-router-dom"; +import { api } from "../../services/api"; +import { dateText } from "../expenses/date"; +import { AsyncState, ErrorBanner, Page } from "../common"; + +type AuditItem = { id: number; occurredAt: number; requestId: string; actorUsername?: string | null; action: string; targetType: string; targetId?: string | null; outcome?: string }; + +function outcomeLabel(outcome?: string): string { + if (!outcome || outcome === "success") return "成功"; + if (outcome === "denied") return "已拒绝"; + if (outcome === "failure") return "失败"; + return outcome; +} + +const ACTION_LABELS: Record = { + deny: "拒绝请求", + "auth.login": "登录", + "auth.logout": "退出登录", + "auth.login_failed": "登录失败", + "expense.created": "创建账目", + "expense.updated": "更新账目", + "expense.status_changed": "切换报销状态", + "expense.trashed": "移入回收站", + "expense.restored": "恢复账目", + "expense.purged": "永久删除账目", + "expense.attachments_added": "添加附件", + "attachment.added": "添加附件", + "attachment.deleted": "删除附件", + "attachment.previewed": "预览附件", + "attachment.downloaded": "下载附件", + "export.created": "创建导出任务", + "export.downloaded": "下载导出文件", + "admin.created": "创建管理员", + "admin.updated": "更新管理员", + "admin.status_changed": "切换管理员状态", + "admin.password_changed": "修改管理员密码", + "admin.password_reset": "重置管理员密码", + "expense.attachment_deleted": "删除附件", + "expense.attachment_read": "读取附件", + "expense.purge": "永久删除账目", + "update.checked": "检查更新", + "update.apply_requested": "提交更新", +}; +const TARGET_LABELS: Record = { + expense: "账目", + admin: "管理员", + export: "导出任务", + session: "会话", + update: "更新任务", + system: "系统检查", +}; +const ALLOWED_TARGETS = new Set(["", "expense", "admin", "export", "session", "update", "system"]); + +export default function AuditPage({ timezone = "Asia/Shanghai" }: { timezone?: string }) { + const [searchParams, setSearchParams] = useSearchParams(); + const [items, setItems] = useState([]); + const rawAction = searchParams.get("action") || ""; + const action = rawAction.trim().slice(0, 100); + const targetParam = searchParams.get("targetType") || ""; + const targetType = ALLOWED_TARGETS.has(targetParam) ? targetParam : ""; + const [actionDraft, setActionDraft] = useState(action); + const [targetDraft, setTargetDraft] = useState(targetType); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(""); + const [hasMore, setHasMore] = useState(false); + const requestSequence = useRef(0); + const offsetRef = useRef(0); + const appendInFlight = useRef(false); + const pageSize = 100; + + const load = async (append = false) => { + if (append && appendInFlight.current) return; + if (append) appendInFlight.current = true; + const sequence = ++requestSequence.current; + if (append) setLoadingMore(true); else { setLoading(true); setLoadingMore(false); } + setError(""); + try { + const nextOffset = append ? offsetRef.current : 0; + const params = new URLSearchParams({ limit: String(pageSize), offset: String(nextOffset) }); + if (action.trim()) params.set("action", action.trim()); + if (targetType) params.set("targetType", targetType); + const result = await api<{ items: AuditItem[] }>(`/api/audit?${params}`); + if (sequence !== requestSequence.current) return; + setItems(current => { + if (!append) return result.items; + const existingIds = new Set(current.map(item => item.id)); + return [...current, ...result.items.filter(item => !existingIds.has(item.id))]; + }); + offsetRef.current = nextOffset + result.items.length; + setHasMore(result.items.length === pageSize); + } catch (e) { + if (sequence === requestSequence.current) setError((e as Error).message); + } + finally { + if (append) appendInFlight.current = false; + if (sequence === requestSequence.current) { if (append) setLoadingMore(false); else setLoading(false); } + } + }; + useEffect(() => { + const params = new URLSearchParams(searchParams); + let changed = false; + if (rawAction !== action) { + if (action) params.set("action", action); else params.delete("action"); + changed = true; + } + if (targetParam !== targetType) { + params.delete("targetType"); + changed = true; + } + if (changed) setSearchParams(params, { replace: true }); + }, [action, rawAction, searchParams, setSearchParams, targetParam, targetType]); + useEffect(() => { setActionDraft(action); setTargetDraft(targetType); }, [action, targetType]); + useEffect(() => { void load(); }, [action, targetType]); + const applyFilters = () => { + const params = new URLSearchParams(searchParams); + const nextAction = actionDraft.trim().slice(0, 100); + if (nextAction) params.set("action", nextAction); else params.delete("action"); + if (targetDraft) params.set("targetType", targetDraft); else params.delete("targetType"); + if (nextAction === action && targetDraft === targetType) void load(); + else setSearchParams(params); + }; + + const columns = [ + { colKey: "occurredAt", title: "时间", cell: ({ row }: any) => dateText(row.occurredAt, timezone) }, + { colKey: "actorUsername", title: "操作者", cell: ({ row }: any) => row.actorUsername || "系统" }, + { colKey: "action", title: "动作", cell: ({ row }: any) => {ACTION_LABELS[row.action] || row.action} }, + { colKey: "target", title: "目标", cell: ({ row }: any) => {TARGET_LABELS[row.targetType] || row.targetType}{row.targetId ? ` / ${row.targetId.slice(0, 8)}` : ""} }, + { colKey: "outcome", title: "结果", cell: ({ row }: any) => {outcomeLabel(row.outcome)} }, + ]; + + return void load()} disabled={loading || loadingMore} icon={}>刷新}> +
{ e.preventDefault(); applyFilters(); }}>} />
{hasMore &&
} + ; +} diff --git a/web-next/src/pages/audit/index.ts b/web-next/src/pages/audit/index.ts new file mode 100644 index 0000000..11df58d --- /dev/null +++ b/web-next/src/pages/audit/index.ts @@ -0,0 +1 @@ +export { default as AuditPage } from "./AuditPage"; diff --git a/web-next/src/pages/auth/ChangePassword.tsx b/web-next/src/pages/auth/ChangePassword.tsx new file mode 100644 index 0000000..ae7be45 --- /dev/null +++ b/web-next/src/pages/auth/ChangePassword.tsx @@ -0,0 +1,2 @@ +export { default } from "./ChangePasswordPage"; +export * from "./ChangePasswordPage"; diff --git a/web-next/src/pages/auth/ChangePasswordPage.tsx b/web-next/src/pages/auth/ChangePasswordPage.tsx new file mode 100644 index 0000000..f42227b --- /dev/null +++ b/web-next/src/pages/auth/ChangePasswordPage.tsx @@ -0,0 +1,133 @@ +import { useMemo, useState } from "react"; +import { ArrowLeft, Check, KeyRound, ShieldCheck } from "lucide-react"; +import { Alert, Button, Form } from "tdesign-react"; +import { changePasswordThunk, clearSessionError, useAppDispatch, useAppSelector } from "../../store"; +import type { Admin } from "../../types/auth"; +import { StarterPasswordInput } from "./StarterAuth"; +import { Page } from "../common"; +import { useUnsavedChanges } from "../../contexts/UnsavedChanges"; + +export interface ChangePasswordPageProps { + admin?: Admin | null; + onSuccess?: (admin: Admin) => void; + onCancel?: () => void; + returnLabel?: string; + firstLogin?: boolean; +} + +export default function ChangePasswordPage({ admin, onSuccess, onCancel, returnLabel = "返回当前页面", firstLogin = false }: ChangePasswordPageProps) { + const dispatch = useAppDispatch(); + const sessionAdmin = useAppSelector((state) => state.session.admin); + const { status, error } = useAppSelector((state) => state.session); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [formError, setFormError] = useState(null); + const [fieldErrors, setFieldErrors] = useState<{ newPassword?: string; confirmation?: string }>({}); + const busy = status === "loading"; + const dirty = Boolean(currentPassword || newPassword || confirmation); + useUnsavedChanges(dirty); + const displayAdmin = admin ?? sessionAdmin; + const isFirstLogin = firstLogin || Boolean(displayAdmin?.mustChangePassword); + const displayName = displayAdmin?.displayName || displayAdmin?.username || "当前管理员"; + const requirements = useMemo(() => [ + { label: "至少 12 个字符", valid: newPassword.length >= 12 }, + { label: "两次输入保持一致", valid: Boolean(confirmation) && newPassword === confirmation }, + ], [confirmation, newPassword]); + + const submit = async () => { + setFormError(null); + setFieldErrors({}); + if (newPassword.length < 12) { + setFieldErrors({ newPassword: "新密码至少需要 12 位" }); + focusPasswordField("新密码"); + return; + } + if (newPassword !== confirmation) { + setFieldErrors({ confirmation: "两次输入的新密码不一致" }); + focusPasswordField("确认新密码"); + return; + } + try { + const updated = await dispatch(changePasswordThunk({ currentPassword, newPassword })).unwrap(); + // Clear the local draft before notifying the parent. This keeps the + // unsaved-changes guard correct even when a host keeps this page mounted. + setCurrentPassword(""); + setNewPassword(""); + setConfirmation(""); + setFieldErrors({}); + onSuccess?.(updated); + } catch { + // The rejected thunk exposes its server message through session.error. + } + }; + + const form = { if (context.validateResult === true) void submit(); }} + > + + { setCurrentPassword(value); setFormError(null); dispatch(clearSessionError()); }} autocomplete="current-password" disabled={busy} placeholder="请输入当前密码" /> + + + { setNewPassword(value); setFieldErrors(current => ({ ...current, newPassword: undefined })); setFormError(null); dispatch(clearSessionError()); }} autocomplete="new-password" disabled={busy} placeholder="请输入至少 12 位的新密码" /> + {fieldErrors.newPassword && {fieldErrors.newPassword}} + + + { setConfirmation(value); setFieldErrors(current => ({ ...current, confirmation: undefined })); setFormError(null); dispatch(clearSessionError()); }} autocomplete="new-password" disabled={busy} placeholder="请再次输入新密码" /> + {fieldErrors.confirmation && {fieldErrors.confirmation}} + + +
+ +
+
+ ; + + const panel =
+
+ +
+

{isFirstLogin ? "设置登录密码" : "修改登录密码"}

+

{isFirstLogin ? "首次登录需要先设置一个新的登录密码。" : `当前账号:${displayName}`}

+
+
+ {(formError || error) &&
} +
+
{form}
+ +
+
; + + if (!isFirstLogin) { + return }>{returnLabel} : undefined} className="tn-password-page">{panel}; + } + + return
+
+
+

首次登录保护

+

管理员 {displayName} 需要先设置新密码。

+
+ {panel} +
+
; +} + +function focusPasswordField(label: string) { + const focus = () => { + const nodes = Array.from(document.querySelectorAll("[aria-invalid='true'], [aria-invalid='true'] input, [aria-invalid='true'] textarea")); + const target = nodes.find(node => node.closest(".t-form__item")?.textContent?.includes(label)); + target?.focus(); + }; + requestAnimationFrame(focus); + window.setTimeout(focus, 60); +} diff --git a/web-next/src/pages/auth/Login.tsx b/web-next/src/pages/auth/Login.tsx new file mode 100644 index 0000000..358f81b --- /dev/null +++ b/web-next/src/pages/auth/Login.tsx @@ -0,0 +1,2 @@ +export { default } from "./LoginPage"; +export * from "./LoginPage"; diff --git a/web-next/src/pages/auth/LoginPage.tsx b/web-next/src/pages/auth/LoginPage.tsx new file mode 100644 index 0000000..9494766 --- /dev/null +++ b/web-next/src/pages/auth/LoginPage.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { Alert, Button, Checkbox, Form } from "tdesign-react"; +import { useAppDispatch, useAppSelector, clearSessionError, loginThunk } from "../../store"; +import type { Admin } from "../../types/auth"; +import { LoginUserIcon, StarterPasswordInput } from "./StarterAuth"; +import AccessibleInput from "../../components/AccessibleInput"; + +const REMEMBERED_USERNAME_KEY = "tallynote.login.username"; + +export interface LoginPageProps { + notice?: string; + onSuccess?: (admin: Admin) => void; +} + +function getRememberedUsername(): string { + if (typeof window === "undefined") return ""; + try { + return window.localStorage.getItem(REMEMBERED_USERNAME_KEY) ?? ""; + } catch { + return ""; + } +} + +export default function LoginPage({ notice, onSuccess }: LoginPageProps) { + const dispatch = useAppDispatch(); + const { status, error } = useAppSelector((state) => state.session); + const [username, setUsername] = useState(getRememberedUsername); + const [password, setPassword] = useState(""); + const [rememberAccount, setRememberAccount] = useState(Boolean(username)); + const [fieldErrors, setFieldErrors] = useState<{ username?: string; password?: string }>({}); + const busy = status === "loading"; + + const updateUsername = (value: string) => { + setUsername(value); + setFieldErrors(current => ({ ...current, username: undefined })); + if (error) dispatch(clearSessionError()); + }; + + const updatePassword = (value: string) => { + setPassword(value); + setFieldErrors(current => ({ ...current, password: undefined })); + if (error) dispatch(clearSessionError()); + }; + + const submit = async () => { + const normalizedUsername = username.trim(); + const nextErrors: { username?: string; password?: string } = {}; + if (!normalizedUsername) nextErrors.username = "请输入用户名"; + if (!password) nextErrors.password = "请输入密码"; + if (Object.keys(nextErrors).length) { + setFieldErrors(nextErrors); + const focusInvalid = () => document.querySelector("[aria-invalid='true'], [aria-invalid='true'] input, [aria-invalid='true'] textarea")?.focus(); + requestAnimationFrame(focusInvalid); + window.setTimeout(focusInvalid, 60); + return; + } + setFieldErrors({}); + try { + if (rememberAccount) { + window.localStorage.setItem(REMEMBERED_USERNAME_KEY, normalizedUsername); + } else { + window.localStorage.removeItem(REMEMBERED_USERNAME_KEY); + } + } catch { + // Private browsing or a locked-down browser may reject localStorage. + } + try { + const admin = await dispatch(loginThunk({ username: normalizedUsername, password })).unwrap(); + onSuccess?.(admin); + } catch { + // The rejected thunk exposes its server message through session.error. + } + }; + + return ( +
+
+
+

登录到 TallyNote

+
+ +
{ context.e?.preventDefault(); void submit(); }} + > + {notice &&
} + {error &&
} + + updateUsername(String(value))} + clearable + disabled={busy} + autocomplete="username" + autofocus + prefixIcon={} + placeholder="请输入用户名" + /> + {fieldErrors.username && {fieldErrors.username}} + + + + {fieldErrors.password && {fieldErrors.password}} + +
记住账号
+ + + + + +
+
+ ); +} diff --git a/web-next/src/pages/auth/StarterAuth.tsx b/web-next/src/pages/auth/StarterAuth.tsx new file mode 100644 index 0000000..b72ed6f --- /dev/null +++ b/web-next/src/pages/auth/StarterAuth.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import AccessibleInput from "../../components/AccessibleInput"; +import { Eye, EyeOff, LockKeyhole, UserRound, X } from "lucide-react"; + +export interface StarterPasswordInputProps { + value: string; + onChange: (value: string) => void; + placeholder: string; + ariaLabel: string; + autocomplete?: string; + disabled?: boolean; + ariaInvalid?: boolean; + ariaDescribedby?: string; +} + +/** Starter-style password field with clear and show/hide affordances. */ +export function StarterPasswordInput({ + value, + onChange, + placeholder, + ariaLabel, + autocomplete, + disabled = false, + ariaInvalid, + ariaDescribedby, +}: StarterPasswordInputProps) { + const [visible, setVisible] = useState(false); + const toggle = () => setVisible((current) => !current); + + return ( + onChange(String(next))} + type={visible ? "text" : "password"} + disabled={disabled} + autocomplete={autocomplete} + prefixIcon={} + // A defined (empty) suffix prevents TDesign's password icon from being + // added in addition to the Starter-style control below. + suffixIcon={
: } + + } + ; +} + +function DashboardCardTitle({ title, subtitle }: { title: string; subtitle: string }) { + return
{title}{subtitle}
; +} diff --git a/web-next/src/pages/dashboard/index.ts b/web-next/src/pages/dashboard/index.ts new file mode 100644 index 0000000..7e1e0a7 --- /dev/null +++ b/web-next/src/pages/dashboard/index.ts @@ -0,0 +1,2 @@ +export { default as DashboardPage } from "./DashboardPage"; +export { default } from "./DashboardPage"; diff --git a/web-next/src/pages/expenses/ExpenseDetail.tsx b/web-next/src/pages/expenses/ExpenseDetail.tsx new file mode 100644 index 0000000..709819a --- /dev/null +++ b/web-next/src/pages/expenses/ExpenseDetail.tsx @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { AlertCircle, ArrowDownToLine, FileText, Image as ImageIcon, Loader2, Search, Settings, Trash2, X } from "lucide-react"; +import { Button, Dialog, Drawer, Loading, Space, Tag, Textarea, Tooltip } from "tdesign-react"; +import { api, ApiError } from "../../services/api"; +import { dateText, formatBytes, money } from "./date"; +import type { Attachment, Expense, Notify, TimelineEvent } from "./types"; +import { useDrawerFocus } from "../../hooks/useDrawerFocus"; + +type Props = { expense: Expense; timezone?: string; onClose: () => void; onUpdated: () => void; onRequestEdit: (expense: Expense) => void; notify?: Notify }; +const TIMELINE_LABELS: Record = { + "expense.created": "创建账目", + "expense.updated": "更新账目", + "expense.status_changed": "切换报销状态", + "expense.trashed": "移入回收站", + "expense.restored": "从回收站恢复", + "attachment.added": "添加附件", + "attachment.deleted": "删除附件", +}; +export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onClose, onUpdated, onRequestEdit, notify }: Props) { + const [detail, setDetail] = useState(expense); const [timeline, setTimeline] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); const [action, setAction] = useState<"status" | "trash" | null>(null); const [removeTarget, setRemoveTarget] = useState(null); const [removeReason, setRemoveReason] = useState(""); const [preview, setPreview] = useState(null); const [removeError, setRemoveError] = useState(""); + const requestSequence = useRef(0); + useDrawerFocus(true, ".tn-detail-drawer"); + const load = useCallback(async () => { + const currentSequence = ++requestSequence.current; + const currentExpenseId = expense.id; + setLoading(true); + setError(""); + try { + const result = await api<{ expense: Expense; timeline: TimelineEvent[] }>(`/api/expenses/${currentExpenseId}`); + if (currentSequence !== requestSequence.current || result.expense.id !== currentExpenseId) return; + setDetail(result.expense); + setTimeline(result.timeline || []); + } catch (caught) { + if (currentSequence === requestSequence.current) setError((caught as Error).message); + } finally { + if (currentSequence === requestSequence.current) setLoading(false); + } + }, [expense.id]); + useEffect(() => { + setDetail(expense); + setTimeline([]); + setRemoveTarget(null); + setPreview(null); + void load(); + return () => { requestSequence.current += 1; }; + }, [expense.id, load]); + const recoverConflict = (caught: unknown, setMessage: (message: string) => void) => { + if (!(caught instanceof ApiError) || caught.status !== 409) return false; + const current = (caught.details as { current?: Expense } | undefined)?.current; + if (!current) return false; + setDetail(current); + setMessage("这笔账目刚被其他管理员修改,已加载最新版本,请确认后重试。"); + return true; + }; + const updateStatus = async () => { setBusy(true); try { const next = detail.status === "reimbursed" ? "unreimbursed" : "reimbursed"; const result = await api<{ expense: Expense }>(`/api/expenses/${detail.id}/status`, { method: "POST", body: JSON.stringify({ status: next, version: detail.version }) }); setDetail(result.expense); setAction(null); notify?.(next === "reimbursed" ? "已标记为已报销" : "已改回未报销", "success"); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setError)) setError((caught as Error).message); setAction(null); } finally { setBusy(false); } }; + const trash = async () => { setBusy(true); try { await api(`/api/expenses/${detail.id}`, { method: "DELETE", body: JSON.stringify({ version: detail.version }) }); notify?.("账目已移入回收站", "success"); setAction(null); onClose(); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setError)) setError((caught as Error).message); setAction(null); } finally { setBusy(false); } }; + const remove = async () => { if (!removeTarget) return; const requires = removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim(); if (requires && !removeReason.trim()) { setRemoveError("请填写无发票原因"); return; } setBusy(true); setRemoveError(""); try { const body: { version: number; invoiceMissingReason?: string } = { version: detail.version }; if (requires) body.invoiceMissingReason = removeReason.trim(); const result = await api<{ expense: Expense }>(`/api/attachments/${removeTarget.id}`, { method: "DELETE", body: JSON.stringify(body) }); setDetail(result.expense); setRemoveTarget(null); notify?.("附件已删除", "success"); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setRemoveError)) setRemoveError((caught as Error).message); } finally { setBusy(false); } }; + return <> + }> + {loading ?
: error ?
{error}
:
+
{money(detail.amountCents)}
+
支付时间
{dateText(detail.paidAt, timezone)}
发票
{detail.invoiceCount > 0 ? `${detail.invoiceCount} 张` : detail.invoiceMissingReason ? <>无发票:{detail.invoiceMissingReason} : 未说明}
状态
{detail.status === "reimbursed" ? "已报销" : "未报销"}
备注
{detail.note || "无"}
+

附件 {detail.attachments?.length || 0}

{(detail.attachments || []).map(a => { const protectsLastProof = a.kind === "payment_proof" && detail.paymentProofCount <= 1; return
{a.mimeType.startsWith("image/") ? : } {a.originalName}{formatBytes(a.sizeBytes)}{a.previewable && }
; })}
+ {timeline.length > 0 && <>

操作记录

{timeline.slice(0, 12).map(t =>
{dateText(t.occurredAt, timezone)}{TIMELINE_LABELS[t.action] || t.action}{t.actorUsername || "系统"}
)}
} +
} +
+ { if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "这笔账目会重新出现在未报销列表。" : "确认这笔账目已完成报销,并从未报销列表移出?"} + { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>账目会从普通列表和导出结果中隐藏,附件会保留,可在回收站恢复。 + { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <>

{removeTarget.kind === "payment_proof" ? "账目至少需要保留一张付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "这是最后一张发票。删除后必须填写无发票原因。" : "将删除这张发票。"}

{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() &&