diff --git a/e2e/keep-alive.spec.ts b/e2e/keep-alive.spec.ts index 079d49e12..32790df8f 100644 --- a/e2e/keep-alive.spec.ts +++ b/e2e/keep-alive.spec.ts @@ -8,7 +8,7 @@ const HEARTBEAT_VALIDATION_WINDOW_MS = 31_000; const openRuntimeSettings = async (context: Parameters[0], extensionId: string) => { const page = await openOptionsPage(context, extensionId); await page - .getByTestId("view-toggle") + .getByTestId("script-search") .or(page.getByTestId("mobile-search")) .first() .waitFor({ state: "visible", timeout: 30_000 }); diff --git a/e2e/options-pages-smoke.spec.ts b/e2e/options-pages-smoke.spec.ts index 0797eeb1e..64d650513 100644 --- a/e2e/options-pages-smoke.spec.ts +++ b/e2e/options-pages-smoke.spec.ts @@ -6,7 +6,7 @@ import type { Locator, Page } from "@playwright/test"; // 这能廉价地兜住「页面挂载即抛错」一类缺陷(例如 getDefaultModelId 在全新安装无默认模型 // 时用 doThrow 抛错导致模型服务/会话页卡死的回归)。 const ROUTES: Array<{ path: string; name: string; anchor: (page: Page) => Locator }> = [ - { path: "/", name: "脚本列表", anchor: (page) => page.getByTestId("view-toggle") }, + { path: "/", name: "脚本列表", anchor: (page) => page.getByTestId("script-search") }, { path: "/subscribe", name: "订阅列表", anchor: (page) => page.getByTestId("subscribe-page") }, { path: "/logs", name: "日志页", anchor: (page) => page.getByTestId("level-chip-bar") }, { path: "/tools", name: "工具页", anchor: (page) => page.getByTestId("tools_export") }, diff --git a/e2e/script-management.spec.ts b/e2e/script-management.spec.ts index f702b8eb0..fa97620b6 100644 --- a/e2e/script-management.spec.ts +++ b/e2e/script-management.spec.ts @@ -2,9 +2,11 @@ import { test, expect } from "./fixtures"; import type { BrowserContext, Page } from "@playwright/test"; import { openEditorPage, openOptionsPage, saveCurrentEditor } from "./utils"; -// new-ui 脚本列表(shadcn 表格视图,桌面默认):空状态 data-testid="script-list-empty", -// 每行启用开关为 Radix Switch(role=switch),删除为行内 Trash2 图标 + Popconfirm -// (确认按钮 data-testid="popconfirm-confirm"),搜索框 data-testid="script-search"。 +// new-ui 脚本列表(统一列表行,桌面默认):空状态 data-testid="script-list-empty", +// 每行启用开关为 Radix Switch(role=switch),搜索框 data-testid="script-search"。 +// 删除为行内 Trash2 图标 + AlertDialog 二次确认(确认按钮 data-slot="alert-dialog-action")。 +// 行操作区在视口不窄于 1000px 时才摊开成按钮,窄于此宽度删除会收进「更多」菜单; +// 本套件依赖 Playwright 默认的 1280 宽视口,改视口需同步调整下面的删除用例。 /** 通过编辑器创建一个脚本,再打开脚本列表 */ async function createScriptAndGoToList(context: BrowserContext, extensionId: string): Promise { @@ -37,8 +39,8 @@ test.describe("脚本管理", () => { await expect(deleteBtn).toBeVisible({ timeout: 10_000 }); await deleteBtn.click(); - // Popconfirm 确认 - await page.getByTestId("popconfirm-confirm").click(); + // AlertDialog 二次确认 + await page.locator('[data-slot="alert-dialog-action"]').click(); // 删除后回到空状态 await expect(page.getByTestId("script-list-empty")).toBeVisible({ timeout: 10_000 }); diff --git a/e2e/utils.ts b/e2e/utils.ts index 0a0e09515..e7a50fdc7 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -147,9 +147,9 @@ async function focusMonacoEditor(page: Page): Promise { async function waitForSavedScriptInList(context: BrowserContext, extensionId: string): Promise { const listPage = await openOptionsPage(context, extensionId); try { - // new-ui 列表页加载完成的稳定信号(桌面工具栏 view-toggle / 移动搜索栏) + // new-ui 列表页加载完成的稳定信号(桌面工具栏搜索框 / 移动搜索栏) await listPage - .getByTestId("view-toggle") + .getByTestId("script-search") .or(listPage.getByTestId("mobile-search")) .first() .waitFor({ state: "visible", timeout: 30_000 }); diff --git a/src/app/service/service_worker/trash_event_partition.test.ts b/src/app/service/service_worker/trash_event_partition.test.ts index f2e6520a3..b097af339 100644 --- a/src/app/service/service_worker/trash_event_partition.test.ts +++ b/src/app/service/service_worker/trash_event_partition.test.ts @@ -43,7 +43,7 @@ describe("回收站事件二分护栏", () => { "src/app/service/service_worker/popup.ts", // 清 popup 菜单缓存 + 刷 badge "src/app/service/service_worker/runtime.ts", // 注销脚本、失效匹配器 "src/app/service/service_worker/synchronize.ts", // 删除云端文件 + 写墓碑 - "src/pages/options/routes/ScriptList/TrashCardGrid.tsx", // 多窗口下刷新移动回收站列表 + "src/pages/options/routes/ScriptList/TrashListMobile.tsx", // 多窗口下刷新移动回收站列表 "src/pages/options/routes/ScriptList/TrashTable.tsx", // 多窗口下刷新桌面回收站列表 "src/pages/options/routes/ScriptList/hooks.ts", // 从「已安装」列表移除该行 "src/pages/popup/usePopupData.ts", // 从 popup 列表移除该行 @@ -58,7 +58,7 @@ describe("回收站事件二分护栏", () => { "src/app/service/service_worker/resource.ts", // 清资源 "src/app/service/service_worker/system.ts", // 清图标 "src/app/service/service_worker/value.ts", // 清 value - "src/pages/options/routes/ScriptList/TrashCardGrid.tsx", // 多窗口下刷新移动回收站列表 + "src/pages/options/routes/ScriptList/TrashListMobile.tsx", // 多窗口下刷新移动回收站列表 "src/pages/options/routes/ScriptList/TrashTable.tsx", // 多窗口下刷新桌面回收站列表 // useTrashCount:彻底删除后刷新回收站 tab 的数量角标。它只读不销毁,故不违反二分。 // 不可省:到期自动清理由 chrome.alarms 在 SW 里直接 purge,全程没有 UI 参与, diff --git a/src/locales/de-DE/guide.json b/src/locales/de-DE/guide.json index b5f21487c..5c6596d28 100644 --- a/src/locales/de-DE/guide.json +++ b/src/locales/de-DE/guide.json @@ -5,7 +5,7 @@ "script_list_enable_title": "Skript-Aktivierung", "script_list_enable_content": "Skripte müssen aktiviert werden, um verwendet zu werden. Seitenskripte sind bei der Installation standardmäßig aktiviert, Hintergrundskripte sind bei der Installation standardmäßig deaktiviert", "script_list_action_title": "Aktion", - "script_list_action_content": "Die Bedienleiste ermöglicht den Zugriff auf die Skriptbearbeitung, die Steuerung der Skriptausführung und -beendigung (Hintergrundskripte) sowie auf die Einstellungen (siehe UserConfig). Über die Schaltflächen auf der rechten Seite können Sie auf erweiterte Filterfunktionen zugreifen und zwischen verschiedenen Ansichtsmodi wechseln.", + "script_list_action_content": "Im Aktionsbereich können Sie ein Skript bearbeiten, Hintergrundskripte und geplante Skripte ausführen und stoppen, die UserConfig festlegen sowie das Skript exportieren und löschen. In einem schmalen Fenster werden diese Aktionen im Menü „Weitere Aktionen“ zusammengefasst.", "tools_backup_title": "Backup", "tools_backup_content": "Backups können Skripte speichern und Verluste vermeiden. Sie können Skriptdateien lokal exportieren oder lokale Skriptdateien laden. Sie können auch in die Cloud sichern, was praktischer ist. Informationen zum Wechsel von einem anderen Manager findest du im Migrationsleitfaden.", "setting_sync_title": "Update und Synchronisation", diff --git a/src/locales/de-DE/script.json b/src/locales/de-DE/script.json index 01f0d0178..0db1194e0 100644 --- a/src/locales/de-DE/script.json +++ b/src/locales/de-DE/script.json @@ -111,5 +111,14 @@ "trash_moved_batch": "{{count}} Skript(e) in den Papierkorb verschoben", "trash_undo": "Rückgängig", "trash_undo_success": "Wiederhergestellt", - "trash_undo_failed": "Wiederherstellen fehlgeschlagen" + "trash_undo_failed": "Wiederherstellen fehlgeschlagen", + "sort_by": "Sortieren", + "sort_create_time": "Hinzugefügt am", + "sort_default": "Standard", + "sort_ascending": "Aufsteigend", + "sort_descending": "Absteigend", + "more_actions": "Weitere Aktionen", + "multi_select": "Mehrfachauswahl", + "site_count": "{{count}} Website(s)", + "select_all": "Alle auswählen" } diff --git a/src/locales/en-US/guide.json b/src/locales/en-US/guide.json index a31cede5d..fe9fc4e2e 100644 --- a/src/locales/en-US/guide.json +++ b/src/locales/en-US/guide.json @@ -5,7 +5,7 @@ "script_list_enable_title": "Enable Scripts", "script_list_enable_content": "Scripts need to be enabled to be used. Page scripts are enabled by default upon installation, while background scripts are disabled by default.", "script_list_action_title": "Action", - "script_list_action_content": "The operation bar allows access to script editing, control script execution and termination (background scripts), and settings (see UserConfig). The buttons on the right allow access to advanced filtering and toggling view modes.", + "script_list_action_content": "The action area lets you edit a script, run or stop background and scheduled scripts, set its UserConfig, and export or delete it. In a narrow window these actions collapse into the “More actions” menu.", "tools_backup_title": "Backup", "tools_backup_content": "Backup allows you to save scripts to avoid loss. You can export script files to your local machine or load script files from your local machine. You can also back up to the cloud for added convenience. Migrating from another manager? See the migration guide.", "setting_sync_title": "Update and Sync", diff --git a/src/locales/en-US/script.json b/src/locales/en-US/script.json index 72203cd57..21187ae5e 100644 --- a/src/locales/en-US/script.json +++ b/src/locales/en-US/script.json @@ -111,5 +111,14 @@ "trash_moved_batch": "Moved {{count}} scripts to Trash", "trash_undo": "Undo", "trash_undo_success": "Restored", - "trash_undo_failed": "Restore failed" + "trash_undo_failed": "Restore failed", + "sort_by": "Sort", + "sort_create_time": "Added time", + "sort_default": "Default", + "sort_ascending": "Ascending", + "sort_descending": "Descending", + "more_actions": "More actions", + "multi_select": "Select multiple", + "site_count": "{{count}} site(s)", + "select_all": "Select all" } diff --git a/src/locales/ja-JP/guide.json b/src/locales/ja-JP/guide.json index 25134e67e..f1606f3b7 100644 --- a/src/locales/ja-JP/guide.json +++ b/src/locales/ja-JP/guide.json @@ -5,7 +5,7 @@ "script_list_enable_title": "スクリプト有効化", "script_list_enable_content": "スクリプトは有効にしないと使用できません。ページスクリプトはインストール時にデフォルトで有効、バックグラウンドスクリプトはインストール時にデフォルトで無効です", "script_list_action_title": "アクション", - "script_list_action_content": "操作バーでは、スクリプトの編集、制御スクリプトの実行と終了(バックグラウンドスクリプト)、および設定(UserConfig を参照)にアクセスできます。右側のボタンからは、高度なフィルタリングや表示モードの切り替えにアクセスできます。", + "script_list_action_content": "操作エリアでは、スクリプトの編集、バックグラウンドスクリプトとスケジュールスクリプトの実行と中止、UserConfig の設定、エクスポートと削除ができます。ウィンドウの幅が狭いときは、これらの操作が「その他の操作」メニューにまとめられます。", "tools_backup_title": "バックアップ", "tools_backup_content": "バックアップはスクリプトを保存し、紛失を避けることができます。スクリプトファイルをローカルにエクスポートしたり、ローカルのスクリプトファイルを読み込んだりできます。クラウドにバックアップすることもでき、より便利です。他のマネージャーからの移行は移行ガイドを参照してください。", "setting_sync_title": "更新と同期", diff --git a/src/locales/ja-JP/script.json b/src/locales/ja-JP/script.json index e98b005cf..b264ff8cd 100644 --- a/src/locales/ja-JP/script.json +++ b/src/locales/ja-JP/script.json @@ -111,5 +111,14 @@ "trash_moved_batch": "{{count}} 件のスクリプトをゴミ箱に移動しました", "trash_undo": "元に戻す", "trash_undo_success": "復元しました", - "trash_undo_failed": "復元に失敗しました" + "trash_undo_failed": "復元に失敗しました", + "sort_by": "並び替え", + "sort_create_time": "追加日時", + "sort_default": "デフォルト", + "sort_ascending": "昇順", + "sort_descending": "降順", + "more_actions": "その他の操作", + "multi_select": "複数選択", + "site_count": "{{count}} サイト", + "select_all": "すべて選択" } diff --git a/src/locales/ko-KR/guide.json b/src/locales/ko-KR/guide.json index e7eef199e..d6b566834 100644 --- a/src/locales/ko-KR/guide.json +++ b/src/locales/ko-KR/guide.json @@ -5,7 +5,7 @@ "script_list_enable_title": "스크립트 활성화", "script_list_enable_content": "스크립트는 활성화해야 사용할 수 있습니다. 페이지 스크립트는 설치 시 기본적으로 활성화되며, 백그라운드 스크립트는 기본적으로 비활성화됩니다.", "script_list_action_title": "작업", - "script_list_action_content": "작업 바에서는 스크립트 편집, 실행 제어 및 중지(백그라운드 스크립트), 설정(UserConfig 참고)에 접근할 수 있습니다. 오른쪽 버튼으로는 고급 필터링과 보기 모드 전환을 사용할 수 있습니다.", + "script_list_action_content": "작업 영역에서 스크립트 편집, 백그라운드 스크립트와 예약 스크립트의 실행 및 중지, UserConfig 설정, 내보내기와 삭제를 할 수 있습니다. 창이 좁으면 이러한 작업은 ‘추가 작업’ 메뉴로 모입니다.", "tools_backup_title": "백업", "tools_backup_content": "백업을 이용하면 스크립트를 저장해 유실을 막을 수 있습니다. 스크립트 파일을 로컬로 내보내거나 로컬에서 불러올 수 있으며, 클라우드에 백업하면 더욱 편리합니다. 다른 매니저에서 이전하려면 마이그레이션 가이드를 참고하세요.", "setting_sync_title": "업데이트 및 동기화", diff --git a/src/locales/ko-KR/script.json b/src/locales/ko-KR/script.json index 12c1aa58c..6474df5a7 100644 --- a/src/locales/ko-KR/script.json +++ b/src/locales/ko-KR/script.json @@ -111,5 +111,14 @@ "trash_moved_batch": "스크립트 {{count}}개를 휴지통으로 이동했습니다", "trash_undo": "실행 취소", "trash_undo_success": "복원되었습니다", - "trash_undo_failed": "복원에 실패했습니다" + "trash_undo_failed": "복원에 실패했습니다", + "sort_by": "정렬", + "sort_create_time": "추가 시간", + "sort_default": "기본", + "sort_ascending": "오름차순", + "sort_descending": "내림차순", + "more_actions": "추가 작업", + "multi_select": "다중 선택", + "site_count": "{{count}}개 사이트", + "select_all": "전체 선택" } diff --git a/src/locales/pt-BR/guide.json b/src/locales/pt-BR/guide.json index 3e9d06337..f32f94fc8 100644 --- a/src/locales/pt-BR/guide.json +++ b/src/locales/pt-BR/guide.json @@ -5,7 +5,7 @@ "script_list_enable_title": "Ativar scripts", "script_list_enable_content": "Os scripts precisam ser ativados para serem usados. Os scripts de página são ativados por padrão na instalação, enquanto os scripts em segundo plano são desativados por padrão.", "script_list_action_title": "Ação", - "script_list_action_content": "A barra de operação permite acesso à edição de scripts, controle de execução e término de scripts (scripts em segundo plano) e configurações (consulte UserConfig). Os botões à direita permitem acesso à filtragem avançada e à alternância de modos de visualização.", + "script_list_action_content": "A área de ações permite editar o script, executar e parar scripts em segundo plano e agendados, definir a UserConfig e exportar ou excluir o script. Em janelas estreitas, essas ações são agrupadas no menu “Mais ações”.", "tools_backup_title": "Backup", "tools_backup_content": "O backup permite que você salve scripts para evitar perdas. Você pode exportar arquivos de script para sua máquina local ou carregar arquivos de script de sua máquina local. Você também pode fazer backup na nuvem para maior conveniência. Migrando de outro gerenciador? Consulte o guia de migração.", "setting_sync_title": "Atualização e sincronização", diff --git a/src/locales/pt-BR/script.json b/src/locales/pt-BR/script.json index 252ac11bc..ac3f1753a 100644 --- a/src/locales/pt-BR/script.json +++ b/src/locales/pt-BR/script.json @@ -111,5 +111,14 @@ "trash_moved_batch": "Scripts movidos para a lixeira: {{count}}", "trash_undo": "Desfazer", "trash_undo_success": "Restaurado", - "trash_undo_failed": "Falha ao restaurar" + "trash_undo_failed": "Falha ao restaurar", + "sort_by": "Ordenar", + "sort_create_time": "Data de adição", + "sort_default": "Padrão", + "sort_ascending": "Crescente", + "sort_descending": "Decrescente", + "more_actions": "Mais ações", + "multi_select": "Selecionar vários", + "site_count": "{{count}} site(s)", + "select_all": "Selecionar tudo" } diff --git a/src/locales/ru-RU/guide.json b/src/locales/ru-RU/guide.json index 41ac9ff35..865b043c4 100644 --- a/src/locales/ru-RU/guide.json +++ b/src/locales/ru-RU/guide.json @@ -5,7 +5,7 @@ "script_list_enable_title": "Включение скрипта", "script_list_enable_content": "Скрипты нужно включить для использования. Скрипты страниц включены по умолчанию при установке, фоновые скрипты выключены по умолчанию", "script_list_action_title": "Действие", - "script_list_action_content": "Панель управления позволяет редактировать скрипты, управлять их выполнением и завершением (фоновые скрипты), а также настраивать параметры (см. UserConfig). Кнопки справа позволяют получить доступ к расширенной фильтрации и переключать режимы просмотра.", + "script_list_action_content": "В области действий можно редактировать скрипт, запускать и останавливать фоновые и запланированные скрипты, задавать UserConfig, а также экспортировать и удалять скрипт. В узком окне эти действия сворачиваются в меню «Другие действия».", "tools_backup_title": "Резервное копирование", "tools_backup_content": "Резервное копирование может сохранять скрипты во избежание потери. Можно экспортировать файлы скриптов локально или загружать локальные файлы скриптов. Также можно создавать резервные копии в облаке для большего удобства. Для переноса из другого менеджера см. руководство по миграции.", "setting_sync_title": "Обновление и синхронизация", diff --git a/src/locales/ru-RU/script.json b/src/locales/ru-RU/script.json index 73340210d..912a6502f 100644 --- a/src/locales/ru-RU/script.json +++ b/src/locales/ru-RU/script.json @@ -111,5 +111,14 @@ "trash_moved_batch": "В корзину перемещено скриптов: {{count}}", "trash_undo": "Отменить", "trash_undo_success": "Восстановлено", - "trash_undo_failed": "Не удалось восстановить" + "trash_undo_failed": "Не удалось восстановить", + "sort_by": "Сортировка", + "sort_create_time": "Время добавления", + "sort_default": "По умолчанию", + "sort_ascending": "По возрастанию", + "sort_descending": "По убыванию", + "more_actions": "Другие действия", + "multi_select": "Выбрать несколько", + "site_count": "{{count}} сайт(ов)", + "select_all": "Выбрать все" } diff --git a/src/locales/tr-TR/guide.json b/src/locales/tr-TR/guide.json index 322d36fc8..db84f75e0 100644 --- a/src/locales/tr-TR/guide.json +++ b/src/locales/tr-TR/guide.json @@ -5,7 +5,7 @@ "script_list_enable_title": "Betikleri Etkinleştir", "script_list_enable_content": "Betiklerin kullanılabilmesi için etkinleştirilmesi gerekir. Sayfa betikleri yüklendiğinde varsayılan olarak etkinleştirilir, Arka Plan Betikleri ise varsayılan olarak devre dışı bırakılır.", "script_list_action_title": "İşlemler", - "script_list_action_content": "İşlem çubuğu; betiği düzenleme, betiği çalıştırma ve durdurma (Arka Plan Betikleri) ile ayarlara (Kullanıcı Yapılandırması) erişim sağlar. Sağ taraftaki düğmeler gelişmiş filtreleme seçeneklerine ve görünüm modları arasında geçiş yapmanıza olanak tanır.", + "script_list_action_content": "İşlem alanı; betiği düzenleme, arka plan ve zamanlanmış betikleri çalıştırma ve durdurma, Kullanıcı Yapılandırması ayarlama, dışa aktarma ve silme işlemlerini sağlar. Pencere dar olduğunda bu işlemler “Diğer işlemler” menüsünde toplanır.", "tools_backup_title": "Yedekleme", "tools_backup_content": "Yedekleme, betiklerinizi kaybetmemeniz için yedek oluşturmanızı sağlar. Betik dosyalarını bilgisayarınıza dışa aktarabilir veya bilgisayarınızdan içe aktarabilirsiniz. Ayrıca daha fazla kolaylık için buluta da yedekleyebilirsiniz. Başka bir betik yöneticisinden mi geçiyorsunuz? geçiş kılavuzuna göz atın.", "setting_sync_title": "Güncelleme ve Senkronizasyon", diff --git a/src/locales/tr-TR/script.json b/src/locales/tr-TR/script.json index 37734d571..1dcad714c 100644 --- a/src/locales/tr-TR/script.json +++ b/src/locales/tr-TR/script.json @@ -111,5 +111,14 @@ "trash_moved_batch": "{{count}} betik çöp kutusuna taşındı", "trash_undo": "Geri Al", "trash_undo_success": "Geri yüklendi", - "trash_undo_failed": "Geri yükleme başarısız" + "trash_undo_failed": "Geri yükleme başarısız", + "sort_by": "Sırala", + "sort_create_time": "Eklenme zamanı", + "sort_default": "Varsayılan", + "sort_ascending": "Artan", + "sort_descending": "Azalan", + "more_actions": "Diğer işlemler", + "multi_select": "Çoklu seçim", + "site_count": "{{count}} site", + "select_all": "Tümünü seç" } diff --git a/src/locales/vi-VN/guide.json b/src/locales/vi-VN/guide.json index 0221f0b2c..b588683a7 100644 --- a/src/locales/vi-VN/guide.json +++ b/src/locales/vi-VN/guide.json @@ -5,7 +5,7 @@ "script_list_enable_title": "Bật script", "script_list_enable_content": "Script cần được bật để sử dụng. Script trang được bật theo mặc định khi cài đặt, trong khi script nền bị tắt theo mặc định.", "script_list_action_title": "Thao tác", - "script_list_action_content": "Thanh thao tác cho phép truy cập vào chức năng chỉnh sửa tập lệnh, kiểm soát việc thực thi và kết thúc tập lệnh (tập lệnh nền) và cài đặt (xem UserConfig). Các nút bên phải cho phép truy cập vào chức năng lọc nâng cao và chuyển đổi chế độ xem.", + "script_list_action_content": "Vùng thao tác cho phép sửa script, chạy và dừng script nền và script hẹn giờ, đặt UserConfig, cũng như xuất và xóa script. Khi cửa sổ hẹp, các thao tác này được gom vào menu “Thao tác khác”.", "tools_backup_title": "Sao lưu", "tools_backup_content": "Sao lưu cho phép bạn lưu script để tránh mất mát. Bạn có thể xuất tệp script sang máy cục bộ hoặc tải tệp script từ máy cục bộ của mình. Bạn cũng có thể sao lưu lên đám mây để thuận tiện hơn. Để di chuyển từ trình quản lý khác, xem hướng dẫn di chuyển.", "setting_sync_title": "Cập nhật và đồng bộ", diff --git a/src/locales/vi-VN/script.json b/src/locales/vi-VN/script.json index c0d9604dd..72b51872d 100644 --- a/src/locales/vi-VN/script.json +++ b/src/locales/vi-VN/script.json @@ -111,5 +111,14 @@ "trash_moved_batch": "Đã chuyển {{count}} script vào thùng rác", "trash_undo": "Hoàn tác", "trash_undo_success": "Đã khôi phục", - "trash_undo_failed": "Khôi phục thất bại" + "trash_undo_failed": "Khôi phục thất bại", + "sort_by": "Sắp xếp", + "sort_create_time": "Thời gian thêm", + "sort_default": "Mặc định", + "sort_ascending": "Tăng dần", + "sort_descending": "Giảm dần", + "more_actions": "Thao tác khác", + "multi_select": "Chọn nhiều", + "site_count": "{{count}} trang", + "select_all": "Chọn tất cả" } diff --git a/src/locales/zh-CN/guide.json b/src/locales/zh-CN/guide.json index 95c9b864c..0fe003e50 100644 --- a/src/locales/zh-CN/guide.json +++ b/src/locales/zh-CN/guide.json @@ -5,7 +5,7 @@ "script_list_enable_title": "脚本开启", "script_list_enable_content": "脚本需要开启才能使用,页面脚本安装默认开启,后台脚本安装默认关闭", "script_list_action_title": "操作", - "script_list_action_content": "操作列可以进入脚本编辑、控制脚本的运行停止(后台脚本)、设置UserConfig,操作右侧按钮可以打开高级筛选与切换视图模式", + "script_list_action_content": "操作区可以编辑脚本、运行与停止后台脚本和定时脚本、设置UserConfig,以及导出与删除脚本;窗口较窄时这些操作会收进「更多操作」菜单。", "tools_backup_title": "备份", "tools_backup_content": "备份可以保存脚本,避免丢失。可以导出脚本文件到本地,也可以加载本地的脚本文件。也可以备份到云端,更加方便。从其他管理器迁移可参考迁移指南。", "setting_sync_title": "更新与同步", diff --git a/src/locales/zh-CN/script.json b/src/locales/zh-CN/script.json index 1e240629f..66ac559d3 100644 --- a/src/locales/zh-CN/script.json +++ b/src/locales/zh-CN/script.json @@ -111,5 +111,14 @@ "trash_moved_batch": "已将 {{count}} 个脚本移入回收站", "trash_undo": "撤销", "trash_undo_success": "已还原", - "trash_undo_failed": "还原失败" + "trash_undo_failed": "还原失败", + "sort_by": "排序", + "sort_create_time": "添加时间", + "sort_default": "默认", + "sort_ascending": "升序", + "sort_descending": "降序", + "more_actions": "更多操作", + "multi_select": "多选", + "site_count": "{{count}} 个站点", + "select_all": "全选" } diff --git a/src/locales/zh-TW/guide.json b/src/locales/zh-TW/guide.json index dae3b79f7..725ad99a5 100644 --- a/src/locales/zh-TW/guide.json +++ b/src/locales/zh-TW/guide.json @@ -5,7 +5,7 @@ "script_list_enable_title": "腳本開啟", "script_list_enable_content": "腳本需要開啟才能使用,頁面腳本安裝預設開啟,背景腳本安裝預設關閉", "script_list_action_title": "操作", - "script_list_action_content": "操作列可以進入腳本編輯、控制腳本的運行停止(背景腳本)、設定UserConfig,操作右側按鈕可開啟進階篩選與切換視圖模式", + "script_list_action_content": "操作區可以編輯腳本、執行與停止背景腳本和排程腳本、設定UserConfig,以及匯出與刪除腳本;視窗較窄時這些操作會收進「更多操作」選單。", "tools_backup_title": "備份", "tools_backup_content": "備份可以保存腳本,避免遺失。可以匯出腳本檔案到本機,也可以載入本機的腳本檔案。也可以備份到雲端,更加方便。從其他管理器遷移可參考遷移指南。", "setting_sync_title": "更新與同步", diff --git a/src/locales/zh-TW/script.json b/src/locales/zh-TW/script.json index 7bc8b47b6..a2b691ed0 100644 --- a/src/locales/zh-TW/script.json +++ b/src/locales/zh-TW/script.json @@ -111,5 +111,14 @@ "trash_moved_batch": "已將 {{count}} 個腳本移入回收筒", "trash_undo": "復原", "trash_undo_success": "已還原", - "trash_undo_failed": "還原失敗" + "trash_undo_failed": "還原失敗", + "sort_by": "排序", + "sort_create_time": "新增時間", + "sort_default": "預設", + "sort_ascending": "升序", + "sort_descending": "降序", + "more_actions": "更多操作", + "multi_select": "多選", + "site_count": "{{count}} 個站點", + "select_all": "全選" } diff --git a/src/pages/components/ui/list-row.test.tsx b/src/pages/components/ui/list-row.test.tsx new file mode 100644 index 000000000..dd315f2e3 --- /dev/null +++ b/src/pages/components/ui/list-row.test.tsx @@ -0,0 +1,62 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { ListRow, ListRowActions, ListRowLeading, ListRowMain, ListRowTrailing } from "./list-row"; + +afterEach(cleanup); + +describe("ListRow 列表行骨架", () => { + it("组合四个槽位时各自带上 data-slot 标记", () => { + render( + + {"勾选"} + {"脚本 A"} + {"3 天前"} + {"操作"} + + ); + + expect(screen.getByTestId("row")).toHaveAttribute("data-slot", "list-row"); + expect(screen.getByText("勾选")).toHaveAttribute("data-slot", "list-row-leading"); + expect(screen.getByText("脚本 A")).toHaveAttribute("data-slot", "list-row-main"); + expect(screen.getByText("3 天前")).toHaveAttribute("data-slot", "list-row-trailing"); + expect(screen.getByText("操作")).toHaveAttribute("data-slot", "list-row-actions"); + }); + + it("中段使用 flex-1 与 min-w-0,长名称截断而非把右锚区挤出行外", () => { + render({"很长的脚本名"}); + + const main = screen.getByText("很长的脚本名"); + expect(main).toHaveClass("flex-1"); + expect(main).toHaveClass("min-w-0"); + }); + + it("操作槽常驻半透明,悬停整行才转为完全不透明", () => { + render({"操作"}); + + const actions = screen.getByText("操作"); + expect(actions).toHaveClass("opacity-[0.55]"); + expect(actions).toHaveClass("group-hover/row:opacity-100"); + }); + + it("选中态与禁用态各自改变行外观,且互不依赖", () => { + const { rerender } = render(); + expect(screen.getByTestId("row")).toHaveClass("bg-primary/10"); + + rerender(); + const row = screen.getByTestId("row"); + expect(row).toHaveClass("opacity-60"); + expect(row).not.toHaveClass("bg-primary/10"); + }); + + it("锚区宽度由消费者给出,骨架不预设——回收站无开关、脚本列表有开关,宽度不同", () => { + render( + <> + {"回收站左锚"} + {"脚本列表左锚"} + + ); + + expect(screen.getByText("回收站左锚")).toHaveClass("w-8"); + expect(screen.getByText("脚本列表左锚")).toHaveClass("w-28"); + }); +}); diff --git a/src/pages/components/ui/list-row.tsx b/src/pages/components/ui/list-row.tsx new file mode 100644 index 000000000..1861e8ca6 --- /dev/null +++ b/src/pages/components/ui/list-row.tsx @@ -0,0 +1,63 @@ +import type React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@App/pkg/utils/cn"; + +// 列表行骨架:只固化行的几何与状态外观(行高、内边距、圆角、悬停/选中/禁用、操作槽的常驻半透明), +// 不认识任何业务字段。左右锚区宽度由消费者给出——脚本列表左锚有勾选/拖拽/开关,回收站只有勾选, +// 两者宽度本就不同,骨架预设宽度会立刻退化成带消费者判别参数的适配器。 +const listRowVariants = cva( + "group/row flex items-center h-14 px-3 rounded-md transition-colors hover:bg-primary/[0.08]", + { + variants: { + selected: { + true: "bg-primary/10", + false: "", + }, + disabled: { + true: "opacity-60", + false: "", + }, + }, + defaultVariants: { + selected: false, + disabled: false, + }, + } +); + +type ListRowProps = React.ComponentProps<"div"> & VariantProps; + +function ListRow({ className, selected, disabled, ...props }: ListRowProps) { + return
; +} + +function ListRowLeading({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function ListRowMain({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function ListRowTrailing({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +// 常驻半透明、悬停整行转为完全不透明:保留改版前 ScriptTable 已有的可发现性, +// 触屏与键盘用户不依赖悬停也能看到操作入口。 +function ListRowActions({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { ListRow, ListRowLeading, ListRowMain, ListRowTrailing, ListRowActions }; diff --git a/src/pages/components/ui/mobile-list.test.tsx b/src/pages/components/ui/mobile-list.test.tsx new file mode 100644 index 000000000..c89d0f828 --- /dev/null +++ b/src/pages/components/ui/mobile-list.test.tsx @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { useState } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { renderHook, act } from "@testing-library/react"; +import { initTestLanguage } from "@Tests/initTestLanguage"; +import { t } from "@App/locales/locales"; +import { + MobileActionSheet, + MobileActionSheetItem, + MobileBatchBar, + MobileBatchBarButton, + MobileListRow, + MobileListRowLeading, + MobileListRowMain, + MobileListRowTrailing, + MobileSelectionHeader, + MobileSwipeRow, + useLongPress, +} from "./mobile-list"; + +beforeAll(() => initTestLanguage("zh-CN")); +afterEach(cleanup); + +// useLongPress 只读 touches[0] 的坐标,构造最小事件即可 +const touchEvent = (clientX: number, clientY: number) => + ({ touches: [{ clientX, clientY }] }) as unknown as React.TouchEvent; + +describe("移动端列表行骨架", () => { + it("四个槽位各自可辨识,行只固化几何与状态、不认业务字段", () => { + const { container } = render( + + {"L"} + {"M"} + {"T"} + + ); + + expect(container.querySelector('[data-slot="mobile-list-row"]')).not.toBeNull(); + expect(container.querySelector('[data-slot="mobile-list-row-leading"]')).not.toBeNull(); + expect(container.querySelector('[data-slot="mobile-list-row-main"]')).not.toBeNull(); + expect(container.querySelector('[data-slot="mobile-list-row-trailing"]')).not.toBeNull(); + }); + + it("主体区是可聚焦按钮,右锚区的开关不落在它内部(点开关不会误开操作面板)", () => { + const onTap = vi.fn(); + render( + + {"名称"} + + + + + ); + + const main = screen.getByRole("button", { name: "名称" }); + expect(main.tagName).toBe("BUTTON"); + expect(main.querySelector("button")).toBeNull(); + + fireEvent.click(main); + expect(onTap).toHaveBeenCalledTimes(1); + }); +}); + +describe("左滑操作", () => { + // 开合受控于列表(同时只允许一行滑开),测试里用最小受控外壳承接 + const ControlledSwipe = ({ onRowClick }: { onRowClick?: () => void }) => { + const [open, setOpen] = useState(false); + return ( + {"删除"}}> + {"行"} + + ); + }; + + const renderSwipe = () => render(); + + const swipe = (from: number, to: number) => { + const row = document.querySelector('[data-slot="mobile-swipe-row"]')!; + fireEvent.touchStart(row, { touches: [{ clientX: from }] }); + fireEvent.touchEnd(row, { changedTouches: [{ clientX: to }] }); + }; + + it("左滑露出操作区", () => { + const { container } = renderSwipe(); + expect(container.querySelector('[data-slot="mobile-swipe-actions"]')).toHaveAttribute("data-state", "closed"); + + swipe(200, 120); + + expect(container.querySelector('[data-slot="mobile-swipe-actions"]')).toHaveAttribute("data-state", "open"); + }); + + it("右滑收起操作区", () => { + const { container } = renderSwipe(); + swipe(200, 120); + + swipe(120, 200); + + expect(container.querySelector('[data-slot="mobile-swipe-actions"]')).toHaveAttribute("data-state", "closed"); + }); + + it("轻微位移不触发滑动,避免点击被误判为左滑", () => { + const { container } = renderSwipe(); + + swipe(200, 190); + + expect(container.querySelector('[data-slot="mobile-swipe-actions"]')).toHaveAttribute("data-state", "closed"); + }); + + it("已滑开时点内容层是收起,不透传为行点击(与移动端通行心智一致,也避免误触)", () => { + const onRowClick = vi.fn(); + const { container } = render(); + swipe(200, 120); + expect(container.querySelector('[data-slot="mobile-swipe-actions"]')).toHaveAttribute("data-state", "open"); + + fireEvent.click(container.querySelector('[data-slot="mobile-swipe-content"]')!); + + expect(container.querySelector('[data-slot="mobile-swipe-actions"]')).toHaveAttribute("data-state", "closed"); + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it("关闭态操作区置为 inert,键盘不会聚焦到不播报的破坏性按钮", () => { + const { container } = renderSwipe(); + const actions = container.querySelector('[data-slot="mobile-swipe-actions"]')!; + expect(actions).toHaveAttribute("inert"); + + swipe(200, 120); + + expect(container.querySelector('[data-slot="mobile-swipe-actions"]')).not.toHaveAttribute("inert"); + }); + + it("内容位移取操作区实际宽度,操作块数量不同的页面不会滑出空白", () => { + const { container } = render(); + const actions = container.querySelector('[data-slot="mobile-swipe-actions"]') as HTMLElement; + Object.defineProperty(actions, "offsetWidth", { configurable: true, value: 64 }); + + swipe(200, 120); + + expect(container.querySelector('[data-slot="mobile-swipe-content"]')).toHaveStyle({ + transform: "translateX(-64px)", + }); + }); + + it("关闭态操作区被内容层盖住,不会漏在行右侧遮挡开关", () => { + const { container } = render( + {"删除"}}> + {"行"} + + ); + + // 操作区是 absolute,内容层必须自己定位才能盖住它(普通流元素会被 absolute 元素压住) + const content = container.querySelector('[data-slot="mobile-swipe-content"]')!; + expect(content.className).toContain("relative"); + expect(content.className).toContain("bg-background"); + }); + + it("透传的触摸回调仍然被调用,长按与左滑可挂在同一行上", () => { + const onTouchStart = vi.fn(); + const { container } = render( + + {"行"} + + ); + + fireEvent.touchStart(container.querySelector('[data-slot="mobile-swipe-row"]')!, { touches: [{ clientX: 10 }] }); + + expect(onTouchStart).toHaveBeenCalledTimes(1); + }); +}); + +describe("长按", () => { + it("按住超过时延触发回调", () => { + vi.useFakeTimers(); + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, 500)); + + act(() => result.current.onTouchStart(touchEvent(100, 100))); + act(() => void vi.advanceTimersByTime(500)); + + expect(onLongPress).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it("按住时手指轻微抖动不取消长按", () => { + vi.useFakeTimers(); + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress)); + + act(() => result.current.onTouchStart(touchEvent(100, 100))); + act(() => result.current.onTouchMove(touchEvent(104, 103))); + act(() => vi.advanceTimersByTime(600)); + + expect(onLongPress).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it("按住时手指明显移动则取消长按,避免与滑动/拖拽抢手势", () => { + vi.useFakeTimers(); + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress)); + + act(() => result.current.onTouchStart(touchEvent(100, 100))); + act(() => result.current.onTouchMove(touchEvent(140, 100))); + act(() => vi.advanceTimersByTime(600)); + + expect(onLongPress).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("提前松手不触发回调", () => { + vi.useFakeTimers(); + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, 500)); + + act(() => result.current.onTouchStart(touchEvent(100, 100))); + act(() => void vi.advanceTimersByTime(200)); + act(() => result.current.onTouchEnd()); + act(() => void vi.advanceTimersByTime(500)); + + expect(onLongPress).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +}); + +describe("选择模式外壳", () => { + it("顶栏显示已选数量,并提供取消与全选", () => { + const onCancel = vi.fn(); + const onToggleSelectAll = vi.fn(); + render( + + ); + + expect(screen.getByText(t("batch_selected", { count: 2 }))).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText(t("editor:cancel"))); + expect(onCancel).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByLabelText(t("script:select_all"))); + expect(onToggleSelectAll).toHaveBeenCalledTimes(1); + }); + + it("底部批量操作条渲染各页自定的动作", () => { + const onDelete = vi.fn(); + render( + + + {t("delete")} + + + ); + + fireEvent.click(screen.getByText(t("delete"))); + expect(onDelete).toHaveBeenCalledTimes(1); + }); +}); + +describe("底部操作面板", () => { + it("打开时展示标题与各项动作,点击后回调并请求关闭", () => { + const onOpenChange = vi.fn(); + const onSelect = vi.fn(); + render( + + {t("edit")} + + ); + + expect(screen.getByText("示例脚本")).toBeInTheDocument(); + expect(screen.getByText("1.0.0")).toBeInTheDocument(); + + fireEvent.click(screen.getByText(t("edit"))); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/pages/components/ui/mobile-list.tsx b/src/pages/components/ui/mobile-list.tsx new file mode 100644 index 000000000..405f4a1b6 --- /dev/null +++ b/src/pages/components/ui/mobile-list.tsx @@ -0,0 +1,340 @@ +import type React from "react"; +import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@App/pkg/utils/cn"; +import { Checkbox } from "./checkbox"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./sheet"; + +// 移动端列表行骨架。与桌面骨架刻意分家:移动端锚位是「图标在左、开关在右」, +// 还多出左滑外壳与选择模式,合并成一套就要同时认识两套相反的几何。 +const mobileListRowVariants = cva("flex h-16 items-center gap-3 px-4 border-b border-border transition-colors", { + variants: { + selected: { true: "bg-primary/10", false: "" }, + disabled: { true: "opacity-60", false: "" }, + }, + defaultVariants: { selected: false, disabled: false }, +}); + +type MobileListRowProps = React.ComponentProps<"div"> & VariantProps; + +function MobileListRow({ className, selected, disabled, ...props }: MobileListRowProps) { + return ( +
+ ); +} + +function MobileListRowLeading({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +// 主体区是真正的可点区域,做成按钮而非整行监听:开关落在右锚区、不在按钮内, +// 点开关就不会连带触发「打开操作面板」。 +function MobileListRowMain({ className, ...props }: React.ComponentProps<"button">) { + return ( + + {t("batch_selected", { count: selectedCount })} +
+ +
+ ); +} + +/** 选择模式底部批量操作条。 */ +function MobileBatchBar({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function MobileBatchBarButton({ + className, + destructive, + ...props +}: React.ComponentProps<"button"> & { destructive?: boolean }) { + return ( +
), @@ -153,6 +151,14 @@ const props = { filterItems: stableFilterItems, selectedFilters: { status: null, type: null, tags: null, source: null }, setSelectedFilters: vi.fn(), + selectedUuids: new Set(), + toggleSelect: vi.fn(), + toggleSelectAll: vi.fn(), + clearSelection: vi.fn(), + onBatchEnable: vi.fn(), + onBatchDisable: vi.fn(), + onBatchExport: vi.fn(), + onBatchDelete: vi.fn(), }; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -174,7 +180,7 @@ describe("ScriptListMobile 移动版", () => { expect(screen.getByTestId("mobile-search")).toHaveAttribute("data-keyword", ""); fireEvent.click(screen.getByTestId("mobile-search")); - expect(screen.getByTestId("trash-card-grid")).toHaveAttribute("data-keyword", "回收站关键词"); + expect(screen.getByTestId("trash-list-mobile")).toHaveAttribute("data-keyword", "回收站关键词"); fireEvent.click(screen.getByRole("button", { name: /已安装/ })); expect(screen.getByTestId("mobile-search")).toHaveAttribute("data-keyword", "已安装关键词"); @@ -281,12 +287,61 @@ describe("回收站 tab 消失时回落到已安装", () => { renderWithRouter(); fireEvent.click(await screen.findByRole("button", { name: /回收站/ })); - expect(screen.getByTestId("trash-card-grid")).toBeInTheDocument(); + expect(screen.getByTestId("trash-list-mobile")).toBeInTheDocument(); fireEvent.click(screen.getByTestId("purge-all")); expect(screen.queryByRole("button", { name: /回收站/ })).not.toBeInTheDocument(); - expect(screen.queryByTestId("trash-card-grid")).not.toBeInTheDocument(); - expect(screen.getByTestId("script-card-grid")).toBeInTheDocument(); + expect(screen.queryByTestId("trash-list-mobile")).not.toBeInTheDocument(); + expect(screen.getByTestId("script-rows-mobile")).toBeInTheDocument(); + }); +}); + +describe("移动端多选模式", () => { + const oneScript = [{ uuid: "u1", name: "示例脚本" }] as never; + + it("顶栏入口进入多选:顶栏换成取消/已选数/全选,底部出现批量操作条", () => { + renderWithRouter(); + + fireEvent.click(screen.getByRole("button", { name: t("script:multi_select") })); + + expect(screen.getByText(t("batch_selected", { count: 0 }))).toBeInTheDocument(); + expect(screen.getByLabelText(t("script:select_all"))).toBeInTheDocument(); + expect(screen.getByLabelText(t("editor:cancel"))).toBeInTheDocument(); + + const bar = document.querySelector('[data-slot="mobile-batch-bar"]')!; + expect(bar.textContent).toContain(t("enable")); + expect(bar.textContent).toContain(t("disable")); + expect(bar.textContent).toContain(t("export")); + expect(bar.textContent).toContain(t("delete")); + }); + + it("退出多选恢复原搜索栏并清空选择", () => { + const clearSelection = vi.fn(); + renderWithRouter(); + + fireEvent.click(screen.getByRole("button", { name: t("script:multi_select") })); + fireEvent.click(screen.getByLabelText(t("editor:cancel"))); + + expect(clearSelection).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("mobile-search")).toBeInTheDocument(); + expect(document.querySelector('[data-slot="mobile-batch-bar"]')).toBeNull(); + }); + + it("列表在多选期间被清空时退回普通视图,不留下悬空的批量操作条", () => { + const { rerender } = renderWithRouter(); + fireEvent.click(screen.getByRole("button", { name: t("script:multi_select") })); + expect(document.querySelector('[data-slot="mobile-batch-bar"]')).not.toBeNull(); + + rerender(); + + expect(document.querySelector('[data-slot="mobile-batch-bar"]')).toBeNull(); + expect(screen.getByTestId("mobile-search")).toBeInTheDocument(); + }); + + it("空列表时多选入口不可用", () => { + renderWithRouter(); + + expect(screen.getByRole("button", { name: t("script:multi_select") })).toBeDisabled(); }); }); diff --git a/src/pages/options/routes/ScriptList/ScriptListMobile.tsx b/src/pages/options/routes/ScriptList/ScriptListMobile.tsx index ce555955d..cda9aa6ca 100644 --- a/src/pages/options/routes/ScriptList/ScriptListMobile.tsx +++ b/src/pages/options/routes/ScriptList/ScriptListMobile.tsx @@ -1,12 +1,16 @@ -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; +import { CheckSquare } from "lucide-react"; +import { Popconfirm } from "@App/pages/components/ui/popconfirm"; +import { useSystemConfig } from "@App/pages/options/hooks/useSystemConfig"; +import { MobileBatchBar, MobileBatchBarButton, MobileSelectionHeader } from "@App/pages/components/ui/mobile-list"; import type { ScriptLoading } from "@App/pages/store/features/script"; import type { SearchFilterRequest } from "./SearchFilter"; import type { FilterBarProps } from "./FilterBar"; import FilterBar from "./FilterBar"; import { MobileSearchBar } from "./MobileSearchBar"; -import ScriptCardGrid from "./ScriptCardGrid"; -import TrashCardGrid from "./TrashCardGrid"; +import ScriptRowsMobile from "./ScriptRowsMobile"; +import TrashListMobile from "./TrashListMobile"; import { useTrashCount } from "./hooks"; export interface ScriptListMobileProps extends FilterBarProps { @@ -18,6 +22,14 @@ export interface ScriptListMobileProps extends FilterBarProps { searchRequest: SearchFilterRequest; setSearchRequest: (req: SearchFilterRequest) => void; scriptListSortOrderMove: (params: { active: string; over: string }) => void; + selectedUuids: Set; + toggleSelect: (uuid: string) => void; + toggleSelectAll: () => void; + clearSelection: () => void; + onBatchEnable: () => void; + onBatchDisable: () => void; + onBatchExport: () => void; + onBatchDelete: () => void; } function ScriptListMobile({ @@ -32,8 +44,32 @@ function ScriptListMobile({ filterItems, selectedFilters, setSelectedFilters, + selectedUuids, + toggleSelect, + toggleSelectAll, + clearSelection, + onBatchEnable, + onBatchDisable, + onBatchExport, + onBatchDelete, }: ScriptListMobileProps) { const { t } = useTranslation(); + const [selectionMode, setSelectionMode] = useState(false); + const [trashEnabled] = useSystemConfig("trash_enabled"); + + const exitSelection = useCallback(() => { + setSelectionMode(false); + clearSelection(); + }, [clearSelection]); + + // 长按进入多选:先亮起模式,再把被长按的那一行选上 + const enterSelectionWith = useCallback( + (uuid: string) => { + setSelectionMode(true); + if (!selectedUuids.has(uuid)) toggleSelect(uuid); + }, + [selectedUuids, toggleSelect] + ); const [activeTab, setActiveTab] = useState<"installed" | "trash">("installed"); const [trashSearchRequest, setTrashSearchRequest] = useState({ keyword: "", type: "auto" }); const isTrash = activeTab === "trash"; @@ -49,6 +85,53 @@ function ScriptListMobile({ if (!showTrashTab && activeTab === "trash") setActiveTab("installed"); } + // 列表在多选期间被清空(删除完/筛选变化)时退回普通视图,否则只剩一条批量操作条悬在空屏上 + if (selectionMode && !isTrash && scriptList.length > 0) { + return ( +
+ 0 && selectedUuids.size === scriptList.length} + onCancel={exitSelection} + onToggleSelectAll={toggleSelectAll} + /> + + + {t("enable")} + {t("disable")} + {t("export")} + { + onBatchDelete(); + exitSelection(); + }} + > + {t("delete")} + + +
+ ); + } + return (
{showTrashTab && ( @@ -79,13 +162,29 @@ function ScriptListMobile({
)} - +
+
+ +
+ {/* 长按不可发现,多选必须另有一个看得见的门(规格决策 7) */} + {!isTrash && ( + + )} +
{isTrash ? ( - + ) : ( <> - )} diff --git a/src/pages/options/routes/ScriptList/ScriptRowsMobile.test.tsx b/src/pages/options/routes/ScriptList/ScriptRowsMobile.test.tsx new file mode 100644 index 000000000..dcf8e780a --- /dev/null +++ b/src/pages/options/routes/ScriptList/ScriptRowsMobile.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; +import { cleanup, fireEvent, screen } from "@testing-library/react"; +import { t } from "@App/locales/locales"; +import { initTestLanguage } from "@Tests/initTestLanguage"; +import { renderWithRouterTooltip } from "@Tests/renderWithTooltip"; +import { + SCRIPT_RUN_STATUS_COMPLETE, + SCRIPT_STATUS_ENABLE, + SCRIPT_TYPE_BACKGROUND, + SCRIPT_TYPE_NORMAL, +} from "@App/app/repo/scripts"; +import type { ScriptLoading } from "@App/pages/store/features/script"; + +const { exportMock, checkUpdateMock } = vi.hoisted(() => ({ exportMock: vi.fn(), checkUpdateMock: vi.fn() })); +vi.mock("@App/pages/store/features/script", async () => { + const { createScriptStoreMock } = await import("@Tests/mocks/pageStores.ts"); + return createScriptStoreMock({ + requestEnableScript: vi.fn(() => Promise.resolve()), + scriptClient: { requestCheckUpdate: checkUpdateMock }, + synchronizeClient: { export: exportMock.mockResolvedValue(undefined) }, + }); +}); + +import ScriptRowsMobile from "./ScriptRowsMobile"; + +beforeAll(() => initTestLanguage("zh-CN")); +afterEach(cleanup); + +const script = { + uuid: "u1", + name: "示例脚本", + namespace: "test", + metadata: { version: ["1.0.0"] }, + type: SCRIPT_TYPE_NORMAL, + status: SCRIPT_STATUS_ENABLE, + sort: 0, + runStatus: SCRIPT_RUN_STATUS_COMPLETE, + createtime: 1700000000000, + updatetime: 1700000000000, + checktime: 1700000000000, +} satisfies ScriptLoading; + +const backgroundScript = { ...script, uuid: "u2", name: "后台脚本", type: SCRIPT_TYPE_BACKGROUND }; + +const baseProps = { + scriptList: [script] as ScriptLoading[], + loadingList: false, + updateScripts: vi.fn(), + handleDelete: vi.fn(), + handleRunStop: vi.fn(), + scriptListSortOrderMove: vi.fn(), + selectionMode: false, + selectedUuids: new Set(), + toggleSelect: vi.fn(), + onEnterSelectionMode: vi.fn(), +}; + +// dnd-kit 会把 role="button" 挂到外层可拖拽容器上,按角色取会同时命中整行; +// 这里直接点主体槽位,与真实点击落点一致。 +const tapRow = (index = 0) => fireEvent.click(document.querySelectorAll('[data-slot="mobile-list-row-main"]')[index]); + +describe("移动端脚本行", () => { + it("空列表与加载态沿用既有提示", () => { + const { rerender } = renderWithRouterTooltip(); + expect(screen.getByText(t("no_scripts"))).toBeInTheDocument(); + + rerender(); + expect(screen.getByText(t("loading"))).toBeInTheDocument(); + }); + + it("行采用移动端槽位骨架,开关在右锚区", () => { + const { container } = renderWithRouterTooltip(); + + expect(container.querySelectorAll('[data-slot="mobile-list-row"]')).toHaveLength(1); + const trailing = container.querySelector('[data-slot="mobile-list-row-trailing"]')!; + expect(trailing.querySelector('[role="switch"]')).not.toBeNull(); + }); + + it("整行点击打开底部操作面板,面板含删除等动作", () => { + renderWithRouterTooltip(); + + tapRow(); + + const sheet = document.querySelector('[data-slot="mobile-action-sheet"]')!; + expect(sheet).not.toBeNull(); + expect(sheet.textContent).toContain(t("edit")); + expect(sheet.textContent).toContain(t("check_update")); + expect(sheet.textContent).toContain(t("export")); + expect(sheet.textContent).toContain(t("delete")); + }); + + it("后台脚本的面板保留运行入口,普通脚本不渲染", () => { + renderWithRouterTooltip(); + tapRow(); + + expect(document.querySelector('[data-slot="mobile-action-sheet"]')!.textContent).toContain(t("editor:run")); + + cleanup(); + renderWithRouterTooltip(); + tapRow(); + expect(document.querySelector('[data-slot="mobile-action-sheet"]')!.textContent).not.toContain(t("editor:run")); + }); + + it("面板里的导出按单个脚本导出", () => { + renderWithRouterTooltip(); + tapRow(); + + fireEvent.click(screen.getByText(t("export"))); + + expect(exportMock).toHaveBeenCalledWith(["u1"]); + }); + + it("左滑露出编辑与删除", () => { + const { container } = renderWithRouterTooltip(); + const row = container.querySelector('[data-slot="mobile-swipe-row"]')!; + + fireEvent.touchStart(row, { touches: [{ clientX: 200 }] }); + fireEvent.touchEnd(row, { changedTouches: [{ clientX: 100 }] }); + + const actions = container.querySelector('[data-slot="mobile-swipe-actions"]')!; + expect(actions).toHaveAttribute("data-state", "open"); + expect(actions.textContent).toContain(t("edit")); + expect(actions.textContent).toContain(t("delete")); + }); + + it("长按进入多选模式并选中该行", () => { + vi.useFakeTimers(); + const onEnterSelectionMode = vi.fn(); + const { container } = renderWithRouterTooltip( + + ); + + fireEvent.touchStart(container.querySelector('[data-slot="mobile-swipe-row"]')!, { touches: [{ clientX: 10 }] }); + vi.advanceTimersByTime(600); + + expect(onEnterSelectionMode).toHaveBeenCalledWith("u1"); + vi.useRealTimers(); + }); + + it("多选模式下左端出现勾选框、开关隐藏,点击行切换选中", () => { + const toggleSelect = vi.fn(); + const { container } = renderWithRouterTooltip( + + ); + + const leading = container.querySelector('[data-slot="mobile-list-row-leading"]')!; + expect(leading.querySelector('[role="checkbox"]')).not.toBeNull(); + expect(container.querySelector('[role="switch"]')).toBeNull(); + expect(container.querySelector('[data-slot="mobile-list-row"]')!.className).toContain("bg-primary/10"); + + tapRow(); + expect(toggleSelect).toHaveBeenCalledWith("u1"); + expect(document.querySelector('[data-slot="mobile-action-sheet"]')).toBeNull(); + }); +}); diff --git a/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx b/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx new file mode 100644 index 000000000..ccec6f977 --- /dev/null +++ b/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx @@ -0,0 +1,438 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { GripVertical } from "lucide-react"; +import { + SCRIPT_RUN_STATUS_RUNNING, + SCRIPT_STATUS_DISABLE, + SCRIPT_TYPE_BACKGROUND, + SCRIPT_TYPE_CRONTAB, + SCRIPT_TYPE_NORMAL, +} from "@App/app/repo/scripts"; +import type { ScriptLoading } from "@App/pages/store/features/script"; +import { requestEnableScript, scriptClient, synchronizeClient } from "@App/pages/store/features/script"; +import { EmptyState } from "@App/pages/components/ui/empty-state"; +import { LoadingState } from "@App/pages/components/ui/loading-state"; +import { Checkbox } from "@App/pages/components/ui/checkbox"; +import { notify } from "@App/pages/components/ui/toast"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@App/pages/components/ui/alert-dialog"; +import { + MobileActionSheet, + MobileActionSheetItem, + MobileListRow, + MobileListRowLeading, + MobileListRowMain, + MobileListRowTrailing, + MobileSwipeRow, + useLongPress, +} from "@App/pages/components/ui/mobile-list"; +import { useSystemConfig } from "@App/pages/options/hooks/useSystemConfig"; +import { i18nName } from "@App/locales/locales"; +import { versionDisplay } from "@App/pages/utils"; +import { + EnableSwitch, + RunStatusBadge, + ScheduleNextRun, + ScriptIcon, + getScriptHomePage, + openExternalUrl, + scriptTypeLabel, +} from "./components"; + +import type { DragEndEvent } from "@dnd-kit/core"; +import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +// ========== 拖拽上下文 ========== +type DragCtx = Pick, "listeners" | "setActivatorNodeRef"> | null; +const SortableDragCtx = createContext(null); + +function DraggableRow({ id, children }: { id: string; children: React.ReactNode }) { + const { setNodeRef, transform, transition, listeners, setActivatorNodeRef, isDragging, attributes } = useSortable({ + id, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + }; + + const ctxValue = useMemo(() => ({ listeners, setActivatorNodeRef }), [listeners, setActivatorNodeRef]); + + return ( + +
+ {children} +
+
+ ); +} + +function DragHandle() { + const sortable = useContext(SortableDragCtx); + if (!sortable) return null; + const { setActivatorNodeRef, listeners } = sortable; + return ( + + + + ); +} + +export interface ScriptRowsMobileProps { + scriptList: ScriptLoading[]; + loadingList: boolean; + updateScripts: (uuids: string[], data: Partial) => void; + handleDelete: (script: ScriptLoading) => void; + handleRunStop: (script: ScriptLoading) => void; + scriptListSortOrderMove: (params: { active: string; over: string }) => void; + selectionMode: boolean; + selectedUuids: Set; + toggleSelect: (uuid: string) => void; + onEnterSelectionMode: (uuid: string) => void; +} + +export default function ScriptRowsMobile({ + scriptList, + loadingList, + updateScripts, + handleDelete, + handleRunStop, + scriptListSortOrderMove, + selectionMode, + selectedUuids, + toggleSelect, + onEnterSelectionMode, +}: ScriptRowsMobileProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [sheetUuid, setSheetUuid] = useState(null); + // 同时只允许一行滑开:否则多行会各自挂着「删除」块,误触面积成倍增加 + const [swipeOpenUuid, setSwipeOpenUuid] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [trashEnabled] = useSystemConfig("trash_enabled"); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 3 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ); + + const sortableIds = useMemo(() => scriptList.map((s) => s.uuid), [scriptList]); + const a11y = useMemo(() => ({ container: document.body }), []); + const handleSwipeOpenChange = useCallback( + (uuid: string, open: boolean) => setSwipeOpenUuid((prev) => (open ? uuid : prev === uuid ? null : prev)), + [] + ); + + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (over && active.id !== over.id) { + scriptListSortOrderMove({ active: `${active.id}`, over: `${over.id}` }); + } + }, + [scriptListSortOrderMove] + ); + + const handleEnable = useCallback( + (script: ScriptLoading, checked: boolean) => { + updateScripts([script.uuid], { enableLoading: true }); + requestEnableScript({ uuid: script.uuid, enable: checked }).catch(() => { + updateScripts([script.uuid], { enableLoading: false }); + }); + }, + [updateScripts] + ); + + const sheetScript = sheetUuid ? (scriptList.find((s) => s.uuid === sheetUuid) ?? null) : null; + + if (loadingList) { + return ( +
+ +
+ ); + } + + if (scriptList.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ + + {scriptList.map((script) => ( + + + + ))} + + + + {sheetScript && ( + !open && setSheetUuid(null)} + navigate={navigate} + onRunStop={handleRunStop} + onDelete={setPendingDelete} + /> + )} + + !open && setPendingDelete(null)}> + + + {t("delete")} + + {pendingDelete && + ((trashEnabled ?? true) + ? t("script:confirm_delete_script_trash_content", { name: i18nName(pendingDelete) }) + : t("script:confirm_delete_script_content", { name: i18nName(pendingDelete) }))} + + + + {t("editor:cancel")} + { + if (pendingDelete) handleDelete(pendingDelete); + setPendingDelete(null); + }} + > + {t("delete")} + + + + +
+ ); +} + +// ========== 单行 ========== +interface ScriptRowMobileProps { + script: ScriptLoading; + swipeOpen: boolean; + onSwipeOpenChange: (uuid: string, open: boolean) => void; + selectionMode: boolean; + selected: boolean; + onEnable: (script: ScriptLoading, checked: boolean) => void; + onOpenActions: (uuid: string) => void; + onToggleSelect: (uuid: string) => void; + onEnterSelectionMode: (uuid: string) => void; + onDelete: (script: ScriptLoading) => void; + navigate: ReturnType; +} + +const ScriptRowMobile = React.memo( + ({ + script, + swipeOpen, + onSwipeOpenChange, + selectionMode, + selected, + onEnable, + onOpenActions, + onToggleSelect, + onEnterSelectionMode, + onDelete, + navigate, + }: ScriptRowMobileProps) => { + const { t } = useTranslation(); + const name = i18nName(script); + const isBackground = script.type === SCRIPT_TYPE_BACKGROUND || script.type === SCRIPT_TYPE_CRONTAB; + const siteCount = script.favorite?.length ?? 0; + const meta = [ + versionDisplay(script.metadata?.version?.[0]), + scriptTypeLabel(script.type, t), + script.type === SCRIPT_TYPE_NORMAL ? t("script:site_count", { count: siteCount }) : "", + ] + .filter(Boolean) + .join(" · "); + + const longPress = useLongPress( + useCallback(() => onEnterSelectionMode(script.uuid), [onEnterSelectionMode, script]) + ); + + return ( + onSwipeOpenChange(script.uuid, open)} + {...(selectionMode ? {} : longPress)} + actions={ + <> + + + + } + > + + + {selectionMode ? ( + onToggleSelect(script.uuid)} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + )} + + + + (selectionMode ? onToggleSelect(script.uuid) : onOpenActions(script.uuid))}> + {name} + {/* 窄屏放不下时按顺序裁切而不是折行:行高固定,折行会把次行挤出行外 */} + + {meta} + {isBackground && ( + + + + )} + + + + + {!selectionMode && ( + + onEnable(script, checked)} + /> + + )} + + + ); + }, + // 与桌面行同理(见 ScriptTable.tsx 的 ScriptRow):store 对任一字段变更都会为该行生成新对象引用, + // 逐字段比较会漏掉 name/metadata/selfMetadata 等,导致行显示过期的名称或版本。 + (prev, next) => + prev.script === next.script && + prev.selectionMode === next.selectionMode && + prev.selected === next.selected && + prev.swipeOpen === next.swipeOpen +); +ScriptRowMobile.displayName = "ScriptRowMobile"; + +// ========== 底部操作面板 ========== +function ScriptActionSheet({ + script, + onOpenChange, + navigate, + onRunStop, + onDelete, +}: { + script: ScriptLoading; + onOpenChange: (open: boolean) => void; + navigate: ReturnType; + onRunStop: (script: ScriptLoading) => void; + onDelete: (script: ScriptLoading) => void; +}) { + const { t } = useTranslation(); + const name = i18nName(script); + const home = getScriptHomePage(script.metadata); + const isBackground = script.type !== SCRIPT_TYPE_NORMAL; + const isRunning = script.runStatus === SCRIPT_RUN_STATUS_RUNNING; + + return ( + } + > + navigate(`/script/editor/${script.uuid}`)}> + {t("edit")} + + {script.config && ( + navigate(`/?userConfig=${script.uuid}`)}> + {t("editor:user_config")} + + )} + {script.metadata?.cloudcat && ( + navigate(`/?cloud=${script.uuid}`)}> + {t("editor:upload_to_cloud")} + + )} + {home && ( + openExternalUrl(home)}>{t("script:homepage")} + )} + {isBackground && ( + onRunStop(script)}> + {isRunning ? t("stop") : t("editor:run")} + + )} + void scriptClient.requestCheckUpdate(script.uuid)}> + {t("check_update")} + + { + const id = notify.loading(t("editor:exporting")); + void synchronizeClient.export([script.uuid]).then(() => notify.success(t("settings:export_success"), { id })); + }} + > + {t("export")} + + onDelete(script)}> + {t("delete")} + + + ); +} diff --git a/src/pages/options/routes/ScriptList/ScriptTable.test.tsx b/src/pages/options/routes/ScriptList/ScriptTable.test.tsx index 30df6d589..704d8624e 100644 --- a/src/pages/options/routes/ScriptList/ScriptTable.test.tsx +++ b/src/pages/options/routes/ScriptList/ScriptTable.test.tsx @@ -20,7 +20,11 @@ vi.mock("@App/pages/store/features/script", async () => { }); vi.mock("./Toolbar", () => ({ Toolbar: () => null })); vi.mock("./FilterBar", () => ({ default: () => null })); -vi.mock("./BatchActionsBar", () => ({ default: () => null })); +vi.mock("./BatchActionsBar", () => ({ + default: ({ allSelected, onToggleSelectAll }: { allSelected: boolean; onToggleSelectAll: () => void }) => ( + - ); -} - export interface ScriptTableProps extends FilterBarProps { /** 顶栏最左侧内容(tabs),透传给 Toolbar 取代标题槽位 */ leading?: React.ReactNode; @@ -135,7 +98,6 @@ export interface ScriptTableProps extends FilterBarProps { updateScripts: (uuids: string[], data: Partial) => void; handleDelete: (script: ScriptLoading) => void; handleRunStop: (script: ScriptLoading) => void; - setViewMode: (mode: "table" | "card") => void; searchRequest: SearchFilterRequest; setSearchRequest: (req: SearchFilterRequest) => void; totalCount: number; @@ -161,7 +123,6 @@ export default function ScriptTable({ updateScripts, handleDelete, handleRunStop, - setViewMode, searchRequest, setSearchRequest, totalCount, @@ -200,8 +161,7 @@ export default function ScriptTable({ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ); - // 列头点击排序;激活时禁用手动拖拽,状态由脚本列表偏好持久化。 - const handleSort = useCallback((key: SortKey) => setSortState((s) => nextSortState(s, key)), [setSortState]); + // 排序激活时禁用手动拖拽,状态由脚本列表偏好持久化。 const isSorted = sortState.key !== null; const displayList = useMemo(() => sortScriptList(scriptList, sortState), [scriptList, sortState]); @@ -221,7 +181,6 @@ export default function ScriptTable({ const a11y = useMemo(() => ({ container: document.body }), []); const isAllSelected = scriptList.length > 0 && selectedUuids.size === scriptList.length; - const isIndeterminate = selectedUuids.size > 0 && selectedUuids.size < scriptList.length; return (
@@ -229,8 +188,8 @@ export default function ScriptTable({ @@ -239,6 +198,8 @@ export default function ScriptTable({ {/* 批量操作栏 */}
- {/* 表格 */} + {/* 列表 */}
- {/* 表头 */} -
-
- -
-
-
- -
-
- -
-
{t("source")}
-
{t("script:tags")}
-
- {t("script:apply_to_run_status")} -
-
- -
-
- {t("action")} -
-
- {/* 加载状态 */} {loadingList && } @@ -358,87 +279,70 @@ function ScriptRowInner({ script, selected, onSelect, onEnable, onDelete, onRunS const name = i18nName(script); return ( -
- {/* 复选框 */} -
- onSelect(script.uuid)} /> -
- - {/* 拖拽手柄 */} -
- -
- - {/* 开关 */} -
- onEnable(script, checked)} - /> -
- - {/* 脚本名称 + 元信息 */} -
+ + + + onSelect(script.uuid)} /> + + + + + + onEnable(script, checked)} + /> + + + + -
- +
+ {name} - - {[versionDisplay(version), scriptTypeLabel(script.type, t), author].filter(Boolean).join(" · ")} + {/* 来源与标签降级为元信息行的行内徽章:原来的两个固定列在多数脚本上半空,占着名称列的宽度 */} + + + {[versionDisplay(version), scriptTypeLabel(script.type, t), author].filter(Boolean).join(" · ")} + + +
-
- - {/* 来源 */} -
- -
- - {/* 标签 */} -
- -
- - {/* 应用至 / 运行状态 */} -
- {isBackground ? ( -
- - - - - - - {typeTooltip} - - -
- ) : ( - - )} -
- - {/* 最后更新 */} -
- -
+
+ + + {/* 窄窗口下整槽让位给名称:运行状态仍可从操作区的运行/停止图标读出, + 这里只在 900px 以下藏掉站点点与下次运行时间。整槽同进同退,右缘对齐不受影响。 */} +
+ {isBackground ? ( +
+ + + + + + + {typeTooltip} + + +
+ ) : ( + + )} +
+
+ +
+
- {/* 操作(行内图标按钮,已去掉 ⋯ 更多菜单) */} - -
+ {/* 槽位数固定为三,宽度随之恒定,右缘不再随脚本类型参差 */} + + + + ); } @@ -455,7 +359,7 @@ function TagBadges({ metadata, selfMetadata }: { metadata: SCMetadata; selfMetad const tags = parseTags(meta); if (tags.length === 0) return null; return ( -
+
{tags.slice(0, 2).map((tag) => { const color = getTagColor(tag); return ( diff --git a/src/pages/options/routes/ScriptList/SelectionBar.tsx b/src/pages/options/routes/ScriptList/SelectionBar.tsx index ed76e7f86..d455c019b 100644 --- a/src/pages/options/routes/ScriptList/SelectionBar.tsx +++ b/src/pages/options/routes/ScriptList/SelectionBar.tsx @@ -2,9 +2,13 @@ import { useState } from "react"; import { X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "@App/pkg/utils/cn"; +import { Checkbox } from "@App/pages/components/ui/checkbox"; export interface SelectionBarProps { selectedCount: number; + /** 当前可见项是否已全部选中;栏只在有选中项时展开,故未全选即半选 */ + allSelected: boolean; + onToggleSelectAll: () => void; onClose: () => void; /** 栏中间的操作按钮,用 SelectionBarButton 渲染 */ children: React.ReactNode; @@ -14,7 +18,13 @@ export interface SelectionBarProps { * 多选态操作栏外壳:撑开时把同一 h-11 窗口内位于其下方的筛选行顶出可视区, * 因此调用方须把本组件与筛选行一起放进 `h-11 overflow-hidden` 的容器。 */ -export default function SelectionBar({ selectedCount, onClose, children }: SelectionBarProps) { +export default function SelectionBar({ + selectedCount, + allSelected, + onToggleSelectAll, + onClose, + children, +}: SelectionBarProps) { const { t } = useTranslation(); const isOpen = selectedCount > 0; const [mounted, setMounted] = useState(false); @@ -28,7 +38,10 @@ export default function SelectionBar({ selectedCount, onClose, children }: Selec return (
+ + {t("batch_selected", { count: selectedCount })}
diff --git a/src/pages/options/routes/ScriptList/SortMenu.test.tsx b/src/pages/options/routes/ScriptList/SortMenu.test.tsx new file mode 100644 index 000000000..e2ad0c41d --- /dev/null +++ b/src/pages/options/routes/ScriptList/SortMenu.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { initTestLanguage } from "@Tests/initTestLanguage"; +import { SortMenu } from "./SortMenu"; + +beforeAll(() => initTestLanguage("zh-CN")); + +afterEach(cleanup); + +// Radix 下拉需要 pointerDown 才会展开(同 FilterBar.test.tsx 的既有写法) +function openMenu() { + const trigger = screen.getByTestId("sort-menu"); + fireEvent.pointerDown(trigger); + fireEvent.click(trigger); +} + +const options = [ + { key: "status", label: "状态" }, + { key: "name", label: "名称" }, +] as const; + +describe("SortMenu 排序下拉", () => { + it("未排序时显示默认项,表头消失后仍能看出当前排序维度", () => { + render(); + + expect(screen.getByTestId("sort-menu")).toHaveTextContent("默认"); + }); + + it("已排序时显示该维度的名称", () => { + render(); + + expect(screen.getByTestId("sort-menu")).toHaveTextContent("名称"); + }); + + it("点击未激活项按升序排序", () => { + const onChange = vi.fn(); + render(); + + openMenu(); + fireEvent.click(screen.getByText("状态")); + + expect(onChange).toHaveBeenCalledWith({ key: "status", order: "asc" }); + }); + + it("重复点击同一项走升序→降序→取消的三态循环", () => { + const onChange = vi.fn(); + const { rerender } = render( + + ); + + openMenu(); + fireEvent.click(screen.getByText("状态")); + expect(onChange).toHaveBeenLastCalledWith({ key: "status", order: "desc" }); + + rerender(); + openMenu(); + fireEvent.click(screen.getByText("状态")); + expect(onChange).toHaveBeenLastCalledWith({ key: null, order: "asc" }); + }); + + it("菜单里有「默认」项,一步回到自然顺序(拖拽顺序)", () => { + const onChange = vi.fn(); + render(); + + openMenu(); + fireEvent.click(screen.getByText("默认")); + + expect(onChange).toHaveBeenCalledWith({ key: null, order: "asc" }); + }); +}); diff --git a/src/pages/options/routes/ScriptList/SortMenu.tsx b/src/pages/options/routes/ScriptList/SortMenu.tsx new file mode 100644 index 000000000..33afdc308 --- /dev/null +++ b/src/pages/options/routes/ScriptList/SortMenu.tsx @@ -0,0 +1,79 @@ +import { ArrowUpDown, Check, ChevronDown, ChevronUp } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@App/pkg/utils/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@App/pages/components/ui/dropdown-menu"; +import { nextSortState } from "./sort"; +import type { SortOrder } from "./sort"; + +export interface SortMenuOption { + key: K; + label: string; +} + +export interface SortMenuProps { + options: SortMenuOption[]; + value: { key: K | null; order: SortOrder }; + onChange: (next: { key: K | null; order: SortOrder }) => void; + className?: string; +} + +/** + * 排序入口。列表行取代表格后没有可点的表头,排序改由本下拉承载; + * 三态循环仍复用 sort.ts 的 nextSortState,避免规则在两处各写一份而漂移。 + */ +export function SortMenu({ options, value, onChange, className }: SortMenuProps) { + const { t } = useTranslation(); + const active = options.find((o) => o.key === value.key); + + return ( + + + + + + {/* 三态循环要点两次才回得到自然顺序,给它一个一步可达的入口 */} + onChange({ key: null, order: "asc" })} + className="flex items-center justify-between gap-2" + > + {t("script:sort_default")} + {value.key === null && } + + {options.map((o) => { + const isActive = o.key === value.key; + return ( + onChange(nextSortState(value, o.key))} + className="flex items-center justify-between gap-2" + > + {o.label} + {isActive && + (value.order === "asc" ? ( + + ) : ( + + ))} + + ); + })} + + + ); +} diff --git a/src/pages/options/routes/ScriptList/Toolbar.tsx b/src/pages/options/routes/ScriptList/Toolbar.tsx index 55c061e0f..bd1622773 100644 --- a/src/pages/options/routes/ScriptList/Toolbar.tsx +++ b/src/pages/options/routes/ScriptList/Toolbar.tsx @@ -1,4 +1,4 @@ -import { Table2, LayoutGrid, ChevronDown, Check } from "lucide-react"; +import { ChevronDown, Check } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { cn } from "@App/pkg/utils/cn"; @@ -11,6 +11,8 @@ import { DropdownMenuTrigger, } from "@App/pages/components/ui/dropdown-menu"; import { SearchInput } from "@App/pages/components/ui/search-input"; +import { SortMenu } from "./SortMenu"; +import type { SortKey, SortState } from "./sort"; // 搜索范围:auto = 名称 + 代码 const scopeOptions: { @@ -31,53 +33,33 @@ function scopeLabelOf(type: SearchFilterRequest["type"], t: TFunction): string { return (scopeOptions.find((o) => o.type === type) ?? scopeOptions[0]).label(t); } -// ========== 视图切换按钮 ========== -function ViewToggleButton({ - active, - onClick, - label, - children, -}: { - active: boolean; - onClick: () => void; - label: string; - children: React.ReactNode; -}) { - return ( - - ); -} - export interface ToolbarProps { totalCount: number; - viewMode: "table" | "card"; - setViewMode: (mode: "table" | "card") => void; + sortState: SortState; + setSortState: (next: SortState) => void; searchRequest: SearchFilterRequest; setSearchRequest: (req: SearchFilterRequest) => void; /** 顶栏最左侧的内容;传入时取代「标题 + 数量」(回收站 tab 引入后由 tabs 占据该槽位) */ leading?: React.ReactNode; } -/** - * 脚本列表顶栏:标题+数量(或 leading 槽位)、搜索框、视图切换、新建脚本。 - * 表格视图与卡片视图共用本组件,仅 viewMode 决定激活态,避免两份顶栏代码分叉 - * (此前卡片视图的顶栏漏掉了「新建脚本」按钮即源于此类重复)。 - */ -export function Toolbar({ totalCount, viewMode, setViewMode, searchRequest, setSearchRequest, leading }: ToolbarProps) { +/** 脚本列表顶栏:标题+数量(或 leading 槽位)、搜索框、排序、新建脚本。 */ +export function Toolbar({ + totalCount, + sortState, + setSortState, + searchRequest, + setSearchRequest, + leading, +}: ToolbarProps) { const { t } = useTranslation(); + const sortOptions: { key: SortKey; label: string }[] = [ + { key: "status", label: t("script:script_list.sidebar.status") }, + { key: "name", label: t("name") }, + { key: "updatetime", label: t("logs:last_updated") }, + ]; return ( -
+
{leading ?? ( // 标题 + 数量
@@ -132,15 +114,8 @@ export function Toolbar({ totalCount, viewMode, setViewMode, searchRequest, setS } /> - {/* 视图切换 */} -
- setViewMode("table")} label="Table view"> - - - setViewMode("card")} label="Card view"> - - -
+ {/* 排序(表头消失后的排序入口) */} + {/* 新建脚本 */} diff --git a/src/pages/options/routes/ScriptList/TrashCardGrid.tsx b/src/pages/options/routes/ScriptList/TrashCardGrid.tsx deleted file mode 100644 index 345858d25..000000000 --- a/src/pages/options/routes/ScriptList/TrashCardGrid.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; -import { RotateCcw, Settings2, Trash2, TriangleAlert } from "lucide-react"; -import type { TrashScript } from "@App/app/repo/trash_script"; -import type { InstallSource } from "@App/app/service/service_worker/types"; -import { requestTrashScripts, requestRestoreScripts, requestPurgeScripts } from "@App/pages/store/features/script"; -import { notify } from "@App/pages/components/ui/toast"; -import { useSystemConfig } from "@App/pages/options/hooks/useSystemConfig"; -import { Popconfirm } from "@App/pages/components/ui/popconfirm"; -import { EmptyState } from "@App/pages/components/ui/empty-state"; -import { Surface } from "@App/pages/components/ui/surface"; -import { semTime } from "@App/locales/relative-date"; -import { versionDisplay } from "@App/pages/utils"; -import { subscribeMessage } from "@App/pages/store/global"; -import { HookManager } from "@App/pkg/utils/hookManager"; -import type { TDeleteScript, TInstallScript } from "@App/app/service/queue"; -import { ScriptIcon } from "./components"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@App/pages/components/ui/alert-dialog"; - -const DAY = 24 * 60 * 60 * 1000; - -const SOURCE_KEY: Record = { - user: "script:trash_source_local", - sync: "script:trash_source_other_device", - subscribe: "script:trash_source_subscribe", -}; - -const sourceKeyOf = (deleteBy: InstallSource) => SOURCE_KEY[deleteBy] ?? "script:trash_source_other"; - -type SourceFilter = "all" | "user" | "sync" | "subscribe"; - -const FILTERS: { value: SourceFilter; key: string }[] = [ - { value: "all", key: "script:trash_filter_all" }, - { value: "user", key: "script:trash_source_local" }, - { value: "sync", key: "script:trash_source_other_device" }, - { value: "subscribe", key: "script:trash_source_subscribe" }, -]; - -export default function TrashCardGrid({ - keyword = "", - onCountChange, -}: { - keyword?: string; - /** 回报条目数,供 tab 角标显示(彻底删除/清空只在本组件内发生,外部感知不到) */ - onCountChange?: (n: number) => void; -}) { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [list, setList] = useState([]); - const [purgeAllOpen, setPurgeAllOpen] = useState(false); - const [sourceFilter, setSourceFilter] = useState("all"); - const [retentionDays] = useSystemConfig("trash_retention_days"); - const [trashEnabled] = useSystemConfig("trash_enabled"); - - const reload = useCallback( - () => - requestTrashScripts().then((l) => { - setList(l ?? []); - onCountChange?.(l?.length ?? 0); - }), - [onCountChange] - ); - - // setState 只发生在异步回调中,避免 effect 体内同步 setState(同 Logger/hooks.ts 的既有写法) - useEffect(() => { - void reload(); - const hooks = new HookManager(); - hooks.append( - subscribeMessage("trashScripts", () => void reload()), - subscribeMessage("deleteScripts", () => void reload()), - subscribeMessage("installScript", () => void reload()) - ); - return hooks.unhook; - }, [reload]); - - const configuredDays = retentionDays ?? 30; - // 关闭回收站后残留条目不再自动清理,倒计时须归零,否则会倒数一个永远不会到来的期限 - const days = (trashEnabled ?? true) ? configuredDays : 0; - - // days=0 有两种含义(关闭 / 永不清理),空状态说明须分别措辞,不能插值出「保留 0 天」 - const emptyDesc = !(trashEnabled ?? true) - ? t("script:trash_hint_disabled") - : days - ? t("script:trash_empty_desc", { days }) - : t("script:trash_empty_desc_never"); - - const daysLeftOf = useCallback( - (item: TrashScript) => (days ? Math.ceil((item.deleteTime + days * DAY - Date.now()) / DAY) : null), - [days] - ); - - const onRestore = useCallback( - async (uuids: string[]) => { - try { - const ret = await requestRestoreScripts(uuids); - if (ret) { - for (const c of ret.conflicts) { - notify.error(t("script:trash_restore_conflict", { name: c.name })); - } - if (ret.restored.length) { - notify.success(t("script:trash_restore_success", { count: ret.restored.length })); - } - } - } catch { - // 条目可能已被其他窗口或到期清理抢先处理(SW 抛 trash scripts not found);失败也要重拉列表清掉陈旧行 - notify.error(t("script:trash_undo_failed")); - } - await reload(); - }, - [reload, t] - ); - - const onPurge = useCallback( - async (uuids: string[]) => { - try { - await requestPurgeScripts(uuids); - notify.success(t("script:trash_purge_success", { count: uuids.length })); - } catch { - notify.error(t("script:delete_failed")); - } - await reload(); - }, - [reload, t] - ); - - const visible = useMemo(() => { - const kw = keyword.trim().toLowerCase(); - return list.filter( - (item) => - (sourceFilter === "all" || item.deleteBy === sourceFilter) && - (!kw || item.name.toLowerCase().includes(kw) || item.namespace.toLowerCase().includes(kw)) - ); - }, [list, sourceFilter, keyword]); - - return ( -
-
- {FILTERS.map((f) => ( - - ))} -
- -
- - {!(trashEnabled ?? true) - ? t("script:trash_hint_disabled") - : days - ? t("script:trash_hint", { days }) - : t("script:trash_hint_never")} - - -
- -
- - - - - {t("script:trash_purge_confirm_title")} - - {t("script:trash_purge_confirm_body", { count: list.length })} - - -
- - {t("script:trash_purge_confirm_warn")} -
- - {t("editor:cancel")} - void onPurge(list.map((i) => i.uuid))}> - {t("script:trash_purge")} - - -
-
- -
- {!list.length ? ( - - ) : ( - visible.map((item) => { - const left = daysLeftOf(item); - const urgent = left !== null && left <= 3; - return ( - -
- - {item.name} -
- - {t(sourceKeyOf(item.deleteBy))} - -
- - {[item.namespace, versionDisplay(item.metadata?.version?.[0])].filter(Boolean).join(" · ")} - -
- {semTime(new Date(item.deleteTime))} - {"·"} - {urgent && } - - {left === null - ? t("script:trash_hint_never") - : left <= 0 - ? t("script:trash_expire_today") - : t("script:trash_expire_in", { days: left })} - -
- - void onPurge([item.uuid])} - > - - -
- - ); - }) - )} -
-
- ); -} diff --git a/src/pages/options/routes/ScriptList/TrashListMobile.tsx b/src/pages/options/routes/ScriptList/TrashListMobile.tsx new file mode 100644 index 000000000..ca45a497b --- /dev/null +++ b/src/pages/options/routes/ScriptList/TrashListMobile.tsx @@ -0,0 +1,484 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { CheckSquare, Settings2, Trash2, TriangleAlert } from "lucide-react"; +import type { TrashScript } from "@App/app/repo/trash_script"; +import type { InstallSource } from "@App/app/service/service_worker/types"; +import { requestTrashScripts, requestRestoreScripts, requestPurgeScripts } from "@App/pages/store/features/script"; +import { notify } from "@App/pages/components/ui/toast"; +import { useSystemConfig } from "@App/pages/options/hooks/useSystemConfig"; +import { Popconfirm } from "@App/pages/components/ui/popconfirm"; +import { EmptyState } from "@App/pages/components/ui/empty-state"; +import { Checkbox } from "@App/pages/components/ui/checkbox"; +import { + MobileActionSheet, + MobileActionSheetItem, + MobileBatchBar, + MobileBatchBarButton, + MobileListRow, + MobileListRowLeading, + MobileListRowMain, + MobileListRowTrailing, + MobileSelectionHeader, + MobileSwipeRow, + useLongPress, +} from "@App/pages/components/ui/mobile-list"; +import { semTime } from "@App/locales/relative-date"; +import { versionDisplay } from "@App/pages/utils"; +import { subscribeMessage } from "@App/pages/store/global"; +import { HookManager } from "@App/pkg/utils/hookManager"; +import type { TDeleteScript, TInstallScript } from "@App/app/service/queue"; +import { ScriptIcon } from "./components"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@App/pages/components/ui/alert-dialog"; + +const DAY = 24 * 60 * 60 * 1000; + +const SOURCE_KEY: Record = { + user: "script:trash_source_local", + sync: "script:trash_source_other_device", + subscribe: "script:trash_source_subscribe", +}; + +const sourceKeyOf = (deleteBy: InstallSource) => SOURCE_KEY[deleteBy] ?? "script:trash_source_other"; + +type SourceFilter = "all" | "user" | "sync" | "subscribe"; + +const FILTERS: { value: SourceFilter; key: string }[] = [ + { value: "all", key: "script:trash_filter_all" }, + { value: "user", key: "script:trash_source_local" }, + { value: "sync", key: "script:trash_source_other_device" }, + { value: "subscribe", key: "script:trash_source_subscribe" }, +]; + +export default function TrashListMobile({ + keyword = "", + onCountChange, +}: { + keyword?: string; + /** 回报条目数,供 tab 角标显示(彻底删除/清空只在本组件内发生,外部感知不到) */ + onCountChange?: (n: number) => void; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [list, setList] = useState([]); + const [purgeAllOpen, setPurgeAllOpen] = useState(false); + const [sourceFilter, setSourceFilter] = useState("all"); + const [selectionMode, setSelectionMode] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [retentionDays] = useSystemConfig("trash_retention_days"); + const [trashEnabled] = useSystemConfig("trash_enabled"); + + const reload = useCallback( + () => + requestTrashScripts().then((l) => { + setList(l ?? []); + onCountChange?.(l?.length ?? 0); + }), + [onCountChange] + ); + + // setState 只发生在异步回调中,避免 effect 体内同步 setState(同 Logger/hooks.ts 的既有写法) + useEffect(() => { + void reload(); + const hooks = new HookManager(); + hooks.append( + subscribeMessage("trashScripts", () => void reload()), + subscribeMessage("deleteScripts", () => void reload()), + subscribeMessage("installScript", () => void reload()) + ); + return hooks.unhook; + }, [reload]); + + const configuredDays = retentionDays ?? 30; + // 关闭回收站后残留条目不再自动清理,倒计时须归零,否则会倒数一个永远不会到来的期限 + const days = (trashEnabled ?? true) ? configuredDays : 0; + + // days=0 有两种含义(关闭 / 永不清理),空状态说明须分别措辞,不能插值出「保留 0 天」 + const emptyDesc = !(trashEnabled ?? true) + ? t("script:trash_hint_disabled") + : days + ? t("script:trash_empty_desc", { days }) + : t("script:trash_empty_desc_never"); + + const daysLeftOf = useCallback( + (item: TrashScript) => (days ? Math.ceil((item.deleteTime + days * DAY - Date.now()) / DAY) : null), + [days] + ); + + const onRestore = useCallback( + async (uuids: string[]) => { + try { + const ret = await requestRestoreScripts(uuids); + if (ret) { + for (const c of ret.conflicts) { + notify.error(t("script:trash_restore_conflict", { name: c.name })); + } + if (ret.restored.length) { + notify.success(t("script:trash_restore_success", { count: ret.restored.length })); + } + } + } catch { + // 条目可能已被其他窗口或到期清理抢先处理(SW 抛 trash scripts not found);失败也要重拉列表清掉陈旧行 + notify.error(t("script:trash_undo_failed")); + } + await reload(); + }, + [reload, t] + ); + + const onPurge = useCallback( + async (uuids: string[]) => { + try { + await requestPurgeScripts(uuids); + notify.success(t("script:trash_purge_success", { count: uuids.length })); + } catch { + notify.error(t("script:delete_failed")); + } + await reload(); + }, + [reload, t] + ); + + const visible = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + return list.filter( + (item) => + (sourceFilter === "all" || item.deleteBy === sourceFilter) && + (!kw || item.name.toLowerCase().includes(kw) || item.namespace.toLowerCase().includes(kw)) + ); + }, [list, sourceFilter, keyword]); + + const allSelected = visible.length > 0 && visible.every((item) => selected.has(item.uuid)); + + // 左滑是隐藏手势,单条还原/彻底删除还需要一条可见入口(与脚本、订阅两页一致) + const [sheetUuid, setSheetUuid] = useState(null); + const [pendingPurge, setPendingPurge] = useState(null); + const sheetItem = sheetUuid ? (visible.find((i) => i.uuid === sheetUuid) ?? null) : null; + + // 同时只允许一行滑开:否则多行会各自挂着「彻底删除」块 + const [swipeOpenUuid, setSwipeOpenUuid] = useState(null); + const handleSwipeOpenChange = useCallback( + (uuid: string, open: boolean) => setSwipeOpenUuid((prev) => (open ? uuid : prev === uuid ? null : prev)), + [] + ); + + const exitSelection = useCallback(() => { + setSelectionMode(false); + setSelected(new Set()); + }, []); + + const enterSelection = useCallback((uuid: string) => { + setSelectionMode(true); + setSelected(new Set([uuid])); + }, []); + + const toggleSelect = useCallback((uuid: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(uuid)) next.delete(uuid); + else next.add(uuid); + return next; + }); + }, []); + + // 同上:条目在多选期间被别的窗口清空时退回普通视图 + if (selectionMode && visible.length > 0) { + return ( +
+ setSelected(allSelected ? new Set() : new Set(visible.map((item) => item.uuid)))} + /> +
+ {visible.map((item) => ( + + ))} +
+ + { + void onRestore([...selected]); + exitSelection(); + }} + > + {t("script:trash_restore")} + + { + void onPurge([...selected]); + exitSelection(); + }} + > + {t("script:trash_purge")} + + +
+ ); + } + + return ( +
+
+ {FILTERS.map((f) => ( + + ))} +
+ +
+ + {!(trashEnabled ?? true) + ? t("script:trash_hint_disabled") + : days + ? t("script:trash_hint", { days }) + : t("script:trash_hint_never")} + + +
+ + +
+ + + + + {t("script:trash_purge_confirm_title")} + + {t("script:trash_purge_confirm_body", { count: list.length })} + + +
+ + {t("script:trash_purge_confirm_warn")} +
+ + {t("editor:cancel")} + void onPurge(list.map((i) => i.uuid))}> + {t("script:trash_purge")} + + +
+
+ +
+ {!list.length ? ( + + ) : ( + visible.map((item) => ( + + )) + )} +
+ + {sheetItem && ( + !open && setSheetUuid(null)} + title={sheetItem.name} + description={[sheetItem.namespace, versionDisplay(sheetItem.metadata?.version?.[0])] + .filter(Boolean) + .join(" · ")} + icon={} + > + void onRestore([sheetItem.uuid])}> + {t("script:trash_restore")} + + {/* 面板关闭会连带销毁挂在面板项上的气泡确认,故彻底删除走列表级的模态 */} + setPendingPurge(sheetItem)}> + {t("script:trash_purge")} + + + )} + + !open && setPendingPurge(null)}> + + + {t("script:trash_purge")} + + {pendingPurge && t("script:trash_purge_one_confirm", { name: pendingPurge.name })} + + + + {t("editor:cancel")} + pendingPurge && void onPurge([pendingPurge.uuid])}> + {t("script:trash_purge")} + + + + +
+ ); +} + +// ========== 单行 ========== +function TrashRowMobile({ + item, + daysLeft, + swipeOpen, + onSwipeOpenChange, + onOpenActions, + selectionMode, + selected, + onToggleSelect, + onEnterSelection, + onRestore, + onPurge, +}: { + item: TrashScript; + daysLeft: number | null; + swipeOpen: boolean; + onSwipeOpenChange: (uuid: string, open: boolean) => void; + onOpenActions: (uuid: string) => void; + selectionMode: boolean; + selected: boolean; + onToggleSelect: (uuid: string) => void; + onEnterSelection: (uuid: string) => void; + onRestore: (uuids: string[]) => Promise; + onPurge: (uuids: string[]) => Promise; +}) { + const { t } = useTranslation(); + const urgent = daysLeft !== null && daysLeft <= 3; + const longPress = useLongPress(useCallback(() => onEnterSelection(item.uuid), [onEnterSelection, item.uuid])); + + return ( + onSwipeOpenChange(item.uuid, open)} + {...(selectionMode ? {} : longPress)} + actions={ + <> + + void onPurge([item.uuid])} + > + + + + } + > + + + {selectionMode ? ( + onToggleSelect(item.uuid)} /> + ) : ( + + )} + + + (selectionMode ? onToggleSelect(item.uuid) : onOpenActions(item.uuid))}> + {item.name} + + + {[item.namespace, versionDisplay(item.metadata?.version?.[0])].filter(Boolean).join(" · ")} + + {semTime(new Date(item.deleteTime))} + + {t(sourceKeyOf(item.deleteBy))} + + + + + {/* 右锚区放过期倒计时而非开关:回收站条目没有启用态,倒计时才是这里唯一的紧迫信息 */} + + {daysLeft === null ? ( + {"—"} + ) : ( + + {urgent && } + {daysLeft <= 0 ? t("script:trash_expire_today") : t("script:trash_expire_in", { days: daysLeft })} + + )} + + + + ); +} diff --git a/src/pages/options/routes/ScriptList/TrashNameStyle.test.ts b/src/pages/options/routes/ScriptList/TrashNameStyle.test.ts index e21742692..272da7889 100644 --- a/src/pages/options/routes/ScriptList/TrashNameStyle.test.ts +++ b/src/pages/options/routes/ScriptList/TrashNameStyle.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; const readComponent = (name: string) => fs.readFileSync(path.resolve(__dirname, name), "utf8"); describe("回收站脚本名称样式", () => { - it.each(["TrashTable.tsx", "TrashCardGrid.tsx"])("%s 中的脚本名称不应使用删除线", (name) => { + it.each(["TrashTable.tsx", "TrashListMobile.tsx"])("%s 中的脚本名称不应使用删除线", (name) => { expect(readComponent(name)).not.toContain("line-through"); }); }); diff --git a/src/pages/options/routes/ScriptList/TrashTable.tsx b/src/pages/options/routes/ScriptList/TrashTable.tsx index 07afaa8b6..86265d390 100644 --- a/src/pages/options/routes/ScriptList/TrashTable.tsx +++ b/src/pages/options/routes/ScriptList/TrashTable.tsx @@ -10,6 +10,15 @@ import { useSystemConfig } from "@App/pages/options/hooks/useSystemConfig"; import { Popconfirm } from "@App/pages/components/ui/popconfirm"; import { Checkbox } from "@App/pages/components/ui/checkbox"; import { SearchInput } from "@App/pages/components/ui/search-input"; +import { + ListRow, + ListRowActions, + ListRowLeading, + ListRowMain, + ListRowTrailing, +} from "@App/pages/components/ui/list-row"; +import { SortMenu } from "./SortMenu"; +import type { SortOrder } from "./sort"; import { semTime } from "@App/locales/relative-date"; import { versionDisplay } from "@App/pages/utils"; import { subscribeMessage } from "@App/pages/store/global"; @@ -38,6 +47,13 @@ const SOURCE_KEY: Record = { const sourceKeyOf = (deleteBy: InstallSource) => SOURCE_KEY[deleteBy] ?? "script:trash_source_other"; +type TrashSortKey = "deleteTime" | "name"; + +const TRASH_COMPARATORS: Record number> = { + deleteTime: (a, b) => a.deleteTime - b.deleteTime, + name: (a, b) => a.name.localeCompare(b.name), +}; + type SourceFilter = "all" | "user" | "sync" | "subscribe"; const FILTERS: { value: SourceFilter; key: string }[] = [ @@ -62,6 +78,11 @@ export default function TrashTable({ const [purgeAllOpen, setPurgeAllOpen] = useState(false); const [sourceFilter, setSourceFilter] = useState("all"); const [keyword, setKeyword] = useState(""); + // 回收站默认按删除时间倒序:最近删掉的最可能是误删,应当最先看到 + const [sortState, setSortState] = useState<{ key: TrashSortKey | null; order: SortOrder }>({ + key: "deleteTime", + order: "desc", + }); const [retentionDays] = useSystemConfig("trash_retention_days"); const [trashEnabled] = useSystemConfig("trash_enabled"); @@ -139,12 +160,16 @@ export default function TrashTable({ const visible = useMemo(() => { const kw = keyword.trim().toLowerCase(); - return list.filter( + const filtered = list.filter( (item) => (sourceFilter === "all" || item.deleteBy === sourceFilter) && (!kw || item.name.toLowerCase().includes(kw) || item.namespace.toLowerCase().includes(kw)) ); - }, [list, sourceFilter, keyword]); + if (sortState.key === null) return filtered; + const cmp = TRASH_COMPARATORS[sortState.key]; + const dir = sortState.order === "asc" ? 1 : -1; + return [...filtered].sort((a, b) => dir * cmp(a, b)); + }, [list, sourceFilter, keyword, sortState]); const allSelected = useMemo( () => visible.length > 0 && visible.every((item) => selected.has(item.uuid)), @@ -154,7 +179,7 @@ export default function TrashTable({ return (
{/* 顶栏:tabs + 搜索 + 清空回收站(与已安装 tab 共用同一条 h-14 顶栏的形状) */} -
+
{leading} setKeyword(e.target.value)} /> + -
-
+ + ); }) )} diff --git a/src/pages/options/routes/ScriptList/TrashViews.test.tsx b/src/pages/options/routes/ScriptList/TrashViews.test.tsx index 877aef8f7..6a3e38ec2 100644 --- a/src/pages/options/routes/ScriptList/TrashViews.test.tsx +++ b/src/pages/options/routes/ScriptList/TrashViews.test.tsx @@ -39,7 +39,7 @@ vi.mock("@App/pages/store/global", async () => { }; }); -import TrashCardGrid from "./TrashCardGrid"; +import TrashListMobile from "./TrashListMobile"; import TrashTable from "./TrashTable"; import { notify } from "@App/pages/components/ui/toast"; @@ -56,7 +56,7 @@ afterEach(cleanup); describe("多窗口回收站实时同步", () => { it.each([ ["桌面端", ], - ["移动端", ], + ["移动端", ], ])("%s 收到回收站内容事件时应重新拉取列表", async (_name, view) => { renderWithRouter(view); await waitFor(() => expect(requestTrashScripts).toHaveBeenCalledTimes(1)); @@ -80,7 +80,7 @@ describe("空回收站的固定控制区", () => { }); it("移动端保留来源筛选和清理时间设置,仅卡片内容显示空状态", () => { - renderWithRouter(); + renderWithRouter(); expect(screen.getByRole("button", { name: "全部" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "设置" })).toBeInTheDocument(); @@ -107,6 +107,16 @@ describe("桌面回收站批量操作槽位", () => { expect(within(bar).getByRole("button", { name: "还原" })).toBeInTheDocument(); }); + it("全选框挂在批量操作栏内,列表顶部没有独立全选行", async () => { + const checkboxes = await renderWithOneTrashed(); + expect(checkboxes).toHaveLength(2); // 批量栏的全选 + 唯一一行 + + const bar = screen.getByText("已选择 0 项").parentElement!; + fireEvent.click(within(bar).getByLabelText("全选")); + + expect(checkboxes[1]).toBeChecked(); + }); + it("点击批量操作栏的关闭按钮清空选择", async () => { const checkboxes = await renderWithOneTrashed(); fireEvent.click(checkboxes[1]); @@ -130,7 +140,7 @@ describe("回收站脚本元信息样式", () => { it.each([ ["桌面端", ], - ["移动端", ], + ["移动端", ], ])("%s 与脚本列表一致显示图标、11px 版本和语义化时间", async (_name, view) => { requestTrashScripts.mockResolvedValue([trashed]); renderWithRouter(view); @@ -168,7 +178,7 @@ describe("回收站关闭时的提示与倒计时", () => { }); it("移动端:提示改为「回收站已关闭」,过期条目不再显示「今天」", async () => { - renderWithRouter(); + renderWithRouter(); expect(await screen.findByText("关闭态脚本")).toBeInTheDocument(); expect(screen.getByText("回收站已关闭 · 新删除的脚本将直接彻底删除")).toBeInTheDocument(); @@ -221,9 +231,13 @@ describe("还原/彻底删除失败时的反馈", () => { it("移动端:还原请求失败时提示错误并重新拉取列表", async () => { requestRestoreScripts.mockRejectedValue(new Error("trash scripts not found")); - renderWithRouter(); + const { container } = renderWithRouter(); await screen.findByText("陈旧条目"); + // 移动端还原挂在左滑露出的操作块上,未滑出时它对无障碍树不可见 + const swipe = container.querySelector('[data-slot="mobile-swipe-row"]')!; + fireEvent.touchStart(swipe, { touches: [{ clientX: 200 }] }); + fireEvent.touchEnd(swipe, { changedTouches: [{ clientX: 100 }] }); fireEvent.click(screen.getByRole("button", { name: "还原" })); await waitFor(() => expect(notify.error).toHaveBeenCalledWith("还原失败")); @@ -244,7 +258,7 @@ describe("空回收站文案与保留时间联动", () => { it("回收站关闭时空状态说明改用关闭态提示", async () => { get.mockImplementation((key: string) => Promise.resolve(key === "trash_enabled" ? false : 30)); - renderWithRouter(); + renderWithRouter(); expect(await screen.findByText("回收站是空的")).toBeInTheDocument(); expect(screen.queryByText(/0 天/)).not.toBeInTheDocument(); @@ -252,3 +266,129 @@ describe("空回收站文案与保留时间联动", () => { expect(screen.getAllByText("回收站已关闭 · 新删除的脚本将直接彻底删除").length).toBeGreaterThan(0); }); }); + +describe("回收站桌面端列表化", () => { + const mkTrash = (uuid: string, name: string, deleteTime: number) => ({ + uuid, + name, + namespace: "example.com", + metadata: { version: ["1.0.0"] }, + deleteBy: "user", + deleteTime, + }); + + // 主段第一个 span 是无图标脚本的兜底头像,脚本名取带 text-sm 的那个 + const trashOrder = () => + Array.from(document.querySelectorAll('[data-slot="list-row-main"] .text-sm')).map((el) => el.textContent); + + const sortBy = (label: string) => { + const trigger = screen.getByTestId("sort-menu"); + fireEvent.pointerDown(trigger); + fireEvent.click(trigger); + fireEvent.click(screen.getByText(label)); + }; + + beforeEach(() => { + requestTrashScripts.mockResolvedValue([ + mkTrash("b", "Banana", 30), + mkTrash("a", "Apple", 10), + mkTrash("c", "Cherry", 20), + ]); + }); + + it("行改用共享的列表行骨架,不再渲染固定列表头", async () => { + renderWithRouter(); + + await waitFor(() => expect(document.querySelectorAll('[data-slot="list-row"]').length).toBe(3)); + expect(screen.queryByText("删除来源")).toBeNull(); + expect(screen.queryByText("自动清理")).toBeNull(); + }); + + it("默认按删除时间倒序,最近删除的排在最前", async () => { + renderWithRouter(); + + await waitFor(() => expect(trashOrder()).toEqual(["Banana", "Cherry", "Apple"])); + }); + + it("工具栏排序下拉可改按名称排序", async () => { + renderWithRouter(); + await waitFor(() => expect(document.querySelectorAll('[data-slot="list-row"]').length).toBe(3)); + + sortBy("名称"); + + await waitFor(() => expect(trashOrder()).toEqual(["Apple", "Banana", "Cherry"])); + }); +}); + +describe("回收站移动端单条操作", () => { + const trashed = { + uuid: "trash-1", + name: "待还原脚本", + namespace: "verify", + deleteBy: "user", + deleteTime: Date.now(), + }; + + // 左滑是隐藏手势,不能是单条还原的唯一入口——脚本列表与订阅列表都有「点整行开面板」这条可见退路 + it("点整行打开操作面板,面板里有还原与彻底删除", async () => { + requestTrashScripts.mockResolvedValue([trashed]); + renderWithRouter(); + await screen.findByText("待还原脚本"); + + fireEvent.click(document.querySelector('[data-slot="mobile-list-row-main"]')!); + + const sheet = await screen.findByRole("dialog"); + expect(within(sheet).getByText("还原")).toBeInTheDocument(); + expect(within(sheet).getByText("彻底删除")).toBeInTheDocument(); + }); + + it("面板中的还原按该条发起请求", async () => { + requestTrashScripts.mockResolvedValue([trashed]); + requestRestoreScripts.mockResolvedValue({ restored: ["trash-1"], conflicts: [] }); + renderWithRouter(); + await screen.findByText("待还原脚本"); + fireEvent.click(document.querySelector('[data-slot="mobile-list-row-main"]')!); + + const sheet = await screen.findByRole("dialog"); + fireEvent.click(within(sheet).getByText("还原")); + + await waitFor(() => expect(requestRestoreScripts).toHaveBeenCalledWith(["trash-1"])); + }); +}); + +describe("回收站移动端多选", () => { + const trashed = { + uuid: "trash-1", + name: "待还原脚本", + namespace: "verify", + deleteBy: "user", + deleteTime: Date.now(), + }; + + it("顶栏入口进入多选,底部批量操作条提供还原与彻底删除", async () => { + requestTrashScripts.mockResolvedValue([trashed]); + renderWithRouter(); + await screen.findByText("待还原脚本"); + + fireEvent.click(screen.getByRole("button", { name: "多选" })); + + expect(screen.getByText("已选择 0 项")).toBeInTheDocument(); + const bar = document.querySelector('[data-slot="mobile-batch-bar"]')!; + expect(bar.textContent).toContain("还原"); + expect(bar.textContent).toContain("彻底删除"); + }); + + it("勾选后批量还原按选中项发起请求,并退出多选", async () => { + requestTrashScripts.mockResolvedValue([trashed]); + requestRestoreScripts.mockResolvedValue({ restored: ["trash-1"], conflicts: [] }); + renderWithRouter(); + await screen.findByText("待还原脚本"); + fireEvent.click(screen.getByRole("button", { name: "多选" })); + + fireEvent.click(screen.getByRole("checkbox", { name: "待还原脚本" })); + fireEvent.click(within(document.querySelector('[data-slot="mobile-batch-bar"]')!).getByText("还原")); + + await waitFor(() => expect(requestRestoreScripts).toHaveBeenCalledWith(["trash-1"])); + expect(document.querySelector('[data-slot="mobile-batch-bar"]')).toBeNull(); + }); +}); diff --git a/src/pages/options/routes/ScriptList/components.test.tsx b/src/pages/options/routes/ScriptList/components.test.tsx index 0539860c5..ab63b7937 100644 --- a/src/pages/options/routes/ScriptList/components.test.tsx +++ b/src/pages/options/routes/ScriptList/components.test.tsx @@ -1,20 +1,23 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; -import { act, cleanup, render, screen, fireEvent } from "@testing-library/react"; +import { act, cleanup, render, screen, fireEvent, within } from "@testing-library/react"; import { t } from "@App/locales/locales"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { renderWithTooltip } from "@Tests/renderWithTooltip"; import { TooltipProvider } from "@App/pages/components/ui/tooltip"; import { SCRIPT_TYPE_NORMAL, SCRIPT_TYPE_BACKGROUND, SCRIPT_TYPE_CRONTAB } from "@App/app/repo/scripts"; +import type { ScriptLoading } from "@App/pages/store/features/script"; // requestCheckUpdate 走后台消息,统一打桩;用 hoisted 以便在 vi.mock 工厂内引用 -const { requestCheckUpdate, preloadUserConfig, preloadCloudScriptPlan, get } = vi.hoisted(() => ({ +const { requestCheckUpdate, exportScripts, preloadUserConfig, preloadCloudScriptPlan, get } = vi.hoisted(() => ({ requestCheckUpdate: vi.fn(), + exportScripts: vi.fn(() => Promise.resolve()), preloadUserConfig: vi.fn(() => Promise.resolve()), preloadCloudScriptPlan: vi.fn(() => Promise.resolve()), get: vi.fn(), })); vi.mock("@App/pages/store/features/script", () => ({ scriptClient: { requestCheckUpdate }, + synchronizeClient: { export: exportScripts }, })); vi.mock("./preload", () => ({ preloadUserConfig })); vi.mock("@App/pages/components/CloudScriptPlan", () => ({ preloadCloudScriptPlan })); @@ -36,7 +39,7 @@ import { getScriptHomePage, getTagColor, ScheduleNextRun, - ScriptRowActions, + ScriptRowActionSlots, scriptTypeLabel, UpdateTimeCell, } from "./components"; @@ -116,7 +119,7 @@ describe("标签配色 getTagColor", () => { }); }); -describe("ScriptRowActions 行内操作(替代 ⋯ 更多菜单)", () => { +describe("ScriptRowActionSlots 宽窄两套排布", () => { const makeScript = (over: Record = {}) => ({ uuid: "u1", @@ -126,151 +129,182 @@ describe("ScriptRowActions 行内操作(替代 ⋯ 更多菜单)", () => { ...over, }) as never; - it("普通脚本始终显示『编辑』『删除』,且不渲染更多菜单按钮", () => { + const renderSlots = ( + over: Record = {}, + handlers: Partial<{ + navigate: (to: string) => void; + onDelete: (script: ScriptLoading) => void; + onRunStop: (script: ScriptLoading) => void; + }> = {} + ) => { + const script = makeScript(over); + const navigate = handlers.navigate ?? vi.fn(); + const onDelete = handlers.onDelete ?? vi.fn(); + const onRunStop = handlers.onRunStop ?? vi.fn(); renderWithTooltip( - + ); - expect(screen.getByLabelText(t("edit"))).toBeInTheDocument(); - expect(screen.getByLabelText(t("delete"))).toBeInTheDocument(); - // 不应再有「更多」菜单 - expect(screen.queryByLabelText(t("more"))).toBeNull(); + return { script, navigate, onDelete, onRunStop }; + }; + + // Radix 下拉需要 pointerDown 才会展开(同 FilterBar.test.tsx 的既有写法) + const openMore = () => { + const trigger = within(screen.getByTestId("row-actions-compact")).getByLabelText(t("script:more_actions")); + fireEvent.pointerDown(trigger); + fireEvent.click(trigger); + }; + + const wide = () => within(screen.getByTestId("row-actions-wide")); + const compact = () => within(screen.getByTestId("row-actions-compact")); + + it("宽排布把编辑、导出、删除摊成常驻按钮,不出现更多菜单", () => { + const { navigate } = renderSlots(); + + expect(wide().getByLabelText(t("edit"))).toBeInTheDocument(); + expect(wide().getByLabelText(t("export"))).toBeInTheDocument(); + expect(wide().getByLabelText(t("delete"))).toBeInTheDocument(); + expect(wide().queryByLabelText(t("script:more_actions"))).toBeNull(); + + fireEvent.click(wide().getByLabelText(t("edit"))); + expect(navigate).toHaveBeenCalledWith("/script/editor/u1"); }); - it("无主页/配置/云端时不显示对应按钮", () => { - renderWithTooltip( - - ); - expect(screen.queryByLabelText(t("script:homepage"))).toBeNull(); - expect(screen.queryByLabelText(t("editor:user_config"))).toBeNull(); - expect(screen.queryByLabelText(t("editor:upload_to_cloud"))).toBeNull(); + it("窄排布只留运行槽与更多按钮,普通脚本的运行槽是等宽占位", () => { + renderSlots(); + + expect(compact().getByLabelText(t("script:more_actions"))).toBeInTheDocument(); + expect(compact().queryByLabelText(t("edit"))).toBeNull(); + expect(compact().queryByLabelText(t("editor:run"))).toBeNull(); + }); + + it("窄排布的更多菜单里有编辑,点击导航到编辑器", () => { + const { navigate } = renderSlots(); + openMore(); + + fireEvent.click(screen.getByText(t("edit"))); + + expect(navigate).toHaveBeenCalledWith("/script/editor/u1"); + }); + + it("无主页/配置/云端时更多菜单里不出现对应项", () => { + renderSlots(); + openMore(); + + expect(screen.queryByText(t("script:homepage"))).toBeNull(); + expect(screen.queryByText(t("editor:user_config"))).toBeNull(); + expect(screen.queryByText(t("editor:upload_to_cloud"))).toBeNull(); }); - it("含主页字段时显示主页按钮,点击打开新标签", () => { + it("含主页字段时菜单出现主页项,点击打开新标签", () => { const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); - renderWithTooltip( - - ); - fireEvent.click(screen.getByLabelText(t("script:homepage"))); + renderSlots({ metadata: { homepage: ["https://home"] } }); + openMore(); + + fireEvent.click(screen.getByText(t("script:homepage"))); + expect(openSpy).toHaveBeenCalledWith("https://home", "_blank"); openSpy.mockRestore(); }); - it("含 config 时显示用户配置按钮,导航到 ?userConfig=", () => { - const navigate = vi.fn(); - renderWithTooltip( - - ); - fireEvent.click(screen.getByLabelText(t("editor:user_config"))); + it("含 config 时菜单出现用户配置项,导航到 ?userConfig=", () => { + const { navigate } = renderSlots({ config: { group: {} } }); + openMore(); + + fireEvent.click(screen.getByText(t("editor:user_config"))); + expect(navigate).toHaveBeenCalledWith("/?userConfig=u1"); }); - it("聚焦用户配置按钮时应预加载当前脚本值", () => { - const script = makeScript({ config: { group: {} } }); - renderWithTooltip(); + it("指针移到用户配置项时预加载当前脚本值", () => { + const { script } = renderSlots({ config: { group: {} } }); + openMore(); - fireEvent.focus(screen.getByLabelText(t("editor:user_config"))); + fireEvent.pointerEnter(screen.getByText(t("editor:user_config"))); expect(preloadUserConfig).toHaveBeenCalledWith(script); }); - it("含 cloudcat 时显示云端按钮,导航到 ?cloud=(而非 cloudSync)", () => { - const navigate = vi.fn(); - renderWithTooltip( - - ); - fireEvent.click(screen.getByLabelText(t("editor:upload_to_cloud"))); + it("含 cloudcat 时菜单出现云端项,导航到 ?cloud=(而非 cloudSync)", () => { + const { navigate } = renderSlots({ metadata: { cloudcat: ["true"] } }); + openMore(); + + fireEvent.click(screen.getByText(t("editor:upload_to_cloud"))); + expect(navigate).toHaveBeenCalledWith("/?cloud=u1"); }); - it("悬浮云端按钮时应预加载当前脚本的导出计划", () => { - const script = makeScript({ metadata: { cloudcat: ["true"] } }); - renderWithTooltip(); + it("指针移到云端项时预加载当前脚本的导出计划", () => { + const { script } = renderSlots({ metadata: { cloudcat: ["true"] } }); + openMore(); - fireEvent.pointerEnter(screen.getByLabelText(t("editor:upload_to_cloud"))); + fireEvent.pointerEnter(screen.getByText(t("editor:upload_to_cloud"))); expect(preloadCloudScriptPlan).toHaveBeenCalledWith(script); }); - it("后台脚本显示运行按钮(标签为「运行」而非进度提示),点击触发 onRunStop", () => { + it("后台脚本的运行槽渲染为真实按钮,点击触发 onRunStop", () => { const onRunStop = vi.fn(); - const script = makeScript({ type: SCRIPT_TYPE_BACKGROUND }); - renderWithTooltip(); - fireEvent.click(screen.getByLabelText(t("editor:run"))); - expect(onRunStop).toHaveBeenCalledWith(script); - }); + const { script } = renderSlots({ type: SCRIPT_TYPE_BACKGROUND }, { onRunStop }); - it("删除触发器为带 aria-haspopup 的真实按钮(Popconfirm 的 trigger 属性透传到 ActionButton 内层按钮)", () => { - renderWithTooltip( - - ); - const trigger = screen.getByLabelText(t("delete")); - expect(trigger.tagName).toBe("BUTTON"); - expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + fireEvent.click(compact().getByLabelText(t("editor:run"))); + + expect(onRunStop).toHaveBeenCalledWith(script); }); - it("点击删除先弹出 Popconfirm 气泡确认,确认前不调用 onDelete(回收站默认开启,文案说可还原)", async () => { + it("删除收进更多菜单,点击后先弹确认框,确认前不调用 onDelete(回收站默认开启,文案说可还原)", async () => { const onDelete = vi.fn(); - const script = makeScript(); - renderWithTooltip(); + renderSlots({}, { onDelete }); + openMore(); - const trigger = screen.getByLabelText(t("delete")); - fireEvent.click(trigger); + fireEvent.click(screen.getByText(t("delete"))); - // 气泡里展示含脚本名的确认文案,但尚未真正删除 expect( await screen.findByText(t("script:confirm_delete_script_trash_content", { name: "脚本A" })) ).toBeInTheDocument(); expect(onDelete).not.toHaveBeenCalled(); }); - it("在 Popconfirm 中点击确认后才触发 onDelete", async () => { + it("确认框中点击删除后才触发 onDelete", async () => { const onDelete = vi.fn(); - const script = makeScript(); - renderWithTooltip(); - - const trigger = screen.getByLabelText(t("delete")); - fireEvent.click(trigger); + const { script } = renderSlots({}, { onDelete }); + openMore(); + fireEvent.click(screen.getByText(t("delete"))); await screen.findByText(t("script:confirm_delete_script_trash_content", { name: "脚本A" })); - // 气泡内确认按钮与触发按钮同名(删除),取非触发的那一个 - const confirmBtn = screen.getByText(t("delete"), { selector: "button" }); - fireEvent.click(confirmBtn); + // 菜单项与确认按钮同名(删除),确认按钮是最后出现的那个 + const buttons = screen.getAllByText(t("delete"), { selector: "button" }); + fireEvent.click(buttons[buttons.length - 1]); + expect(onDelete).toHaveBeenCalledWith(script); }); - it("在 Popconfirm 中点击取消不触发 onDelete", async () => { + it("确认框中点击取消不触发 onDelete", async () => { const onDelete = vi.fn(); - const script = makeScript(); - renderWithTooltip(); - - fireEvent.click(screen.getByLabelText(t("delete"))); + renderSlots({}, { onDelete }); + openMore(); + fireEvent.click(screen.getByText(t("delete"))); await screen.findByText(t("script:confirm_delete_script_trash_content", { name: "脚本A" })); - fireEvent.click(screen.getByText(t("editor:cancel"), { selector: "button" })); + await act(async () => fireEvent.click(screen.getByText(t("editor:cancel"), { selector: "button" }))); + expect(onDelete).not.toHaveBeenCalled(); }); - it("回收站关闭时,确认文案改回「此操作无法撤销」(既有 bug:开启态曾误用此文案)", async () => { + it("更多菜单里的导出按单个脚本导出", async () => { + renderSlots(); + openMore(); + + fireEvent.click(screen.getByText(t("export"))); + + expect(exportScripts).toHaveBeenCalledWith(["u1"]); + }); + + it("回收站关闭时,确认文案改回「此操作无法撤销」", async () => { get.mockImplementation((key: string) => Promise.resolve(key === "trash_enabled" ? false : 30)); - const script = makeScript(); - renderWithTooltip(); + renderSlots(); + openMore(); - fireEvent.click(screen.getByLabelText(t("delete"))); + fireEvent.click(screen.getByText(t("delete"))); expect(await screen.findByText(t("script:confirm_delete_script_content", { name: "脚本A" }))).toBeInTheDocument(); expect( diff --git a/src/pages/options/routes/ScriptList/components.tsx b/src/pages/options/routes/ScriptList/components.tsx index c82624025..8431d0071 100644 --- a/src/pages/options/routes/ScriptList/components.tsx +++ b/src/pages/options/routes/ScriptList/components.tsx @@ -7,12 +7,12 @@ import { SCRIPT_TYPE_NORMAL, SCRIPT_TYPE_CRONTAB, } from "@App/app/repo/scripts"; -import { scriptClient, type ScriptLoading } from "@App/pages/store/features/script"; +import { scriptClient, synchronizeClient, type ScriptLoading } from "@App/pages/store/features/script"; +import { notify } from "@App/pages/components/ui/toast"; import { Switch } from "@App/pages/components/ui/switch"; import { Badge } from "@App/pages/components/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@App/pages/components/ui/tooltip"; import { Button } from "@App/pages/components/ui/button"; -import { Popconfirm } from "@App/pages/components/ui/popconfirm"; import { semTime } from "@App/locales/relative-date"; import { i18nName } from "@App/locales/locales"; import type { TFunction } from "i18next"; @@ -22,18 +22,36 @@ import { getNameAvatarTone, NameAvatar } from "@App/pages/components/NameAvatar" import { Globe, RefreshCw, - House, - Settings2, - UploadCloud, - Pencil, Play, Square, - Trash2, CircleArrowUp, Check, Clock, + Ellipsis, + House, + Settings2, + UploadCloud, + Pencil, + Download, + Trash2, } from "lucide-react"; import { preloadCloudScriptPlan } from "@App/pages/components/CloudScriptPlan"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@App/pages/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@App/pages/components/ui/alert-dialog"; import { preloadUserConfig } from "./preload"; import { nextTimeDisplay } from "@App/pkg/utils/cron"; import { useSystemConfig } from "@App/pages/options/hooks/useSystemConfig"; @@ -127,7 +145,7 @@ function isSafeHttpUrl(url: string | undefined): boolean { } // 打开外部链接(仅限 http/https)。脚本主页/站点图标等 URL 均来自脚本 metadata,不可信。 -function openExternalUrl(url: string | undefined) { +export function openExternalUrl(url: string | undefined) { if (isSafeHttpUrl(url)) window.open(url, "_blank"); } @@ -285,15 +303,14 @@ export const UpdateTimeCell = React.memo(({ script }: { script: ScriptLoading }) UpdateTimeCell.displayName = "UpdateTimeCell"; // ========== 行内操作 ========== -// 取代原 ⋯ 更多菜单:主页 / 用户配置 / 云端 / 运行·停止 / 编辑 / 删除,均按条件出现,右对齐。 -// 表格与卡片复用同一套,确保行为一致。检查更新不在此处(见 UpdateTimeCell)。 +// 操作槽内的统一按钮外观。检查更新不在这里(见 UpdateTimeCell)。 type ActionButtonProps = React.ComponentPropsWithoutRef & { label: string; destructive?: boolean; onPreload?: () => void; }; -// 透传 props + ref:使其可直接作为 Popconfirm(Radix asChild)的 trigger, +// 透传 props + ref:使其可直接作为 Radix asChild 的 trigger(更多菜单即如此用), // 让 trigger 语义/焦点/aria 落在真实按钮上,无需外包 div。 const ActionButton = ({ label, @@ -327,85 +344,146 @@ const ActionButton = ({ ); -export function ScriptRowActions({ +/** + * 列表行的固定三槽操作:编辑 / 运行·停止 / 更多。 + * 槽位数与脚本类型无关——普通脚本的运行槽渲染为等宽占位,使各行右缘始终对齐; + * 条件性操作(主页、用户配置、云端上传、删除)收进「更多」,删除进二级菜单亦降低误触。 + */ +export function ScriptRowActionSlots({ script, navigate, onDelete, onRunStop, - className, }: { script: ScriptLoading; navigate: (to: string) => void; onDelete: (script: ScriptLoading) => void; onRunStop: (script: ScriptLoading) => void; - className?: string; }) { const { t } = useTranslation(); const [trashEnabled] = useSystemConfig("trash_enabled"); + const [confirmDelete, setConfirmDelete] = useState(false); const home = getScriptHomePage(script.metadata); const isBackground = script.type !== SCRIPT_TYPE_NORMAL; const isRunning = script.runStatus === SCRIPT_RUN_STATUS_RUNNING; const preloadUserConfigValues = () => void preloadUserConfig(script).catch(() => undefined); const preloadCloudPlan = () => void preloadCloudScriptPlan(script).catch(() => undefined); + + const openEditor = () => navigate(`/script/editor/${script.uuid}`); + const openUserConfig = () => { + preloadUserConfigValues(); + navigate(`/?userConfig=${script.uuid}`); + }; + const openCloud = () => { + preloadCloudPlan(); + navigate(`/?cloud=${script.uuid}`); + }; + const exportSelf = () => { + const id = notify.loading(t("editor:exporting")); + void synchronizeClient.export([script.uuid]).then(() => notify.success(t("settings:export_success"), { id })); + }; + + // 运行槽在两套排布里都出现,故只写一次 + const runButton = ( + onRunStop(script)} + disabled={script.actionLoading} + > + {isRunning ? : } + + ); + return ( -
- {home && ( - openExternalUrl(home)}> - - - )} - {script.config && ( - { - preloadUserConfigValues(); - navigate(`/?userConfig=${script.uuid}`); - }} - > - - - )} - {script.metadata?.cloudcat && ( - { - preloadCloudPlan(); - navigate(`/?cloud=${script.uuid}`); - }} - > - + <> + {/* 宽窗口摊开全部操作;窄窗口它们会把脚本名挤没(#1698),故窄于断点时换成下面的紧凑排布。 + min-w 取满配 7 个按钮的宽度:条件按钮的有无会改变本区宽度,进而把左侧的更新时间列推得逐行参差。 + 断点取 1160 而非 1000:摊开态比紧凑态多占 160px,实测 1000px 处名称会从 253px 直接掉到 150px, + 即「窗口拉宽反而更窄」;1160px 起摊开态才不会让名称比紧凑态更窄。 */} + + {home && ( + openExternalUrl(home)}> + + + )} + {script.config && ( + + + + )} + {script.metadata?.cloudcat && ( + + + + )} + {isBackground && runButton} + + - )} - {isBackground && ( - onRunStop(script)} - disabled={script.actionLoading} - > - {isRunning ? : } + + - )} - navigate(`/script/editor/${script.uuid}`)}> - - - onDelete(script)} - > - + setConfirmDelete(true)}> - -
+ + + + {/* 普通脚本没有运行按钮,留等宽占位让更多按钮在所有行对齐同一横坐标 */} + {isBackground ? runButton : } + + + + + + + + + {t("edit")} + {home && openExternalUrl(home)}>{t("script:homepage")}} + {script.config && ( + + {t("editor:user_config")} + + )} + {script.metadata?.cloudcat && ( + + {t("editor:upload_to_cloud")} + + )} + {t("export")} + setConfirmDelete(true)} + > + {t("delete")} + + + + + + + + + {t("delete")} + + {(trashEnabled ?? true) + ? t("script:confirm_delete_script_trash_content", { name: i18nName(script) }) + : t("script:confirm_delete_script_content", { name: i18nName(script) })} + + + + {t("editor:cancel")} + onDelete(script)}> + {t("delete")} + + + + + ); } @@ -428,7 +506,7 @@ export const SourceTag = React.memo( return ( - + {t("script:source_subscribe_link")} @@ -438,7 +516,7 @@ export const SourceTag = React.memo( } if (!script.origin) { return ( - + {t("script:source_local_script")} ); @@ -446,7 +524,7 @@ export const SourceTag = React.memo( return ( - + {t("script:source_script_link")} diff --git a/src/pages/options/routes/ScriptList/index.test.tsx b/src/pages/options/routes/ScriptList/index.test.tsx index 3241500eb..19c49d323 100644 --- a/src/pages/options/routes/ScriptList/index.test.tsx +++ b/src/pages/options/routes/ScriptList/index.test.tsx @@ -87,20 +87,18 @@ vi.mock("./ScriptTable", () => ({ - ), })); -vi.mock("./ScriptCard", () => ({ default: () => null })); vi.mock("./ScriptListMobile", () => ({ default: () => null })); vi.mock("@App/pages/components/use-is-mobile", () => ({ useIsMobile: () => false })); import ScriptList from "./index"; import { invalidateUserConfig, preloadUserConfig } from "./preload"; -import { SCRIPT_LIST_PREFERENCES_KEY, SCRIPT_LIST_VIEW_MODE_KEY } from "./preferences"; +import { SCRIPT_LIST_PREFERENCES_KEY } from "./preferences"; import { notify } from "@App/pages/components/ui/toast"; beforeAll(() => initTestLanguage("zh-CN")); @@ -112,7 +110,6 @@ beforeEach(() => { getScriptValue.mockReset(); getScriptValue.mockResolvedValue({}); localStorage.removeItem(SCRIPT_LIST_PREFERENCES_KEY); - localStorage.removeItem(SCRIPT_LIST_VIEW_MODE_KEY); vi.clearAllMocks(); get.mockImplementation((key: string) => Promise.resolve(key === "trash_enabled" ? true : 30)); }); @@ -249,17 +246,14 @@ describe("脚本列表用户配置弹窗", () => { }); describe("脚本列表偏好持久化", () => { - it("更改视图、搜索、筛选与列排序后写入 localStorage", () => { + it("更改搜索、筛选与排序后写入 localStorage,且不再写入视图模式", () => { render(, { wrapper: MemoryRouter }); fireEvent.click(screen.getByText("trigger-search")); fireEvent.click(screen.getByText("trigger-filter")); fireEvent.click(screen.getByText("trigger-sort")); - fireEvent.click(screen.getByText("trigger-view-mode")); - expect(localStorage.getItem(SCRIPT_LIST_VIEW_MODE_KEY)).toBe("card"); expect(JSON.parse(localStorage.getItem(SCRIPT_LIST_PREFERENCES_KEY) || "{}")).toEqual({ - viewMode: "card", selectedFilters: { status: 1, type: null, tags: null, source: null }, searchRequest: { keyword: "helper", type: "name" }, sortState: { key: "name", order: "asc" }, diff --git a/src/pages/options/routes/ScriptList/index.tsx b/src/pages/options/routes/ScriptList/index.tsx index 91e6c3cf6..f0cf9b2f1 100644 --- a/src/pages/options/routes/ScriptList/index.tsx +++ b/src/pages/options/routes/ScriptList/index.tsx @@ -28,7 +28,6 @@ import CloudScriptPlan from "@App/pages/components/CloudScriptPlan"; import ScriptTable from "./ScriptTable"; import type { ScriptTableProps } from "./ScriptTable"; -import ScriptCard from "./ScriptCard"; import { SearchFilter, type SearchFilterRequest } from "./SearchFilter"; import { type TSelectFilter, useScriptDataManagement, useScriptFilters, useTrashCount } from "./hooks"; import type { FilterBarProps } from "./FilterBar"; @@ -41,12 +40,7 @@ import { notify } from "@App/pages/components/ui/toast"; import { useUserConfigPreload } from "./preload"; import { reindexScriptList } from "./sort"; import type { SortState } from "./sort"; -import { - type ScriptListPreferences, - type ScriptListViewMode, - readScriptListPreferences, - writeScriptListPreferences, -} from "./preferences"; +import { type ScriptListPreferences, readScriptListPreferences, writeScriptListPreferences } from "./preferences"; import { useOnboardingDemoActive } from "@App/pages/options/onboarding/OnboardingProvider"; import { getDemoScripts } from "@App/pages/options/onboarding/demo-scripts"; @@ -62,17 +56,12 @@ type BatchProps = Pick< "onBatchEnable" | "onBatchDisable" | "onBatchExport" | "onBatchDelete" | "onBatchPinTop" | "onBatchCheckUpdate" >; -type ContentProps = { viewMode: ScriptListViewMode } & ScriptTableProps & FilterBarProps & SelectionProps & BatchProps; +type ContentProps = ScriptTableProps & FilterBarProps & SelectionProps & BatchProps; /** * 子组件: 内容区域(memo 防止弹窗状态引起列表重绘) */ -const MainContent = memo(({ viewMode, ...rest }: ContentProps) => { - if (viewMode === "card") { - return ; - } - return ; -}); +const MainContent = memo((props: ContentProps) => ); MainContent.displayName = "MainContent"; function PreloadedUserConfigPanel({ script, onClose }: { script: Script; onClose: () => void }) { @@ -105,7 +94,7 @@ export default function ScriptList() { const { t } = useTranslation(); const [preferences, setPreferences] = useState(readScriptListPreferences); // 1. UI 状态 - const { viewMode, selectedFilters, searchRequest, sortState } = preferences; + const { selectedFilters, searchRequest, sortState } = preferences; const updatePreferences = useCallback((updater: (prev: ScriptListPreferences) => ScriptListPreferences) => { setPreferences((prev) => { @@ -126,12 +115,6 @@ export default function ScriptList() { const demoScripts = useMemo(() => getDemoScripts(t), [t]); const displayScripts = demoActive ? demoScripts : filterScriptList; - // 3. 持久化视图切换 - const handleSetViewMode = useCallback( - (mode: ScriptListViewMode) => updatePreferences((prev) => ({ ...prev, viewMode: mode })), - [updatePreferences] - ); - const handleSetSelectedFilters = useCallback( (updater: SetStateAction) => { updatePreferences((prev) => ({ @@ -456,6 +439,14 @@ export default function ScriptList() { filterItems={filterItems} selectedFilters={selectedFilters} setSelectedFilters={handleSetSelectedFilters} + selectedUuids={selectedUuids} + toggleSelect={toggleSelect} + toggleSelectAll={toggleSelectAll} + clearSelection={clearSelection} + onBatchEnable={handleBatchEnable} + onBatchDisable={handleBatchDisable} + onBatchExport={handleBatchExport} + onBatchDelete={handleBatchDelete} /> {userConfigDialog} {cloudDialog} @@ -470,13 +461,11 @@ export default function ScriptList() { ) : ( { - it("没有新偏好时应兼容旧版视图模式", () => { - expect(parseScriptListPreferences(null, "card")).toEqual({ - ...DEFAULT_SCRIPT_LIST_PREFERENCES, - viewMode: "card", - }); + it("列表成为唯一视图后,偏好里不再有 viewMode 字段", () => { + expect(DEFAULT_SCRIPT_LIST_PREFERENCES).not.toHaveProperty("viewMode"); }); - it("应读取筛选、搜索与列排序状态", () => { + it("忽略历史遗留的 viewMode,同一份偏好里的筛选、搜索与排序照常读出", () => { const raw = JSON.stringify({ - viewMode: "table", + viewMode: "card", selectedFilters: { status: 1, type: 2, tags: "tool", source: "example.com" }, searchRequest: { keyword: "helper", type: "name" }, sortState: { key: "updatetime", order: "desc" }, }); - expect(parseScriptListPreferences(raw, null)).toEqual({ - viewMode: "table", + expect(parseScriptListPreferences(raw)).toEqual({ selectedFilters: { status: 1, type: 2, tags: "tool", source: "example.com" }, searchRequest: { keyword: "helper", type: "name" }, sortState: { key: "updatetime", order: "desc" }, }); }); + it("没有存过偏好时回退默认值", () => { + expect(parseScriptListPreferences(null)).toEqual(DEFAULT_SCRIPT_LIST_PREFERENCES); + }); + it("遇到损坏数据时应回退默认值", () => { - expect(parseScriptListPreferences("{bad", "nope")).toEqual(DEFAULT_SCRIPT_LIST_PREFERENCES); + expect(parseScriptListPreferences("{bad")).toEqual(DEFAULT_SCRIPT_LIST_PREFERENCES); }); it("导出稳定的 localStorage key", () => { expect(SCRIPT_LIST_PREFERENCES_KEY).toBe("script-list-preferences"); - expect(SCRIPT_LIST_VIEW_MODE_KEY).toBe("script-list-view-mode"); }); }); diff --git a/src/pages/options/routes/ScriptList/preferences.ts b/src/pages/options/routes/ScriptList/preferences.ts index 2b934f380..09d0c336a 100644 --- a/src/pages/options/routes/ScriptList/preferences.ts +++ b/src/pages/options/routes/ScriptList/preferences.ts @@ -3,20 +3,15 @@ import type { SortKey, SortState } from "./sort"; import type { TSelectFilter } from "./hooks"; import type { SearchFilterRequest } from "./SearchFilter"; -export const SCRIPT_LIST_VIEW_MODE_KEY = "script-list-view-mode"; export const SCRIPT_LIST_PREFERENCES_KEY = "script-list-preferences"; -export type ScriptListViewMode = "table" | "card"; - export type ScriptListPreferences = { - viewMode: ScriptListViewMode; selectedFilters: TSelectFilter; searchRequest: SearchFilterRequest; sortState: SortState; }; export const DEFAULT_SCRIPT_LIST_PREFERENCES: ScriptListPreferences = { - viewMode: "table", selectedFilters: { status: null, type: null, tags: null, source: null }, searchRequest: { keyword: "", type: "auto" }, sortState: { key: null, order: "asc" }, @@ -26,10 +21,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function parseViewMode(value: unknown): ScriptListViewMode | null { - return value === "table" || value === "card" ? value : null; -} - function parseFilterValue(value: unknown): string | number | null { return typeof value === "string" || typeof value === "number" || value === null ? value : null; } @@ -68,35 +59,26 @@ function parseSortState(value: unknown): SortState { }; } -export function parseScriptListPreferences(raw: string | null, legacyViewMode?: string | null): ScriptListPreferences { - const legacy = parseViewMode(legacyViewMode); - if (!raw) { - return { ...DEFAULT_SCRIPT_LIST_PREFERENCES, viewMode: legacy ?? DEFAULT_SCRIPT_LIST_PREFERENCES.viewMode }; - } +/** 旧版存过的 viewMode 会被直接忽略:列表已是唯一视图,同一份偏好里的其它设置照常读出。 */ +export function parseScriptListPreferences(raw: string | null): ScriptListPreferences { + if (!raw) return DEFAULT_SCRIPT_LIST_PREFERENCES; try { const value: unknown = JSON.parse(raw); - if (!isRecord(value)) { - return { ...DEFAULT_SCRIPT_LIST_PREFERENCES, viewMode: legacy ?? DEFAULT_SCRIPT_LIST_PREFERENCES.viewMode }; - } + if (!isRecord(value)) return DEFAULT_SCRIPT_LIST_PREFERENCES; return { - viewMode: parseViewMode(value.viewMode) ?? legacy ?? DEFAULT_SCRIPT_LIST_PREFERENCES.viewMode, selectedFilters: parseSelectedFilters(value.selectedFilters), searchRequest: parseSearchRequest(value.searchRequest), sortState: parseSortState(value.sortState), }; } catch { - return { ...DEFAULT_SCRIPT_LIST_PREFERENCES, viewMode: legacy ?? DEFAULT_SCRIPT_LIST_PREFERENCES.viewMode }; + return DEFAULT_SCRIPT_LIST_PREFERENCES; } } export function readScriptListPreferences(): ScriptListPreferences { - return parseScriptListPreferences( - localStorage.getItem(SCRIPT_LIST_PREFERENCES_KEY), - localStorage.getItem(SCRIPT_LIST_VIEW_MODE_KEY) - ); + return parseScriptListPreferences(localStorage.getItem(SCRIPT_LIST_PREFERENCES_KEY)); } export function writeScriptListPreferences(preferences: ScriptListPreferences): void { localStorage.setItem(SCRIPT_LIST_PREFERENCES_KEY, JSON.stringify(preferences)); - localStorage.setItem(SCRIPT_LIST_VIEW_MODE_KEY, preferences.viewMode); } diff --git a/src/pages/options/routes/ScriptList/sort.ts b/src/pages/options/routes/ScriptList/sort.ts index 26cc5f21c..8a1b048c4 100644 --- a/src/pages/options/routes/ScriptList/sort.ts +++ b/src/pages/options/routes/ScriptList/sort.ts @@ -7,9 +7,13 @@ export type SortOrder = "asc" | "desc"; export type SortState = { key: SortKey | null; order: SortOrder }; /** - * 点击表头时计算下一个排序状态:未激活列 → 升序;升序 → 降序;降序 → 关闭(回到自然顺序)。 + * 计算下一个排序状态:未激活项 → 升序;升序 → 降序;降序 → 关闭(回到自然顺序)。 + * 键类型放宽为泛型,好让回收站等排序维度不同的页面共用同一套三态规则。 */ -export function nextSortState(current: SortState, key: SortKey): SortState { +export function nextSortState( + current: { key: K | null; order: SortOrder }, + key: K +): { key: K | null; order: SortOrder } { if (current.key !== key) return { key, order: "asc" }; if (current.order === "asc") return { key, order: "desc" }; return { key: null, order: "asc" }; diff --git a/src/pages/options/routes/SubscribeList/SubscribeCardGrid.tsx b/src/pages/options/routes/SubscribeList/SubscribeCardGrid.tsx deleted file mode 100644 index 6c444b9bd..000000000 --- a/src/pages/options/routes/SubscribeList/SubscribeCardGrid.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React, { useCallback } from "react"; -import { Inbox } from "lucide-react"; -import { SubscribeStatusType } from "@App/app/repo/subscribe"; -import { requestEnableSubscribe, type SubscribeLoading } from "@App/pages/store/features/subscribe"; -import { useTranslation } from "react-i18next"; -import { notify } from "@App/pages/components/ui/toast"; -import { EmptyState } from "@App/pages/components/ui/empty-state"; -import { LoadingState } from "@App/pages/components/ui/loading-state"; -import { Surface } from "@App/pages/components/ui/surface"; -import { versionDisplay } from "@App/pages/utils"; -import { - SubscribeIcon, - SubscribeEnableSwitch, - PermissionFavicons, - SubscribeSourceTag, - SubscribeUpdateTimeCell, - SubscribeRowActions, -} from "./components"; - -export interface SubscribeCardGridProps { - subscribeList: SubscribeLoading[]; - loadingList: boolean; - updateSubscribes: (urls: string[], data: Partial) => void; - handleDelete: (subscribe: SubscribeLoading) => void; -} - -// ========== 卡片网格主组件 ========== -function SubscribeCardGrid({ subscribeList, loadingList, updateSubscribes, handleDelete }: SubscribeCardGridProps) { - const { t } = useTranslation(); - const handleEnable = useCallback( - (subscribe: SubscribeLoading, checked: boolean) => { - updateSubscribes([subscribe.url], { enableLoading: true }); - // 订阅服务端不广播状态变更,成功后在页面侧乐观写回 status;失败则回滚并提示 - requestEnableSubscribe({ url: subscribe.url, enable: checked }) - .then(() => - updateSubscribes([subscribe.url], { - status: checked ? SubscribeStatusType.enable : SubscribeStatusType.disable, - enableLoading: false, - }) - ) - .catch((e) => { - updateSubscribes([subscribe.url], { enableLoading: false }); - notify.error(`${t("script:operation_failed")}: ${e}`); - }); - }, - [updateSubscribes, t] - ); - - return ( -
- {loadingList && } - - {!loadingList && subscribeList.length === 0 && ( - - )} - - {!loadingList && subscribeList.length > 0 && ( -
- {subscribeList.map((subscribe) => ( - - ))} -
- )} -
- ); -} - -// ========== 单个卡片 ========== -interface SubscribeCardItemProps { - subscribe: SubscribeLoading; - onEnable: (subscribe: SubscribeLoading, checked: boolean) => void; - onDelete: (subscribe: SubscribeLoading) => void; -} - -const SubscribeCardItem = React.memo( - ({ subscribe, onEnable, onDelete }: SubscribeCardItemProps) => { - const { t } = useTranslation(); - const isDisabled = subscribe.status === SubscribeStatusType.disable; - const version = subscribe.metadata.version?.[0] || "0.0"; - const scriptCount = Object.keys(subscribe.scripts || {}).length; - const subtitle = [ - versionDisplay(version), - t("script:subscribe_scripts_count", { count: scriptCount }), - subscribe.author, - ] - .filter(Boolean) - .join(" · "); - - return ( - - {/* 头部: 图标 + 名称/元信息 + 开关 */} -
- -
- {subscribe.name} - {subtitle} -
- onEnable(subscribe, checked)} - /> -
- - {/* 来源 + 权限 */} -
- - -
- - {/* 分隔线 */} -
- - {/* 底部: 更新时间 + 操作 */} -
- - onDelete(subscribe)} /> -
- - ); - }, - (prev, next) => - prev.subscribe.url === next.subscribe.url && - prev.subscribe.status === next.subscribe.status && - prev.subscribe.enableLoading === next.subscribe.enableLoading && - prev.subscribe.actionLoading === next.subscribe.actionLoading && - prev.subscribe.updatetime === next.subscribe.updatetime -); -SubscribeCardItem.displayName = "SubscribeCardItem"; - -export default React.memo(SubscribeCardGrid); diff --git a/src/pages/options/routes/SubscribeList/SubscribeListMobile.test.tsx b/src/pages/options/routes/SubscribeList/SubscribeListMobile.test.tsx index 162d77339..db82dcb93 100644 --- a/src/pages/options/routes/SubscribeList/SubscribeListMobile.test.tsx +++ b/src/pages/options/routes/SubscribeList/SubscribeListMobile.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest"; -import { cleanup } from "@testing-library/react"; +import { cleanup, fireEvent } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; import { mockMatchMedia } from "@Tests/mockMatchMedia"; import { renderWithRouterTooltip } from "@Tests/renderWithTooltip"; @@ -76,38 +76,40 @@ afterEach(() => cleanup()); // ── Tests ──────────────────────────────────────────────────────────────────── -describe("订阅列表移动端卡片外壳", () => { - it("移动端渲染订阅卡片而非横向滚动的宽表格", () => { - const { container, queryByTestId } = renderWithRouterTooltip(); - // 出现卡片 - expect(queryByTestId("subscribe-card")).toBeInTheDocument(); - // 不再出现需要横向滚动的宽表格 - expect(container.querySelector(".min-w-\\[820px\\]")).toBeNull(); +describe("订阅列表移动端列表行", () => { + it("移动端渲染移动端列表行而非桌面列表行", () => { + const { container } = renderWithRouterTooltip(); + + expect(container.querySelector('[data-slot="mobile-list-row"]')).not.toBeNull(); + expect(container.querySelector('[data-slot="list-row"]')).toBeNull(); }); - it("卡片内保留各列数据:名称、版本、来源徽章、更新时间、启用开关与删除按钮", () => { - const { getByTestId } = renderWithRouterTooltip(); - const card = getByTestId("subscribe-card"); - - expect(card).toHaveAttribute("data-slot", "surface"); - // 名称(标识) - expect(card.textContent).toContain("我的订阅"); - // 版本 - expect(card.textContent).toContain("1.2.3"); - // 来源徽章(订阅地址) - expect(card.textContent).toContain(t("script:subscribe_url")); - // 启用开关(role=switch) - expect(card.querySelector('[role="switch"]')).not.toBeNull(); - // 删除按钮(aria-label=删除) - expect(card.querySelector(`[aria-label="${t("delete")}"]`)).not.toBeNull(); - // 检查更新入口(更新时间单元格内) - expect(card.querySelector(`[aria-label="${t("check_update")}"]`)).not.toBeNull(); + it("行上保留名称、版本与启用开关,删除与检查更新落在左滑与操作面板里", () => { + const { container } = renderWithRouterTooltip(); + const row = container.querySelector('[data-slot="mobile-list-row"]')!; + + expect(row.textContent).toContain("我的订阅"); + expect(row.textContent).toContain("1.2.3"); + expect(row.querySelector('[role="switch"]')).not.toBeNull(); + + // 左滑露出删除 + const swipe = container.querySelector('[data-slot="mobile-swipe-row"]')!; + fireEvent.touchStart(swipe, { touches: [{ clientX: 200 }] }); + fireEvent.touchEnd(swipe, { changedTouches: [{ clientX: 100 }] }); + expect(container.querySelector('[data-slot="mobile-swipe-actions"]')!.textContent).toContain(t("delete")); + + // 整行点击打开操作面板,来源徽章、更新时间与检查更新入口都在面板里 + fireEvent.click(container.querySelector('[data-slot="mobile-list-row-main"]')!); + const sheet = document.querySelector('[data-slot="mobile-action-sheet"]')!; + expect(sheet.textContent).toContain(t("script:subscribe_url")); + expect(sheet.querySelector(`[aria-label="${t("check_update")}"]`)).not.toBeNull(); + expect(sheet.textContent).toContain(t("delete")); }); - it("桌面端仍渲染含横向滚动宽表格的表格视图", () => { + it("桌面端渲染桌面列表行而非移动端行", () => { mockedUseIsMobile.mockReturnValue(false); - const { container, queryByTestId } = renderWithRouterTooltip(); - expect(queryByTestId("subscribe-card")).toBeNull(); - expect(container.querySelector(".min-w-\\[820px\\]")).not.toBeNull(); + const { container } = renderWithRouterTooltip(); + expect(container.querySelector('[data-slot="mobile-list-row"]')).toBeNull(); + expect(container.querySelector('[data-slot="list-row"]')).not.toBeNull(); }); }); diff --git a/src/pages/options/routes/SubscribeList/SubscribeListMobile.tsx b/src/pages/options/routes/SubscribeList/SubscribeListMobile.tsx index 64576429d..63c2e7bb0 100644 --- a/src/pages/options/routes/SubscribeList/SubscribeListMobile.tsx +++ b/src/pages/options/routes/SubscribeList/SubscribeListMobile.tsx @@ -11,7 +11,7 @@ import { DropdownMenuTrigger, } from "@App/pages/components/ui/dropdown-menu"; import { SearchInput } from "@App/pages/components/ui/search-input"; -import SubscribeCardGrid from "./SubscribeCardGrid"; +import SubscribeRowsMobile from "./SubscribeRowsMobile"; export interface SubscribeListMobileProps { subscribeList: SubscribeLoading[]; @@ -94,7 +94,7 @@ function SubscribeListMobile({
- ) => void; + handleDelete: (subscribe: SubscribeLoading) => void; +} + +export default function SubscribeRowsMobile({ + subscribeList, + loadingList, + updateSubscribes, + handleDelete, +}: SubscribeRowsMobileProps) { + const { t } = useTranslation(); + const [sheetUrl, setSheetUrl] = useState(null); + + const handleEnable = useCallback( + (subscribe: SubscribeLoading, checked: boolean) => { + updateSubscribes([subscribe.url], { enableLoading: true }); + // 订阅服务端不广播状态变更,成功后在页面侧乐观写回 status;失败则回滚并提示 + requestEnableSubscribe({ url: subscribe.url, enable: checked }) + .then(() => + updateSubscribes([subscribe.url], { + status: checked ? SubscribeStatusType.enable : SubscribeStatusType.disable, + enableLoading: false, + }) + ) + .catch((e) => { + updateSubscribes([subscribe.url], { enableLoading: false }); + notify.error(`${t("script:operation_failed")}: ${e}`); + }); + }, + [updateSubscribes, t] + ); + + // 同时只允许一行滑开:否则多行会各自挂着「删除」块 + const [swipeOpenUrl, setSwipeOpenUrl] = useState(null); + const handleSwipeOpenChange = useCallback( + (url: string, open: boolean) => setSwipeOpenUrl((prev) => (open ? url : prev === url ? null : prev)), + [] + ); + + if (loadingList) { + return ( +
+ +
+ ); + } + + if (subscribeList.length === 0) { + return ( +
+ +
+ ); + } + + const sheetSubscribe = sheetUrl ? (subscribeList.find((s) => s.url === sheetUrl) ?? null) : null; + + return ( +
+ {subscribeList.map((subscribe) => ( + + ))} + + {sheetSubscribe && ( + !open && setSheetUrl(null)} + title={sheetSubscribe.name} + description={subtitleOf(sheetSubscribe, t)} + icon={} + > + {/* 行上放不下来源与更新时间,改由面板承载;检查更新入口就在更新时间旁边 */} +
+ + +
+ handleDelete(sheetSubscribe)}> + {t("delete")} + +
+ )} +
+ ); +} + +function subtitleOf(subscribe: SubscribeLoading, t: TFunction) { + const scriptCount = Object.keys(subscribe.scripts || {}).length; + return [ + versionDisplay(subscribe.metadata.version?.[0] || "0.0"), + t("script:subscribe_scripts_count", { count: scriptCount }), + subscribe.author, + ] + .filter(Boolean) + .join(" · "); +} + +interface SubscribeRowMobileProps { + subscribe: SubscribeLoading; + swipeOpen: boolean; + onSwipeOpenChange: (url: string, open: boolean) => void; + onEnable: (subscribe: SubscribeLoading, checked: boolean) => void; + onOpenActions: (url: string) => void; + onDelete: (subscribe: SubscribeLoading) => void; +} + +const SubscribeRowMobile = React.memo( + ({ subscribe, swipeOpen, onSwipeOpenChange, onEnable, onOpenActions, onDelete }: SubscribeRowMobileProps) => { + const { t } = useTranslation(); + return ( + onSwipeOpenChange(subscribe.url, open)} + actions={ + + } + > + + + + + + onOpenActions(subscribe.url)}> + {subscribe.name} + {subtitleOf(subscribe, t)} + + + + onEnable(subscribe, checked)} + /> + + + + ); + }, + (prev, next) => + prev.subscribe.url === next.subscribe.url && + prev.subscribe.status === next.subscribe.status && + prev.subscribe.enableLoading === next.subscribe.enableLoading && + prev.subscribe.actionLoading === next.subscribe.actionLoading && + prev.subscribe.updatetime === next.subscribe.updatetime +); +SubscribeRowMobile.displayName = "SubscribeRowMobile"; diff --git a/src/pages/options/routes/SubscribeList/SubscribeTable.test.tsx b/src/pages/options/routes/SubscribeList/SubscribeTable.test.tsx new file mode 100644 index 000000000..0bff0e94f --- /dev/null +++ b/src/pages/options/routes/SubscribeList/SubscribeTable.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; +import { useState } from "react"; +import { cleanup, fireEvent, screen } from "@testing-library/react"; +import { initTestLanguage } from "@Tests/initTestLanguage"; +import { renderWithRouterTooltip } from "@Tests/renderWithTooltip"; +import { t } from "@App/locales/locales"; +import { SubscribeStatusType } from "@App/app/repo/subscribe"; +import type { SubscribeLoading } from "@App/pages/store/features/subscribe"; +import type { SubscribeSort } from "./filter"; + +vi.mock("@App/pages/store/features/subscribe", () => ({ + requestEnableSubscribe: vi.fn(() => Promise.resolve(true)), + requestCheckSubscribeUpdate: vi.fn(() => Promise.resolve(false)), +})); + +vi.mock("@App/pages/components/ui/toast", () => ({ + notify: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +import SubscribeTable from "./SubscribeTable"; + +beforeAll(() => initTestLanguage("zh-CN")); +afterEach(cleanup); + +const mk = (url: string, name: string, overrides: Partial = {}): SubscribeLoading => + ({ + url, + name, + code: "", + author: "tester", + scripts: { s1: { uuid: "s1", url: "https://example.com/a.user.js" } }, + metadata: { version: ["1.2.3"], connect: ["example.com"] }, + status: SubscribeStatusType.enable, + createtime: 1700000000000, + updatetime: 1700000100000, + checktime: 1700000100000, + ...overrides, + }) as SubscribeLoading; + +const noop = () => {}; + +// 排序与状态筛选都由本组件的工具栏驱动,harness 只负责把状态回灌, +// 好让三态循环与筛选切换在组件外可观察。 +const Harness = ({ list }: { list: SubscribeLoading[] }) => { + const [sort, setSort] = useState(null); + const [statusFilter, setStatusFilter] = useState(null); + return ( + <> + {sort ? `${sort.field}:${sort.order}` : "none"} + {statusFilter === null ? "none" : String(statusFilter)} + + + ); +}; + +// Radix 下拉需要 pointerDown 才会展开(同 FilterBar.test.tsx 的既有写法) +function openMenu(testId: string) { + const trigger = screen.getByTestId(testId); + fireEvent.pointerDown(trigger); + fireEvent.click(trigger); +} + +describe("桌面订阅列表行", () => { + it("行使用统一列表行骨架,不再有强制横向滚动的宽度下限", () => { + const { container } = renderWithRouterTooltip(); + + expect(container.querySelectorAll('[data-slot="list-row"]')).toHaveLength(1); + expect(container.querySelector('[data-slot="list-row-leading"]')).not.toBeNull(); + expect(container.querySelector('[data-slot="list-row-main"]')).not.toBeNull(); + expect(container.querySelector('[data-slot="list-row-trailing"]')).not.toBeNull(); + expect(container.querySelector('[data-slot="list-row-actions"]')).not.toBeNull(); + expect(container.querySelector(".min-w-\\[820px\\]")).toBeNull(); + }); + + it("表头移除后版本与来源徽章下沉到名称下方的元信息行", () => { + const { container } = renderWithRouterTooltip(); + + const main = container.querySelector('[data-slot="list-row-main"]')!; + expect(main.textContent).toContain("1.2.3"); + expect(main.textContent).toContain(t("script:subscribe_url")); + expect(main.textContent).toContain("tester"); + }); + + it("左锚区只有开关,权限站点与更新时间留在右锚区,删除是唯一操作槽", () => { + const { container } = renderWithRouterTooltip(); + + const leading = container.querySelector('[data-slot="list-row-leading"]')!; + expect(leading.querySelector('[role="switch"]')).not.toBeNull(); + // 行号随排序重新编号、且 Subscribe 没有持久顺序字段,故不再渲染 + expect(leading.textContent).not.toMatch(/\d/); + + const trailing = container.querySelector('[data-slot="list-row-trailing"]')!; + expect(trailing.querySelector(`[aria-label="${t("check_update")}"]`)).not.toBeNull(); + + const actions = container.querySelector('[data-slot="list-row-actions"]')!; + expect(actions.querySelectorAll("button")).toHaveLength(1); + expect(actions.querySelector(`[aria-label="${t("delete")}"]`)).not.toBeNull(); + }); +}); + +describe("桌面订阅工具栏", () => { + it("排序下拉承接原表头的三个排序键", () => { + renderWithRouterTooltip(); + + openMenu("sort-menu"); + + expect(screen.getByText(t("script:sort_create_time"))).toBeInTheDocument(); + expect(screen.getByText(t("name"))).toBeInTheDocument(); + expect(screen.getByText(t("logs:last_updated"))).toBeInTheDocument(); + }); + + it("排序下拉按升序→降序→取消循环,取消后回到未排序", () => { + renderWithRouterTooltip(); + + openMenu("sort-menu"); + fireEvent.click(screen.getByText(t("name"))); + expect(screen.getByTestId("sort-state")).toHaveTextContent("name:asc"); + + openMenu("sort-menu"); + fireEvent.click(screen.getByText(t("name"))); + expect(screen.getByTestId("sort-state")).toHaveTextContent("name:desc"); + + openMenu("sort-menu"); + fireEvent.click(screen.getByText(t("name"))); + expect(screen.getByTestId("sort-state")).toHaveTextContent("none"); + }); + + it("状态筛选下拉承接原表头的状态筛选", () => { + renderWithRouterTooltip(); + + openMenu("subscribe-status-filter"); + fireEvent.click(screen.getByText(t("disable"))); + + expect(screen.getByTestId("status-state")).toHaveTextContent(String(SubscribeStatusType.disable)); + }); + + it("按传入顺序渲染", () => { + const { container } = renderWithRouterTooltip( + + ); + + const names = [...container.querySelectorAll('[data-slot="list-row-main"] .text-sm')].map((n) => n.textContent); + expect(names).toEqual(["订阅乙", "订阅甲"]); + }); +}); diff --git a/src/pages/options/routes/SubscribeList/SubscribeTable.tsx b/src/pages/options/routes/SubscribeList/SubscribeTable.tsx index 4898e79d4..6eb6ec5d9 100644 --- a/src/pages/options/routes/SubscribeList/SubscribeTable.tsx +++ b/src/pages/options/routes/SubscribeList/SubscribeTable.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from "react"; -import { ChevronUp, ChevronDown, ChevronsUpDown, ListFilter, Check, Inbox } from "lucide-react"; +import { ChevronDown, ListFilter, Check, Inbox } from "lucide-react"; import { SubscribeStatusType } from "@App/app/repo/subscribe"; import { requestEnableSubscribe, type SubscribeLoading } from "@App/pages/store/features/subscribe"; import { cn } from "@App/pkg/utils/cn"; @@ -9,12 +9,20 @@ import { EmptyState } from "@App/pages/components/ui/empty-state"; import { LoadingState } from "@App/pages/components/ui/loading-state"; import { SearchInput } from "@App/pages/components/ui/search-input"; import { Tooltip, TooltipContent, TooltipTrigger } from "@App/pages/components/ui/tooltip"; +import { + ListRow, + ListRowActions, + ListRowLeading, + ListRowMain, + ListRowTrailing, +} from "@App/pages/components/ui/list-row"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@App/pages/components/ui/dropdown-menu"; +import { SortMenu } from "../ScriptList/SortMenu"; import type { SubscribeSort, SubscribeSortField } from "./filter"; import { SubscribeIcon, @@ -35,42 +43,13 @@ export interface SubscribeTableProps { setSearchKeyword: (kw: string) => void; totalCount: number; sort: SubscribeSort | null; - onSort: (field: SubscribeSortField) => void; + setSort: (sort: SubscribeSort | null) => void; statusFilter: SubscribeStatusType | null; setStatusFilter: (v: SubscribeStatusType | null) => void; } -// ========== 可排序表头 ========== -function SortHeader({ - label, - field, - sort, - onSort, -}: { - label: string; - field: SubscribeSortField; - sort: SubscribeSort | null; - onSort: (field: SubscribeSortField) => void; -}) { - const active = sort?.field === field; - const Icon = active ? (sort!.order === "asc" ? ChevronUp : ChevronDown) : ChevronsUpDown; - return ( - - ); -} - -// ========== 状态筛选表头 ========== -function StatusFilterHeader({ +// ========== 状态筛选 ========== +function StatusFilterMenu({ statusFilter, setStatusFilter, }: { @@ -83,21 +62,24 @@ function StatusFilterHeader({ { value: SubscribeStatusType.enable, label: t("enable") }, { value: SubscribeStatusType.disable, label: t("disable") }, ]; + const active = options.find((o) => o.value === statusFilter) ?? options[0]; return ( - + {options.map((o) => ( - {/* 顶栏:标题 + 数量 + 搜索 */} + {/* 顶栏:标题 + 数量 + 搜索 + 状态筛选 + 排序(后两者由表头迁入) */}

{t("script:subscribe")}

@@ -164,30 +146,20 @@ export default function SubscribeTable({ value={searchKeyword} onChange={(e) => setSearchKeyword(e.target.value)} /> + + setSort(next.key === null ? null : { field: next.key, order: next.order })} + />
- {/* 表格 */} -
- {/* 表头 */} -
-
- -
-
- -
-
- -
-
{t("version")}
-
{t("permission:permission")}
-
{t("source")}
-
- -
-
{t("action")}
-
- + {/* 列表 */} +
{/* 加载状态 */} {loadingList && } @@ -204,14 +176,8 @@ export default function SubscribeTable({ {/* 订阅行 */} {!loadingList && subscribeList.length > 0 && - subscribeList.map((subscribe, index) => ( - + subscribeList.map((subscribe) => ( + ))}
@@ -220,84 +186,71 @@ export default function SubscribeTable({ // ========== 订阅行 ========== interface SubscribeRowProps { - index: number; subscribe: SubscribeLoading; onEnable: (subscribe: SubscribeLoading, checked: boolean) => void; onDelete: (subscribe: SubscribeLoading) => void; } -function SubscribeRowInner({ index, subscribe, onEnable, onDelete }: SubscribeRowProps) { +function SubscribeRowInner({ subscribe, onEnable, onDelete }: SubscribeRowProps) { const { t } = useTranslation(); - const isDisabled = subscribe.status === SubscribeStatusType.disable; const version = subscribe.metadata.version?.[0] || "0.0"; const scriptCount = Object.keys(subscribe.scripts || {}).length; - const subtitle = [t("script:subscribe_scripts_count", { count: scriptCount }), subscribe.author] + const subtitle = [ + versionDisplay(version), + t("script:subscribe_scripts_count", { count: scriptCount }), + subscribe.author, + ] .filter(Boolean) .join(" · "); return ( -
- {/* 序号 */} -
{index + 1}
- - {/* 开关 */} -
- onEnable(subscribe, checked)} - /> -
+ + + + onEnable(subscribe, checked)} + /> + + - {/* 名称 + 元信息 */} -
+ -
+
{subscribe.name} {subscribe.name} - {subtitle} + {/* 版本与来源由原来的固定列降级为元信息行 */} + + {subtitle} + +
-
- - {/* 版本 */} -
{versionDisplay(version)}
- - {/* 权限 */} -
- -
+
- {/* 来源 */} -
- -
- - {/* 最后更新 */} -
- -
+ + {/* 与脚本列表同一断点:窄窗口下站点图标让位给订阅名 */} +
+ +
+
+ +
+
- {/* 操作 */} - onDelete(subscribe)} - className="w-[80px] justify-end opacity-[0.55] group-hover/row:opacity-100" - /> -
+ + onDelete(subscribe)} /> + +
); } const SubscribeRow = React.memo(SubscribeRowInner, (prev, next) => { return ( - prev.index === next.index && prev.subscribe.url === next.subscribe.url && prev.subscribe.status === next.subscribe.status && prev.subscribe.enableLoading === next.subscribe.enableLoading && diff --git a/src/pages/options/routes/SubscribeList/components.tsx b/src/pages/options/routes/SubscribeList/components.tsx index f5cd66570..a6f444825 100644 --- a/src/pages/options/routes/SubscribeList/components.tsx +++ b/src/pages/options/routes/SubscribeList/components.tsx @@ -92,7 +92,7 @@ export const SubscribeSourceTag = React.memo(({ url }: { url: string }) => { return ( - + {t("script:subscribe_url")} diff --git a/src/pages/options/routes/SubscribeList/index.tsx b/src/pages/options/routes/SubscribeList/index.tsx index 3f4fc6a05..bc6f09a08 100644 --- a/src/pages/options/routes/SubscribeList/index.tsx +++ b/src/pages/options/routes/SubscribeList/index.tsx @@ -17,7 +17,7 @@ import { import SubscribeTable from "./SubscribeTable"; import SubscribeListMobile from "./SubscribeListMobile"; import { useSubscribeDataManagement } from "./hooks"; -import { filterAndSortSubscribes, type SubscribeSort, type SubscribeSortField } from "./filter"; +import { filterAndSortSubscribes, type SubscribeSort } from "./filter"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; /** @@ -66,15 +66,6 @@ export default function SubscribeList() { [subscribeList, statusFilter, searchKeyword, sort] ); - // 点击表头切换排序:升序 → 降序 → 取消 - const handleSort = useCallback((field: SubscribeSortField) => { - setSort((prev) => { - if (!prev || prev.field !== field) return { field, order: "asc" }; - if (prev.order === "asc") return { field, order: "desc" }; - return null; - }); - }, []); - // 删除二次确认(删除订阅会一并删除其安装的脚本) const [pendingDeleteUrl, setPendingDeleteUrl] = useState(null); @@ -120,7 +111,7 @@ export default function SubscribeList() { setSearchKeyword={setSearchKeyword} totalCount={subscribeList.length} sort={sort} - onSort={handleSort} + setSort={setSort} statusFilter={statusFilter} setStatusFilter={setStatusFilter} />