From e71715fc5b30e15ddc9eef2cdc7b44fd4d6bca55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 6 Apr 2026 00:14:18 +0800 Subject: [PATCH 01/97] =?UTF-8?q?=F0=9F=94=A5=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E6=97=A7=20UI=20=E4=BB=A3=E7=A0=81=E4=BB=A5=E5=87=86=E5=A4=87?= =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除基于 Arco Design + UnoCSS 的旧 UI 层与相关依赖,为下一版 基于 Tailwind CSS + shadcn/ui 的 UI 重构做准备。 - 删除 6 个页面(options/popup/install/confirm/import/batchupdate)、所有 UI 组件、HTML 模板和 CSS - 卸载 16 个 UI 相关依赖(@arco-design/web-react、unocss、@dnd-kit/*、react-dropzone、react-joyride、react-router-dom、react-icons、react-i18next、@playwright/test 等) - 删除 e2e 测试目录与 Playwright 配置 - 更新 rspack.config.ts 移除 UI entry 和 HtmlRspackPlugin - 更新技术栈文档(CLAUDE.md、copilot-instructions、CONTRIBUTING) 保留:非 UI 入口(service_worker/content/inject/offscreen/sandbox)、 monaco-editor、i18next、pages/store/{global,favicons,features/script}.ts --- .github/copilot-instructions.md | 4 +- CONTRIBUTING.md | 4 +- docs/CONTRIBUTING_EN.md | 4 +- docs/CONTRIBUTING_RU.md | 4 +- e2e/fixtures.ts | 39 - e2e/gm-api.spec.ts | 216 --- e2e/install.spec.ts | 29 - e2e/options.spec.ts | 102 -- e2e/popup.spec.ts | 73 - e2e/script-editor.spec.ts | 69 - e2e/script-management.spec.ts | 117 -- e2e/settings.spec.ts | 40 - e2e/utils.ts | 87 -- e2e/vscode-connect.spec.ts | 224 --- package.json | 22 +- playwright.config.ts | 21 - pnpm-lock.yaml | 1357 +---------------- postcss.config.mjs | 6 - rspack.config.ts | 76 - src/index.css | 37 - src/locales/arco.ts | 32 - src/locales/locales.ts | 3 +- src/pages/batchupdate/App.tsx | 539 ------- src/pages/batchupdate/index.css | 19 - src/pages/batchupdate/main.tsx | 32 - .../components/CloudScriptPlan/index.tsx | 207 --- src/pages/components/CodeEditor/index.tsx | 294 ---- src/pages/components/CustomLink/index.tsx | 39 - src/pages/components/CustomTrans/index.tsx | 46 - .../components/FileSystemParams/index.tsx | 179 --- src/pages/components/LogLabel/index.css | 26 - src/pages/components/LogLabel/index.tsx | 80 - src/pages/components/PopupWarnings/index.tsx | 182 --- src/pages/components/RuntimeSetting/index.tsx | 186 --- src/pages/components/ScriptMenuList/index.tsx | 523 ------- src/pages/components/ScriptResource/index.tsx | 168 -- src/pages/components/ScriptSetting/Match.tsx | 313 ---- .../components/ScriptSetting/Permission.tsx | 198 --- src/pages/components/ScriptSetting/index.tsx | 227 --- src/pages/components/ScriptStorage/index.tsx | 324 ---- .../components/UserConfigPanel/index.tsx | 212 --- src/pages/components/layout/MainLayout.tsx | 513 ------- src/pages/components/layout/PopupLayout.tsx | 18 - src/pages/components/layout/Sider.tsx | 216 --- src/pages/components/layout/SiderGuide.tsx | 178 --- src/pages/components/layout/index.css | 26 - src/pages/confirm/App.tsx | 165 -- src/pages/confirm/main.tsx | 31 - src/pages/import/App.tsx | 334 ---- src/pages/import/index.css | 3 - src/pages/import/main.tsx | 31 - src/pages/install/App.tsx | 1001 ------------ src/pages/install/index.css | 108 -- src/pages/install/main.tsx | 43 - src/pages/options.html | 22 - src/pages/options/index.css | 111 -- src/pages/options/main.tsx | 38 - src/pages/options/routes/Logger.tsx | 377 ----- .../options/routes/ScriptList/ScriptCard.tsx | 571 ------- .../options/routes/ScriptList/ScriptTable.tsx | 1095 ------------- .../options/routes/ScriptList/SearchFilter.ts | 49 - .../options/routes/ScriptList/Sidebar.tsx | 226 --- .../options/routes/ScriptList/components.tsx | 245 --- src/pages/options/routes/ScriptList/hooks.tsx | 328 ---- src/pages/options/routes/ScriptList/index.tsx | 302 ---- src/pages/options/routes/Setting.tsx | 577 ------- src/pages/options/routes/SubscribeList.tsx | 318 ---- src/pages/options/routes/Tools.tsx | 358 ----- .../options/routes/script/ScriptEditor.tsx | 1292 ---------------- src/pages/options/routes/script/index.css | 29 - src/pages/options/routes/utils.tsx | 251 --- src/pages/popup.html | 26 - src/pages/popup/App.tsx | 591 ------- src/pages/popup/index.css | 19 - src/pages/popup/main.tsx | 32 - src/pages/store/AppContext.tsx | 109 -- src/pages/template.html | 25 - .../pages/components/ScriptMenuList.test.tsx | 84 - tests/pages/options/MainLayout.test.tsx | 168 -- tests/pages/popup/App.test.tsx | 207 --- tests/test-utils.tsx | 46 - uno.config.ts | 13 - 82 files changed, 32 insertions(+), 16504 deletions(-) delete mode 100644 e2e/fixtures.ts delete mode 100644 e2e/gm-api.spec.ts delete mode 100644 e2e/install.spec.ts delete mode 100644 e2e/options.spec.ts delete mode 100644 e2e/popup.spec.ts delete mode 100644 e2e/script-editor.spec.ts delete mode 100644 e2e/script-management.spec.ts delete mode 100644 e2e/settings.spec.ts delete mode 100644 e2e/utils.ts delete mode 100644 e2e/vscode-connect.spec.ts delete mode 100644 playwright.config.ts delete mode 100644 postcss.config.mjs delete mode 100644 src/index.css delete mode 100644 src/locales/arco.ts delete mode 100644 src/pages/batchupdate/App.tsx delete mode 100644 src/pages/batchupdate/index.css delete mode 100644 src/pages/batchupdate/main.tsx delete mode 100644 src/pages/components/CloudScriptPlan/index.tsx delete mode 100644 src/pages/components/CodeEditor/index.tsx delete mode 100644 src/pages/components/CustomLink/index.tsx delete mode 100644 src/pages/components/CustomTrans/index.tsx delete mode 100644 src/pages/components/FileSystemParams/index.tsx delete mode 100644 src/pages/components/LogLabel/index.css delete mode 100644 src/pages/components/LogLabel/index.tsx delete mode 100644 src/pages/components/PopupWarnings/index.tsx delete mode 100644 src/pages/components/RuntimeSetting/index.tsx delete mode 100644 src/pages/components/ScriptMenuList/index.tsx delete mode 100644 src/pages/components/ScriptResource/index.tsx delete mode 100644 src/pages/components/ScriptSetting/Match.tsx delete mode 100644 src/pages/components/ScriptSetting/Permission.tsx delete mode 100644 src/pages/components/ScriptSetting/index.tsx delete mode 100644 src/pages/components/ScriptStorage/index.tsx delete mode 100644 src/pages/components/UserConfigPanel/index.tsx delete mode 100644 src/pages/components/layout/MainLayout.tsx delete mode 100644 src/pages/components/layout/PopupLayout.tsx delete mode 100644 src/pages/components/layout/Sider.tsx delete mode 100644 src/pages/components/layout/SiderGuide.tsx delete mode 100644 src/pages/components/layout/index.css delete mode 100644 src/pages/confirm/App.tsx delete mode 100644 src/pages/confirm/main.tsx delete mode 100644 src/pages/import/App.tsx delete mode 100644 src/pages/import/index.css delete mode 100644 src/pages/import/main.tsx delete mode 100644 src/pages/install/App.tsx delete mode 100644 src/pages/install/index.css delete mode 100644 src/pages/install/main.tsx delete mode 100644 src/pages/options.html delete mode 100644 src/pages/options/index.css delete mode 100644 src/pages/options/main.tsx delete mode 100644 src/pages/options/routes/Logger.tsx delete mode 100644 src/pages/options/routes/ScriptList/ScriptCard.tsx delete mode 100644 src/pages/options/routes/ScriptList/ScriptTable.tsx delete mode 100644 src/pages/options/routes/ScriptList/SearchFilter.ts delete mode 100644 src/pages/options/routes/ScriptList/Sidebar.tsx delete mode 100644 src/pages/options/routes/ScriptList/components.tsx delete mode 100644 src/pages/options/routes/ScriptList/hooks.tsx delete mode 100644 src/pages/options/routes/ScriptList/index.tsx delete mode 100644 src/pages/options/routes/Setting.tsx delete mode 100644 src/pages/options/routes/SubscribeList.tsx delete mode 100644 src/pages/options/routes/Tools.tsx delete mode 100644 src/pages/options/routes/script/ScriptEditor.tsx delete mode 100644 src/pages/options/routes/script/index.css delete mode 100644 src/pages/options/routes/utils.tsx delete mode 100644 src/pages/popup.html delete mode 100644 src/pages/popup/App.tsx delete mode 100644 src/pages/popup/index.css delete mode 100644 src/pages/popup/main.tsx delete mode 100644 src/pages/store/AppContext.tsx delete mode 100644 src/pages/template.html delete mode 100644 tests/pages/components/ScriptMenuList.test.tsx delete mode 100644 tests/pages/options/MainLayout.test.tsx delete mode 100644 tests/pages/popup/App.test.tsx delete mode 100644 tests/test-utils.tsx delete mode 100644 uno.config.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1bebf61b1..1f84c4fd0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -72,8 +72,8 @@ User scripts are compiled with sandbox context isolation: ## Technology Stack - **React 18** - Component framework with automatic runtime -- **Arco Design** - UI component library (`@arco-design/web-react`) -- **UnoCSS** - Atomic CSS framework for styling +- **shadcn/ui** - UI component library built on Radix UI +- **Tailwind CSS** - Utility-first CSS framework for styling - **Rspack** - Fast bundler (Webpack alternative) with SWC - **TypeScript** - Type-safe development diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 869c389b5..22be9dad6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,8 +92,8 @@ pnpm run lint ScriptCat 的页面开发使用了以下技术: - [React](https://reactjs.org/) -- UI 框架 [arco](https://arco.design) -- CSS 框架 [unocss](https://unocss.dev/interactive/) +- UI 框架 [shadcn/ui](https://ui.shadcn.com/) +- CSS 框架 [Tailwind CSS](https://tailwindcss.com/) - RsPack 打包工具 [rspack](https://rspack.dev/) 如果你想在本地运行 ScriptCat,可以使用以下命令: diff --git a/docs/CONTRIBUTING_EN.md b/docs/CONTRIBUTING_EN.md index 385f3b439..fc4165400 100644 --- a/docs/CONTRIBUTING_EN.md +++ b/docs/CONTRIBUTING_EN.md @@ -83,8 +83,8 @@ pnpm run lint ScriptCat's page development uses the following technologies: - [React](https://reactjs.org/) -- UI framework [arco](https://arco.design) -- CSS framework [unocss](https://unocss.dev/interactive/) +- UI framework [shadcn/ui](https://ui.shadcn.com/) +- CSS framework [Tailwind CSS](https://tailwindcss.com/) - RsPack bundling tool [rspack](https://rspack.dev/) If you want to run ScriptCat locally, you can use the following commands: diff --git a/docs/CONTRIBUTING_RU.md b/docs/CONTRIBUTING_RU.md index bc8a54a20..7dc10aada 100644 --- a/docs/CONTRIBUTING_RU.md +++ b/docs/CONTRIBUTING_RU.md @@ -90,8 +90,8 @@ pnpm run lint Для разработки страниц ScriptCat используются следующие технологии: - [React](https://reactjs.org/) -- UI-фреймворк [arco](https://arco.design) -- CSS-фреймворк [unocss](https://unocss.dev/interactive/) +- UI-фреймворк [shadcn/ui](https://ui.shadcn.com/) +- CSS-фреймворк [Tailwind CSS](https://tailwindcss.com/) - Инструмент сборки RsPack [rspack](https://rspack.dev/) Если вы хотите запустить ScriptCat локально, вы можете использовать следующую команду: diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts deleted file mode 100644 index 7d613e7c1..000000000 --- a/e2e/fixtures.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { test as base, chromium, type BrowserContext } from "@playwright/test"; -import path from "path"; - -export const test = base.extend<{ - context: BrowserContext; - extensionId: string; -}>({ - // eslint-disable-next-line no-empty-pattern - context: async ({}, use) => { - const pathToExtension = path.resolve(__dirname, "../dist/ext"); - const context = await chromium.launchPersistentContext("", { - headless: false, - args: ["--headless=new", `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`], - }); - await use(context); - await context.close(); - }, - extensionId: async ({ context }, use) => { - let [background] = context.serviceWorkers(); - if (!background) { - background = await context.waitForEvent("serviceworker"); - } - const extensionId = background.url().split("/")[2]; - - // Dismiss the first-use guide by navigating to the options page and setting localStorage, - // then reload to apply the change before any tests run. - const initPage = await context.newPage(); - await initPage.goto(`chrome-extension://${extensionId}/src/options.html`); - await initPage.waitForLoadState("domcontentloaded"); - await initPage.evaluate(() => { - localStorage.setItem("firstUse", "false"); - }); - await initPage.close(); - - await use(extensionId); - }, -}); - -export const expect = test.expect; diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts deleted file mode 100644 index 5f9a55ff8..000000000 --- a/e2e/gm-api.spec.ts +++ /dev/null @@ -1,216 +0,0 @@ -import fs from "fs"; -import path from "path"; -import os from "os"; -import { test as base, expect, chromium, type BrowserContext } from "@playwright/test"; -import { installScriptByCode } from "./utils"; - -const test = base.extend<{ - context: BrowserContext; - extensionId: string; -}>({ - // eslint-disable-next-line no-empty-pattern - context: async ({}, use) => { - const pathToExtension = path.resolve(__dirname, "../dist/ext"); - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-")); - const chromeArgs = [`--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`]; - - // Phase 1: Enable user scripts permission - const ctx1 = await chromium.launchPersistentContext(userDataDir, { - headless: false, - args: ["--headless=new", ...chromeArgs], - }); - let [bg] = ctx1.serviceWorkers(); - if (!bg) bg = await ctx1.waitForEvent("serviceworker", { timeout: 30_000 }); - const extensionId = bg.url().split("/")[2]; - const extPage = await ctx1.newPage(); - await extPage.goto("chrome://extensions/"); - await extPage.waitForLoadState("domcontentloaded"); - // Wait for developerPrivate API to be available instead of a fixed delay - await extPage.waitForFunction(() => !!(chrome as any).developerPrivate, { timeout: 10_000 }); - await extPage.evaluate(async (id) => { - await (chrome as any).developerPrivate.updateExtensionConfiguration({ - extensionId: id, - userScriptsAccess: true, - }); - }, extensionId); - await extPage.close(); - await ctx1.close(); - - // Phase 2: Relaunch with user scripts enabled - const context = await chromium.launchPersistentContext(userDataDir, { - headless: false, - args: ["--headless=new", ...chromeArgs], - }); - // Ensure service worker is registered before handing context to fixtures, - // preventing extensionId fixture from timing out with the global 10s timeout. - const [sw] = context.serviceWorkers(); - if (!sw) await context.waitForEvent("serviceworker", { timeout: 30_000 }); - await use(context); - await context.close(); - fs.rmSync(userDataDir, { recursive: true, force: true }); - }, - extensionId: async ({ context }, use) => { - let [background] = context.serviceWorkers(); - if (!background) background = await context.waitForEvent("serviceworker"); - const extensionId = background.url().split("/")[2]; - const initPage = await context.newPage(); - await initPage.goto(`chrome-extension://${extensionId}/src/options.html`); - await initPage.waitForLoadState("domcontentloaded"); - await initPage.evaluate(() => localStorage.setItem("firstUse", "false")); - await initPage.close(); - await use(extensionId); - }, -}); - -/** Strip SRI hashes and replace slow CDN with faster alternative */ -function patchScriptCode(code: string): string { - return code - .replace(/^(\/\/\s*@(?:require|resource)\s+.*?)#sha(?:256|384|512)[=-][^\s]+/gm, "$1") - .replace(/https:\/\/cdn\.jsdelivr\.net\/npm\//g, "https://unpkg.com/"); -} - -/** - * Auto-approve permission confirm dialogs opened by the extension. - * Listens for new pages matching confirm.html and clicks the - * "permanent allow all" button (type=4, allow=true). - */ -function autoApprovePermissions(context: BrowserContext): void { - context.on("page", async (page) => { - const url = page.url(); - if (!url.includes("confirm.html")) return; - - try { - await page.waitForLoadState("domcontentloaded"); - // Click the "permanent allow" button (4th success button = type=5 permanent allow this) - // The buttons in order are: allow_once(1), temporary_allow(3), permanent_allow(5) - // We want "permanent_allow" which is the 3rd success button - const successButtons = page.locator("button.arco-btn-status-success"); - await successButtons.first().waitFor({ timeout: 5_000 }); - // Find and click the last always-visible success button (permanent_allow, type=5) - // Button order: allow_once(type=1), temporary_allow(type=3), permanent_allow(type=5) - // Index 2 = permanent_allow (always visible) - const count = await successButtons.count(); - if (count >= 3) { - // permanent_allow is at index 2 - await successButtons.nth(2).click(); - } else { - // Fallback: click the last visible success button - await successButtons.last().click(); - } - console.log("[autoApprove] Permission approved on confirm page"); - } catch (e) { - console.log("[autoApprove] Failed to approve:", e); - } - }); -} - -/** Run a test script on the target page and collect console results */ -async function runTestScript( - context: BrowserContext, - extensionId: string, - scriptFile: string, - targetUrl: string, - timeoutMs: number -): Promise<{ passed: number; failed: number; logs: string[] }> { - let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8"); - code = patchScriptCode(code); - - await installScriptByCode(context, extensionId, code); - - // Start auto-approving permission dialogs - autoApprovePermissions(context); - - const page = await context.newPage(); - const logs: string[] = []; - let passed = -1; - let failed = -1; - - // Resolve as soon as both pass and fail counts appear in console output - const resultReady = new Promise((resolve) => { - page.on("console", (msg) => { - const text = msg.text(); - logs.push(text); - const passMatch = text.match(/通过[::]\s*(\d+)/); - const failMatch = text.match(/失败[::]\s*(\d+)/); - if (passMatch) passed = parseInt(passMatch[1], 10); - if (failMatch) failed = parseInt(failMatch[1], 10); - if (passed >= 0 && failed >= 0) resolve(); - }); - }); - - await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - // Race: resolve immediately when results arrive, or fall through after timeout - await Promise.race([resultReady, page.waitForTimeout(timeoutMs)]); - - await page.close(); - return { passed, failed, logs }; -} - -const TARGET_URL = "https://content-security-policy.com/"; - -test.describe("GM API", () => { - // Two-phase launch + script install + network fetches + permission dialogs - test.setTimeout(300_000); - - test("GM_ sync API tests (gm_api_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript(context, extensionId, "gm_api_test.js", TARGET_URL, 90_000); - - console.log(`[gm_api_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { - console.log("[gm_api_test] logs:", logs.join("\n")); - } - expect(failed, "Some GM_ sync API tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); - }); - - test("GM.* async API tests (gm_api_async_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( - context, - extensionId, - "gm_api_async_test.js", - TARGET_URL, - 90_000 - ); - - console.log(`[gm_api_async_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { - console.log("[gm_api_async_test] logs:", logs.join("\n")); - } - expect(failed, "Some GM.* async API tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); - }); - - test("Content inject tests (inject_content_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( - context, - extensionId, - "inject_content_test.js", - TARGET_URL, - 60_000 - ); - - console.log(`[inject_content_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { - console.log("[inject_content_test] logs:", logs.join("\n")); - } - expect(failed, "Some content inject tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); - }); - - test("Unwrap scriptlet tests (unwrap_e2e_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( - context, - extensionId, - "unwrap_e2e_test.js", - TARGET_URL, - 60_000 - ); - - console.log(`[unwrap_e2e_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { - console.log("[unwrap_e2e_test] logs:", logs.join("\n")); - } - expect(failed, "Some unwrap scriptlet tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); - }); -}); diff --git a/e2e/install.spec.ts b/e2e/install.spec.ts deleted file mode 100644 index fe285c3ff..000000000 --- a/e2e/install.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { test, expect } from "./fixtures"; -import { openInstallPage } from "./utils"; - -test.describe("Install Page", () => { - // Use a well-known public userscript URL for testing - const testScriptUrl = - "https://raw.githubusercontent.com/nicedayzhu/userscripts/refs/heads/master/hello-world.user.js"; - - test("should open install page with URL parameter", async ({ context, extensionId }) => { - const page = await openInstallPage(context, extensionId, testScriptUrl); - - // The page should load without errors - await expect(page).toHaveTitle(/Install.*ScriptCat|ScriptCat/i); - }); - - test("should display script metadata when loading a script", async ({ context, extensionId }) => { - const page = await openInstallPage(context, extensionId, testScriptUrl); - - // Wait for the script to be fetched and metadata to be displayed - // The install page shows script name, version, description, etc. - // Wait for either the metadata to load or an error message - await page.waitForTimeout(5000); - - // Check that the page has loaded content (not just blank) - const body = page.locator("body"); - const text = await body.innerText(); - expect(text.length).toBeGreaterThan(0); - }); -}); diff --git a/e2e/options.spec.ts b/e2e/options.spec.ts deleted file mode 100644 index 2901e4113..000000000 --- a/e2e/options.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { test, expect } from "./fixtures"; -import { openOptionsPage } from "./utils"; - -test.describe("Options Page", () => { - test("should load and display ScriptCat title and logo", async ({ context, extensionId }) => { - const page = await openOptionsPage(context, extensionId); - - // Check logo is visible - const logo = page.locator('img[alt="ScriptCat"]'); - await expect(logo).toBeVisible(); - - // Check title text - await expect(page.getByText("ScriptCat", { exact: true })).toBeVisible(); - }); - - test("should navigate via sidebar menu items", async ({ context, extensionId }) => { - const page = await openOptionsPage(context, extensionId); - - // Wait for the sidebar menu to be visible (use first() since there are two menus) - await expect(page.locator(".arco-menu").first()).toBeVisible(); - - // Click "Subscribe" / "订阅" menu item and verify route change - await page - .locator(".arco-menu-item") - .filter({ hasText: /subscribe|订阅/i }) - .first() - .click(); - await expect(page).toHaveURL(/.*#\/subscribe/); - - // Click "Logs" / "日志" menu item - await page - .locator(".arco-menu-item") - .filter({ hasText: /log|日志/i }) - .first() - .click(); - await expect(page).toHaveURL(/.*#\/logger/); - - // Click "Tools" / "工具" menu item - await page - .locator(".arco-menu-item") - .filter({ hasText: /tool|工具/i }) - .first() - .click(); - await expect(page).toHaveURL(/.*#\/tools/); - - // Click "Settings" / "设置" menu item - await page - .locator(".arco-menu-item") - .filter({ hasText: /setting|设置/i }) - .first() - .click(); - await expect(page).toHaveURL(/.*#\/setting/); - - // Navigate back to script list (home) - click the first menu item - await page - .locator(".arco-menu-item") - .filter({ hasText: /installed.*script|已安装脚本/i }) - .first() - .click(); - await expect(page).toHaveURL(/.*#\//); - }); - - test("should show theme switch dropdown with light/dark/auto options", async ({ context, extensionId }) => { - const page = await openOptionsPage(context, extensionId); - - // Find the theme toggle button in the action-tools area (icon-only button) - const actionTools = page.locator(".action-tools"); - const themeButton = actionTools.locator(".arco-btn-icon-only").first(); - await themeButton.click(); - - // Verify dropdown with theme options appears - use role="menuitem" - const menuItems = page.locator('[role="menuitem"]'); - await expect(menuItems.first()).toBeVisible({ timeout: 5000 }); - const count = await menuItems.count(); - expect(count).toBeGreaterThanOrEqual(3); - }); - - test("should show create script dropdown menu", async ({ context, extensionId }) => { - const page = await openOptionsPage(context, extensionId); - - // The create script button is the first text button in action-tools - const createBtn = page.locator(".action-tools .arco-btn-text").first(); - await createBtn.click(); - - // Verify dropdown menu appears - use role="menuitem" - const menuItems = page.locator('[role="menuitem"]'); - await expect(menuItems.first()).toBeVisible({ timeout: 5000 }); - const count = await menuItems.count(); - expect(count).toBeGreaterThanOrEqual(3); - }); - - test("should show empty state when script list is empty", async ({ context, extensionId }) => { - const page = await openOptionsPage(context, extensionId); - - // Wait for the content area to load - await page.waitForTimeout(2000); - - // The empty state component from arco-design should be visible - const emptyState = page.locator(".arco-empty"); - await expect(emptyState).toBeVisible({ timeout: 10_000 }); - }); -}); diff --git a/e2e/popup.spec.ts b/e2e/popup.spec.ts deleted file mode 100644 index f34be06c8..000000000 --- a/e2e/popup.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { test, expect } from "./fixtures"; -import { openPopupPage } from "./utils"; - -test.describe("Popup Page", () => { - test("should load and display ScriptCat title", async ({ context, extensionId }) => { - const page = await openPopupPage(context, extensionId); - - // The popup should show "ScriptCat" title in the card header - await expect(page.getByText("ScriptCat", { exact: true })).toBeVisible({ timeout: 10_000 }); - }); - - test("should show global script enable/disable switch", async ({ context, extensionId }) => { - const page = await openPopupPage(context, extensionId); - - // The switch for enabling/disabling scripts should be present - const globalSwitch = page.locator(".arco-switch").first(); - await expect(globalSwitch).toBeVisible({ timeout: 10_000 }); - }); - - test("should render Collapse sections for scripts", async ({ context, extensionId }) => { - const page = await openPopupPage(context, extensionId); - - // Wait for the collapse component to render - const collapse = page.locator(".arco-collapse"); - await expect(collapse).toBeVisible({ timeout: 10_000 }); - - // Should have at least one collapse item (current page scripts) - const collapseItems = page.locator(".arco-collapse-item"); - const count = await collapseItems.count(); - expect(count).toBeGreaterThanOrEqual(1); - }); - - test("should have settings button that works", async ({ context, extensionId }) => { - const page = await openPopupPage(context, extensionId); - - // Wait for the popup to fully load - await expect(page.getByText("ScriptCat", { exact: true })).toBeVisible({ timeout: 10_000 }); - - // Find the settings button - it's an icon-only button in the header - // The order is: Switch, Settings, Notification, MoreMenu - const iconButtons = page.locator(".arco-btn-icon-only"); - // Settings is the first icon-only button - const settingsBtn = iconButtons.first(); - await expect(settingsBtn).toBeVisible(); - - // Click the settings button - it should open a new page - const [newPage] = await Promise.all([context.waitForEvent("page"), settingsBtn.click()]); - - // The new page should be the options page - await expect(newPage).toHaveURL(/options\.html/); - }); - - test("should show more menu dropdown with items", async ({ context, extensionId }) => { - const page = await openPopupPage(context, extensionId); - - // Wait for popup to load - await expect(page.getByText("ScriptCat", { exact: true })).toBeVisible({ timeout: 10_000 }); - - // The more menu button is the last icon-only button - const iconButtons = page.locator(".arco-btn-icon-only"); - const count = await iconButtons.count(); - const moreBtn = iconButtons.nth(count - 1); - await moreBtn.click(); - - // Wait for the dropdown to appear - await page.waitForTimeout(500); - - // The dropdown menu items use role="menuitem" - const menuItems = page.locator('[role="menuitem"]'); - const itemCount = await menuItems.count(); - expect(itemCount).toBeGreaterThanOrEqual(3); - }); -}); diff --git a/e2e/script-editor.spec.ts b/e2e/script-editor.spec.ts deleted file mode 100644 index 035e42b16..000000000 --- a/e2e/script-editor.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { test, expect } from "./fixtures"; -import { openEditorPage, openOptionsPage } from "./utils"; - -test.describe("Script Editor", () => { - test("should load editor page with Monaco editor", async ({ context, extensionId }) => { - const page = await openEditorPage(context, extensionId); - - // Wait for Monaco editor to render - const monacoEditor = page.locator(".monaco-editor"); - await expect(monacoEditor).toBeVisible({ timeout: 30_000 }); - }); - - test("should load new user script template", async ({ context, extensionId }) => { - const page = await openEditorPage(context, extensionId); - - // Wait for Monaco editor - const monacoEditor = page.locator(".monaco-editor"); - await expect(monacoEditor).toBeVisible({ timeout: 30_000 }); - - // The editor should contain a UserScript header with default template content - const editorContent = page.locator(".view-lines"); - await expect(editorContent).toContainText("==UserScript==", { timeout: 15_000 }); - }); - - test("should save script and show success message", async ({ context, extensionId }) => { - const page = await openEditorPage(context, extensionId); - - // Wait for Monaco editor to fully load - const monacoEditor = page.locator(".monaco-editor"); - await expect(monacoEditor).toBeVisible({ timeout: 30_000 }); - await expect(page.locator(".view-lines")).toContainText("==UserScript==", { timeout: 15_000 }); - - // Click inside the editor to ensure it has focus - await page.locator(".monaco-editor .view-lines").click(); - await page.waitForTimeout(500); - - // Save the script using Ctrl+S - await page.keyboard.press("ControlOrMeta+s"); - - // After saving, a success message should appear - // Arco Message renders with class "arco-message" containing "arco-message-icon-success" - const successMsg = page.locator(".arco-message"); - await expect(successMsg.first()).toBeVisible({ timeout: 15_000 }); - }); - - test("should show newly created script in the list after saving", async ({ context, extensionId }) => { - // First create a script via the editor - const editorPage = await openEditorPage(context, extensionId); - - await expect(editorPage.locator(".monaco-editor")).toBeVisible({ timeout: 30_000 }); - await expect(editorPage.locator(".view-lines")).toContainText("==UserScript==", { - timeout: 15_000, - }); - - // Click inside editor to ensure focus, then save - await editorPage.locator(".monaco-editor .view-lines").click(); - await editorPage.waitForTimeout(500); - await editorPage.keyboard.press("ControlOrMeta+s"); - await expect(editorPage.locator(".arco-message").first()).toBeVisible({ timeout: 15_000 }); - - // Now open the options page to check the script list - const listPage = await openOptionsPage(context, extensionId); - await listPage.waitForTimeout(2000); - - // The script list should now contain at least one script entry (no empty state) - const emptyState = listPage.locator(".arco-empty"); - await expect(emptyState).toHaveCount(0); - }); -}); diff --git a/e2e/script-management.spec.ts b/e2e/script-management.spec.ts deleted file mode 100644 index d09542796..000000000 --- a/e2e/script-management.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { test, expect } from "./fixtures"; -import type { BrowserContext, Page } from "@playwright/test"; -import { openEditorPage, openOptionsPage } from "./utils"; - -/** - * Helper: create a script via the editor, then open the options page. - */ -async function createScriptAndGoToList(context: BrowserContext, extensionId: string): Promise { - const editorPage = await openEditorPage(context, extensionId); - - // Wait for Monaco editor - await expect(editorPage.locator(".monaco-editor")).toBeVisible({ timeout: 30_000 }); - await expect(editorPage.locator(".view-lines")).toContainText("==UserScript==", { - timeout: 15_000, - }); - - // Click inside editor to ensure focus, then save - await editorPage.locator(".monaco-editor .view-lines").click(); - await editorPage.waitForTimeout(500); - await editorPage.keyboard.press("ControlOrMeta+s"); - - // Wait for success message, retry once if needed - try { - await expect(editorPage.locator(".arco-message").first()).toBeVisible({ timeout: 10_000 }); - } catch { - // Retry: click editor again and resave - await editorPage.locator(".monaco-editor .view-lines").click(); - await editorPage.waitForTimeout(500); - await editorPage.keyboard.press("ControlOrMeta+s"); - await expect(editorPage.locator(".arco-message").first()).toBeVisible({ timeout: 15_000 }); - } - - // Open the options page (script list) - const page = await openOptionsPage(context, extensionId); - await page.waitForTimeout(2000); - - return page; -} - -test.describe("Script Management", () => { - test("should create a script and see it in the list", async ({ context, extensionId }) => { - const page = await createScriptAndGoToList(context, extensionId); - - // The script list should have at least one entry (no empty state) - const emptyState = page.locator(".arco-empty"); - await expect(emptyState).toHaveCount(0); - }); - - test("should toggle enable/disable on a script", async ({ context, extensionId }) => { - const page = await createScriptAndGoToList(context, extensionId); - - // Find the switch/toggle in the script list - const scriptSwitch = page.locator(".arco-switch").first(); - await expect(scriptSwitch).toBeVisible({ timeout: 10_000 }); - - // Get initial state - const initialChecked = await scriptSwitch.getAttribute("aria-checked"); - - // Click to toggle - await scriptSwitch.click(); - await page.waitForTimeout(1000); - - // The state should have changed - const newChecked = await scriptSwitch.getAttribute("aria-checked"); - expect(newChecked).not.toBe(initialChecked); - }); - - test("should delete a script", async ({ context, extensionId }) => { - const page = await createScriptAndGoToList(context, extensionId); - - // Right-click on a script row to get context menu - const scriptRow = page.locator(".arco-table-row, .arco-card-body .arco-list-item, [class*='script']").first(); - if (await scriptRow.isVisible()) { - await scriptRow.click({ button: "right" }); - await page.waitForTimeout(500); - - // Look for delete option in context menu - const deleteOption = page.getByText(/delete|删除/i).first(); - if (await deleteOption.isVisible({ timeout: 2000 }).catch(() => false)) { - await deleteOption.click(); - - // Confirm deletion if a modal appears - const confirmBtn = page.locator(".arco-modal .arco-btn-primary"); - if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) { - await confirmBtn.click(); - } - - await page.waitForTimeout(2000); - - // After deletion, the list should be empty again - const emptyState = page.locator(".arco-empty"); - await expect(emptyState).toBeVisible({ timeout: 10_000 }); - } - } - }); - - test("should search/filter scripts", async ({ context, extensionId }) => { - const page = await createScriptAndGoToList(context, extensionId); - - // Look for a search input - const searchInput = page.locator('input[type="text"], .arco-input').first(); - if (await searchInput.isVisible({ timeout: 3000 }).catch(() => false)) { - // Type a search query that won't match - await searchInput.fill("nonexistent_script_xyz"); - await page.waitForTimeout(1000); - - // The list should show empty or no results - const emptyState = page.locator(".arco-empty"); - await expect(emptyState).toBeVisible({ timeout: 5000 }); - - // Clear search and scripts should reappear - await searchInput.clear(); - await page.waitForTimeout(1000); - await expect(emptyState).toHaveCount(0); - } - }); -}); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts deleted file mode 100644 index 27e26beaf..000000000 --- a/e2e/settings.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { test, expect } from "./fixtures"; -import { openOptionsPage } from "./utils"; - -test.describe("Settings Page", () => { - test("should render the settings page", async ({ context, extensionId }) => { - const page = await openOptionsPage(context, extensionId); - - // Navigate to settings via hash route - await page.goto(`chrome-extension://${extensionId}/src/options.html#/setting`); - await page.waitForLoadState("domcontentloaded"); - - // Wait for the settings page to render - await page.waitForTimeout(2000); - - // The settings page should have visible content (cards, selects, inputs, etc.) - const content = page.locator(".arco-layout-content"); - await expect(content).toBeVisible(); - }); - - test("should have visible and interactive settings items", async ({ context, extensionId }) => { - const page = await openOptionsPage(context, extensionId); - - // Navigate to settings - await page.goto(`chrome-extension://${extensionId}/src/options.html#/setting`); - await page.waitForLoadState("domcontentloaded"); - await page.waitForTimeout(2000); - - // Check that at least one Select component or Input is visible - const selects = page.locator(".arco-select"); - const inputs = page.locator(".arco-input"); - const checkboxes = page.locator(".arco-checkbox"); - - const selectCount = await selects.count(); - const inputCount = await inputs.count(); - const checkboxCount = await checkboxes.count(); - - // Settings page should have at least some interactive elements - expect(selectCount + inputCount + checkboxCount).toBeGreaterThan(0); - }); -}); diff --git a/e2e/utils.ts b/e2e/utils.ts deleted file mode 100644 index d1494c557..000000000 --- a/e2e/utils.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { BrowserContext, Page } from "@playwright/test"; - -/** Open the options page and wait for it to load */ -export async function openOptionsPage(context: BrowserContext, extensionId: string): Promise { - const page = await context.newPage(); - await page.goto(`chrome-extension://${extensionId}/src/options.html`); - await page.waitForLoadState("domcontentloaded"); - return page; -} - -/** Open the popup page and wait for it to load */ -export async function openPopupPage(context: BrowserContext, extensionId: string): Promise { - const page = await context.newPage(); - await page.goto(`chrome-extension://${extensionId}/src/popup.html`); - await page.waitForLoadState("domcontentloaded"); - return page; -} - -/** Open the install page with a script URL parameter */ -export async function openInstallPage(context: BrowserContext, extensionId: string, scriptUrl: string): Promise { - const page = await context.newPage(); - await page.goto(`chrome-extension://${extensionId}/src/install.html?url=${encodeURIComponent(scriptUrl)}`); - await page.waitForLoadState("domcontentloaded"); - return page; -} - -/** Open the script editor page */ -export async function openEditorPage(context: BrowserContext, extensionId: string, params?: string): Promise { - const page = await context.newPage(); - const hash = params ? `#/script/editor?${params}` : "#/script/editor"; - await page.goto(`chrome-extension://${extensionId}/src/options.html${hash}`); - await page.waitForLoadState("domcontentloaded"); - return page; -} - -/** Install a script by injecting code into the Monaco editor and saving */ -export async function installScriptByCode(context: BrowserContext, extensionId: string, code: string): Promise { - const page = await openEditorPage(context, extensionId); - // Wait for Monaco editor DOM and default template content to be ready - await page.locator(".monaco-editor").waitFor({ timeout: 30_000 }); - await page.locator(".view-lines").waitFor({ timeout: 15_000 }); - // Click to focus and wait for the cursor to appear (confirms editor is interactive) - await page.locator(".monaco-editor .view-lines").click(); - await page.locator(".cursors-layer .cursor").waitFor({ timeout: 5_000 }); - // Select all existing content - await page.keyboard.press("ControlOrMeta+a"); - // Capture current content fingerprint, then paste replacement - const initialText = await page.locator(".view-lines").textContent(); - await page.evaluate((text) => navigator.clipboard.writeText(text), code); - await page.keyboard.press("ControlOrMeta+v"); - // Wait for Monaco to finish rendering the pasted content (content will differ from template) - await page.waitForFunction((init) => document.querySelector(".view-lines")?.textContent !== init, initialText, { - timeout: 10_000, - }); - // Save - await page.keyboard.press("ControlOrMeta+s"); - // Wait for save: try arco-message first, then verify via script list - const saved = await page - .locator(".arco-message") - .first() - .waitFor({ timeout: 10_000 }) - .then(() => true) - .catch(() => false); - if (!saved) { - // For scripts with @require/@resource, the message may not appear. - // Verify save by checking the script list on the options page. - const listPage = await openOptionsPage(context, extensionId); - const emptyState = listPage.locator(".arco-empty"); - // Wait until at least one script appears (no empty state), up to 30s - await emptyState.waitFor({ state: "detached", timeout: 30_000 }).catch(() => {}); - await listPage.close(); - } - await page.close(); -} - -/** A sample userscript for testing */ -export const sampleUserScript = `// ==UserScript== -// @name E2E Test Script -// @namespace https://e2e.test -// @version 1.0.0 -// @description A test script for E2E testing -// @author E2E Test -// @match https://example.com/* -// ==/UserScript== - -console.log("E2E Test Script loaded"); -`; diff --git a/e2e/vscode-connect.spec.ts b/e2e/vscode-connect.spec.ts deleted file mode 100644 index bb873c804..000000000 --- a/e2e/vscode-connect.spec.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { test, expect } from "./fixtures"; -import { openOptionsPage } from "./utils"; -import type { Page } from "@playwright/test"; -import { WebSocketServer, type WebSocket } from "ws"; - -// ──────────────────────────────────────────────── -// 辅助函数 -// ──────────────────────────────────────────────── - -/** 打开 Tools 页面 */ -async function openToolsPage(context: Parameters[0], extensionId: string): Promise { - const page = await openOptionsPage(context, extensionId); - await page.goto(`chrome-extension://${extensionId}/src/options.html#/tools`); - await page.waitForLoadState("domcontentloaded"); - return page; -} - -/** 获取「开发工具」卡片区域的定位器 */ -function getDevCard(page: Page) { - // 开发工具 / Development Tool 卡片是页面上第二个 Card - return page.locator(".arco-card").nth(1); -} - -/** 启动一个临时 WebSocket 服务器,返回 URL 和清理函数 */ -function createMockWSServer(): Promise<{ - url: string; - connections: WebSocket[]; - close: () => Promise; - /** 向所有已连接客户端发送消息 */ - broadcast: (data: unknown) => void; - /** 等待收到指定 action 的消息 */ - waitForAction: (action: string, timeout?: number) => Promise; -}> { - return new Promise((resolve, reject) => { - const connections: WebSocket[] = []; - const messageListeners: Array<(msg: unknown) => void> = []; - - const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }, () => { - const addr = wss.address(); - if (typeof addr === "string") { - reject(new Error("Unexpected address type")); - return; - } - const url = `ws://127.0.0.1:${addr.port}`; - - wss.on("connection", (ws) => { - connections.push(ws); - ws.on("message", (raw) => { - try { - const msg = JSON.parse(raw.toString()); - for (const listener of messageListeners) { - listener(msg); - } - } catch { - // 忽略非 JSON 消息 - } - }); - }); - - resolve({ - url, - connections, - close: () => - new Promise((res) => { - for (const ws of connections) ws.close(); - wss.close(() => res()); - }), - broadcast: (data: unknown) => { - const payload = JSON.stringify(data); - for (const ws of connections) { - if (ws.readyState === ws.OPEN) { - ws.send(payload); - } - } - }, - waitForAction: (action: string, timeout = 10_000) => - new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const idx = messageListeners.indexOf(handler); - if (idx >= 0) messageListeners.splice(idx, 1); - reject(new Error(`Timeout waiting for action: ${action}`)); - }, timeout); - - const handler = (msg: any) => { - if (msg.action === action) { - clearTimeout(timer); - const idx = messageListeners.indexOf(handler); - if (idx >= 0) messageListeners.splice(idx, 1); - resolve(msg); - } - }; - messageListeners.push(handler); - }), - }); - }); - }); -} - -// ──────────────────────────────────────────────── -// 测试 -// ──────────────────────────────────────────────── - -test.describe("VSCode 连接", () => { - test("Tools 页面应显示 VSCode 连接相关 UI 元素", async ({ context, extensionId }) => { - const page = await openToolsPage(context, extensionId); - const card = getDevCard(page); - - // 卡片标题 - await expect(card.getByText(/development tool|开发工具/i)).toBeVisible(); - - // VSCode URL 输入框 - const urlInput = card.locator(".arco-input"); - await expect(urlInput).toBeVisible(); - // 默认值应包含 ws:// - const value = await urlInput.inputValue(); - expect(value).toMatch(/^ws:\/\//); - - // 自动连接复选框 - const checkbox = card.locator(".arco-checkbox"); - await expect(checkbox).toBeVisible(); - await expect(card.getByText(/auto connect vscode|自动连接vscode/i)).toBeVisible(); - - // 连接按钮 - const connectBtn = card.locator(".arco-btn-primary"); - await expect(connectBtn).toBeVisible(); - await expect(connectBtn.getByText(/connect|连接/i)).toBeVisible(); - }); - - test("应能修改 VSCode URL 和切换自动连接", async ({ context, extensionId }) => { - const page = await openToolsPage(context, extensionId); - const card = getDevCard(page); - - // 修改 URL - const urlInput = card.locator(".arco-input"); - await urlInput.clear(); - await urlInput.fill("ws://localhost:9999"); - await expect(urlInput).toHaveValue("ws://localhost:9999"); - - // 切换自动连接复选框 - const checkbox = card.locator(".arco-checkbox input"); - const initialChecked = await checkbox.isChecked(); - await card.locator(".arco-checkbox").click(); - const newChecked = await checkbox.isChecked(); - expect(newChecked).toBe(!initialChecked); - }); - - test("点击连接按钮应发送连接命令", async ({ context, extensionId }) => { - const page = await openToolsPage(context, extensionId); - const card = getDevCard(page); - - // 连接按钮存在且可点击 - const connectBtn = card.locator(".arco-btn-primary"); - await connectBtn.click(); - - // connectVSCode 是消息传递操作,消息投递成功即 resolve, - // 所以即使没有 WebSocket 服务器运行,也应显示「连接成功」提示 - const successMsg = page.locator(".arco-message").getByText(/connection successful|连接成功/i); - await expect(successMsg).toBeVisible({ timeout: 10_000 }); - }); - - test("应能通过 WebSocket 连接并接收脚本同步", async ({ context, extensionId }) => { - // 启动 Mock WebSocket 服务器 - const server = await createMockWSServer(); - - try { - const page = await openToolsPage(context, extensionId); - const card = getDevCard(page); - - // 设置 URL 为 Mock 服务器地址 - const urlInput = card.locator(".arco-input"); - await urlInput.clear(); - await urlInput.fill(server.url); - - // 在点击连接之前就开始监听 hello 消息,避免竞态 - const helloPromise = server.waitForAction("hello", 30_000); - - // 等待 offscreen 文档就绪(service worker 启动后异步创建) - await page.waitForTimeout(2000); - - // 点击连接 - const connectBtn = card.locator(".arco-btn-primary"); - await connectBtn.click(); - - // 等待「连接成功」消息 - const successMsg = page.locator(".arco-message").getByText(/connection successful|连接成功/i); - await expect(successMsg).toBeVisible({ timeout: 10_000 }); - - // 等待收到 hello 握手消息 - await helloPromise; - - // 验证客户端已连接 - expect(server.connections.length).toBeGreaterThanOrEqual(1); - - // 发送 onchange 消息,模拟 VSCode 推送脚本 - const testScript = `// ==UserScript== -// @name VSCode E2E Test Script -// @namespace https://e2e.test/vscode -// @version 1.0.0 -// @description Script synced from VSCode E2E test -// @author E2E -// @match https://example.com/* -// ==/UserScript== - -console.log("VSCode synced script"); -`; - - server.broadcast({ - action: "onchange", - data: { - script: testScript, - uri: "file:///e2e-test/vscode-sync-test.user.js", - }, - }); - - // 验证脚本已安装:导航到脚本列表,检查脚本是否出现 - const listPage = await openOptionsPage(context, extensionId); - const scriptItem = listPage.getByText("VSCode E2E Test Script"); - await expect(scriptItem).toBeVisible({ timeout: 15_000 }); - await listPage.close(); - } finally { - await server.close(); - } - }); -}); diff --git a/package.json b/package.json index d9b7b274d..d3972c3c7 100644 --- a/package.json +++ b/package.json @@ -24,16 +24,9 @@ "lint-fix": "tsc --noEmit && eslint --fix .", "changlog": "node ./scripts/changlog.js", "crowdin": "crowdin", - "crowdin:download": "node ./scripts/crowdin-download.js", - "test:e2e": "npx playwright test", - "test:e2e:ui": "npx playwright test --ui" + "crowdin:download": "node ./scripts/crowdin-download.js" }, "dependencies": { - "@arco-design/web-react": "^2.66.7", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", "chardet": "^2.1.1", "cron": "^4.4.0", "crypto-js": "^4.2.0", @@ -47,11 +40,6 @@ "monaco-editor": "^0.52.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-dropzone": "^14.3.8", - "react-i18next": "^15.6.0", - "react-icons": "^5.5.0", - "react-joyride": "^2.9.3", - "react-router-dom": "^7.13.0", "string-similarity-js": "^2.1.4", "uuid": "^11.1.0", "webdav": "^5.9.0", @@ -60,7 +48,6 @@ "devDependencies": { "@eslint/compat": "^1.4.1", "@eslint/js": "9.39.2", - "@playwright/test": "^1.58.2", "@rspack/cli": "^1.7.6", "@rspack/core": "^1.6.8", "@swc/helpers": "^0.5.17", @@ -73,11 +60,8 @@ "@types/react-dom": "^18.3.1", "@types/semver": "^7.7.1", "@types/serviceworker": "^0.0.120", - "@unocss/postcss": "66.5.4", "@vitest/coverage-v8": "^4.0.18", "acorn": "^8.16.0", - "uglify-js":"^3.19.3", - "autoprefixer": "^10.4.21", "cross-env": "^10.1.0", "crx": "^5.0.1", "eslint": "^9.39.2", @@ -93,14 +77,12 @@ "jszip": "^3.10.1", "magic-string": "^0.30.21", "mock-xmlhttprequest": "^8.4.1", - "postcss": "^8.5.6", - "postcss-loader": "^8.2.0", "prettier": "^3.6.2", "semver": "^7.7.1", "ts-node": "^10.9.2", "typescript": "^5.9.3", "typescript-eslint": "^8.46.2", - "unocss": "66.5.4", + "uglify-js": "^3.19.3", "vitest": "^4.0.18" }, "packageManager": "pnpm@10.12.4", diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index 9a15dabba..000000000 --- a/playwright.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from "@playwright/test"; - -export default defineConfig({ - testDir: "./e2e", - timeout: 60_000, - expect: { - timeout: 10_000, - }, - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - workers: 1, - reporter: process.env.CI ? [["html", { open: "never" }], ["list"]] : "list", - outputDir: "test-results", - use: { - actionTimeout: 10_000, - trace: "on-first-retry", - screenshot: "only-on-failure", - video: "retain-on-failure", - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9668d48ea..f63d27100 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,21 +8,6 @@ importers: .: dependencies: - '@arco-design/web-react': - specifier: ^2.66.7 - version: 2.66.7(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/core': - specifier: ^6.3.1 - version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/modifiers': - specifier: ^9.0.0 - version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - '@dnd-kit/sortable': - specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': - specifier: ^3.2.2 - version: 3.2.2(react@18.3.1) chardet: specifier: ^2.1.1 version: 2.1.1 @@ -62,21 +47,6 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) - react-dropzone: - specifier: ^14.3.8 - version: 14.3.8(react@18.3.1) - react-i18next: - specifier: ^15.6.0 - version: 15.6.0(i18next@23.16.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - react-icons: - specifier: ^5.5.0 - version: 5.5.0(react@18.3.1) - react-joyride: - specifier: ^2.9.3 - version: 2.9.3(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-router-dom: - specifier: ^7.13.0 - version: 7.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) string-similarity-js: specifier: ^2.1.4 version: 2.1.4 @@ -96,9 +66,6 @@ importers: '@eslint/js': specifier: 9.39.2 version: 9.39.2 - '@playwright/test': - specifier: ^1.58.2 - version: 1.58.2 '@rspack/cli': specifier: ^1.7.6 version: 1.7.6(@rspack/core@1.7.6(@swc/helpers@0.5.17))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.96.1(uglify-js@3.19.3)) @@ -135,18 +102,12 @@ importers: '@types/serviceworker': specifier: ^0.0.120 version: 0.0.120 - '@unocss/postcss': - specifier: 66.5.4 - version: 66.5.4(postcss@8.5.6) '@vitest/coverage-v8': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18(@types/node@22.16.0)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3)) acorn: specifier: ^8.16.0 version: 8.16.0 - autoprefixer: - specifier: ^10.4.21 - version: 10.4.21(postcss@8.5.6) cross-env: specifier: ^10.1.0 version: 10.1.0 @@ -192,12 +153,6 @@ importers: mock-xmlhttprequest: specifier: ^8.4.1 version: 8.4.1 - postcss: - specifier: ^8.5.6 - version: 8.5.6 - postcss-loader: - specifier: ^8.2.0 - version: 8.2.0(@rspack/core@1.7.6(@swc/helpers@0.5.17))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.96.1(uglify-js@3.19.3)) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -216,9 +171,6 @@ importers: uglify-js: specifier: ^3.19.3 version: 3.19.3 - unocss: - specifier: 66.5.4 - version: 66.5.4(postcss@8.5.6)(vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3)) vitest: specifier: ^4.0.18 version: 4.0.18(@types/node@22.16.0)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3) @@ -228,21 +180,6 @@ packages: '@adobe/css-tools@4.4.3': resolution: {integrity: sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==} - '@antfu/install-pkg@1.1.0': - resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - - '@antfu/utils@9.3.0': - resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==} - - '@arco-design/color@0.4.0': - resolution: {integrity: sha512-s7p9MSwJgHeL8DwcATaXvWT3m2SigKpxx4JA1BGPHL4gfvaQsmQfrLBDpjOJFJuJ2jG2dMt3R3P8Pm9E65q18g==} - - '@arco-design/web-react@2.66.7': - resolution: {integrity: sha512-heZoNjsdD2tXFAv0SmVsMsMFVcwhOVUsjqfRZZtW7WHAWIx1vygbVJceww61DgBwFXPtYPUlyu2SRmPiVz2xlg==} - peerDependencies: - react: '>=16' - react-dom: '>=16' - '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -250,37 +187,14 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.5': - resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/parser@7.27.7': - resolution: {integrity: sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/parser@7.28.0': - resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.0': resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} @@ -290,22 +204,6 @@ packages: resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==} engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.27.7': - resolution: {integrity: sha512-X6ZlfR/O/s5EQ/SnUSLzr+6kGnkg8HXGMzpgsMsrJVcfDtH1vIp6ctCN4eZ1LS5c0+te5Cb6Y514fASjMRJ1nw==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.28.0': - resolution: {integrity: sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -353,34 +251,6 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} - '@dnd-kit/accessibility@3.1.1': - resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} - peerDependencies: - react: '>=16.8.0' - - '@dnd-kit/core@6.3.1': - resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@dnd-kit/modifiers@9.0.0': - resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} - peerDependencies: - '@dnd-kit/core': ^6.3.0 - react: '>=16.8.0' - - '@dnd-kit/sortable@10.0.0': - resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} - peerDependencies: - '@dnd-kit/core': ^6.3.0 - react: '>=16.8.0' - - '@dnd-kit/utilities@3.2.2': - resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} - peerDependencies: - react: '>=16.8.0' - '@emnapi/core@1.7.0': resolution: {integrity: sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==} @@ -740,12 +610,6 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@gilbarbara/deep-equal@0.1.2': - resolution: {integrity: sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA==} - - '@gilbarbara/deep-equal@0.3.1': - resolution: {integrity: sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw==} - '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -766,18 +630,9 @@ packages: resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} engines: {node: '>=18.18'} - '@iconify/types@2.0.0': - resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - - '@iconify/utils@3.0.2': - resolution: {integrity: sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==} - '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -788,9 +643,6 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.29': - resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -957,17 +809,9 @@ packages: resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.58.2': - resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} - engines: {node: '>=18'} - hasBin: true - '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} - '@quansync/fs@0.1.5': - resolution: {integrity: sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA==} - '@rollup/rollup-android-arm-eabi@4.44.2': resolution: {integrity: sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==} cpu: [arm] @@ -1384,92 +1228,6 @@ packages: resolution: {integrity: sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@unocss/astro@66.5.4': - resolution: {integrity: sha512-6KsilC1SiTBmEJRMuPl+Mg8KDWB1+DaVoirGZR7BAEtMf2NzrfQcR4+O/3DHtzb38pfb0K1aHCfWwCozHxLlfA==} - peerDependencies: - vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 - peerDependenciesMeta: - vite: - optional: true - - '@unocss/cli@66.5.4': - resolution: {integrity: sha512-GltHfmJ27O5VHB/ABkkUWwWT72xGBVwFyhTnhIOut4EPkIurKDnfY5MZFEl2PLlIFnYqIJxeTHMHONVg7pySMg==} - engines: {node: '>=14'} - hasBin: true - - '@unocss/config@66.5.4': - resolution: {integrity: sha512-TYwkUw9nZlLTBGCndsyrcHCJ7M+sEsJiK77Edggmd6B3urjkVc1cDjKF3k3tjg4ghQteGsc2akhzn1a4TouybQ==} - engines: {node: '>=14'} - - '@unocss/core@66.5.4': - resolution: {integrity: sha512-UDS2CRgyQCEFH+5kStDyJd7OFtgkIUZYn5Ahr5z7v3jc/pEfeOJ0mxsNAr+FgMS/xb17vy4sVFvx3jj/FwMZ1A==} - - '@unocss/extractor-arbitrary-variants@66.5.4': - resolution: {integrity: sha512-JsgITF11Z2WdXzF8eO2/qkcFIff/dEEc9C2eKYOSUv5pe+RMZxXHoAw4x+D4n0UrGAbHpoUVaJ8E7kG0ayTbGw==} - - '@unocss/inspector@66.5.4': - resolution: {integrity: sha512-eBf1HAxwNz1YItNiiP/YQaIE9LWeErt4Ofdm7Imj0WW464ZZ/TqXhu0ZREcCgyQDy2Lghuyq6Q3d9orm2Cby/w==} - - '@unocss/postcss@66.5.4': - resolution: {integrity: sha512-EpizX20MR8wTWlCI9+E6pI8Xj5S1kk2jRVQuQAxjjXcOEFoLZNTZmyULSlaIpg9vEtT5kuxQgBN6VmvyMgeD7g==} - engines: {node: '>=14'} - peerDependencies: - postcss: ^8.4.21 - - '@unocss/preset-attributify@66.5.4': - resolution: {integrity: sha512-6NmRUKpXp4qc+ZwWI+ImBIUzcdPRKWISfYu65hi8Sads453jzLJko78WOCVTLlgnmkquUJ98akNW5twG9p2mDw==} - - '@unocss/preset-icons@66.5.4': - resolution: {integrity: sha512-wPR2j191mw89hstQflQvsACzu+3QkueV9lHH8as94By9PKfswWnVZ1nPqJBc3Yl5v8fHEAR1YY4i7egN7TEjIA==} - - '@unocss/preset-mini@66.5.4': - resolution: {integrity: sha512-KaBGsw3+Pi5ZTsp5u0OrUUUXFVltHin02cYhv3A4b9392Kej5R3y7zIf1VjiQ3ZXR4KZWfv0CQj0LBqIqAJ5WA==} - - '@unocss/preset-tagify@66.5.4': - resolution: {integrity: sha512-ck71rFCQZEwYDzwf99sjCBuzpT0PnkzwdqWwnR34pN71Lf3ePN16hV6pEo7pWuIiatAGFzXh0AHzJrIQ/61Cxw==} - - '@unocss/preset-typography@66.5.4': - resolution: {integrity: sha512-RG0Bw4lGvwAJxHw8I/0Hx2/r4t8L6fnkWyEP4Iehie3kEGJ+S7Ku9sF6kkIeyOqjwL6Hp68rlnTGAycnYofoEg==} - - '@unocss/preset-uno@66.5.4': - resolution: {integrity: sha512-CEBtkNbbd1lYbCJw+s7HDeOtPeCEkvf+NDi/IrVkkBhOCcYRtYC+VDxjBgh4zjlmgZIQifkU2l7PPfGjd4IMNA==} - - '@unocss/preset-web-fonts@66.5.4': - resolution: {integrity: sha512-FQ/P/a1fSmGkkjWn/FNmErwK5YtsuX2VrkHsEa9DTP372td8Oea3hkK40UUYj3zRUivA71PmjVwhbBf+35nAiA==} - - '@unocss/preset-wind3@66.5.4': - resolution: {integrity: sha512-cqQGg9E2476YVpnX3sgO/jEoA4cKCA5rEl2NgemoAJpKAgdM68JPB+Tve4LlSLssxRQZ7ZYNO6hOfW8R2gVVuw==} - - '@unocss/preset-wind4@66.5.4': - resolution: {integrity: sha512-S5ZysCSTfl/h93jDnXIss214jqYfq+W6xZH50Krc1QTWy5teAOVCFTluRJEB70JTDOdUMwcTtmqFklSHIU5I7g==} - - '@unocss/preset-wind@66.5.4': - resolution: {integrity: sha512-2TWP2QrJwGFr21iwVsPKVzDa2JWjh1EUt1+OtAk5JQfGmmTeyw8EF3pIAcaM06WVi/m4em55RKIPNoW/Kr61yg==} - - '@unocss/reset@66.5.4': - resolution: {integrity: sha512-RF/Xscv4mOEDUltUpdKYZEBgIiE7nAVkipJ8gZWEwKfmUFz0TRcwlf0igjqjgGn11tuix0mJyk5Uwis9LioX4Q==} - - '@unocss/rule-utils@66.5.4': - resolution: {integrity: sha512-LFzLuXQfZKI/qJBrsqkaVKlw0ECU8Xw7m+MaKIKyFH/hqggzkvNG0PyWU2HnPNzz1dIiVBn+Epfpz8pzi+MLFA==} - engines: {node: '>=14'} - - '@unocss/transformer-attributify-jsx@66.5.4': - resolution: {integrity: sha512-VHzrxiWKBVsUZhFU0vG7e6MjSiQG/rR/PidPgi8KaMGAWi+CA6faRBfHaUQbfix2bLBhtDOdaZUDp9AsIgu1ZQ==} - - '@unocss/transformer-compile-class@66.5.4': - resolution: {integrity: sha512-H+IX9C8PHFMCJfYutIRjzkpgIiW/AFC7PzP/EQwM+p9VoC8LH+Wd8Csl/Uyi+uoQ+a78q4nQ2lDYZr42eanmgg==} - - '@unocss/transformer-directives@66.5.4': - resolution: {integrity: sha512-BqM4fRqCC5wIRkEB14SN476/KWKlEhHhrHECL9kOjbZYmIcxBDCYKf/iuZGvtK9IZJtE0JhrSGAdYfyp0Td7fQ==} - - '@unocss/transformer-variant-group@66.5.4': - resolution: {integrity: sha512-n/A74083b8zZtl05A3M0Lo9oWepFKuvRZKPXh+y4bJM2oSZl1Clzm1I+qmgvO/DKoa4rjgj4a/CRaIxeDpoO9A==} - - '@unocss/vite@66.5.4': - resolution: {integrity: sha512-dmSJ3h7/kMbFOIrAyycg2W1VVN+59WCnH5s0dJTGRla2kUTAFB0iWJWdIa/W6IsvFlicMtsoLYJmTnQ/kBQm7A==} - peerDependencies: - vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 - '@vitest/coverage-v8@4.0.18': resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} peerDependencies: @@ -1691,27 +1449,10 @@ packages: async@2.6.4: resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} - attr-accept@2.2.5: - resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} - engines: {node: '>=4'} - - autoprefixer@10.4.21: - resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - b-tween@0.3.3: - resolution: {integrity: sha512-oEHegcRpA7fAuc9KC4nktucuZn2aS8htymCPcP3qkEGPqiBH+GfqtqoG2l7LxHngg6O0HFM7hOeOYExl1Oz4ZA==} - - b-validate@1.5.3: - resolution: {integrity: sha512-iCvCkGFskbaYtfQ0a3GmcQCHl/Sv1GufXFGuUQ+FE+WJa7A/espLOuFIn09B944V8/ImPj71T4+rTASxO2PAuA==} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1773,10 +1514,6 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1822,25 +1559,13 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - color-string@1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} - - color@3.2.1: - resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} - colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -1863,26 +1588,13 @@ packages: resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} - compute-scroll-into-view@1.0.20: - resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} - concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - confbox@0.2.2: - resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} - connect-history-api-fallback@2.0.0: resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==} engines: {node: '>=0.8'} - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -1898,22 +1610,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - cookie@1.0.2: - resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} - engines: {node: '>=18'} - core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - crc32-stream@3.0.1: resolution: {integrity: sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==} engines: {node: '>= 6.9.0'} @@ -1948,10 +1647,6 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} - css-tree@3.1.0: - resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -2008,16 +1703,9 @@ packages: decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} - deep-diff@1.0.2: - resolution: {integrity: sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -2038,9 +1726,6 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - defu@6.1.4: - resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -2053,16 +1738,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - destr@2.0.3: - resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} - destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-node-es@1.1.0: - resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} @@ -2087,9 +1766,6 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dom-helpers@5.2.1: - resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - dompurify@3.3.3: resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} @@ -2125,13 +1801,6 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - - error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} - es-abstract@1.23.9: resolution: {integrity: sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==} engines: {node: '>= 0.4'} @@ -2311,9 +1980,6 @@ packages: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} - exsolve@1.0.7: - resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2364,10 +2030,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-selector@2.1.2: - resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==} - engines: {node: '>= 12'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2387,10 +2049,6 @@ packages: flatted@3.3.1: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} - focus-lock@1.3.5: - resolution: {integrity: sha512-QFaHbhv9WPUeLYBDe/PAuLKJ4Dd9OPvKs9xZBr3yLXnUrDNaVXKu2baDBXe3naPY30hgHYSsf2JW4jzas2mDEQ==} - engines: {node: '>=10'} - follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -2411,9 +2069,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} - fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -2424,11 +2079,6 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2484,18 +2134,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported - globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} - globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - globals@15.15.0: - resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} - engines: {node: '>=18'} - globals@16.5.0: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} @@ -2560,9 +2202,6 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - html-parse-stringify@3.0.1: - resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} - http-deceiver@1.2.7: resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} @@ -2671,12 +2310,6 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-arrayish@0.3.2: - resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} - is-async-function@2.0.0: resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} engines: {node: '>= 0.4'} @@ -2738,12 +2371,6 @@ packages: engines: {node: '>=14.16'} hasBin: true - is-lite@0.8.2: - resolution: {integrity: sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw==} - - is-lite@1.2.1: - resolution: {integrity: sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw==} - is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -2859,11 +2486,6 @@ packages: canvas: optional: true - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2889,9 +2511,6 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kolorist@1.8.0: - resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} - launch-editor@2.12.0: resolution: {integrity: sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==} @@ -2909,17 +2528,10 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - loader-runner@4.3.0: resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} engines: {node: '>=6.11.5'} - local-pkg@1.1.1: - resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==} - engines: {node: '>=14'} - locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2980,9 +2592,6 @@ packages: md5@2.3.0: resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} - mdn-data@2.12.2: - resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -3045,9 +2654,6 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} - mlly@1.7.4: - resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} - mock-xmlhttprequest@8.4.1: resolution: {integrity: sha512-2ORxRN+h40+3/Ylw9LKOtYGfQIoX6grGQlmbvMKqaeZ5/l7oeMvqdJxyG/ax3Poy7VbqMTADI6BwTmO7u10Wrw==} engines: {node: '>=16.0.0'} @@ -3096,9 +2702,6 @@ packages: engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead - node-fetch-native@1.6.4: - resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} - node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3117,13 +2720,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} - - number-precision@1.6.0: - resolution: {integrity: sha512-05OLPgbgmnixJw+VvEh18yNPUo3iyp4BEWJcrLu4X9W05KmMifN7Mu5exYvQXqxxeNWhvIF+j3Rij+HmddM/hQ==} - nwsapi@2.2.20: resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} @@ -3161,9 +2757,6 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - ofetch@1.4.1: - resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3203,9 +2796,6 @@ packages: resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==} engines: {node: '>=16.17'} - package-manager-detector@1.3.0: - resolution: {integrity: sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==} - pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -3213,10 +2803,6 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - parse5@7.2.1: resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} @@ -3256,9 +2842,6 @@ packages: resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} hasBin: true - perfect-debounce@1.0.0: - resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3270,46 +2853,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - pkg-types@2.2.0: - resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==} - - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} - engines: {node: '>=18'} - hasBin: true - - popper.js@1.16.1: - resolution: {integrity: sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==} - deprecated: You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1 - possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} - postcss-loader@8.2.0: - resolution: {integrity: sha512-tHX+RkpsXVcc7st4dSdDGliI+r4aAQDuv+v3vFYHixb6YgjreG5AG4SEB0kDK8u2s6htqEEpKlkhSBUTvWKYnA==} - engines: {node: '>= 18.12.0'} - peerDependencies: - '@rspack/core': 0.x || 1.x - postcss: ^7.0.0 || ^8.0.1 - webpack: ^5.0.0 - peerDependenciesMeta: - '@rspack/core': - optional: true - webpack: - optional: true - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -3352,12 +2899,6 @@ packages: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} - quansync@0.2.10: - resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} - - quansync@0.2.11: - resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -3375,102 +2916,17 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} - react-clientside-effect@1.2.6: - resolution: {integrity: sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==} - peerDependencies: - react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: react: ^18.3.1 - react-dropzone@14.3.8: - resolution: {integrity: sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==} - engines: {node: '>= 10.13'} - peerDependencies: - react: '>= 16.8 || 18.0.0' - - react-floater@0.7.9: - resolution: {integrity: sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg==} - peerDependencies: - react: 15 - 18 - react-dom: 15 - 18 - - react-focus-lock@2.13.2: - resolution: {integrity: sha512-T/7bsofxYqnod2xadvuwjGKHOoL5GH7/EIPI5UyEvaU/c2CcphvGI371opFtuY/SYdbMsNiuF4HsHQ50nA/TKQ==} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - react-i18next@15.6.0: - resolution: {integrity: sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw==} - peerDependencies: - i18next: '>= 23.2.3' - react: '>= 16.8.0' - react-dom: '*' - react-native: '*' - typescript: ^5 - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - typescript: - optional: true - - react-icons@5.5.0: - resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} - peerDependencies: - react: '*' - - react-innertext@1.1.5: - resolution: {integrity: sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==} - peerDependencies: - '@types/react': '>=0.0.0 <=99' - react: '>=0.0.0 <=99' - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - react-joyride@2.9.3: - resolution: {integrity: sha512-1+Mg34XK5zaqJ63eeBhqdbk7dlGCFp36FXwsEvgpjqrtyywX2C6h9vr3jgxP0bGHCw8Ilsp/nRDzNVq6HJ3rNw==} - peerDependencies: - react: 15 - 18 - react-dom: 15 - 18 - - react-router-dom@7.13.0: - resolution: {integrity: sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - - react-router@7.13.0: - resolution: {integrity: sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - peerDependenciesMeta: - react-dom: - optional: true - - react-transition-group@4.4.5: - resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} - peerDependencies: - react: '>=16.6.0' - react-dom: '>=16.6.0' - react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -3505,9 +2961,6 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - resize-observer-polyfill@1.5.1: - resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3581,15 +3034,6 @@ packages: resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==} engines: {node: '>= 10.13.0'} - scroll-into-view-if-needed@2.2.31: - resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==} - - scroll@3.0.1: - resolution: {integrity: sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==} - - scrollparent@2.1.0: - resolution: {integrity: sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==} - select-hose@2.0.0: resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} @@ -3621,9 +3065,6 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} - set-cookie-parser@2.7.1: - resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} - set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3642,9 +3083,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shallowequal@1.1.0: - resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3676,17 +3114,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - simple-swizzle@0.2.2: - resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} - sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} - sirv@3.0.2: - resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} - engines: {node: '>=18'} - sockjs@0.3.24: resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} @@ -3821,9 +3252,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.0.1: - resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} - tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -3863,12 +3291,6 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} - tree-changes@0.11.2: - resolution: {integrity: sha512-4gXlUthrl+RabZw6lLvcCDl6KfJOCmrC16BC5CRdut1EAH509Omgg0BfKLY+ViRlzrvYOTWR0FMS2SQTwzumrw==} - - tree-changes@0.9.3: - resolution: {integrity: sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ==} - tree-dump@1.1.0: resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} engines: {node: '>=10.0'} @@ -3895,9 +3317,6 @@ packages: '@swc/wasm': optional: true - tslib@2.8.0: - resolution: {integrity: sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3910,10 +3329,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@4.31.0: - resolution: {integrity: sha512-yCxltHW07Nkhv/1F6wWBr8kz+5BGMfP+RbRSYFnegVb0qV/UMT0G0ElBloPVerqn4M2ZV80Ir1FtCcYv1cT6vQ==} - engines: {node: '>=16'} - type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -3946,9 +3361,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - ufo@1.5.4: - resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} - uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -3958,32 +3370,13 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - unconfig@7.3.3: - resolution: {integrity: sha512-QCkQoOnJF8L107gxfHL0uavn7WD9b3dpBcFX6HtfQYmjw2YzWxGuFQ0N0J6tE9oguCBJn9KOvfqYDCMPHIZrBA==} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - unocss@66.5.4: - resolution: {integrity: sha512-yNajR8ADgvOzLhDkMKAXVE/SHM4sDrtVhhCnhBjiUMOR0LHIYO7cqunJJudbccrsfJbRTn/odSTBGu9f2IaXOg==} - engines: {node: '>=14'} - peerDependencies: - '@unocss/webpack': 66.5.4 - vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 - peerDependenciesMeta: - '@unocss/webpack': - optional: true - vite: - optional: true - unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unplugin-utils@0.3.1: - resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} - engines: {node: '>=20.19.0'} - update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -4000,26 +3393,6 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - use-callback-ref@1.3.2: - resolution: {integrity: sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - use-sidecar@1.1.2: - resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4116,13 +3489,6 @@ packages: jsdom: optional: true - void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} - engines: {node: '>=0.10.0'} - - vue-flow-layout@0.2.0: - resolution: {integrity: sha512-zKgsWWkXq0xrus7H4Mc+uFs1ESrmdTXlO0YNbR6wMdPaFvosL3fMB8N7uTV308UhGy9UvTrGhIY7mVz9eN+L0Q==} - w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -4296,45 +3662,13 @@ snapshots: '@adobe/css-tools@4.4.3': {} - '@antfu/install-pkg@1.1.0': + '@asamuzakjp/css-color@3.2.0': dependencies: - package-manager-detector: 1.3.0 - tinyexec: 1.0.1 - - '@antfu/utils@9.3.0': {} - - '@arco-design/color@0.4.0': - dependencies: - color: 3.2.1 - - '@arco-design/web-react@2.66.7(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@arco-design/color': 0.4.0 - '@babel/runtime': 7.27.6 - b-tween: 0.3.3 - b-validate: 1.5.3 - compute-scroll-into-view: 1.0.20 - dayjs: 1.11.13 - lodash: 4.17.21 - number-precision: 1.6.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-focus-lock: 2.13.2(@types/react@18.3.23)(react@18.3.1) - react-is: 18.3.1 - react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - resize-observer-polyfill: 1.5.1 - scroll-into-view-if-needed: 2.2.31 - shallowequal: 1.1.0 - transitivePeerDependencies: - - '@types/react' - - '@asamuzakjp/css-color@3.2.0': - dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 10.4.3 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 '@babel/code-frame@7.27.1': dependencies: @@ -4342,66 +3676,16 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/generator@7.28.5': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.27.1': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/parser@7.27.7': - dependencies: - '@babel/types': 7.28.0 - - '@babel/parser@7.28.0': - dependencies: - '@babel/types': 7.28.0 - - '@babel/parser@7.28.5': - dependencies: - '@babel/types': 7.28.5 - '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 '@babel/runtime@7.27.6': {} - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.0 - '@babel/types': 7.28.0 - - '@babel/traverse@7.27.7': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.0 - '@babel/template': 7.27.2 - '@babel/types': 7.28.0 - debug: 4.4.1 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.28.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -4439,38 +3723,6 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@dnd-kit/accessibility@3.1.1(react@18.3.1)': - dependencies: - react: 18.3.1 - tslib: 2.8.1 - - '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/accessibility': 3.1.1(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - tslib: 2.8.0 - - '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - tslib: 2.8.1 - - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@dnd-kit/utilities': 3.2.2(react@18.3.1) - react: 18.3.1 - tslib: 2.8.0 - - '@dnd-kit/utilities@3.2.2(react@18.3.1)': - dependencies: - react: 18.3.1 - tslib: 2.8.0 - '@emnapi/core@1.7.0': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -4693,10 +3945,6 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@gilbarbara/deep-equal@0.1.2': {} - - '@gilbarbara/deep-equal@0.3.1': {} - '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -4710,30 +3958,11 @@ snapshots: '@humanwhocodes/retry@0.4.2': {} - '@iconify/types@2.0.0': {} - - '@iconify/utils@3.0.2': - dependencies: - '@antfu/install-pkg': 1.1.0 - '@antfu/utils': 9.3.0 - '@iconify/types': 2.0.0 - debug: 4.4.1 - globals: 15.15.0 - kolorist: 1.8.0 - local-pkg: 1.1.1 - mlly: 1.7.4 - transitivePeerDependencies: - - supports-color - '@jridgewell/gen-mapping@0.3.12': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + optional: true '@jridgewell/resolve-uri@3.1.2': {} @@ -4745,11 +3974,6 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.29': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -4935,16 +4159,8 @@ snapshots: '@pkgr/core@0.2.7': {} - '@playwright/test@1.58.2': - dependencies: - playwright: 1.58.2 - '@polka/url@1.0.0-next.28': {} - '@quansync/fs@0.1.5': - dependencies: - quansync: 0.2.11 - '@rollup/rollup-android-arm-eabi@4.44.2': optional: true @@ -5381,155 +4597,6 @@ snapshots: '@typescript-eslint/types': 8.46.3 eslint-visitor-keys: 4.2.1 - '@unocss/astro@66.5.4(vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3))': - dependencies: - '@unocss/core': 66.5.4 - '@unocss/reset': 66.5.4 - '@unocss/vite': 66.5.4(vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3)) - optionalDependencies: - vite: 7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3) - - '@unocss/cli@66.5.4': - dependencies: - '@jridgewell/remapping': 2.3.5 - '@unocss/config': 66.5.4 - '@unocss/core': 66.5.4 - '@unocss/preset-uno': 66.5.4 - cac: 6.7.14 - chokidar: 3.6.0 - colorette: 2.0.20 - consola: 3.4.2 - magic-string: 0.30.21 - pathe: 2.0.3 - perfect-debounce: 1.0.0 - tinyglobby: 0.2.15 - unplugin-utils: 0.3.1 - - '@unocss/config@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - unconfig: 7.3.3 - - '@unocss/core@66.5.4': {} - - '@unocss/extractor-arbitrary-variants@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - - '@unocss/inspector@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - '@unocss/rule-utils': 66.5.4 - colorette: 2.0.20 - gzip-size: 6.0.0 - sirv: 3.0.2 - vue-flow-layout: 0.2.0 - - '@unocss/postcss@66.5.4(postcss@8.5.6)': - dependencies: - '@unocss/config': 66.5.4 - '@unocss/core': 66.5.4 - '@unocss/rule-utils': 66.5.4 - css-tree: 3.1.0 - postcss: 8.5.6 - tinyglobby: 0.2.15 - - '@unocss/preset-attributify@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - - '@unocss/preset-icons@66.5.4': - dependencies: - '@iconify/utils': 3.0.2 - '@unocss/core': 66.5.4 - ofetch: 1.4.1 - transitivePeerDependencies: - - supports-color - - '@unocss/preset-mini@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - '@unocss/extractor-arbitrary-variants': 66.5.4 - '@unocss/rule-utils': 66.5.4 - - '@unocss/preset-tagify@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - - '@unocss/preset-typography@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - '@unocss/rule-utils': 66.5.4 - - '@unocss/preset-uno@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - '@unocss/preset-wind3': 66.5.4 - - '@unocss/preset-web-fonts@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - ofetch: 1.4.1 - - '@unocss/preset-wind3@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - '@unocss/preset-mini': 66.5.4 - '@unocss/rule-utils': 66.5.4 - - '@unocss/preset-wind4@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - '@unocss/extractor-arbitrary-variants': 66.5.4 - '@unocss/rule-utils': 66.5.4 - - '@unocss/preset-wind@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - '@unocss/preset-wind3': 66.5.4 - - '@unocss/reset@66.5.4': {} - - '@unocss/rule-utils@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - magic-string: 0.30.21 - - '@unocss/transformer-attributify-jsx@66.5.4': - dependencies: - '@babel/parser': 7.27.7 - '@babel/traverse': 7.27.7 - '@unocss/core': 66.5.4 - transitivePeerDependencies: - - supports-color - - '@unocss/transformer-compile-class@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - - '@unocss/transformer-directives@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - '@unocss/rule-utils': 66.5.4 - css-tree: 3.1.0 - - '@unocss/transformer-variant-group@66.5.4': - dependencies: - '@unocss/core': 66.5.4 - - '@unocss/vite@66.5.4(vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3))': - dependencies: - '@jridgewell/remapping': 2.3.5 - '@unocss/config': 66.5.4 - '@unocss/core': 66.5.4 - '@unocss/inspector': 66.5.4 - chokidar: 3.6.0 - magic-string: 0.30.21 - pathe: 2.0.3 - tinyglobby: 0.2.15 - unplugin-utils: 0.3.1 - vite: 7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3) - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.16.0)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3))': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -5850,26 +4917,10 @@ snapshots: dependencies: lodash: 4.17.21 - attr-accept@2.2.5: {} - - autoprefixer@10.4.21(postcss@8.5.6): - dependencies: - browserslist: 4.25.1 - caniuse-lite: 1.0.30001727 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 - b-tween@0.3.3: {} - - b-validate@1.5.3: {} - balanced-match@1.0.2: {} base-64@1.0.0: {} @@ -5927,6 +4978,7 @@ snapshots: electron-to-chromium: 1.5.180 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) + optional: true buffer-crc32@0.2.13: {} @@ -5946,8 +4998,6 @@ snapshots: bytes@3.1.2: {} - cac@6.7.14: {} - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5972,7 +5022,8 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001727: {} + caniuse-lite@1.0.30001727: + optional: true chai@6.2.2: {} @@ -6000,28 +5051,12 @@ snapshots: chrome-trace-event@1.0.4: optional: true - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 - color-convert@2.0.1: dependencies: color-name: 1.1.4 - color-name@1.1.3: {} - color-name@1.1.4: {} - color-string@1.9.1: - dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.2 - - color@3.2.1: - dependencies: - color-convert: 1.9.3 - color-string: 1.9.1 - colorette@2.0.20: {} commander@2.20.3: {} @@ -6051,18 +5086,10 @@ snapshots: transitivePeerDependencies: - supports-color - compute-scroll-into-view@1.0.20: {} - concat-map@0.0.1: {} - confbox@0.1.8: {} - - confbox@0.2.2: {} - connect-history-api-fallback@2.0.0: {} - consola@3.4.2: {} - content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -6073,19 +5100,8 @@ snapshots: cookie@0.7.2: {} - cookie@1.0.2: {} - core-util-is@1.0.3: {} - cosmiconfig@9.0.0(typescript@5.9.3): - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - parse-json: 5.2.0 - optionalDependencies: - typescript: 5.9.3 - crc32-stream@3.0.1: dependencies: crc: 3.8.0 @@ -6124,11 +5140,6 @@ snapshots: crypto-js@4.2.0: {} - css-tree@3.1.0: - dependencies: - mdn-data: 2.12.2 - source-map-js: 1.2.1 - css.escape@1.5.1: {} cssstyle@4.6.0: @@ -6177,12 +5188,8 @@ snapshots: decimal.js@10.5.0: {} - deep-diff@1.0.2: {} - deep-is@0.1.4: {} - deepmerge@4.3.1: {} - default-browser-id@5.0.1: {} default-browser@5.5.0: @@ -6204,20 +5211,14 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - defu@6.1.4: {} - depd@1.1.2: {} depd@2.0.0: {} dequal@2.0.3: {} - destr@2.0.3: {} - destroy@1.2.0: {} - detect-node-es@1.1.0: {} - detect-node@2.1.0: {} dexie@4.0.10: {} @@ -6236,11 +5237,6 @@ snapshots: dom-accessibility-api@0.6.3: {} - dom-helpers@5.2.1: - dependencies: - '@babel/runtime': 7.27.6 - csstype: 3.1.3 - dompurify@3.3.3: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -6255,7 +5251,8 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.180: {} + electron-to-chromium@1.5.180: + optional: true encodeurl@2.0.0: {} @@ -6273,12 +5270,6 @@ snapshots: entities@6.0.1: {} - env-paths@2.2.1: {} - - error-ex@1.3.2: - dependencies: - is-arrayish: 0.2.1 - es-abstract@1.23.9: dependencies: array-buffer-byte-length: 1.0.2 @@ -6439,7 +5430,8 @@ snapshots: '@esbuild/win32-ia32': 0.25.5 '@esbuild/win32-x64': 0.25.5 - escalade@3.2.0: {} + escalade@3.2.0: + optional: true escape-html@1.0.3: {} @@ -6622,8 +5614,6 @@ snapshots: transitivePeerDependencies: - supports-color - exsolve@1.0.7: {} - fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -6673,10 +5663,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-selector@2.1.2: - dependencies: - tslib: 2.8.1 - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -6705,10 +5691,6 @@ snapshots: flatted@3.3.1: {} - focus-lock@1.3.5: - dependencies: - tslib: 2.8.1 - follow-redirects@1.15.11: {} for-each@0.3.3: @@ -6721,17 +5703,12 @@ snapshots: forwarded@0.2.0: {} - fraction.js@4.3.7: {} - fresh@0.5.2: {} fs-constants@1.0.0: {} fs.realpath@1.0.0: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -6814,12 +5791,8 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 - globals@11.12.0: {} - globals@14.0.0: {} - globals@15.15.0: {} - globals@16.5.0: {} globalthis@1.0.4: @@ -6876,10 +5849,6 @@ snapshots: html-escaper@2.0.2: {} - html-parse-stringify@3.0.1: - dependencies: - void-elements: 3.1.0 - http-deceiver@1.2.7: {} http-errors@1.8.1: @@ -6994,10 +5963,6 @@ snapshots: call-bound: 1.0.3 get-intrinsic: 1.2.7 - is-arrayish@0.2.1: {} - - is-arrayish@0.3.2: {} - is-async-function@2.0.0: dependencies: has-tostringtag: 1.0.2 @@ -7054,10 +6019,6 @@ snapshots: dependencies: is-docker: 3.0.0 - is-lite@0.8.2: {} - - is-lite@1.2.1: {} - is-map@2.0.3: {} is-network-error@1.3.0: {} @@ -7151,7 +6112,8 @@ snapshots: supports-color: 8.1.1 optional: true - jiti@2.6.1: {} + jiti@2.6.1: + optional: true js-tokens@10.0.0: {} @@ -7188,11 +6150,10 @@ snapshots: - supports-color - utf-8-validate - jsesc@3.1.0: {} - json-buffer@3.0.1: {} - json-parse-even-better-errors@2.3.1: {} + json-parse-even-better-errors@2.3.1: + optional: true json-schema-traverse@0.4.1: {} @@ -7218,8 +6179,6 @@ snapshots: dependencies: json-buffer: 3.0.1 - kolorist@1.8.0: {} - launch-editor@2.12.0: dependencies: picocolors: 1.1.1 @@ -7240,17 +6199,9 @@ snapshots: dependencies: immediate: 3.0.6 - lines-and-columns@1.2.4: {} - loader-runner@4.3.0: optional: true - local-pkg@1.1.1: - dependencies: - mlly: 1.7.4 - pkg-types: 2.2.0 - quansync: 0.2.10 - locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -7303,8 +6254,6 @@ snapshots: crypt: 0.0.2 is-buffer: 1.1.6 - mdn-data@2.12.2: {} - media-typer@0.3.0: {} memfs@4.56.10(tslib@2.8.1): @@ -7364,13 +6313,6 @@ snapshots: dependencies: brace-expansion: 2.0.2 - mlly@1.7.4: - dependencies: - acorn: 8.16.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.5.4 - mock-xmlhttprequest@8.4.1: {} monaco-editor@0.52.2: {} @@ -7401,8 +6343,6 @@ snapshots: node-domexception@1.0.0: {} - node-fetch-native@1.6.4: {} - node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 @@ -7411,7 +6351,8 @@ snapshots: node-forge@1.3.3: {} - node-releases@2.0.19: {} + node-releases@2.0.19: + optional: true node-rsa@1.1.1: dependencies: @@ -7419,10 +6360,6 @@ snapshots: normalize-path@3.0.0: {} - normalize-range@0.1.2: {} - - number-precision@1.6.0: {} - nwsapi@2.2.20: {} object-assign@4.1.1: {} @@ -7465,12 +6402,6 @@ snapshots: obug@2.1.1: {} - ofetch@1.4.1: - dependencies: - destr: 2.0.3 - node-fetch-native: 1.6.4 - ufo: 1.5.4 - on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -7519,21 +6450,12 @@ snapshots: is-network-error: 1.3.0 retry: 0.13.1 - package-manager-detector@1.3.0: {} - pako@1.0.11: {} parent-module@1.0.1: dependencies: callsites: 3.1.0 - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.27.1 - error-ex: 1.3.2 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - parse5@7.2.1: dependencies: entities: 4.5.0 @@ -7561,52 +6483,14 @@ snapshots: ieee754: 1.2.1 resolve-protobuf-schema: 2.1.0 - perfect-debounce@1.0.0: {} - picocolors@1.1.1: {} picomatch@2.3.1: {} picomatch@4.0.3: {} - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.7.4 - pathe: 2.0.3 - - pkg-types@2.2.0: - dependencies: - confbox: 0.2.2 - exsolve: 1.0.7 - pathe: 2.0.3 - - playwright-core@1.58.2: {} - - playwright@1.58.2: - dependencies: - playwright-core: 1.58.2 - optionalDependencies: - fsevents: 2.3.2 - - popper.js@1.16.1: {} - possible-typed-array-names@1.0.0: {} - postcss-loader@8.2.0(@rspack/core@1.7.6(@swc/helpers@0.5.17))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.96.1(uglify-js@3.19.3)): - dependencies: - cosmiconfig: 9.0.0(typescript@5.9.3) - jiti: 2.6.1 - postcss: 8.5.6 - semver: 7.7.2 - optionalDependencies: - '@rspack/core': 1.7.6(@swc/helpers@0.5.17) - webpack: 5.96.1(uglify-js@3.19.3) - transitivePeerDependencies: - - typescript - - postcss-value-parser@4.2.0: {} - postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -7648,10 +6532,6 @@ snapshots: dependencies: side-channel: 1.1.0 - quansync@0.2.10: {} - - quansync@0.2.11: {} - querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -7670,112 +6550,16 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - react-clientside-effect@1.2.6(react@18.3.1): - dependencies: - '@babel/runtime': 7.27.6 - react: 18.3.1 - react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 react: 18.3.1 scheduler: 0.23.2 - react-dropzone@14.3.8(react@18.3.1): - dependencies: - attr-accept: 2.2.5 - file-selector: 2.1.2 - prop-types: 15.8.1 - react: 18.3.1 - - react-floater@0.7.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - deepmerge: 4.3.1 - is-lite: 0.8.2 - popper.js: 1.16.1 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - tree-changes: 0.9.3 - - react-focus-lock@2.13.2(@types/react@18.3.23)(react@18.3.1): - dependencies: - '@babel/runtime': 7.27.6 - focus-lock: 1.3.5 - prop-types: 15.8.1 - react: 18.3.1 - react-clientside-effect: 1.2.6(react@18.3.1) - use-callback-ref: 1.3.2(@types/react@18.3.23)(react@18.3.1) - use-sidecar: 1.1.2(@types/react@18.3.23)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.23 - - react-i18next@15.6.0(i18next@23.16.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3): - dependencies: - '@babel/runtime': 7.27.6 - html-parse-stringify: 3.0.1 - i18next: 23.16.4 - react: 18.3.1 - optionalDependencies: - react-dom: 18.3.1(react@18.3.1) - typescript: 5.9.3 - - react-icons@5.5.0(react@18.3.1): - dependencies: - react: 18.3.1 - - react-innertext@1.1.5(@types/react@18.3.23)(react@18.3.1): - dependencies: - '@types/react': 18.3.23 - react: 18.3.1 - react-is@16.13.1: {} react-is@17.0.2: {} - react-is@18.3.1: {} - - react-joyride@2.9.3(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@gilbarbara/deep-equal': 0.3.1 - deep-diff: 1.0.2 - deepmerge: 4.3.1 - is-lite: 1.2.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-floater: 0.7.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-innertext: 1.1.5(@types/react@18.3.23)(react@18.3.1) - react-is: 16.13.1 - scroll: 3.0.1 - scrollparent: 2.1.0 - tree-changes: 0.11.2 - type-fest: 4.31.0 - transitivePeerDependencies: - - '@types/react' - - react-router-dom@7.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 7.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - - react-router@7.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - cookie: 1.0.2 - react: 18.3.1 - set-cookie-parser: 2.7.1 - optionalDependencies: - react-dom: 18.3.1(react@18.3.1) - - react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.27.6 - dom-helpers: 5.2.1 - loose-envify: 1.4.0 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -7827,8 +6611,6 @@ snapshots: requires-port@1.0.0: {} - resize-observer-polyfill@1.5.1: {} - resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: @@ -7929,14 +6711,6 @@ snapshots: ajv-formats: 2.1.1(ajv@8.17.1) ajv-keywords: 5.1.0(ajv@8.17.1) - scroll-into-view-if-needed@2.2.31: - dependencies: - compute-scroll-into-view: 1.0.20 - - scroll@3.0.1: {} - - scrollparent@2.1.0: {} - select-hose@2.0.0: {} selfsigned@2.4.1: @@ -7992,8 +6766,6 @@ snapshots: transitivePeerDependencies: - supports-color - set-cookie-parser@2.7.1: {} - set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -8020,8 +6792,6 @@ snapshots: setprototypeof@1.2.0: {} - shallowequal@1.1.0: {} - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -8060,22 +6830,12 @@ snapshots: siginfo@2.0.0: {} - simple-swizzle@0.2.2: - dependencies: - is-arrayish: 0.3.2 - sirv@2.0.4: dependencies: '@polka/url': 1.0.0-next.28 mrmime: 2.0.0 totalist: 3.0.1 - sirv@3.0.2: - dependencies: - '@polka/url': 1.0.0-next.28 - mrmime: 2.0.0 - totalist: 3.0.1 - sockjs@0.3.24: dependencies: faye-websocket: 0.11.4 @@ -8240,8 +7000,6 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.0.1: {} - tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -8273,16 +7031,6 @@ snapshots: dependencies: punycode: 2.3.1 - tree-changes@0.11.2: - dependencies: - '@gilbarbara/deep-equal': 0.3.1 - is-lite: 1.2.1 - - tree-changes@0.9.3: - dependencies: - '@gilbarbara/deep-equal': 0.1.2 - is-lite: 0.8.2 - tree-dump@1.1.0(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -8309,8 +7057,6 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - tslib@2.8.0: {} - tslib@2.8.1: {} tsx@4.19.2: @@ -8325,8 +7071,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@4.31.0: {} - type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -8378,8 +7122,6 @@ snapshots: typescript@5.9.3: {} - ufo@1.5.4: {} - uglify-js@3.19.3: {} unbox-primitive@1.1.0: @@ -8389,54 +7131,16 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - unconfig@7.3.3: - dependencies: - '@quansync/fs': 0.1.5 - defu: 6.1.4 - jiti: 2.6.1 - quansync: 0.2.11 - undici-types@6.21.0: {} - unocss@66.5.4(postcss@8.5.6)(vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3)): - dependencies: - '@unocss/astro': 66.5.4(vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3)) - '@unocss/cli': 66.5.4 - '@unocss/core': 66.5.4 - '@unocss/postcss': 66.5.4(postcss@8.5.6) - '@unocss/preset-attributify': 66.5.4 - '@unocss/preset-icons': 66.5.4 - '@unocss/preset-mini': 66.5.4 - '@unocss/preset-tagify': 66.5.4 - '@unocss/preset-typography': 66.5.4 - '@unocss/preset-uno': 66.5.4 - '@unocss/preset-web-fonts': 66.5.4 - '@unocss/preset-wind': 66.5.4 - '@unocss/preset-wind3': 66.5.4 - '@unocss/preset-wind4': 66.5.4 - '@unocss/transformer-attributify-jsx': 66.5.4 - '@unocss/transformer-compile-class': 66.5.4 - '@unocss/transformer-directives': 66.5.4 - '@unocss/transformer-variant-group': 66.5.4 - '@unocss/vite': 66.5.4(vite@7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3)) - optionalDependencies: - vite: 7.0.2(@types/node@22.16.0)(jiti@2.6.1)(terser@5.43.1)(tsx@4.19.2)(yaml@2.8.3) - transitivePeerDependencies: - - postcss - - supports-color - unpipe@1.0.0: {} - unplugin-utils@0.3.1: - dependencies: - pathe: 2.0.3 - picomatch: 4.0.3 - update-browserslist-db@1.1.3(browserslist@4.25.1): dependencies: browserslist: 4.25.1 escalade: 3.2.0 picocolors: 1.1.1 + optional: true uri-js@4.4.1: dependencies: @@ -8449,21 +7153,6 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 - use-callback-ref@1.3.2(@types/react@18.3.23)(react@18.3.1): - dependencies: - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.23 - - use-sidecar@1.1.2(@types/react@18.3.23)(react@18.3.1): - dependencies: - detect-node-es: 1.1.0 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.23 - util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -8530,10 +7219,6 @@ snapshots: - tsx - yaml - void-elements@3.1.0: {} - - vue-flow-layout@0.2.0: {} - w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/postcss.config.mjs b/postcss.config.mjs deleted file mode 100644 index ec8c02982..000000000 --- a/postcss.config.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import UnoCSS from "@unocss/postcss"; -import autoprefixer from "autoprefixer"; - -export default { - plugins: [UnoCSS(), autoprefixer()], -}; diff --git a/rspack.config.ts b/rspack.config.ts index 91969281c..9247ee0b6 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -55,12 +55,6 @@ export default { content: `${src}/content.ts`, scripting: `${src}/scripting.ts`, inject: `${src}/inject.ts`, - popup: `${src}/pages/popup/main.tsx`, - install: `${src}/pages/install/main.tsx`, - batchupdate: `${src}/pages/batchupdate/main.tsx`, - confirm: `${src}/pages/confirm/main.tsx`, - import: `${src}/pages/import/main.tsx`, - options: `${src}/pages/options/main.tsx`, "editor.worker": "monaco-editor/esm/vs/editor/editor.worker.js", "ts.worker": "monaco-editor/esm/vs/language/typescript/ts.worker.js", "linter.worker": `${src}/linter.worker.ts`, @@ -88,7 +82,6 @@ export default { { test: /\.css$/i, type: "css/auto", - use: ["postcss-loader"], }, { test: /\.(svg|png)$/, @@ -168,57 +161,6 @@ export default { }, ], }), - new rspack.HtmlRspackPlugin({ - filename: `${dist}/ext/src/install.html`, - template: `${src}/pages/template.html`, - inject: "head", - title: "Install - ScriptCat", - minify: true, - chunks: ["install"], - }), - new rspack.HtmlRspackPlugin({ - filename: `${dist}/ext/src/batchupdate.html`, - template: `${src}/pages/template.html`, - inject: "head", - title: "BatchUpdate - ScriptCat", - minify: true, - chunks: ["batchupdate"], - }), - new rspack.HtmlRspackPlugin({ - filename: `${dist}/ext/src/confirm.html`, - template: `${src}/pages/template.html`, - inject: "head", - title: "Confirm - ScriptCat", - minify: true, - chunks: ["confirm"], - }), - new rspack.HtmlRspackPlugin({ - filename: `${dist}/ext/src/import.html`, - template: `${src}/pages/template.html`, - inject: "head", - title: "Import - ScriptCat", - minify: true, - chunks: ["import"], - }), - new rspack.HtmlRspackPlugin({ - templateParameters: { - isReactTools: isReactTools ? "true" : "false", - }, - filename: `${dist}/ext/src/options.html`, - template: `${src}/pages/options.html`, - inject: "head", - title: "Home - ScriptCat", - minify: true, - chunks: ["options"], - }), - new rspack.HtmlRspackPlugin({ - filename: `${dist}/ext/src/popup.html`, - template: `${src}/pages/popup.html`, - inject: "head", - title: "Home - ScriptCat", - minify: true, - chunks: ["popup"], - }), new rspack.HtmlRspackPlugin({ filename: `${dist}/ext/src/offscreen.html`, template: `${src}/pages/offscreen.html`, @@ -319,31 +261,13 @@ export default { } if (module.type !== "css" && tag === "monaco-editor") return "lib_monaco"; switch (tag) { - case "react-icons": - if (p.includes("/react-icons/tb")) return undefined; - // eslint-disable-next-line no-fallthrough - case "react-dropzone": case "react-dom": - case "react-i18next": - case "react-router-dom": - case "react-joyride": case "react": return `lib_${tag}`; } - if (tag.startsWith("dnd-kit")) return "lib_dnd-kit"; - if (tag.startsWith("popper")) return "lib_react-joyride"; if (tag.startsWith("react-")) return "lib_react"; if (tag.startsWith("eslint")) return "lib_eslint"; if (tag.startsWith("i18n")) return "lib_i18n"; - if ( - tag.startsWith("arco-design") || - tag === "resize-observer-polyfill" || - tag === "b-validate" || - tag === "lodash" || - tag === "focus-lock" - ) { - return "lib_arco_design"; - } if (tag) { // cron, dayjs, yaml, jszip, prettier, ... if (tag === "luxon") return "lib_cron"; diff --git a/src/index.css b/src/index.css deleted file mode 100644 index 6c177117a..000000000 --- a/src/index.css +++ /dev/null @@ -1,37 +0,0 @@ -@unocss preflights; -@unocss default; -@unocss; - -body { - scrollbar-color: var(--color-scrollbar-thumb) var(--color-scrollbar-track); - /* 对于webkit浏览器的滚动条样式 */ - scrollbar-width: thin; -} - -body[arco-theme='dark'] { - --un-default-border-color: var(--color-border-2); - --color-scrollbar-thumb: #6b6b6b; - --color-scrollbar-track: #2d2d2d; - --color-scrollbar-thumb-hover: #8c8c8c; -} - -body[arco-theme='light'] { - --color-scrollbar-thumb: #6b6b6b; - --color-scrollbar-track: #f0f0f0; - --color-scrollbar-thumb-hover: #8c8c8c; -} - -:root { - --sc-zero-1: 0; - --sc-zero-2: 0; - --sc-zero-3: 0; - --sc-zero-4: 0; -} - -/* 自定义 .sc-inset-0 避免打包成 inset: 0 使旧浏览器布局错位 */ -.sc-inset-0 { - top: var(--sc-zero-1); - left: var(--sc-zero-2); - right: var(--sc-zero-3); - bottom: var(--sc-zero-4); -} diff --git a/src/locales/arco.ts b/src/locales/arco.ts deleted file mode 100644 index a97fdb3de..000000000 --- a/src/locales/arco.ts +++ /dev/null @@ -1,32 +0,0 @@ -import enUS from "@arco-design/web-react/es/locale/en-US"; -import zhCN from "@arco-design/web-react/es/locale/zh-CN"; -import zhTW from "@arco-design/web-react/es/locale/zh-TW"; -import jaJP from "@arco-design/web-react/es/locale/ja-JP"; -import deDE from "@arco-design/web-react/es/locale/de-DE"; -import viVN from "@arco-design/web-react/es/locale/vi-VN"; -import ruRU from "@arco-design/web-react/es/locale/ru-RU"; -import type { Locale } from "@arco-design/web-react/es/locale/interface"; - -export function arcoLocale(lang: string): Locale { - switch (lang) { - case "en-US": - return enUS; - case "zh-CN": - return zhCN; - case "zh-TW": - return zhTW; - case "ja-JP": - return jaJP; - case "de-DE": - // @ts-ignore - return deDE; - case "vi-VN": - // @ts-ignore - return viVN; - case "ru-RU": - // @ts-ignore - return ruRU; - default: - return enUS; - } -} diff --git a/src/locales/locales.ts b/src/locales/locales.ts index 4c9edccca..70fd2aff4 100644 --- a/src/locales/locales.ts +++ b/src/locales/locales.ts @@ -1,7 +1,6 @@ import type { SystemConfig } from "@App/pkg/config/config"; import type { Callback } from "i18next"; import i18n, { t } from "i18next"; -import { initReactI18next } from "react-i18next"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import type { SCMetadata } from "@App/app/repo/scripts"; @@ -38,7 +37,7 @@ export const initLocalesPromise = new Promise((resolve) => { }); export function initLanguage(lng: string = "en-US"): void { - i18n.use(initReactI18next).init({ + i18n.init({ fallbackLng: "en-US", lng: lng, // 优先使用localStorage中的语言设置 interpolation: { diff --git a/src/pages/batchupdate/App.tsx b/src/pages/batchupdate/App.tsx deleted file mode 100644 index 3d02fc5da..000000000 --- a/src/pages/batchupdate/App.tsx +++ /dev/null @@ -1,539 +0,0 @@ -import { useEffect, useState } from "react"; -import { - requestBatchUpdateListAction, - requestCheckScriptUpdate, - requestOpenUpdatePageByUUID, - scriptClient, -} from "../store/features/script"; - -import type { CollapseProps } from "@arco-design/web-react"; -import { Collapse, Card, Link, Divider, Grid, Tooltip, Typography, Tag, Space } from "@arco-design/web-react"; -import { useTranslation } from "react-i18next"; -import { - BatchUpdateListActionCode, - UpdateStatusCode, - type TBatchUpdateRecord, - type TBatchUpdateRecordObject, -} from "@App/app/service/service_worker/types"; -import { dayFormat } from "@App/pkg/utils/day_format"; -import { IconSync } from "@arco-design/web-react/icon"; -import { SCRIPT_STATUS_ENABLE } from "@App/app/repo/scripts"; -import { subscribeMessage } from "@App/pages/store/global"; - -const CollapseItem = Collapse.Item; -const { GridItem } = Grid; - -const { Text } = Typography; - -// pageExecute is to store subscribe function(s) globally -const pageExecute: Record void> = {}; - -function App() { - const AUTO_CLOSE_PAGE = 8; // after 8s, auto close - const getUrlParam = (key: string): string => { - return (location.search?.includes(`${key}=`) ? new URLSearchParams(location.search).get(`${key}`) : "") || ""; - }; - // unit: milisecond - const initialTimeForAutoClosePage = parseInt(getUrlParam("autoclose")) || AUTO_CLOSE_PAGE; - const paramSite = getUrlParam("site"); - const { t } = useTranslation(); - - const [mInitial, setInitial] = useState(false); - - const [mRecords, setRecords] = useState<{ - site: TBatchUpdateRecord[]; - other: TBatchUpdateRecord[]; - ignored: TBatchUpdateRecord[]; - } | null>(null); - - const [mStatusText, setStatusText] = useState(""); - - // unit: second - const [mTimeClose, setTimeClose] = useState(initialTimeForAutoClosePage); - useEffect(() => { - if (mTimeClose < 0) return; - if (mTimeClose === 0) { - window.close(); // 会切回到原网页 - return; - } - setTimeout(() => { - // 如 tab 在背景, 不倒数,等用户切回来 - requestAnimationFrame(() => { - setTimeClose((t) => (t >= 1 ? t - 1 : t)); - }); - }, 1000); - }, [mTimeClose]); - - const getBatchUpdateRecord = async (): Promise => { - let resultText = ""; - let r; - let i = 0; - while (true) { - r = await scriptClient.getBatchUpdateRecordLite(i++); - if (!r) break; - const chunk = r.chunk; - if (typeof chunk !== "string") break; - resultText += chunk; - if (r.ended) break; - } - return resultText ? JSON.parse(resultText) : null; - }; - - const updateRecord = () => { - getBatchUpdateRecord().then((batchUpdateRecordObjectLite) => { - const list = batchUpdateRecordObjectLite?.list || []; - const site = [] as TBatchUpdateRecord[]; - const other = [] as TBatchUpdateRecord[]; - const ignored = [] as TBatchUpdateRecord[]; - for (const entry of list) { - if (!entry.checkUpdate) { - site.push(entry); - continue; - } - const newVersion = entry.newMeta?.version?.[0]; - const isIgnored = typeof newVersion === "string" && entry.script.ignoreVersion === newVersion; - const mEntry = { - ...entry, - }; - - if (isIgnored) { - ignored.push(mEntry); - } else { - if (!paramSite || mEntry.sites?.includes(paramSite)) { - site.push(mEntry); - } else { - other.push(mEntry); - } - } - } - setRecords({ site, other, ignored }); - }); - }; - - const onScriptUpdateCheck = (data: any) => { - if ( - mRecords === null && - ((data.status ?? 0) & UpdateStatusCode.CHECKING_UPDATE) === 0 && - ((data.status ?? 0) & UpdateStatusCode.CHECKED_BEFORE) === UpdateStatusCode.CHECKED_BEFORE - ) { - setStatusText( - t("updatepage.status_last_check").replace("$0", data.checktime ? dayFormat(new Date(data.checktime)) : "") - ); - updateRecord(); - setCheckUpdateSpin(false); - } else if (((data.status ?? 0) & UpdateStatusCode.CHECKING_UPDATE) === UpdateStatusCode.CHECKING_UPDATE) { - setStatusText(t("updatepage.status_checking_updates")); - setRecords(null); - setCheckUpdateSpin(true); - } else if (mRecords !== null && data.refreshRecord === true) { - updateRecord(); - } - }; - - // 每次render会重新定义 pageExecute 的 onScriptUpdateCheck - pageExecute.onScriptUpdateCheck = onScriptUpdateCheck; - - // 只在第一次render执行 - const doInitial = () => { - // faster than useEffect - setInitial(true); - subscribeMessage("onScriptUpdateCheck", (msg) => pageExecute.onScriptUpdateCheck!(msg)); - scriptClient.fetchCheckUpdateStatus(); - scriptClient.sendUpdatePageOpened(); - }; - - mInitial === false && doInitial(); - - // const { t } = useTranslation(); - - const onUpdateClick = async (uuid: string) => { - if (!uuid) return; - setTimeClose(-1); // 用户操作,不再倒数,等用户按完用户自行关 - setIsDoingTask(true); - await requestBatchUpdateListAction({ - actionCode: BatchUpdateListActionCode.UPDATE, - actionPayload: [{ uuid }], - }); - setIsDoingTask(false); - }; - - const onIgnoreClick = async (uuid: string, ignoreVersion: string | undefined) => { - if (!ignoreVersion || !uuid) return; - setTimeClose(-1); // 用户操作,不再倒数,等用户按完用户自行关 - setIsDoingTask(true); - await requestBatchUpdateListAction({ - actionCode: BatchUpdateListActionCode.IGNORE, - actionPayload: [{ uuid, ignoreVersion }], - }); - setIsDoingTask(false); - }; - - const onUpdateAllClick = async (s: "site" | "other" | "ignored") => { - const data = (mRecords![s] || null) as TBatchUpdateRecord[] | null; - if (!data) { - console.error("No Data"); - return; - } - if (!data.length) { - console.error("Invalid Array"); - return; - } - const targets = data.filter((entry) => entry.checkUpdate); - const targetUUIDs = targets.map((entry) => entry.uuid); - setTimeClose(-1); // 用户操作,不再倒数,等用户按完用户自行关 - setIsDoingTask(true); - await requestBatchUpdateListAction({ - actionCode: BatchUpdateListActionCode.UPDATE, - actionPayload: targetUUIDs.map((uuid) => ({ uuid })), - }); - setIsDoingTask(false); - }; - - const onIgnoreAllClick = async (s: "site" | "other" | "ignored") => { - const data = (mRecords![s] || null) as TBatchUpdateRecord[] | null; - if (!data) { - console.error("No Data"); - return; - } - if (!data.length) { - console.error("Invalid Array"); - return; - } - const targets = data.filter((entry) => entry.checkUpdate && entry.uuid && entry.newMeta?.version?.[0]); - const payloadScripts = targets.map((entry) => ({ - uuid: entry.uuid, - ignoreVersion: entry.newMeta!.version[0], - })); - setTimeClose(-1); // 用户操作,不再倒数,等用户按完用户自行关 - setIsDoingTask(true); - await requestBatchUpdateListAction({ - actionCode: BatchUpdateListActionCode.IGNORE, - actionPayload: payloadScripts, - }); - setIsDoingTask(false); - }; - - const openUpdatePage = async (uuid: string) => { - setTimeClose(-1); // 用户操作,不再倒数,等用户按完用户自行关 - // this.openUpdatePage(script, "system"); - await requestOpenUpdatePageByUUID(uuid); - }; - - const onCheckUpdateClick = async () => { - if (checkUpdateSpin) return; - setTimeClose(-1); // 用户操作,不再倒数,等用户按完用户自行关 - setCheckUpdateSpin(true); - await requestCheckScriptUpdate({ checkType: "user" }); - setCheckUpdateSpin(false); - }; - - const getNewConnects = (oldConnects: string[] | undefined, newConnects: string[] | undefined) => { - oldConnects = oldConnects || ([] as string[]); - newConnects = newConnects || ([] as string[]); - const oldConnect = new Set(oldConnects || []); - const newConnect = new Set(newConnects || []); - const res = []; - // 老的里面没有新的就需要用户确认了 - for (const key of newConnect) { - if (!oldConnect.has(key)) { - res.push(key); - } - } - return res; - }; - - const makeGrids = (list: TBatchUpdateRecord[] | undefined) => { - return list?.length ? ( - - {list?.map( - (item, _index) => - item?.checkUpdate && ( - - openUpdatePage(item.uuid)} - className="tw-text-clickable tw-text-gray-900 dark:tw-text-gray-100 !hover:tw-text-blue-600 dark:hover:tw-text-blue-400" - > - - {item.script?.name} - - - } - hoverable - extra={ - <> - onUpdateClick(item.uuid)}> - {t("updatepage.update")} - - {typeof item.newMeta?.version?.[0] === "string" && - item.script.ignoreVersion !== item.newMeta.version[0] ? ( - <> - - onIgnoreClick(item.uuid, item.newMeta.version[0])} - > - {t("updatepage.ignore")} - - - ) : ( - <> - )} - - } - > - - - {t("updatepage.old_version_")} - {t("updatepage.new_version_")} - - - - - {item.script?.metadata?.version?.[0] || "N/A"} - - - {item.newMeta?.version?.[0] || "N/A"} - - - - -
- - {item.script.status === 1 ? ( - - - {t("updatepage.enabled")} - - - ) : item.script.status === 2 ? ( - - - {t("updatepage.disabled")} - - - ) : ( - <> - )} - {item.codeSimilarity < 0.75 ? ( - - {t("updatepage.codechange_major")} - - ) : item.codeSimilarity < 0.95 ? ( - - {t("updatepage.codechange_noticeable")} - - ) : ( - - {t("updatepage.codechange_tiny")} - - )} - {item.withNewConnect ? ( - - {t("updatepage.tag_new_connect")} - - ) : ( - <> - )} - -
-
- ) - )} -
- ) : ( - <> - ); - }; - - const [activeKey, setActiveKey] = useState([]); - const [isDoingTask, setIsDoingTask] = useState(false); - const [checkUpdateSpin, setCheckUpdateSpin] = useState(false); - const handleChange: CollapseProps["onChange"] = (_key, keys) => { - // `keys` is the current list of open panels - setActiveKey(keys); - }; - - useEffect(() => { - setActiveKey((prev: string[]) => { - const s = new Set(prev); - if (mRecords?.site?.length) { - s.add("list-current"); - } - if (mRecords?.other?.length) { - s.add("list-other"); - } - return [...s]; - }); - }, [mRecords]); - - return ( - <> - { -
-
- - {t("updatepage.main_header")} - - onCheckUpdateClick()} - className="tw-cursor-pointer tw-text-gray-700 dark:tw-text-gray-300" - /> -
-
- {mStatusText} -
- {mRecords === null ? ( - <> - ) : ( - <> - {mRecords.site.length === 0 && mRecords.other.length === 0 ? ( -
- {t("updatepage.status_no_update")} -
- ) : ( -
- - {t("updatepage.status_n_update").replace("$0", `${mRecords.site.length + mRecords.other.length}`)} - -
- )} - {mRecords.ignored.length === 0 ? ( - //
{"没有已忽略的更新"}
- <> - ) : ( -
- - {t("updatepage.status_n_ignored").replace("$0", `${mRecords.ignored.length}`)} - -
- )} - {mTimeClose >= 0 ? ( -
- - {t("updatepage.status_autoclose").replace("$0", `${mTimeClose}`)} - -
- ) : ( - <> - )} - - )} -
- } - {mRecords === null ? ( -
-
-
- ) : ( -
-
- - {mRecords.site.length === 0 && mRecords.other.length === 0 ? ( - <> - ) : ( - <> - - onUpdateAllClick("site")}> - {t("updatepage.update_all")} - - - onIgnoreAllClick("site")}> - {t("updatepage.ignore_all")} - - - ) : ( - <> - ) - } - > - {makeGrids(mRecords?.site)} - - - onUpdateAllClick("other")}> - {t("updatepage.update_all")} - - - onIgnoreAllClick("other")}> - {t("updatepage.ignore_all")} - - - ) : ( - <> - ) - } - > - {makeGrids(mRecords?.other)} - - - )} - - {mRecords.ignored.length === 0 ? ( - <> - ) : ( - <> - - onUpdateAllClick("ignored")}> - {t("updatepage.update_all")} - - - ) : ( - <> - ) - } - > - {makeGrids(mRecords?.ignored)} - - - )} - -
-
- )} - - ); -} - -export default App; diff --git a/src/pages/batchupdate/index.css b/src/pages/batchupdate/index.css deleted file mode 100644 index 6954b0a75..000000000 --- a/src/pages/batchupdate/index.css +++ /dev/null @@ -1,19 +0,0 @@ -.batchupdate-mainlayout { - min-height: max-content; - padding-top: 12px; - padding-bottom: 12px; -} - -body .text-clickable { - color: inherit; - cursor: pointer; -} - -body .text-clickable:hover { - color: inherit; - text-decoration: underline; -} - -.script-card.card-disabled { - filter: contrast(0.9); -} diff --git a/src/pages/batchupdate/main.tsx b/src/pages/batchupdate/main.tsx deleted file mode 100644 index f87ac8a8a..000000000 --- a/src/pages/batchupdate/main.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import App from "./App.tsx"; -import { AppProvider } from "../store/AppContext.tsx"; -import MainLayout from "../components/layout/MainLayout.tsx"; -import LoggerCore from "@App/app/logger/core.ts"; -import { message } from "../store/global.ts"; -import MessageWriter from "@App/app/logger/message_writer.ts"; -import "@arco-design/web-react/dist/css/arco.css"; -import "@App/locales/locales"; -import "@App/index.css"; -import "./index.css"; - -// 初始化日志组件 -const loggerCore = new LoggerCore({ - writer: new MessageWriter(message), - labels: { env: "batchupdate" }, -}); - -loggerCore.logger().debug("batchupdate page start"); - -const Root = ( - - - - - -); - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - process.env.NODE_ENV === "development" ? {Root} : Root -); diff --git a/src/pages/components/CloudScriptPlan/index.tsx b/src/pages/components/CloudScriptPlan/index.tsx deleted file mode 100644 index c74710b88..000000000 --- a/src/pages/components/CloudScriptPlan/index.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { DocumentationSite } from "@App/app/const"; -import type { Export, ExportTarget } from "@App/app/repo/export"; -import { ExportDAO } from "@App/app/repo/export"; -import type { Script } from "@App/app/repo/scripts"; -import { ScriptCodeDAO } from "@App/app/repo/scripts"; -import { localePath } from "@App/locales/locales"; -import { makeBlobURL } from "@App/pkg/utils/utils"; -import { Button, Checkbox, Form, Input, Message, Modal, Select } from "@arco-design/web-react"; -import { IconQuestionCircleFill } from "@arco-design/web-react/icon"; -import type { ExportParams } from "@Packages/cloudscript/cloudscript"; -import { parseExportCookie, parseExportValue } from "@Packages/cloudscript/cloudscript"; -import CloudScriptFactory from "@Packages/cloudscript/factory"; -import { createJSZip } from "@App/pkg/utils/jszip-x"; -import React, { useEffect } from "react"; -import { useTranslation } from "react-i18next"; - -const FormItem = Form.Item; - -function defaultParams(script: Script) { - return { - exportValue: script.metadata.exportvalue && script.metadata.exportvalue[0], - exportCookie: script.metadata.exportcookie && script.metadata.exportcookie[0], - }; -} - -const CloudScriptPlan: React.FC<{ - script?: Script; - onClose: () => void; -}> = ({ script, onClose }) => { - const [form] = Form.useForm(); - const [visible, setVisible] = React.useState(false); - const [cloudScriptType, setCloudScriptType] = React.useState("local"); - const [, setModel] = React.useState(); - const { t } = useTranslation(); - - const CloudScriptList = [ - { - key: "local", - name: t("local"), - }, - ]; - - useEffect(() => { - if (script) { - setVisible(true); - // 设置默认值 - // 从数据库中获取导出数据 - const dao = new ExportDAO(); - dao.findByScriptID(script.uuid).then((data) => { - setModel(data); - if (data && data.params[data.target]) { - setCloudScriptType(data.target); - form.setFieldsValue(data.params[data.target]); - } else { - setCloudScriptType("local"); - form.setFieldsValue(defaultParams(script)); - } - }); - } - }, [script]); - return ( - - - {script?.name} {t("upload_to_cloud")} - - - - - ); -}; - -export default CloudScriptPlan; diff --git a/src/pages/components/CodeEditor/index.tsx b/src/pages/components/CodeEditor/index.tsx deleted file mode 100644 index cc584d63d..000000000 --- a/src/pages/components/CodeEditor/index.tsx +++ /dev/null @@ -1,294 +0,0 @@ -import { editor, Range } from "monaco-editor"; -import React, { useEffect, useImperativeHandle, useRef, useState } from "react"; -import { systemConfig } from "@App/pages/store/global"; -import { LinterWorkerController, registerEditor } from "@App/pkg/utils/monaco-editor"; -import { fnPlaceHolder } from "@App/pages/store/AppContext"; - -fnPlaceHolder.setEditorTheme = (theme: string) => editor.setTheme(theme); - -type Props = { - className?: string; - diffCode?: string; // 因为代码加载是异步的,diifCode有3种状态:undefined不确定,""没有diff,有diff,不确定的情况下,编辑器不会加载 - editable?: boolean; - id: string; - code?: string; -}; - -type TMarker = { - code: { value: any }; - startLineNumber: any; - endLineNumber: any; - startColumn: any; - endColumn: any; - fix: any; -} & Record; - -type TFormattedMarker = { - startLineNumber: number; - endLineNumber: number; - severity: number; -} & Record; - -const CodeEditor = React.forwardRef<{ editor: editor.IStandaloneCodeEditor | undefined }, Props>( - ({ id, className, code, diffCode, editable }, ref) => { - const [monacoEditor, setEditor] = useState(); - const [enableEslint, setEnableEslint] = useState(false); - const [eslintConfig, setEslintConfig] = useState(""); - - const divRef = useRef(null); - useImperativeHandle(ref, () => ({ editor: monacoEditor })); - - // 注册 monaco 全局环境(只需执行一次) - useEffect(() => { - registerEditor(); - }, []); - - // 载入 ESLint 设定 - useEffect(() => { - Promise.all([systemConfig.getEslintConfig(), systemConfig.getEnableEslint()]).then(([config, enabled]) => { - setEslintConfig(config); - setEnableEslint(enabled); - }); - }, []); - - // 建立 monaco 编辑器实例 - useEffect(() => { - if (diffCode === undefined || code === undefined || !divRef.current) return; - - const container = document.getElementById(id) as HTMLDivElement; - let edit: editor.IStandaloneCodeEditor | editor.IStandaloneDiffEditor; - - const commonEditorOptions = { - folding: true, - foldingStrategy: "indentation", - automaticLayout: true, - scrollbar: { alwaysConsumeMouseWheel: false }, - overviewRulerBorder: false, - scrollBeyondLastLine: false, - - glyphMargin: true, - unicodeHighlight: { - ambiguousCharacters: false, - }, - - // https://code.visualstudio.com/docs/editing/intellisense - - // Controls whether suggestions should be accepted on commit characters. For example, in JavaScript, the semi-colon (`;`) can be a commit character that accepts a suggestion and types that character. - acceptSuggestionOnCommitCharacter: true, - - // Controls if suggestions should be accepted on 'Enter' - in addition to 'Tab'. Helps to avoid ambiguity between inserting new lines or accepting suggestions. The value 'smart' means only accept a suggestion with Enter when it makes a textual change - acceptSuggestionOnEnter: "on", - - // Controls the delay in ms after which quick suggestions will show up. - quickSuggestionsDelay: 10, - - // Controls if suggestions should automatically show up when typing trigger characters - suggestOnTriggerCharacters: true, - - // Controls if pressing tab inserts the best suggestion and if tab cycles through other suggestions - tabCompletion: "off", - - // Controls whether sorting favours words that appear close to the cursor - suggest: { - localityBonus: true, - preview: true, - }, - - // Controls how suggestions are pre-selected when showing the suggest list - suggestSelection: "first", - - // Enable word based suggestions - wordBasedSuggestions: "off", - - // Enable parameter hints - parameterHints: { - enabled: true, - }, - - // https://qiita.com/H-goto16/items/43802950fc5c112c316b - // https://zenn.dev/udonj/articles/ultimate-vscode-customization-2024 - // https://github.com/is0383kk/VSCode - - quickSuggestions: { - other: true, - comments: true, - strings: true, - }, - - fastScrollSensitivity: 10, - smoothScrolling: true, - inlineSuggest: { - enabled: true, - }, - guides: { - indentation: true, - }, - renderLineHighlightOnlyWhenFocus: true, - snippetSuggestions: "top", - - cursorBlinking: "phase", - cursorSmoothCaretAnimation: "off", - - autoIndent: "advanced", - wrappingIndent: "indent", - wordSegmenterLocales: ["ja", "zh-CN", "zh-Hant-TW"] as string[], - - renderLineHighlight: "gutter", - renderWhitespace: "selection", - renderControlCharacters: true, - dragAndDrop: false, - emptySelectionClipboard: false, - copyWithSyntaxHighlighting: false, - bracketPairColorization: { - enabled: true, - }, - mouseWheelZoom: true, - links: true, - accessibilitySupport: "off", - largeFileOptimizations: true, - colorDecorators: true, - } as const; - - if (diffCode) { - edit = editor.createDiffEditor(container, { - hideUnchangedRegions: { enabled: true }, - enableSplitViewResizing: false, - renderSideBySide: false, - readOnly: true, - diffWordWrap: "off", - ...commonEditorOptions, - }); - edit.setModel({ - original: editor.createModel(diffCode, "javascript"), - modified: editor.createModel(code, "javascript"), - }); - } else { - edit = editor.create(container, { - language: "javascript", - theme: document.body.getAttribute("arco-theme") === "dark" ? "vs-dark" : "vs", - readOnly: !editable, - ...commonEditorOptions, - }); - edit.setValue(code); - setEditor(edit); - } - - return () => { - // 目前会出现:Uncaught (in promise) Canceled: Canceled - // 问题追踪:https://github.com/microsoft/monaco-editor/issues/4702 - edit?.dispose(); - }; - }, [id, code, diffCode, editable]); - - // ESLint 即时检查逻辑 - useEffect(() => { - if (!enableEslint || !monacoEditor) return; - - const model = monacoEditor.getModel(); - if (!model) return; - - let timer: ReturnType | null = null; - - const lint = () => { - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - timer = null; - LinterWorkerController.sendLinterMessage({ - code: model.getValue(), - id, - config: JSON.parse(eslintConfig), - }); - }, 500); - }; - // 加载完成就检测一次 - lint(); // 初次载入即检查 - const changeListener = model.onDidChangeContent(lint); - - // 在 glyph margin (行号旁) 显示EsLint错误/警告图示 - const showGlyphIcons = (markers: { startLineNumber: number; endLineNumber: number; severity: number }[]) => { - const glyphMarginClassList = { 4: "icon-warn", 8: "icon-error" }; - - // 清除旧装饰 - const oldDecorations = model - .getAllDecorations() - .filter( - (d) => - d.options.glyphMarginClassName && - Object.values(glyphMarginClassList).includes(d.options.glyphMarginClassName!) - ); - monacoEditor.removeDecorations(oldDecorations.map((d) => d.id)); - - // (重新)添加新装饰 - Decorations - monacoEditor.createDecorationsCollection( - markers.map(({ startLineNumber, endLineNumber, severity }) => ({ - range: new Range(startLineNumber, 1, endLineNumber, 1), - options: { - isWholeLine: true, - glyphMarginClassName: glyphMarginClassList[severity as 4 | 8], - /* 待改进 目前monaco似乎无法满足需求 - glyphMarginHoverMessage: allMarkers.reduce( - (prev: any, next: any) => { - if ( - next.startLineNumber === startLineNumber && - next.endLineNumber === endLineNumber - ) { - prev.push({ - value: `${next.message} ESLinter [(${next.code.value})](${next.code.target})`, - isTrusted: true, - }); - } - return prev; - }, - [] - ), - */ - }, - })) - ); - }; - - const messageHandler = (message: any) => { - if (id !== message.id) return; - - editor.setModelMarkers(model, "ESLint", message.markers); - - // 更新 eslint-fix 快取(每次替换整个 map,避免已修复问题的过期条目残留) - const eslintFixMap = (window.MonacoEnvironment as any)?.eslintFixMap; - if (eslintFixMap) { - eslintFixMap.clear(); - message.markers.forEach((m: TMarker) => { - if (m.fix) { - const key = `${m.code.value}|${m.startLineNumber}|${m.endLineNumber}|${m.startColumn}|${m.endColumn}`; - eslintFixMap.set(key, m.fix); - } - }); - } - - // 显示 glyph 图示 (在行号旁显示ESLint错误/警告图标) - const formatted = message.markers.map((m: TFormattedMarker) => ({ - startLineNumber: m.startLineNumber, - endLineNumber: m.endLineNumber, - severity: m.severity, - })); - showGlyphIcons(formatted); - }; - - LinterWorkerController.hookAddListener("message", messageHandler); - - return () => { - if (timer) { - clearTimeout(timer); - timer = null; - } - changeListener.dispose(); - LinterWorkerController.hookRemoveListener("message", messageHandler); - }; - }, [monacoEditor, enableEslint, eslintConfig, id]); - - return
; - } -); - -CodeEditor.displayName = "CodeEditor"; - -export default CodeEditor; diff --git a/src/pages/components/CustomLink/index.tsx b/src/pages/components/CustomLink/index.tsx deleted file mode 100644 index 85b0af973..000000000 --- a/src/pages/components/CustomLink/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { ReactNode } from "react"; -import React from "react"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; - -const CustomLink: React.FC<{ - children: ReactNode; - to: string; - className?: string; - search?: string; -}> = ({ children, to, search, className }) => { - const nav = useNavigate(); - const { t } = useTranslation(); - - const click = () => { - if (window.onbeforeunload) { - // 目前仅用于 ScriptEditor 编辑内容修改提示 - if (confirm(t("script_modified_leave_confirm"))) { - nav({ - pathname: to, - search, - }); - } - } else { - nav({ - pathname: to, - search, - }); - } - }; - - return ( -
- {children} -
- ); -}; - -export default CustomLink; diff --git a/src/pages/components/CustomTrans/index.tsx b/src/pages/components/CustomTrans/index.tsx deleted file mode 100644 index c0d9c28cb..000000000 --- a/src/pages/components/CustomTrans/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Link } from "@arco-design/web-react"; -import React from "react"; -import { useTranslation } from "react-i18next"; - -// 因为i18n的Trans组件打包后出现问题,所以自己实现一个 -export const CustomTrans: React.FC<{ - className?: string; - i18nKey: string; -}> = ({ className, i18nKey }) => { - const { t } = useTranslation(); - const children: (JSX.Element | string)[] = []; - let content = t(i18nKey); - for (;;) { - const i = content.indexOf("<"); - if (i !== -1) { - children.push(content.substring(0, i)); - const end = content.indexOf(">", i); - const key = content.substring(i + 1, end).split(" ")[0]; - const tag = content.substring(i, end + 1); - const tagEnd = content.indexOf(``, end); - const element = content.substring(end + 1, content.indexOf(``, end)); - switch (key) { - case "Link": - // eslint-disable-next-line no-case-declarations - const href = tag.match(/href="(.*)"/)![1]; - children.push( - - {element} - - ); - break; - default: - children.push(element); - break; - } - content = content.substring(tagEnd + key.length + 3); - } else { - children.push(content); - break; - } - } - - return
{children}
; -}; - -export default CustomTrans; diff --git a/src/pages/components/FileSystemParams/index.tsx b/src/pages/components/FileSystemParams/index.tsx deleted file mode 100644 index 2cec8cfb9..000000000 --- a/src/pages/components/FileSystemParams/index.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Button, Input, Message, Popconfirm, Select, Space } from "@arco-design/web-react"; -import type { FileSystemType } from "@Packages/filesystem/factory"; -import FileSystemFactory from "@Packages/filesystem/factory"; -import { useTranslation } from "react-i18next"; -import { ClearNetDiskToken, HasNetDiskToken, netDiskTypeMap } from "@Packages/filesystem/auth"; - -const FileSystemParams: React.FC<{ - headerContent: React.ReactNode | string; - onChangeFileSystemType: (type: FileSystemType) => void; - onChangeFileSystemParams: (params: any) => void; - children: React.ReactNode; - fileSystemType: FileSystemType; - fileSystemParams: any; -}> = ({ - onChangeFileSystemType, - onChangeFileSystemParams, - headerContent, - children, - fileSystemType, - fileSystemParams, -}) => { - const fsParams = FileSystemFactory.params(); - const { t } = useTranslation(); - const [hasBoundToken, setHasBoundToken] = useState(false); - - const netDiskType = netDiskTypeMap[fileSystemType]; - - useEffect(() => { - if (!netDiskType) { - setHasBoundToken(false); - return; - } - HasNetDiskToken(netDiskType).then(setHasBoundToken); - }, [netDiskType]); - - const fileSystemList: { - key: FileSystemType; - name: string; - }[] = [ - { - key: "webdav", - name: "WebDAV", - }, - { - key: "baidu-netdsik", - name: t("baidu_netdisk"), - }, - { - key: "onedrive", - name: "OneDrive", - }, - { - key: "googledrive", - name: "Google Drive", - }, - { - key: "dropbox", - name: "Dropbox", - }, - { - key: "s3", - name: "Amazon S3", - }, - ]; - - const netDiskName = netDiskType ? fileSystemList.find((item) => item.key === fileSystemType)?.name : null; - const fsParam = fsParams[fileSystemType]; - - return ( - <> - - {headerContent} - - {children} - {netDiskType && netDiskName && hasBoundToken && ( - { - try { - await ClearNetDiskToken(netDiskType); - setHasBoundToken(false); - Message.success(t("netdisk_unbind_success", { provider: netDiskName })!); - } catch (error) { - Message.error(`${t("netdisk_unbind_error", { provider: netDiskName })}: ${String(error)}`); - } - }} - > - - - )} - - - {Object.keys(fsParam).map((key) => { - const props = fsParam[key]; - const selectAuth = fsParam?.authType?.options?.[0]; // webDAV - if (selectAuth && props?.visibilityFor?.includes(fileSystemParams?.authType || selectAuth) === false) { - return null; - } - return ( -
- {props.type === "select" && ( - <> - {props.title} - - - )} - {props.type === "password" && ( - <> - {props.title} - { - onChangeFileSystemParams({ - ...fileSystemParams, - [key]: value, - }); - }} - /> - - )} - {!props.type && ( - <> - {props.title} - { - onChangeFileSystemParams({ - ...fileSystemParams, - [key]: value, - }); - }} - /> - - )} -
- ); - })} -
- - ); -}; - -export default FileSystemParams; diff --git a/src/pages/components/LogLabel/index.css b/src/pages/components/LogLabel/index.css deleted file mode 100644 index 4fbd7a590..000000000 --- a/src/pages/components/LogLabel/index.css +++ /dev/null @@ -1,26 +0,0 @@ -.log-query-label .arco-select-view { - border-radius: 0; -} - -.log-query-label .arco-select:first-child { - border-left: 1px solid var(--color-neutral-3); -} - -.log-query-label .arco-select { - width: auto; - border-top: 1px solid var(--color-neutral-3); - border-bottom: 1px solid var(--color-neutral-3); -} - -.log-query-label .arco-btn { - height: 34px; - border-left: 0; - border-radius: 0; - border-top: 1px solid var(--color-neutral-3); - border-bottom: 1px solid var(--color-neutral-3); - border-right: 1px solid var(--color-neutral-3); -} - -.log-query-label .arco-select { - border-right: 1px solid var(--color-neutral-3); -} diff --git a/src/pages/components/LogLabel/index.tsx b/src/pages/components/LogLabel/index.tsx deleted file mode 100644 index e39615e0f..000000000 --- a/src/pages/components/LogLabel/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Button, Select } from "@arco-design/web-react"; -import { IconClose } from "@arco-design/web-react/icon"; -import React from "react"; -import "./index.css"; - -export type Query = { - key: string; - condition: "=" | "=~" | "!=" | "!~"; - value: string; -}; - -export type Labels = { - [key: string]: { [key: string | number]: boolean }; -}; - -const LogLabel: React.FC<{ - value: Query; - labels: Labels; - onChange: (value: Query) => void; - onClose: () => void; -}> = ({ value, labels, onChange, onClose }) => { - const values = labels[value.key] || {}; - return ( -
- - - -
- ); -}; - -export default LogLabel; diff --git a/src/pages/components/PopupWarnings/index.tsx b/src/pages/components/PopupWarnings/index.tsx deleted file mode 100644 index 8ff441a57..000000000 --- a/src/pages/components/PopupWarnings/index.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { Alert, Button } from "@arco-design/web-react"; -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { checkUserScriptsAvailable, getBrowserType, BrowserType, isPermissionOk } from "@App/pkg/utils/utils"; -import edgeMobileQrCode from "@App/assets/images/edge_mobile_qrcode.png"; - -interface PopupWarningsProps { - isBlacklist: boolean; -} - -function PopupWarnings({ isBlacklist }: PopupWarningsProps) { - const { t } = useTranslation(); - const [isUserScriptsAvailableState, setIsUserScriptsAvailableState] = useState(null); - const [showRequestButton, setShowRequestButton] = useState(false); - const [permissionReqResult, setPermissionReqResult] = useState(""); - - const updateIsUserScriptsAvailableState = async () => { - const badgeText = await chrome.action.getBadgeText({}); - let displayState; - if (badgeText === "!") { - // 要求用户重启扩展/浏览器,会重置badge状态的 - displayState = false; - } else { - displayState = await checkUserScriptsAvailable(); - } - setIsUserScriptsAvailableState(displayState); - }; - - useEffect(() => { - updateIsUserScriptsAvailableState(); - }, []); - - const warningMessageHTML = useMemo(() => { - if (isUserScriptsAvailableState === null) return ""; - // 可使用UserScript的话,不查browserType - const browserType = !isUserScriptsAvailableState ? getBrowserType() : null; - - if (!browserType) return ""; - - const browser = browserType.chrome & BrowserType.Edge ? "edge" : "chrome"; - - let warningMessageHTML; - - if (browserType.firefox) { - // firefox - warningMessageHTML = t("develop_mode_guide", { browser: "firefox" }); - } else if (browserType.chrome) { - // chrome - warningMessageHTML = - browserType.chrome & BrowserType.noUserScriptsAPI - ? t("lower_version_browser_guide") - : // 120+ - browserType.chrome & BrowserType.guardedByDeveloperMode - ? t("develop_mode_guide", { browser }) // Edge浏览器目前没有允许用户脚本选项,开启开发者模式即可 - : // Edge 144+ / Chrome 138+ - browserType.chrome & BrowserType.guardedByAllowScript - ? t("allow_user_script_guide", { browser }) // Edge 144+ 后使用`允许用户脚本`控制 - : // 用于日后扩充更新版本 - "UNKNOWN"; - } else { - // other browsers - warningMessageHTML = "UNKNOWN"; - } - - return warningMessageHTML; - }, [isUserScriptsAvailableState, t]); - - const isEdgeBrowser = useMemo(() => { - const browserType = getBrowserType(); - return ( - localStorage["hideEdgeMobileQrCodeAlert"] !== "1" && (browserType && browserType.chrome & BrowserType.Edge) > 0 - ); - }, []); - - // 权限要求详见:https://github.com/mdn/webextensions-examples/blob/main/userScripts-mv3/options.mjs - useEffect(() => { - isPermissionOk("userScripts").then((permissionOK) => { - if (permissionOK === false) { - // 假设browser能支持 `chrome.permissions.contains` 及在 callback返回一个false值的话, - // chrome.permissions.request 应该可以执行 - // 因此在这里显示按钮 - setShowRequestButton(true); - } - }); - }, []); - - const handleRequestPermission = () => { - const updateOnPermissionGranted = async (granted: boolean) => { - if (granted) { - granted = await new Promise((resolve) => { - chrome.runtime.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" }, (resp) => { - const lastError = chrome.runtime.lastError; - if (lastError) { - resp = false; - console.error("chrome.runtime.lastError in chrome.permissions.request:", lastError.message); - } - resolve(resp === true); - }); - }); - } - if (granted) { - setPermissionReqResult("✅"); - // UserScripts API相关的初始化: - // userScripts.LISTEN_CONNECTIONS 进行 Server 通讯初始化 - // onUserScriptAPIGrantAdded 进行 脚本注册 - updateIsUserScriptsAvailableState(); - } else { - setPermissionReqResult("❎"); - } - }; - chrome.permissions.request({ permissions: ["userScripts"] }, (granted) => { - const lastError = chrome.runtime.lastError; - if (lastError) { - granted = false; - console.error("chrome.runtime.lastError in chrome.permissions.request:", lastError.message); - } - updateOnPermissionGranted(granted); - }); - }; - - return ( - <> - {warningMessageHTML && ( - - - } - /> - )} - {isEdgeBrowser && ( - { - localStorage["hideEdgeMobileQrCodeAlert"] = "1"; - }} - content={ -
-
-
{"在手机上使用脚本猫"}
-
{"扫描二维码在手机上安装脚本猫"}
-
- qrcode -
- } - /> - )} - {showRequestButton && ( - - )} - {isBlacklist && } - - ); -} - -export default PopupWarnings; diff --git a/src/pages/components/RuntimeSetting/index.tsx b/src/pages/components/RuntimeSetting/index.tsx deleted file mode 100644 index db5a72201..000000000 --- a/src/pages/components/RuntimeSetting/index.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Button, Card, Collapse, Link, Message, Space, Switch, Typography } from "@arco-design/web-react"; -import { useTranslation } from "react-i18next"; -import FileSystemParams from "../FileSystemParams"; -import { systemConfig } from "@App/pages/store/global"; -import type { FileSystemType } from "@Packages/filesystem/factory"; -import FileSystemFactory from "@Packages/filesystem/factory"; -import { isPermissionOk, isFirefox } from "@App/pkg/utils/utils"; - -const CollapseItem = Collapse.Item; - -const RuntimeSetting: React.FC = () => { - const [status, setStatus] = useState("unset"); - const [fileSystemType, setFilesystemType] = useState("webdav"); - const [fileSystemParams, setFilesystemParam] = useState<{ - [key: string]: any; - }>({}); - // 开启后台运行 - const [enableBackground, setEnableBackgroundState] = useState(false); - const { t } = useTranslation(); - - useEffect(() => { - systemConfig.getCatFileStorage().then((res) => { - setStatus(res.status); - setFilesystemType(res.filesystem); - setFilesystemParam(res.params[res.filesystem] || {}); - }); - if (isFirefox()) { - // no background permission - } else { - isPermissionOk("background").then((result) => { - if (result === null) return; // 无法要求 background permission - setEnableBackgroundState(result); - }); - } - }, []); - - const setEnableBackground = (enable: boolean) => { - if (isFirefox()) { - // no background permission - } else { - if (enable) { - chrome.permissions.request({ permissions: ["background"] }, (granted) => { - if (chrome.runtime.lastError) { - console.error(chrome.runtime.lastError); - Message.error(t("enable_background.enable_failed")!); - return; - } - setEnableBackgroundState(granted); - }); - } else { - chrome.permissions.remove({ permissions: ["background"] }, (removed) => { - if (chrome.runtime.lastError) { - console.error(chrome.runtime.lastError); - Message.error(t("enable_background.disable_failed")!); - return; - } - if (removed) { - // 成功移除 background 权限,明确关闭开关 - setEnableBackgroundState(false); - } else { - // 未成功移除时再次校验权限状态,以真实权限为准更新 UI - isPermissionOk("background").then((result) => { - if (result === null) return; - setEnableBackgroundState(result); - }); - } - }); - } - } - }; - - return ( - - -
- {!isFirefox() && ( -
- - { - setEnableBackground(!enableBackground); - }} - > - {t("enable_background.title")} - -
- )} - {!isFirefox() && ( - {t("enable_background.description")} - )} -
- - - - - {t("settings")} - - {"CAT_fileStorage"} - - {t("use_file_system")} - - } - fileSystemType={fileSystemType} - fileSystemParams={fileSystemParams} - onChangeFileSystemType={(type) => { - setFilesystemType(type); - }} - onChangeFileSystemParams={(params) => { - setFilesystemParam(params); - }} - > - - - - - {status === "unset" && {t("not_set")}} - {status === "success" && {t("in_use")}} - {status === "error" && {t("storage_error")}} - - - -
-
- ); -}; - -export default RuntimeSetting; diff --git a/src/pages/components/ScriptMenuList/index.tsx b/src/pages/components/ScriptMenuList/index.tsx deleted file mode 100644 index 27448deb7..000000000 --- a/src/pages/components/ScriptMenuList/index.tsx +++ /dev/null @@ -1,523 +0,0 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { - Button, - Collapse, - Empty, - Form, - Input, - InputNumber, - Message, - Popconfirm, - Space, - Switch, -} from "@arco-design/web-react"; -import { - IconCaretDown, - IconCaretUp, - IconDelete, - IconEdit, - IconMenu, - IconMinus, - IconPlus, - IconSettings, -} from "@arco-design/web-react/icon"; -import type { SCMetadata } from "@App/app/repo/scripts"; -import { SCRIPT_RUN_STATUS_RUNNING, ScriptDAO } from "@App/app/repo/scripts"; -import { RiPlayFill, RiStopFill } from "react-icons/ri"; -import { useTranslation } from "react-i18next"; -import { ScriptIcons } from "@App/pages/options/routes/utils"; -import type { - GroupScriptMenuItem, - ScriptMenu, - ScriptMenuItem, - ScriptMenuItemOption, -} from "@App/app/service/service_worker/types"; -import { popupClient, runtimeClient, scriptClient } from "@App/pages/store/features/script"; -import { i18nName } from "@App/locales/locales"; - -// 用于读取 metadata -const scriptDAO = new ScriptDAO(); - -const CollapseItem = Collapse.Item; - -const sendMenuAction = ( - uuid: string, - options: ScriptMenuItemOption | undefined, - menus: ScriptMenuItem[], - inputValue?: any -) => { - popupClient.menuClick(uuid, menus, inputValue).then(() => { - options?.autoClose !== false && window.close(); - }); -}; - -const FormItem = Form.Item; - -type MenuItemProps = { - menuItems: ScriptMenuItem[]; - uuid: string; -}; - -type GroupScriptMenuItemsProp = { group: GroupScriptMenuItem[]; menuUpdated: number }; - -const MenuItem = React.memo(({ menuItems, uuid }: MenuItemProps) => { - const menuItem = menuItems[0]; - const { name, options } = menuItem; - const initialValue = options?.inputDefaultValue; - - const InputMenu = (() => { - const placeholder = options?.inputPlaceholder; - - switch (options?.inputType) { - case "text": - return ; - case "number": - return ; - case "boolean": - return ; - default: - return null; - } - })(); - - return ( -
{ - const inputValue = v.inputValue; - sendMenuAction(uuid, options, menuItems, inputValue); - }} - > - - {InputMenu && ( - - {InputMenu} - - )} -
- ); -}); -MenuItem.displayName = "MenuItem"; - -interface CollapseHeaderProps { - item: ScriptMenuEntry; - onEnableChange: (item: ScriptMenuEntry, checked: boolean) => void; -} - -const CollapseHeader = React.memo( - ({ item, onEnableChange }: CollapseHeaderProps) => { - const { t } = useTranslation(); - - return ( -
{ - e.stopPropagation(); - }} - title={ - item.enable - ? item.runNumByIframe - ? t("script_total_runs", { - runNum: item.runNum, - runNumByIframe: item.runNumByIframe, - })! - : t("script_total_runs_single", { runNum: item.runNum })! - : t("script_disabled")! - } - > - - onEnableChange(item, checked)} /> - - - {i18nName(item)} - - -
- ); - }, - (prevProps, nextProps) => { - return prevProps.item === nextProps.item; - } -); -CollapseHeader.displayName = "CollapseHeader"; - -interface ListMenuItemProps { - item: ScriptMenuEntry; - scriptMenus: GroupScriptMenuItemsProp; - menuExpandNum: number; - isBackscript: boolean; - url: URL | null; - onEnableChange: (item: ScriptMenuEntry, checked: boolean) => void; - handleDeleteScript: (uuid: string) => void; -} - -const ListMenuItem = React.memo( - ({ item, scriptMenus, menuExpandNum, isBackscript, url, onEnableChange, handleDeleteScript }: ListMenuItemProps) => { - const { t } = useTranslation(); - const [isEffective, setIsEffective] = useState(item.isEffective); - const [isActive, setIsActive] = useState(false); - const [isExpand, setIsExpand] = useState(false); - - const handleExpandMenu = () => { - setIsExpand((e) => !e); - }; - - const visibleMenus = useMemo(() => { - // 当menuExpandNum为0时,跟随 isActive 状态显示全部菜单 - const m = scriptMenus?.group || []; - if (menuExpandNum === 0 && isActive) { - return m; - } - return m.length > menuExpandNum && !isExpand ? m.slice(0, menuExpandNum) : m; - }, [scriptMenus?.group, isExpand, menuExpandNum, isActive]); - - const shouldShowMore = useMemo( - () => menuExpandNum > 0 && scriptMenus?.group?.length > menuExpandNum, - [scriptMenus?.group, menuExpandNum] - ); - - const handleExcludeUrl = (uuid: string, excludePattern: string, isExclude: boolean) => { - scriptClient.excludeUrl(uuid, excludePattern, isExclude).finally(() => { - setIsEffective(isExclude); - }); - }; - - return ( - { - setIsActive(keys.includes(item.uuid)); - }} - bordered={false} - expandIconPosition="right" - key={item.uuid} - > - } - name={item.uuid} - contentStyle={{ padding: "0 0 0 40px" }} - > -
- {isBackscript && ( - - )} - - {url && isEffective !== null && ( - - )} - } - onOk={() => handleDeleteScript(item.uuid)} - > - - -
-
-
- {/* 依数量与展开状态决定要显示的分组项(收合时只显示前 menuExpandNum 笔) */} - {visibleMenus.map(({ uuid, groupKey, menus }) => { - // 不同脚本之间可能出现相同的 groupKey;为避免 React key 冲突,需加上 uuid 做区分。 - return ; - })} - {shouldShowMore && ( - - )} - {item.hasUserConfig && ( - - )} -
-
- ); - }, - (prevProps, nextProps) => { - return ( - prevProps.url?.href === nextProps.url?.href && - prevProps.item === nextProps.item && - prevProps.isBackscript === nextProps.isBackscript && - prevProps.menuExpandNum === nextProps.menuExpandNum && - prevProps.scriptMenus?.menuUpdated === nextProps.scriptMenus?.menuUpdated - ); - } -); - -ListMenuItem.displayName = "ListMenuItem"; - -type TGrouppedMenus = Record & { __length__?: number }; - -type ScriptMenuEntryBase = ScriptMenu & { - menuUpdated?: number; -}; - -// ScriptMenuEntryBase 加了 metadata 后变成 ScriptMenuEntry -type ScriptMenuEntry = ScriptMenuEntryBase & { - metadata: SCMetadata; -}; - -const cacheMetadata = new Map(); -// 使用 WeakMap:当 ScriptMenuEntryBase 替换后,ScriptMenuEntryBase的引用会失去,ScriptMenuEntry能被自动回收。 -const cacheMergedItem = new WeakMap(); -// scriptList 更新后会合并 从异步取得的 metadata 至 mergedList -const fetchMergedList = async (item: ScriptMenuEntryBase) => { - const uuid = item.uuid; - // 检查 cacheMetadata 有没有记录 - let metadata = cacheMetadata.get(uuid); - if (!metadata) { - // 如没有记录,对 scriptDAO 发出请求 (通常在首次React元件绘画时进行) - const script = await scriptDAO.get(uuid); - metadata = script?.metadata || {}; // 即使 scriptDAO 返回失败也 fallback 一个空物件 - cacheMetadata.set(uuid, metadata); - } - // 检查 cacheMergedItem 有没有记录 - let merged = cacheMergedItem.get(item); - if (!merged || merged.uuid !== item.uuid) { - // 如没有记录或记录不正确,则重新生成记录 (新物件参考) - merged = { ...item, metadata }; - cacheMergedItem.set(item, merged); - } - // 如 cacheMergedItem 的记录中的 metadata 跟 (新)metadata 物件参考不一致,则更新 merged - if (merged.metadata !== metadata) { - // 新物件参考触发 React UI 重绘 - merged = { ...merged, metadata: metadata }; - cacheMergedItem.set(item, merged); - } - return merged; -}; - -// Popup 页面使用的脚本/选单清单元件:只负责渲染与互动,状态与持久化交由外部 client 处理。 -const ScriptMenuList = React.memo( - ({ - script, - isBackscript, - currentUrl, - menuExpandNum, - }: { - script: ScriptMenuEntryBase[]; - isBackscript: boolean; - currentUrl: string; - menuExpandNum: number; - }) => { - const [scriptMenuList, setScriptMenuList] = useState([]); - const { t } = useTranslation(); - - const [grouppedMenus, setGrouppedMenus] = useState({}); - - const updateScriptMenuList = (scriptMenuList: ScriptMenuEntry[]) => { - setScriptMenuList(scriptMenuList); - // 因为 scriptMenuList 的修改只在这处。 - // 直接在这里呼叫 setGrouppedMenus, 不需要 useEffect - setGrouppedMenus((prev) => { - // 依 groupKey 进行聚合:将同语义(mainframe/subframe)命令合并为单一分组以供 UI 呈现。 - const ret = {} as TGrouppedMenus; - let changed = false; - let retLen = 0; - for (const { uuid, menus, menuUpdated: m } of scriptMenuList) { - retLen++; - const menuUpdated = m || 0; - if (prev[uuid]?.menuUpdated === menuUpdated) { - ret[uuid] = prev[uuid]; - continue; // Skip if unchanged - } - - const resultMap = new Map(); - for (const menu of menus) { - if (menu.options?.mSeparator) continue; // popup 不显示分隔线 - const groupKey = menu.groupKey.split(",")[0]; // popup 显示不区分二级菜单或三级菜单 - let m = resultMap.get(groupKey); - if (!m) resultMap.set(groupKey, (m = [])); - m.push(menu); - } - - const result = []; - for (const [groupKey, arr] of resultMap) { - result.push({ uuid, groupKey, menus: arr } as GroupScriptMenuItem); - } - - // 输出以 uuid 分组存放;不依赖 list 的迭代顺序以避免不稳定渲染。 - ret[uuid] = { group: result, menuUpdated }; - changed = true; - } - ret.__length__ = retLen; - if (!changed && ret.__length__ !== prev.__length__) changed = true; - - // 若无引用变更则维持原物件以降低重渲染 - return changed ? ret : prev; - }); - }; - - const url = useMemo(() => { - let url: URL; - try { - // 容错:当 currentUrl 为空或非法时改用预设 URL,避免 URL 解析抛错。 - const urlToUse = currentUrl?.trim() || "https://example.com"; - url = new URL(urlToUse); - } catch (e: any) { - console.error("Invalid URL:", e); - // 提供预设 URL 以确保后续依赖 url 的流程不会中断。 - url = new URL("https://example.com"); - } - return url; - }, [currentUrl]); - - useEffect(() => { - let isMounted = true; - Promise.all(script.map(fetchMergedList)).then((newList) => { - if (!isMounted) { - return; - } - updateScriptMenuList(newList); - }); - return () => { - isMounted = false; - }; - }, [script]); - - useEffect(() => { - // 注册菜单快速键(accessKey):以各分组第一个项目的 accessKey 作为触发条件。 - const checkItems = new Map(); - for (const [_uuid, menus] of Object.entries(grouppedMenus)) { - if (typeof menus !== "object") continue; - for (const menu of menus.group) { - const menuItem = menu.menus[0]; // 同一分组的语义一致,取首项即可读取 accessKey / name 等共用属性。 - const { name, options } = menuItem; - const accessKey = options?.accessKey; - if (typeof accessKey === "string") { - checkItems.set(`${menu.uuid}:${menu.groupKey}`, [menu.uuid, accessKey.toUpperCase(), name, menu.menus]); - } - } - } - if (!checkItems.size) return; - const sharedKeyPressListner = (e: KeyboardEvent) => { - const keyUpper = e.key.toUpperCase(); - checkItems.forEach(([uuid, accessKeyUpper, _name, menuItems]) => { - if (keyUpper === accessKeyUpper) { - // 快速键触发不需传递 options(autoClose 由 sendMenuAction 内部处理)。 - sendMenuAction(uuid, {}, menuItems); - } - }); - }; - document.addEventListener("keypress", sharedKeyPressListner); - return () => { - checkItems.clear(); - document.removeEventListener("keypress", sharedKeyPressListner); - }; - }, [grouppedMenus]); - - const handleDeleteScript = (uuid: string) => { - // 本地先行移除列表项(乐观更新);若删除失败会显示错误讯息。 - scriptClient.deletes([uuid]).catch((e) => { - Message.error(`${t("delete_failed")}: ${e}`); - }); - }; - - const onEnableChange = (item: ScriptMenuEntry, checked: boolean) => { - scriptClient.enable(item.uuid, checked).catch((err) => { - Message.error(err); - }); - }; - - return ( - <> - {scriptMenuList.length === 0 ? ( - - ) : ( - scriptMenuList.map((item, _index) => ( - - )) - )} - - ); - } -); - -ScriptMenuList.displayName = "ScriptMenuList"; - -export default ScriptMenuList; diff --git a/src/pages/components/ScriptResource/index.tsx b/src/pages/components/ScriptResource/index.tsx deleted file mode 100644 index 887eef7a1..000000000 --- a/src/pages/components/ScriptResource/index.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import type { Resource } from "@App/app/repo/resource"; -import type { Script } from "@App/app/repo/scripts"; -import { ResourceClient } from "@App/app/service/service_worker/client"; -import { message } from "@App/pages/store/global"; -import { base64ToBlob, makeBlobURL } from "@App/pkg/utils/utils"; -import { Button, Drawer, Input, Message, Popconfirm, Space, Table } from "@arco-design/web-react"; -import type { ColumnProps } from "@arco-design/web-react/es/Table"; -import { IconDelete, IconDownload, IconSearch } from "@arco-design/web-react/icon"; -import React, { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; - -type ResourceListItem = { - key: string; -} & Resource; - -const resourceClient = new ResourceClient(message); - -const ScriptResource: React.FC<{ - script?: Script; - visible: boolean; - onOk: () => void; - onCancel: () => void; -}> = ({ script, visible, onCancel, onOk }) => { - const [data, setData] = useState([]); - const { t } = useTranslation(); - - useEffect(() => { - if (!script) { - return () => {}; - } - resourceClient.getScriptResources(script).then((res) => { - const arr: ResourceListItem[] = []; - for (const key of Object.keys(res)) { - // @ts-ignore - const item: ResourceListItem = res[key]; - item.key = key; - arr.push(item); - } - setData(arr); - }); - return () => {}; - }, [script]); - - const columns: ColumnProps[] = [ - { - title: t("key"), - dataIndex: "key", - key: "key", - filterIcon: , - - filterDropdown: ({ filterKeys, setFilterKeys, confirm }: any) => { - return ( -
- { - setFilterKeys(value ? [value] : []); - }} - onSearch={() => { - confirm(); - }} - /> -
- ); - }, - onFilter: (value, row) => !value || row.key.includes(value), - }, - { - title: t("type"), - dataIndex: "contentType", - width: 140, - key: "type", - render(col, res: Resource) { - return `${res.type}/${col}`; - }, - }, - { - title: t("action"), - render(_col, value: Resource, index) { - return ( - - - - - - - - ); -}; - -export default ScriptResource; diff --git a/src/pages/components/ScriptSetting/Match.tsx b/src/pages/components/ScriptSetting/Match.tsx deleted file mode 100644 index a8ff7fedc..000000000 --- a/src/pages/components/ScriptSetting/Match.tsx +++ /dev/null @@ -1,313 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import type { Script } from "@App/app/repo/scripts"; -import { ScriptDAO } from "@App/app/repo/scripts"; -import { Space, Popconfirm, Button, Divider, Typography, Modal, Input } from "@arco-design/web-react"; -import type { ColumnProps } from "@arco-design/web-react/es/Table"; -import Table from "@arco-design/web-react/es/Table"; -import { IconDelete } from "@arco-design/web-react/icon"; -import { scriptClient } from "@App/pages/store/features/script"; - -type MatchItem = { - // id是为了避免match重复 - id: number; - match: string; - byUser: boolean; - hasMatch: boolean; // 是否已经匹配 - isExclude: boolean; // 是否是排除项 -}; - -const Match: React.FC<{ - script: Script; -}> = ({ script }) => { - const scriptDAO = new ScriptDAO(); - const [match, setMatch] = useState([]); - const [exclude, setExclude] = useState([]); - const [matchValue, setMatchValue] = useState(""); - const [matchVisible, setMatchVisible] = useState(false); - const [excludeValue, setExcludeValue] = useState(""); - const [excludeVisible, setExcludeVisible] = useState(false); - const { t } = useTranslation(); // 使用 react-i18next 的 useTranslation 钩子函数获取翻译函数 - - // 自定义的状态更新函数,会在更新后自动刷新数据 - const updateMatchAndRefresh = (newMatch: MatchItem[]) => { - setMatch(newMatch); - refreshMatch(); - }; - - const updateExcludeAndRefresh = (newExclude: MatchItem[]) => { - setExclude(newExclude); - refreshMatch(); - }; - - const refreshMatch = () => { - if (script) { - // 从数据库中获取是简单处理数据一致性的问题 - scriptDAO.get(script.uuid).then((res) => { - if (!res) { - return; - } - const matchArr = res.selfMetadata?.match || res.metadata.match || []; - const matchMap = new Map(); - res.metadata.match?.forEach((m) => { - matchMap.set(m, true); - }); - const v: MatchItem[] = []; - matchArr.forEach((value, index) => { - v.push({ - id: index, - match: value, - byUser: !matchMap.has(value), - hasMatch: false, - isExclude: false, - }); - }); - setMatch(v); - - const excludeArr = res.selfMetadata?.exclude || res.metadata.exclude || []; - const excludeMap = new Map(); - res.metadata.exclude?.forEach((m) => { - excludeMap.set(m, true); - }); - const e: MatchItem[] = []; - excludeArr.forEach((value, index) => { - const hasMatch = matchMap.has(value); - e.push({ - id: index, - match: value, - byUser: !excludeMap.has(value), - hasMatch, - isExclude: true, - }); - }); - setExclude(e); - }); - } - }; - - useEffect(() => { - refreshMatch(); - }, [script]); - - const columns: ColumnProps[] = [ - { - title: t("match"), - dataIndex: "match", - key: "match", - }, - { - title: t("user_setting"), - dataIndex: "byUser", - key: "byUser", - width: 100, - render(col) { - if (col) { - return {t("yes")}; - } - return {t("no")}; - }, - }, - { - title: t("action"), - render(_, item: MatchItem) { - if (item.isExclude) { - return ( - - { - exclude.splice(exclude.indexOf(item), 1); - // 删除所有排除 - scriptClient - .resetExclude( - script.uuid, - exclude.map((m) => m.match) - ) - .then(() => { - updateExcludeAndRefresh([...exclude]); - // 如果包含在里面,再加回match - if (item.hasMatch) { - match.push(item); - // 重置匹配 - scriptClient - .resetMatch( - script.uuid, - match.map((m) => m.match) - ) - .then(() => { - updateMatchAndRefresh([...match]); - }); - } - }); - }} - > - - { - scriptClient.resetMatch(script.uuid, undefined).then(() => { - updateMatchAndRefresh([]); - }); - }} - > - - - - -
- -
- {t("website_exclude")} - - - { - scriptClient.resetExclude(script.uuid, undefined).then(() => { - updateExcludeAndRefresh([]); - }); - }} - > - - - -
-
- - - ); -}; - -export default Match; diff --git a/src/pages/components/ScriptSetting/Permission.tsx b/src/pages/components/ScriptSetting/Permission.tsx deleted file mode 100644 index 3eac8eaf4..000000000 --- a/src/pages/components/ScriptSetting/Permission.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import React, { useEffect, useState } from "react"; -import type { Permission } from "@App/app/repo/permission"; -import type { Script } from "@App/app/repo/scripts"; -import { useTranslation } from "react-i18next"; -import { Space, Popconfirm, Message, Button, Checkbox, Input, Modal, Select, Typography } from "@arco-design/web-react"; -import type { ColumnProps } from "@arco-design/web-react/es/Table"; -import Table from "@arco-design/web-react/es/Table"; -import { IconDelete } from "@arco-design/web-react/icon"; -import { permissionClient } from "@App/pages/store/features/script"; - -const PermissionManager: React.FC<{ - script: Script; -}> = ({ script }) => { - const [permission, setPermission] = useState([]); - const [permissionVisible, setPermissionVisible] = useState(false); - const [permissionValue, setPermissionValue] = useState(); - - const { t } = useTranslation(); - - const columns: ColumnProps[] = [ - { - title: t("type"), - dataIndex: "permission", - key: "permission", - width: 100, - }, - { - title: t("permission_value"), - dataIndex: "permissionValue", - key: "permissionValue", - }, - { - title: t("allow"), - dataIndex: "allow", - key: "allow", - render(col, item: Permission) { - return ( - - ); - }, - }, - { - title: t("action"), - render(_, item: Permission) { - return ( - - { - permissionClient - .deletePermission(script.uuid, item.permission, item.permissionValue) - .then(() => { - Message.success(t("delete_success")!); - setPermission( - permission.filter( - (i) => !(i.permission == item.permission && i.permissionValue == item.permissionValue) - ) - ); - }) - .catch(() => { - Message.error(t("delete_failed")!); - }); - }} - > - - { - permissionClient.resetPermission(script.uuid).then(() => { - setPermission([]); - }); - }} - > - - - - -
- - ); -}; - -export default PermissionManager; diff --git a/src/pages/components/ScriptSetting/index.tsx b/src/pages/components/ScriptSetting/index.tsx deleted file mode 100644 index 495caaea1..000000000 --- a/src/pages/components/ScriptSetting/index.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import type { Script } from "@App/app/repo/scripts"; -import { ScriptDAO } from "@App/app/repo/scripts"; -import { formatUnixTime } from "@App/pkg/utils/day_format"; -import { Checkbox, Descriptions, Divider, Drawer, Input, InputTag, Message, Select, Tag } from "@arco-design/web-react"; -import type { ReactNode } from "react"; -import React, { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import Match from "./Match"; -import PermissionManager from "./Permission"; -import { scriptClient } from "@App/pages/store/features/script"; -import { getCombinedMeta } from "@App/app/service/service_worker/utils"; -import { parseTags } from "@App/app/repo/metadata"; -import { hashColor } from "@App/pages/options/routes/utils"; - -const tagRender: React.FC<{ value: any; label: ReactNode; closable: boolean; onClose: (event: any) => void }> = ( - props -) => { - const { label, value, closable, onClose } = props; - return ( - - {label} - - ); -}; - -const ScriptSetting: React.FC<{ - script: Script; - visible: boolean; - onOk: () => void; - onCancel: () => void; -}> = ({ script, visible, onCancel, onOk }) => { - const [scriptTags, setScriptTags] = useState([]); - const [checkUpdateUrl, setCheckUpdateUrl] = useState(""); - const [checkUpdate, setCheckUpdate] = useState(false); - const [scriptRunEnv, setScriptRunEnv] = useState("all"); - const [scriptRunAt, setScriptRunAt] = useState("default"); - - const { t } = useTranslation(); - - const scriptSettingData = useMemo(() => { - const ret = [ - { - label: t("script_run_env.title"), - value: ( - { - setScriptRunAt(value); - const earlyStart: string[] = []; - const runAt: string[] = []; - if (value === "early-start") { - earlyStart.push(""); - runAt.push("document-start"); - } else if (value !== "default") { - runAt.push(value); - } - Promise.all([ - scriptClient.updateMetadata(script.uuid, "early-start", earlyStart), - scriptClient.updateMetadata(script.uuid, "run-at", runAt), - ]).then(() => { - Message.success(t("update_success")); - }); - }} - /> - ), - }, - ]; - return ret; - }, [script.uuid, scriptRunEnv, scriptRunAt, t]); - - useEffect(() => { - const scriptDAO = new ScriptDAO(); - scriptDAO.get(script.uuid).then((v) => { - if (!v) { - return; - } - setCheckUpdateUrl(v.downloadUrl || ""); - setCheckUpdate(v.checkUpdate === false ? false : true); - let metadata = v.metadata; - if (v.selfMetadata) { - metadata = getCombinedMeta(metadata, v.selfMetadata); - } - setScriptRunEnv(metadata["run-in"]?.[0] || "default"); - let runAt = metadata["run-at"]?.[0] || "default"; - if (runAt === "document-start" && metadata["early-start"] && metadata["early-start"].length > 0) { - runAt = "early-start"; - } - setScriptRunAt(runAt); - setScriptTags(parseTags(metadata) || []); - }); - }, [script]); - - return ( - - {script.name} {t("script_setting.title")} - - } - autoFocus={false} - focusLock={false} - visible={visible} - onOk={() => { - onOk(); - }} - onCancel={() => { - onCancel(); - }} - > - { - setScriptTags(tags); - scriptClient.updateMetadata(script.uuid, "tag", tags).then(() => { - Message.success(t("update_success")); - }); - }} - /> - ), - }, - ]} - style={{ marginBottom: 20 }} - labelStyle={{ paddingRight: 36 }} - /> - - - - {script && } - { - setCheckUpdate(val); - scriptClient.setCheckUpdateUrl(script.uuid, val, checkUpdateUrl).then(() => { - Message.success(t("update_success")); - }); - }} - /> - ), - }, - { - label: t("update_url"), - value: ( - { - setCheckUpdateUrl(e); - }} - onBlur={() => { - scriptClient.setCheckUpdateUrl(script.uuid, checkUpdate, checkUpdateUrl).then(() => { - Message.success(t("update_success")); - }); - }} - /> - ), - }, - ]} - style={{ marginBottom: 20 }} - labelStyle={{ paddingRight: 36 }} - /> - - {script && } - - ); -}; - -export default ScriptSetting; diff --git a/src/pages/components/ScriptStorage/index.tsx b/src/pages/components/ScriptStorage/index.tsx deleted file mode 100644 index 49bc66548..000000000 --- a/src/pages/components/ScriptStorage/index.tsx +++ /dev/null @@ -1,324 +0,0 @@ -import type { Script } from "@App/app/repo/scripts"; -import { valueClient } from "@App/pages/store/features/script"; -import type { TKeyValuePair } from "@App/pkg/utils/message_value"; -import { encodeRValue } from "@App/pkg/utils/message_value"; -import { valueType } from "@App/pkg/utils/utils"; -import { Button, Drawer, Form, Input, Message, Modal, Popconfirm, Select, Space, Table } from "@arco-design/web-react"; -import type { ColumnProps } from "@arco-design/web-react/es/Table"; -import { IconDelete, IconEdit, IconSearch } from "@arco-design/web-react/icon"; -import React, { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; - -const FormItem = Form.Item; - -interface ValueModel { - key: string; - value: any; -} - -const ScriptStorage: React.FC<{ - script?: Script; - visible: boolean; - onOk: () => void; - onCancel: () => void; -}> = ({ script, visible, onCancel, onOk }) => { - const [data, setData] = useState([]); - const [rawData, setRawData] = useState<{ [key: string]: any }>({}); - const [currentValue, setCurrentValue] = useState(); - const [visibleEdit, setVisibleEdit] = useState(false); - const [isEdit, setIsEdit] = useState(false); - const [editValue, setEditValue] = useState(""); - const [form] = Form.useForm(); - const { t } = useTranslation(); - - // 保存单个键值 - const saveData = (key: string, value: any) => { - valueClient.setScriptValue({ uuid: script!.uuid, key, value, ts: Date.now() }); - const newRawData = { ...rawData, [key]: value }; - if (value === undefined) { - delete newRawData[key]; - } - updateRawData(newRawData); - }; - - // 保存所有键值 - const saveRawData = (newRawValue: { [key: string]: any }) => { - const keyValuePairs = [] as TKeyValuePair[]; - for (const [key, value] of Object.entries(newRawValue)) { - keyValuePairs.push([key, encodeRValue(value)]); - } - valueClient.setScriptValues({ uuid: script!.uuid, keyValuePairs, isReplace: true, ts: Date.now() }); - updateRawData(newRawValue); - }; - - // 更新UI数据 - const updateRawData = (newRawValue: { [key: string]: any }) => { - setRawData(newRawValue); - setEditValue(JSON.stringify(newRawValue, null, 2)); - setData( - Object.keys(newRawValue).map((key) => { - return { key: key, value: newRawValue[key] }; - }) - ); - }; - - // 删除单个键值 - const deleteData = (key: string) => { - saveData(key, undefined); - Message.info({ - content: t("delete_success"), - }); - }; - - // 清空所有键值 - const clearData = () => { - saveRawData({}); - Message.info({ - content: t("clear_success"), - }); - }; - - useEffect(() => { - if (!script) { - return () => {}; - } - valueClient.getScriptValue(script).then((rawValue) => { - updateRawData(rawValue); - }); - }, [script]); - const columns: ColumnProps[] = [ - { - title: t("key"), - dataIndex: "key", - key: "key", - filterIcon: , - width: 140, - filterDropdown: ({ filterKeys, setFilterKeys, confirm }: any) => { - return ( -
- { - setFilterKeys(value ? [value] : []); - }} - onSearch={() => { - confirm(); - }} - /> -
- ); - }, - onFilter: (value, row) => !value || row.key.includes(value), - }, - { - title: t("value"), - dataIndex: "value", - key: "value", - className: "max-table-cell", - render(col) { - switch (typeof col) { - case "string": - return col; - default: - return ( - - {JSON.stringify(col, null, 2)} - - ); - } - }, - }, - { - title: t("type"), - dataIndex: "value", - width: 90, - key: "type", - render(col) { - return valueType(col); - }, - }, - { - title: t("action"), - render(_col, value: { key: string; value: string }) { - return ( - - - - - ) : ( - <> - { - clearData(); - }} - > - - - - - )} - - - {isEdit ? ( - setEditValue(value)} - style={{ height: "calc(95vh - 100px)" }} - /> - ) : ( -
- )} - - - ); -}; - -export default ScriptStorage; diff --git a/src/pages/components/UserConfigPanel/index.tsx b/src/pages/components/UserConfigPanel/index.tsx deleted file mode 100644 index a4c89f8ae..000000000 --- a/src/pages/components/UserConfigPanel/index.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import React, { useEffect, useMemo, useRef } from "react"; -import { useTranslation } from "react-i18next"; // 添加这行导入语句 -import type { ConfigGroup, Script, UserConfig, UserConfigWithoutOptions } from "@App/app/repo/scripts"; -import type { FormInstance } from "@arco-design/web-react"; -import { - Checkbox, - Form, - Input, - InputNumber, - Message, - Modal, - Popover, - Select, - Space, - Switch, - Tabs, -} from "@arco-design/web-react"; -import TabPane from "@arco-design/web-react/es/Tabs/tab-pane"; -import { ValueClient } from "@App/app/service/service_worker/client"; -import { message } from "@App/pages/store/global"; -import type { TKeyValuePair } from "@App/pkg/utils/message_value"; -import { encodeRValue } from "@App/pkg/utils/message_value"; - -const FormItem = Form.Item; - -const UserConfigPanel: React.FC<{ - script: Script; - userConfig: UserConfig; - values: { [key: string]: any }; -}> = ({ script, userConfig, values }) => { - const formRefs = useRef<{ [key: string]: FormInstance }>({}); - const [visible, setVisible] = React.useState(true); - const [tab, setTab] = React.useState(""); - useEffect(() => { - setTab(userConfig["#options"]?.sort[0] || Object.keys(userConfig)[0]); - setVisible(true); - }, [script, userConfig]); - - // 过滤掉 #options - const filteredConfig = useMemo(() => { - return (userConfig["#options"]?.sort || Object.keys(userConfig)).reduce((acc, key) => { - if (key === "#options") { - return acc; - } - acc[key] = userConfig[key] as ConfigGroup; - return acc; - }, {} as UserConfigWithoutOptions); - }, [userConfig]); - - const { t } = useTranslation(); - - return ( - {t("save")}} - cancelText={t("close")} // 替换为键值对应的英文文本 - onOk={() => { - if (formRefs.current[tab]) { - const saveValues = formRefs.current[tab].getFieldsValue(); - // 更新value - const valueClient = new ValueClient(message); - const uuid = script.uuid; - const keyValuePairs = [] as TKeyValuePair[]; - for (const key of Object.keys(saveValues)) { - for (const valueKey of Object.keys(saveValues[key])) { - if (saveValues[key][valueKey] === undefined) { - continue; - } - keyValuePairs.push([`${key}.${valueKey}`, encodeRValue(saveValues[key][valueKey])]); - } - } - valueClient.setScriptValues({ - uuid: uuid, - keyValuePairs: keyValuePairs, - isReplace: false, - ts: Date.now(), - }); - Message.success(t("save_success")!); // 替换为键值对应的英文文本 - setVisible(false); - } - }} - onCancel={() => { - setVisible(false); - }} - > - { - setTab(value); - }} - > - {Object.keys(filteredConfig).map((itemKey) => { - const value = filteredConfig[itemKey]; - const keys = Object.keys(value).sort((a, b) => { - return (value[a].index || 0) - (value[b].index || 0); - }); - return ( - -
{ - formRefs.current[itemKey] = el; - }} - > - {keys.map((key) => ( - - {() => { - const item = value[key]; - let { type } = item; - if (!type) { - // 根据其他值判断类型 - if (typeof item.default === "boolean") { - type = "checkbox"; - } else if (item.values) { - if (typeof item.values === "object") { - type = "mult-select"; - } else { - type = "select"; - } - } else if (typeof item.default === "number") { - type = "number"; - } else { - type = "text"; - } - } - switch (type) { - case "text": - if (item.password) { - return ; - } - return ; - case "number": - return ( - - ); - case "checkbox": - return {item.description}; - case "select": - case "mult-select": - // eslint-disable-next-line no-case-declarations - let options: any[]; - if (item.bind) { - const bindKey = item.bind.substring(1); - if (values[bindKey]) { - options = values[bindKey]!; - } else { - options = []; - } - } else { - options = item.values!; - } - return ( - - ); - case "textarea": - return ( - - ); - case "switch": - return ( - - { - formRefs.current[itemKey].setFieldValue(`${itemKey}.${key}`, val); - }} - /> - {item.description} - - ); - default: - return null; - } - }} - - ))} - -
- ); - })} -
-
- ); -}; - -export default UserConfigPanel; diff --git a/src/pages/components/layout/MainLayout.tsx b/src/pages/components/layout/MainLayout.tsx deleted file mode 100644 index b254dca97..000000000 --- a/src/pages/components/layout/MainLayout.tsx +++ /dev/null @@ -1,513 +0,0 @@ -import { - Button, - ConfigProvider, - Dropdown, - Empty, - Input, - Layout, - Menu, - Message, - Modal, - Space, - Typography, -} from "@arco-design/web-react"; -import type { RefTextAreaType } from "@arco-design/web-react/es/Input"; -import { - IconCheckCircle, - IconCloseCircle, - IconDesktop, - IconDown, - IconLanguage, - IconLink, - IconMoonFill, - IconSunFill, -} from "@arco-design/web-react/icon"; -import type { ReactNode } from "react"; -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useAppContext } from "@App/pages/store/AppContext"; -import { RiFileCodeLine, RiImportLine, RiPlayListAddLine, RiTerminalBoxLine, RiTimerLine } from "react-icons/ri"; -import { scriptClient } from "@App/pages/store/features/script"; -import { useDropzone, type FileWithPath } from "react-dropzone"; -import { systemConfig } from "@App/pages/store/global"; -import i18n, { matchLanguage } from "@App/locales/locales"; -import "./index.css"; -import { arcoLocale } from "@App/locales/arco"; -import { prepareScriptByCode } from "@App/pkg/utils/script"; -import { saveHandle } from "@App/pkg/utils/filehandle-db"; -import { makeBlobURL } from "@App/pkg/utils/utils"; - -// --- 工具函数移出组件外,避免每次 Render 重新定义 --- - -const formatUrl = async (url: string) => { - try { - const newUrl = new URL(url.replace(/\/$/, "")); - const { hostname, pathname } = newUrl; - // 判断是否为脚本猫脚本页 - if (hostname === "scriptcat.org" && /script-show-page\/\d+$/.test(pathname)) { - const scriptId = pathname.match(/\d+$/)![0]; - // 请求脚本信息 - const scriptInfo = await fetch(`https://scriptcat.org/api/v2/scripts/${scriptId}`) - .then((res) => { - return res.json(); - }) - .then((json) => { - return json; - }); - const { code, data, msg } = scriptInfo; - if (code !== 0) { - // 无脚本访问权限 - return { success: false, msg }; - } else { - // 返回脚本实际安装地址 - const scriptName = data.name; - return `https://scriptcat.org/scripts/code/${scriptId}/${scriptName}.user.js`; - } - } else { - return url; - } - } catch { - return url; - } -}; - -// 提供一个简单的字串封装(非加密用) -const simpleDigestMessage = async (message: string) => { - const encoder = new TextEncoder(); - const data = encoder.encode(message); - return crypto.subtle.digest("SHA-1", data as BufferSource).then((hashBuffer) => { - const hashArray = new Uint8Array(hashBuffer); - let hex = ""; - for (let i = 0; i < hashArray.length; i++) { - const byte = hashArray[i]; - hex += `${byte < 16 ? "0" : ""}${byte.toString(16)}`; - } - return hex; - }); -}; - -type TImportStat = { - success: number; - fail: number; - msg: string[]; -}; - -const importByUrls = async (urls: string[]): Promise => { - if (urls.length == 0) { - return; - } - const results = (await Promise.allSettled( - urls.map(async (url) => { - const formattedResult = await formatUrl(url); - if (formattedResult instanceof Object) { - return await Promise.resolve(formattedResult); - } else { - return await scriptClient.do("importByUrl", formattedResult); - } - }) - // this.do 只会resolve 不会reject - )) as PromiseFulfilledResult<{ success: boolean; msg: string }>[]; - const stat = { success: 0, fail: 0, msg: [] as string[] }; - results.forEach(({ value }, index) => { - if (value.success) { - stat.success++; - } else { - stat.fail++; - stat.msg.push(`#${index + 1}: ${value.msg}`); - } - }); - return stat; -}; - -const getSafePopupParent = (p: Element) => { - p = (p.closest("button")?.parentNode as Element) || p; // 確保 ancestor 沒有 button 元素 - p = (p.closest("span")?.parentNode as Element) || p; // 確保 ancestor 沒有 span 元素 - p = (p.closest(".arco-collapse-item-content")?.parentNode as Element) || p; // 確保 ancestor 沒有 .arco-collapse-item-content 元素 - p = (p.closest(".arco-card")?.parentNode as Element) || p; // 確保 ancestor 沒有 .arco-card 元素 - p = (p.closest("aside")?.parentNode as Element) || p; // 確保 ancestor 沒有 aside 元素 - return p; -}; - -// --- 子组件:提取拖拽遮罩以优化性能 --- -const DropzoneOverlay: React.FC<{ active: boolean; text: string }> = React.memo(({ active, text }) => { - if (!active) return null; - return ( -
- {text} -
- ); -}); -DropzoneOverlay.displayName = "DropzoneOverlay"; - -const MainLayout: React.FC<{ - children: ReactNode; - className: string; - pageName?: string; -}> = ({ children, className, pageName }) => { - const [modal, contextHolder] = Modal.useModal(); - const { colorThemeState, updateColorTheme } = useAppContext(); - - const importRef = useRef(null); - const [importVisible, setImportVisible] = useState(false); - const [showLanguage, setShowLanguage] = useState(false); - const { t } = useTranslation(); - - const showImportResult = (stat: TImportStat) => { - if (!stat) return; - modal.info!({ - title: t("script_import_result"), - content: ( - -
- - - {stat.success} - {""} - - {stat.fail} - -
- {stat.msg.length > 0 && ( - <> - {t("failure_info") + ":"} - {stat.msg} - - )} -
- ), - }); - }; - - const importByUrlsLocal = async (urls: string[]): Promise => { - const stat = await importByUrls(urls); - if (stat) showImportResult(stat); - }; - - const onDrop = (acceptedFiles: FileWithPath[]) => { - // 本地的文件在当前页面处理,打开安装页面,将FileSystemFileHandle传递过去 - // 实现本地文件的监听 - const stat: TImportStat = { success: 0, fail: 0, msg: [] }; - Promise.all( - acceptedFiles.map(async (aFile) => { - try { - // 解析看看是不是一个标准的script文件 - // 如果是,则打开安装页面 - let fileHandle = aFile.handle; - if (!fileHandle) { - // 如果是file,直接使用blob的形式安装 - if (aFile instanceof FileSystemFileHandle) { - fileHandle = aFile; - } else if (aFile instanceof File) { - // 清理 import-local files 避免同文件不再触发onChange - (document.getElementById("import-local") as HTMLInputElement).value = ""; - const blob = new Blob([aFile], { type: "text/javascript" }); - const url = makeBlobURL({ blob, persistence: false }) as string; // 生成一个临时的URL - const result = await scriptClient.importByUrl(url); - if (result.success) { - stat.success++; - } else { - stat.fail++; - stat.msg.push(...result.msg); - } - return; - } else { - throw new Error("Invalid Local File Access"); - } - } - const file = await fileHandle.getFile(); - if (!file.name || !file.size) { - throw new Error("No Read Access Right for File"); - } - // 先检查内容,后弹出安装页面 - const checkOk = await Promise.allSettled([ - file.text().then((code) => prepareScriptByCode(code, `file:///*resp-check*/${file.name}`)), - simpleDigestMessage(`f=${file.name}\ns=${file.size},m=${file.lastModified}`), - ]); - if (checkOk[0].status === "rejected" || !checkOk[0].value || checkOk[1].status === "rejected") { - throw new Error(t("script_import_failed")); - } - const fid = checkOk[1].value; - await saveHandle(fid, fileHandle); // fileHandle以DB方式传送至安装页面 - // 打开安装页面 - const installWindow = window.open(`/src/install.html?file=${fid}`, "_blank"); - if (!installWindow) { - throw new Error(t("install_page_open_failed")); - } - stat.success++; - } catch (e: any) { - stat.fail++; - stat.msg.push(e.message); - } - }) - ).then(() => { - showImportResult(stat); - }); - }; - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - accept: { "text/javascript": [".js"] }, - onDrop, - noClick: true, - noKeyboard: true, - }); - - // 当dragzone使用时,在加入.dragzone-active,控制CSS行为 - // 只改CSS,不要改动React元件的任何状态,否则会触发重绘计算 - useEffect(() => { - document.body.classList.toggle("dragzone-active", isDragActive); - }, [isDragActive]); - - // 使用 useMemo 缓存语言列表,避免每次重绘都执行循环,然后生成新的参考 - const languageList = useMemo(() => { - const list = Object.keys(i18n.store.data) - .filter((key) => key !== "ach-UG") - .map((key) => ({ - key, - title: i18n.store.data[key].title as string, - })); - return [...list, { key: "help", title: t("help_translate") }]; - }, [t]); - - useEffect(() => { - // 当没有匹配语言时显示语言按钮 - matchLanguage().then((result) => { - if (!result) { - setShowLanguage(true); - } - }); - }, []); - - const handleImport = async () => { - const urls = importRef.current!.dom.value.split("\n").filter((v) => v); - importByUrlsLocal(urls); // 异步却不用等候? - setImportVisible(false); // 不等待 importByUrlsLocal? - }; - - return ( - { - return ; - }} - locale={arcoLocale(i18n.language)} - componentConfig={{ - Select: { - getPopupContainer: (node) => { - return getSafePopupParent(node as Element); - }, - }, - }} - getPopupContainer={(node) => { - return getSafePopupParent(node.parentNode as Element); - }} - > - {contextHolder} - - - { - setImportVisible(false); - }} - > - { - if (e.ctrlKey && e.key === "Enter") { - e.preventDefault(); - handleImport(); - } - }} - /> - -
- ScriptCat - - {"ScriptCat"} - -
- - {pageName === "options" && ( - - - - {t("create_user_script")} - - - - - {t("create_background_script")} - - - - - {t("create_scheduled_script")} - - - { - if ("showOpenFilePicker" in window) { - // 使用新的文件打开接口,解决无法监听本地文件的问题 - //@ts-ignore - window - .showOpenFilePicker({ - multiple: true, - types: [ - { - description: "JavaScript", - accept: { "text/javascript": [".js"] }, - }, - ], - }) - .then((handles: any) => { - onDrop(handles as FileWithPath[]); - }); - } else { - // 旧的方式,无法监听本地文件变更 - document.getElementById("import-local")?.click(); - } - }} - > - {t("import_by_local")} - - { - setImportVisible(true); - }} - > - {t("import_link")} - - - } - position="bl" - > - - - )} - { - const theme = key as "auto" | "light" | "dark"; - updateColorTheme(theme); - }} - selectedKeys={[colorThemeState]} - > - - {t("light")} - - - {t("dark")} - - - {t("system_follow")} - - - } - position="bl" - > - - - )} - -
- - - {/* 性能关键:抽离遮罩组件,只有 active 变化时此小组件重绘 */} - - {children} - -
-
- ); -}; - -export default MainLayout; diff --git a/src/pages/components/layout/PopupLayout.tsx b/src/pages/components/layout/PopupLayout.tsx deleted file mode 100644 index cb7e1c35e..000000000 --- a/src/pages/components/layout/PopupLayout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { ReactNode } from "react"; -import React from "react"; -import "./index.css"; -import { ConfigProvider } from "@arco-design/web-react"; -import { arcoLocale } from "@App/locales/arco"; -import i18n from "@App/locales/locales"; - -const PopupLayout: React.FC<{ - children: ReactNode; -}> = ({ children }) => { - return ( - -
{children}
-
- ); -}; - -export default PopupLayout; diff --git a/src/pages/components/layout/Sider.tsx b/src/pages/components/layout/Sider.tsx deleted file mode 100644 index 4770b8cc9..000000000 --- a/src/pages/components/layout/Sider.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import Logger from "@App/pages/options/routes/Logger"; -import ScriptEditor from "@App/pages/options/routes/script/ScriptEditor"; -import ScriptList from "@App/pages/options/routes/ScriptList"; -import Setting from "@App/pages/options/routes/Setting"; -import SubscribeList from "@App/pages/options/routes/SubscribeList"; -import Tools from "@App/pages/options/routes/Tools"; -import { Layout, Menu } from "@arco-design/web-react"; -import { - IconCode, - IconFile, - IconGithub, - IconLeft, - IconLink, - IconQuestion, - IconRight, - IconSettings, - IconSubscribe, - IconTool, -} from "@arco-design/web-react/icon"; -import React, { useRef, useState } from "react"; -import { HashRouter, Route, Routes } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { RiFileCodeLine, RiGuideLine, RiLinkM } from "react-icons/ri"; -import SiderGuide from "./SiderGuide"; -import CustomLink from "../CustomLink"; -import { localePath } from "@App/locales/locales"; -import { DocumentationSite } from "@App/app/const"; - -const MenuItem = Menu.Item; -let { hash } = window.location; -if (!hash.length) { - hash = "/"; -} else { - hash = hash.substring(1); -} - -const Sider: React.FC = () => { - const [menuSelect, setMenuSelect] = useState(hash); - const [collapsed, setCollapsed] = useState(localStorage.collapsed === "true"); - const { t } = useTranslation(); - const guideRef = useRef<{ open: () => void }>(null); - - const { handleMenuClick } = { - handleMenuClick: (key: string) => { - setMenuSelect(key); - }, - }; - - return ( - - - -
- - - - {t("installed_scripts")} - - - - - {t("subscribe")} - - - - - {t("logs")} - - - - - {t("tools")} - - - - - {t("settings")} - - - - - - {t("helpcenter")} - - } - triggerProps={{ - trigger: "hover", - }} - > - - {t("external_links")} - - } - > - - - {t("api_docs")} - - - - - {t("development_guide")} - - - - {t("script_gallery")} - - } - > - - - ScriptCat - - - - - Greasy Fork - - - - - OpenUserJS - - - - - - {t("community_forum")} - - - - - {"GitHub"} - - - - { - guideRef.current?.open(); - }} - > - {t("guide")} - - - - {t("user_guide")} - - - - { - localStorage.collapsed = !collapsed; - setCollapsed(!collapsed); - }} - > - {collapsed ? ( - <> - - {t("show_main_sidebar")} - - ) : ( - <> - - {t("hide_main_sidebar")} - - )} - - -
-
- -
- - } /> - - } /> - } /> - - } /> - } /> - } /> - } /> - -
-
-
- ); -}; - -export default Sider; diff --git a/src/pages/components/layout/SiderGuide.tsx b/src/pages/components/layout/SiderGuide.tsx deleted file mode 100644 index 3d420bae0..000000000 --- a/src/pages/components/layout/SiderGuide.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React, { useEffect, useImperativeHandle, useState } from "react"; -import { useTranslation } from "react-i18next"; -import type { Step } from "react-joyride"; -import Joyride from "react-joyride"; -import type { Path } from "react-router-dom"; -import { useLocation, useNavigate } from "react-router-dom"; -import CustomTrans from "../CustomTrans"; -import { useAppContext } from "@App/pages/store/AppContext"; - -const SiderGuide: React.ForwardRefRenderFunction<{ open: () => void }, object> = (_props, ref) => { - const { t } = useTranslation(); - const [stepIndex, setStepIndex] = useState(0); - const [initRoute, setInitRoute] = useState>({ pathname: "/" }); - const [run, setRun] = useState(false); - const navigate = useNavigate(); - const location = useLocation(); - const { setGuideMode } = useAppContext(); - useImperativeHandle(ref, () => ({ - open: () => { - setRun(true); - setGuideMode(true); - }, - })); - useEffect(() => { - // 首次使用时,打开引导 - if (localStorage.getItem("firstUse") === null) { - localStorage.setItem("firstUse", "false"); - // 隐身模式不打开引导 - if (!chrome.extension.inIncognitoContext) { - setRun(true); - } - } - }, []); - - const steps: Array = [ - { - title: t("start_guide_title"), - content: t("start_guide_content"), - target: "body", - placement: "center", - }, - { - title: t("installed_scripts"), - content: t("guide_installed_scripts"), - target: ".menu-script", - }, - { - content: , - target: "#script-list", - title: t("guide_script_list_title"), - placement: "auto", - }, - ]; - - steps.push( - { - content: t("guide_script_list_enable_content"), - target: ".script-enable", - title: t("guide_script_list_enable_title"), - }, - { - content: t("guide_script_list_apply_to_run_status_content"), - target: ".apply_to_run_status", - title: t("guide_script_list_apply_to_run_status_title"), - }, - { - target: ".script-sort", - title: t("guide_script_list_sort_title"), - content: t("guide_script_list_sort_content"), - }, - { - target: ".script-updatetime", - title: t("guide_script_list_update_title"), - content: , - }, - { - target: ".script-action", - title: t("guide_script_list_action_title"), - content: , - } - ); - - steps.push( - { - target: ".menu-tools", - title: t("guide_tools_title"), - content: t("guide_tools_content"), - placement: "auto", - }, - { - target: ".tools .backup", - title: t("guide_tools_backup_title"), - content: t("guide_tools_backup_content"), - }, - { - target: ".menu-setting", - title: t("guide_setting_title"), - content: t("guide_setting_content"), - placement: "auto", - }, - { - target: ".setting .sync", - title: t("guide_setting_sync_title"), - content: t("guide_setting_sync_content"), - } - ); - - const gotoNavigate = (go: Partial) => { - if (go.pathname !== location.pathname) { - return navigate(go); - } - if (go.search !== location.search) { - return navigate(go); - } - if (go.hash !== location.hash) { - return navigate(go); - } - return true; - }; - - return ( - { - if (data.action === "stop" || data.action === "close" || data.status === "finished") { - setGuideMode(false); - setRun(false); - setStepIndex(0); - gotoNavigate(initRoute); - } else if (data.action === "next" && data.lifecycle === "complete") { - switch (data.index) { - case 7: - gotoNavigate({ pathname: "/tools" }); - break; - case 9: - gotoNavigate({ pathname: "/setting" }); - break; - default: - break; - } - setStepIndex(data.index + 1); - } else if (data.action === "prev" && data.lifecycle === "complete") { - setStepIndex(data.index - 1); - } else if (data.action === "start" && data.lifecycle === "init") { - gotoNavigate({ pathname: "/" }); - setInitRoute({ - pathname: location.pathname, - search: location.search, - hash: location.hash, - }); - } - }} - locale={{ - nextLabelWithProgress: t("next_with_progress"), - skip: t("skip"), - back: t("back"), - last: t("last"), - }} - continuous - run={run} - scrollToFirstStep - showProgress - showSkipButton - stepIndex={stepIndex} - steps={steps} - disableOverlayClose - disableScrolling - spotlightPadding={0} - styles={{ - options: { - zIndex: 10000, - primaryColor: "#4594D5", - }, - }} - /> - ); -}; - -export default React.forwardRef(SiderGuide); diff --git a/src/pages/components/layout/index.css b/src/pages/components/layout/index.css deleted file mode 100644 index fd53b2633..000000000 --- a/src/pages/components/layout/index.css +++ /dev/null @@ -1,26 +0,0 @@ -.arco-dropdown-menu-selected { - background-color: var(--color-fill-2) !important; -} - -.action-tools .arco-dropdown-popup-visible .arco-icon-down { - transform: rotate(180deg); -} - -.action-tools>.arco-btn { - padding: 0 8px; -} - -.arco-dropdown-menu-item, -.arco-dropdown-menu-item a { - display: flex; - align-items: center; -} - -:is(.arco-dropdown-menu-pop-header, .arco-dropdown-menu-item, .arco-dropdown-menu-item a) > svg { - margin-right: .5em; -} - -/* 避免拖拽时 tooltip 等元件弹出 */ -.dragzone-active .arco-layout-content { - pointer-events: none; -} diff --git a/src/pages/confirm/App.tsx b/src/pages/confirm/App.tsx deleted file mode 100644 index 7ad154ccf..000000000 --- a/src/pages/confirm/App.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import type { ConfirmParam } from "@App/app/service/service_worker/permission_verify"; -import { Button, Message, Space } from "@arco-design/web-react"; -import React, { useEffect, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { permissionClient } from "../store/features/script"; - -// 权限确认组件 -function PermissionConfirmRequest({ uuid }: { uuid: string }) { - const [confirm, setConfirm] = React.useState(); - const [likeNum, setLikeNum] = React.useState(0); - const [second, setSecond] = React.useState(30); - - const { t } = useTranslation(); - - useEffect(() => { - const timer = setInterval(() => { - setSecond((s) => { - if (s <= 1) { - clearInterval(timer); - window.close(); - return 0; - } - return s - 1; - }); - }, 1000); - return () => clearInterval(timer); - }, []); - - useEffect(() => { - const handler = () => { - permissionClient.confirm(uuid, { - allow: false, - type: 0, - }); - }; - window.addEventListener("beforeunload", handler, false); - return () => window.removeEventListener("beforeunload", handler, false); - }, [uuid]); - - useEffect(() => { - permissionClient - .getPermissionInfo(uuid) - .then((data) => { - console.log(data); - setConfirm(data.confirm); - setLikeNum(data.likeNum); - }) - .catch((e: any) => { - Message.error(e.message || t("get_confirm_error")); - }); - }, [uuid, t]); - - const handleConfirm = (allow: boolean, type: number) => { - return async () => { - try { - await permissionClient.confirm(uuid, { - allow, - type, - }); - window.close(); - } catch (e: any) { - Message.error(e.message || t("confirm_error")); - setTimeout(() => { - window.close(); - }, 3000); - } - }; - }; - - const metadata = useMemo(() => (confirm && confirm.metadata && Object.keys(confirm.metadata)) || [], [confirm]); - - return ( -
- - {confirm?.title} - {metadata.map((key) => ( - - {`${key}: ${confirm!.metadata![key]}`} - - ))} - {confirm?.describe} -
- -
-
- - - - {likeNum > 2 && ( - - )} - - {likeNum > 2 && ( - - )} - -
-
- - - - {likeNum > 2 && ( - - )} - - {likeNum > 2 && ( - - )} - -
-
-
- ); -} - -function App() { - const params = new URLSearchParams(location.search); - const uuid = params.get("uuid"); - - if (uuid) { - return ; - } - - return null; -} - -export default App; diff --git a/src/pages/confirm/main.tsx b/src/pages/confirm/main.tsx deleted file mode 100644 index 51985671b..000000000 --- a/src/pages/confirm/main.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import App from "./App.tsx"; -import { AppProvider } from "../store/AppContext.tsx"; -import MainLayout from "../components/layout/MainLayout.tsx"; -import LoggerCore from "@App/app/logger/core.ts"; -import { message } from "../store/global.ts"; -import MessageWriter from "@App/app/logger/message_writer.ts"; -import "@arco-design/web-react/dist/css/arco.css"; -import "@App/locales/locales"; -import "@App/index.css"; - -// 初始化日志组件 -const loggerCore = new LoggerCore({ - writer: new MessageWriter(message), - labels: { env: "confirm" }, -}); - -loggerCore.logger().debug("confirm page start"); - -const Root = ( - - - - - -); - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - process.env.NODE_ENV === "development" ? {Root} : Root -); diff --git a/src/pages/import/App.tsx b/src/pages/import/App.tsx deleted file mode 100644 index 755242d52..000000000 --- a/src/pages/import/App.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Button, Card, Checkbox, Divider, List, Message, Space, Switch, Typography } from "@arco-design/web-react"; -import { useTranslation } from "react-i18next"; // 导入react-i18next的useTranslation钩子 -import { loadAsyncJSZip } from "@App/pkg/utils/jszip-x"; -import type { ScriptOptions, ScriptData, SubscribeData } from "@App/pkg/backup/struct"; -import { prepareScriptByCode } from "@App/pkg/utils/script"; -import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE, ScriptDAO } from "@App/app/repo/scripts"; -import { cacheInstance } from "@App/app/cache"; -import { CACHE_KEY_IMPORT_FILE } from "@App/app/cache_key"; -import { parseBackupZipFile } from "@App/pkg/backup/utils"; -import { scriptClient, synchronizeClient, valueClient } from "../store/features/script"; -import { sleep } from "@App/pkg/utils/utils"; -import type { TKeyValuePair } from "@App/pkg/utils/message_value"; -import { encodeRValue } from "@App/pkg/utils/message_value"; - -const ScriptListItem = React.memo( - ({ - item, - index, - t, - onToggle, - onStatusToggle, - }: { - item: ScriptData; - index: number; - t: (a: string) => string; - onToggle: (index: number) => void; - onStatusToggle: (index: number, checked: boolean) => void; - }) => { - return ( -
onToggle(index)} - > - - - {item.script?.script?.name || item.error || t("unknown")} - - {`${t("author")}: ${item.script?.script?.metadata.author?.[0]}`} - - {`${t("description")}: ${item.script?.script?.metadata.description?.[0]}`} - - - {`${t("source")}: ${item.options?.meta.file_url || t("local_creation")}`} - - - {`${t("operation")}: `} - {(item.install && (item.script?.oldScript ? t("update") : t("add_new"))) || - (item.error - ? `${t("error")}: ${item.options?.meta.name} - ${item.options?.meta.uuid}` - : t("no_operation"))} - - -
- {t("enable_script")} -
- onStatusToggle(index, checked)} - /> -
-
-
- ); - }, - (prevProps, nextProps) => { - return prevProps.index === nextProps.index && prevProps.item === nextProps.item && prevProps.t === nextProps.t; - } -); - -ScriptListItem.displayName = "ScriptListItem"; - -function App() { - const [scripts, setScripts] = useState([]); - const [subscribes, setSubscribes] = useState([]); - const [selectAll, setSelectAll] = useState([true, true]); - const [installNum, setInstallNum] = useState([0, 0]); - const [loading, setLoading] = useState(true); - const { t } = useTranslation(); // 使用useTranslation钩子获取翻译函数 - - const fetchData = async () => { - try { - const url = new URL(window.location.href); - const uuid = url.searchParams.get("uuid") || ""; - const cacheKey = `${CACHE_KEY_IMPORT_FILE}${uuid}`; - const resp = await cacheInstance.get<{ filename: string; url: string }>(cacheKey); - if (!resp) throw new Error("fetchData failed"); - const filedata = await fetch(resp.url).then((resp) => resp.blob()); - const zip = await loadAsyncJSZip(filedata); - const backData = await parseBackupZipFile(zip); - const backDataScript = backData.script as ScriptData[]; - - // 使用缓存优化脚本加载速度 - const scriptDAO = new ScriptDAO(); - scriptDAO.enableCache(); - - // setScripts(backDataScript); - // 获取各个脚本现在已经存在的信息 - await Promise.all( - backDataScript.map(async (item) => { - try { - const prepareScript = await prepareScriptByCode( - item.code, - item.options?.meta.file_url || "", - item.options?.meta.sc_uuid || undefined, - true, - scriptDAO - ); - item.script = prepareScript; - } catch (e: any) { - item.error = e.toString(); - return item; - } - if (!item.options) { - item.options = { - options: {} as ScriptOptions, - meta: { - name: item.script?.script.name, - // 此uuid是对tm的兼容处理 - uuid: item.script?.script.uuid, - sc_uuid: item.script?.script.uuid, - file_url: item.script?.script.downloadUrl || "", - modified: item.script?.script.createtime, - subscribe_url: item.script?.script.subscribeUrl, - }, - settings: { - enabled: - item.enabled === false - ? false - : !(item.script?.script.metadata.background || item.script?.script.metadata.crontab), - position: item.script?.script.sort, - }, - }; - } - item.script.script.sort = item.options.settings.position || 0; - item.script.script.status = - item.enabled !== false && item.options.settings.enabled ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE; - item.install = true; - return item; - }) - ); - const results = backDataScript.slice().sort((a, b) => { - const aName = a.script?.script?.name || ""; - const bName = b.script?.script?.name || ""; - if (aName && bName) return aName.localeCompare(bName); - return 0; - }); - setScripts(results); - setSelectAll([true, true]); - setLoading(false); - } catch (e) { - Message.error(`获取导入文件失败: ${e}`); - } - }; - - useEffect(() => { - fetchData(); - }, []); - - const scriptImportAsync = async (item: ScriptData) => { - try { - if (item.script?.script) { - if (item.script.script.ignoreVersion) item.script.script.ignoreVersion = ""; - } - const scriptDetails = item.script!.script!; - const createtime = item.lastModificationDate; - const updatetime = item.lastModificationDate; - await scriptClient.install({ script: scriptDetails, code: item.code, createtime, updatetime }); - await Promise.all([ - (async () => { - // 导入资源 - if (!item.requires || !item.resources || !item.requiresCss) return; - if (!item.requires[0] && !item.resources[0] && !item.requiresCss[0]) return; - await sleep(((Math.random() * 600) | 0) + 200); - await synchronizeClient.importResources( - item.script?.script.uuid, - item.requires, - item.resources, - item.requiresCss - ); - })(), - (async () => { - // 导入数据 - const { data } = item.storage; - const ts = item.storage.ts || 0; - const entries = Object.entries(data); - if (entries.length === 0) return; - await sleep(((Math.random() * 600) | 0) + 200); - const uuid = item.script!.script.uuid!; - const keyValuePairs = [] as TKeyValuePair[]; - for (const [key, value] of entries) { - keyValuePairs.push([key, encodeRValue(value)]); - } - await valueClient.setScriptValues({ uuid: uuid, keyValuePairs, isReplace: false, ts: ts }); - })(), - ]); - setInstallNum((prev) => [prev[0] + 1, prev[1]]); - } catch (e: any) { - // 跳過失敗 - item.error = e.toString(); - } - }; - - const importScripts = async (scripts: ScriptData[]) => { - const promises: Promise[] = []; - for (const item of scripts) { - if (item.install && !item.error) { - promises.push(scriptImportAsync(item)); - } - } - return Promise.all(promises); - }; - - const handleScriptToggle = (index: number) => { - let bool: boolean; - setScripts((prevScripts) => { - prevScripts = prevScripts.map((script, i) => (i === index ? { ...script, install: !script.install } : script)); - bool = prevScripts.every((script) => script.install); - return prevScripts; - }); - setSelectAll((prev) => [bool, prev[1]]); - }; - - const { - importButtonClick, - closeButtonClick, - handleSelectAllScripts, - handleSelectAllSubscribes, - handleScriptToggleClick, - handleScriptStatusToggle, - } = { - importButtonClick: async () => { - setInstallNum((prev) => [0, prev[1]]); - setLoading(true); - await importScripts(scripts); - setLoading(false); - Message.success(t("import_success")!); - }, - closeButtonClick: () => window.close(), - handleSelectAllScripts: () => { - setSelectAll((prev) => { - const newValue = !prev[0]; - setScripts((prevScripts) => prevScripts.map((script) => ({ ...script, install: newValue }))); - return [newValue, prev[1]]; - }); - }, - handleSelectAllSubscribes: () => { - setSelectAll((prev) => { - const newValue = !prev[1]; - setSubscribes((prevSubscribes) => prevSubscribes.map((subscribe) => ({ ...subscribe, install: newValue }))); - return [prev[0], newValue]; - }); - }, - handleScriptToggleClick: handleScriptToggle, - handleScriptStatusToggle: (index: number, checked: boolean) => { - setScripts((prevScripts) => - prevScripts.map((prevScript, i) => - i === index - ? { - ...prevScript, - script: { - ...prevScript.script!, - script: { - ...prevScript.script!.script, - status: checked ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE, - }, - }, - } - : prevScript - ) - ); - }, - }; - - return ( -
- - - - - - - - {`${t("select_scripts_to_import")}: `} - - {t("select_all")} - - - {`${t("script_import_progress")}: ${installNum[0]}/${scripts.length}`} - - - {`${t("select_subscribes_to_import")}: `} - - {t("select_all")} - - - {`${t("subscribe_import_progress")}: ${installNum[1]}/${subscribes.length}`} - - {scripts.length > 0 && ( - ( - - )} - /> - )} - - -
- ); -} - -export default App; diff --git a/src/pages/import/index.css b/src/pages/import/index.css deleted file mode 100644 index 97c806cf5..000000000 --- a/src/pages/import/index.css +++ /dev/null @@ -1,3 +0,0 @@ -.import-list .arco-typography { - margin: 0; -} diff --git a/src/pages/import/main.tsx b/src/pages/import/main.tsx deleted file mode 100644 index fe51ddedd..000000000 --- a/src/pages/import/main.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import App from "./App.tsx"; -import { AppProvider } from "../store/AppContext.tsx"; -import MainLayout from "../components/layout/MainLayout.tsx"; -import LoggerCore from "@App/app/logger/core.ts"; -import { message } from "../store/global.ts"; -import MessageWriter from "@App/app/logger/message_writer.ts"; -import "@arco-design/web-react/dist/css/arco.css"; -import "@App/locales/locales"; -import "@App/index.css"; - -// 初始化日志组件 -const loggerCore = new LoggerCore({ - writer: new MessageWriter(message), - labels: { env: "import" }, -}); - -loggerCore.logger().debug("import page start"); - -const Root = ( - - - - - -); - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - process.env.NODE_ENV === "development" ? {Root} : Root -); diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx deleted file mode 100644 index cc5453e59..000000000 --- a/src/pages/install/App.tsx +++ /dev/null @@ -1,1001 +0,0 @@ -import { - Button, - Dropdown, - Message, - Menu, - Modal, - Space, - Switch, - Tag, - Tooltip, - Typography, - Popover, -} from "@arco-design/web-react"; -import { IconDown } from "@arco-design/web-react/icon"; -import { uuidv4 } from "@App/pkg/utils/uuid"; -import CodeEditor from "../components/CodeEditor"; -import { useEffect, useMemo, useState } from "react"; -import type { SCMetadata, Script } from "@App/app/repo/scripts"; -import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE } from "@App/app/repo/scripts"; -import type { Subscribe } from "@App/app/repo/subscribe"; -import { i18nDescription, i18nName } from "@App/locales/locales"; -import { useTranslation } from "react-i18next"; -import { createScriptInfo, type ScriptInfo } from "@App/pkg/utils/scriptInstall"; -import { parseMetadata, prepareScriptByCode, prepareSubscribeByCode } from "@App/pkg/utils/script"; -import { nextTimeDisplay } from "@App/pkg/utils/cron"; -import { scriptClient, subscribeClient } from "../store/features/script"; -import { type FTInfo, startFileTrack, unmountFileTrack } from "@App/pkg/utils/file-tracker"; -import { cleanupOldHandles, loadHandle, saveHandle } from "@App/pkg/utils/filehandle-db"; -import { dayFormat } from "@App/pkg/utils/day_format"; -import { intervalExecution, timeoutExecution } from "@App/pkg/utils/timer"; -import { useSearchParams } from "react-router-dom"; -import { CACHE_KEY_SCRIPT_INFO } from "@App/app/cache_key"; -import { cacheInstance } from "@App/app/cache"; -import { formatBytes, isPermissionOk } from "@App/pkg/utils/utils"; -import { ScriptIcons } from "../options/routes/utils"; -import { bytesDecode, detectEncoding } from "@App/pkg/utils/encoding"; -import { prettyUrl } from "@App/pkg/utils/url-utils"; - -const backgroundPromptShownKey = "background_prompt_shown"; - -type ScriptOrSubscribe = Script | Subscribe; - -// Types -interface PermissionItem { - label: string; - color?: string; - value: string[]; -} - -type Permission = PermissionItem[]; - -const closeWindow = (doBackwards: boolean) => { - if (doBackwards) { - history.go(-1); - } else { - window.close(); - } -}; - -const fetchScriptBody = async (url: string, { onProgress }: { [key: string]: any }) => { - let origin; - try { - origin = new URL(url).origin; - } catch { - throw new Error(`Invalid url: ${url}`); - } - const response = await fetch(url, { - headers: { - "Cache-Control": "no-cache", - // 参考:加权 Accept-Encoding 值说明 - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding#weighted_accept-encoding_values - "Accept-Encoding": "br;q=1.0, gzip;q=0.8, *;q=0.1", - Origin: origin, - }, - referrer: origin + "/", - }); - - if (!response.ok) { - throw new Error(`Fetch failed with status ${response.status}`); - } - - if (!response.body || !response.headers) { - throw new Error("No response body or headers"); - } - const reader = response.body.getReader(); - - // 读取数据 - let receivedLength = 0; // 当前已接收的长度 - const chunks = []; // 已接收的二进制分片数组(用于组装正文) - while (true) { - const { done, value } = await reader.read(); - - if (done) { - break; - } - - chunks.push(value); - receivedLength += value.length; - onProgress?.({ receivedLength }); - } - - // 合并分片(chunks) - const chunksAll = new Uint8Array(receivedLength); - let position = 0; - for (const chunk of chunks) { - chunksAll.set(chunk, position); - position += chunk.length; - } - - // 检测编码:优先使用 Content-Type,回退到 chardet(仅检测前16KB) - const contentType = response.headers.get("content-type"); - const encode = detectEncoding(chunksAll, contentType); - - // 使用检测到的 charset 解码 - let code; - try { - code = bytesDecode(encode, chunksAll); - } catch (e: any) { - console.warn(`Failed to decode response with charset ${encode}: ${e.message}`); - // 回退到 UTF-8 - code = new TextDecoder("utf-8").decode(chunksAll); - } - - const metadata = parseMetadata(code); - if (!metadata) { - throw new Error("parse script info failed"); - } - - return { code, metadata }; -}; - -const cleanupStaleInstallInfo = (uuid: string) => { - // 页面打开时不清除当前uuid,每30秒更新一次记录 - const f = () => { - cacheInstance.tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { - val = val || {}; - val[uuid] = Date.now(); - tx.set(val); - }); - }; - f(); - setInterval(f, 30_000); - - // 页面打开后清除旧记录 - const delay = Math.floor(5000 * Math.random()) + 10000; // 使用随机时间避免浏览器重启时大量Tabs同时执行清除 - timeoutExecution( - `${cIdKey}cleanupStaleInstallInfo`, - () => { - cacheInstance - .tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { - const now = Date.now(); - const keeps = new Set(); - const out: Record = {}; - for (const [k, ts] of Object.entries(val ?? {})) { - if (ts > 0 && now - ts < 60_000) { - keeps.add(`${CACHE_KEY_SCRIPT_INFO}${k}`); - out[k] = ts; - } - } - tx.set(out); - return keeps; - }) - .then(async (keeps) => { - const list = await cacheInstance.list(); - const filtered = list.filter((key) => key.startsWith(CACHE_KEY_SCRIPT_INFO) && !keeps.has(key)); - if (filtered.length) { - // 清理缓存 - cacheInstance.dels(filtered); - } - }); - }, - delay - ); -}; - -const cIdKey = `(cid_${Math.random()})`; - -function App() { - const [enable, setEnable] = useState(false); - const [btnText, setBtnText] = useState(""); - const [scriptCode, setScriptCode] = useState(""); - const [scriptInfo, setScriptInfo] = useState(); - const [upsertScript, setUpsertScript] = useState(undefined); - const [diffCode, setDiffCode] = useState(); - const [oldScriptVersion, setOldScriptVersion] = useState(null); - const [isUpdate, setIsUpdate] = useState(false); - const [localFileHandle, setLocalFileHandle] = useState(null); - const [showBackgroundPrompt, setShowBackgroundPrompt] = useState(false); - const { t } = useTranslation(); - const [searchParams, setSearchParams] = useSearchParams(); - const [loaded, setLoaded] = useState(false); - const [doBackwards, setDoBackwards] = useState(false); - - const installOrUpdateScript = async (newScript: Script, code: string) => { - if (newScript.ignoreVersion) newScript.ignoreVersion = ""; - await scriptClient.install({ script: newScript, code }); - const metadata = newScript.metadata; - setScriptInfo((prev) => (prev ? { ...prev, code, metadata } : prev)); - const scriptVersion = metadata.version?.[0]; - const oldScriptVersion = typeof scriptVersion === "string" ? scriptVersion : "N/A"; - setOldScriptVersion(oldScriptVersion); - setUpsertScript(newScript); - setDiffCode(code); - }; - - const getUpdatedNewScript = async (uuid: string, code: string) => { - const oldScript = await scriptClient.info(uuid); - if (!oldScript || oldScript.uuid !== uuid) { - throw new Error("uuid is mismatched"); - } - const { script } = await prepareScriptByCode(code, oldScript.origin || "", uuid); - script.origin = oldScript.origin || script.origin || ""; - if (!script.name) { - throw new Error(t("script_name_cannot_be_set_to_empty")); - } - return script; - }; - - const initAsync = async () => { - try { - const uuid = searchParams.get("uuid"); - const fid = searchParams.get("file"); - - // 如果有 url 或 没有 uuid 和 file,跳过初始化逻辑 - if (searchParams.get("url") || (!uuid && !fid)) { - return; - } - let info: ScriptInfo | undefined; - let isKnownUpdate: boolean = false; - - if (window.history.length > 1) { - setDoBackwards(true); - } - setLoaded(true); - - let paramOptions = {}; - if (uuid) { - const cachedInfo = await scriptClient.getInstallInfo(uuid); - cleanupStaleInstallInfo(uuid); - if (cachedInfo?.[0]) isKnownUpdate = true; - info = cachedInfo?.[1] || undefined; - paramOptions = cachedInfo?.[2] || {}; - if (!info) { - throw new Error("fetch script info failed"); - } - } else { - // 检查是不是本地文件安装 - if (!fid) { - throw new Error("url param - local file id is not found"); - } - const fileHandle = await loadHandle(fid); - if (!fileHandle) { - throw new Error("invalid file access - fileHandle is null"); - } - const file = await fileHandle.getFile(); - if (!file) { - throw new Error("invalid file access - file is null"); - } - // 处理本地文件的安装流程 - // 处理成info对象 - setLocalFileHandle((prev) => { - if (prev instanceof FileSystemFileHandle) unmountFileTrack(prev); - return fileHandle!; - }); - - // 刷新 timestamp, 使 10s~15s 后不会被立即清掉 - // 每五分钟刷新一次db记录的timestamp,使开启中的安装页面的fileHandle不会被刷掉 - intervalExecution(`${cIdKey}liveFileHandle`, () => saveHandle(fid, fileHandle), 5 * 60 * 1000, true); - - const code = await file.text(); - const metadata = parseMetadata(code); - if (!metadata) { - throw new Error("parse script info failed"); - } - info = createScriptInfo(uuidv4(), code, `file:///*from-local*/${file.name}`, "user", metadata); - } - - let prepare: - | { script: Script; oldScript?: Script; oldScriptCode?: string } - | { subscribe: Subscribe; oldSubscribe?: Subscribe }; - let action: Script | Subscribe; - - const { code, url } = info; - let oldVersion: string | undefined = undefined; - let diffCode: string | undefined = undefined; - if (info.userSubscribe) { - prepare = await prepareSubscribeByCode(code, url); - action = prepare.subscribe; - if (prepare.oldSubscribe) { - const oldSubscribeVersion = prepare.oldSubscribe.metadata.version?.[0]; - oldVersion = typeof oldSubscribeVersion === "string" ? oldSubscribeVersion : "N/A"; - } - diffCode = prepare.oldSubscribe?.code; - } else { - const knownUUID = isKnownUpdate ? info.uuid : undefined; - prepare = await prepareScriptByCode(code, url, knownUUID, false, undefined, paramOptions); - action = prepare.script; - if (prepare.oldScript) { - const oldScriptVersion = prepare.oldScript.metadata.version?.[0]; - oldVersion = typeof oldScriptVersion === "string" ? oldScriptVersion : "N/A"; - } - diffCode = prepare.oldScriptCode; - } - setScriptCode(code); - setDiffCode(diffCode); - setOldScriptVersion(typeof oldVersion === "string" ? oldVersion : null); - setIsUpdate(typeof oldVersion === "string"); - setScriptInfo(info); - setUpsertScript(action); - - // 检查是否需要显示后台运行提示 - if (!info.userSubscribe) { - setShowBackgroundPrompt(await checkBackgroundPrompt(action as Script)); - } - } catch (e: any) { - Message.error(t("script_info_load_failed") + " " + e.message); - } finally { - // fileHandle 保留处理方式(暂定): - // fileHandle 会保留一段足够时间,避免用户重新刷画面,重启浏览器等操作后,安装页变得空白一片。 - // 处理会在所有Tab都载入后(不包含睡眠Tab)进行,因此延迟 10s~15s 让处理有足够时间。 - // 安装页面关掉后15分钟为不保留状态,会在安装画面再次打开时(其他脚本安装),进行清除。 - const delay = Math.floor(5000 * Math.random()) + 10000; // 使用乱数时间避免浏览器重启时大量Tabs同时执行DB清除 - timeoutExecution(`${cIdKey}cleanupFileHandle`, cleanupOldHandles, delay); - } - }; - - useEffect(() => { - !loaded && initAsync(); - }, [searchParams, loaded]); - - const [watchFile, setWatchFile] = useState(false); - const metadataLive = useMemo(() => (scriptInfo?.metadata || {}) as SCMetadata, [scriptInfo]); - - const permissions = useMemo(() => { - const permissions: Permission = []; - - if (!scriptInfo) return permissions; - - if (scriptInfo.userSubscribe) { - permissions.push({ - label: t("subscribe_install_label"), - color: "#ff0000", - value: metadataLive.scripturl!, - }); - } - - if (metadataLive.match) { - permissions.push({ label: t("script_runs_in"), value: metadataLive.match }); - } - - if (metadataLive.connect) { - permissions.push({ - label: t("script_has_full_access_to"), - color: "#F9925A", - value: metadataLive.connect, - }); - } - - if (metadataLive.require) { - permissions.push({ label: t("script_requires"), value: metadataLive.require }); - } - - return permissions; - }, [scriptInfo, metadataLive, t]); - - const descriptionParagraph = useMemo(() => { - const ret: JSX.Element[] = []; - - if (!scriptInfo) return ret; - - const isCookie = metadataLive.grant?.some((val) => val === "GM_cookie"); - if (isCookie) { - ret.push( - - {t("cookie_warning")} - - ); - } - - if (metadataLive.crontab) { - ret.push({t("scheduled_script_description_title")}); - ret.push( -
- {t("scheduled_script_description_description_expr")} - {metadataLive.crontab[0]} - {t("scheduled_script_description_description_next")} - {nextTimeDisplay(metadataLive.crontab[0])} -
- ); - } else if (metadataLive.background) { - ret.push({t("background_script_description")}); - } - - return ret; - }, [scriptInfo, metadataLive, t]); - - const antifeatures: { [key: string]: { color: string; title: string; description: string } } = { - "referral-link": { - color: "purple", - title: t("antifeature_referral_link_title"), - description: t("antifeature_referral_link_description"), - }, - ads: { - color: "orange", - title: t("antifeature_ads_title"), - description: t("antifeature_ads_description"), - }, - payment: { - color: "magenta", - title: t("antifeature_payment_title"), - description: t("antifeature_payment_description"), - }, - miner: { - color: "orangered", - title: t("antifeature_miner_title"), - description: t("antifeature_miner_description"), - }, - membership: { - color: "blue", - title: t("antifeature_membership_title"), - description: t("antifeature_membership_description"), - }, - tracking: { - color: "pinkpurple", - title: t("antifeature_tracking_title"), - description: t("antifeature_tracking_description"), - }, - }; - - // 更新按钮文案和页面标题 - useEffect(() => { - if (scriptInfo?.userSubscribe) { - setBtnText(isUpdate ? t("update_subscribe")! : t("install_subscribe")); - } else { - setBtnText(isUpdate ? t("update_script")! : t("install_script")); - } - if (upsertScript) { - document.title = `${!isUpdate ? t("install_script") : t("update_script")} - ${i18nName(upsertScript!)} - ScriptCat`; - } - }, [isUpdate, scriptInfo, upsertScript, t]); - - // 设置脚本状态 - useEffect(() => { - if (upsertScript) { - setEnable(upsertScript.status === SCRIPT_STATUS_ENABLE); - } - }, [upsertScript]); - - // 检查是否需要显示后台运行提示 - const checkBackgroundPrompt = async (script: Script) => { - // 只有后台脚本或定时脚本才提示 - if (!script.metadata.background && !script.metadata.crontab) { - return false; - } - - // 检查是否首次安装或更新 - const hasShown = localStorage.getItem(backgroundPromptShownKey); - - if (hasShown !== "true") { - // 检查是否已经有后台权限 - const permission = await isPermissionOk("background"); - if (permission === false) return true; // optional permission "background" 需要显示后台运行提示 - } - return false; - }; - - const handleInstall = async (options: { closeAfterInstall?: boolean; noMoreUpdates?: boolean } = {}) => { - if (!upsertScript) { - Message.error(t("script_info_load_failed")!); - return; - } - - const { closeAfterInstall: shouldClose = true, noMoreUpdates: disableUpdates = false } = options; - - try { - if (scriptInfo?.userSubscribe) { - await subscribeClient.install(upsertScript as Subscribe); // 首次安装时,upsertScript 里的 scripts 为空物件 - Message.success(t("subscribe_success")!); - setBtnText(t("subscribe_success")!); - } else { - // 如果选择不再检查更新,可以在这里设置脚本的更新配置 - if (disableUpdates && upsertScript) { - // 这里可以设置脚本禁用自动更新的逻辑 - (upsertScript as Script).checkUpdate = false; - } - // 故意只安装或执行,不改变显示内容 - await scriptClient.install({ script: upsertScript as Script, code: scriptCode }); - if (isUpdate) { - Message.success(t("install.update_success")!); - setBtnText(t("install.update_success")!); - } else { - // 如果选择不再检查更新,可以在这里设置脚本的更新配置 - if (disableUpdates && upsertScript) { - // 这里可以设置脚本禁用自动更新的逻辑 - (upsertScript as Script).checkUpdate = false; - } - if ((upsertScript as Script).ignoreVersion) (upsertScript as Script).ignoreVersion = ""; - // 故意只安装或执行,不改变显示内容 - await scriptClient.install({ script: upsertScript as Script, code: scriptCode }); - if (isUpdate) { - Message.success(t("install.update_success")!); - setBtnText(t("install.update_success")!); - } else { - Message.success(t("install_success")!); - setBtnText(t("install_success")!); - } - } - } - - if (shouldClose) { - setTimeout(() => { - closeWindow(doBackwards); - }, 500); - } - } catch (e) { - const errorMessage = scriptInfo?.userSubscribe ? t("subscribe_failed") : t("install_failed"); - Message.error(`${errorMessage}: ${e}`); - } - }; - - const handleClose = (options?: { noMoreUpdates: boolean }) => { - const { noMoreUpdates = false } = options || {}; - if (noMoreUpdates && scriptInfo && !scriptInfo.userSubscribe) { - scriptClient.setCheckUpdateUrl(scriptInfo.uuid, false); - } - closeWindow(doBackwards); - }; - - const { - handleInstallBasic, - handleInstallCloseAfterInstall, - handleInstallNoMoreUpdates, - handleStatusChange, - handleCloseBasic, - handleCloseNoMoreUpdates, - setWatchFileClick, - } = { - handleInstallBasic: () => handleInstall(), - handleInstallCloseAfterInstall: () => handleInstall({ closeAfterInstall: false }), - handleInstallNoMoreUpdates: () => handleInstall({ noMoreUpdates: true }), - handleStatusChange: (checked: boolean) => { - setUpsertScript((script) => { - if (!script) { - return script; - } - script.status = checked ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE; - setEnable(checked); - return script; - }); - }, - handleCloseBasic: () => handleClose(), - handleCloseNoMoreUpdates: () => handleClose({ noMoreUpdates: true }), - setWatchFileClick: () => { - setWatchFile((prev) => !prev); - }, - }; - - const fileWatchMessageId = `id_${Math.random()}`; - - async function onWatchFileCodeChanged(this: FTInfo, code: string, hideInfo: boolean = false) { - if (this.uuid !== scriptInfo?.uuid) return; - if (this.fileName !== localFileHandle?.name) return; - setScriptCode(code); - const uuid = (upsertScript as Script)?.uuid; - if (!uuid) { - throw new Error("uuid is undefined"); - } - try { - const newScript = await getUpdatedNewScript(uuid, code); - await installOrUpdateScript(newScript, code); - } catch (e) { - Message.error({ - id: fileWatchMessageId, - content: t("install_failed") + ": " + e, - }); - return; - } - if (!hideInfo) { - Message.info({ - id: fileWatchMessageId, - content: `${t("last_updated")}: ${dayFormat()}`, - duration: 3000, - closable: true, - showIcon: true, - }); - } - } - - async function onWatchFileError() { - // e.g. NotFoundError - setWatchFile(false); - } - - const memoWatchFile = useMemo(() => { - return `${watchFile}.${scriptInfo?.uuid}.${localFileHandle?.name}`; - }, [watchFile, scriptInfo, localFileHandle]); - - const setupWatchFile = async (uuid: string, fileName: string, handle: FileSystemFileHandle) => { - try { - // 如没有安装纪录,将进行安装。 - // 如已经安装,在FileSystemObserver检查更改前,先进行更新。 - const code = `${scriptCode}`; - await installOrUpdateScript(upsertScript as Script, code); - // setScriptCode(`${code}`); - setDiffCode(`${code}`); - const ftInfo: FTInfo = { - uuid, - fileName, - setCode: onWatchFileCodeChanged, - onFileError: onWatchFileError, - }; - // 进行监听 - startFileTrack(handle, ftInfo); - // 先取最新代码 - const file = await handle.getFile(); - const currentCode = await file.text(); - // 如不一致,先更新 - if (currentCode !== code) { - ftInfo.setCode(currentCode, true); - } - } catch (e: any) { - Message.error(`${e.message}`); - console.warn(e); - } - }; - - useEffect(() => { - if (!watchFile || !localFileHandle) { - return; - } - // 去除React特性 - const [handle] = [localFileHandle]; - unmountFileTrack(handle); // 避免重复追踪 - const uuid = scriptInfo?.uuid; - const fileName = handle?.name; - if (!uuid || !fileName) { - return; - } - setupWatchFile(uuid, fileName, handle); - return () => { - unmountFileTrack(handle); - }; - }, [memoWatchFile]); - - // 检查是否有 uuid 或 file - const searchParamUrl = searchParams.get("url"); - const hasValidSourceParam = !searchParamUrl && !!(searchParams.get("uuid") || searchParams.get("file")); - - const urlHref = useMemo(() => { - if (searchParamUrl) { - try { - // 取url=之后的所有内容 - const idx = location.search.indexOf("url="); - const rawUrl = idx !== -1 ? location.search.slice(idx + 4) : searchParamUrl; - const urlObject = new URL(rawUrl); - // 验证解析后的 URL 是否具备核心要素,确保安全性与合法性 - if (urlObject.protocol && urlObject.hostname && urlObject.pathname) { - return rawUrl; - } - } catch { - // ignored - } - } - return ""; - }, [searchParamUrl]); - - const [fetchingState, setFetchingState] = useState({ - loadingStatus: "", - errorStatus: "", - }); - - const loadURLAsync = async (url: string) => { - // 1. 定义获取单个脚本的内部逻辑,负责处理进度条与单次错误 - const fetchValidScript = async () => { - const result = await fetchScriptBody(url, { - onProgress: (info: { receivedLength: number }) => { - setFetchingState((prev) => ({ - ...prev, - loadingStatus: t("downloading_status_text", { bytes: formatBytes(info.receivedLength) }), - })); - }, - }); - if (result.code && result.metadata) { - return { result, url } as const; // 找到有效的立即返回 - } - throw new Error(t("install_page_load_failed")); - }; - - try { - // 2. 执行获取 - const { result, url } = await fetchValidScript(); - const { code, metadata } = result; - - // 3. 处理数据与缓存 - const uuid = uuidv4(); - const scriptData = [false, createScriptInfo(uuid, code, url, "user", metadata)]; - - await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, scriptData); - - // 4. 更新导向 - setSearchParams(new URLSearchParams(`?uuid=${uuid}`), { replace: true }); - } catch (err: any) { - // 5. 统一错误处理 - setFetchingState((prev) => ({ - ...prev, - loadingStatus: "", - errorStatus: `${err?.message || err}`, - })); - } - }; - - const handleUrlChangeAndFetch = (targetUrlHref: string) => { - setFetchingState((prev) => ({ - ...prev, - loadingStatus: t("install_page_please_wait"), - })); - loadURLAsync(targetUrlHref); - }; - - // 有 url 的话下载内容 - useEffect(() => { - if (urlHref) handleUrlChangeAndFetch(urlHref); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlHref]); - - if (!hasValidSourceParam) { - return urlHref ? ( -
- - {fetchingState.loadingStatus && ( - <> - {t("install_page_loading")} -
- {fetchingState.loadingStatus} -
-
- - )} - {fetchingState.errorStatus && ( - <> - {t("install_page_load_failed")} -
{fetchingState.errorStatus}
- - )} -
-
- ) : ( -
- - {t("invalid_page")} - -
- ); - } - - return ( -
- {/* 后台运行提示对话框 */} - { - try { - const granted = await chrome.permissions.request({ permissions: ["background"] }); - if (granted) { - Message.success(t("enable_background.title")!); - } else { - Message.info(t("enable_background.maybe_later")!); - } - setShowBackgroundPrompt(false); - localStorage.setItem(backgroundPromptShownKey, "true"); - } catch (e) { - console.error(e); - Message.error(t("enable_background.enable_failed")!); - } - }} - onCancel={() => { - setShowBackgroundPrompt(false); - localStorage.setItem(backgroundPromptShownKey, "true"); - }} - okText={t("enable_background.enable_now")} - cancelText={t("enable_background.maybe_later")} - autoFocus={false} - focusLock={true} - > - - - {t("enable_background.prompt_description", { - scriptType: upsertScript?.metadata?.background ? t("background_script") : t("scheduled_script"), - })} - - {t("enable_background.settings_hint")} - - -
-
- {upsertScript?.metadata.icon && } - {upsertScript && ( - - - {i18nName(upsertScript)} - - - )} - - - -
-
-
- {oldScriptVersion && ( - - {oldScriptVersion} - - )} - {typeof metadataLive.version?.[0] === "string" && metadataLive.version[0] !== oldScriptVersion && ( - - - {metadataLive.version[0]} - - - )} -
-
-
-
-
-
-
-
- {(metadataLive.background || metadataLive.crontab) && ( - - - {t("background_script")} - - - )} - {metadataLive.crontab && ( - - - {t("scheduled_script")} - - - )} - {metadataLive.antifeature?.length && - metadataLive.antifeature.map((antifeature) => { - const item = antifeature.split(" ")[0]; - return ( - antifeatures[item] && ( - - - {antifeatures[item].title} - - - ) - ); - })} -
-
-
- {upsertScript && i18nDescription(upsertScript!)} -
-
- {`${t("author")}: ${metadataLive.author}`} -
-
- - {`${t("source")}: ${prettyUrl(scriptInfo?.url)}`} - -
-
-
-
- {descriptionParagraph?.length ? ( -
- - - {descriptionParagraph} - - -
- ) : ( - <> - )} -
- {permissions.map((item) => ( -
- {item.value?.length > 0 ? ( - <> - - {item.label} - -
- {item.value.map((v) => ( -
- {v} -
- ))} -
- - ) : ( - <> - )} -
- ))} -
-
-
-
- {t("install_from_legitimate_sources_warning")} -
-
- - - - - - {isUpdate ? t("update_script_no_close") : t("install_script_no_close")} - - {!scriptInfo?.userSubscribe && ( - - {isUpdate ? t("update_script_no_more_update") : t("install_script_no_more_update")} - - )} - - } - position="bottom" - disabled={watchFile} - > - - - )} - {isUpdate ? ( - - - - {!scriptInfo?.userSubscribe && ( - - {t("close_update_script_no_more_update")} - - )} - - } - position="bottom" - > - - )} - -
-
-
- -
-
-
- ); -} - -export default App; diff --git a/src/pages/install/index.css b/src/pages/install/index.css deleted file mode 100644 index b54e8fd9b..000000000 --- a/src/pages/install/index.css +++ /dev/null @@ -1,108 +0,0 @@ -.monaco-diff-editor .diffOverview { - background: var(--vscode-editorGutter-background); -} - -#install-app-container { - display: flex; - flex-direction: column; - min-height: calc(100vh - 50px); - box-sizing: border-box; -} - -#show-code-container { - display: block; - height: calc( 100% - 44px ); - padding: 2px 2px; - position: relative; - box-sizing: border-box; - margin: 0; - border: 0; - flex-grow: 1; - flex-shrink: 0; - contain: strict; -} - -#show-code { /* 配合 .sc-inset-0 */ - margin: 0px; - padding: 0px; - border: 0px; - overflow: hidden; - border: 1px solid var(--color-neutral-5); - box-sizing: border-box; - position: absolute; - background: #071119; -} - -.downloading { - display: flex; - flex-direction: row; - flex-wrap: wrap; - column-gap: 4px; - align-items: center; -} - -.error-message { - color: red; -} - -.error-message:empty { - display: none; -} - -.error-message::before { - content: "ERROR: "; -} - -/* https://css-loaders.com/dots/ */ -.loader { - width: 60px; - aspect-ratio: 2; - --_g: no-repeat radial-gradient(circle closest-side, currentColor 90%, rgba(0,0,0,0)); - background: var(--_g) 0% 50%, var(--_g) 50% 50%, var(--_g) 100% 50%; - background-size: calc(100%/3) 50%; - animation: l3 1s infinite linear; - transform: scale(0.5); -} - -@keyframes l3 { - 20% { - background-position: 0% 0%, 50% 50%, 100% 50% - } - - 40% { - background-position: 0% 100%, 50% 0%, 100% 50% - } - - 60% { - background-position: 0% 50%, 50% 100%, 100% 0% - } - - 80% { - background-position: 0% 50%, 50% 50%, 100% 100% - } - -} - -.tag-container { - inline-size: min-content; - align-content: flex-start; - justify-items: flex-end; - justify-content: flex-end; -} - -.tag-container .arco-tag { - flex-grow: 1; - text-align: center; -} - -div.permission-entry span.arco-typography { - line-height: 1rem; - padding: 0; - margin: 0; -} - -div.permission-entry { - line-height: 1.2rem; - padding: 0; - margin: 0; -} diff --git a/src/pages/install/main.tsx b/src/pages/install/main.tsx deleted file mode 100644 index 2c1ac0f69..000000000 --- a/src/pages/install/main.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import App from "./App.tsx"; -import { AppProvider } from "../store/AppContext.tsx"; -import MainLayout from "../components/layout/MainLayout.tsx"; -import LoggerCore from "@App/app/logger/core.ts"; -import { message } from "../store/global.ts"; -import MessageWriter from "@App/app/logger/message_writer.ts"; -import "@arco-design/web-react/dist/css/arco.css"; -import "@App/locales/locales"; -import "@App/index.css"; -import "./index.css"; -import { registerEditor } from "@App/pkg/utils/monaco-editor"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; - -registerEditor(); - -// 初始化日志组件 -const loggerCore = new LoggerCore({ - writer: new MessageWriter(message), - labels: { env: "install" }, -}); - -loggerCore.logger().debug("install page start"); - -const MyApp = () => ( - - - - - -); -const Root = ( - - - } /> - - -); - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - process.env.NODE_ENV === "development" ? {Root} : Root -); diff --git a/src/pages/options.html b/src/pages/options.html deleted file mode 100644 index 3e3c3840a..000000000 --- a/src/pages/options.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - <%= htmlRspackPlugin.options.title %> - <% if isReactTools=="true" { %> - - <% } %> - - - -
- - diff --git a/src/pages/options/index.css b/src/pages/options/index.css deleted file mode 100644 index d7b98634f..000000000 --- a/src/pages/options/index.css +++ /dev/null @@ -1,111 +0,0 @@ -.show-log-card .arco-list-item { - border-bottom: 0 !important; -} - -h1.arco-typography, -h2.arco-typography, -h3.arco-typography, -h4.arco-typography, -h5.arco-typography, -h6.arco-typography { - margin-top: 0 !important; -} - -.script-list .arco-card-body { - padding: 0 !important; -} - -.max-table-cell .arco-table-cell { - display: block; - max-height: 100px; - overflow: auto; -} - -/* error、wran图标直接用的油猴CodeMirror编辑器图标 待优化*/ -.icon-error { - background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII=); - background-repeat: no-repeat; - background-position: center; - left: 10px !important; -} - -.icon-warn { - background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII=); - background-repeat: no-repeat; - background-position: center; - left: 10px !important; -} - -.actionList { - height: auto !important; -} - -.arco-table-custom-filter { - padding: 10px; - background-color: var(--color-bg-5); - box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.15); -} - -.arco-table-custom-filter span.arco-input-inner-wrapper, -.arco-table-custom-filter input.arco-input { - transition: none; -} - -#script-list .arco-table-th-item, -#script-list .arco-table-col-has-sorter .arco-table-cell-with-sorter, -#script-list .arco-table-td { - padding: 4px 8px; -} - -#script-list .arco-table-col-has-sorter { - padding: 0; -} - -#script-list col:not([style]):not([class]) { - max-width: 240px; - min-width: 100px; -} - -.source_cell .arco-table-cell-wrap-value, -.source_cell .arco-tag { - max-width: 100%; -} - -.script-list { - .arco-table-container { - border: none !important; - } - - .arco-table-border .arco-table-th:first-child, - .arco-table-border .arco-table-td:first-child { - border-left: none !important; - - } - - /* 卡片视图样式 */ - .script-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - } - - .script-card .arco-card-body { - padding: 16px !important; - } - - .script-card-grid { - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); - } - - @media (max-width: 960px) { - .script-card-grid { - grid-template-columns: 1fr 1fr; - } - } - - @media (max-width: 640px) { - .script-card-grid { - grid-template-columns: 1fr; - } - } - -} \ No newline at end of file diff --git a/src/pages/options/main.tsx b/src/pages/options/main.tsx deleted file mode 100644 index f95c9f909..000000000 --- a/src/pages/options/main.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import MainLayout from "../components/layout/MainLayout.tsx"; -import Sider from "../components/layout/Sider.tsx"; -import { AppProvider } from "../store/AppContext.tsx"; -import "@arco-design/web-react/dist/css/arco.css"; -import "@App/locales/locales"; -import "@App/index.css"; -import "./index.css"; -import LoggerCore from "@App/app/logger/core.ts"; -import { LoggerDAO } from "@App/app/repo/logger.ts"; -import DBWriter from "@App/app/logger/db_writer.ts"; -import { registerEditor } from "@App/pkg/utils/monaco-editor"; -import migrate from "@App/app/migrate.ts"; - -migrate(); - -registerEditor(); - -// 初始化日志组件 -const loggerCore = new LoggerCore({ - writer: new DBWriter(new LoggerDAO()), - labels: { env: "options" }, -}); - -loggerCore.logger().debug("options page start"); - -const Root = ( - - - - - -); - -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - process.env.NODE_ENV === "development" ? {Root} : Root -); diff --git a/src/pages/options/routes/Logger.tsx b/src/pages/options/routes/Logger.tsx deleted file mode 100644 index d77b5db94..000000000 --- a/src/pages/options/routes/Logger.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import React, { useEffect, useRef } from "react"; -import { BackTop, Button, Card, DatePicker, Input, List, Message, Space, Typography } from "@arco-design/web-react"; -import dayjs from "dayjs"; -import type { Logger } from "@App/app/repo/logger"; -import { LoggerDAO } from "@App/app/repo/logger"; -import type { Labels, Query } from "@App/pages/components/LogLabel"; -import LogLabel from "@App/pages/components/LogLabel"; -import { IconPlus } from "@arco-design/web-react/icon"; -import { useSearchParams } from "react-router-dom"; -import { formatUnixTime } from "@App/pkg/utils/day_format"; -import { useTranslation } from "react-i18next"; - -function LoggerPage() { - const [labels, setLabels] = React.useState({}); - const defaultQuery = JSON.parse(useSearchParams()[0].get("query") || "[{}]"); - const [init, setInit] = React.useState(0); - const [querys, setQuerys] = React.useState(defaultQuery); - const [logs, setLogs] = React.useState([]); - const [queryLogs, setQueryLogs] = React.useState([]); - const [search, setSearch] = React.useState(""); - const [startTime, setStartTime] = React.useState(dayjs().subtract(24, "hour").unix()); - const [endTime, setEndTime] = React.useState(dayjs().unix()); - // 标记 endTime 是否代表"当前时间",默认为 true - const [isNow, setIsNow] = React.useState(true); - // 用于强制触发数据重新加载 - const [refreshToken, setRefreshToken] = React.useState(0); - // 标记数据加载后是否需要自动执行过滤 - const needFilterRef = useRef(false); - // 标记本次 onChange 是否由快捷方式触发 - const shortcutClickRef = useRef(false); - const loggerDAO = new LoggerDAO(); - const systemConfig = { logCleanCycle: 1 }; - const { t } = useTranslation(); - - const onQueryLog = (logsToFilter?: Logger[]) => { - const data = logsToFilter ?? logs; - const newQueryLogs: Logger[] = []; - const regex = search && new RegExp(search); - data.forEach((log) => { - for (let i = 0; i < querys.length; i += 1) { - const query = querys[i]; - if (query.key) { - const value = log.label[query.key]; - switch (query.condition) { - case "=": - if (value != query.value) { - return; - } - break; - case "=~": - if (typeof value === "string" && !value.includes(query.value)) { - return; - } - break; - case "!=": - if (value == query.value) { - return; - } - break; - case "!~": - if (typeof value === "string" && !value.includes(query.value)) { - return; - } - break; - default: - if (value != query.value) { - return; - } - break; - } - } - } - if (regex && !regex.test(log.message)) { - return; - } - newQueryLogs.push(log); - }); - setInit(4); - setQueryLogs(newQueryLogs); - }; - - useEffect(() => { - if (init === 1 && defaultQuery.length && defaultQuery[0].key) { - onQueryLog(); - setInit(2); - } - }, [init]); - - useEffect(() => { - loggerDAO.queryLogs(startTime * 1000, endTime * 1000).then((l) => { - setLogs(l); - // 计算标签 - const newLabels = labels; - l.forEach((log) => { - Object.keys(log.label).forEach((key) => { - if (!newLabels[key]) { - newLabels[key] = {}; - } - const value = log.label[key]; - switch (typeof value) { - case "string": - case "number": - newLabels[key][value] = true; - break; - default: - break; - } - }); - }); - setLabels(newLabels); - // 如果是查询按钮触发的刷新,自动执行过滤 - if (needFilterRef.current) { - needFilterRef.current = false; - onQueryLog(l); - } else { - setQueryLogs([]); - } - if (init === 0) { - setInit(1); - } - }); - }, [startTime, endTime, refreshToken]); - - return ( - <> - document.getElementById("backtop")!} /> -
- - - { - if (!time || !time[0]) { - // 清除操作,恢复默认状态 - setStartTime(dayjs().subtract(24, "hour").unix()); - setEndTime(dayjs().unix()); - setIsNow(true); - return; - } - setStartTime(time[0].unix()); - setEndTime(time[1].unix()); - if (shortcutClickRef.current) { - shortcutClickRef.current = false; - setIsNow(true); - } else { - setIsNow(false); - } - }} - onSelectShortcut={() => { - shortcutClickRef.current = true; - }} - shortcuts={[ - { - text: t("last_5_minutes"), - value: () => [dayjs(), dayjs().add(-5, "minute")], - }, - { - text: t("last_15_minutes"), - value: () => [dayjs(), dayjs().add(-15, "minute")], - }, - { - text: t("last_30_minutes"), - value: () => [dayjs(), dayjs().add(-30, "minute")], - }, - { - text: t("last_1_hour"), - value: () => [dayjs(), dayjs().add(-1, "hour")], - }, - { - text: t("last_3_hours"), - value: () => [dayjs(), dayjs().add(-3, "hour")], - }, - { - text: t("last_6_hours"), - value: () => [dayjs(), dayjs().add(-6, "hour")], - }, - { - text: t("last_12_hours"), - value: () => [dayjs(), dayjs().add(-12, "hour")], - }, - { - text: t("last_24_hours"), - value: () => [dayjs(), dayjs().add(-24, "hour")], - }, - { - text: t("last_7_days"), - value: () => [dayjs(), dayjs().add(-7, "day")], - }, - ]} - /> - - - } - > - - -
{t("labels")}
- - {querys.map((query, index) => ( - { - setQuerys((prev) => prev.map((query, i) => (i === index ? v : query))); - }} - onClose={() => { - setQuerys((prev) => prev.filter((_query, i) => i !== index)); - }} - /> - ))} - - - - } - style={{ - padding: 8, - height: "100%", - boxSizing: "border-box", - }} - > - - {formatUnixTime(startTime)} {t("to")} {isNow ? t("now") : formatUnixTime(endTime)}{" "} - {t("total_logs", { length: logs.length })} - {init === 4 - ? `, ${t("filtered_logs", { length: queryLogs.length })}` - : `, ${t("enter_filter_conditions")}`} - - ( - - {formatUnixTime(item.createtime / 1000)}{" "} - {typeof item.message === "object" ? JSON.stringify(item.message) : item.message}{" "} - {JSON.stringify(item.label)} - - )} - /> - -
-
- - ); -} - -export default LoggerPage; diff --git a/src/pages/options/routes/ScriptList/ScriptCard.tsx b/src/pages/options/routes/ScriptList/ScriptCard.tsx deleted file mode 100644 index 5aa5a17b0..000000000 --- a/src/pages/options/routes/ScriptList/ScriptCard.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import React, { createContext, useCallback, useContext, useMemo } from "react"; -import { Avatar, Button, Card, Divider, Popconfirm, Space, Tag, Tooltip, Typography } from "@arco-design/web-react"; -import { Link, useNavigate } from "react-router-dom"; -import { IconClockCircle, IconDragDotVertical } from "@arco-design/web-react/icon"; -import { - RiDeleteBin5Fill, - RiPencilFill, - RiPlayFill, - RiSettings3Fill, - RiStopFill, - RiUploadCloudFill, -} from "react-icons/ri"; -import type { Script, UserConfig } from "@App/app/repo/scripts"; -import { SCRIPT_RUN_STATUS_RUNNING, SCRIPT_TYPE_BACKGROUND, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; -import { requestEnableScript } from "@App/pages/store/features/script"; -import { nextTimeDisplay } from "@App/pkg/utils/cron"; -import { i18nName } from "@App/locales/locales"; -import { hashColor, ScriptIcons } from "../utils"; -import { getCombinedMeta } from "@App/app/service/service_worker/utils"; -import { parseTags } from "@App/app/repo/metadata"; -import type { ScriptLoading } from "@App/pages/store/features/script"; -import { EnableSwitch, HomeCell, MemoizedAvatar, ScriptSearchField, SourceCell, UpdateTimeCell } from "./components"; -import { useTranslation } from "react-i18next"; -import { VscLayoutSidebarLeft, VscLayoutSidebarLeftOff } from "react-icons/vsc"; -import { FaThList } from "react-icons/fa"; -import type { DragEndEvent } from "@dnd-kit/core"; -import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; -import { rectSwappingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { type SearchFilterRequest } from "./SearchFilter"; -import type { SearchType } from "@App/app/service/service_worker/types"; - -type DragCtx = Pick, "listeners" | "setActivatorNodeRef"> | null; -const SortableDragCtx = createContext(null); - -type DraggableEntryProps = { - recordUUID: string; - children: React.ReactElement; -}; - -const DraggableEntry = ({ recordUUID, children }: DraggableEntryProps) => { - const { setNodeRef, transform, transition, listeners, setActivatorNodeRef, isDragging, attributes } = useSortable({ - id: recordUUID, - }); - - const style = { - ...children.props.style, // 合并已有样式 - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - zIndex: isDragging ? 10 : "auto", - }; - - const ctxValue = useMemo( - () => ({ - listeners, - setActivatorNodeRef, - }), - [listeners, setActivatorNodeRef] - ); - - return ( - - {React.cloneElement(children, { - ...attributes, - ref: setNodeRef, - style, - })} - - ); -}; - -const DragHandle = () => { - const sortable = useContext(SortableDragCtx); - - const { listeners, setActivatorNodeRef } = sortable || {}; - const style = { cursor: "move", padding: 6 }; - - return !setActivatorNodeRef ? ( - - - - ) : ( - - - - ); -}; - -interface ScriptCardItemProps { - item: ScriptLoading; - updateScripts: (uuids: string[], data: Partial + ScriptCat + + + +
+ + diff --git a/src/pages/confirm/App.test.tsx b/src/pages/confirm/App.test.tsx new file mode 100644 index 000000000..c3b45165d --- /dev/null +++ b/src/pages/confirm/App.test.tsx @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { initLanguage } from "@App/locales/locales"; +import type { ConfirmParam } from "@App/app/service/service_worker/permission_verify"; + +// 授权数据走后台消息,统一打桩 +const { getPermissionInfo, confirm } = vi.hoisted(() => ({ + getPermissionInfo: vi.fn(), + confirm: vi.fn(), +})); +vi.mock("@App/pages/store/features/script", () => ({ + permissionClient: { getPermissionInfo, confirm }, +})); + +import { PermissionConfirm } from "./App"; + +const baseInfo = (over: Partial = {}, likeNum = 0) => ({ + script: { uuid: "u1", name: "Bilibili 视频下载助手", metadata: { version: ["1.2.0"] } }, + confirm: { + permission: "cors", + permissionValue: "api.bilibili.com", + title: "脚本正在试图访问跨域资源", + describe: "请确认是否允许该脚本访问跨域资源。", + wildcard: true, + permissionContent: "域名", + metadata: { + 脚本名称: "Bilibili 视频下载助手", + 请求域名: "api.bilibili.com", + 请求地址: "https://api.bilibili.com/x/player/playurl", + }, + ...over, + } as ConfirmParam, + likeNum, +}); + +beforeEach(() => { + initLanguage("zh-CN"); + vi.clearAllMocks(); + vi.spyOn(window, "close").mockImplementation(() => {}); + confirm.mockResolvedValue(undefined); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("授权确认页 · 渲染", () => { + it("加载后应展示标题、描述与请求域名", async () => { + getPermissionInfo.mockResolvedValue(baseInfo()); + render(); + expect(await screen.findByText("脚本正在试图访问跨域资源")).toBeInTheDocument(); + expect(screen.getByText("请确认是否允许该脚本访问跨域资源。")).toBeInTheDocument(); + expect(screen.getByText("api.bilibili.com")).toBeInTheDocument(); + }); + + it("脚本名应只出现在身份行,不在请求信息中重复", async () => { + getPermissionInfo.mockResolvedValue(baseInfo()); + render(); + await screen.findByText("脚本正在试图访问跨域资源"); + // 身份行展示脚本名,但 metadata 的「脚本名称」标签不应再次出现 + expect(screen.getByText("Bilibili 视频下载助手")).toBeInTheDocument(); + expect(screen.queryByText("脚本名称")).not.toBeInTheDocument(); + }); +}); + +describe("授权确认页 · 时长与范围映射", () => { + it("默认时长为「仅此次」,点击允许应以 type 1 确认", async () => { + getPermissionInfo.mockResolvedValue(baseInfo()); + render(); + fireEvent.click(await screen.findByRole("button", { name: "允许" })); + await waitFor(() => expect(confirm).toHaveBeenCalledWith("u1", { allow: true, type: 1 })); + }); + + it("选择「永久」后点击允许应以 type 5 确认", async () => { + getPermissionInfo.mockResolvedValue(baseInfo()); + render(); + fireEvent.click(await screen.findByRole("button", { name: "永久" })); + fireEvent.click(screen.getByRole("button", { name: "允许" })); + await waitFor(() => expect(confirm).toHaveBeenCalledWith("u1", { allow: true, type: 5 })); + }); + + it("通配权限且 likeNum>2 时,开启通配并选永久,允许应为 type 4", async () => { + getPermissionInfo.mockResolvedValue(baseInfo({ wildcard: true }, 3)); + render(); + fireEvent.click(await screen.findByRole("button", { name: "永久" })); + fireEvent.click(screen.getByRole("switch")); + fireEvent.click(screen.getByRole("button", { name: "允许" })); + await waitFor(() => expect(confirm).toHaveBeenCalledWith("u1", { allow: true, type: 4 })); + }); + + it("点击拒绝应以当前时长 type 确认 allow=false", async () => { + getPermissionInfo.mockResolvedValue(baseInfo()); + render(); + fireEvent.click(await screen.findByRole("button", { name: "拒绝" })); + await waitFor(() => expect(confirm).toHaveBeenCalledWith("u1", { allow: false, type: 1 })); + }); + + it("点击忽略应以 type 0 确认(忽略不留授权记录)", async () => { + getPermissionInfo.mockResolvedValue(baseInfo()); + render(); + await screen.findByText("脚本正在试图访问跨域资源"); + fireEvent.click(screen.getByRole("button", { name: /忽略/ })); + await waitFor(() => expect(confirm).toHaveBeenCalledWith("u1", { allow: false, type: 0 })); + }); +}); + +describe("授权确认页 · 选项可见性", () => { + it("通配权限但 likeNum≤2 时不显示通配开关", async () => { + getPermissionInfo.mockResolvedValue(baseInfo({ wildcard: true }, 2)); + render(); + await screen.findByText("脚本正在试图访问跨域资源"); + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + }); + + it("persistentOnly 权限不展示「临时」选项", async () => { + getPermissionInfo.mockResolvedValue(baseInfo({ persistentOnly: true, wildcard: false })); + render(); + await screen.findByText("脚本正在试图访问跨域资源"); + expect(screen.getByRole("button", { name: "仅此次" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "永久" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "临时" })).not.toBeInTheDocument(); + }); + + it("cookie 权限应展示高敏感警示", async () => { + getPermissionInfo.mockResolvedValue( + baseInfo({ permission: "cookie", title: "脚本正在试图访问网站 Cookie", wildcard: false }) + ); + render(); + expect(await screen.findByText("高敏感权限")).toBeInTheDocument(); + }); +}); + +describe("授权确认页 · 站点访问变体", () => { + it("仅展示「请求权限」单按钮,不展示时长选择与允许/拒绝", async () => { + getPermissionInfo.mockResolvedValue( + baseInfo({ permission: "extension-site-access", title: "ScriptCat 需要站点访问权限", wildcard: false }) + ); + render(); + expect(await screen.findByRole("button", { name: "请求权限" })).toBeInTheDocument(); + expect(screen.queryByText("授权时长")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "允许" })).not.toBeInTheDocument(); + }); + + it("点击请求权限应先申请站点访问再以 type 1 确认", async () => { + const requestSpy = vi.spyOn(chrome.permissions, "request").mockResolvedValue(true as never); + getPermissionInfo.mockResolvedValue( + baseInfo({ + permission: "extension-site-access", + title: "ScriptCat 需要站点访问权限", + wildcard: false, + extensionSiteAccessOrigins: ["https://example.com/*"], + }) + ); + render(); + fireEvent.click(await screen.findByRole("button", { name: "请求权限" })); + await waitFor(() => expect(requestSpy).toHaveBeenCalledWith({ origins: ["https://example.com/*"] })); + await waitFor(() => expect(confirm).toHaveBeenCalledWith("u1", { allow: true, type: 1 })); + }); +}); + +describe("授权确认页 · 倒计时", () => { + it("倒计时归零应以忽略(type 0)自动关闭", async () => { + vi.useFakeTimers(); + getPermissionInfo.mockResolvedValue(baseInfo()); + render(); + // 等待数据加载(promise microtask) + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(31000); + }); + expect(confirm).toHaveBeenCalledWith("u1", { allow: false, type: 0 }); + }); +}); diff --git a/src/pages/confirm/App.tsx b/src/pages/confirm/App.tsx new file mode 100644 index 000000000..f2314ba80 --- /dev/null +++ b/src/pages/confirm/App.tsx @@ -0,0 +1,352 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Cookie, FolderSync, Globe, ShieldCheck, TriangleAlert, CircleAlert, type LucideIcon } from "lucide-react"; +import { permissionClient } from "@App/pages/store/features/script"; +import { Button } from "@App/pages/components/ui/button"; +import { Switch } from "@App/pages/components/ui/switch"; +import { cn } from "@App/pkg/utils/cn"; +import { + resolveConfirmType, + availableDurations, + canApplyToAll, + isSiteAccess, + isHighSensitive, + type Duration, +} from "./confirm-options"; + +type ConfirmInfo = Awaited>; + +const BRAND = "#1296DB"; +const ORANGE = "#FF9500"; + +const DURATION_LABEL: Record = { + once: "duration_once", + temporary: "duration_temporary", + permanent: "duration_permanent", +}; + +// 不同权限的图标与配色 +function permissionVisual(permission: string): { Icon: LucideIcon; fg?: string; bgClass?: string } { + switch (permission) { + case "cookie": + return { Icon: Cookie, fg: ORANGE }; + case "file_storage": + return { Icon: FolderSync, bgClass: "bg-secondary" }; + case "extension-site-access": + return { Icon: ShieldCheck, fg: BRAND }; + default: + return { Icon: Globe, fg: BRAND }; + } +} + +function BrandMark() { + return ( +
+
+ S +
+ ScriptCat +
+ ); +} + +function PageShell({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} + +const cardClass = "flex w-full max-w-[480px] flex-col gap-5 rounded-2xl border bg-card p-7 shadow-xl"; + +export function PermissionConfirm({ uuid }: { uuid: string }) { + const { t } = useTranslation(["permission", "common"]); + const [info, setInfo] = useState(); + const [loadError, setLoadError] = useState(false); + const [duration, setDuration] = useState("once"); + const [applyToAll, setApplyToAll] = useState(false); + const [second, setSecond] = useState(30); + const decidedRef = useRef(false); + + const decide = useCallback( + async (allow: boolean, type: number) => { + if (decidedRef.current) return; + decidedRef.current = true; + try { + await permissionClient.confirm(uuid, { allow, type }); + window.close(); + } catch (e) { + toast.error((e as Error)?.message || t("common:confirm_error")); + setTimeout(() => window.close(), 3000); + } + }, + [uuid, t] + ); + + const ignore = useCallback(() => decide(false, 0), [decide]); + const ignoreRef = useRef(ignore); + ignoreRef.current = ignore; + + // 加载授权信息 + useEffect(() => { + permissionClient + .getPermissionInfo(uuid) + .then(setInfo) + .catch(() => setLoadError(true)); + }, [uuid]); + + // 倒计时:归零按「忽略」自动关闭 + useEffect(() => { + if (!info) return; + const timer = setInterval(() => { + setSecond((s) => { + if (s <= 1) { + clearInterval(timer); + ignoreRef.current(); + return 0; + } + return s - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, [info]); + + // 用户直接关闭窗口时记为忽略,避免脚本调用悬挂 + useEffect(() => { + const handler = () => { + if (!decidedRef.current) permissionClient.confirm(uuid, { allow: false, type: 0 }); + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [uuid]); + + // 加载失败:3 秒后自动关闭 + useEffect(() => { + if (!loadError) return; + const timer = setTimeout(() => window.close(), 3000); + return () => clearTimeout(timer); + }, [loadError]); + + if (loadError) { + return ( + +
+
+ +
+
+

{t("permission:confirm_expired_title")}

+

{t("permission:confirm_expired_desc")}

+
+ + {t("permission:auto_close_in", { second: 3 })} +
+
+ ); + } + + if (!info) { + return ( + +
+
+
+
+
+
+
+
+
+
+
+
+
+ + ); + } + + const { script, confirm, likeNum } = info; + const siteAccess = isSiteAccess(confirm); + const durations = availableDurations(confirm); + const showWildcard = canApplyToAll(confirm, likeNum); + const { Icon, fg, bgClass } = permissionVisual(confirm.permission); + const effectiveApplyToAll = showWildcard && applyToAll && duration !== "once"; + const type = resolveConfirmType(duration, effectiveApplyToAll); + + const scriptNameLabel = t("common:script_name"); + const metaEntries = Object.entries(confirm.metadata || {}).filter(([k]) => k !== scriptNameLabel); + const version = script.metadata?.version?.[0]; + const initials = + Array.from((script.name || "?").trim()) + .slice(0, 2) + .join("") || "?"; + + const requestSiteAccess = async () => { + if (decidedRef.current) return; + const origins = confirm.extensionSiteAccessOrigins; + if (origins?.length) { + const granted = await chrome.permissions.request({ origins }).catch(() => false); + if (!granted) { + ignore(); + return; + } + } + decide(true, 1); + }; + + return ( + +
+ {/* 头部 */} +
+
+ +
+
+

{confirm.title}

+ {confirm.describe && ( +

{confirm.describe}

+ )} +
+
+ + {/* 高敏感警示 */} + {isHighSensitive(confirm) && ( +
+ +
+ {t("permission:cookie_warning_title")} + + {t("permission:cookie_warning_desc")} + +
+
+ )} + + {/* 脚本身份 */} +
+
+ {initials} +
+
+ {script.name} + + {t("permission:user_script_type")} + {version ? ` · v${version}` : ""} + +
+
+ + {/* 请求目标 */} + {metaEntries.length > 0 && ( +
+ {metaEntries.map(([k, v], i) => ( +
0 && "border-t border-border")}> + {k} + {v} +
+ ))} +
+ )} + + {/* 授权时长 + 通配范围(站点访问无此区) */} + {!siteAccess && ( +
+ {t("permission:auth_duration")} +
+ {durations.map((d) => ( + + ))} +
+ {showWildcard && ( +
+
+ + {t("permission:apply_to_all_domains")} + + {t("permission:apply_to_all_domains_desc")} +
+ +
+ )} +
+ )} + + {/* 操作区 */} + {siteAccess ? ( +
+ + +
+ ) : ( +
+
+ + +
+ +
+ )} +
+
+ ); +} + +export default function App() { + const uuid = new URLSearchParams(location.search).get("uuid"); + if (!uuid) return null; + return ; +} diff --git a/src/pages/confirm/confirm-options.test.ts b/src/pages/confirm/confirm-options.test.ts new file mode 100644 index 000000000..22739c1a3 --- /dev/null +++ b/src/pages/confirm/confirm-options.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { + resolveConfirmType, + availableDurations, + canApplyToAll, + isSiteAccess, + isHighSensitive, +} from "./confirm-options"; +import type { ConfirmParam } from "@App/app/service/service_worker/permission_verify"; + +const cp = (over: Partial = {}): ConfirmParam => ({ permission: "cors", ...over }); + +describe("授权选项 · 时长与范围到 type 的映射", () => { + it("仅此次应映射为 type 1(与是否通配无关)", () => { + expect(resolveConfirmType("once", false)).toBe(1); + expect(resolveConfirmType("once", true)).toBe(1); + }); + it("临时·仅此项应为 type 3,临时·全部应为 type 2", () => { + expect(resolveConfirmType("temporary", false)).toBe(3); + expect(resolveConfirmType("temporary", true)).toBe(2); + }); + it("永久·仅此项应为 type 5,永久·全部应为 type 4", () => { + expect(resolveConfirmType("permanent", false)).toBe(5); + expect(resolveConfirmType("permanent", true)).toBe(4); + }); +}); + +describe("授权选项 · 可选时长", () => { + it("普通权限应提供 仅此次/临时/永久", () => { + expect(availableDurations(cp())).toEqual(["once", "temporary", "permanent"]); + }); + it("persistentOnly 权限应隐藏「临时」(临时不会被缓存,等同一次性)", () => { + expect(availableDurations(cp({ persistentOnly: true }))).toEqual(["once", "permanent"]); + }); +}); + +describe("授权选项 · 通配范围开关可见性", () => { + it("非通配权限不显示通配开关", () => { + expect(canApplyToAll(cp({ wildcard: false }), 5)).toBe(false); + }); + it("通配权限但同类等待请求未超过 2 个时不显示", () => { + expect(canApplyToAll(cp({ wildcard: true }), 2)).toBe(false); + }); + it("通配权限且同类等待请求超过 2 个时显示", () => { + expect(canApplyToAll(cp({ wildcard: true }), 3)).toBe(true); + }); +}); + +describe("授权选项 · 高敏感权限警示", () => { + it("cookie 权限应标记为高敏感(展示警示条)", () => { + expect(isHighSensitive(cp({ permission: "cookie" }))).toBe(true); + }); + it("cors/文件存储等不标记为高敏感", () => { + expect(isHighSensitive(cp({ permission: "cors" }))).toBe(false); + expect(isHighSensitive(cp({ permission: "file_storage" }))).toBe(false); + }); +}); + +describe("授权选项 · 站点访问识别", () => { + it("extension-site-access 应识别为站点访问(单按钮变体)", () => { + expect(isSiteAccess(cp({ permission: "extension-site-access" }))).toBe(true); + }); + it("其它权限不是站点访问", () => { + expect(isSiteAccess(cp({ permission: "cors" }))).toBe(false); + }); +}); diff --git a/src/pages/confirm/confirm-options.ts b/src/pages/confirm/confirm-options.ts new file mode 100644 index 000000000..6c4a801b8 --- /dev/null +++ b/src/pages/confirm/confirm-options.ts @@ -0,0 +1,43 @@ +import type { ConfirmParam } from "@App/app/service/service_worker/permission_verify"; + +// 授权时长 +export type Duration = "once" | "temporary" | "permanent"; + +/** + * 时长 × 是否应用于全部(通配)→ UserConfirm.type + * type 含义:1 允许一次 / 2 临时全部 / 3 临时此 / 4 永久全部 / 5 永久此 + */ +export function resolveConfirmType(duration: Duration, applyToAll: boolean): number { + switch (duration) { + case "once": + return 1; + case "temporary": + return applyToAll ? 2 : 3; + case "permanent": + return applyToAll ? 4 : 5; + } +} + +/** + * 可选的授权时长。persistentOnly 模式下「临时」不会被缓存(等同一次性),故隐藏。 + */ +export function availableDurations(confirm: ConfirmParam): Duration[] { + return confirm.persistentOnly ? ["once", "permanent"] : ["once", "temporary", "permanent"]; +} + +/** + * 「应用到所有域名(通配)」开关是否可见:权限支持通配,且同类等待确认请求超过 2 个时才解锁。 + */ +export function canApplyToAll(confirm: ConfirmParam, likeNum: number): boolean { + return !!confirm.wildcard && likeNum > 2; +} + +/** 是否为站点访问权限(单按钮变体)。 */ +export function isSiteAccess(confirm: ConfirmParam): boolean { + return confirm.permission === "extension-site-access"; +} + +/** 是否为高敏感权限:展示顶部警示条提醒用户谨慎授权。 */ +export function isHighSensitive(confirm: ConfirmParam): boolean { + return confirm.permission === "cookie"; +} diff --git a/src/pages/confirm/main.tsx b/src/pages/confirm/main.tsx new file mode 100644 index 000000000..aaeab6364 --- /dev/null +++ b/src/pages/confirm/main.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import LoggerCore from "@App/app/logger/core.ts"; +import { message } from "../store/global.ts"; +import MessageWriter from "@App/app/logger/message_writer.ts"; +import { ThemeProvider } from "../components/theme-provider.tsx"; +import { Toaster } from "../components/ui/sonner.tsx"; +import "@App/index.css"; + +// 初始化日志组件 +const loggerCore = new LoggerCore({ + writer: new MessageWriter(message), + labels: { env: "confirm" }, +}); + +loggerCore.logger().debug("confirm page start"); + +const Root = ( + + + + +); + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + process.env.NODE_ENV === "development" ? {Root} : Root +); diff --git a/src/pages/options.html b/src/pages/options.html index fd5e3fed9..bf5791bc6 100644 --- a/src/pages/options.html +++ b/src/pages/options.html @@ -3,6 +3,7 @@ + ScriptCat diff --git a/src/pages/options/App.test.tsx b/src/pages/options/App.test.tsx new file mode 100644 index 000000000..0d8434761 --- /dev/null +++ b/src/pages/options/App.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { initLanguage } from "@App/locales/locales"; +import { useIsMobile } from "@App/pages/components/use-is-mobile"; +import { ThemeProvider } from "@App/pages/components/theme-provider"; +import { Layout } from "./App"; + +vi.mock("@App/pages/components/use-is-mobile", () => ({ + useIsMobile: vi.fn(), +})); + +const mockedUseIsMobile = vi.mocked(useIsMobile); + +beforeEach(() => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +}); + +afterEach(cleanup); + +function renderLayout() { + return render( + + + + }> + content
} /> + + + + + ); +} + +describe("Layout 外壳响应式", () => { + it("移动端渲染底部 Tab 栏,不渲染左侧 Sidebar", () => { + initLanguage("zh-CN"); + mockedUseIsMobile.mockReturnValue(true); + const { getByTestId, container } = renderLayout(); + expect(getByTestId("bottom-tab-bar")).toBeInTheDocument(); + expect(container.querySelector("aside")).toBeNull(); + }); + + it("桌面端渲染左侧 Sidebar,不渲染底部 Tab 栏", () => { + initLanguage("zh-CN"); + mockedUseIsMobile.mockReturnValue(false); + const { queryByTestId, container } = renderLayout(); + expect(container.querySelector("aside")).not.toBeNull(); + expect(queryByTestId("bottom-tab-bar")).toBeNull(); + }); +}); diff --git a/src/pages/options/App.tsx b/src/pages/options/App.tsx index d962b88a4..78b948281 100644 --- a/src/pages/options/App.tsx +++ b/src/pages/options/App.tsx @@ -1,14 +1,41 @@ -import { HashRouter, Routes, Route, Outlet } from "react-router-dom"; +import { HashRouter, Routes, Route, Outlet, Navigate, useLocation } from "react-router-dom"; import Sidebar from "./layout/Sidebar"; import ScriptList from "./routes/ScriptList"; +import SubscribeList from "./routes/SubscribeList"; +import ScriptEditor from "./routes/ScriptEditor"; +import Logger from "./routes/Logger"; import Setting from "./routes/Setting"; import { t } from "@App/locales/locales"; +import { useIsMobile } from "@App/pages/components/use-is-mobile"; +import MobileHeader from "./layout/MobileHeader"; +import BottomTabBar from "./layout/BottomTabBar"; -function Layout() { +export function Layout() { + const isMobile = useIsMobile(); + // 编辑器在移动端为全屏布局,自带顶栏/底栏,不显示全局 MobileHeader/BottomTabBar + const isFullscreen = useLocation().pathname.startsWith("/script/editor"); + if (isMobile) { + if (isFullscreen) { + return ( +
+ +
+ ); + } + return ( +
+ +
+ +
+ +
+ ); + } return (
-
+
@@ -30,11 +57,21 @@ export default function App() { }> } /> - } /> - } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> } /> } /> - } /> + } /> diff --git a/src/pages/options/components/SettingCard.test.tsx b/src/pages/options/components/SettingCard.test.tsx index 95fb8f0ef..c3cbd77ff 100644 --- a/src/pages/options/components/SettingCard.test.tsx +++ b/src/pages/options/components/SettingCard.test.tsx @@ -20,7 +20,11 @@ describe("设置卡片原语", () => { }); it("SettingRow 渲染标签/描述与右侧控件", () => { - render(); + render( + + + + ); expect(screen.getByText("语言")).toBeInTheDocument(); expect(screen.getByText("界面语言")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "ctrl" })).toBeInTheDocument(); diff --git a/src/pages/options/components/SettingCard.tsx b/src/pages/options/components/SettingCard.tsx index edd44b575..d58b1a795 100644 --- a/src/pages/options/components/SettingCard.tsx +++ b/src/pages/options/components/SettingCard.tsx @@ -1,18 +1,20 @@ import React from "react"; export function SettingCard({ - id, title, description, register, children, + id, + title, + description, + register, + children, }: { - id: string; title: string; description?: string; + id: string; + title: string; + description?: string; register: (id: string) => (el: HTMLElement | null) => void; children: React.ReactNode; }) { return ( -
+

{title}

{description &&

{description}

} diff --git a/src/pages/options/components/SettingRow.tsx b/src/pages/options/components/SettingRow.tsx index 77944c569..21cb5c7c5 100644 --- a/src/pages/options/components/SettingRow.tsx +++ b/src/pages/options/components/SettingRow.tsx @@ -1,9 +1,13 @@ import React from "react"; export function SettingRow({ - label, description, children, + label, + description, + children, }: { - label: string; description?: string; children: React.ReactNode; + label: string; + description?: string; + children: React.ReactNode; }) { return (
diff --git a/src/pages/options/layout/BottomTabBar.test.tsx b/src/pages/options/layout/BottomTabBar.test.tsx new file mode 100644 index 000000000..a174f0382 --- /dev/null +++ b/src/pages/options/layout/BottomTabBar.test.tsx @@ -0,0 +1,33 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { initLanguage, t } from "@App/locales/locales"; +import BottomTabBar from "./BottomTabBar"; + +afterEach(cleanup); + +describe("BottomTabBar 底部导航", () => { + it("渲染脚本/订阅/日志/工具/设置五个导航项", () => { + initLanguage("zh-CN"); + const { getByTestId } = render( + + + + ); + const bar = getByTestId("bottom-tab-bar"); + for (const label of [t("script:nav_scripts"), t("script:subscribe"), t("logs"), t("tools"), t("settings")]) { + expect(within(bar).getByText(label)).toBeInTheDocument(); + } + }); + + it("当前路由对应项为激活态(aria-current=page)", () => { + initLanguage("zh-CN"); + const { getByText } = render( + + + + ); + const logsLink = getByText(t("logs")).closest("a"); + expect(logsLink).toHaveAttribute("aria-current", "page"); + }); +}); diff --git a/src/pages/options/layout/BottomTabBar.tsx b/src/pages/options/layout/BottomTabBar.tsx new file mode 100644 index 000000000..4b76b055c --- /dev/null +++ b/src/pages/options/layout/BottomTabBar.tsx @@ -0,0 +1,38 @@ +import { NavLink } from "react-router-dom"; +import { Package, Rss, ScrollText, Wrench, Settings } from "lucide-react"; +import { cn } from "@App/pkg/utils/cn"; +import { t } from "@App/locales/locales"; + +const tabs = [ + { to: "/", icon: Package, label: () => t("script:nav_scripts"), end: true }, + { to: "/subscribe", icon: Rss, label: () => t("script:subscribe"), end: false }, + { to: "/logs", icon: ScrollText, label: () => t("logs"), end: false }, + { to: "/tools", icon: Wrench, label: () => t("tools"), end: false }, + { to: "/settings", icon: Settings, label: () => t("settings"), end: false }, +]; + +export default function BottomTabBar() { + return ( + + ); +} diff --git a/src/pages/options/layout/MobileHeader.test.tsx b/src/pages/options/layout/MobileHeader.test.tsx new file mode 100644 index 000000000..2b1ce518d --- /dev/null +++ b/src/pages/options/layout/MobileHeader.test.tsx @@ -0,0 +1,29 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { initLanguage, t } from "@App/locales/locales"; +import MobileHeader from "./MobileHeader"; + +afterEach(cleanup); + +describe("MobileHeader 移动顶栏", () => { + it("渲染 ScriptCat 标题", () => { + initLanguage("zh-CN"); + const { getByText } = render( + + + + ); + expect(getByText("ScriptCat")).toBeInTheDocument(); + }); + + it("渲染新建脚本图标按钮(带无障碍标签)", () => { + initLanguage("zh-CN"); + const { getByLabelText } = render( + + + + ); + expect(getByLabelText(t("script:create_script"))).toBeInTheDocument(); + }); +}); diff --git a/src/pages/options/layout/MobileHeader.tsx b/src/pages/options/layout/MobileHeader.tsx new file mode 100644 index 000000000..a752ff527 --- /dev/null +++ b/src/pages/options/layout/MobileHeader.tsx @@ -0,0 +1,12 @@ +import { CreateScriptMenu } from "@App/pages/options/routes/ScriptList/CreateScriptMenu"; + +export default function MobileHeader() { + return ( +
+ ScriptCat + {"ScriptCat"} +
+ +
+ ); +} diff --git a/src/pages/options/layout/Sidebar.test.tsx b/src/pages/options/layout/Sidebar.test.tsx new file mode 100644 index 000000000..7532c3a9e --- /dev/null +++ b/src/pages/options/layout/Sidebar.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, cleanup, fireEvent, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { initLanguage, t } from "@App/locales/locales"; +import { ThemeProvider } from "@App/pages/components/theme-provider"; +import Sidebar from "./Sidebar"; + +beforeEach(() => { + localStorage.clear(); + initLanguage("zh-CN"); + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +}); + +afterEach(cleanup); + +function renderSidebar(initialPath = "/") { + return render( + + + + + + ); +} + +const subLabels = () => [ + t("agent:chat"), + t("agent:provider"), + t("agent:skills"), + t("agent:mcp"), + t("agent:tasks"), + t("agent:opfs"), + t("agent:settings"), +]; + +describe("Sidebar 侧边栏 AI Agent 菜单", () => { + it("渲染 AI Agent 子菜单入口", () => { + const { getByText } = renderSidebar(); + expect(getByText(t("agent:title"))).toBeInTheDocument(); + }); + + it("默认折叠,点击 AI Agent 后展开显示 7 个子项", () => { + const { getByText, queryByTestId, getByTestId } = renderSidebar(); + expect(queryByTestId("sidebar-agent-submenu")).toBeNull(); + fireEvent.click(getByText(t("agent:title"))); + const submenu = getByTestId("sidebar-agent-submenu"); + for (const label of subLabels()) { + expect(within(submenu).getByText(label)).toBeInTheDocument(); + } + }); + + it("处于 /agent 路由时自动展开且对应子项为激活态", () => { + const { getByText } = renderSidebar("/agent/skills"); + const link = getByText(t("agent:skills")).closest("a"); + expect(link).toHaveAttribute("aria-current", "page"); + }); +}); diff --git a/src/pages/options/layout/Sidebar.tsx b/src/pages/options/layout/Sidebar.tsx index 6003e5a41..2f1dea169 100644 --- a/src/pages/options/layout/Sidebar.tsx +++ b/src/pages/options/layout/Sidebar.tsx @@ -1,6 +1,6 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useHoverMenu } from "../../components/ui/use-hover-menu"; -import { NavLink } from "react-router-dom"; +import { NavLink, useLocation, useNavigate } from "react-router-dom"; import { Package, Rss, @@ -18,6 +18,15 @@ import { FileCode, Store, MessageCircle, + Bot, + ChevronRight, + MessageSquare, + Server, + Sparkles, + Plug, + CalendarClock, + FolderTree, + SlidersHorizontal, } from "lucide-react"; import { GithubIcon } from "../../components/icons/GithubIcon"; import { @@ -39,6 +48,17 @@ const mainNav = [ { to: "/subscribe", icon: Rss, label: () => t("script:subscribe") }, ]; +/** AI Agent 子导航项 */ +const agentNav = [ + { to: "/agent/chat", icon: MessageSquare, label: () => t("agent:chat") }, + { to: "/agent/provider", icon: Server, label: () => t("agent:provider") }, + { to: "/agent/skills", icon: Sparkles, label: () => t("agent:skills") }, + { to: "/agent/mcp", icon: Plug, label: () => t("agent:mcp") }, + { to: "/agent/tasks", icon: CalendarClock, label: () => t("agent:tasks") }, + { to: "/agent/opfs", icon: FolderTree, label: () => t("agent:opfs") }, + { to: "/agent/settings", icon: SlidersHorizontal, label: () => t("agent:settings") }, +]; + /** 辅助导航项 */ const auxNav = [ { to: "/logs", icon: ScrollText, label: () => t("logs") }, @@ -70,17 +90,24 @@ export default function Sidebar() {
@@ -181,8 +196,12 @@ export default function App() { onStop={data.handleStopScript} /> ))} - {data.remainingBackCount > 0 && ( - data.handleToggleExpand("background")} /> + {data.canExpandBack && ( + data.handleToggleExpand("background")} + /> )} {data.fullBackScriptCount === 0 && {t("no_data")}}
@@ -204,18 +223,18 @@ function Header({ onOpenSettings, checkUpdate, onNotificationClick, - host, onCreateScript, onMenuCheckUpdate, + onGetMoreScript, }: { isEnableScript: boolean; onToggleEnableScript: (val: boolean) => void; onOpenSettings: () => void; checkUpdate: { notice: string; isRead: boolean }; onNotificationClick: () => void; - host: string; onCreateScript: () => void; onMenuCheckUpdate: () => void; + onGetMoreScript: (provider?: ScriptProvider) => void; }) { const hasUnreadNotice = !checkUpdate.isRead; @@ -230,7 +249,11 @@ function Header({ - + ); } @@ -254,13 +277,13 @@ function HeaderIconButton({ // ========== More Menu (匹配设计稿 DropdownPanel - More Menu) ========== function MoreMenu({ - host, onCreateScript, onMenuCheckUpdate, + onGetMoreScript, }: { - host: string; onCreateScript: () => void; onMenuCheckUpdate: () => void; + onGetMoreScript: (provider?: ScriptProvider) => void; }) { return ( @@ -282,22 +305,17 @@ function MoreMenu({ { e.stopPropagation(); - window.open(`https://scriptcat.org/search?domain=${host}`, "_blank"); + // 父级点击:打开记忆的脚本站点 + onGetMoreScript(); }} > {t("get_script")} - window.open(`https://scriptcat.org/search?domain=${host}`, "_blank")}> - {"ScriptCat"} - - window.open(`https://greasyfork.org/scripts/by-site/${host}`, "_blank")}> - {"Greasy Fork"} - - window.open(`https://openuserjs.org/?q=${host}`, "_blank")}> - {"OpenUserJS"} - + onGetMoreScript("scriptcat")}>{"ScriptCat"} + onGetMoreScript("greasyfork")}>{"Greasy Fork"} + onGetMoreScript("openuserjs")}>{"OpenUserJS"} @@ -392,15 +410,15 @@ function Divider() { return
; } -function ShowMoreButton({ count, onClick }: { count: number; onClick: () => void }) { +function ShowMoreButton({ count, expanded, onClick }: { count: number; expanded: boolean; onClick: () => void }) { return ( ); } @@ -463,7 +481,12 @@ function ScriptRow({
- + 0 ? "text-foreground" : "text-muted-foreground"}`} title={runTitle} @@ -539,6 +562,7 @@ function ScriptRow({ } + title={menuItem.options?.title} onClick={() => { const sameGroup = script.menus.filter( (m) => m.groupKey === menuItem.groupKey && !m.options?.inputType @@ -580,10 +604,10 @@ function ScriptRow({ function getStatusBadge(script: ScriptMenu): React.ReactNode { if (script.runStatus === SCRIPT_RUN_STATUS_RUNNING) { - return {"运行中"}; + return {t("script:running")}; } if (script.runStatus === SCRIPT_RUN_STATUS_ERROR) { - return {"错误"}; + return {t("error")}; } return null; } @@ -608,44 +632,36 @@ function InputMenuItem({ onMenuClick(uuid, sameGroup, value); }; - if (opts.inputType === "boolean") { - return ( -
- - {opts.inputLabel || menuItem.name} - { - setValue(checked); - const sameGroup = allMenus.filter((m) => m.groupKey === menuItem.groupKey); - onMenuClick(uuid, sameGroup, checked); - }} - /> -
- ); - } - return ( -
- {opts.inputLabel || menuItem.name} -
- setValue(opts.inputType === "number" ? Number(e.target.value) : e.target.value)} - placeholder={opts.inputPlaceholder} - className="h-7 text-[12px] flex-1" - onKeyDown={(e) => e.key === "Enter" && submit()} - /> - -
+
+ {/* 菜单名按钮:点击即以当前输入值提交(与旧版一致,菜单名按钮本身就是提交动作) */} + } title={opts.title} onClick={submit}> + {menuItem.name} + {opts.accessKey && ( + {`(${opts.accessKey.toUpperCase()})`} + )} + + {/* 输入控件(位于菜单名下方) */} + {opts.inputType === "boolean" ? ( +
+ {opts.inputLabel && ( + {opts.inputLabel} + )} + setValue(checked)} /> +
+ ) : ( +
+ {opts.inputLabel && {opts.inputLabel}} + setValue(opts.inputType === "number" ? Number(e.target.value) : e.target.value)} + placeholder={opts.inputPlaceholder} + className="h-7 text-[12px]" + onKeyDown={(e) => e.key === "Enter" && submit()} + /> +
+ )}
); } @@ -657,10 +673,19 @@ interface ActionItemProps { danger?: boolean; warn?: boolean; success?: boolean; + title?: string; onClick?: () => void; } -function ActionItem({ icon, children, danger = false, warn = false, success = false, onClick }: ActionItemProps) { +function ActionItem({ + icon, + children, + danger = false, + warn = false, + success = false, + title, + onClick, +}: ActionItemProps) { const color = danger ? "text-destructive hover:text-destructive" : warn @@ -671,6 +696,7 @@ function ActionItem({ icon, children, danger = false, warn = false, success = fa return ( +
+
+ )} + {showRequestButton && ( + + )} +
+ )} + {showEdgeQr && ( +
+
+
{t("popup:use_on_mobile")}
+
{t("popup:scan_qr_to_install")}
+
+ QR + +
+ )} + + ); +} diff --git a/src/pages/popup/main.tsx b/src/pages/popup/main.tsx index 3e4519244..62ac47d0b 100644 --- a/src/pages/popup/main.tsx +++ b/src/pages/popup/main.tsx @@ -5,6 +5,7 @@ import LoggerCore from "@App/app/logger/core.ts"; import { message } from "../store/global.ts"; import MessageWriter from "@App/app/logger/message_writer.ts"; import { ThemeProvider } from "../components/theme-provider.tsx"; +import { Toaster } from "../components/ui/sonner.tsx"; import "@App/index.css"; // 初始化日志组件 @@ -18,6 +19,7 @@ loggerCore.logger().debug("popup page start"); const Root = ( + ); diff --git a/src/pages/popup/usePopupData.test.ts b/src/pages/popup/usePopupData.test.ts new file mode 100644 index 000000000..6cf22bda8 --- /dev/null +++ b/src/pages/popup/usePopupData.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +// 仅替换 openInCurrentTab / getCurrentTab,其余实导出保留(getCurrentTab 置空以避免触及 chrome.tabs) +vi.mock("@App/pkg/utils/utils", async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, openInCurrentTab: vi.fn(async () => undefined), getCurrentTab: vi.fn(async () => undefined) }; +}); + +import { openInCurrentTab } from "@App/pkg/utils/utils"; +import { getMoreScriptUrl, usePopupData } from "./usePopupData"; + +describe("getMoreScriptUrl 获取更多脚本链接", () => { + it("ScriptCat:有 host 时带 domain 参数", () => { + expect(getMoreScriptUrl("https://www.bilibili.com/video/1", "scriptcat")).toBe( + "https://scriptcat.org/search?domain=www.bilibili.com" + ); + }); + + it("ScriptCat:无 host 时回退到搜索首页", () => { + expect(getMoreScriptUrl("", "scriptcat")).toBe("https://scriptcat.org/search"); + }); + + it("GreasyFork:去掉子域名只保留主域名", () => { + expect(getMoreScriptUrl("https://www.google.com/", "greasyfork")).toBe( + "https://greasyfork.org/scripts/by-site/google.com" + ); + }); + + it("GreasyFork:非 http 页面(无 host)回退到脚本列表页", () => { + expect(getMoreScriptUrl("chrome://extensions", "greasyfork")).toBe("https://greasyfork.org/scripts/"); + }); + + it("OpenUserJS:有 host 时带查询参数", () => { + expect(getMoreScriptUrl("https://example.com", "openuserjs")).toBe("https://openuserjs.org/?q=example.com"); + }); + + it("OpenUserJS:无 host 时回退到首页", () => { + expect(getMoreScriptUrl("about:blank", "openuserjs")).toBe("https://openuserjs.org/"); + }); +}); + +describe("usePopupData 打开编辑器/用户配置", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, "close").mockImplementation(() => {}); + }); + + it("handleOpenEditor 应经由 openInCurrentTab 打开(兼容 Edge Android #686)", async () => { + const { result } = renderHook(() => usePopupData()); + await act(async () => { + await result.current.handleOpenEditor("uuid-1"); + }); + expect(openInCurrentTab).toHaveBeenCalledWith("/src/options.html#/script/editor/uuid-1"); + }); + + it("handleOpenUserConfig 应经由 openInCurrentTab 打开(兼容 Edge Android #686)", async () => { + const { result } = renderHook(() => usePopupData()); + await act(async () => { + await result.current.handleOpenUserConfig("uuid-2"); + }); + expect(openInCurrentTab).toHaveBeenCalledWith("/src/options.html#/?userConfig=uuid-2"); + }); +}); diff --git a/src/pages/popup/usePopupData.ts b/src/pages/popup/usePopupData.ts index 522f9e3df..0ec41a9a8 100644 --- a/src/pages/popup/usePopupData.ts +++ b/src/pages/popup/usePopupData.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import type { ScriptMenu, ScriptMenuItem, TPopupScript } from "@App/app/service/service_worker/types"; import type { TDeleteScript, TEnableScript, TScriptRunStatus } from "@App/app/service/queue"; import { popupClient, scriptClient, runtimeClient, requestOpenBatchUpdatePage } from "../store/features/script"; @@ -6,7 +6,8 @@ import { subscribeMessage, systemConfig } from "../store/global"; import { SCRIPT_RUN_STATUS_RUNNING } from "@App/app/repo/scripts"; import { ExtVersion, ExtServer } from "@App/app/const"; import { sanitizeHTML } from "@App/pkg/utils/sanitize"; -import { getCurrentTab } from "@App/pkg/utils/utils"; +import { getCurrentTab, openInCurrentTab } from "@App/pkg/utils/utils"; +import { cacheInstance } from "@App/app/cache"; export { ExtVersion } from "@App/app/const"; export { VersionCompare, versionCompare } from "@App/pkg/utils/semver"; @@ -21,6 +22,40 @@ export function extractHost(url: string): string { } } +export type ScriptProvider = "scriptcat" | "greasyfork" | "openuserjs"; + +/** 根据当前页面 URL 与脚本站点生成「获取更多脚本」的链接。无有效 host 时回退到站点首页。 */ +export function getMoreScriptUrl(currentUrl: string, provider: ScriptProvider): string { + let urlHost = ""; + if (currentUrl) { + try { + const url = new URL(currentUrl); + // 仅 http(s) 页面才提取 host,扩展页/about: 等忽略 + if (url.hostname && url.protocol.startsWith("http")) { + urlHost = url.hostname; + } + } catch { + // 容错:URL 解析失败时按无 host 处理 + } + } + if (provider === "greasyfork") { + // www.google.com -> google.com + urlHost = /[^.]+\.[^.]+$/.exec(urlHost)?.[0] || urlHost; + } + switch (provider) { + case "scriptcat": + return urlHost + ? `https://scriptcat.org/search?domain=${encodeURIComponent(urlHost)}` + : "https://scriptcat.org/search"; + case "greasyfork": + return urlHost + ? `https://greasyfork.org/scripts/by-site/${encodeURI(urlHost)}` + : "https://greasyfork.org/scripts/"; + case "openuserjs": + return urlHost ? `https://openuserjs.org/?q=${encodeURIComponent(urlHost)}` : "https://openuserjs.org/"; + } +} + /** 按 groupKey 去重菜单项,过滤掉分隔线。popup 不区分二级/三级菜单,只取 groupKey 逗号前的部分 */ export function getVisibleMenuItems(menus: ScriptMenuItem[]): ScriptMenuItem[] { const seen = new Set(); @@ -70,6 +105,7 @@ export function usePopupData() { const [checkUpdateStatus, setCheckUpdateStatus] = useState(0); // 0=idle, 1=checking, 2=latest const [showAlert, setShowAlert] = useState(false); const [menuExpandNum, setMenuExpandNum] = useState(5); + const [defaultScriptProvider, setDefaultScriptProvider] = useState("scriptcat"); // ref 保存最新值,避免 async 回调中的闭包过期 const stateRef = useRef({ currentUrl, currentTabId }); @@ -98,15 +134,17 @@ export function usePopupData() { useEffect(() => { (async () => { try { - const [tab, enableScript, checkUpdateData, expandNum] = await Promise.all([ + const [tab, enableScript, checkUpdateData, expandNum, provider] = await Promise.all([ getCurrentTab(), systemConfig.getEnableScript(), systemConfig.getCheckUpdate({ sanitizeHTML }), systemConfig.getMenuExpandNum(), + cacheInstance.get("default_script_provider"), ]); setIsEnableScript(enableScript); setCheckUpdate(checkUpdateData); setMenuExpandNum(expandNum); + if (provider) setDefaultScriptProvider(provider); if (tab?.id && tab.url) { setCurrentTabId(tab.id); setCurrentUrl(tab.url); @@ -222,13 +260,14 @@ export function usePopupData() { [showError] ); - const handleOpenEditor = useCallback((uuid: string) => { - chrome.tabs.create({ url: chrome.runtime.getURL(`/src/options.html#/script/editor/${uuid}`) }); + const handleOpenEditor = useCallback(async (uuid: string) => { + // 经由扩展 API 打开(而非 window.open / chrome.tabs.create 绝对 URL):兼容 Edge Android(移动端打不开内部页,#686) + await openInCurrentTab(`/src/options.html#/script/editor/${uuid}`); window.close(); }, []); - const handleOpenUserConfig = useCallback((uuid: string) => { - chrome.tabs.create({ url: chrome.runtime.getURL(`/src/options.html#/?userConfig=${uuid}`) }); + const handleOpenUserConfig = useCallback(async (uuid: string) => { + await openInCurrentTab(`/src/options.html#/?userConfig=${uuid}`); window.close(); }, []); @@ -274,13 +313,27 @@ export function usePopupData() { const handleCreateScript = useCallback(async () => { await chrome.storage.local.set({ activeTabUrl: { url: stateRef.current.currentUrl } }); - window.open("/src/options.html#/script/editor?target=initial", "_blank"); + // 使用 openInCurrentTab 而非 window.open,避免 Edge Android 等移动端打开异常(#686) + openInCurrentTab("/src/options.html#/script/editor?target=initial"); }, []); const handleOpenSettings = useCallback(() => { - window.open("/src/options.html", "_blank"); + openInCurrentTab("/src/options.html"); }, []); + // 「获取更多脚本」:记忆上次选择的脚本站点,父级点击时打开记忆的站点 + const handleGetMoreScript = useCallback( + (provider?: ScriptProvider) => { + const target = provider ?? defaultScriptProvider; + if (provider && provider !== defaultScriptProvider) { + cacheInstance.set("default_script_provider", provider); + setDefaultScriptProvider(provider); + } + window.open(getMoreScriptUrl(stateRef.current.currentUrl, target), "_blank"); + }, + [defaultScriptProvider] + ); + const handleToggleEnableScript = useCallback((val: boolean) => { setIsEnableScript(val); systemConfig.setEnableScript(val); @@ -322,26 +375,37 @@ export function usePopupData() { const displayScriptList = expandedSections.current ? filteredScriptList : filteredScriptList.slice(0, EXPAND_LIMIT); const remainingCurrentCount = filteredScriptList.length - displayScriptList.length; + // 超过展示上限即可展开/收起:折叠时显示「显示更多」,展开时显示「收起」 + const canExpandCurrent = filteredScriptList.length > EXPAND_LIMIT; const displayBackScriptList = expandedSections.background ? filteredBackScriptList : filteredBackScriptList.slice(0, EXPAND_LIMIT); const remainingBackCount = filteredBackScriptList.length - displayBackScriptList.length; + const canExpandBack = filteredBackScriptList.length > EXPAND_LIMIT; const backRunningCount = backScriptList.filter((s) => s.runStatus === SCRIPT_RUN_STATUS_RUNNING).length; const enabledScriptCount = scriptList.filter((s) => s.enable).length; const enabledBackScriptCount = backScriptList.filter((s) => s.enable).length; + // 全量脚本(未经搜索过滤/分段截断):用于 accessKey 快捷键注册,确保对所有脚本生效 + const allScripts = useMemo(() => [...scriptList, ...backScriptList], [scriptList, backScriptList]); + return { loading, isBlacklist, host, scriptList: displayScriptList, backScriptList: displayBackScriptList, + allScripts, fullScriptCount: filteredScriptList.length, fullBackScriptCount: filteredBackScriptList.length, remainingCurrentCount, remainingBackCount, + canExpandCurrent, + canExpandBack, + isCurrentExpanded: expandedSections.current, + isBackExpanded: expandedSections.background, totalScriptCount, backRunningCount, enabledScriptCount, @@ -364,6 +428,8 @@ export function usePopupData() { handleNotificationClick, handleVersionClick, handleMenuCheckUpdate, + defaultScriptProvider, + handleGetMoreScript, isEnableScript, checkUpdate, checkUpdateStatus, diff --git a/src/pages/prerender-theme.test.ts b/src/pages/prerender-theme.test.ts new file mode 100644 index 000000000..7312525d1 --- /dev/null +++ b/src/pages/prerender-theme.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import fs from "fs"; +import path from "path"; + +// 防止初始加载时的「白屏闪烁」回归(#1497 / PR #1498): +// src/pages/common.ts 是一个独立预渲染脚本,在 React 挂载前同步给 加上 .dark, +// 使首帧就拿到正确的明暗背景。它必须被各主题化页面在 中尽早加载,否则脚本 +// 虽被打包却无人引用(develop/new-ui 合并 PR #1498 时正是丢失了这处 HTML 接线)。 +const repoRoot = process.cwd(); +const pagesDir = path.join(repoRoot, "src/pages"); + +// 走主题(ThemeProvider + index.css 设计令牌)的可见页面,需要预渲染脚本。 +// 新 UI 重写尚未移植 install.html(脚本安装页,manifest 与 service_worker 已引用 +// /src/install.html);待其落地后必须一并加入此列表并在 引入 common.js。 +const themedPages = ["options.html", "popup.html"]; + +describe("初始加载白屏闪烁修复(#1497)", () => { + it("主题化页面应在 中加载 common.js 预渲染脚本", () => { + for (const page of themedPages) { + const html = fs.readFileSync(path.join(pagesDir, page), "utf8"); + const headContent = html.slice(html.indexOf(""), html.indexOf("")); + expect(headContent, `${page} 缺少 common.js 预渲染脚本`).toMatch(//); + } + }); +}); diff --git a/src/pages/store/features/log.ts b/src/pages/store/features/log.ts new file mode 100644 index 000000000..044b844d8 --- /dev/null +++ b/src/pages/store/features/log.ts @@ -0,0 +1,9 @@ +import type { Logger } from "@App/app/repo/logger"; +import { logClient } from "./script"; + +// 通过 serviceWorker 的 LogService 读取/删除/清空本地日志(页面不直接访问 Dexie) +export const fetchLogs = (start: number, end: number): Promise => logClient.getLogs(start, end); + +export const requestDeleteLogs = (ids: number[]): Promise => logClient.deleteLogs(ids); + +export const requestClearLogs = (): Promise => logClient.clearLogs(); diff --git a/src/pages/store/features/script.ts b/src/pages/store/features/script.ts index ed2cefbab..0c40274f7 100644 --- a/src/pages/store/features/script.ts +++ b/src/pages/store/features/script.ts @@ -1,6 +1,7 @@ import type { Script } from "@App/app/repo/scripts"; import { AgentClient, + LogClient, PermissionClient, PopupClient, ResourceClient, @@ -23,6 +24,7 @@ export const valueClient = new ValueClient(message); export const resourceClient = new ResourceClient(message); export const synchronizeClient = new SynchronizeClient(message); export const agentClient = new AgentClient(message); +export const logClient = new LogClient(message); export const fetchScriptList = async () => { return await scriptClient.getAllScripts(); diff --git a/src/pages/store/features/subscribe.ts b/src/pages/store/features/subscribe.ts new file mode 100644 index 000000000..2d3f5e223 --- /dev/null +++ b/src/pages/store/features/subscribe.ts @@ -0,0 +1,24 @@ +import type { Subscribe } from "@App/app/repo/subscribe"; +import { subscribeClient } from "./script"; + +// 订阅列表项:在 Subscribe 基础上附加 UI 临时状态(开关/操作 loading) +export type SubscribeLoading = Subscribe & { + enableLoading?: boolean; + actionLoading?: boolean; +}; + +export const fetchSubscribeList = async () => { + return await subscribeClient.getAllSubscribe(); +}; + +export const requestEnableSubscribe = async (param: { url: string; enable: boolean }) => { + return await subscribeClient.enable(param.url, param.enable); +}; + +export const requestDeleteSubscribe = async (url: string) => { + return await subscribeClient.delete(url); +}; + +export const requestCheckSubscribeUpdate = async (url: string) => { + return await subscribeClient.checkUpdate(url); +}; diff --git a/src/pkg/utils/shortcut.test.ts b/src/pkg/utils/shortcut.test.ts new file mode 100644 index 000000000..68e63480b --- /dev/null +++ b/src/pkg/utils/shortcut.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { formatShortcut, isMacOS } from "./shortcut"; + +describe("formatShortcut 快捷键格式化", () => { + it("在 Windows/Linux 下应以 Ctrl 加号连接", () => { + expect(formatShortcut(["mod", "S"], false)).toBe("Ctrl+S"); + }); + + it("在 Mac 下应将 mod 显示为 ⌘ 图标且不带加号", () => { + expect(formatShortcut(["mod", "S"], true)).toBe("⌘S"); + }); + + it("在 Windows 下含 Shift 的组合应为 Ctrl+Shift+S", () => { + expect(formatShortcut(["mod", "shift", "S"], false)).toBe("Ctrl+Shift+S"); + }); + + it("在 Mac 下应按 ⌃⌥⇧⌘ 顺序排列修饰键并用图标替代", () => { + expect(formatShortcut(["mod", "shift", "S"], true)).toBe("⇧⌘S"); + expect(formatShortcut(["mod", "alt", "shift", "S"], true)).toBe("⌥⇧⌘S"); + }); + + it("功能键(如 F5)应原样保留", () => { + expect(formatShortcut(["mod", "F5"], false)).toBe("Ctrl+F5"); + expect(formatShortcut(["mod", "F5"], true)).toBe("⌘F5"); + }); +}); + +describe("isMacOS 平台判定", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("userAgentData.platform 为 macOS 时应判定为 Mac", () => { + vi.stubGlobal("navigator", { userAgentData: { platform: "macOS" }, userAgent: "" }); + expect(isMacOS()).toBe(true); + }); + + it("无 userAgentData 时应回退到 userAgent 中的 Macintosh 判定", () => { + vi.stubGlobal("navigator", { + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + }); + expect(isMacOS()).toBe(true); + }); + + it("Windows 下应判定为非 Mac", () => { + vi.stubGlobal("navigator", { + userAgentData: { platform: "Windows" }, + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }); + expect(isMacOS()).toBe(false); + }); +}); diff --git a/src/pkg/utils/shortcut.ts b/src/pkg/utils/shortcut.ts new file mode 100644 index 000000000..0cc7eff6f --- /dev/null +++ b/src/pkg/utils/shortcut.ts @@ -0,0 +1,32 @@ +// 快捷键展示工具:根据操作系统渲染对应的修饰键 +// - Mac:mod 显示为 ⌘ 图标,按 ⌃⌥⇧⌘ 顺序排列,符号间不加分隔符 +// - Windows/Linux:mod 显示为 Ctrl,用 + 连接 + +const MOD_KEYS = new Set(["mod", "ctrl", "alt", "shift"]); +// Mac 修饰键展示顺序:Control → Option → Shift → Command +const MAC_ORDER = ["ctrl", "alt", "shift", "mod"]; +const MAC_SYMBOL: Record = { ctrl: "⌃", alt: "⌥", shift: "⇧", mod: "⌘" }; +const WIN_LABEL: Record = { ctrl: "Ctrl", alt: "Alt", shift: "Shift", mod: "Ctrl" }; + +/** 当前是否运行在 macOS 上 */ +export function isMacOS(): boolean { + const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; + const platform = nav.userAgentData?.platform; + if (platform) return /mac/i.test(platform); + return /Mac/i.test(nav.userAgent || ""); +} + +/** + * 将一组按键(修饰键用 mod/ctrl/alt/shift 表示,其余为实体键)格式化为展示文本。 + * @param keys 例如 ["mod", "shift", "S"] + * @param isMac 是否为 Mac 平台,默认按当前系统判定 + */ +export function formatShortcut(keys: string[], isMac: boolean = isMacOS()): string { + const mods = keys.filter((k) => MOD_KEYS.has(k)); + const rest = keys.filter((k) => !MOD_KEYS.has(k)); + if (isMac) { + const ordered = MAC_ORDER.filter((m) => mods.includes(m)); + return [...ordered.map((m) => MAC_SYMBOL[m]), ...rest].join(""); + } + return [...mods.map((m) => WIN_LABEL[m]), ...rest].join("+"); +} diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index 451c51651..2282a9678 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -46,6 +46,16 @@ chromeMock.runtime.getURL = vi.fn().mockImplementation(function (this: Runtime, return `chrome-extension://${chrome.runtime.id}${path}`; }); +const runtimeWithManifest = chromeMock.runtime as typeof chromeMock.runtime & { + getManifest: ReturnType; +}; + +runtimeWithManifest.getManifest = vi.fn().mockReturnValue({ + optional_permissions: [], + permissions: [], + host_permissions: [], +}); + // ---- 修正 vitest 4.x.x 错误的 adoptedStyleSheets ---- let fixAdoptedStyleSheets = false; if (!document.adoptedStyleSheets) fixAdoptedStyleSheets = true; From 6dd856d4c4bf090cb673a9206281882d922ede88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 12:17:20 +0800 Subject: [PATCH 19/97] =?UTF-8?q?=F0=9F=8C=90=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E6=9D=83=E9=99=90=E7=A1=AE=E8=AE=A4=E6=96=87?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/en-US/permission.json | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/locales/en-US/permission.json b/src/locales/en-US/permission.json index 07399a7ce..adfb9045e 100644 --- a/src/locales/en-US/permission.json +++ b/src/locales/en-US/permission.json @@ -21,5 +21,22 @@ "extension_site_access_description": "Grant browser site access to this origin so ScriptCat can perform the request. This changes the extension's site access setting.", "extension_site_access_content": "Site", "request_permission": "Request Permission", - "allow_user_script_guide": "'Allow User Scripts' is currently not enabled, so the scripts cannot run properly. 👉tap to learn how to enable" + "allow_user_script_guide": "'Allow User Scripts' is currently not enabled, so the scripts cannot run properly. 👉tap to learn how to enable", + "user_script_type": "User Script", + "auth_duration": "Authorization Duration", + "duration_once": "Just Once", + "duration_temporary": "Temporary", + "duration_permanent": "Permanent", + "apply_to_all_domains": "Apply to all requested domains", + "apply_to_all_domains_desc": "Takes effect for all of this script's requests (wildcard)", + "allow_action": "Allow", + "deny_action": "Deny", + "ignore_action": "Ignore", + "cancel_action": "Cancel", + "loading_confirm": "Loading authorization request…", + "cookie_warning_title": "Highly sensitive permission", + "cookie_warning_desc": "Cookies contain sensitive data such as login state. Only authorize trusted scripts.", + "confirm_expired_title": "Authorization request expired", + "confirm_expired_desc": "This authorization request has timed out or already been handled. Please return to the page and trigger it again.", + "auto_close_in": "Window will close automatically in {{second}}s" } From 0e7f3ae13aaebe2fbf09c61beb8d734120616d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 12:19:28 +0800 Subject: [PATCH 20/97] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=88=86=E5=8C=BA=E5=8E=BB=E9=99=A4=20as=20never=20=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Select=20=E5=8F=97=E6=8E=A7=E8=AD=A6?= =?UTF-8?q?=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/Setting/sections/GeneralSection.tsx | 2 +- .../routes/Setting/sections/InterfaceSection.tsx | 16 +++++++--------- .../routes/Setting/sections/UpdateSection.tsx | 6 +++--- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/pages/options/routes/Setting/sections/GeneralSection.tsx b/src/pages/options/routes/Setting/sections/GeneralSection.tsx index 64a9c64f2..f824cd651 100644 --- a/src/pages/options/routes/Setting/sections/GeneralSection.tsx +++ b/src/pages/options/routes/Setting/sections/GeneralSection.tsx @@ -16,7 +16,7 @@ export function GeneralSection({ register }: { register: (id: string) => (el: HT register={register} > - setLanguage(v)}> diff --git a/src/pages/options/routes/Setting/sections/InterfaceSection.tsx b/src/pages/options/routes/Setting/sections/InterfaceSection.tsx index 37142befe..44714794e 100644 --- a/src/pages/options/routes/Setting/sections/InterfaceSection.tsx +++ b/src/pages/options/routes/Setting/sections/InterfaceSection.tsx @@ -5,6 +5,7 @@ import { Switch } from "@App/pages/components/ui/switch"; import { Input } from "@App/pages/components/ui/input"; import { useSystemConfig } from "../../../hooks/useSystemConfig"; import { t } from "@App/locales/locales"; +import type { FaviconService } from "@App/pkg/config/config"; export function InterfaceSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { const [badgeType, setBadgeType] = useSystemConfig("badge_number_type"); @@ -23,7 +24,7 @@ export function InterfaceSection({ register }: { register: (id: string) => (el: >
{t("settings:extension_icon_badge")}
- setBadgeType(v as "none" | "run_count" | "script_count")}> @@ -38,7 +39,7 @@ export function InterfaceSection({ register }: { register: (id: string) => (el: setBgColor(e.target.value as never)} + onChange={(e) => setBgColor(e.target.value)} className="h-8 w-12 rounded border border-border bg-transparent" /> @@ -46,7 +47,7 @@ export function InterfaceSection({ register }: { register: (id: string) => (el: setTextColor(e.target.value as never)} + onChange={(e) => setTextColor(e.target.value)} className="h-8 w-12 rounded border border-border bg-transparent" />
@@ -55,22 +56,19 @@ export function InterfaceSection({ register }: { register: (id: string) => (el: label={t("settings:display_right_click_menu")} description={t("settings:display_right_click_menu_desc")} > - setMenuType((c ? "all" : "no_browser") as never)} - /> + setMenuType(c ? "all" : "no_browser")} /> setExpandNum(Number(e.target.value) as never)} + onChange={(e) => setExpandNum(Number(e.target.value))} />
{t("settings:favicon_service")}
- setFavicon(v as FaviconService)}> diff --git a/src/pages/options/routes/Setting/sections/UpdateSection.tsx b/src/pages/options/routes/Setting/sections/UpdateSection.tsx index 96a5dd9f1..b189e3019 100644 --- a/src/pages/options/routes/Setting/sections/UpdateSection.tsx +++ b/src/pages/options/routes/Setting/sections/UpdateSection.tsx @@ -21,7 +21,7 @@ export function UpdateSection({ register }: { register: (id: string) => (el: HTM label={t("settings:script_update_check_frequency")} description={t("settings:script_auto_update_frequency")} > - setCycle(Number(v))}> @@ -38,11 +38,11 @@ export function UpdateSection({ register }: { register: (id: string) => (el: HTM setUpdateDisabled(c as never)} + onCheckedChange={(c) => setUpdateDisabled(c)} /> - setSilence(c as never)} /> + setSilence(c)} /> ); From cd772250a9844c3cc3feb640dc044ed2e482b6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 12:25:02 +0800 Subject: [PATCH 21/97] =?UTF-8?q?=E2=9C=A8=20=E8=A1=A5=E9=BD=90=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=9B=B4=E6=96=B0=E4=B8=8E=E4=BB=A3=E7=90=86=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rspack.config.ts | 9 + src/locales/de-DE/permission.json | 19 +- src/locales/ja-JP/permission.json | 19 +- src/locales/ru-RU/permission.json | 19 +- src/locales/vi-VN/permission.json | 19 +- src/locales/zh-TW/permission.json | 19 +- src/pages/batchupdate.html | 20 + src/pages/batchupdate/App.tsx | 10 + src/pages/batchupdate/components.tsx | 367 ++++++++++++++++++ src/pages/batchupdate/hooks.ts | 178 +++++++++ src/pages/batchupdate/main.tsx | 28 ++ src/pages/batchupdate/mobile.tsx | 218 +++++++++++ src/pages/confirm/App.tsx | 6 +- .../routes/AgentChat/chat_utils.test.ts | 333 ++++++++++++++++ .../options/routes/AgentChat/chat_utils.ts | 183 +++++++++ .../routes/AgentChat/sub_agent_match.test.ts | 243 ++++++++++++ src/pages/options/routes/AgentChat/types.ts | 21 + 17 files changed, 1703 insertions(+), 8 deletions(-) create mode 100644 src/pages/batchupdate.html create mode 100644 src/pages/batchupdate/App.tsx create mode 100644 src/pages/batchupdate/components.tsx create mode 100644 src/pages/batchupdate/hooks.ts create mode 100644 src/pages/batchupdate/main.tsx create mode 100644 src/pages/batchupdate/mobile.tsx create mode 100644 src/pages/options/routes/AgentChat/chat_utils.test.ts create mode 100644 src/pages/options/routes/AgentChat/chat_utils.ts create mode 100644 src/pages/options/routes/AgentChat/sub_agent_match.test.ts create mode 100644 src/pages/options/routes/AgentChat/types.ts diff --git a/rspack.config.ts b/rspack.config.ts index 37f9ee486..52c9e29e6 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -60,6 +60,7 @@ export default { popup: `${src}/pages/popup/main.tsx`, options: `${src}/pages/options/main.tsx`, confirm: `${src}/pages/confirm/main.tsx`, + batchupdate: `${src}/pages/batchupdate/main.tsx`, "editor.worker": "monaco-editor/esm/vs/editor/editor.worker.js", "ts.worker": "monaco-editor/esm/vs/language/typescript/ts.worker.js", "linter.worker": `${src}/linter.worker.ts`, @@ -191,6 +192,14 @@ export default { minify: true, chunks: ["confirm"], }), + new rspack.HtmlRspackPlugin({ + filename: `${dist}/ext/src/batchupdate.html`, + template: `${src}/pages/batchupdate.html`, + inject: "head", + title: "ScriptCat", + minify: true, + chunks: ["batchupdate"], + }), new rspack.HtmlRspackPlugin({ filename: `${dist}/ext/src/offscreen.html`, template: `${src}/pages/offscreen.html`, diff --git a/src/locales/de-DE/permission.json b/src/locales/de-DE/permission.json index d3fe9b443..44e861c86 100644 --- a/src/locales/de-DE/permission.json +++ b/src/locales/de-DE/permission.json @@ -21,5 +21,22 @@ "extension_site_access_description": "Gewähren Sie dem Browser Website-Zugriff auf diesen Ursprung, damit ScriptCat die Anfrage ausführen kann. Dadurch wird die Website-Zugriffseinstellung der Erweiterung geändert.", "extension_site_access_content": "Website", "request_permission": "Beantragen Sie Genehmigungen", - "allow_user_script_guide": "'Nutzerskripts zulassen' ist derzeit nicht aktiviert, daher können die Skripte nicht richtig ausgeführt werden. 👉Hier klicken, um zu erfahren, wie man es aktiviert" + "allow_user_script_guide": "'Nutzerskripts zulassen' ist derzeit nicht aktiviert, daher können die Skripte nicht richtig ausgeführt werden. 👉Hier klicken, um zu erfahren, wie man es aktiviert", + "user_script_type": "Benutzerskript", + "auth_duration": "Autorisierungsdauer", + "duration_once": "Nur dieses Mal", + "duration_temporary": "Temporär", + "duration_permanent": "Dauerhaft", + "apply_to_all_domains": "Auf alle angefragten Domains anwenden", + "apply_to_all_domains_desc": "Gilt für alle Anfragen dieses Skripts (Platzhalter)", + "allow_action": "Erlauben", + "deny_action": "Verweigern", + "ignore_action": "Ignorieren", + "cancel_action": "Abbrechen", + "loading_confirm": "Autorisierungsanfrage wird geladen…", + "cookie_warning_title": "Hochsensible Berechtigung", + "cookie_warning_desc": "Cookies enthalten sensible Daten wie den Anmeldestatus. Autorisieren Sie nur vertrauenswürdige Skripte.", + "confirm_expired_title": "Autorisierungsanfrage abgelaufen", + "confirm_expired_desc": "Diese Autorisierungsanfrage ist abgelaufen oder wurde bereits bearbeitet. Bitte kehren Sie zur Seite zurück und lösen Sie sie erneut aus.", + "auto_close_in": "Das Fenster wird in {{second}}s automatisch geschlossen" } diff --git a/src/locales/ja-JP/permission.json b/src/locales/ja-JP/permission.json index d98c4f6ed..39c375630 100644 --- a/src/locales/ja-JP/permission.json +++ b/src/locales/ja-JP/permission.json @@ -21,5 +21,22 @@ "extension_site_access_description": "ScriptCat がこのリクエストを実行できるように、このオリジンへのブラウザーのサイトアクセス権限を許可してください。これは拡張機能のサイトアクセス設定を変更します。", "extension_site_access_content": "サイト", "request_permission": "権限をリクエストする", - "allow_user_script_guide": "現在「ユーザー スクリプトを許可する」が有効ではないため、スクリプトは正常に動作しません。👉有効化の方法はこちら" + "allow_user_script_guide": "現在「ユーザー スクリプトを許可する」が有効ではないため、スクリプトは正常に動作しません。👉有効化の方法はこちら", + "user_script_type": "ユーザースクリプト", + "auth_duration": "許可の有効期間", + "duration_once": "今回のみ", + "duration_temporary": "一時的", + "duration_permanent": "永続的", + "apply_to_all_domains": "リクエストするすべてのドメインに適用", + "apply_to_all_domains_desc": "このスクリプトのすべてのリクエストに適用されます(ワイルドカード)", + "allow_action": "許可", + "deny_action": "拒否", + "ignore_action": "無視", + "cancel_action": "キャンセル", + "loading_confirm": "許可リクエストを読み込んでいます…", + "cookie_warning_title": "高機密性の権限", + "cookie_warning_desc": "Cookie にはログイン状態などの機密データが含まれます。信頼できるスクリプトにのみ許可してください。", + "confirm_expired_title": "許可リクエストの有効期限が切れました", + "confirm_expired_desc": "この許可リクエストはタイムアウトしたか、すでに処理されています。ページに戻ってもう一度実行してください。", + "auto_close_in": "{{second}} 秒後にウィンドウが自動的に閉じます" } diff --git a/src/locales/ru-RU/permission.json b/src/locales/ru-RU/permission.json index 917be615f..9812cd431 100644 --- a/src/locales/ru-RU/permission.json +++ b/src/locales/ru-RU/permission.json @@ -21,5 +21,22 @@ "extension_site_access_description": "Разрешите браузеру доступ к сайту для этого источника, чтобы ScriptCat мог выполнить запрос. Это изменит настройку доступа к сайтам для расширения.", "extension_site_access_content": "Сайт", "request_permission": "Запрос разрешения", - "allow_user_script_guide": "«Разрешить пользовательские скрипты» сейчас отключён, поэтому скрипты не могут работать корректно. 👉Нажмите, чтобы узнать, как включить" + "allow_user_script_guide": "«Разрешить пользовательские скрипты» сейчас отключён, поэтому скрипты не могут работать корректно. 👉Нажмите, чтобы узнать, как включить", + "user_script_type": "Пользовательский скрипт", + "auth_duration": "Срок действия разрешения", + "duration_once": "Только сейчас", + "duration_temporary": "Временно", + "duration_permanent": "Постоянно", + "apply_to_all_domains": "Применить ко всем запрашиваемым доменам", + "apply_to_all_domains_desc": "Действует для всех запросов этого скрипта (подстановка)", + "allow_action": "Разрешить", + "deny_action": "Отклонить", + "ignore_action": "Игнорировать", + "cancel_action": "Отмена", + "loading_confirm": "Загрузка запроса разрешения…", + "cookie_warning_title": "Особо чувствительное разрешение", + "cookie_warning_desc": "Cookie содержат конфиденциальные данные, например состояние входа. Разрешайте только доверенным скриптам.", + "confirm_expired_title": "Запрос разрешения истёк", + "confirm_expired_desc": "Этот запрос разрешения превысил время ожидания или уже был обработан. Вернитесь на страницу и вызовите его снова.", + "auto_close_in": "Окно закроется автоматически через {{second}} с" } diff --git a/src/locales/vi-VN/permission.json b/src/locales/vi-VN/permission.json index 66d6e078d..b15454bba 100644 --- a/src/locales/vi-VN/permission.json +++ b/src/locales/vi-VN/permission.json @@ -21,5 +21,22 @@ "extension_site_access_description": "Cấp quyền truy cập trang web của trình duyệt cho nguồn này để ScriptCat có thể thực hiện yêu cầu. Thao tác này thay đổi cài đặt truy cập trang web của tiện ích.", "extension_site_access_content": "Trang web", "request_permission": "Yêu cầu quyền", - "allow_user_script_guide": "'Cho phép tập lệnh người dùng' hiện chưa được bật, nên các script không thể hoạt động đúng cách. 👉Nhấn để xem cách bật" + "allow_user_script_guide": "'Cho phép tập lệnh người dùng' hiện chưa được bật, nên các script không thể hoạt động đúng cách. 👉Nhấn để xem cách bật", + "user_script_type": "Script người dùng", + "auth_duration": "Thời hạn cấp quyền", + "duration_once": "Chỉ lần này", + "duration_temporary": "Tạm thời", + "duration_permanent": "Vĩnh viễn", + "apply_to_all_domains": "Áp dụng cho tất cả miền được yêu cầu", + "apply_to_all_domains_desc": "Có hiệu lực cho mọi yêu cầu của script này (ký tự đại diện)", + "allow_action": "Cho phép", + "deny_action": "Từ chối", + "ignore_action": "Bỏ qua", + "cancel_action": "Hủy", + "loading_confirm": "Đang tải yêu cầu cấp quyền…", + "cookie_warning_title": "Quyền có độ nhạy cảm cao", + "cookie_warning_desc": "Cookie chứa dữ liệu nhạy cảm như trạng thái đăng nhập. Chỉ cấp quyền cho các script đáng tin cậy.", + "confirm_expired_title": "Yêu cầu cấp quyền đã hết hạn", + "confirm_expired_desc": "Yêu cầu cấp quyền này đã hết thời gian chờ hoặc đã được xử lý. Vui lòng quay lại trang và kích hoạt lại thao tác này.", + "auto_close_in": "Cửa sổ sẽ tự động đóng sau {{second}} giây" } diff --git a/src/locales/zh-TW/permission.json b/src/locales/zh-TW/permission.json index 2507328f5..3d4c69fe7 100644 --- a/src/locales/zh-TW/permission.json +++ b/src/locales/zh-TW/permission.json @@ -21,5 +21,22 @@ "extension_site_access_description": "請授予瀏覽器對此來源的網站存取權限,讓 ScriptCat 可以完成本次請求。這會修改擴充功能的網站存取設定。", "extension_site_access_content": "網站", "request_permission": "要求權限", - "allow_user_script_guide": "目前尚未啟用「允許使用者指令碼」,腳本無法正常執行。👉點此查看啟用方式" + "allow_user_script_guide": "目前尚未啟用「允許使用者指令碼」,腳本無法正常執行。👉點此查看啟用方式", + "user_script_type": "使用者腳本", + "auth_duration": "授權時長", + "duration_once": "僅此次", + "duration_temporary": "暫時", + "duration_permanent": "永久", + "apply_to_all_domains": "套用至所有請求網域", + "apply_to_all_domains_desc": "對此腳本的全部請求生效(萬用字元)", + "allow_action": "允許", + "deny_action": "拒絕", + "ignore_action": "忽略", + "cancel_action": "取消", + "loading_confirm": "正在讀取授權請求…", + "cookie_warning_title": "高敏感權限", + "cookie_warning_desc": "Cookie 包含登入狀態等敏感資料,請僅授權可信任的腳本。", + "confirm_expired_title": "授權請求已失效", + "confirm_expired_desc": "此次授權請求已逾時或已被處理。請返回頁面重新觸發該操作。", + "auto_close_in": "{{second}} 秒後自動關閉視窗" } diff --git a/src/pages/batchupdate.html b/src/pages/batchupdate.html new file mode 100644 index 000000000..53a01f254 --- /dev/null +++ b/src/pages/batchupdate.html @@ -0,0 +1,20 @@ + + + + + + + ScriptCat + + + +
+ + diff --git a/src/pages/batchupdate/App.tsx b/src/pages/batchupdate/App.tsx new file mode 100644 index 000000000..67ff4f85a --- /dev/null +++ b/src/pages/batchupdate/App.tsx @@ -0,0 +1,10 @@ +import { useIsMobile } from "@App/pages/components/use-is-mobile"; +import { useBatchUpdate } from "./hooks"; +import { DesktopView } from "./components"; +import { MobileView } from "./mobile"; + +export default function App() { + const view = useBatchUpdate(); + const isMobile = useIsMobile(); + return isMobile ? : ; +} diff --git a/src/pages/batchupdate/components.tsx b/src/pages/batchupdate/components.tsx new file mode 100644 index 000000000..1ed366487 --- /dev/null +++ b/src/pages/batchupdate/components.tsx @@ -0,0 +1,367 @@ +import { + ArrowRight, + BellOff, + ChevronDown, + CircleCheckBig, + Download, + FileCode, + Globe, + Loader2, + PackageCheck, + RefreshCw, + ShieldAlert, + Timer, + X, +} from "lucide-react"; +import { cn } from "@App/pkg/utils/cn"; +import { t } from "@App/locales/locales"; +import { formatUnixTime } from "@App/pkg/utils/day_format"; +import { Button } from "@App/pages/components/ui/button"; +import { Checkbox } from "@App/pages/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@App/pages/components/ui/collapsible"; +import type { UpdateItem, UpdateRisk } from "./logic"; + +/** install:updatepage 命名空间下的翻译快捷方法 */ +export const tk = (key: string, opt?: Record): string => t(`install:updatepage.${key}`, opt); + +/** 批量更新视图(桌面/移动共用)所需的数据与回调 */ +export interface BatchUpdateViewProps { + updates: UpdateItem[]; + ignored: UpdateItem[]; + /** 本次检查覆盖的脚本总数(用于空状态文案) */ + totalChecked: number; + checktime: number; + checking: boolean; + loading: boolean; + selected: Set; + /** 自动关闭剩余秒数;为 null 表示不自动关闭 */ + autoClose: number | null; + onToggle: (uuid: string) => void; + onToggleAll: () => void; + onUpdate: (item: UpdateItem) => void; + onIgnore: (item: UpdateItem) => void; + onRestore: (item: UpdateItem) => void; + onUpdateSelected: () => void; + onIgnoreSelected: () => void; + onRestoreAll: () => void; + onCheckNow: () => void; + onClose: () => void; +} + +const PILL = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap"; + +const RISK_CLASS: Record = { + major: "bg-destructive/10 text-destructive", + noticeable: "bg-primary/10 text-primary", + tiny: "bg-success-bg text-success-fg", +}; +const RISK_KEY: Record = { + major: "codechange_major", + noticeable: "codechange_noticeable", + tiny: "codechange_tiny", +}; + +export function RiskBadge({ risk }: { risk: UpdateRisk }) { + return {tk(RISK_KEY[risk])}; +} + +export function ConnectBadge() { + return ( + + + {tk("tag_new_connect")} + + ); +} + +export function StatusBadge({ enabled }: { enabled: boolean }) { + return ( + + {enabled ? tk("enabled") : tk("disabled")} + + ); +} + +export function VersionDiff({ oldVersion, newVersion }: { oldVersion: string; newVersion: string }) { + return ( +
+ {`v${oldVersion}`} + + {`v${newVersion}`} +
+ ); +} + +export function SourceCell({ source }: { source: string }) { + if (!source) return {"—"}; + return ( + + + {source} + + ); +} + +/** 脚本图标占位贴片 */ +export function ScriptTile() { + return ( + + + + ); +} + +/** 行内文字按钮(更新 / 忽略 / 恢复) */ +function LinkAction({ label, onClick, muted }: { label: string; onClick: () => void; muted?: boolean }) { + return ( + + ); +} + +const COL = { + version: "w-[170px] shrink-0", + change: "w-[210px] shrink-0", + source: "w-[150px] shrink-0", + action: "w-[120px] shrink-0", +}; + +/** 桌面端单行(待更新或已忽略) */ +function DesktopRow({ + item, + selected, + onToggle, + onUpdate, + onIgnore, + onRestore, + ignoredRow, +}: { + item: UpdateItem; + selected?: boolean; + onToggle?: (uuid: string) => void; + onUpdate?: (item: UpdateItem) => void; + onIgnore?: (item: UpdateItem) => void; + onRestore?: (item: UpdateItem) => void; + ignoredRow?: boolean; +}) { + const dim = item.enabled ? "" : "opacity-55"; + return ( +
+
+ {ignoredRow ? ( + + ) : ( + onToggle?.(item.uuid)} /> + )} +
+
+ + {item.name} + +
+
+ +
+
+ + {item.withNewConnect && } +
+
+ +
+
+ {ignoredRow ? ( + onRestore?.(item)} /> + ) : ( + <> + onUpdate?.(item)} /> + + onIgnore?.(item)} muted /> + + )} +
+
+ ); +} + +function DesktopTable({ view }: { view: BatchUpdateViewProps }) { + return ( +
+
+
+
{tk("col_script")}
+
{tk("col_version")}
+
{tk("col_change")}
+
{tk("col_source")}
+
{tk("col_action")}
+
+ {view.updates.map((item) => ( + + ))} +
+ ); +} + +function DesktopIgnored({ view }: { view: BatchUpdateViewProps }) { + return ( + +
+
+ + + {tk("ignored_section")} + + {view.ignored.length} + + + +
+ + {view.ignored.map((item) => ( + + ))} + +
+
+ ); +} + +function DesktopToolbar({ 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; + return ( +
+
+ + + {tk("selected_count", { selected: selectedCount, total: view.updates.length })} + + {view.ignored.length > 0 && ( + <> + {"·"} + + {tk("ignored_count", { count: view.ignored.length })} + + + )} +
+
+ + +
+
+ ); +} + +/** 空状态:所有脚本均为最新 */ +export function EmptyState({ totalChecked, onCheckNow }: { totalChecked: number; onCheckNow: () => void }) { + return ( +
+ + + +
+ {tk("empty_title")} + {tk("empty_desc", { count: totalChecked })} +
+ +
+ ); +} + +/** 顶部状态/自动关闭信息条 */ +function HeaderStatus({ view }: { view: BatchUpdateViewProps }) { + const text = view.checking + ? tk("status_checking_updates") + : view.checktime + ? tk("last_check", { time: formatUnixTime(Math.floor(view.checktime / 1000)) }) + : ""; + if (!text) return null; + return ( + <> + {"·"} + {text} + + ); +} + +/** 自动关闭倒计时小药丸 */ +export function AutoCloseChip({ seconds }: { seconds: number }) { + return ( + + + {tk("auto_close", { count: seconds })} + + ); +} + +/** 桌面端整页视图 */ +export function DesktopView({ view }: { view: BatchUpdateViewProps }) { + const empty = view.updates.length === 0 && view.ignored.length === 0; + return ( +
+
+
+ +

{tk("title")}

+ +
+
+ + {view.autoClose !== null && } + +
+
+
+
+ {view.loading ? ( +
+ +
+ ) : empty ? ( + + ) : ( + <> + {view.updates.length > 0 && ( + <> + + + + )} + {view.ignored.length > 0 && } + + )} +
+
+
+ ); +} diff --git a/src/pages/batchupdate/hooks.ts b/src/pages/batchupdate/hooks.ts new file mode 100644 index 000000000..e47e4a80e --- /dev/null +++ b/src/pages/batchupdate/hooks.ts @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { TBatchUpdateRecord } from "@App/app/service/service_worker/types"; +import { BatchUpdateListActionCode, UpdateStatusCode } from "@App/app/service/service_worker/types"; +import { requestBatchUpdateListAction, requestCheckScriptUpdate, scriptClient } from "@App/pages/store/features/script"; +import { subscribeMessage } from "@App/pages/store/global"; +import { assembleRecord, categorize, type UpdateItem } from "./logic"; +import type { BatchUpdateViewProps } from "./components"; + +/** 服务端 onScriptUpdateCheck 广播的消息体 */ +interface UpdateCheckMessage { + status?: number; + checktime?: number; + refreshRecord?: boolean; +} + +/** 解析 URL 上的 autoclose 参数;> 0 时返回秒数,否则返回 null(不自动关闭) */ +function parseAutoClose(): number | null { + const raw = new URLSearchParams(window.location.search).get("autoclose"); + const n = raw === null ? NaN : parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : null; +} + +/** 批量更新页面的数据与交互逻辑 */ +export function useBatchUpdate(): BatchUpdateViewProps { + const [records, setRecords] = useState([]); + const [checktime, setChecktime] = useState(0); + const [checking, setChecking] = useState(false); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState>(() => new Set()); + const [autoClose, setAutoClose] = useState(() => parseAutoClose()); + + const loadingRef = useRef(false); + + const loadRecord = useCallback(async () => { + if (loadingRef.current) return; + loadingRef.current = true; + try { + const obj = await assembleRecord((i) => scriptClient.getBatchUpdateRecordLite(i)); + setRecords(obj?.list ?? []); + if (typeof obj?.checktime === "number") setChecktime(obj.checktime); + } finally { + loadingRef.current = false; + setLoading(false); + } + }, []); + + // 初始化:订阅状态广播、上报页面已打开、拉取当前状态与记录 + useEffect(() => { + const unsub = subscribeMessage("onScriptUpdateCheck", (msg) => { + if (typeof msg.status === "number") { + setChecking((msg.status & UpdateStatusCode.CHECKING_UPDATE) !== 0); + } + if (typeof msg.checktime === "number") setChecktime(msg.checktime); + const finished = typeof msg.status === "number" && (msg.status & UpdateStatusCode.CHECKING_UPDATE) === 0; + if (msg.refreshRecord || finished) { + void loadRecord(); + } + }); + void scriptClient.fetchCheckUpdateStatus(); + void scriptClient.sendUpdatePageOpened(); + void loadRecord(); + return unsub; + }, [loadRecord]); + + // 自动关闭倒计时:每秒递减一次(标签页不可见或已取消时不动) + useEffect(() => { + const id = window.setInterval(() => { + setAutoClose((s) => (s === null || document.hidden ? s : s - 1)); + }, 1000); + return () => window.clearInterval(id); + }, []); + useEffect(() => { + if (autoClose !== null && autoClose <= 0) window.close(); + }, [autoClose]); + + const cancelAutoClose = useCallback(() => setAutoClose(null), []); + + const { updates, ignored } = useMemo(() => categorize(records), [records]); + + const onUpdate = useCallback( + (item: UpdateItem) => { + cancelAutoClose(); + void requestBatchUpdateListAction({ + actionCode: BatchUpdateListActionCode.UPDATE, + actionPayload: [{ uuid: item.uuid }], + }); + }, + [cancelAutoClose] + ); + + const onIgnore = useCallback( + (item: UpdateItem) => { + cancelAutoClose(); + void requestBatchUpdateListAction({ + actionCode: BatchUpdateListActionCode.IGNORE, + actionPayload: [{ uuid: item.uuid, ignoreVersion: item.newVersion }], + }); + }, + [cancelAutoClose] + ); + + const onUpdateSelected = useCallback(() => { + cancelAutoClose(); + const payload = updates.filter((u) => selected.has(u.uuid)).map((u) => ({ uuid: u.uuid })); + if (payload.length) { + void requestBatchUpdateListAction({ actionCode: BatchUpdateListActionCode.UPDATE, actionPayload: payload }); + } + setSelected(new Set()); + }, [updates, selected, cancelAutoClose]); + + 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 }); + } + setSelected(new Set()); + }, [updates, selected, cancelAutoClose]); + + const onRestoreAll = useCallback(() => { + cancelAutoClose(); + const payload = ignored.map((u) => ({ uuid: u.uuid })); + if (payload.length) { + void requestBatchUpdateListAction({ actionCode: BatchUpdateListActionCode.UPDATE, actionPayload: payload }); + } + }, [ignored, cancelAutoClose]); + + const onCheckNow = useCallback(() => { + cancelAutoClose(); + void requestCheckScriptUpdate({ checkType: "user" }); + }, [cancelAutoClose]); + + const onToggle = useCallback( + (uuid: string) => { + cancelAutoClose(); + setSelected((prev) => { + const next = new Set(prev); + if (next.has(uuid)) next.delete(uuid); + else next.add(uuid); + return next; + }); + }, + [cancelAutoClose] + ); + + const onToggleAll = useCallback(() => { + cancelAutoClose(); + setSelected((prev) => { + if (updates.length > 0 && updates.every((u) => prev.has(u.uuid))) return new Set(); + return new Set(updates.map((u) => u.uuid)); + }); + }, [updates, cancelAutoClose]); + + const onClose = useCallback(() => window.close(), []); + + return { + updates, + ignored, + totalChecked: records.length, + checktime, + checking, + loading, + selected, + autoClose, + onToggle, + onToggleAll, + onUpdate, + onIgnore, + onRestore: onUpdate, + onUpdateSelected, + onIgnoreSelected, + onRestoreAll, + onCheckNow, + onClose, + }; +} diff --git a/src/pages/batchupdate/main.tsx b/src/pages/batchupdate/main.tsx new file mode 100644 index 000000000..8c3c49e78 --- /dev/null +++ b/src/pages/batchupdate/main.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import LoggerCore from "@App/app/logger/core.ts"; +import { message } from "../store/global.ts"; +import MessageWriter from "@App/app/logger/message_writer.ts"; +import { ThemeProvider } from "../components/theme-provider.tsx"; +import { Toaster } from "../components/ui/sonner.tsx"; +import "@App/index.css"; + +// 初始化日志组件 +const loggerCore = new LoggerCore({ + writer: new MessageWriter(message), + labels: { env: "batchupdate" }, +}); + +loggerCore.logger().debug("batchupdate page start"); + +const Root = ( + + + + +); + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + process.env.NODE_ENV === "development" ? {Root} : Root +); diff --git a/src/pages/batchupdate/mobile.tsx b/src/pages/batchupdate/mobile.tsx new file mode 100644 index 000000000..ea7075c38 --- /dev/null +++ b/src/pages/batchupdate/mobile.tsx @@ -0,0 +1,218 @@ +import { BellOff, ChevronRight, Download, Loader2, PackageCheck, RefreshCw, RotateCcw, X } from "lucide-react"; +import { cn } from "@App/pkg/utils/cn"; +import { formatUnixTime } from "@App/pkg/utils/day_format"; +import { Button } from "@App/pages/components/ui/button"; +import { Checkbox } from "@App/pages/components/ui/checkbox"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@App/pages/components/ui/collapsible"; +import type { UpdateItem } from "./logic"; +import { + AutoCloseChip, + ConnectBadge, + EmptyState, + RiskBadge, + ScriptTile, + SourceCell, + StatusBadge, + VersionDiff, + tk, + type BatchUpdateViewProps, +} from "./components"; + +/** 移动端单卡(待更新或已忽略) */ +function MobileCard({ + item, + selected, + onToggle, + onUpdate, + onIgnore, + onRestore, + ignoredCard, +}: { + item: UpdateItem; + selected?: boolean; + onToggle?: (uuid: string) => void; + onUpdate?: (item: UpdateItem) => void; + onIgnore?: (item: UpdateItem) => void; + onRestore?: (item: UpdateItem) => void; + ignoredCard?: boolean; +}) { + const dim = item.enabled ? "" : "opacity-55"; + return ( +
+
+ {ignoredCard ? ( + + ) : ( + onToggle?.(item.uuid)} /> + )} + + + {item.name} + + +
+
+ +
+
+ + {item.withNewConnect && } +
+
+
+ + + +
+ {ignoredCard ? ( + + ) : ( +
+ + + +
+ )} +
+
+ ); +} + +function MobileIgnored({ view }: { view: BatchUpdateViewProps }) { + return ( + +
+ + + {tk("ignored_section")} + + {view.ignored.length} + + + +
+ + {view.ignored.map((item) => ( + + ))} + +
+ ); +} + +/** 移动端整页视图 */ +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 subtitle = view.checking + ? tk("status_checking_updates") + : [ + view.updates.length > 0 ? tk("updates_available", { count: view.updates.length }) : "", + view.checktime ? tk("last_check", { time: formatUnixTime(Math.floor(view.checktime / 1000)) }) : "", + ] + .filter(Boolean) + .join(" · "); + + return ( +
+
+ +
+ {tk("title")} + {subtitle && {subtitle}} +
+
+ + +
+ + {!empty && view.updates.length > 0 && ( +
+
+ + + {tk("selected_count", { selected: selectedCount, total: view.updates.length })} + +
+ {view.autoClose !== null ? ( + + ) : ( + view.ignored.length > 0 && ( + + {tk("ignored_count", { count: view.ignored.length })} + + ) + )} +
+ )} + +
+ {view.loading ? ( +
+ +
+ ) : empty ? ( + + ) : ( +
+ {view.updates.map((item) => ( + + ))} + {view.ignored.length > 0 && } +
+ )} +
+ + {!empty && view.updates.length > 0 && ( +
+ + +
+ )} +
+ ); +} diff --git a/src/pages/confirm/App.tsx b/src/pages/confirm/App.tsx index f2314ba80..8ba014008 100644 --- a/src/pages/confirm/App.tsx +++ b/src/pages/confirm/App.tsx @@ -47,9 +47,9 @@ function BrandMark() { className="flex size-6 items-center justify-center rounded-md text-sm font-bold text-white" style={{ backgroundColor: BRAND }} > - S + {"S"}
- ScriptCat + {"ScriptCat"}
); } @@ -336,7 +336,7 @@ export function PermissionConfirm({ uuid }: { uuid: string }) {
)} diff --git a/src/pages/options/routes/AgentChat/chat_utils.test.ts b/src/pages/options/routes/AgentChat/chat_utils.test.ts new file mode 100644 index 000000000..ef1a29477 --- /dev/null +++ b/src/pages/options/routes/AgentChat/chat_utils.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect } from "vitest"; +import type { ChatMessage } from "@App/app/service/agent/core/types"; +import { + mergeToolResults, + groupMessages, + computeRegenerateAction, + computeEditAction, + computeUserRegenerateAction, + findNextAssistantGroupIndex, +} from "./chat_utils"; + +// 辅助函数:创建测试消息 +function makeMsg(overrides: Partial & { id: string; role: ChatMessage["role"] }): ChatMessage { + return { + conversationId: "conv1", + content: "", + createtime: Date.now(), + ...overrides, + }; +} + +describe("mergeToolResults", () => { + it("过滤掉 tool 和 system 消息,只保留 user 和 assistant", () => { + const messages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "hello" }), + makeMsg({ id: "a1", role: "assistant", content: "hi" }), + makeMsg({ id: "t1", role: "tool", content: "result", toolCallId: "tc1" }), + makeMsg({ id: "s1", role: "system", content: "system prompt" }), + ]; + const result = mergeToolResults(messages); + expect(result.map((m) => m.id)).toEqual(["u1", "a1"]); + }); + + it("将 tool 结果合并到 assistant 的 toolCalls 中", () => { + const messages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "hello" }), + makeMsg({ + id: "a1", + role: "assistant", + content: "", + toolCalls: [{ id: "tc1", name: "test", arguments: "{}", status: "running" }], + }), + makeMsg({ id: "t1", role: "tool", content: "tool output", toolCallId: "tc1" }), + ]; + const result = mergeToolResults(messages); + expect(result).toHaveLength(2); + expect(result[1].toolCalls?.[0].result).toBe("tool output"); + expect(result[1].toolCalls?.[0].status).toBe("running"); + }); +}); + +describe("groupMessages", () => { + it("用户和 assistant 消息交替分组", () => { + const messages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "q1" }), + makeMsg({ id: "a1", role: "assistant", content: "r1" }), + makeMsg({ id: "u2", role: "user", content: "q2" }), + makeMsg({ id: "a2", role: "assistant", content: "r2" }), + ]; + const groups = groupMessages(messages); + expect(groups).toHaveLength(4); + expect(groups[0]).toEqual({ type: "user", message: messages[0] }); + expect(groups[1]).toEqual({ type: "assistant", messages: [messages[1]] }); + expect(groups[2]).toEqual({ type: "user", message: messages[2] }); + expect(groups[3]).toEqual({ type: "assistant", messages: [messages[3]] }); + }); + + it("连续的 assistant 消息合并为一组", () => { + const messages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "q1" }), + makeMsg({ id: "a1", role: "assistant", content: "r1" }), + makeMsg({ id: "a2", role: "assistant", content: "r2" }), + ]; + const groups = groupMessages(messages); + expect(groups).toHaveLength(2); + expect(groups[1]).toEqual({ type: "assistant", messages: [messages[1], messages[2]] }); + }); + + it("空消息列表返回空分组", () => { + expect(groupMessages([])).toEqual([]); + }); +}); + +describe("computeRegenerateAction", () => { + it("重新生成第一条用户消息后的 assistant 响应", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "hello" }), + makeMsg({ id: "a1", role: "assistant", content: "hi" }), + ]; + const groups = groupMessages(mergeToolResults(allMessages)); + + const result = computeRegenerateAction(groups, 1, allMessages); + expect(result).not.toBeNull(); + expect(result!.idsToDelete).toContain("a1"); + expect(result!.idsToDelete).toContain("u1"); + expect(result!.remainingMessages).toEqual([]); + expect(result!.userContent).toBe("hello"); + }); + + it("重新生成中间轮的 assistant 响应", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "first" }), + makeMsg({ id: "a1", role: "assistant", content: "reply1" }), + makeMsg({ id: "u2", role: "user", content: "second" }), + makeMsg({ id: "a2", role: "assistant", content: "reply2" }), + ]; + const groups = groupMessages(mergeToolResults(allMessages)); + + const result = computeRegenerateAction(groups, 3, allMessages); + expect(result).not.toBeNull(); + expect(result!.idsToDelete).toContain("a2"); + expect(result!.idsToDelete).toContain("u2"); + expect(result!.idsToDelete).not.toContain("u1"); + expect(result!.idsToDelete).not.toContain("a1"); + expect(result!.remainingMessages.map((m) => m.id)).toEqual(["u1", "a1"]); + expect(result!.userContent).toBe("second"); + }); + + it("包含 tool 消息时正确处理", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "hello" }), + makeMsg({ + id: "a1", + role: "assistant", + content: "", + toolCalls: [{ id: "tc1", name: "test", arguments: "{}", status: "completed" }], + }), + makeMsg({ id: "t1", role: "tool", content: "result", toolCallId: "tc1" }), + makeMsg({ id: "a2", role: "assistant", content: "final" }), + ]; + const merged = mergeToolResults(allMessages); + const groups = groupMessages(merged); + + const result = computeRegenerateAction(groups, 1, allMessages); + expect(result).not.toBeNull(); + expect(result!.idsToDelete).toContain("a1"); + expect(result!.idsToDelete).toContain("a2"); + expect(result!.idsToDelete).toContain("u1"); + expect(result!.remainingMessages.map((m) => m.id)).toEqual(["t1"]); + }); + + it("没有用户消息时返回 null", () => { + const allMessages: ChatMessage[] = [makeMsg({ id: "a1", role: "assistant", content: "hi" })]; + const groups = groupMessages(allMessages); + const result = computeRegenerateAction(groups, 0, allMessages); + expect(result).toBeNull(); + }); + + it("传入非 assistant 组索引时返回 null", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "hello" }), + makeMsg({ id: "a1", role: "assistant", content: "hi" }), + ]; + const groups = groupMessages(allMessages); + const result = computeRegenerateAction(groups, 0, allMessages); + expect(result).toBeNull(); + }); +}); + +describe("computeEditAction", () => { + it("编辑第一条用户消息:删除所有消息", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "hello" }), + makeMsg({ id: "a1", role: "assistant", content: "hi" }), + ]; + const result = computeEditAction("u1", allMessages); + expect(result).not.toBeNull(); + expect(result!.idsToDelete.map((id) => id)).toEqual(["u1", "a1"]); + expect(result!.remainingMessages).toEqual([]); + }); + + it("编辑中间用户消息:保留之前的消息", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "first" }), + makeMsg({ id: "a1", role: "assistant", content: "reply1" }), + makeMsg({ id: "u2", role: "user", content: "second" }), + makeMsg({ id: "a2", role: "assistant", content: "reply2" }), + ]; + const result = computeEditAction("u2", allMessages); + expect(result).not.toBeNull(); + expect(result!.idsToDelete).toEqual(["u2", "a2"]); + expect(result!.remainingMessages.map((m) => m.id)).toEqual(["u1", "a1"]); + }); + + it("消息不存在时返回 null", () => { + const result = computeEditAction("nonexistent", []); + expect(result).toBeNull(); + }); +}); + +describe("findNextAssistantGroupIndex", () => { + it("用户消息后面有 assistant 组时返回其索引", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "hello" }), + makeMsg({ id: "a1", role: "assistant", content: "hi" }), + ]; + const groups = groupMessages(allMessages); + expect(findNextAssistantGroupIndex(groups, 0)).toBe(1); + }); + + it("用户消息后面没有 assistant 组时返回 null", () => { + const allMessages: ChatMessage[] = [makeMsg({ id: "u1", role: "user", content: "hello" })]; + const groups = groupMessages(allMessages); + expect(findNextAssistantGroupIndex(groups, 0)).toBeNull(); + }); + + it("用户消息是最后一个 group 时返回 null", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "first" }), + makeMsg({ id: "a1", role: "assistant", content: "reply" }), + makeMsg({ id: "u2", role: "user", content: "second" }), + ]; + const groups = groupMessages(allMessages); + expect(findNextAssistantGroupIndex(groups, 2)).toBeNull(); + }); + + it("用户消息重新生成:通过 findNextAssistantGroupIndex + computeRegenerateAction 联合使用", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "hello" }), + makeMsg({ id: "a1", role: "assistant", content: "hi" }), + makeMsg({ id: "u2", role: "user", content: "world" }), + makeMsg({ id: "a2", role: "assistant", content: "bye" }), + ]; + const groups = groupMessages(mergeToolResults(allMessages)); + + const assistantIdx0 = findNextAssistantGroupIndex(groups, 0); + expect(assistantIdx0).toBe(1); + const action0 = computeRegenerateAction(groups, assistantIdx0!, allMessages); + expect(action0).not.toBeNull(); + expect(action0!.userContent).toBe("hello"); + expect(action0!.idsToDelete).toContain("u1"); + expect(action0!.idsToDelete).toContain("a1"); + expect(action0!.remainingMessages.map((m) => m.id)).toEqual(["u2", "a2"]); + + const assistantIdx2 = findNextAssistantGroupIndex(groups, 2); + expect(assistantIdx2).toBe(3); + const action2 = computeRegenerateAction(groups, assistantIdx2!, allMessages); + expect(action2).not.toBeNull(); + expect(action2!.userContent).toBe("world"); + expect(action2!.idsToDelete).toContain("u2"); + expect(action2!.idsToDelete).toContain("a2"); + expect(action2!.remainingMessages.map((m) => m.id)).toEqual(["u1", "a1"]); + }); +}); + +describe("computeUserRegenerateAction — 用户消息重新生成(bug 修复验证)", () => { + it("【bug 回归】第一条用户消息重新生成:必须保留用户消息,只删除 assistant 回复", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "hello" }), + makeMsg({ id: "a1", role: "assistant", content: "hi" }), + ]; + + const result = computeUserRegenerateAction("u1", allMessages); + expect(result).not.toBeNull(); + + expect(result!.idsToDelete).toEqual(["a1"]); + expect(result!.idsToDelete).not.toContain("u1"); + + expect(result!.remainingMessages).toHaveLength(1); + expect(result!.remainingMessages[0].id).toBe("u1"); + expect(result!.remainingMessages[0].content).toBe("hello"); + + expect(result!.skipUserMessage).toBe(true); + expect(result!.userContent).toBe("hello"); + }); + + it("多轮对话中重新生成第一条用户消息:只删除紧跟的 assistant 回复", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "first" }), + makeMsg({ id: "a1", role: "assistant", content: "reply1" }), + makeMsg({ id: "u2", role: "user", content: "second" }), + makeMsg({ id: "a2", role: "assistant", content: "reply2" }), + ]; + + const result = computeUserRegenerateAction("u1", allMessages); + expect(result).not.toBeNull(); + expect(result!.idsToDelete).toEqual(["a1", "u2", "a2"]); + expect(result!.remainingMessages.map((m) => m.id)).toEqual(["u1"]); + expect(result!.skipUserMessage).toBe(true); + expect(result!.userContent).toBe("first"); + }); + + it("重新生成中间用户消息:保留之前的消息和当前用户消息", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "first" }), + makeMsg({ id: "a1", role: "assistant", content: "reply1" }), + makeMsg({ id: "u2", role: "user", content: "second" }), + makeMsg({ id: "a2", role: "assistant", content: "reply2" }), + ]; + + const result = computeUserRegenerateAction("u2", allMessages); + expect(result).not.toBeNull(); + expect(result!.idsToDelete).toEqual(["a2"]); + expect(result!.remainingMessages.map((m) => m.id)).toEqual(["u1", "a1", "u2"]); + expect(result!.skipUserMessage).toBe(true); + expect(result!.userContent).toBe("second"); + }); + + it("用户消息后面没有回复时:idsToDelete 为空", () => { + const allMessages: ChatMessage[] = [makeMsg({ id: "u1", role: "user", content: "hello" })]; + + const result = computeUserRegenerateAction("u1", allMessages); + expect(result).not.toBeNull(); + expect(result!.idsToDelete).toEqual([]); + expect(result!.remainingMessages.map((m) => m.id)).toEqual(["u1"]); + expect(result!.userContent).toBe("hello"); + }); + + it("消息不存在时返回 null", () => { + const result = computeUserRegenerateAction("nonexistent", [makeMsg({ id: "u1", role: "user", content: "hello" })]); + expect(result).toBeNull(); + }); + + it("包含 tool 消息时也只删除用户消息之后的部分", () => { + const allMessages: ChatMessage[] = [ + makeMsg({ id: "u1", role: "user", content: "hello" }), + makeMsg({ + id: "a1", + role: "assistant", + content: "", + toolCalls: [{ id: "tc1", name: "test", arguments: "{}", status: "completed" }], + }), + makeMsg({ id: "t1", role: "tool", content: "result", toolCallId: "tc1" }), + makeMsg({ id: "a2", role: "assistant", content: "final" }), + ]; + + const result = computeUserRegenerateAction("u1", allMessages); + expect(result).not.toBeNull(); + expect(result!.idsToDelete).toEqual(["a1", "t1", "a2"]); + expect(result!.remainingMessages.map((m) => m.id)).toEqual(["u1"]); + expect(result!.skipUserMessage).toBe(true); + }); +}); diff --git a/src/pages/options/routes/AgentChat/chat_utils.ts b/src/pages/options/routes/AgentChat/chat_utils.ts new file mode 100644 index 000000000..15b77cdc2 --- /dev/null +++ b/src/pages/options/routes/AgentChat/chat_utils.ts @@ -0,0 +1,183 @@ +import type { ChatMessage, MessageContent, SubAgentDetails } from "@App/app/service/agent/core/types"; +import type { SubAgentState } from "./types"; + +/** 消息分组:连续的 assistant 消息合并为一组,user 单独成组 */ +export type MessageGroup = { type: "user"; message: ChatMessage } | { type: "assistant"; messages: ChatMessage[] }; + +/** 将 tool 角色消息的结果合并进 assistant 的 toolCalls,并过滤掉 tool/system 消息 */ +export function mergeToolResults(messages: ChatMessage[]): ChatMessage[] { + const toolResultMap = new Map(); + for (const msg of messages) { + if (msg.role === "tool" && msg.toolCallId) { + // tool 消息的 content 始终是 string + toolResultMap.set(msg.toolCallId, typeof msg.content === "string" ? msg.content : ""); + } + } + + return messages + .filter((msg) => msg.role === "user" || msg.role === "assistant") + .map((msg) => { + if (msg.role === "assistant" && msg.toolCalls && toolResultMap.size > 0) { + const updatedToolCalls = msg.toolCalls.map((tc) => { + const result = toolResultMap.get(tc.id); + if (result !== undefined) { + return { ...tc, result, status: (tc.status || "completed") as typeof tc.status }; + } + return tc; + }); + return { ...msg, toolCalls: updatedToolCalls }; + } + return msg; + }); +} + +/** 按角色把消息分组:连续的 assistant 合并为一组 */ +export function groupMessages(messages: ChatMessage[]): MessageGroup[] { + const groups: MessageGroup[] = []; + for (const msg of messages) { + if (msg.role === "user") { + groups.push({ type: "user", message: msg }); + } else { + const last = groups[groups.length - 1]; + if (last && last.type === "assistant") { + last.messages.push(msg); + } else { + groups.push({ type: "assistant", messages: [msg] }); + } + } + } + return groups; +} + +/** + * 计算「重新生成」需要删除的消息与保留的消息。 + * 输入 assistant 组索引;删除该 assistant 组与其前一条用户消息(由 handleSend 重新创建)。 + */ +export function computeRegenerateAction( + groups: MessageGroup[], + assistantGroupIndex: number, + allMessages: ChatMessage[] +): { idsToDelete: string[]; remainingMessages: ChatMessage[]; userContent: MessageContent } | null { + const group = groups[assistantGroupIndex]; + if (!group || group.type !== "assistant") return null; + + // 向前找到对应的用户消息 + let userMessage: ChatMessage | null = null; + for (let i = assistantGroupIndex - 1; i >= 0; i--) { + const g = groups[i]; + if (g.type === "user") { + userMessage = g.message; + break; + } + } + if (!userMessage) return null; + + const idsToDelete = group.messages.map((m) => m.id); + idsToDelete.push(userMessage.id); + + const idSet = new Set(idsToDelete); + const remainingMessages = allMessages.filter((m) => !idSet.has(m.id)); + + return { idsToDelete, remainingMessages, userContent: userMessage.content }; +} + +/** 计算「编辑用户消息」需要删除的消息(该消息及其后全部)与保留的消息 */ +export function computeEditAction( + messageId: string, + allMessages: ChatMessage[] +): { idsToDelete: string[]; remainingMessages: ChatMessage[] } | null { + const idx = allMessages.findIndex((m) => m.id === messageId); + if (idx < 0) return null; + + const idsToDelete = allMessages.slice(idx).map((m) => m.id); + const remainingMessages = allMessages.slice(0, idx); + + return { idsToDelete, remainingMessages }; +} + +/** 用户消息位于 groups 的 userGroupIndex,返回紧跟其后的 assistant 组索引 */ +export function findNextAssistantGroupIndex(groups: MessageGroup[], userGroupIndex: number): number | null { + if (userGroupIndex + 1 < groups.length && groups[userGroupIndex + 1].type === "assistant") { + return userGroupIndex + 1; + } + return null; +} + +/** + * 计算「用户消息重新生成」:保留用户消息本身,只删除其后的回复。 + * skipUserMessage 为 true,避免 startStreaming 重复创建用户消息。 + */ +export function computeUserRegenerateAction( + messageId: string, + allMessages: ChatMessage[] +): { + idsToDelete: string[]; + remainingMessages: ChatMessage[]; + userContent: MessageContent; + skipUserMessage: true; +} | null { + const idx = allMessages.findIndex((m) => m.id === messageId); + if (idx < 0) return null; + + const userContent = allMessages[idx].content; + const idsToDelete = allMessages.slice(idx + 1).map((m) => m.id); + const remainingMessages = allMessages.slice(0, idx + 1); + + return { idsToDelete, remainingMessages, userContent, skipUserMessage: true }; +} + +/** 匹配 agent 工具调用对应的子代理状态(流式 map 优先,回退到持久化 subAgentDetails) */ +export function getSubAgentForToolCall( + tc: { name: string; result?: string; arguments?: string; subAgentDetails?: SubAgentDetails }, + subAgents?: Map +): SubAgentState | undefined { + if (tc.name !== "agent") return undefined; + + if (subAgents) { + // 1a. 从已完成结果匹配(格式: "[agentId: xxx]\n\n...") + if (tc.result) { + const match = tc.result.match(/^\[agentId: ([^\]]+)\]/); + if (match) { + const sa = subAgents.get(match[1]); + if (sa) return sa; + } + } + // 1b. 从参数 to 字段匹配(resume 场景) + if (tc.arguments) { + try { + const args = JSON.parse(tc.arguments); + if (args.to && subAgents.has(args.to)) return subAgents.get(args.to); + } catch { + // 参数可能仍在流式构建中 + } + } + // 1c. 无结果时:优先运行中的子代理,回退到已完成的 + // (覆盖 sub-agent done → tool_call_complete 之间的间隙) + if (!tc.result) { + let completed: SubAgentState | undefined; + for (const sa of subAgents.values()) { + if (sa.isRunning) return sa; + if (!completed) completed = sa; + } + if (completed) return completed; + } + } + + // 2. 回退到持久化 subAgentDetails(刷新/加载后可用) + if (tc.subAgentDetails) { + const d = tc.subAgentDetails; + return { + agentId: d.agentId, + description: d.description, + subAgentType: d.subAgentType, + completedMessages: d.messages, + currentContent: "", + currentThinking: "", + currentToolCalls: [], + isRunning: false, + usage: d.usage, + } satisfies SubAgentState; + } + + return undefined; +} diff --git a/src/pages/options/routes/AgentChat/sub_agent_match.test.ts b/src/pages/options/routes/AgentChat/sub_agent_match.test.ts new file mode 100644 index 000000000..9001ac64b --- /dev/null +++ b/src/pages/options/routes/AgentChat/sub_agent_match.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from "vitest"; +import type { SubAgentState } from "./types"; +import type { ChatMessage, ToolCall } from "@App/app/service/agent/core/types"; +import { getSubAgentForToolCall, mergeToolResults } from "./chat_utils"; + +// 辅助:创建子代理状态 +function makeSA(overrides: Partial & { agentId: string }): SubAgentState { + return { + description: "test sub-agent", + completedMessages: [], + currentContent: "", + currentThinking: "", + currentToolCalls: [], + isRunning: true, + ...overrides, + }; +} + +describe("getSubAgentForToolCall", () => { + describe("非 agent 工具调用", () => { + it("name 不是 agent 时返回 undefined", () => { + const result = getSubAgentForToolCall({ name: "web_search" }); + expect(result).toBeUndefined(); + }); + }); + + describe("路径 1a:通过 tc.result 中的 agentId 匹配", () => { + it("result 包含 [agentId: xxx] 且 subAgents 有对应项", () => { + const sa = makeSA({ agentId: "agent-1" }); + const subAgents = new Map([["agent-1", sa]]); + const result = getSubAgentForToolCall( + { name: "agent", result: "[agentId: agent-1]\n\nTask completed." }, + subAgents + ); + expect(result).toBe(sa); + }); + + it("result 中的 agentId 在 subAgents 中不存在时继续后续匹配", () => { + const subAgents = new Map(); + const result = getSubAgentForToolCall({ name: "agent", result: "[agentId: unknown-id]\n\nDone." }, subAgents); + expect(result).toBeUndefined(); + }); + }); + + describe("路径 1b:通过 arguments.to 匹配(resume 场景)", () => { + it("arguments 有 to 字段且匹配 subAgents", () => { + const sa = makeSA({ agentId: "agent-2" }); + const subAgents = new Map([["agent-2", sa]]); + const result = getSubAgentForToolCall( + { name: "agent", arguments: JSON.stringify({ prompt: "continue", to: "agent-2" }) }, + subAgents + ); + expect(result).toBe(sa); + }); + }); + + describe("路径 1c:无 result 时匹配运行中或已完成的子代理", () => { + it("子代理正在运行时匹配", () => { + const sa = makeSA({ agentId: "agent-3", isRunning: true }); + const subAgents = new Map([["agent-3", sa]]); + const result = getSubAgentForToolCall({ name: "agent" }, subAgents); + expect(result).toBe(sa); + }); + + it("【关键场景】子代理已完成但 result 尚未到达时,回退匹配已完成的子代理", () => { + const sa = makeSA({ agentId: "agent-3", isRunning: false }); + const subAgents = new Map([["agent-3", sa]]); + const result = getSubAgentForToolCall({ name: "agent" }, subAgents); + expect(result).toBe(sa); + }); + + it("多个子代理时优先匹配运行中的", () => { + const completed = makeSA({ agentId: "agent-done", isRunning: false }); + const running = makeSA({ agentId: "agent-running", isRunning: true }); + const subAgents = new Map([ + ["agent-done", completed], + ["agent-running", running], + ]); + const result = getSubAgentForToolCall({ name: "agent" }, subAgents); + expect(result).toBe(running); + }); + + it("tc.result 已设置时不走 1c 路径", () => { + const sa = makeSA({ agentId: "agent-x", isRunning: true }); + const subAgents = new Map([["agent-x", sa]]); + const result = getSubAgentForToolCall({ name: "agent", result: "[agentId: other]\n\nDone." }, subAgents); + expect(result).toBeUndefined(); + }); + }); + + describe("路径 2:回退到持久化 subAgentDetails", () => { + it("无流式 subAgents 时从 subAgentDetails 构建状态", () => { + const result = getSubAgentForToolCall({ + name: "agent", + subAgentDetails: { + agentId: "agent-persisted", + description: "Persisted agent", + messages: [{ content: "hello", toolCalls: [] }], + usage: { inputTokens: 100, outputTokens: 50 }, + }, + }); + expect(result).toBeDefined(); + expect(result!.agentId).toBe("agent-persisted"); + expect(result!.description).toBe("Persisted agent"); + expect(result!.isRunning).toBe(false); + expect(result!.completedMessages).toHaveLength(1); + expect(result!.usage).toEqual({ inputTokens: 100, outputTokens: 50 }); + }); + }); + + describe("完整生命周期模拟", () => { + it("模拟 sub-agent 从启动到完成的全流程", () => { + const subAgents = new Map(); + const tc = { name: "agent", arguments: JSON.stringify({ prompt: "do something", description: "test" }) }; + + expect(getSubAgentForToolCall(tc, subAgents)).toBeUndefined(); + + const sa = makeSA({ agentId: "sa-1", isRunning: true }); + subAgents.set("sa-1", sa); + expect(getSubAgentForToolCall(tc, subAgents)).toBe(sa); + + sa.isRunning = false; + const matched = getSubAgentForToolCall(tc, subAgents); + expect(matched).toBe(sa); + + const tcWithResult = { ...tc, result: "[agentId: sa-1]\n\nTask done." }; + expect(getSubAgentForToolCall(tcWithResult, subAgents)).toBe(sa); + + const tcPersisted = { + name: "agent", + result: "[agentId: sa-1]\n\nTask done.", + subAgentDetails: { + agentId: "sa-1", + description: "test", + messages: [{ content: "Task done.", toolCalls: [] }], + }, + }; + const fromPersisted = getSubAgentForToolCall(tcPersisted); + expect(fromPersisted).toBeDefined(); + expect(fromPersisted!.agentId).toBe("sa-1"); + }); + + it("模拟完整渲染管线:streaming messages → mergeToolResults → getSubAgentForToolCall", () => { + const subAgents = new Map(); + const agentToolCall: ToolCall = { + id: "tc-agent", + name: "agent", + arguments: '{"prompt":"do","description":"test"}', + status: "running", + }; + const assistantMsg: ChatMessage = { + id: "msg-1", + conversationId: "conv-1", + role: "assistant", + content: "", + toolCalls: [agentToolCall], + createtime: Date.now(), + }; + const streamingMessages: ChatMessage[] = [ + { id: "u-1", conversationId: "conv-1", role: "user", content: "hello", createtime: Date.now() }, + assistantMsg, + ]; + + let merged = mergeToolResults(streamingMessages); + let mergedTc = merged.find((m) => m.role === "assistant")!.toolCalls![0]; + expect(getSubAgentForToolCall(mergedTc, subAgents)).toBeUndefined(); + + const sa = makeSA({ agentId: "sa-1", isRunning: true }); + subAgents.set("sa-1", sa); + merged = mergeToolResults(streamingMessages); + mergedTc = merged.find((m) => m.role === "assistant")!.toolCalls![0]; + expect(getSubAgentForToolCall(mergedTc, subAgents)).toBe(sa); + + sa.isRunning = false; + merged = mergeToolResults(streamingMessages); + mergedTc = merged.find((m) => m.role === "assistant")!.toolCalls![0]; + expect(getSubAgentForToolCall(mergedTc, subAgents)).toBe(sa); + + agentToolCall.result = "[agentId: sa-1]\n\nTask done."; + agentToolCall.status = "completed"; + merged = mergeToolResults(streamingMessages); + mergedTc = merged.find((m) => m.role === "assistant")!.toolCalls![0]; + expect(getSubAgentForToolCall(mergedTc, subAgents)).toBe(sa); + + const newAssistant: ChatMessage = { + id: "msg-2", + conversationId: "conv-1", + role: "assistant", + content: "I completed the sub-task.", + createtime: Date.now(), + }; + streamingMessages.push(newAssistant); + merged = mergeToolResults(streamingMessages); + mergedTc = merged.find((m) => m.id === "msg-1")!.toolCalls![0]; + expect(getSubAgentForToolCall(mergedTc, subAgents)).toBe(sa); + + const storedMessages: ChatMessage[] = [ + { id: "u-1", conversationId: "conv-1", role: "user", content: "hello", createtime: Date.now() }, + { + id: "msg-1", + conversationId: "conv-1", + role: "assistant", + content: "", + toolCalls: [ + { + id: "tc-agent", + name: "agent", + arguments: '{"prompt":"do","description":"test"}', + status: "completed", + subAgentDetails: { + agentId: "sa-1", + description: "test", + messages: [{ content: "Task done.", toolCalls: [] }], + }, + }, + ], + createtime: Date.now(), + }, + { + id: "t-1", + conversationId: "conv-1", + role: "tool", + content: "[agentId: sa-1]\n\nTask done.", + toolCallId: "tc-agent", + createtime: Date.now(), + }, + { + id: "msg-2", + conversationId: "conv-1", + role: "assistant", + content: "I completed the sub-task.", + createtime: Date.now(), + }, + ]; + merged = mergeToolResults(storedMessages); + const loadedTc = merged.find((m) => m.id === "msg-1")!.toolCalls![0]; + expect(loadedTc.result).toBe("[agentId: sa-1]\n\nTask done."); + expect(getSubAgentForToolCall(loadedTc)).toBeDefined(); + expect(getSubAgentForToolCall(loadedTc)!.agentId).toBe("sa-1"); + expect(getSubAgentForToolCall(loadedTc, subAgents)).toBe(sa); + }); + }); +}); diff --git a/src/pages/options/routes/AgentChat/types.ts b/src/pages/options/routes/AgentChat/types.ts new file mode 100644 index 000000000..7d9952a57 --- /dev/null +++ b/src/pages/options/routes/AgentChat/types.ts @@ -0,0 +1,21 @@ +import type { SubAgentMessage, ToolCall, TokenUsage } from "@App/app/service/agent/core/types"; + +export type { SubAgentMessage }; + +/** 子代理完整状态(流式期间维护,渲染时消费) */ +export type SubAgentState = { + agentId: string; + description: string; + subAgentType?: string; + /** 已完成的消息轮次 */ + completedMessages: SubAgentMessage[]; + /** 当前正在构建的消息内容 */ + currentContent: string; + currentThinking: string; + currentToolCalls: ToolCall[]; + isRunning: boolean; + /** 重试信息 */ + retryInfo?: { attempt: number; maxRetries: number; error: string }; + /** token 用量 */ + usage?: TokenUsage; +}; From c693d13913548a3841df9671b7898e44c1fa91b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 14:02:38 +0800 Subject: [PATCH 22/97] =?UTF-8?q?=F0=9F=8C=90=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E5=8F=82=E6=95=B0=E6=A0=87=E7=AD=BE=E9=94=AE?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=20settings=E2=86=92common(=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=20factory=20=E8=A3=B8=20key=20=E8=A7=A3=E6=9E=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/de-DE/common.json | 12 +++++++++++- src/locales/de-DE/settings.json | 10 ---------- src/locales/en-US/common.json | 12 +++++++++++- src/locales/en-US/settings.json | 10 ---------- src/locales/ja-JP/common.json | 12 +++++++++++- src/locales/ja-JP/settings.json | 10 ---------- src/locales/ru-RU/common.json | 12 +++++++++++- src/locales/ru-RU/settings.json | 10 ---------- src/locales/vi-VN/common.json | 12 +++++++++++- src/locales/vi-VN/settings.json | 10 ---------- src/locales/zh-CN/common.json | 12 +++++++++++- src/locales/zh-CN/settings.json | 10 ---------- src/locales/zh-TW/common.json | 12 +++++++++++- src/locales/zh-TW/settings.json | 10 ---------- 14 files changed, 77 insertions(+), 77 deletions(-) diff --git a/src/locales/de-DE/common.json b/src/locales/de-DE/common.json index 629c8ea21..9eaeff643 100644 --- a/src/locales/de-DE/common.json +++ b/src/locales/de-DE/common.json @@ -114,5 +114,15 @@ "dark": "Dunkler Modus", "enter_search_value": "Bitte geben Sie {{search}} für die Suche ein", "no_message_content": "Kein Nachrichteninhalt", - "loading": "Wird geladen..." + "loading": "Wird geladen...", + "auth_type": "Authentifizierungstyp", + "url": "URL", + "username": "Benutzername", + "password": "Passwort", + "access_token_bearer": "Zugriffstoken (Bearer)", + "s3_bucket_name": "Bucket-Name", + "s3_region": "Region", + "s3_access_key_id": "Zugriffs-Schlüssel-ID", + "s3_secret_access_key": "Geheimer Zugriffsschlüssel", + "s3_custom_endpoint": "Benutzerdefinierter Endpunkt (optional)" } diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index f4912ce5a..edd5c22f2 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -36,16 +36,6 @@ "sync_system_connect_failed": "Synchronisationssystem-Verbindung fehlgeschlagen", "sync_system_closed": "Synchronisation geschlossen", "sync_system_closed_description": "Synchronisationsfunktion ist geschlossen, bitte neu konfigurieren", - "auth_type": "Authentifizierungstyp", - "url": "URL", - "username": "Benutzername", - "password": "Passwort", - "access_token_bearer": "Zugriffstoken (Bearer)", - "s3_bucket_name": "Bucket-Name", - "s3_region": "Region", - "s3_access_key_id": "Zugriffs-Schlüssel-ID", - "s3_secret_access_key": "Geheimer Zugriffsschlüssel", - "s3_custom_endpoint": "Benutzerdefinierter Endpunkt (optional)", "export_success": "Export erfolgreich", "get_backup_dir_url_failed": "Backup-Verzeichnis-Adresse abrufen fehlgeschlagen", "get_backup_files_failed": "Backup-Dateien abrufen fehlgeschlagen", diff --git a/src/locales/en-US/common.json b/src/locales/en-US/common.json index 377cbbf99..23126b061 100644 --- a/src/locales/en-US/common.json +++ b/src/locales/en-US/common.json @@ -114,5 +114,15 @@ "dark": "Dark", "enter_search_value": "Enter {{search}} to search", "no_message_content": "No Message Content", - "loading": "Loading..." + "loading": "Loading...", + "auth_type": "Authentication Type", + "url": "URL", + "username": "Username", + "password": "Password", + "access_token_bearer": "Access Token (Bearer)", + "s3_bucket_name": "Bucket Name", + "s3_region": "Region", + "s3_access_key_id": "Access Key ID", + "s3_secret_access_key": "Secret Access Key", + "s3_custom_endpoint": "Custom Endpoint (Optional)" } diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index c74acb748..9e3e6d7e9 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -36,16 +36,6 @@ "sync_system_connect_failed": "Sync system connection failed", "sync_system_closed": "Sync turned off", "sync_system_closed_description": "Sync is disabled, please configure again", - "auth_type": "Authentication Type", - "url": "URL", - "username": "Username", - "password": "Password", - "access_token_bearer": "Access Token (Bearer)", - "s3_bucket_name": "Bucket Name", - "s3_region": "Region", - "s3_access_key_id": "Access Key ID", - "s3_secret_access_key": "Secret Access Key", - "s3_custom_endpoint": "Custom Endpoint (Optional)", "export_success": "Dump success saved", "get_backup_dir_url_failed": "Failed to get backup directory address", "get_backup_files_failed": "Failed to fetch backups", diff --git a/src/locales/ja-JP/common.json b/src/locales/ja-JP/common.json index ea4829d8e..868aef54d 100644 --- a/src/locales/ja-JP/common.json +++ b/src/locales/ja-JP/common.json @@ -114,5 +114,15 @@ "dark": "ダークモード", "enter_search_value": "{{search}}を入力して検索してください", "no_message_content": "メッセージ内容なし", - "loading": "読み込み中..." + "loading": "読み込み中...", + "auth_type": "認証タイプ", + "url": "URL", + "username": "ユーザー名", + "password": "パスワード", + "access_token_bearer": "アクセストークン(Bearer)", + "s3_bucket_name": "バケット名", + "s3_region": "リージョン", + "s3_access_key_id": "アクセスキーID", + "s3_secret_access_key": "シークレットアクセスキー", + "s3_custom_endpoint": "カスタムエンドポイント(オプション)" } diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index 0b117d1c3..e05ff39fa 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -36,16 +36,6 @@ "sync_system_connect_failed": "同期システムの接続に失敗しました", "sync_system_closed": "同期が閉じられました", "sync_system_closed_description": "同期機能が閉じられています。再設定してください", - "auth_type": "認証タイプ", - "url": "URL", - "username": "ユーザー名", - "password": "パスワード", - "access_token_bearer": "アクセストークン(Bearer)", - "s3_bucket_name": "バケット名", - "s3_region": "リージョン", - "s3_access_key_id": "アクセスキーID", - "s3_secret_access_key": "シークレットアクセスキー", - "s3_custom_endpoint": "カスタムエンドポイント(オプション)", "export_success": "エクスポートに成功しました", "get_backup_dir_url_failed": "バックアップディレクトリアドレスの取得に失敗しました", "get_backup_files_failed": "バックアップファイルの取得に失敗しました", diff --git a/src/locales/ru-RU/common.json b/src/locales/ru-RU/common.json index 5d40c6682..6b53743e9 100644 --- a/src/locales/ru-RU/common.json +++ b/src/locales/ru-RU/common.json @@ -114,5 +114,15 @@ "dark": "Темный режим", "enter_search_value": "Введите {{search}} для поиска", "no_message_content": "Нет содержимого сообщения", - "loading": "Загрузка..." + "loading": "Загрузка...", + "auth_type": "Тип аутентификации", + "url": "URL", + "username": "Имя пользователя", + "password": "Пароль", + "access_token_bearer": "Токен доступа (Bearer)", + "s3_bucket_name": "Имя корзины", + "s3_region": "Регион", + "s3_access_key_id": "Идентификатор ключа доступа", + "s3_secret_access_key": "Секретный ключ доступа", + "s3_custom_endpoint": "Пользовательская конечная точка (необязательно)" } diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index 60b56cad1..897618779 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -36,16 +36,6 @@ "sync_system_connect_failed": "Ошибка подключения к системе синхронизации", "sync_system_closed": "Синхронизация отключена", "sync_system_closed_description": "Функция синхронизации отключена, пожалуйста, настройте заново", - "auth_type": "Тип аутентификации", - "url": "URL", - "username": "Имя пользователя", - "password": "Пароль", - "access_token_bearer": "Токен доступа (Bearer)", - "s3_bucket_name": "Имя корзины", - "s3_region": "Регион", - "s3_access_key_id": "Идентификатор ключа доступа", - "s3_secret_access_key": "Секретный ключ доступа", - "s3_custom_endpoint": "Пользовательская конечная точка (необязательно)", "export_success": "Экспорт успешен", "get_backup_dir_url_failed": "Ошибка получения адреса папки резервных копий", "get_backup_files_failed": "Ошибка получения файлов резервных копий", diff --git a/src/locales/vi-VN/common.json b/src/locales/vi-VN/common.json index 8ee2ad6bf..4bbbe4884 100644 --- a/src/locales/vi-VN/common.json +++ b/src/locales/vi-VN/common.json @@ -114,5 +114,15 @@ "dark": "Tối", "enter_search_value": "Nhập {{search}} để tìm kiếm", "no_message_content": "Không có nội dung tin nhắn", - "loading": "Đang tải..." + "loading": "Đang tải...", + "auth_type": "Loại xác thực", + "url": "Url", + "username": "Tên người dùng", + "password": "Mật khẩu", + "access_token_bearer": "Mã truy cập (Bearer)", + "s3_bucket_name": "Tên Bucket", + "s3_region": "Vùng", + "s3_access_key_id": "ID Khóa Truy Cập", + "s3_secret_access_key": "Khóa Truy Cập Bí Mật", + "s3_custom_endpoint": "Điểm Cuối Tùy Chỉnh (Tùy Chọn)" } diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index af645ef75..c45b083b9 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -36,16 +36,6 @@ "sync_system_connect_failed": "Kết nối hệ thống đồng bộ thất bại", "sync_system_closed": "Đồng bộ đã tắt", "sync_system_closed_description": "Đồng bộ bị tắt, vui lòng cấu hình lại", - "auth_type": "Loại xác thực", - "url": "Url", - "username": "Tên người dùng", - "password": "Mật khẩu", - "access_token_bearer": "Mã truy cập (Bearer)", - "s3_bucket_name": "Tên Bucket", - "s3_region": "Vùng", - "s3_access_key_id": "ID Khóa Truy Cập", - "s3_secret_access_key": "Khóa Truy Cập Bí Mật", - "s3_custom_endpoint": "Điểm Cuối Tùy Chỉnh (Tùy Chọn)", "export_success": "Đổ dữ liệu thành công đã lưu", "get_backup_dir_url_failed": "Không thể lấy địa chỉ thư mục sao lưu", "get_backup_files_failed": "Không thể lấy các bản sao lưu", diff --git a/src/locales/zh-CN/common.json b/src/locales/zh-CN/common.json index ab45f258f..bafcc2441 100644 --- a/src/locales/zh-CN/common.json +++ b/src/locales/zh-CN/common.json @@ -114,5 +114,15 @@ "dark": "暗色模式", "enter_search_value": "请输入 {{search}} 进行搜索", "no_message_content": "无消息内容", - "loading": "加载中..." + "loading": "加载中...", + "auth_type": "鉴权类型", + "url": "URL", + "username": "用户名", + "password": "密码", + "access_token_bearer": "访问令牌(Bearer)", + "s3_bucket_name": "存储桶名称", + "s3_region": "区域", + "s3_access_key_id": "访问密钥 ID", + "s3_secret_access_key": "访问密钥密文", + "s3_custom_endpoint": "自定义端点(可选)" } diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index b3882651b..03bb42306 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -36,16 +36,6 @@ "sync_system_connect_failed": "同步系统连接失败", "sync_system_closed": "已关闭同步", "sync_system_closed_description": "同步功能已关闭,请重新配置", - "auth_type": "鉴权类型", - "url": "URL", - "username": "用户名", - "password": "密码", - "access_token_bearer": "访问令牌(Bearer)", - "s3_bucket_name": "存储桶名称", - "s3_region": "区域", - "s3_access_key_id": "访问密钥 ID", - "s3_secret_access_key": "访问密钥密文", - "s3_custom_endpoint": "自定义端点(可选)", "export_success": "导出成功", "get_backup_dir_url_failed": "获取备份目录地址失败", "get_backup_files_failed": "获取备份文件失败", diff --git a/src/locales/zh-TW/common.json b/src/locales/zh-TW/common.json index 8761ebc93..52ef6100b 100644 --- a/src/locales/zh-TW/common.json +++ b/src/locales/zh-TW/common.json @@ -114,5 +114,15 @@ "dark": "暗色模式", "enter_search_value": "請輸入 {{search}} 進行搜尋", "no_message_content": "無訊息內容", - "loading": "加載中..." + "loading": "加載中...", + "auth_type": "驗證類型", + "url": "網址", + "username": "使用者名稱", + "password": "密碼", + "access_token_bearer": "存取權杖(Bearer)", + "s3_bucket_name": "儲存貯體名稱", + "s3_region": "區域", + "s3_access_key_id": "存取金鑰 ID", + "s3_secret_access_key": "私密存取金鑰", + "s3_custom_endpoint": "自訂端點(選用)" } diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index b03218ec0..2e09ebbf1 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -36,16 +36,6 @@ "sync_system_connect_failed": "同步系統連接失敗", "sync_system_closed": "已關閉同步", "sync_system_closed_description": "同步功能已關閉,請重新設定", - "auth_type": "驗證類型", - "url": "網址", - "username": "使用者名稱", - "password": "密碼", - "access_token_bearer": "存取權杖(Bearer)", - "s3_bucket_name": "儲存貯體名稱", - "s3_region": "區域", - "s3_access_key_id": "存取金鑰 ID", - "s3_secret_access_key": "私密存取金鑰", - "s3_custom_endpoint": "自訂端點(選用)", "export_success": "匯出成功", "get_backup_dir_url_failed": "取得備份目錄網址失敗", "get_backup_files_failed": "取得備份檔案失敗", From a9c7d6645ba49212aa856b4622fa7f0ec3eeb174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 14:10:56 +0800 Subject: [PATCH 23/97] =?UTF-8?q?=E2=9C=A8=20=E7=A7=BB=E6=A4=8D=20FileSyst?= =?UTF-8?q?emParams=20=E7=BB=84=E4=BB=B6=E5=88=B0=20new-ui(shadcn=20?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E5=8F=82=E6=95=B0=E8=A1=A8=E5=8D=95=20+=20?= =?UTF-8?q?=E7=BD=91=E7=9B=98=E8=A7=A3=E7=BB=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/FileSystemParams.test.tsx | 118 ++++++++++++++ .../options/components/FileSystemParams.tsx | 144 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/pages/options/components/FileSystemParams.test.tsx create mode 100644 src/pages/options/components/FileSystemParams.tsx diff --git a/src/pages/options/components/FileSystemParams.test.tsx b/src/pages/options/components/FileSystemParams.test.tsx new file mode 100644 index 000000000..eb00d9b70 --- /dev/null +++ b/src/pages/options/components/FileSystemParams.test.tsx @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; + +// 仅测试组件的渲染/可见性/回调逻辑,后端 schema 用受控 mock,避免拉起真实文件系统栈 +vi.mock("@Packages/filesystem/factory", () => ({ + default: { + params: () => ({ + webdav: { + authType: { + title: "auth_type", + type: "select", + options: ["password", "digest", "none", "token"], + minWidth: "140px", + }, + url: { title: "url" }, + username: { title: "username", visibilityFor: ["password", "digest"] }, + password: { title: "password", type: "password", visibilityFor: ["password", "digest"] }, + accessToken: { title: "access_token_bearer", visibilityFor: ["token"] }, + }, + "baidu-netdsik": {}, + onedrive: {}, + googledrive: {}, + dropbox: {}, + s3: { + bucket: { title: "s3_bucket_name" }, + region: { title: "s3_region" }, + accessKeyId: { title: "s3_access_key_id" }, + secretAccessKey: { title: "s3_secret_access_key", type: "password" }, + endpoint: { title: "s3_custom_endpoint" }, + }, + }), + }, +})); + +const { hasNetDiskToken, clearNetDiskToken } = vi.hoisted(() => ({ + hasNetDiskToken: vi.fn(() => Promise.resolve(false)), + clearNetDiskToken: vi.fn(() => Promise.resolve()), +})); +vi.mock("@Packages/filesystem/auth", () => ({ + netDiskTypeMap: { "baidu-netdsik": "baidu", onedrive: "onedrive", googledrive: "googledrive", dropbox: "dropbox" }, + HasNetDiskToken: hasNetDiskToken, + ClearNetDiskToken: clearNetDiskToken, +})); + +import FileSystemParams from "./FileSystemParams"; + +afterEach(() => { + cleanup(); + hasNetDiskToken.mockReset(); + hasNetDiskToken.mockResolvedValue(false); + clearNetDiskToken.mockReset(); + clearNetDiskToken.mockResolvedValue(undefined); +}); + +function setup(overrides: Record = {}) { + const onChangeFileSystemType = vi.fn(); + const onChangeFileSystemParams = vi.fn(); + render( + header} + fileSystemType="webdav" + fileSystemParams={{}} + onChangeFileSystemType={onChangeFileSystemType} + onChangeFileSystemParams={onChangeFileSystemParams} + {...(overrides as any)} + /> + ); + return { onChangeFileSystemType, onChangeFileSystemParams }; +} + +describe("文件系统参数表单", () => { + it("WebDAV 默认认证下显示 URL/用户名/密码,隐藏 AccessToken", () => { + setup({ fileSystemType: "webdav", fileSystemParams: {} }); + expect(screen.getByLabelText("url")).toBeInTheDocument(); + expect(screen.getByLabelText("username")).toBeInTheDocument(); + expect(screen.getByLabelText("password")).toBeInTheDocument(); + expect(screen.queryByLabelText("access_token_bearer")).not.toBeInTheDocument(); + }); + + it("认证类型为 token 时显示 AccessToken,隐藏用户名/密码", () => { + setup({ fileSystemType: "webdav", fileSystemParams: { authType: "token" } }); + expect(screen.getByLabelText("access_token_bearer")).toBeInTheDocument(); + expect(screen.queryByLabelText("username")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("password")).not.toBeInTheDocument(); + }); + + it("编辑 URL 输入框时以合并后的参数回调", () => { + const { onChangeFileSystemParams } = setup({ fileSystemType: "webdav", fileSystemParams: { url: "" } }); + fireEvent.change(screen.getByLabelText("url"), { target: { value: "https://dav.example.com" } }); + expect(onChangeFileSystemParams).toHaveBeenCalledWith({ url: "https://dav.example.com" }); + }); + + it("S3 后端渲染其专属字段", () => { + setup({ fileSystemType: "s3", fileSystemParams: {} }); + expect(screen.getByLabelText("s3_bucket_name")).toBeInTheDocument(); + expect(screen.getByLabelText("s3_secret_access_key")).toBeInTheDocument(); + expect(screen.queryByLabelText("url")).not.toBeInTheDocument(); + }); + + it("网盘后端已绑定 token 时显示解绑按钮,确认后清除 token", async () => { + hasNetDiskToken.mockResolvedValue(true); + setup({ fileSystemType: "baidu-netdsik", fileSystemParams: {} }); + const unbind = await screen.findByLabelText("netdisk_unbind"); + fireEvent.click(unbind); + // 弹出确认气泡后点击确认按钮(气泡内最后一个按钮) + await waitFor(() => expect(screen.getAllByRole("button").length).toBeGreaterThan(1)); + const buttons = screen.getAllByRole("button"); + fireEvent.click(buttons[buttons.length - 1]); + await waitFor(() => expect(clearNetDiskToken).toHaveBeenCalledWith("baidu")); + }); + + it("非网盘后端不显示解绑按钮", async () => { + hasNetDiskToken.mockResolvedValue(true); + setup({ fileSystemType: "webdav", fileSystemParams: {} }); + await waitFor(() => expect(screen.getByLabelText("url")).toBeInTheDocument()); + expect(screen.queryByLabelText("netdisk_unbind")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/options/components/FileSystemParams.tsx b/src/pages/options/components/FileSystemParams.tsx new file mode 100644 index 000000000..de90cf657 --- /dev/null +++ b/src/pages/options/components/FileSystemParams.tsx @@ -0,0 +1,144 @@ +import type React from "react"; +import { useEffect, useState } from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@App/pages/components/ui/select"; +import { Input } from "@App/pages/components/ui/input"; +import { Button } from "@App/pages/components/ui/button"; +import { Popconfirm } from "@App/pages/components/ui/popconfirm"; +import FileSystemFactory, { type FileSystemType } from "@Packages/filesystem/factory"; +import { ClearNetDiskToken, HasNetDiskToken, netDiskTypeMap } from "@Packages/filesystem/auth"; +import { t } from "@App/locales/locales"; +import { toast } from "sonner"; + +interface FileSystemParamsProps { + /** 选择器左侧的标题/开关等内容 */ + headerContent: React.ReactNode; + /** 选择器右侧的额外操作(保存/重置等按钮) */ + children?: React.ReactNode; + fileSystemType: FileSystemType; + fileSystemParams: Record; + onChangeFileSystemType: (type: FileSystemType) => void; + onChangeFileSystemParams: (params: Record) => void; +} + +/** + * 文件系统连接参数表单:选择后端(WebDAV/网盘/OneDrive/S3 等),并按后端 schema 动态渲染参数字段。 + * 网盘类后端走 OAuth,已绑定时提供解绑入口。 + */ +export default function FileSystemParams({ + headerContent, + children, + fileSystemType, + fileSystemParams, + onChangeFileSystemType, + onChangeFileSystemParams, +}: FileSystemParamsProps) { + const fsParams = FileSystemFactory.params(); + const [hasBoundToken, setHasBoundToken] = useState(false); + + const netDiskType = netDiskTypeMap[fileSystemType]; + + useEffect(() => { + if (!netDiskType) { + setHasBoundToken(false); + return; + } + HasNetDiskToken(netDiskType).then(setHasBoundToken); + }, [netDiskType]); + + const fileSystemList: { key: FileSystemType; name: string }[] = [ + { key: "webdav", name: "WebDAV" }, + { key: "baidu-netdsik", name: t("settings:baidu_netdisk") }, + { key: "onedrive", name: "OneDrive" }, + { key: "googledrive", name: "Google Drive" }, + { key: "dropbox", name: "Dropbox" }, + { key: "s3", name: "Amazon S3" }, + ]; + + const netDiskName = netDiskType ? fileSystemList.find((item) => item.key === fileSystemType)?.name : null; + const fsParam = fsParams[fileSystemType]; + + const unbind = async () => { + if (!netDiskType) return; + try { + await ClearNetDiskToken(netDiskType); + setHasBoundToken(false); + toast.success(t("settings:netdisk_unbind_success", { provider: netDiskName })); + } catch (error) { + toast.error(`${t("settings:netdisk_unbind_error", { provider: netDiskName })}: ${String(error)}`); + } + }; + + return ( +
+
+ {headerContent} + + {children} + {netDiskType && hasBoundToken && ( + + + + )} +
+ +
+ {Object.keys(fsParam).map((key) => { + const props = fsParam[key]; + const selectAuth = fsParam?.authType?.options?.[0]; // webDAV:默认认证类型 + if (selectAuth && props?.visibilityFor?.includes(fileSystemParams?.authType || selectAuth) === false) { + return null; + } + const setParam = (value: string) => onChangeFileSystemParams({ ...fileSystemParams, [key]: value }); + return ( +
+ {props.title} + {props.type === "select" ? ( + + ) : ( + setParam(e.target.value)} + /> + )} +
+ ); + })} +
+
+ ); +} From a3151f5bb4a9f89ced847d1c6a8226b366abc87f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 14:15:22 +0800 Subject: [PATCH 24/97] =?UTF-8?q?=E2=9C=A8=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=88=86=E5=8C=BA=E5=AE=8C=E6=95=B4=E5=8C=96?= =?UTF-8?q?:FileSystemParams=20=E6=8E=A5=E5=85=A5=20+=20=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E6=97=B6=E8=B4=A6=E5=8F=B7=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/de-DE/settings.json | 4 +- src/locales/de-DE/tools.json | 2 - src/locales/en-US/settings.json | 4 +- src/locales/en-US/tools.json | 2 - src/locales/ja-JP/settings.json | 4 +- src/locales/ja-JP/tools.json | 2 - src/locales/ru-RU/settings.json | 4 +- src/locales/ru-RU/tools.json | 2 - src/locales/vi-VN/settings.json | 4 +- src/locales/vi-VN/tools.json | 2 - src/locales/zh-CN/settings.json | 4 +- src/locales/zh-CN/tools.json | 2 - src/locales/zh-TW/settings.json | 4 +- src/locales/zh-TW/tools.json | 2 - .../Setting/sections/SyncSection.test.tsx | 98 +++++++++++++++++++ .../routes/Setting/sections/SyncSection.tsx | 77 ++++++++++++++- 16 files changed, 195 insertions(+), 22 deletions(-) create mode 100644 src/pages/options/routes/Setting/sections/SyncSection.test.tsx diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index edd5c22f2..e0f63a071 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -113,5 +113,7 @@ "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", "favicon_service_icon-horse": "Icon Horse", - "favicon_service_local": "Lokal abrufen" + "favicon_service_local": "Lokal abrufen", + "cloud_sync_account_verification": "Cloud-Sync-Kontoinformationen werden überprüft...", + "cloud_sync_verification_failed": "Cloud-Sync-Kontoinformationen-Überprüfung fehlgeschlagen" } diff --git a/src/locales/de-DE/tools.json b/src/locales/de-DE/tools.json index 90c85783b..ac2c55a07 100644 --- a/src/locales/de-DE/tools.json +++ b/src/locales/de-DE/tools.json @@ -1,6 +1,4 @@ { - "cloud_sync_account_verification": "Cloud-Sync-Kontoinformationen werden überprüft...", - "cloud_sync_verification_failed": "Cloud-Sync-Kontoinformationen-Überprüfung fehlgeschlagen", "development_tool": "Entwicklungstool", "vscode_url": "VSCode-Adresse", "auto_connect_vscode_service": "Automatisch mit VSCode-Service verbinden", diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index 9e3e6d7e9..f70f4326e 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -113,5 +113,7 @@ "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", "favicon_service_icon-horse": "Icon Horse", - "favicon_service_local": "Local Fetch" + "favicon_service_local": "Local Fetch", + "cloud_sync_account_verification": "Cloud Sync Account Verification in Progress...", + "cloud_sync_verification_failed": "Cloud Sync Account Verification Failed" } diff --git a/src/locales/en-US/tools.json b/src/locales/en-US/tools.json index d28a5c7ef..095002f89 100644 --- a/src/locales/en-US/tools.json +++ b/src/locales/en-US/tools.json @@ -1,6 +1,4 @@ { - "cloud_sync_account_verification": "Cloud Sync Account Verification in Progress...", - "cloud_sync_verification_failed": "Cloud Sync Account Verification Failed", "development_tool": "Development Tool", "vscode_url": "VSCode URL", "auto_connect_vscode_service": "Auto Connect VSCode Service", diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index e05ff39fa..abcc8b92d 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -113,5 +113,7 @@ "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", "favicon_service_icon-horse": "Icon Horse", - "favicon_service_local": "ローカル取得" + "favicon_service_local": "ローカル取得", + "cloud_sync_account_verification": "クラウド同期アカウント情報を確認中...", + "cloud_sync_verification_failed": "クラウド同期アカウント情報の確認に失敗しました" } diff --git a/src/locales/ja-JP/tools.json b/src/locales/ja-JP/tools.json index 8c28c3d5a..bf80b8074 100644 --- a/src/locales/ja-JP/tools.json +++ b/src/locales/ja-JP/tools.json @@ -1,6 +1,4 @@ { - "cloud_sync_account_verification": "クラウド同期アカウント情報を確認中...", - "cloud_sync_verification_failed": "クラウド同期アカウント情報の確認に失敗しました", "development_tool": "開発ツール", "vscode_url": "VSCodeアドレス", "auto_connect_vscode_service": "VSCodeサービスに自動接続", diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index 897618779..739b5a1ca 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -113,5 +113,7 @@ "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", "favicon_service_icon-horse": "Icon Horse", - "favicon_service_local": "Локальное получение" + "favicon_service_local": "Локальное получение", + "cloud_sync_account_verification": "Проверка учетной записи облачной синхронизации...", + "cloud_sync_verification_failed": "Ошибка проверки учетной записи облачной синхронизации" } diff --git a/src/locales/ru-RU/tools.json b/src/locales/ru-RU/tools.json index 94a66dcc1..9350eeec3 100644 --- a/src/locales/ru-RU/tools.json +++ b/src/locales/ru-RU/tools.json @@ -1,6 +1,4 @@ { - "cloud_sync_account_verification": "Проверка учетной записи облачной синхронизации...", - "cloud_sync_verification_failed": "Ошибка проверки учетной записи облачной синхронизации", "development_tool": "Инструмент разработки", "vscode_url": "Адрес VSCode", "auto_connect_vscode_service": "Автоматически подключаться к службе VSCode", diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index c45b083b9..9383c9f70 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -113,5 +113,7 @@ "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", "favicon_service_icon-horse": "Icon Horse", - "favicon_service_local": "Lấy cục bộ" + "favicon_service_local": "Lấy cục bộ", + "cloud_sync_account_verification": "Đang xác minh tài khoản đồng bộ đám mây...", + "cloud_sync_verification_failed": "Xác minh tài khoản đồng bộ đám mây thất bại" } diff --git a/src/locales/vi-VN/tools.json b/src/locales/vi-VN/tools.json index 87eb8fc9c..4d85126da 100644 --- a/src/locales/vi-VN/tools.json +++ b/src/locales/vi-VN/tools.json @@ -1,6 +1,4 @@ { - "cloud_sync_account_verification": "Đang xác minh tài khoản đồng bộ đám mây...", - "cloud_sync_verification_failed": "Xác minh tài khoản đồng bộ đám mây thất bại", "development_tool": "Công cụ phát triển", "vscode_url": "Url vscode", "auto_connect_vscode_service": "Tự động kết nối dịch vụ vscode", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index 03bb42306..4b2fd97fe 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -113,5 +113,7 @@ "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", "favicon_service_icon-horse": "Icon Horse", - "favicon_service_local": "本地获取" + "favicon_service_local": "本地获取", + "cloud_sync_account_verification": "云同步账号信息验证中...", + "cloud_sync_verification_failed": "云同步账号信息验证失败" } diff --git a/src/locales/zh-CN/tools.json b/src/locales/zh-CN/tools.json index e169f344f..813080113 100644 --- a/src/locales/zh-CN/tools.json +++ b/src/locales/zh-CN/tools.json @@ -1,6 +1,4 @@ { - "cloud_sync_account_verification": "云同步账号信息验证中...", - "cloud_sync_verification_failed": "云同步账号信息验证失败", "development_tool": "开发工具", "vscode_url": "VSCode地址", "auto_connect_vscode_service": "自动连接vscode服务", diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index 2e09ebbf1..afc981396 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -113,5 +113,7 @@ "favicon_service_google": "Google", "favicon_service_duckduckgo": "DuckDuckGo", "favicon_service_icon-horse": "Icon Horse", - "favicon_service_local": "本地取得" + "favicon_service_local": "本地取得", + "cloud_sync_account_verification": "雲端同步帳號資訊驗證中...", + "cloud_sync_verification_failed": "雲端同步帳號資訊驗證失敗" } diff --git a/src/locales/zh-TW/tools.json b/src/locales/zh-TW/tools.json index c01945deb..5c42f4cbc 100644 --- a/src/locales/zh-TW/tools.json +++ b/src/locales/zh-TW/tools.json @@ -1,6 +1,4 @@ { - "cloud_sync_account_verification": "雲端同步帳號資訊驗證中...", - "cloud_sync_verification_failed": "雲端同步帳號資訊驗證失敗", "development_tool": "開發工具", "vscode_url": "VSCode網址", "auto_connect_vscode_service": "自動連接VSCode服務", diff --git a/src/pages/options/routes/Setting/sections/SyncSection.test.tsx b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx new file mode 100644 index 000000000..55517cadf --- /dev/null +++ b/src/pages/options/routes/Setting/sections/SyncSection.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; + +const { create } = vi.hoisted(() => ({ create: vi.fn(() => Promise.resolve({})) })); +vi.mock("@Packages/filesystem/factory", () => ({ + default: { + create, + params: () => ({ + webdav: { + authType: { title: "auth_type", type: "select", options: ["password", "digest", "none", "token"] }, + url: { title: "url" }, + username: { title: "username", visibilityFor: ["password", "digest"] }, + password: { title: "password", type: "password", visibilityFor: ["password", "digest"] }, + accessToken: { title: "access_token_bearer", visibilityFor: ["token"] }, + }, + "baidu-netdsik": {}, + onedrive: {}, + googledrive: {}, + dropbox: {}, + s3: {}, + }), + }, +})); +vi.mock("@Packages/filesystem/auth", () => ({ + netDiskTypeMap: { "baidu-netdsik": "baidu", onedrive: "onedrive", googledrive: "googledrive", dropbox: "dropbox" }, + HasNetDiskToken: vi.fn(() => Promise.resolve(false)), + ClearNetDiskToken: vi.fn(() => Promise.resolve()), +})); + +const { get, set } = vi.hoisted(() => ({ get: vi.fn(), set: vi.fn() })); +vi.mock("@App/pages/store/global", () => ({ + systemConfig: { get, set }, + subscribeMessage: () => () => {}, +})); + +import { SyncSection } from "./SyncSection"; + +function mockCloudSync(over: Record = {}) { + get.mockImplementation((key: string) => { + if (key === "cloud_sync") + return Promise.resolve({ + enable: false, + syncDelete: false, + syncStatus: true, + filesystem: "webdav", + params: {}, + ...over, + }); + return Promise.resolve(""); + }); +} + +afterEach(() => { + cleanup(); + get.mockReset(); + set.mockReset(); + create.mockReset(); + create.mockResolvedValue({}); +}); + +describe("同步分区", () => { + it("未启用同步时保存直接写入配置且不做账号校验", async () => { + mockCloudSync({ enable: false }); + render( () => {}} />); + const save = await screen.findByLabelText("cloud_sync_save"); + fireEvent.click(save); + await waitFor(() => expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ enable: false }))); + expect(create).not.toHaveBeenCalled(); + }); + + it("启用同步时保存先校验账号再写入配置", async () => { + mockCloudSync({ enable: true, filesystem: "webdav", params: { webdav: { url: "https://dav" } } }); + render( () => {}} />); + const save = await screen.findByLabelText("cloud_sync_save"); + fireEvent.click(save); + await waitFor(() => expect(create).toHaveBeenCalledWith("webdav", { url: "https://dav" })); + await waitFor(() => expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ enable: true }))); + }); + + it("校验失败时不写入配置", async () => { + create.mockRejectedValue(new Error("bad credentials")); + mockCloudSync({ enable: true, params: { webdav: { url: "https://dav" } } }); + render( () => {}} />); + const save = await screen.findByLabelText("cloud_sync_save"); + fireEvent.click(save); + await waitFor(() => expect(create).toHaveBeenCalled()); + expect(set).not.toHaveBeenCalled(); + }); + + it("切换同步删除复选框后保存写入新值", async () => { + mockCloudSync({ enable: false, syncDelete: false }); + render( () => {}} />); + const cb = await screen.findByLabelText("cloud_sync_sync_delete"); + fireEvent.click(cb); + fireEvent.click(screen.getByLabelText("cloud_sync_save")); + await waitFor(() => expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ syncDelete: true }))); + }); +}); diff --git a/src/pages/options/routes/Setting/sections/SyncSection.tsx b/src/pages/options/routes/Setting/sections/SyncSection.tsx index 180e48250..c799d6377 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.tsx @@ -1,7 +1,39 @@ +import { useEffect, useState } from "react"; import { SettingCard } from "../../../components/SettingCard"; +import FileSystemParams from "../../../components/FileSystemParams"; +import { Checkbox } from "@App/pages/components/ui/checkbox"; +import { Button } from "@App/pages/components/ui/button"; +import { systemConfig } from "@App/pages/store/global"; +import FileSystemFactory from "@Packages/filesystem/factory"; import { t } from "@App/locales/locales"; +import { toast } from "sonner"; +import type { CloudSyncConfig } from "@App/pkg/config/config"; export function SyncSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { + const [draft, setDraft] = useState(undefined); + + useEffect(() => { + Promise.resolve(systemConfig.get("cloud_sync")).then((v) => setDraft(v as CloudSyncConfig)); + }, []); + + const patch = (next: Partial) => setDraft((d) => (d ? { ...d, ...next } : d)); + + const save = async () => { + if (!draft) return; + // 启用同步时先校验账号连通性 + if (draft.enable) { + toast.info(t("settings:cloud_sync_account_verification")); + try { + await FileSystemFactory.create(draft.filesystem, draft.params[draft.filesystem]); + } catch (e) { + toast.error(`${t("settings:cloud_sync_verification_failed")}: ${e instanceof Error ? e.message : String(e)}`); + return; + } + } + systemConfig.set("cloud_sync", draft); + toast.success(t("save_success")); + }; + return ( (el: HTMLE description={t("settings:enable_script_sync_to")} register={register} > -

云端同步配置开发中

+ {draft && ( +
+
+ +

{t("settings:sync_delete_desc")}

+ +
+ + + patch({ enable: c === true })} + /> + {t("settings:enable_script_sync_to")} + + } + fileSystemType={draft.filesystem} + fileSystemParams={draft.params[draft.filesystem] || {}} + onChangeFileSystemType={(type) => patch({ filesystem: type })} + onChangeFileSystemParams={(params) => patch({ params: { ...draft.params, [draft.filesystem]: params } })} + > + + +
+ )}
); } From f5c43a3537358d5f834cce5184a382ac20466a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 14:20:33 +0800 Subject: [PATCH 25/97] =?UTF-8?q?=E2=9C=A8=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=AD=98=E5=82=A8=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E5=8C=96:CAT=5FfileStorage=20=E9=85=8D=E7=BD=AE(=E4=BF=9D?= =?UTF-8?q?=E5=AD=98/=E9=87=8D=E7=BD=AE/=E6=89=93=E5=BC=80=E7=9B=AE?= =?UTF-8?q?=E5=BD=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../options/routes/Setting/index.test.tsx | 10 +- .../Setting/sections/RuntimeSection.test.tsx | 98 ++++++++++++++ .../Setting/sections/RuntimeSection.tsx | 126 +++++++++++++++--- 3 files changed, 213 insertions(+), 21 deletions(-) create mode 100644 src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx diff --git a/src/pages/options/routes/Setting/index.test.tsx b/src/pages/options/routes/Setting/index.test.tsx index 3f19e4796..e633150cf 100644 --- a/src/pages/options/routes/Setting/index.test.tsx +++ b/src/pages/options/routes/Setting/index.test.tsx @@ -2,7 +2,15 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { render, screen, waitFor, cleanup } from "@testing-library/react"; import { initLanguage } from "@App/locales/locales"; -const { get, set } = vi.hoisted(() => ({ get: vi.fn(() => Promise.resolve("scriptcat")), set: vi.fn() })); +const { get, set } = vi.hoisted(() => ({ + get: vi.fn((key: string) => { + if (key === "cloud_sync") + return Promise.resolve({ enable: false, syncDelete: false, syncStatus: true, filesystem: "webdav", params: {} }); + if (key === "cat_file_storage") return Promise.resolve({ status: "unset", filesystem: "webdav", params: {} }); + return Promise.resolve("scriptcat"); + }), + set: vi.fn(), +})); vi.mock("@App/pages/store/global", () => ({ systemConfig: { get, set }, subscribeMessage: () => () => {} })); import Setting from "./index"; diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx new file mode 100644 index 000000000..973a638aa --- /dev/null +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; + +const { create } = vi.hoisted(() => ({ + create: vi.fn(() => Promise.resolve({ openDir: vi.fn(() => Promise.resolve({ getDirUrl: vi.fn(() => Promise.resolve("https://dir")) })) })), +})); +vi.mock("@Packages/filesystem/factory", () => ({ + default: { + create, + params: () => ({ + webdav: { + authType: { title: "auth_type", type: "select", options: ["password", "digest", "none", "token"] }, + url: { title: "url" }, + username: { title: "username", visibilityFor: ["password", "digest"] }, + password: { title: "password", type: "password", visibilityFor: ["password", "digest"] }, + accessToken: { title: "access_token_bearer", visibilityFor: ["token"] }, + }, + "baidu-netdsik": {}, + onedrive: {}, + googledrive: {}, + dropbox: {}, + s3: {}, + }), + }, +})); +vi.mock("@Packages/filesystem/auth", () => ({ + netDiskTypeMap: { "baidu-netdsik": "baidu", onedrive: "onedrive", googledrive: "googledrive", dropbox: "dropbox" }, + HasNetDiskToken: vi.fn(() => Promise.resolve(false)), + ClearNetDiskToken: vi.fn(() => Promise.resolve()), +})); + +const { get, set } = vi.hoisted(() => ({ get: vi.fn(), set: vi.fn() })); +vi.mock("@App/pages/store/global", () => ({ + systemConfig: { get, set }, + subscribeMessage: () => () => {}, +})); + +// 后台权限检测在挂载时调用,固定返回 false 以免干扰存储配置测试 +vi.mock("@App/pkg/utils/utils", async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, isPermissionOk: vi.fn(() => Promise.resolve(false)) }; +}); + +import { RuntimeSection } from "./RuntimeSection"; + +function mockStorage(over: Record = {}) { + get.mockImplementation((key: string) => { + if (key === "cat_file_storage") + return Promise.resolve({ status: "unset", filesystem: "webdav", params: {}, ...over }); + return Promise.resolve(""); + }); +} + +afterEach(() => { + cleanup(); + get.mockReset(); + set.mockReset(); + create.mockReset(); + create.mockResolvedValue({ + openDir: vi.fn(() => Promise.resolve({ getDirUrl: vi.fn(() => Promise.resolve("https://dir")) })), + }); +}); + +describe("运行时分区-存储配置", () => { + it("保存存储配置时校验账号成功后写入 success 状态", async () => { + mockStorage({ status: "unset", filesystem: "webdav", params: { webdav: { url: "https://dav" } } }); + render( () => {}} />); + const save = await screen.findByLabelText("cat_storage_save"); + fireEvent.click(save); + await waitFor(() => expect(create).toHaveBeenCalledWith("webdav", { url: "https://dav" })); + await waitFor(() => + expect(set).toHaveBeenCalledWith( + "cat_file_storage", + expect.objectContaining({ status: "success", filesystem: "webdav" }) + ) + ); + }); + + it("校验失败时不写入存储配置", async () => { + create.mockRejectedValue(new Error("bad")); + mockStorage({ status: "unset", params: { webdav: { url: "https://dav" } } }); + render( () => {}} />); + const save = await screen.findByLabelText("cat_storage_save"); + fireEvent.click(save); + await waitFor(() => expect(create).toHaveBeenCalled()); + expect(set).not.toHaveBeenCalled(); + }); + + it("重置存储配置写入 unset 默认值", async () => { + mockStorage({ status: "success", filesystem: "webdav", params: { webdav: { url: "https://dav" } } }); + render( () => {}} />); + const reset = await screen.findByLabelText("cat_storage_reset"); + fireEvent.click(reset); + await waitFor(() => + expect(set).toHaveBeenCalledWith("cat_file_storage", { status: "unset", filesystem: "webdav", params: {} }) + ); + }); +}); diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx index 2c826cf00..dc35ae781 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx @@ -2,20 +2,28 @@ import { useEffect, useState } from "react"; import { SettingCard } from "../../../components/SettingCard"; import { SettingRow } from "../../../components/SettingRow"; import { Switch } from "@App/pages/components/ui/switch"; -import { useSystemConfig } from "../../../hooks/useSystemConfig"; -import { isPermissionOk } from "@App/pkg/utils/utils"; +import { Button } from "@App/pages/components/ui/button"; +import FileSystemParams from "../../../components/FileSystemParams"; +import { systemConfig } from "@App/pages/store/global"; +import FileSystemFactory from "@Packages/filesystem/factory"; +import { isPermissionOk, isFirefox } from "@App/pkg/utils/utils"; import { t } from "@App/locales/locales"; import { toast } from "sonner"; import type { CATFileStorage } from "@App/pkg/config/config"; +const STORAGE_EXAMPLE_URL = "https://github.com/scriptscat/scriptcat/blob/main/example/cat_file_storage.js"; + export function RuntimeSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { const [bg, setBg] = useState(false); - const [storage] = useSystemConfig("cat_file_storage"); + const [storage, setStorage] = useState(undefined); useEffect(() => { - isPermissionOk("background").then((r) => { - if (r !== null) setBg(r); - }); + if (!isFirefox()) { + isPermissionOk("background").then((r) => { + if (r !== null) setBg(r); + }); + } + Promise.resolve(systemConfig.get("cat_file_storage")).then((v) => setStorage(v as CATFileStorage)); }, []); const toggleBg = (enable: boolean) => { @@ -33,31 +41,109 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT toast.error(t("settings:enable_background.disable_failed")!); return; } - if (removed) setBg(false); + if (removed) { + setBg(false); + } else { + isPermissionOk("background").then((r) => { + if (r !== null) setBg(r); + }); + } }); } }; - const storageData = storage as CATFileStorage | undefined; - const storageStatus = storageData?.status ?? "unset"; const storageStatusLabel = - storageStatus === "success" + storage?.status === "success" ? t("editor:in_use") - : storageStatus === "error" + : storage?.status === "error" ? t("editor:storage_error") : t("editor:not_set"); + const saveStorage = async () => { + if (!storage) return; + try { + await FileSystemFactory.create(storage.filesystem, storage.params[storage.filesystem]); + } catch (e) { + toast.error(`${t("editor:account_validation_failed")}: ${e instanceof Error ? e.message : String(e)}`); + return; + } + const next: CATFileStorage = { ...storage, status: "success" }; + setStorage(next); + systemConfig.set("cat_file_storage", next); + toast.success(t("save_success")); + }; + + const resetStorage = () => { + const next: CATFileStorage = { status: "unset", filesystem: "webdav", params: {} }; + setStorage(next); + systemConfig.set("cat_file_storage", next); + }; + + const openDirectory = async () => { + if (!storage) return; + try { + let fs = await FileSystemFactory.create(storage.filesystem, storage.params[storage.filesystem]); + fs = await fs.openDir("ScriptCat/app"); + window.open(await fs.getDirUrl(), "_blank"); + } catch (e) { + toast.error(`${t("editor:account_validation_failed")}: ${e instanceof Error ? e.message : String(e)}`); + } + }; + return ( - - - - - {storageStatusLabel} - + {!isFirefox() && ( + + + + )} + + {storage && ( +
+
{t("editor:storage_api")}
+ + {t("editor:settings")}{" "} + + CAT_fileStorage + {" "} + {t("editor:use_file_system")} + + } + fileSystemType={storage.filesystem} + fileSystemParams={storage.params[storage.filesystem] || {}} + onChangeFileSystemType={(type) => setStorage((s) => (s ? { ...s, filesystem: type } : s))} + onChangeFileSystemParams={(params) => + setStorage((s) => (s ? { ...s, params: { ...s.params, [s.filesystem]: params } } : s)) + } + > + + + + + + {storageStatusLabel} + +
+ )}
); } From b6cf1df5cd34f487e73f235df1adb0a273347520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 14:30:09 +0800 Subject: [PATCH 26/97] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E5=90=8C=E6=AD=A5/?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=88=86=E5=8C=BA=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=E7=8A=B6=E6=80=81=E8=89=B2=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20success-fg=20=E4=BB=A4=E7=89=8C=E3=80=81=E5=8E=BB?= =?UTF-8?q?=E9=87=8D=E6=8F=8F=E8=BF=B0=E3=80=81=E8=A1=A5=E6=89=93=E5=BC=80?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=B8=8E=E5=90=8C=E6=AD=A5=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Setting/sections/RuntimeSection.test.tsx | 15 +++++++++++++++ .../routes/Setting/sections/RuntimeSection.tsx | 2 +- .../routes/Setting/sections/SyncSection.test.tsx | 9 +++++++++ .../routes/Setting/sections/SyncSection.tsx | 7 +------ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx index 973a638aa..fa5e827c0 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx @@ -95,4 +95,19 @@ describe("运行时分区-存储配置", () => { expect(set).toHaveBeenCalledWith("cat_file_storage", { status: "unset", filesystem: "webdav", params: {} }) ); }); + + it("打开目录时校验账号并打开返回的目录地址", async () => { + const getDirUrl = vi.fn(() => Promise.resolve("https://dir/scriptcat")); + const openDir = vi.fn(() => Promise.resolve({ getDirUrl })); + create.mockResolvedValue({ openDir }); + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + mockStorage({ status: "success", filesystem: "webdav", params: { webdav: { url: "https://dav" } } }); + render( () => {}} />); + const open = await screen.findByLabelText("cat_storage_open"); + fireEvent.click(open); + await waitFor(() => expect(create).toHaveBeenCalledWith("webdav", { url: "https://dav" })); + await waitFor(() => expect(openDir).toHaveBeenCalledWith("ScriptCat/app")); + await waitFor(() => expect(openSpy).toHaveBeenCalledWith("https://dir/scriptcat", "_blank")); + openSpy.mockRestore(); + }); }); diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx index dc35ae781..b888b0d0f 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.tsx @@ -134,7 +134,7 @@ export function RuntimeSection({ register }: { register: (id: string) => (el: HT { fireEvent.click(screen.getByLabelText("cloud_sync_save")); await waitFor(() => expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ syncDelete: true }))); }); + + it("切换同步状态复选框后保存写入新值", async () => { + mockCloudSync({ enable: false, syncStatus: true }); + render( () => {}} />); + const cb = await screen.findByLabelText("cloud_sync_sync_status"); + fireEvent.click(cb); + fireEvent.click(screen.getByLabelText("cloud_sync_save")); + await waitFor(() => expect(set).toHaveBeenCalledWith("cloud_sync", expect.objectContaining({ syncStatus: false }))); + }); }); diff --git a/src/pages/options/routes/Setting/sections/SyncSection.tsx b/src/pages/options/routes/Setting/sections/SyncSection.tsx index c799d6377..ad8ae5009 100644 --- a/src/pages/options/routes/Setting/sections/SyncSection.tsx +++ b/src/pages/options/routes/Setting/sections/SyncSection.tsx @@ -35,12 +35,7 @@ export function SyncSection({ register }: { register: (id: string) => (el: HTMLE }; return ( - + {draft && (
From 35888cd1f746cced3bc6598def746389e00094cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 14:48:22 +0800 Subject: [PATCH 27/97] =?UTF-8?q?=E2=9C=A8=20=E5=B7=A5=E5=85=B7=E9=A1=B5?= =?UTF-8?q?=20new-ui:5=20=E5=88=86=E7=B1=BB=20scroll-spy(=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0/=E4=BA=91=E7=AB=AF=E5=A4=87=E4=BB=BD+=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=A4=87=E4=BB=BD+=E6=95=B0=E6=8D=AE=E8=BF=81?= =?UTF-8?q?=E7=A7=BB+VSCode)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/de-DE/tools.json | 6 +- src/locales/en-US/tools.json | 6 +- src/locales/ja-JP/tools.json | 6 +- src/locales/ru-RU/tools.json | 6 +- src/locales/vi-VN/tools.json | 6 +- src/locales/zh-CN/tools.json | 6 +- src/locales/zh-TW/tools.json | 6 +- src/pages/options/routes/Tools/categories.ts | 11 ++ src/pages/options/routes/Tools/index.test.tsx | 52 ++++++ src/pages/options/routes/Tools/index.tsx | 24 +++ .../options/routes/Tools/openImportWindow.ts | 15 ++ .../Tools/sections/AutoBackupSection.tsx | 19 ++ .../sections/CloudBackupSection.test.tsx | 82 ++++++++ .../Tools/sections/CloudBackupSection.tsx | 175 ++++++++++++++++++ .../Tools/sections/DevToolsSection.test.tsx | 40 ++++ .../routes/Tools/sections/DevToolsSection.tsx | 61 ++++++ .../sections/LocalBackupSection.test.tsx | 34 ++++ .../Tools/sections/LocalBackupSection.tsx | 50 +++++ .../Tools/sections/MigrationSection.test.tsx | 23 +++ .../Tools/sections/MigrationSection.tsx | 29 +++ 20 files changed, 650 insertions(+), 7 deletions(-) create mode 100644 src/pages/options/routes/Tools/categories.ts create mode 100644 src/pages/options/routes/Tools/index.test.tsx create mode 100644 src/pages/options/routes/Tools/index.tsx create mode 100644 src/pages/options/routes/Tools/openImportWindow.ts create mode 100644 src/pages/options/routes/Tools/sections/AutoBackupSection.tsx create mode 100644 src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx create mode 100644 src/pages/options/routes/Tools/sections/CloudBackupSection.tsx create mode 100644 src/pages/options/routes/Tools/sections/DevToolsSection.test.tsx create mode 100644 src/pages/options/routes/Tools/sections/DevToolsSection.tsx create mode 100644 src/pages/options/routes/Tools/sections/LocalBackupSection.test.tsx create mode 100644 src/pages/options/routes/Tools/sections/LocalBackupSection.tsx create mode 100644 src/pages/options/routes/Tools/sections/MigrationSection.test.tsx create mode 100644 src/pages/options/routes/Tools/sections/MigrationSection.tsx diff --git a/src/locales/de-DE/tools.json b/src/locales/de-DE/tools.json index ac2c55a07..f2950bb29 100644 --- a/src/locales/de-DE/tools.json +++ b/src/locales/de-DE/tools.json @@ -9,5 +9,9 @@ "import_error": "Import-Fehler", "pulling_data_from_cloud": "Daten werden aus der Cloud abgerufen", "pull_failed": "Abrufen fehlgeschlagen", - "restore": "Wiederherstellen" + "restore": "Wiederherstellen", + "local_backup": "Lokale Sicherung", + "cloud_backup": "Cloud-Sicherung", + "auto_backup": "Automatische Sicherung", + "data_migration": "Datenmigration" } diff --git a/src/locales/en-US/tools.json b/src/locales/en-US/tools.json index 095002f89..8af838a02 100644 --- a/src/locales/en-US/tools.json +++ b/src/locales/en-US/tools.json @@ -9,5 +9,9 @@ "import_error": "Import Error", "pulling_data_from_cloud": "Pulling Data from Cloud", "pull_failed": "Pull Failed", - "restore": "Restore" + "restore": "Restore", + "local_backup": "Local Backup", + "cloud_backup": "Cloud Backup", + "auto_backup": "Auto Backup", + "data_migration": "Data Migration" } diff --git a/src/locales/ja-JP/tools.json b/src/locales/ja-JP/tools.json index bf80b8074..c860d43d8 100644 --- a/src/locales/ja-JP/tools.json +++ b/src/locales/ja-JP/tools.json @@ -9,5 +9,9 @@ "import_error": "インポートエラー", "pulling_data_from_cloud": "クラウドからデータを取得中", "pull_failed": "取得に失敗しました", - "restore": "復元" + "restore": "復元", + "local_backup": "ローカルバックアップ", + "cloud_backup": "クラウドバックアップ", + "auto_backup": "自動バックアップ", + "data_migration": "データ移行" } diff --git a/src/locales/ru-RU/tools.json b/src/locales/ru-RU/tools.json index 9350eeec3..35fae0887 100644 --- a/src/locales/ru-RU/tools.json +++ b/src/locales/ru-RU/tools.json @@ -9,5 +9,9 @@ "import_error": "Ошибка импорта", "pulling_data_from_cloud": "Получение данных из облака", "pull_failed": "Ошибка получения", - "restore": "Восстановить" + "restore": "Восстановить", + "local_backup": "Локальная резервная копия", + "cloud_backup": "Облачная резервная копия", + "auto_backup": "Автоматическая резервная копия", + "data_migration": "Миграция данных" } diff --git a/src/locales/vi-VN/tools.json b/src/locales/vi-VN/tools.json index 4d85126da..062201890 100644 --- a/src/locales/vi-VN/tools.json +++ b/src/locales/vi-VN/tools.json @@ -9,5 +9,9 @@ "import_error": "Lỗi nhập", "pulling_data_from_cloud": "Đang tải dữ liệu từ đám mây", "pull_failed": "Tải thất bại", - "restore": "Khôi phục" + "restore": "Khôi phục", + "local_backup": "Sao lưu cục bộ", + "cloud_backup": "Sao lưu đám mây", + "auto_backup": "Sao lưu tự động", + "data_migration": "Di chuyển dữ liệu" } diff --git a/src/locales/zh-CN/tools.json b/src/locales/zh-CN/tools.json index 813080113..cb4a3ba4f 100644 --- a/src/locales/zh-CN/tools.json +++ b/src/locales/zh-CN/tools.json @@ -9,5 +9,9 @@ "import_error": "导入错误", "pulling_data_from_cloud": "正在从云端拉取数据", "pull_failed": "拉取失败", - "restore": "恢复" + "restore": "恢复", + "local_backup": "本地备份", + "cloud_backup": "云端备份", + "auto_backup": "自动备份", + "data_migration": "数据迁移" } diff --git a/src/locales/zh-TW/tools.json b/src/locales/zh-TW/tools.json index 5c42f4cbc..dfe274e42 100644 --- a/src/locales/zh-TW/tools.json +++ b/src/locales/zh-TW/tools.json @@ -9,5 +9,9 @@ "import_error": "匯入錯誤", "pulling_data_from_cloud": "正在從雲端拉取資料", "pull_failed": "拉取失敗", - "restore": "還原" + "restore": "還原", + "local_backup": "本機備份", + "cloud_backup": "雲端備份", + "auto_backup": "自動備份", + "data_migration": "資料遷移" } diff --git a/src/pages/options/routes/Tools/categories.ts b/src/pages/options/routes/Tools/categories.ts new file mode 100644 index 000000000..fdc3bcb9c --- /dev/null +++ b/src/pages/options/routes/Tools/categories.ts @@ -0,0 +1,11 @@ +import { HardDrive, Cloud, CalendarClock, Database, Terminal } from "lucide-react"; +import { t } from "@App/locales/locales"; +import type { SettingsCategory } from "../../layout/SettingsLayout"; + +export const TOOLS_CATEGORIES: SettingsCategory[] = [ + { id: "local-backup", icon: HardDrive, label: t("tools:local_backup") }, + { id: "cloud-backup", icon: Cloud, label: t("tools:cloud_backup") }, + { id: "auto-backup", icon: CalendarClock, label: t("tools:auto_backup") }, + { id: "data-migration", icon: Database, label: t("tools:data_migration") }, + { id: "dev-tools", icon: Terminal, label: t("tools:development_tool") }, +]; diff --git a/src/pages/options/routes/Tools/index.test.tsx b/src/pages/options/routes/Tools/index.test.tsx new file mode 100644 index 000000000..c5452e4db --- /dev/null +++ b/src/pages/options/routes/Tools/index.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, waitFor, cleanup } from "@testing-library/react"; +import { initLanguage } from "@App/locales/locales"; + +const { get, set } = vi.hoisted(() => ({ + get: vi.fn((key: string) => { + if (key === "backup") return Promise.resolve({ filesystem: "webdav", params: {} }); + if (key === "vscode_url") return Promise.resolve("ws://localhost:8642"); + if (key === "vscode_reconnect") return Promise.resolve(false); + return Promise.resolve(""); + }), + set: vi.fn(), +})); +vi.mock("@App/pages/store/global", () => ({ + systemConfig: { get, set }, + message: {}, + subscribeMessage: () => () => {}, +})); +vi.mock("@App/pages/store/features/script", () => ({ synchronizeClient: { export: vi.fn(), backupToCloud: vi.fn() } })); +vi.mock("@App/app/migrate", () => ({ migrateToChromeStorage: vi.fn() })); +vi.mock("@App/app/service/service_worker/client", () => ({ SystemClient: vi.fn() })); +vi.mock("@Packages/filesystem/factory", () => ({ + default: { create: vi.fn(), params: () => ({ webdav: { url: { title: "url" } }, "baidu-netdsik": {}, onedrive: {}, googledrive: {}, dropbox: {}, s3: {} }) }, +})); +vi.mock("@Packages/filesystem/auth", () => ({ + netDiskTypeMap: {}, + HasNetDiskToken: vi.fn(() => Promise.resolve(false)), + ClearNetDiskToken: vi.fn(() => Promise.resolve()), +})); + +import Tools from "./index"; + +beforeEach(() => { + initLanguage("en-US"); + // @ts-expect-error test stub + globalThis.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + }; +}); +afterEach(cleanup); + +describe("工具页", () => { + it("渲染 5 个分类导航项", async () => { + render(); + await waitFor(() => expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(5)); + }); +}); diff --git a/src/pages/options/routes/Tools/index.tsx b/src/pages/options/routes/Tools/index.tsx new file mode 100644 index 000000000..29aaa8447 --- /dev/null +++ b/src/pages/options/routes/Tools/index.tsx @@ -0,0 +1,24 @@ +import { SettingsLayout } from "../../layout/SettingsLayout"; +import { TOOLS_CATEGORIES } from "./categories"; +import { LocalBackupSection } from "./sections/LocalBackupSection"; +import { CloudBackupSection } from "./sections/CloudBackupSection"; +import { AutoBackupSection } from "./sections/AutoBackupSection"; +import { MigrationSection } from "./sections/MigrationSection"; +import { DevToolsSection } from "./sections/DevToolsSection"; +import { t } from "@App/locales/locales"; + +export default function Tools() { + return ( + + {(register) => ( + <> + + + + + + + )} + + ); +} diff --git a/src/pages/options/routes/Tools/openImportWindow.ts b/src/pages/options/routes/Tools/openImportWindow.ts new file mode 100644 index 000000000..bf73b318d --- /dev/null +++ b/src/pages/options/routes/Tools/openImportWindow.ts @@ -0,0 +1,15 @@ +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { cacheInstance } from "@App/app/cache"; +import { CACHE_KEY_IMPORT_FILE } from "@App/app/cache_key"; +import { makeBlobURL, openInCurrentTab } from "@App/pkg/utils/utils"; + +/** + * 打开导入窗口:通过 cache 传递文件数据(经扩展 API,兼容 Edge Android)。 + */ +export async function openImportWindow(filename: string, file: Blob) { + const url = makeBlobURL({ blob: file, persistence: true }) as string; + const uuid = uuidv4(); + const cacheKey = `${CACHE_KEY_IMPORT_FILE}${uuid}`; + await cacheInstance.set(cacheKey, { filename, url }); + await openInCurrentTab(`/src/import.html?uuid=${uuid}`); +} diff --git a/src/pages/options/routes/Tools/sections/AutoBackupSection.tsx b/src/pages/options/routes/Tools/sections/AutoBackupSection.tsx new file mode 100644 index 000000000..a8e378479 --- /dev/null +++ b/src/pages/options/routes/Tools/sections/AutoBackupSection.tsx @@ -0,0 +1,19 @@ +import { CalendarClock } from "lucide-react"; +import { SettingCard } from "../../../components/SettingCard"; +import { t } from "@App/locales/locales"; + +export function AutoBackupSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { + return ( + +
+ + {t("settings:under_construction")} +
+
+ ); +} diff --git a/src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx b/src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx new file mode 100644 index 000000000..73a194dcc --- /dev/null +++ b/src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; + +const { create, backupToCloud } = vi.hoisted(() => ({ + create: vi.fn(), + backupToCloud: vi.fn(() => Promise.resolve()), +})); +vi.mock("@Packages/filesystem/factory", () => ({ + default: { + create, + params: () => ({ + webdav: { url: { title: "url" } }, + "baidu-netdsik": {}, + onedrive: {}, + googledrive: {}, + dropbox: {}, + s3: {}, + }), + }, +})); +vi.mock("@Packages/filesystem/auth", () => ({ + netDiskTypeMap: { "baidu-netdsik": "baidu", onedrive: "onedrive", googledrive: "googledrive", dropbox: "dropbox" }, + HasNetDiskToken: vi.fn(() => Promise.resolve(false)), + ClearNetDiskToken: vi.fn(() => Promise.resolve()), +})); +vi.mock("@App/pages/store/features/script", () => ({ synchronizeClient: { backupToCloud } })); +vi.mock("../openImportWindow", () => ({ openImportWindow: vi.fn(() => Promise.resolve()) })); + +const { get, set } = vi.hoisted(() => ({ get: vi.fn(), set: vi.fn() })); +vi.mock("@App/pages/store/global", () => ({ systemConfig: { get, set }, subscribeMessage: () => () => {} })); + +import { CloudBackupSection } from "./CloudBackupSection"; + +function mockBackup(over: Record = {}) { + get.mockImplementation((key: string) => { + if (key === "backup") return Promise.resolve({ filesystem: "webdav", params: { webdav: { url: "https://dav" } }, ...over }); + return Promise.resolve(""); + }); +} + +afterEach(() => { + cleanup(); + get.mockReset(); + set.mockReset(); + create.mockReset(); + backupToCloud.mockReset(); + backupToCloud.mockResolvedValue(undefined); +}); + +describe("云端备份分区", () => { + it("点击备份写入配置并上传云端", async () => { + mockBackup(); + render( () => {}} />); + const btn = await screen.findByLabelText("tools_backup"); + fireEvent.click(btn); + expect(set).toHaveBeenCalledWith("backup", expect.objectContaining({ filesystem: "webdav" })); + await waitFor(() => expect(backupToCloud).toHaveBeenCalledWith("webdav", { url: "https://dav" })); + }); + + it("点击备份列表拉取 zip 文件并展示", async () => { + const list = vi.fn(() => + Promise.resolve([ + { name: "a.zip", updatetime: 2000 }, + { name: "notes.txt", updatetime: 3000 }, + { name: "b.zip", updatetime: 1000 }, + ]) + ); + const fs2 = { list }; + const fs1 = { openDir: vi.fn(() => Promise.resolve(fs2)) }; + create.mockResolvedValue(fs1); + mockBackup(); + render( () => {}} />); + const btn = await screen.findByLabelText("tools_backup_list"); + fireEvent.click(btn); + await waitFor(() => expect(create).toHaveBeenCalledWith("webdav", { url: "https://dav" })); + await waitFor(() => expect(fs1.openDir).toHaveBeenCalledWith("ScriptCat")); + // 只展示 .zip,过滤掉 txt + expect(await screen.findByText("a.zip")).toBeInTheDocument(); + expect(screen.getByText("b.zip")).toBeInTheDocument(); + expect(screen.queryByText("notes.txt")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/options/routes/Tools/sections/CloudBackupSection.tsx b/src/pages/options/routes/Tools/sections/CloudBackupSection.tsx new file mode 100644 index 000000000..99071d6c8 --- /dev/null +++ b/src/pages/options/routes/Tools/sections/CloudBackupSection.tsx @@ -0,0 +1,175 @@ +import { useEffect, useState } from "react"; +import { SettingCard } from "../../../components/SettingCard"; +import FileSystemParams from "../../../components/FileSystemParams"; +import { Button } from "@App/pages/components/ui/button"; +import { Popconfirm } from "@App/pages/components/ui/popconfirm"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from "@App/pages/components/ui/sheet"; +import { systemConfig } from "@App/pages/store/global"; +import { synchronizeClient } from "@App/pages/store/features/script"; +import FileSystemFactory, { type FileSystemType } from "@Packages/filesystem/factory"; +import type { FileInfo, FileReader } from "@Packages/filesystem/filesystem"; +import { formatUnixTime } from "@App/pkg/utils/day_format"; +import { openImportWindow } from "../openImportWindow"; +import { t } from "@App/locales/locales"; +import { toast } from "sonner"; + +type BackupConfig = { filesystem: FileSystemType; params: { [key: string]: any } }; + +export function CloudBackupSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { + const [draft, setDraft] = useState(undefined); + const [loading, setLoading] = useState(false); + const [backupFileList, setBackupFileList] = useState([]); + + useEffect(() => { + Promise.resolve(systemConfig.get("backup")).then((v) => setDraft(v as BackupConfig)); + }, []); + + const currentParams = () => draft!.params[draft!.filesystem]; + + const saveAndBackup = () => { + if (!draft) return; + systemConfig.set("backup", draft); + setLoading(true); + toast.info(t("settings:preparing_backup")); + synchronizeClient + .backupToCloud(draft.filesystem, currentParams()) + .then(() => toast.success(t("settings:backup_success"))) + .catch((e) => toast.error(`${t("settings:backup_failed")}: ${e}`)) + .finally(() => setLoading(false)); + }; + + const listBackups = async () => { + if (!draft) return; + setLoading(true); + try { + let fs = await FileSystemFactory.create(draft.filesystem, currentParams()); + fs = await fs.openDir("ScriptCat"); + let list = await fs.list(); + list = list.filter((file) => file.name.endsWith(".zip")).sort((a, b) => b.updatetime - a.updatetime); + if (list.length === 0) { + toast.info(t("settings:no_backup_files")); + } else { + setBackupFileList(list); + } + } catch (e) { + toast.error(`${t("settings:get_backup_files_failed")}: ${e}`); + } + setLoading(false); + }; + + const openBackupDir = async () => { + if (!draft) return; + try { + let fs = await FileSystemFactory.create(draft.filesystem, currentParams()); + fs = await fs.openDir("ScriptCat"); + const url = await fs.getDirUrl(); + if (url) window.open(url, "_blank"); + } catch (e) { + toast.error(`${t("settings:get_backup_dir_url_failed")}: ${e}`); + } + }; + + const restore = async (item: FileInfo) => { + if (!draft) return; + toast.info(t("tools:pulling_data_from_cloud")); + let fs = await FileSystemFactory.create(draft.filesystem, currentParams()); + let file: FileReader; + let data: Blob; + try { + fs = await fs.openDir("ScriptCat"); + file = await fs.open(item); + data = (await file.read("blob")) as Blob; + } catch (e) { + toast.error(`${t("tools:pull_failed")}: ${e}`); + return; + } + try { + await openImportWindow(item.name, data); + toast.success(t("tools:select_import_script")); + } catch (e) { + toast.error(`${t("tools:import_error")}: ${e}`); + } + }; + + const deleteBackup = async (item: FileInfo) => { + if (!draft) return; + let fs = await FileSystemFactory.create(draft.filesystem, currentParams()); + try { + fs = await fs.openDir("ScriptCat"); + await fs.delete(item.name); + setBackupFileList((prev) => prev.filter((i) => i.name !== item.name)); + toast.success(t("editor:delete_success")); + } catch (e) { + toast.error(`${t("script:delete_failed")}: ${e}`); + } + }; + + return ( + + {draft && ( + {t("settings:backup_to")}} + fileSystemType={draft.filesystem} + fileSystemParams={draft.params[draft.filesystem] || {}} + onChangeFileSystemType={(type) => setDraft((d) => (d ? { ...d, filesystem: type } : d))} + onChangeFileSystemParams={(params) => + setDraft((d) => (d ? { ...d, params: { ...d.params, [d.filesystem]: params } } : d)) + } + > + + + + )} + + 0} onOpenChange={(open) => !open && setBackupFileList([])}> + + +
+ {t("settings:backup_list")} + +
+ {t("tools:cloud_backup")} +
+
+ {backupFileList.map((item) => ( +
+
+
{item.name}
+
{formatUnixTime(item.updatetime / 1000)}
+
+
+ + deleteBackup(item)} + > + + +
+
+ ))} +
+
+
+
+ ); +} diff --git a/src/pages/options/routes/Tools/sections/DevToolsSection.test.tsx b/src/pages/options/routes/Tools/sections/DevToolsSection.test.tsx new file mode 100644 index 000000000..c7551d77b --- /dev/null +++ b/src/pages/options/routes/Tools/sections/DevToolsSection.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; + +const { connectVSCode } = vi.hoisted(() => ({ connectVSCode: vi.fn(() => Promise.resolve()) })); +vi.mock("@App/app/service/service_worker/client", () => ({ + SystemClient: class { + connectVSCode = connectVSCode; + }, +})); + +const { get, set } = vi.hoisted(() => ({ get: vi.fn(), set: vi.fn() })); +vi.mock("@App/pages/store/global", () => ({ systemConfig: { get, set }, message: {} })); + +import { DevToolsSection } from "./DevToolsSection"; + +afterEach(() => { + cleanup(); + get.mockReset(); + set.mockReset(); + connectVSCode.mockClear(); +}); + +describe("开发工具分区", () => { + it("点击连接写入配置并连接 VSCode 服务", async () => { + get.mockImplementation((key: string) => { + if (key === "vscode_url") return Promise.resolve("ws://localhost:8642"); + if (key === "vscode_reconnect") return Promise.resolve(true); + return Promise.resolve(""); + }); + render( () => {}} />); + const input = (await screen.findByLabelText("vscode_url_input")) as HTMLInputElement; + await waitFor(() => expect(input.value).toBe("ws://localhost:8642")); + fireEvent.click(screen.getByLabelText("vscode_connect")); + expect(set).toHaveBeenCalledWith("vscode_url", "ws://localhost:8642"); + expect(set).toHaveBeenCalledWith("vscode_reconnect", true); + await waitFor(() => + expect(connectVSCode).toHaveBeenCalledWith({ url: "ws://localhost:8642", reconnect: true }) + ); + }); +}); diff --git a/src/pages/options/routes/Tools/sections/DevToolsSection.tsx b/src/pages/options/routes/Tools/sections/DevToolsSection.tsx new file mode 100644 index 000000000..3cf785e82 --- /dev/null +++ b/src/pages/options/routes/Tools/sections/DevToolsSection.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; +import { SettingCard } from "../../../components/SettingCard"; +import { SettingRow } from "../../../components/SettingRow"; +import { Input } from "@App/pages/components/ui/input"; +import { Button } from "@App/pages/components/ui/button"; +import { Checkbox } from "@App/pages/components/ui/checkbox"; +import { systemConfig, message } from "@App/pages/store/global"; +import { SystemClient } from "@App/app/service/service_worker/client"; +import { t } from "@App/locales/locales"; +import { toast } from "sonner"; + +export function DevToolsSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { + const [url, setUrl] = useState(""); + const [reconnect, setReconnect] = useState(false); + + useEffect(() => { + Promise.resolve(systemConfig.get("vscode_url")).then((v) => setUrl((v as string) ?? "")); + Promise.resolve(systemConfig.get("vscode_reconnect")).then((v) => setReconnect(Boolean(v))); + }, []); + + const connect = () => { + systemConfig.set("vscode_url", url); + systemConfig.set("vscode_reconnect", reconnect); + const systemClient = new SystemClient(message); + systemClient + .connectVSCode({ url, reconnect }) + .then(() => toast.success(t("tools:connection_success"))) + .catch((e) => toast.error(`${t("tools:connection_failed")}: ${e}`)); + }; + + return ( + + + setUrl(e.target.value)} + /> + + +
+ +
+
+ ); +} diff --git a/src/pages/options/routes/Tools/sections/LocalBackupSection.test.tsx b/src/pages/options/routes/Tools/sections/LocalBackupSection.test.tsx new file mode 100644 index 000000000..0b01f7ed5 --- /dev/null +++ b/src/pages/options/routes/Tools/sections/LocalBackupSection.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; + +const { exportFn } = vi.hoisted(() => ({ exportFn: vi.fn(() => Promise.resolve()) })); +vi.mock("@App/pages/store/features/script", () => ({ synchronizeClient: { export: exportFn } })); + +const { openImport } = vi.hoisted(() => ({ openImport: vi.fn(() => Promise.resolve()) })); +vi.mock("../openImportWindow", () => ({ openImportWindow: openImport })); + +import { LocalBackupSection } from "./LocalBackupSection"; + +afterEach(() => { + cleanup(); + exportFn.mockClear(); + openImport.mockClear(); +}); + +describe("本地备份分区", () => { + it("点击导出触发备份导出", async () => { + render( () => {}} />); + fireEvent.click(screen.getByLabelText("tools_export")); + await waitFor(() => expect(exportFn).toHaveBeenCalled()); + }); + + it("选择文件后通过导入窗口处理", async () => { + render( () => {}} />); + fireEvent.click(screen.getByLabelText("tools_import")); // 绑定 onchange + const input = screen.getByLabelText("tools_import_file") as HTMLInputElement; + const file = new File(["x"], "backup.zip", { type: "application/zip" }); + Object.defineProperty(input, "files", { value: [file], configurable: true }); + fireEvent.change(input); + await waitFor(() => expect(openImport).toHaveBeenCalledWith("backup.zip", file)); + }); +}); diff --git a/src/pages/options/routes/Tools/sections/LocalBackupSection.tsx b/src/pages/options/routes/Tools/sections/LocalBackupSection.tsx new file mode 100644 index 000000000..c39f45cdf --- /dev/null +++ b/src/pages/options/routes/Tools/sections/LocalBackupSection.tsx @@ -0,0 +1,50 @@ +import { useRef, useState } from "react"; +import { SettingCard } from "../../../components/SettingCard"; +import { Button } from "@App/pages/components/ui/button"; +import { synchronizeClient } from "@App/pages/store/features/script"; +import { openImportWindow } from "../openImportWindow"; +import { t } from "@App/locales/locales"; +import { toast } from "sonner"; + +export function LocalBackupSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { + const fileRef = useRef(null); + const [exporting, setExporting] = useState(false); + + const exportFile = async () => { + setExporting(true); + try { + await synchronizeClient.export(); + } finally { + setExporting(false); + } + }; + + const pickImportFile = () => { + const el = fileRef.current!; + el.onchange = async () => { + const file = el.files?.[0]; + if (!file) return; + try { + await openImportWindow(file.name, file); + toast.success(t("tools:select_import_script")); + } catch (e) { + toast.error(`${t("tools:import_error")}: ${e}`); + } + }; + el.click(); + }; + + return ( + + +
+ + +
+
+ ); +} diff --git a/src/pages/options/routes/Tools/sections/MigrationSection.test.tsx b/src/pages/options/routes/Tools/sections/MigrationSection.test.tsx new file mode 100644 index 000000000..38c7be921 --- /dev/null +++ b/src/pages/options/routes/Tools/sections/MigrationSection.test.tsx @@ -0,0 +1,23 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; + +const { migrate } = vi.hoisted(() => ({ migrate: vi.fn() })); +vi.mock("@App/app/migrate", () => ({ migrateToChromeStorage: migrate })); + +import { MigrationSection } from "./MigrationSection"; + +afterEach(() => { + cleanup(); + migrate.mockClear(); +}); + +describe("数据迁移分区", () => { + it("确认后触发存储迁移", async () => { + render( () => {}} />); + fireEvent.click(screen.getByLabelText("retry_migration")); + await waitFor(() => expect(screen.getAllByRole("button").length).toBeGreaterThan(1)); + const buttons = screen.getAllByRole("button"); + fireEvent.click(buttons[buttons.length - 1]); // 气泡内确认按钮 + await waitFor(() => expect(migrate).toHaveBeenCalled()); + }); +}); diff --git a/src/pages/options/routes/Tools/sections/MigrationSection.tsx b/src/pages/options/routes/Tools/sections/MigrationSection.tsx new file mode 100644 index 000000000..9155c7cc1 --- /dev/null +++ b/src/pages/options/routes/Tools/sections/MigrationSection.tsx @@ -0,0 +1,29 @@ +import { SettingCard } from "../../../components/SettingCard"; +import { Button } from "@App/pages/components/ui/button"; +import { Popconfirm } from "@App/pages/components/ui/popconfirm"; +import { migrateToChromeStorage } from "@App/app/migrate"; +import { t } from "@App/locales/locales"; + +export function MigrationSection({ register }: { register: (id: string) => (el: HTMLElement | null) => void }) { + return ( + +
+ migrateToChromeStorage()} + > + + +
+
+ ); +} From 1eef68fd36f8bc2adcd73fee6c91ccc9f4ead690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 14:49:46 +0800 Subject: [PATCH 28/97] =?UTF-8?q?=F0=9F=94=80=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=A1=B5=E8=B7=AF=E7=94=B1:/tools=20?= =?UTF-8?q?=E2=86=92=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/options/App.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/options/App.tsx b/src/pages/options/App.tsx index 78b948281..5559b8531 100644 --- a/src/pages/options/App.tsx +++ b/src/pages/options/App.tsx @@ -5,6 +5,7 @@ import SubscribeList from "./routes/SubscribeList"; import ScriptEditor from "./routes/ScriptEditor"; import Logger from "./routes/Logger"; import Setting from "./routes/Setting"; +import Tools from "./routes/Tools"; import { t } from "@App/locales/locales"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; import MobileHeader from "./layout/MobileHeader"; @@ -69,7 +70,7 @@ export default function App() { } /> } /> - } /> + } /> } /> } /> From 6a348a9828e26c39276b2e72a3a1b5d63a117ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 15:09:08 +0800 Subject: [PATCH 29/97] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=A1=B5=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D:=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E7=A1=AE=E8=AE=A4=E6=96=87=E6=A1=88=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E7=A9=BA=E9=97=B4(Critical)=E3=80=81=E8=A1=A5=20VSCode=20?= =?UTF-8?q?=E5=B8=AE=E5=8A=A9=E9=93=BE=E6=8E=A5=E3=80=81=E8=A1=A5=E6=81=A2?= =?UTF-8?q?=E5=A4=8D/=E5=88=A0=E9=99=A4=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/options/components/SettingCard.tsx | 7 ++- src/pages/options/routes/Tools/index.test.tsx | 7 +-- .../sections/CloudBackupSection.test.tsx | 48 ++++++++++++++++++- .../Tools/sections/CloudBackupSection.tsx | 2 +- .../routes/Tools/sections/DevToolsSection.tsx | 12 +++++ 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/pages/options/components/SettingCard.tsx b/src/pages/options/components/SettingCard.tsx index d58b1a795..5a48b2c7a 100644 --- a/src/pages/options/components/SettingCard.tsx +++ b/src/pages/options/components/SettingCard.tsx @@ -3,12 +3,14 @@ import React from "react"; export function SettingCard({ id, title, + titleAction, description, register, children, }: { id: string; title: string; + titleAction?: React.ReactNode; description?: string; register: (id: string) => (el: HTMLElement | null) => void; children: React.ReactNode; @@ -16,7 +18,10 @@ export function SettingCard({ return (
-

{title}

+
+

{title}

+ {titleAction} +
{description &&

{description}

}
{children}
diff --git a/src/pages/options/routes/Tools/index.test.tsx b/src/pages/options/routes/Tools/index.test.tsx index c5452e4db..62c3ec03c 100644 --- a/src/pages/options/routes/Tools/index.test.tsx +++ b/src/pages/options/routes/Tools/index.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen, waitFor, cleanup } from "@testing-library/react"; +import { render, screen, within, cleanup } from "@testing-library/react"; import { initLanguage } from "@App/locales/locales"; const { get, set } = vi.hoisted(() => ({ @@ -45,8 +45,9 @@ beforeEach(() => { afterEach(cleanup); describe("工具页", () => { - it("渲染 5 个分类导航项", async () => { + it("渲染 5 个分类导航项", () => { render(); - await waitFor(() => expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(5)); + const nav = screen.getByRole("navigation"); + expect(within(nav).getAllByRole("button")).toHaveLength(5); }); }); diff --git a/src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx b/src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx index 73a194dcc..d80d67271 100644 --- a/src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx +++ b/src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx @@ -24,7 +24,9 @@ vi.mock("@Packages/filesystem/auth", () => ({ ClearNetDiskToken: vi.fn(() => Promise.resolve()), })); vi.mock("@App/pages/store/features/script", () => ({ synchronizeClient: { backupToCloud } })); -vi.mock("../openImportWindow", () => ({ openImportWindow: vi.fn(() => Promise.resolve()) })); + +const { openImport } = vi.hoisted(() => ({ openImport: vi.fn(() => Promise.resolve()) })); +vi.mock("../openImportWindow", () => ({ openImportWindow: openImport })); const { get, set } = vi.hoisted(() => ({ get: vi.fn(), set: vi.fn() })); vi.mock("@App/pages/store/global", () => ({ systemConfig: { get, set }, subscribeMessage: () => () => {} })); @@ -45,8 +47,27 @@ afterEach(() => { create.mockReset(); backupToCloud.mockReset(); backupToCloud.mockResolvedValue(undefined); + openImport.mockClear(); }); +// 构造 create → openDir → {list, open, delete} 文件系统链 +function mockFs(items: { name: string; updatetime: number }[]) { + const fileReader = { read: vi.fn(() => Promise.resolve(new Blob(["zip"]))) }; + const fsDir = { + list: vi.fn(() => Promise.resolve(items)), + open: vi.fn(() => Promise.resolve(fileReader)), + delete: vi.fn(() => Promise.resolve()), + }; + const fsRoot = { openDir: vi.fn(() => Promise.resolve(fsDir)) }; + create.mockResolvedValue(fsRoot); + return { fsRoot, fsDir, fileReader }; +} + +async function openBackupList() { + const btn = await screen.findByLabelText("tools_backup_list"); + fireEvent.click(btn); +} + describe("云端备份分区", () => { it("点击备份写入配置并上传云端", async () => { mockBackup(); @@ -79,4 +100,29 @@ describe("云端备份分区", () => { expect(screen.getByText("b.zip")).toBeInTheDocument(); expect(screen.queryByText("notes.txt")).not.toBeInTheDocument(); }); + + it("点击恢复从云端读取文件并打开导入窗口", async () => { + const { fsDir, fileReader } = mockFs([{ name: "a.zip", updatetime: 2000 }]); + mockBackup(); + render( () => {}} />); + await openBackupList(); + const restore = await screen.findByLabelText("tools_restore"); + fireEvent.click(restore); + await waitFor(() => expect(fsDir.open).toHaveBeenCalledWith({ name: "a.zip", updatetime: 2000 })); + await waitFor(() => expect(fileReader.read).toHaveBeenCalledWith("blob")); + await waitFor(() => expect(openImport).toHaveBeenCalledWith("a.zip", expect.any(Blob))); + }); + + it("确认删除后从云端删除文件并移出列表", async () => { + const { fsDir } = mockFs([{ name: "a.zip", updatetime: 2000 }]); + mockBackup(); + render( () => {}} />); + await openBackupList(); + fireEvent.click(await screen.findByLabelText("tools_delete")); + await waitFor(() => expect(screen.getAllByRole("button").length).toBeGreaterThan(2)); + const buttons = screen.getAllByRole("button"); + fireEvent.click(buttons[buttons.length - 1]); // 气泡确认 + await waitFor(() => expect(fsDir.delete).toHaveBeenCalledWith("a.zip")); + await waitFor(() => expect(screen.queryByText("a.zip")).not.toBeInTheDocument()); + }); }); diff --git a/src/pages/options/routes/Tools/sections/CloudBackupSection.tsx b/src/pages/options/routes/Tools/sections/CloudBackupSection.tsx index 99071d6c8..aee80b0c0 100644 --- a/src/pages/options/routes/Tools/sections/CloudBackupSection.tsx +++ b/src/pages/options/routes/Tools/sections/CloudBackupSection.tsx @@ -154,7 +154,7 @@ export function CloudBackupSection({ register }: { register: (id: string) => (el {t("tools:restore")} (el: H + + + } description={t("tools:vscode_url")} register={register} > From 2cd804d3cb6318daac85a0fd2e6fa1b7a3cf2f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 15:10:57 +0800 Subject: [PATCH 30/97] =?UTF-8?q?=F0=9F=8E=A8=20prettier=20=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=96=20FileSystemParams/RuntimeSection.test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/options/components/FileSystemParams.tsx | 5 +---- .../options/routes/Setting/sections/RuntimeSection.test.tsx | 6 +++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pages/options/components/FileSystemParams.tsx b/src/pages/options/components/FileSystemParams.tsx index de90cf657..fcc5b8749 100644 --- a/src/pages/options/components/FileSystemParams.tsx +++ b/src/pages/options/components/FileSystemParams.tsx @@ -112,10 +112,7 @@ export default function FileSystemParams({
{props.title} {props.type === "select" ? ( - setParam(value)}> diff --git a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx index fa5e827c0..8753b39ad 100644 --- a/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/RuntimeSection.test.tsx @@ -2,7 +2,11 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; const { create } = vi.hoisted(() => ({ - create: vi.fn(() => Promise.resolve({ openDir: vi.fn(() => Promise.resolve({ getDirUrl: vi.fn(() => Promise.resolve("https://dir")) })) })), + create: vi.fn(() => + Promise.resolve({ + openDir: vi.fn(() => Promise.resolve({ getDirUrl: vi.fn(() => Promise.resolve("https://dir")) })), + }) + ), })); vi.mock("@Packages/filesystem/factory", () => ({ default: { From 5256f04bd4304a5c5d20edcdde072e41a9fa769d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 15:12:37 +0800 Subject: [PATCH 31/97] =?UTF-8?q?=F0=9F=8E=A8=20prettier=20=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=96=E5=B7=A5=E5=85=B7=E9=A1=B5=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/options/routes/Tools/index.test.tsx | 12 +++++++++++- .../Tools/sections/CloudBackupSection.test.tsx | 3 ++- .../routes/Tools/sections/CloudBackupSection.tsx | 15 +++++++-------- .../Tools/sections/DevToolsSection.test.tsx | 4 +--- .../routes/Tools/sections/DevToolsSection.tsx | 6 +----- .../routes/Tools/sections/LocalBackupSection.tsx | 7 ++++++- 6 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/pages/options/routes/Tools/index.test.tsx b/src/pages/options/routes/Tools/index.test.tsx index 62c3ec03c..c470f14e0 100644 --- a/src/pages/options/routes/Tools/index.test.tsx +++ b/src/pages/options/routes/Tools/index.test.tsx @@ -20,7 +20,17 @@ vi.mock("@App/pages/store/features/script", () => ({ synchronizeClient: { export vi.mock("@App/app/migrate", () => ({ migrateToChromeStorage: vi.fn() })); vi.mock("@App/app/service/service_worker/client", () => ({ SystemClient: vi.fn() })); vi.mock("@Packages/filesystem/factory", () => ({ - default: { create: vi.fn(), params: () => ({ webdav: { url: { title: "url" } }, "baidu-netdsik": {}, onedrive: {}, googledrive: {}, dropbox: {}, s3: {} }) }, + default: { + create: vi.fn(), + params: () => ({ + webdav: { url: { title: "url" } }, + "baidu-netdsik": {}, + onedrive: {}, + googledrive: {}, + dropbox: {}, + s3: {}, + }), + }, })); vi.mock("@Packages/filesystem/auth", () => ({ netDiskTypeMap: {}, diff --git a/src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx b/src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx index d80d67271..9200a0c8c 100644 --- a/src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx +++ b/src/pages/options/routes/Tools/sections/CloudBackupSection.test.tsx @@ -35,7 +35,8 @@ import { CloudBackupSection } from "./CloudBackupSection"; function mockBackup(over: Record = {}) { get.mockImplementation((key: string) => { - if (key === "backup") return Promise.resolve({ filesystem: "webdav", params: { webdav: { url: "https://dav" } }, ...over }); + if (key === "backup") + return Promise.resolve({ filesystem: "webdav", params: { webdav: { url: "https://dav" } }, ...over }); return Promise.resolve(""); }); } diff --git a/src/pages/options/routes/Tools/sections/CloudBackupSection.tsx b/src/pages/options/routes/Tools/sections/CloudBackupSection.tsx index aee80b0c0..b0d050fe9 100644 --- a/src/pages/options/routes/Tools/sections/CloudBackupSection.tsx +++ b/src/pages/options/routes/Tools/sections/CloudBackupSection.tsx @@ -3,13 +3,7 @@ import { SettingCard } from "../../../components/SettingCard"; import FileSystemParams from "../../../components/FileSystemParams"; import { Button } from "@App/pages/components/ui/button"; import { Popconfirm } from "@App/pages/components/ui/popconfirm"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, -} from "@App/pages/components/ui/sheet"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@App/pages/components/ui/sheet"; import { systemConfig } from "@App/pages/store/global"; import { synchronizeClient } from "@App/pages/store/features/script"; import FileSystemFactory, { type FileSystemType } from "@Packages/filesystem/factory"; @@ -111,7 +105,12 @@ export function CloudBackupSection({ register }: { register: (id: string) => (el }; return ( - + {draft && ( {t("settings:backup_to")}} diff --git a/src/pages/options/routes/Tools/sections/DevToolsSection.test.tsx b/src/pages/options/routes/Tools/sections/DevToolsSection.test.tsx index c7551d77b..75022563a 100644 --- a/src/pages/options/routes/Tools/sections/DevToolsSection.test.tsx +++ b/src/pages/options/routes/Tools/sections/DevToolsSection.test.tsx @@ -33,8 +33,6 @@ describe("开发工具分区", () => { fireEvent.click(screen.getByLabelText("vscode_connect")); expect(set).toHaveBeenCalledWith("vscode_url", "ws://localhost:8642"); expect(set).toHaveBeenCalledWith("vscode_reconnect", true); - await waitFor(() => - expect(connectVSCode).toHaveBeenCalledWith({ url: "ws://localhost:8642", reconnect: true }) - ); + await waitFor(() => expect(connectVSCode).toHaveBeenCalledWith({ url: "ws://localhost:8642", reconnect: true })); }); }); diff --git a/src/pages/options/routes/Tools/sections/DevToolsSection.tsx b/src/pages/options/routes/Tools/sections/DevToolsSection.tsx index 3847f03c1..1c57a02ce 100644 --- a/src/pages/options/routes/Tools/sections/DevToolsSection.tsx +++ b/src/pages/options/routes/Tools/sections/DevToolsSection.tsx @@ -56,11 +56,7 @@ export function DevToolsSection({ register }: { register: (id: string) => (el: H />
diff --git a/src/pages/options/routes/Tools/sections/LocalBackupSection.tsx b/src/pages/options/routes/Tools/sections/LocalBackupSection.tsx index c39f45cdf..f7406b315 100644 --- a/src/pages/options/routes/Tools/sections/LocalBackupSection.tsx +++ b/src/pages/options/routes/Tools/sections/LocalBackupSection.tsx @@ -35,7 +35,12 @@ export function LocalBackupSection({ register }: { register: (id: string) => (el }; return ( - +
); })} + )} + +
+ {/* 桌面端:左侧竖向分类导航 */} + {!isMobile && ( + + )} + + {/* 滚动容器始终渲染(切换断点不重挂载,避免 scroll-spy 的 IO root 失效) */}
-
{children(register)}
+
+ {children(register)} +
diff --git a/src/pages/options/routes/Setting/index.test.tsx b/src/pages/options/routes/Setting/index.test.tsx index e633150cf..10a146e91 100644 --- a/src/pages/options/routes/Setting/index.test.tsx +++ b/src/pages/options/routes/Setting/index.test.tsx @@ -17,6 +17,15 @@ import Setting from "./index"; beforeEach(() => { initLanguage("en-US"); + // jsdom 未实现 matchMedia(useIsMobile 用),固定返回 desktop + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })); // jsdom doesn't provide IntersectionObserver — stub it // @ts-expect-error test stub globalThis.IntersectionObserver = class { diff --git a/src/pages/options/routes/Tools/index.test.tsx b/src/pages/options/routes/Tools/index.test.tsx index c470f14e0..58ede3c43 100644 --- a/src/pages/options/routes/Tools/index.test.tsx +++ b/src/pages/options/routes/Tools/index.test.tsx @@ -42,6 +42,15 @@ import Tools from "./index"; beforeEach(() => { initLanguage("en-US"); + // jsdom 未实现 matchMedia(useIsMobile 用),固定返回 desktop + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })); // @ts-expect-error test stub globalThis.IntersectionObserver = class { observe() {} From f3a79a28671c72b27e429c0579fdd57fffc6c6d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 17:31:15 +0800 Subject: [PATCH 33/97] =?UTF-8?q?=E2=9C=A8=20=E6=8B=96=E6=8B=BD=E5=AE=89?= =?UTF-8?q?=E8=A3=85/=E5=AF=BC=E5=85=A5=E8=8F=9C=E5=8D=95/Skill=20?= =?UTF-8?q?=E6=96=87=E6=A1=88:script.json=20=E6=96=B0=E5=A2=9E=20key(7=20?= =?UTF-8?q?=E8=AF=AD=E8=A8=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/de-DE/script.json | 14 +++++++++++++- src/locales/en-US/script.json | 14 +++++++++++++- src/locales/ja-JP/script.json | 14 +++++++++++++- src/locales/ru-RU/script.json | 14 +++++++++++++- src/locales/vi-VN/script.json | 14 +++++++++++++- src/locales/zh-CN/script.json | 14 +++++++++++++- src/locales/zh-TW/script.json | 14 +++++++++++++- 7 files changed, 91 insertions(+), 7 deletions(-) diff --git a/src/locales/de-DE/script.json b/src/locales/de-DE/script.json index c008ca132..d5b931025 100644 --- a/src/locales/de-DE/script.json +++ b/src/locales/de-DE/script.json @@ -99,5 +99,17 @@ "error_old_script_code_missing": "Alter Skriptcode nicht gefunden", "error_subscribe_name_required": "Abonnementname ist erforderlich", "error_grant_conflict": "@grant deklariert sowohl 'none' als auch GM API", - "error_metadata_line_duplicated": "In den Metadaten befinden sich doppelte Deklarationen." + "error_metadata_line_duplicated": "In den Metadaten befinden sich doppelte Deklarationen.", + "create_group": "Erstellen", + "import_group": "Importieren", + "import_local_script": "Lokales Skript importieren", + "link_import": "Per Link importieren", + "import_skill": "Skill importieren", + "link_import_desc": "Skript- / Abonnement-Links einfügen, einer pro Zeile", + "link_import_placeholder": "https://example.com/script.user.js", + "link_import_hint": "Unterstützt Benutzerskripte / Abonnements / Skill-Links", + "not_a_valid_script": "Kein gültiges Benutzerskript oder SkillScript", + "import_done": "Import abgeschlossen: {{success}} erfolgreich · {{fail}} fehlgeschlagen", + "drop_to_install": "Skripte oder Skills hierher ziehen, um sie zu installieren", + "drop_to_install_hint": ".js Benutzerskripte / Abonnements · .zip Skill-Pakete hierher ziehen" } diff --git a/src/locales/en-US/script.json b/src/locales/en-US/script.json index 06ad404e4..3e8322202 100644 --- a/src/locales/en-US/script.json +++ b/src/locales/en-US/script.json @@ -99,5 +99,17 @@ "error_old_script_code_missing": "Previous script code not found", "error_subscribe_name_required": "Subscription name is required", "error_grant_conflict": "@grant declares both 'none' and GM API", - "error_metadata_line_duplicated": "There are duplicate declarations in the metadata." + "error_metadata_line_duplicated": "There are duplicate declarations in the metadata.", + "create_group": "Create", + "import_group": "Import", + "import_local_script": "Import Local Script", + "link_import": "Import from URL", + "import_skill": "Import Skill", + "link_import_desc": "Paste script / subscription URLs, one per line", + "link_import_placeholder": "https://example.com/script.user.js", + "link_import_hint": "Supports user scripts / subscriptions / Skill URLs", + "not_a_valid_script": "Not a valid user script or SkillScript", + "import_done": "Import done: {{success}} succeeded · {{fail}} failed", + "drop_to_install": "Drop scripts or Skills here to install", + "drop_to_install_hint": "Drop .js user scripts / subscriptions · .zip Skill packages" } diff --git a/src/locales/ja-JP/script.json b/src/locales/ja-JP/script.json index 9b0987d85..9c760aeb4 100644 --- a/src/locales/ja-JP/script.json +++ b/src/locales/ja-JP/script.json @@ -99,5 +99,17 @@ "error_old_script_code_missing": "既存スクリプトのコードが見つかりません", "error_subscribe_name_required": "サブスクライブ名は必須です", "error_grant_conflict": "@grant に 'none' と GM API の両方が指定されています", - "error_metadata_line_duplicated": "メタデータ内に重複した宣言があります。" + "error_metadata_line_duplicated": "メタデータ内に重複した宣言があります。", + "create_group": "新規作成", + "import_group": "インポート", + "import_local_script": "ローカルスクリプトをインポート", + "link_import": "URLからインポート", + "import_skill": "Skillをインポート", + "link_import_desc": "スクリプト / サブスクライブのURLを貼り付けてください(1行に1つ)", + "link_import_placeholder": "https://example.com/script.user.js", + "link_import_hint": "ユーザースクリプト / サブスクライブ / Skill URLに対応", + "not_a_valid_script": "有効なユーザースクリプトまたはSkillScriptではありません", + "import_done": "インポート完了: 成功 {{success}} · 失敗 {{fail}}", + "drop_to_install": "スクリプトまたはSkillをここにドロップしてインストール", + "drop_to_install_hint": ".jsユーザースクリプト / サブスクライブ · .zip Skillパッケージをドロップ" } diff --git a/src/locales/ru-RU/script.json b/src/locales/ru-RU/script.json index 27e22cbfd..2de603728 100644 --- a/src/locales/ru-RU/script.json +++ b/src/locales/ru-RU/script.json @@ -99,5 +99,17 @@ "error_old_script_code_missing": "Предыдущий код скрипта не найден", "error_subscribe_name_required": "Имя подписки обязательно", "error_grant_conflict": "@grant одновременно объявляет 'none' и GM API", - "error_metadata_line_duplicated": "В метаданных есть повторяющиеся объявления." + "error_metadata_line_duplicated": "В метаданных есть повторяющиеся объявления.", + "create_group": "Создать", + "import_group": "Импорт", + "import_local_script": "Импортировать локальный скрипт", + "link_import": "Импорт по ссылке", + "import_skill": "Импортировать Skill", + "link_import_desc": "Вставьте ссылки на скрипты / подписки, по одной на строку", + "link_import_placeholder": "https://example.com/script.user.js", + "link_import_hint": "Поддерживает ссылки на пользовательские скрипты / подписки / Skill", + "not_a_valid_script": "Не является допустимым пользовательским скриптом или SkillScript", + "import_done": "Импорт завершён: успешно {{success}} · ошибок {{fail}}", + "drop_to_install": "Перетащите скрипты или Skill для установки", + "drop_to_install_hint": "Перетащите .js скрипты / подписки · .zip пакеты Skill" } diff --git a/src/locales/vi-VN/script.json b/src/locales/vi-VN/script.json index 5b8875069..02fb0e867 100644 --- a/src/locales/vi-VN/script.json +++ b/src/locales/vi-VN/script.json @@ -99,5 +99,17 @@ "error_old_script_code_missing": "Không tìm thấy mã script cũ", "error_subscribe_name_required": "Tên đăng ký là bắt buộc", "error_grant_conflict": "@grant khai báo đồng thời 'none' và GM API", - "error_metadata_line_duplicated": "Có các khai báo trùng lặp trong metadata." + "error_metadata_line_duplicated": "Có các khai báo trùng lặp trong metadata.", + "create_group": "Tạo mới", + "import_group": "Nhập", + "import_local_script": "Nhập script cục bộ", + "link_import": "Nhập từ URL", + "import_skill": "Nhập Skill", + "link_import_desc": "Dán URL script / đăng ký, mỗi dòng một URL", + "link_import_placeholder": "https://example.com/script.user.js", + "link_import_hint": "Hỗ trợ URL script người dùng / đăng ký / Skill", + "not_a_valid_script": "Không phải script người dùng hoặc SkillScript hợp lệ", + "import_done": "Nhập hoàn tất: thành công {{success}} · thất bại {{fail}}", + "drop_to_install": "Kéo thả script hoặc Skill vào đây để cài đặt", + "drop_to_install_hint": "Kéo thả script .js / đăng ký · gói Skill .zip" } diff --git a/src/locales/zh-CN/script.json b/src/locales/zh-CN/script.json index 170a44657..32f60ac91 100644 --- a/src/locales/zh-CN/script.json +++ b/src/locales/zh-CN/script.json @@ -99,5 +99,17 @@ "error_old_script_code_missing": "旧的脚本代码不存在", "error_subscribe_name_required": "订阅名不能为空", "error_grant_conflict": "@grant 同时声明了 none 和 GM API", - "error_metadata_line_duplicated": "Metadata 中有重复的声明。" + "error_metadata_line_duplicated": "Metadata 中有重复的声明。", + "create_group": "新建", + "import_group": "导入", + "import_local_script": "导入本地脚本", + "link_import": "链接导入", + "import_skill": "导入 Skill", + "link_import_desc": "粘贴脚本 / 订阅链接,每行一个", + "link_import_placeholder": "https://example.com/script.user.js", + "link_import_hint": "支持用户脚本 / 订阅 / Skill 链接", + "not_a_valid_script": "不是有效的用户脚本或 SkillScript", + "import_done": "导入完成:成功 {{success}} · 失败 {{fail}}", + "drop_to_install": "拖拽脚本或 Skill 到此处安装", + "drop_to_install_hint": "拖拽 .js 用户脚本 / 订阅 · .zip Skill 包" } diff --git a/src/locales/zh-TW/script.json b/src/locales/zh-TW/script.json index 1e872f55d..47a5c27d3 100644 --- a/src/locales/zh-TW/script.json +++ b/src/locales/zh-TW/script.json @@ -99,5 +99,17 @@ "error_old_script_code_missing": "舊的腳本程式碼不存在", "error_subscribe_name_required": "訂閱名稱不可為空", "error_grant_conflict": "@grant 同時宣告了 none 與 GM API", - "error_metadata_line_duplicated": "Metadata 裡有重覆的聲明。" + "error_metadata_line_duplicated": "Metadata 裡有重覆的聲明。", + "create_group": "新建", + "import_group": "匯入", + "import_local_script": "匯入本機腳本", + "link_import": "連結匯入", + "import_skill": "匯入 Skill", + "link_import_desc": "貼上腳本 / 訂閱連結,每行一個", + "link_import_placeholder": "https://example.com/script.user.js", + "link_import_hint": "支援使用者腳本 / 訂閱 / Skill 連結", + "not_a_valid_script": "不是有效的使用者腳本或 SkillScript", + "import_done": "匯入完成:成功 {{success}} · 失敗 {{fail}}", + "drop_to_install": "拖曳腳本或 Skill 到此處安裝", + "drop_to_install_hint": "拖曳 .js 使用者腳本 / 訂閱 · .zip Skill 包" } From c9663b404f897308df8b5e592a418da7a97060e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 17:48:34 +0800 Subject: [PATCH 34/97] =?UTF-8?q?=E2=9C=A8=20importHandler:=E6=8B=96?= =?UTF-8?q?=E6=8B=BD/=E5=AF=BC=E5=85=A5=E6=96=87=E4=BB=B6=E4=B8=8E?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E6=8C=89=E7=B1=BB=E5=9E=8B=E5=88=86=E6=B5=81?= =?UTF-8?q?=E5=AE=89=E8=A3=85(6=20=E6=B5=8B=E8=AF=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/ScriptList/importHandler.test.ts | 147 ++++++++++++++++++ .../routes/ScriptList/importHandler.ts | 108 +++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/pages/options/routes/ScriptList/importHandler.test.ts create mode 100644 src/pages/options/routes/ScriptList/importHandler.ts diff --git a/src/pages/options/routes/ScriptList/importHandler.test.ts b/src/pages/options/routes/ScriptList/importHandler.test.ts new file mode 100644 index 000000000..8c6a61aad --- /dev/null +++ b/src/pages/options/routes/ScriptList/importHandler.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@App/pages/store/features/script", () => ({ + scriptClient: { importByUrl: vi.fn() }, + agentClient: { + prepareSkillInstall: vi.fn(), + prepareSkillFromUrl: vi.fn(), + }, +})); + +vi.mock("@App/pkg/utils/filehandle-db", () => ({ + saveHandle: vi.fn(), +})); + +vi.mock("@App/pkg/utils/utils", () => ({ + makeBlobURL: vi.fn(), + openInCurrentTab: vi.fn(), +})); + +vi.mock("@App/pkg/utils/script", () => ({ + parseMetadata: vi.fn(), +})); + +vi.mock("@App/pkg/utils/skill_script", () => ({ + parseSkillScriptMetadata: vi.fn(), +})); + +vi.mock("@App/pkg/utils/uuid", () => ({ + uuidv4: vi.fn(() => "fid-1"), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@App/locales/locales", () => ({ + t: vi.fn((k: string) => k), +})); + +import { handleImportFiles, handleImportUrls } from "./importHandler"; +import { scriptClient, agentClient } from "@App/pages/store/features/script"; +import { saveHandle } from "@App/pkg/utils/filehandle-db"; +import { makeBlobURL, openInCurrentTab } from "@App/pkg/utils/utils"; +import { parseMetadata } from "@App/pkg/utils/script"; +import { parseSkillScriptMetadata } from "@App/pkg/utils/skill_script"; +import { toast } from "sonner"; + +function fileOf(name: string, content: string): File { + const file = new File([content], name, { type: "text/javascript" }); + // Mock the text() method on the File instance + (file as any).text = vi.fn().mockResolvedValue(content); + (file as any).arrayBuffer = vi.fn().mockResolvedValue(new TextEncoder().encode(content)); + return file; +} + +describe("importHandler 文件分流", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(parseMetadata).mockReturnValue(null); + vi.mocked(parseSkillScriptMetadata).mockReturnValue(null); + vi.mocked(makeBlobURL).mockReturnValue("blob:fake"); + vi.mocked(openInCurrentTab).mockResolvedValue({ id: 1 } as any); + vi.mocked(saveHandle).mockResolvedValue(undefined); + vi.mocked(scriptClient.importByUrl).mockResolvedValue({ success: true, msg: "" }); + vi.mocked(agentClient.prepareSkillInstall).mockResolvedValue("skill-1"); + vi.mocked(agentClient.prepareSkillFromUrl).mockResolvedValue("skill-1"); + }); + + it("带 handle 的用户脚本应存 handle 并打开 ?file= 安装页", async () => { + const handle = { getFile: async () => fileOf("a.user.js", "// ==UserScript==") } as any; + vi.mocked(parseMetadata).mockReturnValue({} as any); + + const stat = await handleImportFiles([{ file: await handle.getFile(), handle }]); + + expect(vi.mocked(saveHandle)).toHaveBeenCalledWith("fid-1", handle); + expect(vi.mocked(openInCurrentTab)).toHaveBeenCalledWith("/src/install.html?file=fid-1"); + expect(stat.success).toBe(1); + }); + + it("zip 应走 prepareSkillInstall 并打开 ?skill= 安装页", async () => { + vi.mocked(agentClient.prepareSkillInstall).mockResolvedValue("skill-9"); + + const stat = await handleImportFiles([{ file: fileOf("x.zip", "PKzip"), handle: null }]); + + expect(vi.mocked(agentClient.prepareSkillInstall)).toHaveBeenCalled(); + expect(vi.mocked(openInCurrentTab)).toHaveBeenCalledWith("/src/install.html?skill=skill-9"); + expect(stat.success).toBe(1); + }); + + it("无 handle 的脚本应走 importByUrl(blob)", async () => { + vi.mocked(parseMetadata).mockReturnValue({} as any); + + await handleImportFiles([{ file: fileOf("b.user.js", "// ==UserScript=="), handle: null }]); + + expect(vi.mocked(scriptClient.importByUrl)).toHaveBeenCalledWith("blob:fake"); + }); + + it("既非脚本也非 SkillScript 应计入失败", async () => { + vi.mocked(parseMetadata).mockReturnValue(null); + vi.mocked(parseSkillScriptMetadata).mockReturnValue(null); + + const stat = await handleImportFiles([{ file: fileOf("c.js", "garbage"), handle: null }]); + + expect(stat.fail).toBe(1); + expect(stat.success).toBe(0); + }); + + it("单文件成功不弹 toast,多文件成功弹汇总", async () => { + vi.mocked(parseMetadata).mockReturnValue({} as any); + + await handleImportFiles([{ file: fileOf("a.user.js", "// ==UserScript=="), handle: null }]); + expect(vi.mocked(toast.success)).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + vi.mocked(parseMetadata).mockReturnValue({} as any); + vi.mocked(parseSkillScriptMetadata).mockReturnValue(null); + vi.mocked(makeBlobURL).mockReturnValue("blob:fake"); + vi.mocked(scriptClient.importByUrl).mockResolvedValue({ success: true, msg: "" }); + + await handleImportFiles([ + { file: fileOf("a.user.js", "// ==UserScript=="), handle: null }, + { file: fileOf("b.user.js", "// ==UserScript=="), handle: null }, + ]); + expect(vi.mocked(toast.success)).toHaveBeenCalled(); + }); +}); + +describe("importHandler 链接分流", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(scriptClient.importByUrl).mockResolvedValue({ success: true, msg: "" }); + vi.mocked(agentClient.prepareSkillFromUrl).mockResolvedValue("skill-1"); + vi.mocked(openInCurrentTab).mockResolvedValue({ id: 1 } as any); + }); + + it("zip 链接走 prepareSkillFromUrl,其余走 importByUrl", async () => { + vi.mocked(agentClient.prepareSkillFromUrl).mockResolvedValue("skill-7"); + + await handleImportUrls(["https://e.com/s.user.js", "https://e.com/k.zip"]); + + expect(vi.mocked(scriptClient.importByUrl)).toHaveBeenCalledWith("https://e.com/s.user.js"); + expect(vi.mocked(agentClient.prepareSkillFromUrl)).toHaveBeenCalledWith("https://e.com/k.zip"); + }); +}); diff --git a/src/pages/options/routes/ScriptList/importHandler.ts b/src/pages/options/routes/ScriptList/importHandler.ts new file mode 100644 index 000000000..147fa54c6 --- /dev/null +++ b/src/pages/options/routes/ScriptList/importHandler.ts @@ -0,0 +1,108 @@ +import { scriptClient, agentClient } from "@App/pages/store/features/script"; +import { saveHandle } from "@App/pkg/utils/filehandle-db"; +import { makeBlobURL, openInCurrentTab } from "@App/pkg/utils/utils"; +import { parseMetadata } from "@App/pkg/utils/script"; +import { parseSkillScriptMetadata } from "@App/pkg/utils/skill_script"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { toast } from "sonner"; +import { t } from "@App/locales/locales"; + +export interface ImportItem { + file: File; + handle: FileSystemFileHandle | null; +} +export interface ImportStat { + success: number; + fail: number; + messages: string[]; +} + +// ArrayBuffer → base64(分块,避免 String.fromCharCode 参数过多爆栈) +function bufferToBase64(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + const chunk = 0x8000; + let binary = ""; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +async function installSkillZip(file: File): Promise { + const base64 = bufferToBase64(await file.arrayBuffer()); + const uuid = await agentClient.prepareSkillInstall(base64); + await openInCurrentTab(`/src/install.html?skill=${uuid}`); +} + +async function installLocalFile(file: File, handle: FileSystemFileHandle | null): Promise { + const code = await file.text(); + if (!parseMetadata(code) && !parseSkillScriptMetadata(code)) { + throw new Error(t("script:not_a_valid_script")); + } + if (handle) { + // 有 FileSystemFileHandle:存 DB 后开 ?file=,安装页可监听本地文件变更 + const fid = uuidv4(); + await saveHandle(fid, handle); + await openInCurrentTab(`/src/install.html?file=${fid}`); + } else { + // 无 handle( 选择等):走 blob URL,由 SW 打开安装页 + const url = makeBlobURL({ blob: new Blob([code], { type: "text/javascript" }), persistence: false }) as string; + const result = await scriptClient.importByUrl(url); + if (!result.success) throw new Error(result.msg); + } +} + +function reportStat(stat: ImportStat, total: number): void { + if (stat.fail === 0 && total <= 1) return; // 单文件成功:安装页本身即反馈,不打扰 + if (stat.fail === 0) { + toast.success(t("script:import_done", { success: stat.success, fail: stat.fail })); + } else { + toast.error( + `${t("script:import_done", { success: stat.success, fail: stat.fail })}\n${stat.messages.join("\n")}` + ); + } +} + +export async function handleImportFiles(items: ImportItem[]): Promise { + const stat: ImportStat = { success: 0, fail: 0, messages: [] }; + await Promise.all( + items.map(async ({ file, handle }) => { + try { + if (file.name.toLowerCase().endsWith(".zip")) { + await installSkillZip(file); + } else { + await installLocalFile(file, handle); + } + stat.success++; + } catch (e) { + stat.fail++; + stat.messages.push((e as Error).message); + } + }) + ); + reportStat(stat, items.length); + return stat; +} + +export async function handleImportUrls(urls: string[]): Promise { + const stat: ImportStat = { success: 0, fail: 0, messages: [] }; + await Promise.all( + urls.map(async (url) => { + try { + if (url.toLowerCase().endsWith(".zip")) { + const uuid = await agentClient.prepareSkillFromUrl(url); + await openInCurrentTab(`/src/install.html?skill=${uuid}`); + } else { + const result = await scriptClient.importByUrl(url); + if (!result.success) throw new Error(result.msg); + } + stat.success++; + } catch (e) { + stat.fail++; + stat.messages.push((e as Error).message); + } + }) + ); + reportStat(stat, urls.length); + return stat; +} From 1d015355c2f7024f6981fc96e64ae5c09070cc31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 17:50:36 +0800 Subject: [PATCH 35/97] =?UTF-8?q?=E2=9C=A8=20filePicker:showOpenFilePicker?= =?UTF-8?q?=20=E9=80=89=E6=9C=AC=E5=9C=B0=E8=84=9A=E6=9C=AC/Skill(?= =?UTF-8?q?=E5=90=AB=20input=20=E5=9B=9E=E9=80=80,2=20=E6=B5=8B=E8=AF=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/ScriptList/filePicker.test.ts | 22 +++++++++++ .../options/routes/ScriptList/filePicker.ts | 39 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/pages/options/routes/ScriptList/filePicker.test.ts create mode 100644 src/pages/options/routes/ScriptList/filePicker.ts diff --git a/src/pages/options/routes/ScriptList/filePicker.test.ts b/src/pages/options/routes/ScriptList/filePicker.test.ts new file mode 100644 index 000000000..0438571eb --- /dev/null +++ b/src/pages/options/routes/ScriptList/filePicker.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { pickScriptFiles } from "./filePicker"; + +afterEach(() => { vi.unstubAllGlobals(); }); + +describe("filePicker", () => { + it("有 showOpenFilePicker 时返回带 handle 的项", async () => { + const file = new File(["// ==UserScript=="], "a.user.js"); + const handle = { kind: "file", getFile: async () => file }; + vi.stubGlobal("showOpenFilePicker", vi.fn(async () => [handle])); + const items = await pickScriptFiles(); + expect(items).toHaveLength(1); + expect(items[0].handle).toBe(handle); + expect(items[0].file).toBe(file); + }); + + it("用户取消选择返回空数组", async () => { + vi.stubGlobal("showOpenFilePicker", vi.fn(async () => { throw new DOMException("abort", "AbortError"); })); + const items = await pickScriptFiles(); + expect(items).toEqual([]); + }); +}); diff --git a/src/pages/options/routes/ScriptList/filePicker.ts b/src/pages/options/routes/ScriptList/filePicker.ts new file mode 100644 index 000000000..98d0cbf01 --- /dev/null +++ b/src/pages/options/routes/ScriptList/filePicker.ts @@ -0,0 +1,39 @@ +import type { ImportItem } from "./importHandler"; + +interface PickerType { + description: string; + accept: Record; +} + +async function pick(types: PickerType[], inputAccept: string): Promise { + if ("showOpenFilePicker" in window) { + try { + const handles: FileSystemFileHandle[] = await (window as any).showOpenFilePicker({ multiple: true, types }); + return await Promise.all(handles.map(async (handle) => ({ file: await handle.getFile(), handle }))); + } catch (e) { + // 用户取消(AbortError)等同未选择 + if ((e as DOMException)?.name === "AbortError") return []; + throw e; + } + } + // 回退:(无法获得 handle,不能监听本地文件) + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = inputAccept; + input.multiple = true; + input.onchange = () => { + const files = Array.from(input.files || []); + resolve(files.map((file) => ({ file, handle: null }))); + }; + input.click(); + }); +} + +export function pickScriptFiles(): Promise { + return pick([{ description: "JavaScript", accept: { "text/javascript": [".js"] } }], ".js"); +} + +export function pickSkillZip(): Promise { + return pick([{ description: "Skill Package", accept: { "application/zip": [".zip"] } }], ".zip"); +} From 7f52eede5c906769e85013a3d33e95fe11cfc6c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 17:53:02 +0800 Subject: [PATCH 36/97] =?UTF-8?q?=E2=9C=A8=20useScriptDropzone:=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E6=8B=96=E6=8B=BD=20hook(=E4=BB=85=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=A7=A6=E5=8F=91,=E5=85=BC=E5=AE=B9=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=9B=91=E5=90=AC=20handle,3=20=E6=B5=8B=E8=AF=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../options/layout/useScriptDropzone.test.ts | 42 +++++++++++ src/pages/options/layout/useScriptDropzone.ts | 70 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/pages/options/layout/useScriptDropzone.test.ts create mode 100644 src/pages/options/layout/useScriptDropzone.ts diff --git a/src/pages/options/layout/useScriptDropzone.test.ts b/src/pages/options/layout/useScriptDropzone.test.ts new file mode 100644 index 000000000..e4d738830 --- /dev/null +++ b/src/pages/options/layout/useScriptDropzone.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useScriptDropzone } from "./useScriptDropzone"; + +function dragEvent(type: string, opts: { types?: string[]; files?: File[] } = {}) { + const ev = new Event(type, { bubbles: true, cancelable: true }) as any; + ev.dataTransfer = { + types: opts.types ?? [], + files: opts.files ?? [], + items: (opts.files ?? []).map((f) => ({ kind: "file", getAsFile: () => f })), + }; + return ev; +} + +describe("useScriptDropzone", () => { + it("拖入文件时 isDragActive 为 true,离开后为 false", () => { + const { result } = renderHook(() => useScriptDropzone(() => {})); + act(() => { window.dispatchEvent(dragEvent("dragenter", { types: ["Files"] })); }); + expect(result.current.isDragActive).toBe(true); + act(() => { window.dispatchEvent(dragEvent("dragleave", { types: ["Files"] })); }); + expect(result.current.isDragActive).toBe(false); + }); + + it("拖入非文件(元素排序)不激活遮罩", () => { + const { result } = renderHook(() => useScriptDropzone(() => {})); + act(() => { window.dispatchEvent(dragEvent("dragenter", { types: ["text/plain"] })); }); + expect(result.current.isDragActive).toBe(false); + }); + + it("drop 文件时回调收到 items 且遮罩关闭", async () => { + const onFiles = vi.fn(); + const { result } = renderHook(() => useScriptDropzone(onFiles)); + const file = new File(["// ==UserScript=="], "a.user.js"); + await act(async () => { + window.dispatchEvent(dragEvent("dragenter", { types: ["Files"] })); + window.dispatchEvent(dragEvent("drop", { types: ["Files"], files: [file] })); + await Promise.resolve(); + }); + expect(onFiles).toHaveBeenCalledWith([{ file, handle: null }]); + expect(result.current.isDragActive).toBe(false); + }); +}); diff --git a/src/pages/options/layout/useScriptDropzone.ts b/src/pages/options/layout/useScriptDropzone.ts new file mode 100644 index 000000000..351ee3720 --- /dev/null +++ b/src/pages/options/layout/useScriptDropzone.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef, useState } from "react"; +import type { ImportItem } from "@App/pages/options/routes/ScriptList/importHandler"; + +const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes("Files"); + +export function useScriptDropzone(onFiles: (items: ImportItem[]) => void): { isDragActive: boolean } { + const [isDragActive, setActive] = useState(false); + const counter = useRef(0); + const onFilesRef = useRef(onFiles); + onFilesRef.current = onFiles; + + useEffect(() => { + const onEnter = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + counter.current++; + setActive(true); + }; + const onOver = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + }; + const onLeave = (e: DragEvent) => { + if (!hasFiles(e)) return; + counter.current--; + if (counter.current <= 0) { + counter.current = 0; + setActive(false); + } + }; + const onDrop = async (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + counter.current = 0; + setActive(false); + const dt = e.dataTransfer!; + const items: ImportItem[] = []; + const dtItems = Array.from(dt.items || []).filter((it) => it.kind === "file"); + if (dtItems.length) { + await Promise.all( + dtItems.map(async (it) => { + let handle: FileSystemFileHandle | null = null; + if ("getAsFileSystemHandle" in it) { + // Chrome 专有:取 FileSystemFileHandle 以支持本地文件监听;Firefox/Safari 无此 API,回退 getAsFile + const h = await (it as any).getAsFileSystemHandle().catch(() => null); + if (h && h.kind === "file") handle = h as FileSystemFileHandle; + } + const file = handle ? await handle.getFile() : it.getAsFile(); + if (file) items.push({ file, handle }); + }) + ); + } else { + for (const file of Array.from(dt.files)) items.push({ file, handle: null }); + } + if (items.length) onFilesRef.current(items); + }; + window.addEventListener("dragenter", onEnter); + window.addEventListener("dragover", onOver); + window.addEventListener("dragleave", onLeave); + window.addEventListener("drop", onDrop); + return () => { + window.removeEventListener("dragenter", onEnter); + window.removeEventListener("dragover", onOver); + window.removeEventListener("dragleave", onLeave); + window.removeEventListener("drop", onDrop); + }; + }, []); + + return { isDragActive }; +} From 85f2bb7a6cfb55eaad6a73d8dfd4b5c02941ca0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 17:54:53 +0800 Subject: [PATCH 37/97] =?UTF-8?q?=E2=9C=A8=20DropOverlay:=E6=8B=96?= =?UTF-8?q?=E6=8B=BD=E5=AE=89=E8=A3=85=E6=AF=9B=E7=8E=BB=E7=92=83=E9=81=AE?= =?UTF-8?q?=E7=BD=A9=E7=BB=84=E4=BB=B6(=E6=8A=95=E6=94=BE=E5=8D=A1,2=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/options/layout/DropOverlay.test.tsx | 14 ++++++++++ src/pages/options/layout/DropOverlay.tsx | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/pages/options/layout/DropOverlay.test.tsx create mode 100644 src/pages/options/layout/DropOverlay.tsx diff --git a/src/pages/options/layout/DropOverlay.test.tsx b/src/pages/options/layout/DropOverlay.test.tsx new file mode 100644 index 000000000..c66a0cf78 --- /dev/null +++ b/src/pages/options/layout/DropOverlay.test.tsx @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { DropOverlay } from "./DropOverlay"; + +describe("DropOverlay", () => { + it("active=false 时不渲染", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + it("active=true 时显示提示文案", () => { + render(); + expect(screen.getByTestId("drop-overlay")).toBeInTheDocument(); + }); +}); diff --git a/src/pages/options/layout/DropOverlay.tsx b/src/pages/options/layout/DropOverlay.tsx new file mode 100644 index 000000000..82b478598 --- /dev/null +++ b/src/pages/options/layout/DropOverlay.tsx @@ -0,0 +1,27 @@ +import { Download } from "lucide-react"; +import { t } from "@App/locales/locales"; + +export function DropOverlay({ active }: { active: boolean }) { + if (!active) return null; + return ( +
+
+
+ +
+
{t("script:drop_to_install")}
+
{t("script:drop_to_install_hint")}
+
+ {[".user.js", ".sub.js", ".zip"].map((x) => ( + + {x} + + ))} +
+
+
+ ); +} From 070494541b91516a63885ac423a54a8797efd7b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 18:06:55 +0800 Subject: [PATCH 38/97] =?UTF-8?q?=E2=9C=A8=20Agent=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5:=E5=85=B1=E4=BA=AB=E5=9F=BA=E7=A1=80=E7=BB=84?= =?UTF-8?q?=E4=BB=B6(=E9=A1=B5=E5=A4=B4/=E7=A9=BA=E7=8A=B6=E6=80=81/kebab?= =?UTF-8?q?=20=E8=8F=9C=E5=8D=95/=E6=A0=87=E7=AD=BE)+=20jsdom=20=E6=8C=87?= =?UTF-8?q?=E9=92=88=E5=9E=AB=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/_agent/AgentCardMenu.test.tsx | 17 ++++++ .../options/routes/_agent/AgentCardMenu.tsx | 50 ++++++++++++++++ .../routes/_agent/AgentEmptyState.test.tsx | 22 +++++++ .../options/routes/_agent/AgentEmptyState.tsx | 28 +++++++++ .../routes/_agent/AgentPageHeader.test.tsx | 18 ++++++ .../options/routes/_agent/AgentPageHeader.tsx | 30 ++++++++++ src/pages/options/routes/_agent/tags.test.tsx | 20 +++++++ src/pages/options/routes/_agent/tags.tsx | 60 +++++++++++++++++++ tests/vitest.setup.ts | 22 +++++++ 9 files changed, 267 insertions(+) create mode 100644 src/pages/options/routes/_agent/AgentCardMenu.test.tsx create mode 100644 src/pages/options/routes/_agent/AgentCardMenu.tsx create mode 100644 src/pages/options/routes/_agent/AgentEmptyState.test.tsx create mode 100644 src/pages/options/routes/_agent/AgentEmptyState.tsx create mode 100644 src/pages/options/routes/_agent/AgentPageHeader.test.tsx create mode 100644 src/pages/options/routes/_agent/AgentPageHeader.tsx create mode 100644 src/pages/options/routes/_agent/tags.test.tsx create mode 100644 src/pages/options/routes/_agent/tags.tsx diff --git a/src/pages/options/routes/_agent/AgentCardMenu.test.tsx b/src/pages/options/routes/_agent/AgentCardMenu.test.tsx new file mode 100644 index 000000000..315cf4c07 --- /dev/null +++ b/src/pages/options/routes/_agent/AgentCardMenu.test.tsx @@ -0,0 +1,17 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup, screen, fireEvent } from "@testing-library/react"; +import { Pencil } from "lucide-react"; +import { AgentCardMenu } from "./AgentCardMenu"; + +afterEach(() => cleanup()); + +describe("AgentCardMenu 卡片菜单", () => { + it("点击菜单项触发 onSelect", () => { + const onSelect = vi.fn(); + render(); + // Radix 触发器在 pointerdown(左键) 时展开菜单——真实点击即包含此事件 + fireEvent.pointerDown(screen.getByTestId("card-menu"), { button: 0 }); + fireEvent.click(screen.getByTestId("card-menu-edit")); + expect(onSelect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/pages/options/routes/_agent/AgentCardMenu.tsx b/src/pages/options/routes/_agent/AgentCardMenu.tsx new file mode 100644 index 000000000..a19375515 --- /dev/null +++ b/src/pages/options/routes/_agent/AgentCardMenu.tsx @@ -0,0 +1,50 @@ +import type { LucideIcon } from "lucide-react"; +import { MoreVertical } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@App/pages/components/ui/dropdown-menu"; +import { cn } from "@App/pkg/utils/cn"; + +export interface AgentCardMenuItem { + key: string; + label: string; + icon?: LucideIcon; + danger?: boolean; + onSelect: () => void; +} + +// 卡片右上角 kebab 菜单:常驻 ⋮ 触发器 + 下拉项(danger 项标红) +export function AgentCardMenu({ items }: { items: AgentCardMenuItem[] }) { + return ( + + + + + + {items.map((item) => { + const Icon = item.icon; + return ( + + {Icon && } + {item.label} + + ); + })} + + + ); +} diff --git a/src/pages/options/routes/_agent/AgentEmptyState.test.tsx b/src/pages/options/routes/_agent/AgentEmptyState.test.tsx new file mode 100644 index 000000000..dffccbfd5 --- /dev/null +++ b/src/pages/options/routes/_agent/AgentEmptyState.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup, screen } from "@testing-library/react"; +import { Server } from "lucide-react"; +import { AgentEmptyState } from "./AgentEmptyState"; + +afterEach(() => cleanup()); + +describe("AgentEmptyState 空状态", () => { + it("渲染标题/说明/操作", () => { + render( + 添加模型} + /> + ); + expect(screen.getByText("还没有配置模型")).toBeInTheDocument(); + expect(screen.getByText("添加第一个模型")).toBeInTheDocument(); + expect(screen.getByText("添加模型")).toBeInTheDocument(); + }); +}); diff --git a/src/pages/options/routes/_agent/AgentEmptyState.tsx b/src/pages/options/routes/_agent/AgentEmptyState.tsx new file mode 100644 index 000000000..a31f98df4 --- /dev/null +++ b/src/pages/options/routes/_agent/AgentEmptyState.tsx @@ -0,0 +1,28 @@ +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +// Agent 管理页空状态:居中图标块 + 标题 + 说明 + 主操作 +export function AgentEmptyState({ + icon: Icon, + title, + description, + action, +}: { + icon: LucideIcon; + title: string; + description: string; + action?: ReactNode; +}) { + return ( +
+
+ +
+
+

{title}

+

{description}

+
+ {action} +
+ ); +} diff --git a/src/pages/options/routes/_agent/AgentPageHeader.test.tsx b/src/pages/options/routes/_agent/AgentPageHeader.test.tsx new file mode 100644 index 000000000..49da1c9f0 --- /dev/null +++ b/src/pages/options/routes/_agent/AgentPageHeader.test.tsx @@ -0,0 +1,18 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup, screen } from "@testing-library/react"; +import { Server } from "lucide-react"; +import { AgentPageHeader } from "./AgentPageHeader"; + +afterEach(() => cleanup()); + +describe("AgentPageHeader 统一页头", () => { + it("渲染标题与副标题", () => { + render(); + expect(screen.getByText("模型服务")).toBeInTheDocument(); + expect(screen.getByText("管理 AI 模型提供商")).toBeInTheDocument(); + }); + it("渲染右侧操作区", () => { + render(添加} />); + expect(screen.getByText("添加")).toBeInTheDocument(); + }); +}); diff --git a/src/pages/options/routes/_agent/AgentPageHeader.tsx b/src/pages/options/routes/_agent/AgentPageHeader.tsx new file mode 100644 index 000000000..e222a8a38 --- /dev/null +++ b/src/pages/options/routes/_agent/AgentPageHeader.tsx @@ -0,0 +1,30 @@ +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +// Agent 管理页统一 64px 页头:图标块 + 标题/副标题 + 右侧操作区 +export function AgentPageHeader({ + icon: Icon, + title, + subtitle, + actions, +}: { + icon: LucideIcon; + title: string; + subtitle: string; + actions?: ReactNode; +}) { + return ( +
+
+
+ +
+
+ {title} + {subtitle} +
+
+ {actions &&
{actions}
} +
+ ); +} diff --git a/src/pages/options/routes/_agent/tags.test.tsx b/src/pages/options/routes/_agent/tags.test.tsx new file mode 100644 index 000000000..5e447c45d --- /dev/null +++ b/src/pages/options/routes/_agent/tags.test.tsx @@ -0,0 +1,20 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup, screen } from "@testing-library/react"; +import { StatusDot, CapabilityTag } from "./tags"; + +afterEach(() => cleanup()); + +describe("tags 标签", () => { + it("StatusDot 渲染文本与 success 语义色", () => { + render(已连接); + const el = screen.getByText("已连接"); + expect(el).toBeInTheDocument(); + expect(el.className).toContain("text-success-fg"); + }); + it("CapabilityTag 渲染文本与 blue 语义色", () => { + render(视觉); + const el = screen.getByText("视觉"); + expect(el).toBeInTheDocument(); + expect(el.className).toContain("text-primary"); + }); +}); diff --git a/src/pages/options/routes/_agent/tags.tsx b/src/pages/options/routes/_agent/tags.tsx new file mode 100644 index 000000000..754dfcc15 --- /dev/null +++ b/src/pages/options/routes/_agent/tags.tsx @@ -0,0 +1,60 @@ +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { cn } from "@App/pkg/utils/cn"; + +type DotTone = "success" | "error" | "muted"; + +const DOT_TONES: Record = { + success: { pill: "bg-success-bg text-success-fg", dot: "bg-success" }, + error: { pill: "bg-destructive/10 text-destructive", dot: "bg-destructive" }, + muted: { pill: "bg-muted text-muted-foreground", dot: "bg-muted-foreground" }, +}; + +// 状态圆点胶囊:连接/运行状态等 +export function StatusDot({ tone, children }: { tone: DotTone; children: ReactNode }) { + const t = DOT_TONES[tone]; + return ( + + + {children} + + ); +} + +type CapTone = "blue" | "green" | "violet" | "orange" | "muted"; + +const CAP_TONES: Record = { + blue: "bg-primary/10 text-primary", + green: "bg-success-bg text-success-fg", + violet: "bg-violet-500/12 text-violet-600 dark:bg-violet-400/15 dark:text-violet-300", + orange: "bg-warning-bg text-warning-fg", + muted: "bg-muted text-muted-foreground", +}; + +// 能力/属性小标签:视觉/图像/工具数等 +export function CapabilityTag({ + tone, + icon: Icon, + children, +}: { + tone: CapTone; + icon?: LucideIcon; + children: ReactNode; +}) { + return ( + + {Icon && } + {children} + + ); +} diff --git a/tests/vitest.setup.ts b/tests/vitest.setup.ts index 2282a9678..f1b4f8b53 100644 --- a/tests/vitest.setup.ts +++ b/tests/vitest.setup.ts @@ -303,3 +303,25 @@ vi.stubGlobal("define", "特殊关键字不能穿透沙盒"); if (!URL.createObjectURL) URL.createObjectURL = undefined; //@ts-expect-error if (!URL.revokeObjectURL) URL.revokeObjectURL = undefined; + +// ---- Radix UI(DropdownMenu / Select / Sheet 等)在 jsdom 下所需的指针 API 垫片 ---- +// jsdom 未实现 PointerEvent,导致 Radix 触发器的 `event.button === 0` 判断失效、菜单无法展开; +// 同时缺少指针捕获与 scrollIntoView。下面补齐这些浏览器原生 API,仅用于测试环境。 +if (typeof (globalThis as any).PointerEvent === "undefined") { + class PointerEventPolyfill extends MouseEvent { + public pointerId?: number; + public pointerType?: string; + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId; + this.pointerType = params.pointerType; + } + } + vi.stubGlobal("PointerEvent", PointerEventPolyfill); +} +for (const method of ["hasPointerCapture", "setPointerCapture", "releasePointerCapture", "scrollIntoView"] as const) { + if (!(method in Element.prototype)) { + // @ts-ignore 测试环境补齐 jsdom 缺失的指针/滚动方法(no-op) + Element.prototype[method] = function () {}; + } +} From 0a956dd6ada69034159e140f122529cb208f847c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 18:09:18 +0800 Subject: [PATCH 39/97] =?UTF-8?q?=E2=9C=A8=20Agent=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1:provider=5Fapi=20=E7=9B=B4=E8=BF=9E=20HTTP(?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=BF=9E=E6=8E=A5/=E6=8B=89=E5=8F=96?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/AgentProvider/provider_api.test.ts | 54 +++++++++ .../routes/AgentProvider/provider_api.ts | 105 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/pages/options/routes/AgentProvider/provider_api.test.ts create mode 100644 src/pages/options/routes/AgentProvider/provider_api.ts diff --git a/src/pages/options/routes/AgentProvider/provider_api.test.ts b/src/pages/options/routes/AgentProvider/provider_api.test.ts new file mode 100644 index 000000000..727fdc75a --- /dev/null +++ b/src/pages/options/routes/AgentProvider/provider_api.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi } from "vitest"; +import { buildModelsRequest, testConnection, fetchModels, getDefaultBaseUrl } from "./provider_api"; + +const m = { + id: "1", + name: "n", + provider: "openai", + apiBaseUrl: "https://api.openai.com/v1", + apiKey: "sk-x", + model: "gpt-4o", +} as any; + +describe("provider_api 直连 HTTP", () => { + it("openai 拼出 /models 与 Bearer 头", () => { + const { url, headers } = buildModelsRequest(m); + expect(url).toBe("https://api.openai.com/v1/models"); + expect(headers.Authorization).toBe("Bearer sk-x"); + }); + + it("anthropic 使用 x-api-key 与 /v1/models", () => { + const { url, headers } = buildModelsRequest({ ...m, provider: "anthropic", apiBaseUrl: "" }); + expect(url).toBe("https://api.anthropic.com/v1/models"); + expect(headers["x-api-key"]).toBe("sk-x"); + expect(headers["anthropic-version"]).toBe("2023-06-01"); + }); + + it("provider 缺省 baseUrl 时回退到默认地址", () => { + expect(getDefaultBaseUrl("openai")).toBe("https://api.openai.com/v1"); + expect(getDefaultBaseUrl("zhipu")).toBe("https://open.bigmodel.cn/api/paas/v4"); + }); + + it("testConnection 成功返回 ok 且带延迟", async () => { + const fetchImpl = vi.fn(async () => ({ ok: true, json: async () => ({}) })) as any; + const r = await testConnection(m, fetchImpl); + expect(r.ok).toBe(true); + expect(typeof r.latencyMs).toBe("number"); + }); + + it("testConnection 失败返回 error", async () => { + const fetchImpl = vi.fn(async () => ({ ok: false, status: 401, text: async () => "unauthorized" })) as any; + const r = await testConnection(m, fetchImpl); + expect(r.ok).toBe(false); + expect(r.error).toContain("401"); + }); + + it("fetchModels 解析 data[].id 列表", async () => { + const fetchImpl = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [{ id: "gpt-4o" }, { id: "gpt-4o-mini" }] }), + })) as any; + const ids = await fetchModels(m, fetchImpl); + expect(ids).toEqual(["gpt-4o", "gpt-4o-mini"]); + }); +}); diff --git a/src/pages/options/routes/AgentProvider/provider_api.ts b/src/pages/options/routes/AgentProvider/provider_api.ts new file mode 100644 index 000000000..2b445042f --- /dev/null +++ b/src/pages/options/routes/AgentProvider/provider_api.ts @@ -0,0 +1,105 @@ +import type { AgentModelConfig } from "@App/app/service/agent/core/types"; + +type Provider = AgentModelConfig["provider"]; +type FetchLike = typeof fetch; + +// 各 provider 的默认 API Base URL(与 release/v1.4-agent 保持一致) +export function getDefaultBaseUrl(provider: Provider): string { + switch (provider) { + case "anthropic": + return "https://api.anthropic.com"; + case "zhipu": + return "https://open.bigmodel.cn/api/paas/v4"; + default: + return "https://api.openai.com/v1"; + } +} + +type ModelReqInput = Pick; + +// 构造「拉取模型列表」请求的 URL 与请求头 +export function buildModelsRequest(m: ModelReqInput): { url: string; headers: Record } { + const baseUrl = m.apiBaseUrl || getDefaultBaseUrl(m.provider); + const headers: Record = {}; + let url: string; + if (m.provider === "anthropic") { + url = `${baseUrl}/v1/models`; + headers["x-api-key"] = m.apiKey; + headers["anthropic-version"] = "2023-06-01"; + headers["anthropic-dangerous-direct-browser-access"] = "true"; + } else { + url = `${baseUrl}/models`; + if (m.apiKey) headers["Authorization"] = `Bearer ${m.apiKey}`; + } + return { url, headers }; +} + +// 构造「测试连接」的对话补全请求(最小一次往返) +function buildChatRequest(m: AgentModelConfig): { url: string; headers: Record; body: string } { + const baseUrl = m.apiBaseUrl || getDefaultBaseUrl(m.provider); + const headers: Record = { "Content-Type": "application/json" }; + const systemMessage = "Reply in one brief sentence only. No thinking or reasoning."; + const userMessage = "Greet the user warmly in a short, concise sentence."; + if (m.provider === "anthropic") { + headers["x-api-key"] = m.apiKey; + headers["anthropic-version"] = "2023-06-01"; + headers["anthropic-dangerous-direct-browser-access"] = "true"; + return { + url: `${baseUrl}/v1/messages`, + headers, + body: JSON.stringify({ + model: m.model || "claude-sonnet-4-20250514", + max_tokens: 256, + system: systemMessage, + messages: [{ role: "user", content: userMessage }], + stream: false, + }), + }; + } + if (m.apiKey) headers["Authorization"] = `Bearer ${m.apiKey}`; + const defaultModel = m.provider === "zhipu" ? "glm-4-flash" : "gpt-4o-mini"; + return { + url: `${baseUrl}/chat/completions`, + headers, + body: JSON.stringify({ + model: m.model || defaultModel, + max_tokens: 256, + messages: [ + { role: "system", content: systemMessage }, + { role: "user", content: userMessage }, + ], + stream: false, + }), + }; +} + +// 测试连接:发送一次最小对话补全,返回成败与延迟 +export async function testConnection( + m: AgentModelConfig, + fetchImpl: FetchLike = fetch +): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + const { url, headers, body } = buildChatRequest(m); + const start = performance.now(); + try { + const resp = await fetchImpl(url, { method: "POST", headers, body }); + const latencyMs = Math.round(performance.now() - start); + if (!resp.ok) { + const errText = await resp.text().catch(() => ""); + return { ok: false, error: `${resp.status} ${errText}`.trim() }; + } + return { ok: true, latencyMs }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +// 拉取可用模型 ID 列表 +export async function fetchModels(m: AgentModelConfig, fetchImpl: FetchLike = fetch): Promise { + const { url, headers } = buildModelsRequest(m); + const resp = await fetchImpl(url, { method: "GET", headers }); + if (!resp.ok) { + throw new Error(`${resp.status}`); + } + const json = await resp.json(); + return ((json.data as { id: string }[]) || []).map((item) => item.id); +} From 92427c0452f7172595641647a6495ea07e3f90f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 17 Jun 2026 18:10:05 +0800 Subject: [PATCH 40/97] =?UTF-8?q?=E2=9C=A8=20=E5=AF=BC=E5=85=A5=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=89=A9=E5=B1=95(=E6=9C=AC=E5=9C=B0/=E9=93=BE?= =?UTF-8?q?=E6=8E=A5/Skill)+=20=E9=93=BE=E6=8E=A5=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86(4=20=E6=B5=8B=E8=AF=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScriptList/CreateScriptMenu.test.tsx | 71 +++++++++++++++ .../routes/ScriptList/CreateScriptMenu.tsx | 89 ++++++++++++------- .../ScriptList/LinkImportDialog.test.tsx | 15 ++++ .../routes/ScriptList/LinkImportDialog.tsx | 63 +++++++++++++ 4 files changed, 206 insertions(+), 32 deletions(-) create mode 100644 src/pages/options/routes/ScriptList/CreateScriptMenu.test.tsx create mode 100644 src/pages/options/routes/ScriptList/LinkImportDialog.test.tsx create mode 100644 src/pages/options/routes/ScriptList/LinkImportDialog.tsx diff --git a/src/pages/options/routes/ScriptList/CreateScriptMenu.test.tsx b/src/pages/options/routes/ScriptList/CreateScriptMenu.test.tsx new file mode 100644 index 000000000..2c130e8f2 --- /dev/null +++ b/src/pages/options/routes/ScriptList/CreateScriptMenu.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, cleanup, screen, fireEvent, act } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { initLanguage } from "@App/locales/locales"; +import { CreateScriptMenu } from "./CreateScriptMenu"; +import * as filePicker from "./filePicker"; + +vi.mock("./filePicker", () => ({ pickScriptFiles: vi.fn(async () => []), pickSkillZip: vi.fn(async () => []) })); +vi.mock("./importHandler", () => ({ handleImportFiles: vi.fn(), handleImportUrls: vi.fn() })); + +afterEach(cleanup); +beforeEach(() => { + initLanguage("zh-CN"); + vi.clearAllMocks(); +}); + +function renderMenu(variant: "default" | "icon" = "default") { + return render( + + + + ); +} + +describe("CreateScriptMenu 下拉菜单", () => { + it("hover trigger 后菜单展开,包含三个导入项", async () => { + const { getByRole } = renderMenu(); + const trigger = getByRole("button"); + + await act(async () => { + fireEvent.mouseEnter(trigger); + }); + + expect(screen.getByText("导入本地脚本")).toBeInTheDocument(); + expect(screen.getByText("链接导入")).toBeInTheDocument(); + expect(screen.getByText("导入 Skill")).toBeInTheDocument(); + }); + + it("点击「导入本地脚本」调用 pickScriptFiles", async () => { + const { getByRole } = renderMenu(); + const trigger = getByRole("button"); + + await act(async () => { + fireEvent.mouseEnter(trigger); + }); + + const importLocalItem = screen.getByText("导入本地脚本"); + await act(async () => { + fireEvent.click(importLocalItem); + }); + + expect(filePicker.pickScriptFiles).toHaveBeenCalledTimes(1); + }); + + it("点击「链接导入」打开 LinkImportDialog", async () => { + const { getByRole } = renderMenu(); + const trigger = getByRole("button"); + + await act(async () => { + fireEvent.mouseEnter(trigger); + }); + + const linkImportItem = screen.getByText("链接导入"); + await act(async () => { + fireEvent.click(linkImportItem); + }); + + // Dialog 应出现 + expect(screen.getByTestId("link-import-textarea")).toBeInTheDocument(); + }); +}); diff --git a/src/pages/options/routes/ScriptList/CreateScriptMenu.tsx b/src/pages/options/routes/ScriptList/CreateScriptMenu.tsx index e347651d3..9a9eac048 100644 --- a/src/pages/options/routes/ScriptList/CreateScriptMenu.tsx +++ b/src/pages/options/routes/ScriptList/CreateScriptMenu.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { Plus, ChevronDown } from "lucide-react"; import { Button } from "@App/pages/components/ui/button"; @@ -5,55 +6,79 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@App/pages/components/ui/dropdown-menu"; import { useHoverMenu } from "@App/pages/components/ui/use-hover-menu"; import { t } from "@App/locales/locales"; +import { pickScriptFiles, pickSkillZip } from "./filePicker"; +import { handleImportFiles, handleImportUrls } from "./importHandler"; +import { LinkImportDialog } from "./LinkImportDialog"; /** * 新建脚本下拉(hover 触发)。Toolbar 用带文字按钮(variant="default"), - * 移动 header 用 32×32 图标按钮(variant="icon")。 + * 移动 header 用 32×32 图标按钮(variant="icon")。含导入分组:本地/链接/Skill。 */ export function CreateScriptMenu({ variant = "default" }: { variant?: "default" | "icon" }) { const navigate = useNavigate(); const { close, rootProps, hoverProps, contentProps } = useHoverMenu(); + const [linkOpen, setLinkOpen] = useState(false); const handleCreate = (path: string) => { close(); navigate(path); }; + const importLocal = async () => { + close(); + const items = await pickScriptFiles(); + if (items.length) handleImportFiles(items); + }; + const importSkill = async () => { + close(); + const items = await pickSkillZip(); + if (items.length) handleImportFiles(items); + }; return ( - - - {variant === "icon" ? ( - - ) : ( - - )} - - - handleCreate("/script/editor")}> - {t("script:create_user_script")} - - handleCreate("/script/editor?template=background")}> - {t("script:create_background_script")} - - handleCreate("/script/editor?template=crontab")}> - {t("script:create_scheduled_script")} - - - + <> + + + {variant === "icon" ? ( + + ) : ( + + )} + + + handleCreate("/script/editor")}> + {t("script:create_user_script")} + + handleCreate("/script/editor?template=background")}> + {t("script:create_background_script")} + + handleCreate("/script/editor?template=crontab")}> + {t("script:create_scheduled_script")} + + + {t("script:import_local_script")} + { close(); setLinkOpen(true); }}> + {t("script:link_import")} + + {t("script:import_skill")} + + + + ); } diff --git a/src/pages/options/routes/ScriptList/LinkImportDialog.test.tsx b/src/pages/options/routes/ScriptList/LinkImportDialog.test.tsx new file mode 100644 index 000000000..598ae289b --- /dev/null +++ b/src/pages/options/routes/ScriptList/LinkImportDialog.test.tsx @@ -0,0 +1,15 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { LinkImportDialog } from "./LinkImportDialog"; + +describe("LinkImportDialog", () => { + it("提交时把多行文本拆成 URL 数组(忽略空行)", () => { + const onSubmit = vi.fn(); + render( {}} onSubmit={onSubmit} />); + fireEvent.change(screen.getByTestId("link-import-textarea"), { + target: { value: "https://a.com/a.user.js\n\nhttps://b.com/b.zip\n" }, + }); + fireEvent.click(screen.getByTestId("link-import-submit")); + expect(onSubmit).toHaveBeenCalledWith(["https://a.com/a.user.js", "https://b.com/b.zip"]); + }); +}); diff --git a/src/pages/options/routes/ScriptList/LinkImportDialog.tsx b/src/pages/options/routes/ScriptList/LinkImportDialog.tsx new file mode 100644 index 000000000..fdba2d22c --- /dev/null +++ b/src/pages/options/routes/ScriptList/LinkImportDialog.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { Download } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@App/pages/components/ui/dialog"; +import { Button } from "@App/pages/components/ui/button"; +import { t } from "@App/locales/locales"; + +export function LinkImportDialog({ + open, + onOpenChange, + onSubmit, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + onSubmit: (urls: string[]) => void; +}) { + const [text, setText] = useState(""); + const submit = () => { + const urls = text.split("\n").map((s) => s.trim()).filter(Boolean); + if (urls.length) onSubmit(urls); + onOpenChange(false); + setText(""); + }; + return ( + + + + {t("script:link_import")} + {t("script:link_import_desc")} + +