xt-element-ui 2.0.5 → 2.1.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.
@@ -0,0 +1,663 @@
1
+ <template>
2
+ <div class="xt-table-wrapper">
3
+ <!-- 标题栏 / 工具栏 -->
4
+ <div class="xt-table-header" v-if="title || $slots.toolbar">
5
+ <span class="xt-table-title" v-if="title">{{ title }}</span>
6
+ <div class="xt-table-toolbar">
7
+ <slot name="toolbar"></slot>
8
+ </div>
9
+ </div>
10
+
11
+ <!-- 主体表格:单 Table + 内置虚拟滚动(核心改造) -->
12
+ <div class="xt-table-body">
13
+ <VirtualElTable
14
+ ref="table"
15
+ :data="processedTableData"
16
+ :height="computedHeight"
17
+ :max-height="computedMaxHeight"
18
+ :virtual-scroll="virtualScroll"
19
+ :row-height="rowInitHeight"
20
+ :buffer-size="bufferSize"
21
+ :span-method="groupColumns.length ? handleSpanMethod : undefined"
22
+ :row-class-name="getRowClassName"
23
+ v-bind="$attrs"
24
+ v-on="$listeners"
25
+ @selection-change="handleSelectionChange"
26
+ @sort-change="handleSortChange"
27
+ class="xt-table"
28
+ >
29
+ <!-- 选择列 -->
30
+ <el-table-column
31
+ v-if="selection"
32
+ type="selection"
33
+ width="55"
34
+ :fixed="selectionFixed"
35
+ />
36
+ <!-- 序号列 -->
37
+ <el-table-column
38
+ v-if="showIndex"
39
+ type="index"
40
+ width="60"
41
+ label="#"
42
+ :fixed="indexFixed"
43
+ :index="indexMethod"
44
+ />
45
+ <!-- 多级列配置 -->
46
+ <template v-for="col in flattenedColumns">
47
+ <el-table-column
48
+ v-if="col.children && col.children.length"
49
+ :key="col._key"
50
+ v-bind="getColumnProps(col)"
51
+ >
52
+ <template v-for="child in col.children">
53
+ <el-table-column :key="child._key" v-bind="getColumnProps(child)">
54
+ <template v-if="child.render" v-slot="scope">{{ child.render(scope) }}</template>
55
+ <template v-else-if="child.formatter" v-slot="scope">
56
+ <XtTableCell
57
+ :row="scope.row"
58
+ :index="scope.$index"
59
+ :formatter="child.formatter"
60
+ :column="child"
61
+ />
62
+ </template>
63
+ <template v-else-if="child.slot" v-slot="scope">
64
+ <slot
65
+ :name="child.slot"
66
+ :row="scope.row"
67
+ :index="scope.$index"
68
+ :column="child"
69
+ />
70
+ </template>
71
+ </el-table-column>
72
+ </template>
73
+ </el-table-column>
74
+ <el-table-column v-else :key="col._key" v-bind="getColumnProps(col)">
75
+ <template v-if="col.render" v-slot="scope">{{ col.render(scope) }}</template>
76
+ <template v-else-if="col.formatter" v-slot="scope">
77
+ <XtTableCell
78
+ :row="scope.row"
79
+ :index="scope.$index"
80
+ :formatter="col.formatter"
81
+ :column="col"
82
+ />
83
+ </template>
84
+ <template v-else-if="col.slot" v-slot="scope">
85
+ <slot
86
+ :name="col.slot"
87
+ :row="scope.row"
88
+ :index="scope.$index"
89
+ :column="col"
90
+ />
91
+ </template>
92
+ </el-table-column>
93
+ </template>
94
+ </VirtualElTable>
95
+ </div>
96
+
97
+ <!-- 分页 -->
98
+ <div class="xt-table-footer" v-if="showPagination">
99
+ <el-pagination
100
+ :current-page="pagination.pageNum"
101
+ :page-size="pagination.pageSize"
102
+ :total="total"
103
+ :page-sizes="pagination.pageSizes || [10, 20, 50, 100]"
104
+ layout="total, sizes, prev, pager, next, jumper"
105
+ @size-change="handleSizeChange"
106
+ @current-change="handleCurrentChange"
107
+ />
108
+ </div>
109
+ </div>
110
+ </template>
111
+
112
+ <script>
113
+ import XtTableCell from './XtTableCell.vue'
114
+ import VirtualElTable from './VirtualElTable.vue'
115
+
116
+ export default {
117
+ name: 'XtTable',
118
+ inheritAttrs: false,
119
+ components: { XtTableCell, VirtualElTable },
120
+
121
+ props: {
122
+ tableData: { type: Array, default: () => [] },
123
+ columns: { type: Array, default: () => [] },
124
+ groupColumns: { type: Array, default: () => [] },
125
+ // 排序配置
126
+ sortGroup: { type: Boolean, default: false },
127
+ defaultSort: { type: Object, default: null },
128
+ title: { type: String, default: '' },
129
+ height: { type: [Number, String], default: null },
130
+ maxHeight: { type: [Number, String], default: null },
131
+ virtualScroll: { type: Boolean, default: false },
132
+ rowInitHeight: { type: Number, default: 48 },
133
+ bufferSize: { type: Number, default: 5 },
134
+ pagination: { type: Object, default: null },
135
+ total: { type: Number, default: 0 },
136
+ showIndex: { type: Boolean, default: false },
137
+ selection: { type: Boolean, default: false },
138
+ selectionFixed: { type: [String, Boolean], default: false },
139
+ indexFixed: { type: [String, Boolean], default: false },
140
+ loading: { type: Boolean, default: false },
141
+ emptyText: { type: String, default: '暂无数据' },
142
+ subtotalConfig: { type: Object, default: () => ({ enabled: false }) },
143
+ totalConfig: { type: Object, default: () => ({ enabled: false }) }
144
+ },
145
+
146
+ data() {
147
+ return {
148
+ spanCache: {},
149
+ flattenedColumnsCache: [],
150
+ selectedRows: [],
151
+ // 排序状态
152
+ sortProp: null,
153
+ sortOrder: null
154
+ }
155
+ },
156
+
157
+ computed: {
158
+ showPagination() {
159
+ return this.pagination && this.total > 0
160
+ },
161
+ computedHeight() {
162
+ return this.height || undefined
163
+ },
164
+ computedMaxHeight() {
165
+ return this.height ? undefined : this.maxHeight || undefined
166
+ },
167
+
168
+ // 排序后的数据
169
+ sortedTableData() {
170
+ if (!this.tableData.length) return []
171
+ if (!this.sortProp || !this.sortOrder) return [...this.tableData]
172
+
173
+ const order = this.sortOrder === 'ascending' ? 1 : -1
174
+ const sortFn = this.resolveSortMethod(this.sortProp)
175
+
176
+ if (this.sortGroup && this.groupColumns.length) {
177
+ return this.groupSort([...this.tableData], sortFn, order)
178
+ }
179
+ return [...this.tableData].sort((a, b) => sortFn(a, b) * order)
180
+ },
181
+
182
+ // 处理小计、总计后的最终数据
183
+ processedTableData() {
184
+ if (!this.sortedTableData.length) return []
185
+ let data = [...this.sortedTableData]
186
+ const hasSubtotal = this.subtotalConfig && this.subtotalConfig.enabled
187
+ const hasTotal = this.totalConfig && this.totalConfig.enabled
188
+
189
+ if (!hasSubtotal && !hasTotal) return data
190
+
191
+ const labelColumn = this.findLabelColumn()
192
+ let result = []
193
+
194
+ if (hasSubtotal && this.subtotalConfig.groupBy && this.subtotalConfig.groupBy.length) {
195
+ const groups = this.groupData(data, this.subtotalConfig.groupBy)
196
+ groups.forEach((groupRows) => {
197
+ result.push(...groupRows)
198
+ const subtotalRow = this.createSubtotalRow(groupRows)
199
+ result.push(subtotalRow)
200
+ })
201
+ } else {
202
+ result = data
203
+ }
204
+
205
+ if (hasTotal) {
206
+ const totalRow = this.createTotalRow(data)
207
+ result.push(totalRow)
208
+ }
209
+ return result
210
+ },
211
+
212
+ // 扁平化列配置,添加唯一 key
213
+ flattenedColumns() {
214
+ if (this.flattenedColumnsCache.length) return this.flattenedColumnsCache
215
+ const assignKeys = (cols, parentPath = '') => {
216
+ return cols.map((col, index) => {
217
+ const path = parentPath ? `${parentPath}_${index}` : String(index)
218
+ const key = col.prop || col.slot || col.label || path
219
+ const item = { ...col, _key: key }
220
+ if (col.children && col.children.length) {
221
+ item.children = assignKeys(col.children, path)
222
+ }
223
+ return item
224
+ })
225
+ }
226
+ this.flattenedColumnsCache = assignKeys(this.columns)
227
+ return this.flattenedColumnsCache
228
+ }
229
+ },
230
+
231
+ watch: {
232
+ tableData() {
233
+ this.spanCache = {}
234
+ this.flattenedColumnsCache = []
235
+ },
236
+ columns: {
237
+ handler() {
238
+ this.flattenedColumnsCache = []
239
+ this.spanCache = {}
240
+ },
241
+ deep: true
242
+ },
243
+ groupColumns: {
244
+ handler() {
245
+ this.spanCache = {}
246
+ },
247
+ deep: true
248
+ },
249
+ defaultSort: {
250
+ handler(val) {
251
+ if (val && val.prop) {
252
+ this.sortProp = val.prop
253
+ this.sortOrder = val.order || 'ascending'
254
+ }
255
+ },
256
+ immediate: false
257
+ },
258
+ sortProp() {
259
+ this.spanCache = {}
260
+ }
261
+ },
262
+
263
+ created() {
264
+ if (this.defaultSort && this.defaultSort.prop) {
265
+ this.sortProp = this.defaultSort.prop
266
+ this.sortOrder = this.defaultSort.order || 'ascending'
267
+ }
268
+ },
269
+
270
+ mounted() {
271
+ this.$nextTick(() => {
272
+ window.addEventListener('resize', this.handleResize)
273
+ })
274
+ },
275
+
276
+ beforeDestroy() {
277
+ window.removeEventListener('resize', this.handleResize)
278
+ },
279
+
280
+ methods: {
281
+ // ========== 排序逻辑 ==========
282
+ resolveSortMethod(prop) {
283
+ const col = this.findColumnByProp(prop)
284
+ if (col && typeof col.sortMethod === 'function') {
285
+ return col.sortMethod
286
+ }
287
+ return (a, b) => {
288
+ const va = a[prop]
289
+ const vb = b[prop]
290
+ if (va == null && vb == null) return 0
291
+ if (va == null) return -1
292
+ if (vb == null) return 1
293
+ if (typeof va === 'number' && typeof vb === 'number') return va - vb
294
+ return String(va).localeCompare(String(vb), undefined, { numeric: true })
295
+ }
296
+ },
297
+
298
+ findColumnByProp(prop) {
299
+ for (const col of this.columns) {
300
+ if (col.children && col.children.length) {
301
+ for (const child of col.children) {
302
+ if (child.prop === prop) return child
303
+ }
304
+ } else if (col.prop === prop) {
305
+ return col
306
+ }
307
+ }
308
+ return null
309
+ },
310
+
311
+ groupSort(data, sortFn, order) {
312
+ const groups = {}
313
+ data.forEach(row => {
314
+ const key = this.groupColumns.map(f => row[f] == null ? '' : String(row[f])).join('|||')
315
+ if (!groups[key]) groups[key] = []
316
+ groups[key].push(row)
317
+ })
318
+ const result = []
319
+ Object.keys(groups).sort().forEach(key => {
320
+ const groupRows = groups[key]
321
+ groupRows.sort((a, b) => sortFn(a, b) * order)
322
+ result.push(...groupRows)
323
+ })
324
+ return result
325
+ },
326
+
327
+ handleResize() {
328
+ this.$nextTick(() => {
329
+ this.$refs.table && this.$refs.table.doLayout()
330
+ })
331
+ },
332
+
333
+ // ========== 小计 / 总计 相关 ==========
334
+ findLabelColumn() {
335
+ for (const col of this.columns) {
336
+ if (col.children && col.children.length) {
337
+ for (const child of col.children) {
338
+ if (child.prop) return { prop: child.prop, label: child.label }
339
+ }
340
+ } else if (col.prop) {
341
+ return { prop: col.prop, label: col.label }
342
+ }
343
+ }
344
+ return { prop: '', label: '' }
345
+ },
346
+
347
+ groupData(data, groupBy) {
348
+ const map = new Map()
349
+ data.forEach(row => {
350
+ const key = groupBy.map(f => row[f]).join('|||')
351
+ if (!map.has(key)) map.set(key, [])
352
+ map.get(key).push(row)
353
+ })
354
+ return map
355
+ },
356
+
357
+ _calcValue(rows, calc) {
358
+ if (typeof calc === 'function') return calc(rows)
359
+ const prop = calc.prop
360
+ const type = calc.type || 'sum'
361
+ const vals = prop ? rows.map(r => parseFloat(r[prop]) || 0) : []
362
+ switch (type) {
363
+ case 'sum': return vals.reduce((s, v) => s + v, 0)
364
+ case 'avg':
365
+ case 'average': return vals.length ? vals.reduce((s, v) => s + v, 0) / vals.length : 0
366
+ case 'count': return rows.length
367
+ case 'min': return vals.length ? Math.min(...vals) : 0
368
+ case 'max': return vals.length ? Math.max(...vals) : 0
369
+ default: return ''
370
+ }
371
+ },
372
+
373
+ createSubtotalRow(groupRows) {
374
+ const config = this.subtotalConfig
375
+ const rawLabelProp = this.findLabelColumn()
376
+ const labelField = config.groupBy ? config.groupBy[0] : ''
377
+ const groupValue = groupRows[0] ? groupRows[0][labelField] : ''
378
+ const labelText = config.labelText || `${groupValue} 小计`
379
+
380
+ const row = { _rowType: 'subtotal' }
381
+ row[rawLabelProp.prop] = labelText
382
+
383
+ if (config.columns) {
384
+ Object.keys(config.columns).forEach(prop => {
385
+ const calc = config.columns[prop]
386
+ row[prop] = typeof calc === 'string'
387
+ ? this._calcValue(groupRows, { prop, type: calc })
388
+ : this._calcValue(groupRows, calc)
389
+ })
390
+ }
391
+ return row
392
+ },
393
+
394
+ createTotalRow(allRows) {
395
+ const config = this.totalConfig
396
+ const rawLabelProp = this.findLabelColumn()
397
+ const labelText = config.labelText || '总计'
398
+
399
+ const row = { _rowType: 'total' }
400
+ row[rawLabelProp.prop] = labelText
401
+
402
+ if (config.columns) {
403
+ Object.keys(config.columns).forEach(prop => {
404
+ const calc = config.columns[prop]
405
+ row[prop] = typeof calc === 'string'
406
+ ? this._calcValue(allRows, { prop, type: calc })
407
+ : this._calcValue(allRows, calc)
408
+ })
409
+ }
410
+ return row
411
+ },
412
+
413
+ getRowClassName({ row }) {
414
+ if (row._rowType === 'subtotal') return 'xt-table-row-subtotal'
415
+ if (row._rowType === 'total') return 'xt-table-row-total'
416
+ return ''
417
+ },
418
+
419
+ // ========== 列处理 ==========
420
+ getColumnProps(col) {
421
+ const { _key, children, render, formatter, slot, sortMethod, ...props } = col
422
+ return props
423
+ },
424
+
425
+ indexMethod(index) {
426
+ return index + 1
427
+ },
428
+
429
+ // ========== 合并单元格 ==========
430
+ handleSpanMethod({ row, column, rowIndex }) {
431
+ if (row._rowType === 'subtotal' || row._rowType === 'total') {
432
+ return { rowspan: 1, colspan: 1 }
433
+ }
434
+ if (!this.groupColumns.length) return { rowspan: 1, colspan: 1 }
435
+
436
+ const data = this.processedTableData
437
+ const prop = column.property
438
+ const groupIndex = this.groupColumns.indexOf(prop)
439
+ if (groupIndex === -1) return { rowspan: 1, colspan: 1 }
440
+
441
+ const cacheKey = `${rowIndex}_${prop}`
442
+ if (this.spanCache[cacheKey]) return this.spanCache[cacheKey]
443
+
444
+ if (rowIndex > 0) {
445
+ const prevRow = data[rowIndex - 1]
446
+ let isSame = true
447
+ for (let i = 0; i <= groupIndex; i++) {
448
+ const gp = this.groupColumns[i]
449
+ if (prevRow[gp] !== row[gp]) {
450
+ isSame = false
451
+ break
452
+ }
453
+ }
454
+ if (isSame) {
455
+ const res = { rowspan: 0, colspan: 1 }
456
+ this.spanCache[cacheKey] = res
457
+ return res
458
+ }
459
+ }
460
+
461
+ let count = 1
462
+ for (let i = rowIndex + 1; i < data.length; i++) {
463
+ const nextRow = data[i]
464
+ let same = true
465
+ for (let j = 0; j <= groupIndex; j++) {
466
+ const gp = this.groupColumns[j]
467
+ if (nextRow[gp] !== row[gp]) {
468
+ same = false
469
+ break
470
+ }
471
+ }
472
+ if (same) count++
473
+ else break
474
+ }
475
+
476
+ const res = { rowspan: count > 1 ? count : 1, colspan: 1 }
477
+ this.spanCache[cacheKey] = res
478
+ return res
479
+ },
480
+
481
+ // ========== 选择、排序、分页 ==========
482
+ handleSelectionChange(rows) {
483
+ this.selectedRows = rows.filter(r => !r._rowType)
484
+ this.$emit('selection-change', this.selectedRows)
485
+ },
486
+ handleSortChange(info) {
487
+ this.sortProp = info.prop
488
+ this.sortOrder = info.order
489
+ this.$emit('sort-change', info)
490
+ },
491
+ handleSizeChange(size) {
492
+ this.$emit('size-change', size)
493
+ },
494
+ handleCurrentChange(page) {
495
+ this.$emit('page-change', page)
496
+ },
497
+
498
+ // ========== 对外暴露方法 ==========
499
+ getSelection() {
500
+ return this.selectedRows
501
+ },
502
+ clearSelection() {
503
+ this.$refs.table && this.$refs.table.clearSelection()
504
+ this.selectedRows = []
505
+ },
506
+ clearSort() {
507
+ this.sortProp = null
508
+ this.sortOrder = null
509
+ this.$nextTick(() => {
510
+ if (this.$refs.table && this.$refs.table.$refs.innerTable) {
511
+ this.$refs.table.$refs.innerTable.clearSort()
512
+ }
513
+ })
514
+ },
515
+ toggleRowSelection(row, selected) {
516
+ this.$refs.table && this.$refs.table.toggleRowSelection(row, selected)
517
+ },
518
+ toggleRowsSelection(rows, selected) {
519
+ if (!rows || !rows.length) return
520
+ rows.forEach(row => {
521
+ this.$refs.table && this.$refs.table.toggleRowSelection(row, selected)
522
+ })
523
+ },
524
+ doLayout() {
525
+ this.$refs.table && this.$refs.table.doLayout()
526
+ }
527
+ }
528
+ }
529
+ </script>
530
+ <style scoped>
531
+ .xt-table-wrapper {
532
+ width: 100%;
533
+ }
534
+
535
+ /* 表头栏 */
536
+ .xt-table-header {
537
+ display: flex;
538
+ align-items: center;
539
+ justify-content: space-between;
540
+ padding: 12px 0;
541
+ margin-bottom: 8px;
542
+ }
543
+ .xt-table-title {
544
+ font-size: 16px;
545
+ font-weight: 600;
546
+ color: #303133;
547
+ position: relative;
548
+ padding-left: 12px;
549
+ }
550
+ .xt-table-title::before {
551
+ content: '';
552
+ position: absolute;
553
+ left: 0;
554
+ top: 50%;
555
+ transform: translateY(-50%);
556
+ width: 3px;
557
+ height: 16px;
558
+ background: #409eff;
559
+ border-radius: 2px;
560
+ }
561
+ .xt-table-toolbar {
562
+ display: flex;
563
+ align-items: center;
564
+ gap: 8px;
565
+ }
566
+
567
+ .xt-table-body {
568
+ position: relative;
569
+ z-index: 1;
570
+ }
571
+
572
+ /* 分页区域 */
573
+ .xt-table-footer {
574
+ display: flex;
575
+ justify-content: flex-end;
576
+ padding: 16px 0;
577
+ }
578
+
579
+ /* ========== 小计/合计行样式 ========== */
580
+ ::v-deep .xt-table-row-subtotal {
581
+ background-color: #f5f7fa;
582
+ font-weight: 600;
583
+ color: #303133;
584
+ }
585
+ ::v-deep .xt-table-row-total {
586
+ background-color: #e8eaed;
587
+ font-weight: 700;
588
+ color: #303133;
589
+ border-top: 2px solid #c0c4cc;
590
+ }
591
+ ::v-deep .xt-table-row-subtotal:hover > td {
592
+ background-color: #ebeef5 !important;
593
+ }
594
+ ::v-deep .xt-table-row-total:hover > td {
595
+ background-color: #dcdfe6 !important;
596
+ }
597
+
598
+ /* ========== 滚动条:Y轴 + X轴 统一处理 ========== */
599
+ /* 合并为一个选择器块,避免重复导致的优先级问题 */
600
+ ::v-deep .el-table__body-wrapper {
601
+ overflow-y: auto !important;
602
+ overflow-x: auto !important;
603
+ user-select: none;
604
+ -webkit-user-select: none;
605
+ }
606
+ ::v-deep .el-table__body-wrapper::-webkit-scrollbar {
607
+ width: 8px;
608
+ height: 8px;
609
+ }
610
+ ::v-deep .el-table__body-wrapper::-webkit-scrollbar-thumb {
611
+ background: #c1c1c1;
612
+ border-radius: 4px;
613
+ }
614
+ ::v-deep .el-table__body-wrapper::-webkit-scrollbar-track {
615
+ background: #f1f1f1;
616
+ }
617
+ /* 滚动条角落(Y+X 交汇处) */
618
+ ::v-deep .el-table__body-wrapper::-webkit-scrollbar-corner {
619
+ background: #f1f1f1;
620
+ }
621
+
622
+ /* 允许单元格内文字选中,但容器本身不可选中 */
623
+ ::v-deep .el-table__body-wrapper .el-table__cell {
624
+ user-select: text;
625
+ -webkit-user-select: text;
626
+ }
627
+
628
+ /* ========== 固定列层级 + 背景 防文字穿透 ========== */
629
+ ::v-deep .el-table__fixed {
630
+ z-index: 10 !important;
631
+ box-shadow: 4px 0 8px rgba(0, 0, 0, 0.08);
632
+ }
633
+ ::v-deep .el-table__fixed-right {
634
+ z-index: 10 !important;
635
+ box-shadow: -4px 0 8px rgba(0, 0, 0, 0.08);
636
+ }
637
+
638
+ /* 固定列单元格底色(纯白兜底,盖住滚动穿透文字) */
639
+ ::v-deep .el-table__fixed .el-table__cell,
640
+ ::v-deep .el-table__fixed-right .el-table__cell {
641
+ background-color: #ffffff !important;
642
+ }
643
+ ::v-deep .el-table__fixed .el-table__row:nth-child(even) .el-table__cell,
644
+ ::v-deep .el-table__fixed-right .el-table__row:nth-child(even) .el-table__cell {
645
+ background-color: #fafafa !important;
646
+ }
647
+ ::v-deep .el-table__fixed .el-table__row:hover .el-table__cell,
648
+ ::v-deep .el-table__fixed-right .el-table__row:hover .el-table__cell {
649
+ background-color: #f5f7fa !important;
650
+ }
651
+
652
+ ::v-deep .el-table__fixed-header-wrapper,
653
+ ::v-deep .el-table__fixed-right-header-wrapper {
654
+ z-index: 11 !important;
655
+ }
656
+
657
+ /* ========== 虚拟滚动容器:正常布局流撑开滚动条 ========== */
658
+ ::v-deep .vs-phantom {
659
+ position: relative;
660
+ box-sizing: border-box;
661
+ z-index: 1 !important;
662
+ }
663
+ </style>