基于element-plus二次封装通用表格、搜索组件

This commit is contained in:
yinqi 2026-02-10 14:59:33 +08:00
parent f9c3958d5d
commit c8e3ecac54
14 changed files with 1385 additions and 4 deletions

View File

@ -23,7 +23,8 @@
"@element-plus/icons-vue": "2.3.2",
"@highlightjs/vue-plugin": "2.1.2",
"@vueup/vue-quill": "1.2.0",
"@vueuse/core": "13.9.0",
"@vueuse/core": "13.1.0",
"@vitejs/plugin-vue-jsx": "^4.1.2",
"animate.css": "4.1.1",
"await-to-js": "3.0.0",
"axios": "1.13.1",

View File

@ -193,6 +193,7 @@ h6 {
}
.el-card__body {
height: 100%;
padding: 15px 20px 20px 20px !important;
}

View File

@ -0,0 +1,370 @@
<template>
<div class="table-container">
<el-row>
<slot name="topButton" />
</el-row>
<div class="table">
<!-- 表格组件 如需其他事件 请参考官方文档在下方添加 -->
<el-table
id="iTable"
ref="iTableRef"
v-loading="loading"
element-loading-text="正在加载中..."
element-loading-spinner="el-icon-loading"
:height="height"
:max-height="maxHeight"
:row-key="rowKey"
:data="tableData"
show-overflow-tooltip
v-bind="options"
@sort-change="sortChange"
@selection-change="handleSelectionChange"
:tree-props="treeProps"
stripe
>
<!-- 渲染所有列包括多级表头 -->
<template v-for="(column, index) in processedHeaders" :key="index + (column.prop || '')">
<!-- 特殊列处理selectionindex 其他后续补充 -->
<el-table-column
v-if="column.type === 'selection'"
type="selection"
:width="column.width || 50"
:align="column.align || 'center'"
:fixed="column.fixed"
/>
<el-table-column
v-else-if="column.type === 'index'"
type="index"
:label="column.label || '序号'"
:width="column.width || 60"
:align="column.align || 'center'"
:index="column.indexMethod"
:fixed="column.fixed"
/>
<!-- 多级表头处理 -->
<el-table-column
v-else-if="column.children && column.children.length"
:label="column.label"
:align="column.align || 'center'"
:min-width="headSpanFit(column)"
>
<!-- 递归渲染子列 -->
<template v-for="(child, childIndex) in column.children" :key="childIndex + (child.prop || '')">
<el-table-column v-if="isColumnVisible(child)" :min-width="headSpanFit(child)" v-bind="child">
<!-- 子列的插槽和内容渲染与普通列相同 -->
<template v-for="(value, key) in child.slot" #[key]="scope">
<slot :name="value" v-bind="scope"></slot>
</template>
<template v-if="!child.slot" #default="scope">
<template v-if="child.render">
<div v-if="isString(getRenderContent(child, scope))" @click.stop="child.render(scope)">
<div v-html="getRenderContent(child, scope)"></div>
</div>
<component v-else :is="child.render" v-bind="scope" :row="scope.row" :index="scope.$index" @click.stop />
</template>
<template v-else-if="child.formatter">
<span v-html="child.formatter(scope.row, child)"></span>
</template>
<template v-else-if="child.dict">
<dict-tag :options="useDict(child.dict)[child.dict]" :value="scope.row[child.prop]" />
</template>
<template v-else-if="!child.type">
<span>{{ scope.row[child.prop] ?? '--' }}</span>
</template>
</template>
</el-table-column>
</template>
</el-table-column>
<!-- 普通列 -->
<el-table-column v-else-if="isColumnVisible(column)" :min-width="headSpanFit(column)" v-bind="column">
<!-- 渲染插槽 -->
<template v-for="(value, key) in column.slot" #[key]="scope">
<slot :name="value" v-bind="scope"></slot>
</template>
<!-- 渲染默认内容 -->
<template v-if="!column.slot" #default="scope">
<!-- 渲染render -->
<template v-if="column.render">
<div v-if="isString(getRenderContent(column, scope))" @click.stop="column.render(scope)">
<div v-html="getRenderContent(column, scope)"></div>
</div>
<component v-else :is="column.render" v-bind="scope" :row="scope.row" :index="scope.$index" @click.stop />
</template>
<!-- 渲染formatter -->
<template v-else-if="column.formatter">
<span v-html="column.formatter(scope.row, column)"></span>
</template>
<!-- 渲染字典内容 -->
<template v-else-if="column.dict">
<dict-tag :options="useDict(column.dict)[column.dict]" :value="scope.row[column.prop]" />
</template>
<!-- 渲染默认内容 -->
<template v-else-if="!column.type">
<span>{{ scope.row[column.prop] ?? '--' }}</span>
</template>
</template>
</el-table-column>
</template>
<!-- 操作列 -->
<el-table-column v-if="operates.list?.length" label="操作" align="left" :width="operates.width" :fixed="operates.fixed">
<template #default="scope">
<div class="operate-group">
<!-- 渲染外部按钮 -->
<template v-for="(item, idx) in getVisibleButtons(scope.row).outside" :key="idx">
<el-button
v-bind="item"
:type="item.type || 'primary'"
:size="item.size || 'small'"
@click.stop="item.method(scope.row, scope.$index)"
>
{{ item.label }}
</el-button>
</template>
<!-- 渲染下拉按钮 -->
<el-dropdown v-if="getVisibleButtons(scope.row).inside.length" class="custom-dropdown" trigger="click">
<el-button link size="small" class="custom-text">
<el-icon :size="18"><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(item, idx) in getVisibleButtons(scope.row).inside"
:key="idx"
v-bind="item"
@click="item.method(scope.row, scope.$index)"
>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div v-if="!hidden" class="pagination-container" :style="{ justifyContent: paginationJustify }">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:background="background"
:layout="layout"
:page-sizes="pageSizes"
:pager-count="pagerCount"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup>
import { computed, ref, useSlots, watch } from 'vue'
import { MoreFilled } from '@element-plus/icons-vue'
import { scrollTo } from '@/utils/scroll-to'
import { isString, isFunction } from '@/utils/index'
import { havePermi } from '@/plugins/auth'
defineOptions({ name: 'FormTable' })
const props = defineProps({
height: { type: String, default: '300px' },
maxHeight: { type: String, default: '100%' },
loading: { type: Boolean, default: false },
columns: { type: Array, default: () => [] },
tableData: { type: Array, default: () => [] },
total: { type: Number, default: 0 },
page: { type: Number, default: 1 },
options: {
type: Object,
default: () => ({ stripe: false, highlightCurrentRow: false, border: true })
},
limit: { type: Number, default: 20 },
pageSizes: { type: Array, default: () => [10, 20, 30, 50] },
pagerCount: {
type: Number,
default: () => (typeof document !== 'undefined' && document.body.clientWidth < 992 ? 5 : 7)
},
layout: { type: String, default: 'total, sizes, prev, pager, next, jumper' },
background: { type: Boolean, default: true },
autoScroll: { type: Boolean, default: true },
hidden: { type: Boolean, default: false },
operates: { type: Object, default: () => ({ list: [] }) },
paginationPosition: { type: String, default: 'right' },
treeProps: {
type: Object,
default: () => ({ children: 'children', hasChildren: 'hasChildren' })
},
rowKey: { type: String, default: 'id' }
})
const emit = defineEmits(['update:page', 'update:limit', 'pagination', 'sortChange', 'handleSelectionChange'])
const slots = useSlots()
const iTableRef = ref(null)
const processedHeaders = ref([])
/** 1. 逻辑优化:使用 watch 监听 columns 变化,避免在 onBeforeUpdate 中过度计算 **/
watch(
() => [props.columns, slots],
() => {
processedHeaders.value = props.columns.map(column => {
const col = { ...column }
if (!col.key) col.key = col.prop
//
Object.keys(slots).forEach(key => {
const res = key.match(/^(\S+)-(\S+)/)
if (res && res[2] === col.key) {
col.slot = { ...col.slot, [res[1]]: res[0] }
}
})
return col
})
},
{ immediate: true, deep: true }
)
/** 2. 工具函数 **/
//
const headSpanFit = column => {
const labelLen = column?.label?.length || 0
return Math.max(labelLen * 20, 100)
}
//
const isColumnVisible = column => {
if (isFunction(column.show)) return column.show()
return column.show !== false
}
//
const getRenderContent = (column, scope) => {
return isFunction(column.render) ? column.render(scope) : column.render
}
//
const getVisibleButtons = row => {
const list = (props.operates?.list || []).filter(item => {
if (item.permission) {
return havePermi(item.permission)
}
return isFunction(item.show) ? item.show(row) : item.show !== false
})
return list.length > 3
? {
outside: list.slice(0, 2),
inside: list.slice(2)
}
: {
outside: list,
inside: []
}
}
/** 3. 分页计算属性 **/
//
const currentPage = computed({
get: () => props.page,
set: val => emit('update:page', val)
})
//
const pageSize = computed({
get: () => props.limit,
set: val => emit('update:limit', val)
})
//
const paginationJustify = computed(() => {
const map = { left: 'flex-start', center: 'center', right: 'flex-end' }
return map[props.paginationPosition] || 'flex-end'
})
/** 4. 事件处理 **/
//
const handleSizeChange = val => {
if (currentPage.value * val > props.total) currentPage.value = 1
emit('pagination', { page: currentPage.value, limit: val })
if (props.autoScroll) scrollTo(0, 800)
}
//
const handleCurrentChange = val => {
emit('pagination', { page: val, limit: pageSize.value })
if (props.autoScroll) scrollTo(0, 800)
}
//
const sortChange = args => emit('sortChange', args)
//
const handleSelectionChange = val => emit('handleSelectionChange', val)
defineExpose({ iTableRef })
</script>
<style scoped lang="scss">
.table-container {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
.table {
flex: 1;
position: relative;
:deep(.el-table) {
position: absolute;
height: 100%;
}
}
}
.pagination-container {
padding: 16px 0;
display: flex;
}
.operate-group {
display: flex;
align-items: center;
.el-button + .el-button,
.el-button + .custom-dropdown {
margin-left: 16px;
position: relative;
&::before {
content: '';
position: absolute;
left: -8px;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 12px;
background-color: #dcdfe6;
}
}
:deep(.custom-text) {
color: #606266;
padding: 0;
&:hover {
color: #409eff;
}
}
}
</style>

View File

@ -0,0 +1,34 @@
import { defineComponent } from 'vue';
export default defineComponent({
name: 'ex-slot',
props: {
value: [String, Number],
row: Object,
render: Function,
rowIndex: Number,
column: Object,
columnIndex: Number,
class: [String, Array, Object],
style: [String, Array, Object],
dict: [Array]
},
render() {
const value = this.render({
value: this.value,
row: this.row,
column: this.column,
rowIndex: this.rowIndex,
columnIndex: this.columnIndex,
dict: this.dict
});
if (this.style || this.class) {
return (
<div style={this.style} class={this.class}>
{value}
</div>
);
}
return value;
}
});

View File

@ -0,0 +1,215 @@
<template>
<el-input
v-if="['text', 'number', 'textarea'].includes(config.type)"
v-model="value"
:type="config.type"
:clearable="config.clearable"
:placeholder="config.placeholder || '请输入'"
:readonly="config.readonly"
:suffix-icon="typeof config.suffixIcon == 'string' ? config.suffixIcon : ''"
:prefix-icon="typeof config.prefixIcon == 'string' ? config.prefixIcon : ''"
:disabled="config.disabled"
:rows="config.rows || 2"
:class="config.class"
:style="config.style"
@click="$event => typeof config.click == 'function' && config.click(value, $event)"
@mousewheel.prevent
@keydown.up.prevent
@keydown.down.prevent
>
<template #suffix>
<ex-slot v-if="typeof config.suffixIcon == 'function'" :render="config.suffixIcon" :value="value" />
</template>
<template #prefix>
<ex-slot v-if="typeof config.prefixIcon == 'function'" :render="config.prefixIcon" :value="value" />
</template>
</el-input>
<el-select
ref="select"
v-else-if="config.type == 'select'"
v-model="value"
:clearable="config.clearable"
:placeholder="config.placeholder || '请选择'"
:multiple="config.multiple"
:filterable="config.filterable"
:disabled="config.disabled"
:loading="config.loading"
:loading-text="config.loadingText"
:class="config.class"
:style="config.style"
:teleported="false"
:fit-input-width="config.fitInputWidth || false"
:collapse-tags="config.collapseTags"
:collapse-tags-tooltip="config.collapseTagsTooltip"
:max-collapse-tags="config.maxCollapseTags"
@change="$event => typeof config.change == 'function' && config.change($event, $refs.select)"
@visible-change="$event => typeof config.visibleChange == 'function' && config.visibleChange($event, $refs.select)"
>
<el-option
v-for="(item, index) in options"
:key="index"
:value="config.props?.value ? item[config.props?.value] : item"
:label="config.props?.label ? item[config.props?.label] : item"
/>
</el-select>
<el-tree-select
v-else-if="config.type == 'treeselect'"
v-model="value"
:node-key="config.nodeKey || 'id'"
:data="config.options"
:props="config.props"
:clearable="config.clearable"
:placeholder="config.placeholder || '请选择'"
:multiple="config.multiple"
:filterable="config.filterable"
:disabled="config.disabled"
:check-strictly="config.checkStrictly"
:show-checkbox="config.showCheckbox"
:check-on-click-node="config.checkOnClickNode"
:loading="config.loading"
:loading-text="config.loadingText"
:highlight-current="config.highlightCurrent"
:class="config.class"
:style="config.style"
@change="$event => typeof config.change == 'function' && config.change($event)"
/>
<el-date-picker
v-else-if="
['year', 'month', 'date', 'dates', 'datetime', 'week', 'datetimerange', 'daterange', 'monthrange'].includes(config.type)
"
v-model="value"
:clearable="config.clearable"
:editable="config.editable"
:type="config.type"
:placeholder="config.placeholder || '请选择'"
:range-separator="config.rangeSeparator"
:start-placeholder="config.startPlaceholder || '开始时间'"
:end-placeholder="config.endPlaceholder || '结束时间'"
:default-time="config.defaultTime"
:default-value="config.defaultValue"
:value-format="config.valueFormat"
:disabled="config.disabled"
:shortcuts="config.shortcuts"
:class="config.class"
:style="config.style"
@change="$event => typeof config.change == 'function' && config.change($event)"
/>
<el-time-select
v-else-if="config.type == 'timeselect'"
v-model="value"
:type="config.type"
:start="config.start || '00:00'"
:step="config.step || '00:01'"
:end="config.start || '23:59'"
:placeholder="config.placeholder || '请选择'"
:class="config.class"
:style="config.style"
/>
<el-time-picker
v-else-if="config.type == 'timepicker'"
v-model="value"
:editable="config.editable"
:is-range="config.isRange"
:placeholder="config.placeholder || '请选择'"
:start-placeholder="config.startPlaceholder || '开始时间'"
:end-placeholder="config.endPlaceholder || '结束时间'"
:range-separator="config.rangeSeparator"
:clearable="config.clearable"
:class="config.class"
:style="config.style"
/>
<el-autocomplete
v-else-if="config.type == 'autocomplete'"
v-model="value"
:disabled="config.disabled"
:value-key="config.valueKey || 'value'"
:debounce="config.debounce || 300"
:placement="config.placement || 'bottom-start'"
:fetch-suggestions="config.fetchSuggestions"
:clearable="config.clearable"
:placeholder="config.placeholder || '请输入'"
@select="$event => typeof config.select == 'function' && config.select($event)"
/>
</template>
<script>
import exSlot from '@/components/SearchForm/exSlot'
import request from '@/utils/request'
import { useDictStore } from '@/store/modules/dict'
export default {
name: 'form-elem',
components: {
exSlot
},
props: {
config: Object,
start: {
type: String,
default: ''
},
end: {
type: String,
default: ''
}
},
data() {
return {
dictOptions: []
}
},
computed: {
options() {
return this.config.options || this.dictOptions
}
},
created() {
this.requestApi()
this.loadDict()
},
methods: {
requestApi() {
if (this.config.request) {
request({
url: this.config.request.url,
method: this.config.request.method,
params: this.config.request.params
}).then(res => {
if (res.data.success) {
// eslint-disable-next-line vue/no-mutating-props
this.config.options = res.data.data
}
})
}
},
async loadDict() {
if (this.config.dict) {
const dictStore = useDictStore() //
let dictData = dictStore.getDict(this.config.dict) //
if (!dictData) {
//
const response = await request({
url: `/system/dict/data/type/${this.config.dict}`,
method: 'get'
})
dictData = response.data.map(item => ({
label: item.dictLabel,
value: item.dictValue
}))
dictStore.setDict(this.config.dict, dictData) //
}
this.dictOptions = dictData //
}
}
}
}
</script>
<style lang="scss" scoped>
:deep(input::-webkit-outer-spin-button),
:deep(input::-webkit-inner-spin-button) {
-webkit-appearance: none !important;
}
:deep(input[type='number']) {
-moz-appearance: textfield !important;
}
</style>

View File

@ -0,0 +1,185 @@
<template>
<div class="w-100" ref="wrap">
<el-form :model="model" @submit.prevent @keyup.enter="search" inline>
<el-form-item
v-for="(item, index) in config"
:key="index"
:ref="item.prop || `${item.start}_${item.end}`"
:label="`${item.label}`"
label-width="auto"
>
<form-elem v-model="model[item.prop]" :config="item" />
</el-form-item>
<el-form-item ref="search" style="margin-right: 0px">
<template v-if="isSearch">
<el-button type="primary" icon="Search" @click="search">搜索</el-button>
<el-button type="warning" icon="Refresh" v-if="isReset" @click="reset">重置</el-button>
</template>
<slot />
</el-form-item>
</el-form>
<template v-if="suffixConfig.length > 0">
<el-form v-if="visible" :model="model" @submit.prevent @keyup.enter="search" inline>
<el-form-item
v-for="(item, index) in suffixConfig"
:key="index"
:ref="item.prop || `${item.start}_${item.end}`"
:label="`${item.label}`"
label-width="auto"
>
<form-elem v-model="model[item.prop]" :config="item" />
</el-form-item>
</el-form>
<div class="cursor-pointer" @click="showMore">
<el-divider>
{{ visible ? '折叠' : '更多' }}
<el-icon>
<ArrowUp v-if="visible" />
<ArrowDown v-else />
</el-icon>
</el-divider>
</div>
</template>
</div>
</template>
<script>
import { validatenull } from '@/utils/validate'
import formElem from './formElem.vue'
export default {
name: 'search-form',
components: {
'form-elem': formElem
},
props: {
//
model: {
type: Object,
default: () => {}
},
//
configs: {
type: Array,
default: () => []
},
//
isReset: {
type: Boolean,
default: true
},
//
isSearch: {
type: Boolean,
default: true
}
},
data() {
return {
visible: false,
prefixConfig: [],
suffixConfig: []
}
},
computed: {
config() {
return this.prefixConfig.length > 0 ? this.prefixConfig : this.configs
}
},
watch: {
configs: 'resize'
},
beforeMount() {
const dicts = []
this.configs.forEach(item => item.dict && !dicts.includes(item.dict) && dicts.push(item.dict))
},
mounted() {
this.resize()
},
methods: {
showMore() {
this.visible = !this.visible
this.$emit('toggle', this.visible)
},
resize() {
this.prefixConfig = []
this.suffixConfig = []
const wrapWidth = this.$refs.wrap.offsetWidth
const btnWidth = this.$refs.search?.$el.offsetWidth || 0
const formWidth = wrapWidth - btnWidth - 1
let width = 0
for (let index = 0; index < this.configs.length; index++) {
const item = this.configs[index]
const prop = item.prop || `${item.start}_${item.end}`
this.initialize(item)
this.$nextTick(() => {
if (validatenull(item.width) && this.$refs[prop]) {
const elem = this.$refs[prop][0].$el
item.width = elem.offsetWidth + 32
}
width += item.width
if (index == 0 && item.width > formWidth) {
this.prefixConfig.push(item)
} else {
width > formWidth ? this.suffixConfig.push(item) : this.prefixConfig.push(item)
}
})
}
this.$nextTick(() => this.$emit('resize'))
},
initialize(item) {
if (!('type' in item)) item.type = 'text'
if (!('clearable' in item)) item.clearable = true
if (!('props' in item) && item.dict) {
item.props = {
value: 'value',
label: 'label'
}
}
if (!('placeholder' in item)) {
if (['text', 'number', 'autocomplete'].includes(item.type)) {
item.placeholder = `请输入${item.label}`
} else if (['select', 'treeselect', 'year', 'month', 'week', 'date', 'datetime'].includes(item.type)) {
item.placeholder = `请选择${item.label}`
}
}
if (['select', 'treeselect'].includes(item.type)) {
if (item.loading && !('loadingText' in item)) item.loadingText = '加载中...'
if (item.type == 'treeselect') {
if (!('checkOnClickNode' in item)) item.checkOnClickNode = true
if (!('checkStrictly' in item)) item.checkStrictly = true
if (item.multiple && !('showCheckbox' in item)) item.showCheckbox = true
}
}
if (['datetimerange', 'daterange', 'monthrange'].includes(item.type)) {
if (!('startPlaceholder' in item)) {
item.startPlaceholder = '开始时间'
}
if (!('endPlaceholder' in item)) {
item.endPlaceholder = '结束时间'
}
}
},
search() {
this.$emit('search')
},
reset() {
this.$emit('reset')
}
}
}
</script>
<style lang="scss" scoped>
:deep(.el-form-item--small) {
margin-bottom: 10px;
}
.w-100 {
width: 100%;
}
</style>
<style lang="scss">
.el-divider--horizontal {
margin: 10px 0;
}
</style>

View File

@ -0,0 +1,95 @@
<template>
<div v-if="isCard" style="height: 100%">
<search-form
ref="search"
:configs="configs"
:model="model"
:is-reset="isReset"
:is-search="isSearch"
@search="search"
@reset="reset"
@toggle="toggle"
@resize="toggle"
>
<slot name="button" />
</search-form>
<div ref="wrap">
<slot />
</div>
</div>
<div v-else style="height: 100%">
<search-form
ref="search"
:configs="configs"
:model="model"
:is-reset="isReset"
:is-search="isSearch"
@search="search"
@reset="reset"
@toggle="toggle"
@resize="toggle"
/>
<div ref="wrap">
<slot />
</div>
</div>
</template>
<script>
import SearchForm from '@/components/SearchForm/index.vue'
export default {
name: 'search-panel',
components: {
'search-form': SearchForm
},
props: {
//
model: {
type: Object,
default: () => {}
},
//
configs: {
type: Array,
default: () => []
},
//
isReset: {
type: Boolean,
default: true
},
//
isSearch: {
type: Boolean,
default: true
},
//
isCard: {
type: Boolean,
default: true
}
},
mounted() {
this.toggle()
},
methods: {
search() {
this.$emit('search')
},
reset() {
this.$emit('reset')
},
toggle() {
if (this.$refs.search) {
this.$nextTick(() => {
const elem = this.$refs.search.$el
const offsetHeight = elem.offsetHeight
this.$refs.wrap.style.height = `calc(100% - ${offsetHeight}px)`
})
}
}
}
}
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,115 @@
<template>
<div>
<slot name="prefix"></slot>
<el-button
v-if="showAdd"
v-hasPermi="[`${permission}:${permissionKey}:add`]"
type="primary"
:disabled="disAdd"
icon="Plus"
@click="add"
plain
>
{{ addName }}
</el-button>
<slot name="middle"></slot>
<el-button
v-if="showEdit"
v-hasPermi="[`${permission}:${permissionKey}:edit`]"
type="success"
:disabled="disEdit"
icon="Edit"
@click="edit"
plain
>
{{ editName }}
</el-button>
<slot name="center"></slot>
<el-button
v-if="showDel"
v-hasPermi="[`${permission}:${permissionKey}:remove`, `${permission}:${permissionKey}:del`]"
type="danger"
:disabled="disRemove"
icon="Delete"
@click="remove"
plain
>
{{ delName }}
</el-button>
<slot name="suffix"></slot>
</div>
</template>
<script setup>
// props
defineProps({
//
permission: {
type: String,
default: 'device'
},
// key
permissionKey: {
type: String,
default: ''
},
disAdd: {
type: Boolean,
default: false
},
disEdit: {
type: Boolean,
default: false
},
disRemove: {
type: Boolean,
default: false
},
showAdd: {
type: Boolean,
default: true
},
showEdit: {
type: Boolean,
default: true
},
showDel: {
type: Boolean,
default: true
},
addName: {
type: String,
default: '新增'
},
editName: {
type: String,
default: '修改'
},
delName: {
type: String,
default: '删除'
},
permissionList: {
type: Object,
default: () => ({})
}
})
// emits
const emit = defineEmits(['add', 'edit', 'remove'])
//
const add = () => {
emit('add')
}
const edit = () => {
emit('edit')
}
const remove = () => {
emit('remove')
}
</script>
<style lang="scss" scoped></style>

View File

@ -55,9 +55,10 @@ function addIframe() {
.app-main {
/* 50= navbar 50 */
min-height: calc(100vh - 50px);
height: calc(100vh - 50px);
width: 100%;
position: relative;
overflow: hidden;
overflow: auto;
}
.fixed-header + .app-main {
@ -68,6 +69,7 @@ function addIframe() {
.app-main {
/* 84 = navbar + tags-view = 50 + 34 */
min-height: calc(100vh - 84px);
height: calc(100vh - 84px);
}
.fixed-header + .app-main {

View File

@ -24,6 +24,11 @@ const authRole = (role: string): boolean => {
}
};
export const havePermi = (permission: string): boolean => {
// 你的权限校验逻辑
return authPermission(permission);
};
export default {
// 验证用户是否具备某权限
hasPermi(permission: string): boolean {

View File

@ -316,3 +316,21 @@ export const removeClass = (ele: HTMLElement, cls: string) => {
export const isExternal = (path: string) => {
return /^(https?:|http?:|mailto:|tel:)/.test(path);
};
/**
* Check if a variable is a string
* @param str
* @returns {boolean}
*/
export const isString = (str: any): str is string => {
return typeof str === 'string' || str instanceof String;
};
/**
* Check if a variable is a function
* @param func
* @returns {boolean}
*/
export const isFunction = (func: any): func is Function => {
return typeof func === 'function' || func instanceof Function;
};

View File

@ -106,3 +106,26 @@ export const isArray = (arg: string | string[]) => {
}
return Array.isArray(arg);
};
/**
*
*/
export function validatenull(val) {
if (typeof val == 'boolean') {
return false;
}
if (typeof val == 'number') {
return false;
}
if (val instanceof Array) {
if (val.length == 0) return true;
} else if (val instanceof Object) {
if (JSON.stringify(val) === '{}') return true;
} else {
if (val == 'null' || val == null || val == 'undefined' || val == undefined || val == ''){
return true;
}
return false;
}
return false;
}

310
src/views/test/index.vue Normal file
View File

@ -0,0 +1,310 @@
<template>
<div class="p-2" style="height: 100%;">
<transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
<el-card shadow="hover" style="height: 100%;">
<search-panel ref="searchRef" :configs="search.configs" :model="search.data" @search="onSearch" @reset="reset">
<FormTable
ref="tableRef"
row-key="id"
height="100%"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:table-data="tableData"
:columns="columns"
:operates="operates"
:total="total"
v-model:page="page"
v-model:limit="limit"
:options="{
border: true,
stripe: true,
highlightCurrentRow: true
}"
pagination-position="right"
@handleSelectionChange="handleSelectionChange"
@pagination="onPagination"
>
<template #topButton>
<toolbar-button
:permission="'permission'"
:permissionKey="'permissionKey'"
:disRemove="selectData.length == 0"
:disEdit="selectData.length != 1"
delName="批量删除"
@add="handleAdd"
@remove="handleRemoveAll"
/>
</template>
<!-- 使用语法定义插槽: [类型]-[key/prop] -->
<!-- header-name 定义姓名列的表头 -->
<template #header-name="{ column }">
<el-tag effect="dark">自定义 {{ column.label }}</el-tag>
</template>
<!-- default-name 定义姓名列的内容 -->
<template #default-name="{ row }">
<span style="font-weight: bold; color: #67c23a">{{ row.name }}</span>
</template>
</FormTable>
</search-panel>
</el-card>
</transition>
</div>
</template>
<script setup lang="tsx">
import { h, ref, reactive, computed } from 'vue'
import { ElTag, ElMessage } from 'element-plus'
import * as iconsVue from '@element-plus/icons-vue'
//
const tableRef = ref(null)
//
const tableData = ref([
{
id: 1,
date: '2026-05-03',
name: '11111',
gender: 1,
age: 35,
city: '北京',
price: 699,
status: 1
},
{
id: 2,
date: '2026-05-02',
name: '22222',
gender: 0,
age: 45,
city: '上海',
price: 999,
status: 0,
children: [
{
id: 3,
date: '2026-05-02',
name: '22222-1',
gender: 0,
age: 45,
city: '上海',
price: 999,
status: 0
},
{
id: 4,
date: '2026-05-02',
name: '22222-2',
gender: 0,
age: 45,
city: '上海',
price: 999,
status: 0
}
]
},
{
id: 5,
date: '2026-05-04',
name: '33333',
gender: 1,
age: 25,
city: '杭州',
price: 1398,
status: 1
}
])
//
const page = ref(1)
const limit = ref(10)
const total = ref(100)
//
const selectData = ref([])
//
const search = reactive({
data: {},
configs: [
/**
* 搜索内容如果超过一行则只显示一行多余搜索可折叠显示/隐藏
* 支持inputselecttree-selectdate-pickertime-selecttime-pickerautocomplete类型
* 输入框下拉选择框日期选择框时间选择框输入提示框
* 如有其他类型搜索可根据实际情况对组件进行完善
* 具体使用请参照SearchForm组件
*/
{ label: '输入框', prop: 'input' },
// ,options
{
label: '下拉选择框',
prop: 'select',
type: 'select',
dict: 'sys_user_sex',
change: ($event) => {
},
visibleChange: ($event) => {
},
},
{
label: '日期选择框',
prop: 'date',
type: 'date',
valueFormat: 'YYYY-MM-DD',
change: ($event) => {
},
},
{
label: '时间选择框',
prop: 'time',
type: 'daterange',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
defaultTime: [new Date('1 00:00:00'), new Date('1 23:59:59')],
change: ($event) => {
}
},
{
label: '输入提示框',
prop: 'autocomplete',
type: 'autocomplete',
fetchSuggestions: (queryString, cb) => {
// el-autocomplete
},
select: (item) => {
//
}
}
]
})
//
const columns = computed(() => [
// 1.
{ type: 'selection', width: 50 },
// 2.
{ type: 'index', label: '序号', width: 60, align: 'center' },
// 3.
{ prop: 'date', label: '日期', width: 120, fixed: 'left' },
// 4. (使: [type]-[prop])
{ prop: 'name', label: '姓名', width: 120, key: 'name' },
// 5.
{
prop: 'gender',
label: '性别',
width: 80,
render: ({ row }) => h(ElTag, { type: row.gender ? 'primary' : 'danger' }, () => (row.gender ? '男' : '女'))
},
// 6. (HTML)
{
prop: 'age',
label: '年龄',
width: 80,
render: ({ row }) => `<b style="color: #409eff">${row.age} 岁</b>`
},
// 7. (formatter)
{
prop: 'status',
label: '状态',
width: 100,
// dict: 'sys_user_sex' // 使
formatter: row => (row.status ? "<span style='color: green'>正常</span>" : "<span style='color: red'>禁用</span>")
},
{ prop: 'city', label: '城市' },
// 8.
{
prop: 'price',
label: '价格',
formatter: row => `¥ ${row.price.toFixed(2)}`
},
// 9.
{
label: '更多信息',
children: [
{ prop: 'date', label: '购买日期', width: 120 },
{ prop: 'price', label: '购买价格', formatter: row => `¥ ${row.price.toFixed(2)}` }
]
},
// 10. JSX
{
prop: 'city',
label: '城市(JSX)',
width: 120,
render: ({ row }) => {
return <ElTag type="info" onClick={() => console.log('点击城市:', row.city)}>{row.city}</ElTag>
}
}
])
//
const operates = {
width: 260,
fixed: 'right',
list: [
{
label: '编辑',
type: 'primary',
icon: iconsVue.Edit,
// permission: 'xxx:xxx:xxx', //
method: (row, index) => {
ElMessage.info(`编辑第 ${index + 1} 行: ${row.name}`)
}
},
{
label: '查看',
type: 'success',
icon: iconsVue.View,
show: row => row.status === 1, //
method: row => {
console.log('查看详情:', row)
}
},
{
label: '删除',
type: 'danger',
icon: iconsVue.Delete,
method: row => {
ElMessage.error(`删除: ${row.name}`)
}
},
{
label: '更多选项1',
method: row => console.log('更多1', row)
},
{
label: '更多选项2',
method: row => console.log('更多2', row)
}
]
}
//
const handleSelectionChange = val => {
console.log('选中项变化:', val)
selectData.value = val
}
const onSearch = () => {
//
selectData.value = []
console.log('搜索条件:', search.data)
ElMessage.success('搜索触发')
}
const onPagination = ({ page, limit }) => {
//
selectData.value = []
ElMessage.success(`分页变化: 第${page}页, 每页${limit}`)
}
const reset = () => {
ElMessage.info('重置搜索表单')
}
</script>
<style scoped>
:deep(.el-table) {
margin-top: 10px;
}
</style>

View File

@ -1,4 +1,5 @@
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import vueDevTools from 'vite-plugin-vue-devtools';
import createUnoCss from './unocss';
@ -11,8 +12,14 @@ import createSetupExtend from './setup-extend';
import path from 'path';
export default (viteEnv: any, isBuild = false): [] => {
const vitePlugins: any = [];
vitePlugins.push(vue());
const vitePlugins: any = [
vue({
script: {
defineModel: true
}
}),
vueJsx()
];
vitePlugins.push(vueDevTools());
vitePlugins.push(createUnoCss());
vitePlugins.push(createAutoImport(path));