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) {
|
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()
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户