diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 78e886aca..ceb1ea124 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -273,7 +273,7 @@ export class ScriptClient extends Client { } async openUpdatePageByUUID(uuid: string) { - return this.do("openUpdatePageByUUID", uuid); + return this.do("openUpdatePageByUUID", uuid); } async openBatchUpdatePage(opts: TOpenBatchUpdatePageOption) { diff --git a/src/app/service/service_worker/script.test.ts b/src/app/service/service_worker/script.test.ts index cb9574649..317c3716a 100644 --- a/src/app/service/service_worker/script.test.ts +++ b/src/app/service/service_worker/script.test.ts @@ -23,6 +23,24 @@ import { ScriptClient } from "./client"; import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; import { BatchUpdateListActionCode } from "./types"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; +import type * as ScriptUtils from "@App/pkg/utils/script"; +import type * as Utils from "@App/pkg/utils/utils"; + +// 打开更新详情页会真的发网络请求并开标签页;这两处替换成可断言的桩 +const h = vi.hoisted(() => ({ + fetchScriptBody: vi.fn(), + openInCurrentTab: vi.fn(), +})); + +vi.mock("@App/pkg/utils/script", async (importOriginal) => ({ + ...(await importOriginal()), + fetchScriptBody: h.fetchScriptBody, +})); + +vi.mock("@App/pkg/utils/utils", async (importOriginal) => ({ + ...(await importOriginal()), + openInCurrentTab: h.openInCurrentTab, +})); initTestEnv(); @@ -1434,3 +1452,94 @@ describe("ScriptService._checkScriptUpdate —— 检查期间脚本被更新", expect(service["scriptUpdateCheck"].cacheFull?.list?.find((e) => e.uuid === "u-stable")?.checkUpdate).toBe(true); }); }); + +describe("ScriptService.openUpdatePageByUUID —— 打开单条更新详情", () => { + const URL = "https://example.test/open.user.js"; + const userscript = (version: string) => + [ + "// ==UserScript==", + "// @name 更新详情目标", + "// @namespace scriptcat-test", + `// @version ${version}`, + "// ==/UserScript==", + "console.log(1);", + ].join("\n"); + + const saveTarget = async (service: ScriptService, scriptDAO: ScriptDAO) => { + await scriptDAO.save( + makeScript({ + uuid: "u-open", + name: "更新详情目标", + namespace: "scriptcat-test", + metadata: { name: ["更新详情目标"], namespace: ["scriptcat-test"], version: ["1.0.0"] }, + downloadUrl: URL, + checkUpdateUrl: URL, + checkUpdate: true, + }) + ); + await service.scriptCodeDAO.save({ uuid: "u-open", code: userscript("1.0.0") }); + }; + + const primeCache = (service: ScriptService, newCode: string) => + service["scriptUpdateCheck"].setCacheFull({ + checktime: Date.now(), + list: [ + { + uuid: "u-open", + checkUpdate: true, + oldCode: userscript("1.0.0"), + newCode, + newMeta: { version: ["2.0.0"], connect: [] }, + script: makeScript({ uuid: "u-open", name: "更新详情目标", namespace: "scriptcat-test" }), + codeSimilarity: 0.9, + sites: [], + withNewConnect: false, + }, + ], + }); + + beforeEach(() => { + h.fetchScriptBody.mockReset(); + h.openInCurrentTab.mockReset(); + }); + + it("检查记录里已带新版代码时直接打开安装页,不再重新拉取脚本", async () => { + const { service, scriptDAO } = buildService(); + await saveTarget(service, scriptDAO); + primeCache(service, userscript("2.0.0")); + + await expect(service.openUpdatePageByUUID("u-open")).resolves.toBe(true); + + expect(h.fetchScriptBody).not.toHaveBeenCalled(); + expect(h.openInCurrentTab).toHaveBeenCalledWith("/src/install.html?uuid=u-open"); + }); + + it("检查记录已失效时回退到网络拉取并照常打开安装页", async () => { + const { service, scriptDAO } = buildService(); + await saveTarget(service, scriptDAO); + h.fetchScriptBody.mockResolvedValue(userscript("2.0.0")); + + await expect(service.openUpdatePageByUUID("u-open")).resolves.toBe(true); + + expect(h.fetchScriptBody).toHaveBeenCalledWith(URL); + expect(h.openInCurrentTab).toHaveBeenCalledWith("/src/install.html?uuid=u-open"); + }); + + it("拉取失败时回报 false,让更新页能给出失败反馈而不是一直转圈", 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); + + expect(h.openInCurrentTab).not.toHaveBeenCalled(); + }); + + it("脚本已不存在时回报 false 而不是静默无反应", async () => { + const { service } = buildService(); + + await expect(service.openUpdatePageByUUID("missing")).resolves.toBe(false); + + expect(h.openInCurrentTab).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 89c90a162..969b7a63a 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -1168,9 +1168,20 @@ export class ScriptService { options: { source: InstallSource; byWebRequest?: boolean }, update: boolean, logger?: Logger + ) { + return this.prepareUpdateOrInstallPage(uuid, await fetchScriptBody(url), url, options, update, logger); + } + + /** 已经拿到脚本代码之后的处理:静默更新判定 + 写入待安装的临时代码,由调用方决定是否打开安装页 */ + async prepareUpdateOrInstallPage( + uuid: string, + code: string, + url: string, + options: { source: InstallSource; byWebRequest?: boolean }, + update: boolean, + logger?: Logger ) { const upsertBy = options.source; - const code = await fetchScriptBody(url); if (update) { try { const { oldScript, script } = await prepareScriptByCode(code, url, uuid); @@ -1221,8 +1232,11 @@ export class ScriptService { return 1; } - // 打开更新窗口 - public async openUpdatePage(script: Script, source: "user" | "system") { + /** + * 打开更新窗口。cachedNewCode 是检查阶段已经拉到的新版代码,命中时直接复用。 + * @returns 是否已处理(打开了安装页或完成了静默更新) + */ + public async openUpdatePage(script: Script, source: "user" | "system", cachedNewCode?: string) { const { uuid, name, downloadUrl, checkUpdateUrl } = script; const logger = this.logger.with({ uuid, @@ -1232,12 +1246,16 @@ export class ScriptService { }); const url = downloadUrl || checkUpdateUrl!; try { - const ret = await this.openUpdateOrInstallPage(uuid, url, { source }, true, logger); - if (ret === 2) return; // slience update + 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 // 打开安装页面 openInCurrentTab(`/src/install.html?uuid=${uuid}`); + return true; } catch (e) { logger.error("fetch script info failed", Logger.E(e)); + return false; } } @@ -1766,12 +1784,12 @@ export class ScriptService { async openUpdatePageByUUID(uuid: string) { const source = "user"; // TBC - const oldScript = await this.scriptDAO.get(uuid); - if (!oldScript || oldScript.uuid !== uuid) return; - const { name, downloadUrl, checkUpdateUrl } = oldScript; - //@ts-ignore - const script = { uuid, name, downloadUrl, checkUpdateUrl } as Script; - await this.openUpdatePage(script, source); + const script = await this.scriptDAO.get(uuid); + if (!script || script.uuid !== uuid) return false; + // 检查记录里已经带着这次要装的新版代码:复用它既省掉一次让用户干等的网络往返, + // 也保证打开的正是列表上展示的那一版 + const cachedNewCode = this.scriptUpdateCheck.cacheFull?.list?.find((entry) => entry.uuid === uuid)?.newCode; + return await this.openUpdatePage(script, source, cachedNewCode || undefined); } init() { diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index f46724241..be1cc2364 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -128,7 +128,8 @@ "batch_done": "{{count}} Skripte aktualisiert", "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." + "record_expired": "Die Update-Daten sind abgelaufen. Bitte erneut nach Updates suchen.", + "open_failed": "Update-Details konnten nicht geöffnet werden. Bitte erneut versuchen." }, "importpage": { "title": "Datenimport", diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index 616d754b9..d779420c1 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -128,7 +128,8 @@ "batch_done": "Updated {{count}} scripts", "batch_done_partial": "Updated {{updated}}, {{failed}} failed", "view_updated_scripts": "View updated scripts", - "record_expired": "Update data has expired. Please check for updates again." + "record_expired": "Update data has expired. Please check for updates again.", + "open_failed": "Failed to open update details. Please try again." }, "importpage": { "title": "Data Import", diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index 9f8d43cd9..13c91cc4c 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -128,7 +128,8 @@ "batch_done": "{{count}} 件を更新しました", "batch_done_partial": "{{updated}} 件を更新、{{failed}} 件が失敗", "view_updated_scripts": "更新したスクリプトを表示", - "record_expired": "更新データの有効期限が切れました。もう一度更新を確認してください" + "record_expired": "更新データの有効期限が切れました。もう一度更新を確認してください", + "open_failed": "更新の詳細を開けませんでした。もう一度お試しください" }, "importpage": { "title": "データインポート", diff --git a/src/locales/ko-KR/install.json b/src/locales/ko-KR/install.json index 29a94e7d4..f19aa40ae 100644 --- a/src/locales/ko-KR/install.json +++ b/src/locales/ko-KR/install.json @@ -128,7 +128,8 @@ "batch_done": "{{count}}개를 업데이트했습니다", "batch_done_partial": "{{updated}}개 업데이트, {{failed}}개 실패", "view_updated_scripts": "업데이트된 스크립트 보기", - "record_expired": "업데이트 데이터가 만료되었습니다. 업데이트를 다시 확인하세요" + "record_expired": "업데이트 데이터가 만료되었습니다. 업데이트를 다시 확인하세요", + "open_failed": "업데이트 상세 정보를 열지 못했습니다. 다시 시도하세요" }, "importpage": { "title": "데이터 가져오기", diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index c8f1f3ccc..28d3f2bfe 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -128,7 +128,8 @@ "batch_done": "{{count}} scripts atualizados", "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." + "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." }, "importpage": { "title": "Importação de dados", diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index a2c0c9df1..c8cf727a4 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -128,7 +128,8 @@ "batch_done": "Обновлено скриптов: {{count}}", "batch_done_partial": "Обновлено: {{updated}}, с ошибкой: {{failed}}", "view_updated_scripts": "Показать обновлённые скрипты", - "record_expired": "Данные об обновлениях устарели. Проверьте обновления заново." + "record_expired": "Данные об обновлениях устарели. Проверьте обновления заново.", + "open_failed": "Не удалось открыть сведения об обновлении. Попробуйте ещё раз." }, "importpage": { "title": "Импорт данных", diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index 1e85ef0d8..a660c0a26 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -128,7 +128,8 @@ "batch_done": "{{count}} betik güncellendi", "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." + "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." }, "importpage": { "title": "Veri İçe Aktarma", diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index 1fb53a00a..2118327dc 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -128,7 +128,8 @@ "batch_done": "Đã cập nhật {{count}} tập lệnh", "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." + "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." }, "importpage": { "title": "Nhập dữ liệu", diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index f9198e378..f3fbc1631 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -128,7 +128,8 @@ "batch_done": "已更新 {{count}} 个", "batch_done_partial": "已更新 {{updated}} 个,{{failed}} 个失败", "view_updated_scripts": "查看更新的脚本", - "record_expired": "更新数据已过期,请重新检查更新" + "record_expired": "更新数据已过期,请重新检查更新", + "open_failed": "打开更新详情失败,请重试" }, "importpage": { "title": "数据导入", diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index c6d6684db..6c1a080e0 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -128,7 +128,8 @@ "batch_done": "已更新 {{count}} 個", "batch_done_partial": "已更新 {{updated}} 個,{{failed}} 個失敗", "view_updated_scripts": "檢視已更新的腳本", - "record_expired": "更新資料已過期,請重新檢查更新" + "record_expired": "更新資料已過期,請重新檢查更新", + "open_failed": "開啟更新詳情失敗,請重試" }, "importpage": { "title": "資料匯入", diff --git a/src/pages/batchupdate/components.test.tsx b/src/pages/batchupdate/components.test.tsx index b683b09e1..f723aae98 100644 --- a/src/pages/batchupdate/components.test.tsx +++ b/src/pages/batchupdate/components.test.tsx @@ -39,6 +39,7 @@ function mkView(p: Partial = {}): BatchUpdateViewProps { autoClose: null, autoCloseCancelled: false, rowStates: {}, + opening: new Set(), batchProgress: null, recordExpired: false, onToggle: () => {}, @@ -358,3 +359,32 @@ describe("批量更新 全部恢复并更新的确认", () => { expect(screen.getByTestId("ignored-restore-all")).toHaveTextContent(t("install:updatepage.restore_all")); }); }); + +describe("批量更新 打开更新详情进行中", () => { + it("桌面行在打开期间转圈并拒绝再次点击", () => { + const onOpen = vi.fn(); + renderDesktop({ updates: [mkItem()], opening: new Set(["u1"]), onOpen }); + + const name = screen.getByTestId("script-name"); + expect(name).toHaveAttribute("aria-busy", "true"); + expect(name.querySelector(".animate-spin")).toBeTruthy(); + + fireEvent.click(name); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("未在打开时点击脚本名进入更新详情", () => { + const onOpen = vi.fn(); + renderDesktop({ updates: [mkItem()], opening: new Set(), onOpen }); + + fireEvent.click(screen.getByTestId("script-name")); + + expect(onOpen).toHaveBeenCalledWith("u1"); + }); + + it("移动卡片在打开期间同样标记为进行中", () => { + renderMobile({ updates: [mkItem()], opening: new Set(["u1"]) }); + + expect(screen.getByTestId("script-name")).toHaveAttribute("aria-busy", "true"); + }); +}); diff --git a/src/pages/batchupdate/components.tsx b/src/pages/batchupdate/components.tsx index 2c9c39bbb..debbd9797 100644 --- a/src/pages/batchupdate/components.tsx +++ b/src/pages/batchupdate/components.tsx @@ -45,6 +45,8 @@ export interface BatchUpdateViewProps { autoCloseCancelled: boolean; /** 按 uuid 索引的行内更新状态;不在表内即为初始态 */ rowStates: Record; + /** 正在打开更新详情页的脚本 uuid:服务端备代码期间行内转圈并挡住重复点击 */ + opening: Set; /** 批量操作进度;为 null 表示当前没有批量操作 */ batchProgress: BatchProgress | null; /** 服务端检查结果已失效,需重新检查更新 */ @@ -168,16 +170,20 @@ export function SourceCell({ source }: { source: string }) { ); } -/** 可点击跳转更新详情页的脚本名(过长时 tooltip 显示全名) */ -export function ScriptName({ name, onClick }: { name: string; onClick: () => void }) { +/** 可点击跳转更新详情页的脚本名(过长时 tooltip 显示全名);loading 期间转圈并拒绝再次点击 */ +export function ScriptName({ name, loading, onClick }: { name: string; loading?: boolean; onClick: () => void }) { return ( ); @@ -408,6 +414,7 @@ function DesktopRow({ item, state, selected, + opening, onToggle, onOpen, onUpdate, @@ -418,6 +425,7 @@ function DesktopRow({ item: UpdateItem; state?: RowState; selected?: boolean; + opening?: boolean; onToggle?: (uuid: string) => void; onOpen: (uuid: string) => void; onUpdate?: (item: UpdateItem) => void; @@ -444,7 +452,7 @@ function DesktopRow({
- onOpen(item.uuid)} /> + onOpen(item.uuid)} />
@@ -493,6 +501,7 @@ function DesktopTable({ view }: { view: BatchUpdateViewProps }) { item={item} state={view.rowStates[item.uuid]} selected={view.selected.has(item.uuid)} + opening={view.opening.has(item.uuid)} onToggle={view.onToggle} onOpen={view.onOpen} onUpdate={view.onUpdate} @@ -525,6 +534,7 @@ function DesktopIgnored({ view }: { view: BatchUpdateViewProps }) { item={item} state={view.rowStates[item.uuid]} ignoredRow + opening={view.opening.has(item.uuid)} onOpen={view.onOpen} onRestore={view.onRestore} /> diff --git a/src/pages/batchupdate/hooks.test.ts b/src/pages/batchupdate/hooks.test.ts index a5b0fac3b..0c9258b45 100644 --- a/src/pages/batchupdate/hooks.test.ts +++ b/src/pages/batchupdate/hooks.test.ts @@ -19,9 +19,10 @@ const h = vi.hoisted(() => ({ sendUpdatePageOpened: vi.fn(() => Promise.resolve()), requestCheckScriptUpdate: vi.fn(() => Promise.resolve()), requestBatchUpdateListAction: vi.fn((): Promise => Promise.resolve(undefined)), - requestOpenUpdatePageByUUID: vi.fn(() => Promise.resolve()), + requestOpenUpdatePageByUUID: vi.fn(() => Promise.resolve(true)), toastSuccess: vi.fn(), toastWarning: vi.fn(), + toastError: vi.fn(), openInCurrentTab: vi.fn(() => Promise.resolve()), })); @@ -50,7 +51,7 @@ vi.mock("@App/pages/store/global", () => ({ vi.mock("@App/pages/components/ui/toast", () => ({ notify: { success: h.toastSuccess, - error: vi.fn(), + error: h.toastError, info: vi.fn(), warning: h.toastWarning, loading: vi.fn(), @@ -373,3 +374,59 @@ describe("批量更新 Hook useBatchUpdate 查看更新的脚本", () => { expect(h.openInCurrentTab).toHaveBeenCalledWith("/src/options.html#/"); }); }); + +describe("批量更新 Hook useBatchUpdate 打开更新详情", () => { + it("打开过程中标记该行为进行中,重复点击不再重复发起", async () => { + let resolveOpen!: (value: boolean) => void; + h.requestOpenUpdatePageByUUID.mockImplementationOnce( + () => new Promise((resolve) => (resolveOpen = resolve)) + ); + const { result } = await setup([mkRecord("a")]); + + await act(async () => { + void result.current.onOpen("a"); + }); + expect(result.current.opening.has("a")).toBe(true); + + await act(async () => { + void result.current.onOpen("a"); + }); + expect(h.requestOpenUpdatePageByUUID).toHaveBeenCalledTimes(1); + + await act(async () => resolveOpen(true)); + + expect(result.current.opening.has("a")).toBe(false); + }); + + it("打开失败时提示用户并解除进行中标记", async () => { + h.requestOpenUpdatePageByUUID.mockResolvedValueOnce(false); + const { result } = await setup([mkRecord("a")]); + + await act(async () => result.current.onOpen("a")); + + expect(h.toastError).toHaveBeenCalledWith(t("install:updatepage.open_failed")); + expect(result.current.opening.has("a")).toBe(false); + }); + + it("请求本身报错时同样给出失败反馈而不是一直转圈", async () => { + h.requestOpenUpdatePageByUUID.mockRejectedValueOnce(new Error("boom")); + const { result } = await setup([mkRecord("a")]); + + await act(async () => result.current.onOpen("a")); + + expect(h.toastError).toHaveBeenCalledWith(expect.stringContaining(t("install:updatepage.open_failed"))); + expect(h.toastError.mock.calls[0][0]).toContain("boom"); + expect(result.current.opening.has("a")).toBe(false); + }); + + it("点开更新详情算显式操作,停掉自动关闭倒计时", async () => { + window.history.replaceState({}, "", "/?autoclose=30"); + const { result } = await setup([mkRecord("a")]); + + await act(async () => result.current.onOpen("a")); + + expect(result.current.autoClose).toBeNull(); + expect(result.current.autoCloseCancelled).toBe(true); + window.history.replaceState({}, "", "/"); + }); +}); diff --git a/src/pages/batchupdate/hooks.ts b/src/pages/batchupdate/hooks.ts index b2c986f23..3ae1bacdd 100644 --- a/src/pages/batchupdate/hooks.ts +++ b/src/pages/batchupdate/hooks.ts @@ -66,6 +66,9 @@ export function useBatchUpdate(): BatchUpdateViewProps { const [recordExpired, setRecordExpired] = useState(false); // 已播完退场动画、等待下一次全量刷新兜底的行 const [dismissed, setDismissed] = useState>(() => new Set()); + // 正在打开更新详情页的行;ref 与 state 同步,前者用于同步挡住连点,后者驱动行内转圈 + const [opening, setOpening] = useState>(() => new Set()); + const openingRef = useRef>(new Set()); const loadingRef = useRef(false); // 标记本次检查由用户主动发起(点击「检查更新」),用于在检查完成后弹出反馈 toast @@ -128,6 +131,12 @@ export function useBatchUpdate(): BatchUpdateViewProps { applyReload(deferred.finished); }, [applyReload]); + const markOpening = useCallback((uuid: string, busy: boolean) => { + if (busy) openingRef.current.add(uuid); + else openingRef.current.delete(uuid); + setOpening(new Set(openingRef.current)); + }, []); + const commitRows = useCallback((mutate: (draft: Record) => void) => { const next = { ...rowStatesRef.current }; mutate(next); @@ -345,9 +354,28 @@ export function useBatchUpdate(): BatchUpdateViewProps { }); }, [updates, cancelAutoClose]); - const onOpen = useCallback((uuid: string) => { - void requestOpenUpdatePageByUUID(uuid); - }, []); + /** + * 打开更新详情:服务端要先备好待安装代码才会开出安装页,这段等待期间必须挡住重复点击, + * 否则连点几下就会开出好几个安装标签页。 + */ + const onOpen = useCallback( + (uuid: string) => { + if (openingRef.current.has(uuid)) return; + cancelAutoClose(); + markOpening(uuid, true); + void (async () => { + try { + if (!(await requestOpenUpdatePageByUUID(uuid))) 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)}`); + } finally { + markOpening(uuid, false); + } + })(); + }, + [cancelAutoClose, markOpening, t] + ); const onOpenScriptList = useCallback(() => { void openInCurrentTab(SCRIPT_LIST_URL); @@ -364,6 +392,7 @@ export function useBatchUpdate(): BatchUpdateViewProps { autoClose: autoCloseState.seconds, autoCloseCancelled: autoCloseState.cancelled, rowStates, + opening, batchProgress, recordExpired, onToggle, diff --git a/src/pages/batchupdate/mobile.tsx b/src/pages/batchupdate/mobile.tsx index 4eafad617..8acb641ff 100644 --- a/src/pages/batchupdate/mobile.tsx +++ b/src/pages/batchupdate/mobile.tsx @@ -57,6 +57,7 @@ function MobileCard({ item, state, selected, + opening, onToggle, onOpen, onUpdate, @@ -67,6 +68,7 @@ function MobileCard({ item: UpdateItem; state?: RowState; selected?: boolean; + opening?: boolean; onToggle?: (uuid: string) => void; onOpen: (uuid: string) => void; onUpdate?: (item: UpdateItem) => void; @@ -91,7 +93,7 @@ function MobileCard({ )} - onOpen(item.uuid)} /> + onOpen(item.uuid)} />
@@ -172,6 +174,7 @@ function MobileIgnored({ view }: { view: BatchUpdateViewProps }) { item={item} state={view.rowStates[item.uuid]} ignoredCard + opening={view.opening.has(item.uuid)} onOpen={view.onOpen} onRestore={view.onRestore} /> @@ -271,6 +274,7 @@ export function MobileView({ view }: { view: BatchUpdateViewProps }) { item={item} state={view.rowStates[item.uuid]} selected={view.selected.has(item.uuid)} + opening={view.opening.has(item.uuid)} onToggle={view.onToggle} onOpen={view.onOpen} onUpdate={view.onUpdate}