diff --git a/src/components/forms.rs b/src/components/forms.rs index 8347692..fdfffd5 100644 --- a/src/components/forms.rs +++ b/src/components/forms.rs @@ -11,9 +11,12 @@ pub const INPUT_CLASS: &str = "w-full px-4 py-2 border border-paper-border round pub const BUTTON_PRIMARY_CLASS: &str = "w-full py-2.5 px-4 bg-paper-accent text-white font-medium rounded-full hover:brightness-110 active:scale-[0.98] transition-all duration-200 cursor-pointer"; /// FormSelect 实例 id 计数器(跨泛型单例化全局唯一)。 -#[allow(dead_code)] // server 构建下仅被 dead 的 FormSelect 组件体引用 static FORM_SELECT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +/// FormSelect 紧凑触发器样式:工具栏内联小下拉(自动刷新、导出格式等)。 +/// 自动宽度 + text-sm + 小圆角,chevron 与面板样式与默认表单款一致。 +pub const FORM_SELECT_COMPACT_CLASS: &str = "inline-flex w-auto cursor-pointer select-none text-left text-sm pl-3 pr-8 py-1 border border-paper-border rounded-lg bg-paper-theme text-paper-primary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200"; + /// 面板应向上展开的条件:视口下方空间不足,且上方空间比下方更宽余。 /// /// 纯函数便于单测;`panel_height` 由调用方按选项数估算(见 `measure_flip`)。 @@ -27,7 +30,6 @@ fn should_flip(trigger_top: f64, trigger_bottom: f64, viewport_height: f64, pane } /// 键盘导航的循环索引:在 `len` 个选项中从 `cur` 移动 `delta`(±1),越界回绕。 -#[allow(dead_code)] // 同 should_flip fn wrap_index(cur: usize, delta: i32, len: usize) -> usize { if len == 0 { return 0; @@ -101,12 +103,19 @@ pub fn FormSelect( value: T, options: Vec<(T, &'static str)>, onchange: EventHandler, + /// 触发器样式覆盖:缺省为全宽表单款;工具栏内联场景传 + /// [`FORM_SELECT_COMPACT_CLASS`],或自定义类串(如编辑器底部胶囊)。 + #[props(default)] + trigger_class: Option<&'static str>, ) -> Element { - // 触发器样式与 INPUT_CLASS 同族(右侧 pr-10 预留 chevron 位);面板与 - // POPOVER_PANEL_CLASS 同源(卡片化圆角 + 阴影)。定义在函数体内:当前仅 - // wasm 端后台页面使用本组件,模块级常量在 server 构建下会触发 dead_code。 + // 面板与 POPOVER_PANEL_CLASS 同源(卡片化圆角 + 阴影)。宽度取 max(触发器, + // 最长选项):紧凑触发器(如“手动”)下面板仍能完整展示长选项;上限防出屏。 + // 定义在函数体内:模块级私有常量若仅被 wasm 门控调用点引用,会在 server + // 构建下触发 dead_code。 const TRIGGER_CLASS: &str = "w-full block cursor-pointer truncate select-none text-left pl-4 pr-10 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200"; - const PANEL_CLASS: &str = "absolute inset-x-0 z-50 max-h-60 overflow-y-auto rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] p-1.5 shadow-lg animate-select-enter"; + const PANEL_CLASS: &str = "absolute left-0 z-50 w-max min-w-full max-w-[calc(100vw_-_2rem)] max-h-60 overflow-y-auto rounded-2xl border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] p-1.5 shadow-lg animate-select-enter"; + + let trigger_cls = trigger_class.unwrap_or(TRIGGER_CLASS); let id_prefix = use_hook(|| FORM_SELECT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst)); @@ -179,7 +188,7 @@ pub fn FormSelect( button { id: "{trigger_id}", r#type: "button", - class: "{TRIGGER_CLASS}", + class: "{trigger_cls}", aria_haspopup: "listbox", aria_expanded: "{open()}", aria_activedescendant: active_descendant, diff --git a/src/pages/admin/system/db_status.rs b/src/pages/admin/system/db_status.rs index 381939a..ccd7ff4 100644 --- a/src/pages/admin/system/db_status.rs +++ b/src/pages/admin/system/db_status.rs @@ -2,12 +2,22 @@ use dioxus::prelude::*; +use crate::components::forms::{FORM_SELECT_COMPACT_CLASS, FormSelect}; use crate::components::skeletons::atoms::SkeletonBox; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::ui::LoadingButton; use super::format_bytes; +/// 自动刷新间隔可选项(秒;None = 手动)。 +const REFRESH_INTERVAL_OPTIONS: &[(Option, &str)] = &[ + (None, "手动"), + (Some(1), "1s"), + (Some(2), "2s"), + (Some(5), "5s"), + (Some(30), "30s"), +]; + /// 数据库状态 tab:概览卡片 + 表清单 + 索引 Top + 活跃连接。 /// 手动刷新按钮 + 自动刷新开关(1s/2s/5s/30s/手动,默认手动)。 #[allow(non_snake_case)] @@ -136,27 +146,11 @@ pub(super) fn DbStatusTab() -> Element { } div { class: "flex items-center gap-2", span { class: "text-sm text-paper-secondary", "自动刷新" } - select { - class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", - value: "{refresh_interval().map(|s| s.to_string()).unwrap_or_default()}", - onchange: move |e| { - let v = e.value(); - refresh_interval - .set( - match v.as_str() { - "1" => Some(1), - "2" => Some(2), - "5" => Some(5), - "30" => Some(30), - _ => None, - }, - ); - }, - option { value: "", "手动" } - option { value: "1", "1s" } - option { value: "2", "2s" } - option { value: "5", "5s" } - option { value: "30", "30s" } + FormSelect { + trigger_class: Some(FORM_SELECT_COMPACT_CLASS), + value: refresh_interval(), + options: REFRESH_INTERVAL_OPTIONS.to_vec(), + onchange: move |v| refresh_interval.set(v), } } } diff --git a/src/pages/admin/system/export.rs b/src/pages/admin/system/export.rs index 825d249..b1cdb55 100644 --- a/src/pages/admin/system/export.rs +++ b/src/pages/admin/system/export.rs @@ -2,6 +2,7 @@ use dioxus::prelude::*; +use crate::components::forms::{FORM_SELECT_COMPACT_CLASS, FormSelect}; use crate::components::ui::BTN_PRIMARY_SM; /// 数据导出 tab:按表/按查询导出 SQL/CSV,走 Axum 流式下载。 @@ -96,12 +97,14 @@ pub(super) fn ExportTab() -> Element { div { class: "flex flex-wrap items-center gap-4", div { class: "flex items-center gap-2", span { class: "text-sm text-paper-secondary", "格式" } - select { - class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", - value: "{format}", - onchange: move |e| format.set(e.value()), - option { value: "csv", "CSV" } - option { value: "sql", "SQL (INSERT)" } + FormSelect { + trigger_class: Some(FORM_SELECT_COMPACT_CLASS), + value: format(), + options: vec![ + ("csv".to_string(), "CSV"), + ("sql".to_string(), "SQL (INSERT)"), + ], + onchange: move |v| format.set(v), } } label { class: "flex items-center gap-1 text-sm text-paper-secondary", diff --git a/src/pages/admin/system/server_status.rs b/src/pages/admin/system/server_status.rs index 5dc1ef1..127a454 100644 --- a/src/pages/admin/system/server_status.rs +++ b/src/pages/admin/system/server_status.rs @@ -2,6 +2,7 @@ use dioxus::prelude::*; +use crate::components::forms::{FORM_SELECT_COMPACT_CLASS, FormSelect}; use crate::components::skeletons::atoms::SkeletonBox; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::ui::LoadingButton; @@ -23,6 +24,15 @@ fn format_uptime(secs: u64) -> String { format!("{secs}s") } } +/// 自动刷新间隔可选项(毫秒;None = 手动)。 +const REFRESH_MS_OPTIONS: &[(Option, &str)] = &[ + (None, "手动"), + (Some(500), "500ms"), + (Some(1000), "1s"), + (Some(2000), "2s"), + (Some(5000), "5s"), +]; + /// 服务器状态 tab:应用内指标(连接池/会话/缓存命中率)+ 主机层(CPU/内存/磁盘)。 /// 手动刷新 + 自动刷新开关(500ms/1s/2s/5s/手动,默认手动)。 /// 主机层数据由后台 500ms 采样,前端轮询只读快照零成本,故可高频。 @@ -158,27 +168,11 @@ pub(super) fn ServerStatusTab() -> Element { } div { class: "flex items-center gap-2", span { class: "text-sm text-paper-secondary", "自动刷新" } - select { - class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", - value: "{refresh_ms().map(|s| s.to_string()).unwrap_or_default()}", - onchange: move |e| { - let v = e.value(); - refresh_ms - .set( - match v.as_str() { - "500" => Some(500), - "1000" => Some(1000), - "2000" => Some(2000), - "5000" => Some(5000), - _ => None, - }, - ); - }, - option { value: "", "手动" } - option { value: "500", "500ms" } - option { value: "1000", "1s" } - option { value: "2000", "2s" } - option { value: "5000", "5s" } + FormSelect { + trigger_class: Some(FORM_SELECT_COMPACT_CLASS), + value: refresh_ms(), + options: REFRESH_MS_OPTIONS.to_vec(), + onchange: move |v| refresh_ms.set(v), } } } diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs index 72b8f81..5ed493f 100644 --- a/src/pages/admin/write.rs +++ b/src/pages/admin/write.rs @@ -17,6 +17,7 @@ use crate::api::posts::{ #[cfg(target_arch = "wasm32")] use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle}; // 共享上传状态类型:两端都编译(rsx 在 server SSR 时也要渲染这些结构)。 +use crate::components::forms::FormSelect; use crate::components::ui::{LoadingButton, BTN_CLOSE_ICON, BTN_PRIMARY_SM}; use crate::components::write_skeleton::WriteSkeleton; use crate::models::post::Post; @@ -549,33 +550,15 @@ fn write_editor(post_id: Option) -> Element { "取消" } div { class: "w-px h-5 bg-[var(--color-paper-border)]" } - div { class: "relative inline-flex items-center px-4 py-2 rounded-full text-sm font-medium text-[var(--color-paper-secondary)] border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] cursor-pointer hover:bg-[var(--color-paper-theme)] transition-colors", - select { - class: "absolute inset-0 w-full h-full opacity-0 cursor-pointer", - style: "appearance: none; -webkit-appearance: none;", - value: "{status}", - onchange: move |evt| status.set(evt.value()), - option { value: "draft", "存为草稿" } - option { value: "published", "直接发布" } - } - span { class: "pr-2 text-[var(--color-paper-primary)]", - if status() == "draft" { - "存为草稿" - } else { - "直接发布" - } - } - svg { - class: "h-3.5 w-3.5 text-[var(--color-paper-tertiary)] pointer-events-none", - xmlns: "http://www.w3.org/2000/svg", - view_box: "0 0 20 20", - fill: "currentColor", - path { - fill_rule: "evenodd", - d: "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z", - clip_rule: "evenodd", - } - } + // 文章状态下拉(胶囊触发器;贴底栏,面板自动向上展开) + FormSelect { + trigger_class: Some("inline-flex w-auto cursor-pointer select-none text-left text-sm font-medium pl-4 pr-8 py-2 rounded-full border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-theme)] focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200"), + value: status(), + options: vec![ + ("draft".to_string(), "存为草稿"), + ("published".to_string(), "直接发布"), + ], + onchange: move |v| status.set(v), } div { class: "w-px h-5 bg-[var(--color-paper-border)]" } LoadingButton {