foggy-data-viewer 1.0.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +273 -0
- package/dist/favicon.svg +4 -0
- package/dist/index.js +1531 -0
- package/dist/index.umd +1 -0
- package/dist/style.css +1 -0
- package/package.json +51 -0
- package/src/App.vue +469 -0
- package/src/api/viewer.ts +163 -0
- package/src/components/DataTable.test.ts +533 -0
- package/src/components/DataTable.vue +810 -0
- package/src/components/DataTableWithSearch.test.ts +628 -0
- package/src/components/DataTableWithSearch.vue +277 -0
- package/src/components/DataViewer.vue +310 -0
- package/src/components/SearchToolbar.test.ts +521 -0
- package/src/components/SearchToolbar.vue +406 -0
- package/src/components/composables/index.ts +2 -0
- package/src/components/composables/useTableSelection.test.ts +248 -0
- package/src/components/composables/useTableSelection.ts +44 -0
- package/src/components/composables/useTableSummary.test.ts +341 -0
- package/src/components/composables/useTableSummary.ts +129 -0
- package/src/components/filters/BoolFilter.vue +103 -0
- package/src/components/filters/DateRangeFilter.vue +194 -0
- package/src/components/filters/NumberRangeFilter.vue +160 -0
- package/src/components/filters/SelectFilter.vue +464 -0
- package/src/components/filters/TextFilter.vue +230 -0
- package/src/components/filters/index.ts +5 -0
- package/src/examples/EnhancedTableExample.vue +136 -0
- package/src/index.ts +32 -0
- package/src/main.ts +14 -0
- package/src/types/index.ts +159 -0
- package/src/utils/README.md +140 -0
- package/src/utils/schemaHelper.test.ts +215 -0
- package/src/utils/schemaHelper.ts +44 -0
- package/src/vite-env.d.ts +7 -0
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, watch, provide, h, useAttrs } from 'vue'
|
|
3
|
+
import type { VxeGridInstance, VxeGridProps, VxeGridListeners } from 'vxe-table'
|
|
4
|
+
import type { EnhancedColumnSchema, PaginationState, SortState, SliceRequestDef, FilterOption } from '@/types'
|
|
5
|
+
import { TextFilter, NumberRangeFilter, DateRangeFilter, SelectFilter, BoolFilter } from './filters'
|
|
6
|
+
import { useTableSelection, useTableSummary } from './composables'
|
|
7
|
+
|
|
8
|
+
// 禁用自动继承属性,手动控制透传到 vxe-grid
|
|
9
|
+
defineOptions({
|
|
10
|
+
inheritAttrs: false
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
// 获取透传的属性和事件(用于传递给 vxe-grid)
|
|
14
|
+
const attrs = useAttrs()
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* DataTable 组件属性
|
|
18
|
+
*
|
|
19
|
+
* 该组件设计为可扩展的业务基础组件,支持:
|
|
20
|
+
* - 内嵌表头过滤器
|
|
21
|
+
* - 自定义列渲染
|
|
22
|
+
* - 自定义过滤器
|
|
23
|
+
* - 多种插槽扩展点
|
|
24
|
+
* - DSL 格式的过滤条件
|
|
25
|
+
* - 透传 vxe-table 的所有属性和事件
|
|
26
|
+
*/
|
|
27
|
+
interface Props {
|
|
28
|
+
/** 列配置(增强的列配置,包含前端定制) */
|
|
29
|
+
columns: EnhancedColumnSchema[]
|
|
30
|
+
/** 数据 */
|
|
31
|
+
data: Record<string, unknown>[]
|
|
32
|
+
/** 总行数 */
|
|
33
|
+
total: number
|
|
34
|
+
/** 加载状态 */
|
|
35
|
+
loading: boolean
|
|
36
|
+
/** 每页大小 */
|
|
37
|
+
pageSize?: number
|
|
38
|
+
/** 是否显示过滤行 */
|
|
39
|
+
showFilters?: boolean
|
|
40
|
+
/** 初始过滤条件(来自后端缓存) */
|
|
41
|
+
initialSlice?: SliceRequestDef[]
|
|
42
|
+
/** 后端返回的全量汇总数据 */
|
|
43
|
+
serverSummary?: Record<string, unknown> | null
|
|
44
|
+
/** 过滤选项加载器(用于维度列) */
|
|
45
|
+
filterOptionsLoader?: (columnName: string) => Promise<FilterOption[]>
|
|
46
|
+
/** 自定义过滤器组件映射 */
|
|
47
|
+
customFilterComponents?: Record<string, unknown>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
51
|
+
pageSize: 50,
|
|
52
|
+
showFilters: true
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const emit = defineEmits<{
|
|
56
|
+
(e: 'page-change', page: number, size: number): void
|
|
57
|
+
(e: 'sort-change', field: string | null, order: 'asc' | 'desc' | null): void
|
|
58
|
+
/** 过滤条件变更,使用 DSL slice 格式 */
|
|
59
|
+
(e: 'filter-change', slices: SliceRequestDef[]): void
|
|
60
|
+
/** 行点击事件 */
|
|
61
|
+
(e: 'row-click', row: Record<string, unknown>, column: EnhancedColumnSchema): void
|
|
62
|
+
/** 行双击事件 */
|
|
63
|
+
(e: 'row-dblclick', row: Record<string, unknown>, column: EnhancedColumnSchema): void
|
|
64
|
+
}>()
|
|
65
|
+
|
|
66
|
+
// 暴露给插槽使用的上下文
|
|
67
|
+
const slots = defineSlots<{
|
|
68
|
+
/** 表格上方工具栏 */
|
|
69
|
+
toolbar?: () => unknown
|
|
70
|
+
/** 表格下方区域 */
|
|
71
|
+
footer?: () => unknown
|
|
72
|
+
/** 空数据提示 */
|
|
73
|
+
empty?: () => unknown
|
|
74
|
+
/** 自定义列内容 */
|
|
75
|
+
[key: `column-${string}`]: (props: { row: Record<string, unknown>; column: EnhancedColumnSchema; value: unknown }) => unknown
|
|
76
|
+
/** 自定义过滤器 */
|
|
77
|
+
[key: `filter-${string}`]: (props: { column: EnhancedColumnSchema; field: string; modelValue: SliceRequestDef[] | null; onChange: (val: SliceRequestDef[] | null) => void }) => unknown
|
|
78
|
+
}>()
|
|
79
|
+
|
|
80
|
+
const gridRef = ref<VxeGridInstance>()
|
|
81
|
+
|
|
82
|
+
// 分页状态
|
|
83
|
+
const pagination = ref<PaginationState>({
|
|
84
|
+
currentPage: 1,
|
|
85
|
+
pageSize: props.pageSize,
|
|
86
|
+
total: props.total
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// 排序状态
|
|
90
|
+
const sortState = ref<SortState>({
|
|
91
|
+
field: null,
|
|
92
|
+
order: null
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// 过滤状态:每个字段对应一组 SliceRequestDef
|
|
96
|
+
const filterValues = ref<Record<string, SliceRequestDef[] | null>>({})
|
|
97
|
+
|
|
98
|
+
// 维度选项缓存
|
|
99
|
+
const dimensionOptionsCache = ref<Record<string, FilterOption[]>>({})
|
|
100
|
+
const dimensionOptionsLoading = ref<Record<string, boolean>>({})
|
|
101
|
+
|
|
102
|
+
// 使用 composables
|
|
103
|
+
const columnsRef = computed(() => props.columns)
|
|
104
|
+
const { selectedRows, onCheckboxChange, onCheckboxAll, clearSelection } = useTableSelection()
|
|
105
|
+
const { serverSummary, calculateSelectedSummary, generateFooterData, setServerSummary } = useTableSummary(columnsRef)
|
|
106
|
+
|
|
107
|
+
// 监听 serverSummary prop 变化
|
|
108
|
+
watch(() => props.serverSummary, (newVal) => {
|
|
109
|
+
setServerSummary(newVal ?? null)
|
|
110
|
+
}, { immediate: true })
|
|
111
|
+
|
|
112
|
+
// 计算 footer 数据
|
|
113
|
+
const footerData = computed(() => {
|
|
114
|
+
const selectedSummary = calculateSelectedSummary(selectedRows.value as Record<string, unknown>[])
|
|
115
|
+
// 需要获取实际的列配置(包含 checkbox 列)
|
|
116
|
+
const visibleCols = tableColumns.value?.map(col => ({
|
|
117
|
+
field: col.field as string | undefined,
|
|
118
|
+
type: props.columns.find(c => c.name === col.field)?.type
|
|
119
|
+
})) || []
|
|
120
|
+
return generateFooterData(visibleCols, selectedSummary)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// 监听 total 变化
|
|
124
|
+
watch(() => props.total, (newTotal) => {
|
|
125
|
+
pagination.value.total = newTotal
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// 监听 initialSlice 变化,初始化过滤器显示
|
|
129
|
+
watch(() => props.initialSlice, (slices) => {
|
|
130
|
+
if (!slices || slices.length === 0) return
|
|
131
|
+
|
|
132
|
+
// 按 field 分组 slices
|
|
133
|
+
const grouped: Record<string, SliceRequestDef[]> = {}
|
|
134
|
+
for (const slice of slices) {
|
|
135
|
+
if (!slice.field) continue
|
|
136
|
+
if (!grouped[slice.field]) {
|
|
137
|
+
grouped[slice.field] = []
|
|
138
|
+
}
|
|
139
|
+
grouped[slice.field].push(slice)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 设置到 filterValues
|
|
143
|
+
filterValues.value = grouped
|
|
144
|
+
}, { immediate: true })
|
|
145
|
+
|
|
146
|
+
// 加载维度选项
|
|
147
|
+
async function loadDimensionOptions(columnName: string): Promise<FilterOption[]> {
|
|
148
|
+
if (dimensionOptionsCache.value[columnName]) {
|
|
149
|
+
return dimensionOptionsCache.value[columnName]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!props.filterOptionsLoader) {
|
|
153
|
+
return []
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
dimensionOptionsLoading.value[columnName] = true
|
|
157
|
+
try {
|
|
158
|
+
const options = await props.filterOptionsLoader(columnName)
|
|
159
|
+
dimensionOptionsCache.value[columnName] = options
|
|
160
|
+
return options
|
|
161
|
+
} catch (e) {
|
|
162
|
+
console.error('Failed to load dimension options:', e)
|
|
163
|
+
return []
|
|
164
|
+
} finally {
|
|
165
|
+
dimensionOptionsLoading.value[columnName] = false
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 推断过滤器类型(根据 filterType 或 type 回退)
|
|
170
|
+
function inferFilterType(col: EnhancedColumnSchema): string {
|
|
171
|
+
const type = col.type?.toUpperCase()
|
|
172
|
+
|
|
173
|
+
// 日期类型优先使用日期组件,即使 filterType 是 dimension
|
|
174
|
+
switch (type) {
|
|
175
|
+
case 'DAY':
|
|
176
|
+
case 'DATE':
|
|
177
|
+
return 'date'
|
|
178
|
+
case 'DATETIME':
|
|
179
|
+
return 'datetime'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 其次使用后端返回的 filterType
|
|
183
|
+
if (col.filterType) {
|
|
184
|
+
return col.filterType
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 回退:根据 type 推断
|
|
188
|
+
switch (type) {
|
|
189
|
+
case 'NUMBER':
|
|
190
|
+
case 'MONEY':
|
|
191
|
+
case 'BIGDECIMAL':
|
|
192
|
+
case 'INTEGER':
|
|
193
|
+
case 'BIGINT':
|
|
194
|
+
case 'LONG':
|
|
195
|
+
return 'number'
|
|
196
|
+
case 'BOOL':
|
|
197
|
+
case 'BOOLEAN':
|
|
198
|
+
return 'bool'
|
|
199
|
+
case 'DICT':
|
|
200
|
+
return 'dict'
|
|
201
|
+
default:
|
|
202
|
+
return 'text'
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 根据列配置获取过滤器组件
|
|
207
|
+
function getFilterComponent(col: EnhancedColumnSchema) {
|
|
208
|
+
// 优先使用自定义过滤器组件
|
|
209
|
+
if (col.customFilterComponent) {
|
|
210
|
+
return col.customFilterComponent
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const filterType = inferFilterType(col)
|
|
214
|
+
|
|
215
|
+
// 检查自定义过滤器组件
|
|
216
|
+
if (props.customFilterComponents && props.customFilterComponents[filterType]) {
|
|
217
|
+
return props.customFilterComponents[filterType]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
switch (filterType) {
|
|
221
|
+
case 'text':
|
|
222
|
+
return TextFilter
|
|
223
|
+
case 'number':
|
|
224
|
+
return NumberRangeFilter
|
|
225
|
+
case 'date':
|
|
226
|
+
return DateRangeFilter
|
|
227
|
+
case 'datetime':
|
|
228
|
+
return DateRangeFilter
|
|
229
|
+
case 'dict':
|
|
230
|
+
return SelectFilter
|
|
231
|
+
case 'dimension':
|
|
232
|
+
return SelectFilter
|
|
233
|
+
case 'bool':
|
|
234
|
+
return BoolFilter
|
|
235
|
+
default:
|
|
236
|
+
return TextFilter
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 获取维度过滤的正确字段名
|
|
241
|
+
// 维度列选择的是 id 值,所以过滤字段应该用 $id 后缀
|
|
242
|
+
function getDimensionFilterField(columnName: string): string {
|
|
243
|
+
// 如果已经是 $id 结尾,直接返回
|
|
244
|
+
if (columnName.endsWith('$id')) {
|
|
245
|
+
return columnName
|
|
246
|
+
}
|
|
247
|
+
// 如果是 $caption 或其他后缀,替换为 $id
|
|
248
|
+
if (columnName.includes('$')) {
|
|
249
|
+
const baseName = columnName.substring(0, columnName.indexOf('$'))
|
|
250
|
+
return `${baseName}$id`
|
|
251
|
+
}
|
|
252
|
+
// 没有后缀的维度列,添加 $id
|
|
253
|
+
return `${columnName}$id`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 获取过滤器属性
|
|
257
|
+
function getFilterProps(col: EnhancedColumnSchema) {
|
|
258
|
+
const filterType = inferFilterType(col)
|
|
259
|
+
const baseProps: Record<string, unknown> = {
|
|
260
|
+
field: col.name,
|
|
261
|
+
modelValue: filterValues.value[col.name] || null,
|
|
262
|
+
'onUpdate:modelValue': (val: SliceRequestDef[] | null) => updateFilter(col.name, val)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
switch (filterType) {
|
|
266
|
+
case 'datetime':
|
|
267
|
+
return { ...baseProps, showTime: true, format: col.format }
|
|
268
|
+
case 'date':
|
|
269
|
+
return { ...baseProps, format: col.format }
|
|
270
|
+
case 'dict':
|
|
271
|
+
return {
|
|
272
|
+
...baseProps,
|
|
273
|
+
options: col.dictItems || [],
|
|
274
|
+
placeholder: col.title || '请选择'
|
|
275
|
+
}
|
|
276
|
+
case 'dimension': {
|
|
277
|
+
// 维度需要异步加载选项
|
|
278
|
+
if (!dimensionOptionsCache.value[col.name]) {
|
|
279
|
+
loadDimensionOptions(col.name)
|
|
280
|
+
}
|
|
281
|
+
// 维度过滤使用 $id 字段
|
|
282
|
+
const filterField = getDimensionFilterField(col.name)
|
|
283
|
+
return {
|
|
284
|
+
...baseProps,
|
|
285
|
+
field: filterField, // 使用正确的过滤字段($id)
|
|
286
|
+
modelValue: filterValues.value[filterField] || filterValues.value[col.name] || null,
|
|
287
|
+
'onUpdate:modelValue': (val: SliceRequestDef[] | null) => updateFilter(filterField, val),
|
|
288
|
+
options: dimensionOptionsCache.value[col.name] || [],
|
|
289
|
+
loading: dimensionOptionsLoading.value[col.name],
|
|
290
|
+
placeholder: col.title || '请选择'
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
default:
|
|
294
|
+
return baseProps
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 更新过滤值
|
|
299
|
+
function updateFilter(columnName: string, value: SliceRequestDef[] | null) {
|
|
300
|
+
if (value === null || value.length === 0) {
|
|
301
|
+
delete filterValues.value[columnName]
|
|
302
|
+
} else {
|
|
303
|
+
filterValues.value[columnName] = value
|
|
304
|
+
}
|
|
305
|
+
emitFilterChange()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 发送过滤变更事件 - 合并所有字段的 slice
|
|
309
|
+
function emitFilterChange() {
|
|
310
|
+
const allSlices: SliceRequestDef[] = []
|
|
311
|
+
for (const slices of Object.values(filterValues.value)) {
|
|
312
|
+
if (slices && slices.length > 0) {
|
|
313
|
+
allSlices.push(...slices)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
emit('filter-change', allSlices)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 根据字段类型获取格式化配置
|
|
320
|
+
function getColumnFormatter(col: EnhancedColumnSchema): Partial<VxeGridProps['columns']>[number] {
|
|
321
|
+
// 优先使用自定义格式化器
|
|
322
|
+
if (col.customFormatter) {
|
|
323
|
+
return {
|
|
324
|
+
formatter: ({ cellValue }) => col.customFormatter!(cellValue)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const type = col.type?.toUpperCase()
|
|
329
|
+
switch (type) {
|
|
330
|
+
case 'MONEY':
|
|
331
|
+
case 'NUMBER':
|
|
332
|
+
case 'BIGDECIMAL':
|
|
333
|
+
return {
|
|
334
|
+
formatter: ({ cellValue }) => {
|
|
335
|
+
if (cellValue == null) return ''
|
|
336
|
+
return typeof cellValue === 'number'
|
|
337
|
+
? cellValue.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
338
|
+
: cellValue
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
case 'INTEGER':
|
|
342
|
+
case 'BIGINT':
|
|
343
|
+
case 'LONG':
|
|
344
|
+
return {
|
|
345
|
+
formatter: ({ cellValue }) => {
|
|
346
|
+
if (cellValue == null) return ''
|
|
347
|
+
return typeof cellValue === 'number'
|
|
348
|
+
? cellValue.toLocaleString('zh-CN')
|
|
349
|
+
: cellValue
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
case 'DAY':
|
|
353
|
+
case 'DATE':
|
|
354
|
+
return {
|
|
355
|
+
formatter: ({ cellValue }) => {
|
|
356
|
+
if (!cellValue) return ''
|
|
357
|
+
return String(cellValue).split('T')[0]
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
case 'DATETIME':
|
|
361
|
+
return {
|
|
362
|
+
minWidth: 160,
|
|
363
|
+
formatter: ({ cellValue }) => {
|
|
364
|
+
if (!cellValue) return ''
|
|
365
|
+
return String(cellValue).replace('T', ' ').substring(0, 19)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
case 'BOOL':
|
|
369
|
+
case 'BOOLEAN':
|
|
370
|
+
return {
|
|
371
|
+
formatter: ({ cellValue }) => {
|
|
372
|
+
return cellValue === true ? '是' : cellValue === false ? '否' : ''
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
default:
|
|
376
|
+
return {}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 切换排序
|
|
381
|
+
function toggleSort(field: string) {
|
|
382
|
+
const current = sortState.value.field === field ? sortState.value.order : null
|
|
383
|
+
let newOrder: 'asc' | 'desc' | null
|
|
384
|
+
if (current === null) {
|
|
385
|
+
newOrder = 'asc'
|
|
386
|
+
} else if (current === 'asc') {
|
|
387
|
+
newOrder = 'desc'
|
|
388
|
+
} else {
|
|
389
|
+
newOrder = null
|
|
390
|
+
}
|
|
391
|
+
sortState.value.field = newOrder ? field : null
|
|
392
|
+
sortState.value.order = newOrder
|
|
393
|
+
emit('sort-change', sortState.value.field, sortState.value.order)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 生成 vxe-table 列配置
|
|
397
|
+
const tableColumns = computed<VxeGridProps['columns']>(() => {
|
|
398
|
+
// 依赖 sortState 确保排序变化时重新计算
|
|
399
|
+
const currentSort = sortState.value
|
|
400
|
+
|
|
401
|
+
// checkbox 列
|
|
402
|
+
const checkboxColumn = {
|
|
403
|
+
type: 'checkbox',
|
|
404
|
+
width: 50,
|
|
405
|
+
fixed: 'left'
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const dataColumns = props.columns.map(col => {
|
|
409
|
+
const colConfig: Record<string, unknown> = {
|
|
410
|
+
field: col.name,
|
|
411
|
+
title: col.title || col.name,
|
|
412
|
+
width: col.width,
|
|
413
|
+
minWidth: col.minWidth ?? 120,
|
|
414
|
+
fixed: col.fixed,
|
|
415
|
+
sortable: false, // 禁用 vxe-table 内置排序,我们自己处理
|
|
416
|
+
...getColumnFormatter(col),
|
|
417
|
+
// 使用 slots 在表头渲染过滤器
|
|
418
|
+
slots: {
|
|
419
|
+
header: () => {
|
|
420
|
+
// 在渲染时获取排序状态
|
|
421
|
+
const sortOrder = currentSort.field === col.name ? currentSort.order : null
|
|
422
|
+
return h('div', { class: 'column-header-wrapper' }, [
|
|
423
|
+
h('div', {
|
|
424
|
+
class: 'column-title',
|
|
425
|
+
onClick: () => toggleSort(col.name)
|
|
426
|
+
}, [
|
|
427
|
+
h('span', { class: 'title-text' }, col.title || col.name),
|
|
428
|
+
h('span', { class: ['sort-icon', sortOrder ? `sort-${sortOrder}` : ''] },
|
|
429
|
+
sortOrder === 'asc' ? ' ↑' : sortOrder === 'desc' ? ' ↓' : ' ↕'
|
|
430
|
+
)
|
|
431
|
+
]),
|
|
432
|
+
props.showFilters && h('div', { class: 'column-filter' }, [
|
|
433
|
+
renderFilterComponent(col)
|
|
434
|
+
])
|
|
435
|
+
])
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 检查是否有自定义列插槽
|
|
441
|
+
const slotName = `column-${col.name}`
|
|
442
|
+
if (slots[slotName]) {
|
|
443
|
+
colConfig.slots = {
|
|
444
|
+
...colConfig.slots as object,
|
|
445
|
+
default: ({ row }: { row: Record<string, unknown> }) => {
|
|
446
|
+
return slots[slotName]!({ row, column: col, value: row[col.name] })
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
} else if (col.customRender) {
|
|
450
|
+
// 使用自定义渲染函数
|
|
451
|
+
colConfig.slots = {
|
|
452
|
+
...colConfig.slots as object,
|
|
453
|
+
default: ({ row }: { row: Record<string, unknown> }) => {
|
|
454
|
+
return col.customRender!({ row, value: row[col.name] })
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return colConfig
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
// 返回 checkbox 列 + 数据列
|
|
463
|
+
return [checkboxColumn, ...dataColumns]
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// 渲染过滤器组件
|
|
467
|
+
function renderFilterComponent(col: EnhancedColumnSchema) {
|
|
468
|
+
// 检查自定义过滤器插槽
|
|
469
|
+
const filterSlotName = `filter-${col.name}`
|
|
470
|
+
if (slots[filterSlotName]) {
|
|
471
|
+
return slots[filterSlotName]!({
|
|
472
|
+
column: col,
|
|
473
|
+
field: col.name,
|
|
474
|
+
modelValue: filterValues.value[col.name] || null,
|
|
475
|
+
onChange: (val: SliceRequestDef[] | null) => updateFilter(col.name, val)
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// 使用内置过滤器
|
|
480
|
+
const FilterComponent = getFilterComponent(col)
|
|
481
|
+
const filterProps = getFilterProps(col)
|
|
482
|
+
return h(FilterComponent, filterProps)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Grid 配置(合并默认配置和用户传入的属性)
|
|
486
|
+
const gridOptions = computed<VxeGridProps>(() => {
|
|
487
|
+
// 提取事件监听器(on 开头的属性)
|
|
488
|
+
const userProps = Object.keys(attrs)
|
|
489
|
+
.filter(key => !key.startsWith('on'))
|
|
490
|
+
.reduce((acc, key) => ({ ...acc, [key]: attrs[key] }), {})
|
|
491
|
+
|
|
492
|
+
// 默认配置
|
|
493
|
+
const defaultOptions: VxeGridProps = {
|
|
494
|
+
border: true,
|
|
495
|
+
stripe: true,
|
|
496
|
+
showOverflow: true,
|
|
497
|
+
height: 'auto',
|
|
498
|
+
loading: props.loading,
|
|
499
|
+
columnConfig: {
|
|
500
|
+
resizable: true
|
|
501
|
+
},
|
|
502
|
+
rowConfig: {
|
|
503
|
+
isHover: true
|
|
504
|
+
},
|
|
505
|
+
// checkbox 配置
|
|
506
|
+
checkboxConfig: {
|
|
507
|
+
highlight: true,
|
|
508
|
+
trigger: 'cell'
|
|
509
|
+
},
|
|
510
|
+
// footer 配置
|
|
511
|
+
showFooter: true,
|
|
512
|
+
footerData: footerData.value,
|
|
513
|
+
// 表头行高需要容纳过滤器
|
|
514
|
+
headerRowClassName: props.showFilters ? 'header-with-filter' : '',
|
|
515
|
+
pagerConfig: {
|
|
516
|
+
enabled: true,
|
|
517
|
+
currentPage: pagination.value.currentPage,
|
|
518
|
+
pageSize: pagination.value.pageSize,
|
|
519
|
+
total: pagination.value.total,
|
|
520
|
+
pageSizes: [20, 50, 100, 200],
|
|
521
|
+
layouts: ['PrevPage', 'JumpNumber', 'NextPage', 'Sizes', 'FullJump', 'Total']
|
|
522
|
+
},
|
|
523
|
+
// 禁用 vxe-table 内置排序,我们自己处理
|
|
524
|
+
sortConfig: {
|
|
525
|
+
remote: true
|
|
526
|
+
},
|
|
527
|
+
columns: tableColumns.value,
|
|
528
|
+
data: props.data
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// 合并:用户传入的属性覆盖默认值
|
|
532
|
+
return { ...defaultOptions, ...userProps }
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
// 事件处理(合并默认事件和用户传入的事件)
|
|
536
|
+
const gridEvents = computed<VxeGridListeners>(() => {
|
|
537
|
+
// 提取用户传入的事件监听器(on 开头的属性,转换为驼峰)
|
|
538
|
+
const userEvents: Record<string, Function> = {}
|
|
539
|
+
Object.keys(attrs).forEach(key => {
|
|
540
|
+
if (key.startsWith('on')) {
|
|
541
|
+
// onPageChange -> pageChange
|
|
542
|
+
const eventName = key.slice(2, 3).toLowerCase() + key.slice(3)
|
|
543
|
+
userEvents[eventName] = attrs[key] as Function
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
// 创建事件包装函数:先执行默认逻辑,再执行用户监听器
|
|
548
|
+
const wrapEvent = (defaultHandler: Function, eventName: string) => {
|
|
549
|
+
return (...args: any[]) => {
|
|
550
|
+
// 先执行默认逻辑
|
|
551
|
+
defaultHandler(...args)
|
|
552
|
+
// 再执行用户传入的监听器
|
|
553
|
+
if (userEvents[eventName]) {
|
|
554
|
+
userEvents[eventName](...args)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// 默认事件处理器
|
|
560
|
+
const defaultEvents: VxeGridListeners = {
|
|
561
|
+
pageChange: wrapEvent(({ currentPage, pageSize }) => {
|
|
562
|
+
pagination.value.currentPage = currentPage
|
|
563
|
+
pagination.value.pageSize = pageSize
|
|
564
|
+
emit('page-change', currentPage, pageSize)
|
|
565
|
+
}, 'pageChange'),
|
|
566
|
+
cellClick: wrapEvent(({ row, column }) => {
|
|
567
|
+
const col = props.columns.find(c => c.name === column.field)
|
|
568
|
+
if (col) {
|
|
569
|
+
emit('row-click', row, col)
|
|
570
|
+
}
|
|
571
|
+
}, 'cellClick'),
|
|
572
|
+
cellDblclick: wrapEvent(({ row, column }) => {
|
|
573
|
+
const col = props.columns.find(c => c.name === column.field)
|
|
574
|
+
if (col) {
|
|
575
|
+
emit('row-dblclick', row, col)
|
|
576
|
+
}
|
|
577
|
+
}, 'cellDblclick'),
|
|
578
|
+
checkboxChange: wrapEvent(onCheckboxChange, 'checkboxChange'),
|
|
579
|
+
checkboxAll: wrapEvent(onCheckboxAll, 'checkboxAll')
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// 合并:添加用户定义但我们没有默认处理的事件
|
|
583
|
+
const mergedEvents = { ...defaultEvents }
|
|
584
|
+
Object.keys(userEvents).forEach(eventName => {
|
|
585
|
+
if (!mergedEvents[eventName as keyof VxeGridListeners]) {
|
|
586
|
+
mergedEvents[eventName as keyof VxeGridListeners] = userEvents[eventName] as any
|
|
587
|
+
}
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
return mergedEvents
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
// 重置分页
|
|
594
|
+
function resetPagination() {
|
|
595
|
+
pagination.value.currentPage = 1
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 清除所有过滤
|
|
599
|
+
function clearFilters() {
|
|
600
|
+
filterValues.value = {}
|
|
601
|
+
emitFilterChange()
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 暴露方法给父组件
|
|
605
|
+
defineExpose({
|
|
606
|
+
resetPagination,
|
|
607
|
+
clearFilters,
|
|
608
|
+
/** 获取当前过滤状态 (DSL slices) */
|
|
609
|
+
getFilters: (): SliceRequestDef[] => {
|
|
610
|
+
const allSlices: SliceRequestDef[] = []
|
|
611
|
+
for (const slices of Object.values(filterValues.value)) {
|
|
612
|
+
if (slices && slices.length > 0) {
|
|
613
|
+
allSlices.push(...slices)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return allSlices
|
|
617
|
+
},
|
|
618
|
+
/** 设置过滤值 */
|
|
619
|
+
setFilter: updateFilter,
|
|
620
|
+
/** 获取 vxe-grid 实例 */
|
|
621
|
+
getGridInstance: () => gridRef.value
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
// 提供上下文给子组件
|
|
625
|
+
provide('dataTableContext', {
|
|
626
|
+
columns: computed(() => props.columns),
|
|
627
|
+
filters: filterValues,
|
|
628
|
+
updateFilter
|
|
629
|
+
})
|
|
630
|
+
</script>
|
|
631
|
+
|
|
632
|
+
<template>
|
|
633
|
+
<div class="data-table">
|
|
634
|
+
<!-- 工具栏插槽 -->
|
|
635
|
+
<div v-if="$slots.toolbar" class="data-table-toolbar">
|
|
636
|
+
<slot name="toolbar" />
|
|
637
|
+
</div>
|
|
638
|
+
|
|
639
|
+
<!-- 表格主体(过滤器已移入表头) -->
|
|
640
|
+
<div class="table-wrapper">
|
|
641
|
+
<vxe-grid
|
|
642
|
+
ref="gridRef"
|
|
643
|
+
v-bind="gridOptions"
|
|
644
|
+
v-on="gridEvents"
|
|
645
|
+
>
|
|
646
|
+
<!-- 空数据插槽 -->
|
|
647
|
+
<template #empty>
|
|
648
|
+
<slot name="empty">
|
|
649
|
+
<div class="empty-data">暂无数据</div>
|
|
650
|
+
</slot>
|
|
651
|
+
</template>
|
|
652
|
+
</vxe-grid>
|
|
653
|
+
</div>
|
|
654
|
+
|
|
655
|
+
<!-- 底部插槽 -->
|
|
656
|
+
<div v-if="$slots.footer" class="data-table-footer">
|
|
657
|
+
<slot name="footer" />
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
660
|
+
</template>
|
|
661
|
+
|
|
662
|
+
<style scoped>
|
|
663
|
+
.data-table {
|
|
664
|
+
display: flex;
|
|
665
|
+
flex-direction: column;
|
|
666
|
+
width: 100%;
|
|
667
|
+
height: 100%;
|
|
668
|
+
background: white;
|
|
669
|
+
border-radius: 4px;
|
|
670
|
+
overflow: hidden;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.data-table-toolbar {
|
|
674
|
+
padding: 12px 16px;
|
|
675
|
+
border-bottom: 1px solid #e4e7ed;
|
|
676
|
+
background: #fafafa;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.table-wrapper {
|
|
680
|
+
flex: 1;
|
|
681
|
+
overflow: hidden;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/* 表头内嵌过滤器样式 */
|
|
685
|
+
.column-header-wrapper {
|
|
686
|
+
display: flex;
|
|
687
|
+
flex-direction: column;
|
|
688
|
+
width: 100%;
|
|
689
|
+
padding: 4px 0;
|
|
690
|
+
min-height: 60px;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.column-title {
|
|
694
|
+
display: flex;
|
|
695
|
+
align-items: center;
|
|
696
|
+
font-weight: 600;
|
|
697
|
+
margin-bottom: 6px;
|
|
698
|
+
cursor: pointer;
|
|
699
|
+
user-select: none;
|
|
700
|
+
line-height: 1.2;
|
|
701
|
+
min-height: 18px;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
.column-title:hover {
|
|
705
|
+
color: #409eff;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.title-text {
|
|
709
|
+
flex: 1;
|
|
710
|
+
white-space: nowrap;
|
|
711
|
+
overflow: hidden;
|
|
712
|
+
text-overflow: ellipsis;
|
|
713
|
+
font-size: 13px;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.sort-icon {
|
|
717
|
+
flex-shrink: 0;
|
|
718
|
+
font-size: 11px;
|
|
719
|
+
color: #c0c4cc;
|
|
720
|
+
margin-left: 2px;
|
|
721
|
+
width: 12px;
|
|
722
|
+
text-align: center;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
.sort-icon.sort-asc,
|
|
726
|
+
.sort-icon.sort-desc {
|
|
727
|
+
color: #409eff;
|
|
728
|
+
font-weight: bold;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
.column-filter {
|
|
732
|
+
width: 100%;
|
|
733
|
+
min-height: 26px;
|
|
734
|
+
overflow: visible;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/* 过滤器组件通用样式 */
|
|
738
|
+
.column-filter :deep(input),
|
|
739
|
+
.column-filter :deep(select) {
|
|
740
|
+
width: 100%;
|
|
741
|
+
height: 24px;
|
|
742
|
+
padding: 0 6px;
|
|
743
|
+
font-size: 12px;
|
|
744
|
+
border: 1px solid #dcdfe6;
|
|
745
|
+
border-radius: 3px;
|
|
746
|
+
background: #fff;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
.column-filter :deep(input:focus),
|
|
750
|
+
.column-filter :deep(select:focus) {
|
|
751
|
+
border-color: #409eff;
|
|
752
|
+
outline: none;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
.column-filter :deep(.filter-wrapper) {
|
|
756
|
+
display: flex;
|
|
757
|
+
flex-direction: column;
|
|
758
|
+
gap: 4px;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/* vxe-table 表头样式(需要 :deep 穿透 scoped) */
|
|
762
|
+
:deep(.header-with-filter) {
|
|
763
|
+
height: auto !important;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
:deep(.header-with-filter .vxe-header--column) {
|
|
767
|
+
padding: 8px 4px !important;
|
|
768
|
+
vertical-align: top;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
:deep(.vxe-header--column .vxe-cell) {
|
|
772
|
+
overflow: visible !important;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/* 确保下拉框不被表头裁剪 */
|
|
776
|
+
:deep(.vxe-table--header-wrapper) {
|
|
777
|
+
overflow: visible !important;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
:deep(.vxe-table--header-inner-wrapper) {
|
|
781
|
+
overflow: visible !important;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
:deep(.vxe-table--header) {
|
|
785
|
+
overflow: visible !important;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
:deep(.vxe-header--row) {
|
|
789
|
+
overflow: visible !important;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/* 提高下拉框 z-index,确保在表格内容之上 */
|
|
793
|
+
.column-filter :deep(.filter-dropdown),
|
|
794
|
+
.column-filter :deep(.filter-select .filter-dropdown),
|
|
795
|
+
.column-filter :deep(.filter-text .filter-dropdown) {
|
|
796
|
+
z-index: 2000 !important;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
.data-table-footer {
|
|
800
|
+
padding: 12px 16px;
|
|
801
|
+
border-top: 1px solid #e4e7ed;
|
|
802
|
+
background: #fafafa;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
.empty-data {
|
|
806
|
+
padding: 40px;
|
|
807
|
+
text-align: center;
|
|
808
|
+
color: #909399;
|
|
809
|
+
}
|
|
810
|
+
</style>
|