foggy-data-viewer 1.0.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +273 -0
- package/dist/favicon.svg +4 -0
- package/dist/index.js +1531 -0
- package/dist/index.umd +1 -0
- package/dist/style.css +1 -0
- package/package.json +51 -0
- package/src/App.vue +469 -0
- package/src/api/viewer.ts +163 -0
- package/src/components/DataTable.test.ts +533 -0
- package/src/components/DataTable.vue +810 -0
- package/src/components/DataTableWithSearch.test.ts +628 -0
- package/src/components/DataTableWithSearch.vue +277 -0
- package/src/components/DataViewer.vue +310 -0
- package/src/components/SearchToolbar.test.ts +521 -0
- package/src/components/SearchToolbar.vue +406 -0
- package/src/components/composables/index.ts +2 -0
- package/src/components/composables/useTableSelection.test.ts +248 -0
- package/src/components/composables/useTableSelection.ts +44 -0
- package/src/components/composables/useTableSummary.test.ts +341 -0
- package/src/components/composables/useTableSummary.ts +129 -0
- package/src/components/filters/BoolFilter.vue +103 -0
- package/src/components/filters/DateRangeFilter.vue +194 -0
- package/src/components/filters/NumberRangeFilter.vue +160 -0
- package/src/components/filters/SelectFilter.vue +464 -0
- package/src/components/filters/TextFilter.vue +230 -0
- package/src/components/filters/index.ts +5 -0
- package/src/examples/EnhancedTableExample.vue +136 -0
- package/src/index.ts +32 -0
- package/src/main.ts +14 -0
- package/src/types/index.ts +159 -0
- package/src/utils/README.md +140 -0
- package/src/utils/schemaHelper.test.ts +215 -0
- package/src/utils/schemaHelper.ts +44 -0
- package/src/vite-env.d.ts +7 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, watch } from 'vue'
|
|
3
|
+
import type { EnhancedColumnSchema, SliceRequestDef } from '@/types'
|
|
4
|
+
import { TextFilter, NumberRangeFilter, DateRangeFilter, SelectFilter, BoolFilter } from './filters'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SearchToolbar 组件
|
|
8
|
+
*
|
|
9
|
+
* 独立的搜索工具栏组件,可单独使用或集成到 DataTable 的 toolbar 插槽
|
|
10
|
+
* 支持字段级快速筛选,与 DataTable 的过滤器体系保持一致
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
/** 列配置(从 QM Schema 构建) */
|
|
14
|
+
columns: EnhancedColumnSchema[]
|
|
15
|
+
/** 可搜索的字段列表(不指定则显示所有可筛选字段) */
|
|
16
|
+
searchableFields?: string[]
|
|
17
|
+
/** 当前筛选条件(v-model) */
|
|
18
|
+
modelValue?: SliceRequestDef[]
|
|
19
|
+
/** 布局方式 */
|
|
20
|
+
layout?: 'horizontal' | 'vertical'
|
|
21
|
+
/** 是否显示操作按钮(搜索、重置) */
|
|
22
|
+
showActions?: boolean
|
|
23
|
+
/** 过滤选项加载器(用于维度列) */
|
|
24
|
+
filterOptionsLoader?: (columnName: string) => Promise<{ label: string; value: string | number }[]>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
28
|
+
layout: 'horizontal',
|
|
29
|
+
showActions: true
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
(e: 'update:modelValue', value: SliceRequestDef[]): void
|
|
34
|
+
(e: 'search'): void
|
|
35
|
+
(e: 'reset'): void
|
|
36
|
+
}>()
|
|
37
|
+
|
|
38
|
+
// 内部筛选状态:按字段存储
|
|
39
|
+
const filterValues = ref<Record<string, SliceRequestDef[] | null>>({})
|
|
40
|
+
|
|
41
|
+
// 根据配置获取可搜索的列
|
|
42
|
+
const searchableColumns = computed(() => {
|
|
43
|
+
let cols = props.columns.filter(col => col.filterable !== false)
|
|
44
|
+
|
|
45
|
+
if (props.searchableFields && props.searchableFields.length > 0) {
|
|
46
|
+
cols = cols.filter(col => props.searchableFields!.includes(col.name))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return cols
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// 从 modelValue 初始化
|
|
53
|
+
watch(() => props.modelValue, (slices) => {
|
|
54
|
+
if (!slices || slices.length === 0) {
|
|
55
|
+
filterValues.value = {}
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 按 field 分组
|
|
60
|
+
const grouped: Record<string, SliceRequestDef[]> = {}
|
|
61
|
+
for (const slice of slices) {
|
|
62
|
+
if (!slice.field) continue
|
|
63
|
+
if (!grouped[slice.field]) {
|
|
64
|
+
grouped[slice.field] = []
|
|
65
|
+
}
|
|
66
|
+
grouped[slice.field].push(slice)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
filterValues.value = grouped
|
|
70
|
+
}, { immediate: true })
|
|
71
|
+
|
|
72
|
+
// 推断过滤器类型
|
|
73
|
+
function inferFilterType(col: EnhancedColumnSchema): string {
|
|
74
|
+
const type = col.type?.toUpperCase()
|
|
75
|
+
|
|
76
|
+
// 日期类型优先
|
|
77
|
+
switch (type) {
|
|
78
|
+
case 'DAY':
|
|
79
|
+
case 'DATE':
|
|
80
|
+
return 'date'
|
|
81
|
+
case 'DATETIME':
|
|
82
|
+
return 'datetime'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 使用后端返回的 filterType
|
|
86
|
+
if (col.filterType) {
|
|
87
|
+
return col.filterType
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 回退:根据 type 推断
|
|
91
|
+
switch (type) {
|
|
92
|
+
case 'NUMBER':
|
|
93
|
+
case 'MONEY':
|
|
94
|
+
case 'BIGDECIMAL':
|
|
95
|
+
case 'INTEGER':
|
|
96
|
+
case 'BIGINT':
|
|
97
|
+
case 'LONG':
|
|
98
|
+
return 'number'
|
|
99
|
+
case 'BOOL':
|
|
100
|
+
case 'BOOLEAN':
|
|
101
|
+
return 'bool'
|
|
102
|
+
case 'DICT':
|
|
103
|
+
return 'dict'
|
|
104
|
+
default:
|
|
105
|
+
return 'text'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 获取过滤器组件
|
|
110
|
+
function getFilterComponent(col: EnhancedColumnSchema) {
|
|
111
|
+
if (col.customFilterComponent) {
|
|
112
|
+
return col.customFilterComponent
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const filterType = inferFilterType(col)
|
|
116
|
+
|
|
117
|
+
switch (filterType) {
|
|
118
|
+
case 'text':
|
|
119
|
+
return TextFilter
|
|
120
|
+
case 'number':
|
|
121
|
+
return NumberRangeFilter
|
|
122
|
+
case 'date':
|
|
123
|
+
return DateRangeFilter
|
|
124
|
+
case 'datetime':
|
|
125
|
+
return DateRangeFilter
|
|
126
|
+
case 'dict':
|
|
127
|
+
return SelectFilter
|
|
128
|
+
case 'dimension':
|
|
129
|
+
return SelectFilter
|
|
130
|
+
case 'bool':
|
|
131
|
+
return BoolFilter
|
|
132
|
+
default:
|
|
133
|
+
return TextFilter
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 获取过滤器属性
|
|
138
|
+
function getFilterProps(col: EnhancedColumnSchema) {
|
|
139
|
+
const filterType = inferFilterType(col)
|
|
140
|
+
const baseProps: Record<string, unknown> = {
|
|
141
|
+
field: col.name,
|
|
142
|
+
modelValue: filterValues.value[col.name] || null,
|
|
143
|
+
'onUpdate:modelValue': (val: SliceRequestDef[] | null) => updateFilter(col.name, val)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
switch (filterType) {
|
|
147
|
+
case 'datetime':
|
|
148
|
+
return { ...baseProps, showTime: true, format: col.format }
|
|
149
|
+
case 'date':
|
|
150
|
+
return { ...baseProps, format: col.format }
|
|
151
|
+
case 'dict':
|
|
152
|
+
return {
|
|
153
|
+
...baseProps,
|
|
154
|
+
options: col.dictItems || [],
|
|
155
|
+
placeholder: `请选择${col.title || col.name}`
|
|
156
|
+
}
|
|
157
|
+
case 'dimension':
|
|
158
|
+
return {
|
|
159
|
+
...baseProps,
|
|
160
|
+
options: [],
|
|
161
|
+
loading: false,
|
|
162
|
+
placeholder: `请选择${col.title || col.name}`
|
|
163
|
+
}
|
|
164
|
+
default:
|
|
165
|
+
return {
|
|
166
|
+
...baseProps,
|
|
167
|
+
placeholder: `搜索${col.title || col.name}...`
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 更新过滤值
|
|
173
|
+
function updateFilter(columnName: string, value: SliceRequestDef[] | null) {
|
|
174
|
+
if (value === null || value.length === 0) {
|
|
175
|
+
delete filterValues.value[columnName]
|
|
176
|
+
} else {
|
|
177
|
+
filterValues.value[columnName] = value
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 立即同步到 modelValue(实时搜索)
|
|
181
|
+
emitFilterChange()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 发送过滤变更事件
|
|
185
|
+
function emitFilterChange() {
|
|
186
|
+
const allSlices: SliceRequestDef[] = []
|
|
187
|
+
for (const slices of Object.values(filterValues.value)) {
|
|
188
|
+
if (slices && slices.length > 0) {
|
|
189
|
+
allSlices.push(...slices)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
emit('update:modelValue', allSlices)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 点击搜索按钮
|
|
196
|
+
function handleSearch() {
|
|
197
|
+
emitFilterChange()
|
|
198
|
+
emit('search')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 点击重置按钮
|
|
202
|
+
function handleReset() {
|
|
203
|
+
filterValues.value = {}
|
|
204
|
+
emit('update:modelValue', [])
|
|
205
|
+
emit('reset')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 暴露方法
|
|
209
|
+
defineExpose({
|
|
210
|
+
/** 清空所有筛选 */
|
|
211
|
+
clearFilters: handleReset,
|
|
212
|
+
/** 获取当前筛选条件 */
|
|
213
|
+
getFilters: (): SliceRequestDef[] => {
|
|
214
|
+
const allSlices: SliceRequestDef[] = []
|
|
215
|
+
for (const slices of Object.values(filterValues.value)) {
|
|
216
|
+
if (slices && slices.length > 0) {
|
|
217
|
+
allSlices.push(...slices)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return allSlices
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
</script>
|
|
224
|
+
|
|
225
|
+
<template>
|
|
226
|
+
<div class="search-toolbar" :class="`layout-${layout}`">
|
|
227
|
+
<div class="search-fields">
|
|
228
|
+
<div
|
|
229
|
+
v-for="col in searchableColumns"
|
|
230
|
+
:key="col.name"
|
|
231
|
+
class="search-field-item"
|
|
232
|
+
>
|
|
233
|
+
<label class="field-label">{{ col.title || col.name }}</label>
|
|
234
|
+
<div class="field-filter">
|
|
235
|
+
<component
|
|
236
|
+
:is="getFilterComponent(col)"
|
|
237
|
+
v-bind="getFilterProps(col)"
|
|
238
|
+
/>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div v-if="showActions" class="search-actions">
|
|
244
|
+
<button class="btn btn-primary" @click="handleSearch">
|
|
245
|
+
<span class="btn-icon">🔍</span>
|
|
246
|
+
搜索
|
|
247
|
+
</button>
|
|
248
|
+
<button class="btn btn-default" @click="handleReset">
|
|
249
|
+
<span class="btn-icon">↻</span>
|
|
250
|
+
重置
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</template>
|
|
255
|
+
|
|
256
|
+
<style scoped>
|
|
257
|
+
.search-toolbar {
|
|
258
|
+
display: flex;
|
|
259
|
+
gap: 16px;
|
|
260
|
+
padding: 16px;
|
|
261
|
+
background: #fafafa;
|
|
262
|
+
border-radius: 4px;
|
|
263
|
+
border: 1px solid #e4e7ed;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* 水平布局(默认) */
|
|
267
|
+
.search-toolbar.layout-horizontal {
|
|
268
|
+
flex-direction: row;
|
|
269
|
+
align-items: flex-end;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.search-toolbar.layout-horizontal .search-fields {
|
|
273
|
+
flex: 1;
|
|
274
|
+
display: flex;
|
|
275
|
+
flex-wrap: wrap;
|
|
276
|
+
gap: 12px;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.search-toolbar.layout-horizontal .search-field-item {
|
|
280
|
+
min-width: 200px;
|
|
281
|
+
flex: 0 1 auto;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* 垂直布局 */
|
|
285
|
+
.search-toolbar.layout-vertical {
|
|
286
|
+
flex-direction: column;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.search-toolbar.layout-vertical .search-fields {
|
|
290
|
+
display: flex;
|
|
291
|
+
flex-direction: column;
|
|
292
|
+
gap: 12px;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.search-toolbar.layout-vertical .search-field-item {
|
|
296
|
+
display: flex;
|
|
297
|
+
align-items: center;
|
|
298
|
+
gap: 12px;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.search-toolbar.layout-vertical .field-label {
|
|
302
|
+
min-width: 100px;
|
|
303
|
+
text-align: right;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.search-toolbar.layout-vertical .field-filter {
|
|
307
|
+
flex: 1;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* 字段样式 */
|
|
311
|
+
.search-field-item {
|
|
312
|
+
display: flex;
|
|
313
|
+
flex-direction: column;
|
|
314
|
+
gap: 6px;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.field-label {
|
|
318
|
+
font-size: 13px;
|
|
319
|
+
font-weight: 500;
|
|
320
|
+
color: #606266;
|
|
321
|
+
white-space: nowrap;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.field-filter {
|
|
325
|
+
min-width: 0;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* 操作按钮 */
|
|
329
|
+
.search-actions {
|
|
330
|
+
display: flex;
|
|
331
|
+
gap: 8px;
|
|
332
|
+
flex-shrink: 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.btn {
|
|
336
|
+
display: flex;
|
|
337
|
+
align-items: center;
|
|
338
|
+
gap: 4px;
|
|
339
|
+
padding: 8px 16px;
|
|
340
|
+
border: 1px solid transparent;
|
|
341
|
+
border-radius: 4px;
|
|
342
|
+
font-size: 13px;
|
|
343
|
+
font-weight: 500;
|
|
344
|
+
cursor: pointer;
|
|
345
|
+
transition: all 0.2s;
|
|
346
|
+
white-space: nowrap;
|
|
347
|
+
height: 32px;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.btn-icon {
|
|
351
|
+
font-size: 14px;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.btn-primary {
|
|
355
|
+
background: #409eff;
|
|
356
|
+
color: white;
|
|
357
|
+
border-color: #409eff;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.btn-primary:hover {
|
|
361
|
+
background: #66b1ff;
|
|
362
|
+
border-color: #66b1ff;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.btn-primary:active {
|
|
366
|
+
background: #3a8ee6;
|
|
367
|
+
border-color: #3a8ee6;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.btn-default {
|
|
371
|
+
background: white;
|
|
372
|
+
color: #606266;
|
|
373
|
+
border-color: #dcdfe6;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.btn-default:hover {
|
|
377
|
+
color: #409eff;
|
|
378
|
+
border-color: #c6e2ff;
|
|
379
|
+
background: #ecf5ff;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.btn-default:active {
|
|
383
|
+
color: #3a8ee6;
|
|
384
|
+
border-color: #3a8ee6;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* 响应式 */
|
|
388
|
+
@media (max-width: 768px) {
|
|
389
|
+
.search-toolbar.layout-horizontal {
|
|
390
|
+
flex-direction: column;
|
|
391
|
+
align-items: stretch;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.search-toolbar.layout-horizontal .search-fields {
|
|
395
|
+
flex-direction: column;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.search-toolbar.layout-horizontal .search-field-item {
|
|
399
|
+
min-width: 0;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.search-actions {
|
|
403
|
+
justify-content: flex-end;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
</style>
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { useTableSelection } from './useTableSelection'
|
|
3
|
+
|
|
4
|
+
describe('useTableSelection', () => {
|
|
5
|
+
describe('selectedRows', () => {
|
|
6
|
+
it('should initialize with empty array', () => {
|
|
7
|
+
const { selectedRows } = useTableSelection()
|
|
8
|
+
|
|
9
|
+
expect(selectedRows.value).toEqual([])
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('onCheckboxChange', () => {
|
|
14
|
+
it('should update selected rows when checkbox changes', () => {
|
|
15
|
+
const { selectedRows, onCheckboxChange } = useTableSelection()
|
|
16
|
+
|
|
17
|
+
const mockRecords = [
|
|
18
|
+
{ id: 1, name: 'Test 1' },
|
|
19
|
+
{ id: 2, name: 'Test 2' }
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
onCheckboxChange({ records: mockRecords })
|
|
23
|
+
|
|
24
|
+
expect(selectedRows.value).toEqual(mockRecords)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should handle single row selection', () => {
|
|
28
|
+
const { selectedRows, onCheckboxChange } = useTableSelection()
|
|
29
|
+
|
|
30
|
+
const mockRecord = [{ id: 1, name: 'Test 1' }]
|
|
31
|
+
|
|
32
|
+
onCheckboxChange({ records: mockRecord })
|
|
33
|
+
|
|
34
|
+
expect(selectedRows.value).toHaveLength(1)
|
|
35
|
+
expect(selectedRows.value[0]).toEqual(mockRecord[0])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should handle deselection', () => {
|
|
39
|
+
const { selectedRows, onCheckboxChange } = useTableSelection()
|
|
40
|
+
|
|
41
|
+
// 先选中
|
|
42
|
+
onCheckboxChange({ records: [{ id: 1, name: 'Test 1' }] })
|
|
43
|
+
expect(selectedRows.value).toHaveLength(1)
|
|
44
|
+
|
|
45
|
+
// 取消选中
|
|
46
|
+
onCheckboxChange({ records: [] })
|
|
47
|
+
expect(selectedRows.value).toHaveLength(0)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should replace previous selection', () => {
|
|
51
|
+
const { selectedRows, onCheckboxChange } = useTableSelection()
|
|
52
|
+
|
|
53
|
+
onCheckboxChange({ records: [{ id: 1, name: 'Test 1' }] })
|
|
54
|
+
expect(selectedRows.value[0].id).toBe(1)
|
|
55
|
+
|
|
56
|
+
onCheckboxChange({ records: [{ id: 2, name: 'Test 2' }] })
|
|
57
|
+
expect(selectedRows.value).toHaveLength(1)
|
|
58
|
+
expect(selectedRows.value[0].id).toBe(2)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('onCheckboxAll', () => {
|
|
63
|
+
it('should select all rows', () => {
|
|
64
|
+
const { selectedRows, onCheckboxAll } = useTableSelection()
|
|
65
|
+
|
|
66
|
+
const mockRecords = [
|
|
67
|
+
{ id: 1, name: 'Test 1' },
|
|
68
|
+
{ id: 2, name: 'Test 2' },
|
|
69
|
+
{ id: 3, name: 'Test 3' }
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
onCheckboxAll({ records: mockRecords })
|
|
73
|
+
|
|
74
|
+
expect(selectedRows.value).toEqual(mockRecords)
|
|
75
|
+
expect(selectedRows.value).toHaveLength(3)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should deselect all rows', () => {
|
|
79
|
+
const { selectedRows, onCheckboxAll } = useTableSelection()
|
|
80
|
+
|
|
81
|
+
// 先全选
|
|
82
|
+
onCheckboxAll({ records: [
|
|
83
|
+
{ id: 1, name: 'Test 1' },
|
|
84
|
+
{ id: 2, name: 'Test 2' }
|
|
85
|
+
] })
|
|
86
|
+
expect(selectedRows.value).toHaveLength(2)
|
|
87
|
+
|
|
88
|
+
// 取消全选
|
|
89
|
+
onCheckboxAll({ records: [] })
|
|
90
|
+
expect(selectedRows.value).toHaveLength(0)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should replace previous selection with all rows', () => {
|
|
94
|
+
const { selectedRows, onCheckboxChange, onCheckboxAll } = useTableSelection()
|
|
95
|
+
|
|
96
|
+
// 先选中一行
|
|
97
|
+
onCheckboxChange({ records: [{ id: 1, name: 'Test 1' }] })
|
|
98
|
+
expect(selectedRows.value).toHaveLength(1)
|
|
99
|
+
|
|
100
|
+
// 全选
|
|
101
|
+
const allRecords = [
|
|
102
|
+
{ id: 1, name: 'Test 1' },
|
|
103
|
+
{ id: 2, name: 'Test 2' },
|
|
104
|
+
{ id: 3, name: 'Test 3' }
|
|
105
|
+
]
|
|
106
|
+
onCheckboxAll({ records: allRecords })
|
|
107
|
+
expect(selectedRows.value).toHaveLength(3)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('clearSelection', () => {
|
|
112
|
+
it('should clear all selected rows', () => {
|
|
113
|
+
const { selectedRows, onCheckboxChange, clearSelection } = useTableSelection()
|
|
114
|
+
|
|
115
|
+
onCheckboxChange({ records: [
|
|
116
|
+
{ id: 1, name: 'Test 1' },
|
|
117
|
+
{ id: 2, name: 'Test 2' }
|
|
118
|
+
] })
|
|
119
|
+
expect(selectedRows.value).toHaveLength(2)
|
|
120
|
+
|
|
121
|
+
clearSelection()
|
|
122
|
+
expect(selectedRows.value).toEqual([])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should work when no rows are selected', () => {
|
|
126
|
+
const { selectedRows, clearSelection } = useTableSelection()
|
|
127
|
+
|
|
128
|
+
expect(selectedRows.value).toHaveLength(0)
|
|
129
|
+
clearSelection()
|
|
130
|
+
expect(selectedRows.value).toHaveLength(0)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should be idempotent', () => {
|
|
134
|
+
const { selectedRows, onCheckboxChange, clearSelection } = useTableSelection()
|
|
135
|
+
|
|
136
|
+
onCheckboxChange({ records: [{ id: 1, name: 'Test 1' }] })
|
|
137
|
+
|
|
138
|
+
clearSelection()
|
|
139
|
+
expect(selectedRows.value).toHaveLength(0)
|
|
140
|
+
|
|
141
|
+
clearSelection()
|
|
142
|
+
expect(selectedRows.value).toHaveLength(0)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('getSelectedCount', () => {
|
|
147
|
+
it('should return 0 when no rows selected', () => {
|
|
148
|
+
const { getSelectedCount } = useTableSelection()
|
|
149
|
+
|
|
150
|
+
expect(getSelectedCount()).toBe(0)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('should return correct count of selected rows', () => {
|
|
154
|
+
const { onCheckboxChange, getSelectedCount } = useTableSelection()
|
|
155
|
+
|
|
156
|
+
onCheckboxChange({ records: [
|
|
157
|
+
{ id: 1, name: 'Test 1' },
|
|
158
|
+
{ id: 2, name: 'Test 2' }
|
|
159
|
+
] })
|
|
160
|
+
|
|
161
|
+
expect(getSelectedCount()).toBe(2)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should update when selection changes', () => {
|
|
165
|
+
const { onCheckboxChange, clearSelection, getSelectedCount } = useTableSelection()
|
|
166
|
+
|
|
167
|
+
expect(getSelectedCount()).toBe(0)
|
|
168
|
+
|
|
169
|
+
onCheckboxChange({ records: [{ id: 1, name: 'Test 1' }] })
|
|
170
|
+
expect(getSelectedCount()).toBe(1)
|
|
171
|
+
|
|
172
|
+
onCheckboxChange({ records: [
|
|
173
|
+
{ id: 1, name: 'Test 1' },
|
|
174
|
+
{ id: 2, name: 'Test 2' }
|
|
175
|
+
] })
|
|
176
|
+
expect(getSelectedCount()).toBe(2)
|
|
177
|
+
|
|
178
|
+
clearSelection()
|
|
179
|
+
expect(getSelectedCount()).toBe(0)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('Type Safety', () => {
|
|
184
|
+
interface CustomRow {
|
|
185
|
+
customId: number
|
|
186
|
+
customName: string
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
it('should support custom row types', () => {
|
|
190
|
+
const { selectedRows, onCheckboxChange } = useTableSelection<CustomRow>()
|
|
191
|
+
|
|
192
|
+
const mockRecords: CustomRow[] = [
|
|
193
|
+
{ customId: 1, customName: 'Custom 1' },
|
|
194
|
+
{ customId: 2, customName: 'Custom 2' }
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
onCheckboxChange({ records: mockRecords })
|
|
198
|
+
|
|
199
|
+
expect(selectedRows.value).toEqual(mockRecords)
|
|
200
|
+
expect(selectedRows.value[0].customId).toBe(1)
|
|
201
|
+
expect(selectedRows.value[0].customName).toBe('Custom 1')
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('Integration', () => {
|
|
206
|
+
it('should work together in complete workflow', () => {
|
|
207
|
+
const { selectedRows, onCheckboxChange, onCheckboxAll, clearSelection, getSelectedCount } =
|
|
208
|
+
useTableSelection()
|
|
209
|
+
|
|
210
|
+
// 初始状态
|
|
211
|
+
expect(getSelectedCount()).toBe(0)
|
|
212
|
+
|
|
213
|
+
// 选中一行
|
|
214
|
+
onCheckboxChange({ records: [{ id: 1, name: 'Test 1' }] })
|
|
215
|
+
expect(getSelectedCount()).toBe(1)
|
|
216
|
+
|
|
217
|
+
// 全选
|
|
218
|
+
onCheckboxAll({ records: [
|
|
219
|
+
{ id: 1, name: 'Test 1' },
|
|
220
|
+
{ id: 2, name: 'Test 2' },
|
|
221
|
+
{ id: 3, name: 'Test 3' }
|
|
222
|
+
] })
|
|
223
|
+
expect(getSelectedCount()).toBe(3)
|
|
224
|
+
|
|
225
|
+
// 取消选中一行
|
|
226
|
+
onCheckboxChange({ records: [
|
|
227
|
+
{ id: 1, name: 'Test 1' },
|
|
228
|
+
{ id: 3, name: 'Test 3' }
|
|
229
|
+
] })
|
|
230
|
+
expect(getSelectedCount()).toBe(2)
|
|
231
|
+
|
|
232
|
+
// 清空选择
|
|
233
|
+
clearSelection()
|
|
234
|
+
expect(getSelectedCount()).toBe(0)
|
|
235
|
+
expect(selectedRows.value).toEqual([])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('should handle rapid selection changes', () => {
|
|
239
|
+
const { onCheckboxChange, getSelectedCount } = useTableSelection()
|
|
240
|
+
|
|
241
|
+
// 快速连续选择
|
|
242
|
+
for (let i = 1; i <= 5; i++) {
|
|
243
|
+
onCheckboxChange({ records: Array.from({ length: i }, (_, j) => ({ id: j + 1 })) })
|
|
244
|
+
expect(getSelectedCount()).toBe(i)
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 表格行选择逻辑
|
|
5
|
+
*/
|
|
6
|
+
export function useTableSelection<T = Record<string, unknown>>() {
|
|
7
|
+
const selectedRows = ref<T[]>([])
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* checkbox 变化处理
|
|
11
|
+
*/
|
|
12
|
+
function onCheckboxChange({ records }: { records: T[] }) {
|
|
13
|
+
selectedRows.value = records
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 全选变化处理
|
|
18
|
+
*/
|
|
19
|
+
function onCheckboxAll({ records }: { records: T[] }) {
|
|
20
|
+
selectedRows.value = records
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 清空选择
|
|
25
|
+
*/
|
|
26
|
+
function clearSelection() {
|
|
27
|
+
selectedRows.value = []
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 获取选中行数
|
|
32
|
+
*/
|
|
33
|
+
function getSelectedCount() {
|
|
34
|
+
return selectedRows.value.length
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
selectedRows,
|
|
39
|
+
onCheckboxChange,
|
|
40
|
+
onCheckboxAll,
|
|
41
|
+
clearSelection,
|
|
42
|
+
getSelectedCount
|
|
43
|
+
}
|
|
44
|
+
}
|