fix: 最终审查修复 — 数据安全、功能缺失、状态管理

Rust:
- save_paths 添加 Windows PATH 32767 字符上限检查防静默截断
- backup_registry 回退路径统一为 backup_base_dir() 三级链

Store:
- 新增 isSaving 并发守卫防止重复保存
- 保存失败详情通过 Promise.allSettled.reason 展示
- isModified 改为与上次保存快照比较(undo/redo 后准确反映状态)
- 批删除合并为单条撤销记录(N 次删除 → 1 次 Ctrl+Z 恢复)
- 保存失败/备份失败使用 i18n 键(消除硬编码中文)

UI:
- 拖拽添加改用 webkitGetAsEntry().isDirectory 校验文件夹
- Ctrl+F 快捷键聚焦搜索框
- handleClean 使用 is_valid_path_format(替代不完整的 inline 函数)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 00:48:43 +08:00
parent e6a2416271
commit b159407773
8 changed files with 118 additions and 67 deletions
+8 -11
View File
@@ -2,16 +2,18 @@ use chrono::Local;
use std::fs;
use std::path::PathBuf;
/// 获取 APPDATA 路径下的备份目录
#[tauri::command]
pub fn get_appdata_dir() -> String {
fn backup_base_dir() -> PathBuf {
dirs::data_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
.join("PathEditor")
.join("backups")
.to_string_lossy()
.to_string()
}
/// 获取 APPDATA 路径下的备份目录
#[tauri::command]
pub fn get_appdata_dir() -> String {
backup_base_dir().to_string_lossy().to_string()
}
/// 备份当前注册表中的系统 PATH 和用户 PATH
@@ -21,12 +23,7 @@ pub fn backup_registry(custom_dir: Option<String>, sys_paths: Vec<String>, user_
// 确定备份目录
let backup_dir = match custom_dir {
Some(ref dir) if !dir.is_empty() => PathBuf::from(dir),
_ => {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("C:\\"))
.join("PathEditor")
.join("backups")
}
_ => backup_base_dir(),
};
// 创建目录
+11 -1
View File
@@ -19,12 +19,22 @@ fn load_paths(root: winreg::HKEY, sub_path: &str, label: &str) -> Result<Vec<Str
}
fn save_paths(root: winreg::HKEY, sub_path: &str, label: &str, paths: &[String]) -> Result<(), String> {
let value = join_path(paths);
// Windows 注册表 REG_EXPAND_SZ 上限 32767 字符
const MAX_PATH_LEN: usize = 32767;
if value.len() > MAX_PATH_LEN {
return Err(format!(
"{} PATH 总长度 {} 超出 Windows 限制 {} 字符,请移除部分路径后再保存",
label, value.len(), MAX_PATH_LEN
));
}
let key = RegKey::predef(root);
let env_key = key
.open_subkey_with_flags(sub_path, KEY_WRITE)
.map_err(|e| format!("无法写入{}注册表(需要管理员权限): {}", label, e))?;
let value = join_path(paths);
env_key
.set_value(PATH_VALUE, &value)
.map_err(|e| format!("无法写入{} PATH: {}", label, e))?;
+6 -3
View File
@@ -89,9 +89,12 @@ export function AppShell() {
onDrop={(e) => {
e.preventDefault();
if (activeTab === 'merged') return;
for (let i = 0; i < e.dataTransfer.files.length; i++) {
const path = (e.dataTransfer.files[i] as any).path;
if (path) useAppStore.getState().addPath(path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
for (let i = 0; i < e.dataTransfer.items.length; i++) {
const entry = e.dataTransfer.items[i].webkitGetAsEntry();
if (entry?.isDirectory) {
const path = (e.dataTransfer.files[i] as any).path;
if (path) useAppStore.getState().addPath(path, activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM);
}
}
}}
>
+2 -1
View File
@@ -3,6 +3,7 @@ import { useAppStore } from '@/store/app-store';
import { TargetType } from '@/core/undo-redo';
import { open } from '@tauri-apps/plugin-dialog';
import { importFromContent, exportToJson, flattenImportResult } from '@/core/import-export';
import { is_valid_path_format } from '@/core/validation';
import { useKeyboard } from './use-keyboard';
import i18n from '@/i18n';
import type { TabId } from '@/store/app-store';
@@ -68,7 +69,7 @@ export function useAppActions(activeTab: TabId, dialogs: DialogState) {
const handleClean = useCallback(() => {
const removed = useAppStore.getState().cleanPaths(
getCurrentTarget(),
(p) => p.includes('%') || p.includes('\\') || p.includes('/') || /^[a-zA-Z]:[/\\]/.test(p),
is_valid_path_format,
);
if (removed.length > 0) {
useAppStore.getState().setStatusMessage(
+5
View File
@@ -55,6 +55,11 @@ export function useKeyboard(actions: KeyboardActions) {
if (!isAdmin) return;
e.preventDefault();
a.onDelete();
} else if (ctrl && e.key === 'f') {
e.preventDefault();
const searchInput = document.querySelector<HTMLInputElement>('input[placeholder]');
searchInput?.focus();
searchInput?.select();
} else if (e.key === 'F1') {
e.preventDefault();
a.onHelp();
+1
View File
@@ -39,6 +39,7 @@
"saving": "Saving...",
"saved": "Saved successfully",
"error": "Operation failed",
"warning_backup": "Backup creation failed, save will proceed without backup",
"deleted": "Deleted {{count}} path(s)",
"loaded": "Loaded {{sysCount}} system and {{userCount}} user paths",
"dragFolderOnly": "Only folders can be dropped",
+1
View File
@@ -39,6 +39,7 @@
"saving": "正在保存...",
"saved": "保存成功",
"error": "操作失败",
"warning_backup": "备份创建失败,保存将继续但不生成备份",
"deleted": "已删除 {{count}} 个路径",
"loaded": "已加载 {{sysCount}} 个系统路径和 {{userCount}} 个用户路径",
"dragFolderOnly": "只能拖拽文件夹",
+84 -51
View File
@@ -11,6 +11,8 @@ interface AppState {
sysPaths: string[];
userPaths: string[];
undoRedo: UndoRedoManager;
_savedSys: string[]; // 上次保存时的快照,用于 isModified 判断
_savedUser: string[];
activeTab: TabId;
searchQuery: string;
@@ -19,6 +21,7 @@ interface AppState {
statusMessage: string;
isModified: boolean;
isLoading: boolean;
isSaving: boolean;
setActiveTab: (tab: TabId) => void;
setSearchQuery: (query: string) => void;
@@ -41,14 +44,21 @@ interface AppState {
loadPaths: () => Promise<void>;
savePaths: () => Promise<void>;
initialize: () => Promise<void>;
_markDirty: () => void;
}
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
export const useAppStore = create<AppState>((set, get) => ({
sysPaths: [],
userPaths: [],
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
_savedSys: [],
_savedUser: [],
activeTab: 'system',
searchQuery: '',
@@ -57,6 +67,7 @@ export const useAppStore = create<AppState>((set, get) => ({
statusMessage: '',
isModified: false,
isLoading: true,
isSaving: false,
setActiveTab: (tab) => set({ activeTab: tab }),
setSearchQuery: (query) => set({ searchQuery: query }),
@@ -71,8 +82,9 @@ export const useAppStore = create<AppState>((set, get) => ({
type: OperationType.ADD, target, index: newList.length - 1, count: 1,
oldPaths: [], newPaths: [path],
});
if (target === TargetType.SYSTEM) set({ sysPaths: newList, isModified: true });
else set({ userPaths: newList, isModified: true });
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
else set({ userPaths: newList });
get()._markDirty();
},
editPath: (index, newPath, target) => {
@@ -86,8 +98,9 @@ export const useAppStore = create<AppState>((set, get) => ({
});
const newList = [...list];
newList[index] = newPath;
if (target === TargetType.SYSTEM) set({ sysPaths: newList, isModified: true });
else set({ userPaths: newList, isModified: true });
if (target === TargetType.SYSTEM) set({ sysPaths: newList });
else set({ userPaths: newList });
get()._markDirty();
},
deletePaths: (indices, target) => {
@@ -95,18 +108,20 @@ export const useAppStore = create<AppState>((set, get) => ({
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const sorted = [...indices].sort((a, b) => b - a);
const oldPaths = sorted.map((i) => list[i]);
for (const idx of sorted) {
state.undoRedo.push({
type: OperationType.DELETE, target, index: idx, count: 1,
oldPaths: [list[idx]], newPaths: [],
});
}
// 单条撤销记录覆盖全部删除
state.undoRedo.push({
type: OperationType.DELETE, target,
index: sorted[sorted.length - 1], count: sorted.length,
oldPaths, newPaths: [],
});
const toRemove = new Set(sorted);
const newList = list.filter((_, i) => !toRemove.has(i));
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [], isModified: true });
else set({ userPaths: newList, selectedIndices: [], isModified: true });
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [] });
else set({ userPaths: newList, selectedIndices: [] });
get()._markDirty();
},
moveUp: (index, target) => {
@@ -114,13 +129,13 @@ export const useAppStore = create<AppState>((set, get) => ({
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
state.undoRedo.push({
type: OperationType.MOVE_UP, target, index, count: 1,
oldPaths: [], newPaths: [],
type: OperationType.MOVE_UP, target, index, count: 1, oldPaths: [], newPaths: [],
});
const newList = [...list];
[newList[index - 1], newList[index]] = [newList[index], newList[index - 1]];
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index - 1], isModified: true });
else set({ userPaths: newList, selectedIndices: [index - 1], isModified: true });
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index - 1] });
else set({ userPaths: newList, selectedIndices: [index - 1] });
get()._markDirty();
},
moveDown: (index, target) => {
@@ -128,13 +143,13 @@ export const useAppStore = create<AppState>((set, get) => ({
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
if (index >= list.length - 1) return;
state.undoRedo.push({
type: OperationType.MOVE_DOWN, target, index, count: 1,
oldPaths: [], newPaths: [],
type: OperationType.MOVE_DOWN, target, index, count: 1, oldPaths: [], newPaths: [],
});
const newList = [...list];
[newList[index], newList[index + 1]] = [newList[index + 1], newList[index]];
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index + 1], isModified: true });
else set({ userPaths: newList, selectedIndices: [index + 1], isModified: true });
if (target === TargetType.SYSTEM) set({ sysPaths: newList, selectedIndices: [index + 1] });
else set({ userPaths: newList, selectedIndices: [index + 1] });
get()._markDirty();
},
cleanPaths: (target, validateFn) => {
@@ -147,8 +162,9 @@ export const useAppStore = create<AppState>((set, get) => ({
type: OperationType.CLEAN, target, index: 0, count: removed.length,
oldPaths: [...list], newPaths: kept,
});
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [], isModified: true });
else set({ userPaths: kept, selectedIndices: [], isModified: true });
if (target === TargetType.SYSTEM) set({ sysPaths: kept, selectedIndices: [] });
else set({ userPaths: kept, selectedIndices: [] });
get()._markDirty();
}
return removed;
@@ -158,15 +174,15 @@ export const useAppStore = create<AppState>((set, get) => ({
if (importPaths.length === 0) return;
const state = get();
const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths;
const copied = [...importPaths];
state.undoRedo.push({
type: OperationType.IMPORT, target, index: 0, count: copied.length,
oldPaths: [...list], newPaths: copied,
type: OperationType.IMPORT, target, index: 0, count: importPaths.length,
oldPaths: [...list], newPaths: [...importPaths],
});
if (target === TargetType.SYSTEM) set({ sysPaths: copied, selectedIndices: [], isModified: true });
else set({ userPaths: copied, selectedIndices: [], isModified: true });
if (target === TargetType.SYSTEM) set({ sysPaths: [...importPaths], selectedIndices: [] });
else set({ userPaths: [...importPaths], selectedIndices: [] });
get()._markDirty();
},
clearPaths: (target) => {
@@ -179,20 +195,36 @@ export const useAppStore = create<AppState>((set, get) => ({
oldPaths: [...list], newPaths: [],
});
if (target === TargetType.SYSTEM) set({ sysPaths: [], isModified: true });
else set({ userPaths: [], isModified: true });
if (target === TargetType.SYSTEM) set({ sysPaths: [] });
else set({ userPaths: [] });
get()._markDirty();
},
undo: () => {
const { undoRedo, sysPaths, userPaths } = get();
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
const result = undoRedo.undo(sysPaths, userPaths);
if (result) set({ sysPaths: result[0], userPaths: result[1], isModified: true, selectedIndices: [] });
if (result) {
set({
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
});
}
},
redo: () => {
const { undoRedo, sysPaths, userPaths } = get();
const { undoRedo, sysPaths, userPaths, _savedSys, _savedUser } = get();
const result = undoRedo.redo(sysPaths, userPaths);
if (result) set({ sysPaths: result[0], userPaths: result[1], isModified: true, selectedIndices: [] });
if (result) {
set({
sysPaths: result[0], userPaths: result[1], selectedIndices: [],
isModified: !(arraysEqual(result[0], _savedSys) && arraysEqual(result[1], _savedUser)),
});
}
},
_markDirty: () => {
const { _savedSys, _savedUser, sysPaths, userPaths } = get();
set({ isModified: !(arraysEqual(sysPaths, _savedSys) && arraysEqual(userPaths, _savedUser)) });
},
canUndo: () => get().undoRedo.canUndo(),
@@ -206,11 +238,10 @@ export const useAppStore = create<AppState>((set, get) => ({
invoke<string[]>('load_user_paths'),
]);
set({
sysPaths: sysArr,
userPaths: userArr,
sysPaths: sysArr, userPaths: userArr,
_savedSys: [...sysArr], _savedUser: [...userArr],
undoRedo: new UndoRedoManager(appConfig.undo.maxHistory),
isLoading: false,
isModified: false,
isLoading: false, isModified: false,
statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }),
});
} catch (e) {
@@ -219,21 +250,23 @@ export const useAppStore = create<AppState>((set, get) => ({
},
savePaths: async () => {
const { sysPaths, userPaths } = get();
const state = get();
if (state.isSaving) return;
set({ isSaving: true, statusMessage: i18n.t('status.saving') });
const { sysPaths, userPaths } = state;
const sysJoined = sysPaths.join(';');
const userJoined = userPaths.join(';');
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('PATH 长度超过建议值,是否继续保存?')) { set({ isSaving: false }); return; }
}
set({ statusMessage: i18n.t('status.saving') });
// 备份(失败时通知用户)
invoke('backup_registry', { customDir: null, sysPaths, userPaths })
.catch(() => set({ statusMessage: i18n.t('status.warning_backup') }));
// 备份(不阻塞保存)
invoke('backup_registry', { customDir: null, sysPaths, userPaths }).catch(() => {});
// 并行保存
const [sysResult, userResult] = await Promise.allSettled([
invoke('save_system_paths', { paths: sysPaths }),
invoke('save_user_paths', { paths: userPaths }),
@@ -244,13 +277,13 @@ export const useAppStore = create<AppState>((set, get) => ({
if (sysOk && userOk) {
invoke('broadcast_env_change').catch(() => {});
set({ isModified: false, statusMessage: i18n.t('status.saved') });
} else if (sysOk) {
set({ statusMessage: '用户 PATH 保存失败,系统 PATH 已保存' });
} else if (userOk) {
set({ statusMessage: '系统 PATH 保存失败,用户 PATH 已保存' });
const savedSys = [...sysPaths], savedUser = [...userPaths];
set({ isModified: false, isSaving: false, statusMessage: i18n.t('status.saved'), _savedSys: savedSys, _savedUser: savedUser });
} else {
set({ statusMessage: `${i18n.t('status.error')}: 保存失败` });
const reason = (!sysOk && sysResult.status === 'rejected') ? String(sysResult.reason) :
(!userOk && userResult.status === 'rejected') ? String(userResult.reason) : '';
const msg = sysOk ? '用户 PATH 保存失败' : userOk ? '系统 PATH 保存失败' : `保存失败: ${reason}`;
set({ isSaving: false, statusMessage: msg });
}
},