diff --git a/apps/backend/src/routes/chapter.routes.ts b/apps/backend/src/routes/chapter.routes.ts index 2cbb9f4..da39ce6 100644 --- a/apps/backend/src/routes/chapter.routes.ts +++ b/apps/backend/src/routes/chapter.routes.ts @@ -7,6 +7,45 @@ import { OocAgent } from "../services/ai/agents"; const router: Router = Router(); +const MAX_SNAPSHOTS_PER_CHAPTER = 30; + +async function createChapterSnapshot( + chapterId: string, + novelId: string, + content: string, + title: string, + wordCount: number, + label: string = "自动快照", +) { + const existing = await db.query.chapterSnapshots.findMany({ + where: eq(schema.chapterSnapshots.chapterId, chapterId), + orderBy: [desc(schema.chapterSnapshots.createdAt)], + }); + + if (existing.length >= MAX_SNAPSHOTS_PER_CHAPTER) { + const toDelete = existing.slice(MAX_SNAPSHOTS_PER_CHAPTER - 1); + for (const snap of toDelete) { + await db + .delete(schema.chapterSnapshots) + .where(eq(schema.chapterSnapshots.id, snap.id)); + } + } + + const [snapshot] = await db + .insert(schema.chapterSnapshots) + .values({ + chapterId, + novelId, + content, + title, + wordCount: wordCount ?? 0, + label, + }) + .returning(); + + return snapshot; +} + // ========= Analysis & Checking Routes ========= // POST /chapters/:id/ooc-check - Check text for out-of-character behavior @@ -172,31 +211,14 @@ router.post("/:id/snapshots", async (req: AuthRequest, res, next) => { return; } - // Limit to 30 snapshots per chapter - const existing = await db.query.chapterSnapshots.findMany({ - where: eq(schema.chapterSnapshots.chapterId, id), - orderBy: [desc(schema.chapterSnapshots.createdAt)], - }); - if (existing.length >= 30) { - const toDelete = existing.slice(29); - for (const snap of toDelete) { - await db - .delete(schema.chapterSnapshots) - .where(eq(schema.chapterSnapshots.id, snap.id)); - } - } - - const [snapshot] = await db - .insert(schema.chapterSnapshots) - .values({ - chapterId: id, - novelId: chapter.novelId, - content: chapter.content, - title: chapter.title, - wordCount: chapter.wordCount ?? 0, - label: label ?? "手动快照", - }) - .returning(); + const snapshot = await createChapterSnapshot( + id, + chapter.novelId, + chapter.content, + chapter.title, + chapter.wordCount ?? 0, + label ?? "手动快照", + ); res.status(201).json(snapshot); } catch (error) { @@ -244,14 +266,14 @@ router.post( where: eq(schema.chapters.id, id), }); if (currentChapter?.content) { - await db.insert(schema.chapterSnapshots).values({ - chapterId: id, - novelId: currentChapter.novelId, - content: currentChapter.content, - title: currentChapter.title, - wordCount: currentChapter.wordCount ?? 0, - label: "还原前自动保存", - }); + await createChapterSnapshot( + id, + currentChapter.novelId, + currentChapter.content, + currentChapter.title, + currentChapter.wordCount ?? 0, + "还原前自动保存", + ); } // Restore snapshot content diff --git a/apps/backend/src/routes/novel.routes.ts b/apps/backend/src/routes/novel.routes.ts index 7aa164b..63b8137 100644 --- a/apps/backend/src/routes/novel.routes.ts +++ b/apps/backend/src/routes/novel.routes.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { db, schema } from "../database"; -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import { AuthRequest } from "../middleware/auth"; import { AppError } from "../middleware/errorHandler"; import { z } from "zod"; @@ -29,6 +29,24 @@ import epub from "epub-gen-memory"; const router: Router = Router(); +async function validateCharacterRelationships( + novelId: string, + relationships: { characterId: string; relation: string }[] | undefined, +): Promise<{ characterId: string; relation: string }[]> { + if (!relationships?.length) return []; + + const existingCharacterIds = ( + await db.query.characters.findMany({ + where: eq(schema.characters.novelId, novelId), + columns: { id: true }, + }) + ).map((c) => c.id); + + return relationships.filter((r) => + existingCharacterIds.includes(r.characterId), + ); +} + // Configure multer for file uploads const storage = multer.diskStorage({ destination: (_req, _file, cb) => { @@ -215,6 +233,30 @@ router.get("/", async (req: AuthRequest, res, next) => { } }); +// Get all novels with volumes/chapters for stats (bulk endpoint) +router.get("/stats/all", async (req: AuthRequest, res, next) => { + try { + const novels = await db.query.novels.findMany({ + where: eq(schema.novels.userId, req.userId!), + orderBy: (novels, { desc }) => [desc(novels.createdAt)], + with: { + volumes: { + with: { + chapters: { + orderBy: (chapters, { asc }) => [asc(chapters.order)], + }, + }, + orderBy: (volumes, { asc }) => [asc(volumes.order)], + }, + }, + }); + + res.json(novels); + } catch (error) { + next(error); + } +}); + // Get single novel router.get("/:id", async (req: AuthRequest, res, next) => { try { @@ -429,11 +471,19 @@ router.get("/:id/characters", async (req: AuthRequest, res, next) => { // Create character router.post("/:id/characters", async (req: AuthRequest, res, next) => { try { + const novelId = req.params.id; + const characterData = req.body; + + characterData.relationships = await validateCharacterRelationships( + novelId, + characterData.relationships, + ); + const [character] = await db .insert(schema.characters) .values({ - novelId: req.params.id, - ...req.body, + novelId, + ...characterData, }) .returning(); @@ -448,10 +498,21 @@ router.patch( "/:novelId/characters/:characterId", async (req: AuthRequest, res, next) => { try { + const novelId = req.params.novelId; + const characterId = req.params.characterId; + const characterData = req.body; + + if (characterData.relationships !== undefined) { + characterData.relationships = await validateCharacterRelationships( + novelId, + characterData.relationships, + ); + } + const [character] = await db .update(schema.characters) - .set({ ...req.body, updatedAt: new Date() }) - .where(eq(schema.characters.id, req.params.characterId)) + .set({ ...characterData, updatedAt: new Date() }) + .where(eq(schema.characters.id, characterId)) .returning(); res.json(character); @@ -466,9 +527,37 @@ router.delete( "/:novelId/characters/:characterId", async (req: AuthRequest, res, next) => { try { + const novelId = req.params.novelId; + const deletedCharacterId = req.params.characterId; + + const otherCharacters = await db.query.characters.findMany({ + where: and( + eq(schema.characters.novelId, novelId), + ne(schema.characters.id, deletedCharacterId), + ), + }); + + for (const char of otherCharacters) { + if (char.relationships?.length) { + const filteredRelationships = char.relationships.filter( + (r) => r.characterId !== deletedCharacterId, + ); + + if (filteredRelationships.length !== char.relationships.length) { + await db + .update(schema.characters) + .set({ + relationships: filteredRelationships, + updatedAt: new Date(), + }) + .where(eq(schema.characters.id, char.id)); + } + } + } + await db .delete(schema.characters) - .where(eq(schema.characters.id, req.params.characterId)); + .where(eq(schema.characters.id, deletedCharacterId)); res.status(204).send(); } catch (error) { diff --git a/apps/frontend/app/stats/page.tsx b/apps/frontend/app/stats/page.tsx index 5bfbaad..84a30aa 100644 --- a/apps/frontend/app/stats/page.tsx +++ b/apps/frontend/app/stats/page.tsx @@ -86,14 +86,8 @@ export default function StatsPage() { } try { - const res = await novelsAPI.list(); - // Also fetch full novel for each to get volumes/chapters - const detailedNovels = await Promise.all( - (res.data || []).map((n: any) => - novelsAPI.get(n.id).then((r) => r.data), - ), - ); - setNovels(detailedNovels); + const res = await novelsAPI.getStats(); + setNovels(res.data || []); if (showToast) { toast.success("数据已刷新"); diff --git a/apps/frontend/components/novel/RelationshipGraph.tsx b/apps/frontend/components/novel/RelationshipGraph.tsx index b233a43..a9853c7 100644 --- a/apps/frontend/components/novel/RelationshipGraph.tsx +++ b/apps/frontend/components/novel/RelationshipGraph.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo } from "react"; import { ReactFlow, MiniMap, @@ -8,9 +8,9 @@ import { useEdgesState, Edge, Node, - MarkerType -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; + MarkerType, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; interface Character { id: string; @@ -22,7 +22,11 @@ interface Character { relationships?: { characterId: string; relation: string }[]; } -export default function RelationshipGraph({ characters }: { characters: Character[] }) { +export default function RelationshipGraph({ + characters, +}: { + characters: Character[]; +}) { const initialNodes: Node[] = useMemo(() => { if (!characters || characters.length === 0) return []; const radius = Math.max(200, characters.length * 30); @@ -33,17 +37,17 @@ export default function RelationshipGraph({ characters }: { characters: Characte id: c.id, data: { label: c.name }, position: { - x: center.x + radius * Math.cos(angle), - y: center.y + radius * Math.sin(angle) + x: center.x + radius * Math.cos(angle), + y: center.y + radius * Math.sin(angle), }, style: { - background: c.role === '主角' ? '#f3e8ff' : '#fff', - border: '1px solid #d8b4fe', - borderRadius: '8px', - padding: '10px 20px', - fontWeight: 'bold', - color: '#6b21a8' - } + background: c.role === "主角" ? "#f3e8ff" : "#fff", + border: "1px solid #d8b4fe", + borderRadius: "8px", + padding: "10px 20px", + fontWeight: "bold", + color: "#6b21a8", + }, }; }); }, [characters]); @@ -51,25 +55,28 @@ export default function RelationshipGraph({ characters }: { characters: Characte const initialEdges: Edge[] = useMemo(() => { const edges: Edge[] = []; if (!characters) return edges; - characters.forEach(c => { - c.relationships?.forEach(r => { - edges.push({ - id: `e-${c.id}-${r.characterId}`, - source: c.id, - target: r.characterId, - label: r.relation, - type: 'smoothstep', - animated: true, - markerEnd: { - type: MarkerType.ArrowClosed, - color: '#a855f7', - }, - style: { stroke: '#a855f7', strokeWidth: 2 }, - labelStyle: { fill: '#7e22ce', fontWeight: 600, fontSize: 12 }, - labelBgStyle: { fill: 'rgba(255, 255, 255, 0.9)' }, - labelBgPadding: [4, 4], - labelBgBorderRadius: 4, - }); + const characterIds = new Set(characters.map((c) => c.id)); + characters.forEach((c) => { + c.relationships?.forEach((r) => { + if (characterIds.has(r.characterId)) { + edges.push({ + id: `e-${c.id}-${r.characterId}`, + source: c.id, + target: r.characterId, + label: r.relation, + type: "smoothstep", + animated: true, + markerEnd: { + type: MarkerType.ArrowClosed, + color: "#a855f7", + }, + style: { stroke: "#a855f7", strokeWidth: 2 }, + labelStyle: { fill: "#7e22ce", fontWeight: 600, fontSize: 12 }, + labelBgStyle: { fill: "rgba(255, 255, 255, 0.9)" }, + labelBgPadding: [4, 4], + labelBgBorderRadius: 4, + }); + } }); }); return edges; @@ -92,7 +99,10 @@ export default function RelationshipGraph({ characters }: { characters: Characte } return ( -
+
- { - return n.style?.background === '#f3e8ff' ? '#d8b4fe' : '#e5e7eb'; - }} /> + { + return n.style?.background === "#f3e8ff" ? "#d8b4fe" : "#e5e7eb"; + }} + />
diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 928011c..934a19f 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -65,6 +65,7 @@ export const authAPI = { // Novels API export const novelsAPI = { list: () => api.get("/novels"), + getStats: () => api.get("/novels/stats/all"), get: (id: string) => api.get(`/novels/${id}`), create: (data: any) => api.post("/novels", data), update: (id: string, data: any) => api.patch(`/novels/${id}`, data),