Browse Source

refactor: 清理dashboard技术债并改进连接状态

lingfengQAQ 3 months ago
parent
commit
ccb29269a5

+ 20 - 0
docs/README.md

@@ -2,10 +2,30 @@
 
 本目录承载 `README.md` 之外的详细说明,按模块拆分:
 
+- [架构与模块](#架构与模块)
+- [命令详解](#命令详解)
+- [RAG 与配置](#rag-与配置)
+- [题材模板](#题材模板)
+- [运维与恢复](#运维与恢复)
+
+## 架构与模块
+
 - `architecture.md`:系统架构、核心理念、双 Agent、六维审查
+
+## 命令详解
+
 - `commands.md`:`/webnovel-*` 命令详细说明
+
+## RAG 与配置
+
 - `rag-and-config.md`:RAG 检索与环境配置
+
+## 题材模板
+
 - `genres.md`:题材模板与复合题材规则
+
+## 运维与恢复
+
 - `operations.md`:项目结构与故障恢复/运维手册
 
 建议阅读顺序:

+ 22 - 53
webnovel-writer/dashboard/app.py

@@ -6,8 +6,8 @@ Webnovel Dashboard - FastAPI 主应用
 
 import asyncio
 import json
-import os
 import sqlite3
+from contextlib import asynccontextmanager, closing
 from pathlib import Path
 from typing import Optional
 
@@ -48,7 +48,17 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
     if project_root:
         _project_root = Path(project_root).resolve()
 
-    app = FastAPI(title="Webnovel Dashboard", version="0.1.0")
+    @asynccontextmanager
+    async def _lifespan(_: FastAPI):
+        webnovel = _webnovel_dir()
+        if webnovel.is_dir():
+            _watcher.start(webnovel, asyncio.get_running_loop())
+        try:
+            yield
+        finally:
+            _watcher.stop()
+
+    app = FastAPI(title="Webnovel Dashboard", version="0.1.0", lifespan=_lifespan)
 
     app.add_middleware(
         CORSMiddleware,
@@ -57,17 +67,6 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
         allow_headers=["*"],
     )
 
-    # --- 生命周期 ---
-    @app.on_event("startup")
-    async def _startup():
-        webnovel = _webnovel_dir()
-        if webnovel.is_dir():
-            _watcher.start(webnovel, asyncio.get_event_loop())
-
-    @app.on_event("shutdown")
-    async def _shutdown():
-        _watcher.stop()
-
     # ===========================================================
     # API:项目元信息
     # ===========================================================
@@ -98,8 +97,7 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
         include_archived: bool = False,
     ):
         """列出所有实体(可按类型过滤)。"""
-        conn = _get_db()
-        try:
+        with closing(_get_db()) as conn:
             q = "SELECT * FROM entities"
             params: list = []
             clauses: list[str] = []
@@ -113,24 +111,18 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
             q += " ORDER BY last_appearance DESC"
             rows = conn.execute(q, params).fetchall()
             return [dict(r) for r in rows]
-        finally:
-            conn.close()
 
     @app.get("/api/entities/{entity_id}")
     def get_entity(entity_id: str):
-        conn = _get_db()
-        try:
+        with closing(_get_db()) as conn:
             row = conn.execute("SELECT * FROM entities WHERE id = ?", (entity_id,)).fetchone()
             if not row:
                 raise HTTPException(404, "实体不存在")
             return dict(row)
-        finally:
-            conn.close()
 
     @app.get("/api/relationships")
     def list_relationships(entity: Optional[str] = None, limit: int = 200):
-        conn = _get_db()
-        try:
+        with closing(_get_db()) as conn:
             if entity:
                 rows = conn.execute(
                     "SELECT * FROM relationships WHERE from_entity = ? OR to_entity = ? ORDER BY chapter DESC LIMIT ?",
@@ -142,8 +134,6 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
                     (limit,),
                 ).fetchall()
             return [dict(r) for r in rows]
-        finally:
-            conn.close()
 
     @app.get("/api/relationship-events")
     def list_relationship_events(
@@ -152,8 +142,7 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
         to_chapter: Optional[int] = None,
         limit: int = 200,
     ):
-        conn = _get_db()
-        try:
+        with closing(_get_db()) as conn:
             q = "SELECT * FROM relationship_events"
             params: list = []
             clauses: list[str] = []
@@ -172,22 +161,16 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
             params.append(limit)
             rows = conn.execute(q, params).fetchall()
             return [dict(r) for r in rows]
-        finally:
-            conn.close()
 
     @app.get("/api/chapters")
     def list_chapters():
-        conn = _get_db()
-        try:
+        with closing(_get_db()) as conn:
             rows = conn.execute("SELECT * FROM chapters ORDER BY chapter ASC").fetchall()
             return [dict(r) for r in rows]
-        finally:
-            conn.close()
 
     @app.get("/api/scenes")
     def list_scenes(chapter: Optional[int] = None, limit: int = 500):
-        conn = _get_db()
-        try:
+        with closing(_get_db()) as conn:
             if chapter is not None:
                 rows = conn.execute(
                     "SELECT * FROM scenes WHERE chapter = ? ORDER BY scene_index ASC", (chapter,)
@@ -197,35 +180,26 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
                     "SELECT * FROM scenes ORDER BY chapter ASC, scene_index ASC LIMIT ?", (limit,)
                 ).fetchall()
             return [dict(r) for r in rows]
-        finally:
-            conn.close()
 
     @app.get("/api/reading-power")
     def list_reading_power(limit: int = 50):
-        conn = _get_db()
-        try:
+        with closing(_get_db()) as conn:
             rows = conn.execute(
                 "SELECT * FROM chapter_reading_power ORDER BY chapter DESC LIMIT ?", (limit,)
             ).fetchall()
             return [dict(r) for r in rows]
-        finally:
-            conn.close()
 
     @app.get("/api/review-metrics")
     def list_review_metrics(limit: int = 20):
-        conn = _get_db()
-        try:
+        with closing(_get_db()) as conn:
             rows = conn.execute(
                 "SELECT * FROM review_metrics ORDER BY end_chapter DESC LIMIT ?", (limit,)
             ).fetchall()
             return [dict(r) for r in rows]
-        finally:
-            conn.close()
 
     @app.get("/api/state-changes")
     def list_state_changes(entity: Optional[str] = None, limit: int = 100):
-        conn = _get_db()
-        try:
+        with closing(_get_db()) as conn:
             if entity:
                 rows = conn.execute(
                     "SELECT * FROM state_changes WHERE entity_id = ? ORDER BY chapter DESC LIMIT ?",
@@ -236,13 +210,10 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
                     "SELECT * FROM state_changes ORDER BY chapter DESC LIMIT ?", (limit,)
                 ).fetchall()
             return [dict(r) for r in rows]
-        finally:
-            conn.close()
 
     @app.get("/api/aliases")
     def list_aliases(entity: Optional[str] = None):
-        conn = _get_db()
-        try:
+        with closing(_get_db()) as conn:
             if entity:
                 rows = conn.execute(
                     "SELECT * FROM aliases WHERE entity_id = ?", (entity,)
@@ -250,8 +221,6 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
             else:
                 rows = conn.execute("SELECT * FROM aliases").fetchall()
             return [dict(r) for r in rows]
-        finally:
-            conn.close()
 
     # ===========================================================
     # API:文档浏览(正文/大纲/设定集 —— 只读)

File diff suppressed because it is too large
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/index-B2cLSGXr.js


+ 1 - 1
webnovel-writer/dashboard/frontend/dist/index.html

@@ -8,7 +8,7 @@
     <link rel="preconnect" href="https://fonts.googleapis.com" />
     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
     <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
-    <script type="module" crossorigin src="/assets/index-lD60Rg1m.js"></script>
+    <script type="module" crossorigin src="/assets/index-B2cLSGXr.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Cw6rJgHT.css">
   </head>
   <body>

+ 10 - 142
webnovel-writer/dashboard/frontend/src/App.jsx

@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback, useRef } from 'react'
+import { useState, useEffect, useCallback } from 'react'
 import { fetchJSON, subscribeSSE } from './api.js'
 import ForceGraph3D from 'react-force-graph-3d'
 
@@ -22,10 +22,15 @@ export default function App() {
 
     // SSE 订阅
     useEffect(() => {
-        setConnected(true)
-        const unsub = subscribeSSE(() => {
-            setRefreshKey(k => k + 1)
-        })
+        const unsub = subscribeSSE(
+            () => {
+                setRefreshKey(k => k + 1)
+            },
+            {
+                onOpen: () => setConnected(true),
+                onError: () => setConnected(false),
+            },
+        )
         return () => { unsub(); setConnected(false) }
     }, [])
 
@@ -570,140 +575,3 @@ function formatJSON(str) {
         return str
     }
 }
-
-
-// ====================================================================
-// 辅助:Canvas 力导图绘制
-// ====================================================================
-
-function drawGraph(canvas, entities, relationships) {
-    const ctx = canvas.getContext('2d')
-    const dpr = window.devicePixelRatio || 1
-    const rect = canvas.parentElement.getBoundingClientRect()
-    canvas.width = rect.width * dpr
-    canvas.height = rect.height * dpr
-    canvas.style.width = rect.width + 'px'
-    canvas.style.height = rect.height + 'px'
-    ctx.scale(dpr, dpr)
-
-    const W = rect.width, H = rect.height
-
-    // 构建节点集合(仅出现在关系中的实体)
-    const relatedIds = new Set()
-    relationships.forEach(r => { relatedIds.add(r.from_entity); relatedIds.add(r.to_entity) })
-
-    const entityMap = {}
-    entities.forEach(e => { entityMap[e.id] = e })
-
-    const nodeIds = [...relatedIds].slice(0, 80)
-    const nodes = nodeIds.map((id, i) => {
-        const angle = (2 * Math.PI * i) / nodeIds.length
-        const r = Math.min(W, H) * 0.35
-        return {
-            id,
-            label: entityMap[id]?.canonical_name || id,
-            type: entityMap[id]?.type || '未知',
-            x: W / 2 + r * Math.cos(angle) + (Math.random() - 0.5) * 40,
-            y: H / 2 + r * Math.sin(angle) + (Math.random() - 0.5) * 40,
-            vx: 0, vy: 0,
-        }
-    })
-
-    const nodeMap = {}
-    nodes.forEach(n => { nodeMap[n.id] = n })
-
-    const edges = relationships
-        .filter(r => nodeMap[r.from_entity] && nodeMap[r.to_entity])
-        .map(r => ({ source: nodeMap[r.from_entity], target: nodeMap[r.to_entity], type: r.type }))
-
-    // 简易力模拟(50 轮)
-    for (let iter = 0; iter < 50; iter++) {
-        // 排斥力
-        for (let i = 0; i < nodes.length; i++) {
-            for (let j = i + 1; j < nodes.length; j++) {
-                let dx = nodes[j].x - nodes[i].x
-                let dy = nodes[j].y - nodes[i].y
-                let d = Math.sqrt(dx * dx + dy * dy) || 1
-                let force = 5000 / (d * d)
-                let fx = (dx / d) * force
-                let fy = (dy / d) * force
-                nodes[i].vx -= fx; nodes[i].vy -= fy
-                nodes[j].vx += fx; nodes[j].vy += fy
-            }
-        }
-        // 吸引力
-        edges.forEach(e => {
-            let dx = e.target.x - e.source.x
-            let dy = e.target.y - e.source.y
-            let d = Math.sqrt(dx * dx + dy * dy) || 1
-            let force = (d - 120) * 0.01
-            let fx = (dx / d) * force
-            let fy = (dy / d) * force
-            e.source.vx += fx; e.source.vy += fy
-            e.target.vx -= fx; e.target.vy -= fy
-        })
-        // 向心力
-        nodes.forEach(n => {
-            n.vx += (W / 2 - n.x) * 0.001
-            n.vy += (H / 2 - n.y) * 0.001
-            n.x += n.vx * 0.5
-            n.y += n.vy * 0.5
-            n.vx *= 0.8
-            n.vy *= 0.8
-            n.x = Math.max(40, Math.min(W - 40, n.x))
-            n.y = Math.max(40, Math.min(H - 40, n.y))
-        })
-    }
-
-    // 绘制
-    ctx.clearRect(0, 0, W, H)
-
-    // 边(带渐变发光)
-    edges.forEach(e => {
-        const grad = ctx.createLinearGradient(e.source.x, e.source.y, e.target.x, e.target.y)
-        grad.addColorStop(0, 'rgba(139, 92, 246, 0.3)')
-        grad.addColorStop(1, 'rgba(79, 143, 247, 0.15)')
-        ctx.beginPath()
-        ctx.moveTo(e.source.x, e.source.y)
-        ctx.lineTo(e.target.x, e.target.y)
-        ctx.strokeStyle = grad
-        ctx.lineWidth = 1.2
-        ctx.stroke()
-    })
-
-    // 节点(带发光晕)
-    const typeColors = {
-        '角色': '#4f8ff7', '地点': '#34d399', '物品': '#f59e0b',
-        '势力': '#8b5cf6', '招式': '#ef4444',
-    }
-    nodes.forEach(n => {
-        const color = typeColors[n.type] || '#5c6078'
-
-        // 外发光
-        ctx.beginPath()
-        ctx.arc(n.x, n.y, 16, 0, Math.PI * 2)
-        const glow = ctx.createRadialGradient(n.x, n.y, 4, n.x, n.y, 16)
-        glow.addColorStop(0, color + '40')
-        glow.addColorStop(1, 'transparent')
-        ctx.fillStyle = glow
-        ctx.fill()
-
-        // 实心节点
-        ctx.beginPath()
-        ctx.arc(n.x, n.y, 7, 0, Math.PI * 2)
-        ctx.fillStyle = color
-        ctx.fill()
-        ctx.strokeStyle = 'rgba(255,255,255,0.2)'
-        ctx.lineWidth = 1.5
-        ctx.stroke()
-
-        // 标签(带阴影)
-        ctx.fillStyle = '#eaf0ff'
-        ctx.font = '500 11px Inter, Noto Sans SC, sans-serif'
-        ctx.textAlign = 'center'
-        ctx.shadowColor = 'rgba(0,0,0,0.6)'
-        ctx.shadowBlur = 4
-        ctx.fillText(n.label, n.x, n.y - 14)
-        ctx.shadowBlur = 0
-    })
-}

+ 9 - 3
webnovel-writer/dashboard/frontend/src/api.js

@@ -17,17 +17,23 @@ export async function fetchJSON(path, params = {}) {
 /**
  * 订阅 SSE 实时事件流
  * @param {function} onMessage  收到 data 时回调
+ * @param {{onOpen?: function, onError?: function}} handlers 连接状态回调
  * @returns {function} 取消订阅函数
  */
-export function subscribeSSE(onMessage) {
+export function subscribeSSE(onMessage, handlers = {}) {
+    const { onOpen, onError } = handlers
     const es = new EventSource(`${BASE}/api/events`);
+    es.onopen = () => {
+        if (onOpen) onOpen()
+    };
     es.onmessage = (e) => {
         try {
             onMessage(JSON.parse(e.data));
         } catch { /* ignore parse errors */ }
     };
-    es.onerror = () => {
-        // 自动重连由 EventSource 处理
+    es.onerror = (e) => {
+        // EventSource 会自动重连,这里只更新连接状态
+        if (onError) onError(e)
     };
     return () => es.close();
 }

Some files were not shown because too many files changed in this diff