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.
Files changed (34) hide show
  1. package/README.md +273 -0
  2. package/dist/favicon.svg +4 -0
  3. package/dist/index.js +1531 -0
  4. package/dist/index.umd +1 -0
  5. package/dist/style.css +1 -0
  6. package/package.json +51 -0
  7. package/src/App.vue +469 -0
  8. package/src/api/viewer.ts +163 -0
  9. package/src/components/DataTable.test.ts +533 -0
  10. package/src/components/DataTable.vue +810 -0
  11. package/src/components/DataTableWithSearch.test.ts +628 -0
  12. package/src/components/DataTableWithSearch.vue +277 -0
  13. package/src/components/DataViewer.vue +310 -0
  14. package/src/components/SearchToolbar.test.ts +521 -0
  15. package/src/components/SearchToolbar.vue +406 -0
  16. package/src/components/composables/index.ts +2 -0
  17. package/src/components/composables/useTableSelection.test.ts +248 -0
  18. package/src/components/composables/useTableSelection.ts +44 -0
  19. package/src/components/composables/useTableSummary.test.ts +341 -0
  20. package/src/components/composables/useTableSummary.ts +129 -0
  21. package/src/components/filters/BoolFilter.vue +103 -0
  22. package/src/components/filters/DateRangeFilter.vue +194 -0
  23. package/src/components/filters/NumberRangeFilter.vue +160 -0
  24. package/src/components/filters/SelectFilter.vue +464 -0
  25. package/src/components/filters/TextFilter.vue +230 -0
  26. package/src/components/filters/index.ts +5 -0
  27. package/src/examples/EnhancedTableExample.vue +136 -0
  28. package/src/index.ts +32 -0
  29. package/src/main.ts +14 -0
  30. package/src/types/index.ts +159 -0
  31. package/src/utils/README.md +140 -0
  32. package/src/utils/schemaHelper.test.ts +215 -0
  33. package/src/utils/schemaHelper.ts +44 -0
  34. 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>