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
+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>}