初始提交
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