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:
zhangtao
2026-05-15 19:42:39 +08:00
parent 9176d477f9
commit 0344765e5a
35 changed files with 1864 additions and 1762 deletions
@@ -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>