feat(admin): 封装 FormSelect 组件并修复 MCP 页下拉箭头错位

/admin/mcp 的两个 <select> 直接套用文本框的 INPUT_CLASS,原生下拉箭头
由浏览器绘制,深色主题下位置/颜色不协调(垂直不居中、无法跟随主题色)。

- components/forms.rs 新增泛型 FormSelect:appearance-none 隐藏原生箭头,
  右侧叠 pointer-events-none 的 chevron SVG(top-1/2 -translate-y-1/2
  精确居中);option value 内部用序号占位,onchange 直接回传选中项的 T,
  调用方免去「字符串反查枚举」样板
- mcp.rs 两个下拉改用以简形式传入 SCOPE_OPTIONS / LIFETIME_OPTIONS

浏览器实测:chevron 垂直居中偏差 0.00px,右间距 16px(与左内边距对称)。
This commit is contained in:
xfy 2026-07-28 13:52:28 +08:00
parent ef4c4ee8ed
commit 8b4a2b674e
2 changed files with 74 additions and 29 deletions

View File

@ -10,6 +10,71 @@ pub const INPUT_CLASS: &str = "w-full px-4 py-2 border border-paper-border round
/// 主按钮 CSS 类,用于表单提交等主操作按钮。 /// 主按钮 CSS 类,用于表单提交等主操作按钮。
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";
/// 下拉选择框组件(自定义 chevron跨浏览器对齐一致
///
/// 原生 `<select>` 的默认箭头由各浏览器自行绘制,在深色主题与圆角样式下
/// 位置/颜色不协调(无法垂直居中、无法跟随主题色),故用 `appearance-none`
/// 隐藏后叠一个 `top-1/2 -translate-y-1/2` 垂直居中的 chevron SVG
/// `pointer-events-none`,点击穿透到 select
///
/// 泛型值绑定:`<option>` 的 value 内部用序号占位,`onchange` 直接回传选中
/// 项的 `T`,调用方无需做「字符串反查枚举」的样板代码。
///
/// Props
/// - `id`select 元素 id用于与 label 关联
/// - `value`:当前选中项(受控)
/// - `options`:可选项 `(值, 标签)` 列表
/// - `onchange`:选中变化回调,回传新选中项的值
#[component]
pub fn FormSelect<T: Clone + PartialEq + 'static>(
id: Option<String>,
value: T,
options: Vec<(T, &'static str)>,
onchange: EventHandler<T>,
) -> Element {
// 与 INPUT_CLASS 同族但隐藏原生箭头appearance-none
// 右侧预留 pr-10 空间给自定义 chevron。定义在函数体内当前仅 wasm 端
// 后台页面使用本组件,模块级常量在 server 构建下会触发 dead_code。
const SELECT_CLASS: &str = "w-full appearance-none cursor-pointer truncate 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";
// 受控选中序号value 不在 options 时兜底 0与浏览器默认选中第一项一致
let selected = options.iter().position(|(v, _)| *v == value).unwrap_or(0);
// onchange 闭包与下方 for 循环各需一份 options序号 → 值回查 / 渲染)。
let options_for_change = options.clone();
rsx! {
div { class: "relative",
select {
id: id.unwrap_or_default(),
class: "{SELECT_CLASS}",
value: "{selected}",
onchange: move |e| {
if let Ok(i) = e.value().parse::<usize>() {
if let Some((v, _)) = options_for_change.get(i) {
onchange.call(v.clone());
}
}
},
for (i, (_, option_label)) in options.iter().enumerate() {
option { value: "{i}", selected: i == selected, "{option_label}" }
}
}
// 自定义下拉箭头(替代原生箭头,保证垂直居中 + 跟随主题色)
svg {
class: "pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-paper-secondary",
view_box: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
path {
stroke_linecap: "round",
stroke_linejoin: "round",
d: "M6 9l6 6 6-6",
}
}
}
}
}
/// 表单输入框组件。 /// 表单输入框组件。
/// ///
/// Props /// Props

View File

@ -21,7 +21,7 @@ use crate::api::mcp_tokens::{
McpClientConfigs, TokenLifetime, McpClientConfigs, TokenLifetime,
}; };
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::components::forms::INPUT_CLASS; use crate::components::forms::{FormSelect, INPUT_CLASS};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::components::ui::{ use crate::components::ui::{
ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS, BADGE_BASE, BTN_PRIMARY, BTN_PRIMARY_SM, BTN_TEXT_RED, ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS, BADGE_BASE, BTN_PRIMARY, BTN_PRIMARY_SM, BTN_TEXT_RED,
@ -386,39 +386,19 @@ fn CreateTokenCard() -> Element {
// 作用域 // 作用域
div { class: "flex flex-col gap-2", div { class: "flex flex-col gap-2",
label { class: "text-sm font-medium text-[var(--color-paper-secondary)]", "作用域" } label { class: "text-sm font-medium text-[var(--color-paper-secondary)]", "作用域" }
select { FormSelect {
class: "{INPUT_CLASS}", value: scope(),
onchange: move |e| { options: SCOPE_OPTIONS.to_vec(),
if let Some(s) = SCOPE_OPTIONS onchange: move |s| scope.set(s),
.iter()
.find(|(_, label)| *label == e.value().as_str())
.map(|(s, _)| *s)
{
scope.set(s);
}
},
for (s, label) in SCOPE_OPTIONS {
option { value: "{label}", selected: *s == scope(), "{label}" }
}
} }
} }
// 有效期 // 有效期
div { class: "flex flex-col gap-2", div { class: "flex flex-col gap-2",
label { class: "text-sm font-medium text-[var(--color-paper-secondary)]", "有效期" } label { class: "text-sm font-medium text-[var(--color-paper-secondary)]", "有效期" }
select { FormSelect {
class: "{INPUT_CLASS}", value: lifetime(),
onchange: move |e| { options: LIFETIME_OPTIONS.to_vec(),
if let Some(l) = LIFETIME_OPTIONS onchange: move |l| lifetime.set(l),
.iter()
.find(|(_, label)| *label == e.value().as_str())
.map(|(l, _)| *l)
{
lifetime.set(l);
}
},
for (l, label) in LIFETIME_OPTIONS {
option { value: "{label}", selected: *l == lifetime(), "{label}" }
}
} }
} }
} }