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,194 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watch, computed } from 'vue'
|
|
3
|
+
import type { SliceRequestDef } from '@/types'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
field: string
|
|
7
|
+
modelValue?: SliceRequestDef[] | null
|
|
8
|
+
format?: string
|
|
9
|
+
showTime?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
13
|
+
format: 'YYYY-MM-DD',
|
|
14
|
+
showTime: false
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{
|
|
18
|
+
(e: 'update:modelValue', value: SliceRequestDef[] | null): void
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
// 日期范围值 [开始, 结束]
|
|
22
|
+
const dateRange = ref<[Date, Date] | null>(null)
|
|
23
|
+
|
|
24
|
+
// 是否显示时间选择
|
|
25
|
+
const isDatetime = computed(() => props.showTime || props.format?.includes('HH'))
|
|
26
|
+
|
|
27
|
+
// 日期格式
|
|
28
|
+
const dateFormat = computed(() => {
|
|
29
|
+
if (isDatetime.value) {
|
|
30
|
+
return 'YYYY-MM-DD HH:mm'
|
|
31
|
+
}
|
|
32
|
+
return 'YYYY-MM-DD'
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// 从 modelValue 初始化
|
|
36
|
+
watch(() => props.modelValue, (slices) => {
|
|
37
|
+
if (!slices || slices.length === 0) {
|
|
38
|
+
dateRange.value = null
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 从 slice 中解析日期范围
|
|
43
|
+
// 支持 [) 操作符或两个 >= <= 条件
|
|
44
|
+
const rangeSlice = slices.find(s => s.op === '[)' || s.op === '[]')
|
|
45
|
+
if (rangeSlice && Array.isArray(rangeSlice.value)) {
|
|
46
|
+
const [start, end] = rangeSlice.value as [string, string]
|
|
47
|
+
dateRange.value = [
|
|
48
|
+
new Date(start.replace(' ', 'T')),
|
|
49
|
+
new Date(end.replace(' ', 'T'))
|
|
50
|
+
]
|
|
51
|
+
} else {
|
|
52
|
+
// 尝试从 >= 和 <= 条件解析
|
|
53
|
+
const gteSlice = slices.find(s => s.op === '>=')
|
|
54
|
+
const lteSlice = slices.find(s => s.op === '<=' || s.op === '<')
|
|
55
|
+
if (gteSlice || lteSlice) {
|
|
56
|
+
const start = gteSlice ? new Date(String(gteSlice.value).replace(' ', 'T')) : null
|
|
57
|
+
const end = lteSlice ? new Date(String(lteSlice.value).replace(' ', 'T')) : null
|
|
58
|
+
if (start && end) {
|
|
59
|
+
dateRange.value = [start, end]
|
|
60
|
+
} else if (start) {
|
|
61
|
+
dateRange.value = [start, start]
|
|
62
|
+
} else if (end) {
|
|
63
|
+
dateRange.value = [end, end]
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
dateRange.value = null
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}, { immediate: true })
|
|
70
|
+
|
|
71
|
+
function formatDate(date: Date): string {
|
|
72
|
+
const year = date.getFullYear()
|
|
73
|
+
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
74
|
+
const day = String(date.getDate()).padStart(2, '0')
|
|
75
|
+
|
|
76
|
+
if (isDatetime.value) {
|
|
77
|
+
const hours = String(date.getHours()).padStart(2, '0')
|
|
78
|
+
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
79
|
+
const seconds = String(date.getSeconds()).padStart(2, '0')
|
|
80
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return `${year}-${month}-${day}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleChange(val: [Date, Date] | null) {
|
|
87
|
+
if (!val || val.length !== 2) {
|
|
88
|
+
emit('update:modelValue', null)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const [start, end] = val
|
|
93
|
+
const startVal = formatDate(start)
|
|
94
|
+
// 对于日期,结束日期使用 < 下一天来实现左闭右开
|
|
95
|
+
let endVal: string
|
|
96
|
+
if (isDatetime.value) {
|
|
97
|
+
endVal = formatDate(end)
|
|
98
|
+
} else {
|
|
99
|
+
// 日期范围:结束日期+1天,使用 [) 左闭右开
|
|
100
|
+
const nextDay = new Date(end)
|
|
101
|
+
nextDay.setDate(nextDay.getDate() + 1)
|
|
102
|
+
endVal = formatDate(nextDay)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 生成 DSL slice: { field, op: "[)", value: [start, end] }
|
|
106
|
+
emit('update:modelValue', [{
|
|
107
|
+
field: props.field,
|
|
108
|
+
op: '[)',
|
|
109
|
+
value: [startVal, endVal]
|
|
110
|
+
}])
|
|
111
|
+
}
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<template>
|
|
115
|
+
<div class="filter-date-range">
|
|
116
|
+
<el-date-picker
|
|
117
|
+
v-model="dateRange"
|
|
118
|
+
:type="isDatetime ? 'datetimerange' : 'daterange'"
|
|
119
|
+
:format="dateFormat"
|
|
120
|
+
range-separator="~"
|
|
121
|
+
start-placeholder="开始"
|
|
122
|
+
end-placeholder="结束"
|
|
123
|
+
size="small"
|
|
124
|
+
:clearable="true"
|
|
125
|
+
:editable="false"
|
|
126
|
+
@change="handleChange"
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
</template>
|
|
130
|
+
|
|
131
|
+
<style scoped>
|
|
132
|
+
.filter-date-range {
|
|
133
|
+
width: 100%;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.filter-date-range :deep(.el-date-editor) {
|
|
137
|
+
width: 100% !important;
|
|
138
|
+
max-width: 100%;
|
|
139
|
+
height: 26px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.filter-date-range :deep(.el-range-input) {
|
|
143
|
+
font-size: 11px;
|
|
144
|
+
flex: 1;
|
|
145
|
+
min-width: 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.filter-date-range :deep(.el-range-separator) {
|
|
149
|
+
font-size: 11px;
|
|
150
|
+
padding: 0 2px;
|
|
151
|
+
line-height: 24px;
|
|
152
|
+
flex-shrink: 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.filter-date-range :deep(.el-input__wrapper) {
|
|
156
|
+
padding: 0 4px;
|
|
157
|
+
height: 26px;
|
|
158
|
+
box-sizing: border-box;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* 控制图标大小 */
|
|
162
|
+
.filter-date-range :deep(.el-input__prefix),
|
|
163
|
+
.filter-date-range :deep(.el-input__suffix) {
|
|
164
|
+
display: flex;
|
|
165
|
+
align-items: center;
|
|
166
|
+
height: 26px;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.filter-date-range :deep(.el-input__prefix-inner),
|
|
170
|
+
.filter-date-range :deep(.el-input__suffix-inner) {
|
|
171
|
+
display: flex;
|
|
172
|
+
align-items: center;
|
|
173
|
+
height: 100%;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.filter-date-range :deep(.el-input__prefix .el-icon),
|
|
177
|
+
.filter-date-range :deep(.el-input__suffix .el-icon) {
|
|
178
|
+
font-size: 12px !important;
|
|
179
|
+
width: 12px;
|
|
180
|
+
height: 12px;
|
|
181
|
+
line-height: 12px;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.filter-date-range :deep(.el-icon svg) {
|
|
185
|
+
width: 12px;
|
|
186
|
+
height: 12px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* 确保不会换行 */
|
|
190
|
+
.filter-date-range :deep(.el-range-editor--small .el-range__close-icon) {
|
|
191
|
+
font-size: 12px;
|
|
192
|
+
line-height: 24px;
|
|
193
|
+
}
|
|
194
|
+
</style>
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
placeholderMin?: string
|
|
9
|
+
placeholderMax?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
13
|
+
placeholderMin: '最小',
|
|
14
|
+
placeholderMax: '最大'
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{
|
|
18
|
+
(e: 'update:modelValue', value: SliceRequestDef[] | null): void
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
const minValue = ref<string>('')
|
|
22
|
+
const maxValue = ref<string>('')
|
|
23
|
+
|
|
24
|
+
// 从 modelValue 初始化
|
|
25
|
+
watch(() => props.modelValue, (slices) => {
|
|
26
|
+
if (!slices || slices.length === 0) {
|
|
27
|
+
minValue.value = ''
|
|
28
|
+
maxValue.value = ''
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 解析 [] 范围操作符
|
|
33
|
+
const rangeSlice = slices.find(s => s.op === '[]' || s.op === '[)')
|
|
34
|
+
if (rangeSlice && Array.isArray(rangeSlice.value)) {
|
|
35
|
+
const [min, max] = rangeSlice.value as [number, number]
|
|
36
|
+
minValue.value = min != null ? String(min) : ''
|
|
37
|
+
maxValue.value = max != null ? String(max) : ''
|
|
38
|
+
} else {
|
|
39
|
+
// 解析 >= 和 <= 条件
|
|
40
|
+
const gteSlice = slices.find(s => s.op === '>=')
|
|
41
|
+
const lteSlice = slices.find(s => s.op === '<=')
|
|
42
|
+
minValue.value = gteSlice?.value != null ? String(gteSlice.value) : ''
|
|
43
|
+
maxValue.value = lteSlice?.value != null ? String(lteSlice.value) : ''
|
|
44
|
+
}
|
|
45
|
+
}, { immediate: true })
|
|
46
|
+
|
|
47
|
+
function emitChange() {
|
|
48
|
+
const min = minValue.value.trim()
|
|
49
|
+
const max = maxValue.value.trim()
|
|
50
|
+
|
|
51
|
+
if (!min && !max) {
|
|
52
|
+
emit('update:modelValue', null)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const minNum = min ? parseFloat(min) : null
|
|
57
|
+
const maxNum = max ? parseFloat(max) : null
|
|
58
|
+
|
|
59
|
+
// 验证数字
|
|
60
|
+
if (min && isNaN(minNum!)) return
|
|
61
|
+
if (max && isNaN(maxNum!)) return
|
|
62
|
+
|
|
63
|
+
// 生成 DSL slice
|
|
64
|
+
if (minNum !== null && maxNum !== null) {
|
|
65
|
+
// 两个都有值:使用 [] 闭区间
|
|
66
|
+
emit('update:modelValue', [{
|
|
67
|
+
field: props.field,
|
|
68
|
+
op: '[]',
|
|
69
|
+
value: [minNum, maxNum]
|
|
70
|
+
}])
|
|
71
|
+
} else if (minNum !== null) {
|
|
72
|
+
// 只有最小值:>=
|
|
73
|
+
emit('update:modelValue', [{
|
|
74
|
+
field: props.field,
|
|
75
|
+
op: '>=',
|
|
76
|
+
value: minNum
|
|
77
|
+
}])
|
|
78
|
+
} else if (maxNum !== null) {
|
|
79
|
+
// 只有最大值:<=
|
|
80
|
+
emit('update:modelValue', [{
|
|
81
|
+
field: props.field,
|
|
82
|
+
op: '<=',
|
|
83
|
+
value: maxNum
|
|
84
|
+
}])
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clear() {
|
|
89
|
+
minValue.value = ''
|
|
90
|
+
maxValue.value = ''
|
|
91
|
+
emit('update:modelValue', null)
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<template>
|
|
96
|
+
<div class="filter-number-range">
|
|
97
|
+
<input
|
|
98
|
+
v-model="minValue"
|
|
99
|
+
type="text"
|
|
100
|
+
:placeholder="placeholderMin"
|
|
101
|
+
@change="emitChange"
|
|
102
|
+
@keyup.enter="emitChange"
|
|
103
|
+
/>
|
|
104
|
+
<span class="separator">-</span>
|
|
105
|
+
<input
|
|
106
|
+
v-model="maxValue"
|
|
107
|
+
type="text"
|
|
108
|
+
:placeholder="placeholderMax"
|
|
109
|
+
@change="emitChange"
|
|
110
|
+
@keyup.enter="emitChange"
|
|
111
|
+
/>
|
|
112
|
+
<span v-if="minValue || maxValue" class="clear-btn" @click="clear">×</span>
|
|
113
|
+
</div>
|
|
114
|
+
</template>
|
|
115
|
+
|
|
116
|
+
<style scoped>
|
|
117
|
+
.filter-number-range {
|
|
118
|
+
display: flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
gap: 4px;
|
|
121
|
+
position: relative;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.filter-number-range input {
|
|
125
|
+
flex: 1;
|
|
126
|
+
min-width: 0;
|
|
127
|
+
width: 50px;
|
|
128
|
+
height: 26px;
|
|
129
|
+
padding: 0 6px;
|
|
130
|
+
border: 1px solid #dcdfe6;
|
|
131
|
+
border-radius: 4px;
|
|
132
|
+
font-size: 12px;
|
|
133
|
+
line-height: 26px;
|
|
134
|
+
outline: none;
|
|
135
|
+
text-align: right;
|
|
136
|
+
transition: border-color 0.2s;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.filter-number-range input:focus {
|
|
140
|
+
border-color: #409eff;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.separator {
|
|
144
|
+
color: #909399;
|
|
145
|
+
font-size: 12px;
|
|
146
|
+
flex-shrink: 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.clear-btn {
|
|
150
|
+
position: absolute;
|
|
151
|
+
right: -16px;
|
|
152
|
+
color: #c0c4cc;
|
|
153
|
+
cursor: pointer;
|
|
154
|
+
font-size: 14px;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.clear-btn:hover {
|
|
158
|
+
color: #909399;
|
|
159
|
+
}
|
|
160
|
+
</style>
|