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 87cd2095a..9b9733498 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_list_expand_count": "Angezeigte Skripte in der Liste", "script_list_expand_count_desc": "Anzahl der Skripte, die je Popup-Bereich ausgeklappt werden; der Rest wird eingeklappt. Bei 0 werden alle angezeigt", "script_update_check_frequency": "Häufigkeit der Skript-Aktualisierungsprüfung", 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 13bec0644..ab3a97fc0 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_list_expand_count": "Script List Expand Count", "script_list_expand_count_desc": "Scripts shown in each popup section; the rest are collapsed. 0 shows all of them", "script_update_check_frequency": "Script Update Check Frequency", 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 5f2e5b3e5..763c66f74 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_list_expand_count": "スクリプト一覧の展開数", "script_list_expand_count_desc": "ポップアップの各セクションで展開表示するスクリプト数です。超えた分は折りたたまれ、0 の場合はすべて表示します", "script_update_check_frequency": "スクリプト更新の確認頻度", 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 f1bd00e49..237280752 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_list_expand_count": "스크립트 목록 펼침 개수", "script_list_expand_count_desc": "팝업의 각 섹션에서 펼쳐 표시할 스크립트 개수이며, 초과분은 접힙니다. 0이면 모두 표시합니다", "script_update_check_frequency": "스크립트 업데이트 확인 주기", 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 827141920..ba5ed3cb0 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_list_expand_count": "Scripts visíveis na lista", "script_list_expand_count_desc": "Quantidade de scripts expandidos em cada seção do popup; o restante fica recolhido. Com 0, todos são exibidos", "script_update_check_frequency": "Frequência de verificação 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 e9bfc5fc3..548fa4c4e 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_list_expand_count": "Количество скриптов в списке", "script_list_expand_count_desc": "Сколько скриптов разворачивать в каждом разделе всплывающего окна; остальные сворачиваются. При 0 отображаются все", "script_update_check_frequency": "Частота проверки обновления скрипта", 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 e43c8a361..ca21c2cd6 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_list_expand_count": "Listede Gösterilen Betik Sayısı", "script_list_expand_count_desc": "Açılır penceredeki her bölümde genişletilerek gösterilecek betik sayısıdır; fazlası daraltılır. 0 olduğunda tümü gösterilir", "script_update_check_frequency": "Betik Güncelleme Denetimi Sıklığı", @@ -102,16 +104,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 ed380f717..932f2b4fe 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_list_expand_count": "Số script hiển thị trong danh sách", "script_list_expand_count_desc": "Số script mở rộng trong mỗi mục của cửa sổ bật lên, phần còn lại sẽ thu gọn; đặt 0 thì hiển thị tất cả", "script_update_check_frequency": "Tần suất kiểm tra cập nhật tập lệnh", 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 625d943c7..518f1361c 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_list_expand_count": "脚本列表展开数量", "script_list_expand_count_desc": "弹窗中每个分组展开显示的脚本数量,超出部分折叠;填 0 表示全部显示", "script_update_check_frequency": "脚本更新检查频率", 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 19a26817e..bb0dc5e99 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_list_expand_count": "腳本清單展開數量", "script_list_expand_count_desc": "彈出視窗中每個區段展開顯示的腳本數量,超出部分摺疊;填 0 表示全部顯示", "script_update_check_frequency": "腳本更新檢查頻率", 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 729cfb7c9..14e1d23eb 100644 --- a/src/pages/options/routes/Setting/sections/InterfaceSection.test.tsx +++ b/src/pages/options/routes/Setting/sections/InterfaceSection.test.tsx @@ -55,6 +55,17 @@ 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); + }); }); // 两个展开数量此前共用「展开数量 / 超过此数量时自动折叠」文案,用户误以为它管脚本列表(#1558) diff --git a/src/pages/options/routes/Setting/sections/InterfaceSection.tsx b/src/pages/options/routes/Setting/sections/InterfaceSection.tsx index e852a1a24..487886479 100644 --- a/src/pages/options/routes/Setting/sections/InterfaceSection.tsx +++ b/src/pages/options/routes/Setting/sections/InterfaceSection.tsx @@ -17,6 +17,7 @@ export function InterfaceSection({ register }: { register: (id: string) => (el: const [scriptListExpandNum, setScriptListExpandNum] = useSystemConfig("script_list_expand_num"); const [favicon, setFavicon] = useSystemConfig("favicon_service"); const [popupCompactLayout, setPopupCompactLayout] = useSystemConfig("popup_compact_layout"); + const [popupSiteScopeActions, setPopupSiteScopeActions] = useSystemConfig("popup_site_scope_actions"); return ( @@ -57,6 +58,16 @@ export function InterfaceSection({ register }: { register: (id: string) => (el: onCheckedChange={setPopupCompactLayout} /> + + + = {}) { 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 49817abba..2e0e96041 100644 --- a/src/pages/popup/preload.test.ts +++ b/src/pages/popup/preload.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ getMenuExpandNum: vi.fn(async () => 8), getScriptListExpandNum: vi.fn(async () => 21), getPopupCompactLayout: vi.fn(async () => true), + getPopupSiteScopeActions: vi.fn(async () => true), getProvider: vi.fn(async () => "greasyfork"), getPopupData: vi.fn(), })); @@ -21,6 +22,7 @@ vi.mock("../store/global", () => ({ getMenuExpandNum: mocks.getMenuExpandNum, getScriptListExpandNum: mocks.getScriptListExpandNum, getPopupCompactLayout: mocks.getPopupCompactLayout, + getPopupSiteScopeActions: mocks.getPopupSiteScopeActions, }, })); vi.mock("../store/features/script", () => ({ popupClient: { getPopupData: mocks.getPopupData } })); @@ -55,6 +57,7 @@ describe("Popup 数据预加载", () => { expect(mocks.getPopupData).toHaveBeenCalledWith({ tabId: 7, url: "https://example.com/page" }); expect(mocks.getPopupCompactLayout).toHaveBeenCalledOnce(); + expect(mocks.getPopupSiteScopeActions).toHaveBeenCalledOnce(); expect(mocks.getScriptListExpandNum).toHaveBeenCalledOnce(); }); }); diff --git a/src/pages/popup/preload.ts b/src/pages/popup/preload.ts index bbcfd0650..e59e9b14b 100644 --- a/src/pages/popup/preload.ts +++ b/src/pages/popup/preload.ts @@ -17,6 +17,7 @@ export type PopupInitialData = { menuExpandNum: number; scriptListExpandNum: number; popupCompactLayout: boolean; + popupSiteScopeActions: boolean; defaultScriptProvider: ScriptProvider; isBlacklist: boolean; scriptList: ScriptMenu[]; @@ -33,16 +34,25 @@ export const scriptListSorter = (a: ScriptMenu, b: ScriptMenu) => const popupDataQuery = createPreloadableQuery<"popup", PopupInitialData>({ key: (key) => key, load: async (_key, signal) => { - const [tab, isEnableScript, checkUpdate, menuExpandNum, scriptListExpandNum, popupCompactLayout, provider] = - await Promise.all([ - getCurrentTab(), - systemConfig.getEnableScript(), - systemConfig.getCheckUpdate({ sanitizeHTML }), - systemConfig.getMenuExpandNum(), - systemConfig.getScriptListExpandNum(), - systemConfig.getPopupCompactLayout(), - cacheInstance.get("default_script_provider"), - ]); + const [ + tab, + isEnableScript, + checkUpdate, + menuExpandNum, + scriptListExpandNum, + popupCompactLayout, + popupSiteScopeActions, + provider, + ] = await Promise.all([ + getCurrentTab(), + systemConfig.getEnableScript(), + systemConfig.getCheckUpdate({ sanitizeHTML }), + systemConfig.getMenuExpandNum(), + systemConfig.getScriptListExpandNum(), + systemConfig.getPopupCompactLayout(), + systemConfig.getPopupSiteScopeActions(), + cacheInstance.get("default_script_provider"), + ]); if (signal.aborted) throw new DOMException("Popup preload aborted", "AbortError"); @@ -63,6 +73,7 @@ const popupDataQuery = createPreloadableQuery<"popup", PopupInitialData>({ menuExpandNum, scriptListExpandNum, 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 fa78ee63f..572284eb3 100644 --- a/src/pages/popup/usePopupData.test.ts +++ b/src/pages/popup/usePopupData.test.ts @@ -8,6 +8,8 @@ const popupInitialData = vi.hoisted(() => ({ checkUpdate: { notice: "", version: "1.0.0", isRead: false }, menuExpandNum: 5, scriptListExpandNum: 5, + popupCompactLayout: false, + popupSiteScopeActions: false, defaultScriptProvider: "scriptcat" as const, isBlacklist: false, scriptList: [ @@ -87,6 +89,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 6037c5a9a..017ca105e 100644 --- a/src/pages/popup/usePopupData.ts +++ b/src/pages/popup/usePopupData.ts @@ -98,6 +98,7 @@ export function usePopupData() { const [menuExpandNum, setMenuExpandNum] = useState(initialData?.menuExpandNum ?? 5); const [scriptListExpandNum, setScriptListExpandNum] = useState(initialData?.scriptListExpandNum ?? 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() { setMenuExpandNum(initialData.menuExpandNum); setScriptListExpandNum(initialData.scriptListExpandNum); 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 { @@ -410,8 +444,11 @@ export function usePopupData() { handleToggleScript, handleDeleteScript, handleOpenEditor, + handleOpenScriptSettings, handleOpenUserConfig, handleExcludeUrl, + handleOnlyRunOnUrl, + handleAllowUrl, handleMenuClick, handleRunScript, handleStopScript, @@ -430,6 +467,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 37ae0f872..2eb70982b 100644 --- a/src/pkg/backup/config_sections.ts +++ b/src/pkg/backup/config_sections.ts @@ -15,6 +15,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 6a6769ea7..83cb2ffac 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -576,6 +576,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");