From 1b772f45341d4c7d43f2db747edd466dac894bb7 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 28 Jul 2026 15:53:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=BB=A3=E7=A0=81=E5=9D=97=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=AF=AD=E6=B3=95=E9=AB=98=E4=BA=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将配置片段 DTO 从扁平字段重构为 snippets Vec(含 content/content_html), 高亮在服务端经 highlight_code 完成(JSON 配置用 json 语法,CLI 用 bash), 前端以 dangerous_inner_html 渲染并包 .md-content 匹配 highlight.css 作用域。 补 json/bash 语法解析测试,确认两者均产出彩色 span 而非纯文本回退。 --- src/api/mcp_tokens.rs | 76 +++++++++++++++++++++++++----------------- src/highlight.rs | 32 ++++++++++++++++++ src/pages/admin/mcp.rs | 28 ++++++++-------- 3 files changed, 93 insertions(+), 43 deletions(-) diff --git a/src/api/mcp_tokens.rs b/src/api/mcp_tokens.rs index 6b5ea7f..173276f 100644 --- a/src/api/mcp_tokens.rs +++ b/src/api/mcp_tokens.rs @@ -262,50 +262,66 @@ fn row_to_mcp_token_meta(row: &tokio_postgres::Row) -> McpToken { } } -/// 4 种客户端配置 + CLI 一行命令的序列化 DTO。 +/// 单个客户端配置片段:标题 + 原始文本(供复制)+ syntect 高亮 HTML(供展示)。 /// -/// 由 `get_mcp_client_configs` server fn 返回。`ClientConfigs`(在 `src/mcp/config.rs`) -/// 是 server-only(`mcp` 模块整体 `#[cfg(feature = "server")]` 门控),这里复制字段 -/// 为可两端共享的 DTO,让 WASM 前端能经 server fn 拿到配置字符串。 -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct McpClientConfigs { - /// Claude Code(`.mcp.json` / `~/.claude.json`)。`type: "http"`(非 `streamable-http`)。 - pub claude_code_json: String, - /// Cursor(`~/.cursor/mcp.json`)。不带 `type` 字段,按 URL 自动识别 streamable-http。 - pub cursor_json: String, - /// Cline(`cline_mcp_settings.json`)。 - pub cline_json: String, - /// Oh-My-Pi(`~/.pi/agent/mcp.json` / `.pi/mcp.json`)。字段名 `transport`,非 `type`。 - pub omp_json: String, - /// 通用原始 JSON(单 server entry)。 - pub generic_json: String, - /// Claude Code CLI 一行命令。 - pub claude_cli: String, +/// 高亮 HTML 由 `crate::highlight::server::highlight_code` 生成(spaced CSS class 风格, +/// 配合 `public/highlight.css`);前端需将其置于 `.md-content pre code` 作用域下, +/// 否则高亮 CSS 选择器不匹配(见 `src/bin/generate_highlight_css.rs` 的 base 重写)。 +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct McpConfigSnippet { + /// 显示标题(含客户端名与目标文件路径)。 + pub title: String, + /// 原始配置文本(供「复制」按钮复制,未高亮)。 + pub content: String, + /// syntect 高亮后的 HTML(`` 序列,无 `
/` 外壳)。
+    pub content_html: String,
 }
 
-/// 根据明文令牌生成 4 种客户端配置 + CLI 一行命令。
+/// 各客户端 MCP 配置片段集合。
 ///
-/// 配置生成在服务端完成(`crate::mcp::config` 是 server-only 模块),返回给前端展示。
-/// `APP_BASE_URL` 环境变量也只在服务端读取。仅 admin。
+/// 由 `get_mcp_client_configs` server fn 返回。`ClientConfigs`(在 `src/mcp/config.rs`)
+/// 是 server-only(`mcp` 模块整体 `#[cfg(feature = "server")]` 门控);这里把每个片段的
+/// 原始文本与高亮 HTML 打包为可两端共享的 DTO,让 WASM 前端单次请求即可渲染带高亮的配置块。
+#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
+pub struct McpClientConfigs {
+    /// 有序配置片段(标题、原始文本、高亮 HTML)。
+    pub snippets: Vec,
+}
+
+/// 根据明文令牌生成各客户端配置片段(含 syntect 高亮 HTML)。
+///
+/// 配置生成与高亮均在服务端完成(`crate::mcp::config` / `crate::highlight` 均为
+/// server-only),返回给前端展示。`APP_BASE_URL` 环境变量也只在服务端读取。仅 admin。
 #[server]
 pub async fn get_mcp_client_configs(token: String) -> Result {
     #[cfg(feature = "server")]
     {
         use crate::api::auth::get_current_admin_user;
+        use crate::highlight::server::highlight_code;
 
         let _admin = get_current_admin_user().await?;
-        let configs = crate::mcp::config::generate_client_configs(
+        let c = crate::mcp::config::generate_client_configs(
             &crate::mcp::config::base_url_from_env(),
             &token,
         );
-        Ok(McpClientConfigs {
-            claude_code_json: configs.claude_code_json,
-            cursor_json: configs.cursor_json,
-            cline_json: configs.cline_json,
-            omp_json: configs.omp_json,
-            generic_json: configs.generic_json,
-            claude_cli: configs.claude_cli,
-        })
+        // (标题, 内容, 语言):JSON 配置用 json 语法高亮,CLI 一行命令用 bash。
+        let entries: [(&str, String, &str); 6] = [
+            ("Claude Code(.mcp.json / ~/.claude.json)", c.claude_code_json, "json"),
+            ("Cursor(~/.cursor/mcp.json)", c.cursor_json, "json"),
+            ("Cline(cline_mcp_settings.json)", c.cline_json, "json"),
+            ("Oh-My-Pi(~/.pi/agent/mcp.json 或 .pi/mcp.json)", c.omp_json, "json"),
+            ("通用(单 server entry)", c.generic_json, "json"),
+            ("Claude Code CLI", c.claude_cli, "bash"),
+        ];
+        let snippets = entries
+            .into_iter()
+            .map(|(title, content, lang)| McpConfigSnippet {
+                title: title.to_string(),
+                content_html: highlight_code(&content, Some(lang)),
+                content,
+            })
+            .collect();
+        Ok(McpClientConfigs { snippets })
     }
     #[cfg(not(feature = "server"))]
     unreachable!()
diff --git a/src/highlight.rs b/src/highlight.rs
index d1c5e8a..ccf1383 100644
--- a/src/highlight.rs
+++ b/src/highlight.rs
@@ -508,4 +508,36 @@ const message = ref('Hello Vue!')
         let by_ts = highlight_code(code, Some("ts"));
         assert_eq!(result, by_ts);
     }
+
+    #[test]
+    fn highlight_code_json_produces_spans() {
+        // MCP 客户端配置页的 JSON 片段经此路径高亮;验证 json 解析为彩色语法而非纯文本。
+        let code = r#"{"mcpServers": {"yggdrasil": {"type": "http"}}}"#;
+        let result = highlight_code(code, Some("json"));
+        assert!(
+            result.contains(""#),
+            "json 不应回退纯文本: {}",
+            result
+        );
+    }
+
+    #[test]
+    fn highlight_code_bash_alias_produces_spans() {
+        // MCP 客户端配置页的 CLI 一行命令经 bash 别名高亮。
+        let code = "claude mcp add --transport http yggdrasil https://example.com/mcp";
+        let result = highlight_code(code, Some("bash"));
+        assert!(
+            result.contains(" "sh")。
+        let by_sh = highlight_code(code, Some("sh"));
+        assert_eq!(result, by_sh);
+    }
 }
diff --git a/src/pages/admin/mcp.rs b/src/pages/admin/mcp.rs
index e1f4549..f111497 100644
--- a/src/pages/admin/mcp.rs
+++ b/src/pages/admin/mcp.rs
@@ -18,7 +18,7 @@ use dioxus::prelude::*;
 #[cfg(target_arch = "wasm32")]
 use crate::api::mcp_tokens::{
     create_mcp_token, get_mcp_client_configs, list_mcp_tokens, reveal_mcp_token, revoke_mcp_token,
-    McpClientConfigs, TokenLifetime,
+    McpClientConfigs, McpConfigSnippet, TokenLifetime,
 };
 #[cfg(target_arch = "wasm32")]
 use crate::components::forms::{FormSelect, INPUT_CLASS};
@@ -503,12 +503,9 @@ fn ConfigCard() -> Element {
                 p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center", "生成配置中…" }
             } else if let Some(c) = configs() {
                 div { class: "flex flex-col gap-4",
-                    ConfigSnippet { title: "Claude Code(.mcp.json / ~/.claude.json)".to_string(), content: c.claude_code_json.clone() }
-                    ConfigSnippet { title: "Cursor(~/.cursor/mcp.json)".to_string(), content: c.cursor_json.clone() }
-                    ConfigSnippet { title: "Cline(cline_mcp_settings.json)".to_string(), content: c.cline_json.clone() }
-                    ConfigSnippet { title: "Oh-My-Pi(~/.pi/agent/mcp.json 或 .pi/mcp.json)".to_string(), content: c.omp_json.clone() }
-                    ConfigSnippet { title: "通用(单 server entry)".to_string(), content: c.generic_json.clone() }
-                    ConfigSnippet { title: "Claude Code CLI".to_string(), content: c.claude_cli.clone() }
+                    for snippet in c.snippets.iter() {
+                        ConfigSnippet { key: "{snippet.title}", snippet: snippet.clone() }
+                    }
                 }
             } else {
                 p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center",
@@ -522,17 +519,18 @@ fn ConfigCard() -> Element {
 /// 单个配置片段卡片(标题 + 代码块 + 复制按钮)。
 #[cfg(target_arch = "wasm32")]
 #[component]
-fn ConfigSnippet(title: String, content: String) -> Element {
+fn ConfigSnippet(snippet: McpConfigSnippet) -> Element {
     let state: McpPageState = use_context();
     let mut toast = state.toast;
+    let title = snippet.title.clone();
     rsx! {
         div { class: "flex flex-col gap-2",
             div { class: "flex items-center justify-between",
-                span { class: "text-sm font-medium text-[var(--color-paper-primary)]", "{title}" }
+                span { class: "text-sm font-medium text-[var(--color-paper-primary)]", "{snippet.title}" }
                 button {
                     class: "{BTN_PRIMARY_SM}",
                     onclick: move |_| {
-                        let cc = content.clone();
+                        let cc = snippet.content.clone();
                         let tt = title.clone();
                         spawn(async move {
                             copy_clipboard_wasm(&cc).await;
@@ -542,9 +540,13 @@ fn ConfigSnippet(title: String, content: String) -> Element {
                     "复制"
                 }
             }
-            pre {
-                class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-xs overflow-x-auto font-mono",
-                code { "{content}" }
+            // .md-content 是 highlight.css 的作用域钩子(.md-content pre code …);
+            // 仅包裹 pre,不触及标题/按钮,避免 prose 排式泄漏。
+            div { class: "md-content",
+                pre {
+                    class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-xs overflow-x-auto font-mono",
+                    code { dangerous_inner_html: "{snippet.content_html}" }
+                }
             }
         }
     }