1#![allow(unused_imports)]
8
9use dioxus::prelude::*;
10use dioxus::router::components::Link;
11use crate::api::posts::{list_posts, PostListResponse};
14#[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
30const POSTS_PER_PAGE: i32 = 20;
32
33#[component]
38pub fn Posts() -> Element {
39 rsx! {
40 div { class: "animate-page-enter w-full max-w-7xl mx-auto space-y-6",
41 div { class: "flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-[var(--color-paper-border)]/70",
42 div {
43 h1 { class: "text-3xl sm:text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]",
44 "全部文章"
45 }
46 p { class: "text-sm text-[var(--color-paper-secondary)] mt-1.5",
47 "管理与发布文章、草稿及内容渲染缓存"
48 }
49 }
50 div { class: "flex items-center gap-3",
51 RebuildCacheBar {}
52 Link {
53 class: "inline-flex items-center justify-center gap-1.5 px-5 py-2 text-sm font-medium text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full shadow-xs hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer",
54 to: Route::Write {},
55 svg {
56 class: "w-4 h-4",
57 xmlns: "http://www.w3.org/2000/svg",
58 view_box: "0 0 24 24",
59 fill: "none",
60 stroke: "currentColor",
61 stroke_width: "2",
62 stroke_linecap: "round",
63 stroke_linejoin: "round",
64 line { x1: "12", y1: "5", x2: "12", y2: "19" }
65 line { x1: "5", y1: "12", x2: "19", y2: "12" }
66 }
67 "发布文章"
68 }
69 }
70 }
71 AllPostsList {}
72 }
73 }
74}
75
76#[component]
81fn AllPostsList() -> Element {
82 let mut current_page = use_signal(|| 1);
83 let mut status_filter = use_signal(|| "all".to_string());
85 let mut search_input = use_signal(String::new);
87 let mut search_query = use_signal(String::new);
89 let paginated = use_paginated(
94 move || {
95 let _ = search_query();
96 current_page.with(|p| *p)
97 },
98 POSTS_PER_PAGE,
99 move |p, pp| {
100 let q = search_query();
101 async move {
102 list_posts(p, pp, if q.is_empty() { None } else { Some(q) })
103 .await
104 .map(|PostListResponse { posts, total }| (posts, total))
105 .map_err(|e| e.to_string())
106 }
107 },
108 );
109 let mut posts = paginated.items;
110 let mut total = paginated.total;
111 let loading = paginated.loading;
112 let error = paginated.error;
113
114 let mut deleting = use_signal(std::collections::HashSet::<i32>::new);
118 let mut rebuilding = use_signal(std::collections::HashSet::<i32>::new);
121 let get_posts = move || -> Vec<PostListItem> {
122 let list = posts();
123 match status_filter().as_str() {
124 "published" => list
125 .into_iter()
126 .filter(|p| p.status == PostStatus::Published)
127 .collect(),
128 "draft" => list
129 .into_iter()
130 .filter(|p| p.status == PostStatus::Draft)
131 .collect(),
132 _ => list,
133 }
134 };
135 let is_searching = move || !search_query().is_empty();
137 let mut submit_search = move || {
139 let q = search_input().trim().to_string();
140 search_query.set(q);
141 current_page.set(1);
142 };
143 rsx! {
144 div { class: "flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4",
146 FilterTabs {
148 items: vec![
149 ("all", "全部"),
150 ("published", "已发布"),
151 ("draft", "草稿"),
152 ],
153 active_value: status_filter(),
154 on_change: move |v: String| {
155 status_filter.set(v);
156 },
157 }
158
159 div { class: "relative flex items-center gap-2",
161 div { class: "relative flex-1 sm:w-72",
162 span { class: "absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-[var(--color-paper-tertiary)]",
163 svg {
164 class: "w-4 h-4",
165 xmlns: "http://www.w3.org/2000/svg",
166 view_box: "0 0 24 24",
167 fill: "none",
168 stroke: "currentColor",
169 stroke_width: "2",
170 stroke_linecap: "round",
171 stroke_linejoin: "round",
172 circle { cx: "11", cy: "11", r: "8" }
173 line { x1: "21", y1: "21", x2: "16.65", y2: "16.65" }
174 }
175 }
176 input {
177 class: "w-full pl-9 pr-8 py-2 text-sm border border-[var(--color-paper-border)]/70 rounded-2xl bg-[var(--color-paper-entry)]/60 text-[var(--color-paper-primary)] placeholder:text-[var(--color-paper-tertiary)] focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-all",
178 r#type: "text",
179 placeholder: "搜索文章标题...",
180 value: "{search_input}",
181 oninput: move |evt: FormEvent| search_input.set(evt.value()),
182 onkeydown: move |e: KeyboardEvent| {
183 if e.key() == Key::Enter {
184 submit_search();
185 }
186 },
187 }
188 if !search_input().is_empty() {
189 button {
190 class: "absolute inset-y-0 right-0 pr-3 flex items-center text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer",
191 onclick: move |_| {
192 search_input.set(String::new());
193 search_query.set(String::new());
194 current_page.set(1);
195 },
196 "×"
197 }
198 }
199 }
200 button {
201 class: "{BTN_PRIMARY} px-4 py-2 text-xs",
202 onclick: move |_| submit_search(),
203 "搜索"
204 }
205 if is_searching() {
206 button {
207 class: "{BTN_OUTLINE} px-3 py-2 text-xs",
208 onclick: move |_| {
209 search_input.set(String::new());
210 search_query.set(String::new());
211 current_page.set(1);
212 },
213 "清除"
214 }
215 }
216 }
217 }
218
219 if error().is_some() {
220 EmptyState {
221 title: "加载失败",
222 description: "获取文章列表时发生错误,请稍后重试。",
223 }
224 } else if loading() && posts().is_empty() {
225 DelayedSkeleton { PostsTableSkeleton {} }
226 } else if get_posts().is_empty() {
227 if is_searching() {
228 EmptyState {
229 title: "未找到匹配的文章",
230 description: "换个标题关键词再试一次。",
231 }
232 } else if status_filter() == "draft" {
233 EmptyState {
234 title: "暂无草稿",
235 description: "当前没有未发布的草稿文章。",
236 }
237 } else {
238 EmptyState {
239 title: "暂无文章",
240 description: "还没有创建任何文章,开始写下你的第一篇文字吧。",
241 action: EmptyStateAction {
242 label: "写文章".to_string(),
243 to: Route::Write {},
244 },
245 }
246 }
247 } else {
248 div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
249 table { class: "w-full text-sm",
250 thead {
251 tr { class: "bg-[var(--color-paper-entry)]/80 border-b border-[var(--color-paper-border)]/70 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)] select-none",
252 th { class: "px-5 py-3.5", "文章标题" }
253 th { class: "px-4 py-3.5 w-24 text-center whitespace-nowrap",
254 "状态"
255 }
256 th { class: "px-4 py-3.5 w-28 whitespace-nowrap hidden md:table-cell",
257 "字数"
258 }
259 th { class: "px-4 py-3.5 w-32 whitespace-nowrap",
260 "发布日期"
261 }
262 th { class: "px-5 py-3.5 w-48 text-right whitespace-nowrap",
263 "操作"
264 }
265 }
266 }
267 tbody {
268 for (idx, post) in get_posts().iter().enumerate() {
269 PostRow {
270 key: "{post.id}",
271 post: post.clone(),
272 deleting: deleting().contains(&post.id),
273 rebuilding: rebuilding().contains(&post.id),
274 stagger_index: idx as u32,
275 on_delete: move |id| {
276 deleting.write().insert(id);
277 spawn(async move {
278 match delete_post(id).await {
279 Ok(CreatePostResponse { success: true, .. }) => {
280 posts.with_mut(|list| list.retain(|p| p.id != id));
281 total.with_mut(|t| *t = t.saturating_sub(1));
282 }
283 Ok(CreatePostResponse { success: false, message: _message, .. }) => {
284 #[cfg(target_arch = "wasm32")]
285 web_sys::window().map(|w| w.alert_with_message(&_message).ok());
286 }
287 Err(_e) => {
288 #[cfg(target_arch = "wasm32")]
289 web_sys::window().map(|w| w.alert_with_message("删除失败").ok());
290 }
291 }
292 deleting.write().remove(&id);
293 });
294 },
295 on_rebuild: move |id| {
296 rebuilding.write().insert(id);
297 spawn(async move {
298 let _ = rebuild_post_content_html(id).await;
299 rebuilding.write().remove(&id);
300 });
301 },
302 }
303 }
304 }
305 }
306 }
307 Pagination {
308 variant: "admin",
309 current_page: current_page(),
310 total: total(),
311 per_page: POSTS_PER_PAGE,
312 unit: "篇",
313 on_prev: {
314 let mut page = current_page;
315 move |_| {
316 page.with_mut(|p| *p = (*p - 1).max(1));
317 }
318 },
319 on_next: {
320 let mut page = current_page;
321 move |_| {
322 page.with_mut(|p| *p += 1);
323 }
324 },
325 on_jump: {
326 let mut page = current_page;
327 move |p: i32| {
328 page.set(p);
329 }
330 },
331 }
332 }
333 }
334}
335
336#[component]
344#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
345fn RebuildCacheBar() -> Element {
346 let mut rebuilding = use_signal(|| false);
347 let mut rebuild_result = use_signal(|| Option::<String>::None);
348
349 let mut do_rebuild = move |rebuild_all: bool| {
352 rebuilding.set(true);
353 rebuild_result.set(None);
354 spawn(async move {
355 match rebuild_content_html(rebuild_all).await {
356 Ok(RebuildResult {
357 rebuilt,
358 failed,
359 errors,
360 }) => {
361 if failed > 0 {
362 let mut msg = format!("已重建 {rebuilt} 篇,失败 {failed} 篇");
363 if let Some(first) = errors.first() {
364 msg.push_str(&format!("\n{first}"));
365 }
366 rebuild_result.set(Some(msg));
367 } else {
368 rebuild_result.set(Some(format!("已重建 {rebuilt} 篇文章")));
369 }
370 }
371 Err(e) => {
372 rebuild_result.set(Some(format!("失败: {e}")));
373 }
374 }
375 rebuilding.set(false);
376 });
377 };
378
379 rsx! {
380 div { class: "relative flex items-center gap-3",
384 div { class: "flex items-center gap-3",
385 Tooltip {
386 tip: "重建 content_html 为空的文章渲染缓存".to_string(),
387 placement: "bottom",
388 button {
389 class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE },
390 disabled: rebuilding(),
391 onclick: move |_| do_rebuild(false),
392 span { class: if rebuilding() { "opacity-40" } else { "" }, "重建内容" }
393 if rebuilding() {
394 span {
395 class: "absolute inset-0 flex items-center justify-center",
396 dangerous_inner_html: SPINNER_SVG,
397 }
398 }
399 }
400 }
401 Tooltip {
402 tip: "重建所有文章的渲染缓存(含已有内容)".to_string(),
403 placement: "bottom",
404 button {
405 class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE },
406 disabled: rebuilding(),
407 onclick: move |_| do_rebuild(true),
408 span { class: if rebuilding() { "opacity-40" } else { "" }, "重建全部" }
409 if rebuilding() {
410 span {
411 class: "absolute inset-0 flex items-center justify-center",
412 dangerous_inner_html: SPINNER_SVG,
413 }
414 }
415 }
416 }
417 }
418 if let Some(msg) = rebuild_result() {
420 div { class: "absolute top-full right-0 mt-1 text-xs text-paper-secondary whitespace-pre-line",
421 "{msg}"
422 }
423 }
424 }
425 }
426}
427
428#[component]
430fn PostRow(
431 post: PostListItem,
432 deleting: bool,
433 rebuilding: bool,
434 stagger_index: u32,
435 on_delete: EventHandler<i32>,
436 on_rebuild: EventHandler<i32>,
437) -> Element {
438 let date_str = post.formatted_date();
439 let title_dest = if post.status == PostStatus::Draft {
441 Route::PostPreview {
442 slug: post.slug.clone(),
443 }
444 } else {
445 Route::PostDetail {
446 slug: post.slug.clone(),
447 }
448 };
449
450 rsx! {
451 tr {
452 class: "animate-row-enter border-b border-[var(--color-paper-border)]/60 last:border-b-0 hover:bg-[var(--color-paper-accent-soft)]/30 transition-colors duration-150",
453 style: "animation-delay: {stagger_index * 35}ms",
454 td { class: "px-5 py-3.5",
456 div { class: "flex flex-col gap-1",
457 Link {
458 class: "font-semibold text-[var(--color-paper-primary)] hover:text-[var(--color-paper-accent)] transition-colors cursor-pointer leading-snug line-clamp-1",
459 to: title_dest,
460 "{post.title}"
461 }
462 div { class: "flex flex-wrap items-center gap-2 text-xs",
463 span { class: "font-mono text-[11px] text-[var(--color-paper-tertiary)]",
464 "/post/{post.slug}"
465 }
466 if !post.tags.is_empty() {
467 for tag in post.tags.iter().take(3) {
468 span {
469 key: "{tag}",
470 class: "inline-flex items-center px-1.5 py-0.2 rounded text-[10px] bg-[var(--color-paper-theme)] text-[var(--color-paper-tertiary)] border border-[var(--color-paper-border)]/40",
471 "#{tag}"
472 }
473 }
474 }
475 }
476 }
477 }
478 td { class: "px-4 py-3.5 text-center whitespace-nowrap",
480 if post.status == PostStatus::Published {
481 span { class: "inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20",
482 span { class: "w-1.5 h-1.5 rounded-full bg-emerald-500" }
483 "公开"
484 }
485 } else {
486 span { class: "inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20",
487 span { class: "w-1.5 h-1.5 rounded-full bg-amber-500" }
488 "草稿"
489 }
490 }
491 }
492 td { class: "px-4 py-3.5 text-[var(--color-paper-tertiary)] font-mono text-xs whitespace-nowrap hidden md:table-cell",
494 "{post.word_count} 字"
495 }
496 td { class: "px-4 py-3.5 text-[var(--color-paper-secondary)] font-mono text-xs whitespace-nowrap",
498 "{date_str}"
499 }
500 td { class: "px-5 py-3.5 text-right whitespace-nowrap",
502 div { class: "flex justify-end items-center gap-2",
503 Link {
505 class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-theme)] transition-colors cursor-pointer",
506 to: Route::WriteEdit { id: post.id },
507 svg {
508 class: "w-3.5 h-3.5",
509 xmlns: "http://www.w3.org/2000/svg",
510 view_box: "0 0 24 24",
511 fill: "none",
512 stroke: "currentColor",
513 stroke_width: "2",
514 stroke_linecap: "round",
515 stroke_linejoin: "round",
516 path { d: "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" }
517 path { d: "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" }
518 }
519 "编辑"
520 }
521 Tooltip {
523 tip: "重新渲染这篇文章的 HTML".to_string(),
524 align: "end",
525 button {
526 class: if rebuilding { "relative inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-paper-accent cursor-not-allowed" } else { "inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-paper-accent hover:bg-[var(--color-paper-theme)] transition-colors cursor-pointer" },
527 disabled: rebuilding,
528 onclick: move |_| on_rebuild.call(post.id),
529 span { class: if rebuilding { "opacity-0" } else { "flex items-center gap-1" },
530 svg {
531 class: "w-3.5 h-3.5",
532 xmlns: "http://www.w3.org/2000/svg",
533 view_box: "0 0 24 24",
534 fill: "none",
535 stroke: "currentColor",
536 stroke_width: "2",
537 stroke_linecap: "round",
538 stroke_linejoin: "round",
539 path { d: "M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" }
540 }
541 "重建"
542 }
543 if rebuilding {
544 span {
545 class: "absolute inset-0 flex items-center justify-center",
546 dangerous_inner_html: SPINNER_SVG,
547 }
548 }
549 }
550 }
551 button {
553 class: if deleting { "relative inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-red-400 cursor-not-allowed" } else { "inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer" },
554 disabled: deleting,
555 onclick: move |_| on_delete.call(post.id),
556 span { class: if deleting { "opacity-0" } else { "flex items-center gap-1" },
557 svg {
558 class: "w-3.5 h-3.5",
559 xmlns: "http://www.w3.org/2000/svg",
560 view_box: "0 0 24 24",
561 fill: "none",
562 stroke: "currentColor",
563 stroke_width: "2",
564 stroke_linecap: "round",
565 stroke_linejoin: "round",
566 polyline { points: "3 6 5 6 21 6" }
567 path { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }
568 }
569 "删除"
570 }
571 if deleting {
572 span {
573 class: "absolute inset-0 flex items-center justify-center",
574 dangerous_inner_html: SPINNER_SVG,
575 }
576 }
577 }
578 }
579 }
580 }
581 }
582}