1use 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#[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#[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#[cfg(target_arch = "wasm32")]
57const CONFIG_SKELETON_SHAPES: &[(&str, &str)] = &[
58 ("width: 320px;", "height: 152px;"), ("width: 280px;", "height: 168px;"), ("width: 260px;", "height: 168px;"), ("width: 200px;", "height: 136px;"), ("width: 220px;", "height: 168px;"), ("width: 160px;", "height: 104px;"), ("width: 140px;", "height: 56px;"), ];
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#[cfg(target_arch = "wasm32")]
77#[derive(Clone, Copy, PartialEq)]
78struct McpPageState {
79 reload_gen: Signal<u32>,
81 created_plaintext: Signal<Option<String>>,
83 revealed: Signal<Option<(String, String)>>,
85 config_token: Signal<Option<String>>,
87 toast: Signal<Option<(String, bool)>>,
89}
90
91#[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 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 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 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 rsx! {
181 p { class: "text-paper-secondary", "此页面仅在浏览器中可用。" }
182 }
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#[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#[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 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 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#[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 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 td { class: "px-5 py-3.5 font-semibold text-[var(--color-paper-primary)]",
436 "{token.name}"
437 }
438 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 td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
446 "{created}"
447 }
448 td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
450 "{expires}"
451 }
452 td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
454 "{last_used}"
455 }
456 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 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#[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 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 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 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#[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 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 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#[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#[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#[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 let clipboard = window.navigator().clipboard();
934 let _ = JsFuture::from(clipboard.write_text(text)).await;
936}