Skip to main content

yggdrasil/pages/admin/
posts.rs

1//! 文章管理页面(全部文章列表,`/admin/posts`)。
2//!
3//! 本页只承载「全部文章」列表;回收站已拆分为独立路由 `/admin/posts/trash`
4//! (见 `posts_trash.rs`),二者与评论管理共同组成侧边栏「内容管理」子菜单
5//! (issue #17)。翻页由客户端 signal 驱动(不走路由参数)。
6//! 数据加载与写操作仅在 WASM 前端通过 Dioxus server functions 完成。
7#![allow(unused_imports)]
8
9use dioxus::prelude::*;
10use dioxus::router::components::Link;
11// 分页数据接口:list_posts 是 server function,两端都生成(wasm 端为 client stub,
12// server 端为真实实现),故无需 cfg。实际请求只在 use_paginated 的 wasm 分支发出。
13use crate::api::posts::{list_posts, PostListResponse};
14// 操作类 server function 仅在 WASM 代码路径调用,SSR 下触发 unused imports,
15// 按项目惯例放行。
16#[allow(unused_imports)]
17use crate::api::posts::{
18    delete_post, rebuild_content_html, rebuild_post_content_html, CreatePostResponse, RebuildResult,
19};
20use crate::components::empty_state::{EmptyState, EmptyStateAction};
21use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
22use crate::components::skeletons::posts_skeleton::PostsTableSkeleton;
23use crate::components::ui::{
24    FilterTabs, Pagination, Tooltip, BTN_OUTLINE, BTN_PRIMARY, SPINNER_SVG,
25};
26use crate::hooks::query::use_paginated;
27use crate::models::post::{PostListItem, PostStatus};
28use crate::router::Route;
29
30/// 每页展示的文章数量。
31const POSTS_PER_PAGE: i32 = 20;
32
33/// 文章管理入口组件:全部文章列表页。
34///
35/// 纯壳组件:header(标题 + 重建缓存 + 发布文章入口)+ `AllPostsList`。
36/// 回收站已拆至独立路由 `/admin/posts/trash`(见 `posts_trash.rs::PostsTrash`)。
37#[component]
38pub fn Posts() -> Element {
39    rsx! {
40        div { class: "animate-page-enter w-full max-w-7xl mx-auto space-y-6",
41            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",
42                div {
43                    h1 { class: "text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
44                        "全部文章"
45                    }
46                    p { class: "text-sm text-[var(--color-paper-secondary)] mt-1.5",
47                        "管理与发布文章、草稿及内容渲染缓存"
48                    }
49                }
50                div { class: "flex items-center gap-3",
51                    RebuildCacheBar {}
52                    Link {
53                        class: "inline-flex items-center justify-center gap-1.5 px-5 py-2 text-sm font-medium text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full shadow-xs hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer",
54                        to: Route::Write {},
55                        svg {
56                            class: "w-4 h-4",
57                            xmlns: "http://www.w3.org/2000/svg",
58                            view_box: "0 0 24 24",
59                            fill: "none",
60                            stroke: "currentColor",
61                            stroke_width: "2",
62                            stroke_linecap: "round",
63                            stroke_linejoin: "round",
64                            line { x1: "12", y1: "5", x2: "12", y2: "19" }
65                            line { x1: "5", y1: "12", x2: "19", y2: "12" }
66                        }
67                        "发布文章"
68                    }
69                }
70            }
71            AllPostsList {}
72        }
73    }
74}
75
76/// 全部文章列表 tab:分页列表、删除单篇、重建 content_html 缓存。
77///
78/// 翻页用客户端 signal 驱动(`current_page` signal + `use_paginated` 的闭包内读取
79/// 建立依赖,页码变化自动重载),不走路由。删除/重建逻辑与旧实现一致。
80#[component]
81fn AllPostsList() -> Element {
82    let mut current_page = use_signal(|| 1);
83    // 状态分类过滤:all / published / draft
84    let mut status_filter = use_signal(|| "all".to_string());
85    // 搜索输入框实时绑定的文本(每键即更新,但不触发请求)。
86    let mut search_input = use_signal(String::new);
87    // 已提交的搜索词:空串表示不搜索。仅在此值变化时才重新请求,避免逐键打 DB。
88    let mut search_query = use_signal(String::new);
89    // 分页列表加载(loading / posts / total / error)由 use_paginated 统一管理。
90    // page 闭包内同时读取 current_page 与 search_query 建立响应式依赖:
91    // 翻页、或提交新搜索词(即便停留在第 1 页)都会自动重新请求。
92    // fetch 闭包在发起请求时读取 search_query 的当前值传给后端按标题过滤。
93    let paginated = use_paginated(
94        move || {
95            let _ = search_query();
96            current_page.with(|p| *p)
97        },
98        POSTS_PER_PAGE,
99        move |p, pp| {
100            let q = search_query();
101            async move {
102                list_posts(p, pp, if q.is_empty() { None } else { Some(q) })
103                    .await
104                    .map(|PostListResponse { posts, total }| (posts, total))
105                    .map_err(|e| e.to_string())
106            }
107        },
108    );
109    let mut posts = paginated.items;
110    let mut total = paginated.total;
111    let loading = paginated.loading;
112    let error = paginated.error;
113
114    // 删除中 / 重建中文章 ID 集合:均由本组件持有(业务逻辑不归 hook 管)。
115    // 改为非乐观删除后行会保留至请求完成,可并发点多个删除,故用 HashSet
116    // 与 rebuilding 同形,按行通过 contains 判断 loading 态。
117    let mut deleting = use_signal(std::collections::HashSet::<i32>::new);
118    // 重建中文章 ID 集合:支持多篇文章并发重建(行不会随点击消失,单值会被后点
119    // 的覆盖先点的,故用 HashSet),按行通过 contains 判断 loading 态。
120    let mut rebuilding = use_signal(std::collections::HashSet::<i32>::new);
121    let get_posts = move || -> Vec<PostListItem> {
122        let list = posts();
123        match status_filter().as_str() {
124            "published" => list
125                .into_iter()
126                .filter(|p| p.status == PostStatus::Published)
127                .collect(),
128            "draft" => list
129                .into_iter()
130                .filter(|p| p.status == PostStatus::Draft)
131                .collect(),
132            _ => list,
133        }
134    };
135    // 是否处于搜索结果视图(用于区分空状态文案 / 隐藏「写文章」入口)。
136    let is_searching = move || !search_query().is_empty();
137    // 提交搜索:写入 search_query 并回到第 1 页(搜索结果从首页开始分页)。
138    let mut submit_search = move || {
139        let q = search_input().trim().to_string();
140        search_query.set(q);
141        current_page.set(1);
142    };
143    rsx! {
144        // 工具栏:左侧状态分类 Tab + 右侧搜索输入框
145        div { class: "flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4",
146            // 状态筛选 Tab 胶囊
147            FilterTabs {
148                items: vec![
149                    ("all", "全部"),
150                    ("published", "已发布"),
151                    ("draft", "草稿"),
152                ],
153                active_value: status_filter(),
154                on_change: move |v: String| {
155                    status_filter.set(v);
156                },
157            }
158
159            // 搜索输入框
160            div { class: "relative flex items-center gap-2",
161                div { class: "relative flex-1 sm:w-72",
162                    span { class: "absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-[var(--color-paper-tertiary)]",
163                        svg {
164                            class: "w-4 h-4",
165                            xmlns: "http://www.w3.org/2000/svg",
166                            view_box: "0 0 24 24",
167                            fill: "none",
168                            stroke: "currentColor",
169                            stroke_width: "2",
170                            stroke_linecap: "round",
171                            stroke_linejoin: "round",
172                            circle { cx: "11", cy: "11", r: "8" }
173                            line { x1: "21", y1: "21", x2: "16.65", y2: "16.65" }
174                        }
175                    }
176                    input {
177                        class: "w-full pl-9 pr-8 py-2 text-sm border border-[var(--color-paper-border)]/70 rounded-2xl bg-[var(--color-paper-entry)]/60 text-[var(--color-paper-primary)] placeholder:text-[var(--color-paper-tertiary)] focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-all",
178                        r#type: "text",
179                        placeholder: "搜索文章标题...",
180                        value: "{search_input}",
181                        oninput: move |evt: FormEvent| search_input.set(evt.value()),
182                        onkeydown: move |e: KeyboardEvent| {
183                            if e.key() == Key::Enter {
184                                submit_search();
185                            }
186                        },
187                    }
188                    if !search_input().is_empty() {
189                        button {
190                            class: "absolute inset-y-0 right-0 pr-3 flex items-center text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
191                            onclick: move |_| {
192                                search_input.set(String::new());
193                                search_query.set(String::new());
194                                current_page.set(1);
195                            },
196                            "×"
197                        }
198                    }
199                }
200                button {
201                    class: "{BTN_PRIMARY} px-4 py-2 text-xs",
202                    onclick: move |_| submit_search(),
203                    "搜索"
204                }
205                if is_searching() {
206                    button {
207                        class: "{BTN_OUTLINE} px-3 py-2 text-xs",
208                        onclick: move |_| {
209                            search_input.set(String::new());
210                            search_query.set(String::new());
211                            current_page.set(1);
212                        },
213                        "清除"
214                    }
215                }
216            }
217        }
218
219        if error().is_some() {
220            EmptyState {
221                title: "加载失败",
222                description: "获取文章列表时发生错误,请稍后重试。",
223            }
224        } else if loading() && posts().is_empty() {
225            DelayedSkeleton { PostsTableSkeleton {} }
226        } else if get_posts().is_empty() {
227            if is_searching() {
228                EmptyState {
229                    title: "未找到匹配的文章",
230                    description: "换个标题关键词再试一次。",
231                }
232            } else if status_filter() == "draft" {
233                EmptyState {
234                    title: "暂无草稿",
235                    description: "当前没有未发布的草稿文章。",
236                }
237            } else {
238                EmptyState {
239                    title: "暂无文章",
240                    description: "还没有创建任何文章,开始写下你的第一篇文字吧。",
241                    action: EmptyStateAction {
242                        label: "写文章".to_string(),
243                        to: Route::Write {},
244                    },
245                }
246            }
247        } else {
248            div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
249                table { class: "w-full text-sm",
250                    thead {
251                        tr { class: "bg-[var(--color-paper-entry)]/80 border-b border-[var(--color-paper-border)]/70 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)] select-none",
252                            th { class: "px-5 py-3.5", "文章标题" }
253                            th { class: "px-4 py-3.5 w-24 text-center whitespace-nowrap",
254                                "状态"
255                            }
256                            th { class: "px-4 py-3.5 w-28 whitespace-nowrap hidden md:table-cell",
257                                "字数"
258                            }
259                            th { class: "px-4 py-3.5 w-32 whitespace-nowrap",
260                                "发布日期"
261                            }
262                            th { class: "px-5 py-3.5 w-48 text-right whitespace-nowrap",
263                                "操作"
264                            }
265                        }
266                    }
267                    tbody {
268                        for (idx, post) in get_posts().iter().enumerate() {
269                            PostRow {
270                                key: "{post.id}",
271                                post: post.clone(),
272                                deleting: deleting().contains(&post.id),
273                                rebuilding: rebuilding().contains(&post.id),
274                                stagger_index: idx as u32,
275                                on_delete: move |id| {
276                                    deleting.write().insert(id);
277                                    spawn(async move {
278                                        match delete_post(id).await {
279                                            Ok(CreatePostResponse { success: true, .. }) => {
280                                                posts.with_mut(|list| list.retain(|p| p.id != id));
281                                                total.with_mut(|t| *t = t.saturating_sub(1));
282                                            }
283                                            Ok(CreatePostResponse { success: false, message: _message, .. }) => {
284                                                #[cfg(target_arch = "wasm32")]
285                                                web_sys::window().map(|w| w.alert_with_message(&_message).ok());
286                                            }
287                                            Err(_e) => {
288                                                #[cfg(target_arch = "wasm32")]
289                                                web_sys::window().map(|w| w.alert_with_message("删除失败").ok());
290                                            }
291                                        }
292                                        deleting.write().remove(&id);
293                                    });
294                                },
295                                on_rebuild: move |id| {
296                                    rebuilding.write().insert(id);
297                                    spawn(async move {
298                                        let _ = rebuild_post_content_html(id).await;
299                                        rebuilding.write().remove(&id);
300                                    });
301                                },
302                            }
303                        }
304                    }
305                }
306            }
307            Pagination {
308                variant: "admin",
309                current_page: current_page(),
310                total: total(),
311                per_page: POSTS_PER_PAGE,
312                unit: "篇",
313                on_prev: {
314                    let mut page = current_page;
315                    move |_| {
316                        page.with_mut(|p| *p = (*p - 1).max(1));
317                    }
318                },
319                on_next: {
320                    let mut page = current_page;
321                    move |_| {
322                        page.with_mut(|p| *p += 1);
323                    }
324                },
325                on_jump: {
326                    let mut page = current_page;
327                    move |p: i32| {
328                        page.set(p);
329                    }
330                },
331            }
332        }
333    }
334}
335
336/// 重建内容缓存工具条子组件。
337///
338/// 封装「重建内容 / 重建全部」两个按钮及其 `do_rebuild` 异步闭包。状态
339/// (`rebuilding` / `rebuild_result`) 由本组件内部持有(从 `PostsPage` 下沉至此,
340/// 因合并后仅 All tab 需要,无需跨层传递)。
341///
342/// 从 `AllPostsList` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill)。
343#[component]
344#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
345fn RebuildCacheBar() -> Element {
346    let mut rebuilding = use_signal(|| false);
347    let mut rebuild_result = use_signal(|| Option::<String>::None);
348
349    // 重建文章渲染缓存:rebuild_all 为 false 时仅重建 content_html 为空的文章,
350    // 为 true 时重建所有文章(用于语法/渲染逻辑升级后批量刷新已有内容)。
351    let mut do_rebuild = move |rebuild_all: bool| {
352        rebuilding.set(true);
353        rebuild_result.set(None);
354        spawn(async move {
355            match rebuild_content_html(rebuild_all).await {
356                Ok(RebuildResult {
357                    rebuilt,
358                    failed,
359                    errors,
360                }) => {
361                    if failed > 0 {
362                        let mut msg = format!("已重建 {rebuilt} 篇,失败 {failed} 篇");
363                        if let Some(first) = errors.first() {
364                            msg.push_str(&format!("\n{first}"));
365                        }
366                        rebuild_result.set(Some(msg));
367                    } else {
368                        rebuild_result.set(Some(format!("已重建 {rebuilt} 篇文章")));
369                    }
370                }
371                Err(e) => {
372                    rebuild_result.set(Some(format!("失败: {e}")));
373                }
374            }
375            rebuilding.set(false);
376        });
377    };
378
379    rsx! {
380        // 消息绝对定位到按钮行下方,脱离文档流:出现/消失都不撑高祖先容器,
381        // 避免 header 的 md:items-end 把固定底边转化为按钮上移("按钮被顶上去" bug)。
382        // 自持 rebuilding / rebuild_result state,与父组件零耦合。
383        div { class: "relative flex items-center gap-3",
384            div { class: "flex items-center gap-3",
385                Tooltip {
386                    tip: "重建 content_html 为空的文章渲染缓存".to_string(),
387                    placement: "bottom",
388                    button {
389                        class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE },
390                        disabled: rebuilding(),
391                        onclick: move |_| do_rebuild(false),
392                        span { class: if rebuilding() { "opacity-40" } else { "" }, "重建内容" }
393                        if rebuilding() {
394                            span {
395                                class: "absolute inset-0 flex items-center justify-center",
396                                dangerous_inner_html: SPINNER_SVG,
397                            }
398                        }
399                    }
400                }
401                Tooltip {
402                    tip: "重建所有文章的渲染缓存(含已有内容)".to_string(),
403                    placement: "bottom",
404                    button {
405                        class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE },
406                        disabled: rebuilding(),
407                        onclick: move |_| do_rebuild(true),
408                        span { class: if rebuilding() { "opacity-40" } else { "" }, "重建全部" }
409                        if rebuilding() {
410                            span {
411                                class: "absolute inset-0 flex items-center justify-center",
412                                dangerous_inner_html: SPINNER_SVG,
413                            }
414                        }
415                    }
416                }
417            }
418            // 重建结果消息:绝对定位到按钮行正下方,脱离文档流,不影响布局高度。
419            if let Some(msg) = rebuild_result() {
420                div { class: "absolute top-full right-0 mt-1 text-xs text-paper-secondary whitespace-pre-line",
421                    "{msg}"
422                }
423            }
424        }
425    }
426}
427
428/// 文章表格行组件,展示单篇文章的标题、状态、日期与操作按钮。
429#[component]
430fn PostRow(
431    post: PostListItem,
432    deleting: bool,
433    rebuilding: bool,
434    stagger_index: u32,
435    on_delete: EventHandler<i32>,
436    on_rebuild: EventHandler<i32>,
437) -> Element {
438    let date_str = post.formatted_date();
439    // 草稿标题跳预览(/admin/preview/<slug>),已发布标题跳公开详情页。
440    let title_dest = if post.status == PostStatus::Draft {
441        Route::PostPreview {
442            slug: post.slug.clone(),
443        }
444    } else {
445        Route::PostDetail {
446            slug: post.slug.clone(),
447        }
448    };
449
450    rsx! {
451        tr {
452            class: "animate-row-enter border-b border-[var(--color-paper-border)]/60 last:border-b-0 hover:bg-[var(--color-paper-accent-soft)]/30 transition-colors duration-150",
453            style: "animation-delay: {stagger_index * 35}ms",
454            // 标题 + 别名 + 标签
455            td { class: "px-5 py-3.5",
456                div { class: "flex flex-col gap-1",
457                    Link {
458                        class: "font-semibold text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors cursor-pointer leading-snug line-clamp-1",
459                        to: title_dest,
460                        "{post.title}"
461                    }
462                    div { class: "flex flex-wrap items-center gap-2 text-xs",
463                        span { class: "font-mono text-[11px] text-[var(--color-paper-tertiary)]",
464                            "/post/{post.slug}"
465                        }
466                        if !post.tags.is_empty() {
467                            for tag in post.tags.iter().take(3) {
468                                span {
469                                    key: "{tag}",
470                                    class: "inline-flex items-center px-1.5 py-0.2 rounded text-[10px] bg-[var(--color-paper-theme)] text-[var(--color-paper-tertiary)] border border-[var(--color-paper-border)]/40",
471                                    "#{tag}"
472                                }
473                            }
474                        }
475                    }
476                }
477            }
478            // 状态指示胶囊
479            td { class: "px-4 py-3.5 text-center whitespace-nowrap",
480                if post.status == PostStatus::Published {
481                    span { class: "inline-flex items-center gap-1.5 px-2.5 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",
482                        span { class: "w-1.5 h-1.5 rounded-full bg-emerald-500" }
483                        "公开"
484                    }
485                } else {
486                    span { class: "inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20",
487                        span { class: "w-1.5 h-1.5 rounded-full bg-amber-500" }
488                        "草稿"
489                    }
490                }
491            }
492            // 字数
493            td { class: "px-4 py-3.5 text-[var(--color-paper-tertiary)] font-mono text-xs whitespace-nowrap hidden md:table-cell",
494                "{post.word_count} 字"
495            }
496            // 日期
497            td { class: "px-4 py-3.5 text-[var(--color-paper-secondary)] font-mono text-xs whitespace-nowrap",
498                "{date_str}"
499            }
500            // 操作按钮栏
501            td { class: "px-5 py-3.5 text-right whitespace-nowrap",
502                div { class: "flex justify-end items-center gap-2",
503                    // 编辑
504                    Link {
505                        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",
506                        to: Route::WriteEdit { id: post.id },
507                        svg {
508                            class: "w-3.5 h-3.5",
509                            xmlns: "http://www.w3.org/2000/svg",
510                            view_box: "0 0 24 24",
511                            fill: "none",
512                            stroke: "currentColor",
513                            stroke_width: "2",
514                            stroke_linecap: "round",
515                            stroke_linejoin: "round",
516                            path { d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" }
517                            path { d: "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" }
518                        }
519                        "编辑"
520                    }
521                    // 重建缓存
522                    Tooltip {
523                        tip: "重新渲染这篇文章的 HTML".to_string(),
524                        align: "end",
525                        button {
526                            class: if rebuilding { "relative inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-paper-accent cursor-not-allowed" } else { "inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-paper-accent hover:bg-[var(--color-paper-theme)] transition-colors cursor-pointer" },
527                            disabled: rebuilding,
528                            onclick: move |_| on_rebuild.call(post.id),
529                            span { class: if rebuilding { "opacity-0" } else { "flex items-center gap-1" },
530                                svg {
531                                    class: "w-3.5 h-3.5",
532                                    xmlns: "http://www.w3.org/2000/svg",
533                                    view_box: "0 0 24 24",
534                                    fill: "none",
535                                    stroke: "currentColor",
536                                    stroke_width: "2",
537                                    stroke_linecap: "round",
538                                    stroke_linejoin: "round",
539                                    path { d: "M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" }
540                                }
541                                "重建"
542                            }
543                            if rebuilding {
544                                span {
545                                    class: "absolute inset-0 flex items-center justify-center",
546                                    dangerous_inner_html: SPINNER_SVG,
547                                }
548                            }
549                        }
550                    }
551                    // 删除
552                    button {
553                        class: if deleting { "relative inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-red-400 cursor-not-allowed" } else { "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" },
554                        disabled: deleting,
555                        onclick: move |_| on_delete.call(post.id),
556                        span { class: if deleting { "opacity-0" } else { "flex items-center gap-1" },
557                            svg {
558                                class: "w-3.5 h-3.5",
559                                xmlns: "http://www.w3.org/2000/svg",
560                                view_box: "0 0 24 24",
561                                fill: "none",
562                                stroke: "currentColor",
563                                stroke_width: "2",
564                                stroke_linecap: "round",
565                                stroke_linejoin: "round",
566                                polyline { points: "3 6 5 6 21 6" }
567                                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" }
568                            }
569                            "删除"
570                        }
571                        if deleting {
572                            span {
573                                class: "absolute inset-0 flex items-center justify-center",
574                                dangerous_inner_html: SPINNER_SVG,
575                            }
576                        }
577                    }
578                }
579            }
580        }
581    }
582}