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:
loganandClaude Opus 4.7 committed 2026-05-22 11:11:57 +08:00
commit 439155c92e
290 files changed
+61950

No files matched your search

@@ -0,0 +1,65 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
venv/
env/
ENV/
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.npm
.yarn
.pnp.*
# Vue.js
.DS_Store
/dist
/dist-ssr
*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs/
*.log
# Database
*.db
*.sqlite
*.sqlite3
# OS
.DS_Store
Thumbs.db
@@ -0,0 +1,35 @@
# 数据审核平台 Dockerfile
# 构建前端
FROM node:18-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# 构建后端
FROM python:3.11-slim AS backend
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# 复制后端代码
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./
# 复制前端构建产物
COPY --from=frontend-builder /app/frontend/dist ./static
# 暴露端口
EXPOSE 8000
# 启动命令
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -0,0 +1,237 @@
# 数据审核平台
一个基于 FastAPI + Vue3 的数据库变更管理和 SQL 审核平台,支持 MySQL、Redis 等数据库的 SQL 审核、审批流程管理。
## 功能特性
### 核心功能
- **数据源管理**:支持 MySQL、Redis、PostgreSQL、MongoDB 等多种数据库
- **SQL 审核**:自动 SQL 语法检查、风险评估、规则校验
- **工单管理**:DDL/DML 工单提交、审核、执行、回滚全流程
- **审批流程**:多级审批、角色权限控制
- **SQL 查询**:在线 SQL 编辑器,支持语法高亮和自动补全
- **数据字典**:表结构查看、字段信息、索引信息
- **Redis 管理**:Redis 信息监控、命令执行
### 安全特性
- 基于角色的访问控制 (RBAC)
- SQL 注入防护
- 敏感操作审计日志
- 数据脱敏
## 技术栈
### 后端
- **框架**: FastAPI (Python 3.11+)
- **数据库**: SQLAlchemy + MySQL
- **缓存**: Redis
- **认证**: JWT + OAuth2
### 前端
- **框架**: Vue 3 + TypeScript
- **UI 组件**: Element Plus
- **状态管理**: Pinia
- **路由**: Vue Router
- **代码编辑器**: CodeMirror 6
- **图表**: ECharts
## 快速开始
### 环境要求
- Python 3.11+
- Node.js 18+
- MySQL 5.7+
- Redis 6.0+
### 后端启动
```bash
cd backend
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 安装依赖
pip install -r requirements.txt
# 启动服务
python main.py
```
后端服务将在 http://localhost:8000 启动,API 文档地址: http://localhost:8000/docs
### 前端启动
```bash
cd frontend
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
前端服务将在 http://localhost:3000 启动
### 默认账号
| 账号 | 密码 | 角色 |
|------|------|------|
| admin | 123456 | 管理员 |
| dba | 123456 | DBA |
| dev1 | 123456 | 开发者 |
| dev2 | 123456 | 开发者 |
| auditor | 123456 | 审计员 |
## 项目结构
```
data-audit-platform/
├── backend/ # 后端代码
│ ├── main.py # 主入口
│ └── requirements.txt # 依赖
├── frontend/ # 前端代码
│ ├── src/
│ │ ├── api/ # API 接口
│ │ ├── components/ # 组件
│ │ ├── layouts/ # 布局
│ │ ├── router/ # 路由
│ │ ├── stores/ # 状态管理
│ │ ├── styles/ # 样式
│ │ ├── types/ # 类型定义
│ │ ├── views/ # 页面
│ │ ├── App.vue # 根组件
│ │ └── main.ts # 入口
│ ├── index.html
│ ├── package.json
│ ├── tsconfig.json
│ └── vite.config.ts
└── README.md
```
## 功能模块
### 1. 仪表盘
- 数据统计卡片
- 风险分布图表
- 数据库类型分布
- 最近工单列表
### 2. 数据源管理
- 添加/编辑/删除数据源
- 支持多种数据库类型
- 连接测试
- 环境隔离(开发/测试/预发布/生产)
### 3. SQL 工单
- 创建工单(DDL/DML)
- SQL 自动审核
- 多级审批流程
- 工单执行和回滚
### 4. SQL 查询
- 在线 SQL 编辑器
- 语法高亮
- 只读查询限制
- 查询结果展示
### 5. 数据字典
- 表结构查看
- 字段信息
- 索引信息
- DDL 生成
### 6. Redis 管理
- Redis 信息监控
- 内存使用统计
- 命中率监控
- 命令执行
### 7. 审核规则
- 内置审核规则
- 规则启用/禁用
- 风险等级设置
### 8. 用户管理
- 用户列表
- 角色分配
- 权限控制
### 9. 系统设置
- 基本设置
- 通知设置
- 安全设置
## API 接口
### 认证
- `POST /api/auth/login` - 登录
- `GET /api/auth/me` - 获取当前用户
- `POST /api/auth/logout` - 登出
### 用户
- `GET /api/users` - 用户列表
- `GET /api/users/{id}` - 用户详情
### 数据源
- `GET /api/instances` - 数据源列表
- `POST /api/instances` - 创建数据源
- `GET /api/instances/{id}` - 数据源详情
- `PUT /api/instances/{id}` - 更新数据源
- `DELETE /api/instances/{id}` - 删除数据源
- `POST /api/instances/{id}/test` - 测试连接
### SQL 工单
- `GET /api/workflows` - 工单列表
- `POST /api/workflows` - 创建工单
- `GET /api/workflows/{id}` - 工单详情
- `POST /api/workflows/{id}/review` - 审核工单
- `POST /api/workflows/{id}/execute` - 执行工单
- `POST /api/workflows/{id}/rollback` - 回滚工单
- `GET /api/workflows/statistics` - 工单统计
### SQL 查询
- `POST /api/query` - 执行查询
### 数据字典
- `GET /api/dictionary/instances/{id}/databases` - 数据库列表
- `GET /api/dictionary/instances/{id}/tables` - 表列表
- `GET /api/dictionary/instances/{id}/tables/{name}` - 表详情
### Redis
- `GET /api/redis/{id}/info` - Redis 信息
- `POST /api/redis/{id}/command` - 执行命令
### Dashboard
- `GET /api/dashboard/stats` - 统计数据
## 部署
### Docker 部署
```bash
# 构建镜像
docker build -t data-audit-platform .
# 运行容器
docker run -d -p 8000:8000 -p 3000:3000 data-audit-platform
```
### 生产环境部署
1. 配置环境变量
2. 使用 Gunicorn 启动后端
3. 使用 Nginx 反向代理前端
4. 配置 SSL 证书
## 参考项目
- [Bytebase](https://github.com/bytebase/bytebase) - Database DevOps and CI/CD for Developer, DBA and Platform Engineering teams
- [Yearning](https://github.com/cookieY/Yearning) - 开源 MySQL SQL 审核平台
- [Archery](https://github.com/hhyo/Archery) - SQL 审核查询平台
## 许可证
MIT License
@@ -0,0 +1,24 @@
# 数据库配置
DATABASE_URL=mysql+pymysql://root:password@localhost:3306/data_audit
# Redis 配置
REDIS_URL=redis://localhost:6379/0
# JWT 配置
SECRET_KEY=your-secret-key-here
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=120
# 应用配置
DEBUG=True
HOST=0.0.0.0
PORT=8000
# 邮件配置
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASSWORD=your-password
# 钉钉配置
DINGTALK_WEBHOOK=https://oapi.dingtalk.com/robot/send?access_token=xxx
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,14 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
python-multipart==0.0.6
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
redis==5.0.1
pymysql==1.1.0
sqlalchemy==2.0.23
alembic==1.12.1
python-dotenv==1.0.0
httpx==0.25.2
pytest==7.4.3
pytest-asyncio==0.21.1
@@ -0,0 +1,957 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数据审核平台 - 演示</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus@2.5.1/dist/index.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-container {
width: 420px;
padding: 40px;
background: #fff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h1 {
font-size: 28px;
font-weight: 600;
color: #303133;
margin: 16px 0 8px;
}
.login-header p {
font-size: 14px;
color: #909399;
}
.login-btn { width: 100%; }
.login-footer {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #ebeef5;
text-align: center;
}
.login-footer p {
font-size: 12px;
color: #909399;
margin: 4px 0;
}
.main-layout { height: 100vh; }
.sidebar {
background-color: #001529;
color: #fff;
}
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.logo-text {
font-size: 18px;
font-weight: 600;
margin-left: 12px;
}
.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: 24px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.main-content {
background-color: #f0f2f5;
padding: 20px;
overflow-y: auto;
}
.stats-row { margin-bottom: 20px; }
.stat-card {
display: flex;
align-items: center;
padding: 20px;
}
.stat-icon {
width: 64px;
height: 64px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #303133;
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #909399;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.charts-row { margin-bottom: 20px; }
.chart-container { height: 300px; }
.section-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
}
.instance-name {
display: flex;
align-items: center;
gap: 8px;
}
.user-info-cell {
display: flex;
align-items: center;
gap: 12px;
}
.user-detail .username {
font-weight: 500;
color: #303133;
}
.user-detail .email {
font-size: 12px;
color: #909399;
}
.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;
}
.audit-item {
margin-bottom: 16px;
}
.audit-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.audit-message {
color: #606266;
}
.audit-suggestion {
color: #409eff;
font-size: 13px;
margin-top: 4px;
}
</style>
</head>
<body>
<div id="app"></div>
<script src="https://unpkg.com/vue@3.4.15/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus@2.5.1/dist/index.full.js"></script>
<script src="https://unpkg.com/element-plus@2.5.1/dist/locale/zh-cn.js"></script>
<script src="https://unpkg.com/vue-router@4.2.5/dist/vue-router.global.js"></script>
<script src="https://unpkg.com/echarts@5.4.3/dist/echarts.min.js"></script>
<script>
const { createApp, ref, computed, onMounted, reactive } = Vue;
const { createRouter, createWebHashHistory } = VueRouter;
// 模拟数据
const mockStats = {
total_instances: 5,
total_workflows: 128,
pending_workflows: 12,
today_workflows: 8,
risk_distribution: { high: 5, medium: 15, low: 35, safe: 73 },
db_type_distribution: { mysql: 3, redis: 1, postgresql: 1 },
recent_workflows: [
{ id: 1, title: '添加用户表索引', instance_name: '生产-MySQL主库', workflow_type: 'ddl', risk_level: 'safe', status: 'success', submitter_name: 'dev1', created_at: '2024-01-15 10:30:00' },
{ id: 2, title: '修复订单数据', instance_name: '生产-MySQL主库', workflow_type: 'dml', risk_level: 'low', status: 'pending', submitter_name: 'dev1', created_at: '2024-01-15 09:15:00' },
{ id: 3, title: '创建新表', instance_name: '测试-MySQL', workflow_type: 'ddl', risk_level: 'safe', status: 'approved', submitter_name: 'dev2', created_at: '2024-01-14 16:45:00' },
]
};
const mockInstances = [
{ id: 1, name: '生产-MySQL主库', db_type: 'mysql', host: 'mysql-prod.example.com', port: 3306, database: 'production', environment: 'prod', status: 'active', description: '生产环境 MySQL 主库' },
{ id: 2, name: '测试-MySQL', db_type: 'mysql', host: 'mysql-test.example.com', port: 3306, database: 'test_db', environment: 'test', status: 'active', description: '测试环境 MySQL' },
{ id: 3, name: '生产-Redis', db_type: 'redis', host: 'redis-prod.example.com', port: 6379, environment: 'prod', status: 'active', description: '生产环境 Redis 缓存' },
{ id: 4, name: '开发-MySQL', db_type: 'mysql', host: 'mysql-dev.example.com', port: 3306, database: 'dev_db', environment: 'dev', status: 'active', description: '开发环境 MySQL' },
{ id: 5, name: '生产-PostgreSQL', db_type: 'postgresql', host: 'pg-prod.example.com', port: 5432, database: 'production', environment: 'prod', status: 'active', description: '生产环境 PostgreSQL' },
];
const mockWorkflows = [
{ id: 1, title: '添加用户表索引', instance_name: '生产-MySQL主库', workflow_type: 'ddl', risk_level: 'safe', risk_score: 10, status: 'success', submitter_name: 'dev1', created_at: '2024-01-15 10:30:00' },
{ id: 2, title: '修复订单数据', instance_name: '生产-MySQL主库', workflow_type: 'dml', risk_level: 'low', risk_score: 25, status: 'pending', submitter_name: 'dev1', created_at: '2024-01-15 09:15:00' },
{ id: 3, title: '创建新表', instance_name: '测试-MySQL', workflow_type: 'ddl', risk_level: 'safe', risk_score: 5, status: 'approved', submitter_name: 'dev2', created_at: '2024-01-14 16:45:00' },
{ id: 4, title: '批量更新用户状态', instance_name: '生产-MySQL主库', workflow_type: 'dml', risk_level: 'medium', risk_score: 55, status: 'rejected', submitter_name: 'dev1', created_at: '2024-01-14 14:20:00' },
{ id: 5, title: '添加订单表字段', instance_name: '生产-MySQL主库', workflow_type: 'ddl', risk_level: 'low', risk_score: 20, status: 'executing', submitter_name: 'dev2', created_at: '2024-01-14 11:00:00' },
];
const mockUsers = [
{ id: 1, username: 'admin', email: 'admin@example.com', role: 'admin', department: '技术部', phone: '13800138001', is_active: true, created_at: '2024-01-01 10:00:00' },
{ id: 2, username: 'dba', email: 'dba@example.com', role: 'dba', department: '运维部', phone: '13800138002', is_active: true, created_at: '2024-01-02 10:00:00' },
{ id: 3, username: 'dev1', email: 'dev1@example.com', role: 'developer', department: '研发部', phone: '13800138003', is_active: true, created_at: '2024-01-03 10:00:00' },
{ id: 4, username: 'dev2', email: 'dev2@example.com', role: 'developer', department: '研发部', phone: '13800138004', is_active: true, created_at: '2024-01-04 10:00:00' },
{ id: 5, username: 'auditor', email: 'auditor@example.com', role: 'auditor', department: '安全部', phone: '13800138005', is_active: true, created_at: '2024-01-05 10:00:00' },
];
// 登录组件
const Login = {
template: `
<div class="login-page">
<div class="login-container">
<div class="login-header">
<el-icon size="48" color="#409EFF"><DataLine /></el-icon>
<h1>数据审核平台</h1>
<p>Database Change Management & SQL Audit Platform</p>
</div>
<el-form :model="form" @keyup.enter="handleLogin">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" size="large" :prefix-icon="User" />
</el-form-item>
<el-form-item>
<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>
`,
setup() {
const form = reactive({ username: '', password: '' });
const loading = ref(false);
const router = VueRouter.useRouter();
const handleLogin = () => {
if (!form.username || !form.password) {
ElementPlus.ElMessage.warning('请输入用户名和密码');
return;
}
loading.value = true;
setTimeout(() => {
loading.value = false;
if (form.password === '123456') {
localStorage.setItem('user', JSON.stringify({ username: form.username, role: form.username === 'admin' ? 'admin' : form.username === 'dba' ? 'dba' : 'developer' }));
ElementPlus.ElMessage.success('登录成功');
router.push('/');
} else {
ElementPlus.ElMessage.error('密码错误,默认密码: 123456');
}
}, 500);
};
return { form, loading, handleLogin, User: 'User', Lock: 'Lock' };
}
};
// 仪表盘组件
const Dashboard = {
template: `
<div>
<el-row :gutter="20" class="stats-row">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background-color: #409EFF;">
<el-icon size="32" color="#fff"><DataLine /></el-icon>
</div>
<div>
<div class="stat-value">{{ stats.total_instances }}</div>
<div class="stat-label">数据源</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background-color: #67C23A;">
<el-icon size="32" color="#fff"><Document /></el-icon>
</div>
<div>
<div class="stat-value">{{ stats.total_workflows }}</div>
<div class="stat-label">总工单</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background-color: #E6A23C;">
<el-icon size="32" color="#fff"><Timer /></el-icon>
</div>
<div>
<div class="stat-value">{{ stats.pending_workflows }}</div>
<div class="stat-label">待审核</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-icon" style="background-color: #F56C6C;">
<el-icon size="32" color="#fff"><Calendar /></el-icon>
</div>
<div>
<div class="stat-value">{{ stats.today_workflows }}</div>
<div class="stat-label">今日工单</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="charts-row">
<el-col :span="12">
<el-card shadow="hover">
<template #header><div class="card-header"><span>风险分布</span></div></template>
<div ref="riskChart" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover">
<template #header><div class="card-header"><span>数据库类型分布</span></div></template>
<div ref="dbTypeChart" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>最近工单</span>
<el-button type="primary" link @click="$router.push('/workflows')">查看全部</el-button>
</div>
</template>
<el-table :data="stats.recent_workflows" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="instance_name" label="数据源" width="150" />
<el-table-column prop="workflow_type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.workflow_type === 'ddl' ? 'primary' : 'success'">{{ 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)">{{ getRiskLabel(row.risk_level) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">{{ 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="180" />
</el-table>
</el-card>
</div>
`,
setup() {
const stats = ref(mockStats);
const riskChart = ref(null);
const dbTypeChart = ref(null);
const getRiskType = (level) => ({ high: 'danger', medium: 'warning', low: 'info', safe: 'success' }[level] || 'info');
const getRiskLabel = (level) => ({ high: '高风险', medium: '中风险', low: '低风险', safe: '安全' }[level] || level);
const getStatusType = (status) => ({ pending: 'warning', approved: 'success', rejected: 'danger', executing: 'info', success: 'success', failed: 'danger' }[status] || 'info');
const getStatusLabel = (status) => ({ pending: '待审核', approved: '已通过', rejected: '已拒绝', executing: '执行中', success: '成功', failed: '失败' }[status] || status);
onMounted(() => {
setTimeout(() => {
if (riskChart.value) {
const chart = echarts.init(riskChart.value);
chart.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: '5%' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
data: [
{ value: stats.value.risk_distribution.high, name: '高风险', itemStyle: { color: '#F56C6C' } },
{ value: stats.value.risk_distribution.medium, name: '中风险', itemStyle: { color: '#E6A23C' } },
{ value: stats.value.risk_distribution.low, name: '低风险', itemStyle: { color: '#409EFF' } },
{ value: stats.value.risk_distribution.safe, name: '安全', itemStyle: { color: '#67C23A' } },
]
}]
});
}
if (dbTypeChart.value) {
const chart = echarts.init(dbTypeChart.value);
chart.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: '5%' },
series: [{
type: 'pie',
radius: '60%',
data: Object.entries(stats.value.db_type_distribution).map(([name, value]) => ({ name: name.toUpperCase(), value }))
}]
});
}
}, 100);
});
return { stats, riskChart, dbTypeChart, getRiskType, getRiskLabel, getStatusType, getStatusLabel };
}
};
// 数据源管理组件
const Instances = {
template: `
<el-card shadow="hover">
<template #header>
<div class="card-header">
<div>
<el-input v-model="searchQuery" placeholder="搜索数据源" clearable style="width: 300px;">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<el-button type="primary"><el-icon><Plus /></el-icon>添加数据源</el-button>
</div>
</template>
<el-table :data="filteredInstances" stripe>
<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)"><Coin /></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="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 label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="testConnection(row)">测试</el-button>
<el-button type="primary" link>编辑</el-button>
<el-button type="danger" link>删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
`,
setup() {
const searchQuery = ref('');
const instances = ref(mockInstances);
const filteredInstances = computed(() => {
if (!searchQuery.value) return instances.value;
return instances.value.filter(i => i.name.toLowerCase().includes(searchQuery.value.toLowerCase()));
});
const getDbTypeColor = (type) => ({ mysql: '#4479A1', redis: '#DC382D', postgresql: '#336791', mongodb: '#47A248' }[type] || '#909399');
const getDbTypeTag = (type) => ({ mysql: 'primary', redis: 'danger', postgresql: 'info', mongodb: 'success' }[type] || '');
const getEnvType = (env) => ({ dev: 'info', test: 'warning', staging: '', prod: 'danger' }[env] || '');
const getEnvLabel = (env) => ({ dev: '开发', test: '测试', staging: '预发布', prod: '生产' }[env] || env);
const testConnection = (row) => {
ElementPlus.ElMessage.success(`连接成功: ${row.name}`);
};
return { searchQuery, filteredInstances, getDbTypeColor, getDbTypeTag, getEnvType, getEnvLabel, testConnection };
}
};
// SQL 工单组件
const Workflows = {
template: `
<el-card shadow="hover">
<template #header>
<div class="card-header">
<div>
<el-input v-model="searchQuery" placeholder="搜索工单" clearable style="width: 250px;">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<el-button type="primary" @click="$router.push('/workflows/create')"><el-icon><Plus /></el-icon>创建工单</el-button>
</div>
</template>
<el-table :data="filteredWorkflows" stripe>
<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)" :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" />
<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'">
<el-button type="success" link @click="handleReview(row, 'approve')">通过</el-button>
<el-button type="danger" link @click="handleReview(row, 'reject')">拒绝</el-button>
</template>
</template>
</el-table-column>
</el-table>
</el-card>
`,
setup() {
const searchQuery = ref('');
const workflows = ref(mockWorkflows);
const filteredWorkflows = computed(() => {
if (!searchQuery.value) return workflows.value;
return workflows.value.filter(w => w.title.toLowerCase().includes(searchQuery.value.toLowerCase()));
});
const router = VueRouter.useRouter();
const getRiskType = (level) => ({ high: 'danger', medium: 'warning', low: 'info', safe: 'success' }[level] || 'info');
const getRiskLabel = (level) => ({ high: '高风险', medium: '中风险', low: '低风险', safe: '安全' }[level] || level);
const getRiskColor = (score) => score >= 50 ? '#F56C6C' : score >= 30 ? '#E6A23C' : score >= 10 ? '#409EFF' : '#67C23A';
const getStatusType = (status) => ({ pending: 'warning', approved: 'success', rejected: 'danger', executing: 'info', success: 'success', failed: 'danger' }[status] || 'info');
const getStatusLabel = (status) => ({ pending: '待审核', approved: '已通过', rejected: '已拒绝', executing: '执行中', success: '成功', failed: '失败' }[status] || status);
const viewDetail = (id) => {
router.push(`/workflows/${id}`);
};
const handleReview = (row, action) => {
ElementPlus.ElMessage.success(`${action === 'approve' ? '通过' : '拒绝'}工单: ${row.title}`);
};
return { searchQuery, filteredWorkflows, getRiskType, getRiskLabel, getRiskColor, getStatusType, getStatusLabel, viewDetail, handleReview };
}
};
// 创建工单组件
const CreateWorkflow = {
template: `
<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" style="margin-bottom: 32px;">
<el-step title="填写信息" />
<el-step title="SQL 审核" />
<el-step title="提交工单" />
</el-steps>
<div v-if="currentStep === 0" style="max-width: 800px; margin: 0 auto;">
<el-form :model="form" label-width="100px">
<el-form-item label="标题"><el-input v-model="form.title" placeholder="请输入工单标题" /></el-form-item>
<el-form-item label="数据源">
<el-select v-model="form.instance_id" placeholder="选择数据源" style="width: 100%;">
<el-option v-for="instance in instances" :key="instance.id" :label="instance.name" :value="instance.id" />
</el-select>
</el-form-item>
<el-form-item label="工单类型">
<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 语句">
<el-input v-model="form.sql_content" type="textarea" :rows="10" placeholder="请输入 SQL 语句..." />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入工单描述(可选)" />
</el-form-item>
</el-form>
<div style="display: flex; justify-content: center; gap: 16px; margin-top: 32px;">
<el-button type="primary" @click="handleAudit">审核 SQL</el-button>
</div>
</div>
<div v-if="currentStep === 1" style="max-width: 800px; margin: 0 auto;">
<el-alert :title="'审核完成,风险评分: ' + auditResult.risk_score + ',风险等级: ' + getRiskLabel(auditResult.risk_level)" :type="getRiskType(auditResult.risk_level)" show-icon style="margin-bottom: 24px;" />
<div v-if="auditResult.sql_results.length > 0">
<h4 class="section-title">审核结果</h4>
<div v-for="(result, index) in auditResult.sql_results" :key="index" class="audit-item">
<div class="audit-header">
<el-tag :type="getRiskType(result.risk_level)" size="small">{{ getRiskLabel(result.risk_level) }}</el-tag>
<span style="font-weight: 500;">{{ result.rule_name }}</span>
</div>
<div class="audit-message">{{ result.message }}</div>
<div v-if="result.suggestion" class="audit-suggestion">建议: {{ result.suggestion }}</div>
</div>
</div>
<el-result v-else icon="success" title="SQL 审核通过" sub-title="未发现风险问题" />
<div style="display: flex; justify-content: center; gap: 16px; margin-top: 32px;">
<el-button @click="currentStep = 0">上一步</el-button>
<el-button type="primary" @click="handleSubmit">提交工单</el-button>
</div>
</div>
<div v-if="currentStep === 2" style="max-width: 800px; margin: 0 auto;">
<el-result icon="success" title="工单创建成功" sub-title="工单编号: 10086">
<template #extra>
<el-button type="primary" @click="$router.push('/workflows')">查看工单</el-button>
<el-button @click="createAnother">创建新工单</el-button>
</template>
</el-result>
</div>
</el-card>
`,
setup() {
const currentStep = ref(0);
const instances = ref(mockInstances);
const form = reactive({ title: '', instance_id: null, workflow_type: 'ddl', sql_content: '', description: '' });
const auditResult = ref({ risk_score: 0, risk_level: 'safe', sql_results: [] });
const router = VueRouter.useRouter();
const getRiskType = (level) => ({ high: 'danger', medium: 'warning', low: 'info', safe: 'success' }[level] || 'info');
const getRiskLabel = (level) => ({ high: '高风险', medium: '中风险', low: '低风险', safe: '安全' }[level] || level);
const handleAudit = () => {
if (!form.title || !form.instance_id || !form.sql_content) {
ElementPlus.ElMessage.warning('请填写完整信息');
return;
}
const sqlUpper = form.sql_content.toUpperCase();
const results = [];
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 = () => {
ElementPlus.ElMessage.success('工单创建成功');
currentStep.value = 2;
};
const createAnother = () => {
currentStep.value = 0;
form.title = '';
form.instance_id = null;
form.workflow_type = 'ddl';
form.sql_content = '';
form.description = '';
};
return { currentStep, instances, form, auditResult, getRiskType, getRiskLabel, handleAudit, handleSubmit, createAnother };
}
};
// 工单详情组件
const WorkflowDetail = {
template: `
<el-card shadow="hover">
<template #header>
<div class="card-header">
<div style="display: flex; align-items: center; gap: 16px;">
<el-button @click="$router.back()"><el-icon><ArrowLeft /></el-icon>返回</el-button>
<span style="font-size: 18px; font-weight: 500;">工单详情 #{{ workflow.id }}</span>
<el-tag :type="getStatusType(workflow.status)">{{ getStatusLabel(workflow.status) }}</el-tag>
</div>
<div>
<el-button v-if="workflow.status === 'pending'" type="success">通过</el-button>
<el-button v-if="workflow.status === 'pending'" type="danger">拒绝</el-button>
<el-button v-if="workflow.status === 'approved'" type="primary">执行</el-button>
</div>
</div>
</template>
<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.submitter_name }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ workflow.created_at }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 24px;">
<h4 class="section-title">SQL 语句</h4>
<pre class="sql-content">{{ workflow.sql_content }}</pre>
</div>
<div style="margin-top: 24px;">
<h4 class="section-title">审批流程</h4>
<el-steps :active="2" finish-status="success">
<el-step title="提交工单" :description="workflow.submitter_name" />
<el-step title="审核" description="待审核" />
<el-step title="执行" description="待执行" />
</el-steps>
</div>
</el-card>
`,
setup() {
const route = VueRouter.useRoute();
const workflow = ref(mockWorkflows.find(w => w.id === parseInt(route.params.id)) || mockWorkflows[0]);
const getRiskType = (level) => ({ high: 'danger', medium: 'warning', low: 'info', safe: 'success' }[level] || 'info');
const getRiskLabel = (level) => ({ high: '高风险', medium: '中风险', low: '低风险', safe: '安全' }[level] || level);
const getStatusType = (status) => ({ pending: 'warning', approved: 'success', rejected: 'danger', executing: 'info', success: 'success', failed: 'danger' }[status] || 'info');
const getStatusLabel = (status) => ({ pending: '待审核', approved: '已通过', rejected: '已拒绝', executing: '执行中', success: '成功', failed: '失败' }[status] || status);
return { workflow, getRiskType, getRiskLabel, getStatusType, getStatusLabel };
}
};
// 用户管理组件
const Users = {
template: `
<el-card shadow="hover">
<template #header>
<div class="card-header">
<el-input v-model="searchQuery" placeholder="搜索用户" clearable style="width: 300px;">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary"><el-icon><Plus /></el-icon>添加用户</el-button>
</div>
</template>
<el-table :data="filteredUsers" stripe>
<el-table-column label="用户" min-width="200">
<template #default="{ row }">
<div class="user-info-cell">
<el-avatar :size="40" :src="'https://api.dicebear.com/7.x/avataaars/svg?seed=' + row.username" />
<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" />
<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>
`,
setup() {
const searchQuery = ref('');
const users = ref(mockUsers);
const filteredUsers = computed(() => {
if (!searchQuery.value) return users.value;
return users.value.filter(u => u.username.toLowerCase().includes(searchQuery.value.toLowerCase()) || u.email.toLowerCase().includes(searchQuery.value.toLowerCase()));
});
const getRoleType = (role) => ({ admin: 'danger', dba: 'warning', developer: 'success', auditor: 'info' }[role] || 'info');
const getRoleLabel = (role) => ({ admin: '管理员', dba: 'DBA', developer: '开发者', auditor: '审计员' }[role] || role);
return { searchQuery, filteredUsers, getRoleType, getRoleLabel };
}
};
// 布局组件
const MainLayout = {
template: `
<el-container class="main-layout">
<el-aside width="220px" class="sidebar">
<div class="logo">
<el-icon size="28" color="#fff"><DataLine /></el-icon>
<span class="logo-text">数据审核平台</span>
</div>
<el-menu :default-active="$route.path" router background-color="#001529" text-color="#fff" active-text-color="#409EFF">
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div></div>
<div class="header-right">
<el-badge :value="3" class="notification-badge">
<el-icon size="20" style="cursor: pointer;"><Bell /></el-icon>
</el-badge>
<el-dropdown @command="handleCommand">
<div class="user-info">
<el-avatar :size="32" :src="'https://api.dicebear.com/7.x/avataaars/svg?seed=' + user.username" />
<span>{{ user.username }}</span>
<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 />
</el-main>
</el-container>
</el-container>
`,
setup() {
const router = VueRouter.useRouter();
const user = ref(JSON.parse(localStorage.getItem('user') || '{}'));
const menuItems = [
{ path: '/', title: '仪表盘', icon: 'Odometer' },
{ path: '/instances', title: '数据源管理', icon: 'DataLine' },
{ path: '/workflows', title: 'SQL 工单', icon: 'Document' },
{ path: '/query', title: 'SQL 查询', icon: 'Search' },
{ path: '/dictionary', title: '数据字典', icon: 'Collection' },
{ path: '/audit-rules', title: '审核规则', icon: 'Warning' },
{ path: '/redis', title: 'Redis 管理', icon: 'Coin' },
{ path: '/users', title: '用户管理', icon: 'User' },
{ path: '/settings', title: '系统设置', icon: 'Setting' },
];
const handleCommand = (command) => {
if (command === 'logout') {
localStorage.removeItem('user');
router.push('/login');
}
};
return { user, menuItems, handleCommand };
}
};
// 路由配置
const routes = [
{ path: '/login', component: Login },
{
path: '/',
component: MainLayout,
redirect: '/',
children: [
{ path: '/', component: Dashboard },
{ path: '/instances', component: Instances },
{ path: '/workflows', component: Workflows },
{ path: '/workflows/create', component: CreateWorkflow },
{ path: '/workflows/:id', component: WorkflowDetail },
{ path: '/query', component: { template: '<el-card><template #header><span>SQL 查询</span></template><el-alert title="SQL 查询功能" description="支持在线 SQL 编辑器和查询结果展示" type="info" show-icon /></el-card>' } },
{ path: '/dictionary', component: { template: '<el-card><template #header><span>数据字典</span></template><el-alert title="数据字典功能" description="支持表结构查看、字段信息、索引信息" type="info" show-icon /></el-card>' } },
{ path: '/audit-rules', component: { template: '<el-card><template #header><span>审核规则</span></template><el-alert title="审核规则功能" description="支持 SQL 审核规则配置和管理" type="info" show-icon /></el-card>' } },
{ path: '/redis', component: { template: '<el-card><template #header><span>Redis 管理</span></template><el-alert title="Redis 管理功能" description="支持 Redis 信息监控和命令执行" type="info" show-icon /></el-card>' } },
{ path: '/users', component: Users },
{ path: '/settings', component: { template: '<el-card><template #header><span>系统设置</span></template><el-alert title="系统设置功能" description="支持基本设置、通知设置、安全设置" type="info" show-icon /></el-card>' } },
]
}
];
const router = createRouter({
history: createWebHashHistory(),
routes
});
router.beforeEach((to, from, next) => {
if (to.path === '/login') {
next();
return;
}
const user = localStorage.getItem('user');
if (!user) {
next('/login');
return;
}
next();
});
// 创建应用
const app = createApp({
template: '<router-view />'
});
app.use(router);
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
app.mount('#app');
</script>
</body>
</html>
@@ -0,0 +1,46 @@
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=mysql+pymysql://root:password@mysql:3306/data_audit
- REDIS_URL=redis://redis:6379/0
- SECRET_KEY=your-secret-key-change-in-production
- DEBUG=False
depends_on:
- mysql
- redis
networks:
- data-audit-network
mysql:
image: mysql:8.0
environment:
- MYSQL_ROOT_PASSWORD=password
- MYSQL_DATABASE=data_audit
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
networks:
- data-audit-network
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- data-audit-network
volumes:
mysql_data:
redis_data:
networks:
data-audit-network:
driver: bridge
@@ -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,
},
})
@@ -0,0 +1,83 @@
@echo off
chcp 65001 >nul
echo ==========================================
echo 数据审核平台 - 启动脚本
echo ==========================================
echo.
REM 检查 Python
python --version >nul 2>&1
if errorlevel 1 (
echo 错误: 未找到 Python,请先安装 Python 3.11+
exit /b 1
)
REM 检查 Node.js
node --version >nul 2>&1
if errorlevel 1 (
echo 错误: 未找到 Node.js,请先安装 Node.js 18+
exit /b 1
)
REM 启动后端
echo [1/2] 正在启动后端服务...
cd backend
REM 检查虚拟环境
if not exist "venv" (
echo 创建 Python 虚拟环境...
python -m venv venv
)
REM 激活虚拟环境
call venv\Scripts\activate.bat
REM 安装依赖
echo 安装后端依赖...
pip install -q -r requirements.txt
REM 启动后端服务
echo 启动后端服务 (http://localhost:8000)...
start "Backend" python main.py
cd ..
REM 启动前端
echo.
echo [2/2] 正在启动前端服务...
cd frontend
REM 检查 node_modules
if not exist "node_modules" (
echo 安装前端依赖...
npm install
)
REM 启动前端服务
echo 启动前端服务 (http://localhost:3000)...
start "Frontend" npm run dev
cd ..
echo.
echo ==========================================
echo 服务启动成功!
echo ==========================================
echo.
echo 后端 API: http://localhost:8000
echo 前端页面: http://localhost:3000
echo API 文档: http://localhost:8000/docs
echo.
echo 默认账号:
echo 管理员: admin / 123456
echo DBA: dba / 123456
echo 开发者: dev1 / 123456
echo.
echo 按任意键停止服务...
pause >nul
REM 停止服务
taskkill /FI "WINDOWTITLE eq Backend*" /F >nul 2>&1
taskkill /FI "WINDOWTITLE eq Frontend*" /F >nul 2>&1
echo 服务已停止
@@ -0,0 +1,85 @@
#!/bin/bash
# 数据审核平台启动脚本
echo "=========================================="
echo " 数据审核平台 - 启动脚本"
echo "=========================================="
echo ""
# 检查 Python
if ! command -v python3 &> /dev/null; then
echo "错误: 未找到 Python3,请先安装 Python 3.11+"
exit 1
fi
# 检查 Node.js
if ! command -v node &> /dev/null; then
echo "错误: 未找到 Node.js,请先安装 Node.js 18+"
exit 1
fi
# 启动后端
echo "[1/2] 正在启动后端服务..."
cd backend
# 检查虚拟环境
if [ ! -d "venv" ]; then
echo "创建 Python 虚拟环境..."
python3 -m venv venv
fi
# 激活虚拟环境
source venv/bin/activate
# 安装依赖
echo "安装后端依赖..."
pip install -q -r requirements.txt
# 启动后端服务
echo "启动后端服务 (http://localhost:8000)..."
python main.py &
BACKEND_PID=$!
echo "后端服务 PID: $BACKEND_PID"
cd ..
# 启动前端
echo ""
echo "[2/2] 正在启动前端服务..."
cd frontend
# 检查 node_modules
if [ ! -d "node_modules" ]; then
echo "安装前端依赖..."
npm install
fi
# 启动前端服务
echo "启动前端服务 (http://localhost:3000)..."
npm run dev &
FRONTEND_PID=$!
echo "前端服务 PID: $FRONTEND_PID"
cd ..
echo ""
echo "=========================================="
echo " 服务启动成功!"
echo "=========================================="
echo ""
echo "后端 API: http://localhost:8000"
echo "前端页面: http://localhost:3000"
echo "API 文档: http://localhost:8000/docs"
echo ""
echo "默认账号:"
echo " 管理员: admin / 123456"
echo " DBA: dba / 123456"
echo " 开发者: dev1 / 123456"
echo ""
echo "按 Ctrl+C 停止服务"
echo ""
# 等待用户中断
trap "kill $BACKEND_PID $FRONTEND_PID; exit" INT
wait