feat(admin): 全项目原生 <select> 统一替换为 FormSelect

将剩余 4 处原生下拉(write 编辑器状态胶囊、db_status/server_status
自动刷新、export 导出格式)全部迁移到主题化的 FormSelect:

- FormSelect 新增 trigger_class 可选 prop(缺省全宽表单款),并导出
  FORM_SELECT_COMPACT_CLASS 供工具栏内联小下拉复用;write.rs 状态选择器
  以胶囊 trigger_class 复刻原外观(面板贴底栏自动上翻,替代原
  「隐形 select 覆盖 span」hack)
- 面板宽度改为 max(触发器, 最长选项)(w-max min-w-full + 100vw 上限),
  紧凑触发器(如「手动」)下长选项(如「500ms」「SQL (INSERT)」)
  不再被截断
- db_status/server_status 的字符串 match 反查收编为类型安全选项表
  (Option<u32> 直进直出)
- 因 system/write 页面 SSR 真实引用 FormSelect,移除 FORM_SELECT_ID /
  wrap_index 上不再需要的 #[allow(dead_code)]

src 下已无任何原生 <select> 元素。浏览器实测:紧凑面板 93.8px 宽于
触发器 73.8px、胶囊面板上翻贴顶 6px 间隙、选中 check 与高亮行正确。
This commit is contained in:
xfy 2026-07-28 14:38:12 +08:00
parent 717d17d5a7
commit e6f7704367
5 changed files with 65 additions and 82 deletions

View File

@ -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"; 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 计数器(跨泛型单例化全局唯一)。 /// FormSelect 实例 id 计数器(跨泛型单例化全局唯一)。
#[allow(dead_code)] // server 构建下仅被 dead 的 FormSelect 组件体引用
static FORM_SELECT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); 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`)。 /// 纯函数便于单测;`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越界回绕。 /// 键盘导航的循环索引:在 `len` 个选项中从 `cur` 移动 `delta`±1越界回绕。
#[allow(dead_code)] // 同 should_flip
fn wrap_index(cur: usize, delta: i32, len: usize) -> usize { fn wrap_index(cur: usize, delta: i32, len: usize) -> usize {
if len == 0 { if len == 0 {
return 0; return 0;
@ -101,12 +103,19 @@ pub fn FormSelect<T: Clone + PartialEq + 'static>(
value: T, value: T,
options: Vec<(T, &'static str)>, options: Vec<(T, &'static str)>,
onchange: EventHandler<T>, onchange: EventHandler<T>,
/// 触发器样式覆盖:缺省为全宽表单款;工具栏内联场景传
/// [`FORM_SELECT_COMPACT_CLASS`],或自定义类串(如编辑器底部胶囊)。
#[props(default)]
trigger_class: Option<&'static str>,
) -> Element { ) -> Element {
// 触发器样式与 INPUT_CLASS 同族(右侧 pr-10 预留 chevron 位);面板与 // 面板与 POPOVER_PANEL_CLASS 同源(卡片化圆角 + 阴影)。宽度取 max(触发器,
// POPOVER_PANEL_CLASS 同源(卡片化圆角 + 阴影)。定义在函数体内:当前仅 // 最长选项):紧凑触发器(如“手动”)下面板仍能完整展示长选项;上限防出屏。
// wasm 端后台页面使用本组件,模块级常量在 server 构建下会触发 dead_code。 // 定义在函数体内:模块级私有常量若仅被 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 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)); let id_prefix = use_hook(|| FORM_SELECT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst));
@ -179,7 +188,7 @@ pub fn FormSelect<T: Clone + PartialEq + 'static>(
button { button {
id: "{trigger_id}", id: "{trigger_id}",
r#type: "button", r#type: "button",
class: "{TRIGGER_CLASS}", class: "{trigger_cls}",
aria_haspopup: "listbox", aria_haspopup: "listbox",
aria_expanded: "{open()}", aria_expanded: "{open()}",
aria_activedescendant: active_descendant, aria_activedescendant: active_descendant,

View File

@ -2,12 +2,22 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::components::forms::{FORM_SELECT_COMPACT_CLASS, FormSelect};
use crate::components::skeletons::atoms::SkeletonBox; use crate::components::skeletons::atoms::SkeletonBox;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::ui::LoadingButton; use crate::components::ui::LoadingButton;
use super::format_bytes; use super::format_bytes;
/// 自动刷新间隔可选项None = 手动)。
const REFRESH_INTERVAL_OPTIONS: &[(Option<u32>, &str)] = &[
(None, "手动"),
(Some(1), "1s"),
(Some(2), "2s"),
(Some(5), "5s"),
(Some(30), "30s"),
];
/// 数据库状态 tab概览卡片 + 表清单 + 索引 Top + 活跃连接。 /// 数据库状态 tab概览卡片 + 表清单 + 索引 Top + 活跃连接。
/// 手动刷新按钮 + 自动刷新开关1s/2s/5s/30s/手动,默认手动)。 /// 手动刷新按钮 + 自动刷新开关1s/2s/5s/30s/手动,默认手动)。
#[allow(non_snake_case)] #[allow(non_snake_case)]
@ -136,27 +146,11 @@ pub(super) fn DbStatusTab() -> Element {
} }
div { class: "flex items-center gap-2", div { class: "flex items-center gap-2",
span { class: "text-sm text-paper-secondary", "自动刷新" } span { class: "text-sm text-paper-secondary", "自动刷新" }
select { FormSelect {
class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", trigger_class: Some(FORM_SELECT_COMPACT_CLASS),
value: "{refresh_interval().map(|s| s.to_string()).unwrap_or_default()}", value: refresh_interval(),
onchange: move |e| { options: REFRESH_INTERVAL_OPTIONS.to_vec(),
let v = e.value(); onchange: move |v| refresh_interval.set(v),
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" }
} }
} }
} }

View File

@ -2,6 +2,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::components::forms::{FORM_SELECT_COMPACT_CLASS, FormSelect};
use crate::components::ui::BTN_PRIMARY_SM; use crate::components::ui::BTN_PRIMARY_SM;
/// 数据导出 tab按表/按查询导出 SQL/CSV走 Axum 流式下载。 /// 数据导出 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 flex-wrap items-center gap-4",
div { class: "flex items-center gap-2", div { class: "flex items-center gap-2",
span { class: "text-sm text-paper-secondary", "格式" } span { class: "text-sm text-paper-secondary", "格式" }
select { FormSelect {
class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", trigger_class: Some(FORM_SELECT_COMPACT_CLASS),
value: "{format}", value: format(),
onchange: move |e| format.set(e.value()), options: vec![
option { value: "csv", "CSV" } ("csv".to_string(), "CSV"),
option { value: "sql", "SQL (INSERT)" } ("sql".to_string(), "SQL (INSERT)"),
],
onchange: move |v| format.set(v),
} }
} }
label { class: "flex items-center gap-1 text-sm text-paper-secondary", label { class: "flex items-center gap-1 text-sm text-paper-secondary",

View File

@ -2,6 +2,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::components::forms::{FORM_SELECT_COMPACT_CLASS, FormSelect};
use crate::components::skeletons::atoms::SkeletonBox; use crate::components::skeletons::atoms::SkeletonBox;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton; use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::ui::LoadingButton; use crate::components::ui::LoadingButton;
@ -23,6 +24,15 @@ fn format_uptime(secs: u64) -> String {
format!("{secs}s") format!("{secs}s")
} }
} }
/// 自动刷新间隔可选项毫秒None = 手动)。
const REFRESH_MS_OPTIONS: &[(Option<u32>, &str)] = &[
(None, "手动"),
(Some(500), "500ms"),
(Some(1000), "1s"),
(Some(2000), "2s"),
(Some(5000), "5s"),
];
/// 服务器状态 tab应用内指标连接池/会话/缓存命中率)+ 主机层CPU/内存/磁盘)。 /// 服务器状态 tab应用内指标连接池/会话/缓存命中率)+ 主机层CPU/内存/磁盘)。
/// 手动刷新 + 自动刷新开关500ms/1s/2s/5s/手动,默认手动)。 /// 手动刷新 + 自动刷新开关500ms/1s/2s/5s/手动,默认手动)。
/// 主机层数据由后台 500ms 采样,前端轮询只读快照零成本,故可高频。 /// 主机层数据由后台 500ms 采样,前端轮询只读快照零成本,故可高频。
@ -158,27 +168,11 @@ pub(super) fn ServerStatusTab() -> Element {
} }
div { class: "flex items-center gap-2", div { class: "flex items-center gap-2",
span { class: "text-sm text-paper-secondary", "自动刷新" } span { class: "text-sm text-paper-secondary", "自动刷新" }
select { FormSelect {
class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", trigger_class: Some(FORM_SELECT_COMPACT_CLASS),
value: "{refresh_ms().map(|s| s.to_string()).unwrap_or_default()}", value: refresh_ms(),
onchange: move |e| { options: REFRESH_MS_OPTIONS.to_vec(),
let v = e.value(); onchange: move |v| refresh_ms.set(v),
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" }
} }
} }
} }

View File

@ -17,6 +17,7 @@ use crate::api::posts::{
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle}; use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle};
// 共享上传状态类型两端都编译rsx 在 server SSR 时也要渲染这些结构)。 // 共享上传状态类型两端都编译rsx 在 server SSR 时也要渲染这些结构)。
use crate::components::forms::FormSelect;
use crate::components::ui::{LoadingButton, BTN_CLOSE_ICON, BTN_PRIMARY_SM}; use crate::components::ui::{LoadingButton, BTN_CLOSE_ICON, BTN_PRIMARY_SM};
use crate::components::write_skeleton::WriteSkeleton; use crate::components::write_skeleton::WriteSkeleton;
use crate::models::post::Post; use crate::models::post::Post;
@ -549,33 +550,15 @@ fn write_editor(post_id: Option<i32>) -> Element {
"取消" "取消"
} }
div { class: "w-px h-5 bg-[var(--color-paper-border)]" } 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 { FormSelect {
class: "absolute inset-0 w-full h-full opacity-0 cursor-pointer", 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"),
style: "appearance: none; -webkit-appearance: none;", value: status(),
value: "{status}", options: vec![
onchange: move |evt| status.set(evt.value()), ("draft".to_string(), "存为草稿"),
option { value: "draft", "存为草稿" } ("published".to_string(), "直接发布"),
option { value: "published", "直接发布" } ],
} onchange: move |v| status.set(v),
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",
}
}
} }
div { class: "w-px h-5 bg-[var(--color-paper-border)]" } div { class: "w-px h-5 bg-[var(--color-paper-border)]" }
LoadingButton { LoadingButton {