fix(core): 统一全局异常处理器并添加数据库管理功能

- 在所有服务的GlobalExceptionHandler中添加HttpServletRequest参数以记录请求上下文
- 统一异常响应格式为ResponseEntity<ApiResponse<Void>>并改进错误日志记录
- 添加对多种异常类型的处理包括参数验证、请求方法不支持、权限拒绝等
- 为业务异常添加不同级别的日志记录(warn/error)和状态码映射
- 在前端系统API中新增数据库表管理相关接口定义和实现
- 添加数据库表列表、列信息和数据查询的API调用函数
这个提交包含在:
XuqmGroup 2026-05-27 11:51:19 +08:00
父节点 ba48d9f535
当前提交 38e138f955
共有 4 个文件被更改,包括 423 次插入1 次删除

查看文件

@ -80,3 +80,64 @@ export function streamSystemUpdate(onLine: (line: string) => void, signal?: Abor
export function streamSystemReset(onLine: (line: string) => void, signal?: AbortSignal) { export function streamSystemReset(onLine: (line: string) => void, signal?: AbortSignal) {
return streamOperation('/system/reset', onLine, signal) 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', path: 'system-logs',
component: () => import('@/views/system/ServerLogsView.vue'), component: () => import('@/views/system/ServerLogsView.vue'),
}, },
{
path: 'database',
component: () => import('@/views/database/DatabaseView.vue'),
},
{ {
path: 'accounts', path: 'accounts',
component: () => import('@/views/accounts/SubAccountView.vue'), 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-sub-menu>
<el-menu-item index="/security"><el-icon><Lock /></el-icon><span></span></el-menu-item> <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="/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 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-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span></span></el-menu-item>
</el-menu> </el-menu>
@ -60,6 +61,7 @@
</el-sub-menu> </el-sub-menu>
<el-menu-item index="/security"><el-icon><Lock /></el-icon><span></span></el-menu-item> <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="/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 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-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span></span></el-menu-item>
</el-menu> </el-menu>
@ -108,7 +110,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRoute, useRouter } from 'vue-router' 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' import { getDeploymentStatus } from '@/api/system'
const auth = useAuthStore() const auth = useAuthStore()