mirror of
https://github.com/LHY0125/PathEditor.git
synced 2026-06-29 01:37:22 +08:00
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:
@@ -92,3 +92,4 @@ tests/unit/ # Vitest 前端单元测试
|
|||||||
- `.cargo/config.toml` 添加了 `-lmcfgthread` 兼容 GCC 15.2.0 MinGW
|
- `.cargo/config.toml` 添加了 `-lmcfgthread` 兼容 GCC 15.2.0 MinGW
|
||||||
- 移除 `cdylib` crate-type 避免 DLL 导出序数溢出
|
- 移除 `cdylib` crate-type 避免 DLL 导出序数溢出
|
||||||
- 运行需要管理员权限才能编辑系统 PATH
|
- 运行需要管理员权限才能编辑系统 PATH
|
||||||
|
- `cargo test` 需要 MinGW bin 在 PATH 中(GCC 15.2.0 运行时依赖 `libmcfgthread-2.dll`),开发模式下可用 `npx tauri dev` 替代
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
/// 传给前端的统一错误类型
|
/// 传给前端的统一错误类型(保留供未来使用,当前命令返回 Result<T, String>)
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AppError {
|
pub struct AppError {
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod error;
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
|
|||||||
+6
-1
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { initDarkMode, useThemeStore } from '@/store/theme-store';
|
import { initDarkMode, useThemeStore } from '@/store/theme-store';
|
||||||
import { AppShell } from '@/components/layout/AppShell';
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
|
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const initialize = useAppStore((s) => s.initialize);
|
const initialize = useAppStore((s) => s.initialize);
|
||||||
@@ -15,5 +16,9 @@ export default function App() {
|
|||||||
initialize();
|
initialize();
|
||||||
}, [initialize]);
|
}, [initialize]);
|
||||||
|
|
||||||
return <AppShell />;
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AppShell />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useThemeStore } from '@/store/theme-store';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
import { TargetType } from '@/core/undo-redo';
|
import { TargetType } from '@/core/undo-redo';
|
||||||
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { importFromContent, exportToJson, flattenImportResult } from '@/core/import-export';
|
import { importFromContent, exportToJson, flattenImportResult } from '@/core/import-export';
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
import { TitleBar } from './TitleBar';
|
import { TitleBar } from './TitleBar';
|
||||||
@@ -55,19 +56,11 @@ export function AppShell() {
|
|||||||
}
|
}
|
||||||
}, [selectedIndices, getCurrentTarget]);
|
}, [selectedIndices, getCurrentTarget]);
|
||||||
|
|
||||||
const handleBrowse = useCallback(() => {
|
const handleBrowse = useCallback(async () => {
|
||||||
// Tauri native dialog (简化版 — 后续可增强)
|
const selected = await open({ directory: true, multiple: false });
|
||||||
const input = document.createElement('input');
|
if (selected && typeof selected === 'string') {
|
||||||
input.type = 'file';
|
useAppStore.getState().addPath(selected, getCurrentTarget());
|
||||||
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();
|
|
||||||
}, [getCurrentTarget]);
|
}, [getCurrentTarget]);
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
@@ -103,9 +96,10 @@ export function AppShell() {
|
|||||||
input.accept = '.json,.csv,.txt';
|
input.accept = '.json,.csv,.txt';
|
||||||
input.onchange = async (e) => {
|
input.onchange = async (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (!file) return;
|
if (!file) { input.remove(); return; }
|
||||||
const content = await file.text();
|
const content = await file.text();
|
||||||
const result = importFromContent(content, file.name);
|
const result = importFromContent(content, file.name);
|
||||||
|
input.remove();
|
||||||
|
|
||||||
if (result.system.length > 0 && result.user.length > 0) {
|
if (result.system.length > 0 && result.user.length > 0) {
|
||||||
setImportDialog({ open: true, system: result.system, user: result.user });
|
setImportDialog({ open: true, system: result.system, user: result.user });
|
||||||
@@ -136,28 +130,7 @@ export function AppShell() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
const state = useAppStore.getState();
|
useAppStore.getState().savePaths();
|
||||||
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();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── 键盘快捷键 ──
|
// ── 键盘快捷键 ──
|
||||||
@@ -260,7 +233,11 @@ export function AppShell() {
|
|||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onCancel={() => window.close()}
|
onCancel={() => {
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
if (state.isModified && !window.confirm('有未保存的修改,确定退出吗?')) return;
|
||||||
|
window.close();
|
||||||
|
}}
|
||||||
onHelp={() => setHelpOpen(true)}
|
onHelp={() => setHelpOpen(true)}
|
||||||
onLanguage={() => {
|
onLanguage={() => {
|
||||||
const current = localStorage.getItem('i18nextLng') || 'zh-CN';
|
const current = localStorage.getItem('i18nextLng') || 'zh-CN';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export function StatusBar() {
|
|||||||
const isAdmin = useAppStore((s) => s.isAdmin);
|
const isAdmin = useAppStore((s) => s.isAdmin);
|
||||||
const isModified = useAppStore((s) => s.isModified);
|
const isModified = useAppStore((s) => s.isModified);
|
||||||
const isDark = useThemeStore((s) => s.isDark);
|
const isDark = useThemeStore((s) => s.isDark);
|
||||||
|
const hasError = statusMessage.includes(t('status.error'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
@@ -19,7 +20,18 @@ export function StatusBar() {
|
|||||||
color: 'var(--app-fg)',
|
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">
|
<div className="flex gap-3">
|
||||||
{isModified && <span className="text-yellow-500">● {t('status.modified')}</span>}
|
{isModified && <span className="text-yellow-500">● {t('status.modified')}</span>}
|
||||||
{!isAdmin && <span className="text-yellow-500">{t('status.readonly_label')}</span>}
|
{!isAdmin && <span className="text-yellow-500">{t('status.readonly_label')}</span>}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
"redo": "Redo",
|
"redo": "Redo",
|
||||||
"darkMode": "Dark Mode",
|
"darkMode": "Dark Mode",
|
||||||
"lightMode": "Light Mode",
|
"lightMode": "Light Mode",
|
||||||
"language": "Language"
|
"language": "Language",
|
||||||
|
"retry": "Retry"
|
||||||
},
|
},
|
||||||
"merge": {
|
"merge": {
|
||||||
"system": "System",
|
"system": "System",
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
"redo": "重做",
|
"redo": "重做",
|
||||||
"darkMode": "深色模式",
|
"darkMode": "深色模式",
|
||||||
"lightMode": "浅色模式",
|
"lightMode": "浅色模式",
|
||||||
"language": "语言"
|
"language": "语言",
|
||||||
|
"retry": "重试"
|
||||||
},
|
},
|
||||||
"merge": {
|
"merge": {
|
||||||
"system": "系统",
|
"system": "系统",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
|
|||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
import { UndoRedoManager, OperationType, TargetType } from '@/core/undo-redo';
|
import { UndoRedoManager, OperationType, TargetType } from '@/core/undo-redo';
|
||||||
import { pathClean } from '@/core/path-manager';
|
import { pathClean } from '@/core/path-manager';
|
||||||
|
import appConfig from '@/config/default.json';
|
||||||
|
|
||||||
export type TabId = 'system' | 'user' | 'merged';
|
export type TabId = 'system' | 'user' | 'merged';
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ interface AppState {
|
|||||||
export const useAppStore = create<AppState>((set, get) => ({
|
export const useAppStore = create<AppState>((set, get) => ({
|
||||||
sysPaths: [],
|
sysPaths: [],
|
||||||
userPaths: [],
|
userPaths: [],
|
||||||
undoRedo: new UndoRedoManager(50),
|
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||||
|
|
||||||
activeTab: 'system',
|
activeTab: 'system',
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
@@ -207,7 +208,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
sysPaths: sysArr,
|
sysPaths: sysArr,
|
||||||
userPaths: userArr,
|
userPaths: userArr,
|
||||||
undoRedo: new UndoRedoManager(50),
|
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isModified: false,
|
isModified: false,
|
||||||
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
|
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 sysJoined = sysPaths.join(';');
|
||||||
const userJoined = userPaths.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;
|
if (!window.confirm(`${i18n.t('status.error')}: PATH 长度超过建议值,是否继续?`)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Path aliases */
|
/* Path aliases */
|
||||||
|
|||||||
Reference in New Issue
Block a user