Initial commit: 快速Demo演示系统

This commit is contained in:
MaeLucia 2026-02-23 14:31:59 +08:00
commit 3c562fae2a
44 changed files with 8085 additions and 0 deletions

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
# 服务端口
PORT=8888
# 基础域名部署到服务器时配置本地开发留空使用localhost
# 示例BASE_DOMAIN=demo.example.com
BASE_DOMAIN=
# 项目端口范围
PROJECT_PORT_START=9000
PROJECT_PORT_END=9100

31
.gitignore vendored Normal file
View File

@ -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

View File

@ -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. 使用说明

View File

@ -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 依赖)

132
DEPLOYMENT.md Normal file
View File

@ -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
```

111
PORT_CONFIG.md Normal file
View File

@ -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
```

249
README.md Normal file
View File

@ -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
## 联系方式
如有问题或建议,请联系系统管理员。

16
client/index.html Normal file
View File

@ -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>

2449
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
client/package.json Normal file
View File

@ -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"
}
}

6
client/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -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

6
client/src/App.vue Normal file
View File

@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>

75
client/src/api/index.js Normal file
View File

@ -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

View File

@ -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>

View File

@ -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>

12
client/src/main.js Normal file
View File

@ -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')

View File

@ -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

47
client/src/stores/auth.js Normal file
View File

@ -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
}
})

View File

@ -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
}
})

48
client/src/style.css Normal file
View File

@ -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;
}
}

View File

@ -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>

257
client/src/views/Login.vue Normal file
View File

@ -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>

View File

@ -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>

44
client/tailwind.config.js Normal file
View File

@ -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: [],
}

18
client/vite.config.js Normal file
View File

@ -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'
}
})

View File

@ -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"
}
]

View File

@ -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"
}
]

63
data/projects.json Normal file
View File

@ -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"
}
]

198
deploy/DEPLOY_GUIDE.md Normal file
View File

@ -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
```

74
deploy/deploy.bat Normal file
View File

@ -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

113
deploy/deploy.ps1 Normal file
View File

@ -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键退出"

92
deploy/deploy.sh Normal file
View File

@ -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 ""

1711
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -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"
}
}

28
server/config/index.js Normal file
View File

@ -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;

36
server/index.js Normal file
View File

@ -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}`);
}
});

25
server/middleware/auth.js Normal file
View File

@ -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 };

129
server/routes/auth.js Normal file
View File

@ -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;

89
server/routes/deploy.js Normal file
View File

@ -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;

100
server/routes/projects.js Normal file
View File

@ -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;

View File

@ -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
};

View File

@ -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
};

View File

@ -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
};