Compare commits

...
11 Commits
Author SHA1 Message Date
Qiufeng 26fbec49ad feat: improve installer network setup
TallyNote release / linux-x64 (push) Successful in 5m48s
2026-09-02 07:38:38 +08:00
Qiufeng 3cedcb901b fix: allow service-owned data directory during uninstall
TallyNote release / linux-x64 (push) Successful in 5m55s
2026-09-02 06:54:36 +08:00
Qiufeng bac10b6fdf feat: add interactive installer network setup
TallyNote release / linux-x64 (push) Successful in 5m54s
2026-09-02 06:29:29 +08:00
Qiufeng eeeec54d10 feat: support direct IP service access
TallyNote release / linux-x64 (push) Successful in 7m17s
2026-09-01 20:57:56 +08:00
Qiufeng bcc63b8117 test: keep update fixtures ahead of current release
TallyNote release / linux-x64 (push) Successful in 6m9s
2026-09-01 15:04:31 +08:00
Qiufeng a268eb5fe9 feat: add staged release updates
TallyNote release / linux-x64 (push) Failing after 2m51s
2026-09-01 14:46:32 +08:00
Qiufeng 4395317651 feat: add friendly installer progress logs 2026-09-01 11:42:25 +08:00
Qiufeng 4b9c80cc3a feat: add safe one-click uninstall
TallyNote release / linux-x64 (push) Successful in 6m16s
2026-09-01 06:57:52 +08:00
Qiufeng 4434acf697 release: simplify unsigned installation
TallyNote release / linux-x64 (push) Successful in 6m24s
2026-09-01 00:10:35 +08:00
Qiufeng 12495fb6a4 release: 1.1.0
TallyNote release / linux-x64 (push) Successful in 6m24s
2026-08-31 20:11:34 +08:00
Qiufeng cbe0d65320 fix: make API not-found responses deterministic
TallyNote release / linux-x64 (push) Failing after 3m31s
2026-08-29 06:17:37 +08:00
84 changed files with 6450 additions and 194 deletions
+8 -3
View File
@@ -5,6 +5,10 @@ TALLYNOTE_TIMEZONE=Asia/Shanghai
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
TALLYNOTE_TRUST_PROXY=false
TALLYNOTE_COOKIE_SECURE=false
# Set TALLYNOTE_HOST=0.0.0.0 and the server's real IP Origin for direct
# access. HTTP on a non-local Origin is opt-in; use HTTPS behind a proxy in
# production.
TALLYNOTE_ALLOW_INSECURE_HTTP=false
TALLYNOTE_SESSION_IDLE_HOURS=24
TALLYNOTE_SESSION_ABSOLUTE_HOURS=168
TALLYNOTE_EXPORT_TTL_MINUTES=15
@@ -27,9 +31,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
+1 -2
View File
@@ -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
+2
View File
@@ -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
+87 -15
View File
@@ -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,29 +51,92 @@ 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.0 \
--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` 仅供隔离开发机测试,不能用于公网或真实财务数据。
安装命令保持简洁。首次在 SSH/终端中安装时,安装器会交互询问监听方式、端口和公开访问地址:
```bash
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
```
监听方式有两个选项:`127.0.0.1` 仅本机访问(默认、更安全),或 `0.0.0.0` 允许通过局域网/公网 IP 访问。安装时可输入自定义端口(直接回车使用默认端口),安装器会检查 TCP 端口是否已被占用;选择 `0.0.0.0` 时会尝试通过 HTTPS 自动获取公网 IPv4,并将 `http://公网IP:端口` 作为默认访问地址,也可以改填域名。不能填写 `http://0.0.0.0:3000`。直连 HTTP 未加密,安装器会要求明确确认,只适合受控网络。绑定域名后应改为 HTTPS 反向代理,设置真实的 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false`,然后执行 `sudo systemctl restart tallynote.service`。服务启动成功后,安装日志会输出最终访问链接。
安装器不会在已有安装的升级过程中反复询问网络配置,并会保留现有环境文件。自动化或无终端环境可使用 `--non-interactive`(默认安全配置 `127.0.0.1:3000`),也可以显式传入 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 覆盖配置。
非交互安装命令:
```bash
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash -s -- --non-interactive
```
脚本会从公开仓库的 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: 访问地址:http://127.0.0.1:3000
tallynote installer: 查看服务状态:systemctl status tallynote.service
```
任何阶段失败都会以 `tallynote installer:` 前缀写出原因并立即停止;不会把不完整版本切换为当前版本。
如需额外启用签名校验,在环境中设置 `TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true` 并提供 `--signing-key`;不设置时不会要求公钥或 `SHA256SUMS.sig`。
已有安装默认拒绝降级到不高于当前版本;确需回退时显式使用 `--allow-downgrade`,正常更新不会覆盖当前或更高版本。
安装布局为 `/opt/tallynote/releases/<version>` 加 `/opt/tallynote/current` 符号链接;切换通过临时链接和原子重命名完成。root 更新器使用前缀下独立的 `/opt/tallynote/.update-work`(`0700 root:root`)和 `.update-state` 恢复标记,不会把 root 解包工作区放进应用可写暂存目录。SQLite 数据、附件、暂存、导出和更新队列始终在外置 `/var/lib/tallynote`,不会随版本包删除。服务单元位于 `/etc/systemd/system/tallynote.service`,配置文件为 `/etc/tallynote/tallynote.env`,默认仅监听 `127.0.0.1:3000`。
安装布局为 `/opt/tallynote/releases/<version>` 加 `/opt/tallynote/current` 符号链接;切换通过临时链接和原子重命名完成。root 更新器使用前缀下独立的 `/opt/tallynote/.update-work`(`0700 root:root`)和 `.update-state` 恢复标记,不会把 root 解包工作区放进应用可写暂存目录。SQLite 数据、附件、暂存、导出和更新队列始终在外置 `/var/lib/tallynote`,不会随版本包删除。服务单元位于 `/etc/systemd/system/tallynote.service`,配置文件为 `/etc/tallynote/tallynote.env`;监听地址、端口和公开 Origin 由该环境文件控制,默认仍是 `127.0.0.1:3000`。
升级有两种方式:
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 +145,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 +172,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 反代访问。
+57 -18
View File
@@ -6,48 +6,75 @@ 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 主机显式加入白名单。
首次在交互式 SSH/终端中执行时,安装器会在下载前询问监听方式和端口(端口可直接回车使用默认值),并检查所选 TCP 端口是否已被占用。可选择仅本机监听 `127.0.0.1`,或监听 `0.0.0.0` 以允许通过真实服务器 IP/域名访问;选择公网监听时会尝试通过 HTTPS 自动获取公网 IPv4,将 `http://公网IP:端口` 作为默认访问地址,也可以手动改填域名。公网 HTTP 必须在提示中明确确认,公开地址不能填写通配监听地址。服务启动成功后,安装日志会输出最终访问链接。已有安装升级时不会重复询问,并保留现有环境文件。无终端或 CI 使用 `--non-interactive`(默认 `127.0.0.1:3000`),也可通过 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 显式配置。
非交互安装命令:
```bash
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash -s -- --non-interactive
```
脚本会从 `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: 访问地址:http://127.0.0.1:<端口>
```
每个阶段完成时会输出 `[完成]`;错误会立即以 `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 +88,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`;首次安装时可在交互提示中选择 `0.0.0.0` 和真实的服务器 IP/域名。直连 HTTP 会暴露未加密的会话和数据,只适合受控网络;绑定域名后必须改为 HTTPS 反向代理,设置真实 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数。自动化安装可使用 `--non-interactive` 或显式网络环境变量。
更新任务详情按发起管理员隔离,任务错误只返回固定提示,不会把服务器路径、命令输出或上游响应泄露到浏览器;同一时刻仍只允许一个系统更新任务。
+552 -38
View File
@@ -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}
@@ -33,6 +34,22 @@ MAX_TIME=${TALLYNOTE_INSTALL_MAX_TIME_SECONDS:-300}
RELEASE_ALLOWED_HOSTS=${TALLYNOTE_RELEASE_ALLOWED_HOSTS:-}
OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl}
UNAME_BIN=${TALLYNOTE_UNAME_BIN:-uname}
# Service network settings are written to the systemd EnvironmentFile on a
# fresh install. Existing values are preserved unless the corresponding
# TALLYNOTE_* variable is explicitly supplied to the installer.
INSTALL_HOST=${TALLYNOTE_HOST-127.0.0.1}
INSTALL_PORT=${TALLYNOTE_PORT-3000}
INSTALL_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN-}
INSTALL_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP-false}
PUBLIC_IP_URL=${TALLYNOTE_PUBLIC_IP_URL-}
NON_INTERACTIVE=0
NETWORK_INTERACTIVE=0
# The production prompt uses the controlling terminal, even when the
# installer itself is read from `curl | sudo bash`.
PROMPT_INPUT=/dev/tty
PROMPT_OUTPUT=/dev/tty
PROMPT_REPLY=''
INSTALL_SWITCHED=0
INSTALL_COMMITTED=0
@@ -51,21 +68,262 @@ 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]
[--non-interactive]
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. For direct IP access, pass TALLYNOTE_HOST=0.0.0.0 and an actual
TALLYNOTE_PUBLIC_ORIGIN such as http://203.0.113.10:3000; HTTP also requires
TALLYNOTE_ALLOW_INSECURE_HTTP=true. On a fresh terminal install, the listener
and public URL can be selected interactively. The installer checks that the
selected TCP port is free, suggests a public IPv4 address when exposing
0.0.0.0, and prints the final access URL after the service starts. Use --non-interactive (or
TALLYNOTE_NON_INTERACTIVE=true) for automation. --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 "[完成] $*"; }
case "${TALLYNOTE_NON_INTERACTIVE:-false}" in
true|1) NON_INTERACTIVE=1 ;;
false|0) ;;
*) die 'TALLYNOTE_NON_INTERACTIVE 必须是 true 或 false' ;;
esac
prompt_value() {
local label=$1 default=${2-} reply
if [[ -n "$default" ]]; then
printf '%s [%s]: ' "$label" "$default" > "$PROMPT_OUTPUT"
else
printf '%s: ' "$label" > "$PROMPT_OUTPUT"
fi
if ! IFS= read -r reply <&9; then
die '无法读取终端输入;请使用 --non-interactive 或通过环境变量配置'
fi
PROMPT_REPLY=${reply:-$default}
}
detect_public_ipv4() {
local endpoint value octet
local -a endpoints=()
if [[ -n "$PUBLIC_IP_URL" ]]; then
endpoints=("$PUBLIC_IP_URL")
else
# These services return the caller's address as plain text. HTTPS is
# required, and a failure simply falls back to manual address entry.
endpoints=(
'https://api.ipify.org'
'https://ifconfig.me/ip'
'https://checkip.amazonaws.com'
)
fi
command -v curl >/dev/null 2>&1 || return 1
for endpoint in "${endpoints[@]}"; do
[[ "$endpoint" == https://* && "$endpoint" != *[[:space:]]* && "$endpoint" != *[[:cntrl:]]* && "$endpoint" != *'@'* ]] || continue
value=$(curl -4 --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \
--connect-timeout 4 --max-time 8 --max-filesize 128 "$endpoint" 2>/dev/null \
| tr -d '[:space:]') || continue
[[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue
IFS='.' read -r -a _public_ip_octets <<< "$value"
for octet in "${_public_ip_octets[@]}"; do
(( 10#$octet <= 255 )) || continue 2
done
printf '%s' "$value"
return 0
done
return 1
}
port_listener_state() {
local port=$1 output status=0
validate_listen_port "$port" >/dev/null 2>&1 || return 2
if command -v ss >/dev/null 2>&1; then
if output=$(ss -H -ltn 2>/dev/null); then
if awk -v port="$port" '$4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then
return 1
fi
return 0
fi
fi
if command -v lsof >/dev/null 2>&1; then
output=''
status=0
output=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null) || status=$?
[[ -n "$output" ]] && return 1
[[ "$status" == 1 && -z "$output" ]] && return 0
[[ "$status" == 0 ]] && return 0
fi
if command -v netstat >/dev/null 2>&1; then
if output=$(netstat -lnt 2>/dev/null); then
if awk -v port="$port" '$6 == "LISTEN" && $4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then
return 1
fi
return 0
fi
fi
if command -v python3 >/dev/null 2>&1; then
python3 - "$port" <<'PY'
import errno
import socket
import sys
port = int(sys.argv[1])
for family, address in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")):
sock = socket.socket(family, socket.SOCK_STREAM)
try:
if family == socket.AF_INET6:
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
sock.bind((address, port))
except OSError as error:
if error.errno == errno.EADDRINUSE:
sys.exit(1)
finally:
sock.close()
sys.exit(0)
PY
status=$?
case "$status" in
0) return 0 ;;
1) return 1 ;;
esac
fi
return 2
}
check_requested_port() {
local port=$1 state
state=0
port_listener_state "$port" || state=$?
case "$state" in
0) return 0 ;;
1) die "端口 ${port} 已被占用,请选择其他端口" ;;
*) die "无法检测端口 ${port} 是否被占用,请安装 ss、lsof、netstat 或 Python 3 后重试" ;;
esac
}
has_network_environment() {
[[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" || -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" || -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]
}
interactive_network_available() {
(( APPLY )) || return 1
(( NON_INTERACTIVE == 0 )) || return 1
has_network_environment && return 1
[[ ! -e "$CONFIG_DIR/tallynote.env" && ! -L "$CONFIG_DIR/tallynote.env" ]] || return 1
[[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || return 1
return 0
}
configure_network_interactively() {
interactive_network_available || return 0
NETWORK_INTERACTIVE=1
exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请使用 --non-interactive 或通过环境变量配置'
stage '配置服务网络监听(可直接回车使用默认值)'
{
printf '\nTallyNote 服务监听配置\n'
printf ' 1) 仅本机访问:127.0.0.1(更安全)\n'
printf ' 2) 局域网/公网访问:0.0.0.0(需要填写实际访问地址)\n'
} > "$PROMPT_OUTPUT"
local choice selected_port origin answer port_state detected_ip default_origin
while :; do
prompt_value '请选择监听方式 1/2' '1'
choice=$PROMPT_REPLY
case "$choice" in
1|2) break ;;
*) printf '请输入 1 或 2。\n' > "$PROMPT_OUTPUT" ;;
esac
done
while :; do
prompt_value '监听端口' "$INSTALL_PORT"
selected_port=$PROMPT_REPLY
if [[ "$selected_port" =~ ^[1-9][0-9]*$ && "$selected_port" -le 65535 ]]; then
# A real terminal can reject an occupied port immediately. The final
# check in main() runs again after old services have been stopped.
if [[ -t 9 ]]; then
port_state=0
port_listener_state "$selected_port" || port_state=$?
case "$port_state" in
0) break ;;
1) printf '端口 %s 已被占用,请输入其他端口。\n' "$selected_port" > "$PROMPT_OUTPUT"; continue ;;
*) printf '暂时无法预检端口,安装前还会再次检查。\n' > "$PROMPT_OUTPUT"; break ;;
esac
fi
break
fi
printf '端口必须是 1-65535 的整数,请重试。\n' > "$PROMPT_OUTPUT"
done
if [[ "$choice" == 1 ]]; then
INSTALL_HOST=127.0.0.1
INSTALL_PORT=$selected_port
INSTALL_PUBLIC_ORIGIN="http://127.0.0.1:${selected_port}"
INSTALL_ALLOW_INSECURE_HTTP=false
else
INSTALL_HOST=0.0.0.0
INSTALL_PORT=$selected_port
detected_ip=''
# Test fixtures replace /dev/tty with regular files; avoid making their
# behavior depend on an external IP lookup service.
if [[ -t 9 ]]; then
detected_ip=$(detect_public_ipv4 || true)
fi
if [[ -n "$detected_ip" ]]; then
default_origin="http://${detected_ip}:${selected_port}"
printf '已探测公网 IPv4:%s\n' "$detected_ip" > "$PROMPT_OUTPUT"
else
default_origin=''
printf '未能自动获取公网 IPv4,请手动填写访问地址。\n' > "$PROMPT_OUTPUT"
fi
while :; do
prompt_value '实际访问地址(回车使用自动探测地址,也可填写域名)' "$default_origin"
origin=$PROMPT_REPLY
if validate_env_value "$origin" '公开访问地址' >/dev/null 2>&1 && validate_public_origin "$origin" >/dev/null 2>&1; then
INSTALL_PUBLIC_ORIGIN=$origin
break
fi
printf '地址无效:请输入不含路径、凭据或通配监听地址的 http:// 或 https:// 地址。\n' > "$PROMPT_OUTPUT"
done
INSTALL_ALLOW_INSECURE_HTTP=false
if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* ]]; then
{
printf '\n警告:直连 HTTP 不加密,登录信息和账目数据可能被窃听。\n'
printf '仅在受控局域网或你明确接受风险时继续。\n'
} > "$PROMPT_OUTPUT"
while :; do
prompt_value '确认允许公网 HTTP?输入 yes 继续,其他内容取消' 'no'
answer=$PROMPT_REPLY
case "$answer" in
yes|YES|Yes|y|Y) INSTALL_ALLOW_INSECURE_HTTP=true; break ;;
no|NO|No|n|N|'') die '已取消:公网 HTTP 必须明确确认;请改用 HTTPS 或重新运行安装器' ;;
*) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;;
esac
done
fi
fi
exec 9<&-
stage_done "网络配置已选择:${INSTALL_HOST}:${INSTALL_PORT}"
}
[[ "$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'
@@ -101,6 +359,7 @@ while (($#)); do
--keep-releases) KEEP_RELEASES=${2:?missing value for --keep-releases}; shift ;;
--allow-downgrade) ALLOW_DOWNGRADE=true ;;
--allow-unsigned) ALLOW_UNSIGNED=1; REQUIRE_SIGNATURE=false ;;
--non-interactive) NON_INTERACTIVE=1 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1" ;;
esac
@@ -194,15 +453,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 +542,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 +581,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 +637,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 +805,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 +851,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() {
@@ -601,6 +875,66 @@ validate_env_value() {
[[ ${#value} -le 4096 ]] || die "$label 过长"
}
validate_listen_host() {
local value=$1 label=${2:-监听地址}
validate_env_value "$value" "$label"
if [[ "$value" == *:* ]]; then
[[ "$value" =~ ^[0-9A-Fa-f:]+$ ]] || die "$label 必须是有效的 IPv6 地址或主机名"
elif [[ "$value" =~ ^[0-9.]+$ ]]; then
[[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "$label 必须是有效的 IPv4 地址或主机名"
local octet
IFS='.' read -r -a _host_octets <<< "$value"
for octet in "${_host_octets[@]}"; do
(( 10#$octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名"
done
else
[[ "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || die "$label 必须是有效的 IPv4、IPv6 地址或主机名"
[[ "$value" != *..* && "$value" != *.-* && "$value" != *-.* ]] || die "$label 包含不受支持的主机名"
fi
}
validate_listen_port() {
local value=$1 label=${2:-监听端口}
[[ "$value" =~ ^[1-9][0-9]*$ && "$value" -le 65535 ]] || die "$label 必须是 1-65535 的整数"
}
validate_public_origin() {
local value=$1 authority host path_part origin_port suffix
case "$value" in
http://*|https://*) ;;
*) die '公开访问地址必须是 http:// 或 https:// 地址' ;;
esac
[[ "$value" != *[[:space:]]* && "$value" != *[[:cntrl:]]* && "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || die '公开访问地址包含不受支持的字符'
authority=${value#*://}
authority=${authority%%/*}
[[ -n "$authority" ]] || die '公开访问地址缺少主机名'
if [[ "$authority" == \[*\]* ]]; then
host=${authority#\[}; host=${host%%\]*}
suffix=${authority#*\]}
if [[ -n "$suffix" ]]; then
[[ "$suffix" =~ ^:([0-9]+)$ ]] || die '公开访问地址端口无效'
origin_port=${BASH_REMATCH[1]}
fi
else
if [[ "$authority" == *:* ]]; then
[[ "$authority" =~ ^([^:]+):([0-9]+)$ ]] || die '公开访问地址端口无效'
host=${BASH_REMATCH[1]}
origin_port=${BASH_REMATCH[2]}
else
host=$authority
fi
fi
[[ -n "$host" ]] || die '公开访问地址缺少主机名'
[[ "$host" != 0.0.0.0 && "$host" != :: && "$host" != \* ]] || die '公开访问地址不能使用通配监听地址,请填写服务器 IP 或域名'
validate_listen_host "$host" '公开访问地址主机'
if [[ -n "$origin_port" ]]; then
[[ "$origin_port" =~ ^[0-9]{1,5}$ && "$origin_port" -ge 1 && "$origin_port" -le 65535 ]] || die '公开访问地址端口必须是 1-65535 的整数'
fi
path_part=${value#*://}
path_part=${path_part#"$authority"}
[[ -z "$path_part" || "$path_part" == "/" ]] || die '公开访问地址不能包含路径'
}
validate_semver() {
local value=$1 prerelease part
[[ "$value" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || return 1
@@ -620,14 +954,14 @@ validate_install_path() {
}
validate_existing_env() {
local file=$1 value metadata_host
local file=$1 value metadata_host host port origin allow_insecure cookie_secure
[[ ! -L "$file" && -f "$file" ]] || die '现有环境文件不是普通文件'
[[ "$(stat_uid "$file")" == 0 ]] || die '现有环境文件必须由 root 拥有'
local mode_bits
mode_bits=$(stat_mode_bits "$file")
(( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入'
local key key_count
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do
for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do
key_count=$(env_key_count "$file" "$key")
[[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key"
done
@@ -636,7 +970,62 @@ 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'
if (( $(env_key_count "$file" TALLYNOTE_HOST) )); then
host=$(read_env_value "$file" TALLYNOTE_HOST)
validate_listen_host "$host" '环境文件中的监听地址'
else
host=127.0.0.1
fi
if (( $(env_key_count "$file" TALLYNOTE_PORT) )); then
port=$(read_env_value "$file" TALLYNOTE_PORT)
validate_listen_port "$port" '环境文件中的监听端口'
else
port=3000
fi
if (( $(env_key_count "$file" TALLYNOTE_ALLOW_INSECURE_HTTP) )); then
allow_insecure=$(read_env_value "$file" TALLYNOTE_ALLOW_INSECURE_HTTP)
[[ "$allow_insecure" == true || "$allow_insecure" == false ]] || die '环境文件中的公网 HTTP 开关必须是 true 或 false'
else
allow_insecure=false
fi
if (( $(env_key_count "$file" TALLYNOTE_COOKIE_SECURE) )); then
cookie_secure=$(read_env_value "$file" TALLYNOTE_COOKIE_SECURE)
[[ "$cookie_secure" == true || "$cookie_secure" == false ]] || die '环境文件中的安全 Cookie 配置必须是 true 或 false'
else
cookie_secure=''
fi
if (( $(env_key_count "$file" TALLYNOTE_PUBLIC_ORIGIN) )); then
origin=$(read_env_value "$file" TALLYNOTE_PUBLIC_ORIGIN)
validate_env_value "$origin" '环境文件中的公开访问地址'
else
local origin_host=$host
[[ "$origin_host" == *:* && "$origin_host" != \[* ]] && origin_host="[$origin_host]"
origin="http://${origin_host}:${port}"
fi
validate_public_origin "$origin"
local origin_host_for_policy=${origin#*://}
if [[ "$origin_host_for_policy" == \[*\]* ]]; then
origin_host_for_policy=${origin_host_for_policy#\[}
origin_host_for_policy=${origin_host_for_policy%%\]*}
else
origin_host_for_policy=${origin_host_for_policy%%:*}
fi
if [[ "$origin" == http://* && "$allow_insecure" != true ]]; then
case "$origin_host_for_policy" in
127.0.0.1|localhost|::1) ;;
*) die '环境文件中的公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;;
esac
fi
if [[ "$origin" == http://* && "$cookie_secure" == true ]]; then
case "$origin_host_for_policy" in
127.0.0.1|localhost|::1) ;;
*) die '环境文件中的公网 HTTP 公开地址不能启用安全 Cookie' ;;
esac
fi
if [[ "$origin" == https://* && "$cookie_secure" == false ]]; then
die '环境文件中的 HTTPS 公开地址必须启用安全 Cookie'
fi
value=$(read_env_value "$file" TALLYNOTE_UPDATE_METADATA_URL)
if [[ -n "$value" ]]; then
validate_env_value "$value" '环境文件更新源'
@@ -656,7 +1045,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,15 +1094,45 @@ 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
configure_network_interactively
validate_listen_host "$INSTALL_HOST"
validate_listen_port "$INSTALL_PORT"
if (( APPLY && NETWORK_INTERACTIVE )); then
check_requested_port "$INSTALL_PORT"
fi
if [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" && -z "$INSTALL_PUBLIC_ORIGIN" ]]; then
die 'TALLYNOTE_PUBLIC_ORIGIN 不能是空值;省略该变量以使用默认 Origin'
fi
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == true || "$INSTALL_ALLOW_INSECURE_HTTP" == false ]] || die 'TALLYNOTE_ALLOW_INSECURE_HTTP 必须是 true 或 false'
if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then
validate_env_value "$INSTALL_PUBLIC_ORIGIN" '公开访问地址'
validate_public_origin "$INSTALL_PUBLIC_ORIGIN"
if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* && "$INSTALL_ALLOW_INSECURE_HTTP" != true ]]; then
public_host=${INSTALL_PUBLIC_ORIGIN#http://}
if [[ "$public_host" == \[*\]* ]]; then
public_host=${public_host#\[}
public_host=${public_host%%\]*}
else
public_host=${public_host%%:*}
fi
case "$public_host" in
127.0.0.1|localhost|::1) ;;
*) die '公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;;
esac
fi
elif [[ "$INSTALL_HOST" != 127.0.0.1 && "$INSTALL_HOST" != localhost && "$INSTALL_HOST" != ::1 ]]; then
die '监听非本机地址时必须提供 TALLYNOTE_PUBLIC_ORIGIN(例如 http://服务器IP:3000)'
fi
[[ "$KEEP_RELEASES" =~ ^[1-9][0-9]*$ ]] || die '--keep-releases must be a positive integer'
validate_install_path "$PREFIX" '安装目录'
validate_install_path "$DATA_DIR" '数据目录'
@@ -727,14 +1146,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 +1171,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 +1208,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 +1220,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 +1233,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 +1252,23 @@ main() {
if [[ -e "$CONFIG_DIR/tallynote.env" ]]; then
validate_existing_env "$CONFIG_DIR/tallynote.env"
fi
# During upgrades, the existing environment remains authoritative unless a
# new port was explicitly supplied. Check the effective listener port after
# stopping the old service so an unrelated process cannot claim it.
local effective_port=$INSTALL_PORT
if [[ -z "${TALLYNOTE_PORT+x}" && -f "$CONFIG_DIR/tallynote.env" ]]; then
effective_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
effective_port=${effective_port:-3000}
fi
check_requested_port "$effective_port"
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,11 +1281,14 @@ 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
local env_created=0
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"
chown root:root "$CONFIG_DIR/tallynote.env"
chmod 640 "$CONFIG_DIR/tallynote.env"
env_created=1
fi
ensure_env_key() {
local key=$1 value=$2
@@ -846,15 +1301,53 @@ main() {
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env"
fi
}
set_env_key() {
local key=$1 value=$2 escaped tmp
[[ "$key" =~ ^[A-Z0-9_]+$ ]] || die '环境变量名无效'
validate_env_value "$value" "$key"
escaped=${value//\\/\\\\}
escaped=${escaped//&/\\&}
escaped=${escaped//|/\\|}
if grep -qE "^${key}=" "$CONFIG_DIR/tallynote.env"; then
sed -i "s|^${key}=.*|${key}=${escaped}|" "$CONFIG_DIR/tallynote.env"
else
if [[ -s "$CONFIG_DIR/tallynote.env" && "$(tail -c 1 "$CONFIG_DIR/tallynote.env")" != $'\n' ]]; then
printf '\n' >> "$CONFIG_DIR/tallynote.env"
fi
printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env"
fi
}
# A fresh install gets the requested network settings. On upgrades, only
# explicitly supplied values change the existing administrator config.
if (( env_created )) || [[ -n "${TALLYNOTE_HOST+x}" ]]; then set_env_key TALLYNOTE_HOST "$INSTALL_HOST"; fi
if (( env_created )) || [[ -n "${TALLYNOTE_PORT+x}" ]]; then set_env_key TALLYNOTE_PORT "$INSTALL_PORT"; fi
if (( env_created )); then
if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then
set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN"
elif [[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" ]]; then
local generated_origin_host=$INSTALL_HOST
[[ "$generated_origin_host" == *:* && "$generated_origin_host" != \[* ]] && generated_origin_host="[$generated_origin_host]"
set_env_key TALLYNOTE_PUBLIC_ORIGIN "http://${generated_origin_host}:${INSTALL_PORT}"
fi
if [[ "$INSTALL_PUBLIC_ORIGIN" == https://* ]]; then set_env_key TALLYNOTE_COOKIE_SECURE true; fi
set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"
elif [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" ]]; then
set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN"
fi
if [[ -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]; then set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"; fi
ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX"
ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR"
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 +1359,36 @@ 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}"
local access_url access_host access_port configured_host configured_port
access_url=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PUBLIC_ORIGIN 2>/dev/null || true)
if [[ -z "$access_url" ]]; then
configured_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true)
configured_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true)
access_host=${configured_host:-$INSTALL_HOST}
access_port=${configured_port:-$INSTALL_PORT}
if [[ "$access_host" == 0.0.0.0 ]]; then
access_host=$(detect_public_ipv4 || true)
fi
[[ -n "$access_host" ]] || access_host=$INSTALL_HOST
[[ "$access_host" == *:* && "$access_host" != \[* ]] && access_host="[$access_host]"
access_url="http://${access_host}:${access_port}"
fi
log "访问地址:$access_url"
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
View File
@@ -1,6 +1,6 @@
{
"name": "tallynote",
"version": "1.0.0",
"version": "1.1.7",
"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
View File
@@ -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,
},
},
});
+515 -14
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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.
+22 -11
View File
@@ -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"
+25 -1
View File
@@ -13,6 +13,10 @@ STATE_FILE="$PREFIX/.update-state"
SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service}
HOST=${TALLYNOTE_HOST:-127.0.0.1}
PORT=${TALLYNOTE_PORT:-3000}
HEALTH_HOST=$HOST
if [[ "$HEALTH_HOST" == 0.0.0.0 ]]; then HEALTH_HOST=127.0.0.1; fi
if [[ "$HEALTH_HOST" == :: ]]; then HEALTH_HOST=::1; fi
if [[ "$HEALTH_HOST" == *:* && "$HEALTH_HOST" != \[* ]]; then HEALTH_HOST="[$HEALTH_HOST]"; fi
die() { printf 'tallynote update runner: %s\n' "$*" >&2; exit 1; }
[[ ${EUID:-$(id -u)} -eq 0 ]] || die 'must run as root'
@@ -22,6 +26,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
@@ -202,7 +226,7 @@ write_update_state health-check || exit 1
systemctl start "$SERVICE_NAME"
healthy=0
for _ in $(seq 1 30); do
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
if curl --proto '=http' --max-time 2 --silent --show-error "http://$HEALTH_HOST:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi
sleep 1
done
+295
View File
@@ -4,12 +4,37 @@ 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
fi
if TALLYNOTE_HOST=0.0.0.0 bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
echo 'expected non-local listener without public origin to fail' >&2
exit 1
fi
output=$(TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \
TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \
TALLYNOTE_ALLOW_INSECURE_HTTP=true \
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"
output=$(TALLYNOTE_HOST=::1 TALLYNOTE_PORT=3443 \
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"
if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=65536 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 TALLYNOTE_ALLOW_INSECURE_HTTP=true \
bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
echo 'expected invalid listener port to fail' >&2
exit 1
fi
if TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000 \
bash "$root/install.sh" --dry-run --release-base-url https://releases.example.test/releases >/dev/null 2>&1; then
echo 'expected public HTTP without explicit opt-in to fail' >&2
exit 1
fi
tmp=$(mktemp -d)
cleanup_tmp() {
if [[ -d "$tmp" ]]; then
@@ -37,6 +62,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
@@ -53,6 +85,62 @@ bash -c '
fi
' _ "$installer_lib" "$tmp/mode" "$tmp/user-parent"
# The port probe must distinguish a listening TCP port from a free one.
port_tools="$tmp/port-tools"
mkdir -p "$port_tools"
printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\\n" "LISTEN 0 128 127.0.0.1:3443 0.0.0.0:*"' > "$port_tools/ss"
chmod 755 "$port_tools/ss"
bash -c '
script=$1
tools=$2
set --
source "$script"
PATH="$tools:$PATH"
state=0
port_listener_state 3443 || state=$?
[[ "$state" == 1 ]]
state=0
port_listener_state 3444 || state=$?
[[ "$state" == 0 ]]
' _ "$installer_lib" "$port_tools"
# The lsof fallback must treat its normal "no matches" exit status as a free
# port, while still reporting a listener when it returns a PID.
lsof_tools="$tmp/lsof-tools"
mkdir -p "$lsof_tools"
printf '%s\n' '#!/usr/bin/env bash' 'exit 127' > "$lsof_tools/ss"
printf '%s\n' '#!/usr/bin/env bash' 'case "$*" in *TCP:3443*) printf "%s\\n" 4242; exit 0 ;; *) exit 1 ;; esac' > "$lsof_tools/lsof"
chmod 755 "$lsof_tools/ss" "$lsof_tools/lsof"
bash -c '
script=$1
tools=$2
set --
source "$script"
PATH="$tools:$PATH"
state=0
port_listener_state 3443 || state=$?
[[ "$state" == 1 ]]
state=0
port_listener_state 3444 || state=$?
[[ "$state" == 0 ]]
' _ "$installer_lib" "$lsof_tools"
# Public-IP discovery accepts a valid IPv4 response and rejects malformed
# values without making the test depend on an external service.
bash -c '
script=$1
set --
source "$script"
PUBLIC_IP_URL=https://ip.example.test
curl() { printf "%s\\n" "198.51.100.7"; }
[[ "$(detect_public_ipv4)" == "198.51.100.7" ]]
curl() { printf "%s\\n" "999.1.1.1"; }
if detect_public_ipv4 >/dev/null 2>&1; then
echo "expected invalid public IPv4 response to fail" >&2
exit 1
fi
' _ "$installer_lib"
# Duplicate security-sensitive EnvironmentFile assignments are rejected even
# when the first value looks valid (systemd uses the later value).
duplicate_env="$tmp/duplicate.env"
@@ -70,15 +158,207 @@ bash -c '
fi
' _ "$installer_lib" "$duplicate_env"
# Existing installations must validate the network settings they preserve on
# upgrade, including the direct-IP HTTP combination used by the documented
# installer command.
network_env="$tmp/network.env"
printf '%s\n' \
'TALLYNOTE_HOST=0.0.0.0' \
'TALLYNOTE_PORT=3000' \
'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \
'TALLYNOTE_ALLOW_INSECURE_HTTP=true' > "$network_env"
bash -c '
script=$1
env_file=$2
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_existing_env "$env_file"
' _ "$installer_lib" "$network_env"
if sed 's/^TALLYNOTE_PORT=.*/TALLYNOTE_PORT=65536/' "$network_env" > "$tmp/invalid-port.env"; then
if bash -c '
script=$1
env_file=$2
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_existing_env "$env_file"
' _ "$installer_lib" "$tmp/invalid-port.env" >/dev/null 2>&1; then
echo 'expected invalid existing listener port to fail' >&2
exit 1
fi
fi
printf '%s\n' \
'TALLYNOTE_HOST=0.0.0.0' \
'TALLYNOTE_PORT=3000' \
'TALLYNOTE_PUBLIC_ORIGIN=http://203.0.113.10:3000' \
'TALLYNOTE_ALLOW_INSECURE_HTTP=false' > "$tmp/public-http-without-opt-in.env"
if bash -c '
script=$1
env_file=$2
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_existing_env "$env_file"
' _ "$installer_lib" "$tmp/public-http-without-opt-in.env" >/dev/null 2>&1; then
echo 'expected public HTTP without opt-in in existing env to fail' >&2
exit 1
fi
bash -c '
script=$1
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_public_origin "http://[2001:db8::10]:3000"
' _ "$installer_lib"
printf '%s\n' \
'TALLYNOTE_HOST=0.0.0.0' \
'TALLYNOTE_PORT=3000' \
'TALLYNOTE_PUBLIC_ORIGIN=https://tallynote.example.com' \
'TALLYNOTE_COOKIE_SECURE=true' > "$tmp/public-https.env"
bash -c '
script=$1
env_file=$2
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_existing_env "$env_file"
' _ "$installer_lib" "$tmp/public-https.env"
printf '%s\n' \
'TALLYNOTE_HOST=::1' \
'TALLYNOTE_PORT=3443' > "$tmp/ipv6-default-origin.env"
bash -c '
script=$1
env_file=$2
set --
source "$script"
PREFIX=/opt/tallynote
DATA_DIR=/var/lib/tallynote
stat_uid() { printf "0"; }
stat_mode_bits() { printf "384"; }
validate_existing_env "$env_file"
' _ "$installer_lib" "$tmp/ipv6-default-origin.env"
if bash -c '
script=$1
set --
source "$script"
validate_public_origin "http://example.test:65536"
' _ "$installer_lib" >/dev/null 2>&1; then
echo 'expected invalid public origin port to fail' >&2
exit 1
fi
# A fresh interactive install reads from the controlling terminal even when
# the installer script itself is piped from curl. The test substitutes files
# for that terminal and verifies both listener choices without touching the
# host filesystem.
interactive_dir="$tmp/interactive"
mkdir -p "$interactive_dir/config"
printf '\n\n' > "$interactive_dir/local-input"
: > "$interactive_dir/local-output"
env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
bash -c '
script=$1
dir=$2
set --
source "$script"
APPLY=1
NON_INTERACTIVE=0
CONFIG_DIR="$dir/config"
PROMPT_INPUT="$dir/local-input"
PROMPT_OUTPUT="$dir/local-output"
configure_network_interactively
[[ "$INSTALL_HOST" == 127.0.0.1 ]]
[[ "$INSTALL_PORT" == 3000 ]]
[[ "$INSTALL_PUBLIC_ORIGIN" == http://127.0.0.1:3000 ]]
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == false ]]
' _ "$installer_lib" "$interactive_dir"
printf '2\n3443\nhttp://203.0.113.10:3443\nyes\n' > "$interactive_dir/public-input"
: > "$interactive_dir/public-output"
env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
bash -c '
script=$1
dir=$2
set --
source "$script"
APPLY=1
NON_INTERACTIVE=0
CONFIG_DIR="$dir/config"
PROMPT_INPUT="$dir/public-input"
PROMPT_OUTPUT="$dir/public-output"
configure_network_interactively
[[ "$INSTALL_HOST" == 0.0.0.0 ]]
[[ "$INSTALL_PORT" == 3443 ]]
[[ "$INSTALL_PUBLIC_ORIGIN" == http://203.0.113.10:3443 ]]
[[ "$INSTALL_ALLOW_INSECURE_HTTP" == true ]]
' _ "$installer_lib" "$interactive_dir"
printf '2\n3000\nhttp://203.0.113.10:3000\nno\n' > "$interactive_dir/refuse-input"
: > "$interactive_dir/refuse-output"
if env -u TALLYNOTE_HOST -u TALLYNOTE_PORT -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
bash -c '
script=$1
dir=$2
set --
source "$script"
APPLY=1
NON_INTERACTIVE=0
CONFIG_DIR="$dir/config"
PROMPT_INPUT="$dir/refuse-input"
PROMPT_OUTPUT="$dir/refuse-output"
configure_network_interactively
' _ "$installer_lib" "$interactive_dir" >/dev/null 2>&1; then
echo 'expected public HTTP confirmation refusal to stop configuration' >&2
exit 1
fi
# Explicit environment variables take precedence over the prompt, including
# when a terminal is available.
env -u TALLYNOTE_PUBLIC_ORIGIN -u TALLYNOTE_ALLOW_INSECURE_HTTP \
TALLYNOTE_HOST=0.0.0.0 TALLYNOTE_PORT=3000 \
bash -c '
script=$1
dir=$2
set --
source "$script"
APPLY=1
NON_INTERACTIVE=0
CONFIG_DIR="$dir/config"
PROMPT_INPUT="$dir/local-input"
PROMPT_OUTPUT="$dir/local-output"
if interactive_network_available; then
echo "expected explicit network environment to skip prompt" >&2
exit 1
fi
' _ "$installer_lib" "$interactive_dir"
# A release archive is extracted under umask 077, then explicitly normalized
# so the tallynote system user can traverse and execute the shipped tree.
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 +372,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
+193
View File
@@ -0,0 +1,193 @@
#!/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" ]]
# In production the service user owns the data directory. The target itself
# must be accepted while its parent directories remain root-owned. This test
# is meaningful only when the suite runs as root on a host with that account;
# ordinary developer runs continue with the portable fixture coverage above.
tallynote_uid=$(id -u tallynote 2>/dev/null || true)
if [[ "$EUID" == 0 && -n "$tallynote_uid" && "$tallynote_uid" != "$(id -u)" ]]; then
fixture="$tmp/service-user-data"
make_fixture "$fixture"
make_systemctl "$fixture"
chown -R "$tallynote_uid" "$fixture/var/lib/tallynote"
TALLYNOTE_UNINSTALL_TEST_DATA_OWNER_UID="$tallynote_uid" run_uninstall "$fixture" --purge-data --yes
[[ ! -e "$fixture/var/lib/tallynote" ]]
fi
# 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'
+236 -40
View File
@@ -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;
@@ -1640,13 +1823,26 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
return reply.send(await safeReadStream(config.exportsDir, job.filePath));
});
if (existsSync(config.webDir)) {
await app.register(fastifyStatic, { root: config.webDir, wildcard: false });
app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith("/api/")) return reply.code(404).send(errorPayload(request, new AppError(404, "NOT_FOUND", "接口不存在")));
return reply.sendFile("index.html");
const hasWeb = existsSync(config.webDir);
if (hasWeb) {
// 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).
app.setNotFoundHandler((request, reply) => {
if (request.url.split("?", 1)[0]!.startsWith("/api/")) {
return reply.code(404).send(errorPayload(request, new AppError(404, "NOT_FOUND", "接口不存在")));
}
if (hasWeb) return reply.sendFile("index.html");
return reply.code(404).send({
message: `Route ${request.method}:${request.url} not found`,
error: "Not Found",
statusCode: 404,
});
} else {
});
if (!hasWeb) {
app.get("/", async () => ({ name: "TallyNote", mode: "api", hint: "开发界面运行在 Vite 端口" }));
}
+129 -11
View File
@@ -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}`);
+22 -3
View File
@@ -69,7 +69,8 @@ export function loadConfig() {
const installPrefix = path.resolve(process.env.TALLYNOTE_INSTALL_PREFIX ?? (updateStrategyRaw === "systemd" ? path.dirname(projectRoot) : projectRoot));
const host = process.env.TALLYNOTE_HOST ?? "127.0.0.1";
const port = integerEnv("TALLYNOTE_PORT", 3000, 1);
const publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${host}:${port}`;
const originHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
const publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${originHost}:${port}`;
let parsedOrigin: URL;
try {
parsedOrigin = new URL(publicOrigin);
@@ -88,7 +89,14 @@ export function loadConfig() {
const isProduction = process.env.NODE_ENV === "production" || process.env.TALLYNOTE_ENV === "production";
const cookieSecure = booleanEnv("TALLYNOTE_COOKIE_SECURE", parsedOrigin.protocol === "https:");
// Direct IP access is useful during a first deployment, but it is not
// encrypted. Keep this explicitly opt-in so a public install cannot
// accidentally expose session cookies over HTTP.
const allowInsecureHttp = booleanEnv("TALLYNOTE_ALLOW_INSECURE_HTTP", false);
const publicHost = parsedOrigin.hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (["0.0.0.0", "::"].includes(publicHost)) {
throw new Error("TALLYNOTE_PUBLIC_ORIGIN 不能使用通配监听地址,请填写服务器 IP 或域名");
}
const localOrigin = ["127.0.0.1", "localhost", "::1"].includes(publicHost);
const appVersion = (() => {
try {
@@ -104,7 +112,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");
}
@@ -119,6 +130,7 @@ export function loadConfig() {
timezone,
trustProxy: trustProxyEnv(),
cookieSecure,
allowInsecureHttp,
appVersion,
updateMetadataUrl,
updateAllowedHosts,
@@ -139,6 +151,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,
@@ -162,7 +175,13 @@ export function loadConfig() {
isProduction,
};
if (!localOrigin && (parsedOrigin.protocol !== "https:" || !cookieSecure)) {
if (!localOrigin && parsedOrigin.protocol !== "https:" && !allowInsecureHttp) {
throw new Error("公网 HTTP 访问必须显式启用 TALLYNOTE_ALLOW_INSECURE_HTTP=true;生产环境建议使用 HTTPS");
}
if (!localOrigin && parsedOrigin.protocol !== "https:" && cookieSecure) {
throw new Error("HTTP public origin 不能启用安全 Cookie");
}
if (!localOrigin && parsedOrigin.protocol === "https:" && !cookieSecure) {
throw new Error("公网部署必须使用 HTTPS 并启用安全 Cookie");
}
if (parsedOrigin.protocol === "https:" && !cookieSecure) {
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
+30
View File
@@ -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
View File
@@ -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(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/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
View File
@@ -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();
+5 -2
View File
@@ -4,12 +4,15 @@ TALLYNOTE_DATA_DIR=/var/lib/tallynote
TALLYNOTE_INSTALL_PREFIX=/opt/tallynote
TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000
TALLYNOTE_COOKIE_SECURE=false
TALLYNOTE_ALLOW_INSECURE_HTTP=false
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
+96
View File
@@ -34,6 +34,8 @@ describe("TallyNote API", () => {
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://127.0.0.1:3999";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
config = loadConfig();
// API tests must not depend on the ignored, locally generated dist/ tree.
config.webDir = path.join(dataDir, "missing-web");
prepareDataDirectories(config);
database = openDatabase(config);
app = await buildApp(database, config);
@@ -390,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
View File
@@ -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
View File
@@ -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);
});
}
+2 -1
View File
@@ -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);
+26 -3
View File
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { loadConfig, prepareDataDirectories } from "../server/config.js";
const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"];
const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_HOST", "TALLYNOTE_PORT", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_ALLOW_INSECURE_HTTP", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"];
afterEach(() => { for (const key of keys) delete process.env[key]; });
@@ -13,11 +13,32 @@ describe("部署安全配置", () => {
it("公网 HTTP 或 HTTPS 非安全 Cookie 一律拒绝", () => {
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://example.test";
expect(() => loadConfig()).toThrow(/HTTPS/);
process.env.TALLYNOTE_ALLOW_INSECURE_HTTP = "true";
expect(loadConfig().allowInsecureHttp).toBe(true);
process.env.TALLYNOTE_COOKIE_SECURE = "true";
expect(() => loadConfig()).toThrow(/安全 Cookie/);
process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
expect(() => loadConfig()).toThrow(/安全 Cookie/);
});
it("允许显式配置服务器 IP 的直连 HTTP,并拒绝通配 Origin", () => {
process.env.TALLYNOTE_HOST = "0.0.0.0";
process.env.TALLYNOTE_PORT = "3000";
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://192.0.2.10:3000";
process.env.TALLYNOTE_ALLOW_INSECURE_HTTP = "true";
process.env.TALLYNOTE_COOKIE_SECURE = "false";
expect(loadConfig()).toMatchObject({ host: "0.0.0.0", port: 3000, publicOrigin: "http://192.0.2.10:3000", allowInsecureHttp: true });
process.env.TALLYNOTE_PUBLIC_ORIGIN = "http://0.0.0.0:3000";
expect(() => loadConfig()).toThrow(/通配监听地址/);
});
it("为 IPv6 监听地址生成合法的默认 Origin", () => {
process.env.TALLYNOTE_HOST = "::1";
process.env.TALLYNOTE_PORT = "3000";
expect(loadConfig().publicOrigin).toBe("http://[::1]:3000");
});
it("生产环境不接受任意 trust proxy", () => {
process.env.NODE_ENV = "production";
process.env.TALLYNOTE_TRUST_PROXY = "true";
@@ -27,14 +48,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("收紧已有数据目录和数据库文件权限,并拒绝符号链接", () => {
+38 -9
View File
@@ -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.8-${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.8", 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.8", 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.8", 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.8", 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.8", 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.8", 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.8" });
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.8", 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.8", 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.8", 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.8" } });
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.8", 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.8", 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");
+3 -3
View File
@@ -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.8-${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.8", 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.8", 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 {
+67
View File
@@ -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);
});
});
+14
View File
@@ -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
+480
View File
@@ -0,0 +1,480 @@
#!/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:-}
TEST_DATA_OWNER_UID=${TALLYNOTE_UNINSTALL_TEST_DATA_OWNER_UID:-}
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 || ( -n "$TEST_DATA_OWNER_UID" && "$uid" == "$TEST_DATA_OWNER_UID" ) ]]
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"
# The target itself is checked by validate_target with its path-specific
# owner policy (data may belong to the tallynote service user). Keep all
# ancestor directories root-owned, but do not apply that policy twice to
# the final target.
if [[ "$current" != "$target" ]]; then
allowed_owner "$current" || die "路径目录的所有者不受信任:$current"
fi
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
View File
@@ -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",
+35
View File
@@ -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,
},
});
+10
View File
@@ -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} />;
}
+85
View File
@@ -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();
};
}, []);
}
+152
View File
@@ -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]);
}
+104
View File
@@ -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 };
+15
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+8
View File
@@ -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";
+130
View File
@@ -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} />);
+115
View File
@@ -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);
}
+1
View File
@@ -0,0 +1 @@
export { default as AdminsPage } from "./AdminsPage";
+140
View File
@@ -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>;
}
+1
View File
@@ -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);
}
+2
View File
@@ -0,0 +1,2 @@
export { default } from "./LoginPage";
export * from "./LoginPage";
+128
View File
@@ -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>
);
}
+58
View File
@@ -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} />;
}
+4
View File
@@ -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";
+52
View File
@@ -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>;
}
+2
View File
@@ -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>;
}
+26
View File
@@ -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`; }
+5
View File
@@ -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";
+6
View File
@@ -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;
+67
View File
@@ -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>;
}
+1
View File
@@ -0,0 +1 @@
export { default as TrashPage } from "./TrashPage";
+122
View File
@@ -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>;
}
+1
View File
@@ -0,0 +1 @@
export { default as UpdatePage } from "./UpdatePage";
+49
View File
@@ -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 ?? "账目";
}
+144
View File
@@ -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;
+27
View File
@@ -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 };
+13
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./api";
export * from "./auth";
+17
View File
@@ -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";
+156
View File
@@ -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;
+528
View File
@@ -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; }
}
+41
View File
@@ -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;
};
}
+87
View File
@@ -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;
+24
View File
@@ -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;