添加表头编辑

This commit is contained in:
2026-06-01 09:37:30 +08:00
parent da0b61b8e2
commit 0ad61c0e69
6 changed files with 453 additions and 66 deletions

View File

@ -115,7 +115,7 @@ const handleBlur = () => {
<style scoped lang="scss">
.search-container {
margin-bottom: 20px;
margin-bottom: 4px;
.el-form {
display: flex;

View File

@ -1,9 +1,57 @@
<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="tableData"
:data="enableClientFilter ? filteredTableData : tableData"
:row-key="rowKey"
:tree-props="treeProps"
:border="border"
@ -19,7 +67,7 @@
<el-table-column v-if="showSelection" type="selection" width="55" align="center" />
<!-- 动态表头列 -->
<template v-for="(column, index) in columns" :key="index">
<template v-for="(column, index) in finalColumns" :key="index">
<el-table-column
v-if="column.type === 'index'"
type="index"
@ -28,17 +76,59 @@
:align="column.align || 'center'"
/>
<el-table-column
v-else-if="!column.slotName"
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>{{ formatDecimalTruncate(row[column.prop]) }}</span>
</template>
<template v-else-if="column.options && column.prop" #default="{ row }">
<template v-else-if="column.options" #default="{ row }">
<span>{{ getOptionLabel(column.options, row[column.prop]) }}</span>
</template>
</el-table-column>
@ -60,9 +150,16 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, computed, onMounted, reactive } from 'vue'
import { Setting, Rank, Filter } from '@element-plus/icons-vue'
import draggable from 'vuedraggable'
import type { TableInstance } 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 => {
@ -78,10 +175,18 @@ interface TableColumn {
label: string
width?: string | number
align?: 'left' | 'center' | 'right'
slotName?: string //
sortable?: boolean //
isNumber?: boolean //
options?: { label: string; value: string | number }[] //
slotName?: string
sortable?: boolean
isNumber?: boolean
options?: { label: string; value: string | number }[]
visible?: boolean
fixed?: boolean
sortOrder?: number
//
filters?: { text: string; value: any }[]
filterMethod?: (value: any, row: any, column: any) => boolean
filterable?: boolean
filterPlaceholder?: string
}
//
@ -92,10 +197,13 @@ interface Props {
children: string
}
columns?: TableColumn[]
border?: boolean //
highlightCurrentRow?: boolean //
loading?: boolean //
showSelection?: boolean //
border?: boolean
highlightCurrentRow?: boolean
loading?: boolean
showSelection?: boolean
showColumnSetting?: boolean
columnSettingStorageKey?: string
enableClientFilter?: boolean //
}
const props = withDefaults(defineProps<Props>(), {
@ -103,16 +211,170 @@ const props = withDefaults(defineProps<Props>(), {
highlightCurrentRow: true,
loading: false,
showSelection: false,
showColumnSetting: true, //
columnSettingStorageKey: '',
enableClientFilter: true, //
})
//
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-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: 'selection-change', rows: any[]): void
(e: 'current-change', row: any): void
(e: 'filter-change', filters: { [key: string]: any }): void
}>()
// Store
const route = useRoute()
const userStore = useUserStore()
// key
const getCacheKey = () => {
const baseKey = props.columnSettingStorageKey || String(route.name || route.path)
const userId = String(userStore.userInfo?.id || 'guest')
return `table-column-setting-${baseKey}-${userId}`
}
//
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 || []
})
//
const initColumns = () => {
if (!props.columns || props.columns.length === 0) {
localColumns.value = []
return
}
const cacheKey = getCacheKey()
const cached = localStorage.getItem(cacheKey)
if (cached) {
try {
const cachedConfig = JSON.parse(cached)
// sortOrder
cachedConfig.sort((a: any, b: any) => (a.sortOrder || 0) - (b.sortOrder || 0))
//
localColumns.value = cachedConfig.map((cached: any) => {
const originalCol = props.columns?.find((c) => c.prop === cached.prop)
if (originalCol) {
return { ...originalCol, ...cached }
}
return null
}).filter(Boolean)
} catch {
// 使
localColumns.value = props.columns.map((col) => ({
...col,
visible: col.visible !== false,
fixed: col.fixed || false,
}))
}
} else {
// 使
localColumns.value = props.columns.map((col) => ({
...col,
visible: col.visible !== false,
fixed: col.fixed || false,
}))
}
}
// localStorage
const saveToStorage = () => {
const config = localColumns.value.map((col, index) => ({
prop: col.prop,
visible: col.visible,
label: col.label,
width: col.width,
slotName: col.slotName,
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
watch(
() => props.columns,
(newColumns) => {
if (newColumns && newColumns.length > 0) {
//
if (localColumns.value.length === 0) {
initColumns()
}
}
},
{ immediate: true }
)
//
onMounted(() => {
// props.columns
setTimeout(() => {
if (props.columns && props.columns.length > 0 && localColumns.value.length === 0) {
initColumns()
}
}, 100)
})
//
const handleSelectionChange = (rows: any[]) => {
emit('selection-change', rows)
@ -120,69 +382,72 @@ const handleSelectionChange = (rows: any[]) => {
//
const handleCurrentChange = (row: any) => {
// null Vue
if (row === null || row === undefined) {
return
}
if (row === null || row === undefined) return
emit('current-change', row)
}
const tableRef = ref<TableInstance>()
//
//
const selectedRow = ref<any>(null)
/**
* 处理行点击事件支持二次点击取消选中
*/
//
const handleRowClick = (row: any) => {
// null
if (!row) return
//
if (selectedRow.value && selectedRow.value === row) {
//
selectedRow.value = null
tableRef.value?.setCurrentRow(null) // 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) => {
console.log('row', row)
emit('row-contextmenu', row, column, event)
}
// tableData
// tableData
watch(
() => props.tableData,
() => {
//
selectedRow.value = null
tableRef.value?.setCurrentRow(null)
},
{ deep: true }, //
{ deep: true },
)
// loading
watch(
() => props.loading,
(newVal) => {
// loading
console.log('Table loading status changed:', newVal)
},
{ flush: 'sync' }, // 使 sync
)
//
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 })
}
//
defineExpose({
clearCurrentRow: () => {
selectedRow.value = null
@ -203,9 +468,129 @@ defineExpose({
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;
}
}
.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;
}
}
}
</style>

View File

@ -11,11 +11,11 @@
</header>
<div class="table-container">
<div class="table-container-left">
<Table ref="singleTableRef" :table-data="tableData" :columns="tableColumns" row-key="id" :loading="loading"
<Table ref="singleTableRef" :show-column-setting="false" :table-data="tableData" :columns="tableColumns" row-key="id" :loading="loading"
:highlight-current-row="true" @current-change="handleCurrentChange" />
</div>
<div class="table-container-right">
<Table row-key="id" :table-data="tableDataDetail" :columns="tableColumnsDetail" :loading="detailLoading" />
<Table :show-column-setting="false" row-key="id" :table-data="tableDataDetail" :columns="tableColumnsDetail" :loading="detailLoading" />
</div>
</div>
<el-dialog v-model="dialogVisible" :title="dialogTitleType === 1 ? '添加手续费模板' : '编辑手续费模板'" width="500">

View File

@ -8,26 +8,27 @@
v-auth="'riskControl_editRiskControlSetting'">停用</el-button>
<el-button type="primary" @click="handleChangeStatus(1)"
v-auth="'riskControl_editRiskControlSetting'">启用</el-button>
<el-button type="primary" :loading="syncLoading" @click="handleSyncRiskControl()" v-auth="'riskControl_editRiskControlSetting'">
<el-button type="primary" :loading="syncLoading" @click="handleSyncRiskControl()"
v-auth="'riskControl_editRiskControlSetting'">
同步风控模板
</el-button>
</div>
<!-- 表格 -->
<div class="risk-table-content">
<RiskTableSwitch :columns="tableColumnsSwitch" :table-data="tableTreeSwitch" class="table-switch"
@row-click="handleRowClick" row-key="id" highlight-current-row :current-row-key="selectedRowId"
:loading="loading">
<RiskTableSwitch :columns="tableColumnsSwitch" :show-column-setting="false" :table-data="tableTreeSwitch"
class="table-switch" @row-click="handleRowClick" row-key="id" highlight-current-row
:current-row-key="selectedRowId" :loading="loading">
<template #status="{ row }">
<span>{{ row.status === 1 ? '启用' : '停用' }}</span>
</template>
</RiskTableSwitch>
<RiskTableContent :columns="tableColumnsContent" :table-data="currentContent" class="table-content" row-key="id"
:loading="detailLoading">
<RiskTableContent :show-column-setting="false" :columns="tableColumnsContent" :table-data="currentContent"
class="table-content" row-key="id" :loading="detailLoading">
</RiskTableContent>
</div>
<!-- dialog -->
<RiskDialog :visible="dialogVisible" :title="dialogTitle" :type="dialogType" :button-loading="submitLoading" @confirm="handleSubmit"
@cancel="handleCancel" @close="handleClose">
<RiskDialog :visible="dialogVisible" :title="dialogTitle" :type="dialogType" :button-loading="submitLoading"
@confirm="handleSubmit" @cancel="handleCancel" @close="handleClose">
<RiskForm :form-data="dialogForm" :form-rules="riskRules" :form-items="RiskFormItems"
:options-map="riskFormOptionsMap" :row-gutter="rowGutter" :col-span="colSpan" ref="riskFormRef" />
</RiskDialog>
@ -194,7 +195,7 @@ const syncLoading = ref(false)
const handleSyncRiskControl = async () => {
syncLoading.value = true
try {
await syncRiskControlApi()
await syncRiskControlApi()
} finally {
syncLoading.value = false
}
@ -251,7 +252,7 @@ const handleModifyRisk = () => {
} as any
}
const handleChangeStatus = async (status:any) => {
const handleChangeStatus = async (status: any) => {
dialogTitle.value = '停用'
dialogType.value = 'status'
await editRiskApi({ ...selectedRow.value, status: status })
@ -272,7 +273,7 @@ const handleRowClick = (row: any) => {
}
selectedRowId.value = row.id
//
const targetItem:any = tableTreeContent.value.find((item: any) => item.id === row.id)
const targetItem: any = tableTreeContent.value.find((item: any) => item.id === row.id)
const fields = [
{ text: '单笔开仓最大数量' + targetItem.maxOpenPosition },

View File

@ -28,6 +28,7 @@
:table-data="marketTree"
:columns="marketTableColumnsOptions"
row-key="id"
:show-column-setting="true"
:loading="loading"
@row-click="handleRowClick"
@row-contextmenu="handleRowContextMenu"
@ -35,7 +36,7 @@
<template #floatProfitLoss="{ row }">
<span>{{ getMarketFloatProfit(row.id) }}</span>
</template>
<template #closeProfitLoss="{ row }">
<template #s="{ row }">
<span>{{ getMarketCloseProfit(row.id) }}</span>
</template>
<template #pointSpread="{ row }">

View File

@ -12,15 +12,15 @@ export const marketSearch = [
export const marketTableColumns = [
// id
{ label: 'ID', prop: 'id', align: 'center' as const, width: '100px' },
{ label: 'ID', prop: 'id', align: 'center' as const, width: '100px',filterable:false },
//代理关系
{ label: '代理关系', prop: 'agentRelation', align: 'center' as const, width: '200px' },
{ label: '代理关系', prop: 'agentRelation', align: 'center' as const, width: '290' },
//账户名
{ label: '账户名', prop: 'accountName', align: 'center' as const, width: '120px' },
//级别
{ label: '级别', prop: 'level', align: 'center' as const, width: '120px' },
{ label: '邮箱', prop: 'email', align: 'center' as const, width: '160px' },
{ label: '手机号', prop: 'mobile', align: 'center' as const, width: '140px' },
{ label: '手机号', prop: 'mobile', align: 'center' as const, width: '180' },
{ label: '风险率', prop: 'ratio', align: 'center' as const, width: '120px' },
//基本账户
{ label: '基本账户', prop: 'basicAccount', align: 'center' as const, isNumber: true , width: '200px' },