Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a070ad0434 | ||
|
|
3ab3e5e180 | ||
|
|
6c96cddd4e | ||
|
|
efbd0e0d87 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tallynote",
|
"name": "tallynote",
|
||||||
"version": "1.2.4",
|
"version": "1.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@9.0.6",
|
"packageManager": "pnpm@9.0.6",
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ import {
|
|||||||
validateNewPassword,
|
validateNewPassword,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
} from "./security.js";
|
} from "./security.js";
|
||||||
|
import { isNewerVersion } from "./update.js";
|
||||||
import {
|
import {
|
||||||
ACTIVE_UPDATE_STATUSES,
|
ACTIVE_UPDATE_STATUSES,
|
||||||
checkForUpdate,
|
checkForUpdate,
|
||||||
|
currentReleaseVersion,
|
||||||
publicCheckFromCache,
|
publicCheckFromCache,
|
||||||
publicUpdateJob,
|
publicUpdateJob,
|
||||||
reconcileOrphanedUpdateJobs,
|
reconcileOrphanedUpdateJobs,
|
||||||
@@ -1012,6 +1014,15 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
|||||||
const stagedJobId = 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;
|
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 || staged.status !== "staged" || staged.version !== input.version.replace(/^v/i, "")) throw new AppError(409, "UPDATE_NOT_STAGED", "更新任务尚未完成下载");
|
||||||
|
// A package may have been downloaded before the host was upgraded by
|
||||||
|
// another path. Never apply a staged archive that is no longer newer
|
||||||
|
// than the release currently serving traffic.
|
||||||
|
const effectiveCurrentVersion = currentReleaseVersion(config) ?? config.appVersion;
|
||||||
|
if (!isNewerVersion(effectiveCurrentVersion, staged.version)) {
|
||||||
|
const now = Date.now();
|
||||||
|
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, completed_at=?, updated_at=? WHERE id=? AND status='staged'").run("暂存更新已过期,当前版本无需再次升级", now, now, stagedJobId);
|
||||||
|
throw new AppError(409, "UPDATE_NOT_AVAILABLE", "暂存更新已过期,请重新检查更新");
|
||||||
|
}
|
||||||
if (staged.operation === "apply") throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
|
if (staged.operation === "apply") throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
|
||||||
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply);
|
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ function recoveryStateJobId(filePath: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentReleaseVersion(config: AppConfig): string | null {
|
export function currentReleaseVersion(config: AppConfig): string | null {
|
||||||
try {
|
try {
|
||||||
const target = realpathSync(config.currentLink);
|
const target = realpathSync(config.currentLink);
|
||||||
const releases = realpathSync(config.releasesDir);
|
const releases = realpathSync(config.releasesDir);
|
||||||
@@ -487,12 +487,48 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
|
|||||||
let reconciled = 0;
|
let reconciled = 0;
|
||||||
const reconciledIds = new Set<string>();
|
const reconciledIds = new Set<string>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
// A fresh request/state marker means the privileged runner still owns the
|
||||||
|
// hand-off. Do not expire a staged/apply row while the runner is finishing
|
||||||
|
// a successful switch and finalization after a service restart.
|
||||||
|
const matchingFreshRequest = requestMarkerJobId === row.id && requestFresh;
|
||||||
|
const matchingFreshState = stateMarkerJobId === row.id && stateFresh;
|
||||||
|
// A staged archive is actionable only while it is strictly newer than the
|
||||||
|
// release currently serving requests. This can become false when an
|
||||||
|
// administrator upgrades the host by another path (or another operator
|
||||||
|
// completes the same release) before returning to this page. Treat the
|
||||||
|
// archive as an expired terminal task so it cannot keep blocking the
|
||||||
|
// queue or appear as an "apply" action for the current version.
|
||||||
|
const effectiveCurrentVersion = releaseVersion ?? config.appVersion;
|
||||||
|
if (row.status === "staged" && !isNewerVersion(effectiveCurrentVersion, row.version) && !matchingFreshRequest && !matchingFreshState) {
|
||||||
|
const changed = database.transaction(() => {
|
||||||
|
const result = database.prepare(`
|
||||||
|
UPDATE update_jobs
|
||||||
|
SET status='failed', error_message=?, completed_at=?, updated_at=?
|
||||||
|
WHERE id=? AND status='staged'
|
||||||
|
`).run("暂存更新已过期,当前版本无需再次升级", now, now, row.id);
|
||||||
|
if (result.changes !== 1) return false;
|
||||||
|
writeAudit(database, {
|
||||||
|
requestId: row.requestId || randomUUID(),
|
||||||
|
actorAdminId: row.adminId,
|
||||||
|
action: "update.reconciled",
|
||||||
|
targetType: "update",
|
||||||
|
targetId: row.id,
|
||||||
|
outcome: "failure",
|
||||||
|
before: { status: row.status, operation: row.operation, version: row.version },
|
||||||
|
after: { status: "failed", version: row.version, reason: "staged_version_not_newer" },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
if (changed) {
|
||||||
|
reconciled += 1;
|
||||||
|
reconciledIds.add(row.id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// A request that never gets claimed by the root runner must not remain in
|
// A request that never gets claimed by the root runner must not remain in
|
||||||
// the UI as an endless "queued" task. Once the short hand-off window has
|
// the UI as an endless "queued" task. Once the short hand-off window has
|
||||||
// elapsed and no recovery marker exists, release the queue explicitly;
|
// elapsed and no recovery marker exists, release the queue explicitly;
|
||||||
// a fresh state marker proves that the runner has already claimed it.
|
// a fresh state marker proves that the runner has already claimed it.
|
||||||
const matchingFreshRequest = requestMarkerJobId === row.id && requestFresh;
|
|
||||||
const matchingFreshState = stateMarkerJobId === row.id && stateFresh;
|
|
||||||
if (row.status === "queued" && typeof row.updatedAt === "number" && !matchingFreshState && now - row.updatedAt >= QUEUED_UPDATE_TIMEOUT_MS) {
|
if (row.status === "queued" && typeof row.updatedAt === "number" && !matchingFreshState && now - row.updatedAt >= QUEUED_UPDATE_TIMEOUT_MS) {
|
||||||
if (matchingFreshRequest) continue;
|
if (matchingFreshRequest) continue;
|
||||||
const changed = database.transaction(() => {
|
const changed = database.transaction(() => {
|
||||||
|
|||||||
@@ -155,6 +155,53 @@ describe("更新 API", () => {
|
|||||||
expect(checked.json().latest).toMatchObject({ version: "1.3.0", isNewer: true });
|
expect(checked.json().latest).toMatchObject({ version: "1.3.0", isNewer: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("不会应用已经等于当前版本的暂存更新", async () => {
|
||||||
|
const session = await login("update-staged-current");
|
||||||
|
const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged-current") as { id: string };
|
||||||
|
const now = Date.now();
|
||||||
|
const stagedId = randomUUID();
|
||||||
|
database.sqlite.prepare(`
|
||||||
|
INSERT INTO update_jobs(
|
||||||
|
id, admin_id, 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,
|
||||||
|
admin.id,
|
||||||
|
config.appVersion,
|
||||||
|
detectPlatform().target,
|
||||||
|
config.updateMetadataUrl,
|
||||||
|
"current.tar.gz",
|
||||||
|
"https://updates.example/current.tar.gz",
|
||||||
|
"c".repeat(64),
|
||||||
|
"c".repeat(64),
|
||||||
|
path.join(config.dataDir, "staged-current"),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
const apply = 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: config.appVersion, confirm: true },
|
||||||
|
});
|
||||||
|
expect(apply.statusCode).toBe(409);
|
||||||
|
// Reconciliation expires same-version staged jobs before the apply route
|
||||||
|
// can consume them, so the public response is the generic not-staged
|
||||||
|
// conflict while the database records the precise expiry reason.
|
||||||
|
expect(apply.json().error.code).toBe("UPDATE_NOT_STAGED");
|
||||||
|
expect(database.sqlite.prepare("SELECT status, error_message AS errorMessage FROM update_jobs WHERE id=?").get(stagedId)).toEqual({
|
||||||
|
status: "failed",
|
||||||
|
errorMessage: "暂存更新已过期,当前版本无需再次升级",
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
|
||||||
|
expect(status.statusCode).toBe(200);
|
||||||
|
expect(status.json().job).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
|
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
|
||||||
const owner = await login("update-owner");
|
const owner = await login("update-owner");
|
||||||
const other = await login("update-other");
|
const other = await login("update-other");
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ function App() {
|
|||||||
const logoutInFlight = useRef(false);
|
const logoutInFlight = useRef(false);
|
||||||
useDialogAccessibility();
|
useDialogAccessibility();
|
||||||
const notify = useCallback((message: string, kind: "success" | "error" | "info" = "info") => {
|
const notify = useCallback((message: string, kind: "success" | "error" | "info" = "info") => {
|
||||||
// The placement container owns the responsive right inset. Keeping the
|
// Keep notices in the lower-right safe area so they do not compete with
|
||||||
// item offset at zero avoids pushing narrow-screen notices off canvas.
|
// the header controls or obscure the page title.
|
||||||
const options = { content: message, duration: 4200, placement: "top-right" as const, offset: [24, 76] as [number, number], zIndex: 6000 };
|
const options = { content: message, duration: 4200, placement: "bottom-right" as const, offset: [24, 24] as [number, number], zIndex: 6000 };
|
||||||
const show = kind === "success" ? NotificationPlugin.success : kind === "error" ? NotificationPlugin.error : NotificationPlugin.info;
|
const show = kind === "success" ? NotificationPlugin.success : kind === "error" ? NotificationPlugin.error : NotificationPlugin.info;
|
||||||
void show(options);
|
void show(options);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -276,8 +276,8 @@ export default function UpdatePage({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = async (showLoading = true) => {
|
const load = async (showLoading = true): Promise<UpdateInfo | null> => {
|
||||||
if (loadInFlight.current) return;
|
if (loadInFlight.current) return null;
|
||||||
loadInFlight.current = true;
|
loadInFlight.current = true;
|
||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -285,18 +285,16 @@ export default function UpdatePage({
|
|||||||
const res = await api<UpdateInfo>("/api/update/status");
|
const res = await api<UpdateInfo>("/api/update/status");
|
||||||
mergeInfo(res);
|
mergeInfo(res);
|
||||||
updateInfoCache = res;
|
updateInfoCache = res;
|
||||||
|
return res;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (showLoading) setLoading(false);
|
if (showLoading) setLoading(false);
|
||||||
loadInFlight.current = false;
|
loadInFlight.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keep terminal jobs visible so operators can understand what happened and
|
// Keep terminal jobs visible so operators can understand what happened and
|
||||||
// recover without guessing. The polling effect below only polls active jobs.
|
// recover without guessing. The polling effect below only polls active jobs.
|
||||||
const job = info?.job ?? null;
|
const job = info?.job ?? null;
|
||||||
@@ -453,7 +451,16 @@ export default function UpdatePage({
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const result = await api<UpdateInfo>("/api/update/check", { method: "POST" });
|
const result = await api<UpdateInfo>("/api/update/check", { method: "POST" });
|
||||||
mergeInfo(result, true);
|
// The check endpoint returns release metadata but not task state. Read
|
||||||
|
// the status endpoint once more so reconciliation performed before the
|
||||||
|
// check is authoritative: an expired staged task must disappear from
|
||||||
|
// this page immediately instead of surviving until a full refresh.
|
||||||
|
const status = await api<UpdateInfo>("/api/update/status");
|
||||||
|
setLiveInfo((current) => ({
|
||||||
|
...(current ?? result),
|
||||||
|
...result,
|
||||||
|
job: status.job,
|
||||||
|
}));
|
||||||
notify?.("版本检查完成", "info");
|
notify?.("版本检查完成", "info");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 429) {
|
if (e instanceof ApiError && e.status === 429) {
|
||||||
@@ -467,6 +474,24 @@ export default function UpdatePage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
const bootstrap = async () => {
|
||||||
|
const snapshot = await load();
|
||||||
|
if (disposed || !snapshot) return;
|
||||||
|
// Status may be satisfied from the release cache. Refresh it on entry
|
||||||
|
// only when the cached result is absent or older than one minute; this
|
||||||
|
// keeps the page current without turning navigation into a burst of
|
||||||
|
// rate-limited checks.
|
||||||
|
const checkedAt = snapshot.checkedAt || 0;
|
||||||
|
if (!checkedAt || !snapshot.latest || Date.now() - checkedAt > 60_000) await check();
|
||||||
|
};
|
||||||
|
void bootstrap();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Cancel queued job handler
|
// Cancel queued job handler
|
||||||
const cancelJob = async () => {
|
const cancelJob = async () => {
|
||||||
if (cancelInFlight.current || !job?.id) return;
|
if (cancelInFlight.current || !job?.id) return;
|
||||||
@@ -539,9 +564,20 @@ export default function UpdatePage({
|
|||||||
|
|
||||||
const latest = info?.latest;
|
const latest = info?.latest;
|
||||||
const notes = notesFor(latest);
|
const notes = notesFor(latest);
|
||||||
|
const hasChecked = Boolean(info?.checkedAt);
|
||||||
|
const releaseState = checking
|
||||||
|
? "checking"
|
||||||
|
: !hasChecked
|
||||||
|
? "unverified"
|
||||||
|
: !latest
|
||||||
|
? "unavailable"
|
||||||
|
: latest.isNewer
|
||||||
|
? latest.compatible && latest.integrityReady ? "available" : "blocked"
|
||||||
|
: "up-to-date";
|
||||||
|
|
||||||
const canDownload = Boolean(
|
const canDownload = Boolean(
|
||||||
info?.strategy === "systemd" &&
|
info?.strategy === "systemd" &&
|
||||||
|
!checking &&
|
||||||
latest?.isNewer &&
|
latest?.isNewer &&
|
||||||
latest.compatible &&
|
latest.compatible &&
|
||||||
latest.integrityReady &&
|
latest.integrityReady &&
|
||||||
@@ -550,14 +586,19 @@ export default function UpdatePage({
|
|||||||
|
|
||||||
const canApply = Boolean(
|
const canApply = Boolean(
|
||||||
info?.strategy === "systemd" &&
|
info?.strategy === "systemd" &&
|
||||||
|
!checking &&
|
||||||
job &&
|
job &&
|
||||||
job.status === "staged" &&
|
job.status === "staged" &&
|
||||||
job.operation === "download"
|
job.operation === "download" &&
|
||||||
|
latest?.isNewer &&
|
||||||
|
latest.version === job.version &&
|
||||||
|
job.version !== info.currentVersion
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasActiveJob = Boolean(
|
const hasActiveJob = Boolean(
|
||||||
job && activeStatuses.has(job.status) && job.status !== "staged"
|
job && activeStatuses.has(job.status) && !(job.status === "staged" && job.operation === "download")
|
||||||
);
|
);
|
||||||
|
const showJobDetails = hasActiveJob || canApply || Boolean(job?.status === "staged" && job.operation === "apply");
|
||||||
|
|
||||||
// Compute current pipeline step index (0: check, 1: download, 2: verify/stage, 3: apply/restart)
|
// Compute current pipeline step index (0: check, 1: download, 2: verify/stage, 3: apply/restart)
|
||||||
const currentStep = useMemo(() => {
|
const currentStep = useMemo(() => {
|
||||||
@@ -614,7 +655,7 @@ export default function UpdatePage({
|
|||||||
subtitle="管理系统版本升级、更新包完整性校验与安全热重启"
|
subtitle="管理系统版本升级、更新包完整性校验与安全热重启"
|
||||||
actions={
|
actions={
|
||||||
<div className="tn-update-page-actions">
|
<div className="tn-update-page-actions">
|
||||||
{hasActiveJob && (
|
{showJobDetails && (
|
||||||
<Button
|
<Button
|
||||||
theme="primary"
|
theme="primary"
|
||||||
variant="base"
|
variant="base"
|
||||||
@@ -685,10 +726,18 @@ export default function UpdatePage({
|
|||||||
<span className="tn-metric-label">当前运行版本</span>
|
<span className="tn-metric-label">当前运行版本</span>
|
||||||
<div className="tn-metric-value">v{info.currentVersion}</div>
|
<div className="tn-metric-value">v{info.currentVersion}</div>
|
||||||
<div className="tn-metric-foot">
|
<div className="tn-metric-foot">
|
||||||
{latest?.isNewer ? (
|
{releaseState === "checking" ? (
|
||||||
<Tag theme="primary" size="small">可更新至 v{latest.version}</Tag>
|
<Tag theme="default" size="small">正在检查更新</Tag>
|
||||||
) : (
|
) : releaseState === "unverified" ? (
|
||||||
|
<Tag theme="default" size="small">尚未检查更新</Tag>
|
||||||
|
) : releaseState === "available" ? (
|
||||||
|
<Tag theme="primary" size="small">可更新至 v{latest?.version}</Tag>
|
||||||
|
) : releaseState === "up-to-date" ? (
|
||||||
<Tag theme="success" size="small">已是最新版本</Tag>
|
<Tag theme="success" size="small">已是最新版本</Tag>
|
||||||
|
) : releaseState === "blocked" ? (
|
||||||
|
<Tag theme="warning" size="small">发现新版本,但暂不可更新</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag theme="default" size="small">暂未获取发布信息</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
@@ -727,8 +776,8 @@ export default function UpdatePage({
|
|||||||
<Surface className="tn-ascii-release-container">
|
<Surface className="tn-ascii-release-container">
|
||||||
<div className="tn-ascii-release-head">
|
<div className="tn-ascii-release-head">
|
||||||
<h3 className="tn-ascii-release-title">发布版本详情</h3>
|
<h3 className="tn-ascii-release-title">发布版本详情</h3>
|
||||||
<Tag theme={latest.isNewer ? "primary" : "success"} variant="light-outline">
|
<Tag theme={releaseState === "available" ? "primary" : releaseState === "up-to-date" ? "success" : releaseState === "blocked" ? "warning" : "default"} variant="light-outline">
|
||||||
{latest.isNewer ? "发现新版本" : "已是最新版本"}
|
{releaseState === "available" ? "发现新版本" : releaseState === "up-to-date" ? "已是最新版本" : releaseState === "blocked" ? "暂不可安全更新" : "尚未检查"}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -780,7 +829,7 @@ export default function UpdatePage({
|
|||||||
disabled={actionBusy}
|
disabled={actionBusy}
|
||||||
icon={<Download size={16} />}
|
icon={<Download size={16} />}
|
||||||
>
|
>
|
||||||
立即升级至 v{latest.version}
|
下载更新包
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canApply && (
|
{canApply && (
|
||||||
@@ -791,7 +840,7 @@ export default function UpdatePage({
|
|||||||
disabled={actionBusy}
|
disabled={actionBusy}
|
||||||
icon={<Zap size={16} />}
|
icon={<Zap size={16} />}
|
||||||
>
|
>
|
||||||
更新包已就绪,立即应用 (v{latest.version})
|
立即应用并重启 v{latest.version}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasActiveJob && (
|
{hasActiveJob && (
|
||||||
@@ -804,9 +853,14 @@ export default function UpdatePage({
|
|||||||
查看当前升级进度
|
查看当前升级进度
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!latest.isNewer && !hasActiveJob && (
|
{releaseState === "blocked" && !hasActiveJob && !canApply && (
|
||||||
|
<Button theme="default" variant="outline" size="large" onClick={() => void check()} loading={checking} disabled={checking} icon={<RefreshCw size={15} />}>
|
||||||
|
重新检查
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{releaseState === "up-to-date" && !hasActiveJob && !canApply && (
|
||||||
<Button theme="default" variant="outline" size="large" onClick={() => void check()} icon={<RefreshCw size={15} />}>
|
<Button theme="default" variant="outline" size="large" onClick={() => void check()} icon={<RefreshCw size={15} />}>
|
||||||
检查新版本
|
重新检查
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -814,10 +868,10 @@ export default function UpdatePage({
|
|||||||
) : (
|
) : (
|
||||||
<Surface className="tn-empty-surface">
|
<Surface className="tn-empty-surface">
|
||||||
<div className="tn-empty-content">
|
<div className="tn-empty-content">
|
||||||
<CheckCircle2 size={32} className="text-success" />
|
{releaseState === "unverified" || releaseState === "checking" ? <RefreshCw size={32} className={releaseState === "checking" ? "tn-spin" : "text-secondary"} /> : <AlertCircle size={32} className="text-warning" />}
|
||||||
<p>暂无待更新的版本信息,当前系统已是最新状态。</p>
|
<p>{releaseState === "unverified" || releaseState === "checking" ? "尚未完成版本检查,请先获取官方发布信息。" : "暂时没有可用的发布信息,请稍后重新检查。"}</p>
|
||||||
<Button variant="outline" onClick={() => void check()} icon={<RefreshCw size={15} />}>
|
<Button variant="outline" onClick={() => void check()} loading={checking} disabled={checking} icon={<RefreshCw size={15} />}>
|
||||||
检查新版本
|
{releaseState === "checking" ? "正在检查" : "检查新版本"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
@@ -826,7 +880,7 @@ export default function UpdatePage({
|
|||||||
{latest && notes && (
|
{latest && notes && (
|
||||||
<Surface className="tn-ascii-notes-container">
|
<Surface className="tn-ascii-notes-container">
|
||||||
<div className="tn-ascii-notes-head">
|
<div className="tn-ascii-notes-head">
|
||||||
<h3 className="tn-ascii-notes-title">本次版本更新说明</h3>
|
<h3 className="tn-ascii-notes-title">发布说明</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="tn-ascii-notes-body">
|
<div className="tn-ascii-notes-body">
|
||||||
<MarkdownNotes value={notes} />
|
<MarkdownNotes value={notes} />
|
||||||
@@ -851,7 +905,7 @@ export default function UpdatePage({
|
|||||||
{/* Unified Single Upgrade Modal (800px width on desktop) */}
|
{/* Unified Single Upgrade Modal (800px width on desktop) */}
|
||||||
<Dialog
|
<Dialog
|
||||||
visible={showUpgradeModal}
|
visible={showUpgradeModal}
|
||||||
header={`系统升级控制台 · v${latest?.version || ""}`}
|
header={`系统升级控制台 · v${latest?.version || job?.version || ""}`}
|
||||||
className="tn-dialog-large"
|
className="tn-dialog-large"
|
||||||
width="820px"
|
width="820px"
|
||||||
footer={null}
|
footer={null}
|
||||||
@@ -983,7 +1037,7 @@ export default function UpdatePage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 场景 C: 校验通过准备就绪 (staged) (Exact ASCII) */}
|
{/* 场景 C: 校验通过准备就绪 (staged) (Exact ASCII) */}
|
||||||
{job?.status === "staged" && (
|
{job?.status === "staged" && job.operation === "download" && canApply && (
|
||||||
<div className="tn-modal-card-box">
|
<div className="tn-modal-card-box">
|
||||||
<div className="tn-modal-card-title" style={{ color: "#2f7d5c", display: "flex", alignItems: "center", gap: 8 }}>
|
<div className="tn-modal-card-title" style={{ color: "#2f7d5c", display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<CheckCircle2 size={18} />
|
<CheckCircle2 size={18} />
|
||||||
@@ -1011,6 +1065,36 @@ export default function UpdatePage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* A staged archive can become obsolete when the host or release
|
||||||
|
metadata changes while this page is open. Keep the state visible
|
||||||
|
but remove the apply action; the server will reconcile it on the
|
||||||
|
next status request and the operator can perform a fresh check. */}
|
||||||
|
{job?.status === "staged" && job.operation === "download" && !canApply && (
|
||||||
|
<div className="tn-modal-card-box">
|
||||||
|
<div className="tn-modal-card-title">暂存更新已失效</div>
|
||||||
|
<p className="text-secondary" style={{ fontSize: "13px", margin: "0 0 16px", lineHeight: 1.6 }}>
|
||||||
|
这个更新包已不是当前可安全应用的版本,系统不会重复应用。请重新检查更新以获取最新发布信息。
|
||||||
|
</p>
|
||||||
|
<div className="tn-modal-actions-bar">
|
||||||
|
<Button variant="outline" onClick={() => void check()} loading={checking} disabled={checking}>
|
||||||
|
重新检查
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job?.status === "staged" && job.operation === "apply" && (
|
||||||
|
<div className="tn-modal-card-box">
|
||||||
|
<div className="tn-modal-card-title">
|
||||||
|
<div style={{ marginBottom: 12 }}><BeamBar width={180} /></div>
|
||||||
|
应用请求已提交,正在等待更新服务接管
|
||||||
|
</div>
|
||||||
|
<p className="text-secondary" style={{ fontSize: "13px", margin: "0 0 16px", lineHeight: 1.6 }}>
|
||||||
|
系统正在准备备份与重启。页面会持续同步服务状态,请不要重复提交。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 场景 D: 数据快照备份中或服务重启中 (backing_up / applying) (Exact ASCII) */}
|
{/* 场景 D: 数据快照备份中或服务重启中 (backing_up / applying) (Exact ASCII) */}
|
||||||
{(job?.status === "backing_up" || job?.status === "applying") && (
|
{(job?.status === "backing_up" || job?.status === "applying") && (
|
||||||
<div className="tn-modal-card-box">
|
<div className="tn-modal-card-box">
|
||||||
@@ -1072,7 +1156,7 @@ export default function UpdatePage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer close button */}
|
{/* Footer close button */}
|
||||||
{job?.status !== "staged" && job?.status !== "completed" && !confirmReadyToDownload && (
|
{!(job?.status === "staged" && canApply) && job?.status !== "completed" && !confirmReadyToDownload && (
|
||||||
<div className="tn-modal-footer-close">
|
<div className="tn-modal-footer-close">
|
||||||
<Button variant="outline" onClick={() => setShowUpgradeModal(false)}>
|
<Button variant="outline" onClick={() => setShowUpgradeModal(false)}>
|
||||||
关闭窗口(后台继续运行)
|
关闭窗口(后台继续运行)
|
||||||
|
|||||||
@@ -115,14 +115,14 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
|
|||||||
box-sizing: border-box !important;
|
box-sizing: border-box !important;
|
||||||
transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||||
}
|
}
|
||||||
.t-notification__show--top-right {
|
.t-notification__show--bottom-right {
|
||||||
top: 76px !important;
|
bottom: 24px !important;
|
||||||
right: 24px !important;
|
right: 24px !important;
|
||||||
z-index: 6000 !important;
|
z-index: 6000 !important;
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.t-notification__show--top-right {
|
.t-notification__show--bottom-right {
|
||||||
top: 16px !important;
|
bottom: 16px !important;
|
||||||
right: 16px !important;
|
right: 16px !important;
|
||||||
left: 16px !important;
|
left: 16px !important;
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user