mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-26 06:19:04 +00:00
feat(components): 增强 FaForm/FaDialog/FaDrawer 并重构业务页面
- FaForm 新增 scrollbar 和 maxHeight 属性,支持表单滚动条 - FaForm 暴露 resetFields、clearValidate、validateField 方法 - FaDialog 和 FaDrawer 新增 formMode、confirmLoading 等属性及 confirm/cancel 事件 - 使用 FaDescriptions 和 FaForm 重构 demo 和 memory 页面,减少模板冗余 - 修复布局组件路径引用,统一至 @/components/layouts - 补充 Fa 前缀组件的 ESLint 全局变量声明
This commit is contained in:
@@ -2,7 +2,106 @@
|
||||
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
||||
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
||||
<template>
|
||||
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
||||
<ElScrollbar v-if="scrollbar" :max-height="maxHeight" :view-style="{ overflowX: 'hidden' }">
|
||||
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
:label-position="labelPosition"
|
||||
v-bind="{ ...$attrs }"
|
||||
>
|
||||
<ElRow class="flex flex-wrap" :gutter="gutter">
|
||||
<ElCol
|
||||
v-for="item in visibleFormItems"
|
||||
:key="item.key"
|
||||
:xs="getColSpan(item.span, 'xs')"
|
||||
:sm="getColSpan(item.span, 'sm')"
|
||||
:md="getColSpan(item.span, 'md')"
|
||||
:lg="getColSpan(item.span, 'lg')"
|
||||
:xl="getColSpan(item.span, 'xl')"
|
||||
>
|
||||
<ElFormItem
|
||||
:prop="item.key"
|
||||
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
||||
>
|
||||
<template #label v-if="item.label">
|
||||
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
||||
<span v-else>{{ item.label }}</span>
|
||||
</template>
|
||||
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
||||
<component
|
||||
:is="getComponent(item)"
|
||||
:model-value="getFieldValue(item.key)"
|
||||
@update:model-value="setFieldValue(item.key, $event)"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 复选框组 -->
|
||||
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
|
||||
<ElCheckbox
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 单选框组 -->
|
||||
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
|
||||
<ElRadio
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 动态插槽支持 -->
|
||||
<template
|
||||
v-for="(slotFn, slotName) in getSlots(item)"
|
||||
:key="slotName"
|
||||
#[slotName]
|
||||
>
|
||||
<component :is="slotFn" />
|
||||
</template>
|
||||
</component>
|
||||
</slot>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
|
||||
<div
|
||||
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
|
||||
:style="actionButtonsStyle"
|
||||
>
|
||||
<div class="flex gap-2 md:justify-center">
|
||||
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
|
||||
{{ t("table.form.reset") }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="showSubmit"
|
||||
type="primary"
|
||||
class="submit-button"
|
||||
@click="handleSubmit"
|
||||
v-ripple
|
||||
:disabled="disabledSubmit"
|
||||
>
|
||||
{{ t("table.form.submit") }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</section>
|
||||
</ElScrollbar>
|
||||
<!-- 不使用滚动条时直接渲染 -->
|
||||
<section v-else class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
@@ -34,7 +133,6 @@
|
||||
@update:model-value="setFieldValue(item.key, $event)"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
@@ -42,8 +140,6 @@
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 复选框组 -->
|
||||
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
|
||||
<ElCheckbox
|
||||
v-for="option in getProps(item).options"
|
||||
@@ -51,8 +147,6 @@
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 单选框组 -->
|
||||
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
|
||||
<ElRadio
|
||||
v-for="option in getProps(item).options"
|
||||
@@ -60,8 +154,6 @@
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 动态插槽支持 -->
|
||||
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
|
||||
<component :is="slotFn" />
|
||||
</template>
|
||||
@@ -206,6 +298,10 @@ interface FormProps {
|
||||
disabledSubmit?: boolean;
|
||||
/** 提交时是否清洗空值 */
|
||||
sanitizeOutput?: Partial<SanitizeOutputOptions>;
|
||||
/** 是否需要内置 ElScrollbar 包裹 */
|
||||
scrollbar?: boolean;
|
||||
/** ElScrollbar 最大高度 */
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
interface SanitizeOutputOptions {
|
||||
@@ -234,6 +330,8 @@ const props = withDefaults(defineProps<FormProps>(), {
|
||||
showSubmit: true,
|
||||
disabledSubmit: false,
|
||||
sanitizeOutput: () => ({}),
|
||||
scrollbar: false,
|
||||
maxHeight: "75vh",
|
||||
});
|
||||
|
||||
interface FormEmits {
|
||||
@@ -505,6 +603,12 @@ const handleSubmit = () => {
|
||||
defineExpose({
|
||||
ref: formInstance,
|
||||
validate: (...args: any[]) => formInstance.value?.validate(...args),
|
||||
/** 代理 ElForm.resetFields */
|
||||
resetFields: (...args: any[]) => formInstance.value?.resetFields(...args),
|
||||
/** 代理 ElForm.clearValidate */
|
||||
clearValidate: (...args: any[]) => formInstance.value?.clearValidate(...args),
|
||||
/** 代理 ElForm.validateField */
|
||||
validateField: (...args: any[]) => formInstance.value?.validateField(...args),
|
||||
reset: handleReset,
|
||||
// 允许外部在不触发提交事件时主动获取清洗后的输出。
|
||||
getOutput: getSanitizedOutput,
|
||||
|
||||
@@ -42,6 +42,16 @@
|
||||
<template v-if="$slots.footer" #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
<template v-else-if="formMode" #footer>
|
||||
<div class="fa-dialog-footer" :style="'padding-right: var(--el-dialog-padding-primary)'">
|
||||
<ElButton v-if="formMode !== 'detail'" @click="emit('cancel')">
|
||||
{{ cancelText }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="confirmLoading" @click="emit('confirm')">
|
||||
{{ confirmText }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
@@ -63,9 +73,19 @@ const props = withDefaults(
|
||||
dialogClass?: string;
|
||||
/** 遮罩层自定义 class */
|
||||
modalClass?: string;
|
||||
/** 表单模式:detail 仅显示确定;create/update 显示取消+确定 */
|
||||
formMode?: "detail" | "create" | "update";
|
||||
/** 确定按钮 loading 状态 */
|
||||
confirmLoading?: boolean;
|
||||
/** 确定按钮文本 */
|
||||
confirmText?: string;
|
||||
/** 取消按钮文本 */
|
||||
cancelText?: string;
|
||||
}>(),
|
||||
{
|
||||
draggable: true,
|
||||
confirmText: "确定",
|
||||
cancelText: "取消",
|
||||
}
|
||||
);
|
||||
|
||||
@@ -74,6 +94,10 @@ const emit = defineEmits<{
|
||||
close: [];
|
||||
opened: [];
|
||||
"fullscreen-change": [isFullscreen: boolean];
|
||||
/** 点击取消按钮 */
|
||||
cancel: [];
|
||||
/** 点击确定按钮 */
|
||||
confirm: [];
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
@@ -112,6 +136,13 @@ const dialogAttrs = computed(() => {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.fa-dialog-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.core-overlay-dialog__actions {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
<template v-if="$slots.footer" #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
<template v-else-if="formMode" #footer>
|
||||
<div class="fa-drawer-footer" :style="'padding-right: var(--el-drawer-padding-primary)'">
|
||||
<ElButton v-if="formMode !== 'detail'" @click="emit('cancel')">
|
||||
{{ cancelText }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" :loading="confirmLoading" @click="emit('confirm')">
|
||||
{{ confirmText }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
@@ -44,9 +54,19 @@ const props = withDefaults(
|
||||
direction?: "rtl" | "ltr" | "ttb" | "btt";
|
||||
/** 透传到 el-drawer 的 class */
|
||||
drawerClass?: string;
|
||||
/** 表单模式:detail 仅显示确定;create/update 显示取消+确定 */
|
||||
formMode?: "detail" | "create" | "update";
|
||||
/** 确定按钮 loading 状态 */
|
||||
confirmLoading?: boolean;
|
||||
/** 确定按钮文本 */
|
||||
confirmText?: string;
|
||||
/** 取消按钮文本 */
|
||||
cancelText?: string;
|
||||
}>(),
|
||||
{
|
||||
direction: "rtl",
|
||||
confirmText: "确定",
|
||||
cancelText: "取消",
|
||||
}
|
||||
);
|
||||
|
||||
@@ -54,6 +74,10 @@ const emit = defineEmits<{
|
||||
"update:modelValue": [v: boolean];
|
||||
close: [];
|
||||
opened: [];
|
||||
/** 点击取消按钮 */
|
||||
cancel: [];
|
||||
/** 点击确定按钮 */
|
||||
confirm: [];
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
@@ -92,6 +116,13 @@ const drawerAttrs = computed(() => {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.fa-drawer-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.core-overlay-drawer__actions {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<ElScrollbar v-if="scrollbar" :max-height="maxHeight" :view-style="{ overflowX: 'hidden' }">
|
||||
<ElDescriptions v-bind="bindings" :class="ns.b()">
|
||||
<template v-if="$slots.title" #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
<template v-if="!$slots.default">
|
||||
<ElDescriptionsItem
|
||||
v-for="item in items"
|
||||
:key="item.prop"
|
||||
:label="item.label"
|
||||
:span="item.span || span"
|
||||
:label-class-name="item.labelClassName"
|
||||
:class-name="item.className"
|
||||
>
|
||||
<!-- 带 slot 名称的项:通过具名插槽自定义 -->
|
||||
<slot
|
||||
v-if="item.slot"
|
||||
:name="item.slot"
|
||||
:item="item"
|
||||
:value="data ? getNestedValue(data, item.prop) : undefined"
|
||||
:row="data"
|
||||
/>
|
||||
<!-- Tag 模式 -->
|
||||
<template v-else-if="item.tag">
|
||||
<ElTag
|
||||
v-if="item.tag === true && data && data[item.prop] !== undefined"
|
||||
:type="resolveTagType(getNestedValue(data, item.prop))"
|
||||
>
|
||||
{{ getNestedValue(data, item.prop) }}
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-else-if="typeof item.tag === 'object' && data"
|
||||
:type="resolveTagType(getNestedValue(data, item.prop), item.tag.type)"
|
||||
>
|
||||
{{ resolveTagText(getNestedValue(data, item.prop), item.tag) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
<!-- 纯文本模式 -->
|
||||
<template v-else>
|
||||
<slot
|
||||
:name="item.prop"
|
||||
:item="item"
|
||||
:value="data ? getNestedValue(data, item.prop) : undefined"
|
||||
:row="data"
|
||||
>
|
||||
{{ data ? getNestedValue(data, item.prop) : "" }}
|
||||
</slot>
|
||||
</template>
|
||||
</ElDescriptionsItem>
|
||||
</template>
|
||||
<!-- 完全自定义模式 -->
|
||||
<template v-else>
|
||||
<slot />
|
||||
</template>
|
||||
</ElDescriptions>
|
||||
</ElScrollbar>
|
||||
<!-- 不使用滚动条时直接渲染 -->
|
||||
<ElDescriptions v-else v-bind="bindings" :class="ns.b()">
|
||||
<template v-if="$slots.title" #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
<template v-if="!$slots.default">
|
||||
<ElDescriptionsItem
|
||||
v-for="item in items"
|
||||
:key="item.prop"
|
||||
:label="item.label"
|
||||
:span="item.span || span"
|
||||
:label-class-name="item.labelClassName"
|
||||
:class-name="item.className"
|
||||
>
|
||||
<slot
|
||||
v-if="item.slot"
|
||||
:name="item.slot"
|
||||
:item="item"
|
||||
:value="data ? getNestedValue(data, item.prop) : undefined"
|
||||
:row="data"
|
||||
/>
|
||||
<template v-else-if="item.tag">
|
||||
<ElTag
|
||||
v-if="item.tag === true && data && data[item.prop] !== undefined"
|
||||
:type="resolveTagType(getNestedValue(data, item.prop))"
|
||||
>
|
||||
{{ getNestedValue(data, item.prop) }}
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-else-if="typeof item.tag === 'object' && data"
|
||||
:type="resolveTagType(getNestedValue(data, item.prop), item.tag.type)"
|
||||
>
|
||||
{{ resolveTagText(getNestedValue(data, item.prop), item.tag) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot
|
||||
:name="item.prop"
|
||||
:item="item"
|
||||
:value="data ? getNestedValue(data, item.prop) : undefined"
|
||||
:row="data"
|
||||
>
|
||||
{{ data ? getNestedValue(data, item.prop) : "" }}
|
||||
</slot>
|
||||
</template>
|
||||
</ElDescriptionsItem>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot />
|
||||
</template>
|
||||
</ElDescriptions>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: "FaDescriptions" });
|
||||
|
||||
import { computed, useAttrs } from "vue";
|
||||
import { useNamespace } from "element-plus";
|
||||
// ── 组件类型定义(内联,避免跨文件引用导致 TS Server 延迟)──
|
||||
/** ElTag 可接受的 type 值 */
|
||||
export type TagType = "primary" | "success" | "warning" | "danger" | "info";
|
||||
|
||||
/** Tag 渲染配置 */
|
||||
export interface TagConfig {
|
||||
/**
|
||||
* Tag 映射表:key 为数据值,value 为 { type: ElTag类型, text: 显示文本 }
|
||||
* 例:{ '0': { type: 'success', text: '启用' }, '1': { type: 'danger', text: '停用' } }
|
||||
*/
|
||||
map?: Record<string, { type?: TagType; text?: string }>;
|
||||
/** 默认 type,当 map 未匹配到时使用 */
|
||||
type?: TagType;
|
||||
}
|
||||
|
||||
/** 单个描述项配置 */
|
||||
export interface DescriptionsItem {
|
||||
/** 标签文本 */
|
||||
label: string;
|
||||
/** 对应 data 中的属性名,支持 'a.b.c' 嵌套路径 */
|
||||
prop: string;
|
||||
/** 列跨度,不传则使用组件级 span 默认值 */
|
||||
span?: number;
|
||||
/**
|
||||
* 渲染模式:
|
||||
* - 不设置:纯文本
|
||||
* - true:作为 ElTag 渲染,文本为 data[prop]
|
||||
* - TagConfig 对象:通过 map 映射类型和文本
|
||||
*/
|
||||
tag?: boolean | TagConfig;
|
||||
/** 自定义插槽名称,设置后该 item 将通过具名插槽渲染 */
|
||||
slot?: string;
|
||||
/** 传递给 el-descriptions-item 的 label-class-name */
|
||||
labelClassName?: string;
|
||||
/** 传递给 el-descriptions-item 的 class-name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const attrs = useAttrs();
|
||||
const ns = useNamespace("descriptions");
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 每行显示的描述项数量,默认 4 */
|
||||
column?: number;
|
||||
/** 是否展示边框,默认 true */
|
||||
border?: boolean;
|
||||
/** 列表尺寸,默认 'default' */
|
||||
size?: "default" | "small";
|
||||
/** 标签宽度 */
|
||||
labelWidth?: string;
|
||||
/** 描述项配置列表 */
|
||||
items?: DescriptionsItem[];
|
||||
/** 数据对象,items 模式下自动取 data[item.prop] 渲染 */
|
||||
data?: Record<string, unknown> | null;
|
||||
/** 默认 span,当 item 未指定 span 时生效,默认 2 */
|
||||
span?: number;
|
||||
/** 是否需要内置 ElScrollbar 包裹,默认 true */
|
||||
scrollbar?: boolean;
|
||||
/** ElScrollbar 最大高度,默认 '70vh' */
|
||||
maxHeight?: string;
|
||||
}>(),
|
||||
{
|
||||
column: 4,
|
||||
border: true,
|
||||
size: "default",
|
||||
labelWidth: undefined,
|
||||
items: () => [],
|
||||
data: null,
|
||||
span: 2,
|
||||
scrollbar: true,
|
||||
maxHeight: "70vh",
|
||||
}
|
||||
);
|
||||
|
||||
// 传递给 ElDescriptions 的绑定属性
|
||||
const bindings = computed(() => {
|
||||
const bind: Record<string, unknown> = {
|
||||
column: props.column,
|
||||
border: props.border,
|
||||
...attrs, // 透传 class、style 等属性
|
||||
};
|
||||
if (props.size !== "default") bind.size = props.size;
|
||||
if (props.labelWidth !== undefined) bind.labelWidth = props.labelWidth;
|
||||
return bind;
|
||||
});
|
||||
|
||||
/** 获取嵌套属性值,支持 'a.b.c' 路径 */
|
||||
function getNestedValue(obj: Record<string, unknown> | null, path: string): unknown {
|
||||
if (!obj) return undefined;
|
||||
return path.split(".").reduce((acc, key) => {
|
||||
if (acc && typeof acc === "object" && key in acc) {
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, obj as unknown);
|
||||
}
|
||||
|
||||
/** 解析 ElTag 的 type 属性,确保返回 ElTag 可接受的有效类型值 */
|
||||
function resolveTagType(_value: unknown, fallback?: string): TagType {
|
||||
// 优先级 1: fallback 本身就是合法 TagType → 直接使用
|
||||
if (fallback && isValidTagType(fallback)) return fallback;
|
||||
// 优先级 2: 无有效 fallback 时,用 'info' 作为兜底
|
||||
return "info";
|
||||
}
|
||||
|
||||
const TAG_TYPES: Set<string> = new Set(["primary", "success", "warning", "danger", "info"]);
|
||||
function isValidTagType(v: string): v is TagType {
|
||||
return TAG_TYPES.has(v);
|
||||
}
|
||||
|
||||
/** 解析 ElTag 显示的文本,支持 tagMap 映射 */
|
||||
function resolveTagText(value: unknown, tag: TagConfig): string {
|
||||
const raw = value == null ? "" : String(value);
|
||||
if (tag.map && raw in tag.map) {
|
||||
return tag.map[raw].text ?? raw;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* fa-descriptions 在 ElScrollbar 内部包裹时,
|
||||
* 需要占满宽度,避免 ElDescriptions 边框被截断。
|
||||
*/
|
||||
:deep(.fa-descriptions) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user