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::{
31 ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS, BADGE_BASE, BTN_PRIMARY, BTN_PRIMARY_SM, BTN_TEXT_RED,
32};
33#[cfg(target_arch = "wasm32")]
34use crate::models::mcp_token::{McpTokenSummary, TokenScope};
35
36#[cfg(target_arch = "wasm32")]
38const SCOPE_OPTIONS: &[(TokenScope, &str)] = &[
39 (TokenScope::Read, "read — 仅查询已发布文章"),
40 (TokenScope::Write, "write — read + 文章/评论/标签/媒体 CRUD"),
41 (TokenScope::Admin, "admin — write + 站点设置 / 代码运行器"),
42];
43
44#[cfg(target_arch = "wasm32")]
46const LIFETIME_OPTIONS: &[(TokenLifetime, &str)] = &[
47 (TokenLifetime::Days1, "1 天"),
48 (TokenLifetime::Days7, "7 天"),
49 (TokenLifetime::Days30, "30 天"),
50 (TokenLifetime::Days90, "90 天"),
51 (TokenLifetime::Never, "永不过期"),
52];
53
54#[cfg(target_arch = "wasm32")]
59const CONFIG_SKELETON_SHAPES: &[(&str, &str)] = &[
60 ("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;"), ];
68
69#[cfg(target_arch = "wasm32")]
74#[derive(Clone, Copy, PartialEq)]
75struct McpPageState {
76 reload_gen: Signal<u32>,
78 created_plaintext: Signal<Option<String>>,
80 revealed: Signal<Option<(String, String)>>,
82 config_token: Signal<Option<String>>,
84 toast: Signal<Option<(String, bool)>>,
86}
87
88#[component]
90#[allow(clippy::needless_pass_by_value)]
91pub fn Mcp() -> Element {
92 #[cfg(target_arch = "wasm32")]
93 {
94 let state = McpPageState {
95 reload_gen: use_signal(|| 0),
96 created_plaintext: use_signal(|| None),
97 revealed: use_signal(|| None),
98 config_token: use_signal(|| None),
99 toast: use_signal(|| None),
100 };
101 use_context_provider(|| state);
102
103 rsx! {
104 div { class: "w-full max-w-7xl mx-auto space-y-8",
105 PageHeader {}
106 Toast {}
107 TokenList {}
108 CreateTokenCard {}
109 ConfigCard {}
110 }
111 }
112 }
113 #[cfg(not(target_arch = "wasm32"))]
114 {
115 rsx! {
117 p { class: "text-paper-secondary", "此页面仅在浏览器中可用。" }
118 }
120 }
121}
122
123#[component]
125fn PageHeader() -> Element {
126 rsx! {
127 div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-8 border-b border-[var(--color-paper-border)]/50",
128 div {
129 h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
130 "MCP 服务器"
131 }
132 p { class: "text-base text-[var(--color-paper-secondary)] mt-2",
133 "为 AI 客户端(Claude Code / Cursor / Cline)签发访问令牌,并复制接入配置。"
134 }
135 }
136 }
137 }
138}
139
140#[cfg(target_arch = "wasm32")]
142#[component]
143fn Toast() -> Element {
144 let state: McpPageState = use_context();
145 let toast = state.toast;
146 if let Some((msg, is_err)) = toast() {
147 let cls = if is_err {
148 "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300"
149 } else {
150 "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
151 };
152 rsx! {
153 div { class: "text-sm rounded-lg px-3 py-2 {cls}", "{msg}" }
154 }
155 } else {
156 rsx! {}
157 }
158}
159
160#[cfg(target_arch = "wasm32")]
162#[component]
163fn TokenList() -> Element {
164 let mut state: McpPageState = use_context();
165 let mut tokens = use_signal(|| Vec::<McpTokenSummary>::new());
166 let mut loaded_gen = use_signal(|| None::<u32>);
167 let mut loading = use_signal(|| true);
168
169 let reload_gen = state.reload_gen;
170 let mut toast = state.toast;
171
172 use_effect(move || {
174 let g = reload_gen();
175 if loaded_gen() != Some(g) {
176 loaded_gen.set(Some(g));
177 spawn(async move {
178 match list_mcp_tokens().await {
179 Ok(list) => tokens.set(list),
180 Err(e) => toast.set(Some((format!("加载失败:{e}"), true))),
181 }
182 loading.set(false);
183 });
184 }
185 });
186
187 let mut created_plaintext = state.created_plaintext;
188 let mut revealed = state.revealed;
189
190 rsx! {
191 div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-6",
192 div { class: "flex items-center justify-between",
193 h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]",
194 "令牌列表"
195 }
196 button {
197 class: "text-xs text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
198 onclick: move |_| {
199 let g = reload_gen();
200 state.reload_gen.set(g + 1);
201 },
202 "刷新"
203 }
204 }
205
206 if let Some(plaintext) = created_plaintext() {
208 PlaintextModal {
209 title: "令牌已创建(请立即复制,可稍后重新查看)".to_string(),
210 plaintext: plaintext.clone(),
211 on_use_config: {
212 let mut ct = state.config_token;
213 let mut cp = state.created_plaintext;
214 let mut toast = state.toast;
215 move |_| {
216 ct.set(Some(plaintext.clone()));
217 cp.set(None);
218 toast.set(Some(("已选为配置令牌".to_string(), false)));
219 }
220 },
221 on_close: move |_| created_plaintext.set(None),
222 }
223 }
224
225 if let Some((_, plaintext)) = revealed() {
227 PlaintextModal {
228 title: "令牌明文".to_string(),
229 plaintext: plaintext.clone(),
230 on_use_config: {
231 let mut ct = state.config_token;
232 let mut rev = state.revealed;
233 let mut toast = state.toast;
234 move |_| {
235 ct.set(Some(plaintext.clone()));
236 rev.set(None);
237 toast.set(Some(("已选为配置令牌".to_string(), false)));
238 }
239 },
240 on_close: move |_| revealed.set(None),
241 }
242 }
243
244 if loading() && tokens().is_empty() {
246 DelayedSkeleton {
247 div { class: "{ADMIN_TABLE_CLASS}",
248 table { class: "w-full text-sm",
249 thead {
250 tr { class: "bg-[var(--color-paper-theme)]/50",
251 th { class: "px-4 py-3",
252 SkeletonBox { class: "h-3 w-10" }
253 }
254 th { class: "px-4 py-3",
255 SkeletonBox { class: "h-3 w-8" }
256 }
257 th { class: "px-4 py-3",
258 SkeletonBox { class: "h-3 w-8" }
259 }
260 th { class: "px-4 py-3",
261 SkeletonBox { class: "h-3 w-8" }
262 }
263 th { class: "px-4 py-3",
264 SkeletonBox { class: "h-3 w-12" }
265 }
266 th { class: "px-4 py-3",
267 SkeletonBox { class: "h-3 w-8" }
268 }
269 th { class: "px-4 py-3",
270 SkeletonBox { class: "h-3 w-10 ml-auto" }
271 }
272 }
273 }
274 tbody {
275 for _ in 0..4 {
276 tr { class: "border-b border-[var(--color-paper-border)] last:border-b-0",
277 td { class: "px-4 py-3",
278 SkeletonBox { class: "h-4 w-28" }
279 }
280 td { class: "px-4 py-3",
281 SkeletonBox { class: "h-4 w-12" }
282 }
283 td { class: "px-4 py-3",
284 SkeletonBox { class: "h-4 w-16" }
285 }
286 td { class: "px-4 py-3",
287 SkeletonBox { class: "h-4 w-16" }
288 }
289 td { class: "px-4 py-3",
290 SkeletonBox { class: "h-4 w-20" }
291 }
292 td { class: "px-4 py-3",
293 SkeletonBox { class: "h-5 w-10 rounded-full" }
294 }
295 td { class: "px-4 py-3",
296 SkeletonBox { class: "h-4 w-24 ml-auto" }
297 }
298 }
299 }
300 }
301 }
302 }
303 }
304 } else if tokens().is_empty() {
305 p { class: "text-[var(--color-paper-secondary)] text-sm py-4 text-center",
306 "暂无令牌。在下方新建一个。"
307 }
308 } else {
309 div { class: "{ADMIN_TABLE_CLASS}",
310 table { class: "w-full text-sm",
311 thead {
312 tr { class: "bg-[var(--color-paper-theme)]/50 text-left text-[var(--color-paper-secondary)]",
313 th { class: "px-4 py-3 font-medium", "名称" }
314 th { class: "px-4 py-3 font-medium", "作用域" }
315 th { class: "px-4 py-3 font-medium whitespace-nowrap",
316 "创建"
317 }
318 th { class: "px-4 py-3 font-medium whitespace-nowrap",
319 "过期"
320 }
321 th { class: "px-4 py-3 font-medium whitespace-nowrap",
322 "最近使用"
323 }
324 th { class: "px-4 py-3 font-medium whitespace-nowrap",
325 "状态"
326 }
327 th { class: "px-4 py-3 font-medium text-right", "操作" }
328 }
329 }
330 tbody {
331 for t in tokens().iter() {
332 TokenRow {
333 key: "{t.id}",
334 token: t.clone(),
335 state,
336 }
337 }
338 }
339 }
340 }
341 }
342 }
343 }
344}
345
346#[cfg(target_arch = "wasm32")]
348#[component]
349fn TokenRow(token: McpTokenSummary, state: McpPageState) -> Element {
350 let is_revoked = token.revoked_at.is_some();
351 let is_expired = token
352 .expires_at
353 .map(|e| e < chrono::Utc::now())
354 .unwrap_or(false);
355 let active = !is_revoked && !is_expired;
356 let created = token.created_at.format("%Y-%m-%d").to_string();
357 let expires = token
358 .expires_at
359 .map(|e| e.format("%Y-%m-%d").to_string())
360 .unwrap_or_else(|| "永不过期".to_string());
361 let last_used = token
362 .last_used_at
363 .map(|e| e.format("%Y-%m-%d %H:%M").to_string())
364 .unwrap_or_else(|| "—".to_string());
365 let status_label = if is_revoked {
366 "已撤销"
367 } else if is_expired {
368 "已过期"
369 } else {
370 "有效"
371 };
372 let status_class = if active {
373 "bg-green-500/10 text-green-600 dark:text-green-400"
374 } else {
375 "bg-gray-500/10 text-gray-500 dark:text-gray-400"
376 };
377
378 let id_reveal = token.id.clone();
379 let id_config = token.id.clone();
380 let id_revoke = token.id.clone();
381
382 let mut revealed = state.revealed;
384 let mut toast = state.toast;
385 let mut config_token = state.config_token;
386 let reload_gen = state.reload_gen;
387
388 rsx! {
389 tr { class: "border-b border-[var(--color-paper-border)] last:border-b-0 hover:bg-[var(--color-paper-theme)]/30 transition-colors",
390 td { class: "px-4 py-3 font-medium text-[var(--color-paper-primary)]",
391 "{token.name}"
392 }
393 td { class: "px-4 py-3", "{token.scope.as_str()}" }
394 td { class: "px-4 py-3 text-[var(--color-paper-secondary)] whitespace-nowrap",
395 "{created}"
396 }
397 td { class: "px-4 py-3 text-[var(--color-paper-secondary)] whitespace-nowrap",
398 "{expires}"
399 }
400 td { class: "px-4 py-3 text-[var(--color-paper-secondary)] whitespace-nowrap",
401 "{last_used}"
402 }
403 td { class: "px-4 py-3 whitespace-nowrap",
404 span { class: "{BADGE_BASE} {status_class}", "{status_label}" }
405 }
406 td { class: "px-4 py-3 text-right whitespace-nowrap",
407 if active {
408 button {
409 class: "text-xs text-[var(--color-paper-accent)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer mr-3",
410 onclick: move |_| {
411 let id = id_reveal.clone();
412 spawn(async move {
413 match reveal_mcp_token(id.clone()).await {
414 Ok(Some(p)) => revealed.set(Some((id, p))),
415 Ok(None) => toast.set(Some(("无法解密该令牌".to_string(), true))),
416 Err(e) => toast.set(Some((format!("重查失败:{e}"), true))),
417 }
418 });
419 },
420 "重新查看"
421 }
422 button {
423 class: "text-xs text-[var(--color-paper-accent)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer mr-3",
424 onclick: move |_| {
425 let id = id_config.clone();
426 spawn(async move {
427 match reveal_mcp_token(id).await {
428 Ok(Some(p)) => {
429 config_token.set(Some(p));
430 toast.set(Some(("已选为配置令牌".to_string(), false)));
431 }
432 Ok(None) => toast.set(Some(("无法解密该令牌".to_string(), true))),
433 Err(e) => toast.set(Some((format!("重查失败:{e}"), true))),
434 }
435 });
436 },
437 "用于配置"
438 }
439 button {
440 class: "{BTN_TEXT_RED}",
441 onclick: move |_| {
442 let id = id_revoke.clone();
443 spawn(async move {
444 match revoke_mcp_token(id).await {
445 Ok(()) => {
446 toast.set(Some(("已撤销".to_string(), false)));
447 let g = reload_gen();
448 state.reload_gen.set(g + 1);
449 }
450 Err(e) => toast.set(Some((format!("撤销失败:{e}"), true))),
451 }
452 });
453 },
454 "撤销"
455 }
456 } else {
457 span { class: "text-xs text-[var(--color-paper-tertiary)]", "—" }
458 }
459 }
460 }
461 }
462}
463
464#[cfg(target_arch = "wasm32")]
466#[component]
467fn CreateTokenCard() -> Element {
468 let mut state: McpPageState = use_context();
469 let mut name = use_signal(String::new);
470 let mut scope = use_signal(|| TokenScope::Read);
471 let mut lifetime = use_signal(|| TokenLifetime::Days30);
472 let mut busy = use_signal(|| false);
473
474 let mut created_plaintext = state.created_plaintext;
475 let reload_gen = state.reload_gen;
476 let mut toast = state.toast;
477
478 rsx! {
479 div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-6",
480 h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]", "新建令牌" }
481
482 div { class: "grid grid-cols-1 md:grid-cols-3 gap-4",
483 div { class: "flex flex-col gap-2",
485 label { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
486 "名称"
487 }
488 FormInput {
489 r#type: "text",
490 placeholder: "如 claude-code-macbook",
491 value: name(),
492 oninput: move |v: String| name.set(v),
493 }
494 }
495 div { class: "flex flex-col gap-2",
497 label { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
498 "作用域"
499 }
500 FormSelect {
501 value: scope(),
502 options: SCOPE_OPTIONS.to_vec(),
503 onchange: move |s| scope.set(s),
504 }
505 }
506 div { class: "flex flex-col gap-2",
508 label { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
509 "有效期"
510 }
511 FormSelect {
512 value: lifetime(),
513 options: LIFETIME_OPTIONS.to_vec(),
514 onchange: move |l| lifetime.set(l),
515 }
516 }
517 }
518
519 div {
520 button {
521 class: "{BTN_PRIMARY}",
522 disabled: "{busy() || name().trim().is_empty()}",
523 onclick: move |_| {
524 if busy() {
525 return;
526 }
527 let n = name().trim().to_string();
528 if n.is_empty() {
529 return;
530 }
531 let sc = scope();
532 let lt = lifetime();
533 busy.set(true);
534 spawn(async move {
535 match create_mcp_token(n, sc, lt).await {
536 Ok(resp) => {
537 created_plaintext.set(Some(resp.plaintext));
538 name.set(String::new());
539 let g = reload_gen();
540 state.reload_gen.set(g + 1);
541 }
542 Err(e) => {
543 toast.set(Some((format!("创建失败:{e}"), true)));
544 }
545 }
546 busy.set(false);
547 });
548 },
549 if busy() {
550 "创建中…"
551 } else {
552 "创建令牌"
553 }
554 }
555 }
556 }
557 }
558}
559
560#[cfg(target_arch = "wasm32")]
562#[component]
563fn ConfigCard() -> Element {
564 let state: McpPageState = use_context();
565 let mut manual_token = use_signal(String::new);
566 let mut configs = use_signal(|| None::<McpClientConfigs>);
567 let mut loading = use_signal(|| false);
568
569 let config_token = state.config_token;
570 let mut toast = state.toast;
571
572 use_effect(move || {
574 let token = config_token();
575 let Some(t) = token else {
576 return;
577 };
578 loading.set(true);
579 manual_token.set(t.clone());
580 spawn(async move {
581 match get_mcp_client_configs(t).await {
582 Ok(c) => configs.set(Some(c)),
583 Err(e) => toast.set(Some((format!("配置生成失败:{e}"), true))),
584 }
585 loading.set(false);
586 });
587 });
588
589 let mut on_manual_input = move |val: String| {
591 manual_token.set(val.clone());
592 if val.trim().is_empty() {
593 configs.set(None);
594 return;
595 }
596 if loading() {
597 return;
598 }
599 loading.set(true);
600 spawn(async move {
601 match get_mcp_client_configs(val).await {
602 Ok(c) => configs.set(Some(c)),
603 Err(e) => toast.set(Some((format!("配置生成失败:{e}"), true))),
604 }
605 loading.set(false);
606 });
607 };
608
609 rsx! {
610 div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-6",
611 h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]", "客户端配置" }
612 p { class: "text-sm text-[var(--color-paper-secondary)]",
613 "在上方令牌列表点「用于配置」自动填入,或在下方手动粘贴令牌明文(形如 ygg_...)。"
614 }
615
616 div { class: "flex flex-col gap-2",
617 label { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
618 "令牌明文"
619 }
620 FormInput {
621 r#type: "text",
622 placeholder: "ygg_...",
623 value: manual_token(),
624 oninput: move |v: String| on_manual_input(v),
625 }
626 }
627
628 if loading() {
629 div { class: "flex flex-col gap-4",
630 for i in 0..7 {
631 div { key: "{i}", class: "flex flex-col gap-2",
632 div { class: "flex items-center justify-between",
633 SkeletonBox {
634 class: "h-4 rounded",
635 style: CONFIG_SKELETON_SHAPES[i].0,
636 }
637 SkeletonBox { class: "h-7 w-14 rounded-full" }
638 }
639 SkeletonBox {
640 class: "rounded-lg",
641 style: CONFIG_SKELETON_SHAPES[i].1,
642 }
643 }
644 }
645 }
646 } else if let Some(c) = configs() {
647 div { class: "flex flex-col gap-4",
648 for snippet in c.snippets.iter() {
649 ConfigSnippet { key: "{snippet.title}", snippet: snippet.clone() }
650 }
651 }
652 } else {
653 p { class: "text-sm text-[var(--color-paper-tertiary)] py-4 text-center",
654 "粘贴令牌明文后此处显示配置片段。"
655 }
656 }
657 }
658 }
659}
660
661#[cfg(target_arch = "wasm32")]
663#[component]
664fn ConfigSnippet(snippet: McpConfigSnippet) -> Element {
665 let state: McpPageState = use_context();
666 let mut toast = state.toast;
667 let mut copied = use_signal(|| false);
668 let title = snippet.title.clone();
669 let copied_now = copied();
672 let btn_class = if copied_now {
673 "inline-flex items-center justify-center px-4 py-1.5 text-sm font-medium \
674 text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/30 \
675 rounded-full transition-all cursor-default"
676 } else {
677 BTN_PRIMARY_SM
678 };
679 let btn_label = if copied_now { "已复制" } else { "复制" };
680 rsx! {
681 div { class: "flex flex-col gap-2",
682 div { class: "flex items-center justify-between",
683 span { class: "text-sm font-medium text-[var(--color-paper-primary)]",
684 "{snippet.title}"
685 }
686 button {
687 class: "{btn_class}",
688 onclick: move |_| {
689 let cc = snippet.content.clone();
690 let tt = title.clone();
691 copied.set(true);
692 spawn(async move {
693 copy_clipboard_wasm(&cc).await;
694 toast.set(Some((format!("已复制:{tt}"), false)));
695 crate::utils::time::sleep_ms(1500).await;
696 copied.set(false);
697 });
698 },
699 "{btn_label}"
700 }
701 }
702 div { class: "md-content",
705 pre { class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-xs overflow-x-auto font-mono",
706 code { dangerous_inner_html: "{snippet.content_html}" }
707 }
708 }
709 }
710 }
711}
712
713#[cfg(target_arch = "wasm32")]
715#[component]
716fn PlaintextModal(
717 title: String,
718 plaintext: String,
719 on_use_config: EventHandler<()>,
720 on_close: EventHandler<()>,
721) -> Element {
722 rsx! {
723 div {
724 class: "fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4",
725 onclick: move |_| on_close.call(()),
726 div {
727 class: "{ADMIN_CARD_CLASS} p-8 max-w-2xl w-full flex flex-col gap-4",
728 onclick: move |e| e.stop_propagation(),
729 h3 { class: "text-lg font-bold text-[var(--color-paper-primary)]",
730 "{title}"
731 }
732 pre { class: "bg-[var(--color-paper-code-bg)] text-[var(--color-paper-primary)] rounded-lg p-3 text-sm overflow-x-auto font-mono break-all",
733 code { "{plaintext}" }
734 }
735 div { class: "flex flex-wrap gap-3 justify-end",
736 button {
737 class: "{BTN_PRIMARY_SM}",
738 onclick: move |_| {
739 let p = plaintext.clone();
740 spawn(async move {
741 copy_clipboard_wasm(&p).await;
742 });
743 },
744 "复制"
745 }
746 button {
747 class: "{BTN_PRIMARY_SM}",
748 onclick: move |_| on_use_config.call(()),
749 "用于配置"
750 }
751 button {
752 class: "px-4 py-1.5 text-sm font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
753 onclick: move |_| on_close.call(()),
754 "关闭"
755 }
756 }
757 }
758 }
759 }
760}
761
762#[cfg(target_arch = "wasm32")]
766async fn copy_clipboard_wasm(text: &str) {
767 use wasm_bindgen_futures::JsFuture;
768
769 let Some(window) = web_sys::window() else {
770 return;
771 };
772 let clipboard = window.navigator().clipboard();
774 let _ = JsFuture::from(clipboard.write_text(text)).await;
776}