diff --git a/apps/backend/src/queue/worker.ts b/apps/backend/src/queue/worker.ts index 352e493..9cba9d9 100644 --- a/apps/backend/src/queue/worker.ts +++ b/apps/backend/src/queue/worker.ts @@ -16,6 +16,46 @@ import { retrieveRelevantKnowledge } from "../services/embedding"; import { logger } from "../utils/logger"; import { Server } from "socket.io"; +// 正在处理中的任务集合 - 用于内存级别的幂等性检查 +const processingTasks = new Set(); + +/** + * 检查任务是否已经在处理中 + */ +function isTaskProcessing(taskId: string): boolean { + return processingTasks.has(taskId); +} + +/** + * 标记任务为处理中 + */ +function markTaskProcessing(taskId: string): void { + processingTasks.add(taskId); +} + +/** + * 标记任务为处理完成 + */ +function markTaskCompleted(taskId: string): void { + processingTasks.delete(taskId); +} + +/** + * 检查数据库中任务状态是否允许执行 + */ +async function canExecuteTask(taskId: string): Promise { + const task = await db.query.tasks.findFirst({ + where: eq(schema.tasks.id, taskId), + }); + + if (!task) { + return false; + } + + // 只允许 queued 或 failed 状态的任务执行 + return task.status === "queued" || task.status === "failed"; +} + const connection = new IORedis({ host: process.env.REDIS_HOST || "localhost", port: parseInt(process.env.REDIS_PORT || "6379"), @@ -80,6 +120,26 @@ export function startWorker(io: Server) { async (job: Job) => { const { taskId, novelId, chapterId, type, input } = job.data; + // 幂等性检查 1: 内存级别 - 检查是否已经在处理中 + if (isTaskProcessing(taskId)) { + logger.warn( + `Task ${taskId} is already being processed, skipping duplicate execution`, + ); + return { skipped: true, reason: "already_processing" }; + } + + // 幂等性检查 2: 数据库级别 - 检查任务状态 + const canExecute = await canExecuteTask(taskId); + if (!canExecute) { + logger.warn( + `Task ${taskId} cannot be executed (status not queued/failed), skipping`, + ); + return { skipped: true, reason: "invalid_status" }; + } + + // 标记任务为处理中 + markTaskProcessing(taskId); + try { // Update task status to running await db @@ -428,6 +488,9 @@ export function startWorker(io: Server) { }); throw error; + } finally { + // 无论成功或失败,都标记任务为处理完成 + markTaskCompleted(taskId); } }, { diff --git a/apps/backend/src/routes/task.routes.ts b/apps/backend/src/routes/task.routes.ts index 48bab18..b7f08dc 100644 --- a/apps/backend/src/routes/task.routes.ts +++ b/apps/backend/src/routes/task.routes.ts @@ -1,21 +1,107 @@ import { Router } from "express"; import { db, schema } from "../database"; -import { eq } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import { AuthRequest } from "../middleware/auth"; import { novelQueue } from "../queue/worker"; const router: Router = Router(); +// 任务类型分组定义 - 用于幂等性检查 +const TASK_TYPE_GROUPS = { + // 小说级别的任务 - 同一小说同一时间只能有一个进行中的任务 + novelLevel: ["outline", "title", "volume_planning"] as const, + // 分卷级别的任务 + volumeLevel: ["chapter_planning"] as const, + // 章节级别的任务 - 同一章节同一时间只能有一个进行中的任务 + chapterLevel: [ + "chapter_outline", + "chapter_detail", + "content", + "consistency_check", + ] as const, +}; + +/** + * 检查是否存在进行中的相同类型任务 + * @param novelId 小说ID + * @param type 任务类型 + * @param chapterId 章节ID(可选) + * @returns 存在则返回任务ID,否则返回null + */ +async function checkExistingTask( + novelId: string, + type: string, + chapterId?: string, +): Promise { + // 使用 SQL 查询来避免类型问题 + const conditions = [ + eq(schema.tasks.novelId, novelId), + eq(schema.tasks.type, type as any), + sql`${schema.tasks.status} IN ('queued', 'running')`, + ]; + + if (chapterId) { + conditions.push(eq(schema.tasks.chapterId, chapterId)); + } + + const existingTask = await db.query.tasks.findFirst({ + where: and(...conditions), + orderBy: (tasks, { desc }) => [desc(tasks.createdAt)], + }); + + return existingTask?.id || null; +} + +/** + * 检查小说级别任务是否存在冲突 + * 某些任务类型(如大纲生成)应该互斥 + */ +async function checkNovelLevelConflict( + novelId: string, + type: string, +): Promise { + // 检查是否属于 novelLevel 组 + if (TASK_TYPE_GROUPS.novelLevel.includes(type as any)) { + // 检查该小说是否有任何 novelLevel 任务在进行中 + const existingTask = await db.query.tasks.findFirst({ + where: and( + eq(schema.tasks.novelId, novelId), + sql`${schema.tasks.type} IN ('outline', 'title', 'volume_planning')`, + sql`${schema.tasks.status} IN ('queued', 'running')`, + ), + orderBy: (tasks, { desc }) => [desc(tasks.createdAt)], + }); + return existingTask?.id || null; + } + return null; +} + // Generate outline router.post( "/:novelId/generate/outline", async (req: AuthRequest, res, next) => { try { + const novelId = req.params.novelId; + + // 幂等性检查:检查是否已有进行中的大纲生成任务 + const existingTaskId = await checkNovelLevelConflict(novelId, "outline"); + if (existingTaskId) { + const existingTask = await db.query.tasks.findFirst({ + where: eq(schema.tasks.id, existingTaskId), + }); + res.status(409).json({ + error: "已有进行中的生成任务", + message: "该小说已有进行中的大纲生成任务,请等待完成后再试", + task: existingTask, + }); + return; + } + // Create task const [task] = await db .insert(schema.tasks) .values({ - novelId: req.params.novelId, + novelId: novelId, type: "outline", status: "queued", }) @@ -26,7 +112,7 @@ router.post( "generate-outline", { taskId: task.id, - novelId: req.params.novelId, + novelId: novelId, type: "outline", }, { @@ -50,12 +136,27 @@ router.post( "/:novelId/generate/titles", async (req: AuthRequest, res, next) => { try { + const novelId = req.params.novelId; const { outline } = req.body; + // 幂等性检查 + const existingTaskId = await checkNovelLevelConflict(novelId, "title"); + if (existingTaskId) { + const existingTask = await db.query.tasks.findFirst({ + where: eq(schema.tasks.id, existingTaskId), + }); + res.status(409).json({ + error: "已有进行中的生成任务", + message: "该小说已有进行中的标题生成任务,请等待完成后再试", + task: existingTask, + }); + return; + } + const [task] = await db .insert(schema.tasks) .values({ - novelId: req.params.novelId, + novelId: novelId, type: "title", status: "queued", }) @@ -65,7 +166,7 @@ router.post( "generate-titles", { taskId: task.id, - novelId: req.params.novelId, + novelId: novelId, type: "title", input: { outline }, }, @@ -90,12 +191,30 @@ router.post( "/:novelId/generate/volumes", async (req: AuthRequest, res, next) => { try { + const novelId = req.params.novelId; const { outline } = req.body; + // 幂等性检查 + const existingTaskId = await checkNovelLevelConflict( + novelId, + "volume_planning", + ); + if (existingTaskId) { + const existingTask = await db.query.tasks.findFirst({ + where: eq(schema.tasks.id, existingTaskId), + }); + res.status(409).json({ + error: "已有进行中的生成任务", + message: "该小说已有进行中的分卷规划任务,请等待完成后再试", + task: existingTask, + }); + return; + } + const [task] = await db .insert(schema.tasks) .values({ - novelId: req.params.novelId, + novelId: novelId, type: "volume_planning", status: "queued", }) @@ -105,7 +224,7 @@ router.post( "generate-volumes", { taskId: task.id, - novelId: req.params.novelId, + novelId: novelId, type: "volume_planning", input: { outline, @@ -133,13 +252,31 @@ router.post( "/:novelId/generate/chapters", async (req: AuthRequest, res, next) => { try { + const novelId = req.params.novelId; const { outline, volumeId, additionalRequirements, targetCount } = req.body; + // 幂等性检查:检查是否已有进行中的章节规划任务 + const existingTaskId = await checkExistingTask( + novelId, + "chapter_planning", + ); + if (existingTaskId) { + const existingTask = await db.query.tasks.findFirst({ + where: eq(schema.tasks.id, existingTaskId), + }); + res.status(409).json({ + error: "已有进行中的生成任务", + message: "该小说已有进行中的章节规划任务,请等待完成后再试", + task: existingTask, + }); + return; + } + const [task] = await db .insert(schema.tasks) .values({ - novelId: req.params.novelId, + novelId: novelId, type: "chapter_planning", status: "queued", }) @@ -149,7 +286,7 @@ router.post( "generate-chapters", { taskId: task.id, - novelId: req.params.novelId, + novelId: novelId, type: "chapter_planning", input: { outline, volumeId, additionalRequirements, targetCount }, }, @@ -174,11 +311,32 @@ router.post( "/:novelId/chapters/:chapterId/generate", async (req: AuthRequest, res, next) => { try { + const novelId = req.params.novelId; + const chapterId = req.params.chapterId; + + // 幂等性检查:检查该章节是否已有进行中的内容生成任务 + const existingTaskId = await checkExistingTask( + novelId, + "content", + chapterId, + ); + if (existingTaskId) { + const existingTask = await db.query.tasks.findFirst({ + where: eq(schema.tasks.id, existingTaskId), + }); + res.status(409).json({ + error: "已有进行中的生成任务", + message: "该章节已有进行中的内容生成任务,请等待完成后再试", + task: existingTask, + }); + return; + } + const [task] = await db .insert(schema.tasks) .values({ - novelId: req.params.novelId, - chapterId: req.params.chapterId, + novelId: novelId, + chapterId: chapterId, type: "content", status: "queued", }) @@ -188,9 +346,10 @@ router.post( "generate-content", { taskId: task.id, - novelId: req.params.novelId, - chapterId: req.params.chapterId, + novelId: novelId, + chapterId: chapterId, type: "content", + input: req.body, }, { attempts: 3, diff --git a/apps/frontend/app/novels/detail/page.tsx b/apps/frontend/app/novels/detail/page.tsx index 2ddc3aa..5fa3e88 100644 --- a/apps/frontend/app/novels/detail/page.tsx +++ b/apps/frontend/app/novels/detail/page.tsx @@ -1,53 +1,94 @@ -'use client'; - -import { useEffect, useState, useRef, Suspense } from 'react'; -import useSWR from 'swr'; - -import dynamic from 'next/dynamic'; -import { useSearchParams, useRouter } from 'next/navigation'; -import { toast } from 'sonner'; -import { Modal } from '@/components/ui/modal'; -import { BookOpen, ArrowLeft, Settings, FileText, Users, Database, Sparkles, Play, Loader2, Save, Download, Upload, FileJson, Book, Beaker, Plus, Trash2, Wand2, Bookmark, Clock } from 'lucide-react'; -import Link from 'next/link'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { cn } from '@/lib/utils'; +"use client"; + +import { useEffect, useState, useRef, Suspense, useCallback } from "react"; +import useSWR from "swr"; + +import dynamic from "next/dynamic"; +import { useSearchParams, useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Modal } from "@/components/ui/modal"; +import { + BookOpen, + ArrowLeft, + Settings, + FileText, + Users, + Database, + Sparkles, + Play, + Loader2, + Save, + Download, + Upload, + FileJson, + Book, + Beaker, + Plus, + Trash2, + Wand2, + Bookmark, + Clock, +} from "lucide-react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import GenerationContext from '@/components/novel/GenerationContext'; -import GenerationModeSelector from '@/components/novel/GenerationModeSelector'; -import OutlineVersionManager from '@/components/novel/OutlineVersionManager'; -import { novelsAPI, tasksAPI, sandboxAPI } from '@/lib/api'; +import GenerationContext from "@/components/novel/GenerationContext"; +import GenerationModeSelector from "@/components/novel/GenerationModeSelector"; +import OutlineVersionManager from "@/components/novel/OutlineVersionManager"; +import { novelsAPI, tasksAPI, sandboxAPI } from "@/lib/api"; // Lazy-load heavy tab components — only downloaded when the tab is opened const TabSkeleton = () => (
- {[1, 2, 3].map((i) =>
)} + {[1, 2, 3].map((i) => ( +
+ ))}
); -const ChapterGenerator = dynamic(() => import('@/components/novel/ChapterGenerator'), { loading: () => }); -const KnowledgeManager = dynamic(() => import('@/components/novel/KnowledgeManager'), { loading: () => }); -const CharacterManager = dynamic(() => import('@/components/novel/CharacterManager'), { loading: () => }); -const PlotThreadManager = dynamic(() => import('@/components/novel/PlotThreadManager'), { loading: () => }); -const TimelineManager = dynamic(() => import('@/components/novel/TimelineManager'), { loading: () => }); - +const ChapterGenerator = dynamic( + () => import("@/components/novel/ChapterGenerator"), + { loading: () => }, +); +const KnowledgeManager = dynamic( + () => import("@/components/novel/KnowledgeManager"), + { loading: () => }, +); +const CharacterManager = dynamic( + () => import("@/components/novel/CharacterManager"), + { loading: () => }, +); +const PlotThreadManager = dynamic( + () => import("@/components/novel/PlotThreadManager"), + { loading: () => }, +); +const TimelineManager = dynamic( + () => import("@/components/novel/TimelineManager"), + { loading: () => }, +); function NovelDetail() { const searchParams = useSearchParams(); const router = useRouter(); - const novelId = searchParams.get('id') as string; - - const [activeTab, setActiveTab] = useState('settings'); + const novelId = searchParams.get("id") as string; + + const [activeTab, setActiveTab] = useState("settings"); // Fetch novel via SWR — cached so revisiting the page is instant - const { data: novelData, isLoading: loading, mutate: mutateNovel } = useSWR( + const { + data: novelData, + isLoading: loading, + mutate: mutateNovel, + } = useSWR( novelId ? `/api/novels/${novelId}` : null, () => novelsAPI.get(novelId).then((r) => r.data), - { onError: () => router.push('/novels') } + { onError: () => router.push("/novels") }, ); // Local edits overlay SWR data so fields remain editable without round trips @@ -57,69 +98,96 @@ function NovelDetail() { // Outline generation const [outlineVersions, setOutlineVersions] = useState([]); const [currentVersion, setCurrentVersion] = useState(null); - const [generatedOutline, setGeneratedOutline] = useState(''); + const [generatedOutline, setGeneratedOutline] = useState(""); const [isStreaming, setIsStreaming] = useState(false); const [showRollbackModal, setShowRollbackModal] = useState(false); const [versionToRollback, setVersionToRollback] = useState(null); // Style Mimicry state - const [styleSample, setStyleSample] = useState(''); + const [styleSample, setStyleSample] = useState(""); const [isExtractingStyle, setIsExtractingStyle] = useState(false); // Plot Sandbox state const [sandboxes, setSandboxes] = useState([]); const [activeSandbox, setActiveSandbox] = useState(null); - const [sandboxTitle, setSandboxTitle] = useState(''); - const [sandboxPremise, setSandboxPremise] = useState(''); + const [sandboxTitle, setSandboxTitle] = useState(""); + const [sandboxPremise, setSandboxPremise] = useState(""); const [isCreatingSandbox, setIsCreatingSandbox] = useState(false); const [isGeneratingSandbox, setIsGeneratingSandbox] = useState(false); - + // Ref for auto-scrolling textarea const textareaRef = useRef(null); + // 防重复点击 - 使用 ref 进行同步检查 + const isGeneratingOutlineRef = useRef(false); + const handleGenerateOutlineStream = (mode: string) => { + // 防重复点击检查 - 使用 ref 进行同步检查 + if (isGeneratingOutlineRef.current) { + toast.info("大纲生成任务正在进行中,请勿重复点击"); + return; + } + + isGeneratingOutlineRef.current = true; setIsStreaming(true); - if (mode === 'initial') { - setGeneratedOutline(''); + if (mode === "initial") { + setGeneratedOutline(""); } - + // If not initial, we use current generated outline as context - const existingOutline = mode !== 'initial' ? generatedOutline : undefined; + const existingOutline = mode !== "initial" ? generatedOutline : undefined; - const eventSource = novelsAPI.generateOutlineStream(novelId, mode, existingOutline); - let newContent = ''; + const eventSource = novelsAPI.generateOutlineStream( + novelId, + mode, + existingOutline, + ); + let newContent = ""; eventSource.onopen = () => { - console.log('Stream connection opened'); + console.log("Stream connection opened"); }; eventSource.onmessage = (event) => { const data = JSON.parse(event.data); - if (data.type === 'chunk') { + if (data.type === "chunk") { newContent += data.content; - setGeneratedOutline((prev) => mode === 'initial' ? prev + data.content : prev + data.content); // Simplified logic + setGeneratedOutline((prev) => + mode === "initial" ? prev + data.content : prev + data.content, + ); // Simplified logic // Auto scroll if (textareaRef.current) { textareaRef.current.scrollTop = textareaRef.current.scrollHeight; } - } else if (data.type === 'done') { + } else if (data.type === "done") { eventSource.close(); setIsStreaming(false); // Save the version automatically when done saveGeneratedVersion(newContent || generatedOutline, mode); - } else if (data.type === 'error') { - console.error('Stream error:', data.error); + // 延迟重置 ref,确保状态更新完成 + setTimeout(() => { + isGeneratingOutlineRef.current = false; + }, 500); + } else if (data.type === "error") { + console.error("Stream error:", data.error); eventSource.close(); setIsStreaming(false); - toast.error('生成出错:' + data.error); + toast.error("生成出错:" + data.error); + // 延迟重置 ref + setTimeout(() => { + isGeneratingOutlineRef.current = false; + }, 500); } }; eventSource.onerror = (err) => { - console.error('EventSource failed:', err); + console.error("EventSource failed:", err); eventSource.close(); setIsStreaming(false); - // toast.error('连接断开'); + // 延迟重置 ref + setTimeout(() => { + isGeneratingOutlineRef.current = false; + }, 500); }; }; @@ -133,15 +201,16 @@ function NovelDetail() { genre: displayNovel?.genre, targetWords: displayNovel?.targetWords, worldSettings: !!displayNovel?.worldSettings, - knowledgeBases: displayNovel?.knowledgeBases?.map((k: any) => k.name) || [], - mode - } + knowledgeBases: + displayNovel?.knowledgeBases?.map((k: any) => k.name) || [], + mode, + }, }); await loadOutlineVersions(); - toast.success('大纲已生成并保存!'); + toast.success("大纲已生成并保存!"); } catch (error) { - console.error('Failed to autosave version:', error); - toast.error('自动保存失败'); + console.error("Failed to autosave version:", error); + toast.error("自动保存失败"); } }; @@ -149,14 +218,14 @@ function NovelDetail() { try { await novelsAPI.saveOutlineVersion(novelId, { content: generatedOutline, - mode: 'manual', - context: currentVersion?.generationContext + mode: "manual", + context: currentVersion?.generationContext, }); await loadOutlineVersions(); - toast.success('手动保存成功'); + toast.success("手动保存成功"); } catch (error) { - console.error('Failed to save:', error); - toast.error('保存失败'); + console.error("Failed to save:", error); + toast.error("保存失败"); } }; @@ -173,19 +242,23 @@ function NovelDetail() { toast.success(`已回滚到版本 v${versionToRollback.version}`); setShowRollbackModal(false); } catch (error) { - console.error('Rollback failed:', error); - toast.error('回滚失败'); + console.error("Rollback failed:", error); + toast.error("回滚失败"); } }; const handleLock = async (version: any) => { try { - await novelsAPI.lockOutlineVersion(novelId, version.id, !version.isLocked); + await novelsAPI.lockOutlineVersion( + novelId, + version.id, + !version.isLocked, + ); await loadOutlineVersions(); - toast.success(version.isLocked ? '已解锁' : '已锁定'); + toast.success(version.isLocked ? "已解锁" : "已锁定"); } catch (error) { - console.error('Lock failed:', error); - toast.error('操作失败'); + console.error("Lock failed:", error); + toast.error("操作失败"); } }; @@ -200,10 +273,10 @@ function NovelDetail() { worldSettings: novel.worldSettings, writingStyleRules: novel.writingStyleRules, }); - toast.success('基础设定已保存'); + toast.success("基础设定已保存"); } catch (error) { - console.error('Failed to update novel:', error); - toast.error('保存失败'); + console.error("Failed to update novel:", error); + toast.error("保存失败"); } }; @@ -213,24 +286,25 @@ function NovelDetail() { if (!file) return; const formData = new FormData(); - formData.append('cover', file); + formData.append("cover", file); setIsUploadingCover(true); try { - const res = await novelsAPI.updateCover(novelId, formData); + const res = await novelsAPI.updateCover(novelId, formData); mutateNovel(); // revalidate from server - toast.success('封面上传成功'); + toast.success("封面上传成功"); } catch (error) { - console.error('Cover upload failed:', error); - toast.error('封面上传失败'); + console.error("Cover upload failed:", error); + toast.error("封面上传失败"); } finally { setIsUploadingCover(false); } }; // keep localNovel in sync when SWR data changes (e.g. after mutate) - useEffect(() => { if (novelData) setLocalNovel(null); }, [novelData]); - + useEffect(() => { + if (novelData) setLocalNovel(null); + }, [novelData]); const loadOutlineVersions = async () => { try { @@ -241,7 +315,7 @@ function NovelDetail() { setGeneratedOutline(res.data[0].content); } } catch (error: any) { - console.error('Failed to load outline versions:', error); + console.error("Failed to load outline versions:", error); // If it's an auth error, the axios interceptor will handle redirect // Otherwise, just set empty array if (error.response?.status !== 401) { @@ -252,7 +326,7 @@ function NovelDetail() { // Load versions when tab stays on outline or chapters useEffect(() => { - if ((activeTab === 'outline' || activeTab === 'chapters') && novelId) { + if ((activeTab === "outline" || activeTab === "chapters") && novelId) { loadOutlineVersions(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -260,15 +334,17 @@ function NovelDetail() { // Load sandboxes when sandbox tab is opened useEffect(() => { - if (activeTab === 'sandbox' && novelId) { - sandboxAPI.list(novelId).then(res => { - setSandboxes(res.data.sandboxes || []); - }).catch(() => {}); + if (activeTab === "sandbox" && novelId) { + sandboxAPI + .list(novelId) + .then((res) => { + setSandboxes(res.data.sandboxes || []); + }) + .catch(() => {}); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTab, novelId]); - if (!displayNovel && loading) { return (
@@ -282,18 +358,18 @@ function NovelDetail() { // Alias so the existing JSX below doesn't need to change const novel = displayNovel; const setNovel = (val: any) => - setLocalNovel(typeof val === 'function' ? val(displayNovel) : val); + setLocalNovel(typeof val === "function" ? val(displayNovel) : val); const tabs = [ - { id: 'settings', label: '基础设定', icon: Settings }, - { id: 'outline', label: '大纲', icon: FileText }, - { id: 'chapters', label: '章节', icon: BookOpen }, - { id: 'characters', label: '人物卡', icon: Users }, - { id: 'threads', label: '伏笔追踪', icon: Bookmark }, - { id: 'timeline', label: '时间线', icon: Clock }, - { id: 'knowledge', label: '知识库', icon: Database }, - { id: 'style', label: '文风设定', icon: Sparkles }, - { id: 'sandbox', label: '推演沙盒', icon: Beaker }, + { id: "settings", label: "基础设定", icon: Settings }, + { id: "outline", label: "大纲", icon: FileText }, + { id: "chapters", label: "章节", icon: BookOpen }, + { id: "characters", label: "人物卡", icon: Users }, + { id: "threads", label: "伏笔追踪", icon: Bookmark }, + { id: "timeline", label: "时间线", icon: Clock }, + { id: "knowledge", label: "知识库", icon: Database }, + { id: "style", label: "文风设定", icon: Sparkles }, + { id: "sandbox", label: "推演沙盒", icon: Beaker }, ]; return ( @@ -302,14 +378,17 @@ function NovelDetail() {
- + 返回藏书阁

- {novel.title || '未命名小说'} + {novel.title || "未命名小说"}

@@ -321,63 +400,80 @@ function NovelDetail() { - { - try { - const res = await novelsAPI.export(novelId, 'json'); - const blob = new Blob([JSON.stringify(res.data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `${novel.title || 'novel'}_export.json`; - link.click(); - URL.revokeObjectURL(url); - toast.success('JSON 导出成功'); - } catch (err) { - toast.error('导出失败'); - } - }}> + { + try { + const res = await novelsAPI.export(novelId, "json"); + const blob = new Blob( + [JSON.stringify(res.data, null, 2)], + { type: "application/json" }, + ); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${novel.title || "novel"}_export.json`; + link.click(); + URL.revokeObjectURL(url); + toast.success("JSON 导出成功"); + } catch (err) { + toast.error("导出失败"); + } + }} + > 项目备份 (JSON) - { - try { - const res = await novelsAPI.export(novelId, 'txt'); - const blob = new Blob([res.data], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `${novel.title || 'novel'}.txt`; - link.click(); - URL.revokeObjectURL(url); - toast.success('TXT 导出成功'); - } catch (err) { - toast.error('导出失败'); - } - }}> + { + try { + const res = await novelsAPI.export(novelId, "txt"); + const blob = new Blob([res.data], { + type: "text/plain", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${novel.title || "novel"}.txt`; + link.click(); + URL.revokeObjectURL(url); + toast.success("TXT 导出成功"); + } catch (err) { + toast.error("导出失败"); + } + }} + > 纯文本 (TXT) - { - try { - const res = await novelsAPI.export(novelId, 'epub'); - const blob = new Blob([res.data], { type: 'application/epub+zip' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `${novel.title || 'novel'}.epub`; - link.click(); - URL.revokeObjectURL(url); - toast.success('EPUB 导出成功'); - } catch (err) { - toast.error('导出失败'); - } - }}> + { + try { + const res = await novelsAPI.export(novelId, "epub"); + const blob = new Blob([res.data], { + type: "application/epub+zip", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${novel.title || "novel"}.epub`; + link.click(); + URL.revokeObjectURL(url); + toast.success("EPUB 导出成功"); + } catch (err) { + toast.error("导出失败"); + } + }} + > 电子书 (EPUB) - @@ -395,8 +491,8 @@ function NovelDetail() { onClick={() => setActiveTab(tab.id)} className={`flex items-center space-x-1.5 px-4 py-2 rounded-lg text-sm font-medium transition whitespace-nowrap ${ activeTab === tab.id - ? 'bg-primary-500 text-white shadow-sm' - : 'glass hover:bg-primary-100 dark:hover:bg-primary-900 border border-transparent dark:border-gray-800' + ? "bg-primary-500 text-white shadow-sm" + : "glass hover:bg-primary-100 dark:hover:bg-primary-900 border border-transparent dark:border-gray-800" }`} > @@ -409,13 +505,17 @@ function NovelDetail() {
{/* Settings Tab */} {/* Settings Tab */} - {activeTab === 'settings' && ( + {activeTab === "settings" && (
-

基础设定

- +

+ 基础设定 +

+
- +
{/* Left Column */} @@ -426,8 +526,10 @@ function NovelDetail() { setNovel({ ...novel, title: e.target.value })} + value={novel.title || ""} + onChange={(e) => + setNovel({ ...novel, title: e.target.value }) + } className="w-full px-4 py-3 glass rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 font-medium text-lg" />
@@ -437,16 +539,24 @@ function NovelDetail() { 类型与风格
- {(!novel.genre?.length && !novel.style?.length) && ( - 暂无标签 + {!novel.genre?.length && !novel.style?.length && ( + + 暂无标签 + )} {novel.genre?.map((g: string) => ( - + {g} ))} {novel.style?.map((s: string) => ( - + {s} ))} @@ -465,7 +575,7 @@ function NovelDetail() { 时间背景
- {novel.worldSettings.timeBackground || '未设定'} + {novel.worldSettings.timeBackground || "未设定"}
{novel.worldSettings.powerSystem && ( @@ -480,22 +590,25 @@ function NovelDetail() { )}
-
- -
- {novel.targetWords?.toLocaleString() || '未设定'} 字 -
-
-
- -
- {novel.minChapterWords?.toLocaleString() || '3000'} 字 -
-
+
+ +
+ {novel.targetWords?.toLocaleString() || "未设定"}{" "} + 字 +
+
+
+ +
+ {novel.minChapterWords?.toLocaleString() || + "3000"}{" "} + 字 +
+
)} @@ -508,12 +621,17 @@ function NovelDetail() {
{novel.knowledgeBases?.length > 0 ? ( novel.knowledgeBases.map((kb: any) => ( - + {kb.name} )) ) : ( - 暂无关联知识库,请前往“知识库”页添加 + + 暂无关联知识库,请前往“知识库”页添加 + )}
@@ -529,9 +647,13 @@ function NovelDetail() {
{novel.coverUrl ? ( <> - Cover
@@ -556,17 +678,18 @@ function NovelDetail() {

- 支持 JPG、PNG 格式,建议比例 3:4。
+ 支持 JPG、PNG 格式,建议比例 3:4。 +
点击左侧区域或下方按钮上传封面。

-