Переглянути джерело

feat: 统一导出中心支持章节/大纲/拆书库/剧情推演室/灵魂作品导出 TXT/DOCX

Mochocyang 2 місяців тому
батько
коміт
492be60b57

+ 232 - 1
src-tauri/src/commands/fs.rs

@@ -1,5 +1,5 @@
 use std::fs;
-use std::io::Read as IoRead;
+use std::io::{Read as IoRead, Write as IoWrite};
 use std::path::Path;
 use std::thread;
 use std::time::Duration;
@@ -1134,6 +1134,122 @@ pub async fn write_file(path: String, contents: String) -> Result<(), String> {
         .map_err(|e| format!("write_file blocking task join error: {e}"))?
 }
 
+/// Atomically replace the destination with a sibling temporary file.
+#[cfg(windows)]
+fn replace_export_file_atomically(temp: &Path, destination: &Path) -> Result<(), String> {
+    use std::os::windows::ffi::OsStrExt;
+
+    #[link(name = "Kernel32")]
+    extern "system" {
+        fn MoveFileExW(existing: *const u16, new: *const u16, flags: u32) -> i32;
+    }
+
+    const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
+    const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
+    let existing: Vec<u16> = temp.as_os_str().encode_wide().chain(Some(0)).collect();
+    let new: Vec<u16> = destination.as_os_str().encode_wide().chain(Some(0)).collect();
+    let result = unsafe {
+        MoveFileExW(
+            existing.as_ptr(),
+            new.as_ptr(),
+            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
+        )
+    };
+    if result == 0 {
+        Err(format!("原子替换导出文件失败: {}", std::io::Error::last_os_error()))
+    } else {
+        Ok(())
+    }
+}
+
+#[cfg(not(windows))]
+fn replace_export_file_atomically(temp: &Path, destination: &Path) -> Result<(), String> {
+    fs::rename(temp, destination).map_err(|e| format!("原子替换导出文件失败: {e}"))
+}
+
+fn do_write_export_file_with_replace<F>(path: &str, bytes: &[u8], replace: F) -> Result<(), String>
+where
+    F: FnOnce(&Path, &Path) -> Result<(), String>,
+{
+    run_guarded("write_export_file", || {
+        // 保存窗口返回的是用户选定的外部路径,绝不能经过项目路径虚拟化。
+        let destination = Path::new(path);
+        let parent = destination
+            .parent()
+            .ok_or_else(|| "导出路径缺少父目录".to_string())?;
+        fs::create_dir_all(parent)
+            .map_err(|e| format!("创建导出目录失败 '{}': {e}", parent.display()))?;
+        let file_name = destination
+            .file_name()
+            .map(|name| name.to_string_lossy().to_string())
+            .unwrap_or_else(|| "qmai-export".to_string());
+        let temp = parent.join(format!(
+            ".{file_name}.{}.export.tmp",
+            chrono::Utc::now()
+                .timestamp_nanos_opt()
+                .unwrap_or_else(|| chrono::Utc::now().timestamp_millis())
+        ));
+
+        file_sync::mark_app_write_path(&temp);
+        file_sync::mark_app_write_path(destination);
+        let write_result = (|| {
+            let mut file = fs::OpenOptions::new()
+                .write(true)
+                .create_new(true)
+                .open(&temp)
+                .map_err(|e| format!("创建导出临时文件失败 '{}': {e}", temp.display()))?;
+            file.write_all(bytes)
+                .map_err(|e| format!("写入导出临时文件失败 '{}': {e}", temp.display()))?;
+            file.sync_all()
+                .map_err(|e| format!("同步导出临时文件失败 '{}': {e}", temp.display()))?;
+            drop(file);
+            replace(&temp, destination)
+        })();
+        if write_result.is_err() {
+            let _ = fs::remove_file(&temp);
+        }
+        write_result?;
+        file_sync::mark_app_write_path(destination);
+        Ok(())
+    })
+}
+
+/// Write export payloads as exact bytes so ZIP-based DOCX files are not UTF-8 re-encoded.
+pub fn do_write_export_file(path: &str, bytes: &[u8]) -> Result<(), String> {
+    do_write_export_file_with_replace(path, bytes, replace_export_file_atomically)
+}
+
+const MAX_EXPORT_BYTES: usize = 64 * 1024 * 1024;
+const MAX_EXPORT_BASE64_LENGTH: usize = ((MAX_EXPORT_BYTES + 2) / 3) * 4;
+
+fn validate_export_base64_length(length: usize) -> Result<(), String> {
+    if length > MAX_EXPORT_BASE64_LENGTH {
+        Err("导出文件超过 64 MiB 限制,请缩小导出范围。".to_string())
+    } else {
+        Ok(())
+    }
+}
+
+pub fn do_write_export_file_base64(path: &str, contents_base64: &str) -> Result<(), String> {
+    use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
+    validate_export_base64_length(contents_base64.len())?;
+    let bytes = B64
+        .decode(contents_base64)
+        .map_err(|e| format!("导出文件数据解码失败: {e}"))?;
+    if bytes.len() > MAX_EXPORT_BYTES {
+        return Err("导出文件超过 64 MiB 限制,请缩小导出范围。".to_string());
+    }
+    do_write_export_file(path, &bytes)
+}
+
+#[tauri::command]
+pub async fn write_export_file(path: String, contents_base64: String) -> Result<(), String> {
+    tauri::async_runtime::spawn_blocking(move || {
+        do_write_export_file_base64(&path, &contents_base64)
+    })
+    .await
+    .map_err(|e| format!("write_export_file blocking task join error: {e}"))?
+}
 /// Core logic for `write_file_atomic`, callable from both Tauri commands and Axum handlers.
 pub fn do_write_file_atomic(path: &str, contents: &str) -> Result<(), String> {
     run_guarded("write_file_atomic", || {
@@ -1804,6 +1920,121 @@ mod tests {
         path.to_string_lossy().to_string()
     }
 
+    #[test]
+    fn write_export_file_uses_dialog_path_without_project_path_rewrite() {
+        let root = std::env::temp_dir().join(format!(
+            "qmai-export-exact-path-{}",
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap()
+                .as_nanos()
+        ));
+        let path = root.join("wiki").join(".llm-wiki").join("export.docx");
+        fs::create_dir_all(path.parent().unwrap()).unwrap();
+
+        do_write_export_file(&path.to_string_lossy(), b"exact-path").unwrap();
+
+        assert_eq!(fs::read(&path).unwrap(), b"exact-path");
+        assert!(!root.join("QM").join(".qmai").join("export.docx").exists());
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn write_export_file_atomically_replaces_existing_file() {
+        let root = std::env::temp_dir().join(format!(
+            "qmai-export-atomic-success-{}",
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap()
+                .as_nanos()
+        ));
+        fs::create_dir_all(&root).unwrap();
+        let path = root.join("export.docx");
+        fs::write(&path, b"old-content").unwrap();
+
+        do_write_export_file(&path.to_string_lossy(), b"new-content").unwrap();
+
+        assert_eq!(fs::read(&path).unwrap(), b"new-content");
+        let leftovers: Vec<_> = fs::read_dir(&root)
+            .unwrap()
+            .filter_map(Result::ok)
+            .filter(|entry| entry.path() != path)
+            .collect();
+        assert!(leftovers.is_empty(), "临时文件未清理: {leftovers:?}");
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn write_export_file_replace_failure_keeps_old_file_and_removes_temp_file() {
+        let root = std::env::temp_dir().join(format!(
+            "qmai-export-atomic-failure-{}",
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap()
+                .as_nanos()
+        ));
+        fs::create_dir_all(&root).unwrap();
+        let path = root.join("export.docx");
+        fs::write(&path, b"old-content").unwrap();
+
+        let result = do_write_export_file_with_replace(
+            &path.to_string_lossy(),
+            b"new-content",
+            |_temp, _destination| Err("模拟原子替换失败".to_string()),
+        );
+
+        assert!(result.is_err());
+        assert_eq!(fs::read(&path).unwrap(), b"old-content");
+        let leftovers: Vec<_> = fs::read_dir(&root)
+            .unwrap()
+            .filter_map(Result::ok)
+            .filter(|entry| entry.path() != path)
+            .collect();
+        assert!(leftovers.is_empty(), "临时文件未清理: {leftovers:?}");
+        let _ = fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn write_export_file_preserves_binary_bytes() {
+        let path = std::env::temp_dir().join(format!(
+            "qmai-export-center-{}.docx",
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap()
+                .as_nanos()
+        ));
+        let expected = vec![0x50, 0x4b, 0x03, 0x04, 0xff, 0x00, 0x80];
+
+        do_write_export_file(&path.to_string_lossy(), &expected).unwrap();
+
+        assert_eq!(fs::read(&path).unwrap(), expected);
+        let _ = fs::remove_file(path);
+    }
+
+    #[test]
+    fn write_export_file_enforces_64_mib_boundary() {
+        assert!(validate_export_base64_length(MAX_EXPORT_BASE64_LENGTH).is_ok());
+        let error = validate_export_base64_length(MAX_EXPORT_BASE64_LENGTH + 4).unwrap_err();
+        assert!(error.contains("64 MiB"));
+    }
+
+    #[test]
+    fn write_export_file_decodes_base64_before_writing() {
+        let path = std::env::temp_dir().join(format!(
+            "qmai-export-center-base64-{}.docx",
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap()
+                .as_nanos()
+        ));
+        let expected = vec![0x50, 0x4b, 0x03, 0x04, 0xff, 0x00, 0x80];
+
+        do_write_export_file_base64(&path.to_string_lossy(), "UEsDBP8AgA==").unwrap();
+
+        assert_eq!(fs::read(&path).unwrap(), expected);
+        let _ = fs::remove_file(path);
+    }
+
     #[test]
     fn decode_plain_text_bytes_supports_gbk_chinese_novel_text() {
         let (bytes, _, _) = encoding_rs::GBK.encode("第1章 税银案\n许七安醒来。");

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

@@ -47,6 +47,7 @@ pub fn run() {
         .invoke_handler(tauri::generate_handler![
             commands::fs::read_file,
             commands::fs::write_file,
+            commands::fs::write_export_file,
             commands::fs::write_file_atomic,
             commands::fs::list_directory,
             commands::fs::copy_file,

+ 138 - 0
src/components/settings/sections/export-center-section.spec.tsx

@@ -0,0 +1,138 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { ExportDocument, ExportSource } from "@/lib/export-center/types"
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true
+
+const { collectMock, exportMock, registryMock } = vi.hoisted(() => ({
+  collectMock: vi.fn(),
+  exportMock: vi.fn(),
+  registryMock: vi.fn(),
+}))
+
+vi.mock("@/lib/export-center/collectors", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("@/lib/export-center/collectors")>()
+  return { ...actual, collectAllProjectSources: collectMock }
+})
+vi.mock("@/lib/export-center/export-service", () => ({ exportDocuments: exportMock }))
+vi.mock("@/lib/project-identity", () => ({ loadRegistry: registryMock }))
+
+import { ExportCenterSection } from "./export-center-section"
+
+const chapterDocument: ExportDocument = {
+  title: "长安夜雨-章节",
+  source: "chapters",
+  blocks: [{ title: "第一章", paragraphs: ["正文"] }],
+}
+
+function sourceResult(chapters: ExportDocument[] = [chapterDocument]): Record<ExportSource, ExportDocument[]> {
+  return {
+    chapters,
+    outlines: [],
+    "book-analysis": [],
+    "story-simulation": [],
+    "soul-works": [],
+  }
+}
+
+describe("设置中的统一导出中心", () => {
+  let host: HTMLDivElement
+  let root: Root
+
+  beforeEach(async () => {
+    host = document.createElement("div")
+    document.body.appendChild(host)
+    root = createRoot(host)
+    registryMock.mockResolvedValue({
+      p1: { id: "p1", name: "长安夜雨", path: "C:/Novel", lastOpened: 2 },
+      p2: { id: "p2", name: "旧梦", path: "C:/Old", lastOpened: 1 },
+    })
+    collectMock.mockResolvedValue(sourceResult())
+    exportMock.mockResolvedValue({ status: "success", exportedCount: 1 })
+    await act(async () => {
+      root.render(<ExportCenterSection currentProject={{ id: "p1", name: "长安夜雨", path: "C:/Novel" }} />)
+    })
+  })
+
+  afterEach(async () => {
+    await act(async () => root.unmount())
+    host.remove()
+    vi.clearAllMocks()
+  })
+
+  it("提供项目、五类来源和格式选择,缺失来源禁用且内容区可滚动", () => {
+    expect(host.textContent).toContain("统一导出中心")
+    expect(host.querySelectorAll("select option")).toHaveLength(2)
+    expect(host.textContent).toContain("章节")
+    expect(host.textContent).toContain("大纲")
+    expect(host.textContent).toContain("拆书库")
+    expect(host.textContent).toContain("剧情推演室")
+    expect(host.textContent).toContain("灵魂作品")
+    expect(host.textContent).toContain("UTF-8 TXT")
+    expect(host.textContent).toContain("Word DOCX")
+    expect((host.querySelector('input[value="outlines"]') as HTMLInputElement).disabled).toBe(true)
+    expect(host.querySelector("[data-export-center-scroll]")?.className).toContain("overflow-y-auto")
+    expect(host.querySelectorAll("fieldset")).toHaveLength(2)
+    expect(Array.from(host.querySelectorAll("legend")).map((legend) => legend.textContent)).toEqual(["选择导出内容", "导出格式"])
+  })
+
+  it("导出期间禁用操作并显示中文成功反馈", async () => {
+    let finish: ((value: { status: "success"; exportedCount: number }) => void) | undefined
+    exportMock.mockReturnValueOnce(new Promise((resolvePromise) => { finish = resolvePromise }))
+    const chapter = host.querySelector('input[value="chapters"]') as HTMLInputElement
+    const docx = host.querySelector('input[value="docx"]') as HTMLInputElement
+    const button = Array.from(host.querySelectorAll("button")).find((item) => item.textContent === "开始导出") as HTMLButtonElement
+
+    await act(async () => {
+      chapter.click()
+      docx.click()
+    })
+    await act(async () => { button.click() })
+
+    expect(host.textContent).toContain("正在导出…")
+    expect((Array.from(host.querySelectorAll("button")).find((item) => item.textContent === "正在导出…") as HTMLButtonElement).disabled).toBe(true)
+
+    await act(async () => { finish?.({ status: "success", exportedCount: 1 }) })
+    expect(exportMock).toHaveBeenCalledWith([chapterDocument], "docx", undefined, expect.any(Function))
+    expect(host.textContent).toContain("已成功导出 1 个文件。")
+  })
+
+  it("组件卸载后守卫失效并阻止继续更新状态或打开后续保存窗口", async () => {
+    let finish: ((value: { status: "success"; exportedCount: number }) => void) | undefined
+    exportMock.mockReturnValueOnce(new Promise((resolvePromise) => { finish = resolvePromise }))
+    const chapter = host.querySelector('input[value="chapters"]') as HTMLInputElement
+    await act(async () => { chapter.click() })
+    const button = Array.from(host.querySelectorAll("button")).find((item) => item.textContent === "开始导出") as HTMLButtonElement
+    await act(async () => { button.click() })
+    const guard = exportMock.mock.calls[0][3] as () => boolean
+    expect(guard()).toBe(true)
+
+    await act(async () => root.unmount())
+    expect(guard()).toBe(false)
+    await act(async () => { finish?.({ status: "success", exportedCount: 1 }) })
+    root = createRoot(host)
+  })
+
+  it("导出失败时显示中文错误且恢复操作", async () => {
+    exportMock.mockRejectedValueOnce(new Error("磁盘空间不足"))
+    const chapter = host.querySelector('input[value="chapters"]') as HTMLInputElement
+    await act(async () => { chapter.click() })
+    const button = Array.from(host.querySelectorAll("button")).find((item) => item.textContent === "开始导出") as HTMLButtonElement
+
+    await act(async () => { button.click() })
+
+    expect(host.textContent).toContain("导出失败:磁盘空间不足")
+    expect((Array.from(host.querySelectorAll("button")).find((item) => item.textContent === "开始导出") as HTMLButtonElement).disabled).toBe(false)
+  })
+
+  it("以独立设置分类最小接入 settings-view", () => {
+    const source = readFileSync(resolve(process.cwd(), "src/components/settings/settings-view.tsx"), "utf8")
+    expect(source).toContain('| "export-center"')
+    expect(source).toContain('id: "export-center"')
+    expect(source).toContain("<ExportCenterSection currentProject={project} />")
+  })
+})

+ 217 - 0
src/components/settings/sections/export-center-section.tsx

@@ -0,0 +1,217 @@
+import { useEffect, useMemo, useRef, useState } from "react"
+import { Download, Loader2 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { collectAllProjectSources, EXPORT_SOURCES } from "@/lib/export-center/collectors"
+import { exportDocuments } from "@/lib/export-center/export-service"
+import type { ExportDocument, ExportFormat, ExportSource } from "@/lib/export-center/types"
+import { loadRegistry } from "@/lib/project-identity"
+import type { WikiProject } from "@/types/wiki"
+
+interface ExportCenterSectionProps {
+  currentProject?: WikiProject | null
+}
+
+interface SelectableProject extends WikiProject {
+  lastOpened: number
+}
+
+const SOURCE_LABELS: Record<ExportSource, string> = {
+  chapters: "章节",
+  outlines: "大纲",
+  "book-analysis": "拆书库",
+  "story-simulation": "剧情推演室",
+  "soul-works": "灵魂作品",
+}
+
+function emptySources(): Record<ExportSource, ExportDocument[]> {
+  return {
+    chapters: [],
+    outlines: [],
+    "book-analysis": [],
+    "story-simulation": [],
+    "soul-works": [],
+  }
+}
+
+export function ExportCenterSection({ currentProject }: ExportCenterSectionProps) {
+  const [projects, setProjects] = useState<SelectableProject[]>(() => currentProject
+    ? [{ ...currentProject, lastOpened: Number.MAX_SAFE_INTEGER }]
+    : [])
+  const [selectedProjectId, setSelectedProjectId] = useState(currentProject?.id ?? "")
+  const [documentsBySource, setDocumentsBySource] = useState(emptySources)
+  const [selectedSources, setSelectedSources] = useState<Set<ExportSource>>(new Set())
+  const [format, setFormat] = useState<ExportFormat>("txt")
+  const [isLoading, setIsLoading] = useState(false)
+  const [isExporting, setIsExporting] = useState(false)
+  const [message, setMessage] = useState("")
+  const mountedRef = useRef(true)
+
+  useEffect(() => {
+    mountedRef.current = true
+    return () => { mountedRef.current = false }
+  }, [])
+
+  useEffect(() => {
+    let cancelled = false
+    loadRegistry().then((registry) => {
+      if (cancelled) return
+      const merged = new Map<string, SelectableProject>()
+      for (const entry of Object.values(registry)) merged.set(entry.id, entry)
+      if (currentProject) {
+        merged.set(currentProject.id, {
+          ...currentProject,
+          lastOpened: merged.get(currentProject.id)?.lastOpened ?? Number.MAX_SAFE_INTEGER,
+        })
+      }
+      const next = Array.from(merged.values()).sort((a, b) => b.lastOpened - a.lastOpened)
+      setProjects(next)
+      setSelectedProjectId((value) => value || next[0]?.id || "")
+    }).catch(() => {
+      if (!cancelled) setMessage("读取项目列表失败,请稍后重试。")
+    })
+    return () => { cancelled = true }
+  }, [currentProject])
+
+  const selectedProject = useMemo(
+    () => projects.find((project) => project.id === selectedProjectId) ?? null,
+    [projects, selectedProjectId],
+  )
+
+  useEffect(() => {
+    let cancelled = false
+    setSelectedSources(new Set())
+    setDocumentsBySource(emptySources())
+    setMessage("")
+    if (!selectedProject) return () => { cancelled = true }
+
+    setIsLoading(true)
+    collectAllProjectSources(selectedProject).then((sources) => {
+      if (!cancelled) setDocumentsBySource(sources)
+    }).catch((error) => {
+      if (!cancelled) {
+        const detail = error instanceof Error && error.message ? `:${error.message}` : ""
+        setMessage(`读取可导出内容失败${detail}`)
+      }
+    }).finally(() => {
+      if (!cancelled) setIsLoading(false)
+    })
+    return () => { cancelled = true }
+  }, [selectedProject])
+
+  function toggleSource(source: ExportSource) {
+    setSelectedSources((current) => {
+      const next = new Set(current)
+      if (next.has(source)) next.delete(source)
+      else next.add(source)
+      return next
+    })
+  }
+
+  async function handleExport() {
+    const documents = EXPORT_SOURCES.flatMap((source) => selectedSources.has(source) ? documentsBySource[source] : [])
+    if (documents.length === 0) {
+      setMessage("请先选择至少一类可导出内容。")
+      return
+    }
+    setIsExporting(true)
+    setMessage("")
+    try {
+      const result = await exportDocuments(documents, format, undefined, () => mountedRef.current)
+      if (!mountedRef.current) return
+      setMessage(result.status === "cancelled"
+        ? "已取消导出,未继续写入文件。"
+        : `已成功导出 ${result.exportedCount} 个文件。`)
+    } catch (error) {
+      if (!mountedRef.current) return
+      const detail = error instanceof Error && error.message ? error.message : "未知错误"
+      setMessage(`导出失败:${detail}`)
+    } finally {
+      if (mountedRef.current) setIsExporting(false)
+    }
+  }
+
+  const controlsDisabled = isLoading || isExporting
+  const selectedDocumentCount = EXPORT_SOURCES.reduce(
+    (total, source) => total + (selectedSources.has(source) ? documentsBySource[source].length : 0),
+    0,
+  )
+
+  return (
+    <div data-export-center-scroll className="max-h-[calc(100vh-7rem)] space-y-6 overflow-y-auto pr-1">
+      <div>
+        <div className="flex items-center gap-2">
+          <Download className="h-5 w-5 text-primary" />
+          <h2 className="text-xl font-semibold">统一导出中心</h2>
+        </div>
+        <p className="mt-1 text-sm text-muted-foreground">
+          只读汇总项目内容,导出为 UTF-8 TXT 或真实 Word DOCX,不会修改源文件。
+        </p>
+      </div>
+
+      <section className="space-y-3 rounded-md border bg-card p-4">
+        <label className="block text-sm font-medium" htmlFor="export-project">选择项目</label>
+        <select
+          id="export-project"
+          value={selectedProjectId}
+          onChange={(event) => setSelectedProjectId(event.target.value)}
+          disabled={controlsDisabled || projects.length === 0}
+          className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
+        >
+          {projects.length === 0 ? <option value="">暂无可用项目</option> : null}
+          {projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
+        </select>
+      </section>
+
+      <fieldset className="space-y-3 rounded-md border bg-card p-4">
+        <legend className="px-1 text-sm font-medium">选择导出内容</legend>
+        <div>
+          <p className="mt-1 text-xs text-muted-foreground">没有内容的类别不可选择;每部作品保存为独立文件。</p>
+        </div>
+        <div className="grid gap-2 sm:grid-cols-2">
+          {EXPORT_SOURCES.map((source) => {
+            const count = documentsBySource[source].length
+            const unavailable = count === 0
+            return (
+              <label key={source} className={`flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm ${unavailable ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-accent/50"}`}>
+                <span className="flex items-center gap-2">
+                  <input
+                    type="checkbox"
+                    value={source}
+                    checked={selectedSources.has(source)}
+                    onChange={() => toggleSource(source)}
+                    disabled={controlsDisabled || unavailable}
+                  />
+                  {SOURCE_LABELS[source]}
+                </span>
+                <span className="text-xs text-muted-foreground">{isLoading ? "读取中" : unavailable ? "无内容" : `${count} 个文件`}</span>
+              </label>
+            )
+          })}
+        </div>
+      </fieldset>
+
+      <fieldset className="space-y-3 rounded-md border bg-card p-4">
+        <legend className="px-1 text-sm font-medium">导出格式</legend>
+        <div className="grid gap-2 sm:grid-cols-2">
+          <label className="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-accent/50">
+            <input type="radio" name="export-format" value="txt" checked={format === "txt"} onChange={() => setFormat("txt")} disabled={controlsDisabled} />
+            UTF-8 TXT
+          </label>
+          <label className="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-accent/50">
+            <input type="radio" name="export-format" value="docx" checked={format === "docx"} onChange={() => setFormat("docx")} disabled={controlsDisabled} />
+            Word DOCX
+          </label>
+        </div>
+      </fieldset>
+
+      <div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
+        <p role="status" className="min-w-0 flex-1 text-sm text-muted-foreground">
+          {message || (selectedDocumentCount > 0 ? `将导出 ${selectedDocumentCount} 个文件。` : "请选择要导出的内容。")}
+        </p>
+        <Button type="button" onClick={() => void handleExport()} disabled={controlsDisabled || !selectedProject || selectedDocumentCount === 0}>
+          {isExporting ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />正在导出…</> : "开始导出"}
+        </Button>
+      </div>
+    </div>
+  )
+}

+ 8 - 1
src/components/settings/settings-view.tsx

@@ -13,6 +13,7 @@ import {
   HeartHandshake,
   Archive,
   FileText,
+  Download,
 } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import i18n from "@/i18n"
@@ -40,6 +41,7 @@ import { FeedbackSection } from "./sections/feedback-section"
 import { UsageGuideSection } from "./sections/usage-guide-section"
 import { ContactSupportSection } from "./sections/contact-support-section"
 import { DataManagementSection } from "./sections/data-management-section"
+import { ExportCenterSection } from "./sections/export-center-section"
 
 type CategoryId =
   | "llm"
@@ -52,6 +54,7 @@ type CategoryId =
   | "usage-guide"
   | "maintenance"
   | "data-management"
+  | "export-center"
   | "feedback"
   | "contact-support"
   | "classification"
@@ -66,6 +69,7 @@ interface Category {
   /** Optional muted subtitle under the label (e.g. novel → model setup). */
   hintKey?: string
   icon: typeof Bot
+  defaultLabel?: string
 }
 
 const CATEGORIES: Category[] = [
@@ -79,6 +83,7 @@ const CATEGORIES: Category[] = [
   { id: "usage-guide", labelKey: "settings.categories.usageGuide", icon: HelpCircle },
   { id: "maintenance", labelKey: "settings.categories.maintenance", icon: Wrench },
   { id: "data-management", labelKey: "settings.categories.dataManagement", icon: Archive },
+  { id: "export-center", labelKey: "settings.categories.exportCenter", defaultLabel: "导出中心", icon: Download },
   { id: "feedback", labelKey: "settings.categories.feedback", icon: MessageCircle },
   { id: "contact-support", labelKey: "settings.categories.contactSupport", icon: HeartHandshake },
   { id: "classification", labelKey: "settings.categories.classification", icon: FileText },
@@ -539,6 +544,8 @@ export function SettingsView() {
         return <MaintenanceSection />
       case "data-management":
         return <DataManagementSection />
+      case "export-center":
+        return <ExportCenterSection currentProject={project} />
       case "feedback":
         return <FeedbackSection />
       case "contact-support":
@@ -585,7 +592,7 @@ export function SettingsView() {
                   }`}
                 />
                 <span className="flex min-w-0 flex-1 flex-col items-start">
-                  <span className="truncate">{t(c.labelKey)}</span>
+                  <span className="truncate">{t(c.labelKey, { defaultValue: c.defaultLabel })}</span>
                   {c.hintKey ? (
                     <span className={`truncate text-[10px] leading-tight ${
                       isActive ? "text-sidebar-accent-foreground/70" : "text-sidebar-foreground/55"

+ 316 - 0
src/lib/export-center/collectors.spec.ts

@@ -0,0 +1,316 @@
+import { describe, expect, it } from "vitest"
+import { collectAllProjectSources, collectSourceDocuments, type CollectorFileApi } from "./collectors"
+import type { FileNode, WikiProject } from "@/types/wiki"
+
+const project: WikiProject = { id: "p1", name: "长安夜雨", path: "C:/Novel" }
+
+function file(name: string, path: string): FileNode {
+  return { name, path, is_dir: false }
+}
+
+function dir(name: string, path: string): FileNode {
+  return { name, path, is_dir: true }
+}
+
+function api(
+  directories: Record<string, FileNode[]>,
+  files: Record<string, string>,
+): CollectorFileApi {
+  return {
+    listDirectory: async (path) => {
+      if (!(path in directories)) throw new Error("目录不存在")
+      return directories[path]
+    },
+    readFile: async (path) => {
+      if (!(path in files)) throw new Error("文件不存在")
+      return files[path]
+    },
+  }
+}
+
+describe("统一导出 Collector", () => {
+  it("按自然章节号顺序只读收集章节", async () => {
+    const source = api(
+      { "C:/Novel/wiki/chapters": [file("第10章.md", "c10"), file("第2章.md", "c2")] },
+      { c10: "# 第十章\n\n后发生", c2: "# 第二章\n\n先发生\n\n第二段" },
+    )
+
+    const documents = await collectSourceDocuments(project, "chapters", source)
+
+    expect(documents).toHaveLength(1)
+    expect(documents[0].title).toBe("长安夜雨-章节")
+    expect(documents[0].blocks.map((block) => block.title)).toEqual(["第二章", "第十章"])
+    expect(documents[0].blocks[0].paragraphs).toEqual(["先发生", "第二段"])
+  })
+
+  it("章节文件名与 frontmatter chapter_number 冲突时优先按 chapter_number 排序", async () => {
+    const source = api(
+      {
+        "C:/Novel/wiki/chapters": [
+          file("chapter-001.md", "named-first"),
+          file("chapter-999.md", "named-last"),
+        ],
+      },
+      {
+        "named-first": "---\nchapter_number: 2\ntitle: 文件名靠前但实际第二章\n---\n# 文件名靠前但实际第二章\n\n第二章正文",
+        "named-last": "---\nchapter_number: 1\ntitle: 文件名靠后但实际第一章\n---\n# 文件名靠后但实际第一章\n\n第一章正文",
+      },
+    )
+
+    const [document] = await collectSourceDocuments(project, "chapters", source)
+
+    expect(document.blocks.map((block) => block.title)).toEqual([
+      "文件名靠后但实际第一章",
+      "文件名靠前但实际第二章",
+    ])
+  })
+
+  it("大纲按产品显示标题规则提取序号,而不是按文件名排序", async () => {
+    const source = api(
+      { "C:/Novel/wiki/outlines": [file("a-文件名靠前.md", "o10"), file("z-文件名靠后.md", "o2")] },
+      {
+        o10: "---\ntitle: 第十卷 终局\n---\n# 被 frontmatter 标题覆盖\n\n终局",
+        o2: "# 第二卷 发展\n\n发展",
+      },
+    )
+
+    const [document] = await collectSourceDocuments(project, "outlines", source)
+
+    expect(document.blocks.map((block) => block.title)).toEqual(["第二卷 发展", "第十卷 终局"])
+  })
+
+  it("大纲同级目录使用 KnowledgeTree 的 zh-CN localeCompare 顺序递归导出", async () => {
+    const source = api(
+      {
+        "C:/Novel/wiki/outlines": [
+          {
+            ...dir("卷10", "dir-10"),
+            children: [file("chapter-001.md", "dir10-child")],
+          },
+          file("root-000.md", "root-file"),
+          {
+            ...dir("卷2", "dir-2"),
+            children: [file("chapter-100.md", "dir2-child")],
+          },
+        ],
+      },
+      {
+        "dir10-child": "# 第1章 卷十内容\n\n卷十",
+        "dir2-child": "# 第100章 卷二内容\n\n卷二",
+        "root-file": "# 第0章 根目录文件\n\n根目录",
+      },
+    )
+
+    const [document] = await collectSourceDocuments(project, "outlines", source)
+
+    expect(document.blocks.map((block) => block.title)).toEqual([
+      "第1章 卷十内容",
+      "第100章 卷二内容",
+      "第0章 根目录文件",
+    ])
+  })
+
+  it("大纲无元数据和标题时将文件名短横线转换为空格作为显示标题", async () => {
+    const source = api(
+      { "C:/Novel/wiki/outlines": [file("long-night-plan.md", "hyphen-outline")] },
+      { "hyphen-outline": "没有 frontmatter,也没有 Markdown 标题。" },
+    )
+
+    const [document] = await collectSourceDocuments(project, "outlines", source)
+
+    expect(document.blocks[0].title).toBe("long night plan")
+  })
+
+  it("大纲只有二级到六级标题时使用文件名显示标题并保留低级标题正文", async () => {
+    const source = api(
+      { "C:/Novel/wiki/outlines": [file("deep-outline-note.md", "deep-heading-outline")] },
+      { "deep-heading-outline": "## 二级标题\n\n正文段落\n\n###### 六级标题\n\n结尾" },
+    )
+
+    const [document] = await collectSourceDocuments(project, "outlines", source)
+
+    expect(document.blocks[0].title).toBe("deep outline note")
+    expect(document.blocks[0].paragraphs).toEqual([
+      "## 二级标题",
+      "正文段落",
+      "###### 六级标题",
+      "结尾",
+    ])
+  })
+
+  it("将拆书库中的每部作品收集为独立文档并按作品名排序", async () => {
+    const source = api(
+      {},
+      {
+        "C:/Novel/book-analysis/library.json": JSON.stringify({
+          version: 1,
+          entries: [
+            { bookId: "b10", title: "作品10", sourcePath: "book10.txt" },
+            { bookId: "b2", title: "作品2", sourcePath: "book2.txt" },
+          ],
+        }),
+        "book10.txt": "第十部正文",
+        "book2.txt": "第二部正文",
+      },
+    )
+
+    const documents = await collectSourceDocuments(project, "book-analysis", source)
+
+    expect(documents.map((document) => document.title)).toEqual(["作品2", "作品10"])
+    expect(documents[0].blocks[0].paragraphs).toEqual(["第二部正文"])
+  })
+
+  it("将剧情框架及其推演结果按顺序收集为每框架独立文档", async () => {
+    const source = api(
+      {
+        "C:/Novel/.qmai/simulations/frameworks": [file("框架10.md", "f10"), file("框架2.md", "f2")],
+        "C:/Novel/.qmai/simulations/results/框架10": [file("result-2.md", "r10-2"), file("result-1.md", "r10-1")],
+        "C:/Novel/.qmai/simulations/results/框架2": [],
+      },
+      {
+        f10: "# 框架十\n\n前提十",
+        f2: "# 框架二\n\n前提二",
+        "r10-2": "# 推演结果2\n\n后结果",
+        "r10-1": "# 推演结果1\n\n先结果",
+      },
+    )
+
+    const documents = await collectSourceDocuments(project, "story-simulation", source)
+
+    expect(documents.map((document) => document.title)).toEqual(["框架二", "框架十"])
+    expect(documents[1].blocks.map((block) => block.title)).toEqual(["框架十", "推演结果1", "推演结果2"])
+  })
+
+  it("剧情推演文件名顺序与 report.createdAt 冲突时按 createdAt 降序", async () => {
+    const source = api(
+      {
+        "C:/Novel/.qmai/simulations/frameworks": [file("主线.md", "framework-created-at")],
+        "C:/Novel/.qmai/simulations/results/主线": [
+          file("result-1.json", "older-result"),
+          file("result-2.json", "newer-result"),
+        ],
+      },
+      {
+        "framework-created-at": "# 主线\n\n故事前提",
+        "older-result": JSON.stringify({
+          report: { createdAt: "2026-01-01T00:00:00.000Z", recommendation: "旧结果", branches: [], characterAnalyses: [] },
+        }),
+        "newer-result": JSON.stringify({
+          report: { createdAt: "2026-02-01T00:00:00.000Z", recommendation: "新结果", branches: [], characterAnalyses: [] },
+        }),
+      },
+    )
+
+    const [document] = await collectSourceDocuments(project, "story-simulation", source)
+
+    expect(document.blocks.slice(1).map((block) => block.title)).toEqual(["推演结果2", "推演结果1"])
+    expect(document.blocks[1].paragraphs[0]).toBe("推荐:新结果")
+  })
+
+  it("剧情推演优先读取结构化结果并保留推荐与草稿正文", async () => {
+    const source = api(
+      {
+        "C:/Novel/.qmai/simulations/frameworks": [file("主线.md", "framework")],
+        "C:/Novel/.qmai/simulations/results/主线": [
+          file("result-1.md", "summary"),
+          file("result-1.json", "structured"),
+        ],
+      },
+      {
+        framework: "# 主线\n\n故事前提",
+        summary: "# 推演结果\n\n摘要",
+        structured: JSON.stringify({
+          report: { recommendation: "走左路", branches: [], characterAnalyses: [] },
+          draft: { chapters: [{ title: "第一章", content: "完整草稿正文" }] },
+        }),
+      },
+    )
+
+    const [document] = await collectSourceDocuments(project, "story-simulation", source)
+
+    expect(document.blocks).toHaveLength(2)
+    expect(document.blocks[1].paragraphs.join("\n")).toContain("推荐:走左路")
+    expect(document.blocks[1].paragraphs.join("\n")).toContain("第一章\n完整草稿正文")
+  })
+
+  it("仅收集自定义灵魂作品并按名称排序", async () => {
+    const source = api({}, {
+      "C:/Novel/.qmai/character-aura.json": JSON.stringify({
+        customAuras: [
+          { id: "s10", name: "灵魂10", corpus: "语料十", styleDescription: "风格十" },
+          { id: "s2", name: "灵魂2", corpus: "语料二", styleDescription: "风格二", expressionDna: "表达DNA二", mentalModel: "心智模型二" },
+        ],
+        bindings: [],
+      }),
+    })
+
+    const documents = await collectSourceDocuments(project, "soul-works", source)
+
+    expect(documents.map((document) => document.title)).toEqual(["灵魂2", "灵魂10"])
+    expect(documents[0].blocks.some((block) => block.title === "语料" && block.paragraphs[0] === "语料二")).toBe(true)
+    expect(documents[0].blocks.some((block) => block.title === "表达 DNA" && block.paragraphs[0] === "表达DNA二")).toBe(true)
+    expect(documents[0].blocks.some((block) => block.title === "心智模型" && block.paragraphs[0] === "心智模型二")).toBe(true)
+  })
+
+  it("单一来源异常时 collectAllProjectSources 保留其他来源结果", async () => {
+    const isolatedApi: CollectorFileApi = {
+      listDirectory: async (path) => {
+        if (path.endsWith("wiki/chapters")) return null as unknown as FileNode[]
+        if (path.endsWith("wiki/outlines")) return [file("outline.md", "good-outline")]
+        throw new Error("目录不存在")
+      },
+      readFile: async (path) => {
+        if (path === "good-outline") return "# 可用大纲\n\n内容"
+        throw new Error("文件不存在")
+      },
+    }
+
+    const result = await collectAllProjectSources(project, isolatedApi)
+
+    expect(result.chapters).toEqual([])
+    expect(result.outlines[0].blocks[0].title).toBe("可用大纲")
+  })
+
+  it("灵魂作品忽略 null、数组和非对象元素,只导出合法对象", async () => {
+    const source = api({}, {
+      "C:/Novel/.qmai/character-aura.json": JSON.stringify({
+        customAuras: [null, "错误", [], { name: "合法灵魂", corpus: "合法语料" }],
+      }),
+    })
+
+    const documents = await collectSourceDocuments(project, "soul-works", source)
+
+    expect(documents.map((document) => document.title)).toEqual(["合法灵魂"])
+  })
+
+  it("拆书章节回退中单个文件读取失败不影响其他可读章节", async () => {
+    const source = api(
+      {
+        "C:/Novel/book-analysis/b1/chapters": [
+          file("chapter-1.md", "broken-chapter"),
+          file("chapter-2.md", "good-chapter"),
+        ],
+      },
+      {
+        "C:/Novel/book-analysis/library.json": JSON.stringify({
+          version: 1,
+          entries: [{ bookId: "b1", title: "回退作品", sourcePath: "missing-source.txt" }],
+        }),
+        "good-chapter": "# 第二章\n\n可读正文",
+      },
+    )
+
+    const documents = await collectSourceDocuments(project, "book-analysis", source)
+
+    expect(documents).toHaveLength(1)
+    expect(documents[0].blocks.map((block) => block.title)).toEqual(["第二章"])
+  })
+
+  it("五类来源缺失时返回空结果并标记不可用", async () => {
+    const missingApi = api({}, {})
+
+    const result = await collectAllProjectSources(project, missingApi)
+
+    expect(Object.values(result).every((documents) => documents.length === 0)).toBe(true)
+  })
+})

+ 418 - 0
src/lib/export-center/collectors.ts

@@ -0,0 +1,418 @@
+import { listDirectory, readFile } from "@/commands/fs"
+import { parseFrontmatter } from "@/lib/frontmatter"
+import type { FileNode, WikiProject } from "@/types/wiki"
+import type { ExportDocument, ExportDocumentBlock, ExportSource } from "./types"
+
+export interface CollectorFileApi {
+  listDirectory(path: string): Promise<FileNode[]>
+  readFile(path: string): Promise<string>
+}
+
+const defaultFileApi: CollectorFileApi = { listDirectory, readFile }
+const collator = new Intl.Collator("zh-CN", { numeric: true, sensitivity: "base" })
+export const EXPORT_SOURCES: ExportSource[] = [
+  "chapters",
+  "outlines",
+  "book-analysis",
+  "story-simulation",
+  "soul-works",
+]
+
+function joinPath(...parts: string[]): string {
+  return parts.map((part, index) => index === 0 ? part.replace(/[\\/]+$/g, "") : part.replace(/^[\\/]+|[\\/]+$/g, "")).join("/")
+}
+
+function baseName(path: string): string {
+  return path.split(/[\\/]/).pop()?.replace(/\.[^.]+$/i, "") || "未命名内容"
+}
+
+function parseChineseOrderNumber(value: string): number | null {
+  if (/^\d+$/.test(value)) return Number.parseInt(value, 10)
+  const digits: Record<string, number> = {
+    零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4,
+    五: 5, 六: 6, 七: 7, 八: 8, 九: 9,
+  }
+  const tenIndex = value.indexOf("十")
+  if (tenIndex >= 0) {
+    const left = value.slice(0, tenIndex)
+    const right = value.slice(tenIndex + 1)
+    return (left ? digits[left] ?? 0 : 1) * 10 + (right ? digits[right] ?? 0 : 0)
+  }
+  let result = 0
+  for (const character of value) {
+    if (digits[character] === undefined) return null
+    result = result * 10 + digits[character]
+  }
+  return result || null
+}
+
+function extractPageOrderFromTitle(title: string): number | null {
+  const structured = title.match(/第\s*([0-9零一二两三四五六七八九十]+)\s*[章节卷]/)
+  if (structured?.[1]) return parseChineseOrderNumber(structured[1])
+  const numeric = title.match(/(\d+)/)
+  return numeric?.[1] ? Number.parseInt(numeric[1], 10) : null
+}
+
+function readFrontmatterChapterNumber(content: string): number | null {
+  const raw = parseFrontmatter(content).frontmatter?.chapter_number
+  const number = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN
+  return Number.isFinite(number) && number > 0 ? number : null
+}
+
+function contentBlock(content: string, fallbackTitle: string): ExportDocumentBlock {
+  const parsed = parseFrontmatter(content)
+  const normalized = parsed.body.replace(/\r\n/g, "\n").trim()
+  const lines = normalized.split("\n")
+  const headingIndex = lines.findIndex((line) => /^#\s+/.test(line))
+  const headingTitle = headingIndex >= 0
+    ? lines[headingIndex].replace(/^#\s+/, "").trim()
+    : ""
+  const frontmatterTitle = typeof parsed.frontmatter?.title === "string"
+    ? parsed.frontmatter.title.trim()
+    : ""
+  const title = frontmatterTitle || headingTitle || fallbackTitle
+  if (headingIndex >= 0) lines.splice(headingIndex, 1)
+  const paragraphs = lines
+    .join("\n")
+    .split(/\n\s*\n/)
+    .map((paragraph) => paragraph.trim())
+    .filter(Boolean)
+  return { title, paragraphs }
+}
+
+function flattenMarkdownFiles(nodes: FileNode[]): FileNode[] {
+  const files: FileNode[] = []
+  for (const node of nodes) {
+    if (node.is_dir) {
+      if (node.children) files.push(...flattenMarkdownFiles(node.children))
+    } else if (node.name.toLowerCase().endsWith(".md")) {
+      files.push(node)
+    }
+  }
+  return files.sort((a, b) => collator.compare(a.name, b.name))
+}
+
+interface CollectedMarkdown {
+  block: ExportDocumentBlock
+  fileName: string
+  order: number | null
+}
+
+function displayTitleFromFileName(fileName: string): string {
+  return baseName(fileName).replace(/-/g, " ")
+}
+
+function sortCollectedMarkdown(items: CollectedMarkdown[]): CollectedMarkdown[] {
+  return items.sort((left, right) => {
+    if (left.order !== null && right.order !== null && left.order !== right.order) {
+      return left.order - right.order
+    }
+    if (left.order !== null && right.order === null) return -1
+    if (left.order === null && right.order !== null) return 1
+    return collator.compare(left.block.title, right.block.title)
+      || collator.compare(left.fileName, right.fileName)
+  })
+}
+
+async function collectMarkdownTree(
+  nodes: FileNode[],
+  source: "chapters" | "outlines",
+  api: CollectorFileApi,
+): Promise<CollectedMarkdown[]> {
+  const directories = nodes
+    .filter((node) => node.is_dir)
+    .sort((left, right) => left.name.localeCompare(right.name, "zh-CN"))
+  const result: CollectedMarkdown[] = []
+
+  // KnowledgeTree 同级顺序:目录始终排在文件之前,并递归输出目录内容。
+  for (const directory of directories) {
+    result.push(...await collectMarkdownTree(directory.children ?? [], source, api))
+  }
+
+  const siblingFiles: CollectedMarkdown[] = []
+  for (const file of nodes.filter((node) => !node.is_dir && node.name.toLowerCase().endsWith(".md"))) {
+    try {
+      const content = await api.readFile(file.path)
+      const fallbackTitle = displayTitleFromFileName(file.name)
+      const block = contentBlock(content, fallbackTitle)
+      siblingFiles.push({
+        block,
+        fileName: file.name,
+        order: source === "chapters"
+          ? (readFrontmatterChapterNumber(content)
+            ?? extractPageOrderFromTitle(block.title)
+            ?? extractPageOrderFromTitle(fallbackTitle))
+          : (extractPageOrderFromTitle(block.title)
+            ?? extractPageOrderFromTitle(fallbackTitle)),
+      })
+    } catch {
+      // 单个文件不可读时跳过,其余内容仍可导出。
+    }
+  }
+  result.push(...sortCollectedMarkdown(siblingFiles))
+  return result
+}
+
+async function collectMarkdownDirectory(
+  project: WikiProject,
+  source: "chapters" | "outlines",
+  directory: string,
+  api: CollectorFileApi,
+): Promise<ExportDocument[]> {
+  let nodes: FileNode[]
+  try {
+    nodes = await api.listDirectory(directory)
+  } catch {
+    return []
+  }
+  const collected = await collectMarkdownTree(nodes, source, api)
+  const blocks = collected.map((item) => item.block)
+  if (blocks.length === 0) return []
+  return [{
+    title: `${project.name}-${source === "chapters" ? "章节" : "大纲"}`,
+    source,
+    blocks,
+  }]
+}
+interface BookLibraryEntry {
+  bookId: string
+  title: string
+  sourcePath: string
+}
+
+async function collectBookAnalysis(project: WikiProject, api: CollectorFileApi): Promise<ExportDocument[]> {
+  let entries: BookLibraryEntry[]
+  try {
+    const parsed = JSON.parse(await api.readFile(joinPath(project.path, "book-analysis/library.json"))) as { entries?: BookLibraryEntry[] }
+    entries = Array.isArray(parsed.entries) ? parsed.entries : []
+  } catch {
+    return []
+  }
+
+  const documents: ExportDocument[] = []
+  for (const entry of entries) {
+    let blocks: ExportDocumentBlock[] = []
+    try {
+      blocks = [contentBlock(await api.readFile(entry.sourcePath), "正文")]
+    } catch {
+      try {
+        const chapterDir = joinPath(project.path, "book-analysis", entry.bookId, "chapters")
+        const chapterFiles = flattenMarkdownFiles(await api.listDirectory(chapterDir))
+        for (const file of chapterFiles) {
+          try {
+            blocks.push(contentBlock(await api.readFile(file.path), baseName(file.name)))
+          } catch {
+            // 单个拆书章节损坏时保留其他可读章节。
+          }
+        }
+      } catch {
+        blocks = []
+      }
+    }
+    if (blocks.length > 0) {
+      documents.push({ title: entry.title || entry.bookId, source: "book-analysis", blocks })
+    }
+  }
+  return documents.sort((a, b) => collator.compare(a.title, b.title))
+}
+
+function flattenResultFiles(nodes: FileNode[]): FileNode[] {
+  const files: FileNode[] = []
+  for (const node of nodes) {
+    if (node.is_dir) {
+      if (node.children) files.push(...flattenResultFiles(node.children))
+    } else if (/\.(?:json|md)$/i.test(node.name)) {
+      files.push(node)
+    }
+  }
+  return files.sort((a, b) => collator.compare(a.name, b.name))
+}
+
+function simulationResultBlock(content: string, fallbackTitle: string): {
+  block: ExportDocumentBlock
+  createdAt: string
+} {
+  const parsed = JSON.parse(content) as {
+    report?: {
+      recommendation?: string
+      createdAt?: string
+      branches?: unknown[]
+      characterAnalyses?: unknown[]
+    }
+    draft?: { chapters?: Array<{ title?: string; content?: string }> } | null
+    timelineEvents?: unknown[]
+    rumors?: unknown[]
+  }
+  const paragraphs: string[] = []
+  if (parsed.report?.recommendation) paragraphs.push(`推荐:${parsed.report.recommendation}`)
+  for (const branch of parsed.report?.branches ?? []) {
+    paragraphs.push(`剧情分支:\n${JSON.stringify(branch, null, 2)}`)
+  }
+  for (const analysis of parsed.report?.characterAnalyses ?? []) {
+    paragraphs.push(`角色分析:\n${JSON.stringify(analysis, null, 2)}`)
+  }
+  for (const chapter of parsed.draft?.chapters ?? []) {
+    paragraphs.push(`${chapter.title?.trim() || "未命名章节"}\n${chapter.content?.trim() || ""}`.trim())
+  }
+  if (parsed.timelineEvents?.length) paragraphs.push(`时间线:\n${JSON.stringify(parsed.timelineEvents, null, 2)}`)
+  if (parsed.rumors?.length) paragraphs.push(`传闻:\n${JSON.stringify(parsed.rumors, null, 2)}`)
+  return {
+    block: { title: fallbackTitle, paragraphs },
+    createdAt: typeof parsed.report?.createdAt === "string" ? parsed.report.createdAt : "",
+  }
+}
+
+async function collectStorySimulation(project: WikiProject, api: CollectorFileApi): Promise<ExportDocument[]> {
+  const frameworksDir = joinPath(project.path, ".qmai/simulations/frameworks")
+  let frameworks: FileNode[]
+  try {
+    frameworks = flattenMarkdownFiles(await api.listDirectory(frameworksDir))
+  } catch {
+    return []
+  }
+
+  const documents: ExportDocument[] = []
+  for (const framework of frameworks) {
+    let frameworkBlock: ExportDocumentBlock
+    try {
+      frameworkBlock = contentBlock(await api.readFile(framework.path), baseName(framework.name))
+    } catch {
+      continue
+    }
+    const frameworkId = baseName(framework.name)
+    const blocks = [frameworkBlock]
+    try {
+      const resultFiles = flattenResultFiles(
+        await api.listDirectory(joinPath(project.path, ".qmai/simulations/results", frameworkId)),
+      )
+      const structuredIds = new Set(
+        resultFiles.filter((file) => file.name.toLowerCase().endsWith(".json")).map((file) => baseName(file.name)),
+      )
+      const preferredFiles = resultFiles.filter(
+        (file) => file.name.toLowerCase().endsWith(".json") || !structuredIds.has(baseName(file.name)),
+      )
+      const collectedResults: Array<{
+        block: ExportDocumentBlock
+        createdAt: string
+        fileName: string
+      }> = []
+      for (const result of preferredFiles) {
+        try {
+          const raw = await api.readFile(result.path)
+          if (result.name.toLowerCase().endsWith(".json")) {
+            const parsed = simulationResultBlock(raw, baseName(result.name).replace(/^result-/i, "推演结果"))
+            collectedResults.push({ ...parsed, fileName: result.name })
+          } else {
+            const createdAt = raw.match(/^-\s*生成时间:\s*(.+)$/m)?.[1]?.trim() ?? ""
+            collectedResults.push({
+              block: contentBlock(raw, baseName(result.name)),
+              createdAt,
+              fileName: result.name,
+            })
+          }
+        } catch {
+          // 跳过损坏的单个推演结果。
+        }
+      }
+      collectedResults.sort((left, right) => {
+        if (left.createdAt && right.createdAt && left.createdAt !== right.createdAt) {
+          return left.createdAt < right.createdAt ? 1 : -1
+        }
+        if (left.createdAt && !right.createdAt) return -1
+        if (!left.createdAt && right.createdAt) return 1
+        return collator.compare(left.fileName, right.fileName)
+      })
+      blocks.push(...collectedResults.map((result) => result.block))
+    } catch {
+      // 没有推演结果时仍导出框架。
+    }
+    documents.push({ title: frameworkBlock.title, source: "story-simulation", blocks })
+  }
+  return documents.sort((a, b) => collator.compare(a.title, b.title))
+}
+
+interface SoulWork {
+  name?: string
+  sourceNote?: string
+  corpus?: string
+  styleDescription?: string
+  behaviorRules?: string
+  boundaries?: string
+  notes?: string
+  expressionDna?: string
+  mentalModel?: string
+  decisionHeuristics?: string
+  valueAntiPatterns?: string
+  honestyBoundaries?: string
+  sourceUrls?: string
+  localDocumentPaths?: string
+  generationPrompt?: string
+}
+
+async function collectSoulWorks(project: WikiProject, api: CollectorFileApi): Promise<ExportDocument[]> {
+  let works: SoulWork[]
+  try {
+    const parsed = JSON.parse(await api.readFile(joinPath(project.path, ".qmai/character-aura.json"))) as { customAuras?: SoulWork[] }
+    works = Array.isArray(parsed.customAuras)
+      ? parsed.customAuras.filter((work): work is SoulWork => typeof work === "object" && work !== null && !Array.isArray(work))
+      : []
+  } catch {
+    return []
+  }
+  const fieldLabels: Array<[keyof SoulWork, string]> = [
+    ["sourceNote", "来源说明"],
+    ["corpus", "语料"],
+    ["styleDescription", "风格描述"],
+    ["behaviorRules", "行为规则"],
+    ["boundaries", "边界"],
+    ["notes", "备注"],
+    ["expressionDna", "表达 DNA"],
+    ["mentalModel", "心智模型"],
+    ["decisionHeuristics", "决策启发"],
+    ["valueAntiPatterns", "价值反模式"],
+    ["honestyBoundaries", "诚实边界"],
+    ["sourceUrls", "来源链接"],
+    ["localDocumentPaths", "本地资料"],
+    ["generationPrompt", "生成提示词"],
+  ]
+  return works.map((work, index) => ({
+    title: work.name?.trim() || `未命名灵魂作品${index + 1}`,
+    source: "soul-works" as const,
+    blocks: fieldLabels
+      .filter(([key]) => typeof work[key] === "string" && work[key]?.trim())
+      .map(([key, title]) => ({ title, paragraphs: [String(work[key]).trim()] })),
+  })).filter((document) => document.blocks.length > 0)
+    .sort((a, b) => collator.compare(a.title, b.title))
+}
+
+export async function collectSourceDocuments(
+  project: WikiProject,
+  source: ExportSource,
+  api: CollectorFileApi = defaultFileApi,
+): Promise<ExportDocument[]> {
+  switch (source) {
+    case "chapters":
+      return collectMarkdownDirectory(project, source, joinPath(project.path, "wiki/chapters"), api)
+    case "outlines":
+      return collectMarkdownDirectory(project, source, joinPath(project.path, "wiki/outlines"), api)
+    case "book-analysis":
+      return collectBookAnalysis(project, api)
+    case "story-simulation":
+      return collectStorySimulation(project, api)
+    case "soul-works":
+      return collectSoulWorks(project, api)
+  }
+}
+
+export async function collectAllProjectSources(
+  project: WikiProject,
+  api: CollectorFileApi = defaultFileApi,
+): Promise<Record<ExportSource, ExportDocument[]>> {
+  const settled = await Promise.allSettled(
+    EXPORT_SOURCES.map((source) => collectSourceDocuments(project, source, api)),
+  )
+  return Object.fromEntries(EXPORT_SOURCES.map((source, index) => [
+    source,
+    settled[index].status === "fulfilled" ? settled[index].value : [],
+  ])) as Record<ExportSource, ExportDocument[]>
+}

+ 87 - 0
src/lib/export-center/docx-exporter.spec.ts

@@ -0,0 +1,87 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest"
+import { serializeDocx } from "./docx-exporter"
+import type { ExportDocument } from "./types"
+
+function readStoredZipEntries(bytes: Uint8Array): Map<string, string> {
+  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+  const decoder = new TextDecoder()
+  const entries = new Map<string, string>()
+  let offset = 0
+  while (offset + 30 <= bytes.length && view.getUint32(offset, true) === 0x04034b50) {
+    const compressedSize = view.getUint32(offset + 18, true)
+    const nameLength = view.getUint16(offset + 26, true)
+    const extraLength = view.getUint16(offset + 28, true)
+    const nameStart = offset + 30
+    const dataStart = nameStart + nameLength + extraLength
+    const name = decoder.decode(bytes.slice(nameStart, nameStart + nameLength))
+    entries.set(name, decoder.decode(bytes.slice(dataStart, dataStart + compressedSize)))
+    offset = dataStart + compressedSize
+  }
+  return entries
+}
+
+const document: ExportDocument = {
+  title: "山河故人",
+  source: "chapters",
+  blocks: [
+    { title: "第一章", paragraphs: ["中文正文", "A < B & C"] },
+    { title: "第二章", paragraphs: ["结尾"] },
+  ],
+}
+
+describe("DOCX 导出器", () => {
+  it("生成包含 Office Open XML 必需文件的 ZIP 包", () => {
+    const bytes = serializeDocx(document)
+    const entries = readStoredZipEntries(bytes)
+
+    expect(Array.from(bytes.slice(0, 2))).toEqual([0x50, 0x4b])
+    expect(entries.has("[Content_Types].xml")).toBe(true)
+    expect(entries.has("_rels/.rels")).toBe(true)
+    expect(entries.has("word/document.xml")).toBe(true)
+  })
+
+  it("过滤 XML 1.0 禁止控制字符且生成的 document.xml 可由 DOMParser 解析", () => {
+    const withControls: ExportDocument = {
+      title: "控制字符\u0001标题",
+      source: "chapters",
+      blocks: [{ title: "正文\u000b", paragraphs: ["保留制表符\t换行\n回车\r,删除\u0008\u000c\u001f"] }],
+    }
+    const xml = readStoredZipEntries(serializeDocx(withControls)).get("word/document.xml") ?? ""
+    const parsed = new DOMParser().parseFromString(xml, "application/xml")
+
+    expect(xml).not.toMatch(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/)
+    expect(xml).toContain("保留制表符\t换行\n回车\r,删除")
+    expect(parsed.querySelector("parsererror")).toBeNull()
+  })
+
+  it("在 document.xml 中按顺序保留标题、中文和独立段落", () => {
+    const xml = readStoredZipEntries(serializeDocx(document)).get("word/document.xml") ?? ""
+
+    expect(xml).toContain("山河故人")
+    expect(xml).toContain("第一章")
+    expect(xml).toContain("中文正文")
+    expect(xml).toContain("A &lt; B &amp; C")
+    expect(xml.indexOf("第一章")).toBeLessThan(xml.indexOf("第二章"))
+    expect((xml.match(/<w:p>/g) ?? []).length).toBe(7)
+  })
+})
+
+it("中央目录记录与本地文件数据一致,可被标准 ZIP 读取器定位", () => {
+  const bytes = serializeDocx(document)
+  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+  let centralOffset = -1
+  for (let offset = 0; offset <= bytes.length - 4; offset += 1) {
+    if (view.getUint32(offset, true) === 0x02014b50) {
+      centralOffset = offset
+      break
+    }
+  }
+
+  expect(centralOffset).toBeGreaterThan(0)
+  const localOffset = view.getUint32(centralOffset + 42, true)
+  expect(view.getUint32(localOffset, true)).toBe(0x04034b50)
+  expect(view.getUint32(centralOffset + 16, true)).toBe(view.getUint32(localOffset + 14, true))
+  expect(view.getUint32(centralOffset + 20, true)).toBe(view.getUint32(localOffset + 18, true))
+  expect(view.getUint32(centralOffset + 24, true)).toBe(view.getUint32(localOffset + 22, true))
+})

+ 133 - 0
src/lib/export-center/docx-exporter.ts

@@ -0,0 +1,133 @@
+import type { ExportDocument } from "./types"
+
+interface ZipEntry {
+  name: Uint8Array
+  data: Uint8Array
+  crc: number
+  offset: number
+}
+
+const encoder = new TextEncoder()
+
+function xmlEscape(value: string): string {
+  const xml10 = Array.from(value).filter((character) => {
+    const codePoint = character.codePointAt(0) ?? 0
+    return codePoint === 0x09
+      || codePoint === 0x0a
+      || codePoint === 0x0d
+      || (codePoint >= 0x20 && codePoint <= 0xd7ff)
+      || (codePoint >= 0xe000 && codePoint <= 0xfffd)
+      || (codePoint >= 0x10000 && codePoint <= 0x10ffff)
+  }).join("")
+  return xml10
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&apos;")
+}
+
+function crc32(bytes: Uint8Array): number {
+  let crc = 0xffffffff
+  for (const byte of bytes) {
+    crc ^= byte
+    for (let bit = 0; bit < 8; bit += 1) {
+      crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0)
+    }
+  }
+  return (crc ^ 0xffffffff) >>> 0
+}
+
+function concat(parts: Uint8Array[]): Uint8Array {
+  const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
+  let offset = 0
+  for (const part of parts) {
+    result.set(part, offset)
+    offset += part.length
+  }
+  return result
+}
+
+function localHeader(entry: ZipEntry): Uint8Array {
+  const header = new Uint8Array(30)
+  const view = new DataView(header.buffer)
+  view.setUint32(0, 0x04034b50, true)
+  view.setUint16(4, 20, true)
+  view.setUint16(6, 0x0800, true)
+  view.setUint16(8, 0, true)
+  view.setUint16(10, 0, true)
+  view.setUint16(12, 0x21, true)
+  view.setUint32(14, entry.crc, true)
+  view.setUint32(18, entry.data.length, true)
+  view.setUint32(22, entry.data.length, true)
+  view.setUint16(26, entry.name.length, true)
+  return concat([header, entry.name, entry.data])
+}
+
+function centralHeader(entry: ZipEntry): Uint8Array {
+  const header = new Uint8Array(46)
+  const view = new DataView(header.buffer)
+  view.setUint32(0, 0x02014b50, true)
+  view.setUint16(4, 20, true)
+  view.setUint16(6, 20, true)
+  view.setUint16(8, 0x0800, true)
+  view.setUint16(10, 0, true)
+  view.setUint16(12, 0, true)
+  view.setUint16(14, 0x21, true)
+  view.setUint32(16, entry.crc, true)
+  view.setUint32(20, entry.data.length, true)
+  view.setUint32(24, entry.data.length, true)
+  view.setUint16(28, entry.name.length, true)
+  view.setUint32(42, entry.offset, true)
+  return concat([header, entry.name])
+}
+
+function createStoredZip(files: Array<{ name: string; content: string }>): Uint8Array {
+  const localParts: Uint8Array[] = []
+  const entries: ZipEntry[] = []
+  let offset = 0
+  for (const file of files) {
+    const name = encoder.encode(file.name)
+    const data = encoder.encode(file.content)
+    const entry = { name, data, crc: crc32(data), offset }
+    const local = localHeader(entry)
+    entries.push(entry)
+    localParts.push(local)
+    offset += local.length
+  }
+
+  const centralParts = entries.map(centralHeader)
+  const central = concat(centralParts)
+  const end = new Uint8Array(22)
+  const endView = new DataView(end.buffer)
+  endView.setUint32(0, 0x06054b50, true)
+  endView.setUint16(8, entries.length, true)
+  endView.setUint16(10, entries.length, true)
+  endView.setUint32(12, central.length, true)
+  endView.setUint32(16, offset, true)
+  return concat([...localParts, central, end])
+}
+
+function paragraph(text: string): string {
+  return `<w:p><w:r><w:t xml:space="preserve">${xmlEscape(text)}</w:t></w:r></w:p>`
+}
+
+export function serializeDocx(document: ExportDocument): Uint8Array {
+  const paragraphs = [document.title, ""]
+  for (const block of document.blocks) {
+    paragraphs.push(block.title, ...block.paragraphs)
+  }
+  const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${paragraphs.map(paragraph).join("")}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`
+
+  return createStoredZip([
+    {
+      name: "[Content_Types].xml",
+      content: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>',
+    },
+    {
+      name: "_rels/.rels",
+      content: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>',
+    },
+    { name: "word/document.xml", content: documentXml },
+  ])
+}

+ 125 - 0
src/lib/export-center/export-service.spec.ts

@@ -0,0 +1,125 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it, vi } from "vitest"
+import { assertExportByteLength, encodeExportBytesBase64, exportDocuments, MAX_EXPORT_BYTES, type ExportServiceDeps } from "./export-service"
+import type { ExportDocument } from "./types"
+
+const documents: ExportDocument[] = [
+  { title: '作品<>:"一', source: "book-analysis", blocks: [{ title: "正文", paragraphs: ["甲"] }] },
+  { title: "作品二", source: "book-analysis", blocks: [{ title: "正文", paragraphs: ["乙"] }] },
+]
+
+function deps(paths: Array<string | null>): ExportServiceDeps & {
+  saveFile: ReturnType<typeof vi.fn>
+  writeBinary: ReturnType<typeof vi.fn>
+} {
+  return {
+    saveFile: vi.fn().mockImplementation(async () => paths.shift() ?? null),
+    writeBinary: vi.fn().mockResolvedValue(undefined),
+  }
+}
+
+describe("统一导出保存服务", () => {
+  it("导出原始字节允许 64 MiB 边界并以中文拒绝超限", () => {
+    expect(() => assertExportByteLength(MAX_EXPORT_BYTES)).not.toThrow()
+    expect(() => assertExportByteLength(MAX_EXPORT_BYTES + 1)).toThrow("导出文件超过 64 MiB 限制")
+  })
+
+  it("将任意二进制字节稳定编码为 base64 IPC 内容", () => {
+    expect(encodeExportBytesBase64(new Uint8Array([0x00, 0x80, 0xff]))).toBe("AID/")
+  })
+  it("取消保存窗口返回 cancelled,且不写文件", async () => {
+    const adapters = deps([null])
+
+    const result = await exportDocuments([documents[0]], "txt", adapters)
+
+    expect(result).toEqual({ status: "cancelled", exportedCount: 0 })
+    expect(adapters.writeBinary).not.toHaveBeenCalled()
+  })
+
+  it("清理默认文件名并将每部作品保存为独立 TXT 文件", async () => {
+    const adapters = deps(["C:/Export/作品一.txt", "C:/Export/作品二.txt"])
+
+    const result = await exportDocuments(documents, "txt", adapters)
+
+    expect(result).toEqual({ status: "success", exportedCount: 2 })
+    expect(adapters.saveFile.mock.calls.map((call) => call[0].defaultPath)).toEqual([
+      "作品一.txt",
+      "作品二.txt",
+    ])
+    expect(adapters.writeBinary).toHaveBeenCalledTimes(2)
+    expect(new TextDecoder().decode(adapters.writeBinary.mock.calls[0][1])).toContain("甲")
+  })
+
+  it("卸载守卫失效后停止打开后续保存窗口", async () => {
+    const adapters = deps(["C:/Export/作品一.txt", "C:/Export/作品二.txt"])
+    let active = true
+    adapters.writeBinary.mockImplementationOnce(async () => { active = false })
+
+    const result = await exportDocuments(documents, "txt", adapters, () => active)
+
+    expect(result).toEqual({ status: "cancelled", exportedCount: 1 })
+    expect(adapters.saveFile).toHaveBeenCalledTimes(1)
+  })
+
+  it("DOCX 保存窗口和写入数据使用真实 docx 扩展名与 ZIP 字节", async () => {
+    const adapters = deps(["C:/Export/作品一.docx"])
+
+    await exportDocuments([documents[0]], "docx", adapters)
+
+    expect(adapters.saveFile.mock.calls[0][0]).toMatchObject({
+      defaultPath: "作品一.docx",
+      filters: [{ name: "Word 文档", extensions: ["docx"] }],
+    })
+    expect(Array.from(adapters.writeBinary.mock.calls[0][1].slice(0, 2))).toEqual([0x50, 0x4b])
+  })
+
+  it("写入失败时抛出中文错误且停止后续作品", async () => {
+    const adapters = deps(["C:/Export/作品一.txt", "C:/Export/作品二.txt"])
+    adapters.writeBinary.mockRejectedValueOnce(new Error("disk full"))
+
+    await expect(exportDocuments(documents, "txt", adapters)).rejects.toThrow("导出文件写入失败,请检查保存位置和磁盘空间。")
+    expect(adapters.saveFile).toHaveBeenCalledTimes(1)
+  })
+})
+
+it("Rust 临时文件使用同一可写句柄 write_all 和 sync_all,关闭后再原子替换", () => {
+  const fsSource = readFileSync(resolve(process.cwd(), "src-tauri/src/commands/fs.rs"), "utf8")
+  const exportStart = fsSource.indexOf("fn do_write_export_file_with_replace")
+  const exportEnd = fsSource.indexOf("pub fn do_write_export_file(", exportStart)
+  const implementation = fsSource.slice(exportStart, exportEnd)
+
+  expect(implementation).toContain("fs::OpenOptions::new()")
+  expect(implementation).toContain(".write(true)")
+  expect(implementation).toContain(".create_new(true)")
+  expect(implementation).toContain("file.write_all(bytes)")
+  expect(implementation).toContain("file.sync_all()")
+  expect(implementation).toContain("drop(file)")
+  expect(implementation.indexOf("drop(file)")).toBeLessThan(implementation.indexOf("replace(&temp, destination)"))
+  expect(implementation).not.toContain("fs::write(&temp, bytes)")
+  expect(implementation).not.toContain("fs::File::open(&temp)")
+})
+
+it("默认二进制写入桥接在 Rust 后端实现并注册", () => {
+  const fsSource = readFileSync(resolve(process.cwd(), "src-tauri/src/commands/fs.rs"), "utf8")
+  const libSource = readFileSync(resolve(process.cwd(), "src-tauri/src/lib.rs"), "utf8")
+
+  expect(fsSource).toContain("pub async fn write_export_file")
+  expect(fsSource).toContain("file.write_all(bytes)")
+  expect(fsSource).toContain("pub fn do_write_export_file_base64")
+  expect(fsSource).toContain("STANDARD as B64")
+  expect(fsSource).toContain(".decode(contents_base64)")
+  expect(fsSource).toContain("write_export_file_decodes_base64_before_writing")
+  expect(fsSource).not.toContain("let path = resolve_project_storage_path(path);\n        let p = Path::new(&path);\n        if let Some(parent) = p.parent()")
+  expect(fsSource).toContain("do_write_export_file_with_replace")
+  expect(fsSource).toContain("replace_export_file_atomically")
+  expect(fsSource).toContain("write_export_file_uses_dialog_path_without_project_path_rewrite")
+  expect(fsSource).toContain("write_export_file_replace_failure_keeps_old_file_and_removes_temp_file")
+  expect(fsSource).toContain("write_export_file_atomically_replaces_existing_file")
+  expect(fsSource).toContain("MAX_EXPORT_BYTES")
+  expect(fsSource).toContain("validate_export_base64_length")
+  expect(fsSource).toContain("write_export_file_enforces_64_mib_boundary")
+  expect(libSource).toContain("commands::fs::write_export_file")
+  const serviceSource = readFileSync(resolve(process.cwd(), "src/lib/export-center/export-service.ts"), "utf8")
+  expect(serviceSource).toContain("contentsBase64: encodeExportBytesBase64(bytes)")
+})

+ 87 - 0
src/lib/export-center/export-service.ts

@@ -0,0 +1,87 @@
+import { invoke } from "@tauri-apps/api/core"
+import { serializeDocx } from "./docx-exporter"
+import { sanitizeExportFileName, serializeTxt } from "./txt-exporter"
+import type { ExportDocument, ExportFormat } from "./types"
+
+interface SaveOptions {
+  defaultPath: string
+  filters: Array<{ name: string; extensions: string[] }>
+}
+
+export interface ExportServiceDeps {
+  saveFile(options: SaveOptions): Promise<string | null>
+  writeBinary(path: string, bytes: Uint8Array): Promise<void>
+}
+
+export interface ExportRunResult {
+  status: "success" | "cancelled"
+  exportedCount: number
+}
+
+export const MAX_EXPORT_BYTES = 64 * 1024 * 1024
+
+export function assertExportByteLength(byteLength: number): void {
+  if (byteLength > MAX_EXPORT_BYTES) {
+    throw new Error("导出文件超过 64 MiB 限制,请缩小导出范围。")
+  }
+}
+
+const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+
+export function encodeExportBytesBase64(bytes: Uint8Array): string {
+  assertExportByteLength(bytes.byteLength)
+  let output = ""
+  for (let index = 0; index < bytes.length; index += 3) {
+    const first = bytes[index]
+    const hasSecond = index + 1 < bytes.length
+    const hasThird = index + 2 < bytes.length
+    const second = hasSecond ? bytes[index + 1] : 0
+    const third = hasThird ? bytes[index + 2] : 0
+    const combined = (first << 16) | (second << 8) | third
+    output += BASE64_ALPHABET[(combined >>> 18) & 0x3f]
+    output += BASE64_ALPHABET[(combined >>> 12) & 0x3f]
+    output += hasSecond ? BASE64_ALPHABET[(combined >>> 6) & 0x3f] : "="
+    output += hasThird ? BASE64_ALPHABET[combined & 0x3f] : "="
+  }
+  return output
+}
+
+const defaultDeps: ExportServiceDeps = {
+  saveFile: async (options) => {
+    const { save } = await import("@tauri-apps/plugin-dialog")
+    return save(options)
+  },
+  writeBinary: async (path, bytes) => {
+    await invoke("write_export_file", { path, contentsBase64: encodeExportBytesBase64(bytes) })
+  },
+}
+
+export async function exportDocuments(
+  documents: ExportDocument[],
+  format: ExportFormat,
+  deps: ExportServiceDeps = defaultDeps,
+  shouldContinue: () => boolean = () => true,
+): Promise<ExportRunResult> {
+  let exportedCount = 0
+  const filter = format === "txt"
+    ? { name: "UTF-8 文本", extensions: ["txt"] }
+    : { name: "Word 文档", extensions: ["docx"] }
+
+  for (const document of documents) {
+    if (!shouldContinue()) return { status: "cancelled", exportedCount }
+    const defaultPath = `${sanitizeExportFileName(document.title)}.${format}`
+    const path = await deps.saveFile({ defaultPath, filters: [filter] })
+    if (!path) return { status: "cancelled", exportedCount }
+
+    const bytes = format === "txt" ? serializeTxt(document) : serializeDocx(document)
+    assertExportByteLength(bytes.byteLength)
+    try {
+      await deps.writeBinary(path, bytes)
+    } catch {
+      throw new Error("导出文件写入失败,请检查保存位置和磁盘空间。")
+    }
+    exportedCount += 1
+  }
+
+  return { status: "success", exportedCount }
+}

+ 39 - 0
src/lib/export-center/txt-exporter.spec.ts

@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest"
+import { MAX_EXPORT_FILE_NAME_LENGTH, sanitizeExportFileName, serializeTxt } from "./txt-exporter"
+import type { ExportDocument } from "./types"
+
+const document: ExportDocument = {
+  title: "长安:夜雨",
+  source: "chapters",
+  blocks: [
+    { title: "第一章 初见", paragraphs: ["长安夜雨。", "她撑伞而来。"] },
+    { title: "第二章 重逢", paragraphs: ["故人再会。"] },
+  ],
+}
+
+describe("TXT 导出器", () => {
+  it("按标题、段落和原始块顺序生成 UTF-8 文本", () => {
+    const bytes = serializeTxt(document)
+    const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes)
+
+    expect(text).toBe(
+      "长安:夜雨\n\n第一章 初见\n\n长安夜雨。\n\n她撑伞而来。\n\n第二章 重逢\n\n故人再会。\n",
+    )
+  })
+
+  it("清理 Windows 非法字符并保留中文", () => {
+    expect(sanitizeExportFileName(' 长安<>:"/\\|?*夜雨. ')).toBe("长安夜雨")
+  })
+
+  it("规避 Windows 设备名并限制文件名最大长度", () => {
+    expect(sanitizeExportFileName("CON")).toBe("_CON")
+    expect(sanitizeExportFileName("con.txt")).toBe("_con.txt")
+    expect(sanitizeExportFileName("LPT9")).toBe("_LPT9")
+    const longName = "长".repeat(MAX_EXPORT_FILE_NAME_LENGTH + 20)
+    expect(Array.from(sanitizeExportFileName(longName))).toHaveLength(MAX_EXPORT_FILE_NAME_LENGTH)
+  })
+
+  it("文件名清理后为空时使用中文兜底名", () => {
+    expect(sanitizeExportFileName("<>:\"/\\|?* .")).toBe("未命名作品")
+  })
+})

+ 27 - 0
src/lib/export-center/txt-exporter.ts

@@ -0,0 +1,27 @@
+import type { ExportDocument } from "./types"
+
+const WINDOWS_INVALID_FILE_NAME = /[<>:"/\\|?*\u0000-\u001f]/g
+const WINDOWS_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
+export const MAX_EXPORT_FILE_NAME_LENGTH = 120
+
+export function sanitizeExportFileName(value: string): string {
+  const cleaned = value
+    .replace(WINDOWS_INVALID_FILE_NAME, "")
+    .trim()
+    .replace(/[. ]+$/g, "")
+  const truncated = Array.from(cleaned)
+    .slice(0, MAX_EXPORT_FILE_NAME_LENGTH)
+    .join("")
+    .replace(/[. ]+$/g, "")
+  const safe = truncated || "未命名作品"
+  const stem = safe.split(".", 1)[0]
+  return WINDOWS_DEVICE_NAME.test(stem) ? `_${safe}` : safe
+}
+
+export function serializeTxt(document: ExportDocument): Uint8Array {
+  const parts = [document.title]
+  for (const block of document.blocks) {
+    parts.push(block.title, ...block.paragraphs)
+  }
+  return new TextEncoder().encode(`${parts.join("\n\n")}\n`)
+}

+ 19 - 0
src/lib/export-center/types.ts

@@ -0,0 +1,19 @@
+export type ExportSource =
+  | "chapters"
+  | "outlines"
+  | "book-analysis"
+  | "story-simulation"
+  | "soul-works"
+
+export type ExportFormat = "txt" | "docx"
+
+export interface ExportDocumentBlock {
+  title: string
+  paragraphs: string[]
+}
+
+export interface ExportDocument {
+  title: string
+  source: ExportSource
+  blocks: ExportDocumentBlock[]
+}

+ 75 - 0
tongyidaochuzhongxin-分支说明.md

@@ -0,0 +1,75 @@
+# tongyidaochuzhongxin 分支说明
+
+- 目标:设置中的统一导出中心。
+- 范围:章节、大纲、拆书库、剧情推演室、灵魂作品;UTF-8 TXT / 真实 DOCX。
+- 基线:d1d02d5。
+- 分支:tongyidaochuzhongxin。
+- 约束:TDD;只读源数据;不改无关模块;不打包;未经授权不提交或合并。
+- 当前状态:已完成,未提交。
+- 完成时间:2026-07-13 10:00:33。
+
+## 本次完成内容
+
+1. 新增统一导出类型、UTF-8 TXT 序列化与 Windows 文件名清理。
+2. 新增真实 Office Open XML DOCX(ZIP + [Content_Types].xml + _rels/.rels + word/document.xml)。
+3. 新增五类只读 Collector,保持自然顺序;拆书、推演和灵魂作品按作品生成独立文档。
+4. 新增保存服务,取消保存不报错,写入失败显示中文错误。
+5. 新增 Tauri 二进制写入命令,确保 DOCX ZIP 字节不会被文本编码破坏。
+6. 设置页新增独立“导出中心”入口,支持项目、来源、格式选择;缺失来源禁用;处理中禁用操作;中文成功/失败提示;内容区可滚动。
+
+## TDD 与验证
+
+- 专项测试:5 个文件、36 个测试通过。
+- settings 测试:10 个文件、34 个测试通过(含原有 settings 测试)。
+- 源码启动:Vite ready,http://127.0.0.1:1422 返回 HTTP 200。
+- typecheck:通过。
+- build:通过;仅保留项目既有的动态导入与大 chunk 警告。
+- git diff --check:通过。
+- Rust 原始二进制写入测试与 base64 解码写入测试均已添加;指定 cargo test 仍被既有环境缺少 protoc 阻断,未进入本功能测试编译阶段,不能视为 Rust 端到端已验证。
+- cargo fmt --check 已运行;由于全 crate 既有大量 rustfmt 差异(涉及 backup.rs、main.rs、proxy.rs 等无关文件)返回失败。单独检查 fs.rs 的 rustfmt 输出未命中本次 write_export_file/base64 新增区域,未自动格式化或改动无关 Rust 文件。
+- 打包:按任务要求未执行。
+- Git:未提交、未合并。
+## 2026-07-13 规格审查修复
+
+- 修复时间:2026-07-13 10:59:37。
+- 章节顺序:优先 frontmatter chapter_number;缺失时按产品规则从显示标题/文件名提取自然序,最后按标题和文件名比较。
+- 大纲顺序:与 KnowledgeTree 一致,显示标题优先 frontmatter title、再一级标题、再文件名;从显示标题提取章节/卷/数字排序,无序号时按中文标题比较。
+- 剧情推演顺序:结构化结果按 report.createdAt 降序,与 framework-store 一致;无时间的兼容 Markdown 结果使用生成时间或文件名兜底。
+- 二进制 IPC:前端将 TXT/DOCX 字节编码为 base64,Rust 解码后按原始字节写入;增加固定 base64 样本解码写入单测。
+- 状态:修复完成,未提交、未合并、未打包。
+## 2026-07-13 二次规格审查修复
+
+- 修复时间:2026-07-13 11:15:35。
+- 大纲不再先全局扁平化后按卷号排序;改为复现 KnowledgeTree 树顺序:同级目录优先于文件,目录按名称自然序,递归输出目录内容,同级文件按产品显示标题与序号规则排序。
+- 无 frontmatter title、无一级标题时,文件名去扩展名并将短横线替换为空格,作为产品一致的显示标题。
+- 新增嵌套目录与短横线文件名两项 RED→GREEN 测试。
+- 最终专项测试:5 个文件、27 个测试通过;settings:10 个文件、34 个测试通过;typecheck、build、git diff --check、源码 HTTP 200 均通过。
+- cargo fmt --check 仍因全 crate 既有 rustfmt 差异返回 1(输出 147 个 Diff in 差异片段,涉及多个无关 Rust 文件);未自动格式化无关代码。
+- Rust 指定 base64 单测仍在 lance-encoding 构建阶段因缺少 protoc 阻断,未进入本功能测试执行,不能声称 Rust 端到端通过。
+- 状态:修复完成,未提交、未合并、未打包。
+## 2026-07-13 第三次规格审查修复
+
+- 修复时间:2026-07-13 11:33:07。
+- 同级目录比较器改为与 KnowledgeTree 完全一致:left.name.localeCompare(right.name, "zh-CN"),不再使用 numeric collator;当前环境产品顺序为卷10在卷2前。
+- 正文显示标题只识别一级 # 标题;二级至六级标题不再被当作显示标题,也不会从正文中删除。
+- 仅有二级至六级标题时,显示标题回退为去扩展名并将短横线替换为空格后的文件名;低级标题完整保留在导出段落中。
+- 新增目录比较器与低级标题保留两项 RED→GREEN 测试。
+- 最终专项测试:5 个文件、28 个测试通过;settings:10 个文件、34 个测试通过;typecheck、build、git diff --check、源码 HTTP 200 均通过。
+- cargo fmt --check 仍因全 crate 既有格式差异返回 1,本次输出 288 个差异片段、涉及 14 个 Rust 文件;未自动格式化无关代码。
+- Rust 指定 base64 单测仍在 lance-encoding/lance-file 构建阶段因缺少 protoc 阻断,未进入本功能测试执行,不能声称 Rust 端到端通过。
+- 状态:修复完成,未提交、未合并、未打包。
+## 2026-07-13 代码质量审查修复
+
+- 修复时间:2026-07-13 12:33:05。
+- Rust 导出路径原样使用保存窗口路径,不再经过 resolve_project_storage_path;新增包含 wiki/.llm-wiki 的路径测试。
+- 导出写入改为同目录临时文件、fsync、Windows MoveFileExW 原子替换(其他平台 rename);失败保留旧文件并清理临时文件,成功/失败均有测试,Rust 测试自行清理临时文件。
+- DOCX 过滤 XML 1.0 禁止控制字符,并用 DOMParser 解析 document.xml 验证无 parsererror。
+- 前端与 Rust 增加 64 MiB 原始导出字节上限及中文超限错误;前端边界测试与 Rust 边界测试已添加。
+- 五类来源改用 Promise.allSettled 隔离;灵魂作品只接受非 null、非数组对象;拆书章节回退逐文件隔离读取失败。
+- 导出 UI 增加卸载 guard,停止后续保存窗口和卸载后状态更新;来源与格式使用 fieldset/legend。
+- 文件名增加 Windows 设备名规避和 120 字符上限。
+- settings-view 已恢复原 UTF-8 BOM,编码删除 diff 已消失。
+- 最终专项测试:5 个文件、36 个测试通过;settings:10 个文件、35 个测试通过;typecheck、build、git diff --check、源码 HTTP 200 均通过。
+- cargo fmt --check 仍因全 crate 既有格式差异返回 1,本次输出 292 个差异片段、涉及 14 个 Rust 文件;单独 rustfmt 检查输出未命中本轮 Rust 导出区域。
+- cargo test write_export_file 仍在 lance-encoding/lance-file 构建阶段因缺少 protoc 阻断,未进入本功能 Rust 测试执行,不能声称 Rust 端到端通过。
+- 状态:修复完成,未提交、未合并、未打包。