Просмотр исходного кода

feat(story-simulation): 草稿导出 + 时间线角色点击 + 动作短语优化

1. 草稿导出MD:story-draft-view新增导出按钮,导出完整故事草稿为MD到.qmai/simulations/exports/
2. 草稿导出工具:新增draft-export.ts,支持导出带章节结构的Markdown
3. 时间线角色可点击:SimulatingTimelinePanel支持onInterviewAgent回调,actorName和targetName可点击
4. 新增actionTypePhraseOnly函数:在目标名可点击场景下,返回不含目标名的动作短语
5. 仿真过程中不支持采访:仿真阶段lastSimulationStateRef尚未构建完成,不传递onInterviewAgent避免错误
6. 报告页角色点击采访:上轮已实现,本轮保持不变
Mochocyang 3 месяцев назад
Родитель
Сommit
be21ca7dc4

+ 41 - 9
src/components/novel/story-simulation/story-draft-view.tsx

@@ -1,8 +1,10 @@
 import { useState } from "react"
 import { useTranslation } from "react-i18next"
-import { ArrowLeft, Check, Copy, FileText } from "lucide-react"
+import { ArrowLeft, Check, Copy, Download, FileText } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import { useWikiStore } from "@/stores/wiki-store"
+import { exportDraft } from "@/lib/novel/story-simulation/draft-export"
 
 interface StoryDraftViewProps {
   onBack: () => void
@@ -10,8 +12,12 @@ interface StoryDraftViewProps {
 
 export function StoryDraftView({ onBack }: StoryDraftViewProps) {
   const { t } = useTranslation()
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const currentFramework = useStorySimulationStore((s) => s.currentFramework)
   const draft = useStorySimulationStore((s) => s.currentDraft)
+  const setError = useStorySimulationStore((s) => s.setError)
   const [copied, setCopied] = useState(false)
+  const [exporting, setExporting] = useState(false)
 
   if (!draft) return null
 
@@ -24,6 +30,21 @@ export function StoryDraftView({ onBack }: StoryDraftViewProps) {
     setTimeout(() => setCopied(false), 2000)
   }
 
+  const handleExport = async () => {
+    if (!projectPath || !currentFramework || !draft) return
+    setExporting(true)
+    try {
+      const filePath = await exportDraft(projectPath, currentFramework, draft)
+      setError(`草稿已导出到:${filePath}`)
+      setTimeout(() => setError(null), 5000)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "导出失败")
+      setTimeout(() => setError(null), 5000)
+    } finally {
+      setExporting(false)
+    }
+  }
+
   return (
     <div className="flex h-full flex-col">
       <div className="flex items-center justify-between border-b px-4 py-3">
@@ -33,14 +54,25 @@ export function StoryDraftView({ onBack }: StoryDraftViewProps) {
           </Button>
           <h2 className="text-sm font-semibold">{t("storySimulation.draftTitle")}</h2>
         </div>
-        <Button variant="outline" size="sm" onClick={handleCopyAll}>
-          {copied ? (
-            <Check className="h-3.5 w-3.5" />
-          ) : (
-            <Copy className="h-3.5 w-3.5" />
-          )}
-          {copied ? t("storySimulation.copied") : t("storySimulation.copyAll")}
-        </Button>
+        <div className="flex items-center gap-2">
+          <Button
+            variant="outline"
+            size="sm"
+            onClick={handleExport}
+            disabled={exporting}
+          >
+            <Download className="mr-1 h-3.5 w-3.5" />
+            {exporting ? "导出中..." : "导出MD"}
+          </Button>
+          <Button variant="outline" size="sm" onClick={handleCopyAll}>
+            {copied ? (
+              <Check className="h-3.5 w-3.5" />
+            ) : (
+              <Copy className="h-3.5 w-3.5" />
+            )}
+            {copied ? t("storySimulation.copied") : t("storySimulation.copyAll")}
+          </Button>
+        </div>
       </div>
 
       <div className="flex-1 overflow-y-auto p-4">

+ 56 - 2
src/components/novel/story-simulation/story-simulation-view.tsx

@@ -75,6 +75,32 @@ function actionPhrase(type: string, targetName?: string): string {
   }
 }
 
+/** 返回只有动作的短语(不含目标名),用于可点击目标名场景。 */
+function actionTypePhraseOnly(type: string): string {
+  switch (type) {
+    case "evaluate":
+      return "评价"
+    case "pushPlot":
+      return "推动"
+    case "observe":
+      return "观察到"
+    case "react":
+      return "对"
+    case "speak":
+      return "对"
+    case "ally":
+      return "向"
+    case "confront":
+      return "与"
+    case "conceal":
+      return "隐瞒"
+    case "investigate":
+      return "调查"
+    default:
+      return "对"
+  }
+}
+
 /**
  * 故事推演室主视图(重构后)。
  *
@@ -622,10 +648,12 @@ function SimulatingTimelinePanel({
   progress,
   label,
   events,
+  onInterviewAgent,
 }: {
   progress: number
   label: string
   events: TimelineEvent[]
+  onInterviewAgent?: (agentId: string, agentName: string) => void
 }) {
   const clamped = Math.min(100, Math.max(0, progress))
   const logRef = useRef<HTMLDivElement | null>(null)
@@ -742,10 +770,36 @@ function SimulatingTimelinePanel({
                 <span className="mr-1 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
                   {ev.nodeIndex + 1}-{ev.round + 1}
                 </span>
-                <span className="font-medium">{ev.actorName}</span>
+                {onInterviewAgent ? (
+                  <button
+                    type="button"
+                    className="font-medium text-primary hover:underline"
+                    onClick={() => onInterviewAgent(ev.actorId, ev.actorName)}
+                  >
+                    {ev.actorName}
+                  </button>
+                ) : (
+                  <span className="font-medium">{ev.actorName}</span>
+                )}
                 <span className="text-muted-foreground">
                   {" "}
-                  {actionPhrase(ev.actionType, ev.targetName)}:
+                  {ev.targetName && ev.targetId && onInterviewAgent ? (
+                    <>
+                      {actionTypePhraseOnly(ev.actionType)}{" "}
+                      <button
+                        type="button"
+                        className="text-primary hover:underline"
+                        onClick={() => onInterviewAgent(ev.targetId!, ev.targetName!)}
+                      >
+                        {ev.targetName}
+                      </button>
+                      :
+                    </>
+                  ) : (
+                    <>
+                      {actionPhrase(ev.actionType, ev.targetName)}:
+                    </>
+                  )}
                 </span>
                 <span>{ev.content}</span>
               </div>

+ 69 - 0
src/lib/novel/story-simulation/draft-export.ts

@@ -0,0 +1,69 @@
+/**
+ * 故事草稿导出
+ * 将 StoryDraft 导出为 Markdown 文件。
+ */
+
+import { createDirectory, writeFileAtomic } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type { StoryDraft, StoryFramework } from "./types"
+
+const SIM_ROOT = ".qmai/simulations"
+const EXPORTS_DIR = `${SIM_ROOT}/exports`
+
+function exportsDir(projectPath: string): string {
+  return `${normalizePath(projectPath)}/${EXPORTS_DIR}`
+}
+
+function draftFilePath(
+  projectPath: string,
+  frameworkTitle: string,
+  timestamp: string,
+): string {
+  const safeTitle = frameworkTitle
+    .replace(/[\\/:*?"<>|]/g, "_")
+    .slice(0, 30)
+  const safeTs = timestamp.replace(/[:.]/g, "-")
+  return `${exportsDir(projectPath)}/故事草稿_${safeTitle}_${safeTs}.md`
+}
+
+/**
+ * 导出故事草稿为 Markdown。
+ * @returns 导出的文件路径
+ */
+export async function exportDraft(
+  projectPath: string,
+  framework: StoryFramework,
+  draft: StoryDraft,
+): Promise<string> {
+  const dir = exportsDir(projectPath)
+  await createDirectory(dir)
+
+  const now = new Date()
+  const timestamp = now.toISOString()
+  const filePath = draftFilePath(projectPath, framework.shortTitle || framework.title, timestamp)
+
+  const lines: string[] = []
+  lines.push(`# ${framework.title}`)
+  lines.push("")
+  lines.push(`> 生成时间:${now.toLocaleString("zh-CN")}`)
+  lines.push(`> 目标字数:${framework.targetWords}`)
+  lines.push(`> 实际字数:${draft.totalWords}`)
+  if (framework.premise) {
+    lines.push(`>`)
+    lines.push(`> ${framework.premise}`)
+  }
+  lines.push("")
+  lines.push("---")
+  lines.push("")
+
+  for (const chapter of draft.chapters) {
+    lines.push(`## ${chapter.title}`)
+    lines.push("")
+    lines.push(chapter.content)
+    lines.push("")
+  }
+
+  const content = lines.join("\n")
+  await writeFileAtomic(filePath, content)
+  return filePath
+}