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:
+23
@@ -18,6 +18,7 @@
|
|||||||
当前已实现 MCP 工具:
|
当前已实现 MCP 工具:
|
||||||
|
|
||||||
- `add_score`:给指定学生加分或扣分,并写入 `score_events` 记录。
|
- `add_score`:给指定学生加分或扣分,并写入 `score_events` 记录。
|
||||||
|
- `list_students`:获取学生列表(姓名、积分、奖励积分、标签)。
|
||||||
|
|
||||||
## 2. 启动与停止 MCP 服务
|
## 2. 启动与停止 MCP 服务
|
||||||
|
|
||||||
@@ -99,6 +100,28 @@ curl -X POST http://127.0.0.1:3901/mcp \
|
|||||||
- `delta`(integer,必填):分值变化,正数加分,负数扣分。
|
- `delta`(integer,必填):分值变化,正数加分,负数扣分。
|
||||||
- `reason_content`(string,可选):原因,默认 `MCP 加分`。
|
- `reason_content`(string,可选):原因,默认 `MCP 加分`。
|
||||||
|
|
||||||
|
### 3.4 tools/call(调用 list_students)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:3901/mcp \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 4,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "list_students",
|
||||||
|
"arguments": {
|
||||||
|
"limit": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`list_students` 参数:
|
||||||
|
|
||||||
|
- `limit`(integer,可选):最多返回多少条,默认返回全部学生。
|
||||||
|
|
||||||
## 4. 返回结果说明
|
## 4. 返回结果说明
|
||||||
|
|
||||||
调用 `add_score` 成功时:
|
调用 `add_score` 成功时:
|
||||||
|
|||||||
+299
-36
@@ -6,7 +6,10 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait};
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||||
|
TransactionTrait,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -92,6 +95,12 @@ struct AddScoreArgs {
|
|||||||
reason_content: Option<String>,
|
reason_content: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
struct ListStudentsArgs {
|
||||||
|
#[serde(default)]
|
||||||
|
limit: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct AddScoreResult {
|
struct AddScoreResult {
|
||||||
event_id: i32,
|
event_id: i32,
|
||||||
@@ -104,6 +113,21 @@ struct AddScoreResult {
|
|||||||
event_time: String,
|
event_time: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct StudentListItem {
|
||||||
|
id: i32,
|
||||||
|
name: String,
|
||||||
|
score: i32,
|
||||||
|
reward_points: i32,
|
||||||
|
tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ListStudentsResult {
|
||||||
|
total: usize,
|
||||||
|
students: Vec<StudentListItem>,
|
||||||
|
}
|
||||||
|
|
||||||
static MCP_SERVER_STATE: once_cell::sync::Lazy<Arc<Mutex<McpServerState>>> =
|
static MCP_SERVER_STATE: once_cell::sync::Lazy<Arc<Mutex<McpServerState>>> =
|
||||||
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(McpServerState::default())));
|
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(McpServerState::default())));
|
||||||
|
|
||||||
@@ -116,6 +140,20 @@ fn check_admin_permission(state: &Arc<RwLock<AppState>>) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mcp_log_info(app_state: &Arc<RwLock<AppState>>, message: &str, meta: Value) {
|
||||||
|
println!("[MCP][INFO] {} | {}", message, meta);
|
||||||
|
let state_guard = app_state.read();
|
||||||
|
let logger = state_guard.logger.read();
|
||||||
|
logger.info_with_meta(message, meta.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mcp_log_error(app_state: &Arc<RwLock<AppState>>, message: &str, meta: Value) {
|
||||||
|
eprintln!("[MCP][ERROR] {} | {}", message, meta);
|
||||||
|
let state_guard = app_state.read();
|
||||||
|
let logger = state_guard.logger.read();
|
||||||
|
logger.error_with_meta(message, meta.clone());
|
||||||
|
}
|
||||||
|
|
||||||
fn jsonrpc_ok(id: Value, result: Value) -> Value {
|
fn jsonrpc_ok(id: Value, result: Value) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -165,6 +203,16 @@ fn tools_list_result() -> Value {
|
|||||||
},
|
},
|
||||||
"required": ["student_name", "delta"]
|
"required": ["student_name", "delta"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_students",
|
||||||
|
"description": "获取学生列表,包含姓名、积分、奖励积分和标签。",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"limit": {"type": "integer", "description": "最多返回多少条,默认全部"}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -177,11 +225,25 @@ async fn handle_mcp(
|
|||||||
let req = match serde_json::from_value::<McpRequest>(payload) {
|
let req = match serde_json::from_value::<McpRequest>(payload) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
mcp_log_error(
|
||||||
|
&app_state,
|
||||||
|
"mcp:request_parse_failed",
|
||||||
|
json!({ "error": e.to_string() }),
|
||||||
|
);
|
||||||
let body = jsonrpc_error(Value::Null, -32700, &format!("Parse error: {}", e));
|
let body = jsonrpc_error(Value::Null, -32700, &format!("Parse error: {}", e));
|
||||||
return (StatusCode::OK, Json(body)).into_response();
|
return (StatusCode::OK, Json(body)).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mcp_log_info(
|
||||||
|
&app_state,
|
||||||
|
"mcp:request_received",
|
||||||
|
json!({
|
||||||
|
"method": req.method.clone(),
|
||||||
|
"has_id": req.id.is_some()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let Some(id) = req.id.clone() else {
|
let Some(id) = req.id.clone() else {
|
||||||
return StatusCode::NO_CONTENT.into_response();
|
return StatusCode::NO_CONTENT.into_response();
|
||||||
};
|
};
|
||||||
@@ -195,42 +257,130 @@ async fn handle_mcp(
|
|||||||
.and_then(|p| serde_json::from_value::<ToolCallParams>(p).ok());
|
.and_then(|p| serde_json::from_value::<ToolCallParams>(p).ok());
|
||||||
|
|
||||||
match params {
|
match params {
|
||||||
None => jsonrpc_error(id, -32602, "Invalid tools/call params"),
|
None => {
|
||||||
Some(params) if params.name != "add_score" => {
|
mcp_log_error(
|
||||||
jsonrpc_error(id, -32601, &format!("Unknown tool: {}", params.name))
|
&app_state,
|
||||||
|
"mcp:tools_call_invalid_params",
|
||||||
|
json!({ "reason": "invalid_tools_call_params" }),
|
||||||
|
);
|
||||||
|
jsonrpc_error(id, -32602, "Invalid tools/call params")
|
||||||
}
|
}
|
||||||
Some(params) => match serde_json::from_value::<AddScoreArgs>(params.arguments) {
|
Some(params) => match params.name.as_str() {
|
||||||
Ok(args) => match mcp_add_score(&app_state, args).await {
|
"add_score" => match serde_json::from_value::<AddScoreArgs>(params.arguments) {
|
||||||
Ok(payload) => jsonrpc_ok(
|
Ok(args) => match mcp_add_score(&app_state, args).await {
|
||||||
id,
|
Ok(payload) => jsonrpc_ok(
|
||||||
json!({
|
id,
|
||||||
"content": [
|
json!({
|
||||||
{
|
"content": [
|
||||||
"type": "text",
|
{
|
||||||
"text": format!(
|
"type": "text",
|
||||||
"已记录:{} {:+} 分({} -> {})",
|
"text": format!(
|
||||||
payload.student_name, payload.delta, payload.val_prev, payload.val_curr
|
"已记录:{} {:+} 分({} -> {})",
|
||||||
)
|
payload.student_name, payload.delta, payload.val_prev, payload.val_curr
|
||||||
}
|
)
|
||||||
],
|
}
|
||||||
"isError": false,
|
],
|
||||||
"structuredContent": payload
|
"isError": false,
|
||||||
}),
|
"structuredContent": payload
|
||||||
),
|
}),
|
||||||
Err(e) => jsonrpc_ok(
|
),
|
||||||
id,
|
Err(e) => {
|
||||||
json!({
|
mcp_log_error(
|
||||||
"content": [
|
&app_state,
|
||||||
{
|
"mcp:tool_call_failed",
|
||||||
"type": "text",
|
json!({
|
||||||
"text": format!("加分失败: {}", e)
|
"tool": "add_score",
|
||||||
}
|
"error": e
|
||||||
],
|
}),
|
||||||
"isError": true
|
);
|
||||||
}),
|
jsonrpc_ok(
|
||||||
),
|
id,
|
||||||
|
json!({
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": format!("加分失败: {}", e)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isError": true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
mcp_log_error(
|
||||||
|
&app_state,
|
||||||
|
"mcp:tool_call_invalid_arguments",
|
||||||
|
json!({
|
||||||
|
"tool": "add_score",
|
||||||
|
"error": e.to_string()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
jsonrpc_error(id, -32602, &format!("Invalid arguments: {}", e))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Err(e) => jsonrpc_error(id, -32602, &format!("Invalid arguments: {}", e)),
|
"list_students" => {
|
||||||
|
match serde_json::from_value::<ListStudentsArgs>(params.arguments) {
|
||||||
|
Ok(args) => match mcp_list_students(&app_state, args).await {
|
||||||
|
Ok(payload) => jsonrpc_ok(
|
||||||
|
id,
|
||||||
|
json!({
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": format!("已获取 {} 名学生", payload.total)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isError": false,
|
||||||
|
"structuredContent": payload
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
mcp_log_error(
|
||||||
|
&app_state,
|
||||||
|
"mcp:tool_call_failed",
|
||||||
|
json!({
|
||||||
|
"tool": "list_students",
|
||||||
|
"error": e
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
jsonrpc_ok(
|
||||||
|
id,
|
||||||
|
json!({
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": format!("获取学生列表失败: {}", e)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isError": true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
mcp_log_error(
|
||||||
|
&app_state,
|
||||||
|
"mcp:tool_call_invalid_arguments",
|
||||||
|
json!({
|
||||||
|
"tool": "list_students",
|
||||||
|
"error": e.to_string()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
jsonrpc_error(id, -32602, &format!("Invalid arguments: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
mcp_log_error(
|
||||||
|
&app_state,
|
||||||
|
"mcp:tool_call_unknown_tool",
|
||||||
|
json!({
|
||||||
|
"tool": params.name
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
jsonrpc_error(id, -32601, &format!("Unknown tool: {}", params.name))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,6 +395,16 @@ async fn mcp_add_score(
|
|||||||
app_state: &Arc<RwLock<AppState>>,
|
app_state: &Arc<RwLock<AppState>>,
|
||||||
args: AddScoreArgs,
|
args: AddScoreArgs,
|
||||||
) -> Result<AddScoreResult, String> {
|
) -> Result<AddScoreResult, String> {
|
||||||
|
mcp_log_info(
|
||||||
|
app_state,
|
||||||
|
"mcp:tool_call_started",
|
||||||
|
json!({
|
||||||
|
"tool": "add_score",
|
||||||
|
"student_name": args.student_name.clone(),
|
||||||
|
"delta": args.delta
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let student_name = args.student_name.trim();
|
let student_name = args.student_name.trim();
|
||||||
if student_name.is_empty() {
|
if student_name.is_empty() {
|
||||||
return Err("student_name 不能为空".to_string());
|
return Err("student_name 不能为空".to_string());
|
||||||
@@ -308,6 +468,18 @@ async fn mcp_add_score(
|
|||||||
|
|
||||||
realtime_dual_write_sync(app_state).await?;
|
realtime_dual_write_sync(app_state).await?;
|
||||||
|
|
||||||
|
mcp_log_info(
|
||||||
|
app_state,
|
||||||
|
"mcp:tool_call_succeeded",
|
||||||
|
json!({
|
||||||
|
"tool": "add_score",
|
||||||
|
"student_name": student_name,
|
||||||
|
"delta": args.delta,
|
||||||
|
"event_id": inserted.id,
|
||||||
|
"event_uuid": event_uuid
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(AddScoreResult {
|
Ok(AddScoreResult {
|
||||||
event_id: inserted.id,
|
event_id: inserted.id,
|
||||||
event_uuid,
|
event_uuid,
|
||||||
@@ -320,15 +492,84 @@ async fn mcp_add_score(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mcp_list_students(
|
||||||
|
app_state: &Arc<RwLock<AppState>>,
|
||||||
|
args: ListStudentsArgs,
|
||||||
|
) -> Result<ListStudentsResult, String> {
|
||||||
|
mcp_log_info(
|
||||||
|
app_state,
|
||||||
|
"mcp:tool_call_started",
|
||||||
|
json!({
|
||||||
|
"tool": "list_students",
|
||||||
|
"limit": args.limit
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 mut query = students::Entity::find().order_by_asc(students::Column::Name);
|
||||||
|
if let Some(limit) = args.limit {
|
||||||
|
// 防止极大值在 PostgreSQL 绑定参数时溢出导致 panic。
|
||||||
|
let safe_limit = limit.min(i64::MAX as u64);
|
||||||
|
query = query.limit(safe_limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = query.all(&db_conn).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let students = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| StudentListItem {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
score: row.score,
|
||||||
|
reward_points: row.reward_points,
|
||||||
|
tags: serde_json::from_str::<Vec<String>>(&row.tags).unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
mcp_log_info(
|
||||||
|
app_state,
|
||||||
|
"mcp:tool_call_succeeded",
|
||||||
|
json!({
|
||||||
|
"tool": "list_students",
|
||||||
|
"total": students.len()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ListStudentsResult {
|
||||||
|
total: students.len(),
|
||||||
|
students,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn mcp_server_start(
|
pub async fn mcp_server_start(
|
||||||
config: Option<McpServerConfig>,
|
config: Option<McpServerConfig>,
|
||||||
state: State<'_, Arc<RwLock<AppState>>>,
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
) -> Result<IpcResponse<McpServerStartResult>, String> {
|
) -> Result<IpcResponse<McpServerStartResult>, String> {
|
||||||
check_admin_permission(&state)?;
|
check_admin_permission(&state)?;
|
||||||
|
mcp_log_info(
|
||||||
|
state.inner(),
|
||||||
|
"mcp:server_start_requested",
|
||||||
|
json!({
|
||||||
|
"config": config.clone()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let mut server_state = MCP_SERVER_STATE.lock().await;
|
let mut server_state = MCP_SERVER_STATE.lock().await;
|
||||||
if server_state.is_running {
|
if server_state.is_running {
|
||||||
|
mcp_log_info(
|
||||||
|
state.inner(),
|
||||||
|
"mcp:server_already_running",
|
||||||
|
json!({
|
||||||
|
"url": server_state.url.clone()
|
||||||
|
}),
|
||||||
|
);
|
||||||
return Ok(IpcResponse::failure_with_type(
|
return Ok(IpcResponse::failure_with_type(
|
||||||
"MCP server is already running",
|
"MCP server is already running",
|
||||||
));
|
));
|
||||||
@@ -377,6 +618,16 @@ pub async fn mcp_server_start(
|
|||||||
server_state.url = Some(url.clone());
|
server_state.url = Some(url.clone());
|
||||||
server_state.shutdown_tx = Some(shutdown_tx);
|
server_state.shutdown_tx = Some(shutdown_tx);
|
||||||
|
|
||||||
|
mcp_log_info(
|
||||||
|
state.inner(),
|
||||||
|
"mcp:server_started",
|
||||||
|
json!({
|
||||||
|
"url": url.clone(),
|
||||||
|
"host": server_state.config.host.clone(),
|
||||||
|
"port": server_state.config.port
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(IpcResponse::success(McpServerStartResult {
|
Ok(IpcResponse::success(McpServerStartResult {
|
||||||
url,
|
url,
|
||||||
config: server_state.config.clone(),
|
config: server_state.config.clone(),
|
||||||
@@ -388,10 +639,12 @@ pub async fn mcp_server_stop(
|
|||||||
state: State<'_, Arc<RwLock<AppState>>>,
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
) -> Result<IpcResponse<()>, String> {
|
) -> Result<IpcResponse<()>, String> {
|
||||||
check_admin_permission(&state)?;
|
check_admin_permission(&state)?;
|
||||||
|
mcp_log_info(state.inner(), "mcp:server_stop_requested", json!({}));
|
||||||
|
|
||||||
let mut server_state = MCP_SERVER_STATE.lock().await;
|
let mut server_state = MCP_SERVER_STATE.lock().await;
|
||||||
|
|
||||||
if !server_state.is_running {
|
if !server_state.is_running {
|
||||||
|
mcp_log_info(state.inner(), "mcp:server_already_stopped", json!({}));
|
||||||
return Ok(IpcResponse::success_empty());
|
return Ok(IpcResponse::success_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,14 +655,24 @@ pub async fn mcp_server_stop(
|
|||||||
server_state.is_running = false;
|
server_state.is_running = false;
|
||||||
server_state.url = None;
|
server_state.url = None;
|
||||||
|
|
||||||
|
mcp_log_info(state.inner(), "mcp:server_stopped", json!({}));
|
||||||
|
|
||||||
Ok(IpcResponse::success_empty())
|
Ok(IpcResponse::success_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn mcp_server_status(
|
pub async fn mcp_server_status(
|
||||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
) -> Result<IpcResponse<McpServerStatus>, String> {
|
) -> Result<IpcResponse<McpServerStatus>, String> {
|
||||||
let server_state = MCP_SERVER_STATE.lock().await;
|
let server_state = MCP_SERVER_STATE.lock().await;
|
||||||
|
mcp_log_info(
|
||||||
|
state.inner(),
|
||||||
|
"mcp:server_status_requested",
|
||||||
|
json!({
|
||||||
|
"is_running": server_state.is_running,
|
||||||
|
"url": server_state.url.clone()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(IpcResponse::success(McpServerStatus {
|
Ok(IpcResponse::success(McpServerStatus {
|
||||||
is_running: server_state.is_running,
|
is_running: server_state.is_running,
|
||||||
|
|||||||
-88
@@ -63,7 +63,6 @@ function MainContent(): React.JSX.Element {
|
|||||||
const syncCheckingRef = useRef(false)
|
const syncCheckingRef = useRef(false)
|
||||||
const syncApplyLoadingRef = useRef(false)
|
const syncApplyLoadingRef = useRef(false)
|
||||||
const lastLocalMutationAtRef = useRef(0)
|
const lastLocalMutationAtRef = useRef(0)
|
||||||
const lastLandscapeSizeRef = useRef<{ width: number; height: number } | null>(null)
|
|
||||||
|
|
||||||
const activeMenu = useMemo(() => {
|
const activeMenu = useMemo(() => {
|
||||||
const p = location.pathname
|
const p = location.pathname
|
||||||
@@ -285,93 +284,6 @@ function MainContent(): React.JSX.Element {
|
|||||||
if (key === "settings") navigate("/settings")
|
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 = () => {
|
const toggleSidebar = () => {
|
||||||
if (isPortraitMode && sidebarCollapsed) {
|
if (isPortraitMode && sidebarCollapsed) {
|
||||||
setFloatingSidebarExpanded((prev) => !prev)
|
setFloatingSidebarExpanded((prev) => !prev)
|
||||||
|
|||||||
Reference in New Issue
Block a user