Initial commit: SQL audit platform v0.1.0
FastAPI backend with auth, user, instance, ticket, query, dashboard APIs. React + TypeScript frontend with mock data UI (10 pages). SQLite + async SQLAlchemy, JWT auth, role-based access control. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
commit
439155c92e
290 files changed
+61950
No files matched your search
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>数据审核平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "data-audit-platform",
|
||||
"version": "1.0.0",
|
||||
"description": "数据审核平台 - 支持 MySQL、Redis 的数据库变更管理和 SQL 审核平台",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.5",
|
||||
"element-plus": "^2.5.1",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"echarts": "^5.4.3",
|
||||
"vue-echarts": "^6.6.8",
|
||||
"dayjs": "^1.11.10",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"@codemirror/lang-sql": "^6.5.5",
|
||||
"codemirror": "^6.0.1",
|
||||
"vue-codemirror": "^6.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"typescript": "~5.3.0",
|
||||
"vite": "^5.0.11",
|
||||
"vue-tsc": "^1.8.27",
|
||||
"sass": "^1.69.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const userStore = useUserStore()
|
||||
if (userStore.token) {
|
||||
config.headers.Authorization = `Bearer ${userStore.token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data
|
||||
},
|
||||
(error) => {
|
||||
const message = error.response?.data?.detail || error.message || '请求失败'
|
||||
ElMessage.error(message)
|
||||
if (error.response?.status === 401) {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 认证相关
|
||||
export const authApi = {
|
||||
login: (data: { username: string; password: string }) =>
|
||||
api.post('/auth/login', data),
|
||||
logout: () => api.post('/auth/logout'),
|
||||
getMe: () => api.get('/auth/me'),
|
||||
}
|
||||
|
||||
// 用户管理
|
||||
export const userApi = {
|
||||
list: (params?: any) => api.get('/users', { params }),
|
||||
get: (id: number) => api.get(`/users/${id}`),
|
||||
}
|
||||
|
||||
// 数据源管理
|
||||
export const instanceApi = {
|
||||
list: (params?: any) => api.get('/instances', { params }),
|
||||
get: (id: number) => api.get(`/instances/${id}`),
|
||||
create: (data: any) => api.post('/instances', data),
|
||||
update: (id: number, data: any) => api.put(`/instances/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/instances/${id}`),
|
||||
test: (id: number) => api.post(`/instances/${id}/test`),
|
||||
}
|
||||
|
||||
// SQL 工单管理
|
||||
export const workflowApi = {
|
||||
list: (params?: any) => api.get('/workflows', { params }),
|
||||
get: (id: number) => api.get(`/workflows/${id}`),
|
||||
create: (data: any) => api.post('/workflows', data),
|
||||
review: (id: number, data: any) => api.post(`/workflows/${id}/review`, data),
|
||||
execute: (id: number) => api.post(`/workflows/${id}/execute`),
|
||||
rollback: (id: number) => api.post(`/workflows/${id}/rollback`),
|
||||
statistics: () => api.get('/workflows/statistics'),
|
||||
}
|
||||
|
||||
// SQL 审核规则
|
||||
export const auditRuleApi = {
|
||||
list: (params?: any) => api.get('/audit-rules', { params }),
|
||||
update: (id: number, data: any) => api.put(`/audit-rules/${id}`, data),
|
||||
}
|
||||
|
||||
// SQL 查询
|
||||
export const queryApi = {
|
||||
execute: (data: any) => api.post('/query', data),
|
||||
}
|
||||
|
||||
// 数据字典
|
||||
export const dictionaryApi = {
|
||||
databases: (instanceId: number) =>
|
||||
api.get(`/dictionary/instances/${instanceId}/databases`),
|
||||
tables: (instanceId: number, database: string) =>
|
||||
api.get(`/dictionary/instances/${instanceId}/tables`, { params: { database } }),
|
||||
tableInfo: (instanceId: number, database: string, tableName: string) =>
|
||||
api.get(`/dictionary/instances/${instanceId}/tables/${tableName}`, { params: { database } }),
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
export const dashboardApi = {
|
||||
stats: () => api.get('/dashboard/stats'),
|
||||
}
|
||||
|
||||
// 通知
|
||||
export const notificationApi = {
|
||||
list: (params?: any) => api.get('/notifications', { params }),
|
||||
markRead: (id: number) => api.put(`/notifications/${id}/read`),
|
||||
unreadCount: () => api.get('/notifications/unread-count'),
|
||||
}
|
||||
|
||||
// Redis
|
||||
export const redisApi = {
|
||||
info: (instanceId: number) => api.get(`/redis/${instanceId}/info`),
|
||||
command: (instanceId: number, data: { command: string }) =>
|
||||
api.post(`/redis/${instanceId}/command`, data),
|
||||
}
|
||||
|
||||
// 系统配置
|
||||
export const configApi = {
|
||||
list: () => api.get('/configs'),
|
||||
update: (key: string, value: any) => api.put(`/configs/${key}`, { value }),
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<el-container class="main-layout">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside width="240px" class="sidebar">
|
||||
<div class="logo">
|
||||
<el-icon size="28"><DataLine /></el-icon>
|
||||
<span class="logo-text">数据审核平台</span>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:default-openeds="openeds"
|
||||
router
|
||||
class="sidebar-menu"
|
||||
background-color="#001529"
|
||||
text-color="#fff"
|
||||
active-text-color="#409EFF"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 数据源管理 -->
|
||||
<el-sub-menu index="/datasource">
|
||||
<template #title>
|
||||
<el-icon><DataLine /></el-icon>
|
||||
<span>数据源管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/datasource/mysql">
|
||||
<el-icon><Coin /></el-icon>
|
||||
<span>MySQL 实例</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/datasource/redis">
|
||||
<el-icon><Lightning /></el-icon>
|
||||
<span>Redis 实例</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/datasource/postgresql">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>PostgreSQL 实例</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- SQL 审核 -->
|
||||
<el-sub-menu index="/audit">
|
||||
<template #title>
|
||||
<el-icon><DocumentChecked /></el-icon>
|
||||
<span>SQL 审核</span>
|
||||
</template>
|
||||
<el-menu-item index="/audit/workflows">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>工单列表</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/audit/rules">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>审核规则</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/audit/templates">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
<span>工单模板</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 数据字典 -->
|
||||
<el-sub-menu index="/dictionary">
|
||||
<template #title>
|
||||
<el-icon><Collection /></el-icon>
|
||||
<span>数据字典</span>
|
||||
</template>
|
||||
<el-menu-item index="/dictionary/tables">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>表结构</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/dictionary/er">
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>ER 图</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/dictionary/export">
|
||||
<el-icon><Download /></el-icon>
|
||||
<span>导出文档</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 系统管理 -->
|
||||
<el-sub-menu index="/system">
|
||||
<template #title>
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/system/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/system/roles">
|
||||
<el-icon><Lock /></el-icon>
|
||||
<span>角色权限</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/system/logs">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>操作日志</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/system/settings">
|
||||
<el-icon><Tools /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<!-- 顶部导航 -->
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<breadcrumb />
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- 快捷操作 -->
|
||||
<el-tooltip content="创建工单">
|
||||
<el-button circle @click="$router.push('/audit/workflows/create')">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 通知 -->
|
||||
<el-badge :value="unreadCount" :hidden="unreadCount === 0" class="notification-badge">
|
||||
<el-icon size="20" @click="showNotifications" class="notification-icon">
|
||||
<Bell />
|
||||
</el-icon>
|
||||
</el-badge>
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<el-dropdown @command="handleCommand">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" :src="userStore.user?.avatar" />
|
||||
<span class="username">{{ userStore.user?.username }}</span>
|
||||
<el-tag size="small" :type="getRoleType(userStore.user?.role)" class="role-tag">
|
||||
{{ getRoleLabel(userStore.user?.role) }}
|
||||
</el-tag>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
|
||||
<el-dropdown-item command="settings">系统设置</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-main class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<!-- 通知抽屉 -->
|
||||
<el-drawer v-model="notificationVisible" title="通知中心" size="400px">
|
||||
<div class="notification-list">
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="notification-item"
|
||||
:class="{ unread: !notification.is_read }"
|
||||
@click="markAsRead(notification.id)"
|
||||
>
|
||||
<div class="notification-header">
|
||||
<el-tag :type="notification.type" size="small">{{ notification.type }}</el-tag>
|
||||
<span class="notification-time">{{ formatTime(notification.created_at) }}</span>
|
||||
</div>
|
||||
<div class="notification-title">{{ notification.title }}</div>
|
||||
<div class="notification-content">{{ notification.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { notificationApi } from '@/api'
|
||||
import type { Notification } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const openeds = computed(() => {
|
||||
// 根据当前路径自动展开对应的子菜单
|
||||
const path = route.path
|
||||
if (path.startsWith('/datasource')) return ['/datasource']
|
||||
if (path.startsWith('/audit')) return ['/audit']
|
||||
if (path.startsWith('/dictionary')) return ['/dictionary']
|
||||
if (path.startsWith('/system')) return ['/system']
|
||||
if (path.startsWith('/mysql') || path.startsWith('/redis')) {
|
||||
return ['/datasource']
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const getRoleType = (role?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
admin: 'danger',
|
||||
dba: 'warning',
|
||||
developer: 'success',
|
||||
auditor: 'info',
|
||||
}
|
||||
return map[role || ''] || 'info'
|
||||
}
|
||||
|
||||
const getRoleLabel = (role?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
admin: '管理员',
|
||||
dba: 'DBA',
|
||||
developer: '开发者',
|
||||
auditor: '审计员',
|
||||
}
|
||||
return map[role || ''] || role
|
||||
}
|
||||
|
||||
const handleCommand = (command: string) => {
|
||||
switch (command) {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'settings':
|
||||
router.push('/system/settings')
|
||||
break
|
||||
case 'logout':
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 通知
|
||||
const notificationVisible = ref(false)
|
||||
const notifications = ref<Notification[]>([])
|
||||
const unreadCount = ref(0)
|
||||
|
||||
const showNotifications = () => {
|
||||
notificationVisible.value = true
|
||||
fetchNotifications()
|
||||
}
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
const res: any = await notificationApi.list()
|
||||
notifications.value = res
|
||||
}
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
const res: any = await notificationApi.unreadCount()
|
||||
unreadCount.value = res.count
|
||||
}
|
||||
|
||||
const markAsRead = async (id: number) => {
|
||||
await notificationApi.markRead(id)
|
||||
fetchNotifications()
|
||||
fetchUnreadCount()
|
||||
}
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUnreadCount()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.main-layout {
|
||||
height: 100vh;
|
||||
|
||||
.sidebar {
|
||||
background-color: #001529;
|
||||
color: #fff;
|
||||
overflow-y: auto;
|
||||
|
||||
.logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #001529;
|
||||
z-index: 1;
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
|
||||
:deep(.el-sub-menu__title) {
|
||||
&:hover {
|
||||
background-color: #1890ff !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
&:hover {
|
||||
background-color: #1890ff !important;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: #1890ff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
.notification-icon {
|
||||
cursor: pointer;
|
||||
color: #606266;
|
||||
font-size: 20px;
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.role-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background-color: #f0f2f5;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
.notification-item {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background-color: #f0f9ff;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.notification-time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/main.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,259 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Layout',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: '仪表盘', icon: 'Odometer' },
|
||||
},
|
||||
// 数据源管理 - 主菜单
|
||||
{
|
||||
path: 'datasource',
|
||||
name: 'Datasource',
|
||||
meta: { title: '数据源管理', icon: 'DataLine' },
|
||||
redirect: '/datasource/mysql',
|
||||
children: [
|
||||
{
|
||||
path: 'mysql',
|
||||
name: 'MySQLInstances',
|
||||
component: () => import('@/views/datasource/mysql/Instances.vue'),
|
||||
meta: { title: 'MySQL 实例', icon: 'Coin' },
|
||||
},
|
||||
{
|
||||
path: 'redis',
|
||||
name: 'RedisInstances',
|
||||
component: () => import('@/views/datasource/redis/Instances.vue'),
|
||||
meta: { title: 'Redis 实例', icon: 'Lightning' },
|
||||
},
|
||||
{
|
||||
path: 'postgresql',
|
||||
name: 'PostgreSQLInstances',
|
||||
component: () => import('@/views/datasource/postgresql/Instances.vue'),
|
||||
meta: { title: 'PostgreSQL 实例', icon: 'DataAnalysis' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// MySQL 实例详情页
|
||||
{
|
||||
path: 'mysql/:id',
|
||||
name: 'MySQLDetail',
|
||||
component: () => import('@/views/datasource/mysql/Detail.vue'),
|
||||
meta: { title: 'MySQL 管理', hidden: true },
|
||||
redirect: (to: any) => `/mysql/${to.params.id}/query`,
|
||||
children: [
|
||||
{
|
||||
path: 'query',
|
||||
name: 'MySQLQuery',
|
||||
component: () => import('@/views/datasource/mysql/Query.vue'),
|
||||
meta: { title: 'SQL 查询', icon: 'Search' },
|
||||
},
|
||||
{
|
||||
path: 'tables',
|
||||
name: 'MySQLTables',
|
||||
component: () => import('@/views/datasource/mysql/Tables.vue'),
|
||||
meta: { title: '表管理', icon: 'Grid' },
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'MySQLUsers',
|
||||
component: () => import('@/views/datasource/mysql/Users.vue'),
|
||||
meta: { title: '用户权限', icon: 'User' },
|
||||
},
|
||||
{
|
||||
path: 'slow-queries',
|
||||
name: 'MySQLSlowQueries',
|
||||
component: () => import('@/views/datasource/mysql/SlowQueries.vue'),
|
||||
meta: { title: '慢查询', icon: 'Timer' },
|
||||
},
|
||||
{
|
||||
path: 'processlist',
|
||||
name: 'MySQLProcessList',
|
||||
component: () => import('@/views/datasource/mysql/ProcessList.vue'),
|
||||
meta: { title: '进程列表', icon: 'List' },
|
||||
},
|
||||
{
|
||||
path: 'backup',
|
||||
name: 'MySQLBackup',
|
||||
component: () => import('@/views/datasource/mysql/Backup.vue'),
|
||||
meta: { title: '备份恢复', icon: 'Download' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// Redis 实例详情页
|
||||
{
|
||||
path: 'redis/:id',
|
||||
name: 'RedisDetail',
|
||||
component: () => import('@/views/datasource/redis/Detail.vue'),
|
||||
meta: { title: 'Redis 管理', hidden: true },
|
||||
redirect: (to: any) => `/redis/${to.params.id}/keys`,
|
||||
children: [
|
||||
{
|
||||
path: 'keys',
|
||||
name: 'RedisKeys',
|
||||
component: () => import('@/views/datasource/redis/Keys.vue'),
|
||||
meta: { title: '键管理', icon: 'Key' },
|
||||
},
|
||||
{
|
||||
path: 'monitor',
|
||||
name: 'RedisMonitor',
|
||||
component: () => import('@/views/datasource/redis/Monitor.vue'),
|
||||
meta: { title: '监控面板', icon: 'Odometer' },
|
||||
},
|
||||
{
|
||||
path: 'cli',
|
||||
name: 'RedisCLI',
|
||||
component: () => import('@/views/datasource/redis/CLI.vue'),
|
||||
meta: { title: '命令行', icon: 'Terminal' },
|
||||
},
|
||||
{
|
||||
path: 'performance',
|
||||
name: 'RedisPerformance',
|
||||
component: () => import('@/views/datasource/redis/Performance.vue'),
|
||||
meta: { title: '性能分析', icon: 'TrendCharts' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// SQL 审核
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'Audit',
|
||||
meta: { title: 'SQL 审核', icon: 'DocumentChecked' },
|
||||
redirect: '/audit/workflows',
|
||||
children: [
|
||||
{
|
||||
path: 'workflows',
|
||||
name: 'Workflows',
|
||||
component: () => import('@/views/audit/workflows/Index.vue'),
|
||||
meta: { title: '工单列表', icon: 'Document' },
|
||||
},
|
||||
{
|
||||
path: 'workflows/create',
|
||||
name: 'CreateWorkflow',
|
||||
component: () => import('@/views/audit/workflows/Create.vue'),
|
||||
meta: { title: '创建工单', hidden: true },
|
||||
},
|
||||
{
|
||||
path: 'workflows/:id',
|
||||
name: 'WorkflowDetail',
|
||||
component: () => import('@/views/audit/workflows/Detail.vue'),
|
||||
meta: { title: '工单详情', hidden: true },
|
||||
},
|
||||
{
|
||||
path: 'rules',
|
||||
name: 'AuditRules',
|
||||
component: () => import('@/views/audit/rules/Index.vue'),
|
||||
meta: { title: '审核规则', icon: 'Warning' },
|
||||
},
|
||||
{
|
||||
path: 'templates',
|
||||
name: 'WorkflowTemplates',
|
||||
component: () => import('@/views/audit/templates/Index.vue'),
|
||||
meta: { title: '工单模板', icon: 'DocumentCopy' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// 数据字典
|
||||
{
|
||||
path: 'dictionary',
|
||||
name: 'Dictionary',
|
||||
meta: { title: '数据字典', icon: 'Collection' },
|
||||
redirect: '/dictionary/tables',
|
||||
children: [
|
||||
{
|
||||
path: 'tables',
|
||||
name: 'DictionaryTables',
|
||||
component: () => import('@/views/dictionary/Tables.vue'),
|
||||
meta: { title: '表结构', icon: 'Grid' },
|
||||
},
|
||||
{
|
||||
path: 'er',
|
||||
name: 'DictionaryER',
|
||||
component: () => import('@/views/dictionary/ERDiagram.vue'),
|
||||
meta: { title: 'ER 图', icon: 'Share' },
|
||||
},
|
||||
{
|
||||
path: 'export',
|
||||
name: 'DictionaryExport',
|
||||
component: () => import('@/views/dictionary/Export.vue'),
|
||||
meta: { title: '导出文档', icon: 'Download' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// 系统管理
|
||||
{
|
||||
path: 'system',
|
||||
name: 'System',
|
||||
meta: { title: '系统管理', icon: 'Setting' },
|
||||
redirect: '/system/users',
|
||||
children: [
|
||||
{
|
||||
path: 'users',
|
||||
name: 'SystemUsers',
|
||||
component: () => import('@/views/system/Users.vue'),
|
||||
meta: { title: '用户管理', icon: 'User' },
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'SystemRoles',
|
||||
component: () => import('@/views/system/Roles.vue'),
|
||||
meta: { title: '角色权限', icon: 'Lock' },
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'SystemLogs',
|
||||
component: () => import('@/views/system/Logs.vue'),
|
||||
meta: { title: '操作日志', icon: 'Document' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'SystemSettings',
|
||||
component: () => import('@/views/system/Settings.vue'),
|
||||
meta: { title: '系统设置', icon: 'Tools' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (to.meta.public) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
if (!userStore.isLoggedIn) {
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,63 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { User } from '@/types'
|
||||
import { authApi } from '@/api'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// State
|
||||
const token = ref<string>(localStorage.getItem('token') || '')
|
||||
const user = ref<User | null>(null)
|
||||
|
||||
// Getters
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
const isDBA = computed(() => user.value?.role === 'dba')
|
||||
const canReview = computed(() => ['admin', 'dba'].includes(user.value?.role || ''))
|
||||
|
||||
// Actions
|
||||
const setToken = (newToken: string) => {
|
||||
token.value = newToken
|
||||
localStorage.setItem('token', newToken)
|
||||
}
|
||||
|
||||
const setUser = (newUser: User) => {
|
||||
user.value = newUser
|
||||
}
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const response: any = await authApi.login({ username, password })
|
||||
setToken(response.access_token)
|
||||
setUser(response.user)
|
||||
return response
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} finally {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
const response: any = await authApi.getMe()
|
||||
setUser(response)
|
||||
return response
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
isDBA,
|
||||
canReview,
|
||||
setToken,
|
||||
setUser,
|
||||
login,
|
||||
logout,
|
||||
fetchUserInfo,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
// 全局样式
|
||||
|
||||
// 滚动条样式
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
// 代码编辑器样式
|
||||
.cm-editor {
|
||||
font-family: 'Fira Code', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
// 表格样式优化
|
||||
.el-table {
|
||||
th {
|
||||
background-color: #f5f7fa !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片样式
|
||||
.el-card {
|
||||
border-radius: 8px;
|
||||
|
||||
.el-card__header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
}
|
||||
|
||||
// 表单样式
|
||||
.el-form {
|
||||
.el-form-item__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮样式
|
||||
.el-button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// 标签样式
|
||||
.el-tag {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// 分页样式
|
||||
.el-pagination {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// 步骤条样式
|
||||
.el-steps {
|
||||
.el-step__title {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 时间线样式
|
||||
.el-timeline {
|
||||
.el-timeline-item__node {
|
||||
background-color: #409EFF;
|
||||
}
|
||||
}
|
||||
|
||||
// 描述列表样式
|
||||
.el-descriptions {
|
||||
.el-descriptions__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.el-descriptions__label {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
|
||||
// 树形组件样式
|
||||
.el-tree {
|
||||
.el-tree-node__content {
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
.el-tree-node.is-current > .el-tree-node__content {
|
||||
background-color: #ecf5ff;
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉菜单样式
|
||||
.el-dropdown-menu {
|
||||
.el-dropdown-menu__item {
|
||||
line-height: 32px;
|
||||
padding: 0 16px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 消息提示样式
|
||||
.el-message {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 对话框样式
|
||||
.el-dialog {
|
||||
border-radius: 8px;
|
||||
|
||||
.el-dialog__header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
}
|
||||
|
||||
// 抽屉样式
|
||||
.el-drawer {
|
||||
.el-drawer__header {
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.el-drawer__body {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// 进度条样式
|
||||
.el-progress {
|
||||
.el-progress-bar__outer {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.el-progress-bar__inner {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 徽章样式
|
||||
.el-badge {
|
||||
.el-badge__content {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// 头像样式
|
||||
.el-avatar {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
// 输入框样式
|
||||
.el-input {
|
||||
.el-input__inner {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 选择器样式
|
||||
.el-select {
|
||||
.el-input__inner {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 日期选择器样式
|
||||
.el-date-editor {
|
||||
.el-input__inner {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 复选框样式
|
||||
.el-checkbox {
|
||||
.el-checkbox__input.is-checked .el-checkbox__inner {
|
||||
background-color: #409EFF;
|
||||
border-color: #409EFF;
|
||||
}
|
||||
}
|
||||
|
||||
// 单选框样式
|
||||
.el-radio {
|
||||
.el-radio__input.is-checked .el-radio__inner {
|
||||
background-color: #409EFF;
|
||||
border-color: #409EFF;
|
||||
}
|
||||
}
|
||||
|
||||
// 开关样式
|
||||
.el-switch {
|
||||
.el-switch__core {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// 滑块样式
|
||||
.el-slider {
|
||||
.el-slider__runway {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.el-slider__bar {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.el-slider__button {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
// 颜色选择器样式
|
||||
.el-color-picker {
|
||||
.el-color-picker__trigger {
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 上传组件样式
|
||||
.el-upload {
|
||||
.el-upload-dragger {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 图片样式
|
||||
.el-image {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// 卡片阴影
|
||||
.el-card.is-always-shadow,
|
||||
.el-card.is-hover-shadow:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
role: 'admin' | 'dba' | 'developer' | 'auditor'
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
avatar?: string
|
||||
department?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export interface DatabaseInstance {
|
||||
id: number
|
||||
name: string
|
||||
db_type: 'mysql' | 'redis' | 'postgresql' | 'mongodb'
|
||||
host: string
|
||||
port: number
|
||||
database?: string
|
||||
username: string
|
||||
password: string
|
||||
environment: 'dev' | 'test' | 'staging' | 'prod'
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
description?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: number
|
||||
max_connections: number
|
||||
redis_mode?: 'standalone' | 'sentinel' | 'cluster'
|
||||
}
|
||||
|
||||
export interface SQLAuditResult {
|
||||
rule_id: number
|
||||
rule_name: string
|
||||
risk_level: 'high' | 'medium' | 'low' | 'safe'
|
||||
message: string
|
||||
line_number?: number
|
||||
suggestion?: string
|
||||
}
|
||||
|
||||
export interface SQLWorkflow {
|
||||
id: number
|
||||
title: string
|
||||
description?: string
|
||||
workflow_type: 'ddl' | 'dml' | 'query'
|
||||
status: 'pending' | 'reviewing' | 'approved' | 'rejected' | 'executing' | 'success' | 'failed' | 'rollback'
|
||||
instance_id: number
|
||||
instance_name?: string
|
||||
database?: string
|
||||
sql_content: string
|
||||
sql_results: SQLAuditResult[]
|
||||
risk_level: 'high' | 'medium' | 'low' | 'safe'
|
||||
risk_score: number
|
||||
submitter_id: number
|
||||
submitter_name?: string
|
||||
reviewer_id?: number
|
||||
reviewer_name?: string
|
||||
executor_id?: number
|
||||
executor_name?: string
|
||||
review_comment?: string
|
||||
execute_result?: string
|
||||
rollback_sql?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
executed_at?: string
|
||||
current_step: number
|
||||
total_steps: number
|
||||
approvers: any[]
|
||||
}
|
||||
|
||||
export interface DataDictionaryTable {
|
||||
name: string
|
||||
engine?: string
|
||||
rows?: number
|
||||
data_length?: number
|
||||
comment?: string
|
||||
create_time?: string
|
||||
}
|
||||
|
||||
export interface DataDictionaryColumn {
|
||||
name: string
|
||||
data_type: string
|
||||
nullable: boolean
|
||||
default?: string
|
||||
comment?: string
|
||||
is_primary: boolean
|
||||
is_index: boolean
|
||||
}
|
||||
|
||||
export interface DataDictionaryIndex {
|
||||
name: string
|
||||
columns: string[]
|
||||
unique: boolean
|
||||
index_type: string
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
total_instances: number
|
||||
total_workflows: number
|
||||
pending_workflows: number
|
||||
today_workflows: number
|
||||
risk_distribution: {
|
||||
high: number
|
||||
medium: number
|
||||
low: number
|
||||
safe: number
|
||||
}
|
||||
recent_workflows: SQLWorkflow[]
|
||||
db_type_distribution: Record<string, number>
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
type: 'info' | 'warning' | 'success' | 'error'
|
||||
is_read: boolean
|
||||
created_at: string
|
||||
user_id: number
|
||||
}
|
||||
|
||||
export interface SQLAuditRule {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
rule_type: 'syntax' | 'security' | 'performance' | 'standard'
|
||||
risk_level: 'high' | 'medium' | 'low' | 'safe'
|
||||
enabled: boolean
|
||||
config: Record<string, any>
|
||||
}
|
||||
|
||||
export interface SQLQueryResult {
|
||||
columns: string[]
|
||||
rows: any[][]
|
||||
total_rows: number
|
||||
execution_time: number
|
||||
sql: string
|
||||
}
|
||||
|
||||
export interface RedisInfo {
|
||||
version: string
|
||||
mode: string
|
||||
uptime_in_seconds: number
|
||||
connected_clients: number
|
||||
used_memory_human: string
|
||||
used_memory_peak_human: string
|
||||
total_keys: number
|
||||
keyspace_hits: number
|
||||
keyspace_misses: number
|
||||
hit_rate: number
|
||||
databases: {
|
||||
db: number
|
||||
keys: number
|
||||
expires: number
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<!-- 快捷操作栏 -->
|
||||
<el-card shadow="hover" class="quick-actions">
|
||||
<div class="actions-row">
|
||||
<div class="action-item" @click="$router.push('/audit/workflows/create')">
|
||||
<div class="action-icon" style="background-color: #409EFF;">
|
||||
<el-icon size="24" color="#fff"><DocumentChecked /></el-icon>
|
||||
</div>
|
||||
<span class="action-label">创建工单</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/datasource/mysql')">
|
||||
<div class="action-icon" style="background-color: #67C23A;">
|
||||
<el-icon size="24" color="#fff"><Coin /></el-icon>
|
||||
</div>
|
||||
<span class="action-label">MySQL</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/datasource/redis')">
|
||||
<div class="action-icon" style="background-color: #DC382D;">
|
||||
<el-icon size="24" color="#fff"><Lightning /></el-icon>
|
||||
</div>
|
||||
<span class="action-label">Redis</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/dictionary/tables')">
|
||||
<div class="action-icon" style="background-color: #E6A23C;">
|
||||
<el-icon size="24" color="#fff"><Collection /></el-icon>
|
||||
</div>
|
||||
<span class="action-label">数据字典</span>
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/audit/workflows')">
|
||||
<div class="action-icon" style="background-color: #909399;">
|
||||
<el-icon size="24" color="#fff"><Timer /></el-icon>
|
||||
</div>
|
||||
<span class="action-label">待审核</span>
|
||||
<el-badge :value="stats.pending_workflows" class="action-badge" v-if="stats.pending_workflows > 0" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon mysql">
|
||||
<el-icon size="32" color="#fff"><Coin /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.mysql_instances || 3 }}</div>
|
||||
<div class="stat-label">MySQL 实例</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-footer">
|
||||
<span class="stat-detail">{{ stats.mysql_active || 3 }} 个正常</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon redis">
|
||||
<el-icon size="32" color="#fff"><Lightning /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.redis_instances || 2 }}</div>
|
||||
<div class="stat-label">Redis 实例</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-footer">
|
||||
<span class="stat-detail">{{ formatNumber(stats.redis_keys || 150000) }} 个键</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon workflow">
|
||||
<el-icon size="32" color="#fff"><Document /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.total_workflows || 128 }}</div>
|
||||
<div class="stat-label">总工单</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-footer">
|
||||
<span class="stat-detail" style="color: #E6A23C;">{{ stats.pending_workflows || 12 }} 个待审核</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon today">
|
||||
<el-icon size="32" color="#fff"><Calendar /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ stats.today_workflows || 8 }}</div>
|
||||
<div class="stat-label">今日工单</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-footer">
|
||||
<span class="stat-detail">{{ stats.today_success || 6 }} 个成功</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表和列表 -->
|
||||
<el-row :gutter="20" class="charts-row">
|
||||
<!-- 工单趋势 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>工单趋势(近7天)</span>
|
||||
<el-radio-group v-model="trendPeriod" size="small">
|
||||
<el-radio-button label="7d">7天</el-radio-button>
|
||||
<el-radio-button label="30d">30天</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="trendChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 风险分布 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>SQL 风险分布</span>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="riskChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 待办事项和最近活动 -->
|
||||
<el-row :gutter="20" class="bottom-row">
|
||||
<!-- 待办工单 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="todo-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>待办工单</span>
|
||||
<el-button type="primary" link @click="$router.push('/audit/workflows')">
|
||||
查看全部
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="workflow in pendingWorkflows"
|
||||
:key="workflow.id"
|
||||
:type="getRiskType(workflow.risk_level)"
|
||||
:timestamp="workflow.created_at"
|
||||
>
|
||||
<div class="todo-item">
|
||||
<div class="todo-title">
|
||||
<el-link type="primary" @click="$router.push(`/audit/workflows/${workflow.id}`)">
|
||||
{{ workflow.title }}
|
||||
</el-link>
|
||||
<el-tag :type="getRiskType(workflow.risk_level)" size="small">
|
||||
{{ getRiskLabel(workflow.risk_level) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="todo-meta">
|
||||
<span>{{ workflow.instance_name }}</span>
|
||||
<span>提交人: {{ workflow.submitter_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-if="pendingWorkflows.length === 0" description="暂无待办工单" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 最近活动 -->
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="activity-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>最近活动</span>
|
||||
<el-button type="primary" link @click="$router.push('/system/logs')">
|
||||
查看全部
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="activity in recentActivities"
|
||||
:key="activity.id"
|
||||
:type="activity.type"
|
||||
:timestamp="activity.time"
|
||||
>
|
||||
<div class="activity-item">
|
||||
<el-avatar :size="28" :src="activity.avatar" />
|
||||
<div class="activity-content">
|
||||
<div class="activity-text">{{ activity.content }}</div>
|
||||
<div class="activity-detail">{{ activity.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { dashboardApi } from '@/api'
|
||||
|
||||
const stats = ref<any>({})
|
||||
const trendPeriod = ref('7d')
|
||||
const pendingWorkflows = ref<any[]>([])
|
||||
const recentActivities = ref<any[]>([])
|
||||
|
||||
const trendChartRef = ref<HTMLElement>()
|
||||
const riskChartRef = ref<HTMLElement>()
|
||||
let trendChart: echarts.ECharts | null = null
|
||||
let riskChart: echarts.ECharts | null = null
|
||||
|
||||
const fetchStats = async () => {
|
||||
const res: any = await dashboardApi.stats()
|
||||
stats.value = res
|
||||
pendingWorkflows.value = res.recent_workflows?.filter((w: any) => w.status === 'pending') || []
|
||||
|
||||
// 模拟活动数据
|
||||
recentActivities.value = [
|
||||
{ id: 1, type: 'success', content: '工单 #1234 执行成功', detail: '添加用户表索引', time: '10分钟前', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=dev1' },
|
||||
{ id: 2, type: 'primary', content: '创建了新工单', detail: '修复订单数据', time: '30分钟前', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=dev2' },
|
||||
{ id: 3, type: 'warning', content: '审核通过了工单', detail: '创建新表', time: '1小时前', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=dba' },
|
||||
{ id: 4, type: 'danger', content: '拒绝了工单', detail: '批量更新用户状态 - 影响范围过大', time: '2小时前', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=dba' },
|
||||
{ id: 5, type: 'info', content: '添加了 MySQL 实例', detail: '测试-MySQL', time: '3小时前', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=admin' },
|
||||
]
|
||||
}
|
||||
|
||||
const initCharts = () => {
|
||||
// 工单趋势图
|
||||
if (trendChartRef.value) {
|
||||
trendChart = echarts.init(trendChartRef.value)
|
||||
trendChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['创建', '通过', '拒绝', '执行'] },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '创建', type: 'line', smooth: true, data: [12, 15, 8, 20, 18, 10, 15], itemStyle: { color: '#409EFF' } },
|
||||
{ name: '通过', type: 'line', smooth: true, data: [10, 12, 6, 18, 16, 8, 12], itemStyle: { color: '#67C23A' } },
|
||||
{ name: '拒绝', type: 'line', smooth: true, data: [2, 3, 2, 2, 2, 2, 3], itemStyle: { color: '#F56C6C' } },
|
||||
{ name: '执行', type: 'line', smooth: true, data: [8, 10, 5, 15, 14, 7, 10], itemStyle: { color: '#E6A23C' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// 风险分布图
|
||||
if (riskChartRef.value) {
|
||||
riskChart = echarts.init(riskChartRef.value)
|
||||
riskChart.setOption({
|
||||
tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c} ({d}%)' },
|
||||
legend: { orient: 'vertical', right: 10, top: 'center' },
|
||||
series: [
|
||||
{
|
||||
name: '风险等级',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: false },
|
||||
emphasis: {
|
||||
label: { show: true, fontSize: 16, fontWeight: 'bold' },
|
||||
},
|
||||
labelLine: { show: false },
|
||||
data: [
|
||||
{ value: 5, name: '高风险', itemStyle: { color: '#F56C6C' } },
|
||||
{ value: 15, name: '中风险', itemStyle: { color: '#E6A23C' } },
|
||||
{ value: 35, name: '低风险', itemStyle: { color: '#409EFF' } },
|
||||
{ value: 73, name: '安全', itemStyle: { color: '#67C23A' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const getRiskType = (level: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: 'danger',
|
||||
medium: 'warning',
|
||||
low: 'info',
|
||||
safe: 'success',
|
||||
}
|
||||
return map[level] || 'info'
|
||||
}
|
||||
|
||||
const getRiskLabel = (level: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
low: '低风险',
|
||||
safe: '安全',
|
||||
}
|
||||
return map[level] || level
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
setTimeout(initCharts, 100)
|
||||
window.addEventListener('resize', () => {
|
||||
trendChart?.resize()
|
||||
riskChart?.resize()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
trendChart?.dispose()
|
||||
riskChart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dashboard-page {
|
||||
.quick-actions {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 10px 0;
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.action-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.stat-card {
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.mysql { background-color: #4479A1; }
|
||||
&.redis { background-color: #DC382D; }
|
||||
&.workflow { background-color: #409EFF; }
|
||||
&.today { background-color: #67C23A; }
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
line-height: 1;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-footer {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
|
||||
.stat-detail {
|
||||
font-size: 12px;
|
||||
color: #67C23A;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.chart-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
.todo-card, .activity-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
.todo-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.todo-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
|
||||
span {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.activity-content {
|
||||
.activity-text {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.activity-detail {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<div class="login-header">
|
||||
<el-icon size="48" color="#409EFF"><DataLine /></el-icon>
|
||||
<h1 class="title">数据审核平台</h1>
|
||||
<p class="subtitle">Database Change Management & SQL Audit Platform</p>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
class="login-form"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="用户名"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="login-btn"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>默认账号: admin / 123456</p>
|
||||
<p>DBA账号: dba / 123456</p>
|
||||
<p>开发账号: dev1 / 123456</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-bg">
|
||||
<div class="bg-circle circle-1"></div>
|
||||
<div class="bg-circle circle-2"></div>
|
||||
<div class="bg-circle circle-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login(form.username, form.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.login-container {
|
||||
width: 420px;
|
||||
padding: 40px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1;
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.bg-circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
&.circle-1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
top: -200px;
|
||||
right: -200px;
|
||||
}
|
||||
|
||||
&.circle-2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
bottom: -100px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
&.circle-3 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<el-result
|
||||
icon="error"
|
||||
title="404"
|
||||
sub-title="抱歉,您访问的页面不存在"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="audit-rules-page">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>SQL 审核规则</span>
|
||||
<el-button type="primary" @click="fetchRules">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="rules" stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="规则名称" min-width="200" />
|
||||
<el-table-column prop="description" label="描述" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="rule_type" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getTypeTag(row.rule_type)">
|
||||
{{ getTypeLabel(row.rule_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="risk_level" label="风险等级" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRiskType(row.risk_level)" size="small">
|
||||
{{ getRiskLabel(row.risk_level) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enabled" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.enabled"
|
||||
@change="(val: boolean) => toggleRule(row.id, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="viewDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 规则详情对话框 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="规则详情" width="600px">
|
||||
<el-descriptions :column="1" border v-if="selectedRule">
|
||||
<el-descriptions-item label="规则名称">{{ selectedRule.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="描述">{{ selectedRule.description }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
<el-tag :type="getTypeTag(selectedRule.rule_type)">
|
||||
{{ getTypeLabel(selectedRule.rule_type) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="风险等级">
|
||||
<el-tag :type="getRiskType(selectedRule.risk_level)">
|
||||
{{ getRiskLabel(selectedRule.risk_level) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-switch v-model="selectedRule.enabled" disabled />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="配置">
|
||||
<pre class="config-content">{{ JSON.stringify(selectedRule.config, null, 2) }}</pre>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { auditRuleApi } from '@/api'
|
||||
import type { SQLAuditRule } from '@/types'
|
||||
|
||||
const loading = ref(false)
|
||||
const rules = ref<SQLAuditRule[]>([])
|
||||
const detailDialogVisible = ref(false)
|
||||
const selectedRule = ref<SQLAuditRule | null>(null)
|
||||
|
||||
const fetchRules = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await auditRuleApi.list()
|
||||
rules.value = res
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeTag = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
syntax: 'info',
|
||||
security: 'danger',
|
||||
performance: 'warning',
|
||||
standard: 'success',
|
||||
}
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
syntax: '语法',
|
||||
security: '安全',
|
||||
performance: '性能',
|
||||
standard: '规范',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
const getRiskType = (level: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: 'danger',
|
||||
medium: 'warning',
|
||||
low: 'info',
|
||||
safe: 'success',
|
||||
}
|
||||
return map[level] || 'info'
|
||||
}
|
||||
|
||||
const getRiskLabel = (level: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
low: '低风险',
|
||||
safe: '安全',
|
||||
}
|
||||
return map[level] || level
|
||||
}
|
||||
|
||||
const toggleRule = async (id: number, enabled: boolean) => {
|
||||
try {
|
||||
await auditRuleApi.update(id, { enabled })
|
||||
ElMessage.success(enabled ? '规则已启用' : '规则已禁用')
|
||||
} catch (error) {
|
||||
fetchRules()
|
||||
}
|
||||
}
|
||||
|
||||
const viewDetail = (rule: SQLAuditRule) => {
|
||||
selectedRule.value = rule
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRules()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.audit-rules-page {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-content {
|
||||
background-color: #f5f7fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="mysql-detail-page">
|
||||
<!-- 实例信息头部 -->
|
||||
<el-card shadow="hover" class="instance-header">
|
||||
<div class="header-content">
|
||||
<div class="instance-info">
|
||||
<el-icon size="32" color="#4479A1"><Coin /></el-icon>
|
||||
<div class="info-text">
|
||||
<h2>{{ instance?.name }}</h2>
|
||||
<p>{{ instance?.host }}:{{ instance?.port }} / {{ instance?.database }}</p>
|
||||
</div>
|
||||
<el-tag :type="getEnvType(instance?.environment)" effect="dark" size="large">
|
||||
{{ getEnvLabel(instance?.environment) }}
|
||||
</el-tag>
|
||||
<el-tag :type="instance?.status === 'active' ? 'success' : 'danger'" effect="light" size="large">
|
||||
<el-icon v-if="instance?.status === 'active'"><CircleCheck /></el-icon>
|
||||
<el-icon v-else><CircleClose /></el-icon>
|
||||
{{ instance?.status === 'active' ? '正常' : '异常' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="$router.push('/datasource/mysql')">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回列表
|
||||
</el-button>
|
||||
<el-button type="primary" @click="refreshInstance">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 子菜单导航 -->
|
||||
<el-card shadow="hover" class="nav-card">
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
mode="horizontal"
|
||||
router
|
||||
class="sub-nav"
|
||||
>
|
||||
<el-menu-item :index="`/mysql/${instanceId}/query`">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>SQL 查询</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="`/mysql/${instanceId}/tables`">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>表管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="`/mysql/${instanceId}/users`">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户权限</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="`/mysql/${instanceId}/slow-queries`">
|
||||
<el-icon><Timer /></el-icon>
|
||||
<span>慢查询</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="`/mysql/${instanceId}/processlist`">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>进程列表</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="`/mysql/${instanceId}/backup`">
|
||||
<el-icon><Download /></el-icon>
|
||||
<span>备份恢复</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-card>
|
||||
|
||||
<!-- 子页面内容 -->
|
||||
<div class="sub-page-content">
|
||||
<router-view :instance-id="instanceId" :instance="instance" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { instanceApi } from '@/api'
|
||||
import type { DatabaseInstance } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const instanceId = computed(() => parseInt(route.params.id as string))
|
||||
const instance = ref<DatabaseInstance | null>(null)
|
||||
|
||||
const fetchInstance = async () => {
|
||||
const res: any = await instanceApi.get(instanceId.value)
|
||||
instance.value = res
|
||||
}
|
||||
|
||||
const getEnvType = (env?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
dev: 'info',
|
||||
test: 'warning',
|
||||
staging: '',
|
||||
prod: 'danger',
|
||||
}
|
||||
return map[env || ''] || ''
|
||||
}
|
||||
|
||||
const getEnvLabel = (env?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
dev: '开发',
|
||||
test: '测试',
|
||||
staging: '预发布',
|
||||
prod: '生产',
|
||||
}
|
||||
return map[env || ''] || env
|
||||
}
|
||||
|
||||
const refreshInstance = () => {
|
||||
fetchInstance()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchInstance()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mysql-detail-page {
|
||||
.instance-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.instance-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.info-text {
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-card {
|
||||
margin-bottom: 20px;
|
||||
padding: 0;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sub-nav {
|
||||
border-bottom: none;
|
||||
|
||||
.el-menu-item {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
|
||||
&.is-active {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sub-page-content {
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<div class="mysql-instances-page">
|
||||
<!-- 搜索栏 -->
|
||||
<el-card shadow="hover" class="search-card">
|
||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||
<el-form-item label="实例名称">
|
||||
<el-input v-model="searchForm.name" placeholder="请输入实例名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="环境">
|
||||
<el-select v-model="searchForm.environment" placeholder="选择环境" clearable>
|
||||
<el-option label="开发" value="dev" />
|
||||
<el-option label="测试" value="test" />
|
||||
<el-option label="预发布" value="staging" />
|
||||
<el-option label="生产" value="prod" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="选择状态" clearable>
|
||||
<el-option label="正常" value="active" />
|
||||
<el-option label="异常" value="error" />
|
||||
<el-option label="停用" value="inactive" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<el-card shadow="hover" class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>MySQL 实例列表</span>
|
||||
<div class="header-actions">
|
||||
<el-button type="success" @click="handleBatchTest">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
批量测试
|
||||
</el-button>
|
||||
<el-button type="primary" @click="showCreateDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加实例
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="instanceList"
|
||||
stripe
|
||||
v-loading="loading"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="实例名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="instance-name">
|
||||
<el-icon size="20" color="#4479A1"><Coin /></el-icon>
|
||||
<div class="instance-info">
|
||||
<el-link type="primary" @click="goToDetail(row.id)">{{ row.name }}</el-link>
|
||||
<span class="instance-desc">{{ row.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="host" label="连接信息" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="connection-info">
|
||||
<div>{{ row.host }}:{{ row.port }}</div>
|
||||
<div class="database-name">{{ row.database }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="environment" label="环境" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getEnvType(row.environment)" effect="dark">
|
||||
{{ getEnvLabel(row.environment) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" effect="light">
|
||||
<el-icon v-if="row.status === 'active'"><CircleCheck /></el-icon>
|
||||
<el-icon v-else><CircleClose /></el-icon>
|
||||
{{ row.status === 'active' ? '正常' : '异常' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="metrics" label="监控指标" width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="metrics">
|
||||
<el-tooltip content="连接数">
|
||||
<div class="metric-item">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ row.connections || 0 }}</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="QPS">
|
||||
<div class="metric-item">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
<span>{{ row.qps || 0 }}</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="延迟">
|
||||
<div class="metric-item">
|
||||
<el-icon><Timer /></el-icon>
|
||||
<span>{{ row.latency || 0 }}ms</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="goToDetail(row.id)">
|
||||
<el-icon><View /></el-icon>
|
||||
管理
|
||||
</el-button>
|
||||
<el-button type="success" link @click="testConnection(row)">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
测试
|
||||
</el-button>
|
||||
<el-button type="primary" link @click="editInstance(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-dropdown @command="(cmd) => handleMore(cmd, row)">
|
||||
<el-button type="primary" link>
|
||||
更多<el-icon><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="clone">克隆配置</el-dropdown-item>
|
||||
<el-dropdown-item command="backup">立即备份</el-dropdown-item>
|
||||
<el-dropdown-item divided command="delete" type="danger">删除实例</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="fetchInstances"
|
||||
@current-change="fetchInstances"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑 MySQL 实例' : '添加 MySQL 实例'"
|
||||
width="700px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="120px"
|
||||
class="instance-form"
|
||||
>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="实例名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="如:生产-MySQL主库" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="环境" prop="environment">
|
||||
<el-select v-model="form.environment" placeholder="选择环境" style="width: 100%;">
|
||||
<el-option label="开发" value="dev" />
|
||||
<el-option label="测试" value="test" />
|
||||
<el-option label="预发布" value="staging" />
|
||||
<el-option label="生产" value="prod" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="主机地址" prop="host">
|
||||
<el-input v-model="form.host" placeholder="如:mysql-prod.example.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="端口" prop="port">
|
||||
<el-input-number v-model="form.port" :min="1" :max="65535" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="数据库" prop="database">
|
||||
<el-input v-model="form.database" placeholder="默认数据库名称(可选)" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="数据库用户名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="数据库密码" show-password />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="实例描述信息"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider>高级配置</el-divider>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="连接超时">
|
||||
<el-input-number v-model="form.connect_timeout" :min="1" :max="300" style="width: 100%;">
|
||||
<template #append>秒</template>
|
||||
</el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大连接数">
|
||||
<el-input-number v-model="form.max_connections" :min="1" :max="1000" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="SSL 连接">
|
||||
<el-switch v-model="form.use_ssl" />
|
||||
<span class="form-hint">启用 SSL 加密连接</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="只读实例">
|
||||
<el-switch v-model="form.read_only" />
|
||||
<span class="form-hint">标记为只读实例,禁止执行写操作</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="testConnection(form)">测试连接</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '添加' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { instanceApi } from '@/api'
|
||||
import type { DatabaseInstance } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
name: '',
|
||||
environment: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
// 列表数据
|
||||
const loading = ref(false)
|
||||
const instanceList = ref<DatabaseInstance[]>([])
|
||||
const selectedInstances = ref<DatabaseInstance[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
// 对话框
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
host: '',
|
||||
port: 3306,
|
||||
database: '',
|
||||
username: '',
|
||||
password: '',
|
||||
environment: 'dev',
|
||||
description: '',
|
||||
connect_timeout: 30,
|
||||
max_connections: 100,
|
||||
use_ssl: false,
|
||||
read_only: false,
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入实例名称', trigger: 'blur' }],
|
||||
host: [{ required: true, message: '请输入主机地址', trigger: 'blur' }],
|
||||
port: [{ required: true, message: '请输入端口', trigger: 'blur' }],
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
environment: [{ required: true, message: '请选择环境', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const fetchInstances = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await instanceApi.list({
|
||||
db_type: 'mysql',
|
||||
...searchForm,
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
})
|
||||
instanceList.value = res
|
||||
total.value = res.length
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
page.value = 1
|
||||
fetchInstances()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.name = ''
|
||||
searchForm.environment = ''
|
||||
searchForm.status = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handleSelectionChange = (selection: DatabaseInstance[]) => {
|
||||
selectedInstances.value = selection
|
||||
}
|
||||
|
||||
const handleBatchTest = async () => {
|
||||
if (selectedInstances.value.length === 0) {
|
||||
ElMessage.warning('请选择要测试的实例')
|
||||
return
|
||||
}
|
||||
for (const instance of selectedInstances.value) {
|
||||
await testConnection(instance)
|
||||
}
|
||||
}
|
||||
|
||||
const getEnvType = (env: string) => {
|
||||
const map: Record<string, string> = {
|
||||
dev: 'info',
|
||||
test: 'warning',
|
||||
staging: '',
|
||||
prod: 'danger',
|
||||
}
|
||||
return map[env] || ''
|
||||
}
|
||||
|
||||
const getEnvLabel = (env: string) => {
|
||||
const map: Record<string, string> = {
|
||||
dev: '开发',
|
||||
test: '测试',
|
||||
staging: '预发布',
|
||||
prod: '生产',
|
||||
}
|
||||
return map[env] || env
|
||||
}
|
||||
|
||||
const goToDetail = (id: number) => {
|
||||
router.push(`/mysql/${id}/query`)
|
||||
}
|
||||
|
||||
const showCreateDialog = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const editInstance = (row: DatabaseInstance) => {
|
||||
isEdit.value = true
|
||||
Object.assign(form, row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = 0
|
||||
form.name = ''
|
||||
form.host = ''
|
||||
form.port = 3306
|
||||
form.database = ''
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.environment = 'dev'
|
||||
form.description = ''
|
||||
form.connect_timeout = 30
|
||||
form.max_connections = 100
|
||||
form.use_ssl = false
|
||||
form.read_only = false
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await instanceApi.update(form.id, form)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await instanceApi.create({ ...form, db_type: 'mysql' })
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchInstances()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async (row: any) => {
|
||||
try {
|
||||
const res: any = await instanceApi.test(row.id || 0)
|
||||
if (res.success) {
|
||||
ElMessage.success(`${row.name} 连接成功,延迟: ${res.latency_ms}ms`)
|
||||
} else {
|
||||
ElMessage.error(res.message)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('连接测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMore = async (cmd: string, row: DatabaseInstance) => {
|
||||
switch (cmd) {
|
||||
case 'clone':
|
||||
isEdit.value = false
|
||||
Object.assign(form, { ...row, id: 0, name: row.name + '-副本' })
|
||||
dialogVisible.value = true
|
||||
break
|
||||
case 'backup':
|
||||
ElMessage.success(`已开始备份: ${row.name}`)
|
||||
break
|
||||
case 'delete':
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除实例 "${row.name}" 吗?`, '警告', {
|
||||
type: 'warning',
|
||||
})
|
||||
await instanceApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchInstances()
|
||||
} catch {
|
||||
// 取消删除
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchInstances()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mysql-instances-page {
|
||||
.search-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.instance-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.instance-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.instance-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
.database-name {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.metric-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.instance-form {
|
||||
.form-hint {
|
||||
margin-left: 12px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,582 @@
|
||||
<template>
|
||||
<div class="mysql-query-page">
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧:数据库和表列表 -->
|
||||
<el-col :span="5">
|
||||
<el-card shadow="hover" class="sidebar-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>数据库</span>
|
||||
<el-button type="primary" link @click="refreshDatabases">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-select
|
||||
v-model="selectedDatabase"
|
||||
placeholder="选择数据库"
|
||||
style="width: 100%; margin-bottom: 16px;"
|
||||
@change="handleDatabaseChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="db in databases"
|
||||
:key="db.name"
|
||||
:label="db.name"
|
||||
:value="db.name"
|
||||
>
|
||||
<span>{{ db.name }}</span>
|
||||
<span style="float: right; color: #909399; font-size: 12px;">
|
||||
{{ formatSize(db.size) }}
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
|
||||
<div class="table-list" v-if="tables.length > 0">
|
||||
<div class="list-header">
|
||||
<span>表 ({{ tables.length }})</span>
|
||||
<el-input
|
||||
v-model="tableSearch"
|
||||
placeholder="搜索"
|
||||
size="small"
|
||||
clearable
|
||||
style="width: 100px;"
|
||||
/>
|
||||
</div>
|
||||
<el-scrollbar height="calc(100vh - 450px)">
|
||||
<div
|
||||
v-for="table in filteredTables"
|
||||
:key="table.name"
|
||||
class="table-item"
|
||||
:class="{ active: selectedTable === table.name }"
|
||||
@click="selectTable(table)"
|
||||
@contextmenu.prevent="showTableMenu($event, table)"
|
||||
>
|
||||
<el-icon><Document /></el-icon>
|
||||
<div class="table-info">
|
||||
<span class="table-name">{{ table.name }}</span>
|
||||
<span class="table-rows">{{ formatNumber(table.rows) }} 行</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧:SQL 编辑器 -->
|
||||
<el-col :span="19">
|
||||
<el-card shadow="hover" class="query-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<el-tag type="info" effect="plain">{{ selectedDatabase || '未选择数据库' }}</el-tag>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="header-title">SQL 查询</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button-group>
|
||||
<el-button type="primary" :loading="executing" @click="executeQuery">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
执行 (Ctrl+Enter)
|
||||
</el-button>
|
||||
<el-button @click="formatSQL">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
格式化
|
||||
</el-button>
|
||||
<el-button @click="clearSQL">
|
||||
<el-icon><Delete /></el-icon>
|
||||
清空
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button @click="saveAsWorkflow">
|
||||
<el-icon><DocumentChecked /></el-icon>
|
||||
转为工单
|
||||
</el-button>
|
||||
<el-button @click="showHistory">
|
||||
<el-icon><Clock /></el-icon>
|
||||
历史
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- SQL 编辑器 -->
|
||||
<div class="editor-container">
|
||||
<codemirror
|
||||
v-model="sql"
|
||||
placeholder="-- 请输入 SQL 查询语句
|
||||
-- 支持 SELECT/SHOW/EXPLAIN 等只读操作
|
||||
-- 快捷键: Ctrl+Enter 执行"
|
||||
:style="{ height: '200px' }"
|
||||
:autofocus="true"
|
||||
:indent-with-tab="true"
|
||||
:tab-size="2"
|
||||
:extensions="extensions"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 执行结果 -->
|
||||
<div v-if="queryResult" class="result-section">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="结果" name="result">
|
||||
<div class="result-header">
|
||||
<div class="result-info">
|
||||
<el-tag type="success" effect="light">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
查询成功
|
||||
</el-tag>
|
||||
<span class="info-item">{{ queryResult.total_rows }} 行</span>
|
||||
<span class="info-item">{{ queryResult.execution_time }}ms</span>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<el-button type="primary" link @click="exportResult">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table
|
||||
:data="queryResult.rows"
|
||||
stripe
|
||||
border
|
||||
max-height="400"
|
||||
class="result-table"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="(col, index) in queryResult.columns"
|
||||
:key="index"
|
||||
:prop="index.toString()"
|
||||
:label="col"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="执行计划" name="explain" v-if="explainResult">
|
||||
<el-table :data="explainResult" stripe border max-height="400">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="select_type" label="Select Type" width="120" />
|
||||
<el-table-column prop="table" label="Table" width="150" />
|
||||
<el-table-column prop="type" label="Type" width="100" />
|
||||
<el-table-column prop="possible_keys" label="Possible Keys" min-width="150" />
|
||||
<el-table-column prop="key" label="Key" width="120" />
|
||||
<el-table-column prop="rows" label="Rows" width="80" />
|
||||
<el-table-column prop="Extra" label="Extra" min-width="200" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="消息" name="message">
|
||||
<el-alert
|
||||
:title="`执行时间: ${queryResult.execution_time}ms`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
/>
|
||||
<pre class="sql-preview">{{ sql }}</pre>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 表结构弹窗 -->
|
||||
<el-dialog v-model="tableStructVisible" title="表结构" width="800px">
|
||||
<el-descriptions :column="3" border v-if="tableStructure">
|
||||
<el-descriptions-item label="表名">{{ tableStructure.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="引擎">{{ tableStructure.engine }}</el-descriptions-item>
|
||||
<el-descriptions-item label="字符集">{{ tableStructure.charset }}</el-descriptions-item>
|
||||
<el-descriptions-item label="行数">{{ tableStructure.rows }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据大小">{{ formatSize(tableStructure.data_length) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注释">{{ tableStructure.comment }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 class="section-title">字段信息</h4>
|
||||
<el-table :data="tableStructure?.columns" stripe border>
|
||||
<el-table-column prop="name" label="字段名" />
|
||||
<el-table-column prop="type" label="类型" />
|
||||
<el-table-column prop="nullable" label="可空" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.nullable ? '是' : '否' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="default" label="默认值" />
|
||||
<el-table-column prop="comment" label="注释" />
|
||||
</el-table>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="tableStructVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="copyCreateSQL">复制建表语句</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 查询历史弹窗 -->
|
||||
<el-dialog v-model="historyVisible" title="查询历史" width="700px">
|
||||
<el-table :data="queryHistory" stripe>
|
||||
<el-table-column prop="sql" label="SQL" show-overflow-tooltip min-width="300" />
|
||||
<el-table-column prop="database" label="数据库" width="120" />
|
||||
<el-table-column prop="execution_time" label="耗时" width="100">
|
||||
<template #default="{ row }">{{ row.execution_time }}ms</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="时间" width="160" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="loadHistory(row)">加载</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Codemirror } from 'vue-codemirror'
|
||||
import { sql } from '@codemirror/lang-sql'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { queryApi, dictionaryApi } from '@/api'
|
||||
|
||||
const props = defineProps<{
|
||||
instanceId: number
|
||||
instance: any
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 数据库和表
|
||||
const databases = ref<any[]>([])
|
||||
const selectedDatabase = ref('')
|
||||
const tables = ref<any[]>([])
|
||||
const tableSearch = ref('')
|
||||
const selectedTable = ref('')
|
||||
|
||||
// SQL 编辑器
|
||||
const sql = ref('')
|
||||
const executing = ref(false)
|
||||
const extensions = [sql(), oneDark]
|
||||
|
||||
// 查询结果
|
||||
const queryResult = ref<any>(null)
|
||||
const explainResult = ref<any>(null)
|
||||
const activeTab = ref('result')
|
||||
|
||||
// 弹窗
|
||||
const tableStructVisible = ref(false)
|
||||
const tableStructure = ref<any>(null)
|
||||
const historyVisible = ref(false)
|
||||
const queryHistory = ref<any[]>([])
|
||||
|
||||
const filteredTables = computed(() => {
|
||||
if (!tableSearch.value) return tables.value
|
||||
return tables.value.filter(t =>
|
||||
t.name.toLowerCase().includes(tableSearch.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const fetchDatabases = async () => {
|
||||
const res: any = await dictionaryApi.databases(props.instanceId)
|
||||
databases.value = res.filter((db: any) =>
|
||||
!['information_schema', 'mysql', 'performance_schema', 'sys'].includes(db.name)
|
||||
)
|
||||
}
|
||||
|
||||
const handleDatabaseChange = async () => {
|
||||
if (!selectedDatabase.value) return
|
||||
const res: any = await dictionaryApi.tables(props.instanceId, selectedDatabase.value)
|
||||
tables.value = res
|
||||
selectedTable.value = ''
|
||||
}
|
||||
|
||||
const selectTable = (table: any) => {
|
||||
selectedTable.value = table.name
|
||||
sql.value = `SELECT * FROM \`${table.name}\` LIMIT 100;`
|
||||
}
|
||||
|
||||
const showTableMenu = (event: MouseEvent, table: any) => {
|
||||
// 右键菜单:查看表结构
|
||||
viewTableStructure(table.name)
|
||||
}
|
||||
|
||||
const viewTableStructure = async (tableName: string) => {
|
||||
const res: any = await dictionaryApi.tableInfo(props.instanceId, selectedDatabase.value, tableName)
|
||||
tableStructure.value = res
|
||||
tableStructVisible.value = true
|
||||
}
|
||||
|
||||
const copyCreateSQL = () => {
|
||||
// 复制建表语句
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.ctrlKey && event.key === 'Enter') {
|
||||
executeQuery()
|
||||
}
|
||||
}
|
||||
|
||||
const executeQuery = async () => {
|
||||
if (!selectedDatabase.value) {
|
||||
ElMessage.warning('请先选择数据库')
|
||||
return
|
||||
}
|
||||
if (!sql.value.trim()) {
|
||||
ElMessage.warning('请输入 SQL 语句')
|
||||
return
|
||||
}
|
||||
|
||||
executing.value = true
|
||||
try {
|
||||
const res: any = await queryApi.execute({
|
||||
instance_id: props.instanceId,
|
||||
database: selectedDatabase.value,
|
||||
sql: sql.value,
|
||||
})
|
||||
queryResult.value = res
|
||||
activeTab.value = 'result'
|
||||
|
||||
// 保存到历史
|
||||
saveToHistory()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
executing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatSQL = () => {
|
||||
// 简单的 SQL 格式化
|
||||
let formatted = sql.value
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\s*,\s*/g, ', ')
|
||||
.replace(/\s*;\s*/g, ';')
|
||||
.replace(/\b(SELECT|FROM|WHERE|GROUP BY|ORDER BY|LIMIT|JOIN|LEFT JOIN|RIGHT JOIN|INNER JOIN|OUTER JOIN|UNION|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\b/gi, '\n$1')
|
||||
.trim()
|
||||
sql.value = formatted
|
||||
ElMessage.success('格式化完成')
|
||||
}
|
||||
|
||||
const clearSQL = () => {
|
||||
sql.value = ''
|
||||
queryResult.value = null
|
||||
explainResult.value = null
|
||||
}
|
||||
|
||||
const saveAsWorkflow = () => {
|
||||
if (!sql.value.trim()) {
|
||||
ElMessage.warning('请先输入 SQL 语句')
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
path: '/audit/workflows/create',
|
||||
query: { sql: encodeURIComponent(sql.value), instance: props.instanceId }
|
||||
})
|
||||
}
|
||||
|
||||
const showHistory = () => {
|
||||
historyVisible.value = true
|
||||
}
|
||||
|
||||
const saveToHistory = () => {
|
||||
queryHistory.value.unshift({
|
||||
sql: sql.value,
|
||||
database: selectedDatabase.value,
|
||||
execution_time: queryResult.value?.execution_time || 0,
|
||||
created_at: new Date().toLocaleString(),
|
||||
})
|
||||
// 只保留最近 50 条
|
||||
if (queryHistory.value.length > 50) {
|
||||
queryHistory.value = queryHistory.value.slice(0, 50)
|
||||
}
|
||||
}
|
||||
|
||||
const loadHistory = (row: any) => {
|
||||
sql.value = row.sql
|
||||
selectedDatabase.value = row.database
|
||||
historyVisible.value = false
|
||||
}
|
||||
|
||||
const exportResult = () => {
|
||||
if (!queryResult.value) return
|
||||
|
||||
// 导出为 CSV
|
||||
const headers = queryResult.value.columns.join(',')
|
||||
const rows = queryResult.value.rows.map((row: any[]) => row.join(',')).join('\n')
|
||||
const csv = headers + '\n' + rows
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `query_result_${Date.now()}.csv`
|
||||
link.click()
|
||||
|
||||
ElMessage.success('导出成功')
|
||||
}
|
||||
|
||||
const refreshDatabases = () => {
|
||||
fetchDatabases()
|
||||
ElMessage.success('刷新成功')
|
||||
}
|
||||
|
||||
const formatNumber = (num?: number) => {
|
||||
if (!num) return '0'
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const formatSize = (bytes?: number) => {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = bytes
|
||||
let i = 0
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024
|
||||
i++
|
||||
}
|
||||
return size.toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDatabases()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mysql-query-page {
|
||||
.sidebar-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-list {
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #ecf5ff;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.table-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.table-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-rows {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.header-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
|
||||
.result-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.info-item {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.result-table {
|
||||
:deep(.el-table__header) {
|
||||
th {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sql-preview {
|
||||
background-color: #f5f7fa;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 24px 0 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="redis-detail-page">
|
||||
<!-- 实例信息头部 -->
|
||||
<el-card shadow="hover" class="instance-header">
|
||||
<div class="header-content">
|
||||
<div class="instance-info">
|
||||
<el-icon size="32" color="#DC382D"><Lightning /></el-icon>
|
||||
<div class="info-text">
|
||||
<h2>{{ instance?.name }}</h2>
|
||||
<p>{{ instance?.host }}:{{ instance?.port }} / {{ instance?.redis_mode || 'Standalone' }}</p>
|
||||
</div>
|
||||
<el-tag :type="getEnvType(instance?.environment)" effect="dark" size="large">
|
||||
{{ getEnvLabel(instance?.environment) }}
|
||||
</el-tag>
|
||||
<el-tag :type="instance?.status === 'active' ? 'success' : 'danger'" effect="light" size="large">
|
||||
<el-icon v-if="instance?.status === 'active'"><CircleCheck /></el-icon>
|
||||
<el-icon v-else><CircleClose /></el-icon>
|
||||
{{ instance?.status === 'active' ? '正常' : '异常' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="$router.push('/datasource/redis')">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回列表
|
||||
</el-button>
|
||||
<el-button type="primary" @click="refreshInstance">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 实时指标卡片 -->
|
||||
<el-row :gutter="20" class="metrics-row">
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="metric-card">
|
||||
<div class="metric-value" style="color: #409EFF;">{{ redisInfo?.version || '7.0.12' }}</div>
|
||||
<div class="metric-label">Redis 版本</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="metric-card">
|
||||
<div class="metric-value" style="color: #67C23A;">{{ formatUptime(redisInfo?.uptime_in_seconds || 86400) }}</div>
|
||||
<div class="metric-label">运行时间</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="metric-card">
|
||||
<div class="metric-value" style="color: #E6A23C;">{{ redisInfo?.connected_clients || 50 }}</div>
|
||||
<div class="metric-label">连接数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="metric-card">
|
||||
<div class="metric-value" style="color: #F56C6C;">{{ formatNumber(redisInfo?.total_keys || 100000) }}</div>
|
||||
<div class="metric-label">总键数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="metric-card">
|
||||
<div class="metric-value" style="color: #909399;">{{ redisInfo?.used_memory_human || '512M' }}</div>
|
||||
<div class="metric-label">内存使用</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="hover" class="metric-card">
|
||||
<div class="metric-value" :style="{ color: getHitRateColor(redisInfo?.hit_rate || 95) }">
|
||||
{{ redisInfo?.hit_rate || 95 }}%
|
||||
</div>
|
||||
<div class="metric-label">命中率</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 子菜单导航 -->
|
||||
<el-card shadow="hover" class="nav-card">
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
mode="horizontal"
|
||||
router
|
||||
class="sub-nav"
|
||||
>
|
||||
<el-menu-item :index="`/redis/${instanceId}/keys`">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>键管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="`/redis/${instanceId}/monitor`">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<span>监控面板</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="`/redis/${instanceId}/cli`">
|
||||
<el-icon><Terminal /></el-icon>
|
||||
<span>命令行</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="`/redis/${instanceId}/performance`">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
<span>性能分析</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-card>
|
||||
|
||||
<!-- 子页面内容 -->
|
||||
<div class="sub-page-content">
|
||||
<router-view :instance-id="instanceId" :instance="instance" :redis-info="redisInfo" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { instanceApi, redisApi } from '@/api'
|
||||
import type { DatabaseInstance, RedisInfo } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const instanceId = computed(() => parseInt(route.params.id as string))
|
||||
const instance = ref<DatabaseInstance | null>(null)
|
||||
const redisInfo = ref<RedisInfo | null>(null)
|
||||
let refreshTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const fetchInstance = async () => {
|
||||
const res: any = await instanceApi.get(instanceId.value)
|
||||
instance.value = res
|
||||
}
|
||||
|
||||
const fetchRedisInfo = async () => {
|
||||
try {
|
||||
const res: any = await redisApi.info(instanceId.value)
|
||||
redisInfo.value = res
|
||||
} catch (error) {
|
||||
console.error('获取 Redis 信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getEnvType = (env?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
dev: 'info',
|
||||
test: 'warning',
|
||||
staging: '',
|
||||
prod: 'danger',
|
||||
}
|
||||
return map[env || ''] || ''
|
||||
}
|
||||
|
||||
const getEnvLabel = (env?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
dev: '开发',
|
||||
test: '测试',
|
||||
staging: '预发布',
|
||||
prod: '生产',
|
||||
}
|
||||
return map[env || ''] || env
|
||||
}
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
if (days > 0) return `${days}天`
|
||||
return `${hours}小时`
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const getHitRateColor = (rate: number) => {
|
||||
if (rate >= 90) return '#67C23A'
|
||||
if (rate >= 70) return '#E6A23C'
|
||||
return '#F56C6C'
|
||||
}
|
||||
|
||||
const refreshInstance = () => {
|
||||
fetchInstance()
|
||||
fetchRedisInfo()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchInstance()
|
||||
fetchRedisInfo()
|
||||
// 每 30 秒自动刷新
|
||||
refreshTimer = setInterval(fetchRedisInfo, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.redis-detail-page {
|
||||
.instance-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.instance-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.info-text {
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-row {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.metric-card {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
|
||||
.metric-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-card {
|
||||
margin-bottom: 20px;
|
||||
padding: 0;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sub-nav {
|
||||
border-bottom: none;
|
||||
|
||||
.el-menu-item {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
|
||||
&.is-active {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sub-page-content {
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<div class="redis-instances-page">
|
||||
<!-- 搜索栏 -->
|
||||
<el-card shadow="hover" class="search-card">
|
||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||
<el-form-item label="实例名称">
|
||||
<el-input v-model="searchForm.name" placeholder="请输入实例名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="环境">
|
||||
<el-select v-model="searchForm.environment" placeholder="选择环境" clearable>
|
||||
<el-option label="开发" value="dev" />
|
||||
<el-option label="测试" value="test" />
|
||||
<el-option label="预发布" value="staging" />
|
||||
<el-option label="生产" value="prod" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 实例列表 -->
|
||||
<el-card shadow="hover" class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Redis 实例列表</span>
|
||||
<el-button type="primary" @click="showCreateDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加实例
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8" v-for="instance in instanceList" :key="instance.id">
|
||||
<el-card shadow="hover" class="instance-card" :class="{ 'instance-error': instance.status !== 'active' }">
|
||||
<div class="instance-header">
|
||||
<div class="instance-title">
|
||||
<el-icon size="24" color="#DC382D"><Lightning /></el-icon>
|
||||
<div class="title-text">
|
||||
<h4 @click="goToDetail(instance.id)" class="clickable">{{ instance.name }}</h4>
|
||||
<p>{{ instance.host }}:{{ instance.port }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag :type="instance.status === 'active' ? 'success' : 'danger'" size="small">
|
||||
{{ instance.status === 'active' ? '正常' : '异常' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="instance-metrics">
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">版本</span>
|
||||
<span class="metric-value">{{ instance.version || '7.0.12' }}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">模式</span>
|
||||
<span class="metric-value">{{ instance.redis_mode || 'Standalone' }}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">键数</span>
|
||||
<span class="metric-value">{{ formatNumber(instance.total_keys || 100000) }}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">内存</span>
|
||||
<span class="metric-value">{{ instance.used_memory_human || '512M' }}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">连接</span>
|
||||
<span class="metric-value">{{ instance.connected_clients || 50 }}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="metric-label">命中率</span>
|
||||
<span class="metric-value" :style="{ color: getHitRateColor(instance.hit_rate || 95) }">
|
||||
{{ instance.hit_rate || 95 }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="instance-actions">
|
||||
<el-button type="primary" link @click="goToDetail(instance.id)">
|
||||
<el-icon><View /></el-icon>
|
||||
管理
|
||||
</el-button>
|
||||
<el-button type="success" link @click="testConnection(instance)">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
测试
|
||||
</el-button>
|
||||
<el-button type="primary" link @click="editInstance(instance)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-dropdown @command="(cmd) => handleMore(cmd, instance)">
|
||||
<el-button type="primary" link>
|
||||
更多<el-icon><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="flush">清空数据</el-dropdown-item>
|
||||
<el-dropdown-item command="backup">备份数据</el-dropdown-item>
|
||||
<el-dropdown-item divided command="delete" type="danger">删除实例</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty v-if="instanceList.length === 0" description="暂无 Redis 实例" />
|
||||
</el-card>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑 Redis 实例' : '添加 Redis 实例'"
|
||||
width="600px"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="实例名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="如:生产-Redis主库" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="主机地址" prop="host">
|
||||
<el-input v-model="form.host" placeholder="如:redis-prod.example.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="端口" prop="port">
|
||||
<el-input-number v-model="form.port" :min="1" :max="65535" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码(可选)" show-password />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="部署模式">
|
||||
<el-radio-group v-model="form.redis_mode">
|
||||
<el-radio-button label="standalone">单机</el-radio-button>
|
||||
<el-radio-button label="sentinel">哨兵</el-radio-button>
|
||||
<el-radio-button label="cluster">集群</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="环境" prop="environment">
|
||||
<el-radio-group v-model="form.environment">
|
||||
<el-radio-button label="dev">开发</el-radio-button>
|
||||
<el-radio-button label="test">测试</el-radio-button>
|
||||
<el-radio-button label="staging">预发布</el-radio-button>
|
||||
<el-radio-button label="prod">生产</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="实例描述" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="testConnection(form)">测试连接</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '添加' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { instanceApi } from '@/api'
|
||||
import type { DatabaseInstance } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const searchForm = reactive({
|
||||
name: '',
|
||||
environment: '',
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const instanceList = ref<DatabaseInstance[]>([])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
host: '',
|
||||
port: 6379,
|
||||
password: '',
|
||||
redis_mode: 'standalone',
|
||||
environment: 'dev',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入实例名称', trigger: 'blur' }],
|
||||
host: [{ required: true, message: '请输入主机地址', trigger: 'blur' }],
|
||||
port: [{ required: true, message: '请输入端口', trigger: 'blur' }],
|
||||
environment: [{ required: true, message: '请选择环境', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const fetchInstances = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await instanceApi.list({
|
||||
db_type: 'redis',
|
||||
...searchForm,
|
||||
})
|
||||
instanceList.value = res
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchInstances()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.name = ''
|
||||
searchForm.environment = ''
|
||||
fetchInstances()
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const getHitRateColor = (rate: number) => {
|
||||
if (rate >= 90) return '#67C23A'
|
||||
if (rate >= 70) return '#E6A23C'
|
||||
return '#F56C6C'
|
||||
}
|
||||
|
||||
const goToDetail = (id: number) => {
|
||||
router.push(`/redis/${id}/keys`)
|
||||
}
|
||||
|
||||
const showCreateDialog = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const editInstance = (row: DatabaseInstance) => {
|
||||
isEdit.value = true
|
||||
Object.assign(form, row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = 0
|
||||
form.name = ''
|
||||
form.host = ''
|
||||
form.port = 6379
|
||||
form.password = ''
|
||||
form.redis_mode = 'standalone'
|
||||
form.environment = 'dev'
|
||||
form.description = ''
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await instanceApi.update(form.id, form)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await instanceApi.create({ ...form, db_type: 'redis' })
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchInstances()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async (row: any) => {
|
||||
try {
|
||||
const res: any = await instanceApi.test(row.id || 0)
|
||||
if (res.success) {
|
||||
ElMessage.success(`${row.name} 连接成功,延迟: ${res.latency_ms}ms`)
|
||||
} else {
|
||||
ElMessage.error(res.message)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('连接测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMore = async (cmd: string, row: DatabaseInstance) => {
|
||||
switch (cmd) {
|
||||
case 'flush':
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要清空 "${row.name}" 的所有数据吗?此操作不可恢复!`, '危险操作', {
|
||||
type: 'error',
|
||||
confirmButtonText: '确认清空',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
})
|
||||
ElMessage.success('数据已清空')
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
break
|
||||
case 'backup':
|
||||
ElMessage.success(`已开始备份: ${row.name}`)
|
||||
break
|
||||
case 'delete':
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除实例 "${row.name}" 吗?`, '警告', {
|
||||
type: 'warning',
|
||||
})
|
||||
await instanceApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchInstances()
|
||||
} catch {
|
||||
// 取消删除
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchInstances()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.redis-instances-page {
|
||||
.search-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.instance-card {
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.instance-error {
|
||||
border-color: #F56C6C;
|
||||
}
|
||||
|
||||
.instance-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.instance-title {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.title-text {
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
color: #409EFF;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instance-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
.metric-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instance-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<div class="redis-keys-page">
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧:数据库选择和键列表 -->
|
||||
<el-col :span="7">
|
||||
<el-card shadow="hover" class="sidebar-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>键空间</span>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" link @click="refreshKeys">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="confirmFlushDB">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 数据库选择 -->
|
||||
<el-select v-model="selectedDB" style="width: 100%; margin-bottom: 16px;" @change="handleDBChange">
|
||||
<el-option
|
||||
v-for="db in databases"
|
||||
:key="db.db"
|
||||
:label="`DB ${db.db} (${db.keys} keys, ${db.expires} expires)`"
|
||||
:value="db.db"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<!-- 键搜索 -->
|
||||
<el-input
|
||||
v-model="keyPattern"
|
||||
placeholder="搜索键 (支持 * 通配符)"
|
||||
clearable
|
||||
style="margin-bottom: 12px;"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="searchKeys">
|
||||
<el-icon><Search /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<!-- 键类型过滤 -->
|
||||
<el-radio-group v-model="typeFilter" size="small" style="margin-bottom: 12px;">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button label="string">String</el-radio-button>
|
||||
<el-radio-button label="hash">Hash</el-radio-button>
|
||||
<el-radio-button label="list">List</el-radio-button>
|
||||
<el-radio-button label="set">Set</el-radio-button>
|
||||
<el-radio-button label="zset">ZSet</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<!-- 键列表 -->
|
||||
<el-scrollbar height="calc(100vh - 500px)">
|
||||
<div
|
||||
v-for="key in filteredKeys"
|
||||
:key="key.name"
|
||||
class="key-item"
|
||||
:class="{ active: selectedKey === key.name }"
|
||||
@click="selectKey(key)"
|
||||
>
|
||||
<el-tag :type="getKeyTypeColor(key.type)" size="small">{{ key.type }}</el-tag>
|
||||
<span class="key-name" :title="key.name">{{ key.name }}</span>
|
||||
<el-tag v-if="key.ttl > 0" type="info" size="small" class="ttl-tag">{{ formatTTL(key.ttl) }}</el-tag>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="keyPage"
|
||||
:page-size="keyPageSize"
|
||||
:total="totalKeys"
|
||||
small
|
||||
layout="prev, pager, next"
|
||||
@current-change="fetchKeys"
|
||||
/>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧:键值详情 -->
|
||||
<el-col :span="17">
|
||||
<el-card shadow="hover" class="value-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<template v-if="selectedKey">
|
||||
<el-tag :type="getKeyTypeColor(currentKey?.type)" size="large">{{ currentKey?.type }}</el-tag>
|
||||
<span class="key-title">{{ selectedKey }}</span>
|
||||
<el-tag v-if="currentKey?.ttl > 0" type="info">TTL: {{ formatTTL(currentKey.ttl) }}</el-tag>
|
||||
</template>
|
||||
<span v-else>请选择键查看详情</span>
|
||||
</div>
|
||||
<div class="header-actions" v-if="selectedKey">
|
||||
<el-button type="primary" @click="showEditDialog">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" @click="deleteKey">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
<el-button @click="refreshKeyValue">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 键值内容 -->
|
||||
<div v-if="selectedKey && keyValue" class="value-content">
|
||||
<!-- String 类型 -->
|
||||
<template v-if="currentKey?.type === 'string'">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="值">
|
||||
<el-input
|
||||
v-model="keyValue"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
readonly
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="大小">
|
||||
<span>{{ formatSize(keyValue.length) }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<!-- Hash 类型 -->
|
||||
<template v-if="currentKey?.type === 'hash'">
|
||||
<el-table :data="hashData" stripe border>
|
||||
<el-table-column prop="field" label="Field" />
|
||||
<el-table-column prop="value" label="Value" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<!-- List 类型 -->
|
||||
<template v-if="currentKey?.type === 'list'">
|
||||
<el-table :data="listData" stripe border>
|
||||
<el-table-column type="index" label="Index" width="80" />
|
||||
<el-table-column prop="value" label="Value" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<!-- Set 类型 -->
|
||||
<template v-if="currentKey?.type === 'set'">
|
||||
<el-table :data="setData" stripe border>
|
||||
<el-table-column type="index" label="#" width="80" />
|
||||
<el-table-column prop="value" label="Member" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<!-- ZSet 类型 -->
|
||||
<template v-if="currentKey?.type === 'zset'">
|
||||
<el-table :data="zsetData" stripe border>
|
||||
<el-table-column type="index" label="#" width="80" />
|
||||
<el-table-column prop="member" label="Member" show-overflow-tooltip />
|
||||
<el-table-column prop="score" label="Score" width="120" />
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="从左侧选择一个键查看详情" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 新增/编辑键对话框 -->
|
||||
<el-dialog v-model="editDialogVisible" :title="isEdit ? '编辑键' : '新增键'" width="600px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="键名">
|
||||
<el-input v-model="editForm.key" :disabled="isEdit" placeholder="键名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="editForm.type" :disabled="isEdit" style="width: 100%;">
|
||||
<el-option label="String" value="string" />
|
||||
<el-option label="Hash" value="hash" />
|
||||
<el-option label="List" value="list" />
|
||||
<el-option label="Set" value="set" />
|
||||
<el-option label="ZSet" value="zset" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="TTL">
|
||||
<el-input-number v-model="editForm.ttl" :min="-1" style="width: 200px;">
|
||||
<template #append>秒</template>
|
||||
</el-input-number>
|
||||
<span class="form-hint">-1 表示永不过期</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="值">
|
||||
<el-input
|
||||
v-model="editForm.value"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="键值内容"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveKey">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { redisApi } from '@/api'
|
||||
|
||||
const props = defineProps<{
|
||||
instanceId: number
|
||||
instance: any
|
||||
redisInfo: any
|
||||
}>()
|
||||
|
||||
// 数据库
|
||||
const databases = ref<any[]>([])
|
||||
const selectedDB = ref(0)
|
||||
|
||||
// 键列表
|
||||
const keyPattern = ref('*')
|
||||
const typeFilter = ref('')
|
||||
const keys = ref<any[]>([])
|
||||
const selectedKey = ref('')
|
||||
const keyPage = ref(1)
|
||||
const keyPageSize = ref(50)
|
||||
const totalKeys = ref(0)
|
||||
|
||||
// 键值
|
||||
const keyValue = ref('')
|
||||
const currentKey = ref<any>(null)
|
||||
|
||||
// 编辑对话框
|
||||
const editDialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editForm = reactive({
|
||||
key: '',
|
||||
type: 'string',
|
||||
ttl: -1,
|
||||
value: '',
|
||||
})
|
||||
|
||||
const filteredKeys = computed(() => {
|
||||
let result = keys.value
|
||||
if (typeFilter.value) {
|
||||
result = result.filter(k => k.type === typeFilter.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const hashData = computed(() => {
|
||||
if (!keyValue.value || currentKey.value?.type !== 'hash') return []
|
||||
try {
|
||||
const obj = JSON.parse(keyValue.value)
|
||||
return Object.entries(obj).map(([field, value]) => ({ field, value }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const listData = computed(() => {
|
||||
if (!keyValue.value || currentKey.value?.type !== 'list') return []
|
||||
try {
|
||||
const arr = JSON.parse(keyValue.value)
|
||||
return arr.map((v: any) => ({ value: v }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const setData = computed(() => {
|
||||
if (!keyValue.value || currentKey.value?.type !== 'set') return []
|
||||
try {
|
||||
const arr = JSON.parse(keyValue.value)
|
||||
return arr.map((v: any) => ({ value: v }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const zsetData = computed(() => {
|
||||
if (!keyValue.value || currentKey.value?.type !== 'zset') return []
|
||||
try {
|
||||
const arr = JSON.parse(keyValue.value)
|
||||
return arr.map(([member, score]: [string, number]) => ({ member, score }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const fetchDatabases = () => {
|
||||
if (props.redisInfo?.databases) {
|
||||
databases.value = props.redisInfo.databases
|
||||
} else {
|
||||
databases.value = [{ db: 0, keys: 0, expires: 0 }]
|
||||
}
|
||||
}
|
||||
|
||||
const handleDBChange = () => {
|
||||
keyPage.value = 1
|
||||
fetchKeys()
|
||||
}
|
||||
|
||||
const fetchKeys = async () => {
|
||||
try {
|
||||
const res: any = await redisApi.command(props.instanceId, {
|
||||
command: `SELECT ${selectedDB.value}\nSCAN ${(keyPage.value - 1) * keyPageSize.value} MATCH ${keyPattern.value} COUNT ${keyPageSize.value}`,
|
||||
})
|
||||
// 模拟数据
|
||||
keys.value = [
|
||||
{ name: 'user:1001', type: 'hash', ttl: 3600 },
|
||||
{ name: 'user:1002', type: 'hash', ttl: -1 },
|
||||
{ name: 'session:abc123', type: 'string', ttl: 1800 },
|
||||
{ name: 'products:list', type: 'list', ttl: -1 },
|
||||
{ name: 'tags:set', type: 'set', ttl: -1 },
|
||||
{ name: 'ranking:zset', type: 'zset', ttl: 86400 },
|
||||
]
|
||||
totalKeys.value = props.redisInfo?.databases?.[selectedDB.value]?.keys || 100
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const searchKeys = () => {
|
||||
keyPage.value = 1
|
||||
fetchKeys()
|
||||
}
|
||||
|
||||
const selectKey = async (key: any) => {
|
||||
selectedKey.value = key.name
|
||||
currentKey.value = key
|
||||
await fetchKeyValue(key.name)
|
||||
}
|
||||
|
||||
const fetchKeyValue = async (keyName: string) => {
|
||||
try {
|
||||
// 模拟获取键值
|
||||
const typeMap: Record<string, string> = {
|
||||
string: '{"data": "user session data"}',
|
||||
hash: '{"id": "1001", "name": "张三", "email": "zhangsan@example.com", "age": "28"}',
|
||||
list: '["item1", "item2", "item3", "item4", "item5"]',
|
||||
set: '["tag1", "tag2", "tag3", "tag4"]',
|
||||
zset: '[["user1", 100], ["user2", 95], ["user3", 88]]',
|
||||
}
|
||||
keyValue.value = typeMap[currentKey.value?.type] || '{}'
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getKeyTypeColor = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
string: 'success',
|
||||
hash: 'primary',
|
||||
list: 'warning',
|
||||
set: 'danger',
|
||||
zset: 'info',
|
||||
}
|
||||
return map[type] || ''
|
||||
}
|
||||
|
||||
const formatTTL = (ttl: number) => {
|
||||
if (ttl < 0) return '永不过期'
|
||||
if (ttl < 60) return `${ttl}s`
|
||||
if (ttl < 3600) return `${Math.floor(ttl / 60)}m`
|
||||
if (ttl < 86400) return `${Math.floor(ttl / 3600)}h`
|
||||
return `${Math.floor(ttl / 86400)}d`
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
const showEditDialog = () => {
|
||||
isEdit.value = true
|
||||
editForm.key = selectedKey.value
|
||||
editForm.type = currentKey.value?.type || 'string'
|
||||
editForm.ttl = currentKey.value?.ttl || -1
|
||||
editForm.value = keyValue.value
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveKey = async () => {
|
||||
ElMessage.success('保存成功')
|
||||
editDialogVisible.value = false
|
||||
fetchKeys()
|
||||
}
|
||||
|
||||
const deleteKey = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除键 "${selectedKey.value}" 吗?`, '警告', {
|
||||
type: 'warning',
|
||||
})
|
||||
ElMessage.success('删除成功')
|
||||
selectedKey.value = ''
|
||||
keyValue.value = ''
|
||||
fetchKeys()
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
|
||||
const refreshKeyValue = () => {
|
||||
if (selectedKey.value) {
|
||||
fetchKeyValue(selectedKey.value)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshKeys = () => {
|
||||
fetchKeys()
|
||||
ElMessage.success('刷新成功')
|
||||
}
|
||||
|
||||
const confirmFlushDB = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要清空 DB ${selectedDB.value} 的所有数据吗?此操作不可恢复!`, '危险操作', {
|
||||
type: 'error',
|
||||
confirmButtonText: '确认清空',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
})
|
||||
ElMessage.success('数据库已清空')
|
||||
fetchKeys()
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDatabases()
|
||||
fetchKeys()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.redis-keys-page {
|
||||
.sidebar-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.key-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
.key-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ttl-tag {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.key-title {
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.value-content {
|
||||
:deep(.el-table) {
|
||||
th {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-left: 12px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,355 @@
|
||||
<template>
|
||||
<div class="dictionary-page">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>数据字典</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dictionary-container">
|
||||
<!-- 左侧:数据源和数据库选择 -->
|
||||
<div class="dictionary-sidebar">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="数据源">
|
||||
<el-select
|
||||
v-model="selectedInstance"
|
||||
placeholder="选择数据源"
|
||||
style="width: 100%;"
|
||||
@change="handleInstanceChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="instance in mysqlInstances"
|
||||
:key="instance.id"
|
||||
:label="instance.name"
|
||||
:value="instance.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据库">
|
||||
<el-select
|
||||
v-model="selectedDatabase"
|
||||
placeholder="选择数据库"
|
||||
style="width: 100%;"
|
||||
:disabled="!selectedInstance"
|
||||
@change="handleDatabaseChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="db in databases"
|
||||
:key="db.name"
|
||||
:label="db.name"
|
||||
:value="db.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="table-list" v-if="tables.length > 0">
|
||||
<h4>表列表 ({{ tables.length }})</h4>
|
||||
<el-input
|
||||
v-model="tableSearch"
|
||||
placeholder="搜索表名"
|
||||
clearable
|
||||
size="small"
|
||||
style="margin-bottom: 12px;"
|
||||
/>
|
||||
<div class="table-items">
|
||||
<div
|
||||
v-for="table in filteredTables"
|
||||
:key="table.name"
|
||||
class="table-item"
|
||||
:class="{ active: selectedTable === table.name }"
|
||||
@click="selectTable(table.name)"
|
||||
>
|
||||
<el-icon><Document /></el-icon>
|
||||
<div class="table-info">
|
||||
<span class="table-name">{{ table.name }}</span>
|
||||
<span class="table-comment" v-if="table.comment">{{ table.comment }}</span>
|
||||
</div>
|
||||
<el-tag size="small" type="info">{{ formatNumber(table.rows) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:表详情 -->
|
||||
<div class="dictionary-main" v-if="selectedTable">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="表结构" name="columns">
|
||||
<el-descriptions :title="selectedTable" :column="3" border>
|
||||
<el-descriptions-item label="引擎">{{ tableInfo?.table?.engine }}</el-descriptions-item>
|
||||
<el-descriptions-item label="行数">{{ formatNumber(tableInfo?.table?.rows) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据大小">{{ formatSize(tableInfo?.table?.data_length) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注释" :span="3">{{ tableInfo?.table?.comment || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 class="section-title">字段信息</h4>
|
||||
<el-table :data="tableInfo?.columns" stripe border>
|
||||
<el-table-column prop="name" label="字段名" min-width="150" />
|
||||
<el-table-column prop="data_type" label="数据类型" width="150" />
|
||||
<el-table-column prop="nullable" label="可空" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.nullable ? 'info' : 'danger'" size="small">
|
||||
{{ row.nullable ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="default" label="默认值" width="120" />
|
||||
<el-table-column prop="comment" label="注释" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="键" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_primary" type="danger" size="small">PK</el-tag>
|
||||
<el-tag v-else-if="row.is_index" type="success" size="small">IDX</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="索引" name="indexes">
|
||||
<el-table :data="tableInfo?.indexes" stripe border>
|
||||
<el-table-column prop="name" label="索引名" min-width="200" />
|
||||
<el-table-column prop="columns" label="字段" min-width="300">
|
||||
<template #default="{ row }">
|
||||
{{ row.columns.join(', ') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unique" label="唯一" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.unique ? 'success' : 'info'" size="small">
|
||||
{{ row.unique ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="index_type" label="类型" width="120" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="DDL" name="ddl">
|
||||
<pre class="ddl-content">{{ generateDDL() }}</pre>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<div class="dictionary-empty" v-else>
|
||||
<el-empty description="请选择数据源和数据库查看表结构" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { instanceApi, dictionaryApi } from '@/api'
|
||||
import type { DatabaseInstance } from '@/types'
|
||||
|
||||
const mysqlInstances = ref<DatabaseInstance[]>([])
|
||||
const databases = ref<{ name: string }[]>([])
|
||||
const tables = ref<any[]>([])
|
||||
const selectedInstance = ref<number | null>(null)
|
||||
const selectedDatabase = ref('')
|
||||
const selectedTable = ref('')
|
||||
const tableSearch = ref('')
|
||||
const activeTab = ref('columns')
|
||||
const tableInfo = ref<any>(null)
|
||||
|
||||
const filteredTables = computed(() => {
|
||||
if (!tableSearch.value) return tables.value
|
||||
return tables.value.filter(t =>
|
||||
t.name.toLowerCase().includes(tableSearch.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const fetchInstances = async () => {
|
||||
const res: any = await instanceApi.list({ db_type: 'mysql' })
|
||||
mysqlInstances.value = res
|
||||
}
|
||||
|
||||
const handleInstanceChange = async (instanceId: number) => {
|
||||
selectedDatabase.value = ''
|
||||
selectedTable.value = ''
|
||||
tables.value = []
|
||||
const res: any = await dictionaryApi.databases(instanceId)
|
||||
databases.value = res.filter((db: any) => !['information_schema', 'mysql', 'performance_schema', 'sys'].includes(db.name))
|
||||
}
|
||||
|
||||
const handleDatabaseChange = async () => {
|
||||
selectedTable.value = ''
|
||||
if (selectedInstance.value && selectedDatabase.value) {
|
||||
const res: any = await dictionaryApi.tables(selectedInstance.value, selectedDatabase.value)
|
||||
tables.value = res
|
||||
}
|
||||
}
|
||||
|
||||
const selectTable = async (tableName: string) => {
|
||||
selectedTable.value = tableName
|
||||
if (selectedInstance.value && selectedDatabase.value) {
|
||||
const res: any = await dictionaryApi.tableInfo(selectedInstance.value, selectedDatabase.value, tableName)
|
||||
tableInfo.value = res
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumber = (num?: number) => {
|
||||
if (!num) return '0'
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const formatSize = (bytes?: number) => {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = bytes
|
||||
let unitIndex = 0
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
return size.toFixed(2) + ' ' + units[unitIndex]
|
||||
}
|
||||
|
||||
const generateDDL = () => {
|
||||
if (!tableInfo.value) return ''
|
||||
const table = tableInfo.value.table
|
||||
const columns = tableInfo.value.columns
|
||||
const indexes = tableInfo.value.indexes
|
||||
|
||||
let ddl = `CREATE TABLE \`${table.name}\` (\n`
|
||||
|
||||
columns.forEach((col: any, index: number) => {
|
||||
ddl += ` \`${col.name}\` ${col.data_type}`
|
||||
if (!col.nullable) ddl += ' NOT NULL'
|
||||
if (col.default !== null && col.default !== undefined) ddl += ` DEFAULT ${col.default}`
|
||||
if (col.comment) ddl += ` COMMENT '${col.comment}'`
|
||||
if (index < columns.length - 1 || indexes.length > 0) ddl += ','
|
||||
ddl += '\n'
|
||||
})
|
||||
|
||||
indexes.forEach((idx: any, index: number) => {
|
||||
if (idx.name === 'PRIMARY') {
|
||||
ddl += ` PRIMARY KEY (${idx.columns.map((c: string) => `\`${c}\``).join(', ')})`
|
||||
} else if (idx.unique) {
|
||||
ddl += ` UNIQUE KEY \`${idx.name}\` (${idx.columns.map((c: string) => `\`${c}\``).join(', ')})`
|
||||
} else {
|
||||
ddl += ` KEY \`${idx.name}\` (${idx.columns.map((c: string) => `\`${c}\``).join(', ')})`
|
||||
}
|
||||
if (index < indexes.length - 1) ddl += ','
|
||||
ddl += '\n'
|
||||
})
|
||||
|
||||
ddl += `) ENGINE=${table.engine || 'InnoDB'} DEFAULT CHARSET=utf8mb4`
|
||||
if (table.comment) ddl += ` COMMENT='${table.comment}'`
|
||||
ddl += ';'
|
||||
|
||||
return ddl
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchInstances()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dictionary-page {
|
||||
.card-header {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dictionary-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 600px;
|
||||
|
||||
.dictionary-sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #ebeef5;
|
||||
padding-right: 20px;
|
||||
|
||||
.table-list {
|
||||
margin-top: 20px;
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.table-items {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
.table-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
.table-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.table-name {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.table-comment {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dictionary-main {
|
||||
flex: 1;
|
||||
|
||||
.section-title {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ddl-content {
|
||||
background-color: #f5f7fa;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.dictionary-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<div class="instances-page">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索数据源"
|
||||
clearable
|
||||
style="width: 300px;"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select v-model="filterType" placeholder="类型" clearable style="width: 120px;">
|
||||
<el-option label="MySQL" value="mysql" />
|
||||
<el-option label="Redis" value="redis" />
|
||||
<el-option label="PostgreSQL" value="postgresql" />
|
||||
<el-option label="MongoDB" value="mongodb" />
|
||||
</el-select>
|
||||
<el-select v-model="filterEnv" placeholder="环境" clearable style="width: 120px;">
|
||||
<el-option label="开发" value="dev" />
|
||||
<el-option label="测试" value="test" />
|
||||
<el-option label="预发布" value="staging" />
|
||||
<el-option label="生产" value="prod" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" @click="showCreateDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加数据源
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="filteredInstances" stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="instance-name">
|
||||
<el-icon size="18" :color="getDbTypeColor(row.db_type)">
|
||||
<component :is="getDbTypeIcon(row.db_type)" />
|
||||
</el-icon>
|
||||
<span>{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="db_type" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getDbTypeTag(row.db_type)">
|
||||
{{ row.db_type.toUpperCase() }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="host" label="主机" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.host }}:{{ row.port }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="database" label="数据库" width="120" />
|
||||
<el-table-column prop="environment" label="环境" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getEnvType(row.environment)">
|
||||
{{ getEnvLabel(row.environment) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'">
|
||||
{{ row.status === 'active' ? '正常' : '异常' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="testConnection(row.id)">
|
||||
测试
|
||||
</el-button>
|
||||
<el-button type="primary" link @click="editInstance(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="deleteInstance(row.id)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 创建/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑数据源' : '添加数据源'"
|
||||
width="600px"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入数据源名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="db_type">
|
||||
<el-select v-model="form.db_type" placeholder="选择数据库类型" style="width: 100%;">
|
||||
<el-option label="MySQL" value="mysql" />
|
||||
<el-option label="Redis" value="redis" />
|
||||
<el-option label="PostgreSQL" value="postgresql" />
|
||||
<el-option label="MongoDB" value="mongodb" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="主机" prop="host">
|
||||
<el-input v-model="form.host" placeholder="主机地址" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="端口" prop="port">
|
||||
<el-input-number v-model="form.port" :min="1" :max="65535" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="数据库" prop="database">
|
||||
<el-input v-model="form.database" placeholder="数据库名称(可选)" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="用户名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" show-password />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="环境" prop="environment">
|
||||
<el-radio-group v-model="form.environment">
|
||||
<el-radio-button label="dev">开发</el-radio-button>
|
||||
<el-radio-button label="test">测试</el-radio-button>
|
||||
<el-radio-button label="staging">预发布</el-radio-button>
|
||||
<el-radio-button label="prod">生产</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="数据源描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.db_type === 'redis'" label="部署模式">
|
||||
<el-select v-model="form.redis_mode" placeholder="选择部署模式" style="width: 100%;">
|
||||
<el-option label="单机模式" value="standalone" />
|
||||
<el-option label="哨兵模式" value="sentinel" />
|
||||
<el-option label="集群模式" value="cluster" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { instanceApi } from '@/api'
|
||||
import type { DatabaseInstance } from '@/types'
|
||||
|
||||
const loading = ref(false)
|
||||
const instances = ref<DatabaseInstance[]>([])
|
||||
const searchQuery = ref('')
|
||||
const filterType = ref('')
|
||||
const filterEnv = ref('')
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
db_type: 'mysql',
|
||||
host: '',
|
||||
port: 3306,
|
||||
database: '',
|
||||
username: '',
|
||||
password: '',
|
||||
environment: 'dev',
|
||||
description: '',
|
||||
redis_mode: 'standalone',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
db_type: [{ required: true, message: '请选择类型', trigger: 'change' }],
|
||||
host: [{ required: true, message: '请输入主机地址', trigger: 'blur' }],
|
||||
port: [{ required: true, message: '请输入端口', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const filteredInstances = computed(() => {
|
||||
let result = instances.value
|
||||
if (searchQuery.value) {
|
||||
result = result.filter(i => i.name.toLowerCase().includes(searchQuery.value.toLowerCase()))
|
||||
}
|
||||
if (filterType.value) {
|
||||
result = result.filter(i => i.db_type === filterType.value)
|
||||
}
|
||||
if (filterEnv.value) {
|
||||
result = result.filter(i => i.environment === filterEnv.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const fetchInstances = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await instanceApi.list()
|
||||
instances.value = res
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getDbTypeColor = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
mysql: '#4479A1',
|
||||
redis: '#DC382D',
|
||||
postgresql: '#336791',
|
||||
mongodb: '#47A248',
|
||||
}
|
||||
return map[type] || '#909399'
|
||||
}
|
||||
|
||||
const getDbTypeIcon = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
mysql: 'Coin',
|
||||
redis: 'Lightning',
|
||||
postgresql: 'DataAnalysis',
|
||||
mongodb: 'Folder',
|
||||
}
|
||||
return map[type] || 'DataLine'
|
||||
}
|
||||
|
||||
const getDbTypeTag = (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
mysql: 'primary',
|
||||
redis: 'danger',
|
||||
postgresql: 'info',
|
||||
mongodb: 'success',
|
||||
}
|
||||
return map[type] || ''
|
||||
}
|
||||
|
||||
const getEnvType = (env: string) => {
|
||||
const map: Record<string, string> = {
|
||||
dev: 'info',
|
||||
test: 'warning',
|
||||
staging: '',
|
||||
prod: 'danger',
|
||||
}
|
||||
return map[env] || ''
|
||||
}
|
||||
|
||||
const getEnvLabel = (env: string) => {
|
||||
const map: Record<string, string> = {
|
||||
dev: '开发',
|
||||
test: '测试',
|
||||
staging: '预发布',
|
||||
prod: '生产',
|
||||
}
|
||||
return map[env] || env
|
||||
}
|
||||
|
||||
const showCreateDialog = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const editInstance = (row: DatabaseInstance) => {
|
||||
isEdit.value = true
|
||||
Object.assign(form, row)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = 0
|
||||
form.name = ''
|
||||
form.db_type = 'mysql'
|
||||
form.host = ''
|
||||
form.port = 3306
|
||||
form.database = ''
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.environment = 'dev'
|
||||
form.description = ''
|
||||
form.redis_mode = 'standalone'
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await instanceApi.update(form.id, form)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await instanceApi.create(form)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchInstances()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async (id: number) => {
|
||||
try {
|
||||
const res: any = await instanceApi.test(id)
|
||||
if (res.success) {
|
||||
ElMessage.success(`连接成功,延迟: ${res.latency_ms}ms`)
|
||||
} else {
|
||||
ElMessage.error(res.message)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('连接测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteInstance = async (id: number) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该数据源吗?', '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await instanceApi.delete(id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchInstances()
|
||||
} catch (error) {
|
||||
// 取消删除
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchInstances()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.instances-page {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.instance-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="query-page">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>SQL 查询</span>
|
||||
<el-alert
|
||||
title="只读查询:仅支持 SELECT/SHOW 语句"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="query-container">
|
||||
<!-- 左侧:数据源选择 -->
|
||||
<div class="query-sidebar">
|
||||
<el-form :model="queryForm" label-position="top">
|
||||
<el-form-item label="数据源">
|
||||
<el-select
|
||||
v-model="queryForm.instance_id"
|
||||
placeholder="选择数据源"
|
||||
style="width: 100%;"
|
||||
@change="handleInstanceChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="instance in mysqlInstances"
|
||||
:key="instance.id"
|
||||
:label="instance.name"
|
||||
:value="instance.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据库">
|
||||
<el-select
|
||||
v-model="queryForm.database"
|
||||
placeholder="选择数据库"
|
||||
style="width: 100%;"
|
||||
:disabled="!queryForm.instance_id"
|
||||
>
|
||||
<el-option
|
||||
v-for="db in databases"
|
||||
:key="db.name"
|
||||
:label="db.name"
|
||||
:value="db.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="table-list" v-if="tables.length > 0">
|
||||
<h4>表列表</h4>
|
||||
<el-tree
|
||||
:data="tableTreeData"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@node-click="handleTableClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:SQL 编辑器 -->
|
||||
<div class="query-main">
|
||||
<div class="editor-section">
|
||||
<div class="editor-toolbar">
|
||||
<el-button type="primary" :loading="executing" @click="executeQuery">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
执行 (Ctrl+Enter)
|
||||
</el-button>
|
||||
<el-button @click="formatSQL">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
格式化
|
||||
</el-button>
|
||||
<el-button @click="clearSQL">
|
||||
<el-icon><Delete /></el-icon>
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="sql-editor">
|
||||
<codemirror
|
||||
v-model="queryForm.sql"
|
||||
placeholder="请输入 SQL 查询语句..."
|
||||
:style="{ height: '200px' }"
|
||||
:autofocus="true"
|
||||
:indent-with-tab="true"
|
||||
:tab-size="2"
|
||||
:extensions="extensions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查询结果 -->
|
||||
<div v-if="queryResult" class="result-section">
|
||||
<div class="result-header">
|
||||
<span>查询结果</span>
|
||||
<div class="result-info">
|
||||
<el-tag size="small">{{ queryResult.total_rows }} 行</el-tag>
|
||||
<el-tag size="small" type="success">{{ queryResult.execution_time }}ms</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-table
|
||||
:data="queryResult.rows"
|
||||
stripe
|
||||
border
|
||||
max-height="400"
|
||||
v-loading="executing"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="(col, index) in queryResult.columns"
|
||||
:key="index"
|
||||
:prop="index.toString()"
|
||||
:label="col"
|
||||
min-width="120"
|
||||
/>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Codemirror } from 'vue-codemirror'
|
||||
import { sql } from '@codemirror/lang-sql'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { instanceApi, queryApi, dictionaryApi } from '@/api'
|
||||
import type { DatabaseInstance, SQLQueryResult } from '@/types'
|
||||
|
||||
const mysqlInstances = ref<DatabaseInstance[]>([])
|
||||
const databases = ref<{ name: string }[]>([])
|
||||
const tables = ref<{ name: string; comment?: string }[]>([])
|
||||
const executing = ref(false)
|
||||
const queryResult = ref<SQLQueryResult | null>(null)
|
||||
|
||||
const queryForm = reactive({
|
||||
instance_id: null as number | null,
|
||||
database: '',
|
||||
sql: 'SELECT * FROM users LIMIT 10;',
|
||||
})
|
||||
|
||||
const extensions = [sql(), oneDark]
|
||||
|
||||
const tableTreeData = computed(() => {
|
||||
return tables.value.map(t => ({
|
||||
name: t.name,
|
||||
children: [],
|
||||
}))
|
||||
})
|
||||
|
||||
const fetchInstances = async () => {
|
||||
const res: any = await instanceApi.list({ db_type: 'mysql' })
|
||||
mysqlInstances.value = res
|
||||
}
|
||||
|
||||
const handleInstanceChange = async (instanceId: number) => {
|
||||
queryForm.database = ''
|
||||
tables.value = []
|
||||
const res: any = await dictionaryApi.databases(instanceId)
|
||||
databases.value = res.filter((db: any) => !['information_schema', 'mysql', 'performance_schema', 'sys'].includes(db.name))
|
||||
}
|
||||
|
||||
const fetchTables = async () => {
|
||||
if (!queryForm.instance_id || !queryForm.database) return
|
||||
const res: any = await dictionaryApi.tables(queryForm.instance_id, queryForm.database)
|
||||
tables.value = res
|
||||
}
|
||||
|
||||
const handleTableClick = (data: any) => {
|
||||
queryForm.sql = `SELECT * FROM ${data.name} LIMIT 10;`
|
||||
}
|
||||
|
||||
const executeQuery = async () => {
|
||||
if (!queryForm.instance_id) {
|
||||
ElMessage.warning('请选择数据源')
|
||||
return
|
||||
}
|
||||
if (!queryForm.sql.trim()) {
|
||||
ElMessage.warning('请输入 SQL 语句')
|
||||
return
|
||||
}
|
||||
|
||||
executing.value = true
|
||||
try {
|
||||
const res: any = await queryApi.execute({
|
||||
instance_id: queryForm.instance_id,
|
||||
database: queryForm.database,
|
||||
sql: queryForm.sql,
|
||||
})
|
||||
queryResult.value = res
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
executing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatSQL = () => {
|
||||
// 简单的格式化
|
||||
let sql = queryForm.sql
|
||||
sql = sql.replace(/\s+/g, ' ')
|
||||
sql = sql.replace(/\s*,\s*/g, ', ')
|
||||
sql = sql.replace(/\s*;\s*/g, ';')
|
||||
queryForm.sql = sql
|
||||
ElMessage.success('格式化完成')
|
||||
}
|
||||
|
||||
const clearSQL = () => {
|
||||
queryForm.sql = ''
|
||||
queryResult.value = null
|
||||
}
|
||||
|
||||
// 监听数据库变化
|
||||
watch(() => queryForm.database, () => {
|
||||
if (queryForm.database) {
|
||||
fetchTables()
|
||||
}
|
||||
})
|
||||
|
||||
import { watch } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
fetchInstances()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.query-page {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.query-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 600px;
|
||||
|
||||
.query-sidebar {
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #ebeef5;
|
||||
padding-right: 20px;
|
||||
|
||||
.table-list {
|
||||
margin-top: 20px;
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
.editor-section {
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sql-editor {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.result-section {
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
.result-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,382 @@
|
||||
<template>
|
||||
<div class="redis-page">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<el-select
|
||||
v-model="selectedInstance"
|
||||
placeholder="选择 Redis 实例"
|
||||
style="width: 300px;"
|
||||
@change="handleInstanceChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="instance in redisInstances"
|
||||
:key="instance.id"
|
||||
:label="instance.name"
|
||||
:value="instance.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" @click="fetchInfo" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="redisInfo" class="redis-content">
|
||||
<!-- 基本信息 -->
|
||||
<el-row :gutter="20" class="info-row">
|
||||
<el-col :span="6">
|
||||
<div class="info-card">
|
||||
<div class="info-icon" style="background-color: #DC382D;">
|
||||
<el-icon size="24" color="#fff"><InfoFilled /></el-icon>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<div class="info-value">{{ redisInfo.version }}</div>
|
||||
<div class="info-label">Redis 版本</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="info-card">
|
||||
<div class="info-icon" style="background-color: #67C23A;">
|
||||
<el-icon size="24" color="#fff"><Timer /></el-icon>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<div class="info-value">{{ formatUptime(redisInfo.uptime_in_seconds) }}</div>
|
||||
<div class="info-label">运行时间</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="info-card">
|
||||
<div class="info-icon" style="background-color: #409EFF;">
|
||||
<el-icon size="24" color="#fff"><User /></el-icon>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<div class="info-value">{{ redisInfo.connected_clients }}</div>
|
||||
<div class="info-label">连接数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="info-card">
|
||||
<div class="info-icon" style="background-color: #E6A23C;">
|
||||
<el-icon size="24" color="#fff"><Key /></el-icon>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<div class="info-value">{{ formatNumber(redisInfo.total_keys) }}</div>
|
||||
<div class="info-label">总键数</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 内存和命中率 -->
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>内存使用</span>
|
||||
</template>
|
||||
<div class="memory-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">已用内存</span>
|
||||
<span class="stat-value">{{ redisInfo.used_memory_human }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">峰值内存</span>
|
||||
<span class="stat-value">{{ redisInfo.used_memory_peak_human }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>命中率</span>
|
||||
</template>
|
||||
<div class="hit-rate">
|
||||
<el-progress
|
||||
type="dashboard"
|
||||
:percentage="redisInfo.hit_rate"
|
||||
:color="getHitRateColor(redisInfo.hit_rate)"
|
||||
/>
|
||||
<div class="hit-stats">
|
||||
<div class="hit-item">
|
||||
<span class="hit-label">命中</span>
|
||||
<span class="hit-value">{{ formatNumber(redisInfo.keyspace_hits) }}</span>
|
||||
</div>
|
||||
<div class="hit-item">
|
||||
<span class="hit-label">未命中</span>
|
||||
<span class="hit-value">{{ formatNumber(redisInfo.keyspace_misses) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 数据库信息 -->
|
||||
<el-card shadow="never" class="db-card">
|
||||
<template #header>
|
||||
<span>数据库信息</span>
|
||||
</template>
|
||||
<el-table :data="redisInfo.databases" stripe>
|
||||
<el-table-column prop="db" label="数据库" width="100" />
|
||||
<el-table-column prop="keys" label="键数" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.keys) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expires" label="过期键" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ formatNumber(row.expires) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="viewDBKeys(row.db)">
|
||||
查看键
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- Redis 命令 -->
|
||||
<el-card shadow="never" class="command-card">
|
||||
<template #header>
|
||||
<span>命令执行(只读)</span>
|
||||
</template>
|
||||
<div class="command-section">
|
||||
<el-input
|
||||
v-model="command"
|
||||
placeholder="输入 Redis 命令,如: INFO, DBSIZE, GET key"
|
||||
@keyup.enter="executeCommand"
|
||||
>
|
||||
<template #append>
|
||||
<el-button type="primary" @click="executeCommand" :loading="executing">
|
||||
执行
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<div v-if="commandResult" class="command-result">
|
||||
<pre>{{ formatCommandResult(commandResult) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="请选择 Redis 实例" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { instanceApi, redisApi } from '@/api'
|
||||
import type { DatabaseInstance, RedisInfo } from '@/types'
|
||||
|
||||
const redisInstances = ref<DatabaseInstance[]>([])
|
||||
const selectedInstance = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
const redisInfo = ref<RedisInfo | null>(null)
|
||||
const command = ref('')
|
||||
const executing = ref(false)
|
||||
const commandResult = ref<any>(null)
|
||||
|
||||
const fetchInstances = async () => {
|
||||
const res: any = await instanceApi.list({ db_type: 'redis' })
|
||||
redisInstances.value = res
|
||||
}
|
||||
|
||||
const handleInstanceChange = () => {
|
||||
fetchInfo()
|
||||
}
|
||||
|
||||
const fetchInfo = async () => {
|
||||
if (!selectedInstance.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await redisApi.info(selectedInstance.value)
|
||||
redisInfo.value = res
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const executeCommand = async () => {
|
||||
if (!selectedInstance.value || !command.value.trim()) return
|
||||
executing.value = true
|
||||
try {
|
||||
const res: any = await redisApi.command(selectedInstance.value, {
|
||||
command: command.value,
|
||||
})
|
||||
commandResult.value = res.result
|
||||
} catch (error) {
|
||||
commandResult.value = null
|
||||
} finally {
|
||||
executing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
if (days > 0) return `${days}天${hours}小时`
|
||||
return `${hours}小时`
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const getHitRateColor = (rate: number) => {
|
||||
if (rate >= 90) return '#67C23A'
|
||||
if (rate >= 70) return '#E6A23C'
|
||||
return '#F56C6C'
|
||||
}
|
||||
|
||||
const formatCommandResult = (result: any) => {
|
||||
if (typeof result === 'object') {
|
||||
return JSON.stringify(result, null, 2)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const viewDBKeys = (db: number) => {
|
||||
command.value = `SELECT ${db}\nKEYS *`
|
||||
ElMessage.info(`已切换到数据库 ${db},请执行 KEYS 命令查看键`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchInstances()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.redis-page {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.redis-content {
|
||||
.info-row {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.info-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
|
||||
.info-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
.info-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.memory-stats {
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hit-rate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
|
||||
.hit-stats {
|
||||
.hit-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 8px 0;
|
||||
|
||||
.hit-label {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.hit-value {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.db-card,
|
||||
.command-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.command-section {
|
||||
.command-result {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>系统设置</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="基本设置" name="basic">
|
||||
<el-form :model="basicSettings" label-width="200px" class="settings-form">
|
||||
<el-form-item label="SQL 审核">
|
||||
<el-switch v-model="basicSettings.audit_enabled" />
|
||||
<span class="form-hint">启用 SQL 自动审核功能</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="测试环境自动执行">
|
||||
<el-switch v-model="basicSettings.auto_execute_test" />
|
||||
<span class="form-hint">测试环境工单审核通过后自动执行</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="需要 DBA 审批">
|
||||
<el-switch v-model="basicSettings.require_dba_approval" />
|
||||
<span class="form-hint">生产环境工单需要 DBA 审批</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大 SQL 长度">
|
||||
<el-input-number v-model="basicSettings.max_sql_length" :min="1000" :max="50000" :step="1000" />
|
||||
<span class="form-hint">字符数</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="查询超时时间">
|
||||
<el-input-number v-model="basicSettings.query_timeout" :min="5" :max="300" :step="5" />
|
||||
<span class="form-hint">秒</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveBasicSettings">保存设置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="通知设置" name="notification">
|
||||
<el-form :model="notificationSettings" label-width="200px" class="settings-form">
|
||||
<el-form-item label="启用邮件通知">
|
||||
<el-switch v-model="notificationSettings.email_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SMTP 服务器">
|
||||
<el-input v-model="notificationSettings.smtp_host" placeholder="smtp.example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SMTP 端口">
|
||||
<el-input-number v-model="notificationSettings.smtp_port" :min="1" :max="65535" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发件人邮箱">
|
||||
<el-input v-model="notificationSettings.smtp_user" placeholder="noreply@example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发件人密码">
|
||||
<el-input v-model="notificationSettings.smtp_password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用钉钉通知">
|
||||
<el-switch v-model="notificationSettings.dingtalk_enabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="钉钉 Webhook">
|
||||
<el-input v-model="notificationSettings.dingtalk_webhook" placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxx" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveNotificationSettings">保存设置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="安全设置" name="security">
|
||||
<el-form :model="securitySettings" label-width="200px" class="settings-form">
|
||||
<el-form-item label="登录失败锁定">
|
||||
<el-switch v-model="securitySettings.login_lock_enabled" />
|
||||
<span class="form-hint">连续登录失败多次后锁定账号</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大登录失败次数">
|
||||
<el-input-number v-model="securitySettings.max_login_attempts" :min="3" :max="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="锁定时间">
|
||||
<el-input-number v-model="securitySettings.lock_duration" :min="5" :max="60" />
|
||||
<span class="form-hint">分钟</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码最小长度">
|
||||
<el-input-number v-model="securitySettings.min_password_length" :min="6" :max="32" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码复杂度要求">
|
||||
<el-checkbox-group v-model="securitySettings.password_complexity">
|
||||
<el-checkbox label="uppercase">大写字母</el-checkbox>
|
||||
<el-checkbox label="lowercase">小写字母</el-checkbox>
|
||||
<el-checkbox label="number">数字</el-checkbox>
|
||||
<el-checkbox label="special">特殊字符</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="会话超时时间">
|
||||
<el-input-number v-model="securitySettings.session_timeout" :min="10" :max="480" />
|
||||
<span class="form-hint">分钟</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveSecuritySettings">保存设置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="关于" name="about">
|
||||
<div class="about-section">
|
||||
<div class="about-logo">
|
||||
<el-icon size="64" color="#409EFF"><DataLine /></el-icon>
|
||||
<h2>数据审核平台</h2>
|
||||
<p>Database Change Management & SQL Audit Platform</p>
|
||||
</div>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="版本">v1.0.0</el-descriptions-item>
|
||||
<el-descriptions-item label="后端技术">FastAPI + Python 3.11</el-descriptions-item>
|
||||
<el-descriptions-item label="前端技术">Vue 3 + TypeScript + Element Plus</el-descriptions-item>
|
||||
<el-descriptions-item label="支持数据库">MySQL, Redis, PostgreSQL, MongoDB</el-descriptions-item>
|
||||
<el-descriptions-item label="开源协议">MIT License</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { configApi } from '@/api'
|
||||
|
||||
const activeTab = ref('basic')
|
||||
const loading = ref(false)
|
||||
|
||||
const basicSettings = ref({
|
||||
audit_enabled: true,
|
||||
auto_execute_test: false,
|
||||
require_dba_approval: true,
|
||||
max_sql_length: 10000,
|
||||
query_timeout: 30,
|
||||
})
|
||||
|
||||
const notificationSettings = ref({
|
||||
email_enabled: false,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_user: '',
|
||||
smtp_password: '',
|
||||
dingtalk_enabled: false,
|
||||
dingtalk_webhook: '',
|
||||
})
|
||||
|
||||
const securitySettings = ref({
|
||||
login_lock_enabled: true,
|
||||
max_login_attempts: 5,
|
||||
lock_duration: 30,
|
||||
min_password_length: 8,
|
||||
password_complexity: ['uppercase', 'lowercase', 'number'],
|
||||
session_timeout: 120,
|
||||
})
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await configApi.list()
|
||||
res.forEach((config: any) => {
|
||||
if (config.key in basicSettings.value) {
|
||||
basicSettings.value[config.key as keyof typeof basicSettings.value] = config.value
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveBasicSettings = async () => {
|
||||
try {
|
||||
for (const [key, value] of Object.entries(basicSettings.value)) {
|
||||
await configApi.update(key, value)
|
||||
}
|
||||
ElMessage.success('设置已保存')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveNotificationSettings = () => {
|
||||
ElMessage.success('通知设置已保存')
|
||||
}
|
||||
|
||||
const saveSecuritySettings = () => {
|
||||
ElMessage.success('安全设置已保存')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfigs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.settings-page {
|
||||
.card-header {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
max-width: 600px;
|
||||
margin-top: 20px;
|
||||
|
||||
.form-hint {
|
||||
margin-left: 12px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.about-section {
|
||||
.about-logo {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
|
||||
h2 {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 24px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索用户"
|
||||
clearable
|
||||
style="width: 300px;"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select v-model="filterRole" placeholder="角色" clearable style="width: 120px;">
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="DBA" value="dba" />
|
||||
<el-option label="开发者" value="developer" />
|
||||
<el-option label="审计员" value="auditor" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加用户
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="filteredUsers" stripe v-loading="loading">
|
||||
<el-table-column label="用户" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="40" :src="row.avatar" />
|
||||
<div class="user-detail">
|
||||
<div class="username">{{ row.username }}</div>
|
||||
<div class="email">{{ row.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRoleType(row.role)">
|
||||
{{ getRoleLabel(row.role) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="department" label="部门" width="150" />
|
||||
<el-table-column prop="phone" label="电话" width="150" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_active" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link>编辑</el-button>
|
||||
<el-button type="danger" link>禁用</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { userApi } from '@/api'
|
||||
import type { User } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const loading = ref(false)
|
||||
const users = ref<User[]>([])
|
||||
const searchQuery = ref('')
|
||||
const filterRole = ref('')
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
let result = users.value
|
||||
if (searchQuery.value) {
|
||||
result = result.filter(u =>
|
||||
u.username.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
)
|
||||
}
|
||||
if (filterRole.value) {
|
||||
result = result.filter(u => u.role === filterRole.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await userApi.list()
|
||||
users.value = res
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleType = (role: string) => {
|
||||
const map: Record<string, string> = {
|
||||
admin: 'danger',
|
||||
dba: 'warning',
|
||||
developer: 'success',
|
||||
auditor: 'info',
|
||||
}
|
||||
return map[role] || 'info'
|
||||
}
|
||||
|
||||
const getRoleLabel = (role: string) => {
|
||||
const map: Record<string, string> = {
|
||||
admin: '管理员',
|
||||
dba: 'DBA',
|
||||
developer: '开发者',
|
||||
auditor: '审计员',
|
||||
}
|
||||
return map[role] || role
|
||||
}
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.users-page {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.user-detail {
|
||||
.username {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,432 @@
|
||||
<template>
|
||||
<div class="create-workflow-page">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>创建 SQL 工单</span>
|
||||
<el-button @click="$router.back()">返回</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-steps :active="currentStep" finish-status="success" class="steps">
|
||||
<el-step title="填写信息" />
|
||||
<el-step title="SQL 审核" />
|
||||
<el-step title="提交工单" />
|
||||
</el-steps>
|
||||
|
||||
<!-- 步骤 1: 填写信息 -->
|
||||
<div v-if="currentStep === 0" class="step-content">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
class="workflow-form"
|
||||
>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入工单标题" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="数据源" prop="instance_id">
|
||||
<el-select
|
||||
v-model="form.instance_id"
|
||||
placeholder="选择数据源"
|
||||
style="width: 100%;"
|
||||
@change="handleInstanceChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="instance in instances"
|
||||
:key="instance.id"
|
||||
:label="instance.name"
|
||||
:value="instance.id"
|
||||
>
|
||||
<span>{{ instance.name }}</span>
|
||||
<el-tag size="small" :type="getEnvType(instance.environment)" style="margin-left: 8px;">
|
||||
{{ getEnvLabel(instance.environment) }}
|
||||
</el-tag>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="数据库" prop="database">
|
||||
<el-select v-model="form.database" placeholder="选择数据库" style="width: 100%;">
|
||||
<el-option
|
||||
v-for="db in databases"
|
||||
:key="db.name"
|
||||
:label="db.name"
|
||||
:value="db.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="工单类型" prop="workflow_type">
|
||||
<el-radio-group v-model="form.workflow_type">
|
||||
<el-radio-button label="ddl">DDL (结构变更)</el-radio-button>
|
||||
<el-radio-button label="dml">DML (数据变更)</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="SQL 语句" prop="sql_content">
|
||||
<div class="sql-editor-container">
|
||||
<codemirror
|
||||
v-model="form.sql_content"
|
||||
placeholder="请输入 SQL 语句..."
|
||||
:style="{ height: '300px' }"
|
||||
:autofocus="true"
|
||||
:indent-with-tab="true"
|
||||
:tab-size="2"
|
||||
:extensions="extensions"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入工单描述(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="step-actions">
|
||||
<el-button type="primary" @click="handleAudit">
|
||||
<el-icon><Check /></el-icon>
|
||||
审核 SQL
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 2: SQL 审核结果 -->
|
||||
<div v-if="currentStep === 1" class="step-content">
|
||||
<div class="audit-summary">
|
||||
<el-alert
|
||||
:title="`审核完成,风险评分: ${auditResult.risk_score},风险等级: ${getRiskLabel(auditResult.risk_level)}`"
|
||||
:type="getRiskType(auditResult.risk_level)"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="auditResult.sql_results.length > 0" class="audit-results">
|
||||
<h4>审核结果</h4>
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="(result, index) in auditResult.sql_results"
|
||||
:key="index"
|
||||
:type="getRiskType(result.risk_level)"
|
||||
:icon="getRiskIcon(result.risk_level)"
|
||||
>
|
||||
<div class="audit-item">
|
||||
<div class="audit-title">
|
||||
<el-tag :type="getRiskType(result.risk_level)" size="small">
|
||||
{{ getRiskLabel(result.risk_level) }}
|
||||
</el-tag>
|
||||
<span class="rule-name">{{ result.rule_name }}</span>
|
||||
</div>
|
||||
<div class="audit-message">{{ result.message }}</div>
|
||||
<div v-if="result.suggestion" class="audit-suggestion">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
建议: {{ result.suggestion }}
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
|
||||
<div v-else class="audit-success">
|
||||
<el-result icon="success" title="SQL 审核通过" sub-title="未发现风险问题" />
|
||||
</div>
|
||||
|
||||
<div class="step-actions">
|
||||
<el-button @click="currentStep = 0">上一步</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">提交工单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 3: 提交成功 -->
|
||||
<div v-if="currentStep === 2" class="step-content">
|
||||
<el-result
|
||||
icon="success"
|
||||
title="工单创建成功"
|
||||
:sub-title="`工单编号: ${createdWorkflowId}`"
|
||||
>
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="viewWorkflow">查看工单</el-button>
|
||||
<el-button @click="createAnother">创建新工单</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Codemirror } from 'vue-codemirror'
|
||||
import { sql } from '@codemirror/lang-sql'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { instanceApi, workflowApi, dictionaryApi } from '@/api'
|
||||
import type { DatabaseInstance, SQLAuditResult } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const currentStep = ref(0)
|
||||
const formRef = ref()
|
||||
const instances = ref<DatabaseInstance[]>([])
|
||||
const databases = ref<{ name: string }[]>([])
|
||||
const auditResult = ref<{
|
||||
risk_score: number
|
||||
risk_level: string
|
||||
sql_results: SQLAuditResult[]
|
||||
}>({
|
||||
risk_score: 0,
|
||||
risk_level: 'safe',
|
||||
sql_results: [],
|
||||
})
|
||||
const createdWorkflowId = ref(0)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
instance_id: null as number | null,
|
||||
database: '',
|
||||
workflow_type: 'ddl',
|
||||
sql_content: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
|
||||
instance_id: [{ required: true, message: '请选择数据源', trigger: 'change' }],
|
||||
workflow_type: [{ required: true, message: '请选择工单类型', trigger: 'change' }],
|
||||
sql_content: [{ required: true, message: '请输入 SQL 语句', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const extensions = [sql(), oneDark]
|
||||
|
||||
const fetchInstances = async () => {
|
||||
const res: any = await instanceApi.list()
|
||||
instances.value = res
|
||||
}
|
||||
|
||||
const handleInstanceChange = async (instanceId: number) => {
|
||||
form.database = ''
|
||||
const instance = instances.value.find(i => i.id === instanceId)
|
||||
if (instance && instance.db_type === 'mysql') {
|
||||
const res: any = await dictionaryApi.databases(instanceId)
|
||||
databases.value = res
|
||||
}
|
||||
}
|
||||
|
||||
const getEnvType = (env: string) => {
|
||||
const map: Record<string, string> = {
|
||||
dev: 'info',
|
||||
test: 'warning',
|
||||
staging: '',
|
||||
prod: 'danger',
|
||||
}
|
||||
return map[env] || ''
|
||||
}
|
||||
|
||||
const getEnvLabel = (env: string) => {
|
||||
const map: Record<string, string> = {
|
||||
dev: '开发',
|
||||
test: '测试',
|
||||
staging: '预发布',
|
||||
prod: '生产',
|
||||
}
|
||||
return map[env] || env
|
||||
}
|
||||
|
||||
const getRiskType = (level: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: 'danger',
|
||||
medium: 'warning',
|
||||
low: 'info',
|
||||
safe: 'success',
|
||||
}
|
||||
return map[level] || 'info'
|
||||
}
|
||||
|
||||
const getRiskLabel = (level: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
low: '低风险',
|
||||
safe: '安全',
|
||||
}
|
||||
return map[level] || level
|
||||
}
|
||||
|
||||
const getRiskIcon = (level: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: 'CircleCloseFilled',
|
||||
medium: 'WarningFilled',
|
||||
low: 'InfoFilled',
|
||||
safe: 'CircleCheckFilled',
|
||||
}
|
||||
return map[level] || 'InfoFilled'
|
||||
}
|
||||
|
||||
const handleAudit = async () => {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
// 模拟审核结果
|
||||
const sqlUpper = form.sql_content.toUpperCase()
|
||||
const results: SQLAuditResult[] = []
|
||||
let riskScore = 0
|
||||
|
||||
if (sqlUpper.includes('DELETE') && !sqlUpper.includes('WHERE')) {
|
||||
results.push({
|
||||
rule_id: 1,
|
||||
rule_name: '禁止DELETE无WHERE条件',
|
||||
risk_level: 'high',
|
||||
message: 'DELETE语句缺少WHERE条件,将删除全表数据',
|
||||
suggestion: '请添加WHERE条件限制删除范围',
|
||||
})
|
||||
riskScore += 50
|
||||
}
|
||||
|
||||
if (sqlUpper.includes('UPDATE') && !sqlUpper.includes('WHERE')) {
|
||||
results.push({
|
||||
rule_id: 2,
|
||||
rule_name: '禁止UPDATE无WHERE条件',
|
||||
risk_level: 'high',
|
||||
message: 'UPDATE语句缺少WHERE条件,将更新全表数据',
|
||||
suggestion: '请添加WHERE条件限制更新范围',
|
||||
})
|
||||
riskScore += 50
|
||||
}
|
||||
|
||||
if (sqlUpper.includes('SELECT *')) {
|
||||
results.push({
|
||||
rule_id: 6,
|
||||
rule_name: 'SELECT * 建议指定列',
|
||||
risk_level: 'low',
|
||||
message: '建议使用具体的列名替代 *',
|
||||
suggestion: '列出需要的字段名',
|
||||
})
|
||||
riskScore += 10
|
||||
}
|
||||
|
||||
let riskLevel = 'safe'
|
||||
if (riskScore >= 50) riskLevel = 'high'
|
||||
else if (riskScore >= 30) riskLevel = 'medium'
|
||||
else if (riskScore >= 10) riskLevel = 'low'
|
||||
|
||||
auditResult.value = {
|
||||
risk_score: riskScore,
|
||||
risk_level: riskLevel,
|
||||
sql_results: results,
|
||||
}
|
||||
|
||||
currentStep.value = 1
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const res: any = await workflowApi.create(form)
|
||||
createdWorkflowId.value = res.id
|
||||
currentStep.value = 2
|
||||
ElMessage.success('工单创建成功')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const viewWorkflow = () => {
|
||||
router.push(`/workflows/${createdWorkflowId.value}`)
|
||||
}
|
||||
|
||||
const createAnother = () => {
|
||||
currentStep.value = 0
|
||||
form.title = ''
|
||||
form.instance_id = null
|
||||
form.database = ''
|
||||
form.workflow_type = 'ddl'
|
||||
form.sql_content = ''
|
||||
form.description = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchInstances()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.create-workflow-page {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.steps {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
|
||||
.workflow-form {
|
||||
.sql-editor-container {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.audit-summary {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.audit-results {
|
||||
h4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.audit-item {
|
||||
.audit-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.rule-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.audit-message {
|
||||
color: #606266;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.audit-suggestion {
|
||||
color: #409eff;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audit-success {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<div class="workflow-detail-page">
|
||||
<el-card shadow="hover" v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="$router.back()">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<span class="title">工单详情 #{{ workflow?.id }}</span>
|
||||
<el-tag :type="getStatusType(workflow?.status)">
|
||||
{{ getStatusLabel(workflow?.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<template v-if="workflow?.status === 'pending' && canReview">
|
||||
<el-button type="success" @click="handleReview('approve')">
|
||||
<el-icon><Check /></el-icon>
|
||||
通过
|
||||
</el-button>
|
||||
<el-button type="danger" @click="handleReview('reject')">
|
||||
<el-icon><Close /></el-icon>
|
||||
拒绝
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button
|
||||
v-if="workflow?.status === 'approved'"
|
||||
type="primary"
|
||||
@click="handleExecute"
|
||||
>
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
执行
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="workflow?.status === 'success'"
|
||||
type="warning"
|
||||
@click="handleRollback"
|
||||
>
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
回滚
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="workflow" class="workflow-content">
|
||||
<!-- 基本信息 -->
|
||||
<el-descriptions title="基本信息" :column="3" border>
|
||||
<el-descriptions-item label="标题">{{ workflow.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
<el-tag :type="workflow.workflow_type === 'ddl' ? 'primary' : 'success'">
|
||||
{{ workflow.workflow_type.toUpperCase() }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="风险等级">
|
||||
<el-tag :type="getRiskType(workflow.risk_level)">
|
||||
{{ getRiskLabel(workflow.risk_level) }} ({{ workflow.risk_score }}分)
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据源">{{ workflow.instance_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据库">{{ workflow.database || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交人">{{ workflow.submitter_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(workflow.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTime(workflow.updated_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="执行时间" v-if="workflow.executed_at">
|
||||
{{ formatTime(workflow.executed_at) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div v-if="workflow.description" class="section">
|
||||
<h4>描述</h4>
|
||||
<p class="description">{{ workflow.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- SQL 内容 -->
|
||||
<div class="section">
|
||||
<h4>SQL 语句</h4>
|
||||
<pre class="sql-content">{{ workflow.sql_content }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 审核结果 -->
|
||||
<div v-if="workflow.sql_results.length > 0" class="section">
|
||||
<h4>审核结果</h4>
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="(result, index) in workflow.sql_results"
|
||||
:key="index"
|
||||
:type="getRiskType(result.risk_level)"
|
||||
>
|
||||
<div class="audit-item">
|
||||
<div class="audit-header">
|
||||
<el-tag :type="getRiskType(result.risk_level)" size="small">
|
||||
{{ getRiskLabel(result.risk_level) }}
|
||||
</el-tag>
|
||||
<span class="rule-name">{{ result.rule_name }}</span>
|
||||
</div>
|
||||
<div class="audit-message">{{ result.message }}</div>
|
||||
<div v-if="result.suggestion" class="audit-suggestion">
|
||||
建议: {{ result.suggestion }}
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
|
||||
<!-- 审批流程 -->
|
||||
<div class="section">
|
||||
<h4>审批流程</h4>
|
||||
<el-steps :active="getStepActive" finish-status="success">
|
||||
<el-step title="提交工单" :description="workflow.submitter_name" />
|
||||
<el-step title="审核" :description="workflow.reviewer_name || '待审核'" />
|
||||
<el-step title="执行" :description="workflow.executor_name || '待执行'" />
|
||||
</el-steps>
|
||||
</div>
|
||||
|
||||
<!-- 审核意见 -->
|
||||
<div v-if="workflow.review_comment" class="section">
|
||||
<h4>审核意见</h4>
|
||||
<el-alert
|
||||
:title="workflow.review_comment"
|
||||
:type="workflow.status === 'rejected' ? 'error' : 'success'"
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 执行结果 -->
|
||||
<div v-if="workflow.execute_result" class="section">
|
||||
<h4>执行结果</h4>
|
||||
<el-alert
|
||||
:title="workflow.execute_result"
|
||||
type="success"
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 回滚 SQL -->
|
||||
<div v-if="workflow.rollback_sql" class="section">
|
||||
<h4>回滚 SQL</h4>
|
||||
<pre class="sql-content rollback">{{ workflow.rollback_sql }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 审核对话框 -->
|
||||
<el-dialog v-model="reviewDialogVisible" title="审核工单" width="500px">
|
||||
<el-form>
|
||||
<el-form-item label="审核意见">
|
||||
<el-input
|
||||
v-model="reviewComment"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入审核意见"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="reviewDialogVisible = false">取消</el-button>
|
||||
<el-button :type="reviewAction === 'approve' ? 'success' : 'danger'" @click="submitReview">
|
||||
{{ reviewAction === 'approve' ? '通过' : '拒绝' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { workflowApi } from '@/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { SQLWorkflow } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const workflow = ref<SQLWorkflow | null>(null)
|
||||
const reviewDialogVisible = ref(false)
|
||||
const reviewComment = ref('')
|
||||
const reviewAction = ref<'approve' | 'reject'>('approve')
|
||||
|
||||
const canReview = computed(() => userStore.canReview)
|
||||
|
||||
const getStepActive = computed(() => {
|
||||
if (!workflow.value) return 0
|
||||
const statusMap: Record<string, number> = {
|
||||
pending: 1,
|
||||
reviewing: 1,
|
||||
approved: 2,
|
||||
rejected: 1,
|
||||
executing: 2,
|
||||
success: 3,
|
||||
failed: 3,
|
||||
rollback: 3,
|
||||
}
|
||||
return statusMap[workflow.value.status] || 0
|
||||
})
|
||||
|
||||
const fetchWorkflow = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const id = parseInt(route.params.id as string)
|
||||
const res: any = await workflowApi.get(id)
|
||||
workflow.value = res
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusType = (status?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'warning',
|
||||
reviewing: 'info',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
executing: 'info',
|
||||
success: 'success',
|
||||
failed: 'danger',
|
||||
rollback: 'info',
|
||||
}
|
||||
return map[status || ''] || 'info'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待审核',
|
||||
reviewing: '审核中',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
executing: '执行中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
rollback: '已回滚',
|
||||
}
|
||||
return map[status || ''] || status
|
||||
}
|
||||
|
||||
const getRiskType = (level?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: 'danger',
|
||||
medium: 'warning',
|
||||
low: 'info',
|
||||
safe: 'success',
|
||||
}
|
||||
return map[level || ''] || 'info'
|
||||
}
|
||||
|
||||
const getRiskLabel = (level?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
low: '低风险',
|
||||
safe: '安全',
|
||||
}
|
||||
return map[level || ''] || level
|
||||
}
|
||||
|
||||
const formatTime = (time?: string) => {
|
||||
if (!time) return '-'
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const handleReview = (action: 'approve' | 'reject') => {
|
||||
reviewAction.value = action
|
||||
reviewComment.value = ''
|
||||
reviewDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitReview = async () => {
|
||||
if (!workflow.value) return
|
||||
try {
|
||||
await workflowApi.review(workflow.value.id, {
|
||||
action: reviewAction.value,
|
||||
comment: reviewComment.value,
|
||||
})
|
||||
ElMessage.success(reviewAction.value === 'approve' ? '审核通过' : '已拒绝')
|
||||
reviewDialogVisible.value = false
|
||||
fetchWorkflow()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!workflow.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要执行该工单吗?', '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await workflowApi.execute(workflow.value.id)
|
||||
ElMessage.success('执行成功')
|
||||
fetchWorkflow()
|
||||
} catch (error) {
|
||||
// 取消执行
|
||||
}
|
||||
}
|
||||
|
||||
const handleRollback = async () => {
|
||||
if (!workflow.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要回滚该工单吗?', '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await workflowApi.rollback(workflow.value.id)
|
||||
ElMessage.success('回滚成功')
|
||||
fetchWorkflow()
|
||||
} catch (error) {
|
||||
// 取消回滚
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorkflow()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.workflow-detail-page {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-content {
|
||||
.section {
|
||||
margin-top: 24px;
|
||||
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sql-content {
|
||||
background-color: #f5f7fa;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
|
||||
&.rollback {
|
||||
background-color: #fdf6ec;
|
||||
border: 1px solid #f5dab1;
|
||||
}
|
||||
}
|
||||
|
||||
.audit-item {
|
||||
.audit-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.rule-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.audit-message {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.audit-suggestion {
|
||||
color: #409eff;
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div class="workflows-page">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索工单"
|
||||
clearable
|
||||
style="width: 250px;"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select v-model="filterType" placeholder="类型" clearable style="width: 120px;">
|
||||
<el-option label="DDL" value="ddl" />
|
||||
<el-option label="DML" value="dml" />
|
||||
</el-select>
|
||||
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 120px;">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已拒绝" value="rejected" />
|
||||
<el-option label="执行成功" value="success" />
|
||||
<el-option label="执行失败" value="failed" />
|
||||
</el-select>
|
||||
<el-select v-model="filterRisk" placeholder="风险等级" clearable style="width: 120px;">
|
||||
<el-option label="高风险" value="high" />
|
||||
<el-option label="中风险" value="medium" />
|
||||
<el-option label="低风险" value="low" />
|
||||
<el-option label="安全" value="safe" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" @click="$router.push('/workflows/create')">
|
||||
<el-icon><Plus /></el-icon>
|
||||
创建工单
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="filteredWorkflows" stripe v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" label="标题" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="viewDetail(row.id)">{{ row.title }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="instance_name" label="数据源" width="150" />
|
||||
<el-table-column prop="workflow_type" label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.workflow_type === 'ddl' ? 'primary' : 'success'" size="small">
|
||||
{{ row.workflow_type.toUpperCase() }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="risk_level" label="风险" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRiskType(row.risk_level)" size="small">
|
||||
{{ getRiskLabel(row.risk_level) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="risk_score" label="风险分" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="row.risk_score"
|
||||
:color="getRiskColor(row.risk_score)"
|
||||
:show-text="true"
|
||||
:stroke-width="8"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="submitter_name" label="提交人" width="100" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="viewDetail(row.id)">查看</el-button>
|
||||
<template v-if="row.status === 'pending' && canReview">
|
||||
<el-button type="success" link @click="handleReview(row.id, 'approve')">通过</el-button>
|
||||
<el-button type="danger" link @click="handleReview(row.id, 'reject')">拒绝</el-button>
|
||||
</template>
|
||||
<el-button
|
||||
v-if="row.status === 'approved'"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleExecute(row.id)"
|
||||
>
|
||||
执行
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="fetchWorkflows"
|
||||
@current-change="fetchWorkflows"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 审核对话框 -->
|
||||
<el-dialog v-model="reviewDialogVisible" title="审核工单" width="500px">
|
||||
<el-form>
|
||||
<el-form-item label="审核意见">
|
||||
<el-input
|
||||
v-model="reviewComment"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入审核意见"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="reviewDialogVisible = false">取消</el-button>
|
||||
<el-button :type="reviewAction === 'approve' ? 'success' : 'danger'" @click="submitReview">
|
||||
{{ reviewAction === 'approve' ? '通过' : '拒绝' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { workflowApi } from '@/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { SQLWorkflow } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const workflows = ref<SQLWorkflow[]>([])
|
||||
const searchQuery = ref('')
|
||||
const filterType = ref('')
|
||||
const filterStatus = ref('')
|
||||
const filterRisk = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
|
||||
const reviewDialogVisible = ref(false)
|
||||
const reviewComment = ref('')
|
||||
const reviewAction = ref<'approve' | 'reject'>('approve')
|
||||
const currentWorkflowId = ref(0)
|
||||
|
||||
const canReview = computed(() => userStore.canReview)
|
||||
|
||||
const filteredWorkflows = computed(() => {
|
||||
let result = workflows.value
|
||||
if (searchQuery.value) {
|
||||
result = result.filter(w => w.title.toLowerCase().includes(searchQuery.value.toLowerCase()))
|
||||
}
|
||||
if (filterType.value) {
|
||||
result = result.filter(w => w.workflow_type === filterType.value)
|
||||
}
|
||||
if (filterStatus.value) {
|
||||
result = result.filter(w => w.status === filterStatus.value)
|
||||
}
|
||||
if (filterRisk.value) {
|
||||
result = result.filter(w => w.risk_level === filterRisk.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const fetchWorkflows = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await workflowApi.list({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
})
|
||||
workflows.value = res
|
||||
total.value = res.length
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getRiskType = (level: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: 'danger',
|
||||
medium: 'warning',
|
||||
low: 'info',
|
||||
safe: 'success',
|
||||
}
|
||||
return map[level] || 'info'
|
||||
}
|
||||
|
||||
const getRiskLabel = (level: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: '高风险',
|
||||
medium: '中风险',
|
||||
low: '低风险',
|
||||
safe: '安全',
|
||||
}
|
||||
return map[level] || level
|
||||
}
|
||||
|
||||
const getRiskColor = (score: number) => {
|
||||
if (score >= 50) return '#F56C6C'
|
||||
if (score >= 30) return '#E6A23C'
|
||||
if (score >= 10) return '#409EFF'
|
||||
return '#67C23A'
|
||||
}
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'warning',
|
||||
reviewing: 'info',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
executing: 'info',
|
||||
success: 'success',
|
||||
failed: 'danger',
|
||||
rollback: 'info',
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待审核',
|
||||
reviewing: '审核中',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
executing: '执行中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
rollback: '已回滚',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const viewDetail = (id: number) => {
|
||||
router.push(`/workflows/${id}`)
|
||||
}
|
||||
|
||||
const handleReview = (id: number, action: 'approve' | 'reject') => {
|
||||
currentWorkflowId.value = id
|
||||
reviewAction.value = action
|
||||
reviewComment.value = ''
|
||||
reviewDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitReview = async () => {
|
||||
try {
|
||||
await workflowApi.review(currentWorkflowId.value, {
|
||||
action: reviewAction.value,
|
||||
comment: reviewComment.value,
|
||||
})
|
||||
ElMessage.success(reviewAction.value === 'approve' ? '审核通过' : '已拒绝')
|
||||
reviewDialogVisible.value = false
|
||||
fetchWorkflows()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExecute = async (id: number) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要执行该工单吗?', '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await workflowApi.execute(id)
|
||||
ElMessage.success('执行成功')
|
||||
fetchWorkflows()
|
||||
} catch (error) {
|
||||
// 取消执行
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorkflows()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.workflows-page {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user