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 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#[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: 88px;"), ("width: 280px;", "height: 168px;"), ("width: 260px;", "height: 168px;"), ("width: 200px;", "height: 136px;"), ("width: 220px;", "height: 168px;"), ("width: 160px;", "height: 104px;"), ("width: 140px;", "height: 56px;"), ];
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#[cfg(target_arch = "wasm32")]
78#[derive(Clone, Copy, PartialEq)]
79struct McpPageState {
80 reload_gen: Signal<u32>,
82 created_plaintext: Signal<Option<String>>,
84 revealed: Signal<Option<(String, String)>>,
86 config_token: Signal<Option<String>>,
88 toast: Signal<Option<(String, bool)>>,
90 editing: Signal<Option<McpTokenSummary>>,
91}
92
93#[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 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 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 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 rsx! {
187 p { class: "text-paper-secondary", "此页面仅在浏览器中可用。" }
188 }
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#[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#[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 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 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#[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 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 td { class: "px-5 py-3.5 font-semibold text-[var(--color-paper-primary)]",
443 "{token.name}"
444 }
445 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 td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
458 "{created}"
459 }
460 td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
462 "{expires}"
463 }
464 td { class: "px-4 py-3.5 text-xs font-mono text-[var(--color-paper-secondary)] whitespace-nowrap",
466 "{last_used}"
467 }
468 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 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#[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 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 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 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#[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#[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 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 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#[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#[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#[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 let clipboard = window.navigator().clipboard();
1136 let _ = JsFuture::from(clipboard.write_text(text)).await;
1138}