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,277 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, useAttrs } from 'vue'
3
+ import type { EnhancedColumnSchema, SliceRequestDef, FilterOption } from '@/types'
4
+ import SearchToolbar from './SearchToolbar.vue'
5
+ import DataTable from './DataTable.vue'
6
+
7
+ // 禁用自动继承属性
8
+ defineOptions({
9
+ inheritAttrs: false
10
+ })
11
+
12
+ // 获取透传的属性和事件
13
+ const attrs = useAttrs()
14
+
15
+ /**
16
+ * DataTableWithSearch 组件
17
+ *
18
+ * 组合了 SearchToolbar 和 DataTable 的高级组件
19
+ * 支持完整的属性和事件透传到底层组件
20
+ */
21
+ interface Props {
22
+ // ========== DataTable Props ==========
23
+ /** 列配置(必填) */
24
+ columns: EnhancedColumnSchema[]
25
+ /** 表格数据(必填) */
26
+ data: Record<string, unknown>[]
27
+ /** 总数据量(必填) */
28
+ total: number
29
+ /** 加载状态 */
30
+ loading: boolean
31
+ /** 每页大小 */
32
+ pageSize?: number
33
+ /** 是否显示表头过滤器 */
34
+ showFilters?: boolean
35
+ /** 初始筛选条件 */
36
+ initialSlice?: SliceRequestDef[]
37
+ /** 服务端汇总数据 */
38
+ serverSummary?: Record<string, unknown> | null
39
+ /** 过滤选项加载器 */
40
+ filterOptionsLoader?: (columnName: string) => Promise<FilterOption[]>
41
+ /** 自定义过滤器组件映射 */
42
+ customFilterComponents?: Record<string, unknown>
43
+
44
+ // ========== SearchToolbar Props ==========
45
+ /** 是否显示搜索工具栏 */
46
+ showSearchToolbar?: boolean
47
+ /** 搜索工具栏可搜索字段(不指定则使用所有可筛选字段) */
48
+ searchableFields?: string[]
49
+ /** 搜索工具栏布局 */
50
+ searchLayout?: 'horizontal' | 'vertical'
51
+ /** 是否显示搜索按钮 */
52
+ showSearchActions?: boolean
53
+
54
+ // ========== 组合配置 ==========
55
+ /** 搜索工具栏和表头过滤器的筛选条件合并模式 */
56
+ filterMergeMode?: 'replace' | 'merge'
57
+ }
58
+
59
+ const props = withDefaults(defineProps<Props>(), {
60
+ pageSize: 50,
61
+ showFilters: true,
62
+ showSearchToolbar: true,
63
+ searchLayout: 'horizontal',
64
+ showSearchActions: true,
65
+ filterMergeMode: 'merge'
66
+ })
67
+
68
+ const emit = defineEmits<{
69
+ // DataTable 事件
70
+ (e: 'page-change', page: number, size: number): void
71
+ (e: 'sort-change', field: string | null, order: 'asc' | 'desc' | null): void
72
+ (e: 'filter-change', slices: SliceRequestDef[]): void
73
+ (e: 'row-click', row: Record<string, unknown>, column: EnhancedColumnSchema): void
74
+ (e: 'row-dblclick', row: Record<string, unknown>, column: EnhancedColumnSchema): void
75
+ // SearchToolbar 事件
76
+ (e: 'search', slices: SliceRequestDef[]): void
77
+ (e: 'reset'): void
78
+ }>()
79
+
80
+ // 组件引用
81
+ const searchToolbarRef = ref<InstanceType<typeof SearchToolbar>>()
82
+ const dataTableRef = ref<InstanceType<typeof DataTable>>()
83
+
84
+ // 搜索工具栏筛选条件
85
+ const searchSlices = ref<SliceRequestDef[]>([])
86
+ // 表头筛选条件
87
+ const tableSlices = ref<SliceRequestDef[]>([])
88
+
89
+ // 合并后的筛选条件
90
+ const mergedSlices = computed(() => {
91
+ if (props.filterMergeMode === 'replace') {
92
+ // 替换模式:搜索工具栏优先,如果为空则使用表头筛选
93
+ return searchSlices.value.length > 0 ? searchSlices.value : tableSlices.value
94
+ } else {
95
+ // 合并模式:两者合并(去重)
96
+ const merged = [...searchSlices.value]
97
+ const searchFields = new Set(searchSlices.value.map(s => s.field))
98
+
99
+ // 添加表头筛选中不在搜索工具栏中的字段
100
+ for (const slice of tableSlices.value) {
101
+ if (!searchFields.has(slice.field)) {
102
+ merged.push(slice)
103
+ }
104
+ }
105
+
106
+ return merged
107
+ }
108
+ })
109
+
110
+ // 处理搜索工具栏筛选变化
111
+ function handleSearchChange(slices: SliceRequestDef[]) {
112
+ searchSlices.value = slices
113
+ emit('filter-change', mergedSlices.value)
114
+ }
115
+
116
+ // 处理搜索按钮点击
117
+ function handleSearch() {
118
+ emit('search', searchSlices.value)
119
+ emit('filter-change', mergedSlices.value)
120
+ }
121
+
122
+ // 处理重置按钮点击
123
+ function handleReset() {
124
+ searchSlices.value = []
125
+ emit('reset')
126
+ emit('filter-change', mergedSlices.value)
127
+ }
128
+
129
+ // 处理表头筛选变化
130
+ function handleTableFilterChange(slices: SliceRequestDef[]) {
131
+ tableSlices.value = slices
132
+ emit('filter-change', mergedSlices.value)
133
+ }
134
+
135
+ // 分离 DataTable 的 props 和用户自定义的 props
136
+ const dataTableProps = computed(() => {
137
+ // 从 attrs 中提取非事件属性
138
+ const userProps = Object.keys(attrs)
139
+ .filter(key => !key.startsWith('on'))
140
+ .reduce((acc, key) => ({ ...acc, [key]: attrs[key] }), {})
141
+
142
+ return {
143
+ columns: props.columns,
144
+ data: props.data,
145
+ total: props.total,
146
+ loading: props.loading,
147
+ pageSize: props.pageSize,
148
+ showFilters: props.showFilters,
149
+ initialSlice: props.initialSlice,
150
+ serverSummary: props.serverSummary,
151
+ filterOptionsLoader: props.filterOptionsLoader,
152
+ customFilterComponents: props.customFilterComponents,
153
+ ...userProps
154
+ }
155
+ })
156
+
157
+ // 提取用户传入的事件监听器
158
+ const dataTableEvents = computed(() => {
159
+ const userEvents: Record<string, Function> = {}
160
+ Object.keys(attrs).forEach(key => {
161
+ if (key.startsWith('on')) {
162
+ const eventName = key.slice(2, 3).toLowerCase() + key.slice(3)
163
+ userEvents[eventName] = attrs[key] as Function
164
+ }
165
+ })
166
+
167
+ return {
168
+ 'page-change': (...args: any[]) => {
169
+ emit('page-change', ...args as [number, number])
170
+ if (userEvents['pageChange']) {
171
+ userEvents['pageChange'](...args)
172
+ }
173
+ },
174
+ 'sort-change': (...args: any[]) => {
175
+ emit('sort-change', ...args as [string | null, 'asc' | 'desc' | null])
176
+ if (userEvents['sortChange']) {
177
+ userEvents['sortChange'](...args)
178
+ }
179
+ },
180
+ 'filter-change': handleTableFilterChange,
181
+ 'row-click': (...args: any[]) => {
182
+ emit('row-click', ...args as [Record<string, unknown>, EnhancedColumnSchema])
183
+ if (userEvents['rowClick']) {
184
+ userEvents['rowClick'](...args)
185
+ }
186
+ },
187
+ 'row-dblclick': (...args: any[]) => {
188
+ emit('row-dblclick', ...args as [Record<string, unknown>, EnhancedColumnSchema])
189
+ if (userEvents['rowDblclick']) {
190
+ userEvents['rowDblclick'](...args)
191
+ }
192
+ }
193
+ }
194
+ })
195
+
196
+ // 暴露方法和子组件引用
197
+ defineExpose({
198
+ /** 获取 SearchToolbar 实例 */
199
+ getSearchToolbar: () => searchToolbarRef.value,
200
+ /** 获取 DataTable 实例 */
201
+ getDataTable: () => dataTableRef.value,
202
+ /** 清空搜索工具栏筛选 */
203
+ clearSearchFilters: () => searchToolbarRef.value?.clearFilters(),
204
+ /** 清空表头筛选 */
205
+ clearTableFilters: () => dataTableRef.value?.clearFilters(),
206
+ /** 清空所有筛选 */
207
+ clearAllFilters: () => {
208
+ searchToolbarRef.value?.clearFilters()
209
+ dataTableRef.value?.clearFilters()
210
+ },
211
+ /** 获取合并后的筛选条件 */
212
+ getMergedFilters: () => mergedSlices.value,
213
+ /** 重置分页 */
214
+ resetPagination: () => dataTableRef.value?.resetPagination()
215
+ })
216
+ </script>
217
+
218
+ <template>
219
+ <div class="data-table-with-search">
220
+ <!-- 搜索工具栏 -->
221
+ <div v-if="showSearchToolbar" class="search-toolbar-wrapper">
222
+ <SearchToolbar
223
+ ref="searchToolbarRef"
224
+ :columns="columns"
225
+ :searchable-fields="searchableFields"
226
+ :layout="searchLayout"
227
+ :show-actions="showSearchActions"
228
+ :filter-options-loader="filterOptionsLoader"
229
+ v-model="searchSlices"
230
+ @update:model-value="handleSearchChange"
231
+ @search="handleSearch"
232
+ @reset="handleReset"
233
+ />
234
+ </div>
235
+
236
+ <!-- 数据表格 -->
237
+ <div class="data-table-wrapper">
238
+ <DataTable
239
+ ref="dataTableRef"
240
+ v-bind="dataTableProps"
241
+ v-on="dataTableEvents"
242
+ >
243
+ <!-- 透传所有插槽 -->
244
+ <template v-for="(_, name) in $slots" #[name]="slotData">
245
+ <slot :name="name" v-bind="slotData || {}" />
246
+ </template>
247
+ </DataTable>
248
+ </div>
249
+ </div>
250
+ </template>
251
+
252
+ <style scoped>
253
+ .data-table-with-search {
254
+ display: flex;
255
+ flex-direction: column;
256
+ gap: 16px;
257
+ width: 100%;
258
+ height: 100%;
259
+ }
260
+
261
+ .search-toolbar-wrapper {
262
+ flex-shrink: 0;
263
+ }
264
+
265
+ .data-table-wrapper {
266
+ flex: 1;
267
+ min-height: 0;
268
+ display: flex;
269
+ flex-direction: column;
270
+ }
271
+
272
+ .data-table-wrapper :deep(.data-table) {
273
+ flex: 1;
274
+ display: flex;
275
+ flex-direction: column;
276
+ }
277
+ </style>
@@ -0,0 +1,310 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, computed } from 'vue'
3
+ import DataTable from './DataTable.vue'
4
+ import { fetchQueryMeta, fetchQueryData, fetchFilterOptions, fetchQmSchema } from '@/api/viewer'
5
+ import { buildTableColumns } from '@/utils/schemaHelper'
6
+ import type { QueryMetaResponse, ViewerQueryRequest, SliceRequestDef, OrderRequestDef, FilterOption, EnhancedColumnSchema, ColumnSchema } from '@/types'
7
+
8
+ const props = defineProps<{
9
+ queryId: string
10
+ }>()
11
+
12
+ // 状态
13
+ const loading = ref(false)
14
+ const error = ref<string | null>(null)
15
+ const expired = ref(false)
16
+ const meta = ref<QueryMetaResponse | null>(null)
17
+ const qmSchema = ref<ColumnSchema[]>([])
18
+ const columns = ref<EnhancedColumnSchema[]>([])
19
+ const data = ref<Record<string, unknown>[]>([])
20
+ const total = ref(0)
21
+ const serverSummary = ref<Record<string, unknown> | null>(null)
22
+
23
+ // 查询参数 (DSL 格式)
24
+ const queryParams = ref<ViewerQueryRequest>({
25
+ start: 0,
26
+ limit: 50,
27
+ slice: [],
28
+ orderBy: []
29
+ })
30
+
31
+ const dataTableRef = ref<InstanceType<typeof DataTable>>()
32
+
33
+ // 计算属性
34
+ const title = computed(() => meta.value?.title || '数据浏览器')
35
+ const expiresAt = computed(() => {
36
+ if (!meta.value?.expiresAt) return ''
37
+ return new Date(meta.value.expiresAt).toLocaleString('zh-CN')
38
+ })
39
+
40
+ // 加载元数据
41
+ async function loadMeta() {
42
+ try {
43
+ loading.value = true
44
+ error.value = null
45
+
46
+ // 1. 获取查询元数据(包含 tableConfig)
47
+ meta.value = await fetchQueryMeta(props.queryId)
48
+
49
+ // 2. 获取 QM Schema
50
+ if (meta.value.tableConfig.qmModel) {
51
+ qmSchema.value = await fetchQmSchema(meta.value.tableConfig.qmModel)
52
+
53
+ // 3. 使用 buildTableColumns 构建列配置
54
+ columns.value = buildTableColumns(qmSchema.value, meta.value.tableConfig)
55
+ }
56
+ } catch (e) {
57
+ error.value = e instanceof Error ? e.message : '加载元数据失败'
58
+ } finally {
59
+ loading.value = false
60
+ }
61
+ }
62
+
63
+ // 加载数据
64
+ async function loadData() {
65
+ if (!meta.value) return
66
+
67
+ try {
68
+ loading.value = true
69
+ error.value = null
70
+
71
+ const response = await fetchQueryData(props.queryId, queryParams.value)
72
+
73
+ if (response.expired) {
74
+ expired.value = true
75
+ error.value = response.errorMessage || '查询已过期'
76
+ return
77
+ }
78
+
79
+ if (!response.success) {
80
+ error.value = response.errorMessage || '查询失败'
81
+ return
82
+ }
83
+
84
+ data.value = response.items
85
+ total.value = response.total
86
+ // 提取汇总数据
87
+ serverSummary.value = response.totalData ?? null
88
+ } catch (e) {
89
+ error.value = e instanceof Error ? e.message : '加载数据失败'
90
+ } finally {
91
+ loading.value = false
92
+ }
93
+ }
94
+
95
+ // 事件处理
96
+ function handlePageChange(page: number, size: number) {
97
+ queryParams.value.start = (page - 1) * size
98
+ queryParams.value.limit = size
99
+ loadData()
100
+ }
101
+
102
+ function handleSortChange(field: string | null, order: 'asc' | 'desc' | null) {
103
+ if (field && order) {
104
+ queryParams.value.orderBy = [{ field, order }]
105
+ } else {
106
+ queryParams.value.orderBy = []
107
+ }
108
+ loadData()
109
+ }
110
+
111
+ function handleFilterChange(slices: SliceRequestDef[]) {
112
+ queryParams.value.slice = slices
113
+ queryParams.value.start = 0
114
+ dataTableRef.value?.resetPagination()
115
+ loadData()
116
+ }
117
+
118
+ // 加载维度选项
119
+ async function loadFilterOptions(columnName: string): Promise<FilterOption[]> {
120
+ try {
121
+ const response = await fetchFilterOptions(props.queryId, columnName)
122
+ return response.options || []
123
+ } catch (e) {
124
+ console.error('Failed to load filter options:', e)
125
+ return []
126
+ }
127
+ }
128
+
129
+ // 刷新数据
130
+ function refresh() {
131
+ loadData()
132
+ }
133
+
134
+ // 初始化
135
+ onMounted(async () => {
136
+ await loadMeta()
137
+ if (meta.value) {
138
+ // 应用初始过滤条件(如果有)
139
+ if (meta.value.initialSlice && meta.value.initialSlice.length > 0) {
140
+ queryParams.value.slice = meta.value.initialSlice
141
+ }
142
+ await loadData()
143
+ }
144
+ })
145
+ </script>
146
+
147
+ <template>
148
+ <div class="data-viewer">
149
+ <header class="viewer-header">
150
+ <h1 class="viewer-title">{{ title }}</h1>
151
+ <div class="viewer-info">
152
+ <span v-if="meta?.tableConfig?.qmModel" class="info-item">
153
+ 模型: <strong>{{ meta.tableConfig.qmModel }}</strong>
154
+ </span>
155
+ <span v-if="meta?.estimatedRowCount" class="info-item">
156
+ 预估行数: <strong>{{ meta.estimatedRowCount.toLocaleString() }}</strong>
157
+ </span>
158
+ <span v-if="expiresAt" class="info-item">
159
+ 过期时间: <strong>{{ expiresAt }}</strong>
160
+ </span>
161
+ <button class="refresh-btn" @click="refresh" :disabled="loading">
162
+ 刷新
163
+ </button>
164
+ </div>
165
+ </header>
166
+
167
+ <div v-if="expired" class="viewer-expired">
168
+ <div class="expired-content">
169
+ <h2>链接已过期</h2>
170
+ <p>请联系AI助手重新生成查询链接</p>
171
+ </div>
172
+ </div>
173
+
174
+ <div v-else-if="error && !data.length" class="viewer-error">
175
+ <div class="error-content">
176
+ <h2>加载失败</h2>
177
+ <p>{{ error }}</p>
178
+ <button @click="loadData">重试</button>
179
+ </div>
180
+ </div>
181
+
182
+ <main v-else class="viewer-main">
183
+ <DataTable
184
+ ref="dataTableRef"
185
+ :columns="columns"
186
+ :data="data"
187
+ :total="total"
188
+ :loading="loading"
189
+ :initial-slice="meta?.initialSlice"
190
+ :filter-options-loader="loadFilterOptions"
191
+ :server-summary="serverSummary"
192
+ @page-change="handlePageChange"
193
+ @sort-change="handleSortChange"
194
+ @filter-change="handleFilterChange"
195
+ />
196
+ </main>
197
+
198
+ <footer class="viewer-footer">
199
+ <span>Foggy Data Viewer</span>
200
+ </footer>
201
+ </div>
202
+ </template>
203
+
204
+ <style scoped>
205
+ .data-viewer {
206
+ display: flex;
207
+ flex-direction: column;
208
+ height: 100vh;
209
+ background-color: #f5f7fa;
210
+ }
211
+
212
+ .viewer-header {
213
+ padding: 16px 24px;
214
+ background-color: #fff;
215
+ border-bottom: 1px solid #e4e7ed;
216
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
217
+ }
218
+
219
+ .viewer-title {
220
+ margin: 0 0 8px 0;
221
+ font-size: 20px;
222
+ font-weight: 600;
223
+ color: #303133;
224
+ }
225
+
226
+ .viewer-info {
227
+ display: flex;
228
+ align-items: center;
229
+ gap: 24px;
230
+ font-size: 14px;
231
+ color: #606266;
232
+ }
233
+
234
+ .info-item strong {
235
+ color: #303133;
236
+ }
237
+
238
+ .refresh-btn {
239
+ margin-left: auto;
240
+ padding: 6px 16px;
241
+ background-color: #409eff;
242
+ color: #fff;
243
+ border: none;
244
+ border-radius: 4px;
245
+ cursor: pointer;
246
+ font-size: 14px;
247
+ }
248
+
249
+ .refresh-btn:hover {
250
+ background-color: #66b1ff;
251
+ }
252
+
253
+ .refresh-btn:disabled {
254
+ background-color: #a0cfff;
255
+ cursor: not-allowed;
256
+ }
257
+
258
+ .viewer-main {
259
+ flex: 1;
260
+ padding: 16px 24px;
261
+ overflow: hidden;
262
+ }
263
+
264
+ .viewer-expired,
265
+ .viewer-error {
266
+ flex: 1;
267
+ display: flex;
268
+ align-items: center;
269
+ justify-content: center;
270
+ }
271
+
272
+ .expired-content,
273
+ .error-content {
274
+ text-align: center;
275
+ padding: 32px;
276
+ background-color: #fff;
277
+ border-radius: 8px;
278
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
279
+ }
280
+
281
+ .expired-content h2,
282
+ .error-content h2 {
283
+ margin: 0 0 12px 0;
284
+ color: #f56c6c;
285
+ }
286
+
287
+ .expired-content p,
288
+ .error-content p {
289
+ margin: 0 0 16px 0;
290
+ color: #909399;
291
+ }
292
+
293
+ .error-content button {
294
+ padding: 8px 24px;
295
+ background-color: #409eff;
296
+ color: #fff;
297
+ border: none;
298
+ border-radius: 4px;
299
+ cursor: pointer;
300
+ }
301
+
302
+ .viewer-footer {
303
+ padding: 8px 24px;
304
+ text-align: center;
305
+ font-size: 12px;
306
+ color: #909399;
307
+ background-color: #fff;
308
+ border-top: 1px solid #e4e7ed;
309
+ }
310
+ </style>