feat(admin): 仪表盘添加分块进场动画与数字滚动
- 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 对齐
This commit is contained in:
parent
5743c7a581
commit
61634882c5
22
input.css
22
input.css
@ -83,6 +83,23 @@
|
|||||||
animation: popover-enter 150ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
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 */
|
/* Minimalist custom scrollbars */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@ -1080,7 +1097,10 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-enter {
|
.page-enter,
|
||||||
|
.animate-page-enter,
|
||||||
|
.animate-popover-enter,
|
||||||
|
.animate-row-enter {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -67,7 +67,7 @@ pub const BTN_TEXT_ACCENT: &str =
|
|||||||
|
|
||||||
/// 次要按钮:极简风次要操作。
|
/// 次要按钮:极简风次要操作。
|
||||||
pub const BTN_SECONDARY: &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) ---
|
// --- 主操作按钮(主题绿实心胶囊,全站统一 CTA) ---
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,13 @@ pub fn Admin() -> Element {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 待审卡片进场类:数据未就绪(骨架屏)时为空,就绪后补挂以触发一次入场动画。
|
||||||
|
let pending_enter_class = if pending_count().is_some() {
|
||||||
|
"animate-page-enter"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "w-full max-w-7xl mx-auto space-y-8",
|
div { class: "w-full max-w-7xl mx-auto space-y-8",
|
||||||
// 顶部标题和全局操作栏
|
// 顶部标题和全局操作栏
|
||||||
@ -74,19 +81,22 @@ pub fn Admin() -> Element {
|
|||||||
Some(s) => {
|
Some(s) => {
|
||||||
rsx! {
|
rsx! {
|
||||||
StatCard {
|
StatCard {
|
||||||
value: s.total.to_string(),
|
value: s.total,
|
||||||
label: "总文章数".to_string(),
|
label: "总文章数".to_string(),
|
||||||
trend: "+12%".to_string(),
|
trend: "+12%".to_string(),
|
||||||
|
delay_ms: 0,
|
||||||
}
|
}
|
||||||
StatCard {
|
StatCard {
|
||||||
value: s.published.to_string(),
|
value: s.published,
|
||||||
label: "已发布".to_string(),
|
label: "已发布".to_string(),
|
||||||
trend: "活跃".to_string(),
|
trend: "活跃".to_string(),
|
||||||
|
delay_ms: 60,
|
||||||
}
|
}
|
||||||
StatCard {
|
StatCard {
|
||||||
value: s.drafts.to_string(),
|
value: s.drafts,
|
||||||
label: "草稿".to_string(),
|
label: "草稿".to_string(),
|
||||||
trend: "待处理".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 {
|
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 {},
|
to: Route::AdminComments {},
|
||||||
match pending_count() {
|
match pending_count() {
|
||||||
Some(count) => {
|
Some(count) => {
|
||||||
@ -119,7 +132,10 @@ pub fn Admin() -> Element {
|
|||||||
rsx! {
|
rsx! {
|
||||||
div { class: "text-sm font-medium {color_class}", "待审评论" }
|
div { class: "text-sm font-medium {color_class}", "待审评论" }
|
||||||
div { class: "flex items-baseline justify-between mt-4",
|
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",
|
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! {
|
rsx! {
|
||||||
div { class: "{ADMIN_TABLE_CLASS}",
|
div { class: "{ADMIN_TABLE_CLASS}",
|
||||||
div { class: "divide-y divide-paper-border",
|
div { class: "divide-y divide-paper-border",
|
||||||
for post in posts.iter().take(5) {
|
for (i , post) in posts.iter().take(5).enumerate() {
|
||||||
RecentPostItem { key: "{post.id}", post: post.clone() }
|
RecentPostItem {
|
||||||
|
key: "{post.id}",
|
||||||
|
post: post.clone(),
|
||||||
|
delay_ms: (i as i32) * 50,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -191,9 +211,11 @@ pub fn Admin() -> Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn StatCard(value: String, label: String, trend: String) -> Element {
|
fn StatCard(value: i64, label: String, trend: String, delay_ms: i32) -> Element {
|
||||||
rsx! {
|
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: "flex justify-between items-start",
|
||||||
div { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
|
div { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
|
||||||
"{label}"
|
"{label}"
|
||||||
@ -202,21 +224,69 @@ fn StatCard(value: String, label: String, trend: String) -> Element {
|
|||||||
"{trend}"
|
"{trend}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div { class: "text-4xl font-light tracking-tight text-[var(--color-paper-primary)] mt-4",
|
CountUp {
|
||||||
"{value}"
|
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]
|
#[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 date_str = post.formatted_date();
|
||||||
let status_label = post.status_label();
|
let status_label = post.status_label();
|
||||||
let status_class = post.status_class();
|
let status_class = post.status_class();
|
||||||
|
|
||||||
rsx! {
|
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",
|
div { class: "flex items-center gap-6",
|
||||||
span { class: "text-xs font-mono text-[var(--color-paper-tertiary)] w-12 hidden sm:block",
|
span { class: "text-xs font-mono text-[var(--color-paper-tertiary)] w-12 hidden sm:block",
|
||||||
"#{post.id:04}"
|
"#{post.id:04}"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user