增加了mcp server

codex/dev
mula.liu 2026-04-14 12:37:48 +08:00
parent d69b346f4c
commit f44453d93e
11 changed files with 411 additions and 110 deletions

View File

@ -24,7 +24,7 @@ from app.api.endpoints import (
voiceprint, voiceprint,
) )
from app.core.config import UPLOAD_DIR from app.core.config import UPLOAD_DIR
from app.core.middleware import TerminalCheckMiddleware from app.core.middleware import MCPPathNormalizeMiddleware, TerminalCheckMiddleware
from app.mcp import create_mcp_http_app, get_mcp_session_manager from app.mcp import create_mcp_http_app, get_mcp_session_manager
from app.services.system_config_service import SystemConfigService from app.services.system_config_service import SystemConfigService
@ -38,6 +38,7 @@ def create_app() -> FastAPI:
redoc_url=None, redoc_url=None,
) )
app.add_middleware(MCPPathNormalizeMiddleware)
app.add_middleware(TerminalCheckMiddleware) app.add_middleware(TerminalCheckMiddleware)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -50,8 +51,8 @@ def create_app() -> FastAPI:
if UPLOAD_DIR.exists(): if UPLOAD_DIR.exists():
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads") app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
mcp_session_manager = get_mcp_session_manager()
mcp_asgi_app = create_mcp_http_app() mcp_asgi_app = create_mcp_http_app()
mcp_session_manager = get_mcp_session_manager()
if mcp_asgi_app is not None: if mcp_asgi_app is not None:
app.mount("/mcp", mcp_asgi_app, name="mcp") app.mount("/mcp", mcp_asgi_app, name="mcp")

View File

@ -5,6 +5,26 @@ from app.services.terminal_service import terminal_service
from app.services.jwt_service import jwt_service from app.services.jwt_service import jwt_service
from app.core.response import create_api_response from app.core.response import create_api_response
class MCPPathNormalizeMiddleware:
"""Normalize the public MCP endpoint to /mcp while keeping the mounted app happy."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "http" and scope.get("path") == "/mcp":
normalized_scope = dict(scope)
normalized_scope["path"] = "/mcp/"
raw_path = scope.get("raw_path")
if raw_path in (None, b"/mcp"):
normalized_scope["raw_path"] = b"/mcp/"
scope = normalized_scope
await self.app(scope, receive, send)
class TerminalCheckMiddleware(BaseHTTPMiddleware): class TerminalCheckMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# 1. 检查是否有 Imei 头,没有则认为是普通请求,直接放行 # 1. 检查是否有 Imei 头,没有则认为是普通请求,直接放行

View File

@ -6,6 +6,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
import hmac import hmac
import json import json
import logging
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@ -16,7 +17,7 @@ try:
except ImportError: # pragma: no cover - runtime dependency except ImportError: # pragma: no cover - runtime dependency
FastMCP = None FastMCP = None
from app.core.config import APP_CONFIG from app.core.config import APP_CONFIG, API_CONFIG
from app.core.database import get_db_connection from app.core.database import get_db_connection
from app.mcp.context import ( from app.mcp.context import (
MCPRequestContext, MCPRequestContext,
@ -26,6 +27,8 @@ from app.mcp.context import (
) )
from app.services import meeting_service from app.services import meeting_service
logger = logging.getLogger(__name__)
def _build_absolute_url(path: str) -> str: def _build_absolute_url(path: str) -> str:
normalized_base = APP_CONFIG["base_url"].rstrip("/") normalized_base = APP_CONFIG["base_url"].rstrip("/")
@ -181,6 +184,9 @@ class MCPHeaderAuthApp:
reset_current_mcp_request(token) reset_current_mcp_request(token)
_mcp_http_app = None
if FastMCP is not None: if FastMCP is not None:
mcp_server = FastMCP( mcp_server = FastMCP(
"iMeeting MCP", "iMeeting MCP",
@ -188,6 +194,9 @@ if FastMCP is not None:
stateless_http=True, stateless_http=True,
streamable_http_path="/", streamable_http_path="/",
) )
# Keep FastMCP's server settings aligned with the outer Uvicorn process.
mcp_server.settings.host = API_CONFIG["host"]
mcp_server.settings.port = API_CONFIG["port"]
@mcp_server.tool() @mcp_server.tool()
def get_my_meetings() -> Dict[str, Any]: def get_my_meetings() -> Dict[str, Any]:
@ -237,6 +246,7 @@ if FastMCP is not None:
} }
else: # pragma: no cover - graceful fallback without runtime dependency else: # pragma: no cover - graceful fallback without runtime dependency
mcp_server = None mcp_server = None
logger.warning("FastMCP is unavailable because the 'mcp' package is not installed; /mcp will not be mounted.")
def get_mcp_server(): def get_mcp_server():
@ -244,15 +254,21 @@ def get_mcp_server():
def get_mcp_session_manager(): def get_mcp_session_manager():
if mcp_server is None: if create_mcp_http_app() is None:
return None return None
return mcp_server.session_manager return mcp_server.session_manager
def create_mcp_http_app(): def create_mcp_http_app():
global _mcp_http_app
if mcp_server is None: if mcp_server is None:
return None return None
return MCPHeaderAuthApp(mcp_server.streamable_http_app()) if _mcp_http_app is None:
# FastMCP initializes its session manager when the Streamable HTTP app
# is created, so cache the app and reuse it across startup/mounting.
_mcp_http_app = MCPHeaderAuthApp(mcp_server.streamable_http_app())
return _mcp_http_app
def get_mcp_asgi_app(): def get_mcp_asgi_app():

View File

@ -33,6 +33,28 @@ server {
proxy_send_timeout 300s; proxy_send_timeout 300s;
} }
# MCP Streamable HTTP 代理
# 对外以 /mcp 作为标准入口,避免 MCP Client 处理尾斜杠重定向。
location = /mcp {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
chunked_transfer_encoding on;
}
location = /mcp/ {
return 308 /mcp;
}
location = /docs { location = /docs {
proxy_pass http://backend:8000/docs; proxy_pass http://backend:8000/docs;
proxy_http_version 1.1; proxy_http_version 1.1;

View File

@ -1,22 +1,9 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { Suspense, lazy, useCallback, useEffect, useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { ConfigProvider, theme, App as AntdApp } from 'antd'; import { ConfigProvider, theme, App as AntdApp } from 'antd';
import zhCN from 'antd/locale/zh_CN'; import zhCN from 'antd/locale/zh_CN';
import apiClient from './utils/apiClient'; import apiClient from './utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from './config/api'; import { buildApiUrl, API_ENDPOINTS } from './config/api';
import HomePage from './pages/HomePage';
import Dashboard from './pages/Dashboard';
import AdminDashboard from './pages/AdminDashboard';
import MeetingDetails from './pages/MeetingDetails';
import MeetingPreview from './pages/MeetingPreview';
import AdminManagement from './pages/AdminManagement';
import PromptManagementPage from './pages/PromptManagementPage';
import PromptConfigPage from './pages/PromptConfigPage';
import KnowledgeBasePage from './pages/KnowledgeBasePage';
import EditKnowledgeBase from './pages/EditKnowledgeBase';
import ClientDownloadPage from './pages/ClientDownloadPage';
import AccountSettings from './pages/AccountSettings';
import MeetingCenterPage from './pages/MeetingCenterPage';
import MainLayout from './components/MainLayout'; import MainLayout from './components/MainLayout';
import menuService from './services/menuService'; import menuService from './services/menuService';
import { import {
@ -28,6 +15,33 @@ import configService from './utils/configService';
import './App.css'; import './App.css';
import './styles/console-theme.css'; import './styles/console-theme.css';
const HomePage = lazy(() => import('./pages/HomePage'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const MeetingDetails = lazy(() => import('./pages/MeetingDetails'));
const MeetingPreview = lazy(() => import('./pages/MeetingPreview'));
const AdminManagement = lazy(() => import('./pages/AdminManagement'));
const PromptManagementPage = lazy(() => import('./pages/PromptManagementPage'));
const PromptConfigPage = lazy(() => import('./pages/PromptConfigPage'));
const KnowledgeBasePage = lazy(() => import('./pages/KnowledgeBasePage'));
const EditKnowledgeBase = lazy(() => import('./pages/EditKnowledgeBase'));
const ClientDownloadPage = lazy(() => import('./pages/ClientDownloadPage'));
const AccountSettings = lazy(() => import('./pages/AccountSettings'));
const MeetingCenterPage = lazy(() => import('./pages/MeetingCenterPage'));
const RouteLoading = ({ text = '页面加载中...' }) => (
<div className="app-loading">
<div className="loading-spinner"></div>
<p>{text}</p>
</div>
);
const withRouteSuspense = (node, text) => (
<Suspense fallback={<RouteLoading text={text} />}>
{node}
</Suspense>
);
// Layout Wrapper to inject user and handleLogout // Layout Wrapper to inject user and handleLogout
const AuthenticatedLayout = ({ user, handleLogout }) => { const AuthenticatedLayout = ({ user, handleLogout }) => {
// //
@ -228,26 +242,28 @@ function App() {
<Routes> <Routes>
{/* Public Routes */} {/* Public Routes */}
<Route path="/" element={ <Route path="/" element={
user ? <DefaultMenuRedirect user={user} /> : <HomePage onLogin={handleLogin} /> user
? <DefaultMenuRedirect user={user} />
: withRouteSuspense(<HomePage onLogin={handleLogin} />, '加载登录页中...')
} /> } />
<Route path="/meetings/preview/:meeting_id" element={<MeetingPreview />} /> <Route path="/meetings/preview/:meeting_id" element={withRouteSuspense(<MeetingPreview />, '加载会议预览中...')} />
<Route path="/downloads" element={<ClientDownloadPage />} /> <Route path="/downloads" element={withRouteSuspense(<ClientDownloadPage />, '加载客户端下载页中...')} />
{/* Authenticated Routes */} {/* Authenticated Routes */}
<Route element={user ? <AuthenticatedLayout user={user} handleLogout={handleLogout} /> : <Navigate to="/" replace />}> <Route element={user ? <AuthenticatedLayout user={user} handleLogout={handleLogout} /> : <Navigate to="/" replace />}>
<Route path="/dashboard" element={ <Route path="/dashboard" element={
user?.role_id === 1 user?.role_id === 1
? <AdminDashboard user={user} onLogout={handleLogout} /> ? withRouteSuspense(<AdminDashboard user={user} onLogout={handleLogout} />, '加载工作台中...')
: <Dashboard user={user} onLogout={handleLogout} /> : withRouteSuspense(<Dashboard user={user} onLogout={handleLogout} />, '加载工作台中...')
} /> } />
<Route path="/meetings/center" element={ <Route path="/meetings/center" element={
user?.role_id === 1 user?.role_id === 1
? <Navigate to="/dashboard" replace /> ? <Navigate to="/dashboard" replace />
: <MeetingCenterPage user={user} /> : withRouteSuspense(<MeetingCenterPage user={user} />, '加载会议中心中...')
} /> } />
<Route path="/meetings/history" element={<Navigate to="/meetings/center" replace />} /> <Route path="/meetings/history" element={<Navigate to="/meetings/center" replace />} />
<Route path="/meetings/:meeting_id" element={<MeetingDetails user={user} />} /> <Route path="/meetings/:meeting_id" element={withRouteSuspense(<MeetingDetails user={user} />, '加载会议详情中...')} />
<Route path="/meetings/create" element={<Navigate to="/meetings/center" replace />} /> <Route path="/meetings/create" element={<Navigate to="/meetings/center" replace />} />
<Route path="/meetings/edit/:meeting_id" element={<Navigate to="/meetings/center" replace />} /> <Route path="/meetings/edit/:meeting_id" element={<Navigate to="/meetings/center" replace />} />
<Route path="/admin/management" element={ <Route path="/admin/management" element={
@ -256,18 +272,22 @@ function App() {
: <Navigate to="/dashboard" replace /> : <Navigate to="/dashboard" replace />
} /> } />
<Route path="/admin/management/:moduleKey" element={ <Route path="/admin/management/:moduleKey" element={
user?.role_id === 1 ? <AdminManagement user={user} /> : <Navigate to="/dashboard" replace /> user?.role_id === 1
? withRouteSuspense(<AdminManagement user={user} />, '加载管理模块中...')
: <Navigate to="/dashboard" replace />
} /> } />
<Route path="/prompt-management" element={ <Route path="/prompt-management" element={
user?.role_id === 1 ? <PromptManagementPage user={user} /> : <Navigate to="/dashboard" replace /> user?.role_id === 1
? withRouteSuspense(<PromptManagementPage user={user} />, '加载提示词页面中...')
: <Navigate to="/dashboard" replace />
} /> } />
<Route path="/prompt-config" element={<PromptConfigPage user={user} />} /> <Route path="/prompt-config" element={withRouteSuspense(<PromptConfigPage user={user} />, '加载提示词配置中...')} />
<Route path="/personal-prompts" element={ <Route path="/personal-prompts" element={
user?.role_id === 1 ? <Navigate to="/dashboard" replace /> : <Navigate to="/prompt-config" replace /> user?.role_id === 1 ? <Navigate to="/dashboard" replace /> : <Navigate to="/prompt-config" replace />
} /> } />
<Route path="/knowledge-base" element={<KnowledgeBasePage user={user} />} /> <Route path="/knowledge-base" element={withRouteSuspense(<KnowledgeBasePage user={user} />, '加载知识库中...')} />
<Route path="/knowledge-base/edit/:kb_id" element={<EditKnowledgeBase user={user} />} /> <Route path="/knowledge-base/edit/:kb_id" element={withRouteSuspense(<EditKnowledgeBase user={user} />, '加载知识库编辑页中...')} />
<Route path="/account-settings" element={<AccountSettings user={user} onUpdateUser={handleLogin} />} /> <Route path="/account-settings" element={withRouteSuspense(<AccountSettings user={user} onUpdateUser={handleLogin} />, '加载账号设置中...')} />
</Route> </Route>
{/* Catch all */} {/* Catch all */}

View File

@ -1,9 +1,6 @@
import React, { useState, useRef, useMemo } from 'react'; import React, { Suspense, lazy, useEffect, useMemo, useRef, useState } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { EditorView } from '@codemirror/view';
import { import {
Space, Button, Tooltip, Card, Space, Button, Tooltip, Card, Spin,
Divider, Dropdown, Typography Divider, Dropdown, Typography
} from 'antd'; } from 'antd';
import { import {
@ -13,7 +10,8 @@ import {
UnorderedListOutlined, LineOutlined, EyeOutlined, UnorderedListOutlined, LineOutlined, EyeOutlined,
EditOutlined EditOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import MarkdownRenderer from './MarkdownRenderer';
const MarkdownRenderer = lazy(() => import('./MarkdownRenderer'));
const { Text } = Typography; const { Text } = Typography;
@ -28,11 +26,41 @@ const MarkdownEditor = ({
const editorRef = useRef(null); const editorRef = useRef(null);
const imageInputRef = useRef(null); const imageInputRef = useRef(null);
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [editorRuntime, setEditorRuntime] = useState(null);
useEffect(() => {
let active = true;
Promise.all([
import('@uiw/react-codemirror'),
import('@codemirror/lang-markdown'),
import('@codemirror/view'),
])
.then(([codeMirrorModule, markdownModule, viewModule]) => {
if (!active) {
return;
}
setEditorRuntime({
CodeMirror: codeMirrorModule.default,
markdown: markdownModule.markdown,
markdownLanguage: markdownModule.markdownLanguage,
EditorView: viewModule.EditorView,
});
})
.catch((error) => {
console.error('Load markdown editor runtime failed:', error);
});
return () => {
active = false;
};
}, []);
const editorExtensions = useMemo(() => [ const editorExtensions = useMemo(() => [
markdown({ base: markdownLanguage }), ...(editorRuntime ? [editorRuntime.markdown({ base: editorRuntime.markdownLanguage })] : []),
EditorView.lineWrapping, editorRuntime?.EditorView?.lineWrapping,
EditorView.theme({ editorRuntime?.EditorView?.theme({
"&": { "&": {
fontSize: "14px", fontSize: "14px",
border: "1px solid #d9d9d9", border: "1px solid #d9d9d9",
@ -50,7 +78,9 @@ const MarkdownEditor = ({
boxShadow: "0 0 0 2px rgba(22, 119, 255, 0.1)", boxShadow: "0 0 0 2px rgba(22, 119, 255, 0.1)",
} }
}) })
], [height]); ].filter(Boolean), [editorRuntime, height]);
const CodeMirror = editorRuntime?.CodeMirror;
const insertMarkdown = (before, after = '', placeholder = '') => { const insertMarkdown = (before, after = '', placeholder = '') => {
if (!editorRef.current?.view) return; if (!editorRef.current?.view) return;
@ -133,17 +163,29 @@ const MarkdownEditor = ({
{showPreview ? ( {showPreview ? (
<Card bordered styles={{ body: { padding: 16, minHeight: height, overflowY: 'auto' } }} style={{ borderRadius: '0 0 8px 8px' }}> <Card bordered styles={{ body: { padding: 16, minHeight: height, overflowY: 'auto' } }} style={{ borderRadius: '0 0 8px 8px' }}>
<MarkdownRenderer content={value} /> <Suspense fallback={<Spin tip="预览加载中..." />}>
<MarkdownRenderer content={value} />
</Suspense>
</Card> </Card>
) : ( ) : (
<CodeMirror CodeMirror ? (
ref={editorRef} <CodeMirror
value={value} ref={editorRef}
onChange={onChange} value={value}
extensions={editorExtensions} onChange={onChange}
placeholder={placeholder} extensions={editorExtensions}
basicSetup={{ lineNumbers: false, foldGutter: false }} placeholder={placeholder}
/> basicSetup={{ lineNumbers: false, foldGutter: false }}
/>
) : (
<Card
bordered
styles={{ body: { minHeight: height, display: 'flex', alignItems: 'center', justifyContent: 'center' } }}
style={{ borderRadius: '0 0 8px 8px' }}
>
<Spin tip="编辑器加载中..." />
</Card>
)
)} )}
<input ref={imageInputRef} type="file" accept="image/*" onChange={(e) => { <input ref={imageInputRef} type="file" accept="image/*" onChange={(e) => {

View File

@ -1,16 +1,68 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown'; import { Typography, Empty, Spin } from 'antd';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { Typography, Empty } from 'antd';
const { Paragraph } = Typography; const { Paragraph } = Typography;
const MarkdownRenderer = ({ content, className = "", emptyMessage = "暂无内容" }) => { const MarkdownRenderer = ({ content, className = "", emptyMessage = "暂无内容" }) => {
const [runtime, setRuntime] = useState(null);
useEffect(() => {
if (!content || runtime) {
return undefined;
}
let active = true;
Promise.all([
import('react-markdown'),
import('remark-gfm'),
import('rehype-raw'),
])
.then(([reactMarkdownModule, remarkGfmModule, rehypeRawModule]) => {
if (!active) {
return;
}
setRuntime({
ReactMarkdown: reactMarkdownModule.default,
remarkGfm: remarkGfmModule.default,
rehypeRaw: rehypeRawModule.default,
});
})
.catch((error) => {
console.error('Load markdown renderer runtime failed:', error);
});
return () => {
active = false;
};
}, [content, runtime]);
if (!content) { if (!content) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyMessage} />; return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyMessage} />;
} }
if (!runtime) {
return (
<div
className={`markdown-renderer-modern ${className}`}
style={{
minHeight: 120,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '15px',
lineHeight: 1.8,
color: '#262626',
}}
>
<Spin tip="内容渲染中..." />
</div>
);
}
const { ReactMarkdown, remarkGfm, rehypeRaw } = runtime;
return ( return (
<div className={`markdown-renderer-modern ${className}`} style={{ fontSize: '15px', lineHeight: 1.8, color: '#262626' }}> <div className={`markdown-renderer-modern ${className}`} style={{ fontSize: '15px', lineHeight: 1.8, color: '#262626' }}>
<ReactMarkdown <ReactMarkdown

View File

@ -1,11 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Transformer } from 'markmap-lib';
import { Markmap } from 'markmap-view';
import { Spin, Empty, Button, Space } from 'antd'; import { Spin, Empty, Button, Space } from 'antd';
import { FullscreenOutlined, ZoomInOutlined, ZoomOutOutlined, SyncOutlined } from '@ant-design/icons'; import { FullscreenOutlined, ZoomInOutlined, ZoomOutOutlined, SyncOutlined } from '@ant-design/icons';
const transformer = new Transformer();
const hasRenderableSize = (element) => { const hasRenderableSize = (element) => {
if (!element) return false; if (!element) return false;
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
@ -16,35 +12,80 @@ const MindMap = ({ content }) => {
const svgRef = useRef(null); const svgRef = useRef(null);
const markmapRef = useRef(null); const markmapRef = useRef(null);
const latestRootRef = useRef(null); const latestRootRef = useRef(null);
const runtimeRef = useRef(null);
const runtimePromiseRef = useRef(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const ensureRuntime = async () => {
if (runtimeRef.current) {
return runtimeRef.current;
}
if (!runtimePromiseRef.current) {
runtimePromiseRef.current = Promise.all([
import('markmap-lib'),
import('markmap-view'),
]).then(([markmapLibModule, markmapViewModule]) => {
runtimeRef.current = {
transformer: new markmapLibModule.Transformer(),
Markmap: markmapViewModule.Markmap,
};
return runtimeRef.current;
});
}
return runtimePromiseRef.current;
};
useEffect(() => { useEffect(() => {
if (!content || !svgRef.current) return; if (!content || !svgRef.current) return undefined;
let active = true;
setLoading(true); setLoading(true);
try { setLoadError(null);
const { root } = transformer.transform(content);
latestRootRef.current = root;
if (markmapRef.current) { (async () => {
markmapRef.current.setData(root); try {
} else { const { transformer, Markmap } = await ensureRuntime();
markmapRef.current = Markmap.create(svgRef.current, { if (!active || !svgRef.current) {
autoFit: false, return;
duration: 500,
}, root);
}
requestAnimationFrame(() => {
if (svgRef.current && hasRenderableSize(svgRef.current)) {
markmapRef.current?.fit();
} }
});
} catch (error) { const { root } = transformer.transform(content);
console.error('Markmap error:', error); latestRootRef.current = root;
} finally {
setLoading(false); if (markmapRef.current) {
} markmapRef.current.setData(root);
} else {
markmapRef.current = Markmap.create(svgRef.current, {
autoFit: false,
duration: 500,
}, root);
}
requestAnimationFrame(() => {
if (svgRef.current && hasRenderableSize(svgRef.current)) {
markmapRef.current?.fit();
}
});
} catch (error) {
console.error('Markmap error:', error);
if (active) {
setLoadError(error);
}
} finally {
if (active) {
setLoading(false);
}
}
})();
return () => {
active = false;
};
}, [content]); }, [content]);
useEffect(() => { useEffect(() => {
@ -59,10 +100,20 @@ const MindMap = ({ content }) => {
} }
if (!markmapRef.current && latestRootRef.current) { if (!markmapRef.current && latestRootRef.current) {
markmapRef.current = Markmap.create(svgElement, { ensureRuntime()
autoFit: false, .then(({ Markmap }) => {
duration: 500, if (markmapRef.current || !latestRootRef.current) {
}, latestRootRef.current); return;
}
markmapRef.current = Markmap.create(svgElement, {
autoFit: false,
duration: 500,
}, latestRootRef.current);
})
.catch((error) => {
console.error('Resize observer markmap init failed:', error);
});
} }
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -80,6 +131,10 @@ const MindMap = ({ content }) => {
if (!content) return <Empty description="暂无内容,无法生成思维导图" />; if (!content) return <Empty description="暂无内容,无法生成思维导图" />;
if (loadError) {
return <Empty description="思维导图加载失败,请稍后重试" />;
}
return ( return (
<div className="mindmap-container" style={{ position: 'relative', width: '100%', height: '100%', minHeight: 500 }}> <div className="mindmap-container" style={{ position: 'relative', width: '100%', height: '100%', minHeight: 500 }}>
{loading && ( {loading && (

View File

@ -9,15 +9,8 @@ import {
DesktopOutlined, DesktopOutlined,
DeploymentUnitOutlined, DeploymentUnitOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import UserManagement from '../pages/admin/UserManagement';
import PermissionManagement from '../pages/admin/PermissionManagement'; export const SYSTEM_MANAGEMENT_OVERVIEW_LOADER = () => import('../pages/admin/SystemManagementOverview');
import DictManagement from '../pages/admin/DictManagement';
import HotWordManagement from '../pages/admin/HotWordManagement';
import ParameterManagement from '../pages/admin/ParameterManagement';
import ModelManagement from '../pages/admin/ModelManagement';
import ClientManagement from '../pages/ClientManagement';
import ExternalAppManagement from '../pages/admin/ExternalAppManagement';
import TerminalManagement from '../pages/admin/TerminalManagement';
export const SYSTEM_MANAGEMENT_MODULES = [ export const SYSTEM_MANAGEMENT_MODULES = [
{ {
@ -26,7 +19,7 @@ export const SYSTEM_MANAGEMENT_MODULES = [
desc: '账号、角色、密码重置', desc: '账号、角色、密码重置',
path: '/admin/management/user-management', path: '/admin/management/user-management',
icon: <UserOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />, icon: <UserOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />,
component: UserManagement, loader: () => import('../pages/admin/UserManagement'),
group: '基础治理', group: '基础治理',
}, },
{ {
@ -35,7 +28,7 @@ export const SYSTEM_MANAGEMENT_MODULES = [
desc: '菜单与角色授权矩阵', desc: '菜单与角色授权矩阵',
path: '/admin/management/permission-management', path: '/admin/management/permission-management',
icon: <KeyOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />, icon: <KeyOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />,
component: PermissionManagement, loader: () => import('../pages/admin/PermissionManagement'),
group: '基础治理', group: '基础治理',
}, },
{ {
@ -44,7 +37,7 @@ export const SYSTEM_MANAGEMENT_MODULES = [
desc: '码表、平台类型、扩展属性', desc: '码表、平台类型、扩展属性',
path: '/admin/management/dict-management', path: '/admin/management/dict-management',
icon: <DatabaseOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />, icon: <DatabaseOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />,
component: DictManagement, loader: () => import('../pages/admin/DictManagement'),
group: '基础治理', group: '基础治理',
}, },
{ {
@ -53,7 +46,7 @@ export const SYSTEM_MANAGEMENT_MODULES = [
desc: 'ASR 热词与同步', desc: 'ASR 热词与同步',
path: '/admin/management/hot-word-management', path: '/admin/management/hot-word-management',
icon: <FontSizeOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />, icon: <FontSizeOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />,
component: HotWordManagement, loader: () => import('../pages/admin/HotWordManagement'),
group: '基础治理', group: '基础治理',
}, },
{ {
@ -62,7 +55,7 @@ export const SYSTEM_MANAGEMENT_MODULES = [
desc: '系统参数统一配置', desc: '系统参数统一配置',
path: '/admin/management/parameter-management', path: '/admin/management/parameter-management',
icon: <SettingOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />, icon: <SettingOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />,
component: ParameterManagement, loader: () => import('../pages/admin/ParameterManagement'),
group: '平台配置', group: '平台配置',
}, },
{ {
@ -71,7 +64,7 @@ export const SYSTEM_MANAGEMENT_MODULES = [
desc: '音频模型与 LLM 模型配置', desc: '音频模型与 LLM 模型配置',
path: '/admin/management/model-management', path: '/admin/management/model-management',
icon: <AppstoreOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />, icon: <AppstoreOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />,
component: ModelManagement, loader: () => import('../pages/admin/ModelManagement'),
group: '平台配置', group: '平台配置',
}, },
{ {
@ -80,7 +73,7 @@ export const SYSTEM_MANAGEMENT_MODULES = [
desc: '版本、下载地址、发布状态', desc: '版本、下载地址、发布状态',
path: '/admin/management/client-management', path: '/admin/management/client-management',
icon: <DesktopOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />, icon: <DesktopOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />,
component: ClientManagement, loader: () => import('../pages/ClientManagement'),
group: '设备与应用', group: '设备与应用',
}, },
{ {
@ -89,7 +82,7 @@ export const SYSTEM_MANAGEMENT_MODULES = [
desc: '外部系统入口与图标配置', desc: '外部系统入口与图标配置',
path: '/admin/management/external-app-management', path: '/admin/management/external-app-management',
icon: <AppstoreOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />, icon: <AppstoreOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />,
component: ExternalAppManagement, loader: () => import('../pages/admin/ExternalAppManagement'),
group: '设备与应用', group: '设备与应用',
}, },
{ {
@ -98,7 +91,7 @@ export const SYSTEM_MANAGEMENT_MODULES = [
desc: '专用设备、激活和绑定状态', desc: '专用设备、激活和绑定状态',
path: '/admin/management/terminal-management', path: '/admin/management/terminal-management',
icon: <DeploymentUnitOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />, icon: <DeploymentUnitOutlined style={{ fontSize: 18, color: '#1d4ed8' }} />,
component: TerminalManagement, loader: () => import('../pages/admin/TerminalManagement'),
group: '设备与应用', group: '设备与应用',
}, },
]; ];

View File

@ -1,9 +1,9 @@
import React, { useMemo } from 'react'; import React, { Suspense, lazy, useMemo } from 'react';
import { Alert } from 'antd'; import { Alert } from 'antd';
import { Navigate, useParams } from 'react-router-dom'; import { Navigate, useParams } from 'react-router-dom';
import SystemManagementOverview from './admin/SystemManagementOverview';
import { import {
DEFAULT_SYSTEM_MANAGEMENT_MODULE, DEFAULT_SYSTEM_MANAGEMENT_MODULE,
SYSTEM_MANAGEMENT_OVERVIEW_LOADER,
SYSTEM_MANAGEMENT_MODULE_MAP, SYSTEM_MANAGEMENT_MODULE_MAP,
} from '../config/systemManagementModules'; } from '../config/systemManagementModules';
@ -13,15 +13,23 @@ const AdminManagement = () => {
const currentModule = useMemo(() => ( const currentModule = useMemo(() => (
currentModuleKey === DEFAULT_SYSTEM_MANAGEMENT_MODULE currentModuleKey === DEFAULT_SYSTEM_MANAGEMENT_MODULE
? { component: SystemManagementOverview } ? { loader: SYSTEM_MANAGEMENT_OVERVIEW_LOADER }
: SYSTEM_MANAGEMENT_MODULE_MAP[currentModuleKey] : SYSTEM_MANAGEMENT_MODULE_MAP[currentModuleKey]
), [currentModuleKey]); ), [currentModuleKey]);
const CurrentComponent = useMemo(() => {
if (!currentModule?.loader) {
return null;
}
return lazy(currentModule.loader);
}, [currentModule]);
if (!moduleKey) { if (!moduleKey) {
return <Navigate to={`/admin/management/${DEFAULT_SYSTEM_MANAGEMENT_MODULE}`} replace />; return <Navigate to={`/admin/management/${DEFAULT_SYSTEM_MANAGEMENT_MODULE}`} replace />;
} }
if (!currentModule) { if (!currentModule || !CurrentComponent) {
return ( return (
<Alert <Alert
type="warning" type="warning"
@ -32,8 +40,11 @@ const AdminManagement = () => {
); );
} }
const CurrentComponent = currentModule.component; return (
return <CurrentComponent />; <Suspense fallback={<Alert type="info" showIcon message="管理模块加载中..." />}>
<CurrentComponent />
</Suspense>
);
}; };
export default AdminManagement; export default AdminManagement;

View File

@ -1,9 +1,78 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
const manualChunks = (id) => {
if (!id.includes('node_modules')) {
return undefined;
}
const normalizedId = id.split('node_modules/')[1];
if (
normalizedId.startsWith('react/') ||
normalizedId.startsWith('react-dom/') ||
normalizedId.startsWith('scheduler/')
) {
return 'react-vendor';
}
if (
normalizedId.startsWith('@ant-design/icons/') ||
normalizedId.startsWith('@ant-design/icons-svg/')
) {
return 'ant-icons-vendor';
}
if (normalizedId.startsWith('axios/')) {
return 'http-vendor';
}
if (normalizedId.startsWith('yaml/')) {
return 'yaml-vendor';
}
if (
normalizedId.startsWith('markdown-it/') ||
normalizedId.startsWith('markdown-it-') ||
normalizedId.startsWith('linkify-it/') ||
normalizedId.startsWith('mdurl/') ||
normalizedId.startsWith('uc.micro/') ||
normalizedId.startsWith('entities/') ||
normalizedId.startsWith('punycode.js/') ||
normalizedId.startsWith('katex/') ||
normalizedId.startsWith('@vscode/markdown-it-katex/')
) {
return 'markdown-parser-vendor';
}
if (
normalizedId.startsWith('html2canvas/') ||
normalizedId.startsWith('jspdf/') ||
normalizedId.startsWith('canvg/')
) {
return 'capture-vendor';
}
if (
normalizedId.startsWith('qrcode.react/') ||
normalizedId.startsWith('qrcode/')
) {
return 'qrcode-vendor';
}
return undefined;
};
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks,
},
},
},
server: { server: {
host: true, // Optional: Allows the server to be accessible externally host: true, // Optional: Allows the server to be accessible externally
allowedHosts: ['dev.imeeting.unisspace.com','nonperforated-ivonne-fezzy.ngrok-free.dev'], // Add the problematic hostname here allowedHosts: ['dev.imeeting.unisspace.com','nonperforated-ivonne-fezzy.ngrok-free.dev'], // Add the problematic hostname here