Skip to main content

yggdrasil/pages/admin/
friends.rs

1//! 管理后台「友链」页面(`/admin/friends`)。
2//!
3//! 左侧为友链列表:拖动把手或在把手上按 ↑ / ↓ 调整顺序,顺序按 10、20、30… 写回
4//! 排序值;右侧为添加 / 编辑表单(名称、链接、头像、简介、排序值、是否在前台展示),
5//! 编辑时可删除(确认对话框,物理删除)。写操作成功后服务端失效友链缓存与 `/friends`
6//! SSR 缓存。数据只在 WASM 前端读取(Effect + 世代号)。样式见 styles/pages/admin-friends.css。
7
8use 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
23/// 排序值步长:给手动插队留出空位。
24const SORT_STEP: i32 = 10;
25
26/// 拖动状态:正在拖的行下标(宏属性里不便直接写泛型)。
27type DragState = Option<usize>;
28
29/// 链接去掉协议与末尾斜杠,列表里只显示主机与路径。
30fn 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
37/// 名称首字(头像缺省时显示)。
38fn 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
46/// 把 `from` 移到 `to` 后,按步长重排排序值;返回排序值有变化的友链。
47fn 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
64/// 友链管理路由(无 SSR 数据,列表在客户端读取)。
65pub 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
78/// 列表行的重建键:内容或位置变化时重建该行,拖动等瞬时状态不在其中。
79fn 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/// 管理后台友链页面。
93#[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    // 拖动中的行与当前悬停的目标行。
102    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                    // 编辑中的友链若已被删除,退回新建。
121                    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                // 切换编辑对象时按 id 重建表单以回填。
282                {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/// 列表的一行:把手、头像、名称与状态、主机、排序值与编辑。
302#[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/// 添加 / 编辑表单。`link` 为 `None` 时为添加;调用方按编辑对象重建以回填。
418#[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                    // 新建接口默认启用;选择不展示时再写一次状态。
493                    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    // 带泛型的闭包参数类型放在 `view!` 外,避免宏把 `<` 当作标签。
534    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}