refactor(admin): 统一后台输入框为 FormInput 组件
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled

扩展 FormInput:
- 新增 class 覆盖 prop(缺省 INPUT_CLASS,内联场景传 INPUT_INLINE_CLASS)
- 新增 mono flag(代码/表名/JSON 等等宽场景)
- 新增 INPUT_INLINE_CLASS 常量(flex-1 内联款,与按钮并排用)

迁移后台全部文本类输入框到 FormInput:
- write.rs:slug / 标签 / 封面 URL(内联 + 回车提交)
- asset_picker.rs / assets.rs:搜索框(flex-1 / w-56)+ alt 微调
- runner.rs:资源覆盖 JSON(mono)
- system/export.rs:表名(mono)

统一视觉语言(paper-entry/border/accent focus/rounded-2xl),
保留各场景必要布局差异(宽度、等宽、胶囊)。
保留为专用控件未迁移:标题大字、checkbox/radio、file、数字步进器。
This commit is contained in:
xfy 2026-07-28 17:21:20 +08:00
parent 1cb92a8046
commit 8ea51cab7b
6 changed files with 54 additions and 45 deletions

View File

@ -7,6 +7,10 @@ use dioxus::prelude::*;
/// 输入框基础 CSS 类统一文本框、邮箱框、URL 框等样式。 /// 输入框基础 CSS 类统一文本框、邮箱框、URL 框等样式。
pub const INPUT_CLASS: &str = "w-full px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200"; pub const INPUT_CLASS: &str = "w-full px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
/// 内联输入框 CSS 类:与 [`INPUT_CLASS`] 同主题,但用 `flex-1 min-w-0` 取代 `w-full`
/// 用于与按钮并排、需填充剩余宽度的场景搜索栏、URL 输入栏等)。
pub const INPUT_INLINE_CLASS: &str = "flex-1 min-w-0 px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200";
/// 主按钮 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";
@ -317,6 +321,9 @@ pub fn FormSelect<T: Clone + PartialEq + 'static>(
/// - `disabled`:是否禁用(可选,缺省 `false` /// - `disabled`:是否禁用(可选,缺省 `false`
/// - `oninput`:输入事件回调,返回新的字符串值 /// - `oninput`:输入事件回调,返回新的字符串值
/// - `onkeydown`:可选的键盘事件回调 /// - `onkeydown`:可选的键盘事件回调
/// - `class`:自定义 class可选缺省用 [`INPUT_CLASS`] 全宽表单款)。
/// 内联/固定宽度场景传 [`INPUT_INLINE_CLASS`] 或自定义类串,覆盖默认样式。
/// - `mono`是否使用等宽字体代码片段、表名、JSON 等,缺省 `false`
#[component] #[component]
pub fn FormInput( pub fn FormInput(
id: Option<String>, id: Option<String>,
@ -328,16 +335,22 @@ pub fn FormInput(
oninput: EventHandler<String>, oninput: EventHandler<String>,
#[props(default)] #[props(default)]
onkeydown: Option<EventHandler<KeyboardEvent>>, onkeydown: Option<EventHandler<KeyboardEvent>>,
#[props(default)]
class: Option<&'static str>,
#[props(default)]
mono: bool,
) -> Element { ) -> Element {
let base = class.unwrap_or(INPUT_CLASS);
let mono_class = if mono { " font-mono" } else { "" };
let disabled_class = if disabled { let disabled_class = if disabled {
"opacity-60 cursor-not-allowed" " opacity-60 cursor-not-allowed"
} else { } else {
"" ""
}; };
rsx! { rsx! {
input { input {
id: id.unwrap_or_default(), id: id.unwrap_or_default(),
class: "{INPUT_CLASS} {disabled_class}", class: "{base}{mono_class}{disabled_class}",
r#type: "{r#type}", r#type: "{r#type}",
placeholder: "{placeholder}", placeholder: "{placeholder}",
value: "{value}", value: "{value}",

View File

@ -5,6 +5,7 @@
//! 纯 Dioxus 组件,不触碰 Tiptap数据加载仅在 WASM 前端发生。 //! 纯 Dioxus 组件,不触碰 Tiptap数据加载仅在 WASM 前端发生。
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::components::forms::{FormInput, INPUT_INLINE_CLASS};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::api::assets::list_assets; use crate::api::assets::list_assets;
@ -69,12 +70,12 @@ pub fn AssetPickerModal(
h2 { class: "text-lg font-bold text-[var(--color-paper-primary)] shrink-0", h2 { class: "text-lg font-bold text-[var(--color-paper-primary)] shrink-0",
"选择封面图" "选择封面图"
} }
input { FormInput {
class: "flex-1 min-w-0 text-sm bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-2xl px-4 py-2 shadow-sm transition-all",
r#type: "search", r#type: "search",
placeholder: "搜索文件名 / alt", placeholder: "搜索文件名 / alt",
value: "{query}", value: query(),
oninput: move |evt| query.set(evt.value()), class: INPUT_INLINE_CLASS,
oninput: move |v: String| query.set(v),
} }
// 上传新图:成功后直接选中 // 上传新图:成功后直接选中
label { class: "shrink-0 text-sm font-medium px-4 py-2 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity cursor-pointer", label { class: "shrink-0 text-sm font-medium px-4 py-2 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity cursor-pointer",

View File

@ -22,6 +22,7 @@ use crate::api::assets::AssetListResponse;
use crate::api::assets::{BatchDeleteAssetsResponse, PurgeOrphansResponse, RebuildAssetsResponse}; use crate::api::assets::{BatchDeleteAssetsResponse, PurgeOrphansResponse, RebuildAssetsResponse};
use crate::components::empty_state::EmptyState; use crate::components::empty_state::EmptyState;
use crate::components::ui::{FilterTabs, Pagination}; use crate::components::ui::{FilterTabs, Pagination};
use crate::components::forms::FormInput;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use crate::utils::js::invoke_optional_global; use crate::utils::js::invoke_optional_global;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@ -247,14 +248,12 @@ pub fn Assets() -> Element {
"重建索引" "重建索引"
} }
} }
input { FormInput {
class: "w-56 text-sm bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-2xl px-4 py-2 shadow-sm transition-all",
r#type: "search", r#type: "search",
placeholder: "搜索文件名 / alt", placeholder: "搜索文件名 / alt",
value: "{query}", value: query(),
oninput: move |evt| { class: "w-56 text-sm px-4 py-2 border border-paper-border rounded-2xl bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors",
query.set(evt.value()); oninput: move |v: String| query.set(v),
},
} }
button { button {
class: "{sort_created_class}", class: "{sort_created_class}",
@ -576,12 +575,12 @@ pub fn Assets() -> Element {
} }
} else if editing_alt().as_deref() == Some(a.id.as_str()) { } else if editing_alt().as_deref() == Some(a.id.as_str()) {
div { class: "flex items-center gap-1 mt-2", div { class: "flex items-center gap-1 mt-2",
input { FormInput {
class: "flex-1 min-w-0 text-[10px] bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-full px-2 py-1 transition-all",
r#type: "text", r#type: "text",
placeholder: "alt 文本", placeholder: "alt 文本",
value: "{alt_input}", value: alt_input(),
oninput: move |evt| alt_input.set(evt.value()), class: "flex-1 min-w-0 text-[10px] px-2 py-1 rounded-full border border-paper-border bg-paper-entry text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:border-paper-accent transition-colors",
oninput: move |v: String| alt_input.set(v),
} }
button { button {
class: "text-[10px] font-medium cursor-pointer px-2 py-1 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity", class: "text-[10px] font-medium cursor-pointer px-2 py-1 rounded-full bg-[var(--color-paper-primary)] text-[var(--color-paper-theme)] hover:opacity-80 transition-opacity",

View File

@ -9,6 +9,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::components::code_runner::CodeRunner; use crate::components::code_runner::CodeRunner;
use crate::components::forms::FormInput;
use crate::components::ui::{ADMIN_CARD_CLASS, BTN_PRIMARY_SM}; use crate::components::ui::{ADMIN_CARD_CLASS, BTN_PRIMARY_SM};
use crate::infra::runner_config::ResourceLimits; use crate::infra::runner_config::ResourceLimits;
@ -107,12 +108,12 @@ pub fn Runner() -> Element {
label { class: "text-sm font-medium text-[var(--color-paper-secondary)]", label { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
"资源覆盖 (JSON, 可选)" "资源覆盖 (JSON, 可选)"
} }
input { FormInput {
class: "w-full px-3 py-2 text-sm border border-paper-border rounded-lg bg-[var(--color-paper-theme)] text-[var(--color-paper-primary)] font-mono focus:outline-none focus:border-[var(--color-paper-accent)] transition-colors",
r#type: "text", r#type: "text",
placeholder: "如 {{\"timeout_secs\":10,\"memory_mb\":512}}", placeholder: "如 {{\"timeout_secs\":10,\"memory_mb\":512}}",
value: "{overrides_json()}", value: overrides_json(),
oninput: move |e| overrides_json.set(e.value()), mono: true,
oninput: move |v: String| overrides_json.set(v),
} }
if !override_error.is_empty() { if !override_error.is_empty() {
p { class: "text-xs text-red-500 dark:text-red-400", "{override_error}" } p { class: "text-xs text-red-500 dark:text-red-400", "{override_error}" }

View File

@ -2,7 +2,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::components::forms::{FORM_SELECT_COMPACT_CLASS, FormSelect}; use crate::components::forms::{FormInput, 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 流式下载。
@ -66,12 +66,12 @@ pub(super) fn ExportTab() -> Element {
if mode().as_str() == "table" { if mode().as_str() == "table" {
div { div {
label { class: "block text-sm text-paper-secondary mb-1", "表名" } label { class: "block text-sm text-paper-secondary mb-1", "表名" }
input { FormInput {
r#type: "text", r#type: "text",
class: "w-full px-3 py-2 text-sm border border-paper-border rounded bg-paper-theme text-paper-primary font-mono",
placeholder: "如 posts", placeholder: "如 posts",
value: "{table_name}", value: table_name(),
oninput: move |e| table_name.set(e.value()), mono: true,
oninput: move |v: String| table_name.set(v),
} }
p { class: "text-xs text-paper-secondary mt-1", p { class: "text-xs text-paper-secondary mt-1",
"仅支持 public schema 下的用户表,表名需为合法标识符" "仅支持 public schema 下的用户表,表名需为合法标识符"

View File

@ -17,7 +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::forms::{FormInput, FormSelect, INPUT_INLINE_CLASS};
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;
@ -36,11 +36,6 @@ use dioxus::html::HasFileData;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
use dioxus::web::{WebEventExt, WebFileExt}; use dioxus::web::{WebEventExt, WebFileExt};
/// 元信息表单复用的样式常量label 与 input 各一份,避免多处重复粘贴)。
/// 提升到模块级以便 CoverUploader 的 URL 输入框复用。
const META_INPUT_CLASS: &str =
"w-full text-sm bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-2xl px-4 py-3 shadow-sm transition-all";
/// 新建文章页面组件。 /// 新建文章页面组件。
/// ///
/// 内部委托给 `write_editor`,以 `None` 表示新建模式。 /// 内部委托给 `write_editor`,以 `None` 表示新建模式。
@ -408,7 +403,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
} }
}; };
// 元信息表单复用样式见模块级 META_INPUT_CLASS。
rsx! { rsx! {
// 根容器:flex 分区布局。layout 给 write 的 main 是 flex 容器(无 padding/不滚动), // 根容器:flex 分区布局。layout 给 write 的 main 是 flex 容器(无 padding/不滚动),
@ -496,11 +490,11 @@ fn write_editor(post_id: Option<i32>) -> Element {
label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3", label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3",
"链接" "链接"
} }
input { FormInput {
class: "w-full text-sm bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-2xl px-3 py-2 transition-all", r#type: "text",
placeholder: "自动生成", placeholder: "自动生成",
value: "{slug}", value: slug(),
oninput: move |evt| slug.set(evt.value()), oninput: move |v: String| slug.set(v),
} }
} }
// 标签节 // 标签节
@ -508,11 +502,11 @@ fn write_editor(post_id: Option<i32>) -> Element {
label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3", label { class: "block text-xs font-semibold uppercase tracking-wide text-[var(--color-paper-tertiary)] mb-3",
"标签" "标签"
} }
input { FormInput {
class: "w-full text-sm bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-[var(--color-paper-primary)] rounded-2xl px-3 py-2 transition-all", r#type: "text",
placeholder: "逗号分隔...", placeholder: "逗号分隔...",
value: "{tags}", value: tags(),
oninput: move |evt| tags.set(evt.value()), oninput: move |v: String| tags.set(v),
} }
} }
// 摘要节 // 摘要节
@ -809,12 +803,13 @@ fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) ->
// —— URL 输入模式(内联展开,空态时叠加在容器外,避免与拖拽区争抢点击)—— // —— URL 输入模式(内联展开,空态时叠加在容器外,避免与拖拽区争抢点击)——
if cover_url_mode() && cover_image().is_empty() { if cover_url_mode() && cover_image().is_empty() {
div { class: "flex items-center gap-2 mt-2", div { class: "flex items-center gap-2 mt-2",
input { FormInput {
class: "flex-1 {META_INPUT_CLASS}", r#type: "url",
placeholder: "粘贴图片链接...", placeholder: "粘贴图片链接...",
value: "{cover_url_input}", value: cover_url_input(),
oninput: move |evt| cover_url_input.set(evt.value()), class: INPUT_INLINE_CLASS,
onkeydown: move |evt| { oninput: move |v: String| cover_url_input.set(v),
onkeydown: move |evt: KeyboardEvent| {
if evt.key() == Key::Enter { if evt.key() == Key::Enter {
let v = cover_url_input().trim().to_string(); let v = cover_url_input().trim().to_string();
if !v.is_empty() { if !v.is_empty() {