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" lto = "thin"
codegen-units = 1 codegen-units = 1
strip = "symbols" strip = "symbols"
# panic 直接终止WASM 去掉 unwind 元数据以减小体积server 端错误处理走
# Result + ?(见 src/api/error.rs不依赖 panic unwind进程级崩溃由
# systemd/k8s 拉起。这是 Dioxus 官方 optimizing 指南推荐的 WASM 瘦身项。
panic = "abort"
[features] [features]
default = ["web", "server"] default = ["web", "server"]

View File

@ -113,10 +113,8 @@ pub fn AdminLayout() -> Element {
(true, Some(_)) => { (true, Some(_)) => {
rsx! { rsx! {
div { class: "{root_class}", 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}", main { class: "{main_class}", Outlet::<Route> {} }
Outlet::<Route> {}
}
Footer {} Footer {}
} }
} }
@ -134,13 +132,19 @@ pub fn AdminLayout() -> Element {
// 使用与真实布局完全相同的结构包裹内容骨架,避免 checked 变化时的布局闪烁 // 使用与真实布局完全相同的结构包裹内容骨架,避免 checked 变化时的布局闪烁
rsx! { rsx! {
div { class: "{root_class}", 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}", main { class: "{main_class}",
div { class: if show_skeleton() { "" } else { "opacity-0" }, div { class: if show_skeleton() { "" } else { "opacity-0" },
{match route { {
Route::Write {} => rsx! { WriteSkeleton {} }, match route {
_ => rsx! { AdminDashboardSkeleton {} }, Route::Write {} => rsx! {
}} WriteSkeleton {}
},
_ => rsx! {
AdminDashboardSkeleton {}
},
}
}
} }
} }
Footer {} Footer {}

View File

@ -160,9 +160,7 @@ pub fn CommentForm(
} }
} }
p { class: "text-xs text-paper-tertiary", p { class: "text-xs text-paper-tertiary", "支持 Markdown 语法" }
"支持 Markdown 语法"
}
// 蜜罐字段:对普通用户隐藏,用于拦截简单机器人 // 蜜罐字段:对普通用户隐藏,用于拦截简单机器人
textarea { textarea {
@ -195,14 +193,13 @@ pub fn CommentForm(
return; 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"))); message.set(Some(("请填写所有必填项".to_string(), "error")));
return; return;
} }
submitting.set(true); submitting.set(true);
message.set(None); message.set(None);
spawn(async move { spawn(async move {
let result = create_comment( let result = create_comment(
post_id, post_id,
@ -212,40 +209,38 @@ pub fn CommentForm(
if url_val.trim().is_empty() { None } else { Some(url_val.clone()) }, if url_val.trim().is_empty() { None } else { Some(url_val.clone()) },
content.clone(), content.clone(),
hp.clone(), hp.clone(),
).await; )
.await;
submitting.set(false); submitting.set(false);
match result { match result {
Ok(resp) => { Ok(resp) => {
if resp.success { if resp.success {
comment_storage::save_author( comment_storage::save_author(&name, &email, &url_val);
&name,
&email,
&url_val,
);
if let Some(comment_id) = resp.comment_id { if let Some(comment_id) = resp.comment_id {
let avatar_url = resp.avatar_url.unwrap_or_default(); let avatar_url = resp.avatar_url.unwrap_or_default();
let depth = resp.depth.unwrap_or(0); let depth = resp.depth.unwrap_or(0);
let now = chrono::Utc::now().to_rfc3339(); let now = chrono::Utc::now().to_rfc3339();
let pending = PendingComment { let pending = PendingComment {
id: comment_id, id: comment_id,
parent_id, parent_id,
depth, depth,
author_name: name.clone(), 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, avatar_url,
content_md: content, content_md: content,
created_at: now.clone(), created_at: now.clone(),
stored_at: now, stored_at: now,
}; };
comment_storage::save_pending_comment(
comment_storage::save_pending_comment(post_id, pending.clone()); post_id,
pending.clone(),
);
pending_comments.write().push(pending); pending_comments.write().push(pending);
} }
content_md.set(String::new()); content_md.set(String::new());
message.set(Some((resp.message, "success"))); message.set(Some((resp.message, "success")));
if parent_id.is_some() { if parent_id.is_some() {
@ -257,7 +252,10 @@ pub fn CommentForm(
} }
} }
Err(_) => { 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! { _ => rsx! {
span { class: "font-medium text-paper-primary", span { class: "font-medium text-paper-primary", "{comment.author_name}" }
"{comment.author_name}"
}
}, },
}; };
rsx! { rsx! {
div { div { class: "py-4", style: "margin-left: {indent}px",
class: "py-4",
style: "margin-left: {indent}px",
div { class: "flex gap-3", div { class: "flex gap-3",
img { img {
@ -97,14 +93,22 @@ pub fn CommentItem(comment: PublicComment, post_id: i32) -> Element {
active_reply.set(Some(comment.id)); active_reply.set(Some(comment.id));
} }
}, },
if is_replying { "取消回复" } else { "回复" } if is_replying {
"取消回复"
} else {
"回复"
}
} }
} }
} }
if is_replying { 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! { _ => rsx! {
span { class: "font-medium text-paper-primary", span { class: "font-medium text-paper-primary", "{comment.author_name}" }
"{comment.author_name}"
}
}, },
}; };
rsx! { rsx! {
div { div { class: "py-4 opacity-70", style: "margin-left: {indent}px",
class: "py-4 opacity-70",
style: "margin-left: {indent}px",
div { class: "flex gap-3", div { class: "flex gap-3",
img { img {
@ -73,8 +69,7 @@ pub fn PendingCommentItem(comment: PendingComment, post_id: i32) -> Element {
title: "{comment.created_at}", title: "{comment.created_at}",
"{relative_time}" "{relative_time}"
} }
span { 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",
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; let has_any = approved_count > 0 || pending_count > 0;
rsx! { rsx! {
div { class: "space-y-8", div { class: "space-y-8",
h2 { class: "text-xl font-bold text-paper-primary", h2 { class: "text-xl font-bold text-paper-primary", "评论区 ({total_count})" }
"评论区 ({total_count})"
}
CommentForm { post_id, parent_id: None, parent_indent: None } CommentForm { post_id, parent_id: None, parent_indent: None }
@ -116,11 +114,11 @@ pub fn CommentSection(post_id: i32) -> Element {
} }
Some(Err(_)) => { Some(Err(_)) => {
rsx! { 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! {
None => rsx! { CommentListSkeleton {} }, CommentListSkeleton {}
},
} }
} }

View File

@ -114,9 +114,7 @@ pub fn Footer() -> Element {
view_box: "0 -960 960 960", view_box: "0 -960 960 960",
width: "24px", width: "24px",
fill: "currentColor", fill: "currentColor",
path { 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" }
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"), _ => ("bg-paper-code-bg", "text-paper-secondary"),
}; };
rsx! { rsx! {
div { class: "mb-4 p-3 {bg_class} {text_class} rounded-lg text-center", div { class: "mb-4 p-3 {bg_class} {text_class} rounded-lg text-center", "{message}" }
"{message}"
}
} }
} }

View File

@ -20,12 +20,24 @@ use crate::theme::ThemeToggle;
/// 根据当前前台路由选择对应的骨架屏组件。 /// 根据当前前台路由选择对应的骨架屏组件。
fn route_skeleton(route: &Route) -> Element { fn route_skeleton(route: &Route) -> Element {
match route { match route {
Route::Archives {} => rsx! { DelayedSkeleton { ArchiveSkeleton {} } }, Route::Archives {} => rsx! {
Route::Tags {} | Route::TagDetail { .. } => rsx! { DelayedSkeleton { TagsSkeleton {} } }, DelayedSkeleton { ArchiveSkeleton {} }
Route::Search {} => rsx! { DelayedSkeleton { SearchSkeleton {} } }, },
Route::PostDetail { .. } => rsx! { DelayedSkeleton { PostDetailSkeleton {} } }, Route::Tags {} | Route::TagDetail { .. } => rsx! {
Route::NotFound { .. } => rsx! { div { class: "py-20 md:py-28" } }, DelayedSkeleton { TagsSkeleton {} }
_ => rsx! { DelayedSkeleton { HomeSkeleton {} } }, },
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! { rsx! {
div { class: "min-h-screen flex flex-col bg-paper-theme", div { class: "min-h-screen flex flex-col bg-paper-theme",
Header { nav_items, right_content: rsx! { ThemeToggle {} } } Header {
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6", nav_items,
SuspenseBoundary { right_content: rsx! {
fallback: move |_| route_skeleton(&route), ThemeToggle {}
Outlet::<Route> {} },
} }
main { class: "flex-1 w-full max-w-3xl mx-auto px-6 py-6",
SuspenseBoundary { fallback: move |_| route_skeleton(&route), Outlet::<Route> {} }
} }
Footer {} 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", ul { class: "hidden md:flex items-center gap-1",
for item in nav_items.iter().cloned() { for item in nav_items.iter().cloned() {
NavItem { NavItem {
key: "{item.label}",
route: item.route, route: item.route,
label: item.label, label: item.label,
is_active: item.is_active, is_active: item.is_active,
@ -75,7 +76,7 @@ pub fn Header(nav_items: Vec<NavItemConfig>, right_content: Element) -> Element
stroke_linecap: "round", stroke_linecap: "round",
stroke_linejoin: "round", stroke_linejoin: "round",
stroke_width: "2", stroke_width: "2",
d: "M6 18L18 6M6 6l12 12" d: "M6 18L18 6M6 6l12 12",
} }
} }
} else { } else {
@ -89,7 +90,7 @@ pub fn Header(nav_items: Vec<NavItemConfig>, right_content: Element) -> Element
stroke_linecap: "round", stroke_linecap: "round",
stroke_linejoin: "round", stroke_linejoin: "round",
stroke_width: "2", 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", class: "md:hidden border-t border-paper-border bg-paper-theme/95 backdrop-blur-sm",
ul { class: "py-2 px-6 space-y-1", ul { class: "py-2 px-6 space-y-1",
for item in nav_items.iter().cloned() { for item in nav_items.iter().cloned() {
li { li { key: "{item.label}",
MobileNavItem { MobileNavItem {
route: item.route, route: item.route,
label: item.label, label: item.label,
@ -135,11 +136,7 @@ fn NavItem(route: Route, label: &'static str, is_active: bool) -> Element {
rsx! { rsx! {
li { li {
Link { Link { class: "{class_str}", to: route, "{label}" }
class: "{class_str}",
to: route,
"{label}"
}
} }
} }
} }

View File

@ -20,10 +20,7 @@ pub fn Breadcrumbs(title: String) -> Element {
class: "breadcrumbs", class: "breadcrumbs",
role: "navigation", role: "navigation",
aria_label: "Breadcrumb", aria_label: "Breadcrumb",
Link { Link { to: Route::Home {}, "Home" }
to: Route::Home {},
"Home"
}
svg { svg {
xmlns: "http://www.w3.org/2000/svg", xmlns: "http://www.w3.org/2000/svg",
view_box: "0 0 24 24", view_box: "0 0 24 24",

View File

@ -31,7 +31,7 @@ pub fn PostContent(content_html: String) -> Element {
rsx! { rsx! {
div { div {
class: "post-content md-content", 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() { if !tags.is_empty() {
ul { class: "post-tags", ul { class: "post-tags",
for tag in tags.into_iter() { for tag in tags.into_iter() {
li { li { key: "{tag}",
Link { Link {
to: Route::TagDetail { tag: tag.clone() }, to: Route::TagDetail {
tag: tag.clone(),
},
"{tag}" "{tag}"
} }
} }
@ -38,17 +40,11 @@ pub fn PostFooter(post: Post) -> Element {
} }
if post.prev_post.is_some() || post.next_post.is_some() { if post.prev_post.is_some() || post.next_post.is_some() {
PostNavLinks { PostNavLinks { prev: post.prev_post, next: post.next_post }
prev: post.prev_post,
next: post.next_post
}
} }
div { class: "back-to-home", div { class: "back-to-home",
Link { Link { to: Route::Home {}, "← 返回首页" }
to: Route::Home {},
"← 返回首页"
}
} }
} }
} }

View File

@ -27,17 +27,13 @@ pub fn PostHeader(post: Post) -> Element {
h1 { class: "post-title", h1 { class: "post-title",
"{post.title}" "{post.title}"
if post.status == PostStatus::Draft { if post.status == PostStatus::Draft {
span { span { class: "entry-hint", title: "Draft",
class: "entry-hint",
title: "Draft",
svg { svg {
xmlns: "http://www.w3.org/2000/svg", xmlns: "http://www.w3.org/2000/svg",
height: "35", height: "35",
view_box: "0 -960 960 960", view_box: "0 -960 960 960",
fill: "currentColor", fill: "currentColor",
path { 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" }
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 { if let Some(prev_post) = prev {
Link { Link {
class: "prev", class: "prev",
to: Route::PostDetail { slug: prev_post.slug.clone() }, to: Route::PostDetail {
slug: prev_post.slug.clone(),
},
onclick: move |_evt: dioxus::events::MouseEvent| {}, onclick: move |_evt: dioxus::events::MouseEvent| {},
span { class: "title", "« Prev" } span { class: "title", "« Prev" }
span { class: "post-title-nav", "{prev_post.title}" } 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 { if let Some(next_post) = next {
Link { Link {
class: "next", class: "next",
to: Route::PostDetail { slug: next_post.slug.clone() }, to: Route::PostDetail {
slug: next_post.slug.clone(),
},
onclick: move |_evt: dioxus::events::MouseEvent| {}, onclick: move |_evt: dioxus::events::MouseEvent| {},
span { class: "title", "Next »" } span { class: "title", "Next »" }
span { class: "post-title-nav", "{next_post.title}" } span { class: "post-title-nav", "{next_post.title}" }

View File

@ -14,15 +14,10 @@ use dioxus::prelude::*;
pub fn PostToc(toc_html: String) -> Element { pub fn PostToc(toc_html: String) -> Element {
rsx! { rsx! {
details { class: "toc", details { class: "toc",
summary { summary { accesskey: "c", title: "(Alt + C)",
accesskey: "c",
title: "(Alt + C)",
span { class: "title", "Table of Contents" } span { class: "title", "Table of Contents" }
} }
div { div { class: "inner", dangerous_inner_html: "{toc_html}" }
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(); let has_cover = post.cover_image.is_some();
rsx! { rsx! {
article { 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",
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 { if has_cover {
div { div { class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg",
class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg", div { class: "blur-img post-card-cover-blur",
div {
class: "blur-img post-card-cover-blur",
img { img {
class: "blur-img-placeholder", class: "blur-img-placeholder",
src: "{cover_src}?w=20", src: "{cover_src}?w=20",
@ -53,25 +50,24 @@ pub fn PostCard(post: PostListItem) -> Element {
} }
} }
} }
h2 { h2 { class: "text-2xl font-bold leading-tight text-paper-primary group-hover:text-paper-accent transition-colors duration-200",
class: "text-2xl font-bold leading-tight text-paper-primary group-hover:text-paper-accent transition-colors duration-200",
"{post.title}" "{post.title}"
} }
div { div { class: "mt-2 text-sm text-paper-secondary leading-relaxed line-clamp-2",
class: "mt-2 text-sm text-paper-secondary leading-relaxed line-clamp-2",
"{post.summary.as_deref().unwrap_or_default()}" "{post.summary.as_deref().unwrap_or_default()}"
} }
div { div { class: "mt-3 flex items-center gap-3 text-[13px] text-paper-secondary",
class: "mt-3 flex items-center gap-3 text-[13px] text-paper-secondary",
span { "{date_str}" } span { "{date_str}" }
if !post.tags.is_empty() { if !post.tags.is_empty() {
span { "·" } span { "·" }
for tag in post.tags.clone().into_iter() { for tag in post.tags.clone().into_iter() {
span { span { key: "{tag}",
// 标签叠在覆盖链接之上,点击进入标签详情页而非文章详情。 // 标签叠在覆盖链接之上,点击进入标签详情页而非文章详情。
Link { Link {
class: "relative z-10 hover:text-paper-accent transition-colors duration-200", 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(), onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(),
"{tag}" "{tag}"
} }
@ -86,7 +82,9 @@ pub fn PostCard(post: PostListItem) -> Element {
Link { Link {
class: "absolute inset-0 z-[2]", class: "absolute inset-0 z-[2]",
aria_label: "post link to {post.title}", 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! { rsx! {
div { div { class: if pulsing() { "animate-pulse" } else { "" }, {children} }
class: if pulsing() { "animate-pulse" } else { "" },
{children}
}
} }
} }

View File

@ -12,8 +12,7 @@ use crate::components::skeletons::atoms::SkeletonBox;
#[component] #[component]
pub fn PostCardSkeleton() -> Element { pub fn PostCardSkeleton() -> Element {
rsx! { rsx! {
article { article { class: "mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333]",
class: "mb-6 p-6 bg-white dark:bg-[#2e2e33] rounded-lg border border-gray-200 dark:border-[#333]",
// 标题占位 (模拟 h2 text-2xl font-bold) // 标题占位 (模拟 h2 text-2xl font-bold)
SkeletonBox { class: "h-7 w-3/4 rounded mb-3" } 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", table { class: "w-full text-sm",
thead { thead {
tr { class: "border-b border-paper-border", 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",
th { class: "px-4 py-3 w-24", SkeletonBox { class: "h-3 w-10 mx-auto" } } SkeletonBox { class: "h-3 w-10" }
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 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 { tbody {
for _ in 0..10 { for _ in 0..10 {
tr { class: "border-b border-paper-border last:border-0", 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",
td { class: "px-4 py-3", SkeletonBox { class: "h-5 w-14 mx-auto rounded" } } SkeletonBox { class: "h-4 w-1/3" }
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-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! { rsx! {
nav { class: if is_admin { "flex mt-6 justify-between" } else { "flex mt-10 mb-6 justify-between" }, nav { class: if is_admin { "flex mt-6 justify-between" } else { "flex mt-10 mb-6 justify-between" },
if has_prev { if has_prev {
Link { Link { class: "{link_class}", to: prev_route,
class: "{link_class}",
to: prev_route,
span { class: "mr-1", "«" } span { class: "mr-1", "«" }
"上一页" "上一页"
} }
@ -140,9 +138,7 @@ pub fn Pagination(
} }
if has_next { if has_next {
Link { Link { class: "{link_class} {link_extra_next}", to: next_route,
class: "{link_class} {link_extra_next}",
to: next_route,
"下一页" "下一页"
span { class: "ml-1", "»" } span { class: "ml-1", "»" }
} }
@ -168,9 +164,7 @@ pub fn Pagination(
#[component] #[component]
pub fn StatusBadge(color_class: &'static str, label: String) -> Element { pub fn StatusBadge(color_class: &'static str, label: String) -> Element {
rsx! { rsx! {
span { class: "{BADGE_BASE} {color_class}", span { class: "{BADGE_BASE} {color_class}", "{label}" }
"{label}"
}
} }
} }

View File

@ -14,13 +14,13 @@ use dioxus::prelude::*;
pub fn About() -> Element { pub fn About() -> Element {
rsx! { rsx! {
header { class: "page-header mb-6", 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", article { class: "prose dark:prose-invert max-w-none text-paper-content leading-relaxed",
p { "Yggdrasil 是一个以文字为主的简约博客系统。" } 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", "技术栈" } h2 { class: "text-xl font-bold text-paper-primary mt-8 mb-4", "技术栈" }
ul { class: "list-disc pl-5 space-y-1", ul { class: "list-disc pl-5 space-y-1",
li { "Rust + Dioxus 0.7 (全栈 Web 框架)" } li { "Rust + Dioxus 0.7 (全栈 Web 框架)" }

View File

@ -30,7 +30,9 @@ const COMMENTS_PER_PAGE: i32 = 20;
/// 评论管理入口组件,默认展示第 1 页。 /// 评论管理入口组件,默认展示第 1 页。
#[component] #[component]
pub fn AdminComments() -> Element { pub fn AdminComments() -> Element {
rsx! { AdminCommentsPage { page: 1 } } rsx! {
AdminCommentsPage { page: 1 }
}
} }
/// 评论管理分页组件。 /// 评论管理分页组件。
@ -124,18 +126,18 @@ pub fn AdminCommentsPage(page: i32) -> Element {
rsx! { rsx! {
div { class: "space-y-6", 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", 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 { button {
class: if active_filter() == status { 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" },
"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()), onclick: move |_| active_filter.set(status.to_string()),
"{label}" "{label}"
} }
@ -143,20 +145,22 @@ pub fn AdminCommentsPage(page: i32) -> Element {
} }
if !selected_ids().is_empty() { if !selected_ids().is_empty() {
{ rsx! { {
rsx! {
div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg", div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg",
span { class: "text-sm text-paper-secondary", span { class: "text-sm text-paper-secondary", "已选择 {selected_ids().len()} 条" }
"已选择 {selected_ids().len()} 条"
}
button { button {
class: "{BTN_SOLID_GREEN}", class: "{BTN_SOLID_GREEN}",
onclick: move |_| { onclick: move |_| {
let ids: Vec<i64> = selected_ids().iter().copied().collect(); let ids: Vec<i64> = selected_ids().iter().copied().collect();
let ids_for_api = ids.clone(); let ids_for_api = ids.clone();
spawn(async move { spawn(async move {
let _ = batch_update_comment_status(ids_for_api, "approved".to_string()).await; let _ = batch_update_comment_status(ids_for_api, "approved".to_string())
.await;
}); });
for id in &ids { set_comment_status(*id, CommentStatus::Approved); } for id in &ids {
set_comment_status(*id, CommentStatus::Approved);
}
selected_ids.set(HashSet::new()); selected_ids.set(HashSet::new());
}, },
"批量通过" "批量通过"
@ -169,7 +173,9 @@ pub fn AdminCommentsPage(page: i32) -> Element {
spawn(async move { spawn(async move {
let _ = batch_update_comment_status(ids_for_api, "spam".to_string()).await; let _ = batch_update_comment_status(ids_for_api, "spam".to_string()).await;
}); });
for id in &ids { set_comment_status(*id, CommentStatus::Spam); } for id in &ids {
set_comment_status(*id, CommentStatus::Spam);
}
selected_ids.set(HashSet::new()); selected_ids.set(HashSet::new());
}, },
"批量垃圾" "批量垃圾"
@ -180,15 +186,20 @@ pub fn AdminCommentsPage(page: i32) -> Element {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
if web_sys::window() if web_sys::window()
.and_then(|w| w.confirm_with_message("确定要删除这些评论吗?").ok()) .and_then(|w| {
w.confirm_with_message("确定要删除这些评论吗?").ok()
})
.unwrap_or(false) .unwrap_or(false)
{ {
let ids: Vec<i64> = selected_ids().iter().copied().collect(); let ids: Vec<i64> = selected_ids().iter().copied().collect();
let ids_for_api = ids.clone(); let ids_for_api = ids.clone();
spawn(async move { spawn(async move {
let _ = batch_update_comment_status(ids_for_api, "trash".to_string()).await; let _ = batch_update_comment_status(ids_for_api, "trash".to_string())
.await;
}); });
for id in &ids { remove_comment(*id); } for id in &ids {
remove_comment(*id);
}
selected_ids.set(HashSet::new()); selected_ids.set(HashSet::new());
} }
} }
@ -196,12 +207,15 @@ pub fn AdminCommentsPage(page: i32) -> Element {
"批量删除" "批量删除"
} }
} }
} } }
}
} }
{ {
if error().is_some() { if error().is_some() {
rsx! { EmptyState { message: "加载失败", variant: "error" } } rsx! {
EmptyState { message: "加载失败", variant: "error" }
}
} else if loading() && comments().is_empty() { } else if loading() && comments().is_empty() {
rsx! { rsx! {
DelayedSkeleton { DelayedSkeleton {
@ -218,7 +232,9 @@ pub fn AdminCommentsPage(page: i32) -> Element {
} }
} }
} else if comments().is_empty() { } else if comments().is_empty() {
rsx! { EmptyState { message: "暂无评论", variant: "default" } } rsx! {
EmptyState { message: "暂无评论", variant: "default" }
}
} else { } else {
let list = comments(); let list = comments();
let all_selected = list.iter().all(|c| selected_ids().contains(&c.id)); let all_selected = list.iter().all(|c| selected_ids().contains(&c.id));
@ -238,13 +254,17 @@ pub fn AdminCommentsPage(page: i32) -> Element {
move |_| { move |_| {
let mut s = selected_ids(); let mut s = selected_ids();
if all_selected { if all_selected {
for id in &all_ids { s.remove(id); } for id in &all_ids {
s.remove(id);
}
} else { } else {
for id in &all_ids { s.insert(*id); } for id in &all_ids {
s.insert(*id);
}
} }
selected_ids.set(s); selected_ids.set(s);
} }
} },
} }
} }
th { class: "px-4 py-3 font-medium", "作者" } th { class: "px-4 py-3 font-medium", "作者" }
@ -265,7 +285,11 @@ pub fn AdminCommentsPage(page: i32) -> Element {
let id = comment.id; let id = comment.id;
move |checked: bool| { move |checked: bool| {
let mut s = selected_ids(); 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); selected_ids.set(s);
} }
}, },
@ -293,7 +317,9 @@ pub fn AdminCommentsPage(page: i32) -> Element {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
if web_sys::window() if web_sys::window()
.and_then(|w| w.confirm_with_message("确定要删除这条评论吗?").ok()) .and_then(|w| {
w.confirm_with_message("确定要删除这条评论吗?").ok()
})
.unwrap_or(false) .unwrap_or(false)
{ {
spawn(async move { spawn(async move {
@ -315,12 +341,12 @@ pub fn AdminCommentsPage(page: i32) -> Element {
current_page, current_page,
total: total(), total: total(),
per_page: COMMENTS_PER_PAGE, per_page: COMMENTS_PER_PAGE,
prev_route: if current_page - 1 <= 1 { prev_route: if current_page - 1 <= 1 { Route::AdminComments {} } else { Route::AdminCommentsPage {
Route::AdminComments {} page: current_page - 1,
} else { } },
Route::AdminCommentsPage { page: current_page - 1 } next_route: Route::AdminCommentsPage {
page: current_page + 1,
}, },
next_route: Route::AdminCommentsPage { page: current_page + 1 },
unit: "", unit: "",
} }
} }
@ -377,21 +403,19 @@ fn CommentRow(
div { class: "text-sm font-medium text-paper-primary truncate", div { class: "text-sm font-medium text-paper-primary truncate",
"{comment.author_name}" "{comment.author_name}"
} }
div { class: "text-xs text-paper-secondary truncate", div { class: "text-xs text-paper-secondary truncate", "{comment.author_email}" }
"{comment.author_email}"
}
} }
} }
} }
td { class: "px-4 py-3 max-w-xs", td { class: "px-4 py-3 max-w-xs",
p { class: "text-sm text-paper-secondary truncate", p { class: "text-sm text-paper-secondary truncate", "{preview}" }
"{preview}"
}
} }
td { class: "px-4 py-3", td { class: "px-4 py-3",
Link { Link {
class: "text-sm text-paper-primary hover:text-paper-accent transition-colors", 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}" "{comment.post_title}"
} }
} }
@ -399,17 +423,23 @@ fn CommentRow(
StatusBadge { StatusBadge {
// badge_class 是 &'static str 字面量匹配,转为静态生命周期。 // badge_class 是 &'static str 字面量匹配,转为静态生命周期。
color_class: match &comment.status { color_class: match &comment.status {
CommentStatus::Pending => "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", CommentStatus::Pending => {
CommentStatus::Approved => "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-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::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, label: status_label,
} }
} }
td { class: "px-4 py-3 text-sm text-paper-secondary", td { class: "px-4 py-3 text-sm text-paper-secondary", "{date_str}" }
"{date_str}"
}
td { class: "px-4 py-3 text-right", td { class: "px-4 py-3 text-right",
div { class: "flex justify-end gap-2", div { class: "flex justify-end gap-2",
if !matches!(comment.status, CommentStatus::Approved) { if !matches!(comment.status, CommentStatus::Approved) {

View File

@ -87,12 +87,8 @@ pub fn Admin() -> Element {
match pending_count() { match pending_count() {
Some(count) => { Some(count) => {
rsx! { rsx! {
div { class: "text-3xl font-bold text-amber-600 dark:text-amber-400", div { class: "text-3xl font-bold text-amber-600 dark:text-amber-400", "{count}" }
"{count}" div { class: "text-sm text-paper-secondary mt-2", "待审核评论" }
}
div { class: "text-sm text-paper-secondary mt-2",
"待审核评论"
}
} }
} }
None => { None => {
@ -118,15 +114,13 @@ pub fn Admin() -> Element {
} }
div { class: "mb-8", 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() { match recent_posts() {
Some(posts) => { Some(posts) => {
rsx! { rsx! {
div { class: "space-y-0", div { class: "space-y-0",
for post in posts.iter().take(5) { 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 { fn StatCard(value: String, label: String) -> Element {
rsx! { rsx! {
div { class: "{ADMIN_CARD_CLASS} p-6 text-center", div { class: "{ADMIN_CARD_CLASS} p-6 text-center",
div { class: "text-3xl font-bold text-paper-primary", div { class: "text-3xl font-bold text-paper-primary", "{value}" }
"{value}" div { class: "text-sm text-paper-secondary mt-2", "{label}" }
}
div { class: "text-sm text-paper-secondary mt-2",
"{label}"
}
} }
} }
} }
@ -174,16 +164,10 @@ fn RecentPostItem(post: PostListItem) -> Element {
rsx! { rsx! {
div { class: "flex justify-between items-center py-3 border-b border-paper-border", div { class: "flex justify-between items-center py-3 border-b border-paper-border",
div { class: "flex items-center gap-3", div { class: "flex items-center gap-3",
span { class: "text-paper-primary", span { class: "text-paper-primary", "{post.title}" }
"{post.title}" span { class: "text-xs {status_class}", "{status_label}" }
}
span { class: "text-xs {status_class}",
"{status_label}"
}
}
span { class: "text-sm text-paper-secondary",
"{date_str}"
} }
span { class: "text-sm text-paper-secondary", "{date_str}" }
} }
} }
} }

View File

@ -24,7 +24,9 @@ const POSTS_PER_PAGE: i32 = 20;
/// 文章管理入口组件,默认展示第 1 页。 /// 文章管理入口组件,默认展示第 1 页。
#[component] #[component]
pub fn Posts() -> Element { pub fn Posts() -> Element {
rsx! { PostsPage { page: 1 } } rsx! {
PostsPage { page: 1 }
}
} }
/// 文章管理分页组件。 /// 文章管理分页组件。
@ -101,20 +103,18 @@ pub fn PostsPage(page: i32) -> Element {
rsx! { rsx! {
div { class: "space-y-6", div { class: "space-y-6",
div { class: "flex items-center justify-between", 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: "flex items-center gap-3",
div { class: "group relative", div { class: "group relative",
button { button {
class: if rebuilding() { 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" },
"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(), disabled: rebuilding(),
onclick: move |_| do_rebuild(false), 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", 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 为空的文章渲染缓存" "重建 content_html 为空的文章渲染缓存"
@ -122,14 +122,14 @@ pub fn PostsPage(page: i32) -> Element {
} }
div { class: "group relative", div { class: "group relative",
button { button {
class: if rebuilding() { 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" },
"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(), disabled: rebuilding(),
onclick: move |_| do_rebuild(true), 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", 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() { if let Some(msg) = rebuild_result() {
div { class: "text-sm text-paper-secondary px-1", div { class: "text-sm text-paper-secondary px-1", "{msg}" }
"{msg}"
}
} }
if loading() && posts().is_empty() { if loading() && posts().is_empty() {
@ -159,14 +157,19 @@ pub fn PostsPage(page: i32) -> Element {
thead { thead {
tr { class: "border-b border-paper-border text-left text-paper-secondary", 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", "标题" }
th { class: "px-4 py-3 font-medium w-24 text-center", "状态" } 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-32", "日期" }
th { class: "px-4 py-3 font-medium w-24 text-right", "操作" } th { class: "px-4 py-3 font-medium w-24 text-right",
"操作"
}
} }
} }
tbody { tbody {
for post in get_posts().iter() { for post in get_posts().iter() {
PostRow { PostRow {
key: "{post.id}",
post: post.clone(), post: post.clone(),
deleting: deleting() == Some(post.id), deleting: deleting() == Some(post.id),
// 删除文章:先乐观更新本地列表,再调用 server function失败时弹出浏览器提示。 // 删除文章:先乐观更新本地列表,再调用 server function失败时弹出浏览器提示。
@ -189,7 +192,7 @@ pub fn PostsPage(page: i32) -> Element {
} }
deleting.set(None); deleting.set(None);
}); });
} },
} }
} }
} }
@ -200,12 +203,12 @@ pub fn PostsPage(page: i32) -> Element {
current_page, current_page,
total: total(), total: total(),
per_page: POSTS_PER_PAGE, per_page: POSTS_PER_PAGE,
prev_route: if current_page - 1 <= 1 { prev_route: if current_page - 1 <= 1 { Route::Posts {} } else { Route::PostsPage {
Route::Posts {} page: current_page - 1,
} else { } },
Route::PostsPage { page: current_page - 1 } next_route: Route::PostsPage {
page: current_page + 1,
}, },
next_route: Route::PostsPage { page: current_page + 1 },
unit: "", unit: "",
} }
} }
@ -223,7 +226,9 @@ fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler<i32>) ->
td { class: "px-4 py-3", td { class: "px-4 py-3",
Link { Link {
class: "text-paper-primary hover:text-paper-accent transition-colors cursor-pointer", 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}" "{post.title}"
} }
} }
@ -233,9 +238,7 @@ fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler<i32>) ->
label: post.status_label().to_string(), label: post.status_label().to_string(),
} }
} }
td { class: "px-4 py-3 text-paper-secondary", td { class: "px-4 py-3 text-paper-secondary", "{date_str}" }
"{date_str}"
}
td { class: "px-4 py-3 text-right", td { class: "px-4 py-3 text-right",
div { class: "flex justify-end gap-3", div { class: "flex justify-end gap-3",
Link { Link {
@ -244,14 +247,14 @@ fn PostRow(post: PostListItem, deleting: bool, on_delete: EventHandler<i32>) ->
"编辑" "编辑"
} }
button { button {
class: if deleting { class: if deleting { "text-xs text-paper-secondary cursor-not-allowed" } else { BTN_TEXT_RED },
"text-xs text-paper-secondary cursor-not-allowed"
} else {
BTN_TEXT_RED
},
disabled: deleting, disabled: deleting,
onclick: move |_| on_delete.call(post.id), 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 页。 /// 回收站入口组件,默认展示第 1 页。
#[component] #[component]
pub fn Trash() -> Element { 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", div { class: "flex items-center gap-3",
h1 { class: "text-2xl font-bold text-paper-primary", "回收站" } h1 { class: "text-2xl font-bold text-paper-primary", "回收站" }
span { class: "text-sm text-paper-secondary", span { class: "text-sm text-paper-secondary", "共 {total()} 篇" }
"共 {total()} 篇"
}
} }
// 自动清理配置卡片:可折叠的设置面板,顶部始终显示当前状态摘要 // 自动清理配置卡片:可折叠的设置面板,顶部始终显示当前状态摘要
div { div { class: "rounded-xl border border-paper-border overflow-hidden bg-paper-entry",
class: "rounded-xl border border-paper-border overflow-hidden bg-paper-entry",
// 顶部可点击摘要条:状态指示灯 + 标题 + 展开箭头 // 顶部可点击摘要条:状态指示灯 + 标题 + 展开箭头
button { 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", 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,7 +141,8 @@ pub fn TrashPage(page: i32) -> Element {
just_saved.set(false); just_saved.set(false);
}, },
// 状态指示灯 // 状态指示灯
{let dot_class = if settings().auto_purge_enabled { {
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)]" "w-2 h-2 rounded-full bg-paper-accent shadow-[0_0_0_3px_rgba(92,122,94,0.15)]"
} else { } else {
"w-2 h-2 rounded-full bg-paper-tertiary" "w-2 h-2 rounded-full bg-paper-tertiary"
@ -151,12 +151,11 @@ pub fn TrashPage(page: i32) -> Element {
div { class: "w-2 flex-shrink-0 flex items-center justify-center", div { class: "w-2 flex-shrink-0 flex items-center justify-center",
div { class: "{dot_class}" } div { class: "{dot_class}" }
} }
}} }
}
// 标题 + 当前状态描述 // 标题 + 当前状态描述
div { class: "flex-1 min-w-0", 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", div { class: "text-xs text-paper-secondary mt-0.5 truncate",
if settings().auto_purge_enabled { if settings().auto_purge_enabled {
"已开启 · 超过 {settings().retention_days} 天的文章将被自动删除" "已开启 · 超过 {settings().retention_days} 天的文章将被自动删除"
@ -172,7 +171,11 @@ pub fn TrashPage(page: i32) -> Element {
fill: "none", fill: "none",
stroke: "currentColor", stroke: "currentColor",
stroke_width: "2", 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() { if settings_panel_open() {
div { class: "border-t border-paper-border p-5 space-y-6", div { class: "border-t border-paper-border p-5 space-y-6",
// 开关行:启用自动清理 // 开关行:启用自动清理
div { div { class: "flex items-center justify-between gap-4",
class: "flex items-center justify-between gap-4",
div { class: "min-w-0", div { class: "min-w-0",
div { class: "text-sm font-medium text-paper-primary", div { class: "text-sm font-medium text-paper-primary",
"启用自动清理" "启用自动清理"
@ -194,23 +196,13 @@ pub fn TrashPage(page: i32) -> Element {
button { button {
role: "switch", role: "switch",
aria_checked: "{settings_draft_enabled()}", aria_checked: "{settings_draft_enabled()}",
class: if 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" },
"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 |_| { onclick: move |_| {
settings_draft_enabled.set(!settings_draft_enabled()); settings_draft_enabled.set(!settings_draft_enabled());
just_saved.set(false); just_saved.set(false);
}, },
// 滑块圆点 // 滑块圆点
span { 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" } }
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() { if just_saved() {
span { class: "inline-flex items-center gap-1.5 text-xs text-paper-accent", 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", svg {
path { stroke_linecap: "round", stroke_linejoin: "round", d: "M5 13l4 4L19 7" } 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 { } else if dirty {
span { class: "text-xs text-paper-secondary", span { class: "text-xs text-paper-secondary", "有未保存的更改" }
"有未保存的更改"
}
} else { } else {
span { class: "text-xs text-transparent select-none", "·" } span { class: "text-xs text-transparent select-none",
"·"
}
} }
// 保存按钮:启用主题色,禁用/保存中态灰化 // 保存按钮:启用主题色,禁用/保存中态灰化
button { button {
class: if saving_settings() { 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" },
"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, disabled: saving_settings() || just_saved() || !dirty,
onclick: move |_| { onclick: move |_| {
let days: i32 = settings_draft_days().parse().unwrap_or(30); let days: i32 = settings_draft_days().parse().unwrap_or(30);
@ -309,7 +304,11 @@ pub fn TrashPage(page: i32) -> Element {
saving_settings.set(false); 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() { if !selected_ids().is_empty() {
div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg", div { class: "flex items-center gap-3 p-3 bg-paper-theme rounded-lg",
span { class: "text-sm text-paper-secondary", span { class: "text-sm text-paper-secondary", "已选择 {selected_ids().len()} 条" }
"已选择 {selected_ids().len()} 条"
}
button { button {
class: "{BTN_SOLID_GREEN}", class: "{BTN_SOLID_GREEN}",
onclick: move |_| { onclick: move |_| {
@ -329,7 +326,9 @@ pub fn TrashPage(page: i32) -> Element {
spawn(async move { spawn(async move {
let _ = batch_restore_posts(ids).await; 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()); selected_ids.set(HashSet::new());
}, },
"批量恢复" "批量恢复"
@ -340,14 +339,22 @@ pub fn TrashPage(page: i32) -> Element {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
if web_sys::window() if web_sys::window()
.and_then(|w| w.confirm_with_message("确定要彻底删除选中的文章吗?此操作不可恢复。").ok()) .and_then(|w| {
w
.confirm_with_message(
"确定要彻底删除选中的文章吗?此操作不可恢复。",
)
.ok()
}
.unwrap_or(false) .unwrap_or(false)
{ {
let ids: Vec<i32> = selected_ids().iter().copied().collect(); let ids: Vec<i32> = selected_ids().iter().copied().collect();
spawn(async move { spawn(async move {
let _ = batch_purge_posts(ids).await; 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()); selected_ids.set(HashSet::new());
} }
} }
@ -360,7 +367,9 @@ pub fn TrashPage(page: i32) -> Element {
// 主内容:错误 / 加载骨架 / 空态 / 列表 // 主内容:错误 / 加载骨架 / 空态 / 列表
{ {
if error().is_some() { if error().is_some() {
rsx! { EmptyState { message: "加载失败", variant: "error" } } rsx! {
EmptyState { message: "加载失败", variant: "error" }
}
} else if loading() && posts().is_empty() { } else if loading() && posts().is_empty() {
rsx! { rsx! {
DelayedSkeleton { DelayedSkeleton {
@ -372,7 +381,9 @@ pub fn TrashPage(page: i32) -> Element {
} }
} }
} else if posts().is_empty() { } else if posts().is_empty() {
rsx! { EmptyState { message: "回收站为空", variant: "default" } } rsx! {
EmptyState { message: "回收站为空", variant: "default" }
}
} else { } else {
let list = posts(); let list = posts();
let all_selected = list.iter().all(|p| selected_ids().contains(&p.id)); let all_selected = list.iter().all(|p| selected_ids().contains(&p.id));
@ -392,17 +403,21 @@ pub fn TrashPage(page: i32) -> Element {
move |_| { move |_| {
let mut s = selected_ids(); let mut s = selected_ids();
if all_selected { if all_selected {
for id in &all_ids { s.remove(id); } for id in &all_ids {
s.remove(id);
}
} else { } else {
for id in &all_ids { s.insert(*id); } for id in &all_ids {
s.insert(*id);
}
} }
selected_ids.set(s); 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", "原状态" } // 底部:清空回收站 + 分页
th { class: "px-4 py-3 font-medium w-28", "删除时间" } 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-24 text-center", "剩余" }
th { class: "px-4 py-3 font-medium w-32 text-right", "操作" } 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; let id = post.id;
move |checked: bool| { move |checked: bool| {
let mut s = selected_ids(); 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); selected_ids.set(s);
} }
}, },
@ -438,7 +457,13 @@ pub fn TrashPage(page: i32) -> Element {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
if web_sys::window() if web_sys::window()
.and_then(|w| w.confirm_with_message("确定要彻底删除这篇文章吗?此操作不可恢复。").ok()) .and_then(|w| {
w
.confirm_with_message(
"确定要彻底删除这篇文章吗?此操作不可恢复。",
)
.ok()
})
.unwrap_or(false) .unwrap_or(false)
{ {
spawn(async move { spawn(async move {
@ -463,7 +488,13 @@ pub fn TrashPage(page: i32) -> Element {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
if web_sys::window() if web_sys::window()
.and_then(|w| w.confirm_with_message("确定要清空回收站吗?所有已删除文章将被彻底移除,此操作不可恢复。").ok()) .and_then(|w| {
w
.confirm_with_message(
"确定要清空回收站吗?所有已删除文章将被彻底移除,此操作不可恢复。",
)
.ok()
}
.unwrap_or(false) .unwrap_or(false)
{ {
spawn(async move { spawn(async move {
@ -483,12 +514,12 @@ pub fn TrashPage(page: i32) -> Element {
current_page, current_page,
total: total(), total: total(),
per_page: TRASH_PER_PAGE, per_page: TRASH_PER_PAGE,
prev_route: if current_page - 1 <= 1 { prev_route: if current_page - 1 <= 1 { Route::Trash {} } else { Route::TrashPage {
Route::Trash {} page: current_page - 1,
} else { } },
Route::TrashPage { page: current_page - 1 } next_route: Route::TrashPage {
page: current_page + 1,
}, },
next_route: Route::TrashPage { page: current_page + 1 },
unit: "", unit: "",
} }
} }
@ -567,14 +598,9 @@ fn TrashRow(
label: post.status_label().to_string(), label: post.status_label().to_string(),
} }
} }
td { class: "px-4 py-3 text-sm text-paper-secondary", td { class: "px-4 py-3 text-sm text-paper-secondary", "{deleted_str}" }
"{deleted_str}"
}
td { class: "px-4 py-3 text-center", td { class: "px-4 py-3 text-center",
StatusBadge { StatusBadge { color_class: badge_class, label: badge_text }
color_class: badge_class,
label: badge_text,
}
} }
td { class: "px-4 py-3 text-right", td { class: "px-4 py-3 text-right",
div { class: "flex justify-end gap-2", div { class: "flex justify-end gap-2",

View File

@ -423,9 +423,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
rsx! { rsx! {
div { class: "relative flex flex-col", div { class: "relative flex flex-col",
if loading() { if loading() {
div { class: "absolute inset-0 z-10 bg-paper-theme", div { class: "absolute inset-0 z-10 bg-paper-theme", WriteSkeleton {} }
WriteSkeleton {}
}
} }
// 整页自然滚动:元信息 + 编辑器 + 错误提示 + 底部操作栏一起随浏览器滚动。 // 整页自然滚动:元信息 + 编辑器 + 错误提示 + 底部操作栏一起随浏览器滚动。
@ -454,9 +452,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
// 元数据行 - 紧凑精致 // 元数据行 - 紧凑精致
div { class: "flex flex-wrap items-end gap-x-8 gap-y-4 text-sm", div { class: "flex flex-wrap items-end gap-x-8 gap-y-4 text-sm",
div { class: "flex-1 min-w-[140px]", div { class: "flex-1 min-w-[140px]",
label { class: "{META_LABEL_CLASS}", label { class: "{META_LABEL_CLASS}", "Slug" }
"Slug"
}
input { input {
class: "{META_INPUT_CLASS}", class: "{META_INPUT_CLASS}",
placeholder: "自动生成", placeholder: "自动生成",
@ -465,9 +461,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
} }
} }
div { class: "flex-1 min-w-[140px]", div { class: "flex-1 min-w-[140px]",
label { class: "{META_LABEL_CLASS}", label { class: "{META_LABEL_CLASS}", "标签" }
"标签"
}
input { input {
class: "{META_INPUT_CLASS}", class: "{META_INPUT_CLASS}",
placeholder: "逗号分隔", placeholder: "逗号分隔",
@ -483,18 +477,8 @@ fn write_editor(post_id: Option<i32>) -> Element {
div { div {
class: "relative w-full rounded-xl border border-dashed overflow-hidden transition-all duration-200 group/cover", class: "relative w-full rounded-xl border border-dashed overflow-hidden transition-all duration-200 group/cover",
// 空态矮横条;有图/上传中展开成 21:9。 // 空态矮横条;有图/上传中展开成 21:9。
class: if cover_image().is_empty() && !cover_uploading() { class: if cover_image().is_empty() && !cover_uploading() { "h-14" } else { "aspect-[21/9]" },
"h-14" 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)]" },
} 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 必须 prevent_default否则浏览器直接打开文件
ondragover: move |evt| { ondragover: move |evt| {
@ -563,7 +547,12 @@ fn write_editor(post_id: Option<i32>) -> Element {
let cv = cover_image(); let cv = cover_image();
if cv.starts_with("/uploads/") { if cv.starts_with("/uploads/") {
let base = cv.split('?').next().unwrap_or(&cv); let base = cv.split('?').next().unwrap_or(&cv);
if cv.contains('?') { format!("{}&w=600", base) } else { format!("{}?w=600", base) } if cv.contains('?') {
format!("{}&w=600", base)
}
}
format!("{}?w=600", base)
}
} else { } else {
cv cv
} }
@ -744,7 +733,9 @@ fn write_editor(post_id: Option<i32>) -> Element {
// 上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符(避免孤儿) // 上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符(避免孤儿)
for err in upload_errors().clone() { 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", 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}" } span { "图片上传失败: {err.file_name} — {err.message}" }
button { button {
class: "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none", class: "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none",
@ -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: "w-px h-5 bg-[var(--color-paper-border)]" }
div { div { class: "relative inline-flex items-center px-3 py-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer",
class: "relative inline-flex items-center px-3 py-1.5 text-sm text-[var(--color-paper-secondary)] cursor-pointer",
select { select {
class: "absolute inset-0 w-full h-full opacity-0 cursor-pointer", class: "absolute inset-0 w-full h-full opacity-0 cursor-pointer",
style: "appearance: none; -webkit-appearance: none;", style: "appearance: none; -webkit-appearance: none;",
@ -789,7 +779,11 @@ fn write_editor(post_id: Option<i32>) -> Element {
option { value: "published", "发布" } option { value: "published", "发布" }
} }
span { class: "pr-1.5 text-[var(--color-paper-primary)] font-medium", span { class: "pr-1.5 text-[var(--color-paper-primary)] font-medium",
if status() == "draft" { "草稿" } else { "发布" } if status() == "draft" {
"草稿"
} else {
"发布"
}
} }
svg { svg {
class: "h-3.5 w-3.5 text-[var(--color-paper-tertiary)] pointer-events-none", 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 { path {
fill_rule: "evenodd", 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", 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)]" } div { class: "w-px h-5 bg-[var(--color-paper-border)]" }
button { button {
class: if saving() { 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" },
"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(), disabled: saving(),
onclick: on_submit, onclick: on_submit,
"{save_button_text}" "{save_button_text}"

View File

@ -101,9 +101,7 @@ fn group_posts(posts: &[PostListItem]) -> Vec<YearGroup> {
pub fn Archives() -> Element { pub fn Archives() -> Element {
rsx! { rsx! {
header { class: "page-header mb-6", 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 {} ArchivesContent {}
} }
@ -129,15 +127,16 @@ fn ArchivesContent() -> Element {
" 篇文章" " 篇文章"
} }
for year_group in grouped.iter() { 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)) => { Some(Err(e)) => {
rsx! { 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", "加载失败: {e}" }
"加载失败: {e}"
}
} }
} }
None => { None => {
@ -170,7 +169,11 @@ fn YearSection(year_group: YearGroup) -> Element {
sup { class: "archive-count text-sm text-paper-secondary ml-1", "{total}" } sup { class: "archive-count text-sm text-paper-secondary ml-1", "{total}" }
} }
for month_group in year_group.months.iter() { 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", div { class: "archive-posts flex-1",
for post in month_group.posts.iter() { 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", h3 { class: "archive-entry-title text-base font-normal text-paper-primary m-0",
"{post.title}" "{post.title}"
} }
div { class: "archive-meta text-sm text-paper-secondary mt-1", div { class: "archive-meta text-sm text-paper-secondary mt-1", "{date_str}" }
"{date_str}"
}
Link { Link {
class: "entry-link absolute inset-0 z-10", class: "entry-link absolute inset-0 z-10",
aria_label: "post link to {post.title}", 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。 /// 直接委托给 `HomePage` 并固定页码为 1。
#[component] #[component]
pub fn Home() -> Element { pub fn Home() -> Element {
rsx! { HomePage { page: 1 } } rsx! {
HomePage { page: 1 }
}
} }
/// 首页分页组件,对应路由 `/page/:page`。 /// 首页分页组件,对应路由 `/page/:page`。
@ -62,7 +64,7 @@ fn HomePosts(current_page: i32) -> Element {
Some(Ok((posts, total))) => { Some(Ok((posts, total))) => {
rsx! { rsx! {
for post in posts.iter() { for post in posts.iter() {
PostCard { post: post.clone() } PostCard { key: "{post.id}", post: post.clone() }
} }
// total == 0 表示站点确实无文章:显示空状态,且不渲染分页。 // total == 0 表示站点确实无文章:显示空状态,且不渲染分页。
// 注意total > 0 但 posts 为空(如越界页码 /page/9999也不显示空状态 // 注意total > 0 但 posts 为空(如越界页码 /page/9999也不显示空状态
@ -81,12 +83,12 @@ fn HomePosts(current_page: i32) -> Element {
current_page, current_page,
total, total,
per_page: POSTS_PER_PAGE, per_page: POSTS_PER_PAGE,
prev_route: if current_page - 1 <= 1 { prev_route: if current_page - 1 <= 1 { Route::Home {} } else { Route::HomePage {
Route::Home {} page: current_page - 1,
} else { } },
Route::HomePage { page: current_page - 1 } next_route: Route::HomePage {
page: current_page + 1,
}, },
next_route: Route::HomePage { page: current_page + 1 },
unit: "", unit: "",
} }
} }
@ -95,9 +97,7 @@ fn HomePosts(current_page: i32) -> Element {
// 不透传内部错误细节,统一展示通用文案(与标签页等其它页面一致)。 // 不透传内部错误细节,统一展示通用文案(与标签页等其它页面一致)。
Some(Err(_)) => { Some(Err(_)) => {
rsx! { 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 { class: "space-y-4",
div { div {
FormLabel { label: "用户名 / 邮箱", html_for: Some("login-username".to_string()) } FormLabel {
label: "用户名 / 邮箱",
html_for: Some("login-username".to_string()),
}
FormInput { FormInput {
id: Some("login-username".to_string()), id: Some("login-username".to_string()),
r#type: "text", r#type: "text",
@ -81,11 +84,20 @@ pub fn Login() -> Element {
disabled: is_loading, disabled: is_loading,
oninput: move |v: String| username.set(v), 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 { div {
FormLabel { label: "密码", html_for: Some("login-password".to_string()) } FormLabel {
label: "密码",
html_for: Some("login-password".to_string()),
}
FormInput { FormInput {
id: Some("login-password".to_string()), id: Some("login-password".to_string()),
r#type: "password", r#type: "password",
@ -93,7 +105,13 @@ pub fn Login() -> Element {
value: password(), value: password(),
disabled: is_loading, disabled: is_loading,
oninput: move |v: String| password.set(v), 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 { button {
@ -101,7 +119,11 @@ pub fn Login() -> Element {
class: if is_loading { "opacity-60 cursor-not-allowed" }, class: if is_loading { "opacity-60 cursor-not-allowed" },
disabled: is_loading, disabled: is_loading,
onclick: move |_| on_submit(()), onclick: move |_| on_submit(()),
if is_loading { "登录中..." } else { "登录" } if is_loading {
"登录中..."
} else {
"登录"
}
} }
Link { 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", 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() } PostToc { toc_html: toc.clone() }
} }
PostContent { PostContent { content_html: post.content_html.clone().unwrap_or_default() }
content_html: post.content_html.clone().unwrap_or_default()
}
PostFooter { post: post.clone() } PostFooter { post: post.clone() }
@ -86,9 +84,7 @@ pub fn PostDetail(slug: String) -> Element {
Some(Err("not_found")) => { Some(Err("not_found")) => {
rsx! { rsx! {
div { class: "text-center py-20", 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", p { class: "text-paper-secondary mb-6",
"这篇文章可能已被删除或移动。" "这篇文章可能已被删除或移动。"
} }
@ -102,9 +98,7 @@ pub fn PostDetail(slug: String) -> Element {
} }
Some(Err("error")) => { Some(Err("error")) => {
rsx! { 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() { 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", 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 {}, to: Route::Login {},
"去登录" "去登录"
} }
@ -96,7 +97,10 @@ pub fn Register() -> Element {
div { class: "space-y-4", div { class: "space-y-4",
div { div {
FormLabel { label: "用户名", html_for: Some("register-username".to_string()) } FormLabel {
label: "用户名",
html_for: Some("register-username".to_string()),
}
FormInput { FormInput {
id: Some("register-username".to_string()), id: Some("register-username".to_string()),
r#type: "text", r#type: "text",
@ -108,7 +112,10 @@ pub fn Register() -> Element {
} }
} }
div { div {
FormLabel { label: "邮箱", html_for: Some("register-email".to_string()) } FormLabel {
label: "邮箱",
html_for: Some("register-email".to_string()),
}
FormInput { FormInput {
id: Some("register-email".to_string()), id: Some("register-email".to_string()),
r#type: "email", r#type: "email",
@ -120,7 +127,10 @@ pub fn Register() -> Element {
} }
} }
div { div {
FormLabel { label: "密码", html_for: Some("register-password".to_string()) } FormLabel {
label: "密码",
html_for: Some("register-password".to_string()),
}
FormInput { FormInput {
id: Some("register-password".to_string()), id: Some("register-password".to_string()),
r#type: "password", r#type: "password",
@ -132,7 +142,10 @@ pub fn Register() -> Element {
} }
} }
div { div {
FormLabel { label: "确认密码", html_for: Some("register-confirm-password".to_string()) } FormLabel {
label: "确认密码",
html_for: Some("register-confirm-password".to_string()),
}
FormInput { FormInput {
id: Some("register-confirm-password".to_string()), id: Some("register-confirm-password".to_string()),
r#type: "password", r#type: "password",
@ -148,12 +161,17 @@ pub fn Register() -> Element {
class: if is_loading { "opacity-60 cursor-not-allowed" }, class: if is_loading { "opacity-60 cursor-not-allowed" },
disabled: is_loading, disabled: is_loading,
onclick: move |_| on_submit(()), onclick: move |_| on_submit(()),
if is_loading { "注册中..." } else { "注册" } if is_loading {
"注册中..."
} else {
"注册"
}
} }
} }
p { class: "mt-4 text-center text-sm text-paper-secondary", 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 {}, to: Route::Login {},
"去登录" "去登录"
} }

View File

@ -44,9 +44,7 @@ pub fn Search() -> Element {
rsx! { rsx! {
header { class: "page-header mb-6", 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: "mb-8",
div { class: "flex gap-2", div { class: "flex gap-2",
@ -56,7 +54,11 @@ pub fn Search() -> Element {
placeholder: "输入关键词搜索文章...", placeholder: "输入关键词搜索文章...",
value: query(), value: query(),
oninput: move |e| query.set(e.value()), 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 { 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", 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 {} } DelayedSkeleton { SearchSkeleton {} }
} else if let Some(Ok(PostListResponse { posts, total: _ })) = search_res() { } else if let Some(Ok(PostListResponse { posts, total: _ })) = search_res() {
if posts.is_empty() { if posts.is_empty() {
div { class: "text-center text-paper-secondary py-20", div { class: "text-center text-paper-secondary py-20", "未找到相关文章" }
"未找到相关文章"
}
} else { } else {
for post in posts.iter() { for post in posts.iter() {
PostCard { post: post.clone() } PostCard { key: "{post.id}", post: post.clone() }
} }
} }
} else if search_res() } else if search_res().as_ref().map(|r| r.is_err()).unwrap_or(false) {
.as_ref() div { class: "text-center text-red-500 dark:text-red-400 py-20", "搜索失败" }
.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 { pub fn Tags() -> Element {
rsx! { rsx! {
header { class: "page-header mb-6", 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 {} TagsContent {}
} }
@ -62,10 +60,12 @@ fn TagsContent() -> Element {
} }
ul { class: "flex flex-wrap gap-4 mt-6", ul { class: "flex flex-wrap gap-4 mt-6",
for tag in tags { for tag in tags {
li { li { key: "{tag.name}",
Link { 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", 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}" "{tag.name}"
sup { class: "ml-1 text-sm text-paper-secondary", "{tag.post_count}" } sup { class: "ml-1 text-sm text-paper-secondary", "{tag.post_count}" }
} }
@ -76,9 +76,7 @@ fn TagsContent() -> Element {
} }
Some(Err(_)) => { Some(Err(_)) => {
rsx! { 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 { pub fn TagDetail(tag: String) -> Element {
rsx! { rsx! {
header { class: "page-header mb-6", 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", "{tag}" }
"{tag}"
}
} }
TagDetailContent { tag: tag.clone() } TagDetailContent { tag: tag.clone() }
} }
@ -127,15 +123,13 @@ fn TagDetailContent(tag: String) -> Element {
" 篇文章" " 篇文章"
} }
for post in posts.iter() { for post in posts.iter() {
PostCard { post: post.clone() } PostCard { key: "{post.id}", post: post.clone() }
} }
} }
} }
Some(Err(_)) => { Some(Err(_)) => {
rsx! { 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: "/style.css" }
document::Stylesheet { href: "/highlight.css" } document::Stylesheet { href: "/highlight.css" }
document::Title { "Yggdrasil Blog" } document::Title { "Yggdrasil Blog" }
div { div { class: "{theme_class}",
class: "{theme_class}",
ThemePreload {} ThemePreload {}
Router::<Route> {} Router::<Route> {}
} }

View File

@ -150,9 +150,7 @@ const THEME_PRELOAD_SCRIPT: &str = r#"
#[component] #[component]
pub fn ThemePreload() -> Element { pub fn ThemePreload() -> Element {
rsx! { rsx! {
script { script { dangerous_inner_html: "{THEME_PRELOAD_SCRIPT}" }
dangerous_inner_html: "{THEME_PRELOAD_SCRIPT}",
}
} }
} }
@ -174,9 +172,7 @@ pub fn ThemeToggle() -> Element {
view_box: "0 -960 960 960", view_box: "0 -960 960 960",
width: "24px", width: "24px",
fill: "currentColor", fill: "currentColor",
path { 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" }
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 { } else {
svg { svg {
@ -185,9 +181,7 @@ pub fn ThemeToggle() -> Element {
view_box: "0 -960 960 960", view_box: "0 -960 960 960",
width: "24px", width: "24px",
fill: "currentColor", fill: "currentColor",
path { 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" }
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",
}
} }
} }
} }