diff --git a/src/locales/de-DE/logs.json b/src/locales/de-DE/logs.json index fb5aee886..d2129a946 100644 --- a/src/locales/de-DE/logs.json +++ b/src/locales/de-DE/logs.json @@ -55,5 +55,14 @@ "total_logs": "Insgesamt {{length}} Protokolle gefunden", "filtered_logs": "Nach Filterung {{length}} Protokolle", "enter_filter_conditions": "Bitte geben Sie Filterbedingungen für die Abfrage ein", - "custom_range": "Benutzerdefiniert" + "custom_range": "Benutzerdefiniert", + "copy_log_line": "Diesen Log-Eintrag kopieren", + "toggle_log_detail": "Details ein-/ausblenden", + "export_scope": "{{count}} gefilterte Logs exportieren", + "export_copy_text": "Als Text kopieren", + "export_download_log": ".log-Datei herunterladen", + "export_download_json": ".json-Datei herunterladen", + "copied_logs": "Kopierte Logs: {{count}}", + "copy_failed": "Kopieren fehlgeschlagen", + "export_failed": "Export fehlgeschlagen" } diff --git a/src/locales/en-US/logs.json b/src/locales/en-US/logs.json index fb258f6a9..d58a94d58 100644 --- a/src/locales/en-US/logs.json +++ b/src/locales/en-US/logs.json @@ -55,5 +55,14 @@ "total_logs": "A total of {{length}} logs were queried", "filtered_logs": "{{length}} logs after filtering", "enter_filter_conditions": "Please Enter Filter Conditions for Query", - "custom_range": "Custom Range" + "custom_range": "Custom Range", + "copy_log_line": "Copy this log", + "toggle_log_detail": "Toggle details", + "export_scope": "Export {{count}} filtered logs", + "export_copy_text": "Copy as text", + "export_download_log": "Download .log file", + "export_download_json": "Download .json file", + "copied_logs": "Logs copied: {{count}}", + "copy_failed": "Copy failed", + "export_failed": "Export failed" } diff --git a/src/locales/ja-JP/logs.json b/src/locales/ja-JP/logs.json index 8bcee18ce..81549054e 100644 --- a/src/locales/ja-JP/logs.json +++ b/src/locales/ja-JP/logs.json @@ -55,5 +55,14 @@ "total_logs": "合計{{length}}件のログが見つかりました", "filtered_logs": "フィルタリング後{{length}}件のログ", "enter_filter_conditions": "フィルタリング条件を入力してクエリしてください", - "custom_range": "カスタム範囲" + "custom_range": "カスタム範囲", + "copy_log_line": "このログをコピー", + "toggle_log_detail": "詳細の表示/非表示", + "export_scope": "現在の絞り込み結果をエクスポート · {{count}} 件", + "export_copy_text": "テキストとしてコピー", + "export_download_log": ".log ファイルをダウンロード", + "export_download_json": ".json ファイルをダウンロード", + "copied_logs": "{{count}} 件のログをコピーしました", + "copy_failed": "コピーに失敗しました", + "export_failed": "エクスポートに失敗しました" } diff --git a/src/locales/ko-KR/logs.json b/src/locales/ko-KR/logs.json index dc771e31a..53095be6a 100644 --- a/src/locales/ko-KR/logs.json +++ b/src/locales/ko-KR/logs.json @@ -55,5 +55,14 @@ "total_logs": "총 {{length}}개의 로그가 조회되었습니다", "filtered_logs": "필터링 후 {{length}}개의 로그", "enter_filter_conditions": "조회할 필터 조건을 입력하세요", - "custom_range": "사용자 지정 범위" + "custom_range": "사용자 지정 범위", + "copy_log_line": "이 로그 복사", + "toggle_log_detail": "상세 정보 펼치기/접기", + "export_scope": "현재 필터 결과 내보내기 · {{count}}개", + "export_copy_text": "텍스트로 복사", + "export_download_log": ".log 파일 다운로드", + "export_download_json": ".json 파일 다운로드", + "copied_logs": "로그 {{count}}개를 복사했습니다", + "copy_failed": "복사 실패", + "export_failed": "내보내기 실패" } diff --git a/src/locales/pt-BR/logs.json b/src/locales/pt-BR/logs.json index 135264ae0..b4a0750bf 100644 --- a/src/locales/pt-BR/logs.json +++ b/src/locales/pt-BR/logs.json @@ -55,5 +55,14 @@ "total_logs": "Foram encontrados {{length}} logs no total", "filtered_logs": "{{length}} logs após a filtragem", "enter_filter_conditions": "Insira as condições de filtro para a consulta", - "custom_range": "Intervalo personalizado" + "custom_range": "Intervalo personalizado", + "copy_log_line": "Copiar este log", + "toggle_log_detail": "Mostrar/ocultar detalhes", + "export_scope": "Exportar {{count}} logs filtrados", + "export_copy_text": "Copiar como texto", + "export_download_log": "Baixar arquivo .log", + "export_download_json": "Baixar arquivo .json", + "copied_logs": "Logs copiados: {{count}}", + "copy_failed": "Falha ao copiar", + "export_failed": "Falha ao exportar" } diff --git a/src/locales/ru-RU/logs.json b/src/locales/ru-RU/logs.json index 88192af78..18ff480b8 100644 --- a/src/locales/ru-RU/logs.json +++ b/src/locales/ru-RU/logs.json @@ -55,5 +55,14 @@ "total_logs": "Найдено {{length}} записей журнала", "filtered_logs": "После фильтрации {{length}} записей журнала", "enter_filter_conditions": "Введите условия фильтрации для поиска", - "custom_range": "Произвольный" + "custom_range": "Произвольный", + "copy_log_line": "Копировать эту запись", + "toggle_log_detail": "Показать/скрыть подробности", + "export_scope": "Экспорт отфильтрованных логов · {{count}}", + "export_copy_text": "Копировать как текст", + "export_download_log": "Скачать файл .log", + "export_download_json": "Скачать файл .json", + "copied_logs": "Скопировано логов: {{count}}", + "copy_failed": "Не удалось скопировать", + "export_failed": "Не удалось экспортировать" } diff --git a/src/locales/tr-TR/logs.json b/src/locales/tr-TR/logs.json index 9fc5b627c..82f27dd3c 100644 --- a/src/locales/tr-TR/logs.json +++ b/src/locales/tr-TR/logs.json @@ -55,5 +55,14 @@ "total_logs": "Toplam {{length}} günlük sorgulandı", "filtered_logs": "Filtrelemeden sonra {{length}} günlük", "enter_filter_conditions": "Lütfen sorgu için filtre koşullarını girin", - "custom_range": "Özel Aralık" + "custom_range": "Özel Aralık", + "copy_log_line": "Bu günlüğü kopyala", + "toggle_log_detail": "Ayrıntıları göster/gizle", + "export_scope": "Filtrelenen {{count}} günlüğü dışa aktar", + "export_copy_text": "Metin olarak kopyala", + "export_download_log": ".log dosyasını indir", + "export_download_json": ".json dosyasını indir", + "copied_logs": "Kopyalanan günlük: {{count}}", + "copy_failed": "Kopyalama başarısız", + "export_failed": "Dışa aktarma başarısız" } diff --git a/src/locales/vi-VN/logs.json b/src/locales/vi-VN/logs.json index 9e9734a02..2a0276fc9 100644 --- a/src/locales/vi-VN/logs.json +++ b/src/locales/vi-VN/logs.json @@ -55,5 +55,14 @@ "total_logs": "Tổng cộng {{length}} nhật ký đã được truy vấn", "filtered_logs": "{{length}} nhật ký sau khi lọc", "enter_filter_conditions": "Vui lòng nhập điều kiện lọc để truy vấn", - "custom_range": "Tùy chỉnh" + "custom_range": "Tùy chỉnh", + "copy_log_line": "Sao chép nhật ký này", + "toggle_log_detail": "Mở rộng/thu gọn chi tiết", + "export_scope": "Xuất {{count}} nhật ký đã lọc", + "export_copy_text": "Sao chép dạng văn bản", + "export_download_log": "Tải xuống tệp .log", + "export_download_json": "Tải xuống tệp .json", + "copied_logs": "Đã sao chép {{count}} nhật ký", + "copy_failed": "Sao chép thất bại", + "export_failed": "Xuất thất bại" } diff --git a/src/locales/zh-CN/logs.json b/src/locales/zh-CN/logs.json index 0371611a5..e75b0bb52 100644 --- a/src/locales/zh-CN/logs.json +++ b/src/locales/zh-CN/logs.json @@ -55,5 +55,14 @@ "total_logs": "共查询到 {{length}} 条日志", "filtered_logs": "筛选后 {{length}} 条日志", "enter_filter_conditions": "请输入筛选条件进行查询", - "custom_range": "自定义范围" + "custom_range": "自定义范围", + "copy_log_line": "复制此条日志", + "toggle_log_detail": "展开/收起详情", + "export_scope": "导出当前筛选结果 · {{count}} 条", + "export_copy_text": "复制为文本", + "export_download_log": "下载 .log 文件", + "export_download_json": "下载 .json 文件", + "copied_logs": "已复制 {{count}} 条日志", + "copy_failed": "复制失败", + "export_failed": "导出失败" } diff --git a/src/locales/zh-TW/logs.json b/src/locales/zh-TW/logs.json index 51ad990d3..53f1ce856 100644 --- a/src/locales/zh-TW/logs.json +++ b/src/locales/zh-TW/logs.json @@ -55,5 +55,14 @@ "total_logs": "共查詢到 {{length}} 筆紀錄", "filtered_logs": "篩選後 {{length}} 條紀錄", "enter_filter_conditions": "請輸入篩選條件進行查詢", - "custom_range": "自訂範圍" + "custom_range": "自訂範圍", + "copy_log_line": "複製此筆日誌", + "toggle_log_detail": "展開/收合詳情", + "export_scope": "匯出目前篩選結果 · {{count}} 筆", + "export_copy_text": "複製為文字", + "export_download_log": "下載 .log 檔案", + "export_download_json": "下載 .json 檔案", + "copied_logs": "已複製 {{count}} 筆日誌", + "copy_failed": "複製失敗", + "export_failed": "匯出失敗" } diff --git a/src/pages/options/routes/Logger/LoggerMobile.test.tsx b/src/pages/options/routes/Logger/LoggerMobile.test.tsx deleted file mode 100644 index 6dc7661f0..000000000 --- a/src/pages/options/routes/Logger/LoggerMobile.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; -import { render, cleanup, screen, fireEvent } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { t } from "@App/locales/locales"; -import { initTestLanguage } from "@Tests/initTestLanguage"; -import { useIsMobile } from "@App/pages/components/use-is-mobile"; -import type { Logger as LoggerEntry } from "@App/app/repo/logger"; - -// 移动端重壳:整页在 useIsMobile=true 时切换为竖向堆叠布局(图标按钮 + 可横向滚动级别 chip + 消息换行)。 -vi.mock("@App/pages/components/use-is-mobile", () => ({ - useIsMobile: vi.fn(), - MOBILE_BREAKPOINT: 768, -})); - -const sampleLogs: LoggerEntry[] = [ - { id: 1, level: "error", message: "GM_xhr connect failed", label: { component: "GM_xhr" }, createtime: 3 }, - { id: 2, level: "warn", message: "retry timeout reached", label: { component: "GM_log" }, createtime: 2 }, - { id: 3, level: "info", message: "userscript started ok", label: { component: "GM_log" }, createtime: 1 }, -]; - -const { mockLoggerData } = vi.hoisted(() => ({ - mockLoggerData: { - logs: [] as LoggerEntry[], - loading: false, - reload: vi.fn(), - clearLogs: vi.fn(() => Promise.resolve()), - deleteLogs: vi.fn(() => Promise.resolve()), - cleanCycle: 7, - setCleanCycle: vi.fn(), - refreshInterval: "off" as const, - setRefreshInterval: vi.fn(), - preset: "24h" as const, - setPreset: vi.fn(), - isNow: true, - setIsNow: vi.fn(), - range: { start: 0, end: 1 }, - setRange: vi.fn(), - initialQueries: [], - }, -})); -vi.mock("./hooks", () => ({ useLogger: () => mockLoggerData })); -vi.mock("@App/pages/components/ui/toast", () => ({ - notify: { - success: vi.fn(), - error: vi.fn(), - info: vi.fn(), - warning: vi.fn(), - loading: vi.fn(), - promise: vi.fn(), - undo: vi.fn(), - dismiss: vi.fn(), - }, -})); - -import Logger from "./index"; - -const mockedUseIsMobile = vi.mocked(useIsMobile); -const renderPage = () => render(, { wrapper: MemoryRouter }); - -beforeAll(() => initTestLanguage("zh-CN")); - -beforeEach(() => { - mockLoggerData.logs = sampleLogs; - mockedUseIsMobile.mockReturnValue(true); - vi.clearAllMocks(); - mockedUseIsMobile.mockReturnValue(true); -}); - -afterEach(() => cleanup()); - -describe("日志页面 - 移动端", () => { - it("移动端顶栏的删除/清空按钮以图标按钮呈现(带无障碍名,不显示文字标签)", () => { - renderPage(); - const del = screen.getByLabelText(t("logs:delete_current_logs")); - const clear = screen.getByLabelText(t("logs:clear_logs")); - // 图标按钮:可见文本里不再包含完整中文标签 - expect(del.textContent).not.toContain(t("logs:delete_current_logs")); - expect(clear.textContent).not.toContain(t("logs:clear_logs")); - }); - - it("移动端日志行的消息允许换行而非截断", () => { - renderPage(); - const msg = screen.getByText("GM_xhr connect failed"); - expect(msg.className).not.toContain("truncate"); - }); - - it("移动端范围/统计条竖向堆叠,统计与自动清理各自独占整行", () => { - renderPage(); - const bar = screen.getByTestId("logger-stats-bar"); - expect(bar.className).toContain("flex-col"); - // 两组必须撑满整行:否则长文本会溢出自身盒子压到相邻分组上(窄屏文字重叠) - expect(screen.getByTestId("logger-stats-summary").className).toContain("w-full"); - expect(screen.getByTestId("logger-clean-schedule").className).toContain("w-full"); - }); - - it("仍渲染全部日志且筛选交互可用", () => { - renderPage(); - expect(screen.getByText("retry timeout reached")).toBeTruthy(); - fireEvent.click(screen.getByTestId("level-chip-warn")); - expect(screen.queryByText("retry timeout reached")).toBeNull(); - }); -}); diff --git a/src/pages/options/routes/Logger/components.tsx b/src/pages/options/routes/Logger/components.tsx index b1dac98a9..8dd14716d 100644 --- a/src/pages/options/routes/Logger/components.tsx +++ b/src/pages/options/routes/Logger/components.tsx @@ -7,6 +7,7 @@ import { ChevronDown, ChevronLeft, ChevronRight, + Copy, Plus, RefreshCw, X, @@ -111,18 +112,22 @@ function detailJson(log: Logger): string { } /** 单条日志(控制台风格):时间 + 级别徽标 + 消息 + 标签 chips,左侧级别色条;可展开看完整标签。 - * 桌面端单行(消息截断);移动端竖向堆叠(时间/级别一行,消息整段换行)。 */ + * 桌面端单行(消息截断);移动端竖向堆叠(时间/级别一行,消息整段换行)。 + * 行本身不是按钮,文字可拖选复制;键盘展开走右侧箭头按钮。 */ export function LogRow({ log, expanded, onToggle, + onCopy, mobile = false, }: { log: Logger; expanded: boolean; onToggle: () => void; + onCopy: () => void; mobile?: boolean; }) { + const { t } = useTranslation(); const bucket = levelBucket(log.level); const m = LEVEL_META[bucket]; const labelChips = Object.entries(log.label).filter(([, v]) => typeof v === "string" || typeof v === "number") as [ @@ -130,14 +135,60 @@ export function LogRow({ string | number, ][]; + // 拖选文字松开鼠标同样会触发 click,有选区时是在复制,不切换展开 + const handleRowClick = () => { + if (window.getSelection()?.toString()) return; + onToggle(); + }; + + const chips = labelChips.slice(0, 4).map(([k, v]) => ( + + {`${k}:${v}`} + + )); + + const actions = ( + + + + + ); + return ( -
- +
{expanded && (
diff --git a/src/pages/options/routes/Logger/index.test.tsx b/src/pages/options/routes/Logger/index.test.tsx
index e41e66401..2a7a4d31f 100644
--- a/src/pages/options/routes/Logger/index.test.tsx
+++ b/src/pages/options/routes/Logger/index.test.tsx
@@ -1,9 +1,11 @@
 import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
-import { render, cleanup, screen, fireEvent } from "@testing-library/react";
+import { render, cleanup, screen, fireEvent, act } from "@testing-library/react";
 import { MemoryRouter } from "react-router-dom";
 import { t } from "@App/locales/locales";
 import { initTestLanguage } from "@Tests/initTestLanguage";
 import type { Logger as LoggerEntry } from "@App/app/repo/logger";
+import { notify } from "@App/pages/components/ui/toast";
+import { formatLogLine, formatLogsJson, formatLogsText } from "./logic";
 
 // 数据 Hook 涉及 IndexedDB 读取,测试中整体打桩;返回稳定引用避免无限重渲染。
 const sampleLogs: LoggerEntry[] = [
@@ -45,11 +47,14 @@ vi.mock("@App/pages/components/ui/toast", () => ({
     dismiss: vi.fn(),
   },
 }));
-// 桌面视图用例:固定为非移动端(移动端重壳另在 LoggerMobile.test.tsx 覆盖)
-vi.mock("@App/pages/components/use-is-mobile", () => ({ useIsMobile: () => false, MOBILE_BREAKPOINT: 768 }));
+// 桌面与移动端用例放在同一文件:ui 项目 isolate:false 下两个文件各自 mock 同一个 ./index 的依赖会互相串用
+vi.mock("@App/pages/components/use-is-mobile", () => ({ useIsMobile: vi.fn(), MOBILE_BREAKPOINT: 768 }));
 
+import { useIsMobile } from "@App/pages/components/use-is-mobile";
 import Logger from "./index";
 
+const mockedUseIsMobile = vi.mocked(useIsMobile);
+
 const renderPage = () => render(, { wrapper: MemoryRouter });
 
 beforeAll(() => initTestLanguage("zh-CN"));
@@ -58,9 +63,35 @@ beforeEach(() => {
   mockLoggerData.logs = sampleLogs;
   mockLoggerData.clearLogs = vi.fn(() => Promise.resolve());
   vi.clearAllMocks();
+  mockedUseIsMobile.mockReturnValue(false);
+});
+
+afterEach(() => {
+  cleanup();
+  vi.restoreAllMocks();
 });
 
-afterEach(() => cleanup());
+const mockClipboard = (writeText: (text: string) => Promise) => {
+  const fn = vi.fn(writeText);
+  Object.defineProperty(navigator, "clipboard", { value: { writeText: fn }, configurable: true });
+  return fn;
+};
+
+const openExportMenu = () => {
+  const trigger = screen.getByRole("button", { name: t("export") });
+  fireEvent.pointerDown(trigger, { button: 0 });
+  fireEvent.click(trigger);
+};
+
+// 捕获交给 chrome.downloads 的文件名与 Blob 内容
+const captureDownload = () => {
+  const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:logs");
+  const download = vi.spyOn(chrome.downloads, "download").mockResolvedValue(undefined);
+  return {
+    download,
+    blobText: () => (createObjectURL.mock.calls[0][0] as Blob).text(),
+  };
+};
 
 describe("日志页面", () => {
   it("渲染传入的全部日志消息", () => {
@@ -100,4 +131,116 @@ describe("日志页面", () => {
     expect(await screen.findByText(t("logs:clear_logs_confirm"))).toBeTruthy();
     expect(mockLoggerData.clearLogs).not.toHaveBeenCalled();
   });
+
+  it("日志消息文字不在按钮内,可以被拖选复制", () => {
+    renderPage();
+    expect(screen.getByText("GM_xhr connect failed").closest("button")).toBeNull();
+  });
+
+  it("拖选文字后松开鼠标不会展开该行", () => {
+    vi.spyOn(window, "getSelection").mockReturnValue({ toString: () => "connect failed" } as Selection);
+    renderPage();
+    fireEvent.click(screen.getByTestId("log-row-1"));
+    expect(screen.queryByTestId("log-detail-1")).toBeNull();
+  });
+
+  it("点击行内复制按钮把该条日志的文本行写入剪贴板,且不展开该行", async () => {
+    const writeText = mockClipboard(() => Promise.resolve());
+    renderPage();
+    await act(async () => fireEvent.click(screen.getByTestId("log-copy-1")));
+    expect(writeText).toHaveBeenCalledWith(formatLogLine(sampleLogs[0]));
+    expect(notify.success).toHaveBeenCalledWith(t("logs:copied_logs", { count: 1 }));
+    expect(screen.queryByTestId("log-detail-1")).toBeNull();
+  });
+
+  it("导出菜单「复制为文本」复制的是当前筛选结果", async () => {
+    const writeText = mockClipboard(() => Promise.resolve());
+    renderPage();
+    fireEvent.click(screen.getByTestId("level-chip-warn"));
+    openExportMenu();
+    await act(async () => fireEvent.click(screen.getByRole("menuitem", { name: t("logs:export_copy_text") })));
+    expect(writeText).toHaveBeenCalledWith(formatLogsText([sampleLogs[0], sampleLogs[2]]));
+    expect(notify.success).toHaveBeenCalledWith(t("logs:copied_logs", { count: 2 }));
+  });
+
+  it("写入剪贴板失败时提示错误而不是提示成功", async () => {
+    mockClipboard(() => Promise.reject(new Error("denied")));
+    renderPage();
+    openExportMenu();
+    await act(async () => fireEvent.click(screen.getByRole("menuitem", { name: t("logs:export_copy_text") })));
+    expect(notify.error).toHaveBeenCalled();
+    expect(notify.success).not.toHaveBeenCalled();
+  });
+
+  it("导出 .log 文件下载当前筛选结果的纯文本", async () => {
+    const { download, blobText } = captureDownload();
+    renderPage();
+    openExportMenu();
+    await act(async () => fireEvent.click(screen.getByRole("menuitem", { name: t("logs:export_download_log") })));
+    expect(download).toHaveBeenCalledWith(
+      expect.objectContaining({ saveAs: true, filename: expect.stringMatching(/^scriptcat-logs-\d{8}-\d{6}\.log$/) })
+    );
+    await expect(blobText()).resolves.toBe(formatLogsText(sampleLogs));
+  });
+
+  it("导出 .json 文件下载当前筛选结果的完整字段", async () => {
+    const { download, blobText } = captureDownload();
+    renderPage();
+    openExportMenu();
+    await act(async () => fireEvent.click(screen.getByRole("menuitem", { name: t("logs:export_download_json") })));
+    expect(download).toHaveBeenCalledWith(
+      expect.objectContaining({ saveAs: true, filename: expect.stringMatching(/^scriptcat-logs-\d{8}-\d{6}\.json$/) })
+    );
+    await expect(blobText()).resolves.toBe(formatLogsJson(sampleLogs));
+  });
+
+  it("下载失败时提示错误", async () => {
+    vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:logs");
+    vi.spyOn(chrome.downloads, "download").mockRejectedValue(new Error("download blocked"));
+    renderPage();
+    openExportMenu();
+    await act(async () => fireEvent.click(screen.getByRole("menuitem", { name: t("logs:export_download_log") })));
+    expect(notify.error).toHaveBeenCalledWith(expect.stringContaining("download blocked"));
+  });
+
+  it("筛选结果为空时导出按钮不可用", () => {
+    mockLoggerData.logs = [];
+    renderPage();
+    expect(screen.getByRole("button", { name: t("export") })).toBeDisabled();
+  });
+});
+
+describe("日志页面 - 移动端", () => {
+  beforeEach(() => mockedUseIsMobile.mockReturnValue(true));
+
+  it("移动端顶栏的删除/清空按钮以图标按钮呈现(带无障碍名,不显示文字标签)", () => {
+    renderPage();
+    const del = screen.getByLabelText(t("logs:delete_current_logs"));
+    const clear = screen.getByLabelText(t("logs:clear_logs"));
+    // 图标按钮:可见文本里不再包含完整中文标签
+    expect(del.textContent).not.toContain(t("logs:delete_current_logs"));
+    expect(clear.textContent).not.toContain(t("logs:clear_logs"));
+  });
+
+  it("移动端日志行的消息允许换行而非截断", () => {
+    renderPage();
+    const msg = screen.getByText("GM_xhr connect failed");
+    expect(msg.className).not.toContain("truncate");
+  });
+
+  it("移动端范围/统计条竖向堆叠,统计与自动清理各自独占整行", () => {
+    renderPage();
+    const bar = screen.getByTestId("logger-stats-bar");
+    expect(bar.className).toContain("flex-col");
+    // 两组必须撑满整行:否则长文本会溢出自身盒子压到相邻分组上(窄屏文字重叠)
+    expect(screen.getByTestId("logger-stats-summary").className).toContain("w-full");
+    expect(screen.getByTestId("logger-clean-schedule").className).toContain("w-full");
+  });
+
+  it("仍渲染全部日志且筛选交互可用", () => {
+    renderPage();
+    expect(screen.getByText("retry timeout reached")).toBeTruthy();
+    fireEvent.click(screen.getByTestId("level-chip-warn"));
+    expect(screen.queryByText("retry timeout reached")).toBeNull();
+  });
 });
diff --git a/src/pages/options/routes/Logger/index.tsx b/src/pages/options/routes/Logger/index.tsx
index ce41c7d9c..64add65c1 100644
--- a/src/pages/options/routes/Logger/index.tsx
+++ b/src/pages/options/routes/Logger/index.tsx
@@ -1,11 +1,30 @@
 import { useMemo, useRef, useState } from "react";
 import { useTranslation } from "react-i18next";
-import { ArrowUp, ChevronDown, Inbox, ScrollText, Trash, Trash2 } from "lucide-react";
+import {
+  ArrowUp,
+  ChevronDown,
+  Copy,
+  Download,
+  FileJson,
+  FileText,
+  Inbox,
+  ScrollText,
+  Trash,
+  Trash2,
+} from "lucide-react";
 import { notify } from "@App/pages/components/ui/toast";
 import { cn } from "@App/pkg/utils/cn";
-import { formatUnixTime } from "@App/pkg/utils/day_format";
+import { dayFormat, formatUnixTime } from "@App/pkg/utils/day_format";
+import { makeBlobURL } from "@App/pkg/utils/utils";
 import { useIsMobile } from "@App/pages/components/use-is-mobile";
 import { Button } from "@App/pages/components/ui/button";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuLabel,
+  DropdownMenuTrigger,
+} from "@App/pages/components/ui/dropdown-menu";
 import { EmptyState } from "@App/pages/components/ui/empty-state";
 import { LoadingState } from "@App/pages/components/ui/loading-state";
 import { Popconfirm } from "@App/pages/components/ui/popconfirm";
@@ -15,6 +34,9 @@ import {
   aggregateLabels,
   countLevels,
   filterLogs,
+  formatLogLine,
+  formatLogsJson,
+  formatLogsText,
   presetRange,
   type LevelBucket,
   type LogQuery,
@@ -99,6 +121,25 @@ export default function Logger() {
     if (!ids.length) return;
     void deleteLogs(ids).then(() => notify.success(t("logs:delete_completed")));
   };
+  const copyText = (text: string, count: number) =>
+    navigator.clipboard.writeText(text).then(
+      () => notify.success(t("logs:copied_logs", { count })),
+      (e) => notify.error(`${t("logs:copy_failed")}: ${e instanceof Error ? e.message : e}`)
+    );
+  const handleDownload = async (ext: "log" | "json") => {
+    const content = ext === "log" ? formatLogsText(filtered) : formatLogsJson(filtered);
+    const type = ext === "log" ? "text/plain" : "application/json";
+    try {
+      const url = await makeBlobURL({ blob: new Blob([content], { type }), persistence: false });
+      await chrome.downloads.download({
+        url,
+        saveAs: true,
+        filename: `scriptcat-logs-${dayFormat(new Date(), "YYYYMMDD-HHmmss")}.${ext}`,
+      });
+    } catch (e) {
+      notify.error(`${t("logs:export_failed")}: ${e instanceof Error ? e.message : e}`);
+    }
+  };
   const handleClear = () => {
     void clearLogs().then(() => notify.success(t("logs:clear_completed")));
   };
@@ -117,6 +158,38 @@ export default function Logger() {
           
         
+ + + {isMobile ? ( + + ) : ( + + )} + + + + {t("logs:export_scope", { count: filtered.length })} + + void copyText(formatLogsText(filtered), filtered.length)}> + + {t("logs:export_copy_text")} + + void handleDownload("log")}> + + {t("logs:export_download_log")} + + void handleDownload("json")}> + + {t("logs:export_download_json")} + + + {isMobile ? (