Skip to main content

yggdrasil/pages/admin/
mcp.rs

1//! 管理后台「MCP 服务器」页面。
2//!
3//! 管理员在此签发/查看/编辑权限/撤销为 AI 客户端(Codex / Claude Code / Cursor / Cline 等)准备的
4//! bearer 令牌,并复制对应的客户端配置片段。功能分三块:
5//! - 令牌列表:名称 / 作用域 / 创建时间 / 过期 / 最近使用 / 状态 + 撤销 / 重查按钮。
6//! - 新建令牌表单:名称 + 作用域下拉 + 有效期下拉;提交后一次性弹窗展示明文。
7//! - 客户端配置:选中令牌后展示各客户端可复制的配置片段 + 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    update_mcp_token_permissions, 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/// 按真实配置片段的长短差异给出不同占位尺寸,让骨架屏更像即将出现的真实内容
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: 88px;"),  // Codex TOML
60    ("width: 280px;", "height: 168px;"), // OpenCode JSON
61    ("width: 260px;", "height: 168px;"), // Claude Code JSON
62    ("width: 200px;", "height: 136px;"), // Cursor JSON
63    ("width: 220px;", "height: 168px;"), // Cline JSON
64    ("width: 160px;", "height: 104px;"), // 通用 JSON
65    ("width: 140px;", "height: 56px;"),  // CLI 一行命令
66];
67#[cfg(target_arch = "wasm32")]
68const BTN_COPIED_SM: &str =
69    "inline-flex items-center justify-center px-4 py-1.5 text-sm font-medium \
70     text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/30 \
71     rounded-full transition-all cursor-default";
72
73/// 跨子组件共享的页面状态:刷新代际、一次性明文弹窗、配置用令牌、操作提示。
74///
75/// `PartialEq` 由组件宏生成的 Props 结构体要求(`TokenRow` 以此为 prop)。
76/// `Signal<T>` 实现了 `PartialEq`(比较当前值),故派生可行。
77#[cfg(target_arch = "wasm32")]
78#[derive(Clone, Copy, PartialEq)]
79struct McpPageState {
80    /// 递增以触发令牌列表重新加载(创建/编辑/撤销后 +1)。
81    reload_gen: Signal<u32>,
82    /// 一次性明文弹窗:Some 时展示。
83    created_plaintext: Signal<Option<String>>,
84    /// 重查明文弹窗:(id, plaintext)。
85    revealed: Signal<Option<(String, String)>>,
86    /// 配置卡片使用的令牌明文。
87    config_token: Signal<Option<String>>,
88    /// 全局操作提示:(消息, 是否错误)。
89    toast: Signal<Option<(String, bool)>>,
90    editing: Signal<Option<McpTokenSummary>>,
91}
92
93/// 管理后台 MCP 令牌管理页面。
94#[component]
95#[allow(clippy::needless_pass_by_value)]
96pub fn Mcp() -> Element {
97    #[cfg(target_arch = "wasm32")]
98    {
99        let state = McpPageState {
100            reload_gen: use_signal(|| 0),
101            created_plaintext: use_signal(|| None),
102            revealed: use_signal(|| None),
103            config_token: use_signal(|| None),
104            toast: use_signal(|| None),
105            editing: use_signal(|| None),
106        };
107        use_context_provider(|| state);
108
109        rsx! {
110            // 将弹窗放在进场动画容器之外,避免 transform 祖先改变 fixed 的定位参考系。
111            div { class: "w-full",
112                div { class: "animate-page-enter w-full max-w-7xl mx-auto space-y-8",
113                    div {
114                        class: "animate-row-enter",
115                        style: "animation-delay: 0ms",
116                        PageHeader {}
117                    }
118                    div {
119                        class: "animate-row-enter",
120                        style: "animation-delay: 60ms",
121                        TokenList {}
122                    }
123                    // FormSelect 面板为绝对定位;提高本区块层级,避免被后续 ConfigCard 覆盖。
124                    div {
125                        class: "animate-row-enter relative z-10",
126                        style: "animation-delay: 120ms",
127                        CreateTokenCard {}
128                    }
129                    div {
130                        class: "animate-row-enter",
131                        style: "animation-delay: 180ms",
132                        ConfigCard {}
133                    }
134                }
135
136                if let Some(token) = (state.editing)() {
137                    EditTokenPermissionsModal { key: "{token.id}", token }
138                }
139                // 明文弹窗不放在 TokenList 的动画树内,确保 fixed 相对整个视口居中。
140                if let Some(plaintext) = (state.created_plaintext)() {
141                    PlaintextModal {
142                        title: "令牌已创建(请立即复制,可稍后重新查看)".to_string(),
143                        plaintext: plaintext.clone(),
144                        on_use_config: {
145                            let mut ct = state.config_token;
146                            let mut cp = state.created_plaintext;
147                            let mut toast = state.toast;
148                            move |_| {
149                                ct.set(Some(plaintext.clone()));
150                                cp.set(None);
151                                toast.set(Some(("已选为配置令牌".to_string(), false)));
152                            }
153                        },
154                        on_close: {
155                            let mut cp = state.created_plaintext;
156                            move |_| cp.set(None)
157                        },
158                    }
159                }
160                if let Some((_, plaintext)) = (state.revealed)() {
161                    PlaintextModal {
162                        title: "令牌明文".to_string(),
163                        plaintext: plaintext.clone(),
164                        on_use_config: {
165                            let mut ct = state.config_token;
166                            let mut rev = state.revealed;
167                            let mut toast = state.toast;
168                            move |_| {
169                                ct.set(Some(plaintext.clone()));
170                                rev.set(None);
171                                toast.set(Some(("已选为配置令牌".to_string(), false)));
172                            }
173                        },
174                        on_close: {
175                            let mut rev = state.revealed;
176                            move |_| rev.set(None)
177                        },
178                    }
179                }
180            }
181        }
182    }
183    #[cfg(not(target_arch = "wasm32"))]
184    {
185        // server 构建下页面无前端交互;路由实际只在 WASM 渲染。
186        rsx! {
187            p { class: "text-paper-secondary", "此页面仅在浏览器中可用。" }
188            // server 构建下页面无前端交互;路由实际只在 WASM 渲染。
189        }
190    }
191}
192
193#[component]
194fn PageHeader() -> Element {
195    rsx! {
196        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",
197            div {
198                h1 { class: "text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
199                    "MCP 服务器"
200                }
201                p { class: "text-sm text-[var(--color-paper-secondary)] mt-1.5",
202                    "为 AI 客户端(Codex / Claude Code / Cursor / Cline / Oh-My-Pi / OpenCode)签发访问令牌并获取接入配置"
203                }
204            }
205            div { class: "flex items-center gap-2.5",
206                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",
207                    span { class: "w-1.5 h-1.5 rounded-full bg-[var(--color-paper-accent)]" }
208                    span { "Endpoint: /mcp" }
209                }
210            }
211        }
212    }
213}
214
215/// 全局操作提示条(读取共享 context 的 toast)。
216#[cfg(target_arch = "wasm32")]
217#[component]
218fn Toast() -> Element {
219    let state: McpPageState = use_context();
220    let toast = state.toast;
221    if let Some((msg, is_err)) = toast() {
222        let cls = if is_err {
223            "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300"
224        } else {
225            "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
226        };
227        rsx! {
228            div { class: "text-sm rounded-lg px-3 py-2 {cls}", "{msg}" }
229        }
230    } else {
231        rsx! {}
232    }
233}
234
235/// 令牌列表卡片 + 撤销/重查操作。
236#[cfg(target_arch = "wasm32")]
237#[component]
238fn TokenList() -> Element {
239    let mut state: McpPageState = use_context();
240    let mut tokens = use_signal(Vec::<McpTokenSummary>::new);
241    let mut loaded_gen = use_signal(|| None::<u32>);
242    let mut loading = use_signal(|| true);
243
244    let reload_gen = state.reload_gen;
245    let mut toast = state.toast;
246
247    // reload_gen 变化时重新加载。
248    use_effect(move || {
249        let g = reload_gen();
250        if loaded_gen() != Some(g) {
251            loaded_gen.set(Some(g));
252            spawn(async move {
253                match list_mcp_tokens().await {
254                    Ok(list) => tokens.set(list),
255                    Err(e) => toast.set(Some((format!("加载失败:{e}"), true))),
256                }
257                loading.set(false);
258            });
259        }
260    });
261
262    rsx! {
263        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",
264            div { class: "flex items-center justify-between border-b border-[var(--color-paper-border)]/60 pb-4",
265                div { class: "flex items-center gap-2.5",
266                    svg {
267                        class: "w-5 h-5 text-[var(--color-paper-accent)]",
268                        xmlns: "http://www.w3.org/2000/svg",
269                        view_box: "0 0 24 24",
270                        fill: "none",
271                        stroke: "currentColor",
272                        stroke_width: "2",
273                        stroke_linecap: "round",
274                        stroke_linejoin: "round",
275                        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" }
276                    }
277                    h2 { class: "text-lg sm:text-xl font-bold text-[var(--color-paper-primary)]",
278                        "已签发令牌 ({tokens().len()})"
279                    }
280                }
281                button {
282                    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",
283                    onclick: move |_| {
284                        let g = reload_gen();
285                        state.reload_gen.set(g + 1);
286                    },
287                    svg {
288                        class: "w-3.5 h-3.5",
289                        xmlns: "http://www.w3.org/2000/svg",
290                        view_box: "0 0 24 24",
291                        fill: "none",
292                        stroke: "currentColor",
293                        stroke_width: "2",
294                        stroke_linecap: "round",
295                        stroke_linejoin: "round",
296                        path { d: "M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" }
297                    }
298                    "刷新列表"
299                }
300            }
301
302            // 表格区
303            if loading() && tokens().is_empty() {
304                DelayedSkeleton {
305                    div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
306                        table { class: "w-full text-sm",
307                            thead {
308                                tr { class: "bg-[var(--color-paper-entry)]/80 border-b border-[var(--color-paper-border)]/70",
309                                    th { class: "px-5 py-3.5",
310                                        SkeletonBox { class: "h-3 w-12" }
311                                    }
312                                    th { class: "px-4 py-3.5",
313                                        SkeletonBox { class: "h-3 w-10" }
314                                    }
315                                    th { class: "px-4 py-3.5",
316                                        SkeletonBox { class: "h-3 w-14" }
317                                    }
318                                    th { class: "px-4 py-3.5",
319                                        SkeletonBox { class: "h-3 w-14" }
320                                    }
321                                    th { class: "px-4 py-3.5",
322                                        SkeletonBox { class: "h-3 w-16" }
323                                    }
324                                    th { class: "px-4 py-3.5",
325                                        SkeletonBox { class: "h-3 w-10" }
326                                    }
327                                    th { class: "px-5 py-3.5",
328                                        SkeletonBox { class: "h-3 w-16 ml-auto" }
329                                    }
330                                }
331                            }
332                            tbody {
333                                for _ in 0..3 {
334                                    tr { class: "border-b border-[var(--color-paper-border)]/60 last:border-b-0",
335                                        td { class: "px-5 py-3.5",
336                                            SkeletonBox { class: "h-4 w-28" }
337                                        }
338                                        td { class: "px-4 py-3.5",
339                                            SkeletonBox { class: "h-4 w-12" }
340                                        }
341                                        td { class: "px-4 py-3.5",
342                                            SkeletonBox { class: "h-4 w-16" }
343                                        }
344                                        td { class: "px-4 py-3.5",
345                                            SkeletonBox { class: "h-4 w-16" }
346                                        }
347                                        td { class: "px-4 py-3.5",
348                                            SkeletonBox { class: "h-4 w-20" }
349                                        }
350                                        td { class: "px-4 py-3.5",
351                                            SkeletonBox { class: "h-5 w-10 rounded-full" }
352                                        }
353                                        td { class: "px-5 py-3.5",
354                                            SkeletonBox { class: "h-6 w-36 ml-auto rounded" }
355                                        }
356                                    }
357                                }
358                            }
359                        }
360                    }
361                }
362            } else if tokens().is_empty() {
363                p { class: "text-[var(--color-paper-secondary)] text-sm py-8 text-center",
364                    "暂无已签发的 MCP 访问令牌。在下方新建一个。"
365                }
366            } else {
367                div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
368                    div { class: "overflow-x-auto",
369                        table { class: "w-full text-sm",
370                            thead {
371                                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",
372                                    th { class: "px-5 py-3.5 font-semibold", "令牌名称" }
373                                    th { class: "px-4 py-3.5 font-semibold whitespace-nowrap", "权限作用域" }
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",
378                                        "有效期至"
379                                    }
380                                    th { class: "px-4 py-3.5 font-semibold whitespace-nowrap",
381                                        "最近活跃"
382                                    }
383                                    th { class: "px-4 py-3.5 font-semibold whitespace-nowrap text-center",
384                                        "状态"
385                                    }
386                                    th { class: "px-5 py-3.5 font-semibold text-right whitespace-nowrap", "操作" }
387                                }
388                            }
389                            tbody {
390                                for (i, t) in tokens().iter().enumerate() {
391                                    TokenRow {
392                                        key: "{t.id}",
393                                        token: t.clone(),
394                                        state,
395                                        stagger_index: i as u32,
396                                    }
397                                }
398                            }
399                        }
400                    }
401                }
402            }
403        }
404    }
405}
406
407/// 单行令牌:展示元数据 + 重查/用于配置/撤销按钮。
408#[cfg(target_arch = "wasm32")]
409#[component]
410fn TokenRow(token: McpTokenSummary, state: McpPageState, stagger_index: u32) -> Element {
411    let is_revoked = token.revoked_at.is_some();
412    let is_expired = token
413        .expires_at
414        .map(|e| e < chrono::Utc::now())
415        .unwrap_or(false);
416    let active = !is_revoked && !is_expired;
417    let created = token.created_at.format("%Y-%m-%d").to_string();
418    let expires = token
419        .expires_at
420        .map(|e| e.format("%Y-%m-%d").to_string())
421        .unwrap_or_else(|| "永不过期".to_string());
422    let last_used = token
423        .last_used_at
424        .map(|e| e.format("%Y-%m-%d %H:%M").to_string())
425        .unwrap_or_else(|| "—".to_string());
426    let id_reveal = token.id.clone();
427    let id_config = token.id.clone();
428    let id_revoke = token.id.clone();
429    let edit_token = token.clone();
430
431    // 提取信号到局部,避免 `state.field()` 被解析为方法调用。
432    let mut revealed = state.revealed;
433    let mut toast = state.toast;
434    let mut config_token = state.config_token;
435    let reload_gen = state.reload_gen;
436
437    rsx! {
438        tr {
439            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",
440            style: "animation-delay: {stagger_index * 40}ms",
441            // 令牌名称
442            td { class: "px-5 py-3.5 font-semibold text-[var(--color-paper-primary)]",
443                "{token.name}"
444            }
445            // 作用域胶囊
446            td { class: "px-4 py-3.5 whitespace-nowrap",
447                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",
448                    "{token.scope.as_str()}"
449                }
450                if token.notes.read || token.notes.write {
451                    p {class:"text-xs text-paper-accent mt-1",if token.notes.write {"笔记读写"} else {"知识库只读"}
452                        if let Some(ids)=&token.notes.notebook_ids {" · {ids.len()} 个笔记本"} else {" · 全部笔记本"}
453                    }
454                }
455            }
456            // 创建日期
457            td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
458                "{created}"
459            }
460            // 过期时间
461            td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
462                "{expires}"
463            }
464            // 最近使用
465            td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
466                "{last_used}"
467            }
468            // 状态胶囊
469            td { class: "px-4 py-3.5 text-center whitespace-nowrap",
470                if active {
471                    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",
472                        span { class: "w-1.5 h-1.5 rounded-full bg-emerald-500" }
473                        "有效"
474                    }
475                } else if is_expired {
476                    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",
477                        span { class: "w-1.5 h-1.5 rounded-full bg-amber-500" }
478                        "已过期"
479                    }
480                } else {
481                    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",
482                        span { class: "w-1.5 h-1.5 rounded-full bg-gray-400" }
483                        "已撤销"
484                    }
485                }
486            }
487            // 操作列
488            td { class: "px-5 py-3.5 text-right whitespace-nowrap",
489                if active {
490                    div { class: "flex justify-end items-center gap-1.5",
491                        button {
492                            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",
493                            onclick: move |_| state.editing.set(Some(edit_token.clone())),
494                            "编辑权限"
495                        }
496                        button {
497                            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",
498                            onclick: move |_| {
499                                let id = id_reveal.clone();
500                                spawn(async move {
501                                    match reveal_mcp_token(id.clone()).await {
502                                        Ok(Some(p)) => revealed.set(Some((id, p))),
503                                        Ok(None) => toast.set(Some(("无法解密该令牌".to_string(), true))),
504                                        Err(e) => toast.set(Some((format!("重查失败:{e}"), true))),
505                                    }
506                                });
507                            },
508                            svg {
509                                class: "w-3.5 h-3.5",
510                                xmlns: "http://www.w3.org/2000/svg",
511                                view_box: "0 0 24 24",
512                                fill: "none",
513                                stroke: "currentColor",
514                                stroke_width: "2",
515                                stroke_linecap: "round",
516                                stroke_linejoin: "round",
517                                path { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }
518                                circle { cx: "12", cy: "12", r: "3" }
519                            }
520                            "重新查看"
521                        }
522                        button {
523                            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",
524                            onclick: move |_| {
525                                let id = id_config.clone();
526                                spawn(async move {
527                                    match reveal_mcp_token(id).await {
528                                        Ok(Some(p)) => {
529                                            config_token.set(Some(p));
530                                            toast.set(Some(("已选为配置令牌".to_string(), false)));
531                                        }
532                                        Ok(None) => toast.set(Some(("无法解密该令牌".to_string(), true))),
533                                        Err(e) => toast.set(Some((format!("重查失败:{e}"), true))),
534                                    }
535                                });
536                            },
537                            svg {
538                                class: "w-3.5 h-3.5 text-[var(--color-paper-accent)]",
539                                xmlns: "http://www.w3.org/2000/svg",
540                                view_box: "0 0 24 24",
541                                fill: "none",
542                                stroke: "currentColor",
543                                stroke_width: "2",
544                                stroke_linecap: "round",
545                                stroke_linejoin: "round",
546                                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" }
547                            }
548                            "用于配置"
549                        }
550                        button {
551                            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",
552                            onclick: move |_| {
553                                let id = id_revoke.clone();
554                                spawn(async move {
555                                    match revoke_mcp_token(id).await {
556                                        Ok(()) => {
557                                            toast.set(Some(("已撤销".to_string(), false)));
558                                            let g = reload_gen();
559                                            state.reload_gen.set(g + 1);
560                                        }
561                                        Err(e) => toast.set(Some((format!("撤销失败:{e}"), true))),
562                                    }
563                                });
564                            },
565                            svg {
566                                class: "w-3.5 h-3.5",
567                                xmlns: "http://www.w3.org/2000/svg",
568                                view_box: "0 0 24 24",
569                                fill: "none",
570                                stroke: "currentColor",
571                                stroke_width: "2",
572                                stroke_linecap: "round",
573                                stroke_linejoin: "round",
574                                circle { cx: "12", cy: "12", r: "10" }
575                                line { x1: "4.93", y1: "4.93", x2: "19.07", y2: "19.07" }
576                            }
577                            "撤销"
578                        }
579                    }
580                } else {
581                    span { class: "text-xs text-[var(--color-paper-tertiary)]", "—" }
582                }
583            }
584        }
585    }
586}
587
588/// 新建令牌表单卡片。成功后把明文写入共享 context 的一次性弹窗。
589#[cfg(target_arch = "wasm32")]
590#[component]
591fn CreateTokenCard() -> Element {
592    let mut state: McpPageState = use_context();
593    let mut name = use_signal(String::new);
594    let mut scope = use_signal(|| TokenScope::Read);
595    let mut lifetime = use_signal(|| TokenLifetime::Days30);
596    let mut busy = use_signal(|| false);
597    let notes_read = use_signal(|| false);
598    let notes_write = use_signal(|| false);
599    let restricted = use_signal(|| false);
600    let selected_books = use_signal(Vec::<i32>::new);
601    let mut name_touched = use_signal(|| false);
602    let mut submitted = use_signal(|| false);
603
604    let name_error = crate::models::mcp_token::validate_token_name(&name()).err();
605    let show_name_error = (name_touched() || submitted()) && name_error.is_some();
606    let missing_books = notes_read() && restricted() && selected_books().is_empty();
607
608    let mut created_plaintext = state.created_plaintext;
609    let reload_gen = state.reload_gen;
610    let mut toast = state.toast;
611
612    rsx! {
613        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",
614            div { class: "flex items-center gap-2.5 border-b border-[var(--color-paper-border)]/60 pb-4",
615                svg {
616                    class: "w-5 h-5 text-[var(--color-paper-accent)]",
617                    xmlns: "http://www.w3.org/2000/svg",
618                    view_box: "0 0 24 24",
619                    fill: "none",
620                    stroke: "currentColor",
621                    stroke_width: "2",
622                    stroke_linecap: "round",
623                    stroke_linejoin: "round",
624                    line { x1: "12", y1: "5", x2: "12", y2: "19" }
625                    line { x1: "5", y1: "12", x2: "19", y2: "12" }
626                }
627                h2 { class: "text-lg sm:text-xl font-bold text-[var(--color-paper-primary)]",
628                    "新建访问令牌"
629                }
630            }
631
632            div { class: "grid grid-cols-1 md:grid-cols-3 gap-5",
633                // 名称
634                div { class: "flex flex-col gap-2",
635                    label { r#for: "mcp-token-name", class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
636                        "令牌名称 *"
637                    }
638                    input {
639                        id: "mcp-token-name",
640                        class: crate::components::forms::INPUT_CLASS,
641                        style: if show_name_error { "border-color: var(--color-red-500);" } else { "" },
642                        r#type: "text",
643                        required: true,
644                        aria_invalid: if show_name_error { "true" } else { "false" },
645                        aria_describedby: "mcp-token-name-hint",
646                        placeholder: "如 claude-code-macbook",
647                        value: name(),
648                        oninput: move |e| name.set(e.value()),
649                        onblur: move |_| name_touched.set(true),
650                    }
651                    p {
652                        id: "mcp-token-name-hint",
653                        class: if show_name_error { "text-xs text-red-500" } else { "text-xs text-paper-secondary" },
654                        aria_live: "polite",
655                        if show_name_error { "{name_error.unwrap_or_default()}" } else { "必填,最多 64 个字符" }
656                    }
657                }
658                // 作用域
659                div { class: "flex flex-col gap-2",
660                    label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
661                        "权限作用域"
662                    }
663                    FormSelect {
664                        value: scope(),
665                        options: SCOPE_OPTIONS.to_vec(),
666                        onchange: move |s| scope.set(s),
667                    }
668                }
669                // 有效期
670                div { class: "flex flex-col gap-2",
671                    label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
672                        "有效期"
673                    }
674                    FormSelect {
675                        value: lifetime(),
676                        options: LIFETIME_OPTIONS.to_vec(),
677                        onchange: move |l| lifetime.set(l),
678                    }
679                }
680            }
681
682            NoteGrantFields { notes_read, notes_write, restricted, selected_books, submitted: submitted(), id_prefix: "mcp-token" }
683
684            div { class: "pt-1",
685                button {
686                    class: "{BTN_PRIMARY} inline-flex items-center gap-1.5",
687                    disabled: busy(),
688                    onclick: move |_| {
689                        if busy() {
690                            return;
691                        }
692                        submitted.set(true);
693                        let n = name().trim().to_string();
694                        if crate::models::mcp_token::validate_token_name(&n).is_err() || missing_books {
695                            use wasm_bindgen::JsCast;
696
697                            let selector = if crate::models::mcp_token::validate_token_name(&n).is_err() {
698                                "#mcp-token-name"
699                            } else {
700                                ".mcp-token-notebook input"
701                            };
702                            if let Some(document) = web_sys::window().and_then(|w| w.document()) {
703                                let target = document.query_selector(selector).ok().flatten()
704                                    .or_else(|| document.query_selector("#mcp-token-restricted input").ok().flatten());
705                                if let Some(target) = target.and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok()) {
706                                    let _ = target.focus();
707                                }
708                            }
709                            return;
710                        }
711                        let sc = scope();
712                        let lt = lifetime();
713                        let notes=crate::models::mcp_token::NoteGrant {read:notes_read(),write:notes_write(),notebook_ids:if notes_read() && restricted(){Some(selected_books())}else{None}};
714                        busy.set(true);
715                        spawn(async move {
716                            match create_mcp_token(n, sc, lt, Some(notes)).await {
717                                Ok(resp) => {
718                                    created_plaintext.set(Some(resp.plaintext));
719                                    name.set(String::new());
720                                    name_touched.set(false);
721                                    submitted.set(false);
722                                    let g = reload_gen();
723                                    state.reload_gen.set(g + 1);
724                                }
725                                Err(e) => {
726                                    toast.set(Some((format!("创建失败:{e}"), true)));
727                                }
728                            }
729                            busy.set(false);
730                        });
731                    },
732                    if busy() {
733                        "创建中…"
734                    } else {
735                        "创建访问令牌"
736                    }
737                }
738            }
739        }
740    }
741}
742
743/// 创建和编辑令牌共用的笔记授权字段。
744#[cfg(target_arch = "wasm32")]
745#[component]
746fn NoteGrantFields(
747    mut notes_read: Signal<bool>,
748    mut notes_write: Signal<bool>,
749    mut restricted: Signal<bool>,
750    mut selected_books: Signal<Vec<i32>>,
751    submitted: bool,
752    id_prefix: &'static str,
753) -> Element {
754    let mut books =
755        use_resource(|| super::notes::client_request(crate::api::notes::owned_notebooks()));
756    let missing_books = notes_read() && restricted() && selected_books().is_empty();
757    rsx! {
758        fieldset { class: "min-w-0 space-y-3 text-sm border-t border-paper-border pt-4",
759            legend { class: "font-semibold", "笔记知识库授权" }
760            p { class: "text-xs text-paper-secondary", "与文章权限独立。只读授权可检索已收录的私人笔记;写入授权可读写工作稿,不会自动发布。" }
761            label { class: "flex items-center gap-2",
762                crate::components::ui::Checkbox { checked: notes_read(), onchange: move |v| { notes_read.set(v); if !v { notes_write.set(false); } } }
763                "读取我的知识库"
764            }
765            label { class: "flex items-center gap-2",
766                crate::components::ui::Checkbox { checked: notes_write(), onchange: move |v| { notes_write.set(v); if v { notes_read.set(true); } } }
767                "允许创建和修改笔记草稿"
768            }
769            if notes_read() {
770                label { id: "{id_prefix}-restricted", class: "flex items-center gap-2",
771                    crate::components::ui::Checkbox { checked: restricted(), onchange: move |v| restricted.set(v) }
772                    "仅授权指定笔记本"
773                }
774                if restricted() {
775                    match books.read().as_ref() {
776                        Some(Ok(items)) => rsx! {
777                            for book in items {
778                                label { class: "{id_prefix}-notebook flex items-center gap-2 ml-4", key: "{book.id}",
779                                    crate::components::ui::Checkbox {
780                                        checked: selected_books().contains(&book.id),
781                                        onchange: { let id = book.id; move |v| selected_books.with_mut(|ids| { ids.retain(|x| *x != id); if v { ids.push(id); } }) },
782                                    }
783                                    "{book.title}"
784                                }
785                            }
786                            if items.is_empty() { p { class: "text-xs text-paper-secondary", "请先在笔记管理中创建笔记本。" } }
787                        },
788                        Some(Err(e)) => rsx! {
789                            p { role: "alert", class: "text-xs text-red-500", "笔记本加载失败:{e}" }
790                            button { r#type: "button", class: "text-xs text-paper-accent", onclick: move |_| books.restart(), "重试" }
791                        },
792                        None => rsx! { p { class: "text-xs text-paper-secondary", "正在加载笔记本…" } },
793                    }
794                    p {
795                        class: if submitted && missing_books { "text-xs text-red-500" } else { "text-xs text-paper-secondary" },
796                        aria_live: "polite",
797                        if missing_books { "请至少选择一个笔记本(必选)" }
798                    }
799                }
800            }
801        }
802    }
803}
804
805#[cfg(target_arch = "wasm32")]
806#[component]
807fn EditTokenPermissionsModal(token: McpTokenSummary) -> Element {
808    let mut state: McpPageState = use_context();
809    let mut visible = use_signal(|| true);
810    let mut closing = use_signal(|| false);
811    let mut scope = use_signal(|| token.scope);
812    let notes_read = use_signal(|| token.notes.read);
813    let notes_write = use_signal(|| token.notes.write);
814    let restricted = use_signal(|| token.notes.notebook_ids.is_some());
815    let selected_books = use_signal(|| token.notes.notebook_ids.clone().unwrap_or_default());
816    let mut submitted = use_signal(|| false);
817    let mut busy = use_signal(|| false);
818    let mut error = use_signal(|| None::<String>);
819    use_effect(move || {
820        if !visible() && !closing() && !busy() {
821            state.editing.set(None);
822        }
823    });
824    rsx! {
825        crate::components::ui::ModalShell {
826            visible, closing, title: "编辑令牌权限", overlay_padding: "p-4",
827            panel_class: "w-full max-w-xl max-h-[90dvh] overflow-y-auto p-6",
828            div { class: "space-y-5", onkeydown: move |e| {
829                if e.key() == Key::Escape && !busy() { closing.set(true); visible.set(false); }
830            },
831                h2 { class: "text-lg font-bold", "编辑权限 · {token.name}" }
832                p { class: "text-sm text-paper-secondary", "保存后下次调用生效,无需更换客户端令牌。有效期保持不变。" }
833                fieldset { disabled: busy(), class: "min-w-0 space-y-5",
834                    div { class: "space-y-2",
835                        label { class: "text-sm font-semibold", "权限作用域" }
836                        FormSelect { value: scope(), options: SCOPE_OPTIONS.to_vec(), onchange: move |v| scope.set(v) }
837                    }
838                    NoteGrantFields { notes_read, notes_write, restricted, selected_books, submitted: submitted(), id_prefix: "mcp-edit-token" }
839                }
840                if let Some(message) = error() {
841                    p { role: "alert", class: "text-sm text-red-500", "{message}" }
842                }
843                div { class: "flex justify-end gap-3",
844                    button {
845                        class: crate::components::ui::BTN_SECONDARY,
846                        disabled: busy(),
847                        onclick: move |_| { closing.set(true); visible.set(false); },
848                        "取消"
849                    }
850                    button {
851                        class: BTN_PRIMARY_SM, disabled: busy(),
852                        onclick: move |_| {
853                            if busy() { return; }
854                            submitted.set(true);
855                            if notes_read() && restricted() && selected_books().is_empty() { return; }
856                            let notes = crate::models::mcp_token::NoteGrant {
857                                read: notes_read(), write: notes_write(),
858                                notebook_ids: if notes_read() && restricted() { Some(selected_books()) } else { None },
859                            };
860                            let id = token.id.clone();
861                            let scope = scope();
862                            busy.set(true);
863                            error.set(None);
864                            spawn(async move {
865                                match update_mcp_token_permissions(id, scope, notes).await {
866                                    Ok(()) => {
867                                        state.reload_gen.with_mut(|g| *g += 1);
868                                        state.toast.set(Some(("权限已更新,原令牌继续有效".into(), false)));
869                                        closing.set(true);
870                                        visible.set(false);
871                                    }
872                                    Err(e) => error.set(Some(format!("保存失败:{e}"))),
873                                }
874                                busy.set(false);
875                            });
876                        },
877                        if busy() { "保存中…" } else { "保存权限" }
878                    }
879                }
880            }
881        }
882    }
883}
884
885/// 客户端配置卡片(需先在令牌列表点「用于配置」或手动粘贴)。
886#[cfg(target_arch = "wasm32")]
887#[component]
888fn ConfigCard() -> Element {
889    let state: McpPageState = use_context();
890    let mut manual_token = use_signal(String::new);
891    let mut configs = use_signal(|| None::<McpClientConfigs>);
892    let mut loading = use_signal(|| false);
893
894    let config_token = state.config_token;
895    let mut toast = state.toast;
896
897    // 当 config_token 变化时,请求服务端生成配置。
898    use_effect(move || {
899        let token = config_token();
900        let Some(t) = token else {
901            return;
902        };
903        loading.set(true);
904        manual_token.set(t.clone());
905        spawn(async move {
906            match get_mcp_client_configs(t).await {
907                Ok(c) => configs.set(Some(c)),
908                Err(e) => toast.set(Some((format!("配置生成失败:{e}"), true))),
909            }
910            loading.set(false);
911        });
912    });
913
914    // 手动输入时也触发生成。
915    let mut on_manual_input = move |val: String| {
916        manual_token.set(val.clone());
917        if val.trim().is_empty() {
918            configs.set(None);
919            return;
920        }
921        if loading() {
922            return;
923        }
924        loading.set(true);
925        spawn(async move {
926            match get_mcp_client_configs(val).await {
927                Ok(c) => configs.set(Some(c)),
928                Err(e) => toast.set(Some((format!("配置生成失败:{e}"), true))),
929            }
930            loading.set(false);
931        });
932    };
933
934    rsx! {
935        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",
936            div { class: "flex flex-col gap-1 border-b border-[var(--color-paper-border)]/60 pb-4",
937                div { class: "flex items-center gap-2.5",
938                    svg {
939                        class: "w-5 h-5 text-[var(--color-paper-accent)]",
940                        xmlns: "http://www.w3.org/2000/svg",
941                        view_box: "0 0 24 24",
942                        fill: "none",
943                        stroke: "currentColor",
944                        stroke_width: "2",
945                        stroke_linecap: "round",
946                        stroke_linejoin: "round",
947                        polyline { points: "4 17 10 11 4 5" }
948                        line { x1: "12", y1: "19", x2: "20", y2: "19" }
949                    }
950                    h2 { class: "text-lg sm:text-xl font-bold text-[var(--color-paper-primary)]",
951                        "客户端接入配置"
952                    }
953                }
954                p { class: "text-sm text-[var(--color-paper-secondary)] mt-1",
955                    "在上方令牌列表点击「用于配置」自动填入,或直接粘贴令牌明文(ygg_...)。"
956                }
957            }
958
959            div { class: "flex flex-col gap-2",
960                label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
961                    "令牌明文 (Bearer Token)"
962                }
963                div { class: "relative",
964                    FormInput {
965                        r#type: "text",
966                        placeholder: "ygg_xxxxxxxxxxxxxxxxxxxx",
967                        value: manual_token(),
968                        oninput: move |v: String| on_manual_input(v),
969                    }
970                }
971            }
972
973            if loading() {
974                div { class: "flex flex-col gap-4",
975                    for i in 0..CONFIG_SKELETON_SHAPES.len() {
976                        div { key: "{i}", class: "flex flex-col gap-2",
977                            div { class: "flex items-center justify-between",
978                                SkeletonBox {
979                                    class: "h-4 rounded",
980                                    style: CONFIG_SKELETON_SHAPES[i].0,
981                                }
982                                SkeletonBox { class: "h-7 w-14 rounded-full" }
983                            }
984                            SkeletonBox {
985                                class: "rounded-lg",
986                                style: CONFIG_SKELETON_SHAPES[i].1,
987                            }
988                        }
989                    }
990                }
991            } else if let Some(c) = configs() {
992                div { class: "grid grid-cols-1 gap-5",
993                    for (i, snippet) in c.snippets.iter().enumerate() {
994                        div {
995                            key: "{snippet.title}",
996                            class: "animate-row-enter",
997                            style: "animation-delay: {i * 40}ms",
998                            ConfigSnippet { snippet: snippet.clone() }
999                        }
1000                    }
1001                }
1002            } else {
1003                p { class: "text-sm text-[var(--color-paper-tertiary)] py-8 text-center",
1004                    "填入令牌明文后此处自动生成针对各客户端的接入配置代码。"
1005                }
1006            }
1007        }
1008    }
1009}
1010
1011/// 单个配置片段卡片(标题 + 代码块 + 复制按钮)。
1012#[cfg(target_arch = "wasm32")]
1013#[component]
1014fn ConfigSnippet(snippet: McpConfigSnippet) -> Element {
1015    let state: McpPageState = use_context();
1016    let mut toast = state.toast;
1017    let mut copied = use_signal(|| false);
1018    let title = snippet.title.clone();
1019    let copied_now = copied();
1020    let btn_class = if copied_now {
1021        BTN_COPIED_SM
1022    } else {
1023        BTN_PRIMARY_SM
1024    };
1025    let btn_label = if copied_now { "已复制" } else { "复制" };
1026    rsx! {
1027        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",
1028            div { class: "flex items-center justify-between",
1029                span { class: "text-sm font-semibold text-[var(--color-paper-primary)]",
1030                    "{snippet.title}"
1031                }
1032                button {
1033                    class: "{btn_class}",
1034                    onclick: move |_| {
1035                        let cc = snippet.content.clone();
1036                        let tt = title.clone();
1037                        copied.set(true);
1038                        spawn(async move {
1039                            copy_clipboard_wasm(&cc).await;
1040                            toast.set(Some((format!("已复制:{tt}"), false)));
1041                            crate::utils::time::sleep_ms(1500).await;
1042                            copied.set(false);
1043                        });
1044                    },
1045                    "{btn_label}"
1046                }
1047            }
1048            div { class: "md-content",
1049                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",
1050                    code { dangerous_inner_html: "{snippet.content_html}" }
1051                }
1052            }
1053        }
1054    }
1055}
1056
1057/// 明文展示弹窗。
1058#[cfg(target_arch = "wasm32")]
1059#[component]
1060fn PlaintextModal(
1061    title: String,
1062    plaintext: String,
1063    on_use_config: EventHandler<()>,
1064    on_close: EventHandler<()>,
1065) -> Element {
1066    let mut copied = use_signal(|| false);
1067    let copied_now = copied();
1068    let btn_class = if copied_now {
1069        BTN_COPIED_SM
1070    } else {
1071        BTN_PRIMARY_SM
1072    };
1073    let btn_label = if copied_now { "已复制" } else { "复制" };
1074    rsx! {
1075        div {
1076            class: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-modal-overlay-enter",
1077            onclick: move |_| on_close.call(()),
1078            div {
1079                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",
1080                onclick: move |e| e.stop_propagation(),
1081                div { class: "flex items-center justify-between border-b border-[var(--color-paper-border)]/60 pb-3",
1082                    h3 { class: "text-base sm:text-lg font-bold text-[var(--color-paper-primary)]",
1083                        "{title}"
1084                    }
1085                    button {
1086                        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",
1087                        onclick: move |_| on_close.call(()),
1088                        "×"
1089                    }
1090                }
1091                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",
1092                    code { "{plaintext}" }
1093                }
1094                div { class: "flex flex-wrap gap-2.5 justify-end pt-1",
1095                    button {
1096                        class: "{btn_class}",
1097                        onclick: move |_| {
1098                            let p = plaintext.clone();
1099                            copied.set(true);
1100                            spawn(async move {
1101                                copy_clipboard_wasm(&p).await;
1102                                crate::utils::time::sleep_ms(1500).await;
1103                                copied.set(false);
1104                            });
1105                        },
1106                        "{btn_label}"
1107                    }
1108                    button {
1109                        class: "{BTN_PRIMARY_SM}",
1110                        onclick: move |_| on_use_config.call(()),
1111                        "用于配置"
1112                    }
1113                    button {
1114                        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",
1115                        onclick: move |_| on_close.call(()),
1116                        "关闭"
1117                    }
1118                }
1119            }
1120        }
1121    }
1122}
1123
1124/// WASM 端剪贴板写入(navigator.clipboard.writeText)。
1125///
1126/// 失败静默忽略(非关键路径;用户可手动选中复制)。
1127#[cfg(target_arch = "wasm32")]
1128async fn copy_clipboard_wasm(text: &str) {
1129    use wasm_bindgen_futures::JsFuture;
1130
1131    let Some(window) = web_sys::window() else {
1132        return;
1133    };
1134    // web-sys 的 Navigator::clipboard() 直接返回 Clipboard(非 Option)。
1135    let clipboard = window.navigator().clipboard();
1136    // write_text 返回 Promise<void>;忽略 reject(如非 HTTPS / 无焦点)。
1137    let _ = JsFuture::from(clipboard.write_text(text)).await;
1138}