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::components::forms::FormInput;
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/// 行内加载 spinner:环形渐变 + 自旋动画,用 currentColor 继承文字色。
29///
30/// 内联 SVG(含 `@keyframes`),通过 `dangerous_inner_html` 注入;尺寸由外层
31/// Tailwind 类(如 `w-3.5 h-3.5`)控制。源文件 `public/icons/90-ring-with-gradient.svg`。
32pub 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>"#;
33
34#[allow(dead_code)]
35pub const BADGE_BASE: &str =
36    "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap";
37pub const MEDIA_BADGE_BASE: &str =
38    "inline-flex items-center px-2 py-0.5 rounded-lg text-[10px] font-mono font-medium whitespace-nowrap";
39
40// --- 实心小按钮(批量操作栏:通过 / 垃圾 / 删除) ---
41
42/// 绿色实心小按钮(批量通过、批量恢复)。
43pub const BTN_SOLID_GREEN: &str =
44    "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";
45/// 琥珀色实心小按钮(批量标为垃圾)。
46pub const BTN_SOLID_AMBER: &str =
47    "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";
48/// 红色实心小按钮(批量删除、批量彻底删除)。
49pub const BTN_SOLID_RED: &str =
50    "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";
51
52// --- 文字小按钮(表格行内操作:通过 / 垃圾 / 删除 / 恢复) ---
53
54#[allow(dead_code)]
55pub 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";
56#[allow(dead_code)]
57pub const BTN_TEXT_RED: &str =
58    "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer";
59
60/// 弱化文字按钮(弹窗「取消」等次要取消操作):无描边、无填充,悬浮转主色。
61pub const BTN_GHOST: &str =
62    "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary transition-colors cursor-pointer";
63
64// --- 次要按钮(Teal 第二色,ghost 描边风格,从属于主色 Green) ---
65
66/// 次要按钮:极简风次要操作。
67pub const BTN_SECONDARY: &str =
68    "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";
69
70// --- 主操作按钮(主题绿实心胶囊,全站统一 CTA) ---
71
72/// 主操作按钮:主题绿实心胶囊(用于 `<Link>`、无 loading 态的静态按钮)。
73pub const BTN_PRIMARY: &str =
74    "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";
75
76/// 小号主操作按钮:工具栏场景(刷新 / 导出 / 创建备份)。
77pub const BTN_PRIMARY_SM: &str =
78    "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";
79
80// --- 描边按钮 ---
81
82/// 描边次要按钮(posts 重建、system 刷新列表):`relative` 以承载 spinner 叠加层。
83pub const BTN_OUTLINE: &str =
84    "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";
85
86/// 红色描边危险按钮(trash 清空回收站)。
87pub const BTN_DANGER_OUTLINE: &str =
88    "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";
89
90// --- 图标按钮 ---
91
92/// 关闭图标按钮(× 关闭提示条)。
93pub const BTN_CLOSE_ICON: &str =
94    "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none";
95
96/// 方形图标按钮(trash 步进 −/+)。
97pub const BTN_ICON: &str =
98    "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";
99
100// ===========================================================================
101// 组件
102// ===========================================================================
103
104/// 分页导航组件。
105///
106/// 统一了后台与前台的分页 UI,通过 `variant` 切换配色与展示细节:
107/// - `"admin"`:描边胶囊按钮(与 `BTN_OUTLINE` 同族),显示页码计数
108///   (`{当前} / {总} 页 (共 {total} {unit})`),首尾页渲染禁用态。
109/// - `"frontend"`:描边胶囊按钮与居中页码,首尾页不渲染越界按钮。
110///
111/// Props:
112/// - `variant`:`"admin"` 或 `"frontend"`
113/// - `current_page`:当前页码(从 1 开始)
114/// - `total`:数据总条数
115/// - `per_page`:每页条数,用于计算总页数
116/// - `prev_route`:点击上一页跳转的目标路由(路由式翻页用;回调式翻页可不传,默认 `None`)
117/// - `next_route`:点击下一页跳转的目标路由(路由式翻页用;回调式翻页可不传,默认 `None`)
118/// - `unit`:计数单位("篇" / "条"),仅 admin 显示计数时使用
119/// - `on_prev` / `on_next`:可选回调。传入时渲染 `<button onclick>` 走客户端
120///   signal 翻页(与路由式 `prev_route`/`next_route` 互斥,回调优先);不传则保持
121///   原路由式 `<Link>`。后台客户端分页列表(如「全部文章」)用回调翻页,
122///   前台/其他分页页面仍走路由,零影响。
123/// - `on_jump`:可选跳页回调。传入且总页数 > 1 时,admin 变体的当前页码渲染为
124///   可编辑输入框——聚焦编辑、回车跳转(自动夹取到 `[1, total_pages]`,非法输入
125///   还原为当前页)、失焦回显。仅回调式翻页可用——路由式翻页无法由任意页码反推
126///   `Route`,故路由式调用方不传此 prop,页码保持纯文本。
127/// - `compact`:admin 变体是否去掉默认外边距,供弹窗等固定底栏嵌入使用。
128#[component]
129pub fn Pagination<R: Routable + Clone + PartialEq + 'static>(
130    variant: &'static str,
131    current_page: i32,
132    total: i64,
133    per_page: i32,
134    #[props(default)] prev_route: Option<R>,
135    #[props(default)] next_route: Option<R>,
136    unit: &'static str,
137    #[props(default)] on_prev: Option<EventHandler<()>>,
138    #[props(default)] on_next: Option<EventHandler<()>>,
139    #[props(default)] on_jump: Option<EventHandler<i32>>,
140    #[props(default)] compact: bool,
141) -> Element {
142    let has_prev = current_page > 1;
143    let total_pages = ((total + per_page as i64 - 1) / per_page as i64).max(1) as i32;
144    let has_next = current_page < total_pages;
145
146    // admin 与 frontend 的配色差异。admin 上一页/下一页用描边胶囊,与本页其它
147    // 操作按钮(BTN_OUTLINE)同族,避免出现方角实心按钮破坏整体圆角语言。
148    let is_admin = variant == "admin";
149    let nav_class = if is_admin {
150        if compact {
151            "admin-pagination"
152        } else {
153            "admin-pagination mt-6"
154        }
155    } else {
156        "frontend-pagination"
157    };
158    let (link_class, link_extra_next): (String, &'static str) = if is_admin {
159        (
160            format!("{BTN_OUTLINE} inline-flex items-center active:scale-[0.98]"),
161            "",
162        )
163    } else {
164        (
165            format!("{BTN_OUTLINE} inline-flex items-center justify-center whitespace-nowrap focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-paper-primary"),
166            "col-start-3 row-start-1 justify-self-end",
167        )
168    };
169    let disabled_class =
170        "inline-flex items-center px-4 py-2 text-sm font-medium text-paper-secondary border border-paper-border rounded-full cursor-not-allowed";
171
172    // admin 首尾页渲染禁用态;frontend 首尾页直接不渲染。
173    // on_prev/on_next 存在时走回调式 button(客户端 signal 翻页),否则走路由式 Link。
174    let prev_inner = rsx! {
175        span { class: "mr-1", "«" }
176        "上一页"
177    };
178    let next_inner = rsx! {
179        "下一页"
180        span { class: "ml-1", "»" }
181    };
182
183    // 跳页状态:当前页码本身渲染为可编辑输入框(仅回调式翻页且多页时)。
184    // `jump_editing` = 输入框聚焦态——聚焦期间显示草稿,失焦回显 current_page,
185    // 与父组件翻页天然同步,无需 effect 桥接;`jump_draft` = 用户输入草稿。
186    // 两者皆为独立 UI 状态(非派生镜像),合法 use_signal。
187    let mut jump_editing: Signal<bool> = use_signal(|| false);
188    let mut jump_draft: Signal<String> = use_signal(String::new);
189    rsx! {
190        nav { class: nav_class, aria_label: "分页导航",
191            if has_prev {
192                if let Some(on_prev) = on_prev {
193                    button {
194                        class: "{link_class}",
195                        onclick: move |_| on_prev.call(()),
196                        {prev_inner}
197                    }
198                } else if let Some(pr) = prev_route.clone() {
199                    Link { class: "{link_class}", to: pr, {prev_inner} }
200                }
201            } else if is_admin {
202                span { class: "{disabled_class}",
203                    span { class: "mr-1", "«" }
204                    "上一页"
205                }
206            }
207
208            // admin 显示页码计数;回调式翻页且多页时,当前页码可直接编辑跳页。
209            if is_admin {
210                span { class: "flex items-center gap-1.5 text-sm text-paper-secondary whitespace-nowrap tabular-nums",
211                    if total_pages > 1 && on_jump.is_some() {
212                        FormInput {
213                            r#type: "text",
214                            placeholder: "",
215                            value: if jump_editing() { jump_draft() } else { current_page.to_string() },
216                            class: Some(
217                                "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",
218                            ),
219                            inputmode: Some("numeric"),
220                            title: Some("输入页码,回车跳转"),
221                            oninput: move |v: String| jump_draft.set(v),
222                            onfocus: move |_| {
223                                jump_draft.set(current_page.to_string());
224                                jump_editing.set(true);
225                            },
226                            onblur: move |_| jump_editing.set(false),
227                            onkeydown: move |e: KeyboardEvent| {
228                                if e.key() == Key::Enter {
229                                    if let Some(on_jump) = on_jump {
230                                        // 合法页码:夹取到 [1, total_pages] 后回调并回显;
231                                        // 非法输入(空串/非数字):还原为当前页。
232                                        if let Ok(p) = jump_draft().trim().parse::<i32>() {
233                                            let p = p.clamp(1, total_pages);
234                                            on_jump.call(p);
235                                            jump_draft.set(p.to_string());
236                                        } else {
237                                            jump_draft.set(current_page.to_string());
238                                        }
239                                    }
240                                }
241                            },
242                        }
243                    } else {
244                        "{current_page}"
245                    }
246                    " / {total_pages} 页 (共 {total} {unit})"
247                }
248            } else {
249                span {
250                    class: "col-start-2 row-start-1 text-xs text-paper-secondary whitespace-nowrap tabular-nums",
251                    aria_label: "第 {current_page} 页,共 {total_pages} 页",
252                    aria_current: "page",
253                    "{current_page} / {total_pages}"
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 if let Some(nr) = next_route.clone() {
265                    Link {
266                        class: "{link_class} {link_extra_next}",
267                        to: nr,
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/// 首页封面与头像共用的树芽占位图。
298#[component]
299pub fn SproutPlaceholder(class: &'static str) -> Element {
300    rsx! {
301        svg { class, view_box: "0 0 32 32", fill: "none", "aria-hidden": "true",
302            path { d: "M16 27V15M16 21C7 21 4 15 5 8C12 8 17 12 16 21ZM16 16C16 7 21 4 28 5C28 12 23 17 16 16M10 27H22", stroke: "currentColor", stroke_width: "1.2", stroke_linecap: "round", stroke_linejoin: "round" }
303        }
304    }
305}
306
307/// 用户头像:加载失败显示树芽,无图回退展示名首字符(accent 软底)。
308///
309/// 头像三态(图片 / 树芽 / 首字符)的统一实现,调用方经 `class` 控制尺寸与形状
310/// (须含 `w-* h-* rounded-full` 与字号如 `text-xs`)。使用处:后台侧栏
311/// 用户卡片(28px)、个人信息页身份卡(96px,外层按钮带 hover 遮罩)、
312/// 前台评论表单身份行(24px)与评论列表(32px)。
313///
314/// Props:
315/// - `name`:展示名(用于首字符兜底与 alt 文本)
316/// - `avatar_url`:头像 URL;`None` 或空白串时渲染首字符兜底
317/// - `class`:尺寸与形状类;组件内补 `object-cover`(图片)/ flex 居中(首字符)
318#[component]
319pub fn UserAvatar(name: String, avatar_url: Option<String>, class: &'static str) -> Element {
320    // 按 URL 记录失败,更换头像后仍可重新加载。
321    let mut failed_url = use_signal(|| None::<String>);
322    let initial = name
323        .chars()
324        .next()
325        .map(|c| c.to_uppercase().collect::<String>())
326        .unwrap_or_else(|| "?".to_string());
327    match avatar_url.filter(|u| !u.trim().is_empty()) {
328        Some(url) if failed_url.read().as_ref() == Some(&url) => rsx! {
329            span {
330                class: "{class} flex items-center justify-center bg-paper-entry text-paper-tertiary",
331                role: "img",
332                aria_label: "{name} 的头像",
333                SproutPlaceholder { class: "w-2/3 h-2/3" }
334            }
335        },
336        Some(url) => {
337            #[cfg(target_arch = "wasm32")]
338            let url_for_mount = url.clone();
339            rsx! {
340                img {
341                    class: "{class} object-cover",
342                    src: "{url}",
343                    alt: "{name} 的头像",
344                    loading: "lazy",
345                    decoding: "async",
346                    onerror: move |_| failed_url.set(Some(url.clone())),
347                    // SSR 图片可能在 hydration 前已失败,挂载时补查完成状态。
348                    onmounted: move |_event| {
349                        #[cfg(target_arch = "wasm32")]
350                        {
351                            use wasm_bindgen::JsCast;
352                            if let Some(img) = _event.data().downcast::<web_sys::Element>()
353                                .and_then(|element| element.dyn_ref::<web_sys::HtmlImageElement>())
354                            {
355                                if img.complete() && img.natural_width() == 0 {
356                                    failed_url.set(Some(url_for_mount.clone()));
357                                }
358                            }
359                        }
360                    },
361                }
362            }
363        }
364        None => rsx! {
365            span { class: "{class} flex items-center justify-center bg-[var(--color-paper-accent-soft)] text-[var(--color-paper-accent)] font-bold select-none",
366                "{initial}"
367            }
368        },
369    }
370}
371
372/// Tooltip 基础样式(胶囊:黑底白字,hover 显现)。水平定位由调用方经 `align` 选择,
373/// 不在此处写死居中——触发器贴容器边缘时居中会让 tooltip 溢出被 `overflow-hidden` 裁掉。
374const TOOLTIP_STYLE: &str =
375    "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";
376
377/// Tooltip 包裹组件。
378///
379/// 将任意触发器(按钮等)包裹后,鼠标 hover 时在上方或下方弹出提示。
380/// 用 CSS `group/tooltip` + `group-hover/tooltip:opacity-100` 实现,无 JS 状态,`pointer-events-none`
381/// 保证不拦截点击。
382///
383/// Props:
384/// - `tip`:提示文案
385/// - `children`:触发器元素(按钮 / 链接等)
386/// - `placement`:垂直方向,`"top"`(默认,弹出在触发器上方)或 `"bottom"`(下方)
387/// - `align`:水平对齐,`"center"`(默认,居中)/ `"start"`(左对齐,向右延伸)/
388///   `"end"`(右对齐,向左延伸)
389///
390/// 注意:父容器若有 `overflow-hidden`(如 `ADMIN_TABLE_CLASS`),`position:absolute`
391/// 的 tooltip 会被裁掉。此时除选朝外的 `placement` 外,还须用 `align` 让 tooltip
392/// 朝容器内侧延伸——否则居中的宽 tooltip 越过容器边缘即被裁(见 issue #14:
393/// 表格最右列按钮的 tooltip 右半段被裁、左半段压住相邻内容)。
394#[component]
395pub fn Tooltip(
396    tip: String,
397    children: Element,
398    #[props(default = "top")] placement: &'static str,
399    #[props(default = "center")] align: &'static str,
400) -> Element {
401    // 朝上:tooltip 在触发器上方(bottom-full + mb-2);朝下:在下方(top-full + mt-2)。
402    let position_class = if placement == "bottom" {
403        "top-full mt-2"
404    } else {
405        "bottom-full mb-2"
406    };
407    // 水平对齐:center 居中 / start 左对齐向右 / end 右对齐向左。
408    let align_class = match align {
409        "start" => "left-0",
410        "end" => "right-0",
411        _ => "left-1/2 -translate-x-1/2",
412    };
413    rsx! {
414        div { class: "group/tooltip relative inline-flex",
415            {children}
416            div { class: "{TOOLTIP_STYLE} {position_class} {align_class}", "{tip}" }
417        }
418    }
419}
420/// 可折叠卡片外壳,默认使用后台设置样式,前台可通过 `class` 定制外观。
421///
422/// 复用回收站与系统设置的同一套交互:状态指示灯、摘要标题、旋转箭头,以及
423/// `grid-template-rows` 的 0fr↔1fr 平滑展开。调用方通过 `children` 提供面板内容,
424/// 通过 `on_toggle` 在展开状态变化时清理本地反馈等附加状态。
425///
426/// Props:
427/// - `title`:卡片标题
428/// - `summary`:标题下的当前状态摘要
429/// - `enabled`:状态指示灯是否使用主题色
430/// - `children`:折叠面板内容(调用方负责内容内边距与分隔线)
431/// - `on_toggle`:可选的展开/收起回调
432/// - `default_open`:首次挂载时是否展开
433/// - `class` / `panel_id`:可选的外观类名与面板 ID(连接 aria-controls)
434#[component]
435pub fn CollapsibleSettingsCard(
436    title: String,
437    summary: String,
438    enabled: bool,
439    children: Element,
440    #[props(default)] on_toggle: Option<EventHandler<()>>,
441    #[props(default)] default_open: bool,
442    #[props(default)] class: String,
443    #[props(default)] panel_id: Option<String>,
444) -> Element {
445    let mut open = use_signal(|| default_open);
446    let chevron_rotate = if open() { "rotate-180" } else { "" };
447    let dot_class = if enabled {
448        "w-2 h-2 rounded-full bg-paper-accent shadow-[0_0_0_3px_rgba(64,160,43,0.15)]"
449    } else {
450        "w-2 h-2 rounded-full bg-paper-tertiary"
451    };
452
453    rsx! {
454        div {
455            class: "collapsible-card rounded-2xl border border-paper-border overflow-hidden bg-paper-entry {class}",
456            "data-open": "{open()}",
457            button {
458                r#type: "button",
459                class: "collapsible-trigger 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",
460                aria_expanded: "{open()}",
461                aria_controls: panel_id.clone(),
462                onclick: move |_| {
463                    open.set(!open());
464                    if let Some(on_toggle) = on_toggle {
465                        on_toggle.call(());
466                    }
467                },
468                div { class: "collapsible-indicator w-2 flex-shrink-0 flex items-center justify-center", aria_hidden: "true",
469                    div { class: "{dot_class}" }
470                }
471                div { class: "collapsible-heading flex-1 min-w-0",
472                    div { class: "collapsible-title text-sm font-medium text-paper-primary", "{title}" }
473                    div { class: "collapsible-summary text-xs text-paper-secondary mt-0.5 truncate", "{summary}" }
474                }
475                svg {
476                    class: "collapsible-chevron w-4 h-4 text-paper-secondary transition-transform duration-200 flex-shrink-0 {chevron_rotate}",
477                    "aria-hidden": "true",
478                    view_box: "0 0 24 24",
479                    fill: "none",
480                    stroke: "currentColor",
481                    stroke_width: "2",
482                    path {
483                        stroke_linecap: "round",
484                        stroke_linejoin: "round",
485                        d: "M19 9l-7 7-7-7",
486                    }
487                }
488            }
489            div {
490                id: panel_id,
491                class: "collapsible-panel",
492                // 常驻 DOM 保留双向动画;收起时立即移出键盘与辅助技术的交互范围。
493                inert: if open() { None } else { Some("") },
494                div { class: "overflow-hidden min-h-0", {children} }
495            }
496        }
497    }
498}
499
500/// Popover 遮罩与面板的层级(z-40 遮罩 < z-50 面板),与 Tooltip/lightbox 同 z-50。
501const POPOVER_OVERLAY_CLASS: &str = "fixed inset-0 z-40";
502/// Popover 面板:卡片化大圆角 + 阴影 + 淡入缩放动画。
503/// 居中变体:animate-popover-enter 的关键帧烘了 translateX(-50%),与居中的
504/// 静态 transform 一致;端点对齐(start/end)变体静态 transform 为空,必须换用
505/// 无位移的 animate-popover-enter-edge,否则 both fill 期面板水平错位半宽。
506const POPOVER_PANEL_CLASS: &str =
507    "fixed z-50 bg-[var(--color-paper-entry)] rounded-2xl shadow-lg border border-[var(--color-paper-border)] p-4 animate-popover-enter";
508const POPOVER_PANEL_EDGE_CLASS: &str =
509    "fixed z-50 bg-[var(--color-paper-entry)] rounded-2xl shadow-lg border border-[var(--color-paper-border)] p-4 animate-popover-enter-edge";
510
511/// 受控式通用 Popover(浮层)组件。
512///
513/// 与 [`Tooltip`] 对称——但 Tooltip 是纯 CSS hover、无状态;Popover 是点击触发、
514/// 受控开关,用于承载确认框、轻量表单等交互内容。
515///
516/// ## 定位策略
517///
518/// 父容器常有 `overflow-hidden`(如 `ADMIN_TABLE_CLASS`),`position:absolute` 子节点
519/// 会被裁掉,故面板用 **`position:fixed`**,以触发点击的**视口坐标**(`MouseEvent::
520/// client_coordinates()`)作为锚点(参照 `theme.rs` 圆形展开动画的坐标用法)。无需
521/// `getBoundingClientRect`/`node_ref`。
522///
523/// - `placement: "top"`(默认):面板底边贴点击点上方(`bottom: 100vh - y + gap`)。
524/// - `placement: "bottom"`:面板顶边贴点击点下方(`top: y + gap`)。
525/// - 水平由 `align` 决定:`"center"`(默认)面板中心对齐点击点(`left: x` +
526///   `-translate-x-1/2`);`"start"` 面板左缘贴点向右延伸(`left: x`);
527///   `"end"` 面板右缘贴点向左延伸(`right: 100vw - x`)。
528///
529/// 触发器贴近视口边缘时(如表格最右列的按钮),居中的宽面板会越出视口——
530/// 此时用 `align` 让面板朝视口内侧延伸(与 Tooltip 的 align 同款约定,
531/// 见 issue #14)。`end` 用 `right` 锚定右缘,与面板内容宽度无关,天然不越右缘。
532///
533/// ## 关闭路径(三条)
534///
535/// 1. 点遮罩(透明,仅作点击兜底)→ `on_close`。
536/// 2. Escape 键 → `on_close`(组件内 `use_effect` 注册全局 keydown 监听,`use_drop` 清理)。
537/// 3. 面板内确认/取消按钮调用 `on_close`。
538///
539/// ## Props
540///
541/// - `open`:受控开关;`false` 时组件不渲染任何内容(SSR 安全)。
542/// - `anchor_x` / `anchor_y`:触发点击的视口坐标。
543/// - `placement`:`"top"`(默认)/ `"bottom"`。
544/// - `align`:`"center"`(默认)/ `"start"` / `"end"`(水平对齐,见上)。
545/// - `children`:面板内容(确认框等)。
546/// - `on_close`:任一关闭路径触发。
547#[component]
548#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
549pub fn Popover(
550    open: bool,
551    anchor_x: i32,
552    anchor_y: i32,
553    children: Element,
554    on_close: EventHandler<()>,
555    #[props(default = "top")] placement: &'static str,
556    #[props(default = "center")] align: &'static str,
557) -> Element {
558    // Escape 关闭:组件 open 时注册全局 keydown 监听,关闭/卸载时移除。
559    // 手写最小 listener 而非复用 use_event_listener——后者 handler 无参,拿不到
560    // KeyboardEvent 的 key()。用 use_hook 持有 Closure,use_effect 注册,use_drop 清理。
561    #[cfg(target_arch = "wasm32")]
562    {
563        use dioxus::prelude::{use_drop, use_effect, use_hook, use_reactive};
564        use std::cell::RefCell;
565        use std::rc::Rc;
566        type EscState =
567            Rc<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>>>>;
568        let state: EscState = use_hook(|| Rc::new(RefCell::new(None)));
569        let state_for_drop = state.clone();
570        let open_for_effect = open;
571        let on_close_for_esc = on_close;
572        use_effect(use_reactive((&open_for_effect,), move |(is_open,)| {
573            let Some(window) = web_sys::window() else {
574                return;
575            };
576            if let Some(previous) = state.borrow_mut().take() {
577                let _ = window.remove_event_listener_with_callback(
578                    "keydown",
579                    wasm_bindgen::JsCast::unchecked_ref(previous.as_ref()),
580                );
581            }
582            if !is_open {
583                return;
584            }
585            let on_close_for_esc = on_close_for_esc;
586            // Closure 带 KeyboardEvent 参数:浏览器调用 handler 时传入事件对象,
587            // 无需依赖已废弃的 window.event()。as_ref + unchecked_ref 转成 JS Function。
588            let closure =
589                wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| {
590                    if ev.key() == "Escape" {
591                        on_close_for_esc.call(());
592                    }
593                })
594                    as Box<dyn FnMut(web_sys::KeyboardEvent)>);
595            let _ = window.add_event_listener_with_callback(
596                "keydown",
597                wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
598            );
599            *state.borrow_mut() = Some(closure);
600        }));
601        use_drop(move || {
602            if let Some(closure) = state_for_drop.borrow_mut().take() {
603                if let Some(window) = web_sys::window() {
604                    let _ = window.remove_event_listener_with_callback(
605                        "keydown",
606                        wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
607                    );
608                }
609            }
610        });
611    }
612
613    if !open {
614        return rsx! {};
615    }
616
617    // 面板定位:placement 决定垂直方向;align 决定水平。end 用 right 锚定
618    // (右缘 = 点击点),无需 translate,故与面板宽度解耦;start/end 的入场动画
619    // 必须是无位移变体(关键帧 fill 值须等于静态 transform,见常量注释)。
620    let horizontal = match align {
621        "start" => format!("left: {x}px;", x = anchor_x),
622        "end" => format!("right: calc(100vw - {x}px);", x = anchor_x),
623        _ => format!("left: {x}px; transform: translateX(-50%);", x = anchor_x),
624    };
625    let style = if placement == "bottom" {
626        format!("top: {y}px; {horizontal}", y = anchor_y + 8)
627    } else {
628        // top:面板在点击点上方——用 bottom 锚定 viewport 底,差值即视口高度 - y + 间隙。
629        // 视口高度用 100vh,纯 CSS 无需 JS 读取 scrollHeight。
630        format!(
631            "bottom: calc(100vh - {y}px + 8px); {horizontal}",
632            y = anchor_y
633        )
634    };
635    let panel_class = if align == "center" {
636        POPOVER_PANEL_CLASS
637    } else {
638        POPOVER_PANEL_EDGE_CLASS
639    };
640
641    rsx! {
642        // 透明遮罩:拦截外部点击(点遮罩即关)。z-40 < 面板 z-50。
643        div {
644            class: "{POPOVER_OVERLAY_CLASS}",
645            onclick: move |_| on_close.call(()),
646        }
647        // 面板:fixed 定位逃出 overflow-hidden 容器。
648        div { class: "{panel_class}", style: "{style}", {children} }
649    }
650}
651
652/// 弹窗关闭/元素退出动画统一时长(ms),与 input.css 里 `.modal-panel` / `.animate-row-leave`
653/// 等退出过渡动画时长一一对应;调用方需要在动画播放完毕后再真正卸载/摘除元素时复用本常量。
654pub const EXIT_ANIM_MS: u32 = 200;
655
656/// 弹窗遮罩基础类(不含内边距——不同弹窗尺寸的响应式内边距不同,经 `overlay_padding` 传入)。
657const MODAL_OVERLAY_BASE: &str = "fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm modal-overlay animate-modal-overlay-enter";
658/// 弹窗面板基础类(不含宽度——不同弹窗尺寸不同,经 `panel_class` 传入追加)。
659const MODAL_PANEL_BASE: &str = "flex flex-col max-h-[80vh] rounded-[2rem] border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] shadow-xl overflow-hidden modal-panel animate-modal-panel-enter";
660
661/// 通用弹窗外壳:遮罩 + 面板容器 + 开合动画状态机(`opened` / `closing` / 退出计时)。
662///
663/// 收敛 `AssetUploadModal` / `AssetPickerModal` 曾各自照抄的一份遮罩 + 面板 + 开合动画
664/// 状态机,顺带补齐两者曾意外分叉的 `role="dialog"` / `aria-modal` / `aria-label`
665/// (此前只有 `AssetPickerModal` 有)。调用方仍各自负责标题栏、内容区等具体内容
666/// (经 `children` 传入),以及自身域相关的开关副作用(如重置搜索词/选中集)。
667///
668/// ## 关闭路径与同帧动画
669///
670/// 本组件自身处理遮罩点击关闭。调用方若在 `children` 内还有别的关闭入口
671/// (标题栏 × 按钮、选中即关闭等),必须用与本组件相同的写法**同步**先置
672/// `closing.set(true)` 再置 `visible.set(false)`——同一帧内让存活元素换上
673/// `.is-closing` 类,过渡动画才能从可见态播放(只翻 `visible`、等下一帧的
674/// `use_effect` 兜底补置 `closing` 为时已晚,元素已被提前卸载,动画会变成
675/// 瞬间消失而非淡出)。因此 `visible` / `closing` 是与调用方共享的 `Signal`,
676/// 而非本组件内部私有状态;`opened`(mount 守卫)才是本组件私有的。
677///
678/// ## Props
679///
680/// - `visible` / `closing`:与调用方共享的显隐 / 关闭动画信号。
681/// - `title`:面板的 `aria-label`(不渲染可见标题——各调用方的标题栏自行渲染)。
682/// - `overlay_padding`:遮罩内边距(响应式内边距因弹窗尺寸而异,默认 `"p-6"`)。
683/// - `panel_class`:在共享面板底座上追加的宽度等布局类(默认 `"w-full max-w-lg"`)。
684/// - `children`:面板内容(含调用方自己的标题栏、× 按钮等)。
685#[component]
686pub fn ModalShell(
687    mut visible: Signal<bool>,
688    mut closing: Signal<bool>,
689    title: &'static str,
690    #[props(default = "p-6")] overlay_padding: &'static str,
691    #[props(default = "w-full max-w-lg")] panel_class: &'static str,
692    children: Element,
693) -> Element {
694    // 公共弹窗在打开时接管焦点,Escape 关闭后还给触发器;监听随显隐同步清理。
695    #[cfg(target_arch = "wasm32")]
696    {
697        use std::cell::RefCell;
698        use std::rc::Rc;
699        use wasm_bindgen::JsCast;
700
701        type KeyListener =
702            Rc<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>>>>;
703        let listener: KeyListener = use_hook(|| Rc::new(RefCell::new(None)));
704        let previous_focus: Rc<RefCell<Option<web_sys::Element>>> =
705            use_hook(|| Rc::new(RefCell::new(None)));
706        let listener_for_drop = listener.clone();
707        let mut close_visible = visible;
708        let mut close_closing = closing;
709        use_effect(move || {
710            let is_visible = visible();
711            let Some(window) = web_sys::window() else {
712                return;
713            };
714            if let Some(old) = listener.borrow_mut().take() {
715                let _ = window
716                    .remove_event_listener_with_callback("keydown", old.as_ref().unchecked_ref());
717            }
718            if is_visible {
719                if let Some(document) = window.document() {
720                    *previous_focus.borrow_mut() = document.active_element();
721                    if let Ok(panels) = document
722                        .query_selector_all("[data-ygg-modal-panel][data-ygg-modal-open='true']")
723                    {
724                        for index in (0..panels.length()).rev() {
725                            if let Some(panel) = panels
726                                .item(index)
727                                .and_then(|node| node.dyn_into::<web_sys::Element>().ok())
728                            {
729                                if panel.get_attribute("aria-label").as_deref() == Some(title) {
730                                    if let Some(element) = panel.dyn_ref::<web_sys::HtmlElement>() {
731                                        let _ = element.focus();
732                                    }
733                                    break;
734                                }
735                            }
736                        }
737                    }
738                }
739                let closure = wasm_bindgen::prelude::Closure::wrap(Box::new(
740                    move |event: web_sys::KeyboardEvent| {
741                        if event.key() == "Escape" {
742                            close_closing.set(true);
743                            close_visible.set(false);
744                        }
745                    },
746                )
747                    as Box<dyn FnMut(web_sys::KeyboardEvent)>);
748                let _ = window
749                    .add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
750                *listener.borrow_mut() = Some(closure);
751            } else if let Some(element) = previous_focus.borrow_mut().take() {
752                if let Some(element) = element.dyn_ref::<web_sys::HtmlElement>() {
753                    let _ = element.focus();
754                }
755            }
756        });
757        use_drop(move || {
758            if let Some(old) = listener_for_drop.borrow_mut().take() {
759                if let Some(window) = web_sys::window() {
760                    let _ = window.remove_event_listener_with_callback(
761                        "keydown",
762                        old.as_ref().unchecked_ref(),
763                    );
764                }
765            }
766        });
767    }
768
769    // 是否曾开过弹窗:mount 时 visible=false 也会跑一次 use_effect,不加此守卫会
770    // 在页面加载后 EXIT_ANIM_MS 内渲染一层透明遮罩(opacity:0 仍拦截点击)吞掉首次点击。
771    let mut opened = use_signal(|| false);
772
773    // visible 翻转驱动关闭动画:关闭入口(× / 遮罩 / Esc)会同步先置 closing 再翻
774    // visible(见本组件 doc);这里只负责重开复位与 EXIT_ANIM_MS 后的复位卸载。
775    // 闭包只订阅 visible,closing/opened 全用 peek 防自触发循环。
776    use_effect(move || {
777        if visible() {
778            opened.set(true);
779            closing.set(false);
780        } else if *opened.peek() {
781            // 非交互路径的 visible 翻转(理论上不存在)兜底补置 closing。
782            if !*closing.peek() {
783                closing.set(true);
784            }
785            spawn(async move {
786                crate::utils::time::sleep_ms(EXIT_ANIM_MS).await;
787                closing.set(false);
788            });
789        }
790    });
791
792    // closing() 的订阅读是必需的——closing.set 靠这个订阅触发重渲染,peek 不会重绘。
793    let is_closing = closing();
794    if !visible() && !is_closing {
795        return rsx! {};
796    }
797
798    rsx! {
799        // 遮罩:点击关闭。
800        div {
801            class: "{MODAL_OVERLAY_BASE} {overlay_padding}",
802            class: if is_closing { "is-closing" } else { "" },
803            onclick: move |_| {
804                closing.set(true);
805                visible.set(false);
806            },
807            // 面板:阻止点击穿透到遮罩。
808            div {
809                class: "{MODAL_PANEL_BASE} {panel_class}",
810                role: "dialog",
811                tabindex: "-1",
812                "data-ygg-modal-panel": "true",
813                "data-ygg-modal-open": if visible() { "true" } else { "false" },
814                aria_modal: "true",
815                aria_label: "{title}",
816                onclick: move |evt| evt.stop_propagation(),
817                {children}
818            }
819        }
820    }
821}
822
823static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
824
825/// 筛选选项卡组件。
826///
827/// 用于切换不同的视图或筛选条件(例如:全部、待审核、已通过等)。
828/// 具备高级的平滑滑动底部指示器动画。
829///
830/// Props:
831/// - `items`:选项卡列表,每一项为 `(value, label)`
832/// - `active_value`:当前选中的值
833/// - `on_change`:选项卡切换时的回调
834#[component]
835pub fn FilterTabs(
836    items: Vec<(&'static str, &'static str)>,
837    active_value: String,
838    on_change: EventHandler<String>,
839) -> Element {
840    #[allow(unused_mut)]
841    let mut indicator_style = use_signal(|| "left: 0px; width: 0px; opacity: 0;".to_string());
842    let id_prefix = use_hook(|| TAB_GROUP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
843
844    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
845    let update_indicator = move |active: String| {
846        spawn(async move {
847            #[cfg(target_arch = "wasm32")]
848            {
849                use wasm_bindgen::JsCast;
850
851                // 等待 DOM 节点更新
852                crate::utils::time::sleep_ms(50).await;
853
854                if let Some(window) = web_sys::window() {
855                    if let Some(doc) = window.document() {
856                        let element_id = format!("tab-{}-{}", id_prefix, active);
857                        if let Some(el) = doc.get_element_by_id(&element_id) {
858                            if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
859                                let left = html_el.offset_left();
860                                let width = html_el.offset_width();
861                                indicator_style.set(format!(
862                                    "left: {}px; width: {}px; opacity: 1;",
863                                    left, width
864                                ));
865                            }
866                        }
867                    }
868                }
869            }
870        });
871    };
872
873    use_effect({
874        let active_value = active_value.clone();
875        move || {
876            update_indicator(active_value.clone());
877        }
878    });
879
880    rsx! {
881        div { class: "relative flex gap-4 border-b border-paper-border mb-6",
882            for (value, label) in items {
883                button {
884                    id: "tab-{id_prefix}-{value}",
885                    key: "{value}",
886                    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" },
887                    onclick: {
888                        let v = value.to_string();
889                        move |_| {
890                            on_change.call(v.clone());
891                            update_indicator(v.clone());
892                        }
893                    },
894                    "{label}"
895                }
896            }
897            // 绝对定位的滑动颜色条
898            div {
899                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",
900                style: "{indicator_style}",
901            }
902        }
903    }
904}
905
906/// 主操作按钮,内置 loading spinner 叠加(统一三态)。
907///
908/// 用于全站所有主题绿 CTA(执行 / 刷新 / 创建备份 / 发布 / 保存设置)。
909/// 三态:
910/// - `loading=true`:主题绿底 + 文字隐藏(`opacity-0`)+ spinner 绝对居中
911///   (按钮宽度不变,避免加载时布局抖动)
912/// - `disabled=true`(且未 loading):灰色底(`bg-paper-tertiary`)+
913///   `cursor-not-allowed`
914/// - 正常:主题绿底 + `hover:brightness-110` + `active:scale-[0.98]`
915///
916/// Props:
917/// - `label`:正常态显示的文案(loading 时隐藏,由 spinner 占位)
918/// - `loading`:是否处于加载态
919/// - `disabled`:是否禁用(loading 优先级更高)
920/// - `variant`:`"primary"`(默认,`px-5 py-2`)或 `"sm"`(`px-4 py-1.5`)
921/// - `onclick`:点击回调
922#[component]
923pub fn LoadingButton(
924    label: String,
925    loading: bool,
926    #[props(default = false)] disabled: bool,
927    #[props(default = "primary")] variant: &'static str,
928    onclick: EventHandler<()>,
929) -> Element {
930    let base = if variant == "sm" {
931        BTN_PRIMARY_SM
932    } else {
933        BTN_PRIMARY
934    };
935    // 尺寸随 base 常量而定;禁用态需要独立灰色配色(BTN_PRIMARY/_SM 未建模禁用态),
936    // 故仅在此分支手写尺寸 + 灰色板,正常态直接复用常量,避免两套配色定义分叉。
937    let class = if disabled && !loading {
938        let size = if variant == "sm" {
939            "px-4 py-1.5"
940        } else {
941            "px-5 py-2"
942        };
943        format!(
944            "relative inline-flex items-center justify-center {size} rounded-full text-sm font-medium transition-all bg-[var(--color-paper-tertiary)] text-[var(--color-paper-secondary)] cursor-not-allowed"
945        )
946    } else {
947        format!("relative {base}")
948    };
949
950    rsx! {
951        button {
952            class: "{class}",
953            disabled: loading || disabled,
954            onclick: move |_| onclick.call(()),
955            span { class: if loading { "opacity-0" } else { "" }, "{label}" }
956            if loading {
957                span {
958                    class: "absolute inset-0 flex items-center justify-center",
959                    dangerous_inner_html: SPINNER_SVG,
960                }
961            }
962        }
963    }
964}
965
966/// 标签芯片组件:统一归档索引、软底标签、卡片胶囊与文字链接的展示。
967///
968/// 收敛原本散落在标签云与文章卡片(`post_card.rs` 描边胶囊)
969/// 的两套手写 `Link` 样式——两者都是跳转到 [`Route::TagDetail`] 的可点击标签,
970/// 仅视觉变体不同,故合并为一个组件 + `variant` prop。
971///
972/// Props:
973/// - `label`:标签名
974/// - `to`:跳转目标路由(泛型,调用方传入具体 `Route` 变体,原子层不绑定 app 路由类型)
975/// - `variant`:`"archive"`(归档标签索引)/ `"solid"`(软底)/ `"outline"`(卡片胶囊)/ `"text"`(紧凑文章流)
976/// - `count`:可选的文章计数,归档变体使用独立计数徽标,其余使用 `<sup>`
977/// - `stop_propagation`:是否阻止点击冒泡(卡片内覆盖层链接场景需要,见 `PostCard`)
978#[component]
979pub fn TagChip<R: Routable + Clone + PartialEq + 'static>(
980    label: String,
981    to: R,
982    #[props(default = "outline")] variant: &'static str,
983    #[props(default)] count: Option<i64>,
984    #[props(default)] stop_propagation: bool,
985) -> Element {
986    let class = match variant {
987        "archive" => "archive-tag-chip",
988        "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",
989        "text" => "inline-flex items-center py-1 text-paper-secondary hover:text-paper-primary underline decoration-paper-border underline-offset-4 hover:decoration-paper-accent transition-colors focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-paper-accent",
990        _ => "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",
991    };
992    rsx! {
993        Link {
994            class: "{class}",
995            to,
996            onclick: move |evt: dioxus::events::MouseEvent| {
997                if stop_propagation {
998                    evt.stop_propagation();
999                }
1000            },
1001            if variant == "archive" {
1002                span { class: "archive-tag-mark", aria_hidden: "true", "#" }
1003                span { class: "archive-tag-name", "{label}" }
1004            } else {
1005                "{label}"
1006            }
1007            if let Some(c) = count {
1008                if variant == "archive" {
1009                    span { class: "archive-tag-count", aria_label: "{c} 篇文章", "{c}" }
1010                } else {
1011                    sup { class: "ml-1 text-sm text-paper-secondary", "{c}" }
1012                }
1013            }
1014        }
1015    }
1016}
1017
1018/// 带勾画动画的复选框:对勾 SVG 用 stroke-dashoffset 描边绘制 + scale 弹入。
1019///
1020/// 原生 `<input type="checkbox">` 是替换元素,吃不到 `::after`/`::before`,
1021/// 无法做 transform 描边动画。本组件用透明 input 做命中/无障碍层,同级 SVG
1022/// path 做对勾视觉层;勾选时 path 的 stroke-dashoffset 归零「画出」对勾,
1023/// 同时 SVG scale 从 0.6 弹入(弹跳曲线)——双层动画带来盖戳质感。
1024///
1025/// 与旧的 `.ygg-checkbox`(纯 background-size 缩放)相比,描边动画更像手写勾画,
1026/// 且 SVG 是矢量合成层,比光栅 background-image 缩放更锐利。
1027///
1028/// Props:
1029/// - `checked`:受控勾选态
1030/// - `onchange`:状态变化回调,返回新的 bool
1031/// - `danger`:危险态(红色语义,SQL 控制台「我了解后果」),缺省 `false`
1032#[component]
1033pub fn Checkbox(
1034    checked: bool,
1035    onchange: EventHandler<bool>,
1036    #[props(default)] danger: bool,
1037) -> Element {
1038    let wrap = if danger {
1039        "ygg-cb ygg-cb-danger"
1040    } else {
1041        "ygg-cb"
1042    };
1043    rsx! {
1044        span { class: "{wrap}",
1045            input {
1046                r#type: "checkbox",
1047                checked,
1048                onchange: move |e: Event<FormData>| onchange.call(e.checked()),
1049            }
1050            svg { class: "ygg-cb-mark", view_box: "0 0 16 16",
1051                path { class: "ygg-cb-check", d: "M3.5 8.5l3 3 6-6.5" }
1052            }
1053        }
1054    }
1055}
1056
1057/// 主题化单选框,与 Checkbox 使用相同尺寸、主题色和焦点描边。
1058///
1059/// 同组使用相同 `name`,每项提供独立 `value`;用外层 `label` 关联可见文案。
1060/// 保留原生 radio 的互斥、方向键和表单语义,`onchange` 返回选中项的值。
1061#[component]
1062pub fn Radio(
1063    name: String,
1064    value: String,
1065    checked: bool,
1066    onchange: EventHandler<String>,
1067    #[props(default)] disabled: bool,
1068) -> Element {
1069    rsx! {
1070        input {
1071            class: "ygg-radio",
1072            r#type: "radio",
1073            name,
1074            value,
1075            checked,
1076            disabled,
1077            onchange: move |event: Event<FormData>| onchange.call(event.value()),
1078        }
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085
1086    #[test]
1087    fn tooltip_uses_named_group_to_prevent_ancestor_trigger() {
1088        assert!(
1089            TOOLTIP_STYLE.contains("group-hover/tooltip:opacity-100"),
1090            "Tooltip 必须使用专属命名空间 group-hover/tooltip:opacity-100,避免被外层祖先 group 误触"
1091        );
1092    }
1093}