From e0317aa6c74d6dc0c3513bf0d9f1aa330fedf94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sun, 30 Aug 2026 12:16:16 +0800 Subject: [PATCH 01/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E4=B8=8E=E5=9B=9E=E6=94=B6=E7=AB=99=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E7=AB=AF=E6=94=B9=E4=B8=BA=E7=BB=9F=E4=B8=80=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 表格的固定列宽合计约 660px,名称列只能吃剩下的空间,而来源、标签两列 在多数脚本上半空;操作列预留 192px 却因按钮条件渲染仍然右缘参差。 改为「左锚 + 自适应中段 + 右锚」的列表行:新增 ListRow 槽位骨架,只固化 行高、悬停/选中/禁用外观与操作槽的常驻半透明,锚区宽度由消费者给出—— 脚本列表左锚有勾选/拖拽/开关,回收站只有勾选,预设宽度会立刻退化成带 消费者判别参数的适配器。骨架在本次提交内即由两页共同验证。 来源与标签降级为元信息行的行内徽章,名称区从 520px 增至约 800px。 操作区固定三槽(编辑 / 运行·停止 / 更多),槽位数与脚本类型无关,右缘 不再参差;条件性操作收进「更多」菜单。 表头移除后:排序改由工具栏 SortMenu 承载,三态循环仍复用 sort.ts 的 nextSortState(键类型放宽为泛型以供回收站共用);全选改由列表上方的常驻 控件承载,避免随表头一起消失;新手引导锚点由 col-* 改挂到行内 row-*。 视图切换与卡片视图一并移除,已持久化的 viewMode 读取时忽略,同一份偏好 里的筛选、搜索、排序照常读出。 --- src/locales/de-DE/script.json | 8 +- src/locales/en-US/script.json | 8 +- src/locales/ja-JP/script.json | 8 +- src/locales/ko-KR/script.json | 8 +- src/locales/pt-BR/script.json | 8 +- src/locales/ru-RU/script.json | 8 +- src/locales/tr-TR/script.json | 8 +- src/locales/vi-VN/script.json | 8 +- src/locales/zh-CN/script.json | 8 +- src/locales/zh-TW/script.json | 8 +- src/pages/components/ui/list-row.test.tsx | 62 +++++ src/pages/components/ui/list-row.tsx | 63 +++++ src/pages/options/onboarding/steps.ts | 4 +- .../routes/ScriptList/BatchActions.test.tsx | 1 - .../routes/ScriptList/ScriptCard.test.tsx | 47 ---- .../options/routes/ScriptList/ScriptCard.tsx | 72 ----- .../ScriptList/ScriptListMobile.test.tsx | 3 - .../routes/ScriptList/ScriptTable.test.tsx | 69 ++--- .../options/routes/ScriptList/ScriptTable.tsx | 250 ++++++------------ .../routes/ScriptList/SortMenu.test.tsx | 60 +++++ .../options/routes/ScriptList/SortMenu.tsx | 71 +++++ .../options/routes/ScriptList/Toolbar.tsx | 67 ++--- .../options/routes/ScriptList/TrashTable.tsx | 129 +++++---- .../routes/ScriptList/TrashViews.test.tsx | 53 ++++ .../options/routes/ScriptList/components.tsx | 118 +++++++++ .../options/routes/ScriptList/index.test.tsx | 10 +- src/pages/options/routes/ScriptList/index.tsx | 27 +- .../routes/ScriptList/preferences.test.ts | 22 +- .../options/routes/ScriptList/preferences.ts | 30 +-- src/pages/options/routes/ScriptList/sort.ts | 8 +- 30 files changed, 750 insertions(+), 496 deletions(-) create mode 100644 src/pages/components/ui/list-row.test.tsx create mode 100644 src/pages/components/ui/list-row.tsx delete mode 100644 src/pages/options/routes/ScriptList/ScriptCard.test.tsx delete mode 100644 src/pages/options/routes/ScriptList/ScriptCard.tsx create mode 100644 src/pages/options/routes/ScriptList/SortMenu.test.tsx create mode 100644 src/pages/options/routes/ScriptList/SortMenu.tsx diff --git a/src/locales/de-DE/script.json b/src/locales/de-DE/script.json index 01f0d0178..f8d01dc76 100644 --- a/src/locales/de-DE/script.json +++ b/src/locales/de-DE/script.json @@ -111,5 +111,11 @@ "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_default": "Standard", + "sort_ascending": "Aufsteigend", + "sort_descending": "Absteigend", + "more_actions": "Weitere Aktionen", + "select_all": "Alle auswählen" } diff --git a/src/locales/en-US/script.json b/src/locales/en-US/script.json index 72203cd57..cc3032c5c 100644 --- a/src/locales/en-US/script.json +++ b/src/locales/en-US/script.json @@ -111,5 +111,11 @@ "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_default": "Default", + "sort_ascending": "Ascending", + "sort_descending": "Descending", + "more_actions": "More actions", + "select_all": "Select all" } diff --git a/src/locales/ja-JP/script.json b/src/locales/ja-JP/script.json index e98b005cf..a5faf7efe 100644 --- a/src/locales/ja-JP/script.json +++ b/src/locales/ja-JP/script.json @@ -111,5 +111,11 @@ "trash_moved_batch": "{{count}} 件のスクリプトをゴミ箱に移動しました", "trash_undo": "元に戻す", "trash_undo_success": "復元しました", - "trash_undo_failed": "復元に失敗しました" + "trash_undo_failed": "復元に失敗しました", + "sort_by": "並び替え", + "sort_default": "デフォルト", + "sort_ascending": "昇順", + "sort_descending": "降順", + "more_actions": "その他の操作", + "select_all": "すべて選択" } diff --git a/src/locales/ko-KR/script.json b/src/locales/ko-KR/script.json index 12c1aa58c..1cde20768 100644 --- a/src/locales/ko-KR/script.json +++ b/src/locales/ko-KR/script.json @@ -111,5 +111,11 @@ "trash_moved_batch": "스크립트 {{count}}개를 휴지통으로 이동했습니다", "trash_undo": "실행 취소", "trash_undo_success": "복원되었습니다", - "trash_undo_failed": "복원에 실패했습니다" + "trash_undo_failed": "복원에 실패했습니다", + "sort_by": "정렬", + "sort_default": "기본", + "sort_ascending": "오름차순", + "sort_descending": "내림차순", + "more_actions": "추가 작업", + "select_all": "전체 선택" } diff --git a/src/locales/pt-BR/script.json b/src/locales/pt-BR/script.json index 252ac11bc..a4d230486 100644 --- a/src/locales/pt-BR/script.json +++ b/src/locales/pt-BR/script.json @@ -111,5 +111,11 @@ "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_default": "Padrão", + "sort_ascending": "Crescente", + "sort_descending": "Decrescente", + "more_actions": "Mais ações", + "select_all": "Selecionar tudo" } diff --git a/src/locales/ru-RU/script.json b/src/locales/ru-RU/script.json index 73340210d..6ddaf145b 100644 --- a/src/locales/ru-RU/script.json +++ b/src/locales/ru-RU/script.json @@ -111,5 +111,11 @@ "trash_moved_batch": "В корзину перемещено скриптов: {{count}}", "trash_undo": "Отменить", "trash_undo_success": "Восстановлено", - "trash_undo_failed": "Не удалось восстановить" + "trash_undo_failed": "Не удалось восстановить", + "sort_by": "Сортировка", + "sort_default": "По умолчанию", + "sort_ascending": "По возрастанию", + "sort_descending": "По убыванию", + "more_actions": "Другие действия", + "select_all": "Выбрать все" } diff --git a/src/locales/tr-TR/script.json b/src/locales/tr-TR/script.json index 37734d571..2fc3970f1 100644 --- a/src/locales/tr-TR/script.json +++ b/src/locales/tr-TR/script.json @@ -111,5 +111,11 @@ "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_default": "Varsayılan", + "sort_ascending": "Artan", + "sort_descending": "Azalan", + "more_actions": "Diğer işlemler", + "select_all": "Tümünü seç" } diff --git a/src/locales/vi-VN/script.json b/src/locales/vi-VN/script.json index c0d9604dd..c0e947119 100644 --- a/src/locales/vi-VN/script.json +++ b/src/locales/vi-VN/script.json @@ -111,5 +111,11 @@ "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_default": "Mặc định", + "sort_ascending": "Tăng dần", + "sort_descending": "Giảm dần", + "more_actions": "Thao tác khác", + "select_all": "Chọn tất cả" } diff --git a/src/locales/zh-CN/script.json b/src/locales/zh-CN/script.json index 1e240629f..24d6eb44c 100644 --- a/src/locales/zh-CN/script.json +++ b/src/locales/zh-CN/script.json @@ -111,5 +111,11 @@ "trash_moved_batch": "已将 {{count}} 个脚本移入回收站", "trash_undo": "撤销", "trash_undo_success": "已还原", - "trash_undo_failed": "还原失败" + "trash_undo_failed": "还原失败", + "sort_by": "排序", + "sort_default": "默认", + "sort_ascending": "升序", + "sort_descending": "降序", + "more_actions": "更多操作", + "select_all": "全选" } diff --git a/src/locales/zh-TW/script.json b/src/locales/zh-TW/script.json index 7bc8b47b6..da426f9c6 100644 --- a/src/locales/zh-TW/script.json +++ b/src/locales/zh-TW/script.json @@ -111,5 +111,11 @@ "trash_moved_batch": "已將 {{count}} 個腳本移入回收筒", "trash_undo": "復原", "trash_undo_success": "已還原", - "trash_undo_failed": "還原失敗" + "trash_undo_failed": "還原失敗", + "sort_by": "排序", + "sort_default": "預設", + "sort_ascending": "升序", + "sort_descending": "降序", + "more_actions": "更多操作", + "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..0b81b0ec8 --- /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, listRowVariants }; diff --git a/src/pages/options/onboarding/steps.ts b/src/pages/options/onboarding/steps.ts index 2f56a76c6..98df0e140 100644 --- a/src/pages/options/onboarding/steps.ts +++ b/src/pages/options/onboarding/steps.ts @@ -19,7 +19,7 @@ export const DESKTOP_STEPS: TourStep[] = [ }, { id: "enable", - target: "col-enable", + target: "row-enable", route: "/", titleKey: "guide:script_list_enable_title", contentKey: "guide:script_list_enable_content", @@ -27,7 +27,7 @@ export const DESKTOP_STEPS: TourStep[] = [ }, { id: "action", - target: "col-action", + target: "row-action", route: "/", titleKey: "guide:script_list_action_title", contentKey: "guide:script_list_action_content", diff --git a/src/pages/options/routes/ScriptList/BatchActions.test.tsx b/src/pages/options/routes/ScriptList/BatchActions.test.tsx index c55fe1919..d50a240d7 100644 --- a/src/pages/options/routes/ScriptList/BatchActions.test.tsx +++ b/src/pages/options/routes/ScriptList/BatchActions.test.tsx @@ -61,7 +61,6 @@ vi.mock("./ScriptTable", () => ({ ), })); -vi.mock("./ScriptCard", () => ({ default: () => null })); vi.mock("./ScriptListMobile", () => ({ default: () => null })); vi.mock("@App/pages/components/use-is-mobile", () => ({ useIsMobile: () => false })); diff --git a/src/pages/options/routes/ScriptList/ScriptCard.test.tsx b/src/pages/options/routes/ScriptList/ScriptCard.test.tsx deleted file mode 100644 index fe853b46f..000000000 --- a/src/pages/options/routes/ScriptList/ScriptCard.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; -import { render, cleanup, screen } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { t } from "@App/locales/locales"; -import { initTestLanguage } from "@Tests/initTestLanguage"; -import ScriptCard from "./ScriptCard"; - -vi.mock("@App/pages/store/features/script", () => ({ - requestEnableScript: vi.fn(), - requestFilterResult: vi.fn(), -})); - -vi.mock("./importHandler", () => ({ - handleImportFiles: vi.fn(), - handleImportUrls: vi.fn(), -})); - -beforeAll(() => initTestLanguage("zh-CN")); - -afterEach(cleanup); - -const baseProps = { - scriptList: [], - loadingList: false, - updateScripts: vi.fn(), - handleDelete: vi.fn(), - handleRunStop: vi.fn(), - setViewMode: vi.fn(), - searchRequest: { keyword: "", type: "auto" as const }, - setSearchRequest: vi.fn(), - totalCount: 0, - scriptListSortOrderMove: vi.fn(), - filterItems: { statusItems: [], typeItems: [], tagItems: [], sourceItems: [] }, - selectedFilters: { status: null, type: null, tags: null, source: null }, - setSelectedFilters: vi.fn(), -}; - -describe("ScriptCard 卡片视图顶栏", () => { - it("卡片模式也应显示「新建脚本」按钮", () => { - render( - - - - ); - expect(screen.getByText(t("script:create_script"))).toBeInTheDocument(); - }); -}); diff --git a/src/pages/options/routes/ScriptList/ScriptCard.tsx b/src/pages/options/routes/ScriptList/ScriptCard.tsx deleted file mode 100644 index d478aadd9..000000000 --- a/src/pages/options/routes/ScriptList/ScriptCard.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from "react"; -import type { ScriptLoading } from "@App/pages/store/features/script"; -import type { SearchFilterRequest } from "./SearchFilter"; -import FilterBar from "./FilterBar"; -import type { FilterBarProps } from "./FilterBar"; -import { Toolbar } from "./Toolbar"; -import ScriptCardGrid from "./ScriptCardGrid"; - -export interface ScriptCardProps extends FilterBarProps { - /** 顶栏最左侧内容(tabs),透传给 Toolbar 取代标题槽位 */ - leading?: React.ReactNode; - scriptList: ScriptLoading[]; - loadingList: boolean; - 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; - scriptListSortOrderMove: (params: { active: string; over: string }) => void; -} - -function ScriptCard({ - scriptList, - loadingList, - updateScripts, - handleDelete, - handleRunStop, - setViewMode, - searchRequest, - setSearchRequest, - totalCount, - scriptListSortOrderMove, - filterItems, - selectedFilters, - setSelectedFilters, - leading, -}: ScriptCardProps) { - return ( -
- - - -
- ); -} - -export default React.memo(ScriptCard, (prev, next) => { - return ( - prev.loadingList === next.loadingList && - prev.scriptList === next.scriptList && - prev.searchRequest.keyword === next.searchRequest.keyword && - prev.searchRequest.type === next.searchRequest.type && - prev.totalCount === next.totalCount && - prev.selectedFilters === next.selectedFilters - ); -}); diff --git a/src/pages/options/routes/ScriptList/ScriptListMobile.test.tsx b/src/pages/options/routes/ScriptList/ScriptListMobile.test.tsx index 8ae80319f..805b4b1bc 100644 --- a/src/pages/options/routes/ScriptList/ScriptListMobile.test.tsx +++ b/src/pages/options/routes/ScriptList/ScriptListMobile.test.tsx @@ -78,9 +78,6 @@ vi.mock("./ScriptTable", () => ({
{leading ?? }
), })); -vi.mock("./ScriptCard", () => ({ - default: () => null, -})); vi.mock("./ScriptCardGrid", () => ({ default: () =>
, })); diff --git a/src/pages/options/routes/ScriptList/ScriptTable.test.tsx b/src/pages/options/routes/ScriptList/ScriptTable.test.tsx index 30df6d589..30562c111 100644 --- a/src/pages/options/routes/ScriptList/ScriptTable.test.tsx +++ b/src/pages/options/routes/ScriptList/ScriptTable.test.tsx @@ -43,9 +43,11 @@ const noop = () => {}; const TableHarness = ({ scriptList, initialSortState = { key: null, order: "asc" }, + toggleSelectAll = noop, }: { scriptList: ScriptLoading[]; initialSortState?: SortState; + toggleSelectAll?: () => void; }) => { const [sortState, setSortState] = useState(initialSortState); return ( @@ -55,7 +57,6 @@ const TableHarness = ({ updateScripts={noop} handleDelete={noop} handleRunStop={() => Promise.resolve()} - setViewMode={noop} searchRequest={{ keyword: "", type: "auto" }} setSearchRequest={noop} totalCount={scriptList.length} @@ -65,7 +66,7 @@ const TableHarness = ({ setSelectedFilters={noop} selectedUuids={new Set()} toggleSelect={noop} - toggleSelectAll={noop} + toggleSelectAll={toggleSelectAll} clearSelection={noop} onBatchEnable={noop} onBatchDisable={noop} @@ -90,66 +91,68 @@ const renderTable = (scriptList: ScriptLoading[], initialSortState?: SortState) const renderedOrder = () => Array.from(document.querySelectorAll('a[href*="/script/editor/"]')).map((a) => a.textContent); -describe("ScriptTable 列头点击排序", () => { +// 表头随列表化移除后,排序的三态循环归 SortMenu 所有(见 SortMenu.test.tsx); +// 这里只覆盖 ScriptTable 自己的契约:给定排序状态时行的渲染顺序与拖拽可用性。 +describe("ScriptTable 按排序状态渲染", () => { // 自然顺序:B、A、C const list = [mk("b", "Banana", 30), mk("a", "Apple", 10), mk("c", "Cherry", 20)]; - it("点击「名称」表头在 升序 → 降序 → 关闭 之间循环", () => { + it("未排序时保持拖拽得到的自然顺序", () => { renderTable(list); expect(renderedOrder()).toEqual(["Banana", "Apple", "Cherry"]); + }); - const nameHeader = screen.getByText(t("name")).closest("button")!; - fireEvent.click(nameHeader); // 升序 + it("按名称升序与降序分别渲染对应顺序", () => { + renderTable(list, { key: "name", order: "asc" }); expect(renderedOrder()).toEqual(["Apple", "Banana", "Cherry"]); - fireEvent.click(nameHeader); // 降序 + cleanup(); + renderTable(list, { key: "name", order: "desc" }); expect(renderedOrder()).toEqual(["Cherry", "Banana", "Apple"]); - - fireEvent.click(nameHeader); // 关闭,回到自然顺序 - expect(renderedOrder()).toEqual(["Banana", "Apple", "Cherry"]); }); - it("点击「最后更新」表头按更新时间升序排列", () => { - renderTable(list); - fireEvent.click(screen.getByText(t("logs:last_updated")).closest("button")!); + it("按更新时间排序", () => { + renderTable(list, { key: "updatetime", order: "asc" }); // updatetime: A=10, C=20, B=30 expect(renderedOrder()).toEqual(["Apple", "Cherry", "Banana"]); }); - it("从外部排序状态恢复上次的列排序", () => { - renderTable(list, { key: "updatetime", order: "desc" }); - expect(renderedOrder()).toEqual(["Banana", "Cherry", "Apple"]); - }); - - it("排序激活时禁用手动拖拽(不再渲染可拖拽手柄)", () => { + it("排序激活时禁用手动拖拽,未排序时每行都有拖拽手柄", () => { renderTable(list); - // 未排序时每行都有可拖拽手柄 expect(document.querySelectorAll(".cursor-grab").length).toBe(list.length); - fireEvent.click(screen.getByText(t("name")).closest("button")!); + cleanup(); + renderTable(list, { key: "name", order: "asc" }); expect(document.querySelectorAll(".cursor-grab").length).toBe(0); }); }); -describe("ScriptTable 英文列头布局", () => { - it("状态列为 Status 文本和排序图标保留完整宽度", () => { - initTestLanguage("en-US"); - renderTable([mk("a", "Apple", 10)]); +describe("ScriptTable 全选入口", () => { + it("表头移除后全选仍可达:工具栏提供三态全选复选框", () => { + initTestLanguage("zh-CN"); + const onToggleAll = vi.fn(); + renderWithRouterTooltip( + + ); - const statusColumn = document.querySelector('[data-tour="col-enable"]'); - expect(statusColumn).toHaveClass("w-16"); + const selectAll = screen.getByLabelText(t("script:select_all")); + fireEvent.click(selectAll); + expect(onToggleAll).toHaveBeenCalled(); }); +}); - it("状态列表头居中且开关列不额外偏移", () => { +describe("ScriptTable 新手引导锚点", () => { + it("表头移除后,引导锚点落在行内的开关与操作区上", () => { initTestLanguage("zh-CN"); renderTable([mk("a", "Apple", 10)]); - expect(screen.getByText(t("script:script_list.sidebar.status")).closest("button")).toHaveClass("justify-center"); - expect(document.querySelector('[data-tour="col-enable"]')).toHaveClass("text-center"); + const enableAnchor = document.querySelector('[data-tour="row-enable"]'); + const actionAnchor = document.querySelector('[data-tour="row-action"]'); - const switchColumn = document.querySelector('[role="switch"]')!.parentElement; - expect(switchColumn).toHaveClass("w-16", "flex"); - expect(switchColumn).not.toHaveClass("justify-center"); + expect(enableAnchor).not.toBeNull(); + expect(enableAnchor!.querySelector('[role="switch"]')).not.toBeNull(); + expect(actionAnchor).not.toBeNull(); + expect(actionAnchor!.querySelectorAll("button").length).toBeGreaterThan(0); }); }); diff --git a/src/pages/options/routes/ScriptList/ScriptTable.tsx b/src/pages/options/routes/ScriptList/ScriptTable.tsx index 4fbe0871f..d765e56f3 100644 --- a/src/pages/options/routes/ScriptList/ScriptTable.tsx +++ b/src/pages/options/routes/ScriptList/ScriptTable.tsx @@ -1,7 +1,7 @@ import React, { createContext, useCallback, useContext, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; -import { ChevronDown, ChevronUp, ChevronsUpDown, GripVertical } from "lucide-react"; +import { GripVertical } from "lucide-react"; import type { DragEndEvent } from "@dnd-kit/core"; import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { @@ -19,6 +19,13 @@ import { parseTags } from "@App/app/repo/metadata"; import { getCombinedMeta } from "@App/app/service/service_worker/utils"; import type { SCMetadata } from "@App/app/repo/scripts"; import { Checkbox } from "@App/pages/components/ui/checkbox"; +import { + ListRow, + ListRowActions, + ListRowLeading, + ListRowMain, + ListRowTrailing, +} from "@App/pages/components/ui/list-row"; import { Tooltip, TooltipContent, TooltipTrigger } from "@App/pages/components/ui/tooltip"; import { EmptyState } from "@App/pages/components/ui/empty-state"; import { LoadingState } from "@App/pages/components/ui/loading-state"; @@ -35,11 +42,11 @@ import { SourceTag, scriptTypeLabel, getTagColor, - ScriptRowActions, + ScriptRowActionSlots, } from "./components"; import type { SearchFilterRequest } from "./SearchFilter"; -import { nextSortState, sortScriptList } from "./sort"; -import type { SortKey, SortState } from "./sort"; +import { sortScriptList } from "./sort"; +import type { SortState } from "./sort"; import FilterBar from "./FilterBar"; import type { FilterBarProps } from "./FilterBar"; import BatchActionsBar from "./BatchActionsBar"; @@ -83,50 +90,6 @@ function RowDragHandle() { return handle ?? ; } -// ========== 可排序表头 ========== -function SortHeader({ - label, - sortKey, - sortState, - onSort, - className, - leftPad, -}: { - label: string; - sortKey: SortKey; - sortState: SortState; - onSort: (key: SortKey) => void; - className?: string; - leftPad?: boolean; -}) { - const active = sortState.key === sortKey; - return ( - - ); -} - 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]); @@ -229,8 +189,8 @@ export default function ScriptTable({ @@ -255,47 +215,20 @@ export default function ScriptTable({ />
- {/* 表格 */} + {/* 列表 */}
- {/* 表头 */} -
-
- -
-
-
- + {/* 全选:表头随列表化移除后,它是全选的唯一入口,故常驻而非只在已选中时出现 */} + {!loadingList && scriptList.length > 0 && ( +
+ + +
-
- -
-
{t("source")}
-
{t("script:tags")}
-
- {t("script:apply_to_run_status")} -
-
- -
-
- {t("action")} -
-
+ )} {/* 加载状态 */} {loadingList && } @@ -358,87 +291,68 @@ 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} - - -
- ) : ( - - )} -
- - {/* 最后更新 */} -
- -
+
+ + +
+ {isBackground ? ( +
+ + + + + + + {typeTooltip} + + +
+ ) : ( + + )} +
+
+ +
+
- {/* 操作(行内图标按钮,已去掉 ⋯ 更多菜单) */} - -
+ {/* 槽位数固定为三,宽度随之恒定,右缘不再随脚本类型参差 */} + + + + ); } 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..f29d96ce5 --- /dev/null +++ b/src/pages/options/routes/ScriptList/SortMenu.test.tsx @@ -0,0 +1,60 @@ +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" }); + }); +}); diff --git a/src/pages/options/routes/ScriptList/SortMenu.tsx b/src/pages/options/routes/ScriptList/SortMenu.tsx new file mode 100644 index 000000000..dd7607b7f --- /dev/null +++ b/src/pages/options/routes/ScriptList/SortMenu.tsx @@ -0,0 +1,71 @@ +import { ArrowUpDown, 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 ( + + + + + + {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..f54e8520b 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,51 +33,31 @@ 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/TrashTable.tsx b/src/pages/options/routes/ScriptList/TrashTable.tsx index 07afaa8b6..4496c70d3 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)), @@ -164,6 +189,14 @@ export default function TrashTable({ value={keyword} onChange={(e) => 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..fe27bad9a 100644 --- a/src/pages/options/routes/ScriptList/TrashViews.test.tsx +++ b/src/pages/options/routes/ScriptList/TrashViews.test.tsx @@ -252,3 +252,56 @@ 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"])); + }); +}); diff --git a/src/pages/options/routes/ScriptList/components.tsx b/src/pages/options/routes/ScriptList/components.tsx index c82624025..e80f67793 100644 --- a/src/pages/options/routes/ScriptList/components.tsx +++ b/src/pages/options/routes/ScriptList/components.tsx @@ -32,8 +32,25 @@ import { CircleArrowUp, Check, Clock, + Ellipsis, } 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"; @@ -409,6 +426,107 @@ export function ScriptRowActions({ ); } +/** + * 列表行的固定三槽操作:编辑 / 运行·停止 / 更多。 + * 槽位数与脚本类型无关——普通脚本的运行槽渲染为等宽占位,使各行右缘始终对齐; + * 条件性操作(主页、用户配置、云端上传、删除)收进「更多」,删除进二级菜单亦降低误触。 + */ +export function ScriptRowActionSlots({ + script, + navigate, + onDelete, + onRunStop, +}: { + script: ScriptLoading; + navigate: (to: string) => void; + onDelete: (script: ScriptLoading) => void; + onRunStop: (script: ScriptLoading) => void; +}) { + 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); + + return ( + <> + navigate(`/script/editor/${script.uuid}`)}> + + + + {isBackground ? ( + onRunStop(script)} + disabled={script.actionLoading} + > + {isRunning ? : } + + ) : ( + + )} + + + + + + + + + {home && openExternalUrl(home)}>{t("script:homepage")}} + {script.config && ( + { + preloadUserConfigValues(); + navigate(`/?userConfig=${script.uuid}`); + }} + > + {t("editor:user_config")} + + )} + {script.metadata?.cloudcat && ( + { + preloadCloudPlan(); + navigate(`/?cloud=${script.uuid}`); + }} + > + {t("editor:upload_to_cloud")} + + )} + 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")} + + + + + + ); +} + // ========== 脚本主页链接 ========== // 取脚本主页/支持链接(优先 homepage,其次 homepageurl / website / source / supporturl) export function getScriptHomePage(metadata?: SCMetadata): string | undefined { 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..3aeffe892 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) => ({ @@ -470,13 +453,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" }; From d063a668166f16cf4e405b1e587fced3fe59a404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sun, 30 Aug 2026 12:35:41 +0800 Subject: [PATCH 02/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E9=A1=B5=E6=94=B9=E4=B8=BA=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 表头的三个排序键与状态筛选迁入工具栏下拉,版本与来源徽章下沉到名称下方的 元信息行,移除表头与行上的 min-w-[820px](窄窗口不再横向滚动)。 排序三态循环改由 SortMenu 统一承担,index.tsx 不再自带一份。 --- src/locales/de-DE/script.json | 1 + src/locales/en-US/script.json | 1 + src/locales/ja-JP/script.json | 1 + src/locales/ko-KR/script.json | 1 + src/locales/pt-BR/script.json | 1 + src/locales/ru-RU/script.json | 1 + src/locales/tr-TR/script.json | 1 + src/locales/vi-VN/script.json | 1 + src/locales/zh-CN/script.json | 1 + src/locales/zh-TW/script.json | 1 + .../SubscribeListMobile.test.tsx | 10 +- .../SubscribeList/SubscribeTable.test.tsx | 160 ++++++++++++++++ .../routes/SubscribeList/SubscribeTable.tsx | 179 +++++++----------- .../options/routes/SubscribeList/index.tsx | 13 +- 14 files changed, 247 insertions(+), 125 deletions(-) create mode 100644 src/pages/options/routes/SubscribeList/SubscribeTable.test.tsx diff --git a/src/locales/de-DE/script.json b/src/locales/de-DE/script.json index f8d01dc76..f503be6da 100644 --- a/src/locales/de-DE/script.json +++ b/src/locales/de-DE/script.json @@ -113,6 +113,7 @@ "trash_undo_success": "Wiederhergestellt", "trash_undo_failed": "Wiederherstellen fehlgeschlagen", "sort_by": "Sortieren", + "sort_create_time": "Hinzugefügt am", "sort_default": "Standard", "sort_ascending": "Aufsteigend", "sort_descending": "Absteigend", diff --git a/src/locales/en-US/script.json b/src/locales/en-US/script.json index cc3032c5c..99f7f7448 100644 --- a/src/locales/en-US/script.json +++ b/src/locales/en-US/script.json @@ -113,6 +113,7 @@ "trash_undo_success": "Restored", "trash_undo_failed": "Restore failed", "sort_by": "Sort", + "sort_create_time": "Added time", "sort_default": "Default", "sort_ascending": "Ascending", "sort_descending": "Descending", diff --git a/src/locales/ja-JP/script.json b/src/locales/ja-JP/script.json index a5faf7efe..0f787272d 100644 --- a/src/locales/ja-JP/script.json +++ b/src/locales/ja-JP/script.json @@ -113,6 +113,7 @@ "trash_undo_success": "復元しました", "trash_undo_failed": "復元に失敗しました", "sort_by": "並び替え", + "sort_create_time": "追加日時", "sort_default": "デフォルト", "sort_ascending": "昇順", "sort_descending": "降順", diff --git a/src/locales/ko-KR/script.json b/src/locales/ko-KR/script.json index 1cde20768..e54179446 100644 --- a/src/locales/ko-KR/script.json +++ b/src/locales/ko-KR/script.json @@ -113,6 +113,7 @@ "trash_undo_success": "복원되었습니다", "trash_undo_failed": "복원에 실패했습니다", "sort_by": "정렬", + "sort_create_time": "추가 시간", "sort_default": "기본", "sort_ascending": "오름차순", "sort_descending": "내림차순", diff --git a/src/locales/pt-BR/script.json b/src/locales/pt-BR/script.json index a4d230486..0c3f3f91f 100644 --- a/src/locales/pt-BR/script.json +++ b/src/locales/pt-BR/script.json @@ -113,6 +113,7 @@ "trash_undo_success": "Restaurado", "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", diff --git a/src/locales/ru-RU/script.json b/src/locales/ru-RU/script.json index 6ddaf145b..d2b6c0f8f 100644 --- a/src/locales/ru-RU/script.json +++ b/src/locales/ru-RU/script.json @@ -113,6 +113,7 @@ "trash_undo_success": "Восстановлено", "trash_undo_failed": "Не удалось восстановить", "sort_by": "Сортировка", + "sort_create_time": "Время добавления", "sort_default": "По умолчанию", "sort_ascending": "По возрастанию", "sort_descending": "По убыванию", diff --git a/src/locales/tr-TR/script.json b/src/locales/tr-TR/script.json index 2fc3970f1..ad42104e9 100644 --- a/src/locales/tr-TR/script.json +++ b/src/locales/tr-TR/script.json @@ -113,6 +113,7 @@ "trash_undo_success": "Geri yüklendi", "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", diff --git a/src/locales/vi-VN/script.json b/src/locales/vi-VN/script.json index c0e947119..7b7b7c03e 100644 --- a/src/locales/vi-VN/script.json +++ b/src/locales/vi-VN/script.json @@ -113,6 +113,7 @@ "trash_undo_success": "Đã khôi phục", "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", diff --git a/src/locales/zh-CN/script.json b/src/locales/zh-CN/script.json index 24d6eb44c..f6f7cba34 100644 --- a/src/locales/zh-CN/script.json +++ b/src/locales/zh-CN/script.json @@ -113,6 +113,7 @@ "trash_undo_success": "已还原", "trash_undo_failed": "还原失败", "sort_by": "排序", + "sort_create_time": "添加时间", "sort_default": "默认", "sort_ascending": "升序", "sort_descending": "降序", diff --git a/src/locales/zh-TW/script.json b/src/locales/zh-TW/script.json index da426f9c6..289d119cc 100644 --- a/src/locales/zh-TW/script.json +++ b/src/locales/zh-TW/script.json @@ -113,6 +113,7 @@ "trash_undo_success": "已還原", "trash_undo_failed": "還原失敗", "sort_by": "排序", + "sort_create_time": "新增時間", "sort_default": "預設", "sort_ascending": "升序", "sort_descending": "降序", diff --git a/src/pages/options/routes/SubscribeList/SubscribeListMobile.test.tsx b/src/pages/options/routes/SubscribeList/SubscribeListMobile.test.tsx index 162d77339..a623876b1 100644 --- a/src/pages/options/routes/SubscribeList/SubscribeListMobile.test.tsx +++ b/src/pages/options/routes/SubscribeList/SubscribeListMobile.test.tsx @@ -77,12 +77,12 @@ afterEach(() => cleanup()); // ── Tests ──────────────────────────────────────────────────────────────────── describe("订阅列表移动端卡片外壳", () => { - it("移动端渲染订阅卡片而非横向滚动的宽表格", () => { + it("移动端渲染订阅卡片而非桌面列表行", () => { const { container, queryByTestId } = renderWithRouterTooltip(); // 出现卡片 expect(queryByTestId("subscribe-card")).toBeInTheDocument(); - // 不再出现需要横向滚动的宽表格 - expect(container.querySelector(".min-w-\\[820px\\]")).toBeNull(); + // 不落到桌面分支 + expect(container.querySelector('[data-slot="list-row"]')).toBeNull(); }); it("卡片内保留各列数据:名称、版本、来源徽章、更新时间、启用开关与删除按钮", () => { @@ -104,10 +104,10 @@ describe("订阅列表移动端卡片外壳", () => { expect(card.querySelector(`[aria-label="${t("check_update")}"]`)).not.toBeNull(); }); - it("桌面端仍渲染含横向滚动宽表格的表格视图", () => { + it("桌面端渲染统一列表行而非卡片", () => { mockedUseIsMobile.mockReturnValue(false); const { container, queryByTestId } = renderWithRouterTooltip(); expect(queryByTestId("subscribe-card")).toBeNull(); - expect(container.querySelector(".min-w-\\[820px\\]")).not.toBeNull(); + expect(container.querySelector('[data-slot="list-row"]')).not.toBeNull(); }); }); 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..ca3d2f817 --- /dev/null +++ b/src/pages/options/routes/SubscribeList/SubscribeTable.test.tsx @@ -0,0 +1,160 @@ +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.textContent).toContain("1"); + expect(leading.querySelector('[role="switch"]')).not.toBeNull(); + + 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(["订阅乙", "订阅甲"]); + const indexes = [...container.querySelectorAll('[data-slot="list-row-leading"]')].map((n) => n.textContent?.trim()); + expect(indexes).toEqual(["1", "2"]); + }); +}); diff --git a/src/pages/options/routes/SubscribeList/SubscribeTable.tsx b/src/pages/options/routes/SubscribeList/SubscribeTable.tsx index 4898e79d4..a0628eee6 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 && } @@ -228,70 +200,59 @@ interface SubscribeRowProps { function SubscribeRowInner({ index, 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)} - /> -
+ + + {index + 1} + + 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)} /> + +
); } 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} /> From ab0608f05123ad841f34b9a34289e2921bd99210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sun, 30 Aug 2026 12:54:25 +0800 Subject: [PATCH 03/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E4=B8=89=E9=A1=B5?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E6=94=B9=E4=B8=BA=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E8=A1=8C=E4=B8=8E=E5=8E=9F=E7=94=9F=E6=89=8B?= =?UTF-8?q?=E5=8A=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增移动端槽位骨架 mobile-list(行/左滑外壳/长按/选择模式外壳/底部操作面板), 由脚本列表、回收站、订阅三页同时消费;四个卡片组件(ScriptCardGrid / TrashCardGrid / SubscribeCardGrid 与随之失去引用的 ScriptRowActions)删除。 - 脚本列表:行左图标两行文本、右开关;整行点击开面板;左滑编辑/删除; 长按或顶栏入口进多选,顶栏换取消/已选数/全选,底部换批量操作条。 - 回收站:右锚区改过期倒计时,左滑还原/彻底删除,多选批量还原/彻底删除。 - 订阅:右锚区开关,左滑删除;来源、更新时间与检查更新入口移入操作面板。 - 同步两个跨层文件守卫的文件清单(trash_event_partition / TrashNameStyle)。 --- .../trash_event_partition.test.ts | 4 +- src/locales/de-DE/script.json | 2 + src/locales/en-US/script.json | 2 + src/locales/ja-JP/script.json | 2 + src/locales/ko-KR/script.json | 2 + src/locales/pt-BR/script.json | 2 + src/locales/ru-RU/script.json | 2 + src/locales/tr-TR/script.json | 2 + src/locales/vi-VN/script.json | 2 + src/locales/zh-CN/script.json | 2 + src/locales/zh-TW/script.json | 2 + src/pages/components/ui/list-row.tsx | 2 +- src/pages/components/ui/mobile-list.test.tsx | 196 +++++++++ src/pages/components/ui/mobile-list.tsx | 280 ++++++++++++ .../routes/ScriptList/ScriptCardGrid.test.tsx | 64 --- .../routes/ScriptList/ScriptCardGrid.tsx | 320 -------------- .../ScriptList/ScriptListMobile.test.tsx | 63 ++- .../routes/ScriptList/ScriptListMobile.tsx | 122 +++++- .../ScriptList/ScriptRowsMobile.test.tsx | 155 +++++++ .../routes/ScriptList/ScriptRowsMobile.tsx | 414 ++++++++++++++++++ ...{TrashCardGrid.tsx => TrashListMobile.tsx} | 259 ++++++++--- .../routes/ScriptList/TrashNameStyle.test.ts | 2 +- .../routes/ScriptList/TrashViews.test.tsx | 55 ++- .../routes/ScriptList/components.test.tsx | 184 ++++---- .../options/routes/ScriptList/components.tsx | 106 +---- src/pages/options/routes/ScriptList/index.tsx | 8 + .../SubscribeList/SubscribeCardGrid.tsx | 136 ------ .../SubscribeListMobile.test.tsx | 56 +-- .../SubscribeList/SubscribeListMobile.tsx | 4 +- .../SubscribeList/SubscribeRowsMobile.tsx | 170 +++++++ 30 files changed, 1793 insertions(+), 827 deletions(-) create mode 100644 src/pages/components/ui/mobile-list.test.tsx create mode 100644 src/pages/components/ui/mobile-list.tsx delete mode 100644 src/pages/options/routes/ScriptList/ScriptCardGrid.test.tsx delete mode 100644 src/pages/options/routes/ScriptList/ScriptCardGrid.tsx create mode 100644 src/pages/options/routes/ScriptList/ScriptRowsMobile.test.tsx create mode 100644 src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx rename src/pages/options/routes/ScriptList/{TrashCardGrid.tsx => TrashListMobile.tsx} (53%) delete mode 100644 src/pages/options/routes/SubscribeList/SubscribeCardGrid.tsx create mode 100644 src/pages/options/routes/SubscribeList/SubscribeRowsMobile.tsx 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/script.json b/src/locales/de-DE/script.json index f503be6da..0db1194e0 100644 --- a/src/locales/de-DE/script.json +++ b/src/locales/de-DE/script.json @@ -118,5 +118,7 @@ "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/script.json b/src/locales/en-US/script.json index 99f7f7448..21187ae5e 100644 --- a/src/locales/en-US/script.json +++ b/src/locales/en-US/script.json @@ -118,5 +118,7 @@ "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/script.json b/src/locales/ja-JP/script.json index 0f787272d..b264ff8cd 100644 --- a/src/locales/ja-JP/script.json +++ b/src/locales/ja-JP/script.json @@ -118,5 +118,7 @@ "sort_ascending": "昇順", "sort_descending": "降順", "more_actions": "その他の操作", + "multi_select": "複数選択", + "site_count": "{{count}} サイト", "select_all": "すべて選択" } diff --git a/src/locales/ko-KR/script.json b/src/locales/ko-KR/script.json index e54179446..6474df5a7 100644 --- a/src/locales/ko-KR/script.json +++ b/src/locales/ko-KR/script.json @@ -118,5 +118,7 @@ "sort_ascending": "오름차순", "sort_descending": "내림차순", "more_actions": "추가 작업", + "multi_select": "다중 선택", + "site_count": "{{count}}개 사이트", "select_all": "전체 선택" } diff --git a/src/locales/pt-BR/script.json b/src/locales/pt-BR/script.json index 0c3f3f91f..ac3f1753a 100644 --- a/src/locales/pt-BR/script.json +++ b/src/locales/pt-BR/script.json @@ -118,5 +118,7 @@ "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/script.json b/src/locales/ru-RU/script.json index d2b6c0f8f..912a6502f 100644 --- a/src/locales/ru-RU/script.json +++ b/src/locales/ru-RU/script.json @@ -118,5 +118,7 @@ "sort_ascending": "По возрастанию", "sort_descending": "По убыванию", "more_actions": "Другие действия", + "multi_select": "Выбрать несколько", + "site_count": "{{count}} сайт(ов)", "select_all": "Выбрать все" } diff --git a/src/locales/tr-TR/script.json b/src/locales/tr-TR/script.json index ad42104e9..1dcad714c 100644 --- a/src/locales/tr-TR/script.json +++ b/src/locales/tr-TR/script.json @@ -118,5 +118,7 @@ "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/script.json b/src/locales/vi-VN/script.json index 7b7b7c03e..72b51872d 100644 --- a/src/locales/vi-VN/script.json +++ b/src/locales/vi-VN/script.json @@ -118,5 +118,7 @@ "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/script.json b/src/locales/zh-CN/script.json index f6f7cba34..66ac559d3 100644 --- a/src/locales/zh-CN/script.json +++ b/src/locales/zh-CN/script.json @@ -118,5 +118,7 @@ "sort_ascending": "升序", "sort_descending": "降序", "more_actions": "更多操作", + "multi_select": "多选", + "site_count": "{{count}} 个站点", "select_all": "全选" } diff --git a/src/locales/zh-TW/script.json b/src/locales/zh-TW/script.json index 289d119cc..a2b691ed0 100644 --- a/src/locales/zh-TW/script.json +++ b/src/locales/zh-TW/script.json @@ -118,5 +118,7 @@ "sort_ascending": "升序", "sort_descending": "降序", "more_actions": "更多操作", + "multi_select": "多選", + "site_count": "{{count}} 個站點", "select_all": "全選" } diff --git a/src/pages/components/ui/list-row.tsx b/src/pages/components/ui/list-row.tsx index 0b81b0ec8..1861e8ca6 100644 --- a/src/pages/components/ui/list-row.tsx +++ b/src/pages/components/ui/list-row.tsx @@ -60,4 +60,4 @@ function ListRowActions({ className, ...props }: React.ComponentProps<"div">) { ); } -export { ListRow, ListRowLeading, ListRowMain, ListRowTrailing, ListRowActions, listRowVariants }; +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..d03aaad9f --- /dev/null +++ b/src/pages/components/ui/mobile-list.test.tsx @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +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); + +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 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 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()); + act(() => void vi.advanceTimersByTime(500)); + + expect(onLongPress).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it("提前松手不触发回调", () => { + vi.useFakeTimers(); + const onLongPress = vi.fn(); + const { result } = renderHook(() => useLongPress(onLongPress, 500)); + + act(() => result.current.onTouchStart()); + 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..86d264c63 --- /dev/null +++ b/src/pages/components/ui/mobile-list.tsx @@ -0,0 +1,280 @@ +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 ( +
), @@ -150,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 ──────────────────────────────────────────────────────────────────── @@ -171,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", "已安装关键词"); @@ -278,12 +287,50 @@ 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("空列表时多选入口不可用", () => { + 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..d93d88759 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,52 @@ function ScriptListMobile({ if (!showTrashTab && activeTab === "trash") setActiveTab("installed"); } + if (selectionMode && !isTrash) { + return ( +
+ 0 && selectedUuids.size === scriptList.length} + onCancel={exitSelection} + onToggleSelectAll={toggleSelectAll} + /> + + + {t("enable")} + {t("disable")} + {t("export")} + { + onBatchDelete(); + exitSelection(); + }} + > + {t("delete")} + + +
+ ); + } + return (
{showTrashTab && ( @@ -79,13 +161,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..2160d32f5 --- /dev/null +++ b/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx @@ -0,0 +1,414 @@ +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 { + 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 [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 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; + 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, + 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 ( + + + + + } + > + + + {selectionMode ? ( + onToggleSelect(script.uuid)} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + )} + + + + (selectionMode ? onToggleSelect(script.uuid) : onOpenActions(script.uuid))}> + {name} + + {meta} + {isBackground && } + + + + + {!selectionMode && ( + + onEnable(script, checked)} + /> + + )} + + + ); + }, + (prev, next) => + prev.script.uuid === next.script.uuid && + prev.script.status === next.script.status && + prev.script.enableLoading === next.script.enableLoading && + prev.script.actionLoading === next.script.actionLoading && + prev.script.runStatus === next.script.runStatus && + prev.script.updatetime === next.script.updatetime && + prev.script.favorite === next.script.favorite && + prev.selectionMode === next.selectionMode && + prev.selected === next.selected +); +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/TrashCardGrid.tsx b/src/pages/options/routes/ScriptList/TrashListMobile.tsx similarity index 53% rename from src/pages/options/routes/ScriptList/TrashCardGrid.tsx rename to src/pages/options/routes/ScriptList/TrashListMobile.tsx index 345858d25..4d71f4865 100644 --- a/src/pages/options/routes/ScriptList/TrashCardGrid.tsx +++ b/src/pages/options/routes/ScriptList/TrashListMobile.tsx @@ -1,7 +1,7 @@ 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 { 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"; @@ -9,7 +9,18 @@ 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 { Checkbox } from "@App/pages/components/ui/checkbox"; +import { + 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"; @@ -46,7 +57,7 @@ const FILTERS: { value: SourceFilter; key: string }[] = [ { value: "subscribe", key: "script:trash_source_subscribe" }, ]; -export default function TrashCardGrid({ +export default function TrashListMobile({ keyword = "", onCountChange, }: { @@ -59,6 +70,8 @@ export default function TrashCardGrid({ 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"); @@ -142,6 +155,77 @@ export default function TrashCardGrid({ ); }, [list, sourceFilter, keyword]); + const allSelected = visible.length > 0 && visible.every((item) => selected.has(item.uuid)); + + 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) { + 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 (
@@ -177,6 +261,15 @@ export default function TrashCardGrid({ {t("settings")}
+ - void onPurge([item.uuid])} - > - - -
- - ); - }) + visible.map((item) => ( + + )) )}
); } + +// ========== 单行 ========== +function TrashRowMobile({ + item, + daysLeft, + selectionMode, + selected, + onToggleSelect, + onEnterSelection, + onRestore, + onPurge, +}: { + item: TrashScript; + daysLeft: number | null; + 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 ( + + + void onPurge([item.uuid])} + > + + + + } + > + + + {selectionMode ? ( + onToggleSelect(item.uuid)} /> + ) : ( + + )} + + + selectionMode && onToggleSelect(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/TrashViews.test.tsx b/src/pages/options/routes/ScriptList/TrashViews.test.tsx index fe27bad9a..4c9719a6a 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(); @@ -130,7 +130,7 @@ describe("回收站脚本元信息样式", () => { it.each([ ["桌面端", ], - ["移动端", ], + ["移动端", ], ])("%s 与脚本列表一致显示图标、11px 版本和语义化时间", async (_name, view) => { requestTrashScripts.mockResolvedValue([trashed]); renderWithRouter(view); @@ -168,7 +168,7 @@ describe("回收站关闭时的提示与倒计时", () => { }); it("移动端:提示改为「回收站已关闭」,过期条目不再显示「今天」", async () => { - renderWithRouter(); + renderWithRouter(); expect(await screen.findByText("关闭态脚本")).toBeInTheDocument(); expect(screen.getByText("回收站已关闭 · 新删除的脚本将直接彻底删除")).toBeInTheDocument(); @@ -221,9 +221,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 +248,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(); @@ -305,3 +309,40 @@ describe("回收站桌面端列表化", () => { 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(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..19d86e2a0 100644 --- a/src/pages/options/routes/ScriptList/components.test.tsx +++ b/src/pages/options/routes/ScriptList/components.test.tsx @@ -5,6 +5,7 @@ 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(() => ({ @@ -36,7 +37,7 @@ import { getScriptHomePage, getTagColor, ScheduleNextRun, - ScriptRowActions, + ScriptRowActionSlots, scriptTypeLabel, UpdateTimeCell, } from "./components"; @@ -116,7 +117,7 @@ describe("标签配色 getTagColor", () => { }); }); -describe("ScriptRowActions 行内操作(替代 ⋯ 更多菜单)", () => { +describe("ScriptRowActionSlots 固定三槽操作", () => { const makeScript = (over: Record = {}) => ({ uuid: "u1", @@ -126,151 +127,152 @@ 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( - + ); + return { script, navigate, onDelete, onRunStop }; + }; + + // Radix 下拉需要 pointerDown 才会展开(同 FilterBar.test.tsx 的既有写法) + const openMore = () => { + const trigger = screen.getByLabelText(t("script:more_actions")); + fireEvent.pointerDown(trigger); + fireEvent.click(trigger); + }; + + it("普通脚本渲染编辑与更多两个按钮,运行槽只留等宽占位以对齐右缘", () => { + const { navigate } = renderSlots(); + expect(screen.getByLabelText(t("edit"))).toBeInTheDocument(); - expect(screen.getByLabelText(t("delete"))).toBeInTheDocument(); - // 不应再有「更多」菜单 - expect(screen.queryByLabelText(t("more"))).toBeNull(); + expect(screen.getByLabelText(t("script:more_actions"))).toBeInTheDocument(); + expect(screen.queryByLabelText(t("editor:run"))).toBeNull(); + + fireEvent.click(screen.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(); + 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(); + const { script } = renderSlots({ type: SCRIPT_TYPE_BACKGROUND }, { onRunStop }); + fireEvent.click(screen.getByLabelText(t("editor:run"))); - expect(onRunStop).toHaveBeenCalledWith(script); - }); - it("删除触发器为带 aria-haspopup 的真实按钮(Popconfirm 的 trigger 属性透传到 ActionButton 内层按钮)", () => { - renderWithTooltip( - - ); - const trigger = screen.getByLabelText(t("delete")); - expect(trigger.tagName).toBe("BUTTON"); - expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + 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 () => { 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 e80f67793..79c93b891 100644 --- a/src/pages/options/routes/ScriptList/components.tsx +++ b/src/pages/options/routes/ScriptList/components.tsx @@ -12,28 +12,13 @@ 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"; import { useTranslation } from "react-i18next"; import { cn } from "@App/pkg/utils/cn"; import { getNameAvatarTone, NameAvatar } from "@App/pages/components/NameAvatar"; -import { - Globe, - RefreshCw, - House, - Settings2, - UploadCloud, - Pencil, - Play, - Square, - Trash2, - CircleArrowUp, - Check, - Clock, - Ellipsis, -} from "lucide-react"; +import { Globe, RefreshCw, Pencil, Play, Square, CircleArrowUp, Check, Clock, Ellipsis } from "lucide-react"; import { preloadCloudScriptPlan } from "@App/pages/components/CloudScriptPlan"; import { DropdownMenu, @@ -144,7 +129,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"); } @@ -302,15 +287,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, @@ -344,88 +328,6 @@ const ActionButton = ({ ); -export function ScriptRowActions({ - 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 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); - return ( -
- {home && ( - openExternalUrl(home)}> - - - )} - {script.config && ( - { - preloadUserConfigValues(); - navigate(`/?userConfig=${script.uuid}`); - }} - > - - - )} - {script.metadata?.cloudcat && ( - { - preloadCloudPlan(); - navigate(`/?cloud=${script.uuid}`); - }} - > - - - )} - {isBackground && ( - onRunStop(script)} - disabled={script.actionLoading} - > - {isRunning ? : } - - )} - navigate(`/script/editor/${script.uuid}`)}> - - - onDelete(script)} - > - - - - -
- ); -} - /** * 列表行的固定三槽操作:编辑 / 运行·停止 / 更多。 * 槽位数与脚本类型无关——普通脚本的运行槽渲染为等宽占位,使各行右缘始终对齐; diff --git a/src/pages/options/routes/ScriptList/index.tsx b/src/pages/options/routes/ScriptList/index.tsx index 3aeffe892..f0cf9b2f1 100644 --- a/src/pages/options/routes/ScriptList/index.tsx +++ b/src/pages/options/routes/ScriptList/index.tsx @@ -439,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} 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 a623876b1..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(); - // 不落到桌面分支 +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(); + 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] + ); + + 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; + onEnable: (subscribe: SubscribeLoading, checked: boolean) => void; + onOpenActions: (url: string) => void; + onDelete: (subscribe: SubscribeLoading) => void; +} + +const SubscribeRowMobile = React.memo( + ({ subscribe, onEnable, onOpenActions, onDelete }: SubscribeRowMobileProps) => { + const { t } = useTranslation(); + return ( + onDelete(subscribe)} + className="flex w-16 items-center justify-center bg-destructive text-xs font-medium text-destructive-foreground" + > + {t("delete")} + + } + > + + + + + + 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"; From 3c639b6a93454fb1e0b7cbd96d0ffa52d45dd401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sun, 30 Aug 2026 12:58:13 +0800 Subject: [PATCH 04/17] =?UTF-8?q?=E2=9C=85=20=E8=A1=A5=E9=BD=90=E8=A7=84?= =?UTF-8?q?=E6=A0=BC=E8=A6=81=E6=B1=82=E7=9A=84=E6=8E=92=E5=BA=8F=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E9=A1=B9=E4=B8=8E=E5=8D=95=E8=84=9A=E6=9C=AC=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 排序下拉缺「默认」项,三态循环要点两次才回得到自然顺序; 更多菜单缺规格列出的「导出」,改用 synchronizeClient.export([uuid])。 --- .../options/routes/ScriptList/SortMenu.test.tsx | 10 ++++++++++ src/pages/options/routes/ScriptList/SortMenu.tsx | 10 +++++++++- .../options/routes/ScriptList/components.test.tsx | 13 ++++++++++++- src/pages/options/routes/ScriptList/components.tsx | 13 ++++++++++++- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/pages/options/routes/ScriptList/SortMenu.test.tsx b/src/pages/options/routes/ScriptList/SortMenu.test.tsx index f29d96ce5..e2ad0c41d 100644 --- a/src/pages/options/routes/ScriptList/SortMenu.test.tsx +++ b/src/pages/options/routes/ScriptList/SortMenu.test.tsx @@ -57,4 +57,14 @@ describe("SortMenu 排序下拉", () => { 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 index dd7607b7f..33afdc308 100644 --- a/src/pages/options/routes/ScriptList/SortMenu.tsx +++ b/src/pages/options/routes/ScriptList/SortMenu.tsx @@ -1,4 +1,4 @@ -import { ArrowUpDown, ChevronDown, ChevronUp } from "lucide-react"; +import { ArrowUpDown, Check, ChevronDown, ChevronUp } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "@App/pkg/utils/cn"; import { @@ -47,6 +47,14 @@ export function SortMenu({ options, value, onChange, className + {/* 三态循环要点两次才回得到自然顺序,给它一个一步可达的入口 */} + 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 ( diff --git a/src/pages/options/routes/ScriptList/components.test.tsx b/src/pages/options/routes/ScriptList/components.test.tsx index 19d86e2a0..0087fe3a1 100644 --- a/src/pages/options/routes/ScriptList/components.test.tsx +++ b/src/pages/options/routes/ScriptList/components.test.tsx @@ -8,14 +8,16 @@ import { SCRIPT_TYPE_NORMAL, SCRIPT_TYPE_BACKGROUND, SCRIPT_TYPE_CRONTAB } from 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 })); @@ -267,6 +269,15 @@ describe("ScriptRowActionSlots 固定三槽操作", () => { expect(onDelete).not.toHaveBeenCalled(); }); + 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)); renderSlots(); diff --git a/src/pages/options/routes/ScriptList/components.tsx b/src/pages/options/routes/ScriptList/components.tsx index 79c93b891..e59cbf6e0 100644 --- a/src/pages/options/routes/ScriptList/components.tsx +++ b/src/pages/options/routes/ScriptList/components.tsx @@ -7,7 +7,8 @@ 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"; @@ -401,6 +402,16 @@ export function ScriptRowActionSlots({ {t("editor:upload_to_cloud")} )} + { + const id = notify.loading(t("editor:exporting")); + void synchronizeClient + .export([script.uuid]) + .then(() => notify.success(t("settings:export_success"), { id })); + }} + > + {t("export")} + setConfirmDelete(true)}> {t("delete")} From bbdeae3c04048b462b695c1e432faee2853d2974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sun, 30 Aug 2026 13:01:16 +0800 Subject: [PATCH 05/17] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=B7=A6=E6=BB=91=E4=BD=8D=E7=A7=BB=E5=86=99=E6=AD=BB=E4=B8=8E?= =?UTF-8?q?=E5=A4=9A=E9=80=89=E7=A9=BA=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 左滑内容位移写死 -translate-x-32(128px),订阅只有一个操作块(64px)时 会滑出一段空白;改为按操作区实际宽度位移。 列表在多选期间被清空时会停在只剩批量操作条的空屏上,改为退回普通视图。 --- src/pages/components/ui/mobile-list.test.tsx | 16 +++++++++++++++ src/pages/components/ui/mobile-list.tsx | 20 ++++++++++++++++--- .../ScriptList/ScriptListMobile.test.tsx | 11 ++++++++++ .../routes/ScriptList/ScriptListMobile.tsx | 3 ++- .../routes/ScriptList/TrashListMobile.tsx | 3 ++- 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/pages/components/ui/mobile-list.test.tsx b/src/pages/components/ui/mobile-list.test.tsx index d03aaad9f..d9a01d68b 100644 --- a/src/pages/components/ui/mobile-list.test.tsx +++ b/src/pages/components/ui/mobile-list.test.tsx @@ -96,6 +96,22 @@ describe("左滑操作", () => { expect(container.querySelector('[data-slot="mobile-swipe-actions"]')).toHaveAttribute("data-state", "closed"); }); + 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 onTouchStart = vi.fn(); const { container } = render( diff --git a/src/pages/components/ui/mobile-list.tsx b/src/pages/components/ui/mobile-list.tsx index 86d264c63..6f838cc50 100644 --- a/src/pages/components/ui/mobile-list.tsx +++ b/src/pages/components/ui/mobile-list.tsx @@ -66,7 +66,9 @@ export interface MobileSwipeRowProps extends React.ComponentProps<"div"> { function MobileSwipeRow({ actions, children, className, onTouchStart, onTouchEnd, ...props }: MobileSwipeRowProps) { const [open, setOpen] = useState(false); + const [offset, setOffset] = useState(0); const startX = useRef(null); + const actionsRef = useRef(null); const handleTouchStart = (e: React.TouchEvent) => { startX.current = e.touches[0]?.clientX ?? null; @@ -79,8 +81,13 @@ function MobileSwipeRow({ actions, children, className, onTouchStart, onTouchEnd const to = e.changedTouches[0]?.clientX; if (from !== null && to !== undefined) { const delta = to - from; - if (delta <= -SWIPE_THRESHOLD) setOpen(true); - else if (delta >= SWIPE_THRESHOLD) setOpen(false); + if (delta <= -SWIPE_THRESHOLD) { + // 位移量取操作区实际宽度:各页操作块数量不同,写死距离会在少一块时滑出空白 + setOffset(actionsRef.current?.offsetWidth ?? 0); + setOpen(true); + } else if (delta >= SWIPE_THRESHOLD) { + setOpen(false); + } } onTouchEnd?.(e); }; @@ -94,6 +101,7 @@ function MobileSwipeRow({ actions, children, className, onTouchStart, onTouchEnd {...props} >
{actions}
-
{children}
+
+ {children} +
); } diff --git a/src/pages/options/routes/ScriptList/ScriptListMobile.test.tsx b/src/pages/options/routes/ScriptList/ScriptListMobile.test.tsx index 7f7f215d4..065a9ce3d 100644 --- a/src/pages/options/routes/ScriptList/ScriptListMobile.test.tsx +++ b/src/pages/options/routes/ScriptList/ScriptListMobile.test.tsx @@ -328,6 +328,17 @@ describe("移动端多选模式", () => { 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(); diff --git a/src/pages/options/routes/ScriptList/ScriptListMobile.tsx b/src/pages/options/routes/ScriptList/ScriptListMobile.tsx index d93d88759..cda9aa6ca 100644 --- a/src/pages/options/routes/ScriptList/ScriptListMobile.tsx +++ b/src/pages/options/routes/ScriptList/ScriptListMobile.tsx @@ -85,7 +85,8 @@ function ScriptListMobile({ if (!showTrashTab && activeTab === "trash") setActiveTab("installed"); } - if (selectionMode && !isTrash) { + // 列表在多选期间被清空(删除完/筛选变化)时退回普通视图,否则只剩一条批量操作条悬在空屏上 + if (selectionMode && !isTrash && scriptList.length > 0) { return (
0) { return (
Date: Sun, 30 Aug 2026 13:18:46 +0800 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E6=9A=B4=E9=9C=B2=E7=9A=84=E5=9B=9B?= =?UTF-8?q?=E5=A4=84=E6=B8=B2=E6=9F=93=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 左滑操作区未滑动时就盖住行右侧(挡住开关):操作区是 absolute, 普通流的内容层压不住它,内容层补 relative。 - 移动端次行在窄屏折行把内容挤出固定行高:改为按顺序水平裁切。 - 桌面运行槽占位 size-8 与按钮 size-7 不等宽,普通脚本行的编辑按钮 比后台脚本行左移 4px,右缘并未真正对齐。 - 桌面更新时间列 76px 装不下中文相对时间而折行,加宽并禁止换行; 底部操作面板末项在无安全区的设备上贴住屏幕底边。 --- src/pages/components/ui/mobile-list.test.tsx | 13 +++++++++++++ src/pages/components/ui/mobile-list.tsx | 6 ++++-- .../options/routes/ScriptList/ScriptRowsMobile.tsx | 11 ++++++++--- src/pages/options/routes/ScriptList/ScriptTable.tsx | 2 +- src/pages/options/routes/ScriptList/components.tsx | 2 +- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/pages/components/ui/mobile-list.test.tsx b/src/pages/components/ui/mobile-list.test.tsx index d9a01d68b..80ca73043 100644 --- a/src/pages/components/ui/mobile-list.test.tsx +++ b/src/pages/components/ui/mobile-list.test.tsx @@ -112,6 +112,19 @@ describe("左滑操作", () => { }); }); + 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( diff --git a/src/pages/components/ui/mobile-list.tsx b/src/pages/components/ui/mobile-list.tsx index 6f838cc50..bb626606c 100644 --- a/src/pages/components/ui/mobile-list.tsx +++ b/src/pages/components/ui/mobile-list.tsx @@ -111,7 +111,9 @@ function MobileSwipeRow({ actions, children, className, onTouchStart, onTouchEnd
{children} @@ -246,7 +248,7 @@ function MobileActionSheet({ open, onOpenChange, title, description, icon, child )} -
+
{children}
diff --git a/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx b/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx index 2160d32f5..a501a1b10 100644 --- a/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx +++ b/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx @@ -311,10 +311,15 @@ const ScriptRowMobile = React.memo( (selectionMode ? onToggleSelect(script.uuid) : onOpenActions(script.uuid))}> {name} - + {/* 窄屏放不下时按顺序裁切而不是折行:行高固定,折行会把次行挤出行外 */} + {meta} - {isBackground && } - + {isBackground && ( + + + + )} + diff --git a/src/pages/options/routes/ScriptList/ScriptTable.tsx b/src/pages/options/routes/ScriptList/ScriptTable.tsx index d765e56f3..5492a019b 100644 --- a/src/pages/options/routes/ScriptList/ScriptTable.tsx +++ b/src/pages/options/routes/ScriptList/ScriptTable.tsx @@ -343,7 +343,7 @@ function ScriptRowInner({ script, selected, onSelect, onEnable, onDelete, onRunS )}
-
+
diff --git a/src/pages/options/routes/ScriptList/components.tsx b/src/pages/options/routes/ScriptList/components.tsx index e59cbf6e0..8dde32850 100644 --- a/src/pages/options/routes/ScriptList/components.tsx +++ b/src/pages/options/routes/ScriptList/components.tsx @@ -369,7 +369,7 @@ export function ScriptRowActionSlots({ {isRunning ? : } ) : ( - + )} From 0915eacf7a692719a3d4d2393f81e806f9510929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sun, 30 Aug 2026 14:24:46 +0800 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=92=84=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E4=B8=89=E9=A1=B5=E5=88=97=E8=A1=A8=E8=A1=8C=E4=B9=8B=E9=97=B4?= =?UTF-8?q?=E5=8A=A0=E5=88=86=E9=9A=94=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit card 与 background 两个表面令牌只差 4.4%(亮)/9.5%(暗),靠底色分不开层; border 才是这套令牌里最强的分隔手段(1.207 / 1.161)。列表容器改用 divide-y, 线只出现在行与行之间,首行上方与末行下方不画;骨架去掉圆角,使悬停底色为通栏 矩形而非圆角块。移动端行本就有分隔线,不动。 方案在设计稿 Zone 11.3 定稿(方案 C)。同区另存了方案 G(分隔线 + 加深 --background),因其需连带改 background/border/input/muted-foreground 四个 全站令牌才能成立,本轮不采用。 --- src/pages/components/ui/list-row.tsx | 31 +++++++++---------- .../options/routes/ScriptList/ScriptTable.tsx | 2 +- .../options/routes/ScriptList/TrashTable.tsx | 2 +- .../routes/SubscribeList/SubscribeTable.tsx | 2 +- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/pages/components/ui/list-row.tsx b/src/pages/components/ui/list-row.tsx index 1861e8ca6..2cbaaf0dd 100644 --- a/src/pages/components/ui/list-row.tsx +++ b/src/pages/components/ui/list-row.tsx @@ -5,25 +5,22 @@ 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: "", - }, +const listRowVariants = cva("group/row flex items-center h-14 px-3 transition-colors hover:bg-primary/[0.08]", { + variants: { + selected: { + true: "bg-primary/10", + false: "", }, - defaultVariants: { - selected: false, - disabled: false, + disabled: { + true: "opacity-60", + false: "", }, - } -); + }, + defaultVariants: { + selected: false, + disabled: false, + }, +}); type ListRowProps = React.ComponentProps<"div"> & VariantProps; diff --git a/src/pages/options/routes/ScriptList/ScriptTable.tsx b/src/pages/options/routes/ScriptList/ScriptTable.tsx index 5492a019b..6a2d6f93e 100644 --- a/src/pages/options/routes/ScriptList/ScriptTable.tsx +++ b/src/pages/options/routes/ScriptList/ScriptTable.tsx @@ -216,7 +216,7 @@ export default function ScriptTable({
{/* 列表 */} -
+
{/* 全选:表头随列表化移除后,它是全选的唯一入口,故常驻而非只在已选中时出现 */} {!loadingList && scriptList.length > 0 && (
diff --git a/src/pages/options/routes/ScriptList/TrashTable.tsx b/src/pages/options/routes/ScriptList/TrashTable.tsx index 4496c70d3..563e9bee2 100644 --- a/src/pages/options/routes/ScriptList/TrashTable.tsx +++ b/src/pages/options/routes/ScriptList/TrashTable.tsx @@ -296,7 +296,7 @@ export default function TrashTable({
)} -
+
{!list.length ? (
diff --git a/src/pages/options/routes/SubscribeList/SubscribeTable.tsx b/src/pages/options/routes/SubscribeList/SubscribeTable.tsx index a0628eee6..ab35e7316 100644 --- a/src/pages/options/routes/SubscribeList/SubscribeTable.tsx +++ b/src/pages/options/routes/SubscribeList/SubscribeTable.tsx @@ -159,7 +159,7 @@ export default function SubscribeTable({
{/* 列表 */} -
+
{/* 加载状态 */} {loadingList && } From e76934bf2219e3e8ba0212302e768cb47a7dbb5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sun, 30 Aug 2026 14:36:13 +0800 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=92=84=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E5=85=A8=E9=80=89=E6=A1=86=E4=BB=8E=E5=88=97=E8=A1=A8=E9=A1=B6?= =?UTF-8?q?=E9=83=A8=E7=8B=AC=E7=AB=8B=E8=A1=8C=E6=8C=AA=E8=BF=9B=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=93=8D=E4=BD=9C=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 表头随列表化移除后,全选框被单独摆成一行,视觉上是一条只有一个复选框的空行。 改为挂在 SelectionBar 上(脚本列表与回收站共用),列表顶部直接从第一行开始。 代价是全选变两步:先勾一行让批量条展开,再点栏内全选。 --- .../ScriptList/BatchActionsBar.test.tsx | 22 +++++++++++ .../routes/ScriptList/BatchActionsBar.tsx | 11 +++++- .../routes/ScriptList/ScriptTable.test.tsx | 39 +++++++++++++++---- .../options/routes/ScriptList/ScriptTable.tsx | 16 +------- .../routes/ScriptList/SelectionBar.tsx | 18 ++++++++- .../options/routes/ScriptList/TrashTable.tsx | 19 +++------ .../routes/ScriptList/TrashViews.test.tsx | 10 +++++ 7 files changed, 99 insertions(+), 36 deletions(-) diff --git a/src/pages/options/routes/ScriptList/BatchActionsBar.test.tsx b/src/pages/options/routes/ScriptList/BatchActionsBar.test.tsx index 68ea03eef..b12846a7c 100644 --- a/src/pages/options/routes/ScriptList/BatchActionsBar.test.tsx +++ b/src/pages/options/routes/ScriptList/BatchActionsBar.test.tsx @@ -28,6 +28,8 @@ const renderBar = (over: Partial> = onBatchPinTop={noop} onBatchCheckUpdate={noop} onClose={noop} + allSelected={false} + onToggleSelectAll={noop} {...over} /> ); @@ -83,3 +85,23 @@ describe("BatchActionsBar 批量删除二次确认", () => { expect(screen.queryByText(t("script:confirm_delete_scripts_trash_content", { count: 3 }))).not.toBeInTheDocument(); }); }); + +describe("BatchActionsBar 全选入口", () => { + it("栏内提供三态全选框:未全选时为半选,全选后为选中", () => { + renderBar({ allSelected: false }); + expect(screen.getByLabelText(t("script:select_all"))).toHaveAttribute("data-state", "indeterminate"); + + cleanup(); + renderBar({ allSelected: true }); + expect(screen.getByLabelText(t("script:select_all"))).toHaveAttribute("data-state", "checked"); + }); + + it("点击全选框调用 onToggleSelectAll", () => { + const onToggleSelectAll = vi.fn(); + renderBar({ onToggleSelectAll }); + + fireEvent.click(screen.getByLabelText(t("script:select_all"))); + + expect(onToggleSelectAll).toHaveBeenCalled(); + }); +}); diff --git a/src/pages/options/routes/ScriptList/BatchActionsBar.tsx b/src/pages/options/routes/ScriptList/BatchActionsBar.tsx index af5dad69e..7f9eb8d42 100644 --- a/src/pages/options/routes/ScriptList/BatchActionsBar.tsx +++ b/src/pages/options/routes/ScriptList/BatchActionsBar.tsx @@ -5,6 +5,8 @@ import SelectionBar, { SelectionBarButton } from "./SelectionBar"; export interface BatchActionsBarProps { selectedCount: number; + allSelected: boolean; + onToggleSelectAll: () => void; onBatchEnable: () => void; onBatchDisable: () => void; onBatchExport: () => void; @@ -16,6 +18,8 @@ export interface BatchActionsBarProps { export default function BatchActionsBar({ selectedCount, + allSelected, + onToggleSelectAll, onBatchEnable, onBatchDisable, onBatchExport, @@ -28,7 +32,12 @@ export default function BatchActionsBar({ const [trashEnabled] = useSystemConfig("trash_enabled"); return ( - + {t("enable")} diff --git a/src/pages/options/routes/ScriptList/ScriptTable.test.tsx b/src/pages/options/routes/ScriptList/ScriptTable.test.tsx index 30562c111..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 }) => ( + }> - {"行"} + // 开合受控于列表(同时只允许一行滑开),测试里用最小受控外壳承接 + 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"]')!; @@ -96,12 +106,30 @@ describe("左滑操作", () => { 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 { container } = render(); const actions = container.querySelector('[data-slot="mobile-swipe-actions"]') as HTMLElement; Object.defineProperty(actions, "offsetWidth", { configurable: true, value: 64 }); @@ -114,7 +142,7 @@ describe("左滑操作", () => { it("关闭态操作区被内容层盖住,不会漏在行右侧遮挡开关", () => { const { container } = render( - {"删除"}}> + {"删除"}}> {"行"} ); @@ -128,7 +156,7 @@ describe("左滑操作", () => { it("透传的触摸回调仍然被调用,长按与左滑可挂在同一行上", () => { const onTouchStart = vi.fn(); const { container } = render( - + {"行"} ); @@ -145,19 +173,45 @@ describe("长按", () => { const onLongPress = vi.fn(); const { result } = renderHook(() => useLongPress(onLongPress, 500)); - act(() => result.current.onTouchStart()); + 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()); + act(() => result.current.onTouchStart(touchEvent(100, 100))); act(() => void vi.advanceTimersByTime(200)); act(() => result.current.onTouchEnd()); act(() => void vi.advanceTimersByTime(500)); diff --git a/src/pages/components/ui/mobile-list.tsx b/src/pages/components/ui/mobile-list.tsx index bb626606c..405f4a1b6 100644 --- a/src/pages/components/ui/mobile-list.tsx +++ b/src/pages/components/ui/mobile-list.tsx @@ -59,13 +59,27 @@ function MobileListRowTrailing({ className, ...props }: React.ComponentProps<"di // 小于该位移的触摸当作点击,不当作滑动 const SWIPE_THRESHOLD = 40; +// 长按期间允许的手指抖动量,超过即认为是滑动/拖拽 +const LONG_PRESS_MOVE_TOLERANCE = 10; + export interface MobileSwipeRowProps extends React.ComponentProps<"div"> { /** 左滑后露出的操作块 */ actions: React.ReactNode; + /** 开合受控于列表:同一时刻只允许一行滑开,否则多行会同时挂着破坏性操作块 */ + open: boolean; + onOpenChange: (open: boolean) => void; } -function MobileSwipeRow({ actions, children, className, onTouchStart, onTouchEnd, ...props }: MobileSwipeRowProps) { - const [open, setOpen] = useState(false); +function MobileSwipeRow({ + actions, + open, + onOpenChange, + children, + className, + onTouchStart, + onTouchEnd, + ...props +}: MobileSwipeRowProps) { const [offset, setOffset] = useState(0); const startX = useRef(null); const actionsRef = useRef(null); @@ -84,9 +98,9 @@ function MobileSwipeRow({ actions, children, className, onTouchStart, onTouchEnd if (delta <= -SWIPE_THRESHOLD) { // 位移量取操作区实际宽度:各页操作块数量不同,写死距离会在少一块时滑出空白 setOffset(actionsRef.current?.offsetWidth ?? 0); - setOpen(true); + onOpenChange(true); } else if (delta >= SWIPE_THRESHOLD) { - setOpen(false); + onOpenChange(false); } } onTouchEnd?.(e); @@ -105,6 +119,9 @@ function MobileSwipeRow({ actions, children, className, onTouchStart, onTouchEnd data-slot="mobile-swipe-actions" data-state={open ? "open" : "closed"} aria-hidden={!open} + // inert 与 aria-hidden 必须同进退:pointer-events-none 只挡指针, + // 键盘仍能 Tab 进这棵不播报的子树并触发「删除」 + inert={!open} className={cn("absolute inset-y-0 right-0 flex items-stretch", !open && "pointer-events-none")} > {actions} @@ -115,6 +132,14 @@ function MobileSwipeRow({ actions, children, className, onTouchStart, onTouchEnd // 未滑动时操作块就会盖在行右侧(挡住开关) className="relative bg-background transition-transform" style={{ transform: open ? `translateX(-${offset}px)` : undefined }} + // 已滑开时点内容层收起而非透传为行点击:与移动端通行心智一致, + // 也避免紧挨着删除块的误触 + onClickCapture={(e) => { + if (!open) return; + e.preventDefault(); + e.stopPropagation(); + onOpenChange(false); + }} > {children}
@@ -125,25 +150,44 @@ function MobileSwipeRow({ actions, children, className, onTouchStart, onTouchEnd /** 长按手势。返回的处理器整体透传给行元素即可,与左滑外壳的触摸处理可以叠加。 */ function useLongPress(onLongPress: () => void, delay = 500) { const timer = useRef | null>(null); + const origin = useRef<{ x: number; y: number } | null>(null); const clear = useCallback(() => { if (timer.current !== null) { clearTimeout(timer.current); timer.current = null; } + origin.current = null; }, []); - const onTouchStart = useCallback(() => { - clear(); - timer.current = setTimeout(() => { - timer.current = null; - onLongPress(); - }, delay); - }, [clear, onLongPress, delay]); + const onTouchStart = useCallback( + (e: React.TouchEvent) => { + clear(); + const touch = e.touches[0]; + origin.current = touch ? { x: touch.clientX, y: touch.clientY } : null; + timer.current = setTimeout(() => { + timer.current = null; + onLongPress(); + }, delay); + }, + [clear, onLongPress, delay] + ); + + // 手指按住时的自然抖动有几像素,无条件取消会让长按在真机上频繁失败; + // 超过容差才认为用户是在滑动或拖拽,把手势让出去 + const onTouchMove = useCallback( + (e: React.TouchEvent) => { + const from = origin.current; + const touch = e.touches[0]; + if (!from || !touch) return; + if (Math.hypot(touch.clientX - from.x, touch.clientY - from.y) > LONG_PRESS_MOVE_TOLERANCE) clear(); + }, + [clear] + ); return useMemo( - () => ({ onTouchStart, onTouchEnd: clear, onTouchMove: clear, onTouchCancel: clear }), - [onTouchStart, clear] + () => ({ onTouchStart, onTouchMove, onTouchEnd: clear, onTouchCancel: clear }), + [onTouchStart, onTouchMove, clear] ); } diff --git a/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx b/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx index 3aa78836e..ccec6f977 100644 --- a/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx +++ b/src/pages/options/routes/ScriptList/ScriptRowsMobile.tsx @@ -50,6 +50,7 @@ import { 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, @@ -123,6 +124,8 @@ export default function ScriptRowsMobile({ 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"); @@ -132,6 +135,11 @@ export default function ScriptRowsMobile({ ); 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) => { @@ -172,12 +180,20 @@ export default function ScriptRowsMobile({ return (
- + {scriptList.map((script) => ( void; selectionMode: boolean; selected: boolean; onEnable: (script: ScriptLoading, checked: boolean) => void; @@ -247,6 +265,8 @@ interface ScriptRowMobileProps { const ScriptRowMobile = React.memo( ({ script, + swipeOpen, + onSwipeOpenChange, selectionMode, selected, onEnable, @@ -274,6 +294,8 @@ const ScriptRowMobile = React.memo( return ( onSwipeOpenChange(script.uuid, open)} {...(selectionMode ? {} : longPress)} actions={ <> @@ -336,16 +358,13 @@ const ScriptRowMobile = React.memo( ); }, + // 与桌面行同理(见 ScriptTable.tsx 的 ScriptRow):store 对任一字段变更都会为该行生成新对象引用, + // 逐字段比较会漏掉 name/metadata/selfMetadata 等,导致行显示过期的名称或版本。 (prev, next) => - prev.script.uuid === next.script.uuid && - prev.script.status === next.script.status && - prev.script.enableLoading === next.script.enableLoading && - prev.script.actionLoading === next.script.actionLoading && - prev.script.runStatus === next.script.runStatus && - prev.script.updatetime === next.script.updatetime && - prev.script.favorite === next.script.favorite && + prev.script === next.script && prev.selectionMode === next.selectionMode && - prev.selected === next.selected + prev.selected === next.selected && + prev.swipeOpen === next.swipeOpen ); ScriptRowMobile.displayName = "ScriptRowMobile"; diff --git a/src/pages/options/routes/ScriptList/TrashListMobile.tsx b/src/pages/options/routes/ScriptList/TrashListMobile.tsx index a0941c369..ca45a497b 100644 --- a/src/pages/options/routes/ScriptList/TrashListMobile.tsx +++ b/src/pages/options/routes/ScriptList/TrashListMobile.tsx @@ -11,6 +11,8 @@ 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, @@ -157,6 +159,18 @@ export default function TrashListMobile({ 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()); @@ -192,6 +206,9 @@ export default function TrashListMobile({ key={item.uuid} item={item} daysLeft={daysLeftOf(item)} + swipeOpen={swipeOpenUuid === item.uuid} + onSwipeOpenChange={handleSwipeOpenChange} + onOpenActions={setSheetUuid} selectionMode selected={selected.has(item.uuid)} onToggleSelect={toggleSelect} @@ -311,6 +328,9 @@ export default function TrashListMobile({ key={item.uuid} item={item} daysLeft={daysLeftOf(item)} + swipeOpen={swipeOpenUuid === item.uuid} + onSwipeOpenChange={handleSwipeOpenChange} + onOpenActions={setSheetUuid} selectionMode={false} selected={false} onToggleSelect={toggleSelect} @@ -321,6 +341,43 @@ export default function TrashListMobile({ )) )}
+ + {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")} + + + +
); } @@ -329,6 +386,9 @@ export default function TrashListMobile({ function TrashRowMobile({ item, daysLeft, + swipeOpen, + onSwipeOpenChange, + onOpenActions, selectionMode, selected, onToggleSelect, @@ -338,6 +398,9 @@ function TrashRowMobile({ }: { 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; @@ -351,6 +414,8 @@ function TrashRowMobile({ return ( onSwipeOpenChange(item.uuid, open)} {...(selectionMode ? {} : longPress)} actions={ <> @@ -387,7 +452,7 @@ function TrashRowMobile({ )} - selectionMode && onToggleSelect(item.uuid)}> + (selectionMode ? onToggleSelect(item.uuid) : onOpenActions(item.uuid))}> {item.name} diff --git a/src/pages/options/routes/ScriptList/TrashViews.test.tsx b/src/pages/options/routes/ScriptList/TrashViews.test.tsx index 0f1e1bf48..6a3e38ec2 100644 --- a/src/pages/options/routes/ScriptList/TrashViews.test.tsx +++ b/src/pages/options/routes/ScriptList/TrashViews.test.tsx @@ -320,6 +320,42 @@ describe("回收站桌面端列表化", () => { }); }); +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", diff --git a/src/pages/options/routes/ScriptList/components.tsx b/src/pages/options/routes/ScriptList/components.tsx index 7b4f01ad9..8431d0071 100644 --- a/src/pages/options/routes/ScriptList/components.tsx +++ b/src/pages/options/routes/ScriptList/components.tsx @@ -396,11 +396,13 @@ export function ScriptRowActionSlots({ return ( <> - {/* 宽窗口摊开全部操作;窄窗口它们会把脚本名挤没(#1698),故 1000px 以下换成下面的紧凑排布。 - min-w 取满配 7 个按钮的宽度:条件按钮的有无会改变本区宽度,进而把左侧的更新时间列推得逐行参差 */} + {/* 宽窗口摊开全部操作;窄窗口它们会把脚本名挤没(#1698),故窄于断点时换成下面的紧凑排布。 + min-w 取满配 7 个按钮的宽度:条件按钮的有无会改变本区宽度,进而把左侧的更新时间列推得逐行参差。 + 断点取 1160 而非 1000:摊开态比紧凑态多占 160px,实测 1000px 处名称会从 253px 直接掉到 150px, + 即「窗口拉宽反而更窄」;1160px 起摊开态才不会让名称比紧凑态更窄。 */} {home && ( openExternalUrl(home)}> @@ -429,7 +431,7 @@ export function ScriptRowActionSlots({ - + {/* 普通脚本没有运行按钮,留等宽占位让更多按钮在所有行对齐同一横坐标 */} {isBackground ? runButton : } diff --git a/src/pages/options/routes/SubscribeList/SubscribeRowsMobile.tsx b/src/pages/options/routes/SubscribeList/SubscribeRowsMobile.tsx index 2f5648dd3..f3149dd61 100644 --- a/src/pages/options/routes/SubscribeList/SubscribeRowsMobile.tsx +++ b/src/pages/options/routes/SubscribeList/SubscribeRowsMobile.tsx @@ -54,6 +54,13 @@ export default function SubscribeRowsMobile({ [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 (
@@ -78,6 +85,8 @@ export default function SubscribeRowsMobile({ void; onEnable: (subscribe: SubscribeLoading, checked: boolean) => void; onOpenActions: (url: string) => void; onDelete: (subscribe: SubscribeLoading) => void; } const SubscribeRowMobile = React.memo( - ({ subscribe, onEnable, onOpenActions, onDelete }: SubscribeRowMobileProps) => { + ({ subscribe, swipeOpen, onSwipeOpenChange, onEnable, onOpenActions, onDelete }: SubscribeRowMobileProps) => { const { t } = useTranslation(); return ( onSwipeOpenChange(subscribe.url, open)} actions={