Skip to main content

yggdrasil/pages/admin/
posts.rs

1//! 文章列表(`/admin/posts`)。
2//!
3//! 标题行(篇数、重建渲染菜单、新建文章)→ 状态选项卡(「回收站」进入独立路由
4//! `/admin/posts/trash`)、标题搜索与标签筛选 → 表格(勾选、标题与 slug、状态、标签、
5//! 字数、发布时间、「⋯」菜单)与底栏分页。勾选后底部浮起批量操作条。手机端表格行
6//! 改为卡片,主操作为右下角浮动按钮。筛选、页码与搜索框内容按历史记录保存,从预览
7//! 返回时恢复。数据仅在 WASM 前端通过 server function 加载(后台数据是私有的,
8//! SSR 不取数)。样式见 styles/pages/admin-posts.css。
9
10use std::collections::HashSet;
11
12use leptos::ev::KeyboardEvent;
13use leptos::prelude::*;
14use leptos::task::spawn_local;
15use leptos_router::{lazy_route, LazyRoute};
16
17use crate::api::posts::{
18    batch_posts, delete_post, list_posts, list_tags, rebuild_content_html,
19    rebuild_post_content_html, AdminPostQuery, BatchPostAction, PostListResponse,
20};
21use crate::components::admin_layout::AdminPageHead;
22use crate::components::empty_state::{EmptyState, EmptyStateAction};
23use crate::components::forms::{FormSelect, FORM_SELECT_COMPACT_CLASS};
24use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
25use crate::components::skeletons::posts_skeleton::PostsTableSkeleton;
26use crate::components::ui::{
27    button_class, use_toast, ButtonSize, ButtonVariant, Checkbox, Icon, IconButton, IconName,
28    LoadError, Menu, MenuItem, SegmentedControl, SegmentedItem, StatusDot, TablePager,
29};
30use crate::hooks::query::use_paginated;
31use crate::models::post::{PostListItem, PostStats, PostStatus};
32use crate::routes::Route;
33use crate::utils::error::failure_message;
34use crate::utils::time::{hm, to_local, ymd};
35
36/// 每页展示的文章数量。
37const POSTS_PER_PAGE: i32 = 20;
38/// 输入停顿多久后自动搜索(毫秒)。
39const SEARCH_DEBOUNCE_MS: u32 = 300;
40
41/// `/admin/posts` 路由:纯客户端数据,`data()` 不取数。
42pub struct PostsRoute;
43
44#[lazy_route]
45impl LazyRoute for PostsRoute {
46    fn data() -> Self {
47        Self
48    }
49
50    fn view(_this: Self) -> AnyView {
51        view! { <Posts/> }.into_any()
52    }
53}
54
55#[derive(Clone, serde::Serialize, serde::Deserialize)]
56struct PostsHistoryState {
57    page: i32,
58    status: String,
59    search_input: String,
60    search_query: String,
61    #[serde(default)]
62    tag: String,
63}
64
65impl Default for PostsHistoryState {
66    fn default() -> Self {
67        Self {
68            page: 1,
69            status: "all".to_string(),
70            search_input: String::new(),
71            search_query: String::new(),
72            tag: String::new(),
73        }
74    }
75}
76
77/// 状态选项卡的值转成查询条件。
78fn status_filter(value: &str) -> Option<PostStatus> {
79    match value {
80        "published" => Some(PostStatus::Published),
81        "draft" => Some(PostStatus::Draft),
82        _ => None,
83    }
84}
85
86/// 标签列:前两个用「·」连接,其余折成「+N」。
87fn tag_summary(tags: &[String]) -> (String, Option<String>) {
88    let shown = tags.iter().take(2).cloned().collect::<Vec<_>>().join(" · ");
89    let more = (tags.len() > 2).then(|| format!("+{}", tags.len() - 2));
90    (shown, more)
91}
92
93/// 表头勾选框:全部勾选时为选中;点它在「全选本页」与「全不选」之间切换。
94fn all_selected(rows: &[PostListItem], selected: &HashSet<i32>) -> bool {
95    !rows.is_empty() && rows.iter().all(|p| selected.contains(&p.id))
96}
97
98/// 列表区域的四种互斥状态。
99#[derive(Clone, Copy, PartialEq)]
100enum ListState {
101    Error,
102    Loading,
103    Empty,
104    Table,
105}
106
107/// 空列表时的三种文案。
108#[derive(Clone, Copy, PartialEq)]
109enum EmptyKind {
110    Filtered,
111    Drafts,
112    NoPosts,
113}
114
115/// 文章列表页。
116#[component]
117fn Posts() -> impl IntoView {
118    let stats = RwSignal::new(None::<PostStats>);
119    let stats_version = RwSignal::new(0_u32);
120    // 请求世代号:连续刷新篇数时只有最后一次响应落地。
121    let stats_generation = StoredValue::new(0_u64);
122    Effect::new(move |_| {
123        stats_version.track();
124        let Some(id) = stats_generation.try_update_value(|g| {
125            *g = g.wrapping_add(1);
126            *g
127        }) else {
128            return;
129        };
130        spawn_local(async move {
131            if let Ok(res) = crate::api::posts::get_post_stats().await {
132                if stats_generation.try_get_value() == Some(id) {
133                    stats.try_set(Some(res.stats));
134                }
135            }
136        });
137    });
138    let summary = Memo::new(move |_| {
139        stats.get().map(|s| {
140            let mut parts = vec![format!("{} 篇已发布", s.published)];
141            if s.drafts > 0 {
142                parts.push(format!("草稿 {}", s.drafts));
143            }
144            if s.trash > 0 {
145                parts.push(format!("回收站 {}", s.trash));
146            }
147            parts.join(" · ")
148        })
149    });
150
151    view! {
152        <div class="admin-posts">
153            <AdminPageHead
154                title="文章"
155                actions=move || {
156                    view! {
157                        <RebuildMenu/>
158                        <a
159                            class=format!(
160                                "{} admin-hide-mobile",
161                                button_class(ButtonVariant::Primary, ButtonSize::Md),
162                            )
163                            href=Route::Write.to_string()
164                        >
165                            <Icon name=IconName::Plus size=16/>
166                            "新建文章"
167                        </a>
168                    }
169                }
170            >
171                {move || summary.get()}
172            </AdminPageHead>
173            <PostsTable on_changed=move |_: ()| stats_version.update(|n| *n += 1)/>
174            <a class="admin-fab" href=Route::Write.to_string() aria-label="写文章">
175                <Icon name=IconName::Plus size=18/>
176                "写文章"
177            </a>
178        </div>
179    }
180}
181
182/// 「重建渲染」菜单:只补空缺的渲染,或全部重建(渲染规则升级后用)。结果用提示条告知。
183#[component]
184fn RebuildMenu() -> impl IntoView {
185    let toast = use_toast();
186    let busy = RwSignal::new(false);
187    let run = move |rebuild_all: bool| {
188        if busy.get_untracked() {
189            return;
190        }
191        busy.set(true);
192        spawn_local(async move {
193            // 服务端每批只处理一部分,按 next_after_id 游标续传直到处理完。
194            let mut after_id = 0_i32;
195            let mut rebuilt = 0_u64;
196            let mut failed = 0_u64;
197            let mut first_error: Option<String> = None;
198            let mut interrupted = None;
199            loop {
200                match rebuild_content_html(rebuild_all, after_id).await {
201                    Ok(res) => {
202                        rebuilt += res.rebuilt;
203                        failed += res.failed;
204                        if first_error.is_none() {
205                            first_error = res.errors.into_iter().next();
206                        }
207                        match res.next_after_id {
208                            Some(next) => after_id = next,
209                            None => break,
210                        }
211                    }
212                    Err(e) => {
213                        interrupted = Some(e);
214                        break;
215                    }
216                }
217            }
218            if let Some(e) = interrupted {
219                let prefix = if rebuilt > 0 {
220                    format!("已重建 {rebuilt} 篇后中断")
221                } else {
222                    "重建失败".to_string()
223                };
224                toast.error(failure_message(&prefix, &e));
225            } else if failed > 0 {
226                toast.error(format!(
227                    "已重建 {rebuilt} 篇,失败 {failed} 篇{}",
228                    first_error.map(|e| format!(":{e}")).unwrap_or_default()
229                ));
230            } else {
231                toast.success(format!("已重建 {rebuilt} 篇文章的渲染"));
232            }
233            busy.try_set(false);
234        });
235    };
236    // Menu 的 label 不是响应式 prop:忙碌态变化时按新文案重建菜单。
237    view! {
238        {move || {
239            let label = if busy.get() { "正在重建渲染…" } else { "更多操作" };
240            view! {
241                <Menu
242                    label=label
243                    size=ButtonSize::Md
244                    items=vec![
245                        MenuItem::new("missing", "补全缺失的渲染").icon(IconName::RefreshCw),
246                        MenuItem::new("all", "重建全部渲染").icon(IconName::RotateCw),
247                    ]
248                    on_select=move |id: &'static str| run(id == "all")
249                />
250            }
251        }}
252    }
253}
254
255/// 文章表格:筛选、勾选、批量操作与分页。`on_changed` 在增删改后通知页头刷新篇数。
256#[component]
257fn PostsTable(#[prop(into)] on_changed: Callback<()>) -> impl IntoView {
258    let entry_id = crate::bridges::navigation::entry_id();
259    let saved = crate::bridges::navigation::read_state::<PostsHistoryState>("admin-posts")
260        .unwrap_or_default();
261    let toast = use_toast();
262    let current_page = RwSignal::new(saved.page.max(1));
263    let status = RwSignal::new(saved.status.clone());
264    let tag = RwSignal::new(saved.tag.clone());
265    // 输入框内容与已提交的搜索词分开:停顿 300ms 或回车才提交。
266    let search_input = RwSignal::new(saved.search_input.clone());
267    let search_query = RwSignal::new(saved.search_query.clone());
268    let search_seq = RwSignal::new(0_u64);
269    let selected = RwSignal::new(HashSet::<i32>::new());
270    let busy = RwSignal::new(HashSet::<i32>::new());
271    let batch_busy = RwSignal::new(false);
272    let tags = RwSignal::new(Vec::<String>::new());
273    // 原地重新请求当前页(批量操作或重试后)。
274    let refresh = RwSignal::new(0_u32);
275
276    Effect::new(move |_| {
277        crate::bridges::navigation::write_state(
278            &entry_id,
279            "admin-posts",
280            &PostsHistoryState {
281                page: current_page.get(),
282                status: status.get(),
283                search_input: search_input.get(),
284                search_query: search_query.get(),
285                tag: tag.get(),
286            },
287        );
288    });
289    Effect::new(move |_| {
290        spawn_local(async move {
291            if let Ok(res) = list_tags().await {
292                tags.try_set(res.tags.into_iter().map(|t| t.name).collect());
293            }
294        });
295    });
296
297    let paginated = use_paginated(
298        move || {
299            search_query.track();
300            status.track();
301            tag.track();
302            refresh.track();
303            current_page.get()
304        },
305        POSTS_PER_PAGE,
306        move |p, pp| {
307            let query = AdminPostQuery {
308                search: Some(search_query.get()).filter(|q| !q.is_empty()),
309                status: status_filter(&status.get()),
310                tag: Some(tag.get()).filter(|t| !t.is_empty()),
311            };
312            async move {
313                list_posts(p, pp, query)
314                    .await
315                    .map(|PostListResponse { posts, total }| (posts, total))
316                    .map_err(|e| failure_message("文章列表加载失败", &e))
317            }
318        },
319    );
320    let posts = paginated.items;
321    let total = paginated.total;
322    let loading = paginated.loading;
323    let error = paginated.error;
324    let reload = move || {
325        refresh.try_update(|n| *n += 1);
326    };
327
328    // 换筛选条件时清掉勾选,回到第 1 页。
329    let reset_view = move || {
330        selected.update(|s| s.clear());
331        current_page.set(1);
332    };
333    let submit_search = move || {
334        let q = search_input.get_untracked().trim().to_string();
335        if q != search_query.get_untracked() {
336            search_query.set(q);
337            reset_view();
338        }
339    };
340    let schedule_search = move || {
341        search_seq.update(|s| *s += 1);
342        let id = search_seq.get_untracked();
343        spawn_local(async move {
344            crate::utils::time::sleep_ms(SEARCH_DEBOUNCE_MS).await;
345            if search_seq.try_get_untracked() == Some(id) {
346                submit_search();
347            }
348        });
349    };
350
351    let run_batch = move |action: BatchPostAction| {
352        let ids: Vec<i32> = selected.get_untracked().into_iter().collect();
353        if ids.is_empty() || batch_busy.get_untracked() {
354            return;
355        }
356        batch_busy.set(true);
357        spawn_local(async move {
358            match batch_posts(ids, action).await {
359                Ok(res) => {
360                    let verb = match action {
361                        BatchPostAction::Publish => "发布",
362                        BatchPostAction::Unpublish => "转为草稿",
363                        BatchPostAction::Trash => "移到回收站",
364                    };
365                    if res.failed > 0 {
366                        toast.error(format!("已{verb} {} 篇,{} 篇失败", res.done, res.failed));
367                    } else {
368                        toast.success(format!("已{verb} {} 篇", res.done));
369                    }
370                    selected.try_update(|s| s.clear());
371                    reload();
372                    on_changed.try_run(());
373                }
374                Err(e) => toast.error(failure_message("操作失败", &e)),
375            }
376            batch_busy.try_set(false);
377        });
378    };
379
380    let on_row_action =
381        Callback::new(
382            move |(post, action): (PostListItem, &'static str)| match action {
383                "edit" => {
384                    crate::navigation::push(&Route::WriteEdit { id: post.id }.to_string());
385                }
386                "preview" => {
387                    crate::navigation::push(&Route::PostPreview { slug: post.slug }.to_string());
388                }
389                "copy" => {
390                    #[cfg(target_arch = "wasm32")]
391                    spawn_local(async move {
392                        let origin = web_sys::window()
393                            .and_then(|w| w.location().origin().ok())
394                            .unwrap_or_default();
395                        if crate::utils::js::copy_text(&format!("{origin}/post/{}", post.slug))
396                            .await
397                        {
398                            toast.success("已复制文章链接");
399                        } else {
400                            toast.error("复制失败,请手动复制地址栏中的链接");
401                        }
402                    });
403                }
404                "rebuild" => {
405                    busy.update(|b| _ = b.insert(post.id));
406                    spawn_local(async move {
407                        match rebuild_post_content_html(post.id).await {
408                            Ok(res) if res.success => {
409                                toast.success(format!("已重建《{}》的渲染", post.title))
410                            }
411                            Ok(res) => toast.error(res.message),
412                            Err(e) => toast.error(failure_message("重建失败", &e)),
413                        }
414                        busy.try_update(|b| _ = b.remove(&post.id));
415                    });
416                }
417                "trash" => {
418                    busy.update(|b| _ = b.insert(post.id));
419                    spawn_local(async move {
420                        match delete_post(post.id).await {
421                            Ok(res) if res.success => {
422                                posts.try_update(|list| list.retain(|p| p.id != post.id));
423                                total.try_update(|t| *t = t.saturating_sub(1));
424                                selected.try_update(|s| _ = s.remove(&post.id));
425                                toast.success(format!("已把《{}》移到回收站", post.title));
426                                on_changed.try_run(());
427                            }
428                            Ok(res) => toast.error(res.message),
429                            Err(e) => toast.error(failure_message("移到回收站失败", &e)),
430                        }
431                        busy.try_update(|b| _ = b.remove(&post.id));
432                    });
433                }
434                _ => {}
435            },
436        );
437    let on_row_select = Callback::new(move |(id, checked): (i32, bool)| {
438        selected.update(|s| {
439            if checked {
440                s.insert(id);
441            } else {
442                s.remove(&id);
443            }
444        });
445    });
446
447    let page_all = Memo::new(move |_| {
448        posts.with(|rows| selected.with(|selection| all_selected(rows, selection)))
449    });
450    let filtering =
451        Memo::new(move |_| !search_query.with(|q| q.is_empty()) || !tag.with(|t| t.is_empty()));
452    let list_state = Memo::new(move |_| {
453        if error.with(Option::is_some) {
454            ListState::Error
455        } else if posts.with(Vec::is_empty) {
456            if loading.get() {
457                ListState::Loading
458            } else {
459                ListState::Empty
460            }
461        } else {
462            ListState::Table
463        }
464    });
465    let empty_kind = Memo::new(move |_| {
466        if filtering.get() {
467            EmptyKind::Filtered
468        } else if status.with(|s| s == "draft") {
469            EmptyKind::Drafts
470        } else {
471            EmptyKind::NoPosts
472        }
473    });
474    let tag_options = Signal::derive(move || {
475        let mut options: Vec<(String, String)> = vec![(String::new(), "全部标签".to_string())];
476        options.extend(tags.get().into_iter().map(|t| (t.clone(), t)));
477        options
478    });
479    let selection_count = Memo::new(move |_| selected.with(HashSet::len));
480    let has_selection = Memo::new(move |_| selection_count.get() > 0);
481
482    view! {
483        {move || {
484            (!loading.get() || error.with(Option::is_some))
485                .then(|| view! { <span hidden data-vt-list="true"></span> })
486        }}
487        <div class="admin-toolbar">
488            <SegmentedControl
489                aria_label="文章状态"
490                items=vec![
491                    SegmentedItem::new("all".to_string(), "全部"),
492                    SegmentedItem::new("published".to_string(), "已发布"),
493                    SegmentedItem::new("draft".to_string(), "草稿"),
494                    SegmentedItem::new("trash".to_string(), "回收站"),
495                ]
496                value=status
497                on_change=move |value: String| {
498                    if value == "trash" {
499                        crate::navigation::push(&Route::PostsTrash.to_string());
500                    } else {
501                        status.set(value);
502                        reset_view();
503                    }
504                }
505            />
506            <div class="admin-toolbar-filters">
507                <label class="field field-sm admin-filter-search">
508                    <Icon name=IconName::Search size=14/>
509                    <span class="sr-only">"按标题搜索"</span>
510                    <input
511                        type="search"
512                        placeholder="按标题搜索"
513                        prop:value=move || search_input.get()
514                        on:input=move |e| {
515                            search_input.set(event_target_value(&e));
516                            schedule_search();
517                        }
518                        on:keydown=move |e: KeyboardEvent| {
519                            let key = e.key();
520                            if key == "Enter" && !e.is_composing() {
521                                search_seq.update(|s| *s += 1);
522                                submit_search();
523                            } else if key == "Escape"
524                                && search_input.with_untracked(|s| !s.is_empty())
525                            {
526                                e.prevent_default();
527                                search_input.set(String::new());
528                                search_seq.update(|s| *s += 1);
529                                submit_search();
530                            }
531                        }
532                    />
533                </label>
534                <FormSelect
535                    id="admin-posts-tag"
536                    value=tag
537                    options=tag_options
538                    trigger_class=FORM_SELECT_COMPACT_CLASS
539                    aria_label="按标签筛选"
540                    onchange=move |value: String| {
541                        tag.set(value);
542                        reset_view();
543                    }
544                />
545            </div>
546        </div>
547
548        {move || match list_state.get() {
549            ListState::Error => {
550                view! { <LoadError title="文章列表加载失败" on_retry=move |_: ()| reload()/> }
551                    .into_any()
552            }
553            ListState::Loading => {
554                view! {
555                    <DelayedSkeleton>
556                        <PostsTableSkeleton/>
557                    </DelayedSkeleton>
558                }
559                    .into_any()
560            }
561            ListState::Empty => {
562                view! {
563                    <div class="admin-panel">
564                        {move || match empty_kind.get() {
565                            EmptyKind::Filtered => {
566                                view! {
567                                    <EmptyState
568                                        icon=IconName::SearchX
569                                        title="没有符合条件的文章"
570                                        description="换个标题关键词,或清除标签筛选再试。"
571                                        action=EmptyStateAction::new(
572                                            "清除筛选",
573                                            move |_| {
574                                                search_input.set(String::new());
575                                                search_query.set(String::new());
576                                                tag.set(String::new());
577                                                reset_view();
578                                            },
579                                        )
580                                    />
581                                }
582                                    .into_any()
583                            }
584                            EmptyKind::Drafts => {
585                                view! {
586                                    <EmptyState
587                                        title="没有草稿"
588                                        description="未发布的文章会出现在这里。"
589                                    />
590                                }
591                                    .into_any()
592                            }
593                            EmptyKind::NoPosts => {
594                                view! {
595                                    <EmptyState
596                                        title="还没有文章"
597                                        description="写下第一篇,它会出现在这里。"
598                                        action=EmptyStateAction::new(
599                                            "写文章",
600                                            move |_| {
601                                                crate::navigation::push(&Route::Write.to_string());
602                                            },
603                                        )
604                                    />
605                                }
606                                    .into_any()
607                            }
608                        }}
609                    </div>
610                }
611                    .into_any()
612            }
613            ListState::Table => {
614                view! {
615                    <div
616                        class="admin-panel admin-table-wrap"
617                        aria-busy=move || loading.get().then_some("true")
618                    >
619                        <table class="admin-table admin-posts-table">
620                            <thead>
621                                <tr>
622                                    <th class="admin-table-check">
623                                        <Checkbox
624                                            checked=page_all
625                                            label="选择本页全部文章".to_string()
626                                            onchange=move |checked: bool| {
627                                                let ids: Vec<i32> = posts
628                                                    .with_untracked(|rows| rows.iter().map(|p| p.id).collect());
629                                                selected
630                                                    .update(|set| {
631                                                        for id in &ids {
632                                                            if checked {
633                                                                set.insert(*id);
634                                                            } else {
635                                                                set.remove(id);
636                                                            }
637                                                        }
638                                                    });
639                                            }
640                                        />
641                                    </th>
642                                    <th>"标题"</th>
643                                    <th>"状态"</th>
644                                    <th class="admin-posts-tags">"标签"</th>
645                                    <th class="admin-table-num admin-posts-words">"字数"</th>
646                                    <th>"发布时间"</th>
647                                    <th class="admin-table-actions">
648                                        <span class="sr-only">"操作"</span>
649                                    </th>
650                                </tr>
651                            </thead>
652                            <tbody>
653                                {move || {
654                                    posts
655                                        .get()
656                                        .into_iter()
657                                        .map(|post| {
658                                            let id = post.id;
659                                            view! {
660                                                <PostRow
661                                                    selected=Signal::derive(move || {
662                                                        selected.with(|s| s.contains(&id))
663                                                    })
664                                                    busy=Signal::derive(move || busy.with(|b| b.contains(&id)))
665                                                    on_select=on_row_select
666                                                    on_action=on_row_action
667                                                    post=post
668                                                />
669                                            }
670                                        })
671                                        .collect_view()
672                                }}
673                            </tbody>
674                        </table>
675                        <TablePager
676                            page=current_page
677                            total=total
678                            per_page=POSTS_PER_PAGE
679                            unit="篇"
680                            on_change=move |page: i32| {
681                                current_page.set(page);
682                                #[cfg(target_arch = "wasm32")]
683                                if let Some(scroller) = web_sys::window()
684                                    .and_then(|w| w.document())
685                                    .and_then(|d| d.query_selector("[data-vt-scroll]").ok().flatten())
686                                {
687                                    scroller.set_scroll_top(0);
688                                }
689                            }
690                        />
691                    </div>
692                }
693                    .into_any()
694            }
695        }}
696
697        <Show when=move || has_selection.get()>
698            <div class="batch-bar" role="toolbar" aria-label="批量操作">
699                <span class="batch-bar-count">
700                    {move || format!("已选 {} 篇", selection_count.get())}
701                </span>
702                <button
703                    class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
704                    type="button"
705                    disabled=move || batch_busy.get()
706                    on:click=move |_| run_batch(BatchPostAction::Unpublish)
707                >
708                    "转为草稿"
709                </button>
710                <button
711                    class=format!(
712                        "{} batch-bar-danger",
713                        button_class(ButtonVariant::Ghost, ButtonSize::Sm),
714                    )
715                    type="button"
716                    disabled=move || batch_busy.get()
717                    on:click=move |_| run_batch(BatchPostAction::Trash)
718                >
719                    "移到回收站"
720                </button>
721                <span class="batch-bar-sep" aria-hidden="true"></span>
722                <IconButton
723                    icon=IconName::X
724                    label="取消选择"
725                    size=ButtonSize::Sm
726                    onclick=move |_| selected.update(|s| s.clear())
727                />
728            </div>
729        </Show>
730    }
731}
732
733/// 表格的一行。标题进入后台预览(保留列表来源与侧栏);「⋯」菜单回传操作 id。
734#[component]
735fn PostRow(
736    post: PostListItem,
737    #[prop(into)] selected: Signal<bool>,
738    #[prop(into)] busy: Signal<bool>,
739    #[prop(into)] on_select: Callback<(i32, bool)>,
740    #[prop(into)] on_action: Callback<(PostListItem, &'static str)>,
741) -> impl IntoView {
742    let published = post.status == PostStatus::Published;
743    let date = post.published_at.map(|d| {
744        let t = to_local(d);
745        format!("{} {}", ymd(&t, '-'), hm(&t))
746    });
747    let (tags, more) = tag_summary(&post.tags);
748    let mut copy = MenuItem::new("copy", "复制链接").icon(IconName::Link);
749    if !published {
750        copy = copy.disabled("草稿还没有公开地址");
751    }
752    let items = vec![
753        MenuItem::new("edit", "编辑").icon(IconName::Pencil),
754        MenuItem::new("preview", "预览").icon(IconName::Eye),
755        copy,
756        MenuItem::new("rebuild", "重建渲染").icon(IconName::RefreshCw),
757        MenuItem::new("trash", "移到回收站")
758            .icon(IconName::Trash)
759            .danger()
760            .separated(),
761    ];
762    let id = post.id;
763    let menu_post = post.clone();
764    let checkbox_label = format!("选择《{}》", post.title);
765    let preview_href = Route::PostPreview {
766        slug: post.slug.clone(),
767    }
768    .to_string();
769    let tags_cell = if tags.is_empty() {
770        view! { <span class="admin-table-muted">"—"</span> }.into_any()
771    } else {
772        view! {
773            {tags}
774            {more.map(|more| view! { <span class="admin-table-muted">{format!(" {more}")}</span> })}
775        }
776        .into_any()
777    };
778    let date_cell = match date {
779        Some(date) => view! { <time>{date}</time> }.into_any(),
780        None => view! { <span class="admin-table-muted">"未发布"</span> }.into_any(),
781    };
782    view! {
783        <tr
784            data-selected=move || selected.get().then_some("true")
785            aria-busy=move || busy.get().then_some("true")
786        >
787            <td class="admin-table-check">
788                <Checkbox
789                    checked=selected
790                    label=checkbox_label
791                    onchange=move |checked: bool| on_select.run((id, checked))
792                />
793            </td>
794            <td class="admin-posts-title">
795                <a
796                    href=preview_href
797                    data-vt-post-link=id.to_string()
798                    data-vt-post-id=id.to_string()
799                    data-vt-role="title"
800                >
801                    {post.title}
802                </a>
803                <span class="admin-posts-slug">{format!("/{}", post.slug)}</span>
804            </td>
805            <td class="admin-posts-status">
806                <StatusDot label=post.status.label() tone=post.status.tone()/>
807            </td>
808            <td class="admin-posts-tags">{tags_cell}</td>
809            <td class="admin-table-num admin-posts-words">{post.word_count}</td>
810            <td class="admin-posts-date">{date_cell}</td>
811            <td class="admin-table-actions">
812                <Menu
813                    label="更多操作"
814                    items=items
815                    on_select=move |action: &'static str| {
816                        on_action.run((menu_post.clone(), action))
817                    }
818                />
819            </td>
820        </tr>
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827
828    #[test]
829    fn tag_summary_folds_extra_tags() {
830        let tags: Vec<String> = ["Rust", "架构", "Leptos"]
831            .iter()
832            .map(|s| s.to_string())
833            .collect();
834        assert_eq!(
835            tag_summary(&tags),
836            ("Rust · 架构".to_string(), Some("+1".to_string()))
837        );
838        assert_eq!(tag_summary(&tags[..1]), ("Rust".to_string(), None));
839        assert_eq!(tag_summary(&[]), (String::new(), None));
840    }
841
842    #[test]
843    fn status_filter_maps_tabs() {
844        assert_eq!(status_filter("draft"), Some(PostStatus::Draft));
845        assert_eq!(status_filter("published"), Some(PostStatus::Published));
846        assert_eq!(status_filter("all"), None);
847    }
848}