Skip to main content

yggdrasil/pages/admin/
mcp.rs

1//! 管理后台「MCP 服务器」页面。
2//!
3//! 管理员在此签发/查看/撤销为 AI 客户端(Claude Code / Cursor / Cline)准备的
4//! bearer 令牌,并复制对应的客户端配置片段。功能分三块:
5//! - 令牌列表:名称 / 作用域 / 创建时间 / 过期 / 最近使用 / 状态 + 撤销 / 重查按钮。
6//! - 新建令牌表单:名称 + 作用域下拉 + 有效期下拉;提交后一次性弹窗展示明文。
7//! - 客户端配置:选中令牌后展示 4 种可复制的配置片段 + CLI 一行命令。
8//!
9//! 仅 WASM 前端交互,数据经 Dioxus server functions(`src/api/mcp_tokens.rs`)加载。
10//! 配置片段由服务端生成(`src/mcp/config.rs`,server-only 模块),经 `get_mcp_client_configs`
11//! server fn 返回——`APP_BASE_URL` 等环境变量只在服务端可读。
12//!
13//! 跨子组件状态(刷新触发、一次性明文弹窗、配置令牌)经一个共享 context 传递,
14//! 避免列表与表单组件各自维护互相不可见的信号。
15
16use dioxus::prelude::*;
17
18#[cfg(target_arch = "wasm32")]
19use crate::api::mcp_tokens::{
20    create_mcp_token, get_mcp_client_configs, list_mcp_tokens, reveal_mcp_token, revoke_mcp_token,
21    McpClientConfigs, McpConfigSnippet, TokenLifetime,
22};
23#[cfg(target_arch = "wasm32")]
24use crate::components::forms::{FormInput, FormSelect};
25#[cfg(target_arch = "wasm32")]
26use crate::components::skeletons::atoms::SkeletonBox;
27#[cfg(target_arch = "wasm32")]
28use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
29#[cfg(target_arch = "wasm32")]
30use crate::components::ui::{
31    ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS, BADGE_BASE, BTN_PRIMARY, BTN_PRIMARY_SM, BTN_TEXT_RED,
32};
33#[cfg(target_arch = "wasm32")]
34use crate::models::mcp_token::{McpTokenSummary, TokenScope};
35
36/// 作用域可选项(下拉菜单)。
37#[cfg(target_arch = "wasm32")]
38const SCOPE_OPTIONS: &[(TokenScope, &str)] = &[
39    (TokenScope::Read, "read — 仅查询已发布文章"),
40    (TokenScope::Write, "write — read + 文章/评论/标签/媒体 CRUD"),
41    (TokenScope::Admin, "admin — write + 站点设置 / 代码运行器"),
42];
43
44/// 有效期可选项(下拉菜单)。
45#[cfg(target_arch = "wasm32")]
46const LIFETIME_OPTIONS: &[(TokenLifetime, &str)] = &[
47    (TokenLifetime::Days1, "1 天"),
48    (TokenLifetime::Days7, "7 天"),
49    (TokenLifetime::Days30, "30 天"),
50    (TokenLifetime::Days90, "90 天"),
51    (TokenLifetime::Never, "永不过期"),
52];
53
54/// 配置骨架屏的尺寸变化:(标题宽度 px, 代码块高度 px)。
55///
56/// 按 7 个真实配置片段的长短差异给出不同占位尺寸,让骨架屏更像即将出现的真实内容
57/// 而非千篇一律的等高块。
58#[cfg(target_arch = "wasm32")]
59const CONFIG_SKELETON_SHAPES: &[(&str, &str)] = &[
60    ("width: 320px;", "height: 152px;"), // Oh-My-Pi JSON
61    ("width: 280px;", "height: 168px;"), // OpenCode JSON
62    ("width: 260px;", "height: 168px;"), // Claude Code JSON
63    ("width: 200px;", "height: 136px;"), // Cursor JSON
64    ("width: 220px;", "height: 168px;"), // Cline JSON
65    ("width: 160px;", "height: 104px;"), // 通用 JSON
66    ("width: 140px;", "height: 56px;"),  // CLI 一行命令
67];
68
69/// 跨子组件共享的页面状态:刷新代际、一次性明文弹窗、配置用令牌、操作提示。
70///
71/// `PartialEq` 由组件宏生成的 Props 结构体要求(`TokenRow` 以此为 prop)。
72/// `Signal<T>` 实现了 `PartialEq`(比较当前值),故派生可行。
73#[cfg(target_arch = "wasm32")]
74#[derive(Clone, Copy, PartialEq)]
75struct McpPageState {
76    /// 递增以触发令牌列表重新加载(创建/撤销后 +1)。
77    reload_gen: Signal<u32>,
78    /// 一次性明文弹窗:Some 时展示。
79    created_plaintext: Signal<Option<String>>,
80    /// 重查明文弹窗:(id, plaintext)。
81    revealed: Signal<Option<(String, String)>>,
82    /// 配置卡片使用的令牌明文。
83    config_token: Signal<Option<String>>,
84    /// 全局操作提示:(消息, 是否错误)。
85    toast: Signal<Option<(String, bool)>>,
86}
87
88/// 管理后台 MCP 令牌管理页面。
89#[component]
90#[allow(clippy::needless_pass_by_value)]
91pub fn Mcp() -> Element {
92    #[cfg(target_arch = "wasm32")]
93    {
94        let state = McpPageState {
95            reload_gen: use_signal(|| 0),
96            created_plaintext: use_signal(|| None),
97            revealed: use_signal(|| None),
98            config_token: use_signal(|| None),
99            toast: use_signal(|| None),
100        };
101        use_context_provider(|| state);
102
103        rsx! {
104            div { class: "w-full max-w-7xl mx-auto space-y-8",
105                PageHeader {}
106                Toast {}
107                TokenList {}
108                CreateTokenCard {}
109                ConfigCard {}
110            }
111        }
112    }
113    #[cfg(not(target_arch = "wasm32"))]
114    {
115        // server 构建下页面无前端交互;路由实际只在 WASM 渲染。
116        rsx! {
117            p { class: "text-paper-secondary", "此页面仅在浏览器中可用。" }
118            // server 构建下页面无前端交互;路由实际只在 WASM 渲染。
119        }
120    }
121}
122
123/// 页头标题区。
124#[component]
125fn PageHeader() -> Element {
126    rsx! {
127        div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-8 border-b border-[var(--color-paper-border)]/50",
128            div {
129                h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
130                    "MCP 服务器"
131                }
132                p { class: "text-base text-[var(--color-paper-secondary)] mt-2",
133                    "为 AI 客户端(Claude Code / Cursor / Cline)签发访问令牌,并复制接入配置。"
134                }
135            }
136        }
137    }
138}
139
140/// 全局操作提示条(读取共享 context 的 toast)。
141#[cfg(target_arch = "wasm32")]
142#[component]
143fn Toast() -> Element {
144    let state: McpPageState = use_context();
145    let toast = state.toast;
146    if let Some((msg, is_err)) = toast() {
147        let cls = if is_err {
148            "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300"
149        } else {
150            "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
151        };
152        rsx! {
153            div { class: "text-sm rounded-lg px-3 py-2 {cls}", "{msg}" }
154        }
155    } else {
156        rsx! {}
157    }
158}
159
160/// 令牌列表卡片 + 一次性明文弹窗 + 撤销/重查操作。
161#[cfg(target_arch = "wasm32")]
162#[component]
163fn TokenList() -> Element {
164    let mut state: McpPageState = use_context();
165    let mut tokens = use_signal(|| Vec::<McpTokenSummary>::new());
166    let mut loaded_gen = use_signal(|| None::<u32>);
167    let mut loading = use_signal(|| true);
168
169    let reload_gen = state.reload_gen;
170    let mut toast = state.toast;
171
172    // reload_gen 变化时重新加载。
173    use_effect(move || {
174        let g = reload_gen();
175        if loaded_gen() != Some(g) {
176            loaded_gen.set(Some(g));
177            spawn(async move {
178                match list_mcp_tokens().await {
179                    Ok(list) => tokens.set(list),
180                    Err(e) => toast.set(Some((format!("加载失败:{e}"), true))),
181                }
182                loading.set(false);
183            });
184        }
185    });
186
187    let mut created_plaintext = state.created_plaintext;
188    let mut revealed = state.revealed;
189
190    rsx! {
191        div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-6",
192            div { class: "flex items-center justify-between",
193                h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]",
194                    "令牌列表"
195                }
196                button {
197                    class: "text-xs text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
198                    onclick: move |_| {
199                        let g = reload_gen();
200                        state.reload_gen.set(g + 1);
201                    },
202                    "刷新"
203                }
204            }
205
206            // 一次性明文弹窗(签发后立即展示)
207            if let Some(plaintext) = created_plaintext() {
208                PlaintextModal {
209                    title: "令牌已创建(请立即复制,可稍后重新查看)".to_string(),
210                    plaintext: plaintext.clone(),
211                    on_use_config: {
212                        let mut ct = state.config_token;
213                        let mut cp = state.created_plaintext;
214                        let mut toast = state.toast;
215                        move |_| {
216                            ct.set(Some(plaintext.clone()));
217                            cp.set(None);
218                            toast.set(Some(("已选为配置令牌".to_string(), false)));
219                        }
220                    },
221                    on_close: move |_| created_plaintext.set(None),
222                }
223            }
224
225            // 重查明文弹窗
226            if let Some((_, plaintext)) = revealed() {
227                PlaintextModal {
228                    title: "令牌明文".to_string(),
229                    plaintext: plaintext.clone(),
230                    on_use_config: {
231                        let mut ct = state.config_token;
232                        let mut rev = state.revealed;
233                        let mut toast = state.toast;
234                        move |_| {
235                            ct.set(Some(plaintext.clone()));
236                            rev.set(None);
237                            toast.set(Some(("已选为配置令牌".to_string(), false)));
238                        }
239                    },
240                    on_close: move |_| revealed.set(None),
241                }
242            }
243
244            // 表格区:首次加载(loading 且无数据)显示骨架屏,加载后无数据显示空态,否则显示表格。
245            if loading() && tokens().is_empty() {
246                DelayedSkeleton {
247                    div { class: "{ADMIN_TABLE_CLASS}",
248                        table { class: "w-full text-sm",
249                            thead {
250                                tr { class: "bg-[var(--color-paper-theme)]/50",
251                                    th { class: "px-4 py-3",
252                                        SkeletonBox { class: "h-3 w-10" }
253                                    }
254                                    th { class: "px-4 py-3",
255                                        SkeletonBox { class: "h-3 w-8" }
256                                    }
257                                    th { class: "px-4 py-3",
258                                        SkeletonBox { class: "h-3 w-8" }
259                                    }
260                                    th { class: "px-4 py-3",
261                                        SkeletonBox { class: "h-3 w-8" }
262                                    }
263                                    th { class: "px-4 py-3",
264                                        SkeletonBox { class: "h-3 w-12" }
265                                    }
266                                    th { class: "px-4 py-3",
267                                        SkeletonBox { class: "h-3 w-8" }
268                                    }
269                                    th { class: "px-4 py-3",
270                                        SkeletonBox { class: "h-3 w-10 ml-auto" }
271                                    }
272                                }
273                            }
274                            tbody {
275                                for _ in 0..4 {
276                                    tr { class: "border-b border-[var(--color-paper-border)] last:border-b-0",
277                                        td { class: "px-4 py-3",
278                                            SkeletonBox { class: "h-4 w-28" }
279                                        }
280                                        td { class: "px-4 py-3",
281                                            SkeletonBox { class: "h-4 w-12" }
282                                        }
283                                        td { class: "px-4 py-3",
284                                            SkeletonBox { class: "h-4 w-16" }
285                                        }
286                                        td { class: "px-4 py-3",
287                                            SkeletonBox { class: "h-4 w-16" }
288                                        }
289                                        td { class: "px-4 py-3",
290                                            SkeletonBox { class: "h-4 w-20" }
291                                        }
292                                        td { class: "px-4 py-3",
293                                            SkeletonBox { class: "h-5 w-10 rounded-full" }
294                                        }
295                                        td { class: "px-4 py-3",
296                                            SkeletonBox { class: "h-4 w-24 ml-auto" }
297                                        }
298                                    }
299                                }
300                            }
301                        }
302                    }
303                }
304            } else if tokens().is_empty() {
305                p { class: "text-[var(--color-paper-secondary)] text-sm py-4 text-center",
306                    "暂无令牌。在下方新建一个。"
307                }
308            } else {
309                div { class: "{ADMIN_TABLE_CLASS}",
310                    table { class: "w-full text-sm",
311                        thead {
312                            tr { class: "bg-[var(--color-paper-theme)]/50 text-left text-[var(--color-paper-secondary)]",
313                                th { class: "px-4 py-3 font-medium", "名称" }
314                                th { class: "px-4 py-3 font-medium", "作用域" }
315                                th { class: "px-4 py-3 font-medium whitespace-nowrap",
316                                    "创建"
317                                }
318                                th { class: "px-4 py-3 font-medium whitespace-nowrap",
319                                    "过期"
320                                }
321                                th { class: "px-4 py-3 font-medium whitespace-nowrap",
322                                    "最近使用"
323                                }
324                                th { class: "px-4 py-3 font-medium whitespace-nowrap",
325                                    "状态"
326                                }
327                                th { class: "px-4 py-3 font-medium text-right", "操作" }
328                            }
329                        }
330                        tbody {
331                            for t in tokens().iter() {
332                                TokenRow {
333                                    key: "{t.id}",
334                                    token: t.clone(),
335                                    state,
336                                }
337                            }
338                        }
339                    }
340                }
341            }
342        }
343    }
344}
345
346/// 单行令牌:展示元数据 + 重查/用于配置/撤销按钮。
347#[cfg(target_arch = "wasm32")]
348#[component]
349fn TokenRow(token: McpTokenSummary, state: McpPageState) -> Element {
350    let is_revoked = token.revoked_at.is_some();
351    let is_expired = token
352        .expires_at
353        .map(|e| e < chrono::Utc::now())
354        .unwrap_or(false);
355    let active = !is_revoked && !is_expired;
356    let created = token.created_at.format("%Y-%m-%d").to_string();
357    let expires = token
358        .expires_at
359        .map(|e| e.format("%Y-%m-%d").to_string())
360        .unwrap_or_else(|| "永不过期".to_string());
361    let last_used = token
362        .last_used_at
363        .map(|e| e.format("%Y-%m-%d %H:%M").to_string())
364        .unwrap_or_else(|| "—".to_string());
365    let status_label = if is_revoked {
366        "已撤销"
367    } else if is_expired {
368        "已过期"
369    } else {
370        "有效"
371    };
372    let status_class = if active {
373        "bg-green-500/10 text-green-600 dark:text-green-400"
374    } else {
375        "bg-gray-500/10 text-gray-500 dark:text-gray-400"
376    };
377
378    let id_reveal = token.id.clone();
379    let id_config = token.id.clone();
380    let id_revoke = token.id.clone();
381
382    // 提取信号到局部,避免 `state.field()` 被解析为方法调用。
383    let mut revealed = state.revealed;
384    let mut toast = state.toast;
385    let mut config_token = state.config_token;
386    let reload_gen = state.reload_gen;
387
388    rsx! {
389        tr { class: "border-b border-[var(--color-paper-border)] last:border-b-0 hover:bg-[var(--color-paper-theme)]/30 transition-colors",
390            td { class: "px-4 py-3 font-medium text-[var(--color-paper-primary)]",
391                "{token.name}"
392            }
393            td { class: "px-4 py-3", "{token.scope.as_str()}" }
394            td { class: "px-4 py-3 text-[var(--color-paper-secondary)] whitespace-nowrap",
395                "{created}"
396            }
397            td { class: "px-4 py-3 text-[var(--color-paper-secondary)] whitespace-nowrap",
398                "{expires}"
399            }
400            td { class: "px-4 py-3 text-[var(--color-paper-secondary)] whitespace-nowrap",
401                "{last_used}"
402            }
403            td { class: "px-4 py-3 whitespace-nowrap",
404                span { class: "{BADGE_BASE} {status_class}", "{status_label}" }
405            }
406            td { class: "px-4 py-3 text-right whitespace-nowrap",
407                if active {
408                    button {
409                        class: "text-xs text-[var(--color-paper-accent)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer mr-3",
410                        onclick: move |_| {
411                            let id = id_reveal.clone();
412                            spawn(async move {
413                                match reveal_mcp_token(id.clone()).await {
414                                    Ok(Some(p)) => revealed.set(Some((id, p))),
415                                    Ok(None) => toast.set(Some(("无法解密该令牌".to_string(), true))),
416                                    Err(e) => toast.set(Some((format!("重查失败:{e}"), true))),
417                                }
418                            });
419                        },
420                        "重新查看"
421                    }
422                    button {
423                        class: "text-xs text-[var(--color-paper-accent)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer mr-3",
424                        onclick: move |_| {
425                            let id = id_config.clone();
426                            spawn(async move {
427                                match reveal_mcp_token(id).await {
428                                    Ok(Some(p)) => {
429                                        config_token.set(Some(p));
430                                        toast.set(Some(("已选为配置令牌".to_string(), false)));
431                                    }
432                                    Ok(None) => toast.set(Some(("无法解密该令牌".to_string(), true))),
433                                    Err(e) => toast.set(Some((format!("重查失败:{e}"), true))),
434                                }
435                            });
436                        },
437                        "用于配置"
438                    }
439                    button {
440                        class: "{BTN_TEXT_RED}",
441                        onclick: move |_| {
442                            let id = id_revoke.clone();
443                            spawn(async move {
444                                match revoke_mcp_token(id).await {
445                                    Ok(()) => {
446                                        toast.set(Some(("已撤销".to_string(), false)));
447                                        let g = reload_gen();
448                                        state.reload_gen.set(g + 1);
449                                    }
450                                    Err(e) => toast.set(Some((format!("撤销失败:{e}"), true))),
451                                }
452                            });
453                        },
454                        "撤销"
455                    }
456                } else {
457                    span { class: "text-xs text-[var(--color-paper-tertiary)]", "—" }
458                }
459            }
460        }
461    }
462}
463
464/// 新建令牌表单卡片。成功后把明文写入共享 context 的一次性弹窗。
465#[cfg(target_arch = "wasm32")]
466#[component]
467fn CreateTokenCard() -> Element {
468    let mut state: McpPageState = use_context();
469    let mut name = use_signal(String::new);
470    let mut scope = use_signal(|| TokenScope::Read);
471    let mut lifetime = use_signal(|| TokenLifetime::Days30);
472    let mut busy = use_signal(|| false);
473
474    let mut created_plaintext = state.created_plaintext;
475    let reload_gen = state.reload_gen;
476    let mut toast = state.toast;
477
478    rsx! {
479        div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-6",
480            h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]", "新建令牌" }
481
482            div { class: "grid grid-cols-1 md:grid-cols-3 gap-4",
483                // 名称
484                div { class: "flex flex-col gap-2",
485                    label { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
486                        "名称"
487                    }
488                    FormInput {
489                        r#type: "text",
490                        placeholder: "如 claude-code-macbook",
491                        value: name(),
492                        oninput: move |v: String| name.set(v),
493                    }
494                }
495                // 作用域
496                div { class: "flex flex-col gap-2",
497                    label { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
498                        "作用域"
499                    }
500                    FormSelect {
501                        value: scope(),
502                        options: SCOPE_OPTIONS.to_vec(),
503                        onchange: move |s| scope.set(s),
504                    }
505                }
506                // 有效期
507                div { class: "flex flex-col gap-2",
508                    label { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
509                        "有效期"
510                    }
511                    FormSelect {
512                        value: lifetime(),
513                        options: LIFETIME_OPTIONS.to_vec(),
514                        onchange: move |l| lifetime.set(l),
515                    }
516                }
517            }
518
519            div {
520                button {
521                    class: "{BTN_PRIMARY}",
522                    disabled: "{busy() || name().trim().is_empty()}",
523                    onclick: move |_| {
524                        if busy() {
525                            return;
526                        }
527                        let n = name().trim().to_string();
528                        if n.is_empty() {
529                            return;
530                        }
531                        let sc = scope();
532                        let lt = lifetime();
533                        busy.set(true);
534                        spawn(async move {
535                            match create_mcp_token(n, sc, lt).await {
536                                Ok(resp) => {
537                                    created_plaintext.set(Some(resp.plaintext));
538                                    name.set(String::new());
539                                    let g = reload_gen();
540                                    state.reload_gen.set(g + 1);
541                                }
542                                Err(e) => {
543                                    toast.set(Some((format!("创建失败:{e}"), true)));
544                                }
545                            }
546                            busy.set(false);
547                        });
548                    },
549                    if busy() {
550                        "创建中…"
551                    } else {
552                        "创建令牌"
553                    }
554                }
555            }
556        }
557    }
558}
559
560/// 客户端配置卡片:展示 4 种配置片段(需先在令牌列表点「用于配置」或手动粘贴)。
561#[cfg(target_arch = "wasm32")]
562#[component]
563fn ConfigCard() -> Element {
564    let state: McpPageState = use_context();
565    let mut manual_token = use_signal(String::new);
566    let mut configs = use_signal(|| None::<McpClientConfigs>);
567    let mut loading = use_signal(|| false);
568
569    let config_token = state.config_token;
570    let mut toast = state.toast;
571
572    // 当 config_token 变化时,请求服务端生成配置。
573    use_effect(move || {
574        let token = config_token();
575        let Some(t) = token else {
576            return;
577        };
578        loading.set(true);
579        manual_token.set(t.clone());
580        spawn(async move {
581            match get_mcp_client_configs(t).await {
582                Ok(c) => configs.set(Some(c)),
583                Err(e) => toast.set(Some((format!("配置生成失败:{e}"), true))),
584            }
585            loading.set(false);
586        });
587    });
588
589    // 手动输入时也触发生成。
590    let mut on_manual_input = move |val: String| {
591        manual_token.set(val.clone());
592        if val.trim().is_empty() {
593            configs.set(None);
594            return;
595        }
596        if loading() {
597            return;
598        }
599        loading.set(true);
600        spawn(async move {
601            match get_mcp_client_configs(val).await {
602                Ok(c) => configs.set(Some(c)),
603                Err(e) => toast.set(Some((format!("配置生成失败:{e}"), true))),
604            }
605            loading.set(false);
606        });
607    };
608
609    rsx! {
610        div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-6",
611            h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]", "客户端配置" }
612            p { class: "text-sm text-[var(--color-paper-secondary)]",
613                "在上方令牌列表点「用于配置」自动填入,或在下方手动粘贴令牌明文(形如 ygg_...)。"
614            }
615
616            div { class: "flex flex-col gap-2",
617                label { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
618                    "令牌明文"
619                }
620                FormInput {
621                    r#type: "text",
622                    placeholder: "ygg_...",
623                    value: manual_token(),
624                    oninput: move |v: String| on_manual_input(v),
625                }
626            }
627
628            if loading() {
629                div { class: "flex flex-col gap-4",
630                    for i in 0..7 {
631                        div { key: "{i}", class: "flex flex-col gap-2",
632                            div { class: "flex items-center justify-between",
633                                SkeletonBox {
634                                    class: "h-4 rounded",
635                                    style: CONFIG_SKELETON_SHAPES[i].0,
636                                }
637                                SkeletonBox { class: "h-7 w-14 rounded-full" }
638                            }
639                            SkeletonBox {
640                                class: "rounded-lg",
641                                style: CONFIG_SKELETON_SHAPES[i].1,
642                            }
643                        }
644                    }
645                }
646            } else if let Some(c) = configs() {
647                div { class: "flex flex-col gap-4",
648                    for snippet in c.snippets.iter() {
649                        ConfigSnippet { key: "{snippet.title}", snippet: snippet.clone() }
650                    }
651                }
652            } else {
653                p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center",
654                    "粘贴令牌明文后此处显示配置片段。"
655                }
656            }
657        }
658    }
659}
660
661/// 单个配置片段卡片(标题 + 代码块 + 复制按钮)。
662#[cfg(target_arch = "wasm32")]
663#[component]
664fn ConfigSnippet(snippet: McpConfigSnippet) -> Element {
665    let state: McpPageState = use_context();
666    let mut toast = state.toast;
667    let mut copied = use_signal(|| false);
668    let title = snippet.title.clone();
669    // 反馈就在按钮本身:Toast 渲染在页面顶部(见 Mcp 组件树),配置区在页面最末,
670    // 点击「复制」时 Toast 落在视口之外,用户看不到。故按钮就地短暂变绿提示「已复制」。
671    let copied_now = copied();
672    let btn_class = if copied_now {
673        "inline-flex items-center justify-center px-4 py-1.5 text-sm font-medium \
674         text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/30 \
675         rounded-full transition-all cursor-default"
676    } else {
677        BTN_PRIMARY_SM
678    };
679    let btn_label = if copied_now { "已复制" } else { "复制" };
680    rsx! {
681        div { class: "flex flex-col gap-2",
682            div { class: "flex items-center justify-between",
683                span { class: "text-sm font-medium text-[var(--color-paper-primary)]",
684                    "{snippet.title}"
685                }
686                button {
687                    class: "{btn_class}",
688                    onclick: move |_| {
689                        let cc = snippet.content.clone();
690                        let tt = title.clone();
691                        copied.set(true);
692                        spawn(async move {
693                            copy_clipboard_wasm(&cc).await;
694                            toast.set(Some((format!("已复制:{tt}"), false)));
695                            crate::utils::time::sleep_ms(1500).await;
696                            copied.set(false);
697                        });
698                    },
699                    "{btn_label}"
700                }
701            }
702            // .md-content 是 highlight.css 的作用域钩子(.md-content pre code …);
703            // 仅包裹 pre,不触及标题/按钮,避免 prose 排式泄漏。
704            div { class: "md-content",
705                pre { class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-xs overflow-x-auto font-mono",
706                    code { dangerous_inner_html: "{snippet.content_html}" }
707                }
708            }
709        }
710    }
711}
712
713/// 明文展示弹窗。
714#[cfg(target_arch = "wasm32")]
715#[component]
716fn PlaintextModal(
717    title: String,
718    plaintext: String,
719    on_use_config: EventHandler<()>,
720    on_close: EventHandler<()>,
721) -> Element {
722    rsx! {
723        div {
724            class: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4",
725            onclick: move |_| on_close.call(()),
726            div {
727                class: "{ADMIN_CARD_CLASS} p-8 max-w-2xl w-full flex flex-col gap-4",
728                onclick: move |e| e.stop_propagation(),
729                h3 { class: "text-lg font-bold text-[var(--color-paper-primary)]",
730                    "{title}"
731                }
732                pre { class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-sm overflow-x-auto font-mono break-all",
733                    code { "{plaintext}" }
734                }
735                div { class: "flex flex-wrap gap-3 justify-end",
736                    button {
737                        class: "{BTN_PRIMARY_SM}",
738                        onclick: move |_| {
739                            let p = plaintext.clone();
740                            spawn(async move {
741                                copy_clipboard_wasm(&p).await;
742                            });
743                        },
744                        "复制"
745                    }
746                    button {
747                        class: "{BTN_PRIMARY_SM}",
748                        onclick: move |_| on_use_config.call(()),
749                        "用于配置"
750                    }
751                    button {
752                        class: "px-4 py-1.5 text-sm font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
753                        onclick: move |_| on_close.call(()),
754                        "关闭"
755                    }
756                }
757            }
758        }
759    }
760}
761
762/// WASM 端剪贴板写入(navigator.clipboard.writeText)。
763///
764/// 失败静默忽略(非关键路径;用户可手动选中复制)。
765#[cfg(target_arch = "wasm32")]
766async fn copy_clipboard_wasm(text: &str) {
767    use wasm_bindgen_futures::JsFuture;
768
769    let Some(window) = web_sys::window() else {
770        return;
771    };
772    // web-sys 的 Navigator::clipboard() 直接返回 Clipboard(非 Option)。
773    let clipboard = window.navigator().clipboard();
774    // write_text 返回 Promise<void>;忽略 reject(如非 HTTPS / 无焦点)。
775    let _ = JsFuture::from(clipboard.write_text(text)).await;
776}