fix(core): 统一全局异常处理器并添加数据库管理功能
- 在所有服务的GlobalExceptionHandler中添加HttpServletRequest参数以记录请求上下文 - 统一异常响应格式为ResponseEntity<ApiResponse<Void>>并改进错误日志记录 - 添加对多种异常类型的处理包括参数验证、请求方法不支持、权限拒绝等 - 为业务异常添加不同级别的日志记录(warn/error)和状态码映射 - 在前端系统API中新增数据库表管理相关接口定义和实现 - 添加数据库表列表、列信息和数据查询的API调用函数
这个提交包含在:
父节点
ba48d9f535
当前提交
38e138f955
@ -80,3 +80,64 @@ export function streamSystemUpdate(onLine: (line: string) => void, signal?: Abor
|
||||
export function streamSystemReset(onLine: (line: string) => void, signal?: AbortSignal) {
|
||||
return streamOperation('/system/reset', onLine, signal)
|
||||
}
|
||||
|
||||
// ── Database Management (PRIVATE mode only) ──────────────────────────────
|
||||
|
||||
export interface TableInfo {
|
||||
name: string
|
||||
comment: string
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
nullable: boolean
|
||||
comment: string
|
||||
}
|
||||
|
||||
export interface TableDataPage {
|
||||
columns: string[]
|
||||
rows: Record<string, any>[]
|
||||
total: number
|
||||
totalPages: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
async function authFetch(path: string): Promise<any> {
|
||||
const token = localStorage.getItem('token') ?? ''
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null)
|
||||
throw new Error(json?.message ?? `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function listDatabaseTables(): Promise<TableInfo[]> {
|
||||
const json = await authFetch('/system/database/tables')
|
||||
return json.data as TableInfo[]
|
||||
}
|
||||
|
||||
export async function getTableColumns(tableName: string): Promise<ColumnInfo[]> {
|
||||
const json = await authFetch(`/system/database/tables/${encodeURIComponent(tableName)}/columns`)
|
||||
return json.data as ColumnInfo[]
|
||||
}
|
||||
|
||||
export async function getTableData(
|
||||
tableName: string,
|
||||
params: { page?: number; size?: number; keyword?: string; sortColumn?: string; sortDirection?: string },
|
||||
): Promise<TableDataPage> {
|
||||
const query = new URLSearchParams()
|
||||
if (params.page != null) query.set('page', String(params.page))
|
||||
if (params.size != null) query.set('size', String(params.size))
|
||||
if (params.keyword) query.set('keyword', params.keyword)
|
||||
if (params.sortColumn) query.set('sortColumn', params.sortColumn)
|
||||
if (params.sortDirection) query.set('sortDirection', params.sortDirection)
|
||||
const qs = query.toString()
|
||||
const json = await authFetch(`/system/database/tables/${encodeURIComponent(tableName)}/data${qs ? '?' + qs : ''}`)
|
||||
return json.data as TableDataPage
|
||||
}
|
||||
|
||||
@ -105,6 +105,10 @@ const router = createRouter({
|
||||
path: 'system-logs',
|
||||
component: () => import('@/views/system/ServerLogsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'database',
|
||||
component: () => import('@/views/database/DatabaseView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'accounts',
|
||||
component: () => import('@/views/accounts/SubAccountView.vue'),
|
||||
|
||||
@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>数据库管理</h2>
|
||||
<p class="subtitle">浏览数据库表结构和数据,支持关键字搜索。仅限私有化部署使用。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="db-layout">
|
||||
<!-- 左侧:表列表 -->
|
||||
<el-card class="table-list-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>数据表</span>
|
||||
<el-button text size="small" :loading="loadingTables" @click="loadTables">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-skeleton v-if="loadingTables" :rows="5" animated />
|
||||
<el-empty v-else-if="tables.length === 0" description="暂无数据表" :image-size="48" />
|
||||
<div v-else class="table-list">
|
||||
<div
|
||||
v-for="t in tables"
|
||||
:key="t.name"
|
||||
class="table-item"
|
||||
:class="{ active: activeTable === t.name }"
|
||||
@click="selectTable(t.name)"
|
||||
>
|
||||
<el-icon class="table-icon"><Coin /></el-icon>
|
||||
<div class="table-meta">
|
||||
<span class="table-name">{{ t.name }}</span>
|
||||
<span v-if="t.comment" class="table-comment">{{ t.comment }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 右侧:数据浏览 -->
|
||||
<el-card class="data-card">
|
||||
<template v-if="activeTable">
|
||||
<div class="data-header">
|
||||
<h3 class="data-title">{{ activeTable }}</h3>
|
||||
<div class="data-controls">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索关键字..."
|
||||
clearable
|
||||
style="width: 240px"
|
||||
size="default"
|
||||
@keyup.enter="doSearch"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="doSearch">搜索</el-button>
|
||||
<el-button @click="() => { keyword = ''; loadData(0) }">重置</el-button>
|
||||
<el-button :loading="loadingData" @click="() => loadData(currentPage)">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="loadingData" :rows="8" animated />
|
||||
<template v-else>
|
||||
<div class="data-status">
|
||||
共 {{ total }} 条记录,第 {{ currentPage + 1 }} / {{ totalPages }} 页
|
||||
</div>
|
||||
<el-table
|
||||
:data="rows"
|
||||
border
|
||||
stripe
|
||||
size="small"
|
||||
max-height="calc(100vh - 340px)"
|
||||
@row-click="showDetail"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="col in displayColumns"
|
||||
:key="col"
|
||||
:prop="col"
|
||||
:label="col"
|
||||
:min-width="getColumnWidth(col)"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatCell(row[col]) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="prev, pager, next, jumper, total"
|
||||
@current-change="(p: number) => loadData(p - 1)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-empty description="请从左侧选择一个数据表" :image-size="80" />
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 行详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="记录详情" width="600px" destroy-on-close>
|
||||
<el-table :data="detailRows" border size="small" :show-header="false">
|
||||
<el-table-column prop="field" label="字段" width="180" />
|
||||
<el-table-column prop="value" label="值">
|
||||
<template #default="{ row }">
|
||||
<span class="detail-value">{{ formatCell(row.value) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Coin, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { listDatabaseTables, getTableData } from '@/api/system'
|
||||
import type { TableInfo } from '@/api/system'
|
||||
|
||||
const tables = ref<TableInfo[]>([])
|
||||
const loadingTables = ref(false)
|
||||
const activeTable = ref('')
|
||||
|
||||
const keyword = ref('')
|
||||
const currentPage = ref(0)
|
||||
const pageSize = ref(50)
|
||||
const total = ref(0)
|
||||
const totalPages = ref(0)
|
||||
const displayColumns = ref<string[]>([])
|
||||
const rows = ref<Record<string, any>[]>([])
|
||||
const loadingData = ref(false)
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailRows = ref<{ field: string; value: any }[]>([])
|
||||
|
||||
// ── 初始化 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(loadTables)
|
||||
|
||||
async function loadTables() {
|
||||
loadingTables.value = true
|
||||
try {
|
||||
tables.value = await listDatabaseTables()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message ?? '获取表列表失败')
|
||||
} finally {
|
||||
loadingTables.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 表选择与数据加载 ────────────────────────────────────────────────────────
|
||||
|
||||
function selectTable(name: string) {
|
||||
activeTable.value = name
|
||||
keyword.value = ''
|
||||
currentPage.value = 0
|
||||
loadData(0)
|
||||
}
|
||||
|
||||
async function loadData(page: number) {
|
||||
if (!activeTable.value) return
|
||||
loadingData.value = true
|
||||
try {
|
||||
const data = await getTableData(activeTable.value, {
|
||||
page,
|
||||
size: pageSize.value,
|
||||
keyword: keyword.value || undefined,
|
||||
})
|
||||
displayColumns.value = data.columns
|
||||
rows.value = data.rows
|
||||
total.value = data.total
|
||||
totalPages.value = data.totalPages
|
||||
currentPage.value = data.page
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message ?? '查询数据失败')
|
||||
} finally {
|
||||
loadingData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
loadData(0)
|
||||
}
|
||||
|
||||
// ── 行详情 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function showDetail(row: Record<string, any>) {
|
||||
detailRows.value = displayColumns.value.map(col => ({
|
||||
field: col,
|
||||
value: row[col],
|
||||
}))
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// ── 工具函数 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatCell(value: any): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function getColumnWidth(col: string): number {
|
||||
if (col === 'id') return 280
|
||||
if (col.includes('content') || col.includes('json') || col.includes('text')) return 200
|
||||
return 120
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.db-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.table-list-card {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.table-list-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.table-list {
|
||||
max-height: calc(100vh - 260px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.table-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.table-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.table-item.active {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
.table-icon {
|
||||
flex-shrink: 0;
|
||||
color: #909399;
|
||||
}
|
||||
.table-item.active .table-icon {
|
||||
color: #409eff;
|
||||
}
|
||||
.table-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.table-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-comment {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.data-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.data-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.data-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.data-status {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.db-layout {
|
||||
flex-direction: column;
|
||||
min-height: auto;
|
||||
}
|
||||
.table-list-card {
|
||||
width: 100%;
|
||||
}
|
||||
.table-list {
|
||||
max-height: 200px;
|
||||
}
|
||||
.data-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.data-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -24,6 +24,7 @@
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/security"><el-icon><Lock /></el-icon><span>安全中心</span></el-menu-item>
|
||||
<el-menu-item v-if="isPrivateDeploy" index="/system-logs"><el-icon><Monitor /></el-icon><span>服务日志</span></el-menu-item>
|
||||
<el-menu-item v-if="isPrivateDeploy" index="/database"><el-icon><Coin /></el-icon><span>数据库管理</span></el-menu-item>
|
||||
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span>操作日志</span></el-menu-item>
|
||||
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span>子账号管理</span></el-menu-item>
|
||||
</el-menu>
|
||||
@ -60,6 +61,7 @@
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/security"><el-icon><Lock /></el-icon><span>安全中心</span></el-menu-item>
|
||||
<el-menu-item v-if="isPrivateDeploy" index="/system-logs"><el-icon><Monitor /></el-icon><span>服务日志</span></el-menu-item>
|
||||
<el-menu-item v-if="isPrivateDeploy" index="/database"><el-icon><Coin /></el-icon><span>数据库管理</span></el-menu-item>
|
||||
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span>操作日志</span></el-menu-item>
|
||||
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span>子账号管理</span></el-menu-item>
|
||||
</el-menu>
|
||||
@ -108,7 +110,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Bell, ChatDotRound, Document, Grid, Key, List, Lock, Menu, Monitor, Odometer, Upload, User } from '@element-plus/icons-vue'
|
||||
import { Bell, ChatDotRound, Coin, Document, Grid, Key, List, Lock, Menu, Monitor, Odometer, Upload, User } from '@element-plus/icons-vue'
|
||||
import { getDeploymentStatus } from '@/api/system'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户