Initial commit: 快速Demo演示系统
This commit is contained in:
commit
3c562fae2a
|
|
@ -0,0 +1,10 @@
|
|||
# 服务端口
|
||||
PORT=8888
|
||||
|
||||
# 基础域名(部署到服务器时配置,本地开发留空使用localhost)
|
||||
# 示例:BASE_DOMAIN=demo.example.com
|
||||
BASE_DOMAIN=
|
||||
|
||||
# 项目端口范围
|
||||
PROJECT_PORT_START=9000
|
||||
PROJECT_PORT_END=9100
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
client/dist/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
|
||||
# Data files
|
||||
server/data/
|
||||
|
||||
# Project uploads
|
||||
projects/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Lock files (optional, uncomment if needed)
|
||||
# package-lock.json
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
## 自动部署Web系统实现计划
|
||||
|
||||
### 技术栈
|
||||
- **后端**: Node.js + Express
|
||||
- **前端**: Vue.js 3 + Vite + Tailwind CSS
|
||||
- **认证**: JWT Token
|
||||
- **设计风格**: 简约现代,无衬线字体,流畅动画
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
auto-deploy-demo/
|
||||
├── server/ # 后端代码
|
||||
│ ├── index.js # 主入口
|
||||
│ ├── routes/ # API路由
|
||||
│ ├── middleware/ # JWT认证中间件
|
||||
│ └── services/ # 业务逻辑
|
||||
├── client/ # Vue.js前端
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ ├── api/ # API封装
|
||||
│ │ ├── stores/ # Pinia状态管理
|
||||
│ │ └── router/ # 路由配置
|
||||
│ └── tailwind.config.js
|
||||
├── projects/ # 部署项目存储
|
||||
├── data/ # 数据存储
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### 设计规范
|
||||
- **配色**: 白色/浅灰背景 + 蓝色主色调 + 绿色成功状态
|
||||
- **字体**: Inter / SF Pro (现代无衬线)
|
||||
- **动画**: 平滑过渡 (transition-all duration-200)
|
||||
- **布局**: 简洁卡片式,充足留白
|
||||
|
||||
### 实现步骤
|
||||
|
||||
**第一阶段: 后端核心 (步骤1-6)**
|
||||
1. 初始化项目,创建package.json
|
||||
2. Express服务器主入口
|
||||
3. JWT认证中间件
|
||||
4. 用户认证API
|
||||
5. 项目管理API (CRUD)
|
||||
6. 文件上传模块
|
||||
|
||||
**第二阶段: 部署功能 (步骤7-10)**
|
||||
7. 端口自动分配 (3100-3200)
|
||||
8. 项目启动/停止
|
||||
9. 访问链接生成
|
||||
10. 日志记录
|
||||
|
||||
**第三阶段: Vue前端 (步骤11-18)**
|
||||
11. 初始化Vue 3 + Vite + Tailwind
|
||||
12. 路由和状态管理配置
|
||||
13. 登录页面 (简约现代风格)
|
||||
14. 项目列表页面
|
||||
15. 文件上传组件 (拖拽支持)
|
||||
16. 部署进度显示
|
||||
17. 响应式布局优化
|
||||
18. 动画和交互优化
|
||||
|
||||
**第四阶段: 文档 (步骤19-20)**
|
||||
19. 部署文档
|
||||
20. 使用说明
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
## 域名配置功能实现方案
|
||||
|
||||
### 问题分析
|
||||
当前系统使用 `localhost` 生成 demo 项目链接,部署到服务器后需要使用指定域名。
|
||||
|
||||
### 实现步骤
|
||||
|
||||
#### 1. 创建环境配置文件
|
||||
- 创建 `.env.example` 示例配置文件
|
||||
- 创建 `.env` 实际配置文件(本地开发用)
|
||||
- 支持配置项:
|
||||
- `PORT` - 主服务端口(默认 8888)
|
||||
- `BASE_DOMAIN` - 基础域名(如 `demo.example.com`)
|
||||
- `PROJECT_PORT_START` - 项目端口起始值(默认 9000)
|
||||
- `PROJECT_PORT_END` - 项目端口结束值(默认 9100)
|
||||
|
||||
#### 2. 安装 dotenv 依赖
|
||||
- 添加 `dotenv` 包用于读取环境变量
|
||||
|
||||
#### 3. 创建配置模块
|
||||
- 创建 `server/config/index.js` 统一管理配置
|
||||
- 提供获取项目 URL 的方法,支持域名模式
|
||||
|
||||
#### 4. 修改 processManager.js
|
||||
- 使用配置模块获取域名
|
||||
- 生成 URL 时根据配置使用域名或 IP:端口 格式
|
||||
- 支持两种模式:
|
||||
- **子域名模式**:`http://project-id.demo.example.com`
|
||||
- **端口模式**:`http://demo.example.com:9000`
|
||||
|
||||
#### 5. 修改 server/index.js
|
||||
- 启动时加载环境变量配置
|
||||
|
||||
#### 6. 前端适配
|
||||
- 前端 API 请求使用相对路径(已支持)
|
||||
- 无需额外修改
|
||||
|
||||
### 配置示例
|
||||
|
||||
```env
|
||||
# .env 示例
|
||||
PORT=8888
|
||||
BASE_DOMAIN=demo.example.com
|
||||
PROJECT_PORT_START=9000
|
||||
PROJECT_PORT_END=9100
|
||||
```
|
||||
|
||||
### URL 生成规则
|
||||
- 本地开发(无 BASE_DOMAIN):`http://localhost:9000`
|
||||
- 服务器部署(有 BASE_DOMAIN):`http://demo.example.com:9000`
|
||||
|
||||
### 文件修改清单
|
||||
1. 新建:`.env.example`、`.env`、`server/config/index.js`
|
||||
2. 修改:`server/index.js`、`server/services/processManager.js`
|
||||
3. 更新:`package.json`(添加 dotenv 依赖)
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
# 快速部署系统 - 部署文档
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Node.js 18.0 或更高版本
|
||||
- npm 9.0 或更高版本
|
||||
- 腾讯云服务器 (推荐配置: 2核4G)
|
||||
|
||||
## 一、服务器环境配置
|
||||
|
||||
### 1. 安装 Node.js
|
||||
|
||||
```bash
|
||||
# 使用 nvm 安装 Node.js
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||
source ~/.bashrc
|
||||
nvm install 18
|
||||
nvm use 18
|
||||
```
|
||||
|
||||
### 2. 安装 PM2 (进程管理器)
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
### 3. 配置防火墙
|
||||
|
||||
```bash
|
||||
# 开放系统端口
|
||||
firewall-cmd --permanent --add-port=8888/tcp
|
||||
firewall-cmd --permanent --add-port=9000-9100/tcp
|
||||
firewall-cmd --reload
|
||||
```
|
||||
|
||||
### 4. 腾讯云安全组配置
|
||||
|
||||
在腾讯云控制台配置安全组规则:
|
||||
- 开放 8888 端口 (管理界面)
|
||||
- 开放 9000-9100 端口范围 (部署的项目)
|
||||
|
||||
## 二、项目部署
|
||||
|
||||
### 1. 上传项目代码
|
||||
|
||||
```bash
|
||||
# 将项目上传到服务器
|
||||
scp -r auto-deploy-demo root@your-server-ip:/opt/
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
cd /opt/auto-deploy-demo
|
||||
|
||||
# 安装后端依赖
|
||||
npm install
|
||||
|
||||
# 安装前端依赖并构建
|
||||
cd client
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
### 3. 配置环境变量
|
||||
|
||||
创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
cat > .env << EOF
|
||||
PORT=8888
|
||||
JWT_SECRET=your-secure-secret-key-change-this
|
||||
HOST=your-server-ip
|
||||
EOF
|
||||
```
|
||||
|
||||
### 4. 启动服务
|
||||
|
||||
```bash
|
||||
# 使用 PM2 启动
|
||||
pm2 start server/index.js --name "deploy-system"
|
||||
|
||||
# 设置开机自启
|
||||
pm2 startup
|
||||
pm2 save
|
||||
```
|
||||
|
||||
### 5. 验证部署
|
||||
|
||||
访问 `http://your-server-ip:8888` 检查系统是否正常运行。
|
||||
|
||||
## 三、Nginx 反向代理配置 (可选)
|
||||
|
||||
如需使用域名访问,可配置 Nginx:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8888;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 四、常见问题
|
||||
|
||||
### Q: 端口被占用怎么办?
|
||||
|
||||
检查端口占用:
|
||||
```bash
|
||||
lsof -i :8888
|
||||
```
|
||||
|
||||
### Q: 如何查看日志?
|
||||
|
||||
```bash
|
||||
pm2 logs deploy-system
|
||||
```
|
||||
|
||||
### Q: 如何重启服务?
|
||||
|
||||
```bash
|
||||
pm2 restart deploy-system
|
||||
```
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
# 端口配置说明
|
||||
|
||||
## 端口分配
|
||||
|
||||
### 管理系统端口
|
||||
- **端口**: 8888
|
||||
- **用途**: Web管理界面
|
||||
- **访问地址**: http://localhost:8888 (本地) 或 http://your-server-ip:8888 (服务器)
|
||||
|
||||
### 演示项目端口范围
|
||||
- **端口范围**: 9000-9100
|
||||
- **用途**: 部署的前端演示项目
|
||||
- **分配方式**: 系统自动分配可用端口
|
||||
- **最大并发**: 最多同时运行100个项目
|
||||
|
||||
## 端口选择说明
|
||||
|
||||
### 管理系统端口 (8888)
|
||||
- 选择8888作为管理端口,避开常见端口
|
||||
- 不与常见服务冲突(如80、443、3000、8080、3306等)
|
||||
- 便于记忆和配置
|
||||
|
||||
### 演示项目端口 (9000-9100)
|
||||
- 选择9000-9100范围,远离常用端口段
|
||||
- 避免与系统服务端口冲突
|
||||
- 提供100个可用端口,满足并发需求
|
||||
|
||||
## 防火墙配置
|
||||
|
||||
### Windows防火墙
|
||||
```powershell
|
||||
# 开放管理端口
|
||||
New-NetFirewallRule -DisplayName "Deploy System" -Direction Inbound -LocalPort 8888 -Protocol TCP -Action Allow
|
||||
|
||||
# 开放演示项目端口范围
|
||||
New-NetFirewallRule -DisplayName "Deploy Projects" -Direction Inbound -LocalPort 9000-9100 -Protocol TCP -Action Allow
|
||||
```
|
||||
|
||||
### Linux防火墙 (firewalld)
|
||||
```bash
|
||||
# 开放管理端口
|
||||
firewall-cmd --permanent --add-port=8888/tcp
|
||||
|
||||
# 开放演示项目端口范围
|
||||
firewall-cmd --permanent --add-port=9000-9100/tcp
|
||||
|
||||
# 重载防火墙
|
||||
firewall-cmd --reload
|
||||
```
|
||||
|
||||
### 腾讯云安全组
|
||||
在腾讯云控制台配置以下安全组规则:
|
||||
- **入站规则**:
|
||||
- 协议: TCP
|
||||
- 端口: 8888
|
||||
- 策略: 允许
|
||||
- 协议: TCP
|
||||
- 端口: 9000-9100
|
||||
- 策略: 允许
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
创建 `.env` 文件配置端口:
|
||||
|
||||
```bash
|
||||
PORT=8888
|
||||
JWT_SECRET=your-secure-secret-key-change-this
|
||||
HOST=your-server-ip
|
||||
```
|
||||
|
||||
## 端口冲突排查
|
||||
|
||||
### 检查端口占用
|
||||
|
||||
**Windows:**
|
||||
```powershell
|
||||
Get-NetTCPConnection -State Listen | Where-Object {$_.LocalPort -eq 8888}
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
lsof -i :8888
|
||||
netstat -tuln | grep 8888
|
||||
```
|
||||
|
||||
### 解决端口冲突
|
||||
|
||||
如果端口被占用,可以:
|
||||
1. 停止占用端口的进程
|
||||
2. 修改 `.env` 文件中的 `PORT` 值
|
||||
3. 重启服务
|
||||
|
||||
## 端口使用示例
|
||||
|
||||
### 本地开发
|
||||
```
|
||||
管理界面: http://localhost:8888
|
||||
演示项目: http://localhost:9000-9100
|
||||
```
|
||||
|
||||
### 服务器部署
|
||||
```
|
||||
管理界面: http://your-server-ip:8888
|
||||
演示项目: http://your-server-ip:9000-9100
|
||||
```
|
||||
|
||||
### 域名访问 (Nginx反向代理)
|
||||
```
|
||||
管理界面: http://your-domain.com
|
||||
演示项目: http://your-domain.com:9000-9100
|
||||
```
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
# 快速Demo演示系统
|
||||
|
||||
一个简易的前端项目部署管理平台,支持快速上传、部署和运行前端演示项目。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **快速部署** - 拖拽上传文件,一键启动项目
|
||||
- **多项目支持** - 同时运行多个演示项目
|
||||
- **自动端口分配** - 自动分配可用端口(9000-9100)
|
||||
- **SPA路由支持** - 自动处理前端路由刷新问题
|
||||
- **安全登录** - 支持登录失败锁定机制
|
||||
- **密码修改** - 用户可自行修改密码
|
||||
- **域名配置** - 支持自定义域名生成项目链接
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Node.js + Express
|
||||
- **前端**: Vue 3 + Vite + Tailwind CSS
|
||||
- **状态管理**: Pinia
|
||||
- **进程管理**: PM2
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js >= 16.0.0
|
||||
- npm >= 8.0.0
|
||||
- 操作系统: Windows / Linux / macOS
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 安装后端依赖
|
||||
npm install
|
||||
|
||||
# 安装前端依赖并构建
|
||||
cd client
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
### 配置环境变量
|
||||
|
||||
复制配置文件并修改:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```env
|
||||
# 服务端口
|
||||
PORT=8888
|
||||
|
||||
# 基础域名(部署到服务器时配置)
|
||||
BASE_DOMAIN=your-domain.com
|
||||
|
||||
# 项目端口范围
|
||||
PROJECT_PORT_START=9000
|
||||
PROJECT_PORT_END=9100
|
||||
```
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
访问 http://localhost:8888 进入系统。
|
||||
|
||||
### 默认账号
|
||||
|
||||
- 用户名: `admin`
|
||||
- 密码: `admin123`
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 创建项目
|
||||
|
||||
1. 点击右上角「新建项目」按钮
|
||||
2. 填写项目名称和描述
|
||||
3. 拖拽或点击上传项目文件
|
||||
4. 点击「创建项目」完成
|
||||
|
||||
### 部署项目
|
||||
|
||||
1. 在项目列表点击「启动项目」
|
||||
2. 系统自动分配端口并启动
|
||||
3. 点击生成的链接访问项目
|
||||
|
||||
### 修改密码
|
||||
|
||||
1. 点击右上角「设置」按钮
|
||||
2. 输入当前密码和新密码
|
||||
3. 点击「确认修改」
|
||||
|
||||
### 安全特性
|
||||
|
||||
- 连续3次密码错误将锁定账户10分钟
|
||||
- 锁定期间显示实时倒计时
|
||||
- 提供「忘记密码」入口
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
auto-deploy-demo/
|
||||
├── server/ # 后端代码
|
||||
│ ├── index.js # 入口文件
|
||||
│ ├── config/ # 配置模块
|
||||
│ ├── routes/ # API路由
|
||||
│ │ ├── auth.js # 认证相关
|
||||
│ │ ├── projects.js # 项目管理
|
||||
│ │ └── deploy.js # 部署相关
|
||||
│ ├── services/ # 业务服务
|
||||
│ │ ├── projectService.js
|
||||
│ │ └── processManager.js
|
||||
│ ├── middleware/ # 中间件
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ └── loginLimiter.js
|
||||
│ └── data/ # 数据存储目录
|
||||
├── client/ # 前端代码
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ └── stores/ # 状态管理
|
||||
│ └── dist/ # 构建产物
|
||||
├── projects/ # 上传的项目存储
|
||||
├── deploy/ # 部署脚本
|
||||
└── .env # 环境配置
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### 认证相关
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/auth/login | 用户登录 |
|
||||
| GET | /api/auth/verify | 验证Token |
|
||||
| POST | /api/auth/change-password | 修改密码 |
|
||||
| GET | /api/auth/lock-status/:username | 获取锁定状态 |
|
||||
|
||||
### 项目管理
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /api/projects | 获取项目列表 |
|
||||
| POST | /api/projects | 创建项目 |
|
||||
| GET | /api/projects/:id | 获取项目详情 |
|
||||
| DELETE | /api/projects/:id | 删除项目 |
|
||||
|
||||
### 部署相关
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /api/deploy/:id/start | 启动项目 |
|
||||
| POST | /api/deploy/:id/stop | 停止项目 |
|
||||
| GET | /api/deploy/:id/status | 获取运行状态 |
|
||||
| GET | /api/deploy/:id/logs | 获取运行日志 |
|
||||
|
||||
## 服务器部署
|
||||
|
||||
### 使用部署脚本
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
cd deploy
|
||||
.\deploy.ps1
|
||||
```
|
||||
|
||||
### 手动部署
|
||||
|
||||
1. **安装Node.js**
|
||||
```bash
|
||||
curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
|
||||
sudo yum install -y nodejs
|
||||
sudo npm install -g pm2
|
||||
```
|
||||
|
||||
2. **上传文件**
|
||||
```bash
|
||||
scp -r server projects client/dist package.json .env user@server:/path/to/app/
|
||||
```
|
||||
|
||||
3. **启动服务**
|
||||
```bash
|
||||
npm install --production
|
||||
pm2 start server/index.js --name auto-deploy-demo
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
4. **配置防火墙**
|
||||
```bash
|
||||
sudo firewall-cmd --permanent --add-port=8888/tcp
|
||||
sudo firewall-cmd --permanent --add-port=9000-9100/tcp
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
详细部署指南请参考 [deploy/DEPLOY_GUIDE.md](deploy/DEPLOY_GUIDE.md)
|
||||
|
||||
## 支持的项目类型
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| 静态HTML | 直接部署,无需构建 |
|
||||
| Vue/React构建产物 | 上传dist目录内容 |
|
||||
| Vite项目 | 自动识别并启动 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **文件大小**: 单个文件最大100MB
|
||||
2. **端口范围**:
|
||||
- 管理系统: 8888
|
||||
- 演示项目: 9000-9100
|
||||
3. **并发限制**: 最多同时运行100个项目
|
||||
4. **数据备份**: 请定期备份 `server/data` 和 `projects` 目录
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 上传后项目无法访问?
|
||||
|
||||
检查项目是否包含 `index.html` 入口文件。
|
||||
|
||||
### Q: 启动失败怎么办?
|
||||
|
||||
查看项目详情页的操作日志,了解错误原因。
|
||||
|
||||
### Q: 刷新页面显示404?
|
||||
|
||||
系统已支持SPA路由回退,如仍有问题请检查项目路由配置。
|
||||
|
||||
### Q: 如何修改登录密码?
|
||||
|
||||
点击右上角「设置」按钮,在弹窗中修改密码。
|
||||
|
||||
### Q: 密码错误多次被锁定?
|
||||
|
||||
连续3次密码错误将锁定10分钟,请等待倒计时结束后重试。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题或建议,请联系系统管理员。
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>快速部署系统</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "auto-deploy-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"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",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"vite": "^5.0.10",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.32",
|
||||
"autoprefixer": "^10.4.16"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 265 B |
|
|
@ -0,0 +1,6 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import axios from 'axios'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
const getAuthHeaders = () => ({
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
})
|
||||
|
||||
const api = {
|
||||
async login(username, password) {
|
||||
const response = await axios.post(`${API_BASE}/auth/login`, { username, password })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async verifyToken() {
|
||||
const response = await axios.get(`${API_BASE}/auth/verify`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getProjects() {
|
||||
const response = await axios.get(`${API_BASE}/projects`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getProject(id) {
|
||||
const response = await axios.get(`${API_BASE}/projects/${id}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createProject(formData) {
|
||||
const response = await axios.post(`${API_BASE}/projects`, formData, {
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteProject(id) {
|
||||
const response = await axios.delete(`${API_BASE}/projects/${id}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async startProject(id) {
|
||||
const response = await axios.post(`${API_BASE}/deploy/${id}/start`, {}, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async stopProject(id) {
|
||||
const response = await axios.post(`${API_BASE}/deploy/${id}/stop`, {}, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getProjectLogs(id) {
|
||||
const response = await axios.get(`${API_BASE}/deploy/${id}/logs`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50" @click.self="$emit('close')">
|
||||
<div class="bg-white rounded-2xl w-full max-w-md max-h-[90vh] overflow-hidden animate-slide-up">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900">修改密码</h2>
|
||||
<button @click="$emit('close')" class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">当前密码</label>
|
||||
<input
|
||||
v-model="form.currentPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="请输入当前密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">新密码</label>
|
||||
<input
|
||||
v-model="form.newPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">确认新密码</label>
|
||||
<input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="请再次输入新密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="p-3 bg-red-50 border border-red-100 rounded-lg text-red-600 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="p-3 bg-green-50 border border-green-100 rounded-lg text-green-600 text-sm">
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3 pt-2">
|
||||
<button type="button" @click="$emit('close')" class="btn-secondary flex-1">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="btn-primary flex-1"
|
||||
>
|
||||
<span v-if="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
修改中...
|
||||
</span>
|
||||
<span v-else>确认修改</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const form = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
if (form.newPassword.length < 6) {
|
||||
error.value = '新密码长度至少6位'
|
||||
return
|
||||
}
|
||||
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
error.value = '两次输入的新密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
await axios.post('/api/auth/change-password', {
|
||||
currentPassword: form.currentPassword,
|
||||
newPassword: form.newPassword
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
success.value = '密码修改成功'
|
||||
form.currentPassword = ''
|
||||
form.newPassword = ''
|
||||
form.confirmPassword = ''
|
||||
|
||||
setTimeout(() => {
|
||||
emit('success')
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.error || '密码修改失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
<template>
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50" @click.self="$emit('close')">
|
||||
<div class="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-hidden animate-slide-up">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900">新建项目</h2>
|
||||
<button @click="$emit('close')" class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="p-6 space-y-5 overflow-y-auto">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">项目名称 *</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="请输入项目名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">项目描述</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="input resize-none"
|
||||
rows="2"
|
||||
placeholder="请输入项目描述(可选)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">项目文件 *</label>
|
||||
|
||||
<div
|
||||
class="border-2 border-dashed rounded-xl p-8 text-center transition-all duration-200"
|
||||
:class="[
|
||||
isDragging
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
]"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<div class="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 mb-1">拖拽文件到此处或点击上传</p>
|
||||
<p class="text-sm text-gray-400">支持批量上传前端项目文件</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.files.length > 0" class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="(file, index) in form.files"
|
||||
:key="index"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700 truncate">{{ file.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-500">{{ formatSize(file.size) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeFile(index)"
|
||||
class="p-1 hover:bg-gray-200 rounded transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="p-3 bg-red-50 border border-red-100 rounded-lg text-red-600 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="uploadProgress > 0 && uploadProgress < 100" class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600">上传中...</span>
|
||||
<span class="text-primary-600 font-medium">{{ uploadProgress }}%</span>
|
||||
</div>
|
||||
<div class="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary-500 rounded-full transition-all duration-300"
|
||||
:style="{ width: `${uploadProgress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3 pt-2">
|
||||
<button type="button" @click="$emit('close')" class="btn-secondary flex-1">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || form.files.length === 0"
|
||||
class="btn-primary flex-1"
|
||||
>
|
||||
<span v-if="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
创建中...
|
||||
</span>
|
||||
<span v-else>创建项目</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const fileInput = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const uploadProgress = ref(0)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
files: []
|
||||
})
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const files = Array.from(e.target.files)
|
||||
addFiles(files)
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
isDragging.value = false
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
addFiles(files)
|
||||
}
|
||||
|
||||
const addFiles = (files) => {
|
||||
files.forEach(file => {
|
||||
if (!form.files.some(f => f.name === file.name)) {
|
||||
form.files.push(file)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeFile = (index) => {
|
||||
form.files.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (form.files.length === 0) {
|
||||
error.value = '请至少上传一个文件'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
uploadProgress.value = 0
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('name', form.name)
|
||||
formData.append('description', form.description)
|
||||
|
||||
form.files.forEach(file => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
await projectStore.createProject(formData)
|
||||
emit('success')
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.error || '创建失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
uploadProgress.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: { guest: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/project/:id',
|
||||
name: 'ProjectDetail',
|
||||
component: () => import('../views/ProjectDetail.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (to.meta.guest && authStore.isAuthenticated) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('token') || null)
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
const login = async (username, password) => {
|
||||
const response = await api.login(username, password)
|
||||
token.value = response.token
|
||||
user.value = response.user
|
||||
localStorage.setItem('token', response.token)
|
||||
localStorage.setItem('user', JSON.stringify(response.user))
|
||||
return response
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
token.value = null
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
const verifyToken = async () => {
|
||||
if (!token.value) return false
|
||||
try {
|
||||
const response = await api.verifyToken()
|
||||
user.value = response.user
|
||||
return true
|
||||
} catch {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
verifyToken
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api'
|
||||
|
||||
export const useProjectStore = defineStore('project', () => {
|
||||
const projects = ref([])
|
||||
const currentProject = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const fetchProjects = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
projects.value = await api.getProjects()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProject = async (id) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
currentProject.value = await api.getProject(id)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createProject = async (formData) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const project = await api.createProject(formData)
|
||||
projects.value.unshift(project)
|
||||
return project
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProject = async (id) => {
|
||||
try {
|
||||
await api.deleteProject(id)
|
||||
projects.value = projects.value.filter(p => p.id !== id)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const startProject = async (id) => {
|
||||
try {
|
||||
const result = await api.startProject(id)
|
||||
const project = projects.value.find(p => p.id === id)
|
||||
if (project) {
|
||||
project.status = 'running'
|
||||
project.port = result.port
|
||||
project.url = result.url
|
||||
}
|
||||
if (currentProject.value?.id === id) {
|
||||
currentProject.value.status = 'running'
|
||||
currentProject.value.port = result.port
|
||||
currentProject.value.url = result.url
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const stopProject = async (id) => {
|
||||
try {
|
||||
await api.stopProject(id)
|
||||
const project = projects.value.find(p => p.id === id)
|
||||
if (project) {
|
||||
project.status = 'stopped'
|
||||
project.port = null
|
||||
project.url = null
|
||||
}
|
||||
if (currentProject.value?.id === id) {
|
||||
currentProject.value.status = 'stopped'
|
||||
currentProject.value.port = null
|
||||
currentProject.value.url = null
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projects,
|
||||
currentProject,
|
||||
loading,
|
||||
error,
|
||||
fetchProjects,
|
||||
fetchProject,
|
||||
createProject,
|
||||
deleteProject,
|
||||
startProject,
|
||||
stopProject
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-500 text-white hover:bg-primary-600
|
||||
focus:ring-primary-500 active:bg-primary-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-gray-100 text-gray-700 hover:bg-gray-200
|
||||
focus:ring-gray-500 active:bg-gray-300;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-red-500 text-white hover:bg-red-600
|
||||
focus:ring-red-500 active:bg-red-700;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-4 py-3 border border-gray-200 rounded-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent
|
||||
transition-all duration-200;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-sm border border-gray-100 p-6;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<header class="bg-white border-b border-gray-100 sticky top-0 z-10">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-9 h-9 bg-primary-500 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-lg font-semibold text-gray-900">快速Demo演示系统</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm text-gray-500">{{ authStore.user?.username }}</span>
|
||||
<button @click="showSettingsModal = true" class="btn-secondary text-sm py-2 px-3">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
设置
|
||||
</span>
|
||||
</button>
|
||||
<button @click="handleLogout" class="btn-secondary text-sm py-2 px-3">
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">项目列表</h2>
|
||||
<p class="text-gray-500 mt-1">管理和部署您的前端项目</p>
|
||||
</div>
|
||||
<button @click="showUploadModal = true" class="btn-primary mt-4 sm:mt-0">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
新建项目
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="projectStore.loading" class="flex justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="projectStore.projects.length === 0" class="card text-center py-12">
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">暂无项目</h3>
|
||||
<p class="text-gray-500 mb-4">点击上方按钮创建您的第一个项目</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="project in projectStore.projects"
|
||||
:key="project.id"
|
||||
class="card hover:shadow-md transition-shadow duration-200 cursor-pointer group"
|
||||
@click="goToProject(project.id)"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 truncate group-hover:text-primary-600 transition-colors">
|
||||
{{ project.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mt-1 truncate">{{ project.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-1 text-xs font-medium rounded-full',
|
||||
project.status === 'running'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
]"
|
||||
>
|
||||
{{ project.status === 'running' ? '运行中' : '已停止' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{{ project.files?.length || 0 }} 个文件</span>
|
||||
<span>{{ formatDate(project.createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="project.status === 'running' && project.url" class="mt-4 pt-4 border-t border-gray-100">
|
||||
<a
|
||||
:href="project.url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
class="text-primary-600 hover:text-primary-700 text-sm font-medium flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
访问项目
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<UploadModal
|
||||
v-if="showUploadModal"
|
||||
@close="showUploadModal = false"
|
||||
@success="handleUploadSuccess"
|
||||
/>
|
||||
|
||||
<SettingsModal
|
||||
v-if="showSettingsModal"
|
||||
@close="showSettingsModal = false"
|
||||
@success="handleSettingsSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import UploadModal from '../components/UploadModal.vue'
|
||||
import SettingsModal from '../components/SettingsModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const showUploadModal = ref(false)
|
||||
const showSettingsModal = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
projectStore.fetchProjects()
|
||||
})
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const goToProject = (id) => {
|
||||
router.push(`/project/${id}`)
|
||||
}
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
showUploadModal.value = false
|
||||
projectStore.fetchProjects()
|
||||
}
|
||||
|
||||
const handleSettingsSuccess = () => {
|
||||
showSettingsModal.value = false
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card animate-fade-in">
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 bg-primary-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">快速Demo演示系统</h1>
|
||||
<p class="text-gray-500 mt-2">登录以管理您的项目</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isLocked" class="p-4 bg-red-50 border border-red-200 rounded-lg mb-5">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-red-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span class="text-red-700 font-medium">登录已被临时限制</span>
|
||||
</div>
|
||||
<p class="text-red-600 text-sm mt-2">
|
||||
剩余时间:<span class="font-bold">{{ formatTime(remainingSeconds) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="attempts > 0 && !isLocked" class="p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-5">
|
||||
<p class="text-yellow-700 text-sm">
|
||||
<span class="font-medium">警告:</span>已失败 {{ attempts }} 次,连续失败 {{ maxAttempts }} 次将被锁定10分钟
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
:disabled="isLocked"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">密码</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
:disabled="isLocked"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="p-3 bg-red-50 border border-red-100 rounded-lg text-red-600 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || isLocked"
|
||||
class="btn-primary w-full py-3 text-base disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="loading" class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
登录中...
|
||||
</span>
|
||||
<span v-else-if="isLocked">登录已锁定</span>
|
||||
<span v-else>登录</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 pt-6 border-t border-gray-100 text-center">
|
||||
<button @click="showForgotPassword" class="text-primary-600 hover:text-primary-700 text-sm font-medium">
|
||||
忘记密码?联系管理员
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showForgotModal" class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50" @click.self="showForgotModal = false">
|
||||
<div class="bg-white rounded-2xl w-full max-w-md p-6 animate-slide-up">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">忘记密码</h3>
|
||||
<button @click="showForgotModal = false" class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">
|
||||
如果您忘记了密码,请联系系统管理员进行密码重置。
|
||||
</p>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-500">
|
||||
<p class="font-medium text-gray-700 mb-2">联系方式:</p>
|
||||
<p>请通过以下方式联系管理员:</p>
|
||||
<ul class="list-disc list-inside mt-2 space-y-1">
|
||||
<li>发送邮件至管理员邮箱</li>
|
||||
<li>联系技术支持团队</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button @click="showForgotModal = false" class="btn-primary w-full mt-4">
|
||||
我知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const isLocked = ref(false)
|
||||
const lockedUntil = ref(null)
|
||||
const remainingSeconds = ref(0)
|
||||
const attempts = ref(0)
|
||||
const maxAttempts = ref(3)
|
||||
const showForgotModal = ref(false)
|
||||
|
||||
let countdownTimer = null
|
||||
|
||||
const formatTime = (totalSeconds) => {
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}分${seconds.toString().padStart(2, '0')}秒`
|
||||
}
|
||||
|
||||
const updateCountdown = () => {
|
||||
if (!lockedUntil.value) {
|
||||
isLocked.value = false
|
||||
remainingSeconds.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const remaining = Math.max(0, Math.floor((lockedUntil.value - now) / 1000))
|
||||
|
||||
remainingSeconds.value = remaining
|
||||
|
||||
if (remaining <= 0) {
|
||||
isLocked.value = false
|
||||
lockedUntil.value = null
|
||||
attempts.value = 0
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startCountdown = () => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
updateCountdown()
|
||||
countdownTimer = setInterval(updateCountdown, 1000)
|
||||
}
|
||||
|
||||
const checkLockStatus = async () => {
|
||||
if (!username.value) return
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/auth/lock-status/${encodeURIComponent(username.value)}`)
|
||||
const data = response.data
|
||||
|
||||
maxAttempts.value = data.maxAttempts || 3
|
||||
|
||||
if (data.locked) {
|
||||
isLocked.value = true
|
||||
lockedUntil.value = data.lockedUntil
|
||||
attempts.value = data.attempts
|
||||
startCountdown()
|
||||
} else {
|
||||
isLocked.value = false
|
||||
lockedUntil.value = null
|
||||
attempts.value = data.attempts || 0
|
||||
remainingSeconds.value = 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to check lock status:', e)
|
||||
}
|
||||
}
|
||||
|
||||
let debounceTimer = null
|
||||
watch(username, (newVal) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (newVal) {
|
||||
debounceTimer = setTimeout(checkLockStatus, 300)
|
||||
} else {
|
||||
isLocked.value = false
|
||||
attempts.value = 0
|
||||
remainingSeconds.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (isLocked.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await authStore.login(username.value, password.value)
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
const data = e.response?.data
|
||||
error.value = data?.error || '登录失败,请重试'
|
||||
|
||||
if (data?.locked) {
|
||||
isLocked.value = true
|
||||
lockedUntil.value = data.lockedUntil
|
||||
startCountdown()
|
||||
} else if (data?.attempts) {
|
||||
attempts.value = data.attempts
|
||||
maxAttempts.value = data.maxAttempts || 3
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showForgotPassword = () => {
|
||||
showForgotModal.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (username.value) {
|
||||
checkLockStatus()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<header class="bg-white border-b border-gray-100 sticky top-0 z-10">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center space-x-4">
|
||||
<button @click="goBack" class="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold text-gray-900">{{ project?.name }}</h1>
|
||||
<p class="text-sm text-gray-500">{{ project?.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 text-sm font-medium rounded-full',
|
||||
project?.status === 'running'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
]"
|
||||
>
|
||||
{{ project?.status === 'running' ? '运行中' : '已停止' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div v-if="projectStore.loading" class="flex justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="project">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">部署控制</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
v-if="project.status !== 'running'"
|
||||
@click="handleStart"
|
||||
:disabled="actionLoading"
|
||||
class="btn-primary"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
启动项目
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else
|
||||
@click="handleStop"
|
||||
:disabled="actionLoading"
|
||||
class="btn-danger"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
停止项目
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="project.status === 'running' && project.url"
|
||||
@click="openProject"
|
||||
class="btn-secondary"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
访问项目
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="handleDelete"
|
||||
:disabled="actionLoading"
|
||||
class="btn-secondary text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
删除项目
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="project.status === 'running' && project.url" class="mt-6 p-4 bg-green-50 rounded-lg">
|
||||
<p class="text-sm text-green-700 font-medium mb-2">项目访问地址</p>
|
||||
<a
|
||||
:href="project.url"
|
||||
target="_blank"
|
||||
class="text-primary-600 hover:text-primary-700 font-mono text-sm break-all"
|
||||
>
|
||||
{{ project.url }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">文件列表</h2>
|
||||
|
||||
<div v-if="project.files?.length === 0" class="text-center py-8 text-gray-500">
|
||||
暂无文件
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="file in project.files"
|
||||
:key="file.name"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-700">{{ file.name }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">{{ formatSize(file.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">项目信息</h2>
|
||||
|
||||
<dl class="space-y-3">
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500">创建时间</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 mt-1">{{ formatDateTime(project.createdAt) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm text-gray-500">更新时间</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 mt-1">{{ formatDateTime(project.updatedAt) }}</dd>
|
||||
</div>
|
||||
<div v-if="project.lastDeployed">
|
||||
<dt class="text-sm text-gray-500">最后部署</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 mt-1">{{ formatDateTime(project.lastDeployed) }}</dd>
|
||||
</div>
|
||||
<div v-if="project.port">
|
||||
<dt class="text-sm text-gray-500">运行端口</dt>
|
||||
<dd class="text-sm font-medium text-gray-900 mt-1">{{ project.port }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">操作日志</h2>
|
||||
|
||||
<div v-if="logs.length === 0" class="text-center py-4 text-gray-500 text-sm">
|
||||
暂无日志
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div
|
||||
v-for="(log, index) in logs"
|
||||
:key="index"
|
||||
class="text-sm p-2 rounded"
|
||||
:class="{
|
||||
'bg-green-50 text-green-700': log.type === 'success',
|
||||
'bg-red-50 text-red-700': log.type === 'error',
|
||||
'bg-gray-50 text-gray-600': log.type === 'info'
|
||||
}"
|
||||
>
|
||||
<p>{{ log.message }}</p>
|
||||
<p class="text-xs opacity-70 mt-1">{{ formatDateTime(log.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import api from '../api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const project = computed(() => projectStore.currentProject)
|
||||
const actionLoading = ref(false)
|
||||
const logs = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
await projectStore.fetchProject(route.params.id)
|
||||
fetchLogs()
|
||||
})
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
logs.value = await api.getProjectLogs(route.params.id)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch logs:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStart = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await projectStore.startProject(route.params.id)
|
||||
fetchLogs()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.error || '启动失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await projectStore.stopProject(route.params.id)
|
||||
fetchLogs()
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.error || '停止失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('确定要删除此项目吗?此操作不可恢复。')) return
|
||||
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await projectStore.deleteProject(route.params.id)
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
alert(e.response?.data?.error || '删除失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openProject = () => {
|
||||
if (project.value?.url) {
|
||||
window.open(project.value.url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'pulse-slow': 'pulse 3s infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8888',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2026-02-23T04:58:11.547Z",
|
||||
"message": "Extracted dist.zip",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T04:58:11.965Z",
|
||||
"message": "Project \"loT-demo\" created with 4 file(s)",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T04:58:19.431Z",
|
||||
"message": "Project started on port 9000",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T04:58:36.076Z",
|
||||
"message": "Project stopped",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:04:00.097Z",
|
||||
"message": "Project started on port 9001",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:04:56.824Z",
|
||||
"message": "Project stopped",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:43:59.981Z",
|
||||
"message": "Project started on port 9000",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:48:32.620Z",
|
||||
"message": "Project stopped",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:48:33.171Z",
|
||||
"message": "Project started on port 9000",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:57:37.014Z",
|
||||
"message": "Project stopped",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
[
|
||||
{
|
||||
"timestamp": "2026-02-23T05:03:36.023Z",
|
||||
"message": "Extracted dist.zip",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:03:36.120Z",
|
||||
"message": "Project \"rushe-demo\" created with 3 file(s)",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:03:44.138Z",
|
||||
"message": "Project started on port 9000",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:05:11.825Z",
|
||||
"message": "Project stopped",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:09:33.720Z",
|
||||
"message": "Project started on port 9000",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:28:22.481Z",
|
||||
"message": "Project stopped",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:28:23.286Z",
|
||||
"message": "Project started on port 9000",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:28:28.136Z",
|
||||
"message": "Project stopped",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:44:04.885Z",
|
||||
"message": "Project started on port 9001",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:48:30.005Z",
|
||||
"message": "Project stopped",
|
||||
"type": "info"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:48:37.640Z",
|
||||
"message": "Project started on port 9001",
|
||||
"type": "success"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-02-23T05:57:34.667Z",
|
||||
"message": "Project stopped",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
[
|
||||
{
|
||||
"id": "1771822691510",
|
||||
"name": "loT-demo",
|
||||
"description": "智慧楼宇demo演示",
|
||||
"files": [
|
||||
{
|
||||
"name": "assets\\index-csziwVZn.css",
|
||||
"size": 28375,
|
||||
"hash": "6e9e99cb0161d7c6ca05aa04d421c424"
|
||||
},
|
||||
{
|
||||
"name": "assets\\index-Ii3hNxW3.js",
|
||||
"size": 2046014,
|
||||
"hash": "3cd425f9f0c4e0540ac6c2d63280f821"
|
||||
},
|
||||
{
|
||||
"name": "index.html",
|
||||
"size": 787,
|
||||
"hash": "907b8098cc7855605955cddd567f7c74"
|
||||
},
|
||||
{
|
||||
"name": "vite.svg",
|
||||
"size": 633,
|
||||
"hash": "3208e1714d3d4aff82769f80952b2960"
|
||||
}
|
||||
],
|
||||
"status": "stopped",
|
||||
"port": null,
|
||||
"url": null,
|
||||
"createdAt": "2026-02-23T04:58:11.965Z",
|
||||
"updatedAt": "2026-02-23T04:58:11.965Z",
|
||||
"lastDeployed": "2026-02-23T05:48:33.170Z"
|
||||
},
|
||||
{
|
||||
"id": "1771823016002",
|
||||
"name": "rushe-demo",
|
||||
"description": "入舍家装改造demo",
|
||||
"files": [
|
||||
{
|
||||
"name": "assets\\index-DtArR4F6.js",
|
||||
"size": 416147,
|
||||
"hash": "9c55c3f179e1bc5bf03baf01ccaa7479"
|
||||
},
|
||||
{
|
||||
"name": "assets\\index-gvDC5mON.css",
|
||||
"size": 25321,
|
||||
"hash": "7da89693bde15e541205dbc19aee46a5"
|
||||
},
|
||||
{
|
||||
"name": "index.html",
|
||||
"size": 572,
|
||||
"hash": "831e97d4a3f00617843c3c797098842b"
|
||||
}
|
||||
],
|
||||
"status": "stopped",
|
||||
"port": null,
|
||||
"url": null,
|
||||
"createdAt": "2026-02-23T05:03:36.120Z",
|
||||
"updatedAt": "2026-02-23T05:03:36.120Z",
|
||||
"lastDeployed": "2026-02-23T05:48:37.640Z"
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
# 服务器部署指南
|
||||
|
||||
## 服务器信息
|
||||
- **IP地址**: 49.232.209.156
|
||||
- **用户名**: ai-auto
|
||||
- **密码**: ashai@X1an
|
||||
- **域名**: ashai.com.cn
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 第一步:SSH连接服务器
|
||||
|
||||
在本地终端执行:
|
||||
```bash
|
||||
ssh ai-auto@49.232.209.156
|
||||
# 密码: ashai@X1an
|
||||
```
|
||||
|
||||
### 第二步:安装Node.js环境
|
||||
|
||||
```bash
|
||||
# 安装Node.js 18.x
|
||||
curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
|
||||
sudo yum install -y nodejs
|
||||
|
||||
# 验证安装
|
||||
node -v
|
||||
npm -v
|
||||
```
|
||||
|
||||
### 第三步:安装PM2进程管理器
|
||||
|
||||
```bash
|
||||
sudo npm install -g pm2
|
||||
pm2 -v
|
||||
```
|
||||
|
||||
### 第四步:创建项目目录
|
||||
|
||||
```bash
|
||||
mkdir -p /home/ai-auto/auto-deploy-demo
|
||||
cd /home/ai-auto/auto-deploy-demo
|
||||
```
|
||||
|
||||
### 第五步:上传项目文件
|
||||
|
||||
在**本地电脑**新开一个终端,执行以下命令上传文件:
|
||||
|
||||
```bash
|
||||
# 进入项目目录
|
||||
cd f:\develop\TraeProject\auto-deploy-demo
|
||||
|
||||
# 上传server目录
|
||||
scp -r server ai-auto@49.232.209.156:/home/ai-auto/auto-deploy-demo/
|
||||
|
||||
# 上传projects目录
|
||||
scp -r projects ai-auto@49.232.209.156:/home/ai-auto/auto-deploy-demo/
|
||||
|
||||
# 上传client/dist目录
|
||||
scp -r client/dist ai-auto@49.232.209.156:/home/ai-auto/auto-deploy-demo/client/
|
||||
|
||||
# 上传配置文件
|
||||
scp package.json package-lock.json .env ai-auto@49.232.209.156:/home/ai-auto/auto-deploy-demo/
|
||||
```
|
||||
|
||||
### 第六步:安装项目依赖
|
||||
|
||||
回到**服务器终端**:
|
||||
```bash
|
||||
cd /home/ai-auto/auto-deploy-demo
|
||||
npm install --production
|
||||
```
|
||||
|
||||
### 第七步:配置防火墙
|
||||
|
||||
```bash
|
||||
# 开放主服务端口
|
||||
sudo firewall-cmd --permanent --add-port=8888/tcp
|
||||
|
||||
# 开放项目端口范围
|
||||
sudo firewall-cmd --permanent --add-port=9000-9100/tcp
|
||||
|
||||
# 重载防火墙
|
||||
sudo firewall-cmd --reload
|
||||
|
||||
# 查看开放的端口
|
||||
sudo firewall-cmd --list-ports
|
||||
```
|
||||
|
||||
### 第八步:启动服务
|
||||
|
||||
```bash
|
||||
cd /home/ai-auto/auto-deploy-demo
|
||||
|
||||
# 启动服务
|
||||
pm2 start server/index.js --name "auto-deploy-demo"
|
||||
|
||||
# 保存PM2配置
|
||||
pm2 save
|
||||
|
||||
# 设置开机自启动
|
||||
pm2 startup
|
||||
# 执行输出的命令(需要sudo权限)
|
||||
```
|
||||
|
||||
### 第九步:验证部署
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
pm2 status
|
||||
|
||||
# 查看日志
|
||||
pm2 logs auto-deploy-demo
|
||||
|
||||
# 测试API
|
||||
curl http://localhost:8888
|
||||
```
|
||||
|
||||
## 访问地址
|
||||
|
||||
- **主系统**: http://ashai.com.cn:8888 或 http://49.232.209.156:8888
|
||||
- **默认账号**: admin
|
||||
- **默认密码**: admin123
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
pm2 status
|
||||
|
||||
# 查看日志
|
||||
pm2 logs auto-deploy-demo
|
||||
|
||||
# 重启服务
|
||||
pm2 restart auto-deploy-demo
|
||||
|
||||
# 停止服务
|
||||
pm2 stop auto-deploy-demo
|
||||
|
||||
# 重新部署(更新代码后)
|
||||
cd /home/ai-auto/auto-deploy-demo
|
||||
pm2 restart auto-deploy-demo
|
||||
```
|
||||
|
||||
## 域名配置
|
||||
|
||||
确保域名 `ashai.com.cn` 已解析到服务器IP `49.232.209.156`。
|
||||
|
||||
### DNS解析配置
|
||||
- **记录类型**: A记录
|
||||
- **主机记录**: @
|
||||
- **记录值**: 49.232.209.156
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 1. 无法访问服务
|
||||
```bash
|
||||
# 检查服务是否运行
|
||||
pm2 status
|
||||
|
||||
# 检查端口是否监听
|
||||
netstat -tlnp | grep 8888
|
||||
|
||||
# 检查防火墙
|
||||
sudo firewall-cmd --list-ports
|
||||
```
|
||||
|
||||
### 2. 端口被占用
|
||||
```bash
|
||||
# 查看端口占用
|
||||
netstat -tlnp | grep 8888
|
||||
|
||||
# 杀死占用进程
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### 3. 查看错误日志
|
||||
```bash
|
||||
pm2 logs auto-deploy-demo --lines 100
|
||||
```
|
||||
|
||||
### 4. 重启服务器后服务未自动启动
|
||||
```bash
|
||||
pm2 resurrect
|
||||
```
|
||||
|
||||
## 更新部署
|
||||
|
||||
当需要更新代码时:
|
||||
|
||||
```bash
|
||||
# 1. 在本地上传新文件
|
||||
scp -r server ai-auto@49.232.209.156:/home/ai-auto/auto-deploy-demo/
|
||||
scp -r client/dist ai-auto@49.232.209.156:/home/ai-auto/auto-deploy-demo/client/
|
||||
|
||||
# 2. 在服务器重启服务
|
||||
pm2 restart auto-deploy-demo
|
||||
```
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set SERVER_IP=49.232.209.156
|
||||
set SERVER_USER=ai-auto
|
||||
set SERVER_PASS=ashai@X1an
|
||||
set DEPLOY_DIR=/home/ai-auto/auto-deploy-demo
|
||||
set DOMAIN=ashai.com.cn
|
||||
|
||||
echo ==========================================
|
||||
echo 自动部署脚本 - 快速Demo演示系统
|
||||
echo ==========================================
|
||||
echo.
|
||||
|
||||
echo [提示] 此脚本需要安装以下工具:
|
||||
echo - OpenSSH (Windows 10/11 已内置)
|
||||
echo - sshpass 或 plink (用于密码认证)
|
||||
echo.
|
||||
echo 如果未安装sshpass,请使用手动部署步骤
|
||||
echo.
|
||||
|
||||
pause
|
||||
|
||||
echo.
|
||||
echo [1/6] 检查SSH连接...
|
||||
echo y| plink -ssh %SERVER_USER%@%SERVER_IP% -pw %SERVER_PASS% "echo 服务器连接成功"
|
||||
if errorlevel 1 (
|
||||
echo [错误] 无法连接到服务器,请检查网络和凭据
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2/6] 安装Node.js环境...
|
||||
echo y| plink -ssh %SERVER_USER%@%SERVER_IP% -pw %SERVER_PASS% "if ! command -v node ^&^> /dev/null; then curl -fsSL https://rpm.nodesource.com/setup_18.x ^| sudo bash - ^&^& sudo yum install -y nodejs; fi; node -v; npm -v"
|
||||
|
||||
echo.
|
||||
echo [3/6] 安装PM2进程管理器...
|
||||
echo y| plink -ssh %SERVER_USER%@%SERVER_IP% -pw %SERVER_PASS% "sudo npm install -g pm2; pm2 -v"
|
||||
|
||||
echo.
|
||||
echo [4/6] 创建项目目录...
|
||||
echo y| plink -ssh %SERVER_USER%@%SERVER_IP% -pw %SERVER_PASS% "mkdir -p %DEPLOY_DIR%"
|
||||
|
||||
echo.
|
||||
echo [5/6] 传输项目文件...
|
||||
echo 正在传输server目录...
|
||||
pscp -r -pw %SERVER_PASS% ..\server %SERVER_USER%@%SERVER_IP%:%DEPLOY_DIR%/
|
||||
echo 正在传输projects目录...
|
||||
pscp -r -pw %SERVER_PASS% ..\projects %SERVER_USER%@%SERVER_IP%:%DEPLOY_DIR%/
|
||||
echo 正在传输client/dist目录...
|
||||
pscp -r -pw %SERVER_PASS% ..\client\dist %SERVER_USER%@%SERVER_IP%:%DEPLOY_DIR%/client/
|
||||
echo 正在传输配置文件...
|
||||
pscp -pw %SERVER_PASS% ..\package.json %SERVER_USER%@%SERVER_IP%:%DEPLOY_DIR%/
|
||||
pscp -pw %SERVER_PASS% ..\package-lock.json %SERVER_USER%@%SERVER_IP%:%DEPLOY_DIR%/
|
||||
pscp -pw %SERVER_PASS% ..\.env %SERVER_USER%@%SERVER_IP%:%DEPLOY_DIR%/
|
||||
|
||||
echo.
|
||||
echo [6/6] 安装依赖并启动服务...
|
||||
echo y| plink -ssh %SERVER_USER%@%SERVER_IP% -pw %SERVER_PASS% "cd %DEPLOY_DIR% ^&^& npm install --production ^&^& pm2 delete all 2^>/dev/null; pm2 start server/index.js --name auto-deploy-demo ^&^& pm2 save"
|
||||
|
||||
echo.
|
||||
echo ==========================================
|
||||
echo 部署完成!
|
||||
echo ==========================================
|
||||
echo.
|
||||
echo 访问地址: http://%DOMAIN%:8888
|
||||
echo 或者: http://%SERVER_IP%:8888
|
||||
echo.
|
||||
echo 默认账号: admin
|
||||
echo 默认密码: admin123
|
||||
echo.
|
||||
pause
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
$SERVER_IP = "49.232.209.156"
|
||||
$SERVER_USER = "ai-auto"
|
||||
$SERVER_PASS = "ashai@X1an"
|
||||
$DEPLOY_DIR = "/home/ai-auto/auto-deploy-demo"
|
||||
$DOMAIN = "ashai.com.cn"
|
||||
|
||||
Write-Host "=========================================="
|
||||
Write-Host " 自动部署脚本 - 快速Demo演示系统"
|
||||
Write-Host "=========================================="
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "[提示] 请确保已安装OpenSSH客户端"
|
||||
Write-Host "Windows 10/11 可在 '设置 > 应用 > 可选功能' 中安装"
|
||||
Write-Host ""
|
||||
Write-Host "即将开始部署,请准备好输入服务器密码: $SERVER_PASS"
|
||||
Write-Host ""
|
||||
Read-Host "按Enter键继续..."
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $projectRoot
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[1/8] 测试SSH连接..."
|
||||
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "$SERVER_USER@$SERVER_IP" "echo 'SSH连接成功'"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "[错误] 无法连接到服务器" -ForegroundColor Red
|
||||
Read-Host "按Enter键退出"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[2/8] 安装Node.js环境..."
|
||||
ssh "$SERVER_USER@$SERVER_IP" @"
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo '正在安装Node.js...'
|
||||
curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
|
||||
sudo yum install -y nodejs
|
||||
fi
|
||||
echo 'Node.js版本:' `$(node -v)
|
||||
echo 'NPM版本:' `$(npm -v)
|
||||
"@
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[3/8] 安装PM2进程管理器..."
|
||||
ssh "$SERVER_USER@$SERVER_IP" @"
|
||||
if ! command -v pm2 &> /dev/null; then
|
||||
sudo npm install -g pm2
|
||||
fi
|
||||
echo 'PM2版本:' `$(pm2 -v)
|
||||
"@
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[4/8] 创建项目目录..."
|
||||
ssh "$SERVER_USER@$SERVER_IP" "mkdir -p $DEPLOY_DIR"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[5/8] 上传项目文件..."
|
||||
Write-Host "上传server目录..."
|
||||
scp -r "$projectRoot\server" "$SERVER_USER@$SERVER_IP`:$DEPLOY_DIR/"
|
||||
|
||||
Write-Host "上传projects目录..."
|
||||
scp -r "$projectRoot\projects" "$SERVER_USER@$SERVER_IP`:$DEPLOY_DIR/"
|
||||
|
||||
Write-Host "上传client/dist目录..."
|
||||
ssh "$SERVER_USER@$SERVER_IP" "mkdir -p $DEPLOY_DIR/client"
|
||||
scp -r "$projectRoot\client\dist\*" "$SERVER_USER@$SERVER_IP`:$DEPLOY_DIR/client/dist/"
|
||||
|
||||
Write-Host "上传配置文件..."
|
||||
scp "$projectRoot\package.json" "$SERVER_USER@$SERVER_IP`:$DEPLOY_DIR/"
|
||||
scp "$projectRoot\package-lock.json" "$SERVER_USER@$SERVER_IP`:$DEPLOY_DIR/"
|
||||
scp "$projectRoot\.env" "$SERVER_USER@$SERVER_IP`:$DEPLOY_DIR/"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[6/8] 安装项目依赖..."
|
||||
ssh "$SERVER_USER@$SERVER_IP" "cd $DEPLOY_DIR && npm install --production"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[7/8] 配置防火墙..."
|
||||
ssh "$SERVER_USER@$SERVER_IP" @"
|
||||
sudo firewall-cmd --permanent --add-port=8888/tcp
|
||||
sudo firewall-cmd --permanent --add-port=9000-9100/tcp
|
||||
sudo firewall-cmd --reload
|
||||
echo '防火墙配置完成'
|
||||
"@
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[8/8] 启动服务..."
|
||||
ssh "$SERVER_USER@$SERVER_IP" @"
|
||||
cd $DEPLOY_DIR
|
||||
pm2 delete all 2>/dev/null || true
|
||||
pm2 start server/index.js --name 'auto-deploy-demo'
|
||||
pm2 save
|
||||
pm2 startup | tail -n 1 | sudo bash 2>/dev/null || true
|
||||
echo '服务启动完成'
|
||||
"@
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=========================================="
|
||||
Write-Host " 部署完成!"
|
||||
Write-Host "=========================================="
|
||||
Write-Host ""
|
||||
Write-Host "访问地址: http://$DOMAIN`:8888" -ForegroundColor Green
|
||||
Write-Host "或者: http://$SERVER_IP`:8888" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "默认账号: admin"
|
||||
Write-Host "默认密码: admin123"
|
||||
Write-Host ""
|
||||
Write-Host "常用命令:"
|
||||
Write-Host " 查看日志: ssh $SERVER_USER@$SERVER_IP 'pm2 logs auto-deploy-demo'"
|
||||
Write-Host " 重启服务: ssh $SERVER_USER@$SERVER_IP 'pm2 restart auto-deploy-demo'"
|
||||
Write-Host " 停止服务: ssh $SERVER_USER@$SERVER_IP 'pm2 stop auto-deploy-demo'"
|
||||
Write-Host ""
|
||||
Read-Host "按Enter键退出"
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
SERVER_IP="49.232.209.156"
|
||||
SERVER_USER="ai-auto"
|
||||
SERVER_PASS="ashai@X1an"
|
||||
DEPLOY_DIR="/home/ai-auto/auto-deploy-demo"
|
||||
DOMAIN="ashai.com.cn"
|
||||
|
||||
echo "=========================================="
|
||||
echo " 自动部署脚本 - 快速Demo演示系统"
|
||||
echo "=========================================="
|
||||
|
||||
echo ""
|
||||
echo "[1/7] 检查服务器连接..."
|
||||
sshpass -p "$SERVER_PASS" ssh -o StrictHostKeyChecking=no $SERVER_USER@$SERVER_IP "echo '服务器连接成功'"
|
||||
|
||||
echo ""
|
||||
echo "[2/7] 安装Node.js环境..."
|
||||
sshpass -p "$SERVER_PASS" ssh $SERVER_USER@$SERVER_IP << 'EOF'
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "正在安装Node.js..."
|
||||
curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash -
|
||||
sudo yum install -y nodejs
|
||||
fi
|
||||
echo "Node.js版本: $(node -v)"
|
||||
echo "NPM版本: $(npm -v)"
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "[3/7] 安装PM2进程管理器..."
|
||||
sshpass -p "$SERVER_PASS" ssh $SERVER_USER@$SERVER_IP << 'EOF'
|
||||
if ! command -v pm2 &> /dev/null; then
|
||||
sudo npm install -g pm2
|
||||
fi
|
||||
echo "PM2版本: $(pm2 -v)"
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "[4/7] 创建项目目录并传输文件..."
|
||||
sshpass -p "$SERVER_PASS" ssh $SERVER_USER@$SERVER_IP "mkdir -p $DEPLOY_DIR"
|
||||
|
||||
sshpass -p "$SERVER_PASS" scp -r ../server $SERVER_USER@$SERVER_IP:$DEPLOY_DIR/
|
||||
sshpass -p "$SERVER_PASS" scp -r ../projects $SERVER_USER@$SERVER_IP:$DEPLOY_DIR/
|
||||
sshpass -p "$SERVER_PASS" scp -r ../client/dist $SERVER_USER@$SERVER_IP:$DEPLOY_DIR/client/
|
||||
sshpass -p "$SERVER_PASS" scp ../package.json $SERVER_USER@$SERVER_IP:$DEPLOY_DIR/
|
||||
sshpass -p "$SERVER_PASS" scp ../package-lock.json $SERVER_USER@$SERVER_IP:$DEPLOY_DIR/
|
||||
sshpass -p "$SERVER_PASS" scp ../.env $SERVER_USER@$SERVER_IP:$DEPLOY_DIR/
|
||||
|
||||
echo ""
|
||||
echo "[5/7] 安装项目依赖..."
|
||||
sshpass -p "$SERVER_PASS" ssh $SERVER_USER@$SERVER_IP << EOF
|
||||
cd $DEPLOY_DIR
|
||||
npm install --production
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "[6/7] 配置防火墙..."
|
||||
sshpass -p "$SERVER_PASS" ssh $SERVER_USER@$SERVER_IP << 'EOF'
|
||||
sudo firewall-cmd --permanent --add-port=8888/tcp
|
||||
sudo firewall-cmd --permanent --add-port=9000-9100/tcp
|
||||
sudo firewall-cmd --reload
|
||||
echo "防火墙配置完成"
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "[7/7] 启动服务..."
|
||||
sshpass -p "$SERVER_PASS" ssh $SERVER_USER@$SERVER_IP << EOF
|
||||
cd $DEPLOY_DIR
|
||||
pm2 delete all 2>/dev/null || true
|
||||
pm2 start server/index.js --name "auto-deploy-demo"
|
||||
pm2 save
|
||||
pm2 startup | tail -n 1 | sudo bash || true
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 部署完成!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "访问地址: http://$DOMAIN:8888"
|
||||
echo "或者: http://$SERVER_IP:8888"
|
||||
echo ""
|
||||
echo "默认账号: admin"
|
||||
echo "默认密码: admin123"
|
||||
echo ""
|
||||
echo "常用命令:"
|
||||
echo " 查看日志: ssh $SERVER_USER@$SERVER_IP 'pm2 logs auto-deploy-demo'"
|
||||
echo " 重启服务: ssh $SERVER_USER@$SERVER_IP 'pm2 restart auto-deploy-demo'"
|
||||
echo " 停止服务: ssh $SERVER_USER@$SERVER_IP 'pm2 stop auto-deploy-demo'"
|
||||
echo ""
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "auto-deploy-demo",
|
||||
"version": "1.0.0",
|
||||
"description": "快速发布部署简易Web系统",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"server": "node server/index.js",
|
||||
"client": "cd client && npm run dev",
|
||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||
"build": "cd client && npm run build",
|
||||
"start": "node server/index.js"
|
||||
},
|
||||
"keywords": [
|
||||
"deploy",
|
||||
"web",
|
||||
"automation"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.10",
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
const config = {
|
||||
port: parseInt(process.env.PORT, 10) || 8888,
|
||||
|
||||
baseDomain: process.env.BASE_DOMAIN || '',
|
||||
|
||||
projectPortStart: parseInt(process.env.PROJECT_PORT_START, 10) || 9000,
|
||||
projectPortEnd: parseInt(process.env.PROJECT_PORT_END, 10) || 9100,
|
||||
|
||||
getProjectUrl(port) {
|
||||
if (this.baseDomain) {
|
||||
return `http://${this.baseDomain}:${port}`;
|
||||
}
|
||||
return `http://localhost:${port}`;
|
||||
},
|
||||
|
||||
getBaseUrl() {
|
||||
if (this.baseDomain) {
|
||||
return `http://${this.baseDomain}`;
|
||||
}
|
||||
return `http://localhost:${this.port}`;
|
||||
},
|
||||
|
||||
isProduction() {
|
||||
return !!this.baseDomain;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const config = require('./config');
|
||||
|
||||
const app = express();
|
||||
const PORT = config.port;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const projectRoutes = require('./routes/projects');
|
||||
const deployRoutes = require('./routes/deploy');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/projects', projectRoutes);
|
||||
app.use('/api/deploy', deployRoutes);
|
||||
|
||||
if (fs.existsSync(path.join(__dirname, '../client/dist'))) {
|
||||
app.use(express.static(path.join(__dirname, '../client/dist')));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../client/dist/index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
if (config.baseDomain) {
|
||||
console.log(`Base domain: ${config.baseDomain}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
|
||||
const authMiddleware = (req, res, next) => {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.userId = decoded.userId;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
|
||||
const generateToken = (userId) => {
|
||||
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '24h' });
|
||||
};
|
||||
|
||||
module.exports = { authMiddleware, generateToken };
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { generateToken, authMiddleware } = require('../middleware/auth');
|
||||
const { getLockStatus, recordFailedAttempt, clearLock, getConfig } = require('../utils/loginLimiter');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const users = [
|
||||
{
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
password: bcrypt.hashSync('1221xian', 10)
|
||||
}
|
||||
];
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: '请输入用户名和密码' });
|
||||
}
|
||||
|
||||
const lockStatus = getLockStatus(username);
|
||||
|
||||
if (lockStatus.locked) {
|
||||
return res.status(429).json({
|
||||
error: '登录已被临时限制',
|
||||
locked: true,
|
||||
remainingMs: lockStatus.remainingMs,
|
||||
lockedUntil: lockStatus.lockedUntil
|
||||
});
|
||||
}
|
||||
|
||||
const user = users.find(u => u.username === username);
|
||||
|
||||
if (!user) {
|
||||
const newLockStatus = recordFailedAttempt(username);
|
||||
return res.status(401).json({
|
||||
error: '用户名或密码错误',
|
||||
attempts: newLockStatus.attempts,
|
||||
maxAttempts: getConfig().maxAttempts
|
||||
});
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
const newLockStatus = recordFailedAttempt(username);
|
||||
return res.status(401).json({
|
||||
error: '用户名或密码错误',
|
||||
attempts: newLockStatus.attempts,
|
||||
maxAttempts: getConfig().maxAttempts,
|
||||
locked: newLockStatus.locked,
|
||||
remainingMs: newLockStatus.remainingMs,
|
||||
lockedUntil: newLockStatus.lockedUntil
|
||||
});
|
||||
}
|
||||
|
||||
clearLock(username);
|
||||
|
||||
const token = generateToken(user.id);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/lock-status/:username', (req, res) => {
|
||||
const { username } = req.params;
|
||||
|
||||
if (!username) {
|
||||
return res.status(400).json({ error: '用户名不能为空' });
|
||||
}
|
||||
|
||||
const lockStatus = getLockStatus(username);
|
||||
const config = getConfig();
|
||||
|
||||
res.json({
|
||||
locked: lockStatus.locked,
|
||||
attempts: lockStatus.attempts,
|
||||
maxAttempts: config.maxAttempts,
|
||||
remainingMs: lockStatus.remainingMs,
|
||||
lockedUntil: lockStatus.lockedUntil
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/verify', authMiddleware, (req, res) => {
|
||||
const user = users.find(u => u.id === req.userId);
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/change-password', authMiddleware, async (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: '当前密码和新密码不能为空' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({ error: '新密码长度至少6位' });
|
||||
}
|
||||
|
||||
const user = users.find(u => u.id === req.userId);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: '用户不存在' });
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(currentPassword, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: '当前密码错误' });
|
||||
}
|
||||
|
||||
user.password = bcrypt.hashSync(newPassword, 10);
|
||||
|
||||
res.json({ message: '密码修改成功' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
const express = require('express');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const projectService = require('../services/projectService');
|
||||
const processManager = require('../services/processManager');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/:id/start', authMiddleware, async (req, res) => {
|
||||
const project = projectService.getProjectById(req.params.id);
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
if (project.status === 'running') {
|
||||
return res.status(400).json({ error: 'Project is already running' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processManager.startProject(project);
|
||||
projectService.updateProjectStatus(project.id, 'running', result.port, result.url);
|
||||
|
||||
res.json({
|
||||
message: 'Project started successfully',
|
||||
url: result.url,
|
||||
port: result.port
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/stop', authMiddleware, (req, res) => {
|
||||
const project = projectService.getProjectById(req.params.id);
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
if (project.status !== 'running') {
|
||||
return res.status(400).json({ error: 'Project is not running' });
|
||||
}
|
||||
|
||||
try {
|
||||
processManager.stopProject(project);
|
||||
projectService.updateProjectStatus(project.id, 'stopped');
|
||||
|
||||
res.json({ message: 'Project stopped successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/ports/status', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const status = await processManager.getPortStatus();
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id/logs', authMiddleware, (req, res) => {
|
||||
const project = projectService.getProjectById(req.params.id);
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const logs = projectService.getProjectLogs(project.id);
|
||||
res.json(logs);
|
||||
});
|
||||
|
||||
router.get('/:id/status', authMiddleware, (req, res) => {
|
||||
const project = projectService.getProjectById(req.params.id);
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: project.status,
|
||||
port: project.port,
|
||||
url: project.url,
|
||||
lastDeployed: project.lastDeployed
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const projectService = require('../services/projectService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, uniqueSuffix + '-' + file.originalname);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 100 * 1024 * 1024 }
|
||||
});
|
||||
|
||||
router.get('/', authMiddleware, (req, res) => {
|
||||
const projects = projectService.getAllProjects();
|
||||
res.json(projects);
|
||||
});
|
||||
|
||||
router.get('/:id', authMiddleware, (req, res) => {
|
||||
const project = projectService.getProjectById(req.params.id);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
router.post('/', authMiddleware, upload.array('files'), (req, res) => {
|
||||
const { name, description } = req.body;
|
||||
const files = req.files;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Project name is required' });
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({ error: 'At least one file is required' });
|
||||
}
|
||||
|
||||
const project = projectService.createProject({
|
||||
name,
|
||||
description: description || '',
|
||||
files
|
||||
});
|
||||
|
||||
res.status(201).json(project);
|
||||
});
|
||||
|
||||
router.put('/:id', authMiddleware, (req, res) => {
|
||||
const { name, description } = req.body;
|
||||
const project = projectService.updateProject(req.params.id, { name, description });
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
router.delete('/:id', authMiddleware, (req, res) => {
|
||||
const success = projectService.deleteProject(req.params.id);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Project deleted successfully' });
|
||||
});
|
||||
|
||||
router.post('/:id/files', authMiddleware, upload.array('files'), (req, res) => {
|
||||
const files = req.files;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
const project = projectService.addFilesToProject(req.params.id, files);
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const net = require('net');
|
||||
const config = require('../config');
|
||||
|
||||
const PORT_RANGE_START = config.projectPortStart;
|
||||
const PORT_RANGE_END = config.projectPortEnd;
|
||||
|
||||
const runningProcesses = new Map();
|
||||
|
||||
const checkPortAvailable = (port) => {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
server.listen(port);
|
||||
});
|
||||
};
|
||||
|
||||
const getUsedPortsFromRunningProcesses = () => {
|
||||
const ports = new Set();
|
||||
for (const [, info] of runningProcesses.entries()) {
|
||||
if (info.port) {
|
||||
ports.add(info.port);
|
||||
}
|
||||
}
|
||||
return ports;
|
||||
};
|
||||
|
||||
const findAvailablePort = async () => {
|
||||
const systemUsedPorts = getUsedPortsFromRunningProcesses();
|
||||
|
||||
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
|
||||
if (!systemUsedPorts.has(port)) {
|
||||
const available = await checkPortAvailable(port);
|
||||
if (available) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`No available ports in range ${PORT_RANGE_START}-${PORT_RANGE_END}`);
|
||||
};
|
||||
|
||||
const detectProjectType = (projectDir) => {
|
||||
if (fs.existsSync(path.join(projectDir, 'package.json'))) {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8'));
|
||||
if (pkg.scripts && pkg.scripts.start) {
|
||||
return 'node';
|
||||
}
|
||||
if (fs.existsSync(path.join(projectDir, 'vite.config.js')) ||
|
||||
fs.existsSync(path.join(projectDir, 'vite.config.ts'))) {
|
||||
return 'vite';
|
||||
}
|
||||
if (fs.existsSync(path.join(projectDir, 'vue.config.js'))) {
|
||||
return 'vue';
|
||||
}
|
||||
return 'node';
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(projectDir);
|
||||
if (files.some(f => f.endsWith('.html'))) {
|
||||
return 'static';
|
||||
}
|
||||
|
||||
return 'static';
|
||||
};
|
||||
|
||||
const startStaticServer = (projectDir, port) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const app = express();
|
||||
app.use(express.static(projectDir));
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
const indexPath = path.join(projectDir, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.sendFile(indexPath);
|
||||
} else {
|
||||
res.status(404).send('index.html not found');
|
||||
}
|
||||
});
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
resolve({ server, port });
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${port} is already in use`));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const startProject = async (project) => {
|
||||
const projectDir = path.join(__dirname, '../../projects', project.id);
|
||||
|
||||
if (!fs.existsSync(projectDir)) {
|
||||
throw new Error('Project directory not found');
|
||||
}
|
||||
|
||||
const port = await findAvailablePort();
|
||||
const projectType = detectProjectType(projectDir);
|
||||
|
||||
let processInfo = null;
|
||||
|
||||
switch (projectType) {
|
||||
case 'static':
|
||||
const { server } = await startStaticServer(projectDir, port);
|
||||
processInfo = {
|
||||
type: 'static',
|
||||
server,
|
||||
port
|
||||
};
|
||||
break;
|
||||
|
||||
case 'node':
|
||||
const nodeProcess = spawn('npm', ['start'], {
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, PORT: port.toString() },
|
||||
shell: true
|
||||
});
|
||||
|
||||
processInfo = {
|
||||
type: 'node',
|
||||
process: nodeProcess,
|
||||
port
|
||||
};
|
||||
|
||||
nodeProcess.on('exit', () => {
|
||||
runningProcesses.delete(project.id);
|
||||
});
|
||||
break;
|
||||
|
||||
case 'vite':
|
||||
case 'vue':
|
||||
const viteProcess = spawn('npx', ['vite', '--port', port.toString(), '--host'], {
|
||||
cwd: projectDir,
|
||||
shell: true
|
||||
});
|
||||
|
||||
processInfo = {
|
||||
type: 'vite',
|
||||
process: viteProcess,
|
||||
port
|
||||
};
|
||||
|
||||
viteProcess.on('exit', () => {
|
||||
runningProcesses.delete(project.id);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
runningProcesses.set(project.id, processInfo);
|
||||
|
||||
const url = config.getProjectUrl(port);
|
||||
|
||||
return { port, url };
|
||||
};
|
||||
|
||||
const stopProject = (project) => {
|
||||
const processInfo = runningProcesses.get(project.id);
|
||||
|
||||
if (!processInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const port = processInfo.port;
|
||||
|
||||
if (processInfo.type === 'static' && processInfo.server) {
|
||||
processInfo.server.close();
|
||||
} else if (processInfo.process) {
|
||||
processInfo.process.kill('SIGTERM');
|
||||
}
|
||||
|
||||
runningProcesses.delete(project.id);
|
||||
|
||||
return { stopped: true, port };
|
||||
};
|
||||
|
||||
const forceStopByPort = (port) => {
|
||||
for (const [projectId, info] of runningProcesses.entries()) {
|
||||
if (info.port === port) {
|
||||
if (info.type === 'static' && info.server) {
|
||||
info.server.close();
|
||||
} else if (info.process) {
|
||||
info.process.kill('SIGTERM');
|
||||
}
|
||||
runningProcesses.delete(projectId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isPortInUse = async (port) => {
|
||||
for (const [, info] of runningProcesses.entries()) {
|
||||
if (info.port === port) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return !(await checkPortAvailable(port));
|
||||
};
|
||||
|
||||
const releaseAllPorts = () => {
|
||||
for (const [projectId, info] of runningProcesses.entries()) {
|
||||
if (info.type === 'static' && info.server) {
|
||||
try {
|
||||
info.server.close();
|
||||
} catch (e) {
|
||||
}
|
||||
} else if (info.process) {
|
||||
try {
|
||||
info.process.kill('SIGTERM');
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
runningProcesses.clear();
|
||||
};
|
||||
|
||||
const getPortStatus = async () => {
|
||||
const status = [];
|
||||
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
|
||||
const runningProcess = Array.from(runningProcesses.entries())
|
||||
.find(([, info]) => info.port === port);
|
||||
|
||||
const systemAvailable = await checkPortAvailable(port);
|
||||
|
||||
status.push({
|
||||
port,
|
||||
inUseBySystem: !systemAvailable,
|
||||
inUseByProject: runningProcess ? {
|
||||
projectId: runningProcess[0],
|
||||
type: runningProcess[1].type
|
||||
} : null
|
||||
});
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
const getRunningProcesses = () => {
|
||||
return Array.from(runningProcesses.entries()).map(([id, info]) => ({
|
||||
projectId: id,
|
||||
type: info.type,
|
||||
port: info.port
|
||||
}));
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
startProject,
|
||||
stopProject,
|
||||
getRunningProcesses,
|
||||
forceStopByPort,
|
||||
isPortInUse,
|
||||
releaseAllPorts,
|
||||
getPortStatus,
|
||||
checkPortAvailable,
|
||||
findAvailablePort,
|
||||
PORT_RANGE_START,
|
||||
PORT_RANGE_END
|
||||
};
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const AdmZip = require('adm-zip');
|
||||
const processManager = require('./processManager');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '../../data');
|
||||
const PROJECTS_FILE = path.join(DATA_DIR, 'projects.json');
|
||||
const LOGS_DIR = path.join(DATA_DIR, 'logs');
|
||||
const DEPLOY_DIR = path.join(__dirname, '../../projects');
|
||||
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(LOGS_DIR)) {
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(DEPLOY_DIR)) {
|
||||
fs.mkdirSync(DEPLOY_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const getProjects = () => {
|
||||
if (!fs.existsSync(PROJECTS_FILE)) {
|
||||
fs.writeFileSync(PROJECTS_FILE, JSON.stringify([]));
|
||||
return [];
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(PROJECTS_FILE, 'utf8'));
|
||||
};
|
||||
|
||||
const saveProjects = (projects) => {
|
||||
fs.writeFileSync(PROJECTS_FILE, JSON.stringify(projects, null, 2));
|
||||
};
|
||||
|
||||
const addLog = (projectId, message, type = 'info') => {
|
||||
const logFile = path.join(LOGS_DIR, `${projectId}.json`);
|
||||
const logs = fs.existsSync(logFile)
|
||||
? JSON.parse(fs.readFileSync(logFile, 'utf8'))
|
||||
: [];
|
||||
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
type
|
||||
});
|
||||
|
||||
fs.writeFileSync(logFile, JSON.stringify(logs, null, 2));
|
||||
};
|
||||
|
||||
const calculateFileHash = (filePath) => {
|
||||
const content = fs.readFileSync(filePath);
|
||||
return crypto.createHash('md5').update(content).digest('hex');
|
||||
};
|
||||
|
||||
const getAllProjects = () => {
|
||||
return getProjects();
|
||||
};
|
||||
|
||||
const getProjectById = (id) => {
|
||||
const projects = getProjects();
|
||||
return projects.find(p => p.id === id);
|
||||
};
|
||||
|
||||
const createProject = ({ name, description, files }) => {
|
||||
const projects = getProjects();
|
||||
const id = Date.now().toString();
|
||||
|
||||
const projectDir = path.join(DEPLOY_DIR, id);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const fileInfos = [];
|
||||
|
||||
files.forEach(file => {
|
||||
const destPath = path.join(projectDir, file.originalname);
|
||||
fs.copyFileSync(file.path, destPath);
|
||||
fs.unlinkSync(file.path);
|
||||
|
||||
if (file.originalname.endsWith('.zip')) {
|
||||
try {
|
||||
const zip = new AdmZip(destPath);
|
||||
zip.extractAllTo(projectDir, true);
|
||||
fs.unlinkSync(destPath);
|
||||
addLog(id, `Extracted ${file.originalname}`, 'success');
|
||||
} catch (e) {
|
||||
addLog(id, `Failed to extract ${file.originalname}: ${e.message}`, 'error');
|
||||
}
|
||||
} else {
|
||||
fileInfos.push({
|
||||
name: file.originalname,
|
||||
size: file.size,
|
||||
hash: calculateFileHash(destPath)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const allFiles = getAllFiles(projectDir);
|
||||
|
||||
const project = {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
files: allFiles.map(f => ({
|
||||
name: path.relative(projectDir, f),
|
||||
size: fs.statSync(f).size,
|
||||
hash: calculateFileHash(f)
|
||||
})),
|
||||
status: 'stopped',
|
||||
port: null,
|
||||
url: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastDeployed: null
|
||||
};
|
||||
|
||||
projects.push(project);
|
||||
saveProjects(projects);
|
||||
|
||||
addLog(id, `Project "${name}" created with ${allFiles.length} file(s)`, 'success');
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
const getAllFiles = (dir) => {
|
||||
const files = [];
|
||||
const items = fs.readdirSync(dir);
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
files.push(...getAllFiles(fullPath));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const updateProject = (id, updates) => {
|
||||
const projects = getProjects();
|
||||
const index = projects.findIndex(p => p.id === id);
|
||||
|
||||
if (index === -1) return null;
|
||||
|
||||
projects[index] = {
|
||||
...projects[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
saveProjects(projects);
|
||||
addLog(id, `Project updated: ${JSON.stringify(updates)}`);
|
||||
|
||||
return projects[index];
|
||||
};
|
||||
|
||||
const deleteProject = (id) => {
|
||||
const projects = getProjects();
|
||||
const index = projects.findIndex(p => p.id === id);
|
||||
|
||||
if (index === -1) return false;
|
||||
|
||||
const project = projects[index];
|
||||
|
||||
if (project.status === 'running') {
|
||||
processManager.stopProject(project);
|
||||
}
|
||||
|
||||
const projectDir = path.join(DEPLOY_DIR, id);
|
||||
if (fs.existsSync(projectDir)) {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const logFile = path.join(LOGS_DIR, `${id}.json`);
|
||||
if (fs.existsSync(logFile)) {
|
||||
fs.unlinkSync(logFile);
|
||||
}
|
||||
|
||||
projects.splice(index, 1);
|
||||
saveProjects(projects);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const addFilesToProject = (id, files) => {
|
||||
const projects = getProjects();
|
||||
const project = projects.find(p => p.id === id);
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const projectDir = path.join(DEPLOY_DIR, id);
|
||||
|
||||
const newFileInfos = files.map(file => {
|
||||
const destPath = path.join(projectDir, file.originalname);
|
||||
fs.copyFileSync(file.path, destPath);
|
||||
fs.unlinkSync(file.path);
|
||||
|
||||
return {
|
||||
name: file.originalname,
|
||||
size: file.size,
|
||||
hash: calculateFileHash(destPath)
|
||||
};
|
||||
});
|
||||
|
||||
project.files = [...project.files, ...newFileInfos];
|
||||
project.updatedAt = new Date().toISOString();
|
||||
|
||||
saveProjects(projects);
|
||||
addLog(id, `Added ${files.length} file(s) to project`);
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
const updateProjectStatus = (id, status, port = null, url = null) => {
|
||||
const projects = getProjects();
|
||||
const project = projects.find(p => p.id === id);
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
project.status = status;
|
||||
project.port = port;
|
||||
project.url = url;
|
||||
|
||||
if (status === 'running') {
|
||||
project.lastDeployed = new Date().toISOString();
|
||||
addLog(id, `Project started on port ${port}`, 'success');
|
||||
} else if (status === 'stopped') {
|
||||
addLog(id, 'Project stopped');
|
||||
}
|
||||
|
||||
saveProjects(projects);
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
const getProjectLogs = (id) => {
|
||||
const logFile = path.join(LOGS_DIR, `${id}.json`);
|
||||
|
||||
if (!fs.existsSync(logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return JSON.parse(fs.readFileSync(logFile, 'utf8'));
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllProjects,
|
||||
getProjectById,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
addFilesToProject,
|
||||
updateProjectStatus,
|
||||
getProjectLogs
|
||||
};
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOCK_FILE = path.join(__dirname, '../data/login_locks.json');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
maxAttempts: 3,
|
||||
lockDuration: 10 * 60 * 1000
|
||||
};
|
||||
|
||||
function ensureDataDir() {
|
||||
const dataDir = path.join(__dirname, '../data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function loadLocks() {
|
||||
ensureDataDir();
|
||||
try {
|
||||
if (fs.existsSync(LOCK_FILE)) {
|
||||
const data = fs.readFileSync(LOCK_FILE, 'utf8');
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading login locks:', e);
|
||||
}
|
||||
return { users: {}, config: DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
function saveLocks(locks) {
|
||||
ensureDataDir();
|
||||
try {
|
||||
fs.writeFileSync(LOCK_FILE, JSON.stringify(locks, null, 2));
|
||||
} catch (e) {
|
||||
console.error('Error saving login locks:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getLockStatus(username) {
|
||||
const locks = loadLocks();
|
||||
const userLock = locks.users[username];
|
||||
|
||||
if (!userLock) {
|
||||
return { locked: false, attempts: 0 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (userLock.lockedUntil && now < userLock.lockedUntil) {
|
||||
const remainingMs = userLock.lockedUntil - now;
|
||||
return {
|
||||
locked: true,
|
||||
attempts: userLock.attempts,
|
||||
lockedUntil: userLock.lockedUntil,
|
||||
remainingMs: remainingMs
|
||||
};
|
||||
}
|
||||
|
||||
if (userLock.lockedUntil && now >= userLock.lockedUntil) {
|
||||
const newLocks = loadLocks();
|
||||
delete newLocks.users[username];
|
||||
saveLocks(newLocks);
|
||||
return { locked: false, attempts: 0 };
|
||||
}
|
||||
|
||||
return { locked: false, attempts: userLock.attempts || 0 };
|
||||
}
|
||||
|
||||
function recordFailedAttempt(username) {
|
||||
const locks = loadLocks();
|
||||
const now = Date.now();
|
||||
|
||||
if (!locks.users[username]) {
|
||||
locks.users[username] = { attempts: 0 };
|
||||
}
|
||||
|
||||
const userLock = locks.users[username];
|
||||
|
||||
if (userLock.lockedUntil && now >= userLock.lockedUntil) {
|
||||
userLock.attempts = 0;
|
||||
userLock.lockedUntil = null;
|
||||
}
|
||||
|
||||
userLock.attempts = (userLock.attempts || 0) + 1;
|
||||
userLock.lastAttempt = now;
|
||||
|
||||
const maxAttempts = locks.config?.maxAttempts || DEFAULT_CONFIG.maxAttempts;
|
||||
const lockDuration = locks.config?.lockDuration || DEFAULT_CONFIG.lockDuration;
|
||||
|
||||
if (userLock.attempts >= maxAttempts) {
|
||||
userLock.lockedUntil = now + lockDuration;
|
||||
}
|
||||
|
||||
saveLocks(locks);
|
||||
|
||||
return getLockStatus(username);
|
||||
}
|
||||
|
||||
function clearLock(username) {
|
||||
const locks = loadLocks();
|
||||
delete locks.users[username];
|
||||
saveLocks(locks);
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const locks = loadLocks();
|
||||
return locks.config || DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
function updateConfig(newConfig) {
|
||||
const locks = loadLocks();
|
||||
locks.config = { ...DEFAULT_CONFIG, ...newConfig };
|
||||
saveLocks(locks);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getLockStatus,
|
||||
recordFailedAttempt,
|
||||
clearLock,
|
||||
getConfig,
|
||||
updateConfig
|
||||
};
|
||||
Loading…
Reference in New Issue