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:
parent
717d17d5a7
commit
e6f7704367
@ -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,
|
||||||
|
|||||||
@ -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" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user