初始提交
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -0,0 +1,6 @@
|
||||
# API地址
|
||||
VITE_API_BASE_URL=/api
|
||||
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=KVM主控操作平台
|
||||
VITE_APP_VERSION=1.0.0
|
||||
@@ -0,0 +1,6 @@
|
||||
# API地址
|
||||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=KVM主控操作平台
|
||||
VITE_APP_VERSION=1.0.0
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
@@ -0,0 +1,201 @@
|
||||
# KVM主控操作平台 - 前端
|
||||
|
||||
基于 Vue 3 + Ant Design Vue + Pinia 的虚拟化管理后台前端项目。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Vue 3** - 渐进式 JavaScript 框架
|
||||
- **Vite** - 下一代前端构建工具
|
||||
- **Ant Design Vue** - 企业级 UI 组件库
|
||||
- **Pinia** - Vue 状态管理
|
||||
- **Vue Router** - Vue 官方路由管理器
|
||||
- **Axios** - HTTP 客户端
|
||||
- **ECharts** - 数据可视化图表库
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── api/ # API 接口封装
|
||||
│ │ ├── request.js # Axios 封装
|
||||
│ │ ├── userApi.js # 用户相关 API
|
||||
│ │ ├── vmApi.js # 虚拟机相关 API
|
||||
│ │ └── ...
|
||||
│ ├── stores/ # Pinia 状态管理
|
||||
│ │ ├── userStore.js # 用户状态
|
||||
│ │ └── vmStore.js # 虚拟机状态
|
||||
│ ├── router/ # 路由配置
|
||||
│ │ └── index.js # 路由定义和权限守卫
|
||||
│ ├── views/ # 页面组件
|
||||
│ │ ├── auth/ # 认证页面(登录、注册)
|
||||
│ │ ├── Dashboard.vue # 仪表盘
|
||||
│ │ ├── vm/ # 虚拟机管理
|
||||
│ │ ├── image/ # 镜像管理
|
||||
│ │ ├── network/ # 网络管理
|
||||
│ │ ├── volume/ # 数据卷管理
|
||||
│ │ ├── portGroup/ # 安全组管理
|
||||
│ │ └── user/ # 用户管理
|
||||
│ ├── layouts/ # 布局组件
|
||||
│ │ └── DefaultLayout.vue # 默认布局
|
||||
│ ├── components/ # 公共组件
|
||||
│ │ └── common/ # 通用组件
|
||||
│ ├── composables/ # 组合式函数
|
||||
│ │ ├── useTable.js # 表格相关
|
||||
│ │ ├── useForm.js # 表单相关
|
||||
│ │ └── useAuth.js # 认证相关
|
||||
│ ├── styles/ # 全局样式
|
||||
│ │ ├── main.css # 主样式
|
||||
│ │ ├── variables.css # CSS 变量
|
||||
│ │ └── dark.css # 深色模式
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.js # 入口文件
|
||||
├── public/ # 静态资源
|
||||
├── .env.example # 环境变量示例
|
||||
├── vite.config.js # Vite 配置
|
||||
└── package.json # 项目依赖
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 16.0.0
|
||||
- npm >= 7.0.0
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 开发环境运行
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:5173
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 预览生产构建
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
复制 `.env.example` 为 `.env` 并配置:
|
||||
|
||||
```env
|
||||
# API地址
|
||||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
|
||||
# 应用配置
|
||||
VITE_APP_TITLE=KVM主控操作平台
|
||||
VITE_APP_VERSION=1.0.0
|
||||
```
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 已实现功能
|
||||
|
||||
1. **用户认证**
|
||||
- 用户登录/注册
|
||||
- JWT Token 管理
|
||||
- 权限控制(管理员/普通用户)
|
||||
|
||||
2. **仪表盘**
|
||||
- 资源统计卡片
|
||||
- 资源使用情况图表
|
||||
- 虚拟机状态分布图表
|
||||
- 最近操作记录
|
||||
|
||||
3. **虚拟机管理**
|
||||
- 虚拟机列表(搜索、分页)
|
||||
- 虚拟机详情
|
||||
- 创建虚拟机
|
||||
- 虚拟机操作(启动、停止、重启、删除等)
|
||||
|
||||
4. **镜像管理**
|
||||
- 镜像列表(搜索、筛选)
|
||||
- 镜像操作(创建、编辑、删除、重新下载)
|
||||
|
||||
5. **网络管理**
|
||||
- 网络列表(搜索、筛选)
|
||||
- 网络操作(创建、编辑、删除)
|
||||
|
||||
6. **数据卷管理**
|
||||
- 数据卷列表(搜索、筛选)
|
||||
- 数据卷操作(创建、挂载、卸载、扩容、删除)
|
||||
|
||||
7. **安全组管理**
|
||||
- 安全组列表
|
||||
- 安全组规则管理
|
||||
- 安全组绑定/解绑
|
||||
|
||||
8. **用户管理**(仅管理员)
|
||||
- 用户列表
|
||||
- 创建/编辑用户
|
||||
- 修改密码
|
||||
- 删除用户
|
||||
|
||||
### UI 特性
|
||||
|
||||
- **深色模式**:默认深色主题,符合运维场景
|
||||
- **玻璃态效果**:卡片和面板使用玻璃态设计
|
||||
- **响应式设计**:支持桌面、平板、移动端
|
||||
- **信息密度高**:优先展示关键信息
|
||||
- **流畅交互**:微动画和过渡效果
|
||||
|
||||
## API 接口
|
||||
|
||||
所有 API 接口统一使用 `/api` 前缀,认证方式为 Bearer Token。
|
||||
|
||||
详细 API 文档请参考:`本网站API文档(封装功能API和用户功能).md`
|
||||
|
||||
## 权限控制
|
||||
|
||||
- **路由守卫**:根据用户角色控制页面访问
|
||||
- **菜单过滤**:普通用户隐藏管理员菜单
|
||||
- **按钮控制**:根据权限显示/隐藏操作按钮
|
||||
|
||||
## 样式规范
|
||||
|
||||
- 主色:`#10b981`(翠绿色)
|
||||
- 深色背景:`#1a1a2e`
|
||||
- 玻璃态效果:`backdrop-filter: blur(12px)`
|
||||
- 圆角:`12px`
|
||||
- 间距:使用 8px 基准网格
|
||||
|
||||
详细样式规范请参考:`UI设计规范.md`
|
||||
|
||||
## 开发规范
|
||||
|
||||
1. **组件命名**:使用 PascalCase
|
||||
2. **文件命名**:使用 PascalCase(组件)或 camelCase(工具函数)
|
||||
3. **API 调用**:统一使用封装的 API 函数
|
||||
4. **状态管理**:使用 Pinia stores
|
||||
5. **路由**:使用 Vue Router,配置权限守卫
|
||||
6. **样式**:优先使用 CSS 变量,支持深色模式
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 所有 API 请求都需要携带 JWT Token(除登录/注册接口)
|
||||
2. Token 过期会自动跳转到登录页
|
||||
3. 管理员可以访问所有功能,普通用户受限
|
||||
4. 删除操作需要二次确认
|
||||
5. 列表页面支持搜索和分页
|
||||
|
||||
## 问题反馈
|
||||
|
||||
如有问题,请查看:
|
||||
- 架构文档:`架构文档.md`
|
||||
- API 文档:`本网站API文档(封装功能API和用户功能).md`
|
||||
- UI 规范:`UI设计规范.md`
|
||||
+13
@@ -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>KVM主控操作平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
> kvm-control-frontend@1.0.0 dev
|
||||
> vite
|
||||
|
||||
|
||||
VITE v5.4.21 ready in 785 ms
|
||||
|
||||
➜ Local: http://localhost:15173/
|
||||
➜ Network: use --host to expose
|
||||
|
||||
> kvm-control-frontend@1.0.0 dev
|
||||
> vite
|
||||
|
||||
|
||||
VITE v5.4.21 ready in 760 ms
|
||||
|
||||
➜ Local: http://localhost:15173/
|
||||
➜ Network: use --host to expose
|
||||
Generated
+3004
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "kvm-control-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"ant-design-vue": "^4.0.0",
|
||||
"axios": "^1.6.2",
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@icon-park/vue": "^1.1.2",
|
||||
"echarts": "^5.4.3",
|
||||
"vue-echarts": "^6.6.1",
|
||||
"@vueuse/core": "^10.7.0",
|
||||
"@vueuse/motion": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"sass": "^1.69.5"
|
||||
}
|
||||
}
|
||||
+374
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 589 KiB |
+21
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 简化App.vue,移除认证相关逻辑
|
||||
</script>
|
||||
|
||||
<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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import request from './request.js'
|
||||
|
||||
/**
|
||||
* 认证相关API
|
||||
*/
|
||||
export const login = async (username, password) => {
|
||||
try {
|
||||
const response = await request({
|
||||
method: 'POST',
|
||||
url: '/auth/login/json',
|
||||
data: {
|
||||
username,
|
||||
password
|
||||
}
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
login
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import request from './request'
|
||||
|
||||
// 获取镜像列表
|
||||
export const getImageList = (params) => {
|
||||
return request.get('/image/list', { params })
|
||||
}
|
||||
|
||||
// 获取镜像详情
|
||||
export const getImageDetail = (params) => {
|
||||
return request.get('/image/detail', { params })
|
||||
}
|
||||
|
||||
// 创建镜像
|
||||
export const createImage = (data) => {
|
||||
return request.post('/image/create', data)
|
||||
}
|
||||
|
||||
// 更新镜像
|
||||
export const updateImage = (data) => {
|
||||
return request.put('/image/update', data)
|
||||
}
|
||||
|
||||
// 删除镜像
|
||||
export const deleteImage = (params) => {
|
||||
return request.delete('/image/delete', { params })
|
||||
}
|
||||
|
||||
// 重新下载镜像
|
||||
export const reloadImage = (params) => {
|
||||
return request.get('/image/reload', { params })
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from './request'
|
||||
|
||||
// 获取操作日志列表
|
||||
export const getLogList = (params) => {
|
||||
return request.get('/logs', { params })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import request from './request'
|
||||
|
||||
// 获取所有监控指标
|
||||
export const getAllMetrics = () => {
|
||||
return request.get('/metrics')
|
||||
}
|
||||
|
||||
// 获取主机数据
|
||||
export const getHostData = () => {
|
||||
return request.get('/metrics/host_data')
|
||||
}
|
||||
|
||||
// 获取虚拟机监控数据
|
||||
export const getVmData = (params) => {
|
||||
return request.get('/metrics/vm_data', { params })
|
||||
}
|
||||
|
||||
// 更新主机统计数据
|
||||
export const updateHost = (data) => {
|
||||
return request.post('/metrics/update_host', data)
|
||||
}
|
||||
|
||||
// 更新虚拟机统计数据
|
||||
export const updateVm = (data) => {
|
||||
return request.post('/metrics/update_vm', data)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import request from './request'
|
||||
|
||||
// 获取网络列表
|
||||
export const getNetworkList = (params) => {
|
||||
return request.get('/network/list', { params })
|
||||
}
|
||||
|
||||
// 获取网络详情
|
||||
export const getNetworkDetail = (params) => {
|
||||
return request.get('/network/detail', { params })
|
||||
}
|
||||
|
||||
// 创建网络
|
||||
export const createNetwork = (data) => {
|
||||
return request.post('/network/create', data)
|
||||
}
|
||||
|
||||
// 更新网络
|
||||
export const updateNetwork = (data) => {
|
||||
return request.put('/network/update', data)
|
||||
}
|
||||
|
||||
// 删除网络
|
||||
export const deleteNetwork = (params) => {
|
||||
return request.delete('/network/delete', { params })
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import request from './request'
|
||||
|
||||
// 获取安全组列表
|
||||
export const getPortGroupList = (params) => {
|
||||
return request.get('/port_group/list', { params })
|
||||
}
|
||||
|
||||
// 获取安全组详情
|
||||
export const getPortGroupDetail = (params) => {
|
||||
return request.get('/port_group/detail', { params })
|
||||
}
|
||||
|
||||
// 创建安全组
|
||||
export const createPortGroup = (data) => {
|
||||
return request.post('/port_group/create', data)
|
||||
}
|
||||
|
||||
// 绑定安全组
|
||||
export const bindPortGroup = (params) => {
|
||||
return request.post('/port_group/bind', null, { params })
|
||||
}
|
||||
|
||||
// 解绑安全组
|
||||
export const unbindPortGroup = (params) => {
|
||||
return request.post('/port_group/unbind', null, { params })
|
||||
}
|
||||
|
||||
// 删除安全组
|
||||
export const deletePortGroup = (params) => {
|
||||
return request.delete('/port_group/delete', { params })
|
||||
}
|
||||
|
||||
// 开启白名单模式
|
||||
export const enableWhiteList = (params) => {
|
||||
return request.post('/port_group/enable_white_list', null, { params })
|
||||
}
|
||||
|
||||
// 关闭白名单模式
|
||||
export const disableWhiteList = (params) => {
|
||||
return request.post('/port_group/disable_white_list', null, { params })
|
||||
}
|
||||
|
||||
// 创建安全组规则
|
||||
export const createPortGroupRule = (data) => {
|
||||
return request.post('/port_group/create_rule', data)
|
||||
}
|
||||
|
||||
// 获取安全组规则列表
|
||||
export const getPortGroupRuleList = (params) => {
|
||||
return request.get('/port_group/list_rule', { params })
|
||||
}
|
||||
|
||||
// 删除安全组规则
|
||||
export const deletePortGroupRule = (params) => {
|
||||
return request.delete('/port_group/delete_rule', { params })
|
||||
}
|
||||
|
||||
// 修改安全组规则
|
||||
export const updatePortGroupRule = (data) => {
|
||||
return request.put('/port_group/update_rule', data)
|
||||
}
|
||||
|
||||
// 批量修改安全组规则
|
||||
export const batchUpdatePortGroupRule = (data) => {
|
||||
return request.put('/port_group/batch_update_rule', data)
|
||||
}
|
||||
|
||||
// 应用安全组修改
|
||||
export const applyPortGroup = (params) => {
|
||||
return request.post('/port_group/apply', null, { params })
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import axios from 'axios'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: apiBaseURL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
// 从localStorage中获取token
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
// 直接返回响应数据
|
||||
return response.data
|
||||
},
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
// 服务器返回错误
|
||||
const { status, data } = error.response
|
||||
const errorMessage = data.message || data.msg || `请求失败 (${status})`
|
||||
message.error(errorMessage)
|
||||
} else {
|
||||
// 网络错误
|
||||
message.error('网络错误,请检查网络连接')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from './request'
|
||||
|
||||
// 获取任务状态
|
||||
export const getTaskStatus = (taskId) => {
|
||||
return request.get(`/task/${taskId}`)
|
||||
}
|
||||
|
||||
// 获取任务日志
|
||||
export const getTaskLogs = (taskId) => {
|
||||
return request.get(`/task/${taskId}/logs`)
|
||||
}
|
||||
|
||||
// 取消任务
|
||||
export const cancelTask = (taskId, terminate = false) => {
|
||||
return request.post(`/task/${taskId}/cancel`, { terminate })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import request from './request'
|
||||
|
||||
// 获取变量列表
|
||||
export const getVariableList = (params) => {
|
||||
return request.get('/variable/list', { params })
|
||||
}
|
||||
|
||||
// 创建变量
|
||||
export const createVariable = (data) => {
|
||||
return request.post('/variable/create', data)
|
||||
}
|
||||
|
||||
// 获取变量详情
|
||||
export const getVariableDetail = (params) => {
|
||||
return request.get('/variable/detail', { params })
|
||||
}
|
||||
|
||||
// 获取变量值
|
||||
export const getVariableValue = (params) => {
|
||||
return request.get('/variable/value', { params })
|
||||
}
|
||||
|
||||
// 更新变量
|
||||
export const updateVariable = (data) => {
|
||||
return request.put('/variable/update', data)
|
||||
}
|
||||
|
||||
// 更新变量值
|
||||
export const setVariableValue = (params) => {
|
||||
return request.put('/variable/set', {}, { params })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import request from './request'
|
||||
|
||||
// 获取虚拟机列表
|
||||
export const getVmList = (params) => {
|
||||
return request.get('/vm/list', { params })
|
||||
}
|
||||
|
||||
// 获取虚拟机详情
|
||||
export const getVmDetail = (params) => {
|
||||
return request.get('/vm/detail', { params })
|
||||
}
|
||||
|
||||
// 创建虚拟机
|
||||
export const createVm = (data) => {
|
||||
return request.post('/vm/create', data)
|
||||
}
|
||||
|
||||
// 启动虚拟机
|
||||
export const startVm = (params) => {
|
||||
return request.put('/vm/start', undefined, { params })
|
||||
}
|
||||
|
||||
// 停止虚拟机
|
||||
export const stopVm = (params) => {
|
||||
return request.put('/vm/stop', undefined, { params })
|
||||
}
|
||||
|
||||
// 重启虚拟机
|
||||
export const rebootVm = (params) => {
|
||||
return request.put('/vm/reboot', undefined, { params })
|
||||
}
|
||||
|
||||
// 删除虚拟机
|
||||
export const deleteVm = (params) => {
|
||||
return request.delete('/vm/delete', { params })
|
||||
}
|
||||
|
||||
// 更新虚拟机
|
||||
export const updateVm = (data) => {
|
||||
return request.put('/vm/update', data)
|
||||
}
|
||||
|
||||
// 重构虚拟机
|
||||
export const refactorVm = (data) => {
|
||||
return request.put('/vm/refactor', data)
|
||||
}
|
||||
|
||||
// 获取虚拟机状态
|
||||
export const getVmStatus = (params) => {
|
||||
return request.get('/vm/status', { params })
|
||||
}
|
||||
|
||||
// 重建虚拟机
|
||||
export const rebuildVm = (data) => {
|
||||
return request.post('/vm/rebuild', data)
|
||||
}
|
||||
|
||||
// 进入救援系统
|
||||
export const enterRescue = (params) => {
|
||||
return request.put('/vm/rescue', undefined, { params })
|
||||
}
|
||||
|
||||
// 退出救援系统
|
||||
export const exitRescue = (params) => {
|
||||
return request.put('/vm/exit_rescue', undefined, { params })
|
||||
}
|
||||
|
||||
// 暂停虚拟机
|
||||
export const pauseVm = (params) => {
|
||||
return request.put('/vm/pause', undefined, { params })
|
||||
}
|
||||
|
||||
// 恢复虚拟机
|
||||
export const resumeVm = (params) => {
|
||||
return request.put('/vm/resume', undefined, { params })
|
||||
}
|
||||
|
||||
// 修改虚拟机带宽
|
||||
export const updateVmTraffic = (data) => {
|
||||
return request.put('/vm/update_traffic', data)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import request from './request'
|
||||
|
||||
// 获取数据卷列表
|
||||
export const getVolumeList = (params) => {
|
||||
return request.get('/volume/list', { params })
|
||||
}
|
||||
|
||||
// 获取数据卷详情
|
||||
export const getVolumeDetail = (params) => {
|
||||
return request.get('/volume/detail', { params })
|
||||
}
|
||||
|
||||
// 创建数据卷
|
||||
export const createVolume = (data) => {
|
||||
return request.post('/volume/create', data)
|
||||
}
|
||||
|
||||
// 挂载数据卷
|
||||
export const mountVolume = (data) => {
|
||||
return request.put('/volume/mount', data)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 卸载数据卷
|
||||
export const unmountVolume = (data) => {
|
||||
return request.put('/volume/unmount', data)
|
||||
}
|
||||
|
||||
// 修改数据卷大小
|
||||
export const resizeVolume = (data) => {
|
||||
return request.put('/volume/resize', data)
|
||||
}
|
||||
|
||||
// 转移数据卷
|
||||
export const transferVolume = (data) => {
|
||||
return request.put('/volume/transfer', data)
|
||||
}
|
||||
|
||||
// 删除数据卷
|
||||
export const deleteVolume = (params) => {
|
||||
return request.delete('/volume/delete', { params })
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<a-button
|
||||
:type="type"
|
||||
:danger="danger"
|
||||
:loading="loading"
|
||||
:size="size"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Modal } from 'ant-design-vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
danger: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '确认操作'
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: '确定要执行此操作吗?'
|
||||
},
|
||||
okText: {
|
||||
type: String,
|
||||
default: '确定'
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: '取消'
|
||||
},
|
||||
okType: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
},
|
||||
onConfirm: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const handleClick = () => {
|
||||
Modal.confirm({
|
||||
title: props.title,
|
||||
content: props.content,
|
||||
okText: props.okText,
|
||||
cancelText: props.cancelText,
|
||||
okType: props.okType,
|
||||
onOk: async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await props.onConfirm()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<a-tag :color="color">
|
||||
{{ text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
statusMap: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const defaultStatusMap = {
|
||||
running: { color: 'green', text: '运行中' },
|
||||
stopped: { color: 'default', text: '已停止' },
|
||||
paused: { color: 'orange', text: '已暂停' },
|
||||
error: { color: 'red', text: '错误' },
|
||||
ready: { color: 'green', text: '就绪' },
|
||||
downloading: { color: 'blue', text: '下载中' },
|
||||
mounting: { color: 'blue', text: '挂载中' },
|
||||
lock: { color: 'red', text: '锁定' },
|
||||
unlock: { color: 'green', text: '未锁定' }
|
||||
}
|
||||
|
||||
const statusConfig = computed(() => {
|
||||
return props.statusMap[props.status] || defaultStatusMap[props.status] || { color: 'default', text: props.status }
|
||||
})
|
||||
|
||||
const color = computed(() => statusConfig.value.color)
|
||||
const text = computed(() => statusConfig.value.text)
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
|
||||
/**
|
||||
* 认证相关组合式函数
|
||||
*/
|
||||
export function useAuth() {
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isAuthenticated = computed(() => userStore.isAuthenticated)
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
const currentUser = computed(() => userStore.user)
|
||||
|
||||
// 检查权限
|
||||
const checkPermission = (requiredRole) => {
|
||||
if (requiredRole === 'admin' && !isAdmin.value) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 需要登录
|
||||
const requireAuth = () => {
|
||||
if (!isAuthenticated.value) {
|
||||
router.push('/login')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 需要管理员权限
|
||||
const requireAdmin = () => {
|
||||
if (!isAuthenticated.value) {
|
||||
router.push('/login')
|
||||
return false
|
||||
}
|
||||
if (!isAdmin.value) {
|
||||
router.push('/dashboard')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
currentUser,
|
||||
checkPermission,
|
||||
requireAuth,
|
||||
requireAdmin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
/**
|
||||
* 表单相关组合式函数
|
||||
* @param {Object} initialForm - 初始表单数据
|
||||
* @param {Function} submitFn - 提交函数
|
||||
* @param {Object} options - 选项
|
||||
*/
|
||||
export function useForm(initialForm, submitFn, options = {}) {
|
||||
const form = ref({ ...initialForm })
|
||||
const loading = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
form.value = { ...initialForm }
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
loading.value = true
|
||||
await submitFn(form.value)
|
||||
if (options.successMessage) {
|
||||
message.success(options.successMessage)
|
||||
}
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess()
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.errorFields) {
|
||||
return false
|
||||
}
|
||||
console.error('提交失败:', error)
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
loading,
|
||||
formRef,
|
||||
resetForm,
|
||||
handleSubmit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 表格相关组合式函数
|
||||
* @param {Function} fetchFn - 获取数据的函数
|
||||
* @param {Object} defaultParams - 默认参数
|
||||
*/
|
||||
export function useTable(fetchFn, defaultParams = {}) {
|
||||
const loading = ref(false)
|
||||
const dataSource = ref([])
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
})
|
||||
const searchParams = ref({ ...defaultParams })
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.value.current,
|
||||
count: pagination.value.pageSize,
|
||||
...searchParams.value
|
||||
}
|
||||
const data = await fetchFn(params)
|
||||
dataSource.value = data.list || []
|
||||
pagination.value.total = data.total || 0
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.value.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.value.current = pag.current
|
||||
pagination.value.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchParams.value = { ...defaultParams }
|
||||
pagination.value.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
dataSource,
|
||||
pagination,
|
||||
searchParams,
|
||||
fetchData,
|
||||
handleSearch,
|
||||
handleRefresh,
|
||||
handleTableChange,
|
||||
resetSearch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<a-layout class="layout-container">
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
:width="240"
|
||||
:trigger="null"
|
||||
collapsible
|
||||
class="layout-sider"
|
||||
>
|
||||
<div class="logo">
|
||||
<img v-if="!collapsed" src="/vite.svg" alt="Logo" class="logo-img" />
|
||||
<span v-if="!collapsed" class="logo-text">KVM主控</span>
|
||||
<img v-else src="/vite.svg" alt="Logo" class="logo-img-small" />
|
||||
</div>
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
@click="handleMenuClick"
|
||||
>
|
||||
<a-menu-item v-for="item in menuItems" :key="item.key">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<a-layout>
|
||||
<a-layout-header :class="['layout-header', { collapsed: collapsed }]">
|
||||
<div class="header-left">
|
||||
<menu-unfold-outlined
|
||||
v-if="collapsed"
|
||||
class="trigger"
|
||||
@click="() => (collapsed = !collapsed)"
|
||||
/>
|
||||
<menu-fold-outlined
|
||||
v-else
|
||||
class="trigger"
|
||||
@click="() => (collapsed = !collapsed)"
|
||||
/>
|
||||
<a-input-search
|
||||
v-model:value="searchValue"
|
||||
placeholder="搜索虚拟机、镜像..."
|
||||
style="width: 300px; margin-left: 16px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-badge :count="0" class="notification-badge">
|
||||
<bell-outlined class="header-icon" />
|
||||
</a-badge>
|
||||
<a-dropdown>
|
||||
<a class="user-info" @click.prevent>
|
||||
<a-avatar :size="32" style="background-color: #10b981">
|
||||
{{ 'U' }}
|
||||
</a-avatar>
|
||||
<span class="username">用户</span>
|
||||
<down-outlined />
|
||||
</a>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="handleLogout">
|
||||
<logout-outlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content :class="['layout-content', { collapsed: collapsed }]">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import {
|
||||
MenuUnfoldOutlined,
|
||||
MenuFoldOutlined,
|
||||
DashboardOutlined,
|
||||
CloudServerOutlined,
|
||||
FileImageOutlined,
|
||||
ApartmentOutlined,
|
||||
DatabaseOutlined,
|
||||
SafetyOutlined,
|
||||
BellOutlined,
|
||||
ControlOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
DownOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import * as vmApi from '@/api/vmApi'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const collapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true')
|
||||
const searchValue = ref('')
|
||||
const selectedKeys = ref([])
|
||||
const openKeys = ref([])
|
||||
|
||||
// 菜单项配置
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: DashboardOutlined,
|
||||
label: '仪表盘',
|
||||
title: '仪表盘'
|
||||
},
|
||||
{
|
||||
key: '/vm',
|
||||
icon: CloudServerOutlined,
|
||||
label: '虚拟机管理',
|
||||
title: '虚拟机管理'
|
||||
},
|
||||
{
|
||||
key: '/image',
|
||||
icon: FileImageOutlined,
|
||||
label: '镜像管理',
|
||||
title: '镜像管理'
|
||||
},
|
||||
{
|
||||
key: '/network',
|
||||
icon: ApartmentOutlined,
|
||||
label: '网络管理',
|
||||
title: '网络管理'
|
||||
},
|
||||
{
|
||||
key: '/volume',
|
||||
icon: DatabaseOutlined,
|
||||
label: '数据卷管理',
|
||||
title: '数据卷管理'
|
||||
},
|
||||
{
|
||||
key: '/port-group',
|
||||
icon: SafetyOutlined,
|
||||
label: '安全组管理',
|
||||
title: '安全组管理'
|
||||
},
|
||||
{
|
||||
key: '/variable',
|
||||
icon: ControlOutlined,
|
||||
label: '变量管理',
|
||||
title: '变量管理'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 监听路由变化,更新选中菜单
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
selectedKeys.value = [path]
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听折叠状态,保存到localStorage
|
||||
watch(collapsed, (val) => {
|
||||
localStorage.setItem('sidebarCollapsed', val.toString())
|
||||
})
|
||||
|
||||
// 菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
router.push(key)
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = async (value) => {
|
||||
if (!value.trim()) {
|
||||
message.warning('请输入搜索关键词')
|
||||
return
|
||||
}
|
||||
const key = value.trim()
|
||||
|
||||
try {
|
||||
const vmResult = await vmApi.getVmList({ page: 1, count: 20, key })
|
||||
const vmTotal = vmResult?.total ?? 0
|
||||
message.info(`搜索关键词 "${key}":虚拟机 ${vmTotal} 条`)
|
||||
} catch (error) {
|
||||
console.error('全局搜索失败:', error)
|
||||
message.error('搜索失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
Modal.confirm({
|
||||
title: '确认退出',
|
||||
content: '确定要退出登录吗?',
|
||||
onOk: () => {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout-sider {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.logo-img-small {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
margin-left: 240px;
|
||||
transition: margin-left 0.2s;
|
||||
}
|
||||
|
||||
.layout-header.collapsed {
|
||||
margin-left: 80px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.header-icon:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
margin-left: 240px;
|
||||
transition: margin-left 0.2s;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.layout-content.collapsed {
|
||||
margin-left: 80px;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/main.css'
|
||||
import './styles/variables.css'
|
||||
import './styles/dark.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/auth/Login.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/DefaultLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: '仪表盘', icon: 'DashboardOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'vm',
|
||||
name: 'VmManagement',
|
||||
component: () => import('@/views/vm/VmList.vue'),
|
||||
meta: { title: '虚拟机管理', icon: 'CloudServerOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'vm/create',
|
||||
name: 'VmCreate',
|
||||
component: () => import('@/views/vm/VmCreate.vue'),
|
||||
meta: { title: '创建虚拟机', hidden: true }
|
||||
},
|
||||
{
|
||||
path: 'vm/:id',
|
||||
name: 'VmDetail',
|
||||
component: () => import('@/views/vm/VmDetail.vue'),
|
||||
meta: { title: '虚拟机详情', hidden: true }
|
||||
},
|
||||
{
|
||||
path: 'image',
|
||||
name: 'ImageManagement',
|
||||
component: () => import('@/views/image/ImageList.vue'),
|
||||
meta: { title: '镜像管理', icon: 'FileImageOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'network',
|
||||
name: 'NetworkManagement',
|
||||
component: () => import('@/views/network/NetworkList.vue'),
|
||||
meta: { title: '网络管理', icon: 'ApartmentOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'volume',
|
||||
name: 'VolumeManagement',
|
||||
component: () => import('@/views/volume/VolumeList.vue'),
|
||||
meta: { title: '数据卷管理', icon: 'DatabaseOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'variable',
|
||||
name: 'VariableManagement',
|
||||
component: () => import('@/views/variable/VariableList.vue'),
|
||||
meta: { title: '变量管理', icon: 'ControlOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'port-group',
|
||||
name: 'PortGroupManagement',
|
||||
component: () => import('@/views/portGroup/PortGroupList.vue'),
|
||||
meta: { title: '安全组管理', icon: 'SafetyOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'TaskManagement',
|
||||
component: () => import('@/views/task/TaskList.vue'),
|
||||
meta: { title: '任务管理', icon: 'ScheduleOutlined' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth) {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录用户访问登录页,重定向到首页
|
||||
if (to.path === '/login') {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
next('/vm')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 从localStorage中初始化token
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const user = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
// 登录方法
|
||||
const login = (accessToken) => {
|
||||
token.value = accessToken
|
||||
localStorage.setItem('token', accessToken)
|
||||
}
|
||||
|
||||
// 登出方法
|
||||
const logout = () => {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as vmApi from '@/api/vmApi'
|
||||
|
||||
export const useVmStore = defineStore('vm', () => {
|
||||
const vmList = ref([])
|
||||
const vmDetail = ref(null)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
// 获取虚拟机列表
|
||||
const fetchVmList = async (params = {}) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await vmApi.getVmList(params)
|
||||
// 处理API返回的数据格式
|
||||
const responseData = data.data || data
|
||||
vmList.value = responseData.data || []
|
||||
total.value = responseData.count || 0
|
||||
return responseData
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取虚拟机详情
|
||||
const fetchVmDetail = async (params) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const raw = await vmApi.getVmDetail(params)
|
||||
const detail = raw?.data || raw
|
||||
vmDetail.value = detail
|
||||
return detail
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建虚拟机
|
||||
const createVm = async (data) => {
|
||||
return await vmApi.createVm(data)
|
||||
}
|
||||
|
||||
// 启动虚拟机
|
||||
const startVm = async (vmId) => {
|
||||
return await vmApi.startVm({ vm_id: vmId })
|
||||
}
|
||||
|
||||
// 停止虚拟机
|
||||
const stopVm = async (vmId, force = false) => {
|
||||
return await vmApi.stopVm({ vm_id: vmId, force })
|
||||
}
|
||||
|
||||
// 重启虚拟机
|
||||
const rebootVm = async (vmId, force = false) => {
|
||||
return await vmApi.rebootVm({ vm_id: vmId, force })
|
||||
}
|
||||
|
||||
// 删除虚拟机
|
||||
const deleteVm = async (vmId, deleteVolume = false) => {
|
||||
return await vmApi.deleteVm({ vm_id: vmId, delete_volume: deleteVolume })
|
||||
}
|
||||
|
||||
// 更新虚拟机
|
||||
const updateVm = async (data) => {
|
||||
return await vmApi.updateVm(data)
|
||||
}
|
||||
|
||||
// 重构虚拟机
|
||||
const refactorVm = async (data) => {
|
||||
return await vmApi.refactorVm(data)
|
||||
}
|
||||
|
||||
// 获取虚拟机状态
|
||||
const fetchVmStatus = async (vmId) => {
|
||||
return await vmApi.getVmStatus({ vm_id: vmId })
|
||||
}
|
||||
|
||||
// 重建虚拟机
|
||||
const rebuildVm = async (data) => {
|
||||
return await vmApi.rebuildVm(data)
|
||||
}
|
||||
|
||||
// 进入救援系统
|
||||
const enterRescue = async (vmId) => {
|
||||
return await vmApi.enterRescue({ vm_id: vmId })
|
||||
}
|
||||
|
||||
// 退出救援系统
|
||||
const exitRescue = async (vmId) => {
|
||||
return await vmApi.exitRescue({ vm_id: vmId })
|
||||
}
|
||||
|
||||
// 暂停虚拟机
|
||||
const pauseVm = async (vmId) => {
|
||||
return await vmApi.pauseVm({ vm_id: vmId })
|
||||
}
|
||||
|
||||
// 恢复虚拟机
|
||||
const resumeVm = async (vmId) => {
|
||||
return await vmApi.resumeVm({ vm_id: vmId })
|
||||
}
|
||||
|
||||
// 修改带宽
|
||||
const updateTraffic = async (data) => {
|
||||
return await vmApi.updateVmTraffic(data)
|
||||
}
|
||||
|
||||
return {
|
||||
vmList,
|
||||
vmDetail,
|
||||
total,
|
||||
loading,
|
||||
fetchVmList,
|
||||
fetchVmDetail,
|
||||
createVm,
|
||||
startVm,
|
||||
stopVm,
|
||||
rebootVm,
|
||||
deleteVm,
|
||||
updateVm,
|
||||
refactorVm,
|
||||
fetchVmStatus,
|
||||
rebuildVm,
|
||||
enterRescue,
|
||||
exitRescue,
|
||||
pauseVm,
|
||||
resumeVm,
|
||||
updateTraffic
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as volumeApi from '@/api/volumeApi'
|
||||
|
||||
export const useVolumeStore = defineStore('volume', () => {
|
||||
const volumeList = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
|
||||
// 获取数据卷列表
|
||||
const fetchVolumeList = async (params = {}) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await volumeApi.getVolumeList(params)
|
||||
// 处理API返回的数据格式
|
||||
const responseData = data.data || data
|
||||
volumeList.value = responseData.data || []
|
||||
total.value = responseData.count || 0
|
||||
return responseData
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数据卷详情
|
||||
const fetchVolumeDetail = async (params) => {
|
||||
try {
|
||||
const data = await volumeApi.getVolumeDetail(params)
|
||||
return data.data || data
|
||||
} catch (error) {
|
||||
console.error('获取数据卷详情失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 创建数据卷
|
||||
const createVolume = async (data) => {
|
||||
try {
|
||||
const result = await volumeApi.createVolume(data)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('创建数据卷失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 挂载数据卷 - 修正参数
|
||||
const mountVolume = async (data) => {
|
||||
try {
|
||||
const result = await volumeApi.mountVolume(data)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('挂载数据卷失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 卸载数据卷 - 修正参数
|
||||
const unmountVolume = async (data) => {
|
||||
try {
|
||||
const result = await volumeApi.unmountVolume(data)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('卸载数据卷失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 删除数据卷 - 修正参数
|
||||
const deleteVolume = async (data) => {
|
||||
try {
|
||||
const result = await volumeApi.deleteVolume(data)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('删除数据卷失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// volumeStore.js 中添加
|
||||
const resizeVolume = async (data) => {
|
||||
try {
|
||||
const result = await volumeApi.resizeVolume(data)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('修改数据卷大小失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const transferVolume = async (data) => {
|
||||
try {
|
||||
const result = await volumeApi.transferVolume(data)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('转移数据卷失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
volumeList,
|
||||
loading,
|
||||
total,
|
||||
fetchVolumeList,
|
||||
fetchVolumeDetail,
|
||||
createVolume,
|
||||
mountVolume,
|
||||
unmountVolume,
|
||||
deleteVolume,
|
||||
resizeVolume,
|
||||
transferVolume
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
/* 深色模式样式补充 */
|
||||
.dark-mode {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* 确保所有文本在深色模式下可见 */
|
||||
.dark-mode * {
|
||||
color-scheme: dark;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
/* 全局样式 */
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Ant Design Vue 深色模式定制 */
|
||||
.ant-layout {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.ant-layout-sider {
|
||||
background: var(--bg-secondary) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ant-layout-header {
|
||||
background: var(--bg-secondary) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.ant-layout-content {
|
||||
background: var(--bg-primary);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background: var(--bg-secondary) !important;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: var(--bg-secondary) !important;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.ant-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.ant-btn-primary {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.ant-btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* 输入框样式 */
|
||||
.ant-input,
|
||||
.ant-input-password,
|
||||
.ant-select-selector {
|
||||
background: transparent !important;
|
||||
border-color: var(--border-color) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.ant-input:focus,
|
||||
.ant-input-password:focus,
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
/* 菜单样式 */
|
||||
.ant-menu {
|
||||
background: transparent;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ant-menu-item:hover {
|
||||
background: var(--menu-hover-bg);
|
||||
color: var(--menu-hover-color);
|
||||
}
|
||||
|
||||
.ant-menu-item-selected {
|
||||
background: var(--menu-active-bg);
|
||||
color: var(--menu-active-color);
|
||||
}
|
||||
|
||||
/* 菜单选中左侧高亮条颜色 */
|
||||
.ant-menu-inline .ant-menu-item::after {
|
||||
border-right-color: transparent;
|
||||
}
|
||||
|
||||
.ant-menu-inline .ant-menu-item-selected::after {
|
||||
border-right-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 覆盖 Antd dark 主题下菜单的悬浮/选中颜色,避免对比度过低 */
|
||||
.ant-menu-dark .ant-menu-item,
|
||||
.ant-menu-dark .ant-menu-submenu-title {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ant-menu-dark .ant-menu-item:hover,
|
||||
.ant-menu-dark .ant-menu-item-active {
|
||||
background: var(--menu-hover-bg) !important;
|
||||
color: var(--menu-hover-color) !important;
|
||||
}
|
||||
|
||||
.ant-menu-dark .ant-menu-item-selected {
|
||||
background: var(--menu-active-bg) !important;
|
||||
color: var(--menu-active-color) !important;
|
||||
}
|
||||
|
||||
.ant-menu-dark.ant-menu-inline .ant-menu-item::after {
|
||||
border-right-color: transparent;
|
||||
}
|
||||
|
||||
.ant-menu-dark.ant-menu-inline .ant-menu-item-selected::after {
|
||||
border-right-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 标签页样式 */
|
||||
.ant-tabs {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 分页样式 */
|
||||
.ant-pagination {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ant-pagination-item {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.ant-pagination-item a {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ant-pagination-item-active {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 标签样式 */
|
||||
.ant-tag {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 消息提示样式 */
|
||||
.ant-message {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.ant-modal-content {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 抽屉样式 */
|
||||
.ant-drawer-content {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.ant-drawer-header {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 描述列表样式 */
|
||||
.ant-descriptions-item-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ant-descriptions-item-content {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 输入框自动填充样式,保证文字背景透明 */
|
||||
input.ant-input:-webkit-autofill,
|
||||
input.ant-input:-webkit-autofill:hover,
|
||||
input.ant-input:-webkit-autofill:focus,
|
||||
input.ant-input-password:-webkit-autofill,
|
||||
input.ant-input-password:-webkit-autofill:hover,
|
||||
input.ant-input-password:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0 1000px transparent inset !important;
|
||||
box-shadow: 0 0 0 1000px transparent inset !important;
|
||||
background-color: transparent !important;
|
||||
-webkit-text-fill-color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* 骨架屏样式 */
|
||||
.ant-skeleton-content {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-card);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/* CSS变量定义 */
|
||||
:root {
|
||||
/* 主色 - 浅色系蓝青色(适配白色背景) */
|
||||
--primary-color: #0ea5e9;
|
||||
--primary-hover: #0284c7;
|
||||
--primary-active: #0369a1;
|
||||
|
||||
/* 侧边栏菜单状态色(提升悬浮/选中对比度) */
|
||||
--menu-hover-bg: rgba(14, 165, 233, 0.18);
|
||||
--menu-active-bg: rgba(14, 165, 233, 0.3);
|
||||
--menu-hover-color: var(--text-primary);
|
||||
--menu-active-color: var(--primary-color);
|
||||
|
||||
/* 浅色背景 */
|
||||
--bg-primary: #f5f7fa;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-card: #ffffff;
|
||||
--bg-glass: rgba(255, 255, 255, 0.7);
|
||||
|
||||
/* 文字颜色(深色系) */
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
--text-disabled: #9ca3af;
|
||||
|
||||
/* 边框 */
|
||||
--border-color: #e5e7eb;
|
||||
--border-radius: 12px;
|
||||
|
||||
/* 阴影(在浅色背景下更柔和) */
|
||||
--shadow-sm: 0 2px 6px rgba(15, 23, 42, 0.06);
|
||||
--shadow-md: 0 8px 20px rgba(15, 23, 42, 0.08);
|
||||
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.12);
|
||||
|
||||
/* 状态颜色 */
|
||||
--success-color: #22c55e;
|
||||
--warning-color: #f59e0b;
|
||||
--error-color: #ef4444;
|
||||
--info-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 玻璃态效果类(适配浅色背景的柔和玻璃态) */
|
||||
.glass {
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<!-- 统计卡片 -->
|
||||
<a-col :xs="24" :sm="12" :md="6" v-for="stat in stats" :key="stat.key">
|
||||
<div class="stat-card glass-card">
|
||||
<div class="stat-icon" :style="{ background: stat.color }">
|
||||
<component :is="stat.icon" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<a-row :gutter="[16, 16]" style="margin-top: 16px">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="资源使用情况" class="chart-card glass-card">
|
||||
<div id="resource-chart" style="height: 300px"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="虚拟机状态分布" class="chart-card glass-card">
|
||||
<div id="vm-status-chart" style="height: 300px"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 最近操作 -->
|
||||
<a-card title="最近操作" class="recent-actions-card glass-card" style="margin-top: 16px">
|
||||
<a-table
|
||||
:columns="actionColumns"
|
||||
:data-source="recentActions"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-tag :color="getActionColor(record.action)">
|
||||
{{ record.action }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'time'">
|
||||
{{ formatTime(record.time) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { CloudServerOutlined, FileImageOutlined, ApartmentOutlined, DatabaseOutlined } from '@ant-design/icons-vue'
|
||||
import * as echarts from 'echarts'
|
||||
import * as metricsApi from '@/api/metricsApi'
|
||||
import * as vmApi from '@/api/vmApi'
|
||||
import * as imageApi from '@/api/imageApi'
|
||||
import * as networkApi from '@/api/networkApi'
|
||||
import * as volumeApi from '@/api/volumeApi'
|
||||
import * as logsApi from '@/api/logsApi'
|
||||
|
||||
const stats = ref([
|
||||
{
|
||||
key: 'vm',
|
||||
label: '虚拟机总数',
|
||||
value: 0,
|
||||
icon: CloudServerOutlined,
|
||||
color: 'rgba(16, 185, 129, 0.2)'
|
||||
},
|
||||
{
|
||||
key: 'image',
|
||||
label: '镜像数量',
|
||||
value: 0,
|
||||
icon: FileImageOutlined,
|
||||
color: 'rgba(59, 130, 246, 0.2)'
|
||||
},
|
||||
{
|
||||
key: 'network',
|
||||
label: '网络配置',
|
||||
value: 0,
|
||||
icon: ApartmentOutlined,
|
||||
color: 'rgba(245, 158, 11, 0.2)'
|
||||
},
|
||||
{
|
||||
key: 'volume',
|
||||
label: '数据卷',
|
||||
value: 0,
|
||||
icon: DatabaseOutlined,
|
||||
color: 'rgba(239, 68, 68, 0.2)'
|
||||
}
|
||||
])
|
||||
|
||||
const actionColumns = [
|
||||
{ title: '操作', key: 'action', dataIndex: 'action' },
|
||||
{ title: '资源', key: 'resource', dataIndex: 'resource' },
|
||||
{ title: '时间', key: 'time', dataIndex: 'time' }
|
||||
]
|
||||
|
||||
const recentActions = ref([])
|
||||
|
||||
let resourceChart = null
|
||||
let vmStatusChart = null
|
||||
let refreshTimer = null
|
||||
const isMounted = ref(true)
|
||||
|
||||
// 停止定时刷新,避免在未登录状态下继续发请求
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 仪表盘拉取虚拟机列表用于总数 + 状态分布,需足够大的 count 以拿到全量
|
||||
const DASHBOARD_VM_COUNT = 9999
|
||||
|
||||
// 获取统计数据(虚拟机 / 镜像 / 网络 / 数据卷数量)
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const [vmData, imageData, networkData, volumeData] = await Promise.all([
|
||||
vmApi.getVmList({ page: 1, count: DASHBOARD_VM_COUNT }),
|
||||
imageApi.getImageList({ page: 1, count: 1 }),
|
||||
networkApi.getNetworkList({ page: 1, count: 1 }),
|
||||
volumeApi.getVolumeList({ page: 1, count: 1 })
|
||||
])
|
||||
|
||||
stats.value[0].value = vmData?.total ?? 0
|
||||
stats.value[1].value = imageData?.total ?? 0
|
||||
stats.value[2].value = networkData?.total ?? 0
|
||||
stats.value[3].value = volumeData?.total ?? 0
|
||||
|
||||
// 使用虚拟机列表数据更新状态分布图
|
||||
updateVmStatusChart(vmData?.list || [])
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
if (error?.response?.status === 401) {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从监控接口获取资源使用情况并更新图表
|
||||
const updateResourceChart = async () => {
|
||||
if (!isMounted.value) return
|
||||
const resourceChartDom = document.getElementById('resource-chart')
|
||||
if (!resourceChartDom) return
|
||||
|
||||
if (!resourceChart) {
|
||||
resourceChart = echarts.init(resourceChartDom)
|
||||
}
|
||||
if (resourceChart.isDisposed?.()) return
|
||||
|
||||
try {
|
||||
const data = await metricsApi.getHostData()
|
||||
// 约定数据格式为:[{ time, cpu, memory, storage }]
|
||||
const points = Array.isArray(data) ? data : []
|
||||
const times = points.map((item) => item.time)
|
||||
const cpu = points.map((item) => item.cpu)
|
||||
const memory = points.map((item) => item.memory)
|
||||
const storage = points.map((item) => item.storage)
|
||||
|
||||
if (!isMounted.value || resourceChart.isDisposed?.()) return
|
||||
resourceChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: ['CPU', '内存', '存储'],
|
||||
textStyle: { color: '#fff' }
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: times,
|
||||
axisLabel: { color: '#fff' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#fff' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'CPU',
|
||||
type: 'line',
|
||||
data: cpu,
|
||||
itemStyle: { color: '#10b981' }
|
||||
},
|
||||
{
|
||||
name: '内存',
|
||||
type: 'line',
|
||||
data: memory,
|
||||
itemStyle: { color: '#3b82f6' }
|
||||
},
|
||||
{
|
||||
name: '存储',
|
||||
type: 'line',
|
||||
data: storage,
|
||||
itemStyle: { color: '#f59e0b' }
|
||||
}
|
||||
]
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取主机监控数据失败:', error)
|
||||
if (error?.response?.status === 401) {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据虚拟机列表更新状态分布饼图
|
||||
const updateVmStatusChart = (vmList) => {
|
||||
if (!isMounted.value) return
|
||||
const vmStatusChartDom = document.getElementById('vm-status-chart')
|
||||
if (!vmStatusChartDom) return
|
||||
|
||||
if (!vmStatusChart) {
|
||||
vmStatusChart = echarts.init(vmStatusChartDom)
|
||||
}
|
||||
if (vmStatusChart.isDisposed?.()) return
|
||||
|
||||
const statusCount = {
|
||||
running: 0,
|
||||
stopped: 0,
|
||||
paused: 0,
|
||||
error: 0
|
||||
}
|
||||
|
||||
vmList.forEach((vm) => {
|
||||
const status = vm.status
|
||||
if (statusCount[status] != null) {
|
||||
statusCount[status] += 1
|
||||
}
|
||||
})
|
||||
|
||||
const seriesData = [
|
||||
{ value: statusCount.running, name: '运行中', itemStyle: { color: '#10b981' } },
|
||||
{ value: statusCount.stopped, name: '已停止', itemStyle: { color: '#6b7280' } },
|
||||
{ value: statusCount.paused, name: '已暂停', itemStyle: { color: '#f59e0b' } },
|
||||
{ value: statusCount.error, name: '错误', itemStyle: { color: '#ef4444' } }
|
||||
].filter((item) => item.value > 0)
|
||||
|
||||
if (!isMounted.value || vmStatusChart.isDisposed?.()) return
|
||||
vmStatusChart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
textStyle: { color: '#fff' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '虚拟机状态',
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: seriesData,
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// 获取最近操作
|
||||
const fetchRecentActions = async () => {
|
||||
try {
|
||||
const data = await logsApi.getLogList({ limit: 10, offset: 0 })
|
||||
const list = data?.list || []
|
||||
|
||||
const actionLabelMap = {
|
||||
create: '创建',
|
||||
delete: '删除',
|
||||
start: '启动',
|
||||
stop: '停止',
|
||||
reboot: '重启',
|
||||
update: '更新',
|
||||
list: '查询',
|
||||
detail: '查看详情',
|
||||
resize: '扩容',
|
||||
mount: '挂载',
|
||||
unmount: '卸载',
|
||||
transfer: '转移'
|
||||
}
|
||||
|
||||
recentActions.value = list.map((item) => {
|
||||
let displayAction = item.action
|
||||
if (typeof item.action === 'string') {
|
||||
const parts = item.action.split(' ')
|
||||
const path = parts[1] || ''
|
||||
const actionKey = path.replace('/', '')
|
||||
displayAction = actionLabelMap[actionKey] || actionKey || parts[0] || item.action
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
action: displayAction,
|
||||
resource: item.resource,
|
||||
time: item.created_at
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取最近操作失败:', error)
|
||||
if (error?.response?.status === 401) {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化图表(仅确保实例存在;状态分布图由 fetchStats 驱动,不在此处用空数据覆盖)
|
||||
const initCharts = () => {
|
||||
if (!isMounted.value) return
|
||||
updateResourceChart()
|
||||
}
|
||||
|
||||
// 获取操作颜色
|
||||
const getActionColor = (action) => {
|
||||
const colorMap = {
|
||||
'创建': 'green',
|
||||
'删除': 'red',
|
||||
'启动': 'blue',
|
||||
'停止': 'orange',
|
||||
'更新': 'purple'
|
||||
}
|
||||
return colorMap[action] || 'default'
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time) => {
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
setTimeout(() => {
|
||||
initCharts()
|
||||
}, 100)
|
||||
|
||||
// 定时刷新数据(每10秒)
|
||||
refreshTimer = setInterval(() => {
|
||||
fetchStats()
|
||||
updateResourceChart()
|
||||
fetchRecentActions()
|
||||
}, 10000)
|
||||
|
||||
fetchRecentActions()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isMounted.value = false
|
||||
stopAutoRefresh()
|
||||
if (resourceChart && !resourceChart.isDisposed?.()) {
|
||||
resourceChart.dispose()
|
||||
resourceChart = null
|
||||
}
|
||||
if (vmStatusChart && !vmStatusChart.isDisposed?.()) {
|
||||
vmStatusChart.dispose()
|
||||
vmStatusChart = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: var(--primary-color);
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.recent-actions-card {
|
||||
min-height: 300px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card glass-card">
|
||||
<div class="login-header">
|
||||
<img src="/vite.svg" alt="Logo" class="login-logo" />
|
||||
<h1 class="login-title">KVM主控操作平台</h1>
|
||||
<p class="login-subtitle">欢迎回来,请登录您的账户</p>
|
||||
</div>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
class="login-form"
|
||||
>
|
||||
<a-form-item name="username">
|
||||
<a-input
|
||||
v-model:value="form.username"
|
||||
size="large"
|
||||
placeholder="用户名"
|
||||
:prefix="h(UserOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item name="password">
|
||||
<a-input-password
|
||||
v-model:value="form.password"
|
||||
size="large"
|
||||
placeholder="密码"
|
||||
:prefix="h(LockOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<div class="login-footer">
|
||||
<span>还没有账户?</span>
|
||||
<a @click="$router.push('/register')">立即注册</a>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import * as authApi from '@/api/authApi'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const formRef = ref()
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度在3-20个字符之间', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 8, message: '密码至少8个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await authApi.login(form.value.username, form.value.password)
|
||||
if (response.access_token) {
|
||||
// 保存token到localStorage
|
||||
localStorage.setItem('token', response.access_token)
|
||||
message.success('登录成功')
|
||||
router.push('/vm')
|
||||
} else {
|
||||
message.error('登录失败:未获取到token')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
message.error('登录失败:' + (error.message || '网络错误'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.value) return
|
||||
formRef.value
|
||||
.validate()
|
||||
.then(() => {
|
||||
handleLogin()
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
console.error('表单校验失败:', errorInfo)
|
||||
const firstError =
|
||||
errorInfo?.errorFields?.[0]?.errors?.[0] ||
|
||||
errorInfo?.errorFields?.[0]?.errors?.[0]
|
||||
if (firstError) {
|
||||
message.error(firstError)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.login-footer a {
|
||||
color: var(--primary-color);
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-footer a:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="register-container">
|
||||
<div class="register-card glass-card">
|
||||
<div class="register-header">
|
||||
<img src="/vite.svg" alt="Logo" class="register-logo" />
|
||||
<h1 class="register-title">注册账户</h1>
|
||||
<p class="register-subtitle">创建您的KVM主控操作平台账户</p>
|
||||
</div>
|
||||
<a-form
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
@finish="handleRegister"
|
||||
class="register-form"
|
||||
>
|
||||
<a-form-item name="username">
|
||||
<a-input
|
||||
v-model:value="form.username"
|
||||
size="large"
|
||||
placeholder="用户名(3-20个字符)"
|
||||
:prefix="h(UserOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item name="password">
|
||||
<a-input-password
|
||||
v-model:value="form.password"
|
||||
size="large"
|
||||
placeholder="密码(至少8个字符)"
|
||||
:prefix="h(LockOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item name="confirmPassword">
|
||||
<a-input-password
|
||||
v-model:value="form.confirmPassword"
|
||||
size="large"
|
||||
placeholder="确认密码"
|
||||
:prefix="h(LockOutlined)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
>
|
||||
注册
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<div class="register-footer">
|
||||
<span>已有账户?</span>
|
||||
<a @click="$router.push('/login')">立即登录</a>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const validateConfirmPassword = (rule, value) => {
|
||||
if (!value) {
|
||||
return Promise.reject('请确认密码')
|
||||
}
|
||||
if (value !== form.value.password) {
|
||||
return Promise.reject('两次输入的密码不一致')
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度在3-20个字符之间', trigger: 'blur' },
|
||||
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字、下划线', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 8, message: '密码至少8个字符', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, validator: validateConfirmPassword, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.register(form.value.username, form.value.password)
|
||||
message.success('注册成功,请登录')
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error)
|
||||
const msg = error?.message || '注册失败,请稍后重试'
|
||||
if (msg.includes('系统已初始化')) {
|
||||
message.error(msg)
|
||||
router.push('/login')
|
||||
} else {
|
||||
message.error(msg)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.register-logo {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.register-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.register-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.register-form {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.register-footer a {
|
||||
color: var(--primary-color);
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.register-footer a:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<div class="image-list-container">
|
||||
<a-card class="glass-card">
|
||||
<template #title>
|
||||
<span>镜像列表</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<plus-outlined />
|
||||
创建镜像
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<div class="search-bar">
|
||||
<a-input-search
|
||||
v-model:value="searchKey"
|
||||
placeholder="搜索镜像名称"
|
||||
style="width: 300px"
|
||||
@search="handleSearch"
|
||||
allow-clear
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filterOsType"
|
||||
placeholder="操作系统类型"
|
||||
style="width: 150px"
|
||||
allow-clear
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option value="linux">Linux</a-select-option>
|
||||
<a-select-option value="windows">Windows</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="handleRefresh">
|
||||
<reload-outlined />
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="imageList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'os_type'">
|
||||
<a-tag :color="record.os_type === 'linux' ? 'blue' : 'purple'">
|
||||
{{ record.os_type }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">
|
||||
查看
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleReload(record)">
|
||||
重新下载
|
||||
</a-button>
|
||||
<a-button type="link" size="small" danger @click="handleDelete(record)">
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 查看镜像详情 -->
|
||||
<a-modal
|
||||
v-model:open="detailVisible"
|
||||
title="镜像详情"
|
||||
:footer="null"
|
||||
width="560px"
|
||||
>
|
||||
<a-spin :spinning="detailLoading">
|
||||
<a-descriptions
|
||||
v-if="detailData"
|
||||
:column="1"
|
||||
bordered
|
||||
size="small"
|
||||
>
|
||||
<a-descriptions-item label="ID">{{ detailData.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="名称">{{ detailData.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="大小">{{ detailData.size != null ? detailData.size : '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作系统类型">
|
||||
<a-tag :color="detailData.os_type === 'linux' ? 'blue' : 'purple'">
|
||||
{{ detailData.os_type }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">{{ detailData.type }}</a-descriptions-item>
|
||||
<a-descriptions-item label="来源">{{ detailData.source ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="源路径">{{ detailData.source_path ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="存储路径">{{ detailData.path ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(detailData.status)">
|
||||
{{ detailData.status }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ detailData.created_at ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">{{ detailData.updated_at ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailData.deleted_at" label="删除时间">
|
||||
{{ detailData.deleted_at }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
|
||||
<!-- 创建镜像 -->
|
||||
<a-modal
|
||||
v-model:open="createVisible"
|
||||
title="创建镜像"
|
||||
ok-text="创建"
|
||||
:confirm-loading="createSubmitting"
|
||||
@ok="submitCreate"
|
||||
@cancel="resetCreateForm"
|
||||
>
|
||||
<a-form
|
||||
ref="createFormRef"
|
||||
:model="createForm"
|
||||
:rules="createRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="镜像名称" name="name" required>
|
||||
<a-input v-model:value="createForm.name" placeholder="请输入镜像名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="下载地址" name="url" required>
|
||||
<a-input v-model:value="createForm.url" placeholder="镜像文件 URL(如 http(s) 或 file 路径)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="操作系统类型" name="os_type">
|
||||
<a-select v-model:value="createForm.os_type" placeholder="选择操作系统类型">
|
||||
<a-select-option value="linux">Linux</a-select-option>
|
||||
<a-select-option value="windows">Windows</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="镜像类型" name="type">
|
||||
<a-select v-model:value="createForm.type" placeholder="选择镜像类型">
|
||||
<a-select-option value="system">system系统</a-select-option>
|
||||
<a-select-option value="data">data数据</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 编辑镜像 -->
|
||||
<a-modal
|
||||
v-model:open="editVisible"
|
||||
title="编辑镜像"
|
||||
ok-text="保存"
|
||||
:confirm-loading="editSubmitting"
|
||||
@ok="submitEdit"
|
||||
@cancel="resetEditForm"
|
||||
>
|
||||
<a-spin :spinning="editLoading">
|
||||
<a-form
|
||||
ref="editFormRef"
|
||||
:model="editForm"
|
||||
:rules="editRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="ID">
|
||||
<a-input v-model:value="editForm.id" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="镜像名称" name="name" required>
|
||||
<a-input v-model:value="editForm.name" placeholder="请输入镜像名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="路径" name="path" required>
|
||||
<a-input v-model:value="editForm.path" placeholder="镜像存储路径" />
|
||||
</a-form-item>
|
||||
<a-form-item label="操作系统类型" name="os_type">
|
||||
<a-select v-model:value="editForm.os_type" placeholder="选择操作系统类型">
|
||||
<a-select-option value="linux">Linux</a-select-option>
|
||||
<a-select-option value="windows">Windows</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="类型" name="type">
|
||||
<a-select v-model:value="editForm.type" placeholder="选择类型">
|
||||
<a-select-option value="system">system系统</a-select-option>
|
||||
<a-select-option value="data">data数据</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model:value="editForm.status" placeholder="选择状态">
|
||||
<a-select-option value="ready">ready</a-select-option>
|
||||
<a-select-option value="downloading">downloading</a-select-option>
|
||||
<a-select-option value="error">error</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import * as imageApi from '@/api/imageApi'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
|
||||
const searchKey = ref('')
|
||||
const filterOsType = ref(undefined)
|
||||
const imageList = ref([])
|
||||
const loading = ref(false)
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '操作系统', key: 'os_type', width: 120 },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 120 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '路径', dataIndex: 'path', key: 'path' },
|
||||
{ title: '操作', key: 'actions', width: 300, fixed: 'right' }
|
||||
]
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
ready: 'green',
|
||||
downloading: 'blue',
|
||||
error: 'red'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.value.current,
|
||||
count: pagination.value.pageSize,
|
||||
key: searchKey.value || undefined,
|
||||
os_type: filterOsType.value
|
||||
}
|
||||
const data = await imageApi.getImageList(params)
|
||||
// 处理API返回的数据格式
|
||||
const responseData = data.data || data
|
||||
imageList.value = responseData.data || []
|
||||
pagination.value.total = responseData.count || 0
|
||||
} catch (error) {
|
||||
console.error('获取列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.value.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.value.current = pag.current
|
||||
pagination.value.pageSize = pag.pageSize
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 创建镜像
|
||||
const createVisible = ref(false)
|
||||
const createFormRef = ref(null)
|
||||
const createSubmitting = ref(false)
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
url: '',
|
||||
os_type: 'linux',
|
||||
type: 'qcow2'
|
||||
})
|
||||
const createRules = {
|
||||
name: [{ required: true, message: '请输入镜像名称', trigger: 'blur' }],
|
||||
url: [{ required: true, message: '请输入下载地址', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.name = ''
|
||||
createForm.url = ''
|
||||
createForm.os_type = 'linux'
|
||||
createForm.type = 'qcow2'
|
||||
createFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
resetCreateForm()
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
const submitCreate = async () => {
|
||||
try {
|
||||
await createFormRef.value?.validate()
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
createSubmitting.value = true
|
||||
try {
|
||||
await imageApi.createImage({
|
||||
name: createForm.name.trim(),
|
||||
path: createForm.url.trim(),
|
||||
os_type: createForm.os_type,
|
||||
type: createForm.type
|
||||
})
|
||||
message.success('创建成功')
|
||||
createVisible.value = false
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('创建镜像失败:', error)
|
||||
} finally {
|
||||
createSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查看镜像详情
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref(null)
|
||||
|
||||
const handleView = async (record) => {
|
||||
detailVisible.value = true
|
||||
detailData.value = null
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await imageApi.getImageDetail({ image_id: record.id })
|
||||
// 后端可能返回双层 data(源头 API 被原样包在 data 里),取内层为实际镜像对象
|
||||
detailData.value = res?.data != null ? res.data : res
|
||||
} catch (error) {
|
||||
console.error('获取镜像详情失败:', error)
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑镜像
|
||||
const editVisible = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const editSubmitting = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const editForm = reactive({
|
||||
id: undefined,
|
||||
name: '',
|
||||
path: '',
|
||||
os_type: '',
|
||||
type: '',
|
||||
status: ''
|
||||
})
|
||||
const editRules = {
|
||||
name: [{ required: true, message: '请输入镜像名称', trigger: 'blur' }],
|
||||
path: [{ required: true, message: '请输入路径', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const resetEditForm = () => {
|
||||
editForm.id = undefined
|
||||
editForm.name = ''
|
||||
editForm.path = ''
|
||||
editForm.os_type = ''
|
||||
editForm.type = ''
|
||||
editForm.status = ''
|
||||
editFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const handleEdit = async (record) => {
|
||||
editVisible.value = true
|
||||
resetEditForm()
|
||||
editLoading.value = true
|
||||
try {
|
||||
const res = await imageApi.getImageDetail({ image_id: record.id })
|
||||
const detail = res?.data != null ? res.data : res
|
||||
if (detail) {
|
||||
editForm.id = detail.id
|
||||
editForm.name = detail.name ?? ''
|
||||
editForm.path = detail.path ?? ''
|
||||
editForm.os_type = detail.os_type ?? ''
|
||||
editForm.type = detail.type ?? ''
|
||||
editForm.status = detail.status ?? ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取镜像详情失败:', error)
|
||||
editVisible.value = false
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitEdit = async () => {
|
||||
try {
|
||||
await editFormRef.value?.validate()
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await imageApi.updateImage({
|
||||
id: editForm.id,
|
||||
name: editForm.name.trim(),
|
||||
path: editForm.path.trim(),
|
||||
os_type: editForm.os_type,
|
||||
type: editForm.type,
|
||||
status: editForm.status
|
||||
})
|
||||
message.success('更新成功')
|
||||
editVisible.value = false
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('更新镜像失败:', error)
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReload = async (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认重新下载',
|
||||
content: `确定要重新下载镜像 "${record.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await imageApi.reloadImage({ image_id: record.id })
|
||||
message.success('开始重新下载')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('重新下载失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = async (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除镜像 "${record.name}" 吗?此操作不可恢复!`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await imageApi.deleteImage({ image_id: record.id })
|
||||
message.success('删除成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-list-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,638 @@
|
||||
<template>
|
||||
<div class="network-list-container">
|
||||
<a-card class="glass-card">
|
||||
<template #title>
|
||||
<span>网络列表</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<plus-outlined />
|
||||
创建网络
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<div class="search-bar">
|
||||
<a-input-search
|
||||
v-model:value="searchKey"
|
||||
placeholder="搜索网络名称"
|
||||
style="width: 300px"
|
||||
@search="handleSearch"
|
||||
allow-clear
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filterType"
|
||||
placeholder="网络类型"
|
||||
style="width: 150px"
|
||||
allow-clear
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option value="bridge">Bridge</a-select-option>
|
||||
<a-select-option value="nat">NAT</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="handleRefresh">
|
||||
<reload-outlined />
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="networkList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="record.type === 'bridge' ? 'blue' : 'green'">
|
||||
{{ record.type }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.vm_id ? 'green' : 'default'">
|
||||
{{ record.vm_id ? '已使用' : '未使用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">
|
||||
查看
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
@click="handleDelete(record)"
|
||||
:disabled="!!record.vm_id"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 查看网络详情 -->
|
||||
<a-modal
|
||||
v-model:open="detailVisible"
|
||||
title="网络详情"
|
||||
:footer="null"
|
||||
width="560px"
|
||||
>
|
||||
<a-spin :spinning="detailLoading">
|
||||
<a-descriptions
|
||||
v-if="detailData"
|
||||
:column="1"
|
||||
bordered
|
||||
size="small"
|
||||
>
|
||||
<a-descriptions-item label="ID">{{ detailData.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="名称">{{ detailData.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">
|
||||
<a-tag :color="detailData.type === 'bridge' ? 'blue' : 'green'">
|
||||
{{ detailData.type }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="detailData.vm_id ? 'green' : 'default'">
|
||||
{{ detailData.vm_id ? '已使用' : '未使用' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="地址">{{ detailData.address ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="网关">{{ detailData.gateway ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="DNS">{{ detailData.nameservers ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="MAC 地址">{{ detailData.mac_address ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="网桥名称">{{ detailData.bridge_name ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="LS 网桥名称">{{ detailData.ls_bridge_name ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="目标设备">{{ detailData.target_device ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="LS 名称">{{ detailData.ls_name ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="关联虚拟机 ID">{{ detailData.vm_id ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ detailData.created_at ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">{{ detailData.updated_at ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailData.deleted_at" label="删除时间">
|
||||
{{ detailData.deleted_at }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
|
||||
<!-- 创建网络 -->
|
||||
<a-modal
|
||||
v-model:open="createVisible"
|
||||
title="创建网络"
|
||||
width="560px"
|
||||
@cancel="resetCreateForm"
|
||||
>
|
||||
<a-spin :spinning="createSubmitting">
|
||||
<a-steps :current="createCurrentStep" class="create-network-steps">
|
||||
<a-step title="基础信息" />
|
||||
<a-step title="网桥与设备" />
|
||||
</a-steps>
|
||||
<a-form
|
||||
ref="createFormRef"
|
||||
:model="createForm"
|
||||
:rules="createRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<template v-if="createCurrentStep === 0">
|
||||
<a-form-item label="名称" name="name" required>
|
||||
<a-input v-model:value="createForm.name" placeholder="请输入网络名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="类型" name="type">
|
||||
<a-select v-model:value="createForm.type" placeholder="选择类型">
|
||||
<a-select-option value="bridge">bridge</a-select-option>
|
||||
<a-select-option value="nat">nat</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="地址" name="address">
|
||||
<a-input v-model:value="createForm.address" placeholder="如 192.168.1.0/24" />
|
||||
</a-form-item>
|
||||
<a-form-item label="网关" name="gateway">
|
||||
<a-input v-model:value="createForm.gateway" placeholder="如 192.168.1.1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="DNS" name="nameservers">
|
||||
<a-input v-model:value="createForm.nameservers" placeholder="如 114.114.114.114,8.8.8.8" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-form-item label="MAC 地址" name="mac_address">
|
||||
<a-input v-model:value="createForm.mac_address" placeholder="如 52:54:00:d8:92:4b" />
|
||||
</a-form-item>
|
||||
<a-form-item label="网桥名称" name="bridge_name">
|
||||
<a-input v-model:value="createForm.bridge_name" placeholder="如 br0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="LS 网桥名称" name="ls_bridge_name">
|
||||
<a-input v-model:value="createForm.ls_bridge_name" placeholder="如 br-int" />
|
||||
</a-form-item>
|
||||
<a-form-item label="LS 名称" name="ls_name">
|
||||
<a-input v-model:value="createForm.ls_name" placeholder="如 ls-public" />
|
||||
</a-form-item>
|
||||
<a-form-item label="目标设备" name="target_device">
|
||||
<a-input v-model:value="createForm.target_device" placeholder="如 vnet8" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
<template #footer>
|
||||
<a-space>
|
||||
<a-button v-if="createCurrentStep > 0" @click="createPrevStep">
|
||||
上一步
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="createCurrentStep < 1"
|
||||
type="primary"
|
||||
@click="createNextStep"
|
||||
>
|
||||
下一步
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
@click="submitCreate"
|
||||
:loading="createSubmitting"
|
||||
>
|
||||
创建
|
||||
</a-button>
|
||||
<a-button @click="closeCreateModal">取消</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<!-- 编辑网络 -->
|
||||
<a-modal
|
||||
v-model:open="editVisible"
|
||||
title="编辑网络"
|
||||
width="560px"
|
||||
@cancel="resetEditForm"
|
||||
>
|
||||
<a-spin :spinning="editLoading">
|
||||
<a-steps :current="editCurrentStep" class="edit-network-steps">
|
||||
<a-step title="基础信息" />
|
||||
<a-step title="网桥与设备" />
|
||||
</a-steps>
|
||||
<a-form
|
||||
ref="editFormRef"
|
||||
:model="editForm"
|
||||
:rules="editRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<template v-if="editCurrentStep === 0">
|
||||
<a-form-item label="ID">
|
||||
<a-input v-model:value="editForm.id" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="名称" name="name" required>
|
||||
<a-input v-model:value="editForm.name" placeholder="请输入网络名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="类型" name="type">
|
||||
<a-select v-model:value="editForm.type" placeholder="选择类型">
|
||||
<a-select-option value="bridge">bridge</a-select-option>
|
||||
<a-select-option value="nat">nat</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-tag :color="editForm.vm_id ? 'green' : 'default'">
|
||||
{{ editForm.vm_id ? '已使用' : '未使用' }}
|
||||
</a-tag>
|
||||
</a-form-item>
|
||||
<a-form-item label="地址" name="address">
|
||||
<a-input v-model:value="editForm.address" placeholder="如 192.168.1.0/24" />
|
||||
</a-form-item>
|
||||
<a-form-item label="网关" name="gateway">
|
||||
<a-input v-model:value="editForm.gateway" placeholder="如 192.168.1.1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="DNS" name="nameservers">
|
||||
<a-input v-model:value="editForm.nameservers" placeholder="如 114.114.114.114,8.8.8.8" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-form-item label="MAC 地址" name="mac_address">
|
||||
<a-input v-model:value="editForm.mac_address" placeholder="如 52:54:00:xx:xx:xx" />
|
||||
</a-form-item>
|
||||
<a-form-item label="网桥名称" name="bridge_name">
|
||||
<a-input v-model:value="editForm.bridge_name" placeholder="如 br0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="LS 网桥名称" name="ls_bridge_name">
|
||||
<a-input v-model:value="editForm.ls_bridge_name" placeholder="如 br-int" />
|
||||
</a-form-item>
|
||||
<a-form-item label="LS 名称" name="ls_name">
|
||||
<a-input v-model:value="editForm.ls_name" placeholder="如 ls-public" />
|
||||
</a-form-item>
|
||||
<a-form-item label="接口 ID" name="interface_id">
|
||||
<a-input v-model:value="editForm.interface_id" placeholder="interface_id" />
|
||||
</a-form-item>
|
||||
<a-form-item label="目标设备" name="target_device">
|
||||
<a-input v-model:value="editForm.target_device" placeholder="如 vnet8" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
<template #footer>
|
||||
<a-space>
|
||||
<a-button v-if="editCurrentStep > 0" @click="editPrevStep">
|
||||
上一步
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="editCurrentStep < 1"
|
||||
type="primary"
|
||||
@click="editNextStep"
|
||||
>
|
||||
下一步
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
@click="submitEdit"
|
||||
:loading="editSubmitting"
|
||||
>
|
||||
保存
|
||||
</a-button>
|
||||
<a-button @click="closeEditModal">取消</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import * as networkApi from '@/api/networkApi'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
|
||||
const searchKey = ref('')
|
||||
const filterType = ref(undefined)
|
||||
const networkList = ref([])
|
||||
const loading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref(null)
|
||||
const createVisible = ref(false)
|
||||
const createFormRef = ref(null)
|
||||
const createSubmitting = ref(false)
|
||||
const createCurrentStep = ref(0)
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
address: '',
|
||||
gateway: '',
|
||||
nameservers: '',
|
||||
type: 'bridge',
|
||||
mac_address: '',
|
||||
bridge_name: '',
|
||||
ls_bridge_name: '',
|
||||
ls_name: '',
|
||||
target_device: ''
|
||||
})
|
||||
const createRules = {
|
||||
name: [{ required: true, message: '请输入网络名称', trigger: 'blur' }]
|
||||
}
|
||||
const editVisible = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const editSubmitting = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const editCurrentStep = ref(0)
|
||||
const editForm = reactive({
|
||||
id: undefined,
|
||||
name: '',
|
||||
address: '',
|
||||
gateway: '',
|
||||
nameservers: '',
|
||||
type: '',
|
||||
mac_address: '',
|
||||
bridge_name: '',
|
||||
ls_bridge_name: '',
|
||||
ls_name: '',
|
||||
interface_id: '',
|
||||
target_device: '',
|
||||
vm_id: null
|
||||
})
|
||||
const editRules = {
|
||||
name: [{ required: true, message: '请输入网络名称', trigger: 'blur' }]
|
||||
}
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类型', key: 'type', width: 120 },
|
||||
{ title: '地址', dataIndex: 'address', key: 'address' },
|
||||
{ title: '网关', dataIndex: 'gateway', key: 'gateway' },
|
||||
{ title: 'DNS', dataIndex: 'nameservers', key: 'nameservers' },
|
||||
{ title: '使用状态', key: 'status', width: 100 },
|
||||
{ title: '关联虚拟机', dataIndex: 'vm_id', key: 'vm_id', width: 120 },
|
||||
{ title: '操作', key: 'actions', width: 200, fixed: 'right' }
|
||||
]
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.value.current,
|
||||
count: pagination.value.pageSize,
|
||||
key: searchKey.value || undefined,
|
||||
type: filterType.value
|
||||
}
|
||||
const data = await networkApi.getNetworkList(params)
|
||||
// 处理API返回的数据格式
|
||||
const responseData = data.data || data
|
||||
networkList.value = responseData.data || []
|
||||
pagination.value.total = responseData.count || 0
|
||||
} catch (error) {
|
||||
console.error('获取列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.value.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.value.current = pag.current
|
||||
pagination.value.pageSize = pag.pageSize
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
createVisible.value = true
|
||||
resetCreateForm()
|
||||
}
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createCurrentStep.value = 0
|
||||
createForm.name = ''
|
||||
createForm.address = ''
|
||||
createForm.gateway = ''
|
||||
createForm.nameservers = ''
|
||||
createForm.type = 'bridge'
|
||||
createForm.mac_address = ''
|
||||
createForm.bridge_name = ''
|
||||
createForm.ls_bridge_name = ''
|
||||
createForm.ls_name = ''
|
||||
createForm.target_device = ''
|
||||
createFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const createPrevStep = () => {
|
||||
createCurrentStep.value--
|
||||
}
|
||||
|
||||
const createNextStep = () => {
|
||||
createFormRef.value?.validateFields().then(() => {
|
||||
createCurrentStep.value++
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
createVisible.value = false
|
||||
resetCreateForm()
|
||||
}
|
||||
|
||||
const submitCreate = async () => {
|
||||
try {
|
||||
await createFormRef.value?.validate()
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
createSubmitting.value = true
|
||||
try {
|
||||
await networkApi.createNetwork({
|
||||
name: (createForm.name || '').trim(),
|
||||
address: (createForm.address || '').trim() || undefined,
|
||||
gateway: (createForm.gateway || '').trim() || undefined,
|
||||
nameservers: (createForm.nameservers || '').trim() || undefined,
|
||||
type: (createForm.type || 'bridge').trim(),
|
||||
mac_address: (createForm.mac_address || '').trim() || undefined,
|
||||
bridge_name: (createForm.bridge_name || '').trim() || undefined,
|
||||
ls_bridge_name: (createForm.ls_bridge_name || '').trim() || undefined,
|
||||
ls_name: (createForm.ls_name || '').trim() || undefined,
|
||||
target_device: (createForm.target_device || '').trim() || undefined
|
||||
})
|
||||
message.success('创建成功')
|
||||
createVisible.value = false
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('创建网络失败:', error)
|
||||
message.error(error?.response?.data?.message || error?.message || '创建失败')
|
||||
} finally {
|
||||
createSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleView = async (record) => {
|
||||
detailVisible.value = true
|
||||
detailData.value = null
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await networkApi.getNetworkDetail({ network_id: record.id })
|
||||
// 兼容返回 { code, message, data: { ... } } 或直接返回详情对象
|
||||
detailData.value = res?.data != null ? res.data : res
|
||||
} catch (error) {
|
||||
console.error('获取网络详情失败:', error)
|
||||
message.error('获取网络详情失败')
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetEditForm = () => {
|
||||
editCurrentStep.value = 0
|
||||
editForm.id = undefined
|
||||
editForm.name = ''
|
||||
editForm.address = ''
|
||||
editForm.gateway = ''
|
||||
editForm.nameservers = ''
|
||||
editForm.type = ''
|
||||
editForm.mac_address = ''
|
||||
editForm.bridge_name = ''
|
||||
editForm.ls_bridge_name = ''
|
||||
editForm.ls_name = ''
|
||||
editForm.interface_id = ''
|
||||
editForm.target_device = ''
|
||||
editForm.vm_id = null
|
||||
editFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
resetEditForm()
|
||||
editVisible.value = false
|
||||
}
|
||||
|
||||
const editPrevStep = () => {
|
||||
if (editCurrentStep.value > 0) editCurrentStep.value--
|
||||
}
|
||||
|
||||
const editNextStep = async () => {
|
||||
try {
|
||||
await editFormRef.value?.validateFields(['name'])
|
||||
if (editCurrentStep.value < 1) editCurrentStep.value++
|
||||
} catch (e) {
|
||||
// 校验未通过,不切换步骤
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = async (record) => {
|
||||
editVisible.value = true
|
||||
resetEditForm()
|
||||
editLoading.value = true
|
||||
try {
|
||||
const res = await networkApi.getNetworkDetail({ network_id: record.id })
|
||||
const detail = res?.data != null ? res.data : res
|
||||
if (detail) {
|
||||
editForm.id = detail.id
|
||||
editForm.name = detail.name ?? ''
|
||||
editForm.address = detail.address ?? ''
|
||||
editForm.gateway = detail.gateway ?? ''
|
||||
editForm.nameservers = detail.nameservers ?? ''
|
||||
editForm.type = detail.type ?? ''
|
||||
editForm.mac_address = detail.mac_address ?? ''
|
||||
editForm.bridge_name = detail.bridge_name ?? ''
|
||||
editForm.ls_bridge_name = detail.ls_bridge_name ?? ''
|
||||
editForm.ls_name = detail.ls_name ?? ''
|
||||
editForm.interface_id = detail.interface_id != null ? String(detail.interface_id) : ''
|
||||
editForm.target_device = detail.target_device ?? ''
|
||||
editForm.vm_id = detail.vm_id
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取网络详情失败:', error)
|
||||
editVisible.value = false
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitEdit = async () => {
|
||||
try {
|
||||
await editFormRef.value?.validate()
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
await networkApi.updateNetwork({
|
||||
id: editForm.id,
|
||||
name: (editForm.name || '').trim(),
|
||||
address: (editForm.address || '').trim(),
|
||||
gateway: (editForm.gateway || '').trim(),
|
||||
nameservers: (editForm.nameservers || '').trim(),
|
||||
type: (editForm.type || '').trim(),
|
||||
mac_address: (editForm.mac_address || '').trim(),
|
||||
bridge_name: (editForm.bridge_name || '').trim(),
|
||||
ls_bridge_name: (editForm.ls_bridge_name || '').trim(),
|
||||
ls_name: (editForm.ls_name || '').trim(),
|
||||
interface_id: (editForm.interface_id || '').trim(),
|
||||
target_device: (editForm.target_device || '').trim()
|
||||
})
|
||||
message.success('更新成功')
|
||||
editVisible.value = false
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('更新网络失败:', error)
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (record) => {
|
||||
if (record.vm_id) {
|
||||
Modal.warning({
|
||||
title: '无法删除',
|
||||
content: `网络 "${record.name}" 已被虚拟机(ID: ${record.vm_id})使用,无法删除!`,
|
||||
okText: '确定'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除网络 "${record.name}" 吗?此操作不可恢复!`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await networkApi.deleteNetwork({ network_id: record.id })
|
||||
message.success('删除成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-list-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.edit-network-steps {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,935 @@
|
||||
<template>
|
||||
<div class="port-group-list-container">
|
||||
<a-card class="glass-card">
|
||||
<template #title>
|
||||
<span>安全组列表</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<plus-outlined />
|
||||
创建安全组
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<div class="search-bar">
|
||||
<a-input-search
|
||||
v-model:value="searchKey"
|
||||
placeholder="搜索安全组名称"
|
||||
style="width: 300px"
|
||||
@search="handleSearch"
|
||||
allow-clear
|
||||
/>
|
||||
<a-button @click="handleRefresh">
|
||||
<reload-outlined />
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="portGroupList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'direction'">
|
||||
<a-tag :color="record.direction === 'in' ? 'blue' : record.direction === 'out' ? 'green' : 'default'">
|
||||
{{ record.direction === 'in' ? '入站' : record.direction === 'out' ? '出站' : '未指定' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'lock'">
|
||||
<a-tag :color="record.lock ? 'red' : 'green'">
|
||||
{{ record.lock ? '锁定' : '未锁定' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'drop_all'">
|
||||
<a-space>
|
||||
<a-tag :color="record.drop_all ? 'red' : 'blue'">
|
||||
{{ record.drop_all ? '全部拒绝' : '允许' }}
|
||||
</a-tag>
|
||||
<a-switch
|
||||
:checked="record.drop_all"
|
||||
checked-children="白名单"
|
||||
un-checked-children="普通模式"
|
||||
size="small"
|
||||
@change="(checked) => handleToggleWhiteList(record, checked)"
|
||||
/>
|
||||
</a-space>
|
||||
</template>
|
||||
<!-- <template v-if="column.key === 'rule_count'">
|
||||
{{ record.rule_count || 0 }}
|
||||
</template>
|
||||
<template v-if="column.key === 'vm_count'">
|
||||
{{ record.vm_count || 0 }}
|
||||
</template> -->
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">
|
||||
查看
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleManageRules(record)">
|
||||
管理规则
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleBind(record)">
|
||||
绑定
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleApply(record)"
|
||||
:disabled="!record.rule_count && !record.vm_count"
|
||||
>
|
||||
应用变更
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
@click="handleDelete(record)"
|
||||
:disabled="record.lock"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 创建安全组弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="createModalVisible"
|
||||
title="创建安全组"
|
||||
:confirm-loading="createSubmitting"
|
||||
ok-text="创建"
|
||||
@ok="submitCreate"
|
||||
@cancel="resetCreateForm"
|
||||
>
|
||||
<a-form ref="createFormRef" :model="createForm" layout="vertical">
|
||||
<a-form-item
|
||||
label="ID"
|
||||
name="id"
|
||||
:rules="[
|
||||
{ required: true, message: '请输入安全组 ID' },
|
||||
{ type: 'number', min: 1, message: 'ID 必须为正整数' }
|
||||
]"
|
||||
>
|
||||
<a-input-number
|
||||
v-model:value="createForm.id"
|
||||
placeholder="请输入正整数,由您指定安全组 ID"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="名称" name="name" :rules="[{ required: true, message: '请输入安全组名称' }]">
|
||||
<a-input v-model:value="createForm.name" placeholder="请输入安全组名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="方向" name="direction">
|
||||
<a-select v-model:value="createForm.direction" placeholder="可选,默认不指定">
|
||||
<a-select-option value="in">入站 (in)</a-select-option>
|
||||
<a-select-option value="out">出站 (out)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="锁定" name="lock">
|
||||
<a-switch v-model:checked="createForm.lock" />
|
||||
<span class="form-hint">锁定后不可随意修改</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="默认策略" name="drop_all">
|
||||
<a-switch v-model:checked="createForm.drop_all" checked-children="全部拒绝" un-checked-children="允许" />
|
||||
<span class="form-hint">白名单模式下未匹配规则时的默认行为</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 规则管理抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="ruleDrawerVisible"
|
||||
:title="`安全组规则管理 - ${currentPortGroup?.name || ''}`"
|
||||
placement="right"
|
||||
width="720"
|
||||
>
|
||||
<a-space direction="vertical" style="width: 100%">
|
||||
<a-alert
|
||||
message="提示"
|
||||
type="info"
|
||||
show-icon
|
||||
description="此处可以对当前安全组的规则进行增删改操作,修改后需要点击应用变更才能真正生效。"
|
||||
/>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>当前安全组ID: {{ currentPortGroup?.id }}</span>
|
||||
<a-space>
|
||||
<a-button type="dashed" @click="openCreateRule">
|
||||
新增规则
|
||||
</a-button>
|
||||
<a-button type="primary" @click="handleApply(currentPortGroup)" :loading="applying">
|
||||
应用变更
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<a-table
|
||||
:columns="ruleColumns"
|
||||
:data-source="ruleList"
|
||||
:loading="ruleLoading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'direction'">
|
||||
<a-tag :color="record.direction === 'in' ? 'blue' : 'green'">
|
||||
{{ record.direction === 'in' ? '入站' : '出站' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'protocol'">
|
||||
<a-tag>{{ record.protocol.toUpperCase() }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-tag :color="record.action === 'allow' ? 'green' : 'red'">
|
||||
{{ record.action === 'allow' ? '允许' : '拒绝' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'priority'">
|
||||
<a-input-number
|
||||
v-model:value="record.priority"
|
||||
size="small"
|
||||
:min="1"
|
||||
:max="100"
|
||||
style="width: 80px"
|
||||
@change="(value) => handlePriorityChange(record, value)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="openEditRule(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
@click="handleDeleteRule(record)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-space>
|
||||
|
||||
<a-modal
|
||||
v-model:open="ruleModalVisible"
|
||||
:title="ruleModalMode === 'create' ? '新增规则' : '编辑规则'"
|
||||
:ok-text="ruleModalMode === 'create' ? '创建' : '保存'"
|
||||
@ok="submitRule"
|
||||
:confirm-loading="ruleSubmitting"
|
||||
>
|
||||
<a-form ref="ruleFormRef" :model="ruleForm" layout="vertical">
|
||||
<a-form-item
|
||||
label="方向"
|
||||
name="direction"
|
||||
:rules="[{ required: true, message: '请选择方向' }]"
|
||||
>
|
||||
<a-select v-model:value="ruleForm.direction" placeholder="请选择方向">
|
||||
<a-select-option value="in">入站 (in)</a-select-option>
|
||||
<a-select-option value="out">出站 (out)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="协议"
|
||||
name="protocol"
|
||||
:rules="[{ required: true, message: '请选择协议' }]"
|
||||
>
|
||||
<a-select v-model:value="ruleForm.protocol" placeholder="请选择协议">
|
||||
<a-select-option value="tcp">TCP</a-select-option>
|
||||
<a-select-option value="udp">UDP</a-select-option>
|
||||
<a-select-option value="icmp">ICMP</a-select-option>
|
||||
<a-select-option value="all">ALL</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="端口范围"
|
||||
name="port_range"
|
||||
:rules="[{ required: true, message: '请输入端口范围' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="ruleForm.port_range"
|
||||
placeholder="例如: 22 或 80-100, 空表示所有端口"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="IP范围"
|
||||
name="ip_range"
|
||||
:rules="[{ required: true, message: '请输入IP范围' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="ruleForm.ip_range"
|
||||
placeholder="例如: 0.0.0.0/0 或 192.168.1.0/24, 空表示所有IP"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="优先级"
|
||||
name="priority"
|
||||
:rules="[{ required: true, message: '请输入优先级', type: 'number' }]"
|
||||
>
|
||||
<a-input-number
|
||||
v-model:value="ruleForm.priority"
|
||||
:min="1"
|
||||
:max="100"
|
||||
style="width: 100%"
|
||||
placeholder="数字越小优先级越高"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="动作"
|
||||
name="action"
|
||||
:rules="[{ required: true, message: '请选择动作' }]"
|
||||
>
|
||||
<a-select v-model:value="ruleForm.action" placeholder="请选择动作">
|
||||
<a-select-option value="allow">允许 (allow)</a-select-option>
|
||||
<a-select-option value="deny">拒绝 (deny)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 绑定安全组弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="bindModalVisible"
|
||||
title="绑定安全组到虚拟机"
|
||||
@ok="submitBind"
|
||||
:confirm-loading="bindSubmitting"
|
||||
>
|
||||
<a-form ref="bindFormRef" :model="bindForm" layout="vertical">
|
||||
<a-form-item label="安全组ID">
|
||||
<a-input v-model:value="bindForm.port_group_id" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="虚拟机ID"
|
||||
name="vm_id"
|
||||
:rules="[{ required: true, message: '请输入虚拟机ID' }]"
|
||||
>
|
||||
<a-input-number
|
||||
v-model:value="bindForm.vm_id"
|
||||
placeholder="请输入虚拟机ID"
|
||||
:min="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
getPortGroupList,
|
||||
getPortGroupDetail,
|
||||
createPortGroup,
|
||||
bindPortGroup,
|
||||
unbindPortGroup,
|
||||
deletePortGroup,
|
||||
enableWhiteList,
|
||||
disableWhiteList,
|
||||
createPortGroupRule,
|
||||
getPortGroupRuleList,
|
||||
deletePortGroupRule,
|
||||
updatePortGroupRule,
|
||||
applyPortGroup
|
||||
} from '@/api/portGroupApi'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
|
||||
const searchKey = ref('')
|
||||
const portGroupList = ref([])
|
||||
const loading = ref(false)
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{ title: '方向', dataIndex: 'direction', key: 'direction', width: 100 },
|
||||
{ title: '锁定状态', key: 'lock', width: 100 },
|
||||
{ title: '默认策略', key: 'drop_all', width: 120 },
|
||||
// { title: '规则数量', key: 'rule_count', width: 100 },
|
||||
// { title: '绑定虚拟机', key: 'vm_count', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 350, fixed: 'right' }
|
||||
]
|
||||
|
||||
// 规则管理相关
|
||||
const ruleDrawerVisible = ref(false)
|
||||
const currentPortGroup = ref(null)
|
||||
const ruleLoading = ref(false)
|
||||
const ruleList = ref([])
|
||||
const ruleSubmitting = ref(false)
|
||||
const ruleFormRef = ref(null)
|
||||
|
||||
const ruleColumns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
|
||||
{ title: '方向', dataIndex: 'direction', key: 'direction', width: 80 },
|
||||
{ title: '协议', dataIndex: 'protocol', key: 'protocol', width: 80 },
|
||||
{ title: '端口范围', dataIndex: 'port_range', key: 'port_range', width: 120 },
|
||||
{ title: 'IP范围', dataIndex: 'ip_range', key: 'ip_range', width: 150 },
|
||||
{ title: '优先级', dataIndex: 'priority', key: 'priority', width: 100 },
|
||||
{ title: '动作', dataIndex: 'action', key: 'action', width: 80 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 120, fixed: 'right' }
|
||||
]
|
||||
|
||||
const ruleModalVisible = ref(false)
|
||||
const ruleModalMode = ref('create') // 'create' | 'edit'
|
||||
const editingRuleId = ref(null)
|
||||
const ruleForm = ref({
|
||||
direction: 'in',
|
||||
protocol: 'tcp',
|
||||
port_range: '',
|
||||
ip_range: '',
|
||||
priority: 1,
|
||||
action: 'allow'
|
||||
})
|
||||
|
||||
// 创建安全组相关
|
||||
const createModalVisible = ref(false)
|
||||
const createFormRef = ref(null)
|
||||
const createSubmitting = ref(false)
|
||||
const createForm = ref({
|
||||
id: undefined,
|
||||
name: '',
|
||||
direction: undefined,
|
||||
lock: false,
|
||||
drop_all: false
|
||||
})
|
||||
|
||||
// 绑定相关
|
||||
const bindModalVisible = ref(false)
|
||||
const bindFormRef = ref(null)
|
||||
const bindSubmitting = ref(false)
|
||||
const bindForm = ref({
|
||||
port_group_id: null,
|
||||
vm_id: null
|
||||
})
|
||||
|
||||
// 应用变更相关
|
||||
const applying = ref(false)
|
||||
|
||||
// 规则ID自增计数器
|
||||
const ruleIdCounter = ref(1)
|
||||
|
||||
// 获取安全组列表
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.value.current,
|
||||
count: pagination.value.pageSize,
|
||||
key: searchKey.value || undefined
|
||||
}
|
||||
const response = await getPortGroupList(params)
|
||||
|
||||
if (response.code === 200) {
|
||||
// 处理API返回的数据格式
|
||||
const responseData = response.data || {}
|
||||
portGroupList.value = responseData.data || []
|
||||
pagination.value.total = responseData.count || portGroupList.value.length
|
||||
} else {
|
||||
message.error(response.message || '获取列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取列表失败:', error)
|
||||
message.error('获取列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.value.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 表格分页变化
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.value.current = pag.current
|
||||
pagination.value.pageSize = pag.pageSize
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 创建安全组
|
||||
const handleCreate = () => {
|
||||
resetCreateForm()
|
||||
createModalVisible.value = true
|
||||
}
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.value = {
|
||||
id: undefined,
|
||||
name: '',
|
||||
direction: undefined,
|
||||
lock: false,
|
||||
drop_all: false
|
||||
}
|
||||
createFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const submitCreate = async () => {
|
||||
try {
|
||||
await createFormRef.value?.validate()
|
||||
} catch (e) {
|
||||
console.error('表单验证失败:', e)
|
||||
return
|
||||
}
|
||||
|
||||
const id = Number(createForm.value.id)
|
||||
if (!Number.isInteger(id) || id < 1) {
|
||||
message.warning('ID 必须为正整数')
|
||||
return
|
||||
}
|
||||
|
||||
createSubmitting.value = true
|
||||
try {
|
||||
const body = {
|
||||
id,
|
||||
name: createForm.value.name.trim(),
|
||||
lock: !!createForm.value.lock,
|
||||
drop_all: !!createForm.value.drop_all
|
||||
}
|
||||
|
||||
if (createForm.value.direction) {
|
||||
body.direction = createForm.value.direction
|
||||
}
|
||||
|
||||
const response = await createPortGroup(body)
|
||||
if (response.code === 200) {
|
||||
message.success('创建成功')
|
||||
createModalVisible.value = false
|
||||
resetCreateForm()
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(response.message || '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建失败:', error)
|
||||
message.error(error?.response?.data?.message || error?.message || '创建失败')
|
||||
} finally {
|
||||
createSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleView = async (record) => {
|
||||
try {
|
||||
const response = await getPortGroupDetail({ port_group_id: record.id })
|
||||
if (response.code === 200) {
|
||||
const detail = response.data
|
||||
Modal.info({
|
||||
title: '安全组详情',
|
||||
width: 600,
|
||||
content: `
|
||||
<div style="line-height: 1.8">
|
||||
<p><strong>ID:</strong>${detail.port_group?.id}</p>
|
||||
<p><strong>名称:</strong>${detail.port_group?.name}</p>
|
||||
<p><strong>方向:</strong>${detail.port_group?.direction || '未指定'}</p>
|
||||
<p><strong>锁定状态:</strong>${detail.port_group?.lock ? '已锁定' : '未锁定'}</p>
|
||||
<p><strong>默认策略:</strong>${detail.port_group?.drop_all ? '全部拒绝(白名单模式)' : '允许(普通模式)'}</p>
|
||||
<p><strong>创建时间:</strong>${detail.port_group?.created_at}</p>
|
||||
<p><strong>更新时间:</strong>${detail.port_group?.updated_at}</p>
|
||||
<p><strong>规则数量:</strong>${detail.port_group_rules?.length || 0}</p>
|
||||
${detail.port_group_rules?.length > 0 ? `
|
||||
<p><strong>规则列表:</strong></p>
|
||||
<div style="max-height: 300px; overflow-y: auto; border: 1px solid #d9d9d9; padding: 10px; border-radius: 4px;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: #fafafa;">
|
||||
<th style="padding: 8px; border: 1px solid #d9d9d9; text-align: left;">方向</th>
|
||||
<th style="padding: 8px; border: 1px solid #d9d9d9; text-align: left;">协议</th>
|
||||
<th style="padding: 8px; border: 1px solid #d9d9d9; text-align: left;">端口范围</th>
|
||||
<th style="padding: 8px; border: 1px solid #d9d9d9; text-align: left;">IP范围</th>
|
||||
<th style="padding: 8px; border: 1px solid #d9d9d9; text-align: left;">动作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${detail.port_group_rules.map(rule => `
|
||||
<tr>
|
||||
<td style="padding: 8px; border: 1px solid #d9d9d9;">${rule.direction === 'in' ? '入站' : '出站'}</td>
|
||||
<td style="padding: 8px; border: 1px solid #d9d9d9;">${rule.protocol.toUpperCase()}</td>
|
||||
<td style="padding: 8px; border: 1px solid #d9d9d9;">${rule.port_range || '全部'}</td>
|
||||
<td style="padding: 8px; border: 1px solid #d9d9d9;">${rule.ip_range || '全部'}</td>
|
||||
<td style="padding: 8px; border: 1px solid #d9d9d9;">
|
||||
<span style="color: ${rule.action === 'allow' ? 'green' : 'red'}">
|
||||
${rule.action === 'allow' ? '允许' : '拒绝'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
} else {
|
||||
message.error(response.message || '获取详情失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
message.error('获取详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 管理规则
|
||||
const handleManageRules = (record) => {
|
||||
currentPortGroup.value = record
|
||||
ruleDrawerVisible.value = true
|
||||
fetchRuleList()
|
||||
}
|
||||
|
||||
// 绑定安全组
|
||||
const handleBind = (record) => {
|
||||
bindForm.value = {
|
||||
port_group_id: record.id,
|
||||
vm_id: null
|
||||
}
|
||||
bindModalVisible.value = true
|
||||
}
|
||||
|
||||
const submitBind = async () => {
|
||||
try {
|
||||
await bindFormRef.value?.validate()
|
||||
} catch (e) {
|
||||
console.error('表单验证失败:', e)
|
||||
return
|
||||
}
|
||||
|
||||
bindSubmitting.value = true
|
||||
try {
|
||||
const params = {
|
||||
vm_id: bindForm.value.vm_id,
|
||||
port_group_id: bindForm.value.port_group_id
|
||||
}
|
||||
|
||||
const response = await bindPortGroup(params)
|
||||
if (response.code === 200) {
|
||||
message.success('绑定成功')
|
||||
bindModalVisible.value = false
|
||||
bindForm.value = { port_group_id: null, vm_id: null }
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(response.message || '绑定失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('绑定失败:', error)
|
||||
message.error(error?.response?.data?.message || '绑定失败')
|
||||
} finally {
|
||||
bindSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除安全组
|
||||
const handleDelete = async (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除安全组 "${record.name}" (ID: ${record.id}) 吗?此操作不可恢复!`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
try {
|
||||
const response = await deletePortGroup({ port_group_id: record.id })
|
||||
if (response.code === 200) {
|
||||
message.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(response.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 切换白名单模式
|
||||
const handleToggleWhiteList = (record, checked) => {
|
||||
Modal.confirm({
|
||||
title: checked ? '开启白名单模式' : '关闭白名单模式',
|
||||
content: `确定要对安全组 "${record.name}" ${checked ? '开启' : '关闭'} 白名单模式吗?`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
try {
|
||||
const params = { port_group_id: record.id }
|
||||
let response
|
||||
if (checked) {
|
||||
response = await enableWhiteList(params)
|
||||
} else {
|
||||
response = await disableWhiteList(params)
|
||||
}
|
||||
|
||||
if (response.code === 200) {
|
||||
message.success('操作成功')
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(response.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换白名单模式失败:', error)
|
||||
message.error('切换白名单模式失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 应用安全组修改
|
||||
const handleApply = async (record) => {
|
||||
Modal.confirm({
|
||||
title: '应用安全组变更',
|
||||
content: `确定要将安全组 "${record.name}" 的变更应用到所有绑定的虚拟机上吗?`,
|
||||
okText: '应用',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
applying.value = true
|
||||
try {
|
||||
const response = await applyPortGroup({ port_group_id: record.id })
|
||||
if (response.code === 200) {
|
||||
message.success('已触发应用变更')
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(response.message || '应用变更失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('应用安全组变更失败:', error)
|
||||
message.error('应用安全组变更失败')
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取规则列表
|
||||
const fetchRuleList = async () => {
|
||||
if (!currentPortGroup.value) return
|
||||
ruleLoading.value = true
|
||||
try {
|
||||
const params = {
|
||||
port_group_id: currentPortGroup.value.id,
|
||||
page: 1,
|
||||
count: 100
|
||||
}
|
||||
const response = await getPortGroupRuleList(params)
|
||||
if (response.code === 200) {
|
||||
ruleList.value = response.data || []
|
||||
} else {
|
||||
message.error(response.message || '获取规则列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取规则列表失败:', error)
|
||||
message.error('获取规则列表失败')
|
||||
} finally {
|
||||
ruleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetRuleForm = () => {
|
||||
ruleForm.value = {
|
||||
direction: 'in',
|
||||
protocol: 'tcp',
|
||||
port_range: '',
|
||||
ip_range: '',
|
||||
priority: 1,
|
||||
action: 'allow'
|
||||
}
|
||||
editingRuleId.value = null
|
||||
ruleFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const openCreateRule = () => {
|
||||
resetRuleForm()
|
||||
ruleModalMode.value = 'create'
|
||||
ruleModalVisible.value = true
|
||||
}
|
||||
|
||||
const openEditRule = (record) => {
|
||||
ruleModalMode.value = 'edit'
|
||||
editingRuleId.value = record.id
|
||||
ruleForm.value = {
|
||||
direction: record.direction,
|
||||
protocol: record.protocol,
|
||||
port_range: record.port_range,
|
||||
ip_range: record.ip_range,
|
||||
priority: record.priority,
|
||||
action: record.action
|
||||
}
|
||||
ruleModalVisible.value = true
|
||||
}
|
||||
|
||||
const submitRule = async () => {
|
||||
try {
|
||||
await ruleFormRef.value?.validate()
|
||||
} catch (e) {
|
||||
console.error('表单验证失败:', e)
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentPortGroup.value) {
|
||||
message.error('未选择安全组')
|
||||
return
|
||||
}
|
||||
|
||||
ruleSubmitting.value = true
|
||||
try {
|
||||
// 创建请求数据,移除direction字段以符合后端API要求
|
||||
const { direction, ...ruleData } = ruleForm.value
|
||||
const formData = {
|
||||
...ruleData,
|
||||
port_group_id: currentPortGroup.value.id,
|
||||
id: ruleModalMode.value === 'create' ? ruleIdCounter.value : editingRuleId.value // 创建时使用自增ID,编辑时使用原ID
|
||||
}
|
||||
|
||||
let response
|
||||
if (ruleModalMode.value === 'create') {
|
||||
response = await createPortGroupRule(formData)
|
||||
} else {
|
||||
response = await updatePortGroupRule(formData)
|
||||
}
|
||||
|
||||
if (response.code === 200) {
|
||||
message.success(ruleModalMode.value === 'create' ? '创建规则成功' : '更新规则成功')
|
||||
if (ruleModalMode.value === 'create') {
|
||||
ruleIdCounter.value++ // 自增计数器
|
||||
}
|
||||
ruleModalVisible.value = false
|
||||
resetRuleForm()
|
||||
fetchRuleList()
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(response.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存规则失败:', error)
|
||||
message.error(error?.response?.data?.message || '保存规则失败')
|
||||
} finally {
|
||||
ruleSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteRule = (record) => {
|
||||
if (!currentPortGroup.value) return
|
||||
Modal.confirm({
|
||||
title: '确认删除规则',
|
||||
content: `确定要删除该规则吗?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
try {
|
||||
const response = await deletePortGroupRule({ rule_id: record.id })
|
||||
if (response.code === 200) {
|
||||
message.success('删除规则成功')
|
||||
fetchRuleList()
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(response.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除规则失败:', error)
|
||||
message.error('删除规则失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 修改优先级
|
||||
const handlePriorityChange = async (record, value) => {
|
||||
try {
|
||||
const formData = {
|
||||
id: record.id,
|
||||
port_group_id: currentPortGroup.value.id,
|
||||
direction: record.direction,
|
||||
protocol: record.protocol,
|
||||
port_range: record.port_range,
|
||||
ip_range: record.ip_range,
|
||||
priority: value,
|
||||
action: record.action
|
||||
}
|
||||
|
||||
const response = await updatePortGroupRule(formData)
|
||||
if (response.code === 200) {
|
||||
message.success('优先级更新成功')
|
||||
fetchRuleList()
|
||||
} else {
|
||||
message.error(response.message || '更新失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新优先级失败:', error)
|
||||
message.error('更新优先级失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.port-group-list-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 移除局部覆盖,使用全局样式 */
|
||||
/* .glass-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
} */
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-left: 8px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 移除局部覆盖,使用全局样式 */
|
||||
/* :deep(.ant-table) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
} */
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="task-list-container">
|
||||
<a-card class="glass-card">
|
||||
<template #title>
|
||||
<span>任务列表</span>
|
||||
</template>
|
||||
|
||||
<div class="search-bar">
|
||||
<a-input-search
|
||||
v-model:value="searchTaskId"
|
||||
placeholder="输入任务ID"
|
||||
style="width: 260px"
|
||||
@search="handleSearch"
|
||||
allow-clear
|
||||
/>
|
||||
<a-button @click="handleRefresh">
|
||||
<reload-outlined />
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="taskRows"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="task_id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">
|
||||
查看日志
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.status === 'pending' || record.status === 'running'"
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
@click="handleCancel(record)"
|
||||
>
|
||||
取消任务
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-drawer
|
||||
v-model:open="logDrawerVisible"
|
||||
title="任务日志"
|
||||
placement="right"
|
||||
width="480"
|
||||
>
|
||||
<a-skeleton v-if="logLoading" active :paragraph="{ rows: 8 }" />
|
||||
<div v-else class="log-content">
|
||||
<pre v-if="logs.length" class="log-text">
|
||||
{{ logs.join('\n') }}
|
||||
</pre>
|
||||
<a-empty v-else description="暂无日志" />
|
||||
</div>
|
||||
</a-drawer>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import * as taskApi from '@/api/taskApi'
|
||||
|
||||
const searchTaskId = ref('')
|
||||
const taskRows = ref([])
|
||||
const loading = ref(false)
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const logDrawerVisible = ref(false)
|
||||
const logLoading = ref(false)
|
||||
const logs = ref([])
|
||||
const currentTaskId = ref('')
|
||||
|
||||
const columns = [
|
||||
{ title: '任务ID', dataIndex: 'task_id', key: 'task_id' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 120 },
|
||||
{ title: '进度(%)', dataIndex: 'progress', key: 'progress', width: 120 },
|
||||
{ title: '操作', key: 'actions', width: 200, fixed: 'right' }
|
||||
]
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const map = {
|
||||
pending: 'default',
|
||||
running: 'blue',
|
||||
success: 'green',
|
||||
failed: 'red'
|
||||
}
|
||||
return map[status] || 'default'
|
||||
}
|
||||
|
||||
const fetchTask = async () => {
|
||||
if (!searchTaskId.value) {
|
||||
taskRows.value = []
|
||||
pagination.value.total = 0
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await taskApi.getTaskStatus(searchTaskId.value)
|
||||
// 接口返回单个任务对象,包装成表格用的数组
|
||||
if (data) {
|
||||
taskRows.value = [data]
|
||||
pagination.value.total = 1
|
||||
} else {
|
||||
taskRows.value = []
|
||||
pagination.value.total = 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取任务状态失败:', error)
|
||||
taskRows.value = []
|
||||
pagination.value.total = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.value.current = 1
|
||||
fetchTask()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchTask()
|
||||
}
|
||||
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.value.current = pag.current
|
||||
pagination.value.pageSize = pag.pageSize
|
||||
}
|
||||
|
||||
const handleView = async (record) => {
|
||||
currentTaskId.value = record.task_id
|
||||
logDrawerVisible.value = true
|
||||
logLoading.value = true
|
||||
logs.value = []
|
||||
|
||||
try {
|
||||
const data = await taskApi.getTaskLogs(record.task_id)
|
||||
const logArray = Array.isArray(data.logs) ? data.logs : []
|
||||
logs.value = logArray
|
||||
} catch (error) {
|
||||
console.error('获取任务日志失败:', error)
|
||||
} finally {
|
||||
logLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认取消任务',
|
||||
content: `确定要取消任务 ${record.task_id} 吗?`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await taskApi.cancelTask(record.task_id, false)
|
||||
message.success('已提交取消请求')
|
||||
fetchTask()
|
||||
} catch (error) {
|
||||
console.error('取消任务失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-list-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.log-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: transparent;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
<template>
|
||||
<div class="variable-list-container">
|
||||
<a-card class="glass-card">
|
||||
<template #title>
|
||||
<span>变量列表</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<plus-outlined />
|
||||
创建变量
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<div class="search-bar">
|
||||
<a-input-search
|
||||
v-model:value="searchKey"
|
||||
placeholder="搜索变量名称或描述"
|
||||
style="width: 300px"
|
||||
@search="handleSearch"
|
||||
allow-clear
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filterType"
|
||||
placeholder="变量类型"
|
||||
style="width: 160px"
|
||||
allow-clear
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option value="string">字符串</a-select-option>
|
||||
<a-select-option value="int">整数</a-select-option>
|
||||
<a-select-option value="float">小数</a-select-option>
|
||||
<a-select-option value="bool">布尔</a-select-option>
|
||||
<a-select-option value="json">JSON</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="handleRefresh">
|
||||
<reload-outlined />
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="variableList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="name"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
{{ TYPE_LABELS[record.type] ?? record.type }}
|
||||
</template>
|
||||
<template v-if="column.key === 'value'">
|
||||
<span class="value-text">
|
||||
{{ formatValuePreview(record.value) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">
|
||||
查看
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleSetValue(record)">
|
||||
修改值
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 查看/编辑变量详情 -->
|
||||
<a-modal
|
||||
v-model:open="detailVisible"
|
||||
:title="detailMode === 'view' ? '变量详情' : '编辑变量'"
|
||||
@ok="handleDetailOk"
|
||||
:ok-text="detailMode === 'view' ? '关闭' : '保存'"
|
||||
>
|
||||
<a-form
|
||||
v-if="currentDetail"
|
||||
:model="detailForm"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="变量名称" required>
|
||||
<a-input v-model:value="detailForm.name" :disabled="detailMode === 'view'" />
|
||||
</a-form-item>
|
||||
<a-form-item label="变量类型">
|
||||
<a-select v-model:value="detailForm.type" :disabled="detailMode === 'view'">
|
||||
<a-select-option value="string">字符串</a-select-option>
|
||||
<a-select-option value="int">整数</a-select-option>
|
||||
<a-select-option value="float">小数</a-select-option>
|
||||
<a-select-option value="bool">布尔</a-select-option>
|
||||
<a-select-option value="json">JSON</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-input
|
||||
v-model:value="detailForm.description"
|
||||
:disabled="detailMode === 'view'"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="变量值">
|
||||
<a-textarea
|
||||
v-model:value="detailForm.value"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
:disabled="detailMode === 'view'"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-skeleton v-else active :paragraph="{ rows: 6 }" />
|
||||
</a-modal>
|
||||
|
||||
<!-- 创建变量 -->
|
||||
<a-modal
|
||||
v-model:open="createVisible"
|
||||
title="创建变量"
|
||||
ok-text="创建"
|
||||
@ok="submitCreate"
|
||||
>
|
||||
<a-form :model="createForm" layout="vertical">
|
||||
<a-form-item label="变量名称" required>
|
||||
<a-input v-model:value="createForm.name" placeholder="变量唯一名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="变量类型">
|
||||
<a-select v-model:value="createForm.type" placeholder="选择变量类型">
|
||||
<a-select-option value="string">字符串</a-select-option>
|
||||
<a-select-option value="int">整数</a-select-option>
|
||||
<a-select-option value="float">小数</a-select-option>
|
||||
<a-select-option value="bool">布尔</a-select-option>
|
||||
<a-select-option value="json">JSON</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-input v-model:value="createForm.description" />
|
||||
</a-form-item>
|
||||
<a-form-item label="变量值">
|
||||
<a-textarea
|
||||
v-model:value="createForm.value"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 修改变量值 -->
|
||||
<a-modal
|
||||
v-model:open="setValueVisible"
|
||||
title="修改变量值"
|
||||
ok-text="保存"
|
||||
@ok="submitSetValue"
|
||||
>
|
||||
<a-form :model="setValueForm" layout="vertical">
|
||||
<a-form-item label="变量名称">
|
||||
<a-input v-model:value="setValueForm.name" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="变量值">
|
||||
<a-textarea
|
||||
v-model:value="setValueForm.value"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import * as variableApi from '@/api/variableApi'
|
||||
|
||||
const searchKey = ref('')
|
||||
const filterType = ref()
|
||||
const variableList = ref([])
|
||||
const loading = ref(false)
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const TYPE_LABELS = {
|
||||
string: '字符串',
|
||||
int: '整数',
|
||||
float: '小数',
|
||||
bool: '布尔',
|
||||
json: 'JSON'
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 120 },
|
||||
{ title: '描述', dataIndex: 'description', key: 'description' },
|
||||
{ title: '当前值', dataIndex: 'value', key: 'value', width: 200 },
|
||||
{ title: '操作', key: 'actions', width: 260, fixed: 'right' }
|
||||
]
|
||||
|
||||
const formatValuePreview = (value) => {
|
||||
if (value == null) return ''
|
||||
const str = String(value)
|
||||
return str.length > 32 ? str.slice(0, 32) + '...' : str
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.value.current,
|
||||
count: pagination.value.pageSize,
|
||||
key: searchKey.value || undefined,
|
||||
type: filterType.value || undefined
|
||||
}
|
||||
const response = await variableApi.getVariableList(params)
|
||||
// 处理API返回的数据格式
|
||||
const responseData = response.data || response
|
||||
variableList.value = responseData.data || []
|
||||
pagination.value.total = responseData.count || 0
|
||||
} catch (error) {
|
||||
console.error('获取变量列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.value.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.value.current = pag.current
|
||||
pagination.value.pageSize = pag.pageSize
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 创建变量
|
||||
const createVisible = ref(false)
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
value: '',
|
||||
description: '',
|
||||
type: 'string'
|
||||
})
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.name = ''
|
||||
createForm.value = ''
|
||||
createForm.description = ''
|
||||
createForm.type = 'string'
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
resetCreateForm()
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!createForm.name.trim()) {
|
||||
message.warning('变量名称不能为空')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await variableApi.createVariable({ ...createForm })
|
||||
if (response.code === 200) {
|
||||
message.success('创建成功')
|
||||
createVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(response.message || '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建变量失败:', error)
|
||||
message.error('创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看/编辑详情
|
||||
const detailVisible = ref(false)
|
||||
const detailMode = ref('view') // 'view' | 'edit'
|
||||
const currentDetail = ref(null)
|
||||
const detailForm = reactive({
|
||||
id: undefined,
|
||||
name: '',
|
||||
value: '',
|
||||
description: '',
|
||||
type: ''
|
||||
})
|
||||
|
||||
const loadDetail = async (name) => {
|
||||
currentDetail.value = null
|
||||
try {
|
||||
const response = await variableApi.getVariableDetail({ name })
|
||||
// 处理API返回的数据格式
|
||||
const detail = response.data?.data ?? response.data ?? response
|
||||
currentDetail.value = detail
|
||||
detailForm.id = detail.id
|
||||
detailForm.name = detail.name ?? ''
|
||||
detailForm.value = detail.value ?? ''
|
||||
detailForm.description = detail.description ?? ''
|
||||
detailForm.type = detail.type ?? 'string'
|
||||
} catch (error) {
|
||||
console.error('获取变量详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleView = async (record) => {
|
||||
detailMode.value = 'view'
|
||||
detailVisible.value = true
|
||||
await loadDetail(record.name)
|
||||
}
|
||||
|
||||
const handleEdit = async (record) => {
|
||||
detailMode.value = 'edit'
|
||||
detailVisible.value = true
|
||||
await loadDetail(record.name)
|
||||
}
|
||||
|
||||
const handleDetailOk = () => {
|
||||
if (detailMode.value === 'view') {
|
||||
detailVisible.value = false
|
||||
return
|
||||
}
|
||||
return submitEdit()
|
||||
}
|
||||
|
||||
const submitEdit = async () => {
|
||||
try {
|
||||
const response = await variableApi.updateVariable({ ...detailForm })
|
||||
if (response.code === 200) {
|
||||
message.success('更新成功')
|
||||
detailVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(response.message || '更新失败')
|
||||
throw new Error(response.message || '更新失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新变量失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 修改变量值
|
||||
const setValueVisible = ref(false)
|
||||
const setValueForm = reactive({
|
||||
name: '',
|
||||
value: ''
|
||||
})
|
||||
|
||||
const handleSetValue = async (record) => {
|
||||
setValueForm.name = record.name
|
||||
try {
|
||||
const response = await variableApi.getVariableValue({ name: record.name })
|
||||
// 处理API返回的数据格式
|
||||
const data = response.data?.data ?? response.data ?? response
|
||||
setValueForm.value = data.value ?? ''
|
||||
} catch (error) {
|
||||
console.error('获取变量值失败:', error)
|
||||
setValueForm.value = ''
|
||||
}
|
||||
setValueVisible.value = true
|
||||
}
|
||||
|
||||
const submitSetValue = async () => {
|
||||
try {
|
||||
const response = await variableApi.setVariableValue({
|
||||
name: setValueForm.name,
|
||||
value: setValueForm.value
|
||||
})
|
||||
if (response.code === 200) {
|
||||
message.success('更新成功')
|
||||
setValueVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
message.error(response.message || '更新失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新变量值失败:', error)
|
||||
message.error('更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.variable-list-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.value-text {
|
||||
max-width: 180px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
<template>
|
||||
<div class="vm-create-container">
|
||||
<a-card class="glass-card">
|
||||
<template #title>
|
||||
<span>创建虚拟机</span>
|
||||
</template>
|
||||
|
||||
<a-steps :current="currentStep" class="vm-steps">
|
||||
<a-step title="基础与硬件" />
|
||||
<a-step title="访问与安全" />
|
||||
<a-step title="网络配置" />
|
||||
<a-step title="高级性能" />
|
||||
</a-steps>
|
||||
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 18 }"
|
||||
>
|
||||
<template v-if="currentStep === 0">
|
||||
<a-divider orientation="left">基础信息</a-divider>
|
||||
<a-form-item label="虚拟机名称" name="name">
|
||||
<a-input v-model:value="form.name" placeholder="请输入虚拟机名称(不能重复)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="UUID" name="uuid">
|
||||
<a-input v-model:value="form.uuid" placeholder="留空则自动生成" />
|
||||
</a-form-item>
|
||||
<a-form-item label="镜像" name="image_id">
|
||||
<a-select
|
||||
v-model:value="form.image_id"
|
||||
placeholder="请选择镜像"
|
||||
:loading="imageLoading"
|
||||
show-search
|
||||
:filter-option="filterOption"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="image in imageList"
|
||||
:key="image.id"
|
||||
:value="image.id"
|
||||
>
|
||||
{{ image.name }} (ID: {{ image.id }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">硬件配置</a-divider>
|
||||
<a-form-item label="CPU核心数" name="vcpu">
|
||||
<a-input-number
|
||||
v-model:value="form.vcpu"
|
||||
:min="1"
|
||||
:max="32"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="内存(MB)" name="memory">
|
||||
<a-input-number
|
||||
v-model:value="form.memory"
|
||||
:min="512"
|
||||
:max="65536"
|
||||
:step="512"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="系统盘大小(GB)" name="system_size">
|
||||
<a-input-number
|
||||
v-model:value="form.system_size"
|
||||
:min="30"
|
||||
:max="1000"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-tip">建议30GB以上</div>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="currentStep === 1">
|
||||
<a-divider orientation="left">访问与安全</a-divider>
|
||||
<a-form-item label="root密码" name="root_password">
|
||||
<a-input
|
||||
v-model:value="form.root_password"
|
||||
type="password"
|
||||
placeholder="留空则自动生成随机密码"
|
||||
/>
|
||||
<div class="form-tip">
|
||||
当前密码:{{ form.root_password || '未设置' }}
|
||||
<a-button type="link" size="small" @click="regeneratePassword">重新生成</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="VNC密码" name="vnc_password">
|
||||
<a-input
|
||||
v-model:value="form.vnc_password"
|
||||
placeholder="留空则自动生成(8-12位)"
|
||||
:maxlength="12"
|
||||
/>
|
||||
<div class="form-tip">限制8-12位字符</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="SSH端口" name="ssh_port">
|
||||
<a-input-number
|
||||
v-model:value="form.ssh_port"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="VNC端口" name="vnc_port">
|
||||
<a-input-number
|
||||
v-model:value="form.vnc_port"
|
||||
:min="0"
|
||||
:max="65535"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="配置路径" name="config_path">
|
||||
<a-input
|
||||
v-model:value="form.config_path"
|
||||
placeholder="/data/kvm/config"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="元数据ID" name="mate_data_id">
|
||||
<a-input
|
||||
v-model:value="form.mate_data_id"
|
||||
placeholder="留空则自动生成"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="接口ID" name="interface_id">
|
||||
<a-input
|
||||
v-model:value="form.interface_id"
|
||||
placeholder="留空则自动生成"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="currentStep === 2">
|
||||
<a-divider orientation="left">网络配置</a-divider>
|
||||
<a-form-item label="物理网卡名" name="physical_name">
|
||||
<a-input
|
||||
v-model:value="form.physical_name"
|
||||
placeholder="例如 eth0"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="入站带宽(Mbps)" name="rx_bandwidth">
|
||||
<a-input-number
|
||||
v-model:value="form.rx_bandwidth"
|
||||
:min="1"
|
||||
:max="10000"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="出站带宽(Mbps)" name="tx_bandwidth">
|
||||
<a-input-number
|
||||
v-model:value="form.tx_bandwidth"
|
||||
:min="1"
|
||||
:max="10000"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="总流量上限(MB)" name="traffic_max">
|
||||
<a-input-number
|
||||
v-model:value="form.traffic_max"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div class="form-tip">0表示不限制,默认1048576MB(1TB)</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="业务网络ID列表" name="network_ids_input">
|
||||
<a-input
|
||||
v-model:value="form.network_ids_input"
|
||||
placeholder="例如: 1,2,3 (至少一个,用逗号分隔)"
|
||||
/>
|
||||
<div class="form-tip">至少需要提供一个网络ID</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="公网网络ID" name="internet_network_id">
|
||||
<a-input-number
|
||||
v-model:value="form.internet_network_id"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="端口组ID" name="port_group_id">
|
||||
<a-input-number
|
||||
v-model:value="form.port_group_id"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<a-divider orientation="left">高级性能限制</a-divider>
|
||||
<a-form-item label="挂载数据卷ID" name="volume_id">
|
||||
<a-input-number
|
||||
v-model:value="form.volume_id"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-divider orientation="left">磁盘IO限制</a-divider>
|
||||
<a-form-item label="读带宽(B/s)" name="read_bytes_sec">
|
||||
<a-input-number
|
||||
v-model:value="form.read_bytes_sec"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="写带宽(B/s)" name="write_bytes_sec">
|
||||
<a-input-number
|
||||
v-model:value="form.write_bytes_sec"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="读IOPS" name="read_iops_sec">
|
||||
<a-input-number
|
||||
v-model:value="form.read_iops_sec"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="写IOPS" name="write_iops_sec">
|
||||
<a-input-number
|
||||
v-model:value="form.write_iops_sec"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-divider orientation="left">磁盘IO最大限制</a-divider>
|
||||
<a-form-item label="最大读带宽" name="read_bytes_sec_max">
|
||||
<a-input-number
|
||||
v-model:value="form.read_bytes_sec_max"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="最大写带宽" name="write_bytes_sec_max">
|
||||
<a-input-number
|
||||
v-model:value="form.write_bytes_sec_max"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="最大读IOPS" name="read_iops_sec_max">
|
||||
<a-input-number
|
||||
v-model:value="form.read_iops_sec_max"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="最大写IOPS" name="write_iops_sec_max">
|
||||
<a-input-number
|
||||
v-model:value="form.write_iops_sec_max"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item :wrapper-col="{ offset: 6, span: 18 }">
|
||||
<a-space>
|
||||
<a-button v-if="currentStep > 0" @click="prevStep" :disabled="loading">
|
||||
上一步
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="currentStep < 3"
|
||||
type="primary"
|
||||
@click="nextStep"
|
||||
:loading="loading"
|
||||
>
|
||||
下一步
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="loading"
|
||||
>
|
||||
创建
|
||||
</a-button>
|
||||
<a-button @click="$router.back()" :disabled="loading">取消</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useVmStore } from '@/stores/vmStore'
|
||||
import * as imageApi from '@/api/imageApi'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const vmStore = useVmStore()
|
||||
const formRef = ref()
|
||||
const currentStep = ref(0)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
uuid: '',
|
||||
image_id: undefined,
|
||||
vcpu: 2,
|
||||
memory: 2048,
|
||||
system_size: 30,
|
||||
rx_bandwidth: 100,
|
||||
tx_bandwidth: 100,
|
||||
// 访问与安全
|
||||
root_password: '',
|
||||
ssh_port: 22,
|
||||
vnc_port: 0,
|
||||
vnc_password: '',
|
||||
config_path: '/data/kvm/config',
|
||||
mate_data_id: '',
|
||||
interface_id: '',
|
||||
// 网络配置
|
||||
traffic_max: 1048576,
|
||||
network_ids_input: '0', // 用户输入的字符串
|
||||
internet_network_id: 0,
|
||||
port_group_id: 0,
|
||||
physical_name: 'eth0',
|
||||
// 高级性能限制
|
||||
volume_id: 0,
|
||||
read_bytes_sec: 314572800,
|
||||
write_bytes_sec: 314572800,
|
||||
read_iops_sec: 1000,
|
||||
write_iops_sec: 1000,
|
||||
read_bytes_sec_max: 314572800,
|
||||
write_bytes_sec_max: 314572800,
|
||||
read_iops_sec_max: 1000,
|
||||
write_iops_sec_max: 1000
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const imageList = ref([])
|
||||
const imageLoading = ref(false)
|
||||
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入虚拟机名称', trigger: 'blur' },
|
||||
{ min: 1, max: 50, message: '名称长度在1-50个字符之间', trigger: 'blur' }
|
||||
],
|
||||
image_id: [{ required: true, message: '请选择镜像', trigger: 'change' }],
|
||||
vcpu: [
|
||||
{ required: true, message: '请输入CPU核心数', trigger: 'blur' },
|
||||
{ type: 'number', min: 1, max: 32, message: 'CPU核心数范围1-32', trigger: 'blur' }
|
||||
],
|
||||
memory: [
|
||||
{ required: true, message: '请输入内存大小', trigger: 'blur' },
|
||||
{ type: 'number', min: 512, max: 65536, message: '内存范围512-65536MB', trigger: 'blur' }
|
||||
],
|
||||
system_size: [
|
||||
{ required: true, message: '请输入系统盘大小', trigger: 'blur' },
|
||||
{ type: 'number', min: 30, max: 1000, message: '系统盘大小范围30-1000GB', trigger: 'blur' }
|
||||
],
|
||||
network_ids_input: [
|
||||
{ required: true, message: '请输入至少一个网络ID', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return Promise.reject('请输入网络ID列表')
|
||||
}
|
||||
const ids = value.split(',').map(id => id.trim()).filter(id => id !== '')
|
||||
if (ids.length === 0) {
|
||||
return Promise.reject('请输入有效的网络ID')
|
||||
}
|
||||
for (const id of ids) {
|
||||
if (isNaN(Number(id))) {
|
||||
return Promise.reject(`网络ID "${id}" 不是有效的数字`)
|
||||
}
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
vnc_password: [
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (value && (value.length < 8 || value.length > 12)) {
|
||||
return Promise.reject('VNC密码长度必须为8-12位')
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const stepFields = [
|
||||
// 第一步:基础信息 + 硬件配置
|
||||
['name', 'uuid', 'image_id', 'vcpu', 'memory', 'system_size'],
|
||||
// 第二步:访问与安全
|
||||
['root_password', 'ssh_port', 'vnc_port', 'vnc_password', 'config_path', 'mate_data_id', 'interface_id'],
|
||||
// 第三步:网络配置
|
||||
['physical_name', 'rx_bandwidth', 'tx_bandwidth', 'traffic_max', 'network_ids_input', 'internet_network_id', 'port_group_id'],
|
||||
// 第四步:高级性能限制
|
||||
['volume_id', 'read_bytes_sec', 'write_bytes_sec', 'read_iops_sec', 'write_iops_sec', 'read_bytes_sec_max', 'write_bytes_sec_max', 'read_iops_sec_max', 'write_iops_sec_max']
|
||||
]
|
||||
|
||||
// 获取镜像列表
|
||||
const fetchImageList = async () => {
|
||||
imageLoading.value = true
|
||||
try {
|
||||
const data = await imageApi.getImageList({ page: 1, count: 100 })
|
||||
imageList.value = data.list || []
|
||||
// 如果有镜像,默认选择第一个
|
||||
if (imageList.value.length > 0 && !form.value.image_id) {
|
||||
form.value.image_id = imageList.value[0].id
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取镜像列表失败:', error)
|
||||
message.error('获取镜像列表失败')
|
||||
} finally {
|
||||
imageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 镜像搜索过滤
|
||||
const filterOption = (input, option) => {
|
||||
return option.children[0].children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}
|
||||
|
||||
// 生成随机密码(用于root密码)
|
||||
const generateRandomPassword = (length = 16) => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+'
|
||||
let result = ''
|
||||
const cryptoObj = window.crypto || window.msCrypto
|
||||
if (cryptoObj && cryptoObj.getRandomValues) {
|
||||
const randomValues = new Uint32Array(length)
|
||||
cryptoObj.getRandomValues(randomValues)
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[randomValues[i] % chars.length]
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 生成VNC密码(8-12位)
|
||||
const generateVncPassword = () => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
const length = Math.floor(Math.random() * 5) + 8 // 8-12位
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 重新生成密码
|
||||
const regeneratePassword = () => {
|
||||
form.value.root_password = generateRandomPassword()
|
||||
if (!form.value.vnc_password) {
|
||||
form.value.vnc_password = generateVncPassword()
|
||||
}
|
||||
}
|
||||
|
||||
// 解析网络ID列表
|
||||
const parseNetworkIds = () => {
|
||||
if (!form.value.network_ids_input || form.value.network_ids_input.trim() === '') {
|
||||
return [0]
|
||||
}
|
||||
const ids = form.value.network_ids_input
|
||||
.split(',')
|
||||
.map(id => parseInt(id.trim(), 10))
|
||||
.filter(id => !isNaN(id) && id >= 0)
|
||||
return ids.length > 0 ? ids : [0]
|
||||
}
|
||||
|
||||
// 构建创建虚拟机请求体,过滤掉值为0的非必填字段
|
||||
// 构建创建虚拟机请求体,只发送有值的字段
|
||||
const buildCreateVmPayload = () => {
|
||||
const base = form.value
|
||||
const selectedImage = imageList.value.find((image) => image.id === base.image_id)
|
||||
|
||||
// 解析网络ID列表
|
||||
const networkIds = parseNetworkIds()
|
||||
|
||||
// 生成UUID(如果未填写)
|
||||
let uuid = base.uuid || ''
|
||||
if (!uuid && window.crypto && typeof window.crypto.randomUUID === 'function') {
|
||||
uuid = window.crypto.randomUUID()
|
||||
}
|
||||
|
||||
// 定义字段映射,只包含有值的字段
|
||||
const payload = {
|
||||
// 必填字段 - 总是包含
|
||||
name: base.name,
|
||||
memory: base.memory,
|
||||
vcpu: base.vcpu,
|
||||
system_size: base.system_size,
|
||||
rx_bandwidth: base.rx_bandwidth,
|
||||
tx_bandwidth: base.tx_bandwidth,
|
||||
image_id: base.image_id,
|
||||
image_name: selectedImage?.name || '',
|
||||
network_ids: networkIds,
|
||||
traffic_max: base.traffic_max,
|
||||
}
|
||||
|
||||
// 可选字段 - 只有有值时才包含
|
||||
const optionalFields = [
|
||||
{ key: 'root_password', value: base.root_password },
|
||||
{ key: 'uuid', value: uuid },
|
||||
{ key: 'mate_data_id', value: base.mate_data_id },
|
||||
{ key: 'interface_id', value: base.interface_id },
|
||||
{ key: 'physical_name', value: base.physical_name },
|
||||
{ key: 'config_path', value: base.config_path },
|
||||
{ key: 'ssh_port', value: base.ssh_port, defaultValue: 22 },
|
||||
{ key: 'vnc_port', value: base.vnc_port, defaultValue: 0 },
|
||||
{ key: 'vnc_password', value: base.vnc_password },
|
||||
{ key: 'internet_network_id', value: base.internet_network_id, defaultValue: 0 },
|
||||
{ key: 'port_group_id', value: base.port_group_id, defaultValue: 0 },
|
||||
{ key: 'volume_id', value: base.volume_id, defaultValue: 0 },
|
||||
{ key: 'read_bytes_sec', value: base.read_bytes_sec, defaultValue: 0 },
|
||||
{ key: 'write_bytes_sec', value: base.write_bytes_sec, defaultValue: 0 },
|
||||
{ key: 'read_iops_sec', value: base.read_iops_sec, defaultValue: 0 },
|
||||
{ key: 'write_iops_sec', value: base.write_iops_sec, defaultValue: 0 },
|
||||
{ key: 'read_bytes_sec_max', value: base.read_bytes_sec_max, defaultValue: 0 },
|
||||
{ key: 'write_bytes_sec_max', value: base.write_bytes_sec_max, defaultValue: 0 },
|
||||
{ key: 'read_iops_sec_max', value: base.read_iops_sec_max, defaultValue: 0 },
|
||||
{ key: 'write_iops_sec_max', value: base.write_iops_sec_max, defaultValue: 0 },
|
||||
]
|
||||
|
||||
// 只添加有值的可选字段
|
||||
optionalFields.forEach(({ key, value, defaultValue }) => {
|
||||
// 如果有默认值,且当前值不等于默认值,则包含
|
||||
if (defaultValue !== undefined) {
|
||||
if (value !== defaultValue) {
|
||||
payload[key] = value
|
||||
}
|
||||
}
|
||||
// 没有默认值,但有实际内容(非空字符串),则包含
|
||||
else if (value !== undefined && value !== null && value !== '') {
|
||||
payload[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
console.log('最终提交数据:', payload)
|
||||
console.log('字段列表:', Object.keys(payload))
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const nextStep = async () => {
|
||||
if (!formRef.value) return
|
||||
const fields = stepFields[currentStep.value] || []
|
||||
try {
|
||||
if (fields.length > 0) {
|
||||
await formRef.value.validateFields(fields)
|
||||
}
|
||||
if (currentStep.value < 3) {
|
||||
currentStep.value += 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('验证失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
if (formRef.value) {
|
||||
await formRef.value.validate()
|
||||
}
|
||||
const payload = buildCreateVmPayload()
|
||||
console.log('提交数据:', payload)
|
||||
await vmStore.createVm(payload)
|
||||
message.success('创建成功')
|
||||
router.push('/vm')
|
||||
} catch (error) {
|
||||
console.error('创建失败:', error)
|
||||
message.error(error.message || '创建失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化默认密码
|
||||
regeneratePassword()
|
||||
fetchImageList()
|
||||
})
|
||||
|
||||
// 监听网络ID输入,确保至少有一个
|
||||
watch(() => form.value.network_ids_input, (newVal) => {
|
||||
if (!newVal || newVal.trim() === '') {
|
||||
form.value.network_ids_input = '0'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vm-create-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.vm-steps {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,419 @@
|
||||
<template>
|
||||
<div class="vm-detail-container">
|
||||
<a-card class="glass-card" v-if="vmDetail">
|
||||
<template #title>
|
||||
<div class="detail-header">
|
||||
<span>{{ vmDetail.name }}</span>
|
||||
<a-tag :color="getStatusColor(vmDetail.status)" style="margin-left: 12px">
|
||||
{{ vmDetail.status }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button v-if="vmDetail.status !== 'running'" @click="handleStart">
|
||||
启动
|
||||
</a-button>
|
||||
<a-button v-if="vmDetail.status === 'running'" @click="handleStop">
|
||||
停止
|
||||
</a-button>
|
||||
<a-button v-if="vmDetail.status === 'running'" @click="handleReboot">
|
||||
重启
|
||||
</a-button>
|
||||
<a-button @click="handlePause">暂停</a-button>
|
||||
<a-button @click="handleResume">恢复</a-button>
|
||||
<a-button @click="handleEnterRescue">进入救援系统</a-button>
|
||||
<a-button @click="handleExitRescue">退出救援系统</a-button>
|
||||
<a-button @click="openTrafficModal">修改带宽</a-button>
|
||||
<a-button @click="handleEdit">编辑</a-button>
|
||||
<a-button danger @click="handleDelete">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-tabs>
|
||||
<a-tab-pane key="basic" tab="基本信息">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="ID">{{ vmDetail.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="名称">{{ vmDetail.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(vmDetail.status)">
|
||||
{{ vmDetail.status }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="CPU">{{ vmDetail.vcpu }} 核</a-descriptions-item>
|
||||
<a-descriptions-item label="内存">{{ vmDetail.memory }} MB</a-descriptions-item>
|
||||
<a-descriptions-item label="系统盘">{{ formatSystemSize(vmDetail.system_size) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="入站带宽">{{ vmDetail.rx_bandwidth }} Mbps</a-descriptions-item>
|
||||
<a-descriptions-item label="出站带宽">{{ vmDetail.tx_bandwidth }} Mbps</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间" :span="2">
|
||||
{{ formatTime(vmDetail.created_at) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="monitor" tab="监控数据">
|
||||
<div id="vm-monitor-chart" style="height: 400px"></div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
<a-skeleton v-else active :paragraph="{ rows: 8 }" />
|
||||
<a-modal
|
||||
v-model:open="trafficModalVisible"
|
||||
title="修改虚拟机带宽"
|
||||
ok-text="保存"
|
||||
@ok="submitTraffic"
|
||||
>
|
||||
<a-form :model="trafficForm" layout="vertical">
|
||||
<a-form-item label="虚拟机">
|
||||
<a-input v-model:value="trafficForm.name" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="入站带宽(Mbps)" required>
|
||||
<a-input-number
|
||||
v-model:value="trafficForm.rx_bandwidth"
|
||||
:min="1"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="出站带宽(Mbps)" required>
|
||||
<a-input-number
|
||||
v-model:value="trafficForm.tx_bandwidth"
|
||||
:min="1"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="最大流量(Mbps)" required>
|
||||
<a-input-number
|
||||
v-model:value="trafficForm.traffic_max"
|
||||
:min="1"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useVmStore } from '@/stores/vmStore'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import * as echarts from 'echarts'
|
||||
import * as metricsApi from '@/api/metricsApi'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const vmStore = useVmStore()
|
||||
|
||||
const vmDetail = ref(null)
|
||||
const vmId = ref(route.params.id)
|
||||
let monitorChart = null
|
||||
|
||||
// 获取详情
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const data = await vmStore.fetchVmDetail({ vm_id: vmId.value })
|
||||
vmDetail.value = data
|
||||
await initMonitorChart()
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化监控图表
|
||||
const initMonitorChart = async () => {
|
||||
if (!vmDetail.value) return
|
||||
|
||||
const chartDom = document.getElementById('vm-monitor-chart')
|
||||
if (chartDom) {
|
||||
if (!monitorChart) {
|
||||
monitorChart = echarts.init(chartDom)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await metricsApi.getVmData({ vm_name: vmDetail.value.name })
|
||||
// 约定数据格式为:[{ time, cpu, memory, network }]
|
||||
const points = Array.isArray(data) ? data : []
|
||||
const times = points.map((item) => item.time)
|
||||
const cpu = points.map((item) => item.cpu)
|
||||
const memory = points.map((item) => item.memory)
|
||||
const network = points.map((item) => item.network)
|
||||
|
||||
monitorChart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['CPU', '内存', '网络'],
|
||||
textStyle: { color: '#fff' }
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: times,
|
||||
axisLabel: { color: '#fff' }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#fff' }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'CPU',
|
||||
type: 'line',
|
||||
data: cpu,
|
||||
itemStyle: { color: '#10b981' }
|
||||
},
|
||||
{
|
||||
name: '内存',
|
||||
type: 'line',
|
||||
data: memory,
|
||||
itemStyle: { color: '#3b82f6' }
|
||||
},
|
||||
{
|
||||
name: '网络',
|
||||
type: 'line',
|
||||
data: network,
|
||||
itemStyle: { color: '#f59e0b' }
|
||||
}
|
||||
]
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取虚拟机监控数据失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
running: 'green',
|
||||
stopped: 'default',
|
||||
paused: 'orange',
|
||||
error: 'red'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time) => {
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 格式化系统盘大小
|
||||
const formatSystemSize = (size) => {
|
||||
if (typeof size === 'number' && size > 0) {
|
||||
return `${size} GB`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
// 启动
|
||||
const handleStart = async () => {
|
||||
Modal.confirm({
|
||||
title: '确认启动',
|
||||
content: '确定要启动此虚拟机吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.startVm(vmId.value)
|
||||
message.success('启动成功')
|
||||
fetchDetail()
|
||||
} catch (error) {
|
||||
console.error('启动失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 停止
|
||||
const handleStop = async () => {
|
||||
Modal.confirm({
|
||||
title: '确认停止',
|
||||
content: '确定要停止此虚拟机吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.stopVm(vmId.value)
|
||||
message.success('停止成功')
|
||||
fetchDetail()
|
||||
} catch (error) {
|
||||
console.error('停止失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 重启
|
||||
const handleReboot = async () => {
|
||||
Modal.confirm({
|
||||
title: '确认重启',
|
||||
content: '确定要重启此虚拟机吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.rebootVm(vmId.value)
|
||||
message.success('重启成功')
|
||||
fetchDetail()
|
||||
} catch (error) {
|
||||
console.error('重启失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 暂停
|
||||
const handlePause = () => {
|
||||
if (!vmDetail.value) return
|
||||
Modal.confirm({
|
||||
title: '确认暂停',
|
||||
content: '确定要暂停此虚拟机吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.pauseVm(vmId.value)
|
||||
message.success('暂停成功')
|
||||
fetchDetail()
|
||||
} catch (error) {
|
||||
console.error('暂停失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 恢复
|
||||
const handleResume = () => {
|
||||
if (!vmDetail.value) return
|
||||
Modal.confirm({
|
||||
title: '确认恢复',
|
||||
content: '确定要恢复此虚拟机吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.resumeVm(vmId.value)
|
||||
message.success('恢复成功')
|
||||
fetchDetail()
|
||||
} catch (error) {
|
||||
console.error('恢复失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 进入救援系统
|
||||
const handleEnterRescue = () => {
|
||||
if (!vmDetail.value) return
|
||||
Modal.confirm({
|
||||
title: '确认进入救援系统',
|
||||
content: '确定要让此虚拟机进入救援系统吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.enterRescue(vmId.value)
|
||||
message.success('操作成功')
|
||||
fetchDetail()
|
||||
} catch (error) {
|
||||
console.error('进入救援系统失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 退出救援系统
|
||||
const handleExitRescue = () => {
|
||||
if (!vmDetail.value) return
|
||||
Modal.confirm({
|
||||
title: '确认退出救援系统',
|
||||
content: '确定要让此虚拟机退出救援系统吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.exitRescue(vmId.value)
|
||||
message.success('操作成功')
|
||||
fetchDetail()
|
||||
} catch (error) {
|
||||
console.error('退出救援系统失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = () => {
|
||||
router.push(`/vm/${vmId.value}?edit=true`)
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async () => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除此虚拟机吗?此操作不可恢复!',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.deleteVm(vmId.value)
|
||||
message.success('删除成功')
|
||||
router.push('/vm')
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 修改带宽
|
||||
const trafficModalVisible = ref(false)
|
||||
const trafficForm = ref({
|
||||
vm_id: null,
|
||||
name: '',
|
||||
rx_bandwidth: null,
|
||||
tx_bandwidth: null,
|
||||
traffic_max: null
|
||||
})
|
||||
|
||||
const openTrafficModal = () => {
|
||||
if (!vmDetail.value) return
|
||||
trafficForm.value.vm_id = vmDetail.value.id
|
||||
trafficForm.value.name = vmDetail.value.name
|
||||
trafficForm.value.rx_bandwidth = vmDetail.value.rx_bandwidth || 1
|
||||
trafficForm.value.tx_bandwidth = vmDetail.value.tx_bandwidth || 1
|
||||
trafficForm.value.traffic_max = vmDetail.value.traffic_max || 1
|
||||
trafficModalVisible.value = true
|
||||
}
|
||||
|
||||
const submitTraffic = async () => {
|
||||
if (!trafficForm.value.vm_id) return
|
||||
try {
|
||||
await vmStore.updateTraffic({
|
||||
vm_id: trafficForm.value.vm_id,
|
||||
rx_bandwidth: trafficForm.value.rx_bandwidth,
|
||||
tx_bandwidth: trafficForm.value.tx_bandwidth,
|
||||
traffic_max: trafficForm.value.traffic_max
|
||||
})
|
||||
message.success('带宽修改成功')
|
||||
trafficModalVisible.value = false
|
||||
fetchDetail()
|
||||
} catch (error) {
|
||||
console.error('修改带宽失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDetail()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (monitorChart) {
|
||||
monitorChart.dispose()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vm-detail-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,444 @@
|
||||
<template>
|
||||
<div class="vm-list-container">
|
||||
<a-card class="glass-card">
|
||||
<template #title>
|
||||
<span>虚拟机列表</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<template #icon>
|
||||
<plus-outlined />
|
||||
</template>
|
||||
创建虚拟机
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<a-input-search
|
||||
v-model:value="searchKey"
|
||||
placeholder="搜索虚拟机名称或ID"
|
||||
style="width: 300px"
|
||||
@search="handleSearch"
|
||||
allow-clear
|
||||
/>
|
||||
<a-button @click="handleRefresh">
|
||||
<template #icon>
|
||||
<reload-outlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="vmStore.vmList"
|
||||
:loading="vmStore.loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">
|
||||
查看
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<a-button type="link" size="small">
|
||||
操作 <down-outlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
v-if="record.status !== 'running'"
|
||||
@click="handleStart(record)"
|
||||
>
|
||||
启动
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
v-if="record.status === 'running'"
|
||||
@click="handleStop(record)"
|
||||
>
|
||||
停止
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
v-if="record.status === 'running'"
|
||||
@click="handleReboot(record)"
|
||||
>
|
||||
重启
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item @click="handlePause(record)">
|
||||
暂停
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleResume(record)">
|
||||
恢复
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleEnterRescue(record)">
|
||||
进入救援系统
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleExitRescue(record)">
|
||||
退出救援系统
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleRebuild(record)">
|
||||
重建
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="openTrafficModal(record)">
|
||||
修改带宽
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-menu-item>
|
||||
<a-menu-item danger @click="handleDelete(record)">
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<a-modal
|
||||
v-model:open="trafficModalVisible"
|
||||
title="修改虚拟机带宽"
|
||||
ok-text="保存"
|
||||
@ok="submitTraffic"
|
||||
>
|
||||
<a-form :model="trafficForm" layout="vertical">
|
||||
<a-form-item label="虚拟机">
|
||||
<a-input v-model:value="trafficForm.name" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="入站带宽(Mbps)" required>
|
||||
<a-input-number
|
||||
v-model:value="trafficForm.rx_bandwidth"
|
||||
:min="1"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="出站带宽(Mbps)" required>
|
||||
<a-input-number
|
||||
v-model:value="trafficForm.tx_bandwidth"
|
||||
:min="1"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="最大流量(Mbps)" required>
|
||||
<a-input-number
|
||||
v-model:value="trafficForm.traffic_max"
|
||||
:min="1"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useVmStore } from '@/stores/vmStore'
|
||||
import { PlusOutlined, ReloadOutlined, DownOutlined } from '@ant-design/icons-vue'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const vmStore = useVmStore()
|
||||
|
||||
const searchKey = ref('')
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: 'CPU', dataIndex: 'vcpu', key: 'vcpu', width: 80 },
|
||||
{ title: '内存(MB)', dataIndex: 'memory', key: 'memory', width: 120 },
|
||||
{ title: '系统盘(GB)', dataIndex: 'system_size', key: 'system_size', width: 120 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 150, fixed: 'right' }
|
||||
]
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
running: 'green',
|
||||
stopped: 'default',
|
||||
paused: 'orange',
|
||||
error: 'red'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取列表
|
||||
const fetchList = async () => {
|
||||
const params = {
|
||||
page: pagination.value.current,
|
||||
count: pagination.value.pageSize,
|
||||
key: searchKey.value || undefined
|
||||
}
|
||||
const data = await vmStore.fetchVmList(params)
|
||||
pagination.value.total = data.total || 0
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.value.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.value.current = pag.current
|
||||
pagination.value.pageSize = pag.pageSize
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 创建
|
||||
const handleCreate = () => {
|
||||
router.push('/vm/create')
|
||||
}
|
||||
|
||||
// 查看
|
||||
const handleView = (record) => {
|
||||
router.push(`/vm/${record.id}`)
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (record) => {
|
||||
router.push(`/vm/${record.id}?edit=true`)
|
||||
}
|
||||
|
||||
// 启动
|
||||
const handleStart = async (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认启动',
|
||||
content: `确定要启动虚拟机 "${record.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.startVm(record.id)
|
||||
message.success('启动成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('启动失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 停止
|
||||
const handleStop = async (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认停止',
|
||||
content: `确定要停止虚拟机 "${record.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.stopVm(record.id)
|
||||
message.success('停止成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('停止失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 重启
|
||||
const handleReboot = async (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认重启',
|
||||
content: `确定要重启虚拟机 "${record.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.rebootVm(record.id)
|
||||
message.success('重启成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('重启失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除虚拟机 "${record.name}" 吗?此操作不可恢复!`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.deleteVm(record.id)
|
||||
message.success('删除成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 暂停
|
||||
const handlePause = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认暂停',
|
||||
content: `确定要暂停虚拟机 "${record.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.pauseVm(record.id)
|
||||
message.success('暂停成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('暂停失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 恢复
|
||||
const handleResume = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认恢复',
|
||||
content: `确定要恢复虚拟机 "${record.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.resumeVm(record.id)
|
||||
message.success('恢复成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('恢复失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 进入救援系统
|
||||
const handleEnterRescue = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认进入救援系统',
|
||||
content: `确定要让虚拟机 "${record.name}" 进入救援系统吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.enterRescue(record.id)
|
||||
message.success('操作成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('进入救援系统失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 退出救援系统
|
||||
const handleExitRescue = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认退出救援系统',
|
||||
content: `确定要让虚拟机 "${record.name}" 退出救援系统吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.exitRescue(record.id)
|
||||
message.success('操作成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('退出救援系统失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 重建
|
||||
const handleRebuild = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认重建虚拟机',
|
||||
content: `确定要重建虚拟机 "${record.name}" 吗?此操作可能导致数据丢失,请谨慎操作。`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await vmStore.rebuildVm({ vm_id: record.id })
|
||||
message.success('已提交重建请求')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('重建失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 修改带宽
|
||||
const trafficModalVisible = ref(false)
|
||||
const trafficForm = ref({
|
||||
vm_id: null,
|
||||
name: '',
|
||||
rx_bandwidth: null,
|
||||
tx_bandwidth: null,
|
||||
traffic_max: null
|
||||
})
|
||||
|
||||
const openTrafficModal = (record) => {
|
||||
trafficForm.value.vm_id = record.id
|
||||
trafficForm.value.name = record.name
|
||||
trafficForm.value.rx_bandwidth = record.rx_bandwidth || 1
|
||||
trafficForm.value.tx_bandwidth = record.tx_bandwidth || 1
|
||||
trafficForm.value.traffic_max = record.traffic_max || 1
|
||||
trafficModalVisible.value = true
|
||||
}
|
||||
|
||||
const submitTraffic = async () => {
|
||||
if (!trafficForm.value.vm_id) return
|
||||
try {
|
||||
await vmStore.updateTraffic({
|
||||
vm_id: trafficForm.value.vm_id,
|
||||
rx_bandwidth: trafficForm.value.rx_bandwidth,
|
||||
tx_bandwidth: trafficForm.value.tx_bandwidth,
|
||||
traffic_max: trafficForm.value.traffic_max
|
||||
})
|
||||
message.success('带宽修改成功')
|
||||
trafficModalVisible.value = false
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('修改带宽失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vm-list-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,950 @@
|
||||
<template>
|
||||
<div class="volume-list-container">
|
||||
<a-card class="glass-card">
|
||||
<template #title>
|
||||
<span>数据卷管理</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="openCreateModal">
|
||||
<template #icon>
|
||||
<plus-outlined />
|
||||
</template>
|
||||
创建数据卷
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<a-input-search
|
||||
v-model:value="searchKey"
|
||||
placeholder="搜索数据卷名称"
|
||||
style="width: 300px"
|
||||
@search="handleSearch"
|
||||
allow-clear
|
||||
/>
|
||||
<a-button @click="handleRefresh">
|
||||
<template #icon>
|
||||
<reload-outlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="volumeStore.volumeList"
|
||||
:loading="volumeStore.loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'is_system'">
|
||||
<a-tag :color="record.is_system ? 'blue' : 'default'">
|
||||
{{ record.is_system ? '系统盘' : '数据盘' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'is_mount'">
|
||||
<a-tag :color="record.vm_id ? 'green' : 'orange'">
|
||||
{{ record.vm_id ? '已挂载' : '未挂载' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'vm_info'">
|
||||
<span v-if="record.vm_id && record.vm_name">
|
||||
{{ record.vm_name }} (ID: {{ record.vm_id }})
|
||||
</span>
|
||||
<span v-else-if="record.vm_id">
|
||||
虚拟机 ID: {{ record.vm_id }}
|
||||
</span>
|
||||
<span v-else>未挂载</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">
|
||||
查看
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<a-button type="link" size="small">
|
||||
操作 <down-outlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
v-if="!record.vm_id"
|
||||
@click="handleMount(record)"
|
||||
>
|
||||
挂载
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
v-if="record.vm_id"
|
||||
@click="handleUnmount(record)"
|
||||
>
|
||||
卸载
|
||||
</a-menu-item>
|
||||
<a-menu-item
|
||||
v-if="record.vm_id"
|
||||
@click="handleTransfer(record)"
|
||||
>
|
||||
转移
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleResize(record)">
|
||||
修改大小
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item danger @click="handleDelete(record)">
|
||||
删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 查看详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="viewModalVisible"
|
||||
title="数据卷详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<a-spin :spinning="detailLoading">
|
||||
<a-descriptions
|
||||
v-if="currentVolumeDetail"
|
||||
bordered
|
||||
:column="2"
|
||||
size="small"
|
||||
>
|
||||
<a-descriptions-item label="ID" span="1">
|
||||
{{ currentVolumeDetail.id }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="名称" span="1">
|
||||
{{ currentVolumeDetail.name }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="大小(GB)" span="1">
|
||||
{{ currentVolumeDetail.size }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="类型" span="1">
|
||||
<a-tag :color="currentVolumeDetail.is_system ? 'blue' : 'default'">
|
||||
{{ currentVolumeDetail.is_system ? '系统盘' : '数据盘' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态" span="1">
|
||||
<a-tag :color="getStatusColor(currentVolumeDetail.status)">
|
||||
{{ currentVolumeDetail.status }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="挂载状态" span="1">
|
||||
<a-tag :color="currentVolumeDetail.vm_id ? 'green' : 'orange'">
|
||||
{{ currentVolumeDetail.vm_id ? '已挂载' : '未挂载' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="路径" span="2">
|
||||
{{ currentVolumeDetail.path }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="目标设备" span="1">
|
||||
{{ currentVolumeDetail.target_device }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="关联虚拟机ID" span="1">
|
||||
{{ currentVolumeDetail.vm_id || '未关联' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="镜像ID" span="1">
|
||||
{{ currentVolumeDetail.image_id || '无' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间" span="1">
|
||||
{{ formatTime(currentVolumeDetail.created_at) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间" span="1">
|
||||
{{ formatTime(currentVolumeDetail.updated_at) }}
|
||||
</a-descriptions-item>
|
||||
|
||||
<!-- QoS参数 -->
|
||||
<a-descriptions-item label="读速率(bytes/s)" span="1">
|
||||
{{ currentVolumeDetail.read_bytes_sec }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="写速率(bytes/s)" span="1">
|
||||
{{ currentVolumeDetail.write_bytes_sec }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="读IOPS" span="1">
|
||||
{{ currentVolumeDetail.read_iops_sec }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="写IOPS" span="1">
|
||||
{{ currentVolumeDetail.write_iops_sec }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最大读速率(bytes/s)" span="1">
|
||||
{{ currentVolumeDetail.read_bytes_sec_max }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最大写速率(bytes/s)" span="1">
|
||||
{{ currentVolumeDetail.write_bytes_sec_max }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最大读IOPS" span="1">
|
||||
{{ currentVolumeDetail.read_iops_sec_max }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最大写IOPS" span="1">
|
||||
{{ currentVolumeDetail.write_iops_sec_max }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
|
||||
<!-- 创建数据卷弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="createModalVisible"
|
||||
title="创建数据卷"
|
||||
width="600px"
|
||||
@ok="handleCreateSubmit"
|
||||
:confirm-loading="createLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="createFormRef"
|
||||
:model="createForm"
|
||||
:rules="createRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="名称" name="name" required>
|
||||
<a-input
|
||||
v-model:value="createForm.name"
|
||||
placeholder="请输入数据卷名称"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="大小(GB)" name="size" required>
|
||||
<a-input-number
|
||||
v-model:value="createForm.size"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
style="width: 100%"
|
||||
placeholder="请输入数据卷大小"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="类型" name="is_system">
|
||||
<a-radio-group v-model:value="createForm.is_system">
|
||||
<a-radio :value="false">数据盘</a-radio>
|
||||
<a-radio :value="true">系统盘</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="目标设备" name="target_device">
|
||||
<a-select
|
||||
v-model:value="createForm.target_device"
|
||||
placeholder="请选择目标设备"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="hda">hda</a-select-option>
|
||||
<a-select-option value="hdb">hdb</a-select-option>
|
||||
<a-select-option value="hdc">hdc</a-select-option>
|
||||
<a-select-option value="hdd">hdd</a-select-option>
|
||||
<a-select-option value="vda">vda</a-select-option>
|
||||
<a-select-option value="vdb">vdb</a-select-option>
|
||||
<a-select-option value="vdc">vdc</a-select-option>
|
||||
<a-select-option value="vdd">vdd</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="关联虚拟机ID" name="vm_id">
|
||||
<a-input-number
|
||||
v-model:value="createForm.vm_id"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="请输入虚拟机ID(0表示不关联)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="路径" name="path" required>
|
||||
<a-input
|
||||
v-model:value="createForm.path"
|
||||
placeholder="请输入存储路径,如:/data/kvm/volume/xxx.qcow2"
|
||||
/>
|
||||
<div class="form-tip">示例:/data/kvm/volume/2026-01-29/d1571112-5545-4cf1-b7f1-1c19533edfc5.qcow2</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-divider>QoS参数(可选)</a-divider>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="读速率(bytes/s)" name="read_bytes_sec">
|
||||
<a-input-number
|
||||
v-model:value="createForm.read_bytes_sec"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="默认为314572800"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="写速率(bytes/s)" name="write_bytes_sec">
|
||||
<a-input-number
|
||||
v-model:value="createForm.write_bytes_sec"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="默认为314572800"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="读IOPS" name="read_iops_sec">
|
||||
<a-input-number
|
||||
v-model:value="createForm.read_iops_sec"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="默认为1000"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="写IOPS" name="write_iops_sec">
|
||||
<a-input-number
|
||||
v-model:value="createForm.write_iops_sec"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="默认为1000"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="最大读速率(bytes/s)" name="read_bytes_sec_max">
|
||||
<a-input-number
|
||||
v-model:value="createForm.read_bytes_sec_max"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="默认为314572800"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="最大写速率(bytes/s)" name="write_bytes_sec_max">
|
||||
<a-input-number
|
||||
v-model:value="createForm.write_bytes_sec_max"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="默认为314572800"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="最大读IOPS" name="read_iops_sec_max">
|
||||
<a-input-number
|
||||
v-model:value="createForm.read_iops_sec_max"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="默认为1000"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="最大写IOPS" name="write_iops_sec_max">
|
||||
<a-input-number
|
||||
v-model:value="createForm.write_iops_sec_max"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
placeholder="默认为1000"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 挂载弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="mountModalVisible"
|
||||
title="挂载数据卷"
|
||||
width="500px"
|
||||
@ok="handleMountSubmit"
|
||||
:confirm-loading="mountLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="mountFormRef"
|
||||
:model="mountForm"
|
||||
:rules="mountRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="数据卷">
|
||||
<a-input v-model:value="mountForm.volumeName" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="选择虚拟机" name="vm_id" required>
|
||||
<a-select
|
||||
v-model:value="mountForm.vm_id"
|
||||
placeholder="请选择要挂载的虚拟机"
|
||||
:loading="vmListLoading"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option
|
||||
v-for="vm in vmList"
|
||||
:key="vm.id"
|
||||
:value="vm.id"
|
||||
:label="`${vm.name} (ID: ${vm.id})`"
|
||||
>
|
||||
{{ vm.name }} (ID: {{ vm.id }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 修改大小弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="resizeModalVisible"
|
||||
title="修改数据卷大小"
|
||||
width="500px"
|
||||
@ok="handleResizeSubmit"
|
||||
:confirm-loading="resizeLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="resizeFormRef"
|
||||
:model="resizeForm"
|
||||
:rules="resizeRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="数据卷">
|
||||
<a-input v-model:value="resizeForm.volumeName" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="当前大小(GB)">
|
||||
<a-input v-model:value="resizeForm.currentSize" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="新大小(GB)" name="newSize" required>
|
||||
<a-input-number
|
||||
v-model:value="resizeForm.newSize"
|
||||
:min="resizeForm.currentSize + 1"
|
||||
:max="1000"
|
||||
style="width: 100%"
|
||||
placeholder="请输入新的大小"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 转移数据卷弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="transferModalVisible"
|
||||
title="转移数据卷"
|
||||
width="500px"
|
||||
@ok="handleTransferSubmit"
|
||||
:confirm-loading="transferLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="transferFormRef"
|
||||
:model="transferForm"
|
||||
:rules="transferRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="数据卷">
|
||||
<a-input v-model:value="transferForm.volumeName" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="当前虚拟机ID">
|
||||
<a-input v-model:value="transferForm.currentVmId" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="选择目标虚拟机" name="newVmId" required>
|
||||
<a-select
|
||||
v-model:value="transferForm.newVmId"
|
||||
placeholder="请选择目标虚拟机"
|
||||
:loading="vmListLoading"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option
|
||||
v-for="vm in vmList.filter(v => v.id !== transferForm.currentVmId)"
|
||||
:key="vm.id"
|
||||
:value="vm.id"
|
||||
:label="`${vm.name} (ID: ${vm.id})`"
|
||||
>
|
||||
{{ vm.name }} (ID: {{ vm.id }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { PlusOutlined, ReloadOutlined, DownOutlined } from '@ant-design/icons-vue'
|
||||
import { useVolumeStore } from '@/stores/volumeStore'
|
||||
import { useVmStore } from '@/stores/vmStore'
|
||||
|
||||
const volumeStore = useVolumeStore()
|
||||
const vmStore = useVmStore()
|
||||
|
||||
// 搜索和分页
|
||||
const searchKey = ref('')
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '大小(GB)', dataIndex: 'size', key: 'size', width: 100 },
|
||||
{ title: '类型', key: 'is_system', width: 100 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '挂载状态', key: 'is_mount', width: 100 },
|
||||
{ title: '关联虚拟机', key: 'vm_info', width: 150 },
|
||||
{ title: '路径', dataIndex: 'path', key: 'path', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: '操作', key: 'actions', width: 150, fixed: 'right' }
|
||||
]
|
||||
|
||||
// 查看详情相关
|
||||
const viewModalVisible = ref(false)
|
||||
const currentVolumeDetail = ref(null)
|
||||
const detailLoading = ref(false)
|
||||
|
||||
// 挂载相关
|
||||
const mountModalVisible = ref(false)
|
||||
const mountLoading = ref(false)
|
||||
const mountFormRef = ref()
|
||||
const mountForm = reactive({
|
||||
volumeId: null,
|
||||
volumeName: '',
|
||||
vm_id: null,
|
||||
target_device: 'vdb'
|
||||
})
|
||||
|
||||
const mountRules = {
|
||||
vm_id: [
|
||||
{ required: true, message: '请选择虚拟机', trigger: 'change' }
|
||||
],
|
||||
}
|
||||
|
||||
// 修改大小相关
|
||||
const resizeModalVisible = ref(false)
|
||||
const resizeLoading = ref(false)
|
||||
const resizeFormRef = ref()
|
||||
const resizeForm = reactive({
|
||||
volumeId: null,
|
||||
volumeName: '',
|
||||
currentSize: 0,
|
||||
newSize: 0
|
||||
})
|
||||
|
||||
const resizeRules = {
|
||||
newSize: [
|
||||
{ required: true, message: '请输入新的大小', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 转移相关
|
||||
const transferModalVisible = ref(false)
|
||||
const transferLoading = ref(false)
|
||||
const transferFormRef = ref()
|
||||
const transferForm = reactive({
|
||||
volumeId: null,
|
||||
volumeName: '',
|
||||
currentVmId: null,
|
||||
newVmId: null
|
||||
})
|
||||
|
||||
const transferRules = {
|
||||
newVmId: [
|
||||
{ required: true, message: '请选择目标虚拟机', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 创建数据卷相关
|
||||
const createModalVisible = ref(false)
|
||||
const createLoading = ref(false)
|
||||
const createFormRef = ref()
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
size: 20,
|
||||
is_system: false,
|
||||
target_device: 'vdb',
|
||||
vm_id: 0,
|
||||
path: '',
|
||||
read_bytes_sec: 314572800,
|
||||
write_bytes_sec: 314572800,
|
||||
read_iops_sec: 1000,
|
||||
write_iops_sec: 1000,
|
||||
read_bytes_sec_max: 314572800,
|
||||
write_bytes_sec_max: 314572800,
|
||||
read_iops_sec_max: 1000,
|
||||
write_iops_sec_max: 1000
|
||||
})
|
||||
|
||||
const createRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入数据卷名称', trigger: 'blur' }
|
||||
],
|
||||
size: [
|
||||
{ required: true, message: '请输入数据卷大小', trigger: 'blur' },
|
||||
{ type: 'number', min: 1, message: '大小必须大于0', trigger: 'blur' }
|
||||
],
|
||||
path: [
|
||||
{ required: true, message: '请输入存储路径', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取虚拟机列表
|
||||
const vmList = ref([])
|
||||
const vmListLoading = ref(false)
|
||||
|
||||
// 获取列表
|
||||
const fetchList = async () => {
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
count: pagination.pageSize,
|
||||
key: searchKey.value || undefined
|
||||
}
|
||||
const data = await volumeStore.fetchVolumeList(params)
|
||||
pagination.total = data.total || 0
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleView = async (record) => {
|
||||
try {
|
||||
detailLoading.value = true
|
||||
viewModalVisible.value = true
|
||||
|
||||
const detail = await volumeStore.fetchVolumeDetail({ volume_id: record.id })
|
||||
currentVolumeDetail.value = detail
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
message.error('获取详情失败')
|
||||
viewModalVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
ready: 'green',
|
||||
creating: 'blue',
|
||||
error: 'red',
|
||||
mounting: 'orange',
|
||||
unmounting: 'orange',
|
||||
mounted: 'green',
|
||||
unmounted: 'default'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time) => {
|
||||
if (!time) return ''
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取虚拟机列表
|
||||
const fetchVmList = async () => {
|
||||
try {
|
||||
vmListLoading.value = true
|
||||
const data = await vmStore.fetchVmList({
|
||||
page: 1,
|
||||
count: 100,
|
||||
key: ''
|
||||
})
|
||||
vmList.value = data.data || []
|
||||
} catch (error) {
|
||||
console.error('获取虚拟机列表失败:', error)
|
||||
message.error('获取虚拟机列表失败')
|
||||
} finally {
|
||||
vmListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 挂载
|
||||
const handleMount = (record) => {
|
||||
if (record.vm_id) {
|
||||
message.warning('该数据卷已挂载')
|
||||
return
|
||||
}
|
||||
|
||||
mountForm.volumeId = record.id
|
||||
mountForm.volumeName = record.name
|
||||
mountForm.vm_id = null
|
||||
|
||||
mountModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleMountSubmit = async () => {
|
||||
try {
|
||||
await mountFormRef.value.validate()
|
||||
mountLoading.value = true
|
||||
|
||||
const mountData = {
|
||||
volume_id: mountForm.volumeId,
|
||||
vm_id: mountForm.vm_id
|
||||
}
|
||||
|
||||
await volumeStore.mountVolume(mountData)
|
||||
message.success('挂载成功')
|
||||
mountModalVisible.value = false
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('挂载失败:', error)
|
||||
if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else {
|
||||
message.error('挂载失败')
|
||||
}
|
||||
} finally {
|
||||
mountLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 卸载
|
||||
const handleUnmount = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认卸载',
|
||||
content: `确定要卸载数据卷 "${record.name}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await volumeStore.unmountVolume({ volume_id: record.id })
|
||||
message.success('卸载成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('卸载失败:', error)
|
||||
message.error('卸载失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 修改数据卷大小
|
||||
const handleResize = (record) => {
|
||||
resizeForm.volumeId = record.id
|
||||
resizeForm.volumeName = record.name
|
||||
resizeForm.currentSize = record.size
|
||||
resizeForm.newSize = record.size + 10 // 默认增加10GB
|
||||
|
||||
// 清空之前的验证状态
|
||||
if (resizeFormRef.value) {
|
||||
resizeFormRef.value.clearValidate()
|
||||
}
|
||||
|
||||
resizeModalVisible.value = true
|
||||
}
|
||||
|
||||
// 实际修改大小的提交函数
|
||||
const handleResizeSubmit = async () => {
|
||||
try {
|
||||
await resizeFormRef.value.validate()
|
||||
resizeLoading.value = true
|
||||
|
||||
const resizeData = {
|
||||
volume_id: resizeForm.volumeId,
|
||||
size: resizeForm.newSize
|
||||
}
|
||||
|
||||
await volumeStore.resizeVolume(resizeData)
|
||||
message.success('修改大小成功')
|
||||
resizeModalVisible.value = false
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('修改大小失败:', error)
|
||||
if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else {
|
||||
message.error('修改大小失败')
|
||||
}
|
||||
} finally {
|
||||
resizeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 转移数据卷
|
||||
const handleTransfer = (record) => {
|
||||
transferForm.volumeId = record.id
|
||||
transferForm.volumeName = record.name
|
||||
transferForm.currentVmId = record.vm_id
|
||||
transferForm.newVmId = null
|
||||
|
||||
// 清空之前的验证状态
|
||||
if (transferFormRef.value) {
|
||||
transferFormRef.value.clearValidate()
|
||||
}
|
||||
|
||||
transferModalVisible.value = true
|
||||
}
|
||||
|
||||
// 实际转移的提交函数
|
||||
const handleTransferSubmit = async () => {
|
||||
try {
|
||||
await transferFormRef.value.validate()
|
||||
transferLoading.value = true
|
||||
|
||||
const transferData = {
|
||||
volume_id: transferForm.volumeId,
|
||||
vm_id: transferForm.newVmId
|
||||
}
|
||||
|
||||
await volumeStore.transferVolume(transferData)
|
||||
message.success('转移成功')
|
||||
transferModalVisible.value = false
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('转移失败:', error)
|
||||
if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else {
|
||||
message.error('转移失败')
|
||||
}
|
||||
} finally {
|
||||
transferLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除数据卷 "${record.name}" 吗?此操作不可恢复!`,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await volumeStore.deleteVolume({ volume_id: record.id })
|
||||
message.success('删除成功')
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (record) => {
|
||||
message.info('编辑功能开发中...')
|
||||
}
|
||||
|
||||
// 创建数据卷弹窗
|
||||
const openCreateModal = () => {
|
||||
createFormRef.value?.resetFields()
|
||||
Object.assign(createForm, {
|
||||
name: '',
|
||||
size: 20,
|
||||
is_system: false,
|
||||
target_device: 'vdb',
|
||||
vm_id: 0,
|
||||
path: '',
|
||||
read_bytes_sec: 314572800,
|
||||
write_bytes_sec: 314572800,
|
||||
read_iops_sec: 1000,
|
||||
write_iops_sec: 1000,
|
||||
read_bytes_sec_max: 314572800,
|
||||
write_bytes_sec_max: 314572800,
|
||||
read_iops_sec_max: 1000,
|
||||
write_iops_sec_max: 1000
|
||||
})
|
||||
createModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleCreateSubmit = async () => {
|
||||
try {
|
||||
await createFormRef.value.validate()
|
||||
createLoading.value = true
|
||||
|
||||
const submitData = {
|
||||
name: createForm.name,
|
||||
size: createForm.size,
|
||||
is_system: createForm.is_system,
|
||||
target_device: createForm.target_device,
|
||||
vm_id: createForm.vm_id,
|
||||
path: createForm.path,
|
||||
read_bytes_sec: createForm.read_bytes_sec,
|
||||
write_bytes_sec: createForm.write_bytes_sec,
|
||||
read_iops_sec: createForm.read_iops_sec,
|
||||
write_iops_sec: createForm.write_iops_sec,
|
||||
read_bytes_sec_max: createForm.read_bytes_sec_max,
|
||||
write_bytes_sec_max: createForm.write_bytes_sec_max,
|
||||
read_iops_sec_max: createForm.read_iops_sec_max,
|
||||
write_iops_sec_max: createForm.write_iops_sec_max
|
||||
}
|
||||
|
||||
Object.keys(submitData).forEach(key => {
|
||||
if (submitData[key] === '' || submitData[key] === null || submitData[key] === undefined) {
|
||||
delete submitData[key]
|
||||
}
|
||||
})
|
||||
|
||||
await volumeStore.createVolume(submitData)
|
||||
message.success('创建成功')
|
||||
createModalVisible.value = false
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
console.error('创建失败:', error)
|
||||
if (error.response?.data?.message) {
|
||||
message.error(error.response.data.message)
|
||||
} else {
|
||||
message.error('创建失败')
|
||||
}
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
fetchList()
|
||||
fetchVmList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.volume-list-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
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: 15173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://111.170.7.193:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user