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 形成层次。
19pub const ADMIN_CARD_CLASS: &str = "bg-[var(--color-paper-entry)] rounded-2xl shadow-sm border border-transparent hover:border-[var(--color-paper-border)] transition-colors";
20
21/// Admin 表格容器:内容档圆角(16px),与卡片一致。
22pub const ADMIN_TABLE_CLASS: &str = "bg-[var(--color-paper-entry)] rounded-2xl shadow-sm border border-transparent hover:border-[var(--color-paper-border)] transition-colors overflow-hidden";
23
24/// Admin 表格行 hover 态:底部分割线 + 悬停背景。
25pub const ADMIN_ROW_HOVER: &str =
26    "border-b border-paper-border last:border-b-0 hover:bg-[var(--color-paper-accent-soft)] transition-colors";
27
28/// 列表复选框统一样式(全选表头 + 行内)。
29pub const CHECKBOX_CLASS: &str = "rounded border-paper-border";
30
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/// 状态徽章外层:小号圆角胶囊。
38pub const BADGE_BASE: &str =
39    "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap";
40
41// --- 实心小按钮(批量操作栏:通过 / 垃圾 / 删除) ---
42
43/// 绿色实心小按钮(批量通过、批量恢复)。
44pub const BTN_SOLID_GREEN: &str =
45    "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";
46/// 琥珀色实心小按钮(批量标为垃圾)。
47pub const BTN_SOLID_AMBER: &str =
48    "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";
49/// 红色实心小按钮(批量删除、批量彻底删除)。
50pub const BTN_SOLID_RED: &str =
51    "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";
52
53// --- 文字小按钮(表格行内操作:通过 / 垃圾 / 删除 / 恢复) ---
54
55/// 绿色文字小按钮(行内通过)。
56pub 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";
57/// 琥珀色文字小按钮(行内标为垃圾)。
58pub 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";
59/// 红色文字小按钮(行内删除 / 彻底删除)。
60pub const BTN_TEXT_RED: &str =
61    "text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300 transition-colors cursor-pointer";
62/// 主题绿(鼠尾草)文字小按钮(行内恢复)。
63pub const BTN_TEXT_ACCENT: &str =
64    "text-xs text-paper-accent hover:text-paper-primary transition-colors cursor-pointer";
65
66// --- 次要按钮(Teal 第二色,ghost 描边风格,从属于主色 Green) ---
67
68/// 次要按钮:极简风次要操作。
69pub const BTN_SECONDARY: &str =
70    "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";
71
72// --- 主操作按钮(主题绿实心胶囊,全站统一 CTA) ---
73
74/// 主操作按钮:主题绿实心胶囊(用于 `<Link>`、无 loading 态的静态按钮)。
75pub const BTN_PRIMARY: &str =
76    "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";
77
78/// 小号主操作按钮:工具栏场景(刷新 / 导出 / 创建备份)。
79pub const BTN_PRIMARY_SM: &str =
80    "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";
81
82// --- 描边按钮 ---
83
84/// 描边次要按钮(posts 重建、system 刷新列表):`relative` 以承载 spinner 叠加层。
85pub const BTN_OUTLINE: &str =
86    "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";
87
88/// 红色描边危险按钮(trash 清空回收站)。
89pub const BTN_DANGER_OUTLINE: &str =
90    "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";
91
92// --- 图标按钮 ---
93
94/// 关闭图标按钮(× 关闭提示条)。
95pub const BTN_CLOSE_ICON: &str =
96    "shrink-0 text-red-400 hover:text-red-600 cursor-pointer text-lg leading-none";
97
98/// 方形图标按钮(trash 步进 −/+)。
99pub const BTN_ICON: &str =
100    "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";
101
102// ===========================================================================
103// 组件
104// ===========================================================================
105
106/// 分页导航组件。
107///
108/// 统一了后台与前台的分页 UI,通过 `variant` 切换配色与展示细节:
109/// - `"admin"`:描边胶囊按钮(与 `BTN_OUTLINE` 同族),显示页码计数
110///   (`{当前} / {总} 页 (共 {total} {unit})`),首尾页渲染禁用态。
111/// - `"frontend"`:主题绿胶囊按钮,不显示计数,首尾页直接不渲染按钮。
112///
113/// Props:
114/// - `variant`:`"admin"` 或 `"frontend"`
115/// - `current_page`:当前页码(从 1 开始)
116/// - `total`:数据总条数
117/// - `per_page`:每页条数,用于计算总页数
118/// - `prev_route`:点击上一页跳转的目标路由(路由式翻页用)
119/// - `next_route`:点击下一页跳转的目标路由(路由式翻页用)
120/// - `unit`:计数单位("篇" / "条"),仅 admin 显示计数时使用
121/// - `on_prev` / `on_next`:可选回调。传入时渲染 `<button onclick>` 走客户端
122///   signal 翻页(与路由式 `prev_route`/`next_route` 互斥,回调优先);不传则保持
123///   原路由式 `<Link>`。后台单路由 tab(如「管理文章」列表)用回调翻页,
124///   前台/其他分页页面仍走路由,零影响。
125/// - `on_jump`:可选跳页回调。传入且总页数 > 1 时,admin 变体的当前页码渲染为
126///   可编辑输入框——聚焦编辑、回车跳转(自动夹取到 `[1, total_pages]`,非法输入
127///   还原为当前页)、失焦回显。仅回调式翻页可用——路由式翻页无法由任意页码反推
128///   `Route`,故路由式调用方不传此 prop,页码保持纯文本。
129#[component]
130pub fn Pagination(
131    variant: &'static str,
132    current_page: i32,
133    total: i64,
134    per_page: i32,
135    #[props(default = Route::Home {})] prev_route: Route,
136    #[props(default = Route::Home {})] next_route: Route,
137    unit: &'static str,
138    #[props(default)] on_prev: Option<EventHandler<()>>,
139    #[props(default)] on_next: Option<EventHandler<()>>,
140    #[props(default)] on_jump: Option<EventHandler<i32>>,
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 (link_class, link_extra_next) = if is_admin {
150        (
151            "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",
152            "",
153        )
154    } else {
155        (
156            "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",
157            "ml-auto",
158        )
159    };
160    let disabled_class =
161        "inline-flex items-center px-4 py-2 text-sm font-medium text-paper-secondary border border-paper-border rounded-full cursor-not-allowed";
162
163    // admin 首尾页渲染禁用态;frontend 首尾页直接不渲染。
164    // on_prev/on_next 存在时走回调式 button(客户端 signal 翻页),否则走路由式 Link。
165    let prev_inner = rsx! {
166        span { class: "mr-1", "«" }
167        "上一页"
168    };
169    let next_inner = rsx! {
170        "下一页"
171        span { class: "ml-1", "»" }
172    };
173
174    // 跳页状态:当前页码本身渲染为可编辑输入框(仅回调式翻页且多页时)。
175    // `jump_editing` = 输入框聚焦态——聚焦期间显示草稿,失焦回显 current_page,
176    // 与父组件翻页天然同步,无需 effect 桥接;`jump_draft` = 用户输入草稿。
177    // 两者皆为独立 UI 状态(非派生镜像),合法 use_signal。
178    let mut jump_editing: Signal<bool> = use_signal(|| false);
179    let mut jump_draft: Signal<String> = use_signal(String::new);
180    rsx! {
181        nav { class: if is_admin { "flex mt-6 justify-between" } else { "flex mt-10 mb-6 justify-between" },
182            if has_prev {
183                if let Some(on_prev) = on_prev {
184                    button {
185                        class: "{link_class}",
186                        onclick: move |_| on_prev.call(()),
187                        {prev_inner}
188                    }
189                } else {
190                    Link { class: "{link_class}", to: prev_route, {prev_inner} }
191                }
192            } else if is_admin {
193                span { class: "{disabled_class}",
194                    span { class: "mr-1", "«" }
195                    "上一页"
196                }
197            }
198
199            // admin 显示页码计数;回调式翻页且多页时,当前页码可直接编辑跳页。
200            if is_admin {
201                span { class: "flex items-center gap-1.5 self-center text-sm text-paper-secondary",
202                    if total_pages > 1 && on_jump.is_some() {
203                        input {
204                            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",
205                            r#type: "text",
206                            inputmode: "numeric",
207                            title: "输入页码,回车跳转",
208                            value: if jump_editing() { jump_draft() } else { current_page.to_string() },
209                            onfocus: move |_| {
210                                jump_draft.set(current_page.to_string());
211                                jump_editing.set(true);
212                            },
213                            onblur: move |_| jump_editing.set(false),
214                            oninput: move |e| jump_draft.set(e.value()),
215                            onkeydown: move |e| {
216                                if e.key() == Key::Enter {
217                                    if let Some(on_jump) = on_jump {
218                                        // 合法页码:夹取到 [1, total_pages] 后回调并回显;
219                                        // 非法输入(空串/非数字):还原为当前页。
220                                        if let Ok(p) = jump_draft().trim().parse::<i32>() {
221                                            let p = p.clamp(1, total_pages);
222                                            on_jump.call(p);
223                                            jump_draft.set(p.to_string());
224                                        } else {
225                                            jump_draft.set(current_page.to_string());
226                                        }
227                                    }
228                                }
229                            },
230                        }
231                    } else {
232                        "{current_page}"
233                    }
234                    " / {total_pages} 页 (共 {total} {unit})"
235                }
236            }
237
238            if has_next {
239                if let Some(on_next) = on_next {
240                    button {
241                        class: "{link_class} {link_extra_next}",
242                        onclick: move |_| on_next.call(()),
243                        {next_inner}
244                    }
245                } else {
246                    Link {
247                        class: "{link_class} {link_extra_next}",
248                        to: next_route,
249                        {next_inner}
250                    }
251                }
252            } else if is_admin {
253                span { class: "{disabled_class}",
254                    "下一页"
255                    span { class: "ml-1", "»" }
256                }
257            }
258        }
259    }
260}
261
262/// 状态徽章组件。
263///
264/// 外层固定 `BADGE_BASE`,颜色类由调用方传入。之所以用 `color_class` prop
265/// 而非枚举变体,是因为部分场景(如回收站剩余天数)的颜色由动态逻辑决定
266/// (>7 天中性 / ≤7 天主题绿 / ≤0 琥珀),硬编码 variant 反而不够灵活。
267///
268/// Props:
269/// - `color_class`:背景与文字颜色类(如 `post.status_badge_class()` 的返回值)
270/// - `label`:徽章文本
271#[component]
272pub fn StatusBadge(color_class: &'static str, label: String) -> Element {
273    rsx! {
274        span { class: "{BADGE_BASE} {color_class}", "{label}" }
275    }
276}
277
278/// Tooltip 定位样式(胶囊:黑底白字,hover 显现)。
279const TOOLTIP_STYLE: &str =
280    "pointer-events-none absolute left-1/2 -translate-x-1/2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-paper-primary text-paper-theme shadow-lg z-50";
281
282/// Tooltip 包裹组件。
283///
284/// 将任意触发器(按钮等)包裹后,鼠标 hover 时在上方或下方居中弹出提示。
285/// 用 CSS `group` + `group-hover:opacity-100` 实现,无 JS 状态,`pointer-events-none`
286/// 保证不拦截点击。
287///
288/// Props:
289/// - `tip`:提示文案
290/// - `children`:触发器元素(按钮 / 链接等)
291/// - `placement`:弹出方向,`"top"`(默认)或 `"bottom"`
292///
293/// 注意:父容器若有 `overflow-hidden` 会裁掉 tooltip,此时应选朝外的方向
294/// (如表格行在 `overflow-hidden` 容器内,朝上的 tooltip 才不会被裁)。
295#[component]
296pub fn Tooltip(
297    tip: String,
298    children: Element,
299    #[props(default = "top")] placement: &'static str,
300) -> Element {
301    // 朝上:tooltip 在触发器上方(bottom-full + mb-2);朝下:在下方(top-full + mt-2)。
302    let position_class = if placement == "bottom" {
303        "top-full mt-2"
304    } else {
305        "bottom-full mb-2"
306    };
307    rsx! {
308        div { class: "group relative inline-flex",
309            {children}
310            div { class: "{TOOLTIP_STYLE} {position_class}", "{tip}" }
311        }
312    }
313}
314
315/// Popover 遮罩与面板的层级(z-40 遮罩 < z-50 面板),与 Tooltip/lightbox 同 z-50。
316const POPOVER_OVERLAY_CLASS: &str = "fixed inset-0 z-40";
317/// Popover 面板:卡片化大圆角 + 阴影 + 淡入缩放动画。
318const POPOVER_PANEL_CLASS: &str =
319    "fixed z-50 bg-[var(--color-paper-entry)] rounded-2xl shadow-lg border border-[var(--color-paper-border)] p-4 animate-popover-enter";
320
321/// 受控式通用 Popover(浮层)组件。
322///
323/// 与 [`Tooltip`] 对称——但 Tooltip 是纯 CSS hover、无状态;Popover 是点击触发、
324/// 受控开关,用于承载确认框、轻量表单等交互内容。
325///
326/// ## 定位策略
327///
328/// 父容器常有 `overflow-hidden`(如 `ADMIN_TABLE_CLASS`),`position:absolute` 子节点
329/// 会被裁掉,故面板用 **`position:fixed`**,以触发点击的**视口坐标**(`MouseEvent::
330/// client_coordinates()`)作为锚点(参照 `theme.rs` 圆形展开动画的坐标用法)。无需
331/// `getBoundingClientRect`/`node_ref`。
332///
333/// - `placement: "top"`(默认):面板底边贴点击点上方(`bottom: 100vh - y + gap`)。
334/// - `placement: "bottom"`:面板顶边贴点击点下方(`top: y + gap`)。
335/// - 水平:面板中心对齐点击点(`left: x` + `-translate-x-1/2`)。
336///
337/// ## 关闭路径(三条)
338///
339/// 1. 点遮罩(透明,仅作点击兜底)→ `on_close`。
340/// 2. Escape 键 → `on_close`(组件内 `use_effect` 注册全局 keydown 监听,`use_drop` 清理)。
341/// 3. 面板内确认/取消按钮调用 `on_close`。
342///
343/// ## Props
344///
345/// - `open`:受控开关;`false` 时组件不渲染任何内容(SSR 安全)。
346/// - `anchor_x` / `anchor_y`:触发点击的视口坐标。
347/// - `placement`:`"top"`(默认)/ `"bottom"`。
348/// - `children`:面板内容(确认框等)。
349/// - `on_close`:任一关闭路径触发。
350#[component]
351#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
352pub fn Popover(
353    open: bool,
354    anchor_x: i32,
355    anchor_y: i32,
356    children: Element,
357    on_close: EventHandler<()>,
358    #[props(default = "top")] placement: &'static str,
359) -> Element {
360    // Escape 关闭:组件 open 时注册全局 keydown 监听,关闭/卸载时移除。
361    // 手写最小 listener 而非复用 use_event_listener——后者 handler 无参,拿不到
362    // KeyboardEvent 的 key()。用 use_hook 持有 Closure,use_effect 注册,use_drop 清理。
363    #[cfg(target_arch = "wasm32")]
364    {
365        use dioxus::prelude::{use_drop, use_effect, use_hook};
366        use std::cell::RefCell;
367        use std::rc::Rc;
368        type EscState =
369            Rc<RefCell<Option<wasm_bindgen::prelude::Closure<dyn FnMut(web_sys::KeyboardEvent)>>>>;
370        let state: EscState = use_hook(|| Rc::new(RefCell::new(None)));
371        let state_for_drop = state.clone();
372        let open_for_effect = open;
373        let on_close_for_esc = on_close;
374        use_effect(move || {
375            if !open_for_effect {
376                return;
377            }
378            let Some(window) = web_sys::window() else {
379                return;
380            };
381            let on_close_for_esc = on_close_for_esc;
382            // Closure 带 KeyboardEvent 参数:浏览器调用 handler 时传入事件对象,
383            // 无需依赖已废弃的 window.event()。as_ref + unchecked_ref 转成 JS Function。
384            let closure =
385                wasm_bindgen::prelude::Closure::wrap(Box::new(move |ev: web_sys::KeyboardEvent| {
386                    if ev.key() == "Escape" {
387                        on_close_for_esc.call(());
388                    }
389                })
390                    as Box<dyn FnMut(web_sys::KeyboardEvent)>);
391            let _ = window.add_event_listener_with_callback(
392                "keydown",
393                wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
394            );
395            *state.borrow_mut() = Some(closure);
396        });
397        use_drop(move || {
398            if let Some(closure) = state_for_drop.borrow_mut().take() {
399                if let Some(window) = web_sys::window() {
400                    let _ = window.remove_event_listener_with_callback(
401                        "keydown",
402                        wasm_bindgen::JsCast::unchecked_ref(closure.as_ref()),
403                    );
404                }
405            }
406        });
407    }
408
409    if !open {
410        return rsx! {};
411    }
412
413    // 面板定位:top/bottom 决定垂直方向;水平统一居中于点击点。
414    let style = if placement == "bottom" {
415        format!(
416            "top: {y}px; left: {x}px; transform: translateX(-50%);",
417            x = anchor_x,
418            y = anchor_y + 8
419        )
420    } else {
421        // top:面板在点击点上方——用 bottom 锚定 viewport 底,差值即视口高度 - y + 间隙。
422        // 视口高度用 100vh,纯 CSS 无需 JS 读取 scrollHeight。
423        format!(
424            "bottom: calc(100vh - {y}px + 8px); left: {x}px; transform: translateX(-50%);",
425            x = anchor_x,
426            y = anchor_y,
427        )
428    };
429
430    rsx! {
431        // 透明遮罩:拦截外部点击(点遮罩即关)。z-40 < 面板 z-50。
432        div {
433            class: "{POPOVER_OVERLAY_CLASS}",
434            onclick: move |_| on_close.call(()),
435        }
436        // 面板:fixed 定位逃出 overflow-hidden 容器。
437        div { class: "{POPOVER_PANEL_CLASS}", style: "{style}", {children} }
438    }
439}
440
441static TAB_GROUP_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
442
443/// 筛选选项卡组件。
444///
445/// 用于切换不同的视图或筛选条件(例如:全部、待审核、已通过等)。
446/// 具备高级的平滑滑动底部指示器动画。
447///
448/// Props:
449/// - `items`:选项卡列表,每一项为 `(value, label)`
450/// - `active_value`:当前选中的值
451/// - `on_change`:选项卡切换时的回调
452#[component]
453pub fn FilterTabs(
454    items: Vec<(&'static str, &'static str)>,
455    active_value: String,
456    on_change: EventHandler<String>,
457) -> Element {
458    #[allow(unused_mut)]
459    let mut indicator_style = use_signal(|| "left: 0px; width: 0px; opacity: 0;".to_string());
460    let id_prefix = use_hook(|| TAB_GROUP_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
461
462    #[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
463    let update_indicator = move |active: String| {
464        spawn(async move {
465            #[cfg(target_arch = "wasm32")]
466            {
467                use wasm_bindgen::JsCast;
468
469                // 等待 DOM 节点更新
470                crate::utils::time::sleep_ms(50).await;
471
472                if let Some(window) = web_sys::window() {
473                    if let Some(doc) = window.document() {
474                        let element_id = format!("tab-{}-{}", id_prefix, active);
475                        if let Some(el) = doc.get_element_by_id(&element_id) {
476                            if let Ok(html_el) = el.dyn_into::<web_sys::HtmlElement>() {
477                                let left = html_el.offset_left();
478                                let width = html_el.offset_width();
479                                indicator_style.set(format!(
480                                    "left: {}px; width: {}px; opacity: 1;",
481                                    left, width
482                                ));
483                            }
484                        }
485                    }
486                }
487            }
488        });
489    };
490
491    use_effect({
492        let active_value = active_value.clone();
493        move || {
494            update_indicator(active_value.clone());
495        }
496    });
497
498    rsx! {
499        div { class: "relative flex gap-4 border-b border-paper-border mb-6",
500            for (value, label) in items {
501                button {
502                    id: "tab-{id_prefix}-{value}",
503                    key: "{value}",
504                    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" },
505                    onclick: {
506                        let v = value.to_string();
507                        move |_| {
508                            on_change.call(v.clone());
509                            update_indicator(v.clone());
510                        }
511                    },
512                    "{label}"
513                }
514            }
515            // 绝对定位的滑动颜色条
516            div {
517                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",
518                style: "{indicator_style}",
519            }
520        }
521    }
522}
523
524/// 主操作按钮,内置 loading spinner 叠加(统一三态)。
525///
526/// 用于全站所有主题绿 CTA(执行 / 刷新 / 创建备份 / 发布 / 保存设置)。
527/// 三态:
528/// - `loading=true`:主题绿底 + 文字隐藏(`opacity-0`)+ spinner 绝对居中
529///   (按钮宽度不变,避免加载时布局抖动)
530/// - `disabled=true`(且未 loading):灰色底(`bg-paper-tertiary`)+
531///   `cursor-not-allowed`
532/// - 正常:主题绿底 + `hover:brightness-110` + `active:scale-[0.98]`
533///
534/// Props:
535/// - `label`:正常态显示的文案(loading 时隐藏,由 spinner 占位)
536/// - `loading`:是否处于加载态
537/// - `disabled`:是否禁用(loading 优先级更高)
538/// - `variant`:`"primary"`(默认,`px-5 py-2`)或 `"sm"`(`px-4 py-1.5`)
539/// - `onclick`:点击回调
540#[component]
541pub fn LoadingButton(
542    label: String,
543    loading: bool,
544    #[props(default = false)] disabled: bool,
545    #[props(default = "primary")] variant: &'static str,
546    onclick: EventHandler<()>,
547) -> Element {
548    // 尺寸变体:sm 用于工具栏(刷新/导出),primary 用于主 CTA(执行/发布/保存)。
549    let size = if variant == "sm" {
550        "px-4 py-1.5"
551    } else {
552        "px-5 py-2 shadow-sm"
553    };
554
555    // 三态背景:loading 与正常都是主题绿(保持视觉连续),disabled 灰化。
556    let (bg, cursor) = if disabled && !loading {
557        (
558            "bg-[var(--color-paper-tertiary)] text-[var(--color-paper-secondary)]",
559            "cursor-not-allowed",
560        )
561    } else {
562        (
563            "text-[var(--color-paper-theme)] bg-[var(--color-paper-accent)] hover:brightness-110 active:scale-[0.98]",
564            "cursor-pointer",
565        )
566    };
567
568    rsx! {
569        button {
570            class: "relative inline-flex items-center justify-center {size} {bg} {cursor} rounded-full text-sm font-medium transition-all",
571            disabled: loading || disabled,
572            onclick: move |_| onclick.call(()),
573            span { class: if loading { "opacity-0" } else { "" }, "{label}" }
574            if loading {
575                span {
576                    class: "absolute inset-0 flex items-center justify-center",
577                    dangerous_inner_html: SPINNER_SVG,
578                }
579            }
580        }
581    }
582}