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::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/// 跨子组件共享的页面状态:刷新代际、操作提示、编辑目标。
31///
32/// `editing` 为 `Some(link)` 时表单进入编辑模式(回填该友链),`None` 为新建模式。
33#[cfg(target_arch = "wasm32")]
34#[derive(Clone, Copy, PartialEq)]
35struct FriendsPageState {
36    /// 递增以触发友链列表重新加载(创建/更新/删除后 +1)。
37    reload_gen: Signal<u32>,
38    /// 全局操作提示:(消息, 是否错误)。
39    toast: Signal<Option<(String, bool)>>,
40    /// 编辑目标:Some = 编辑模式,None = 新建模式。
41    editing: Signal<Option<FriendLink>>,
42}
43
44/// 管理后台友链管理页面。
45#[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        // server 构建下页面无前端交互;路由实际只在 WASM 渲染。
78        rsx! {
79            p { class: "text-paper-secondary", "此页面仅在浏览器中可用。" }
80        }
81    }
82}
83
84/// 页头标题区。
85#[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/// 全局操作提示条(读取共享 context 的 toast)。
122#[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/// 新建 / 编辑二合一表单卡片。
142///
143/// 表单草稿用独立 signal(合法独立状态);进入编辑模式时经 `use_effect`
144/// 一次性回填草稿(同 write.rs 的种子回填合法例外),取消/保存成功时清空。
145#[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    // 编辑目标变化时回填草稿(一次性种子回填;新建/取消时 editing 为 None 不动草稿,
165    // 由「取消」按钮与保存成功路径显式清空)。
166    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                // 表单标题区
197                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                    // 名称
233                    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                    // URL
245                    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                    // 头像(可留空):圆形预览磁贴 + 外链输入框 + 素材库按钮。
257                    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                            // 圆形预览磁贴
263                            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                    // 排序
301                    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                    // 描述
313                    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                    // 启用状态(仅编辑模式展示)
326                    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            // 放在动画卡片外,避免 fixed 弹窗继承动画 transform 而相对内容区错位。
422            AssetPickerModal {
423                visible: picker_visible,
424                cover_uploading: avatar_uploading,
425                title: "选择头像",
426                on_select: move |picks: Vec<AssetSelection>| {
427                    // 单选模式:载荷恰含一个元素。
428                    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/// 友链列表卡片:纵向行列表(非表格,友链字段少)。
439#[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    // reload_gen 变化时重新加载。
451    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/// 单行友链:头像磁贴 + 名称/URL/描述 + 排序 + 状态徽章 + 编辑/删除操作。
512#[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    // 图片加载失败时回退到名称首字符。
522    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                // 头像磁贴
539                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                // 信息主体:名称 + 状态 + 排序 + 描述 + 链接
555                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            // 操作按钮
604            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}