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:
parent
c836e3e1df
commit
0398cc6c66
@ -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"]
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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")),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
"审核中"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}" }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}, "← 返回首页" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}" }
|
||||
|
||||
@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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} }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
// 摘要第一行
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 框架)" }
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
"删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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", "加载失败" }
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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", "加载失败" }
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
||||
@ -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 {},
|
||||
"去登录"
|
||||
}
|
||||
|
||||
@ -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", "搜索失败" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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", "加载失败" }
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
||||
@ -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> {}
|
||||
}
|
||||
|
||||
12
src/theme.rs
12
src/theme.rs
@ -150,9 +150,7 @@ const THEME_PRELOAD_SCRIPT: &str = r#"
|
||||
#[component]
|
||||
pub fn ThemePreload() -> Element {
|
||||
rsx! {
|
||||
script {
|
||||
dangerous_inner_html: "{THEME_PRELOAD_SCRIPT}",
|
||||
}
|
||||
script { dangerous_inner_html: "{THEME_PRELOAD_SCRIPT}" }
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,9 +172,7 @@ pub fn ThemeToggle() -> Element {
|
||||
view_box: "0 -960 960 960",
|
||||
width: "24px",
|
||||
fill: "currentColor",
|
||||
path {
|
||||
d: "M484-80q-84 0-157.5-32t-128-86.5Q144-253 112-326.5T80-484q0-146 93-257.5T410-880q-18 99 11 193.5T521-521q71 71 165.5 100T880-410q-26 144-138 237T484-80Zm0-80q88 0 163-44t118-121q-86-8-163-43.5T464-465q-61-61-97-138t-43-163q-77 43-120.5 118.5T160-484q0 135 94.5 229.5T484-160Zm-20-305Z",
|
||||
}
|
||||
path { d: "M484-80q-84 0-157.5-32t-128-86.5Q144-253 112-326.5T80-484q0-146 93-257.5T410-880q-18 99 11 193.5T521-521q71 71 165.5 100T880-410q-26 144-138 237T484-80Zm0-80q88 0 163-44t118-121q-86-8-163-43.5T464-465q-61-61-97-138t-43-163q-77 43-120.5 118.5T160-484q0 135 94.5 229.5T484-160Zm-20-305Z" }
|
||||
}
|
||||
} else {
|
||||
svg {
|
||||
@ -185,9 +181,7 @@ pub fn ThemeToggle() -> Element {
|
||||
view_box: "0 -960 960 960",
|
||||
width: "24px",
|
||||
fill: "currentColor",
|
||||
path {
|
||||
d: "M440-800v-120h80v120h-80Zm0 760v-120h80v120h-80Zm360-400v-80h120v80H800Zm-760 0v-80h120v80H40Zm708-252-56-56 70-72 58 58-72 70ZM198-140l-58-58 72-70 56 56-70 72Zm564 0-70-72 56-56 72 70-58 58ZM212-692l-72-70 58-58 70 72-56 56Zm98 382q-70-70-70-170t70-170q70-70 170-70t170 70q70 70 70 170t-70 170q-70 70-170 70t-170-70Zm283.5-56.5Q640-413 640-480t-46.5-113.5Q547-640 480-640t-113.5 46.5Q320-547 320-480t46.5 113.5Q413-320 480-320t113.5-46.5ZM480-480Z",
|
||||
}
|
||||
path { d: "M440-800v-120h80v120h-80Zm0 760v-120h80v120h-80Zm360-400v-80h120v80H800Zm-760 0v-80h120v80H40Zm708-252-56-56 70-72 58 58-72 70ZM198-140l-58-58 72-70 56 56-70 72Zm564 0-70-72 56-56 72 70-58 58ZM212-692l-72-70 58-58 70 72-56 56Zm98 382q-70-70-70-170t70-170q70-70 170-70t170 70q70 70 70 170t-70 170q-70 70-170 70t-170-70Zm283.5-56.5Q640-413 640-480t-46.5-113.5Q547-640 480-640t-113.5 46.5Q320-547 320-480t46.5 113.5Q413-320 480-320t113.5-46.5ZM480-480Z" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user