{
+ 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 不变
+ });
+});