Skip to main content

yggdrasil/components/
ui.rs

1//! 通用 UI 原子组件与类名常量。
2//!
3//! 提供跨页面共享的样式常量(卡片、按钮、徽章外层等)与可复用组件
4//! (分页导航、状态徽章、空状态)。样式常量用于消除散落在各页面的重复
5//! Tailwind 类字符串;组件用于封装结构固定的 UI 单元。
6//!
7//! 与 `forms.rs`(表单控件)并列,本模块聚焦通用展示类原子。
8
9use dioxus::prelude::*;
10use dioxus::router::components::Link;
11
12use crate::router::Route;
13
14// ===========================================================================
15// 样式常量
16// ===========================================================================
17
18/// Admin 卡片容器:内容档圆角(16px),作为主面板内的内容卡片,与外壳 32px 形成层次。
19///
20/// 用裸 `transition`(Tailwind v4 默认列表含 colors/transform/box-shadow/opacity)而非
21/// `transition-colors`:编译产物中 `.transition-colors` 排在 `.transition-all` 之后,
22/// 同层同优先级会覆盖组件追加的 `transition-all`,导致 hover 位移/阴影瞬时跳变。
23pub const ADMIN_CARD_CLASS: &str = "bg-[var(--color-paper-entry)] rounded-2xl shadow-sm border border-transparent hover:border-[var(--color-paper-border)] transition";
24
25/// Admin 表格容器:内容档圆角(16px),与卡片一致。
26pub const ADMIN_TABLE_CLASS: &str = "bg-[var(--color-paper-entry)] rounded-2xl shadow-sm border border-transparent hover:border-[var(--color-paper-border)] transition overflow-hidden";
27
28#[allow(dead_code)]
29pub const ADMIN_ROW_HOVER: &str =
30    "border-b border-paper-border last:border-b-0 hover:bg-[var(--color-paper-accent-soft)] transition-colors";
31/// 行内加载 spinner:环形渐变 + 自旋动画,用 currentColor 继承文字色。
32///
33/// 内联 SVG(含 `@keyframes`),通过 `dangerous_inner_html` 注入;尺寸由外层
34/// Tailwind 类(如 `w-3.5 h-3.5`)控制。源文件 `public/icons/90-ring-with-gradient.svg`。
35pub const SPINNER_SVG: &str = r#"<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="yggSpinnerGrad"><stop offset="0%" stop-color="currentColor" stop-opacity="1"/><stop offset="100%" stop-color="currentColor" stop-opacity="0.25"/></linearGradient></defs><style>@keyframes yggSpin { to { transform: rotate(360deg); } } .ygg-spinner-circle { transform-origin: 50% 50%; stroke: url(#yggSpinnerGrad); fill: none; animation: yggSpin .5s infinite linear; }</style><circle cx="10" cy="10" r="8" class="ygg-spinner-circle" stroke-width="2"/></svg>"#;
36
37#[allow(dead_code)]
38pub const BADGE_BASE: &str =
39    "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap";
40pub const MEDIA_BADGE_BASE: &str =
41    "inline-flex items-center px-2 py-0.5 rounded-lg text-[10px] font-mono font-medium whitespace-nowrap";
42
43// --- 实心小按钮(批量操作栏:通过 / 垃圾 / 删除) ---
44
45/// 绿色实心小按钮(批量通过、批量恢复)。
46pub const BTN_SOLID_GREEN: &str =
47    "px-4 py-1.5 text-sm font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full hover:bg-green-500/20 transition-colors cursor-pointer";
48/// 琥珀色实心小按钮(批量标为垃圾)。
49pub const BTN_SOLID_AMBER: &str =
50    "px-4 py-1.5 text-sm font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded-full hover:bg-amber-500/20 transition-colors cursor-pointer";
51/// 红色实心小按钮(批量删除、批量彻底删除)。
52pub const BTN_SOLID_RED: &str =
53    "px-4 py-1.5 text-sm font-medium bg-red-500/10 text-red-600 dark:text-red-400 rounded-full hover:bg-red-500/20 transition-colors cursor-pointer";
54
55// --- 文字小按钮(表格行内操作:通过 / 垃圾 / 删除 / 恢复) ---
56
57/// 绿色文字小按钮(行内通过)。
58#[allow(dead_code)]
59pub const BTN_TEXT_GREEN: &str = "text-xs text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 transition-colors cursor-pointer";
60#[allow(dead_code)]
61pub const BTN_TEXT_AMBER: &str = "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 transition-colors cursor-pointer";
62#[allow(dead_code)]
63pub const BTN_TEXT_RED: &str =
64    "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer";
65/// 主题绿(鼠尾草)文字小按钮(行内恢复)。
66#[allow(dead_code)]
67pub const BTN_TEXT_ACCENT: &str =
68    "text-xs text-paper-accent hover:text-paper-primary transition-colors cursor-pointer";
69
70/// 弱化文字按钮(弹窗「取消」等次要取消操作):无描边、无填充,悬浮转主色。
71pub const BTN_GHOST: &str =
72    "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer";
73
74// --- 次要按钮(Teal 第二色,ghost 描边风格,从属于主色 Green) ---
75
76/// 次要按钮:极简风次要操作。
77pub const BTN_SECONDARY: &str =
78    "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";
79
80// --- 主操作按钮(主题绿实心胶囊,全站统一 CTA) ---
81
82/// 主操作按钮:主题绿实心胶囊(用于 `<Link>`、无 loading 态的静态按钮)。
83pub const BTN_PRIMARY: &str =
84    "inline-flex items-center justify-center px-5 py-2 text-sm font-medium text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full shadow-sm hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer";
85
86/// 小号主操作按钮:工具栏场景(刷新 / 导出 / 创建备份)。
87pub const BTN_PRIMARY_SM: &str =
88    "inline-flex items-center justify-center px-4 py-1.5 text-sm font-medium text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] rounded-full hover:brightness-110 active:scale-[0.98] transition-all cursor-pointer";
89
90// --- 描边按钮 ---
91
92/// 描边次要按钮(posts 重建、system 刷新列表):`relative` 以承载 spinner 叠加层。
93pub const BTN_OUTLINE: &str =
94    "relative px-4 py-2 rounded-full text-sm font-medium text-paper-primary border border-paper-border hover:border-paper-accent hover:text-paper-accent transition-all cursor-pointer";
95
96/// 红色描边危险按钮(trash 清空回收站)。
97pub const BTN_DANGER_OUTLINE: &str =
98    "px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-900/50 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer";
99
100// --- 图标按钮 ---
101
102/// 关闭图标按钮(× 关闭提示条)。
103pub const BTN_CLOSE_ICON: &str =
104    "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none";
105
106/// 方形图标按钮(trash 步进 −/+)。
107pub const BTN_ICON: &str =
108    "w-9 h-9 flex items-center justify-center text-sm text-paper-secondary hover:text-paper-primary hover:bg-paper-theme cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-paper-accent/40";
109
110// ===========================================================================
111// 组件
112// ===========================================================================
113
114/// 分页导航组件。
115///
116/// 统一了后台与前台的分页 UI,通过 `variant` 切换配色与展示细节:
117/// - `"admin"`:描边胶囊按钮(与 `BTN_OUTLINE` 同族),显示页码计数
118///   (`{当前} / {总} 页 (共 {total} {unit})`),首尾页渲染禁用态。
119/// - `"frontend"`:主题绿胶囊按钮,不显示计数,首尾页直接不渲染按钮。
120///
121/// Props:
122/// - `variant`:`"admin"` 或 `"frontend"`
123/// - `current_page`:当前页码(从 1 开始)
124/// - `total`:数据总条数
125/// - `per_page`:每页条数,用于计算总页数
126/// - `prev_route`:点击上一页跳转的目标路由(路由式翻页用)
127/// - `next_route`:点击下一页跳转的目标路由(路由式翻页用)
128/// - `unit`:计数单位("篇" / "条"),仅 admin 显示计数时使用
129/// - `on_prev` / `on_next`:可选回调。传入时渲染 `<button onclick>` 走客户端
130///   signal 翻页(与路由式 `prev_route`/`next_route` 互斥,回调优先);不传则保持
131///   原路由式 `<Link>`。后台客户端分页列表(如「全部文章」)用回调翻页,
132///   前台/其他分页页面仍走路由,零影响。
133/// - `on_jump`:可选跳页回调。传入且总页数 > 1 时,admin 变体的当前页码渲染为
134///   可编辑输入框——聚焦编辑、回车跳转(自动夹取到 `[1, total_pages]`,非法输入
135///   还原为当前页)、失焦回显。仅回调式翻页可用——路由式翻页无法由任意页码反推
136///   `Route`,故路由式调用方不传此 prop,页码保持纯文本。
137/// - `compact`:admin 变体是否去掉默认外边距,供弹窗等固定底栏嵌入使用。
138#[component]
139pub fn Pagination(
140    variant: &'static str,
141    current_page: i32,
142    total: i64,
143    per_page: i32,
144    #[props(default = Route::Home {})] prev_route: Route,
145    #[props(default = Route::Home {})] next_route: Route,
146    unit: &'static str,
147    #[props(default)] on_prev: Option<EventHandler<()>>,
148    #[props(default)] on_next: Option<EventHandler<()>>,
149    #[props(default)] on_jump: Option<EventHandler<i32>>,
150    #[props(default)] compact: bool,
151) -> Element {
152    let has_prev = current_page > 1;
153    let total_pages = ((total + per_page as i64 - 1) / per_page as i64).max(1) as i32;
154    let has_next = current_page < total_pages;
155
156    // admin 与 frontend 的配色差异。admin 上一页/下一页用描边胶囊,与本页其它
157    // 操作按钮(BTN_OUTLINE)同族,避免出现方角实心按钮破坏整体圆角语言。
158    let is_admin = variant == "admin";
159    let nav_class = if is_admin {
160        if compact {
161            "flex justify-between"
162        } else {
163            "flex mt-6 justify-between"
164        }
165    } else {
166        "flex mt-10 mb-6 justify-between"
167    };
168    let (link_class, link_extra_next) = if is_admin {
169        (
170            "inline-flex items-center px-4 py-2 text-sm font-medium text-paper-primary border border-paper-border rounded-full hover:border-paper-accent hover:text-paper-accent active:scale-[0.98] transition-all duration-200 cursor-pointer",
171            "",
172        )
173    } else {
174        (
175            "inline-flex items-center px-4 py-2 text-sm text-white bg-paper-accent rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer",
176            "ml-auto",
177        )
178    };
179    let disabled_class =
180        "inline-flex items-center px-4 py-2 text-sm font-medium text-paper-secondary border border-paper-border rounded-full cursor-not-allowed";
181
182    // admin 首尾页渲染禁用态;frontend 首尾页直接不渲染。
183    // on_prev/on_next 存在时走回调式 button(客户端 signal 翻页),否则走路由式 Link。
184    let prev_inner = rsx! {
185        span { class: "mr-1", "«" }
186        "上一页"
187    };
188    let next_inner = rsx! {
189        "下一页"
190        span { class: "ml-1", "»" }
191    };
192
193    // 跳页状态:当前页码本身渲染为可编辑输入框(仅回调式翻页且多页时)。
194    // `jump_editing` = 输入框聚焦态——聚焦期间显示草稿,失焦回显 current_page,
195    // 与父组件翻页天然同步,无需 effect 桥接;`jump_draft` = 用户输入草稿。
196    // 两者皆为独立 UI 状态(非派生镜像),合法 use_signal。
197    let mut jump_editing: Signal<bool> = use_signal(|| false);
198    let mut jump_draft: Signal<String> = use_signal(String::new);
199    rsx! {
200        nav { class: nav_class,
201            if has_prev {
202                if let Some(on_prev) = on_prev {
203                    button {
204                        class: "{link_class}",
205                        onclick: move |_| on_prev.call(()),
206                        {prev_inner}
207                    }
208                } else {
209                    Link { class: "{link_class}", to: prev_route, {prev_inner} }
210                }
211            } else if is_admin {
212                span { class: "{disabled_class}",
213                    span { class: "mr-1", "«" }
214                    "上一页"
215                }
216            }
217
218            // admin 显示页码计数;回调式翻页且多页时,当前页码可直接编辑跳页。
219            if is_admin {
220                span { class: "flex items-center gap-1.5 self-center text-sm text-paper-secondary",
221                    if total_pages > 1 && on_jump.is_some() {
222                        input {
223                            class: "w-11 px-1 py-0.5 text-sm text-center bg-transparent text-paper-primary border border-paper-border rounded-full hover:border-paper-accent/60 focus:outline-none focus:border-paper-accent transition-colors",
224                            r#type: "text",
225                            inputmode: "numeric",
226                            title: "输入页码,回车跳转",
227                            value: if jump_editing() { jump_draft() } else { current_page.to_string() },
228                            onfocus: move |_| {
229                                jump_draft.set(current_page.to_string());
230                                jump_editing.set(true);
231                            },
232                            onblur: move |_| jump_editing.set(false),
233                            oninput: move |e| jump_draft.set(e.value()),
234                            onkeydown: move |e| {
235                                if e.key() == Key::Enter {
236                                    if let Some(on_jump) = on_jump {
237                                        // 合法页码:夹取到 [1, total_pages] 后回调并回显;
238                                        // 非法输入(空串/非数字):还原为当前页。
239                                        if let Ok(p) = jump_draft().trim().parse::<i32>() {
240                                            let p = p.clamp(1, total_pages);
241                                            on_jump.call(p);
242                                            jump_draft.set(p.to_string());
243                                        } else {
244                                            jump_draft.set(current_page.to_string());
245                                        }
246                                    }
247                                }
248                            },
249                        }
250                    } else {
251                        "{current_page}"
252                    }
253                    " / {total_pages} 页 (共 {total} {unit})"
254                }
255            }
256
257            if has_next {
258                if let Some(on_next) = on_next {
259                    button {
260                        class: "{link_class} {link_extra_next}",
261                        onclick: move |_| on_next.call(()),
262                        {next_inner}
263                    }
264                } else {
265                    Link {
266                        class: "{link_class} {link_extra_next}",
267                        to: next_route,
268                        {next_inner}
269                    }
270                }
271            } else if is_admin {
272                span { class: "{disabled_class}",
273                    "下一页"
274                    span { class: "ml-1", "»" }
275                }
276            }
277        }
278    }
279}
280
281/// 状态徽章组件。
282///
283/// 外层固定 `BADGE_BASE`,颜色类由调用方传入。之所以用 `color_class` prop
284/// 而非枚举变体,是因为部分场景(如回收站剩余天数)的颜色由动态逻辑决定
285/// (>7 天中性 / ≤7 天主题绿 / ≤0 琥珀),硬编码 variant 反而不够灵活。
286///
287/// Props:
288/// - `color_class`:背景与文字颜色类(如 `post.status_badge_class()` 的返回值)
289/// - `label`:徽章文本
290#[component]
291pub fn StatusBadge(color_class: &'static str, label: String) -> Element {
292    rsx! {
293        span { class: "{BADGE_BASE} {color_class}", "{label}" }
294    }
295}
296
297/// 用户头像:有图显示图,无图回退展示名首字符(accent 软底)。
298///
299/// 头像三态(图片 / 首字符)的统一实现,调用方经 `class` 控制尺寸与形状
300/// (须含 `w-* h-* rounded-full` 与字号如 `text-xs`)。使用处:后台侧栏
301/// 用户卡片(28px)、个人信息页身份卡(96px,外层按钮带 hover 遮罩)、
302/// 前台评论表单身份行(32px)。
303///
304/// Props:
305/// - `name`:展示名(用于首字符兜底与 alt 文本)
306/// - `avatar_url`:头像 URL;`None` 或空白串时渲染首字符兜底
307/// - `class`:尺寸与形状类;组件内补 `object-cover`(图片)/ flex 居中(首字符)
308#[component]
309pub fn UserAvatar(name: String, avatar_url: Option<String>, class: &'static str) -> Element {
310    let initial = name
311        .chars()
312        .next()
313        .map(|c| c.to_uppercase().collect::<String>())
314        .unwrap_or_else(|| "?".to_string());
315    match avatar_url.filter(|u| !u.trim().is_empty()) {
316        Some(url) => rsx! {
317            img { class: "{class} object-cover", src: "{url}", alt: "{name} 的头像" }
318        },
319        None => rsx! {
320            span { class: "{class} flex items-center justify-center bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-accent)] font-bold select-none",
321                "{initial}"
322            }
323        },
324    }
325}
326
327/// Tooltip 基础样式(胶囊:黑底白字,hover 显现)。水平定位由调用方经 `align` 选择,
328/// 不在此处写死居中——触发器贴容器边缘时居中会让 tooltip 溢出被 `overflow-hidden` 裁掉。
329const TOOLTIP_STYLE: &str =
330    "pointer-events-none absolute px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover/tooltip:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50";
331
332/// Tooltip 包裹组件。
333///
334/// 将任意触发器(按钮等)包裹后,鼠标 hover 时在上方或下方弹出提示。
335/// 用 CSS `group/tooltip` + `group-hover/tooltip:opacity-100` 实现,无 JS 状态,`pointer-events-none`
336/// 保证不拦截点击。
337///
338/// Props:
339/// - `tip`:提示文案
340/// - `children`:触发器元素(按钮 / 链接等)
341/// - `placement`:垂直方向,`"top"`(默认,弹出在触发器上方)或 `"bottom"`(下方)
342/// - `align`:水平对齐,`"center"`(默认,居中)/ `"start"`(左对齐,向右延伸)/
343///   `"end"`(右对齐,向左延伸)
344///
345/// 注意:父容器若有 `overflow-hidden`(如 `ADMIN_TABLE_CLASS`),`position:absolute`
346/// 的 tooltip 会被裁掉。此时除选朝外的 `placement` 外,还须用 `align` 让 tooltip
347/// 朝容器内侧延伸——否则居中的宽 tooltip 越过容器边缘即被裁(见 issue #14:
348/// 表格最右列按钮的 tooltip 右半段被裁、左半段压住相邻内容)。
349#[component]
350pub fn Tooltip(
351    tip: String,
352    children: Element,
353    #[props(default = "top")] placement: &'static str,
354    #[props(default = "center")] align: &'static str,
355) -> Element {
356    // 朝上:tooltip 在触发器上方(bottom-full + mb-2);朝下:在下方(top-full + mt-2)。
357    let position_class = if placement == "bottom" {
358        "top-full mt-2"
359    } else {
360        "bottom-full mb-2"
361    };
362    // 水平对齐:center 居中 / start 左对齐向右 / end 右对齐向左。
363    let align_class = match align {
364        "start" => "left-0",
365        "end" => "right-0",
366        _ => "left-1/2 -translate-x-1/2",
367    };
368    rsx! {
369        div { class: "group/tooltip relative inline-flex",
370            {children}
371            div { class: "{TOOLTIP_STYLE} {position_class} {align_class}", "{tip}" }
372        }
373    }
374}
375/// 可折叠的后台设置卡片外壳。
376///
377/// 复用回收站与系统设置的同一套交互:状态指示灯、摘要标题、旋转箭头,以及
378/// `grid-template-rows` 的 0fr↔1fr 平滑展开。调用方通过 `children` 提供面板内容,
379/// 通过 `on_toggle` 在展开状态变化时清理本地反馈等附加状态。
380///
381/// Props:
382/// - `title`:卡片标题
383/// - `summary`:标题下的当前状态摘要
384/// - `enabled`:状态指示灯是否使用主题色
385/// - `children`:折叠面板内容(调用方负责内容内边距与分隔线)
386/// - `on_toggle`:可选的展开/收起回调
387#[component]
388pub fn CollapsibleSettingsCard(
389    title: String,
390    summary: String,
391    enabled: bool,
392    children: Element,
393    #[props(default)] on_toggle: Option<EventHandler<()>>,
394) -> Element {
395    let mut open = use_signal(|| false);
396    let chevron_rotate = if open() { "rotate-180" } else { "" };
397    let dot_class = if enabled {
398        "w-2 h-2 rounded-full bg-paper-accent shadow-[0_0_0_3px_rgba(64,160,43,0.15)]"
399    } else {
400        "w-2 h-2 rounded-full bg-paper-tertiary"
401    };
402
403    rsx! {
404        div { class: "rounded-2xl border border-paper-border overflow-hidden bg-paper-entry",
405            button {
406                r#type: "button",
407                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",
408                aria_expanded: "{open()}",
409                onclick: move |_| {
410                    open.set(!open());
411                    if let Some(on_toggle) = on_toggle {
412                        on_toggle.call(());
413                    }
414                },
415                div { class: "w-2 flex-shrink-0 flex items-center justify-center",
416                    div { class: "{dot_class}" }
417                }
418                div { class: "flex-1 min-w-0",
419                    div { class: "text-sm font-medium text-paper-primary", "{title}" }
420                    div { class: "text-xs text-paper-secondary mt-0.5 truncate", "{summary}" }
421                }
422                svg {
423                    class: "w-4 h-4 text-paper-secondary transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
424                    view_box: "0 0 24 24",
425                    fill: "none",
426                    stroke: "currentColor",
427                    stroke_width: "2",
428                    path {
429                        stroke_linecap: "round",
430                        stroke_linejoin: "round",
431                        d: "M19 9l-7 7-7-7",
432                    }
433                }
434            }
435            div {
436                class: "grid transition-all duration-300 ease-in-out",
437                style: if open() { "grid-template-rows: 1fr; opacity: 1; pointer-events: auto;" } else { "grid-template-rows: 0fr; opacity: 0; pointer-events: none;" },
438                div { class: "overflow-hidden min-h-0", {children} }
439            }
440        }
441    }
442}
443
444/// Popover 遮罩与面板的层级(z-40 遮罩 < z-50 面板),与 Tooltip/lightbox 同 z-50。
445const POPOVER_OVERLAY_CLASS: &str = "fixed inset-0 z-40";
446/// Popover 面板:卡片化大圆角 + 阴影 + 淡入缩放动画。
447/// 居中变体:animate-popover-enter 的关键帧烘了 translateX(-50%),与居中的
448/// 静态 transform 一致;端点对齐(start/end)变体静态 transform 为空,必须换用
449/// 无位移的 animate-popover-enter-edge,否则 both fill 期面板水平错位半宽。
450const POPOVER_PANEL_CLASS: &str =
451    "fixed z-50 bg-[var(--color-paper-entry)] rounded-2xl shadow-lg border border-[var(--color-paper-border)] p-4 animate-popover-enter";
452const POPOVER_PANEL_EDGE_CLASS: &str =
453    "fixed z-50 bg-[var(--color-paper-entry)] rounded-2xl shadow-lg border border-[var(--color-paper-border)] p-4 animate-popover-enter-edge";
454
455/// 受控式通用 Popover(浮层)组件。
456///
457/// 与 [`Tooltip`] 对称——但 Tooltip 是纯 CSS hover、无状态;Popover 是点击触发、
458/// 受控开关,用于承载确认框、轻量表单等交互内容。
459///
460/// ## 定位策略
461///
462/// 父容器常有 `overflow-hidden`(如 `ADMIN_TABLE_CLASS`),`position:absolute` 子节点
463/// 会被裁掉,故面板用 **`position:fixed`**,以触发点击的**视口坐标**(`MouseEvent::
464/// client_coordinates()`)作为锚点(参照 `theme.rs` 圆形展开动画的坐标用法)。无需
465/// `getBoundingClientRect`/`node_ref`。
466///
467/// - `placement: "top"`(默认):面板底边贴点击点上方(`bottom: 100vh - y + gap`)。
468/// - `placement: "bottom"`:面板顶边贴点击点下方(`top: y + gap`)。
469/// - 水平由 `align` 决定:`"center"`(默认)面板中心对齐点击点(`left: x` +
470///   `-translate-x-1/2`);`"start"` 面板左缘贴点向右延伸(`left: x`);
471///   `"end"` 面板右缘贴点向左延伸(`right: 100vw - x`)。
472///
473/// 触发器贴近视口边缘时(如表格最右列的按钮),居中的宽面板会越出视口——
474/// 此时用 `align` 让面板朝视口内侧延伸(与 Tooltip 的 align 同款约定,
475/// 见 issue #14)。`end` 用 `right` 锚定右缘,与面板内容宽度无关,天然不越右缘。
476///
477/// ## 关闭路径(三条)
478///
479/// 1. 点遮罩(透明,仅作点击兜底)→ `on_close`。
480/// 2. Escape 键 → `on_close`(组件内 `use_effect` 注册全局 keydown 监听,`use_drop` 清理)。
481/// 3. 面板内确认/取消按钮调用 `on_close`。
482///
483/// ## Props
484///
485/// - `open`:受控开关;`false` 时组件不渲染任何内容(SSR 安全)。
486/// - `anchor_x` / `anchor_y`:触发点击的视口坐标。
487/// - `placement`:`"top"`(默认)/ `"bottom"`。
488/// - `align`:`"center"`(默认)/ `"start"` / `"end"`(水平对齐,见上)。
489/// - `children`:面板内容(确认框等)。
490/// - `on_close`:任一关闭路径触发。
491#[component]
492#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
493pub fn Popover(
494    open: bool,
495    anchor_x: i32,
496    anchor_y: i32,
497    children: Element,
498    on_close: EventHandler<()>,
499    #[props(default = "top")] placement: &'static str,
500    #[props(default = "center")] align: &'static str,
501) -> Element {
502    // Escape 关闭:组件 open 时注册全局 keydown 监听,关闭/卸载时移除。
503    // 手写最小 listener 而非复用 use_event_listener——后者 handler 无参,拿不到
504    // KeyboardEvent 的 key()。用 use_hook 持有 Closure,use_effect 注册,use_drop 清理。
505    #[cfg(target_arch = "wasm32")]
506    {
507        use dioxus::prelude::{use_drop, use_effect, use_hook};
508        use std::cell::RefCell;
509        use std::rc::Rc;
510        type EscState =
511            Rc<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>>>>;
512        let state: EscState = use_hook(|| Rc::new(RefCell::new(None)));
513        let state_for_drop = state.clone();
514        let open_for_effect = open;
515        let on_close_for_esc = on_close;
516        use_effect(move || {
517            if !open_for_effect {
518                return;
519            }
520            let Some(window) = web_sys::window() else {
521                return;
522            };
523            let on_close_for_esc = on_close_for_esc;
524            // Closure 带 KeyboardEvent 参数:浏览器调用 handler 时传入事件对象,
525            // 无需依赖已废弃的 window.event()。as_ref + unchecked_ref 转成 JS Function。
526            let closure =
527                wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| {
528                    if ev.key() == "Escape" {
529                        on_close_for_esc.call(());
530                    }
531                })
532                    as Box<dyn FnMut(web_sys::KeyboardEvent)>);
533            let _ = window.add_event_listener_with_callback(
534                "keydown",
535                wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
536            );
537            *state.borrow_mut() = Some(closure);
538        });
539        use_drop(move || {
540            if let Some(closure) = state_for_drop.borrow_mut().take() {
541                if let Some(window) = web_sys::window() {
542                    let _ = window.remove_event_listener_with_callback(
543                        "keydown",
544                        wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
545                    );
546                }
547            }
548        });
549    }
550
551    if !open {
552        return rsx! {};
553    }
554
555    // 面板定位:placement 决定垂直方向;align 决定水平。end 用 right 锚定
556    // (右缘 = 点击点),无需 translate,故与面板宽度解耦;start/end 的入场动画
557    // 必须是无位移变体(关键帧 fill 值须等于静态 transform,见常量注释)。
558    let horizontal = match align {
559        "start" => format!("left: {x}px;", x = anchor_x),
560        "end" => format!("right: calc(100vw - {x}px);", x = anchor_x),
561        _ => format!("left: {x}px; transform: translateX(-50%);", x = anchor_x),
562    };
563    let style = if placement == "bottom" {
564        format!("top: {y}px; {horizontal}", y = anchor_y + 8)
565    } else {
566        // top:面板在点击点上方——用 bottom 锚定 viewport 底,差值即视口高度 - y + 间隙。
567        // 视口高度用 100vh,纯 CSS 无需 JS 读取 scrollHeight。
568        format!(
569            "bottom: calc(100vh - {y}px + 8px); {horizontal}",
570            y = anchor_y
571        )
572    };
573    let panel_class = if align == "center" {
574        POPOVER_PANEL_CLASS
575    } else {
576        POPOVER_PANEL_EDGE_CLASS
577    };
578
579    rsx! {
580        // 透明遮罩:拦截外部点击(点遮罩即关)。z-40 < 面板 z-50。
581        div {
582            class: "{POPOVER_OVERLAY_CLASS}",
583            onclick: move |_| on_close.call(()),
584        }
585        // 面板:fixed 定位逃出 overflow-hidden 容器。
586        div { class: "{panel_class}", style: "{style}", {children} }
587    }
588}
589
590static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
591
592/// 筛选选项卡组件。
593///
594/// 用于切换不同的视图或筛选条件(例如:全部、待审核、已通过等)。
595/// 具备高级的平滑滑动底部指示器动画。
596///
597/// Props:
598/// - `items`:选项卡列表,每一项为 `(value, label)`
599/// - `active_value`:当前选中的值
600/// - `on_change`:选项卡切换时的回调
601#[component]
602pub fn FilterTabs(
603    items: Vec<(&'static str, &'static str)>,
604    active_value: String,
605    on_change: EventHandler<String>,
606) -> Element {
607    #[allow(unused_mut)]
608    let mut indicator_style = use_signal(|| "left: 0px; width: 0px; opacity: 0;".to_string());
609    let id_prefix = use_hook(|| TAB_GROUP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
610
611    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
612    let update_indicator = move |active: String| {
613        spawn(async move {
614            #[cfg(target_arch = "wasm32")]
615            {
616                use wasm_bindgen::JsCast;
617
618                // 等待 DOM 节点更新
619                crate::utils::time::sleep_ms(50).await;
620
621                if let Some(window) = web_sys::window() {
622                    if let Some(doc) = window.document() {
623                        let element_id = format!("tab-{}-{}", id_prefix, active);
624                        if let Some(el) = doc.get_element_by_id(&element_id) {
625                            if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
626                                let left = html_el.offset_left();
627                                let width = html_el.offset_width();
628                                indicator_style.set(format!(
629                                    "left: {}px; width: {}px; opacity: 1;",
630                                    left, width
631                                ));
632                            }
633                        }
634                    }
635                }
636            }
637        });
638    };
639
640    use_effect({
641        let active_value = active_value.clone();
642        move || {
643            update_indicator(active_value.clone());
644        }
645    });
646
647    rsx! {
648        div { class: "relative flex gap-4 border-b border-paper-border mb-6",
649            for (value, label) in items {
650                button {
651                    id: "tab-{id_prefix}-{value}",
652                    key: "{value}",
653                    class: if active_value == *value { "cursor-pointer px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-primary transition-colors" } else { "cursor-pointer px-2 py-3 text-xs font-mono tracking-widest uppercase text-paper-secondary hover:text-paper-primary transition-colors" },
654                    onclick: {
655                        let v = value.to_string();
656                        move |_| {
657                            on_change.call(v.clone());
658                            update_indicator(v.clone());
659                        }
660                    },
661                    "{label}"
662                }
663            }
664            // 绝对定位的滑动颜色条
665            div {
666                class: "absolute bottom-[-1px] h-[2px] bg-paper-primary transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] pointer-events-none",
667                style: "{indicator_style}",
668            }
669        }
670    }
671}
672
673/// 主操作按钮,内置 loading spinner 叠加(统一三态)。
674///
675/// 用于全站所有主题绿 CTA(执行 / 刷新 / 创建备份 / 发布 / 保存设置)。
676/// 三态:
677/// - `loading=true`:主题绿底 + 文字隐藏(`opacity-0`)+ spinner 绝对居中
678///   (按钮宽度不变,避免加载时布局抖动)
679/// - `disabled=true`(且未 loading):灰色底(`bg-paper-tertiary`)+
680///   `cursor-not-allowed`
681/// - 正常:主题绿底 + `hover:brightness-110` + `active:scale-[0.98]`
682///
683/// Props:
684/// - `label`:正常态显示的文案(loading 时隐藏,由 spinner 占位)
685/// - `loading`:是否处于加载态
686/// - `disabled`:是否禁用(loading 优先级更高)
687/// - `variant`:`"primary"`(默认,`px-5 py-2`)或 `"sm"`(`px-4 py-1.5`)
688/// - `onclick`:点击回调
689#[component]
690pub fn LoadingButton(
691    label: String,
692    loading: bool,
693    #[props(default = false)] disabled: bool,
694    #[props(default = "primary")] variant: &'static str,
695    onclick: EventHandler<()>,
696) -> Element {
697    // 尺寸变体:sm 用于工具栏(刷新/导出),primary 用于主 CTA(执行/发布/保存)。
698    let size = if variant == "sm" {
699        "px-4 py-1.5"
700    } else {
701        "px-5 py-2 shadow-sm"
702    };
703
704    // 三态背景:loading 与正常都是主题绿(保持视觉连续),disabled 灰化。
705    let (bg, cursor) = if disabled && !loading {
706        (
707            "bg-[var(--color-paper-tertiary)] text-[var(--color-paper-secondary)]",
708            "cursor-not-allowed",
709        )
710    } else {
711        (
712            "text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] hover:brightness-110 active:scale-[0.98]",
713            "cursor-pointer",
714        )
715    };
716
717    rsx! {
718        button {
719            class: "relative inline-flex items-center justify-center {size} {bg} {cursor} rounded-full text-sm font-medium transition-all",
720            disabled: loading || disabled,
721            onclick: move |_| onclick.call(()),
722            span { class: if loading { "opacity-0" } else { "" }, "{label}" }
723            if loading {
724                span {
725                    class: "absolute inset-0 flex items-center justify-center",
726                    dangerous_inner_html: SPINNER_SVG,
727                }
728            }
729        }
730    }
731}
732
733/// 标签芯片组件:统一的标签可视化(标签云软底 / 卡片描边两种变体)。
734///
735/// 收敛原本散落在标签云(`tags.rs` 软底圆角)与文章卡片(`post_card.rs` 描边胶囊)
736/// 的两套手写 `Link` 样式——两者都是跳转到 [`Route::TagDetail`] 的可点击标签,
737/// 仅视觉变体不同,故合并为一个组件 + `variant` prop。
738///
739/// Props:
740/// - `label`:标签名
741/// - `variant`:`"solid"`(标签云:软底圆角,可选计数)/ `"outline"`(卡片内:描边胶囊)
742/// - `count`:可选的文章计数(仅 `solid` 渲染为 `<sup>`)
743/// - `stop_propagation`:是否阻止点击冒泡(卡片内覆盖层链接场景需要,见 `PostCard`)
744#[component]
745pub fn TagChip(
746    label: String,
747    #[props(default = "outline")] variant: &'static str,
748    #[props(default)] count: Option<i64>,
749    #[props(default)] stop_propagation: bool,
750) -> Element {
751    let class = match variant {
752        "solid" => "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",
753        _ => "inline-flex items-center px-3 py-1 rounded-full border border-paper-border hover:bg-paper-accent hover:border-paper-accent hover:text-white transition-all duration-200",
754    };
755    rsx! {
756        Link {
757            class: "{class}",
758            to: Route::TagDetail {
759                tag: label.clone(),
760            },
761            onclick: move |evt: dioxus::events::MouseEvent| {
762                if stop_propagation {
763                    evt.stop_propagation();
764                }
765            },
766            "{label}"
767            if let Some(c) = count {
768                sup { class: "ml-1 text-sm text-paper-secondary", "{c}" }
769            }
770        }
771    }
772}
773
774/// 带勾画动画的复选框:对勾 SVG 用 stroke-dashoffset 描边绘制 + scale 弹入。
775///
776/// 原生 `<input type="checkbox">` 是替换元素,吃不到 `::after`/`::before`,
777/// 无法做 transform 描边动画。本组件用透明 input 做命中/无障碍层,同级 SVG
778/// path 做对勾视觉层;勾选时 path 的 stroke-dashoffset 归零「画出」对勾,
779/// 同时 SVG scale 从 0.6 弹入(弹跳曲线)——双层动画带来盖戳质感。
780///
781/// 与旧的 `.ygg-checkbox`(纯 background-size 缩放)相比,描边动画更像手写勾画,
782/// 且 SVG 是矢量合成层,比光栅 background-image 缩放更锐利。
783///
784/// Props:
785/// - `checked`:受控勾选态
786/// - `onchange`:状态变化回调,返回新的 bool
787/// - `danger`:危险态(红色语义,SQL 控制台「我了解后果」),缺省 `false`
788#[component]
789pub fn Checkbox(
790    checked: bool,
791    onchange: EventHandler<bool>,
792    #[props(default)] danger: bool,
793) -> Element {
794    let wrap = if danger {
795        "ygg-cb ygg-cb-danger"
796    } else {
797        "ygg-cb"
798    };
799    rsx! {
800        span { class: "{wrap}",
801            input {
802                r#type: "checkbox",
803                checked,
804                onchange: move |e: Event<FormData>| onchange.call(e.checked()),
805            }
806            svg { class: "ygg-cb-mark", view_box: "0 0 16 16",
807                path { class: "ygg-cb-check", d: "M3.5 8.5l3 3 6-6.5" }
808            }
809        }
810    }
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816
817    #[test]
818    fn tooltip_uses_named_group_to_prevent_ancestor_trigger() {
819        assert!(
820            TOOLTIP_STYLE.contains("group-hover/tooltip:opacity-100"),
821            "Tooltip 必须使用专属命名空间 group-hover/tooltip:opacity-100,避免被外层祖先 group 误触"
822        );
823    }
824}