feat: 导入改用原生对话框、新增 app-store 单元测试、修复窗口滚动

- handleImport 从 DOM <input> 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:01:56 +08:00
parent 41008e9282
commit 775a570d31
6 changed files with 334 additions and 20 deletions
+5
View File
@@ -0,0 +1,5 @@
/// 读取文本文件内容(供前端原生对话框选择文件后使用)
#[tauri::command]
pub fn read_text_file(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
}
+1
View File
@@ -1,3 +1,4 @@
pub mod registry;
pub mod system;
pub mod backup;
pub mod fs;
+1
View File
@@ -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");
+1 -1
View File
@@ -84,7 +84,7 @@ export function AppShell() {
</div>
<div
className="flex-1 overflow-hidden"
className="flex-1 overflow-auto"
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'link'; }}
onDrop={(e) => {
e.preventDefault();
+9 -12
View File
@@ -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,16 +81,14 @@ 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();
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<string>('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) {
@@ -97,8 +96,6 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
} else if (result.user.length > 0) {
useAppStore.getState().importPaths(TargetType.USER, result.user);
}
};
input.click();
}, [setImportDialog]);
const handleExport = useCallback((format: 'json' | 'csv' = 'json') => {
+310
View File
@@ -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<string, unknown>) => {
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 不变
});
});