Browse Source

merge: 合并 ZIP 备份内容选择与 V2 清单部分合并恢复

Mochocyang 2 months ago
parent
commit
659f3a8d98

File diff suppressed because it is too large
+ 885 - 112
src-tauri/src/commands/backup.rs


+ 107 - 0
src/components/settings/sections/backup-export-dialog.spec.tsx

@@ -0,0 +1,107 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { ProjectBackupInfo } from "@/lib/backup/types"
+import { BackupExportDialog } from "./backup-export-dialog"
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true
+
+const projects: ProjectBackupInfo[] = [
+  { id: "p1", name: "长安夜雨", path: "C:/Novel" },
+  { id: "p2", name: "旧梦", path: "C:/Old" },
+]
+
+describe("数据管理备份选择弹窗", () => {
+  let host: HTMLDivElement
+  let root: Root
+  const onConfirm = vi.fn()
+
+  beforeEach(async () => {
+    host = document.createElement("div")
+    document.body.appendChild(host)
+    root = createRoot(host)
+    await act(async () => {
+      root.render(
+        <BackupExportDialog
+          open
+          projects={projects}
+          exporting={false}
+          onOpenChange={vi.fn()}
+          onConfirm={onConfirm}
+        />,
+      )
+    })
+  })
+
+  afterEach(async () => {
+    await act(async () => root.unmount())
+    host.remove()
+    vi.clearAllMocks()
+  })
+
+  it("完整备份默认全选并保证弹窗内容可滚动", () => {
+    expect(document.body.textContent).toContain("选择备份内容")
+    expect(document.querySelector('[data-slot="dialog-content"]')?.className).toContain("max-h-[85vh]")
+    expect(document.querySelector('[data-backup-export-scroll]')?.className).toContain("overflow-y-auto")
+    expect((document.querySelector('[aria-label="完整备份"]') as HTMLInputElement).checked).toBe(true)
+    expect((document.querySelector('[aria-label="API Key 和其他凭据"]') as HTMLInputElement).checked).toBe(true)
+    expect(document.querySelectorAll('[data-backup-project] input:checked')).toHaveLength(2)
+    expect(document.querySelectorAll('[data-backup-section] input:checked')).toHaveLength(5)
+  })
+
+  it("自定义备份默认排除凭据并只提交用户选中的项目和类别", async () => {
+    const custom = document.querySelector('[aria-label="自定义备份"]') as HTMLInputElement
+    await act(async () => custom.click())
+
+    const credentials = document.querySelector('[aria-label="API Key 和其他凭据"]') as HTMLInputElement
+    expect(credentials.checked).toBe(false)
+
+    const oldProject = document.querySelector('[aria-label="导出项目 旧梦"]') as HTMLInputElement
+    const memory = document.querySelector('[aria-label="结构化记忆与章节快照"]') as HTMLInputElement
+    await act(async () => {
+      oldProject.click()
+      memory.click()
+    })
+
+    const confirm = [...document.querySelectorAll("button")].find((button) => button.textContent === "开始导出")!
+    await act(async () => confirm.click())
+
+    expect(onConfirm).toHaveBeenCalledWith({
+      includeGlobalConfig: true,
+      includeUiPreferences: true,
+      includeCredentials: false,
+      projects: [{
+        ...projects[0],
+        sections: ["content", "analysis", "indexes", "trash"],
+      }],
+    })
+  })
+
+  it("没有选择任何内容时禁止导出,包含凭据时显示风险提示", async () => {
+    await act(async () => (document.querySelector('[aria-label="自定义备份"]') as HTMLInputElement).click())
+    await act(async () => {
+      ;(document.querySelector('[aria-label="模型与 AI 配置"]') as HTMLInputElement).click()
+      ;(document.querySelector('[aria-label="界面与软件偏好"]') as HTMLInputElement).click()
+      ;(document.querySelector('[aria-label="取消选择全部项目"]') as HTMLButtonElement).click()
+    })
+
+    const confirm = [...document.querySelectorAll("button")].find((button) => button.textContent === "开始导出") as HTMLButtonElement
+    expect(confirm.disabled).toBe(true)
+
+    await act(async () => (document.querySelector('[aria-label="API Key 和其他凭据"]') as HTMLInputElement).click())
+    expect(document.body.textContent).toContain("备份文件未加密")
+  })
+
+  it("取消全部项目类别时不提交空项目条目", async () => {
+    await act(async () => (document.querySelector('[aria-label="自定义备份"]') as HTMLInputElement).click())
+    for (const input of document.querySelectorAll<HTMLInputElement>('[data-backup-section] input:checked')) {
+      await act(async () => input.click())
+    }
+
+    const confirm = [...document.querySelectorAll("button")].find((button) => button.textContent === "开始导出")!
+    await act(async () => confirm.click())
+
+    expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ projects: [] }))
+  })
+})

+ 305 - 0
src/components/settings/sections/backup-export-dialog.tsx

@@ -0,0 +1,305 @@
+import { useEffect, useState } from "react"
+import { Archive, ShieldAlert } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import type {
+  BackupExportOptions,
+  ProjectBackupInfo,
+  ProjectBackupSection,
+} from "@/lib/backup/types"
+
+interface BackupExportDialogProps {
+  open: boolean
+  projects: ProjectBackupInfo[]
+  exporting: boolean
+  onOpenChange: (open: boolean) => void
+  onConfirm: (options: BackupExportOptions) => void
+}
+
+type BackupMode = "full" | "projects-only" | "custom"
+
+const ALL_SECTIONS: ProjectBackupSection[] = [
+  "content",
+  "memory",
+  "analysis",
+  "indexes",
+  "trash",
+]
+
+const SECTION_LABELS: Record<ProjectBackupSection, string> = {
+  content: "项目正文与资料",
+  memory: "结构化记忆与章节快照",
+  analysis: "拆书、推演与 AI 工作数据",
+  indexes: "向量与检索索引",
+  trash: "回收站",
+}
+
+export function BackupExportDialog({
+  open,
+  projects,
+  exporting,
+  onOpenChange,
+  onConfirm,
+}: BackupExportDialogProps) {
+  const [mode, setMode] = useState<BackupMode>("full")
+  const [includeGlobalConfig, setIncludeGlobalConfig] = useState(true)
+  const [includeUiPreferences, setIncludeUiPreferences] = useState(true)
+  const [includeCredentials, setIncludeCredentials] = useState(true)
+  const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(
+    () => new Set(projects.map((project) => project.id)),
+  )
+  const [selectedSections, setSelectedSections] = useState<Set<ProjectBackupSection>>(
+    () => new Set(ALL_SECTIONS),
+  )
+
+  useEffect(() => {
+    if (!open) return
+    setMode("full")
+    setIncludeGlobalConfig(true)
+    setIncludeUiPreferences(true)
+    setIncludeCredentials(true)
+    setSelectedProjectIds(new Set(projects.map((project) => project.id)))
+    setSelectedSections(new Set(ALL_SECTIONS))
+  }, [open, projects])
+
+  function selectMode(nextMode: BackupMode) {
+    setMode(nextMode)
+    const projectIds = new Set(projects.map((project) => project.id))
+    setSelectedProjectIds(projectIds)
+    setSelectedSections(new Set(ALL_SECTIONS))
+
+    if (nextMode === "full") {
+      setIncludeGlobalConfig(true)
+      setIncludeUiPreferences(true)
+      setIncludeCredentials(true)
+      return
+    }
+    if (nextMode === "projects-only") {
+      setIncludeGlobalConfig(false)
+      setIncludeUiPreferences(false)
+      setIncludeCredentials(false)
+      return
+    }
+    setIncludeGlobalConfig(true)
+    setIncludeUiPreferences(true)
+    setIncludeCredentials(false)
+  }
+
+  function toggleProject(projectId: string) {
+    setSelectedProjectIds((current) => {
+      const next = new Set(current)
+      if (next.has(projectId)) next.delete(projectId)
+      else next.add(projectId)
+      return next
+    })
+  }
+
+  function toggleSection(section: ProjectBackupSection) {
+    setSelectedSections((current) => {
+      const next = new Set(current)
+      if (next.has(section)) next.delete(section)
+      else next.add(section)
+      return next
+    })
+  }
+
+  const hasProjectContent = selectedProjectIds.size > 0 && selectedSections.size > 0
+  const canExport = includeGlobalConfig || includeUiPreferences || hasProjectContent
+
+  function confirmExport() {
+    if (!canExport) return
+    const sections = ALL_SECTIONS.filter((section) => selectedSections.has(section))
+    onConfirm({
+      includeGlobalConfig,
+      includeUiPreferences,
+      includeCredentials,
+      projects: sections.length === 0
+        ? []
+        : projects
+          .filter((project) => selectedProjectIds.has(project.id))
+          .map((project) => ({ ...project, sections })),
+    })
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={(nextOpen) => !exporting && onOpenChange(nextOpen)}>
+      <DialogContent
+        showCloseButton={false}
+        className="flex max-h-[85vh] flex-col sm:max-w-[720px]"
+      >
+        <DialogHeader className="shrink-0">
+          <div className="flex items-center gap-2">
+            <Archive className="h-5 w-5 text-primary" />
+            <DialogTitle>选择备份内容</DialogTitle>
+          </div>
+          <DialogDescription>
+            选择需要写入 ZIP 的配置、项目和数据类别。未选择的内容不会在导入时被覆盖。
+          </DialogDescription>
+        </DialogHeader>
+
+        <div data-backup-export-scroll className="min-h-0 flex-1 space-y-5 overflow-y-auto pr-1">
+          <fieldset className="space-y-2">
+            <legend className="text-sm font-medium">备份模式</legend>
+            <div className="grid gap-2 sm:grid-cols-3">
+              {([
+                ["full", "完整备份"],
+                ["projects-only", "仅项目内容"],
+                ["custom", "自定义备份"],
+              ] as const).map(([value, label]) => (
+                <label
+                  key={value}
+                  className={`cursor-pointer border px-3 py-2 text-sm ${mode === value ? "border-primary bg-accent" : "hover:bg-accent/50"}`}
+                >
+                  <input
+                    type="radio"
+                    name="backup-mode"
+                    value={value}
+                    aria-label={label}
+                    checked={mode === value}
+                    onChange={() => selectMode(value)}
+                    disabled={exporting}
+                    className="mr-2"
+                  />
+                  {label}
+                </label>
+              ))}
+            </div>
+          </fieldset>
+
+          <fieldset className="space-y-2">
+            <legend className="text-sm font-medium">全局数据</legend>
+            <label className="flex items-start gap-3 border px-3 py-2 text-sm">
+              <input
+                type="checkbox"
+                aria-label="模型与 AI 配置"
+                checked={includeGlobalConfig}
+                onChange={(event) => setIncludeGlobalConfig(event.target.checked)}
+                disabled={exporting || mode !== "custom"}
+                className="mt-0.5"
+              />
+              <span>
+                <span className="block font-medium">模型与 AI 配置</span>
+                <span className="text-xs text-muted-foreground">模型供应商、Embedding、Rerank、搜索、MCP 和写作配置</span>
+              </span>
+            </label>
+            <label className="flex items-start gap-3 border px-3 py-2 text-sm">
+              <input
+                type="checkbox"
+                aria-label="界面与软件偏好"
+                checked={includeUiPreferences}
+                onChange={(event) => setIncludeUiPreferences(event.target.checked)}
+                disabled={exporting || mode !== "custom"}
+                className="mt-0.5"
+              />
+              <span>
+                <span className="block font-medium">界面与软件偏好</span>
+                <span className="text-xs text-muted-foreground">主题、语言、字体、侧栏、图谱显示和布局偏好</span>
+              </span>
+            </label>
+            <label className="flex items-start gap-3 border px-3 py-2 text-sm">
+              <input
+                type="checkbox"
+                aria-label="API Key 和其他凭据"
+                checked={includeCredentials}
+                onChange={(event) => setIncludeCredentials(event.target.checked)}
+                disabled={exporting || mode !== "custom"}
+                className="mt-0.5"
+              />
+              <span>
+                <span className="block font-medium">API Key 和其他凭据</span>
+                <span className="text-xs text-muted-foreground">关闭时保留模型设置,但不写入密钥字段</span>
+              </span>
+            </label>
+            {includeCredentials && (
+              <div className="flex items-start gap-2 border border-yellow-300 bg-yellow-50 p-3 text-xs text-yellow-900 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-100">
+                <ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
+                <p>备份文件未加密,包含 API Key 时请勿分享给他人。</p>
+              </div>
+            )}
+          </fieldset>
+
+          <fieldset className="space-y-3">
+            <legend className="text-sm font-medium">项目</legend>
+            <div className="flex flex-wrap items-center justify-end gap-2">
+              <div className="flex gap-2">
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="sm"
+                  aria-label="选择全部项目"
+                  onClick={() => setSelectedProjectIds(new Set(projects.map((project) => project.id)))}
+                  disabled={exporting}
+                >
+                  全选
+                </Button>
+                <Button
+                  type="button"
+                  variant="ghost"
+                  size="sm"
+                  aria-label="取消选择全部项目"
+                  onClick={() => setSelectedProjectIds(new Set())}
+                  disabled={exporting}
+                >
+                  清空
+                </Button>
+              </div>
+            </div>
+            <p className="text-xs text-muted-foreground">已选择 {selectedProjectIds.size} / {projects.length} 个项目</p>
+            <div className="max-h-40 space-y-1 overflow-y-auto border p-2">
+              {projects.length === 0 && <p className="p-2 text-sm text-muted-foreground">当前没有可备份的项目。</p>}
+              {projects.map((project) => (
+                <label key={project.id} data-backup-project className="flex cursor-pointer items-center gap-3 px-2 py-1.5 text-sm hover:bg-accent/50">
+                  <input
+                    type="checkbox"
+                    aria-label={`导出项目 ${project.name}`}
+                    checked={selectedProjectIds.has(project.id)}
+                    onChange={() => toggleProject(project.id)}
+                    disabled={exporting}
+                  />
+                  <span className="min-w-0 flex-1">
+                    <span className="block truncate font-medium">{project.name}</span>
+                    <span className="block truncate text-xs text-muted-foreground">{project.path}</span>
+                  </span>
+                </label>
+              ))}
+            </div>
+          </fieldset>
+
+          <fieldset className="space-y-2">
+            <legend className="text-sm font-medium">项目数据类别</legend>
+            <div className="grid gap-2 sm:grid-cols-2">
+              {ALL_SECTIONS.map((section) => (
+                <label key={section} data-backup-section className="flex cursor-pointer items-center gap-2 border px-3 py-2 text-sm hover:bg-accent/50">
+                  <input
+                    type="checkbox"
+                    aria-label={SECTION_LABELS[section]}
+                    checked={selectedSections.has(section)}
+                    onChange={() => toggleSection(section)}
+                    disabled={exporting || mode !== "custom"}
+                  />
+                  {SECTION_LABELS[section]}
+                </label>
+              ))}
+            </div>
+          </fieldset>
+        </div>
+
+        <DialogFooter className="shrink-0">
+          <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={exporting}>
+            取消
+          </Button>
+          <Button type="button" onClick={confirmExport} disabled={exporting || !canExport}>
+            {exporting ? "正在导出..." : "开始导出"}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 98 - 0
src/components/settings/sections/data-management-section.spec.tsx

@@ -0,0 +1,98 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true
+
+const { exportMock, registryMock, readManifestMock, selectBackupMock } = vi.hoisted(() => ({
+  exportMock: vi.fn(),
+  registryMock: vi.fn(),
+  readManifestMock: vi.fn(),
+  selectBackupMock: vi.fn(),
+}))
+
+vi.mock("react-i18next", () => ({
+  useTranslation: () => ({
+    t: (_key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? _key,
+  }),
+}))
+vi.mock("@/lib/backup/export", () => ({ exportBackup: exportMock }))
+vi.mock("@/lib/project-identity", () => ({ loadRegistry: registryMock, upsertProjectInfo: vi.fn() }))
+vi.mock("@/lib/backup/import", () => ({
+  importBackup: vi.fn(),
+  readBackupManifest: readManifestMock,
+  selectBackupFile: selectBackupMock,
+}))
+
+import { DataManagementSection } from "./data-management-section"
+
+describe("数据管理备份导出入口", () => {
+  let host: HTMLDivElement
+  let root: Root
+
+  beforeEach(async () => {
+    host = document.createElement("div")
+    document.body.appendChild(host)
+    root = createRoot(host)
+    exportMock.mockResolvedValue({ success: true, warnings: [], fileCount: 1, totalSize: 32, error: null })
+    registryMock.mockResolvedValue({
+      p1: { id: "p1", name: "长安夜雨", path: "C:/Novel", lastOpened: 2 },
+    })
+    selectBackupMock.mockResolvedValue("C:/backup/global-only.zip")
+    readManifestMock.mockResolvedValue({
+      backupVersion: 2,
+      contents: { globalConfig: true, uiPreferences: false, credentials: false },
+      projects: [],
+    })
+    await act(async () => root.render(<DataManagementSection />))
+  })
+
+  afterEach(async () => {
+    await act(async () => root.unmount())
+    host.remove()
+    vi.clearAllMocks()
+  })
+
+  it("用户确认选择后才开始导出", async () => {
+    const exportButton = [...document.querySelectorAll("button")]
+      .find((button) => button.textContent === "导出备份")!
+
+    await act(async () => exportButton.click())
+
+    expect(document.body.textContent).toContain("选择备份内容")
+    expect(exportMock).not.toHaveBeenCalled()
+
+    await act(async () => (document.querySelector('[aria-label="自定义备份"]') as HTMLInputElement).click())
+    const confirm = [...document.querySelectorAll("button")]
+      .find((button) => button.textContent === "开始导出")!
+    await act(async () => confirm.click())
+
+    expect(exportMock).toHaveBeenCalledWith({
+      includeGlobalConfig: true,
+      includeUiPreferences: true,
+      includeCredentials: false,
+      projects: [{
+        id: "p1",
+        name: "长安夜雨",
+        path: "C:/Novel",
+        sections: ["content", "memory", "analysis", "indexes", "trash"],
+      }],
+    }, expect.any(Function))
+  })
+
+  it("明确说明备份可以选择内容且凭据不是固定导出", () => {
+    expect(document.body.textContent).toContain("可选择全局配置、项目和数据类别")
+    expect(document.body.textContent).toContain("只有勾选 API Key 和其他凭据时")
+    expect(document.body.textContent).toContain("恢复备份中包含的全部内容")
+  })
+
+  it("选择性导入不含项目的备份时显示中文错误", async () => {
+    await act(async () => (document.querySelector('input[value="selective"]') as HTMLInputElement).click())
+    const importButton = [...document.querySelectorAll("button")]
+      .find((button) => button.textContent === "导入备份")!
+    await act(async () => importButton.click())
+
+    expect(document.body.textContent).toContain("该备份不包含可恢复的项目数据")
+  })
+})

+ 64 - 11
src/components/settings/sections/data-management-section.tsx

@@ -11,11 +11,15 @@ import {
 import { Button } from "@/components/ui/button"
 import { exportBackup } from "@/lib/backup/export"
 import { importBackup, readBackupManifest, selectBackupFile } from "@/lib/backup/import"
+import { loadRegistry } from "@/lib/project-identity"
+import { BackupExportDialog } from "./backup-export-dialog"
 import type {
+  BackupExportOptions,
   ExportResult,
   ImportResult,
   ImportStrategy,
   BackupProgressPayload,
+  ProjectBackupInfo,
   ProjectManifestEntry,
   ProjectRestoreInfo,
 } from "@/lib/backup/types"
@@ -29,6 +33,9 @@ export function DataManagementSection() {
   const [importStrategy, setImportStrategy] = useState<ImportStrategy>("full")
   const [progress, setProgress] = useState<BackupProgressPayload | null>(null)
   const [showProjectSelect, setShowProjectSelect] = useState(false)
+  const [showExportSelect, setShowExportSelect] = useState(false)
+  const [isLoadingExportOptions, setIsLoadingExportOptions] = useState(false)
+  const [exportProjects, setExportProjects] = useState<ProjectBackupInfo[]>([])
   const [manifestProjects, setManifestProjects] = useState<ProjectManifestEntry[]>([])
   const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
   const [pendingZipPath, setPendingZipPath] = useState<string>("")
@@ -37,12 +44,37 @@ export function DataManagementSection() {
     setProgress(payload)
   }, [])
 
-  async function handleExport() {
+  async function openExportDialog() {
+    setExportResult(null)
+    setIsLoadingExportOptions(true)
+    try {
+      const registry = await loadRegistry()
+      setExportProjects(Object.values(registry).map((project) => ({
+        id: project.id,
+        name: project.name,
+        path: project.path,
+      })))
+      setShowExportSelect(true)
+    } catch (err) {
+      setExportResult({
+        success: false,
+        warnings: [],
+        fileCount: 0,
+        totalSize: 0,
+        error: `读取项目列表失败:${String(err)}`,
+      })
+    } finally {
+      setIsLoadingExportOptions(false)
+    }
+  }
+
+  async function handleExport(options: BackupExportOptions) {
+    setShowExportSelect(false)
     setIsExporting(true)
     setExportResult(null)
     setProgress(null)
     try {
-      const result = await exportBackup(handleProgress)
+      const result = await exportBackup(options, handleProgress)
       setExportResult(result)
     } catch (err) {
       setExportResult({
@@ -66,8 +98,19 @@ export function DataManagementSection() {
 
       try {
         const manifest = await readBackupManifest(zipPath)
-        setManifestProjects(manifest)
-        setSelectedProjectIds(new Set(manifest.map((p) => p.id)))
+        if (manifest.projects.length === 0) {
+          setImportResult({
+            success: false,
+            appState: null,
+            localStorageData: null,
+            projects: [],
+            warnings: [],
+            error: "该备份不包含可恢复的项目数据",
+          })
+          return
+        }
+        setManifestProjects(manifest.projects)
+        setSelectedProjectIds(new Set(manifest.projects.map((p) => p.id)))
         setPendingZipPath(zipPath)
         setShowProjectSelect(true)
       } catch {
@@ -152,7 +195,7 @@ export function DataManagementSection() {
     setSelectedProjectIds(new Set())
   }
 
-  const isBusy = isExporting || isImporting
+  const isBusy = isExporting || isImporting || isLoadingExportOptions
 
   return (
     <div className="space-y-6">
@@ -204,14 +247,16 @@ export function DataManagementSection() {
         </div>
         <p className="text-sm text-muted-foreground">
           {t("settings.sections.dataManagement.exportDescription", {
-            defaultValue: "将所有数据打包为一个 zip 文件,用于重装系统前备份。包含:全局配置、所有项目数据、UI偏好。",
+            defaultValue: "将选中的内容打包为 ZIP 文件,可选择全局配置、项目和数据类别。",
           })}
         </p>
-        <Button onClick={handleExport} disabled={isBusy}>
-          {isExporting ? (
+        <Button onClick={() => void openExportDialog()} disabled={isBusy}>
+          {isExporting || isLoadingExportOptions ? (
             <>
               <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-              {t("settings.sections.dataManagement.exporting", { defaultValue: "导出中..." })}
+              {t("settings.sections.dataManagement.exporting", {
+                defaultValue: isLoadingExportOptions ? "正在读取项目..." : "导出中...",
+              })}
             </>
           ) : (
             <>
@@ -272,7 +317,7 @@ export function DataManagementSection() {
           </label>
           <div className="space-y-1">
             {([
-              { value: "full" as const, label: "完全覆盖(清除当前所有数据)" },
+              { value: "full" as const, label: "恢复备份中包含的全部内容(V1 会覆盖全局配置)" },
               { value: "selective" as const, label: "选择性导入(仅恢复选中的项目)" },
               { value: "global-only" as const, label: "仅导入全局配置(模型、UI偏好)" },
             ]).map((opt) => (
@@ -415,12 +460,20 @@ export function DataManagementSection() {
         </div>
       )}
 
+      <BackupExportDialog
+        open={showExportSelect}
+        projects={exportProjects}
+        exporting={isExporting}
+        onOpenChange={setShowExportSelect}
+        onConfirm={(options) => void handleExport(options)}
+      />
+
       <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" />
           <p>
             {t("settings.sections.dataManagement.securityWarning", {
-              defaultValue: "备份文件包含 API 密钥等敏感信息,请妥善保管,不要分享给他人。",
+              defaultValue: "只有勾选 API Key 和其他凭据时,备份才包含敏感信息;包含凭据的 ZIP 未加密,请勿分享。",
             })}
           </p>
         </div>

+ 2 - 2
src/i18n/en.json

@@ -1027,7 +1027,7 @@
         "title": "Data Management",
         "description": "Backup and restore all your data, including model configs, AI chats, novel content, outlines, memory, book analysis results, etc.",
         "exportTitle": "Export Backup",
-        "exportDescription": "Pack all data into a zip file for backup before reinstalling OS. Includes: global configs, all project data, UI preferences.",
+        "exportDescription": "Pack selected global settings, projects, and data categories into a ZIP backup.",
         "exportButton": "Export Backup",
         "exporting": "Exporting...",
         "exportSuccess": "Export successful",
@@ -1039,7 +1039,7 @@
         "importing": "Importing...",
         "importSuccess": "Import successful. Please restart the app.",
         "restoredProjects": "Restored {{count}} projects",
-        "securityWarning": "Backup files contain sensitive information like API keys. Keep them safe and do not share."
+        "securityWarning": "Credentials are included only when selected. ZIP backups containing credentials are not encrypted and should not be shared."
       },
       "changelog": {
         "title": "Release Notes",

+ 2 - 2
src/i18n/zh.json

@@ -836,7 +836,7 @@
         "title": "数据管理",
         "description": "备份和恢复你的所有数据,包括模型配置、AI对话、小说内容、大纲、记忆库、拆书结果等。",
         "exportTitle": "导出备份",
-        "exportDescription": "将所有数据打包为一个 zip 文件,用于重装系统前备份。包含:全局配置、所有项目数据、UI偏好。",
+        "exportDescription": "将选中的内容打包为 ZIP 文件,可选择全局配置、项目和数据类别。",
         "exportButton": "导出备份",
         "exporting": "导出中...",
         "exportSuccess": "导出成功",
@@ -848,7 +848,7 @@
         "importing": "导入中...",
         "importSuccess": "导入成功,请重启软件以使配置生效",
         "restoredProjects": "已恢复 {{count}} 个项目",
-        "securityWarning": "备份文件包含 API 密钥等敏感信息,请妥善保管,不要分享给他人。"
+        "securityWarning": "只有勾选 API Key 和其他凭据时,备份才包含敏感信息;包含凭据的 ZIP 未加密,请勿分享。"
       },
       "changelog": {
         "title": "版本更新",

+ 83 - 0
src/lib/backup/export.spec.ts

@@ -0,0 +1,83 @@
+// @vitest-environment jsdom
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const { invokeMock, saveMock, listenMock, unlistenMock, registryMock } = vi.hoisted(() => ({
+  invokeMock: vi.fn(),
+  saveMock: vi.fn(),
+  listenMock: vi.fn(),
+  unlistenMock: vi.fn(),
+  registryMock: vi.fn(),
+}))
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }))
+vi.mock("@tauri-apps/plugin-dialog", () => ({ save: saveMock }))
+vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock }))
+vi.mock("@/lib/project-identity", () => ({ loadRegistry: registryMock }))
+
+import { exportBackup } from "./export"
+
+describe("数据管理备份导出参数", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    saveMock.mockResolvedValue("C:/backup/qmai.zip")
+    listenMock.mockResolvedValue(unlistenMock)
+    invokeMock.mockResolvedValue({
+      success: true,
+      warnings: [],
+      fileCount: 1,
+      totalSize: 128,
+      error: null,
+    })
+    registryMock.mockResolvedValue({
+      p1: { id: "p1", name: "长安夜雨", path: "C:/Novel", lastOpened: 2 },
+      p2: { id: "p2", name: "旧梦", path: "C:/Old", lastOpened: 1 },
+    })
+    localStorage.clear()
+    localStorage.setItem("qmai-ui-density", "compact")
+    localStorage.setItem("qmai_fallback_fingerprint", "sensitive-fingerprint")
+    localStorage.setItem("unrelated", "keep-local-only")
+  })
+
+  it("只传递用户选中的项目、数据类别和全局选项", async () => {
+    await exportBackup({
+      includeGlobalConfig: true,
+      includeUiPreferences: true,
+      includeCredentials: false,
+      projects: [{
+        id: "p1",
+        name: "长安夜雨",
+        path: "C:/Novel",
+        sections: ["content", "indexes"],
+      }],
+    })
+
+    expect(invokeMock).toHaveBeenCalledWith("export_backup", {
+      params: {
+        savePath: "C:/backup/qmai.zip",
+        includeGlobalConfig: true,
+        includeUiPreferences: true,
+        includeCredentials: false,
+        localStorageData: { "qmai-ui-density": "compact" },
+        projects: [{
+          id: "p1",
+          name: "长安夜雨",
+          path: "C:/Novel",
+          sections: ["content", "indexes"],
+        }],
+      },
+    })
+  })
+
+  it("未选择界面偏好时不收集 localStorage", async () => {
+    await exportBackup({
+      includeGlobalConfig: true,
+      includeUiPreferences: false,
+      includeCredentials: true,
+      projects: [],
+    })
+
+    expect(invokeMock).toHaveBeenCalledWith("export_backup", {
+      params: expect.objectContaining({ localStorageData: {} }),
+    })
+  })
+})

+ 12 - 15
src/lib/backup/export.ts

@@ -1,22 +1,23 @@
 import { invoke } from "@tauri-apps/api/core"
 import { save } from "@tauri-apps/plugin-dialog"
 import { listen, type UnlistenFn } from "@tauri-apps/api/event"
-import { loadRegistry } from "@/lib/project-identity"
 import type {
+  BackupExportOptions,
   ExportParams,
   ExportResult,
-  ProjectBackupInfo,
   BackupProgressCallback,
 } from "./types"
 
 const LS_PREFIXES = ["qmai", "lk-"]
+const SENSITIVE_STORAGE_KEY = /(api[_-]?key|token|secret|password|fingerprint)/i
 
-function collectLocalStorage(): Record<string, string> {
+function collectLocalStorage(includeCredentials: boolean): Record<string, string> {
   const data: Record<string, string> = {}
   for (let i = 0; i < localStorage.length; i++) {
     const key = localStorage.key(i)
     if (!key) continue
     if (LS_PREFIXES.some((prefix) => key.startsWith(prefix))) {
+      if (!includeCredentials && SENSITIVE_STORAGE_KEY.test(key)) continue
       const value = localStorage.getItem(key)
       if (value !== null) {
         data[key] = value
@@ -26,16 +27,8 @@ function collectLocalStorage(): Record<string, string> {
   return data
 }
 
-async function collectProjects(): Promise<ProjectBackupInfo[]> {
-  const registry = await loadRegistry()
-  return Object.values(registry).map((entry) => ({
-    id: entry.id,
-    path: entry.path,
-    name: entry.name,
-  }))
-}
-
 export async function exportBackup(
+  options: BackupExportOptions,
   onProgress?: BackupProgressCallback,
 ): Promise<ExportResult> {
   const now = new Date()
@@ -57,13 +50,17 @@ export async function exportBackup(
     }
   }
 
-  const localStorageData = collectLocalStorage()
-  const projects = await collectProjects()
+  const localStorageData = options.includeUiPreferences
+    ? collectLocalStorage(options.includeCredentials)
+    : {}
 
   const params: ExportParams = {
     savePath,
+    includeGlobalConfig: options.includeGlobalConfig,
+    includeUiPreferences: options.includeUiPreferences,
+    includeCredentials: options.includeCredentials,
     localStorageData,
-    projects,
+    projects: options.projects,
   }
 
   let unlisten: UnlistenFn | undefined

+ 88 - 0
src/lib/backup/import.spec.ts

@@ -0,0 +1,88 @@
+// @vitest-environment jsdom
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const { invokeMock, listenMock, unlistenMock } = vi.hoisted(() => ({
+  invokeMock: vi.fn(),
+  listenMock: vi.fn(),
+  unlistenMock: vi.fn(),
+}))
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }))
+vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }))
+vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock }))
+vi.mock("@/lib/project-identity", () => ({ loadRegistry: vi.fn(), upsertProjectInfo: vi.fn() }))
+vi.mock("@/lib/project-refresh", () => ({ refreshProjectState: vi.fn() }))
+vi.mock("@/stores/wiki-store", () => ({
+  useWikiStore: { getState: () => ({ project: null }) },
+}))
+
+import { importBackup } from "./import"
+
+const preview = {
+  backupVersion: 2,
+  contents: { globalConfig: true, uiPreferences: true, credentials: false },
+  projects: [],
+}
+
+function importResult(replaceLocalStorage: boolean) {
+  return {
+    success: true,
+    appState: null,
+    localStorageData: { "qmai-new-layout": "wide" },
+    replaceLocalStorage,
+    projects: [],
+    warnings: [],
+    error: null,
+  }
+}
+
+describe("V2 备份导入适配", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    localStorage.clear()
+    listenMock.mockResolvedValue(unlistenMock)
+  })
+
+  it("部分界面偏好采用合并恢复并保留当前键", async () => {
+    localStorage.setItem("qmai-existing-layout", "compact")
+    invokeMock.mockImplementation((command: string) => {
+      if (command === "read_backup_manifest") return Promise.resolve(preview)
+      if (command === "import_backup") return Promise.resolve(importResult(false))
+      throw new Error(`unexpected command: ${command}`)
+    })
+
+    await importBackup("full", undefined, undefined, "C:/backup/partial.zip")
+
+    expect(localStorage.getItem("qmai-existing-layout")).toBe("compact")
+    expect(localStorage.getItem("qmai-new-layout")).toBe("wide")
+  })
+
+  it("完整或 V1 界面偏好仍清除旧键后恢复", async () => {
+    localStorage.setItem("qmai-existing-layout", "compact")
+    localStorage.setItem("unrelated", "keep")
+    invokeMock.mockImplementation((command: string) => {
+      if (command === "read_backup_manifest") return Promise.resolve(preview)
+      if (command === "import_backup") return Promise.resolve(importResult(true))
+      throw new Error(`unexpected command: ${command}`)
+    })
+
+    await importBackup("full", undefined, undefined, "C:/backup/full.zip")
+
+    expect(localStorage.getItem("qmai-existing-layout")).toBeNull()
+    expect(localStorage.getItem("qmai-new-layout")).toBe("wide")
+    expect(localStorage.getItem("unrelated")).toBe("keep")
+  })
+
+  it("仅导入全局配置时不检查项目路径", async () => {
+    invokeMock.mockImplementation((command: string) => {
+      if (command === "read_backup_manifest") throw new Error("不应读取项目路径")
+      if (command === "import_backup") return Promise.resolve(importResult(false))
+      throw new Error(`unexpected command: ${command}`)
+    })
+
+    const result = await importBackup("global-only", undefined, undefined, "C:/backup/global.zip")
+
+    expect(result.success).toBe(true)
+    expect(invokeMock).not.toHaveBeenCalledWith("read_backup_manifest", expect.anything())
+  })
+})

+ 16 - 12
src/lib/backup/import.ts

@@ -11,6 +11,7 @@ import type {
   ImportStrategy,
   ProjectRestoreInfo,
   ProjectManifestEntry,
+  BackupManifestPreview,
   BackupProgressCallback,
 } from "./types"
 
@@ -41,7 +42,8 @@ async function refreshCurrentProjectIfNeeded(restoredProjects: Array<{ path: str
 async function checkAndRemapPaths(zipPath: string): Promise<Record<string, string> | null> {
   let manifest: ProjectManifestEntry[]
   try {
-    manifest = await invoke<ProjectManifestEntry[]>("read_backup_manifest", { zipPath })
+    const preview = await invoke<BackupManifestPreview>("read_backup_manifest", { zipPath })
+    manifest = preview.projects
   } catch {
     // manifest 读取失败,回退到原行为(直接导入)
     return {}
@@ -85,8 +87,8 @@ async function checkAndRemapPaths(zipPath: string): Promise<Record<string, strin
  * 读取备份文件的 manifest,返回项目列表。
  * 供 UI 组件在导入前预览项目列表使用。
  */
-export async function readBackupManifest(zipPath: string): Promise<ProjectManifestEntry[]> {
-  return await invoke<ProjectManifestEntry[]>("read_backup_manifest", { zipPath })
+export async function readBackupManifest(zipPath: string): Promise<BackupManifestPreview> {
+  return await invoke<BackupManifestPreview>("read_backup_manifest", { zipPath })
 }
 
 /**
@@ -122,7 +124,7 @@ export async function importBackup(
   }
 
   // 导入前检查路径可达性,必要时弹窗让用户选择新目录
-  const pathOverrides = await checkAndRemapPaths(zipPath)
+  const pathOverrides = strategy === "global-only" ? {} : await checkAndRemapPaths(zipPath)
   if (pathOverrides === null) {
     return {
       success: false,
@@ -157,15 +159,17 @@ export async function importBackup(
 
     if (result.localStorageData) {
       const prefixes = ["qmai", "lk-"]
-      const keysToRemove: string[] = []
-      for (let i = 0; i < localStorage.length; i++) {
-        const key = localStorage.key(i)
-        if (key && prefixes.some((p) => key.startsWith(p))) {
-          keysToRemove.push(key)
+      if (result.replaceLocalStorage !== false) {
+        const keysToRemove: string[] = []
+        for (let i = 0; i < localStorage.length; i++) {
+          const key = localStorage.key(i)
+          if (key && prefixes.some((p) => key.startsWith(p))) {
+            keysToRemove.push(key)
+          }
+        }
+        for (const key of keysToRemove) {
+          localStorage.removeItem(key)
         }
-      }
-      for (const key of keysToRemove) {
-        localStorage.removeItem(key)
       }
       for (const [key, value] of Object.entries(result.localStorageData)) {
         localStorage.setItem(key, value)

+ 38 - 2
src/lib/backup/types.ts

@@ -5,11 +5,32 @@ export interface ProjectBackupInfo {
   name: string
 }
 
+export type ProjectBackupSection =
+  | "content"
+  | "memory"
+  | "analysis"
+  | "indexes"
+  | "trash"
+
+export interface SelectedProjectBackupInfo extends ProjectBackupInfo {
+  sections: ProjectBackupSection[]
+}
+
+export interface BackupExportOptions {
+  includeGlobalConfig: boolean
+  includeUiPreferences: boolean
+  includeCredentials: boolean
+  projects: SelectedProjectBackupInfo[]
+}
+
 /** 导出参数 */
 export interface ExportParams {
   savePath: string
+  includeGlobalConfig: boolean
+  includeUiPreferences: boolean
+  includeCredentials: boolean
   localStorageData: Record<string, string>
-  projects: ProjectBackupInfo[]
+  projects: SelectedProjectBackupInfo[]
 }
 
 /** 导出结果 */
@@ -55,6 +76,13 @@ export interface ImportResult {
   projects: ProjectRestoreResult[]
   warnings: string[]
   error: string | null
+  replaceLocalStorage?: boolean
+}
+
+export interface BackupContents {
+  globalConfig: boolean
+  uiPreferences: boolean
+  credentials: boolean
 }
 
 /** 备份清单(zip 内 manifest.json) */
@@ -62,7 +90,8 @@ export interface BackupManifest {
   backupVersion: number
   createdAt: string
   appVersion: string
-  projects: ProjectBackupInfo[]
+  contents?: BackupContents
+  projects: Array<ProjectBackupInfo & { sections?: ProjectBackupSection[] }>
 }
 
 /** manifest 项目条目(含路径可达性) */
@@ -71,6 +100,13 @@ export interface ProjectManifestEntry {
   path: string
   name: string
   pathAccessible: boolean
+  sections: ProjectBackupSection[]
+}
+
+export interface BackupManifestPreview {
+  backupVersion: number
+  contents: BackupContents
+  projects: ProjectManifestEntry[]
 }
 
 /** 进度事件载荷 */

Some files were not shown because too many files changed in this diff