From 223e0162af78dc2442010615993a6d6848c1d70c Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 20 Mar 2026 20:19:18 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A8=AA=E7=AB=96?= =?UTF-8?q?=E5=B1=8F=E5=88=87=E6=8D=A2=E5=AF=BC=E8=87=B4=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=B0=BA=E5=AF=B8=E6=8C=81=E7=BB=AD=E5=A2=9E=E5=A4=A7=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c5f4742..4a5b406 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -279,6 +279,44 @@ function MainContent(): React.JSX.Element { if (key === "settings") navigate("/settings") } + const resolveOrientationTargetSize = (nextPortraitMode: boolean) => { + const currentWidth = Math.max(1, Math.round(window.innerWidth || 0)) + const currentHeight = Math.max(1, Math.round(window.innerHeight || 0)) + + // 切换横竖屏时优先以当前窗口尺寸“互换宽高”为目标,避免每次切换都朝固定大尺寸放大。 + let targetWidth = nextPortraitMode ? currentHeight : currentWidth + let targetHeight = nextPortraitMode ? currentWidth : currentHeight + + const rotatedWidth = nextPortraitMode ? currentHeight : currentWidth + const rotatedHeight = nextPortraitMode ? currentWidth : currentHeight + const ratio = rotatedWidth / Math.max(1, rotatedHeight) + + const availableWidth = window.screen?.availWidth || window.innerWidth || 1200 + const availableHeight = window.screen?.availHeight || window.innerHeight || 800 + const maxWidth = Math.max(420, Math.round(availableWidth - 64)) + const maxHeight = Math.max(520, Math.round(availableHeight - 64)) + + if (targetWidth > maxWidth) { + targetWidth = maxWidth + targetHeight = Math.max(1, Math.round(targetWidth / ratio)) + } + if (targetHeight > maxHeight) { + targetHeight = maxHeight + targetWidth = Math.max(1, Math.round(targetHeight * ratio)) + } + + if (nextPortraitMode && targetHeight <= targetWidth) { + targetHeight = Math.min(maxHeight, targetWidth + 200) + } else if (!nextPortraitMode && targetWidth <= targetHeight) { + targetWidth = Math.min(maxWidth, targetHeight + 200) + } + + return { + width: Math.max(420, Math.round(targetWidth)), + height: Math.max(520, Math.round(targetHeight)), + } + } + const toggleOrientationMode = async () => { const api = (window as any).api if (!api) return @@ -289,14 +327,15 @@ function MainContent(): React.JSX.Element { if (maximized) { await api.windowMaximize() } + const targetSize = resolveOrientationTargetSize(nextPortraitMode) if (nextPortraitMode) { await api.windowSetResizable(false) - await api.windowResize(940, 1600) + await api.windowResize(targetSize.width, targetSize.height) setSidebarCollapsed(true) setFloatingSidebarExpanded(false) } else { await api.windowSetResizable(true) - await api.windowResize(2560, 1440) + await api.windowResize(targetSize.width, targetSize.height) setSidebarCollapsed(false) setFloatingSidebarExpanded(false) } From 3cb15a92674d9c2e3d387a4869263139b329307c Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 20 Mar 2026 20:23:23 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=A8=AA=E7=AB=96?= =?UTF-8?q?=E5=B1=8F=E5=88=87=E6=8D=A2=E7=AD=96=E7=95=A5=E4=B8=BA=E6=A8=AA?= =?UTF-8?q?=E5=B1=8F=E8=AE=B0=E5=BF=86=E6=81=A2=E5=A4=8D=E4=B8=8E=E7=AB=96?= =?UTF-8?q?=E5=B1=8F=E5=9B=BA=E5=AE=9A=E5=B0=BA=E5=AF=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 68 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4a5b406..3dada4d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,6 +63,7 @@ function MainContent(): React.JSX.Element { const syncCheckingRef = useRef(false) const syncApplyLoadingRef = useRef(false) const lastLocalMutationAtRef = useRef(0) + const lastLandscapeSizeRef = useRef<{ width: number; height: number } | null>(null) const activeMenu = useMemo(() => { const p = location.pathname @@ -279,44 +280,47 @@ function MainContent(): React.JSX.Element { if (key === "settings") navigate("/settings") } - const resolveOrientationTargetSize = (nextPortraitMode: boolean) => { - const currentWidth = Math.max(1, Math.round(window.innerWidth || 0)) - const currentHeight = Math.max(1, Math.round(window.innerHeight || 0)) - - // 切换横竖屏时优先以当前窗口尺寸“互换宽高”为目标,避免每次切换都朝固定大尺寸放大。 - let targetWidth = nextPortraitMode ? currentHeight : currentWidth - let targetHeight = nextPortraitMode ? currentWidth : currentHeight - - const rotatedWidth = nextPortraitMode ? currentHeight : currentWidth - const rotatedHeight = nextPortraitMode ? currentWidth : currentHeight - const ratio = rotatedWidth / Math.max(1, rotatedHeight) - + const getMaxWindowSize = () => { const availableWidth = window.screen?.availWidth || window.innerWidth || 1200 const availableHeight = window.screen?.availHeight || window.innerHeight || 800 - const maxWidth = Math.max(420, Math.round(availableWidth - 64)) - const maxHeight = Math.max(520, Math.round(availableHeight - 64)) - - if (targetWidth > maxWidth) { - targetWidth = maxWidth - targetHeight = Math.max(1, Math.round(targetWidth / ratio)) - } - if (targetHeight > maxHeight) { - targetHeight = maxHeight - targetWidth = Math.max(1, Math.round(targetHeight * ratio)) + return { + maxWidth: Math.max(800, Math.round(availableWidth - 64)), + maxHeight: Math.max(600, Math.round(availableHeight - 64)), } + } - if (nextPortraitMode && targetHeight <= targetWidth) { - targetHeight = Math.min(maxHeight, targetWidth + 200) - } else if (!nextPortraitMode && targetWidth <= targetHeight) { - targetWidth = Math.min(maxWidth, targetHeight + 200) + const getFixedPortraitSize = () => { + const { maxWidth, maxHeight } = getMaxWindowSize() + const width = Math.min(940, maxWidth) + const height = Math.max(600, Math.min(1600, maxHeight)) + + if (height > width) { + return { width, height } } return { - width: Math.max(420, Math.round(targetWidth)), - height: Math.max(520, Math.round(targetHeight)), + width: Math.min(maxWidth, Math.max(800, width - 120)), + height: Math.min(maxHeight, Math.max(600, height + 120)), } } + const getLandscapeRestoreSize = () => { + const { maxWidth, maxHeight } = getMaxWindowSize() + const fallback = { width: 1180, height: 680 } + const source = lastLandscapeSizeRef.current || fallback + + let width = Math.max(800, Math.min(maxWidth, Math.round(source.width))) + let height = Math.max(600, Math.min(maxHeight, Math.round(source.height))) + if (width < height) { + const swappedWidth = Math.max(800, Math.min(maxWidth, height)) + const swappedHeight = Math.max(600, Math.min(maxHeight, width)) + width = swappedWidth + height = swappedHeight + } + + return { width, height } + } + const toggleOrientationMode = async () => { const api = (window as any).api if (!api) return @@ -327,13 +331,19 @@ function MainContent(): React.JSX.Element { if (maximized) { await api.windowMaximize() } - const targetSize = resolveOrientationTargetSize(nextPortraitMode) + if (nextPortraitMode) { + lastLandscapeSizeRef.current = { + width: Math.max(1, Math.round(window.innerWidth || 0)), + height: Math.max(1, Math.round(window.innerHeight || 0)), + } + const targetSize = getFixedPortraitSize() await api.windowSetResizable(false) await api.windowResize(targetSize.width, targetSize.height) setSidebarCollapsed(true) setFloatingSidebarExpanded(false) } else { + const targetSize = getLandscapeRestoreSize() await api.windowSetResizable(true) await api.windowResize(targetSize.width, targetSize.height) setSidebarCollapsed(false) From a7d5c59207fca7cdcd3f1e675635d04ac7a1d1ca Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 20 Mar 2026 20:26:24 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=AB=96=E5=B1=8F?= =?UTF-8?q?=E4=B8=BA=E6=89=8B=E6=9C=BA=E5=B0=BA=E5=AF=B8=E5=B9=B6=E6=94=BE?= =?UTF-8?q?=E5=AE=BD=E7=AA=97=E5=8F=A3=E6=9C=80=E5=B0=8F=E5=B0=BA=E5=AF=B8?= =?UTF-8?q?=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/tauri.conf.json | 4 ++-- src/App.tsx | 22 ++++++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3bade52..44642d9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,8 +22,8 @@ "decorations": false, "transparent": false, "center": true, - "minWidth": 800, - "minHeight": 600 + "minWidth": 360, + "minHeight": 640 } ], "security": { diff --git a/src/App.tsx b/src/App.tsx index 3dada4d..914ffe2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -284,24 +284,26 @@ function MainContent(): React.JSX.Element { const availableWidth = window.screen?.availWidth || window.innerWidth || 1200 const availableHeight = window.screen?.availHeight || window.innerHeight || 800 return { - maxWidth: Math.max(800, Math.round(availableWidth - 64)), - maxHeight: Math.max(600, Math.round(availableHeight - 64)), + maxWidth: Math.max(360, Math.round(availableWidth - 64)), + maxHeight: Math.max(640, Math.round(availableHeight - 64)), } } const getFixedPortraitSize = () => { const { maxWidth, maxHeight } = getMaxWindowSize() - const width = Math.min(940, maxWidth) - const height = Math.max(600, Math.min(1600, maxHeight)) + const phonePortraitBase = { width: 430, height: 932 } + let width = Math.max(360, Math.min(phonePortraitBase.width, maxWidth)) + let height = Math.max(640, Math.min(phonePortraitBase.height, maxHeight)) - if (height > width) { - return { width, height } + // 保证竖屏高于宽,维持手机风格比例。 + if (height <= width) { + height = Math.min(maxHeight, Math.max(640, Math.round(width * 2.0))) + } + if (height <= width) { + width = Math.max(360, Math.min(maxWidth, Math.round(height * 0.5))) } - return { - width: Math.min(maxWidth, Math.max(800, width - 120)), - height: Math.min(maxHeight, Math.max(600, height + 120)), - } + return { width, height } } const getLandscapeRestoreSize = () => { From de4935b54d0aa5a7f4560f5b4b80ac86fb0c9f22 Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 20 Mar 2026 21:13:15 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E5=9C=A8=E7=8E=B0=E6=9C=89=20Rust=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=96=B0=E5=A2=9E=20MCP=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E4=B8=8E=E5=8A=A0=E5=88=86=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/mcp.rs | 419 ++++++++++++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 3 + 4 files changed, 425 insertions(+) create mode 100644 src-tauri/src/commands/mcp.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ac2c981..1a617e0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ tauri-plugin-shell = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } +axum = "0.7" sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres"] } sea-orm = { version = "0.12", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls"] } uuid = { version = "1", features = ["v4", "serde"] } diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs new file mode 100644 index 0000000..5fc86ec --- /dev/null +++ b/src-tauri/src/commands/mcp.rs @@ -0,0 +1,419 @@ +use axum::{ + extract::State as AxumState, + http::StatusCode, + response::{IntoResponse, Response}, + routing::post, + Json, Router, +}; +use parking_lot::RwLock; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::net::SocketAddr; +use std::sync::Arc; +use tauri::State; +use tokio::sync::{oneshot, Mutex}; +use uuid::Uuid; + +use crate::db::entities::{score_events, students}; +use crate::services::permission::PermissionLevel; +use crate::state::AppState; + +use super::database::realtime_dual_write_sync; +use super::response::IpcResponse; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerConfig { + pub port: u16, + pub host: String, +} + +impl Default for McpServerConfig { + fn default() -> Self { + Self { + port: 3901, + host: "127.0.0.1".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerStartResult { + pub url: String, + pub config: McpServerConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerStatus { + pub is_running: bool, + pub config: McpServerConfig, + pub url: Option, +} + +struct McpServerState { + pub is_running: bool, + pub config: McpServerConfig, + pub url: Option, + pub shutdown_tx: Option>, +} + +impl Default for McpServerState { + fn default() -> Self { + Self { + is_running: false, + config: McpServerConfig::default(), + url: None, + shutdown_tx: None, + } + } +} + +#[derive(Debug, Deserialize)] +struct McpRequest { + #[allow(dead_code)] + jsonrpc: Option, + id: Option, + method: String, + params: Option, +} + +#[derive(Debug, Deserialize)] +struct ToolCallParams { + name: String, + #[serde(default)] + arguments: Value, +} + +#[derive(Debug, Deserialize)] +struct AddScoreArgs { + student_name: String, + delta: i32, + #[serde(default)] + reason_content: Option, +} + +#[derive(Debug, Serialize)] +struct AddScoreResult { + event_id: i32, + event_uuid: String, + student_name: String, + delta: i32, + val_prev: i32, + val_curr: i32, + reason_content: String, + event_time: String, +} + +static MCP_SERVER_STATE: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(McpServerState::default()))); + +fn check_admin_permission(state: &Arc>) -> Result<(), String> { + let state_guard = state.read(); + let mut permissions = state_guard.permissions.write(); + if !permissions.require_permission(0, PermissionLevel::Admin) { + return Err("Permission denied: Admin required".to_string()); + } + Ok(()) +} + +fn jsonrpc_ok(id: Value, result: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + }) +} + +fn jsonrpc_error(id: Value, code: i32, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }) +} + +fn initialize_result() -> Value { + json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": { + "listChanged": false + } + }, + "serverInfo": { + "name": "secscore-mcp", + "version": "1.0.0" + } + }) +} + +fn tools_list_result() -> Value { + json!({ + "tools": [ + { + "name": "add_score", + "description": "给指定学生加分/扣分,并写入 score_events 记录。", + "inputSchema": { + "type": "object", + "properties": { + "student_name": {"type": "string", "description": "学生姓名"}, + "delta": {"type": "integer", "description": "分值变化,正数加分,负数扣分"}, + "reason_content": {"type": "string", "description": "加分原因,默认 MCP 加分"} + }, + "required": ["student_name", "delta"] + } + } + ] + }) +} + +async fn handle_mcp( + AxumState(app_state): AxumState>>, + Json(payload): Json, +) -> Response { + let req = match serde_json::from_value::(payload) { + Ok(v) => v, + Err(e) => { + let body = jsonrpc_error(Value::Null, -32700, &format!("Parse error: {}", e)); + return (StatusCode::OK, Json(body)).into_response(); + } + }; + + let Some(id) = req.id.clone() else { + return StatusCode::NO_CONTENT.into_response(); + }; + + let response = match req.method.as_str() { + "initialize" => jsonrpc_ok(id, initialize_result()), + "tools/list" => jsonrpc_ok(id, tools_list_result()), + "tools/call" => { + let params = req + .params + .and_then(|p| serde_json::from_value::(p).ok()); + + match params { + None => jsonrpc_error(id, -32602, "Invalid tools/call params"), + Some(params) if params.name != "add_score" => { + jsonrpc_error(id, -32601, &format!("Unknown tool: {}", params.name)) + } + Some(params) => match serde_json::from_value::(params.arguments) { + Ok(args) => match mcp_add_score(&app_state, args).await { + Ok(payload) => jsonrpc_ok( + id, + json!({ + "content": [ + { + "type": "text", + "text": format!( + "已记录:{} {:+} 分({} -> {})", + payload.student_name, payload.delta, payload.val_prev, payload.val_curr + ) + } + ], + "isError": false, + "structuredContent": payload + }), + ), + Err(e) => jsonrpc_ok( + id, + json!({ + "content": [ + { + "type": "text", + "text": format!("加分失败: {}", e) + } + ], + "isError": true + }), + ), + }, + Err(e) => jsonrpc_error(id, -32602, &format!("Invalid arguments: {}", e)), + }, + } + } + "notifications/initialized" => return StatusCode::NO_CONTENT.into_response(), + _ => jsonrpc_error(id, -32601, &format!("Method not found: {}", req.method)), + }; + + (StatusCode::OK, Json(response)).into_response() +} + +async fn mcp_add_score( + app_state: &Arc>, + args: AddScoreArgs, +) -> Result { + let student_name = args.student_name.trim(); + if student_name.is_empty() { + return Err("student_name 不能为空".to_string()); + } + + let reason_content = args + .reason_content + .as_deref() + .unwrap_or("MCP 加分") + .trim() + .to_string(); + + let db_conn = { + let state_guard = app_state.read(); + let db_guard = state_guard.db.read(); + db_guard.clone() + } + .ok_or_else(|| "Database not connected".to_string())?; + + let student = students::Entity::find() + .filter(students::Column::Name.eq(student_name)) + .one(&db_conn) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Student not found: {}", student_name))?; + + let val_prev = student.score; + let val_curr = val_prev + args.delta; + let reward_curr = student.reward_points + args.delta; + let event_time = chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + let event_uuid = Uuid::new_v4().to_string(); + + let txn = db_conn.begin().await.map_err(|e| e.to_string())?; + + let new_event = score_events::ActiveModel { + id: sea_orm::ActiveValue::NotSet, + uuid: Set(event_uuid.clone()), + student_name: Set(student_name.to_string()), + reason_content: Set(reason_content.clone()), + delta: Set(args.delta), + val_prev: Set(val_prev), + val_curr: Set(val_curr), + event_time: Set(event_time.clone()), + settlement_id: Set(None), + }; + + let inserted = new_event.insert(&txn).await.map_err(|e| e.to_string())?; + + let mut student_model: students::ActiveModel = student.into(); + student_model.score = Set(val_curr); + student_model.reward_points = Set(reward_curr); + student_model.updated_at = Set(event_time.clone()); + student_model + .update(&txn) + .await + .map_err(|e| e.to_string())?; + + txn.commit().await.map_err(|e| e.to_string())?; + + realtime_dual_write_sync(app_state).await?; + + Ok(AddScoreResult { + event_id: inserted.id, + event_uuid, + student_name: student_name.to_string(), + delta: args.delta, + val_prev, + val_curr, + reason_content, + event_time, + }) +} + +#[tauri::command] +pub async fn mcp_server_start( + config: Option, + state: State<'_, Arc>>, +) -> Result, String> { + check_admin_permission(&state)?; + + let mut server_state = MCP_SERVER_STATE.lock().await; + if server_state.is_running { + return Ok(IpcResponse::failure_with_type( + "MCP server is already running", + )); + } + + let config = config.unwrap_or_default(); + + let host: std::net::IpAddr = config + .host + .parse() + .map_err(|e| format!("Invalid host address: {}", e))?; + + let bind_addr: SocketAddr = (host, config.port).into(); + + let listener = tokio::net::TcpListener::bind(bind_addr) + .await + .map_err(|e| format!("Failed to bind MCP server: {}", e))?; + + let local_addr = listener + .local_addr() + .map_err(|e| format!("Failed to get local addr: {}", e))?; + + let app_state = state.inner().clone(); + let router = Router::new() + .route("/mcp", post(handle_mcp)) + .with_state(app_state); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + tauri::async_runtime::spawn(async move { + let server = axum::serve(listener, router).with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }); + + if let Err(e) = server.await { + eprintln!("MCP server error: {}", e); + } + }); + + let url = format!("http://{}:{}/mcp", local_addr.ip(), local_addr.port()); + + server_state.is_running = true; + server_state.config = McpServerConfig { + host: local_addr.ip().to_string(), + port: local_addr.port(), + }; + server_state.url = Some(url.clone()); + server_state.shutdown_tx = Some(shutdown_tx); + + Ok(IpcResponse::success(McpServerStartResult { + url, + config: server_state.config.clone(), + })) +} + +#[tauri::command] +pub async fn mcp_server_stop( + state: State<'_, Arc>>, +) -> Result, String> { + check_admin_permission(&state)?; + + let mut server_state = MCP_SERVER_STATE.lock().await; + + if !server_state.is_running { + return Ok(IpcResponse::success_empty()); + } + + if let Some(tx) = server_state.shutdown_tx.take() { + let _ = tx.send(()); + } + + server_state.is_running = false; + server_state.url = None; + + Ok(IpcResponse::success_empty()) +} + +#[tauri::command] +pub async fn mcp_server_status( + _state: State<'_, Arc>>, +) -> Result, String> { + let server_state = MCP_SERVER_STATE.lock().await; + + Ok(IpcResponse::success(McpServerStatus { + is_running: server_state.is_running, + config: server_state.config.clone(), + url: server_state.url.clone(), + })) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5b3419c..a2c92c3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod event; pub mod filesystem; pub mod http_server; pub mod log; +pub mod mcp; pub mod reason; pub mod response; pub mod reward; @@ -28,6 +29,7 @@ pub use event::*; pub use filesystem::*; pub use http_server::*; pub use log::*; +pub use mcp::*; pub use reason::*; pub use response::*; pub use reward::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 408dab1..3603139 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -114,6 +114,9 @@ pub fn run() { http_server_start, http_server_stop, http_server_status, + mcp_server_start, + mcp_server_stop, + mcp_server_status, register_url_protocol, app_quit, app_restart, From 0ad981fc07307f5f9cfde9a29b0bf6dcd7d6d62e Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 20 Mar 2026 21:19:19 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20MCP=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E8=AF=B4=E6=98=8E=E6=96=87=E6=A1=A3=E5=B9=B6=E5=9C=A8?= =?UTF-8?q?=20README=20=E5=A2=9E=E5=8A=A0=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MCP-使用说明.md | 139 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 4 ++ 2 files changed, 143 insertions(+) create mode 100644 MCP-使用说明.md diff --git a/MCP-使用说明.md b/MCP-使用说明.md new file mode 100644 index 0000000..61dfc7c --- /dev/null +++ b/MCP-使用说明.md @@ -0,0 +1,139 @@ +# SecScore MCP 使用说明 + +本文说明如何在现有 Tauri Rust 后端中使用 MCP 服务(HTTP 方式),并调用 `add_score` 工具完成加分/扣分。 + +## 1. 功能概览 + +当前后端已提供以下 Tauri 命令: + +- `mcp_server_start(config?)` +- `mcp_server_stop()` +- `mcp_server_status()` + +服务启动后会暴露 MCP HTTP 入口: + +- `POST /mcp` +- 默认地址:`http://127.0.0.1:3901/mcp` + +当前已实现 MCP 工具: + +- `add_score`:给指定学生加分或扣分,并写入 `score_events` 记录。 + +## 2. 启动与停止 MCP 服务 + +你可以在前端通过 Tauri `invoke` 调用: + +```ts +import { invoke } from '@tauri-apps/api/core'; + +await invoke('mcp_server_start', { + config: { + host: '127.0.0.1', + port: 3901, + }, +}); + +const status = await invoke('mcp_server_status'); +console.log(status); + +await invoke('mcp_server_stop'); +``` + +说明: + +- 不传 `config` 时,默认使用 `127.0.0.1:3901`。 +- 只有管理权限(Admin)可调用 `mcp_server_start/stop/status`。 + +## 3. MCP 请求方式 + +服务采用 JSON-RPC 2.0 格式,请求头使用 `Content-Type: application/json`。 + +### 3.1 initialize + +```bash +curl -X POST http://127.0.0.1:3901/mcp \ + -H 'Content-Type: application/json' \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + }' +``` + +### 3.2 tools/list + +```bash +curl -X POST http://127.0.0.1:3901/mcp \ + -H 'Content-Type: application/json' \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list" + }' +``` + +### 3.3 tools/call(调用 add_score) + +```bash +curl -X POST http://127.0.0.1:3901/mcp \ + -H 'Content-Type: application/json' \ + -d '{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "add_score", + "arguments": { + "student_name": "张三", + "delta": 5, + "reason_content": "课堂表现优秀" + } + } + }' +``` + +`add_score` 参数: + +- `student_name`(string,必填):学生姓名。 +- `delta`(integer,必填):分值变化,正数加分,负数扣分。 +- `reason_content`(string,可选):原因,默认 `MCP 加分`。 + +## 4. 返回结果说明 + +调用 `add_score` 成功时: + +- `result.isError = false` +- `result.structuredContent` 包含: + - `event_id` + - `event_uuid` + - `student_name` + - `delta` + - `val_prev` + - `val_curr` + - `reason_content` + - `event_time` + +调用失败时: + +- `result.isError = true` +- `result.content[0].text` 含失败原因(例如学生不存在、数据库未连接)。 + +## 5. 常见问题 + +### 5.1 调用报 `MCP server is already running` + +表示服务已启动,可直接调用 `/mcp`,或先执行 `mcp_server_stop` 再重启。 + +### 5.2 调用报 `Permission denied: Admin required` + +说明当前会话未获得管理权限,先在应用中解锁管理权限再调用服务管理命令。 + +### 5.3 调用 `add_score` 提示 `Student not found` + +请确认学生已在学生列表中存在,且 `student_name` 与库内名称完全一致。 + +## 6. 安全建议 + +- 推荐仅绑定到 `127.0.0.1`,避免暴露到局域网。 +- 若需要远程访问,建议配合网关鉴权、反向代理白名单或专用内网通道。 diff --git a/README.md b/README.md index ee27cf1..ecee8c8 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,10 @@ SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScrip ## 开发与运行(面向贡献者) +## MCP 文档 + +- [MCP-使用说明](./MCP-使用说明.md) + ### 环境要求 - Node.js(建议使用 LTS 版本) From 1fe9dbe66a8a3df649fd86fb5ba64f3178057ec1 Mon Sep 17 00:00:00 2001 From: JSR Date: Fri, 20 Mar 2026 21:23:23 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20MCP=20=E6=9C=8D=E5=8A=A1=E5=90=AF=E5=8A=A8=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E4=B8=8E=E7=8A=B6=E6=80=81=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Settings.tsx | 155 ++++++++++++++++++++++++++++++++++++ src/i18n/locales/en-US.json | 21 +++++ src/i18n/locales/zh-CN.json | 21 +++++ src/preload/types.ts | 16 ++++ 4 files changed, 213 insertions(+) diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index b3400bd..080d363 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -84,6 +84,20 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission const [urlRegisterLoading, setUrlRegisterLoading] = useState(false) const [appQuitLoading, setAppQuitLoading] = useState(false) const [appRestartLoading, setAppRestartLoading] = useState(false) + const [mcpLoading, setMcpLoading] = useState(false) + const [mcpConfig, setMcpConfig] = useState<{ host: string; port: number }>({ + host: "127.0.0.1", + port: 3901, + }) + const [mcpStatus, setMcpStatus] = useState<{ + is_running: boolean + config: { host: string; port: number } + url?: string | null + }>({ + is_running: false, + config: { host: "127.0.0.1", port: 3901 }, + url: null, + }) const canAdmin = permission === "admin" const [messageApi, contextHolder] = message.useMessage() @@ -115,6 +129,21 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) } + const loadMcpStatus = async () => { + if (!(window as any).api?.mcpServerStatus) return + try { + const res = await (window as any).api.mcpServerStatus() + if (res.success && res.data) { + setMcpStatus(res.data) + if (res.data.config?.host && res.data.config?.port) { + setMcpConfig({ host: res.data.config.host, port: res.data.config.port }) + } + } + } catch { + // ignore status polling errors in settings page + } + } + const loadAll = async () => { if (!(window as any).api) return const res = await (window as any).api.getAllSettings() @@ -125,6 +154,7 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission } const authRes = await (window as any).api.authGetStatus() if (authRes.success && authRes.data) setSecurityStatus(authRes.data) + await loadMcpStatus() } useEffect(() => { @@ -456,6 +486,61 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission } } + const startMcpServer = async () => { + if (!(window as any).api?.mcpServerStart) return + const host = mcpConfig.host.trim() + const port = Number(mcpConfig.port) + if (!host) { + messageApi.warning(t("settings.mcp.hostRequired")) + return + } + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + messageApi.warning(t("settings.mcp.portInvalid")) + return + } + + setMcpLoading(true) + try { + const res = await withTimeout( + (window as any).api.mcpServerStart({ host, port }), + 10_000, + t("settings.mcp.startTimeout") + ) + if (res.success) { + messageApi.success(t("settings.mcp.startSuccess")) + } else { + messageApi.error(res.message || t("settings.mcp.startFailed")) + } + } catch (e: any) { + messageApi.error(e?.message || t("settings.mcp.startFailed")) + } finally { + await loadMcpStatus() + setMcpLoading(false) + } + } + + const stopMcpServer = async () => { + if (!(window as any).api?.mcpServerStop) return + setMcpLoading(true) + try { + const res = await withTimeout( + (window as any).api.mcpServerStop(), + 10_000, + t("settings.mcp.stopTimeout") + ) + if (res.success) { + messageApi.success(t("settings.mcp.stopSuccess")) + } else { + messageApi.error(res.message || t("settings.mcp.stopFailed")) + } + } catch (e: any) { + messageApi.error(e?.message || t("settings.mcp.stopFailed")) + } finally { + await loadMcpStatus() + setMcpLoading(false) + } + } + const currentYear = new Date().getFullYear() const confirmQuitApp = () => { @@ -1057,6 +1142,76 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission +
+ {t("settings.mcp.title")} +
+
+ {t("settings.mcp.description")} +
+ + + {mcpStatus.is_running ? t("settings.mcp.running") : t("settings.mcp.stopped")} + + {mcpStatus.url ? ( + {mcpStatus.url} + ) : ( + {t("settings.mcp.noUrl")} + )} + +
+ + setMcpConfig((prev) => ({ ...prev, host: e.target.value }))} + disabled={!canAdmin || mcpStatus.is_running} + style={{ width: "280px" }} + placeholder="127.0.0.1" + /> + + + { + const next = Number(e.target.value.replace(/[^\d]/g, "")) + if (Number.isFinite(next) && next > 0) { + setMcpConfig((prev) => ({ ...prev, port: next })) + } else if (!e.target.value) { + setMcpConfig((prev) => ({ ...prev, port: 0 })) + } + }} + disabled={!canAdmin || mcpStatus.is_running} + style={{ width: "180px" }} + placeholder="3901" + /> + + + + + + + + +
+
+ {t("settings.mcp.hint")} +
+
{t("settings.app.title")}
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 8b723a1..a395713 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -276,6 +276,27 @@ "registerFailed": "Registration failed", "installerRequired": "Requires installed SecScore, may not work in development mode." }, + "mcp": { + "title": "MCP Service", + "description": "Manage the built-in MCP HTTP service for external MCP clients.", + "running": "Running", + "stopped": "Stopped", + "noUrl": "No URL", + "host": "Host", + "port": "Port", + "start": "Start", + "stop": "Stop", + "refresh": "Refresh Status", + "hint": "Recommended to bind only to 127.0.0.1; after start, endpoint is http://host:port/mcp", + "hostRequired": "Please enter MCP host", + "portInvalid": "Please enter a valid port (1-65535)", + "startSuccess": "MCP server started", + "startFailed": "Failed to start MCP server", + "stopSuccess": "MCP server stopped", + "stopFailed": "Failed to stop MCP server", + "startTimeout": "MCP server start timed out, please try again", + "stopTimeout": "MCP server stop timed out, please try again" + }, "app": { "title": "App Controls", "description": "Quickly restart or quit the app.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0b66ec7..71d39a2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -276,6 +276,27 @@ "registerFailed": "注册失败", "installerRequired": "需要安装版 SecScore,开发模式下可能无效。" }, + "mcp": { + "title": "MCP 服务", + "description": "管理内置 MCP HTTP 服务,供外部 MCP 客户端调用。", + "running": "运行中", + "stopped": "已停止", + "noUrl": "暂无地址", + "host": "主机", + "port": "端口", + "start": "启动", + "stop": "停止", + "refresh": "刷新状态", + "hint": "建议仅绑定到 127.0.0.1;启动后 MCP 入口为 http://host:port/mcp", + "hostRequired": "请输入 MCP 主机地址", + "portInvalid": "请输入有效端口(1-65535)", + "startSuccess": "MCP 服务启动成功", + "startFailed": "MCP 服务启动失败", + "stopSuccess": "MCP 服务已停止", + "stopFailed": "MCP 服务停止失败", + "startTimeout": "启动 MCP 服务超时,请稍后重试", + "stopTimeout": "停止 MCP 服务超时,请稍后重试" + }, "app": { "title": "应用控制", "description": "快速重启或退出应用。", diff --git a/src/preload/types.ts b/src/preload/types.ts index 136c600..9fdacb7 100644 --- a/src/preload/types.ts +++ b/src/preload/types.ts @@ -291,6 +291,22 @@ const api = { data: { isRunning: boolean; config?: any; url?: string } }> => invoke("http_server_status"), + // MCP Server + mcpServerStart: (config?: { + port?: number + host?: string + }): Promise<{ success: boolean; data?: { url: string; config: { port: number; host: string } } }> => + invoke("mcp_server_start", { config }), + mcpServerStop: (): Promise<{ success: boolean }> => invoke("mcp_server_stop"), + mcpServerStatus: (): Promise<{ + success: boolean + data?: { + is_running: boolean + config: { port: number; host: string } + url?: string | null + } + }> => invoke("mcp_server_status"), + // File System fsGetConfigStructure: (): Promise<{ success: boolean