xt-element-ui 2.1.4 → 2.1.6

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 (33) hide show
  1. package/docs/components/base/xt-date-picker.md +127 -37
  2. package/docs/components/base/xt-form-schema.md +358 -0
  3. package/docs/components/base/xt-grid-box.md +4 -4
  4. package/docs/components/base/xt-input.md +204 -42
  5. package/docs/components/base/xt-list.md +458 -458
  6. package/docs/components/base/xt-text.md +1 -0
  7. package/docs/components/base/xt-transfer-tree.md +272 -0
  8. package/docs/components/utils/config.md +285 -0
  9. package/docs/components/utils/format.md +445 -0
  10. package/lib/index.common.js +93128 -112769
  11. package/lib/index.css +1 -1
  12. package/lib/index.umd.js +93128 -112769
  13. package/lib/index.umd.min.js +1 -34
  14. package/package.json +4 -2
  15. package/src/components/xt-date-picker/component/Picker.vue +196 -0
  16. package/src/components/xt-date-picker/component/RangeDate.vue +136 -0
  17. package/src/components/xt-date-picker/index.vue +164 -144
  18. package/src/components/xt-flex-box/index.vue +1 -1
  19. package/src/components/xt-form-schema/index.js +8 -0
  20. package/src/components/xt-form-schema/index.vue +328 -0
  21. package/src/components/xt-grid-item/index.vue +2 -2
  22. package/src/components/xt-input/index.vue +224 -28
  23. package/src/components/xt-input/style/index.scss +10 -0
  24. package/src/components/xt-list/index.js +7 -7
  25. package/src/components/xt-list/index.vue +885 -885
  26. package/src/components/xt-text/index.vue +16 -4
  27. package/src/components/xt-transfer-tree/index.js +8 -0
  28. package/src/components/xt-transfer-tree/index.vue +494 -0
  29. package/src/index.js +8 -2
  30. package/src/utils/index.js +278 -1
  31. package/src/components/xt-date-picker/SearchDate.vue +0 -45
  32. package/src/components/xt-date-picker/quarter.vue +0 -154
  33. package/src/components/xt-table/index copy.vue +0 -663
@@ -1,886 +1,886 @@
1
- <template>
2
- <div class="xt-list" :class="{ 'xt-list--loading': loading }">
3
- <!-- 标题栏 -->
4
- <div class="xt-list__header" v-if="title || filterable || sortable || $slots.toolbar">
5
- <span class="xt-list__title" v-if="title">{{ title }}</span>
6
- <div class="xt-list__toolbar">
7
- <!-- 搜索 -->
8
- <div class="xt-list__search" v-if="filterable">
9
- <el-input
10
- v-model="searchText"
11
- :placeholder="filterPlaceholder"
12
- size="small"
13
- clearable
14
- prefix-icon="el-icon-search"
15
- @input="handleSearch"
16
- />
17
- </div>
18
- <!-- 排序 -->
19
- <el-button
20
- v-if="sortable && sortBy"
21
- size="small"
22
- icon="el-icon-sort"
23
- :type="currentSortOrder ? 'primary' : 'default'"
24
- plain
25
- @click="handleSortToggle"
26
- >
27
- {{ sortLabel }}
28
- </el-button>
29
- <slot name="toolbar"></slot>
30
- </div>
31
- </div>
32
-
33
- <!-- 主体区域 -->
34
- <div
35
- ref="scrollContainer"
36
- class="xt-list__body"
37
- :style="bodyStyle"
38
- @scroll="onScroll"
39
- >
40
- <!-- 虚拟滚动 phantom -->
41
- <div
42
- v-if="virtualScroll"
43
- class="xt-list__phantom"
44
- :style="{ height: totalHeight + 'px' }"
45
- >
46
- <div :style="{ paddingTop: offsetY + 'px' }">
47
- <template v-if="groupedData.length">
48
- <template v-for="(group, gIdx) in visibleGroups">
49
- <div :key="group._key" class="xt-list__group">
50
- <!-- 分组标题 -->
51
- <div
52
- class="xt-list__group-title"
53
- @click="toggleGroup(group._key)"
54
- >
55
- <span class="xt-list__group-arrow" :class="{ 'is-expanded': group._expanded }">
56
- <i class="el-icon-arrow-right"></i>
57
- </span>
58
- <slot name="group-title" :group="group" :items="group._items" :expanded="group._expanded">
59
- <span class="xt-list__group-label">{{ getGroupLabel(group) }}</span>
60
- <span class="xt-list__group-count">({{ group._items.length }})</span>
61
- </slot>
62
- </div>
63
-
64
- <!-- 分组卡片 -->
65
- <div v-if="group._expanded" :class="['xt-list__cards', 'xt-list__cols-' + columns]">
66
- <div
67
- v-for="(item, idx) in getVisibleItems(group)"
68
- :key="item._id || idx"
69
- class="xt-list__item"
70
- @click="handleItemClick(item, group._key)"
71
- >
72
- <el-card :shadow="shadow" :body-style="cardBodyStyle">
73
- <slot
74
- :item="item"
75
- :index="getItemIndex(item, group)"
76
- :group="group"
77
- >
78
- <!-- 图片区域 -->
79
- <div v-if="getImage(item)" class="xt-list__image">
80
- <img :src="getImage(item)" :alt="getConfigValue(cardConfig.title, item)" />
81
- </div>
82
- <!-- 标签 -->
83
- <div v-if="getTag(item)" class="xt-list__tag">
84
- <el-tag size="small" :type="getTagType(item)">{{ getTag(item) }}</el-tag>
85
- </div>
86
- <!-- 标题 -->
87
- <div class="xt-list__card-title" v-if="getConfigValue(cardConfig.title, item)">
88
- {{ getConfigValue(cardConfig.title, item) }}
89
- </div>
90
- <!-- 副标题 -->
91
- <div class="xt-list__card-subtitle" v-if="getConfigValue(cardConfig.subtitle, item)">
92
- {{ getConfigValue(cardConfig.subtitle, item) }}
93
- </div>
94
- <!-- 内容 -->
95
- <div class="xt-list__card-content" v-if="getConfigValue(cardConfig.content, item)">
96
- {{ getConfigValue(cardConfig.content, item) }}
97
- </div>
98
- <!-- 底部 -->
99
- <div class="xt-list__card-footer" v-if="getConfigValue(cardConfig.footer, item)">
100
- <slot name="card-footer" :item="item" :index="getItemIndex(item, group)">
101
- {{ getConfigValue(cardConfig.footer, item) }}
102
- </slot>
103
- </div>
104
- </slot>
105
- </el-card>
106
- </div>
107
- </div>
108
- </div>
109
- </template>
110
- </template>
111
- <div
112
- v-else-if="!loading"
113
- class="xt-list__empty"
114
- >
115
- <slot name="empty">
116
- <span>{{ emptyText }}</span>
117
- </slot>
118
- </div>
119
- </div>
120
- </div>
121
-
122
- <!-- 非虚拟滚动模式 -->
123
- <template v-else-if="groupedData.length">
124
- <template v-for="group in groupedData">
125
- <div :key="group._key" class="xt-list__group">
126
- <div
127
- class="xt-list__group-title"
128
- @click="toggleGroup(group._key)"
129
- >
130
- <span class="xt-list__group-arrow" :class="{ 'is-expanded': group._expanded }">
131
- <i class="el-icon-arrow-right"></i>
132
- </span>
133
- <slot name="group-title" :group="group" :items="group._items" :expanded="group._expanded">
134
- <span class="xt-list__group-label">{{ getGroupLabel(group) }}</span>
135
- <span class="xt-list__group-count">({{ group._items.length }})</span>
136
- </slot>
137
- </div>
138
- <div v-if="group._expanded" :class="['xt-list__cards', 'xt-list__cols-' + columns]">
139
- <div
140
- v-for="(item, idx) in group._items"
141
- :key="item._id || idx"
142
- class="xt-list__item"
143
- @click="handleItemClick(item, group._key)"
144
- >
145
- <el-card :shadow="shadow" :body-style="cardBodyStyle">
146
- <slot :item="item" :index="idx" :group="group">
147
- <div v-if="getImage(item)" class="xt-list__image">
148
- <img :src="getImage(item)" :alt="getConfigValue(cardConfig.title, item)" />
149
- </div>
150
- <div v-if="getTag(item)" class="xt-list__tag">
151
- <el-tag size="small" :type="getTagType(item)">{{ getTag(item) }}</el-tag>
152
- </div>
153
- <div class="xt-list__card-title" v-if="getConfigValue(cardConfig.title, item)">
154
- {{ getConfigValue(cardConfig.title, item) }}
155
- </div>
156
- <div class="xt-list__card-subtitle" v-if="getConfigValue(cardConfig.subtitle, item)">
157
- {{ getConfigValue(cardConfig.subtitle, item) }}
158
- </div>
159
- <div class="xt-list__card-content" v-if="getConfigValue(cardConfig.content, item)">
160
- {{ getConfigValue(cardConfig.content, item) }}
161
- </div>
162
- <div class="xt-list__card-footer" v-if="getConfigValue(cardConfig.footer, item)">
163
- <slot name="card-footer" :item="item" :index="idx">
164
- {{ getConfigValue(cardConfig.footer, item) }}
165
- </slot>
166
- </div>
167
- </slot>
168
- </el-card>
169
- </div>
170
- </div>
171
- </div>
172
- </template>
173
- </template>
174
-
175
- <div v-else-if="!loading" class="xt-list__empty">
176
- <slot name="empty">
177
- <span>{{ emptyText }}</span>
178
- </slot>
179
- </div>
180
-
181
- <!-- 加载更多 -->
182
- <div v-if="loadMore && hasMore && !loading" class="xt-list__loadmore">
183
- <el-button type="text" :loading="loadMoreLoading" @click="handleLoadMore">
184
- {{ loadMoreText }}
185
- </el-button>
186
- </div>
187
- </div>
188
-
189
- <!-- 加载状态 -->
190
- <div v-if="loading" class="xt-list__loading">
191
- <slot name="loading">
192
- <i class="el-icon-loading"></i>
193
- <span>加载中...</span>
194
- </slot>
195
- </div>
196
-
197
- <!-- 分页 -->
198
- <div class="xt-list__footer" v-if="showPagination">
199
- <el-pagination
200
- background
201
- small
202
- :current-page="pagination.pageNum"
203
- :page-size="pagination.pageSize"
204
- :total="total"
205
- :page-sizes="pagination.pageSizes || [10, 20, 50]"
206
- layout="total, prev, pager, next"
207
- @size-change="handleSizeChange"
208
- @current-change="handleCurrentChange"
209
- />
210
- </div>
211
- </div>
212
- </template>
213
-
214
- <script>
215
- const CARD_ITEM_HEIGHT = 160
216
- const GROUP_HEADER_HEIGHT = 44
217
-
218
- export default {
219
- name: 'XtList',
220
-
221
- props: {
222
- data: { type: Array, default: () => [] },
223
- // 分组配置
224
- groupBy: { type: String, default: '' },
225
- groupLabel: { type: [String, Function], default: '' },
226
- // 卡片配置
227
- cardConfig: {
228
- type: Object,
229
- default: () => ({
230
- title: 'title',
231
- subtitle: '',
232
- content: '',
233
- image: '',
234
- tag: '',
235
- tagType: '',
236
- footer: ''
237
- })
238
- },
239
- // 显示配置
240
- title: { type: String, default: '' },
241
- columns: { type: Number, default: 1 },
242
- shadow: { type: String, default: 'hover' },
243
- cardBodyStyle: { type: Object, default: () => ({}) },
244
- // 虚拟滚动
245
- virtualScroll: { type: Boolean, default: false },
246
- itemHeight: { type: Number, default: CARD_ITEM_HEIGHT },
247
- bufferSize: { type: Number, default: 3 },
248
- height: { type: [Number, String], default: null },
249
- maxHeight: { type: [Number, String], default: null },
250
- // 分组折叠
251
- expandAll: { type: Boolean, default: true },
252
- accordion: { type: Boolean, default: false },
253
- // 分页
254
- pagination: { type: Object, default: null },
255
- total: { type: Number, default: 0 },
256
- // 加载更多
257
- loadMore: { type: Boolean, default: false },
258
- hasMore: { type: Boolean, default: false },
259
- loadMoreText: { type: String, default: '加载更多' },
260
- loadMoreLoading: { type: Boolean, default: false },
261
- // 状态
262
- loading: { type: Boolean, default: false },
263
- emptyText: { type: String, default: '暂无数据' },
264
- // 搜索筛选
265
- filterable: { type: Boolean, default: false },
266
- filterPlaceholder: { type: String, default: '请输入搜索内容' },
267
- filterMethod: { type: Function, default: null },
268
- // 排序
269
- sortable: { type: Boolean, default: false },
270
- sortBy: { type: String, default: '' },
271
- sortOrder: { type: String, default: '' },
272
- sortMethod: { type: Function, default: null }
273
- },
274
-
275
- data() {
276
- return {
277
- groupExpandState: {},
278
- scrollTop: 0,
279
- containerHeight: 0,
280
- resizeObserver: null,
281
- rafId: null,
282
- searchText: '',
283
- currentSortOrder: this.sortOrder || ''
284
- }
285
- },
286
-
287
- computed: {
288
- showPagination() {
289
- return this.pagination && this.total > 0
290
- },
291
- sortLabel() {
292
- if (!this.currentSortOrder) return '排序'
293
- return this.currentSortOrder === 'ascending' ? '升序' : '降序'
294
- },
295
- bodyStyle() {
296
- const style = {}
297
- if (this.height) {
298
- style.height = typeof this.height === 'number' ? this.height + 'px' : this.height
299
- }
300
- if (this.maxHeight) {
301
- style.maxHeight = typeof this.maxHeight === 'number' ? this.maxHeight + 'px' : this.maxHeight
302
- }
303
- if (this.virtualScroll) {
304
- style.overflowY = 'auto'
305
- style.overflowX = 'hidden'
306
- }
307
- return style
308
- },
309
-
310
- // 筛选后的数据
311
- filteredData() {
312
- if (!this.filterable || !this.searchText) return this.data
313
- return this.data.filter(item => {
314
- if (typeof this.filterMethod === 'function') {
315
- return this.filterMethod(item, this.searchText)
316
- }
317
- return Object.values(item).some(val => {
318
- if (val == null) return false
319
- return String(val).toLowerCase().includes(this.searchText.toLowerCase())
320
- })
321
- })
322
- },
323
-
324
- // 排序后的数据
325
- sortedData() {
326
- if (!this.sortable || !this.sortBy || !this.currentSortOrder) return this.filteredData
327
- const order = this.currentSortOrder === 'ascending' ? 1 : -1
328
- const sortFn = typeof this.sortMethod === 'function'
329
- ? this.sortMethod
330
- : (a, b) => {
331
- const va = a[this.sortBy]
332
- const vb = b[this.sortBy]
333
- if (va == null && vb == null) return 0
334
- if (va == null) return -1
335
- if (vb == null) return 1
336
- if (typeof va === 'number' && typeof vb === 'number') return va - vb
337
- return String(va).localeCompare(String(vb), undefined, { numeric: true })
338
- }
339
- return [...this.filteredData].sort((a, b) => sortFn(a, b) * order)
340
- },
341
-
342
- // 分组后的数据
343
- groupedData() {
344
- const source = this.sortedData
345
- if (!source || !source.length) return []
346
-
347
- if (!this.groupBy) {
348
- return [{
349
- _key: '_default',
350
- _items: source.map((item, idx) => ({ ...item, _id: item._id || `item_${idx}` })),
351
- _expanded: true
352
- }]
353
- }
354
-
355
- const groups = {}
356
- source.forEach((item, idx) => {
357
- const value = item[this.groupBy]
358
- const key = value != null ? String(value) : '_undefined'
359
- if (!groups[key]) {
360
- groups[key] = {
361
- _key: key,
362
- _value: value,
363
- _items: []
364
- }
365
- }
366
- groups[key]._items.push({ ...item, _id: item._id || `item_${idx}` })
367
- })
368
-
369
- const result = Object.keys(groups).sort().map(key => {
370
- const g = groups[key]
371
- g._expanded = this.groupExpandState[key] !== undefined ? this.groupExpandState[key] : this.expandAll
372
- return g
373
- })
374
-
375
- return result
376
- },
377
-
378
- // 虚拟滚动:总高度
379
- totalHeight() {
380
- if (!this.virtualScroll) return 0
381
- let height = 0
382
- this.groupedData.forEach(group => {
383
- height += GROUP_HEADER_HEIGHT
384
- if (group._expanded) {
385
- const rows = Math.ceil(group._items.length / this.columns)
386
- height += rows * this.itemHeight
387
- }
388
- })
389
- return height
390
- },
391
-
392
- // 虚拟滚动:可见分组
393
- visibleGroups() {
394
- if (!this.virtualScroll) return this.groupedData
395
-
396
- const start = Math.max(0, this.scrollTop - this.bufferSize * this.itemHeight)
397
- const end = start + this.containerHeight + this.bufferSize * this.itemHeight * 2
398
- const visible = []
399
- let currentTop = 0
400
-
401
- this.groupedData.forEach(group => {
402
- const groupHeight = GROUP_HEADER_HEIGHT +
403
- (group._expanded ? Math.ceil(group._items.length / this.columns) * this.itemHeight : 0)
404
- const groupEnd = currentTop + groupHeight
405
-
406
- if (groupEnd >= start && currentTop <= end) {
407
- visible.push(group)
408
- }
409
- currentTop = groupEnd
410
- })
411
-
412
- return visible
413
- },
414
-
415
- // 虚拟滚动:偏移量
416
- offsetY() {
417
- if (!this.virtualScroll) return 0
418
- let offset = 0
419
- const start = Math.max(0, this.scrollTop - this.bufferSize * this.itemHeight)
420
-
421
- for (let i = 0; i < this.groupedData.length; i++) {
422
- const group = this.groupedData[i]
423
- const h = GROUP_HEADER_HEIGHT +
424
- (group._expanded ? Math.ceil(group._items.length / this.columns) * this.itemHeight : 0)
425
- if (offset + h <= start) {
426
- offset += h
427
- } else {
428
- break
429
- }
430
- }
431
- return offset
432
- }
433
- },
434
-
435
- watch: {
436
- data: {
437
- handler() {
438
- this.$nextTick(() => {
439
- this.updateContainerHeight()
440
- })
441
- }
442
- },
443
- expandAll(val) {
444
- const state = {}
445
- this.groupedData.forEach(g => { state[g._key] = val })
446
- this.groupExpandState = state
447
- }
448
- },
449
-
450
- mounted() {
451
- this.$nextTick(() => {
452
- this.updateContainerHeight()
453
- this.bindResizeObserver()
454
- })
455
- },
456
-
457
- beforeDestroy() {
458
- this.unbindResizeObserver()
459
- if (this.rafId && typeof cancelAnimationFrame !== 'undefined') {
460
- cancelAnimationFrame(this.rafId)
461
- this.rafId = null
462
- }
463
- },
464
-
465
- methods: {
466
- // ========== 分组展开/折叠 ==========
467
- toggleGroup(key) {
468
- const newState = { ...this.groupExpandState }
469
- const current = newState[key] !== undefined ? newState[key] : this.expandAll
470
-
471
- if (this.accordion && !current) {
472
- Object.keys(newState).forEach(k => { newState[k] = false })
473
- }
474
- newState[key] = !current
475
- this.groupExpandState = newState
476
-
477
- this.$nextTick(() => {
478
- this.updateContainerHeight()
479
- this.$emit('group-toggle', { key, expanded: newState[key] })
480
- })
481
- },
482
-
483
- getGroupLabel(group) {
484
- if (this.groupLabel === '') return group._value
485
- if (typeof this.groupLabel === 'function') return this.groupLabel(group)
486
- if (typeof this.groupLabel === 'string' && group._items[0]) {
487
- return group._items[0][this.groupLabel] || group._value
488
- }
489
- return group._value
490
- },
491
-
492
- // ========== 卡片配置解析 ==========
493
- getConfigValue(config, item) {
494
- if (!config) return ''
495
- if (typeof config === 'function') return config(item)
496
- if (typeof config === 'string') return item[config] || ''
497
- return ''
498
- },
499
-
500
- getImage(item) {
501
- return this.getConfigValue(this.cardConfig.image, item)
502
- },
503
-
504
- getTag(item) {
505
- return this.getConfigValue(this.cardConfig.tag, item)
506
- },
507
-
508
- getTagType(item) {
509
- if (this.cardConfig.tagType) {
510
- if (typeof this.cardConfig.tagType === 'function') return this.cardConfig.tagType(item)
511
- return this.cardConfig.tagType
512
- }
513
- return ''
514
- },
515
-
516
- // ========== 虚拟滚动 ==========
517
- getVisibleItems(group) {
518
- if (!this.virtualScroll) return group._items
519
-
520
- const bufferPx = this.bufferSize * this.itemHeight
521
- const start = Math.max(0, this.scrollTop - bufferPx)
522
- const end = start + this.containerHeight + bufferPx * 2
523
-
524
- let currentTop = 0
525
- let groupStart = 0
526
- for (let i = 0; i < this.groupedData.length; i++) {
527
- const g = this.groupedData[i]
528
- if (g._key === group._key) {
529
- groupStart = currentTop + GROUP_HEADER_HEIGHT
530
- break
531
- }
532
- currentTop += GROUP_HEADER_HEIGHT +
533
- (g._expanded ? Math.ceil(g._items.length / this.columns) * this.itemHeight : 0)
534
- }
535
-
536
- const groupEnd = groupStart + Math.ceil(group._items.length / this.columns) * this.itemHeight
537
-
538
- if (groupEnd <= start || groupStart >= end) return []
539
-
540
- const visibleStart = Math.max(0, Math.floor((start - groupStart) / this.itemHeight) * this.columns)
541
- const visibleEnd = Math.min(group._items.length, Math.ceil((end - groupStart) / this.itemHeight) * this.columns + bufferPx)
542
-
543
- return group._items.slice(Math.max(0, visibleStart), visibleEnd)
544
- },
545
-
546
- getItemIndex(item, group) {
547
- return group._items.indexOf(item)
548
- },
549
-
550
- onScroll() {
551
- if (!this.virtualScroll) return
552
- this.scrollTop = this.$refs.scrollContainer.scrollTop
553
- if (this.rafId) return
554
- if (typeof requestAnimationFrame === 'undefined') return
555
- this.rafId = requestAnimationFrame(() => {
556
- this.$forceUpdate()
557
- this.rafId = null
558
- })
559
- },
560
-
561
- updateContainerHeight() {
562
- if (this.$refs.scrollContainer) {
563
- this.containerHeight = this.$refs.scrollContainer.clientHeight
564
- }
565
- },
566
-
567
- bindResizeObserver() {
568
- if (typeof ResizeObserver === 'undefined') return
569
- const container = this.$refs.scrollContainer
570
- if (!container) return
571
- this.resizeObserver = new ResizeObserver(() => {
572
- this.updateContainerHeight()
573
- })
574
- this.resizeObserver.observe(container)
575
- },
576
-
577
- unbindResizeObserver() {
578
- if (this.resizeObserver) {
579
- this.resizeObserver.disconnect()
580
- this.resizeObserver = null
581
- }
582
- },
583
-
584
- // ========== 事件处理 ==========
585
- handleSearch() {
586
- this.$emit('search', this.searchText)
587
- },
588
-
589
- handleSortToggle() {
590
- const orders = ['', 'ascending', 'descending']
591
- const idx = orders.indexOf(this.currentSortOrder)
592
- this.currentSortOrder = orders[(idx + 1) % 3]
593
- this.$emit('sort-change', { prop: this.sortBy, order: this.currentSortOrder })
594
- },
595
-
596
- handleItemClick(item, groupKey) {
597
- this.$emit('click-item', { item, groupKey })
598
- },
599
-
600
- handleLoadMore() {
601
- this.$emit('load-more')
602
- },
603
-
604
- handleSizeChange(size) {
605
- this.$emit('size-change', size)
606
- },
607
-
608
- handleCurrentChange(page) {
609
- this.$emit('page-change', page)
610
- },
611
-
612
- // ========== 对外暴露方法 ==========
613
- scrollToTop() {
614
- if (this.$refs.scrollContainer) {
615
- this.$refs.scrollContainer.scrollTo({ top: 0, behavior: 'smooth' })
616
- }
617
- },
618
-
619
- expandGroup(key) {
620
- const newState = { ...this.groupExpandState }
621
- newState[key] = true
622
- this.groupExpandState = newState
623
- },
624
-
625
- collapseGroup(key) {
626
- const newState = { ...this.groupExpandState }
627
- newState[key] = false
628
- this.groupExpandState = newState
629
- },
630
-
631
- collapseAll() {
632
- const newState = {}
633
- this.groupedData.forEach(g => { newState[g._key] = false })
634
- this.groupExpandState = newState
635
- },
636
-
637
- expandAllGroups() {
638
- this.groupExpandState = {}
639
- }
640
- }
641
- }
642
- </script>
643
-
644
- <style scoped>
645
- .xt-list {
646
- width: 100%;
647
- position: relative;
648
- }
649
-
650
- /* 标题栏 */
651
- .xt-list__header {
652
- display: flex;
653
- align-items: center;
654
- justify-content: space-between;
655
- padding: 12px 0;
656
- margin-bottom: 8px;
657
- }
658
- .xt-list__title {
659
- font-size: 16px;
660
- font-weight: 600;
661
- color: #303133;
662
- position: relative;
663
- padding-left: 12px;
664
- }
665
- .xt-list__title::before {
666
- content: '';
667
- position: absolute;
668
- left: 0;
669
- top: 50%;
670
- transform: translateY(-50%);
671
- width: 3px;
672
- height: 16px;
673
- background: #409eff;
674
- border-radius: 2px;
675
- }
676
- .xt-list__toolbar {
677
- display: flex;
678
- align-items: center;
679
- gap: 8px;
680
- }
681
-
682
- /* 搜索栏 */
683
- .xt-list__search {
684
- width: 180px;
685
- flex-shrink: 0;
686
- }
687
-
688
- /* 主体区域 */
689
- .xt-list__body {
690
- position: relative;
691
- }
692
-
693
- /* 虚拟滚动 phantom */
694
- .xt-list__phantom {
695
- position: relative;
696
- box-sizing: border-box;
697
- }
698
-
699
- /* 分组 */
700
- .xt-list__group {
701
- margin-bottom: 12px;
702
- }
703
-
704
- /* 分组标题 */
705
- .xt-list__group-title {
706
- display: flex;
707
- align-items: center;
708
- padding: 10px 12px;
709
- background: #f5f7fa;
710
- border-radius: 6px;
711
- cursor: pointer;
712
- user-select: none;
713
- transition: background 0.2s;
714
- margin-bottom: 8px;
715
- }
716
- .xt-list__group-title:hover {
717
- background: #ebeef5;
718
- }
719
- .xt-list__group-title:active {
720
- background: #e4e7ed;
721
- }
722
- .xt-list__group-arrow {
723
- display: inline-flex;
724
- align-items: center;
725
- justify-content: center;
726
- width: 20px;
727
- height: 20px;
728
- margin-right: 8px;
729
- transition: transform 0.3s;
730
- color: #909399;
731
- }
732
- .xt-list__group-arrow.is-expanded {
733
- transform: rotate(90deg);
734
- }
735
- .xt-list__group-label {
736
- font-size: 14px;
737
- font-weight: 600;
738
- color: #303133;
739
- }
740
- .xt-list__group-count {
741
- font-size: 12px;
742
- color: #909399;
743
- margin-left: 6px;
744
- }
745
-
746
- /* 卡片网格 */
747
- .xt-list__cards {
748
- display: grid;
749
- gap: 12px;
750
- }
751
- .xt-list__cols-1 {
752
- grid-template-columns: 1fr;
753
- }
754
- .xt-list__cols-2 {
755
- grid-template-columns: repeat(2, 1fr);
756
- }
757
- .xt-list__cols-3 {
758
- grid-template-columns: repeat(3, 1fr);
759
- }
760
-
761
- /* 卡片项 */
762
- .xt-list__item {
763
- cursor: pointer;
764
- transition: transform 0.2s;
765
- }
766
- .xt-list__item:active {
767
- transform: scale(0.98);
768
- }
769
-
770
- /* 卡片内部 */
771
- .xt-list__image {
772
- width: 100%;
773
- margin: -20px -20px 12px;
774
- border-radius: 4px 4px 0 0;
775
- overflow: hidden;
776
- }
777
- .xt-list__image img {
778
- width: 100%;
779
- display: block;
780
- object-fit: cover;
781
- }
782
- .xt-list__tag {
783
- margin-bottom: 8px;
784
- }
785
- .xt-list__card-title {
786
- font-size: 15px;
787
- font-weight: 600;
788
- color: #303133;
789
- margin-bottom: 6px;
790
- line-height: 1.4;
791
- overflow: hidden;
792
- text-overflow: ellipsis;
793
- display: -webkit-box;
794
- -webkit-line-clamp: 2;
795
- -webkit-box-orient: vertical;
796
- }
797
- .xt-list__card-subtitle {
798
- font-size: 13px;
799
- color: #909399;
800
- margin-bottom: 8px;
801
- line-height: 1.3;
802
- }
803
- .xt-list__card-content {
804
- font-size: 14px;
805
- color: #606266;
806
- line-height: 1.6;
807
- overflow: hidden;
808
- text-overflow: ellipsis;
809
- display: -webkit-box;
810
- -webkit-line-clamp: 3;
811
- -webkit-box-orient: vertical;
812
- }
813
- .xt-list__card-footer {
814
- margin-top: 12px;
815
- padding-top: 10px;
816
- border-top: 1px solid #ebeef5;
817
- font-size: 13px;
818
- color: #909399;
819
- }
820
-
821
- /* 空状态 */
822
- .xt-list__empty {
823
- display: flex;
824
- align-items: center;
825
- justify-content: center;
826
- padding: 48px 0;
827
- color: #909399;
828
- font-size: 14px;
829
- }
830
-
831
- /* 加载状态 */
832
- .xt-list__loading {
833
- display: flex;
834
- align-items: center;
835
- justify-content: center;
836
- gap: 8px;
837
- padding: 48px 0;
838
- color: #909399;
839
- font-size: 14px;
840
- }
841
- .xt-list__loading .el-icon-loading {
842
- font-size: 20px;
843
- animation: rotating 2s linear infinite;
844
- }
845
- @keyframes rotating {
846
- from { transform: rotate(0deg); }
847
- to { transform: rotate(360deg); }
848
- }
849
-
850
- /* 加载更多 */
851
- .xt-list__loadmore {
852
- display: flex;
853
- justify-content: center;
854
- padding: 16px 0;
855
- }
856
-
857
- /* 分页 */
858
- .xt-list__footer {
859
- display: flex;
860
- justify-content: center;
861
- padding: 16px 0;
862
- }
863
-
864
- /* 滚动条 */
865
- .xt-list__body::-webkit-scrollbar {
866
- width: 6px;
867
- }
868
- .xt-list__body::-webkit-scrollbar-thumb {
869
- background: #c1c1c1;
870
- border-radius: 3px;
871
- }
872
- .xt-list__body::-webkit-scrollbar-track {
873
- background: transparent;
874
- }
875
-
876
- /* 移动端适配 */
877
- @media (max-width: 768px) {
878
- .xt-list__cols-2,
879
- .xt-list__cols-3 {
880
- grid-template-columns: 1fr;
881
- }
882
- .xt-list__group-title {
883
- padding: 10px 8px;
884
- }
885
- }
1
+ <template>
2
+ <div class="xt-list" :class="{ 'xt-list--loading': loading }">
3
+ <!-- 标题栏 -->
4
+ <div class="xt-list__header" v-if="title || filterable || sortable || $slots.toolbar">
5
+ <span class="xt-list__title" v-if="title">{{ title }}</span>
6
+ <div class="xt-list__toolbar">
7
+ <!-- 搜索 -->
8
+ <div class="xt-list__search" v-if="filterable">
9
+ <el-input
10
+ v-model="searchText"
11
+ :placeholder="filterPlaceholder"
12
+ size="small"
13
+ clearable
14
+ prefix-icon="el-icon-search"
15
+ @input="handleSearch"
16
+ />
17
+ </div>
18
+ <!-- 排序 -->
19
+ <el-button
20
+ v-if="sortable && sortBy"
21
+ size="small"
22
+ icon="el-icon-sort"
23
+ :type="currentSortOrder ? 'primary' : 'default'"
24
+ plain
25
+ @click="handleSortToggle"
26
+ >
27
+ {{ sortLabel }}
28
+ </el-button>
29
+ <slot name="toolbar"></slot>
30
+ </div>
31
+ </div>
32
+
33
+ <!-- 主体区域 -->
34
+ <div
35
+ ref="scrollContainer"
36
+ class="xt-list__body"
37
+ :style="bodyStyle"
38
+ @scroll="onScroll"
39
+ >
40
+ <!-- 虚拟滚动 phantom -->
41
+ <div
42
+ v-if="virtualScroll"
43
+ class="xt-list__phantom"
44
+ :style="{ height: totalHeight + 'px' }"
45
+ >
46
+ <div :style="{ paddingTop: offsetY + 'px' }">
47
+ <template v-if="groupedData.length">
48
+ <template v-for="(group, gIdx) in visibleGroups">
49
+ <div :key="group._key" class="xt-list__group">
50
+ <!-- 分组标题 -->
51
+ <div
52
+ class="xt-list__group-title"
53
+ @click="toggleGroup(group._key)"
54
+ >
55
+ <span class="xt-list__group-arrow" :class="{ 'is-expanded': group._expanded }">
56
+ <i class="el-icon-arrow-right"></i>
57
+ </span>
58
+ <slot name="group-title" :group="group" :items="group._items" :expanded="group._expanded">
59
+ <span class="xt-list__group-label">{{ getGroupLabel(group) }}</span>
60
+ <span class="xt-list__group-count">({{ group._items.length }})</span>
61
+ </slot>
62
+ </div>
63
+
64
+ <!-- 分组卡片 -->
65
+ <div v-if="group._expanded" :class="['xt-list__cards', 'xt-list__cols-' + columns]">
66
+ <div
67
+ v-for="(item, idx) in getVisibleItems(group)"
68
+ :key="item._id || idx"
69
+ class="xt-list__item"
70
+ @click="handleItemClick(item, group._key)"
71
+ >
72
+ <el-card :shadow="shadow" :body-style="cardBodyStyle">
73
+ <slot
74
+ :item="item"
75
+ :index="getItemIndex(item, group)"
76
+ :group="group"
77
+ >
78
+ <!-- 图片区域 -->
79
+ <div v-if="getImage(item)" class="xt-list__image">
80
+ <img :src="getImage(item)" :alt="getConfigValue(cardConfig.title, item)" />
81
+ </div>
82
+ <!-- 标签 -->
83
+ <div v-if="getTag(item)" class="xt-list__tag">
84
+ <el-tag size="small" :type="getTagType(item)">{{ getTag(item) }}</el-tag>
85
+ </div>
86
+ <!-- 标题 -->
87
+ <div class="xt-list__card-title" v-if="getConfigValue(cardConfig.title, item)">
88
+ {{ getConfigValue(cardConfig.title, item) }}
89
+ </div>
90
+ <!-- 副标题 -->
91
+ <div class="xt-list__card-subtitle" v-if="getConfigValue(cardConfig.subtitle, item)">
92
+ {{ getConfigValue(cardConfig.subtitle, item) }}
93
+ </div>
94
+ <!-- 内容 -->
95
+ <div class="xt-list__card-content" v-if="getConfigValue(cardConfig.content, item)">
96
+ {{ getConfigValue(cardConfig.content, item) }}
97
+ </div>
98
+ <!-- 底部 -->
99
+ <div class="xt-list__card-footer" v-if="getConfigValue(cardConfig.footer, item)">
100
+ <slot name="card-footer" :item="item" :index="getItemIndex(item, group)">
101
+ {{ getConfigValue(cardConfig.footer, item) }}
102
+ </slot>
103
+ </div>
104
+ </slot>
105
+ </el-card>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </template>
110
+ </template>
111
+ <div
112
+ v-else-if="!loading"
113
+ class="xt-list__empty"
114
+ >
115
+ <slot name="empty">
116
+ <span>{{ emptyText }}</span>
117
+ </slot>
118
+ </div>
119
+ </div>
120
+ </div>
121
+
122
+ <!-- 非虚拟滚动模式 -->
123
+ <template v-else-if="groupedData.length">
124
+ <template v-for="group in groupedData">
125
+ <div :key="group._key" class="xt-list__group">
126
+ <div
127
+ class="xt-list__group-title"
128
+ @click="toggleGroup(group._key)"
129
+ >
130
+ <span class="xt-list__group-arrow" :class="{ 'is-expanded': group._expanded }">
131
+ <i class="el-icon-arrow-right"></i>
132
+ </span>
133
+ <slot name="group-title" :group="group" :items="group._items" :expanded="group._expanded">
134
+ <span class="xt-list__group-label">{{ getGroupLabel(group) }}</span>
135
+ <span class="xt-list__group-count">({{ group._items.length }})</span>
136
+ </slot>
137
+ </div>
138
+ <div v-if="group._expanded" :class="['xt-list__cards', 'xt-list__cols-' + columns]">
139
+ <div
140
+ v-for="(item, idx) in group._items"
141
+ :key="item._id || idx"
142
+ class="xt-list__item"
143
+ @click="handleItemClick(item, group._key)"
144
+ >
145
+ <el-card :shadow="shadow" :body-style="cardBodyStyle">
146
+ <slot :item="item" :index="idx" :group="group">
147
+ <div v-if="getImage(item)" class="xt-list__image">
148
+ <img :src="getImage(item)" :alt="getConfigValue(cardConfig.title, item)" />
149
+ </div>
150
+ <div v-if="getTag(item)" class="xt-list__tag">
151
+ <el-tag size="small" :type="getTagType(item)">{{ getTag(item) }}</el-tag>
152
+ </div>
153
+ <div class="xt-list__card-title" v-if="getConfigValue(cardConfig.title, item)">
154
+ {{ getConfigValue(cardConfig.title, item) }}
155
+ </div>
156
+ <div class="xt-list__card-subtitle" v-if="getConfigValue(cardConfig.subtitle, item)">
157
+ {{ getConfigValue(cardConfig.subtitle, item) }}
158
+ </div>
159
+ <div class="xt-list__card-content" v-if="getConfigValue(cardConfig.content, item)">
160
+ {{ getConfigValue(cardConfig.content, item) }}
161
+ </div>
162
+ <div class="xt-list__card-footer" v-if="getConfigValue(cardConfig.footer, item)">
163
+ <slot name="card-footer" :item="item" :index="idx">
164
+ {{ getConfigValue(cardConfig.footer, item) }}
165
+ </slot>
166
+ </div>
167
+ </slot>
168
+ </el-card>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </template>
173
+ </template>
174
+
175
+ <div v-else-if="!loading" class="xt-list__empty">
176
+ <slot name="empty">
177
+ <span>{{ emptyText }}</span>
178
+ </slot>
179
+ </div>
180
+
181
+ <!-- 加载更多 -->
182
+ <div v-if="loadMore && hasMore && !loading" class="xt-list__loadmore">
183
+ <el-button type="text" :loading="loadMoreLoading" @click="handleLoadMore">
184
+ {{ loadMoreText }}
185
+ </el-button>
186
+ </div>
187
+ </div>
188
+
189
+ <!-- 加载状态 -->
190
+ <div v-if="loading" class="xt-list__loading">
191
+ <slot name="loading">
192
+ <i class="el-icon-loading"></i>
193
+ <span>加载中...</span>
194
+ </slot>
195
+ </div>
196
+
197
+ <!-- 分页 -->
198
+ <div class="xt-list__footer" v-if="showPagination">
199
+ <el-pagination
200
+ background
201
+ small
202
+ :current-page="pagination.pageNum"
203
+ :page-size="pagination.pageSize"
204
+ :total="total"
205
+ :page-sizes="pagination.pageSizes || [10, 20, 50]"
206
+ layout="total, prev, pager, next"
207
+ @size-change="handleSizeChange"
208
+ @current-change="handleCurrentChange"
209
+ />
210
+ </div>
211
+ </div>
212
+ </template>
213
+
214
+ <script>
215
+ const CARD_ITEM_HEIGHT = 160
216
+ const GROUP_HEADER_HEIGHT = 44
217
+
218
+ export default {
219
+ name: 'XtList',
220
+
221
+ props: {
222
+ data: { type: Array, default: () => [] },
223
+ // 分组配置
224
+ groupBy: { type: String, default: '' },
225
+ groupLabel: { type: [String, Function], default: '' },
226
+ // 卡片配置
227
+ cardConfig: {
228
+ type: Object,
229
+ default: () => ({
230
+ title: 'title',
231
+ subtitle: '',
232
+ content: '',
233
+ image: '',
234
+ tag: '',
235
+ tagType: '',
236
+ footer: ''
237
+ })
238
+ },
239
+ // 显示配置
240
+ title: { type: String, default: '' },
241
+ columns: { type: Number, default: 1 },
242
+ shadow: { type: String, default: 'hover' },
243
+ cardBodyStyle: { type: Object, default: () => ({}) },
244
+ // 虚拟滚动
245
+ virtualScroll: { type: Boolean, default: false },
246
+ itemHeight: { type: Number, default: CARD_ITEM_HEIGHT },
247
+ bufferSize: { type: Number, default: 3 },
248
+ height: { type: [Number, String], default: null },
249
+ maxHeight: { type: [Number, String], default: null },
250
+ // 分组折叠
251
+ expandAll: { type: Boolean, default: true },
252
+ accordion: { type: Boolean, default: false },
253
+ // 分页
254
+ pagination: { type: Object, default: null },
255
+ total: { type: Number, default: 0 },
256
+ // 加载更多
257
+ loadMore: { type: Boolean, default: false },
258
+ hasMore: { type: Boolean, default: false },
259
+ loadMoreText: { type: String, default: '加载更多' },
260
+ loadMoreLoading: { type: Boolean, default: false },
261
+ // 状态
262
+ loading: { type: Boolean, default: false },
263
+ emptyText: { type: String, default: '暂无数据' },
264
+ // 搜索筛选
265
+ filterable: { type: Boolean, default: false },
266
+ filterPlaceholder: { type: String, default: '请输入搜索内容' },
267
+ filterMethod: { type: Function, default: null },
268
+ // 排序
269
+ sortable: { type: Boolean, default: false },
270
+ sortBy: { type: String, default: '' },
271
+ sortOrder: { type: String, default: '' },
272
+ sortMethod: { type: Function, default: null }
273
+ },
274
+
275
+ data() {
276
+ return {
277
+ groupExpandState: {},
278
+ scrollTop: 0,
279
+ containerHeight: 0,
280
+ resizeObserver: null,
281
+ rafId: null,
282
+ searchText: '',
283
+ currentSortOrder: this.sortOrder || ''
284
+ }
285
+ },
286
+
287
+ computed: {
288
+ showPagination() {
289
+ return this.pagination && this.total > 0
290
+ },
291
+ sortLabel() {
292
+ if (!this.currentSortOrder) return '排序'
293
+ return this.currentSortOrder === 'ascending' ? '升序' : '降序'
294
+ },
295
+ bodyStyle() {
296
+ const style = {}
297
+ if (this.height) {
298
+ style.height = typeof this.height === 'number' ? this.height + 'px' : this.height
299
+ }
300
+ if (this.maxHeight) {
301
+ style.maxHeight = typeof this.maxHeight === 'number' ? this.maxHeight + 'px' : this.maxHeight
302
+ }
303
+ if (this.virtualScroll) {
304
+ style.overflowY = 'auto'
305
+ style.overflowX = 'hidden'
306
+ }
307
+ return style
308
+ },
309
+
310
+ // 筛选后的数据
311
+ filteredData() {
312
+ if (!this.filterable || !this.searchText) return this.data
313
+ return this.data.filter(item => {
314
+ if (typeof this.filterMethod === 'function') {
315
+ return this.filterMethod(item, this.searchText)
316
+ }
317
+ return Object.values(item).some(val => {
318
+ if (val == null) return false
319
+ return String(val).toLowerCase().includes(this.searchText.toLowerCase())
320
+ })
321
+ })
322
+ },
323
+
324
+ // 排序后的数据
325
+ sortedData() {
326
+ if (!this.sortable || !this.sortBy || !this.currentSortOrder) return this.filteredData
327
+ const order = this.currentSortOrder === 'ascending' ? 1 : -1
328
+ const sortFn = typeof this.sortMethod === 'function'
329
+ ? this.sortMethod
330
+ : (a, b) => {
331
+ const va = a[this.sortBy]
332
+ const vb = b[this.sortBy]
333
+ if (va == null && vb == null) return 0
334
+ if (va == null) return -1
335
+ if (vb == null) return 1
336
+ if (typeof va === 'number' && typeof vb === 'number') return va - vb
337
+ return String(va).localeCompare(String(vb), undefined, { numeric: true })
338
+ }
339
+ return [...this.filteredData].sort((a, b) => sortFn(a, b) * order)
340
+ },
341
+
342
+ // 分组后的数据
343
+ groupedData() {
344
+ const source = this.sortedData
345
+ if (!source || !source.length) return []
346
+
347
+ if (!this.groupBy) {
348
+ return [{
349
+ _key: '_default',
350
+ _items: source.map((item, idx) => ({ ...item, _id: item._id || `item_${idx}` })),
351
+ _expanded: true
352
+ }]
353
+ }
354
+
355
+ const groups = {}
356
+ source.forEach((item, idx) => {
357
+ const value = item[this.groupBy]
358
+ const key = value != null ? String(value) : '_undefined'
359
+ if (!groups[key]) {
360
+ groups[key] = {
361
+ _key: key,
362
+ _value: value,
363
+ _items: []
364
+ }
365
+ }
366
+ groups[key]._items.push({ ...item, _id: item._id || `item_${idx}` })
367
+ })
368
+
369
+ const result = Object.keys(groups).sort().map(key => {
370
+ const g = groups[key]
371
+ g._expanded = this.groupExpandState[key] !== undefined ? this.groupExpandState[key] : this.expandAll
372
+ return g
373
+ })
374
+
375
+ return result
376
+ },
377
+
378
+ // 虚拟滚动:总高度
379
+ totalHeight() {
380
+ if (!this.virtualScroll) return 0
381
+ let height = 0
382
+ this.groupedData.forEach(group => {
383
+ height += GROUP_HEADER_HEIGHT
384
+ if (group._expanded) {
385
+ const rows = Math.ceil(group._items.length / this.columns)
386
+ height += rows * this.itemHeight
387
+ }
388
+ })
389
+ return height
390
+ },
391
+
392
+ // 虚拟滚动:可见分组
393
+ visibleGroups() {
394
+ if (!this.virtualScroll) return this.groupedData
395
+
396
+ const start = Math.max(0, this.scrollTop - this.bufferSize * this.itemHeight)
397
+ const end = start + this.containerHeight + this.bufferSize * this.itemHeight * 2
398
+ const visible = []
399
+ let currentTop = 0
400
+
401
+ this.groupedData.forEach(group => {
402
+ const groupHeight = GROUP_HEADER_HEIGHT +
403
+ (group._expanded ? Math.ceil(group._items.length / this.columns) * this.itemHeight : 0)
404
+ const groupEnd = currentTop + groupHeight
405
+
406
+ if (groupEnd >= start && currentTop <= end) {
407
+ visible.push(group)
408
+ }
409
+ currentTop = groupEnd
410
+ })
411
+
412
+ return visible
413
+ },
414
+
415
+ // 虚拟滚动:偏移量
416
+ offsetY() {
417
+ if (!this.virtualScroll) return 0
418
+ let offset = 0
419
+ const start = Math.max(0, this.scrollTop - this.bufferSize * this.itemHeight)
420
+
421
+ for (let i = 0; i < this.groupedData.length; i++) {
422
+ const group = this.groupedData[i]
423
+ const h = GROUP_HEADER_HEIGHT +
424
+ (group._expanded ? Math.ceil(group._items.length / this.columns) * this.itemHeight : 0)
425
+ if (offset + h <= start) {
426
+ offset += h
427
+ } else {
428
+ break
429
+ }
430
+ }
431
+ return offset
432
+ }
433
+ },
434
+
435
+ watch: {
436
+ data: {
437
+ handler() {
438
+ this.$nextTick(() => {
439
+ this.updateContainerHeight()
440
+ })
441
+ }
442
+ },
443
+ expandAll(val) {
444
+ const state = {}
445
+ this.groupedData.forEach(g => { state[g._key] = val })
446
+ this.groupExpandState = state
447
+ }
448
+ },
449
+
450
+ mounted() {
451
+ this.$nextTick(() => {
452
+ this.updateContainerHeight()
453
+ this.bindResizeObserver()
454
+ })
455
+ },
456
+
457
+ beforeDestroy() {
458
+ this.unbindResizeObserver()
459
+ if (this.rafId && typeof cancelAnimationFrame !== 'undefined') {
460
+ cancelAnimationFrame(this.rafId)
461
+ this.rafId = null
462
+ }
463
+ },
464
+
465
+ methods: {
466
+ // ========== 分组展开/折叠 ==========
467
+ toggleGroup(key) {
468
+ const newState = { ...this.groupExpandState }
469
+ const current = newState[key] !== undefined ? newState[key] : this.expandAll
470
+
471
+ if (this.accordion && !current) {
472
+ Object.keys(newState).forEach(k => { newState[k] = false })
473
+ }
474
+ newState[key] = !current
475
+ this.groupExpandState = newState
476
+
477
+ this.$nextTick(() => {
478
+ this.updateContainerHeight()
479
+ this.$emit('group-toggle', { key, expanded: newState[key] })
480
+ })
481
+ },
482
+
483
+ getGroupLabel(group) {
484
+ if (this.groupLabel === '') return group._value
485
+ if (typeof this.groupLabel === 'function') return this.groupLabel(group)
486
+ if (typeof this.groupLabel === 'string' && group._items[0]) {
487
+ return group._items[0][this.groupLabel] || group._value
488
+ }
489
+ return group._value
490
+ },
491
+
492
+ // ========== 卡片配置解析 ==========
493
+ getConfigValue(config, item) {
494
+ if (!config) return ''
495
+ if (typeof config === 'function') return config(item)
496
+ if (typeof config === 'string') return item[config] || ''
497
+ return ''
498
+ },
499
+
500
+ getImage(item) {
501
+ return this.getConfigValue(this.cardConfig.image, item)
502
+ },
503
+
504
+ getTag(item) {
505
+ return this.getConfigValue(this.cardConfig.tag, item)
506
+ },
507
+
508
+ getTagType(item) {
509
+ if (this.cardConfig.tagType) {
510
+ if (typeof this.cardConfig.tagType === 'function') return this.cardConfig.tagType(item)
511
+ return this.cardConfig.tagType
512
+ }
513
+ return ''
514
+ },
515
+
516
+ // ========== 虚拟滚动 ==========
517
+ getVisibleItems(group) {
518
+ if (!this.virtualScroll) return group._items
519
+
520
+ const bufferPx = this.bufferSize * this.itemHeight
521
+ const start = Math.max(0, this.scrollTop - bufferPx)
522
+ const end = start + this.containerHeight + bufferPx * 2
523
+
524
+ let currentTop = 0
525
+ let groupStart = 0
526
+ for (let i = 0; i < this.groupedData.length; i++) {
527
+ const g = this.groupedData[i]
528
+ if (g._key === group._key) {
529
+ groupStart = currentTop + GROUP_HEADER_HEIGHT
530
+ break
531
+ }
532
+ currentTop += GROUP_HEADER_HEIGHT +
533
+ (g._expanded ? Math.ceil(g._items.length / this.columns) * this.itemHeight : 0)
534
+ }
535
+
536
+ const groupEnd = groupStart + Math.ceil(group._items.length / this.columns) * this.itemHeight
537
+
538
+ if (groupEnd <= start || groupStart >= end) return []
539
+
540
+ const visibleStart = Math.max(0, Math.floor((start - groupStart) / this.itemHeight) * this.columns)
541
+ const visibleEnd = Math.min(group._items.length, Math.ceil((end - groupStart) / this.itemHeight) * this.columns + bufferPx)
542
+
543
+ return group._items.slice(Math.max(0, visibleStart), visibleEnd)
544
+ },
545
+
546
+ getItemIndex(item, group) {
547
+ return group._items.indexOf(item)
548
+ },
549
+
550
+ onScroll() {
551
+ if (!this.virtualScroll) return
552
+ this.scrollTop = this.$refs.scrollContainer.scrollTop
553
+ if (this.rafId) return
554
+ if (typeof requestAnimationFrame === 'undefined') return
555
+ this.rafId = requestAnimationFrame(() => {
556
+ this.$forceUpdate()
557
+ this.rafId = null
558
+ })
559
+ },
560
+
561
+ updateContainerHeight() {
562
+ if (this.$refs.scrollContainer) {
563
+ this.containerHeight = this.$refs.scrollContainer.clientHeight
564
+ }
565
+ },
566
+
567
+ bindResizeObserver() {
568
+ if (typeof ResizeObserver === 'undefined') return
569
+ const container = this.$refs.scrollContainer
570
+ if (!container) return
571
+ this.resizeObserver = new ResizeObserver(() => {
572
+ this.updateContainerHeight()
573
+ })
574
+ this.resizeObserver.observe(container)
575
+ },
576
+
577
+ unbindResizeObserver() {
578
+ if (this.resizeObserver) {
579
+ this.resizeObserver.disconnect()
580
+ this.resizeObserver = null
581
+ }
582
+ },
583
+
584
+ // ========== 事件处理 ==========
585
+ handleSearch() {
586
+ this.$emit('search', this.searchText)
587
+ },
588
+
589
+ handleSortToggle() {
590
+ const orders = ['', 'ascending', 'descending']
591
+ const idx = orders.indexOf(this.currentSortOrder)
592
+ this.currentSortOrder = orders[(idx + 1) % 3]
593
+ this.$emit('sort-change', { prop: this.sortBy, order: this.currentSortOrder })
594
+ },
595
+
596
+ handleItemClick(item, groupKey) {
597
+ this.$emit('click-item', { item, groupKey })
598
+ },
599
+
600
+ handleLoadMore() {
601
+ this.$emit('load-more')
602
+ },
603
+
604
+ handleSizeChange(size) {
605
+ this.$emit('size-change', size)
606
+ },
607
+
608
+ handleCurrentChange(page) {
609
+ this.$emit('page-change', page)
610
+ },
611
+
612
+ // ========== 对外暴露方法 ==========
613
+ scrollToTop() {
614
+ if (this.$refs.scrollContainer) {
615
+ this.$refs.scrollContainer.scrollTo({ top: 0, behavior: 'smooth' })
616
+ }
617
+ },
618
+
619
+ expandGroup(key) {
620
+ const newState = { ...this.groupExpandState }
621
+ newState[key] = true
622
+ this.groupExpandState = newState
623
+ },
624
+
625
+ collapseGroup(key) {
626
+ const newState = { ...this.groupExpandState }
627
+ newState[key] = false
628
+ this.groupExpandState = newState
629
+ },
630
+
631
+ collapseAll() {
632
+ const newState = {}
633
+ this.groupedData.forEach(g => { newState[g._key] = false })
634
+ this.groupExpandState = newState
635
+ },
636
+
637
+ expandAllGroups() {
638
+ this.groupExpandState = {}
639
+ }
640
+ }
641
+ }
642
+ </script>
643
+
644
+ <style scoped>
645
+ .xt-list {
646
+ width: 100%;
647
+ position: relative;
648
+ }
649
+
650
+ /* 标题栏 */
651
+ .xt-list__header {
652
+ display: flex;
653
+ align-items: center;
654
+ justify-content: space-between;
655
+ padding: 12px 0;
656
+ margin-bottom: 8px;
657
+ }
658
+ .xt-list__title {
659
+ font-size: 16px;
660
+ font-weight: 600;
661
+ color: #303133;
662
+ position: relative;
663
+ padding-left: 12px;
664
+ }
665
+ .xt-list__title::before {
666
+ content: '';
667
+ position: absolute;
668
+ left: 0;
669
+ top: 50%;
670
+ transform: translateY(-50%);
671
+ width: 3px;
672
+ height: 16px;
673
+ background: #409eff;
674
+ border-radius: 2px;
675
+ }
676
+ .xt-list__toolbar {
677
+ display: flex;
678
+ align-items: center;
679
+ gap: 8px;
680
+ }
681
+
682
+ /* 搜索栏 */
683
+ .xt-list__search {
684
+ width: 180px;
685
+ flex-shrink: 0;
686
+ }
687
+
688
+ /* 主体区域 */
689
+ .xt-list__body {
690
+ position: relative;
691
+ }
692
+
693
+ /* 虚拟滚动 phantom */
694
+ .xt-list__phantom {
695
+ position: relative;
696
+ box-sizing: border-box;
697
+ }
698
+
699
+ /* 分组 */
700
+ .xt-list__group {
701
+ margin-bottom: 12px;
702
+ }
703
+
704
+ /* 分组标题 */
705
+ .xt-list__group-title {
706
+ display: flex;
707
+ align-items: center;
708
+ padding: 10px 12px;
709
+ background: #f5f7fa;
710
+ border-radius: 6px;
711
+ cursor: pointer;
712
+ user-select: none;
713
+ transition: background 0.2s;
714
+ margin-bottom: 8px;
715
+ }
716
+ .xt-list__group-title:hover {
717
+ background: #ebeef5;
718
+ }
719
+ .xt-list__group-title:active {
720
+ background: #e4e7ed;
721
+ }
722
+ .xt-list__group-arrow {
723
+ display: inline-flex;
724
+ align-items: center;
725
+ justify-content: center;
726
+ width: 20px;
727
+ height: 20px;
728
+ margin-right: 8px;
729
+ transition: transform 0.3s;
730
+ color: #909399;
731
+ }
732
+ .xt-list__group-arrow.is-expanded {
733
+ transform: rotate(90deg);
734
+ }
735
+ .xt-list__group-label {
736
+ font-size: 14px;
737
+ font-weight: 600;
738
+ color: #303133;
739
+ }
740
+ .xt-list__group-count {
741
+ font-size: 12px;
742
+ color: #909399;
743
+ margin-left: 6px;
744
+ }
745
+
746
+ /* 卡片网格 */
747
+ .xt-list__cards {
748
+ display: grid;
749
+ gap: 12px;
750
+ }
751
+ .xt-list__cols-1 {
752
+ grid-template-columns: 1fr;
753
+ }
754
+ .xt-list__cols-2 {
755
+ grid-template-columns: repeat(2, 1fr);
756
+ }
757
+ .xt-list__cols-3 {
758
+ grid-template-columns: repeat(3, 1fr);
759
+ }
760
+
761
+ /* 卡片项 */
762
+ .xt-list__item {
763
+ cursor: pointer;
764
+ transition: transform 0.2s;
765
+ }
766
+ .xt-list__item:active {
767
+ transform: scale(0.98);
768
+ }
769
+
770
+ /* 卡片内部 */
771
+ .xt-list__image {
772
+ width: 100%;
773
+ margin: -20px -20px 12px;
774
+ border-radius: 4px 4px 0 0;
775
+ overflow: hidden;
776
+ }
777
+ .xt-list__image img {
778
+ width: 100%;
779
+ display: block;
780
+ object-fit: cover;
781
+ }
782
+ .xt-list__tag {
783
+ margin-bottom: 8px;
784
+ }
785
+ .xt-list__card-title {
786
+ font-size: 15px;
787
+ font-weight: 600;
788
+ color: #303133;
789
+ margin-bottom: 6px;
790
+ line-height: 1.4;
791
+ overflow: hidden;
792
+ text-overflow: ellipsis;
793
+ display: -webkit-box;
794
+ -webkit-line-clamp: 2;
795
+ -webkit-box-orient: vertical;
796
+ }
797
+ .xt-list__card-subtitle {
798
+ font-size: 13px;
799
+ color: #909399;
800
+ margin-bottom: 8px;
801
+ line-height: 1.3;
802
+ }
803
+ .xt-list__card-content {
804
+ font-size: 14px;
805
+ color: #606266;
806
+ line-height: 1.6;
807
+ overflow: hidden;
808
+ text-overflow: ellipsis;
809
+ display: -webkit-box;
810
+ -webkit-line-clamp: 3;
811
+ -webkit-box-orient: vertical;
812
+ }
813
+ .xt-list__card-footer {
814
+ margin-top: 12px;
815
+ padding-top: 10px;
816
+ border-top: 1px solid #ebeef5;
817
+ font-size: 13px;
818
+ color: #909399;
819
+ }
820
+
821
+ /* 空状态 */
822
+ .xt-list__empty {
823
+ display: flex;
824
+ align-items: center;
825
+ justify-content: center;
826
+ padding: 48px 0;
827
+ color: #909399;
828
+ font-size: 14px;
829
+ }
830
+
831
+ /* 加载状态 */
832
+ .xt-list__loading {
833
+ display: flex;
834
+ align-items: center;
835
+ justify-content: center;
836
+ gap: 8px;
837
+ padding: 48px 0;
838
+ color: #909399;
839
+ font-size: 14px;
840
+ }
841
+ .xt-list__loading .el-icon-loading {
842
+ font-size: 20px;
843
+ animation: rotating 2s linear infinite;
844
+ }
845
+ @keyframes rotating {
846
+ from { transform: rotate(0deg); }
847
+ to { transform: rotate(360deg); }
848
+ }
849
+
850
+ /* 加载更多 */
851
+ .xt-list__loadmore {
852
+ display: flex;
853
+ justify-content: center;
854
+ padding: 16px 0;
855
+ }
856
+
857
+ /* 分页 */
858
+ .xt-list__footer {
859
+ display: flex;
860
+ justify-content: center;
861
+ padding: 16px 0;
862
+ }
863
+
864
+ /* 滚动条 */
865
+ .xt-list__body::-webkit-scrollbar {
866
+ width: 6px;
867
+ }
868
+ .xt-list__body::-webkit-scrollbar-thumb {
869
+ background: #c1c1c1;
870
+ border-radius: 3px;
871
+ }
872
+ .xt-list__body::-webkit-scrollbar-track {
873
+ background: transparent;
874
+ }
875
+
876
+ /* 移动端适配 */
877
+ @media (max-width: 768px) {
878
+ .xt-list__cols-2,
879
+ .xt-list__cols-3 {
880
+ grid-template-columns: 1fr;
881
+ }
882
+ .xt-list__group-title {
883
+ padding: 10px 8px;
884
+ }
885
+ }
886
886
  </style>