From 12495fb6a40dc0c97b7592e0533351f451a83a92 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Mon, 31 Aug 2026 20:11:34 +0800 Subject: [PATCH] release: 1.1.0 --- .gitignore | 2 + README.md | 19 +- docs/release.md | 10 +- package.json | 15 +- playwright.config.ts | 18 +- pnpm-lock.yaml | 529 +++++++++++++++++- server/app.ts | 165 +++++- server/exporter.ts | 5 +- server/files.ts | 27 +- shared/contracts.ts | 4 +- tests/api.test.ts | 94 ++++ tests/core.test.ts | 9 +- tests/e2e/smoke.spec.ts | 27 +- tests/update-api.test.ts | 18 +- tests/update.test.ts | 6 +- tests/web-foundation.test.ts | 67 +++ tsconfig.web-next.json | 14 + vite.config.ts | 6 +- vite.next.config.ts | 35 ++ web-next/index.html | 10 + .../src/assets/login-background-tech.webp | Bin 0 -> 68160 bytes web-next/src/components/AccessibleInput.tsx | 28 + web-next/src/contexts/UnsavedChanges.tsx | 85 +++ .../src/contexts/UnsavedChangesContext.ts | 19 + web-next/src/hooks/useDialogAccessibility.ts | 142 +++++ web-next/src/hooks/useDrawerFocus.ts | 152 +++++ web-next/src/layouts/AppLayout.tsx | 104 ++++ web-next/src/layouts/Content.tsx | 15 + web-next/src/layouts/Header.tsx | 104 ++++ web-next/src/layouts/Menu.tsx | 46 ++ web-next/src/layouts/index.ts | 8 + web-next/src/main.tsx | 130 +++++ web-next/src/pages/admins/AdminsPage.tsx | 115 ++++ web-next/src/pages/admins/index.ts | 1 + web-next/src/pages/audit/AuditPage.tsx | 140 +++++ web-next/src/pages/audit/index.ts | 1 + web-next/src/pages/auth/ChangePassword.tsx | 2 + .../src/pages/auth/ChangePasswordPage.tsx | 133 +++++ web-next/src/pages/auth/Login.tsx | 2 + web-next/src/pages/auth/LoginPage.tsx | 128 +++++ web-next/src/pages/auth/StarterAuth.tsx | 58 ++ web-next/src/pages/auth/index.ts | 4 + web-next/src/pages/common.tsx | 52 ++ .../src/pages/dashboard/DashboardPage.tsx | 165 ++++++ web-next/src/pages/dashboard/index.ts | 2 + web-next/src/pages/expenses/ExpenseDetail.tsx | 72 +++ web-next/src/pages/expenses/ExpenseDrawer.tsx | 169 ++++++ web-next/src/pages/expenses/ExpensesPage.tsx | 131 +++++ web-next/src/pages/expenses/date.ts | 26 + web-next/src/pages/expenses/index.ts | 5 + web-next/src/pages/expenses/types.ts | 6 + web-next/src/pages/trash/TrashPage.tsx | 67 +++ web-next/src/pages/trash/index.ts | 1 + web-next/src/pages/update/UpdatePage.tsx | 113 ++++ web-next/src/pages/update/index.ts | 1 + web-next/src/router/index.ts | 49 ++ web-next/src/services/api.ts | 144 +++++ web-next/src/services/auth.ts | 27 + web-next/src/services/formErrors.ts | 13 + web-next/src/services/index.ts | 2 + web-next/src/store/index.ts | 17 + web-next/src/store/sessionSlice.ts | 156 ++++++ web-next/src/styles/theme.css | 525 +++++++++++++++++ web-next/src/types/auth.ts | 41 ++ web-next/src/utils/date.ts | 87 +++ web-next/src/utils/money.ts | 24 + 66 files changed, 4315 insertions(+), 77 deletions(-) create mode 100644 tests/web-foundation.test.ts create mode 100644 tsconfig.web-next.json create mode 100644 vite.next.config.ts create mode 100644 web-next/index.html create mode 100644 web-next/src/assets/login-background-tech.webp create mode 100644 web-next/src/components/AccessibleInput.tsx create mode 100644 web-next/src/contexts/UnsavedChanges.tsx create mode 100644 web-next/src/contexts/UnsavedChangesContext.ts create mode 100644 web-next/src/hooks/useDialogAccessibility.ts create mode 100644 web-next/src/hooks/useDrawerFocus.ts create mode 100644 web-next/src/layouts/AppLayout.tsx create mode 100644 web-next/src/layouts/Content.tsx create mode 100644 web-next/src/layouts/Header.tsx create mode 100644 web-next/src/layouts/Menu.tsx create mode 100644 web-next/src/layouts/index.ts create mode 100644 web-next/src/main.tsx create mode 100644 web-next/src/pages/admins/AdminsPage.tsx create mode 100644 web-next/src/pages/admins/index.ts create mode 100644 web-next/src/pages/audit/AuditPage.tsx create mode 100644 web-next/src/pages/audit/index.ts create mode 100644 web-next/src/pages/auth/ChangePassword.tsx create mode 100644 web-next/src/pages/auth/ChangePasswordPage.tsx create mode 100644 web-next/src/pages/auth/Login.tsx create mode 100644 web-next/src/pages/auth/LoginPage.tsx create mode 100644 web-next/src/pages/auth/StarterAuth.tsx create mode 100644 web-next/src/pages/auth/index.ts create mode 100644 web-next/src/pages/common.tsx create mode 100644 web-next/src/pages/dashboard/DashboardPage.tsx create mode 100644 web-next/src/pages/dashboard/index.ts create mode 100644 web-next/src/pages/expenses/ExpenseDetail.tsx create mode 100644 web-next/src/pages/expenses/ExpenseDrawer.tsx create mode 100644 web-next/src/pages/expenses/ExpensesPage.tsx create mode 100644 web-next/src/pages/expenses/date.ts create mode 100644 web-next/src/pages/expenses/index.ts create mode 100644 web-next/src/pages/expenses/types.ts create mode 100644 web-next/src/pages/trash/TrashPage.tsx create mode 100644 web-next/src/pages/trash/index.ts create mode 100644 web-next/src/pages/update/UpdatePage.tsx create mode 100644 web-next/src/pages/update/index.ts create mode 100644 web-next/src/router/index.ts create mode 100644 web-next/src/services/api.ts create mode 100644 web-next/src/services/auth.ts create mode 100644 web-next/src/services/formErrors.ts create mode 100644 web-next/src/services/index.ts create mode 100644 web-next/src/store/index.ts create mode 100644 web-next/src/store/sessionSlice.ts create mode 100644 web-next/src/styles/theme.css create mode 100644 web-next/src/types/auth.ts create mode 100644 web-next/src/utils/date.ts create mode 100644 web-next/src/utils/money.ts 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 0000000000000000000000000000000000000000..523b497ccfa0360ac6add5bc1aca1ca005912890 GIT binary patch literal 68160 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 zuZr7(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() &&