Selaa lähdekoodia

Merge branch 'shujubeifen'

# Conflicts:
#	src/components/chat/chat-model-selector.tsx
Mochocyang 2 kuukautta sitten
vanhempi
sitoutus
27df99bccd

+ 109 - 2
src-tauri/src/commands/backup.rs

@@ -58,6 +58,7 @@ pub struct ImportParams {
     pub zip_path: String,
     pub strategy: ImportStrategy,
     pub projects: Option<Vec<ProjectRestoreInfo>>,
+    pub project_path_overrides: Option<std::collections::HashMap<String, String>>,
 }
 
 #[derive(Debug, Clone, Serialize)]
@@ -89,6 +90,15 @@ pub struct BackupManifest {
     pub projects: Vec<ProjectBackupInfo>,
 }
 
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ProjectManifestEntry {
+    pub id: String,
+    pub path: String,
+    pub name: String,
+    pub path_accessible: bool,
+}
+
 #[derive(Debug, Clone, Serialize)]
 #[serde(rename_all = "camelCase")]
 pub struct BackupProgressPayload {
@@ -99,7 +109,7 @@ pub struct BackupProgressPayload {
     pub message: String,
 }
 
-const PROJECT_SUBDIRS: &[&str] = &[".qmai", ".novel", "book-analysis", "raw"];
+const PROJECT_SUBDIRS: &[&str] = &[".qmai", ".novel", "book-analysis", "raw", ".trash"];
 const PROJECT_FILES: &[&str] = &["soul.md", "schema.md", "purpose.md"];
 
 // 知识目录的可能名称(新版用 QM,旧版用 wiki),导出时统一以 wiki 名称存入 zip
@@ -290,6 +300,29 @@ fn extract_dir_from_zip(
     Ok(count)
 }
 
+/// 检查项目路径的根(盘符/根目录)是否可达。
+/// Windows: 检查盘符是否存在(如 "D:\" 存在)。
+/// Unix: 根目录 "/" 始终可达。
+fn is_path_root_accessible(path: &str) -> bool {
+    let p = Path::new(path);
+    match p.components().next() {
+        Some(std::path::Component::Prefix(prefix)) => {
+            // Windows 驱动器前缀(如 "C:" "D:")
+            let prefix_path = prefix.as_os_str();
+            let root = if prefix_path.to_string_lossy().len() == 2 {
+                // "D:" → "D:\"
+                format!("{}\\", prefix_path.to_string_lossy())
+            } else {
+                // UNC 路径等,直接检查
+                prefix_path.to_string_lossy().to_string()
+            };
+            Path::new(&root).exists()
+        }
+        Some(std::path::Component::RootDir) => true,
+        _ => true,
+    }
+}
+
 // ── Core logic (Tauri-agnostic) ──────────────────────────────────
 
 /// Core export backup logic.
@@ -588,7 +621,15 @@ pub fn do_import_backup<F: Fn(&BackupProgressPayload)>(
             ImportStrategy::Full => {
                 manifest_projects
                     .iter()
-                    .map(|p| (p.id.clone(), p.path.clone(), p.name.clone()))
+                    .map(|p| {
+                        let path = params
+                            .project_path_overrides
+                            .as_ref()
+                            .and_then(|m| m.get(&p.id))
+                            .cloned()
+                            .unwrap_or_else(|| p.path.clone());
+                        (p.id.clone(), path, p.name.clone())
+                    })
                     .collect()
             }
             ImportStrategy::Selective => params
@@ -679,6 +720,38 @@ pub fn do_import_backup<F: Fn(&BackupProgressPayload)>(
     })
 }
 
+/// 读取备份文件中的 manifest.json,返回项目列表及路径可达性。
+/// 不做实际解压,供前端在导入前检查路径。
+pub fn do_read_backup_manifest(zip_path: &Path) -> Result<Vec<ProjectManifestEntry>, String> {
+    if !zip_path.exists() {
+        return Err("备份文件不存在".to_string());
+    }
+
+    let file = fs::File::open(zip_path)
+        .map_err(|e| format!("打开备份文件失败: {e}"))?;
+    let mut archive = zip::ZipArchive::new(file)
+        .map_err(|e| format!("读取备份文件失败,可能已损坏: {e}"))?;
+
+    let manifest_bytes = extract_file_from_zip(&mut archive, "manifest.json")?
+        .ok_or_else(|| "备份文件缺少 manifest.json".to_string())?;
+
+    let manifest: BackupManifest = serde_json::from_slice(&manifest_bytes)
+        .map_err(|e| format!("解析 manifest.json 失败: {e}"))?;
+
+    let entries = manifest
+        .projects
+        .iter()
+        .map(|p| ProjectManifestEntry {
+            id: p.id.clone(),
+            path: p.path.clone(),
+            name: p.name.clone(),
+            path_accessible: is_path_root_accessible(&p.path),
+        })
+        .collect();
+
+    Ok(entries)
+}
+
 // ── Tauri commands ───────────────────────────────────────────────
 
 #[tauri::command]
@@ -739,6 +812,14 @@ pub async fn import_backup(
     })
 }
 
+#[tauri::command]
+pub async fn read_backup_manifest(zip_path: String) -> Result<Vec<ProjectManifestEntry>, String> {
+    run_guarded("read_backup_manifest", || {
+        let path = Path::new(&zip_path);
+        do_read_backup_manifest(path)
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -798,4 +879,30 @@ mod tests {
 
         std::fs::remove_dir_all(&tmp).ok();
     }
+
+    #[test]
+    fn test_trash_in_project_subdirs() {
+        assert!(
+            PROJECT_SUBDIRS.contains(&".trash"),
+            ".trash 应包含在 PROJECT_SUBDIRS 中"
+        );
+    }
+
+    #[test]
+    fn test_is_path_root_accessible_windows_drive() {
+        // C 盘在 Windows 上始终存在
+        assert!(is_path_root_accessible("C:\\some\\path"));
+    }
+
+    #[test]
+    fn test_is_path_root_accessible_unix_root() {
+        // Unix 根目录始终可达
+        assert!(is_path_root_accessible("/home/user/project"));
+    }
+
+    #[test]
+    fn test_is_path_root_accessible_nonexistent_drive() {
+        // Z 盘大概率不存在
+        assert!(!is_path_root_accessible("Z:\\nonexistent\\path"));
+    }
 }

+ 1 - 0
src-tauri/src/lib.rs

@@ -119,6 +119,7 @@ pub fn run() {
             commands::file_sync::ignore_file_change_task,
             commands::backup::export_backup,
             commands::backup::import_backup,
+            commands::backup::read_backup_manifest,
             set_proxy_env,
         ])
         .on_window_event(|window, event| {

+ 14 - 8
src/components/chat/chat-model-selector.tsx

@@ -105,12 +105,13 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
       const availableBelow = window.innerHeight - rect.bottom
       let top: number
       let maxHeight: number
-      if (availableBelow < DROPDOWN_MAX_HEIGHT + DROPDOWN_GAP && availableAbove >= DROPDOWN_MAX_HEIGHT + DROPDOWN_GAP) {
-        top = rect.top - DROPDOWN_MAX_HEIGHT - DROPDOWN_GAP
-        maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, Math.max(DROPDOWN_MIN_HEIGHT, availableAbove - DROPDOWN_GAP))
+      // 始终优先放下方,只有下方空间不足最小高度时才翻转到上方
+      if (availableBelow < DROPDOWN_MIN_HEIGHT && availableAbove >= DROPDOWN_MIN_HEIGHT) {
+        maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, availableAbove - DROPDOWN_GAP)
+        top = rect.top - maxHeight - DROPDOWN_GAP
       } else {
-        top = rect.bottom + DROPDOWN_GAP
         maxHeight = Math.min(DROPDOWN_MAX_HEIGHT, Math.max(DROPDOWN_MIN_HEIGHT, availableBelow - DROPDOWN_GAP))
+        top = rect.bottom + DROPDOWN_GAP
       }
       setDropdownStyle({
         left: Math.min(rect.left, window.innerWidth - width - 4),
@@ -119,9 +120,12 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
         maxHeight,
       })
     }
-    updatePosition()
+    const raf = requestAnimationFrame(updatePosition)
     window.addEventListener("resize", updatePosition)
-    return () => window.removeEventListener("resize", updatePosition)
+    return () => {
+      cancelAnimationFrame(raf)
+      window.removeEventListener("resize", updatePosition)
+    }
   }, [open])
 
   return (
@@ -143,17 +147,19 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
       {open && dropdownStyle && createPortal(
         <>
           <div
-            className="fixed inset-0 z-40"
+            className="fixed inset-0"
+            style={{ zIndex: 9998 }}
             onClick={() => setOpen(false)}
           />
           <div
-            className="fixed z-50 rounded-md border bg-popover p-1 shadow-md model-selector-dropdown"
+            className="fixed rounded-md border bg-popover p-1 shadow-md model-selector-dropdown"
             style={{
               left: dropdownStyle.left,
               top: dropdownStyle.top,
               width: dropdownStyle.width,
               maxHeight: dropdownStyle.maxHeight,
               overflowY: "auto",
+              zIndex: 9999,
             }}
           >
             {modelGroups.map((group, groupIdx) => (

+ 117 - 1
src/components/settings/sections/data-management-section.tsx

@@ -6,15 +6,18 @@ import {
   Loader2,
   AlertTriangle,
   CheckCircle2,
+  ListChecks,
 } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { exportBackup } from "@/lib/backup/export"
-import { importBackup } from "@/lib/backup/import"
+import { importBackup, readBackupManifest, selectBackupFile } from "@/lib/backup/import"
 import type {
   ExportResult,
   ImportResult,
   ImportStrategy,
   BackupProgressPayload,
+  ProjectManifestEntry,
+  ProjectRestoreInfo,
 } from "@/lib/backup/types"
 
 export function DataManagementSection() {
@@ -25,6 +28,10 @@ export function DataManagementSection() {
   const [importResult, setImportResult] = useState<ImportResult | null>(null)
   const [importStrategy, setImportStrategy] = useState<ImportStrategy>("full")
   const [progress, setProgress] = useState<BackupProgressPayload | null>(null)
+  const [showProjectSelect, setShowProjectSelect] = useState(false)
+  const [manifestProjects, setManifestProjects] = useState<ProjectManifestEntry[]>([])
+  const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
+  const [pendingZipPath, setPendingZipPath] = useState<string>("")
 
   const handleProgress = useCallback((payload: BackupProgressPayload) => {
     setProgress(payload)
@@ -52,6 +59,30 @@ export function DataManagementSection() {
   }
 
   async function handleImport() {
+    if (importStrategy === "selective") {
+      // 选择性导入:先选文件,再读 manifest,再弹项目选择
+      const zipPath = await selectBackupFile()
+      if (!zipPath) return
+
+      try {
+        const manifest = await readBackupManifest(zipPath)
+        setManifestProjects(manifest)
+        setSelectedProjectIds(new Set(manifest.map((p) => p.id)))
+        setPendingZipPath(zipPath)
+        setShowProjectSelect(true)
+      } catch {
+        setImportResult({
+          success: false,
+          appState: null,
+          localStorageData: null,
+          projects: [],
+          warnings: [],
+          error: "读取备份文件失败,文件可能已损坏",
+        })
+      }
+      return
+    }
+
     setIsImporting(true)
     setImportResult(null)
     setProgress(null)
@@ -73,6 +104,46 @@ export function DataManagementSection() {
     }
   }
 
+  async function handleSelectiveImport() {
+    setShowProjectSelect(false)
+    setIsImporting(true)
+    setImportResult(null)
+    setProgress(null)
+
+    const selectedProjects: ProjectRestoreInfo[] = manifestProjects
+      .filter((p) => selectedProjectIds.has(p.id))
+      .map((p) => ({ id: p.id, targetPath: p.path }))
+
+    try {
+      const result = await importBackup("selective", selectedProjects, handleProgress, pendingZipPath)
+      setImportResult(result)
+    } catch (err) {
+      setImportResult({
+        success: false,
+        appState: null,
+        localStorageData: null,
+        projects: [],
+        warnings: [],
+        error: String(err),
+      })
+    } finally {
+      setIsImporting(false)
+      setProgress((p) => (p && p.stage === "done" ? p : null))
+    }
+  }
+
+  function toggleProject(id: string) {
+    setSelectedProjectIds((prev) => {
+      const next = new Set(prev)
+      if (next.has(id)) {
+        next.delete(id)
+      } else {
+        next.add(id)
+      }
+      return next
+    })
+  }
+
   const isBusy = isExporting || isImporting
 
   return (
@@ -183,6 +254,7 @@ export function DataManagementSection() {
           <div className="space-y-1">
             {([
               { value: "full" as const, label: "完全覆盖(清除当前所有数据)" },
+              { value: "selective" as const, label: "选择性导入(仅恢复选中的项目)" },
               { value: "global-only" as const, label: "仅导入全局配置(模型、UI偏好)" },
             ]).map((opt) => (
               <label key={opt.value} className="flex items-center gap-2 text-sm cursor-pointer">
@@ -255,6 +327,50 @@ export function DataManagementSection() {
         )}
       </div>
 
+      {/* 选择性导入项目选择弹窗 */}
+      {showProjectSelect && manifestProjects.length > 0 && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+          <div className="mx-4 w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg">
+            <div className="flex items-center gap-2 mb-4">
+              <ListChecks className="h-5 w-5 text-primary" />
+              <h3 className="text-lg font-semibold">选择要恢复的项目</h3>
+            </div>
+            <p className="text-sm text-muted-foreground mb-4">
+              请勾选需要恢复的项目,未勾选的项目将不会被导入。
+            </p>
+            <div className="max-h-64 space-y-2 overflow-y-auto border rounded-lg p-3">
+              {manifestProjects.map((p) => (
+                <label key={p.id} className="flex items-center gap-3 text-sm cursor-pointer hover:bg-muted rounded px-2 py-1.5">
+                  <input
+                    type="checkbox"
+                    checked={selectedProjectIds.has(p.id)}
+                    onChange={() => toggleProject(p.id)}
+                    className="cursor-pointer"
+                  />
+                  <div className="flex-1 min-w-0">
+                    <p className="font-medium truncate">{p.name}</p>
+                    <p className="text-xs text-muted-foreground truncate">{p.path}</p>
+                  </div>
+                </label>
+              ))}
+            </div>
+            <div className="flex items-center justify-between mt-4">
+              <p className="text-xs text-muted-foreground">
+                已选择 {selectedProjectIds.size} / {manifestProjects.length} 个项目
+              </p>
+              <div className="flex gap-2">
+                <Button variant="outline" onClick={() => setShowProjectSelect(false)}>
+                  取消
+                </Button>
+                <Button onClick={handleSelectiveImport} disabled={selectedProjectIds.size === 0}>
+                  开始恢复
+                </Button>
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
+
       <div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-900 dark:bg-yellow-950 dark:text-yellow-200">
         <div className="flex items-start gap-2">
           <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />

+ 88 - 5
src/lib/backup/import.ts

@@ -10,6 +10,7 @@ import type {
   ImportResult,
   ImportStrategy,
   ProjectRestoreInfo,
+  ProjectManifestEntry,
   BackupProgressCallback,
 } from "./types"
 
@@ -31,17 +32,98 @@ async function refreshCurrentProjectIfNeeded(restoredProjects: Array<{ path: str
   }
 }
 
+/**
+ * 导入前读取备份 manifest,检查路径可达性。
+ * 如果有项目路径的盘符不存在,弹窗让用户选择新目录并构建路径重映射表。
+ * @returns 重映射表(projectId -> 新路径),如果无需重映射则返回空对象。
+ *          如果用户取消选择,返回 null。
+ */
+async function checkAndRemapPaths(zipPath: string): Promise<Record<string, string> | null> {
+  let manifest: ProjectManifestEntry[]
+  try {
+    manifest = await invoke<ProjectManifestEntry[]>("read_backup_manifest", { zipPath })
+  } catch {
+    // manifest 读取失败,回退到原行为(直接导入)
+    return {}
+  }
+
+  const inaccessibleProjects = manifest.filter((p) => !p.pathAccessible)
+
+  if (inaccessibleProjects.length === 0) {
+    // 所有路径可达,无需重映射
+    return {}
+  }
+
+  // 构建不可达项目列表描述
+  const projectList = inaccessibleProjects
+    .map((p) => `  · ${p.name}(原路径: ${p.path})`)
+    .join("\n")
+
+  // 弹窗让用户选择新的基础目录
+  const newBaseDir = await open({
+    title: `以下 ${inaccessibleProjects.length} 个项目路径不可用,请选择新的存放目录:\n${projectList}`,
+    directory: true,
+    multiple: false,
+  })
+
+  if (!newBaseDir || typeof newBaseDir !== "string") {
+    // 用户取消
+    return null
+  }
+
+  // 构建重映射表:{ projectId: "{新目录}/{原项目文件夹名}" }
+  const overrides: Record<string, string> = {}
+  for (const project of inaccessibleProjects) {
+    const folderName = project.path.split(/[\\/]/).filter(Boolean).pop() || project.id
+    overrides[project.id] = `${newBaseDir}\\${folderName}`
+  }
+
+  return overrides
+}
+
+/**
+ * 读取备份文件的 manifest,返回项目列表。
+ * 供 UI 组件在导入前预览项目列表使用。
+ */
+export async function readBackupManifest(zipPath: string): Promise<ProjectManifestEntry[]> {
+  return await invoke<ProjectManifestEntry[]>("read_backup_manifest", { zipPath })
+}
+
+/**
+ * 打开文件选择对话框选择备份文件。
+ */
+export async function selectBackupFile(): Promise<string | null> {
+  const zipPath = await open({
+    filters: [{ name: "ZIP 备份文件", extensions: ["zip"] }],
+    multiple: false,
+  })
+  return typeof zipPath === "string" ? zipPath : null
+}
+
 export async function importBackup(
   strategy: ImportStrategy,
   projects?: ProjectRestoreInfo[],
   onProgress?: BackupProgressCallback,
+  zipPath?: string,
 ): Promise<ImportResult> {
-  const zipPath = await open({
-    filters: [{ name: "ZIP 备份文件", extensions: ["zip"] }],
-    multiple: false,
-  })
+  // 如果没有传入 zipPath,则弹出文件选择对话框
+  if (!zipPath) {
+    zipPath = await selectBackupFile() ?? undefined
+    if (!zipPath) {
+      return {
+        success: false,
+        appState: null,
+        localStorageData: null,
+        projects: [],
+        warnings: [],
+        error: "用户取消了导入",
+      }
+    }
+  }
 
-  if (!zipPath || typeof zipPath !== "string") {
+  // 导入前检查路径可达性,必要时弹窗让用户选择新目录
+  const pathOverrides = await checkAndRemapPaths(zipPath)
+  if (pathOverrides === null) {
     return {
       success: false,
       appState: null,
@@ -56,6 +138,7 @@ export async function importBackup(
     zipPath,
     strategy,
     projects,
+    projectPathOverrides: Object.keys(pathOverrides).length > 0 ? pathOverrides : undefined,
   }
 
   let unlisten: UnlistenFn | undefined

+ 9 - 0
src/lib/backup/types.ts

@@ -35,6 +35,7 @@ export interface ImportParams {
   zipPath: string
   strategy: ImportStrategy
   projects?: ProjectRestoreInfo[]
+  projectPathOverrides?: Record<string, string>
 }
 
 /** 项目恢复结果 */
@@ -64,6 +65,14 @@ export interface BackupManifest {
   projects: ProjectBackupInfo[]
 }
 
+/** manifest 项目条目(含路径可达性) */
+export interface ProjectManifestEntry {
+  id: string
+  path: string
+  name: string
+  pathAccessible: boolean
+}
+
 /** 进度事件载荷 */
 export interface BackupProgressPayload {
   operation: "export" | "import"