初始提交

This commit is contained in:
2026-02-12 15:40:10 +08:00
commit 1035446d4d
52 changed files with 11397 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.git
*.md
.env
.env.local
.env.*.local
+6
View File
@@ -0,0 +1,6 @@
# API地址
VITE_API_BASE_URL=/api
# 应用配置
VITE_APP_TITLE=KVM主控操作平台
VITE_APP_VERSION=1.0.0
+6
View File
@@ -0,0 +1,6 @@
# API地址
VITE_API_BASE_URL=http://localhost:3000/api
# 应用配置
VITE_APP_TITLE=KVM主控操作平台
VITE_APP_VERSION=1.0.0
+1
View File
@@ -0,0 +1 @@
node_modules/
+10
View File
@@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
+201
View File
@@ -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
View File
@@ -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>
View File
+18
View File
@@ -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
+3004
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 589 KiB

+21
View File
@@ -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>
+24
View File
@@ -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
}
+31
View File
@@ -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 })
}
+7
View File
@@ -0,0 +1,7 @@
import request from './request'
// 获取操作日志列表
export const getLogList = (params) => {
return request.get('/logs', { params })
}
+26
View File
@@ -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)
}
+26
View File
@@ -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 })
}
+71
View File
@@ -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 })
}
+49
View File
@@ -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
+17
View File
@@ -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 })
}
+32
View File
@@ -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 })
}
+81
View File
@@ -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)
}
+43
View File
@@ -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 })
}
+75
View File
@@ -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>
+39
View File
@@ -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>
+54
View File
@@ -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
}
}
+52
View File
@@ -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
}
}
+77
View File
@@ -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
}
}
+336
View File
@@ -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
View File
@@ -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')
+108
View File
@@ -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
+32
View File
@@ -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
}
})
+131
View File
@@ -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
}
})
+114
View File
@@ -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
}
})
+9
View File
@@ -0,0 +1,9 @@
/* 深色模式样式补充 */
.dark-mode {
color-scheme: dark;
}
/* 确保所有文本在深色模式下可见 */
.dark-mode * {
color-scheme: dark;
}
+275
View File
@@ -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);
}
+58
View File
@@ -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);
}
+431
View File
@@ -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>
+178
View File
@@ -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>
+182
View File
@@ -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>
+474
View File
@@ -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>
+638
View File
@@ -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>
+935
View File
@@ -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>
+211
View File
@@ -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>
+417
View File
@@ -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>
+622
View File
@@ -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表示不限制默认1048576MB1TB</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>
+419
View File
@@ -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>
+444
View File
@@ -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>
+950
View File
@@ -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>
View File
+22
View File
@@ -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
}
}
}
})