fdb2 1.0.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/.dockerignore +21 -0
- package/.editorconfig +11 -0
- package/.eslintrc.cjs +14 -0
- package/.eslintrc.json +7 -0
- package/.prettierrc.js +3 -0
- package/.tpl.env +22 -0
- package/README.md +260 -0
- package/bin/build.sh +28 -0
- package/bin/deploy.sh +8 -0
- package/bin/dev.sh +10 -0
- package/bin/docker/.env +4 -0
- package/bin/docker/dev-docker-compose.yml +43 -0
- package/bin/docker/dev.Dockerfile +24 -0
- package/bin/docker/prod-docker-compose.yml +17 -0
- package/bin/docker/prod.Dockerfile +29 -0
- package/bin/fdb2.js +142 -0
- package/data/connections.demo.json +32 -0
- package/env.d.ts +1 -0
- package/nw-build.js +120 -0
- package/nw-dev.js +65 -0
- package/package.json +114 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +9 -0
- package/public/modules/header.tpl +14 -0
- package/public/modules/initial_state.tpl +55 -0
- package/server/index.ts +677 -0
- package/server/model/connection.entity.ts +66 -0
- package/server/model/database.entity.ts +246 -0
- package/server/service/connection.service.ts +334 -0
- package/server/service/database/base.service.ts +363 -0
- package/server/service/database/database.service.ts +510 -0
- package/server/service/database/index.ts +7 -0
- package/server/service/database/mssql.service.ts +723 -0
- package/server/service/database/mysql.service.ts +761 -0
- package/server/service/database/oracle.service.ts +839 -0
- package/server/service/database/postgres.service.ts +744 -0
- package/server/service/database/sqlite.service.ts +559 -0
- package/server/service/session.service.ts +158 -0
- package/server.js +128 -0
- package/src/adapter/ajax.ts +135 -0
- package/src/assets/base.css +1 -0
- package/src/assets/database.css +950 -0
- package/src/assets/images/collapse.png +0 -0
- package/src/assets/images/no-login.png +0 -0
- package/src/assets/images/svg/illustrations/illustration-1.svg +1 -0
- package/src/assets/images/svg/illustrations/illustration-2.svg +2 -0
- package/src/assets/images/svg/illustrations/illustration-3.svg +50 -0
- package/src/assets/images/svg/illustrations/illustration-4.svg +1 -0
- package/src/assets/images/svg/illustrations/illustration-5.svg +73 -0
- package/src/assets/images/svg/illustrations/illustration-6.svg +89 -0
- package/src/assets/images/svg/illustrations/illustration-7.svg +39 -0
- package/src/assets/images/svg/illustrations/illustration-8.svg +1 -0
- package/src/assets/images/svg/separators/curve-2.svg +3 -0
- package/src/assets/images/svg/separators/curve.svg +3 -0
- package/src/assets/images/svg/separators/line.svg +3 -0
- package/src/assets/images/theme/light/screen-1-1000x800.jpg +0 -0
- package/src/assets/images/theme/light/screen-2-1000x800.jpg +0 -0
- package/src/assets/login/bg.jpg +0 -0
- package/src/assets/login/bg.png +0 -0
- package/src/assets/login/left.jpg +0 -0
- package/src/assets/logo.svg +73 -0
- package/src/assets/logo.webp +0 -0
- package/src/assets/main.css +1 -0
- package/src/base/config.ts +20 -0
- package/src/base/detect.ts +134 -0
- package/src/base/entity.ts +92 -0
- package/src/base/eventBus.ts +37 -0
- package/src/base//345/237/272/347/241/200/345/261/202.md +7 -0
- package/src/components/connection-editor/index.vue +590 -0
- package/src/components/dataGrid/index.vue +105 -0
- package/src/components/dataGrid/pagination.vue +106 -0
- package/src/components/loading/index.vue +43 -0
- package/src/components/modal/index.ts +181 -0
- package/src/components/modal/index.vue +560 -0
- package/src/components/toast/index.ts +44 -0
- package/src/components/toast/toast.vue +58 -0
- package/src/components/user/name.vue +104 -0
- package/src/components/user/selector.vue +416 -0
- package/src/domain/SysConfig.ts +74 -0
- package/src/platform/App.vue +8 -0
- package/src/platform/database/components/connection-detail.vue +1154 -0
- package/src/platform/database/components/data-editor.vue +478 -0
- package/src/platform/database/components/data-import-export.vue +1602 -0
- package/src/platform/database/components/database-detail.vue +1173 -0
- package/src/platform/database/components/database-monitor.vue +1086 -0
- package/src/platform/database/components/db-tools.vue +577 -0
- package/src/platform/database/components/query-history.vue +1349 -0
- package/src/platform/database/components/sql-executor.vue +738 -0
- package/src/platform/database/components/sql-query-editor.vue +1046 -0
- package/src/platform/database/components/table-detail.vue +1376 -0
- package/src/platform/database/components/table-editor.vue +690 -0
- package/src/platform/database/explorer.vue +1840 -0
- package/src/platform/database/index.vue +1193 -0
- package/src/platform/database/layout.vue +367 -0
- package/src/platform/database/router.ts +37 -0
- package/src/platform/database/styles/common.scss +602 -0
- package/src/platform/database/types/common.ts +445 -0
- package/src/platform/database/utils/export.ts +232 -0
- package/src/platform/database/utils/helpers.ts +437 -0
- package/src/platform/index.ts +33 -0
- package/src/platform/router.ts +41 -0
- package/src/service/base.ts +128 -0
- package/src/service/database.ts +500 -0
- package/src/service/login.ts +121 -0
- package/src/shims-vue.d.ts +7 -0
- package/src/stores/connection.ts +266 -0
- package/src/stores/session.ts +87 -0
- package/src/typings/database-types.ts +413 -0
- package/src/typings/database.ts +364 -0
- package/src/typings/global.d.ts +58 -0
- package/src/typings/pinia.d.ts +8 -0
- package/src/utils/clipboard.ts +30 -0
- package/src/utils/database-types.ts +243 -0
- package/src/utils/modal.ts +124 -0
- package/src/utils/request.ts +55 -0
- package/src/utils/sleep.ts +4 -0
- package/src/utils/toast.ts +73 -0
- package/src/utils/util.ts +171 -0
- package/src/utils/xlsx.ts +228 -0
- package/tsconfig.json +33 -0
- package/tsconfig.server.json +19 -0
- package/view/index.html +9 -0
- package/view/modules/header.tpl +14 -0
- package/view/modules/initial_state.tpl +20 -0
- package/vite.config.ts +384 -0
|
@@ -0,0 +1,1376 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="table-detail">
|
|
3
|
+
<!-- 表头部信息 -->
|
|
4
|
+
<div class="table-header">
|
|
5
|
+
<div class="table-header-content">
|
|
6
|
+
<div class="table-info">
|
|
7
|
+
<div class="table-icon">
|
|
8
|
+
<i class="bi bi-table"></i>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="table-meta">
|
|
11
|
+
<h4 class="table-name">{{ table?.name }}</h4>
|
|
12
|
+
<div class="table-breadcrumb">
|
|
13
|
+
<span class="connection">{{ connection?.name }}</span>
|
|
14
|
+
<i class="bi bi-chevron-right"></i>
|
|
15
|
+
<span class="database">{{ database }}</span>
|
|
16
|
+
<i class="bi bi-chevron-right"></i>
|
|
17
|
+
<span class="table">{{ table?.name }}</span>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="table-stats">
|
|
22
|
+
<div class="stat-item">
|
|
23
|
+
<div class="stat-value">{{ formatNumber(table?.rowCount || 0) }}</div>
|
|
24
|
+
<div class="stat-label">行数据</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="stat-item">
|
|
27
|
+
<div class="stat-value">{{ tableStructure?.columns?.length || 0 }}</div>
|
|
28
|
+
<div class="stat-label">列</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="stat-item">
|
|
31
|
+
<div class="stat-value">{{ tableStructure?.indexes?.length || 0 }}</div>
|
|
32
|
+
<div class="stat-label">索引</div>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="stat-item">
|
|
35
|
+
<div class="stat-value">{{ formatSize(table?.dataSize || 0) }}</div>
|
|
36
|
+
<div class="stat-label">大小</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- 操作工具栏 -->
|
|
43
|
+
<div class="table-toolbar">
|
|
44
|
+
<div class="toolbar-left">
|
|
45
|
+
<button class="btn btn-primary btn-sm" @click="refreshData">
|
|
46
|
+
<i class="bi bi-arrow-clockwise"></i> 刷新数据
|
|
47
|
+
</button>
|
|
48
|
+
<button class="btn btn-info btn-sm" @click="editTableStructure">
|
|
49
|
+
<i class="bi bi-pencil-square"></i> 修改表结构
|
|
50
|
+
</button>
|
|
51
|
+
<button class="btn btn-success btn-sm" @click="()=>insertData()">
|
|
52
|
+
<i class="bi bi-plus-lg"></i> 插入数据
|
|
53
|
+
</button>
|
|
54
|
+
<div class="btn-group">
|
|
55
|
+
<button class="btn btn-info btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
|
56
|
+
<i class="bi bi-download"></i> 导出
|
|
57
|
+
</button>
|
|
58
|
+
<ul class="dropdown-menu">
|
|
59
|
+
<li><button class="dropdown-item" @click="exportTableData('csv')">
|
|
60
|
+
<i class="bi bi-file-earmark-spreadsheet me-2"></i>导出 CSV
|
|
61
|
+
</button></li>
|
|
62
|
+
<li><button class="dropdown-item" @click="exportTableData('json')">
|
|
63
|
+
<i class="bi bi-file-earmark-code me-2"></i>导出 JSON
|
|
64
|
+
</button></li>
|
|
65
|
+
<li><button class="dropdown-item" @click="exportTableData('excel')">
|
|
66
|
+
<i class="bi bi-file-earmark-excel me-2"></i>导出 Excel
|
|
67
|
+
</button></li>
|
|
68
|
+
<li><hr class="dropdown-divider"></li>
|
|
69
|
+
<li><button class="dropdown-item" @click="exportTableStructure()">
|
|
70
|
+
<i class="bi bi-file-earmark-text me-2"></i>导出表结构
|
|
71
|
+
</button></li>
|
|
72
|
+
<li><button class="dropdown-item" @click="exportTableDataSQL()">
|
|
73
|
+
<i class="bi bi-file-earmark-code me-2"></i>导出表数据(SQL)
|
|
74
|
+
</button></li>
|
|
75
|
+
</ul>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="toolbar-right">
|
|
79
|
+
<button class="btn btn-outline-warning btn-sm" @click="truncateTable" v-if="tableData.length > 0">
|
|
80
|
+
<i class="bi bi-trash"></i> 清空表
|
|
81
|
+
</button>
|
|
82
|
+
<button class="btn btn-outline-danger btn-sm" @click="dropTable">
|
|
83
|
+
<i class="bi bi-x-circle"></i> 删除表
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<!-- 标签页 -->
|
|
89
|
+
<div class="table-tabs">
|
|
90
|
+
<ul class="nav nav-tabs">
|
|
91
|
+
<li class="nav-item">
|
|
92
|
+
<button
|
|
93
|
+
class="nav-link"
|
|
94
|
+
:class="{ active: activeTab === 'data' }"
|
|
95
|
+
@click="activeTab = 'data'"
|
|
96
|
+
>
|
|
97
|
+
<i class="bi bi-grid"></i> 数据
|
|
98
|
+
<span class="badge bg-secondary ms-2" v-if="tableData.length > 0">{{ tableData.length }}</span>
|
|
99
|
+
</button>
|
|
100
|
+
</li>
|
|
101
|
+
<li class="nav-item">
|
|
102
|
+
<button
|
|
103
|
+
class="nav-link"
|
|
104
|
+
:class="{ active: activeTab === 'structure' }"
|
|
105
|
+
@click="activeTab = 'structure'"
|
|
106
|
+
>
|
|
107
|
+
<i class="bi bi-diagram-3"></i> 结构
|
|
108
|
+
</button>
|
|
109
|
+
</li>
|
|
110
|
+
<li class="nav-item">
|
|
111
|
+
<button
|
|
112
|
+
class="nav-link"
|
|
113
|
+
:class="{ active: activeTab === 'indexes' }"
|
|
114
|
+
@click="activeTab = 'indexes'"
|
|
115
|
+
>
|
|
116
|
+
<i class="bi bi-key"></i> 索引
|
|
117
|
+
</button>
|
|
118
|
+
</li>
|
|
119
|
+
<li class="nav-item">
|
|
120
|
+
<button
|
|
121
|
+
class="nav-link"
|
|
122
|
+
:class="{ active: activeTab === 'relations' }"
|
|
123
|
+
@click="activeTab = 'relations'"
|
|
124
|
+
>
|
|
125
|
+
<i class="bi bi-link-45deg"></i> 关系
|
|
126
|
+
</button>
|
|
127
|
+
</li>
|
|
128
|
+
<li class="nav-item">
|
|
129
|
+
<button
|
|
130
|
+
class="nav-link"
|
|
131
|
+
:class="{ active: activeTab === 'sql' }"
|
|
132
|
+
@click="activeTab = 'sql'"
|
|
133
|
+
>
|
|
134
|
+
<i class="bi bi-code-slash"></i> SQL
|
|
135
|
+
</button>
|
|
136
|
+
</li>
|
|
137
|
+
</ul>
|
|
138
|
+
|
|
139
|
+
<div class="tab-content">
|
|
140
|
+
<!-- 数据标签页 -->
|
|
141
|
+
<div v-show="activeTab === 'data'" class="tab-panel">
|
|
142
|
+
<div class="data-content" :class="{ 'loading': loading }">
|
|
143
|
+
<div class="table-responsive" v-if="!loading && paginatedData.length > 0">
|
|
144
|
+
<table class="table table-sm table-striped table-hover">
|
|
145
|
+
<thead class="table-light">
|
|
146
|
+
<tr>
|
|
147
|
+
<th v-for="column in safeTableColumns" :key="column.name">
|
|
148
|
+
<div class="column-header">
|
|
149
|
+
<span>{{ column.name }}</span>
|
|
150
|
+
<small class="text-muted d-block">{{ column.type }}</small>
|
|
151
|
+
<span class="column-key" v-if="column.isPrimary">
|
|
152
|
+
<i class="bi bi-key-fill"></i>
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
</th>
|
|
156
|
+
<th width="100">操作</th>
|
|
157
|
+
</tr>
|
|
158
|
+
</thead>
|
|
159
|
+
<tbody>
|
|
160
|
+
<tr v-for="(row, index) in paginatedData" :key="index">
|
|
161
|
+
<td v-for="(value, key) in row" :key="key">
|
|
162
|
+
<div class="cell-value">
|
|
163
|
+
{{ formatCellValue(value) }}
|
|
164
|
+
</div>
|
|
165
|
+
</td>
|
|
166
|
+
<td>
|
|
167
|
+
<div class="btn-group btn-group-sm">
|
|
168
|
+
<button class="btn btn-outline-primary btn-sm" @click="editRow(row)">
|
|
169
|
+
<i class="bi bi-pencil"></i>
|
|
170
|
+
</button>
|
|
171
|
+
<button class="btn btn-outline-danger btn-sm" @click="deleteRow(row)">
|
|
172
|
+
<i class="bi bi-trash"></i>
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
</td>
|
|
176
|
+
</tr>
|
|
177
|
+
</tbody>
|
|
178
|
+
</table>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<!-- 加载状态 -->
|
|
182
|
+
<div v-if="loading" class="loading-state">
|
|
183
|
+
<div class="spinner-border text-primary" role="status">
|
|
184
|
+
<span class="visually-hidden">加载中...</span>
|
|
185
|
+
</div>
|
|
186
|
+
<p>正在加载数据...</p>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<!-- 空状态 -->
|
|
190
|
+
<div v-if="!loading && paginatedData.length === 0" class="empty-state">
|
|
191
|
+
<i class="bi bi-inbox"></i>
|
|
192
|
+
<p v-if="searchQuery">没有找到匹配的数据</p>
|
|
193
|
+
<p v-else>表中暂无数据</p>
|
|
194
|
+
<button class="btn btn-success" @click="()=>insertData()">
|
|
195
|
+
<i class="bi bi-plus"></i> 插入第一条数据
|
|
196
|
+
</button>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<!-- 分页 -->
|
|
200
|
+
<nav v-if="!loading && totalPages > 0" class="pagination-nav">
|
|
201
|
+
<div class="pagination-container">
|
|
202
|
+
<div class="pagination-info">
|
|
203
|
+
共 {{ formatNumber(total) }} 条记录,第 {{ formatNumber(currentPage) }} 页/共 {{ formatNumber(totalPages) }} 页
|
|
204
|
+
</div>
|
|
205
|
+
<ul class="pagination pagination-sm">
|
|
206
|
+
<li class="page-item" :class="{ disabled: currentPage === 1 }">
|
|
207
|
+
<a class="page-link" href="#" @click.prevent="goToPage(1)" title="首页">
|
|
208
|
+
<i class="bi bi-chevron-double-left"></i>
|
|
209
|
+
</a>
|
|
210
|
+
</li>
|
|
211
|
+
<li class="page-item" :class="{ disabled: currentPage === 1 }">
|
|
212
|
+
<a class="page-link" href="#" @click.prevent="goToPage(currentPage - 1)" title="上一页">
|
|
213
|
+
<i class="bi bi-chevron-left"></i>
|
|
214
|
+
</a>
|
|
215
|
+
</li>
|
|
216
|
+
|
|
217
|
+
<!-- 第一页和省略号 -->
|
|
218
|
+
<li v-if="currentPage > 4" class="page-item">
|
|
219
|
+
<a class="page-link" href="#" @click.prevent="goToPage(1)">1</a>
|
|
220
|
+
</li>
|
|
221
|
+
<li v-if="currentPage > 5" class="page-item disabled">
|
|
222
|
+
<span class="page-link">...</span>
|
|
223
|
+
</li>
|
|
224
|
+
|
|
225
|
+
<!-- 中间页码 -->
|
|
226
|
+
<li
|
|
227
|
+
v-for="page in visiblePages"
|
|
228
|
+
:key="page"
|
|
229
|
+
class="page-item"
|
|
230
|
+
:class="{ active: currentPage === page }"
|
|
231
|
+
>
|
|
232
|
+
<a class="page-link" href="#" @click.prevent="goToPage(page)">{{ page }}</a>
|
|
233
|
+
</li>
|
|
234
|
+
|
|
235
|
+
<!-- 省略号和最后一页 -->
|
|
236
|
+
<li v-if="currentPage < totalPages - 4" class="page-item disabled">
|
|
237
|
+
<span class="page-link">...</span>
|
|
238
|
+
</li>
|
|
239
|
+
<li v-if="currentPage < totalPages - 3" class="page-item">
|
|
240
|
+
<a class="page-link" href="#" @click.prevent="goToPage(totalPages)">{{ totalPages }}</a>
|
|
241
|
+
</li>
|
|
242
|
+
|
|
243
|
+
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
|
|
244
|
+
<a class="page-link" href="#" @click.prevent="goToPage(currentPage + 1)" title="下一页">
|
|
245
|
+
<i class="bi bi-chevron-right"></i>
|
|
246
|
+
</a>
|
|
247
|
+
</li>
|
|
248
|
+
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
|
|
249
|
+
<a class="page-link" href="#" @click.prevent="goToPage(totalPages)" title="末页">
|
|
250
|
+
<i class="bi bi-chevron-double-right"></i>
|
|
251
|
+
</a>
|
|
252
|
+
</li>
|
|
253
|
+
</ul>
|
|
254
|
+
<div class="page-size-selector">
|
|
255
|
+
<label class="form-label-sm mb-0">每页显示:</label>
|
|
256
|
+
<select class="form-select form-select-sm ms-2" v-model="pageSize" style="width: 80px;">
|
|
257
|
+
<option :value="10">10</option>
|
|
258
|
+
<option :value="20">20</option>
|
|
259
|
+
<option :value="50">50</option>
|
|
260
|
+
<option :value="100">100</option>
|
|
261
|
+
<option :value="200">200</option>
|
|
262
|
+
<option :value="500">500</option>
|
|
263
|
+
</select>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="page-jump">
|
|
266
|
+
<label class="form-label-sm mb-0">跳转到:</label>
|
|
267
|
+
<input
|
|
268
|
+
type="number"
|
|
269
|
+
class="form-control form-control-sm ms-2"
|
|
270
|
+
v-model.number="jumpToPage"
|
|
271
|
+
min="1"
|
|
272
|
+
:max="totalPages"
|
|
273
|
+
style="width: 70px;"
|
|
274
|
+
@keyup.enter="jumpToPageHandler"
|
|
275
|
+
@blur="jumpToPageHandler"
|
|
276
|
+
>
|
|
277
|
+
<button class="btn btn-primary btn-sm ms-2" @click="jumpToPageHandler">
|
|
278
|
+
跳转
|
|
279
|
+
</button>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
</nav>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<!-- 结构标签页 -->
|
|
287
|
+
<div v-show="activeTab === 'structure'" class="tab-panel">
|
|
288
|
+
<div class="structure-actions mb-3">
|
|
289
|
+
<button class="btn btn-success btn-sm" @click="addColumn">
|
|
290
|
+
<i class="bi bi-plus-lg"></i> 新增字段
|
|
291
|
+
</button>
|
|
292
|
+
<button class="btn btn-info btn-sm" @click="editTableStructure">
|
|
293
|
+
<i class="bi bi-pencil-square"></i> 修改表结构
|
|
294
|
+
</button>
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<div class="structure-content">
|
|
298
|
+
<div class="structure-table">
|
|
299
|
+
<table class="table table-bordered">
|
|
300
|
+
<thead class="table-dark">
|
|
301
|
+
<tr>
|
|
302
|
+
<th>列名</th>
|
|
303
|
+
<th>数据类型</th>
|
|
304
|
+
<th>可空</th>
|
|
305
|
+
<th>默认值</th>
|
|
306
|
+
<th>主键</th>
|
|
307
|
+
<th>自增</th>
|
|
308
|
+
<th>注释</th>
|
|
309
|
+
<th width="100">操作</th>
|
|
310
|
+
</tr>
|
|
311
|
+
</thead>
|
|
312
|
+
<tbody>
|
|
313
|
+
<tr v-for="column in tableStructure?.columns || []" :key="column.name">
|
|
314
|
+
<td><strong>{{ column.name }}</strong></td>
|
|
315
|
+
<td><code>{{ column.type }}</code></td>
|
|
316
|
+
<td>
|
|
317
|
+
<span :class="column.nullable ? 'text-warning' : 'text-success'">
|
|
318
|
+
<i :class="column.nullable ? 'bi bi-unlock' : 'bi bi-lock-fill'"></i>
|
|
319
|
+
{{ column.nullable ? 'YES' : 'NO' }}
|
|
320
|
+
</span>
|
|
321
|
+
</td>
|
|
322
|
+
<td>{{ column.defaultValue || '-' }}</td>
|
|
323
|
+
<td>
|
|
324
|
+
<span v-if="column.isPrimary" class="badge bg-primary">
|
|
325
|
+
<i class="bi bi-key-fill"></i> 主键
|
|
326
|
+
</span>
|
|
327
|
+
<span v-else>-</span>
|
|
328
|
+
</td>
|
|
329
|
+
<td>
|
|
330
|
+
<span v-if="column.isAutoIncrement" class="badge bg-success">
|
|
331
|
+
<i class="bi bi-arrow-up-circle"></i> 自增
|
|
332
|
+
</span>
|
|
333
|
+
<span v-else>-</span>
|
|
334
|
+
</td>
|
|
335
|
+
<td>{{ column.comment || '-' }}</td>
|
|
336
|
+
<td>
|
|
337
|
+
<div class="btn-group btn-group-sm">
|
|
338
|
+
<button class="btn btn-outline-primary btn-sm" @click="editColumn(column)">
|
|
339
|
+
<i class="bi bi-pencil"></i>
|
|
340
|
+
</button>
|
|
341
|
+
<button class="btn btn-outline-danger btn-sm" @click="deleteColumn(column)">
|
|
342
|
+
<i class="bi bi-trash"></i>
|
|
343
|
+
</button>
|
|
344
|
+
</div>
|
|
345
|
+
</td>
|
|
346
|
+
</tr>
|
|
347
|
+
</tbody>
|
|
348
|
+
</table>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<!-- 索引标签页 -->
|
|
354
|
+
<div v-show="activeTab === 'indexes'" class="tab-panel">
|
|
355
|
+
<div class="indexes-content">
|
|
356
|
+
<div class="indexes-table">
|
|
357
|
+
<table class="table table-bordered">
|
|
358
|
+
<thead class="table-dark">
|
|
359
|
+
<tr>
|
|
360
|
+
<th>索引名</th>
|
|
361
|
+
<th>类型</th>
|
|
362
|
+
<th>唯一</th>
|
|
363
|
+
<th>列</th>
|
|
364
|
+
<th width="100">操作</th>
|
|
365
|
+
</tr>
|
|
366
|
+
</thead>
|
|
367
|
+
<tbody>
|
|
368
|
+
<tr v-for="index in tableStructure?.indexes || []" :key="index.name">
|
|
369
|
+
<td><strong>{{ index.name }}</strong></td>
|
|
370
|
+
<td><span class="badge bg-info">{{ index.type }}</span></td>
|
|
371
|
+
<td>
|
|
372
|
+
<span :class="index.unique ? 'text-success' : 'text-secondary'">
|
|
373
|
+
<i :class="index.unique ? 'bi bi-check-circle-fill' : 'bi bi-circle'"></i>
|
|
374
|
+
{{ index.unique ? '是' : '否' }}
|
|
375
|
+
</span>
|
|
376
|
+
</td>
|
|
377
|
+
<td><code>{{ index.columns.join(', ') }}</code></td>
|
|
378
|
+
<td>
|
|
379
|
+
<div class="btn-group btn-group-sm">
|
|
380
|
+
<button class="btn btn-outline-primary btn-sm" @click="editIndex(index)">
|
|
381
|
+
<i class="bi bi-pencil"></i>
|
|
382
|
+
</button>
|
|
383
|
+
<button class="btn btn-outline-danger btn-sm" @click="deleteIndex(index)">
|
|
384
|
+
<i class="bi bi-trash"></i>
|
|
385
|
+
</button>
|
|
386
|
+
</div>
|
|
387
|
+
</td>
|
|
388
|
+
</tr>
|
|
389
|
+
</tbody>
|
|
390
|
+
</table>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<!-- 关系标签页 -->
|
|
396
|
+
<div v-show="activeTab === 'relations'" class="tab-panel">
|
|
397
|
+
<div class="relations-content">
|
|
398
|
+
<div class="relations-table">
|
|
399
|
+
<table class="table table-bordered">
|
|
400
|
+
<thead class="table-dark">
|
|
401
|
+
<tr>
|
|
402
|
+
<th>约束名</th>
|
|
403
|
+
<th>本表列</th>
|
|
404
|
+
<th>目标表</th>
|
|
405
|
+
<th>目标列</th>
|
|
406
|
+
<th>删除规则</th>
|
|
407
|
+
<th>更新规则</th>
|
|
408
|
+
<th width="100">操作</th>
|
|
409
|
+
</tr>
|
|
410
|
+
</thead>
|
|
411
|
+
<tbody>
|
|
412
|
+
<tr v-for="fk in tableStructure?.foreignKeys || []" :key="fk.name">
|
|
413
|
+
<td><strong>{{ fk.name }}</strong></td>
|
|
414
|
+
<td><code>{{ fk.column }}</code></td>
|
|
415
|
+
<td><code>{{ fk.referencedTable }}</code></td>
|
|
416
|
+
<td><code>{{ fk.referencedColumn }}</code></td>
|
|
417
|
+
<td>{{ fk.onDelete || '-' }}</td>
|
|
418
|
+
<td>{{ fk.onUpdate || '-' }}</td>
|
|
419
|
+
<td>
|
|
420
|
+
<div class="btn-group btn-group-sm">
|
|
421
|
+
<button class="btn btn-outline-danger btn-sm" @click="deleteForeignKey(fk)">
|
|
422
|
+
<i class="bi bi-trash"></i>
|
|
423
|
+
</button>
|
|
424
|
+
</div>
|
|
425
|
+
</td>
|
|
426
|
+
</tr>
|
|
427
|
+
</tbody>
|
|
428
|
+
</table>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<!-- SQL标签页 -->
|
|
434
|
+
<div v-show="activeTab === 'sql'" class="tab-panel">
|
|
435
|
+
<div class="sql-section">
|
|
436
|
+
<SqlExecutor
|
|
437
|
+
:connection="connection"
|
|
438
|
+
:database="database"
|
|
439
|
+
/>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
<!-- 数据编辑器 -->
|
|
446
|
+
<DataEditor
|
|
447
|
+
:visible="showDataEditor"
|
|
448
|
+
:is-edit="isEditMode"
|
|
449
|
+
:data="editingRow"
|
|
450
|
+
:columns="safeTableColumns"
|
|
451
|
+
:connection="connection"
|
|
452
|
+
:database="database"
|
|
453
|
+
:table-name="table?.name"
|
|
454
|
+
@close="closeDataEditor"
|
|
455
|
+
@submit="handleDataSubmit"
|
|
456
|
+
/>
|
|
457
|
+
|
|
458
|
+
<!-- 表格编辑器 -->
|
|
459
|
+
<TableEditor
|
|
460
|
+
:visible="showTableEditor"
|
|
461
|
+
:connection="connection"
|
|
462
|
+
:database="database"
|
|
463
|
+
:table="table"
|
|
464
|
+
:mode="tableEditorMode"
|
|
465
|
+
@close="closeTableEditor"
|
|
466
|
+
@submit="handleTableStructureChange"
|
|
467
|
+
/>
|
|
468
|
+
</div>
|
|
469
|
+
</template>
|
|
470
|
+
|
|
471
|
+
<script lang="ts" setup>
|
|
472
|
+
import { ref, computed, watch, onMounted } from 'vue';
|
|
473
|
+
import type { ConnectionEntity, TableEntity } from '@/typings/database';
|
|
474
|
+
import { DatabaseService } from '@/service/database';
|
|
475
|
+
import DataEditor from './data-editor.vue';
|
|
476
|
+
|
|
477
|
+
import TableEditor from './table-editor.vue';
|
|
478
|
+
import SqlExecutor from './sql-executor.vue';
|
|
479
|
+
import { exportDataToCSV, exportDataToJSON, exportDataToExcel, formatFileName } from '../utils/export';
|
|
480
|
+
import { modal } from '@/utils/modal';
|
|
481
|
+
import { isNumericType, isBooleanType } from '@/utils/database-types';
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
// Props
|
|
485
|
+
const props = defineProps<{
|
|
486
|
+
connection: ConnectionEntity | null;
|
|
487
|
+
database: string;
|
|
488
|
+
table: TableEntity | null;
|
|
489
|
+
tableData: any[];
|
|
490
|
+
tableStructure: any;
|
|
491
|
+
loading: boolean;
|
|
492
|
+
total: number;
|
|
493
|
+
sqlExecuting?: boolean;
|
|
494
|
+
sqlResult?: {
|
|
495
|
+
success: boolean;
|
|
496
|
+
message?: string;
|
|
497
|
+
data?: any[];
|
|
498
|
+
columns?: string[];
|
|
499
|
+
affectedRows?: number;
|
|
500
|
+
insertId?: any;
|
|
501
|
+
error?: string;
|
|
502
|
+
};
|
|
503
|
+
}>();
|
|
504
|
+
|
|
505
|
+
// Emits
|
|
506
|
+
const emit = defineEmits<{
|
|
507
|
+
'refresh-data': [page: number, pageSize: number, searchQuery?: string];
|
|
508
|
+
'refresh-database': [];
|
|
509
|
+
'refresh-structure': [];
|
|
510
|
+
'truncate-table': [];
|
|
511
|
+
'drop-table': [];
|
|
512
|
+
'delete-row': [row: any];
|
|
513
|
+
'insert-data': [];
|
|
514
|
+
'export-table': [];
|
|
515
|
+
'edit-row': [row: any];
|
|
516
|
+
'execute-sql': [sql: string];
|
|
517
|
+
}>();
|
|
518
|
+
|
|
519
|
+
const databaseService = new DatabaseService();
|
|
520
|
+
|
|
521
|
+
// 响应式数据
|
|
522
|
+
const activeTab = ref('data');
|
|
523
|
+
const searchQuery = ref('');
|
|
524
|
+
const currentPage = ref(1);
|
|
525
|
+
const pageSize = ref(50);
|
|
526
|
+
const sqlQuery = ref('');
|
|
527
|
+
const jumpToPage = ref(1);
|
|
528
|
+
const searchTimeout = ref<NodeJS.Timeout | null>(null);
|
|
529
|
+
|
|
530
|
+
// 数据编辑相关
|
|
531
|
+
const showDataEditor = ref(false);
|
|
532
|
+
const isEditMode = ref(false);
|
|
533
|
+
const editingRow = ref<any>(null);
|
|
534
|
+
|
|
535
|
+
// 表格编辑器相关
|
|
536
|
+
const showTableEditor = ref(false);
|
|
537
|
+
const tableEditorMode = ref<'create' | 'edit'>('edit');
|
|
538
|
+
|
|
539
|
+
// 计算属性
|
|
540
|
+
const tableColumns = computed(() => props.tableStructure?.columns || []);
|
|
541
|
+
|
|
542
|
+
// 类型安全的表列数据
|
|
543
|
+
const safeTableColumns = computed(() => {
|
|
544
|
+
const columns = props.tableStructure?.columns || [];
|
|
545
|
+
return columns.map(col => ({
|
|
546
|
+
...col,
|
|
547
|
+
name: col.name || '',
|
|
548
|
+
type: col.type || '',
|
|
549
|
+
nullable: !!col.nullable,
|
|
550
|
+
isPrimary: !!col.isPrimary,
|
|
551
|
+
isAutoIncrement: !!col.isAutoIncrement,
|
|
552
|
+
comment: col.comment || ''
|
|
553
|
+
}));
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// 直接使用后端返回的数据,不需要前端分页和过滤
|
|
557
|
+
const paginatedData = computed(() => {
|
|
558
|
+
return props.tableData || [];
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const totalPages = computed(() => {
|
|
562
|
+
const total = parseInt(props.total) || 0;
|
|
563
|
+
return Math.ceil(total / pageSize.value);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const visiblePages = computed(() => {
|
|
567
|
+
const pages: number[] = [];
|
|
568
|
+
let start = Math.max(1, currentPage.value - 2);
|
|
569
|
+
let end = Math.min(totalPages.value, start + 4);
|
|
570
|
+
|
|
571
|
+
// 如果显示的页码数不足5个,调整起始位置
|
|
572
|
+
if (end - start < 4) {
|
|
573
|
+
start = Math.max(1, end - 4);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
for (let i = start; i <= end; i++) {
|
|
577
|
+
pages.push(i);
|
|
578
|
+
}
|
|
579
|
+
return pages;
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// 监听变化
|
|
583
|
+
watch(() => props.table, () => {
|
|
584
|
+
activeTab.value = 'data';
|
|
585
|
+
currentPage.value = 1;
|
|
586
|
+
searchQuery.value = '';
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
watch(pageSize, () => {
|
|
590
|
+
currentPage.value = 1;
|
|
591
|
+
jumpToPage.value = 1;
|
|
592
|
+
// 调用后端分页接口
|
|
593
|
+
emit('refresh-data', currentPage.value, pageSize.value, searchQuery.value);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
watch(currentPage, (newPage) => {
|
|
597
|
+
jumpToPage.value = newPage;
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
// 方法
|
|
601
|
+
function formatSize(bytes: number): string {
|
|
602
|
+
if (bytes === 0) return '0 B';
|
|
603
|
+
const k = 1024;
|
|
604
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
605
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
606
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function formatNumber(num: number): string {
|
|
610
|
+
return num?.toLocaleString?.() || num?.toString() || '';
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function formatCellValue(value: any): string {
|
|
614
|
+
if (value === null || value === undefined) return 'NULL';
|
|
615
|
+
|
|
616
|
+
// 尝试检测并格式化 JSON 数据
|
|
617
|
+
let strValue = String(value);
|
|
618
|
+
if (typeof value === 'string') {
|
|
619
|
+
// 检查是否可能是 JSON 字符串
|
|
620
|
+
const trimmedValue = strValue.trim();
|
|
621
|
+
if ((trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) ||
|
|
622
|
+
(trimmedValue.startsWith('[') && trimmedValue.endsWith(']'))) {
|
|
623
|
+
try {
|
|
624
|
+
const parsed = JSON.parse(trimmedValue);
|
|
625
|
+
// 格式化 JSON 并限制长度
|
|
626
|
+
const formatted = JSON.stringify(parsed, null, 2);
|
|
627
|
+
if (formatted.length > 50) {
|
|
628
|
+
return formatted.substring(0, 50) + '...';
|
|
629
|
+
}
|
|
630
|
+
return formatted;
|
|
631
|
+
} catch (e) {
|
|
632
|
+
// 不是有效的 JSON,继续处理
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
} else if (typeof value === 'object') {
|
|
636
|
+
// 对于对象或数组类型,直接格式化
|
|
637
|
+
try {
|
|
638
|
+
const formatted = JSON.stringify(value, null, 2);
|
|
639
|
+
if (formatted.length > 50) {
|
|
640
|
+
return formatted.substring(0, 50) + '...';
|
|
641
|
+
}
|
|
642
|
+
return formatted;
|
|
643
|
+
} catch (e) {
|
|
644
|
+
// 格式化失败,继续处理
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 对于普通字符串,限制显示长度
|
|
649
|
+
if (strValue.length > 50) return strValue.substring(0, 50) + '...';
|
|
650
|
+
|
|
651
|
+
return strValue;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function goToPage(page: number) {
|
|
655
|
+
if (page >= 1 && page <= totalPages.value) {
|
|
656
|
+
currentPage.value = page;
|
|
657
|
+
// 调用后端分页接口
|
|
658
|
+
emit('refresh-data', currentPage.value, pageSize.value, searchQuery.value);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function jumpToPageHandler() {
|
|
663
|
+
if (jumpToPage.value >= 1 && jumpToPage.value <= totalPages.value) {
|
|
664
|
+
currentPage.value = jumpToPage.value;
|
|
665
|
+
// 调用后端分页接口
|
|
666
|
+
emit('refresh-data', currentPage.value, pageSize.value, searchQuery.value);
|
|
667
|
+
} else {
|
|
668
|
+
// 重置到有效范围
|
|
669
|
+
jumpToPage.value = Math.max(1, Math.min(jumpToPage.value, totalPages.value));
|
|
670
|
+
currentPage.value = jumpToPage.value;
|
|
671
|
+
emit('refresh-data', currentPage.value, pageSize.value, searchQuery.value);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function handleSearch() {
|
|
676
|
+
// 使用防抖,避免频繁调用后端接口
|
|
677
|
+
clearTimeout(searchTimeout.value);
|
|
678
|
+
searchTimeout.value = setTimeout(() => {
|
|
679
|
+
currentPage.value = 1;
|
|
680
|
+
jumpToPage.value = 1;
|
|
681
|
+
// 调用后端搜索接口
|
|
682
|
+
emit('refresh-data', currentPage.value, pageSize.value, searchQuery.value);
|
|
683
|
+
}, 500);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function refreshData() {
|
|
687
|
+
emit('refresh-data', currentPage.value, pageSize.value, searchQuery.value);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function insertData(newData?: any) {
|
|
691
|
+
if (newData) {
|
|
692
|
+
// 从编辑器来的新增数据
|
|
693
|
+
performInsert(newData);
|
|
694
|
+
} else {
|
|
695
|
+
// 新增按钮点击,打开编辑器
|
|
696
|
+
editingRow.value = null;
|
|
697
|
+
isEditMode.value = false;
|
|
698
|
+
showDataEditor.value = true;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async function performInsert(data: any) {
|
|
703
|
+
try {
|
|
704
|
+
// 构建INSERT语句
|
|
705
|
+
const columns = [];
|
|
706
|
+
const values = [];
|
|
707
|
+
|
|
708
|
+
safeTableColumns.value.forEach((column: any) => {
|
|
709
|
+
if (!column.isPrimary || !column.isAutoIncrement) {
|
|
710
|
+
columns.push(column.name);
|
|
711
|
+
values.push(formatValueForSQL(data[column.name], column.type));
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
if (columns.length === 0) {
|
|
716
|
+
await modal.error('没有可插入的字段');
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const sql = `INSERT INTO ${props.table?.name} (${columns.join(', ')}) VALUES (${values.join(', ')})`;
|
|
721
|
+
|
|
722
|
+
// 执行SQL
|
|
723
|
+
emit('execute-sql', sql);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
console.error('插入数据失败:', error);
|
|
726
|
+
modal.error('插入数据失败: ' + (error as any).message);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function editRow(row: any) {
|
|
731
|
+
editingRow.value = row;
|
|
732
|
+
isEditMode.value = true;
|
|
733
|
+
showDataEditor.value = true;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function deleteRow(row: any) {
|
|
737
|
+
try {
|
|
738
|
+
const result = await modal.confirm('确定要删除这条记录吗?', {
|
|
739
|
+
confirmButtonText: '删除',
|
|
740
|
+
cancelButtonText: '取消',
|
|
741
|
+
type: 'danger'
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
if (result) {
|
|
745
|
+
emit('delete-row', row);
|
|
746
|
+
}
|
|
747
|
+
} catch (error) {
|
|
748
|
+
console.error('删除行失败:', error);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function truncateTable() {
|
|
753
|
+
try {
|
|
754
|
+
const result = await modal.confirm('确定要清空表中的所有数据吗?此操作不可恢复!', {
|
|
755
|
+
confirmButtonText: '确定清空',
|
|
756
|
+
cancelButtonText: '取消',
|
|
757
|
+
type: 'danger'
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
if (result) {
|
|
761
|
+
emit('truncate-table');
|
|
762
|
+
}
|
|
763
|
+
} catch (error) {
|
|
764
|
+
console.error('清空表失败:', error);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function dropTable() {
|
|
769
|
+
try {
|
|
770
|
+
const result = await modal.confirm('确定要删除此表吗?此操作不可恢复!', {
|
|
771
|
+
confirmButtonText: '删除',
|
|
772
|
+
cancelButtonText: '取消',
|
|
773
|
+
type: 'danger'
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
if (result) {
|
|
777
|
+
try {
|
|
778
|
+
const response = await databaseService.dropTable(
|
|
779
|
+
props.connection?.id || '',
|
|
780
|
+
props.database,
|
|
781
|
+
props.table?.name || ''
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
if (response.ret === 0 && response.data?.success) {
|
|
785
|
+
await modal.success('表删除成功');
|
|
786
|
+
// 表删除后需要返回到数据库视图,这里通过事件通知父组件
|
|
787
|
+
emit('refresh-database');
|
|
788
|
+
} else {
|
|
789
|
+
await modal.error('表删除失败');
|
|
790
|
+
}
|
|
791
|
+
} catch (error) {
|
|
792
|
+
console.error('删除表失败:', error);
|
|
793
|
+
modal.error(error.msg || error.message || '删除表失败', {
|
|
794
|
+
operation: 'DROP_TABLE',
|
|
795
|
+
table: props.table?.name,
|
|
796
|
+
stack: error.stack
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
} catch (error) {
|
|
801
|
+
console.error('删除表失败:', error);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function handleDataSubmit(result: any) {
|
|
806
|
+
try {
|
|
807
|
+
|
|
808
|
+
if (result.ret === 0) {
|
|
809
|
+
// 操作成功,刷新数据
|
|
810
|
+
emit('refresh-data');
|
|
811
|
+
closeDataEditor();
|
|
812
|
+
} else {
|
|
813
|
+
modal.error('操作失败');
|
|
814
|
+
}
|
|
815
|
+
} catch (error) {
|
|
816
|
+
console.error('处理数据提交失败:', error);
|
|
817
|
+
|
|
818
|
+
modal.error(error.msg || error.message || '操作失败', {
|
|
819
|
+
//operation: operation,
|
|
820
|
+
table: props.table?.name,
|
|
821
|
+
stack: error.stack
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function closeDataEditor() {
|
|
827
|
+
showDataEditor.value = false;
|
|
828
|
+
editingRow.value = null;
|
|
829
|
+
isEditMode.value = false;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// 表格编辑相关方法
|
|
833
|
+
function editTableStructure() {
|
|
834
|
+
tableEditorMode.value = 'edit';
|
|
835
|
+
showTableEditor.value = true;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function addColumn() {
|
|
839
|
+
// 这里可以打开列编辑器或直接调用表格编辑器
|
|
840
|
+
tableEditorMode.value = 'edit';
|
|
841
|
+
showTableEditor.value = true;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function closeTableEditor() {
|
|
845
|
+
showTableEditor.value = false;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function handleTableStructureChange(result: any) {
|
|
849
|
+
try {
|
|
850
|
+
|
|
851
|
+
if (result.success) {
|
|
852
|
+
// 表结构修改成功,刷新结构
|
|
853
|
+
emit('refresh-structure');
|
|
854
|
+
emit('refresh-database');
|
|
855
|
+
closeTableEditor();
|
|
856
|
+
await modal.success('表结构修改成功');
|
|
857
|
+
} else {
|
|
858
|
+
await modal.error('表结构修改失败');
|
|
859
|
+
}
|
|
860
|
+
} catch (error) {
|
|
861
|
+
console.error('处理表结构修改失败:', error);
|
|
862
|
+
|
|
863
|
+
modal.error(error.msg || error.message || '表结构修改失败', {
|
|
864
|
+
operation: 'MODIFY_TABLE',
|
|
865
|
+
table: props.table?.name,
|
|
866
|
+
stack: error.stack
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// 其他方法
|
|
872
|
+
function editColumn(column: any) {
|
|
873
|
+
// 打开列编辑器
|
|
874
|
+
tableEditorMode.value = 'edit';
|
|
875
|
+
showTableEditor.value = true;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function deleteColumn(column: any) {
|
|
879
|
+
// 删除列
|
|
880
|
+
modal.confirm(`确定要删除列 ${column.name} 吗?`, {
|
|
881
|
+
confirmButtonText: '删除',
|
|
882
|
+
cancelButtonText: '取消',
|
|
883
|
+
type: 'danger'
|
|
884
|
+
}).then(result => {
|
|
885
|
+
if (result) {
|
|
886
|
+
// 这里可以调用API删除列
|
|
887
|
+
emit('refresh-structure');
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function editIndex(index: any) {
|
|
893
|
+
// 编辑索引
|
|
894
|
+
console.log('编辑索引:', index);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function deleteIndex(index: any) {
|
|
898
|
+
// 删除索引
|
|
899
|
+
modal.confirm(`确定要删除索引 ${index.name} 吗?`, {
|
|
900
|
+
confirmButtonText: '删除',
|
|
901
|
+
cancelButtonText: '取消',
|
|
902
|
+
type: 'danger'
|
|
903
|
+
}).then(result => {
|
|
904
|
+
if (result) {
|
|
905
|
+
// 这里可以调用API删除索引
|
|
906
|
+
emit('refresh-structure');
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function deleteForeignKey(fk: any) {
|
|
912
|
+
// 删除外键
|
|
913
|
+
modal.confirm(`确定要删除外键 ${fk.name} 吗?`, {
|
|
914
|
+
confirmButtonText: '删除',
|
|
915
|
+
cancelButtonText: '取消',
|
|
916
|
+
type: 'danger'
|
|
917
|
+
}).then(result => {
|
|
918
|
+
if (result) {
|
|
919
|
+
// 这里可以调用API删除外键
|
|
920
|
+
emit('refresh-structure');
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function formatValueForSQL(value: any, type: string): string {
|
|
926
|
+
if (value === null || value === undefined) {
|
|
927
|
+
return 'NULL';
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (typeof value === 'string') {
|
|
931
|
+
// 转义单引号
|
|
932
|
+
const escaped = value.replace(/'/g, "''");
|
|
933
|
+
return `'${escaped}'`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (typeof value === 'boolean') {
|
|
937
|
+
return value ? '1' : '0';
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return String(value);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
async function exportTableData(format: 'csv' | 'json' | 'excel') {
|
|
946
|
+
try {
|
|
947
|
+
if (!props.connection || !props.database || !props.table?.name) {
|
|
948
|
+
await modal.warning('缺少必要的连接信息');
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// 调用后端API导出表数据
|
|
953
|
+
let response;
|
|
954
|
+
switch (format) {
|
|
955
|
+
case 'csv':
|
|
956
|
+
response = await databaseService.exportTableDataToCSV(
|
|
957
|
+
props.connection.id,
|
|
958
|
+
props.database,
|
|
959
|
+
props.table.name
|
|
960
|
+
);
|
|
961
|
+
break;
|
|
962
|
+
case 'json':
|
|
963
|
+
response = await databaseService.exportTableDataToJSON(
|
|
964
|
+
props.connection.id,
|
|
965
|
+
props.database,
|
|
966
|
+
props.table.name
|
|
967
|
+
);
|
|
968
|
+
break;
|
|
969
|
+
case 'excel':
|
|
970
|
+
response = await databaseService.exportTableDataToExcel(
|
|
971
|
+
props.connection.id,
|
|
972
|
+
props.database,
|
|
973
|
+
props.table.name
|
|
974
|
+
);
|
|
975
|
+
break;
|
|
976
|
+
default:
|
|
977
|
+
throw new Error('不支持的导出格式');
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (response.ret === 0) {
|
|
981
|
+
await modal.success(`表数据导出成功,文件路径:${response.data}`);
|
|
982
|
+
} else {
|
|
983
|
+
await modal.error('导出表数据失败: ' + response.msg);
|
|
984
|
+
}
|
|
985
|
+
} catch (error) {
|
|
986
|
+
console.error('导出表数据失败:', error);
|
|
987
|
+
modal.error('导出表数据失败: ' + (error as any).message);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async function exportTableStructure() {
|
|
992
|
+
try {
|
|
993
|
+
// 构建CREATE TABLE语句
|
|
994
|
+
let createTableSQL = `CREATE TABLE ${props.table?.name} (
|
|
995
|
+
`;
|
|
996
|
+
|
|
997
|
+
const columns = [];
|
|
998
|
+
safeTableColumns.value.forEach((column: any, index: number) => {
|
|
999
|
+
let columnDef = ` ${column.name} ${column.type}`;
|
|
1000
|
+
|
|
1001
|
+
if (!column.nullable) {
|
|
1002
|
+
columnDef += ' NOT NULL';
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (column.defaultValue !== undefined && column.defaultValue !== null) {
|
|
1006
|
+
if (typeof column.defaultValue === 'string') {
|
|
1007
|
+
columnDef += ` DEFAULT '${column.defaultValue.replace(/'/g, "''")}'`;
|
|
1008
|
+
} else {
|
|
1009
|
+
columnDef += ` DEFAULT ${column.defaultValue}`;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (column.isPrimary) {
|
|
1014
|
+
columnDef += ' PRIMARY KEY';
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (column.isAutoIncrement) {
|
|
1018
|
+
columnDef += ' AUTO_INCREMENT';
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (column.comment) {
|
|
1022
|
+
columnDef += ` COMMENT '${column.comment.replace(/'/g, "''")}'`;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
columns.push(columnDef);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
createTableSQL += columns.join(',\n');
|
|
1029
|
+
createTableSQL += '\n);\n';
|
|
1030
|
+
|
|
1031
|
+
// 添加索引
|
|
1032
|
+
if (props.tableStructure?.indexes) {
|
|
1033
|
+
props.tableStructure.indexes.forEach((index: any) => {
|
|
1034
|
+
if (!index.isPrimary) {
|
|
1035
|
+
createTableSQL += `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${index.name} ON ${props.table?.name} (${index.columns.join(', ')})\n`;
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// 添加外键
|
|
1041
|
+
if (props.tableStructure?.foreignKeys) {
|
|
1042
|
+
props.tableStructure.foreignKeys.forEach((fk: any) => {
|
|
1043
|
+
createTableSQL += `ALTER TABLE ${props.table?.name} ADD CONSTRAINT ${fk.name} FOREIGN KEY (${fk.column}) REFERENCES ${fk.referencedTable} (${fk.referencedColumn})${fk.onDelete ? ` ON DELETE ${fk.onDelete}` : ''}${fk.onUpdate ? ` ON UPDATE ${fk.onUpdate}` : ''}\n`;
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// 下载SQL文件
|
|
1048
|
+
downloadSQLFile(createTableSQL, `${props.table?.name}_structure.sql`);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
console.error('导出表结构失败:', error);
|
|
1051
|
+
modal.error('导出表结构失败: ' + (error as any).message);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async function exportTableDataSQL() {
|
|
1056
|
+
try {
|
|
1057
|
+
if (!props.connection || !props.database || !props.table?.name) {
|
|
1058
|
+
await modal.warning('缺少必要的连接信息');
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// 调用后端API导出表数据
|
|
1063
|
+
const response = await databaseService.exportTableDataToSQL(
|
|
1064
|
+
props.connection.id,
|
|
1065
|
+
props.database,
|
|
1066
|
+
props.table.name
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
if (response.ret === 0) {
|
|
1070
|
+
await modal.success(`表数据导出成功,文件路径:${response.data}`);
|
|
1071
|
+
} else {
|
|
1072
|
+
await modal.error('导出表数据失败: ' + response.msg);
|
|
1073
|
+
}
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
console.error('导出表数据失败:', error);
|
|
1076
|
+
modal.error('导出表数据失败: ' + (error as any).message);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function downloadSQLFile(content: string, filename: string) {
|
|
1081
|
+
const blob = new Blob([content], { type: 'text/sql;charset=utf-8;' });
|
|
1082
|
+
const link = document.createElement('a');
|
|
1083
|
+
|
|
1084
|
+
if (link.download !== undefined) {
|
|
1085
|
+
const url = URL.createObjectURL(blob);
|
|
1086
|
+
link.setAttribute('href', url);
|
|
1087
|
+
link.setAttribute('download', filename);
|
|
1088
|
+
link.style.visibility = 'hidden';
|
|
1089
|
+
document.body.appendChild(link);
|
|
1090
|
+
link.click();
|
|
1091
|
+
document.body.removeChild(link);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
</script>
|
|
1095
|
+
|
|
1096
|
+
<style scoped>
|
|
1097
|
+
.table-detail {
|
|
1098
|
+
width: 100%;
|
|
1099
|
+
height: 100%;
|
|
1100
|
+
display: flex;
|
|
1101
|
+
flex-direction: column;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
.table-header {
|
|
1105
|
+
background-color: #f8f9fa;
|
|
1106
|
+
border-bottom: 1px solid #dee2e6;
|
|
1107
|
+
padding: 15px 20px;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
.table-header-content {
|
|
1111
|
+
display: flex;
|
|
1112
|
+
justify-content: space-between;
|
|
1113
|
+
align-items: center;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
.table-info {
|
|
1117
|
+
display: flex;
|
|
1118
|
+
align-items: center;
|
|
1119
|
+
gap: 15px;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
.table-icon {
|
|
1123
|
+
font-size: 32px;
|
|
1124
|
+
color: #495057;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
.table-meta {
|
|
1128
|
+
display: flex;
|
|
1129
|
+
flex-direction: column;
|
|
1130
|
+
gap: 5px;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
.table-name {
|
|
1134
|
+
margin: 0;
|
|
1135
|
+
font-size: 1.25rem;
|
|
1136
|
+
font-weight: 600;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
.table-breadcrumb {
|
|
1140
|
+
display: flex;
|
|
1141
|
+
align-items: center;
|
|
1142
|
+
gap: 8px;
|
|
1143
|
+
font-size: 0.875rem;
|
|
1144
|
+
color: #6c757d;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
.table-stats {
|
|
1148
|
+
display: flex;
|
|
1149
|
+
gap: 20px;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
.stat-item {
|
|
1153
|
+
display: flex;
|
|
1154
|
+
flex-direction: column;
|
|
1155
|
+
align-items: center;
|
|
1156
|
+
gap: 2px;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
.stat-value {
|
|
1160
|
+
font-size: 1.125rem;
|
|
1161
|
+
font-weight: 600;
|
|
1162
|
+
color: #495057;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
.stat-label {
|
|
1166
|
+
font-size: 0.75rem;
|
|
1167
|
+
color: #6c757d;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
.table-toolbar {
|
|
1171
|
+
display: flex;
|
|
1172
|
+
justify-content: space-between;
|
|
1173
|
+
align-items: center;
|
|
1174
|
+
padding: 10px 20px;
|
|
1175
|
+
background-color: #f8f9fa;
|
|
1176
|
+
border-bottom: 1px solid #dee2e6;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
.toolbar-left {
|
|
1180
|
+
display: flex;
|
|
1181
|
+
gap: 10px;
|
|
1182
|
+
align-items: center;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
.toolbar-right {
|
|
1186
|
+
display: flex;
|
|
1187
|
+
gap: 10px;
|
|
1188
|
+
align-items: center;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
.table-tabs {
|
|
1192
|
+
flex: 1;
|
|
1193
|
+
display: flex;
|
|
1194
|
+
flex-direction: column;
|
|
1195
|
+
overflow: hidden;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
.nav-tabs {
|
|
1199
|
+
border-bottom: 1px solid #dee2e6;
|
|
1200
|
+
background-color: #f8f9fa;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
.nav-tabs .nav-link {
|
|
1204
|
+
color: #495057;
|
|
1205
|
+
border: none;
|
|
1206
|
+
border-bottom: 3px solid transparent;
|
|
1207
|
+
border-radius: 0;
|
|
1208
|
+
padding: 10px 15px;
|
|
1209
|
+
font-weight: 500;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
.nav-tabs .nav-link:hover {
|
|
1213
|
+
background-color: #e9ecef;
|
|
1214
|
+
border-bottom-color: #adb5bd;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
.nav-tabs .nav-link.active {
|
|
1218
|
+
background-color: #fff;
|
|
1219
|
+
border-bottom-color: #0d6efd;
|
|
1220
|
+
color: #0d6efd;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
.tab-content {
|
|
1224
|
+
flex: 1;
|
|
1225
|
+
overflow: auto;
|
|
1226
|
+
padding: 20px;
|
|
1227
|
+
background-color: #fff;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
.tab-panel {
|
|
1231
|
+
height: 100%;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
.data-content {
|
|
1235
|
+
height: 100%;
|
|
1236
|
+
display: flex;
|
|
1237
|
+
flex-direction: column;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
.data-content.loading {
|
|
1241
|
+
opacity: 0.7;
|
|
1242
|
+
pointer-events: none;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
.table-responsive {
|
|
1246
|
+
flex: 1;
|
|
1247
|
+
overflow: auto;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
.column-header {
|
|
1251
|
+
position: relative;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
.column-key {
|
|
1255
|
+
position: absolute;
|
|
1256
|
+
top: -5px;
|
|
1257
|
+
right: -15px;
|
|
1258
|
+
color: #0d6efd;
|
|
1259
|
+
font-size: 0.75rem;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
.cell-value {
|
|
1263
|
+
max-width: 200px;
|
|
1264
|
+
overflow: hidden;
|
|
1265
|
+
text-overflow: ellipsis;
|
|
1266
|
+
white-space: nowrap;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
.loading-state {
|
|
1270
|
+
display: flex;
|
|
1271
|
+
flex-direction: column;
|
|
1272
|
+
align-items: center;
|
|
1273
|
+
justify-content: center;
|
|
1274
|
+
height: 300px;
|
|
1275
|
+
gap: 15px;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
.empty-state {
|
|
1279
|
+
display: flex;
|
|
1280
|
+
flex-direction: column;
|
|
1281
|
+
align-items: center;
|
|
1282
|
+
justify-content: center;
|
|
1283
|
+
height: 300px;
|
|
1284
|
+
gap: 15px;
|
|
1285
|
+
color: #6c757d;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
.empty-state i {
|
|
1289
|
+
font-size: 48px;
|
|
1290
|
+
opacity: 0.5;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
.pagination-nav {
|
|
1294
|
+
margin-top: 20px;
|
|
1295
|
+
border-top: 1px solid #dee2e6;
|
|
1296
|
+
padding-top: 15px;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
.pagination-container {
|
|
1300
|
+
display: flex;
|
|
1301
|
+
align-items: center;
|
|
1302
|
+
justify-content: space-between;
|
|
1303
|
+
flex-wrap: wrap;
|
|
1304
|
+
gap: 10px;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
.pagination-info {
|
|
1308
|
+
font-size: 0.875rem;
|
|
1309
|
+
color: #6c757d;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
.page-size-selector {
|
|
1313
|
+
display: flex;
|
|
1314
|
+
align-items: center;
|
|
1315
|
+
gap: 5px;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
.page-jump {
|
|
1319
|
+
display: flex;
|
|
1320
|
+
align-items: center;
|
|
1321
|
+
gap: 5px;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
.structure-actions {
|
|
1325
|
+
display: flex;
|
|
1326
|
+
gap: 10px;
|
|
1327
|
+
margin-bottom: 15px;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
.structure-table, .indexes-table, .relations-table {
|
|
1331
|
+
overflow: auto;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
.structure-table table, .indexes-table table, .relations-table table {
|
|
1335
|
+
width: 100%;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
.sql-section {
|
|
1339
|
+
height: 100%;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/* 响应式设计 */
|
|
1343
|
+
@media (max-width: 768px) {
|
|
1344
|
+
.table-header-content {
|
|
1345
|
+
flex-direction: column;
|
|
1346
|
+
align-items: flex-start;
|
|
1347
|
+
gap: 15px;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
.table-stats {
|
|
1351
|
+
width: 100%;
|
|
1352
|
+
justify-content: space-around;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
.table-toolbar {
|
|
1356
|
+
flex-direction: column;
|
|
1357
|
+
align-items: stretch;
|
|
1358
|
+
gap: 10px;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
.toolbar-left, .toolbar-right {
|
|
1362
|
+
justify-content: center;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
.pagination-container {
|
|
1366
|
+
flex-direction: column;
|
|
1367
|
+
align-items: flex-start;
|
|
1368
|
+
gap: 15px;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
.pagination {
|
|
1372
|
+
width: 100%;
|
|
1373
|
+
justify-content: center;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
</style>
|