perf(render): 列表加显式 key + release 启用 panic=abort

依据 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。
This commit is contained in:
xfy 2026-06-25 18:07:22 +08:00
parent c836e3e1df
commit 0398cc6c66
36 changed files with 856 additions and 803 deletions

View File

@ -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"]

View File

@ -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::<Route> {}
}
Header { nav_items: admin_nav_items, right_content }
main { class: "{main_class}", Outlet::<Route> {} }
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 {}

View File

@ -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")),
);
}
}
});

View File

@ -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),
}
}
}
}

View File

@ -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",
"审核中"
}
}

View File

@ -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 {}
},
}
}

View File

@ -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" }
}
}
}

View File

@ -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}" }
}
}

View File

@ -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::<Route> {}
}
SuspenseBoundary { fallback: move |_| route_skeleton(&route), Outlet::<Route> {} }
}
Footer {}
}

View File

@ -47,6 +47,7 @@ pub fn Header(nav_items: Vec<NavItemConfig>, 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<NavItemConfig>, 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<NavItemConfig>, 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<NavItemConfig>, 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}" }
}
}
}

View File

@ -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",

View File

@ -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}",
}
}
}

View File

@ -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 {}, "← 返回首页" }
}
}
}

View File

@ -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" }
}
}
}

View File

@ -22,7 +22,9 @@ pub fn PostNavLinks(prev: Option<PostNav>, next: Option<PostNav>) -> 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<PostNav>, next: Option<PostNav>) -> 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}" }

View File

@ -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}" }
}
}
}

View File

@ -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,
},
}
}
}

View File

@ -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} }
}
}

View File

@ -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" }
// 摘要第一行

View File

@ -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" }
}
}
}
}

View File

@ -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}" }
}
}

View File

@ -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 框架)" }

View File

@ -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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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) {

View File

@ -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}" }
}
}
}

View File

@ -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<i32>) ->
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<i32>) ->
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<i32>) ->
"编辑"
}
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 {
"删除"
}
}
}
}

View File

@ -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<i32> = 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",

View File

@ -423,349 +423,340 @@ fn write_editor(post_id: Option<i32>) -> 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::<web_sys::ClipboardEvent>() {
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::<web_sys::ClipboardEvent>() {
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<i32>) -> 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<i32>) -> 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<i32>) -> 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}"

View File

@ -101,9 +101,7 @@ fn group_posts(posts: &[PostListItem]) -> Vec<YearGroup> {
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(),
},
}
}
}

View File

@ -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", "加载失败" }
}
}
_ => {

View File

@ -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",

View File

@ -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", "加载失败" }
}
}
_ => {

View File

@ -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 {},
"去登录"
}

View File

@ -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", "搜索失败" }
}
}
}

View File

@ -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", "加载失败" }
}
}
_ => {

View File

@ -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::<Route> {}
}

View File

@ -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" }
}
}
}