@idooel/components 0.0.2-beta.32 → 0.0.2-beta.34

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.
@@ -1,611 +1,612 @@
1
- <template>
2
- <div class="g-table__wrapper" ref="tableWrapper" :style="wrapperStyle">
3
- <a-table
4
- :key="tableRenderKey"
5
- :bordered="bordered"
6
- :class="[isNoData && 'g-table__no-data']"
7
- :pagination="false"
8
- :loading="loading"
9
- size="middle"
10
- :columns="smartColumns"
11
- :row-selection="smartRowSelection"
12
- :row-class-name="setRowClassName"
13
- :data-source="dataSource"
14
- :scroll="smartScroll">
15
- <template slot="action" slot-scope="record">
16
- <Actions v-on="$listeners" :data-source="actions" :record="record"></Actions>
17
- </template>
18
- </a-table>
19
- <div class="g-table__pagination">
20
- <a-pagination
21
- :show-total="all => `共 ${all} 条数据`"
22
- v-if="mode === 'default'"
23
- show-size-changer
24
- show-quick-jumper
25
- :pageSize="innerPageSize"
26
- :current="innerCurrentPage"
27
- :pageSizeOptions="pageSizeOptions"
28
- @change="onChangePagination"
29
- @showSizeChange="onShowSizeChange"
30
- :total="total">
31
- </a-pagination>
32
- <ele-pagination
33
- v-else
34
- :pageSize="innerPageSize"
35
- :current="innerCurrentPage"
36
- :pageSizeOptions="pageSizeOptions"
37
- :data="dataSource"
38
- :loading="countLoading"
39
- @change="onChangePagination"
40
- @showSizeChange="onShowSizeChange"
41
- :total="total"
42
- ></ele-pagination>
43
- </div>
44
- </div>
45
- </template>
46
-
47
- <script>
48
- import Actions from './action.vue'
49
- export default {
50
- name: 'ele-table',
51
- components: {
52
- Actions
53
- },
54
- props: {
55
- mode: {
56
- type: String,
57
- default: 'default',
58
- validator: (value) => {
59
- return ['default', 'next-cursor'].includes(value)
60
- }
61
- },
62
- // ant table wrapper
63
- height: {
64
- type: Number
65
- },
66
- width: {
67
- type: Number
68
- },
69
- x: {
70
- // ant-design-vue: scroll.x 支持 number | string(如 'max-content')
71
- type: [Number, String],
72
- default: 1200
73
- },
74
- y: {
75
- type: Number,
76
- default: 200
77
- },
78
- scroll: {
79
- type: Object
80
- },
81
- rowSelection: {
82
- type: Object
83
- },
84
- actions: {
85
- type: Array,
86
- default: () => []
87
- },
88
- total: {
89
- type: Number,
90
- default: 0
91
- },
92
- loading: {
93
- type: Boolean,
94
- default: false
95
- },
96
- columns: {
97
- type: Array,
98
- default: () => []
99
- },
100
- dataSource: {
101
- type: Array,
102
- default: () => []
103
- },
104
- pageSize: {
105
- type: [Number, String],
106
- default: 10
107
- },
108
- pageSizeOptions: {
109
- type: Array,
110
- default: () => ['10', '20', '30', '40']
111
- },
112
- bordered: {
113
- type: Boolean,
114
- default: true
115
- },
116
- countLoading: {
117
- type: Boolean,
118
- default: false
119
- }
120
- },
121
- data() {
122
- return {
123
- tableHeaderHeight: 0,
124
- paginationHeight: 0,
125
- innerPageSize: 10,
126
- innerCurrentPage: 1,
127
- tableContentHeight: 0,
128
- obs: [],
129
- // 容器宽度,用于智能判断是否需要 fixed 列
130
- containerWidth: 0,
131
- // 用于强制重新渲染表格(当 fixed 列状态切换时)
132
- tableRenderKey: 0
133
- }
134
- },
135
- computed: {
136
- wrapperStyle () {
137
- // 外层容器样式
138
- if (!this.height) return {}
139
- return { height: `${this.height}px` }
140
- },
141
- needScrollY () {
142
- // 判断是否需要 y 轴滚动:基于数据行数与可用高度预估
143
- if (!this.height) return false
144
-
145
- const availableHeight = this.getScrollHeightByHeight
146
- if (availableHeight <= 0) return false
147
-
148
- // 预估每行高度(包含边框),antd 默认约 54px
149
- const estimatedRowHeight = 54
150
- const estimatedTableHeight = this.dataSource.length * estimatedRowHeight
151
-
152
- return estimatedTableHeight > availableHeight
153
- },
154
- innerColumns () {
155
- return this.columns.filter(col => !Object.keys(col).includes('multiple'))
156
- },
157
- isNoData () {
158
- return !this.dataSource.length
159
- },
160
- getScrollHeightByHeight () {
161
- // 始终返回可用的剩余高度,让表格内容不足时也能占满容器
162
- return this.height - this.tableHeaderHeight - this.paginationHeight
163
- },
164
- isFlexColumn () {
165
- return this.columns.every(col => !col.width)
166
- },
167
- /**
168
- * 计算所有列的总宽度(包括 rowSelection 的 checkbox 列和操作列)
169
- */
170
- totalColumnsWidth () {
171
- const cols = this.innerColumns || []
172
- let total = cols.reduce((sum, col) => {
173
- const w = col && col.width
174
- return sum + (typeof w === 'number' ? w : 0)
175
- }, 0)
176
- // rowSelection checkbox/radio 列,antd 默认约 60px
177
- if (this.rowSelection) total += 60
178
- // 操作列(operations)的宽度
179
- if (this.operations && this.operations.width && typeof this.operations.width === 'number') {
180
- total += this.operations.width
181
- }
182
- return total
183
- },
184
- /**
185
- * 是否需要横向滚动:容器宽度 < 列总宽度
186
- */
187
- needHorizontalScroll () {
188
- // 未获取容器宽度前,先假定需要滚动(保守策略)
189
- if (!this.containerWidth) return true
190
- // 加一点容差
191
- return this.containerWidth < this.totalColumnsWidth - 5
192
- },
193
- /**
194
- * 智能列配置:
195
- * - 当需要横向滚动时,保留原始 fixed 属性
196
- * - 当容器足够宽时,移除 fixed 属性,让表格自动铺满
197
- */
198
- smartColumns () {
199
- if (this.needHorizontalScroll) {
200
- // 需要滚动,保留原始配置
201
- return this.innerColumns
202
- }
203
- // 不需要滚动,移除所有 fixed 属性
204
- return this.innerColumns.map(col => {
205
- if (col.fixed) {
206
- const { fixed, ...rest } = col
207
- return rest
208
- }
209
- return col
210
- })
211
- },
212
- /**
213
- * 智能 scroll 配置:
214
- * - 当需要横向滚动时,设置 scroll.x
215
- * - 当容器足够宽时,不设置 scroll.x,避免产生空白区域
216
- */
217
- smartScroll () {
218
- if (!this.needHorizontalScroll) {
219
- // 不需要横向滚动,只保留 y 方向(如果需要)
220
- if (this.height && this.needScrollY) {
221
- const availableHeight = this.tableHeaderHeight && this.paginationHeight
222
- ? this.getScrollHeightByHeight
223
- : this.height - 100
224
- if (availableHeight > 50) {
225
- return { y: availableHeight }
226
- }
227
- }
228
- return {}
229
- }
230
- // 需要横向滚动,使用原有逻辑
231
- return this.getScroll
232
- },
233
- /**
234
- * 智能 rowSelection 配置:
235
- * - 当需要横向滚动时,保留原始 fixed 属性
236
- * - 当容器足够宽时,移除 fixed 属性
237
- */
238
- smartRowSelection () {
239
- if (!this.rowSelection) return null
240
- if (this.needHorizontalScroll) {
241
- return this.rowSelection
242
- }
243
- // 不需要滚动,移除 fixed 属性
244
- if (this.rowSelection.fixed) {
245
- const { fixed, ...rest } = this.rowSelection
246
- return rest
247
- }
248
- return this.rowSelection
249
- },
250
- getScroll () {
251
- if (this.scroll) {
252
- return this.scroll
253
- } else {
254
- // 固定列需要 scroll.x 才能正确同步行高,始终设置一个有效值
255
- let baseX = (this.x === '' || this.x === null || this.x === undefined) ? 1200 : this.x
256
-
257
- // 解决“x 给太大导致操作列前出现大块空白”的问题:
258
- // 当所有列都给了明确 width 时,scroll.x 取列宽总和最合理;大于总和会产生多余区域。
259
- if (typeof baseX === 'number') {
260
- const cols = this.innerColumns || []
261
- const total = cols.reduce((sum, col) => {
262
- const w = col && col.width
263
- return sum + (typeof w === 'number' ? w : 0)
264
- }, 0)
265
-
266
- // rowSelection checkbox/radio 列是 antd 自动加的,给一个经验宽度避免误差
267
- const selectionWidth = this.rowSelection ? 60 : 0
268
-
269
- // 只有当所有列都明确给了宽度(total > 0)时才 clamp
270
- if (total > 0) {
271
- const minX = total + selectionWidth
272
- if (baseX > minX) baseX = minX
273
- }
274
- }
275
-
276
- if (this.height && this.needScrollY) {
277
- const availableHeight = this.tableHeaderHeight && this.paginationHeight
278
- ? this.getScrollHeightByHeight
279
- : this.height - 100
280
-
281
- if (availableHeight > 50) {
282
- return { x: baseX, y: availableHeight }
283
- }
284
- }
285
- return { x: baseX }
286
- }
287
- }
288
- },
289
- watch: {
290
- pageSize: {
291
- handler (pageSize) {
292
- this.innerPageSize = pageSize
293
- },
294
- immediate: true
295
- },
296
- /**
297
- * 监听 needHorizontalScroll 变化,强制重新渲染表格
298
- * 当从"需要固定列"切换到"不需要固定列"或反之时,antd 的 a-table 需要完全重新渲染
299
- */
300
- needHorizontalScroll (newVal, oldVal) {
301
- if (newVal !== oldVal) {
302
- // 更新 key 强制 Vue 销毁并重建 a-table 组件
303
- this.tableRenderKey++
304
- // 使用重试机制确保固定列完全渲染
305
- this.$nextTick(() => {
306
- this.retrySyncFixedColumns(newVal)
307
- })
308
- }
309
- }
310
- },
311
- methods: {
312
- onShowSizeChange (current, pageSize) {
313
- this.innerCurrentPage = current
314
- this.innerPageSize = pageSize
315
- this.$emit('change-page', current, pageSize)
316
- },
317
- setPaginationHeight () {
318
- this.$nextTick(() => {
319
- const el = this.$el.querySelector('.g-table__pagination')
320
- if (el) {
321
- const { height } = el.getBoundingClientRect()
322
- this.paginationHeight = height
323
- }
324
- })
325
- },
326
- setTableHeaderHeight () {
327
- this.$nextTick(() => {
328
- const el = this.$el.querySelector('.ant-table-header')
329
- if (!el) return
330
- const { height } = el.getBoundingClientRect()
331
- this.tableHeaderHeight = height
332
- })
333
- },
334
- setTableTbodyHeight () {
335
- this.$nextTick(() => {
336
- this.setTableHeaderHeight()
337
- })
338
- },
339
- setRowClassName (record, idx) {
340
- return idx % 2 === 0 ? 'g-table__row--even' : 'g-table__row--odd'
341
- },
342
- onChangePagination (page, pageSize) {
343
- this.innerCurrentPage = page
344
- this.innerPageSize = pageSize
345
- this.$emit('change-page', page, pageSize)
346
- },
347
- syncFixedColumns () {
348
- // 强制 ant-design-vue 重新计算固定列的宽度
349
- this.$nextTick(() => {
350
- const tableEl = this.$el.querySelector('.ant-table')
351
- if (tableEl) {
352
- // 触发窗口 resize 事件,让 ant-design-vue 重新计算
353
- window.dispatchEvent(new Event('resize'))
354
- }
355
- })
356
- },
357
- /**
358
- * 带重试的固定列同步机制
359
- * antd 的 a-table 在 key 变化后重新渲染固定列需要一定时间,
360
- * 这里通过重试确保固定列渲染完成后再同步。
361
- * @param {boolean} needFixed - 是否需要固定列
362
- * @param {number} retries - 当前重试次数
363
- */
364
- retrySyncFixedColumns (needFixed, retries = 0) {
365
- const MAX_RETRIES = 5
366
- const DELAY = 100 // 每次延迟 100ms
367
-
368
- setTimeout(() => {
369
- this.syncFixedColumns()
370
- this.syncHeaderTableWidth()
371
- this.bindScrollSync()
372
-
373
- // 如果需要固定列,检查是否已渲染
374
- if (needFixed && retries < MAX_RETRIES) {
375
- const hasFixedLeft = this.$el.querySelector('.ant-table-fixed-left .ant-table-body tbody tr')
376
- const hasFixedRight = this.$el.querySelector('.ant-table-fixed-right .ant-table-body tbody tr')
377
-
378
- // 如果有固定列配置但还没渲染出来,继续重试
379
- const hasFixedConfig = this.innerColumns.some(c => c.fixed) || (this.rowSelection && this.rowSelection.fixed)
380
- if (hasFixedConfig && !hasFixedLeft && !hasFixedRight) {
381
- this.retrySyncFixedColumns(needFixed, retries + 1)
382
- }
383
- }
384
- }, DELAY)
385
- },
386
- /**
387
- * 修复 x: 'max-content' 场景下表头不跟着横向滚动的问题。
388
- * 原因:ant-design-vue 的 header table 和 body table 各自算 max-content,
389
- * header 按表头文字算、body 按实际数据算,二者宽度不同时 header 就"滚不动"。
390
- * 方案:数据渲染后把 header table 的 width 强制设成和 body table 一样。
391
- */
392
- syncHeaderTableWidth () {
393
- this.$nextTick(() => {
394
- const headerTable = this.$el.querySelector('.ant-table-scroll .ant-table-header table')
395
- const bodyTable = this.$el.querySelector('.ant-table-scroll .ant-table-body table')
396
- if (!headerTable || !bodyTable) return
397
-
398
- const bodyW = bodyTable.getBoundingClientRect().width
399
- const headerW = headerTable.getBoundingClientRect().width
400
-
401
- // 始终同步表头宽度到表体宽度,确保窗口变大/变小时都能正确响应
402
- // 只有当宽度差异超过 2px 时才更新,避免频繁设置样式
403
- if (Math.abs(bodyW - headerW) > 2) {
404
- headerTable.style.width = `${bodyW}px`
405
- headerTable.style.minWidth = `${bodyW}px`
406
- }
407
- })
408
- },
409
- /**
410
- * 监听表体横向滚动,同步到表头(防止 antd 自带同步失效)
411
- */
412
- bindScrollSync () {
413
- const body = this.$el.querySelector('.ant-table-scroll .ant-table-body')
414
- const header = this.$el.querySelector('.ant-table-scroll .ant-table-header')
415
- if (!body || !header) return
416
-
417
- if (this._scrollHandler) return // 已绑定
418
- this._scrollHandler = () => {
419
- header.scrollLeft = body.scrollLeft
420
- }
421
- body.addEventListener('scroll', this._scrollHandler, { passive: true })
422
- },
423
- unbindScrollSync () {
424
- const body = this.$el.querySelector('.ant-table-scroll .ant-table-body')
425
- if (body && this._scrollHandler) {
426
- body.removeEventListener('scroll', this._scrollHandler)
427
- this._scrollHandler = null
428
- }
429
- },
430
- /**
431
- * 测量容器宽度
432
- */
433
- measureContainerWidth () {
434
- const wrapper = this.$refs.tableWrapper
435
- if (wrapper) {
436
- this.containerWidth = wrapper.clientWidth
437
- }
438
- },
439
- /**
440
- * 使用 ResizeObserver 监听容器宽度变化
441
- */
442
- observeContainerWidth () {
443
- const wrapper = this.$refs.tableWrapper
444
- if (!wrapper || typeof ResizeObserver === 'undefined') return
445
-
446
- if (this._containerResizeObserver) return // 已绑定
447
-
448
- this._containerResizeObserver = new ResizeObserver((entries) => {
449
- for (const entry of entries) {
450
- const newWidth = entry.contentRect.width
451
- // 只有宽度变化超过阈值才触发更新,避免微小变化导致频繁重渲染
452
- if (Math.abs(newWidth - this.containerWidth) > 10) {
453
- const oldNeedScroll = this.needHorizontalScroll
454
- this.containerWidth = newWidth
455
- // 容器宽度变化会触发 needHorizontalScroll 的重新计算
456
- // needHorizontalScroll watcher 会处理表格重新渲染和列宽同步
457
- // 这里不需要直接调用 syncFixedColumns,避免与 watcher 的执行时机冲突
458
- // 无论 needHorizontalScroll 是否变化,都立即同步表头宽度
459
- // 确保窗口变大/变小时表头都能及时响应
460
- this.$nextTick(() => {
461
- // 使用 requestAnimationFrame 确保在浏览器重绘后同步表头宽度
462
- // 这样能确保表体宽度已经更新完成
463
- requestAnimationFrame(() => {
464
- this.syncHeaderTableWidth()
465
- })
466
-
467
- // 如果 needHorizontalScroll 状态没有变化,说明只是列宽需要调整,直接同步
468
- // 如果状态变化了,watcher 会处理重新渲染
469
- if (this.needHorizontalScroll === oldNeedScroll) {
470
- // 防抖:延迟同步固定列,避免频繁调用
471
- if (this._resizeDebounceTimer) {
472
- clearTimeout(this._resizeDebounceTimer)
473
- }
474
- this._resizeDebounceTimer = setTimeout(() => {
475
- this.syncFixedColumns()
476
- this.syncHeaderTableWidth()
477
- this.bindScrollSync()
478
- }, 150)
479
- }
480
- })
481
- }
482
- }
483
- })
484
- this._containerResizeObserver.observe(wrapper)
485
- },
486
- /**
487
- * 断开容器宽度监听
488
- */
489
- unobserveContainerWidth () {
490
- if (this._containerResizeObserver) {
491
- this._containerResizeObserver.disconnect()
492
- this._containerResizeObserver = null
493
- }
494
- // 清理防抖定时器
495
- if (this._resizeDebounceTimer) {
496
- clearTimeout(this._resizeDebounceTimer)
497
- this._resizeDebounceTimer = null
498
- }
499
- }
500
- },
501
- mounted() {
502
- this.$nextTick(() => {
503
- // 先测量容器宽度,用于智能判断是否需要 fixed 列
504
- this.measureContainerWidth()
505
- this.observeContainerWidth()
506
-
507
- this.setPaginationHeight()
508
- setTimeout(() => {
509
- this.setTableTbodyHeight()
510
- this.setPaginationHeight()
511
- // 强制同步固定列和主表的列宽
512
- this.syncFixedColumns()
513
- // 同步表头 table 宽度(修复 max-content 场景)
514
- this.syncHeaderTableWidth()
515
- // 绑定横向滚动同步
516
- this.bindScrollSync()
517
- }, 200)
518
- })
519
-
520
- // 监听数据变化,重新同步列宽
521
- this.$watch('dataSource', () => {
522
- this.$nextTick(() => {
523
- setTimeout(() => {
524
- this.syncFixedColumns()
525
- this.syncHeaderTableWidth()
526
- this.bindScrollSync()
527
- }, 100)
528
- })
529
- }, { deep: true })
530
- },
531
- destroyed () {
532
- this.obs.forEach(ob => ob.disconnect())
533
- this.unbindScrollSync()
534
- this.unobserveContainerWidth()
535
- }
536
- }
537
- </script>
538
-
539
- <style lang="scss" scoped>
540
- .g-table__wrapper {
541
- /**
542
- * 修复"宽屏下表格两侧出现空白"问题:
543
- * 当视口宽度大于表格内容宽度时,主表(ant-table-scroll)不会自动拉伸,
544
- * 而固定列(fixed-left/fixed-right)是 position:absolute 定位在容器边缘,中间就出现空白。
545
- * 解决方案:让主表的 table 元素 min-width:100%,使其始终填满滚动容器。
546
- */
547
- ::v-deep .ant-table-scroll .ant-table-header table,
548
- ::v-deep .ant-table-scroll .ant-table-body table {
549
- min-width: 100%;
550
- }
551
-
552
- /**
553
- * 修复"固定列 + scroll.x"场景下,主表(ant-table-scroll)里会渲染一份 fixed 列的占位 header/cell。
554
- * 这份占位本来只用于计算宽度,但在某些布局下会被看见,表现为"操作列前多了一大块空白/空列"
555
- * 这里用 visibility:hidden 隐藏占位(不影响占位宽度与 fixed 计算),避免视觉空白。
556
- */
557
- ::v-deep .ant-table-scroll .ant-table-header thead > tr > th.ant-table-fixed-columns-in-body.ant-table-row-cell-last,
558
- ::v-deep .ant-table-scroll .ant-table-body tbody > tr > td.ant-table-fixed-columns-in-body.ant-table-row-cell-last {
559
- visibility: hidden;
560
- }
561
-
562
- /* 强制统一行高,确保主表和固定列对齐 */
563
- ::v-deep .ant-table-tbody > tr > td {
564
- height: 54px;
565
- padding: 8px 16px;
566
- vertical-align: middle;
567
- box-sizing: border-box;
568
- line-height: 38px;
569
- }
570
-
571
- /* 表头也统一高度和样式 */
572
- ::v-deep .ant-table-thead > tr > th {
573
- height: 54px;
574
- padding: 8px 16px;
575
- vertical-align: middle;
576
- box-sizing: border-box;
577
- line-height: 38px;
578
- }
579
-
580
- /* 分页区域固定在底部 */
581
- .g-table__pagination {
582
- display: flex;
583
- flex-direction: row;
584
- justify-content: end;
585
- border-top: unset;
586
- padding-top: 8px;
587
- padding-bottom: 8px;
588
- background: #fff;
589
- }
590
-
591
- /* 空数据状态顶部显示 */
592
- .g-table__no-data {
593
- position: relative;
594
- ::v-deep .ant-table-placeholder {
595
- position: absolute;
596
- top: 50%;
597
- left: 50%;
598
- transform: translate(-50%, -40%);
599
- width: 100%;
600
- height: 100%;
601
- text-align: center;
602
- color: #999;
603
- font-size: 14px;
604
- font-weight: normal;
605
- line-height: 20px;
606
- overflow: hidden;
607
- border: unset;
608
- }
609
- }
610
- }
1
+ <template>
2
+ <div class="g-table__wrapper" ref="tableWrapper" :style="wrapperStyle">
3
+ <a-table
4
+ :key="tableRenderKey"
5
+ :bordered="bordered"
6
+ :class="[isNoData && 'g-table__no-data']"
7
+ :pagination="false"
8
+ :loading="loading"
9
+ size="middle"
10
+ :columns="smartColumns"
11
+ :row-selection="smartRowSelection"
12
+ :row-class-name="setRowClassName"
13
+ :data-source="dataSource"
14
+ :scroll="smartScroll"
15
+ :locale="{ emptyText: '暂无数据' }">
16
+ <template slot="action" slot-scope="record">
17
+ <Actions v-on="$listeners" :data-source="actions" :record="record"></Actions>
18
+ </template>
19
+ </a-table>
20
+ <div class="g-table__pagination">
21
+ <a-pagination
22
+ :show-total="all => `共 ${all} 条数据`"
23
+ v-if="mode === 'default'"
24
+ show-size-changer
25
+ show-quick-jumper
26
+ :pageSize="innerPageSize"
27
+ :current="innerCurrentPage"
28
+ :pageSizeOptions="pageSizeOptions"
29
+ @change="onChangePagination"
30
+ @showSizeChange="onShowSizeChange"
31
+ :total="total">
32
+ </a-pagination>
33
+ <ele-pagination
34
+ v-else
35
+ :pageSize="innerPageSize"
36
+ :current="innerCurrentPage"
37
+ :pageSizeOptions="pageSizeOptions"
38
+ :data="dataSource"
39
+ :loading="countLoading"
40
+ @change="onChangePagination"
41
+ @showSizeChange="onShowSizeChange"
42
+ :total="total"
43
+ ></ele-pagination>
44
+ </div>
45
+ </div>
46
+ </template>
47
+
48
+ <script>
49
+ import Actions from './action.vue'
50
+ export default {
51
+ name: 'ele-table',
52
+ components: {
53
+ Actions
54
+ },
55
+ props: {
56
+ mode: {
57
+ type: String,
58
+ default: 'default',
59
+ validator: (value) => {
60
+ return ['default', 'next-cursor'].includes(value)
61
+ }
62
+ },
63
+ // ant table wrapper
64
+ height: {
65
+ type: Number
66
+ },
67
+ width: {
68
+ type: Number
69
+ },
70
+ x: {
71
+ // ant-design-vue: scroll.x 支持 number | string(如 'max-content')
72
+ type: [Number, String],
73
+ default: 1200
74
+ },
75
+ y: {
76
+ type: Number,
77
+ default: 200
78
+ },
79
+ scroll: {
80
+ type: Object
81
+ },
82
+ rowSelection: {
83
+ type: Object
84
+ },
85
+ actions: {
86
+ type: Array,
87
+ default: () => []
88
+ },
89
+ total: {
90
+ type: Number,
91
+ default: 0
92
+ },
93
+ loading: {
94
+ type: Boolean,
95
+ default: false
96
+ },
97
+ columns: {
98
+ type: Array,
99
+ default: () => []
100
+ },
101
+ dataSource: {
102
+ type: Array,
103
+ default: () => []
104
+ },
105
+ pageSize: {
106
+ type: [Number, String],
107
+ default: 10
108
+ },
109
+ pageSizeOptions: {
110
+ type: Array,
111
+ default: () => ['10', '20', '30', '40']
112
+ },
113
+ bordered: {
114
+ type: Boolean,
115
+ default: true
116
+ },
117
+ countLoading: {
118
+ type: Boolean,
119
+ default: false
120
+ }
121
+ },
122
+ data() {
123
+ return {
124
+ tableHeaderHeight: 0,
125
+ paginationHeight: 0,
126
+ innerPageSize: 10,
127
+ innerCurrentPage: 1,
128
+ tableContentHeight: 0,
129
+ obs: [],
130
+ // 容器宽度,用于智能判断是否需要 fixed 列
131
+ containerWidth: 0,
132
+ // 用于强制重新渲染表格(当 fixed 列状态切换时)
133
+ tableRenderKey: 0
134
+ }
135
+ },
136
+ computed: {
137
+ wrapperStyle () {
138
+ // 外层容器样式
139
+ if (!this.height) return {}
140
+ return { height: `${this.height}px` }
141
+ },
142
+ needScrollY () {
143
+ // 判断是否需要 y 轴滚动:基于数据行数与可用高度预估
144
+ if (!this.height) return false
145
+
146
+ const availableHeight = this.getScrollHeightByHeight
147
+ if (availableHeight <= 0) return false
148
+
149
+ // 无数据时也需设置 scroll.y,使表格体有高度,否则“暂无数据”占位区域会塌陷无法正常显示
150
+ if (!this.dataSource.length) return true
151
+
152
+ // 预估每行高度(包含边框),antd 默认约 54px
153
+ const estimatedRowHeight = 54
154
+ const estimatedTableHeight = this.dataSource.length * estimatedRowHeight
155
+
156
+ return estimatedTableHeight > availableHeight
157
+ },
158
+ innerColumns () {
159
+ return this.columns.filter(col => !Object.keys(col).includes('multiple'))
160
+ },
161
+ isNoData () {
162
+ return !this.dataSource.length
163
+ },
164
+ getScrollHeightByHeight () {
165
+ // 始终返回可用的剩余高度,让表格内容不足时也能占满容器
166
+ return this.height - this.tableHeaderHeight - this.paginationHeight
167
+ },
168
+ isFlexColumn () {
169
+ return this.columns.every(col => !col.width)
170
+ },
171
+ /**
172
+ * 计算所有列的总宽度(包括 rowSelection checkbox 列和操作列)
173
+ */
174
+ totalColumnsWidth () {
175
+ const cols = this.innerColumns || []
176
+ let total = cols.reduce((sum, col) => {
177
+ const w = col && col.width
178
+ return sum + (typeof w === 'number' ? w : 0)
179
+ }, 0)
180
+ // rowSelection 的 checkbox/radio 列,antd 默认约 60px
181
+ if (this.rowSelection) total += 60
182
+ // 操作列(operations)的宽度
183
+ if (this.operations && this.operations.width && typeof this.operations.width === 'number') {
184
+ total += this.operations.width
185
+ }
186
+ return total
187
+ },
188
+ /**
189
+ * 是否需要横向滚动:容器宽度 < 列总宽度
190
+ */
191
+ needHorizontalScroll () {
192
+ // 未获取容器宽度前,先假定需要滚动(保守策略)
193
+ if (!this.containerWidth) return true
194
+ // 加一点容差
195
+ return this.containerWidth < this.totalColumnsWidth - 5
196
+ },
197
+ /**
198
+ * 智能列配置:
199
+ * - 当需要横向滚动时,保留原始 fixed 属性
200
+ * - 当容器足够宽时,移除 fixed 属性,让表格自动铺满
201
+ */
202
+ smartColumns () {
203
+ if (this.needHorizontalScroll) {
204
+ // 需要滚动,保留原始配置
205
+ return this.innerColumns
206
+ }
207
+ // 不需要滚动,移除所有 fixed 属性
208
+ return this.innerColumns.map(col => {
209
+ if (col.fixed) {
210
+ const { fixed, ...rest } = col
211
+ return rest
212
+ }
213
+ return col
214
+ })
215
+ },
216
+ /**
217
+ * 智能 scroll 配置:
218
+ * - 当需要横向滚动时,设置 scroll.x
219
+ * - 当容器足够宽时,不设置 scroll.x,避免产生空白区域
220
+ */
221
+ smartScroll () {
222
+ if (!this.needHorizontalScroll) {
223
+ // 不需要横向滚动,只保留 y 方向(如果需要)
224
+ if (this.height && this.needScrollY) {
225
+ const availableHeight = this.tableHeaderHeight && this.paginationHeight
226
+ ? this.getScrollHeightByHeight
227
+ : this.height - 100
228
+ if (availableHeight > 50) {
229
+ return { y: availableHeight }
230
+ }
231
+ }
232
+ return {}
233
+ }
234
+ // 需要横向滚动,使用原有逻辑
235
+ return this.getScroll
236
+ },
237
+ /**
238
+ * 智能 rowSelection 配置:
239
+ * - 当需要横向滚动时,保留原始 fixed 属性
240
+ * - 当容器足够宽时,移除 fixed 属性
241
+ */
242
+ smartRowSelection () {
243
+ if (!this.rowSelection) return null
244
+ if (this.needHorizontalScroll) {
245
+ return this.rowSelection
246
+ }
247
+ // 不需要滚动,移除 fixed 属性
248
+ if (this.rowSelection.fixed) {
249
+ const { fixed, ...rest } = this.rowSelection
250
+ return rest
251
+ }
252
+ return this.rowSelection
253
+ },
254
+ getScroll () {
255
+ if (this.scroll) {
256
+ return this.scroll
257
+ } else {
258
+ // 固定列需要 scroll.x 才能正确同步行高,始终设置一个有效值
259
+ let baseX = (this.x === '' || this.x === null || this.x === undefined) ? 1200 : this.x
260
+
261
+ // 解决“x 给太大导致操作列前出现大块空白”的问题:
262
+ // 当所有列都给了明确 width 时,scroll.x 取列宽总和最合理;大于总和会产生多余区域。
263
+ if (typeof baseX === 'number') {
264
+ const cols = this.innerColumns || []
265
+ const total = cols.reduce((sum, col) => {
266
+ const w = col && col.width
267
+ return sum + (typeof w === 'number' ? w : 0)
268
+ }, 0)
269
+
270
+ // rowSelection checkbox/radio 列是 antd 自动加的,给一个经验宽度避免误差
271
+ const selectionWidth = this.rowSelection ? 60 : 0
272
+
273
+ // 只有当所有列都明确给了宽度(total > 0)时才 clamp
274
+ if (total > 0) {
275
+ const minX = total + selectionWidth
276
+ if (baseX > minX) baseX = minX
277
+ }
278
+ }
279
+
280
+ if (this.height && this.needScrollY) {
281
+ const availableHeight = this.tableHeaderHeight && this.paginationHeight
282
+ ? this.getScrollHeightByHeight
283
+ : this.height - 100
284
+
285
+ if (availableHeight > 50) {
286
+ return { x: baseX, y: availableHeight }
287
+ }
288
+ }
289
+ return { x: baseX }
290
+ }
291
+ }
292
+ },
293
+ watch: {
294
+ pageSize: {
295
+ handler (pageSize) {
296
+ this.innerPageSize = pageSize
297
+ },
298
+ immediate: true
299
+ },
300
+ /**
301
+ * 监听 needHorizontalScroll 变化,强制重新渲染表格
302
+ * 当从"需要固定列"切换到"不需要固定列"或反之时,antd a-table 需要完全重新渲染
303
+ */
304
+ needHorizontalScroll (newVal, oldVal) {
305
+ if (newVal !== oldVal) {
306
+ // 更新 key 强制 Vue 销毁并重建 a-table 组件
307
+ this.tableRenderKey++
308
+ // 使用重试机制确保固定列完全渲染
309
+ this.$nextTick(() => {
310
+ this.retrySyncFixedColumns(newVal)
311
+ })
312
+ }
313
+ }
314
+ },
315
+ methods: {
316
+ onShowSizeChange (current, pageSize) {
317
+ this.innerCurrentPage = current
318
+ this.innerPageSize = pageSize
319
+ this.$emit('change-page', current, pageSize)
320
+ },
321
+ setPaginationHeight () {
322
+ this.$nextTick(() => {
323
+ const el = this.$el.querySelector('.g-table__pagination')
324
+ if (el) {
325
+ const { height } = el.getBoundingClientRect()
326
+ this.paginationHeight = height
327
+ }
328
+ })
329
+ },
330
+ setTableHeaderHeight () {
331
+ this.$nextTick(() => {
332
+ const el = this.$el.querySelector('.ant-table-header')
333
+ if (!el) return
334
+ const { height } = el.getBoundingClientRect()
335
+ this.tableHeaderHeight = height
336
+ })
337
+ },
338
+ setTableTbodyHeight () {
339
+ this.$nextTick(() => {
340
+ this.setTableHeaderHeight()
341
+ })
342
+ },
343
+ setRowClassName (record, idx) {
344
+ return idx % 2 === 0 ? 'g-table__row--even' : 'g-table__row--odd'
345
+ },
346
+ onChangePagination (page, pageSize) {
347
+ this.innerCurrentPage = page
348
+ this.innerPageSize = pageSize
349
+ this.$emit('change-page', page, pageSize)
350
+ },
351
+ syncFixedColumns () {
352
+ // 强制 ant-design-vue 重新计算固定列的宽度
353
+ this.$nextTick(() => {
354
+ const tableEl = this.$el.querySelector('.ant-table')
355
+ if (tableEl) {
356
+ // 触发窗口 resize 事件,让 ant-design-vue 重新计算
357
+ window.dispatchEvent(new Event('resize'))
358
+ }
359
+ })
360
+ },
361
+ /**
362
+ * 带重试的固定列同步机制
363
+ * antd 的 a-table 在 key 变化后重新渲染固定列需要一定时间,
364
+ * 这里通过重试确保固定列渲染完成后再同步。
365
+ * @param {boolean} needFixed - 是否需要固定列
366
+ * @param {number} retries - 当前重试次数
367
+ */
368
+ retrySyncFixedColumns (needFixed, retries = 0) {
369
+ const MAX_RETRIES = 5
370
+ const DELAY = 100 // 每次延迟 100ms
371
+
372
+ setTimeout(() => {
373
+ this.syncFixedColumns()
374
+ this.syncHeaderTableWidth()
375
+ this.bindScrollSync()
376
+
377
+ // 如果需要固定列,检查是否已渲染
378
+ if (needFixed && retries < MAX_RETRIES) {
379
+ const hasFixedLeft = this.$el.querySelector('.ant-table-fixed-left .ant-table-body tbody tr')
380
+ const hasFixedRight = this.$el.querySelector('.ant-table-fixed-right .ant-table-body tbody tr')
381
+
382
+ // 如果有固定列配置但还没渲染出来,继续重试
383
+ const hasFixedConfig = this.innerColumns.some(c => c.fixed) || (this.rowSelection && this.rowSelection.fixed)
384
+ if (hasFixedConfig && !hasFixedLeft && !hasFixedRight) {
385
+ this.retrySyncFixedColumns(needFixed, retries + 1)
386
+ }
387
+ }
388
+ }, DELAY)
389
+ },
390
+ /**
391
+ * 修复 x: 'max-content' 场景下表头不跟着横向滚动的问题。
392
+ * 原因:ant-design-vue 的 header table 和 body table 各自算 max-content,
393
+ * header 按表头文字算、body 按实际数据算,二者宽度不同时 header 就"滚不动"。
394
+ * 方案:数据渲染后把 header table 的 width 强制设成和 body table 一样。
395
+ */
396
+ syncHeaderTableWidth () {
397
+ this.$nextTick(() => {
398
+ const headerTable = this.$el.querySelector('.ant-table-scroll .ant-table-header table')
399
+ const bodyTable = this.$el.querySelector('.ant-table-scroll .ant-table-body table')
400
+ if (!headerTable || !bodyTable) return
401
+
402
+ const bodyW = bodyTable.getBoundingClientRect().width
403
+ const headerW = headerTable.getBoundingClientRect().width
404
+
405
+ // 始终同步表头宽度到表体宽度,确保窗口变大/变小时都能正确响应
406
+ // 只有当宽度差异超过 2px 时才更新,避免频繁设置样式
407
+ if (Math.abs(bodyW - headerW) > 2) {
408
+ headerTable.style.width = `${bodyW}px`
409
+ headerTable.style.minWidth = `${bodyW}px`
410
+ }
411
+ })
412
+ },
413
+ /**
414
+ * 监听表体横向滚动,同步到表头(防止 antd 自带同步失效)
415
+ */
416
+ bindScrollSync () {
417
+ const body = this.$el.querySelector('.ant-table-scroll .ant-table-body')
418
+ const header = this.$el.querySelector('.ant-table-scroll .ant-table-header')
419
+ if (!body || !header) return
420
+
421
+ if (this._scrollHandler) return // 已绑定
422
+ this._scrollHandler = () => {
423
+ header.scrollLeft = body.scrollLeft
424
+ }
425
+ body.addEventListener('scroll', this._scrollHandler, { passive: true })
426
+ },
427
+ unbindScrollSync () {
428
+ const body = this.$el.querySelector('.ant-table-scroll .ant-table-body')
429
+ if (body && this._scrollHandler) {
430
+ body.removeEventListener('scroll', this._scrollHandler)
431
+ this._scrollHandler = null
432
+ }
433
+ },
434
+ /**
435
+ * 测量容器宽度
436
+ */
437
+ measureContainerWidth () {
438
+ const wrapper = this.$refs.tableWrapper
439
+ if (wrapper) {
440
+ this.containerWidth = wrapper.clientWidth
441
+ }
442
+ },
443
+ /**
444
+ * 使用 ResizeObserver 监听容器宽度变化
445
+ */
446
+ observeContainerWidth () {
447
+ const wrapper = this.$refs.tableWrapper
448
+ if (!wrapper || typeof ResizeObserver === 'undefined') return
449
+
450
+ if (this._containerResizeObserver) return // 已绑定
451
+
452
+ this._containerResizeObserver = new ResizeObserver((entries) => {
453
+ for (const entry of entries) {
454
+ const newWidth = entry.contentRect.width
455
+ // 只有宽度变化超过阈值才触发更新,避免微小变化导致频繁重渲染
456
+ if (Math.abs(newWidth - this.containerWidth) > 10) {
457
+ const oldNeedScroll = this.needHorizontalScroll
458
+ this.containerWidth = newWidth
459
+ // 容器宽度变化会触发 needHorizontalScroll 的重新计算
460
+ // needHorizontalScroll 的 watcher 会处理表格重新渲染和列宽同步
461
+ // 这里不需要直接调用 syncFixedColumns,避免与 watcher 的执行时机冲突
462
+ // 无论 needHorizontalScroll 是否变化,都立即同步表头宽度
463
+ // 确保窗口变大/变小时表头都能及时响应
464
+ this.$nextTick(() => {
465
+ // 使用 requestAnimationFrame 确保在浏览器重绘后同步表头宽度
466
+ // 这样能确保表体宽度已经更新完成
467
+ requestAnimationFrame(() => {
468
+ this.syncHeaderTableWidth()
469
+ })
470
+
471
+ // 如果 needHorizontalScroll 状态没有变化,说明只是列宽需要调整,直接同步
472
+ // 如果状态变化了,watcher 会处理重新渲染
473
+ if (this.needHorizontalScroll === oldNeedScroll) {
474
+ // 防抖:延迟同步固定列,避免频繁调用
475
+ if (this._resizeDebounceTimer) {
476
+ clearTimeout(this._resizeDebounceTimer)
477
+ }
478
+ this._resizeDebounceTimer = setTimeout(() => {
479
+ this.syncFixedColumns()
480
+ this.syncHeaderTableWidth()
481
+ this.bindScrollSync()
482
+ }, 150)
483
+ }
484
+ })
485
+ }
486
+ }
487
+ })
488
+ this._containerResizeObserver.observe(wrapper)
489
+ },
490
+ /**
491
+ * 断开容器宽度监听
492
+ */
493
+ unobserveContainerWidth () {
494
+ if (this._containerResizeObserver) {
495
+ this._containerResizeObserver.disconnect()
496
+ this._containerResizeObserver = null
497
+ }
498
+ // 清理防抖定时器
499
+ if (this._resizeDebounceTimer) {
500
+ clearTimeout(this._resizeDebounceTimer)
501
+ this._resizeDebounceTimer = null
502
+ }
503
+ }
504
+ },
505
+ mounted() {
506
+ this.$nextTick(() => {
507
+ // 先测量容器宽度,用于智能判断是否需要 fixed 列
508
+ this.measureContainerWidth()
509
+ this.observeContainerWidth()
510
+
511
+ this.setPaginationHeight()
512
+ setTimeout(() => {
513
+ this.setTableTbodyHeight()
514
+ this.setPaginationHeight()
515
+ // 强制同步固定列和主表的列宽
516
+ this.syncFixedColumns()
517
+ // 同步表头 table 宽度(修复 max-content 场景)
518
+ this.syncHeaderTableWidth()
519
+ // 绑定横向滚动同步
520
+ this.bindScrollSync()
521
+ }, 200)
522
+ })
523
+
524
+ // 监听数据变化,重新同步列宽
525
+ this.$watch('dataSource', () => {
526
+ this.$nextTick(() => {
527
+ setTimeout(() => {
528
+ this.syncFixedColumns()
529
+ this.syncHeaderTableWidth()
530
+ this.bindScrollSync()
531
+ }, 100)
532
+ })
533
+ }, { deep: true })
534
+ },
535
+ destroyed () {
536
+ this.obs.forEach(ob => ob.disconnect())
537
+ this.unbindScrollSync()
538
+ this.unobserveContainerWidth()
539
+ }
540
+ }
541
+ </script>
542
+
543
+ <style lang="scss" scoped>
544
+ .g-table__wrapper {
545
+ display: flex;
546
+ flex-direction: column;
547
+ /* 表格区域占据剩余空间并可滚动,分页始终在底部可见 */
548
+ & > *:first-child {
549
+ flex: 1;
550
+ min-height: 0;
551
+ overflow: hidden;
552
+ }
553
+ /**
554
+ * 修复"宽屏下表格两侧出现空白"问题:
555
+ * 当视口宽度大于表格内容宽度时,主表(ant-table-scroll)不会自动拉伸,
556
+ * 而固定列(fixed-left/fixed-right)是 position:absolute 定位在容器边缘,中间就出现空白。
557
+ * 解决方案:让主表的 table 元素 min-width:100%,使其始终填满滚动容器。
558
+ */
559
+ ::v-deep .ant-table-scroll .ant-table-header table,
560
+ ::v-deep .ant-table-scroll .ant-table-body table {
561
+ min-width: 100%;
562
+ }
563
+
564
+ /**
565
+ * 修复"固定列 + scroll.x"场景下,主表(ant-table-scroll)里会渲染一份 fixed 列的占位 header/cell。
566
+ * 这份占位本来只用于计算宽度,但在某些布局下会被看见,表现为"操作列前多了一大块空白/空列"。
567
+ * 这里用 visibility:hidden 隐藏占位(不影响占位宽度与 fixed 计算),避免视觉空白。
568
+ */
569
+ ::v-deep .ant-table-scroll .ant-table-header thead > tr > th.ant-table-fixed-columns-in-body.ant-table-row-cell-last,
570
+ ::v-deep .ant-table-scroll .ant-table-body tbody > tr > td.ant-table-fixed-columns-in-body.ant-table-row-cell-last {
571
+ visibility: hidden;
572
+ }
573
+
574
+ /* 强制统一行高,确保主表和固定列对齐 */
575
+ ::v-deep .ant-table-tbody > tr > td {
576
+ height: 54px;
577
+ padding: 8px 16px;
578
+ vertical-align: middle;
579
+ box-sizing: border-box;
580
+ line-height: 38px;
581
+ }
582
+
583
+ /* 表头也统一高度和样式 */
584
+ ::v-deep .ant-table-thead > tr > th {
585
+ height: 54px;
586
+ padding: 8px 16px;
587
+ vertical-align: middle;
588
+ box-sizing: border-box;
589
+ line-height: 38px;
590
+ }
591
+
592
+ /* 分页区域固定在底部,不被挤出视口 */
593
+ .g-table__pagination {
594
+ flex-shrink: 0;
595
+ display: flex;
596
+ flex-direction: row;
597
+ justify-content: end;
598
+ border-top: unset;
599
+ padding-top: 8px;
600
+ padding-bottom: 8px;
601
+ background: #fff;
602
+ }
603
+
604
+ /* 空数据状态:使用表格内置“暂无数据”,保留表头 + 占位行,不覆盖、不绝对定位 */
605
+ .g-table__no-data {
606
+ ::v-deep .ant-table-placeholder {
607
+ color: rgba(0, 0, 0, 0.45);
608
+ font-size: 14px;
609
+ }
610
+ }
611
+ }
611
612
  </style>