diff --git a/Claw/.trae/documents/fix_date_response_plan.md b/Claw/.trae/documents/fix_date_response_plan.md deleted file mode 100644 index 442bbd1..0000000 --- a/Claw/.trae/documents/fix_date_response_plan.md +++ /dev/null @@ -1,61 +0,0 @@ -# SmartClaw 日期回复问题修复计划 - -## [/] 任务 1: 分析日期回复不正确的原因 -- **优先级**: P0 -- **依赖**: 无 -- **描述**: - - 分析为什么SmartClaw回复的日期不正确(显示2024年10月30日,而实际是2026年3月23日) - - 检查LMStudio API调用的参数和系统提示 -- **成功标准**: - - 确定日期回复不正确的根本原因 -- **测试要求**: - - `programmatic` TR-1.1: 检查LMStudio API调用的请求参数 - - `human-judgement` TR-1.2: 分析系统提示是否包含日期信息 - -## [ ] 任务 2: 修改system prompt,添加日期信息 -- **优先级**: P0 -- **依赖**: 任务 1 -- **描述**: - - 修改system prompt,在其中添加当前日期信息 - - 确保每次调用时都使用最新的日期 -- **成功标准**: - - system prompt中包含当前日期信息 -- **测试要求**: - - `programmatic` TR-2.1: 检查system prompt是否包含当前日期 - - `human-judgement` TR-2.2: 验证日期格式是否正确 - -## [ ] 任务 3: 修改LMStudio API调用,传递日期信息 -- **优先级**: P0 -- **依赖**: 任务 1 -- **描述**: - - 在调用LMStudio API时,在input参数中添加当前日期信息 - - 确保模型能够获取到最新的日期 -- **成功标准**: - - LMStudio API调用中包含当前日期信息 -- **测试要求**: - - `programmatic` TR-3.1: 检查API请求参数是否包含日期信息 - - `human-judgement` TR-3.2: 验证日期信息是否正确传递 - -## [ ] 任务 4: 测试日期回复功能 -- **优先级**: P0 -- **依赖**: 任务 2, 任务 3 -- **描述**: - - 测试SmartClaw对日期相关问题的回复 - - 验证回复的日期是否正确 -- **成功标准**: - - SmartClaw能够正确回复当前日期 -- **测试要求**: - - `programmatic` TR-4.1: 发送"今天是几月几日?"的问题,检查回复是否包含正确的日期 - - `human-judgement` TR-4.2: 验证回复的日期格式是否合理 - -## [ ] 任务 5: 优化日期处理逻辑 -- **优先级**: P1 -- **依赖**: 任务 4 -- **描述**: - - 优化日期处理逻辑,确保在不同场景下都能正确处理日期相关问题 - - 考虑时区和格式问题 -- **成功标准**: - - 日期处理逻辑能够适应不同的日期相关问题 -- **测试要求**: - - `programmatic` TR-5.1: 测试不同的日期相关问题,如"明天是几号?"、"昨天是几号?" - - `human-judgement` TR-5.2: 验证回复是否合理且准确 diff --git a/Claw/Server/SmartClaw/build.bat b/Claw/Server/SmartClaw/build.bat new file mode 100644 index 0000000..de8cad7 --- /dev/null +++ b/Claw/Server/SmartClaw/build.bat @@ -0,0 +1,2 @@ +set RUSTFLAGS=-C target-feature=+crt-static +cargo build --release \ No newline at end of file diff --git a/Claw/Server/SmartClaw/src/websocket_client.rs b/Claw/Server/SmartClaw/src/websocket_client.rs index 89f8b5c..aa91a85 100644 --- a/Claw/Server/SmartClaw/src/websocket_client.rs +++ b/Claw/Server/SmartClaw/src/websocket_client.rs @@ -18,9 +18,7 @@ fn get_current_time() -> String { /// 带时间前缀的打印宏 macro_rules! log { - ($($arg:tt)*) => { - println!("[{}] {}", get_current_time(), format!($($arg)*)); - }; + ($($arg:tt)*) => { println!("[{}] {}", get_current_time(), format!($($arg)*)) } } /// WebSocket 客户端连接管理器 @@ -302,14 +300,52 @@ impl WebSocketClient { } } } - Some("wechat_message") => { - // 处理企业微信消息 + Some("miniprogram_message") => { if debug { - log!("📱 收到企业微信消息"); + log!("收到小程序消息: {:?}", message); + } + let from_user_name = message.get("data").and_then(|d| d.get("from_user_name").and_then(|v| v.as_str())).unwrap_or("miniprogram_user"); + let content = message.get("data").and_then(|d| d.get("content").and_then(|v| v.as_str())).unwrap_or(""); + let conn_id = message.get("data").and_then(|d| d.get("conn_id").and_then(|v| v.as_str())).unwrap_or(""); + + if !content.is_empty() { + if debug { log!("处理小程序消息: {}", content); } + + let ai_response = match self.call_lmstudio(content).await { + Ok(r) => r, + Err(e) => { + if debug { log!("LMStudio error: {}", e); } + format!("处理消息时出错: {}", e) + } + }; + + if debug { log!("AI回复: {}", ai_response); } + + let reply_message = serde_json::json!({ + "type": "miniprogram_message_response", + "data": { + "conn_id": conn_id, + "from_user_name": from_user_name, + "content": ai_response, + "msg_type": "text", + "timestamp": chrono::Utc::now().timestamp() + } + }); + + if let Some(sender) = &*self.sender.lock().await { + if let Err(e) = sender.send(reply_message.to_string()).await { + if debug { log!("发送回复失败: {}", e); } + } else { + if debug { log!("AI回复已发送"); } + } + } + } + } + Some("wechat_message") => { + if debug { + log!("收到企业微信消息"); log!(" 消息内容: {:?}", message); } - - // 提取消息数据 let from_user_name = message.get("data").and_then(|d| d.get("from_user_name").and_then(|v| v.as_str())).unwrap_or(""); let content = message.get("data").and_then(|d| d.get("content").and_then(|v| v.as_str())).unwrap_or(""); @@ -384,6 +420,25 @@ impl WebSocketClient { log!("✅ 收到确认消息"); } } + Some("wechat_app_sse_request") => { + if debug { + log!("📡 收到 WechatApp SSE 请求"); + } + let request_id = message.get("request_id").and_then(|v| v.as_str()).unwrap_or(""); + let data = message.get("data"); + + if !request_id.is_empty() && data.is_some() { + if debug { + log!("📡 处理 SSE 请求,request_id: {}", request_id); + } + let client_clone = self.clone(); + let request_id_clone = request_id.to_string(); + let data_clone = data.cloned(); + tokio::spawn(async move { + client_clone.handle_sse_request(request_id_clone, data_clone).await; + }); + } + } Some(msg_type) => { if debug { log!("❓ 收到未知消息类型: {}", msg_type); @@ -524,7 +579,7 @@ impl WebSocketClient { // 构建LMStudio API请求 let payload = json!({ - "model": "qwen-3-panda-agi-v2", // 使用当前可用模型 + "model": "qwen2.5-vl-7b-instruct", // 使用当前可用模型,qwen-3-panda-agi-v2 "input": message, "system_prompt": system_prompt, "temperature": 0.7, @@ -595,6 +650,105 @@ impl WebSocketClient { Ok(ai_response) } + + /// 处理 WechatApp SSE 请求 + async fn handle_sse_request(&self, request_id: String, data: Option) { + if !self.lmstudio_enabled { + let error_msg = serde_json::json!({ + "type": "sse_error", + "request_id": request_id, + "error": "LMStudio is not enabled" + }); + self.send_message(error_msg.to_string()).await.ok(); + return; + } + + if self.debug { + log!("📡 开始处理 SSE 请求: {}", request_id); + } + + // 解析请求数据 + let chat_request = match data { + Some(d) => d, + None => { + let error_msg = serde_json::json!({ + "type": "sse_error", + "request_id": request_id, + "error": "Invalid request data" + }); + self.send_message(error_msg.to_string()).await.ok(); + return; + } + }; + + // 发送请求到 LMStudio SSE 端点 + let client = reqwest::Client::new(); + let lmstudio_url = format!("{}/v1/chat/completions", self.lmstudio_url); + + if self.debug { + log!("🌐 发送 SSE 请求到 LMStudio: {}", lmstudio_url); + log!("📝 请求数据: {}", chat_request); + } + + let response = match client.post(&lmstudio_url) + .header("Content-Type", "application/json") + .json(&chat_request) + .send() + .await { + Ok(resp) => resp, + Err(e) => { + if self.debug { + log!("❌ LMStudio SSE 请求失败: {}", e); + } + let error_msg = serde_json::json!({ + "type": "sse_error", + "request_id": request_id, + "error": format!("LMStudio request failed: {}", e) + }); + self.send_message(error_msg.to_string()).await.ok(); + return; + } + }; + + // 处理 SSE 流 + let body = response.bytes().await; + match body { + Ok(bytes) => { + // 将 SSE 片段发送回网关 + let sse_chunk = serde_json::json!({ + "type": "sse_chunk", + "request_id": request_id, + "chunk": String::from_utf8_lossy(&bytes) + }); + if self.debug { + log!("📡 发送 SSE 片段: {}", sse_chunk); + } + self.send_message(sse_chunk.to_string()).await.ok(); + } + Err(e) => { + if self.debug { + log!("❌ 读取 SSE 流失败: {}", e); + } + let error_msg = serde_json::json!({ + "type": "sse_error", + "request_id": request_id, + "error": format!("Failed to read SSE stream: {}", e) + }); + self.send_message(error_msg.to_string()).await.ok(); + return; + } + } + + // 发送 SSE 完成消息 + let done_msg = serde_json::json!({ + "type": "sse_done", + "request_id": request_id + }); + if self.debug { + log!("📡 发送 SSE 完成消息: {}", done_msg); + } + self.send_message(done_msg.to_string()).await.ok(); + } } /// WebSocket 客户端管理器 diff --git a/Claw/Server/gateway/Cargo.toml b/Claw/Server/gateway/Cargo.toml index 4dbf17d..fe0609a 100644 --- a/Claw/Server/gateway/Cargo.toml +++ b/Claw/Server/gateway/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] actix-web = "^4.0" tokio = { version = "^1.0", features = ["full"] } +tokio-stream = "^0.1" serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" reqwest = { version = "^0.11", features = ["json"] } diff --git a/Claw/Server/gateway/build.bat b/Claw/Server/gateway/build.bat new file mode 100644 index 0000000..de8cad7 --- /dev/null +++ b/Claw/Server/gateway/build.bat @@ -0,0 +1,2 @@ +set RUSTFLAGS=-C target-feature=+crt-static +cargo build --release \ No newline at end of file diff --git a/Claw/Server/gateway/config.json b/Claw/Server/gateway/config.json new file mode 100644 index 0000000..773e827 --- /dev/null +++ b/Claw/Server/gateway/config.json @@ -0,0 +1,17 @@ +{ + "wechat": { + "token": "mytoken123456", + "corp_id": "wwa7bb7aec981103b4", + "encoding_aes_key": "PXP7FjoinIPc9WscGymDlf1VwMyBLh1cKJJSJFx2SO8", + "corp_secret": "your_corp_secret_here" + }, + "wechat_mini_program": { + "app_id": "your_app_id", + "app_secret": "your_app_secret" + }, + "debug": { + "wechat": false, + "config": false, + "http": true + } +} diff --git a/Claw/Server/gateway/src/communication.rs b/Claw/Server/gateway/src/communication.rs index 9e30d4e..b8d268e 100644 --- a/Claw/Server/gateway/src/communication.rs +++ b/Claw/Server/gateway/src/communication.rs @@ -13,9 +13,7 @@ fn get_current_time() -> String { /// 带时间前缀的打印宏 macro_rules! log { - ($($arg:tt)*) => { - println!("[{}] {}", get_current_time(), format!($($arg)*)); - }; + ($($arg:tt)*) => { println!("[{}] {}", get_current_time(), format!($($arg)*)) } } /// 连接信息 @@ -169,6 +167,19 @@ impl WebSocketPool { } /// 广播消息到所有控制通道连接(SmartClaw) + + pub async fn send_to_session_by_id(&self, conn_id: &str, message: serde_json::Value) -> Result<(), String> { + let sessions = self.sessions.read().await; + let msg_str = message.to_string(); + if let Some(session) = sessions.get(conn_id) { + let mut s = session.write().await; + s.text(msg_str).await.map_err(|e| e.to_string()) + } else { + Err(format!("session not found: {}", conn_id)) + } + } + + pub async fn broadcast_to_control(&self, message: serde_json::Value) -> Result<(), String> { let manager = self.manager.read().await; let connections = manager.get_all_connections(); @@ -202,7 +213,8 @@ impl WebSocketPool { Ok(()) } - /// 广播消息到所有连接(兼容旧接口) + + pub async fn broadcast(&self, message: serde_json::Value) -> Result<(), String> { self.broadcast_to_control(message).await } diff --git a/Claw/Server/gateway/src/main.rs b/Claw/Server/gateway/src/main.rs index 37950bd..eafd64d 100644 --- a/Claw/Server/gateway/src/main.rs +++ b/Claw/Server/gateway/src/main.rs @@ -11,6 +11,16 @@ use shared::{TaskRequest, TaskResponse, HealthResponse, utils}; use sha1::{Sha1, Digest}; use futures::StreamExt; use crate::communication::ConnectionInfo; +use crate::sse_proxy::chat_completions; + +/// 微信小程序手机号解密请求结构体 +#[derive(Deserialize)] +struct PhoneDecryptRequest { + #[serde(rename = "encryptedData")] + _encrypted_data: String, + _iv: String, + _code: Option, +} /// 获取当前本地时间的格式化字符串 fn get_current_time() -> String { @@ -20,14 +30,15 @@ fn get_current_time() -> String { /// 带时间前缀的打印宏 macro_rules! log { - ($($arg:tt)*) => { - println!("[{}] {}", get_current_time(), format!($($arg)*)); - }; + ($($arg:tt)*) => { println!("[{}] {}", get_current_time(), format!($($arg)*)) }; } mod communication; +mod sse_manager; +mod sse_proxy; use communication::{ConnectionManager, WebSocketPool, CommunicationConfig, WebSocketClient}; +use sse_manager::SseManager; /// 企业微信消息发送结构体 #[derive(Serialize)] @@ -117,6 +128,8 @@ async fn send_wechat_message(touser: &str, content: &str, debug: bool) -> Result #[derive(Deserialize, Serialize, Debug)] struct WeChatConfig { wechat: WeChatSettings, + #[serde(default)] + wechat_app: WechatAppSettings, debug: DebugSettings, } @@ -135,6 +148,28 @@ struct DebugSettings { http: bool, } +#[derive(Deserialize, Serialize, Debug, Clone)] +struct WechatAppSettings { + #[serde(default = "WechatAppSettings::default_app_id")] + app_id: String, + #[serde(default = "WechatAppSettings::default_app_secret")] + app_secret: String, +} + +impl WechatAppSettings { + fn default_app_id() -> String { "wx07f7e566fb459333".to_string() } + fn default_app_secret() -> String { "506673cfc479c67d9b5588e9977f0baa".to_string() } +} + +impl Default for WechatAppSettings { + fn default() -> Self { + Self { + app_id: Self::default_app_id(), + app_secret: Self::default_app_secret(), + } + } +} + impl Default for WeChatConfig { fn default() -> Self { Self { @@ -144,6 +179,7 @@ impl Default for WeChatConfig { encoding_aes_key: "PXP7FjoinIPc9WscGymDlf1VwMyBLh1cKJJSJFx2SO8".to_string(), corp_secret: "your_corp_secret_here".to_string(), }, + wechat_app: WechatAppSettings::default(), debug: DebugSettings { wechat: false, config: false, @@ -181,6 +217,17 @@ fn get_wechat_config() -> (String, String, String, String, bool, bool, bool) { if config.debug.config { println!("✅ 配置文件读取成功"); } + + // 检查是否缺少 wechat_app 字段,缺少则补写 + let json_str = serde_json::to_string_pretty(&config).unwrap_or_default(); + if !json_str.contains("wechat_app") { + if let Ok(mut f) = std::fs::File::create(config_path) { + use std::io::Write; + let _ = f.write_all(json_str.as_bytes()); + println!("✅ 已补写 wechat_app 默认配置到 config.json"); + } + } + return (config.wechat.token, config.wechat.corp_id, config.wechat.encoding_aes_key, config.wechat.corp_secret, config.debug.wechat, config.debug.config, config.debug.http); } Err(e) => { @@ -229,6 +276,105 @@ fn get_wechat_config() -> (String, String, String, String, bool, bool, bool) { (default_config.wechat.token, default_config.wechat.corp_id, default_config.wechat.encoding_aes_key, default_config.wechat.corp_secret, default_config.debug.wechat, default_config.debug.config, default_config.debug.http) } +use std::collections::HashMap; +use std::sync::LazyLock; +use std::sync::RwLock as StdRwLock; + +/// 全局 session_key 缓存:openid -> session_key +static SESSION_KEY_CACHE: LazyLock>> = + LazyLock::new(|| StdRwLock::new(HashMap::new())); + +use aes::Aes128; +use cbc::cipher::{BlockDecryptMut, KeyIvInit}; +type Aes128CbcDec = cbc::Decryptor; + +/// 微信小程序手机号解密(AES-128-CBC + PKCS7) +fn decrypt_wechat_phone(session_key: &str, encrypted_data: &str, iv: &str) -> Result { + use base64::{engine::general_purpose::STANDARD as B64, Engine}; + + let key = B64.decode(session_key).map_err(|e| format!("session_key decode: {}", e))?; + let mut data = B64.decode(encrypted_data).map_err(|e| format!("encryptedData decode: {}", e))?; + let iv_bytes = B64.decode(iv).map_err(|e| format!("iv decode: {}", e))?; + + let cipher = Aes128CbcDec::new_from_slices(&key, &iv_bytes) + .map_err(|e| format!("AES init: {}", e))?; + cipher.decrypt_padded_mut::(&mut data) + .map_err(|e| format!("AES decrypt/pad: {}", e))?; + + let json_str = String::from_utf8(data).map_err(|e| format!("utf8: {}", e))?; + let obj: serde_json::Value = serde_json::from_str(&json_str) + .map_err(|e| format!("json parse: {}", e))?; + + obj.get("phoneNumber") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| format!("phoneNumber not found in: {}", json_str)) +} + +/// 通过微信 code 换 openid 和 session_key +async fn wechat_code_to_openid(code: &str) -> Result<(String, String), String> { + let (app_id, app_secret) = get_wechat_app_config(); + let url = format!( + "https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code", + app_id, app_secret, code + ); + let client = reqwest::Client::new(); + #[derive(Deserialize)] + struct Resp { openid: Option, session_key: Option, errmsg: Option } + let resp: Resp = client.get(&url).send().await + .map_err(|e| e.to_string())? + .json().await + .map_err(|e| e.to_string())?; + match (resp.openid, resp.session_key) { + (Some(openid), Some(sk)) => Ok((openid, sk)), + _ => Err(resp.errmsg.unwrap_or_else(|| "no openid/session_key".into())), + } +} + +/// 微信小程序配置(从config.json读取,缺少则补写默认值) +fn get_wechat_app_config() -> (String, String) { + use std::fs::File; + use std::io::{Read, Write}; + use std::path::Path; + + let config_path = Path::new("./config.json"); + + if config_path.exists() { + if let Ok(mut file) = File::open(config_path) { + let mut contents = String::new(); + if file.read_to_string(&mut contents).is_ok() { + // 用完整 WeChatConfig 解析,serde 会用 #[serde(default)] 填充缺少的 wechat_app + if let Ok(config) = serde_json::from_str::(&contents) { + let needs_write = !contents.contains("wechat_app"); + + if needs_write { + if let Ok(updated) = serde_json::to_string_pretty(&config) { + if let Ok(mut f) = File::create(config_path) { + let _ = f.write_all(updated.as_bytes()); + println!("✅ 已补写 wechat_app 默认配置到 config.json"); + } + } + } + + return (config.wechat_app.app_id, config.wechat_app.app_secret); + } + + // 回退:直接解析 JSON 取 wechat_app 字段 + if let Ok(val) = serde_json::from_str::(&contents) { + if let Some(wa) = val.get("wechat_app") { + let app_id = wa.get("app_id").and_then(|v| v.as_str()).unwrap_or("wx07f7e566fb459333").to_string(); + let app_secret = wa.get("app_secret").and_then(|v| v.as_str()).unwrap_or("506673cfc479c67d9b5588e9977f0baa").to_string(); + return (app_id, app_secret); + } + } + } + } + } + + let s = WechatAppSettings::default(); + (s.app_id, s.app_secret) +} + /// 解析企业微信XML消息 fn parse_wechat_xml_message(xml_content: &str, debug: bool) -> (Option, Option, Option, Option) { if debug { @@ -417,6 +563,7 @@ struct TaskService { connection_manager: Arc>, websocket_pool: WebSocketPool, communication_config: CommunicationConfig, + sse_manager: Arc, } impl TaskService { @@ -425,15 +572,18 @@ impl TaskService { let connection_manager = Arc::new(RwLock::new(ConnectionManager::new())); let websocket_pool = WebSocketPool::new(connection_manager.clone()); let communication_config = CommunicationConfig::default(); + let sse_manager = Arc::new(SseManager::new()); log!("🚀 初始化任务处理服务"); log!("📋 WebSocket连接池已创建"); log!("⚙️ 通信配置已加载: {:?}", communication_config.websocket_url); + log!("🔄 SSE Manager 已初始化"); Self { connection_manager, websocket_pool, communication_config, + sse_manager, } } @@ -648,7 +798,7 @@ impl TaskService { } /// 验证微信小程序签名 - fn validate_miniprogram_signature(signature: &str, data: &str, session_key: &str) -> bool { + fn validate_wechat_app_signature(signature: &str, data: &str, session_key: &str) -> bool { log!("🔐 验证微信小程序签名:"); log!(" signature: {}", signature); log!(" data: {}", data); @@ -704,14 +854,18 @@ async fn websocket_handler( // 获取请求路径,区分连接类型 let path = req.path(); let is_control_connection = path == "/api/v1/ws/control"; + let is_miniprogram = path == "/api/v1/ws/miniprogram"; if is_control_connection { log!("🎯 检测到SmartClaw服务连接 (控制通道)"); + } else if is_miniprogram { + log!("📨 检测到小程序连接"); } else { log!("📱 检测到设备连接 (任务通道)"); } log!("🔗 开始WebSocket握手..."); + // 使用actix-ws处理WebSocket连接 let (response, mut session, msg_stream) = match actix_ws::handle(&req, body) { Ok(result) => { @@ -726,6 +880,7 @@ async fn websocket_handler( // 生成连接ID let connection_id = uuid::Uuid::new_v4().to_string(); + // 添加连接到连接管理器 {{ @@ -734,7 +889,7 @@ async fn websocket_handler( id: connection_id.clone(), connected_at: Instant::now(), last_heartbeat: Instant::now(), - client_info: Some(if is_control_connection { "SmartClaw" } else { "Device" }.to_string()), + client_info: Some(if is_control_connection { "SmartClaw" } else if is_miniprogram { "wechat_app" } else { "Device" }.to_string()), }; manager.add_connection(connection_info); // 保存会话到WebSocketPool @@ -754,6 +909,110 @@ async fn websocket_handler( // 处理消息 if let Ok(parsed) = serde_json::from_str::(&text) { match parsed.get("type").and_then(|t| t.as_str()) { + Some("miniprogram_message") => { + let conn_id_owned = connection_id.clone(); + + // 收到小程序消息:记录conn_id,广播给SmartClaw + let data = parsed.get("data"); + let content = data.and_then(|d| d.get("content").and_then(|v| v.as_str())).unwrap_or(""); + log!("收到小程序消息: {}", content); + + // 将发送者conn_id存入消息,让SmartClaw知道回传给谁 + let mut forward_msg = parsed.clone(); + if let Some(d) = forward_msg.get_mut("data").and_then(|d| d.as_object_mut()) { + d.insert("conn_id".to_string(), serde_json::json!(conn_id_owned)); + } + + match app_data.websocket_pool.broadcast_to_control(forward_msg).await { + Ok(_) => log!("小程序消息已广播,conn_id={}", conn_id_owned), + Err(e) => log!("广播失败: {}", e), + } + } + Some("miniprogram_message_response") => { + // 收到SmartClaw的AI响应,提取conn_id直接发送 + let target_conn_id = parsed.get("data") + .and_then(|d| d.get("conn_id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !target_conn_id.is_empty() { + match app_data.websocket_pool.send_to_session_by_id(target_conn_id, parsed.clone()).await { + Ok(_) => log!("AI回复已发送小程序 {}", target_conn_id), + Err(e) => log!("发送AI回复失败: {}", e), + } + } else { + log!("miniprogram_message_response missing conn_id"); + } + } + Some("wechat_login") => { + let code = parsed.get("data") + .and_then(|d| d.get("code")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let Some(code) = code else { + log!("wechat_login missing code"); + return; + }; + + let conn_id_owned = connection_id.clone(); + let app_data_clone = app_data.clone(); + + tokio::spawn(async move { + match wechat_code_to_openid(&code).await { + Ok((openid, session_key)) => { + SESSION_KEY_CACHE.write().ok() + .map(|mut c| { c.insert(openid.clone(), session_key); }); + let resp = serde_json::json!({ + "type": "wechat_login_ret", + "data": { "openid": openid } + }); + if let Err(e) = app_data_clone.websocket_pool.send_to_session_by_id(&conn_id_owned, resp).await { + log!("wechat_login_ret 发送失败: {}", e); + } else { + log!("✅ wechat_login 成功 openid={}", openid); + } + } + Err(e) => { + let resp = serde_json::json!({ + "type": "wechat_login_ret", + "data": { "error": e } + }); + let _ = app_data_clone.websocket_pool.send_to_session_by_id(&conn_id_owned, resp).await; + } + } + }); + } + Some("wechat_decrypt_phone") => { + let openid = parsed.get("data").and_then(|d| d.get("openid")).and_then(|v| v.as_str()).map(|s| s.to_string()); + let encrypted_data = parsed.get("data").and_then(|d| d.get("encryptedData")).and_then(|v| v.as_str()).map(|s| s.to_string()); + let iv = parsed.get("data").and_then(|d| d.get("iv")).and_then(|v| v.as_str()).map(|s| s.to_string()); + + let conn_id_owned = connection_id.clone(); + let app_data_owned = app_data.clone(); + + let (Some(openid), Some(encrypted_data), Some(iv)) = (openid, encrypted_data, iv) else { + log!("wechat_decrypt_phone 参数缺失"); + return; + }; + + tokio::spawn(async move { + let session_key = SESSION_KEY_CACHE.read().ok() + .and_then(|c| c.get(&openid).cloned()); + + let resp = match session_key { + Some(sk) => match decrypt_wechat_phone(&sk, &encrypted_data, &iv) { + Ok(phone) => serde_json::json!({"type": "wechat_decrypt_phone_ret", "data": {"phone": phone}}), + Err(e) => serde_json::json!({"type": "wechat_decrypt_phone_ret", "data": {"error": e}}), + }, + None => serde_json::json!({"type": "wechat_decrypt_phone_ret", "data": {"error": "session_key not found, please re-login"}}), + }; + + match app_data_owned.websocket_pool.send_to_session_by_id(&conn_id_owned, resp).await { + Ok(_) => log!("手机号解密响应已发送 conn_id={}", conn_id_owned), + Err(e) => log!("手机号解密响应发送失败: {}", e), + } + }); + } Some("wechat_message_response") => { // 处理SmartClaw的微信消息回复 log!("📱 收到SmartClaw的微信消息回复"); @@ -784,6 +1043,47 @@ async fn websocket_handler( let mut manager = app_data.connection_manager.write().await; manager.update_heartbeat(&connection_id); } + Some("sse_chunk") => { + // 处理 SSE 片段消息 + let request_id = parsed + .get("request_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let chunk = parsed + .get("chunk") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(tx) = app_data.sse_manager.get(request_id).await { + let _ = tx.send(chunk.as_bytes().to_vec()).await; + } + } + Some("sse_done") => { + // 处理 SSE 完成消息 + let request_id = parsed + .get("request_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + app_data.sse_manager.remove(request_id).await; + } + Some("sse_error") => { + // 处理 SSE 错误消息 + let request_id = parsed + .get("request_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let error_msg = parsed.get("error") + .and_then(|v| v.as_str()) + .unwrap_or("未知错误"); + + if let Some(tx) = app_data.sse_manager.get(request_id).await { + let _ = tx.send(format!("data: {{\"error\": \"{}\"}}\n\n", error_msg).into_bytes()).await; + app_data.sse_manager.remove(request_id).await; + } + } Some("connect") => { // 处理连接消息 log!("🔗 收到连接消息"); @@ -1157,7 +1457,7 @@ async fn handle_wechat_callback(req: HttpRequest, body: web::Bytes, app_data: we } /// 微信小程序回调处理器 -async fn handle_wechat_miniprogram_callback(req: HttpRequest, body: web::Bytes) -> impl Responder { +async fn handle_wechat_app_callback(req: HttpRequest, body: web::Bytes) -> impl Responder { println!("📱 收到微信小程序回调"); // 获取查询参数 @@ -1166,13 +1466,13 @@ async fn handle_wechat_miniprogram_callback(req: HttpRequest, body: web::Bytes) // 解析查询参数 #[derive(Deserialize)] - struct MiniProgramQuery { + struct MiniAppQuery { signature: String, openid: Option, session_key: Option, } - let query: MiniProgramQuery = match web::Query::::from_query(query_string) { + let query: MiniAppQuery = match web::Query::::from_query(query_string) { Ok(q) => q.into_inner(), Err(e) => { println!("❌ 解析查询参数失败: {}", e); @@ -1196,7 +1496,7 @@ async fn handle_wechat_miniprogram_callback(req: HttpRequest, body: web::Bytes) } // 验证签名 - let is_valid = TaskService::validate_miniprogram_signature( + let is_valid = TaskService::validate_wechat_app_signature( &query.signature, &body_str, &session_key @@ -1432,6 +1732,26 @@ struct TaskListQuery { per_page: Option, } +/// 微信小程序手机号解密处理器 +async fn decrypt_phone_number(req: web::Json) -> impl Responder { + log!("📱 收到微信小程序手机号解密请求"); + + let _phone_request = req.into_inner(); + + // 这里应该调用微信官方接口获取session_key,然后解密手机号 + // 由于是模拟环境,我们使用假数据 + // 实际项目中应该: + // 1. 使用code调用微信接口获取session_key + // 2. 使用session_key、iv和encryptedData解密手机号 + + // 模拟解密结果 + let phone_number = "138****8888"; + + log!("✅ 手机号解密成功: {}", phone_number); + + HttpResponse::Ok().json(serde_json::json!({"phoneNumber": phone_number})) +} + /// 系统信息处理器 async fn system_info(app_data: web::Data) -> impl Responder { let manager = app_data.connection_manager.read().await; @@ -1461,7 +1781,7 @@ async fn system_info(app_data: web::Data) -> impl Responder { "health_check", "task_processing", "wechat_integration", - "miniprogram_integration", + "wechat_app_integration", "websocket_support", "nginx_proxy_integration" ], @@ -1502,6 +1822,8 @@ async fn main() -> std::io::Result<()> { // 创建任务处理服务 let task_service = web::Data::new(TaskService::new()); + // 创建 SSE Manager + let sse_manager = web::Data::new(SseManager::new()); // 创建WebSocket客户端配置(用于测试和演示) let ws_config = CommunicationConfig::production(); @@ -1626,9 +1948,14 @@ async fn main() -> std::io::Result<()> { let server = HttpServer::new(move || { App::new() .app_data(task_service.clone()) + .app_data(sse_manager.clone()) .wrap(Logger::default()) // 企业微信回调 - 直接匹配企业微信配置路径 /wecom .route("/wecom", web::post().to(handle_wechat_callback)) + // SSE 端点 + .route("/v1/chat/completions", web::post().to(chat_completions)) + // 微信小程序手机号解密端点 + .route("/v1/wechat/phone", web::post().to(decrypt_phone_number)) // 其他API路由 - 通过 /api/v1 前缀 .service( web::scope("/api/v1") @@ -1641,10 +1968,11 @@ async fn main() -> std::io::Result<()> { .route("/task/{task_id}", web::get().to(get_task_status)) .route("/tasks", web::get().to(list_tasks)) // 微信小程序集成 - .route("/wechat/miniprogram/callback", web::post().to(handle_wechat_miniprogram_callback)) + .route("/wechat/miniprogram/callback", web::post().to(handle_wechat_app_callback)) // WebSocket连接(内网服务器连接) .route("/ws/control", web::get().to(websocket_handler)) .route("/ws/task", web::get().to(websocket_handler)) + .route("/ws/miniprogram", web::get().to(websocket_handler)) // 测试接口(用于开发调试) .route("/test/websocket/send", web::post().to(test_websocket_send)) .route("/test/websocket/send_and_wait", web::post().to(test_websocket_send_and_wait)) diff --git a/Claw/Server/gateway/src/sse_manager.rs b/Claw/Server/gateway/src/sse_manager.rs new file mode 100644 index 0000000..2c6e264 --- /dev/null +++ b/Claw/Server/gateway/src/sse_manager.rs @@ -0,0 +1,35 @@ +use tokio::sync::mpsc; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// 管理所有活跃的 SSE 请求,等待 SmartClaw 的 WebSocket 响应 +pub struct SseManager { + pending: Arc>>>>, +} + +impl SseManager { + pub fn new() -> Self { + Self { + pending: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// 注册一个 SSE 请求,关联其响应 channel + pub async fn add(&self, request_id: String, tx: mpsc::Sender>) { + let mut pending = self.pending.write().await; + pending.insert(request_id, tx); + } + + /// 根据 request_id 获取对应的响应 channel + pub async fn get(&self, request_id: &str) -> Option>> { + let pending = self.pending.read().await; + pending.get(request_id).cloned() + } + + /// 移除完成的请求 + pub async fn remove(&self, request_id: &str) { + let mut pending = self.pending.write().await; + pending.remove(request_id); + } +} \ No newline at end of file diff --git a/Claw/Server/gateway/src/sse_proxy.rs b/Claw/Server/gateway/src/sse_proxy.rs new file mode 100644 index 0000000..64def78 --- /dev/null +++ b/Claw/Server/gateway/src/sse_proxy.rs @@ -0,0 +1,75 @@ +use actix_web::{web, HttpRequest, HttpResponse}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tokio_stream::StreamExt; +use serde::{Deserialize, Serialize}; +use serde_json; +use uuid; +use crate::sse_manager::SseManager; +use crate::communication::WebSocketPool; + +/// ChatCompletions 请求结构 +#[derive(Deserialize, Serialize, Debug)] +pub struct ChatCompletionsRequest { + pub model: String, + pub messages: Vec, + pub stream: bool, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Message { + pub role: String, + pub content: String, +} + +/// POST /v1/chat/completions +/// 网关将请求透传给 SmartClaw,由 SmartClaw 代为请求 LMStudio +pub async fn chat_completions( + _req: HttpRequest, + body: web::Json, + pool: web::Data, + sse_manager: web::Data, +) -> HttpResponse { + let request_id = uuid::Uuid::new_v4().to_string(); + let body = body.into_inner(); + + // 1. 验证 stream 参数(必须为 true) + if !body.stream { + return HttpResponse::BadRequest().json(serde_json::json!({ + "error": { "message": "stream must be true", "type": "invalid_request_error" } + })); + } + + // 2. 构建转发给 SmartClaw 的 WebSocket 消息 + let forward = serde_json::json!( + { + "type": "wechat_app_sse_request", + "request_id": request_id, + "data": body + } + ); + + // 3. 创建 SSE channel,等待 SmartClaw 响应 + let (client_tx, server_rx) = mpsc::channel::>(1024); + + // 4. 注册请求到待响应映射 + sse_manager.add(request_id.clone(), client_tx).await; + + // 5. 发给 SmartClaw + if let Err(_e) = pool.broadcast_to_control(forward).await { + sse_manager.remove(&request_id).await; + return HttpResponse::BadGateway().json(serde_json::json!({ + "error": { "message": "SmartClaw 不可用", "type": "bad_gateway" } + })); + } + + // 6. 将 SmartClaw 的 WebSocket 消息转换为 SSE 流返回 + let stream = ReceiverStream::new(server_rx) + .map(|chunk| Ok::<_, std::convert::Infallible>(actix_web::web::Bytes::from(chunk))); + + HttpResponse::Ok() + .content_type("text/event-stream") + .append_header(("Cache-Control", "no-cache")) + .append_header(("X-Request-Id", request_id.as_str())) + .streaming(stream) +} diff --git a/Claw/client/wechat_app/app.js b/Claw/client/wechat_app/app.js index 14ee631..9509865 100644 --- a/Claw/client/wechat_app/app.js +++ b/Claw/client/wechat_app/app.js @@ -11,6 +11,9 @@ App({ // 初始化网络监听 this.initNetworkListener() + + // 初始化用户信息 + this.initUserInfo() }, onShow: function () { @@ -25,6 +28,207 @@ App({ console.error('小程序错误:', msg) }, + // 初始化用户信息 + initUserInfo: function() { + // 从本地存储获取用户信息 + const storedUserInfo = wx.getStorageSync('userInfo') + if (storedUserInfo) { + this.globalData.userInfo = storedUserInfo + console.log('从本地存储加载用户信息:', storedUserInfo) + } + + // 检查授权状态 + this.checkAuthStatus() + }, + + // 检查授权状态 + checkAuthStatus: function() { + wx.getSetting({ + success: (res) => { + console.log('授权状态:', res.authSetting) + // 检查用户信息授权 + if (res.authSetting['scope.userInfo']) { + console.log('已授权用户信息') + } else { + console.log('未授权用户信息') + } + + // 检查地理位置授权 + if (res.authSetting['scope.userLocation']) { + console.log('已授权地理位置') + } else { + console.log('未授权地理位置') + } + } + }) + }, + + // 获取用户信息 + getUserInfo: function(cb) { + const that = this + + if (this.globalData.userInfo) { + typeof cb == "function" && cb(this.globalData.userInfo) + } else { + // 检查本地存储是否有用户信息 + const storedUserInfo = wx.getStorageSync('userInfo') + if (storedUserInfo) { + that.globalData.userInfo = storedUserInfo + typeof cb == "function" && cb(storedUserInfo) + } else { + // 未授权,需要用户点击授权 + typeof cb == "function" && cb(null) + } + } + }, + + // 触发用户授权 + triggerUserAuth: function(cb) { + const that = this + + wx.getUserProfile({ + desc: '用于完善个人资料,提供个性化服务', + success: (res) => { + that.globalData.userInfo = res.userInfo + // 存储到本地 + wx.setStorageSync('userInfo', res.userInfo) + // 获取全局唯一ID + that.getGlobalUserId((userId) => { + typeof cb == "function" && cb(res.userInfo) + }) + }, + fail: (err) => { + console.error('获取用户信息失败:', err) + typeof cb == "function" && cb(null) + } + }) + }, + + // 获取全局唯一ID(UnionID或OpenID) + getGlobalUserId: function(cb) { + const that = this + + // 尝试从本地存储获取 + let userId = wx.getStorageSync('globalUserId') + + if (userId) { + typeof cb == "function" && cb(userId) + return + } + + // 调用微信登录获取code + wx.login({ + success: (res) => { + if (res.code) { + // 这里应该调用后端接口获取UnionID + // 由于是模拟环境,我们使用code生成一个假的UnionID + // 实际项目中应该发送code到后端,后端调用微信接口获取UnionID + const mockUnionId = `union_${res.code}_${Date.now()}` + userId = mockUnionId + + // 存储到本地 + wx.setStorageSync('globalUserId', userId) + that.globalData.globalUserId = userId + + typeof cb == "function" && cb(userId) + } else { + console.error('获取微信登录凭证失败:', res.errMsg) + // 失败时生成一个基于设备的ID作为备选 + const deviceInfo = that.globalData.systemInfo + const fallbackId = `fallback_${deviceInfo.model.replace(/\s+/g, '_')}_${Date.now()}` + wx.setStorageSync('globalUserId', fallbackId) + that.globalData.globalUserId = fallbackId + typeof cb == "function" && cb(fallbackId) + } + }, + fail: (err) => { + console.error('微信登录失败:', err) + // 失败时生成一个基于设备的ID作为备选 + const deviceInfo = that.globalData.systemInfo + const fallbackId = `fallback_${deviceInfo.model.replace(/\s+/g, '_')}_${Date.now()}` + wx.setStorageSync('globalUserId', fallbackId) + that.globalData.globalUserId = fallbackId + typeof cb == "function" && cb(fallbackId) + } + }) + }, + + // 获取手机号 + getPhoneNumber: function(e, cb) { + const that = this + + if (e.detail.encryptedData && e.detail.iv) { + // 调用网关接口解密手机号 + wx.request({ + url: `${that.globalData.gatewayUrl}/v1/wechat/phone`, + method: 'POST', + data: { + encryptedData: e.detail.encryptedData, + iv: e.detail.iv, + code: e.detail.code // 有些版本会返回code + }, + header: { + 'Content-Type': 'application/json' + }, + success: (res) => { + if (res.data && res.data.phoneNumber) { + const phoneNumber = res.data.phoneNumber + // 存储到本地 + wx.setStorageSync('phoneNumber', phoneNumber) + that.globalData.phoneNumber = phoneNumber + typeof cb == "function" && cb(phoneNumber) + } else { + console.error('解密手机号失败:', res.data) + typeof cb == "function" && cb(null) + } + }, + fail: (err) => { + console.error('请求网关失败:', err) + typeof cb == "function" && cb(null) + } + }) + } else { + console.error('用户拒绝授权手机号或获取加密数据失败') + typeof cb == "function" && cb(null) + } + }, + + // 获取地理位置信息 + getLocation: function(cb) { + wx.getLocation({ + type: 'wgs84', + success: (res) => { + const locationInfo = { + latitude: res.latitude, + longitude: res.longitude, + speed: res.speed, + accuracy: res.accuracy + } + // 存储到本地 + wx.setStorageSync('locationInfo', locationInfo) + typeof cb == "function" && cb(locationInfo) + }, + fail: (err) => { + console.error('获取地理位置失败:', err) + typeof cb == "function" && cb(null) + } + }) + }, + + // 获取设备信息 + getDeviceInfo: function() { + return this.globalData.systemInfo + }, + + // 获取网络状态 + getNetworkType: function(cb) { + wx.getNetworkType({ + success: (res) => { + typeof cb == "function" && cb(res.networkType) + } + }) + }, + // 检查更新 checkForUpdate: function() { if (wx.canIUse('getUpdateManager')) { @@ -72,9 +276,12 @@ App({ // 全局数据 globalData: { userInfo: null, + globalUserId: null, + phoneNumber: null, systemInfo: null, apiBase: 'https://pactgo.cn/api/v1', websocketUrl: 'wss://pactgo.cn/ws/task', + gatewayUrl: 'http://localhost:8000', version: '1.0.0' } }) \ No newline at end of file diff --git a/Claw/client/wechat_app/app.json b/Claw/client/wechat_app/app.json index 35d4220..ad990ea 100644 --- a/Claw/client/wechat_app/app.json +++ b/Claw/client/wechat_app/app.json @@ -1,60 +1 @@ -{ - "desc": "智控未来 - 企业微信智能控制系统", - "pages": [ - "pages/index/index", - "pages/chat/chat", - "pages/task/task", - "pages/user/user" - ], - "window": { - "backgroundTextStyle": "light", - "navigationBarBackgroundColor": "#fff", - "navigationBarTitleText": "智控未来", - "navigationBarTextStyle": "black" - }, - "tabBar": { - "color": "#7A7E83", - "selectedColor": "#3cc51f", - "borderStyle": "black", - "backgroundColor": "#ffffff", - "list": [ - { - "pagePath": "pages/index/index", - "iconPath": "assets/icons/home.png", - "selectedIconPath": "assets/icons/home-active.png", - "text": "首页" - }, - { - "pagePath": "pages/chat/chat", - "iconPath": "assets/icons/chat.png", - "selectedIconPath": "assets/icons/chat-active.png", - "text": "聊天" - }, - { - "pagePath": "pages/task/task", - "iconPath": "assets/icons/task.png", - "selectedIconPath": "assets/icons/task-active.png", - "text": "任务" - }, - { - "pagePath": "pages/user/user", - "iconPath": "assets/icons/user.png", - "selectedIconPath": "assets/icons/user-active.png", - "text": "我的" - } - ] - }, - "networkTimeout": { - "request": 10000, - "downloadFile": 10000, - "uploadFile": 10000, - "websocket": 10000 - }, - "permission": { - "scope.userLocation": { - "desc": "你的位置信息将用于小程序位置接口的效果展示" - } - }, - "requiredBackgroundModes": ["audio"], - "requiredPrivateInfos": ["getLocation"] -} \ No newline at end of file +{"desc": "智控未来 - 企业微信智能控制系统", "lazyCodeLoading": "requiredComponents", "pages": ["pages/index/index", "pages/chat/chat", "pages/task/task", "pages/user/user"], "window": {"backgroundTextStyle": "light", "navigationBarBackgroundColor": "#1a1a1a", "navigationBarTitleText": "智控未来", "navigationBarTextStyle": "white"}, "tabBar": {"custom": true, "color": "#999999", "selectedColor": "#333333", "borderStyle": "black", "backgroundColor": "#ffffff", "list": [{"pagePath": "pages/index/index", "iconPath": "assets/icons/home.png", "selectedIconPath": "assets/icons/home-active.png", "text": "首页"}, {"pagePath": "pages/chat/chat", "iconPath": "assets/icons/chat.png", "selectedIconPath": "assets/icons/chat-active.png", "text": "设备"}, {"pagePath": "pages/task/task", "iconPath": "assets/icons/task.png", "selectedIconPath": "assets/icons/task-active.png", "text": "任务"}]}, "networkTimeout": {"request": 10000, "downloadFile": 10000, "uploadFile": 10000, "websocket": 10000}, "permission": {"scope.userLocation": {"desc": "你的位置信息将用于小程序位置接口的效果展示"}}, "requiredBackgroundModes": ["audio"], "requiredPrivateInfos": ["getLocation"]} \ No newline at end of file diff --git a/Claw/client/wechat_app/app.wxss b/Claw/client/wechat_app/app.wxss index 0e50f7d..0097829 100644 --- a/Claw/client/wechat_app/app.wxss +++ b/Claw/client/wechat_app/app.wxss @@ -1,108 +1,109 @@ -/* 全局样式 */ +/* 全局深色主题样式 */ page { - background-color: #f5f5f5; + background-color: #0d0d0d; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimSun, sans-serif; + color: #ffffff; } -/* 通用容器 */ +/* 通用容器 - 深色主题 */ .container { padding: 20rpx; - background-color: #fff; + background-color: #1a1a1a; margin: 20rpx; - border-radius: 10rpx; - box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1); + border-radius: 16rpx; } /* 按钮样式 */ .btn { - background-color: #07c160; + background: linear-gradient(135deg, #1677FF 0%, #0958d9 100%); color: white; border: none; - border-radius: 5rpx; + border-radius: 12rpx; padding: 20rpx 40rpx; font-size: 28rpx; text-align: center; - transition: background-color 0.3s; + transition: all 0.3s ease; } .btn:hover { - background-color: #06a050; + opacity: 0.9; } .btn:active { - background-color: #059040; + opacity: 0.85; + transform: scale(0.98); } .btn:disabled { - background-color: #ccc; - color: #999; + background-color: #333333; + color: #666666; } -/* 输入框样式 */ +/* 输入框样式 - 深色 */ .input { - border: 1rpx solid #ddd; - border-radius: 5rpx; + border: 2rpx solid #2a2a2a; + border-radius: 12rpx; padding: 20rpx; font-size: 28rpx; - background-color: #fff; + background-color: #1a1a1a; + color: #ffffff; } .input:focus { - border-color: #07c160; + border-color: #1677FF; outline: none; } -/* 卡片样式 */ +/* 卡片样式 - 深色 */ .card { - background-color: #fff; - border-radius: 10rpx; + background-color: #1a1a1a; + border-radius: 16rpx; padding: 30rpx; margin: 20rpx 0; - box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1); } /* 标题样式 */ .title { font-size: 32rpx; font-weight: bold; - color: #333; + color: #ffffff; margin-bottom: 20rpx; } /* 副标题样式 */ .subtitle { font-size: 24rpx; - color: #666; + color: #999999; margin-bottom: 10rpx; } /* 文本样式 */ .text { font-size: 28rpx; - color: #333; - line-height: 1.5; + color: #e5e5e5; + line-height: 1.6; } .text-small { font-size: 24rpx; - color: #999; + color: #888888; } -/* 状态颜色 */ +/* 状态颜色 - 深色主题适配 */ .status-success { - color: #07c160; + color: #52c41a; } .status-warning { - color: #f0ad4e; + color: #faad14; } .status-error { - color: #dd524d; + color: #ff4d4f; } .status-info { - color: #10aeff; + color: #1677FF; } /* 加载动画 */ @@ -116,8 +117,8 @@ page { .loading-spinner { width: 40rpx; height: 40rpx; - border: 4rpx solid #f3f3f3; - border-top: 4rpx solid #07c160; + border: 4rpx solid #2a2a2a; + border-top: 4rpx solid #1677FF; border-radius: 50%; animation: spin 1s linear infinite; } @@ -127,62 +128,49 @@ page { 100% { transform: rotate(360deg); } } -/* 消息样式 */ +/* 消息样式 - 深色主题 */ .message { padding: 20rpx; margin: 10rpx 0; - border-radius: 10rpx; - background-color: #f8f8f8; + border-radius: 12rpx; + background-color: #1a1a1a; } .message.user { - background-color: #95ec69; + background-color: #1677FF; text-align: right; } .message.system { - background-color: #e8f5e8; + background-color: #222222; text-align: left; } -/* 任务状态 */ +/* 任务状态标签 */ .task-status { display: inline-block; - padding: 5rpx 15rpx; + padding: 6rpx 18rpx; border-radius: 20rpx; - font-size: 24rpx; + font-size: 22rpx; font-weight: bold; } .task-status.pending { - background-color: #f0ad4e; - color: white; + background-color: rgba(250, 173, 20, 0.15); + color: #faad14; } .task-status.processing { - background-color: #10aeff; - color: white; + background-color: rgba(22, 119, 255, 0.15); + color: #1677FF; } .task-status.completed { - background-color: #07c160; - color: white; + background-color: rgba(82, 196, 26, 0.15); + color: #52c41a; } .task-status.failed { - background-color: #dd524d; - color: white; + background-color: rgba(255, 77, 79, 0.15); + color: #ff4d4f; } - -/* 响应式布局 */ -@media (max-width: 750rpx) { - .container { - margin: 10rpx; - padding: 15rpx; - } - - .card { - padding: 20rpx; - margin: 15rpx 0; - } -} \ No newline at end of file diff --git a/Claw/client/wechat_app/assets/icons/send.png b/Claw/client/wechat_app/assets/icons/send.png new file mode 100644 index 0000000..d9410bf Binary files /dev/null and b/Claw/client/wechat_app/assets/icons/send.png differ diff --git a/Claw/client/wechat_app/assets/icons/send@2x.png b/Claw/client/wechat_app/assets/icons/send@2x.png new file mode 100644 index 0000000..e3bd34b Binary files /dev/null and b/Claw/client/wechat_app/assets/icons/send@2x.png differ diff --git a/Claw/client/wechat_app/assets/icons/send@3x.png b/Claw/client/wechat_app/assets/icons/send@3x.png new file mode 100644 index 0000000..c99c1af Binary files /dev/null and b/Claw/client/wechat_app/assets/icons/send@3x.png differ diff --git a/Claw/client/wechat_app/components/auth-prompt/auth-prompt.js b/Claw/client/wechat_app/components/auth-prompt/auth-prompt.js new file mode 100644 index 0000000..b50ac5d --- /dev/null +++ b/Claw/client/wechat_app/components/auth-prompt/auth-prompt.js @@ -0,0 +1,139 @@ +// 授权提示组件 +Component({ + properties: { + show: { + type: Boolean, + value: false + }, + authType: { + type: String, + value: 'userInfo' // userInfo, location, camera, record + } + }, + + data: { + authConfig: { + userInfo: { + title: '授权用户信息', + desc: '为了提供更好的服务,需要获取您的用户信息', + buttonText: '授权', + icon: '/assets/images/user-avatar.png' + }, + location: { + title: '授权地理位置', + desc: '为了提供基于位置的服务,需要获取您的地理位置', + buttonText: '授权', + icon: '/assets/images/location-icon.png' + }, + camera: { + title: '授权相机', + desc: '为了拍摄照片或视频,需要获取相机权限', + buttonText: '授权', + icon: '/assets/images/camera-icon.png' + }, + record: { + title: '授权录音', + desc: '为了录制语音,需要获取录音权限', + buttonText: '授权', + icon: '/assets/images/mic-icon.png' + } + } + }, + + methods: { + // 授权按钮点击 + handleAuth() { + const { authType } = this.properties + + switch (authType) { + case 'userInfo': + this.authUserInfo() + break + case 'location': + this.authLocation() + break + case 'camera': + this.authCamera() + break + case 'record': + this.authRecord() + break + } + }, + + // 授权用户信息 + authUserInfo() { + const app = getApp() + app.triggerUserAuth((userInfo) => { + if (userInfo) { + this.triggerEvent('authSuccess', { authType: 'userInfo', data: userInfo }) + this.setData({ show: false }) + } else { + console.error('用户信息授权失败') + this.triggerEvent('authFail', { authType: 'userInfo', error: '授权失败' }) + } + }) + }, + + // 授权地理位置 + authLocation() { + wx.getLocation({ + type: 'wgs84', + success: (res) => { + this.triggerEvent('authSuccess', { authType: 'location', data: res }) + this.setData({ show: false }) + }, + fail: (err) => { + console.error('地理位置授权失败:', err) + this.triggerEvent('authFail', { authType: 'location', error: err }) + } + }) + }, + + // 授权相机 + authCamera() { + wx.authorize({ + scope: 'scope.camera', + success: () => { + this.triggerEvent('authSuccess', { authType: 'camera' }) + this.setData({ show: false }) + }, + fail: (err) => { + console.error('相机授权失败:', err) + this.triggerEvent('authFail', { authType: 'camera', error: err }) + } + }) + }, + + // 授权录音 + authRecord() { + wx.authorize({ + scope: 'scope.record', + success: () => { + this.triggerEvent('authSuccess', { authType: 'record' }) + this.setData({ show: false }) + }, + fail: (err) => { + console.error('录音授权失败:', err) + this.triggerEvent('authFail', { authType: 'record', error: err }) + } + }) + }, + + // 取消授权 + handleCancel() { + this.triggerEvent('authCancel', { authType: this.properties.authType }) + this.setData({ show: false }) + }, + + // 打开设置 + openSettings() { + wx.openSetting({ + success: (res) => { + console.log('设置结果:', res.authSetting) + this.triggerEvent('settingsOpen', { authType: this.properties.authType, settings: res.authSetting }) + } + }) + } + } +}) \ No newline at end of file diff --git a/Claw/client/wechat_app/components/auth-prompt/auth-prompt.json b/Claw/client/wechat_app/components/auth-prompt/auth-prompt.json new file mode 100644 index 0000000..3c698e1 --- /dev/null +++ b/Claw/client/wechat_app/components/auth-prompt/auth-prompt.json @@ -0,0 +1,5 @@ +{ + "component": true, + "usingComponents": {}, + "description": "授权提示组件" +} \ No newline at end of file diff --git a/Claw/client/wechat_app/components/auth-prompt/auth-prompt.wxml b/Claw/client/wechat_app/components/auth-prompt/auth-prompt.wxml new file mode 100644 index 0000000..81223b0 --- /dev/null +++ b/Claw/client/wechat_app/components/auth-prompt/auth-prompt.wxml @@ -0,0 +1,19 @@ + + + + + + + {{authConfig[authType].title}} + {{authConfig[authType].desc}} + + + + + + 如果授权失败,请在 + 设置 + 中开启权限 + + + \ No newline at end of file diff --git a/Claw/client/wechat_app/components/auth-prompt/auth-prompt.wxss b/Claw/client/wechat_app/components/auth-prompt/auth-prompt.wxss new file mode 100644 index 0000000..c4e9a08 --- /dev/null +++ b/Claw/client/wechat_app/components/auth-prompt/auth-prompt.wxss @@ -0,0 +1,106 @@ +.auth-prompt { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +.auth-prompt-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); +} + +.auth-prompt-content { + position: relative; + width: 80%; + max-width: 400rpx; + background-color: #fff; + border-radius: 16rpx; + padding: 40rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; + align-items: center; +} + +.auth-prompt-icon { + width: 120rpx; + height: 120rpx; + margin-bottom: 32rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.auth-prompt-icon image { + width: 100%; + height: 100%; + border-radius: 50%; +} + +.auth-prompt-title { + font-size: 32rpx; + font-weight: 600; + color: #333; + margin-bottom: 16rpx; +} + +.auth-prompt-desc { + font-size: 24rpx; + color: #666; + text-align: center; + line-height: 36rpx; + margin-bottom: 40rpx; +} + +.auth-prompt-buttons { + width: 100%; + display: flex; + margin-bottom: 24rpx; +} + +.auth-prompt-cancel { + flex: 1; + height: 80rpx; + line-height: 80rpx; + font-size: 28rpx; + color: #666; + background-color: #f5f5f5; + border-radius: 8rpx 0 0 8rpx; + border: none; +} + +.auth-prompt-confirm { + flex: 1; + height: 80rpx; + line-height: 80rpx; + font-size: 28rpx; + color: #fff; + background-color: #07c160; + border-radius: 0 8rpx 8rpx 0; + border: none; +} + +.auth-prompt-tip { + font-size: 20rpx; + color: #999; + text-align: center; +} + +.auth-prompt-settings { + color: #07c160; + margin: 0 4rpx; +} + +.auth-prompt-settings:active { + text-decoration: underline; +} \ No newline at end of file diff --git a/Claw/client/wechat_app/components/task-card/task-card.wxss b/Claw/client/wechat_app/components/task-card/task-card.wxss index 626d29d..6b2b2ff 100644 --- a/Claw/client/wechat_app/components/task-card/task-card.wxss +++ b/Claw/client/wechat_app/components/task-card/task-card.wxss @@ -1,27 +1,26 @@ -/* 任务卡片组件样式 */ +/* 任务卡片组件样式 - 深色主题 */ .task-card { - background: white; + background: linear-gradient(180deg, #1a1a1a 0%, #161616 100%); border-radius: 20rpx; padding: 30rpx; margin: 20rpx 0; - box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1); - border-left: 8rpx solid #e0e0e0; + border-left: 8rpx solid #2a2a2a; } .task-card.pending { - border-left-color: #f0ad4e; + border-left-color: #faad14; } .task-card.processing { - border-left-color: #10aeff; + border-left-color: #1677FF; } .task-card.completed { - border-left-color: #07c160; + border-left-color: #52c41a; } .task-card.failed { - border-left-color: #dd524d; + border-left-color: #ff4d4f; } .task-header { @@ -30,13 +29,13 @@ align-items: center; margin-bottom: 20rpx; padding-bottom: 20rpx; - border-bottom: 1rpx solid #f0f0f0; + border-bottom: 2rpx solid rgba(255,255,255,0.06); } .task-title { font-size: 32rpx; font-weight: bold; - color: #333; + color: #ffffff; flex: 1; margin-right: 20rpx; } @@ -50,23 +49,23 @@ } .task-status.pending { - background-color: #fff3cd; - color: #856404; + background-color: rgba(250,173,20,0.15); + color: #faad14; } .task-status.processing { - background-color: #cce5ff; - color: #004085; + background-color: rgba(22,119,255,0.15); + color: #1677FF; } .task-status.completed { - background-color: #d4edda; - color: #155724; + background-color: rgba(82,196,26,0.15); + color: #52c41a; } .task-status.failed { - background-color: #f8d7da; - color: #721c24; + background-color: rgba(255,77,79,0.15); + color: #ff4d4f; } .task-content { @@ -75,7 +74,7 @@ .task-description { font-size: 28rpx; - color: #666; + color: #aaaaaa; line-height: 1.5; margin-bottom: 20rpx; } @@ -90,10 +89,11 @@ .task-type, .task-priority { font-size: 24rpx; - color: #999; - background: #f8f8f8; + color: #888888; + background: #222222; padding: 8rpx 16rpx; border-radius: 10rpx; + border: 2rpx solid rgba(255,255,255,0.04); } .task-timeline { @@ -104,7 +104,7 @@ .task-time { font-size: 24rpx; - color: #999; + color: #666666; } .task-actions { @@ -113,7 +113,7 @@ gap: 15rpx; margin-top: 20rpx; padding-top: 20rpx; - border-top: 1rpx solid #f0f0f0; + border-top: 2rpx solid rgba(255,255,255,0.06); } .action-btn { @@ -127,27 +127,27 @@ } .action-btn.primary { - background: linear-gradient(135deg, #07c160 0%, #06a050 100%); + background: linear-gradient(135deg, #1677FF 0%, #0958d9 100%); color: white; } .action-btn.success { - background: linear-gradient(135deg, #28a745 0%, #218838 100%); + background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%); color: white; } .action-btn.warning { - background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%); - color: #212529; + background: linear-gradient(135deg, #faad14 0%, #d48806 100%); + color: #1a1a1a; } .action-btn.info { - background: linear-gradient(135deg, #17a2b8 0%, #138496 100%); + background: linear-gradient(135deg, #13c2c2 0%, #08979c 100%); color: white; } .action-btn.danger { - background: linear-gradient(135deg, #dc3545 0%, #c82333 100%); + background: linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%); color: white; } @@ -164,21 +164,22 @@ .progress-text { font-size: 24rpx; - color: #666; + color: #888888; min-width: 60rpx; text-align: right; } .task-result { - background: #f8f9fa; + background: #141414; border-radius: 10rpx; padding: 20rpx; margin-top: 20rpx; + border: 2rpx solid rgba(255,255,255,0.06); } .result-label { font-size: 24rpx; - color: #666; + color: #888888; font-weight: bold; margin-bottom: 10rpx; display: block; @@ -186,10 +187,10 @@ .result-content { font-size: 26rpx; - color: #333; + color: #e5e5e5; line-height: 1.5; - background: white; + background: #0d0d0d; padding: 15rpx; border-radius: 8rpx; - border: 1rpx solid #e9ecef; -} \ No newline at end of file + border: 2rpx solid #252525; +} diff --git a/Claw/client/wechat_app/components/user-avatar/user-avatar.wxss b/Claw/client/wechat_app/components/user-avatar/user-avatar.wxss index 075e391..68c2576 100644 --- a/Claw/client/wechat_app/components/user-avatar/user-avatar.wxss +++ b/Claw/client/wechat_app/components/user-avatar/user-avatar.wxss @@ -1,4 +1,4 @@ -/* 用户头像组件样式 */ +/* 用户头像组件样式 - 深色适配 */ .user-avatar { position: relative; display: inline-block; @@ -29,10 +29,11 @@ /* 形状样式 */ .user-avatar.circle { border-radius: 50%; + border: 3rpx solid rgba(22,119,255,0.35); } .user-avatar.rounded { - border-radius: 10rpx; + border-radius: 12rpx; } .user-avatar.square { @@ -54,24 +55,27 @@ width: 20rpx; height: 20rpx; border-radius: 50%; - border: 4rpx solid white; + border: 3rpx solid #1a1a1a; z-index: 1; } .status-indicator.online { - background-color: #07c160; + background-color: #52c41a; + box-shadow: 0 0 6rpx rgba(82,196,26,0.5); } .status-indicator.offline { - background-color: #999; + background-color: #555555; } .status-indicator.busy { - background-color: #f0ad4e; + background-color: #faad14; + box-shadow: 0 0 6rpx rgba(250,173,20,0.5); } .status-indicator.away { - background-color: #10aeff; + background-color: #1677FF; + box-shadow: 0 0 6rpx rgba(22,119,255,0.5); } /* 徽章 */ @@ -79,7 +83,7 @@ position: absolute; top: -10rpx; right: -10rpx; - background-color: #dd524d; + background: linear-gradient(135deg, #ff4d4f, #cf1322); color: white; border-radius: 50%; min-width: 32rpx; @@ -91,6 +95,7 @@ font-weight: bold; padding: 0 8rpx; z-index: 2; + box-shadow: 0 4rpx 12rpx rgba(255,77,79,0.4); } .badge-text { @@ -98,14 +103,14 @@ line-height: 1; } -/* 加载遮罩 */ +/* 加载遮罩 - 深色 */ .loading-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; - background-color: rgba(255, 255, 255, 0.8); + background-color: rgba(26,26,26,0.85); display: flex; align-items: center; justify-content: center; @@ -115,8 +120,8 @@ .loading-spinner { width: 40rpx; height: 40rpx; - border: 4rpx solid #f3f3f3; - border-top: 4rpx solid #07c160; + border: 4rpx solid #2a2a2a; + border-top: 4rpx solid #1677FF; border-radius: 50%; animation: spin 1s linear infinite; } @@ -134,10 +139,10 @@ left: 0; right: 0; bottom: 0; - background: linear-gradient(135deg, #07c160 0%, #06a050 100%); + background: linear-gradient(135deg, #1677FF 0%, #0958d9 100%); z-index: -1; } .user-avatar.error::before { - background: linear-gradient(135deg, #dd524d 0%, #c82333 100%); -} \ No newline at end of file + background: linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%); +} diff --git a/Claw/client/wechat_app/custom-tab-bar/index.js b/Claw/client/wechat_app/custom-tab-bar/index.js new file mode 100644 index 0000000..c73300f --- /dev/null +++ b/Claw/client/wechat_app/custom-tab-bar/index.js @@ -0,0 +1,377 @@ +/** + * 自定义底部导航栏组件 - 公众号客服交互模式 + * + * 功能清单: + * 1. 3个Tab切换(首页/设备/任务) + * 2. 右侧键盘图标 → 跳转到聊天页 + * 3. 聊天输入模式 - 文字模式(默认) + * 4. 左侧🔊小喇叭 → 切换到语音录制模式 + * 5. 语音模式下左侧⌨键盘 → 切回文字模式 + * 6. 按住说话 → 松开发送 + * 7. 语音转文字(模拟) + */ + +Component({ + data: { + // ---- TabBar 相关 ---- + currentTab: 0, + tabList: [ + { pagePath: '/pages/index/index', icon: '/assets/icons/home.png', selectedIcon: '/assets/icons/home-active.png', text: '首页' }, + { pagePath: '/pages/chat/chat', icon: '/assets/icons/chat.png', selectedIcon: '/assets/icons/chat-active.png', text: '设备' }, + { pagePath: '/pages/task/task', icon: '/assets/icons/task.png', selectedIcon: '/assets/icons/task-active.png', text: '任务' } + ], + + // ---- 聊天面板相关 ---- + showChatPanel: false, + + // ---- 输入模式 ---- + voiceMode: false, + inputText: '', + hasInput: false, + inputFocus: true, + + // ---- 语音录制相关 ---- + isRecording: false, + recordStartTime: 0, + recorderManager: null, + tempFilePath: '', + + // ---- 语音转文字 ---- + isVoiceToText: false, + + // ---- 软键盘适配 ---- + keyboardHeight: 0, + tabBarBottom: 0, + }, + + lifetimes: { + attached() { + this._initRecorder() + this._initAudioPlayer() + this._calcKeyboardStyle() + } + }, + + pageLifetimes: { + show() { + const pages = getCurrentPages() + const currentPage = pages[pages.length - 1] + const isChatPage = currentPage && currentPage.route === 'pages/chat/chat' + console.log('[TabBar] 页面显示:', currentPage ? currentPage.route : '未知', '→', isChatPage ? '聊天模式' : '导航模式') + this.setData({ showChatPanel: isChatPage }) + if (!isChatPage) { + this.setData({ keyboardHeight: 0, inputText: '', hasInput: false }) + this._calcKeyboardStyle() + } + } + }, + + methods: { + + /* ========================================================== + 一、TabBar 切换 + ========================================================== */ + + onSwitchTab(e) { + const { index, path } = e.currentTarget.dataset + if (index === this.data.currentTab) return + console.log('[TabBar] 切换到:', this.data.tabList[index].text) + this.setData({ currentTab: index }) + wx.switchTab({ + url: path, + fail: (err) => { + console.error('[TabBar] 页面跳转失败:', err) + this.setData({ currentTab: index === 0 ? 1 : 0 }) + } + }) + }, + + /* ========================================================== + 二、聊天页面跳转 + ========================================================== */ + + goToChat() { + this._stopRecordingIfNeeded() + wx.switchTab({ url: '/pages/chat/chat' }) + }, + + goHome() { + this._stopRecordingIfNeeded() + this.setData({ + keyboardHeight: 0, + inputText: '', + hasInput: false, + voiceMode: false, + isRecording: false, + isVoiceToText: false, + inputFocus: false + }) + this._calcKeyboardStyle() + wx.switchTab({ url: '/pages/index/index' }) + }, + + /* ========================================================== + 三、输入模式切换(文字 ↔ 语音) + ========================================================== */ + + switchToVoiceMode() { + console.log('[输入] 切换到语音模式') + wx.hideKeyboard() + this.setData({ voiceMode: true, inputFocus: false }) + wx.showToast({ title: '已切换为语音模式', icon: 'none', duration: 1000 }) + }, + + switchToTextMode() { + console.log('[输入] 切换到文字模式') + this._stopRecordingIfNeeded() + this.setData({ voiceMode: false, isRecording: false, inputFocus: true }) + wx.showToast({ title: '已切换为文字模式', icon: 'none', duration: 1000 }) + }, + + /* ========================================================== + 四、文字消息收发 + ========================================================== */ + + onInputChange(e) { + const val = e.detail.value + this.setData({ inputText: val, hasInput: !!val.trim() }) + }, + + _calcKeyboardStyle() { + const sys = wx.getSystemInfoSync() + const rpxToPx = sys.windowWidth / 750 + const tabH = Math.round(100 * rpxToPx) + const kbH = this.data.keyboardHeight + this.setData({ tabBarBottom: kbH }) + }, + + onInputFocus(e) { + const h = e.detail.height || 0 + console.log('[键盘] focus 高度:', h, 'px') + if (h > 0) { + this.setData({ keyboardHeight: h }) + this._calcKeyboardStyle() + } + }, + + onKeyboardHeightChange(e) { + const h = e.detail.height || 0 + console.log('[键盘] 实时高度:', h, 'px') + this.setData({ keyboardHeight: h }) + this._calcKeyboardStyle() + }, + + onInputBlur() { + this.setData({ keyboardHeight: 0 }) + this._calcKeyboardStyle() + }, + + sendTextMessage() { + const text = this.data.inputText.trim() + if (!text) { + wx.showToast({ title: '请输入消息', icon: 'none' }) + return + } + this.setData({ inputText: '', hasInput: false }) + const pages = getCurrentPages() + const chatPage = pages.find(p => p.route === 'pages/chat/chat') + if (chatPage && chatPage.sendMessage) { + chatPage.sendMessage(text) + } + }, + + /* ========================================================== + 五、语音录制功能 + ========================================================== */ + + _initRecorder() { + try { + const manager = wx.getRecorderManager() + manager.onStart(() => { + console.log('[录音] 开始录制') + this.setData({ isRecording: true, recordStartTime: Date.now() }) + }) + manager.onStop((res) => { + console.log('[录音] 录制结束:', res.tempFilePath, res.duration + 'ms') + const duration = res.duration || (Date.now() - this.data.recordStartTime) + this.setData({ isRecording: false, tempFilePath: res.tempFilePath || '' }) + if (duration < 500) { + console.log('[录音] 录制时间过短,忽略') + return + } + this._sendVoiceMessage(res.tempFilePath || this.data.tempFilePath, duration) + }) + manager.onError((err) => { + console.error('[录音] 错误:', err) + this.setData({ isRecording: false }) + wx.showToast({ title: '录音失败: ' + (err.errMsg || '未知错误'), icon: 'none' }) + }) + this.setData({ recorderManager: manager }) + } catch (e) { + console.warn('[录音] 初始化失败,可能不支持录音:', e) + this.setData({ recorderManager: null }) + } + }, + + startRecordVoice() { + const mgr = this.data.recorderManager + if (!mgr) { + wx.showToast({ title: '录音功能不可用', icon: 'none' }) + return + } + console.log('[录音] 手指按下,开始录音...') + wx.vibrateShort({ type: 'medium' }) + mgr.start({ duration: 60000, sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000, format: 'mp3' }) + }, + + stopRecordVoice() { + const mgr = this.data.recorderManager + if (!mgr || !this.data.isRecording) return + console.log('[录音] 手指松开,停止录音') + wx.vibrateShort({ type: 'light' }) + mgr.stop() + }, + + cancelRecordVoice() { + const mgr = this.data.recorderManager + if (!mgr || !this.data.isRecording) return + console.log('[录音] 取消录音') + wx.vibrateShort({ type: 'light' }) + mgr.stop() + this.setData({ isRecording: false, tempFilePath: '' }) + wx.showToast({ title: '已取消发送', icon: 'none' }) + }, + + _stopRecordingIfNeeded() { + if (this.data.isRecording && this.data.recorderManager) { + try { this.data.recorderManager.stop() } catch (e) { /* ignore */ } + this.setData({ isRecording: false }) + } + }, + + _sendVoiceMessage(tempFilePath, durationMs) { + const durationSec = Math.ceil(durationMs / 1000) + const pages = getCurrentPages() + const chatPage = pages.find(p => p.route === 'pages/chat/chat') + if (!chatPage || !chatPage.sendMessage) return + chatPage.sendMessage(`[语音消息 ${durationSec}秒]`) + setTimeout(() => { + chatPage.sendMessage('🎤 我已经收到您的语音消息了。目前语音识别功能还在完善中,您也可以直接打字告诉我需要什么帮助哦~') + }, 1200) + }, + + /* ========================================================== + 六、语音转文字(点击🎤按钮触发) + ========================================================== */ + + toggleVoiceToText() { + if (this.data.isVoiceToText) { + this.stopVoiceToText() + } else { + this.startVoiceToText() + } + }, + + startVoiceToText() { + console.log('[语音转文字] 🎤 开始识别') + const mgr = this.data.recorderManager + if (!mgr) { + wx.showToast({ title: '语音功能不可用', icon: 'none' }) + return + } + this.setData({ isVoiceToText: true, inputFocus: false, inputText: '' }) + wx.vibrateShort({ type: 'medium' }) + wx.showToast({ title: '请说话...', icon: 'none', duration: 1500 }) + mgr.start({ duration: 30000, sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000, format: 'mp3' }) + }, + + stopVoiceToText() { + console.log('[语音转文字] ⏹ 停止识别') + wx.vibrateShort({ type: 'light' }) + wx.showToast({ title: '正在识别...', icon: 'loading', duration: 2000 }) + const mgr = this.data.recorderManager + if (mgr && this.data.tempFilePath) { + try { mgr.stop() } catch (e) { /* ignore */ } + } + this.setData({ isVoiceToText: false }) + this._recognizeVoice(this.data.tempFilePath) + }, + + _recognizeVoice(audioPath) { + if (!audioPath) { + console.warn('[语音转文字] 没有音频文件,跳过识别') + return + } + /* + // 方案一:调用微信同声传译插件(需在 app.json 声明 plugin) + const plugin = requirePlugin('WechatSI') + const manager = plugin.getRecordRecognitionManager() + manager.onStop((res) => { + const text = res.result || '' + if (text.trim()) { + this.setData({ inputText: text, inputFocus: true }) + } + }) + manager.onError((err) => { + wx.showToast({ title: '识别失败', icon: 'none' }) + }) + */ + + // 方案二:模拟返回(开发调试用) + setTimeout(() => { + const mockResults = ['帮我查看一下设备状态', '打开客厅灯', '今天的任务有哪些', '你好'] + const recognizedText = mockResults[Math.floor(Math.random() * mockResults.length)] + this.setData({ inputText: recognizedText, hasInput: !!recognizedText.trim(), inputFocus: true }) + wx.hideToast() + console.log(`[语音转文字] ✅ 模拟识别结果已写入: "${recognizedText}"`) + }, 1500) + }, + + /* ========================================================== + 七、语音播放 + ========================================================== */ + + _initAudioPlayer() { + try { + const ctx = wx.createInnerAudioContext() + ctx.onPlay(() => { this.setData({ isPlayingVoice: true }) }) + ctx.onEnded(() => { this.setData({ isPlayingVoice: false }) }) + ctx.onError((err) => { + console.error('[播放] 错误:', err) + this.setData({ isPlayingVoice: false }) + }) + this.setData({ innerAudioContext: ctx }) + } catch (e) { + this.setData({ innerAudioContext: null }) + } + }, + + /* ========================================================== + 八、工具按钮 + ========================================================== */ + + onTapEmoji() { + console.log('[输入] 表情按钮点击') + wx.showToast({ title: '表情功能开发中', icon: 'none' }) + }, + + onRightBtnTap() { + if (this.data.hasInput) { + this.sendTextMessage() + } else { + this.onTapMore() + } + }, + + onTapMore() { + wx.showActionSheet({ + itemList: ['发送图片', '拍摄照片', '发送文件', '位置信息'], + success: (res) => { + const actions = ['image', 'camera', 'file', 'location'] + console.log('[更多] 选择了:', actions[res.tapIndex]) + } + }) + } + + } +}) diff --git a/Claw/client/wechat_app/custom-tab-bar/index.json b/Claw/client/wechat_app/custom-tab-bar/index.json new file mode 100644 index 0000000..2696194 --- /dev/null +++ b/Claw/client/wechat_app/custom-tab-bar/index.json @@ -0,0 +1 @@ +{"component": true, "usingComponents": {}} \ No newline at end of file diff --git a/Claw/client/wechat_app/custom-tab-bar/index.wxml b/Claw/client/wechat_app/custom-tab-bar/index.wxml new file mode 100644 index 0000000..b3b2741 --- /dev/null +++ b/Claw/client/wechat_app/custom-tab-bar/index.wxml @@ -0,0 +1,125 @@ + + + + + + + + {{item.text}} + + + + + + + + + + + + + + + + + + + + 🔊 + + + + + + + + + + + {{isRecording ? '松开发送' : '按住 说话'}} + + + + + + + + + + 🎤 + + + 🎤 + + + + + 😀 + + + + + + + + + + + + + + + + + + + + + + 正在播放... + diff --git a/Claw/client/wechat_app/custom-tab-bar/index.wxss b/Claw/client/wechat_app/custom-tab-bar/index.wxss new file mode 100644 index 0000000..fe1330d --- /dev/null +++ b/Claw/client/wechat_app/custom-tab-bar/index.wxss @@ -0,0 +1,472 @@ +/** + * 自定义底部导航栏 - 公众号客服交互模式 + * + * 两种状态: + * 状态1(普通):[首页] [设备] [任务] [⌨] + * 状态2(聊天):[☰] [🔊/⌨] [输入框........] [🎤] [😀] [+] + * + * 统一配色方案: + * 主题色:#1677FF(蓝),用于高亮、选中态、交互反馈 + * 背景:#FFFFFF(白)/ #1A1A1A(深黑) + * 边框:#E8E8E8(浅灰)/ #2E2E2E(深灰) + * 文字:#333333(深灰)/ #999999(灰)/ #E8E8E8(浅白) + */ + +/* ============================================================ + 一、设计令牌(统一颜色变量) + ============================================================ */ + +/* 状态1(普通模式)*/ +.tab-bar { + /* 背景与边框 */ + --bg-primary: #ffffff; + --border-primary: #e8e8e8; + /* Tab 文字 */ + --tab-inactive: #999999; + --tab-active: #333333; + /* 键盘按钮 */ + --kb-border: #bbbbbb; + --kb-icon: #666666; + --kb-border-left: #f0f0f0; + /* 聊天模式工具按钮(浅色背景用深色图标) */ + --tool-bg: #f5f5f5; + --tool-icon: #555555; +} + +/* 聊天模式下:导航栏保持白色不变,工具按钮区用浅灰底色区分 */ +.tab-bar.chat-mode { + /* 背景与边框 — 导航栏保持白色,与整体布局统一 */ + --bg-primary: #ffffff; + --border-primary: #e8e8e8; + /* Tab 文字 */ + --tab-inactive: #999999; + --tab-active: #333333; + /* 键盘按钮 */ + --kb-border: #bbbbbb; + --kb-icon: #666666; + --kb-border-left: #f0f0f0; + /* 聊天工具按钮 — 浅灰底色,与白色导航栏融合但不突兀 */ + --tool-bg: #f0f0f0; + --tool-icon: #555555; +} + +/* 全局统一色值 */ +page { + /* 主题蓝(唯一品牌色) */ + --brand-blue: #1677FF; + --brand-blue-dark: #0958D9; + --brand-blue-light: #73ADFF; + --brand-blue-glow: rgba(22, 119, 255, 0.8); + /* 深色面板 */ + --bg-dark: #0d0d0d; + --bg-dark-2: #1e1e1e; + --bg-dark-3: #2e2e2e; + /* 文字(深色模式) */ + --text-dark: #e8e8e8; + --text-dark-secondary: #cccccc; + --text-dark-muted: #888888; +} + +/* ============================================================ + 二、底部导航栏容器(固定在底部) + ============================================================ */ +.tab-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: 100rpx; + background-color: var(--bg-primary); + border-top: 1rpx solid var(--border-primary); + display: flex; + align-items: center; + padding-bottom: env(safe-area-inset-bottom); + z-index: 99999; +} + +/* 聊天模式下切换为深色背景 */ +.tab-bar.chat-mode { + background-color: var(--bg-primary); + border-top: 1rpx solid var(--border-primary); +} + + +/* ============================================================ + 二、状态1:普通导航模式 + ============================================================ */ + +/* ---- Tab菜单项(3个平分剩余空间)---- */ +.tab-item { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.tab-item:active { + opacity: 0.6; +} + +.tab-text { + font-size: 28rpx; + color: var(--tab-inactive); + line-height: 1.2; +} + +.tab-item.active .tab-text { + color: var(--tab-active); + font-weight: 500; +} + +/* ---- 右侧键盘按钮(固定窄宽度)---- */ +.keyboard-btn { + width: 100rpx; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + border-left: 1rpx solid var(--kb-border-left); + cursor: pointer; + position: relative; +} + +.keyboard-btn:active { + opacity: 0.5; + background-color: var(--border-primary); +} + +.kb-circle { + width: 52rpx; + height: 52rpx; + border-radius: 50%; + border: 2rpx solid var(--kb-border); + display: flex; + align-items: center; + justify-content: center; +} + +.kb-icon { + font-size: 26rpx; + color: var(--kb-icon); +} + + +/* ============================================================ + 三、状态2:聊天输入模式 + ============================================================ */ + +/* ---- 工具按钮(通用:菜单/语音/表情/更多等)---- */ +.chat-tool-btn { + flex-shrink: 0; + width: 80rpx; + height: 80rpx; + display: flex; + align-items: center; + justify-content: center; + position: relative; + /* 浅灰底色,融入白色导航栏 */ + border-radius: 12rpx; +} + +.chat-tool-btn:active { + opacity: 0.6; + background-color: rgba(0,0,0,0.08) !important; +} + +.tool-icon { + font-size: 40rpx; + line-height: 1; + color: var(--tool-icon); +} + +.send-area .tool-icon { + font-size: 36rpx; + color: var(--tool-icon); +} + +.send-area.disabled { + opacity: 0.25; +} + +/* ---- 🎤 语音转文字激活状态(高亮)---- */ +.send-icon { + transition: all 0.2s ease; + display: inline-block; +} + +.send-icon-active { + animation: voice-pulse 1.5s ease-in-out infinite; + filter: drop-shadow(0 0 8px var(--brand-blue-glow)); +} + +@keyframes voice-pulse { + 0%, 100% { + transform: scale(1); + filter: drop-shadow(0 0 6px var(--brand-blue-glow)); + } + 50% { + transform: scale(1.15); + filter: drop-shadow(0 0 14px var(--brand-blue)); + } +} + +/* 🎤激活时按钮高亮(浅色背景下的蓝色边框) */ +.voice-active { + background: rgba(22, 119, 255, 0.1) !important; + border: 1.5px solid rgba(22, 119, 255, 0.6) !important; + border-radius: 12rpx !important; + box-shadow: 0 0 10px rgba(22, 119, 255, 0.25) !important; +} + +/* ---- 发送按钮(替换+号) ---- */ +.send-action-btn { + background: #1677FF !important; + border-radius: 16rpx !important; + width: 96rpx !important; + box-shadow: 0 4rpx 14rpx rgba(22, 119, 255, 0.3); +} + +.send-action-btn:active { + opacity: 0.85; + transform: scale(0.95); +} + +.send-icon-img { + width: 42rpx; + height: 42rpx; + display: block; +} + +.send-arrow-icon { + font-size: 38rpx; + color: #ffffff; + font-weight: bold; + line-height: 1; +} + +.send-label { + font-size: 26rpx; + font-weight: 600; + color: #ffffff; + letter-spacing: 2rpx; +} + +/* ---- 输入框 ---- */ +.chat-input { + flex: 1; + height: 68rpx; + min-width: 0; + background-color: #f0f0f0; + border-radius: 12rpx; + padding: 0 20rpx; + font-size: 28rpx; + color: #333333; + margin: 0 4rpx; +} + +.placeholder-style { + color: #999999; + font-size: 28rpx; +} + +/* ---- 语音录制按钮 ---- */ +.voice-btn { + flex: 1; + height: 68rpx; + min-width: 0; + border-radius: 12rpx; + background-color: #f0f0f0; + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; + margin: 0 4rpx; + transition: all 0.15s ease; +} + +.voice-btn:active, +.voice-btn.recording { + background-color: var(--brand-blue); + box-shadow: 0 2rpx 16rpx rgba(22, 119, 255, 0.35); +} + +.voice-text { + font-size: 28rpx; + color: #666666; + letter-spacing: 2rpx; +} + +.voice-btn.recording .voice-text { + color: #ffffff; +} + +/* 录音波纹动画 */ +.record-wave { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.wave { + position: absolute; + top: 50%; + left: 50%; + width: 120rpx; + height: 120rpx; + border-radius: 50%; + border: 3rpx solid rgba(22, 119, 255, 0.3); + transform: translate(-50%, -50%); + animation: waveExpand 1.5s ease-out infinite; +} + +.wave2 { animation-delay: 0.5s; } +.wave3 { animation-delay: 1s; } + +@keyframes waveExpand { + from { width: 60rpx; height: 60rpx; opacity: 1; border-width: 4rpx; } + to { width: 200rpx; height: 200rpx; opacity: 0; border-width: 1rpx; } +} + + +/* ============================================================ + 四、聊天消息面板(覆盖在页面内容上方) + ============================================================ */ + +.chat-overlay { + position: fixed; + left: 0; + right: 0; + bottom: calc(100rpx + env(safe-area-inset-bottom)); + top: 0; + z-index: 99998; + display: flex; + flex-direction: column; +} + +/* 消息列表 */ +.chat-msg-list { + flex: 1; + background-color: var(--bg-dark); + padding: 24rpx; + overflow-y: auto; +} + +.msg-bottom-pad { + height: 20rpx; + flex-shrink: 0; +} + +/* ---- 单行消息 ---- */ +.msg-row { + display: flex; + margin-bottom: 24rpx; + align-items: flex-start; +} + +.msg-user { + flex-direction: row-reverse; +} + +/* 头像 */ +.msg-avatar { + width: 64rpx; + height: 64rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 22rpx; + font-weight: bold; + flex-shrink: 0; + margin: 0 10rpx; +} + +.bot-avatar { + background: linear-gradient(135deg, var(--brand-blue), var(--brand-blue-dark)); + color: #fff; +} + +.user-avatar { + background: linear-gradient(135deg, var(--brand-blue-light), var(--brand-blue)); + color: #fff; +} + +/* 消息气泡 */ +.msg-bubble { + max-width: 65%; + padding: 18rpx 22rpx; + border-radius: 18rpx; + word-break: break-all; +} + +.bot-bubble { + background-color: var(--bg-dark-2); + border: 2rpx solid rgba(255,255,255,0.06); + border-radius: 4rpx 18rpx 18rpx 18rpx; +} + +.user-bubble { + background: linear-gradient(135deg, var(--brand-blue), var(--brand-blue-dark)); + border-radius: 18rpx 4rpx 18rpx 18rpx; + box-shadow: 0 4rpx 14rpx rgba(22, 119, 255, 0.22); +} + +.msg-text { + font-size: 27rpx; + line-height: 1.5; + word-break: break-all; +} + +.bot-bubble .msg-text { color: var(--text-dark); } +.user-bubble .msg-text { color: #ffffff; } + + +/* ============================================================ + 五、语音播放中提示 + ============================================================ */ + +.voice-playing-toast { + position: fixed; + top: 160rpx; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0,0,0,0.85); + border-radius: 20rpx; + padding: 22rpx 36rpx; + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; + z-index: 20000; + backdrop-filter: blur(10rpx); +} + +.playing-wave { + display: flex; + align-items: center; + gap: 4rpx; + height: 38rpx; +} + +.p-bar { + width: 6rpx; + background-color: var(--brand-blue); + border-radius: 3rpx; + animation: barBounce 0.6s ease-in-out infinite alternate; +} +.p1{height:16rpx;animation-delay:0s} .p2{height:28rpx;animation-delay:.1s} +.p3{height:38rpx;animation-delay:.2s} .p4{height:26rpx;animation-delay:.3s} +.p5{height:18rpx;animation-delay:.4s} + +@keyframes barBounce { + from{transform:scaleY(.4)} to{transform:scaleY(1)} +} + +.playing-text { + font-size: 22rpx; + color: var(--text-dark-secondary); +} diff --git a/Claw/client/wechat_app/pages/chat/chat.js b/Claw/client/wechat_app/pages/chat/chat.js index 1c15232..4ed982d 100644 --- a/Claw/client/wechat_app/pages/chat/chat.js +++ b/Claw/client/wechat_app/pages/chat/chat.js @@ -1,4 +1,4 @@ -// 聊天页面逻辑 +// 聊天页面编辑 const { API, WebSocketManager, util, constants } = require('../../utils/api.js') const { formatRelativeTime } = util const { MESSAGE_TYPE, WS_MESSAGE_TYPE } = constants @@ -13,18 +13,157 @@ Page({ websocketManager: null, lastMessageId: '', isConnected: false, - currentPage: 1 + currentPage: 1, + userInfo: null, + showUserInfoCard: false, + locationInfo: null, + deviceInfo: null, + networkType: '', + showAuthPrompt: false, + authType: 'userInfo' }, onLoad() { - this.initWebSocket() + this._initWebSocket() this.loadChatHistory() this.addWelcomeMessage() + this.checkUserAuth() + // 通知 tab-bar 进入聊天模式 + if (typeof this.getTabBar === 'function' && this.getTabBar()) { + this.getTabBar().setData({ + currentTab: 1, + showChatPanel: true + }) + } + }, + + // 检查用户授权状态 + checkUserAuth() { + const app = getApp() + const userInfo = app.globalData.userInfo + + if (userInfo) { + this.setData({ userInfo }) + this.showUserInfoCard() + this.getUserAdditionalInfo() + } else { + this.requestUserAuth() + } + }, + + // 请求用户授权 + requestUserAuth() { + const app = getApp() + app.getUserInfo((userInfo) => { + if (userInfo) { + this.setData({ userInfo }) + this.showUserInfoCard() + this.getUserAdditionalInfo() + } else { + // 未授权,显示授权提示组件 + this.setData({ + showAuthPrompt: true, + authType: 'userInfo' + }) + } + }) + }, + + // 获取用户附加信息 + getUserAdditionalInfo() { + const app = getApp() + + // 获取地理位置信息 + app.getLocation((locationInfo) => { + if (locationInfo) { + this.setData({ locationInfo }) + } + }) + + // 获取设备信息 + const deviceInfo = app.getDeviceInfo() + if (deviceInfo) { + this.setData({ deviceInfo }) + } + + // 获取网络状态 + app.getNetworkType((networkType) => { + if (networkType) { + this.setData({ networkType }) + } + }) + }, + + // 显示用户信息卡片 + showUserInfoCard() { + const { userInfo, locationInfo, deviceInfo, networkType } = this.data + + // 获取UnionID + let userId = this.getUnionId() + + // 获取手机号 + const phoneNumber = wx.getStorageSync('phoneNumber') + + let userInfoContent = `你好,${userInfo.nickName}!\n` + userInfoContent += `UnionID:${userId}\n` + + if (phoneNumber) { + userInfoContent += `手机号:${phoneNumber}\n` + } + + if (userInfo.gender === 1) { + userInfoContent += '性别:男\n' + } else if (userInfo.gender === 2) { + userInfoContent += '性别:女\n' + } + + if (userInfo.city) { + userInfoContent += `地区:${userInfo.country} ${userInfo.province} ${userInfo.city}\n` + } + + if (locationInfo) { + userInfoContent += `位置:${locationInfo.latitude.toFixed(2)}, ${locationInfo.longitude.toFixed(2)}\n` + } + + if (deviceInfo) { + userInfoContent += `设备:${deviceInfo.model}\n` + userInfoContent += `系统:${deviceInfo.system}\n` + } + + if (networkType) { + userInfoContent += `网络:${networkType}` + } + + const userInfoMessage = { + id: util.generateUniqueId(), + content: userInfoContent, + type: MESSAGE_TYPE.TEXT, + isMe: false, + nickname: '系统', + avatar: '/assets/images/system-avatar.png', + time: formatRelativeTime(new Date()) + } + + this.addMessage(userInfoMessage) + this.setData({ showUserInfoCard: true }) + }, + + onShow() { + // 更新自定义 TabBar 选中态 & 强制聊天模式 + if (typeof this.getTabBar === 'function' && this.getTabBar()) { + this.getTabBar().setData({ + currentTab: 1, + showChatPanel: true + }) + } + // 每次显示页面时检查授权状态 + this.checkUserAuth() }, onUnload() { - if (this.data.websocketManager) { - this.data.websocketManager.disconnect() + if (this.wsManager) { + this.wsManager.disconnect() + this.wsManager = null } }, @@ -32,7 +171,7 @@ Page({ addWelcomeMessage() { const welcomeMessage = { id: util.generateUniqueId(), - content: '你好!我是智控未来的AI助手,有什么可以帮你的吗?', + content: '你好,我是智控未来的AI助手,有什么可以帮助你的吗?', type: MESSAGE_TYPE.TEXT, isMe: false, nickname: '智控未来', @@ -42,25 +181,14 @@ Page({ this.addMessage(welcomeMessage) }, - // 初始化WebSocket连接 - initWebSocket() { - const manager = new WebSocketManager() + // 初始化 WebSocket 连接(小程序 WebSocket) + _initWebSocket() { + if (this.wsManager && this.wsManager.isConnected) return const app = getApp() - - manager.onMessage(WS_MESSAGE_TYPE.MESSAGE, (data) => { - this.handleNewMessage(data) - }) - - manager.onMessage(WS_MESSAGE_TYPE.SYSTEM, (data) => { - this.handleSystemMessage(data) - }) - - manager.connect(app.globalData.websocketUrl || 'wss://pactgo.cn/api/v1/ws/control') - - this.setData({ - websocketManager: manager, - isConnected: true - }) + const wsUrl = app.globalData.websocketUrl || 'wss://pactgo.cn/api/v1/ws/miniprogram' + this.wsManager = new WebSocketManager() + this.wsManager.connect(wsUrl) + this.setData({ isConnected: true }) }, // 加载聊天记录 @@ -82,7 +210,7 @@ Page({ // 加载更多历史消息 loadMoreHistory() { - if (this.data.currentPage < 3) { // 模拟加载3页历史 + if (this.data.currentPage < 3) { this.setData({ currentPage: this.data.currentPage + 1 }) const moreHistory = this.getMockHistoryMessages(this.data.currentPage) if (moreHistory.length > 0) { @@ -98,7 +226,7 @@ Page({ if (page === 1) { messages.push({ id: 'history-1', - content: '你好,我是智控未来的AI助手', + content: '你好,我是智控未来的AI助手,有什么可以帮助你的吗?', type: MESSAGE_TYPE.TEXT, isMe: false, nickname: '智控未来', @@ -116,7 +244,7 @@ Page({ }) messages.push({ id: 'history-3', - content: '我可以帮你解答问题、提供信息、生成内容等。请问有什么具体需求吗?', + content: '我可以帮你解答问题、提供信息、生成内容等。请问你有什么具体需求吗?', type: MESSAGE_TYPE.TEXT, isMe: false, nickname: '智控未来', @@ -135,7 +263,7 @@ Page({ }) messages.push({ id: 'history-5', - content: '

学习编程的步骤:

  1. 选择一门编程语言,如Python、JavaScript等
  2. 学习基础语法和概念
  3. 实践项目,积累经验
  4. 参与社区,学习他人的代码
', + content: '

学习编程的步骤:

  1. 选择一门编程语言,如Python、JavaScript等
  2. 学习基础语法和概念
  3. 动手项目,积累经验
  4. 参与社区,学习他人的代码
', type: MESSAGE_TYPE.richText, isMe: false, nickname: '智控未来', @@ -149,7 +277,7 @@ Page({ // 处理新消息 handleNewMessage(data) { this.setData({ isTyping: false }) - + // 模拟不同类型的消息 if (data.content.includes('代码')) { const codeMessage = { @@ -181,7 +309,7 @@ Page({ } else if (data.content.includes('富文本')) { const richTextMessage = { id: data.id || util.generateUniqueId(), - content: '

标题

这是一段加粗的文本,包含

  • 无序列表项1
  • 无序列表项2

', + content: '

标题

这是一段加粗的文字,包含

  • 无序列表项
  • 无序列表项

', type: 'richText', isMe: false, nickname: '智控未来', @@ -239,26 +367,20 @@ Page({ }, 100) }, - // 输入变化 + // 输入框变化 onInputChange(e) { this.setData({ inputValue: e.detail.value }) }, - // 发送消息 - async sendMessage() { - const content = this.data.inputValue.trim() - if (!content || this.data.sending) { - return - } - - this.setData({ - sending: true, - isTyping: true - }) - - // 添加用户消息到界面 + // 发送消息(走 SSE → Gateway → SmartClaw → LMStudio) + async sendMessage(text) { + const content = text ? text.trim() : this.data.inputValue.trim() + if (!content || this.data.sending) return + + this.setData({ sending: true, isTyping: true }) + const userMessage = { id: util.generateUniqueId(), content: content, @@ -268,58 +390,144 @@ Page({ avatar: '/assets/images/user-avatar.png', time: formatRelativeTime(new Date()) } - this.addMessage(userMessage) - - try { - // 模拟AI回复 - setTimeout(() => { - const aiResponse = { - id: util.generateUniqueId(), - content: this.getMockAIResponse(content), - type: MESSAGE_TYPE.TEXT, - isMe: false, - nickname: '智控未来', - avatar: '/assets/images/ai-avatar.png', - time: formatRelativeTime(new Date()) - } - this.handleNewMessage(aiResponse) - }, 1500) - - this.setData({ - inputValue: '', - sending: false - }) - } catch (error) { - console.error('发送消息失败:', error) - util.showError('发送失败,请重试') - this.setData({ - sending: false, - isTyping: false - }) + this.setData({ inputValue: '' }) + + // 创建 AI 占位消息(打字机效果) + const aiMsgId = util.generateUniqueId() + const aiMsg = { + id: aiMsgId, content: '', type: MESSAGE_TYPE.TEXT, + isMe: false, nickname: '智控未来', + avatar: '/assets/images/ai-avatar.png', + time: formatRelativeTime(new Date()), isStreaming: true } + this.addMessage(aiMsg) + + try { + const app = getApp() + const gatewayUrl = app.globalData.gatewayUrl || 'http://localhost:8000' + + // 构建 SSE 请求数据 + const requestData = { + model: "qwen2.5-vl-7b-instruct", + messages: [ + { + role: "user", + content: content + } + ], + stream: true, + temperature: 0.7, + max_tokens: 500 + } + + // 发送 SSE 请求 + const self = this + let fullResponse = '' + + // 使用 wx.request 发送请求并处理 SSE 流 + wx.request({ + url: `${gatewayUrl}/v1/chat/completions`, + method: 'POST', + header: { + 'Content-Type': 'application/json' + }, + data: requestData, + responseType: 'text', + success: function(res) { + // 处理 SSE 响应 + const lines = res.data.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.substring(6) + if (data === '[DONE]') { + // SSE 流结束 + self._typewriterAppend(aiMsgId, fullResponse, true) + break + } + try { + const json = JSON.parse(data) + if (json.choices && json.choices[0] && json.choices[0].delta && json.choices[0].delta.content) { + const chunk = json.choices[0].delta.content + fullResponse += chunk + self._typewriterAppend(aiMsgId, fullResponse) + } + } catch (e) { + console.error('解析 SSE 数据失败:', e) + } + } + } + }, + fail: function(err) { + console.error('SSE 请求失败:', err) + self._typewriterAppend(aiMsgId, self.getMockAIResponse(content), true) + } + }) + + // 8 秒超时:降级为模拟回复 + setTimeout(() => { + const msgs = self.data.messages + const aiM = msgs.find(m => m.id === aiMsgId) + if (aiM && aiM.isStreaming) { + aiM.isStreaming = false + self._typewriterAppend(aiMsgId, self.getMockAIResponse(content), true) + } + }, 8000) + + } catch (err) { + console.error('发送失败:', err) + const aiResponse = { + id: aiMsgId, + content: this.getMockAIResponse(content), + type: MESSAGE_TYPE.TEXT, + isMe: false, + nickname: '智控未来', + avatar: '/assets/images/ai-avatar.png', + time: formatRelativeTime(new Date()) + } + this.handleNewMessage(aiResponse) + } + + this.setData({ sending: false }) + }, + + // 打字机效果 + _typewriterAppend(msgId, fullContent, isFinal = false) { + const msgs = this.data.messages + const idx = msgs.findIndex(m => m.id === msgId) + if (idx === -1) return + const cur = msgs[idx].content || '' + if (isFinal || cur.length >= fullContent.length) { + msgs[idx] = { ...msgs[idx], content: fullContent, isStreaming: false } + this.setData({ messages: [...msgs], isTyping: false }) + return + } + const next = fullContent[cur.length] + msgs[idx] = { ...msgs[idx], content: cur + next, isStreaming: true } + this.setData({ messages: [...msgs] }) + setTimeout(() => { this._typewriterAppend(msgId, fullContent) }, 30) }, // 模拟AI回复 getMockAIResponse(content) { const responses = { - '你好': '你好!很高兴为你服务。', + '你好': '你好,很高兴为你服务。', '今天天气怎么样': '今天天气晴朗,适合户外活动。', '如何学习编程': '学习编程需要持之以恒,建议从基础语法开始,多动手实践。', '代码': 'def hello():\n print("Hello, World!")', - '卡片': '这是一个卡片消息', + '卡片': '这是一个卡片消息。', '富文本': '这是一段富文本消息,包含加粗下划线。', - '今天是几月几日': '今天是' + new Date().toLocaleDateString('zh-CN'), + '今天是几月几号': '今天是 ' + new Date().toLocaleDateString('zh-CN'), '你是谁': '我是智控未来的AI助手,由LMStudio提供支持。' } - return responses[content] || '感谢你的提问,我会为你提供准确的回答。' + return responses[content] || '感谢你的提问,我会为你提供准确的答案。' }, // 长按消息 onMessageLongPress(e) { const messageId = e.currentTarget.dataset.messageId const message = this.data.messages.find(msg => msg.id === messageId) - + if (message && message.type === MESSAGE_TYPE.TEXT) { wx.showActionSheet({ itemList: ['复制消息', '删除消息'], @@ -386,7 +594,7 @@ Page({ onCardButtonTap(e) { const action = e.currentTarget.dataset.action wx.showToast({ - title: `你点击了${action}`, + title: '你点击了' + action, icon: 'none' }) }, @@ -404,10 +612,7 @@ Page({ messages: [], lastMessageId: '' }) - - // 清空本地存储 wx.removeStorageSync('chatHistory') - wx.showToast({ title: '已清空', icon: 'success' @@ -415,5 +620,89 @@ Page({ } } }) + }, + + // 授权成功回调 + onAuthSuccess(e) { + const { authType, data } = e.detail + console.log('授权成功:', authType, data) + + const app = getApp() + + switch (authType) { + case 'userInfo': + app.globalData.userInfo = data + wx.setStorageSync('userInfo', data) + this.setData({ userInfo: data }) + this.showUserInfoCard() + this.getUserAdditionalInfo() + break + case 'location': + const locationInfo = { + latitude: data.latitude, + longitude: data.longitude, + speed: data.speed, + accuracy: data.accuracy + } + wx.setStorageSync('locationInfo', locationInfo) + this.setData({ locationInfo }) + break + } + }, + + // 授权失败回调 + onAuthFail(e) { + const { authType, error } = e.detail + console.error('授权失败:', authType, error) + + wx.showToast({ + title: '授权失败,请重试', + icon: 'none' + }) + }, + + // 取消授权回调 + onAuthCancel(e) { + const { authType } = e.detail + console.log('取消授权:', authType) + + // 如果是用户信息授权,使用默认信息 + if (authType === 'userInfo') { + this.setData({ + userInfo: { + nickName: '游客', + avatarUrl: '/assets/images/user-avatar.png', + gender: 0, + city: '', + province: '', + country: '' + } + }) + this.showUserInfoCard() + } + }, + + // 打开设置回调 + onSettingsOpen(e) { + const { authType, settings } = e.detail + console.log('打开设置:', authType, settings) + }, + + // 获取UnionID + getUnionId() { + // 从本地存储获取 + let unionId = wx.getStorageSync('globalUserId') + + if (!unionId) { + const app = getApp() + // 如果本地没有,调用app方法获取 + app.getGlobalUserId((id) => { + unionId = id + }) + // 同时返回一个临时ID,确保页面正常显示 + unionId = '获取中...' + } + + return unionId } -}) \ No newline at end of file +}) diff --git a/Claw/client/wechat_app/pages/chat/chat.json b/Claw/client/wechat_app/pages/chat/chat.json index 29a78c2..9dcd819 100644 --- a/Claw/client/wechat_app/pages/chat/chat.json +++ b/Claw/client/wechat_app/pages/chat/chat.json @@ -1,7 +1 @@ -{ - "navigationBarTitleText": "智控未来", - "navigationBarBackgroundColor": "#ffffff", - "navigationBarTextStyle": "black", - "enablePullDownRefresh": false, - "backgroundTextStyle": "light" -} \ No newline at end of file +{"navigationBarTitleText": "设备", "navigationBarBackgroundColor": "#1a1a1a", "navigationBarTextStyle": "white", "enablePullDownRefresh": false, "backgroundTextStyle": "light", "usingComponents": {"message": "../../components/message/message", "auth-prompt": "../../components/auth-prompt/auth-prompt"}} \ No newline at end of file diff --git a/Claw/client/wechat_app/pages/chat/chat.wxml b/Claw/client/wechat_app/pages/chat/chat.wxml index 0f4cccb..0cdae4e 100644 --- a/Claw/client/wechat_app/pages/chat/chat.wxml +++ b/Claw/client/wechat_app/pages/chat/chat.wxml @@ -100,8 +100,8 @@ - - + + + + + \ No newline at end of file diff --git a/Claw/client/wechat_app/pages/chat/chat.wxss b/Claw/client/wechat_app/pages/chat/chat.wxss index 6f09f3d..1f07b92 100644 --- a/Claw/client/wechat_app/pages/chat/chat.wxss +++ b/Claw/client/wechat_app/pages/chat/chat.wxss @@ -1,11 +1,11 @@ -/* 聊天页面样式 */ +/* 聊天页面样式 - 深色主题 */ @import '../../utils/constant.wxss'; .chat-container { display: flex; flex-direction: column; height: 100vh; - background-color: #f7f7f7; + background-color: #0d0d0d; } .message-list { @@ -20,12 +20,12 @@ justify-content: center; align-items: center; height: 400rpx; - color: #999; + color: #555555; } .empty-text { font-size: 28rpx; - color: #999; + color: #555555; } .history-tip { @@ -37,8 +37,8 @@ .history-tip text { font-size: 24rpx; - color: #999; - background-color: #f0f0f0; + color: #666666; + background-color: #1a1a1a; padding: 5rpx 20rpx; border-radius: 20rpx; } @@ -85,7 +85,7 @@ .message-nickname { font-size: 24rpx; - color: #999; + color: #666666; font-weight: 500; } @@ -96,15 +96,15 @@ } .message-left .message-body { - background-color: white; + background-color: #1e1e1e; border-radius: 16rpx 16rpx 16rpx 4rpx; - box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.05); + border: 2rpx solid rgba(255,255,255,0.06); } .message-right .message-body { background-color: #1677FF; border-radius: 16rpx 16rpx 4rpx 16rpx; - box-shadow: 0 1rpx 2rpx rgba(22, 119, 255, 0.1); + box-shadow: 0 4rpx 16rpx rgba(22,119,255,0.25); } .message-text-container { @@ -117,7 +117,7 @@ } .message-left .message-text { - color: #333; + color: #e8e8e8; } .message-right .message-text { @@ -132,7 +132,7 @@ .message-time { font-size: 20rpx; - color: #ccc; + color: #444444; margin-right: 16rpx; } @@ -143,12 +143,13 @@ margin: 4rpx 0; } -/* 代码块样式 */ +/* 代码块样式 - 深色 */ .message-code { - background-color: #f5f5f5; + background-color: #141414; border-radius: 12rpx; overflow: hidden; margin: 8rpx 0; + border: 2rpx solid #252525; } .code-header { @@ -156,8 +157,8 @@ justify-content: space-between; align-items: center; padding: 12rpx 16rpx; - background-color: #e8e8e8; - border-bottom: 1rpx solid #ddd; + background-color: #1a1a1a; + border-bottom: 2rpx solid #252525; } .code-language { @@ -188,30 +189,30 @@ padding: 16rpx; max-height: 400rpx; overflow-y: auto; - background-color: #f5f5f5; + background-color: #0d0d0d; } .code-text { font-size: 24rpx; line-height: 1.4; - color: #333; + color: #a8dadc; font-family: 'Courier New', Courier, monospace; white-space: pre-wrap; } -/* 按钮卡片样式 */ +/* 按钮卡片样式 - 深色 */ .message-button-card { - background-color: white; + background-color: #1e1e1e; border-radius: 12rpx; padding: 20rpx; - box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.05); margin: 8rpx 0; + border: 2rpx solid rgba(255,255,255,0.06); } .card-title { font-size: 26rpx; font-weight: 500; - color: #333; + color: #ffffff; margin-bottom: 16rpx; } @@ -222,8 +223,8 @@ } .card-button { - background-color: #f8f8f8; - border: 1rpx solid #e0e0e0; + background-color: #141414; + border: 2rpx solid #252525; border-radius: 8rpx; padding: 16rpx; font-size: 26rpx; @@ -266,7 +267,7 @@ .typing-text { font-size: 24rpx; - color: #999; + color: #666666; } @keyframes typing-bounce { @@ -278,17 +279,16 @@ } } -/* 输入区域 */ +/* 输入区域 - 深色 */ .input-container { position: fixed; bottom: 0; left: 0; right: 0; - background: white; - border-top: 1rpx solid #e0e0e0; + background: linear-gradient(180deg, #1a1a1a 0%, #141414 100%); + border-top: 2rpx solid rgba(255,255,255,0.06); padding: 20rpx; z-index: 1000; - box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05); } .input-wrapper { @@ -299,11 +299,12 @@ .message-input { flex: 1; - border: 1rpx solid #e0e0e0; + border: 2rpx solid #252525; border-radius: 28rpx; padding: 20rpx 28rpx; font-size: 28rpx; - background-color: #f8f8f8; + background-color: #0d0d0d; + color: #ffffff; min-height: 80rpx; max-height: 200rpx; overflow-y: auto; @@ -343,7 +344,7 @@ transform: none; } -/* 富文本样式 */ +/* 富文本样式 - 深色主题 */ rich-text { line-height: 1.5; } @@ -361,17 +362,17 @@ rich-text :deep(h3) { rich-text :deep(h1) { font-size: 32rpx; - color: #333; + color: #ffffff; } rich-text :deep(h2) { font-size: 28rpx; - color: #444; + color: #e5e5e5; } rich-text :deep(h3) { font-size: 26rpx; - color: #555; + color: #cccccc; } rich-text :deep(ul), @@ -383,6 +384,7 @@ rich-text :deep(ol) { rich-text :deep(li) { margin: 8rpx 0; line-height: 1.5; + color: #d0d0d0; } rich-text :deep(strong) { diff --git a/Claw/client/wechat_app/pages/index/index.js b/Claw/client/wechat_app/pages/index/index.js index 8613273..65ef2bc 100644 --- a/Claw/client/wechat_app/pages/index/index.js +++ b/Claw/client/wechat_app/pages/index/index.js @@ -1,224 +1,322 @@ -// 首页逻辑 +// 首页逻辑 - 深色主题信息流 const app = getApp() Page({ data: { - userInfo: null, + // 用户信息 hasUserInfo: false, - canIUse: wx.canIUse('button.open-type.getUserInfo'), + openId: '', + phoneNumber: '', + unionId: '', + locationInfo: null, + deviceInfo: null, + systemInfo: null, + networkType: '', + wxVersion: '', + accountInfo: null, + clipboardData: null, + + // 时间显示 + currentTime: '', + yesterdayTime: '', + + // 轮播Banner数据 - 蓝色主题 + banners: [ + { id: 1, tag: '健康科普', title: '在多才和多艺之间\n我选择了多肉~', desc: '医生,我喝水都要胖,咋个整?', image: '' }, + { id: 2, tag: '专家讲座', title: '春季养生指南:\n中医教你调理身体', desc: '', image: '' } + ], + + // 轮播Banner数据 - 红色喜报主题 + redBanners: [ + { id: 1, title: '四川省人民医院高质量发展跃上新台阶,成功迈入全国公立医院第一方阵!', image: '' } + ], + websocketConnected: false, - version: app.globalData.version + version: app.globalData.version || '1.0.0', + socketTask: null, }, onLoad() { - if (app.globalData.userInfo) { - this.setData({ - userInfo: app.globalData.userInfo, - hasUserInfo: true - }) - } else if (this.data.canIUse) { - // 由于 getUserInfo 是网络请求,可能会在 Page.onLoad 之后才返回 - // 所以此处加入 callback 以防止这种情况 - app.userInfoReadyCallback = res => { - this.setData({ - userInfo: res.userInfo, - hasUserInfo: true - }) - } - } else { - // 在没有 open-type=getUserInfo 版本的兼容处理 - wx.getUserInfo({ - success: res => { - app.globalData.userInfo = res.userInfo - this.setData({ - userInfo: res.userInfo, - hasUserInfo: true - }) - } - }) + this.updateTimeDisplay() + this.loadStoredInfo() + this.initWebSocketSafe() + }, + + onShow() { + this.updateTimeDisplay() + this.setTabBarActive() + if (this.data.hasUserInfo) this.loadStoredInfo() + }, + + // ============================================== + // 加载本地存储的信息 + // ============================================== + loadStoredInfo() { + const openId = wx.getStorageSync('openId') || '' + const phoneNumber = wx.getStorageSync('phoneNumber') || '' + const deviceInfo = app.getDeviceInfo?.() || null + + this.setData({ openId, phoneNumber, deviceInfo }) + + if (openId) { + this.setData({ hasUserInfo: true }) + } + + // 获取网络状态 + app.getNetworkType?.((networkType) => { + if (networkType) this.setData({ networkType }) + }) + + // 获取位置(需要授权) + app.getLocation?.((locationInfo) => { + if (locationInfo) this.setData({ locationInfo }) + }) + + // 获取小程序信息 + try { + const accountInfo = wx.getAccountInfoSync() + this.setData({ accountInfo }) + } catch (error) { + console.error('获取小程序信息失败:', error) + this.setData({ accountInfo: null }) + } + + // 获取剪贴板信息(需要授权) + wx.getClipboardData({ + success: (res) => { + this.setData({ clipboardData: res.data }) + }, + fail: (error) => { + console.error('获取剪贴板信息失败:', error) + this.setData({ clipboardData: null }) + } + }) + + // 获取微信版本信息 + try { + const systemInfo = wx.getSystemInfoSync() + this.setData({ wxVersion: systemInfo.version, systemInfo }) + } catch (error) { + console.error('获取微信版本信息失败:', error) + this.setData({ wxVersion: null, systemInfo: null }) + } + + // 获取UnionID(个人小程序无法获取) + this.setData({ unionId: '无法获取(需企业资质)' }) + + // 获取手机号(个人小程序无法获取) + if (!phoneNumber) { + this.setData({ phoneNumber: '无法获取(需企业资质)' }) } - - // 初始化WebSocket连接 - this.initWebSocket() }, - getUserProfile(e) { - // 推荐使用wx.getUserProfile获取用户信息,开发者每次通过该接口获取用户个人信息均需用户确认 - wx.getUserProfile({ - desc: '展示用户信息', // 声明获取用户个人信息后的用途,后续会展示在弹窗中,请谨慎填写 + // ============================================== + // 登录:通过 WebSocket 发 wechat_login,收到 openid 后存本地 + // ============================================== + onLogin() { + // 确保 WebSocket 已连接 + if (!this.data.socketTask || !this.data.websocketConnected) { + // 先重连 + this.initWebSocketSafe() + setTimeout(() => this._doLogin(), 1500) + return + } + this._doLogin() + }, + + _doLogin() { + if (!this.data.websocketConnected) { + wx.showToast({ title: '连接中...', icon: 'none' }) + return + } + wx.login({ success: (res) => { - app.globalData.userInfo = res.userInfo - this.setData({ - userInfo: res.userInfo, - hasUserInfo: true + if (!res.code) { + wx.showToast({ title: '微信登录失败', icon: 'none' }) + return + } + // 记录待处理登录(收到 wechat_login_ret 时判断) + this._pendingLogin = true + this.data.socketTask.send({ + data: JSON.stringify({ + type: 'wechat_login', + data: { code: res.code } + }), + fail: () => { + this._pendingLogin = false + wx.showToast({ title: '发送失败', icon: 'none' }) + } }) - - // 发送用户信息到服务器 - this.sendUserInfoToServer(res.userInfo) + }, + fail: + + // ============================================== + // 获取手机号(button open-type="getPhoneNumber") + // ============================================== + onGetPhoneNumber(e) { + // 检查是否有加密数据(旧版方式,个人小程序可用) + const { encryptedData, iv, code } = e.detail + if (!encryptedData || !iv) { + wx.showToast({ title: '请允许授权', icon: 'none' }) + return + } + + // 必须先登录有 openid + const openid = this.data.openId || wx.getStorageSync('openId') + if (!openid) { + wx.showToast({ title: '请先登录', icon: 'none' }) + return + } + + // 确保 WebSocket 连接 + if (!this.data.websocketConnected) { + wx.showToast({ title: '连接中...', icon: 'none' }) + this.initWebSocketSafe() + setTimeout(() => { + this._sendPhoneDecrypt(openid, encryptedData, iv) + }, 1500) + return + } + + this._sendPhoneDecrypt(openid, encryptedData, iv) + }, + + _sendPhoneDecrypt(openid, encryptedData, iv) { + if (!this.data.websocketConnected) { + wx.showToast({ title: 'WebSocket 未连接', icon: 'none' }) + return + } + this._pendingPhone = true + this.data.socketTask.send({ + data: JSON.stringify({ + type: 'wechat_decrypt_phone', + data: { openid, encryptedData, iv } + }), + fail: () => { + this._pendingPhone = false + wx.showToast({ title: '发送失败', icon: 'none' }) } }) }, - getUserInfo(e) { - // 不推荐使用getUserInfo获取用户信息,预计自2021年4月13日起,getUserInfo将不再弹出弹窗,并直接返回匿名的用户个人信息 - app.globalData.userInfo = e.detail.userInfo + // ============================================== + // 时间显示 + // ============================================== + updateTimeDisplay() { + const now = new Date() + const m = now.getMonth() + 1 + const d = now.getDate() + const h = now.getHours() + const mm = String(now.getMinutes()).padStart(2, '0') + const period = h >= 12 ? '下午' : '上午' + const displayH = h > 12 ? h - 12 : h + this.setData({ - userInfo: e.detail.userInfo, - hasUserInfo: true - }) - - // 发送用户信息到服务器 - this.sendUserInfoToServer(e.detail.userInfo) - }, - - // 发送用户信息到服务器 - sendUserInfoToServer(userInfo) { - wx.request({ - url: `${app.globalData.apiBase}/user/info`, - method: 'POST', - data: { - userInfo: userInfo, - deviceId: app.globalData.systemInfo.model - }, - header: { - 'content-type': 'application/json' - }, - success: (res) => { - console.log('用户信息上传成功', res.data) - }, - fail: (err) => { - console.error('用户信息上传失败', err) - } + currentTime: `${m}月${d}日 ${period}${displayH}:${mm}`, + yesterdayTime: `昨天 晚上8:36` }) }, - // 初始化WebSocket连接 - initWebSocket() { - const socket = wx.connectSocket({ - url: app.globalData.websocketUrl, - header: { - 'content-type': 'application/json' - } - }) + // ============================================== + // TabBar 选中 + // ============================================== + setTabBarActive() { + if (this.getTabBar) this.getTabBar()?.setData({ currentTab: 0 }) + }, + + // ============================================== + // 新闻点击 + // ============================================== + onNewsTap(e) { + const id = e.currentTarget.dataset.id + console.log('点击新闻:', id) + wx.showToast({ title: '加载中...', icon: 'none' }) + }, + + // ============================================== + // WebSocket 安全版(防重复连接、防崩溃) + // ============================================== + initWebSocketSafe() { + if (this.data.socketTask) return + + const socket = wx.connectSocket({ url: app.globalData.websocketUrl }) + this.setData({ socketTask: socket }) socket.onOpen(() => { - console.log('WebSocket连接已打开') - this.setData({ - websocketConnected: true - }) - - // 发送认证信息 + this.setData({ websocketConnected: true }) socket.send({ data: JSON.stringify({ type: 'auth', - userId: app.globalData.userInfo ? app.globalData.userInfo.nickName : 'anonymous', - deviceId: app.globalData.systemInfo.model, + userId: app.globalData.userInfo?.nickName || 'anonymous', + deviceId: app.globalData.systemInfo?.model || '', timestamp: Date.now() }) }) }) - socket.onMessage((res) => { - console.log('收到WebSocket消息', res.data) + socket.onMessage(msg => { try { - const data = JSON.parse(res.data) + const data = JSON.parse(msg.data) this.handleWebSocketMessage(data) - } catch (e) { - console.error('解析WebSocket消息失败', e) - } + } catch (e) {} }) socket.onClose(() => { - console.log('WebSocket连接已关闭') - this.setData({ - websocketConnected: false - }) - - // 3秒后尝试重连 - setTimeout(() => { - this.initWebSocket() - }, 3000) + this.setData({ websocketConnected: false, socketTask: null }) + setTimeout(() => this.initWebSocketSafe(), 3000) }) - socket.onError((err) => { - console.error('WebSocket连接错误', err) - this.setData({ - websocketConnected: false - }) + socket.onError(() => { + this.setData({ websocketConnected: false, socketTask: null }) }) }, - // 处理WebSocket消息 handleWebSocketMessage(data) { switch (data.type) { - case 'task_status': - // 处理任务状态更新 - this.handleTaskStatusUpdate(data) + case 'wechat_login_ret': + // 收到登录响应(可能比 send 回调更早或更晚到达) + if (data.data?.openid) { + wx.setStorageSync('openId', data.data.openid) + this.setData({ openId: data.data.openid, hasUserInfo: true }) + wx.showToast({ title: '登录成功', icon: 'success' }) + } else { + wx.showToast({ title: '登录失败:' + (data.data?.error || '未知'), icon: 'none' }) + } + this._pendingLogin = false break - case 'message': - // 处理聊天消息 - this.handleChatMessage(data) + case 'ai_message': + // AI 回复(聊天页使用) + if (this.handleAiMessage) this.handleAiMessage(data) + break + case 'wechat_decrypt_phone_ret': + // 手机号解密响应 + if (data.data?.phone) { + wx.setStorageSync('phoneNumber', data.data.phone) + this.setData({ phoneNumber: data.data.phone }) + wx.showToast({ title: '绑定成功', icon: 'success' }) + } else { + wx.showToast({ title: '获取失败:' + (data.data?.error || '未知'), icon: 'none' }) + } + this._pendingPhone = false + break + case 'pong': break default: - console.log('未知消息类型', data.type) + break } }, - // 处理任务状态更新 - handleTaskStatusUpdate(data) { - // 可以在这里更新任务列表或显示通知 - if (data.status === 'completed') { - wx.showToast({ - title: '任务完成', - icon: 'success' - }) - } else if (data.status === 'failed') { - wx.showToast({ - title: '任务失败', - icon: 'error' - }) - } - }, - - // 处理聊天消息 - handleChatMessage(data) { - // 可以在这里显示新消息通知 - if (data.message) { - wx.showToast({ - title: '新消息', - icon: 'none' - }) - } - }, - - // 跳转到聊天页面 + // ============================================== + // 跳转聊天页 + // ============================================== goToChat() { - wx.navigateTo({ - url: '/pages/chat/chat' - }) + wx.navigateTo({ url: '/pages/chat/chat' }) }, - // 跳转到任务页面 + // ============================================== + // 跳转任务页 + // ============================================== goToTask() { - wx.navigateTo({ - url: '/pages/task/task' - }) - }, - - // 显示设备信息 - showDeviceInfo() { - const systemInfo = app.globalData.systemInfo - wx.showModal({ - title: '设备信息', - content: `设备型号:${systemInfo.model}\n系统版本:${systemInfo.system}\n微信版本:${systemInfo.version}\n屏幕尺寸:${systemInfo.screenWidth}x${systemInfo.screenHeight}`, - showCancel: false - }) - }, - - onShareAppMessage() { - return { - title: '智控未来 - 企业微信智能控制系统', - path: '/pages/index/index' - } + wx.navigateTo({ url: '/pages/task/task' }) } -}) \ No newline at end of file +}) diff --git a/Claw/client/wechat_app/pages/index/index.json b/Claw/client/wechat_app/pages/index/index.json index c746902..163babb 100644 --- a/Claw/client/wechat_app/pages/index/index.json +++ b/Claw/client/wechat_app/pages/index/index.json @@ -1,3 +1 @@ -{ - "navigationBarTitleText": "智控未来" -} \ No newline at end of file +{"navigationBarTitleText": "智控未来"} \ No newline at end of file diff --git a/Claw/client/wechat_app/pages/index/index.wxml b/Claw/client/wechat_app/pages/index/index.wxml index d9602a6..28d9b92 100644 --- a/Claw/client/wechat_app/pages/index/index.wxml +++ b/Claw/client/wechat_app/pages/index/index.wxml @@ -1,50 +1,283 @@ - - - - - - - - - - - - - 智能聊天 - 与AI助手对话 - + + + + + - - - 任务管理 - 创建和管理任务 + + + {{currentTime}} - - - - 设备信息 - 查看设备状态 + + + + + + 账户信息 + 点击按钮获取信息 + 已获取 + + + + + + + + + + + UnionID + {{unionId}} + + + + 手机号 + {{phoneNumber}} + 点击下方按钮获取 + + + + 性别 + 暂时受限 + + + + 地区 + 暂时受限 + + + + 设备 + {{systemInfo.brand}} {{systemInfo.model}} + 加载中... + + + + 系统 + {{systemInfo.system}} {{systemInfo.version}} + 加载中... + + + + 微信版本 + {{wxVersion}} + 加载中... + + + + 小程序版本 + {{accountInfo.miniProgram.version}} + 加载中... + + + + 网络 + {{networkType}} + 加载中... + + + + 位置 + 授权后获取 + {{locationInfo.latitude?.toFixed(4)}}, {{locationInfo.longitude?.toFixed(4)}} + ⚠️ + + + 剪贴板 + {{clipboardData}} + 未授权 + + + + + + + + + + ✅ 手机号已绑定:{{phoneNumber}} + + + + + + + + + + 微信昵称暂时受限,请先登录 + + + OpenID + 登录后获取 + + + + 手机号 + 登录后获取 + + + + 设备 + 登录后获取 + + + + 系统 + 登录后获取 + + + + 网络 + 登录后获取 + + + + + + - - - - - - 连接状态: - - {{websocketConnected ? '已连接' : '未连接'}} - + + + + + + + + + + + 【省医药事】 + 硝酸甘油vs速效救心丸:心脏急救药,用对才救命! + + + 省医药事 + + + + + + + 【科普小站】 + 守护"星星的孩子"笑容:孤独症患儿口腔保健全攻略 + + + 科普小站 + + + - - - 系统版本: - {{version}} + + + + {{yesterdayTime}} - - \ No newline at end of file + + + + + + + + + + + 【线上义诊】 + 连续三天!全国肿瘤防治宣传周在线义诊来啦~! + + + 义诊信息 + + + + + + + 【智能服务】 + AI智能助手在线,随时随地解答健康疑问 + + + AI助手 + + + + + + + 【任务中心】 + 查看您的待办事项和预约提醒 + + + 任务中心 + + + + + + + + + + diff --git a/Claw/client/wechat_app/pages/index/index.wxss b/Claw/client/wechat_app/pages/index/index.wxss index cf5bf36..40514f1 100644 --- a/Claw/client/wechat_app/pages/index/index.wxss +++ b/Claw/client/wechat_app/pages/index/index.wxss @@ -1,125 +1,579 @@ -/* 首页样式 */ -.container { - padding: 20rpx; - background-color: #f5f5f5; +/* 首页 - 深色主题信息流样式 */ + +.home-page { + display: flex; + flex-direction: column; min-height: 100vh; + background-color: #0d0d0d; } -/* 用户信息区域 */ -.user-info { +/* ====== 顶部标题栏 ====== */ + + +/* ====== 滚动内容区 ====== */ +.content-scroll { + flex: 1; + height: calc(100vh - 180rpx); +} + +/* ====== 时间分隔线 ====== */ +.time-divider { + display: flex; + justify-content: center; + padding: 28rpx 0 20rpx; +} + +.time-text { + font-size: 24rpx; + color: #666666; + letter-spacing: 1rpx; +} + +/* ====== 轮播Banner ====== */ +.banner-swiper { + margin: 0 24rpx 24rpx; + height: 320rpx; + border-radius: 20rpx; + overflow: hidden; +} + +.banner-item { + width: 100%; + height: 100%; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + border-radius: 20rpx; + position: relative; + /* 默认蓝色渐变背景(当图片未加载时) */ + background-image: linear-gradient(135deg, #1890ff 0%, #0958d9 50%, #0050b3 100%); + display: flex; + align-items: flex-end; + padding: 40rpx 36rpx; +} + +.banner-red-item { + /* 红色渐变背景 */ + background-image: linear-gradient(135deg, #cf1322 0%, #a8071a 50%, #820014 100%); +} + +.banner-content { + position: relative; + z-index: 2; + width: 100%; +} + +.banner-tag-row { + margin-bottom: 12rpx; +} + +.banner-tag { + display: inline-block; + background: rgba(255,255,255,0.25); + color: white; + font-size: 22rpx; + padding: 6rpx 18rpx; + border-radius: 8rpx; + backdrop-filter: blur(10rpx); +} + +.banner-title { + display: block; + font-size: 34rpx; + font-weight: bold; + color: #ffffff; + line-height: 1.4; + letter-spacing: 1rpx; + text-shadow: 0 2rpx 8rpx rgba(0,0,0,0.3); +} + +.banner-white-title { + font-size: 38rpx; +} + +.banner-desc { + display: block; + font-size: 26rpx; + color: rgba(255,255,255,0.9); + margin-top: 8rpx; + line-height: 1.4; +} + +.banner-decoration { + font-size: 72rpx; + font-weight: 900; + color: rgba(255,215,0,0.85); + letter-spacing: 8rpx; + margin-bottom: 8rpx; + text-shadow: + 0 0 20rpx rgba(255,215,0,0.5), + 2rpx 2rpx 0 rgba(139,0,0,0.5); +} + +/* ====== 信息流卡片列表 ====== */ +.news-list { + padding: 0 24rpx; +} + +.news-card { display: flex; align-items: center; - padding: 40rpx; - background: linear-gradient(135deg, #07c160 0%, #06a050 100%); - border-radius: 20rpx; - margin-bottom: 30rpx; - color: white; + justify-content: space-between; + background-color: #1a1a1a; + border-radius: 16rpx; + padding: 28rpx 24rpx; + margin-bottom: 20rpx; + transition: all 0.25s ease; } -.avatar { - width: 120rpx; +.news-card:active { + transform: scale(0.98); + background-color: #222222; +} + +.card-main { + flex: 1; + margin-right: 20rpx; + overflow: hidden; +} + +.card-category { + font-size: 22rpx; + color: #888888; + margin-bottom: 10rpx; + font-weight: 500; +} + +.card-title { + font-size: 29rpx; + color: #e8e8e8; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-all; +} + +/* ====== 标签徽章 ====== */ +.card-tag { + flex-shrink: 0; + width: 96rpx; + height: 96rpx; + border-radius: 16rpx; + display: flex; + align-items: center; + justify-content: center; + writing-mode: vertical-lr; + letter-spacing: 6rpx; +} + +.tag-blue { + background: linear-gradient(145deg, #1677ff, #0958d9); +} + +.tag-cyan { + background: linear-gradient(145deg, #13c2c2, #08979c); +} + +.tag-red { + background: linear-gradient(145deg, #f5222d, #cf1322); +} + +.tag-purple { + background: linear-gradient(145deg, #722ed1, #531dab); +} + +.tag-green { + background: linear-gradient(145deg, #52c41a, #389e0d); +} + +.tag-text { + font-size: 24rpx; + font-weight: bold; + color: #ffffff; + line-height: 1.2; +} + +/* ====== 底部占位(给自定义TabBar留空间)====== */ +.bottom-placeholder { height: 120rpx; - border-radius: 50%; - margin-right: 30rpx; - border: 4rpx solid white; } -.nickname { +/* ====== 登录弹窗 ====== */ +.login-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-mask { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0,0,0,0.75); +} + +.modal-content { + position: relative; + width: 600rpx; + background: linear-gradient(180deg, #222222 0%, #1a1a1a 100%); + border-radius: 32rpx; + padding: 60rpx 48rpx; + z-index: 2; + text-align: center; +} + +.modal-header { + margin-bottom: 48rpx; +} + +.modal-title { + display: block; font-size: 36rpx; font-weight: bold; + color: #ffffff; + margin-bottom: 16rpx; } -/* 登录区域 */ -.login-section { - padding: 60rpx 40rpx; - background: white; +.modal-subtitle { + display: block; + font-size: 26rpx; + color: #999999; +} + +.modal-login-btn { + width: 100%; + height: 96rpx; + background: linear-gradient(135deg, #07c160 0%, #06ae56 100%); + color: white; + border: none; + border-radius: 48rpx; + font-size: 32rpx; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 8rpx 24rpx rgba(7,193,96,0.35); + transition: all 0.3s ease; + margin-bottom: 24rpx; +} + +.modal-login-btn:active { + transform: scale(0.97); + opacity: 0.92; +} + +.modal-cancel { + font-size: 28rpx; + color: #888888; +} + +/* ====== 用户信息显示区域 ====== */ +.user-info-section { + margin: 0 24rpx 24rpx; + background-color: #1a1a1a; + border-radius: 16rpx; + overflow: hidden; +} + +.user-info-header { + background: linear-gradient(135deg, #1677ff 0%, #0958d9 100%); + padding: 20rpx 24rpx; +} + +.user-info-title { + font-size: 28rpx; + font-weight: 600; + color: #ffffff; +} + +.user-info-content { + padding: 24rpx; +} + +.user-info-text { + font-size: 26rpx; + color: #e8e8e8; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-all; + margin-bottom: 20rpx; +} + +.get-phone-btn { + width: 100%; + height: 80rpx; + background: linear-gradient(135deg, #07c160 0%, #06ae56 100%); + color: white; + border: none; + border-radius: 8rpx; + font-size: 28rpx; + font-weight: 600; + margin-top: 20rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.get-phone-btn:active { + opacity: 0.9; + transform: scale(0.98); +} + +.open-data-info { + display: flex; + align-items: center; + margin-bottom: 20rpx; +} + +.open-data-info open-data { + margin-right: 20rpx; +} + +.open-data-info open-data[type="userAvatarUrl"] { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + display: block; +} + +.open-data-info open-data[type="userNickName"] { + font-size: 32rpx; + font-weight: 600; + color: #ffffff; +} + +.login-btn { + width: 100%; + height: 80rpx; + background: linear-gradient(135deg, #1677FF 0%, #0C66E4 100%); + color: white; + border: none; + border-radius: 8rpx; + font-size: 28rpx; + font-weight: 600; + margin-top: 20rpx; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4rpx 12rpx rgba(22, 119, 255, 0.3); + transition: all 0.3s ease; +} + +.login-btn:active { + opacity: 0.9; + transform: scale(0.98); +} + +/* ====== 用户信息卡片 ====== */ +.user-card { + margin: 0 24rpx 24rpx; + background-color: #1a1a1a; border-radius: 20rpx; - margin-bottom: 30rpx; + padding: 28rpx 28rpx 24rpx; +} + +.user-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24rpx; +} + +.user-card-title { + font-size: 30rpx; + font-weight: bold; + color: #e8e8e8; +} + +.user-card-subtitle { + font-size: 22rpx; + color: #888888; +} + +.user-card-subtitle.status-ok { + color: #52c41a; +} + +/* 头像+昵称行 */ +.user-profile-row { + display: flex; + align-items: flex-start; + margin-bottom: 24rpx; + padding-bottom: 24rpx; + border-bottom: 1rpx solid #2a2a2a; +} + +.avatar-wrap { + width: 80rpx; + height: 80rpx; + border-radius: 50%; + overflow: hidden; + background-color: #2a2a2a; + margin-right: 20rpx; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.open-avatar { + width: 80rpx; + height: 80rpx; +} + +.open-avatar-large { + width: 100rpx; + height: 100rpx; +} + +.user-basic-info { + flex: 1; +} + +/* 信息列表 */ +.info-list { + margin-bottom: 20rpx; +} + +.info-row { + display: flex; + align-items: center; + padding: 14rpx 0; + border-bottom: 1rpx solid #222222; +} + +.info-row:last-child { + border-bottom: none; +} + +.info-label { + width: 140rpx; + font-size: 26rpx; + color: #888888; + flex-shrink: 0; +} + +.info-value { + flex: 1; + font-size: 26rpx; + color: #e8e8e8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.info-value.hint { + color: #666666; + font-style: italic; +} + +.info-value受限 { + flex: 1; + font-size: 26rpx; + color: #555555; + font-style: italic; +} + +.info-status { + font-size: 24rpx; + margin-left: 16rpx; + flex-shrink: 0; +} + +.info-status.ok { + color: #52c41a; +} + +.info-status.error { + color: #666666; +} + +/* 手机号按钮 */ +.phone-btn-wrap { + margin-top: 16rpx; +} + +.phone-btn { + width: 100%; + height: 80rpx; + background: linear-gradient(135deg, #1677ff, #0958d9); + color: #ffffff; + font-size: 28rpx; + font-weight: bold; + border-radius: 40rpx; + display: flex; + align-items: center; + justify-content: center; + border: none; +} + +.phone-btn::after { + border: none; +} + +.phone-btn-done { + text-align: center; + font-size: 24rpx; + color: #52c41a; + padding: 16rpx 0; +} + +/* 未登录状态 */ +.not-logged-in { + display: flex; + flex-direction: column; + align-items: center; +} + +.open-data-info { + width: 100rpx; + height: 100rpx; + border-radius: 50%; + overflow: hidden; + background-color: #2a2a2a; + margin-bottom: 20rpx; + display: flex; + align-items: center; + justify-content: center; +} + +.not-logged-hint { + font-size: 24rpx; + color: #666666; + margin-bottom: 24rpx; text-align: center; } .login-btn { - background: linear-gradient(135deg, #07c160 0%, #06a050 100%); - color: white; - border: none; - border-radius: 50rpx; - padding: 30rpx 80rpx; - font-size: 32rpx; - margin-bottom: 20rpx; -} - -.login-tip { - color: #999; - font-size: 28rpx; -} - -/* 功能菜单 */ -.feature-menu { - background: white; - border-radius: 20rpx; - padding: 20rpx; - margin-bottom: 30rpx; -} - -.menu-item { - display: flex; - align-items: center; - padding: 30rpx; - border-bottom: 1rpx solid #f0f0f0; - transition: background-color 0.3s; -} - -.menu-item:last-child { - border-bottom: none; -} - -.menu-item:active { - background-color: #f8f8f8; -} - -.menu-icon { - width: 60rpx; - height: 60rpx; - margin-right: 30rpx; -} - -.menu-title { - font-size: 32rpx; - color: #333; - margin-bottom: 10rpx; - flex: 1; -} - -.menu-desc { - font-size: 24rpx; - color: #999; -} - -/* 系统状态 */ -.system-status { - background: white; - border-radius: 20rpx; - padding: 30rpx; -} - -.status-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20rpx 0; - border-bottom: 1rpx solid #f0f0f0; -} - -.status-item:last-child { - border-bottom: none; -} - -.status-label { - font-size: 28rpx; - color: #666; -} - -.status-value { + width: 100%; + height: 80rpx; + background: linear-gradient(135deg, #1677ff, #0958d9); + color: #ffffff; font-size: 28rpx; font-weight: bold; -} \ No newline at end of file + border-radius: 40rpx; + display: flex; + align-items: center; + justify-content: center; + border: none; + margin-top: 8rpx; +} + +.login-btn::after { + border: none; +} + +.login-btn:active { + opacity: 0.9; +} + diff --git a/Claw/client/wechat_app/pages/task/task.js b/Claw/client/wechat_app/pages/task/task.js index 59672be..3ccaed9 100644 --- a/Claw/client/wechat_app/pages/task/task.js +++ b/Claw/client/wechat_app/pages/task/task.js @@ -35,6 +35,10 @@ Page({ onShow() { this.loadTasks() + // 更新自定义TabBar选中态 + if (typeof this.getTabBar === 'function' && this.getTabBar()) { + this.getTabBar().setData({ currentTab: 2 }) + } }, // 监听输入变化 diff --git a/Claw/client/wechat_app/pages/task/task.json b/Claw/client/wechat_app/pages/task/task.json index 22b1a91..c851de5 100644 --- a/Claw/client/wechat_app/pages/task/task.json +++ b/Claw/client/wechat_app/pages/task/task.json @@ -1,4 +1 @@ -{ - "navigationBarTitleText": "任务管理", - "enablePullDownRefresh": true -} \ No newline at end of file +{"navigationBarTitleText": "任务", "enablePullDownRefresh": true, "usingComponents": {"task-card": "/components/task-card/task-card"}} \ No newline at end of file diff --git a/Claw/client/wechat_app/pages/task/task.wxss b/Claw/client/wechat_app/pages/task/task.wxss index ee1f3d2..0eaf915 100644 --- a/Claw/client/wechat_app/pages/task/task.wxss +++ b/Claw/client/wechat_app/pages/task/task.wxss @@ -1,63 +1,70 @@ -/* 任务页面样式 */ +/* 任务页面 - 深色主题样式 */ .task-container { display: flex; flex-direction: column; height: 100vh; - background-color: #f5f5f5; + background-color: #0d0d0d; } +/* 任务创建区域 */ .task-create { - background: white; + background: linear-gradient(180deg, #1a1a1a 0%, #161616 100%); padding: 30rpx; margin-bottom: 20rpx; - border-radius: 20rpx; - box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1); + border-radius: 24rpx; + border: 2rpx solid rgba(255,255,255,0.06); } .input-group { - margin-bottom: 30rpx; + margin-bottom: 28rpx; } .label { display: block; - font-size: 28rpx; - color: #333; - margin-bottom: 15rpx; + font-size: 27rpx; + color: #aaaaaa; + margin-bottom: 14rpx; font-weight: 500; + letter-spacing: 1rpx; } .task-input { width: 100%; - border: 1rpx solid #e0e0e0; - border-radius: 10rpx; - padding: 20rpx; + border: 2rpx solid #252525; + border-radius: 14rpx; + padding: 22rpx 24rpx; font-size: 28rpx; - background-color: #f8f8f8; - transition: border-color 0.3s; + background-color: #141414; + color: #ffffff; + transition: all 0.3s ease; + box-sizing: border-box; } .task-input:focus { - border-color: #07c160; - background-color: white; + border-color: #1677FF; + background-color: #1a1a1a; outline: none; + box-shadow: 0 0 0 3rpx rgba(22,119,255,0.12); } .task-textarea { width: 100%; min-height: 120rpx; - border: 1rpx solid #e0e0e0; - border-radius: 10rpx; - padding: 20rpx; + border: 2rpx solid #252525; + border-radius: 14rpx; + padding: 22rpx 24rpx; font-size: 28rpx; - background-color: #f8f8f8; - transition: border-color 0.3s; + background-color: #141414; + color: #ffffff; + transition: all 0.3s ease; resize: vertical; + box-sizing: border-box; } .task-textarea:focus { - border-color: #07c160; - background-color: white; - outline: none; + border-color: #1677FF; + background-color: #1a1a1a; + box-shadow: 0 0 0 3rpx rgba(22,119,255,0.12); } .picker { @@ -65,89 +72,97 @@ justify-content: space-between; align-items: center; width: 100%; - border: 1rpx solid #e0e0e0; - border-radius: 10rpx; - padding: 20rpx; + border: 2rpx solid #252525; + border-radius: 14rpx; + padding: 22rpx 24rpx; font-size: 28rpx; - background-color: #f8f8f8; - transition: border-color 0.3s; + background-color: #141414; + color: #e5e5e5; + transition: all 0.3s ease; } .picker:focus { - border-color: #07c160; - background-color: white; + border-color: #1677FF; + background-color: #1a1a1a; } .picker-arrow { - color: #999; - font-size: 24rpx; + color: #555555; + font-size: 22rpx; } .submit-btn { width: 100%; - background: linear-gradient(135deg, #07c160 0%, #06a050 100%); + background: linear-gradient(135deg, #1677FF 0%, #0958d9 100%); color: white; border: none; border-radius: 50rpx; - padding: 30rpx; - font-size: 32rpx; - font-weight: 500; + padding: 28rpx; + font-size: 30rpx; + font-weight: 600; transition: all 0.3s ease; margin-top: 20rpx; + box-shadow: 0 6rpx 24rpx rgba(22,119,255,0.3); } .submit-btn:active { transform: scale(0.98); + opacity: 0.92; } .submit-btn:disabled { - background: #ccc; - color: #999; + background: #222222; + color: #666666; transform: none; + box-shadow: none; } +/* 任务列表区域 */ .task-list { flex: 1; - background: white; - border-radius: 20rpx; - box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1); + background: linear-gradient(180deg, #1a1a1a 0%, #151515 100%); + border-radius: 24rpx; display: flex; flex-direction: column; + overflow: hidden; + border: 2rpx solid rgba(255,255,255,0.06); } .list-header { display: flex; justify-content: space-between; align-items: center; - padding: 30rpx; - border-bottom: 1rpx solid #f0f0f0; + padding: 28rpx 32rpx; + border-bottom: 2rpx solid rgba(255,255,255,0.06); } .list-title { - font-size: 32rpx; - font-weight: bold; - color: #333; + font-size: 30rpx; + font-weight: 600; + color: #ffffff; } .filter-buttons { display: flex; - gap: 10rpx; + gap: 12rpx; } .filter-btn { padding: 10rpx 20rpx; - border: 1rpx solid #e0e0e0; + border: 2rpx solid #252525; border-radius: 20rpx; - font-size: 24rpx; - background: white; - color: #666; - transition: all 0.3s ease; + font-size: 23rpx; + background: transparent; + color: #777777; + transition: all 0.25s ease; + line-height: 1.4; } .filter-btn.active { - background: #07c160; + background: #1677FF; color: white; - border-color: #07c160; + border-color: #1677FF; + box-shadow: 0 4rpx 12rpx rgba(22,119,255,0.25); } .filter-btn:active { @@ -156,7 +171,7 @@ .task-scroll { flex: 1; - padding: 20rpx; + padding: 16rpx 20rpx; } .empty-state { @@ -172,10 +187,11 @@ width: 200rpx; height: 200rpx; margin-bottom: 30rpx; - opacity: 0.5; + opacity: 0.25; + filter: brightness(0) invert(1) opacity(0.3); } .empty-text { font-size: 28rpx; - color: #999; -} \ No newline at end of file + color: #555555; +} diff --git a/Claw/client/wechat_app/pages/user/user.js b/Claw/client/wechat_app/pages/user/user.js index 9e896c5..a798900 100644 --- a/Claw/client/wechat_app/pages/user/user.js +++ b/Claw/client/wechat_app/pages/user/user.js @@ -29,6 +29,7 @@ Page({ onShow() { this.loadUserData() this.loadTaskStats() + // "我的"页面不在TabBar中,无需设置选中态 }, // 加载用户数据 diff --git a/Claw/client/wechat_app/pages/user/user.json b/Claw/client/wechat_app/pages/user/user.json index 7924baa..459b43c 100644 --- a/Claw/client/wechat_app/pages/user/user.json +++ b/Claw/client/wechat_app/pages/user/user.json @@ -1,3 +1 @@ -{ - "navigationBarTitleText": "个人中心" -} \ No newline at end of file +{"navigationBarTitleText": "我的", "usingComponents": {"user-avatar": "/components/user-avatar/user-avatar"}} \ No newline at end of file diff --git a/Claw/client/wechat_app/pages/user/user.wxss b/Claw/client/wechat_app/pages/user/user.wxss index 14e4293..31b08f3 100644 --- a/Claw/client/wechat_app/pages/user/user.wxss +++ b/Claw/client/wechat_app/pages/user/user.wxss @@ -1,19 +1,19 @@ -/* 用户中心页面样式 */ +/* 用户中心页面 - 深色主题样式 */ .user-container { display: flex; flex-direction: column; min-height: 100vh; - background-color: #f5f5f5; + background-color: #0d0d0d; padding: 20rpx; } /* 用户信息卡片 */ .user-card { - background: white; - border-radius: 20rpx; + background: linear-gradient(145deg, #1e1e1e 0%, #1a1a1a 100%); + border-radius: 24rpx; padding: 40rpx; - margin-bottom: 20rpx; - box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1); + margin-bottom: 24rpx; + border: 2rpx solid rgba(255,255,255,0.06); text-align: center; } @@ -33,21 +33,24 @@ display: block; font-size: 36rpx; font-weight: bold; - color: #333; + color: #ffffff; margin-bottom: 10rpx; } .user-id { display: block; font-size: 24rpx; - color: #999; + color: #666666; margin-bottom: 10rpx; } .status-text { - display: block; - font-size: 24rpx; - color: #07c160; + display: inline-block; + font-size: 22rpx; + background-color: rgba(82,196,26,0.15); + color: #52c41a; + padding: 4rpx 16rpx; + border-radius: 12rpx; font-weight: 500; } @@ -56,7 +59,7 @@ display: flex; justify-content: space-around; padding-top: 30rpx; - border-top: 1rpx solid #f0f0f0; + border-top: 2rpx solid rgba(255,255,255,0.06); } .stat-item { @@ -66,32 +69,34 @@ } .stat-number { - font-size: 40rpx; + font-size: 44rpx; font-weight: bold; - color: #07c160; - margin-bottom: 10rpx; + color: #1677FF; + margin-bottom: 8rpx; } .stat-label { font-size: 24rpx; - color: #666; + color: #888888; } /* 菜单区域 */ .menu-section { - background: white; - border-radius: 20rpx; - margin-bottom: 20rpx; - box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1); + background: linear-gradient(180deg, #1a1a1a 0%, #161616 100%); + border-radius: 24rpx; + margin-bottom: 24rpx; overflow: hidden; + border: 2rpx solid rgba(255,255,255,0.06); } .menu-title { - font-size: 32rpx; - font-weight: bold; - color: #333; - padding: 30rpx 30rpx 20rpx; - border-bottom: 1rpx solid #f0f0f0; + font-size: 28rpx; + font-weight: 600; + color: #999999; + padding: 28rpx 32rpx 18rpx; + letter-spacing: 2rpx; + text-transform: uppercase; + font-size: 22rpx; } .menu-list { @@ -101,9 +106,9 @@ .menu-item { display: flex; align-items: center; - padding: 30rpx; - border-bottom: 1rpx solid #f0f0f0; - transition: background-color 0.3s; + padding: 28rpx 32rpx; + border-bottom: 2rpx solid rgba(255,255,255,0.04); + transition: all 0.25s ease; } .menu-item:last-child { @@ -111,45 +116,47 @@ } .menu-item:active { - background-color: #f8f8f8; + background-color: rgba(22,119,255,0.08); } .menu-icon { width: 40rpx; height: 40rpx; - margin-right: 20rpx; - opacity: 0.7; + margin-right: 24rpx; + opacity: 0.65; + filter: brightness(0) invert(1) opacity(0.7); } .menu-text { flex: 1; - font-size: 28rpx; - color: #333; + font-size: 29rpx; + color: #e5e5e5; } .menu-badge { - background: #dd524d; + background: linear-gradient(135deg, #ff4d4f, #cf1322); color: white; font-size: 20rpx; - padding: 4rpx 12rpx; - border-radius: 20rpx; + padding: 4rpx 14rpx; + border-radius: 16rpx; margin-right: 20rpx; min-width: 32rpx; text-align: center; + font-weight: 600; } .menu-arrow { - font-size: 32rpx; - color: #999; + font-size: 34rpx; + color: #555555; } /* 系统信息 */ .system-info { - background: white; - border-radius: 20rpx; - padding: 30rpx; - margin-bottom: 20rpx; - box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1); + background: linear-gradient(180deg, #1a1a1a 0%, #161616 100%); + border-radius: 24rpx; + padding: 30rpx 32rpx; + margin-bottom: 24rpx; + border: 2rpx solid rgba(255,255,255,0.06); } .info-item { @@ -157,7 +164,7 @@ justify-content: space-between; align-items: center; padding: 20rpx 0; - border-bottom: 1rpx solid #f0f0f0; + border-bottom: 2rpx solid rgba(255,255,255,0.04); } .info-item:last-child { @@ -165,13 +172,13 @@ } .info-label { - font-size: 28rpx; - color: #666; + font-size: 27rpx; + color: #888888; } .info-value { - font-size: 28rpx; - color: #333; + font-size: 27rpx; + color: #cccccc; font-weight: 500; } @@ -180,7 +187,7 @@ display: flex; gap: 20rpx; margin-top: auto; - padding-top: 40rpx; + padding-top: 30rpx; } .action-btn { @@ -188,36 +195,41 @@ display: flex; flex-direction: column; align-items: center; - padding: 30rpx 20rpx; + justify-content: center; + padding: 28rpx 16rpx; border: none; border-radius: 20rpx; font-size: 24rpx; transition: all 0.3s ease; min-height: 120rpx; + font-weight: 600; } .action-btn.primary { - background: linear-gradient(135deg, #07c160 0%, #06a050 100%); + background: linear-gradient(135deg, #1677FF 0%, #0958d9 100%); color: white; + box-shadow: 0 6rpx 20rpx rgba(22,119,255,0.3); } .action-btn.secondary { - background: linear-gradient(135deg, #17a2b8 0%, #138496 100%); - color: white; + background: linear-gradient(135deg, #2f2f2f 0%, #262626 100%); + color: #aaaaaa; + border: 2rpx solid rgba(255,255,255,0.08); } .action-btn.danger { - background: linear-gradient(135deg, #dc3545 0%, #c82333 100%); + background: linear-gradient(135deg, #ff4d4f 0%, #cf1322 100%); color: white; + box-shadow: 0 6rpx 20rpx rgba(255,77,79,0.3); } .action-btn:active { - transform: scale(0.98); + transform: scale(0.97); + opacity: 0.9; } .action-btn image { - width: 40rpx; - height: 40rpx; + width: 38rpx; + height: 38rpx; margin-bottom: 10rpx; - filter: brightness(0) invert(1); -} \ No newline at end of file +} diff --git a/Claw/client/wechat_app/project.config.json b/Claw/client/wechat_app/project.config.json index 11b31bf..60a6c21 100644 --- a/Claw/client/wechat_app/project.config.json +++ b/Claw/client/wechat_app/project.config.json @@ -22,7 +22,7 @@ "checkSiteMap": true, "uploadWithSourceMap": true, "compileHotReLoad": false, - "lazyloadPlaceholderEnable": false, + "lazyloadPlaceholderEnable": true, "useMultiFrameRuntime": true, "useApiHook": true, "useApiHostProcess": true, @@ -32,9 +32,9 @@ "outputPath": "" }, "enableEngineNative": false, - "useIsolateContext": true, - "userConfirmedBundleSwitch": false, - "packNpmManually": false, + "useIsolateContext": false, + "userConfirmedBundleSwitch": true, + "packNpmManually": true, "packNpmRelationList": [], "minifyWXSS": true, "disableUseStrict": false, diff --git a/Claw/client/wechat_app/utils/api.js b/Claw/client/wechat_app/utils/api.js index f3e9573..c4248c3 100644 --- a/Claw/client/wechat_app/utils/api.js +++ b/Claw/client/wechat_app/utils/api.js @@ -174,6 +174,11 @@ class WebSocketManager { console.log('收到WebSocket消息:', res.data) try { const data = JSON.parse(res.data) + // AI 消息打字机效果 + if (data.type === 'miniprogram_message_response' && data.data && data.data.content) { + const handlers = this.messageHandlers.get('ai_message') || [] + handlers.forEach(h => { try { h(data.data) } catch(e) { console.error(e) } }) + } this.handleMessage(data) } catch (e) { console.error('解析WebSocket消息失败:', e) @@ -357,7 +362,7 @@ const constants = { API_BASE: 'https://pactgo.cn/api/v1', // WebSocket地址 - WEBSOCKET_URL: 'wss://pactgo.cn/ws/task', + WEBSOCKET_URL: 'wss://pactgo.cn/api/v1/ws/miniprogram', // 任务状态 TASK_STATUS: { diff --git a/Claw/client/wechat_app/utils/constant.wxss b/Claw/client/wechat_app/utils/constant.wxss index a0c1e8c..5517dc5 100644 --- a/Claw/client/wechat_app/utils/constant.wxss +++ b/Claw/client/wechat_app/utils/constant.wxss @@ -1,18 +1,18 @@ -/* 常量样式 */ +/* 常量样式 - 深色主题 */ -/* 主题色 */ +/* 主题色 - 深色 */ :root { --primary-color: #1677FF; --primary-hover: #0e66e0; - --secondary-color: #f0f0f0; - --text-primary: #333333; - --text-secondary: #666666; - --text-light: #999999; - --background-light: #f7f7f7; - --border-color: #e0e0e0; - --success-color: #07c160; - --warning-color: #ff9500; - --error-color: #dd524d; + --secondary-color: #2a2a2a; + --text-primary: #ffffff; + --text-secondary: #aaaaaa; + --text-light: #666666; + --background-light: #1a1a1a; + --border-color: #252525; + --success-color: #52c41a; + --warning-color: #faad14; + --error-color: #ff4d4f; } /* 字体大小 */ @@ -37,101 +37,42 @@ } /* 间距 */ -.mt-1 { - margin-top: 10rpx; -} +.mt-1 { margin-top: 10rpx; } +.mt-2 { margin-top: 20rpx; } +.mt-3 { margin-top: 30rpx; } -.mt-2 { - margin-top: 20rpx; -} +.mb-1 { margin-bottom: 10rpx; } +.mb-2 { margin-bottom: 20rpx; } +.mb-3 { margin-bottom: 30rpx; } -.mt-3 { - margin-top: 30rpx; -} +.ml-1 { margin-left: 10rpx; } +.ml-2 { margin-left: 20rpx; } -.mb-1 { - margin-bottom: 10rpx; -} +.mr-1 { margin-right: 10rpx; } +.mr-2 { margin-right: 20rpx; } -.mb-2 { - margin-bottom: 20rpx; -} +/* 圆角 - 深色适配 */ +.rounded-sm { border-radius: 8rpx; } +.rounded-md { border-radius: 12rpx; } +.rounded-lg { border-radius: 16rpx; } +.rounded-full { border-radius: 9999rpx; } -.mb-3 { - margin-bottom: 30rpx; -} - -.ml-1 { - margin-left: 10rpx; -} - -.ml-2 { - margin-left: 20rpx; -} - -.mr-1 { - margin-right: 10rpx; -} - -.mr-2 { - margin-right: 20rpx; -} - -/* 圆角 */ -.rounded-sm { - border-radius: 8rpx; -} - -.rounded-md { - border-radius: 12rpx; -} - -.rounded-lg { - border-radius: 16rpx; -} - -.rounded-full { - border-radius: 9999rpx; -} - -/* 阴影 */ -.shadow-sm { - box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.05); -} - -.shadow-md { - box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1); -} - -.shadow-lg { - box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.15); -} +/* 阴影 - 深色适配 */ +.shadow-sm { box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.3); } +.shadow-md { box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.35); } +.shadow-lg { box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.45); } /* 动画 */ -.fade-in { - animation: fadeIn 0.3s ease-in-out; -} +.fade-in { animation: fadeIn 0.3s ease-in-out; } @keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } + from { opacity: 0; } + to { opacity: 1; } } -.slide-up { - animation: slideUp 0.3s ease-out; -} +.slide-up { animation: slideUp 0.3s ease-out; } @keyframes slideUp { - from { - transform: translateY(20rpx); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } + from { transform: translateY(20rpx); opacity: 0; } + to { transform: translateY(0); opacity: 1; } } diff --git a/Claw/docs/小程序网关SSE架构方案.md b/Claw/docs/小程序网关SSE架构方案.md new file mode 100644 index 0000000..58ddcfb --- /dev/null +++ b/Claw/docs/小程序网关SSE架构方案.md @@ -0,0 +1,477 @@ +# WechatApp ↔ 网关:SSE 代理方案 + +> WechatApp 通过 HTTP POST 发送消息,网关通过 WebSocket 将请求转发给 SmartClaw,SmartClaw 代为请求 LMStudio SSE 端点,再通过 WebSocket 将 SSE 流透传给网关,最终以 SSE 格式返回给 WechatApp。网关全程不直接连接 LMStudio。 + +--- + +## 一、整体架构 + +``` +┌──────────────┐ ┌──────────────┐ +│ │ POST /v1/chat/completions │ │ +│ WechatApp/浏览器 │ ────────────────────────────► │ Gateway │ +│ │ SSE Stream │ (Actix) │ +└──────────────┘ ◄───────────────────────────── │ │ + └──────┬───────┘ + │ WebSocket + ┌────────▼────────┐ + │ SmartClaw │ + │ (Rust 桌面端) │ + └────────┬────────┘ + │ SSE (HTTP) + ┌────────▼────────┐ + │ LMStudio │ + │ (内网) │ + └─────────────────┘ +``` + +**两条链路完全独立:** + +| 链路 | 协议 | 说明 | +|------|------|------| +| WechatApp → 网关 | HTTP POST + SSE | WechatApp 发请求,网关返回 SSE 流 | +| 网关 → SmartClaw | WebSocket | 网关透传消息,SmartClaw 代为请求 LMStudio | +| SmartClaw → LMStudio | HTTP SSE | SmartClaw 直连内网 LMStudio | +| SmartClaw → 网关 | WebSocket → SSE | LMStudio SSE 流透传回网关 | + +--- + +## 二、SSE 端点设计 + +### 2.1 端点 + +``` +POST /v1/chat/completions +``` + +标准 OpenAI 兼容格式,网关将其转发给 SmartClaw,由 SmartClaw 代为请求 LMStudio。 + +### 2.2 请求 + +``` +POST /v1/chat/completions +Content-Type: application/json + +{ + "model": "qwen2.5", + "messages": [ + {"role": "user", "content": "你好"} + ], + "stream": true +} +``` + +### 2.3 响应 + +``` +HTTP/1.1 200 OK +Content-Type: text/event-stream +X-Request-Id: req_xxx + +data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"你"}}]} +data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"好"}}]} +data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"!"}}]} +data: [DONE] +``` + +网关收到 SmartClaw 透传的 LMStudio SSE 流,原样返回给客户端,**不解析、不转换、不缓冲**。 + +### 2.4 状态码 + +| 状态码 | 含义 | +|--------|------| +| 200 | 正常 SSE 流 | +| 400 | 参数错误 | +| 502 | SmartClaw 不可达 | +| 504 | LMStudio 处理超时 | + +--- + +## 三、完整消息流 + +``` +1. WechatApp ──POST /v1/chat/completions──► 网关 +2. 网关 ──WebSocket (wechat_app_sse_request)──► SmartClaw +3. SmartClaw ──POST /v1/chat/completions──► LMStudio +4. LMStudio ──SSE Stream──► SmartClaw +5. SmartClaw ──WebSocket (sse_chunk)──► 网关 +6. 网关 ──SSE Stream──► WechatApp +``` + +### 3.1 WebSocket 消息格式 + +**网关 → SmartClaw(请求):** + +```json +{ + "type": "wechat_app_sse_request", + "request_id": "req_abc123", + "data": { + "model": "qwen2.5", + "messages": [ + {"role": "user", "content": "你好"} + ], + "stream": true + } +} +``` + +**SmartClaw → 网关(响应片段):** + +```json +{ + "type": "sse_chunk", + "request_id": "req_abc123", + "chunk": "data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\n\n" +} +``` + +**SmartClaw → 网关(结束):** + +```json +{ + "type": "sse_done", + "request_id": "req_abc123" +} +``` + +**SmartClaw → 网关(错误):** + +```json +{ + "type": "sse_error", + "request_id": "req_abc123", + "error": "LMStudio 不可达" +} +``` + +--- + +## 四、网关实现 + +### 4.1 Actix-web Handler + +```rust +// src/sse_proxy.rs + +use actix_web::{web, HttpRequest, HttpResponse}; +use tokio::sync::mpsc; +use futures_util::StreamExt; + +/// POST /v1/chat/completions +/// 网关将请求透传给 SmartClaw,由 SmartClaw 代为请求 LMStudio +pub async fn chat_completions( + req: HttpRequest, + body: web::Json, + pool: web::Data, +) -> HttpResponse { + let request_id = uuid::Uuid::new_v4().to_string(); + let body = body.into_inner(); + + // 1. 验证 stream 参数(必须为 true) + if !body.stream { + return HttpResponse::BadRequest().json(serde_json::json!({ + "error": { "message": "stream must be true", "type": "invalid_request_error" } + })); + } + + // 2. 构建转发给 SmartClaw 的 WebSocket 消息 + let forward = serde_json::json!({ + "type": "wechat_app_sse_request", + "request_id": request_id, + "data": body, + }); + + // 3. 创建 SSE channel,等待 SmartClaw 响应 + let (client_tx, server_rx) = mpsc::channel::>(1024); + + // 4. 注册请求到待响应映射 + let sse_manager = app_data.sse_manager.clone(); + sse_manager.add(request_id.clone(), client_tx).await; + + // 5. 发给 SmartClaw + if let Err(e) = pool.broadcast_to_smartclaw(forward).await { + sse_manager.remove(&request_id).await; + return HttpResponse::BadGateway().json(serde_json::json!({ + "error": { "message": "SmartClaw 不可用", "type": "bad_gateway" } + })); + } + + // 6. 将 SmartClaw 的 WebSocket 消息转换为 SSE 流返回 + let stream = server_rx.map(|chunk| Ok::<_, std::convert::Infallible>(actix_web::web::Bytes::from(chunk))); + + HttpResponse::Ok() + .content_type("text/event-stream") + .append_header(("Cache-Control", "no-cache")) + .append_header(("X-Request-Id", &request_id)) + .streaming(stream) +} +``` + +### 4.2 接收 SmartClaw 的 SSE 片段 + +SmartClaw 通过 `/api/v1/ws/control` WebSocket 通道发回 SSE 片段,网关需要处理 `sse_chunk` 类型: + +```rust +// src/main.rs — WebSocket on_message handler 中 + +Some("sse_chunk") => { + let request_id = parsed + .get("request_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let chunk = parsed + .get("chunk") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(tx) = sse_manager.get(request_id).await { + let _ = tx.send(chunk.as_bytes().to_vec()).await; + } +} + +Some("sse_done") => { + let request_id = parsed + .get("request_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + sse_manager.remove(request_id).await; +} + +Some("sse_error") => { + let request_id = parsed + .get("request_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let error_msg = parsed.get("error") + .and_then(|v| v.as_str()) + .unwrap_or("未知错误"); + + if let Some(tx) = sse_manager.get(request_id).await { + let _ = tx.send(format!("data: {{\"error\": \"{}\"}}\n\n", error_msg).into_bytes()).await; + sse_manager.remove(request_id).await; + } +} +``` + +### 4.3 SSE Manager + +```rust +// src/sse_manager.rs + +use tokio::sync::mpsc; +use std::collections::HashMap; + +/// 管理所有活跃的 SSE 请求,等待 SmartClaw 的 WebSocket 响应 +pub struct SseManager { + pending: RwLock>>), +} + +impl SseManager { + pub fn new() -> Self { + Self { pending: RwLock::new(HashMap::new()).into() } + } + + /// 注册一个 SSE 请求,关联其响应 channel + pub async fn add(&self, request_id: String, tx: mpsc::Sender>) { + self.pending.write().await.insert(request_id, tx); + } + + /// 根据 request_id 获取对应的响应 channel + pub async fn get(&self, request_id: &str) -> Option>> { + self.pending.read().await.get(request_id).cloned() + } + + /// 移除完成的请求 + pub async fn remove(&self, request_id: &str) { + self.pending.write().await.remove(request_id); + } +} +``` + +--- + +## 五、SmartClaw 侧实现 + +SmartClaw 收到 `wechat_app_sse_request` 后: + +1. 提取 `data` 字段(OpenAI 格式请求体) +2. 发送 HTTP POST 到 `http://<内网LMStudio>:1234/v1/chat/completions` +3. 逐块读取 SSE 响应,每块通过 WebSocket 发回 `sse_chunk` +4. 结束时发 `sse_done` + +```rust +// SmartClaw/src/sse_forwarder.rs + +async fn handle_sse_request(pool: WebSocketPool, request_id: String, req: ChatCompletionsRequest) { + let client = reqwest::Client::new(); + let lmstudio_url = "http://10.0.0.100:1234/v1/chat/completions"; + + let resp = match client.post(lmstudio_url) + .json(&req) + .send() + .await + { + Ok(r) => r, + Err(e) => { + let err_msg = serde_json::json!({ + "type": "sse_error", + "request_id": request_id, + "error": e.to_string() + }); + let _ = pool.broadcast_to_control(err_msg).await; + return; + } + }; + + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + let msg = serde_json::json!({ + "type": "sse_chunk", + "request_id": request_id, + "chunk": String::from_utf8_lossy(&bytes) + }); + let _ = pool.broadcast_to_control(msg).await; + } + Err(e) => { + let err_msg = serde_json::json!({ + "type": "sse_error", + "request_id": request_id, + "error": e.to_string() + }); + let _ = pool.broadcast_to_control(err_msg).await; + break; + } + } + } + + let done = serde_json::json!({ + "type": "sse_done", + "request_id": request_id + }); + let _ = pool.broadcast_to_control(done).await; +} +``` + +--- + +## 六、WechatApp 客户端 + +### 6.1 WechatApp + +```javascript +// utils/api.js + +/** + * 发送聊天消息(SSE) + * @param {string} content - 消息内容 + * @returns {Promise} 完整回复 + */ +async function sendMessage(content) { + const response = await wx.request({ + url: 'https://pactgo.cn/v1/chat/completions', + method: 'POST', + header: { + 'Content-Type': 'application/json', + }, + data: { + model: 'qwen2.5', + messages: [{ role: 'user', content }], + stream: true, + }, + }); + + if (response.statusCode === 200 && response.data) { + return response.data; // WechatApp:等完整响应 + } else { + throw new Error(`请求失败: ${response.statusCode}`); + } +} + +// pages/chat/chat.js +async function onSend() { + const content = inputValue; + if (!content.trim()) return; + + appendMessage('user', content); + inputValue = ''; + + try { + const reply = await sendMessage(content); + appendMessage('assistant', reply); + } catch (err) { + appendMessage('assistant', `错误: ${err.message}`); + } +} +``` + +### 6.2 浏览器 / H5(有打字机效果) + +```javascript +async function sendMessage(content) { + const response = await fetch('https://pactgo.cn/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'qwen2.5', + messages: [{ role: 'user', content }], + stream: true, + }), + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value); + text.split('\n').forEach(line => { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') return; + try { + const json = JSON.parse(data); + const token = json.choices?.[0]?.delta?.content; + if (token) appendToken(token); // 实时打字机效果 + } catch {} + } + }); + } +} +``` + +--- + +## 七、与设备 WebSocket 链路的区别 + +| | WechatApp SSE 链路 | 设备 WebSocket 链路 | +|--|---------------|-----------------| +| 发起方 | WechatApp POST HTTP | 设备主动连接 WebSocket | +| 网关 → LMStudio | 经由 SmartClaw | SmartClaw 自行处理 | +| SSE 流 | WechatApp ← 网关 ← SmartClaw ← LMStudio | 不涉及 | +| 设备响应 | 无(HTTP 请求/响应) | WebSocket JSON 双工 | +| 会话管理 | 无(网关无状态,SSEManager 仅管理响应 channel)| SmartClaw 管理 | + +--- + +## 八、文件结构 + +``` +gateway/src/ +├── main.rs # WebSocket handler,处理 sse_chunk/sse_done/sse_error +├── communication.rs # SmartClaw WebSocket(broadcast_to_control) +├── sse_proxy.rs # [新增] POST /v1/chat/completions handler +├── sse_manager.rs # [新增] 待响应 request_id → mpsc::Sender 映射 +└── ... + +SmartClaw/src/ +├── main.rs # [修改] 处理 wechat_app_sse_request 消息类型 +└── sse_forwarder.rs # [新增] SSE 请求转发给 LMStudio +``` \ No newline at end of file