mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
Merge branch 'SecScore-tauri' of https://github.com/SECTL/SecScore into SecScore-tauri
This commit is contained in:
+139
@@ -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`,避免暴露到局域网。
|
||||
- 若需要远程访问,建议配合网关鉴权、反向代理白名单或专用内网通道。
|
||||
@@ -102,6 +102,10 @@ SecScore 是一款教育积分管理软件,基于 Electron + React + TypeScrip
|
||||
|
||||
## 开发与运行(面向贡献者)
|
||||
|
||||
## MCP 文档
|
||||
|
||||
- [MCP-使用说明](./MCP-使用说明.md)
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js(建议使用 LTS 版本)
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
struct McpServerState {
|
||||
pub is_running: bool,
|
||||
pub config: McpServerConfig,
|
||||
pub url: Option<String>,
|
||||
pub shutdown_tx: Option<oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
id: Option<Value>,
|
||||
method: String,
|
||||
params: Option<Value>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<Arc<Mutex<McpServerState>>> =
|
||||
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(McpServerState::default())));
|
||||
|
||||
fn check_admin_permission(state: &Arc<RwLock<AppState>>) -> 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<Arc<RwLock<AppState>>>,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Response {
|
||||
let req = match serde_json::from_value::<McpRequest>(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::<ToolCallParams>(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::<AddScoreArgs>(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<RwLock<AppState>>,
|
||||
args: AddScoreArgs,
|
||||
) -> Result<AddScoreResult, String> {
|
||||
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<McpServerConfig>,
|
||||
state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<McpServerStartResult>, 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<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<()>, 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<RwLock<AppState>>>,
|
||||
) -> Result<IpcResponse<McpServerStatus>, 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(),
|
||||
}))
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"center": true,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600
|
||||
"minWidth": 360,
|
||||
"minHeight": 640
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+88
@@ -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
|
||||
@@ -284,6 +285,93 @@ function MainContent(): React.JSX.Element {
|
||||
if (key === "settings") navigate("/settings")
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
const getMaxWindowSize = () => {
|
||||
const availableWidth = window.screen?.availWidth || window.innerWidth || 1200
|
||||
const availableHeight = window.screen?.availHeight || window.innerHeight || 800
|
||||
return {
|
||||
maxWidth: Math.max(360, Math.round(availableWidth - 64)),
|
||||
maxHeight: Math.max(640, Math.round(availableHeight - 64)),
|
||||
}
|
||||
}
|
||||
|
||||
const getFixedPortraitSize = () => {
|
||||
const { maxWidth, maxHeight } = getMaxWindowSize()
|
||||
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) {
|
||||
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, height }
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const nextPortraitMode = !isPortraitMode
|
||||
try {
|
||||
const maximized = await api.windowIsMaximized()
|
||||
if (maximized) {
|
||||
await api.windowMaximize()
|
||||
}
|
||||
|
||||
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)
|
||||
setFloatingSidebarExpanded(false)
|
||||
}
|
||||
setIsPortraitMode(nextPortraitMode)
|
||||
} catch (error) {
|
||||
messageApi.error(t("common.error"))
|
||||
console.error("Failed to toggle orientation mode:", error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPortraitMode || !sidebarCollapsed) {
|
||||
setFloatingSidebarExpanded(false)
|
||||
}
|
||||
}, [isPortraitMode, sidebarCollapsed])
|
||||
|
||||
>>>>>>> 1fe9dbe66a8a3df649fd86fb5ba64f3178057ec1
|
||||
const toggleSidebar = () => {
|
||||
if (isPortraitMode && sidebarCollapsed) {
|
||||
setFloatingSidebarExpanded((prev) => !prev)
|
||||
|
||||
@@ -88,6 +88,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()
|
||||
|
||||
@@ -119,6 +133,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()
|
||||
@@ -129,6 +158,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(() => {
|
||||
@@ -464,6 +494,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 = () => {
|
||||
@@ -1065,6 +1150,76 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
|
||||
</Space>
|
||||
</div>
|
||||
<Divider />
|
||||
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
|
||||
{t("settings.mcp.title")}
|
||||
</div>
|
||||
<div style={{ color: "var(--ss-text-secondary)", marginBottom: "12px", fontSize: "12px" }}>
|
||||
{t("settings.mcp.description")}
|
||||
</div>
|
||||
<Space style={{ marginBottom: "12px" }}>
|
||||
<Tag color={mcpStatus.is_running ? "success" : "default"}>
|
||||
{mcpStatus.is_running ? t("settings.mcp.running") : t("settings.mcp.stopped")}
|
||||
</Tag>
|
||||
{mcpStatus.url ? (
|
||||
<Tag color="blue">{mcpStatus.url}</Tag>
|
||||
) : (
|
||||
<Tag>{t("settings.mcp.noUrl")}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
<Form layout="horizontal" labelCol={{ span: 4 }} wrapperCol={{ span: 20 }}>
|
||||
<Form.Item label={t("settings.mcp.host")}>
|
||||
<Input
|
||||
value={mcpConfig.host}
|
||||
onChange={(e) => setMcpConfig((prev) => ({ ...prev, host: e.target.value }))}
|
||||
disabled={!canAdmin || mcpStatus.is_running}
|
||||
style={{ width: "280px" }}
|
||||
placeholder="127.0.0.1"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("settings.mcp.port")}>
|
||||
<Input
|
||||
value={String(mcpConfig.port)}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("common.operation")}>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={startMcpServer}
|
||||
loading={mcpLoading}
|
||||
disabled={!canAdmin || mcpStatus.is_running}
|
||||
>
|
||||
{t("settings.mcp.start")}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={stopMcpServer}
|
||||
loading={mcpLoading}
|
||||
disabled={!canAdmin || !mcpStatus.is_running}
|
||||
>
|
||||
{t("settings.mcp.stop")}
|
||||
</Button>
|
||||
<Button onClick={loadMcpStatus} disabled={mcpLoading}>
|
||||
{t("settings.mcp.refresh")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div style={{ color: "var(--ss-text-secondary)", marginTop: "-8px", fontSize: "12px" }}>
|
||||
{t("settings.mcp.hint")}
|
||||
</div>
|
||||
<Divider />
|
||||
<div style={{ fontSize: "16px", fontWeight: 600, marginBottom: "8px" }}>
|
||||
{t("settings.app.title")}
|
||||
</div>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "快速重启或退出应用。",
|
||||
|
||||
@@ -295,6 +295,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
|
||||
|
||||
Reference in New Issue
Block a user