1use dioxus::prelude::*;
9use dioxus::router::components::Link;
10
11use crate::api::posts::{list_posts, PostListResponse};
14#[allow(unused_imports)]
17use crate::api::posts::{
18 delete_post, get_post_stats, rebuild_content_html, rebuild_post_content_html,
19 CreatePostResponse, PostStatsResponse, RebuildResult,
20};
21use crate::components::empty_state::{EmptyState, EmptyStateAction};
22use crate::components::forms::{FormInput, INPUT_INLINE_CLASS};
23use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
24use crate::components::skeletons::posts_skeleton::PostsSkeleton;
25use crate::components::ui::{
26 Pagination, StatusBadge, Tooltip, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_OUTLINE, BTN_PRIMARY,
27 BTN_TEXT_ACCENT, BTN_TEXT_RED, SPINNER_SVG,
28};
29use crate::hooks::query::use_paginated;
30use crate::models::post::PostListItem;
31use crate::router::Route;
32use super::posts_trash::PostsTrashPanel;
34
35const POSTS_PER_PAGE: i32 = 20;
37
38#[derive(Clone, Copy, PartialEq, Debug)]
43pub(super) enum PostsTab {
44 All,
46 Trash,
48}
49
50impl PostsTab {
51 fn as_str(&self) -> &'static str {
52 match self {
53 PostsTab::All => "all",
54 PostsTab::Trash => "trash",
55 }
56 }
57}
58
59#[component]
66#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
67pub fn Posts() -> Element {
68 let mut active_tab = use_signal(|| PostsTab::All);
69 let mut trash_count = use_signal(|| Option::<i64>::None);
72
73 use_effect(move || {
74 #[cfg(target_arch = "wasm32")]
75 spawn(async move {
76 if let Ok(PostStatsResponse { stats }) = get_post_stats().await {
77 trash_count.set(Some(stats.trash));
78 }
79 });
80 });
81
82 rsx! {
83 div { class: "w-full max-w-7xl mx-auto space-y-6",
84 div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-paper-border mb-6",
86 div {
87 h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
88 if active_tab() == PostsTab::All {
89 "管理文章"
90 } else {
91 "回收站"
92 }
93 }
94 p { class: "text-base text-[var(--color-paper-secondary)] mt-2",
95 if active_tab() == PostsTab::All {
96 "所有文章及草稿"
97 } else {
98 if let Some(count) = trash_count() {
99 "已删除文章 ({count})"
100 } else {
101 "已删除文章"
102 }
103 }
104 }
105 }
106 if active_tab() == PostsTab::All {
108 div { class: "flex items-center gap-3",
109 RebuildCacheBar {}
110 Link { class: "{BTN_PRIMARY}", to: Route::Write {}, "发布文章" }
111 }
112 }
113 }
114
115 PostsTabs {
117 active: active_tab,
118 trash_count,
119 on_change: move |t: PostsTab| active_tab.set(t),
120 }
121
122 div { key: "{active_tab().as_str()}",
124 match active_tab() {
125 PostsTab::All => rsx! {
126 AllPostsList {}
127 },
128 PostsTab::Trash => rsx! {
129 PostsTrashPanel {}
130 },
131 }
132 }
133 }
134 }
135}
136
137#[component]
142fn AllPostsList() -> Element {
143 let mut current_page = use_signal(|| 1);
144 let mut search_input = use_signal(String::new);
146 let mut search_query = use_signal(String::new);
148
149 let paginated = use_paginated(
154 move || {
155 let _ = search_query();
156 current_page.with(|p| *p)
157 },
158 POSTS_PER_PAGE,
159 move |p, pp| {
160 let q = search_query();
161 async move {
162 list_posts(p, pp, if q.is_empty() { None } else { Some(q) })
163 .await
164 .map(|PostListResponse { posts, total }| (posts, total))
165 .map_err(|e| e.to_string())
166 }
167 },
168 );
169 let mut posts = paginated.items;
170 let mut total = paginated.total;
171 let loading = paginated.loading;
172 let error = paginated.error;
173
174 let mut deleting = use_signal(std::collections::HashSet::<i32>::new);
178 let mut rebuilding = use_signal(std::collections::HashSet::<i32>::new);
181 let get_posts = move || -> Vec<PostListItem> { posts() };
182 let is_searching = move || !search_query().is_empty();
184 let mut submit_search = move || {
186 let q = search_input().trim().to_string();
187 search_query.set(q);
188 current_page.set(1);
189 };
190
191 rsx! {
192 div { class: "flex gap-2 mb-4",
194 FormInput {
195 r#type: "search",
196 placeholder: "搜索文章标题...",
197 value: search_input(),
198 class: INPUT_INLINE_CLASS,
199 oninput: move |v: String| search_input.set(v),
200 onkeydown: move |e: KeyboardEvent| {
201 if e.key() == Key::Enter {
202 submit_search();
203 }
204 },
205 }
206 button { class: "{BTN_PRIMARY}", onclick: move |_| submit_search(), "搜索" }
207 if is_searching() {
208 button {
209 class: "{BTN_OUTLINE}",
210 onclick: move |_| {
211 search_input.set(String::new());
212 search_query.set(String::new());
213 current_page.set(1);
214 },
215 "清除"
216 }
217 }
218 }
219
220 if error().is_some() {
221 EmptyState {
222 title: "加载失败",
223 description: "获取文章列表时发生错误,请稍后重试。",
224 }
225 } else if loading() && posts().is_empty() {
226 DelayedSkeleton { PostsSkeleton {} }
227 } else if posts().is_empty() {
228 if is_searching() {
229 EmptyState {
230 title: "未找到匹配的文章",
231 description: "换个标题关键词再试一次。",
232 }
233 } else {
234 EmptyState {
235 title: "暂无文章",
236 description: "还没有创建任何文章,开始写下你的第一篇文字吧。",
237 action: EmptyStateAction {
238 label: "写文章".to_string(),
239 to: Route::Write {},
240 },
241 }
242 }
243 } else {
244 div { class: "{ADMIN_TABLE_CLASS}",
245 table { class: "w-full text-sm",
246 thead {
247 tr { class: "border-b border-paper-border text-left text-paper-secondary",
248 th { class: "px-4 py-3 font-medium", "标题" }
249 th { class: "px-4 py-3 font-medium w-24 text-center whitespace-nowrap",
250 "状态"
251 }
252 th { class: "px-4 py-3 font-medium w-32 whitespace-nowrap",
253 "日期"
254 }
255 th { class: "px-4 py-3 font-medium w-44 text-right whitespace-nowrap",
256 "操作"
257 }
258 }
259 }
260 tbody {
261 for post in get_posts().iter() {
262 PostRow {
263 key: "{post.id}",
264 post: post.clone(),
265 deleting: deleting().contains(&post.id),
266 rebuilding: rebuilding().contains(&post.id),
267 on_delete: move |id| {
268 deleting.write().insert(id);
269 spawn(async move {
270 match delete_post(id).await {
271 Ok(CreatePostResponse { success: true, .. }) => {
272 posts.with_mut(|list| list.retain(|p| p.id != id));
273 total.with_mut(|t| *t = t.saturating_sub(1));
274 }
275 Ok(CreatePostResponse { success: false, message: _message, .. }) => {
276 #[cfg(target_arch = "wasm32")]
277 web_sys::window().map(|w| w.alert_with_message(&_message).ok());
278 }
279 Err(_e) => {
280 #[cfg(target_arch = "wasm32")]
281 web_sys::window().map(|w| w.alert_with_message("删除失败").ok());
282 }
283 }
284 deleting.write().remove(&id);
285 });
286 },
287 on_rebuild: move |id| {
288 rebuilding.write().insert(id);
289 spawn(async move {
290 let _ = rebuild_post_content_html(id).await;
291 rebuilding.write().remove(&id);
292 });
293 },
294 }
295 }
296 }
297 }
298 }
299 Pagination {
300 variant: "admin",
301 current_page: current_page(),
302 total: total(),
303 per_page: POSTS_PER_PAGE,
304 unit: "篇",
305 on_prev: {
306 let mut page = current_page;
307 move |_| {
308 page.with_mut(|p| *p = (*p - 1).max(1));
309 }
310 },
311 on_next: {
312 let mut page = current_page;
313 move |_| {
314 page.with_mut(|p| *p += 1);
315 }
316 },
317 on_jump: {
318 let mut page = current_page;
319 move |p: i32| {
320 page.set(p);
321 }
322 },
323 }
324 }
325 }
326}
327
328#[component]
336#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
337fn RebuildCacheBar() -> Element {
338 let mut rebuilding = use_signal(|| false);
339 let mut rebuild_result = use_signal(|| Option::<String>::None);
340
341 let mut do_rebuild = move |rebuild_all: bool| {
344 rebuilding.set(true);
345 rebuild_result.set(None);
346 spawn(async move {
347 match rebuild_content_html(rebuild_all).await {
348 Ok(RebuildResult {
349 rebuilt,
350 failed,
351 errors,
352 }) => {
353 if failed > 0 {
354 let mut msg = format!("已重建 {rebuilt} 篇,失败 {failed} 篇");
355 if let Some(first) = errors.first() {
356 msg.push_str(&format!("\n{first}"));
357 }
358 rebuild_result.set(Some(msg));
359 } else {
360 rebuild_result.set(Some(format!("已重建 {rebuilt} 篇文章")));
361 }
362 }
363 Err(e) => {
364 rebuild_result.set(Some(format!("失败: {e}")));
365 }
366 }
367 rebuilding.set(false);
368 });
369 };
370
371 rsx! {
372 div { class: "relative flex items-center gap-3",
376 div { class: "flex items-center gap-3",
377 Tooltip {
378 tip: "重建 content_html 为空的文章渲染缓存".to_string(),
379 placement: "bottom",
380 button {
381 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 },
382 disabled: rebuilding(),
383 onclick: move |_| do_rebuild(false),
384 span { class: if rebuilding() { "opacity-40" } else { "" }, "重建内容" }
385 if rebuilding() {
386 span {
387 class: "absolute inset-0 flex items-center justify-center",
388 dangerous_inner_html: SPINNER_SVG,
389 }
390 }
391 }
392 }
393 Tooltip {
394 tip: "重建所有文章的渲染缓存(含已有内容)".to_string(),
395 placement: "bottom",
396 button {
397 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 },
398 disabled: rebuilding(),
399 onclick: move |_| do_rebuild(true),
400 span { class: if rebuilding() { "opacity-40" } else { "" }, "重建全部" }
401 if rebuilding() {
402 span {
403 class: "absolute inset-0 flex items-center justify-center",
404 dangerous_inner_html: SPINNER_SVG,
405 }
406 }
407 }
408 }
409 }
410 if let Some(msg) = rebuild_result() {
412 div { class: "absolute top-full right-0 mt-1 text-xs text-paper-secondary whitespace-pre-line",
413 "{msg}"
414 }
415 }
416 }
417 }
418}
419
420#[component]
422fn PostRow(
423 post: PostListItem,
424 deleting: bool,
425 rebuilding: bool,
426 on_delete: EventHandler<i32>,
427 on_rebuild: EventHandler<i32>,
428) -> Element {
429 let date_str = post.formatted_date();
430
431 rsx! {
432 tr { class: "{ADMIN_ROW_HOVER}",
433 td { class: "px-4 py-3",
434 Link {
435 class: "text-paper-primary hover:text-paper-accent transition-colors cursor-pointer",
436 to: Route::PostDetail {
437 slug: post.slug.clone(),
438 },
439 "{post.title}"
440 }
441 }
442 td { class: "px-4 py-3 text-center whitespace-nowrap",
443 StatusBadge {
444 color_class: post.status_badge_class(),
445 label: post.status_label().to_string(),
446 }
447 }
448 td { class: "px-4 py-3 text-paper-secondary whitespace-nowrap", "{date_str}" }
449 td { class: "px-4 py-3 text-right whitespace-nowrap",
450 div { class: "flex justify-end items-center gap-3",
451 Link {
452 class: "text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer",
453 to: Route::WriteEdit { id: post.id },
454 "编辑"
455 }
456 Tooltip { tip: "重新渲染这篇文章的 HTML".to_string(),
457 button {
458 class: if rebuilding { "relative inline-flex items-center text-xs text-paper-accent cursor-not-allowed" } else { BTN_TEXT_ACCENT },
459 disabled: rebuilding,
460 onclick: move |_| on_rebuild.call(post.id),
461 span { class: if rebuilding { "opacity-40" } else { "" }, "重建" }
462 if rebuilding {
463 span {
464 class: "absolute inset-0 flex items-center justify-center",
465 dangerous_inner_html: SPINNER_SVG,
466 }
467 }
468 }
469 }
470 button {
471 class: if deleting { "relative inline-flex items-center text-xs text-paper-secondary cursor-not-allowed" } else { BTN_TEXT_RED },
472 disabled: deleting,
473 onclick: move |_| on_delete.call(post.id),
474 span { class: if deleting { "opacity-40" } else { "" }, "删除" }
475 if deleting {
476 span {
477 class: "absolute inset-0 flex items-center justify-center",
478 dangerous_inner_html: SPINNER_SVG,
479 }
480 }
481 }
482 }
483 }
484 }
485 }
486}
487
488static POSTS_TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
491
492#[component]
505#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
506pub(super) fn PostsTabs(
507 active: Signal<PostsTab>,
508 trash_count: Signal<Option<i64>>,
509 on_change: EventHandler<PostsTab>,
510) -> Element {
511 let is_trash = active() == PostsTab::Trash;
512 let mut indicator_style = use_signal(|| "left: 0px; width: 0px; opacity: 0;".to_string());
514 let id_prefix =
515 use_hook(|| POSTS_TAB_GROUP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
516
517 let update_indicator = move |active_key: &str| {
520 let active_key = active_key.to_string();
521 spawn(async move {
522 #[cfg(target_arch = "wasm32")]
523 {
524 use wasm_bindgen::JsCast;
525 crate::utils::time::sleep_ms(50).await;
526 if let Some(el) = web_sys::window().and_then(|w| w.document()).and_then(|d| {
527 d.get_element_by_id(&format!("posts-tab-{id_prefix}-{active_key}"))
528 }) {
529 if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
530 indicator_style.set(format!(
531 "left: {}px; width: {}px; opacity: 1;",
532 html_el.offset_left(),
533 html_el.offset_width()
534 ));
535 }
536 }
537 }
538 });
539 };
540
541 use_effect(move || {
543 update_indicator(active().as_str());
544 });
545
546 rsx! {
547 div { class: "relative flex items-center gap-4 border-b border-paper-border",
549 button {
550 id: "posts-tab-{id_prefix}-all",
551 class: if !is_trash { "inline-flex items-center px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors cursor-pointer" } else { "inline-flex items-center px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer" },
552 onclick: move |_| on_change.call(PostsTab::All),
553 "全部文章"
554 }
555 button {
556 id: "posts-tab-{id_prefix}-trash",
557 class: if is_trash { "inline-flex items-center gap-1.5 px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors cursor-pointer" } else { "inline-flex items-center gap-1.5 px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer" },
558 onclick: move |_| on_change.call(PostsTab::Trash),
559 "回收站"
560 if let Some(count) = trash_count() {
562 span { class: if count > 0 { "inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[0.625rem] font-semibold normal-case tracking-normal bg-paper-accent-soft text-paper-accent" } else { "inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[0.625rem] font-semibold normal-case tracking-normal bg-paper-tertiary text-paper-secondary" },
563 "{count}"
564 }
565 }
566 }
567 div {
569 class: "absolute bottom-[-1px] h-[2px] bg-paper-primary transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] pointer-events-none",
570 style: "{indicator_style}",
571 }
572 }
573 }
574}