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,464 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
3
+ import type { SliceRequestDef, FilterOption } from '@/types'
4
+
5
+ interface Props {
6
+ field: string
7
+ modelValue?: SliceRequestDef[] | null
8
+ options: FilterOption[]
9
+ placeholder?: string
10
+ loading?: boolean
11
+ maxDisplayItems?: number
12
+ }
13
+
14
+ const props = withDefaults(defineProps<Props>(), {
15
+ placeholder: '请选择...',
16
+ loading: false,
17
+ maxDisplayItems: 100
18
+ })
19
+
20
+ const emit = defineEmits<{
21
+ (e: 'update:modelValue', value: SliceRequestDef[] | null): void
22
+ }>()
23
+
24
+ const showDropdown = ref(false)
25
+ const isMulti = ref(false)
26
+ const searchText = ref('')
27
+ const selectedValues = ref<Set<string | number>>(new Set())
28
+ const containerRef = ref<HTMLElement>()
29
+ const highlightIndex = ref(0)
30
+ const searchInputRef = ref<HTMLInputElement>()
31
+
32
+ // 从 modelValue 初始化
33
+ watch(() => props.modelValue, (slices) => {
34
+ selectedValues.value.clear()
35
+ if (!slices || slices.length === 0) return
36
+
37
+ const slice = slices[0]
38
+ if (slice.op === 'in' && Array.isArray(slice.value)) {
39
+ isMulti.value = true
40
+ ;(slice.value as (string | number)[]).forEach(v => selectedValues.value.add(v))
41
+ } else if (slice.op === '=') {
42
+ isMulti.value = false
43
+ selectedValues.value.add(slice.value as string | number)
44
+ }
45
+ }, { immediate: true })
46
+
47
+ // 过滤后的选项
48
+ const filteredOptions = computed(() => {
49
+ if (!searchText.value.trim()) {
50
+ return props.options
51
+ }
52
+ const keyword = searchText.value.toLowerCase()
53
+ return props.options.filter(opt =>
54
+ opt.label.toLowerCase().includes(keyword) ||
55
+ String(opt.value).toLowerCase().includes(keyword)
56
+ )
57
+ })
58
+
59
+ // 显示的选项(限制数量)
60
+ const displayOptions = computed(() => {
61
+ return filteredOptions.value.slice(0, props.maxDisplayItems)
62
+ })
63
+
64
+ // 是否有更多选项
65
+ const hasMore = computed(() => {
66
+ return filteredOptions.value.length > props.maxDisplayItems
67
+ })
68
+
69
+ // 显示文本
70
+ const displayText = computed(() => {
71
+ if (selectedValues.value.size === 0) {
72
+ return ''
73
+ }
74
+ const selected = props.options.filter(opt => selectedValues.value.has(opt.value))
75
+ if (selected.length === 0) {
76
+ return Array.from(selectedValues.value).join(', ')
77
+ }
78
+ if (selected.length <= 2) {
79
+ return selected.map(s => s.label).join(', ')
80
+ }
81
+ return `已选 ${selected.length} 项`
82
+ })
83
+
84
+ function toggleDropdown() {
85
+ showDropdown.value = !showDropdown.value
86
+ if (showDropdown.value) {
87
+ searchText.value = ''
88
+ highlightIndex.value = 0
89
+ // 聚焦搜索框
90
+ setTimeout(() => searchInputRef.value?.focus(), 0)
91
+ }
92
+ }
93
+
94
+ function toggleMultiMode() {
95
+ isMulti.value = !isMulti.value
96
+ if (!isMulti.value && selectedValues.value.size > 1) {
97
+ // 切换到单选时,只保留第一个并立即查询
98
+ const first = selectedValues.value.values().next().value
99
+ selectedValues.value.clear()
100
+ if (first !== undefined) {
101
+ selectedValues.value.add(first)
102
+ }
103
+ emitChange()
104
+ }
105
+ // 切换到多选时不触发查询,等用户点击确定
106
+ }
107
+
108
+ function isSelected(opt: FilterOption): boolean {
109
+ return selectedValues.value.has(opt.value)
110
+ }
111
+
112
+ function selectItem(opt: FilterOption) {
113
+ if (isMulti.value) {
114
+ // 多选模式:只更新选中状态,不触发查询
115
+ if (selectedValues.value.has(opt.value)) {
116
+ selectedValues.value.delete(opt.value)
117
+ } else {
118
+ selectedValues.value.add(opt.value)
119
+ }
120
+ // 不调用 emitChange,等用户点击确定按钮
121
+ } else {
122
+ // 单选模式:立即触发查询并关闭
123
+ selectedValues.value.clear()
124
+ selectedValues.value.add(opt.value)
125
+ emitChange()
126
+ showDropdown.value = false
127
+ }
128
+ }
129
+
130
+ // 多选确认
131
+ function confirmMultiSelect() {
132
+ emitChange()
133
+ showDropdown.value = false
134
+ }
135
+
136
+ function emitChange() {
137
+ if (selectedValues.value.size === 0) {
138
+ emit('update:modelValue', null)
139
+ return
140
+ }
141
+
142
+ // 生成 DSL slice
143
+ if (isMulti.value || selectedValues.value.size > 1) {
144
+ emit('update:modelValue', [{
145
+ field: props.field,
146
+ op: 'in',
147
+ value: Array.from(selectedValues.value)
148
+ }])
149
+ } else {
150
+ emit('update:modelValue', [{
151
+ field: props.field,
152
+ op: '=',
153
+ value: selectedValues.value.values().next().value
154
+ }])
155
+ }
156
+ }
157
+
158
+ function clear() {
159
+ selectedValues.value.clear()
160
+ searchText.value = ''
161
+ showDropdown.value = false
162
+ emit('update:modelValue', null)
163
+ }
164
+
165
+ function onSearchInput() {
166
+ highlightIndex.value = 0
167
+ }
168
+
169
+ function onKeydown(e: KeyboardEvent) {
170
+ if (!showDropdown.value) return
171
+
172
+ const options = displayOptions.value
173
+ if (options.length === 0) return
174
+
175
+ switch (e.key) {
176
+ case 'ArrowDown':
177
+ e.preventDefault()
178
+ highlightIndex.value = (highlightIndex.value + 1) % options.length
179
+ break
180
+ case 'ArrowUp':
181
+ e.preventDefault()
182
+ highlightIndex.value = (highlightIndex.value - 1 + options.length) % options.length
183
+ break
184
+ case 'Enter':
185
+ e.preventDefault()
186
+ if (options[highlightIndex.value]) {
187
+ selectItem(options[highlightIndex.value])
188
+ }
189
+ break
190
+ case 'Escape':
191
+ showDropdown.value = false
192
+ break
193
+ }
194
+ }
195
+
196
+ // 点击外部关闭下拉
197
+ function handleClickOutside(e: MouseEvent) {
198
+ if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
199
+ if (showDropdown.value && isMulti.value) {
200
+ // 多选模式下关闭时提交选择
201
+ emitChange()
202
+ }
203
+ showDropdown.value = false
204
+ }
205
+ }
206
+
207
+ onMounted(() => {
208
+ document.addEventListener('click', handleClickOutside)
209
+ })
210
+
211
+ onUnmounted(() => {
212
+ document.removeEventListener('click', handleClickOutside)
213
+ })
214
+ </script>
215
+
216
+ <template>
217
+ <div ref="containerRef" class="filter-select">
218
+ <div class="select-input" @click="toggleDropdown">
219
+ <span v-if="displayText" class="selected-text">{{ displayText }}</span>
220
+ <span v-else class="placeholder-text">{{ placeholder }}</span>
221
+ <span class="toggle-multi" @click.stop="toggleMultiMode" :title="isMulti ? '切换单选' : '切换多选'">
222
+ {{ isMulti ? '多' : '单' }}
223
+ </span>
224
+ <span v-if="displayText" class="clear-btn" @click.stop="clear">×</span>
225
+ </div>
226
+
227
+ <div v-if="showDropdown" class="filter-dropdown">
228
+ <div class="search-box">
229
+ <input
230
+ ref="searchInputRef"
231
+ v-model="searchText"
232
+ type="text"
233
+ placeholder="搜索..."
234
+ @click.stop
235
+ @input="onSearchInput"
236
+ @keydown="onKeydown"
237
+ />
238
+ </div>
239
+
240
+ <div class="options-container">
241
+ <div v-if="loading" class="loading-hint">
242
+ 加载中...
243
+ </div>
244
+
245
+ <template v-else>
246
+ <div
247
+ v-for="(opt, index) in displayOptions"
248
+ :key="String(opt.value)"
249
+ class="filter-option"
250
+ :class="{ selected: isSelected(opt), highlighted: index === highlightIndex }"
251
+ @click="selectItem(opt)"
252
+ @mouseenter="highlightIndex = index"
253
+ >
254
+ <input
255
+ v-if="isMulti"
256
+ type="checkbox"
257
+ :checked="isSelected(opt)"
258
+ @click.stop
259
+ />
260
+ <span class="option-label">{{ opt.label }}</span>
261
+ </div>
262
+
263
+ <div v-if="displayOptions.length === 0" class="no-data">
264
+ 无匹配数据
265
+ </div>
266
+ </template>
267
+ </div>
268
+
269
+ <div v-if="hasMore" class="more-hint">
270
+ 还有 {{ filteredOptions.length - maxDisplayItems }} 条,请输入关键词搜索
271
+ </div>
272
+
273
+ <!-- 多选模式下显示确认按钮 -->
274
+ <div v-if="isMulti" class="confirm-bar">
275
+ <span class="selected-count">已选 {{ selectedValues.size }} 项</span>
276
+ <button class="confirm-btn" @click="confirmMultiSelect">确定</button>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ </template>
281
+
282
+ <style scoped>
283
+ .filter-select {
284
+ position: relative;
285
+ width: 100%;
286
+ }
287
+
288
+ .select-input {
289
+ display: flex;
290
+ align-items: center;
291
+ padding: 0 8px;
292
+ border: 1px solid #dcdfe6;
293
+ border-radius: 4px;
294
+ cursor: pointer;
295
+ background: white;
296
+ height: 26px;
297
+ gap: 4px;
298
+ }
299
+
300
+ .select-input:hover {
301
+ border-color: #c0c4cc;
302
+ }
303
+
304
+ .selected-text {
305
+ flex: 1;
306
+ font-size: 12px;
307
+ color: #606266;
308
+ overflow: hidden;
309
+ text-overflow: ellipsis;
310
+ white-space: nowrap;
311
+ }
312
+
313
+ .placeholder-text {
314
+ flex: 1;
315
+ font-size: 12px;
316
+ color: #c0c4cc;
317
+ }
318
+
319
+ .toggle-multi {
320
+ padding: 1px 4px;
321
+ font-size: 9px;
322
+ color: white;
323
+ background: #409eff;
324
+ border-radius: 2px;
325
+ cursor: pointer;
326
+ line-height: 1.2;
327
+ }
328
+
329
+ .toggle-multi:hover {
330
+ background: #337ecc;
331
+ }
332
+
333
+ .clear-btn {
334
+ color: #c0c4cc;
335
+ cursor: pointer;
336
+ font-size: 12px;
337
+ }
338
+
339
+ .clear-btn:hover {
340
+ color: #909399;
341
+ }
342
+
343
+ .filter-dropdown {
344
+ position: absolute;
345
+ top: 100%;
346
+ left: 0;
347
+ right: 0;
348
+ margin-top: 4px;
349
+ background: white;
350
+ border: 1px solid #e4e7ed;
351
+ border-radius: 4px;
352
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
353
+ z-index: 9999;
354
+ max-height: 280px;
355
+ display: flex;
356
+ flex-direction: column;
357
+ overflow: hidden;
358
+ }
359
+
360
+ .search-box {
361
+ flex-shrink: 0;
362
+ padding: 8px;
363
+ border-bottom: 1px solid #e4e7ed;
364
+ }
365
+
366
+ .search-box input {
367
+ width: 100%;
368
+ padding: 6px 8px;
369
+ border: 1px solid #dcdfe6;
370
+ border-radius: 4px;
371
+ font-size: 12px;
372
+ outline: none;
373
+ }
374
+
375
+ .search-box input:focus {
376
+ border-color: #409eff;
377
+ }
378
+
379
+ .options-container {
380
+ flex: 1;
381
+ overflow-y: auto;
382
+ min-height: 0;
383
+ }
384
+
385
+ .filter-option {
386
+ display: flex;
387
+ align-items: center;
388
+ padding: 8px 12px;
389
+ font-size: 12px;
390
+ color: #606266;
391
+ cursor: pointer;
392
+ transition: background-color 0.2s;
393
+ gap: 6px;
394
+ }
395
+
396
+ .filter-option:hover,
397
+ .filter-option.highlighted {
398
+ background-color: #f5f7fa;
399
+ }
400
+
401
+ .filter-option.selected {
402
+ color: #409eff;
403
+ background-color: #ecf5ff;
404
+ }
405
+
406
+ .filter-option input[type="checkbox"] {
407
+ margin: 0;
408
+ }
409
+
410
+ .option-label {
411
+ flex: 1;
412
+ overflow: hidden;
413
+ text-overflow: ellipsis;
414
+ white-space: nowrap;
415
+ }
416
+
417
+ .loading-hint,
418
+ .no-data,
419
+ .more-hint {
420
+ padding: 12px;
421
+ text-align: center;
422
+ font-size: 12px;
423
+ color: #909399;
424
+ }
425
+
426
+ .more-hint {
427
+ flex-shrink: 0;
428
+ padding: 8px 12px;
429
+ text-align: center;
430
+ font-size: 12px;
431
+ color: #909399;
432
+ border-top: 1px solid #e4e7ed;
433
+ background: #f5f7fa;
434
+ }
435
+
436
+ .confirm-bar {
437
+ flex-shrink: 0;
438
+ display: flex;
439
+ align-items: center;
440
+ justify-content: space-between;
441
+ padding: 8px 12px;
442
+ border-top: 1px solid #e4e7ed;
443
+ background: #fafafa;
444
+ }
445
+
446
+ .selected-count {
447
+ font-size: 12px;
448
+ color: #606266;
449
+ }
450
+
451
+ .confirm-btn {
452
+ padding: 4px 12px;
453
+ font-size: 12px;
454
+ color: white;
455
+ background: #409eff;
456
+ border: none;
457
+ border-radius: 3px;
458
+ cursor: pointer;
459
+ }
460
+
461
+ .confirm-btn:hover {
462
+ background: #337ecc;
463
+ }
464
+ </style>
@@ -0,0 +1,230 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
3
+ import type { SliceRequestDef } from '@/types'
4
+
5
+ interface Props {
6
+ field: string
7
+ modelValue?: SliceRequestDef[] | null
8
+ placeholder?: string
9
+ }
10
+
11
+ const props = withDefaults(defineProps<Props>(), {
12
+ placeholder: '搜索...'
13
+ })
14
+
15
+ const emit = defineEmits<{
16
+ (e: 'update:modelValue', value: SliceRequestDef[] | null): void
17
+ }>()
18
+
19
+ const inputValue = ref('')
20
+ const showDropdown = ref(false)
21
+ const containerRef = ref<HTMLElement>()
22
+ const highlightIndex = ref(0)
23
+
24
+ // 从 modelValue 初始化
25
+ watch(() => props.modelValue, (slices) => {
26
+ if (!slices || slices.length === 0) {
27
+ inputValue.value = ''
28
+ return
29
+ }
30
+
31
+ const slice = slices[0]
32
+ if (slice.op === 'in' && Array.isArray(slice.value)) {
33
+ inputValue.value = (slice.value as string[]).join(', ')
34
+ } else {
35
+ inputValue.value = String(slice.value || '').replace(/%/g, '')
36
+ }
37
+ }, { immediate: true })
38
+
39
+ // 操作符选项
40
+ const operatorOptions = computed(() => {
41
+ const val = inputValue.value.trim()
42
+ if (!val) return []
43
+
44
+ return [
45
+ { op: '=', label: `等于:${val}` },
46
+ { op: 'right_like', label: `左匹配:${val}***` },
47
+ { op: 'in', label: `批量查找:${val}` }
48
+ ]
49
+ })
50
+
51
+ function onInput() {
52
+ if (inputValue.value.trim()) {
53
+ showDropdown.value = true
54
+ highlightIndex.value = 0
55
+ } else {
56
+ showDropdown.value = false
57
+ emit('update:modelValue', null)
58
+ }
59
+ }
60
+
61
+ function selectOperator(op: string) {
62
+ const val = inputValue.value.trim()
63
+ if (!val) return
64
+
65
+ let slice: SliceRequestDef
66
+
67
+ if (op === 'in') {
68
+ // 批量查找:按分隔符分割
69
+ const values = val.split(/[,,\s]+/).filter(v => v.trim())
70
+ if (values.length === 1) {
71
+ // 只有一个值,改用 =
72
+ slice = { field: props.field, op: '=', value: values[0] }
73
+ } else {
74
+ slice = { field: props.field, op: 'in', value: values }
75
+ }
76
+ } else {
77
+ slice = { field: props.field, op, value: val }
78
+ }
79
+
80
+ emit('update:modelValue', [slice])
81
+ showDropdown.value = false
82
+ }
83
+
84
+ function onKeydown(e: KeyboardEvent) {
85
+ if (!showDropdown.value || operatorOptions.value.length === 0) {
86
+ if (e.key === 'Enter' && inputValue.value.trim()) {
87
+ showDropdown.value = true
88
+ highlightIndex.value = 0
89
+ }
90
+ return
91
+ }
92
+
93
+ switch (e.key) {
94
+ case 'ArrowDown':
95
+ e.preventDefault()
96
+ highlightIndex.value = (highlightIndex.value + 1) % operatorOptions.value.length
97
+ break
98
+ case 'ArrowUp':
99
+ e.preventDefault()
100
+ highlightIndex.value = (highlightIndex.value - 1 + operatorOptions.value.length) % operatorOptions.value.length
101
+ break
102
+ case 'Enter':
103
+ e.preventDefault()
104
+ selectOperator(operatorOptions.value[highlightIndex.value].op)
105
+ break
106
+ case 'Escape':
107
+ showDropdown.value = false
108
+ break
109
+ }
110
+ }
111
+
112
+ function clear() {
113
+ inputValue.value = ''
114
+ showDropdown.value = false
115
+ emit('update:modelValue', null)
116
+ }
117
+
118
+ // 点击外部关闭下拉
119
+ function handleClickOutside(e: MouseEvent) {
120
+ if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
121
+ showDropdown.value = false
122
+ }
123
+ }
124
+
125
+ onMounted(() => {
126
+ document.addEventListener('click', handleClickOutside)
127
+ })
128
+
129
+ onUnmounted(() => {
130
+ document.removeEventListener('click', handleClickOutside)
131
+ })
132
+ </script>
133
+
134
+ <template>
135
+ <div ref="containerRef" class="filter-text">
136
+ <div class="input-wrapper">
137
+ <input
138
+ v-model="inputValue"
139
+ type="text"
140
+ :placeholder="placeholder"
141
+ @input="onInput"
142
+ @keydown="onKeydown"
143
+ @focus="inputValue.trim() && (showDropdown = true)"
144
+ />
145
+ <span v-if="inputValue" class="clear-btn" @click.stop="clear">×</span>
146
+ </div>
147
+
148
+ <div v-if="showDropdown && operatorOptions.length > 0" class="filter-dropdown">
149
+ <div
150
+ v-for="(opt, index) in operatorOptions"
151
+ :key="opt.op"
152
+ class="filter-option"
153
+ :class="{ highlighted: index === highlightIndex }"
154
+ @click="selectOperator(opt.op)"
155
+ @mouseenter="highlightIndex = index"
156
+ >
157
+ {{ opt.label }}
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </template>
162
+
163
+ <style scoped>
164
+ .filter-text {
165
+ position: relative;
166
+ width: 100%;
167
+ }
168
+
169
+ .input-wrapper {
170
+ position: relative;
171
+ display: flex;
172
+ align-items: center;
173
+ }
174
+
175
+ .input-wrapper input {
176
+ width: 100%;
177
+ height: 26px;
178
+ padding: 0 24px 0 8px;
179
+ border: 1px solid #dcdfe6;
180
+ border-radius: 4px;
181
+ font-size: 12px;
182
+ line-height: 26px;
183
+ outline: none;
184
+ transition: border-color 0.2s;
185
+ }
186
+
187
+ .input-wrapper input:focus {
188
+ border-color: #409eff;
189
+ }
190
+
191
+ .clear-btn {
192
+ position: absolute;
193
+ right: 6px;
194
+ color: #c0c4cc;
195
+ cursor: pointer;
196
+ font-size: 14px;
197
+ }
198
+
199
+ .clear-btn:hover {
200
+ color: #909399;
201
+ }
202
+
203
+ .filter-dropdown {
204
+ position: absolute;
205
+ top: 100%;
206
+ left: 0;
207
+ right: 0;
208
+ margin-top: 4px;
209
+ background: white;
210
+ border: 1px solid #e4e7ed;
211
+ border-radius: 4px;
212
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
213
+ z-index: 9999;
214
+ max-height: 200px;
215
+ overflow-y: auto;
216
+ }
217
+
218
+ .filter-option {
219
+ padding: 8px 12px;
220
+ font-size: 12px;
221
+ color: #606266;
222
+ cursor: pointer;
223
+ transition: background-color 0.2s;
224
+ }
225
+
226
+ .filter-option:hover,
227
+ .filter-option.highlighted {
228
+ background-color: #f5f7fa;
229
+ }
230
+ </style>
@@ -0,0 +1,5 @@
1
+ export { default as TextFilter } from './TextFilter.vue'
2
+ export { default as NumberRangeFilter } from './NumberRangeFilter.vue'
3
+ export { default as DateRangeFilter } from './DateRangeFilter.vue'
4
+ export { default as SelectFilter } from './SelectFilter.vue'
5
+ export { default as BoolFilter } from './BoolFilter.vue'