From 8845c87ec3753b1547fd0c67c4ca293a71bd5f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 9 Oct 2026 17:17:09 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20=E6=89=B9=E9=87=8F=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=A1=B5=E7=9B=B8=E4=BC=BC=E5=BA=A6=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=90=91=E4=B8=8B=E5=8F=96=E6=95=B4=EF=BC=8C=E4=B8=8D=E8=B6=B3?= =?UTF-8?q?=201=20=E4=B8=8D=E5=86=8D=E6=98=BE=E7=A4=BA=20100%=20(#1778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先用 Math.round(similarity * 100) 展示,服务端存的三位小数分数 0.995~0.999 会被四舍五入成 100%,同一行却标着「轻微改动」,两个信号互相矛盾。 改为向下取整的百分比:只有分数恰好为 1 才显示 100%。乘 100 前加一个极小量, 抵消 0.29 * 100 = 28.999… 这类浮点误差,避免少算一档。风险分级阈值不变。 --- src/pages/batchupdate/components.tsx | 3 ++- src/pages/batchupdate/logic.test.ts | 20 +++++++++++++++++++- src/pages/batchupdate/logic.ts | 8 ++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/pages/batchupdate/components.tsx b/src/pages/batchupdate/components.tsx index 0b73f16ef..ae8a7557b 100644 --- a/src/pages/batchupdate/components.tsx +++ b/src/pages/batchupdate/components.tsx @@ -26,6 +26,7 @@ import { StateScreen } from "@App/pages/components/ui/state-screen"; import { DataPanel } from "@App/pages/components/ui/data-panel"; import { Tooltip, TooltipContent, TooltipTrigger } from "@App/pages/components/ui/tooltip"; import { Popconfirm } from "@App/pages/components/ui/popconfirm"; +import { formatSimilarity } from "./logic"; import type { BatchProgress, RowState, UpdateItem, UpdateRisk } from "./logic"; /** 批量更新视图(桌面/移动共用)所需的数据与回调 */ @@ -117,7 +118,7 @@ const RISK_KEY: Record = { export function RiskBadge({ risk, similarity }: { risk: UpdateRisk; similarity: number }) { const { t } = useTranslation(); return ( - + {t(`install:updatepage.${RISK_KEY[risk]}`)} ); diff --git a/src/pages/batchupdate/logic.test.ts b/src/pages/batchupdate/logic.test.ts index e6dde4018..0556028b0 100644 --- a/src/pages/batchupdate/logic.test.ts +++ b/src/pages/batchupdate/logic.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll } from "vitest"; import { initTestLanguage } from "@Tests/initTestLanguage"; import type { Script } from "@App/app/repo/scripts"; import type { TBatchUpdateRecord, TBatchUpdateRecordObject } from "@App/app/service/service_worker/types"; -import { riskLevel, getSource, toUpdateItem, categorize, assembleRecord } from "./logic"; +import { riskLevel, getSource, toUpdateItem, categorize, assembleRecord, formatSimilarity } from "./logic"; beforeAll(() => initTestLanguage("zh-CN")); @@ -210,6 +210,24 @@ describe("categorize 记录分组为更新/已忽略", () => { }); }); +describe("formatSimilarity 相似度展示", () => { + it("向下取整为百分比,不足 1 的分数永远不显示 100%", () => { + expect(formatSimilarity(0.999)).toBe("99%"); + expect(formatSimilarity(0.995)).toBe("99%"); + expect(formatSimilarity(0.95)).toBe("95%"); + }); + it("恰好为 1 时显示 100%", () => { + expect(formatSimilarity(1)).toBe("100%"); + }); + it("不被浮点误差少算一档(0.29 × 100 = 28.999…)", () => { + expect(formatSimilarity(0.29)).toBe("29%"); + expect(formatSimilarity(0.57)).toBe("57%"); + }); + it("0 显示为 0%", () => { + expect(formatSimilarity(0)).toBe("0%"); + }); +}); + describe("assembleRecord 拼接分片并解析", () => { it("按分片顺序拼接并解析 JSON", async () => { const obj: TBatchUpdateRecordObject = { checktime: 123, list: [mkRecord({ uuid: "a" })] }; diff --git a/src/pages/batchupdate/logic.ts b/src/pages/batchupdate/logic.ts index 06ccfe751..817789abe 100644 --- a/src/pages/batchupdate/logic.ts +++ b/src/pages/batchupdate/logic.ts @@ -71,6 +71,14 @@ export function riskLevel(similarity: number): UpdateRisk { return "tiny"; } +/** + * 相似度展示为向下取整的百分比:只有真正等于 1 才显示 100%,避免 0.999 被四舍五入成 100% 却标着「轻微改动」。 + * 加一个极小量是为了抵消浮点误差(0.29 * 100 = 28.999…),否则会少算一档。 + */ +export function formatSimilarity(similarity: number): string { + return `${Math.floor(similarity * 100 + 1e-9)}%`; +} + export function getSource(record: TBatchUpdateRecord): string { if (!record.checkUpdate) return ""; if (record.script.originDomain) return record.script.originDomain; From 8cf4c7e9e63f5dc09e5e313822f2ccce830841c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 9 Oct 2026 17:17:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=92=84=20=E6=89=B9=E9=87=8F=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E9=A1=B5=E6=8C=89=E5=BD=93=E5=89=8D=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E5=88=86=E6=AE=B5=EF=BC=8C=E6=A1=8C=E9=9D=A2=E6=94=B9=E4=B8=A4?= =?UTF-8?q?=E8=A1=8C=E8=A1=8C=E5=B8=83=E5=B1=80=EF=BC=8C=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E9=85=8D=E8=89=B2=E4=B8=8E=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E8=A7=A6=E6=8E=A7=E5=8C=BA=20(#1775)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从某个网站打开(?site=)且有命中时,列表分成「{site} 相关」与「其他更新」两段, 组头复选框只切换本段;顶部复选框显式标为「全选」,部分选中时半选。 无 site 或无命中时保持不分段。站点信息原本只用于排序、从未展示, 用户看不出前几行为何靠前,全选也会悄悄选中其它网站的更新。 - 桌面只保留「脚本 / 变更 / 操作」三列,版本与来源移到脚本名下的次要行: 原固定列合计约 776px,768px(桌面最窄)时脚本名完全没有空间,长版本号会压到相邻列。 长版本截断、悬停看全文;移动端触屏无悬停,版本单独一行折行完整展示。 - 风险标签:重大改动改用警示色(原危险红),显著改动改用中性色(原主色,与「更新」抢视线)。 - 移动端:顶栏按钮、卡片「更新 / 忽略」与复选框的可点区域扩到约 44px,外观与卡片高度不变; 批量进行中卡片复选框禁用,与桌面一致。 - 移除不再使用的 updatepage.col_version / col_source 文案,新增分段文案到全部语言包。 --- src/locales/de-DE/install.json | 5 +- src/locales/en-US/install.json | 5 +- src/locales/ja-JP/install.json | 5 +- src/locales/ko-KR/install.json | 5 +- src/locales/pt-BR/install.json | 5 +- src/locales/ru-RU/install.json | 5 +- src/locales/tr-TR/install.json | 5 +- src/locales/vi-VN/install.json | 5 +- src/locales/zh-CN/install.json | 5 +- src/locales/zh-TW/install.json | 5 +- src/pages/batchupdate/components.test.tsx | 99 +++++++++ src/pages/batchupdate/components.tsx | 245 ++++++++++++++++------ src/pages/batchupdate/hooks.test.ts | 24 +++ src/pages/batchupdate/hooks.ts | 23 +- src/pages/batchupdate/logic.test.ts | 64 +++++- src/pages/batchupdate/logic.ts | 29 +++ src/pages/batchupdate/mobile.tsx | 101 ++++++--- 17 files changed, 510 insertions(+), 125 deletions(-) diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index 81504780d..da6ed50e7 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -105,10 +105,11 @@ "restore_all_confirm": "{{count}} ignorierte Updates werden sofort installiert. Das lässt sich nicht rückgängig machen.", "restore_all_confirm_connect": "{{count}} davon fordern neue @connect-Berechtigungen an.", "ignored_section": "Ignorierte Updates", + "group_site": "Updates für {{site}}", + "group_site_desc": "Skripte, die auf dieser Website ausgeführt wurden", + "group_other": "Weitere Updates", "col_script": "Skript", - "col_version": "Version", "col_change": "Änderungen", - "col_source": "Quelle", "col_action": "Aktionen", "enabled": "Aktiviert", "disabled": "Deaktiviert", diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index 5a3fda45d..b964a0e73 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -105,10 +105,11 @@ "restore_all_confirm": "{{count}} ignored updates will be installed right away. This cannot be undone.", "restore_all_confirm_connect": "{{count}} of them request new @connect permissions.", "ignored_section": "Ignored Updates", + "group_site": "Updates for {{site}}", + "group_site_desc": "Scripts that have run on this site", + "group_other": "Other updates", "col_script": "Script", - "col_version": "Version", "col_change": "Changes", - "col_source": "Source", "col_action": "Actions", "enabled": "Enabled", "disabled": "Disabled", diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index 50ba190f0..fe238ecff 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -105,10 +105,11 @@ "restore_all_confirm": "無視した更新 {{count}} 件を今すぐインストールします。この操作は取り消せません。", "restore_all_confirm_connect": "そのうち {{count}} 件が新しい @connect 権限を要求します。", "ignored_section": "無視した更新", + "group_site": "{{site}} の更新", + "group_site_desc": "このサイトで実行されたスクリプト", + "group_other": "その他の更新", "col_script": "スクリプト", - "col_version": "バージョン", "col_change": "変更", - "col_source": "提供元", "col_action": "操作", "enabled": "有効", "disabled": "無効", diff --git a/src/locales/ko-KR/install.json b/src/locales/ko-KR/install.json index cd6dc2c29..da8c9b4c9 100644 --- a/src/locales/ko-KR/install.json +++ b/src/locales/ko-KR/install.json @@ -105,10 +105,11 @@ "restore_all_confirm": "무시한 업데이트 {{count}}개를 즉시 설치합니다. 되돌릴 수 없습니다.", "restore_all_confirm_connect": "그중 {{count}}개가 새로운 @connect 권한을 요청합니다.", "ignored_section": "무시된 업데이트", + "group_site": "{{site}} 관련 업데이트", + "group_site_desc": "이 사이트에서 실행된 스크립트", + "group_other": "기타 업데이트", "col_script": "스크립트", - "col_version": "버전", "col_change": "변경 사항", - "col_source": "출처", "col_action": "작업", "enabled": "활성화", "disabled": "비활성화", diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index 3c12fe1fc..88fa46d16 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -105,10 +105,11 @@ "restore_all_confirm": "{{count}} atualizações ignoradas serão instaladas agora. Não é possível desfazer.", "restore_all_confirm_connect": "{{count}} delas solicitam novas permissões @connect.", "ignored_section": "Atualizações ignoradas", + "group_site": "Atualizações para {{site}}", + "group_site_desc": "Scripts executados neste site", + "group_other": "Outras atualizações", "col_script": "Script", - "col_version": "Versão", "col_change": "Alterações", - "col_source": "Origem", "col_action": "Ações", "enabled": "Ativado", "disabled": "Desativado", diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index 034f63a5c..554c9d233 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -105,10 +105,11 @@ "restore_all_confirm": "Игнорируемые обновления ({{count}}) будут установлены сразу. Отменить это нельзя.", "restore_all_confirm_connect": "Из них {{count}} запрашивают новые разрешения @connect.", "ignored_section": "Игнорируемые обновления", + "group_site": "Обновления для {{site}}", + "group_site_desc": "Скрипты, которые запускались на этом сайте", + "group_other": "Другие обновления", "col_script": "Скрипт", - "col_version": "Версия", "col_change": "Изменения", - "col_source": "Источник", "col_action": "Действия", "enabled": "Включено", "disabled": "Выключено", diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index 473db1796..9f709facc 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -105,10 +105,11 @@ "restore_all_confirm": "Yok sayılan {{count}} güncelleme hemen kurulacak. Bu işlem geri alınamaz.", "restore_all_confirm_connect": "Bunlardan {{count}} tanesi yeni @connect izni istiyor.", "ignored_section": "Yoksayılan Güncellemeler", + "group_site": "{{site}} için güncellemeler", + "group_site_desc": "Bu sitede çalışmış betikler", + "group_other": "Diğer güncellemeler", "col_script": "Betik", - "col_version": "Sürüm", "col_change": "Değişiklikler", - "col_source": "Kaynak", "col_action": "İşlemler", "enabled": "Etkin", "disabled": "Devre Dışı", diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index c0cb5e3ef..566d27b8a 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -105,10 +105,11 @@ "restore_all_confirm": "{{count}} bản cập nhật đã bỏ qua sẽ được cài đặt ngay. Không thể hoàn tác.", "restore_all_confirm_connect": "Trong đó {{count}} tập lệnh yêu cầu quyền @connect mới.", "ignored_section": "Cập nhật đã bỏ qua", + "group_site": "Cập nhật cho {{site}}", + "group_site_desc": "Script đã chạy trên trang này", + "group_other": "Cập nhật khác", "col_script": "Tập lệnh", - "col_version": "Phiên bản", "col_change": "Thay đổi", - "col_source": "Nguồn", "col_action": "Thao tác", "enabled": "Đã bật", "disabled": "Đã tắt", diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index 7e8d3a0c9..b64fab433 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -105,10 +105,11 @@ "restore_all_confirm": "将立即安装 {{count}} 个已忽略的更新,安装后无法撤销。", "restore_all_confirm_connect": "其中 {{count}} 个脚本新增了 @connect 权限。", "ignored_section": "已忽略的更新", + "group_site": "{{site}} 相关", + "group_site_desc": "在此网站运行过的脚本", + "group_other": "其他更新", "col_script": "脚本", - "col_version": "版本", "col_change": "变更", - "col_source": "来源", "col_action": "操作", "enabled": "已启用", "disabled": "已停用", diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index bb60ef0d5..172cd680d 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -105,10 +105,11 @@ "restore_all_confirm": "將立即安裝 {{count}} 個已忽略的更新,安裝後無法撤銷。", "restore_all_confirm_connect": "其中 {{count}} 個腳本新增了 @connect 權限。", "ignored_section": "已忽略的更新", + "group_site": "{{site}} 相關", + "group_site_desc": "曾在此網站執行過的腳本", + "group_other": "其他更新", "col_script": "腳本", - "col_version": "版本", "col_change": "變更", - "col_source": "來源", "col_action": "操作", "enabled": "已啟用", "disabled": "已停用", diff --git a/src/pages/batchupdate/components.test.tsx b/src/pages/batchupdate/components.test.tsx index 7074b4b8d..4d0f00b29 100644 --- a/src/pages/batchupdate/components.test.tsx +++ b/src/pages/batchupdate/components.test.tsx @@ -29,6 +29,7 @@ function mkItem(p: Partial = {}): UpdateItem { function mkView(p: Partial = {}): BatchUpdateViewProps { return { + site: "", updates: [], ignored: [], totalChecked: 0, @@ -44,6 +45,7 @@ function mkView(p: Partial = {}): BatchUpdateViewProps { recordExpired: false, onToggle: () => {}, onToggleAll: () => {}, + onToggleGroup: () => {}, onUpdate: () => {}, onIgnore: () => {}, onRestore: () => {}, @@ -496,3 +498,100 @@ describe("批量更新 批量中断汇总", () => { expect(screen.getByTestId("batch-open-scripts")).toBeTruthy(); }); }); + +describe("批量更新 当前站点分段", () => { + const siteUpdates = () => [ + mkItem({ uuid: "s1", name: "本站脚本一", siteMatch: true }), + mkItem({ uuid: "s2", name: "本站脚本二", siteMatch: true }), + mkItem({ uuid: "o1", name: "其它脚本", siteMatch: false }), + ]; + + it("桌面带 site 且有命中时分成「本站相关」与「其它更新」两段", () => { + renderDesktop({ site: "example.com", updates: siteUpdates() }); + + const siteGroup = screen.getByTestId("update-group-site"); + expect(siteGroup).toHaveTextContent(t("install:updatepage.group_site", { site: "example.com" })); + expect(siteGroup).toHaveTextContent(t("install:updatepage.group_site_desc")); + expect(screen.getByTestId("update-group-other")).toHaveTextContent(t("install:updatepage.group_other")); + }); + + it("桌面组头复选框只切换本段,并以半选反映本段的部分选择", () => { + const onToggleGroup = vi.fn(); + renderDesktop({ site: "example.com", updates: siteUpdates(), selected: new Set(["s1"]), onToggleGroup }); + + const siteToggle = within(screen.getByTestId("update-group-site")).getByRole("checkbox"); + expect(siteToggle).toHaveAttribute("data-state", "indeterminate"); + expect(within(screen.getByTestId("update-group-other")).getByRole("checkbox")).toHaveAttribute( + "data-state", + "unchecked" + ); + + fireEvent.click(siteToggle); + expect(onToggleGroup).toHaveBeenCalledWith(["s1", "s2"]); + }); + + it("桌面顶部复选框标明「全选」,部分选择时为半选", () => { + renderDesktop({ site: "example.com", updates: siteUpdates(), selected: new Set(["s1"]) }); + + const selectAll = screen.getByRole("checkbox", { name: t("script:select_all") }); + expect(selectAll).toHaveAttribute("data-state", "indeterminate"); + }); + + it("没有命中项或没有 site 时不分段", () => { + renderDesktop({ site: "example.com", updates: [mkItem({ uuid: "o1" })] }); + expect(screen.queryByTestId("update-group-site")).toBeNull(); + expect(screen.queryByTestId("update-group-other")).toBeNull(); + cleanup(); + + renderDesktop({ site: "", updates: [mkItem({ uuid: "o1" })] }); + expect(screen.queryByTestId("update-group-site")).toBeNull(); + }); + + it("全部命中时只出现「本站相关」一段", () => { + renderDesktop({ site: "example.com", updates: [mkItem({ uuid: "s1", siteMatch: true })] }); + expect(screen.getByTestId("update-group-site")).toBeInTheDocument(); + expect(screen.queryByTestId("update-group-other")).toBeNull(); + }); + + it("移动端同样分段,组头复选框只切换本段", () => { + const onToggleGroup = vi.fn(); + renderMobile({ site: "example.com", updates: siteUpdates(), onToggleGroup }); + + expect(screen.getByTestId("update-group-site")).toHaveTextContent( + t("install:updatepage.group_site", { site: "example.com" }) + ); + fireEvent.click(within(screen.getByTestId("update-group-other")).getByRole("checkbox")); + expect(onToggleGroup).toHaveBeenCalledWith(["o1"]); + }); +}); + +describe("批量更新 风险标签配色", () => { + it("重大改动用警示色而不是危险红,显著改动用中性色", () => { + renderDesktop({ + updates: [ + mkItem({ uuid: "m", risk: "major", similarity: 0.5 }), + mkItem({ uuid: "n", risk: "noticeable", similarity: 0.9 }), + ], + }); + + const major = screen.getByText(t("install:updatepage.codechange_major")); + expect(major).toHaveClass("bg-warning-bg", "text-warning-fg"); + expect(major.className).not.toMatch(/destructive/); + const noticeable = screen.getByText(t("install:updatepage.codechange_noticeable")); + expect(noticeable).toHaveClass("bg-muted", "text-fg-secondary"); + expect(noticeable.className).not.toMatch(/primary/); + }); +}); + +describe("批量更新 移动端批量进行中", () => { + it("批量进行中卡片复选框与组头复选框一并禁用,和桌面一致", () => { + renderMobile({ + site: "example.com", + updates: [mkItem({ uuid: "s1", siteMatch: true }), mkItem({ uuid: "o1" })], + batchBusy: true, + batchProgress: { done: 0, total: 1, failed: 0, finished: false }, + }); + + for (const box of screen.getAllByRole("checkbox")) expect(box).toBeDisabled(); + }); +}); diff --git a/src/pages/batchupdate/components.tsx b/src/pages/batchupdate/components.tsx index ae8a7557b..45637496a 100644 --- a/src/pages/batchupdate/components.tsx +++ b/src/pages/batchupdate/components.tsx @@ -26,11 +26,13 @@ import { StateScreen } from "@App/pages/components/ui/state-screen"; import { DataPanel } from "@App/pages/components/ui/data-panel"; import { Tooltip, TooltipContent, TooltipTrigger } from "@App/pages/components/ui/tooltip"; import { Popconfirm } from "@App/pages/components/ui/popconfirm"; -import { formatSimilarity } from "./logic"; +import { formatSimilarity, groupCheckState, splitBySite } from "./logic"; import type { BatchProgress, RowState, UpdateItem, UpdateRisk } from "./logic"; /** 批量更新视图(桌面/移动共用)所需的数据与回调 */ export interface BatchUpdateViewProps { + /** 触发本页的当前网址(?site=),为空表示不是从某个网站打开的 */ + site: string; updates: UpdateItem[]; ignored: UpdateItem[]; /** 本次检查覆盖的脚本总数(用于空状态文案) */ @@ -53,6 +55,8 @@ export interface BatchUpdateViewProps { recordExpired: boolean; onToggle: (uuid: string) => void; onToggleAll: () => void; + /** 切换一组(如「本站相关」)的选择,不影响组外已选项 */ + onToggleGroup: (uuids: string[]) => void; onUpdate: (item: UpdateItem) => void; onIgnore: (item: UpdateItem) => void; onRestore: (item: UpdateItem) => void; @@ -104,9 +108,10 @@ export function ScriptAvatar({ name, iconUrl, size = 28 }: { name: string; iconU const PILL = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap"; +// 重大改动是「需要留意」而不是「危险」,用警示色而非危险红;显著改动用中性色,不和主色的「更新」抢视线 const RISK_CLASS: Record = { - major: "bg-destructive/10 text-destructive", - noticeable: "bg-primary/10 text-primary", + major: "bg-warning-bg text-warning-fg", + noticeable: "bg-muted text-fg-secondary", tiny: "bg-success-bg text-success-fg", }; const RISK_KEY: Record = { @@ -149,14 +154,38 @@ export function StatusBadge({ enabled }: { enabled: boolean }) { ); } -export function VersionDiff({ oldVersion, newVersion }: { oldVersion: string; newVersion: string }) { - return ( -
- {`v${oldVersion}`} - - {`v${newVersion}`} +/** + * 新旧版本对比。桌面放在脚本名下的次要行里,过长时各自截断、悬停看全文; + * 移动端触屏没有悬停,wrap 时折行展示完整版本号。 + */ +export function VersionDiff({ + oldVersion, + newVersion, + wrap, + className, +}: { + oldVersion: string; + newVersion: string; + wrap?: boolean; + className?: string; +}) { + const diff = ( +
+ {`v${oldVersion}`} + + + {`v${newVersion}`} +
); + if (wrap) return diff; + return {diff}; } export function SourceCell({ source }: { source: string }) { @@ -199,7 +228,7 @@ export function ScriptName({ aria-busy={loading} data-testid={`script-name-${uuid}`} className={cn( - "flex min-w-0 flex-1 items-center gap-1.5 text-left text-sm font-medium text-foreground hover:text-primary hover:underline", + "flex min-w-0 items-center gap-1.5 text-left text-sm font-medium text-foreground hover:text-primary hover:underline", loading && "cursor-progress" )} > @@ -212,6 +241,50 @@ export function ScriptName({ ); } +/** 复选框的扩展点击区:方框不变,伪元素向外撑到约 44px */ +export const CHECKBOX_HIT = "relative after:absolute after:-inset-3.5 after:content-['']"; + +/** 分段组头(「{site} 相关」/「其他更新」):复选框只切换本段,桌面与移动共用 */ +export function UpdateGroupHeader({ + kind, + items, + view, + className, +}: { + kind: "site" | "other"; + items: UpdateItem[]; + view: BatchUpdateViewProps; + className?: string; +}) { + const { t } = useTranslation(); + const uuids = items.map((item) => item.uuid); + const title = + kind === "site" ? t("install:updatepage.group_site", { site: view.site }) : t("install:updatepage.group_other"); + return ( +
+
+ view.onToggleGroup(uuids)} + className={CHECKBOX_HIT} + /> +
+ + {kind === "site" && } + {title} + + + {items.length} + + {kind === "site" && ( + {t("install:updatepage.group_site_desc")} + )} +
+ ); +} + /** 行内文字按钮(更新 / 忽略 / 恢复) */ function LinkAction({ label, onClick, muted }: { label: string; onClick: () => void; muted?: boolean }) { return ( @@ -461,10 +534,12 @@ export function showSkeleton(view: BatchUpdateViewProps, empty: boolean): boolea return view.loading || (view.checking && empty && view.checktime === 0); } +/** + * 桌面只保留「脚本 / 变更 / 操作」三列,版本与来源放进脚本名下的次要行: + * 固定列只剩约 440px,768px(桌面最窄)时脚本名仍有可读宽度,不必再加断点。 + */ const COL = { - version: "w-[170px] shrink-0", - change: "w-[230px] shrink-0", - source: "w-[160px] shrink-0", + change: "w-[220px] shrink-0 pl-2", // 需容纳最宽的行内状态(「更新失败 · 重试」/ 各语言的「已更新 vX.Y.Z」),不能按初始态的两个按钮取宽 action: "w-[180px] shrink-0", }; @@ -503,7 +578,7 @@ function DesktopRow({ return (
@@ -516,19 +591,23 @@ function DesktopRow({
- onOpen(item.uuid)} /> - -
-
- +
+
+ onOpen(item.uuid)} /> + +
+
+ {/* 版本优先保留,来源先被截断 */} + + {"·"} + +
+
{item.withNewConnect && }
-
- -
{ignoredRow ? ( @@ -547,32 +626,54 @@ function DesktopRow({ ); } -function DesktopTable({ view }: { view: BatchUpdateViewProps }) { +function DesktopTableHeader() { const { t } = useTranslation(); + return ( +
+
+
{t("install:updatepage.col_script")}
+
{t("install:updatepage.col_change")}
+
{t("install:updatepage.col_action")}
+
+ ); +} + +const DESKTOP_GROUP_HEAD = "h-11 px-4 border-b border-border bg-muted/40"; + +function DesktopTable({ view }: { view: BatchUpdateViewProps }) { + const groups = splitBySite(view.updates); + const rows = (items: UpdateItem[]) => + items.map((item) => ( + + )); return ( -
-
-
{t("install:updatepage.col_script")}
-
{t("install:updatepage.col_version")}
-
{t("install:updatepage.col_change")}
-
{t("install:updatepage.col_source")}
-
{t("install:updatepage.col_action")}
-
- {view.updates.map((item) => ( - - ))} + + {groups ? ( + <> + + {rows(groups.site)} + {groups.other.length > 0 && ( + <> + + {rows(groups.other)} + + )} + + ) : ( + rows(view.updates) + )} ); } @@ -611,16 +712,41 @@ function DesktopIgnored({ view }: { view: BatchUpdateViewProps }) { ); } +/** + * 顶部全选:有分段时它的作用范围(全部待更新)不再不言自明,显式写出「全选」,与组头复选框区分。 + */ +export function SelectAllLabel({ view }: { view: BatchUpdateViewProps }) { + const { t } = useTranslation(); + const selectedCount = view.updates.filter((u) => view.selected.has(u.uuid)).length; + const count = t("install:updatepage.selected_count", { selected: selectedCount, total: view.updates.length }); + return <>{splitBySite(view.updates) ? `${t("script:select_all")} · ${count}` : count}; +} + +export function SelectAllCheckbox({ view, className }: { view: BatchUpdateViewProps; className?: string }) { + const { t } = useTranslation(); + return ( + u.uuid), + view.selected + )} + disabled={view.batchBusy} + onCheckedChange={view.onToggleAll} + className={className} + /> + ); +} + function DesktopToolbar({ view }: { view: BatchUpdateViewProps }) { const { t } = useTranslation(); const selectedCount = view.updates.filter((u) => view.selected.has(u.uuid)).length; - const allSelected = view.updates.length > 0 && selectedCount === view.updates.length; return (
- + - {t("install:updatepage.selected_count", { selected: selectedCount, total: view.updates.length })} + {view.ignored.length > 0 && ( <> @@ -690,35 +816,24 @@ function SkeletonTable() { } function SkeletonRows() { - const { t } = useTranslation(); return ( -
-
-
{t("install:updatepage.col_script")}
-
{t("install:updatepage.col_version")}
-
{t("install:updatepage.col_change")}
-
{t("install:updatepage.col_source")}
-
{t("install:updatepage.col_action")}
-
+ {Array.from({ length: 4 }).map((_, i) => ( -
+
- -
-
- +
+ + +
-
- -
diff --git a/src/pages/batchupdate/hooks.test.ts b/src/pages/batchupdate/hooks.test.ts index 81a3ef00c..afcdaf97b 100644 --- a/src/pages/batchupdate/hooks.test.ts +++ b/src/pages/batchupdate/hooks.test.ts @@ -178,6 +178,30 @@ describe("批量更新 Hook useBatchUpdate 站点优先级(?site=)", () => { await waitFor(() => expect(result.current.updates).toHaveLength(2)); expect(result.current.updates.map((u) => u.uuid)).toEqual(["a", "b"]); expect(result.current.updates.every((u) => u.siteMatch === false)).toBe(true); + expect(result.current.site).toBe(""); + }); + + it("把 site 交给视图,组头切换只增删本组的选择", async () => { + window.history.replaceState({}, "", "/?site=example.com"); + + const { result } = renderHook(() => useBatchUpdate()); + await act(async () => {}); + await runCheck([ + mkRecord("a", "1.1.0", ["example.com"]), + mkRecord("b", "1.1.0", ["example.com"]), + mkRecord("c", "1.1.0", ["other.com"]), + ]); + await waitFor(() => expect(result.current.updates).toHaveLength(3)); + expect(result.current.site).toBe("example.com"); + + act(() => result.current.onToggle("c")); + act(() => result.current.onToggleGroup(["a", "b"])); + expect([...result.current.selected].sort()).toEqual(["a", "b", "c"]); + + act(() => result.current.onToggleGroup(["a", "b"])); + expect([...result.current.selected]).toEqual(["c"]); + + window.history.replaceState({}, "", "/"); }); }); diff --git a/src/pages/batchupdate/hooks.ts b/src/pages/batchupdate/hooks.ts index dbfa0aa98..217b29d60 100644 --- a/src/pages/batchupdate/hooks.ts +++ b/src/pages/batchupdate/hooks.ts @@ -11,7 +11,15 @@ import { scriptClient, } from "@App/pages/store/features/script"; import { subscribeMessage } from "@App/pages/store/global"; -import { assembleRecord, categorize, isRowInFlight, type BatchProgress, type RowState, type UpdateItem } from "./logic"; +import { + assembleRecord, + categorize, + isRowInFlight, + toggleGroup, + type BatchProgress, + type RowState, + type UpdateItem, +} from "./logic"; import type { BatchUpdateViewProps } from "./components"; /** 服务端 onScriptUpdateCheck 广播的消息体 */ @@ -447,12 +455,11 @@ export function useBatchUpdate(): BatchUpdateViewProps { }); }, []); - const onToggleAll = useCallback(() => { - setSelected((prev) => { - if (updates.length > 0 && updates.every((u) => prev.has(u.uuid))) return new Set(); - return new Set(updates.map((u) => u.uuid)); - }); - }, [updates]); + const onToggleGroup = useCallback((uuids: string[]) => { + setSelected((prev) => toggleGroup(prev, uuids)); + }, []); + + const onToggleAll = useCallback(() => onToggleGroup(updates.map((u) => u.uuid)), [updates, onToggleGroup]); /** * 打开更新详情:服务端要先备好待安装代码才会开出安装页,这段等待期间必须挡住重复点击, @@ -494,6 +501,7 @@ export function useBatchUpdate(): BatchUpdateViewProps { }, []); return { + site, updates, ignored, totalChecked: records.length, @@ -510,6 +518,7 @@ export function useBatchUpdate(): BatchUpdateViewProps { recordExpired, onToggle, onToggleAll, + onToggleGroup, onUpdate, onIgnore, onRestore: onUpdate, diff --git a/src/pages/batchupdate/logic.test.ts b/src/pages/batchupdate/logic.test.ts index 0556028b0..480a61bdb 100644 --- a/src/pages/batchupdate/logic.test.ts +++ b/src/pages/batchupdate/logic.test.ts @@ -2,7 +2,17 @@ import { describe, it, expect, beforeAll } from "vitest"; import { initTestLanguage } from "@Tests/initTestLanguage"; import type { Script } from "@App/app/repo/scripts"; import type { TBatchUpdateRecord, TBatchUpdateRecordObject } from "@App/app/service/service_worker/types"; -import { riskLevel, getSource, toUpdateItem, categorize, assembleRecord, formatSimilarity } from "./logic"; +import { + riskLevel, + getSource, + toUpdateItem, + categorize, + assembleRecord, + formatSimilarity, + splitBySite, + toggleGroup, + groupCheckState, +} from "./logic"; beforeAll(() => initTestLanguage("zh-CN")); @@ -228,6 +238,58 @@ describe("formatSimilarity 相似度展示", () => { }); }); +describe("splitBySite 按当前站点拆成两段", () => { + it("有命中项时拆成「本站」与「其它」两段,各自保持原有顺序", () => { + const { updates } = categorize( + [ + mkRecord({ uuid: "a", sites: ["other.com"] }), + mkRecord({ uuid: "b", sites: ["example.com"] }), + mkRecord({ uuid: "c", sites: [] }), + mkRecord({ uuid: "d", sites: ["example.com"] }), + ], + "example.com" + ); + const groups = splitBySite(updates); + expect(groups?.site.map((u) => u.uuid)).toEqual(["b", "d"]); + expect(groups?.other.map((u) => u.uuid)).toEqual(["a", "c"]); + }); + it("全部命中时「其它」为空", () => { + const { updates } = categorize([mkRecord({ uuid: "a", sites: ["example.com"] })], "example.com"); + expect(splitBySite(updates)).toEqual({ site: updates, other: [] }); + }); + it("没有任何命中(含未传 site)时不分段", () => { + const records = [mkRecord({ uuid: "a", sites: ["other.com"] })]; + expect(splitBySite(categorize(records, "example.com").updates)).toBeNull(); + expect(splitBySite(categorize(records).updates)).toBeNull(); + }); +}); + +describe("toggleGroup 分组选择切换", () => { + it("组内未全选时补选整组,保留组外已选项", () => { + const next = toggleGroup(new Set(["x", "a"]), ["a", "b"]); + expect([...next].sort()).toEqual(["a", "b", "x"]); + }); + it("组内已全选时只取消本组,组外已选项不受影响", () => { + const next = toggleGroup(new Set(["x", "a", "b"]), ["a", "b"]); + expect([...next]).toEqual(["x"]); + }); + it("空组不改变选择", () => { + const prev = new Set(["x"]); + expect(toggleGroup(prev, [])).toBe(prev); + }); +}); + +describe("groupCheckState 分组复选框状态", () => { + it("组内全选为 true、部分选为 indeterminate、一个没选为 false", () => { + expect(groupCheckState(["a", "b"], new Set(["a", "b", "x"]))).toBe(true); + expect(groupCheckState(["a", "b"], new Set(["a"]))).toBe("indeterminate"); + expect(groupCheckState(["a", "b"], new Set(["x"]))).toBe(false); + }); + it("空组视为未选", () => { + expect(groupCheckState([], new Set(["x"]))).toBe(false); + }); +}); + describe("assembleRecord 拼接分片并解析", () => { it("按分片顺序拼接并解析 JSON", async () => { const obj: TBatchUpdateRecordObject = { checktime: 123, list: [mkRecord({ uuid: "a" })] }; diff --git a/src/pages/batchupdate/logic.ts b/src/pages/batchupdate/logic.ts index 817789abe..5f8dd47e5 100644 --- a/src/pages/batchupdate/logic.ts +++ b/src/pages/batchupdate/logic.ts @@ -146,6 +146,35 @@ export function categorize( return { updates, ignored }; } +/** + * 把待更新项拆成「命中当前站点」与「其它」两段(各自保持 categorize 给出的顺序)。 + * 没有任何命中(包括没有 site 参数)时返回 null,页面保持不分段的单一列表。 + */ +export function splitBySite(updates: UpdateItem[]): { site: UpdateItem[]; other: UpdateItem[] } | null { + const site = updates.filter((u) => u.siteMatch); + if (site.length === 0) return null; + return { site, other: updates.filter((u) => !u.siteMatch) }; +} + +/** 切换一组的选择:组内已全选则只取消本组,否则补选整组;组外的已选项始终保留 */ +export function toggleGroup(selected: Set, uuids: string[]): Set { + if (uuids.length === 0) return selected; + const next = new Set(selected); + if (uuids.every((uuid) => selected.has(uuid))) { + for (const uuid of uuids) next.delete(uuid); + } else { + for (const uuid of uuids) next.add(uuid); + } + return next; +} + +/** 一组复选框的勾选态:全选 true、部分选 indeterminate、未选(含空组)false */ +export function groupCheckState(uuids: string[], selected: Set): boolean | "indeterminate" { + const count = uuids.filter((uuid) => selected.has(uuid)).length; + if (count === 0) return false; + return count === uuids.length ? true : "indeterminate"; +} + export async function assembleRecord( fetchChunk: (index: number) => Promise<{ chunk: string; ended: boolean }> ): Promise { diff --git a/src/pages/batchupdate/mobile.tsx b/src/pages/batchupdate/mobile.tsx index 6ac7e42d5..bb25facf7 100644 --- a/src/pages/batchupdate/mobile.tsx +++ b/src/pages/batchupdate/mobile.tsx @@ -7,9 +7,10 @@ import { Button } from "@App/pages/components/ui/button"; import { Checkbox } from "@App/pages/components/ui/checkbox"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@App/pages/components/ui/collapsible"; import { Surface } from "@App/pages/components/ui/surface"; -import type { RowState, UpdateItem } from "./logic"; +import { splitBySite, type RowState, type UpdateItem } from "./logic"; import { BatchSummary, + CHECKBOX_HIT, ConnectBadge, EmptyState, LoadErrorScreen, @@ -19,6 +20,8 @@ import { RowStatus, RowWorkingBar, rowPhaseClass, + SelectAllCheckbox, + SelectAllLabel, ScriptAvatar, ScriptName, showSkeleton, @@ -26,6 +29,7 @@ import { SourceCell, StatusBadge, TopProgressBar, + UpdateGroupHeader, VersionDiff, type BatchUpdateViewProps, } from "./components"; @@ -88,12 +92,19 @@ function SkeletonActionBar() { ); } +/** + * 卡片内文字按钮的触控区:撑到 44px 高、左右各 12px,再用负外边距抵消纵向占位, + * 外观和卡片高度都不变;两个按钮的触控区以分隔线为界,不会互相覆盖。 + */ +const TAP = "-my-3 flex h-11 items-center px-3"; + /** 移动端单卡(待更新或已忽略) */ function MobileCard({ item, state, selected, opening, + batchBusy, onToggle, onOpen, onUpdate, @@ -105,6 +116,7 @@ function MobileCard({ state?: RowState; selected?: boolean; opening?: boolean; + batchBusy?: boolean; onToggle?: (uuid: string) => void; onOpen: (uuid: string) => void; onUpdate?: (item: UpdateItem) => void; @@ -125,7 +137,12 @@ function MobileCard({ {ignoredCard ? ( ) : ( - onToggle?.(item.uuid)} /> + onToggle?.(item.uuid)} + className={CHECKBOX_HIT} + /> )} @@ -133,16 +150,15 @@ function MobileCard({
-
- -
-
+
+ +
{item.withNewConnect && }
- +
@@ -151,7 +167,11 @@ function MobileCard({ @@ -169,7 +189,7 @@ function MobileCard({ @@ -224,7 +244,22 @@ function MobileIgnored({ view }: { view: BatchUpdateViewProps }) { export function MobileView({ view }: { view: BatchUpdateViewProps }) { const { t } = useTranslation(); const selectedCount = view.updates.filter((u) => view.selected.has(u.uuid)).length; - const allSelected = view.updates.length > 0 && selectedCount === view.updates.length; + const groups = splitBySite(view.updates); + const cards = (items: UpdateItem[]) => + items.map((item) => ( + + )); const empty = view.updates.length === 0 && view.ignored.length === 0; const skeleton = view.loadError === null && showSkeleton(view, empty); @@ -241,26 +276,27 @@ export function MobileView({ view }: { view: BatchUpdateViewProps }) { return (
-
- +
+
{t("install:updatepage.title")} {subtitle && {subtitle}}