1use dioxus::prelude::*;
12
13#[cfg(target_arch = "wasm32")]
14use crate::api::friends::{
15 create_friend_link, delete_friend_link, list_all_friend_links, update_friend_link,
16};
17#[cfg(target_arch = "wasm32")]
18use crate::components::assets::{AssetPickerModal, AssetSelection};
19#[cfg(target_arch = "wasm32")]
20use crate::components::forms::{FormInput, FormSelect, INPUT_CLASS, INPUT_INLINE_CLASS};
21#[cfg(target_arch = "wasm32")]
22use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
23#[cfg(target_arch = "wasm32")]
24use crate::components::skeletons::friends_admin_skeleton::FriendsAdminListSkeleton;
25#[cfg(target_arch = "wasm32")]
26use crate::components::ui::{Popover, BTN_DANGER_OUTLINE, BTN_GHOST, BTN_OUTLINE, BTN_PRIMARY};
27#[cfg(target_arch = "wasm32")]
28use crate::models::friend_link::FriendLink;
29use crate::router::Route;
30#[cfg(target_arch = "wasm32")]
34#[derive(Clone, Copy, PartialEq)]
35struct FriendsPageState {
36 reload_gen: Signal<u32>,
38 toast: Signal<Option<(String, bool)>>,
40 editing: Signal<Option<FriendLink>>,
42}
43
44#[component]
46pub fn FriendsAdmin() -> Element {
47 #[cfg(target_arch = "wasm32")]
48 {
49 let state = FriendsPageState {
50 reload_gen: use_signal(|| 0),
51 toast: use_signal(|| None),
52 editing: use_signal(|| None),
53 };
54 use_context_provider(|| state);
55
56 rsx! {
57 div { class: "w-full max-w-7xl mx-auto space-y-8",
58 div { class: "animate-page-enter",
59 div {
60 class: "animate-row-enter",
61 style: "animation-delay: 0ms",
62 PageHeader {}
63 }
64 }
65 Toast {}
66 EditorCard {}
67 div {
68 class: "animate-row-enter",
69 style: "animation-delay: 120ms",
70 LinkList {}
71 }
72 }
73 }
74 }
75 #[cfg(not(target_arch = "wasm32"))]
76 {
77 rsx! {
79 p { class: "text-paper-secondary", "此页面仅在浏览器中可用。" }
80 }
81 }
82}
83
84#[component]
86fn PageHeader() -> Element {
87 rsx! {
88 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",
89 div {
90 h1 { class: "text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
91 "友链管理"
92 }
93 p { class: "text-sm text-[var(--color-paper-secondary)] mt-1.5",
94 "维护前台 /friends 页面展示的友链与合作伙伴"
95 }
96 }
97 div { class: "flex items-center gap-3",
98 Link {
99 class: "inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
100 to: NavigationTarget::<Route>::External("/friends".to_string()),
101 svg {
102 class: "w-4 h-4",
103 xmlns: "http://www.w3.org/2000/svg",
104 view_box: "0 0 24 24",
105 fill: "none",
106 stroke: "currentColor",
107 stroke_width: "2",
108 stroke_linecap: "round",
109 stroke_linejoin: "round",
110 path { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" }
111 polyline { points: "15 3 21 3 21 9" }
112 line { x1: "10", y1: "14", x2: "21", y2: "3" }
113 }
114 "查看前台页面"
115 }
116 }
117 }
118 }
119}
120
121#[cfg(target_arch = "wasm32")]
123#[component]
124fn Toast() -> Element {
125 let state: FriendsPageState = use_context();
126 let toast = state.toast;
127 if let Some((msg, is_err)) = toast() {
128 let cls = if is_err {
129 "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300"
130 } else {
131 "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
132 };
133 rsx! {
134 div { class: "text-sm rounded-lg px-3 py-2 {cls}", "{msg}" }
135 }
136 } else {
137 rsx! {}
138 }
139}
140
141#[cfg(target_arch = "wasm32")]
146#[component]
147fn EditorCard() -> Element {
148 let mut state: FriendsPageState = use_context();
149 let mut name = use_signal(String::new);
150 let mut url = use_signal(String::new);
151 let mut avatar = use_signal(String::new);
152 let mut desc = use_signal(String::new);
153 let mut sort_str = use_signal(|| "0".to_string());
154 let mut is_active = use_signal(|| true);
155 let mut busy = use_signal(|| false);
156 let mut picker_visible = use_signal(|| false);
157 let avatar_uploading = use_signal(|| false);
158 let mut avatar_failed = use_signal(|| false);
159
160 let mut editing = state.editing;
161 let reload_gen = state.reload_gen;
162 let mut toast = state.toast;
163
164 use_effect(move || {
167 if let Some(link) = editing() {
168 name.set(link.name.clone());
169 url.set(link.url.clone());
170 avatar.set(link.avatar_url.clone().unwrap_or_default());
171 avatar_failed.set(false);
172 desc.set(link.description.clone());
173 sort_str.set(link.sort_order.to_string());
174 is_active.set(link.is_active);
175 }
176 });
177
178 let mut clear_drafts = move || {
179 name.set(String::new());
180 url.set(String::new());
181 avatar.set(String::new());
182 avatar_failed.set(false);
183 desc.set(String::new());
184 sort_str.set("0".to_string());
185 is_active.set(true);
186 };
187
188 let editing_target = editing();
189 let editing_mode = editing_target.is_some();
190
191 rsx! {
192 div {
193 div {
194 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 animate-row-enter",
195 style: "animation-delay: 60ms",
196 div { class: "flex items-center justify-between border-b border-[var(--color-paper-border)]/60 pb-4",
198 div { class: "flex items-center gap-2.5",
199 svg {
200 class: "w-5 h-5 text-[var(--color-paper-accent)]",
201 xmlns: "http://www.w3.org/2000/svg",
202 view_box: "0 0 24 24",
203 fill: "none",
204 stroke: "currentColor",
205 stroke_width: "2",
206 stroke_linecap: "round",
207 stroke_linejoin: "round",
208 if editing_mode {
209 path { d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" }
210 path { d: "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" }
211 } else {
212 line { x1: "12", y1: "5", x2: "12", y2: "19" }
213 line { x1: "5", y1: "12", x2: "19", y2: "12" }
214 }
215 }
216 h2 { class: "text-lg sm:text-xl font-bold text-[var(--color-paper-primary)]",
217 if editing_mode {
218 "编辑友链"
219 } else {
220 "添加新友链"
221 }
222 }
223 }
224 if editing_mode {
225 span { class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-[var(--color-paper-accent)]/10 text-[var(--color-paper-accent)] border border-[var(--color-paper-accent)]/20",
226 "编辑中"
227 }
228 }
229 }
230
231 div { class: "grid grid-cols-1 md:grid-cols-2 gap-5",
232 div { class: "flex flex-col gap-2",
234 label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
235 "站点名称 *"
236 }
237 FormInput {
238 r#type: "text",
239 placeholder: "如:Rua Lab",
240 value: name(),
241 oninput: move |v: String| name.set(v),
242 }
243 }
244 div { class: "flex flex-col gap-2",
246 label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
247 "站点链接 (URL) *"
248 }
249 FormInput {
250 r#type: "url",
251 placeholder: "https://example.com",
252 value: url(),
253 oninput: move |v: String| url.set(v),
254 }
255 }
256 div { class: "flex flex-col gap-2",
258 label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
259 "站点头像"
260 }
261 div { class: "flex items-center gap-3",
262 div { class: "relative h-10 w-10 shrink-0 rounded-2xl bg-[var(--color-paper-entry)] border border-[var(--color-paper-border)]/70 flex items-center justify-center overflow-hidden shadow-2xs",
264 if avatar().trim().is_empty() || avatar_failed() {
265 span { class: "text-sm font-bold text-[var(--color-paper-primary)] select-none",
266 {
267 name()
268 .chars()
269 .next()
270 .map(|c| c.to_uppercase().collect::<String>())
271 .unwrap_or_else(|| "?".to_string())
272 }
273 }
274 } else {
275 img {
276 class: "w-full h-full object-cover",
277 src: "{avatar()}",
278 alt: "头像预览",
279 onerror: move |_| avatar_failed.set(true),
280 }
281 }
282 }
283 FormInput {
284 r#type: "url",
285 placeholder: "粘贴外链或从素材库选择...",
286 value: avatar(),
287 class: INPUT_INLINE_CLASS,
288 oninput: move |v: String| {
289 avatar.set(v);
290 avatar_failed.set(false);
291 },
292 }
293 button {
294 class: "shrink-0 {BTN_OUTLINE} text-xs px-3.5 py-2",
295 onclick: move |_| picker_visible.set(true),
296 "素材库"
297 }
298 }
299 }
300 div { class: "flex flex-col gap-2",
302 label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
303 "展示排序(数字越小越靠前)"
304 }
305 FormInput {
306 r#type: "number",
307 placeholder: "0",
308 value: sort_str(),
309 oninput: move |v: String| sort_str.set(v),
310 }
311 }
312 div { class: "flex flex-col gap-2 md:col-span-2",
314 label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
315 "站点描述"
316 }
317 textarea {
318 class: "{INPUT_CLASS} resize-none",
319 rows: "2",
320 placeholder: "一句话介绍对方站点...",
321 value: desc(),
322 oninput: move |e| desc.set(e.value()),
323 }
324 }
325 if editing_mode {
327 div { class: "flex flex-col gap-2",
328 label { class: "text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)]",
329 "展示状态"
330 }
331 FormSelect {
332 value: is_active(),
333 options: vec![(true, "启用展示"), (false, "暂停展示 (停用)")],
334 onchange: move |a: bool| is_active.set(a),
335 }
336 }
337 }
338 }
339
340 div { class: "flex items-center gap-3 pt-2",
341 button {
342 class: "{BTN_PRIMARY} inline-flex items-center gap-1.5",
343 disabled: "{busy() || avatar_uploading() || name().trim().is_empty() || url().trim().is_empty()}",
344 onclick: move |_| {
345 if busy() {
346 return;
347 }
348 let n = name().trim().to_string();
349 let u = url().trim().to_string();
350 if n.is_empty() || u.is_empty() {
351 return;
352 }
353 let av = {
354 let a = avatar().trim().to_string();
355 if a.is_empty() { None } else { Some(a) }
356 };
357 let d = desc().trim().to_string();
358 let sort = match sort_str().trim().parse::<i32>() {
359 Ok(s) => s,
360 Err(_) => {
361 toast.set(Some(("排序值必须是整数".to_string(), true)));
362 return;
363 }
364 };
365 let active = is_active();
366 let target = editing();
367 let was_edit = target.is_some();
368 busy.set(true);
369 spawn(async move {
370 let result = match target {
371 Some(link) => {
372 update_friend_link(link.id, n, u, av, d, sort, active).await
373 }
374 None => create_friend_link(n, u, av, d, sort).await,
375 };
376 match result {
377 Ok(_) => {
378 toast
379 .set(
380 Some((
381 if was_edit {
382 "已成功更新友链".to_string()
383 } else {
384 "已成功添加友链".to_string()
385 },
386 false,
387 )),
388 );
389 editing.set(None);
390 clear_drafts();
391 let g = reload_gen();
392 state.reload_gen.set(g + 1);
393 }
394 Err(e) => {
395 toast.set(Some((format!("保存失败:{e}"), true)));
396 }
397 }
398 busy.set(false);
399 });
400 },
401 if busy() {
402 "保存中…"
403 } else if editing_mode {
404 "保存修改"
405 } else {
406 "确认添加"
407 }
408 }
409 if editing_mode {
410 button {
411 class: "{BTN_OUTLINE}",
412 onclick: move |_| {
413 editing.set(None);
414 clear_drafts();
415 },
416 "取消编辑"
417 }
418 }
419 }
420 }
421 AssetPickerModal {
423 visible: picker_visible,
424 cover_uploading: avatar_uploading,
425 title: "选择头像",
426 on_select: move |picks: Vec<AssetSelection>| {
427 if let Some(first) = picks.into_iter().next() {
429 avatar.set(first.url);
430 avatar_failed.set(false);
431 }
432 },
433 }
434 }
435 }
436}
437
438#[cfg(target_arch = "wasm32")]
440#[component]
441fn LinkList() -> Element {
442 let state: FriendsPageState = use_context();
443 let mut links = use_signal(Vec::<FriendLink>::new);
444 let mut loaded_gen = use_signal(|| None::<u32>);
445 let mut loading = use_signal(|| true);
446
447 let reload_gen = state.reload_gen;
448 let mut toast = state.toast;
449
450 use_effect(move || {
452 let g = reload_gen();
453 if loaded_gen() != Some(g) {
454 loaded_gen.set(Some(g));
455 spawn(async move {
456 match list_all_friend_links().await {
457 Ok(list) => links.set(list),
458 Err(e) => toast.set(Some((format!("加载失败:{e}"), true))),
459 }
460 loading.set(false);
461 });
462 }
463 });
464
465 rsx! {
466 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",
467 div { class: "flex items-center justify-between border-b border-[var(--color-paper-border)]/60 pb-4",
468 div { class: "flex items-center gap-2",
469 svg {
470 class: "w-5 h-5 text-[var(--color-paper-secondary)]",
471 xmlns: "http://www.w3.org/2000/svg",
472 view_box: "0 0 24 24",
473 fill: "none",
474 stroke: "currentColor",
475 stroke_width: "2",
476 stroke_linecap: "round",
477 stroke_linejoin: "round",
478 path { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }
479 circle { cx: "9", cy: "7", r: "4" }
480 path { d: "M23 21v-2a4 4 0 0 0-3-3.87" }
481 path { d: "M16 3.13a4 4 0 0 1 0 7.75" }
482 }
483 h2 { class: "text-lg sm:text-xl font-bold text-[var(--color-paper-primary)]",
484 "已添加友链 ({links().len()})"
485 }
486 }
487 }
488
489 if loading() && links().is_empty() {
490 DelayedSkeleton { FriendsAdminListSkeleton {} }
491 } else if links().is_empty() {
492 p { class: "text-[var(--color-paper-secondary)] text-sm py-8 text-center",
493 "还没有友链,在上方表单添加第一位伙伴吧。"
494 }
495 } else {
496 div { class: "flex flex-col divide-y divide-[var(--color-paper-border)]/50",
497 for (i, link) in links().iter().enumerate() {
498 LinkRow {
499 key: "{link.id}",
500 link: link.clone(),
501 state,
502 stagger_index: i as u32,
503 }
504 }
505 }
506 }
507 }
508 }
509}
510
511#[cfg(target_arch = "wasm32")]
513#[component]
514fn LinkRow(link: FriendLink, state: FriendsPageState, stagger_index: u32) -> Element {
515 let initial: String = link
516 .name
517 .chars()
518 .next()
519 .map(|c| c.to_uppercase().collect())
520 .unwrap_or_else(|| "?".to_string());
521 let mut img_failed = use_signal(|| false);
523
524 let id_delete = link.id;
525 let link_for_edit = link.clone();
526 let mut editing = state.editing;
527 let mut toast = state.toast;
528 let reload_gen = state.reload_gen;
529 let mut delete_open = use_signal(|| false);
530 let mut anchor_x = use_signal(|| 0i32);
531 let mut anchor_y = use_signal(|| 0i32);
532
533 rsx! {
534 div {
535 class: "animate-row-enter flex flex-col sm:flex-row sm:items-center justify-between gap-4 py-4.5 hover:bg-[var(--color-paper-accent-soft)]/20 px-2 rounded-xl transition-colors duration-150",
536 style: "animation-delay: {stagger_index * 40}ms",
537 div { class: "flex items-start sm:items-center gap-3.5 min-w-0 flex-1",
538 div { class: "relative h-10 w-10 shrink-0 rounded-2xl bg-[var(--color-paper-entry)] border border-[var(--color-paper-border)]/70 flex items-center justify-center overflow-hidden shadow-2xs mt-0.5 sm:mt-0",
540 span { class: "text-sm font-bold text-[var(--color-paper-primary)] select-none",
541 "{initial}"
542 }
543 if let Some(avatar_url) = &link.avatar_url {
544 if !img_failed() {
545 img {
546 class: "absolute inset-0 w-full h-full object-cover rounded-2xl",
547 src: "{avatar_url}",
548 alt: "{link.name} 的头像",
549 onerror: move |_| img_failed.set(true),
550 }
551 }
552 }
553 }
554 div { class: "flex-1 min-w-0 flex flex-col gap-1",
556 div { class: "flex flex-wrap items-center gap-2",
557 span { class: "font-semibold text-sm sm:text-base text-[var(--color-paper-primary)] truncate",
558 "{link.name}"
559 }
560 if link.is_active {
561 span { class: "inline-flex items-center gap-1 px-2 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",
562 span { class: "w-1.5 h-1.5 rounded-full bg-emerald-500" }
563 "启用"
564 }
565 } else {
566 span { class: "inline-flex items-center gap-1 px-2 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",
567 span { class: "w-1.5 h-1.5 rounded-full bg-gray-400" }
568 "停用"
569 }
570 }
571 span { class: "inline-flex items-center px-1.5 py-0.2 rounded text-[11px] font-mono bg-[var(--color-paper-entry)] text-[var(--color-paper-tertiary)] border border-[var(--color-paper-border)]/50",
572 "排序 #{link.sort_order}"
573 }
574 }
575 if !link.description.is_empty() {
576 p { class: "text-xs text-[var(--color-paper-secondary)] line-clamp-1 leading-normal",
577 "{link.description}"
578 }
579 }
580 if !link.url.is_empty() {
581 a {
582 class: "inline-flex items-center gap-1 text-xs font-mono text-[var(--color-paper-accent)] hover:underline truncate w-fit",
583 href: "{link.url}",
584 target: "_blank",
585 rel: "noopener noreferrer",
586 svg {
587 class: "w-3 h-3 shrink-0 opacity-80",
588 xmlns: "http://www.w3.org/2000/svg",
589 view_box: "0 0 24 24",
590 fill: "none",
591 stroke: "currentColor",
592 stroke_width: "2",
593 stroke_linecap: "round",
594 stroke_linejoin: "round",
595 path { d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }
596 path { d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }
597 }
598 "{link.url}"
599 }
600 }
601 }
602 }
603 div { class: "flex items-center gap-1.5 self-end sm:self-center shrink-0",
605 button {
606 class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-theme)] transition-colors cursor-pointer",
607 onclick: move |_| editing.set(Some(link_for_edit.clone())),
608 svg {
609 class: "w-3.5 h-3.5",
610 xmlns: "http://www.w3.org/2000/svg",
611 view_box: "0 0 24 24",
612 fill: "none",
613 stroke: "currentColor",
614 stroke_width: "2",
615 stroke_linecap: "round",
616 stroke_linejoin: "round",
617 path { d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" }
618 path { d: "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" }
619 }
620 "编辑"
621 }
622 button {
623 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",
624 onclick: move |e| {
625 let coordinates = e.client_coordinates();
626 anchor_x.set(coordinates.x as i32);
627 anchor_y.set(coordinates.y as i32);
628 delete_open.set(true);
629 },
630 svg {
631 class: "w-3.5 h-3.5",
632 xmlns: "http://www.w3.org/2000/svg",
633 view_box: "0 0 24 24",
634 fill: "none",
635 stroke: "currentColor",
636 stroke_width: "2",
637 stroke_linecap: "round",
638 stroke_linejoin: "round",
639 polyline { points: "3 6 5 6 21 6" }
640 path { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }
641 }
642 "删除"
643 }
644 }
645 Popover {
646 open: delete_open(),
647 anchor_x: anchor_x(),
648 anchor_y: anchor_y(),
649 placement: "top",
650 align: "end",
651 on_close: move |_| delete_open.set(false),
652 div { class: "w-56 space-y-3",
653 p { class: "text-sm text-paper-primary leading-relaxed",
654 "确定要删除「{link.name}」吗?"
655 }
656 div { class: "flex justify-end gap-2 pt-1",
657 button {
658 class: "{BTN_GHOST}",
659 onclick: move |_| delete_open.set(false),
660 "取消"
661 }
662 button {
663 class: "{BTN_DANGER_OUTLINE}",
664 onclick: move |_| {
665 delete_open.set(false);
666 let id = id_delete;
667 spawn(async move {
668 match delete_friend_link(id).await {
669 Ok(()) => {
670 toast.set(Some(("已删除友链".to_string(), false)));
671 let g = reload_gen();
672 state.reload_gen.set(g + 1);
673 }
674 Err(e) => {
675 toast.set(Some((format!("删除失败:{e}"), true)));
676 }
677 }
678 });
679 },
680 "确认删除"
681 }
682 }
683 }
684 }
685 }
686 }
687}