refactor(admin): 统一后台输入框为 FormInput 组件
扩展 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:
parent
1cb92a8046
commit
8ea51cab7b
@ -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}",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}" }
|
||||
|
||||
@ -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 下的用户表,表名需为合法标识符"
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user