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