foreign-admin-ui/src/components/common/Table.vue

676 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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)">&nbsp;</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>