foggy-data-viewer 1.0.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +273 -0
  2. package/dist/favicon.svg +4 -0
  3. package/dist/index.js +1531 -0
  4. package/dist/index.umd +1 -0
  5. package/dist/style.css +1 -0
  6. package/package.json +51 -0
  7. package/src/App.vue +469 -0
  8. package/src/api/viewer.ts +163 -0
  9. package/src/components/DataTable.test.ts +533 -0
  10. package/src/components/DataTable.vue +810 -0
  11. package/src/components/DataTableWithSearch.test.ts +628 -0
  12. package/src/components/DataTableWithSearch.vue +277 -0
  13. package/src/components/DataViewer.vue +310 -0
  14. package/src/components/SearchToolbar.test.ts +521 -0
  15. package/src/components/SearchToolbar.vue +406 -0
  16. package/src/components/composables/index.ts +2 -0
  17. package/src/components/composables/useTableSelection.test.ts +248 -0
  18. package/src/components/composables/useTableSelection.ts +44 -0
  19. package/src/components/composables/useTableSummary.test.ts +341 -0
  20. package/src/components/composables/useTableSummary.ts +129 -0
  21. package/src/components/filters/BoolFilter.vue +103 -0
  22. package/src/components/filters/DateRangeFilter.vue +194 -0
  23. package/src/components/filters/NumberRangeFilter.vue +160 -0
  24. package/src/components/filters/SelectFilter.vue +464 -0
  25. package/src/components/filters/TextFilter.vue +230 -0
  26. package/src/components/filters/index.ts +5 -0
  27. package/src/examples/EnhancedTableExample.vue +136 -0
  28. package/src/index.ts +32 -0
  29. package/src/main.ts +14 -0
  30. package/src/types/index.ts +159 -0
  31. package/src/utils/README.md +140 -0
  32. package/src/utils/schemaHelper.test.ts +215 -0
  33. package/src/utils/schemaHelper.ts +44 -0
  34. package/src/vite-env.d.ts +7 -0
@@ -0,0 +1,341 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { ref } from 'vue'
3
+ import { useTableSummary } from './useTableSummary'
4
+ import type { ColumnSchema } from '@/types'
5
+
6
+ describe('useTableSummary', () => {
7
+ const mockColumns = ref<ColumnSchema[]>([
8
+ { name: 'id', type: 'INTEGER', title: 'ID' },
9
+ { name: 'name', type: 'TEXT', title: '名称' },
10
+ { name: 'amount', type: 'MONEY', title: '金额' },
11
+ { name: 'count', type: 'NUMBER', title: '数量' },
12
+ { name: 'status', type: 'TEXT', title: '状态' }
13
+ ])
14
+
15
+ describe('measureColumns', () => {
16
+ it('should identify measure columns correctly', () => {
17
+ const { measureColumns } = useTableSummary(mockColumns)
18
+
19
+ expect(measureColumns.value).toHaveLength(3) // id, amount, count
20
+ expect(measureColumns.value[0].name).toBe('id')
21
+ expect(measureColumns.value[1].name).toBe('amount')
22
+ expect(measureColumns.value[2].name).toBe('count')
23
+ })
24
+
25
+ it('should handle columns without type', () => {
26
+ const columnsWithoutType = ref<ColumnSchema[]>([
27
+ { name: 'col1', type: '', title: 'Column 1' },
28
+ { name: 'col2', type: 'MONEY', title: 'Column 2' }
29
+ ])
30
+
31
+ const { measureColumns } = useTableSummary(columnsWithoutType)
32
+
33
+ expect(measureColumns.value).toHaveLength(1)
34
+ expect(measureColumns.value[0].name).toBe('col2')
35
+ })
36
+
37
+ it('should recognize all measure types', () => {
38
+ const measureTypeColumns = ref<ColumnSchema[]>([
39
+ { name: 'col1', type: 'NUMBER', title: 'Number' },
40
+ { name: 'col2', type: 'MONEY', title: 'Money' },
41
+ { name: 'col3', type: 'BIGDECIMAL', title: 'BigDecimal' },
42
+ { name: 'col4', type: 'INTEGER', title: 'Integer' },
43
+ { name: 'col5', type: 'BIGINT', title: 'BigInt' },
44
+ { name: 'col6', type: 'LONG', title: 'Long' },
45
+ { name: 'col7', type: 'TEXT', title: 'Text' }
46
+ ])
47
+
48
+ const { measureColumns } = useTableSummary(measureTypeColumns)
49
+
50
+ expect(measureColumns.value).toHaveLength(6)
51
+ expect(measureColumns.value.map(c => c.name)).toEqual([
52
+ 'col1', 'col2', 'col3', 'col4', 'col5', 'col6'
53
+ ])
54
+ })
55
+ })
56
+
57
+ describe('calculateSelectedSummary', () => {
58
+ it('should calculate summary for selected rows', () => {
59
+ const { calculateSelectedSummary } = useTableSummary(mockColumns)
60
+
61
+ const selectedRows = [
62
+ { id: 1, name: 'Test 1', amount: 100, count: 5 },
63
+ { id: 2, name: 'Test 2', amount: 200, count: 10 },
64
+ { id: 3, name: 'Test 3', amount: 300, count: 15 }
65
+ ]
66
+
67
+ const summary = calculateSelectedSummary(selectedRows)
68
+
69
+ expect(summary._count).toBe(3)
70
+ expect(summary.id).toBe(6) // 1 + 2 + 3
71
+ expect(summary.amount).toBe(600)
72
+ expect(summary.count).toBe(30)
73
+ })
74
+
75
+ it('should handle empty selection', () => {
76
+ const { calculateSelectedSummary } = useTableSummary(mockColumns)
77
+
78
+ const summary = calculateSelectedSummary([])
79
+
80
+ expect(summary._count).toBe(0)
81
+ expect(summary.id).toBe(0)
82
+ expect(summary.amount).toBe(0)
83
+ expect(summary.count).toBe(0)
84
+ })
85
+
86
+ it('should handle null/undefined values', () => {
87
+ const { calculateSelectedSummary } = useTableSummary(mockColumns)
88
+
89
+ const selectedRows = [
90
+ { id: 1, name: 'Test 1', amount: null, count: 5 },
91
+ { id: 2, name: 'Test 2', amount: undefined, count: 10 },
92
+ { id: 3, name: 'Test 3', amount: 100, count: null }
93
+ ]
94
+
95
+ const summary = calculateSelectedSummary(selectedRows)
96
+
97
+ expect(summary._count).toBe(3)
98
+ expect(summary.id).toBe(6)
99
+ expect(summary.amount).toBe(100)
100
+ expect(summary.count).toBe(15)
101
+ })
102
+
103
+ it('should handle non-numeric values', () => {
104
+ const { calculateSelectedSummary } = useTableSummary(mockColumns)
105
+
106
+ const selectedRows = [
107
+ { id: 1, name: 'Test 1', amount: 'abc', count: 5 },
108
+ { id: 2, name: 'Test 2', amount: 100, count: 'xyz' }
109
+ ]
110
+
111
+ const summary = calculateSelectedSummary(selectedRows)
112
+
113
+ expect(summary._count).toBe(2)
114
+ expect(summary.id).toBe(3)
115
+ expect(summary.amount).toBe(100)
116
+ expect(summary.count).toBe(5)
117
+ })
118
+ })
119
+
120
+ describe('formatValue', () => {
121
+ it('should format MONEY type', () => {
122
+ const { formatValue } = useTableSummary(mockColumns)
123
+
124
+ expect(formatValue(1234.5, 'MONEY')).toBe('1,234.50')
125
+ expect(formatValue(1000000, 'MONEY')).toBe('1,000,000.00')
126
+ })
127
+
128
+ it('should format NUMBER type', () => {
129
+ const { formatValue } = useTableSummary(mockColumns)
130
+
131
+ expect(formatValue(1234.567, 'NUMBER')).toBe('1,234.57')
132
+ })
133
+
134
+ it('should format BIGDECIMAL type', () => {
135
+ const { formatValue } = useTableSummary(mockColumns)
136
+
137
+ expect(formatValue(9999.99, 'BIGDECIMAL')).toBe('9,999.99')
138
+ })
139
+
140
+ it('should format INTEGER type without decimals', () => {
141
+ const { formatValue } = useTableSummary(mockColumns)
142
+
143
+ expect(formatValue(1234, 'INTEGER')).toBe('1,234')
144
+ })
145
+
146
+ it('should handle null/undefined values', () => {
147
+ const { formatValue } = useTableSummary(mockColumns)
148
+
149
+ expect(formatValue(null, 'MONEY')).toBe('')
150
+ expect(formatValue(undefined, 'MONEY')).toBe('')
151
+ })
152
+
153
+ it('should handle non-numeric values', () => {
154
+ const { formatValue } = useTableSummary(mockColumns)
155
+
156
+ expect(formatValue('text', 'MONEY')).toBe('text')
157
+ })
158
+
159
+ it('should format zero correctly', () => {
160
+ const { formatValue } = useTableSummary(mockColumns)
161
+
162
+ expect(formatValue(0, 'MONEY')).toBe('0.00')
163
+ expect(formatValue(0, 'INTEGER')).toBe('0')
164
+ })
165
+ })
166
+
167
+ describe('generateFooterData', () => {
168
+ it('should generate footer data with two rows', () => {
169
+ const { generateFooterData, setServerSummary } = useTableSummary(mockColumns)
170
+
171
+ setServerSummary({ total: 100, amount: 10000, count: 500 })
172
+
173
+ const visibleColumns = [
174
+ { field: undefined }, // checkbox column
175
+ { field: 'id', type: 'INTEGER' },
176
+ { field: 'amount', type: 'MONEY' },
177
+ { field: 'count', type: 'NUMBER' }
178
+ ]
179
+
180
+ const selectedSummary = { _count: 3, amount: 600, count: 30 }
181
+
182
+ const footerData = generateFooterData(visibleColumns, selectedSummary)
183
+
184
+ expect(footerData).toHaveLength(2)
185
+ expect(footerData[0]).toHaveLength(4) // 选中行
186
+ expect(footerData[1]).toHaveLength(4) // 全量汇总
187
+ })
188
+
189
+ it('should show labels in first column', () => {
190
+ const { generateFooterData, setServerSummary } = useTableSummary(mockColumns)
191
+
192
+ setServerSummary({ total: 100 })
193
+
194
+ const visibleColumns = [{ field: undefined }]
195
+ const selectedSummary = { _count: 3 }
196
+
197
+ const footerData = generateFooterData(visibleColumns, selectedSummary)
198
+
199
+ expect(footerData[0][0]).toBe('选中')
200
+ expect(footerData[1][0]).toBe('合计')
201
+ })
202
+
203
+ it('should show count in second column', () => {
204
+ const { generateFooterData, setServerSummary } = useTableSummary(mockColumns)
205
+
206
+ setServerSummary({ total: 100 })
207
+
208
+ const visibleColumns = [
209
+ { field: undefined },
210
+ { field: 'id', type: 'INTEGER' }
211
+ ]
212
+ const selectedSummary = { _count: 5 }
213
+
214
+ const footerData = generateFooterData(visibleColumns, selectedSummary)
215
+
216
+ expect(footerData[0][1]).toBe('5 条')
217
+ expect(footerData[1][1]).toBe('100 条')
218
+ })
219
+
220
+ it('should show measure values in other columns', () => {
221
+ const { generateFooterData, setServerSummary } = useTableSummary(mockColumns)
222
+
223
+ setServerSummary({ total: 100, amount: 10000 })
224
+
225
+ const visibleColumns = [
226
+ { field: undefined },
227
+ { field: 'id', type: 'INTEGER' },
228
+ { field: 'amount', type: 'MONEY' }
229
+ ]
230
+ const selectedSummary = { _count: 3, amount: 600 }
231
+
232
+ const footerData = generateFooterData(visibleColumns, selectedSummary)
233
+
234
+ expect(footerData[0][2]).toBe('600.00')
235
+ expect(footerData[1][2]).toBe('10,000.00')
236
+ })
237
+
238
+ it('should show null for non-measure columns', () => {
239
+ const { generateFooterData, setServerSummary } = useTableSummary(mockColumns)
240
+
241
+ setServerSummary({ total: 100 })
242
+
243
+ const visibleColumns = [
244
+ { field: undefined },
245
+ { field: 'id', type: 'INTEGER' },
246
+ { field: 'name', type: 'TEXT' } // non-measure
247
+ ]
248
+ const selectedSummary = { _count: 3 }
249
+
250
+ const footerData = generateFooterData(visibleColumns, selectedSummary)
251
+
252
+ expect(footerData[0][2]).toBeNull()
253
+ expect(footerData[1][2]).toBeNull()
254
+ })
255
+
256
+ it('should handle zero counts', () => {
257
+ const { generateFooterData, setServerSummary } = useTableSummary(mockColumns)
258
+
259
+ setServerSummary({ total: 0 })
260
+
261
+ const visibleColumns = [
262
+ { field: undefined },
263
+ { field: 'id', type: 'INTEGER' }
264
+ ]
265
+ const selectedSummary = { _count: 0 }
266
+
267
+ const footerData = generateFooterData(visibleColumns, selectedSummary)
268
+
269
+ expect(footerData[0][1]).toBe('0 条')
270
+ expect(footerData[1][1]).toBe('0 条')
271
+ })
272
+ })
273
+
274
+ describe('setServerSummary', () => {
275
+ it('should set server summary data', () => {
276
+ const { serverSummary, setServerSummary } = useTableSummary(mockColumns)
277
+
278
+ const summaryData = { total: 100, amount: 10000 }
279
+ setServerSummary(summaryData)
280
+
281
+ expect(serverSummary.value).toEqual(summaryData)
282
+ })
283
+
284
+ it('should handle null server summary', () => {
285
+ const { serverSummary, setServerSummary } = useTableSummary(mockColumns)
286
+
287
+ setServerSummary(null)
288
+
289
+ expect(serverSummary.value).toBeNull()
290
+ })
291
+
292
+ it('should update existing server summary', () => {
293
+ const { serverSummary, setServerSummary } = useTableSummary(mockColumns)
294
+
295
+ setServerSummary({ total: 50 })
296
+ expect(serverSummary.value?.total).toBe(50)
297
+
298
+ setServerSummary({ total: 100 })
299
+ expect(serverSummary.value?.total).toBe(100)
300
+ })
301
+ })
302
+
303
+ describe('Integration', () => {
304
+ it('should work together for complete workflow', () => {
305
+ const {
306
+ measureColumns,
307
+ calculateSelectedSummary,
308
+ generateFooterData,
309
+ setServerSummary
310
+ } = useTableSummary(mockColumns)
311
+
312
+ // 1. 识别度量列
313
+ expect(measureColumns.value).toHaveLength(3) // id, amount, count
314
+
315
+ // 2. 设置服务端汇总
316
+ setServerSummary({ total: 100, amount: 50000, count: 1000 })
317
+
318
+ // 3. 计算选中行汇总
319
+ const selectedRows = [
320
+ { id: 1, amount: 100, count: 5 },
321
+ { id: 2, amount: 200, count: 10 }
322
+ ]
323
+ const selectedSummary = calculateSelectedSummary(selectedRows)
324
+
325
+ expect(selectedSummary._count).toBe(2)
326
+ expect(selectedSummary.amount).toBe(300)
327
+
328
+ // 4. 生成 footer 数据
329
+ const visibleColumns = [
330
+ { field: undefined },
331
+ { field: 'id', type: 'INTEGER' },
332
+ { field: 'amount', type: 'MONEY' }
333
+ ]
334
+ const footerData = generateFooterData(visibleColumns, selectedSummary)
335
+
336
+ expect(footerData).toHaveLength(2)
337
+ expect(footerData[0][1]).toBe('2 条')
338
+ expect(footerData[1][1]).toBe('100 条')
339
+ })
340
+ })
341
+ })
@@ -0,0 +1,129 @@
1
+ import { ref, computed, type Ref } from 'vue'
2
+ import type { ColumnSchema } from '@/types'
3
+
4
+ /** 度量类型列表 */
5
+ const MEASURE_TYPES = ['NUMBER', 'MONEY', 'BIGDECIMAL', 'INTEGER', 'BIGINT', 'LONG']
6
+
7
+ /**
8
+ * 表格汇总数据计算逻辑
9
+ */
10
+ export function useTableSummary(columns: Ref<ColumnSchema[]>) {
11
+ /** 后端返回的全量汇总 */
12
+ const serverSummary = ref<Record<string, unknown> | null>(null)
13
+
14
+ /** 度量列(用于汇总计算) */
15
+ const measureColumns = computed(() => {
16
+ return columns.value.filter(col =>
17
+ MEASURE_TYPES.includes(col.type?.toUpperCase() || '')
18
+ )
19
+ })
20
+
21
+ /**
22
+ * 计算选中行的汇总
23
+ */
24
+ function calculateSelectedSummary(
25
+ selectedRows: Record<string, unknown>[]
26
+ ): Record<string, unknown> {
27
+ const summary: Record<string, unknown> = {
28
+ _count: selectedRows.length
29
+ }
30
+
31
+ for (const col of measureColumns.value) {
32
+ summary[col.name] = selectedRows.reduce((sum, row) => {
33
+ const val = row[col.name]
34
+ return sum + (typeof val === 'number' ? val : 0)
35
+ }, 0)
36
+ }
37
+
38
+ return summary
39
+ }
40
+
41
+ /**
42
+ * 格式化数值用于显示
43
+ */
44
+ function formatValue(value: unknown, type?: string): string {
45
+ if (value == null) return ''
46
+ if (typeof value !== 'number') return String(value)
47
+
48
+ const upperType = type?.toUpperCase()
49
+ if (upperType === 'MONEY' || upperType === 'NUMBER' || upperType === 'BIGDECIMAL') {
50
+ return value.toLocaleString('zh-CN', {
51
+ minimumFractionDigits: 2,
52
+ maximumFractionDigits: 2
53
+ })
54
+ }
55
+ return value.toLocaleString('zh-CN')
56
+ }
57
+
58
+ /**
59
+ * 生成 footer 数据(二维数组)
60
+ * @param visibleColumns 可见列配置(包含 checkbox 列)
61
+ * @param selectedSummary 选中行汇总
62
+ * @returns footer 数据,第一行选中汇总,第二行全量汇总
63
+ */
64
+ function generateFooterData(
65
+ visibleColumns: { field?: string; type?: string }[],
66
+ selectedSummary: Record<string, unknown>
67
+ ): (string | number | null)[][] {
68
+ const row1: (string | number | null)[] = [] // 选中汇总
69
+ const row2: (string | number | null)[] = [] // 全量汇总
70
+
71
+ for (let i = 0; i < visibleColumns.length; i++) {
72
+ const col = visibleColumns[i]
73
+ const field = col.field
74
+
75
+ // 第一列(checkbox 列)显示标签
76
+ if (i === 0) {
77
+ row1.push('选中')
78
+ row2.push('合计')
79
+ continue
80
+ }
81
+
82
+ // 第二列显示记录数
83
+ if (i === 1) {
84
+ const selectedCount = selectedSummary._count as number || 0
85
+ const totalCount = serverSummary.value?.total as number || 0
86
+ row1.push(`${selectedCount} 条`)
87
+ row2.push(`${totalCount} 条`)
88
+ continue
89
+ }
90
+
91
+ // 其他列:如果是度量列则显示汇总值
92
+ if (field) {
93
+ const colSchema = columns.value.find(c => c.name === field)
94
+ const isMeasure = colSchema && MEASURE_TYPES.includes(colSchema.type?.toUpperCase() || '')
95
+
96
+ if (isMeasure) {
97
+ const selectedVal = selectedSummary[field]
98
+ const serverVal = serverSummary.value?.[field]
99
+ row1.push(formatValue(selectedVal, colSchema?.type))
100
+ row2.push(formatValue(serverVal, colSchema?.type))
101
+ } else {
102
+ row1.push(null)
103
+ row2.push(null)
104
+ }
105
+ } else {
106
+ row1.push(null)
107
+ row2.push(null)
108
+ }
109
+ }
110
+
111
+ return [row1, row2]
112
+ }
113
+
114
+ /**
115
+ * 设置服务端汇总数据
116
+ */
117
+ function setServerSummary(data: Record<string, unknown> | null) {
118
+ serverSummary.value = data
119
+ }
120
+
121
+ return {
122
+ serverSummary,
123
+ measureColumns,
124
+ calculateSelectedSummary,
125
+ generateFooterData,
126
+ setServerSummary,
127
+ formatValue
128
+ }
129
+ }
@@ -0,0 +1,103 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch } from 'vue'
3
+ import type { SliceRequestDef } from '@/types'
4
+
5
+ interface Props {
6
+ field: string
7
+ modelValue?: SliceRequestDef[] | null
8
+ trueLabel?: string
9
+ falseLabel?: string
10
+ }
11
+
12
+ const props = withDefaults(defineProps<Props>(), {
13
+ trueLabel: '是',
14
+ falseLabel: '否'
15
+ })
16
+
17
+ const emit = defineEmits<{
18
+ (e: 'update:modelValue', value: SliceRequestDef[] | null): void
19
+ }>()
20
+
21
+ const selectedValue = ref<boolean | null>(null)
22
+
23
+ // 从 modelValue 初始化
24
+ watch(() => props.modelValue, (slices) => {
25
+ if (!slices || slices.length === 0) {
26
+ selectedValue.value = null
27
+ return
28
+ }
29
+
30
+ const slice = slices[0]
31
+ if (slice.op === '=') {
32
+ selectedValue.value = slice.value === true || slice.value === 'true' || slice.value === 1
33
+ } else {
34
+ selectedValue.value = null
35
+ }
36
+ }, { immediate: true })
37
+
38
+ function select(val: boolean | null) {
39
+ selectedValue.value = val
40
+ if (val === null) {
41
+ emit('update:modelValue', null)
42
+ } else {
43
+ emit('update:modelValue', [{
44
+ field: props.field,
45
+ op: '=',
46
+ value: val
47
+ }])
48
+ }
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <div class="filter-bool">
54
+ <button
55
+ :class="{ active: selectedValue === null }"
56
+ @click="select(null)"
57
+ >
58
+ 全部
59
+ </button>
60
+ <button
61
+ :class="{ active: selectedValue === true }"
62
+ @click="select(true)"
63
+ >
64
+ {{ trueLabel }}
65
+ </button>
66
+ <button
67
+ :class="{ active: selectedValue === false }"
68
+ @click="select(false)"
69
+ >
70
+ {{ falseLabel }}
71
+ </button>
72
+ </div>
73
+ </template>
74
+
75
+ <style scoped>
76
+ .filter-bool {
77
+ display: flex;
78
+ gap: 4px;
79
+ }
80
+
81
+ .filter-bool button {
82
+ height: 26px;
83
+ padding: 0 8px;
84
+ border: 1px solid #dcdfe6;
85
+ border-radius: 4px;
86
+ background: white;
87
+ font-size: 11px;
88
+ color: #606266;
89
+ cursor: pointer;
90
+ transition: all 0.2s;
91
+ }
92
+
93
+ .filter-bool button:hover {
94
+ border-color: #409eff;
95
+ color: #409eff;
96
+ }
97
+
98
+ .filter-bool button.active {
99
+ background: #409eff;
100
+ border-color: #409eff;
101
+ color: white;
102
+ }
103
+ </style>