<!-- 表格搜索组件 -->
|
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
<template>
|
<section class="art-search-bar art-card-xs" :class="{ 'is-expanded': isExpanded }">
|
<ElForm
|
ref="formRef"
|
:model="modelValue"
|
:label-position="labelPosition"
|
v-bind="{ ...$attrs }"
|
>
|
<ElRow :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="action-column">
|
<div class="action-buttons-wrapper" :style="actionButtonsStyle">
|
<div class="form-buttons">
|
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
|
{{ t('table.searchBar.reset') }}
|
</ElButton>
|
<ElButton
|
v-if="showSearch"
|
type="primary"
|
class="search-button"
|
@click="handleSearch"
|
v-ripple
|
:disabled="disabledSearch"
|
>
|
{{ t('table.searchBar.search') }}
|
</ElButton>
|
</div>
|
<div v-if="shouldShowExpandToggle" class="filter-toggle" @click="toggleExpand">
|
<span>{{ expandToggleText }}</span>
|
<div class="icon-wrapper">
|
<ElIcon>
|
<ArrowUpBold v-if="isExpanded" />
|
<ArrowDownBold v-else />
|
</ElIcon>
|
</div>
|
</div>
|
</div>
|
</ElCol>
|
</ElRow>
|
</ElForm>
|
</section>
|
</template>
|
|
<script setup>
|
import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
|
|
import { useWindowSize } from '@vueuse/core'
|
import { useI18n } from 'vue-i18n'
|
import { toRaw } from 'vue'
|
import {
|
ElCascader,
|
ElCheckbox,
|
ElCheckboxGroup,
|
ElDatePicker,
|
ElInput,
|
ElInputTag,
|
ElInputNumber,
|
ElRadioGroup,
|
ElRate,
|
ElSelect,
|
ElSlider,
|
ElSwitch,
|
ElTimePicker,
|
ElTimeSelect,
|
ElTreeSelect
|
} from 'element-plus'
|
import { calculateResponsiveSpan } from '@/utils/form/responsive'
|
defineOptions({ name: 'ArtSearchBar' })
|
const componentMap = {
|
input: ElInput,
|
// 输入框
|
inputTag: ElInputTag,
|
// 标签输入框
|
number: ElInputNumber,
|
// 数字输入框
|
select: ElSelect,
|
// 选择器
|
switch: ElSwitch,
|
// 开关
|
checkbox: ElCheckbox,
|
// 复选框
|
checkboxgroup: ElCheckboxGroup,
|
// 复选框组
|
radiogroup: ElRadioGroup,
|
// 单选框组
|
date: ElDatePicker,
|
// 日期选择器
|
daterange: ElDatePicker,
|
// 日期范围选择器
|
datetime: ElDatePicker,
|
// 日期时间选择器
|
datetimerange: ElDatePicker,
|
// 日期时间范围选择器
|
rate: ElRate,
|
// 评分
|
slider: ElSlider,
|
// 滑块
|
cascader: ElCascader,
|
// 级联选择器
|
timepicker: ElTimePicker,
|
// 时间选择器
|
timeselect: ElTimeSelect,
|
// 时间选择
|
treeselect: ElTreeSelect
|
// 树选择器
|
}
|
const { width } = useWindowSize()
|
const { t } = useI18n()
|
const isMobile = computed(() => width.value < 500)
|
const formInstance = useTemplateRef('formRef')
|
const props = defineProps({
|
items: { required: false, default: () => [] },
|
span: { required: false, default: 6 },
|
gutter: { required: false, default: 12 },
|
isExpand: { required: false, default: false },
|
labelPosition: { required: false, default: 'right' },
|
labelWidth: { required: false, default: '70px' },
|
showExpand: { required: false, default: true },
|
defaultExpanded: { required: false, default: false },
|
buttonLeftLimit: { required: false, default: 2 },
|
showReset: { required: false, default: true },
|
showSearch: { required: false, default: true },
|
disabledSearch: { required: false, default: false },
|
sanitizeOutput: { required: false, default: () => ({}) }
|
})
|
const emit = defineEmits(['reset', 'search'])
|
const modelValue = defineModel({ default: {} })
|
const initialModelValue = ref({})
|
const cloneModelValue = (value) => {
|
if (!value) return {}
|
const deepClone = (source) => {
|
if (Array.isArray(source)) {
|
return source.map((item) => deepClone(item))
|
}
|
if (source && typeof source === 'object') {
|
const rawSource = toRaw(source)
|
return Object.keys(rawSource).reduce((accumulator, key) => {
|
accumulator[key] = deepClone(rawSource[key])
|
return accumulator
|
}, {})
|
}
|
return source
|
}
|
return deepClone(toRaw(value))
|
}
|
initialModelValue.value = cloneModelValue(modelValue.value)
|
const isExpanded = ref(props.defaultExpanded)
|
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
|
const sanitizeOutputOptions = computed(() => ({
|
removeEmptyString: true,
|
removeEmptyArray: true,
|
removeEmptyObject: true,
|
removeEmptyRichText: true,
|
keepZero: true,
|
keepFalse: true,
|
...props.sanitizeOutput
|
}))
|
const getProps = (item) => {
|
if (item.props) return item.props
|
const props2 = { ...item }
|
rootProps.forEach((key) => delete props2[key])
|
return props2
|
}
|
const getSlots = (item) => {
|
if (!item.slots) return {}
|
const validSlots = {}
|
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
if (slotFn) {
|
validSlots[key] = slotFn
|
}
|
})
|
return validSlots
|
}
|
const getColSpan = (itemSpan, breakpoint) => {
|
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
|
}
|
const normalizeFieldValue = (value) => {
|
return value === '' ? void 0 : value
|
}
|
const getFieldValue = (key) => modelValue.value[key]
|
const setFieldValue = (key, value) => {
|
const normalizedValue = normalizeFieldValue(value)
|
if (normalizedValue === void 0) {
|
delete modelValue.value[key]
|
return
|
}
|
modelValue.value[key] = normalizedValue
|
}
|
const isRichTextEmpty = (value) => {
|
if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) {
|
return false
|
}
|
return (
|
value
|
.replace(/ /gi, '')
|
.replace(/<br\s*\/?>/gi, '')
|
.replace(/<[^>]*>/g, '')
|
.trim() === ''
|
)
|
}
|
const sanitizeOutputValue = (value) => {
|
const options = sanitizeOutputOptions.value
|
if (Array.isArray(value)) {
|
const sanitizedArray = value
|
.map((item) => sanitizeOutputValue(item))
|
.filter((item) => item !== void 0)
|
return sanitizedArray.length === 0 && options.removeEmptyArray ? void 0 : sanitizedArray
|
}
|
if (value && typeof value === 'object') {
|
const rawValue = toRaw(value)
|
const sanitizedObject = Object.entries(rawValue).reduce((accumulator, [key, item]) => {
|
const sanitizedItem = sanitizeOutputValue(item)
|
if (sanitizedItem !== void 0) {
|
accumulator[key] = sanitizedItem
|
}
|
return accumulator
|
}, {})
|
return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject
|
? void 0
|
: sanitizedObject
|
}
|
if (typeof value === 'string') {
|
if (options.removeEmptyString && value.trim() === '') {
|
return void 0
|
}
|
if (options.removeEmptyRichText && isRichTextEmpty(value)) {
|
return void 0
|
}
|
return value
|
}
|
if (value === 0) {
|
return options.keepZero ? value : void 0
|
}
|
if (value === false) {
|
return options.keepFalse ? value : void 0
|
}
|
return value ?? void 0
|
}
|
const getSanitizedOutput = () => {
|
return sanitizeOutputValue(cloneModelValue(modelValue.value)) || {}
|
}
|
const getComponent = (item) => {
|
if (item.render) {
|
return item.render
|
}
|
const { type } = item
|
return componentMap[type] || componentMap['input']
|
}
|
const visibleFormItems = computed(() => {
|
const filteredItems = props.items.filter((item) => !item.hidden)
|
const shouldShowLess = !props.isExpand && !isExpanded.value
|
if (shouldShowLess) {
|
const maxItemsPerRow = Math.floor(24 / props.span) - 1
|
return filteredItems.slice(0, maxItemsPerRow)
|
}
|
return filteredItems
|
})
|
const shouldShowExpandToggle = computed(() => {
|
const filteredItems = props.items.filter((item) => !item.hidden)
|
return (
|
!props.isExpand && props.showExpand && filteredItems.length > Math.floor(24 / props.span) - 1
|
)
|
})
|
const expandToggleText = computed(() => {
|
return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand')
|
})
|
const actionButtonsStyle = computed(() => ({
|
'justify-content': isMobile.value
|
? 'flex-end'
|
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
? 'flex-start'
|
: 'flex-end'
|
}))
|
const toggleExpand = () => {
|
isExpanded.value = !isExpanded.value
|
}
|
const handleReset = () => {
|
formInstance.value?.resetFields()
|
Object.keys(modelValue.value).forEach((key) => {
|
delete modelValue.value[key]
|
})
|
Object.assign(modelValue.value, cloneModelValue(initialModelValue.value))
|
emit('reset')
|
}
|
const handleSearch = () => {
|
emit('search', getSanitizedOutput())
|
}
|
defineExpose({
|
ref: formInstance,
|
validate: (...args) => formInstance.value?.validate(...args),
|
reset: handleReset,
|
// 允许外部在手动组装请求前直接读取清洗后的参数。
|
getOutput: getSanitizedOutput
|
})
|
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
|
</script>
|
|
<style lang="scss" scoped>
|
.art-search-bar {
|
padding: 15px 20px 0;
|
|
.action-column {
|
flex: 1;
|
max-width: 100%;
|
|
.action-buttons-wrapper {
|
display: flex;
|
flex-wrap: wrap;
|
align-items: center;
|
justify-content: flex-end;
|
margin-bottom: 12px;
|
}
|
|
.form-buttons {
|
display: flex;
|
gap: 8px;
|
}
|
|
.filter-toggle {
|
display: flex;
|
align-items: center;
|
margin-left: 10px;
|
line-height: 32px;
|
color: var(--theme-color);
|
cursor: pointer;
|
transition: color 0.2s ease;
|
|
&:hover {
|
color: var(--ElColor-primary);
|
}
|
|
span {
|
font-size: 14px;
|
user-select: none;
|
}
|
|
.icon-wrapper {
|
display: flex;
|
align-items: center;
|
margin-left: 4px;
|
font-size: 14px;
|
transition: transform 0.2s ease;
|
}
|
}
|
}
|
}
|
|
// 响应式优化
|
@media (width <= 768px) {
|
.art-search-bar {
|
padding: 16px 16px 0;
|
|
.action-column {
|
.action-buttons-wrapper {
|
flex-direction: column;
|
gap: 8px;
|
align-items: stretch;
|
|
.form-buttons {
|
justify-content: center;
|
}
|
|
.filter-toggle {
|
justify-content: center;
|
margin-left: 0;
|
}
|
}
|
}
|
}
|
}
|
</style>
|