From 775a570d31c6a3ce6b75509b16accea0986f0b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com> Date: Tue, 26 May 2026 19:01:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=BC=E5=85=A5=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=AF=B9=E8=AF=9D=E6=A1=86=E3=80=81=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20app-store=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E3=80=81=E4=BF=AE=E5=A4=8D=E7=AA=97=E5=8F=A3=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleImport 从 DOM hack 改为 @tauri-apps/plugin-dialog 原生文件选择 - 新增 Rust read_text_file 命令读取文件内容,零外部依赖 - 新增 tests/unit/app-store.test.ts,25 个测试覆盖 CRUD/undo-redo/loadPaths/savePaths - 修复 AppShell overflow-hidden 导致无法滚动窗口的问题 Co-Authored-By: Claude Opus 4.7 --- src-tauri/src/commands/fs.rs | 5 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 1 + src/components/layout/AppShell.tsx | 2 +- src/hooks/use-app-actions.ts | 35 ++-- tests/unit/app-store.test.ts | 310 +++++++++++++++++++++++++++++ 6 files changed, 334 insertions(+), 20 deletions(-) create mode 100644 src-tauri/src/commands/fs.rs create mode 100644 tests/unit/app-store.test.ts diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs new file mode 100644 index 0000000..0ed7466 --- /dev/null +++ b/src-tauri/src/commands/fs.rs @@ -0,0 +1,5 @@ +/// 读取文本文件内容(供前端原生对话框选择文件后使用) +#[tauri::command] +pub fn read_text_file(path: &str) -> Result { + std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e)) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2194107..c449329 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ pub mod registry; pub mod system; pub mod backup; +pub mod fs; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2468e2..fe116f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,6 +26,7 @@ pub fn run() { commands::system::broadcast_env_change, commands::backup::backup_registry, commands::backup::get_appdata_dir, + commands::fs::read_text_file, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 9d66b78..d15a28d 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -84,7 +84,7 @@ export function AppShell() {
{ e.preventDefault(); e.dataTransfer.dropEffect = 'link'; }} onDrop={(e) => { e.preventDefault(); diff --git a/src/hooks/use-app-actions.ts b/src/hooks/use-app-actions.ts index 5a031c9..b42c820 100644 --- a/src/hooks/use-app-actions.ts +++ b/src/hooks/use-app-actions.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect } from 'react'; import { useAppStore } from '@/store/app-store'; import { TargetType } from '@/core/undo-redo'; import { open } from '@tauri-apps/plugin-dialog'; +import { invoke } from '@tauri-apps/api/core'; import { importFromContent, exportToJson, exportToCsv, flattenImportResult } from '@/core/import-export'; import { is_valid_path_format } from '@/core/validation'; import { useKeyboard } from './use-keyboard'; @@ -80,25 +81,21 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) { // ── 导入导出 ── - const handleImport = useCallback(() => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json,.csv,.txt'; - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) { input.remove(); return; } - const content = await file.text(); - const result = importFromContent(content, file.name); - input.remove(); - if (result.system.length > 0 && result.user.length > 0) { - setImportDialog({ open: true, system: result.system, user: result.user }); - } else if (result.system.length > 0) { - useAppStore.getState().importPaths(TargetType.SYSTEM, result.system); - } else if (result.user.length > 0) { - useAppStore.getState().importPaths(TargetType.USER, result.user); - } - }; - input.click(); + const handleImport = useCallback(async () => { + const selected = await open({ + filters: [{ name: '受支持格式', extensions: ['json', 'csv', 'txt'] }], + multiple: false, + }); + if (!selected || typeof selected !== 'string') return; + const content = await invoke('read_text_file', { path: selected }); + const result = importFromContent(content, selected); + if (result.system.length > 0 && result.user.length > 0) { + setImportDialog({ open: true, system: result.system, user: result.user }); + } else if (result.system.length > 0) { + useAppStore.getState().importPaths(TargetType.SYSTEM, result.system); + } else if (result.user.length > 0) { + useAppStore.getState().importPaths(TargetType.USER, result.user); + } }, [setImportDialog]); const handleExport = useCallback((format: 'json' | 'csv' = 'json') => { diff --git a/tests/unit/app-store.test.ts b/tests/unit/app-store.test.ts new file mode 100644 index 0000000..f9fcf5d --- /dev/null +++ b/tests/unit/app-store.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock @tauri-apps/api/core +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +// Mock i18n +vi.mock('@/i18n', () => ({ + default: { t: vi.fn((key: string, opts?: Record) => { + if (key === 'status.loaded') return `已加载 ${opts?.sysCount} 条系统 PATH,${opts?.userCount} 条用户 PATH`; + if (key === 'status.error') return '加载失败'; + if (key === 'status.saving') return '正在保存...'; + if (key === 'status.saved') return '保存成功'; + if (key === 'status.warning_backup') return '备份失败,但保存继续'; + if (key === 'status.readonly') return '只读模式'; + if (key === 'status.deleted') return `已删除 ${opts?.count} 条路径`; + return key; + }) }, +})); + +import { useAppStore } from '@/store/app-store'; +import { UndoRedoManager, TargetType } from '@/core/undo-redo'; +import { invoke } from '@tauri-apps/api/core'; + +const mockedInvoke = vi.mocked(invoke); + +function resetStore() { + useAppStore.setState({ + sysPaths: [], + userPaths: [], + undoRedo: new UndoRedoManager(50), + _savedSys: [], + _savedUser: [], + isModified: false, + isLoading: false, + isSaving: false, + selectedIndices: [], + searchQuery: '', + statusMessage: '', + }); +} + +describe('app-store CRUD', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it('addPath 追加到 sysPaths', () => { + useAppStore.getState().addPath('C:\\test', TargetType.SYSTEM); + const s = useAppStore.getState(); + expect(s.sysPaths).toEqual(['C:\\test']); + expect(s.isModified).toBe(true); + expect(s.undoRedo.historyLength).toBe(1); + }); + + it('addPath 追加到 userPaths', () => { + useAppStore.getState().addPath('D:\\user', TargetType.USER); + const s = useAppStore.getState(); + expect(s.userPaths).toEqual(['D:\\user']); + expect(s.sysPaths).toEqual([]); + }); + + it('editPath 替换正确位置', () => { + const store = useAppStore.getState(); + store.addPath('C:\\old', TargetType.SYSTEM); + store.editPath(0, 'C:\\new', TargetType.SYSTEM); + expect(useAppStore.getState().sysPaths).toEqual(['C:\\new']); + }); + + it('editPath 越界 index 无崩溃', () => { + expect(() => { + useAppStore.getState().editPath(99, 'X', TargetType.SYSTEM); + }).not.toThrow(); + }); + + it('deletePaths 单选删除', () => { + const store = useAppStore.getState(); + store.addPath('A', TargetType.SYSTEM); + store.addPath('B', TargetType.SYSTEM); + store.addPath('C', TargetType.SYSTEM); + store.deletePaths([1], TargetType.SYSTEM); + expect(useAppStore.getState().sysPaths).toEqual(['A', 'C']); + expect(useAppStore.getState().selectedIndices).toEqual([]); + }); + + it('deletePaths 多选删除(逆序排序一次 undo 覆盖)', () => { + const store = useAppStore.getState(); + store.addPath('A', TargetType.USER); + store.addPath('B', TargetType.USER); + store.addPath('C', TargetType.USER); + store.addPath('D', TargetType.USER); + store.deletePaths([1, 3], TargetType.USER); + expect(useAppStore.getState().userPaths).toEqual(['A', 'C']); + }); + + it('moveUp index=0 无操作', () => { + const store = useAppStore.getState(); + store.addPath('A', TargetType.SYSTEM); + store.moveUp(0, TargetType.SYSTEM); + expect(useAppStore.getState().sysPaths).toEqual(['A']); + }); + + it('moveUp 正常交换位置', () => { + const store = useAppStore.getState(); + store.addPath('A', TargetType.SYSTEM); + store.addPath('B', TargetType.SYSTEM); + store.moveUp(1, TargetType.SYSTEM); + expect(useAppStore.getState().sysPaths).toEqual(['B', 'A']); + expect(useAppStore.getState().selectedIndices).toEqual([0]); + }); + + it('moveDown 末位无操作', () => { + const store = useAppStore.getState(); + store.addPath('A', TargetType.USER); + store.moveDown(0, TargetType.USER); + expect(useAppStore.getState().userPaths).toEqual(['A']); + }); + + it('cleanPaths 移除无效路径并返回 removed', () => { + const store = useAppStore.getState(); + store.addPath('C:\\valid', TargetType.SYSTEM); + store.addPath(':::invalid:::', TargetType.SYSTEM); + // is_valid_path_format 拒绝全标点路径 + const removed = store.cleanPaths(TargetType.SYSTEM, (p) => !p.includes(':::')); + expect(removed).toEqual([':::invalid:::']); + expect(useAppStore.getState().sysPaths).toEqual(['C:\\valid']); + }); + + it('importPaths 整体替换列表', () => { + const store = useAppStore.getState(); + store.addPath('old1', TargetType.USER); + store.addPath('old2', TargetType.USER); + store.importPaths(TargetType.USER, ['new1', 'new2', 'new3']); + expect(useAppStore.getState().userPaths).toEqual(['new1', 'new2', 'new3']); + }); + + it('clearPaths 清空列表', () => { + const store = useAppStore.getState(); + store.addPath('A', TargetType.SYSTEM); + store.addPath('B', TargetType.SYSTEM); + store.clearPaths(TargetType.SYSTEM); + expect(useAppStore.getState().sysPaths).toEqual([]); + }); + + it('clearPaths 空列表无操作', () => { + const store = useAppStore.getState(); + store.clearPaths(TargetType.USER); + expect(useAppStore.getState().undoRedo.historyLength).toBe(0); + }); +}); + +describe('undo/redo', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it('undo 恢复操作前状态', () => { + useAppStore.getState().addPath('test', TargetType.SYSTEM); + expect(useAppStore.getState().sysPaths.length).toBe(1); + useAppStore.getState().undo(); + expect(useAppStore.getState().sysPaths).toEqual([]); + }); + + it('redo 回到操作后状态', () => { + const store = useAppStore.getState(); + store.addPath('test', TargetType.SYSTEM); + store.undo(); + store.redo(); + expect(useAppStore.getState().sysPaths).toEqual(['test']); + }); + + it('undo/redo 正确更新 isModified', () => { + const store = useAppStore.getState(); + // 设置已保存快照 + useAppStore.setState({ _savedSys: [], _savedUser: [] }); + store.addPath('test', TargetType.SYSTEM); + expect(useAppStore.getState().isModified).toBe(true); + store.undo(); + expect(useAppStore.getState().isModified).toBe(false); + store.redo(); + expect(useAppStore.getState().isModified).toBe(true); + }); +}); + +describe('_markDirty', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it('修改后 isModified 为 true', () => { + useAppStore.setState({ _savedSys: [], sysPaths: [], _savedUser: [], userPaths: [] }); + useAppStore.getState()._markDirty(); + expect(useAppStore.getState().isModified).toBe(false); // 相等 + }); + + it('路径变化时 isModified 为 true', () => { + useAppStore.setState({ + _savedSys: ['A'], sysPaths: ['B'], + _savedUser: [], userPaths: [], + }); + useAppStore.getState()._markDirty(); + expect(useAppStore.getState().isModified).toBe(true); + }); +}); + +describe('loadPaths', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it('成功加载', async () => { + mockedInvoke.mockResolvedValueOnce(['C:\\sys1', 'C:\\sys2']); + mockedInvoke.mockResolvedValueOnce(['D:\\usr1']); + await useAppStore.getState().loadPaths(); + const s = useAppStore.getState(); + expect(s.sysPaths).toEqual(['C:\\sys1', 'C:\\sys2']); + expect(s.userPaths).toEqual(['D:\\usr1']); + expect(s.isLoading).toBe(false); + expect(s.isModified).toBe(false); + }); + + it('加载失败时 isLoading 重置', async () => { + mockedInvoke.mockRejectedValueOnce(new Error('reg error')); + mockedInvoke.mockResolvedValueOnce([]); + await useAppStore.getState().loadPaths(); + const s = useAppStore.getState(); + expect(s.isLoading).toBe(false); + expect(s.statusMessage).toContain('加载失败'); + }); +}); + +describe('savePaths', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + useAppStore.setState({ sysPaths: ['A'], userPaths: ['B'] }); + }); + + it('保存成功', async () => { + mockedInvoke.mockResolvedValue(undefined); + await useAppStore.getState().savePaths(); + const s = useAppStore.getState(); + expect(s.isSaving).toBe(false); + expect(s.isModified).toBe(false); + expect(s.statusMessage).toBe('保存成功'); + }); + + it('部分失败时报告具体 hive', async () => { + mockedInvoke + .mockResolvedValueOnce(undefined) // backup_registry + .mockResolvedValueOnce(undefined) // save_system_paths + .mockRejectedValueOnce('权限不足'); // save_user_paths + await useAppStore.getState().savePaths(); + const s = useAppStore.getState(); + expect(s.isSaving).toBe(false); + expect(s.statusMessage).toContain('用户 PATH 保存失败'); + }); + + it('isSaving 守卫:并发第二次调用直接返回', async () => { + let resolveAll: (v: unknown) => void; + const pending = new Promise((r) => { resolveAll = r; }); + mockedInvoke.mockReturnValue(pending as any); + + // 第一次调用(不等它完成,停在 Promise.allSettled) + const p1 = useAppStore.getState().savePaths(); + // 第二次调用应被 isSaving 守卫拦截(此时 isSaving=true) + const r2 = useAppStore.getState().savePaths(); + + // 第二次调用同步返回 undefined(被守卫拦截) + await expect(r2).resolves.toBeUndefined(); + + // 放行第一次调用的所有 invoke + resolveAll!(undefined); + await p1; + }); +}); + +describe('initialize', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetStore(); + }); + + it('管理员模式初始化', async () => { + mockedInvoke + .mockResolvedValueOnce(true) // check_admin + .mockResolvedValueOnce(['S1']) // load_system_paths + .mockResolvedValueOnce(['U1']); // load_user_paths + await useAppStore.getState().initialize(); + const s = useAppStore.getState(); + expect(s.isAdmin).toBe(true); + expect(s.sysPaths).toEqual(['S1']); + expect(s.userPaths).toEqual(['U1']); + }); + + it('非管理员初始化进入只读模式', async () => { + mockedInvoke + .mockResolvedValueOnce(false) // check_admin + .mockResolvedValueOnce([]) // load_system_paths + .mockResolvedValueOnce([]); // load_user_paths + await useAppStore.getState().initialize(); + expect(useAppStore.getState().isAdmin).toBe(false); + // statusMessage 被后续 loadPaths 覆盖为加载完成消息,但 isAdmin=false 不变 + }); +});