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::{BTN_PRIMARY, BTN_PRIMARY_SM};
31#[cfg(target_arch = "wasm32")]
32use crate::models::mcp_token::{McpTokenSummary, TokenScope};
33
34/// 作用域可选项(下拉菜单)。
35#[cfg(target_arch = "wasm32")]
36const SCOPE_OPTIONS: &[(TokenScope, &str)] = &[
37    (TokenScope::Read, "read — 仅查询已发布文章"),
38    (TokenScope::Write, "write — read + 文章/评论/标签/媒体 CRUD"),
39    (TokenScope::Admin, "admin — write + 站点设置 / 代码运行器"),
40];
41
42/// 有效期可选项(下拉菜单)。
43#[cfg(target_arch = "wasm32")]
44const LIFETIME_OPTIONS: &[(TokenLifetime, &str)] = &[
45    (TokenLifetime::Days1, "1 天"),
46    (TokenLifetime::Days7, "7 天"),
47    (TokenLifetime::Days30, "30 天"),
48    (TokenLifetime::Days90, "90 天"),
49    (TokenLifetime::Never, "永不过期"),
50];
51
52/// 配置骨架屏的尺寸变化:(标题宽度 px, 代码块高度 px)。
53///
54/// 按 7 个真实配置片段的长短差异给出不同占位尺寸,让骨架屏更像即将出现的真实内容
55/// 而非千篇一律的等高块。
56#[cfg(target_arch = "wasm32")]
57const CONFIG_SKELETON_SHAPES: &[(&str, &str)] = &[
58    ("width: 320px;", "height: 152px;"), // Oh-My-Pi JSON
59    ("width: 280px;", "height: 168px;"), // OpenCode JSON
60    ("width: 260px;", "height: 168px;"), // Claude Code JSON
61    ("width: 200px;", "height: 136px;"), // Cursor JSON
62    ("width: 220px;", "height: 168px;"), // Cline JSON
63    ("width: 160px;", "height: 104px;"), // 通用 JSON
64    ("width: 140px;", "height: 56px;"),  // CLI 一行命令
65];
66#[cfg(target_arch = "wasm32")]
67const BTN_COPIED_SM: &str =
68    "inline-flex items-center justify-center px-4 py-1.5 text-sm font-medium \
69     text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/30 \
70     rounded-full transition-all cursor-default";
71
72/// 跨子组件共享的页面状态:刷新代际、一次性明文弹窗、配置用令牌、操作提示。
73///
74/// `PartialEq` 由组件宏生成的 Props 结构体要求(`TokenRow` 以此为 prop)。
75/// `Signal<T>` 实现了 `PartialEq`(比较当前值),故派生可行。
76#[cfg(target_arch = "wasm32")]
77#[derive(Clone, Copy, PartialEq)]
78struct McpPageState {
79    /// 递增以触发令牌列表重新加载(创建/撤销后 +1)。
80    reload_gen: Signal<u32>,
81    /// 一次性明文弹窗:Some 时展示。
82    created_plaintext: Signal<Option<String>>,
83    /// 重查明文弹窗:(id, plaintext)。
84    revealed: Signal<Option<(String, String)>>,
85    /// 配置卡片使用的令牌明文。
86    config_token: Signal<Option<String>>,
87    /// 全局操作提示:(消息, 是否错误)。
88    toast: Signal<Option<(String, bool)>>,
89}
90
91/// 管理后台 MCP 令牌管理页面。
92#[component]
93#[allow(clippy::needless_pass_by_value)]
94pub fn Mcp() -> Element {
95    #[cfg(target_arch = "wasm32")]
96    {
97        let state = McpPageState {
98            reload_gen: use_signal(|| 0),
99            created_plaintext: use_signal(|| None),
100            revealed: use_signal(|| None),
101            config_token: use_signal(|| None),
102            toast: use_signal(|| None),
103        };
104        use_context_provider(|| state);
105
106        rsx! {
107            // 将弹窗放在进场动画容器之外,避免 transform 祖先改变 fixed 的定位参考系。
108            div { class: "w-full",
109                div { class: "animate-page-enter w-full max-w-7xl mx-auto space-y-8",
110                    div {
111                        class: "animate-row-enter",
112                        style: "animation-delay: 0ms",
113                        PageHeader {}
114                    }
115                    div {
116                        class: "animate-row-enter",
117                        style: "animation-delay: 60ms",
118                        TokenList {}
119                    }
120                    // FormSelect 面板为绝对定位;提高本区块层级,避免被后续 ConfigCard 覆盖。
121                    div {
122                        class: "animate-row-enter relative z-10",
123                        style: "animation-delay: 120ms",
124                        CreateTokenCard {}
125                    }
126                    div {
127                        class: "animate-row-enter",
128                        style: "animation-delay: 180ms",
129                        ConfigCard {}
130                    }
131                }
132
133                // 明文弹窗不放在 TokenList 的动画树内,确保 fixed 相对整个视口居中。
134                if let Some(plaintext) = (state.created_plaintext)() {
135                    PlaintextModal {
136                        title: "令牌已创建(请立即复制,可稍后重新查看)".to_string(),
137                        plaintext: plaintext.clone(),
138                        on_use_config: {
139                            let mut ct = state.config_token;
140                            let mut cp = state.created_plaintext;
141                            let mut toast = state.toast;
142                            move |_| {
143                                ct.set(Some(plaintext.clone()));
144                                cp.set(None);
145                                toast.set(Some(("已选为配置令牌".to_string(), false)));
146                            }
147                        },
148                        on_close: {
149                            let mut cp = state.created_plaintext;
150                            move |_| cp.set(None)
151                        },
152                    }
153                }
154                if let Some((_, plaintext)) = (state.revealed)() {
155                    PlaintextModal {
156                        title: "令牌明文".to_string(),
157                        plaintext: plaintext.clone(),
158                        on_use_config: {
159                            let mut ct = state.config_token;
160                            let mut rev = state.revealed;
161                            let mut toast = state.toast;
162                            move |_| {
163                                ct.set(Some(plaintext.clone()));
164                                rev.set(None);
165                                toast.set(Some(("已选为配置令牌".to_string(), false)));
166                            }
167                        },
168                        on_close: {
169                            let mut rev = state.revealed;
170                            move |_| rev.set(None)
171                        },
172                    }
173                }
174            }
175        }
176    }
177    #[cfg(not(target_arch = "wasm32"))]
178    {
179        // server 构建下页面无前端交互;路由实际只在 WASM 渲染。
180        rsx! {
181            p { class: "text-paper-secondary", "此页面仅在浏览器中可用。" }
182            // server 构建下页面无前端交互;路由实际只在 WASM 渲染。
183        }
184    }
185}
186
187#[component]
188fn PageHeader() -> Element {
189    rsx! {
190        div { class: "flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-[var(--color-paper-border)]/70",
191            div {
192                h1 { class: "text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
193                    "MCP 服务器"
194                }
195                p { class: "text-sm text-[var(--color-paper-secondary)] mt-1.5",
196                    "为 AI 客户端(Claude Code / Cursor / Cline / Oh-My-Pi)签发访问令牌并获取接入配置"
197                }
198            }
199            div { class: "flex items-center gap-2.5",
200                div { class: "inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-mono bg-[var(--color-paper-entry)] text-[var(--color-paper-secondary)] border border-[var(--color-paper-border)]/70 shadow-2xs",
201                    span { class: "w-1.5 h-1.5 rounded-full bg-[var(--color-paper-accent)]" }
202                    span { "Endpoint: /mcp" }
203                }
204            }
205        }
206    }
207}
208
209/// 全局操作提示条(读取共享 context 的 toast)。
210#[cfg(target_arch = "wasm32")]
211#[component]
212fn Toast() -> Element {
213    let state: McpPageState = use_context();
214    let toast = state.toast;
215    if let Some((msg, is_err)) = toast() {
216        let cls = if is_err {
217            "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300"
218        } else {
219            "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
220        };
221        rsx! {
222            div { class: "text-sm rounded-lg px-3 py-2 {cls}", "{msg}" }
223        }
224    } else {
225        rsx! {}
226    }
227}
228
229/// 令牌列表卡片 + 撤销/重查操作。
230#[cfg(target_arch = "wasm32")]
231#[component]
232fn TokenList() -> Element {
233    let mut state: McpPageState = use_context();
234    let mut tokens = use_signal(Vec::<McpTokenSummary>::new);
235    let mut loaded_gen = use_signal(|| None::<u32>);
236    let mut loading = use_signal(|| true);
237
238    let reload_gen = state.reload_gen;
239    let mut toast = state.toast;
240
241    // reload_gen 变化时重新加载。
242    use_effect(move || {
243        let g = reload_gen();
244        if loaded_gen() != Some(g) {
245            loaded_gen.set(Some(g));
246            spawn(async move {
247                match list_mcp_tokens().await {
248                    Ok(list) => tokens.set(list),
249                    Err(e) => toast.set(Some((format!("加载失败:{e}"), true))),
250                }
251                loading.set(false);
252            });
253        }
254    });
255
256    rsx! {
257        div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 p-6 sm:p-8 flex flex-col gap-6",
258            div { class: "flex items-center justify-between border-b border-[var(--color-paper-border)]/60 pb-4",
259                div { class: "flex items-center gap-2.5",
260                    svg {
261                        class: "w-5 h-5 text-[var(--color-paper-accent)]",
262                        xmlns: "http://www.w3.org/2000/svg",
263                        view_box: "0 0 24 24",
264                        fill: "none",
265                        stroke: "currentColor",
266                        stroke_width: "2",
267                        stroke_linecap: "round",
268                        stroke_linejoin: "round",
269                        path { d: "M21 2l-2 2m-1.5 1.5L14 9l-1.5-1.5L11 9l-1.5-1.5L8 9 3 14a5 5 0 0 0 7 7l5-5 1.5 1.5L18 15l-1.5-1.5L18 12l1.5-1.5 1.5 1.5z" }
270                    }
271                    h2 { class: "text-lg sm:text-xl font-bold text-[var(--color-paper-primary)]",
272                        "已签发令牌 ({tokens().len()})"
273                    }
274                }
275                button {
276                    class: "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
277                    onclick: move |_| {
278                        let g = reload_gen();
279                        state.reload_gen.set(g + 1);
280                    },
281                    svg {
282                        class: "w-3.5 h-3.5",
283                        xmlns: "http://www.w3.org/2000/svg",
284                        view_box: "0 0 24 24",
285                        fill: "none",
286                        stroke: "currentColor",
287                        stroke_width: "2",
288                        stroke_linecap: "round",
289                        stroke_linejoin: "round",
290                        path { d: "M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" }
291                    }
292                    "刷新列表"
293                }
294            }
295
296            // 表格区
297            if loading() && tokens().is_empty() {
298                DelayedSkeleton {
299                    div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
300                        table { class: "w-full text-sm",
301                            thead {
302                                tr { class: "bg-[var(--color-paper-entry)]/80 border-b border-[var(--color-paper-border)]/70",
303                                    th { class: "px-5 py-3.5",
304                                        SkeletonBox { class: "h-3 w-12" }
305                                    }
306                                    th { class: "px-4 py-3.5",
307                                        SkeletonBox { class: "h-3 w-10" }
308                                    }
309                                    th { class: "px-4 py-3.5",
310                                        SkeletonBox { class: "h-3 w-14" }
311                                    }
312                                    th { class: "px-4 py-3.5",
313                                        SkeletonBox { class: "h-3 w-14" }
314                                    }
315                                    th { class: "px-4 py-3.5",
316                                        SkeletonBox { class: "h-3 w-16" }
317                                    }
318                                    th { class: "px-4 py-3.5",
319                                        SkeletonBox { class: "h-3 w-10" }
320                                    }
321                                    th { class: "px-5 py-3.5",
322                                        SkeletonBox { class: "h-3 w-16 ml-auto" }
323                                    }
324                                }
325                            }
326                            tbody {
327                                for _ in 0..3 {
328                                    tr { class: "border-b border-[var(--color-paper-border)]/60 last:border-b-0",
329                                        td { class: "px-5 py-3.5",
330                                            SkeletonBox { class: "h-4 w-28" }
331                                        }
332                                        td { class: "px-4 py-3.5",
333                                            SkeletonBox { class: "h-4 w-12" }
334                                        }
335                                        td { class: "px-4 py-3.5",
336                                            SkeletonBox { class: "h-4 w-16" }
337                                        }
338                                        td { class: "px-4 py-3.5",
339                                            SkeletonBox { class: "h-4 w-16" }
340                                        }
341                                        td { class: "px-4 py-3.5",
342                                            SkeletonBox { class: "h-4 w-20" }
343                                        }
344                                        td { class: "px-4 py-3.5",
345                                            SkeletonBox { class: "h-5 w-10 rounded-full" }
346                                        }
347                                        td { class: "px-5 py-3.5",
348                                            SkeletonBox { class: "h-6 w-36 ml-auto rounded" }
349                                        }
350                                    }
351                                }
352                            }
353                        }
354                    }
355                }
356            } else if tokens().is_empty() {
357                p { class: "text-[var(--color-paper-secondary)] text-sm py-8 text-center",
358                    "暂无已签发的 MCP 访问令牌。在下方新建一个。"
359                }
360            } else {
361                div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
362                    div { class: "overflow-x-auto",
363                        table { class: "w-full text-sm",
364                            thead {
365                                tr { class: "bg-[var(--color-paper-entry)]/80 border-b border-[var(--color-paper-border)]/70 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)] select-none",
366                                    th { class: "px-5 py-3.5 font-semibold", "令牌名称" }
367                                    th { class: "px-4 py-3.5 font-semibold whitespace-nowrap", "权限作用域" }
368                                    th { class: "px-4 py-3.5 font-semibold whitespace-nowrap",
369                                        "创建日期"
370                                    }
371                                    th { class: "px-4 py-3.5 font-semibold whitespace-nowrap",
372                                        "有效期至"
373                                    }
374                                    th { class: "px-4 py-3.5 font-semibold whitespace-nowrap",
375                                        "最近活跃"
376                                    }
377                                    th { class: "px-4 py-3.5 font-semibold whitespace-nowrap text-center",
378                                        "状态"
379                                    }
380                                    th { class: "px-5 py-3.5 font-semibold text-right whitespace-nowrap", "操作" }
381                                }
382                            }
383                            tbody {
384                                for (i, t) in tokens().iter().enumerate() {
385                                    TokenRow {
386                                        key: "{t.id}",
387                                        token: t.clone(),
388                                        state,
389                                        stagger_index: i as u32,
390                                    }
391                                }
392                            }
393                        }
394                    }
395                }
396            }
397        }
398    }
399}
400
401/// 单行令牌:展示元数据 + 重查/用于配置/撤销按钮。
402#[cfg(target_arch = "wasm32")]
403#[component]
404fn TokenRow(token: McpTokenSummary, state: McpPageState, stagger_index: u32) -> Element {
405    let is_revoked = token.revoked_at.is_some();
406    let is_expired = token
407        .expires_at
408        .map(|e| e < chrono::Utc::now())
409        .unwrap_or(false);
410    let active = !is_revoked && !is_expired;
411    let created = token.created_at.format("%Y-%m-%d").to_string();
412    let expires = token
413        .expires_at
414        .map(|e| e.format("%Y-%m-%d").to_string())
415        .unwrap_or_else(|| "永不过期".to_string());
416    let last_used = token
417        .last_used_at
418        .map(|e| e.format("%Y-%m-%d %H:%M").to_string())
419        .unwrap_or_else(|| "—".to_string());
420    let id_reveal = token.id.clone();
421    let id_config = token.id.clone();
422    let id_revoke = token.id.clone();
423
424    // 提取信号到局部,避免 `state.field()` 被解析为方法调用。
425    let mut revealed = state.revealed;
426    let mut toast = state.toast;
427    let mut config_token = state.config_token;
428    let reload_gen = state.reload_gen;
429
430    rsx! {
431        tr {
432            class: "animate-row-enter border-b border-[var(--color-paper-border)]/60 last:border-b-0 hover:bg-[var(--color-paper-accent-soft)]/30 transition-colors duration-150 group",
433            style: "animation-delay: {stagger_index * 40}ms",
434            // 令牌名称
435            td { class: "px-5 py-3.5 font-semibold text-[var(--color-paper-primary)]",
436                "{token.name}"
437            }
438            // 作用域胶囊
439            td { class: "px-4 py-3.5 whitespace-nowrap",
440                span { class: "inline-flex items-center px-2 py-0.5 rounded-md text-xs font-mono font-medium bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] border border-[var(--color-paper-border)]/60",
441                    "{token.scope.as_str()}"
442                }
443            }
444            // 创建日期
445            td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
446                "{created}"
447            }
448            // 过期时间
449            td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
450                "{expires}"
451            }
452            // 最近使用
453            td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
454                "{last_used}"
455            }
456            // 状态胶囊
457            td { class: "px-4 py-3.5 text-center whitespace-nowrap",
458                if active {
459                    span { class: "inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20",
460                        span { class: "w-1.5 h-1.5 rounded-full bg-emerald-500" }
461                        "有效"
462                    }
463                } else if is_expired {
464                    span { class: "inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20",
465                        span { class: "w-1.5 h-1.5 rounded-full bg-amber-500" }
466                        "已过期"
467                    }
468                } else {
469                    span { class: "inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-500/10 text-gray-600 dark:text-gray-400 border border-gray-500/20",
470                        span { class: "w-1.5 h-1.5 rounded-full bg-gray-400" }
471                        "已撤销"
472                    }
473                }
474            }
475            // 操作列
476            td { class: "px-5 py-3.5 text-right whitespace-nowrap",
477                if active {
478                    div { class: "flex justify-end items-center gap-1.5",
479                        button {
480                            class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-colors cursor-pointer",
481                            onclick: move |_| {
482                                let id = id_reveal.clone();
483                                spawn(async move {
484                                    match reveal_mcp_token(id.clone()).await {
485                                        Ok(Some(p)) => revealed.set(Some((id, p))),
486                                        Ok(None) => toast.set(Some(("无法解密该令牌".to_string(), true))),
487                                        Err(e) => toast.set(Some((format!("重查失败:{e}"), true))),
488                                    }
489                                });
490                            },
491                            svg {
492                                class: "w-3.5 h-3.5",
493                                xmlns: "http://www.w3.org/2000/svg",
494                                view_box: "0 0 24 24",
495                                fill: "none",
496                                stroke: "currentColor",
497                                stroke_width: "2",
498                                stroke_linecap: "round",
499                                stroke_linejoin: "round",
500                                path { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }
501                                circle { cx: "12", cy: "12", r: "3" }
502                            }
503                            "重新查看"
504                        }
505                        button {
506                            class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-theme)] transition-colors cursor-pointer",
507                            onclick: move |_| {
508                                let id = id_config.clone();
509                                spawn(async move {
510                                    match reveal_mcp_token(id).await {
511                                        Ok(Some(p)) => {
512                                            config_token.set(Some(p));
513                                            toast.set(Some(("已选为配置令牌".to_string(), false)));
514                                        }
515                                        Ok(None) => toast.set(Some(("无法解密该令牌".to_string(), true))),
516                                        Err(e) => toast.set(Some((format!("重查失败:{e}"), true))),
517                                    }
518                                });
519                            },
520                            svg {
521                                class: "w-3.5 h-3.5 text-[var(--color-paper-accent)]",
522                                xmlns: "http://www.w3.org/2000/svg",
523                                view_box: "0 0 24 24",
524                                fill: "none",
525                                stroke: "currentColor",
526                                stroke_width: "2",
527                                stroke_linecap: "round",
528                                stroke_linejoin: "round",
529                                path { d: "M12 3v3m0 12v3M3 12h3m12 0h3M5.6 5.6l2.1 2.1m8.6 8.6l2.1 2.1M5.6 18.4l2.1-2.1m8.6-8.6l2.1-2.1" }
530                            }
531                            "用于配置"
532                        }
533                        button {
534                            class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer",
535                            onclick: move |_| {
536                                let id = id_revoke.clone();
537                                spawn(async move {
538                                    match revoke_mcp_token(id).await {
539                                        Ok(()) => {
540                                            toast.set(Some(("已撤销".to_string(), false)));
541                                            let g = reload_gen();
542                                            state.reload_gen.set(g + 1);
543                                        }
544                                        Err(e) => toast.set(Some((format!("撤销失败:{e}"), true))),
545                                    }
546                                });
547                            },
548                            svg {
549                                class: "w-3.5 h-3.5",
550                                xmlns: "http://www.w3.org/2000/svg",
551                                view_box: "0 0 24 24",
552                                fill: "none",
553                                stroke: "currentColor",
554                                stroke_width: "2",
555                                stroke_linecap: "round",
556                                stroke_linejoin: "round",
557                                circle { cx: "12", cy: "12", r: "10" }
558                                line { x1: "4.93", y1: "4.93", x2: "19.07", y2: "19.07" }
559                            }
560                            "撤销"
561                        }
562                    }
563                } else {
564                    span { class: "text-xs text-[var(--color-paper-tertiary)]", "—" }
565                }
566            }
567        }
568    }
569}
570
571/// 新建令牌表单卡片。成功后把明文写入共享 context 的一次性弹窗。
572#[cfg(target_arch = "wasm32")]
573#[component]
574fn CreateTokenCard() -> Element {
575    let mut state: McpPageState = use_context();
576    let mut name = use_signal(String::new);
577    let mut scope = use_signal(|| TokenScope::Read);
578    let mut lifetime = use_signal(|| TokenLifetime::Days30);
579    let mut busy = use_signal(|| false);
580
581    let mut created_plaintext = state.created_plaintext;
582    let reload_gen = state.reload_gen;
583    let mut toast = state.toast;
584
585    rsx! {
586        div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 p-6 sm:p-8 flex flex-col gap-6",
587            div { class: "flex items-center gap-2.5 border-b border-[var(--color-paper-border)]/60 pb-4",
588                svg {
589                    class: "w-5 h-5 text-[var(--color-paper-accent)]",
590                    xmlns: "http://www.w3.org/2000/svg",
591                    view_box: "0 0 24 24",
592                    fill: "none",
593                    stroke: "currentColor",
594                    stroke_width: "2",
595                    stroke_linecap: "round",
596                    stroke_linejoin: "round",
597                    line { x1: "12", y1: "5", x2: "12", y2: "19" }
598                    line { x1: "5", y1: "12", x2: "19", y2: "12" }
599                }
600                h2 { class: "text-lg sm:text-xl font-bold text-[var(--color-paper-primary)]",
601                    "新建访问令牌"
602                }
603            }
604
605            div { class: "grid grid-cols-1 md:grid-cols-3 gap-5",
606                // 名称
607                div { class: "flex flex-col gap-2",
608                    label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
609                        "令牌名称 *"
610                    }
611                    FormInput {
612                        r#type: "text",
613                        placeholder: "如 claude-code-macbook",
614                        value: name(),
615                        oninput: move |v: String| name.set(v),
616                    }
617                }
618                // 作用域
619                div { class: "flex flex-col gap-2",
620                    label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
621                        "权限作用域"
622                    }
623                    FormSelect {
624                        value: scope(),
625                        options: SCOPE_OPTIONS.to_vec(),
626                        onchange: move |s| scope.set(s),
627                    }
628                }
629                // 有效期
630                div { class: "flex flex-col gap-2",
631                    label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
632                        "有效期"
633                    }
634                    FormSelect {
635                        value: lifetime(),
636                        options: LIFETIME_OPTIONS.to_vec(),
637                        onchange: move |l| lifetime.set(l),
638                    }
639                }
640            }
641
642            div { class: "pt-1",
643                button {
644                    class: "{BTN_PRIMARY} inline-flex items-center gap-1.5",
645                    disabled: "{busy() || name().trim().is_empty()}",
646                    onclick: move |_| {
647                        if busy() {
648                            return;
649                        }
650                        let n = name().trim().to_string();
651                        if n.is_empty() {
652                            return;
653                        }
654                        let sc = scope();
655                        let lt = lifetime();
656                        busy.set(true);
657                        spawn(async move {
658                            match create_mcp_token(n, sc, lt).await {
659                                Ok(resp) => {
660                                    created_plaintext.set(Some(resp.plaintext));
661                                    name.set(String::new());
662                                    let g = reload_gen();
663                                    state.reload_gen.set(g + 1);
664                                }
665                                Err(e) => {
666                                    toast.set(Some((format!("创建失败:{e}"), true)));
667                                }
668                            }
669                            busy.set(false);
670                        });
671                    },
672                    if busy() {
673                        "创建中…"
674                    } else {
675                        "创建访问令牌"
676                    }
677                }
678            }
679        }
680    }
681}
682
683/// 客户端配置卡片:展示 4 种配置片段(需先在令牌列表点「用于配置」或手动粘贴)。
684#[cfg(target_arch = "wasm32")]
685#[component]
686fn ConfigCard() -> Element {
687    let state: McpPageState = use_context();
688    let mut manual_token = use_signal(String::new);
689    let mut configs = use_signal(|| None::<McpClientConfigs>);
690    let mut loading = use_signal(|| false);
691
692    let config_token = state.config_token;
693    let mut toast = state.toast;
694
695    // 当 config_token 变化时,请求服务端生成配置。
696    use_effect(move || {
697        let token = config_token();
698        let Some(t) = token else {
699            return;
700        };
701        loading.set(true);
702        manual_token.set(t.clone());
703        spawn(async move {
704            match get_mcp_client_configs(t).await {
705                Ok(c) => configs.set(Some(c)),
706                Err(e) => toast.set(Some((format!("配置生成失败:{e}"), true))),
707            }
708            loading.set(false);
709        });
710    });
711
712    // 手动输入时也触发生成。
713    let mut on_manual_input = move |val: String| {
714        manual_token.set(val.clone());
715        if val.trim().is_empty() {
716            configs.set(None);
717            return;
718        }
719        if loading() {
720            return;
721        }
722        loading.set(true);
723        spawn(async move {
724            match get_mcp_client_configs(val).await {
725                Ok(c) => configs.set(Some(c)),
726                Err(e) => toast.set(Some((format!("配置生成失败:{e}"), true))),
727            }
728            loading.set(false);
729        });
730    };
731
732    rsx! {
733        div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 p-6 sm:p-8 flex flex-col gap-6",
734            div { class: "flex flex-col gap-1 border-b border-[var(--color-paper-border)]/60 pb-4",
735                div { class: "flex items-center gap-2.5",
736                    svg {
737                        class: "w-5 h-5 text-[var(--color-paper-accent)]",
738                        xmlns: "http://www.w3.org/2000/svg",
739                        view_box: "0 0 24 24",
740                        fill: "none",
741                        stroke: "currentColor",
742                        stroke_width: "2",
743                        stroke_linecap: "round",
744                        stroke_linejoin: "round",
745                        polyline { points: "4 17 10 11 4 5" }
746                        line { x1: "12", y1: "19", x2: "20", y2: "19" }
747                    }
748                    h2 { class: "text-lg sm:text-xl font-bold text-[var(--color-paper-primary)]",
749                        "客户端接入配置"
750                    }
751                }
752                p { class: "text-sm text-[var(--color-paper-secondary)] mt-1",
753                    "在上方令牌列表点击「用于配置」自动填入,或直接粘贴令牌明文(ygg_...)。"
754                }
755            }
756
757            div { class: "flex flex-col gap-2",
758                label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
759                    "令牌明文 (Bearer Token)"
760                }
761                div { class: "relative",
762                    FormInput {
763                        r#type: "text",
764                        placeholder: "ygg_xxxxxxxxxxxxxxxxxxxx",
765                        value: manual_token(),
766                        oninput: move |v: String| on_manual_input(v),
767                    }
768                }
769            }
770
771            if loading() {
772                div { class: "flex flex-col gap-4",
773                    for i in 0..7 {
774                        div { key: "{i}", class: "flex flex-col gap-2",
775                            div { class: "flex items-center justify-between",
776                                SkeletonBox {
777                                    class: "h-4 rounded",
778                                    style: CONFIG_SKELETON_SHAPES[i].0,
779                                }
780                                SkeletonBox { class: "h-7 w-14 rounded-full" }
781                            }
782                            SkeletonBox {
783                                class: "rounded-lg",
784                                style: CONFIG_SKELETON_SHAPES[i].1,
785                            }
786                        }
787                    }
788                }
789            } else if let Some(c) = configs() {
790                div { class: "grid grid-cols-1 gap-5",
791                    for (i, snippet) in c.snippets.iter().enumerate() {
792                        div {
793                            key: "{snippet.title}",
794                            class: "animate-row-enter",
795                            style: "animation-delay: {i * 40}ms",
796                            ConfigSnippet { snippet: snippet.clone() }
797                        }
798                    }
799                }
800            } else {
801                p { class: "text-sm text-[var(--color-paper-tertiary)] py-8 text-center",
802                    "填入令牌明文后此处自动生成针对各客户端的接入配置代码。"
803                }
804            }
805        }
806    }
807}
808
809/// 单个配置片段卡片(标题 + 代码块 + 复制按钮)。
810#[cfg(target_arch = "wasm32")]
811#[component]
812fn ConfigSnippet(snippet: McpConfigSnippet) -> Element {
813    let state: McpPageState = use_context();
814    let mut toast = state.toast;
815    let mut copied = use_signal(|| false);
816    let title = snippet.title.clone();
817    let copied_now = copied();
818    let btn_class = if copied_now {
819        BTN_COPIED_SM
820    } else {
821        BTN_PRIMARY_SM
822    };
823    let btn_label = if copied_now { "已复制" } else { "复制" };
824    rsx! {
825        div { class: "bg-[var(--color-paper-theme)]/70 rounded-2xl p-5 border border-[var(--color-paper-border)]/60 flex flex-col gap-3 shadow-2xs",
826            div { class: "flex items-center justify-between",
827                span { class: "text-sm font-semibold text-[var(--color-paper-primary)]",
828                    "{snippet.title}"
829                }
830                button {
831                    class: "{btn_class}",
832                    onclick: move |_| {
833                        let cc = snippet.content.clone();
834                        let tt = title.clone();
835                        copied.set(true);
836                        spawn(async move {
837                            copy_clipboard_wasm(&cc).await;
838                            toast.set(Some((format!("已复制:{tt}"), false)));
839                            crate::utils::time::sleep_ms(1500).await;
840                            copied.set(false);
841                        });
842                    },
843                    "{btn_label}"
844                }
845            }
846            div { class: "md-content",
847                pre { class: "bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] rounded-xl p-3.5 text-xs overflow-x-auto font-mono border border-[var(--color-paper-border)]/50",
848                    code { dangerous_inner_html: "{snippet.content_html}" }
849                }
850            }
851        }
852    }
853}
854
855/// 明文展示弹窗。
856#[cfg(target_arch = "wasm32")]
857#[component]
858fn PlaintextModal(
859    title: String,
860    plaintext: String,
861    on_use_config: EventHandler<()>,
862    on_close: EventHandler<()>,
863) -> Element {
864    let mut copied = use_signal(|| false);
865    let copied_now = copied();
866    let btn_class = if copied_now {
867        BTN_COPIED_SM
868    } else {
869        BTN_PRIMARY_SM
870    };
871    let btn_label = if copied_now { "已复制" } else { "复制" };
872    rsx! {
873        div {
874            class: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-modal-overlay-enter",
875            onclick: move |_| on_close.call(()),
876            div {
877                class: "bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] rounded-3xl p-7 max-w-xl w-full flex flex-col gap-5 border border-[var(--color-paper-border)] shadow-2xl animate-modal-panel-enter",
878                onclick: move |e| e.stop_propagation(),
879                div { class: "flex items-center justify-between border-b border-[var(--color-paper-border)]/60 pb-3",
880                    h3 { class: "text-base sm:text-lg font-bold text-[var(--color-paper-primary)]",
881                        "{title}"
882                    }
883                    button {
884                        class: "text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] p-1 rounded-lg hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
885                        onclick: move |_| on_close.call(()),
886                        "×"
887                    }
888                }
889                pre { class: "bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] border border-[var(--color-paper-border)]/60 rounded-2xl p-4 text-xs font-mono break-all select-all leading-relaxed",
890                    code { "{plaintext}" }
891                }
892                div { class: "flex flex-wrap gap-2.5 justify-end pt-1",
893                    button {
894                        class: "{btn_class}",
895                        onclick: move |_| {
896                            let p = plaintext.clone();
897                            copied.set(true);
898                            spawn(async move {
899                                copy_clipboard_wasm(&p).await;
900                                crate::utils::time::sleep_ms(1500).await;
901                                copied.set(false);
902                            });
903                        },
904                        "{btn_label}"
905                    }
906                    button {
907                        class: "{BTN_PRIMARY_SM}",
908                        onclick: move |_| on_use_config.call(()),
909                        "用于配置"
910                    }
911                    button {
912                        class: "px-4 py-1.5 text-sm font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-entry)] rounded-full transition-colors cursor-pointer",
913                        onclick: move |_| on_close.call(()),
914                        "关闭"
915                    }
916                }
917            }
918        }
919    }
920}
921
922/// WASM 端剪贴板写入(navigator.clipboard.writeText)。
923///
924/// 失败静默忽略(非关键路径;用户可手动选中复制)。
925#[cfg(target_arch = "wasm32")]
926async fn copy_clipboard_wasm(text: &str) {
927    use wasm_bindgen_futures::JsFuture;
928
929    let Some(window) = web_sys::window() else {
930        return;
931    };
932    // web-sys 的 Navigator::clipboard() 直接返回 Clipboard(非 Option)。
933    let clipboard = window.navigator().clipboard();
934    // write_text 返回 Promise<void>;忽略 reject(如非 HTTPS / 无焦点)。
935    let _ = JsFuture::from(clipboard.write_text(text)).await;
936}