From 0398cc6c66bd74c2068c5390f1ff36c05cd383b6 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 25 Jun 2026 18:07:22 +0800 Subject: [PATCH] =?UTF-8?q?perf(render):=20=E5=88=97=E8=A1=A8=E5=8A=A0?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=20key=20+=20release=20=E5=90=AF=E7=94=A8=20p?= =?UTF-8?q?anic=3Dabort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依据 Dioxus 0.7 optimizing 指南做两项改进: - 为数据驱动列表渲染补全显式 key(13 处,9 个文件): home/archives/tags/search 文章与标签列表、post_card/post_footer 标签、header 桌面/移动导航、admin dashboard/posts/write 列表。 使用各类型稳定唯一字段(post.id / tag / tag.name / item.label / err.id / year_group.year / month_group.month_en)作为 key, 让 Dioxus diff 正确识别元素身份。骨架屏 for _ in 0..N 静态占位 循环与已带 key 的评论树/admin 行不动。 - [profile.release] 加 panic = "abort":WASM 去掉 unwind 元数据减小 体积;server 端错误处理走 Result+?(见 error.rs),不依赖 panic unwind,无副作用。 其余文件为本机格式化(单行折叠/多行展开),一并提交。 验证:dx check 通过、cargo test 405 passed。 --- Cargo.toml | 4 + src/components/admin_layout.rs | 22 +- src/components/comments/form.rs | 54 +- src/components/comments/item.rs | 20 +- src/components/comments/pending_item.rs | 11 +- src/components/comments/section.rs | 12 +- src/components/footer.rs | 4 +- src/components/forms.rs | 4 +- src/components/frontend_layout.rs | 36 +- src/components/header.rs | 13 +- src/components/post/breadcrumbs.rs | 5 +- src/components/post/post_content.rs | 2 +- src/components/post/post_footer.rs | 16 +- src/components/post/post_header.rs | 8 +- src/components/post/post_nav_links.rs | 8 +- src/components/post/post_toc.rs | 9 +- src/components/post_card.rs | 28 +- src/components/skeletons/delayed_skeleton.rs | 5 +- .../skeletons/post_card_skeleton.rs | 3 +- src/components/skeletons/posts_skeleton.rs | 32 +- src/components/ui.rs | 12 +- src/pages/about.rs | 8 +- src/pages/admin/comments.rs | 204 +++--- src/pages/admin/dashboard.rs | 34 +- src/pages/admin/posts.rs | 153 ++--- src/pages/admin/trash.rs | 174 +++--- src/pages/admin/write.rs | 588 +++++++++--------- src/pages/archives.rs | 29 +- src/pages/home.rs | 20 +- src/pages/login.rs | 32 +- src/pages/post_detail.rs | 12 +- src/pages/register.rs | 32 +- src/pages/search.rs | 26 +- src/pages/tags.rs | 24 +- src/router.rs | 3 +- src/theme.rs | 12 +- 36 files changed, 856 insertions(+), 803 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index aa715b7..35feb45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,10 @@ opt-level = 3 lto = "thin" codegen-units = 1 strip = "symbols" +# panic 直接终止:WASM 去掉 unwind 元数据以减小体积;server 端错误处理走 +# Result + ?(见 src/api/error.rs),不依赖 panic unwind,进程级崩溃由 +# systemd/k8s 拉起。这是 Dioxus 官方 optimizing 指南推荐的 WASM 瘦身项。 +panic = "abort" [features] default = ["web", "server"] diff --git a/src/components/admin_layout.rs b/src/components/admin_layout.rs index 0576bf3..5e9364f 100644 --- a/src/components/admin_layout.rs +++ b/src/components/admin_layout.rs @@ -113,10 +113,8 @@ pub fn AdminLayout() -> Element { (true, Some(_)) => { rsx! { div { class: "{root_class}", - Header { nav_items: admin_nav_items, right_content: right_content } - main { class: "{main_class}", - Outlet:: {} - } + Header { nav_items: admin_nav_items, right_content } + main { class: "{main_class}", Outlet:: {} } Footer {} } } @@ -134,13 +132,19 @@ pub fn AdminLayout() -> Element { // 使用与真实布局完全相同的结构包裹内容骨架,避免 checked 变化时的布局闪烁 rsx! { div { class: "{root_class}", - Header { nav_items: admin_nav_items, right_content: right_content } + Header { nav_items: admin_nav_items, right_content } main { class: "{main_class}", div { class: if show_skeleton() { "" } else { "opacity-0" }, - {match route { - Route::Write {} => rsx! { WriteSkeleton {} }, - _ => rsx! { AdminDashboardSkeleton {} }, - }} + { + match route { + Route::Write {} => rsx! { + WriteSkeleton {} + }, + _ => rsx! { + AdminDashboardSkeleton {} + }, + } + } } } Footer {} diff --git a/src/components/comments/form.rs b/src/components/comments/form.rs index ef5a2a8..d83f369 100644 --- a/src/components/comments/form.rs +++ b/src/components/comments/form.rs @@ -160,9 +160,7 @@ pub fn CommentForm( } } - p { class: "text-xs text-paper-tertiary", - "支持 Markdown 语法" - } + p { class: "text-xs text-paper-tertiary", "支持 Markdown 语法" } // 蜜罐字段:对普通用户隐藏,用于拦截简单机器人 textarea { @@ -195,57 +193,54 @@ pub fn CommentForm( return; } - if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() { + if name.trim().is_empty() || email.trim().is_empty() || content.trim().is_empty() + { message.set(Some(("请填写所有必填项".to_string(), "error"))); return; } - submitting.set(true); message.set(None); - spawn(async move { let result = create_comment( - post_id, - parent_id, - name.clone(), - email.clone(), - if url_val.trim().is_empty() { None } else { Some(url_val.clone()) }, - content.clone(), - hp.clone(), - ).await; - + post_id, + parent_id, + name.clone(), + email.clone(), + if url_val.trim().is_empty() { None } else { Some(url_val.clone()) }, + content.clone(), + hp.clone(), + ) + .await; submitting.set(false); - match result { Ok(resp) => { if resp.success { - comment_storage::save_author( - &name, - &email, - &url_val, - ); - + comment_storage::save_author(&name, &email, &url_val); if let Some(comment_id) = resp.comment_id { let avatar_url = resp.avatar_url.unwrap_or_default(); let depth = resp.depth.unwrap_or(0); - let now = chrono::Utc::now().to_rfc3339(); let pending = PendingComment { id: comment_id, parent_id, depth, author_name: name.clone(), - author_url: if url_val.trim().is_empty() { None } else { Some(url_val) }, + author_url: if url_val.trim().is_empty() { + None + } else { + Some(url_val) + }, avatar_url, content_md: content, created_at: now.clone(), stored_at: now, }; - - comment_storage::save_pending_comment(post_id, pending.clone()); + comment_storage::save_pending_comment( + post_id, + pending.clone(), + ); pending_comments.write().push(pending); } - content_md.set(String::new()); message.set(Some((resp.message, "success"))); if parent_id.is_some() { @@ -257,7 +252,10 @@ pub fn CommentForm( } } Err(_) => { - message.set(Some(("提交失败,请稍后重试".to_string(), "error"))); + message + .set( + Some(("提交失败,请稍后重试".to_string(), "error")), + ); } } }); diff --git a/src/components/comments/item.rs b/src/components/comments/item.rs index 29a38ef..167a000 100644 --- a/src/components/comments/item.rs +++ b/src/components/comments/item.rs @@ -49,16 +49,12 @@ pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element { } }, _ => rsx! { - span { class: "font-medium text-paper-primary", - "{comment.author_name}" - } + span { class: "font-medium text-paper-primary", "{comment.author_name}" } }, }; rsx! { - div { - class: "py-4", - style: "margin-left: {indent}px", + div { class: "py-4", style: "margin-left: {indent}px", div { class: "flex gap-3", img { @@ -97,14 +93,22 @@ pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element { active_reply.set(Some(comment.id)); } }, - if is_replying { "取消回复" } else { "回复" } + if is_replying { + "取消回复" + } else { + "回复" + } } } } if is_replying { - CommentForm { post_id, parent_id: Some(comment.id), parent_indent: Some(indent) } + CommentForm { + post_id, + parent_id: Some(comment.id), + parent_indent: Some(indent), + } } } } diff --git a/src/components/comments/pending_item.rs b/src/components/comments/pending_item.rs index d0a27e5..24cfe43 100644 --- a/src/components/comments/pending_item.rs +++ b/src/components/comments/pending_item.rs @@ -44,16 +44,12 @@ pub fn PendingCommentItem(comment: PendingComment, post_id: i32) -> Element { } }, _ => rsx! { - span { class: "font-medium text-paper-primary", - "{comment.author_name}" - } + span { class: "font-medium text-paper-primary", "{comment.author_name}" } }, }; rsx! { - div { - class: "py-4 opacity-70", - style: "margin-left: {indent}px", + div { class: "py-4 opacity-70", style: "margin-left: {indent}px", div { class: "flex gap-3", img { @@ -73,8 +69,7 @@ pub fn PendingCommentItem(comment: PendingComment, post_id: i32) -> Element { title: "{comment.created_at}", "{relative_time}" } - span { - class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", + span { class: "inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", "审核中" } } diff --git a/src/components/comments/section.rs b/src/components/comments/section.rs index 87491ba..f30ec97 100644 --- a/src/components/comments/section.rs +++ b/src/components/comments/section.rs @@ -94,9 +94,7 @@ pub fn CommentSection(post_id: i32) -> Element { let has_any = approved_count > 0 || pending_count > 0; rsx! { div { class: "space-y-8", - h2 { class: "text-xl font-bold text-paper-primary", - "评论区 ({total_count})" - } + h2 { class: "text-xl font-bold text-paper-primary", "评论区 ({total_count})" } CommentForm { post_id, parent_id: None, parent_indent: None } @@ -116,11 +114,11 @@ pub fn CommentSection(post_id: i32) -> Element { } Some(Err(_)) => { rsx! { - div { class: "text-center text-red-500 dark:text-red-400 py-8", - "评论加载失败" - } + div { class: "text-center text-red-500 dark:text-red-400 py-8", "评论加载失败" } } } - None => rsx! { CommentListSkeleton {} }, + None => rsx! { + CommentListSkeleton {} + }, } } diff --git a/src/components/footer.rs b/src/components/footer.rs index 3e3e9c8..bbf8bca 100644 --- a/src/components/footer.rs +++ b/src/components/footer.rs @@ -114,9 +114,7 @@ pub fn Footer() -> Element { view_box: "0 -960 960 960", width: "24px", fill: "currentColor", - path { - d: "m296-224-56-56 240-240 240 240-56 56-184-183-184 183Zm0-240-56-56 240-240 240 240-56 56-184-183-184 183Z", - } + path { d: "m296-224-56-56 240-240 240 240-56 56-184-183-184 183Zm0-240-56-56 240-240 240 240-56 56-184-183-184 183Z" } } } } diff --git a/src/components/forms.rs b/src/components/forms.rs index 5960806..182c02f 100644 --- a/src/components/forms.rs +++ b/src/components/forms.rs @@ -88,8 +88,6 @@ pub fn AlertBox(message: String, variant: &'static str) -> Element { _ => ("bg-paper-code-bg", "text-paper-secondary"), }; rsx! { - div { class: "mb-4 p-3 {bg_class} {text_class} rounded-lg text-center", - "{message}" - } + div { class: "mb-4 p-3 {bg_class} {text_class} rounded-lg text-center", "{message}" } } } diff --git a/src/components/frontend_layout.rs b/src/components/frontend_layout.rs index bb4d7e3..1361347 100644 --- a/src/components/frontend_layout.rs +++ b/src/components/frontend_layout.rs @@ -20,12 +20,24 @@ use crate::theme::ThemeToggle; /// 根据当前前台路由选择对应的骨架屏组件。 fn route_skeleton(route: &Route) -> Element { match route { - Route::Archives {} => rsx! { DelayedSkeleton { ArchiveSkeleton {} } }, - Route::Tags {} | Route::TagDetail { .. } => rsx! { DelayedSkeleton { TagsSkeleton {} } }, - Route::Search {} => rsx! { DelayedSkeleton { SearchSkeleton {} } }, - Route::PostDetail { .. } => rsx! { DelayedSkeleton { PostDetailSkeleton {} } }, - Route::NotFound { .. } => rsx! { div { class: "py-20 md:py-28" } }, - _ => rsx! { DelayedSkeleton { HomeSkeleton {} } }, + Route::Archives {} => rsx! { + DelayedSkeleton { ArchiveSkeleton {} } + }, + Route::Tags {} | Route::TagDetail { .. } => rsx! { + DelayedSkeleton { TagsSkeleton {} } + }, + Route::Search {} => rsx! { + DelayedSkeleton { SearchSkeleton {} } + }, + Route::PostDetail { .. } => rsx! { + DelayedSkeleton { PostDetailSkeleton {} } + }, + Route::NotFound { .. } => rsx! { + div { class: "py-20 md:py-28" } + }, + _ => rsx! { + DelayedSkeleton { HomeSkeleton {} } + }, } } @@ -40,12 +52,14 @@ pub fn FrontendLayout() -> Element { rsx! { div { class: "min-h-screen flex flex-col bg-paper-theme", - Header { nav_items, right_content: rsx! { ThemeToggle {} } } + Header { + nav_items, + right_content: rsx! { + ThemeToggle {} + }, + } main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6", - SuspenseBoundary { - fallback: move |_| route_skeleton(&route), - Outlet:: {} - } + SuspenseBoundary { fallback: move |_| route_skeleton(&route), Outlet:: {} } } Footer {} } diff --git a/src/components/header.rs b/src/components/header.rs index 65f10b4..4065d34 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -47,6 +47,7 @@ pub fn Header(nav_items: Vec, right_content: Element) -> Element ul { class: "hidden md:flex items-center gap-1", for item in nav_items.iter().cloned() { NavItem { + key: "{item.label}", route: item.route, label: item.label, is_active: item.is_active, @@ -75,7 +76,7 @@ pub fn Header(nav_items: Vec, right_content: Element) -> Element stroke_linecap: "round", stroke_linejoin: "round", stroke_width: "2", - d: "M6 18L18 6M6 6l12 12" + d: "M6 18L18 6M6 6l12 12", } } } else { @@ -89,7 +90,7 @@ pub fn Header(nav_items: Vec, right_content: Element) -> Element stroke_linecap: "round", stroke_linejoin: "round", stroke_width: "2", - d: "M4 6h16M4 12h16M4 18h16" + d: "M4 6h16M4 12h16M4 18h16", } } } @@ -104,7 +105,7 @@ pub fn Header(nav_items: Vec, right_content: Element) -> Element class: "md:hidden border-t border-paper-border bg-paper-theme/95 backdrop-blur-sm", ul { class: "py-2 px-6 space-y-1", for item in nav_items.iter().cloned() { - li { + li { key: "{item.label}", MobileNavItem { route: item.route, label: item.label, @@ -135,11 +136,7 @@ fn NavItem(route: Route, label: &'static str, is_active: bool) -> Element { rsx! { li { - Link { - class: "{class_str}", - to: route, - "{label}" - } + Link { class: "{class_str}", to: route, "{label}" } } } } diff --git a/src/components/post/breadcrumbs.rs b/src/components/post/breadcrumbs.rs index b04a4ea..74448cd 100644 --- a/src/components/post/breadcrumbs.rs +++ b/src/components/post/breadcrumbs.rs @@ -20,10 +20,7 @@ pub fn Breadcrumbs(title: String) -> Element { class: "breadcrumbs", role: "navigation", aria_label: "Breadcrumb", - Link { - to: Route::Home {}, - "Home" - } + Link { to: Route::Home {}, "Home" } svg { xmlns: "http://www.w3.org/2000/svg", view_box: "0 0 24 24", diff --git a/src/components/post/post_content.rs b/src/components/post/post_content.rs index 032640a..157f9e4 100644 --- a/src/components/post/post_content.rs +++ b/src/components/post/post_content.rs @@ -31,7 +31,7 @@ pub fn PostContent(content_html: String) -> Element { rsx! { div { class: "post-content md-content", - dangerous_inner_html: "{content_html}" + dangerous_inner_html: "{content_html}", } } } diff --git a/src/components/post/post_footer.rs b/src/components/post/post_footer.rs index f9f92ca..b247ead 100644 --- a/src/components/post/post_footer.rs +++ b/src/components/post/post_footer.rs @@ -27,9 +27,11 @@ pub fn PostFooter(post: Post) -> Element { if !tags.is_empty() { ul { class: "post-tags", for tag in tags.into_iter() { - li { + li { key: "{tag}", Link { - to: Route::TagDetail { tag: tag.clone() }, + to: Route::TagDetail { + tag: tag.clone(), + }, "{tag}" } } @@ -38,17 +40,11 @@ pub fn PostFooter(post: Post) -> Element { } if post.prev_post.is_some() || post.next_post.is_some() { - PostNavLinks { - prev: post.prev_post, - next: post.next_post - } + PostNavLinks { prev: post.prev_post, next: post.next_post } } div { class: "back-to-home", - Link { - to: Route::Home {}, - "← 返回首页" - } + Link { to: Route::Home {}, "← 返回首页" } } } } diff --git a/src/components/post/post_header.rs b/src/components/post/post_header.rs index 4d342da..29b0a4d 100644 --- a/src/components/post/post_header.rs +++ b/src/components/post/post_header.rs @@ -27,17 +27,13 @@ pub fn PostHeader(post: Post) -> Element { h1 { class: "post-title", "{post.title}" if post.status == PostStatus::Draft { - span { - class: "entry-hint", - title: "Draft", + span { class: "entry-hint", title: "Draft", svg { xmlns: "http://www.w3.org/2000/svg", height: "35", view_box: "0 -960 960 960", fill: "currentColor", - path { - d: "M160-410v-60h300v60H160Zm0-165v-60h470v60H160Zm0-165v-60h470v60H160Zm360 580v-123l221-220q9-9 20-13t22-4q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22q0 11-4.5 22.5T862.09-380L643-160H520Zm300-263-37-37 37 37ZM580-220h38l121-122-18-19-19-18-122 121v38Zm141-141-19-18 37 37-18-19Z" - } + path { d: "M160-410v-60h300v60H160Zm0-165v-60h470v60H160Zm0-165v-60h470v60H160Zm360 580v-123l221-220q9-9 20-13t22-4q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22q0 11-4.5 22.5T862.09-380L643-160H520Zm300-263-37-37 37 37ZM580-220h38l121-122-18-19-19-18-122 121v38Zm141-141-19-18 37 37-18-19Z" } } } } diff --git a/src/components/post/post_nav_links.rs b/src/components/post/post_nav_links.rs index eb70824..ce84a85 100644 --- a/src/components/post/post_nav_links.rs +++ b/src/components/post/post_nav_links.rs @@ -22,7 +22,9 @@ pub fn PostNavLinks(prev: Option, next: Option) -> Element { if let Some(prev_post) = prev { Link { class: "prev", - to: Route::PostDetail { slug: prev_post.slug.clone() }, + to: Route::PostDetail { + slug: prev_post.slug.clone(), + }, onclick: move |_evt: dioxus::events::MouseEvent| {}, span { class: "title", "« Prev" } span { class: "post-title-nav", "{prev_post.title}" } @@ -34,7 +36,9 @@ pub fn PostNavLinks(prev: Option, next: Option) -> Element { if let Some(next_post) = next { Link { class: "next", - to: Route::PostDetail { slug: next_post.slug.clone() }, + to: Route::PostDetail { + slug: next_post.slug.clone(), + }, onclick: move |_evt: dioxus::events::MouseEvent| {}, span { class: "title", "Next »" } span { class: "post-title-nav", "{next_post.title}" } diff --git a/src/components/post/post_toc.rs b/src/components/post/post_toc.rs index 8db37e0..34bbff7 100644 --- a/src/components/post/post_toc.rs +++ b/src/components/post/post_toc.rs @@ -14,15 +14,10 @@ use dioxus::prelude::*; pub fn PostToc(toc_html: String) -> Element { rsx! { details { class: "toc", - summary { - accesskey: "c", - title: "(Alt + C)", + summary { accesskey: "c", title: "(Alt + C)", span { class: "title", "Table of Contents" } } - div { - class: "inner", - dangerous_inner_html: "{toc_html}" - } + div { class: "inner", dangerous_inner_html: "{toc_html}" } } } } diff --git a/src/components/post_card.rs b/src/components/post_card.rs index 1e68eef..8b44a20 100644 --- a/src/components/post_card.rs +++ b/src/components/post_card.rs @@ -32,13 +32,10 @@ pub fn PostCard(post: PostListItem) -> Element { let has_cover = post.cover_image.is_some(); rsx! { - article { - class: "group relative mb-6 p-6 bg-paper-entry rounded-lg border border-paper-border hover:-translate-y-0.5 hover:border-paper-accent/50 hover:shadow-sm transition-all duration-200", + article { class: "group relative mb-6 p-6 bg-paper-entry rounded-lg border border-paper-border hover:-translate-y-0.5 hover:border-paper-accent/50 hover:shadow-sm transition-all duration-200", if has_cover { - div { - class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg", - div { - class: "blur-img post-card-cover-blur", + div { class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg", + div { class: "blur-img post-card-cover-blur", img { class: "blur-img-placeholder", src: "{cover_src}?w=20", @@ -53,25 +50,24 @@ pub fn PostCard(post: PostListItem) -> Element { } } } - h2 { - class: "text-2xl font-bold leading-tight text-paper-primary group-hover:text-paper-accent transition-colors duration-200", + h2 { class: "text-2xl font-bold leading-tight text-paper-primary group-hover:text-paper-accent transition-colors duration-200", "{post.title}" } - div { - class: "mt-2 text-sm text-paper-secondary leading-relaxed line-clamp-2", + div { class: "mt-2 text-sm text-paper-secondary leading-relaxed line-clamp-2", "{post.summary.as_deref().unwrap_or_default()}" } - div { - class: "mt-3 flex items-center gap-3 text-[13px] text-paper-secondary", + div { class: "mt-3 flex items-center gap-3 text-[13px] text-paper-secondary", span { "{date_str}" } if !post.tags.is_empty() { span { "·" } for tag in post.tags.clone().into_iter() { - span { + span { key: "{tag}", // 标签叠在覆盖链接之上,点击进入标签详情页而非文章详情。 Link { class: "relative z-10 hover:text-paper-accent transition-colors duration-200", - to: Route::TagDetail { tag: tag.clone() }, + to: Route::TagDetail { + tag: tag.clone(), + }, onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(), "{tag}" } @@ -86,7 +82,9 @@ pub fn PostCard(post: PostListItem) -> Element { Link { class: "absolute inset-0 z-[2]", aria_label: "post link to {post.title}", - to: Route::PostDetail { slug: post_slug }, + to: Route::PostDetail { + slug: post_slug, + }, } } } diff --git a/src/components/skeletons/delayed_skeleton.rs b/src/components/skeletons/delayed_skeleton.rs index 4d42958..775d45c 100644 --- a/src/components/skeletons/delayed_skeleton.rs +++ b/src/components/skeletons/delayed_skeleton.rs @@ -31,9 +31,6 @@ pub fn DelayedSkeleton(children: Element) -> Element { }); rsx! { - div { - class: if pulsing() { "animate-pulse" } else { "" }, - {children} - } + div { class: if pulsing() { "animate-pulse" } else { "" }, {children} } } } diff --git a/src/components/skeletons/post_card_skeleton.rs b/src/components/skeletons/post_card_skeleton.rs index 4438581..2ec53fa 100644 --- a/src/components/skeletons/post_card_skeleton.rs +++ b/src/components/skeletons/post_card_skeleton.rs @@ -12,8 +12,7 @@ use crate::components::skeletons::atoms::SkeletonBox; #[component] pub fn PostCardSkeleton() -> Element { rsx! { - article { - class: "mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333]", + article { class: "mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333]", // 标题占位 (模拟 h2 text-2xl font-bold) SkeletonBox { class: "h-7 w-3/4 rounded mb-3" } // 摘要第一行 diff --git a/src/components/skeletons/posts_skeleton.rs b/src/components/skeletons/posts_skeleton.rs index 75f8028..38ae5b8 100644 --- a/src/components/skeletons/posts_skeleton.rs +++ b/src/components/skeletons/posts_skeleton.rs @@ -17,19 +17,35 @@ pub fn PostsSkeleton() -> Element { table { class: "w-full text-sm", thead { tr { class: "border-b border-paper-border", - th { class: "px-4 py-3", SkeletonBox { class: "h-3 w-10" } } - th { class: "px-4 py-3 w-24", SkeletonBox { class: "h-3 w-10 mx-auto" } } - th { class: "px-4 py-3 w-32", SkeletonBox { class: "h-3 w-10" } } - th { class: "px-4 py-3 w-24", SkeletonBox { class: "h-3 w-10 ml-auto" } } + th { class: "px-4 py-3", + SkeletonBox { class: "h-3 w-10" } + } + th { class: "px-4 py-3 w-24", + SkeletonBox { class: "h-3 w-10 mx-auto" } + } + th { class: "px-4 py-3 w-32", + SkeletonBox { class: "h-3 w-10" } + } + th { class: "px-4 py-3 w-24", + SkeletonBox { class: "h-3 w-10 ml-auto" } + } } } tbody { for _ in 0..10 { tr { class: "border-b border-paper-border last:border-0", - td { class: "px-4 py-3", SkeletonBox { class: "h-4 w-1/3" } } - td { class: "px-4 py-3", SkeletonBox { class: "h-5 w-14 mx-auto rounded" } } - td { class: "px-4 py-3", SkeletonBox { class: "h-4 w-20" } } - td { class: "px-4 py-3", SkeletonBox { class: "h-4 w-12 ml-auto" } } + td { class: "px-4 py-3", + SkeletonBox { class: "h-4 w-1/3" } + } + td { class: "px-4 py-3", + SkeletonBox { class: "h-5 w-14 mx-auto rounded" } + } + td { class: "px-4 py-3", + SkeletonBox { class: "h-4 w-20" } + } + td { class: "px-4 py-3", + SkeletonBox { class: "h-4 w-12 ml-auto" } + } } } } diff --git a/src/components/ui.rs b/src/components/ui.rs index a7ad322..674cb05 100644 --- a/src/components/ui.rs +++ b/src/components/ui.rs @@ -119,9 +119,7 @@ pub fn Pagination( rsx! { nav { class: if is_admin { "flex mt-6 justify-between" } else { "flex mt-10 mb-6 justify-between" }, if has_prev { - Link { - class: "{link_class}", - to: prev_route, + Link { class: "{link_class}", to: prev_route, span { class: "mr-1", "«" } "上一页" } @@ -140,9 +138,7 @@ pub fn Pagination( } if has_next { - Link { - class: "{link_class} {link_extra_next}", - to: next_route, + Link { class: "{link_class} {link_extra_next}", to: next_route, "下一页" span { class: "ml-1", "»" } } @@ -168,9 +164,7 @@ pub fn Pagination( #[component] pub fn StatusBadge(color_class: &'static str, label: String) -> Element { rsx! { - span { class: "{BADGE_BASE} {color_class}", - "{label}" - } + span { class: "{BADGE_BASE} {color_class}", "{label}" } } } diff --git a/src/pages/about.rs b/src/pages/about.rs index e5c6e5c..5be9f2f 100644 --- a/src/pages/about.rs +++ b/src/pages/about.rs @@ -14,13 +14,13 @@ use dioxus::prelude::*; pub fn About() -> Element { rsx! { header { class: "page-header mb-6", - h1 { class: "text-4xl font-bold text-paper-primary tracking-tight", - "关于" - } + h1 { class: "text-4xl font-bold text-paper-primary tracking-tight", "关于" } } article { class: "prose dark:prose-invert max-w-none text-paper-content leading-relaxed", p { "Yggdrasil 是一个以文字为主的简约博客系统。" } - p { "它使用 Rust + Dioxus 构建,采用 PostgreSQL 作为数据库,支持 Markdown 写作、标签管理和暗色模式。" } + p { + "它使用 Rust + Dioxus 构建,采用 PostgreSQL 作为数据库,支持 Markdown 写作、标签管理和暗色模式。" + } h2 { class: "text-xl font-bold text-paper-primary mt-8 mb-4", "技术栈" } ul { class: "list-disc pl-5 space-y-1", li { "Rust + Dioxus 0.7 (全栈 Web 框架)" } diff --git a/src/pages/admin/comments.rs b/src/pages/admin/comments.rs index b0bed14..63063ad 100644 --- a/src/pages/admin/comments.rs +++ b/src/pages/admin/comments.rs @@ -30,7 +30,9 @@ const COMMENTS_PER_PAGE: i32 = 20; /// 评论管理入口组件,默认展示第 1 页。 #[component] pub fn AdminComments() -> Element { - rsx! { AdminCommentsPage { page: 1 } } + rsx! { + AdminCommentsPage { page: 1 } + } } /// 评论管理分页组件。 @@ -124,18 +126,18 @@ pub fn AdminCommentsPage(page: i32) -> Element { rsx! { div { class: "space-y-6", - h1 { class: "text-2xl font-bold text-paper-primary", - "评论管理" - } + h1 { class: "text-2xl font-bold text-paper-primary", "评论管理" } div { class: "flex gap-1 border-b border-paper-border", - for (status, label) in [("", "全部"), ("pending", "待审核"), ("approved", "已通过"), ("spam", "垃圾箱")] { + for (status, label) in [ + ("", "全部"), + ("pending", "待审核"), + ("approved", "已通过"), + ("spam", "垃圾箱"), + ] + { button { - class: if active_filter() == status { - "px-4 py-2 text-sm font-medium border-b-2 border-paper-accent text-paper-primary" - } else { - "px-4 py-2 text-sm font-medium text-paper-secondary hover:text-paper-primary transition-colors" - }, + class: if active_filter() == status { "px-4 py-2 text-sm font-medium border-b-2 border-paper-accent text-paper-primary" } else { "px-4 py-2 text-sm font-medium text-paper-secondary hover:text-paper-primary transition-colors" }, onclick: move |_| active_filter.set(status.to_string()), "{label}" } @@ -143,65 +145,77 @@ pub fn AdminCommentsPage(page: i32) -> Element { } if !selected_ids().is_empty() { - { rsx! { - div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg", - span { class: "text-sm text-paper-secondary", - "已选择 {selected_ids().len()} 条" - } - button { - class: "{BTN_SOLID_GREEN}", - onclick: move |_| { - let ids: Vec = selected_ids().iter().copied().collect(); - let ids_for_api = ids.clone(); - spawn(async move { - let _ = batch_update_comment_status(ids_for_api, "approved".to_string()).await; - }); - for id in &ids { set_comment_status(*id, CommentStatus::Approved); } - selected_ids.set(HashSet::new()); - }, - "批量通过" - } - button { - class: "{BTN_SOLID_AMBER}", - onclick: move |_| { - let ids: Vec = selected_ids().iter().copied().collect(); - let ids_for_api = ids.clone(); - spawn(async move { - let _ = batch_update_comment_status(ids_for_api, "spam".to_string()).await; - }); - for id in &ids { set_comment_status(*id, CommentStatus::Spam); } - selected_ids.set(HashSet::new()); - }, - "批量垃圾" - } - button { - class: "{BTN_SOLID_RED}", - onclick: move |_| { - #[cfg(target_arch = "wasm32")] - { - if web_sys::window() - .and_then(|w| w.confirm_with_message("确定要删除这些评论吗?").ok()) - .unwrap_or(false) - { - let ids: Vec = selected_ids().iter().copied().collect(); - let ids_for_api = ids.clone(); - spawn(async move { - let _ = batch_update_comment_status(ids_for_api, "trash".to_string()).await; - }); - for id in &ids { remove_comment(*id); } - selected_ids.set(HashSet::new()); + { + rsx! { + div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg", + span { class: "text-sm text-paper-secondary", "已选择 {selected_ids().len()} 条" } + button { + class: "{BTN_SOLID_GREEN}", + onclick: move |_| { + let ids: Vec = selected_ids().iter().copied().collect(); + let ids_for_api = ids.clone(); + spawn(async move { + let _ = batch_update_comment_status(ids_for_api, "approved".to_string()) + .await; + }); + for id in &ids { + set_comment_status(*id, CommentStatus::Approved); } - } - }, - "批量删除" + selected_ids.set(HashSet::new()); + }, + "批量通过" + } + button { + class: "{BTN_SOLID_AMBER}", + onclick: move |_| { + let ids: Vec = selected_ids().iter().copied().collect(); + let ids_for_api = ids.clone(); + spawn(async move { + let _ = batch_update_comment_status(ids_for_api, "spam".to_string()).await; + }); + for id in &ids { + set_comment_status(*id, CommentStatus::Spam); + } + selected_ids.set(HashSet::new()); + }, + "批量垃圾" + } + button { + class: "{BTN_SOLID_RED}", + onclick: move |_| { + #[cfg(target_arch = "wasm32")] + { + if web_sys::window() + .and_then(|w| { + w.confirm_with_message("确定要删除这些评论吗?").ok() + }) + .unwrap_or(false) + { + let ids: Vec = selected_ids().iter().copied().collect(); + let ids_for_api = ids.clone(); + spawn(async move { + let _ = batch_update_comment_status(ids_for_api, "trash".to_string()) + .await; + }); + for id in &ids { + remove_comment(*id); + } + selected_ids.set(HashSet::new()); + } + } + }, + "批量删除" + } } } - } } + } } { if error().is_some() { - rsx! { EmptyState { message: "加载失败", variant: "error" } } + rsx! { + EmptyState { message: "加载失败", variant: "error" } + } } else if loading() && comments().is_empty() { rsx! { DelayedSkeleton { @@ -218,7 +232,9 @@ pub fn AdminCommentsPage(page: i32) -> Element { } } } else if comments().is_empty() { - rsx! { EmptyState { message: "暂无评论", variant: "default" } } + rsx! { + EmptyState { message: "暂无评论", variant: "default" } + } } else { let list = comments(); let all_selected = list.iter().all(|c| selected_ids().contains(&c.id)); @@ -238,13 +254,17 @@ pub fn AdminCommentsPage(page: i32) -> Element { move |_| { let mut s = selected_ids(); if all_selected { - for id in &all_ids { s.remove(id); } + for id in &all_ids { + s.remove(id); + } } else { - for id in &all_ids { s.insert(*id); } + for id in &all_ids { + s.insert(*id); + } } selected_ids.set(s); } - } + }, } } th { class: "px-4 py-3 font-medium", "作者" } @@ -265,7 +285,11 @@ pub fn AdminCommentsPage(page: i32) -> Element { let id = comment.id; move |checked: bool| { let mut s = selected_ids(); - if checked { s.insert(id); } else { s.remove(&id); } + if checked { + s.insert(id); + } + s.remove(&id); + } selected_ids.set(s); } }, @@ -293,7 +317,9 @@ pub fn AdminCommentsPage(page: i32) -> Element { #[cfg(target_arch = "wasm32")] { if web_sys::window() - .and_then(|w| w.confirm_with_message("确定要删除这条评论吗?").ok()) + .and_then(|w| { + w.confirm_with_message("确定要删除这条评论吗?").ok() + }) .unwrap_or(false) { spawn(async move { @@ -315,12 +341,12 @@ pub fn AdminCommentsPage(page: i32) -> Element { current_page, total: total(), per_page: COMMENTS_PER_PAGE, - prev_route: if current_page - 1 <= 1 { - Route::AdminComments {} - } else { - Route::AdminCommentsPage { page: current_page - 1 } + prev_route: if current_page - 1 <= 1 { Route::AdminComments {} } else { Route::AdminCommentsPage { + page: current_page - 1, + } }, + next_route: Route::AdminCommentsPage { + page: current_page + 1, }, - next_route: Route::AdminCommentsPage { page: current_page + 1 }, unit: "条", } } @@ -377,21 +403,19 @@ fn CommentRow( div { class: "text-sm font-medium text-paper-primary truncate", "{comment.author_name}" } - div { class: "text-xs text-paper-secondary truncate", - "{comment.author_email}" - } + div { class: "text-xs text-paper-secondary truncate", "{comment.author_email}" } } } } td { class: "px-4 py-3 max-w-xs", - p { class: "text-sm text-paper-secondary truncate", - "{preview}" - } + p { class: "text-sm text-paper-secondary truncate", "{preview}" } } td { class: "px-4 py-3", Link { class: "text-sm text-paper-primary hover:text-paper-accent transition-colors", - to: Route::PostDetail { slug: comment.post_slug.clone() }, + to: Route::PostDetail { + slug: comment.post_slug.clone(), + }, "{comment.post_title}" } } @@ -399,17 +423,23 @@ fn CommentRow( StatusBadge { // badge_class 是 &'static str 字面量匹配,转为静态生命周期。 color_class: match &comment.status { - CommentStatus::Pending => "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", - CommentStatus::Approved => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", - CommentStatus::Spam => "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", - CommentStatus::Trash => "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400", + CommentStatus::Pending => { + "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" + } + CommentStatus::Approved => { + "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + } + CommentStatus::Spam => { + "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" + } + CommentStatus::Trash => { + "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400" + } }, label: status_label, } } - td { class: "px-4 py-3 text-sm text-paper-secondary", - "{date_str}" - } + td { class: "px-4 py-3 text-sm text-paper-secondary", "{date_str}" } td { class: "px-4 py-3 text-right", div { class: "flex justify-end gap-2", if !matches!(comment.status, CommentStatus::Approved) { diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs index 63af261..4162a7f 100644 --- a/src/pages/admin/dashboard.rs +++ b/src/pages/admin/dashboard.rs @@ -87,12 +87,8 @@ pub fn Admin() -> Element { match pending_count() { Some(count) => { rsx! { - div { class: "text-3xl font-bold text-amber-600 dark:text-amber-400", - "{count}" - } - div { class: "text-sm text-paper-secondary mt-2", - "待审核评论" - } + div { class: "text-3xl font-bold text-amber-600 dark:text-amber-400", "{count}" } + div { class: "text-sm text-paper-secondary mt-2", "待审核评论" } } } None => { @@ -118,15 +114,13 @@ pub fn Admin() -> Element { } div { class: "mb-8", - h2 { class: "text-xl font-bold text-paper-primary mb-4", - "最近文章" - } + h2 { class: "text-xl font-bold text-paper-primary mb-4", "最近文章" } match recent_posts() { Some(posts) => { rsx! { div { class: "space-y-0", for post in posts.iter().take(5) { - RecentPostItem { post: post.clone() } + RecentPostItem { key: "{post.id}", post: post.clone() } } } } @@ -154,12 +148,8 @@ pub fn Admin() -> Element { fn StatCard(value: String, label: String) -> Element { rsx! { div { class: "{ADMIN_CARD_CLASS} p-6 text-center", - div { class: "text-3xl font-bold text-paper-primary", - "{value}" - } - div { class: "text-sm text-paper-secondary mt-2", - "{label}" - } + div { class: "text-3xl font-bold text-paper-primary", "{value}" } + div { class: "text-sm text-paper-secondary mt-2", "{label}" } } } } @@ -174,16 +164,10 @@ fn RecentPostItem(post: PostListItem) -> Element { rsx! { div { class: "flex justify-between items-center py-3 border-b border-paper-border", div { class: "flex items-center gap-3", - span { class: "text-paper-primary", - "{post.title}" - } - span { class: "text-xs {status_class}", - "{status_label}" - } - } - span { class: "text-sm text-paper-secondary", - "{date_str}" + span { class: "text-paper-primary", "{post.title}" } + span { class: "text-xs {status_class}", "{status_label}" } } + span { class: "text-sm text-paper-secondary", "{date_str}" } } } } diff --git a/src/pages/admin/posts.rs b/src/pages/admin/posts.rs index ea4eea6..5a824ea 100644 --- a/src/pages/admin/posts.rs +++ b/src/pages/admin/posts.rs @@ -24,7 +24,9 @@ const POSTS_PER_PAGE: i32 = 20; /// 文章管理入口组件,默认展示第 1 页。 #[component] pub fn Posts() -> Element { - rsx! { PostsPage { page: 1 } } + rsx! { + PostsPage { page: 1 } + } } /// 文章管理分页组件。 @@ -101,20 +103,18 @@ pub fn PostsPage(page: i32) -> Element { rsx! { div { class: "space-y-6", div { class: "flex items-center justify-between", - h1 { class: "text-2xl font-bold text-paper-primary", - "文章管理" - } + h1 { class: "text-2xl font-bold text-paper-primary", "文章管理" } div { class: "flex items-center gap-3", div { class: "group relative", button { - class: if rebuilding() { - "px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" - } else { - "px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all" - }, + class: if rebuilding() { "px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { "px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all" }, disabled: rebuilding(), onclick: move |_| do_rebuild(false), - if rebuilding() { "重建中..." } else { "重建内容" } + if rebuilding() { + "重建中..." + } else { + "重建内容" + } } div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50", "重建 content_html 为空的文章渲染缓存" @@ -122,14 +122,14 @@ pub fn PostsPage(page: i32) -> Element { } div { class: "group relative", button { - class: if rebuilding() { - "px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" - } else { - "px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all" - }, + class: if rebuilding() { "px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { "px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all" }, disabled: rebuilding(), onclick: move |_| do_rebuild(true), - if rebuilding() { "重建中..." } else { "重建全部" } + if rebuilding() { + "重建中..." + } else { + "重建全部" + } } div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50", "重建所有文章的渲染缓存(含已有内容)" @@ -144,9 +144,7 @@ pub fn PostsPage(page: i32) -> Element { } if let Some(msg) = rebuild_result() { - div { class: "text-sm text-paper-secondary px-1", - "{msg}" - } + div { class: "text-sm text-paper-secondary px-1", "{msg}" } } if loading() && posts().is_empty() { @@ -155,59 +153,64 @@ pub fn PostsPage(page: i32) -> Element { EmptyState { message: "暂无文章", variant: "default" } } else { div { class: "{ADMIN_TABLE_CLASS}", - table { class: "w-full text-sm", - thead { - tr { class: "border-b border-paper-border text-left text-paper-secondary", - th { class: "px-4 py-3 font-medium", "标题" } - th { class: "px-4 py-3 font-medium w-24 text-center", "状态" } - th { class: "px-4 py-3 font-medium w-32", "日期" } - th { class: "px-4 py-3 font-medium w-24 text-right", "操作" } + table { class: "w-full text-sm", + thead { + tr { class: "border-b border-paper-border text-left text-paper-secondary", + th { class: "px-4 py-3 font-medium", "标题" } + th { class: "px-4 py-3 font-medium w-24 text-center", + "状态" + } + th { class: "px-4 py-3 font-medium w-32", "日期" } + th { class: "px-4 py-3 font-medium w-24 text-right", + "操作" } } - tbody { - for post in get_posts().iter() { - PostRow { - post: post.clone(), - deleting: deleting() == Some(post.id), - // 删除文章:先乐观更新本地列表,再调用 server function,失败时弹出浏览器提示。 - on_delete: move |id| { - deleting.set(Some(id)); - let id_for_api = id; - posts.with_mut(|list| list.retain(|p| p.id != id)); - total.with_mut(|t| *t = t.saturating_sub(1)); - spawn(async move { - match delete_post(id_for_api).await { - Ok(CreatePostResponse { success: false, message: _message, .. }) => { - #[cfg(target_arch = "wasm32")] - web_sys::window().map(|w| w.alert_with_message(&_message).ok()); - } - Err(_e) => { - #[cfg(target_arch = "wasm32")] - web_sys::window().map(|w| w.alert_with_message("删除失败").ok()); - } - _ => {} + } + tbody { + for post in get_posts().iter() { + PostRow { + key: "{post.id}", + post: post.clone(), + deleting: deleting() == Some(post.id), + // 删除文章:先乐观更新本地列表,再调用 server function,失败时弹出浏览器提示。 + on_delete: move |id| { + deleting.set(Some(id)); + let id_for_api = id; + posts.with_mut(|list| list.retain(|p| p.id != id)); + total.with_mut(|t| *t = t.saturating_sub(1)); + spawn(async move { + match delete_post(id_for_api).await { + Ok(CreatePostResponse { success: false, message: _message, .. }) => { + #[cfg(target_arch = "wasm32")] + web_sys::window().map(|w| w.alert_with_message(&_message).ok()); } - deleting.set(None); - }); - } - } + Err(_e) => { + #[cfg(target_arch = "wasm32")] + web_sys::window().map(|w| w.alert_with_message("删除失败").ok()); + } + _ => {} + } + deleting.set(None); + }); + }, } } } } - Pagination { - variant: "admin", - current_page, - total: total(), - per_page: POSTS_PER_PAGE, - prev_route: if current_page - 1 <= 1 { - Route::Posts {} - } else { - Route::PostsPage { page: current_page - 1 } - }, - next_route: Route::PostsPage { page: current_page + 1 }, - unit: "篇", - } + } + Pagination { + variant: "admin", + current_page, + total: total(), + per_page: POSTS_PER_PAGE, + prev_route: if current_page - 1 <= 1 { Route::Posts {} } else { Route::PostsPage { + page: current_page - 1, + } }, + next_route: Route::PostsPage { + page: current_page + 1, + }, + unit: "篇", + } } } } @@ -223,7 +226,9 @@ fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler) -> td { class: "px-4 py-3", Link { class: "text-paper-primary hover:text-paper-accent transition-colors cursor-pointer", - to: Route::PostDetail { slug: post.slug.clone() }, + to: Route::PostDetail { + slug: post.slug.clone(), + }, "{post.title}" } } @@ -233,9 +238,7 @@ fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler) -> label: post.status_label().to_string(), } } - td { class: "px-4 py-3 text-paper-secondary", - "{date_str}" - } + td { class: "px-4 py-3 text-paper-secondary", "{date_str}" } td { class: "px-4 py-3 text-right", div { class: "flex justify-end gap-3", Link { @@ -244,14 +247,14 @@ fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler) -> "编辑" } button { - class: if deleting { - "text-xs text-paper-secondary cursor-not-allowed" - } else { - BTN_TEXT_RED - }, + class: if deleting { "text-xs text-paper-secondary cursor-not-allowed" } else { BTN_TEXT_RED }, disabled: deleting, onclick: move |_| on_delete.call(post.id), - if deleting { "删除中..." } else { "删除" } + if deleting { + "删除中..." + } else { + "删除" + } } } } diff --git a/src/pages/admin/trash.rs b/src/pages/admin/trash.rs index 2fd69b7..f509cc3 100644 --- a/src/pages/admin/trash.rs +++ b/src/pages/admin/trash.rs @@ -34,7 +34,9 @@ const TRASH_PER_PAGE: i32 = 20; /// 回收站入口组件,默认展示第 1 页。 #[component] pub fn Trash() -> Element { - rsx! { TrashPage { page: 1 } } + rsx! { + TrashPage { page: 1 } + } } /// 回收站分页组件。 @@ -126,14 +128,11 @@ pub fn TrashPage(page: i32) -> Element { // 页面标题 div { class: "flex items-center gap-3", h1 { class: "text-2xl font-bold text-paper-primary", "回收站" } - span { class: "text-sm text-paper-secondary", - "共 {total()} 篇" - } + span { class: "text-sm text-paper-secondary", "共 {total()} 篇" } } // 自动清理配置卡片:可折叠的设置面板,顶部始终显示当前状态摘要 - div { - class: "rounded-xl border border-paper-border overflow-hidden bg-paper-entry", + div { class: "rounded-xl border border-paper-border overflow-hidden bg-paper-entry", // 顶部可点击摘要条:状态指示灯 + 标题 + 展开箭头 button { class: "w-full flex items-center gap-3 px-5 py-4 text-left cursor-pointer hover:bg-paper-theme focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40", @@ -142,21 +141,21 @@ pub fn TrashPage(page: i32) -> Element { just_saved.set(false); }, // 状态指示灯 - {let dot_class = if settings().auto_purge_enabled { - "w-2 h-2 rounded-full bg-paper-accent shadow-[0_0_0_3px_rgba(92,122,94,0.15)]" - } else { - "w-2 h-2 rounded-full bg-paper-tertiary" - }; - rsx! { - div { class: "w-2 flex-shrink-0 flex items-center justify-center", - div { class: "{dot_class}" } + { + let dot_class = if settings().auto_purge_enabled { + "w-2 h-2 rounded-full bg-paper-accent shadow-[0_0_0_3px_rgba(92,122,94,0.15)]" + } else { + "w-2 h-2 rounded-full bg-paper-tertiary" + }; + rsx! { + div { class: "w-2 flex-shrink-0 flex items-center justify-center", + div { class: "{dot_class}" } + } } - }} + } // 标题 + 当前状态描述 div { class: "flex-1 min-w-0", - div { class: "text-sm font-medium text-paper-primary", - "自动清理" - } + div { class: "text-sm font-medium text-paper-primary", "自动清理" } div { class: "text-xs text-paper-secondary mt-0.5 truncate", if settings().auto_purge_enabled { "已开启 · 超过 {settings().retention_days} 天的文章将被自动删除" @@ -172,7 +171,11 @@ pub fn TrashPage(page: i32) -> Element { fill: "none", stroke: "currentColor", stroke_width: "2", - path { stroke_linecap: "round", stroke_linejoin: "round", d: "M19 9l-7 7-7-7" } + path { + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M19 9l-7 7-7-7", + } } } @@ -180,8 +183,7 @@ pub fn TrashPage(page: i32) -> Element { if settings_panel_open() { div { class: "border-t border-paper-border p-5 space-y-6", // 开关行:启用自动清理 - div { - class: "flex items-center justify-between gap-4", + div { class: "flex items-center justify-between gap-4", div { class: "min-w-0", div { class: "text-sm font-medium text-paper-primary", "启用自动清理" @@ -194,23 +196,13 @@ pub fn TrashPage(page: i32) -> Element { button { role: "switch", aria_checked: "{settings_draft_enabled()}", - class: if settings_draft_enabled() { - "relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-accent cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40" - } else { - "relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-tertiary cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40" - }, + class: if settings_draft_enabled() { "relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-accent cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40" } else { "relative w-11 h-6 flex-shrink-0 rounded-full bg-paper-tertiary cursor-pointer transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40" }, onclick: move |_| { settings_draft_enabled.set(!settings_draft_enabled()); just_saved.set(false); }, // 滑块圆点 - span { - class: if settings_draft_enabled() { - "absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200 translate-x-5" - } else { - "absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200" - }, - } + span { class: if settings_draft_enabled() { "absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200 translate-x-5" } else { "absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-sm dark:shadow-black/30 transition-transform duration-200" } } } } @@ -275,27 +267,30 @@ pub fn TrashPage(page: i32) -> Element { // 草稿状态提示 if just_saved() { span { class: "inline-flex items-center gap-1.5 text-xs text-paper-accent", - svg { class: "w-3.5 h-3.5", view_box: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2.5", - path { stroke_linecap: "round", stroke_linejoin: "round", d: "M5 13l4 4L19 7" } + svg { + class: "w-3.5 h-3.5", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2.5", + path { + stroke_linecap: "round", + stroke_linejoin: "round", + d: "M5 13l4 4L19 7", + } } "已保存" } } else if dirty { - span { class: "text-xs text-paper-secondary", - "有未保存的更改" - } + span { class: "text-xs text-paper-secondary", "有未保存的更改" } } else { - span { class: "text-xs text-transparent select-none", "·" } + span { class: "text-xs text-transparent select-none", + "·" + } } // 保存按钮:启用主题色,禁用/保存中态灰化 button { - class: if saving_settings() { - "inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-paper-secondary bg-paper-tertiary rounded-full" - } else if just_saved() { - "inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-paper-secondary bg-paper-tertiary rounded-full" - } else { - "inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-paper-theme bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40" - }, + class: if saving_settings() { "inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-paper-secondary bg-paper-tertiary rounded-full" } else if just_saved() { "inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium cursor-not-allowed text-paper-secondary bg-paper-tertiary rounded-full" } else { "inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-paper-theme bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40" }, disabled: saving_settings() || just_saved() || !dirty, onclick: move |_| { let days: i32 = settings_draft_days().parse().unwrap_or(30); @@ -309,7 +304,11 @@ pub fn TrashPage(page: i32) -> Element { saving_settings.set(false); }); }, - if saving_settings() { "保存中…" } else { "保存设置" } + if saving_settings() { + "保存中…" + } else { + "保存设置" + } } } } @@ -319,9 +318,7 @@ pub fn TrashPage(page: i32) -> Element { // 批量操作栏(选中时显示) if !selected_ids().is_empty() { div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg", - span { class: "text-sm text-paper-secondary", - "已选择 {selected_ids().len()} 条" - } + span { class: "text-sm text-paper-secondary", "已选择 {selected_ids().len()} 条" } button { class: "{BTN_SOLID_GREEN}", onclick: move |_| { @@ -329,7 +326,9 @@ pub fn TrashPage(page: i32) -> Element { spawn(async move { let _ = batch_restore_posts(ids).await; }); - for id in selected_ids() { remove_post(id); } + for id in selected_ids() { + remove_post(id); + } selected_ids.set(HashSet::new()); }, "批量恢复" @@ -340,14 +339,22 @@ pub fn TrashPage(page: i32) -> Element { #[cfg(target_arch = "wasm32")] { if web_sys::window() - .and_then(|w| w.confirm_with_message("确定要彻底删除选中的文章吗?此操作不可恢复。").ok()) + .and_then(|w| { + w + .confirm_with_message( + "确定要彻底删除选中的文章吗?此操作不可恢复。", + ) + .ok() + } .unwrap_or(false) { let ids: Vec = selected_ids().iter().copied().collect(); spawn(async move { let _ = batch_purge_posts(ids).await; }); - for id in selected_ids() { remove_post(id); } + for id in selected_ids() { + remove_post(id); + } selected_ids.set(HashSet::new()); } } @@ -360,7 +367,9 @@ pub fn TrashPage(page: i32) -> Element { // 主内容:错误 / 加载骨架 / 空态 / 列表 { if error().is_some() { - rsx! { EmptyState { message: "加载失败", variant: "error" } } + rsx! { + EmptyState { message: "加载失败", variant: "error" } + } } else if loading() && posts().is_empty() { rsx! { DelayedSkeleton { @@ -372,7 +381,9 @@ pub fn TrashPage(page: i32) -> Element { } } } else if posts().is_empty() { - rsx! { EmptyState { message: "回收站为空", variant: "default" } } + rsx! { + EmptyState { message: "回收站为空", variant: "default" } + } } else { let list = posts(); let all_selected = list.iter().all(|p| selected_ids().contains(&p.id)); @@ -392,17 +403,21 @@ pub fn TrashPage(page: i32) -> Element { move |_| { let mut s = selected_ids(); if all_selected { - for id in &all_ids { s.remove(id); } + for id in &all_ids { + s.remove(id); + } } else { - for id in &all_ids { s.insert(*id); } + for id in &all_ids { + s.insert(*id); + } } selected_ids.set(s); } - } + }, } } th { class: "px-4 py-3 font-medium", "标题" } - th { class: "px-4 py-3 font-medium", "原状态" } + th { class: "px-4 py-3 font-medium", "原状态" } // 底部:清空回收站 + 分页 th { class: "px-4 py-3 font-medium w-28", "删除时间" } th { class: "px-4 py-3 font-medium w-24 text-center", "剩余" } th { class: "px-4 py-3 font-medium w-32 text-right", "操作" } @@ -419,7 +434,11 @@ pub fn TrashPage(page: i32) -> Element { let id = post.id; move |checked: bool| { let mut s = selected_ids(); - if checked { s.insert(id); } else { s.remove(&id); } + if checked { + s.insert(id); + } + s.remove(&id); + } selected_ids.set(s); } }, @@ -438,7 +457,13 @@ pub fn TrashPage(page: i32) -> Element { #[cfg(target_arch = "wasm32")] { if web_sys::window() - .and_then(|w| w.confirm_with_message("确定要彻底删除这篇文章吗?此操作不可恢复。").ok()) + .and_then(|w| { + w + .confirm_with_message( + "确定要彻底删除这篇文章吗?此操作不可恢复。", + ) + .ok() + }) .unwrap_or(false) { spawn(async move { @@ -463,7 +488,13 @@ pub fn TrashPage(page: i32) -> Element { #[cfg(target_arch = "wasm32")] { if web_sys::window() - .and_then(|w| w.confirm_with_message("确定要清空回收站吗?所有已删除文章将被彻底移除,此操作不可恢复。").ok()) + .and_then(|w| { + w + .confirm_with_message( + "确定要清空回收站吗?所有已删除文章将被彻底移除,此操作不可恢复。", + ) + .ok() + } .unwrap_or(false) { spawn(async move { @@ -483,12 +514,12 @@ pub fn TrashPage(page: i32) -> Element { current_page, total: total(), per_page: TRASH_PER_PAGE, - prev_route: if current_page - 1 <= 1 { - Route::Trash {} - } else { - Route::TrashPage { page: current_page - 1 } + prev_route: if current_page - 1 <= 1 { Route::Trash {} } else { Route::TrashPage { + page: current_page - 1, + } }, + next_route: Route::TrashPage { + page: current_page + 1, }, - next_route: Route::TrashPage { page: current_page + 1 }, unit: "篇", } } @@ -567,14 +598,9 @@ fn TrashRow( label: post.status_label().to_string(), } } - td { class: "px-4 py-3 text-sm text-paper-secondary", - "{deleted_str}" - } + td { class: "px-4 py-3 text-sm text-paper-secondary", "{deleted_str}" } td { class: "px-4 py-3 text-center", - StatusBadge { - color_class: badge_class, - label: badge_text, - } + StatusBadge { color_class: badge_class, label: badge_text } } td { class: "px-4 py-3 text-right", div { class: "flex justify-end gap-2", diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs index 6984d9d..6fffb28 100644 --- a/src/pages/admin/write.rs +++ b/src/pages/admin/write.rs @@ -423,349 +423,340 @@ fn write_editor(post_id: Option) -> Element { rsx! { div { class: "relative flex flex-col", if loading() { - div { class: "absolute inset-0 z-10 bg-paper-theme", - WriteSkeleton {} - } + div { class: "absolute inset-0 z-10 bg-paper-theme", WriteSkeleton {} } } // 整页自然滚动:元信息 + 编辑器 + 错误提示 + 底部操作栏一起随浏览器滚动。 div { class: "flex flex-col", // 顶部元信息区域 div { class: "flex-shrink-0 space-y-5 pt-8", - // 标题区域 - 大字号无框输入 - div { - input { - class: "w-full text-3xl md:text-4xl font-bold bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none tracking-tight leading-tight", - placeholder: "文章标题", - value: "{title}", - oninput: move |evt| title.set(evt.value()), - } - } - - // 摘要 - textarea { - class: "w-full text-base bg-transparent text-[var(--color-paper-secondary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none resize-none leading-relaxed mb-0", - placeholder: "摘要(留空则自动生成)", - rows: "2", - value: "{summary}", - oninput: move |evt| summary.set(evt.value()), - } - - // 元数据行 - 紧凑精致 - div { class: "flex flex-wrap items-end gap-x-8 gap-y-4 text-sm", - div { class: "flex-1 min-w-[140px]", - label { class: "{META_LABEL_CLASS}", - "Slug" - } + // 标题区域 - 大字号无框输入 + div { input { - class: "{META_INPUT_CLASS}", - placeholder: "自动生成", - value: "{slug}", - oninput: move |evt| slug.set(evt.value()), + class: "w-full text-3xl md:text-4xl font-bold bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none tracking-tight leading-tight", + placeholder: "文章标题", + value: "{title}", + oninput: move |evt| title.set(evt.value()), } } - div { class: "flex-1 min-w-[140px]", - label { class: "{META_LABEL_CLASS}", - "标签" + + // 摘要 + textarea { + class: "w-full text-base bg-transparent text-[var(--color-paper-secondary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none resize-none leading-relaxed mb-0", + placeholder: "摘要(留空则自动生成)", + rows: "2", + value: "{summary}", + oninput: move |evt| summary.set(evt.value()), + } + + // 元数据行 - 紧凑精致 + div { class: "flex flex-wrap items-end gap-x-8 gap-y-4 text-sm", + div { class: "flex-1 min-w-[140px]", + label { class: "{META_LABEL_CLASS}", "Slug" } + input { + class: "{META_INPUT_CLASS}", + placeholder: "自动生成", + value: "{slug}", + oninput: move |evt| slug.set(evt.value()), + } } - input { - class: "{META_INPUT_CLASS}", - placeholder: "逗号分隔", - value: "{tags}", - oninput: move |evt| tags.set(evt.value()), + div { class: "flex-1 min-w-[140px]", + label { class: "{META_LABEL_CLASS}", "标签" } + input { + class: "{META_INPUT_CLASS}", + placeholder: "逗号分隔", + value: "{tags}", + oninput: move |evt| tags.set(evt.value()), + } } } - } - // 封面图上传区:空态矮横条(不挤压编辑器),有图时展开成 21:9 超宽预览。 - // 21:9 与首页卡片封面统一比例,比 16:9 更扁,适合宽屏横幅式封面。 - // 容器统一绑定拖拽与粘贴事件;内部按 cover_image / cover_uploading 切换空态、上传中、预览。 - div { - class: "relative w-full rounded-xl border border-dashed overflow-hidden transition-all duration-200 group/cover", - // 空态矮横条;有图/上传中展开成 21:9。 - class: if cover_image().is_empty() && !cover_uploading() { - "h-14" - } else { - "aspect-[21/9]" - }, - class: if cover_drag_active() { - "border-[var(--color-paper-accent)] bg-[var(--color-paper-accent-soft)]" - } else if cover_image().is_empty() { - "border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] hover:border-[var(--color-paper-accent)] hover:bg-[var(--color-paper-accent-soft)]" - } else { - "border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]" - }, + // 封面图上传区:空态矮横条(不挤压编辑器),有图时展开成 21:9 超宽预览。 + // 21:9 与首页卡片封面统一比例,比 16:9 更扁,适合宽屏横幅式封面。 + // 容器统一绑定拖拽与粘贴事件;内部按 cover_image / cover_uploading 切换空态、上传中、预览。 + div { + class: "relative w-full rounded-xl border border-dashed overflow-hidden transition-all duration-200 group/cover", + // 空态矮横条;有图/上传中展开成 21:9。 + class: if cover_image().is_empty() && !cover_uploading() { "h-14" } else { "aspect-[21/9]" }, + class: if cover_drag_active() { "border-[var(--color-paper-accent)] bg-[var(--color-paper-accent-soft)]" } else if cover_image().is_empty() { "border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] hover:border-[var(--color-paper-accent)] hover:bg-[var(--color-paper-accent-soft)]" } else { "border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]" }, - // 整个容器可接收拖拽与粘贴(ondragover 必须 prevent_default,否则浏览器直接打开文件)。 - ondragover: move |evt| { - evt.prevent_default(); - if !cover_uploading() && cover_image().is_empty() { - cover_drag_active.set(true); - } - }, - ondragenter: move |evt| { - evt.prevent_default(); - }, - ondragleave: move |_| { - cover_drag_active.set(false); - }, - ondrop: move |evt| { - evt.prevent_default(); - cover_drag_active.set(false); - if !cover_uploading() && cover_image().is_empty() { - #[cfg(target_arch = "wasm32")] - { - if let Some(file) = evt.files().into_iter().next() { - if let Some(web_file) = file.get_web_file() { - spawn_cover_upload(web_file); + // 整个容器可接收拖拽与粘贴(ondragover 必须 prevent_default,否则浏览器直接打开文件)。 + ondragover: move |evt| { + evt.prevent_default(); + if !cover_uploading() && cover_image().is_empty() { + cover_drag_active.set(true); + } + }, + ondragenter: move |evt| { + evt.prevent_default(); + }, + ondragleave: move |_| { + cover_drag_active.set(false); + }, + ondrop: move |evt| { + evt.prevent_default(); + cover_drag_active.set(false); + if !cover_uploading() && cover_image().is_empty() { + #[cfg(target_arch = "wasm32")] + { + if let Some(file) = evt.files().into_iter().next() { + if let Some(web_file) = file.get_web_file() { + spawn_cover_upload(web_file); + } } } } - } - }, - onpaste: move |evt| { - evt.prevent_default(); - if !cover_uploading() && cover_image().is_empty() { - #[cfg(target_arch = "wasm32")] - { - use wasm_bindgen::JsCast; - if let Some(raw) = evt.try_as_web_event() { - if let Some(ce) = raw.dyn_ref::() { - if let Some(dt) = ce.clipboard_data() { - if let Some(file_list) = dt.files() { - if let Some(file) = file_list.item(0) { - spawn_cover_upload(file); + }, + onpaste: move |evt| { + evt.prevent_default(); + if !cover_uploading() && cover_image().is_empty() { + #[cfg(target_arch = "wasm32")] + { + use wasm_bindgen::JsCast; + if let Some(raw) = evt.try_as_web_event() { + if let Some(ce) = raw.dyn_ref::() { + if let Some(dt) = ce.clipboard_data() { + if let Some(file_list) = dt.files() { + if let Some(file) = file_list.item(0) { + spawn_cover_upload(file); + } } } } } } } - } - }, + }, - // —— 上传中:骨架占位 + 文案 —— - if cover_uploading() { - div { class: "absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[var(--color-paper-tertiary)]/30 animate-pulse", - span { class: "text-sm text-[var(--color-paper-secondary)]", - "上传中..." - } - } - } - - // —— 有图:预览 + 移除/更换 —— - if !cover_image().is_empty() && !cover_uploading() { - // 预览图:/uploads/ 路径加 ?w=600 缩略,外链 URL 原样用。 - // 用条件表达式内联计算,避免 rsx 内 let 块(宏在 server 端解析受限)。 - img { - class: "absolute inset-0 w-full h-full object-cover", - src: { - let cv = cover_image(); - if cv.starts_with("/uploads/") { - let base = cv.split('?').next().unwrap_or(&cv); - if cv.contains('?') { format!("{}&w=600", base) } else { format!("{}?w=600", base) } - } else { - cv - } - }, - alt: "封面预览", - // 外链预览加载失败时提示,避免空白。 - onerror: move |_| { - cover_error.set(Some("封面图加载失败,请检查 URL".to_string())); - }, - } - // 右上角移除按钮(hover 出现)。 - button { - class: "absolute top-2 right-2 w-7 h-7 flex items-center justify-center rounded-full bg-black/50 text-white opacity-0 group-hover/cover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer", - aria_label: "移除封面", - onclick: move |_| { - cover_image.set(String::new()); - cover_error.set(None); - cover_url_mode.set(false); - cover_url_input.set(String::new()); - }, - // 内联 SVG:关闭 X(与 header.rs 关闭按钮同风格,view_box 0 0 24 24)。 - svg { - class: "w-4 h-4", - xmlns: "http://www.w3.org/2000/svg", - view_box: "0 0 24 24", - fill: "none", - stroke: "currentColor", - stroke_width: "2", - stroke_linecap: "round", - stroke_linejoin: "round", - path { d: "M6 6l12 12M6 18L18 6" } - } - } - // 底部渐变遮罩 + "更换封面"提示(hover 出现)。 - div { class: "absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover/cover:opacity-100 transition-opacity flex items-end justify-center pb-2 pointer-events-none", - span { class: "text-xs text-white/90", - "点击下方区域可重新上传" - } - } - } - - // —— 空态:横向矮横条,图标+提示+URL 链 —— - if cover_image().is_empty() && !cover_uploading() { - // label 包裹整个空态:点击天然触发隐藏的 file input,无需 JS。 - label { class: "absolute inset-0 flex flex-row items-center gap-3 cursor-pointer px-4 text-left", - // 上传图标(Feather 风格线框,与项目现有图标体系一致)。 - svg { - class: "w-5 h-5 shrink-0 text-[var(--color-paper-secondary)]", - xmlns: "http://www.w3.org/2000/svg", - view_box: "0 0 24 24", - fill: "none", - stroke: "currentColor", - stroke_width: "1.8", - stroke_linecap: "round", - stroke_linejoin: "round", - path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" } - polyline { points: "17 8 12 3 7 8" } - line { - x1: "12", - y1: "3", - x2: "12", - y2: "15", + // —— 上传中:骨架占位 + 文案 —— + if cover_uploading() { + div { class: "absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[var(--color-paper-tertiary)]/30 animate-pulse", + span { class: "text-sm text-[var(--color-paper-secondary)]", + "上传中..." } } - span { class: "text-sm text-[var(--color-paper-secondary)] shrink-0", - "拖拽 · 点击 · 粘贴封面图" - } - // URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。 - span { - class: "text-[11px] font-medium tracking-wider text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-accent)] transition-colors ml-auto shrink-0", - onclick: move |evt| { - evt.prevent_default(); - evt.stop_propagation(); - cover_url_mode.set(true); - cover_url_input.set(cover_image()); + } + + // —— 有图:预览 + 移除/更换 —— + if !cover_image().is_empty() && !cover_uploading() { + // 预览图:/uploads/ 路径加 ?w=600 缩略,外链 URL 原样用。 + // 用条件表达式内联计算,避免 rsx 内 let 块(宏在 server 端解析受限)。 + img { + class: "absolute inset-0 w-full h-full object-cover", + src: { + let cv = cover_image(); + if cv.starts_with("/uploads/") { + let base = cv.split('?').next().unwrap_or(&cv); + if cv.contains('?') { + format!("{}&w=600", base) + } + } + format!("{}?w=600", base) + } + } else { + cv + } + }, + alt: "封面预览", + // 外链预览加载失败时提示,避免空白。 + onerror: move |_| { + cover_error.set(Some("封面图加载失败,请检查 URL".to_string())); }, - "或使用图片 URL" } - // 隐藏的 file input,由 label 点击触发。 - input { - r#type: "file", - accept: "image/jpeg,image/png,image/gif,image/webp", - class: "hidden", - onchange: move |evt| { - #[cfg(target_arch = "wasm32")] - { - if let Some(file) = evt.files().into_iter().next() { - if let Some(web_file) = file.get_web_file() { - spawn_cover_upload(web_file); + // 右上角移除按钮(hover 出现)。 + button { + class: "absolute top-2 right-2 w-7 h-7 flex items-center justify-center rounded-full bg-black/50 text-white opacity-0 group-hover/cover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer", + aria_label: "移除封面", + onclick: move |_| { + cover_image.set(String::new()); + cover_error.set(None); + cover_url_mode.set(false); + cover_url_input.set(String::new()); + }, + // 内联 SVG:关闭 X(与 header.rs 关闭按钮同风格,view_box 0 0 24 24)。 + svg { + class: "w-4 h-4", + xmlns: "http://www.w3.org/2000/svg", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "M6 6l12 12M6 18L18 6" } + } + } + // 底部渐变遮罩 + "更换封面"提示(hover 出现)。 + div { class: "absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover/cover:opacity-100 transition-opacity flex items-end justify-center pb-2 pointer-events-none", + span { class: "text-xs text-white/90", + "点击下方区域可重新上传" + } + } + } + + // —— 空态:横向矮横条,图标+提示+URL 链 —— + if cover_image().is_empty() && !cover_uploading() { + // label 包裹整个空态:点击天然触发隐藏的 file input,无需 JS。 + label { class: "absolute inset-0 flex flex-row items-center gap-3 cursor-pointer px-4 text-left", + // 上传图标(Feather 风格线框,与项目现有图标体系一致)。 + svg { + class: "w-5 h-5 shrink-0 text-[var(--color-paper-secondary)]", + xmlns: "http://www.w3.org/2000/svg", + view_box: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "1.8", + stroke_linecap: "round", + stroke_linejoin: "round", + path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" } + polyline { points: "17 8 12 3 7 8" } + line { + x1: "12", + y1: "3", + x2: "12", + y2: "15", + } + } + span { class: "text-sm text-[var(--color-paper-secondary)] shrink-0", + "拖拽 · 点击 · 粘贴封面图" + } + // URL 文字链:阻止 label 的默认 file 触发,切换到 URL 输入模式。 + span { + class: "text-[11px] font-medium tracking-wider text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-accent)] transition-colors ml-auto shrink-0", + onclick: move |evt| { + evt.prevent_default(); + evt.stop_propagation(); + cover_url_mode.set(true); + cover_url_input.set(cover_image()); + }, + "或使用图片 URL" + } + // 隐藏的 file input,由 label 点击触发。 + input { + r#type: "file", + accept: "image/jpeg,image/png,image/gif,image/webp", + class: "hidden", + onchange: move |evt| { + #[cfg(target_arch = "wasm32")] + { + if let Some(file) = evt.files().into_iter().next() { + if let Some(web_file) = file.get_web_file() { + spawn_cover_upload(web_file); + } } } - } - // 注意:未重置 input.value,重复选择同一文件不会再次触发 onchange。 - // 这是 file input 的通用行为,封面场景影响可忽略。 - }, + // 注意:未重置 input.value,重复选择同一文件不会再次触发 onchange。 + // 这是 file input 的通用行为,封面场景影响可忽略。 + }, + } } } } - } - // —— URL 输入模式(内联展开,空态时叠加在容器外,避免与拖拽区争抢点击)—— - if cover_url_mode() && cover_image().is_empty() { - div { class: "flex items-center gap-2 mt-2", - input { - class: "flex-1 {META_INPUT_CLASS}", - placeholder: "粘贴图片 URL", - value: "{cover_url_input}", - oninput: move |evt| cover_url_input.set(evt.value()), - onkeydown: move |evt| { - if evt.key() == Key::Enter { + // —— URL 输入模式(内联展开,空态时叠加在容器外,避免与拖拽区争抢点击)—— + if cover_url_mode() && cover_image().is_empty() { + div { class: "flex items-center gap-2 mt-2", + input { + class: "flex-1 {META_INPUT_CLASS}", + placeholder: "粘贴图片 URL", + value: "{cover_url_input}", + oninput: move |evt| cover_url_input.set(evt.value()), + onkeydown: move |evt| { + if evt.key() == Key::Enter { + let v = cover_url_input().trim().to_string(); + if !v.is_empty() { + cover_image.set(v); + cover_error.set(None); + cover_url_mode.set(false); + } + } + }, + } + button { + class: "shrink-0 px-3 py-1 text-xs text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer", + onclick: move |_| { let v = cover_url_input().trim().to_string(); if !v.is_empty() { cover_image.set(v); cover_error.set(None); cover_url_mode.set(false); } - } - }, - } - button { - class: "shrink-0 px-3 py-1 text-xs text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer", - onclick: move |_| { - let v = cover_url_input().trim().to_string(); - if !v.is_empty() { - cover_image.set(v); - cover_error.set(None); + }, + "确认" + } + button { + class: "shrink-0 px-3 py-1 text-xs text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer", + onclick: move |_| { cover_url_mode.set(false); - } - }, - "确认" + cover_url_input.set(String::new()); + }, + "取消" + } } - button { - class: "shrink-0 px-3 py-1 text-xs text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] transition-colors cursor-pointer", - onclick: move |_| { - cover_url_mode.set(false); - cover_url_input.set(String::new()); - }, - "取消" + } + + // 封面上传失败提示:复用页面红色条风格。 + if let Some(err) = cover_error() { + div { class: "flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mt-2", + span { "封面图: {err}" } + button { + class: "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none", + aria_label: "关闭提示", + onclick: move |_| cover_error.set(None), + "×" + } } } } - // 封面上传失败提示:复用页面红色条风格。 - if let Some(err) = cover_error() { - div { class: "flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mt-2", - span { "封面图: {err}" } + // 编辑器区域 - 整页滚动下用视口高度,保证充裕的编辑空间。 + // h-[60vh]:随窗口高度自适应;min-h-[400px]:窗口过矮时仍可用。 + div { class: "h-[60vh] min-h-[400px] flex flex-col my-4", + div { + class: "flex-1 min-h-0 w-full border border-[var(--color-paper-border)] rounded-xl overflow-hidden bg-[var(--color-paper-entry)] shadow-[0_2px_8px_rgba(0,0,0,0.04)] dark:shadow-none", + id: "tiptap-editor", + } + } + + // 错误和成功提示 + if let Some(err) = load_error() { + div { class: "flex-shrink-0 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mb-2", + "{err}" + } + } + + if let Some(err) = error() { + div { class: "flex-shrink-0 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mb-2", + "{err}" + } + } + + // 上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符(避免孤儿) + for err in upload_errors().clone() { + div { + key: "{err.id}", + class: "flex-shrink-0 flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mb-2", + span { "图片上传失败: {err.file_name} — {err.message}" } button { class: "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none", aria_label: "关闭提示", - onclick: move |_| cover_error.set(None), + onclick: { + // 捕获 owned id,避免借用临时值 + let id = err.id.clone(); + let mut upload_errors = upload_errors; + move |_| { + // 关闭提示同时删除编辑器内失败占位符(避免孤儿) + #[cfg(target_arch = "wasm32")] + if let Some(handle) = &*editor.read() { + handle.instance().remove_upload_by_upload_id(&id); + } + upload_errors.write().retain(|e| e.id != id); + } + }, "×" } } } - } - - // 编辑器区域 - 整页滚动下用视口高度,保证充裕的编辑空间。 - // h-[60vh]:随窗口高度自适应;min-h-[400px]:窗口过矮时仍可用。 - div { class: "h-[60vh] min-h-[400px] flex flex-col my-4", - div { - class: "flex-1 min-h-0 w-full border border-[var(--color-paper-border)] rounded-xl overflow-hidden bg-[var(--color-paper-entry)] shadow-[0_2px_8px_rgba(0,0,0,0.04)] dark:shadow-none", - id: "tiptap-editor", - } - } - - // 错误和成功提示 - if let Some(err) = load_error() { - div { class: "flex-shrink-0 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mb-2", - "{err}" - } - } - - if let Some(err) = error() { - div { class: "flex-shrink-0 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mb-2", - "{err}" - } - } - - // 上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符(避免孤儿) - for err in upload_errors().clone() { - div { class: "flex-shrink-0 flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-xl text-sm border border-red-100 dark:border-red-900/30 mb-2", - span { "图片上传失败: {err.file_name} — {err.message}" } - button { - class: "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none", - aria_label: "关闭提示", - onclick: { - // 捕获 owned id,避免借用临时值 - let id = err.id.clone(); - let mut upload_errors = upload_errors; - move |_| { - // 关闭提示同时删除编辑器内失败占位符(避免孤儿) - #[cfg(target_arch = "wasm32")] - if let Some(handle) = &*editor.read() { - handle.instance().remove_upload_by_upload_id(&id); - } - upload_errors.write().retain(|e| e.id != id); - } - }, - "×" - } - } - } } // 内容区闭合 // 底部操作栏 - 跟随页面滚动 @@ -778,8 +769,7 @@ fn write_editor(post_id: Option) -> Element { "取消" } div { class: "w-px h-5 bg-[var(--color-paper-border)]" } - div { - class: "relative inline-flex items-center px-3 py-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer", + div { class: "relative inline-flex items-center px-3 py-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer", select { class: "absolute inset-0 w-full h-full opacity-0 cursor-pointer", style: "appearance: none; -webkit-appearance: none;", @@ -789,7 +779,11 @@ fn write_editor(post_id: Option) -> Element { option { value: "published", "发布" } } span { class: "pr-1.5 text-[var(--color-paper-primary)] font-medium", - if status() == "draft" { "草稿" } else { "发布" } + if status() == "draft" { + "草稿" + } else { + "发布" + } } svg { class: "h-3.5 w-3.5 text-[var(--color-paper-tertiary)] pointer-events-none", @@ -799,17 +793,13 @@ fn write_editor(post_id: Option) -> Element { path { fill_rule: "evenodd", d: "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z", - clip_rule: "evenodd" + clip_rule: "evenodd", } } } div { class: "w-px h-5 bg-[var(--color-paper-border)]" } button { - class: if saving() { - "px-5 py-1.5 text-sm bg-[var(--color-paper-tertiary)] text-[var(--color-paper-secondary)] rounded-xl font-medium cursor-not-allowed" - } else { - "px-5 py-1.5 text-sm bg-[var(--color-paper-accent)] text-[var(--color-paper-theme)] rounded-xl font-medium hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer" - }, + class: if saving() { "px-5 py-1.5 text-sm bg-[var(--color-paper-tertiary)] text-[var(--color-paper-secondary)] rounded-xl font-medium cursor-not-allowed" } else { "px-5 py-1.5 text-sm bg-[var(--color-paper-accent)] text-[var(--color-paper-theme)] rounded-xl font-medium hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer" }, disabled: saving(), onclick: on_submit, "{save_button_text}" diff --git a/src/pages/archives.rs b/src/pages/archives.rs index 89be690..268e770 100644 --- a/src/pages/archives.rs +++ b/src/pages/archives.rs @@ -101,9 +101,7 @@ fn group_posts(posts: &[PostListItem]) -> Vec { pub fn Archives() -> Element { rsx! { header { class: "page-header mb-6", - h1 { class: "text-4xl font-bold text-paper-primary tracking-tight", - "归档" - } + h1 { class: "text-4xl font-bold text-paper-primary tracking-tight", "归档" } } ArchivesContent {} } @@ -129,15 +127,16 @@ fn ArchivesContent() -> Element { " 篇文章" } for year_group in grouped.iter() { - YearSection { year_group: year_group.clone() } + YearSection { + key: "{year_group.year}", + year_group: year_group.clone(), + } } } } Some(Err(e)) => { rsx! { - div { class: "text-center text-red-500 dark:text-red-400 py-20", - "加载失败: {e}" - } + div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败: {e}" } } } None => { @@ -170,7 +169,11 @@ fn YearSection(year_group: YearGroup) -> Element { sup { class: "archive-count text-sm text-paper-secondary ml-1", "{total}" } } for month_group in year_group.months.iter() { - MonthSection { month_group: month_group.clone(), year: year_group.year.clone() } + MonthSection { + key: "{month_group.month_en}", + month_group: month_group.clone(), + year: year_group.year.clone(), + } } } } @@ -195,7 +198,7 @@ fn MonthSection(month_group: MonthGroup, year: String) -> Element { } div { class: "archive-posts flex-1", for post in month_group.posts.iter() { - ArchiveEntry { post: post.clone() } + ArchiveEntry { key: "{post.id}", post: post.clone() } } } } @@ -212,13 +215,13 @@ fn ArchiveEntry(post: PostListItem) -> Element { h3 { class: "archive-entry-title text-base font-normal text-paper-primary m-0", "{post.title}" } - div { class: "archive-meta text-sm text-paper-secondary mt-1", - "{date_str}" - } + div { class: "archive-meta text-sm text-paper-secondary mt-1", "{date_str}" } Link { class: "entry-link absolute inset-0 z-10", aria_label: "post link to {post.title}", - to: Route::PostDetail { slug: post.slug.clone() }, + to: Route::PostDetail { + slug: post.slug.clone(), + }, } } } diff --git a/src/pages/home.rs b/src/pages/home.rs index 87bd94e..80c76ef 100644 --- a/src/pages/home.rs +++ b/src/pages/home.rs @@ -27,7 +27,9 @@ const POSTS_PER_PAGE: i32 = 10; /// 直接委托给 `HomePage` 并固定页码为 1。 #[component] pub fn Home() -> Element { - rsx! { HomePage { page: 1 } } + rsx! { + HomePage { page: 1 } + } } /// 首页分页组件,对应路由 `/page/:page`。 @@ -62,7 +64,7 @@ fn HomePosts(current_page: i32) -> Element { Some(Ok((posts, total))) => { rsx! { for post in posts.iter() { - PostCard { post: post.clone() } + PostCard { key: "{post.id}", post: post.clone() } } // total == 0 表示站点确实无文章:显示空状态,且不渲染分页。 // 注意:total > 0 但 posts 为空(如越界页码 /page/9999)也不显示空状态, @@ -81,12 +83,12 @@ fn HomePosts(current_page: i32) -> Element { current_page, total, per_page: POSTS_PER_PAGE, - prev_route: if current_page - 1 <= 1 { - Route::Home {} - } else { - Route::HomePage { page: current_page - 1 } + prev_route: if current_page - 1 <= 1 { Route::Home {} } else { Route::HomePage { + page: current_page - 1, + } }, + next_route: Route::HomePage { + page: current_page + 1, }, - next_route: Route::HomePage { page: current_page + 1 }, unit: "篇", } } @@ -95,9 +97,7 @@ fn HomePosts(current_page: i32) -> Element { // 不透传内部错误细节,统一展示通用文案(与标签页等其它页面一致)。 Some(Err(_)) => { rsx! { - div { class: "text-center text-red-500 dark:text-red-400 py-20", - "加载失败" - } + div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败" } } } _ => { diff --git a/src/pages/login.rs b/src/pages/login.rs index 3575343..66210d3 100644 --- a/src/pages/login.rs +++ b/src/pages/login.rs @@ -72,7 +72,10 @@ pub fn Login() -> Element { div { class: "space-y-4", div { - FormLabel { label: "用户名 / 邮箱", html_for: Some("login-username".to_string()) } + FormLabel { + label: "用户名 / 邮箱", + html_for: Some("login-username".to_string()), + } FormInput { id: Some("login-username".to_string()), r#type: "text", @@ -81,11 +84,20 @@ pub fn Login() -> Element { disabled: is_loading, oninput: move |v: String| username.set(v), // 回车键触发提交 - onkeydown: Some(EventHandler::new(move |e: KeyboardEvent| if e.key() == Key::Enter { on_submit(()) })), + onkeydown: Some( + EventHandler::new(move |e: KeyboardEvent| { + if e.key() == Key::Enter { + on_submit(()) + } + }), + ), } } div { - FormLabel { label: "密码", html_for: Some("login-password".to_string()) } + FormLabel { + label: "密码", + html_for: Some("login-password".to_string()), + } FormInput { id: Some("login-password".to_string()), r#type: "password", @@ -93,7 +105,13 @@ pub fn Login() -> Element { value: password(), disabled: is_loading, oninput: move |v: String| password.set(v), - onkeydown: Some(EventHandler::new(move |e: KeyboardEvent| if e.key() == Key::Enter { on_submit(()) })), + onkeydown: Some( + EventHandler::new(move |e: KeyboardEvent| { + if e.key() == Key::Enter { + on_submit(()) + } + }), + ), } } button { @@ -101,7 +119,11 @@ pub fn Login() -> Element { class: if is_loading { "opacity-60 cursor-not-allowed" }, disabled: is_loading, onclick: move |_| on_submit(()), - if is_loading { "登录中..." } else { "登录" } + if is_loading { + "登录中..." + } else { + "登录" + } } Link { class: "block w-full py-2 px-4 text-center text-paper-secondary hover:text-paper-accent font-medium rounded-lg transition-all duration-200 cursor-pointer", diff --git a/src/pages/post_detail.rs b/src/pages/post_detail.rs index d408f94..68f8a2e 100644 --- a/src/pages/post_detail.rs +++ b/src/pages/post_detail.rs @@ -63,9 +63,7 @@ pub fn PostDetail(slug: String) -> Element { PostToc { toc_html: toc.clone() } } - PostContent { - content_html: post.content_html.clone().unwrap_or_default() - } + PostContent { content_html: post.content_html.clone().unwrap_or_default() } PostFooter { post: post.clone() } @@ -86,9 +84,7 @@ pub fn PostDetail(slug: String) -> Element { Some(Err("not_found")) => { rsx! { div { class: "text-center py-20", - h2 { class: "text-2xl font-bold text-paper-primary mb-4", - "文章不存在" - } + h2 { class: "text-2xl font-bold text-paper-primary mb-4", "文章不存在" } p { class: "text-paper-secondary mb-6", "这篇文章可能已被删除或移动。" } @@ -102,9 +98,7 @@ pub fn PostDetail(slug: String) -> Element { } Some(Err("error")) => { rsx! { - div { class: "text-center text-red-500 dark:text-red-400 py-20", - "加载失败" - } + div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败" } } } _ => { diff --git a/src/pages/register.rs b/src/pages/register.rs index 660819a..ef67e78 100644 --- a/src/pages/register.rs +++ b/src/pages/register.rs @@ -83,7 +83,8 @@ pub fn Register() -> Element { if success() { div { class: "mb-4 p-3 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg text-center", "注册成功!" - Link { class: "block mt-2 text-paper-accent hover:underline cursor-pointer", + Link { + class: "block mt-2 text-paper-accent hover:underline cursor-pointer", to: Route::Login {}, "去登录" } @@ -96,7 +97,10 @@ pub fn Register() -> Element { div { class: "space-y-4", div { - FormLabel { label: "用户名", html_for: Some("register-username".to_string()) } + FormLabel { + label: "用户名", + html_for: Some("register-username".to_string()), + } FormInput { id: Some("register-username".to_string()), r#type: "text", @@ -108,7 +112,10 @@ pub fn Register() -> Element { } } div { - FormLabel { label: "邮箱", html_for: Some("register-email".to_string()) } + FormLabel { + label: "邮箱", + html_for: Some("register-email".to_string()), + } FormInput { id: Some("register-email".to_string()), r#type: "email", @@ -120,7 +127,10 @@ pub fn Register() -> Element { } } div { - FormLabel { label: "密码", html_for: Some("register-password".to_string()) } + FormLabel { + label: "密码", + html_for: Some("register-password".to_string()), + } FormInput { id: Some("register-password".to_string()), r#type: "password", @@ -132,7 +142,10 @@ pub fn Register() -> Element { } } div { - FormLabel { label: "确认密码", html_for: Some("register-confirm-password".to_string()) } + FormLabel { + label: "确认密码", + html_for: Some("register-confirm-password".to_string()), + } FormInput { id: Some("register-confirm-password".to_string()), r#type: "password", @@ -148,12 +161,17 @@ pub fn Register() -> Element { class: if is_loading { "opacity-60 cursor-not-allowed" }, disabled: is_loading, onclick: move |_| on_submit(()), - if is_loading { "注册中..." } else { "注册" } + if is_loading { + "注册中..." + } else { + "注册" + } } } p { class: "mt-4 text-center text-sm text-paper-secondary", "已有账号?" - Link { class: "text-paper-accent hover:underline cursor-pointer", + Link { + class: "text-paper-accent hover:underline cursor-pointer", to: Route::Login {}, "去登录" } diff --git a/src/pages/search.rs b/src/pages/search.rs index ffcea5e..e389e4b 100644 --- a/src/pages/search.rs +++ b/src/pages/search.rs @@ -44,9 +44,7 @@ pub fn Search() -> Element { rsx! { header { class: "page-header mb-6", - h1 { class: "text-4xl font-bold text-paper-primary tracking-tight", - "搜索" - } + h1 { class: "text-4xl font-bold text-paper-primary tracking-tight", "搜索" } } div { class: "mb-8", div { class: "flex gap-2", @@ -56,7 +54,11 @@ pub fn Search() -> Element { placeholder: "输入关键词搜索文章...", value: query(), oninput: move |e| query.set(e.value()), - onkeydown: move |e| if e.key() == Key::Enter { on_search() }, + onkeydown: move |e| { + if e.key() == Key::Enter { + on_search() + } + }, } button { class: "px-6 py-2 bg-paper-accent text-white rounded-full font-medium hover:brightness-110 active:scale-[0.98] transition-all duration-200", @@ -70,22 +72,14 @@ pub fn Search() -> Element { DelayedSkeleton { SearchSkeleton {} } } else if let Some(Ok(PostListResponse { posts, total: _ })) = search_res() { if posts.is_empty() { - div { class: "text-center text-paper-secondary py-20", - "未找到相关文章" - } + div { class: "text-center text-paper-secondary py-20", "未找到相关文章" } } else { for post in posts.iter() { - PostCard { post: post.clone() } + PostCard { key: "{post.id}", post: post.clone() } } } - } else if search_res() - .as_ref() - .map(|r| r.is_err()) - .unwrap_or(false) - { - div { class: "text-center text-red-500 dark:text-red-400 py-20", - "搜索失败" - } + } else if search_res().as_ref().map(|r| r.is_err()).unwrap_or(false) { + div { class: "text-center text-red-500 dark:text-red-400 py-20", "搜索失败" } } } } diff --git a/src/pages/tags.rs b/src/pages/tags.rs index 232e9a3..befaa7e 100644 --- a/src/pages/tags.rs +++ b/src/pages/tags.rs @@ -27,9 +27,7 @@ use crate::router::Route; pub fn Tags() -> Element { rsx! { header { class: "page-header mb-6", - h1 { class: "text-4xl font-bold text-paper-primary tracking-tight", - "标签" - } + h1 { class: "text-4xl font-bold text-paper-primary tracking-tight", "标签" } } TagsContent {} } @@ -62,10 +60,12 @@ fn TagsContent() -> Element { } ul { class: "flex flex-wrap gap-4 mt-6", for tag in tags { - li { + li { key: "{tag.name}", Link { class: "inline-flex items-center px-3 py-1.5 text-base font-medium bg-paper-accent-soft text-paper-accent rounded-lg hover:bg-paper-accent hover:text-white transition-all duration-200", - to: Route::TagDetail { tag: tag.name.clone() }, + to: Route::TagDetail { + tag: tag.name.clone(), + }, "{tag.name}" sup { class: "ml-1 text-sm text-paper-secondary", "{tag.post_count}" } } @@ -76,9 +76,7 @@ fn TagsContent() -> Element { } Some(Err(_)) => { rsx! { - div { class: "text-center text-red-500 dark:text-red-400 py-20", - "加载失败" - } + div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败" } } } _ => { @@ -96,9 +94,7 @@ fn TagsContent() -> Element { pub fn TagDetail(tag: String) -> Element { rsx! { header { class: "page-header mb-6", - h1 { class: "text-4xl font-bold text-paper-primary tracking-tight", - "{tag}" - } + h1 { class: "text-4xl font-bold text-paper-primary tracking-tight", "{tag}" } } TagDetailContent { tag: tag.clone() } } @@ -127,15 +123,13 @@ fn TagDetailContent(tag: String) -> Element { " 篇文章" } for post in posts.iter() { - PostCard { post: post.clone() } + PostCard { key: "{post.id}", post: post.clone() } } } } Some(Err(_)) => { rsx! { - div { class: "text-center text-red-500 dark:text-red-400 py-20", - "加载失败" - } + div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败" } } } _ => { diff --git a/src/router.rs b/src/router.rs index 9cff4be..58c8f16 100644 --- a/src/router.rs +++ b/src/router.rs @@ -122,8 +122,7 @@ pub fn AppRouter() -> Element { document::Stylesheet { href: "/style.css" } document::Stylesheet { href: "/highlight.css" } document::Title { "Yggdrasil Blog" } - div { - class: "{theme_class}", + div { class: "{theme_class}", ThemePreload {} Router:: {} } diff --git a/src/theme.rs b/src/theme.rs index 20e7ee3..f1ea03a 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -150,9 +150,7 @@ const THEME_PRELOAD_SCRIPT: &str = r#" #[component] pub fn ThemePreload() -> Element { rsx! { - script { - dangerous_inner_html: "{THEME_PRELOAD_SCRIPT}", - } + script { dangerous_inner_html: "{THEME_PRELOAD_SCRIPT}" } } } @@ -174,9 +172,7 @@ pub fn ThemeToggle() -> Element { view_box: "0 -960 960 960", width: "24px", fill: "currentColor", - path { - d: "M484-80q-84 0-157.5-32t-128-86.5Q144-253 112-326.5T80-484q0-146 93-257.5T410-880q-18 99 11 193.5T521-521q71 71 165.5 100T880-410q-26 144-138 237T484-80Zm0-80q88 0 163-44t118-121q-86-8-163-43.5T464-465q-61-61-97-138t-43-163q-77 43-120.5 118.5T160-484q0 135 94.5 229.5T484-160Zm-20-305Z", - } + path { d: "M484-80q-84 0-157.5-32t-128-86.5Q144-253 112-326.5T80-484q0-146 93-257.5T410-880q-18 99 11 193.5T521-521q71 71 165.5 100T880-410q-26 144-138 237T484-80Zm0-80q88 0 163-44t118-121q-86-8-163-43.5T464-465q-61-61-97-138t-43-163q-77 43-120.5 118.5T160-484q0 135 94.5 229.5T484-160Zm-20-305Z" } } } else { svg { @@ -185,9 +181,7 @@ pub fn ThemeToggle() -> Element { view_box: "0 -960 960 960", width: "24px", fill: "currentColor", - path { - d: "M440-800v-120h80v120h-80Zm0 760v-120h80v120h-80Zm360-400v-80h120v80H800Zm-760 0v-80h120v80H40Zm708-252-56-56 70-72 58 58-72 70ZM198-140l-58-58 72-70 56 56-70 72Zm564 0-70-72 56-56 72 70-58 58ZM212-692l-72-70 58-58 70 72-56 56Zm98 382q-70-70-70-170t70-170q70-70 170-70t170 70q70 70 70 170t-70 170q-70 70-170 70t-170-70Zm283.5-56.5Q640-413 640-480t-46.5-113.5Q547-640 480-640t-113.5 46.5Q320-547 320-480t46.5 113.5Q413-320 480-320t113.5-46.5ZM480-480Z", - } + path { d: "M440-800v-120h80v120h-80Zm0 760v-120h80v120h-80Zm360-400v-80h120v80H800Zm-760 0v-80h120v80H40Zm708-252-56-56 70-72 58 58-72 70ZM198-140l-58-58 72-70 56 56-70 72Zm564 0-70-72 56-56 72 70-58 58ZM212-692l-72-70 58-58 70 72-56 56Zm98 382q-70-70-70-170t70-170q70-70 170-70t170 70q70 70 70 170t-70 170q-70 70-170 70t-170-70Zm283.5-56.5Q640-413 640-480t-46.5-113.5Q547-640 480-640t-113.5 46.5Q320-547 320-480t46.5 113.5Q413-320 480-320t113.5-46.5ZM480-480Z" } } } }