Parcourir la source

feat(story-simulation): 实现框架列表和绑定对话框 UI

Mochocyang il y a 2 mois
Parent
commit
381615cb01

+ 145 - 0
src/components/novel/story-simulation/framework-binding-dialog.tsx

@@ -0,0 +1,145 @@
+import { useEffect, useState } from "react"
+
+import { useWikiStore } from "@/stores/wiki-store"
+import { useStorySimulationStore } from "@/stores/story-simulation-store"
+import {
+  saveBinding,
+  clearBinding,
+} from "@/lib/novel/story-simulation/framework-binding"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import type { StoryFramework } from "@/lib/novel/story-simulation/types"
+
+const CHAPTER_OPTIONS = [5, 10, 20, 30, 50]
+
+interface FrameworkBindingDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  framework: StoryFramework
+  onBound: () => void
+}
+
+export function FrameworkBindingDialog({
+  open,
+  onOpenChange,
+  framework,
+  onBound,
+}: FrameworkBindingDialogProps) {
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const binding = useStorySimulationStore((s) => s.binding)
+  const setBinding = useStorySimulationStore((s) => s.setBinding)
+
+  const [chapterCount, setChapterCount] = useState(10)
+  const [submitting, setSubmitting] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const isBound = binding?.frameworkId === framework.id
+
+  useEffect(() => {
+    if (!open) return
+    const bound =
+      binding && binding.frameworkId === framework.id ? binding : null
+    setChapterCount(bound ? bound.targetChapterCount : 10)
+    setError(null)
+  }, [open, binding, framework])
+
+  const handleConfirm = async () => {
+    if (!projectPath) return
+    setSubmitting(true)
+    setError(null)
+    try {
+      const updated = await saveBinding(projectPath, framework, chapterCount)
+      setBinding(updated)
+      onBound()
+      onOpenChange(false)
+    } catch (err) {
+      setError("绑定失败,请重试")
+      console.error(err)
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  const handleClear = async () => {
+    if (!projectPath) return
+    setSubmitting(true)
+    setError(null)
+    try {
+      await clearBinding(projectPath)
+      setBinding(null)
+      onBound()
+      onOpenChange(false)
+    } catch (err) {
+      setError("取消绑定失败,请重试")
+      console.error(err)
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-[440px]">
+        <DialogHeader>
+          <DialogTitle>绑定框架到 AI 会话</DialogTitle>
+          <DialogDescription>
+            将「{framework.title}」按章节数分配到各故事节点,并注入 AI 写作会话。
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4 py-2">
+          <div className="space-y-2">
+            <span className="text-sm font-medium">目标章节数</span>
+            <div className="flex flex-wrap gap-2">
+              {CHAPTER_OPTIONS.map((count) => (
+                <Button
+                  key={count}
+                  size="sm"
+                  variant={chapterCount === count ? "default" : "outline"}
+                  onClick={() => setChapterCount(count)}
+                  disabled={submitting}
+                >
+                  {count} 章
+                </Button>
+              ))}
+            </div>
+            <p className="text-xs text-muted-foreground">
+              共 {framework.nodes.length} 个故事节点,章节将按起承转合分配。
+            </p>
+          </div>
+
+          {error && <p className="text-sm text-destructive">{error}</p>}
+        </div>
+
+        <DialogFooter>
+          {isBound && (
+            <Button
+              variant="destructive"
+              onClick={handleClear}
+              disabled={submitting}
+            >
+              取消绑定
+            </Button>
+          )}
+          <Button
+            variant="outline"
+            onClick={() => onOpenChange(false)}
+            disabled={submitting}
+          >
+            关闭
+          </Button>
+          <Button onClick={handleConfirm} disabled={submitting}>
+            {submitting ? "处理中..." : "确认绑定"}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 143 - 0
src/components/novel/story-simulation/framework-list.tsx

@@ -0,0 +1,143 @@
+import { useEffect, useState } from "react"
+import { Plus, Link2, Unlink } from "lucide-react"
+
+import { useWikiStore } from "@/stores/wiki-store"
+import {
+  useStorySimulationStore,
+} from "@/stores/story-simulation-store"
+import { loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
+import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
+import { Button } from "@/components/ui/button"
+import type { StoryFramework } from "@/lib/novel/story-simulation/types"
+
+import { FrameworkBindingDialog } from "./framework-binding-dialog"
+
+interface FrameworkListProps {
+  onSelectFramework: (framework: StoryFramework) => void
+  onNewFramework: () => void
+}
+
+export function FrameworkList({
+  onSelectFramework,
+  onNewFramework,
+}: FrameworkListProps) {
+  const projectPath = useWikiStore((s) => s.project?.path)
+  const frameworks = useStorySimulationStore((s) => s.frameworks)
+  const setFrameworks = useStorySimulationStore((s) => s.setFrameworks)
+  const binding = useStorySimulationStore((s) => s.binding)
+  const setBinding = useStorySimulationStore((s) => s.setBinding)
+
+  const [loading, setLoading] = useState(true)
+  const [dialogFramework, setDialogFramework] =
+    useState<StoryFramework | null>(null)
+
+  useEffect(() => {
+    if (!projectPath) {
+      setLoading(false)
+      return
+    }
+    let cancelled = false
+    const run = async () => {
+      setLoading(true)
+      try {
+        const [list, currentBinding] = await Promise.all([
+          loadFrameworks(projectPath),
+          loadBinding(projectPath),
+        ])
+        if (cancelled) return
+        setFrameworks(list)
+        setBinding(currentBinding)
+      } catch {
+        // 加载失败时保持空列表,不阻塞 UI
+      } finally {
+        if (!cancelled) setLoading(false)
+      }
+    }
+    void run()
+    return () => {
+      cancelled = true
+    }
+  }, [projectPath, setFrameworks, setBinding])
+
+  if (!projectPath) {
+    return (
+      <div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
+        请先打开一个项目
+      </div>
+    )
+  }
+
+  return (
+    <div className="flex h-full flex-col gap-3 p-4">
+      <div className="flex items-center justify-between">
+        <h3 className="text-base font-medium">故事框架</h3>
+        <Button size="sm" onClick={onNewFramework}>
+          <Plus className="mr-1 h-4 w-4" />
+          新建框架
+        </Button>
+      </div>
+
+      {loading ? (
+        <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
+          加载中...
+        </div>
+      ) : frameworks.length === 0 ? (
+        <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
+          暂无故事框架
+        </div>
+      ) : (
+        <div className="flex flex-1 flex-col gap-2 overflow-auto">
+          {frameworks.map((framework) => {
+            const isBound = binding?.frameworkId === framework.id
+            return (
+              <div
+                key={framework.id}
+                className="flex items-center gap-3 rounded-lg border p-3 hover:bg-muted/50"
+              >
+                <button
+                  type="button"
+                  className="flex flex-1 flex-col items-start gap-1 text-left"
+                  onClick={() => onSelectFramework(framework)}
+                >
+                  <span className="font-medium">{framework.title}</span>
+                  <span className="text-xs text-muted-foreground">
+                    {framework.nodes.length} 个节点 · 目标{" "}
+                    {framework.targetWords} 字
+                  </span>
+                </button>
+                <Button
+                  size="sm"
+                  variant={isBound ? "outline" : "default"}
+                  onClick={() => setDialogFramework(framework)}
+                >
+                  {isBound ? (
+                    <>
+                      <Unlink className="mr-1 h-4 w-4" />
+                      取消绑定
+                    </>
+                  ) : (
+                    <>
+                      <Link2 className="mr-1 h-4 w-4" />
+                      绑定到 AI 会话
+                    </>
+                  )}
+                </Button>
+              </div>
+            )
+          })}
+        </div>
+      )}
+
+      {dialogFramework && (
+        <FrameworkBindingDialog
+          open={true}
+          onOpenChange={(open) => {
+            if (!open) setDialogFramework(null)
+          }}
+          framework={dialogFramework}
+          onBound={() => setDialogFramework(null)}
+        />
+      )}
+    </div>
+  )
+}