Skip to main content

yggdrasil/pages/admin/
friends.rs

1//! 管理后台「友链」页面。
2//!
3//! 管理员在此维护前台 `/friends` 展示的友链:新建 / 编辑(名称、URL、头像、描述、
4//! 排序、启用状态)/ 删除(物理删除,无回收站)。数据经 Dioxus server functions
5//! (`src/api/friends.rs`)加载;写操作成功后由服务端失效 moka 缓存 + `/friends`
6//! SSR 缓存,前台下次访问立即生效。
7//!
8//! 仅 WASM 前端交互(照 mcp.rs 的 `#[cfg(target_arch = "wasm32")]` 门控模式)。
9//! 跨子组件状态(刷新触发、操作提示、编辑目标)经一个共享 context 传递。
10
11use 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::forms::{FormInput, FormLabel, FormSelect, INPUT_CLASS};
19#[cfg(target_arch = "wasm32")]
20use crate::components::ui::{
21    ADMIN_CARD_CLASS, BADGE_BASE, BTN_OUTLINE, BTN_PRIMARY, BTN_TEXT_ACCENT, BTN_TEXT_RED,
22};
23#[cfg(target_arch = "wasm32")]
24use crate::models::friend_link::FriendLink;
25
26/// 跨子组件共享的页面状态:刷新代际、操作提示、编辑目标。
27///
28/// `editing` 为 `Some(link)` 时表单进入编辑模式(回填该友链),`None` 为新建模式。
29#[cfg(target_arch = "wasm32")]
30#[derive(Clone, Copy, PartialEq)]
31struct FriendsPageState {
32    /// 递增以触发友链列表重新加载(创建/更新/删除后 +1)。
33    reload_gen: Signal<u32>,
34    /// 全局操作提示:(消息, 是否错误)。
35    toast: Signal<Option<(String, bool)>>,
36    /// 编辑目标:Some = 编辑模式,None = 新建模式。
37    editing: Signal<Option<FriendLink>>,
38}
39
40/// 管理后台友链管理页面。
41#[component]
42pub fn FriendsAdmin() -> Element {
43    #[cfg(target_arch = "wasm32")]
44    {
45        let state = FriendsPageState {
46            reload_gen: use_signal(|| 0),
47            toast: use_signal(|| None),
48            editing: use_signal(|| None),
49        };
50        use_context_provider(|| state);
51
52        rsx! {
53            div { class: "w-full max-w-7xl mx-auto space-y-8",
54                PageHeader {}
55                Toast {}
56                EditorCard {}
57                LinkList {}
58            }
59        }
60    }
61    #[cfg(not(target_arch = "wasm32"))]
62    {
63        // server 构建下页面无前端交互;路由实际只在 WASM 渲染。
64        rsx! {
65            p { class: "text-paper-secondary", "此页面仅在浏览器中可用。" }
66        }
67    }
68}
69
70/// 页头标题区。
71#[component]
72fn PageHeader() -> Element {
73    rsx! {
74        div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-8 border-b border-[var(--color-paper-border)]/50",
75            div {
76                h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
77                    "友链管理"
78                }
79                p { class: "text-base text-[var(--color-paper-secondary)] mt-2",
80                    "维护前台 /friends 页展示的友链。"
81                }
82            }
83        }
84    }
85}
86
87/// 全局操作提示条(读取共享 context 的 toast)。
88#[cfg(target_arch = "wasm32")]
89#[component]
90fn Toast() -> Element {
91    let state: FriendsPageState = use_context();
92    let toast = state.toast;
93    if let Some((msg, is_err)) = toast() {
94        let cls = if is_err {
95            "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300"
96        } else {
97            "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
98        };
99        rsx! {
100            div { class: "text-sm rounded-lg px-3 py-2 {cls}", "{msg}" }
101        }
102    } else {
103        rsx! {}
104    }
105}
106
107/// 新建 / 编辑二合一表单卡片。
108///
109/// 表单草稿用独立 signal(合法独立状态);进入编辑模式时经 `use_effect`
110/// 一次性回填草稿(同 write.rs 的种子回填合法例外),取消/保存成功时清空。
111#[cfg(target_arch = "wasm32")]
112#[component]
113fn EditorCard() -> Element {
114    let mut state: FriendsPageState = use_context();
115    let mut name = use_signal(String::new);
116    let mut url = use_signal(String::new);
117    let mut avatar = use_signal(String::new);
118    let mut desc = use_signal(String::new);
119    let mut sort_str = use_signal(String::new);
120    let mut is_active = use_signal(|| true);
121    let mut busy = use_signal(|| false);
122
123    let mut editing = state.editing;
124    let reload_gen = state.reload_gen;
125    let mut toast = state.toast;
126
127    // 编辑目标变化时回填草稿(一次性种子回填;新建/取消时 editing 为 None 不动草稿,
128    // 由「取消」按钮与保存成功路径显式清空)。
129    use_effect(move || {
130        if let Some(link) = editing() {
131            name.set(link.name.clone());
132            url.set(link.url.clone());
133            avatar.set(link.avatar_url.clone().unwrap_or_default());
134            desc.set(link.description.clone());
135            sort_str.set(link.sort_order.to_string());
136            is_active.set(link.is_active);
137        }
138    });
139
140    let mut clear_drafts = move || {
141        name.set(String::new());
142        url.set(String::new());
143        avatar.set(String::new());
144        desc.set(String::new());
145        sort_str.set(String::new());
146        is_active.set(true);
147    };
148
149    let editing_target = editing();
150    let editing_mode = editing_target.is_some();
151
152    rsx! {
153        div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-6",
154            h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]",
155                if editing_mode {
156                    "编辑友链"
157                } else {
158                    "添加友链"
159                }
160            }
161
162            div { class: "grid grid-cols-1 md:grid-cols-2 gap-4",
163                // 名称
164                div { class: "flex flex-col gap-2",
165                    FormLabel { label: "名称" }
166                    FormInput {
167                        r#type: "text",
168                        placeholder: "站点名称",
169                        value: name(),
170                        oninput: move |v: String| name.set(v),
171                    }
172                }
173                // URL
174                div { class: "flex flex-col gap-2",
175                    FormLabel { label: "URL" }
176                    FormInput {
177                        r#type: "url",
178                        placeholder: "https://example.com",
179                        value: url(),
180                        oninput: move |v: String| url.set(v),
181                    }
182                }
183                // 头像 URL(可空)
184                div { class: "flex flex-col gap-2",
185                    FormLabel { label: "头像 URL(可留空)" }
186                    FormInput {
187                        r#type: "url",
188                        placeholder: "https://example.com/avatar.png(可留空)",
189                        value: avatar(),
190                        oninput: move |v: String| avatar.set(v),
191                    }
192                }
193                // 排序
194                div { class: "flex flex-col gap-2",
195                    FormLabel { label: "排序(越小越靠前)" }
196                    FormInput {
197                        r#type: "number",
198                        placeholder: "0",
199                        value: sort_str(),
200                        oninput: move |v: String| sort_str.set(v),
201                    }
202                }
203                // 描述
204                div { class: "flex flex-col gap-2 md:col-span-2",
205                    FormLabel { label: "描述" }
206                    textarea {
207                        class: "{INPUT_CLASS}",
208                        rows: "2",
209                        placeholder: "一句话介绍对方站点",
210                        value: desc(),
211                        oninput: move |e| desc.set(e.value()),
212                    }
213                }
214                // 启用状态(仅编辑模式展示)
215                if editing_mode {
216                    div { class: "flex flex-col gap-2",
217                        FormLabel { label: "状态" }
218                        FormSelect {
219                            value: is_active(),
220                            options: vec![(true, "启用"), (false, "停用")],
221                            onchange: move |a: bool| is_active.set(a),
222                        }
223                    }
224                }
225            }
226
227            div { class: "flex items-center gap-3",
228                button {
229                    class: "{BTN_PRIMARY}",
230                    disabled: "{busy() || name().trim().is_empty() || url().trim().is_empty()}",
231                    onclick: move |_| {
232                        if busy() {
233                            return;
234                        }
235                        let n = name().trim().to_string();
236                        let u = url().trim().to_string();
237                        if n.is_empty() || u.is_empty() {
238                            return;
239                        }
240                        let av = {
241                            let a = avatar().trim().to_string();
242                            if a.is_empty() { None } else { Some(a) }
243                        };
244                        let d = desc().trim().to_string();
245                        let sort = match sort_str().trim().parse::<i32>() {
246                            Ok(s) => s,
247                            Err(_) => {
248                                toast.set(Some(("排序值必须是整数".to_string(), true)));
249                                return;
250                            }
251                        };
252                        let active = is_active();
253                        let target = editing();
254                        let was_edit = target.is_some();
255                        busy.set(true);
256                        spawn(async move {
257                            let result = match target {
258                                Some(link) => {
259                                    update_friend_link(link.id, n, u, av, d, sort, active).await
260                                }
261                                None => create_friend_link(n, u, av, d, sort).await,
262                            };
263                            match result {
264                                Ok(_) => {
265                                    toast
266                                        .set(
267                                            Some((
268                                                if was_edit {
269                                                    "已保存".to_string()
270                                                } else {
271                                                    "已添加".to_string()
272                                                },
273                                                false,
274                                            )),
275                                        );
276                                    editing.set(None);
277                                    clear_drafts();
278                                    let g = reload_gen();
279                                    state.reload_gen.set(g + 1);
280                                }
281                                Err(e) => {
282                                    toast.set(Some((format!("保存失败:{e}"), true)));
283                                }
284                            }
285                            busy.set(false);
286                        });
287                    },
288                    if busy() {
289                        "保存中…"
290                    } else if editing_mode {
291                        "保存修改"
292                    } else {
293                        "添加友链"
294                    }
295                }
296                if editing_mode {
297                    button {
298                        class: "{BTN_OUTLINE}",
299                        onclick: move |_| {
300                            editing.set(None);
301                            clear_drafts();
302                        },
303                        "取消"
304                    }
305                }
306            }
307        }
308    }
309}
310
311/// 友链列表卡片:纵向行列表(非表格,友链字段少)。
312#[cfg(target_arch = "wasm32")]
313#[component]
314fn LinkList() -> Element {
315    let mut state: FriendsPageState = use_context();
316    let mut links = use_signal(|| Vec::<FriendLink>::new());
317    let mut loaded_gen = use_signal(|| None::<u32>);
318    let mut loading = use_signal(|| true);
319
320    let reload_gen = state.reload_gen;
321    let mut toast = state.toast;
322
323    // reload_gen 变化时重新加载。
324    use_effect(move || {
325        let g = reload_gen();
326        if loaded_gen() != Some(g) {
327            loaded_gen.set(Some(g));
328            spawn(async move {
329                match list_all_friend_links().await {
330                    Ok(list) => links.set(list),
331                    Err(e) => toast.set(Some((format!("加载失败:{e}"), true))),
332                }
333                loading.set(false);
334            });
335        }
336    });
337
338    rsx! {
339        div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col gap-4",
340            h2 { class: "text-xl font-bold text-[var(--color-paper-primary)]", "友链列表" }
341
342            if loading() && links().is_empty() {
343                p { class: "text-[var(--color-paper-secondary)] text-sm py-4", "加载中…" }
344            } else if links().is_empty() {
345                p { class: "text-[var(--color-paper-secondary)] text-sm py-4 text-center",
346                    "还没有友链,先添加一位伙伴吧。"
347                }
348            } else {
349                div { class: "flex flex-col divide-y divide-[var(--color-paper-border)]/50",
350                    for link in links().iter() {
351                        LinkRow { key: "{link.id}", link: link.clone(), state }
352                    }
353                }
354            }
355        }
356    }
357}
358
359/// 单行友链:头像磁贴 + 名称/URL/描述 + 排序 + 状态徽章 + 编辑/删除操作。
360#[cfg(target_arch = "wasm32")]
361#[component]
362fn LinkRow(link: FriendLink, state: FriendsPageState) -> Element {
363    let initial: String = link
364        .name
365        .chars()
366        .next()
367        .map(|c| c.to_uppercase().collect())
368        .unwrap_or_else(|| "?".to_string());
369    let status_label = if link.is_active { "启用" } else { "停用" };
370    let status_class = if link.is_active {
371        "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300"
372    } else {
373        "bg-paper-code-block text-paper-tertiary"
374    };
375
376    let id_delete = link.id;
377    let link_for_edit = link.clone();
378    let mut editing = state.editing;
379    let mut toast = state.toast;
380    let reload_gen = state.reload_gen;
381
382    rsx! {
383        div { class: "flex items-center gap-4 py-4",
384            div { class: "relative h-8 w-8 shrink-0 rounded-2xl bg-[var(--color-paper-code-bg)] flex items-center justify-center overflow-hidden",
385                span { class: "text-sm font-semibold text-[var(--color-paper-primary)] select-none",
386                    "{initial}"
387                }
388            }
389            div { class: "flex-1 min-w-0",
390                div { class: "flex items-baseline gap-2",
391                    span { class: "font-medium text-[var(--color-paper-primary)] truncate",
392                        "{link.name}"
393                    }
394                    span { class: "{BADGE_BASE} {status_class}", "{status_label}" }
395                }
396                div { class: "flex items-baseline gap-3 mt-0.5",
397                    if !link.url.is_empty() {
398                        span { class: "text-xs text-[var(--color-paper-tertiary)] font-mono truncate",
399                            "{link.url}"
400                        }
401                    }
402                    if !link.description.is_empty() {
403                        span { class: "text-xs text-[var(--color-paper-secondary)] truncate",
404                            "{link.description}"
405                        }
406                    }
407                }
408            }
409            span { class: "text-xs text-[var(--color-paper-tertiary)] whitespace-nowrap",
410                "排序 {link.sort_order}"
411            }
412            div { class: "flex items-center gap-3 whitespace-nowrap",
413                button {
414                    class: "{BTN_TEXT_ACCENT}",
415                    onclick: move |_| editing.set(Some(link_for_edit.clone())),
416                    "编辑"
417                }
418                button {
419                    class: "{BTN_TEXT_RED}",
420                    onclick: move |_| {
421                        let confirmed = web_sys::window()
422                            .and_then(|w| {
423                                w.confirm_with_message("确定要删除这条友链吗?").ok()
424                            })
425                            .unwrap_or(false);
426                        if !confirmed {
427                            return;
428                        }
429                        let id = id_delete;
430                        spawn(async move {
431                            match delete_friend_link(id).await {
432                                Ok(()) => {
433                                    toast.set(Some(("已删除".to_string(), false)));
434                                    let g = reload_gen();
435                                    state.reload_gen.set(g + 1);
436                                }
437                                Err(e) => {
438                                    toast.set(Some((format!("删除失败:{e}"), true)));
439                                }
440                            }
441                        });
442                    },
443                    "删除"
444                }
445            }
446        }
447    }
448}