فهرست منبع

feat: 导入弹窗显示不可达项目列表 + 选择性导入 UI

Mochocyang 3 ماه پیش
والد
کامیت
12009f820b
2فایلهای تغییر یافته به همراه155 افزوده شده و 15 حذف شده
  1. 117 1
      src/components/settings/sections/data-management-section.tsx
  2. 38 14
      src/lib/backup/import.ts

+ 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" />

+ 38 - 14
src/lib/backup/import.ts

@@ -54,9 +54,14 @@ async function checkAndRemapPaths(zipPath: string): Promise<Record<string, strin
     return {}
   }
 
+  // 构建不可达项目列表描述
+  const projectList = inaccessibleProjects
+    .map((p) => `  · ${p.name}(原路径: ${p.path})`)
+    .join("\n")
+
   // 弹窗让用户选择新的基础目录
   const newBaseDir = await open({
-    title: "部分项目路径不存在(原盘符不可用),请选择新的存放目录",
+    title: `以下 ${inaccessibleProjects.length} 个项目路径不可用,请选择新的存放目录:\n${projectList}`,
     directory: true,
     multiple: false,
   })
@@ -76,24 +81,43 @@ async function checkAndRemapPaths(zipPath: string): Promise<Record<string, strin
   return overrides
 }
 
-export async function importBackup(
-  strategy: ImportStrategy,
-  projects?: ProjectRestoreInfo[],
-  onProgress?: BackupProgressCallback,
-): Promise<ImportResult> {
+/**
+ * 读取备份文件的 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
+}
 
-  if (!zipPath || typeof zipPath !== "string") {
-    return {
-      success: false,
-      appState: null,
-      localStorageData: null,
-      projects: [],
-      warnings: [],
-      error: "用户取消了导入",
+export async function importBackup(
+  strategy: ImportStrategy,
+  projects?: ProjectRestoreInfo[],
+  onProgress?: BackupProgressCallback,
+  zipPath?: string,
+): Promise<ImportResult> {
+  // 如果没有传入 zipPath,则弹出文件选择对话框
+  if (!zipPath) {
+    zipPath = await selectBackupFile() ?? undefined
+    if (!zipPath) {
+      return {
+        success: false,
+        appState: null,
+        localStorageData: null,
+        projects: [],
+        warnings: [],
+        error: "用户取消了导入",
+      }
     }
   }