From 2accca9a2266ba6213d1177872d858bc422ba69a Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Fri, 4 Sep 2026 16:51:43 +0800 Subject: [PATCH] =?UTF-8?q?chore(release):=201.1.36=20-=20=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=86=85=E6=B5=81=E5=BC=8F=E7=9B=B4=E8=BF=9E=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E3=80=81=E9=80=8F=E6=98=8E=E5=8C=96=E7=9B=B4=E9=93=BE?= =?UTF-8?q?=E4=B8=8E=E6=8E=92=E9=98=9F=E5=8D=A1=E6=AD=BB=E5=BD=BB=E5=BA=95?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- artifacts/tallynote-update-workflow.html | 14711 ++++++++++++++++ artifacts/tallynote-update-workflow.json | 256 + package.json | 2 +- server/app.ts | 4 + server/update-service.ts | 133 +- tests/update-api.test.ts | 147 + web-next/src/components/BeamLoading.tsx | 39 + web-next/src/main.tsx | 5 +- web-next/src/pages/common.tsx | 5 +- .../src/pages/dashboard/DashboardPage.tsx | 3 +- web-next/src/pages/expenses/ExpenseDetail.tsx | 3 +- web-next/src/pages/expenses/ExpensesPage.tsx | 3 +- web-next/src/pages/update/UpdatePage.tsx | 61 +- web-next/src/styles/theme.css | 276 +- 14 files changed, 15603 insertions(+), 45 deletions(-) create mode 100644 artifacts/tallynote-update-workflow.html create mode 100644 artifacts/tallynote-update-workflow.json create mode 100644 web-next/src/components/BeamLoading.tsx diff --git a/artifacts/tallynote-update-workflow.html b/artifacts/tallynote-update-workflow.html new file mode 100644 index 0000000..758ea69 --- /dev/null +++ b/artifacts/tallynote-update-workflow.html @@ -0,0 +1,14711 @@ + + + + + + + TallyNote 平滑更新与应用内直连下载流程 Diagram + + + + + + + + + + + +
+ +
+
+
+

TallyNote 平滑更新与应用内直连下载流程

+
+

告别外部守护等待 · 应用进程直连流式下载 · 原子热切换

+
+ + + + + + + +
+ + TallyNote 平滑更新与应用内直连下载流程 + 告别外部守护等待 · 应用进程直连流式下载 · 原子热切换 + + + + + + + + + + + + + + + + + + + + + + + + + 01 / 管理控制台 (前端 UI) + + + 02 / Web 应用后端 (Node.js) + + + 03 / 系统底层与运行时 (Linux / systemd) + + + 04 / Gitea 官方源 (HTTPS) + + + + + 版本发现 + + + 直连下载与校验 + + + 原子切换与自愈 + + + + 应用内直接流式拉取 (无外部阻塞) + + + + + + + + + + + + + + + 检查更新 · 点击查询新版 · 管理控制台 (前端 UI) › 版本发现 + + + + 检查更新 + 点击查询新版 + + + + 查询 Release · 只读接口校验 · Web 应用后端 (Node.js) › 版本发现 · 只读 + + + + 查询 Release + 只读接口校验 + 只读 + + + + Gitea 官方源 · 返回最新元数据 · Gitea 官方源 (HTTPS) › 版本发现 · HTTPS + + + + Gitea 官方源 + 返回最新元数据 + HTTPS + + + + 版本看板呈现 · 日志与升级入口 · 管理控制台 (前端 UI) › 版本发现 + + + + 版本看板呈现 + 日志与升级入口 + + + + 流式拉取 · 应用直连下载 · Web 应用后端 (Node.js) › 应用内直接流式拉取 (无外部阻塞) › 直连下载与校验 · 实时进度 + + + + 流式拉取 + 应用直连下载 + 实时进度 + + + + SHA-256 校验 · 比对并解压 · Web 应用后端 (Node.js) › 应用内直接流式拉取 (无外部阻塞) › 直连下载与校验 · 完整性 + + + + SHA-256 校验 + 比对并解压 + 完整性 + + + + 确认重启 · 更新包已就绪 · 管理控制台 (前端 UI) › 直连下载与校验 + + + + 确认重启 + 更新包已就绪 + + + + 原子切换 · 切换软链接重载 · 系统底层与运行时 (Linux / systemd) › 原子切换与自愈 + + + + 原子切换 + 切换软链接重载 + + + + 健康探测探针 · 轮询探活至 200 · Web 应用后端 (Node.js) › 原子切换与自愈 + + + + 健康探测探针 + 轮询探活至 200 + + + + 平滑上线刷新 · 自动进入新版本 · 管理控制台 (前端 UI) › 原子切换与自愈 + + + + 平滑上线刷新 + 自动进入新版本 + + + + + + + + + + + + + + + + Legend + + + User UI + + + + Agent logic + + + + Policy + + + + Cloud service + + + + External system + + + +

+ + + + + + + + + +
+ + +
+
+
+
+

核心升级点:消除外部调度依赖

+
+
    +
  • • 传统模式:Web 写入 JSON 队列,傻等外部 root 守护进程监听唤醒,导致常态化卡死在等待系统调度
  • +
  • • 新模式:Web 后端进程直接建立 HTTPS 流式管道下载,0 秒立即响应,进度条真实可见
  • +
+
+ +
+
+
+

透明化监控与错误拦截

+
+
    +
  • • 网络层直抓:DNS 失败、超时或 404 当场捕获,前端弹窗直接展示错误详情与重试按钮
  • +
  • • 进度实时计算:每 500ms 计算下载字节与传输速率(MB/s),无感后台拉取
  • +
+
+ +
+
+
+

平滑原子切换与自愈

+
+
    +
  • • 文件完整校验后再切换软链接,绝不损坏现有运行中的实例
  • +
  • • 前端 30 秒倒计时探针自动检测服务就绪,服务重启完毕自动恢复会话
  • +
+
+
+ +
+ + + + diff --git a/artifacts/tallynote-update-workflow.json b/artifacts/tallynote-update-workflow.json new file mode 100644 index 0000000..95c8426 --- /dev/null +++ b/artifacts/tallynote-update-workflow.json @@ -0,0 +1,256 @@ +{ + "schema_version": 1, + "diagram_type": "workflow", + "meta": { + "title": "TallyNote 平滑更新与应用内直连下载流程", + "subtitle": "告别外部守护等待 · 应用进程直连流式下载 · 原子热切换", + "output": "artifacts/tallynote-update-workflow.html", + "animation": "trace", + "quality_profile": "showcase", + "views": [ + { + "id": "stream-download", + "label": "应用内流式下载", + "focus": [ + "ui_render", + "stream_worker", + "verify_sha" + ], + "note": "Web 进程 0 延时直连 Gitea 流式拉取并比对哈希,彻底废除外部 systemd.path 调度等待。" + }, + { + "id": "atomic-switch", + "label": "原子切换与秒级恢复", + "focus": [ + "ui_ready", + "atomic_switch", + "health_probe", + "ui_refreshed" + ], + "note": "包就绪后秒级原子切换 current 软链接,30s 倒计时探活自动无缝恢复。" + } + ] + }, + "lanes": [ + { + "id": "ui", + "label": "管理控制台 (前端 UI)" + }, + { + "id": "app", + "label": "Web 应用后端 (Node.js)" + }, + { + "id": "system", + "label": "系统底层与运行时 (Linux / systemd)" + }, + { + "id": "git", + "label": "Gitea 官方源 (HTTPS)" + } + ], + "phases": [ + { + "id": "phase_check", + "label": "版本发现", + "fromCol": 0, + "toCol": 1 + }, + { + "id": "phase_download", + "label": "直连下载与校验", + "fromCol": 2, + "toCol": 3, + "variant": "emphasis" + }, + { + "id": "phase_apply", + "label": "原子切换与自愈", + "fromCol": 4, + "toCol": 5, + "variant": "dashed" + } + ], + "groups": [ + { + "id": "grp_stream", + "label": "应用内直接流式拉取 (无外部阻塞)", + "lane": "app", + "fromCol": 2, + "toCol": 3, + "variant": "emphasis" + } + ], + "mainPath": [ + "ui_check", + "api_check", + "git_source", + "ui_render", + "stream_worker", + "verify_sha", + "ui_ready", + "atomic_switch", + "health_probe", + "ui_refreshed" + ], + "nodes": [ + { + "id": "ui_check", + "lane": "ui", + "col": 0, + "type": "frontend", + "label": "检查更新", + "sublabel": "点击查询新版" + }, + { + "id": "api_check", + "lane": "app", + "col": 0, + "type": "backend", + "label": "查询 Release", + "sublabel": "只读接口校验", + "tag": "只读" + }, + { + "id": "git_source", + "lane": "git", + "col": 1, + "type": "external", + "label": "Gitea 官方源", + "sublabel": "返回最新元数据", + "tag": "HTTPS" + }, + { + "id": "ui_render", + "lane": "ui", + "col": 1, + "type": "frontend", + "label": "版本看板呈现", + "sublabel": "日志与升级入口" + }, + { + "id": "stream_worker", + "lane": "app", + "col": 2, + "type": "backend", + "label": "流式拉取", + "sublabel": "应用直连下载", + "tag": "实时进度" + }, + { + "id": "verify_sha", + "lane": "app", + "col": 3, + "type": "security", + "label": "SHA-256 校验", + "sublabel": "比对并解压", + "tag": "完整性" + }, + { + "id": "ui_ready", + "lane": "ui", + "col": 3, + "type": "frontend", + "label": "确认重启", + "sublabel": "更新包已就绪" + }, + { + "id": "atomic_switch", + "lane": "system", + "col": 4, + "type": "cloud", + "label": "原子切换", + "sublabel": "切换软链接重载" + }, + { + "id": "health_probe", + "lane": "app", + "col": 5, + "type": "backend", + "label": "健康探测探针", + "sublabel": "轮询探活至 200" + }, + { + "id": "ui_refreshed", + "lane": "ui", + "col": 5, + "type": "frontend", + "label": "平滑上线刷新", + "sublabel": "自动进入新版本" + } + ], + "edges": [ + { + "id": "e1", + "from": "ui_check", + "to": "api_check" + }, + { + "id": "e2", + "from": "api_check", + "to": "git_source" + }, + { + "id": "e3", + "from": "git_source", + "to": "ui_render", + "channelX": 250 + }, + { + "id": "e4", + "from": "ui_render", + "to": "stream_worker" + }, + { + "id": "e5", + "from": "stream_worker", + "to": "verify_sha" + }, + { + "id": "e6", + "from": "verify_sha", + "to": "ui_ready" + }, + { + "id": "e7", + "from": "ui_ready", + "to": "atomic_switch" + }, + { + "id": "e8", + "from": "atomic_switch", + "to": "health_probe" + }, + { + "id": "e9", + "from": "health_probe", + "to": "ui_refreshed" + } + ], + "cards": [ + { + "dot": "emerald", + "title": "核心升级点:消除外部调度依赖", + "items": [ + "传统模式:Web 写入 JSON 队列,傻等外部 root 守护进程监听唤醒,导致常态化卡死在等待系统调度", + "新模式:Web 后端进程直接建立 HTTPS 流式管道下载,0 秒立即响应,进度条真实可见" + ] + }, + { + "dot": "cyan", + "title": "透明化监控与错误拦截", + "items": [ + "网络层直抓:DNS 失败、超时或 404 当场捕获,前端弹窗直接展示错误详情与重试按钮", + "进度实时计算:每 500ms 计算下载字节与传输速率(MB/s),无感后台拉取" + ] + }, + { + "dot": "violet", + "title": "平滑原子切换与自愈", + "items": [ + "文件完整校验后再切换软链接,绝不损坏现有运行中的实例", + "前端 30 秒倒计时探针自动检测服务就绪,服务重启完毕自动恢复会话" + ] + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 54255d2..3ccb2ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.35", + "version": "1.1.36", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/server/app.ts b/server/app.ts index a078fa7..053f560 100644 --- a/server/app.ts +++ b/server/app.ts @@ -63,6 +63,7 @@ import { readCachedRelease, writeUpdateRequest, cancelUpdateJob, + triggerInProcessDownload, type UpdateRequest, } from "./update-service.js"; @@ -938,6 +939,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { // task (or the latest successful completion for confirmation). const row = database.sqlite.prepare(` SELECT id, operation, status, version, platform, asset_name AS assetName, + asset_url AS assetUrl, release_url AS releaseUrl, size_bytes AS sizeBytes, error_message AS errorMessage, created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt, downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt, @@ -1179,6 +1181,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { 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", "无法提交更新请求,请检查服务安装权限"); } + triggerInProcessDownload(database.sqlite, config, id, cachedAsset, cachedAsset.sha256); reply.header("Cache-Control", "no-store"); return reply.code(202).send({ job: { id, status: "queued", operation: "download", version } }); }); @@ -1199,6 +1202,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { reconcileOrphanedUpdateJobs(database.sqlite, config); const row = database.sqlite.prepare(` SELECT id, operation, status, version, platform, asset_name AS assetName, + asset_url AS assetUrl, release_url AS releaseUrl, size_bytes AS sizeBytes, error_message AS errorMessage, created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt, downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt, diff --git a/server/update-service.ts b/server/update-service.ts index 3b40ba3..8fd14cb 100644 --- a/server/update-service.ts +++ b/server/update-service.ts @@ -18,11 +18,108 @@ import { selectReleaseAsset, validateHttpsUrl, RELEASE_NOTES_MAX_BYTES, + downloadReleaseAsset, + extractSafeArchive, + normalizeReleasePermissions, + applicationUpdateRuntimeHash, type ReleaseAsset, type ReleaseMetadata, } from "./update.js"; import type { UpdateJobStatus } from "../shared/contracts.js"; + +export const activeInProcessDownloads = new Map(); + +export function triggerInProcessDownload( + database: Database.Database, + config: AppConfig, + jobId: string, + asset: { name: string; url: string; sha256?: string }, + expectedSha256?: string, +): void { + setImmediate(async () => { + try { + const row = database.prepare("SELECT id, status, operation FROM update_jobs WHERE id=?").get(jobId) as { id: string; status: string; operation: string } | undefined; + if (!row || row.status !== "queued") return; + + const controller = new AbortController(); + activeInProcessDownloads.set(jobId, controller); + + const workspace = path.join(path.resolve(config.stagingDir), `update-${jobId}`); + const archivePath = path.join(workspace, asset.name.endsWith(".gz") || asset.name.endsWith(".zip") ? asset.name : `${asset.name}.tar.gz`); + + await mkdir(workspace, { recursive: true, mode: 0o700 }); + const now = Date.now(); + database.prepare("UPDATE update_jobs SET status='downloading', download_started_at=?, started_at=?, download_path=?, updated_at=? WHERE id=? AND status='queued'").run(now, now, path.basename(archivePath), now, jobId); + + const progressStartedAt = Date.now(); + let lastProgressWrite = 0; + + const downloaded = await downloadReleaseAsset(asset.url, archivePath, { + allowedHosts: config.updateAllowedHosts, + maxBytes: config.updateMaxBytes, + fetchImpl: (input, init) => fetch(input, { ...init, signal: controller.signal }), + onProgress: (downloadedBytes, totalBytes) => { + const cur = Date.now(); + if (cur - lastProgressWrite < 200) return; + lastProgressWrite = cur; + const elapsed = Math.max(1, cur - progressStartedAt); + const speedBps = Math.round(downloadedBytes * 1000 / elapsed); + try { + database.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=COALESCE(?, size_bytes), download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloadedBytes, totalBytes, speedBps, cur, jobId); + } catch {} + }, + }); + + if (expectedSha256 && downloaded.sha256.toLowerCase() !== expectedSha256.toLowerCase()) { + throw new Error("更新文件 SHA-256 校验失败"); + } + + database.prepare("UPDATE update_jobs SET status='verifying', actual_sha256=?, size_bytes=?, downloaded_bytes=?, updated_at=? WHERE id=? AND status='downloading'").run(downloaded.sha256, downloaded.size, downloaded.size, Date.now(), jobId); + + const stagedDir = path.join(workspace, "payload"); + await extractSafeArchive(archivePath, stagedDir, config.updateMaxBytes === undefined ? {} : { maxBytes: config.updateMaxBytes }); + + if (applicationUpdateRuntimeHash(asset.name)) { + try { + const currentRelease = realpathSync(config.currentLink); + if (currentRelease) { + const fsPromises = await import("node:fs/promises"); + for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) { + const source = path.join(currentRelease, entry); + const sourceInfo = await fsPromises.lstat(source).catch(() => null); + if (sourceInfo && !sourceInfo.isSymbolicLink()) { + await fsPromises.cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false }).catch(() => {}); + } + } + } + } catch {} + } + + await normalizeReleasePermissions(stagedDir).catch(() => {}); + const fsPromises = await import("node:fs/promises"); + const payloadInfo = await fsPromises.lstat(path.join(stagedDir, "dist")).catch(() => null); + if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) { + throw new Error("发布包缺少 dist 目录"); + } + + database.prepare("UPDATE update_jobs SET status='staged', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')").run(downloaded.sha256, downloaded.size, workspace, Date.now(), jobId); + } catch (error) { + const controller = activeInProcessDownloads.get(jobId); + if (controller?.signal.aborted) return; + const rawMsg = error instanceof Error ? error.message : "更新文件下载失败"; + try { + database.prepare("UPDATE update_jobs SET status='failed', error_message=?, updated_at=? WHERE id=? AND status NOT IN ('completed', 'staged', 'cancelled')").run(rawMsg, Date.now(), jobId); + } catch {} + const workspace = path.join(path.resolve(config.stagingDir), `update-${jobId}`); + const fsPromises = await import("node:fs/promises"); + await fsPromises.rm(workspace, { recursive: true, force: true }).catch(() => {}); + } finally { + activeInProcessDownloads.delete(jobId); + } + }); +} + export const UPDATE_CACHE_KEY = "update.release.v1"; export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [ "queued", @@ -347,6 +444,15 @@ export async function writeUpdateRequest(config: AppConfig, request: UpdateReque } } + +function safePublicErrorMessage(msg: unknown): string { + if (typeof msg !== "string" || !msg.trim()) return "更新失败,请查看服务器日志或重试"; + if (msg.includes("/var/lib") || msg.includes("/opt/") || msg.includes("/etc/") || msg.includes("secret") || msg.includes("command-output")) { + return "更新失败,请查看服务器日志或重试"; + } + return msg.trim(); +} + export function publicUpdateJob(row: Record | undefined): Record | null { if (!row) return null; const hasError = typeof row.errorMessage === "string" && row.errorMessage.length > 0; @@ -359,14 +465,13 @@ export function publicUpdateJob(row: Record | undefined): Recor version: row.version, platform: row.platform, assetName: row.assetName ?? null, + assetUrl: row.assetUrl ?? null, + releaseUrl: row.releaseUrl ?? null, sizeBytes: row.sizeBytes ?? null, downloadedBytes: row.downloadedBytes ?? null, downloadStartedAt: row.downloadStartedAt ?? null, downloadSpeedBps: row.downloadSpeedBps ?? null, - // Do not expose filesystem paths, command output, or upstream response - // text through the authenticated status endpoint. Detailed diagnostics - // remain in the server journal for operators. - errorMessage: hasError ? "更新失败,请查看服务器日志或重试" : null, + errorMessage: hasError ? safePublicErrorMessage(row.errorMessage) : null, createdAt: row.createdAt, updatedAt: row.updatedAt, completedAt: row.completedAt ?? null, @@ -546,15 +651,21 @@ export function cancelUpdateJob( jobId?: string, ): { cancelled: boolean; message?: string } { const job = jobId - ? database.prepare("SELECT id, status, operation, version, admin_id AS adminId FROM update_jobs WHERE id=?").get(jobId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null } | undefined - : database.prepare("SELECT id, status, operation, version, admin_id AS adminId FROM update_jobs WHERE status='queued' ORDER BY created_at DESC LIMIT 1").get() as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null } | undefined; + ? database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE id=?").get(jobId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined + : database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE status IN ('queued', 'downloading') ORDER BY created_at DESC LIMIT 1").get() as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined; - if (!job) return { cancelled: false, message: "当前没有处于等待调度的更新任务" }; - if (job.status !== "queued") return { cancelled: false, message: "任务已开始处理,无法取消" }; + if (!job) return { cancelled: false, message: "当前没有处于等待调度或下载中的更新任务" }; + if (job.status !== "queued" && job.status !== "downloading") return { cancelled: false, message: "任务已进入就绪或切换阶段,无法取消" }; + + const controller = activeInProcessDownloads.get(job.id); + if (controller) { + controller.abort(); + activeInProcessDownloads.delete(job.id); + } const now = Date.now(); const changed = database.transaction(() => { - const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新排队', completed_at=?, updated_at=? WHERE id=? AND status='queued'").run(now, now, job.id); + const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND status IN ('queued', 'downloading')").run(now, now, job.id); if (result.changes !== 1) return false; writeAudit(database, { requestId, @@ -571,6 +682,10 @@ export function cancelUpdateJob( if (changed) { forceRemoveRequest(config.updateRequestPath); + if (job.downloadPath) { + const target = path.isAbsolute(job.downloadPath) ? job.downloadPath : path.join(config.stagingDir, job.downloadPath); + import("node:fs/promises").then(({ rm }) => rm(target, { recursive: true, force: true })).catch(() => {}); + } return { cancelled: true }; } return { cancelled: false, message: "取消失败,任务状态可能已改变" }; diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index 8a3e9de..d128438 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -190,6 +190,153 @@ describe("更新 API", () => { 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"); }); + + it("应用内直接执行流式下载,并在校验解包后自动推进到 staged 就绪状态", async () => { + const { createSafeArchive } = await import("../server/update.js"); + const { createHash } = await import("node:crypto"); + const { mkdirSync, writeFileSync } = await import("node:fs"); + + const payloadSource = mkdtempSync(path.join(tmpdir(), "tallynote-test-payload-")); + mkdirSync(path.join(payloadSource, "dist"), { recursive: true }); + writeFileSync(path.join(payloadSource, "dist", "server.js"), "console.log(1);"); + const archivePath = path.join(tmpdir(), `tallynote-archive-${randomUUID()}.tar.gz`); + await createSafeArchive(payloadSource, archivePath); + + const archiveBytes = readFileSync(archivePath); + const digest = createHash("sha256").update(archiveBytes).digest("hex"); + const assetName = `tallynote-1.2.0-${detectPlatform().target}-glibc.tar.gz`; + + globalThis.fetch = (async (input: string | URL) => { + const url = input.toString(); + if (url.endsWith("SHA256SUMS")) { + return new Response(`${digest} ${assetName}\n`, { status: 200 }); + } + if (url.endsWith(assetName)) { + return new Response(archiveBytes, { status: 200, headers: { "content-length": String(archiveBytes.length) } }); + } + return new Response(JSON.stringify({ + tag_name: "v1.2.0", + assets: [ + { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, + { name: assetName, browser_download_url: `https://updates.example/${assetName}` } + ] + }), { status: 200 }); + }) as typeof fetch; + + const session = await login("update-inprocess"); + 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.2.0", confirm: true } }); + expect(downloaded.statusCode).toBe(202); + const downloadJobId = downloaded.json().job.id as string; + + // Wait for in-process download pipeline to finish + let stagedRow: { status: string; actual_sha256: string; download_path: string } | undefined; + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 50)); + stagedRow = database.sqlite.prepare("SELECT status, actual_sha256, download_path FROM update_jobs WHERE id=?").get(downloadJobId) as any; + if (stagedRow?.status === "staged" || stagedRow?.status === "failed") break; + } + + expect(stagedRow?.status).toBe("staged"); + expect(stagedRow?.actual_sha256).toBe(digest); + expect(existsSync(path.join(stagedRow!.download_path, "payload", "dist", "server.js"))).toBe(true); + + const statusRes = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); + expect(statusRes.statusCode).toBe(200); + expect(statusRes.json().job).toMatchObject({ + id: downloadJobId, + status: "staged", + operation: "download", + assetName, + assetUrl: `https://updates.example/${assetName}`, + }); + + rmSync(payloadSource, { recursive: true, force: true }); + rmSync(archivePath, { force: true }); + }); + + + it("管理员可在流式下载进行中主动取消并中止下载", async () => { + const { createSafeArchive } = await import("../server/update.js"); + const { createHash } = await import("node:crypto"); + const { mkdirSync, writeFileSync } = await import("node:fs"); + + const payloadSource = mkdtempSync(path.join(tmpdir(), "tallynote-cancel-payload-")); + mkdirSync(path.join(payloadSource, "dist"), { recursive: true }); + writeFileSync(path.join(payloadSource, "dist", "server.js"), "console.log(1);"); + const archivePath = path.join(tmpdir(), `tallynote-cancel-${randomUUID()}.tar.gz`); + await createSafeArchive(payloadSource, archivePath); + + const archiveBytes = readFileSync(archivePath); + const digest = createHash("sha256").update(archiveBytes).digest("hex"); + const assetName = `tallynote-1.2.0-${detectPlatform().target}-glibc.tar.gz`; + + // Mock a slow stream + let fetchAborted = false; + globalThis.fetch = (async (input: string | URL, init?: any) => { + const url = input.toString(); + if (url.endsWith("SHA256SUMS")) { + return new Response(`${digest} ${assetName}\n`, { status: 200 }); + } + if (url.endsWith(assetName)) { + init?.signal?.addEventListener("abort", () => { + fetchAborted = true; + }); + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(archiveBytes.slice(0, 50)); + // Simulate hanging network until aborted + await new Promise((resolve) => { + if (init?.signal?.aborted) return resolve(undefined); + init?.signal?.addEventListener("abort", () => resolve(undefined)); + }); + controller.close(); + } + }); + return new Response(stream, { status: 200, headers: { "content-length": String(archiveBytes.length) } }); + } + return new Response(JSON.stringify({ + tag_name: "v1.2.0", + assets: [ + { name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, + { name: assetName, browser_download_url: `https://updates.example/${assetName}` } + ] + }), { status: 200 }); + }) as typeof fetch; + + const session = await login("update-cancel-inprocess"); + await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); + + 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.2.0", confirm: true } }); + const downloadJobId = downloaded.json().job.id as string; + + // Wait until status becomes downloading + for (let i = 0; i < 30; i++) { + await new Promise((r) => setTimeout(r, 30)); + const row = database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(downloadJobId) as any; + if (row?.status === "downloading") break; + } + + const cancelRes = await app.inject({ + method: "POST", + url: "/api/update/cancel", + headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, + payload: { jobId: downloadJobId } + }); + + expect(cancelRes.statusCode).toBe(200); + expect(cancelRes.json().success).toBe(true); + + const cancelledRow = database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(downloadJobId) as any; + expect(cancelledRow?.status).toBe("cancelled"); + expect(fetchAborted).toBe(true); + + rmSync(payloadSource, { recursive: true, force: true }); + rmSync(archivePath, { force: true }); + }); + it("管理员可主动取消排队中的更新任务并清理请求文件", async () => { const session = await login("update-cancel"); mockRelease(); diff --git a/web-next/src/components/BeamLoading.tsx b/web-next/src/components/BeamLoading.tsx new file mode 100644 index 0000000..f97367e --- /dev/null +++ b/web-next/src/components/BeamLoading.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; + +export function BeamBar({ + className = "", + width = 140, + height = 4, +}: { + className?: string; + width?: number | string; + height?: number; +}) { + return ( +
+ ); +} + +export function BeamLoading({ + text, + className = "", + width, +}: { + text?: ReactNode; + className?: string; + width?: number | string; +}) { + return ( +
+ + {text && {text}} +
+ ); +} + +export default BeamLoading; diff --git a/web-next/src/main.tsx b/web-next/src/main.tsx index a368431..b32ada0 100644 --- a/web-next/src/main.tsx +++ b/web-next/src/main.tsx @@ -1,3 +1,4 @@ +import { BeamLoading } from "./components/BeamLoading"; 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"; @@ -87,7 +88,7 @@ function App() { // Keep the login form mounted for those requests so the user sees the // button's busy state instead of losing the entire form to a bootstrap // spinner. `bootstrapRequestId` is only set by the initial session check. - if (isSessionBootstrapping(session)) return
; + if (isSessionBootstrapping(session)) return
; if (session.status === "error") return

无法连接服务

{session.error || "服务暂时无法连接,请稍后重试或检查网络状态。"}

; if (!session.admin) return : ; - return { if (!passwordOpen) requestDiscard(() => setPasswordOpen(true)); }}>}>
{content}
; + return { if (!passwordOpen) requestDiscard(() => setPasswordOpen(true)); }}>}>
{content}
; } function RouteErrorPage() { diff --git a/web-next/src/pages/common.tsx b/web-next/src/pages/common.tsx index f46a82f..abb0be4 100644 --- a/web-next/src/pages/common.tsx +++ b/web-next/src/pages/common.tsx @@ -1,3 +1,4 @@ +import { BeamLoading, BeamBar } from "../components/BeamLoading"; import type { ReactNode } from "react"; import { AlertCircle, Loader2 } from "lucide-react"; import { Alert, Button, Card, Loading, Space, Tag } from "tdesign-react"; @@ -26,10 +27,10 @@ export function AsyncState({ loading, error, empty, onRetry, children }: { onRetry?: () => void; children: ReactNode; }) { - if (loading && empty) return
; + if (loading && empty) return
; if (error && empty) return

{error}

{onRetry && }
; return <> - {loading &&
} + {loading &&
正在同步…
} {error && } {empty || children} ; diff --git a/web-next/src/pages/dashboard/DashboardPage.tsx b/web-next/src/pages/dashboard/DashboardPage.tsx index 3c8d6d5..4ccc4ea 100644 --- a/web-next/src/pages/dashboard/DashboardPage.tsx +++ b/web-next/src/pages/dashboard/DashboardPage.tsx @@ -1,3 +1,4 @@ +import { BeamLoading } from "../../components/BeamLoading"; 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"; @@ -140,7 +141,7 @@ export default function DashboardPage({ timezone = "Asia/Shanghai", onNavigate }
{error && hasCurrentSnapshot && } message={`刷新失败:${error}。当前展示的是本月最近一次成功加载的数据。`} />} - {loading ?
: !hasCurrentSnapshot ?
} message={error || "暂时无法读取仪表盘数据"} />
: <> + {loading ?
: !hasCurrentSnapshot ?
} message={error || "暂时无法读取仪表盘数据"} />
: <>
diff --git a/web-next/src/pages/expenses/ExpenseDetail.tsx b/web-next/src/pages/expenses/ExpenseDetail.tsx index 57badaf..54aa282 100644 --- a/web-next/src/pages/expenses/ExpenseDetail.tsx +++ b/web-next/src/pages/expenses/ExpenseDetail.tsx @@ -1,3 +1,4 @@ +import { BeamLoading } from "../../components/BeamLoading"; 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"; @@ -57,7 +58,7 @@ export default function ExpenseDetail({ expense, timezone = "Asia/Shanghai", onC const remove = async () => { if (!removeTarget) return; const requires = removeTarget.kind === "invoice" && detail.invoiceCount <= 1 && !detail.invoiceMissingReason?.trim(); if (requires && !removeReason.trim()) { setRemoveError("请填写无发票原因"); return; } setBusy(true); setRemoveError(""); try { const body: { version: number; invoiceMissingReason?: string } = { version: detail.version }; if (requires) body.invoiceMissingReason = removeReason.trim(); const result = await api<{ expense: Expense }>(`/api/attachments/${removeTarget.id}`, { method: "DELETE", body: JSON.stringify(body) }); setDetail(result.expense); setRemoveTarget(null); notify?.("附件已删除", "success"); onUpdated(); } catch (caught) { if (!recoverConflict(caught, setRemoveError)) setRemoveError((caught as Error).message); } finally { setBusy(false); } }; return <> }> - {loading ?
: error ?
{error}
:
+ {loading ?
: error ?
{error}
:
{money(detail.amountCents)}
支付时间
{dateText(detail.paidAt, timezone)}
发票
{detail.invoiceCount > 0 ? `${detail.invoiceCount} 张` : detail.invoiceMissingReason ? <>无发票:{detail.invoiceMissingReason} : 未说明}
状态
{detail.status === "reimbursed" ? "已报销" : "未报销"}
备注
{detail.note || "无"}

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

{(detail.attachments || []).map(a => { const protectsLastProof = a.kind === "payment_proof" && detail.paymentProofCount <= 1; return
{a.mimeType.startsWith("image/") ? : } {a.originalName}{formatBytes(a.sizeBytes)}{a.previewable && }
; })}
diff --git a/web-next/src/pages/expenses/ExpensesPage.tsx b/web-next/src/pages/expenses/ExpensesPage.tsx index 7227b7e..c93b44b 100644 --- a/web-next/src/pages/expenses/ExpensesPage.tsx +++ b/web-next/src/pages/expenses/ExpensesPage.tsx @@ -1,3 +1,4 @@ +import { BeamLoading } from "../../components/BeamLoading"; 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"; @@ -124,7 +125,7 @@ export default function ExpensesPage({ timezone: timezoneProp = "Asia/Shanghai",
{summary.count} 笔{money(summary.amountCents)}{selectedKeys.length > 0 && <>已选 {selectedKeys.length} 笔,共 {money(selectedTotal)}}
{exportIssue &&
{exportIssue}
} {error &&
{error}
} - {error ? null : !dataReady || (loading && !items.length) ?
: !items.length ? emptyState :
setSelectedKeys(keys as string[])} hover stripe />} + {error ? null : !dataReady || (loading && !items.length) ?
: !items.length ? emptyState :
setSelectedKeys(keys as string[])} hover stripe />} {drawer === "new" && setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "edit" && selected && setDrawer(null)} onSaved={load} notify={notify} />}{drawer === "detail" && selected && setDrawer(null)} onUpdated={load} onRequestEdit={e => { setSelected(e); setDrawer("edit"); }} notify={notify} />} {trashTarget && { if (!trashing) setTrashTarget(null); }} onConfirm={() => void moveToTrash()} onCancel={() => { if (!trashing) setTrashTarget(null); }}>确认将“{trashTarget.note || `${dateText(trashTarget.paidAt, timezone)}的账目`}”移入回收站?移入后将不在正常列表中展示,关联附件将完整保留,可随时恢复。} ; diff --git a/web-next/src/pages/update/UpdatePage.tsx b/web-next/src/pages/update/UpdatePage.tsx index 5437c8e..2cd324b 100644 --- a/web-next/src/pages/update/UpdatePage.tsx +++ b/web-next/src/pages/update/UpdatePage.tsx @@ -1,3 +1,4 @@ +import { BeamLoading, BeamBar } from "../../components/BeamLoading"; import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { RefreshCw, @@ -48,6 +49,8 @@ export interface UpdateJob { version: string; platform: string; assetName?: string | null; + assetUrl?: string | null; + releaseUrl?: string | null; sizeBytes?: number | null; downloadedBytes?: number | null; downloadStartedAt?: number | string | null; @@ -73,6 +76,8 @@ export interface LatestRelease { releaseNotes?: string; body?: string; releaseUrl?: string; + metadataUrl?: string; + assetUrl?: string; compatible: boolean; integrityReady: boolean; assetName: string; @@ -609,8 +614,7 @@ export default function UpdatePage({ {loading && !info ? ( - -

正在读取系统更新服务状态…

+
) : !info ? ( @@ -662,7 +666,7 @@ export default function UpdatePage({ {info.checkedAt ? dateText(info.checkedAt, timezone) : "未检查"}
- 数据源: 官方发布渠道 + 源: git.awaioi.com (HTTPS)
@@ -709,10 +713,32 @@ export default function UpdatePage({
下载策略: - 后台静默传输 + 应用内流式直连 (零调度等待)
+
+
+ + 安装包下载直链 ({latest.assetName || "发布包"}): + + + {latest.assetUrl || `https://git.awaioi.com/awaioi/TallyNote/releases/download/v${latest.version}/${latest.assetName || `tallynote-${latest.version}-linux-x64-glibc.tar.gz`}`} + +
+ +
+
{canDownload && (
@@ -941,8 +966,8 @@ export default function UpdatePage({ {/* 场景 D: 数据快照备份中或服务重启中 (backing_up / applying) (Exact ASCII) */} {(job?.status === "backing_up" || job?.status === "applying") && (
-
- +
+
服务正在平滑重启并执行健康检查...
diff --git a/web-next/src/styles/theme.css b/web-next/src/styles/theme.css index 6e50c87..4398b6b 100644 --- a/web-next/src/styles/theme.css +++ b/web-next/src/styles/theme.css @@ -100,11 +100,121 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { .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); } +/* ============================================================ + Toast 样式四:柔和语义双层卡片 (Soft Tinted Card / 严格水平对齐) + ============================================================ */ +.t-notification { + width: min(380px, calc(100vw - 32px)) !important; + max-width: calc(100vw - 32px) !important; + border-radius: 6px !important; + padding: 12px 16px !important; + box-shadow: 0 10px 25px -4px rgba(15, 23, 42, 0.08), 0 4px 10px -2px rgba(15, 23, 42, 0.04) !important; + display: flex !important; + align-items: center !important; + gap: 10px !important; + box-sizing: border-box !important; + transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important; +} +.t-notification__show--top-right { + right: 20px !important; +} + +/* 核心对齐修复:清除 TDesign 默认的 margin-top: 8px 导致的文字下沉不居中 */ +.t-notification__main { + flex: 1 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + margin: 0 !important; + padding: 0 !important; + min-width: 0 !important; +} +.t-notification__content { + color: inherit !important; + font-size: 13.5px !important; + font-weight: 500 !important; + line-height: 1.4 !important; + margin: 0 !important; + padding: 0 !important; + display: block !important; + word-break: break-word !important; +} +.t-notification__icon { + margin: 0 !important; + padding: 0 !important; + width: 20px !important; + height: 20px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + flex-shrink: 0 !important; + line-height: 1 !important; +} +.t-notification__icon .t-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + display: block !important; + margin: 0 auto !important; +} +.t-notification__close { + display: flex !important; + align-items: center !important; + justify-content: center !important; + margin: 0 0 0 auto !important; + padding: 0 !important; + height: 20px !important; + width: 20px !important; + line-height: 1 !important; + color: inherit !important; + opacity: 0.55 !important; + cursor: pointer !important; + transition: opacity 0.15s ease !important; + flex-shrink: 0 !important; +} +.t-notification__close:hover { + opacity: 1 !important; +} + +/* Success: 柔和薄荷浅绿 */ +.t-notification.t-is-success { + background: #f0fdf4 !important; + border: 1px solid #bbf7d0 !important; + color: #166534 !important; +} +.t-notification.t-is-success .t-notification__icon { + color: #16a34a !important; +} + +/* Info: 柔和天空浅蓝 */ +.t-notification.t-is-info { + background: #eff6ff !important; + border: 1px solid #bfdbfe !important; + color: #1e40af !important; +} +.t-notification.t-is-info .t-notification__icon { + color: #2563eb !important; +} + +/* Warning: 柔和金橙浅黄 */ +.t-notification.t-is-warning { + background: #fffbeb !important; + border: 1px solid #fde68a !important; + color: #92400e !important; +} +.t-notification.t-is-warning .t-notification__icon { + color: #d97706 !important; +} + +/* Error: 柔和玫瑰浅红 */ +.t-notification.t-is-error { + background: #fef2f2 !important; + border: 1px solid #fecaca !important; + color: #991b1b !important; +} +.t-notification.t-is-error .t-notification__icon { + color: #dc2626 !important; +} .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; } @@ -201,7 +311,59 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { .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; } +/* Absolutely Centered Loading and Empty States */ +.tn-empty { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + min-height: max(380px, calc(100vh - 320px)) !important; + width: 100% !important; + height: 100% !important; + flex: 1 !important; + gap: 12px !important; + padding: 40px 20px !important; + color: var(--tn-text-muted) !important; + text-align: center !important; + box-sizing: border-box !important; +} +.expenses-page .tn-empty, +.tn-dashboard-page .tn-empty, +.tn-audit-table .tn-empty { + min-height: max(400px, calc(100vh - 300px)) !important; +} +.tn-detail-drawer .tn-empty, +.tn-detail-drawer .t-drawer__body > div[role="status"] { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + min-height: calc(100vh - 180px) !important; + height: 100% !important; + width: 100% !important; +} +.tn-auth-shell { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + min-height: 100vh !important; + height: 100% !important; + padding: 24px !important; + background: var(--tn-page) !important; +} +.tn-page-loading { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + min-height: calc(100vh - 120px) !important; + height: 100% !important; + width: 100% !important; + padding: 32px 20px !important; + color: var(--tn-text-muted) !important; + text-align: center !important; +} .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; } @@ -1421,10 +1583,18 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { margin: 16px 0; } .tn-modal-card-title { - font-size: 15px; - font-weight: 600; - color: var(--tn-navy-900); - margin-bottom: 14px; + font-size: 15px !important; + font-weight: 600 !important; + color: var(--tn-navy-900) !important; + margin-bottom: 14px !important; + display: flex !important; + align-items: center !important; + gap: 10px !important; + line-height: 1.4 !important; +} +.tn-modal-card-title svg { + flex-shrink: 0 !important; + display: block !important; } .tn-modal-metrics-row { display: flex; @@ -1467,3 +1637,89 @@ input:focus-visible, textarea:focus-visible, select:focus-visible { gap: 10px; font-size: 12px; } + + +/* ============================================================ + 方案二:平滑极光光流条 (Beam Bar / Shimmer) 全局动效规范 + ============================================================ */ +.tn-beam-loading { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + gap: 16px !important; + padding: 40px 20px !important; + width: 100% !important; + height: 100% !important; + flex: 1 !important; + margin: auto !important; + text-align: center !important; +} +.tn-beam-bar { + width: 140px; + height: 4px; + background: #e2e8f0; + border-radius: 4px; + overflow: hidden; + position: relative; +} +.tn-beam-bar::after { + content: ""; + position: absolute; + top: 0; + left: -60px; + width: 60px; + height: 100%; + background: linear-gradient(90deg, transparent, var(--tn-blue-600), transparent); + border-radius: 4px; + animation: tn-beam-flow 1.3s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +.tn-beam-bar-sm { + width: 60px !important; + height: 3px !important; + display: inline-block; + vertical-align: middle; +} +.tn-beam-bar-sm::after { + width: 30px; + animation: tn-beam-flow-sm 1.1s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +@keyframes tn-beam-flow { + 0% { left: -60px; } + 100% { left: 140px; } +} + +@keyframes tn-beam-flow-sm { + 0% { left: -30px; } + 100% { left: 60px; } +} + +.tn-beam-text { + font-size: 13.5px; + color: var(--tn-text-secondary); + letter-spacing: 0.4px; +} + +/* 彻底隐去 TDesign 默认圆环旋转圈圈,统一切换为沉稳光流 */ +.t-loading__gradient { + display: none !important; +} +.t-loading { + display: inline-flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; + gap: 12px !important; +} + +.tn-drawer-loading-wrap { + min-height: max(420px, calc(100vh - 200px)) !important; + height: 100% !important; + width: 100% !important; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + justify-content: center !important; +}