diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 4a618893e..1f39e4fa9 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -98,6 +98,14 @@ export class ScriptClient extends Client { return this.do("excludeUrl", { uuid, excludePattern, remove }); } + onlyRunOnUrl(uuid: string, matchPattern: string) { + return this.do("onlyRunOnUrl", { uuid, matchPattern }); + } + + allowUrl(uuid: string, matchPattern: string, excludePattern: string) { + return this.do("allowUrl", { uuid, matchPattern, excludePattern }); + } + // 重置匹配项 resetMatch(uuid: string, match: string[] | undefined) { return this.do("resetMatch", { uuid, match }); diff --git a/src/app/service/service_worker/script.test.ts b/src/app/service/service_worker/script.test.ts index 2159e336e..e35c2ba6c 100644 --- a/src/app/service/service_worker/script.test.ts +++ b/src/app/service/service_worker/script.test.ts @@ -658,6 +658,48 @@ describe("ScriptService selfMetadata 用户覆盖", () => { }); }); + describe("popup 站点范围快捷操作", () => { + it("仅在当前站点执行应以当前站点替换用户匹配列表", async () => { + const script = createMockScript({ selfMetadata: { match: ["*://old.example/*"] } }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.onlyRunOnUrl({ uuid: script.uuid, matchPattern: "*://current.example/*" }); + + expect(savedSelfMetadata()).toEqual({ match: ["*://current.example/*"] }); + }); + + it("自定义匹配未覆盖当前站点时应把当前站点加入允许列表", async () => { + const script = createMockScript({ + selfMetadata: { match: ["*://allowed.example/*"], exclude: ["*://current.example/*"] }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.allowUrl({ + uuid: script.uuid, + matchPattern: "*://current.example/*", + excludePattern: "*://current.example/*", + }); + + expect(savedSelfMetadata()).toEqual({ + match: ["*://allowed.example/*", "*://current.example/*"], + exclude: [], + }); + }); + + it("因排除规则不生效时应移除当前站点排除而不创建匹配覆盖", async () => { + const script = createMockScript({ selfMetadata: { exclude: ["*://current.example/*"] } }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.allowUrl({ + uuid: script.uuid, + matchPattern: "*://current.example/*", + excludePattern: "*://current.example/*", + }); + + expect(savedSelfMetadata()).toEqual({ exclude: [] }); + }); + }); + describe("resetMatch / resetExclude - 编辑器匹配列表", () => { it("传入 undefined(重置)应删除用户覆盖", async () => { const script = createMockScript({ selfMetadata: { match: ["*://user.com/*"] } }); diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index d5c3912b6..d26187877 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -925,6 +925,33 @@ export class ScriptService { }); } + async onlyRunOnUrl({ uuid, matchPattern }: { uuid: string; matchPattern: string }) { + return this.resetMatch({ uuid, match: [matchPattern] }); + } + + async allowUrl({ + uuid, + matchPattern, + excludePattern, + }: { + uuid: string; + matchPattern: string; + excludePattern: string; + }) { + let script = await this.scriptDAO.get(uuid); + if (!script) throw new Error("script not found"); + if (script.selfMetadata?.match !== undefined) { + script = selfMetadataUpdate(script, "match", new Set([...script.selfMetadata.match, matchPattern])); + } + const excludeSet = new Set(script.selfMetadata?.exclude || script.metadata?.exclude || []); + excludeSet.delete(excludePattern); + script = selfMetadataUpdate(script, "exclude", excludeSet); + return this.scriptDAO.update(uuid, script).then(() => { + this.publishInstallScript(script, { update: true }); + return true; + }); + } + async resetExclude({ uuid, exclude }: { uuid: string; exclude: string[] | undefined }) { let script = await this.scriptDAO.get(uuid); if (!script) { @@ -1668,6 +1695,8 @@ export class ScriptService { this.group.on("getFilterResult", this.getFilterResult.bind(this)); this.group.on("getScriptRunResourceByUUID", this.getScriptRunResourceByUUID.bind(this)); this.group.on("excludeUrl", this.excludeUrl.bind(this)); + this.group.on("onlyRunOnUrl", this.onlyRunOnUrl.bind(this)); + this.group.on("allowUrl", this.allowUrl.bind(this)); this.group.on("resetMatch", this.resetMatch.bind(this)); this.group.on("resetExclude", this.resetExclude.bind(this)); this.group.on("requestCheckUpdate", this.requestCheckUpdate.bind(this)); diff --git a/src/locales/de-DE/common.json b/src/locales/de-DE/common.json index df1003539..3c7a448fe 100644 --- a/src/locales/de-DE/common.json +++ b/src/locales/de-DE/common.json @@ -60,6 +60,8 @@ "copy": "Kopieren", "exclude_on": "Wiederherstellen auf $0 zur Ausführung", "exclude_off": "Ausschließen auf $0 zur Ausführung", + "only_on_site": "Nur auf $0 ausführen", + "allow_on_site": "Ausführung auf $0 zulassen", "confirm_error": "Bestätigung fehlgeschlagen", "import": "Importieren", "error": "Fehler", diff --git a/src/locales/de-DE/settings.json b/src/locales/de-DE/settings.json index d9f7b3ddb..f0e2832ff 100644 --- a/src/locales/de-DE/settings.json +++ b/src/locales/de-DE/settings.json @@ -72,6 +72,8 @@ "popup_layout": "Popup-Layout", "compact_popup_layout": "Kompaktes Popup-Layout", "compact_popup_layout_desc": "Verringert die Abstände zwischen Bereichen und Skriptzeilen im Popup", + "popup_site_scope_actions": "Aktionen für den Website-Bereich", + "popup_site_scope_actions_desc": "Zeigt im Popup Schnellaktionen an, um ein Skript auf der aktuellen Website einzuschränken oder zuzulassen", "script_update_check_frequency": "Häufigkeit der Skript-Aktualisierungsprüfung", "script_auto_update_frequency": "Frequenz der automatischen Skript-Update-Prüfung", "control_script_update_behavior": "Skript-Update-Verhalten kontrollieren", diff --git a/src/locales/en-US/common.json b/src/locales/en-US/common.json index 7ce145b36..b3599e882 100644 --- a/src/locales/en-US/common.json +++ b/src/locales/en-US/common.json @@ -60,6 +60,8 @@ "copy": "Copy", "exclude_on": "Reinstate $0's execution", "exclude_off": "Exclude $0's execution", + "only_on_site": "Run only on $0", + "allow_on_site": "Allow execution on $0", "confirm_error": "Confirmation Failed", "import": "Import", "error": "Error", diff --git a/src/locales/en-US/settings.json b/src/locales/en-US/settings.json index acff52b63..2b3b28f45 100644 --- a/src/locales/en-US/settings.json +++ b/src/locales/en-US/settings.json @@ -72,6 +72,8 @@ "popup_layout": "Popup Layout", "compact_popup_layout": "Compact Popup Layout", "compact_popup_layout_desc": "Reduce spacing between popup sections and script rows", + "popup_site_scope_actions": "Site scope actions", + "popup_site_scope_actions_desc": "Show quick actions in the popup to restrict or allow a script on the current site", "script_update_check_frequency": "Script Update Check Frequency", "script_auto_update_frequency": "Script automatic update check frequency", "control_script_update_behavior": "Control script update behavior", diff --git a/src/locales/ja-JP/common.json b/src/locales/ja-JP/common.json index 1919d18e8..72d09484d 100644 --- a/src/locales/ja-JP/common.json +++ b/src/locales/ja-JP/common.json @@ -60,6 +60,8 @@ "copy": "コピー", "exclude_on": "$0の実行を復元", "exclude_off": "$0の実行を除外", + "only_on_site": "$0 でのみ実行", + "allow_on_site": "$0 での実行を許可", "confirm_error": "確認に失敗しました", "import": "インポート", "error": "エラー", diff --git a/src/locales/ja-JP/settings.json b/src/locales/ja-JP/settings.json index f212a11a1..4c418916f 100644 --- a/src/locales/ja-JP/settings.json +++ b/src/locales/ja-JP/settings.json @@ -72,6 +72,8 @@ "popup_layout": "ポップアップレイアウト", "compact_popup_layout": "コンパクトなポップアップ", "compact_popup_layout_desc": "ポップアップのセクションとスクリプト行の間隔を狭くします", + "popup_site_scope_actions": "サイト範囲の操作", + "popup_site_scope_actions_desc": "現在のサイトでスクリプトの実行を制限または許可するクイック操作をポップアップに表示します", "script_update_check_frequency": "スクリプト更新の確認頻度", "script_auto_update_frequency": "スクリプトの自動更新確認頻度", "control_script_update_behavior": "スクリプト更新の動作を制御", diff --git a/src/locales/ko-KR/common.json b/src/locales/ko-KR/common.json index 754291c08..90254478a 100644 --- a/src/locales/ko-KR/common.json +++ b/src/locales/ko-KR/common.json @@ -60,6 +60,8 @@ "copy": "복사", "exclude_on": "$0에서 다시 실행", "exclude_off": "$0에서 실행 제외", + "only_on_site": "$0에서만 실행", + "allow_on_site": "$0에서 실행 허용", "confirm_error": "확인 실패", "import": "가져오기", "error": "오류", diff --git a/src/locales/ko-KR/settings.json b/src/locales/ko-KR/settings.json index ab94b9b1c..f3a3740b2 100644 --- a/src/locales/ko-KR/settings.json +++ b/src/locales/ko-KR/settings.json @@ -72,6 +72,8 @@ "popup_layout": "팝업 레이아웃", "compact_popup_layout": "간격을 줄인 팝업 레이아웃", "compact_popup_layout_desc": "팝업 섹션과 스크립트 행 사이의 간격을 줄입니다", + "popup_site_scope_actions": "사이트 범위 작업", + "popup_site_scope_actions_desc": "현재 사이트에서 스크립트를 제한하거나 허용하는 빠른 작업을 팝업에 표시합니다", "script_update_check_frequency": "스크립트 업데이트 확인 주기", "script_auto_update_frequency": "스크립트 자동 업데이트 확인 주기", "control_script_update_behavior": "스크립트 업데이트 동작 제어", diff --git a/src/locales/pt-BR/common.json b/src/locales/pt-BR/common.json index ed59c3cba..e8714ad99 100644 --- a/src/locales/pt-BR/common.json +++ b/src/locales/pt-BR/common.json @@ -60,6 +60,8 @@ "copy": "Copiar", "exclude_on": "Restaurar a execução de $0", "exclude_off": "Impedir a execução em $0", + "only_on_site": "Executar somente em $0", + "allow_on_site": "Permitir execução em $0", "confirm_error": "Falha na confirmação", "import": "Importar", "error": "Erro", @@ -94,4 +96,4 @@ "s3_secret_access_key": "Chave de acesso secreta", "s3_custom_endpoint": "Endpoint personalizado (opcional)", "cancel": "Cancelar" -} \ No newline at end of file +} diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index 104625aff..0fa35ce8d 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -72,6 +72,8 @@ "popup_layout": "Layout do popup", "compact_popup_layout": "Layout compacto do popup", "compact_popup_layout_desc": "Reduzir o espaçamento entre as seções do popup e as linhas de scripts", + "popup_site_scope_actions": "Ações de escopo do site", + "popup_site_scope_actions_desc": "Mostrar no popup ações rápidas para restringir ou permitir um script no site atual", "script_update_check_frequency": "Frequência de verificação de atualização de scripts", "script_auto_update_frequency": "Frequência de verificação automática de atualização de scripts", "control_script_update_behavior": "Controlar o comportamento de atualização de scripts", diff --git a/src/locales/ru-RU/common.json b/src/locales/ru-RU/common.json index 745001a42..06c75092d 100644 --- a/src/locales/ru-RU/common.json +++ b/src/locales/ru-RU/common.json @@ -60,6 +60,8 @@ "copy": "Копировать", "exclude_on": "Восстановить в $0 выполнении", "exclude_off": "Исключить в $0 выполнении", + "only_on_site": "Выполнять только на $0", + "allow_on_site": "Разрешить выполнение на $0", "confirm_error": "Ошибка подтверждения", "import": "Импорт", "error": "Ошибка", diff --git a/src/locales/ru-RU/settings.json b/src/locales/ru-RU/settings.json index 1cbd5eb2d..7aae1041a 100644 --- a/src/locales/ru-RU/settings.json +++ b/src/locales/ru-RU/settings.json @@ -72,6 +72,8 @@ "popup_layout": "Макет всплывающего окна", "compact_popup_layout": "Компактное всплывающее окно", "compact_popup_layout_desc": "Уменьшает отступы между разделами и строками скриптов во всплывающем окне", + "popup_site_scope_actions": "Действия для области сайтов", + "popup_site_scope_actions_desc": "Показывать во всплывающем окне быстрые действия для ограничения или разрешения скрипта на текущем сайте", "script_update_check_frequency": "Частота проверки обновления скрипта", "script_auto_update_frequency": "Частота автоматической проверки обновлений скриптов", "control_script_update_behavior": "Управление поведением обновления скриптов", diff --git a/src/locales/tr-TR/common.json b/src/locales/tr-TR/common.json index 0f93f6400..e6d804e88 100644 --- a/src/locales/tr-TR/common.json +++ b/src/locales/tr-TR/common.json @@ -60,6 +60,8 @@ "copy": "Kopyala", "exclude_on": "$0 yürütmesini yeniden etkinleştir", "exclude_off": "$0 yürütmesini dışla", + "only_on_site": "Yalnızca $0 üzerinde çalıştır", + "allow_on_site": "$0 üzerinde çalışmasına izin ver", "confirm_error": "Onay başarısız", "import": "İçe Aktar", "error": "Hata", diff --git a/src/locales/tr-TR/settings.json b/src/locales/tr-TR/settings.json index 73a396c8e..18053b658 100644 --- a/src/locales/tr-TR/settings.json +++ b/src/locales/tr-TR/settings.json @@ -72,6 +72,8 @@ "popup_layout": "Açılır Pencere Düzeni", "compact_popup_layout": "Kompakt Açılır Pencere Düzeni", "compact_popup_layout_desc": "Açılır penceredeki bölümler ve betik satırları arasındaki boşluğu azaltır", + "popup_site_scope_actions": "Site kapsamı işlemleri", + "popup_site_scope_actions_desc": "Geçerli sitede bir betiği kısıtlamak veya çalışmasına izin vermek için açılır pencerede hızlı işlemler gösterir", "script_update_check_frequency": "Betik Güncelleme Denetimi Sıklığı", "script_auto_update_frequency": "Betik Otomatik Güncelleme Denetimi Sıklığı", "control_script_update_behavior": "Betik güncelleme davranışını kontrol et", @@ -100,16 +102,16 @@ "title": "Arka Planda Çalıştırmayı Etkinleştir", "description": "Etkinleştirildiğinde, tüm pencereleri kapattıktan sonra tarayıcı arka planda çalışmaya devam eder ve siz tarayıcıyı manuel olarak kapatana kadar sistem tepsisine küçülür. Bu, arka plan betiklerinin çalışmaya devam etmesini sağlar.", "enable_failed": "Etkinleştirilemedi", -"enable_success": "Etkinleştirildi", -"disable_failed": "Devre Dışı Bırakılamadı", -"disable_success": "Devre Dışı Bırakıldı", -"prompt_title": "Arka planda çalıştırma etkinleştirilsin mi?", -"prompt_description": "Bu bir {{scriptType}}. Arka planda çalıştırmayı etkinleştirmek, tarayıcı kapatıldıktan sonra betiğin çalışmaya devam etmesini sağlar.", + "enable_success": "Etkinleştirildi", + "disable_failed": "Devre Dışı Bırakılamadı", + "disable_success": "Devre Dışı Bırakıldı", + "prompt_title": "Arka planda çalıştırma etkinleştirilsin mi?", + "prompt_description": "Bu bir {{scriptType}}. Arka planda çalıştırmayı etkinleştirmek, tarayıcı kapatıldıktan sonra betiğin çalışmaya devam etmesini sağlar.", "enable_now": "Şimdi Etkinleştir", "maybe_later": "Daha Sonra", "settings_hint": "Bu seçeneği istediğiniz zaman ayarlardan değiştirebilirsiniz." }, -"keep_scripts_alive": { + "keep_scripts_alive": { "title": "Arka Plan ve Zamanlanmış Betikleri Canlı Tut", "description": "ScriptCat'in arka plan çalışma ortamını etkin tutarak Arka Plan Betikleri ve Zamanlanmış Betiklerin çalışmaya devam etmesini sağlar. Bu özellik deneyseldir, tarayıcı tarafından garanti edilmez ve biraz daha fazla kaynak kullanabilir.", "enable_failed": "Etkinleştirilemedi", diff --git a/src/locales/vi-VN/common.json b/src/locales/vi-VN/common.json index 7f6077493..b214d9e76 100644 --- a/src/locales/vi-VN/common.json +++ b/src/locales/vi-VN/common.json @@ -60,6 +60,8 @@ "copy": "Sao chép", "exclude_on": "Cho phép chạy lại $0", "exclude_off": "Loại trừ chạy $0", + "only_on_site": "Chỉ chạy trên $0", + "allow_on_site": "Cho phép chạy trên $0", "confirm_error": "Xác nhận thất bại", "import": "Nhập", "error": "Lỗi", diff --git a/src/locales/vi-VN/settings.json b/src/locales/vi-VN/settings.json index bb4f0d8da..9561ad69b 100644 --- a/src/locales/vi-VN/settings.json +++ b/src/locales/vi-VN/settings.json @@ -72,6 +72,8 @@ "popup_layout": "Bố cục cửa sổ bật lên", "compact_popup_layout": "Bố cục cửa sổ bật lên thu gọn", "compact_popup_layout_desc": "Giảm khoảng cách giữa các mục và hàng script trong cửa sổ bật lên", + "popup_site_scope_actions": "Thao tác phạm vi trang", + "popup_site_scope_actions_desc": "Hiển thị thao tác nhanh trong cửa sổ bật lên để giới hạn hoặc cho phép script trên trang hiện tại", "script_update_check_frequency": "Tần suất kiểm tra cập nhật tập lệnh", "script_auto_update_frequency": "Tần suất kiểm tra cập nhật script tự động", "control_script_update_behavior": "Kiểm soát hành vi cập nhật script", diff --git a/src/locales/zh-CN/common.json b/src/locales/zh-CN/common.json index 1d6fce2ac..1394b0048 100644 --- a/src/locales/zh-CN/common.json +++ b/src/locales/zh-CN/common.json @@ -60,6 +60,8 @@ "copy": "复制", "exclude_on": "恢复在 $0 上执行", "exclude_off": "排除在 $0 上执行", + "only_on_site": "仅在 $0 执行", + "allow_on_site": "允许在 $0 执行", "confirm_error": "确认失败", "import": "导入", "error": "错误", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index 660aa1650..0495394c8 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -72,6 +72,8 @@ "popup_layout": "弹窗布局", "compact_popup_layout": "紧凑弹窗布局", "compact_popup_layout_desc": "减少弹窗分组与脚本行之间的间距", + "popup_site_scope_actions": "站点范围快捷操作", + "popup_site_scope_actions_desc": "在弹窗中显示仅在当前站点执行或允许当前站点执行的快捷操作", "script_update_check_frequency": "脚本更新检查频率", "script_auto_update_frequency": "脚本自动检查更新的频率", "control_script_update_behavior": "控制脚本更新的行为", diff --git a/src/locales/zh-TW/common.json b/src/locales/zh-TW/common.json index 9d6e0c2fd..e16ac471a 100644 --- a/src/locales/zh-TW/common.json +++ b/src/locales/zh-TW/common.json @@ -60,6 +60,8 @@ "copy": "複製", "exclude_on": "恢復 $0 的執行", "exclude_off": "排除 $0 的執行", + "only_on_site": "僅在 $0 執行", + "allow_on_site": "允許在 $0 執行", "confirm_error": "確認失敗", "import": "匯入", "error": "錯誤", diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index edbd54d54..025434a49 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -72,6 +72,8 @@ "popup_layout": "彈出視窗版面", "compact_popup_layout": "緊湊彈出視窗版面", "compact_popup_layout_desc": "縮小彈出視窗區段與腳本列的間距", + "popup_site_scope_actions": "站點範圍快速操作", + "popup_site_scope_actions_desc": "在彈出視窗中顯示僅在目前站點執行或允許目前站點執行的快速操作", "script_update_check_frequency": "腳本更新檢查頻率", "script_auto_update_frequency": "腳本自動檢查更新的頻率", "control_script_update_behavior": "控制腳本更新的行為", diff --git a/src/pages/options/routes/ScriptEditor/index.tsx b/src/pages/options/routes/ScriptEditor/index.tsx index 034cead62..bb4fe3a41 100644 --- a/src/pages/options/routes/ScriptEditor/index.tsx +++ b/src/pages/options/routes/ScriptEditor/index.tsx @@ -118,13 +118,15 @@ export default function ScriptEditor() { return; } const code = await loadScriptCode(uuid); - dispatch({ type: "open", tab: { uuid, script, code, subView: "code", isChanged: false } }); + const requestedView = searchParams.get("view"); + const subView: SubView = requestedView === "setting" ? "setting" : "code"; + dispatch({ type: "open", tab: { uuid, script, code, subView, isChanged: false } }); } else { const tab = await emptyScript(template || "", target); dispatch({ type: "open", tab }); } }, - [t] + [searchParams, t] ); // 初始化:列表就绪后根据 URL uuid 打开 diff --git a/src/pages/options/routes/Setting/sections/InterfaceSection.test.tsx b/src/pages/options/routes/Setting/sections/InterfaceSection.test.tsx index 5c20a558e..e0cc509fb 100644 --- a/src/pages/options/routes/Setting/sections/InterfaceSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/InterfaceSection.test.tsx @@ -55,4 +55,15 @@ describe("界面分区-popup 布局", () => { fireEvent.click(compactSwitch); expect(set).toHaveBeenCalledWith("popup_compact_layout", false); }); + + it("站点范围快捷操作应默认关闭并保存开启结果", async () => { + get.mockResolvedValue(undefined); + render( () => {}} />); + + const siteScopeSwitch = await screen.findByRole("switch", { name: "站点范围快捷操作" }); + expect(siteScopeSwitch).not.toBeChecked(); + + fireEvent.click(siteScopeSwitch); + expect(set).toHaveBeenCalledWith("popup_site_scope_actions", true); + }); }); diff --git a/src/pages/options/routes/Setting/sections/InterfaceSection.tsx b/src/pages/options/routes/Setting/sections/InterfaceSection.tsx index 0288ff09d..04aae91e1 100644 --- a/src/pages/options/routes/Setting/sections/InterfaceSection.tsx +++ b/src/pages/options/routes/Setting/sections/InterfaceSection.tsx @@ -16,6 +16,7 @@ export function InterfaceSection({ register }: { register: (id: string) => (el: const [expandNum, setExpandNum] = useSystemConfig("menu_expand_num"); const [favicon, setFavicon] = useSystemConfig("favicon_service"); const [popupCompactLayout, setPopupCompactLayout] = useSystemConfig("popup_compact_layout"); + const [popupSiteScopeActions, setPopupSiteScopeActions] = useSystemConfig("popup_site_scope_actions"); return ( @@ -56,6 +57,16 @@ export function InterfaceSection({ register }: { register: (id: string) => (el: onCheckedChange={setPopupCompactLayout} /> + + +
{t("settings:script_menu")}
= {}) { showAlert: false, menuExpandNum: 5, popupCompactLayout: false, + popupSiteScopeActions: false, defaultScriptProvider: "scriptcat", currentUrl: "https://example.com", handleToggleScript: vi.fn(), handleDeleteScript: vi.fn(), handleOpenEditor: vi.fn(), + handleOpenScriptSettings: vi.fn(), handleOpenUserConfig: vi.fn(), handleExcludeUrl: vi.fn(), + handleOnlyRunOnUrl: vi.fn(), + handleAllowUrl: vi.fn(), handleMenuClick: vi.fn(), handleRunScript: vi.fn(), handleStopScript: vi.fn(), @@ -156,6 +160,49 @@ describe("Popup 紧凑布局", () => { }); }); +describe("Popup 脚本快捷设置与站点范围操作", () => { + it("展开脚本后始终显示脚本设置入口,并在开关关闭时隐藏站点范围操作", () => { + mockData = makeData({ + scriptList: [makeScriptMenu({ isEffective: true })], + fullScriptCount: 1, + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Script A/ })); + + expect(screen.getByRole("button", { name: "脚本设置" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "仅在 example.com 执行" })).not.toBeInTheDocument(); + }); + + it("开关开启且本站生效时显示仅在与排除两个动作", () => { + mockData = makeData({ + popupSiteScopeActions: true, + scriptList: [makeScriptMenu({ isEffective: true })], + fullScriptCount: 1, + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Script A/ })); + + expect(screen.getByRole("button", { name: "仅在 example.com 执行" })).toHaveClass("text-primary"); + expect(screen.getByRole("button", { name: "排除在 example.com 上执行" })).toBeInTheDocument(); + }); + + it("本站不生效时只显示允许动作", () => { + mockData = makeData({ + popupSiteScopeActions: true, + scriptList: [makeScriptMenu({ isEffective: false })], + fullScriptCount: 1, + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Script A/ })); + + expect(screen.getByRole("button", { name: "允许在 example.com 执行" })).toHaveClass("text-primary"); + expect(screen.queryByRole("button", { name: "排除在 example.com 上执行" })).not.toBeInTheDocument(); + }); +}); + describe("Popup 脚本列表展开/收起", () => { it("当前页脚本超过展示上限且已展开时,应显示「收起」按钮并可再次折叠", () => { const handleToggleExpand = vi.fn(); diff --git a/src/pages/popup/App.tsx b/src/pages/popup/App.tsx index c1ab04dc8..b4f043e9f 100644 --- a/src/pages/popup/App.tsx +++ b/src/pages/popup/App.tsx @@ -19,6 +19,8 @@ import { Bug, BookOpen, MessageCircle, + SlidersHorizontal, + CircleDot, } from "lucide-react"; import { GithubIcon } from "../components/icons/GithubIcon"; import { Switch } from "../components/ui/switch"; @@ -162,8 +164,12 @@ export default function App() { onToggle={data.handleToggleScript} onDelete={data.handleDeleteScript} onOpenEditor={data.handleOpenEditor} + onOpenScriptSettings={data.handleOpenScriptSettings} onOpenUserConfig={data.handleOpenUserConfig} onExcludeUrl={data.handleExcludeUrl} + showSiteScopeActions={data.popupSiteScopeActions} + onOnlyRunOnUrl={data.handleOnlyRunOnUrl} + onAllowUrl={data.handleAllowUrl} onMenuClick={data.handleMenuClick} /> ))} @@ -195,6 +201,7 @@ export default function App() { onToggle={data.handleToggleScript} onDelete={data.handleDeleteScript} onOpenEditor={data.handleOpenEditor} + onOpenScriptSettings={data.handleOpenScriptSettings} onOpenUserConfig={data.handleOpenUserConfig} onMenuClick={data.handleMenuClick} onRun={data.handleRunScript} @@ -453,8 +460,12 @@ interface ScriptRowProps { onToggle: (uuid: string, enable: boolean) => void; onDelete: (uuid: string) => void; onOpenEditor: (uuid: string) => void; + onOpenScriptSettings: (uuid: string) => void; onOpenUserConfig: (uuid: string) => void; onExcludeUrl?: (uuid: string, isEffective: boolean) => void; + showSiteScopeActions?: boolean; + onOnlyRunOnUrl?: (uuid: string) => void; + onAllowUrl?: (uuid: string) => void; onMenuClick: (uuid: string, menus: ScriptMenuItem[], inputValue?: any) => void; onRun?: (uuid: string) => void; onStop?: (uuid: string) => void; @@ -469,8 +480,12 @@ function ScriptRow({ onToggle, onDelete, onOpenEditor, + onOpenScriptSettings, onOpenUserConfig, onExcludeUrl, + showSiteScopeActions = false, + onOnlyRunOnUrl, + onAllowUrl, onMenuClick, onRun, onStop, @@ -548,17 +563,34 @@ function ScriptRow({ } onClick={() => onOpenEditor(script.uuid)}> {t("edit")} + } + onClick={() => onOpenScriptSettings(script.uuid)} + > + {t("editor:script_setting")} + + {isPageScript && host && showSiteScopeActions && script.isEffective === false && onAllowUrl && ( + } primary onClick={() => onAllowUrl(script.uuid)}> + {t("allow_on_site").replace("$0", host)} + + )} + {isPageScript && host && showSiteScopeActions && script.isEffective === true && onOnlyRunOnUrl && ( + } + primary + onClick={() => onOnlyRunOnUrl(script.uuid)} + > + {t("only_on_site").replace("$0", host)} + + )} {/* 排除/取消排除 host(无二次确认,与旧版一致) */} - {isPageScript && host && onExcludeUrl && script.isEffective !== null && ( + {isPageScript && host && onExcludeUrl && script.isEffective === true && ( : - } - warn={script.isEffective === true} - success={script.isEffective === false} + icon={} + warn onClick={() => onExcludeUrl(script.uuid, script.isEffective!)} > - {script.isEffective ? t("exclude_off").replace("$0", host) : t("exclude_on").replace("$0", host)} + {t("exclude_off").replace("$0", host)} )} {/* 删除(AlertDialog 二次确认) */} @@ -705,7 +737,7 @@ interface ActionItemProps extends React.ButtonHTMLAttributes icon: React.ReactNode; danger?: boolean; warn?: boolean; - success?: boolean; + primary?: boolean; muted?: boolean; } @@ -715,7 +747,7 @@ const ActionItem = ({ children, danger = false, warn = false, - success = false, + primary = false, muted = false, className, ref, @@ -725,8 +757,8 @@ const ActionItem = ({ ? "text-destructive hover:text-destructive" : warn ? "text-type-orange hover:text-type-orange" - : success - ? "text-type-green hover:text-type-green" + : primary + ? "text-primary hover:text-primary" : muted ? "text-muted-foreground" : ""; diff --git a/src/pages/popup/preload.test.ts b/src/pages/popup/preload.test.ts index 76b26279b..6c8bcd428 100644 --- a/src/pages/popup/preload.test.ts +++ b/src/pages/popup/preload.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ getCheckUpdate: vi.fn(async () => ({ notice: "notice", version: "2.0.0", isRead: false })), getMenuExpandNum: vi.fn(async () => 8), getPopupCompactLayout: vi.fn(async () => true), + getPopupSiteScopeActions: vi.fn(async () => true), getProvider: vi.fn(async () => "greasyfork"), getPopupData: vi.fn(), })); @@ -19,6 +20,7 @@ vi.mock("../store/global", () => ({ getCheckUpdate: mocks.getCheckUpdate, getMenuExpandNum: mocks.getMenuExpandNum, getPopupCompactLayout: mocks.getPopupCompactLayout, + getPopupSiteScopeActions: mocks.getPopupSiteScopeActions, }, })); vi.mock("../store/features/script", () => ({ popupClient: { getPopupData: mocks.getPopupData } })); @@ -53,5 +55,6 @@ describe("Popup 数据预加载", () => { expect(mocks.getPopupData).toHaveBeenCalledWith({ tabId: 7, url: "https://example.com/page" }); expect(mocks.getPopupCompactLayout).toHaveBeenCalledOnce(); + expect(mocks.getPopupSiteScopeActions).toHaveBeenCalledOnce(); }); }); diff --git a/src/pages/popup/preload.ts b/src/pages/popup/preload.ts index f53fae882..8368a8354 100644 --- a/src/pages/popup/preload.ts +++ b/src/pages/popup/preload.ts @@ -16,6 +16,7 @@ export type PopupInitialData = { checkUpdate: { notice: string; version: string; isRead: boolean }; menuExpandNum: number; popupCompactLayout: boolean; + popupSiteScopeActions: boolean; defaultScriptProvider: ScriptProvider; isBlacklist: boolean; scriptList: ScriptMenu[]; @@ -32,14 +33,16 @@ export const scriptListSorter = (a: ScriptMenu, b: ScriptMenu) => const popupDataQuery = createPreloadableQuery<"popup", PopupInitialData>({ key: (key) => key, load: async (_key, signal) => { - const [tab, isEnableScript, checkUpdate, menuExpandNum, popupCompactLayout, provider] = await Promise.all([ - getCurrentTab(), - systemConfig.getEnableScript(), - systemConfig.getCheckUpdate({ sanitizeHTML }), - systemConfig.getMenuExpandNum(), - systemConfig.getPopupCompactLayout(), - cacheInstance.get("default_script_provider"), - ]); + const [tab, isEnableScript, checkUpdate, menuExpandNum, popupCompactLayout, popupSiteScopeActions, provider] = + await Promise.all([ + getCurrentTab(), + systemConfig.getEnableScript(), + systemConfig.getCheckUpdate({ sanitizeHTML }), + systemConfig.getMenuExpandNum(), + systemConfig.getPopupCompactLayout(), + systemConfig.getPopupSiteScopeActions(), + cacheInstance.get("default_script_provider"), + ]); if (signal.aborted) throw new DOMException("Popup preload aborted", "AbortError"); @@ -59,6 +62,7 @@ const popupDataQuery = createPreloadableQuery<"popup", PopupInitialData>({ checkUpdate: checkUpdate ?? { notice: "", version: ExtVersion, isRead: false }, menuExpandNum, popupCompactLayout, + popupSiteScopeActions, defaultScriptProvider: provider ?? "scriptcat", isBlacklist: popupData.isBlacklist, scriptList: popupData.scriptList.sort(scriptListSorter), diff --git a/src/pages/popup/usePopupData.test.ts b/src/pages/popup/usePopupData.test.ts index 42868eb7b..121f67def 100644 --- a/src/pages/popup/usePopupData.test.ts +++ b/src/pages/popup/usePopupData.test.ts @@ -7,6 +7,8 @@ const popupInitialData = vi.hoisted(() => ({ isEnableScript: true, checkUpdate: { notice: "", version: "1.0.0", isRead: false }, menuExpandNum: 5, + popupCompactLayout: false, + popupSiteScopeActions: false, defaultScriptProvider: "scriptcat" as const, isBlacklist: false, scriptList: [ @@ -86,6 +88,14 @@ describe("usePopupData 打开编辑器/用户配置", () => { }); expect(openInCurrentTab).toHaveBeenCalledWith("/src/options.html#/?userConfig=uuid-2"); }); + + it("handleOpenScriptSettings 应直达该脚本的设置页", async () => { + const { result } = renderHook(() => usePopupData()); + await act(async () => { + await result.current.handleOpenScriptSettings("uuid-3"); + }); + expect(openInCurrentTab).toHaveBeenCalledWith("/src/options.html#/script/editor/uuid-3?view=setting"); + }); }); describe("usePopupData 预加载数据", () => { diff --git a/src/pages/popup/usePopupData.ts b/src/pages/popup/usePopupData.ts index 68bf8d018..cac93cac8 100644 --- a/src/pages/popup/usePopupData.ts +++ b/src/pages/popup/usePopupData.ts @@ -99,6 +99,7 @@ export function usePopupData() { const [showAlert, setShowAlert] = useState(false); const [menuExpandNum, setMenuExpandNum] = useState(initialData?.menuExpandNum ?? 5); const [popupCompactLayout, setPopupCompactLayout] = useState(initialData?.popupCompactLayout ?? false); + const [popupSiteScopeActions, setPopupSiteScopeActions] = useState(initialData?.popupSiteScopeActions ?? false); const [defaultScriptProvider, setDefaultScriptProvider] = useState( initialData?.defaultScriptProvider ?? "scriptcat" ); @@ -140,6 +141,7 @@ export function usePopupData() { setCheckUpdate(initialData.checkUpdate); setMenuExpandNum(initialData.menuExpandNum); setPopupCompactLayout(initialData.popupCompactLayout); + setPopupSiteScopeActions(initialData.popupSiteScopeActions); setDefaultScriptProvider(initialData.defaultScriptProvider); setInitialized(true); } @@ -256,6 +258,11 @@ export function usePopupData() { window.close(); }, []); + const handleOpenScriptSettings = useCallback(async (uuid: string) => { + await openInCurrentTab(`/src/options.html#/script/editor/${uuid}?view=setting`); + window.close(); + }, []); + const handleOpenUserConfig = useCallback(async (uuid: string) => { await openInCurrentTab(`/src/options.html#/?userConfig=${uuid}`); window.close(); @@ -273,6 +280,33 @@ export function usePopupData() { } }, []); + const handleOnlyRunOnUrl = useCallback( + async (uuid: string) => { + const host = extractHost(stateRef.current.currentUrl); + if (!host) return; + try { + await scriptClient.onlyRunOnUrl(uuid, `*://${host}/*`); + } catch (e) { + showError(String(e)); + } + }, + [showError] + ); + + const handleAllowUrl = useCallback( + async (uuid: string) => { + const host = extractHost(stateRef.current.currentUrl); + if (!host) return; + try { + await scriptClient.allowUrl(uuid, `*://${host}/*`, `*://${host}/*`); + setScriptList((prev) => prev.map((s) => (s.uuid === uuid ? { ...s, isEffective: true } : s))); + } catch (e) { + showError(String(e)); + } + }, + [showError] + ); + /** 调用方需从 script.menus 中按 groupKey 过滤出所有匹配项传入 */ const handleMenuClick = useCallback(async (uuid: string, menus: ScriptMenuItem[], inputValue?: any) => { try { @@ -406,8 +440,11 @@ export function usePopupData() { handleToggleScript, handleDeleteScript, handleOpenEditor, + handleOpenScriptSettings, handleOpenUserConfig, handleExcludeUrl, + handleOnlyRunOnUrl, + handleAllowUrl, handleMenuClick, handleRunScript, handleStopScript, @@ -426,6 +463,7 @@ export function usePopupData() { showAlert, menuExpandNum, popupCompactLayout, + popupSiteScopeActions, handleSearch, handleToggleExpand, }; diff --git a/src/pkg/backup/config_sections.ts b/src/pkg/backup/config_sections.ts index 72adab166..ebe0eb2e4 100644 --- a/src/pkg/backup/config_sections.ts +++ b/src/pkg/backup/config_sections.ts @@ -14,6 +14,7 @@ const APPEARANCE_KEYS = new Set([ "badge_text_color", "favicon_service", "popup_compact_layout", + "popup_site_scope_actions", ]); const UPDATE_KEYS = new Set([ "check_script_update_cycle", diff --git a/src/pkg/config/config.test.ts b/src/pkg/config/config.test.ts index d83a312e2..197f57c7e 100644 --- a/src/pkg/config/config.test.ts +++ b/src/pkg/config/config.test.ts @@ -139,6 +139,15 @@ describe("SystemConfig 双 storage 与懒迁移", () => { expect(localData["system_popup_compact_layout"]).toBeUndefined(); }); + it("popup 站点范围快捷操作应默认关闭并写入 sync storage", async () => { + await expect(config.getPopupSiteScopeActions()).resolves.toBe(false); + config.setPopupSiteScopeActions(true); + await expect(config.getPopupSiteScopeActions()).resolves.toBe(true); + await expect(chrome.storage.sync.get("system_popup_site_scope_actions")).resolves.toMatchObject({ + system_popup_site_scope_actions: true, + }); + }); + it("编辑器偏好应返回默认值并写入 sync storage", async () => { await expect(config.getEditorPreferences()).resolves.toEqual({ version: 1, diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index fb50f668e..b58f72672 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -567,6 +567,14 @@ export class SystemConfig { this._set("popup_compact_layout", val); } + getPopupSiteScopeActions() { + return this._get("popup_site_scope_actions", false); + } + + setPopupSiteScopeActions(val: boolean) { + this._set("popup_site_scope_actions", val); + } + async getLanguage() { if (globalThis.localStorage) { const cachedLanguage = localStorage.getItem("language");