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#[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#[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#[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 let mut status_filter = use_signal(|| saved.status.clone());
109 let mut search_input = use_signal(|| saved.search_input.clone());
111 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 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 let mut deleting = use_signal(std::collections::HashSet::<i32>::new);
154 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 let is_searching = move || !search_query().is_empty();
173 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 div { class: "flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4",
185 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 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#[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 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 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 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#[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 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 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 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 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 td { class: "px-4 py-3.5 text-[var(--color-paper-secondary)] font-mono text-xs whitespace-nowrap",
537 "{date_str}"
538 }
539 td { class: "px-5 py-3.5 text-right whitespace-nowrap",
541 div { class: "flex justify-end items-center gap-2",
542 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 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 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}