1use leptos::prelude::*;
9use leptos::task::spawn_local;
10use leptos_router::{lazy_route, LazyRoute};
11
12use crate::components::admin_layout::AdminPageHead;
13use crate::components::assets::{AssetPickerModal, AssetSelection};
14use crate::components::empty_state::EmptyState;
15use crate::components::forms::{FormInput, FormLabel, FormTextarea, ToggleSwitch};
16use crate::components::skeletons::friends_admin_skeleton::FriendsAdminListSkeleton;
17use crate::components::ui::{
18 button_class, use_toast, ButtonSize, ButtonVariant, ConfirmDialog, Icon, IconName, LoadError,
19};
20use crate::models::friend_link::FriendLink;
21use crate::utils::error::failure_message;
22
23const SORT_STEP: i32 = 10;
25
26type DragState = Option<usize>;
28
29fn host_of(url: &str) -> String {
31 url.trim_start_matches("https://")
32 .trim_start_matches("http://")
33 .trim_end_matches('/')
34 .to_string()
35}
36
37fn initial_of(name: &str) -> String {
39 name.trim()
40 .chars()
41 .next()
42 .map(|c| c.to_uppercase().to_string())
43 .unwrap_or_else(|| "?".into())
44}
45
46fn reorder(list: &mut Vec<FriendLink>, from: usize, to: usize) -> Vec<FriendLink> {
48 if from == to || from >= list.len() || to >= list.len() {
49 return Vec::new();
50 }
51 let item = list.remove(from);
52 list.insert(to, item);
53 let mut changed = Vec::new();
54 for (i, link) in list.iter_mut().enumerate() {
55 let order = (i as i32 + 1) * SORT_STEP;
56 if link.sort_order != order {
57 link.sort_order = order;
58 changed.push(link.clone());
59 }
60 }
61 changed
62}
63
64pub struct FriendsAdminRoute;
66
67#[lazy_route]
68impl LazyRoute for FriendsAdminRoute {
69 fn data() -> Self {
70 Self
71 }
72
73 fn view(_this: Self) -> AnyView {
74 view! { <FriendsAdmin/> }.into_any()
75 }
76}
77
78fn row_key(index: usize, link: &FriendLink) -> String {
80 format!(
81 "{index}|{}|{}|{}|{}|{}|{}|{}",
82 link.id,
83 link.sort_order,
84 link.is_active,
85 link.name,
86 link.url,
87 link.avatar_url.as_deref().unwrap_or_default(),
88 link.description,
89 )
90}
91
92#[component]
94fn FriendsAdmin() -> impl IntoView {
95 let toast = use_toast();
96 let links = RwSignal::new(None::<Vec<FriendLink>>);
97 let error = RwSignal::new(false);
98 let reload = RwSignal::new(0_u32);
99 let generation = StoredValue::new(0_u64);
100 let editing = RwSignal::new(None::<FriendLink>);
101 let dragging = RwSignal::new(None::<usize>);
103 let drop_at = RwSignal::new(None::<usize>);
104 let saving_order = RwSignal::new(false);
105
106 Effect::new(move |_| {
107 reload.track();
108 let mut id = 0;
109 generation.update_value(|g| {
110 *g += 1;
111 id = *g;
112 });
113 spawn_local(async move {
114 let result = crate::api::friends::list_all_friend_links().await;
115 if generation.try_get_value() != Some(id) {
116 return;
117 }
118 match result {
119 Ok(list) => {
120 let current = editing.with_untracked(|l| l.as_ref().map(|l| l.id));
122 if current.is_some_and(|id| !list.iter().any(|l| l.id == id)) {
123 let _ = editing.try_set(None);
124 }
125 let _ = links.try_set(Some(list));
126 let _ = error.try_set(false);
127 }
128 Err(_) => {
129 let _ = error.try_set(true);
130 }
131 }
132 });
133 });
134
135 let persist_order = Callback::new(move |(from, to): (usize, usize)| {
136 let Some(mut list) = links.get_untracked() else {
137 return;
138 };
139 let changed = reorder(&mut list, from, to);
140 if changed.is_empty() {
141 return;
142 }
143 links.set(Some(list));
144 saving_order.set(true);
145 spawn_local(async move {
146 let mut failed = None;
147 for link in changed {
148 if let Err(e) = crate::api::friends::update_friend_link(
149 link.id,
150 link.name,
151 link.url,
152 link.avatar_url,
153 link.description,
154 link.sort_order,
155 link.is_active,
156 )
157 .await
158 {
159 failed = Some(e);
160 break;
161 }
162 }
163 match failed {
164 None => toast.success("顺序已保存"),
165 Some(e) => {
166 toast.error(failure_message("顺序保存失败", &e));
167 let _ = reload.try_update(|n| *n += 1);
168 }
169 }
170 let _ = saving_order.try_set(false);
171 });
172 });
173
174 let editing_id = Memo::new(move |_| editing.with(|l| l.as_ref().map(|l| l.id)));
175 let next_order = Memo::new(move |_| {
176 links.with(|list| {
177 list.as_ref()
178 .and_then(|l| l.iter().map(|f| f.sort_order).max())
179 .map_or(SORT_STEP, |max| max + SORT_STEP)
180 })
181 });
182 let count = Memo::new(move |_| links.with(|l| l.as_ref().map_or(0, Vec::len)));
183 let rows = Memo::new(move |_| links.get().unwrap_or_default());
184
185 view! {
186 <div class="admin-friends">
187 <AdminPageHead
188 title="友链"
189 actions=|| {
190 view! {
191 <a
192 class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
193 href="/friends"
194 target="_blank"
195 rel="noopener"
196 >
197 "查看前台页面"
198 <Icon name=IconName::ArrowUpRight size=14/>
199 </a>
200 }
201 }
202 >
203 "维护前台 /friends 展示的友链。拖动把手,或在把手上按 ↑ / ↓ 调整顺序。"
204 </AdminPageHead>
205 <div class="admin-friends-grid">
206 <div class="admin-friends-list-col">
207 {move || {
208 if error.get() {
209 view! {
210 <LoadError
211 title="友链加载失败"
212 on_retry=move |()| reload.update(|n| *n += 1)
213 />
214 }
215 .into_any()
216 } else if links.with(Option::is_none) {
217 view! { <FriendsAdminListSkeleton/> }.into_any()
218 } else if count.get() == 0 {
219 view! {
220 <div class="admin-panel">
221 <EmptyState
222 icon=IconName::Link
223 title="还没有友链"
224 description="在右侧填写对方站点的名称和链接,添加第一条。"
225 />
226 </div>
227 }
228 .into_any()
229 } else {
230 view! {
231 <ol
232 class="admin-panel admin-friends-list"
233 aria-busy=move || saving_order.get().then_some("true")
234 >
235 <For
236 each={move || {
237 rows.get().into_iter().enumerate().collect::<Vec<_>>()
238 }}
239 key=|(index, link)| row_key(*index, link)
240 children=move |(index, link)| {
241 let link_id = link.id;
242 view! {
243 <FriendRow
244 link=link
245 index=index
246 count=count
247 editing=Signal::derive(move || {
248 editing_id.get() == Some(link_id)
249 })
250 dragging=Signal::derive(move || dragging.get() == Some(index))
251 drop_target=Signal::derive(move || {
252 drop_at.get() == Some(index)
253 && dragging.get().is_some_and(|d| d != index)
254 })
255 on_edit=move |link: FriendLink| editing.set(Some(link))
256 on_move=persist_order
257 on_drag=move |state: DragState| {
258 dragging.set(state);
259 if state.is_none() {
260 drop_at.set(None);
261 }
262 }
263 on_over=move |i: usize| drop_at.set(Some(i))
264 on_drop=move |to: usize| {
265 if let Some(from) = dragging.get_untracked() {
266 persist_order.run((from, to));
267 }
268 dragging.set(None);
269 drop_at.set(None);
270 }
271 />
272 }
273 }
274 />
275 </ol>
276 }
277 .into_any()
278 }
279 }}
280 </div>
281 {move || {
283 let _key = editing_id.get().unwrap_or(0);
284 view! {
285 <FriendForm
286 link=editing.get_untracked()
287 next_order=next_order.get_untracked()
288 on_saved=move |()| {
289 editing.set(None);
290 reload.update(|n| *n += 1);
291 }
292 on_new=move |()| editing.set(None)
293 />
294 }
295 }}
296 </div>
297 </div>
298 }
299}
300
301#[component]
303#[allow(clippy::too_many_arguments)]
304fn FriendRow(
305 link: FriendLink,
306 index: usize,
307 #[prop(into)] count: Signal<usize>,
308 #[prop(into)] editing: Signal<bool>,
309 #[prop(into)] dragging: Signal<bool>,
310 #[prop(into)] drop_target: Signal<bool>,
311 #[prop(into)] on_edit: Callback<FriendLink>,
312 #[prop(into)] on_move: Callback<(usize, usize)>,
313 #[prop(into)] on_drag: Callback<Option<usize>>,
314 #[prop(into)] on_over: Callback<usize>,
315 #[prop(into)] on_drop: Callback<usize>,
316) -> impl IntoView {
317 let host = host_of(&link.url);
318 let initial = initial_of(&link.name);
319 let avatar_failed = RwSignal::new(false);
320 let edit_link = link.clone();
321 let paused = !link.is_active;
322 let avatar_url = link.avatar_url.clone().filter(|u| !u.trim().is_empty());
323 let grip_label = format!("调整「{}」的顺序,当前第 {} 位", link.name, index + 1);
324 view! {
325 <li
326 class="admin-friend"
327 data-editing=move || editing.get().then_some("true")
328 data-dragging=move || dragging.get().then_some("true")
329 data-drop=move || drop_target.get().then_some("true")
330 data-paused=paused.then_some("true")
331 draggable="true"
332 on:dragstart=move |_| on_drag.run(Some(index))
333 on:dragend=move |_| on_drag.run(None)
334 on:dragover=move |e: leptos::ev::DragEvent| {
335 e.prevent_default();
336 on_over.run(index);
337 }
338 on:drop=move |e: leptos::ev::DragEvent| {
339 e.prevent_default();
340 on_drop.run(index);
341 }
342 >
343 <button
344 class="admin-friend-grip"
345 type="button"
346 aria-label=grip_label
347 title="拖动,或按 ↑ / ↓"
348 on:keydown=move |e: leptos::ev::KeyboardEvent| {
349 let to = match e.key().as_str() {
350 "ArrowUp" if index > 0 => Some(index - 1),
351 "ArrowDown" if index + 1 < count.get_untracked() => Some(index + 1),
352 _ => None,
353 };
354 if let Some(to) = to {
355 e.prevent_default();
356 on_move.run((index, to));
357 }
358 }
359 >
360 <Icon name=IconName::GripVertical size=15/>
361 </button>
362 <span class="admin-friend-avatar" aria-hidden="true">
363 {move || match avatar_url.clone().filter(|_| !avatar_failed.get()) {
364 Some(url) => {
365 view! {
366 <img
367 src=url
368 alt=""
369 loading="lazy"
370 on:error=move |_| avatar_failed.set(true)
371 />
372 }
373 .into_any()
374 }
375 None => initial.clone().into_any(),
376 }}
377 </span>
378 <div class="admin-friend-text">
379 <div class="admin-friend-name">
380 <span>{link.name.clone()}</span>
381 {move || {
382 editing
383 .get()
384 .then(|| {
385 view! {
386 <span class="admin-friend-tag" data-tone="clay">
387 "编辑中"
388 </span>
389 }
390 })
391 }}
392 {paused.then(|| view! { <span class="admin-friend-tag">"已停用"</span> })}
393 </div>
394 <a
395 class="admin-friend-host"
396 href=link.url.clone()
397 target="_blank"
398 rel="noopener"
399 >
400 {host}
401 </a>
402 </div>
403 <span class="admin-friend-order" title="排序值">
404 {link.sort_order}
405 </span>
406 <button
407 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
408 type="button"
409 on:click=move |_| on_edit.run(edit_link.clone())
410 >
411 "编辑"
412 </button>
413 </li>
414 }
415}
416
417#[component]
419fn FriendForm(
420 link: Option<FriendLink>,
421 next_order: i32,
422 #[prop(into)] on_saved: Callback<()>,
423 #[prop(into)] on_new: Callback<()>,
424) -> impl IntoView {
425 let toast = use_toast();
426 let editing = link.is_some();
427 let id = link.as_ref().map(|l| l.id);
428 let name = RwSignal::new(link.as_ref().map(|l| l.name.clone()).unwrap_or_default());
429 let url = RwSignal::new(link.as_ref().map(|l| l.url.clone()).unwrap_or_default());
430 let avatar = RwSignal::new(
431 link.as_ref()
432 .and_then(|l| l.avatar_url.clone())
433 .unwrap_or_default(),
434 );
435 let desc = RwSignal::new(
436 link.as_ref()
437 .map(|l| l.description.clone())
438 .unwrap_or_default(),
439 );
440 let order = RwSignal::new(
441 link.as_ref()
442 .map_or(next_order, |l| l.sort_order)
443 .to_string(),
444 );
445 let active = RwSignal::new(link.as_ref().is_none_or(|l| l.is_active));
446 let busy = RwSignal::new(false);
447 let picker = RwSignal::new(false);
448 let picker_uploading = RwSignal::new(false);
449 let confirm_delete = RwSignal::new(false);
450 let avatar_failed = RwSignal::new(false);
451
452 let order_value = Memo::new(move |_| order.with(|o| o.trim().parse::<i32>().ok()));
453 let can_submit = Memo::new(move |_| {
454 !busy.get()
455 && name.with(|n| !n.trim().is_empty())
456 && url.with(|u| !u.trim().is_empty())
457 && order_value.get().is_some()
458 });
459
460 let submit = move |e: leptos::ev::SubmitEvent| {
461 e.prevent_default();
462 let Some(sort) = order_value.get_untracked() else {
463 return;
464 };
465 if busy.get_untracked() {
466 return;
467 }
468 busy.set(true);
469 let (n, u, d, is_active) = (
470 name.get_untracked().trim().to_string(),
471 url.get_untracked().trim().to_string(),
472 desc.get_untracked().trim().to_string(),
473 active.get_untracked(),
474 );
475 let av = Some(avatar.get_untracked().trim().to_string()).filter(|s| !s.is_empty());
476 spawn_local(async move {
477 let result = match id {
478 Some(id) => {
479 crate::api::friends::update_friend_link(id, n, u, av, d, sort, is_active)
480 .await
481 .map(|_| ())
482 }
483 None => match crate::api::friends::create_friend_link(
484 n.clone(),
485 u.clone(),
486 av.clone(),
487 d.clone(),
488 sort,
489 )
490 .await
491 {
492 Ok(created) if !is_active => crate::api::friends::update_friend_link(
494 created.id, n, u, av, d, sort, false,
495 )
496 .await
497 .map(|_| ()),
498 Ok(_) => Ok(()),
499 Err(e) => Err(e),
500 },
501 };
502 match result {
503 Ok(()) => {
504 toast.success(if id.is_some() {
505 "友链已保存"
506 } else {
507 "友链已添加"
508 });
509 on_saved.try_run(());
510 }
511 Err(e) => toast.error(failure_message("保存失败", &e)),
512 }
513 let _ = busy.try_set(false);
514 });
515 };
516 let delete = Callback::new(move |()| {
517 let Some(id) = id else {
518 return;
519 };
520 busy.set(true);
521 spawn_local(async move {
522 match crate::api::friends::delete_friend_link(id).await {
523 Ok(()) => {
524 toast.success("友链已删除");
525 on_saved.try_run(());
526 }
527 Err(e) => toast.error(failure_message("删除失败", &e)),
528 }
529 let _ = busy.try_set(false);
530 });
531 });
532
533 let on_pick = move |picks: Vec<AssetSelection>| {
535 if let Some(first) = picks.into_iter().next() {
536 avatar.set(first.url);
537 avatar_failed.set(false);
538 }
539 };
540
541 view! {
542 <form class="admin-friend-form" aria-labelledby="friend-form-h" on:submit=submit>
543 <div class="admin-friend-form-head">
544 <h2 id="friend-form-h">{if editing { "编辑友链" } else { "添加友链" }}</h2>
545 {editing
546 .then(|| {
547 view! {
548 <button
549 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
550 type="button"
551 on:click=move |_| on_new.run(())
552 >
553 "改为新建"
554 </button>
555 }
556 })}
557 </div>
558 <div>
559 <FormLabel label="站点名称" html_for="friend-name".to_string()/>
560 <FormInput
561 id="friend-name"
562 placeholder="如:Rua Lab"
563 maxlength=100
564 value=name
565 oninput=move |v: String| name.set(v)
566 />
567 </div>
568 <div>
569 <FormLabel label="链接" html_for="friend-url".to_string()/>
570 <FormInput
571 id="friend-url"
572 class="field admin-friend-mono"
573 r#type="url"
574 placeholder="https://"
575 value=url
576 oninput=move |v: String| url.set(v)
577 />
578 </div>
579 <div>
580 <FormLabel label="头像" html_for="friend-avatar".to_string()/>
581 <div class="admin-friend-avatar-row">
582 <span class="admin-friend-avatar" aria-hidden="true">
583 {move || {
584 if avatar.with(|a| a.trim().is_empty()) || avatar_failed.get() {
585 name.with(|n| initial_of(n)).into_any()
586 } else {
587 view! {
588 <img
589 src=avatar.get()
590 alt=""
591 on:error=move |_| avatar_failed.set(true)
592 />
593 }
594 .into_any()
595 }
596 }}
597 </span>
598 <FormInput
599 id="friend-avatar"
600 placeholder="粘贴外链或从素材库选择"
601 value=avatar
602 oninput=move |v: String| {
603 avatar.set(v);
604 avatar_failed.set(false);
605 }
606 />
607 <button
608 class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
609 type="button"
610 on:click=move |_| picker.set(true)
611 >
612 "素材库"
613 </button>
614 </div>
615 </div>
616 <div>
617 <FormLabel label="简介" html_for="friend-desc".to_string()/>
618 <FormTextarea
619 id="friend-desc"
620 rows=3
621 maxlength=300
622 placeholder="一句话介绍对方站点"
623 value=desc
624 oninput=move |v: String| desc.set(v)
625 />
626 </div>
627 <div class="admin-friend-form-row">
628 <div class="admin-friend-order-field">
629 <FormLabel label="排序值" html_for="friend-order".to_string()/>
630 <FormInput
631 id="friend-order"
632 class="field admin-friend-mono"
633 inputmode="numeric"
634 invalid=Signal::derive(move || order_value.get().is_none())
635 value=order
636 oninput=move |v: String| order.set(v)
637 />
638 </div>
639 <div class="admin-friend-switch">
640 <ToggleSwitch
641 checked=active
642 aria_label="在前台展示".to_string()
643 ontoggle=move |()| active.update(|a| *a = !*a)
644 />
645 <span>"在前台展示"</span>
646 </div>
647 </div>
648 {move || {
649 order_value
650 .get()
651 .is_none()
652 .then(|| {
653 view! {
654 <p class="admin-friend-error" role="alert">
655 "排序值须为整数,越小越靠前。"
656 </p>
657 }
658 })
659 }}
660 <div class="admin-friend-actions">
661 <button
662 class=button_class(ButtonVariant::Primary, ButtonSize::Md)
663 type="submit"
664 disabled=move || !can_submit.get()
665 >
666 {move || {
667 if busy.get() {
668 "保存中…"
669 } else if editing {
670 "保存修改"
671 } else {
672 "添加"
673 }
674 }}
675 </button>
676 {editing
677 .then(|| {
678 view! {
679 <button
680 class=button_class(ButtonVariant::Outline, ButtonSize::Md)
681 data-danger="true"
682 type="button"
683 disabled=move || busy.get()
684 on:click=move |_| confirm_delete.set(true)
685 >
686 "删除"
687 </button>
688 }
689 })}
690 </div>
691 </form>
692 <AssetPickerModal
693 visible=picker
694 cover_uploading=picker_uploading
695 title="选择头像"
696 on_select=on_pick
697 />
698 <ConfirmDialog
699 visible=confirm_delete
700 title="删除这条友链?"
701 description=Signal::derive(move || {
702 format!(
703 "「{}」会从友链页移除,无法恢复。只想暂时隐藏可以关闭「在前台展示」。",
704 name.get(),
705 )
706 })
707 confirm_label="删除"
708 on_confirm=delete
709 />
710 }
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716
717 fn link(id: i32, order: i32) -> FriendLink {
718 FriendLink {
719 id,
720 name: format!("站点 {id}"),
721 url: format!("https://{id}.example.com"),
722 avatar_url: None,
723 description: String::new(),
724 sort_order: order,
725 is_active: true,
726 created_at: chrono::Utc::now(),
727 updated_at: chrono::Utc::now(),
728 }
729 }
730
731 #[test]
732 fn reorder_moves_item_and_rewrites_only_changed_orders() {
733 let mut list = vec![link(1, 10), link(2, 20), link(3, 30)];
734 let changed = reorder(&mut list, 2, 0);
735 assert_eq!(list.iter().map(|l| l.id).collect::<Vec<_>>(), [3, 1, 2]);
736 assert_eq!(
737 list.iter().map(|l| l.sort_order).collect::<Vec<_>>(),
738 [10, 20, 30]
739 );
740 assert_eq!(changed.iter().map(|l| l.id).collect::<Vec<_>>(), [3, 1, 2]);
741 assert!(reorder(&mut list, 1, 1).is_empty());
742 }
743
744 #[test]
745 fn host_and_initial_are_display_friendly() {
746 assert_eq!(host_of("https://blog.example.dev/"), "blog.example.dev");
747 assert_eq!(initial_of(" rua lab"), "R");
748 assert_eq!(initial_of(""), "?");
749 }
750}