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:
parent
ef4c4ee8ed
commit
8b4a2b674e
@ -10,6 +10,71 @@ pub const INPUT_CLASS: &str = "w-full px-4 py-2 border border-paper-border round
|
||||
/// 主按钮 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";
|
||||
|
||||
/// 下拉选择框组件(自定义 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:
|
||||
|
||||
@ -21,7 +21,7 @@ use crate::api::mcp_tokens::{
|
||||
McpClientConfigs, TokenLifetime,
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::components::forms::INPUT_CLASS;
|
||||
use crate::components::forms::{FormSelect, INPUT_CLASS};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::components::ui::{
|
||||
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",
|
||||
label { class: "text-sm font-medium text-[var(--color-paper-secondary)]", "作用域" }
|
||||
select {
|
||||
class: "{INPUT_CLASS}",
|
||||
onchange: move |e| {
|
||||
if let Some(s) = SCOPE_OPTIONS
|
||||
.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}" }
|
||||
}
|
||||
FormSelect {
|
||||
value: scope(),
|
||||
options: SCOPE_OPTIONS.to_vec(),
|
||||
onchange: move |s| scope.set(s),
|
||||
}
|
||||
}
|
||||
// 有效期
|
||||
div { class: "flex flex-col gap-2",
|
||||
label { class: "text-sm font-medium text-[var(--color-paper-secondary)]", "有效期" }
|
||||
select {
|
||||
class: "{INPUT_CLASS}",
|
||||
onchange: move |e| {
|
||||
if let Some(l) = LIFETIME_OPTIONS
|
||||
.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}" }
|
||||
}
|
||||
FormSelect {
|
||||
value: lifetime(),
|
||||
options: LIFETIME_OPTIONS.to_vec(),
|
||||
onchange: move |l| lifetime.set(l),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user