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 框等样式。
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 类,用于表单提交等主操作按钮。
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`
/// - `oninput`:输入事件回调,返回新的字符串值
/// - `onkeydown`:可选的键盘事件回调
/// - `class`:自定义 class可选缺省用 [`INPUT_CLASS`] 全宽表单款)。
/// 内联/固定宽度场景传 [`INPUT_INLINE_CLASS`] 或自定义类串,覆盖默认样式。
/// - `mono`是否使用等宽字体代码片段、表名、JSON 等,缺省 `false`
#[component]
pub fn FormInput(
id: Option<String>,
@ -328,7 +335,13 @@ pub fn FormInput(
oninput: EventHandler<String>,
#[props(default)]
onkeydown: Option<EventHandler<KeyboardEvent>>,
#[props(default)]
class: Option<&'static str>,
#[props(default)]
mono: bool,
) -> Element {
let base = class.unwrap_or(INPUT_CLASS);
let mono_class = if mono { " font-mono" } else { "" };
let disabled_class = if disabled {
" opacity-60 cursor-not-allowed"
} else {
@ -337,7 +350,7 @@ pub fn FormInput(
rsx! {
input {
id: id.unwrap_or_default(),
class: "{INPUT_CLASS} {disabled_class}",
class: "{base}{mono_class}{disabled_class}",
r#type: "{r#type}",
placeholder: "{placeholder}",
value: "{value}",

View File

@ -5,6 +5,7 @@
//! 纯 Dioxus 组件,不触碰 Tiptap数据加载仅在 WASM 前端发生。
use dioxus::prelude::*;
use crate::components::forms::{FormInput, INPUT_INLINE_CLASS};
#[cfg(target_arch = "wasm32")]
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",
"选择封面图"
}
input {
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",
FormInput {
r#type: "search",
placeholder: "搜索文件名 / alt",
value: "{query}",
oninput: move |evt| query.set(evt.value()),
value: query(),
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",

View File

@ -22,6 +22,7 @@ use crate::api::assets::AssetListResponse;
use crate::api::assets::{BatchDeleteAssetsResponse, PurgeOrphansResponse, RebuildAssetsResponse};
use crate::components::empty_state::EmptyState;
use crate::components::ui::{FilterTabs, Pagination};
use crate::components::forms::FormInput;
#[cfg(target_arch = "wasm32")]
use crate::utils::js::invoke_optional_global;
#[cfg(target_arch = "wasm32")]
@ -247,14 +248,12 @@ pub fn Assets() -> Element {
"重建索引"
}
}
input {
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",
FormInput {
r#type: "search",
placeholder: "搜索文件名 / alt",
value: "{query}",
oninput: move |evt| {
query.set(evt.value());
},
value: query(),
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",
oninput: move |v: String| query.set(v),
}
button {
class: "{sort_created_class}",
@ -576,12 +575,12 @@ pub fn Assets() -> Element {
}
} else if editing_alt().as_deref() == Some(a.id.as_str()) {
div { class: "flex items-center gap-1 mt-2",
input {
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",
FormInput {
r#type: "text",
placeholder: "alt 文本",
value: "{alt_input}",
oninput: move |evt| alt_input.set(evt.value()),
value: alt_input(),
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 {
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 crate::components::code_runner::CodeRunner;
use crate::components::forms::FormInput;
use crate::components::ui::{ADMIN_CARD_CLASS, BTN_PRIMARY_SM};
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)]",
"资源覆盖 (JSON, 可选)"
}
input {
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",
FormInput {
r#type: "text",
placeholder: "如 {{\"timeout_secs\":10,\"memory_mb\":512}}",
value: "{overrides_json()}",
oninput: move |e| overrides_json.set(e.value()),
value: overrides_json(),
mono: true,
oninput: move |v: String| overrides_json.set(v),
}
if !override_error.is_empty() {
p { class: "text-xs text-red-500 dark:text-red-400", "{override_error}" }

View File

@ -2,7 +2,7 @@
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;
/// 数据导出 tab按表/按查询导出 SQL/CSV走 Axum 流式下载。
@ -66,12 +66,12 @@ pub(super) fn ExportTab() -> Element {
if mode().as_str() == "table" {
div {
label { class: "block text-sm text-paper-secondary mb-1", "表名" }
input {
FormInput {
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",
value: "{table_name}",
oninput: move |e| table_name.set(e.value()),
value: table_name(),
mono: true,
oninput: move |v: String| table_name.set(v),
}
p { class: "text-xs text-paper-secondary mt-1",
"仅支持 public schema 下的用户表,表名需为合法标识符"

View File

@ -17,7 +17,7 @@ use crate::api::posts::{
#[cfg(target_arch = "wasm32")]
use crate::tiptap_bridge::{consume_upload_event, upload_image_file, EditorHandle};
// 共享上传状态类型两端都编译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::write_skeleton::WriteSkeleton;
use crate::models::post::Post;
@ -36,11 +36,6 @@ use dioxus::html::HasFileData;
#[cfg(target_arch = "wasm32")]
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` 表示新建模式。
@ -408,7 +403,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
}
};
// 元信息表单复用样式见模块级 META_INPUT_CLASS。
rsx! {
// 根容器: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",
"链接"
}
input {
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",
FormInput {
r#type: "text",
placeholder: "自动生成",
value: "{slug}",
oninput: move |evt| slug.set(evt.value()),
value: slug(),
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",
"标签"
}
input {
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",
FormInput {
r#type: "text",
placeholder: "逗号分隔...",
value: "{tags}",
oninput: move |evt| tags.set(evt.value()),
value: tags(),
oninput: move |v: String| tags.set(v),
}
}
// 摘要节
@ -809,12 +803,13 @@ fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) ->
// —— URL 输入模式(内联展开,空态时叠加在容器外,避免与拖拽区争抢点击)——
if cover_url_mode() && cover_image().is_empty() {
div { class: "flex items-center gap-2 mt-2",
input {
class: "flex-1 {META_INPUT_CLASS}",
FormInput {
r#type: "url",
placeholder: "粘贴图片链接...",
value: "{cover_url_input}",
oninput: move |evt| cover_url_input.set(evt.value()),
onkeydown: move |evt| {
value: cover_url_input(),
class: INPUT_INLINE_CLASS,
oninput: move |v: String| cover_url_input.set(v),
onkeydown: move |evt: KeyboardEvent| {
if evt.key() == Key::Enter {
let v = cover_url_input().trim().to_string();
if !v.is_empty() {