xfy b207babdb6 refactor(admin): 统一按钮令牌,消除样式散落
将 7 个 admin 页面中复制粘贴/各自为政的按钮 class 收敛到 ui.rs 令牌:

主操作色系统一为主题绿(bg-paper-accent):
- dashboard/posts「发布文章」、write「发布/更新」+「确认」、runner 语言切换选中态
  改用 BTN_PRIMARY / BTN_PRIMARY_SM(原为深色 bg-paper-primary,现统一为绿)
- system 4 处刷新/执行/创建备份、trash 保存设置、write 发布按钮改用 LoadingButton
  统一 loading 态为 spinner 绝对居中叠加(按钮宽度不变,原 system 用文字切换、
  write 用整体变灰,现全部一致)

描边/图标/行内按钮统一:
- posts 重建、system 刷新列表 → BTN_OUTLINE
- trash 清空回收站 → BTN_DANGER_OUTLINE
- trash 步进 ± 按钮(2 处重复字符串)→ BTN_ICON
- write 关闭 × 按钮(2 处重复)→ BTN_CLOSE_ICON
- system BackupRow 恢复/删除绕过令牌 → BTN_TEXT_AMBER / BTN_TEXT_RED
  (删除色从 red-600 校正为令牌的 red-500,与其他行内删除一致)

消除约 15 处内联 class 字符串重复,system.rs 4 种主操作变体收敛为 1 种。
2026-07-06 00:17:25 +08:00

367 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 文章管理列表页面。
//!
//! 提供文章分页列表、删除单篇文章、重建 content_html 缓存,以及跳转到编辑器的能力。
//! 数据加载与写操作仅在 WASM 前端通过 Dioxus server functions 完成。
use dioxus::prelude::*;
use dioxus::router::components::Link;
// 分页数据接口list_posts 是 server function两端都生成wasm 端为 client stub
// server 端为真实实现),故无需 cfg。实际请求只在 use_paginated 的 wasm 分支发出。
use crate::api::posts::{list_posts, PostListResponse};
use crate::api::posts::{
delete_post, rebuild_content_html, rebuild_post_content_html, CreatePostResponse, RebuildResult,
};
use crate::components::empty_state::{EmptyState, EmptyStateAction};
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
use crate::components::ui::{
Pagination, StatusBadge, Tooltip, ADMIN_ROW_HOVER, ADMIN_TABLE_CLASS, BTN_OUTLINE,
BTN_PRIMARY, BTN_TEXT_ACCENT, BTN_TEXT_RED, SPINNER_SVG,
};
use crate::hooks::query::use_paginated;
use crate::models::post::PostListItem;
use crate::router::Route;
/// 每页展示的文章数量。
const POSTS_PER_PAGE: i32 = 20;
/// 文章管理入口组件,默认展示第 1 页。
#[component]
pub fn Posts() -> Element {
rsx! {
PostsPage { page: 1 }
}
}
/// 文章管理分页组件。
///
/// 根据 `page` 参数加载对应页的文章列表,支持删除单篇文章与重建文章 HTML 缓存。
#[component]
pub fn PostsPage(page: i32) -> Element {
let current_page = page.max(1);
// 分页列表加载loading / posts / total / error由 use_paginated 统一管理;
// 原先吞掉 Err现向 trash 看齐暴露 error signal保持一致性
let paginated = use_paginated(
move || current_page,
POSTS_PER_PAGE,
|p, pp| async move {
list_posts(p, pp)
.await
.map(|PostListResponse { posts, total }| (posts, total))
},
);
let mut posts = paginated.items;
let mut total = paginated.total;
let loading = paginated.loading;
let _error = paginated.error;
// 删除中 / 重建中文章 ID 集合:均由本组件持有(业务逻辑不归 hook 管)。
// 改为非乐观删除后行会保留至请求完成,可并发点多个删除,故用 HashSet
// 与 rebuilding 同形,按行通过 contains 判断 loading 态。
let mut deleting = use_signal(std::collections::HashSet::<i32>::new);
// 重建中文章 ID 集合:支持多篇文章并发重建(行不会随点击消失,单值会被后点
// 的覆盖先点的,故用 HashSet按行通过 contains 判断 loading 态。
let mut rebuilding = use_signal(std::collections::HashSet::<i32>::new);
// 重建缓存的状态由本组件持有并下发给 RebuildCacheBar结果消息也在本组件
// 渲染header 与表格之间的独立行),既不撑高 header 触发 items-center 重排,
// 也不脱离文档流溢进表格。rebuilding 仅按钮态用,不在此渲染。
// 不加 mut本组件只读信号并下发.set() 都在 RebuildCacheBar 的 spawn 块里,
// 走 Signal 的内部可变性SSR target 下那些 set 不可见mut 会触发 unused_mut。
let batch_rebuilding = use_signal(|| false);
let rebuild_result = use_signal(|| Option::<String>::None);
let get_posts = move || -> Vec<PostListItem> { posts() };
rsx! {
div { class: "w-full max-w-7xl mx-auto space-y-6",
div { class: "flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-paper-border mb-6",
div {
h1 { class: "text-4xl font-extrabold tracking-tight text-[var(--color-paper-primary)]", "管理文章" }
p { class: "text-base text-[var(--color-paper-secondary)] mt-2", "所有文章及草稿" }
}
div { class: "flex items-center gap-3",
// 重建缓存工具条(抽取为子组件 RebuildCacheBar见文件末尾
RebuildCacheBar {
rebuilding: batch_rebuilding,
rebuild_result: rebuild_result,
}
Link {
class: "{BTN_PRIMARY}",
to: Route::Write {},
"发布文章"
}
}
}
// 重建结果消息:独立成行,进入文档流,吃 space-y-6 的正常间距。
// 既不撑高 header不在 header 内)触发 items-center 重排,也不脱离流
// 溢进表格(曾用 absolute top-full mt-2因 28px > 24px 间距溢出 4px
if let Some(msg) = rebuild_result() {
div { class: "text-sm text-paper-secondary",
"{msg}"
}
}
if loading() && posts().is_empty() {
DelayedSkeleton { PostsSkeleton {} }
} else if posts().is_empty() {
EmptyState {
title: "暂无文章",
description: "还没有创建任何文章,开始写下你的第一篇文字吧。",
action: EmptyStateAction {
label: "写文章".to_string(),
to: Route::Write {},
},
}
} 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-44 text-right",
"操作"
}
}
}
tbody {
for post in get_posts().iter() {
PostRow {
key: "{post.id}",
post: post.clone(),
deleting: deleting().contains(&post.id),
rebuilding: rebuilding().contains(&post.id),
// 删除文章:非乐观——先标记 deleting 让按钮显示 loading
// 服务端成功后才移除行并减总数,失败则保留行并弹出浏览器提示。
on_delete: move |id| {
deleting.write().insert(id);
spawn(async move {
match delete_post(id).await {
Ok(CreatePostResponse { success: true, .. }) => {
posts.with_mut(|list| list.retain(|p| p.id != id));
total.with_mut(|t| *t = t.saturating_sub(1));
}
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());
}
}
deleting.write().remove(&id);
});
},
// 重建单篇文章内容:调用 server function 重新渲染 content_html。
// 静默执行,仅按行切换 rebuilding 按钮态,不弹窗。
// 用 HashSet 记录在途 ID支持多篇并发重建。
on_rebuild: move |id| {
rebuilding.write().insert(id);
spawn(async move {
let _ = rebuild_post_content_html(id).await;
rebuilding.write().remove(&id);
});
},
}
}
}
}
}
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: "",
}
}
}
}
}
/// 重建内容缓存工具条子组件。
///
/// 封装「重建内容 / 重建全部」两个按钮及其 `do_rebuild` 异步闭包。状态
/// (`rebuilding` / `rebuild_result`) 由父组件 `PostsPage` 持有并下发:
/// 结果消息在父组件渲染为 header 与表格之间的独立行(进入文档流,吃
/// `space-y-6` 的正常间距),既不撑高 header 触发 `items-center` 重排,
/// 也不脱离文档流溢进表格。
///
/// 从 `PostsPage` 抽取以降低 god component 复杂度(见 dioxus-render-purity skill
#[component]
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
fn RebuildCacheBar(
rebuilding: Signal<bool>,
rebuild_result: Signal<Option<String>>,
) -> Element {
// 重建文章渲染缓存rebuild_all 为 false 时仅重建 content_html 为空的文章,
// 为 true 时重建所有文章(用于语法/渲染逻辑升级后批量刷新已有内容)。
let mut do_rebuild = move |rebuild_all: bool| {
rebuilding.set(true);
rebuild_result.set(None);
spawn(async move {
match rebuild_content_html(rebuild_all).await {
Ok(RebuildResult {
rebuilt,
failed,
errors,
}) => {
if failed > 0 {
let mut msg = format!("已重建 {rebuilt} 篇,失败 {failed}");
if let Some(first) = errors.first() {
msg.push_str(&format!("\n{first}"));
}
rebuild_result.set(Some(msg));
} else {
rebuild_result.set(Some(format!("已重建 {rebuilt} 篇文章")));
}
}
Err(e) => {
rebuild_result.set(Some(format!("失败: {e}")));
}
}
rebuilding.set(false);
});
};
rsx! {
// 仅渲染按钮行本身:结果消息已上提到 PostsPage作为独立状态行进入文档流。
div { class: "flex items-center gap-3",
Tooltip {
tip: "重建 content_html 为空的文章渲染缓存".to_string(),
placement: "bottom",
button {
class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE },
disabled: rebuilding(),
onclick: move |_| do_rebuild(false),
span {
class: if rebuilding() { "opacity-40" } else { "" },
"重建内容"
}
if rebuilding() {
span {
class: "absolute inset-0 flex items-center justify-center",
dangerous_inner_html: SPINNER_SVG,
}
}
}
}
Tooltip {
tip: "重建所有文章的渲染缓存(含已有内容)".to_string(),
placement: "bottom",
button {
class: if rebuilding() { "relative px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-paper-secondary border border-paper-border" } else { BTN_OUTLINE },
disabled: rebuilding(),
onclick: move |_| do_rebuild(true),
span {
class: if rebuilding() { "opacity-40" } else { "" },
"重建全部"
}
if rebuilding() {
span {
class: "absolute inset-0 flex items-center justify-center",
dangerous_inner_html: SPINNER_SVG,
}
}
}
}
}
}
}
/// 文章表格行组件,展示单篇文章的标题、状态、日期与操作按钮。
#[component]
fn PostRow(
post: PostListItem,
deleting: bool,
rebuilding: bool,
on_delete: EventHandler<i32>,
on_rebuild: EventHandler<i32>,
) -> Element {
let date_str = post.formatted_date();
rsx! {
tr { class: "{ADMIN_ROW_HOVER}",
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(),
},
"{post.title}"
}
}
td { class: "px-4 py-3 text-center",
StatusBadge {
color_class: post.status_badge_class(),
label: post.status_label().to_string(),
}
}
td { class: "px-4 py-3 text-paper-secondary", "{date_str}" }
td { class: "px-4 py-3 text-right",
div { class: "flex justify-end items-center gap-3",
Link {
class: "text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer",
to: Route::WriteEdit { id: post.id },
"编辑"
}
Tooltip {
tip: "重新渲染这篇文章的 HTML".to_string(),
button {
class: if rebuilding {
"relative inline-flex items-center text-xs text-paper-accent cursor-not-allowed"
} else {
BTN_TEXT_ACCENT
},
disabled: rebuilding,
onclick: move |_| on_rebuild.call(post.id),
span {
class: if rebuilding { "opacity-40" } else { "" },
"重建"
}
if rebuilding {
span {
class: "absolute inset-0 flex items-center justify-center",
dangerous_inner_html: SPINNER_SVG,
}
}
}
}
button {
class: if deleting {
"relative inline-flex items-center text-xs text-paper-secondary cursor-not-allowed"
} else {
BTN_TEXT_RED
},
disabled: deleting,
onclick: move |_| on_delete.call(post.id),
span {
class: if deleting { "opacity-40" } else { "" },
"删除"
}
if deleting {
span {
class: "absolute inset-0 flex items-center justify-center",
dangerous_inner_html: SPINNER_SVG,
}
}
}
}
}
}
}
}