From 4b0afe6c0c58c5e554eeb127c6fc2bd750e4dc55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 3 Sep 2026 17:46:55 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20=E6=89=93=E5=BC=80=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=AF=A6=E6=83=85=E5=8C=BA=E5=88=86=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=8C=E5=BF=BD=E7=95=A5=E5=8A=A8=E4=BD=9C?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E9=80=90=E6=9D=A1=E5=9B=9E=E6=89=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面此前无从判断服务端到底做了什么:openUpdatePageByUUID 在命中静默更新时 不开安装页却同样返回 true,用户点完脚本名只看到转一圈、什么都没发生; IGNORE 分支根本没有返回值,页面只能 fire-and-forget。 - openUpdatePageByUUID / openUpdatePage 返回 "opened" | "silent" | "failed" - IGNORE 逐条回报结果。忽略写的是脚本自身的 ignoreVersion,与检查缓存无关, 因此缓存随 Service Worker 回收后忽略照样生效,这里如实回报而不是谎报失效 - checkScriptUpdate 的结果收敛成 TCheckScriptUpdateResult 并用 reason 区分 「已有检查在跑」与真正的失败,页面才能分别提示 --- src/app/service/service_worker/client.ts | 14 +++- src/app/service/service_worker/script.test.ts | 77 +++++++++++++++++-- src/app/service/service_worker/script.ts | 75 ++++++++---------- src/app/service/service_worker/types.ts | 23 +++++- 4 files changed, 138 insertions(+), 51 deletions(-) diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index ceb1ea124..07ea7b6fb 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -3,7 +3,15 @@ import { type Resource } from "@App/app/repo/resource"; import { type Subscribe } from "@App/app/repo/subscribe"; import { type Logger } from "@App/app/repo/logger"; import { type Permission } from "@App/app/repo/permission"; -import type { InstallSource, ScriptMenu, ScriptMenuItem, TBatchUpdateListAction, TPopupPageStatus } from "./types"; +import type { + InstallSource, + ScriptMenu, + ScriptMenuItem, + TBatchUpdateListAction, + TCheckScriptUpdateResult, + TOpenUpdatePageResult, + TPopupPageStatus, +} from "./types"; import { Client } from "@Packages/message/client"; import type { MessageSend } from "@Packages/message/types"; import type PermissionVerify from "./permission_verify"; @@ -273,7 +281,7 @@ export class ScriptClient extends Client { } async openUpdatePageByUUID(uuid: string) { - return this.do("openUpdatePageByUUID", uuid); + return this.do("openUpdatePageByUUID", uuid); } async openBatchUpdatePage(opts: TOpenBatchUpdatePageOption) { @@ -281,7 +289,7 @@ export class ScriptClient extends Client { } async checkScriptUpdate(opts: TCheckScriptUpdateOption) { - return this.do("checkScriptUpdate", opts); + return this.do("checkScriptUpdate", opts); } } diff --git a/src/app/service/service_worker/script.test.ts b/src/app/service/service_worker/script.test.ts index 317c3716a..ba86da670 100644 --- a/src/app/service/service_worker/script.test.ts +++ b/src/app/service/service_worker/script.test.ts @@ -1508,7 +1508,7 @@ describe("ScriptService.openUpdatePageByUUID —— 打开单条更新详情", ( await saveTarget(service, scriptDAO); primeCache(service, userscript("2.0.0")); - await expect(service.openUpdatePageByUUID("u-open")).resolves.toBe(true); + await expect(service.openUpdatePageByUUID("u-open")).resolves.toBe("opened"); expect(h.fetchScriptBody).not.toHaveBeenCalled(); expect(h.openInCurrentTab).toHaveBeenCalledWith("/src/install.html?uuid=u-open"); @@ -1519,27 +1519,92 @@ describe("ScriptService.openUpdatePageByUUID —— 打开单条更新详情", ( await saveTarget(service, scriptDAO); h.fetchScriptBody.mockResolvedValue(userscript("2.0.0")); - await expect(service.openUpdatePageByUUID("u-open")).resolves.toBe(true); + await expect(service.openUpdatePageByUUID("u-open")).resolves.toBe("opened"); expect(h.fetchScriptBody).toHaveBeenCalledWith(URL); expect(h.openInCurrentTab).toHaveBeenCalledWith("/src/install.html?uuid=u-open"); }); - it("拉取失败时回报 false,让更新页能给出失败反馈而不是一直转圈", async () => { + it("拉取失败时回报 failed,让更新页能给出失败反馈而不是一直转圈", async () => { const { service, scriptDAO } = buildService(); await saveTarget(service, scriptDAO); h.fetchScriptBody.mockRejectedValue(new Error("network error")); - await expect(service.openUpdatePageByUUID("u-open")).resolves.toBe(false); + await expect(service.openUpdatePageByUUID("u-open")).resolves.toBe("failed"); expect(h.openInCurrentTab).not.toHaveBeenCalled(); }); - it("脚本已不存在时回报 false 而不是静默无反应", async () => { + it("脚本已不存在时回报 failed 而不是静默无反应", async () => { const { service } = buildService(); - await expect(service.openUpdatePageByUUID("missing")).resolves.toBe(false); + await expect(service.openUpdatePageByUUID("missing")).resolves.toBe("failed"); expect(h.openInCurrentTab).not.toHaveBeenCalled(); }); + + it("命中静默更新时回报 silent:不开安装页,由调用方补一条反馈", async () => { + const { service, scriptDAO, systemConfig } = buildService(); + await saveTarget(service, scriptDAO); + systemConfig.setSilenceUpdateScript(true); + primeCache(service, userscript("2.0.0")); + + await expect(service.openUpdatePageByUUID("u-open")).resolves.toBe("silent"); + + // 静默更新是真的装了,只是页面上什么都不会发生 + expect(h.openInCurrentTab).not.toHaveBeenCalled(); + expect((await scriptDAO.get("u-open"))?.metadata.version?.[0]).toBe("2.0.0"); + }); +}); + +describe("ScriptService.batchUpdateListAction —— 忽略更新", () => { + const saveIgnoreTarget = (scriptDAO: ScriptDAO) => + scriptDAO.save( + makeScript({ + uuid: "u-ignore", + name: "忽略目标", + namespace: "scriptcat-test", + metadata: { name: ["忽略目标"], namespace: ["scriptcat-test"], version: ["1.0.0"] }, + }) + ); + + it("逐条回报忽略结果,页面据此收起该行", async () => { + const { service, scriptDAO } = buildService(); + await saveIgnoreTarget(scriptDAO); + + const res = await service.batchUpdateListAction({ + actionCode: BatchUpdateListActionCode.IGNORE, + actionPayload: [{ uuid: "u-ignore", ignoreVersion: "2.0.0" }], + }); + + expect(res).toEqual({ ok: true, items: [{ uuid: "u-ignore", success: true }] }); + expect((await scriptDAO.get("u-ignore"))?.ignoreVersion).toBe("2.0.0"); + }); + + it("检查缓存已随 Service Worker 回收时,忽略照样生效并照常回报", async () => { + const { service, scriptDAO } = buildService(); + await saveIgnoreTarget(scriptDAO); + // 忽略写的是脚本自身的 ignoreVersion,与检查缓存无关 + expect(service["scriptUpdateCheck"].cacheFull).toBeFalsy(); + + const res = await service.batchUpdateListAction({ + actionCode: BatchUpdateListActionCode.IGNORE, + actionPayload: [{ uuid: "u-ignore", ignoreVersion: "2.0.0" }], + }); + + expect(res).toEqual({ ok: true, items: [{ uuid: "u-ignore", success: true }] }); + expect((await scriptDAO.get("u-ignore"))?.ignoreVersion).toBe("2.0.0"); + }); + + it("脚本已不存在时该条回报失败,而不是静默当作成功", async () => { + const { service } = buildService(); + + const res = await service.batchUpdateListAction({ + actionCode: BatchUpdateListActionCode.IGNORE, + actionPayload: [{ uuid: "missing", ignoreVersion: "2.0.0" }], + }); + + expect(res?.ok).toBe(true); + expect(res?.items[0]).toMatchObject({ uuid: "missing", success: false }); + }); }); diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 969b7a63a..1ec7d8253 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -46,6 +46,8 @@ import { type TBatchUpdateRecord, type TBatchUpdateItemResult, type TBatchUpdateResult, + type TCheckScriptUpdateResult, + type TOpenUpdatePageResult, } from "./types"; import { getSimilarityScore, ScriptUpdateCheck } from "./script_update_check"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; @@ -1234,9 +1236,13 @@ export class ScriptService { /** * 打开更新窗口。cachedNewCode 是检查阶段已经拉到的新版代码,命中时直接复用。 - * @returns 是否已处理(打开了安装页或完成了静默更新) + * 静默更新与打开安装页必须让调用方能区分:前者页面上什么都不会发生,需要自己补一条反馈。 */ - public async openUpdatePage(script: Script, source: "user" | "system", cachedNewCode?: string) { + public async openUpdatePage( + script: Script, + source: "user" | "system", + cachedNewCode?: string + ): Promise { const { uuid, name, downloadUrl, checkUpdateUrl } = script; const logger = this.logger.with({ uuid, @@ -1249,13 +1255,13 @@ export class ScriptService { const ret = cachedNewCode ? await this.prepareUpdateOrInstallPage(uuid, cachedNewCode, url, { source }, true, logger) : await this.openUpdateOrInstallPage(uuid, url, { source }, true, logger); - if (ret === 2) return true; // slience update + if (ret === 2) return "silent"; // 打开安装页面 openInCurrentTab(`/src/install.html?uuid=${uuid}`); - return true; + return "opened"; } catch (e) { logger.error("fetch script info failed", Logger.E(e)); - return false; + return "failed"; } } @@ -1275,20 +1281,7 @@ export class ScriptService { } // 用于定时自动检查脚本更新 - async _checkScriptUpdate(opts: TCheckScriptUpdateOption): Promise< - | { - ok: true; - targetSites: string[]; - err?: undefined; - fresh: boolean; - checktime: number; - } - | { - ok: false; - targetSites?: undefined; - err?: string | Error; - } - > { + async _checkScriptUpdate(opts: TCheckScriptUpdateOption): Promise { const executeSlienceUpdate = opts.checkType === "system" && (await this.systemConfig.getSilenceUpdateScript()); const checkCycle = await this.systemConfig.getCheckScriptUpdateCycle(); if (!checkCycle) { @@ -1467,16 +1460,13 @@ export class ScriptService { }; } - async checkScriptUpdate(opts: TCheckScriptUpdateOption) { - let res; + async checkScriptUpdate(opts: TCheckScriptUpdateOption): Promise { + let res: TCheckScriptUpdateResult; if ((this.scriptUpdateCheck.state.status & UpdateStatusCode.CHECKING_UPDATE) === UpdateStatusCode.CHECKING_UPDATE) { res = { ok: false, + reason: "busy", err: "checkScriptUpdate is busy. Please try again later.", - } as { - ok: false; - targetSites?: undefined; - err?: string | Error; }; } else if (this.scriptUpdateCheck.canSkipScriptUpdateCheck(opts)) { return { @@ -1493,14 +1483,7 @@ export class ScriptService { res = await this._checkScriptUpdate(opts); } catch (e) { this.logger.error("check script updates failed", Logger.E(e)); - res = { - ok: false, - err: e, - } as { - ok: false; - targetSites?: undefined; - err?: string | Error; - }; + res = { ok: false, err: e as Error }; } // clear CHECKING_UPDATE this.scriptUpdateCheck.state.status &= ~UpdateStatusCode.CHECKING_UPDATE; @@ -1704,12 +1687,21 @@ export class ScriptService { async batchUpdateListAction(action: TBatchUpdateListAction) { if (action.actionCode === BatchUpdateListActionCode.IGNORE) { const map = new Map(); - await Promise.allSettled( - action.actionPayload.map(async (script) => { - const { uuid, ignoreVersion } = script; - const updatedScript = await this.scriptDAO.update(uuid, { ignoreVersion }); - if (!updatedScript || updatedScript.uuid !== uuid) return; - map.set(uuid, updatedScript); + // 逐条回报结果:忽略写的是脚本本身的 ignoreVersion,与检查缓存无关, + // 因此即使缓存已随 Service Worker 回收,忽略照样生效,页面据此收起该行 + const items: TBatchUpdateItemResult[] = await Promise.all( + action.actionPayload.map(async ({ uuid, ignoreVersion }) => { + try { + const updatedScript = await this.scriptDAO.update(uuid, { ignoreVersion }); + if (!updatedScript || updatedScript.uuid !== uuid) { + return { uuid, success: false, error: "script not found" }; + } + map.set(uuid, updatedScript); + return { uuid, success: true }; + } catch (e) { + this.logger.error("ignore script update failed", { uuid }, Logger.E(e)); + return { uuid, success: false, error: e instanceof Error ? e.message : String(e) }; + } }) ); if (this.scriptUpdateCheck.cacheFull) { @@ -1723,6 +1715,7 @@ export class ScriptService { this.scriptUpdateCheck.setCacheFull(this.scriptUpdateCheck.cacheFull); this.scriptUpdateCheck.announceMessage({ refreshRecord: true }); } + return { ok: true, items } satisfies TBatchUpdateResult; } else if (action.actionCode === BatchUpdateListActionCode.UPDATE) { const uuids = action.actionPayload.map((entry) => entry.uuid); const list = this.scriptUpdateCheck.cacheFull?.list; @@ -1782,10 +1775,10 @@ export class ScriptService { } } - async openUpdatePageByUUID(uuid: string) { + async openUpdatePageByUUID(uuid: string): Promise { const source = "user"; // TBC const script = await this.scriptDAO.get(uuid); - if (!script || script.uuid !== uuid) return false; + if (!script || script.uuid !== uuid) return "failed"; // 检查记录里已经带着这次要装的新版代码:复用它既省掉一次让用户干等的网络往返, // 也保证打开的正是列表上展示的那一版 const cachedNewCode = this.scriptUpdateCheck.cacheFull?.list?.find((entry) => entry.uuid === uuid)?.newCode; diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index d979bed30..5197fa039 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -295,7 +295,7 @@ export type TBatchUpdateItemResult = { }; /** - * UPDATE 动作的执行结果。 + * UPDATE / IGNORE 动作的执行结果。 * ok 为 false 表示整批根本没有执行:Service Worker 的检查结果只存在于内存(ScriptUpdateCheck.cacheFull), * MV3 回收 Service Worker 后即丢失,此时必须让调用方能与「逐条安装失败」区分开,提示用户重新检查更新。 */ @@ -305,4 +305,25 @@ export type TBatchUpdateResult = { items: TBatchUpdateItemResult[]; }; +/** 检查更新的结果 */ +export type TCheckScriptUpdateResult = + | { + ok: true; + targetSites: string[]; + /** false 表示上次结果仍够新、本次并没有真的重新检查 */ + fresh: boolean; + checktime: number; + err?: undefined; + } + | { + ok: false; + /** busy 区分「已有检查在跑」与真正的失败,二者对用户是不同的话 */ + reason?: "busy"; + targetSites?: undefined; + err?: string | Error; + }; + +/** 打开更新详情的结果:opened=已开出安装页,silent=已静默更新完成,failed=没能处理 */ +export type TOpenUpdatePageResult = "opened" | "silent" | "failed"; + export type TPopupScript = { tabId: number; uuids: string[] }; From a87c144bd89372470271e5a815462079a88d1a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 3 Sep 2026 17:47:53 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20=E5=AE=89=E8=A3=85=E9=A1=B5?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=8A=A0=E8=BD=BD=E5=88=86=E6=A1=A3=E3=80=81?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=AA=A8=E6=9E=B6=E4=B8=8E=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=BF=99=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从批量更新页点脚本名进来的必然是「更新」,加载屏却把上下文 chip 写死成 「脚本安装」,几百毫秒后再闪成「脚本更新」;描述写着「正在从来源下载」, 但这条入口的代码 Service Worker 早已备好,根本不下载。 - 状态屏按来路分档,未确知场景不渲染 chip(不猜),并补一条与就绪态操作栏 等高的底部占位,避免就绪瞬间内容区高度再跳一次 - 暂存代码被定时清理回收时落到专属终态,出口换成「重新检查更新」—— 原来的「重试」在这个最常见的失败原因下重试多少次都是同一结果 - Monaco 实例就绪前渲染代码骨架,替代此前 340px 的纯空白 - toggleWatch / rejectExternalAccess 补忙态,install 加重入守卫: 这两个动作全程不置忙态,连点会发出两次安装/两次决定 --- src/locales/de-DE/install.json | 9 +- src/locales/en-US/install.json | 9 +- src/locales/ja-JP/install.json | 9 +- src/locales/ko-KR/install.json | 9 +- src/locales/pt-BR/install.json | 9 +- src/locales/ru-RU/install.json | 9 +- src/locales/tr-TR/install.json | 9 +- src/locales/vi-VN/install.json | 9 +- src/locales/zh-CN/install.json | 9 +- src/locales/zh-TW/install.json | 9 +- src/pages/install/App.tsx | 15 ++- .../install/components/CodePreview.test.tsx | 20 ++++ src/pages/install/components/CodePreview.tsx | 45 ++++++-- .../install/components/InstallStates.test.tsx | 47 +++++++- .../install/components/InstallStates.tsx | 75 ++++++++++-- src/pages/install/useInstallData.test.ts | 108 +++++++++++++++++- src/pages/install/useInstallData.ts | 51 ++++++++- tests/mocks/CodeEditor.tsx | 24 +++- 18 files changed, 439 insertions(+), 36 deletions(-) diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index be1cc2364..b04c550bd 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -35,6 +35,9 @@ "code_expand": "Ausklappen", "loading_title": "Skript wird geladen", "loading_desc": "Skriptinhalt wird von der Quelle heruntergeladen und ausgewertet", + "loading_title_update": "Update-Details werden vorbereitet", + "loading_desc_prepare": "Der vorbereitete Skriptinhalt wird gelesen", + "loading_desc_prepare_update": "Die heruntergeladene neue Version wird gelesen und mit der aktuellen verglichen", "error_retry": "Erneut versuchen", "error_invalid_desc": "Es fehlt ein gültiger Installationsquellen-Parameter, das Skript kann nicht geladen werden.", "context_install": "Skript installieren", @@ -234,5 +237,9 @@ "script_info_load_failed": "Skript-Informationen laden fehlgeschlagen!", "btn_restore": "Wiederherstellen", "btn_restore_update": "Wiederherstellen und aktualisieren", - "in_trash_hint": "Dieses Skript befindet sich im Papierkorb. Die Installation stellt es wieder her und behält die vorhandenen Daten." + "in_trash_hint": "Dieses Skript befindet sich im Papierkorb. Die Installation stellt es wieder her und behält die vorhandenen Daten.", + "expired_title": "Update-Inhalt ist abgelaufen", + "expired_desc": "Der für dieses Update vorbereitete Code wurde bereinigt. Bitte erneut nach Updates suchen.", + "expired_recheck": "Erneut nach Updates suchen", + "code_loading": "Code wird geladen" } diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index d779420c1..5a8ab1eec 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -35,6 +35,9 @@ "code_expand": "Expand", "loading_title": "Loading script", "loading_desc": "Downloading and parsing the script content from the source", + "loading_title_update": "Preparing update details", + "loading_desc_prepare": "Reading the prepared script content", + "loading_desc_prepare_update": "Reading the downloaded new version and comparing it with the current one", "error_retry": "Retry", "error_invalid_desc": "A valid install source parameter is missing, so the script cannot be loaded.", "context_install": "Install Script", @@ -234,5 +237,9 @@ "script_info_load_failed": "Failed to load script information", "btn_restore": "Restore Script", "btn_restore_update": "Restore and Update Script", - "in_trash_hint": "This script is in Trash. Installing will restore it and keep its existing data." + "in_trash_hint": "This script is in Trash. Installing will restore it and keep its existing data.", + "expired_title": "Update content has expired", + "expired_desc": "The code prepared for this update has been cleaned up. Please check for updates again.", + "expired_recheck": "Check for updates again", + "code_loading": "Loading code" } diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index 13c91cc4c..250893124 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -35,6 +35,9 @@ "code_expand": "展開", "loading_title": "スクリプトを読み込み中", "loading_desc": "ソースからスクリプトの内容をダウンロードして解析しています", + "loading_title_update": "更新の詳細を準備中", + "loading_desc_prepare": "準備済みのスクリプト内容を読み込み中", + "loading_desc_prepare_update": "ダウンロード済みの新バージョンを読み込み、現在のバージョンと比較しています", "error_retry": "再試行", "error_invalid_desc": "有効なインストール元のパラメーターがないため、スクリプトを読み込めません。", "context_install": "スクリプトのインストール", @@ -234,5 +237,9 @@ "script_info_load_failed": "スクリプト情報の読み込みに失敗しました!", "btn_restore": "復元", "btn_restore_update": "復元して更新", - "in_trash_hint": "このスクリプトはゴミ箱に入っています。インストールすると復元され、既存のデータはそのまま保持されます" + "in_trash_hint": "このスクリプトはゴミ箱に入っています。インストールすると復元され、既存のデータはそのまま保持されます", + "expired_title": "更新内容の有効期限が切れました", + "expired_desc": "この更新のために用意されたコードは削除されました。もう一度更新を確認してください。", + "expired_recheck": "更新を再確認", + "code_loading": "コードを読み込み中" } diff --git a/src/locales/ko-KR/install.json b/src/locales/ko-KR/install.json index f19aa40ae..147bb94f6 100644 --- a/src/locales/ko-KR/install.json +++ b/src/locales/ko-KR/install.json @@ -35,6 +35,9 @@ "code_expand": "펼치기", "loading_title": "스크립트 불러오는 중", "loading_desc": "소스에서 스크립트 내용을 다운로드하고 분석하고 있습니다", + "loading_title_update": "업데이트 세부 정보를 준비하는 중", + "loading_desc_prepare": "준비된 스크립트 내용을 읽는 중", + "loading_desc_prepare_update": "내려받은 새 버전을 읽어 현재 버전과 비교하는 중", "error_retry": "다시 시도", "error_invalid_desc": "유효한 설치 소스 매개변수가 없어 스크립트를 불러올 수 없습니다.", "context_install": "스크립트 설치", @@ -234,5 +237,9 @@ "script_info_load_failed": "스크립트 정보를 불러오지 못했습니다", "btn_restore": "스크립트 복원", "btn_restore_update": "스크립트 복원 및 업데이트", - "in_trash_hint": "이 스크립트는 휴지통에 있습니다. 설치하면 스크립트를 복원하고 기존 데이터를 유지합니다." + "in_trash_hint": "이 스크립트는 휴지통에 있습니다. 설치하면 스크립트를 복원하고 기존 데이터를 유지합니다.", + "expired_title": "업데이트 내용이 만료되었습니다", + "expired_desc": "이번 업데이트를 위해 준비한 코드가 정리되었습니다. 업데이트를 다시 확인해 주세요.", + "expired_recheck": "업데이트 다시 확인", + "code_loading": "코드를 불러오는 중" } diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index 28d3f2bfe..350dc4bee 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -35,6 +35,9 @@ "code_expand": "Expandir", "loading_title": "Carregando script", "loading_desc": "Baixando e analisando o conteúdo da origem do script", + "loading_title_update": "Preparando os detalhes da atualização", + "loading_desc_prepare": "Lendo o conteúdo do script já preparado", + "loading_desc_prepare_update": "Lendo a nova versão baixada e comparando com a atual", "error_retry": "Tentar novamente", "error_invalid_desc": "Falta um parâmetro de origem de instalação válido, então o script não pode ser carregado.", "context_install": "Instalar script", @@ -234,5 +237,9 @@ "script_info_load_failed": "Falha ao carregar informações do script", "btn_restore": "Restaurar script", "btn_restore_update": "Restaurar e atualizar script", - "in_trash_hint": "Este script está na lixeira. Ao instalá-lo, ele será restaurado e seus dados existentes serão mantidos." + "in_trash_hint": "Este script está na lixeira. Ao instalá-lo, ele será restaurado e seus dados existentes serão mantidos.", + "expired_title": "O conteúdo da atualização expirou", + "expired_desc": "O código preparado para esta atualização foi removido. Verifique as atualizações novamente.", + "expired_recheck": "Verificar atualizações novamente", + "code_loading": "Carregando código" } diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index c8cf727a4..cadf115a4 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -35,6 +35,9 @@ "code_expand": "Развернуть", "loading_title": "Загрузка скрипта", "loading_desc": "Загрузка и разбор содержимого скрипта из источника", + "loading_title_update": "Подготовка сведений об обновлении", + "loading_desc_prepare": "Чтение подготовленного содержимого скрипта", + "loading_desc_prepare_update": "Чтение загруженной новой версии и сравнение с текущей", "error_retry": "Повторить", "error_invalid_desc": "Отсутствует допустимый параметр источника установки, скрипт не может быть загружен.", "context_install": "Установка скрипта", @@ -234,5 +237,9 @@ "script_info_load_failed": "Ошибка загрузки информации о скрипте!", "btn_restore": "Восстановить", "btn_restore_update": "Восстановить и обновить", - "in_trash_hint": "Этот скрипт находится в корзине. Установка восстановит его и сохранит имеющиеся данные." + "in_trash_hint": "Этот скрипт находится в корзине. Установка восстановит его и сохранит имеющиеся данные.", + "expired_title": "Содержимое обновления устарело", + "expired_desc": "Код, подготовленный для этого обновления, был очищен. Проверьте обновления ещё раз.", + "expired_recheck": "Проверить обновления ещё раз", + "code_loading": "Загрузка кода" } diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index a660c0a26..b5b198dd7 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -35,6 +35,9 @@ "code_expand": "Genişlet", "loading_title": "Betik yükleniyor", "loading_desc": "Betik içeriği kaynaktan indiriliyor ve ayrıştırılıyor", + "loading_title_update": "Güncelleme ayrıntıları hazırlanıyor", + "loading_desc_prepare": "Hazırlanmış betik içeriği okunuyor", + "loading_desc_prepare_update": "İndirilen yeni sürüm okunuyor ve mevcut sürümle karşılaştırılıyor", "error_retry": "Yeniden dene", "error_invalid_desc": "Geçerli bir yükleme kaynağı parametresi eksik, bu nedenle betik yüklenemiyor.", "context_install": "Betik Yükle", @@ -234,5 +237,9 @@ "script_info_load_failed": "Betik bilgileri yüklenemedi", "btn_restore": "Betik Geri Yükle", "btn_restore_update": "Betik Geri Yükle ve Güncelle", - "in_trash_hint": "Bu betik çöp kutusunda; yüklemek onu geri yükleyecek ve mevcut verilerini koruyacaktır" + "in_trash_hint": "Bu betik çöp kutusunda; yüklemek onu geri yükleyecek ve mevcut verilerini koruyacaktır", + "expired_title": "Güncelleme içeriğinin süresi doldu", + "expired_desc": "Bu güncelleme için hazırlanan kod temizlendi. Lütfen güncellemeleri yeniden denetleyin.", + "expired_recheck": "Güncellemeleri yeniden denetle", + "code_loading": "Kod yükleniyor" } diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index 2118327dc..cff363628 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -35,6 +35,9 @@ "code_expand": "Mở rộng", "loading_title": "Đang tải script", "loading_desc": "Đang tải xuống và phân tích nội dung script từ nguồn", + "loading_title_update": "Đang chuẩn bị chi tiết cập nhật", + "loading_desc_prepare": "Đang đọc nội dung tập lệnh đã chuẩn bị", + "loading_desc_prepare_update": "Đang đọc phiên bản mới đã tải và so sánh với phiên bản hiện tại", "error_retry": "Thử lại", "error_invalid_desc": "Thiếu tham số nguồn cài đặt hợp lệ nên không thể tải script.", "context_install": "Cài đặt script", @@ -234,5 +237,9 @@ "script_info_load_failed": "Tải thông tin script thất bại", "btn_restore": "Khôi phục script", "btn_restore_update": "Khôi phục và cập nhật script", - "in_trash_hint": "Script này đang ở trong thùng rác, cài đặt sẽ khôi phục nó và giữ nguyên dữ liệu hiện có" + "in_trash_hint": "Script này đang ở trong thùng rác, cài đặt sẽ khôi phục nó và giữ nguyên dữ liệu hiện có", + "expired_title": "Nội dung cập nhật đã hết hạn", + "expired_desc": "Mã đã chuẩn bị cho lần cập nhật này đã bị dọn dẹp. Vui lòng kiểm tra cập nhật lại.", + "expired_recheck": "Kiểm tra cập nhật lại", + "code_loading": "Đang tải mã" } diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index f3fbc1631..751632b83 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -35,6 +35,9 @@ "code_expand": "展开", "loading_title": "正在加载脚本", "loading_desc": "正在从来源下载并解析脚本内容", + "loading_title_update": "正在准备更新详情", + "loading_desc_prepare": "正在读取已备好的脚本内容", + "loading_desc_prepare_update": "正在读取已下载的新版本并与当前版本比对", "error_retry": "重试", "error_invalid_desc": "缺少有效的安装来源参数,无法加载脚本。", "context_install": "脚本安装", @@ -234,5 +237,9 @@ "script_info_load_failed": "脚本信息加载失败!", "btn_restore": "还原", "btn_restore_update": "还原并更新", - "in_trash_hint": "此脚本在回收站中,安装将还原它并保留原有数据" + "in_trash_hint": "此脚本在回收站中,安装将还原它并保留原有数据", + "expired_title": "更新内容已过期", + "expired_desc": "这次更新准备好的代码已被清理,需要重新检查一次更新。", + "expired_recheck": "重新检查更新", + "code_loading": "正在加载代码" } diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index 6c1a080e0..a9c1c2ed8 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -35,6 +35,9 @@ "code_expand": "展開", "loading_title": "正在載入腳本", "loading_desc": "正在從來源下載並解析腳本內容", + "loading_title_update": "正在準備更新詳情", + "loading_desc_prepare": "正在讀取已備好的腳本內容", + "loading_desc_prepare_update": "正在讀取已下載的新版本並與目前版本比對", "error_retry": "重試", "error_invalid_desc": "缺少有效的安裝來源參數,無法載入腳本。", "context_install": "腳本安裝", @@ -234,5 +237,9 @@ "script_info_load_failed": "腳本資訊載入失敗!", "btn_restore": "還原", "btn_restore_update": "還原並更新", - "in_trash_hint": "此腳本在回收筒中,安裝將還原它並保留原有資料" + "in_trash_hint": "此腳本在回收筒中,安裝將還原它並保留原有資料", + "expired_title": "更新內容已過期", + "expired_desc": "這次更新準備好的程式碼已被清理,需要重新檢查一次更新。", + "expired_recheck": "重新檢查更新", + "code_loading": "正在載入程式碼" } diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index bfa3136aa..4bb8ae370 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -11,7 +11,7 @@ import { SkillInstallView } from "./components/SkillInstallView"; import { CodePreview } from "./components/CodePreview"; import { InstallActions } from "./components/InstallActions"; import { InstallWarning } from "./components/InstallWarning"; -import { InstallLoading, InstallError } from "./components/InstallStates"; +import { InstallLoading, InstallError, InstallExpired } from "./components/InstallStates"; import { WatchingBanner } from "./components/WatchingBanner"; import { ExternalAccessBanner } from "./components/ExternalAccessBanner"; import { InstallSuccessRibbon } from "./components/InstallSuccessRibbon"; @@ -49,6 +49,7 @@ export default function App() { installSkill, cancelSkill, retry, + recheck, retryInstall, } = useInstallData(); const [bgPrompt, setBgPrompt] = useState<{ scriptType: string; permission: PromptPermission } | null>(null); @@ -97,7 +98,17 @@ export default function App() { } if (state.status === "loading") { - return ; + return ( + + ); + } + if (state.status === "expired") { + return ; } if (state.status === "invalid") { return ; diff --git a/src/pages/install/components/CodePreview.test.tsx b/src/pages/install/components/CodePreview.test.tsx index 946a301c5..8bf1029d4 100644 --- a/src/pages/install/components/CodePreview.test.tsx +++ b/src/pages/install/components/CodePreview.test.tsx @@ -5,6 +5,7 @@ import { initTestLanguage } from "@Tests/initTestLanguage"; // Monaco 无法在 DOM 测试环境中渲染(需 worker),用轻量桩替换,仅暴露 props 供断言接线 vi.mock("@App/pages/components/CodeEditor", () => import("@Tests/mocks/CodeEditor.tsx")); +import { setEditorMounts } from "@Tests/mocks/CodeEditor"; import { CodePreview } from "./CodePreview"; const code = "// line1\nconst a = 1;\nconsole.log(a);"; @@ -59,3 +60,22 @@ describe("CodePreview 代码卡", () => { expect(screen.getByText("−18")).toBeInTheDocument(); }); }); + +describe("CodePreview 编辑器加载期的占位", () => { + afterEach(() => setEditorMounts(true)); + + it("编辑器实例就绪前渲染代码骨架,而不是一块看着像加载失败的空白", () => { + setEditorMounts(false); + render(); + + const skeleton = screen.getByTestId("code-skeleton"); + expect(skeleton).toHaveAttribute("aria-busy", "true"); + expect(skeleton.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(3); + }); + + it("编辑器挂载后收起骨架", () => { + render(); + + expect(screen.queryByTestId("code-skeleton")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/install/components/CodePreview.tsx b/src/pages/install/components/CodePreview.tsx index 9eb929396..ad69f74ad 100644 --- a/src/pages/install/components/CodePreview.tsx +++ b/src/pages/install/components/CodePreview.tsx @@ -2,6 +2,19 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { CodeXml, Copy, Check, ChevronDown, ChevronRight } from "lucide-react"; import CodeEditor from "@App/pages/components/CodeEditor"; +import { Skeleton } from "@App/pages/components/ui/skeleton"; +import { cn } from "@App/pkg/utils/cn"; + +/** 代码形态的骨架:缩进与行宽错落,读起来像代码而不是一堆等长灰条 */ +const CODE_SKELETON_LINES = [ + "w-[58%]", + "ml-4 w-[74%]", + "ml-4 w-[46%]", + "ml-8 w-[66%]", + "ml-4 w-[38%]", + "w-[52%]", + "ml-4 w-[62%]", +]; export interface CodePreviewProps { code: string; @@ -22,6 +35,7 @@ export function CodePreview({ const { t } = useTranslation(["install", "common", "editor"]); const [collapsed, setCollapsed] = useState(defaultCollapsed); const [copied, setCopied] = useState(false); + const [editorReady, setEditorReady] = useState(false); const lineCount = useMemo(() => code.split("\n").length, [code]); // diffCode 语义:""=无 diff(普通只读预览),有值=内联 diff;切勿传 undefined(表示不加载) @@ -69,13 +83,30 @@ export function CodePreview({ {!collapsed && ( - +
+ {!editorReady && ( + // 编辑器实例要等偏好设置读出来才创建,这段时间这里本来是一块纯空白,看着像加载失败 +
+ {CODE_SKELETON_LINES.map((line, i) => ( + + ))} +
+ )} + setEditorReady(true)} + className="h-full w-full" + /> +
)} ); diff --git a/src/pages/install/components/InstallStates.test.tsx b/src/pages/install/components/InstallStates.test.tsx index ede3749f8..a91f946e4 100644 --- a/src/pages/install/components/InstallStates.test.tsx +++ b/src/pages/install/components/InstallStates.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { t } from "@App/locales/locales"; import { initTestLanguage } from "@Tests/initTestLanguage"; -import { InstallLoading, InstallError } from "./InstallStates"; +import { InstallLoading, InstallError, InstallExpired } from "./InstallStates"; beforeAll(() => initTestLanguage("zh-CN")); @@ -59,3 +60,47 @@ describe("InstallError 加载失败状态屏", () => { expect(screen.getByTestId("install-top-bar")).toBeInTheDocument(); }); }); + +describe("InstallStates 加载态的上下文与占位", () => { + it("未确知场景时不渲染上下文 chip,不猜成「脚本安装」", () => { + render(); + + expect(screen.queryByText(t("install:context_install"))).toBeNull(); + expect(screen.queryByText(t("install:context_update"))).toBeNull(); + }); + + it("更新场景显示「脚本更新」与对应文案", () => { + render(); + + expect(screen.getByText(t("install:context_update"))).toBeTruthy(); + expect(screen.getByText(t("install:loading_title_update"))).toBeTruthy(); + expect(screen.getByText(t("install:loading_desc_prepare_update"))).toBeTruthy(); + }); + + it("只有真的在下载时才说「正在下载」", () => { + render(); + + expect(screen.getByText(t("install:loading_desc"))).toBeTruthy(); + }); + + it("状态屏保留与就绪态操作栏等高的底部占位,避免就绪瞬间再跳一次", () => { + render(); + + expect(screen.getByTestId("state-action-placeholder")).toBeTruthy(); + }); +}); + +describe("InstallStates 代码已过期", () => { + it("给出过期专属文案与重新检查出口,而不是必然再失败的重试", () => { + const onRecheck = vi.fn(); + const onClose = vi.fn(); + render(); + + expect(screen.getByTestId("install-expired")).toBeTruthy(); + expect(screen.getByText(t("install:expired_title"))).toBeTruthy(); + expect(screen.queryByText(t("install:error_retry"))).toBeNull(); + + fireEvent.click(screen.getByText(t("install:expired_recheck"))); + expect(onRecheck).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/pages/install/components/InstallStates.tsx b/src/pages/install/components/InstallStates.tsx index 65cd84c06..cf2c38c9b 100644 --- a/src/pages/install/components/InstallStates.tsx +++ b/src/pages/install/components/InstallStates.tsx @@ -1,19 +1,32 @@ import { useTranslation } from "react-i18next"; -import { CloudOff, Download, Globe, Loader2 } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { CloudOff, Download, Globe, Loader2, RefreshCw, TimerOff } from "lucide-react"; import { Button } from "@App/pages/components/ui/button"; import { Progress } from "@App/pages/components/ui/progress"; import { StateScreen } from "@App/pages/components/ui/state-screen"; import { InstallTopBar } from "./InstallTopBar"; -/** 状态屏外壳:保留品牌顶栏(对照设计稿,加载/失败态不丢失外壳),内容区垂直居中 */ -function StateShell({ children }: { children: React.ReactNode }) { - const { t } = useTranslation(["install", "common"]); +/** + * 状态屏外壳:保留品牌顶栏(对照设计稿,加载/失败态不丢失外壳),内容区垂直居中。 + * chip 只在确知场景时才给——从更新页点进来的必然是更新,写死「脚本安装」会先闪一次错的上下文; + * 底部留一条与就绪态操作栏等高的占位,避免就绪瞬间内容区高度再跳一次。 + */ +function StateShell({ + title, + titleIcon, + children, +}: { + title?: string; + titleIcon?: LucideIcon; + children: React.ReactNode; +}) { return (
- +
{children}
+
); } @@ -22,21 +35,35 @@ export function InstallLoading({ source, bytesText, percent, + mode, }: { source?: string; bytesText?: string; percent?: number; + /** 已确知的安装/更新场景;未确知时不渲染上下文 chip,不猜 */ + mode?: "install" | "update"; }) { const { t } = useTranslation(["install", "common"]); + const isUpdate = mode === "update"; return ( - + {source && ( @@ -75,6 +102,7 @@ export function InstallError({ return ( ); } + +/** + * 待安装代码已过期。这条路的失败几乎只有一个原因——暂存条目被定时清理清掉, + * 此时「重试」重跑同一段取数必然还是同样结果,出口必须换成让服务端重新备料。 + */ +export function InstallExpired({ onRecheck, onClose }: { onRecheck: () => void; onClose: () => void }) { + const { t } = useTranslation(["install", "common"]); + return ( + + + + +
+ } + /> + + ); +} diff --git a/src/pages/install/useInstallData.test.ts b/src/pages/install/useInstallData.test.ts index 1ffb640e1..651e40720 100644 --- a/src/pages/install/useInstallData.test.ts +++ b/src/pages/install/useInstallData.test.ts @@ -414,12 +414,27 @@ describe("useInstallData 数据流编排", () => { expect(state.skill.metadata.name).toBe("查询串技能"); }); - it("getInstallInfo 无数据时进入 error 状态", async () => { + it("getInstallInfo 无数据时落到「代码已过期」而不是笼统的加载失败", async () => { window.history.replaceState({}, "", "/install.html?uuid=u1"); (scriptClient.getInstallInfo as Mock).mockResolvedValue(undefined); const { result } = renderHook(() => useInstallData()); - await waitFor(() => expect(result.current.state.status).toBe("error")); + // 这条入口的失败几乎只有一个原因:暂存条目被定时清理回收,重试同一段取数不可能成功 + await waitFor(() => expect(result.current.state.status).toBe("expired")); + }); + + it("暂存代码已被清理时同样落到「代码已过期」", async () => { + window.history.replaceState({}, "", "/install.html?uuid=u1"); + const metadata = { name: ["示例脚本"], version: ["1.0.0"] }; + (scriptClient.getInstallInfo as Mock).mockResolvedValue([ + true, + { url: "https://e.com/x.user.js", code: "", uuid: "u1", userSubscribe: false, metadata, source: "user" }, + {}, + ]); + (getTempCode as Mock).mockResolvedValue(undefined); + + const { result } = renderHook(() => useInstallData()); + await waitFor(() => expect(result.current.state.status).toBe("expired")); }); it("加载失败后调用 retry 重新加载并进入 ready", async () => { @@ -445,7 +460,7 @@ describe("useInstallData 数据流编排", () => { }); const { result } = renderHook(() => useInstallData()); - await waitFor(() => expect(result.current.state.status).toBe("error")); + await waitFor(() => expect(result.current.state.status).toBe("expired")); allowSuccess = true; await act(async () => result.current.retry()); @@ -957,6 +972,32 @@ describe("MCP 来源的安装请求", () => { ); }); + it("拒绝外部接入同样置忙态,连点只发一次决定", async () => { + window.history.replaceState({}, "", "/install.html?uuid=u-mcp"); + const info = mcpScriptInfo(); + (scriptClient.getInstallInfo as Mock).mockResolvedValue([false, info, {}]); + (getTempCode as Mock).mockResolvedValue("// code"); + (prepareScriptByCode as Mock).mockResolvedValue({ + script: { name: "MCP 脚本", metadata: info.metadata, status: 2 } as unknown as Script, + }); + vi.spyOn(window, "close").mockImplementation(() => {}); + (externalAccessClient.decideOperation as Mock).mockImplementation(() => new Promise(() => {})); + + const { result } = renderHook(() => useInstallData()); + await waitFor(() => expect(result.current.state.status).toBe("ready")); + + await act(async () => { + void result.current.rejectExternalAccess(); + }); + expect(result.current.outcome.phase).toBe("installing"); + + await act(async () => { + void result.current.rejectExternalAccess(); + }); + expect(externalAccessClient.decideOperation as Mock).toHaveBeenCalledTimes(1); + }); + + it("rejectExternalAccess() 调用 externalAccessClient.decideOperation(approved:false)", async () => { window.history.replaceState({}, "", "/install.html?uuid=u-mcp"); const info = mcpScriptInfo(); @@ -1050,3 +1091,64 @@ describe("MCP 来源的安装请求", () => { expect(externalAccessClient.decideOperation).not.toHaveBeenCalled(); }); }); + +describe("useInstallData 提交中的忙态与防重入", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + window.history.replaceState({}, "", "/install.html"); + }); + + it("开启监听会先真的装一次,这期间置忙态并挡住重复点击", async () => { + window.history.replaceState({}, "", "/install.html?file=fid1"); + const metadata = { name: ["本地脚本"], version: ["1.0.0"] }; + (loadHandle as Mock).mockResolvedValue({ + name: "x.user.js", + getFile: async () => ({ text: async () => "// file code", name: "x.user.js" }), + }); + (parseMetadata as Mock).mockReturnValue(metadata); + (prepareScriptByCode as Mock).mockResolvedValue({ + script: { name: "本地脚本", metadata, status: SCRIPT_STATUS_ENABLE, uuid: "u9" } as unknown as Script, + }); + let finishInstall!: () => void; + (scriptClient.install as Mock).mockImplementation( + () => new Promise((resolve) => (finishInstall = resolve as () => void)) + ); + + const { result } = renderHook(() => useInstallData()); + await waitFor(() => expect(result.current.state.status).toBe("ready")); + + await act(async () => { + void result.current.toggleWatch(); + }); + expect(result.current.outcome.phase).toBe("installing"); + + // 忙态期间再点:不能再发一次安装 + await act(async () => { + void result.current.toggleWatch(); + }); + expect(scriptClient.install as Mock).toHaveBeenCalledTimes(1); + + await act(async () => finishInstall()); + expect(result.current.watching).toBe(true); + }); + + it("安装在飞行中时再次调用 install 不会重复提交", async () => { + const result = await setupReady(); + let finishInstall!: () => void; + (scriptClient.install as Mock).mockImplementation( + () => new Promise((resolve) => (finishInstall = resolve as () => void)) + ); + + await act(async () => { + void result.current.install({ closeAfterInstall: false }); + }); + await act(async () => { + void result.current.install({ closeAfterInstall: false }); + }); + + expect(scriptClient.install as Mock).toHaveBeenCalledTimes(1); + await act(async () => finishInstall()); + expect(result.current.outcome.phase).toBe("installed"); + }); +}); diff --git a/src/pages/install/useInstallData.ts b/src/pages/install/useInstallData.ts index 65c8c45c9..080341ab2 100644 --- a/src/pages/install/useInstallData.ts +++ b/src/pages/install/useInstallData.ts @@ -105,8 +105,10 @@ export interface SkillInstallData { } export type InstallState = - | { status: "loading"; source?: string; bytesText?: string; percent?: number } + | { status: "loading"; source?: string; bytesText?: string; percent?: number; mode?: "install" | "update" } | { status: "invalid" } + // 待安装代码已被暂存清理回收:与一般加载失败区分开,重试同一段取数不可能成功 + | { status: "expired" } | { status: "error"; message: string } | { status: "ready"; view: InstallView } | { status: "skill"; skill: SkillInstallData }; @@ -205,6 +207,8 @@ export interface UseInstallData { installSkill: () => Promise; cancelSkill: () => void; retry: () => void; + /** 待安装代码已过期时的出口:请服务端重新检查该脚本的更新并重新备料 */ + recheck: () => void; /** 失败后重放刚才那次安装动作(供内联错误条的重试按钮) */ retryInstall: () => void; } @@ -226,6 +230,8 @@ export function useInstallData(): UseInstallData { const skillDataRef = useRef(null); const isUpdateRef = useRef(false); const lastInstallOptsRef = useRef({}); + // 安装/决定这类会真正落地的提交是否在飞行中;ref 而非 state,重入判定要在同一个事件里立即生效 + const submittingRef = useRef(false); useEffect(() => { const params = new URLSearchParams(location.search); @@ -302,9 +308,20 @@ export function useInstallData(): UseInstallData { startKeepAlive(uuid); const cached = await scriptClient.getInstallInfo(uuid); const info = cached?.[1]; - if (!info) throw new Error(t("install:script_info_load_failed")); + // 暂存条目/代码已被定时清理回收,是这条入口最常见的失败,单独落到过期终态 + if (!info) { + if (!cancelled) setState({ status: "expired" }); + return; + } + // 是安装还是更新此刻才确知;在此之前不猜,免得顶栏 chip 先闪一次错的上下文 + if (!cancelled) { + setState((s) => (s.status === "loading" ? { ...s, mode: cached?.[0] ? "update" : "install" } : s)); + } const code = await getTempCode(uuid); - if (code === undefined) throw new Error(t("install:script_info_load_failed")); + if (code === undefined) { + if (!cancelled) setState({ status: "expired" }); + return; + } info.code = code; await loadFromInfo(info, !!cached?.[0], cached?.[2] || {}); } else if (rawUrl) { @@ -387,6 +404,9 @@ export function useInstallData(): UseInstallData { const action = actionRef.current; const info = infoRef.current; if (!action || !info) return; + // UI 的 disabled 只是第一道防线:下拉菜单项在 phase 翻转前已经展开时仍能被选中 + if (submittingRef.current) return; + submittingRef.current = true; lastInstallOptsRef.current = opts; setOutcome({ phase: "installing" }); const name = i18nName(action); @@ -434,6 +454,8 @@ export function useInstallData(): UseInstallData { if (closeAfterInstall) setTimeout(() => leaveInstallPage(), LEAVE_DELAY_MS); } catch (e) { setOutcome({ phase: "failed", message: (e as Error)?.message || String(e) }); + } finally { + submittingRef.current = false; } }, []); @@ -441,6 +463,10 @@ export function useInstallData(): UseInstallData { const rejectExternalAccess = useCallback(async () => { const info = infoRef.current; if (!info?.externalAccess) return; + if (submittingRef.current) return; + submittingRef.current = true; + // 决定同样是一次跨进程往返,不置忙态的话按钮全程可点,连点会发出两次决定 + setOutcome({ phase: "installing" }); try { await externalAccessClient.decideOperation({ operationId: info.externalAccess.operationId, approved: false }); } finally { @@ -479,13 +505,19 @@ export function useInstallData(): UseInstallData { const action = actionRef.current; if (!handle || !info || !action) return; if (!watching) { - // 开启监听前先安装当前内容,再追踪后续变更(对照 v1.4 setupWatchFile) + if (submittingRef.current) return; + submittingRef.current = true; + // 开启监听前先安装当前内容,再追踪后续变更(对照 v1.4 setupWatchFile)。 + // 这一步就是一次真正的安装,必须置忙态,否则连点会装两次 + setOutcome({ phase: "installing" }); try { await scriptClient.install({ script: action as Script, code: info.code }); setOutcome({ phase: "idle" }); } catch (e) { setOutcome({ phase: "failed", message: (e as Error)?.message || String(e) }); return; + } finally { + submittingRef.current = false; } const ftInfo: FTInfo = { uuid: info.uuid, @@ -537,6 +569,16 @@ export function useInstallData(): UseInstallData { setReloadKey((k) => k + 1); }, []); + /** + * 代码过期后的出口:让服务端重新检查这个脚本的更新并重新备料, + * 原地重试同一段取数只会再失败一次。 + */ + const recheck = useCallback(() => { + const uuid = new URLSearchParams(location.search).get("uuid"); + if (uuid) void scriptClient.requestCheckUpdate(uuid); + leaveInstallPage(); + }, []); + // 重试要重放「刚才失败的那次动作」本身:沿用同一组安装选项,否则用户点重试会得到 // 与他原本意图不同的结果(例如把「不关闭窗口」「不再检查更新」丢掉)。 const retryInstall = useCallback(() => { @@ -564,6 +606,7 @@ export function useInstallData(): UseInstallData { installSkill, cancelSkill, retry, + recheck, retryInstall, }; } diff --git a/tests/mocks/CodeEditor.tsx b/tests/mocks/CodeEditor.tsx index 8c3deee1b..2d9b93f47 100644 --- a/tests/mocks/CodeEditor.tsx +++ b/tests/mocks/CodeEditor.tsx @@ -1,3 +1,25 @@ -export default function MockCodeEditor({ id, code, diffCode }: { id: string; code?: string; diffCode?: string }) { +import { useEffect } from "react"; + +// 真实编辑器要等偏好设置读出来才创建实例并回调 onEditorMount。默认模拟「已就绪」, +// 需要停在就绪之前(例如断言代码骨架)的用例调 setEditorMounts(false)。 +let editorMounts = true; +export function setEditorMounts(v: boolean) { + editorMounts = v; +} + +export default function MockCodeEditor({ + id, + code, + diffCode, + onEditorMount, +}: { + id: string; + code?: string; + diffCode?: string; + onEditorMount?: (editor: unknown) => void; +}) { + useEffect(() => { + if (editorMounts) onEditorMount?.({}); + }, [onEditorMount]); return
; } From dc399245900c0d5c5207f81e014b78d3ee9d0487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 3 Sep 2026 17:48:34 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20=E6=89=B9=E9=87=8F=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=A1=B5=E8=A1=A5=E9=BD=90=E5=8F=96=E6=95=B0=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E3=80=81=E6=A3=80=E6=9F=A5=E7=A9=BA=E7=AA=97=E6=9C=9F?= =?UTF-8?q?=E4=B8=8E=E5=BF=BD=E7=95=A5/=E6=89=B9=E9=87=8F=E7=9A=84?= =?UTF-8?q?=E4=B8=AD=E9=97=B4=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 取数失败时记录仍是空的,页面直接走到空态,把一次加载失败渲染成 「所有脚本均为最新(已检查 0 个脚本)」这条与事实相反的成功终态; 点「检查更新」到服务端广播回来之间页面完全静止,期间可以连点。 - 取数失败落错误终态:等宽 detail 框 + 重试 / 脚本列表出口 - 主动检查由本地 pending 立刻接管忙态,并把服务端的「正忙」「结果够新已跳过」 「通道异常」三条回执分别说出来;跳过时就地清掉待反馈标记, 否则会在下一次后台检查完成时冒出一条用户没点过的 toast - 忽略复用与更新相同的行级阶段(working → success → 退场),不再 fire-and-forget - 批量进行中互斥(行内勾选、两个批量按钮、全部恢复),避免两条进度互相覆盖; 被「结果失效」中断时保留已完成条数,不把汇总抹掉 - 骨架补齐工具条(桌面)与顶部选择栏/底部操作栏(移动)占位,消除数据到达时的 布局跳动,并加 role="status" / aria-busy;空态下重新检查不再整页闪回骨架 - 脚本名改用 aria-disabled + onClick 早退:disabled 会让浏览器不派发指针事件, 正好在名字被截断、最需要看全名时把 tooltip 一起关掉,键盘触发后焦点还会掉到 body --- src/locales/de-DE/install.json | 14 +- src/locales/en-US/install.json | 14 +- src/locales/ja-JP/install.json | 14 +- src/locales/ko-KR/install.json | 14 +- src/locales/pt-BR/install.json | 14 +- src/locales/ru-RU/install.json | 14 +- src/locales/tr-TR/install.json | 14 +- src/locales/vi-VN/install.json | 14 +- src/locales/zh-CN/install.json | 14 +- src/locales/zh-TW/install.json | 14 +- src/pages/batchupdate/components.test.tsx | 157 ++++++++++++++- src/pages/batchupdate/components.tsx | 222 +++++++++++++++++---- src/pages/batchupdate/hooks.test.ts | 177 ++++++++++++++++- src/pages/batchupdate/hooks.ts | 223 ++++++++++++++++------ src/pages/batchupdate/logic.ts | 4 + src/pages/batchupdate/mobile.tsx | 71 +++++-- 16 files changed, 875 insertions(+), 119 deletions(-) diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index b04c550bd..ea3f9ed8e 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -132,7 +132,19 @@ "batch_done_partial": "{{updated}} aktualisiert, {{failed}} fehlgeschlagen", "view_updated_scripts": "Aktualisierte Skripte anzeigen", "record_expired": "Die Update-Daten sind abgelaufen. Bitte erneut nach Updates suchen.", - "open_failed": "Update-Details konnten nicht geöffnet werden. Bitte erneut versuchen." + "open_failed": "Update-Details konnten nicht geöffnet werden. Bitte erneut versuchen.", + "loading_list": "Update-Liste wird geladen", + "load_failed_title": "Update-Datensätze konnten nicht geladen werden", + "load_failed_desc": "Das Prüfergebnis konnte nicht aus dem Hintergrund gelesen werden. Bitte erneut versuchen.", + "script_list": "Skriptliste", + "check_busy": "Es läuft bereits eine Update-Prüfung", + "check_skipped": "Ergebnisse sind noch aktuell, es wurde nicht erneut geprüft", + "check_failed": "Update-Prüfung fehlgeschlagen, bitte später erneut versuchen", + "row_ignoring": "Wird ignoriert…", + "row_ignored": "v{{version}} ignoriert", + "row_ignore_failed": "Ignorieren fehlgeschlagen", + "batch_interrupted": "{{count}} aktualisiert, der Rest wurde abgebrochen, da das Prüfergebnis abgelaufen ist", + "silent_updated": "Automatisch auf v{{version}} aktualisiert, keine Bestätigung nötig" }, "importpage": { "title": "Datenimport", diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index 5a8ab1eec..8da91bbd5 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -132,7 +132,19 @@ "batch_done_partial": "Updated {{updated}}, {{failed}} failed", "view_updated_scripts": "View updated scripts", "record_expired": "Update data has expired. Please check for updates again.", - "open_failed": "Failed to open update details. Please try again." + "open_failed": "Failed to open update details. Please try again.", + "loading_list": "Loading update list", + "load_failed_title": "Failed to load update records", + "load_failed_desc": "Could not read the check result from the background. Please try again.", + "script_list": "Script list", + "check_busy": "An update check is already running", + "check_skipped": "Results are still fresh, no new check was run", + "check_failed": "Update check failed, please try again later", + "row_ignoring": "Ignoring…", + "row_ignored": "Ignored v{{version}}", + "row_ignore_failed": "Ignore failed", + "batch_interrupted": "Updated {{count}}, the rest stopped because the check result expired", + "silent_updated": "Silently updated to v{{version}}, no confirmation needed" }, "importpage": { "title": "Data Import", diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index 250893124..ef385b408 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -132,7 +132,19 @@ "batch_done_partial": "{{updated}} 件を更新、{{failed}} 件が失敗", "view_updated_scripts": "更新したスクリプトを表示", "record_expired": "更新データの有効期限が切れました。もう一度更新を確認してください", - "open_failed": "更新の詳細を開けませんでした。もう一度お試しください" + "open_failed": "更新の詳細を開けませんでした。もう一度お試しください", + "loading_list": "更新リストを読み込み中", + "load_failed_title": "更新記録の読み込みに失敗しました", + "load_failed_desc": "バックグラウンドから今回のチェック結果を読み取れませんでした。再試行してください。", + "script_list": "スクリプト一覧", + "check_busy": "更新確認を実行中です。しばらくお待ちください", + "check_skipped": "結果はまだ新しいため、再チェックは行いませんでした", + "check_failed": "更新の確認に失敗しました。しばらくしてから再試行してください", + "row_ignoring": "無視中", + "row_ignored": "v{{version}} を無視しました", + "row_ignore_failed": "無視に失敗しました", + "batch_interrupted": "{{count}} 件を更新しました。残りはチェック結果の失効により中止されました", + "silent_updated": "v{{version}} にサイレント更新しました(確認不要)" }, "importpage": { "title": "データインポート", diff --git a/src/locales/ko-KR/install.json b/src/locales/ko-KR/install.json index 147bb94f6..30b8418b5 100644 --- a/src/locales/ko-KR/install.json +++ b/src/locales/ko-KR/install.json @@ -132,7 +132,19 @@ "batch_done_partial": "{{updated}}개 업데이트, {{failed}}개 실패", "view_updated_scripts": "업데이트된 스크립트 보기", "record_expired": "업데이트 데이터가 만료되었습니다. 업데이트를 다시 확인하세요", - "open_failed": "업데이트 상세 정보를 열지 못했습니다. 다시 시도하세요" + "open_failed": "업데이트 상세 정보를 열지 못했습니다. 다시 시도하세요", + "loading_list": "업데이트 목록을 불러오는 중", + "load_failed_title": "업데이트 기록을 불러오지 못했습니다", + "load_failed_desc": "백그라운드에서 이번 검사 결과를 읽을 수 없습니다. 다시 시도해 주세요.", + "script_list": "스크립트 목록", + "check_busy": "이미 업데이트 확인이 실행 중입니다", + "check_skipped": "결과가 아직 최신이라 다시 확인하지 않았습니다", + "check_failed": "업데이트 확인에 실패했습니다. 잠시 후 다시 시도해 주세요", + "row_ignoring": "무시하는 중", + "row_ignored": "v{{version}} 무시함", + "row_ignore_failed": "무시 실패", + "batch_interrupted": "{{count}}개를 업데이트했으며, 나머지는 검사 결과 만료로 중단되었습니다", + "silent_updated": "v{{version}}(으)로 자동 업데이트했습니다. 확인이 필요 없습니다" }, "importpage": { "title": "데이터 가져오기", diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index 350dc4bee..1ffd38636 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -132,7 +132,19 @@ "batch_done_partial": "{{updated}} atualizados, {{failed}} com falha", "view_updated_scripts": "Ver scripts atualizados", "record_expired": "Os dados de atualização expiraram. Verifique as atualizações novamente.", - "open_failed": "Falha ao abrir os detalhes da atualização. Tente novamente." + "open_failed": "Falha ao abrir os detalhes da atualização. Tente novamente.", + "loading_list": "Carregando lista de atualizações", + "load_failed_title": "Falha ao carregar os registros de atualização", + "load_failed_desc": "Não foi possível ler o resultado da verificação em segundo plano. Tente novamente.", + "script_list": "Lista de scripts", + "check_busy": "Já existe uma verificação de atualizações em andamento", + "check_skipped": "Os resultados ainda estão atualizados, nenhuma nova verificação foi feita", + "check_failed": "Falha ao verificar atualizações, tente novamente mais tarde", + "row_ignoring": "Ignorando…", + "row_ignored": "v{{version}} ignorada", + "row_ignore_failed": "Falha ao ignorar", + "batch_interrupted": "{{count}} atualizado(s), o restante foi interrompido porque o resultado da verificação expirou", + "silent_updated": "Atualizado silenciosamente para v{{version}}, sem necessidade de confirmação" }, "importpage": { "title": "Importação de dados", diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index cadf115a4..2bd091551 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -132,7 +132,19 @@ "batch_done_partial": "Обновлено: {{updated}}, с ошибкой: {{failed}}", "view_updated_scripts": "Показать обновлённые скрипты", "record_expired": "Данные об обновлениях устарели. Проверьте обновления заново.", - "open_failed": "Не удалось открыть сведения об обновлении. Попробуйте ещё раз." + "open_failed": "Не удалось открыть сведения об обновлении. Попробуйте ещё раз.", + "loading_list": "Загрузка списка обновлений", + "load_failed_title": "Не удалось загрузить записи обновлений", + "load_failed_desc": "Не удалось получить результат проверки из фонового процесса. Повторите попытку.", + "script_list": "Список скриптов", + "check_busy": "Проверка обновлений уже выполняется", + "check_skipped": "Результаты ещё актуальны, повторная проверка не выполнялась", + "check_failed": "Не удалось проверить обновления, повторите позже", + "row_ignoring": "Игнорирование…", + "row_ignored": "Версия v{{version}} проигнорирована", + "row_ignore_failed": "Не удалось проигнорировать", + "batch_interrupted": "Обновлено: {{count}}, остальное прервано из-за устаревшего результата проверки", + "silent_updated": "Тихо обновлено до v{{version}}, подтверждение не требуется" }, "importpage": { "title": "Импорт данных", diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index b5b198dd7..6370696b0 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -132,7 +132,19 @@ "batch_done_partial": "{{updated}} güncellendi, {{failed}} başarısız", "view_updated_scripts": "Güncellenen betikleri gör", "record_expired": "Güncelleme verileri geçersiz oldu. Lütfen güncellemeleri yeniden denetleyin.", - "open_failed": "Güncelleme ayrıntıları açılamadı. Lütfen yeniden deneyin." + "open_failed": "Güncelleme ayrıntıları açılamadı. Lütfen yeniden deneyin.", + "loading_list": "Güncelleme listesi yükleniyor", + "load_failed_title": "Güncelleme kayıtları yüklenemedi", + "load_failed_desc": "Kontrol sonucu arka plandan okunamadı. Lütfen tekrar deneyin.", + "script_list": "Betik listesi", + "check_busy": "Zaten bir güncelleme kontrolü çalışıyor", + "check_skipped": "Sonuçlar hâlâ güncel, yeniden kontrol edilmedi", + "check_failed": "Güncelleme kontrolü başarısız oldu, lütfen daha sonra tekrar deneyin", + "row_ignoring": "Yok sayılıyor…", + "row_ignored": "v{{version}} yok sayıldı", + "row_ignore_failed": "Yok sayma başarısız", + "batch_interrupted": "{{count}} güncellendi, kontrol sonucu geçersizleştiği için kalanlar durduruldu", + "silent_updated": "Sessizce v{{version}} sürümüne güncellendi, onay gerekmiyor" }, "importpage": { "title": "Veri İçe Aktarma", diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index cff363628..f1cf3179b 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -132,7 +132,19 @@ "batch_done_partial": "Đã cập nhật {{updated}}, {{failed}} thất bại", "view_updated_scripts": "Xem tập lệnh đã cập nhật", "record_expired": "Dữ liệu cập nhật đã hết hiệu lực. Vui lòng kiểm tra cập nhật lại.", - "open_failed": "Không mở được chi tiết cập nhật. Vui lòng thử lại." + "open_failed": "Không mở được chi tiết cập nhật. Vui lòng thử lại.", + "loading_list": "Đang tải danh sách cập nhật", + "load_failed_title": "Không tải được bản ghi cập nhật", + "load_failed_desc": "Không đọc được kết quả kiểm tra từ nền. Vui lòng thử lại.", + "script_list": "Danh sách tập lệnh", + "check_busy": "Đang có một lượt kiểm tra cập nhật chạy rồi", + "check_skipped": "Kết quả vẫn còn mới, chưa kiểm tra lại", + "check_failed": "Kiểm tra cập nhật thất bại, vui lòng thử lại sau", + "row_ignoring": "Đang bỏ qua…", + "row_ignored": "Đã bỏ qua v{{version}}", + "row_ignore_failed": "Bỏ qua thất bại", + "batch_interrupted": "Đã cập nhật {{count}}, phần còn lại dừng vì kết quả kiểm tra đã hết hạn", + "silent_updated": "Đã cập nhật ngầm lên v{{version}}, không cần xác nhận" }, "importpage": { "title": "Nhập dữ liệu", diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index 751632b83..801a71279 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -132,7 +132,19 @@ "batch_done_partial": "已更新 {{updated}} 个,{{failed}} 个失败", "view_updated_scripts": "查看更新的脚本", "record_expired": "更新数据已过期,请重新检查更新", - "open_failed": "打开更新详情失败,请重试" + "open_failed": "打开更新详情失败,请重试", + "loading_list": "正在加载更新列表", + "load_failed_title": "更新记录加载失败", + "load_failed_desc": "无法从后台读取本次检查结果,请重试。", + "script_list": "脚本列表", + "check_busy": "正在检查更新,请稍候", + "check_skipped": "检查结果仍是最新的,未重新检查", + "check_failed": "检查更新失败,请稍后重试", + "row_ignoring": "忽略中", + "row_ignored": "已忽略 v{{version}}", + "row_ignore_failed": "忽略失败", + "batch_interrupted": "已更新 {{count}} 个,其余因检查结果失效中止", + "silent_updated": "已静默更新到 v{{version}},无需确认" }, "importpage": { "title": "数据导入", diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index a9c1c2ed8..c51148b19 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -132,7 +132,19 @@ "batch_done_partial": "已更新 {{updated}} 個,{{failed}} 個失敗", "view_updated_scripts": "檢視已更新的腳本", "record_expired": "更新資料已過期,請重新檢查更新", - "open_failed": "開啟更新詳情失敗,請重試" + "open_failed": "開啟更新詳情失敗,請重試", + "loading_list": "正在載入更新列表", + "load_failed_title": "更新記錄載入失敗", + "load_failed_desc": "無法從背景讀取本次檢查結果,請重試。", + "script_list": "腳本列表", + "check_busy": "正在檢查更新,請稍候", + "check_skipped": "檢查結果仍是最新的,未重新檢查", + "check_failed": "檢查更新失敗,請稍後重試", + "row_ignoring": "忽略中", + "row_ignored": "已忽略 v{{version}}", + "row_ignore_failed": "忽略失敗", + "batch_interrupted": "已更新 {{count}} 個,其餘因檢查結果失效中止", + "silent_updated": "已靜默更新到 v{{version}},無需確認" }, "importpage": { "title": "資料匯入", diff --git a/src/pages/batchupdate/components.test.tsx b/src/pages/batchupdate/components.test.tsx index f723aae98..46e3e1166 100644 --- a/src/pages/batchupdate/components.test.tsx +++ b/src/pages/batchupdate/components.test.tsx @@ -35,6 +35,8 @@ function mkView(p: Partial = {}): BatchUpdateViewProps { checktime: 0, checking: false, loading: false, + loadError: null, + batchBusy: false, selected: new Set(), autoClose: null, autoCloseCancelled: false, @@ -51,6 +53,7 @@ function mkView(p: Partial = {}): BatchUpdateViewProps { onIgnoreSelected: () => {}, onRestoreAll: () => {}, onCheckNow: () => {}, + onRetryLoad: () => {}, onCancelAutoClose: () => {}, onOpen: () => {}, onOpenScriptList: () => {}, @@ -365,7 +368,7 @@ describe("批量更新 打开更新详情进行中", () => { const onOpen = vi.fn(); renderDesktop({ updates: [mkItem()], opening: new Set(["u1"]), onOpen }); - const name = screen.getByTestId("script-name"); + const name = screen.getByTestId("script-name-u1"); expect(name).toHaveAttribute("aria-busy", "true"); expect(name.querySelector(".animate-spin")).toBeTruthy(); @@ -373,11 +376,19 @@ describe("批量更新 打开更新详情进行中", () => { expect(onOpen).not.toHaveBeenCalled(); }); + it("打开期间用 aria-disabled 而不是 disabled,保住 tooltip 与键盘焦点", () => { + renderDesktop({ updates: [mkItem()], opening: new Set(["u1"]) }); + + const name = screen.getByTestId("script-name-u1"); + expect(name).toHaveAttribute("aria-disabled", "true"); + expect(name).not.toBeDisabled(); + }); + it("未在打开时点击脚本名进入更新详情", () => { const onOpen = vi.fn(); renderDesktop({ updates: [mkItem()], opening: new Set(), onOpen }); - fireEvent.click(screen.getByTestId("script-name")); + fireEvent.click(screen.getByTestId("script-name-u1")); expect(onOpen).toHaveBeenCalledWith("u1"); }); @@ -385,6 +396,146 @@ describe("批量更新 打开更新详情进行中", () => { it("移动卡片在打开期间同样标记为进行中", () => { renderMobile({ updates: [mkItem()], opening: new Set(["u1"]) }); - expect(screen.getByTestId("script-name")).toHaveAttribute("aria-busy", "true"); + expect(screen.getByTestId("script-name-u1")).toHaveAttribute("aria-busy", "true"); + }); +}); + +describe("批量更新 骨架屏几何与无障碍", () => { + it("桌面骨架同时占住工具条,避免数据到达时整表下移", () => { + renderDesktop({ loading: true }); + + const skeleton = screen.getByRole("status", { name: t("install:updatepage.loading_list") }); + expect(skeleton).toHaveAttribute("aria-busy", "true"); + // 工具条占位(复选框 + 计数 + 两个按钮)与表格骨架同时在场 + expect(within(skeleton).getByTestId("update-skeleton")).toBeTruthy(); + expect(skeleton.querySelectorAll('[data-slot="skeleton"]').length).toBeGreaterThan(24); + }); + + it("移动骨架把滚动区外的顶部选择栏与底部操作栏一并占住", () => { + const { container } = renderMobile({ loading: true }); + + expect(screen.getByTestId("update-skeleton")).toHaveAttribute("aria-busy", "true"); + // 真实态里这两条在滚动区之外,只给列表画骨架挡不住上下挤压 + expect(container.querySelector(".h-11 [data-slot='skeleton']")).toBeTruthy(); + expect(container.querySelector(".border-t [data-slot='skeleton']")).toBeTruthy(); + }); +}); + +describe("批量更新 记录加载失败", () => { + it("桌面渲染错误终态而不是「所有脚本均为最新」", () => { + renderDesktop({ loadError: "message channel closed", updates: [], ignored: [] }); + + expect(screen.getByTestId("update-load-error")).toBeTruthy(); + expect(screen.getByTestId("update-load-error-detail")).toHaveTextContent("message channel closed"); + expect(screen.queryByTestId("update-empty")).toBeNull(); + expect(screen.queryByTestId("update-skeleton")).toBeNull(); + }); + + it("重试按钮触发重新加载", () => { + const onRetryLoad = vi.fn(); + renderDesktop({ loadError: "boom", onRetryLoad }); + + fireEvent.click(screen.getByTestId("load-error-retry")); + + expect(onRetryLoad).toHaveBeenCalledTimes(1); + }); + + it("移动端同样落到错误终态", () => { + renderMobile({ loadError: "boom", updates: [], ignored: [] }); + + expect(screen.getByTestId("update-load-error")).toBeTruthy(); + expect(screen.queryByTestId("update-empty")).toBeNull(); + }); +}); + +describe("批量更新 空态下重新检查", () => { + it("检查过一次之后再检查,空态原地保留而不是闪回骨架", () => { + renderDesktop({ checking: true, checktime: 1_700_000_000_000, updates: [], ignored: [] }); + + expect(screen.getByTestId("update-empty")).toBeTruthy(); + expect(screen.queryByTestId("update-skeleton")).toBeNull(); + expect(screen.getByTestId("empty-recheck")).toBeDisabled(); + }); +}); + +describe("批量更新 结果失效提示", () => { + it("提示条可被读屏播报并自带重新检查入口", () => { + const onCheckNow = vi.fn(); + renderDesktop({ recordExpired: true, onCheckNow }); + + expect(screen.getByTestId("record-expired")).toHaveAttribute("role", "alert"); + fireEvent.click(screen.getByTestId("record-expired-recheck")); + expect(onCheckNow).toHaveBeenCalledTimes(1); + }); +}); + +describe("批量更新 批量进行中互斥", () => { + it("桌面批量进行中禁用勾选与两个批量按钮", () => { + renderDesktop({ + updates: [mkItem(), mkItem({ uuid: "u2" })], + selected: new Set(["u1"]), + batchBusy: true, + batchProgress: { done: 1, total: 2, failed: 0, finished: false }, + }); + + for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled(); + expect(screen.getByRole("button", { name: t("install:updatepage.ignore_selected") })).toBeDisabled(); + expect( + screen.getByRole("button", { name: t("install:updatepage.update_selected", { count: 1 }) }) + ).toBeDisabled(); + }); + + it("已忽略分组的全部恢复在批量进行中同样禁用", () => { + renderDesktop({ ignored: [mkItem({ ignored: true })], batchBusy: true }); + + expect(screen.getByTestId("ignored-restore-all")).toBeDisabled(); + }); +}); + +describe("批量更新 忽略的行内状态", () => { + it("忽略中的行显示「忽略中」而不是「更新中」", () => { + renderDesktop({ updates: [mkItem()], rowStates: { u1: { phase: "working", kind: "ignore" } } }); + + const status = screen.getByTestId("row-status-u1"); + expect(status).toHaveAttribute("data-kind", "ignore"); + expect(status).toHaveTextContent(t("install:updatepage.row_ignoring")); + }); + + it("忽略成功的行展示已忽略的版本号", () => { + renderDesktop({ updates: [mkItem()], rowStates: { u1: { phase: "success", kind: "ignore" } } }); + + expect(screen.getByTestId("row-status-u1")).toHaveTextContent( + t("install:updatepage.row_ignored", { version: "1.1.0" }) + ); + }); + + it("忽略失败后点重试重放的是忽略,而不是改成安装", () => { + const onIgnore = vi.fn(); + const onUpdate = vi.fn(); + renderDesktop({ + updates: [mkItem()], + rowStates: { u1: { phase: "fail", kind: "ignore", error: "boom" } }, + onIgnore, + onUpdate, + }); + + fireEvent.click(screen.getByTestId("row-retry-u1")); + + expect(onIgnore).toHaveBeenCalledTimes(1); + expect(onUpdate).not.toHaveBeenCalled(); + }); +}); + +describe("批量更新 批量中断汇总", () => { + it("中断时如实汇报已完成条数,并保留查看入口", () => { + renderDesktop({ + recordExpired: true, + batchProgress: { done: 1, total: 3, failed: 0, finished: true, interrupted: true }, + }); + + expect(screen.getByTestId("batch-summary")).toHaveTextContent( + t("install:updatepage.batch_interrupted", { count: 1 }) + ); + expect(screen.getByTestId("batch-open-scripts")).toBeTruthy(); }); }); diff --git a/src/pages/batchupdate/components.tsx b/src/pages/batchupdate/components.tsx index debbd9797..cefcbc8fd 100644 --- a/src/pages/batchupdate/components.tsx +++ b/src/pages/batchupdate/components.tsx @@ -38,6 +38,10 @@ export interface BatchUpdateViewProps { checktime: number; checking: boolean; loading: boolean; + /** 记录取数失败的原因;非 null 时整页落到错误终态 */ + loadError: string | null; + /** 批量正在进行:期间禁止勾选与再次发起批量,避免两条进度互相覆盖 */ + batchBusy: boolean; selected: Set; /** 自动关闭剩余秒数;为 null 表示不再倒计时 */ autoClose: number | null; @@ -60,6 +64,8 @@ export interface BatchUpdateViewProps { onIgnoreSelected: () => void; onRestoreAll: () => void; onCheckNow: () => void; + /** 重新拉取更新记录(加载失败后的出口) */ + onRetryLoad: () => void; onCancelAutoClose: () => void; /** 打开单个脚本的更新详情页 */ onOpen: (uuid: string) => void; @@ -170,20 +176,42 @@ export function SourceCell({ source }: { source: string }) { ); } -/** 可点击跳转更新详情页的脚本名(过长时 tooltip 显示全名);loading 期间转圈并拒绝再次点击 */ -export function ScriptName({ name, loading, onClick }: { name: string; loading?: boolean; onClick: () => void }) { +/** + * 可点击跳转更新详情页的脚本名(过长时 tooltip 显示全名);loading 期间转圈并拒绝再次点击。 + * 用 aria-disabled 而不是 disabled:浏览器不向 disabled 控件派发指针事件, + * 那样会在名字被截断、最需要看全名的时候把 tooltip 一起关掉,键盘触发后焦点也会掉到 body。 + * 转圈位常驻等宽空槽,避免 spinner 出现时把名字挤窄造成二次截断。 + */ +export function ScriptName({ + name, + loading, + uuid, + onClick, +}: { + name: string; + loading?: boolean; + uuid: string; + onClick: () => void; +}) { return ( ); @@ -222,8 +250,14 @@ export function RowStatus({ }) { const { t } = useTranslation(); const testId = `row-status-${item.uuid}`; + const ignoring = state?.kind === "ignore"; const wrap = (phase: string, content: ReactNode) => ( - + {content} ); @@ -238,7 +272,9 @@ export function RowStatus({ "working", <> - {t("install:updatepage.row_updating")} + + {ignoring ? t("install:updatepage.row_ignoring") : t("install:updatepage.row_updating")} + ); case "success": @@ -248,7 +284,9 @@ export function RowStatus({ <> - {t("install:updatepage.row_updated", { version: item.newVersion })} + {ignoring + ? t("install:updatepage.row_ignored", { version: item.newVersion }) + : t("install:updatepage.row_updated", { version: item.newVersion })} ); @@ -258,7 +296,7 @@ export function RowStatus({ <> - {t("install:updatepage.row_failed")} + {ignoring ? t("install:updatepage.row_ignore_failed") : t("install:updatepage.row_failed")} {"·"} @@ -324,6 +362,7 @@ export function RestoreAllAction({ view, className }: { view: BatchUpdateViewPro variant="link" size="sm" data-testid="ignored-restore-all" + disabled={view.batchBusy} className={cn("h-auto p-0 text-[13px]", className)} > {t("install:updatepage.restore_all")} @@ -343,14 +382,16 @@ export function BatchSummary({ className?: string; }) { const { t } = useTranslation(); - const { done, total, failed, finished } = progress; + const { done, total, failed, finished, interrupted } = progress; const updated = done - failed; - const succeeded = finished && failed === 0; + const succeeded = finished && failed === 0 && !interrupted; const text = !finished ? t("install:updatepage.batch_progress", { done, total }) - : failed > 0 - ? t("install:updatepage.batch_done_partial", { updated, failed }) - : t("install:updatepage.batch_done", { count: updated }); + : interrupted + ? t("install:updatepage.batch_interrupted", { count: updated }) + : failed > 0 + ? t("install:updatepage.batch_done_partial", { updated, failed }) + : t("install:updatepage.batch_done", { count: updated }); return (
0 ? "bg-warning" : undefined} + indicatorClassName={succeeded ? "bg-success" : failed > 0 || interrupted ? "bg-warning" : undefined} />
0 && finished ? "text-warning-fg" : "text-fg-secondary" + succeeded + ? "text-success-fg" + : finished && (failed > 0 || interrupted) + ? "text-warning-fg" + : "text-fg-secondary" )} > {text} @@ -384,23 +429,43 @@ export function BatchSummary({ ); } -/** 检查结果已随 Service Worker 回收失效:点更新不会有任何效果,必须显式告知 */ -export function RecordExpiredNotice({ className }: { className?: string }) { +/** + * 检查结果已随 Service Worker 回收失效:点更新不会有任何效果,必须显式告知。 + * 自带重新检查入口——移动端顶栏那个按钮只有图标,让用户自己找不合理。 + */ +export function RecordExpiredNotice({ onCheckNow, className }: { onCheckNow: () => void; className?: string }) { const { t } = useTranslation(); return (
- {t("install:updatepage.record_expired")} + {t("install:updatepage.record_expired")} +
); } +/** + * 是否展示骨架。首屏取数期间必然要;此外只有「从未检查过、这次是第一次」才用骨架, + * 已经给出过空态之后再点检查,保留空态 + 顶部进度条即可,不要整页闪回骨架再闪回来。 + */ +export function showSkeleton(view: BatchUpdateViewProps, empty: boolean): boolean { + return view.loading || (view.checking && empty && view.checktime === 0); +} + const COL = { version: "w-[170px] shrink-0", change: "w-[230px] shrink-0", @@ -415,6 +480,7 @@ function DesktopRow({ state, selected, opening, + batchBusy, onToggle, onOpen, onUpdate, @@ -426,6 +492,7 @@ function DesktopRow({ state?: RowState; selected?: boolean; opening?: boolean; + batchBusy?: boolean; onToggle?: (uuid: string) => void; onOpen: (uuid: string) => void; onUpdate?: (item: UpdateItem) => void; @@ -435,7 +502,9 @@ function DesktopRow({ }) { const { t } = useTranslation(); const dim = item.enabled ? "" : "opacity-55"; - const primaryAction = () => (ignoredRow ? onRestore?.(item) : onUpdate?.(item)); + // 重试要重放失败的那个动作本身,忽略失败后再点「重试」不该改成安装 + const primaryAction = () => + state?.kind === "ignore" ? onIgnore?.(item) : ignoredRow ? onRestore?.(item) : onUpdate?.(item); return (
) : ( - onToggle?.(item.uuid)} /> + onToggle?.(item.uuid)} /> )}
- onOpen(item.uuid)} /> + onOpen(item.uuid)} />
@@ -502,6 +571,7 @@ function DesktopTable({ view }: { view: BatchUpdateViewProps }) { state={view.rowStates[item.uuid]} selected={view.selected.has(item.uuid)} opening={view.opening.has(item.uuid)} + batchBusy={view.batchBusy} onToggle={view.onToggle} onOpen={view.onOpen} onUpdate={view.onUpdate} @@ -535,6 +605,7 @@ function DesktopIgnored({ view }: { view: BatchUpdateViewProps }) { state={view.rowStates[item.uuid]} ignoredRow opening={view.opening.has(item.uuid)} + batchBusy={view.batchBusy} onOpen={view.onOpen} onRestore={view.onRestore} /> @@ -552,7 +623,7 @@ function DesktopToolbar({ view }: { view: BatchUpdateViewProps }) { return (
- + {t("install:updatepage.selected_count", { selected: selectedCount, total: view.updates.length })} @@ -566,11 +637,16 @@ function DesktopToolbar({ view }: { view: BatchUpdateViewProps }) { )}
- - @@ -590,8 +666,30 @@ export function SkeletonBar({ className }: { className?: string }) { return ; } -/** 桌面端检查中的骨架表格:保留表头 + 占位行,取代冻结的空状态/大转圈 */ +/** + * 桌面端检查中的骨架:保留表头 + 占位行,取代冻结的空状态/大转圈。 + * 工具条也要占位——真实态里它在表格上方,缺了这一块数据到达时整张表会向下跳一截。 + */ function SkeletonTable() { + const { t } = useTranslation(); + return ( +
+
+
+ + +
+
+ + +
+
+ +
+ ); +} + +function SkeletonRows() { const { t } = useTranslation(); return ( @@ -630,8 +728,16 @@ function SkeletonTable() { ); } -/** 空状态:所有脚本均为最新 */ -export function EmptyState({ totalChecked, onCheckNow }: { totalChecked: number; onCheckNow: () => void }) { +/** 空状态:所有脚本均为最新。重新检查时原地保留,只让按钮转圈,不整页闪回骨架 */ +export function EmptyState({ + totalChecked, + checking, + onCheckNow, +}: { + totalChecked: number; + checking: boolean; + onCheckNow: () => void; +}) { const { t } = useTranslation(); return ( - + } @@ -652,6 +758,46 @@ export function EmptyState({ totalChecked, onCheckNow }: { totalChecked: number; ); } +/** + * 更新记录取数失败的终态。没有这一屏时,空记录会被渲染成「所有脚本均为最新」, + * 把一次加载失败说成了检查成功。 + */ +export function LoadErrorScreen({ + error, + onRetry, + onOpenScriptList, +}: { + error: string; + onRetry: () => void; + onOpenScriptList: () => void; +}) { + const { t } = useTranslation(); + return ( + + + +
+ } + /> + ); +} + /** 顶部状态/自动关闭信息条 */ function HeaderStatus({ view }: { view: BatchUpdateViewProps }) { const { t } = useTranslation(); @@ -754,16 +900,22 @@ export function DesktopView({ view }: { view: BatchUpdateViewProps }) {
{view.checking && } - {view.recordExpired && } + {view.recordExpired && } {view.batchProgress && ( )}
- {view.loading || (view.checking && empty) ? ( + {view.loadError !== null ? ( + + ) : showSkeleton(view, empty) ? ( ) : empty ? ( - + ) : ( <> {view.updates.length > 0 && ( diff --git a/src/pages/batchupdate/hooks.test.ts b/src/pages/batchupdate/hooks.test.ts index 0c9258b45..451fba7cc 100644 --- a/src/pages/batchupdate/hooks.test.ts +++ b/src/pages/batchupdate/hooks.test.ts @@ -6,20 +6,27 @@ import type { TBatchUpdateRecord, TBatchUpdateRecordObject, TBatchUpdateResult, + TCheckScriptUpdateResult, + TOpenUpdatePageResult, } from "@App/app/service/service_worker/types"; import type * as Utils from "@App/pkg/utils/utils"; // useBatchUpdate 通过消息总线订阅检查状态、拉取记录并发起动作;这里整体打桩, // 只验证「用户主动点检查更新 → 完成后弹 toast」的反馈逻辑。 +const freshResult: TCheckScriptUpdateResult = { ok: true, targetSites: [], fresh: true, checktime: 200 }; + const h = vi.hoisted(() => ({ record: { checktime: 0, list: [] } as TBatchUpdateRecordObject, handlers: {} as Record void>, getBatchUpdateRecordLite: vi.fn(), fetchCheckUpdateStatus: vi.fn(() => Promise.resolve()), sendUpdatePageOpened: vi.fn(() => Promise.resolve()), - requestCheckScriptUpdate: vi.fn(() => Promise.resolve()), + requestCheckScriptUpdate: vi.fn( + (): Promise => + Promise.resolve({ ok: true, targetSites: [], fresh: true, checktime: 200 }) + ), requestBatchUpdateListAction: vi.fn((): Promise => Promise.resolve(undefined)), - requestOpenUpdatePageByUUID: vi.fn(() => Promise.resolve(true)), + requestOpenUpdatePageByUUID: vi.fn((): Promise => Promise.resolve("opened")), toastSuccess: vi.fn(), toastWarning: vi.fn(), toastError: vi.fn(), @@ -377,9 +384,9 @@ describe("批量更新 Hook useBatchUpdate 查看更新的脚本", () => { describe("批量更新 Hook useBatchUpdate 打开更新详情", () => { it("打开过程中标记该行为进行中,重复点击不再重复发起", async () => { - let resolveOpen!: (value: boolean) => void; + let resolveOpen!: (value: TOpenUpdatePageResult) => void; h.requestOpenUpdatePageByUUID.mockImplementationOnce( - () => new Promise((resolve) => (resolveOpen = resolve)) + () => new Promise((resolve) => (resolveOpen = resolve)) ); const { result } = await setup([mkRecord("a")]); @@ -393,13 +400,13 @@ describe("批量更新 Hook useBatchUpdate 打开更新详情", () => { }); expect(h.requestOpenUpdatePageByUUID).toHaveBeenCalledTimes(1); - await act(async () => resolveOpen(true)); + await act(async () => resolveOpen("opened")); expect(result.current.opening.has("a")).toBe(false); }); it("打开失败时提示用户并解除进行中标记", async () => { - h.requestOpenUpdatePageByUUID.mockResolvedValueOnce(false); + h.requestOpenUpdatePageByUUID.mockResolvedValueOnce("failed"); const { result } = await setup([mkRecord("a")]); await act(async () => result.current.onOpen("a")); @@ -419,6 +426,17 @@ describe("批量更新 Hook useBatchUpdate 打开更新详情", () => { expect(result.current.opening.has("a")).toBe(false); }); + it("服务端判定可静默更新时给出成功反馈,而不是转一圈什么都没发生", async () => { + h.requestOpenUpdatePageByUUID.mockResolvedValueOnce("silent"); + const { result } = await setup([mkRecord("a")]); + + await act(async () => result.current.onOpen("a")); + + expect(h.toastError).not.toHaveBeenCalled(); + expect(h.toastSuccess).toHaveBeenCalledWith(t("install:updatepage.silent_updated", { version: "1.1.0" })); + expect(result.current.rowStates.a.phase).toBe("success"); + }); + it("点开更新详情算显式操作,停掉自动关闭倒计时", async () => { window.history.replaceState({}, "", "/?autoclose=30"); const { result } = await setup([mkRecord("a")]); @@ -430,3 +448,150 @@ describe("批量更新 Hook useBatchUpdate 打开更新详情", () => { window.history.replaceState({}, "", "/"); }); }); + +describe("批量更新 Hook useBatchUpdate 记录加载失败", () => { + it("取数失败时给出错误终态,而不是渲染成「所有脚本均为最新」", async () => { + h.getBatchUpdateRecordLite.mockRejectedValueOnce(new Error("message channel closed")); + + const { result } = renderHook(() => useBatchUpdate()); + await act(async () => {}); + + await waitFor(() => expect(result.current.loadError).toBe("message channel closed")); + expect(result.current.loading).toBe(false); + }); + + it("重试加载成功后清除错误态", async () => { + h.getBatchUpdateRecordLite.mockRejectedValueOnce(new Error("boom")); + const { result } = renderHook(() => useBatchUpdate()); + await act(async () => {}); + await waitFor(() => expect(result.current.loadError).toBe("boom")); + + h.record = { checktime: 200, list: [mkRecord("a")] }; + await act(async () => result.current.onRetryLoad()); + + expect(result.current.loadError).toBeNull(); + expect(result.current.updates).toHaveLength(1); + }); +}); + +describe("批量更新 Hook useBatchUpdate 主动检查的空窗期", () => { + it("点击后立刻进入检查中,不等服务端广播回来", async () => { + let finishCheck!: (value: TCheckScriptUpdateResult) => void; + h.requestCheckScriptUpdate.mockImplementationOnce( + () => new Promise((resolve) => (finishCheck = resolve)) + ); + const { result } = await setup([mkRecord("a")]); + + act(() => result.current.onCheckNow()); + + expect(result.current.checking).toBe(true); + + await act(async () => finishCheck(freshResult)); + }); + + it("服务端回报正忙时提示用户并解除本地检查中状态", async () => { + h.requestCheckScriptUpdate.mockResolvedValueOnce({ ok: false, reason: "busy", err: "checkScriptUpdate is busy" }); + const { result } = await setup([mkRecord("a")]); + + await act(async () => result.current.onCheckNow()); + + await waitFor(() => expect(h.toastWarning).toHaveBeenCalledWith(t("install:updatepage.check_busy"))); + expect(result.current.checking).toBe(false); + }); + + it("检查请求本身报错时给出失败反馈,并解除本地检查中状态", async () => { + h.requestCheckScriptUpdate.mockRejectedValueOnce(new Error("channel closed")); + const { result } = await setup([mkRecord("a")]); + + await act(async () => result.current.onCheckNow()); + + await waitFor(() => + expect(h.toastError).toHaveBeenCalledWith(expect.stringContaining(t("install:updatepage.check_failed"))) + ); + expect(h.toastError.mock.calls[0][0]).toContain("channel closed"); + expect(result.current.checking).toBe(false); + }); + + it("结果够新被跳过时告知用户,且不再在下次后台检查完成时冒出用户没点过的 toast", async () => { + h.requestCheckScriptUpdate.mockResolvedValueOnce({ ...freshResult, fresh: false }); + const { result } = await setup([mkRecord("a")]); + + await act(async () => result.current.onCheckNow()); + + await waitFor(() => expect(h.toastSuccess).toHaveBeenCalledWith(t("install:updatepage.check_skipped"))); + expect(result.current.checking).toBe(false); + + h.toastSuccess.mockClear(); + await runCheck([mkRecord("a")]); + expect(h.toastSuccess).not.toHaveBeenCalled(); + }); +}); + +describe("批量更新 Hook useBatchUpdate 忽略更新", () => { + it("忽略时行内先进入进行中,服务端确认后落到已忽略", async () => { + let resolveIgnore!: (value: TBatchUpdateResult) => void; + h.requestBatchUpdateListAction.mockImplementationOnce( + () => new Promise((resolve) => (resolveIgnore = resolve)) + ); + const { result } = await setup([mkRecord("a")]); + + act(() => result.current.onIgnore(result.current.updates[0])); + + expect(result.current.rowStates.a).toEqual({ phase: "working", kind: "ignore" }); + + await act(async () => resolveIgnore(okItem("a"))); + + // 退场与摘除的时序由更新路径的用例覆盖,这里只认「忽略走的是同一套阶段」 + expect(result.current.rowStates.a).toEqual({ phase: "success", kind: "ignore" }); + }); + + it("忽略失败时行停在失败态并保留重试入口,而不是无声无息", async () => { + h.requestBatchUpdateListAction.mockResolvedValueOnce({ + ok: true, + items: [{ uuid: "a", success: false, error: "script not found" }], + }); + const { result } = await setup([mkRecord("a")]); + + await act(async () => result.current.onIgnore(result.current.updates[0])); + + expect(result.current.rowStates.a).toEqual({ phase: "fail", kind: "ignore", error: "script not found" }); + expect(result.current.updates).toHaveLength(1); + }); +}); + +describe("批量更新 Hook useBatchUpdate 批量互斥与中断", () => { + it("批量进行中不接受第二次批量,避免两条进度互相覆盖", async () => { + let resolveFirst!: (value: TBatchUpdateResult) => void; + h.requestBatchUpdateListAction.mockImplementationOnce( + () => new Promise((resolve) => (resolveFirst = resolve)) + ); + const { result } = await setup([mkRecord("a"), mkRecord("b")]); + + act(() => result.current.onToggle("a")); + act(() => result.current.onUpdateSelected()); + expect(result.current.batchBusy).toBe(true); + + act(() => result.current.onToggle("b")); + act(() => result.current.onUpdateSelected()); + + expect(h.requestBatchUpdateListAction).toHaveBeenCalledTimes(1); + expect(result.current.batchProgress).toEqual({ done: 0, total: 1, failed: 0, finished: false }); + + await act(async () => resolveFirst(okItem("a"))); + await waitFor(() => expect(result.current.batchBusy).toBe(false), { timeout: 3000 }); + }); + + it("批量被记录失效中断时保留已完成的条数,不把汇总抹掉", async () => { + h.requestBatchUpdateListAction + .mockResolvedValueOnce(okItem("a")) + .mockResolvedValueOnce({ ok: false, reason: "record_expired", items: [] }); + const { result } = await setup([mkRecord("a"), mkRecord("b")]); + + act(() => result.current.onToggleAll()); + await act(async () => result.current.onUpdateSelected()); + + expect(result.current.recordExpired).toBe(true); + expect(result.current.batchProgress).toEqual({ done: 1, total: 2, failed: 0, finished: true, interrupted: true }); + expect(result.current.rowStates.b).toBeUndefined(); + }); +}); diff --git a/src/pages/batchupdate/hooks.ts b/src/pages/batchupdate/hooks.ts index 3ae1bacdd..7e06f4cdd 100644 --- a/src/pages/batchupdate/hooks.ts +++ b/src/pages/batchupdate/hooks.ts @@ -53,6 +53,10 @@ export function useBatchUpdate(): BatchUpdateViewProps { const [checktime, setChecktime] = useState(0); const [checking, setChecking] = useState(false); const [loading, setLoading] = useState(true); + // 记录取数失败的原因;非 null 时整页落到错误终态,不再冒充「均为最新」 + const [loadError, setLoadError] = useState(null); + // 点了「检查更新」但服务端广播还没回来的那段空窗期:本地先接管忙态,否则期间可以连点 + const [pendingCheck, setPendingCheck] = useState(false); const [selected, setSelected] = useState>(() => new Set()); const [autoCloseState, setAutoCloseState] = useState(() => ({ seconds: parseAutoClose(), @@ -78,6 +82,10 @@ export function useBatchUpdate(): BatchUpdateViewProps { // 更新过程中收到的 refreshRecord 广播:先记下,等行状态全部落地后再补做,避免全量刷新冲掉乐观 UI const deferredReloadRef = useRef<{ finished: boolean } | null>(null); const timersRef = useRef>(new Set()); + // 批量是否正在进行;ref 而非 state,因为互斥判定要在同一个事件里立即生效 + const batchRunningRef = useRef(false); + // 按 uuid 索引当前列表项,供打开详情等异步回调取用而不必把整张列表塞进依赖 + const itemsRef = useRef>(new Map()); const loadRecord = useCallback(async (): Promise => { if (loadingRef.current) return null; @@ -87,6 +95,7 @@ export function useBatchUpdate(): BatchUpdateViewProps { const list = obj?.list ?? []; setRecords(list); if (typeof obj?.checktime === "number") setChecktime(obj.checktime); + setLoadError(null); return list; } finally { loadingRef.current = false; @@ -94,6 +103,26 @@ export function useBatchUpdate(): BatchUpdateViewProps { } }, []); + /** + * 取数的唯一入口。失败时记录仍是空的,不落错误态就会被渲染成「所有脚本均为最新」 + * 这条与事实相反的成功终态;错误收在这里而不是 loadRecord 内部,是因为在 async 函数里 + * catch 到的 setState 无法被证明发生在 await 之后(同步抛出时就是同步 setState)。 + */ + const loadRecordSafely = useCallback( + () => + loadRecord().catch((e: unknown) => { + setLoadError(e instanceof Error ? e.message : String(e)); + return null; + }), + [loadRecord] + ); + + const onRetryLoad = useCallback(() => { + setLoading(true); + setLoadError(null); + void loadRecordSafely(); + }, [loadRecordSafely]); + const setTimer = useCallback((fn: () => void, ms: number) => { const id = window.setTimeout(() => { timersRef.current.delete(id); @@ -104,7 +133,7 @@ export function useBatchUpdate(): BatchUpdateViewProps { const applyReload = useCallback( (finished: boolean) => { - void loadRecord().then((list) => { + void loadRecordSafely().then((list) => { // list 为 null 表示这次刷新被并发的加载挡掉了,记录并未更新,此时不能清掉已退场的行 if (list) setDismissed((prev) => (prev.size === 0 ? prev : new Set())); // 仅对用户主动发起的检查在完成后给出 toast 反馈(后台/系统检查不打扰) @@ -119,7 +148,7 @@ export function useBatchUpdate(): BatchUpdateViewProps { } }); }, - [loadRecord, t] + [loadRecordSafely, t] ); /** 行状态全部落地(只剩 fail 或为空)时,补做被推迟的全量刷新 */ @@ -162,9 +191,9 @@ export function useBatchUpdate(): BatchUpdateViewProps { }); void scriptClient.fetchCheckUpdateStatus(); void scriptClient.sendUpdatePageOpened(); - void loadRecord(); + void loadRecordSafely(); return unsub; - }, [applyReload, loadRecord]); + }, [applyReload, loadRecordSafely]); // 行状态推进用的定时器只在卸载时清理:不能挂在订阅那个 effect 上, // 否则运行时切语言(t 变化导致 effect 重跑)会把正在进行的展示/退场计时一并砍掉 @@ -200,6 +229,10 @@ export function useBatchUpdate(): BatchUpdateViewProps { }; }, [records, site, dismissed]); + useEffect(() => { + itemsRef.current = new Map([...updates, ...ignored].map((item) => [item.uuid, item])); + }, [updates, ignored]); + /** 成功的行:停留展示 → 收拢退场 → 摘除节点 */ const scheduleRowExit = useCallback( (uuid: string) => { @@ -224,6 +257,9 @@ export function useBatchUpdate(): BatchUpdateViewProps { */ const runUpdates = useCallback( async (items: UpdateItem[], batch: boolean) => { + // 一批还没跑完就再起一批,两个循环会同时推同一条进度:先到的 done 被后到的覆盖, + // 进度条来回跳、收尾 toast 也会出两条 + if (batch && batchRunningRef.current) return; const targets = items.filter((item) => !isRowInFlight(rowStatesRef.current[item.uuid])); if (targets.length === 0) return; cancelAutoClose(); @@ -231,43 +267,53 @@ export function useBatchUpdate(): BatchUpdateViewProps { commitRows((draft) => { for (const item of targets) draft[item.uuid] = { phase: batch ? "queued" : "working" }; }); - if (batch) setBatchProgress({ done: 0, total: targets.length, failed: 0, finished: false }); + if (batch) { + batchRunningRef.current = true; + setBatchProgress({ done: 0, total: targets.length, failed: 0, finished: false }); + } let failed = 0; - for (let i = 0; i < targets.length; i++) { - const item = targets[i]; - commitRows((draft) => { - draft[item.uuid] = { phase: "working" }; - }); - const res: TBatchUpdateResult | undefined = await requestBatchUpdateListAction({ - actionCode: BatchUpdateListActionCode.UPDATE, - actionPayload: [{ uuid: item.uuid }], - }); - if (res && !res.ok) { - // 服务端的检查结果已失效,余下的条目连尝试的意义都没有:回到初始态,等用户重新检查 - commitRows((draft) => { - for (let rest = i; rest < targets.length; rest++) delete draft[targets[rest].uuid]; - }); - setRecordExpired(true); - setBatchProgress(null); - flushDeferredReload(); - return; - } - const result = res?.items.find((entry) => entry.uuid === item.uuid); - if (result?.success) { + try { + for (let i = 0; i < targets.length; i++) { + const item = targets[i]; commitRows((draft) => { - draft[item.uuid] = { phase: "success" }; + draft[item.uuid] = { phase: "working" }; }); - scheduleRowExit(item.uuid); - } else { - failed += 1; - commitRows((draft) => { - draft[item.uuid] = { phase: "fail", error: result?.error ?? "" }; + const res: TBatchUpdateResult | undefined = await requestBatchUpdateListAction({ + actionCode: BatchUpdateListActionCode.UPDATE, + actionPayload: [{ uuid: item.uuid }], }); + if (res && !res.ok) { + // 服务端的检查结果已失效,余下的条目连尝试的意义都没有:回到初始态,等用户重新检查。 + // 已经装好的那几条是真的装好了,汇总必须留着如实交代,不能连同进度一起抹掉 + commitRows((draft) => { + for (let rest = i; rest < targets.length; rest++) delete draft[targets[rest].uuid]; + }); + setRecordExpired(true); + setBatchProgress( + batch ? { done: i, total: targets.length, failed, finished: true, interrupted: true } : null + ); + flushDeferredReload(); + return; + } + const result = res?.items.find((entry) => entry.uuid === item.uuid); + if (result?.success) { + commitRows((draft) => { + draft[item.uuid] = { phase: "success" }; + }); + scheduleRowExit(item.uuid); + } else { + failed += 1; + commitRows((draft) => { + draft[item.uuid] = { phase: "fail", error: result?.error ?? "" }; + }); + } + if (batch) { + setBatchProgress({ done: i + 1, total: targets.length, failed, finished: i + 1 === targets.length }); + } } - if (batch) { - setBatchProgress({ done: i + 1, total: targets.length, failed, finished: i + 1 === targets.length }); - } + } finally { + batchRunningRef.current = false; } if (batch) { @@ -294,15 +340,45 @@ export function useBatchUpdate(): BatchUpdateViewProps { [runUpdates] ); - const onIgnore = useCallback( - (item: UpdateItem) => { + /** + * 忽略同样是一次跨进程往返,fire-and-forget 时用户只会以为没点上并反复点击。 + * 因此复用与更新完全相同的行级阶段(working → success → 退场),只换文案。 + */ + const runIgnores = useCallback( + async (items: UpdateItem[]) => { + const targets = items.filter((item) => !isRowInFlight(rowStatesRef.current[item.uuid])); + if (targets.length === 0) return; cancelAutoClose(); - void requestBatchUpdateListAction({ + commitRows((draft) => { + for (const item of targets) draft[item.uuid] = { phase: "working", kind: "ignore" }; + }); + const res = await requestBatchUpdateListAction({ actionCode: BatchUpdateListActionCode.IGNORE, - actionPayload: [{ uuid: item.uuid, ignoreVersion: item.newVersion }], + actionPayload: targets.map((item) => ({ uuid: item.uuid, ignoreVersion: item.newVersion })), }); + for (const item of targets) { + const result = res?.items.find((entry) => entry.uuid === item.uuid); + if (result?.success) { + commitRows((draft) => { + draft[item.uuid] = { phase: "success", kind: "ignore" }; + }); + scheduleRowExit(item.uuid); + } else { + commitRows((draft) => { + draft[item.uuid] = { phase: "fail", kind: "ignore", error: result?.error ?? "" }; + }); + } + } + flushDeferredReload(); }, - [cancelAutoClose] + [cancelAutoClose, commitRows, flushDeferredReload, scheduleRowExit] + ); + + const onIgnore = useCallback( + (item: UpdateItem) => { + void runIgnores([item]); + }, + [runIgnores] ); const onUpdateSelected = useCallback(() => { @@ -312,26 +388,48 @@ export function useBatchUpdate(): BatchUpdateViewProps { }, [updates, selected, runUpdates]); const onIgnoreSelected = useCallback(() => { - cancelAutoClose(); - const payload = updates - .filter((u) => selected.has(u.uuid)) - .map((u) => ({ uuid: u.uuid, ignoreVersion: u.newVersion })); - if (payload.length) { - void requestBatchUpdateListAction({ actionCode: BatchUpdateListActionCode.IGNORE, actionPayload: payload }); - } + const targets = updates.filter((u) => selected.has(u.uuid)); setSelected(new Set()); - }, [updates, selected, cancelAutoClose]); + void runIgnores(targets); + }, [updates, selected, runIgnores]); const onRestoreAll = useCallback(() => { void runUpdates(ignored, true); }, [ignored, runUpdates]); - const onCheckNow = useCallback(() => { + /** + * 主动检查更新。checking 只跟随服务端广播,往返回来之前页面没有任何忙态, + * 因此本地先接管;服务端「正忙」「结果够新已跳过」两条回执也必须说出来, + * 否则用户点完看到的是一个完全静止的页面。 + */ + const runCheckNow = useCallback(async () => { cancelAutoClose(); setRecordExpired(false); + // 中断汇总是上一轮的结论,重新检查即作废;正常跑完的汇总由驻留计时自己收 + setBatchProgress((p) => (p?.interrupted ? null : p)); userCheckPendingRef.current = true; - void requestCheckScriptUpdate({ checkType: "user" }); - }, [cancelAutoClose]); + setPendingCheck(true); + try { + const res = await requestCheckScriptUpdate({ checkType: "user" }); + if (res?.ok && res.fresh) return; + // 没有真的发起检查,就不会有「检查完成」的广播,待反馈标记必须就地清掉, + // 否则会在下一次后台检查完成时冒出一条用户没点过的 toast + userCheckPendingRef.current = false; + if (res?.ok) notify.success(t("install:updatepage.check_skipped")); + else if (res?.reason === "busy") notify.warning(t("install:updatepage.check_busy")); + else notify.error(t("install:updatepage.check_failed")); + } catch (e) { + // 消息通道本身失败(Service Worker 未就绪等)同样没有广播可等 + userCheckPendingRef.current = false; + notify.error(`${t("install:updatepage.check_failed")}: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setPendingCheck(false); + } + }, [cancelAutoClose, t]); + + const onCheckNow = useCallback(() => { + void runCheckNow(); + }, [runCheckNow]); const onToggle = useCallback( (uuid: string) => { @@ -365,7 +463,20 @@ export function useBatchUpdate(): BatchUpdateViewProps { markOpening(uuid, true); void (async () => { try { - if (!(await requestOpenUpdatePageByUUID(uuid))) notify.error(t("install:updatepage.open_failed")); + const res = await requestOpenUpdatePageByUUID(uuid); + if (res === "silent") { + // 服务端判定可以静默更新:不会开出安装页,页面上什么都不会发生, + // 必须自己把这一行结掉并给出反馈,否则用户只看到转了一圈 + commitRows((draft) => { + draft[uuid] = { phase: "success" }; + }); + scheduleRowExit(uuid); + notify.success( + t("install:updatepage.silent_updated", { version: itemsRef.current.get(uuid)?.newVersion ?? "" }) + ); + } else if (res !== "opened") { + notify.error(t("install:updatepage.open_failed")); + } } catch (e) { // 消息通道本身失败(Service Worker 未就绪等)也要落到同一条反馈上,不能让这行一直转圈 notify.error(`${t("install:updatepage.open_failed")}: ${e instanceof Error ? e.message : String(e)}`); @@ -374,7 +485,7 @@ export function useBatchUpdate(): BatchUpdateViewProps { } })(); }, - [cancelAutoClose, markOpening, t] + [cancelAutoClose, commitRows, markOpening, scheduleRowExit, t] ); const onOpenScriptList = useCallback(() => { @@ -386,8 +497,11 @@ export function useBatchUpdate(): BatchUpdateViewProps { ignored, totalChecked: records.length, checktime, - checking, + // 本地空窗期与服务端广播的检查中状态对页面是同一件事,合成一个对外的忙态 + checking: checking || pendingCheck, loading, + loadError, + batchBusy: batchProgress !== null && !batchProgress.finished, selected, autoClose: autoCloseState.seconds, autoCloseCancelled: autoCloseState.cancelled, @@ -404,6 +518,7 @@ export function useBatchUpdate(): BatchUpdateViewProps { onIgnoreSelected, onRestoreAll, onCheckNow, + onRetryLoad, onCancelAutoClose: cancelAutoClose, onOpen, onOpenScriptList, diff --git a/src/pages/batchupdate/logic.ts b/src/pages/batchupdate/logic.ts index b606bf949..06ccfe751 100644 --- a/src/pages/batchupdate/logic.ts +++ b/src/pages/batchupdate/logic.ts @@ -32,6 +32,8 @@ export type RowPhase = "queued" | "working" | "success" | "exiting" | "fail"; export interface RowState { phase: RowPhase; + /** 该行正在执行的动作;缺省即更新。忽略走同一套阶段,只是文案不同 */ + kind?: "update" | "ignore"; /** phase 为 fail 时的失败原因,可能为空串(服务端未给出可读信息) */ error?: string; } @@ -42,6 +44,8 @@ export interface BatchProgress { total: number; failed: number; finished: boolean; + /** 因检查结果失效而提前中止:已完成的条数仍然有效,需要如实汇报而不是抹掉 */ + interrupted?: boolean; } /** 行处于进行中(不可再次触发更新)的阶段 */ diff --git a/src/pages/batchupdate/mobile.tsx b/src/pages/batchupdate/mobile.tsx index 8acb641ff..6db282d8b 100644 --- a/src/pages/batchupdate/mobile.tsx +++ b/src/pages/batchupdate/mobile.tsx @@ -13,6 +13,7 @@ import { BatchSummary, ConnectBadge, EmptyState, + LoadErrorScreen, RecordExpiredNotice, RestoreAllAction, RiskBadge, @@ -21,6 +22,7 @@ import { rowPhaseClass, ScriptAvatar, ScriptName, + showSkeleton, SkeletonBar, SourceCell, StatusBadge, @@ -29,10 +31,17 @@ import { type BatchUpdateViewProps, } from "./components"; -/** 移动端检查中的骨架卡片:取代冻结的空状态/大转圈 */ +/** 移动端检查中的骨架卡片:三行对齐真实卡片(名称行 / 版本行 / 来源+操作行),取代冻结的空状态/大转圈 */ function SkeletonCards() { + const { t } = useTranslation(); return ( -
+
{Array.from({ length: 3 }).map((_, i) => (
@@ -46,12 +55,40 @@ function SkeletonCards() {
+
+ +
+ + +
))}
); } +/** + * 顶部选择栏与底部操作栏的骨架占位。 + * 这两条都在滚动区之外,真实数据到达时会同时从上下挤压列表,只给列表画骨架挡不住这次跳动。 + */ +function SkeletonSelectBar() { + return ( +
+ + +
+ ); +} + +function SkeletonActionBar() { + return ( +
+ + +
+ ); +} + /** 移动端单卡(待更新或已忽略) */ function MobileCard({ item, @@ -93,7 +130,7 @@ function MobileCard({ )} - onOpen(item.uuid)} /> + onOpen(item.uuid)} />
@@ -190,6 +227,7 @@ export function MobileView({ view }: { view: BatchUpdateViewProps }) { const selectedCount = view.updates.filter((u) => view.selected.has(u.uuid)).length; const allSelected = view.updates.length > 0 && selectedCount === view.updates.length; const empty = view.updates.length === 0 && view.ignored.length === 0; + const skeleton = view.loadError === null && showSkeleton(view, empty); const subtitle = view.checking ? t("install:updatepage.status_checking_updates") @@ -232,15 +270,17 @@ export function MobileView({ view }: { view: BatchUpdateViewProps }) { {view.checking && } - {view.recordExpired && } + {view.recordExpired && } {view.batchProgress && ( )} - {!empty && view.updates.length > 0 && ( + {skeleton && } + + {!skeleton && !empty && view.updates.length > 0 && (
- + {t("install:updatepage.selected_count", { selected: selectedCount, total: view.updates.length })} @@ -262,10 +302,12 @@ export function MobileView({ view }: { view: BatchUpdateViewProps }) { )}
- {view.loading || (view.checking && empty) ? ( + {view.loadError !== null ? ( + + ) : skeleton ? ( ) : empty ? ( - + ) : (
{view.updates.map((item) => ( @@ -286,19 +328,26 @@ export function MobileView({ view }: { view: BatchUpdateViewProps }) { )}
- {!empty && view.updates.length > 0 && ( + {skeleton && } + + {!skeleton && !empty && view.updates.length > 0 && (
-