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,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>
|