feat(web端): 项目初始化

master
tangqk 2025-08-05 09:57:49 +08:00
parent 2b39922d58
commit 5187fa408f
76 changed files with 18345 additions and 0 deletions

View File

@ -0,0 +1,3 @@
module.exports = {
extends: require.resolve('@umijs/max/eslint'),
};

14
web-fe/.gitignore vendored 100644
View File

@ -0,0 +1,14 @@
/node_modules
/.env.local
/.umirc.local.ts
/config/config.local.ts
/src/.umi
/src/.umi-production
/src/.umi-test
/.umi
/.umi-production
/.umi-test
/dist
/.mfsu
.swc
/serve/node_modules

View File

@ -0,0 +1,17 @@
{
"*.{md,json}": [
"prettier --cache --write"
],
"*.{js,jsx}": [
"max lint --fix --eslint-only",
"prettier --cache --write"
],
"*.{css,less}": [
"max lint --fix --stylelint-only",
"prettier --cache --write"
],
"*.ts?(x)": [
"max lint --fix --eslint-only",
"prettier --cache --parser=typescript --write"
]
}

2
web-fe/.npmrc 100644
View File

@ -0,0 +1,2 @@
registry=https://registry.npmmirror.com/

View File

@ -0,0 +1,3 @@
node_modules
.umi
.umi-production

View File

@ -0,0 +1,8 @@
{
"printWidth": 80,
"singleQuote": true,
"trailingComma": "all",
"proseWrap": "never",
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
}

View File

@ -0,0 +1,3 @@
module.exports = {
extends: require.resolve('@umijs/max/stylelint'),
};

34
web-fe/.umirc.ts 100644
View File

@ -0,0 +1,34 @@
import { defineConfig } from '@umijs/max';
export default defineConfig({
antd: {},
access: {},
model: {},
initialState: {},
request: {},
layout: false,
outputPath: 'serve/dist',
// 路由配置
routes: [
{
path: '/login',
component: '@/pages/login',
},
{
path: '/',
component: '@/pages/components/Layout/index',
routes: [
{
path: '/images',
component: '@/pages/images',
},
{
path: '/profile',
component: '@/pages/profile',
},
],
},
],
npmClient: 'pnpm',
});

3
web-fe/README.md 100644
View File

@ -0,0 +1,3 @@
# README
`@umijs/max` 模板项目,更多功能参考 [Umi Max 简介](https://umijs.org/docs/max/introduce)

View File

@ -0,0 +1,20 @@
const users = [
{ id: 0, name: 'Umi', nickName: 'U', gender: 'MALE' },
{ id: 1, name: 'Fish', nickName: 'B', gender: 'FEMALE' },
];
export default {
'GET /api/v1/queryUserList': (req: any, res: any) => {
res.json({
success: true,
data: { list: users },
errorCode: 0,
});
},
'PUT /api/v1/user/': (req: any, res: any) => {
res.json({
success: true,
errorCode: 0,
});
},
};

View File

@ -0,0 +1,27 @@
{
"private": true,
"author": "may505 <1072317596@qq.com>",
"scripts": {
"dev": "max dev",
"build": "max build",
"format": "prettier --cache --write .",
"postinstall": "max setup",
"setup": "max setup",
"start": "npm run dev"
},
"dependencies": {
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-components": "^2.4.4",
"@umijs/max": "^4.4.11",
"antd": "^5.4.0"
},
"devDependencies": {
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"lint-staged": "^13.2.0",
"prettier": "^2.8.7",
"prettier-plugin-organize-imports": "^3.2.2",
"prettier-plugin-packagejson": "^2.4.3",
"typescript": "^5.0.3"
}
}

13813
web-fe/pnpm-lock.yaml 100644

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.DS_Store

View File

@ -0,0 +1,27 @@
# 使用官方 Node.js 运行时作为基础镜像
# FROM registry.cn-hangzhou.aliyuncs.com/library/node:18-alpine
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:lts-slim
# 设置工作目录
WORKDIR /app
# 设置 npm 镜像源
RUN npm config set registry https://registry.npmmirror.com
# 复制所有项目文件
COPY . .
# 安装依赖
RUN npm install
RUN npm install -g pm2
# 设置执行权限
RUN chmod +x ./start.sh
# 暴露端口
EXPOSE 3000
# 1、使用 start.sh 脚本启动应用
# CMD ["./start.sh"]
# 2、使用node直接部署
CMD ["node", "index.js"]

View File

@ -0,0 +1,219 @@
# 紫光汇智 VDI 管理平台前端服务
## 项目简介
本项目是紫光汇智 VDI虚拟桌面基础设施管理平台的前端服务负责为企业级桌面云环境提供高效、可靠的 Web 管理界面。系统支持多种操作系统镜像的管理、用户权限控制、桌面分配与监控等功能,助力企业实现桌面云的集中化、自动化运维。
## 主要功能
- 镜像管理:支持多种操作系统镜像的上传、下载、删除、版本管理
- 用户与权限管理:多角色权限分级,安全可靠
- 桌面分配与监控:支持桌面分配、状态监控、资源统计
- 登录认证:支持多种登录方式,安全便捷
- 响应式设计:适配 PC 和移动端
## 技术栈
- 前端框架React + UmiJS + Ant Design
- 服务端Node.js + Express
- 进程管理PM2
- 容器化Docker 支持
## 部署方式
### 1. 使用 Docker 部署
- 支持一键构建和运行 Docker 容器,适合云服务器、生产环境
- 详见下方 Docker 相关命令
### 2. 使用 PM2 部署
- 适合传统物理机或虚拟机环境,支持进程守护、日志管理
---
# VDI 管理平台 - 服务器部署
## 📋 概述
这是 VDI 管理平台的前端服务器,使用 Express.js 提供静态文件服务,并通过 PM2 进行进程管理。
## 🚀 快速开始
### 1. 安装依赖
```bash
npm install
```
### 2. 启动服务
#### 方式一:使用启动脚本(推荐)
```bash
chmod +x start.sh
./start.sh
```
#### 方式二:手动启动
```bash
# 安装 PM2如果未安装
npm install -g pm2
# 启动应用
pm2 start ecosystem.config.js
```
### 3. 访问应用
打开浏览器访问http://localhost:3000
## 📊 PM2 管理命令
### 查看应用状态
```bash
pm2 status
```
### 查看日志
```bash
# 查看所有日志
pm2 logs vdi-web
# 实时查看日志
pm2 logs vdi-web --lines 100 -f
```
### 重启应用
```bash
pm2 restart vdi-web
```
### 停止应用
```bash
pm2 stop vdi-web
```
### 删除应用
```bash
pm2 delete vdi-web
```
### 监控应用
```bash
pm2 monit
```
## 🔧 开发模式
### 使用 nodemon 开发
```bash
npm run dev
```
### 直接启动
```bash
npm start
```
## 📁 目录结构
```
serve/
├── dist/ # 前端构建文件
├── logs/ # 日志文件
├── index.js # Express 服务器
├── package.json # 依赖配置
├── ecosystem.config.js # PM2 配置
├── start.sh # 启动脚本
├── stop.sh # 停止脚本
└── README.md # 说明文档
```
## ⚙️ 配置说明
### 环境变量
- `PORT`: 服务器端口默认3000
- `NODE_ENV`: 运行环境development/production
### PM2 配置
- **实例数**: 1个实例
- **内存限制**: 1GB
- **自动重启**: 启用
- **日志轮转**: 启用
## 🔒 安全特性
- 使用 Helmet 增强安全性
- 启用 gzip 压缩
- 静态文件缓存
- 错误处理中间件
## 📝 日志
日志文件位于 `logs/` 目录:
- `combined.log`: 合并日志
- `out.log`: 标准输出日志
- `error.log`: 错误日志
## 🚨 故障排除
### 端口被占用
```bash
# 查看端口占用
lsof -i :3000
# 杀死进程
kill -9 <PID>
```
### PM2 进程异常
```bash
# 重置 PM2
pm2 kill
pm2 start ecosystem.config.js
```
### 权限问题
```bash
# 给脚本执行权限
chmod +x start.sh stop.sh
```
## 📞 技术支持
如有问题,请联系紫光汇智技术支持团队。
## 项目结构
```text
web-fe/
├── mock/ # 模拟接口数据
├── package.json # 前端依赖配置
├── pnpm-lock.yaml # pnpm锁定文件
├── README.md # 前端说明文档
├── serve/ # 服务端Express+PM2+Docker
│ ├── dist/ # 前端构建产物(静态文件)
│ ├── logs/ # 日志目录
│ ├── node_modules/ # 服务端依赖
│ ├── Dockerfile # Docker 构建文件
│ ├── ecosystem.config.js# PM2 配置
│ ├── index.js # Express 启动入口
│ ├── package.json # 服务端依赖配置
│ ├── start.sh # 启动脚本
│ ├── stop.sh # 停止脚本
│ ├── README.md # 服务端说明文档
│ └── ...
├── src/ # 前端源码
│ ├── access.ts
│ ├── app.ts
│ ├── assets/
│ ├── components/
│ ├── constants/
│ ├── models/
│ ├── pages/ # 页面目录
│ ├── services/ # 接口服务
│ ├── utils/ # 工具函数
│ └── ...
├── tsconfig.json # TypeScript 配置
└── typings.d.ts # 全局类型声明
```
---

27
web-fe/serve/dist/306.async.js vendored 100644

File diff suppressed because one or more lines are too long

49
web-fe/serve/dist/35.async.js vendored 100644

File diff suppressed because one or more lines are too long

96
web-fe/serve/dist/402.async.js vendored 100644

File diff suppressed because one or more lines are too long

16
web-fe/serve/dist/492.async.js vendored 100644

File diff suppressed because one or more lines are too long

68
web-fe/serve/dist/555.async.js vendored 100644

File diff suppressed because one or more lines are too long

7
web-fe/serve/dist/67.async.js vendored 100644

File diff suppressed because one or more lines are too long

70
web-fe/serve/dist/713.async.js vendored 100644

File diff suppressed because one or more lines are too long

1
web-fe/serve/dist/725.async.js vendored 100644

File diff suppressed because one or more lines are too long

23
web-fe/serve/dist/796.async.js vendored 100644

File diff suppressed because one or more lines are too long

13
web-fe/serve/dist/index.html vendored 100644
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="/umi.css">
</head>
<body>
<div id="root"></div>
<script src="/umi.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.main-layout{min-height:100vh}.main-sider .logo{height:64px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:18px;font-weight:700;border-bottom:1px solid #303030}.main-header{background:#fff;padding:0 24px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 2px 8px #0000001a}.main-header .trigger{font-size:18px;color:#666}.main-header .trigger:hover{color:#1890ff}.main-header .header-right{display:flex;align-items:center;gap:16px}.main-header .header-right .welcome-text{color:#666;font-size:14px}.main-header .header-right .user-avatar{cursor:pointer;background:#1890ff}.main-header .header-right .user-avatar:hover{opacity:.8}.main-content{background:#fff;border-radius:8px;box-shadow:0 2px 8px #0000001a;min-height:calc(100vh - 112px)}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.main-layout{min-height:100vh}.main-sider .logo{height:64px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:18px;font-weight:700;border-bottom:1px solid #303030}.main-header{background:#fff;padding:0 24px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 2px 8px #0000001a}.main-header .trigger{font-size:18px;color:#666}.main-header .trigger:hover{color:#1890ff}.main-header .header-right{display:flex;align-items:center;gap:16px}.main-header .header-right .welcome-text{color:#666;font-size:14px}.main-header .header-right .user-avatar{cursor:pointer;background:#1890ff}.main-header .header-right .user-avatar:hover{opacity:.8}.main-content{background:#fff;border-radius:8px;box-shadow:0 2px 8px #0000001a;min-height:calc(100vh - 112px)}

View File

@ -0,0 +1 @@
"use strict";(self.webpackChunk=self.webpackChunk||[]).push([[661],{9665:function(b,m,t){t.r(m),t.d(m,{default:function(){return z}});var T=t(48305),d=t.n(T),u=t(75271),E=t(97102),v=t(39032),h=t(36646),B=t(44126),r=t(74970),D=t(50),y=t(78032),A=t(12944),Z=t(21317),I=t(10770),e=t(52676),p=function(){var N=(0,u.useState)([]),x=d()(N,2),c=x[0],F=x[1],O=(0,u.useState)(!1),f=d()(O,2),L=f[0],C=f[1],P=(0,u.useState)(null),j=d()(P,2),i=j[0],k=j[1],G=(0,u.useState)(!1),g=d()(G,2),V=g[0],o=g[1];(0,u.useEffect)(function(){w()},[]);var w=function(){C(!0),setTimeout(function(){var n=[{id:"1",name:"Windows 10 \u4E13\u4E1A\u7248",version:"v1.0.0",size:"15.2 GB",status:"active",createTime:"2024-01-15 10:30:00",description:"Windows 10 \u4E13\u4E1A\u7248\u955C\u50CF\uFF0C\u5305\u542B\u5E38\u7528\u529E\u516C\u8F6F\u4EF6"},{id:"2",name:"Ubuntu 22.04 LTS",version:"v2.1.0",size:"8.5 GB",status:"active",createTime:"2024-01-10 14:20:00",description:"Ubuntu 22.04 LTS \u670D\u52A1\u5668\u7248\u672C\uFF0C\u9002\u7528\u4E8E\u5F00\u53D1\u73AF\u5883"},{id:"3",name:"CentOS 8",version:"v1.5.0",size:"12.1 GB",status:"building",createTime:"2024-01-20 09:15:00",description:"CentOS 8 \u4F01\u4E1A\u7EA7\u670D\u52A1\u5668\u64CD\u4F5C\u7CFB\u7EDF"},{id:"4",name:"macOS Monterey",version:"v1.2.0",size:"18.7 GB",status:"inactive",createTime:"2024-01-05 16:45:00",description:"macOS Monterey \u5F00\u53D1\u73AF\u5883\u955C\u50CF"}];F(n),C(!1)},1e3)},S=function(n){var l={active:{color:"green",text:"\u53EF\u7528"},inactive:{color:"red",text:"\u4E0D\u53EF\u7528"},building:{color:"orange",text:"\u6784\u5EFA\u4E2D"}},s=l[n];return(0,e.jsx)(E.Z,{color:s.color,children:s.text})},M=function(n){k(n),o(!0)},K=function(n){v.ZP.info("\u7F16\u8F91\u955C\u50CF\uFF1A".concat(n.name))},U=function(n){h.Z.confirm({title:"\u786E\u8BA4\u5220\u9664",content:'\u786E\u5B9A\u8981\u5220\u9664\u955C\u50CF "'.concat(n.name,'" \u5417\uFF1F'),onOk:function(){F(c.filter(function(s){return s.id!==n.id})),v.ZP.success("\u5220\u9664\u6210\u529F")}})},W=[{title:"\u955C\u50CF\u540D\u79F0",dataIndex:"name",key:"name",width:200},{title:"\u7248\u672C",dataIndex:"version",key:"version",width:100},{title:"\u5927\u5C0F",dataIndex:"size",key:"size",width:100},{title:"\u72B6\u6001",dataIndex:"status",key:"status",width:100,render:function(n){return S(n)}},{title:"\u521B\u5EFA\u65F6\u95F4",dataIndex:"createTime",key:"createTime",width:180},{title:"\u64CD\u4F5C",key:"action",width:200,render:function(n,l){return(0,e.jsxs)(B.Z,{size:"small",children:[(0,e.jsx)(r.ZP,{type:"text",icon:(0,e.jsx)(Z.Z,{}),onClick:function(){return M(l)},title:"\u67E5\u770B\u8BE6\u60C5"}),(0,e.jsx)(D.Z,{title:"\u786E\u5B9A\u8981\u5220\u9664\u8FD9\u4E2A\u955C\u50CF\u5417\uFF1F",description:"\u5220\u9664\u540E\u65E0\u6CD5\u6062\u590D\uFF0C\u8BF7\u8C28\u614E\u64CD\u4F5C\u3002",onConfirm:function(){return U(l)},okText:"\u786E\u5B9A",cancelText:"\u53D6\u6D88",children:(0,e.jsx)(r.ZP,{type:"text",icon:(0,e.jsx)(I.Z,{}),title:"\u5220\u9664",danger:!0})})]})}}];return(0,e.jsxs)("div",{className:"image-list",children:[(0,e.jsx)(y.Z,{children:(0,e.jsx)(A.Z,{columns:W,dataSource:c,rowKey:"id",loading:L,pagination:{total:c.length,pageSize:10,showSizeChanger:!0,showQuickJumper:!0,showTotal:function(n){return"\u5171 ".concat(n," \u6761\u8BB0\u5F55")}}})}),(0,e.jsx)(h.Z,{title:"\u955C\u50CF\u8BE6\u60C5",open:V,onCancel:function(){return o(!1)},footer:[(0,e.jsx)(r.ZP,{onClick:function(){return o(!1)},children:"\u5173\u95ED"},"close")],width:600,children:i&&(0,e.jsxs)("div",{className:"image-detail",children:[(0,e.jsxs)("div",{className:"detail-item",children:[(0,e.jsx)("label",{children:"\u955C\u50CF\u540D\u79F0\uFF1A"}),(0,e.jsx)("span",{children:i.name})]}),(0,e.jsxs)("div",{className:"detail-item",children:[(0,e.jsx)("label",{children:"\u7248\u672C\uFF1A"}),(0,e.jsx)("span",{children:i.version})]}),(0,e.jsxs)("div",{className:"detail-item",children:[(0,e.jsx)("label",{children:"\u5927\u5C0F\uFF1A"}),(0,e.jsx)("span",{children:i.size})]}),(0,e.jsxs)("div",{className:"detail-item",children:[(0,e.jsx)("label",{children:"\u72B6\u6001\uFF1A"}),(0,e.jsx)("span",{children:S(i.status)})]}),(0,e.jsxs)("div",{className:"detail-item",children:[(0,e.jsx)("label",{children:"\u521B\u5EFA\u65F6\u95F4\uFF1A"}),(0,e.jsx)("span",{children:i.createTime})]}),(0,e.jsxs)("div",{className:"detail-item",children:[(0,e.jsx)("label",{children:"\u63CF\u8FF0\uFF1A"}),(0,e.jsx)("p",{children:i.description})]})]})})]})},z=p}}]);

View File

@ -0,0 +1 @@
.page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.page-header h2{margin:0;font-size:24px;font-weight:600;color:#333}.image-list .image-detail .detail-item{margin-bottom:16px}.image-list .image-detail .detail-item label{font-weight:600;color:#333;display:inline-block;width:100px}.image-list .image-detail .detail-item span{color:#666}.image-list .image-detail .detail-item p{margin:8px 0 0 100px;color:#666;line-height:1.6}.profile-page .profile-content .profile-header{display:flex;align-items:center;gap:16px;margin-bottom:16px}.profile-page .profile-content .profile-header .profile-info h3{margin:0 0 4px;font-size:20px;font-weight:600;color:#333}.profile-page .profile-content .profile-header .profile-info p{margin:0;color:#666;font-size:14px}.profile-page .profile-content .quick-actions{display:flex;gap:12px;flex-wrap:wrap}@media (max-width: 768px){.page-header{flex-direction:column;align-items:flex-start;gap:12px}.profile-content .profile-header{flex-direction:column;text-align:center}.profile-content .quick-actions{justify-content:center}}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.login-container{display:flex;min-height:100vh;background:linear-gradient(135deg,#1890ff,#722ed1)}.login-container:before{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background:url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba%28255,255,255,0.1%29" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url%28%23grid%29"/></svg>');opacity:.3}.login-left{flex:1 1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}.brand-content{text-align:center;color:#fff;z-index:1;position:relative;max-width:400px;padding:40px}.brand-logo{margin-bottom:30px}.brand-logo .logo-icon{font-size:60px;margin-bottom:20px;display:block;color:#ffffffe6}.brand-logo .brand-title{font-size:36px;font-weight:700;margin:0;color:#fff;text-shadow:0 2px 4px rgba(0,0,0,.3)}.brand-subtitle{font-size:20px;font-weight:500;margin-bottom:30px;color:#ffffffe6}.brand-description{margin-bottom:40px}.brand-description p{font-size:16px;line-height:1.6;margin:8px 0;color:#fffc}.brand-features{display:flex;justify-content:space-around;flex-wrap:wrap;gap:20px}.brand-features .feature-item{display:flex;flex-direction:column;align-items:center;gap:8px}.brand-features .feature-item .feature-icon{font-size:24px}.brand-features .feature-item span:last-child{font-size:14px;color:#fffc}.login-right{flex:1 1;display:flex;align-items:center;justify-content:center;padding:40px}.login-form-container{width:100%;max-width:400px}.login-header{text-align:center;margin-bottom:40px}.login-header .login-title{font-size:28px;font-weight:600;color:#fff;margin:0 0 8px}.login-header .login-subtitle{font-size:16px;color:#fff;margin:0}.login-form .ant-form-item{margin-bottom:24px}.login-input{height:48px;border-radius:8px;border:1px solid #d9d9d9;font-size:16px}.login-input:hover,.login-input:focus,.login-input.ant-input-focused{border-color:#1890ff;box-shadow:0 0 0 2px #1890ff33}.login-input .input-icon{color:#bfbfbf;font-size:16px}.login-button{height:48px;border-radius:8px;font-size:16px;font-weight:500;background:linear-gradient(135deg,#1890ff,#722ed1);border:none;margin-top:8px}.login-button:hover{background:linear-gradient(135deg,#40a9ff,#9254de);transform:translateY(-1px);box-shadow:0 4px 12px #1890ff4d}.login-button:active{transform:translateY(0)}.login-tips{text-align:center;margin-top:24px;padding:16px;background-color:#f6f8fa;border-radius:8px;border:1px solid #e8e8e8}.login-tips p{margin:0;color:#595959;font-size:14px}.login-footer{text-align:center;margin-top:40px}.login-footer p{color:#8c8c8c;font-size:12px;margin:0}@media (max-width: 768px){.login-container{flex-direction:column}.login-left{flex:none;height:200px;padding:20px}.brand-content{padding:20px}.brand-logo .logo-icon{font-size:40px}.brand-title{font-size:24px}.brand-subtitle{font-size:16px}.brand-description p{font-size:14px}.login-right{flex:1 1;padding:20px}}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.page-header h2{margin:0;font-size:24px;font-weight:600;color:#333}.image-list .image-detail .detail-item{margin-bottom:16px}.image-list .image-detail .detail-item label{font-weight:600;color:#333;display:inline-block;width:100px}.image-list .image-detail .detail-item span{color:#666}.image-list .image-detail .detail-item p{margin:8px 0 0 100px;color:#666;line-height:1.6}.profile-page .profile-content .profile-header{display:flex;align-items:center;gap:16px;margin-bottom:16px}.profile-page .profile-content .profile-header .profile-info h3{margin:0 0 4px;font-size:20px;font-weight:600;color:#333}.profile-page .profile-content .profile-header .profile-info p{margin:0;color:#666;font-size:14px}.profile-page .profile-content .quick-actions{display:flex;gap:12px;flex-wrap:wrap}@media (max-width: 768px){.page-header{flex-direction:column;align-items:flex-start;gap:12px}.profile-content .profile-header{flex-direction:column;text-align:center}.profile-content .quick-actions{justify-content:center}}

1
web-fe/serve/dist/umi.css vendored 100644
View File

@ -0,0 +1 @@
html,body{width:100%;height:100%}input::-ms-clear,input::-ms-reveal{display:none}*,*:before,*:after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{margin:0}[tabindex="-1"]:focus{outline:none}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5em;font-weight:500}p{margin-top:0;margin-bottom:1em}abbr[title],abbr[data-original-title]{text-decoration:underline dotted;border-bottom:0;cursor:help}address{margin-bottom:1em;font-style:normal;line-height:inherit}input[type=text],input[type=password],input[type=number],textarea{-webkit-appearance:none}ol,ul,dl{margin-top:0;margin-bottom:1em}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:500}dd{margin-bottom:.5em;margin-left:0}blockquote{margin:0 0 1em}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}pre,code,kbd,samp{font-size:1em;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace}pre{margin-top:0;margin-bottom:1em;overflow:auto}figure{margin:0 0 1em}img{vertical-align:middle;border-style:none}a,area,button,[role=button],input:not([type=range]),label,select,summary,textarea{touch-action:manipulation}table{border-collapse:collapse}caption{padding-top:.75em;padding-bottom:.3em;text-align:left;caption-side:bottom}input,button,select,optgroup,textarea{margin:0;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{padding:0;border-style:none}input[type=radio],input[type=checkbox]{box-sizing:border-box;padding:0}input[type=date],input[type=time],input[type=datetime-local],input[type=month]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;margin:0;padding:0;border:0}legend{display:block;width:100%;max-width:100%;margin-bottom:.5em;padding:0;color:inherit;font-size:1.5em;line-height:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item}template{display:none}[hidden]{display:none!important}mark{padding:.2em;background-color:#feffe6}

19
web-fe/serve/dist/umi.js vendored 100644

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,21 @@
#!/bin/bash
# Docker 专用启动脚本
echo "🐳 Docker 容器启动中..."
# 创建日志目录
mkdir -p logs
# 设置环境变量
export NODE_ENV=production
export HOST=0.0.0.0
export PORT=3000
# 直接启动 Node.js 应用
echo "🚀 启动 Node.js 应用..."
echo "📍 绑定地址: 0.0.0.0:3000"
echo "🌐 访问地址: http://localhost:3000"
# 启动应用并保持前台运行
exec node index.js

View File

@ -0,0 +1,60 @@
module.exports = {
apps: [
{
name: 'vdi-web',
script: 'index.js',
cwd: __dirname,
instances: 1,
exec_mode: 'fork',
env: {
NODE_ENV: 'production',
PORT: 3000
},
env_development: {
NODE_ENV: 'development',
PORT: 3000
},
// 日志配置
log_file: './logs/combined.log',
out_file: './logs/out.log',
error_file: './logs/error.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
// 自动重启配置
autorestart: true,
watch: false,
max_memory_restart: '1G',
// 启动配置
min_uptime: '10s',
max_restarts: 10,
// 监控配置
merge_logs: true,
// 优雅关闭
kill_timeout: 5000,
listen_timeout: 3000,
// 环境变量
env_production: {
NODE_ENV: 'production',
PORT: 3000
}
}
],
// 部署配置(可选)
deploy: {
production: {
user: 'node',
host: 'localhost',
ref: 'origin/main',
repo: 'git@github.com:your-repo/vdi-web.git',
path: '/var/www/vdi-web',
'pre-deploy-local': '',
'post-deploy': 'npm install && pm2 reload ecosystem.config.js --env production',
'pre-setup': ''
}
}
};

View File

@ -0,0 +1,46 @@
const express = require('express');
const path = require('path');
const compression = require('compression');
const helmet = require('helmet');
const app = express();
const PORT = process.env.PORT || 3000;
// 安全中间件
app.use(helmet({
contentSecurityPolicy: false, // 允许内联脚本和样式
}));
// 压缩中间件
app.use(compression());
// 静态文件服务
app.use(express.static(path.join(__dirname, 'dist')));
// 处理 SPA 路由,所有未匹配的路由都返回 index.html
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
// 错误处理中间件
app.use((err, req, res, next) => {
console.error('服务器错误:', err.stack);
res.status(500).send('服务器内部错误');
});
app.listen(PORT, () => {
console.log(`🚀 VDI 管理平台服务器已启动`);
console.log(`📍 访问地址: http://localhost:${PORT}`);
console.log(`⏰ 启动时间: ${new Date().toLocaleString()}`);
});
// 优雅关闭
process.on('SIGTERM', () => {
console.log('收到 SIGTERM 信号,正在关闭服务器...');
process.exit(0);
});
process.on('SIGINT', () => {
console.log('收到 SIGINT 信号,正在关闭服务器...');
process.exit(0);
});

View File

@ -0,0 +1,2 @@
# 日志目录
# 此文件用于保持 logs 目录在 git 中

View File

@ -0,0 +1,3 @@
2025-07-30 11:05:04 +08:00: 🚀 VDI 管理平台服务器已启动
2025-07-30 11:05:04 +08:00: 📍 访问地址: http://localhost:3000
2025-07-30 11:05:04 +08:00: ⏰ 启动时间: 2025/7/30 11:05:04

View File

View File

@ -0,0 +1,3 @@
2025-07-30 11:05:04 +08:00: 🚀 VDI 管理平台服务器已启动
2025-07-30 11:05:04 +08:00: 📍 访问地址: http://localhost:3000
2025-07-30 11:05:04 +08:00: ⏰ 启动时间: 2025/7/30 11:05:04

View File

@ -0,0 +1,33 @@
{
"name": "vdi-web-server",
"version": "1.0.0",
"description": "VDI 管理平台前端服务器",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"pm2:start": "pm2 start ecosystem.config.js",
"pm2:stop": "pm2 stop vdi-web",
"pm2:restart": "pm2 restart vdi-web",
"pm2:delete": "pm2 delete vdi-web",
"pm2:logs": "pm2 logs vdi-web",
"pm2:monit": "pm2 monit"
},
"keywords": [
"vdi",
"express",
"pm2",
"frontend"
],
"author": "紫光汇智",
"license": "MIT",
"dependencies": {
"compression": "^1.7.4",
"express": "^4.18.2",
"helmet": "^7.1.0",
"pm2": "^6.0.8"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,31 @@
#!/bin/bash
# VDI 管理平台启动脚本
echo "🚀 启动 VDI 管理平台..."
# # 检查 Node.js 是否安装
# if ! command -v node &> /dev/null; then
# echo "❌ 错误: Node.js 未安装,请先安装 Node.js"
# exit 1
# fi
# 检查 PM2 是否安装
if ! command -v pm2 &> /dev/null; then
echo "📦 正在安装 PM2..."
npm install -g pm2
fi
# # 安装依赖
# echo "📦 安装依赖..."
# npm install
# 创建日志目录
mkdir -p logs
# 启动应用 - 使用前台模式
echo "🚀 使用 PM2 启动应用..."
pm2 start ecosystem.config.js --no-daemon
# 注意:--no-daemon 参数让 PM2 在前台运行,不会退出
# 这样 Docker 容器就能保持运行状态

View File

@ -0,0 +1,12 @@
#!/bin/bash
# VDI 管理平台停止脚本
echo "🛑 停止 VDI 管理平台..."
# 停止应用
pm2 stop vdi-web
echo "✅ 应用已停止!"
echo "📊 查看状态: pm2 status"
echo "🚀 重新启动: pm2 start vdi-web"

View File

@ -0,0 +1,10 @@
export default (initialState: API.UserInfo) => {
// 在这里按照初始化数据定义项目中的权限,统一管理
// 参考文档 https://umijs.org/docs/max/access
const canSeeAdmin = !!(
initialState && initialState.name !== 'dontHaveAccess'
);
return {
canSeeAdmin,
};
};

16
web-fe/src/app.ts 100644
View File

@ -0,0 +1,16 @@
// 运行时配置
// 全局初始化数据配置,用于 Layout 用户信息和权限初始化
// 更多信息见文档https://umijs.org/docs/api/runtime-config#getinitialstate
export async function getInitialState(): Promise<{ name: string }> {
return { name: '@umijs/max' };
}
// export const layout = () => {
// return {
// logo: 'https://img.alicdn.com/tfs/TB1YHEpwUT1gK0jSZFhXXaAtVXa-28-27.svg',
// menu: {
// locale: false,
// },
// };
// };

View File

View File

@ -0,0 +1,4 @@
.title {
margin: 0 auto;
font-weight: 200;
}

View File

@ -0,0 +1,23 @@
import { Layout, Row, Typography } from 'antd';
import React from 'react';
import styles from './Guide.less';
interface Props {
name: string;
}
// 脚手架示例组件
const Guide: React.FC<Props> = (props) => {
const { name } = props;
return (
<Layout>
<Row>
<Typography.Title level={3} className={styles.title}>
使 <strong>{name}</strong>
</Typography.Title>
</Row>
</Layout>
);
};
export default Guide;

View File

@ -0,0 +1,64 @@
.main-layout {
min-height: 100vh;
}
.main-sider {
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid #303030;
}
}
.main-header {
background: #fff;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.trigger {
font-size: 18px;
color: #666;
&:hover {
color: #1890ff;
}
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
.welcome-text {
color: #666;
font-size: 14px;
}
.user-avatar {
cursor: pointer;
background: #1890ff;
&:hover {
opacity: 0.8;
}
}
}
}
.main-content {
// margin: 24px;
// padding: 24px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-height: calc(100vh - 112px);
}

View File

@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react';
import { Layout, Menu, Button, Avatar, Dropdown, message } from 'antd';
import {
AppstoreOutlined,
UserOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from '@ant-design/icons';
import { history, useLocation, Outlet } from 'umi';
import './index.less';
const { Header, Sider, Content } = Layout;
const MainLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const [username, setUsername] = useState('');
const location = useLocation();
useEffect(() => {
// 检查登录状态
const isLoggedIn = localStorage.getItem('isLoggedIn');
const currentUsername = localStorage.getItem('username');
if (!isLoggedIn) {
message.error('请先登录!');
history.push('/login');
return;
}
setUsername(currentUsername || '');
}, []);
const handleMenuClick = (key: string) => {
// 使用路由导航
history.push(`/${key}`);
};
const handleLogout = () => {
localStorage.removeItem('isLoggedIn');
localStorage.removeItem('username');
message.success('已退出登录');
history.push('/login');
};
const userMenu = (
<Menu>
<Menu.Item key="profile" icon={<UserOutlined />} onClick={() => history.push('/profile')}>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="logout" icon={<LogoutOutlined />} onClick={handleLogout}>
退
</Menu.Item>
</Menu>
);
// 根据当前路径确定选中的菜单项
const getSelectedKey = () => {
const path = location.pathname;
if (path === '/images') return 'images';
if (path === '/profile') return 'profile';
return 'images'; // 默认选中镜像列表
};
return (
<Layout className="main-layout">
<Sider
trigger={null}
collapsible
collapsed={collapsed}
className="main-sider"
>
<div className="logo">
{!collapsed && <span>VDI </span>}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[getSelectedKey()]}
onClick={({ key }) => handleMenuClick(key)}
>
<Menu.Item key="images" icon={<AppstoreOutlined />}>
</Menu.Item>
<Menu.Item key="profile" icon={<UserOutlined />}>
</Menu.Item>
</Menu>
</Sider>
<Layout>
<Header className="main-header">
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
className="trigger"
/>
<div className="header-right">
<span className="welcome-text">{username}</span>
<Dropdown overlay={userMenu} placement="bottomRight">
<Avatar icon={<UserOutlined />} className="user-avatar" />
</Dropdown>
</div>
</Header>
<Content className="main-content">
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default MainLayout;

View File

@ -0,0 +1,2 @@
import Guide from './Guide';
export default Guide;

View File

@ -0,0 +1 @@
export const DEFAULT_NAME = 'Umi Max';

View File

@ -0,0 +1,63 @@
.main-layout {
min-height: 100vh;
}
.main-sider {
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid #303030;
}
}
.main-header {
background: #fff;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.trigger {
font-size: 18px;
color: #666;
&:hover {
color: #1890ff;
}
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
.welcome-text {
color: #666;
font-size: 14px;
}
.user-avatar {
cursor: pointer;
background: #1890ff;
&:hover {
opacity: 0.8;
}
}
}
}
.main-content {
// margin: 24px;
// padding: 24px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-height: calc(100vh - 112px);
}

View File

@ -0,0 +1,29 @@
import React, { useState, useEffect } from 'react';
import { Layout, Menu, Button, Avatar, Dropdown, message } from 'antd';
import {
AppstoreOutlined,
UserOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from '@ant-design/icons';
import { history, useLocation, Outlet } from 'umi';
import './index.less';
const { Header, Sider, Content } = Layout;
const MainLayout: React.FC = () => {
const location = useLocation();
return (
<Layout className="main-layout">
<Content className="main-content">
<Outlet />
</Content>
</Layout>
);
};
export default MainLayout;

View File

@ -0,0 +1,13 @@
// 全局共享数据示例
import { DEFAULT_NAME } from '@/constants';
import { useState } from 'react';
const useUser = () => {
const [name, setName] = useState<string>(DEFAULT_NAME);
return {
name,
setName,
};
};
export default useUser;

View File

@ -0,0 +1,64 @@
.main-layout {
min-height: 100vh;
}
.main-sider {
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid #303030;
}
}
.main-header {
background: #fff;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.trigger {
font-size: 18px;
color: #666;
&:hover {
color: #1890ff;
}
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
.welcome-text {
color: #666;
font-size: 14px;
}
.user-avatar {
cursor: pointer;
background: #1890ff;
&:hover {
opacity: 0.8;
}
}
}
}
.main-content {
// margin: 24px;
// padding: 24px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-height: calc(100vh - 112px);
}

View File

@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react';
import { Layout, Menu, Button, Avatar, Dropdown, message } from 'antd';
import {
AppstoreOutlined,
UserOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from '@ant-design/icons';
import { history, useLocation, Outlet } from 'umi';
import './index.less';
const { Header, Sider, Content } = Layout;
const MainLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const [username, setUsername] = useState('');
const location = useLocation();
useEffect(() => {
// 检查登录状态
const isLoggedIn = localStorage.getItem('isLoggedIn');
const currentUsername = localStorage.getItem('username');
if (!isLoggedIn) {
message.error('请先登录!');
history.push('/login');
return;
}
setUsername(currentUsername || '');
}, []);
const handleMenuClick = (key: string) => {
// 使用路由导航
history.push(`/${key}`);
};
const handleLogout = () => {
localStorage.removeItem('isLoggedIn');
localStorage.removeItem('username');
message.success('已退出登录');
history.push('/login');
};
const userMenu = (
<Menu>
<Menu.Item key="profile" icon={<UserOutlined />} onClick={() => history.push('/profile')}>
</Menu.Item>
<Menu.Divider />
<Menu.Item key="logout" icon={<LogoutOutlined />} onClick={handleLogout}>
退
</Menu.Item>
</Menu>
);
// 根据当前路径确定选中的菜单项
const getSelectedKey = () => {
const path = location.pathname;
if (path === '/images') return 'images';
if (path === '/profile') return 'profile';
return 'images'; // 默认选中镜像列表
};
return (
<Layout className="main-layout">
<Sider
trigger={null}
collapsible
collapsed={collapsed}
className="main-sider"
>
<div className="logo">
{!collapsed && <span>VDI</span>}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[getSelectedKey()]}
onClick={({ key }) => handleMenuClick(key)}
>
<Menu.Item key="images" icon={<AppstoreOutlined />}>
</Menu.Item>
<Menu.Item key="profile" icon={<UserOutlined />}>
</Menu.Item>
</Menu>
</Sider>
<Layout>
<Header className="main-header">
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
className="trigger"
/>
<div className="header-right">
<span className="welcome-text">{username}</span>
<Dropdown overlay={userMenu} placement="bottomRight">
<Avatar icon={<UserOutlined />} className="user-avatar" />
</Dropdown>
</div>
</Header>
<Content className="main-content">
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default MainLayout;

View File

@ -0,0 +1,9 @@
const DocsPage = () => {
return (
<div>
<p>This is umi docs.</p>
</div>
);
};
export default DocsPage;

View File

@ -0,0 +1,93 @@
// 页面头部样式
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
}
// 镜像列表样式
.image-list {
.image-detail {
.detail-item {
margin-bottom: 16px;
label {
font-weight: 600;
color: #333;
display: inline-block;
width: 100px;
}
span {
color: #666;
}
p {
margin: 8px 0 0 100px;
color: #666;
line-height: 1.6;
}
}
}
}
// 个人资料样式
.profile-page {
.profile-content {
.profile-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
.profile-info {
h3 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
p {
margin: 0;
color: #666;
font-size: 14px;
}
}
}
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.profile-content {
.profile-header {
flex-direction: column;
text-align: center;
}
.quick-actions {
justify-content: center;
}
}
}

View File

@ -0,0 +1,235 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Space, Modal, message, Popconfirm } from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined
} from '@ant-design/icons';
import './index.less';
interface ImageItem {
id: string;
name: string;
version: string;
size: string;
status: 'active' | 'inactive' | 'building';
createTime: string;
description: string;
}
const ImageList: React.FC = () => {
const [images, setImages] = useState<ImageItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null);
const [detailVisible, setDetailVisible] = useState(false);
useEffect(() => {
loadImages();
}, []);
const loadImages = () => {
setLoading(true);
// 模拟数据加载
setTimeout(() => {
const mockData: ImageItem[] = [
{
id: '1',
name: 'Windows 10 专业版',
version: 'v1.0.0',
size: '15.2 GB',
status: 'active',
createTime: '2024-01-15 10:30:00',
description: 'Windows 10 专业版镜像,包含常用办公软件'
},
{
id: '2',
name: 'Ubuntu 22.04 LTS',
version: 'v2.1.0',
size: '8.5 GB',
status: 'active',
createTime: '2024-01-10 14:20:00',
description: 'Ubuntu 22.04 LTS 服务器版本,适用于开发环境'
},
{
id: '3',
name: 'CentOS 8',
version: 'v1.5.0',
size: '12.1 GB',
status: 'building',
createTime: '2024-01-20 09:15:00',
description: 'CentOS 8 企业级服务器操作系统'
},
{
id: '4',
name: 'macOS Monterey',
version: 'v1.2.0',
size: '18.7 GB',
status: 'inactive',
createTime: '2024-01-05 16:45:00',
description: 'macOS Monterey 开发环境镜像'
}
];
setImages(mockData);
setLoading(false);
}, 1000);
};
const getStatusTag = (status: string) => {
const statusMap = {
active: { color: 'green', text: '可用' },
inactive: { color: 'red', text: '不可用' },
building: { color: 'orange', text: '构建中' }
};
const config = statusMap[status as keyof typeof statusMap];
return <Tag color={config.color}>{config.text}</Tag>;
};
const handleViewDetail = (record: ImageItem) => {
setSelectedImage(record);
setDetailVisible(true);
};
const handleEdit = (record: ImageItem) => {
message.info(`编辑镜像:${record.name}`);
};
const handleDelete = (record: ImageItem) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除镜像 "${record.name}" 吗?`,
onOk: () => {
setImages(images.filter(img => img.id !== record.id));
message.success('删除成功');
}
});
};
const columns = [
{
title: '镜像名称',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: '版本',
dataIndex: 'version',
key: 'version',
width: 100,
},
{
title: '大小',
dataIndex: 'size',
key: 'size',
width: 100,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => getStatusTag(status),
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180,
},
{
title: '操作',
key: 'action',
width: 200,
render: (_: any, record: ImageItem) => (
<Space size="small">
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
title="查看详情"
/>
<Popconfirm
title="确定要删除这个镜像吗?"
description="删除后无法恢复,请谨慎操作。"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button
type="text"
icon={<DeleteOutlined />}
title="删除"
danger
/>
</Popconfirm>
</Space>
),
},
];
return (
<div className="image-list">
<Card>
<Table
columns={columns}
dataSource={images}
rowKey="id"
loading={loading}
pagination={{
total: images.length,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`,
}}
/>
</Card>
<Modal
title="镜像详情"
open={detailVisible}
onCancel={() => setDetailVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailVisible(false)}>
</Button>
]}
width={600}
>
{selectedImage && (
<div className="image-detail">
<div className="detail-item">
<label></label>
<span>{selectedImage.name}</span>
</div>
<div className="detail-item">
<label></label>
<span>{selectedImage.version}</span>
</div>
<div className="detail-item">
<label></label>
<span>{selectedImage.size}</span>
</div>
<div className="detail-item">
<label></label>
<span>{getStatusTag(selectedImage.status)}</span>
</div>
<div className="detail-item">
<label></label>
<span>{selectedImage.createTime}</span>
</div>
<div className="detail-item">
<label></label>
<p>{selectedImage.description}</p>
</div>
</div>
)}
</Modal>
</div>
);
};
export default ImageList;

View File

@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { history } from 'umi';
export default function HomePage() {
useEffect(() => {
// 检查登录状态
const isLoggedIn = localStorage.getItem('isLoggedIn');
if (isLoggedIn) {
// 已登录,跳转到镜像列表页面
history.push('/images');
} else {
// 未登录,跳转到登录页面
history.push('/login');
}
}, []);
return null; // 不渲染任何内容,因为会立即跳转
}

View File

@ -0,0 +1,238 @@
.login-container {
display: flex;
min-height: 100vh;
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
opacity: 0.3;
}
}
/* 左侧品牌区域 */
.login-left {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.brand-content {
text-align: center;
color: white;
z-index: 1;
position: relative;
max-width: 400px;
padding: 40px;
}
.brand-logo {
margin-bottom: 30px;
.logo-icon {
font-size: 60px;
margin-bottom: 20px;
display: block;
color: rgba(255, 255, 255, 0.9);
}
.brand-title {
font-size: 36px;
font-weight: 700;
margin: 0;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
}
.brand-subtitle {
font-size: 20px;
font-weight: 500;
margin-bottom: 30px;
color: rgba(255, 255, 255, 0.9);
}
.brand-description {
margin-bottom: 40px;
p {
font-size: 16px;
line-height: 1.6;
margin: 8px 0;
color: rgba(255, 255, 255, 0.8);
}
}
.brand-features {
display: flex;
justify-content: space-around;
flex-wrap: wrap;
gap: 20px;
.feature-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
.feature-icon {
font-size: 24px;
}
span:last-child {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
}
}
}
/* 右侧登录区域 */
.login-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.login-form-container {
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 40px;
.login-title {
font-size: 28px;
font-weight: 600;
color: #fff;
margin: 0 0 8px 0;
}
.login-subtitle {
font-size: 16px;
color: #fff;
margin: 0;
}
}
.login-form {
.ant-form-item {
margin-bottom: 24px;
}
}
.login-input {
height: 48px;
border-radius: 8px;
border: 1px solid #d9d9d9;
font-size: 16px;
&:hover,
&:focus,
&.ant-input-focused {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.input-icon {
color: #bfbfbf;
font-size: 16px;
}
}
.login-button {
height: 48px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
border: none;
margin-top: 8px;
&:hover {
background: linear-gradient(135deg, #40a9ff 0%, #9254de 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
&:active {
transform: translateY(0);
}
}
.login-tips {
text-align: center;
margin-top: 24px;
padding: 16px;
background-color: #f6f8fa;
border-radius: 8px;
border: 1px solid #e8e8e8;
p {
margin: 0;
color: #595959;
font-size: 14px;
}
}
.login-footer {
text-align: center;
margin-top: 40px;
p {
color: #8c8c8c;
font-size: 12px;
margin: 0;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-container {
flex-direction: column;
}
.login-left {
flex: none;
height: 200px;
padding: 20px;
}
.brand-content {
padding: 20px;
}
.brand-logo .logo-icon {
font-size: 40px;
}
.brand-title {
font-size: 24px;
}
.brand-subtitle {
font-size: 16px;
}
.brand-description p {
font-size: 14px;
}
.login-right {
flex: 1;
padding: 20px;
}
}

View File

@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { Form, Input, Button, message } from 'antd';
import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import { history } from '@umijs/max';
import './index.less';
interface LoginForm {
username: string;
password: string;
}
const LoginPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const onFinish = async (values: LoginForm) => {
setLoading(true);
try {
// 模拟登录验证
if (values.username === 'admin' && values.password === '123456') {
message.success('登录成功!');
// 存储登录状态
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('username', values.username);
// 跳转到镜像列表页面
history.push('/images');
} else {
message.error('用户名或密码错误!');
}
} catch (error) {
message.error('登录失败,请重试!');
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
{/* 左侧品牌区域 */}
<div className="login-left">
<div className="brand-content">
<div className="brand-logo">
<SafetyCertificateOutlined className="logo-icon" />
<h1 className="brand-title"></h1>
</div>
<div className="brand-subtitle">VDI </div>
<div className="brand-description">
<p></p>
<p></p>
</div>
<div className="brand-features">
<div className="feature-item">
<span className="feature-icon">🔒</span>
<span></span>
</div>
<div className="feature-item">
<span className="feature-icon"></span>
<span></span>
</div>
<div className="feature-item">
<span className="feature-icon">🔄</span>
<span></span>
</div>
</div>
</div>
</div>
{/* 右侧登录区域 */}
<div className="login-right">
<div className="login-form-container">
<div className="login-header">
<h2 className="login-title"></h2>
<p className="login-subtitle">使VDI</p>
</div>
<Form
name="login"
onFinish={onFinish}
autoComplete="off"
size="large"
className="login-form"
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名!' }]}
>
<Input
prefix={<UserOutlined className="input-icon" />}
placeholder="请输入用户名"
className="login-input"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码!' }]}
>
<Input.Password
prefix={<LockOutlined className="input-icon" />}
placeholder="请输入密码"
className="login-input"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={loading}
className="login-button"
block
>
{loading ? '登录中...' : '登录'}
</Button>
</Form.Item>
</Form>
<div className="login-tips">
<p>admin / 123456</p>
</div>
<div className="login-footer">
<p>© 2025 </p>
</div>
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@ -0,0 +1,93 @@
// 页面头部样式
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
}
// 镜像列表样式
.image-list {
.image-detail {
.detail-item {
margin-bottom: 16px;
label {
font-weight: 600;
color: #333;
display: inline-block;
width: 100px;
}
span {
color: #666;
}
p {
margin: 8px 0 0 100px;
color: #666;
line-height: 1.6;
}
}
}
}
// 个人资料样式
.profile-page {
.profile-content {
.profile-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
.profile-info {
h3 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
p {
margin: 0;
color: #666;
font-size: 14px;
}
}
}
.quick-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.profile-content {
.profile-header {
flex-direction: column;
text-align: center;
}
.quick-actions {
justify-content: center;
}
}
}

View File

@ -0,0 +1,270 @@
import React, { useState, useEffect } from 'react';
import { Card, Avatar, Descriptions, Button, Form, Input, Modal, message, Divider, List, Tag } from 'antd';
import {
UserOutlined,
EditOutlined,
SaveOutlined,
KeyOutlined,
BellOutlined,
SecurityScanOutlined,
HistoryOutlined
} from '@ant-design/icons';
import './index.less';
interface UserInfo {
username: string;
email: string;
phone: string;
department: string;
role: string;
lastLoginTime: string;
createTime: string;
}
interface LoginHistory {
id: string;
loginTime: string;
ip: string;
location: string;
device: string;
}
const Profile: React.FC = () => {
const [userInfo, setUserInfo] = useState<UserInfo>({
username: '',
email: 'admin@example.com',
phone: '138****8888',
department: '技术部',
role: '管理员',
lastLoginTime: '2024-01-20 15:30:00',
createTime: '2023-01-01 00:00:00'
});
const [editModalVisible, setEditModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [loginHistory, setLoginHistory] = useState<LoginHistory[]>([]);
useEffect(() => {
const username = localStorage.getItem('username') || '';
setUserInfo(prev => ({ ...prev, username }));
loadLoginHistory();
}, []);
const loadLoginHistory = () => {
// 模拟登录历史数据
const mockHistory: LoginHistory[] = [
{
id: '1',
loginTime: '2024-01-20 15:30:00',
ip: '192.168.1.100',
location: '北京市',
device: 'Chrome 120.0.0.0'
},
{
id: '2',
loginTime: '2024-01-19 09:15:00',
ip: '192.168.1.100',
location: '北京市',
device: 'Chrome 120.0.0.0'
},
{
id: '3',
loginTime: '2024-01-18 14:20:00',
ip: '192.168.1.100',
location: '北京市',
device: 'Chrome 120.0.0.0'
}
];
setLoginHistory(mockHistory);
};
const handleEditProfile = (values: any) => {
setUserInfo(prev => ({ ...prev, ...values }));
setEditModalVisible(false);
message.success('个人信息更新成功');
};
const handleChangePassword = (values: any) => {
if (values.newPassword !== values.confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
setPasswordModalVisible(false);
message.success('密码修改成功');
};
return (
<div className="profile-page">
<div className="page-header">
<h2></h2>
</div>
<div className="profile-content">
{/* 基本信息卡片 */}
<Card
title="基本信息"
extra={
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => setEditModalVisible(true)}
>
</Button>
}
style={{ marginBottom: 24 }}
>
<div className="profile-header">
<Avatar size={80} icon={<UserOutlined />} />
<div className="profile-info">
<h3>{userInfo.username}</h3>
<p>{userInfo.role}</p>
</div>
</div>
<Divider />
<Descriptions column={2}>
<Descriptions.Item label="用户名">{userInfo.username}</Descriptions.Item>
<Descriptions.Item label="邮箱">{userInfo.email}</Descriptions.Item>
<Descriptions.Item label="手机号">{userInfo.phone}</Descriptions.Item>
<Descriptions.Item label="部门">{userInfo.department}</Descriptions.Item>
<Descriptions.Item label="角色">{userInfo.role}</Descriptions.Item>
<Descriptions.Item label="最后登录">{userInfo.lastLoginTime}</Descriptions.Item>
<Descriptions.Item label="注册时间">{userInfo.createTime}</Descriptions.Item>
</Descriptions>
</Card>
{/* 快捷操作卡片 */}
<Card title="快捷操作" style={{ marginBottom: 24 }}>
<div className="quick-actions">
<Button
type="primary"
icon={<KeyOutlined />}
onClick={() => setPasswordModalVisible(true)}
>
</Button>
<Button icon={<BellOutlined />}>
</Button>
<Button icon={<SecurityScanOutlined />}>
</Button>
</div>
</Card>
{/* 登录历史卡片 */}
<Card title="登录历史" extra={<HistoryOutlined />}>
<List
dataSource={loginHistory}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
title={`登录时间:${item.loginTime}`}
description={
<div>
<p>IP{item.ip}</p>
<p>{item.location}</p>
<p>{item.device}</p>
</div>
}
/>
<Tag color="green"></Tag>
</List.Item>
)}
/>
</Card>
</div>
{/* 编辑个人信息模态框 */}
<Modal
title="编辑个人信息"
open={editModalVisible}
onCancel={() => setEditModalVisible(false)}
footer={null}
>
<Form
layout="vertical"
initialValues={userInfo}
onFinish={handleEditProfile}
>
<Form.Item
name="email"
label="邮箱"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' }
]}
>
<Input />
</Form.Item>
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号' }
]}
>
<Input />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} block>
</Button>
</Form.Item>
</Form>
</Modal>
{/* 修改密码模态框 */}
<Modal
title="修改密码"
open={passwordModalVisible}
onCancel={() => setPasswordModalVisible(false)}
footer={null}
>
<Form
layout="vertical"
onFinish={handleChangePassword}
>
<Form.Item
name="oldPassword"
label="当前密码"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度不能少于6位' }
]}
>
<Input.Password />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认新密码"
rules={[{ required: true, message: '请确认新密码' }]}
>
<Input.Password />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} block>
</Button>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default Profile;

View File

@ -0,0 +1,96 @@
/* eslint-disable */
// 该文件由 OneAPI 自动生成,请勿手动修改!
import { request } from '@umijs/max';
/** 此处后端没有提供注释 GET /api/v1/queryUserList */
export async function queryUserList(
params: {
// query
/** keyword */
keyword?: string;
/** current */
current?: number;
/** pageSize */
pageSize?: number;
},
options?: { [key: string]: any },
) {
return request<API.Result_PageInfo_UserInfo__>('/api/v1/queryUserList', {
method: 'GET',
params: {
...params,
},
...(options || {}),
});
}
/** 此处后端没有提供注释 POST /api/v1/user */
export async function addUser(
body?: API.UserInfoVO,
options?: { [key: string]: any },
) {
return request<API.Result_UserInfo_>('/api/v1/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** 此处后端没有提供注释 GET /api/v1/user/${param0} */
export async function getUserDetail(
params: {
// path
/** userId */
userId?: string;
},
options?: { [key: string]: any },
) {
const { userId: param0 } = params;
return request<API.Result_UserInfo_>(`/api/v1/user/${param0}`, {
method: 'GET',
params: { ...params },
...(options || {}),
});
}
/** 此处后端没有提供注释 PUT /api/v1/user/${param0} */
export async function modifyUser(
params: {
// path
/** userId */
userId?: string;
},
body?: API.UserInfoVO,
options?: { [key: string]: any },
) {
const { userId: param0 } = params;
return request<API.Result_UserInfo_>(`/api/v1/user/${param0}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
params: { ...params },
data: body,
...(options || {}),
});
}
/** 此处后端没有提供注释 DELETE /api/v1/user/${param0} */
export async function deleteUser(
params: {
// path
/** userId */
userId?: string;
},
options?: { [key: string]: any },
) {
const { userId: param0 } = params;
return request<API.Result_string_>(`/api/v1/user/${param0}`, {
method: 'DELETE',
params: { ...params },
...(options || {}),
});
}

View File

@ -0,0 +1,7 @@
/* eslint-disable */
// 该文件由 OneAPI 自动生成,请勿手动修改!
import * as UserController from './UserController';
export default {
UserController,
};

View File

@ -0,0 +1,68 @@
/* eslint-disable */
// 该文件由 OneAPI 自动生成,请勿手动修改!
declare namespace API {
interface PageInfo {
/**
1 */
current?: number;
pageSize?: number;
total?: number;
list?: Array<Record<string, any>>;
}
interface PageInfo_UserInfo_ {
/**
1 */
current?: number;
pageSize?: number;
total?: number;
list?: Array<UserInfo>;
}
interface Result {
success?: boolean;
errorMessage?: string;
data?: Record<string, any>;
}
interface Result_PageInfo_UserInfo__ {
success?: boolean;
errorMessage?: string;
data?: PageInfo_UserInfo_;
}
interface Result_UserInfo_ {
success?: boolean;
errorMessage?: string;
data?: UserInfo;
}
interface Result_string_ {
success?: boolean;
errorMessage?: string;
data?: string;
}
type UserGenderEnum = 'MALE' | 'FEMALE';
interface UserInfo {
id?: string;
name?: string;
/** nick */
nickName?: string;
/** email */
email?: string;
gender?: UserGenderEnum;
}
interface UserInfoVO {
name?: string;
/** nick */
nickName?: string;
/** email */
email?: string;
}
type definitions_0 = null;
}

View File

@ -0,0 +1,4 @@
// 示例方法,没有实际意义
export function trim(str: string) {
return str.trim();
}

View File

@ -0,0 +1,3 @@
{
"extends": "./src/.umi/tsconfig.json"
}

1
web-fe/typings.d.ts vendored 100644
View File

@ -0,0 +1 @@
import '@umijs/max/typings';