676 lines
18 KiB
Vue
676 lines
18 KiB
Vue
<template>
|
||
<div class="table-container">
|
||
<div class="table-toolbar" v-if="showColumnSetting">
|
||
<!-- 气泡确认框 -->
|
||
<el-popover
|
||
v-model:visible="columnSettingVisible"
|
||
placement="bottom-end"
|
||
:width="320"
|
||
trigger="click"
|
||
>
|
||
<template #reference>
|
||
<el-button :icon="Setting" circle />
|
||
</template>
|
||
|
||
<div class="column-setting-content">
|
||
<!-- 操作栏 -->
|
||
<div class="setting-header">
|
||
<span class="setting-title">列设置</span>
|
||
<el-button type="primary" size="small" text @click="handleReset">
|
||
重置
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 拖拽列表 -->
|
||
<div class="column-list">
|
||
<draggable
|
||
:list="localColumns"
|
||
item-key="prop"
|
||
handle=".drag-handle"
|
||
:animation="200"
|
||
ghost-class="ghost"
|
||
@end="handleDragEnd"
|
||
>
|
||
<template #item="{ element }">
|
||
<div class="column-item">
|
||
<el-icon class="drag-handle"><Rank /></el-icon>
|
||
<el-checkbox
|
||
v-model="element.visible"
|
||
:disabled="element.fixed"
|
||
@change="handleVisibleChange"
|
||
>
|
||
{{ element.label }}
|
||
</el-checkbox>
|
||
</div>
|
||
</template>
|
||
</draggable>
|
||
</div>
|
||
</div>
|
||
</el-popover>
|
||
</div>
|
||
<el-table
|
||
ref="tableRef"
|
||
style="width: 100%; flex: 1"
|
||
:data="enableClientFilter ? filteredTableData : tableData"
|
||
:row-key="rowKey"
|
||
:tree-props="treeProps"
|
||
:border="border"
|
||
:highlight-current-row="highlightCurrentRow"
|
||
v-loading="loading"
|
||
v-bind="$attrs"
|
||
@row-click="handleRowClick"
|
||
@row-contextmenu="handleRowContextmenu"
|
||
@selection-change="handleSelectionChange"
|
||
@current-change="handleCurrentChange"
|
||
>
|
||
<!-- 勾选列/单选列 -->
|
||
<el-table-column v-if="showSelection && !singleSelect" type="selection" width="55" align="center" />
|
||
<el-table-column v-if="showSelection && singleSelect" width="55" align="center" label="">
|
||
<template #default="{ row }">
|
||
<el-radio v-model="selectedRadioRow" :label="getRowKeyValue(row)" @change="handleRadioChange(row)"> </el-radio>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<!-- 动态表头列 -->
|
||
<template v-for="(column, index) in finalColumns" :key="index">
|
||
<el-table-column
|
||
v-if="column.type === 'index'"
|
||
type="index"
|
||
:label="column.label"
|
||
:width="column.width || 60"
|
||
:align="column.align || 'center'"
|
||
/>
|
||
<el-table-column
|
||
v-else-if="!column.slotName && column.prop"
|
||
:prop="column.prop"
|
||
:label="column.label"
|
||
:width="column.width"
|
||
:align="column.align || 'left'"
|
||
:sortable="column.sortable"
|
||
:column-key="column.prop"
|
||
>
|
||
<template #header>
|
||
<div class="table-header-cell">
|
||
<div class="table-header-label">
|
||
<span>{{ column.label }}</span>
|
||
<template v-if="column.filters && column.filters.length > 0">
|
||
<el-dropdown
|
||
trigger="click"
|
||
@command="(command: any) => handleFilterCommand(command, column)"
|
||
>
|
||
<span class="filter-trigger">
|
||
<el-icon :class="{ 'is-active': filterStates[column.prop!]?.length }">
|
||
<Filter />
|
||
</el-icon>
|
||
</span>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item
|
||
v-for="item in column.filters"
|
||
:key="item.value"
|
||
:command="item.value"
|
||
:divided="filterStates[column.prop!]?.includes(item.value)"
|
||
>
|
||
{{ item.text }}
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</template>
|
||
</div>
|
||
<el-input
|
||
v-if="column.filterable"
|
||
v-model="filterStates[column.prop!]"
|
||
size="small"
|
||
:placeholder="column.filterPlaceholder || '筛选'"
|
||
clearable
|
||
@change="handleFilterInput(column)"
|
||
@clear="handleFilterClear(column)"
|
||
class="header-filter-input"
|
||
/>
|
||
</div>
|
||
</template>
|
||
<template v-if="column.isNumber" #default="{ row }">
|
||
<span class="cell-content">{{ formatDecimalTruncate(row[column.prop]) }}</span>
|
||
<el-icon v-if="column.copy" class="copy-icon" @click.stop="handleCopy(row[column.prop])"><DocumentCopy /></el-icon>
|
||
</template>
|
||
<template v-else-if="column.options" #default="{ row }">
|
||
<span class="cell-content">{{ getOptionLabel(column.options, row[column.prop]) }}</span>
|
||
<el-icon v-if="column.copy" class="copy-icon" @click.stop="handleCopy(row[column.prop])"><DocumentCopy /></el-icon>
|
||
</template>
|
||
<template v-else #default="{ row }">
|
||
<span class="cell-content">{{ row[column.prop] }}</span>
|
||
<el-icon v-if="column.copy" class="copy-icon" @click.stop="handleCopy(row[column.prop])"><DocumentCopy /></el-icon>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<!-- 具名插槽列 -->
|
||
<el-table-column
|
||
v-else
|
||
:label="column.label"
|
||
:width="column.width"
|
||
:align="column.align || 'left'"
|
||
:fixed="column.fixed"
|
||
>
|
||
<template #default="{ row }">
|
||
<slot :name="column.slotName" :row="row" />
|
||
</template>
|
||
</el-table-column>
|
||
</template>
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, computed, onMounted, reactive } from 'vue'
|
||
import { Setting, Rank, Filter, DocumentCopy } from '@element-plus/icons-vue'
|
||
import draggable from 'vuedraggable'
|
||
import type { TableInstance } from 'element-plus'
|
||
import { ElMessage } from 'element-plus'
|
||
import { formatDecimalTruncate } from '@/utils/formatDecimal'
|
||
import { useRoute } from 'vue-router'
|
||
import { useUserStore } from '@/stores/modules/user'
|
||
|
||
// 筛选状态管理
|
||
const filterStates = reactive<Record<string, any>>({})
|
||
|
||
// 根据选项配置获取标签
|
||
const getOptionLabel = (options: { label: string; value: string | number }[], value: any): string => {
|
||
if (value === null || value === undefined) return ''
|
||
const option = options.find((opt) => opt.value === value)
|
||
return option?.label || String(value)
|
||
}
|
||
|
||
// 定义表格列配置接口
|
||
interface TableColumn {
|
||
prop?: string
|
||
type?: string
|
||
label: string
|
||
width?: string | number
|
||
align?: 'left' | 'center' | 'right'
|
||
slotName?: string
|
||
sortable?: boolean
|
||
isNumber?: boolean
|
||
options?: { label: string; value: string | number }[]
|
||
visible?: boolean
|
||
fixed?: boolean | 'left' | 'right'
|
||
sortOrder?: number
|
||
// 表头筛选器相关属性
|
||
filters?: { text: string; value: any }[]
|
||
filterMethod?: (value: any, row: any, column: any) => boolean
|
||
filterable?: boolean
|
||
filterPlaceholder?: string
|
||
// 复制功能
|
||
copy?: boolean
|
||
}
|
||
|
||
// 定义表格组件属性接口
|
||
interface Props {
|
||
tableData: any[]
|
||
rowKey?: string
|
||
treeProps?: {
|
||
children: string
|
||
}
|
||
columns?: TableColumn[]
|
||
border?: boolean
|
||
highlightCurrentRow?: boolean
|
||
loading?: boolean
|
||
showSelection?: boolean
|
||
singleSelect?: boolean // 单选模式
|
||
showColumnSetting?: boolean
|
||
columnSettingStorageKey?: string
|
||
enableClientFilter?: boolean // 是否启用客户端筛选
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
border: true,
|
||
highlightCurrentRow: true,
|
||
loading: false,
|
||
showSelection: false,
|
||
singleSelect: false,
|
||
showColumnSetting: true, // 默认开启列设置
|
||
columnSettingStorageKey: '',
|
||
enableClientFilter: true, // 默认开启客户端筛选
|
||
})
|
||
|
||
// 单选模式相关
|
||
const selectedRadioRow = ref<any>(null)
|
||
|
||
// 获取行标识的safe版本
|
||
const getRowKeyValue = (row: any) => {
|
||
return props.rowKey ? row[props.rowKey] : row.id
|
||
}
|
||
|
||
const handleRadioChange = (row: any) => {
|
||
selectedRadioRow.value = getRowKeyValue(row)
|
||
emit('selection-change', [row])
|
||
}
|
||
|
||
// 获取过滤后的数据
|
||
const filteredTableData = computed(() => {
|
||
if (!props.enableClientFilter || Object.keys(filterStates).length === 0) {
|
||
return props.tableData
|
||
}
|
||
|
||
return props.tableData.filter((row) => {
|
||
for (const prop in filterStates) {
|
||
const filterValue = filterStates[prop]
|
||
if (Array.isArray(filterValue) && filterValue.length > 0) {
|
||
if (!filterValue.includes(row[prop])) {
|
||
return false
|
||
}
|
||
} else if (filterValue && typeof filterValue === 'string') {
|
||
const cellValue = String(row[prop] || '').toLowerCase()
|
||
if (!cellValue.includes(filterValue.toLowerCase())) {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
return true
|
||
})
|
||
})
|
||
|
||
// 定义组件抛出的事件
|
||
const emit = defineEmits<{
|
||
(e: 'row-click', row: any): void
|
||
(e: 'row-contextmenu', row: any, column: any, event: MouseEvent): void
|
||
(e: 'selection-change', rows: any[]): void
|
||
(e: 'current-change', row: any): void
|
||
(e: 'filter-change', filters: { [key: string]: any }): void
|
||
}>()
|
||
|
||
const route = useRoute()
|
||
const userStore = useUserStore()
|
||
|
||
// 缓存 key 版本号(当列配置结构升级时可手动改这个值,使旧缓存失效)
|
||
const CACHE_VERSION = 'v1'
|
||
|
||
// 生成缓存 key
|
||
const getCacheKey = () => {
|
||
const baseKey = props.columnSettingStorageKey
|
||
|| String((route.name as string) || route.path || 'default')
|
||
const userId = String((userStore.userInfo as any)?.id || 'guest')
|
||
return `table-column-setting-${CACHE_VERSION}-${baseKey}-${userId}`
|
||
}
|
||
|
||
// 获取列的唯一标识(用于匹配缓存中的列)
|
||
const getColumnKey = (col: TableColumn): string => {
|
||
if (col.prop) return `prop:${col.prop}`
|
||
if (col.slotName) return `slot:${col.slotName}`
|
||
if (col.type) return `type:${col.type}`
|
||
return `label:${col.label}`
|
||
}
|
||
|
||
// 气泡确认框显示状态
|
||
const columnSettingVisible = ref(false)
|
||
|
||
// 本地列配置副本
|
||
const localColumns = ref<TableColumn[]>([])
|
||
|
||
// 计算最终使用的列配置
|
||
const finalColumns = computed(() => {
|
||
// 当 showColumnSetting 开启时,始终使用 localColumns
|
||
if (props.showColumnSetting && localColumns.value.length > 0) {
|
||
return localColumns.value.filter((col) => col.visible !== false)
|
||
}
|
||
return props.columns || []
|
||
})
|
||
|
||
// 初始化列配置(从缓存或默认值)
|
||
// 逻辑:原始 props.columns 为准,缓存只控制“可见性”和“顺序”。
|
||
// - 原始列中与缓存匹配的项 → 应用缓存的 visible / sortOrder
|
||
// - 原始列中新增的项 → 追加到末尾,visible 默认 true
|
||
// - 缓存中存在但原始列已删除的项 → 丢弃
|
||
const initColumns = () => {
|
||
if (!props.columns || props.columns.length === 0) {
|
||
localColumns.value = []
|
||
return
|
||
}
|
||
|
||
// 读取缓存(可能为空)
|
||
const cacheKey = getCacheKey()
|
||
let cachedConfig: any[] = []
|
||
const cached = localStorage.getItem(cacheKey)
|
||
if (cached) {
|
||
try {
|
||
const parsed = JSON.parse(cached)
|
||
if (Array.isArray(parsed)) cachedConfig = parsed
|
||
} catch {
|
||
cachedConfig = []
|
||
}
|
||
}
|
||
|
||
// 构建缓存映射:key -> { visible, sortOrder }
|
||
const cachedMap = new Map<string, { visible: boolean; sortOrder: number }>()
|
||
cachedConfig.forEach((c, idx) => {
|
||
cachedMap.set(c.key || getColumnKey(c), {
|
||
visible: c.visible !== false,
|
||
sortOrder: typeof c.sortOrder === 'number' ? c.sortOrder : idx,
|
||
})
|
||
})
|
||
|
||
// 以原始列为主,命中缓存则用缓存的 visible / sortOrder,否则追加到末尾
|
||
const maxOrder = Math.max(-1, ...Array.from(cachedMap.values()).map((v) => v.sortOrder))
|
||
let nextOrder = maxOrder + 1
|
||
|
||
localColumns.value = props.columns.map((col) => {
|
||
const hit = cachedMap.get(getColumnKey(col))
|
||
if (hit) {
|
||
return { ...col, visible: hit.visible, sortOrder: hit.sortOrder }
|
||
}
|
||
return { ...col, visible: col.visible !== false, sortOrder: nextOrder++ }
|
||
})
|
||
|
||
// 按 sortOrder 排序
|
||
localColumns.value.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||
}
|
||
|
||
// 保存配置到 localStorage(仅缓存 key / visible / sortOrder)
|
||
const saveToStorage = () => {
|
||
const config = localColumns.value.map((col, index) => ({
|
||
key: getColumnKey(col),
|
||
visible: col.visible !== false,
|
||
sortOrder: index,
|
||
}))
|
||
localStorage.setItem(getCacheKey(), JSON.stringify(config))
|
||
}
|
||
|
||
// 处理可见性变化
|
||
const handleVisibleChange = () => {
|
||
saveToStorage()
|
||
}
|
||
|
||
// 处理拖拽结束
|
||
const handleDragEnd = () => {
|
||
saveToStorage()
|
||
}
|
||
|
||
// 重置为默认配置
|
||
const handleReset = () => {
|
||
localColumns.value = (props.columns || []).map((col) => ({
|
||
...col,
|
||
visible: col.visible !== false,
|
||
fixed: col.fixed || false,
|
||
}))
|
||
localStorage.removeItem(getCacheKey())
|
||
columnSettingVisible.value = false
|
||
}
|
||
|
||
// 监听 columns 变化
|
||
// 注意:当原始 columns 变化时(如父组件新增/删除了列配置),
|
||
// 需要重新合并缓存,否则新字段不会展示。
|
||
let lastColumnsKey = ''
|
||
watch(
|
||
() => props.columns,
|
||
(newColumns) => {
|
||
if (!newColumns || newColumns.length === 0) return
|
||
// 通过长度+关键字段生成 key,避免不必要的重新初始化
|
||
const newKey = newColumns.map((c) => getColumnKey(c)).join('|')
|
||
if (newKey === lastColumnsKey && localColumns.value.length > 0) return
|
||
lastColumnsKey = newKey
|
||
initColumns()
|
||
},
|
||
{ immediate: true, deep: true }
|
||
)
|
||
|
||
// 组件挂载时,如果列还没初始化,再次尝试
|
||
onMounted(() => {
|
||
if (props.columns && props.columns.length > 0 && localColumns.value.length === 0) {
|
||
initColumns()
|
||
}
|
||
})
|
||
|
||
const tableRef = ref<TableInstance>()
|
||
|
||
// 维护当前选中行状态
|
||
const selectedRow = ref<any>(null)
|
||
|
||
// 处理勾选变化
|
||
const handleSelectionChange = (rows: any[]) => {
|
||
emit('selection-change', rows)
|
||
}
|
||
|
||
// 处理当前行变化
|
||
const handleCurrentChange = (row: any) => {
|
||
if (row === null || row === undefined) return
|
||
emit('current-change', row)
|
||
}
|
||
|
||
// 处理行点击事件
|
||
const handleRowClick = (row: any) => {
|
||
if (!row) return
|
||
if (selectedRow.value && selectedRow.value === row) {
|
||
selectedRow.value = null
|
||
tableRef.value?.setCurrentRow(null)
|
||
} else {
|
||
selectedRow.value = row
|
||
tableRef.value?.setCurrentRow(row)
|
||
}
|
||
emit('row-click', selectedRow.value)
|
||
}
|
||
|
||
// 处理行右键菜单事件
|
||
const handleRowContextmenu = (row: any, column: any, event: MouseEvent) => {
|
||
emit('row-contextmenu', row, column, event)
|
||
}
|
||
|
||
// 监听 tableData 变化(清除单选/行选中状态)
|
||
watch(
|
||
() => props.tableData,
|
||
() => {
|
||
if (props.singleSelect) {
|
||
selectedRadioRow.value = null
|
||
}
|
||
selectedRow.value = null
|
||
tableRef.value?.setCurrentRow(null)
|
||
},
|
||
{ deep: true }
|
||
)
|
||
|
||
// 处理下拉筛选命令
|
||
const handleFilterCommand = (command: any, column: TableColumn) => {
|
||
const prop = column.prop!
|
||
if (!filterStates[prop]) {
|
||
filterStates[prop] = []
|
||
}
|
||
|
||
const index = filterStates[prop].indexOf(command)
|
||
if (index > -1) {
|
||
filterStates[prop].splice(index, 1)
|
||
} else {
|
||
filterStates[prop].push(command)
|
||
}
|
||
|
||
emit('filter-change', { ...filterStates })
|
||
}
|
||
|
||
// 处理输入筛选
|
||
const handleFilterInput = (column: TableColumn) => {
|
||
emit('filter-change', { ...filterStates })
|
||
}
|
||
|
||
// 处理清除筛选
|
||
const handleFilterClear = (column: TableColumn) => {
|
||
filterStates[column.prop!] = ''
|
||
emit('filter-change', { ...filterStates })
|
||
}
|
||
|
||
// 复制文本
|
||
const handleCopy = async (text: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
ElMessage.success('复制成功')
|
||
} catch (error) {
|
||
console.error('复制失败', error)
|
||
}
|
||
}
|
||
|
||
// 暴露方法
|
||
defineExpose({
|
||
clearCurrentRow: () => {
|
||
selectedRow.value = null
|
||
tableRef.value?.setCurrentRow(null)
|
||
},
|
||
setCurrentRow: (row: any) => {
|
||
tableRef.value?.setCurrentRow(row)
|
||
},
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.table-container {
|
||
width: 100%;
|
||
height: 0;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
|
||
.table-toolbar {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
padding: 8px 0;
|
||
|
||
:deep(.el-button) {
|
||
font-size: 18px;
|
||
}
|
||
}
|
||
|
||
:deep(.el-table) {
|
||
flex: 1;
|
||
overflow: auto;
|
||
}
|
||
}
|
||
|
||
::v-deep(.cell) {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
.column-setting-content {
|
||
.setting-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
|
||
.setting-title {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #303133;
|
||
}
|
||
}
|
||
|
||
.column-list {
|
||
max-height: 360px;
|
||
overflow-y: auto;
|
||
|
||
.column-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8px;
|
||
margin-bottom: 4px;
|
||
background: #f5f7fa;
|
||
border-radius: 4px;
|
||
transition: background-color 0.2s;
|
||
|
||
&:hover {
|
||
background: #ebeef5;
|
||
}
|
||
|
||
.drag-handle {
|
||
margin-right: 10px;
|
||
cursor: move;
|
||
color: #909399;
|
||
font-size: 14px;
|
||
|
||
&:hover {
|
||
color: #409eff;
|
||
}
|
||
}
|
||
|
||
:deep(.el-checkbox) {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
.ghost {
|
||
opacity: 0.5;
|
||
background: #c6e2ff;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 表头单元格样式
|
||
.table-header-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
width: 100%;
|
||
|
||
.table-header-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.filter-trigger {
|
||
display: flex;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
padding: 2px;
|
||
border-radius: 4px;
|
||
transition: all 0.2s;
|
||
|
||
&:hover {
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
.el-icon {
|
||
font-size: 14px;
|
||
color: #909399;
|
||
|
||
&.is-active {
|
||
color: #409eff;
|
||
}
|
||
}
|
||
}
|
||
|
||
.header-filter-input {
|
||
width: 100%;
|
||
// max-width: 140px;
|
||
|
||
:deep(.el-input__wrapper) {
|
||
padding: 0 8px;
|
||
font-size: 12px;
|
||
height: 26px;
|
||
}
|
||
|
||
:deep(.el-input__inner) {
|
||
height: 24px;
|
||
line-height: 24px;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 复制图标样式
|
||
.copy-icon {
|
||
margin-left: 4px;
|
||
cursor: pointer;
|
||
color: #909399;
|
||
font-size: 14px;
|
||
|
||
&:hover {
|
||
color: #409eff;
|
||
}
|
||
}
|
||
|
||
.cell-content {
|
||
// 可选:让文字和图标在同一行
|
||
}
|
||
</style>
|