From 61634882c5ae4f75ebb207a8f64dee8200782050 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 27 Jul 2026 14:39:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=BB=AA=E8=A1=A8=E7=9B=98?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=88=86=E5=9D=97=E8=BF=9B=E5=9C=BA=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E4=B8=8E=E6=95=B0=E5=AD=97=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatCard 三张卡 animate-page-enter 阶梯入场(0/60/120ms),待审评论卡数据就绪后补挂入场类(180ms),近期文章行 animate-row-enter 逐行 stagger(50ms 步进)——动画挂在数据就绪的真实分支上,避免骨架屏截断 - 新增 CountUp 组件:数值 easeOutQuint 滚动(500ms),use_effect 内驱动保持渲染纯净;命中 prefers-reduced-motion 直接显示终值 - input.css 新增 row-enter 关键帧;reduced-motion 媒体查询补全 animate-page-enter/popover-enter/row-enter 覆盖(此前仅 page-enter 生效) - BTN_SECONDARY 补 active:scale-[0.98] 按压反馈,与 BTN_PRIMARY 对齐 --- input.css | 22 ++++++++- src/components/ui.rs | 2 +- src/pages/admin/dashboard.rs | 96 +++++++++++++++++++++++++++++++----- 3 files changed, 105 insertions(+), 15 deletions(-) diff --git a/input.css b/input.css index c42ad1b..4ba5ed1 100644 --- a/input.css +++ b/input.css @@ -83,6 +83,23 @@ animation: popover-enter 150ms cubic-bezier(0.22, 1, 0.36, 1) both; } + /* 列表行进场(比 page-enter 更轻的位移,供 admin 列表逐行 stagger 使用, + 配合内联 style="animation-delay: Nms" 与 both fill-mode 实现阶梯入场) */ + @keyframes row-enter { + 0% { + opacity: 0; + transform: translateY(8px); + } + 100% { + opacity: 1; + transform: none; + } + } + + .animate-row-enter { + animation: row-enter 300ms cubic-bezier(0.22, 1, 0.36, 1) both; + } + /* Minimalist custom scrollbars */ * { scrollbar-width: thin; @@ -1080,7 +1097,10 @@ transition: none; } - .page-enter { + .page-enter, + .animate-page-enter, + .animate-popover-enter, + .animate-row-enter { animation: none; } diff --git a/src/components/ui.rs b/src/components/ui.rs index d9652ca..a291b68 100644 --- a/src/components/ui.rs +++ b/src/components/ui.rs @@ -67,7 +67,7 @@ pub const BTN_TEXT_ACCENT: &str = /// 次要按钮:极简风次要操作。 pub const BTN_SECONDARY: &str = - "px-6 py-2.5 rounded-full text-sm font-medium text-center text-[var(--color-paper-secondary)] bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-border)] hover:text-[var(--color-paper-primary)] transition-all cursor-pointer"; + "px-6 py-2.5 rounded-full text-sm font-medium text-center text-[var(--color-paper-secondary)] bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-border)] hover:text-[var(--color-paper-primary)] active:scale-[0.98] transition-all cursor-pointer"; // --- 主操作按钮(主题绿实心胶囊,全站统一 CTA) --- diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs index c78ff30..c7546d3 100644 --- a/src/pages/admin/dashboard.rs +++ b/src/pages/admin/dashboard.rs @@ -50,6 +50,13 @@ pub fn Admin() -> Element { } }); + // 待审卡片进场类:数据未就绪(骨架屏)时为空,就绪后补挂以触发一次入场动画。 + let pending_enter_class = if pending_count().is_some() { + "animate-page-enter" + } else { + "" + }; + rsx! { div { class: "w-full max-w-7xl mx-auto space-y-8", // 顶部标题和全局操作栏 @@ -74,19 +81,22 @@ pub fn Admin() -> Element { Some(s) => { rsx! { StatCard { - value: s.total.to_string(), + value: s.total, label: "总文章数".to_string(), trend: "+12%".to_string(), + delay_ms: 0, } StatCard { - value: s.published.to_string(), + value: s.published, label: "已发布".to_string(), trend: "活跃".to_string(), + delay_ms: 60, } StatCard { - value: s.drafts.to_string(), + value: s.drafts, label: "草稿".to_string(), trend: "待处理".to_string(), + delay_ms: 120, } } } @@ -103,8 +113,11 @@ pub fn Admin() -> Element { } // 评论待办卡片 (独立色块突出) + // 数据就绪后补挂 animate-page-enter:类名变更触发 CSS 动画从 0% 播放, + // 骨架屏阶段不播(避免动画被骨架屏截断,见 yggdrasil-ui-design-taste 规范)。 Link { - class: "block {ADMIN_CARD_CLASS} p-8 bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-border)]/20 transition-all h-36 flex flex-col justify-between group hover:-translate-y-1 hover:shadow-md duration-300", + class: "block {ADMIN_CARD_CLASS} p-8 bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-border)]/20 transition-all h-36 flex flex-col justify-between group hover:-translate-y-1 hover:shadow-md duration-300 {pending_enter_class}", + style: "animation-delay: 180ms", to: Route::AdminComments {}, match pending_count() { Some(count) => { @@ -119,7 +132,10 @@ pub fn Admin() -> Element { rsx! { div { class: "text-sm font-medium {color_class}", "待审评论" } div { class: "flex items-baseline justify-between mt-4", - div { class: "text-4xl font-light tracking-tight {text_class}", "{count}" } + CountUp { + target: count, + class: format!("text-4xl font-light tracking-tight {text_class}"), + } div { class: "text-xs font-medium text-[var(--color-paper-secondary)] group-hover:text-[var(--color-paper-primary)] transition-colors", "去审核 →" } @@ -162,8 +178,12 @@ pub fn Admin() -> Element { rsx! { div { class: "{ADMIN_TABLE_CLASS}", div { class: "divide-y divide-paper-border", - for post in posts.iter().take(5) { - RecentPostItem { key: "{post.id}", post: post.clone() } + for (i , post) in posts.iter().take(5).enumerate() { + RecentPostItem { + key: "{post.id}", + post: post.clone(), + delay_ms: (i as i32) * 50, + } } } } @@ -191,9 +211,11 @@ pub fn Admin() -> Element { } #[component] -fn StatCard(value: String, label: String, trend: String) -> Element { +fn StatCard(value: i64, label: String, trend: String, delay_ms: i32) -> Element { rsx! { - div { class: "{ADMIN_CARD_CLASS} p-8 flex flex-col justify-between h-36 relative group hover:-translate-y-1 hover:shadow-md transition-all duration-300", + div { + class: "{ADMIN_CARD_CLASS} p-8 flex flex-col justify-between h-36 relative group hover:-translate-y-1 hover:shadow-md transition-all duration-300 animate-page-enter", + style: "animation-delay: {delay_ms}ms", div { class: "flex justify-between items-start", div { class: "text-sm font-medium text-[var(--color-paper-secondary)]", "{label}" @@ -202,21 +224,69 @@ fn StatCard(value: String, label: String, trend: String) -> Element { "{trend}" } } - div { class: "text-4xl font-light tracking-tight text-[var(--color-paper-primary)] mt-4", - "{value}" + CountUp { + target: value, + class: "text-4xl font-light tracking-tight text-[var(--color-paper-primary)] mt-4".to_string(), } } } } +/// 数字滚动组件:值从 0 以 easeOutQuint 缓动递增到 `target`(约 500ms)。 +/// +/// 命中 `prefers-reduced-motion` 时直接显示终值。动画在 `use_effect` 内驱动, +/// 渲染体保持纯净(见 dioxus-render-purity 规范);数据仅 WASM 端加载,SSR 不挂载本组件。 #[component] -fn RecentPostItem(post: PostListItem) -> Element { +fn CountUp(target: i64, class: String) -> Element { + let mut display = use_signal(|| 0i64); + + use_effect(move || { + #[cfg(target_arch = "wasm32")] + spawn(async move { + let reduced = web_sys::window() + .and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()) + .map(|m| m.matches()) + .unwrap_or(false); + if reduced || target <= 0 { + display.set(target); + return; + } + const DURATION_MS: i64 = 500; + let start = crate::utils::time::now_millis(); + loop { + crate::utils::time::sleep_ms(16).await; + let elapsed = crate::utils::time::now_millis() - start; + if elapsed >= DURATION_MS { + display.set(target); + break; + } + let t = elapsed as f64 / DURATION_MS as f64; + // easeOutQuint,与 CSS 侧 cubic-bezier(0.22, 1, 0.36, 1) 同族。 + let eased = 1.0 - (1.0 - t).powi(5); + display.set((target as f64 * eased).round() as i64); + } + }); + #[cfg(not(target_arch = "wasm32"))] + { + display.set(target); + } + }); + + rsx! { + div { class: "{class}", "{display}" } + } +} + +#[component] +fn RecentPostItem(post: PostListItem, delay_ms: i32) -> Element { let date_str = post.formatted_date(); let status_label = post.status_label(); let status_class = post.status_class(); rsx! { - div { class: "flex flex-col sm:flex-row sm:justify-between sm:items-center px-8 py-5 hover:bg-[var(--color-paper-accent-soft)] transition-colors cursor-pointer group", + div { + class: "flex flex-col sm:flex-row sm:justify-between sm:items-center px-8 py-5 hover:bg-[var(--color-paper-accent-soft)] transition-colors cursor-pointer group animate-row-enter", + style: "animation-delay: {delay_ms}ms", div { class: "flex items-center gap-6", span { class: "text-xs font-mono text-[var(--color-paper-tertiary)] w-12 hidden sm:block", "#{post.id:04}"