1use 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
36const POSTS_PER_PAGE: i32 = 20;
38const SEARCH_DEBOUNCE_MS: u32 = 300;
40
41pub 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
77fn status_filter(value: &str) -> Option<PostStatus> {
79 match value {
80 "published" => Some(PostStatus::Published),
81 "draft" => Some(PostStatus::Draft),
82 _ => None,
83 }
84}
85
86fn 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
93fn all_selected(rows: &[PostListItem], selected: &HashSet<i32>) -> bool {
95 !rows.is_empty() && rows.iter().all(|p| selected.contains(&p.id))
96}
97
98#[derive(Clone, Copy, PartialEq)]
100enum ListState {
101 Error,
102 Loading,
103 Empty,
104 Table,
105}
106
107#[derive(Clone, Copy, PartialEq)]
109enum EmptyKind {
110 Filtered,
111 Drafts,
112 NoPosts,
113}
114
115#[component]
117fn Posts() -> impl IntoView {
118 let stats = RwSignal::new(None::<PostStats>);
119 let stats_version = RwSignal::new(0_u32);
120 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#[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 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 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#[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 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 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 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#[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}