Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcc63b8117 | ||
|
|
a268eb5fe9 | ||
|
|
4395317651 | ||
|
|
4b9c80cc3a | ||
|
|
4434acf697 | ||
|
|
12495fb6a4 |
+4
-3
@@ -27,9 +27,10 @@ TALLYNOTE_INSTALL_PREFIX=./
|
||||
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
|
||||
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
|
||||
TALLYNOTE_UPDATE_MAX_MB=512
|
||||
# One-click/systemd updates require an Ed25519 signature over SHA256SUMS.
|
||||
# Keep this file root-readable and point to a root-managed public key.
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true
|
||||
# SHA-256 is always required. Detached Ed25519 signatures are optional; set
|
||||
# this to true only when a root-managed public key is configured below.
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false
|
||||
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
|
||||
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
|
||||
TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=15
|
||||
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
||||
|
||||
@@ -32,10 +32,9 @@ jobs:
|
||||
pnpm test
|
||||
- name: Build Linux release
|
||||
run: pnpm release:build "${GITHUB_REF_NAME#v}" ./release
|
||||
- name: Create and publish signed Gitea Release
|
||||
- name: Create and publish Gitea Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY: ${{ secrets.TALLYNOTE_RELEASE_SIGNING_KEY }}
|
||||
run: ./scripts/publish-gitea-release.sh "$GITHUB_REF_NAME" ./release
|
||||
|
||||
# Linux x86 (i386/i686) is intentionally not published: Node.js 24 and the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
@@ -42,17 +51,43 @@ pnpm start
|
||||
|
||||
安装器正式支持 **Linux x86_64(x64)**,脚本和运行时也支持在对应原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。ARMv7/ARM32 仅实验性支持;Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持,因为 Node.js 24 和项目原生依赖没有可维护的官方构建。不要在 32 位系统上强行安装。
|
||||
|
||||
发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元,以及 `SHA256SUMS` 和 `SHA256SUMS.sig`。安装器默认 dry-run,只有显式 `--apply` 才会下载或写盘;正式安装必须提供独立核对过的 Ed25519 公钥:
|
||||
发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元、`uninstall.sh`,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本:
|
||||
|
||||
```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 \
|
||||
--signing-key /root/tallynote-update.pub \
|
||||
--update-public-key-file /root/tallynote-update.pub
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
指定版本时,脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 获取归档、`SHA256SUMS` 和签名。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。`--allow-unsigned` 仅供隔离开发机测试,不能用于公网或真实财务数据。
|
||||
脚本会从公开仓库的 latest Release 获取当前架构归档和 `SHA256SUMS`,并在安装前始终校验 SHA-256。也可以通过 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL`、`TALLYNOTE_RELEASE_ALLOWED_HOSTS` 和 `--release-base-url` 指向自己的仓库或受信 CDN。需要固定版本或预览时,仍可使用 `TALLYNOTE_VERSION`、`--version` 或 `--dry-run` 等高级选项。
|
||||
|
||||
安装过程会持续输出带统一前缀的阶段日志,不会在下载、校验或启动服务时静默等待。交互式 SSH/终端中下载还会显示 curl 进度条;非交互式运行(例如 CI)只输出干净的阶段日志。典型输出如下(版本号、架构和耗时会按实际环境变化):
|
||||
|
||||
```text
|
||||
tallynote installer: [阶段] 检查运行环境、权限和目标架构
|
||||
tallynote installer: [完成] 运行环境可用:x64/glibc
|
||||
tallynote installer: [阶段] 从 Release API 获取最新版本
|
||||
tallynote installer: [完成] 已解析最新版本:1.1.2
|
||||
tallynote installer: [完成] Release 下载地址已准备
|
||||
tallynote installer: [阶段] 获取发布包:tallynote-1.1.2-linux-x64-glibc.tar.gz
|
||||
tallynote installer: [完成] 发布包已下载并通过大小限制
|
||||
tallynote installer: [阶段] 获取 SHA-256 校验清单
|
||||
tallynote installer: [完成] SHA-256 校验清单已准备
|
||||
tallynote installer: [阶段] 校验 SHA-256 和发布签名
|
||||
tallynote installer: [完成] 发布包校验通过
|
||||
tallynote installer: [阶段] 解包、校验包结构并原子切换到版本 1.1.2
|
||||
tallynote installer: [完成] 版本 1.1.2 已切换为当前版本
|
||||
tallynote installer: [阶段] 安装 systemd 单元、更新辅助程序和卸载器
|
||||
tallynote installer: [完成] systemd 单元、更新辅助程序和卸载器已安装
|
||||
tallynote installer: [阶段] 重新加载 systemd 并启动 TallyNote
|
||||
tallynote installer: [完成] TallyNote 服务已启用并启动
|
||||
tallynote installer: [阶段] 清理旧版本并完成安装
|
||||
tallynote installer: [完成] 旧版本清理完成
|
||||
tallynote installer: [完成] 安装完成:TallyNote 1.1.2
|
||||
tallynote installer: 查看服务状态:systemctl status tallynote.service
|
||||
```
|
||||
|
||||
任何阶段失败都会以 `tallynote installer:` 前缀写出原因并立即停止;不会把不完整版本切换为当前版本。
|
||||
|
||||
如需额外启用签名校验,在环境中设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;不设置时不会要求公钥或 `SHA256SUMS.sig`。
|
||||
|
||||
已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--allow-downgrade`,正常更新不会覆盖当前或更高版本。
|
||||
|
||||
@@ -60,11 +95,31 @@ curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
|
||||
升级有两种方式:
|
||||
|
||||
1. 后台进入“系统更新”,点击“检查更新”后确认版本。应用只会把经过 HTTPS、主机白名单、SHA-256 和 Ed25519 签名校验的请求写入队列;root 权限的 `tallynote-update.path`/`tallynote-update.service` 会重新获取配置源、验证签名,再执行停机、备份、切换和健康检查。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。
|
||||
1. 后台进入“系统更新”,点击“检查更新”后可先“下载更新包”,等待校验完成,再点击“立即更新”。应用只会把经过 HTTPS、主机白名单和 SHA-256 校验的请求写入队列;如果显式配置了公钥,再额外验证 Ed25519 签名。下载阶段主服务保持运行;应用阶段才会停机、备份、切换和健康检查,页面会显示重启倒计时并自动重试连接。Web 进程没有 `systemctl` 权限,队列中的 URL、文件地址和摘要不会直接驱动 root 下载。
|
||||
2. 手动执行 `sudo /usr/local/sbin/tallynote-update --rollback` 可切回上一份 release。更新失败会自动保留旧版本并尝试恢复;不要删除 `/var/lib/tallynote`。
|
||||
|
||||
更新任务详情按发起管理员隔离;失败信息在浏览器中使用固定提示,不暴露服务器路径、命令输出或上游响应。系统同一时刻只允许一个更新任务。
|
||||
|
||||
### 卸载
|
||||
|
||||
安装完成后会提供 `/usr/local/sbin/tallynote-uninstall`。普通卸载会停止并禁用 TallyNote 的 systemd 单元,删除当前版本、更新辅助程序和已知配置,但保留 `/var/lib/tallynote` 以及更新备份,方便以后重新安装:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-uninstall
|
||||
```
|
||||
|
||||
如果确认不再需要数据库、附件、暂存、导出和更新备份,必须显式同时提供 `--purge-data --yes`:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-uninstall --purge-data --yes --purge-config
|
||||
```
|
||||
|
||||
卸载检测到未完成的更新状态时会停止并要求人工确认;确认更新已停止后再加 `--force`。也可以直接从公开仓库获取同一脚本执行普通卸载:
|
||||
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/uninstall.sh | sudo bash
|
||||
```
|
||||
|
||||
公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。
|
||||
|
||||
### 构建发布包
|
||||
@@ -73,17 +128,17 @@ curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm release:build 1.0.1 ./release
|
||||
pnpm release:build 1.1.2 ./release
|
||||
```
|
||||
|
||||
将生成的 `tallynote-<版本>-linux-<架构>-<libc>.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。
|
||||
将生成的 `tallynote-<版本>-linux-<架构>-<libc>.tar.gz` 上传到同一个 Gitea Release。推荐由 `.gitea/workflows/release.yml` 自动执行 `scripts/publish-gitea-release.sh`,统一生成并上传 `SHA256SUMS`;如果 CI 提供签名私钥,还会额外上传 `SHA256SUMS.sig`。CI 只需要 `GITEA_TOKEN`;签名私钥属于可选增强。
|
||||
|
||||
版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.0.1` 与 `v1.0.1`),workflow 会在构建前拒绝不一致的 tag。发布一个版本:
|
||||
版本由 `package.json` 和 Git tag 双重约束:两者必须相同(例如 `1.1.2` 与 `v1.1.2`),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.2"
|
||||
git tag -a v1.1.2 -m "TallyNote 1.1.2"
|
||||
git push origin main --follow-tags
|
||||
```
|
||||
|
||||
@@ -100,4 +155,4 @@ docker compose run --rm --no-deps tallynote node dist/server/cli/admin-init.js -
|
||||
|
||||
业务导出不是系统备份。停服后复制完整数据目录(数据库、WAL/SHM、`files/`、`staging/`、`exports/` 和更新任务文件),恢复时保持目录 `0700`、文件 `0600` 权限,并在启动前确保没有其他 TallyNote 进程使用该目录。更新器会在切换前额外写入 `/var/lib/tallynote-backups/`,但仍建议保留服务器级备份。
|
||||
|
||||
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256/签名的归档、路径穿越、特殊文件和符号链接;附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
|
||||
应用层会拒绝非 HTTPS 更新源、未匹配主机、无 SHA-256 的归档、路径穿越、特殊文件和符号链接;启用签名要求时也会拒绝无有效签名的归档。附件与导出下载需要登录并写入审计。拥有服务器文件权限的人仍然可以直接读取 SQLite 和附件,部署时应限制 SSH、备份和磁盘权限,并通过 HTTPS 反代访问。
|
||||
|
||||
+48
-18
@@ -6,48 +6,66 @@ TallyNote 的发布包必须在目标 Linux 架构上构建。`better-sqlite3`
|
||||
|
||||
## 自动发布
|
||||
|
||||
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.0.1`)会触发 `.gitea/workflows/release.yml`:
|
||||
向 Gitea 推送符合 SemVer 的 tag(例如 `v1.1.2`)会触发 `.gitea/workflows/release.yml`:
|
||||
|
||||
1. 在 Linux runner 上安装依赖,执行 `pnpm check`、`pnpm test` 和 `pnpm release:build`。
|
||||
2. 由 `scripts/publish-gitea-release.sh` 计算所有归档的 `SHA256SUMS`。
|
||||
3. 用 Ed25519 私钥生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和签名。
|
||||
3. 如果提供 Ed25519 私钥则生成 `SHA256SUMS.sig`,通过 Gitea Releases API 创建/复用对应 Release,并幂等上传归档、清单和可选签名。
|
||||
|
||||
在仓库的 Actions secrets 配置:
|
||||
|
||||
- `GITEA_TOKEN`:仅授予当前仓库 Release 写权限的 token。
|
||||
- `TALLYNOTE_RELEASE_SIGNING_KEY`:Ed25519 私钥 PEM。它只作为 CI secret 使用,绝不能提交到 Git。
|
||||
- `TALLYNOTE_RELEASE_SIGNING_KEY`:可选的 Ed25519 私钥 PEM。它只作为 CI secret 使用,绝不能提交到 Git。
|
||||
|
||||
也可以在 Linux 发布机上手动执行:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm check && pnpm test
|
||||
pnpm release:build 1.0.1 ./release
|
||||
pnpm release:build 1.1.2 ./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.2 ./release
|
||||
```
|
||||
|
||||
发布资产名称必须包含当前平台,例如 `tallynote-1.0.1-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS` 和一个 `SHA256SUMS.sig`,清单签名覆盖其完整原文。
|
||||
发布资产名称必须包含当前平台,例如 `tallynote-1.1.2-linux-x64-glibc.tar.gz`。同一个 Release 只保留一个 `SHA256SUMS`;有签名时再保留一个 `SHA256SUMS.sig`,签名覆盖清单完整原文。
|
||||
|
||||
## curl 安装
|
||||
|
||||
安装器默认只做 dry-run;只有显式 `--apply` 才会下载或写盘。正式安装必须同时提供 Ed25519 公钥和 `SHA256SUMS.sig`,公钥应通过独立的受信渠道核对指纹。下面示例假设公钥已安全放在服务器 `/root/tallynote-update.pub`:
|
||||
安装器默认直接获取并安装 latest Release。它始终校验 `SHA256SUMS` 中的 SHA-256,不要求公钥或签名文件:
|
||||
|
||||
```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 \
|
||||
--signing-key /root/tallynote-update.pub \
|
||||
--update-public-key-file /root/tallynote-update.pub
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档、`SHA256SUMS` 和 `SHA256SUMS.sig`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。
|
||||
脚本会从 `https://git.awaioi.com/awaioi/TallyNote/releases/download/v<版本>/` 下载当前架构归档和 `SHA256SUMS`,限制 HTTPS 重定向只能落在配置的受信主机,校验压缩/展开大小、条目数量、路径和特殊文件,再原子切换 `/opt/tallynote/current`。自定义仓库时同时设置 `TALLYNOTE_REPOSITORY_URL`、`TALLYNOTE_RELEASE_API_URL` 和 `TALLYNOTE_RELEASE_ALLOWED_HOSTS`;若使用独立 CDN,必须把 CDN 主机显式加入白名单。需要预览时显式加 `--dry-run`,需要固定版本时使用 `--version`。
|
||||
|
||||
安装器会在每个关键阶段输出统一格式的日志,便于在 SSH 或 systemd 安装会话中确认进度;交互式终端下载时还会显示 curl 进度条,CI 或日志重定向时则保持纯文本输出:
|
||||
|
||||
```text
|
||||
tallynote installer: [阶段] 检查运行环境、权限和目标架构
|
||||
tallynote installer: [阶段] 从 Release API 获取最新版本
|
||||
tallynote installer: [阶段] 获取发布包:tallynote-<版本>-linux-x64-glibc.tar.gz
|
||||
tallynote installer: [阶段] 获取 SHA-256 校验清单
|
||||
tallynote installer: [阶段] 校验 SHA-256 和发布签名
|
||||
tallynote installer: [阶段] 解包、校验包结构并原子切换到版本 <版本>
|
||||
tallynote installer: [完成] 版本 <版本> 已切换为当前版本
|
||||
tallynote installer: [阶段] 安装 systemd 单元、更新辅助程序和卸载器
|
||||
tallynote installer: [完成] systemd 单元、更新辅助程序和卸载器已安装
|
||||
tallynote installer: [阶段] 重新加载 systemd 并启动 TallyNote
|
||||
tallynote installer: [完成] TallyNote 服务已启用并启动
|
||||
tallynote installer: [阶段] 清理旧版本并完成安装
|
||||
tallynote installer: [完成] 旧版本清理完成
|
||||
tallynote installer: [完成] 安装完成:TallyNote <版本>
|
||||
```
|
||||
|
||||
每个阶段完成时会输出 `[完成]`;错误会立即以 `tallynote installer:` 前缀输出,不会静默等待或切换半成品版本。
|
||||
|
||||
如需启用签名校验,设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;后台更新同样可通过 `TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 开启。默认关闭签名要求,方便公开自维护仓库直接更新。
|
||||
|
||||
已有安装默认拒绝安装不高于当前版本的 release;只有在明确执行 `--allow-downgrade`(或设置 `TALLYNOTE_ALLOW_DOWNGRADE=true`)时才允许回退版本。
|
||||
|
||||
`--allow-unsigned` 只用于隔离的开发/测试主机,不能用于公网或保存真实财务数据的服务器。安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。
|
||||
安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。发布包同时携带 `uninstall.sh`,安装后会落到 `/usr/local/sbin/tallynote-uninstall`。`--allow-unsigned` 作为旧版本兼容参数保留。
|
||||
|
||||
安装布局:
|
||||
|
||||
@@ -61,17 +79,29 @@ curl --proto '=https' --tlsv1.2 -fsSL \
|
||||
/etc/tallynote/tallynote.env
|
||||
```
|
||||
|
||||
## 卸载与数据保留
|
||||
|
||||
默认卸载只移除发布代码、systemd 单元、更新辅助程序和已知配置,数据目录与更新备份不会删除:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-uninstall
|
||||
```
|
||||
|
||||
只有显式 `--purge-data --yes` 才会删除 SQLite、附件、暂存、导出、更新队列和备份;`--purge-config` 可在确认配置目录中没有其他文件后移除空配置目录。卸载器不会自动删除 `tallynote` 系统用户,也不会跟随符号链接删除目录。检测到 `.update-state` 或 `update-request.json` 时会拒绝执行,确认更新已经停止后使用 `--force`。
|
||||
|
||||
## 后台一键更新
|
||||
|
||||
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL`、`TALLYNOTE_UPDATE_ALLOWED_HOSTS` 和 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且同时通过 SHA-256 与 Ed25519 签名验证的资产;缺少任一项时“更新”按钮保持禁用。
|
||||
将环境文件中的 `TALLYNOTE_UPDATE_STRATEGY=systemd`、`TALLYNOTE_UPDATE_METADATA_URL` 和 `TALLYNOTE_UPDATE_ALLOWED_HOSTS` 配好后,后台“系统更新”会读取 Gitea 的 `/api/v1/repos/<owner>/<repo>/releases/latest`。检查结果只显示当前平台匹配且通过 SHA-256 校验的资产;如果配置了 `TALLYNOTE_UPDATE_PUBLIC_KEY_FILE` 并启用签名要求,再额外验证 Ed25519 签名。
|
||||
|
||||
浏览器只能提交版本号和确认标志。Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源、重新下载并验证 metadata、清单和签名,不信任队列文件中的 URL 或摘要。更新前会备份数据,切换失败或健康检查失败会恢复旧版本;手动回滚:
|
||||
后台更新分为两个明确阶段。管理员先在“系统更新”读取最新 Release 的版本号、发布时间和更新说明,点击“下载更新包”;root 更新器会在主服务继续运行时下载、校验 SHA-256、解包并暂存。页面显示“下载完成,等待应用”后,管理员再点击“立即更新”。应用阶段才会短暂停止服务、备份数据、切换 release、启动并执行健康检查;页面显示重启倒计时并自动重试连接。浏览器只提交版本号、任务 ID 和确认标志,不能提交 URL 或文件路径。
|
||||
|
||||
Web 进程把受保护的任务文件交给 root 的 `tallynote-update.path`/`tallynote-update.service`,root runner 会重新读取配置源并验证 metadata、清单和暂存目录,不信任队列文件中的 URL 或摘要。切换失败或健康检查失败会恢复旧版本;手动回滚:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-update --rollback
|
||||
```
|
||||
|
||||
更新检查和应用接口带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求。服务单元默认仅监听 `127.0.0.1`,并使用最小化 systemd 权限;公网访问必须通过 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。
|
||||
更新检查、下载和应用接口分别带有冷却时间(可用 `TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS`、`TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS` 调整),避免反复触发外部请求或重复排队。服务单元默认仅监听 `127.0.0.1`,并使用最小化 systemd 权限;公网访问必须通过 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。
|
||||
|
||||
更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。
|
||||
|
||||
|
||||
+92
-36
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# TallyNote native installer. Dry-run by default; pass --apply to mutate the host.
|
||||
# TallyNote native installer. Installs the latest release by default; use
|
||||
# --dry-run to preview without changing the host.
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||||
export PATH
|
||||
umask 077
|
||||
@@ -20,9 +21,9 @@ SIGNING_KEY=${TALLYNOTE_SIGNING_KEY:-}
|
||||
SIGNATURE_FORMAT=${TALLYNOTE_SIGNATURE_FORMAT:-ed25519}
|
||||
SHA256_FILE=${TALLYNOTE_SHA256_FILE:-}
|
||||
UPDATE_PUBLIC_KEY_FILE=${TALLYNOTE_UPDATE_PUBLIC_KEY_FILE:-}
|
||||
APPLY=0
|
||||
APPLY=1
|
||||
KEEP_RELEASES=${TALLYNOTE_KEEP_RELEASES:-3}
|
||||
REQUIRE_SIGNATURE=${TALLYNOTE_INSTALL_REQUIRE_SIGNATURE:-true}
|
||||
REQUIRE_SIGNATURE=${TALLYNOTE_INSTALL_REQUIRE_SIGNATURE:-false}
|
||||
ALLOW_DOWNGRADE=${TALLYNOTE_ALLOW_DOWNGRADE:-false}
|
||||
ALLOW_UNSIGNED=0
|
||||
MAX_RELEASE_MB=${TALLYNOTE_MAX_RELEASE_MB:-512}
|
||||
@@ -51,21 +52,25 @@ RELEASE_API_URL=${RELEASE_API_URL%/}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: install.sh [--apply] [--version VERSION] [--release-base-url HTTPS_URL]
|
||||
Usage: install.sh [--dry-run] [--version VERSION] [--release-base-url HTTPS_URL]
|
||||
[--release-file FILE] [--sha256-url HTTPS_URL|--sha256-file FILE]
|
||||
[--signature-url HTTPS_URL] [--signing-key PUBLIC_KEY_FILE]
|
||||
[--signature-format ed25519|gpg]
|
||||
[--update-public-key-file FILE]
|
||||
[--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--dry-run]
|
||||
[--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--apply]
|
||||
|
||||
The default is --dry-run. Network downloads and filesystem changes happen only
|
||||
with --apply. Production installs require a detached signature (Ed25519 over
|
||||
SHA256SUMS by default; legacy GPG archive signatures are opt-in); --allow-unsigned
|
||||
is for isolated development hosts only.
|
||||
Without arguments, the installer resolves the latest compatible release and
|
||||
installs it. SHA-256 from SHA256SUMS is always required. Detached signature
|
||||
verification is optional by default; enable it with
|
||||
TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true and provide a public key. Use
|
||||
--dry-run to inspect the selected release without downloading or changing the
|
||||
host. --apply is accepted for backwards compatibility.
|
||||
EOF
|
||||
}
|
||||
die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; }
|
||||
log() { printf 'tallynote installer: %s\n' "$*"; }
|
||||
stage() { log "[阶段] $*"; }
|
||||
stage_done() { log "[完成] $*"; }
|
||||
|
||||
[[ "$REQUIRE_SIGNATURE" == true || "$REQUIRE_SIGNATURE" == false ]] || die 'TALLYNOTE_INSTALL_REQUIRE_SIGNATURE 必须是 true 或 false'
|
||||
[[ "$ALLOW_DOWNGRADE" == true || "$ALLOW_DOWNGRADE" == false ]] || die 'TALLYNOTE_ALLOW_DOWNGRADE 必须是 true 或 false'
|
||||
@@ -194,15 +199,23 @@ assert_allowed_url() {
|
||||
download() {
|
||||
local url=$1 out=$2 max_bytes=${3:-$((MAX_RELEASE_MB * 1024 * 1024))}
|
||||
local current="$url" headers status location actual origin scheme authority
|
||||
local -a curl_args=(--proto '=https' --tlsv1.2 --fail --show-error --max-redirs 0
|
||||
--connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" --max-filesize "$max_bytes"
|
||||
--retry 2 --retry-connrefused)
|
||||
# Keep CI and journal output clean, while showing curl's standard progress
|
||||
# bar during an interactive SSH/terminal installation.
|
||||
if [[ -t 2 ]]; then
|
||||
curl_args+=(--progress-bar)
|
||||
else
|
||||
curl_args+=(--silent)
|
||||
fi
|
||||
require_https "$url"
|
||||
assert_allowed_url "$url"
|
||||
[[ ! -L "$out" && ! -e "$out" ]] || die "download destination already exists: $out"
|
||||
for _redirect in 0 1 2 3; do
|
||||
headers="${out}.headers-${RANDOM}-$$"
|
||||
status=$(curl --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \
|
||||
--connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" --max-filesize "$max_bytes" \
|
||||
--retry 2 --retry-connrefused --output "$out" --dump-header "$headers" \
|
||||
--write-out '%{http_code}' "$current" 2>/dev/null) || status=000
|
||||
status=$(curl "${curl_args[@]}" --output "$out" --dump-header "$headers" \
|
||||
--write-out '%{http_code}' "$current") || status=000
|
||||
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
|
||||
rm -f -- "$headers"
|
||||
break
|
||||
@@ -275,9 +288,9 @@ verify_archive() {
|
||||
[[ -n "$expected" ]] || die "checksum file has no entry for $archive_name"
|
||||
[[ "$expected" =~ ^[A-Fa-f0-9]{64}$ ]] || die 'checksum file does not contain a SHA-256 digest'
|
||||
printf '%s %s\n' "$expected" "$archive" | sha256sum -c - >/dev/null || die 'SHA-256 verification failed'
|
||||
if [[ "$REQUIRE_SIGNATURE" == true ]]; then
|
||||
[[ -n "$signature" && -s "$signature" ]] || die '发布包缺少 SHA256SUMS.sig;生产安装必须使用签名'
|
||||
[[ -n "$key" && -f "$key" && ! -L "$key" ]] || die '生产安装必须提供签名公钥(--signing-key FILE)'
|
||||
if [[ "$REQUIRE_SIGNATURE" == true || ( -n "$signature" && -n "$key" ) ]]; then
|
||||
[[ -n "$signature" && -s "$signature" ]] || die '发布包缺少签名文件(SHA256SUMS.sig 或 .asc)'
|
||||
[[ -n "$key" && -f "$key" && ! -L "$key" ]] || die '签名校验需要有效的公钥文件(--signing-key FILE)'
|
||||
[[ "$(stat_uid "$key")" == 0 ]] || die '更新公钥必须由 root 拥有'
|
||||
[[ "$(wc -c < "$key" | tr -d '[:space:]')" -le 16384 ]] || die '更新公钥文件过大'
|
||||
local key_bits
|
||||
@@ -314,7 +327,7 @@ verify_archive() {
|
||||
fi
|
||||
fi
|
||||
elif [[ -n "$signature" || -n "$key" ]]; then
|
||||
log 'warning: signature verification disabled by explicit --allow-unsigned'
|
||||
log 'warning: signature verification skipped; provide both a signature and public key, or enable TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true'
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -370,7 +383,7 @@ normalize_release_tree() {
|
||||
fi
|
||||
find "$root" -type d -exec chmod 755 {} +
|
||||
find "$root" -type f -exec chmod 644 {} +
|
||||
for item in "$root/bin"/* "$root/scripts"/*.sh "$root/runtime/bin"/*; do
|
||||
for item in "$root/bin"/* "$root/scripts"/*.sh "$root/runtime/bin"/* "$root/uninstall.sh"; do
|
||||
[[ -f "$item" && ! -L "$item" ]] || continue
|
||||
chmod 755 "$item"
|
||||
done
|
||||
@@ -538,10 +551,11 @@ rollback_install_if_needed() {
|
||||
fi
|
||||
if (( INSTALL_COMMITTED == 0 )) && [[ -n "$INSTALL_BACKUP_DIR" && -d "$INSTALL_BACKUP_DIR" ]]; then
|
||||
local backup_name target
|
||||
for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote.env update-signing-key.pub; do
|
||||
for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote-uninstall tallynote.env update-signing-key.pub; do
|
||||
case "$backup_name" in
|
||||
tallynote.env) target="$CONFIG_DIR/tallynote.env" ;;
|
||||
update-signing-key.pub) target="$CONFIG_DIR/update-signing-key.pub" ;;
|
||||
tallynote-uninstall) target="/usr/local/sbin/tallynote-uninstall" ;;
|
||||
*) target="/etc/systemd/system/$backup_name" ;;
|
||||
esac
|
||||
[[ ! -L "$target" ]] || continue
|
||||
@@ -583,6 +597,12 @@ backup_install_files() {
|
||||
cp -a -- "$target" "$directory/$name"
|
||||
fi
|
||||
done
|
||||
target="/usr/local/sbin/tallynote-uninstall"
|
||||
[[ ! -L "$target" ]] || die "现有卸载器不能是符号链接:$target"
|
||||
if [[ -e "$target" ]]; then
|
||||
[[ -f "$target" ]] || die "现有卸载器不是普通文件:$target"
|
||||
cp -a -- "$target" "$directory/tallynote-uninstall"
|
||||
fi
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
@@ -636,7 +656,7 @@ validate_existing_env() {
|
||||
value=$(read_env_value "$file" TALLYNOTE_DATA_DIR)
|
||||
[[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致'
|
||||
value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE)
|
||||
[[ -z "$value" || "$value" == true ]] || die '环境文件禁止关闭发布签名校验'
|
||||
[[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false'
|
||||
value=$(read_env_value "$file" TALLYNOTE_UPDATE_METADATA_URL)
|
||||
if [[ -n "$value" ]]; then
|
||||
validate_env_value "$value" '环境文件更新源'
|
||||
@@ -656,7 +676,7 @@ install_release() {
|
||||
[[ -x "$tmp/unpacked/bin/tallynote" ]] || die 'release archive must contain executable bin/tallynote'
|
||||
[[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete'
|
||||
[[ -f "$tmp/unpacked/systemd/tallynote.service" && -f "$tmp/unpacked/systemd/tallynote-update.service" && -f "$tmp/unpacked/systemd/tallynote-update.path" ]] || die 'release archive is missing systemd units'
|
||||
[[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" ]] || die 'release archive is missing update support files'
|
||||
[[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" && -x "$tmp/unpacked/uninstall.sh" ]] || die 'release archive is missing update/uninstall support files'
|
||||
grep -Eq '"version"[[:space:]]*:[[:space:]]*"'"$version"'"([,}]|[[:space:]])' "$tmp/unpacked/package.json" || die 'release package version does not match requested version'
|
||||
ensure_root_directory "$PREFIX" 755
|
||||
ensure_root_directory "$PREFIX/releases" 755
|
||||
@@ -705,12 +725,13 @@ prune_releases() {
|
||||
}
|
||||
|
||||
main() {
|
||||
stage '检查运行环境、权限和目标架构'
|
||||
# These variables are useful for isolated tests, but a root install must
|
||||
# never execute an untrusted PATH entry supplied through sudo's environment.
|
||||
if (( APPLY )) || [[ -n "${TALLYNOTE_UNAME_BIN+x}" ]]; then
|
||||
validate_trusted_tool "$UNAME_BIN" 'uname'
|
||||
fi
|
||||
if (( APPLY )) || [[ -n "${TALLYNOTE_OPENSSL_BIN+x}" ]]; then
|
||||
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" || -n "$UPDATE_PUBLIC_KEY_FILE" || -n "${TALLYNOTE_OPENSSL_BIN+x}" ]]; then
|
||||
validate_trusted_tool "$OPENSSL_BIN" 'openssl'
|
||||
fi
|
||||
detect_platform
|
||||
@@ -727,14 +748,21 @@ main() {
|
||||
# TALLYNOTE_RELEASE_ALLOWED_HOSTS when the operator has reviewed it.
|
||||
append_allowed_host "$(url_host "$RELEASE_API_URL")"
|
||||
append_allowed_host "$(url_host "$REPOSITORY_URL")"
|
||||
stage_done "运行环境可用:${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}"
|
||||
if [[ "$VERSION" == "latest" ]]; then
|
||||
if (( ! APPLY )); then
|
||||
[[ -z "$RELEASE_BASE_URL" ]] || require_https "$RELEASE_BASE_URL"
|
||||
log 'version: latest (release lookup happens with --apply)'
|
||||
stage '预览最新版本解析(dry-run 不访问 Release)'
|
||||
log 'version: latest (release lookup skipped in dry-run)'
|
||||
log 'dry-run: pass --version VERSION to preview an exact artifact'
|
||||
stage_done 'dry-run 预览完成:不会下载、解包或修改 systemd'
|
||||
return 0
|
||||
fi
|
||||
stage '从 Release API 获取最新版本'
|
||||
resolve_latest_version
|
||||
stage_done "已解析最新版本:${VERSION#v}"
|
||||
else
|
||||
stage "使用指定版本:${VERSION#v}"
|
||||
fi
|
||||
validate_semver "$VERSION" || die 'version must be a semantic version (for example 1.2.3)'
|
||||
VERSION=${VERSION#v}
|
||||
@@ -745,27 +773,35 @@ main() {
|
||||
die "拒绝安装不高于当前版本的 release:当前 $current_version,候选 $VERSION(如确需降级请使用 --allow-downgrade)"
|
||||
fi
|
||||
fi
|
||||
stage '准备 Release 下载地址和发布包'
|
||||
release_urls
|
||||
local artifact archive checksum signature artifact_url work release_dir
|
||||
artifact=${RELEASE_FILE:+$(basename -- "$RELEASE_FILE")}
|
||||
artifact=${artifact:-tallynote-${VERSION}-linux-${TALLYNOTE_ARCH}-${TALLYNOTE_LIBC}.tar.gz}
|
||||
[[ "$artifact" =~ ^[A-Za-z0-9][A-Za-z0-9._+\-]*\.(tar\.gz|tgz|tar)$ ]] || die 'release 文件名无效'
|
||||
artifact_url="$RELEASE_BASE_URL/$artifact"
|
||||
stage_done 'Release 下载地址已准备'
|
||||
log "platform: ${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}; release: ${VERSION#v}"
|
||||
log "layout: $PREFIX/releases + atomic $PREFIX/current; data: $DATA_DIR"
|
||||
if (( ! APPLY )); then log 'dry-run: pass --apply to download, verify, extract, and configure systemd'; return 0; fi
|
||||
[[ "$REQUIRE_SIGNATURE" == true || "$ALLOW_UNSIGNED" -eq 1 ]] || die '生产安装必须校验发布签名;仅隔离开发环境可使用 --allow-unsigned'
|
||||
[[ "$("$UNAME_BIN" -s)" == Linux ]] || die '安装器只允许在 Linux 上执行 --apply'
|
||||
[[ $EUID -eq 0 ]] || die '--apply must run as root'
|
||||
if (( ! APPLY )); then
|
||||
log 'dry-run: no download, extraction, or systemd changes'
|
||||
stage_done 'dry-run 预览完成:不会下载、解包或修改 systemd'
|
||||
return 0
|
||||
fi
|
||||
[[ "$("$UNAME_BIN" -s)" == Linux ]] || die '安装器只允许在 Linux 上执行'
|
||||
[[ $EUID -eq 0 ]] || die '安装必须以 root 运行'
|
||||
for command_name in curl sha256sum tar install sed awk find systemctl; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"
|
||||
done
|
||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
|
||||
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" || -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
|
||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signature verification is enabled'
|
||||
fi
|
||||
work=$(mktemp -d)
|
||||
INSTALL_WORK_DIR=$work
|
||||
INSTALL_BACKUP_DIR="$work/original"
|
||||
trap rollback_install_if_needed EXIT
|
||||
archive="$work/$artifact"
|
||||
stage "获取发布包:$artifact"
|
||||
if [[ -n "$RELEASE_FILE" && -f "$RELEASE_FILE" && ! -L "$RELEASE_FILE" ]]; then
|
||||
cp -- "$RELEASE_FILE" "$archive"
|
||||
chmod 600 "$archive"
|
||||
@@ -774,8 +810,10 @@ main() {
|
||||
[[ -z "$RELEASE_FILE" ]] || die '本地 release 文件不存在或是符号链接'
|
||||
download "$artifact_url" "$archive"
|
||||
fi
|
||||
stage_done '发布包已下载并通过大小限制'
|
||||
checksum="$work/SHA256SUMS"
|
||||
SHA256_URL=${SHA256_URL:-$RELEASE_BASE_URL/SHA256SUMS}
|
||||
stage '获取 SHA-256 校验清单'
|
||||
if [[ -n "$SHA256_FILE" && -f "$SHA256_FILE" && ! -L "$SHA256_FILE" ]]; then
|
||||
cp -- "$SHA256_FILE" "$checksum"
|
||||
chmod 600 "$checksum"
|
||||
@@ -784,8 +822,11 @@ main() {
|
||||
[[ -z "$SHA256_FILE" ]] || die '本地 SHA256SUMS 文件不存在或是符号链接'
|
||||
download "$SHA256_URL" "$checksum" $((2 * 1024 * 1024))
|
||||
fi
|
||||
stage_done 'SHA-256 校验清单已准备'
|
||||
SIGNING_KEY=${SIGNING_KEY:-$UPDATE_PUBLIC_KEY_FILE}
|
||||
signature=''
|
||||
if [[ "$REQUIRE_SIGNATURE" == true ]]; then
|
||||
if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" ]]; then
|
||||
stage '获取发布签名'
|
||||
if [[ "$SIGNATURE_FORMAT" == gpg ]]; then
|
||||
SIGNATURE_URL=${SIGNATURE_URL:-$RELEASE_BASE_URL/$artifact.asc}
|
||||
signature="$work/$artifact.asc"
|
||||
@@ -794,14 +835,14 @@ main() {
|
||||
signature="$work/SHA256SUMS.sig"
|
||||
fi
|
||||
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
|
||||
elif [[ -n "$SIGNATURE_URL" ]]; then
|
||||
signature="$work/SHA256SUMS.sig"
|
||||
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
|
||||
stage_done '发布签名已准备'
|
||||
fi
|
||||
SIGNING_KEY=${SIGNING_KEY:-$UPDATE_PUBLIC_KEY_FILE}
|
||||
stage '校验 SHA-256 和发布签名'
|
||||
verify_archive "$archive" "$checksum" "$signature" "$SIGNING_KEY"
|
||||
stage_done '发布包校验通过'
|
||||
[[ "$PREFIX" = /* && "$DATA_DIR" = /* && "$CONFIG_DIR" = /* ]] || die '安装、数据和配置目录必须是绝对路径'
|
||||
[[ ! -L "$DATA_DIR" && ! -L "$PREFIX" && ! -L "$CONFIG_DIR" ]] || die 'installation/data/config paths must not be symlinks'
|
||||
stage '停止旧服务并准备安装、配置和数据目录'
|
||||
id tallynote >/dev/null 2>&1 || useradd --system --user-group --home-dir "$DATA_DIR" --shell /usr/sbin/nologin tallynote
|
||||
backup_install_files "$INSTALL_BACKUP_DIR"
|
||||
stop_existing_services
|
||||
@@ -813,10 +854,14 @@ main() {
|
||||
if [[ -e "$CONFIG_DIR/tallynote.env" ]]; then
|
||||
validate_existing_env "$CONFIG_DIR/tallynote.env"
|
||||
fi
|
||||
stage_done '目录、权限和旧服务状态已准备'
|
||||
stage "解包、校验包结构并原子切换到版本 ${VERSION#v}"
|
||||
install_release "$archive" "$VERSION"
|
||||
stage_done "版本 ${VERSION#v} 已切换为当前版本"
|
||||
release_dir="$PREFIX/releases/$VERSION"
|
||||
[[ -f "$release_dir/systemd/tallynote.service" && -f "$release_dir/systemd/tallynote-update.service" && -f "$release_dir/systemd/tallynote-update.path" ]] || die 'release package is missing systemd unit files'
|
||||
[[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" ]] || die 'release package is missing update support files'
|
||||
[[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" && -x "$release_dir/uninstall.sh" ]] || die 'release package is missing update/uninstall support files'
|
||||
stage '安装 systemd 单元、更新辅助程序和卸载器'
|
||||
install -d -m 755 /usr/local/libexec /etc/systemd/system
|
||||
local unit_tmp
|
||||
unit_tmp=$(mktemp -d)
|
||||
@@ -829,6 +874,7 @@ main() {
|
||||
rm -rf "$unit_tmp"
|
||||
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update.sh" /usr/local/sbin/tallynote-update
|
||||
install -o root -g root -m 755 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner
|
||||
install -o root -g root -m 755 "$release_dir/uninstall.sh" /usr/local/sbin/tallynote-uninstall
|
||||
ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700
|
||||
if [[ ! -f "$CONFIG_DIR/tallynote.env" ]]; then
|
||||
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env"
|
||||
@@ -851,10 +897,14 @@ main() {
|
||||
ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd
|
||||
ensure_env_key TALLYNOTE_UPDATE_METADATA_URL "$RELEASE_API_URL"
|
||||
ensure_env_key TALLYNOTE_UPDATE_ALLOWED_HOSTS "$RELEASE_ALLOWED_HOSTS"
|
||||
ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE true
|
||||
# The bootstrap verification key is also the key used by the privileged
|
||||
# updater unless the operator already configured a separate one.
|
||||
UPDATE_PUBLIC_KEY_FILE=${UPDATE_PUBLIC_KEY_FILE:-$SIGNING_KEY}
|
||||
if [[ -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
|
||||
ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE true
|
||||
else
|
||||
ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE false
|
||||
fi
|
||||
if [[ -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then
|
||||
validate_install_path "$UPDATE_PUBLIC_KEY_FILE" '更新公钥路径'
|
||||
[[ -f "$UPDATE_PUBLIC_KEY_FILE" && ! -L "$UPDATE_PUBLIC_KEY_FILE" ]] || die 'update public key file is invalid'
|
||||
@@ -866,15 +916,21 @@ main() {
|
||||
printf 'TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=%s\n' "$CONFIG_DIR/update-signing-key.pub" >> "$CONFIG_DIR/tallynote.env"
|
||||
fi
|
||||
fi
|
||||
stage_done 'systemd 单元、更新辅助程序和卸载器已安装'
|
||||
stage '重新加载 systemd 并启动 TallyNote'
|
||||
chown root:root "$CONFIG_DIR/tallynote.env"
|
||||
chmod 640 "$CONFIG_DIR/tallynote.env"
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now tallynote.service tallynote-update.path
|
||||
stage_done 'TallyNote 服务已启用并启动'
|
||||
stage '清理旧版本并完成安装'
|
||||
prune_releases
|
||||
stage_done '旧版本清理完成'
|
||||
INSTALL_COMMITTED=1
|
||||
trap - EXIT
|
||||
rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true
|
||||
INSTALL_WORK_DIR=''
|
||||
log 'installed; inspect with systemctl status tallynote.service'
|
||||
stage_done "安装完成:TallyNote ${VERSION#v}"
|
||||
log '查看服务状态:systemctl status tallynote.service'
|
||||
}
|
||||
main "$@"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE update_jobs ADD COLUMN operation TEXT NOT NULL DEFAULT 'apply' CHECK(operation IN ('download','apply'));
|
||||
CREATE INDEX IF NOT EXISTS update_jobs_operation_idx ON update_jobs(operation, status, created_at);
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
@@ -9,14 +9,18 @@
|
||||
},
|
||||
"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:installer": "bash scripts/test-installer.sh && bash scripts/test-uninstaller.sh",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
@@ -25,18 +29,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"
|
||||
},
|
||||
|
||||
+13
-5
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+515
-14
File diff suppressed because it is too large
Load Diff
@@ -32,10 +32,11 @@ cp -a migrations/. "$stage/migrations/"
|
||||
cp package.json pnpm-lock.yaml "$stage/"
|
||||
cp -a bin/. "$stage/bin/"
|
||||
cp -a scripts/tallynote-update.sh scripts/tallynote-update-runner.sh "$stage/scripts/"
|
||||
cp uninstall.sh "$stage/uninstall.sh"
|
||||
cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/"
|
||||
node_path=$(command -v node)
|
||||
cp -L "$node_path" "$stage/runtime/bin/node"
|
||||
chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node"
|
||||
chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh"
|
||||
|
||||
# pnpm's default linker creates symlinks. A release archive is deliberately
|
||||
# symlink-free so the installer can reject traversal links deterministically.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Publish one immutable, signed release to a Gitea-compatible API. The script
|
||||
# is intentionally separate from the workflow so operators can dry-run the
|
||||
# exact same asset selection locally without ever exposing a signing key.
|
||||
# Publish one immutable release to a Gitea-compatible API. SHA256SUMS is
|
||||
# always generated; an Ed25519 detached signature is added when a signing key
|
||||
# is supplied. The script remains separate from the workflow so operators can
|
||||
# dry-run the exact same asset selection locally without exposing a key.
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||||
export PATH
|
||||
umask 077
|
||||
@@ -23,6 +24,7 @@ DRY_RUN=0
|
||||
AUTH_CONFIG=''
|
||||
SUMS_TMP=''
|
||||
SIG_TMP=''
|
||||
SIGNATURE_GENERATED=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -30,8 +32,12 @@ Usage: publish-gitea-release.sh TAG [ASSET_DIR] [--dry-run]
|
||||
|
||||
Required in publish mode:
|
||||
GITEA_TOKEN (or GITHUB_TOKEN) API token with release write access
|
||||
|
||||
Optional:
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY_FILE Ed25519 private-key file
|
||||
or TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
|
||||
TALLYNOTE_RELEASE_SIGNING_KEY PEM value supplied by CI secret
|
||||
|
||||
Without a signing key, the release is published with SHA256SUMS only.
|
||||
EOF
|
||||
}
|
||||
die() { printf 'release publisher: %s\n' "$*" >&2; exit 1; }
|
||||
@@ -116,7 +122,9 @@ API_ROOT=${API_ROOT%/}
|
||||
validate_api_root "$API_ROOT"
|
||||
[[ -d "$ASSET_DIR" && ! -L "$ASSET_DIR" ]] || die "asset directory is invalid: $ASSET_DIR"
|
||||
command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required'
|
||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
|
||||
if [[ -n "$SIGNING_KEY_FILE" || -n "$SIGNING_KEY_VALUE" ]]; then
|
||||
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signing a release'
|
||||
fi
|
||||
[[ "$CURL_BIN" != *[[:space:]]* && "$CURL_BIN" != *[[:cntrl:]]* ]] || die 'curl executable path is invalid'
|
||||
command -v "$CURL_BIN" >/dev/null 2>&1 || die 'curl is required'
|
||||
|
||||
@@ -164,8 +172,6 @@ elif [[ -n "$SIGNING_KEY_VALUE" ]]; then
|
||||
chmod 600 "$temporary_key"
|
||||
printf '%s\n' "$SIGNING_KEY_VALUE" > "$temporary_key"
|
||||
unset SIGNING_KEY_VALUE
|
||||
else
|
||||
[[ "$DRY_RUN" -eq 1 ]] || die 'TALLYNOTE_RELEASE_SIGNING_KEY_FILE or TALLYNOTE_RELEASE_SIGNING_KEY is required'
|
||||
fi
|
||||
if [[ -n "$temporary_key" ]]; then
|
||||
"$OPENSSL_BIN" pkey -in "$temporary_key" -noout >/dev/null 2>&1 || die 'signing key is not a valid private key'
|
||||
@@ -174,16 +180,18 @@ if [[ -n "$temporary_key" ]]; then
|
||||
chmod 600 "$SIG_TMP"
|
||||
mv -f -- "$SIG_TMP" "$SIG_FILE"
|
||||
SIG_TMP=''
|
||||
SIGNATURE_GENERATED=1
|
||||
fi
|
||||
|
||||
log "tag: $TAG"
|
||||
log "assets: ${#assets[@]} archive(s), SHA256SUMS${temporary_key:+, SHA256SUMS.sig}"
|
||||
asset_summary="assets: ${#assets[@]} archive(s), SHA256SUMS"
|
||||
if (( SIGNATURE_GENERATED )); then asset_summary+=", SHA256SUMS.sig"; fi
|
||||
log "$asset_summary"
|
||||
if (( DRY_RUN )); then
|
||||
log 'dry-run: no API request was sent'
|
||||
exit 0
|
||||
fi
|
||||
[[ -n "$TOKEN" ]] || die 'GITEA_TOKEN (or GITHUB_TOKEN) is required'
|
||||
[[ -s "$SIG_FILE" ]] || die 'signature was not generated'
|
||||
command -v jq >/dev/null 2>&1 || die 'jq is required for Gitea API publishing'
|
||||
write_auth_config
|
||||
unset TOKEN
|
||||
@@ -230,7 +238,8 @@ assets_endpoint="$API_ROOT/repos/$repo_path/releases/$release_id/assets"
|
||||
existing=$(api_curl "$assets_endpoint") || die '无法读取现有 Release 资产'
|
||||
while IFS=$'\t' read -r existing_id existing_name; do
|
||||
[[ -n "$existing_id" && -n "$existing_name" ]] || continue
|
||||
for candidate in "${assets[@]}" "$SUMS_FILE" "$SIG_FILE"; do
|
||||
candidates=("${assets[@]}" "$SUMS_FILE" "$SIG_FILE")
|
||||
for candidate in "${candidates[@]}"; do
|
||||
[[ "$existing_name" == "$(basename -- "$candidate")" ]] || continue
|
||||
api_curl -X DELETE "$assets_endpoint/$existing_id" >/dev/null || die "无法删除旧资产:$existing_name"
|
||||
done
|
||||
@@ -245,5 +254,7 @@ upload_asset() {
|
||||
}
|
||||
for file in "${assets[@]}"; do upload_asset "$file"; done
|
||||
upload_asset "$SUMS_FILE"
|
||||
upload_asset "$SIG_FILE"
|
||||
if (( SIGNATURE_GENERATED )); then
|
||||
upload_asset "$SIG_FILE"
|
||||
fi
|
||||
log "published $TAG to $REPOSITORY"
|
||||
|
||||
@@ -22,6 +22,26 @@ die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; }
|
||||
old_target=$(readlink -f -- "$CURRENT_LINK")
|
||||
[[ "$old_target" == "$PREFIX/releases/"* && -d "$old_target" ]] || die 'current release target is invalid'
|
||||
|
||||
request_operation='apply'
|
||||
if [[ -f "$REQUEST_FILE" && ! -L "$REQUEST_FILE" ]]; then
|
||||
request_operation=$(sed -n 's/.*"operation"[[:space:]]*:[[:space:]]*"\(download\|apply\)".*/\1/p' "$REQUEST_FILE" | head -n 1)
|
||||
[[ "$request_operation" == download || "$request_operation" == apply ]] || request_operation='apply'
|
||||
fi
|
||||
|
||||
# Downloading is intentionally handled while the main service remains up.
|
||||
# The CLI persists the validated payload under the root-owned workspace and
|
||||
# leaves the job staged for a later apply request.
|
||||
if [[ "$request_operation" == download ]]; then
|
||||
node_bin="$CURRENT_LINK/runtime/bin/node"
|
||||
[[ -x "$node_bin" ]] || node_bin=$(command -v node || true)
|
||||
[[ -n "$node_bin" ]] || die 'node runtime not found'
|
||||
cli="$CURRENT_LINK/dist/server/cli/update.js"
|
||||
[[ -f "$cli" ]] || die 'update CLI not found in current release'
|
||||
"$node_bin" "$cli" --request-file "$REQUEST_FILE" || exit $?
|
||||
rm -f -- "$REQUEST_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
was_active=0
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then was_active=1; fi
|
||||
# shellcheck disable=SC2329 # invoked indirectly by the EXIT trap below
|
||||
|
||||
@@ -4,8 +4,11 @@ root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
bash -n "$root/install.sh" "$root/scripts/tallynote-update.sh"
|
||||
output=$(bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'dry-run' <<<"$output"
|
||||
grep -q '\[阶段\] 检查运行环境' <<<"$output"
|
||||
grep -q '\[完成\] dry-run 预览完成' <<<"$output"
|
||||
output=$(bash "$root/install.sh" --dry-run --version 1.2.3 --release-base-url https://releases.example.test/releases)
|
||||
grep -q 'release: 1.2.3' <<<"$output"
|
||||
grep -q '\[阶段\] 使用指定版本:1.2.3' <<<"$output"
|
||||
if bash "$root/install.sh" --dry-run --release-base-url http://insecure.example.test/releases >/dev/null 2>&1; then
|
||||
echo 'expected non-HTTPS URL to fail' >&2
|
||||
exit 1
|
||||
@@ -37,6 +40,13 @@ fi
|
||||
# main invocation lets this subprocess source the exact production code.
|
||||
installer_lib="$tmp/install-lib.sh"
|
||||
sed '$d' "$root/install.sh" > "$installer_lib"
|
||||
bash -c '
|
||||
script=$1
|
||||
set --
|
||||
source "$script"
|
||||
[[ "$APPLY" -eq 1 ]]
|
||||
[[ "$REQUIRE_SIGNATURE" == false ]]
|
||||
' _ "$installer_lib"
|
||||
bash -c '
|
||||
script=$1
|
||||
mode_dir=$2
|
||||
@@ -75,10 +85,12 @@ bash -c '
|
||||
source_tmp="$tmp/source"
|
||||
mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts" "$source_tmp/runtime/bin"
|
||||
printf '%s\n' 'server' > "$source_tmp/dist/server/index.js"
|
||||
printf '%s\n' '#!/bin/sh' > "$source_tmp/uninstall.sh"
|
||||
printf '%s\n' '#!/bin/sh' > "$source_tmp/bin/tallynote"
|
||||
printf '%s\n' '#!/bin/sh' > "$source_tmp/scripts/runner.sh"
|
||||
printf '%s\n' 'node' > "$source_tmp/runtime/bin/node"
|
||||
chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" "$source_tmp/runtime/bin/node"
|
||||
chmod 755 "$source_tmp/uninstall.sh"
|
||||
archive_tmp="$tmp/release.tar.gz"
|
||||
tar -C "$source_tmp" -czf "$archive_tmp" .
|
||||
bash -c '
|
||||
@@ -92,8 +104,23 @@ bash -c '
|
||||
[[ "$(stat_mode "$destination/dist")" == 755 ]]
|
||||
[[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]]
|
||||
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
|
||||
[[ "$(stat_mode "$destination/uninstall.sh")" == 755 ]]
|
||||
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
|
||||
|
||||
# A normal public-release install only needs the detached SHA-256 manifest;
|
||||
# absence of a signature and public key must not block archive verification.
|
||||
checksum_tmp="$tmp/SHA256SUMS"
|
||||
(cd "$(dirname -- "$archive_tmp")" && sha256sum "$(basename -- "$archive_tmp")") > "$checksum_tmp"
|
||||
bash -c '
|
||||
script=$1
|
||||
archive=$2
|
||||
checksum=$3
|
||||
set --
|
||||
source "$script"
|
||||
REQUIRE_SIGNATURE=false
|
||||
verify_archive "$archive" "$checksum" "" ""
|
||||
' _ "$installer_lib" "$archive_tmp" "$checksum_tmp"
|
||||
|
||||
# Newline/control characters in release configuration must never become extra
|
||||
# systemd EnvironmentFile assignments.
|
||||
if TALLYNOTE_RELEASE_API_URL=$'https://git.awaioi.com/api/v1\nEVIL=1' bash "$root/install.sh" --dry-run >/dev/null 2>&1; then
|
||||
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
root=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
|
||||
bash -n "$root/uninstall.sh"
|
||||
|
||||
tmp=$(cd "$(mktemp -d)" && pwd -P)
|
||||
cleanup() { rm -rf -- "$tmp" 2>/dev/null || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
make_fixture() {
|
||||
local fixture=$1
|
||||
mkdir -p "$fixture/opt/tallynote/releases/1.1.1/dist" \
|
||||
"$fixture/opt/tallynote/.update-work" \
|
||||
"$fixture/var/lib/tallynote/files" \
|
||||
"$fixture/var/lib/tallynote/staging" \
|
||||
"$fixture/var/lib/tallynote/exports" \
|
||||
"$fixture/var/lib/tallynote-backups" \
|
||||
"$fixture/etc/tallynote" \
|
||||
"$fixture/etc/systemd/system" \
|
||||
"$fixture/usr/local/sbin" \
|
||||
"$fixture/usr/local/libexec"
|
||||
printf '%s\n' 'release' > "$fixture/opt/tallynote/releases/1.1.1/dist/index.js"
|
||||
ln -s "$fixture/opt/tallynote/releases/1.1.1" "$fixture/opt/tallynote/current"
|
||||
printf '%s\n' \
|
||||
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \
|
||||
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
|
||||
"TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$fixture/etc/tallynote/update-signing-key.pub" \
|
||||
> "$fixture/etc/tallynote/tallynote.env"
|
||||
chmod 600 "$fixture/etc/tallynote/tallynote.env"
|
||||
printf '%s\n' 'fake public key' > "$fixture/etc/tallynote/update-signing-key.pub"
|
||||
for unit in tallynote.service tallynote-update.service tallynote-update.path; do
|
||||
printf '%s\n' "Description=TallyNote $unit" "WorkingDirectory=$fixture/opt/tallynote/current" "PathExists=$fixture/var/lib/tallynote/update-request.json" > "$fixture/etc/systemd/system/$unit"
|
||||
done
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/sbin/tallynote-update"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/libexec/tallynote-update-runner"
|
||||
cp "$root/uninstall.sh" "$fixture/usr/local/sbin/tallynote-uninstall"
|
||||
chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-uninstall"
|
||||
printf '%s\n' 'sqlite' > "$fixture/var/lib/tallynote/tallynote.db"
|
||||
printf '%s\n' 'backup' > "$fixture/var/lib/tallynote-backups/backup.db"
|
||||
}
|
||||
|
||||
make_systemctl() {
|
||||
local fixture=$1
|
||||
cat > "$fixture/systemctl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
printf '%s\n' "$*" >> "$TALLYNOTE_TEST_SYSTEMCTL_LOG"
|
||||
case "${1:-}" in
|
||||
is-active) exit 0 ;;
|
||||
stop|disable|daemon-reload) exit 0 ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
EOF
|
||||
chmod 755 "$fixture/systemctl"
|
||||
}
|
||||
|
||||
run_uninstall() {
|
||||
local fixture=$1
|
||||
TALLYNOTE_UNINSTALL_TEST_MODE=true \
|
||||
TALLYNOTE_UNINSTALL_ROOT="$fixture" \
|
||||
TALLYNOTE_SYSTEMCTL_BIN="$fixture/systemctl" \
|
||||
TALLYNOTE_TEST_SYSTEMCTL_LOG="$fixture/systemctl.log" \
|
||||
bash "$root/uninstall.sh" "${@:2}"
|
||||
}
|
||||
|
||||
fixture="$tmp/normal"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
run_uninstall "$fixture"
|
||||
[[ -d "$fixture/var/lib/tallynote" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
[[ -d "$fixture/var/lib/tallynote-backups" && -f "$fixture/var/lib/tallynote-backups/backup.db" ]]
|
||||
[[ ! -e "$fixture/opt/tallynote" || -z "$(find "$fixture/opt/tallynote" -mindepth 1 -print -quit 2>/dev/null)" ]]
|
||||
[[ ! -e "$fixture/etc/systemd/system/tallynote.service" ]]
|
||||
[[ ! -e "$fixture/usr/local/sbin/tallynote-update" ]]
|
||||
grep -n '^is-active.*tallynote-update.path' "$fixture/systemctl.log" >/dev/null
|
||||
grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" >/dev/null
|
||||
path_stop=$(grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
|
||||
update_stop=$(grep -n '^stop tallynote-update.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
|
||||
main_stop=$(grep -n '^stop tallynote.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
|
||||
(( path_stop < update_stop && update_stop < main_stop ))
|
||||
|
||||
# Re-running after the first uninstall is harmless and does not touch data.
|
||||
run_uninstall "$fixture"
|
||||
[[ -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
|
||||
# Purge requires the explicit acknowledgement flag and must fail before any
|
||||
# application files are removed.
|
||||
fixture="$tmp/purge"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
if run_uninstall "$fixture" --purge-data >/dev/null 2>&1; then
|
||||
echo 'expected --purge-data without --yes to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
run_uninstall "$fixture" --purge-data --yes --purge-config
|
||||
[[ ! -e "$fixture/var/lib/tallynote" && ! -e "$fixture/var/lib/tallynote-backups" ]]
|
||||
[[ ! -e "$fixture/etc/tallynote" ]]
|
||||
|
||||
# A custom data path must not overlap the release prefix; otherwise removing
|
||||
# releases could destroy data that the default uninstall promises to keep.
|
||||
fixture="$tmp/overlap"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
printf '%s\n' \
|
||||
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \
|
||||
"TALLYNOTE_DATA_DIR=$fixture/opt/tallynote/releases/data" \
|
||||
> "$fixture/etc/tallynote/tallynote.env"
|
||||
mkdir -p "$fixture/opt/tallynote/releases/data"
|
||||
printf '%s\n' protected > "$fixture/opt/tallynote/releases/data/keep.db"
|
||||
if run_uninstall "$fixture" >/dev/null 2>&1; then
|
||||
echo 'expected overlapping data path to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -f "$fixture/opt/tallynote/releases/data/keep.db" ]]
|
||||
|
||||
# Trailing-slash aliases are rejected before the lexical overlap guard can be
|
||||
# bypassed.
|
||||
fixture="$tmp/trailing"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
printf '%s\n' \
|
||||
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/" \
|
||||
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
|
||||
> "$fixture/etc/tallynote/tallynote.env"
|
||||
if run_uninstall "$fixture" >/dev/null 2>&1; then
|
||||
echo 'expected trailing slash path to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
|
||||
# Dot-component aliases are rejected as well; textual paths must be canonical
|
||||
# before the managed-directory containment checks run.
|
||||
fixture="$tmp/dot"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
printf '%s\n' \
|
||||
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/." \
|
||||
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
|
||||
> "$fixture/etc/tallynote/tallynote.env"
|
||||
if run_uninstall "$fixture" >/dev/null 2>&1; then
|
||||
echo 'expected dot path component to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
|
||||
# Pending update state blocks destructive work until an operator overrides it.
|
||||
fixture="$tmp/pending"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
printf '%s\n' pending > "$fixture/opt/tallynote/.update-state"
|
||||
if run_uninstall "$fixture" >/dev/null 2>&1; then
|
||||
echo 'expected pending update state to block uninstall' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
|
||||
# A current link escaping the release tree is rejected without deleting data.
|
||||
fixture="$tmp/link"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
rm -f "$fixture/opt/tallynote/current"
|
||||
ln -s "$fixture/outside" "$fixture/opt/tallynote/current"
|
||||
if run_uninstall "$fixture" >/dev/null 2>&1; then
|
||||
echo 'expected unsafe current symlink to fail' >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -L "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
|
||||
# dry-run must not call systemctl or remove files.
|
||||
fixture="$tmp/dry-run"
|
||||
make_fixture "$fixture"
|
||||
make_systemctl "$fixture"
|
||||
run_uninstall "$fixture" --dry-run >/dev/null
|
||||
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
|
||||
[[ ! -e "$fixture/systemctl.log" ]]
|
||||
|
||||
printf '%s\n' 'uninstaller shell tests passed'
|
||||
+220
-35
@@ -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";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
permanentDeleteSchema,
|
||||
statusUpdateSchema,
|
||||
updateApplySchema,
|
||||
updateDownloadSchema,
|
||||
versionSchema,
|
||||
type AttachmentKind,
|
||||
type ExpenseStatus,
|
||||
@@ -94,7 +95,7 @@ const unsafeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
const sessionCookie = "tally_session";
|
||||
const csrfCookie = "tally_csrf";
|
||||
|
||||
type UpdateRateState = { checkedAt: number; appliedAt: number };
|
||||
type UpdateRateState = { checkedAt: number; downloadedAt: number; appliedAt: number };
|
||||
const updateRateStates = new WeakMap<DatabaseContext["sqlite"], Map<string, UpdateRateState>>();
|
||||
|
||||
function updateRateState(database: DatabaseContext["sqlite"], adminId: string): UpdateRateState {
|
||||
@@ -105,7 +106,7 @@ function updateRateState(database: DatabaseContext["sqlite"], adminId: string):
|
||||
}
|
||||
let state = states.get(adminId);
|
||||
if (!state) {
|
||||
state = { checkedAt: 0, appliedAt: 0 };
|
||||
state = { checkedAt: 0, downloadedAt: 0, appliedAt: 0 };
|
||||
states.set(adminId, state);
|
||||
}
|
||||
return state;
|
||||
@@ -115,13 +116,13 @@ function enforceUpdateCooldown(
|
||||
database: DatabaseContext["sqlite"],
|
||||
config: AppConfig,
|
||||
adminId: string,
|
||||
operation: "check" | "apply",
|
||||
operation: "check" | "download" | "apply",
|
||||
reply: FastifyReply,
|
||||
): void {
|
||||
const state = updateRateState(database, adminId);
|
||||
const now = Date.now();
|
||||
const previous = operation === "check" ? state.checkedAt : state.appliedAt;
|
||||
const cooldown = operation === "check" ? config.updateCheckCooldownMs : config.updateApplyCooldownMs;
|
||||
const previous = operation === "check" ? state.checkedAt : operation === "download" ? state.downloadedAt : state.appliedAt;
|
||||
const cooldown = operation === "check" ? config.updateCheckCooldownMs : operation === "download" ? config.updateDownloadCooldownMs : config.updateApplyCooldownMs;
|
||||
if (cooldown > 0 && previous > 0 && now - previous < cooldown) {
|
||||
const retryAfter = Math.max(1, Math.ceil((cooldown - (now - previous)) / 1000));
|
||||
reply.header("Retry-After", retryAfter);
|
||||
@@ -130,6 +131,7 @@ function enforceUpdateCooldown(
|
||||
: "更新操作过于频繁,请稍后再试");
|
||||
}
|
||||
if (operation === "check") state.checkedAt = now;
|
||||
else if (operation === "download") state.downloadedAt = now;
|
||||
else state.appliedAt = now;
|
||||
}
|
||||
|
||||
@@ -393,8 +395,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 +428,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<string, string>; files: StagedFile[] }> {
|
||||
const fields: Record<string, string> = {};
|
||||
const files: StagedFile[] = [];
|
||||
async function parseExpenseMultipart(request: FastifyRequest, config: AppConfig, options: { allowVersion?: boolean } = {}): Promise<{ fields: Record<string, string>; files: StagedFile[] }> {
|
||||
const fields: Record<string, string> = {};
|
||||
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 +459,127 @@ async function parseExpenseMultipart(request: FastifyRequest, config: AppConfig)
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteAll(config: AppConfig, files: StagedFile[]): Promise<Array<StagedFile & { storagePath: string }>> {
|
||||
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<StagedFile & { storagePath?: string }>): Promise<void> {
|
||||
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<Array<StagedFile & { storagePath: string }>> {
|
||||
const promoted: Array<StagedFile & { storagePath: string }> = [];
|
||||
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<typeof expenseUpdateSchema>;
|
||||
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("账目不存在");
|
||||
@@ -822,9 +934,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
const cached = publicCheckFromCache(database.sqlite, config);
|
||||
const row = database.sqlite.prepare(`
|
||||
SELECT id, status, version, platform, asset_name AS assetName,
|
||||
SELECT id, operation, status, version, platform, asset_name AS assetName,
|
||||
size_bytes AS sizeBytes, error_message AS errorMessage,
|
||||
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
|
||||
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
|
||||
requested_at AS applyQueuedAt
|
||||
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
|
||||
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
|
||||
return {
|
||||
@@ -836,7 +949,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,
|
||||
@@ -874,6 +991,37 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
if (config.updateStrategy !== "systemd") {
|
||||
throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
|
||||
}
|
||||
if (input.jobId) {
|
||||
const stagedJobId = input.jobId;
|
||||
const staged = database.sqlite.prepare("SELECT id, status, operation, version, asset_url AS assetUrl, asset_name AS assetName, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=? AND admin_id=?").get(stagedJobId, request.auth!.admin.id) as { id: string; status: string; operation: string; version: string; assetUrl: string; assetName: string | null; expectedSha256: string | null } | undefined;
|
||||
if (!staged || staged.status !== "staged" || staged.version !== input.version.replace(/^v/i, "")) throw new AppError(409, "UPDATE_NOT_STAGED", "更新任务尚未完成下载");
|
||||
if (staged.operation === "apply") throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
|
||||
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply);
|
||||
const now = Date.now();
|
||||
const active = database.sqlite.transaction(() => {
|
||||
const conflictRow = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) AND id<>? LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES, stagedJobId) as { id: string } | undefined;
|
||||
if (conflictRow) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
|
||||
const changed = database.sqlite.prepare("UPDATE update_jobs SET operation='apply', error_message=NULL, requested_at=?, request_id=?, updated_at=? WHERE id=? AND status='staged' AND operation='download'").run(now, request.id, now, stagedJobId);
|
||||
if (changed.changes !== 1) throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
|
||||
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: stagedJobId, after: { version: staged.version, staged: true } });
|
||||
return { id: stagedJobId, now };
|
||||
}).immediate();
|
||||
applyAuditTarget = stagedJobId;
|
||||
if (!staged.expectedSha256 || !/^[a-f0-9]{64}$/i.test(staged.expectedSha256)) {
|
||||
database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("暂存更新缺少有效校验值", Date.now(), active.id);
|
||||
throw new AppError(409, "UPDATE_NOT_VERIFIED", "暂存更新缺少有效校验值,请重新下载");
|
||||
}
|
||||
try {
|
||||
await writeUpdateRequest(config, { jobId: active.id, operation: "apply", version: staged.version, metadataUrl: config.updateMetadataUrl, assetUrl: staged.assetUrl, assetName: staged.assetName ?? "staged", expectedSha256: staged.expectedSha256, requestedAt: active.now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir });
|
||||
} catch {
|
||||
database.sqlite.prepare("UPDATE update_jobs SET operation='download', error_message=?, updated_at=? WHERE id=? AND status='staged' AND operation='apply'").run("无法创建系统更新请求", Date.now(), active.id);
|
||||
applyAuditRecorded = true;
|
||||
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.apply_requested", targetType: "update", targetId: active.id, outcome: "failure" });
|
||||
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
|
||||
}
|
||||
reply.header("Cache-Control", "no-store");
|
||||
return reply.code(202).send({ job: { id: active.id, status: "staged", version: staged.version, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } });
|
||||
}
|
||||
// Preserve the actionable in-progress response for duplicate clicks before
|
||||
// applying the per-admin cooldown.
|
||||
const activeBeforeCheck = database.sqlite.prepare(`
|
||||
@@ -941,8 +1089,9 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
return { id, now };
|
||||
}).immediate();
|
||||
applyAuditTarget = active.id;
|
||||
const updateRequest: UpdateRequest = {
|
||||
jobId: active.id,
|
||||
const updateRequest: UpdateRequest = {
|
||||
jobId: active.id,
|
||||
operation: "apply",
|
||||
version: requestedVersion,
|
||||
metadataUrl: cached.metadataUrl,
|
||||
assetUrl: releaseAsset.url,
|
||||
@@ -970,7 +1119,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
|
||||
}
|
||||
reply.header("Cache-Control", "no-store");
|
||||
return reply.code(202).send({ job: { id: active.id, status: "queued", version: requestedVersion } });
|
||||
return reply.code(202).send({ job: { id: active.id, status: "queued", version: requestedVersion, operation: "apply", applyQueuedAt: active.now, restartWindowSeconds: 30 } });
|
||||
} catch (error) {
|
||||
if (!applyAuditRecorded) {
|
||||
writeAudit(database.sqlite, {
|
||||
@@ -987,12 +1136,43 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/update/download", { preHandler: guard(database, config), bodyLimit: 32 * 1024 }, async (request, reply) => {
|
||||
const input = updateDownloadSchema.parse(request.body);
|
||||
if (config.updateStrategy !== "systemd") throw new AppError(503, "UPDATE_NOT_AVAILABLE", "当前安装方式未启用一键更新,请使用命令行更新");
|
||||
const active = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
|
||||
if (active) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
|
||||
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "download", reply);
|
||||
const checked = await checkForUpdate(database.sqlite, config);
|
||||
const version = input.version.replace(/^v/i, "");
|
||||
if (!checked.latest || checked.latest.version !== version || !checked.latest.isNewer || !checked.latest.compatible || !checked.latest.integrityReady) throw new AppError(409, "UPDATE_NOT_AVAILABLE", "该版本已不可用,请重新检查更新");
|
||||
const cached = readCachedRelease(database.sqlite, config);
|
||||
const cachedAsset = cached?.asset;
|
||||
if (!cached || !cachedAsset?.sha256 || cached.version !== version) throw new AppError(409, "UPDATE_NOT_VERIFIED", "发布文件缺少 SHA-256 校验值,无法更新");
|
||||
const now = Date.now();
|
||||
const id = randomUUID();
|
||||
database.sqlite.transaction(() => {
|
||||
const conflict = database.sqlite.prepare(`SELECT id FROM update_jobs WHERE status IN (${ACTIVE_UPDATE_STATUSES.map(() => "?").join(",")}) LIMIT 1`).get(...ACTIVE_UPDATE_STATUSES) as { id: string } | undefined;
|
||||
if (conflict) throw new AppError(409, "UPDATE_IN_PROGRESS", "已有更新任务正在进行,请等待完成");
|
||||
database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'queued', ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, request.auth!.admin.id, request.auth!.tokenHash, request.id, now, version, checked.platform.target, cached.metadataUrl, cachedAsset.name, cachedAsset.url, cachedAsset.sha256, now, now);
|
||||
writeAudit(database.sqlite, { requestId: request.id, actorAdminId: request.auth!.admin.id, actorUsername: request.auth!.admin.username, action: "update.download_requested", targetType: "update", targetId: id, after: { version } });
|
||||
}).immediate();
|
||||
try {
|
||||
await writeUpdateRequest(config, { jobId: id, operation: "download", version, metadataUrl: cached.metadataUrl, assetUrl: cachedAsset.url, assetName: cachedAsset.name, expectedSha256: cachedAsset.sha256, requestedAt: now, currentLink: config.currentLink, releasesDir: config.releasesDir, dataDir: config.dataDir });
|
||||
} catch {
|
||||
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=?").run("无法创建系统更新请求", Date.now(), id);
|
||||
throw new AppError(503, "UPDATE_QUEUE_FAILED", "无法提交更新请求,请检查服务安装权限");
|
||||
}
|
||||
reply.header("Cache-Control", "no-store");
|
||||
return reply.code(202).send({ job: { id, status: "queued", operation: "download", version } });
|
||||
});
|
||||
|
||||
app.get("/api/update/jobs/:id", { preHandler: guard(database, config) }, async (request, reply) => {
|
||||
const id = z.string().uuid().parse((request.params as { id: string }).id);
|
||||
const row = database.sqlite.prepare(`
|
||||
SELECT id, status, version, platform, asset_name AS assetName,
|
||||
SELECT id, operation, status, version, platform, asset_name AS assetName,
|
||||
size_bytes AS sizeBytes, error_message AS errorMessage,
|
||||
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
|
||||
created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt,
|
||||
requested_at AS applyQueuedAt
|
||||
FROM update_jobs WHERE id=? AND admin_id=?
|
||||
`).get(id, request.auth!.admin.id) as Record<string, unknown> | undefined;
|
||||
if (!row) notFound("更新任务不存在");
|
||||
@@ -1067,7 +1247,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 +1290,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 +1299,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 +1411,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 +1459,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 +1498,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 +1538,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<ReturnType<typeof fileReadStream>>;
|
||||
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 +1550,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 +1576,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 +1667,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 +1825,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).
|
||||
|
||||
+129
-11
@@ -31,6 +31,7 @@ import type { UpdateJobStatus } from "../../shared/contracts.js";
|
||||
|
||||
const updateRequestFileSchema = z.object({
|
||||
jobId: z.string().uuid(),
|
||||
operation: z.enum(["download", "apply"]).default("apply"),
|
||||
version: z.string().regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
|
||||
metadataUrl: z.string().url(),
|
||||
assetUrl: z.string().url(),
|
||||
@@ -96,6 +97,8 @@ export type UpdateRunOptions = UrlPolicy & {
|
||||
jobId?: string | undefined;
|
||||
publicKey?: string | undefined;
|
||||
requireSignature?: boolean | undefined;
|
||||
operation?: "download" | "apply" | undefined;
|
||||
stagedPath?: string | undefined;
|
||||
};
|
||||
|
||||
export type UpdateRunResult = {
|
||||
@@ -140,21 +143,24 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
|
||||
requestId?: string | undefined;
|
||||
requestedAt?: number | undefined;
|
||||
startedAt?: number | undefined;
|
||||
operation?: "download" | "apply" | undefined;
|
||||
}): void {
|
||||
if (!sqlite) return;
|
||||
const now = Date.now();
|
||||
const effectiveOperation = values.operation ?? (sqlite.prepare("SELECT operation FROM update_jobs WHERE id=?").get(jobId) as { operation?: "download" | "apply" } | undefined)?.operation ?? "apply";
|
||||
sqlite.prepare(`
|
||||
INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, started_at,
|
||||
status, version, platform, release_url, asset_name, asset_url,
|
||||
operation, status, version, platform, release_url, asset_name, asset_url,
|
||||
expected_sha256, actual_sha256, download_path, backup_path, size_bytes, error_message,
|
||||
created_at, updated_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
admin_id=COALESCE(excluded.admin_id, update_jobs.admin_id),
|
||||
session_hash=COALESCE(excluded.session_hash, update_jobs.session_hash),
|
||||
request_id=COALESCE(excluded.request_id, update_jobs.request_id),
|
||||
requested_at=COALESCE(excluded.requested_at, update_jobs.requested_at),
|
||||
started_at=COALESCE(excluded.started_at, update_jobs.started_at),
|
||||
operation=excluded.operation,
|
||||
status=excluded.status, version=excluded.version, platform=excluded.platform,
|
||||
release_url=COALESCE(excluded.release_url, update_jobs.release_url),
|
||||
asset_name=COALESCE(excluded.asset_name, update_jobs.asset_name),
|
||||
@@ -174,6 +180,7 @@ function writeJob(sqlite: Database.Database | undefined, jobId: string, values:
|
||||
values.requestId ?? null,
|
||||
values.requestedAt ?? null,
|
||||
values.startedAt ?? null,
|
||||
effectiveOperation,
|
||||
values.status,
|
||||
values.version,
|
||||
values.platform,
|
||||
@@ -238,9 +245,28 @@ async function ensurePrivilegedWorkspace(directory: string): Promise<string> {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Validate a queued staged directory before a root process consumes it. */
|
||||
async function validateStagedWorkspacePath(candidate: string, workspaceRoot: string): Promise<string> {
|
||||
const rootResolved = path.resolve(workspaceRoot);
|
||||
const rootInfo = await lstat(rootResolved).catch(() => null);
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
|
||||
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink() || (rootInfo.mode & 0o077) !== 0 || rootInfo.uid !== 0 || uid !== 0) {
|
||||
throw new Error("更新工作目录权限无效");
|
||||
}
|
||||
const root = await realpath(rootResolved).catch(() => { throw new Error("更新工作目录无效"); });
|
||||
const resolved = path.resolve(candidate);
|
||||
if (resolved === rootResolved || !resolved.startsWith(`${rootResolved}${path.sep}`)) throw new Error("更新暂存路径无效");
|
||||
const info = await lstat(resolved).catch(() => null);
|
||||
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0) throw new Error("更新暂存目录权限无效");
|
||||
const real = await realpath(resolved).catch(() => { throw new Error("更新暂存目录无效"); });
|
||||
if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new Error("更新暂存路径无效");
|
||||
return real;
|
||||
}
|
||||
|
||||
export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunResult> {
|
||||
const platform = options.platform ?? detectPlatform();
|
||||
const jobId = options.jobId ?? randomUUID();
|
||||
const operation = options.operation ?? "apply";
|
||||
let resolved: Awaited<ReturnType<typeof resolveRelease>> | undefined;
|
||||
try {
|
||||
resolved = await resolveRelease(options, platform);
|
||||
@@ -250,27 +276,36 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
|
||||
if (!expectedSha256) throw new Error("发布信息缺少 SHA-256 校验值");
|
||||
if (options.currentVersion && !isNewerVersion(options.currentVersion, resolved.version)) throw new Error("更新版本不是较新版本");
|
||||
writeJob(options.sqlite, jobId, {
|
||||
status: "queued", version: resolved.version, platform: platform.target,
|
||||
operation, status: "queued", version: resolved.version, platform: platform.target,
|
||||
releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url,
|
||||
expectedSha256, adminId: options.adminId, sessionHash: options.sessionHash,
|
||||
requestId: options.requestId, requestedAt: Date.now(),
|
||||
});
|
||||
|
||||
await mkdir(options.stagingDir, { recursive: true, mode: 0o700 });
|
||||
const workspace = await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
|
||||
let keepWorkspace = false;
|
||||
const workspace = operation === "download"
|
||||
? path.join(path.resolve(options.stagingDir), `update-${jobId}`)
|
||||
: await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
|
||||
if (operation === "download") await mkdir(workspace, { recursive: false, mode: 0o700 });
|
||||
const archivePath = path.join(workspace, resolved.asset.name.endsWith(".gz") || resolved.asset.name.endsWith(".zip") ? resolved.asset.name : `${resolved.asset.name}.tar.gz`);
|
||||
try {
|
||||
updateJob(options.sqlite, jobId, { status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
|
||||
updateJob(options.sqlite, jobId, { operation, status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
|
||||
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, options);
|
||||
if (expectedSha256 && downloaded.sha256 !== expectedSha256) throw new Error("更新文件 SHA-256 校验失败");
|
||||
updateJob(options.sqlite, jobId, { status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
|
||||
updateJob(options.sqlite, jobId, { operation, status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
|
||||
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
|
||||
const stagedDir = path.join(workspace, "payload");
|
||||
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
|
||||
await normalizeReleasePermissions(stagedDir);
|
||||
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
|
||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
|
||||
updateJob(options.sqlite, jobId, { status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath });
|
||||
updateJob(options.sqlite, jobId, { operation, status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: workspace });
|
||||
|
||||
if (operation === "download") {
|
||||
keepWorkspace = true;
|
||||
return { jobId, version: resolved.version, asset: resolved.asset, archivePath };
|
||||
}
|
||||
|
||||
let backupArchivePath: string | undefined;
|
||||
if (options.dataBackupArchivePath && options.dataBackupSource) {
|
||||
@@ -295,8 +330,10 @@ export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunRes
|
||||
updateJob(options.sqlite, jobId, { status: options.deferCompletion ? "applying" : "completed", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath), backupPath: switchedBackup ?? backupArchivePath, ...(options.deferCompletion ? {} : { completedAt }) });
|
||||
return { jobId, version: resolved.version, asset: resolved.asset, archivePath, ...(backupArchivePath ? { backupArchivePath } : {}), ...(switchedBackup ? { backupDir: switchedBackup } : {}) };
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
clearTransientJobPath(options.sqlite, jobId);
|
||||
if (!keepWorkspace) {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
clearTransientJobPath(options.sqlite, jobId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const fallbackVersion = resolved?.version ?? options.version ?? "0.0.0";
|
||||
@@ -335,6 +372,59 @@ export function finalizeUpdateJob(
|
||||
})();
|
||||
}
|
||||
|
||||
export async function applyStagedUpdate(options: {
|
||||
sqlite: Database.Database;
|
||||
jobId: string;
|
||||
version: string;
|
||||
stagedPath: string;
|
||||
currentDir: string;
|
||||
currentLink: string;
|
||||
releasesDir: string;
|
||||
backupArchivePath?: string;
|
||||
dataBackupArchivePath?: string;
|
||||
dataBackupSource?: string;
|
||||
maxBytes?: number;
|
||||
dataBackupMaxBytes?: number;
|
||||
workspaceRoot?: string;
|
||||
}): Promise<void> {
|
||||
const row = options.sqlite.prepare(`SELECT status, operation, version, platform, release_url AS releaseUrl, asset_name AS assetName, asset_url AS assetUrl, expected_sha256 AS expectedSha256, actual_sha256 AS actualSha256, size_bytes AS sizeBytes FROM update_jobs WHERE id=?`).get(options.jobId) as Record<string, unknown> | undefined;
|
||||
if (!row || row.status !== "staged" || row.operation !== "apply") throw new Error("更新任务未处于待应用状态");
|
||||
if (typeof row.version === "string" && row.version !== options.version) throw new Error("更新版本不一致");
|
||||
const stagedPath = options.workspaceRoot
|
||||
? await validateStagedWorkspacePath(options.stagedPath, options.workspaceRoot)
|
||||
: options.stagedPath;
|
||||
const payload = path.join(stagedPath, "payload");
|
||||
const payloadInfo = await lstat(payload).catch(() => null);
|
||||
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效");
|
||||
await normalizeReleasePermissions(payload);
|
||||
let switchedBackup: string | undefined;
|
||||
let committed = false;
|
||||
try {
|
||||
if (options.dataBackupArchivePath && options.dataBackupSource) {
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.dataBackupArchivePath });
|
||||
await createSafeArchive(options.dataBackupSource, options.dataBackupArchivePath, { maxBytes: options.dataBackupMaxBytes ?? 2 * 1024 * 1024 * 1024 });
|
||||
}
|
||||
if (options.backupArchivePath) {
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath });
|
||||
const source = await realpath(options.currentDir).catch(() => options.currentDir);
|
||||
await createSafeArchive(source, options.backupArchivePath, { maxBytes: options.maxBytes ?? 512 * 1024 * 1024 });
|
||||
}
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath, startedAt: Date.now() });
|
||||
switchedBackup = (await atomicSwitchRelease(payload, options.currentLink, options.releasesDir, options.version)).previousTarget;
|
||||
committed = true;
|
||||
await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
} catch (error) {
|
||||
if (!committed) {
|
||||
await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "failed", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), errorMessage: safeErrorMessage(error) });
|
||||
clearTransientJobPath(options.sqlite, options.jobId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, backupPath: switchedBackup ?? options.backupArchivePath });
|
||||
clearTransientJobPath(options.sqlite, options.jobId);
|
||||
}
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
@@ -379,9 +469,36 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
||||
prepareDataDirectories(config);
|
||||
if (request) await ensurePrivilegedWorkspace(stagingDir);
|
||||
else await mkdir(stagingDir, { recursive: true, mode: 0o700 });
|
||||
const release = acquireInstanceLock(config);
|
||||
// The download phase intentionally runs beside the live app so users keep
|
||||
// access while the archive is fetched and staged. SQLite WAL plus the
|
||||
// configured busy timeout serializes writes; the exclusive process lock is
|
||||
// reserved for apply/rollback, when the service is stopped by systemd.
|
||||
const release = request?.operation === "download" ? () => undefined : acquireInstanceLock(config);
|
||||
const database = openDatabase(config);
|
||||
try {
|
||||
if (request?.operation === "apply") {
|
||||
const staged = database.sqlite.prepare("SELECT download_path AS downloadPath, version FROM update_jobs WHERE id=? AND status='staged' AND operation='apply'").get(request.jobId) as { downloadPath: string | null; version: string } | undefined;
|
||||
if (!staged?.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效");
|
||||
const root = path.resolve(config.updateWorkspaceDir);
|
||||
const candidate = await validateStagedWorkspacePath(staged.downloadPath, root);
|
||||
await applyStagedUpdate({
|
||||
sqlite: database.sqlite,
|
||||
jobId: request.jobId,
|
||||
version: request.version,
|
||||
stagedPath: candidate,
|
||||
currentDir,
|
||||
currentLink: request.currentLink,
|
||||
releasesDir: request.releasesDir,
|
||||
workspaceRoot: root,
|
||||
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
|
||||
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}),
|
||||
dataBackupSource: config.dataDir,
|
||||
maxBytes: config.updateMaxBytes,
|
||||
dataBackupMaxBytes: config.maxTotalBytes,
|
||||
});
|
||||
console.log(`更新已切换:${request.version}`);
|
||||
return;
|
||||
}
|
||||
const result = await runUpdate({
|
||||
...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}),
|
||||
...(effectiveAssetUrl ? { assetUrl: effectiveAssetUrl } : {}),
|
||||
@@ -398,9 +515,10 @@ export async function main(config: AppConfig = loadConfig()): Promise<void> {
|
||||
dataBackupMaxBytes: config.maxTotalBytes,
|
||||
currentVersion: config.appVersion,
|
||||
...(deferCompletion ? { deferCompletion: true } : {}),
|
||||
...(request?.operation === "download" ? { operation: "download" as const } : {}),
|
||||
...(request ? { jobId: request.jobId } : {}),
|
||||
publicKey: config.updatePublicKey,
|
||||
requireSignature: request ? true : config.updateRequireSignature,
|
||||
requireSignature: config.updateRequireSignature,
|
||||
sqlite: database.sqlite,
|
||||
});
|
||||
console.log(`更新完成:${result.version}`);
|
||||
|
||||
+5
-1
@@ -104,7 +104,10 @@ export function loadConfig() {
|
||||
|| "https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest";
|
||||
const updateAllowedHosts = csvEnv("TALLYNOTE_UPDATE_ALLOWED_HOSTS");
|
||||
const updatePublicKey = updatePublicKeyEnv();
|
||||
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", updateStrategyRaw === "systemd");
|
||||
// Public releases always require HTTPS, host allowlisting, and SHA-256.
|
||||
// Detached signatures remain an opt-in hardening layer so a self-hosted
|
||||
// public repository can use one-click updates without provisioning a key.
|
||||
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", false);
|
||||
if (!(updateStrategyRaw === "disabled" || updateStrategyRaw === "systemd")) {
|
||||
throw new Error("TALLYNOTE_UPDATE_STRATEGY 必须是 disabled 或 systemd");
|
||||
}
|
||||
@@ -139,6 +142,7 @@ export function loadConfig() {
|
||||
// cooldown so an authenticated account cannot turn the endpoint into an
|
||||
// outbound request flood; set to 0 only for controlled test environments.
|
||||
updateCheckCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS", 60) * 1000,
|
||||
updateDownloadCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS", 15) * 1000,
|
||||
updateApplyCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS", 15) * 1000,
|
||||
isLocalOrigin: localOrigin,
|
||||
dataDir,
|
||||
|
||||
@@ -136,6 +136,7 @@ export const updateJobs = sqliteTable("update_jobs", {
|
||||
adminId: text("admin_id").references(() => admins.id, { onDelete: "set null" }),
|
||||
sessionHash: text("session_hash"),
|
||||
requestId: text("request_id"),
|
||||
operation: text("operation", { enum: ["download", "apply"] }).notNull().default("apply"),
|
||||
status: text("status", { enum: ["queued", "downloading", "verifying", "staged", "backing_up", "applying", "completed", "failed", "cancelled"] }).notNull(),
|
||||
version: text("version").notNull(),
|
||||
platform: text("platform").notNull(),
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
+24
-3
@@ -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<ReturnType<typeof open>>;
|
||||
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<Buffer> {
|
||||
const handle = await open(safeStoragePath(config.filesDir, storagePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
||||
let handle: Awaited<ReturnType<typeof open>>;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
sanitizeAssetName,
|
||||
selectReleaseAsset,
|
||||
validateHttpsUrl,
|
||||
RELEASE_NOTES_MAX_BYTES,
|
||||
type ReleaseAsset,
|
||||
type ReleaseMetadata,
|
||||
} from "./update.js";
|
||||
@@ -34,7 +35,10 @@ export type CachedRelease = {
|
||||
metadataUrl: string;
|
||||
version: string;
|
||||
tagName?: string;
|
||||
releaseName?: string;
|
||||
publishedAt?: string;
|
||||
notes?: string;
|
||||
releaseUrl?: string;
|
||||
platform: string;
|
||||
signatureVerified?: boolean;
|
||||
asset?: {
|
||||
@@ -53,7 +57,10 @@ export type UpdateCheckResult = {
|
||||
latest: {
|
||||
version: string;
|
||||
tagName?: string;
|
||||
releaseName?: string;
|
||||
publishedAt?: string;
|
||||
notes?: string;
|
||||
releaseUrl?: string;
|
||||
compatible: boolean;
|
||||
integrityReady: boolean;
|
||||
signatureReady: boolean;
|
||||
@@ -65,6 +72,7 @@ export type UpdateCheckResult = {
|
||||
|
||||
export type UpdateRequest = {
|
||||
jobId: string;
|
||||
operation?: "download" | "apply";
|
||||
version: string;
|
||||
metadataUrl: string;
|
||||
assetUrl: string;
|
||||
@@ -76,6 +84,7 @@ export type UpdateRequest = {
|
||||
currentLink: string;
|
||||
releasesDir: string;
|
||||
dataDir: string;
|
||||
stagedPath?: string;
|
||||
};
|
||||
|
||||
function setting(database: Database.Database, key: string): string | undefined {
|
||||
@@ -206,7 +215,10 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
|
||||
metadataUrl,
|
||||
version: safeVersion,
|
||||
...(metadata.tagName ? { tagName: metadata.tagName } : {}),
|
||||
...(metadata.releaseName ? { releaseName: metadata.releaseName } : {}),
|
||||
...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}),
|
||||
...(metadata.notes ? { notes: metadata.notes } : {}),
|
||||
...(metadata.releaseUrl ? { releaseUrl: metadata.releaseUrl } : {}),
|
||||
platform: platform.target,
|
||||
signatureVerified,
|
||||
...(asset ? {
|
||||
@@ -227,7 +239,10 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
|
||||
latest: {
|
||||
version: safeVersion,
|
||||
...(metadata.tagName ? { tagName: metadata.tagName } : {}),
|
||||
...(metadata.releaseName ? { releaseName: metadata.releaseName } : {}),
|
||||
...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}),
|
||||
...(metadata.notes ? { notes: metadata.notes } : {}),
|
||||
...(metadata.releaseUrl ? { releaseUrl: metadata.releaseUrl } : {}),
|
||||
compatible: Boolean(asset),
|
||||
integrityReady: Boolean(asset?.sha256 && (!config.updateRequireSignature || signatureVerified)),
|
||||
signatureReady: !config.updateRequireSignature || signatureVerified,
|
||||
@@ -245,6 +260,9 @@ export function readCachedRelease(database: Database.Database, config: AppConfig
|
||||
if (!value || typeof value !== "object" || typeof value.version !== "string" || typeof value.metadataUrl !== "string" || typeof value.platform !== "string") return null;
|
||||
parseSemver(value.version);
|
||||
const metadataUrl = validateHttpsUrl(value.metadataUrl, policy(config)).toString();
|
||||
if (value.releaseName !== undefined && (typeof value.releaseName !== "string" || value.releaseName.length > 200 || /[\u0000-\u001f\u007f]/.test(value.releaseName))) return null;
|
||||
if (value.notes !== undefined && (typeof value.notes !== "string" || Buffer.byteLength(value.notes, "utf8") > RELEASE_NOTES_MAX_BYTES)) return null;
|
||||
if (value.releaseUrl !== undefined) validateHttpsUrl(value.releaseUrl, policy(config));
|
||||
if (value.signatureVerified !== undefined && typeof value.signatureVerified !== "boolean") return null;
|
||||
if (value.asset) {
|
||||
if (typeof value.asset.name !== "string" || typeof value.asset.url !== "string") return null;
|
||||
@@ -266,7 +284,10 @@ export function publicCheckFromCache(database: Database.Database, config: AppCon
|
||||
return { configured: config.updateStrategy !== "disabled", currentVersion: config.appVersion, platform, checkedAt: cached?.checkedAt ?? 0, latest: cached ? {
|
||||
version: cached.version,
|
||||
...(cached.tagName ? { tagName: cached.tagName } : {}),
|
||||
...(cached.releaseName ? { releaseName: cached.releaseName } : {}),
|
||||
...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}),
|
||||
...(cached.notes ? { notes: cached.notes } : {}),
|
||||
...(cached.releaseUrl ? { releaseUrl: cached.releaseUrl } : {}),
|
||||
compatible,
|
||||
integrityReady: compatible && Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true),
|
||||
signatureReady: !config.updateRequireSignature || cached.signatureVerified === true,
|
||||
@@ -282,7 +303,10 @@ export function publicCheckFromCache(database: Database.Database, config: AppCon
|
||||
latest: {
|
||||
version: cached.version,
|
||||
...(cached.tagName ? { tagName: cached.tagName } : {}),
|
||||
...(cached.releaseName ? { releaseName: cached.releaseName } : {}),
|
||||
...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}),
|
||||
...(cached.notes ? { notes: cached.notes } : {}),
|
||||
...(cached.releaseUrl ? { releaseUrl: cached.releaseUrl } : {}),
|
||||
compatible: Boolean(cached.asset),
|
||||
integrityReady: Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true),
|
||||
signatureReady: !config.updateRequireSignature || cached.signatureVerified === true,
|
||||
@@ -309,8 +333,11 @@ export async function writeUpdateRequest(config: AppConfig, request: UpdateReque
|
||||
export function publicUpdateJob(row: Record<string, unknown> | undefined): Record<string, unknown> | null {
|
||||
if (!row) return null;
|
||||
const hasError = typeof row.errorMessage === "string" && row.errorMessage.length > 0;
|
||||
const updatedAt = typeof row.updatedAt === "number" ? row.updatedAt : null;
|
||||
const expectedRecoveryAt = row.status === "applying" && updatedAt !== null ? updatedAt + 30_000 : null;
|
||||
return {
|
||||
id: row.id,
|
||||
operation: row.operation ?? "apply",
|
||||
status: row.status,
|
||||
version: row.version,
|
||||
platform: row.platform,
|
||||
@@ -323,5 +350,8 @@ export function publicUpdateJob(row: Record<string, unknown> | undefined): Recor
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
completedAt: row.completedAt ?? null,
|
||||
...(row.applyQueuedAt ? { applyQueuedAt: row.applyQueuedAt } : {}),
|
||||
...(expectedRecoveryAt ? { expectedRecoveryAt } : {}),
|
||||
...(row.status === "applying" ? { restartWindowSeconds: 30 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
+68
-1
@@ -35,7 +35,11 @@ export type ReleaseAsset = {
|
||||
export type ReleaseMetadata = {
|
||||
version: string;
|
||||
tagName?: string;
|
||||
releaseName?: string;
|
||||
publishedAt?: string;
|
||||
/** Plain-text release notes, bounded to keep API/cache payloads small. */
|
||||
notes?: string;
|
||||
releaseUrl?: string;
|
||||
assets: ReleaseAsset[];
|
||||
};
|
||||
|
||||
@@ -143,6 +147,57 @@ function metadataError(): Error {
|
||||
}
|
||||
|
||||
const DEFAULT_METADATA_MAX_BYTES = 2 * 1024 * 1024;
|
||||
export const RELEASE_NOTES_MAX_BYTES = 64 * 1024;
|
||||
|
||||
function releaseNotesText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || value.length === 0) return undefined;
|
||||
// Gitea exposes both Markdown (body/body_html) and releaseNotes depending on
|
||||
// endpoint/version. Keep the browser contract text-only and bounded.
|
||||
const text = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p\s*>/gi, "\n\n")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.trim();
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
if (bytes.length <= RELEASE_NOTES_MAX_BYTES) return text;
|
||||
return bytes.subarray(0, RELEASE_NOTES_MAX_BYTES).toString("utf8").replace(/\uFFFD$/u, "") + "\n[内容已截断]";
|
||||
}
|
||||
|
||||
function releaseNameText(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const text = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
||||
return text.length > 0 ? text.slice(0, 200) : undefined;
|
||||
}
|
||||
|
||||
/** Gitea installations behind a reverse proxy sometimes emit internal HTTP
|
||||
* asset URLs. Rebind those URLs to the already trusted HTTPS release origin,
|
||||
* while continuing to reject arbitrary HTTPS hosts and credentials. */
|
||||
function releaseResourceUrl(value: string, current: URL, options: UrlPolicy): string {
|
||||
let candidate: URL;
|
||||
try {
|
||||
candidate = new URL(value, current);
|
||||
} catch {
|
||||
throw new Error("更新地址无效");
|
||||
}
|
||||
if (candidate.username || candidate.password) throw new Error("更新地址不允许携带凭据");
|
||||
try {
|
||||
return validateHttpsUrl(candidate, { ...options, baseUrl: current }).toString();
|
||||
} catch {
|
||||
if (candidate.protocol !== "http:") throw new Error("更新地址必须使用 HTTPS");
|
||||
const rebound = new URL(current);
|
||||
rebound.pathname = candidate.pathname;
|
||||
rebound.search = candidate.search;
|
||||
rebound.hash = "";
|
||||
return validateHttpsUrl(rebound, { ...options, baseUrl: current }).toString();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a fetch body without ever buffering more than the caller's bound. */
|
||||
async function readBoundedResponse(response: Response, maxBytes: number, tooLargeMessage: string): Promise<Buffer> {
|
||||
@@ -227,12 +282,24 @@ export async function fetchReleaseMetadata(
|
||||
const candidate = digest.replace(/^sha256:/i, "").toLowerCase();
|
||||
if (/^[a-f0-9]{64}$/.test(candidate)) sha256 = candidate;
|
||||
}
|
||||
assets.push({ name, url: validateHttpsUrl(url, { ...options, baseUrl: current }).toString(), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
|
||||
assets.push({ name, url: releaseResourceUrl(url, current, options), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
|
||||
}
|
||||
const notes = releaseNotesText(item.body ?? item.releaseNotes ?? item.release_notes ?? item.body_html);
|
||||
const releaseName = releaseNameText(item.name ?? item.releaseName);
|
||||
let releaseUrl: string | undefined;
|
||||
if (typeof item.html_url === "string" || typeof item.url === "string") {
|
||||
try {
|
||||
const candidate = typeof item.html_url === "string" ? item.html_url : item.url as string;
|
||||
releaseUrl = releaseResourceUrl(candidate, current, options);
|
||||
} catch { /* omit invalid optional release page URL */ }
|
||||
}
|
||||
return {
|
||||
version: `${version.major}.${version.minor}.${version.patch}${version.prerelease.length ? `-${version.prerelease.join(".")}` : ""}${version.build.length ? `+${version.build.join(".")}` : ""}`,
|
||||
...(typeof item.tag_name === "string" ? { tagName: item.tag_name } : {}),
|
||||
...(releaseName ? { releaseName } : {}),
|
||||
...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
...(releaseUrl ? { releaseUrl } : {}),
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
+12
-1
@@ -93,9 +93,20 @@ export const updateJobStatusSchema = z.enum([
|
||||
]);
|
||||
export type UpdateJobStatus = z.infer<typeof updateJobStatusSchema>;
|
||||
|
||||
export const updateOperationSchema = z.enum(["download", "apply"]);
|
||||
export type UpdateOperation = z.infer<typeof updateOperationSchema>;
|
||||
|
||||
/** 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),
|
||||
jobId: z.string().uuid().optional(),
|
||||
}).strict();
|
||||
|
||||
export const updateDownloadSchema = z.object({
|
||||
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();
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ TALLYNOTE_TIMEZONE=Asia/Shanghai
|
||||
TALLYNOTE_UPDATE_STRATEGY=systemd
|
||||
TALLYNOTE_UPDATE_METADATA_URL=https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest
|
||||
TALLYNOTE_UPDATE_ALLOWED_HOSTS=git.awaioi.com
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true
|
||||
TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=false
|
||||
TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=60
|
||||
TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=15
|
||||
TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=15
|
||||
# Configure a root-managed Ed25519 public key before enabling one-click updates.
|
||||
# Optional: configure a root-managed Ed25519 public key and set
|
||||
# TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=true to require detached signatures.
|
||||
# TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=/etc/tallynote/update-signing-key.pub
|
||||
|
||||
@@ -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("<invoice />") },
|
||||
]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+8
-1
@@ -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");
|
||||
|
||||
+24
-3
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,9 +41,10 @@ describe("数据库迁移", () => {
|
||||
{ name: "0001_invoice_missing_reason.sql" },
|
||||
{ name: "0002_update_jobs.sql" },
|
||||
{ name: "0003_update_job_ownership.sql" },
|
||||
{ name: "0004_update_download_apply.sql" },
|
||||
]);
|
||||
const updateColumns = migrated.sqlite.prepare("PRAGMA table_info(update_jobs)").all() as Array<{ name: string }>;
|
||||
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at"]));
|
||||
expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at", "operation"]));
|
||||
expect(migrated.sqlite.prepare("SELECT note, invoice_missing_reason AS reason FROM expenses WHERE id='00000000-0000-4000-8000-000000000099'").get()).toEqual({ note: "旧账目", reason: null });
|
||||
migrated.sqlite.close();
|
||||
migrated = openDatabase(config);
|
||||
|
||||
@@ -27,14 +27,16 @@ describe("部署安全配置", () => {
|
||||
expect(loadConfig().trustProxy).toBe(1);
|
||||
});
|
||||
|
||||
it("systemd 更新必须绑定主机白名单并默认要求签名", () => {
|
||||
it("systemd 更新必须绑定主机白名单,签名校验默认关闭", () => {
|
||||
process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd";
|
||||
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
|
||||
process.env.TALLYNOTE_COOKIE_SECURE = "true";
|
||||
expect(() => loadConfig()).toThrow(/ALLOWED_HOSTS/);
|
||||
process.env.TALLYNOTE_UPDATE_ALLOWED_HOSTS = "updates.example";
|
||||
const config = loadConfig();
|
||||
expect(config.updateRequireSignature).toBe(true);
|
||||
expect(config.updateRequireSignature).toBe(false);
|
||||
process.env.TALLYNOTE_UPDATE_REQUIRE_SIGNATURE = "true";
|
||||
expect(loadConfig().updateRequireSignature).toBe(true);
|
||||
});
|
||||
|
||||
it("收紧已有数据目录和数据库文件权限,并拒绝符号链接", () => {
|
||||
|
||||
@@ -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.4-${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.4", 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.4", 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.4", 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.4", 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.4", 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 } });
|
||||
@@ -93,9 +93,38 @@ describe("更新 API", () => {
|
||||
expect(audit.map((row) => row.action)).toEqual(expect.arrayContaining(["update.checked", "update.apply_requested"]));
|
||||
});
|
||||
|
||||
it("先下载并暂存更新包,再由同一管理员认领应用", async () => {
|
||||
const session = await login("update-staged");
|
||||
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);
|
||||
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.4", confirm: true } });
|
||||
expect(downloaded.statusCode).toBe(202);
|
||||
const downloadJobId = downloaded.json().job.id as string;
|
||||
expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.1.4" });
|
||||
const downloadRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string };
|
||||
expect(downloadRequest).toMatchObject({ jobId: downloadJobId, operation: "download" });
|
||||
expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(downloadJobId)).toEqual({ operation: "download", status: "queued" });
|
||||
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message='test', updated_at=? WHERE id=?").run(Date.now(), downloadJobId);
|
||||
|
||||
const stagedId = randomUUID();
|
||||
const now = Date.now();
|
||||
database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, actual_sha256, download_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(stagedId, (await database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged") as { id: string }).id, "session", "staged-request", now, "1.1.4", detectPlatform().target, config.updateMetadataUrl, "release.tar.gz", "https://updates.example/release.tar.gz", "c".repeat(64), "c".repeat(64), path.join(config.dataDir, "staged-workspace"), now, now);
|
||||
const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.1.4", confirm: true } });
|
||||
expect(applied.statusCode).toBe(202);
|
||||
expect(applied.json().job).toMatchObject({ id: stagedId, operation: "apply", status: "staged" });
|
||||
expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ operation: "apply", status: "staged" });
|
||||
const applyRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string; assetUrl: string; expectedSha256: string };
|
||||
expect(applyRequest).toMatchObject({ jobId: stagedId, operation: "apply", assetUrl: "https://updates.example/release.tar.gz", expectedSha256: "c".repeat(64) });
|
||||
const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.1.4", confirm: true } });
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS");
|
||||
});
|
||||
|
||||
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.4" } });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
|
||||
const disabledConfig = loadConfig();
|
||||
@@ -108,7 +137,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.4", 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 +155,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.4", 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");
|
||||
|
||||
@@ -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.4-${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.4", 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.4", 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 {
|
||||
|
||||
@@ -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<ApiError>({ 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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Executable
+473
@@ -0,0 +1,473 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# TallyNote native uninstaller. The default operation removes only the
|
||||
# application and service integration; the database and attachments stay in
|
||||
# place until --purge-data --yes is explicitly requested.
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||||
export PATH
|
||||
umask 077
|
||||
|
||||
TEST_MODE=${TALLYNOTE_UNINSTALL_TEST_MODE:-false}
|
||||
TEST_ROOT=${TALLYNOTE_UNINSTALL_ROOT:-}
|
||||
PREFIX=${TALLYNOTE_PREFIX:-/opt/tallynote}
|
||||
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
|
||||
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
|
||||
UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-/etc/systemd/system}
|
||||
SBIN_DIR=${TALLYNOTE_SBIN_DIR:-/usr/local/sbin}
|
||||
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-/usr/local/libexec}
|
||||
SYSTEMCTL_BIN=systemctl
|
||||
SYSTEMCTL_AVAILABLE=0
|
||||
PURGE_DATA=0
|
||||
PURGE_CONFIG=0
|
||||
YES=0
|
||||
DRY_RUN=0
|
||||
FORCE=0
|
||||
EXPLICIT_PREFIX=0
|
||||
EXPLICIT_DATA=0
|
||||
EXPLICIT_CONFIG=0
|
||||
|
||||
die() { printf 'tallynote uninstaller: %s\n' "$*" >&2; exit 1; }
|
||||
log() { printf 'tallynote uninstaller: %s\n' "$*"; }
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: tallynote-uninstall [--yes] [--purge-data] [--purge-config]
|
||||
[--dry-run] [--force]
|
||||
[--prefix PATH] [--data-dir PATH] [--config-dir PATH]
|
||||
|
||||
By default, remove the TallyNote release tree, systemd units, update helpers,
|
||||
and known configuration files. The database, attachments, staging, exports,
|
||||
update queue, and update backups are preserved. Data removal requires both
|
||||
--purge-data and --yes. --force is only for an operator who has verified that
|
||||
no update is in progress; it overrides the pending-update guard.
|
||||
EOF
|
||||
}
|
||||
|
||||
is_true() { [[ "$1" == true || "$1" == 1 ]]; }
|
||||
|
||||
if [[ "$TEST_MODE" != true && "$TEST_MODE" != false && "$TEST_MODE" != 1 && "$TEST_MODE" != 0 ]]; then
|
||||
die 'TALLYNOTE_UNINSTALL_TEST_MODE must be true or false'
|
||||
fi
|
||||
if [[ "$TEST_MODE" == 1 ]]; then TEST_MODE=true; fi
|
||||
if [[ "$TEST_MODE" == 0 ]]; then TEST_MODE=false; fi
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--yes) YES=1 ;;
|
||||
--purge-data) PURGE_DATA=1 ;;
|
||||
--purge-config) PURGE_CONFIG=1 ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--force) FORCE=1 ;;
|
||||
--prefix) PREFIX=${2:?missing value for --prefix}; EXPLICIT_PREFIX=1; shift ;;
|
||||
--data-dir) DATA_DIR=${2:?missing value for --data-dir}; EXPLICIT_DATA=1; shift ;;
|
||||
--config-dir) CONFIG_DIR=${2:?missing value for --config-dir}; EXPLICIT_CONFIG=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ "$TEST_MODE" == true ]]; then
|
||||
[[ -n "$TEST_ROOT" ]] || die 'test mode requires TALLYNOTE_UNINSTALL_ROOT'
|
||||
[[ "$TEST_ROOT" = /* && "$TEST_ROOT" != *'..'* && "$TEST_ROOT" != *'//'* && "$TEST_ROOT" != *$'\n'* && "$TEST_ROOT" != *$'\r'* ]] || die 'test root is invalid'
|
||||
(( EXPLICIT_PREFIX )) || PREFIX=${TALLYNOTE_PREFIX:-$TEST_ROOT/opt/tallynote}
|
||||
(( EXPLICIT_DATA )) || DATA_DIR=${TALLYNOTE_DATA_DIR:-$TEST_ROOT/var/lib/tallynote}
|
||||
(( EXPLICIT_CONFIG )) || CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-$TEST_ROOT/etc/tallynote}
|
||||
UNIT_DIR=${TALLYNOTE_SYSTEMD_UNIT_DIR:-$TEST_ROOT/etc/systemd/system}
|
||||
SBIN_DIR=${TALLYNOTE_SBIN_DIR:-$TEST_ROOT/usr/local/sbin}
|
||||
LIBEXEC_DIR=${TALLYNOTE_LIBEXEC_DIR:-$TEST_ROOT/usr/local/libexec}
|
||||
SYSTEMCTL_BIN=${TALLYNOTE_SYSTEMCTL_BIN:-systemctl}
|
||||
fi
|
||||
|
||||
stat_uid() { stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1"; }
|
||||
stat_mode() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"; }
|
||||
stat_mode_bits() {
|
||||
local mode
|
||||
mode=$(stat_mode "$1")
|
||||
[[ "$mode" =~ ^[0-7]+$ ]] || die "无法读取路径权限:$1"
|
||||
printf '%d' "$((8#$mode))"
|
||||
}
|
||||
|
||||
allowed_owner() {
|
||||
local path=$1 uid
|
||||
uid=$(stat_uid "$path")
|
||||
if [[ "$TEST_MODE" == true ]]; then
|
||||
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]]
|
||||
else
|
||||
[[ "$uid" == 0 ]]
|
||||
fi
|
||||
}
|
||||
|
||||
allowed_data_owner() {
|
||||
local path=$1 uid tallynote_uid
|
||||
uid=$(stat_uid "$path")
|
||||
if [[ "$TEST_MODE" == true ]]; then
|
||||
[[ "$uid" == "$(id -u)" || "$uid" == 0 ]]
|
||||
return
|
||||
fi
|
||||
[[ "$uid" == 0 ]] && return 0
|
||||
tallynote_uid=$(id -u tallynote 2>/dev/null || true)
|
||||
[[ -n "$tallynote_uid" && "$uid" == "$tallynote_uid" ]]
|
||||
}
|
||||
|
||||
validate_path_value() {
|
||||
local value=$1 label=$2
|
||||
[[ "$value" = /* && "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 必须是绝对路径"
|
||||
[[ "$value" =~ ^/[A-Za-z0-9._/-]+$ && "$value" != *"//"* && "$value" != *"/../"* && "$value" != */.. && "$value" != *"/./"* && "$value" != */. && "$value" != / && "$value" != */ ]] || die "$label 包含不受支持的路径字符"
|
||||
case "$value" in
|
||||
/opt|/var|/etc|/usr|/usr/local|/bin|/sbin|/home|/root|/tmp) die "$label 不能指向系统顶层目录" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_parent_chain() {
|
||||
local target=$1 current=/ component relative
|
||||
relative=${target#/}
|
||||
IFS='/' read -r -a _parts <<< "$relative"
|
||||
for component in "${_parts[@]}"; do
|
||||
[[ -n "$component" ]] || continue
|
||||
current="${current%/}/$component"
|
||||
if [[ -L "$current" ]]; then die "路径不能包含符号链接:$current"; fi
|
||||
if [[ -e "$current" ]]; then
|
||||
[[ -d "$current" ]] || die "路径不是目录:$current"
|
||||
allowed_owner "$current" || die "路径目录的所有者不受信任:$current"
|
||||
local mode_bits
|
||||
mode_bits=$(stat_mode_bits "$current")
|
||||
(( (mode_bits & 18) == 0 || (mode_bits & 512) != 0 )) || die "路径目录权限过宽:$current"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
validate_target() {
|
||||
local target=$1 label=$2 owner_check=allowed_owner
|
||||
[[ "${3:-}" == data ]] && owner_check=allowed_data_owner
|
||||
validate_path_value "$target" "$label"
|
||||
validate_parent_chain "$target"
|
||||
if [[ -e "$target" || -L "$target" ]]; then
|
||||
"$owner_check" "$target" || die "$label 的所有者不受信任:$target"
|
||||
fi
|
||||
}
|
||||
|
||||
read_env_value() {
|
||||
local file=$1 key=$2
|
||||
sed -n "s/^${key}=//p" "$file" | head -n 1
|
||||
}
|
||||
|
||||
env_key_count() {
|
||||
local file=$1 key=$2
|
||||
awk -v key="$key" 'index($0, key "=") == 1 { count += 1 } END { print count + 0 }' "$file"
|
||||
}
|
||||
|
||||
load_config() {
|
||||
local env_file=$CONFIG_DIR/tallynote.env value key count
|
||||
[[ -e "$env_file" || -L "$env_file" ]] || return 0
|
||||
[[ -f "$env_file" && ! -L "$env_file" ]] || die '环境文件不是普通文件'
|
||||
allowed_owner "$env_file" || die '环境文件的所有者不受信任'
|
||||
local mode_bits
|
||||
mode_bits=$(stat_mode_bits "$env_file")
|
||||
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
|
||||
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR; do
|
||||
count=$(env_key_count "$env_file" "$key")
|
||||
[[ "$count" == 0 || "$count" == 1 ]] || die "环境文件包含重复配置:$key"
|
||||
done
|
||||
if (( ! EXPLICIT_PREFIX )); then
|
||||
value=$(read_env_value "$env_file" TALLYNOTE_INSTALL_PREFIX)
|
||||
[[ -z "$value" ]] || PREFIX=$value
|
||||
fi
|
||||
if (( ! EXPLICIT_DATA )); then
|
||||
value=$(read_env_value "$env_file" TALLYNOTE_DATA_DIR)
|
||||
[[ -z "$value" ]] || DATA_DIR=$value
|
||||
fi
|
||||
}
|
||||
|
||||
path_inside() {
|
||||
local child=$1 parent=$2
|
||||
[[ "$child" == "$parent"/* ]]
|
||||
}
|
||||
|
||||
assert_disjoint_paths() {
|
||||
local left left_label right right_label
|
||||
local -a labels=(prefix data config unit sbin libexec)
|
||||
for left_label in "${labels[@]}"; do
|
||||
case "$left_label" in
|
||||
prefix) left=$PREFIX ;;
|
||||
data) left=$DATA_DIR ;;
|
||||
config) left=$CONFIG_DIR ;;
|
||||
unit) left=$UNIT_DIR ;;
|
||||
sbin) left=$SBIN_DIR ;;
|
||||
libexec) left=$LIBEXEC_DIR ;;
|
||||
esac
|
||||
for right_label in "${labels[@]}"; do
|
||||
[[ "$left_label" == "$right_label" ]] && continue
|
||||
case "$right_label" in
|
||||
prefix) right=$PREFIX ;;
|
||||
data) right=$DATA_DIR ;;
|
||||
config) right=$CONFIG_DIR ;;
|
||||
unit) right=$UNIT_DIR ;;
|
||||
sbin) right=$SBIN_DIR ;;
|
||||
libexec) right=$LIBEXEC_DIR ;;
|
||||
esac
|
||||
if [[ "$left" == "$right" ]] || path_inside "$left" "$right" || path_inside "$right" "$left"; then
|
||||
die "卸载目录不能互相嵌套:$left 与 $right"
|
||||
fi
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
assert_test_scope() {
|
||||
[[ "$TEST_MODE" == true ]] || return 0
|
||||
[[ -d "$TEST_ROOT" && ! -L "$TEST_ROOT" ]] || die 'test root must be an existing directory'
|
||||
validate_parent_chain "$TEST_ROOT"
|
||||
allowed_owner "$TEST_ROOT" || die 'test root owner is not trusted'
|
||||
local value label
|
||||
for label in PREFIX DATA_DIR CONFIG_DIR UNIT_DIR SBIN_DIR LIBEXEC_DIR; do
|
||||
case "$label" in
|
||||
PREFIX) value=$PREFIX ;;
|
||||
DATA_DIR) value=$DATA_DIR ;;
|
||||
CONFIG_DIR) value=$CONFIG_DIR ;;
|
||||
UNIT_DIR) value=$UNIT_DIR ;;
|
||||
SBIN_DIR) value=$SBIN_DIR ;;
|
||||
LIBEXEC_DIR) value=$LIBEXEC_DIR ;;
|
||||
esac
|
||||
[[ "$value" == "$TEST_ROOT"/* ]] || die "test mode path escapes TALLYNOTE_UNINSTALL_ROOT: $value"
|
||||
done
|
||||
}
|
||||
|
||||
managed_file() {
|
||||
local target=$1 label=$2
|
||||
case "$label" in
|
||||
service\ unit|updater\ unit|path\ unit|update\ helper|update\ runner|uninstaller)
|
||||
grep -Eiq 'tallynote|TallyNote' "$target" || return 1
|
||||
if [[ "$label" == 'path unit' ]]; then
|
||||
grep -Fq "$DATA_DIR" "$target" || return 1
|
||||
elif [[ "$label" == *unit ]]; then
|
||||
grep -Fq "$PREFIX" "$target" || return 1
|
||||
else
|
||||
grep -Eq 'TALLYNOTE_INSTALL_PREFIX|/opt/tallynote' "$target" || return 1
|
||||
fi
|
||||
;;
|
||||
environment\ file)
|
||||
grep -q '^TALLYNOTE_INSTALL_PREFIX=' "$target" || return 1
|
||||
grep -q '^TALLYNOTE_DATA_DIR=' "$target" || return 1
|
||||
[[ "$(read_env_value "$target" TALLYNOTE_INSTALL_PREFIX)" == "$PREFIX" ]] || return 1
|
||||
[[ "$(read_env_value "$target" TALLYNOTE_DATA_DIR)" == "$DATA_DIR" ]] || return 1
|
||||
;;
|
||||
update\ public\ key)
|
||||
[[ -f "$CONFIG_DIR/tallynote.env" ]] || return 1
|
||||
[[ "$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_UPDATE_PUBLIC_KEY_FILE)" == "$target" ]] || return 1
|
||||
;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_release_tree() {
|
||||
local tree=$1 owner_check=${2:-allowed_owner}
|
||||
[[ -d "$tree" && ! -L "$tree" ]] || die "发布目录无效:$tree"
|
||||
"$owner_check" "$tree" || die "发布目录的所有者不受信任:$tree"
|
||||
if find "$tree" -type l -print -quit | grep -q .; then
|
||||
die "发布目录包含符号链接:$tree"
|
||||
fi
|
||||
if find "$tree" ! -type d ! -type f -print -quit | grep -q .; then
|
||||
die "发布目录包含不支持的文件类型:$tree"
|
||||
fi
|
||||
local node mode_bits
|
||||
while IFS= read -r node; do
|
||||
"$owner_check" "$node" || die "发布目录节点的所有者不受信任:$node"
|
||||
mode_bits=$(stat_mode_bits "$node")
|
||||
(( (mode_bits & 18) == 0 )) || die "发布目录节点权限过宽:$node"
|
||||
done < <(find "$tree" -print)
|
||||
}
|
||||
|
||||
pending_update() {
|
||||
[[ -e "$PREFIX/.update-state" || -L "$PREFIX/.update-state" || -e "$DATA_DIR/update-request.json" || -L "$DATA_DIR/update-request.json" ]]
|
||||
}
|
||||
|
||||
run_systemctl() {
|
||||
(( DRY_RUN )) && return 0
|
||||
if [[ "$SYSTEMCTL_BIN" == */* ]]; then
|
||||
[[ -x "$SYSTEMCTL_BIN" ]] || return 0
|
||||
else
|
||||
command -v "$SYSTEMCTL_BIN" >/dev/null 2>&1 || return 0
|
||||
fi
|
||||
"$SYSTEMCTL_BIN" "$@"
|
||||
}
|
||||
|
||||
stop_services() {
|
||||
local unit active status
|
||||
if (( DRY_RUN )); then
|
||||
log 'dry-run: would stop/disable systemd units in path -> updater -> app order'
|
||||
return 0
|
||||
fi
|
||||
if (( ! SYSTEMCTL_AVAILABLE )); then
|
||||
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
|
||||
[[ ! -e "$UNIT_DIR/$unit" ]] || die 'systemctl 不可用,无法安全停止已安装服务'
|
||||
done
|
||||
return 0
|
||||
fi
|
||||
for unit in tallynote-update.path tallynote-update.service tallynote.service; do
|
||||
active=0
|
||||
if run_systemctl is-active --quiet "$unit" >/dev/null 2>&1; then
|
||||
active=1
|
||||
else
|
||||
status=$?
|
||||
case "$status" in
|
||||
3|4) ;;
|
||||
*) die "无法读取服务状态:$unit" ;;
|
||||
esac
|
||||
fi
|
||||
if (( active )); then
|
||||
run_systemctl stop "$unit" || die "无法停止服务:$unit"
|
||||
fi
|
||||
if [[ -e "$UNIT_DIR/$unit" ]]; then
|
||||
run_systemctl disable "$unit" >/dev/null 2>&1 || die "无法禁用服务:$unit"
|
||||
fi
|
||||
done
|
||||
run_systemctl daemon-reload >/dev/null 2>&1 || die 'systemd daemon-reload 失败'
|
||||
}
|
||||
|
||||
validate_systemctl() {
|
||||
local resolved uid mode_bits
|
||||
if [[ "$TEST_MODE" == true ]]; then
|
||||
if [[ "$SYSTEMCTL_BIN" == */* && -x "$SYSTEMCTL_BIN" ]]; then
|
||||
SYSTEMCTL_AVAILABLE=1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
resolved=$(command -v systemctl 2>/dev/null || true)
|
||||
if [[ -z "$resolved" ]]; then
|
||||
SYSTEMCTL_AVAILABLE=0
|
||||
return 0
|
||||
fi
|
||||
[[ -x "$resolved" && ! -L "$resolved" ]] || die 'systemctl 必须是可信的普通可执行文件'
|
||||
uid=$(stat_uid "$resolved")
|
||||
mode_bits=$(stat_mode_bits "$resolved")
|
||||
[[ "$uid" == 0 && $((mode_bits & 18)) -eq 0 ]] || die 'systemctl 必须由 root 拥有且不可被其他用户写入'
|
||||
SYSTEMCTL_BIN=$resolved
|
||||
SYSTEMCTL_AVAILABLE=1
|
||||
}
|
||||
|
||||
remove_file_if_owned() {
|
||||
local target=$1 label=$2
|
||||
[[ -e "$target" || -L "$target" ]] || return 0
|
||||
if [[ -L "$target" || ! -f "$target" ]]; then
|
||||
log "warning: 保留非普通文件:$target"
|
||||
return 0
|
||||
fi
|
||||
if ! allowed_owner "$target"; then
|
||||
log "warning: 保留非本安装创建的文件:$target"
|
||||
return 0
|
||||
fi
|
||||
if ! managed_file "$target" "$label"; then
|
||||
log "warning: 保留内容不匹配的文件:$target"
|
||||
return 0
|
||||
fi
|
||||
if (( DRY_RUN )); then
|
||||
log "dry-run: remove $label $target"
|
||||
else
|
||||
rm -f -- "$target"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_tree() {
|
||||
local target=$1 label=$2 owner_check=${3:-allowed_owner}
|
||||
[[ -e "$target" || -L "$target" ]] || return 0
|
||||
[[ -d "$target" && ! -L "$target" ]] || die "$label 不是安全目录:$target"
|
||||
"$owner_check" "$target" || die "$label 的所有者不受信任:$target"
|
||||
validate_release_tree "$target" "$owner_check"
|
||||
if (( DRY_RUN )); then
|
||||
log "dry-run: remove $label $target"
|
||||
else
|
||||
rm -rf -- "$target"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_prefix() {
|
||||
local current=$PREFIX/current current_target releases=$PREFIX/releases
|
||||
if [[ -L "$current" ]]; then
|
||||
current_target=$(readlink "$current")
|
||||
[[ "$current_target" = "$PREFIX/releases/"* && "$current_target" != *'..'* ]] || die 'current 符号链接指向安装目录之外'
|
||||
[[ -d "$current_target" && ! -L "$current_target" ]] || die 'current 目标不是安全目录'
|
||||
if (( DRY_RUN )); then
|
||||
log "dry-run: remove current link $current"
|
||||
else
|
||||
rm -f -- "$current"
|
||||
fi
|
||||
elif [[ -e "$current" ]]; then
|
||||
log "warning: 保留非符号链接 current:$current"
|
||||
fi
|
||||
remove_tree "$releases" 'releases'
|
||||
remove_tree "$PREFIX/.update-work" 'update work'
|
||||
remove_file_if_owned "$PREFIX/.update-state" 'update state'
|
||||
if [[ -d "$PREFIX" && ! -L "$PREFIX" ]]; then
|
||||
allowed_owner "$PREFIX" || die "安装目录的所有者不受信任:$PREFIX"
|
||||
if (( DRY_RUN )); then
|
||||
log "dry-run: remove empty install directory if empty: $PREFIX"
|
||||
else
|
||||
rmdir -- "$PREFIX" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
remove_config() {
|
||||
remove_file_if_owned "$CONFIG_DIR/update-signing-key.pub" 'update public key'
|
||||
remove_file_if_owned "$CONFIG_DIR/tallynote.env" 'environment file'
|
||||
if (( PURGE_CONFIG )) && [[ -d "$CONFIG_DIR" && ! -L "$CONFIG_DIR" ]]; then
|
||||
allowed_owner "$CONFIG_DIR" || die '配置目录的所有者不受信任'
|
||||
if (( DRY_RUN )); then log "dry-run: remove config directory if safe: $CONFIG_DIR"; else rmdir -- "$CONFIG_DIR" 2>/dev/null || true; fi
|
||||
fi
|
||||
}
|
||||
|
||||
remove_data() {
|
||||
local backup_dir
|
||||
backup_dir=$(dirname -- "$DATA_DIR")/tallynote-backups
|
||||
if (( PURGE_DATA )); then
|
||||
(( YES )) || die '--purge-data 必须同时提供 --yes'
|
||||
remove_tree "$DATA_DIR" 'data' allowed_data_owner
|
||||
remove_tree "$backup_dir" 'backup data'
|
||||
else
|
||||
log "保留数据目录:$DATA_DIR"
|
||||
if [[ -d "$backup_dir" ]]; then
|
||||
log "保留备份目录:$backup_dir"
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ "$TEST_MODE" != true ]]; then
|
||||
[[ $EUID -eq 0 ]] || die '卸载必须以 root 运行(请使用 sudo)'
|
||||
fi
|
||||
if (( PURGE_DATA && ! YES )); then
|
||||
die '--purge-data 必须同时提供 --yes'
|
||||
fi
|
||||
validate_path_value "$CONFIG_DIR" '配置目录'
|
||||
validate_target "$CONFIG_DIR" '配置目录'
|
||||
assert_test_scope
|
||||
load_config
|
||||
validate_target "$PREFIX" '安装目录'
|
||||
validate_target "$DATA_DIR" '数据目录' data
|
||||
validate_target "$CONFIG_DIR" '配置目录'
|
||||
validate_target "$UNIT_DIR" 'systemd 单元目录'
|
||||
validate_target "$SBIN_DIR" 'sbin 目录'
|
||||
validate_target "$LIBEXEC_DIR" 'libexec 目录'
|
||||
assert_test_scope
|
||||
assert_disjoint_paths
|
||||
validate_systemctl
|
||||
if (( ! FORCE )) && pending_update; then
|
||||
die '检测到未完成的更新状态;确认更新已停止后使用 --force 重试'
|
||||
fi
|
||||
log "target: prefix=$PREFIX data=$DATA_DIR config=$CONFIG_DIR"
|
||||
stop_services
|
||||
remove_prefix
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote.service" 'service unit'
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote-update.service" 'updater unit'
|
||||
remove_file_if_owned "$UNIT_DIR/tallynote-update.path" 'path unit'
|
||||
remove_file_if_owned "$SBIN_DIR/tallynote-update" 'update helper'
|
||||
remove_file_if_owned "$LIBEXEC_DIR/tallynote-update-runner" 'update runner'
|
||||
remove_file_if_owned "$SBIN_DIR/tallynote-uninstall" 'uninstaller'
|
||||
remove_config
|
||||
remove_data
|
||||
log 'uninstall complete'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+5
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#175CD3" />
|
||||
<title>TallyNote 账目台</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Input, type InputProps, type InputRef } from "tdesign-react";
|
||||
|
||||
export interface AccessibleInputProps extends InputProps {
|
||||
inputAriaLabel?: string;
|
||||
inputAriaInvalid?: boolean;
|
||||
inputAriaDescribedby?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TDesign places unknown aria props on its wrapper. This adapter mirrors the
|
||||
* important field attributes onto the native input so labels, errors and
|
||||
* keyboard tooling target the actual editable control.
|
||||
*/
|
||||
export default function AccessibleInput({ inputAriaLabel, inputAriaInvalid, inputAriaDescribedby, ...props }: AccessibleInputProps) {
|
||||
const ref = useRef<InputRef>(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 <Input {...props} ref={ref} />;
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<(() => void) | null>(null);
|
||||
const warnings = useRef(new Map<string, string>());
|
||||
const warningRef = useRef<string | null>(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 <UnsavedContext.Provider value={value}>
|
||||
{children}
|
||||
<Dialog visible={visible} header="放弃未保存内容?" confirmBtn={{ content: "放弃并离开", theme: "danger" }} cancelBtn="继续编辑" onClose={cancel} onConfirm={confirm} onCancel={cancel}>
|
||||
{warning || "当前页面有未保存的内容,离开后将无法恢复。"}
|
||||
</Dialog>
|
||||
</UnsavedContext.Provider>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<UnsavedContextValue | null>;
|
||||
};
|
||||
|
||||
const registry = typeof window === "undefined" ? null : window as ContextRegistry;
|
||||
export const UnsavedContext: Context<UnsavedContextValue | null> = registry?.__tallynoteUnsavedContext ?? createContext<UnsavedContextValue | null>(null);
|
||||
if (registry && !registry.__tallynoteUnsavedContext) registry.__tallynoteUnsavedContext = UnsavedContext;
|
||||
@@ -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<HTMLElement, () => 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<HTMLElement>(FOCUSABLE)).filter(isVisible);
|
||||
|
||||
const enhance = (dialog: HTMLElement) => {
|
||||
if (enhanced.has(dialog)) return;
|
||||
const context = dialog.closest<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".t-dialog__cancel:not([disabled])")
|
||||
?? dialog.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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<HTMLElement | null>(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<HTMLElement>(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<HTMLElement>(`${selector}.t-drawer--open`) || document.querySelector<HTMLElement>(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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(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]);
|
||||
}
|
||||
@@ -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<HTMLButtonElement>(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 (
|
||||
<Layout className="tn-shell tn-layout">
|
||||
<nav id="tn-desktop-navigation" className={`tn-desktop-menu ${desktopVisible ? "" : "is-hidden"}`} aria-label="主导航" aria-hidden={!desktopVisible || undefined} inert={!desktopVisible || undefined}>
|
||||
<AppMenu activeId={activeId} onChange={choose} />
|
||||
</nav>
|
||||
<Layout className="tn-layout-main">
|
||||
<AppHeader
|
||||
adminName={adminName}
|
||||
adminUsername={adminUsername}
|
||||
menuButtonRef={menuButtonRef}
|
||||
menuOpen={isMobile ? mobileVisible : desktopVisible}
|
||||
menuControls={isMobile ? "tn-mobile-navigation" : "tn-desktop-navigation"}
|
||||
onToggleMenu={toggleMenu}
|
||||
onLogout={onLogout}
|
||||
onOpenPassword={onOpenPassword}
|
||||
/>
|
||||
<AppContent>{children}</AppContent>
|
||||
</Layout>
|
||||
<Drawer
|
||||
className="tn-mobile-drawer"
|
||||
visible={mobileVisible}
|
||||
placement="left"
|
||||
size="216px"
|
||||
closeBtn={false}
|
||||
header={<div className="tn-mobile-drawer-header"><span>主导航</span><Button variant="text" shape="square" aria-label="关闭导航菜单" onClick={closeMobileMenu} icon={<X size={17} />} /></div>}
|
||||
footer={false}
|
||||
destroyOnClose
|
||||
onClose={closeMobileMenu}
|
||||
>
|
||||
<nav id="tn-mobile-navigation" className="tn-mobile-menu-shell" aria-label="主导航">
|
||||
<AppMenu activeId={activeId} onChange={choose} width={["216px", "64px"]} />
|
||||
</nav>
|
||||
</Drawer>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export { AppContent, AppHeader, AppMenu };
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Layout } from "tdesign-react";
|
||||
|
||||
export interface AppContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AppContent({ children, className }: AppContentProps) {
|
||||
return (
|
||||
<Layout.Content className={["tn-content", className].filter(Boolean).join(" ")}>
|
||||
<div className="tn-content-inner">{children}</div>
|
||||
</Layout.Content>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ChevronDown, KeyRound, LogOut, Menu as MenuIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type Ref } from "react";
|
||||
import { Avatar, Button, Dropdown, Layout, Space, Tooltip, type DropdownOption } from "tdesign-react";
|
||||
|
||||
export interface AppHeaderProps {
|
||||
adminName?: string;
|
||||
adminUsername?: string;
|
||||
onToggleMenu?: () => void;
|
||||
menuButtonRef?: Ref<HTMLButtonElement>;
|
||||
menuOpen?: boolean;
|
||||
menuControls?: string;
|
||||
onLogout?: () => void;
|
||||
onOpenPassword?: () => void;
|
||||
}
|
||||
|
||||
export default function AppHeader({ adminName, adminUsername, onToggleMenu, menuButtonRef, menuOpen = false, menuControls, onLogout, onOpenPassword }: AppHeaderProps) {
|
||||
const displayName = adminName?.trim() || "管理员";
|
||||
const avatarText = [...displayName][0]?.toUpperCase() || "管";
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const userMenuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuItem = (label: string) => <button type="button" role="menuitem" className="tn-user-dropdown-button" onKeyDown={handleMenuKeyDown}>{label}</button>;
|
||||
const userOptions: DropdownOption[] = [
|
||||
...(onOpenPassword ? [{ value: "password", content: menuItem("修改密码"), prefixIcon: <KeyRound size={16} /> }] : []),
|
||||
...(onLogout ? [{ value: "logout", content: menuItem("退出登录"), theme: "error" as const, prefixIcon: <LogOut size={16} /> }] : []),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!userMenuOpen) return;
|
||||
let disposed = false;
|
||||
const focusMenu = () => {
|
||||
if (disposed) return;
|
||||
const menu = document.querySelector<HTMLElement>(".tn-user-dropdown");
|
||||
if (!menu) return;
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", "账号操作");
|
||||
menu.querySelector<HTMLElement>("[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<HTMLButtonElement>) {
|
||||
const items = Array.from(document.querySelectorAll<HTMLButtonElement>(".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 (
|
||||
<Layout.Header className="tn-header">
|
||||
<Space className="tn-header-leading" align="center" size="small">
|
||||
<Tooltip content="导航菜单" placement="bottom">
|
||||
<Button
|
||||
ref={menuButtonRef}
|
||||
className="tn-header-menu-toggle"
|
||||
shape="square"
|
||||
size="large"
|
||||
variant="text"
|
||||
aria-label="导航菜单"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls={menuControls}
|
||||
onClick={onToggleMenu}
|
||||
icon={<MenuIcon size={19} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<span className="tn-header-nav-label">主导航</span>
|
||||
</Space>
|
||||
<Space className="tn-header-actions" align="center" size="small">
|
||||
{userOptions.length > 0 && <Dropdown
|
||||
trigger="click"
|
||||
placement="bottom-right"
|
||||
options={userOptions}
|
||||
minColumnWidth={184}
|
||||
maxColumnWidth={240}
|
||||
onClick={handleUserAction}
|
||||
panelTopContent={<div className="tn-user-dropdown-identity"><strong>{displayName}</strong>{adminUsername && <span>{adminUsername}</span>}</div>}
|
||||
popupProps={{ overlayInnerClassName: "tn-user-dropdown", onVisibleChange: setUserMenuOpen }}
|
||||
>
|
||||
<Button ref={userMenuButtonRef} className="tn-user-menu-trigger" variant="text" size="large" aria-label={`${displayName},打开账号菜单`} aria-haspopup="menu" aria-expanded={userMenuOpen} suffix={<ChevronDown size={16} />}>
|
||||
<Avatar className="tn-user-avatar" size="32px" shape="circle">{avatarText}</Avatar>
|
||||
<span className="tn-header-user">{displayName}</span>
|
||||
</Button>
|
||||
</Dropdown>}
|
||||
</Space>
|
||||
</Layout.Header>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<TMenu
|
||||
className={className}
|
||||
value={activeId}
|
||||
collapsed={collapsed}
|
||||
width={width}
|
||||
theme="light"
|
||||
logo={showLogo ? <MenuLogo collapsed={collapsed} /> : undefined}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{APP_ROUTES.map(({ id, title, icon: Icon }) => (
|
||||
<MenuItem key={id} value={id} href={routePath(id)} onClick={({ e }) => e.preventDefault()} icon={<Icon className="t-icon tn-menu-icon" size={18} strokeWidth={1.8} />}>
|
||||
{title}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuLogo({ collapsed }: { collapsed: boolean }) {
|
||||
return (
|
||||
<div className="tn-menu-logo" aria-label="TallyNote">
|
||||
<span className={collapsed ? "tn-logo-short" : "tn-logo-full"}>{collapsed ? "TN" : "TallyNote"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<string>).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 <main className="tn-auth-shell" role="status" aria-live="polite"><Loading text="正在连接本地账本…" /></main>;
|
||||
if (session.status === "error") return <main className="tn-auth-shell"><div className="tn-auth-panel"><h1 className="tn-page-title">无法连接 TallyNote</h1><p className="tn-page-subtitle">{session.error || "请确认本地服务正在运行。"}</p><Button theme="primary" onClick={() => void dispatch(bootstrapSession())}>重新连接</Button></div></main>;
|
||||
if (!session.admin) return <LoginPage
|
||||
notice={session.initialized ? undefined : "尚未初始化管理员,请先在服务器执行 pnpm admin:init。"}
|
||||
onSuccess={() => setPasswordOpen(false)}
|
||||
/>;
|
||||
if (session.admin.mustChangePassword) return <ChangePasswordPage admin={session.admin} firstLogin onSuccess={() => notify("密码已更新", "success")} />;
|
||||
|
||||
const content = passwordOpen
|
||||
? <ChangePasswordPage admin={session.admin} returnLabel={`返回${routeTitle(page)}`} onCancel={() => requestDiscard(() => setPasswordOpen(false))} onSuccess={() => { setPasswordOpen(false); notify("密码已更新", "success"); }} />
|
||||
: page === "dashboard" ? <DashboardPage timezone={session.timezone} onNavigate={onNavigate} />
|
||||
: page === "expenses" ? <ExpensesPage timezone={session.timezone} sessionKey={session.admin.id} notify={notify} />
|
||||
: page === "trash" ? <TrashPage timezone={session.timezone} notify={notify} />
|
||||
: page === "admins" ? <AdminsPage currentAdmin={session.admin} timezone={session.timezone} notify={notify} />
|
||||
: page === "audit" ? <AuditPage timezone={session.timezone} />
|
||||
: <UpdatePage timezone={session.timezone} notify={notify} />;
|
||||
|
||||
return <AppLayout activeId={page} adminName={session.admin.displayName} adminUsername={session.admin.username} onNavigate={onNavigate} onLogout={onLogout} onOpenPassword={() => { if (!passwordOpen) requestDiscard(() => setPasswordOpen(true)); }}><Suspense fallback={<main className="tn-page-loading" role="status" aria-live="polite"><Loading text="正在打开页面…" /></main>}><div key={passwordOpen ? "password" : page} className="tn-page-transition">{content}</div></Suspense></AppLayout>;
|
||||
}
|
||||
|
||||
function RouteErrorPage() {
|
||||
const error = useRouteError();
|
||||
useEffect(() => { document.title = "页面出现问题 | TallyNote"; }, []);
|
||||
const message = error instanceof Error && error.message.includes("网络")
|
||||
? "服务连接暂时不可用。"
|
||||
: "页面暂时无法打开,请重新加载后再试。";
|
||||
return <main className="tn-route-error" role="alert"><div className="tn-route-error-panel"><h1>页面出现问题</h1><p>{message}</p><Button theme="primary" onClick={() => window.location.reload()}>重新加载</Button></div></main>;
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([{ path: "*", element: <Provider store={store}><UnsavedChangesProvider><App /></UnsavedChangesProvider></Provider>, errorElement: <RouteErrorPage /> }]);
|
||||
// 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(<RouterProvider router={router} />);
|
||||
@@ -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<Admin[]>([]);
|
||||
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) => <span className="tn-code">{row.username}</span> },
|
||||
{ colKey: "displayName", title: "显示名" },
|
||||
{ colKey: "status", title: "状态", cell: ({ row }: any) => <StatusTag status={row.status} /> },
|
||||
{ colKey: "lastLoginAt", title: "最近登录", cell: ({ row }: any) => row.lastLoginAt ? dateText(row.lastLoginAt, timezone) : "从未登录" },
|
||||
{ colKey: "version", title: "版本", cell: ({ row }: any) => <span className="tn-code">v{row.version}</span> },
|
||||
{ colKey: "actions", title: "操作", width: 250, cell: ({ row }: any) => <Space className="tn-action-group"><Tooltip content={row.id === currentAdmin.id ? "当前账号请使用右上角修改密码" : "生成一次性临时密码"}><Button variant="outline" onClick={() => setAction({ kind: "reset", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={<KeyRound size={15} />}>重置密码</Button></Tooltip><Button variant="outline" theme={row.status === "active" ? "danger" : "primary"} onClick={() => setAction({ kind: "toggle", admin: row })} disabled={busy || row.id === currentAdmin.id} icon={row.status === "active" ? <UserRoundX size={15} /> : <UserRoundCheck size={15} />}>{row.status === "active" ? "停用" : "启用"}</Button></Space> },
|
||||
];
|
||||
|
||||
return <Page title="管理员" subtitle="多个等权管理员共享同一本地账目,停用会立即撤销该账号的现有会话。" actions={<Space><Button variant="outline" onClick={() => void load()} disabled={loading} icon={<RotateCcw size={15} />}>刷新</Button><Button theme="primary" onClick={() => { setForm({ username: "", displayName: "" }); setFormError(""); setFormFields({}); setShowCreate(true); }} icon={<Plus size={16} />}>新增管理员</Button></Space>}>
|
||||
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><UserRound size={30} /><p>暂无管理员</p></div> : undefined} onRetry={() => void load()}><div className="tn-table-wrap" role="region" aria-label="管理员列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} hover stripe /></div></AsyncState>
|
||||
<Drawer className="tn-content-drawer tn-admin-drawer" visible={showCreate} destroyOnClose placement="right" size="440px" header="新增管理员" onClose={() => { if (!busy) requestDiscard(() => setShowCreate(false)); }} footer={<Space><Button variant="outline" onClick={() => requestDiscard(() => setShowCreate(false))} disabled={busy}>取消</Button><Button theme="primary" type="button" onClick={() => void create()} disabled={busy} icon={busy ? <BusyIcon /> : <ShieldCheck size={15} />}>创建并生成临时密码</Button></Space>}>
|
||||
<Form id="admin-create-form" layout="vertical" onSubmit={() => { void create(); }}><Form.FormItem label="用户名" help={formFields.username || "至少 3 个字符"} status={formFields.username ? "error" : undefined} rules={[{ required: true, min: 3, max: 64, message: "用户名至少需要 3 个字符" }]}><AccessibleInput disabled={busy} inputAriaLabel="用户名" inputAriaInvalid={Boolean(formFields.username)} value={form.username} onChange={value => { setForm({ ...form, username: value }); setFormFields(current => ({ ...current, username: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={64} autocomplete="off" /></Form.FormItem><Form.FormItem label="显示名" help={formFields.displayName} status={formFields.displayName ? "error" : undefined} rules={[{ required: true, max: 80, message: "请输入显示名" }]}><AccessibleInput disabled={busy} inputAriaLabel="显示名" inputAriaInvalid={Boolean(formFields.displayName)} value={form.displayName} onChange={value => { setForm({ ...form, displayName: value }); setFormFields(current => ({ ...current, displayName: undefined })); setFormError(""); }} onEnter={(_, context) => { context.e.preventDefault(); void create(); }} maxlength={80} /></Form.FormItem>{formError && <div className="tn-inline-error" role="alert">{formError}</div>}</Form>
|
||||
</Drawer>
|
||||
<Dialog visible={Boolean(action)} header={action?.kind === "reset" ? "重置管理员密码?" : action?.admin.status === "active" ? "停用管理员?" : "启用管理员?"} confirmBtn={{ content: action?.kind === "reset" ? "重置密码" : action?.admin.status === "active" ? "停用" : "启用", theme: action?.kind === "toggle" && action.admin.status === "active" ? "danger" : "primary", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { 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" ? "停用后该管理员的现有会话会立即失效。" : "启用后该管理员可以重新登录。"}
|
||||
</Dialog>
|
||||
<Dialog visible={Boolean(secret)} header="临时密码已生成" confirmBtn="关闭" cancelBtn={null} onClose={() => setSecret("")} onConfirm={() => setSecret("")} onCancel={() => setSecret("")}><div className="tn-secret"><code>{secret}</code><Button variant="outline" icon={<Copy size={15} />} onClick={() => { void copySecret(secret); }}>复制</Button></div><p className="tn-dialog-note">请通过安全渠道交给管理员。首次登录必须修改密码。</p></Dialog>
|
||||
</Page>;
|
||||
|
||||
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<HTMLElement>(selector);
|
||||
const target = node?.matches("input") ? node : node?.querySelector<HTMLElement>("input");
|
||||
target?.focus();
|
||||
};
|
||||
requestAnimationFrame(focus);
|
||||
window.setTimeout(focus, 60);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AdminsPage } from "./AdminsPage";
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<AuditItem[]>([]);
|
||||
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) => <span className="tn-code" title={row.action}>{ACTION_LABELS[row.action] || row.action}</span> },
|
||||
{ colKey: "target", title: "目标", cell: ({ row }: any) => <span className="tn-code" title={row.targetId ? `${row.targetType} / ${row.targetId}` : row.targetType}>{TARGET_LABELS[row.targetType] || row.targetType}{row.targetId ? ` / ${row.targetId.slice(0, 8)}` : ""}</span> },
|
||||
{ colKey: "outcome", title: "结果", cell: ({ row }: any) => <Tag theme={row.outcome === "success" || !row.outcome ? "success" : row.outcome === "denied" ? "warning" : "danger"}>{outcomeLabel(row.outcome)}</Tag> },
|
||||
];
|
||||
|
||||
return <Page title="审计日志" subtitle="记录登录、账目、附件、导出、管理员和更新操作。日志只读,永久删除也不会清除它。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || loadingMore} icon={<RotateCcw size={15} />}>刷新</Button>}>
|
||||
<form className="tn-toolbar tn-audit-toolbar" onSubmit={e => { e.preventDefault(); applyFilters(); }}><AccessibleInput inputAriaLabel="动作筛选" value={actionDraft} onChange={setActionDraft} maxlength={100} placeholder="动作,例如 expense.created" prefixIcon={<Search size={16} />} /><Select aria-label="目标类型" value={targetDraft} onChange={v => setTargetDraft(String(v))} options={[{ label: "全部目标", value: "" }, { label: "账目", value: "expense" }, { label: "管理员", value: "admin" }, { label: "导出", value: "export" }, { label: "会话", value: "session" }, { label: "更新任务", value: "update" }, { label: "系统检查", value: "system" }]} /><Button theme="primary" type="submit" icon={<Search size={15} />}>筛选</Button></form>
|
||||
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><Archive size={30} /><p>{action || targetType ? "没有符合当前筛选条件的审计记录" : "暂无审计记录"}</p>{(action || targetType) && <Button variant="outline" onClick={() => setSearchParams(new URLSearchParams())}>清除筛选</Button>}</div> : undefined} onRetry={() => void load()}><div className="tn-table-wrap tn-audit-table" role="region" aria-label="审计日志列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} hover stripe /></div>{hasMore && <div className="tn-table-more"><Button variant="outline" onClick={() => void load(true)} disabled={loadingMore} icon={<RotateCcw size={15} />}>{loadingMore ? "加载中…" : "加载更早记录"}</Button></div>}</AsyncState>
|
||||
</Page>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AuditPage } from "./AuditPage";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./ChangePasswordPage";
|
||||
export * from "./ChangePasswordPage";
|
||||
@@ -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<string | null>(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 = <Form
|
||||
className="tn-password-form"
|
||||
labelAlign="top"
|
||||
labelWidth={0}
|
||||
onSubmit={(context) => { if (context.validateResult === true) void submit(); }}
|
||||
>
|
||||
<Form.FormItem label="当前密码" name="currentPassword" rules={[{ required: true, message: "请输入当前密码", type: "error" }]}>
|
||||
<StarterPasswordInput ariaLabel="当前密码" value={currentPassword} onChange={value => { setCurrentPassword(value); setFormError(null); dispatch(clearSessionError()); }} autocomplete="current-password" disabled={busy} placeholder="请输入当前密码" />
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label="新密码" name="newPassword" status={fieldErrors.newPassword ? "error" : undefined} help={fieldErrors.newPassword} rules={[{ required: true, min: 12, message: "新密码至少需要 12 位", type: "error" }]}>
|
||||
<StarterPasswordInput ariaLabel="新密码" ariaInvalid={Boolean(fieldErrors.newPassword)} ariaDescribedby={fieldErrors.newPassword ? "password-new-error" : undefined} value={newPassword} onChange={value => { setNewPassword(value); setFieldErrors(current => ({ ...current, newPassword: undefined })); setFormError(null); dispatch(clearSessionError()); }} autocomplete="new-password" disabled={busy} placeholder="请输入至少 12 位的新密码" />
|
||||
{fieldErrors.newPassword && <span id="password-new-error" className="tn-sr-only">{fieldErrors.newPassword}</span>}
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label="确认新密码" name="confirmation" status={fieldErrors.confirmation ? "error" : undefined} help={fieldErrors.confirmation} rules={[{ required: true, message: "请再次输入新密码", type: "error" }]}>
|
||||
<StarterPasswordInput ariaLabel="确认新密码" ariaInvalid={Boolean(fieldErrors.confirmation)} ariaDescribedby={fieldErrors.confirmation ? "password-confirmation-error" : undefined} value={confirmation} onChange={value => { setConfirmation(value); setFieldErrors(current => ({ ...current, confirmation: undefined })); setFormError(null); dispatch(clearSessionError()); }} autocomplete="new-password" disabled={busy} placeholder="请再次输入新密码" />
|
||||
{fieldErrors.confirmation && <span id="password-confirmation-error" className="tn-sr-only">{fieldErrors.confirmation}</span>}
|
||||
</Form.FormItem>
|
||||
<Form.FormItem className="tn-password-submit">
|
||||
<div className="tn-password-actions">
|
||||
<Button className="tn-password-submit-button" theme="primary" type="submit" loading={busy} disabled={busy}>更新密码</Button>
|
||||
</div>
|
||||
</Form.FormItem>
|
||||
</Form>;
|
||||
|
||||
const panel = <section className={`tn-password-card${isFirstLogin ? " tn-password-card-first" : ""}`} aria-labelledby="password-card-title">
|
||||
<header className="tn-password-card-head">
|
||||
<div className="tn-password-card-icon" aria-hidden="true"><KeyRound size={20} /></div>
|
||||
<div>
|
||||
<h2 id="password-card-title">{isFirstLogin ? "设置登录密码" : "修改登录密码"}</h2>
|
||||
<p>{isFirstLogin ? "首次登录需要先设置一个新的登录密码。" : `当前账号:${displayName}`}</p>
|
||||
</div>
|
||||
</header>
|
||||
{(formError || error) && <div role="alert" aria-live="assertive"><Alert className="tn-password-alert" theme="error" message={formError || error || "密码更新失败"} /></div>}
|
||||
<div className="tn-password-card-body">
|
||||
<div>{form}</div>
|
||||
<aside className="tn-password-guidance" aria-label="密码要求">
|
||||
<div className="tn-password-guidance-title"><ShieldCheck size={18} />密码要求</div>
|
||||
<p>请使用不易猜测且未在其他服务重复使用的密码。</p>
|
||||
<ul>
|
||||
{requirements.map((item) => <li key={item.label} className={item.valid ? "is-valid" : ""}><Check size={14} />{item.label}</li>)}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</section>;
|
||||
|
||||
if (!isFirstLogin) {
|
||||
return <Page title="修改密码" subtitle="更新当前管理员的登录凭据。" actions={onCancel ? <Button variant="text" type="button" onClick={onCancel} icon={<ArrowLeft size={16} />}>{returnLabel}</Button> : undefined} className="tn-password-page">{panel}</Page>;
|
||||
}
|
||||
|
||||
return <main className="tn-login-page" data-page="change-password">
|
||||
<section className="tn-login-container tn-password-container">
|
||||
<div className="tn-login-heading">
|
||||
<h1 id="password-title" className="tn-login-title">首次登录保护</h1>
|
||||
<p className="tn-login-subtitle">管理员 {displayName} 需要先设置新密码。</p>
|
||||
</div>
|
||||
{panel}
|
||||
</section>
|
||||
</main>;
|
||||
}
|
||||
|
||||
function focusPasswordField(label: string) {
|
||||
const focus = () => {
|
||||
const nodes = Array.from(document.querySelectorAll<HTMLElement>("[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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./LoginPage";
|
||||
export * from "./LoginPage";
|
||||
@@ -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<HTMLElement>("[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 (
|
||||
<main className="tn-login-page" data-page="login">
|
||||
<section className="tn-login-container" aria-labelledby="login-title">
|
||||
<div className="tn-login-heading">
|
||||
<h1 id="login-title" className="tn-login-title">登录到 <span className="tn-login-title-brand">TallyNote</span></h1>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
className="tn-login-form"
|
||||
labelWidth={0}
|
||||
onSubmit={(context) => { context.e?.preventDefault(); void submit(); }}
|
||||
>
|
||||
{notice && <div role="status" aria-live="polite"><Alert className="tn-login-alert" theme="info" message={notice} /></div>}
|
||||
{error && <div role="alert" aria-live="assertive"><Alert className="tn-login-alert" theme="error" message={error} /></div>}
|
||||
<Form.FormItem label="用户名" requiredMark={false} name="username" status={fieldErrors.username ? "error" : undefined} help={fieldErrors.username}>
|
||||
<AccessibleInput
|
||||
inputAriaLabel="用户名"
|
||||
inputAriaInvalid={Boolean(fieldErrors.username)}
|
||||
inputAriaDescribedby={fieldErrors.username ? "login-username-error" : undefined}
|
||||
size="large"
|
||||
value={username}
|
||||
onChange={(value) => updateUsername(String(value))}
|
||||
clearable
|
||||
disabled={busy}
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
prefixIcon={<LoginUserIcon />}
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
{fieldErrors.username && <span id="login-username-error" className="tn-sr-only">{fieldErrors.username}</span>}
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label="密码" requiredMark={false} name="password" status={fieldErrors.password ? "error" : undefined} help={fieldErrors.password}>
|
||||
<StarterPasswordInput
|
||||
ariaLabel="密码"
|
||||
ariaInvalid={Boolean(fieldErrors.password)}
|
||||
ariaDescribedby={fieldErrors.password ? "login-password-error" : undefined}
|
||||
value={password}
|
||||
onChange={updatePassword}
|
||||
autocomplete="current-password"
|
||||
disabled={busy}
|
||||
placeholder="请输入登录密码"
|
||||
/>
|
||||
{fieldErrors.password && <span id="login-password-error" className="tn-sr-only">{fieldErrors.password}</span>}
|
||||
</Form.FormItem>
|
||||
<div className="tn-login-options"><Checkbox checked={rememberAccount} onChange={setRememberAccount} disabled={busy}>记住账号</Checkbox></div>
|
||||
<Form.FormItem className="tn-login-submit">
|
||||
<Button block size="large" theme="primary" type="submit" loading={busy} disabled={busy}>登录</Button>
|
||||
</Form.FormItem>
|
||||
</Form>
|
||||
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<AccessibleInput
|
||||
inputAriaLabel={ariaLabel}
|
||||
inputAriaInvalid={ariaInvalid}
|
||||
inputAriaDescribedby={ariaDescribedby}
|
||||
size="large"
|
||||
value={value}
|
||||
onChange={(next) => onChange(String(next))}
|
||||
type={visible ? "text" : "password"}
|
||||
disabled={disabled}
|
||||
autocomplete={autocomplete}
|
||||
prefixIcon={<LockKeyhole className="tn-login-input-icon" size={18} />}
|
||||
// A defined (empty) suffix prevents TDesign's password icon from being
|
||||
// added in addition to the Starter-style control below.
|
||||
suffixIcon={<span className="tn-login-suffix-placeholder" aria-hidden="true" />}
|
||||
suffix={<span className="tn-login-suffix-actions">
|
||||
{value && <button type="button" className="tn-login-clear" aria-label="清空密码" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange("")}><X size={15} /></button>}
|
||||
<button type="button" className="tn-login-password-toggle" aria-label={visible ? "隐藏密码" : "显示密码"} aria-pressed={visible} onMouseDown={(event) => event.preventDefault()} onClick={toggle}>
|
||||
{visible ? <Eye size={17} /> : <EyeOff size={17} />}
|
||||
</button>
|
||||
</span>}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginUserIcon() {
|
||||
return <UserRound className="tn-login-prefix-icon" aria-hidden="true" size={18} />;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as LoginPage } from "./LoginPage";
|
||||
export { default as ChangePasswordPage } from "./ChangePasswordPage";
|
||||
export type { LoginPageProps } from "./LoginPage";
|
||||
export type { ChangePasswordPageProps } from "./ChangePasswordPage";
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Alert, Button, Card, Loading, Space, Tag } from "tdesign-react";
|
||||
|
||||
export function Page({ eyebrow, title, subtitle, actions, children, className = "" }: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <section className={`tn-page ${className}`}>
|
||||
<div className="tn-page-head">
|
||||
<div>{eyebrow && <div className="tn-eyebrow">{eyebrow}</div>}<h1 className="tn-page-title">{title}</h1>{subtitle && <p className="tn-page-subtitle">{subtitle}</p>}</div>
|
||||
{actions && <div className="tn-page-actions">{actions}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function AsyncState({ loading, error, empty, onRetry, children }: {
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
empty?: ReactNode;
|
||||
onRetry?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (loading && empty) return <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载中…" /></div>;
|
||||
if (error && empty) return <div className="tn-empty" role="alert"><AlertCircle size={28} /><p>{error}</p>{onRetry && <Button variant="outline" onClick={onRetry}>重试</Button>}</div>;
|
||||
return <>
|
||||
{loading && <div className="tn-inline-loading" role="status"><Loader2 size={15} className="tn-spin" aria-hidden="true" />正在更新…</div>}
|
||||
{error && <ErrorBanner message={error} onRetry={onRetry} />}
|
||||
{empty || children}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ErrorBanner({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
return <div role="alert"><Alert theme="error" className="tn-alert" icon={<AlertCircle size={16} />} message={<Space size="small"><span>{message}</span>{onRetry && <Button variant="text" onClick={onRetry}>重试</Button>}</Space>} /></div>;
|
||||
}
|
||||
|
||||
export function BusyIcon() { return <Loader2 size={16} className="tn-spin" aria-hidden="true" />; }
|
||||
|
||||
export function StatusTag({ status }: { status: "unreimbursed" | "reimbursed" | "active" | "disabled" }) {
|
||||
const labels = { unreimbursed: "未报销", reimbursed: "已报销", active: "有效", disabled: "已停用" } as const;
|
||||
const themes = { unreimbursed: "warning", reimbursed: "success", active: "success", disabled: "default" } as const;
|
||||
return <Tag theme={themes[status]} variant="light-outline">{labels[status]}</Tag>;
|
||||
}
|
||||
|
||||
export function Surface({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return <Card bordered className={`tn-flat-section ${className}`}>{children}</Card>;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircle, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
|
||||
import { Alert, Button, Card, DatePicker, Empty, Loading, Space, Statistic, Table, Tag, Tooltip } from "tdesign-react";
|
||||
import ReactEChartsCore from "echarts-for-react/lib/core";
|
||||
import * as echarts from "echarts/core";
|
||||
import { LineChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { api } from "../../services/api";
|
||||
import { dateText, money, monthNow } from "../expenses/date";
|
||||
import type { Expense } from "../expenses/types";
|
||||
import type { RouteId } from "../../router";
|
||||
|
||||
echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer]);
|
||||
|
||||
type Props = { timezone?: string; onNavigate?: (id: RouteId, search?: string) => void };
|
||||
type ExpenseResult = { items: Expense[]; summary: { count: number; amountCents: number } };
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.matchMedia === "function"
|
||||
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
: false;
|
||||
}
|
||||
|
||||
export default function DashboardPage({ timezone = "Asia/Shanghai", onNavigate }: Props) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const rawMonthParam = searchParams.get("month");
|
||||
const defaultMonth = monthNow(timezone);
|
||||
const month = rawMonthParam && /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonthParam) ? rawMonthParam : defaultMonth;
|
||||
const [unreimbursed, setUnreimbursed] = useState<ExpenseResult>({ items: [], summary: { count: 0, amountCents: 0 } });
|
||||
const [reimbursed, setReimbursed] = useState<ExpenseResult>({ items: [], summary: { count: 0, amountCents: 0 } });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [loadedMonth, setLoadedMonth] = useState<string | null>(null);
|
||||
const requestSequence = useRef(0);
|
||||
const [reducedMotion, setReducedMotion] = useState(prefersReducedMotion);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const sync = () => setReducedMotion(media.matches);
|
||||
sync();
|
||||
if (typeof media.addEventListener === "function") {
|
||||
media.addEventListener("change", sync);
|
||||
return () => media.removeEventListener("change", sync);
|
||||
}
|
||||
media.addListener?.(sync);
|
||||
return () => media.removeListener?.(sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawMonthParam || rawMonthParam === month) return;
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("month", month);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [month, rawMonthParam, searchParams, setSearchParams]);
|
||||
|
||||
const setDashboardMonth = (nextMonth: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("month", nextMonth);
|
||||
setSearchParams(params);
|
||||
};
|
||||
const load = async () => {
|
||||
const sequence = ++requestSequence.current;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [pending, done] = await Promise.all([
|
||||
api<ExpenseResult>(`/api/expenses?month=${month}&status=unreimbursed&query=&missingInvoice=false`),
|
||||
api<ExpenseResult>(`/api/expenses?month=${month}&status=reimbursed&query=&missingInvoice=false`),
|
||||
]);
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setUnreimbursed(pending);
|
||||
setReimbursed(done);
|
||||
setLoadedMonth(month);
|
||||
} catch (caught) {
|
||||
if (sequence === requestSequence.current) setError((caught as Error).message);
|
||||
} finally {
|
||||
if (sequence === requestSequence.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { void load(); }, [month]);
|
||||
|
||||
const allItems = useMemo(() => [...unreimbursed.items, ...reimbursed.items].sort((a, b) => b.paidAt - a.paidAt), [unreimbursed.items, reimbursed.items]);
|
||||
const totalCents = unreimbursed.summary.amountCents + reimbursed.summary.amountCents;
|
||||
const totalCount = unreimbursed.summary.count + reimbursed.summary.count;
|
||||
const missingInvoiceCount = allItems.filter((item) => item.invoiceCount === 0).length;
|
||||
const recentItems = allItems.slice(0, 8);
|
||||
const reimbursedPercent = totalCount ? Math.round((reimbursed.summary.count / totalCount) * 100) : 0;
|
||||
const hasCurrentSnapshot = loadedMonth === month;
|
||||
const expensesSearch = `?month=${encodeURIComponent(month)}`;
|
||||
const chartDateFormatter = useMemo(() => new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit" }), [timezone]);
|
||||
|
||||
const chartOption = useMemo(() => {
|
||||
const grouped = new Map<string, { label: string; unreimbursed: number; reimbursed: number }>();
|
||||
for (const item of allItems) {
|
||||
const parts = Object.fromEntries(chartDateFormatter.formatToParts(new Date(item.paidAt)).map((part) => [part.type, part.value]));
|
||||
const key = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
const label = `${parts.month}/${parts.day}`;
|
||||
const current = grouped.get(key) ?? { label, unreimbursed: 0, reimbursed: 0 };
|
||||
current[item.status] += item.amountCents / 100;
|
||||
grouped.set(key, current);
|
||||
}
|
||||
const values = [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, value]) => value);
|
||||
return {
|
||||
animation: !reducedMotion,
|
||||
animationDuration: reducedMotion ? 0 : 380,
|
||||
color: ["#175cd3", "#2f7d5c"],
|
||||
tooltip: { trigger: "axis", valueFormatter: (value: number) => `¥${value.toFixed(2)}` },
|
||||
legend: { top: 0, right: 0, textStyle: { color: "#667085" }, data: ["未报销", "已报销"] },
|
||||
grid: { left: 48, right: 20, top: 38, bottom: 30 },
|
||||
xAxis: { type: "category", data: values.map((value) => value.label), boundaryGap: false, axisLine: { lineStyle: { color: "#dce3ec" } }, axisLabel: { color: "#667085" } },
|
||||
yAxis: { type: "value", axisLabel: { color: "#667085", formatter: (value: number) => `¥${value}` }, splitLine: { lineStyle: { color: "#edf1f5" } } },
|
||||
series: [
|
||||
{ name: "未报销", type: "line", smooth: true, symbol: "circle", symbolSize: 6, data: values.map((value) => value.unreimbursed), lineStyle: { width: 2 }, areaStyle: { color: "rgba(23,92,211,.08)" } },
|
||||
{ name: "已报销", type: "line", smooth: true, symbol: "circle", symbolSize: 6, data: values.map((value) => value.reimbursed), lineStyle: { width: 2 }, areaStyle: { color: "rgba(47,125,92,.06)" } },
|
||||
],
|
||||
};
|
||||
}, [allItems, chartDateFormatter, reducedMotion]);
|
||||
|
||||
const shiftMonth = (delta: number) => {
|
||||
const [rawYear, rawMonth] = month.split("-").map(Number);
|
||||
const year = rawYear ?? new Date().getFullYear();
|
||||
const monthNumber = rawMonth ?? 1;
|
||||
const next = new Date(year, monthNumber - 1 + delta, 1);
|
||||
setDashboardMonth(`${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ colKey: "paidAt", title: "支付时间", cell: ({ row }: any) => dateText(row.paidAt, timezone) },
|
||||
{ colKey: "amountCents", title: "金额", cell: ({ row }: any) => <strong className="tn-amount">{money(row.amountCents)}</strong> },
|
||||
{ colKey: "note", title: "备注", cell: ({ row }: any) => row.note || <span className="tn-muted">无备注</span>, ellipsis: true },
|
||||
{ colKey: "status", title: "状态", cell: ({ row }: any) => <Tag theme={row.status === "reimbursed" ? "success" : "warning"}>{row.status === "reimbursed" ? "已报销" : "未报销"}</Tag> },
|
||||
];
|
||||
|
||||
return <section className="tn-page tn-dashboard-page">
|
||||
<div className="tn-page-head">
|
||||
<div><h1 className="tn-page-title">仪表盘</h1><p className="tn-page-subtitle">快速查看本月采购报销进度和待处理账目。</p></div>
|
||||
<div className="tn-page-actions"><div className="tn-dashboard-actions"><div className="tn-dashboard-month-control"><Tooltip content="上个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(-1)} aria-label="上个月" icon={<ChevronLeft size={16} />} /></Tooltip><DatePicker className="tn-dashboard-month" mode="month" format="YYYY-MM" value={month} onChange={(value: any) => { const next = String(value || "").slice(0, 7); if (/^\d{4}-\d{2}$/.test(next)) setDashboardMonth(next); }} inputProps={{ "aria-label": "仪表盘月份" } as any} /><Tooltip content="下个月"><Button variant="outline" shape="square" onClick={() => shiftMonth(1)} aria-label="下个月" icon={<ChevronRight size={16} />} /></Tooltip></div><div className="tn-dashboard-secondary-actions"><Button className="tn-dashboard-refresh" variant="outline" onClick={() => void load()} disabled={loading} icon={<RefreshCw size={15} />}>刷新</Button><Button className="tn-dashboard-view" theme="primary" onClick={() => onNavigate?.("expenses", expensesSearch)}>查看账目</Button></div></div></div>
|
||||
</div>
|
||||
{error && hasCurrentSnapshot && <Alert theme="error" icon={<AlertCircle size={16} />} message={`刷新失败:${error}。当前展示的是本月最近一次成功加载的数据。`} />}
|
||||
{loading ? <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载仪表盘…" /></div> : !hasCurrentSnapshot ? <div className="tn-empty" role="alert"><Alert theme="error" icon={<AlertCircle size={16} />} message={error || "暂时无法读取仪表盘数据"} /><Button variant="outline" onClick={() => void load()} icon={<RefreshCw size={15} />}>重新加载</Button></div> : <>
|
||||
<div className="tn-dashboard-stats">
|
||||
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-total"><Statistic title="本月总额" value={totalCents / 100} prefix="¥" decimalPlaces={2} /></Card>
|
||||
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-count"><Statistic title="账目笔数" value={totalCount} suffix="笔" /></Card>
|
||||
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-pending"><Statistic title="未报销" value={unreimbursed.summary.amountCents / 100} prefix="¥" decimalPlaces={2} /></Card>
|
||||
<Card bordered className="tn-dashboard-stat tn-dashboard-stat-missing"><Statistic title="缺发票" value={missingInvoiceCount} suffix="笔" /></Card>
|
||||
</div>
|
||||
<div className="tn-dashboard-grid">
|
||||
<Card bordered className="tn-dashboard-chart" title={<DashboardCardTitle title="支付趋势" subtitle="按支付日期汇总本月全部账目" />}>
|
||||
{allItems.length ? <div role="img" aria-label={`${month} 支付趋势:共 ${totalCount} 笔,未报销 ${unreimbursed.summary.count} 笔,已报销 ${reimbursed.summary.count} 笔。`}><ReactEChartsCore echarts={echarts} option={chartOption} style={{ height: 286, width: "100%" }} notMerge lazyUpdate /></div> : <div className="tn-dashboard-chart-empty"><span>本月暂无支付数据</span><small>新增账目后,这里会显示每日支付趋势。</small></div>}
|
||||
</Card>
|
||||
<Card bordered className="tn-dashboard-progress" title={<DashboardCardTitle title="报销进度" subtitle="本月账目状态分布" />}><div className="tn-progress-ring" style={{ background: `conic-gradient(var(--tn-blue-700) ${reimbursedPercent}%, #e7edf3 0)` }} role="img" aria-label={`已报销 ${reimbursedPercent}%`}><div><strong>{reimbursedPercent}%</strong><span>已报销</span></div></div><div className="tn-dashboard-progress-legend"><span><i className="is-blue" />未报销 <strong>{unreimbursed.summary.count} 笔</strong></span><span><i className="is-green" />已报销 <strong>{reimbursed.summary.count} 笔</strong></span></div></Card>
|
||||
</div>
|
||||
<Card bordered className="tn-dashboard-recent" title={<DashboardCardTitle title="最近账目" subtitle="按支付时间倒序排列" />} actions={<Button variant="text" onClick={() => onNavigate?.("expenses", expensesSearch)}>查看全部</Button>}>
|
||||
{recentItems.length ? <div className="tn-table-wrap tn-dashboard-table" role="region" aria-label="最近账目列表,可横向滚动查看更多列"><Table rowKey="id" data={recentItems} columns={columns as any} hover stripe /> </div> : <Empty description="本月暂无账目" />}
|
||||
</Card>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function DashboardCardTitle({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
return <div className="tn-dashboard-card-title"><span>{title}</span><small>{subtitle}</small></div>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as DashboardPage } from "./DashboardPage";
|
||||
export { default } from "./DashboardPage";
|
||||
@@ -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<string, string> = {
|
||||
"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<TimelineEvent[]>([]); 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<Attachment | null>(null); const [removeReason, setRemoveReason] = useState(""); const [preview, setPreview] = useState<Attachment | null>(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 <>
|
||||
<Drawer className="tn-content-drawer tn-detail-drawer" placement="right" size="520px" visible destroyOnClose header="账目详情" onClose={onClose} footer={<Space><Button onClick={() => setAction("status")} disabled={busy}>{detail.status === "reimbursed" ? "标记未报销" : "标记已报销"}</Button><Button theme="danger" onClick={() => setAction("trash")} disabled={busy}><Trash2 size={15} />移入回收站</Button></Space>}>
|
||||
{loading ? <div role="status" aria-live="polite"><Loading text="加载详情…" /></div> : error ? <div role="alert" className="expense-error"><AlertCircle size={16} />{error}<Button variant="text" onClick={load}>重试</Button></div> : <div className="expense-detail">
|
||||
<div className="expense-detail-head"><strong>{money(detail.amountCents)}</strong><Button variant="outline" onClick={() => onRequestEdit(detail)}><Settings size={15} />编辑</Button></div>
|
||||
<dl><div><dt>支付时间</dt><dd>{dateText(detail.paidAt, timezone)}</dd></div><div><dt>发票</dt><dd>{detail.invoiceCount > 0 ? `${detail.invoiceCount} 张` : detail.invoiceMissingReason ? <><Tag theme="warning">无发票</Tag>:{detail.invoiceMissingReason}</> : <Tag theme="danger">未说明</Tag>}</dd></div><div><dt>状态</dt><dd><Tag theme={detail.status === "reimbursed" ? "success" : "warning"}>{detail.status === "reimbursed" ? "已报销" : "未报销"}</Tag></dd></div><div><dt>备注</dt><dd>{detail.note || "无"}</dd></div></dl>
|
||||
<h3>附件 <small>{detail.attachments?.length || 0}</small></h3><div className="expense-attachments">{(detail.attachments || []).map(a => { const protectsLastProof = a.kind === "payment_proof" && detail.paymentProofCount <= 1; return <div className="expense-attachment" key={a.id}><span title={a.originalName}>{a.mimeType.startsWith("image/") ? <ImageIcon size={16} /> : <FileText size={16} />} {a.originalName}<small>{formatBytes(a.sizeBytes)}</small></span><Space>{a.previewable && <Tooltip content="预览附件" placement="left"><Button variant="text" shape="circle" onClick={() => setPreview(a)} aria-label={`预览 ${a.originalName}`}><Search size={15} /></Button></Tooltip>}<Tooltip content="下载附件" placement="left"><Button variant="text" shape="circle" onClick={() => { window.location.href = `/api/attachments/${a.id}/content?download=1`; }} aria-label={`下载 ${a.originalName}`}><ArrowDownToLine size={15} /></Button></Tooltip><Tooltip content={protectsLastProof ? "至少保留一张付款凭证" : "删除附件"} placement="left"><Button variant="text" shape="circle" theme="danger" disabled={protectsLastProof} onClick={() => { setRemoveReason(""); setRemoveError(""); setRemoveTarget(a); }} aria-label={protectsLastProof ? `不可删除最后一张付款凭证 ${a.originalName}` : `删除 ${a.originalName}`}><Trash2 size={14} /></Button></Tooltip></Space></div>; })}</div>
|
||||
{timeline.length > 0 && <><h3>操作记录</h3><div className="expense-timeline">{timeline.slice(0, 12).map(t => <div key={t.id}><span>{dateText(t.occurredAt, timezone)}</span><strong title={t.action}>{TIMELINE_LABELS[t.action] || t.action}</strong><small>{t.actorUsername || "系统"}</small></div>)}</div></>}
|
||||
</div>}
|
||||
</Drawer>
|
||||
<Dialog visible={action === "status"} header={detail.status === "reimbursed" ? "改回未报销?" : "标记为已报销?"} confirmBtn={{ content: "确认变更", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void updateStatus()} onCancel={() => { if (!busy) setAction(null); }}>{detail.status === "reimbursed" ? "这笔账目会重新出现在未报销列表。" : "确认这笔账目已完成报销,并从未报销列表移出?"}</Dialog>
|
||||
<Dialog visible={action === "trash"} header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setAction(null); }} onConfirm={() => void trash()} onCancel={() => { if (!busy) setAction(null); }}>账目会从普通列表和导出结果中隐藏,附件会保留,可在回收站恢复。</Dialog>
|
||||
<Dialog visible={Boolean(removeTarget)} header="删除附件?" confirmBtn={{ content: "删除附件", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) setRemoveTarget(null); }} onConfirm={() => void remove()} onCancel={() => { if (!busy) setRemoveTarget(null); }}>{removeTarget && <><p>{removeTarget.kind === "payment_proof" ? "账目至少需要保留一张付款凭证。" : detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() ? "这是最后一张发票。删除后必须填写无发票原因。" : "将删除这张发票。"}</p>{removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim() && <Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(removeError)} aria-describedby={removeError ? "remove-attachment-error" : undefined} value={removeReason} onChange={value => { setRemoveReason(value); setRemoveError(""); }} placeholder="例如:商家无法开具发票" maxlength={500} />}{removeError && <div id="remove-attachment-error" className="expense-error" role="alert"><AlertCircle size={16} />{removeError}</div>}</>}</Dialog>
|
||||
<Dialog visible={Boolean(preview)} header={preview?.originalName} onClose={() => setPreview(null)} cancelBtn="关闭" footer={null}>{preview && <div className="tn-preview">{preview.mimeType.startsWith("image/") ? <img src={`/api/attachments/${preview.id}/content`} alt={preview.originalName} /> : <iframe src={`/api/attachments/${preview.id}/content`} title={preview.originalName} />}</div>}</Dialog>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircle, Check, FileUp, Loader2, X } from "lucide-react";
|
||||
import { Button, Checkbox, DatePicker, Dialog, Drawer, Textarea, Tooltip, Upload } from "tdesign-react";
|
||||
import AccessibleInput from "../../components/AccessibleInput";
|
||||
import { api, ApiError } from "../../services/api";
|
||||
import { apiFieldErrors } from "../../services/formErrors";
|
||||
import { dateFromInput, dateInputValue, formatBytes } from "./date";
|
||||
import type { Expense, Notify } from "./types";
|
||||
import { useUnsavedActions, useUnsavedChanges } from "../../contexts/UnsavedChanges";
|
||||
import { useDrawerFocus } from "../../hooks/useDrawerFocus";
|
||||
|
||||
type Props = { expense?: Expense | null; timezone?: string; onClose: () => void; onSaved: () => void; notify?: Notify };
|
||||
type UploadFile = File & { raw?: File };
|
||||
type ExpenseField = "paidAt" | "amount" | "note" | "paymentProofs" | "invoices" | "invoiceMissingReason";
|
||||
|
||||
export default function ExpenseDrawer({ expense, timezone = "Asia/Shanghai", onClose, onSaved, notify }: Props) {
|
||||
const initial = useMemo(() => ({ amount: expense ? (expense.amountCents / 100).toFixed(2) : "", note: expense?.note || "", paidAt: expense ? dateInputValue(new Date(expense.paidAt), timezone) : dateInputValue(new Date(), timezone), reason: expense?.invoiceMissingReason || "" }), [expense, timezone]);
|
||||
const [amount, setAmount] = useState(initial.amount); const [note, setNote] = useState(initial.note); const [paidAt, setPaidAt] = useState(initial.paidAt);
|
||||
const [proofs, setProofs] = useState<File[]>([]); const [invoices, setInvoices] = useState<File[]>([]); const [missing, setMissing] = useState(Boolean(initial.reason)); const [reason, setReason] = useState(initial.reason);
|
||||
const [serverInvoiceCount, setServerInvoiceCount] = useState(expense?.invoiceCount || 0); const [version, setVersion] = useState(expense?.version || 1); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [conflict, setConflict] = useState<Expense | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<ExpenseField, string>>>({});
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||
const submitInFlight = useRef(false);
|
||||
useDrawerFocus(true, ".tn-expense-drawer");
|
||||
useEffect(() => { setAmount(initial.amount); setNote(initial.note); setPaidAt(initial.paidAt); setReason(initial.reason); setMissing(Boolean(initial.reason)); setServerInvoiceCount(expense?.invoiceCount || 0); setVersion(expense?.version || 1); setProofs([]); setInvoices([]); }, [initial, expense]);
|
||||
// The drawer is mounted through a portal; explicitly hydrate from the record
|
||||
// identity as well so a portal reuse cannot leave stale blank controls.
|
||||
useEffect(() => {
|
||||
if (!expense) return;
|
||||
setAmount((expense.amountCents / 100).toFixed(2));
|
||||
setNote(expense.note || "");
|
||||
setPaidAt(dateInputValue(new Date(expense.paidAt), timezone));
|
||||
setReason(expense.invoiceMissingReason || "");
|
||||
setMissing(Boolean(expense.invoiceMissingReason));
|
||||
setServerInvoiceCount(expense.invoiceCount || 0);
|
||||
setVersion(expense.version || 1);
|
||||
}, [expense?.id, expense?.amountCents, expense?.paidAt, expense?.note, expense?.invoiceMissingReason, expense?.invoiceCount, expense?.version, timezone]);
|
||||
useEffect(() => {
|
||||
// TDesign keeps its native file input hidden and does not forward ARIA
|
||||
// props to it. Label both inputs explicitly for assistive technology;
|
||||
// the visible trigger buttons remain the primary keyboard targets.
|
||||
const inputs: Array<[string, string, string, string]> = [
|
||||
[".tn-payment-proof-upload input[type=file]", "expense-payment-proofs-input", "expense-payment-proofs-label", "付款凭证文件"],
|
||||
[".tn-invoice-upload input[type=file]", "expense-invoices-input", "expense-invoices-label", "发票文件"],
|
||||
];
|
||||
const applyLabels = () => inputs.forEach(([selector, id, labelledBy, label]) => {
|
||||
const input = document.querySelector<HTMLInputElement>(selector);
|
||||
if (!input) return;
|
||||
input.id = id;
|
||||
input.setAttribute("aria-labelledby", labelledBy);
|
||||
input.setAttribute("aria-label", label);
|
||||
});
|
||||
// Drawer content is portalled after the first effect pass. Retry on the
|
||||
// next frame and once after the opening transition so the native inputs
|
||||
// receive their labels in both mounted and animated states.
|
||||
applyLabels();
|
||||
const frame = window.requestAnimationFrame(applyLabels);
|
||||
const timer = window.setTimeout(applyLabels, 260);
|
||||
return () => { window.cancelAnimationFrame(frame); window.clearTimeout(timer); };
|
||||
}, []);
|
||||
const hasInvoice = serverInvoiceCount + invoices.length > 0;
|
||||
const dirty = proofs.length > 0 || invoices.length > 0 || amount !== initial.amount || note !== initial.note || paidAt !== initial.paidAt || reason !== initial.reason || missing !== Boolean(initial.reason);
|
||||
const { requestDiscard } = useUnsavedActions();
|
||||
useUnsavedChanges(dirty);
|
||||
const clearField = (...fields: ExpenseField[]) => {
|
||||
setFieldErrors(current => {
|
||||
const next = { ...current };
|
||||
fields.forEach(field => delete next[field]);
|
||||
return next;
|
||||
});
|
||||
setError("");
|
||||
};
|
||||
const choose = (kind: "proof" | "invoice") => (files: any[]) => {
|
||||
clearField(kind === "proof" ? "paymentProofs" : "invoices", "invoiceMissingReason");
|
||||
const next = files.map((f: any) => (f.raw || f.originFileObj || f) as UploadFile).filter((f: any) => f instanceof File || f?.name);
|
||||
if (kind === "proof") setProofs(next.slice(0, Math.max(0, 20 - invoices.length))); else { setInvoices(next.slice(0, Math.max(0, 20 - proofs.length))); if (next.length) { setMissing(false); setReason(""); } }
|
||||
};
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (busy || submitInFlight.current) return;
|
||||
setError("");
|
||||
const nextErrors: Partial<Record<ExpenseField, string>> = {};
|
||||
if (!expense && !proofs.length) nextErrors.paymentProofs = "至少选择一张付款凭证";
|
||||
if (!/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(amount) || Number(amount) <= 0) nextErrors.amount = "金额必须为大于零且最多两位小数";
|
||||
let iso = "";
|
||||
try { iso = dateFromInput(paidAt, timezone); } catch (caught) { nextErrors.paidAt = (caught as Error).message; }
|
||||
const normalized = missing ? reason.trim() : "";
|
||||
if (!hasInvoice && !normalized) nextErrors.invoiceMissingReason = "请上传发票,或勾选“无发票”并填写原因";
|
||||
if (Object.keys(nextErrors).length) { setFieldErrors(nextErrors); setError("请检查标记的字段后再保存"); focusExpenseField(nextErrors); return; }
|
||||
setFieldErrors({});
|
||||
submitInFlight.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (expense) {
|
||||
// A multipart edit carries the fields and all new attachments in one
|
||||
// optimistic-lock transaction. This prevents partial edits when one
|
||||
// upload or a later validation fails.
|
||||
const body = new FormData();
|
||||
body.append("paidAt", iso);
|
||||
body.append("amount", amount);
|
||||
body.append("note", note);
|
||||
body.append("invoiceMissingReason", hasInvoice ? "" : normalized);
|
||||
body.append("version", String(version));
|
||||
proofs.forEach(file => body.append("paymentProofs", file));
|
||||
invoices.forEach(file => body.append("invoices", file));
|
||||
const updated = await api<{ expense: Expense }>(`/api/expenses/${expense.id}`, { method: "PATCH", body });
|
||||
setVersion(updated.expense.version);
|
||||
setServerInvoiceCount(updated.expense.invoiceCount);
|
||||
} else { const body = new FormData(); body.append("paidAt", iso); body.append("amount", amount); body.append("note", note); body.append("invoiceMissingReason", normalized); proofs.forEach(f => body.append("paymentProofs", f)); invoices.forEach(f => body.append("invoices", f)); await api("/api/expenses", { method: "POST", body }); }
|
||||
notify?.(expense ? "账目已更新" : "账目已保存", "success"); onSaved(); onClose();
|
||||
} catch (caught) {
|
||||
if (caught instanceof ApiError && caught.status === 409 && (caught as any).details?.current) setConflict((caught as any).details.current);
|
||||
else {
|
||||
const mapped = apiFieldErrors(caught) as Partial<Record<ExpenseField, string>>;
|
||||
if (Object.keys(mapped).length) { setFieldErrors(mapped); setError("请检查标记的字段后再保存"); focusExpenseField(mapped); }
|
||||
else setError((caught as Error).message);
|
||||
}
|
||||
} finally { submitInFlight.current = false; setBusy(false); }
|
||||
};
|
||||
const closeDrawer = (context?: { trigger?: string }) => {
|
||||
// ESC first belongs to the nested date/time popup. TDesign bubbles the
|
||||
// same key to Drawer, so consume that close event while the popup is open.
|
||||
if (context?.trigger === "esc" && datePickerOpen) {
|
||||
setDatePickerOpen(false);
|
||||
return;
|
||||
}
|
||||
requestDiscard(onClose);
|
||||
};
|
||||
return <>
|
||||
<Drawer className="tn-content-drawer tn-expense-drawer" placement="right" size="480px" visible destroyOnClose header={expense ? "编辑账目" : "新增账目"} onClose={closeDrawer} footer={<><Button variant="outline" onClick={() => requestDiscard(onClose)} disabled={busy}>取消</Button><Button theme="primary" onClick={() => { void submit({ preventDefault() {} } as React.FormEvent); }} disabled={busy}>{busy ? <Loader2 size={16} className="spin" /> : <><Check size={16} />保存</>}</Button></>}>
|
||||
<form className="expense-form" onSubmit={submit}>
|
||||
<label className="tn-form-field"><span>支付时间</span><DatePicker disabled={busy} inputProps={{ "aria-label": "支付时间", "aria-invalid": Boolean(fieldErrors.paidAt), "aria-describedby": fieldErrors.paidAt ? "expense-paid-at-error" : undefined } as any} popupProps={{ onVisibleChange: (visible: boolean) => setDatePickerOpen(visible) }} enableTimePicker format="YYYY-MM-DD HH:mm" value={paidAt} onChange={(v: any) => { setPaidAt(String(v)); clearField("paidAt"); }} /><small>选择日期和时间(精确到分钟,按应用时区保存)</small><FieldError id="expense-paid-at-error" message={fieldErrors.paidAt} /></label>
|
||||
<label className="tn-form-field"><span>金额(元)</span><AccessibleInput disabled={busy} inputAriaLabel="金额(元)" inputAriaInvalid={Boolean(fieldErrors.amount)} inputAriaDescribedby={fieldErrors.amount ? "expense-amount-error" : undefined} value={amount} onChange={value => { setAmount(value); clearField("amount"); }} placeholder="0.00" /><FieldError id="expense-amount-error" message={fieldErrors.amount} /></label>
|
||||
<label className="tn-form-field"><span>备注</span><Textarea disabled={busy} aria-label="备注" aria-invalid={Boolean(fieldErrors.note)} aria-describedby={fieldErrors.note ? "expense-note-error" : undefined} value={note} onChange={value => { setNote(value); clearField("note"); }} maxlength={2000} placeholder="可选" /><FieldError id="expense-note-error" message={fieldErrors.note} /></label>
|
||||
<div className="tn-form-field" role="group" aria-labelledby="expense-payment-proofs-label"><span id="expense-payment-proofs-label">付款凭证(至少 1 张)</span><Upload disabled={busy} className="tn-payment-proof-upload" aria-label="选择付款凭证文件" aria-labelledby="expense-payment-proofs-label" multiple autoUpload={false} accept="image/jpeg,image/png,image/webp" onChange={choose("proof") as any} trigger={<Button type="button" variant="outline" disabled={busy} aria-invalid={Boolean(fieldErrors.paymentProofs)} aria-describedby={fieldErrors.paymentProofs ? "expense-proof-error" : undefined} icon={<FileUp size={15} />}>选择付款凭证</Button>} /><FieldError id="expense-proof-error" message={fieldErrors.paymentProofs} /></div>
|
||||
{proofs.length > 0 && <FileList files={proofs} disabled={busy} remove={f => setProofs(proofs.filter(x => x !== f))} />}
|
||||
<div className="tn-form-field" role="group" aria-labelledby="expense-invoices-label"><span id="expense-invoices-label">发票(可选)</span><Upload disabled={busy} className="tn-invoice-upload" aria-label="选择发票文件" aria-labelledby="expense-invoices-label" multiple autoUpload={false} accept=".pdf,.ofd,.xml,application/pdf,application/ofd,application/xml" onChange={choose("invoice") as any} trigger={<Button type="button" variant="outline" disabled={busy} aria-invalid={Boolean(fieldErrors.invoices)} aria-describedby={fieldErrors.invoices ? "expense-invoice-error" : undefined} icon={<FileUp size={15} />}>选择发票</Button>} /><FieldError id="expense-invoice-error" message={fieldErrors.invoices} /></div>
|
||||
{invoices.length > 0 && <FileList files={invoices} disabled={busy} remove={f => setInvoices(invoices.filter(x => x !== f))} />}
|
||||
<Checkbox className="invoice-missing-toggle" aria-invalid={Boolean(fieldErrors.invoiceMissingReason)} aria-describedby={fieldErrors.invoiceMissingReason ? "expense-reason-error" : undefined} checked={missing} disabled={busy || hasInvoice} onChange={(checked: boolean) => { setMissing(checked); clearField("invoiceMissingReason"); if (!checked) setReason(""); }}>无发票</Checkbox>
|
||||
{!hasInvoice && !missing && (fieldErrors.invoiceMissingReason ? <small id="expense-reason-error" className="tn-field-error" role="alert">{fieldErrors.invoiceMissingReason}</small> : <small className="tn-form-help">请上传发票,或勾选“无发票”并填写原因。</small>)}
|
||||
{hasInvoice && <small>已上传发票,无需填写无发票原因。</small>}
|
||||
{missing && !hasInvoice && <label className="tn-form-field"><span>无发票原因</span><Textarea disabled={busy} aria-label="无发票原因" aria-invalid={Boolean(fieldErrors.invoiceMissingReason)} aria-describedby={fieldErrors.invoiceMissingReason ? "expense-reason-error" : undefined} value={reason} onChange={value => { setReason(value); clearField("invoiceMissingReason"); }} maxlength={500} placeholder="例如:商家无法开具发票" /><FieldError id="expense-reason-error" message={fieldErrors.invoiceMissingReason} /></label>}
|
||||
{error && <div role="alert" className="expense-error"><AlertCircle size={16} />{error}</div>}
|
||||
</form>
|
||||
</Drawer>
|
||||
<Dialog visible={Boolean(conflict)} header="记录已被更新" confirmBtn="保留当前内容" cancelBtn="加载服务器版本" onClose={() => { setConflict(null); setError("冲突提示已关闭,请再次保存以重新确认最新版本。"); }} onConfirm={() => { if (conflict) { setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); setError("当前内容已保留,请再次保存以覆盖服务器版本。"); } }} onCancel={() => { if (conflict) { setAmount((conflict.amountCents / 100).toFixed(2)); setNote(conflict.note); setPaidAt(dateInputValue(new Date(conflict.paidAt), timezone)); setVersion(conflict.version); setServerInvoiceCount(conflict.invoiceCount); setConflict(null); } }}>另一位管理员刚刚修改了这笔账目。请选择如何处理,系统不会静默覆盖。</Dialog>
|
||||
</>;
|
||||
}
|
||||
function focusExpenseField(errors: Partial<Record<ExpenseField, string>>) {
|
||||
const selectors: Partial<Record<ExpenseField, string>> = {
|
||||
paidAt: "[aria-describedby='expense-paid-at-error'], [aria-describedby='expense-paid-at-error'] input",
|
||||
amount: "[aria-describedby='expense-amount-error'] input, [aria-describedby='expense-amount-error']",
|
||||
note: "[aria-describedby='expense-note-error'] textarea, [aria-describedby='expense-note-error']",
|
||||
paymentProofs: "[aria-describedby='expense-proof-error']",
|
||||
invoices: "[aria-describedby='expense-invoice-error']",
|
||||
invoiceMissingReason: "[aria-describedby='expense-reason-error'] textarea, [aria-describedby='expense-reason-error'], .invoice-missing-toggle",
|
||||
};
|
||||
const field = (["paidAt", "amount", "note", "paymentProofs", "invoices", "invoiceMissingReason"] as ExpenseField[]).find(key => errors[key]);
|
||||
if (!field) return;
|
||||
requestAnimationFrame(() => {
|
||||
const node = document.querySelector<HTMLElement>(selectors[field] || "");
|
||||
const target = node && /^(INPUT|TEXTAREA|BUTTON|SELECT)$/.test(node.tagName)
|
||||
? node
|
||||
: node?.querySelector<HTMLElement>("input, textarea, button, select");
|
||||
target?.focus();
|
||||
});
|
||||
}
|
||||
function FieldError({ id, message }: { id: string; message?: string }) { return message ? <small id={id} className="tn-field-error" role="alert">{message}</small> : null; }
|
||||
function FileList({ files, remove, disabled = false }: { files: File[]; remove: (f: File) => void; disabled?: boolean }) { return <ul className="expense-file-list">{files.map(f => <li key={`${f.name}-${f.size}-${f.lastModified}`}><FileUp size={14} />{f.name}<small>{formatBytes(f.size)}</small><Tooltip content="移除文件" placement="left"><Button variant="text" shape="circle" disabled={disabled} onClick={() => remove(f)} aria-label={`移除文件 ${f.name}`}><X size={14} /></Button></Tooltip></li>)}</ul>; }
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertCircle, ChevronLeft, ChevronRight, ClipboardList, FileDown, FileText, Pencil, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import { Button, Checkbox, DatePicker, Dialog, Loading, Radio, Space, Table, Tag, Tooltip } from "tdesign-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { api } from "../../services/api";
|
||||
import ExpenseDrawer from "./ExpenseDrawer";
|
||||
import ExpenseDetail from "./ExpenseDetail";
|
||||
import AccessibleInput from "../../components/AccessibleInput";
|
||||
import { dateText, money, monthNow } from "./date";
|
||||
import type { Expense, Notify } from "./types";
|
||||
|
||||
type Props = { timezone?: string; notify?: Notify; sessionKey?: string };
|
||||
const EXPORT_JOB_STORAGE_KEY = "tallynote.exportJobId";
|
||||
|
||||
function savedExportJob(storageKey: string): string | null {
|
||||
try { return window.sessionStorage.getItem(storageKey); } catch { return null; }
|
||||
}
|
||||
|
||||
export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai", notify, sessionKey = "current" }: Props) {
|
||||
const timezone = timezoneProp;
|
||||
const exportStorageKey = `${EXPORT_JOB_STORAGE_KEY}.${sessionKey}`;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const rawMonthParam = searchParams.get("month");
|
||||
const rawMonth = rawMonthParam || monthNow(timezone);
|
||||
const month = /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonth) ? rawMonth : monthNow(timezone);
|
||||
const rawStatus = searchParams.get("status");
|
||||
const status: "unreimbursed" | "reimbursed" = rawStatus === "reimbursed" ? "reimbursed" : "unreimbursed";
|
||||
const rawQuery = searchParams.get("query") || "";
|
||||
const query = rawQuery.slice(0, 200);
|
||||
const rawMissingInvoice = searchParams.get("missingInvoice");
|
||||
const missingInvoice = searchParams.get("missingInvoice") === "true";
|
||||
const [queryDraft, setQueryDraft] = useState(query);
|
||||
const [items, setItems] = useState<Expense[]>([]); const [summary, setSummary] = useState({ count: 0, amountCents: 0 }); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [loadedFilterKey, setLoadedFilterKey] = useState<string | null>(null); const [selectedKeys, setSelectedKeys] = useState<string[]>([]); const [drawer, setDrawer] = useState<"new" | "detail" | "edit" | null>(null); const [selected, setSelected] = useState<Expense | null>(null); const [includeManifest, setIncludeManifest] = useState(false); const [exporting, setExporting] = useState<string | null>(() => savedExportJob(exportStorageKey)); const [exportIssue, setExportIssue] = useState(""); const [trashTarget, setTrashTarget] = useState<Expense | null>(null); const [trashing, setTrashing] = useState(false); const sequence = useRef(0); const exportStarting = useRef(false);
|
||||
const filterKey = `${month}|${status}|${query}|${missingInvoice ? "1" : "0"}`;
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
let changed = false;
|
||||
if (rawMonthParam && rawMonthParam !== month) { params.set("month", month); changed = true; }
|
||||
if (rawStatus && rawStatus !== status) { params.set("status", status); changed = true; }
|
||||
if (rawQuery !== query) {
|
||||
if (query) params.set("query", query); else params.delete("query");
|
||||
changed = true;
|
||||
}
|
||||
if (rawMissingInvoice && rawMissingInvoice !== "true") { params.delete("missingInvoice"); changed = true; }
|
||||
if (changed) setSearchParams(params, { replace: true });
|
||||
}, [month, query, rawMissingInvoice, rawMonthParam, rawQuery, rawStatus, searchParams, setSearchParams, status]);
|
||||
useEffect(() => { setQueryDraft(query); }, [query]);
|
||||
const updateFilters = (next: { month?: string; status?: "unreimbursed" | "reimbursed"; query?: string; missingInvoice?: boolean }) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("month", next.month ?? month);
|
||||
params.set("status", next.status ?? status);
|
||||
const nextQuery = (next.query ?? query).trim().slice(0, 200);
|
||||
if (nextQuery) params.set("query", nextQuery); else params.delete("query");
|
||||
if (next.missingInvoice ?? missingInvoice) params.set("missingInvoice", "true"); else params.delete("missingInvoice");
|
||||
setSearchParams(params);
|
||||
};
|
||||
const load = async () => { const s = ++sequence.current; const requestedKey = filterKey; setLoading(true); setError(""); try { const result = await api<{ items: Expense[]; summary: typeof summary }>(`/api/expenses?month=${month}&status=${status}&query=${encodeURIComponent(query)}&missingInvoice=${missingInvoice}`); if (s === sequence.current) { setItems(result.items); setSummary(result.summary); setLoadedFilterKey(requestedKey); setSelectedKeys(keys => keys.filter(k => result.items.some(x => x.id === k))); } } catch (e) { if (s === sequence.current) { setItems([]); setSummary({ count: 0, amountCents: 0 }); setLoadedFilterKey(null); setError((e as Error).message); } } finally { if (s === sequence.current) setLoading(false); } };
|
||||
useEffect(() => { setSelectedKeys([]); setItems([]); setSummary({ count: 0, amountCents: 0 }); setLoadedFilterKey(null); void load(); }, [filterKey]);
|
||||
const selectedTotal = useMemo(() => items.filter(i => selectedKeys.includes(i.id)).reduce((sum, i) => sum + i.amountCents, 0), [items, selectedKeys]);
|
||||
const shiftMonth = (delta: number) => { const [rawYear, rawMonthNumber] = month.split("-").map(Number); const y = rawYear || new Date().getFullYear(); const m = rawMonthNumber || 1; const d = new Date(y, m - 1 + delta, 1); updateFilters({ month: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` }); };
|
||||
const rememberExportJob = (jobId: string | null) => {
|
||||
setExporting(jobId);
|
||||
try {
|
||||
if (jobId) window.sessionStorage.setItem(exportStorageKey, jobId);
|
||||
else window.sessionStorage.removeItem(exportStorageKey);
|
||||
} catch { /* Session storage can be unavailable in hardened browsers. */ }
|
||||
};
|
||||
const exportAll = async () => { if (exporting || exportStarting.current) return; exportStarting.current = true; try { const body = selectedKeys.length ? { ids: selectedKeys, includeManifest } : { month, status, query, missingInvoice, includeManifest }; const r = await api<{ job: { id: string } }>("/api/exports", { method: "POST", body: JSON.stringify(body) }); setExportIssue(""); rememberExportJob(r.job.id); notify?.(includeManifest ? "导出任务已创建(含 manifest.json)" : "导出任务已创建", "info"); } catch (e) { notify?.((e as Error).message, "error"); } finally { exportStarting.current = false; } };
|
||||
useEffect(() => {
|
||||
if (!exporting) return;
|
||||
let disposed = false;
|
||||
let timer: number | undefined;
|
||||
let failureCount = 0;
|
||||
const finish = () => { setExportIssue(""); rememberExportJob(null); };
|
||||
const schedule = (delay: number) => { timer = window.setTimeout(() => void poll(), delay); };
|
||||
const poll = async () => {
|
||||
try {
|
||||
const r = await api<{ job: { status: string; errorMessage?: string } }>(`/api/exports/${exporting}`);
|
||||
if (disposed) return;
|
||||
failureCount = 0;
|
||||
setExportIssue("");
|
||||
if (r.job.status === "ready") {
|
||||
const downloadUrl = `/api/exports/${exporting}/download`;
|
||||
finish();
|
||||
window.location.href = downloadUrl;
|
||||
} else if (["failed", "expired"].includes(r.job.status)) {
|
||||
finish();
|
||||
notify?.(r.job.errorMessage || "导出失败", "error");
|
||||
} else {
|
||||
schedule(1000);
|
||||
}
|
||||
} catch {
|
||||
if (disposed) return;
|
||||
failureCount += 1;
|
||||
setExportIssue("网络连接不稳定,导出仍在后台进行,正在重新查询状态…");
|
||||
schedule(Math.min(1000 * (2 ** Math.min(failureCount, 3)), 8000));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); };
|
||||
}, [exporting, notify]);
|
||||
const moveToTrash = async () => { if (!trashTarget) return; setTrashing(true); try { await api(`/api/expenses/${trashTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: trashTarget.version }) }); notify?.("账目已移入回收站,可在回收站恢复", "success"); setTrashTarget(null); await load(); } catch (e) { notify?.((e as Error).message, "error"); } finally { setTrashing(false); } };
|
||||
const columns = [
|
||||
{ colKey: "row-select", type: "multiple" },
|
||||
{ colKey: "paidAt", title: "支付时间", cell: ({ row }: any) => dateText(row.paidAt, timezone) },
|
||||
{ colKey: "amountCents", title: "金额", cell: ({ row }: any) => <strong>{money(row.amountCents)}</strong> },
|
||||
{ colKey: "note", title: "备注", cell: ({ row }: any) => row.note || <span className="tn-muted">无备注</span>, ellipsis: true },
|
||||
{ colKey: "paymentProofCount", title: "凭证" },
|
||||
{ colKey: "invoiceCount", title: "发票", cell: ({ row }: any) => row.invoiceCount === 0 ? <Tag theme="danger" title={row.invoiceMissingReason || "缺发票"}>{row.invoiceMissingReason ? `无发票:${row.invoiceMissingReason}` : "缺发票"}</Tag> : row.invoiceCount },
|
||||
{ colKey: "status", title: "状态", cell: ({ row }: any) => <Tag theme={row.status === "reimbursed" ? "success" : "warning"}>{row.status === "reimbursed" ? "已报销" : "未报销"}</Tag> },
|
||||
{ colKey: "op", title: "操作", width: 150, cell: ({ row }: any) => {
|
||||
const label = row.note || "账目";
|
||||
return <Space className="tn-action-group"><Tooltip content="查看详情"><Button variant="text" shape="square" onClick={() => { setSelected(row); setDrawer("detail"); }} aria-label={`查看 ${label}`} icon={<FileText size={16} />} /></Tooltip><Tooltip content="编辑"><Button variant="text" shape="square" onClick={() => { setSelected(row); setDrawer("edit"); }} aria-label={`编辑 ${label}`} icon={<Pencil size={15} />} /></Tooltip><Tooltip content="移入回收站"><Button variant="text" shape="square" theme="danger" onClick={() => setTrashTarget(row)} aria-label={`将 ${label} 移入回收站`} icon={<Trash2 size={15} />} /></Tooltip></Space>;
|
||||
}},
|
||||
];
|
||||
const emptyState = query || missingInvoice
|
||||
? <div className="tn-empty expenses-empty"><ClipboardList size={30} /><p>没有符合当前筛选条件的账目</p><Button variant="outline" onClick={() => { setQueryDraft(""); updateFilters({ query: "", missingInvoice: false }); }}>清除筛选</Button></div>
|
||||
: status === "reimbursed"
|
||||
? <div className="tn-empty expenses-empty"><ClipboardList size={30} /><p>本月暂无已报销账目</p><Button variant="outline" onClick={() => updateFilters({ status: "unreimbursed" })}>查看未报销</Button></div>
|
||||
: <div className="tn-empty expenses-empty"><ClipboardList size={30} /><p>本月暂无未报销账目</p><Button theme="primary" onClick={() => setDrawer("new")} icon={<Plus size={15} />}>新增第一笔</Button></div>;
|
||||
const dataReady = loadedFilterKey === filterKey;
|
||||
return <div className="tn-page expenses-page"><div className="tn-page-head expenses-head"><div><h1 className="tn-page-title">账目列表</h1></div><div className="tn-page-actions"><Checkbox checked={includeManifest} onChange={setIncludeManifest}>包含 manifest.json</Checkbox><Button variant="outline" onClick={() => void exportAll()} disabled={Boolean(exporting) || (!selectedKeys.length && (!items.length || !dataReady))} icon={<FileDown size={16} />}>{exporting ? "导出中…" : selectedKeys.length ? `导出所选(${selectedKeys.length})` : "导出筛选结果"}</Button><Button theme="primary" onClick={() => setDrawer("new")} icon={<Plus size={16} />}>新增账目</Button></div></div>
|
||||
<div className="tn-toolbar expenses-toolbar"><div className="tn-expense-month-controls"><Tooltip content="上个月"><Button variant="text" shape="square" onClick={() => shiftMonth(-1)} aria-label="上个月" icon={<ChevronLeft size={18} />} /></Tooltip><DatePicker className="tn-month-picker" mode="month" format="YYYY-MM" value={month} onChange={(value: any) => { const next = String(value || "").slice(0, 7); if (/^\d{4}-\d{2}$/.test(next)) updateFilters({ month: next }); }} placeholder="选择月份" inputProps={{ "aria-label": "账目月份" } as any} /><Tooltip content="下个月"><Button variant="text" shape="square" onClick={() => shiftMonth(1)} aria-label="下个月" icon={<ChevronRight size={18} />} /></Tooltip></div><Radio.Group className="tn-segmented" theme="button" variant="primary-filled" value={status} onChange={(value: any) => updateFilters({ status: value as "unreimbursed" | "reimbursed" })} aria-label="报销状态"><Radio.Button value="unreimbursed">未报销</Radio.Button><Radio.Button value="reimbursed">已报销</Radio.Button></Radio.Group><div className="tn-expense-search-controls"><AccessibleInput inputAriaLabel="搜索备注" className="tn-expense-search" value={queryDraft} onChange={setQueryDraft} onEnter={() => { if (queryDraft.trim() === query) void load(); else updateFilters({ query: queryDraft }); }} maxlength={200} placeholder="搜索备注" prefixIcon={<Search size={16} />} suffix={queryDraft ? <Tooltip content="清除搜索"><Button variant="text" shape="square" onClick={() => { setQueryDraft(""); updateFilters({ query: "" }); }} aria-label="清除搜索" icon={<X size={14} />} /></Tooltip> : undefined} /><Button variant="outline" onClick={() => { if (queryDraft.trim() === query) void load(); else updateFilters({ query: queryDraft }); }} disabled={loading} icon={<Search size={15} />}>搜索</Button></div><div className="tn-expense-filter-actions"><Checkbox checked={missingInvoice} onChange={checked => updateFilters({ missingInvoice: checked })}>缺发票</Checkbox><Tooltip content="刷新当前结果"><Button variant="outline" shape="square" onClick={() => void load()} disabled={loading} aria-label="刷新当前结果" icon={<RefreshCw size={15} />} /></Tooltip></div></div>
|
||||
<div className="tn-summary expenses-summary"><span>{summary.count} 笔</span><strong>{money(summary.amountCents)}</strong>{selectedKeys.length > 0 && <><span>已选 {selectedKeys.length} 笔,共 {money(selectedTotal)}</span><Button variant="text" onClick={() => setSelectedKeys([])}>清除选择</Button></>}</div>
|
||||
{exportIssue && <div className="tn-export-status" role="status"><RefreshCw size={15} className="spin" /><span>{exportIssue}</span><Button variant="text" onClick={() => { setExportIssue(""); rememberExportJob(null); }}>停止等待</Button></div>}
|
||||
{error && <div className="expense-error" role="alert"><AlertCircle size={16} />{error}<Button variant="text" onClick={() => void load()}>重试</Button></div>}
|
||||
{error ? null : !dataReady || (loading && !items.length) ? <div className="tn-empty" role="status" aria-live="polite"><Loading text="加载中…" /></div> : !items.length ? emptyState : <div className="tn-table-wrap" role="region" aria-label="账目列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} selectedRowKeys={selectedKeys} onSelectChange={(keys: any[]) => setSelectedKeys(keys as string[])} hover stripe /></div>}
|
||||
{drawer === "new" && <ExpenseDrawer timezone={timezone} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "edit" && selected && <ExpenseDrawer timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "detail" && selected && <ExpenseDetail timezone={timezone} expense={selected} onClose={() => setDrawer(null)} onUpdated={load} onRequestEdit={e => { setSelected(e); setDrawer("edit"); }} notify={notify} />}
|
||||
{trashTarget && <Dialog visible header="移入回收站?" confirmBtn={{ content: "移入回收站", theme: "danger", loading: trashing, disabled: trashing }} cancelBtn="取消" onClose={() => { if (!trashing) setTrashTarget(null); }} onConfirm={() => void moveToTrash()} onCancel={() => { if (!trashing) setTrashTarget(null); }}>将“{trashTarget.note || `${dateText(trashTarget.paidAt, timezone)}的账目`}”移入回收站。它会从普通列表和导出结果中隐藏,附件会保留,可随时恢复。</Dialog>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function money(cents: number) { return `¥${(cents / 100).toFixed(2)}`; }
|
||||
export function dateText(ms: number, timezone = "Asia/Shanghai") { return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short", timeZone: timezone }).format(new Date(ms)); }
|
||||
export function monthNow(timezone = "Asia/Shanghai") {
|
||||
const p = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit" }).formatToParts(new Date()).map(x => [x.type, x.value]));
|
||||
return `${p.year}-${p.month}`;
|
||||
}
|
||||
export function dateInputValue(date: Date, timezone = "Asia/Shanghai") {
|
||||
const p = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(date).map(x => [x.type, x.value]));
|
||||
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`;
|
||||
}
|
||||
export function dateFromInput(value: string, timezone = "Asia/Shanghai") {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/.exec(value.trim());
|
||||
if (!m) throw new Error("请选择有效的支付日期和时间");
|
||||
const year = Number(m[1]);
|
||||
const month = Number(m[2]);
|
||||
const day = Number(m[3]);
|
||||
const hour = Number(m[4]);
|
||||
const minute = Number(m[5]);
|
||||
const probe = new Date(0); probe.setUTCFullYear(year, month - 1, day); probe.setUTCHours(0, 0, 0, 0);
|
||||
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day || hour > 23 || minute > 59) throw new Error("支付时间无效");
|
||||
const wall = Date.UTC(year, month - 1, day, hour, minute);
|
||||
const observed = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(new Date(wall)).map(x => [x.type, x.value]));
|
||||
const observedUtc = Date.UTC(Number(observed.year), Number(observed.month) - 1, Number(observed.day), Number(observed.hour), Number(observed.minute));
|
||||
return new Date(wall + wall - observedUtc).toISOString();
|
||||
}
|
||||
export function formatBytes(bytes: number) { return bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`; }
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default } from "./ExpensesPage";
|
||||
export { default as ExpensesPage } from "./ExpensesPage";
|
||||
export { default as ExpenseDrawer } from "./ExpenseDrawer";
|
||||
export { default as ExpenseDetail } from "./ExpenseDetail";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,6 @@
|
||||
export type ExpenseStatus = "unreimbursed" | "reimbursed";
|
||||
export type AttachmentKind = "payment_proof" | "invoice";
|
||||
export type Attachment = { id: string; kind: AttachmentKind; originalName: string; mimeType: string; sizeBytes: number; previewable: boolean };
|
||||
export type Expense = { id: string; paidAt: number; amountCents: number; note: string; invoiceMissingReason: string | null; status: ExpenseStatus; version: number; paymentProofCount: number; invoiceCount: number; deletedAt?: number | null; attachments?: Attachment[] };
|
||||
export type TimelineEvent = { id: number; occurredAt: number; actorUsername?: string | null; action: string };
|
||||
export type Notify = (message: string, kind?: "success" | "error" | "info") => void;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { RotateCcw, Trash2 } from "lucide-react";
|
||||
import { Button, Dialog, Space, Table } from "tdesign-react";
|
||||
import { api } from "../../services/api";
|
||||
import AccessibleInput from "../../components/AccessibleInput";
|
||||
import type { Notify } from "../expenses/types";
|
||||
import type { Expense } from "../expenses/types";
|
||||
import { dateText, money } from "../expenses/date";
|
||||
import { AsyncState, BusyIcon, ErrorBanner, Page, StatusTag } from "../common";
|
||||
|
||||
export default function TrashPage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) {
|
||||
const [items, setItems] = useState<Expense[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [purgeTarget, setPurgeTarget] = useState<Expense | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [purgeError, setPurgeError] = useState("");
|
||||
const loadSequence = useRef(0);
|
||||
|
||||
const load = async () => {
|
||||
const sequence = ++loadSequence.current;
|
||||
setLoading(true); setError("");
|
||||
try {
|
||||
const result = await api<{ items: Expense[] }>("/api/trash");
|
||||
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 restore = async (item: Expense) => {
|
||||
setBusy(true); setError("");
|
||||
try { await api(`/api/trash/${item.id}/restore`, { method: "POST", body: JSON.stringify({ version: item.version }) }); notify?.("账目已恢复", "success"); await load(); }
|
||||
catch (e) { setError((e as Error).message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const purge = async () => {
|
||||
if (!purgeTarget || !password) { setPurgeError("请输入当前管理员密码"); return; }
|
||||
setBusy(true); setPurgeError("");
|
||||
try { await api(`/api/trash/${purgeTarget.id}`, { method: "DELETE", body: JSON.stringify({ version: purgeTarget.version, password }) }); setPurgeTarget(null); setPassword(""); notify?.("账目已永久删除,审计历史仍保留", "success"); await load(); }
|
||||
catch (e) { setPurgeError((e as Error).message); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ colKey: "deletedAt", title: "删除时间", cell: ({ row }: any) => row.deletedAt ? dateText(row.deletedAt, timezone) : "-" },
|
||||
{ colKey: "paidAt", title: "支付时间", cell: ({ row }: any) => dateText(row.paidAt, timezone) },
|
||||
{ colKey: "amountCents", title: "金额", cell: ({ row }: any) => <span className="tn-amount">{money(row.amountCents)}</span> },
|
||||
{ colKey: "note", title: "备注", cell: ({ row }: any) => <span className="tn-note">{row.note || "无备注"}</span> },
|
||||
{ colKey: "status", title: "原状态", cell: ({ row }: any) => <StatusTag status={row.status} /> },
|
||||
{ colKey: "actions", title: "操作", width: 190, cell: ({ row }: any) => <Space className="tn-action-group"><Button variant="outline" onClick={() => void restore(row)} disabled={busy} icon={busy ? <BusyIcon /> : <RotateCcw size={15} />}>恢复</Button><Button theme="danger" variant="outline" onClick={() => { setPurgeTarget(row); setPassword(""); setPurgeError(""); }} disabled={busy} icon={<Trash2 size={15} />}>永久删除</Button></Space> },
|
||||
];
|
||||
|
||||
return <Page title="回收站" subtitle="已删除的账目会保留附件,可恢复或经过密码确认后永久删除。" actions={<Button variant="outline" onClick={() => void load()} disabled={loading || busy} icon={<RotateCcw size={15} />}>刷新</Button>}>
|
||||
<AsyncState loading={loading} error={error} empty={items.length === 0 ? <div className="tn-empty"><Trash2 size={30} /><p>回收站为空</p></div> : undefined} onRetry={() => void load()}>
|
||||
<div className="tn-table-wrap" role="region" aria-label="回收站账目列表,可横向滚动查看更多列"><Table rowKey="id" data={items} columns={columns as any} hover stripe /></div>
|
||||
</AsyncState>
|
||||
<Dialog visible={Boolean(purgeTarget)} header="永久删除账目" confirmBtn={{ content: "永久删除", theme: "danger", loading: busy, disabled: busy }} cancelBtn="取消" onClose={() => { if (!busy) { setPurgeTarget(null); setPurgeError(""); } }} onConfirm={() => void purge()} onCancel={() => { if (!busy) { setPurgeTarget(null); setPurgeError(""); } }}>
|
||||
<p>此操作会移除账目和附件字节,完整审计内容仍会保留,且无法恢复。</p>
|
||||
<p className="tn-dialog-note">请输入当前管理员密码确认。</p>
|
||||
<AccessibleInput inputAriaLabel="当前管理员密码" inputAriaInvalid={Boolean(purgeError)} inputAriaDescribedby={purgeError ? "trash-purge-error" : undefined} type="password" value={password} onChange={value => { setPassword(value); setPurgeError(""); }} placeholder="当前管理员密码" autocomplete="current-password" />
|
||||
{purgeError && <div id="trash-purge-error" className="tn-inline-error" role="alert">{purgeError}</div>}
|
||||
</Dialog>
|
||||
</Page>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TrashPage } from "./TrashPage";
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AlertCircle, CheckCircle2, Download, RefreshCw, Server, ShieldCheck, Terminal, Zap } from "lucide-react";
|
||||
import { Button, Dialog, Tag } from "tdesign-react";
|
||||
import { ApiError, api } from "../../services/api";
|
||||
import { dateText } from "../expenses/date";
|
||||
import { ErrorBanner, Page, Surface } from "../common";
|
||||
import type { Notify } from "../expenses/types";
|
||||
|
||||
type JobStatus = "queued" | "downloading" | "verifying" | "staged" | "backing_up" | "applying" | "completed" | "failed" | "cancelled";
|
||||
type UpdateJob = { id: string; operation?: "download" | "apply"; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; errorMessage?: string | null; createdAt?: number; updatedAt?: number; completedAt?: number | null; applyQueuedAt?: number | string | null; restartWindowSeconds?: number | null; restartDeadline?: number | string | null; restartAt?: number | string | null; expectedRecoveryAt?: number | string | null };
|
||||
type LatestRelease = { version: string; tagName?: string; releaseName?: string; publishedAt?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; isNewer: boolean; assetName?: string; assetSize?: number; notes?: string | null; releaseNotes?: string | null; body?: string | null; htmlUrl?: string | null };
|
||||
type UpdateInfo = { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; platform: { target: string; os: string; arch: string }; checkedAt: number; latest: LatestRelease | null; job: UpdateJob | null };
|
||||
const active = new Set<JobStatus>(["queued", "downloading", "verifying", "staged", "backing_up", "applying"]);
|
||||
const pollable = new Set<JobStatus>(["queued", "downloading", "verifying", "backing_up", "applying"]);
|
||||
const labels: Record<JobStatus, string> = { queued: "等待系统服务", downloading: "下载中", verifying: "校验文件", staged: "下载完成,等待应用", backing_up: "备份数据", applying: "切换并检查服务", completed: "已完成", failed: "失败", cancelled: "已取消" };
|
||||
|
||||
function timestamp(value: number | string | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const n = typeof value === "number" ? value : Date.parse(value);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return n < 10_000_000_000 ? n * 1000 : n;
|
||||
}
|
||||
function notesFor(latest: LatestRelease): string | null {
|
||||
const value = latest.notes ?? latest.releaseNotes ?? latest.body;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) {
|
||||
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [pollError, setPollError] = useState("");
|
||||
const [confirmVersion, setConfirmVersion] = useState<string | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<"download" | "apply">("download");
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [reloadReady, setReloadReady] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const announced = useRef<string | null>(null);
|
||||
const checkInFlight = useRef(false);
|
||||
const actionInFlight = useRef(false);
|
||||
const disconnected = useRef(false);
|
||||
const recoveredNotice = useRef(false);
|
||||
|
||||
const load = async () => { setLoading(true); setError(""); try { setInfo(await api<UpdateInfo>("/api/update/status")); } catch (e) { setError((e as Error).message); } finally { setLoading(false); } };
|
||||
useEffect(() => { void load(); }, []);
|
||||
const job = info?.job;
|
||||
const applyQueuedAt = timestamp(job?.applyQueuedAt);
|
||||
const restartAt = timestamp(job?.restartDeadline)
|
||||
?? timestamp(job?.restartAt)
|
||||
?? timestamp(job?.expectedRecoveryAt)
|
||||
?? (job?.operation === "apply" && applyQueuedAt ? applyQueuedAt + (job.restartWindowSeconds ?? 30) * 1000 : null)
|
||||
?? (job?.status === "applying" && job.updatedAt ? timestamp(job.updatedAt)! + 30_000 : null);
|
||||
const restartSeconds = restartAt ? Math.max(0, Math.ceil((restartAt - now) / 1000)) : null;
|
||||
useEffect(() => { if (!restartAt) return; const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); }, [restartAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldPoll = Boolean(job && (pollable.has(job.status) || (job.status === "staged" && job.operation === "apply")));
|
||||
if (!shouldPoll || !job) { setPollError(""); return; }
|
||||
let disposed = false; let timer: number | undefined; let failures = 0;
|
||||
const schedule = (delay: number) => { timer = window.setTimeout(() => void poll(), delay); };
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await api<{ job: UpdateJob }>(`/api/update/jobs/${job.id}`);
|
||||
if (disposed) return;
|
||||
failures = 0;
|
||||
if (disconnected.current && !recoveredNotice.current) { recoveredNotice.current = true; notify?.("服务已恢复,更新状态已刷新", "success"); }
|
||||
disconnected.current = false; setPollError("");
|
||||
setInfo(current => current ? { ...current, job: result.job } : current);
|
||||
if (result.job.status === "completed" && announced.current !== result.job.id) { announced.current = result.job.id; setReloadReady(true); notify?.("更新完成,请重新加载页面", "success"); }
|
||||
if (pollable.has(result.job.status) || (result.job.status === "staged" && result.job.operation === "apply")) schedule(1500);
|
||||
} catch {
|
||||
if (disposed) return;
|
||||
failures += 1; disconnected.current = true; recoveredNotice.current = false;
|
||||
setPollError(`服务暂时不可用${restartSeconds !== null ? `,预计 ${restartSeconds} 秒后恢复` : ",页面会自动重试"}。更新任务仍在后台运行。`);
|
||||
schedule(Math.min(1500 * (2 ** Math.min(failures, 3)), 12_000));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); };
|
||||
}, [job?.id, job?.status, job?.operation, notify]);
|
||||
|
||||
const check = async () => {
|
||||
if (checkInFlight.current) return;
|
||||
checkInFlight.current = true; setChecking(true); setError("");
|
||||
try {
|
||||
const result = await api<Omit<UpdateInfo, "job"> & { job?: UpdateJob | null }>("/api/update/check", { method: "POST", body: "{}" });
|
||||
setInfo(current => ({ ...result, job: result.job ?? current?.job ?? null })); notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
|
||||
} catch (caught) {
|
||||
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") { try { await load(); notify?.(`已显示最近一次检查结果${caught.retryAfter ? `,请 ${caught.retryAfter} 秒后再检查` : ",请稍后再检查"}`, "info"); return; } catch { /* fall through */ } }
|
||||
setError((caught as Error).message);
|
||||
} finally { checkInFlight.current = false; setChecking(false); }
|
||||
};
|
||||
const submitAction = async () => {
|
||||
if (!confirmVersion || actionInFlight.current) return;
|
||||
actionInFlight.current = true; setActionBusy(true); setError("");
|
||||
try {
|
||||
const endpoint = confirmAction === "download" ? "/api/update/download" : "/api/update/apply";
|
||||
const body = confirmAction === "download" ? { version: confirmVersion, confirm: true } : { jobId: job?.id, version: confirmVersion, confirm: true };
|
||||
const result = await api<{ job: UpdateJob }>(endpoint, { method: "POST", body: JSON.stringify(body) });
|
||||
setConfirmVersion(null); setReloadReady(false); setInfo(current => current ? { ...current, job: result.job } : current); notify?.(confirmAction === "download" ? "更新包下载已开始" : "更新已开始,服务会短暂重启", "info");
|
||||
} catch (caught) { setError((caught as Error).message); } finally { actionInFlight.current = false; setActionBusy(false); }
|
||||
};
|
||||
|
||||
const latest = info?.latest; const hasActiveJob = Boolean(job && active.has(job.status));
|
||||
const sameCompleted = Boolean(job?.status === "completed" && latest && job.version === latest.version);
|
||||
const canDownload = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && !sameCompleted && (!job || job.version !== latest.version || job.status === "failed" || job.status === "cancelled"));
|
||||
const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job?.operation === "download" && job.status === "staged" && job.version === latest.version);
|
||||
const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial<Record<JobStatus, number>>)[job.status] ?? 100 : 0;
|
||||
const notes = latest ? notesFor(latest) : null;
|
||||
|
||||
return <Page title="系统更新" subtitle="检查受信任的 Release;更新前会校验文件并保护现有数据。" actions={<Button variant="outline" onClick={() => void check()} disabled={checking || loading || hasActiveJob} icon={<RefreshCw size={15} />}>{checking ? "检查中…" : "检查更新"}</Button>}>
|
||||
{error && <ErrorBanner message={error} onRetry={() => void load()} />}{pollError && <ErrorBanner message={pollError} />}
|
||||
{loading ? <div className="tn-empty" role="status" aria-live="polite">正在读取版本信息…</div> : info && <>
|
||||
<div className="tn-update-grid"><Surface className="tn-update-block"><Server size={20} /><span className="tn-eyebrow">当前版本</span><strong className="tn-update-value">v{info.currentVersion}</strong><small>运行平台:{info.platform.target}</small></Surface><Surface className="tn-update-block"><ShieldCheck size={20} /><span className="tn-eyebrow">更新方式</span><strong>{info.strategy === "systemd" ? "后台一键更新" : "手动命令行更新"}</strong><small>{info.strategy === "systemd" ? (info.configured ? "由 systemd 更新服务执行" : "尚未配置发布源") : "当前安装未启用后台更新"}</small></Surface></div>
|
||||
{latest ? <Surface className="tn-update-release"><div className="tn-update-release-head"><div><span className="tn-eyebrow">最新 Release</span><h2>{latest.releaseName || latest.tagName || `v${latest.version}`}</h2>{latest.publishedAt && <small>发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}</small>}</div><Tag theme={latest.isNewer ? "primary" : "success"}>{latest.isNewer ? "有新版本" : "已是最新"}</Tag></div>{notes && <div className="tn-release-notes"><span className="tn-eyebrow">Release notes</span><div>{notes}</div></div>}<div className="tn-facts"><div><span>平台文件</span><strong>{latest.compatible ? latest.assetName : "无匹配文件"}</strong></div><div><span>完整性</span><strong className={latest.integrityReady ? "text-success" : "text-danger"}>{latest.integrityReady ? "SHA-256 + 签名可验证" : "不可验证"}</strong></div><div><span>文件大小</span><strong>{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}</strong></div></div>{latest.isNewer && !latest.compatible && <div className="tn-inline-error"><AlertCircle size={16} />当前平台没有可安装的 Release 文件。</div>}{latest.isNewer && latest.compatible && !latest.integrityReady && <div className="tn-inline-error"><AlertCircle size={16} />发布文件缺少完整校验,已禁用更新。</div>}<div className="tn-page-actions">{canDownload && <Button theme="primary" onClick={() => { setConfirmAction("download"); setConfirmVersion(latest.version); }} disabled={actionBusy} loading={actionBusy && confirmAction === "download"} icon={<Download size={16} />}>下载更新包</Button>}{canApply && <Button theme="primary" onClick={() => { setConfirmAction("apply"); setConfirmVersion(latest.version); }} disabled={actionBusy} loading={actionBusy && confirmAction === "apply"} icon={<Zap size={16} />}>立即更新</Button>}{reloadReady && <Button theme="primary" onClick={() => window.location.reload()}>重新加载</Button>}</div></Surface> : <div className="tn-empty">点击“检查更新”获取最新 Release。</div>}
|
||||
{!info.configured && <div className="tn-update-explainer"><Terminal size={17} /><div><strong>当前为手动更新模式</strong><p>源码安装默认不启用后台更新。需要更新时,在服务器拉取对应 Release 后重新构建并重启服务;安装器部署并配置 systemd 后,才会显示后台一键更新。</p></div></div>}
|
||||
{job && <Surface className="tn-update-release"><span className="tn-sr-only" aria-live="polite">更新任务状态:{labels[job.status]}</span><div className="tn-update-release-head"><div><span className="tn-eyebrow">最近任务</span><h2>v{job.version}</h2></div><Tag theme={job.status === "completed" ? "success" : job.status === "failed" ? "danger" : "primary"}>{labels[job.status]}</Tag></div>{active.has(job.status) && <><div className="tn-progress" role="progressbar" aria-label="系统更新进度" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress}><span style={{ width: `${progress}%` }} /></div><small>{job.status === "staged" && job.operation === "download" ? "更新包已下载并校验,可以立即应用。" : job.status === "staged" && job.operation === "apply" ? "立即更新请求已提交,服务即将重启。" : "更新服务正在后台运行,页面会自动刷新状态。"}</small>{restartSeconds !== null && (job.status === "applying" || disconnected.current) && <div className="tn-restart-countdown" role="status">服务正在重启,预计 {restartSeconds} 秒后恢复</div>}</>}{job.status === "failed" && job.errorMessage && <div className="tn-inline-error" role="alert">{job.errorMessage}</div>}{job.status === "completed" && <div className="tn-inline-info"><CheckCircle2 size={16} />新版本已通过健康检查,数据和附件保持不变。</div>}</Surface>}
|
||||
</>}
|
||||
<Dialog visible={Boolean(confirmVersion)} header={confirmAction === "download" ? "下载更新包" : "确认立即更新"} confirmBtn={{ content: confirmAction === "download" ? "开始下载" : "立即更新", theme: "primary", loading: actionBusy, disabled: actionBusy }} cancelBtn="取消" onClose={() => { if (!actionBusy) setConfirmVersion(null); }} onConfirm={() => void submitAction()} onCancel={() => { if (!actionBusy) setConfirmVersion(null); }}>{confirmAction === "download" ? `将下载并校验 v${confirmVersion},完成后可选择立即更新。` : `将应用已下载的 v${confirmVersion}。服务会短暂重启,更新前会备份数据目录。`}</Dialog>
|
||||
</Page>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as UpdatePage } from "./UpdatePage";
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Archive, ClipboardList, LayoutDashboard, RefreshCw, Trash2, Users } from "lucide-react";
|
||||
|
||||
export type RouteId = "dashboard" | "expenses" | "trash" | "admins" | "audit" | "update";
|
||||
|
||||
export interface AppRoute {
|
||||
id: RouteId;
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const APP_ROUTES: readonly AppRoute[] = [
|
||||
{ id: "dashboard", title: "仪表盘", eyebrow: "概览", icon: LayoutDashboard },
|
||||
{ id: "expenses", title: "账目", eyebrow: "工作台", icon: ClipboardList },
|
||||
{ id: "trash", title: "回收站", eyebrow: "系统", icon: Trash2 },
|
||||
{ id: "admins", title: "管理员", eyebrow: "系统", icon: Users },
|
||||
{ id: "audit", title: "审计日志", eyebrow: "系统", icon: Archive },
|
||||
{ id: "update", title: "系统更新", eyebrow: "系统", icon: RefreshCw },
|
||||
];
|
||||
|
||||
export const DEFAULT_ROUTE_ID: RouteId = "dashboard";
|
||||
|
||||
export const ROUTE_PATHS: Readonly<Record<RouteId, string>> = {
|
||||
dashboard: "/",
|
||||
expenses: "/expenses",
|
||||
trash: "/trash",
|
||||
admins: "/admins",
|
||||
audit: "/audit",
|
||||
update: "/update",
|
||||
};
|
||||
|
||||
export function isRouteId(value: string): value is RouteId {
|
||||
return APP_ROUTES.some((route) => route.id === value);
|
||||
}
|
||||
|
||||
export function routePath(id: RouteId): string {
|
||||
return ROUTE_PATHS[id];
|
||||
}
|
||||
|
||||
export function routeIdFromPath(pathname: string): RouteId | undefined {
|
||||
const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
||||
if (normalized === "/dashboard") return DEFAULT_ROUTE_ID;
|
||||
return APP_ROUTES.find((route) => ROUTE_PATHS[route.id] === normalized)?.id;
|
||||
}
|
||||
|
||||
export function routeTitle(id: string): string {
|
||||
return APP_ROUTES.find((route) => route.id === id)?.title ?? "账目";
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ApiErrorBody } from "../types/auth";
|
||||
|
||||
export const AUTH_EXPIRED_EVENT = "tallynote-auth-expired";
|
||||
const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
const AUTH_EXEMPT_PATHS = new Set([
|
||||
"/api/auth/login",
|
||||
"/api/auth/session",
|
||||
]);
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
||||
const UPLOAD_REQUEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
export interface ApiRequestInit extends RequestInit {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code?: string;
|
||||
readonly details?: unknown;
|
||||
readonly requestId?: string;
|
||||
readonly retryAfter?: number;
|
||||
|
||||
constructor(status: number, message: string, code?: string, details?: unknown, requestId?: string, retryAfter?: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
this.requestId = requestId;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(name: string): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
const prefix = `${name}=`;
|
||||
const value = document.cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith(prefix));
|
||||
if (!value) return "";
|
||||
const raw = value.slice(prefix.length);
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function requestPath(url: string): string {
|
||||
try {
|
||||
return new URL(url, typeof window === "undefined" ? "http://localhost" : window.location.origin).pathname;
|
||||
} catch {
|
||||
return url.split("?", 1)[0] ?? url;
|
||||
}
|
||||
}
|
||||
|
||||
function emitAuthExpired(message: string): void {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(AUTH_EXPIRED_EVENT, { detail: message }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(url: string, init: ApiRequestInit = {}): Promise<T> {
|
||||
const { timeoutMs: requestedTimeout, signal: externalSignal, ...requestInit } = init;
|
||||
const method = (init.method ?? "GET").toUpperCase();
|
||||
const headers = new Headers(init.headers);
|
||||
if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (UNSAFE_METHODS.has(method)) {
|
||||
const csrf = readCookie("tally_csrf");
|
||||
if (csrf) headers.set("X-CSRF-Token", csrf);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeoutMs = requestedTimeout ?? (init.body instanceof FormData ? UPLOAD_REQUEST_TIMEOUT_MS : DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
const abortFromCaller = () => controller.abort(externalSignal?.reason);
|
||||
if (externalSignal?.aborted) abortFromCaller();
|
||||
else externalSignal?.addEventListener("abort", abortFromCaller, { once: true });
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...requestInit, credentials: "include", headers, signal: controller.signal });
|
||||
if (response.status === 204) return undefined as T;
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const data: unknown = contentType.includes("json")
|
||||
? await response.json().catch(() => ({}))
|
||||
: await response.text().catch(() => "");
|
||||
if (!response.ok) {
|
||||
const body = (data && typeof data === "object" ? data : {}) as ApiErrorBody;
|
||||
const error = body.error;
|
||||
if (response.status === 401 && error?.code === "AUTH_REQUIRED" && !AUTH_EXEMPT_PATHS.has(requestPath(url))) {
|
||||
emitAuthExpired(error.message || "登录已失效,请重新登录");
|
||||
}
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
error?.message || `请求失败(${response.status})`,
|
||||
error?.code,
|
||||
error?.details,
|
||||
error?.requestId,
|
||||
Number(response.headers.get("retry-after") || 0) || undefined,
|
||||
);
|
||||
}
|
||||
return data as T;
|
||||
} catch (caught) {
|
||||
if (caught instanceof ApiError) throw caught;
|
||||
if (timedOut) throw new ApiError(408, "请求超时,请稍后重试", "REQUEST_TIMEOUT");
|
||||
if (externalSignal?.aborted) throw new ApiError(0, "请求已取消", "REQUEST_CANCELED");
|
||||
throw new ApiError(0, "网络连接失败,请确认服务仍在运行");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
externalSignal?.removeEventListener("abort", abortFromCaller);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiGet = <T>(url: string, init?: ApiRequestInit) => api<T>(url, { ...init, method: "GET" });
|
||||
export const apiPost = <T>(url: string, body?: unknown, init: ApiRequestInit = {}) => api<T>(url, {
|
||||
...init,
|
||||
method: "POST",
|
||||
body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const apiPut = <T>(url: string, body?: unknown, init: ApiRequestInit = {}) => api<T>(url, {
|
||||
...init,
|
||||
method: "PUT",
|
||||
body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const apiPatch = <T>(url: string, body?: unknown, init: ApiRequestInit = {}) => api<T>(url, {
|
||||
...init,
|
||||
method: "PATCH",
|
||||
body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const apiDelete = <T>(url: string, body?: unknown, init: ApiRequestInit = {}) => api<T>(url, {
|
||||
...init,
|
||||
method: "DELETE",
|
||||
body: body === undefined ? init.body : body instanceof FormData ? body : JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const fetchApi = api;
|
||||
export const request = api;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { api, apiGet, apiPost } from "./api";
|
||||
import type { Admin, AuthResponse, AuthStatus, ChangePasswordInput, LoginCredentials } from "../types/auth";
|
||||
|
||||
export function getAuthStatus(): Promise<AuthStatus> {
|
||||
return apiGet<AuthStatus>("/api/auth/status");
|
||||
}
|
||||
|
||||
export function getSession(): Promise<AuthResponse> {
|
||||
return apiGet<AuthResponse>("/api/auth/session");
|
||||
}
|
||||
|
||||
export function login(credentials: LoginCredentials): Promise<AuthResponse> {
|
||||
return apiPost<AuthResponse>("/api/auth/login", credentials);
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
return apiPost<void>("/api/auth/logout");
|
||||
}
|
||||
|
||||
export function changePassword(input: ChangePasswordInput): Promise<AuthResponse> {
|
||||
return apiPost<AuthResponse>("/api/auth/change-password", input);
|
||||
}
|
||||
|
||||
export type { Admin, AuthResponse, AuthStatus, ChangePasswordInput, LoginCredentials };
|
||||
|
||||
// Keep a typed escape hatch for future authenticated services without exposing fetch details.
|
||||
export { api };
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiError } from "./api";
|
||||
|
||||
type ValidationIssue = { path?: Array<string | number>; message?: string };
|
||||
|
||||
export function apiFieldErrors(caught: unknown): Record<string, string> {
|
||||
if (!(caught instanceof ApiError) || !Array.isArray(caught.details)) return {};
|
||||
const errors: Record<string, string> = {};
|
||||
for (const issue of caught.details as ValidationIssue[]) {
|
||||
const field = issue.path?.[0];
|
||||
if (typeof field === "string" && issue.message && !errors[field]) errors[field] = issue.message;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./api";
|
||||
export * from "./auth";
|
||||
@@ -0,0 +1,17 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux";
|
||||
import sessionReducer from "./sessionSlice";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
session: sessionReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
||||
export { default as sessionReducer } from "./sessionSlice";
|
||||
export * from "./sessionSlice";
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createAsyncThunk, createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||
import { ApiError } from "../services/api";
|
||||
import { changePassword as changePasswordRequest, getAuthStatus, getSession, login as loginRequest, logout as logoutRequest } from "../services/auth";
|
||||
import type { Admin, AuthStatus, ChangePasswordInput, LoginCredentials } from "../types/auth";
|
||||
|
||||
export type SessionStatus = "idle" | "loading" | "authenticated" | "unauthenticated" | "error";
|
||||
|
||||
export interface SessionState {
|
||||
status: SessionStatus;
|
||||
admin: Admin | null;
|
||||
initialized: boolean | null;
|
||||
timezone: string;
|
||||
error: string | null;
|
||||
bootstrapRequestId: string | null;
|
||||
}
|
||||
|
||||
const initialState: SessionState = {
|
||||
status: "idle",
|
||||
admin: null,
|
||||
initialized: null,
|
||||
timezone: "Asia/Shanghai",
|
||||
error: null,
|
||||
bootstrapRequestId: null,
|
||||
};
|
||||
|
||||
/** Only the initial session check should replace the auth surface with a
|
||||
* full-page bootstrap indicator. Login and password requests keep their form
|
||||
* mounted so their controls can show progress in place. */
|
||||
export function isSessionBootstrapping(state: Pick<SessionState, "status" | "initialized" | "bootstrapRequestId">): boolean {
|
||||
return state.status === "idle"
|
||||
|| (state.status === "loading" && (state.initialized === null || state.bootstrapRequestId !== null));
|
||||
}
|
||||
|
||||
export const bootstrapSession = createAsyncThunk("session/bootstrap", async () => {
|
||||
const authStatus = await getAuthStatus();
|
||||
try {
|
||||
const session = await getSession();
|
||||
return { ...authStatus, admin: session.admin };
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) return { ...authStatus, admin: null };
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const loginThunk = createAsyncThunk("session/login", async (credentials: LoginCredentials) => {
|
||||
const result = await loginRequest(credentials);
|
||||
return result.admin;
|
||||
});
|
||||
|
||||
export const changePasswordThunk = createAsyncThunk("session/changePassword", async (input: ChangePasswordInput) => {
|
||||
const result = await changePasswordRequest(input);
|
||||
return result.admin;
|
||||
});
|
||||
|
||||
export const logoutThunk = createAsyncThunk("session/logout", async () => {
|
||||
await logoutRequest();
|
||||
});
|
||||
|
||||
// Friendly aliases keep page code terse while the *Thunk suffix remains explicit at call sites.
|
||||
export const restoreSession = bootstrapSession;
|
||||
export const login = loginThunk;
|
||||
export const changePassword = changePasswordThunk;
|
||||
export const logout = logoutThunk;
|
||||
|
||||
const sessionSlice = createSlice({
|
||||
name: "session",
|
||||
initialState,
|
||||
reducers: {
|
||||
clearSessionError(state) {
|
||||
state.error = null;
|
||||
},
|
||||
sessionExpired(state, action: PayloadAction<string | undefined>) {
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "unauthenticated";
|
||||
state.admin = null;
|
||||
state.error = action.payload ?? null;
|
||||
},
|
||||
setTimezone(state, action: PayloadAction<string>) {
|
||||
state.timezone = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(bootstrapSession.pending, (state, action) => {
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
state.bootstrapRequestId = action.meta.requestId;
|
||||
})
|
||||
.addCase(bootstrapSession.fulfilled, (state, action) => {
|
||||
if (state.bootstrapRequestId !== action.meta.requestId) return;
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = action.payload.admin ? "authenticated" : "unauthenticated";
|
||||
state.admin = action.payload.admin;
|
||||
state.initialized = action.payload.initialized;
|
||||
state.timezone = action.payload.timezone || state.timezone;
|
||||
})
|
||||
.addCase(bootstrapSession.rejected, (state, action) => {
|
||||
if (state.bootstrapRequestId !== action.meta.requestId) return;
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "error";
|
||||
state.error = action.error.message ?? "无法读取登录状态";
|
||||
})
|
||||
.addCase(loginThunk.pending, (state) => {
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loginThunk.fulfilled, (state, action: PayloadAction<Admin>) => {
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "authenticated";
|
||||
state.admin = action.payload;
|
||||
state.initialized = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loginThunk.rejected, (state, action) => {
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "unauthenticated";
|
||||
state.error = action.error.message ?? "登录失败";
|
||||
})
|
||||
.addCase(changePasswordThunk.pending, (state) => {
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "loading";
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(changePasswordThunk.fulfilled, (state, action: PayloadAction<Admin>) => {
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "authenticated";
|
||||
state.admin = action.payload;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(changePasswordThunk.rejected, (state, action) => {
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "authenticated";
|
||||
state.error = action.error.message ?? "密码更新失败";
|
||||
})
|
||||
.addCase(logoutThunk.pending, (state) => {
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "loading";
|
||||
})
|
||||
.addCase(logoutThunk.fulfilled, (state) => {
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "unauthenticated";
|
||||
state.admin = null;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(logoutThunk.rejected, (state, action) => {
|
||||
state.bootstrapRequestId = null;
|
||||
state.status = "unauthenticated";
|
||||
state.admin = null;
|
||||
state.error = action.error.message ?? "退出登录失败";
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { clearSessionError, sessionExpired, setTimezone } = sessionSlice.actions;
|
||||
export default sessionSlice.reducer;
|
||||
@@ -0,0 +1,528 @@
|
||||
@import 'tdesign-react/es/style/index.css';
|
||||
@import '@fontsource-variable/plus-jakarta-sans';
|
||||
|
||||
/* TallyNote UX baseline: dense, calm, and keyboard-friendly surfaces. */
|
||||
|
||||
:root {
|
||||
font-family: "Plus Jakarta Sans Variable", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #1f2937;
|
||||
background: #f4f7fb;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--tn-navy-950: #082f76;
|
||||
--tn-navy-900: #0b3a88;
|
||||
--tn-navy-800: #1556ad;
|
||||
--tn-blue-700: #175cd3;
|
||||
--tn-blue-100: #e8f0ff;
|
||||
--tn-surface: #ffffff;
|
||||
--tn-page: #f4f7fb;
|
||||
--tn-border: #dce3ec;
|
||||
--tn-border-subtle: #e7edf3;
|
||||
--tn-text: #172033;
|
||||
--tn-text-secondary: #475467;
|
||||
--tn-text-muted: #5b6575;
|
||||
--tn-success: #2f7d5c;
|
||||
--tn-warning: #805400;
|
||||
--tn-danger: #c93737;
|
||||
|
||||
/* TDesign tokens kept on one deep-blue brand scale. */
|
||||
--td-brand-color: #175cd3;
|
||||
--td-brand-color-1: #e8f0ff;
|
||||
--td-brand-color-2: #c9dcff;
|
||||
--td-brand-color-3: #9fbeff;
|
||||
--td-brand-color-4: #6f9ff0;
|
||||
--td-brand-color-5: #3d7be5;
|
||||
--td-brand-color-6: #2f6fe4;
|
||||
--td-brand-color-7: #175cd3;
|
||||
--td-brand-color-8: #1250bb;
|
||||
--td-brand-color-9: #0f429b;
|
||||
--td-brand-color-10: #082f76;
|
||||
--td-brand-color-hover: var(--td-brand-color-6);
|
||||
--td-brand-color-focus: var(--td-brand-color-2);
|
||||
--td-brand-color-active: var(--td-brand-color-8);
|
||||
--td-brand-color-disabled: var(--td-brand-color-3);
|
||||
--td-brand-color-light: var(--td-brand-color-1);
|
||||
--td-brand-color-light-hover: var(--td-brand-color-2);
|
||||
--td-mask-active: rgba(8, 47, 118, .34);
|
||||
--td-error-color: var(--tn-danger);
|
||||
--td-warning-color: var(--tn-warning);
|
||||
--td-success-color: var(--tn-success);
|
||||
--td-bg-color-page: var(--tn-page);
|
||||
--td-bg-color-container: var(--tn-surface);
|
||||
--td-text-color-primary: var(--tn-text);
|
||||
--td-text-color-secondary: var(--tn-text-secondary);
|
||||
--td-component-stroke: var(--tn-border);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
.tn-sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
|
||||
/* The page owns scrolling in `.tn-content`; reserving a root scrollbar gutter
|
||||
would shrink the flex layout and make fixed notifications drift inward. */
|
||||
html { min-width: 320px; height: 100%; overflow: hidden; }
|
||||
body { height: 100%; margin: 0; min-width: 320px; background: var(--tn-page); overflow: hidden; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
button:focus-visible, a:focus-visible { outline: 2px solid #175cd3; outline-offset: 2px; }
|
||||
.t-dialog__close:focus-visible { border-radius: 3px; outline: 2px solid var(--td-brand-color); outline-offset: 2px; }
|
||||
/* Keep a single, inset focus indicator. TDesign already owns the field
|
||||
border, so an inset outline avoids the heavy second ring users see with
|
||||
the browser default while remaining visible to keyboard users. */
|
||||
input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||
outline: 2px solid var(--td-brand-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* TDesign resets focus-visible on its nested inputs with higher specificity.
|
||||
The wrapper owns the single visible indicator, so the native input does
|
||||
not draw a second rectangle inside it. */
|
||||
.t-input :focus-visible, .t-textarea :focus-visible, .t-date-picker :focus-visible, .t-select-input :focus-visible {
|
||||
outline: 0 !important;
|
||||
}
|
||||
.t-input:has(:focus-visible), .t-textarea:has(:focus-visible), .t-date-picker:has(:focus-visible), .t-select-input:has(:focus-visible) {
|
||||
border-color: var(--td-brand-color) !important;
|
||||
box-shadow: inset 0 0 0 1px var(--td-brand-color) !important;
|
||||
}
|
||||
.t-input.t-is-focused, .t-input.t-input--focused,
|
||||
.t-textarea.t-is-focused, .t-textarea.t-textarea--focused,
|
||||
.t-date-picker.t-is-focused, .t-date-picker .t-input.t-is-focused,
|
||||
.t-select-input .t-input.t-input--focused {
|
||||
border-color: var(--td-brand-color) !important;
|
||||
box-shadow: none !important;
|
||||
-webkit-box-shadow: none !important;
|
||||
}
|
||||
.t-input:focus-within, .t-textarea:focus-within, .t-date-picker:focus-within, .t-select-input:focus-within {
|
||||
border-color: var(--td-brand-color) !important;
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid CanvasText; outline-offset: 1px; }
|
||||
}
|
||||
#root { height: 100%; min-height: 100vh; }
|
||||
.tn-route-error { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: var(--tn-page); }
|
||||
.tn-route-error-panel { width: min(420px, 100%); padding: 30px; border: 1px solid var(--tn-border); border-radius: 4px; background: var(--tn-surface); box-shadow: 0 14px 36px rgba(11, 58, 136, .08); }
|
||||
.tn-route-error-panel h1 { margin: 0; color: var(--tn-text); font-size: 22px; }
|
||||
.tn-route-error-panel p { margin: 10px 0 20px; color: var(--tn-text-secondary); overflow-wrap: anywhere; }
|
||||
.t-notification { width: min(360px, calc(100vw - 32px)); max-width: calc(100vw - 32px); border: 1px solid var(--tn-border); border-left-width: 1px; border-radius: 4px; box-shadow: 0 10px 26px rgba(11, 58, 136, .14); }
|
||||
/* TDesign mounts this wrapper directly under body. Keep one viewport inset
|
||||
at every breakpoint so notices remain fully visible while the page resizes. */
|
||||
.t-notification__show--top-right { right: 16px; }
|
||||
.t-notification__content { color: var(--tn-text); }
|
||||
|
||||
.tn-layout { width: 100%; height: 100%; min-height: 100vh; display: flex; flex-direction: row !important; background: var(--tn-page); overflow: hidden; }
|
||||
.tn-desktop-menu { position: relative; top: 0; z-index: 30; display: block; width: 232px; height: 100%; flex: 0 0 232px; min-height: 100%; overflow: hidden; border-right: 1px solid var(--tn-border); background: var(--tn-surface); transition: width .22s ease, flex-basis .22s ease, opacity .22s ease; }
|
||||
.tn-desktop-menu.is-hidden { width: 0; flex-basis: 0; border-right-width: 0; opacity: 0; pointer-events: none; }
|
||||
.tn-desktop-menu > .t-default-menu { width: 232px !important; height: 100%; min-height: 100%; border-right: 0; transition: width .22s ease; }
|
||||
.tn-desktop-menu.is-hidden > .t-default-menu { width: 0 !important; }
|
||||
.tn-desktop-menu > .t-default-menu .t-menu { width: 100% !important; overflow-x: hidden !important; overflow-y: auto; }
|
||||
.tn-desktop-menu .t-menu__logo, .tn-mobile-drawer .t-menu__logo { height: 64px; min-height: 64px; border-bottom: 1px solid var(--tn-border); border-top: 0; }
|
||||
.tn-layout-main { display: flex; height: 100%; min-width: 0; min-height: 0; flex: 1; flex-direction: column; overflow: hidden; }
|
||||
.tn-header { position: relative; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 64px; flex: 0 0 64px; min-width: 0; padding: 0 24px; border-bottom: 1px solid var(--tn-border); background: rgba(255,255,255,.96); }
|
||||
.tn-header-leading, .tn-header-actions { min-width: 0; }
|
||||
.tn-header-leading { flex: 1 1 auto; }
|
||||
.tn-header-actions { flex: 0 1 auto; }
|
||||
.tn-header-menu-toggle { display: inline-flex; color: var(--tn-blue-700); }
|
||||
.tn-header-nav-label { display: none; min-width: 0; color: var(--tn-blue-700); font-size: 14px; font-weight: 650; white-space: nowrap; }
|
||||
.tn-header-user { display: inline-block; min-width: 0; max-width: 180px; overflow: hidden; color: var(--tn-text-secondary); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tn-user-menu-trigger { display: inline-flex; max-width: min(240px, 42vw); min-height: 40px; align-items: center; gap: 8px; padding: 4px 8px; border: 1px solid transparent; border-radius: 4px; color: var(--tn-text); }
|
||||
.tn-user-menu-trigger .t-button__text { display: inline-flex; min-width: 0; align-items: center; gap: 8px; }
|
||||
.tn-user-menu-trigger:hover, .tn-user-menu-trigger.t-is-hover { border-color: var(--tn-border); background: #f7f9fc; }
|
||||
.tn-user-menu-trigger:focus-visible { border-color: var(--td-brand-color-3); }
|
||||
.tn-user-menu-trigger .t-button__suffix { display: inline-flex; align-items: center; color: var(--tn-text-secondary); }
|
||||
.tn-user-avatar { flex: 0 0 32px; color: #fff; background: var(--tn-blue-700); font-size: 13px; font-weight: 700; }
|
||||
.tn-user-dropdown { min-width: 184px; padding: 4px; border: 1px solid var(--tn-border); border-radius: 4px; box-shadow: 0 12px 28px rgba(8, 47, 118, .16); }
|
||||
.tn-user-dropdown .t-dropdown__item { min-height: 38px; border-radius: 3px; }
|
||||
.tn-user-dropdown-button { display: block; width: 100%; min-height: 30px; padding: 0; border: 0; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; }
|
||||
.tn-user-dropdown-button:focus-visible { border-radius: 2px; outline: 2px solid var(--td-brand-color); outline-offset: 1px; }
|
||||
.tn-user-dropdown-identity { display: grid; gap: 3px; margin: 2px 4px 6px; padding: 8px 9px 10px; border-bottom: 1px solid var(--tn-border-subtle); }
|
||||
.tn-user-dropdown-identity strong { color: var(--tn-text); font-size: 13px; }
|
||||
.tn-user-dropdown-identity span { overflow: hidden; color: var(--tn-text-secondary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tn-menu-logo { display: flex; width: 100%; height: 64px; align-items: center; justify-content: center; color: var(--tn-navy-900); }
|
||||
.tn-logo-full { font-size: 18px; font-weight: 750; letter-spacing: .01em; }
|
||||
.tn-logo-short { color: var(--tn-blue-700); font-size: 16px; font-weight: 800; letter-spacing: .04em; }
|
||||
.tn-menu-icon { flex: 0 0 20px; width: 20px; height: 20px; margin-right: 10px; }
|
||||
.tn-desktop-menu .t-menu__item, .tn-mobile-drawer .t-menu__item { width: 100%; column-gap: 0; }
|
||||
.tn-desktop-menu .t-menu__item .tn-menu-icon, .tn-mobile-drawer .t-menu__item .tn-menu-icon { margin-right: 10px; }
|
||||
.tn-desktop-menu .t-menu__item.t-is-active:not(.t-is-opened), .tn-mobile-drawer .t-menu__item.t-is-active:not(.t-is-opened) { background: var(--td-brand-color-1); color: var(--td-brand-color); }
|
||||
.tn-desktop-menu .t-menu__item.t-is-active:not(.t-is-opened) .t-icon, .tn-mobile-drawer .t-menu__item.t-is-active:not(.t-is-opened) .t-icon { color: var(--td-brand-color); }
|
||||
.tn-desktop-menu .t-menu--collapsed .tn-menu-icon { margin-right: 0; }
|
||||
.tn-content { min-width: 0; min-height: 0; flex: 1; overflow-x: hidden; overflow-y: auto; background: var(--tn-page); }
|
||||
.tn-mobile-drawer { display: none; }
|
||||
.tn-mobile-drawer .t-drawer__body { padding: 0; }
|
||||
.tn-mobile-drawer-header { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 8px; color: var(--tn-text); font-size: 14px; font-weight: 700; }
|
||||
.tn-mobile-drawer-header .t-button { color: var(--tn-text-secondary); }
|
||||
.tn-mobile-menu-shell { height: 100%; }
|
||||
.tn-mobile-drawer .t-default-menu { width: 216px !important; min-height: 100%; }
|
||||
.tn-mobile-drawer .t-default-menu .t-menu { width: 100% !important; min-height: 100%; border-right: 0; overflow-x: hidden !important; }
|
||||
|
||||
.tn-shell { min-height: 100vh; background: var(--tn-page); }
|
||||
.tn-content { min-width: 0; padding: 28px 32px 40px; }
|
||||
.tn-content-inner { width: min(1480px, 100%); margin: 0 auto; }
|
||||
.tn-page { min-width: 0; }
|
||||
.tn-page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 20px; }
|
||||
.tn-eyebrow { margin-bottom: 4px; color: var(--tn-text-secondary); font-size: 12px; letter-spacing: .02em; }
|
||||
.tn-page-title { margin: 0; color: var(--tn-text); font-size: 26px; line-height: 1.2; font-weight: 700; }
|
||||
.tn-page-subtitle { margin: 7px 0 0; color: var(--tn-text-secondary); font-size: 13px; }
|
||||
.tn-page-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.tn-page-actions .t-button, .tn-toolbar .t-button { min-height: 36px; }
|
||||
.tn-flat-section { border: 1px solid var(--tn-border); border-radius: 4px; background: var(--tn-surface); }
|
||||
.tn-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 12px; border: 1px solid var(--tn-border); border-radius: 4px; background: var(--tn-surface); }
|
||||
.tn-expense-month-controls, .tn-expense-search-controls, .tn-expense-filter-actions { display: flex; min-width: 0; align-items: center; gap: 8px; }
|
||||
.tn-expense-month-controls .tn-month-picker { width: 150px; min-width: 0; }
|
||||
.tn-expense-search-controls { min-width: 280px; flex: 1 1 340px; }
|
||||
.tn-expense-search-controls .tn-expense-search { min-width: 0; flex: 1; }
|
||||
.tn-expense-filter-actions { flex: 0 0 auto; }
|
||||
.tn-audit-toolbar { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(150px, 190px) auto; align-items: center; }
|
||||
.tn-audit-toolbar > .t-input__wrap, .tn-audit-toolbar > .t-select__wrap { width: auto; min-width: 0; }
|
||||
.tn-audit-toolbar > .t-input__wrap { min-width: 0; }
|
||||
.tn-table-wrap { overflow: hidden; border: 1px solid var(--tn-border); border-radius: 4px; background: var(--tn-surface); }
|
||||
.tn-table-wrap .t-table { font-variant-numeric: tabular-nums; }
|
||||
.tn-summary { display: flex; align-items: baseline; gap: 16px; padding: 16px 2px 12px; color: var(--tn-text-secondary); }
|
||||
.tn-summary strong { color: var(--tn-text); font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.tn-summary .t-button { margin-left: auto; }
|
||||
.tn-amount, .tn-code { font-variant-numeric: tabular-nums; font-family: "Plus Jakarta Sans Variable", monospace; }
|
||||
.tn-amount { color: var(--tn-text); font-weight: 700; white-space: nowrap; }
|
||||
.tn-muted { color: var(--tn-text-muted); }
|
||||
.tn-note { display: block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tn-status { display: inline-flex; align-items: center; min-height: 24px; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
||||
.tn-status-unreimbursed { color: var(--tn-warning); background: #fff7e6; }
|
||||
.tn-status-reimbursed { color: var(--tn-success); background: #eaf7ef; }
|
||||
.tn-status-disabled { color: var(--tn-text-secondary); background: #eef1f5; }
|
||||
.tn-missing { color: var(--tn-danger); font-size: 12px; }
|
||||
.tn-action-group { display: inline-flex; align-items: center; gap: 2px; }
|
||||
.tn-action-group .t-button { color: var(--tn-text-secondary); }
|
||||
.tn-action-group .t-button:not(.t-button--theme-danger):hover { color: var(--tn-blue-700); background: var(--tn-blue-100); }
|
||||
.tn-action-group .t-button.t-button--theme-danger:hover { color: var(--tn-danger); background: #fff0f0; }
|
||||
.tn-empty { display: grid; min-height: 260px; place-items: center; align-content: center; gap: 10px; padding: 48px 20px; color: var(--tn-text-muted); text-align: center; }
|
||||
.tn-page-loading { display: grid; min-width: 0; min-height: 100%; place-items: center; align-content: center; gap: 10px; padding: 32px 20px; color: var(--tn-text-muted); text-align: center; }
|
||||
.tn-empty p { margin: 0; color: var(--tn-text-secondary); }
|
||||
.tn-selection { display: flex; align-items: center; gap: 14px; padding: 10px 12px; margin: 10px 0; border-left: 3px solid var(--tn-success); background: #eff7f2; color: #24543f; }
|
||||
.tn-selection .t-button { margin-left: auto; }
|
||||
.tn-auth-shell { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: var(--tn-page); }
|
||||
.tn-auth-panel { width: min(420px, 100%); padding: 38px 36px; border: 1px solid var(--tn-border); border-radius: 4px; background: var(--tn-surface); box-shadow: 0 14px 36px rgba(11, 58, 136, .08); }
|
||||
.tn-login-page { position: relative; z-index: 0; display: flex; height: 100vh; min-height: 100svh; flex-direction: column; overflow-x: hidden; overflow-y: auto; color: var(--tn-text); background-color: #f5f9ff; background-image: url("../assets/login-background-tech.webp"); background-position: center center; background-size: cover; animation: tn-auth-page-in .24s ease-out both; }
|
||||
.tn-login-page::before { position: absolute; z-index: 0; inset: 0; background: linear-gradient(90deg, rgba(247, 250, 255, .9) 0%, rgba(247, 250, 255, .72) 38%, rgba(247, 250, 255, .16) 72%, rgba(247, 250, 255, 0) 100%); content: ""; pointer-events: none; }
|
||||
.tn-login-header { position: relative; z-index: 1; display: flex; height: 64px; flex: 0 0 64px; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid rgba(23, 92, 211, .18); background: rgba(255, 255, 255, .68); backdrop-filter: blur(8px); }
|
||||
.tn-login-brand-group { display: flex; min-width: 0; align-items: baseline; gap: 14px; }
|
||||
.tn-login-wordmark { color: var(--tn-navy-900); font-size: 19px; font-weight: 750; letter-spacing: .01em; white-space: nowrap; }
|
||||
.tn-login-environment { color: var(--tn-text-secondary); font-size: 13px; white-space: nowrap; }
|
||||
.tn-login-operations { color: var(--tn-text-secondary); }
|
||||
.tn-login-operation { color: var(--tn-text-secondary); }
|
||||
.tn-login-operation:hover { color: var(--td-brand-color); background: var(--td-brand-color-1); }
|
||||
.tn-login-container { position: absolute; z-index: 1; top: 22%; left: 5%; min-height: 500px; line-height: 22px; }
|
||||
.tn-login-heading { margin-bottom: 0; }
|
||||
.tn-login-title { margin: 4px 0 0; color: var(--tn-navy-950); font-size: 36px; font-weight: 700; line-height: 44px; }
|
||||
.tn-login-title-brand { color: var(--tn-blue-700); }
|
||||
.tn-login-subtitle { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; color: var(--tn-text-secondary); font-size: 14px; }
|
||||
.tn-login-subtitle-divider { color: var(--tn-text-muted); }
|
||||
.tn-login-form { width: 400px; max-width: calc(100vw - 48px); margin-top: 48px; }
|
||||
.tn-login-form .t-form__item { margin-bottom: 16px; }
|
||||
.tn-login-form .t-input { border-color: var(--tn-border); background: rgba(255, 255, 255, .96); }
|
||||
.tn-login-form .t-input:hover { border-color: var(--td-brand-color-5); }
|
||||
.tn-login-form .t-input.t-is-focused { border-color: var(--td-brand-color); box-shadow: none; }
|
||||
.tn-login-form .t-input__suffix { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.tn-login-suffix-actions { display: inline-flex; align-items: center; gap: 3px; }
|
||||
.tn-login-suffix-placeholder { display: none; }
|
||||
.tn-login-clear { display: inline-flex; width: 22px; height: 22px; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 3px; color: var(--tn-text-muted); background: transparent; cursor: pointer; }
|
||||
.tn-login-clear:hover { color: var(--td-brand-color); background: var(--td-brand-color-1); }
|
||||
.tn-login-alert { margin-bottom: 14px; border: 1px solid currentColor; border-left-width: 1px; border-radius: 4px; background: rgba(255, 255, 255, .9); }
|
||||
.tn-login-options { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 10px; color: var(--tn-text-secondary); font-size: 14px; }
|
||||
.tn-login-options .t-checkbox { flex: 0 0 auto; }
|
||||
.tn-login-options > span { color: var(--tn-text-secondary); white-space: nowrap; }
|
||||
.tn-login-submit { margin-top: 48px; margin-bottom: 0 !important; }
|
||||
.tn-login-note { margin-top: 24px; color: var(--tn-text-secondary); font-size: 12px; }
|
||||
.tn-login-note .t-typography { color: inherit; font-size: inherit; }
|
||||
.tn-login-dot { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: var(--tn-blue-700); }
|
||||
.tn-login-input-icon, .tn-login-prefix-icon { color: var(--tn-text-secondary); }
|
||||
.tn-login-password-toggle { display: inline-flex; width: 24px; height: 24px; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 3px; color: var(--tn-text-secondary); background: transparent; cursor: pointer; }
|
||||
.tn-login-password-toggle:hover { color: var(--td-brand-color); background: var(--td-brand-color-1); }
|
||||
.tn-login-password-toggle:focus-visible { outline: 2px solid var(--td-brand-color-3); outline-offset: 1px; }
|
||||
.tn-login-footer { position: absolute; z-index: 1; bottom: 64px; left: 5%; color: var(--tn-text-secondary); font-size: 14px; }
|
||||
.tn-dialog-note { margin: 12px 0 0; color: var(--tn-text-secondary); font-size: 13px; }
|
||||
.tn-password-container { width: min(920px, calc(100vw - 10%)); margin-top: clamp(76px, 16vh, 150px); }
|
||||
.tn-password-page .tn-password-card { max-width: 920px; margin: 4px auto 0; }
|
||||
.tn-password-card { overflow: hidden; border: 1px solid var(--tn-border); border-radius: 4px; background: var(--tn-surface); box-shadow: 0 10px 26px rgba(11, 58, 136, .07); }
|
||||
.tn-password-card-head { display: flex; align-items: flex-start; gap: 12px; padding: 22px 24px 18px; border-bottom: 1px solid var(--tn-border-subtle); }
|
||||
.tn-password-card-icon { display: inline-grid; width: 38px; height: 38px; flex: 0 0 38px; place-items: center; border: 1px solid var(--td-brand-color-2); border-radius: 4px; color: var(--td-brand-color); background: var(--td-brand-color-1); }
|
||||
.tn-password-card-head h2 { margin: 0; color: var(--tn-text); font-size: 19px; line-height: 1.3; }
|
||||
.tn-password-card-head p { margin: 5px 0 0; color: var(--tn-text-secondary); font-size: 13px; line-height: 1.45; }
|
||||
.tn-password-card-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, .7fr); gap: 28px; padding: 22px 24px 24px; }
|
||||
.tn-password-form { width: 100%; margin: 0; }
|
||||
.tn-password-form .t-form__item { margin-bottom: 16px; }
|
||||
.tn-password-form .t-form__item:last-child { margin-bottom: 0; }
|
||||
.tn-password-form:not(.t-form-inline) .t-form__item--last { margin: 0 0 16px; }
|
||||
.tn-password-form:not(.t-form-inline) > .t-form__item.tn-password-submit { margin-top: 28px; }
|
||||
.tn-password-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.tn-password-actions .tn-password-submit-button { min-width: 112px; }
|
||||
.tn-password-alert { margin: 16px 24px 0; }
|
||||
.tn-password-guidance { align-self: start; padding: 16px; border: 1px solid var(--tn-border-subtle); border-radius: 4px; background: #f8fafc; }
|
||||
.tn-password-guidance-title { display: flex; align-items: center; gap: 7px; color: var(--tn-navy-900); font-size: 13px; font-weight: 700; }
|
||||
.tn-password-guidance > p { margin: 9px 0 12px; color: var(--tn-text-secondary); font-size: 12px; line-height: 1.5; }
|
||||
.tn-password-guidance ul { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
||||
.tn-password-guidance li { display: flex; align-items: center; gap: 7px; color: var(--tn-text-secondary); font-size: 12px; }
|
||||
.tn-password-guidance li svg { color: var(--tn-text-muted); }
|
||||
.tn-password-guidance li.is-valid { color: var(--tn-success); }
|
||||
.tn-password-guidance li.is-valid svg { color: var(--tn-success); }
|
||||
.tn-file-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; padding: 7px 9px; border: 1px solid var(--tn-border-subtle); border-radius: 3px; background: #f8fafc; }
|
||||
.tn-file-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tn-file-name small { margin-left: 6px; color: var(--tn-text-muted); }
|
||||
.tn-detail-amount { margin: 0 0 10px; color: var(--tn-navy-900); font-size: 32px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.tn-detail-note { white-space: pre-wrap; word-break: break-word; }
|
||||
.tn-preview { display: grid; min-height: 300px; max-height: 68vh; place-items: center; overflow: auto; background: #f2f5f9; border-radius: 3px; }
|
||||
.tn-preview img { display: block; max-width: 100%; max-height: 64vh; object-fit: contain; }
|
||||
.tn-preview iframe { width: 100%; height: min(64vh, calc(100dvh - 150px)); min-height: 240px; border: 0; background: #fff; }
|
||||
.tn-section-title { margin: 20px 0 12px; padding-top: 18px; border-top: 1px solid var(--tn-border-subtle); color: var(--tn-text); font-size: 14px; font-weight: 700; }
|
||||
.tn-update-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 14px; }
|
||||
.tn-update-block { padding: 0; }
|
||||
.tn-update-block .t-card__body { display: grid; grid-template-columns: auto 1fr; align-items: center; column-gap: 10px; row-gap: 3px; padding: 18px; }
|
||||
.tn-update-block .t-card__body > svg { grid-row: span 3; color: var(--tn-blue-700); }
|
||||
.tn-update-value { color: var(--tn-navy-900); font-size: 23px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.tn-update-release { padding: 0; margin-bottom: 14px; }
|
||||
.tn-update-release .t-card__body { padding: 20px; }
|
||||
.tn-update-release-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
|
||||
.tn-update-release h2 { margin: 3px 0 5px; color: var(--tn-text); font-size: 21px; }
|
||||
.tn-release-notes { margin: 16px 0; padding: 12px 14px; border-left: 3px solid var(--td-brand-color-3); background: #f7f9fc; color: var(--tn-text-secondary); font-size: 13px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.tn-release-notes .tn-eyebrow { display: block; margin-bottom: 4px; color: var(--tn-navy-900); }
|
||||
.tn-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 18px 0; padding: 14px 0; border-top: 1px solid var(--tn-border-subtle); border-bottom: 1px solid var(--tn-border-subtle); }
|
||||
.tn-facts span { display: block; margin-bottom: 4px; color: var(--tn-text-secondary); font-size: 12px; }
|
||||
.tn-facts strong { overflow-wrap: anywhere; color: #344054; font-size: 14px; }
|
||||
.tn-chart-section { margin-top: 14px; padding: 18px; }
|
||||
.tn-chart-title { margin: 0 0 4px; color: var(--tn-text); font-size: 14px; font-weight: 700; }
|
||||
.tn-chart-subtitle { margin: 0 0 12px; color: var(--tn-text-secondary); font-size: 12px; }
|
||||
.tn-dashboard-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 14px; }
|
||||
.tn-dashboard-stat { min-width: 0; transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease; }
|
||||
.tn-dashboard-stat:hover { border-color: var(--td-brand-color-3); box-shadow: 0 8px 20px rgba(23, 92, 211, .08); transform: translateY(-1px); }
|
||||
.tn-dashboard-stat .t-card__body { display: flex; min-height: 112px; align-items: center; gap: 12px; padding: 18px; }
|
||||
.tn-dashboard-stat .t-card__body { justify-content: space-between; }
|
||||
.tn-dashboard-stat .t-statistic__content { font-weight: 700; }
|
||||
.tn-dashboard-stat-icon { display: inline-grid; width: 38px; height: 38px; flex: 0 0 38px; place-items: center; border: 1px solid var(--td-brand-color-2); border-radius: 4px; color: var(--td-brand-color); background: var(--td-brand-color-1); }
|
||||
.tn-dashboard-stat .t-statistic { min-width: 0; }
|
||||
.tn-dashboard-stat .t-statistic__title { margin-bottom: 5px; color: var(--tn-text-secondary); font-size: 12px; }
|
||||
.tn-dashboard-stat .t-statistic__content { color: var(--tn-text); font-family: "Plus Jakarta Sans Variable", sans-serif; font-size: 23px; font-variant-numeric: tabular-nums; }
|
||||
.tn-dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, .85fr); gap: 14px; margin-bottom: 14px; }
|
||||
.tn-dashboard-chart, .tn-dashboard-progress, .tn-dashboard-recent { min-width: 0; }
|
||||
.tn-dashboard-card-title { display: grid; min-width: 0; gap: 3px; }
|
||||
.tn-dashboard-card-title > span { color: var(--tn-text); font-weight: 600; line-height: 1.25; }
|
||||
.tn-dashboard-card-title > small { color: var(--tn-text-secondary); font-size: 12px; font-weight: 400; line-height: 1.35; }
|
||||
.tn-dashboard-chart .t-card__body { min-height: 300px; padding: 12px 18px 18px; }
|
||||
.tn-dashboard-chart .echarts-for-react { min-width: 0; }
|
||||
.tn-dashboard-chart-empty { display: flex; min-height: 286px; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: var(--tn-text-secondary); text-align: center; }
|
||||
.tn-dashboard-chart-empty span { font-size: 14px; font-weight: 600; }
|
||||
.tn-dashboard-chart-empty small { color: var(--tn-text-muted); font-size: 12px; }
|
||||
.tn-dashboard-progress .t-card__body { display: flex; min-height: 300px; flex-direction: column; align-items: center; justify-content: center; padding: 18px; }
|
||||
.tn-progress-ring { display: grid; width: 176px; height: 176px; flex: 0 0 176px; place-items: center; border-radius: 50%; transition: background .35s ease; }
|
||||
.tn-progress-ring > div { display: flex; width: 128px; height: 128px; flex-direction: column; align-items: center; justify-content: center; border-radius: 50%; background: var(--tn-surface); box-shadow: inset 0 0 0 1px var(--tn-border-subtle); }
|
||||
.tn-progress-ring strong { color: var(--tn-navy-900); font-family: "Plus Jakarta Sans Variable", sans-serif; font-size: 28px; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.tn-progress-ring span { margin-top: 5px; color: var(--tn-text-secondary); font-size: 12px; }
|
||||
.tn-dashboard-progress-legend { display: grid; width: min(260px, 100%); gap: 8px; margin-top: 18px; }
|
||||
.tn-dashboard-progress-legend > span { display: flex; align-items: center; gap: 7px; color: var(--tn-text-secondary); font-size: 12px; }
|
||||
.tn-dashboard-progress-legend i { display: inline-block; width: 8px; height: 8px; flex: 0 0 8px; border-radius: 2px; }
|
||||
.tn-dashboard-progress-legend i.is-blue { background: var(--tn-blue-700); }
|
||||
.tn-dashboard-progress-legend i.is-green { background: var(--tn-success); }
|
||||
.tn-dashboard-progress-legend strong { margin-left: auto; color: var(--tn-text); font-family: "Plus Jakarta Sans Variable", sans-serif; font-variant-numeric: tabular-nums; }
|
||||
.tn-dashboard-recent { margin-bottom: 14px; }
|
||||
.tn-dashboard-recent .t-card__body { padding: 0; }
|
||||
.tn-dashboard-table { border: 0; border-radius: 0; }
|
||||
.tn-dashboard-table .t-table { min-width: 620px; }
|
||||
.tn-dashboard-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.tn-dashboard-month-control, .tn-dashboard-secondary-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.tn-dashboard-month-control .t-date-picker { height: 36px; }
|
||||
.tn-dashboard-month-control button.t-button { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }
|
||||
.tn-dashboard-month-control .tn-dashboard-month { width: 148px; flex: 0 1 148px; }
|
||||
.tn-dashboard-secondary-actions .tn-dashboard-refresh, .tn-dashboard-secondary-actions .tn-dashboard-view { flex: 0 0 auto; }
|
||||
.tn-layout { animation: tn-app-enter .24s ease-out both; }
|
||||
.tn-page-transition { animation: tn-page-in .18s ease-out both; }
|
||||
.tn-page-actions .t-button { min-height: 36px; }
|
||||
.expenses-toolbar { display: flex; align-items: center; gap: 8px; }
|
||||
.expenses-toolbar .t-button--shape-square { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }
|
||||
.tn-month-picker { width: 148px; }
|
||||
.tn-segmented { display: inline-flex; align-items: center; min-height: 36px; }
|
||||
.tn-segmented .t-radio-button { min-width: 76px; height: 32px; min-height: 32px; padding: 0 16px; line-height: 22px; }
|
||||
.tn-segmented .t-radio-button.t-is-checked { background: var(--td-brand-color); border-color: var(--td-brand-color); color: #fff; }
|
||||
.tn-expense-search { flex: 1 1 260px; min-width: 190px; }
|
||||
.expenses-head { margin-bottom: 16px; }
|
||||
.tn-table-wrap { min-width: 0; overflow-x: auto; }
|
||||
.tn-table-wrap .t-table { min-width: 780px; }
|
||||
.tn-audit-table .tn-code { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
|
||||
.tn-alert { margin: 12px 0; }
|
||||
.tn-inline-loading { display: flex; align-items: center; gap: 7px; min-height: 32px; margin: 0 0 10px; color: var(--tn-text-secondary); font-size: 12px; }
|
||||
.tn-inline-error, .tn-inline-info { display: flex; align-items: center; gap: 7px; margin-top: 10px; padding: 9px 11px; border-radius: 3px; font-size: 13px; }
|
||||
.tn-inline-error { color: #a33a3a; background: #fff0f0; }
|
||||
.tn-inline-info { color: #246044; background: #eaf7ef; }
|
||||
.tn-restart-countdown { margin-top: 10px; color: var(--tn-warning); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.tn-update-explainer { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 14px; padding: 13px 15px; border: 1px solid var(--td-brand-color-2); border-radius: 4px; color: var(--tn-navy-900); background: var(--td-brand-color-1); }
|
||||
.tn-update-explainer > svg { flex: 0 0 auto; margin-top: 1px; color: var(--td-brand-color); }
|
||||
.tn-update-explainer strong { display: block; font-size: 13px; }
|
||||
.tn-update-explainer p { margin: 5px 0 0; color: var(--tn-text-secondary); font-size: 12px; line-height: 1.55; }
|
||||
.tn-secret { display: flex; align-items: center; gap: 10px; }
|
||||
.tn-secret code { flex: 1; min-width: 0; padding: 10px 12px; border: 1px solid var(--tn-border); border-radius: 3px; background: #f7f9fc; color: var(--tn-navy-900); font-family: "Plus Jakarta Sans Variable", monospace; font-size: 16px; overflow-wrap: anywhere; }
|
||||
.tn-table-more { display: flex; justify-content: center; padding: 16px 0; }
|
||||
.tn-update-block small { color: var(--tn-text-secondary); }
|
||||
.tn-progress { height: 7px; margin: 18px 0 10px; overflow: hidden; border-radius: 999px; background: #e8edf3; }
|
||||
.tn-progress span { display: block; height: 100%; border-radius: inherit; background: var(--tn-blue-700); transition: width .35s ease; }
|
||||
.text-success { color: var(--tn-success); }
|
||||
.text-danger { color: var(--tn-danger); }
|
||||
.expense-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.tn-form-field { display: flex; flex-direction: column; gap: 7px; color: var(--tn-text); font-size: 13px; font-weight: 600; }
|
||||
.tn-form-field > small { color: var(--tn-text-secondary); font-size: 12px; font-weight: 400; }
|
||||
.tn-field-error { color: var(--tn-danger); font-size: 12px; font-weight: 400; }
|
||||
.tn-form-field .t-input, .tn-form-field .t-textarea, .tn-form-field .t-date-picker { width: 100%; }
|
||||
.invoice-missing-toggle { display: inline-flex; align-items: center; gap: 7px; color: var(--tn-text); font-size: 13px; }
|
||||
.tn-form-help { display: block; margin-top: -8px; color: var(--tn-text-secondary); font-size: 12px; }
|
||||
.expense-file-list { display: flex; flex-direction: column; gap: 6px; margin: -8px 0 0; padding: 0; list-style: none; }
|
||||
.expense-file-list li { display: flex; align-items: center; gap: 7px; min-width: 0; padding: 7px 9px; border: 1px solid var(--tn-border-subtle); border-radius: 3px; background: #f8fafc; color: var(--tn-text); font-size: 12px; }
|
||||
.expense-file-list li small { margin-left: auto; color: var(--tn-text-muted); }
|
||||
.expense-file-list li .t-button { flex: 0 0 auto; }
|
||||
.expense-error { display: flex; align-items: flex-start; gap: 7px; padding: 9px 11px; border-radius: 3px; color: var(--tn-danger); background: #fff0f0; font-size: 13px; }
|
||||
.tn-export-status { display: flex; align-items: center; gap: 8px; padding: 9px 11px; border: 1px solid var(--td-brand-color-2); border-radius: 3px; color: var(--tn-blue-700); background: var(--td-brand-color-1); font-size: 13px; }
|
||||
.tn-export-status span { flex: 1; min-width: 0; }
|
||||
.expense-detail { display: flex; flex-direction: column; gap: 14px; }
|
||||
.expense-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.expense-detail-head > strong { color: var(--tn-navy-900); font-family: "Plus Jakarta Sans Variable", monospace; font-size: 30px; font-variant-numeric: tabular-nums; }
|
||||
.expense-detail dl { display: grid; gap: 10px; margin: 0; }
|
||||
.expense-detail dl > div { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 10px; align-items: start; }
|
||||
.expense-detail dt { color: var(--tn-text-secondary); font-size: 12px; }
|
||||
.expense-detail dd { margin: 0; color: var(--tn-text); font-size: 13px; word-break: break-word; }
|
||||
.expense-detail h3 { display: flex; align-items: baseline; gap: 7px; margin: 10px 0 0; padding-top: 16px; border-top: 1px solid var(--tn-border-subtle); color: var(--tn-text); font-size: 14px; }
|
||||
.expense-detail h3 small { color: var(--tn-text-muted); font-size: 12px; font-weight: 400; }
|
||||
.expense-attachments { display: flex; flex-direction: column; gap: 6px; }
|
||||
.expense-attachment { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; padding: 8px 9px; border: 1px solid var(--tn-border-subtle); border-radius: 3px; background: #f8fafc; }
|
||||
.expense-attachment > span { display: flex; align-items: center; gap: 7px; min-width: 0; color: var(--tn-text); font-size: 12px; }
|
||||
.expense-attachment > span small { color: var(--tn-text-muted); white-space: nowrap; }
|
||||
.expense-attachment > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.expense-timeline { display: flex; flex-direction: column; gap: 8px; }
|
||||
.expense-timeline > div { display: grid; grid-template-columns: 155px minmax(0, 1fr) 90px; gap: 8px; align-items: baseline; padding: 8px 0; border-bottom: 1px solid var(--tn-border-subtle); font-size: 12px; }
|
||||
.expense-timeline span, .expense-timeline small { color: var(--tn-text-secondary); }
|
||||
.expense-timeline strong, .expense-timeline small { min-width: 0; overflow-wrap: anywhere; word-break: break-word; }
|
||||
.expense-timeline strong { color: var(--tn-text); font-family: "Plus Jakarta Sans Variable", monospace; font-size: 12px; }
|
||||
.tn-spin, .spin { animation: tn-spin .9s linear infinite; }
|
||||
@keyframes tn-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@keyframes tn-page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes tn-app-enter { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes tn-auth-page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.t-dialog { border: 1px solid var(--tn-border); border-radius: 4px; box-shadow: 0 18px 42px rgba(8, 47, 118, .18); }
|
||||
.t-dialog__mask { background: var(--td-mask-active) !important; }
|
||||
.t-dialog__footer .t-button { min-width: 76px; }
|
||||
@media (max-width: 900px) {
|
||||
.tn-desktop-menu { display: none; }
|
||||
.tn-header { padding: 0 16px; }
|
||||
.tn-header-menu-toggle { display: inline-flex; }
|
||||
.tn-header-nav-label { display: inline-block; }
|
||||
.tn-mobile-drawer { display: block; }
|
||||
.tn-content { padding: 20px 16px 30px; }
|
||||
.tn-page-head { flex-direction: column; }
|
||||
.tn-page-actions { width: 100%; justify-content: flex-start; }
|
||||
.tn-audit-toolbar { grid-template-columns: minmax(0, 1fr); }
|
||||
.tn-dashboard-actions { width: 100%; justify-content: flex-start; }
|
||||
.tn-dashboard-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.tn-dashboard-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.tn-password-card-body { grid-template-columns: minmax(0, 1fr); gap: 20px; }
|
||||
.tn-password-guidance { order: 2; }
|
||||
.tn-update-release .tn-page-actions { justify-content: flex-start; }
|
||||
.expenses-toolbar { align-items: stretch; }
|
||||
.expenses-toolbar .tn-expense-search { flex-basis: 100%; }
|
||||
.tn-update-grid { grid-template-columns: 1fr; }
|
||||
.tn-login-header { padding: 0 24px; }
|
||||
.tn-login-container { left: 24px; }
|
||||
.tn-login-footer { left: 24px; }
|
||||
}
|
||||
@media (min-width: 901px) {
|
||||
.tn-login-container.tn-password-container {
|
||||
/* The first-login form can grow beyond a short desktop viewport. Keep it
|
||||
in normal flow so the login canvas owns the scroll instead of clipping
|
||||
an absolutely positioned card. */
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
min-height: 0;
|
||||
margin: clamp(40px, 10vh, 88px) 5% 64px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 561px) and (max-width: 900px) {
|
||||
.tn-login-page { height: auto; min-height: 100svh; }
|
||||
.tn-login-container, .tn-password-container { position: relative; top: auto; left: auto; width: min(520px, calc(100% - 48px)); min-height: 0; margin: clamp(52px, 10vh, 96px) 24px 72px; }
|
||||
.tn-login-title { font-size: 32px; line-height: 40px; }
|
||||
.tn-login-form { width: 100%; max-width: 100%; }
|
||||
.tn-login-footer { display: none; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.tn-content-drawer .t-drawer__content-wrapper { width: 100vw !important; max-width: 100vw; }
|
||||
.tn-mobile-drawer .t-drawer__content-wrapper { width: 216px !important; max-width: 216px; }
|
||||
.t-drawer__body { min-width: 0; overflow-x: hidden; }
|
||||
.tn-auth-panel { padding: 30px 22px; }
|
||||
.tn-password-page .tn-password-card { margin-top: 0; }
|
||||
.tn-password-card-head { padding: 18px 16px 15px; }
|
||||
.tn-password-card-body { gap: 16px; padding: 18px 16px 20px; }
|
||||
.tn-password-alert { margin: 14px 16px 0; }
|
||||
.tn-password-actions { justify-content: stretch; }
|
||||
.tn-password-actions .t-button { flex: 1 1 0; min-width: 0; }
|
||||
.tn-password-actions .tn-password-submit-button { width: 100%; min-width: 112px; }
|
||||
.tn-password-guidance { padding: 14px; }
|
||||
.tn-page-title { font-size: 23px; }
|
||||
.tn-page-actions .t-button { flex: 0 1 auto; min-width: 0; }
|
||||
.expenses-head .tn-page-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; width: 100%; }
|
||||
.expenses-head .tn-page-actions > .t-checkbox { grid-column: 1 / -1; }
|
||||
.expenses-head .tn-page-actions > .t-button { width: 100%; min-width: 0; flex: none; }
|
||||
.tn-dashboard-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); width: 100%; gap: 8px; }
|
||||
.tn-dashboard-month-control { display: grid; grid-column: 1 / -1; grid-template-columns: 36px minmax(0, 1fr) 36px; width: 100%; gap: 8px; }
|
||||
.tn-dashboard-month-control .tn-dashboard-month { width: 100%; min-width: 0; flex: none; }
|
||||
.tn-dashboard-secondary-actions { display: grid; grid-column: 1 / -1; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
|
||||
.tn-dashboard-secondary-actions .tn-dashboard-refresh, .tn-dashboard-secondary-actions .tn-dashboard-view { width: 100%; min-width: 0; flex: none; }
|
||||
.tn-dashboard-stats { grid-template-columns: minmax(0, 1fr); }
|
||||
.tn-dashboard-stat .t-card__body { min-height: 92px; }
|
||||
.tn-dashboard-chart .t-card__body, .tn-dashboard-progress .t-card__body { min-height: 270px; }
|
||||
.tn-facts { grid-template-columns: 1fr; gap: 10px; }
|
||||
.tn-update-release { padding: 0; }
|
||||
.tn-update-release .t-card__body { padding: 16px; }
|
||||
.tn-update-release-head { flex-direction: column; }
|
||||
.expenses-toolbar { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.expenses-toolbar .tn-segmented, .expenses-toolbar .tn-expense-month-controls, .expenses-toolbar .tn-expense-search-controls, .expenses-toolbar .tn-expense-filter-actions { width: 100%; min-width: 0; }
|
||||
.expenses-toolbar .tn-segmented { display: flex; }
|
||||
.expenses-toolbar .tn-segmented .t-radio-button { min-width: 0; flex: 1 1 50%; }
|
||||
.expenses-toolbar .tn-segmented .t-radio-button__label { display: flex; justify-content: center; }
|
||||
.tn-expense-month-controls { display: grid; grid-template-columns: 36px minmax(0, 1fr) 36px; }
|
||||
.tn-expense-month-controls .tn-month-picker { width: 100%; }
|
||||
.tn-expense-search-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.tn-expense-filter-actions { justify-content: space-between; }
|
||||
.expense-timeline > div { grid-template-columns: minmax(0, 1fr) minmax(84px, 34%); gap: 4px 8px; align-items: start; }
|
||||
.expense-timeline > div > span { grid-column: 1 / -1; }
|
||||
.expense-timeline > div > strong { white-space: normal; }
|
||||
.expense-timeline > div > small { max-width: none; text-align: right; white-space: normal; }
|
||||
.tn-login-page { min-height: 100svh; background-position: 38% center; overflow-y: auto; }
|
||||
.tn-login-page::before { background: linear-gradient(180deg, rgba(247, 250, 255, .94) 0%, rgba(247, 250, 255, .82) 54%, rgba(247, 250, 255, .42) 100%); }
|
||||
.tn-login-header { padding: 0 20px; }
|
||||
.tn-login-environment { display: none; }
|
||||
.tn-login-container, .tn-password-container { position: relative; top: auto; left: auto; width: calc(100% - 32px); min-height: 0; margin: 48px 16px 56px; }
|
||||
.tn-login-title { font-size: 29px; line-height: 36px; }
|
||||
.tn-login-form { width: 100%; max-width: 100%; }
|
||||
.tn-login-subtitle { gap: 5px; }
|
||||
.tn-login-footer { display: none; }
|
||||
.tn-header { gap: 8px; padding: 0 10px; }
|
||||
.tn-header-leading { gap: 4px !important; }
|
||||
.tn-header-actions { gap: 2px !important; }
|
||||
.tn-header-user { max-width: 72px; font-size: 12px; }
|
||||
.tn-header-actions .t-button { width: 36px; height: 36px; min-height: 36px; flex: 0 0 36px; }
|
||||
.tn-header-actions .tn-user-menu-trigger { width: auto; max-width: 168px; flex: 0 1 auto; padding-right: 5px; padding-left: 5px; }
|
||||
.tn-header-actions .tn-user-menu-trigger .t-button__suffix { display: none; }
|
||||
.tn-header-actions .tn-user-menu-trigger .tn-user-avatar { width: 30px; height: 30px; flex-basis: 30px; }
|
||||
.tn-header-actions .tn-user-menu-trigger .tn-header-user { max-width: 92px; }
|
||||
.tn-update-release .tn-page-actions { width: 100%; }
|
||||
.tn-update-release .tn-page-actions .t-button { width: 100%; }
|
||||
.tn-table-wrap:not(.tn-dashboard-table):not(.tn-audit-table) .t-table th:first-child,
|
||||
.tn-table-wrap:not(.tn-dashboard-table):not(.tn-audit-table) .t-table td:first-child { position: sticky; left: 0; z-index: 2; background: var(--tn-surface); box-shadow: 1px 0 0 var(--tn-border-subtle); }
|
||||
.tn-table-wrap:not(.tn-dashboard-table):not(.tn-audit-table) .t-table thead th:first-child { z-index: 3; }
|
||||
.tn-table-wrap:not(.tn-dashboard-table):not(.tn-audit-table) .t-table th:last-child,
|
||||
.tn-table-wrap:not(.tn-dashboard-table):not(.tn-audit-table) .t-table td:last-child { position: sticky; right: 0; z-index: 2; background: var(--tn-surface); box-shadow: -1px 0 0 var(--tn-border-subtle); }
|
||||
.tn-table-wrap:not(.tn-dashboard-table):not(.tn-audit-table) .t-table thead th:last-child { z-index: 3; }
|
||||
.tn-login-header { backdrop-filter: none; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.tn-preview { min-height: 0; max-height: calc(100dvh - 112px); }
|
||||
.tn-preview iframe { height: calc(100dvh - 160px); min-height: 0; }
|
||||
}
|
||||
@media (min-width: 561px) and (max-height: 680px) {
|
||||
/* The Starter layout positions the form absolutely. Give short desktop
|
||||
windows a scrollable canvas so the lower fields are never clipped. */
|
||||
.tn-login-page { height: auto; min-height: 720px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export type AdminStatus = "active" | "disabled";
|
||||
|
||||
export interface Admin {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
status: AdminStatus;
|
||||
mustChangePassword: boolean;
|
||||
version: number;
|
||||
createdAt: number;
|
||||
lastLoginAt: number | null;
|
||||
disabledAt: number | null;
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
initialized: boolean;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
admin: Admin;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordInput {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
requestId?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
const DEFAULT_TIMEZONE = "Asia/Shanghai";
|
||||
let appTimezone = DEFAULT_TIMEZONE;
|
||||
|
||||
export function getAppTimezone(): string {
|
||||
return appTimezone;
|
||||
}
|
||||
|
||||
export function setAppTimezone(timezone: string): void {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en", { timeZone: timezone }).format();
|
||||
appTimezone = timezone;
|
||||
} catch {
|
||||
appTimezone = DEFAULT_TIMEZONE;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateTime(value: Date | number | string, timezone = appTimezone): string {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: timezone,
|
||||
}).format(value instanceof Date || typeof value === "number" ? new Date(value) : new Date(value));
|
||||
}
|
||||
|
||||
export const formatDate = formatDateTime;
|
||||
export const dateText = formatDateTime;
|
||||
|
||||
export function toDateInputValue(value: Date | number | string, timezone = appTimezone): string {
|
||||
const date = value instanceof Date || typeof value === "number" ? new Date(value) : new Date(value);
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(date);
|
||||
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${values.year}-${values.month}-${values.day}T${values.hour}:${values.minute}`;
|
||||
}
|
||||
|
||||
export const formatDateTimeInput = toDateInputValue;
|
||||
|
||||
export function fromDateInput(value: string, timezone = appTimezone): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$/.exec(value.trim());
|
||||
if (!match) throw new Error("请选择有效的支付日期和时间");
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const hour = Number(match[4]);
|
||||
const minute = Number(match[5]);
|
||||
const calendarProbe = new Date(0);
|
||||
calendarProbe.setUTCFullYear(year, month - 1, day);
|
||||
calendarProbe.setUTCHours(0, 0, 0, 0);
|
||||
if (month < 1 || month > 12 || day < 1 || day > 31 || calendarProbe.getUTCFullYear() !== year || calendarProbe.getUTCMonth() !== month - 1 || calendarProbe.getUTCDate() !== day || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
throw new Error("支付时间无效");
|
||||
}
|
||||
|
||||
// Interpret the form value as a wall-clock time in the configured zone.
|
||||
const wallUtc = Date.UTC(year, month - 1, day, hour, minute);
|
||||
const observedParts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(new Date(wallUtc));
|
||||
const observed = Object.fromEntries(observedParts.map((part) => [part.type, part.value]));
|
||||
const observedUtc = Date.UTC(Number(observed.year), Number(observed.month) - 1, Number(observed.day), Number(observed.hour), Number(observed.minute));
|
||||
return new Date(wallUtc + wallUtc - observedUtc).toISOString();
|
||||
}
|
||||
|
||||
export const dateFromInput = fromDateInput;
|
||||
|
||||
export function currentMonth(timezone = appTimezone): string {
|
||||
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
}).formatToParts(new Date()).map((part) => [part.type, part.value]));
|
||||
return `${parts.year}-${parts.month}`;
|
||||
}
|
||||
|
||||
export const monthNow = currentMonth;
|
||||
@@ -0,0 +1,24 @@
|
||||
export function formatMoney(cents: number, currency = "CNY", locale = "zh-CN"): string {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
export const money = formatMoney;
|
||||
|
||||
export function centsToAmount(cents: number): string {
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
export function amountToCents(value: string): number {
|
||||
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(value.trim());
|
||||
if (!match) throw new Error("INVALID_AMOUNT");
|
||||
const cents = Number(match[1]) * 100 + Number((match[2] ?? "").padEnd(2, "0"));
|
||||
if (!Number.isSafeInteger(cents) || cents <= 0) throw new Error("INVALID_AMOUNT");
|
||||
return cents;
|
||||
}
|
||||
|
||||
export const yuanToCents = amountToCents;
|
||||
Reference in New Issue
Block a user