feat: 原生对话框、ErrorBoundary、配置生效、交互打磨

- handleBrowse 改用 @tauri-apps/plugin-dialog 原生目录选择
- handleImport 清理临时 DOM 元素(add input.remove())
- config/default.json 实际导入生效(maxHistory、path 长度限制)
- app-store.ts 长度检查改用配置值
- 删除 AppShell 中与 store 重复的长度检查
- 新增 ErrorBoundary 组件避免单异常白屏
- StatusBar 加载失败时显示重试按钮
- 取消按钮检查 isModified 未保存提示
- lib.rs 注册 tauri-plugin-dialog
- tsconfig 添加 resolveJsonModule
- CLAUDE.md 添加 cargo test 运行时说明

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 00:33:57 +08:00
parent bfd114d80f
commit 3a21891f84
11 changed files with 79 additions and 45 deletions
+1
View File
@@ -92,3 +92,4 @@ tests/unit/ # Vitest 前端单元测试
- `.cargo/config.toml` 添加了 `-lmcfgthread` 兼容 GCC 15.2.0 MinGW
- 移除 `cdylib` crate-type 避免 DLL 导出序数溢出
- 运行需要管理员权限才能编辑系统 PATH
- `cargo test` 需要 MinGW bin 在 PATH 中(GCC 15.2.0 运行时依赖 `libmcfgthread-2.dll`),开发模式下可用 `npx tauri dev` 替代
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Serialize;
/// 传给前端的统一错误类型
/// 传给前端的统一错误类型(保留供未来使用,当前命令返回 Result<T, String>
#[derive(Debug, Serialize)]
pub struct AppError {
pub message: String,
+1
View File
@@ -4,6 +4,7 @@ mod error;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
+6 -1
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useAppStore } from '@/store/app-store';
import { initDarkMode, useThemeStore } from '@/store/theme-store';
import { AppShell } from '@/components/layout/AppShell';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
export default function App() {
const initialize = useAppStore((s) => s.initialize);
@@ -15,5 +16,9 @@ export default function App() {
initialize();
}, [initialize]);
return <AppShell />;
return (
<ErrorBoundary>
<AppShell />
</ErrorBoundary>
);
}
+14 -37
View File
@@ -4,6 +4,7 @@ import { useThemeStore } from '@/store/theme-store';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
import { TargetType } from '@/core/undo-redo';
import { open } from '@tauri-apps/plugin-dialog';
import { importFromContent, exportToJson, flattenImportResult } from '@/core/import-export';
import { StatusBar } from './StatusBar';
import { TitleBar } from './TitleBar';
@@ -55,19 +56,11 @@ export function AppShell() {
}
}, [selectedIndices, getCurrentTarget]);
const handleBrowse = useCallback(() => {
// Tauri native dialog (简化版 — 后续可增强)
const input = document.createElement('input');
input.type = 'file';
input.webkitdirectory = true;
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
if (files && files.length > 0) {
const path = (files[0] as any).path || files[0].name;
useAppStore.getState().addPath(path, getCurrentTarget());
}
};
input.click();
const handleBrowse = useCallback(async () => {
const selected = await open({ directory: true, multiple: false });
if (selected && typeof selected === 'string') {
useAppStore.getState().addPath(selected, getCurrentTarget());
}
}, [getCurrentTarget]);
const handleDelete = useCallback(() => {
@@ -103,9 +96,10 @@ export function AppShell() {
input.accept = '.json,.csv,.txt';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
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 });
@@ -136,28 +130,7 @@ export function AppShell() {
}, []);
const handleSave = useCallback(() => {
const state = useAppStore.getState();
const sysJoined = state.sysPaths.join(';');
const userJoined = state.userPaths.join(';');
const combined = sysJoined + ';' + userJoined;
const warnings: string[] = [];
if (sysJoined.length > 2048) {
warnings.push(`系统 PATH 长度 ${sysJoined.length} 超过建议值 2048`);
}
if (userJoined.length > 2048) {
warnings.push(`用户 PATH 长度 ${userJoined.length} 超过建议值 2048`);
}
if (combined.length > 8191) {
warnings.push(`合并 PATH 长度 ${combined.length} 超过命令行安全限制 8191`);
}
if (warnings.length > 0) {
const msg = warnings.join('\n') + '\n\n是否继续保存?';
if (!window.confirm(msg)) return;
}
state.savePaths();
useAppStore.getState().savePaths();
}, []);
// ── 键盘快捷键 ──
@@ -260,7 +233,11 @@ export function AppShell() {
onImport={handleImport}
onExport={handleExport}
onSave={handleSave}
onCancel={() => window.close()}
onCancel={() => {
const state = useAppStore.getState();
if (state.isModified && !window.confirm('有未保存的修改,确定退出吗?')) return;
window.close();
}}
onHelp={() => setHelpOpen(true)}
onLanguage={() => {
const current = localStorage.getItem('i18nextLng') || 'zh-CN';
+33
View File
@@ -0,0 +1,33 @@
import { Component, type ReactNode } from 'react';
interface Props { children: ReactNode; }
interface State { hasError: boolean; error: string; }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: '' };
static getDerivedStateFromError(e: Error): State {
return { hasError: true, error: e.message };
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center h-screen" style={{ backgroundColor: 'var(--app-bg)', color: 'var(--app-fg)' }}>
<div className="text-center space-y-4">
<h2 className="text-xl font-bold"></h2>
<p className="text-sm opacity-70">{this.state.error}</p>
<button
className="px-4 py-2 rounded border"
onClick={() => this.setState({ hasError: false })}
style={{ borderColor: 'var(--app-border)' }}
>
</button>
</div>
</div>
);
}
return this.props.children;
}
}
+13 -1
View File
@@ -9,6 +9,7 @@ export function StatusBar() {
const isAdmin = useAppStore((s) => s.isAdmin);
const isModified = useAppStore((s) => s.isModified);
const isDark = useThemeStore((s) => s.isDark);
const hasError = statusMessage.includes(t('status.error'));
return (
<footer
@@ -19,7 +20,18 @@ export function StatusBar() {
color: 'var(--app-fg)',
}}
>
<span>{isLoading ? t('status.loading') : statusMessage}</span>
<div className="flex items-center gap-2">
<span>{isLoading ? t('status.loading') : statusMessage}</span>
{hasError && !isLoading && (
<button
className="px-2 py-0.5 rounded border text-xs"
style={{ borderColor: 'var(--app-border)' }}
onClick={() => useAppStore.getState().loadPaths()}
>
{t('button.retry')}
</button>
)}
</div>
<div className="flex gap-3">
{isModified && <span className="text-yellow-500"> {t('status.modified')}</span>}
{!isAdmin && <span className="text-yellow-500">{t('status.readonly_label')}</span>}
+2 -1
View File
@@ -25,7 +25,8 @@
"redo": "Redo",
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"language": "Language"
"language": "Language",
"retry": "Retry"
},
"merge": {
"system": "System",
+2 -1
View File
@@ -25,7 +25,8 @@
"redo": "重做",
"darkMode": "深色模式",
"lightMode": "浅色模式",
"language": "语言"
"language": "语言",
"retry": "重试"
},
"merge": {
"system": "系统",
+5 -3
View File
@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
import i18n from '@/i18n';
import { UndoRedoManager, OperationType, TargetType } from '@/core/undo-redo';
import { pathClean } from '@/core/path-manager';
import appConfig from '@/config/default.json';
export type TabId = 'system' | 'user' | 'merged';
@@ -47,7 +48,7 @@ interface AppState {
export const useAppStore = create<AppState>((set, get) => ({
sysPaths: [],
userPaths: [],
undoRedo: new UndoRedoManager(50),
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
activeTab: 'system',
searchQuery: '',
@@ -207,7 +208,7 @@ export const useAppStore = create<AppState>((set, get) => ({
set({
sysPaths: sysArr,
userPaths: userArr,
undoRedo: new UndoRedoManager(50),
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
isLoading: false,
isModified: false,
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
@@ -222,7 +223,8 @@ export const useAppStore = create<AppState>((set, get) => ({
const sysJoined = sysPaths.join(';');
const userJoined = userPaths.join(';');
if (sysJoined.length > 2048 || userJoined.length > 2048 || (sysJoined + userJoined).length > 8191) {
const { maxSystemLength, maxUserLength, maxCombinedLength } = appConfig.path;
if (sysJoined.length > maxSystemLength || userJoined.length > maxUserLength || (sysJoined + userJoined).length > maxCombinedLength) {
if (!window.confirm(`${i18n.t('status.error')}: PATH 长度超过建议值,是否继续?`)) return;
}
+1
View File
@@ -13,6 +13,7 @@
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
/* Path aliases */