生产环境修正

This commit is contained in:
2025-10-05 13:34:28 +08:00
parent 47576fdb30
commit b0b748f6d1
47 changed files with 1152 additions and 217 deletions

View File

@@ -1,20 +1,42 @@
# 生产环境配置 # 梦回高句丽 - 生产环境配置
NODE_ENV=production NODE_ENV=production
PORT=4001
# 服务器端口配置 (宝塔面板推荐范围: 4001-4010)
PORT=4003
FRONTEND_PORT=4004
# 域名配置
DOMAIN=ggl.xi.plus
BASE_URL=https://ggl.xi.plus
# 数据库配置 # 数据库配置
DB_PATH=./database.db DB_PATH=./database.db
DB_BACKUP_PATH=./database/goguryeo_video.db.backup
# JWT配置 # JWT配置 (生产环境请使用强密钥)
JWT_SECRET=your-production-jwt-secret-key-here JWT_SECRET=ggl-xi-plus-production-jwt-secret-2024
JWT_EXPIRES_IN=7d
# 文件上传配置 # 文件上传配置
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
MAX_FILE_SIZE=100MB MAX_FILE_SIZE=100MB
ALLOWED_FILE_TYPES=jpg,jpeg,png,gif,webp,mp4,avi,mov,wmv
# 服务器配置 # 服务器配置
HOST=0.0.0.0 HOST=0.0.0.0
CORS_ORIGIN=* CORS_ORIGIN=https://ggl.xi.plus,http://ggl.xi.plus
# 安全配置
SECURE_COOKIES=true
CSRF_PROTECTION=true
RATE_LIMIT_WINDOW=15
RATE_LIMIT_MAX=100
# 性能配置
CACHE_TTL=3600
STATIC_CACHE_MAX_AGE=31536000
# 日志配置 # 日志配置
LOG_LEVEL=info LOG_LEVEL=info
LOG_FILE=./logs/production.log
ERROR_LOG_FILE=./logs/error.log

View File

@@ -0,0 +1 @@
gsfKg36gD--OGPGB5GqQUSy8JXZ86aUF7nLeJRg8ztk.OSdKi3nCfYN2E9beqMDFL03NkU68V1A8He3MYctm_jw

191
BAOTA_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,191 @@
# 梦回高句丽 - 宝塔面板部署指南
## 项目概述
- **项目名称**: 梦回高句丽视频网站
- **域名**: ggl.xi.plus
- **技术栈**: Vue.js + Node.js + Express + SQLite
- **部署方式**: 宝塔面板 NODE 项目
## 部署步骤
### 1. 宝塔面板 NODE 项目配置
#### 基本设置
- **项目名称**: `梦回高句丽`
- **项目路径**: `/www/wwwroot/ggl`
- **启动文件**: `start-production.cjs`
- **运行端口**: `4001` (API) + `4002` (前端)
- **Node.js 版本**: `18.x` 或更高
#### 启动命令配置
```bash
# 在宝塔面板 NODE 项目中设置
启动命令: node start-production.cjs
或者使用 npm 脚本: npm run start:bt
```
### 2. 环境准备
#### 安装依赖
```bash
cd /www/wwwroot/ggl
npm install
```
#### 构建前端项目
```bash
npm run build
```
#### 检查必要文件
确保以下文件存在:
- `dist/` 目录 (前端构建产物)
- `database.db``database/goguryeo_video.db`
- `.env.production` 配置文件
- `logs/` 目录 (自动创建)
### 3. 域名和 SSL 配置
#### 域名绑定
1. 在宝塔面板中添加站点 `ggl.xi.plus`
2. 设置网站目录为 `/www/wwwroot/ggl/dist`
3. 申请并配置 SSL 证书
#### Nginx 配置
使用提供的 `nginx-ggl-xi-plus.conf` 配置文件:
```bash
# 复制配置到宝塔 Nginx 配置目录
cp nginx-ggl-xi-plus.conf /www/server/panel/vhost/nginx/ggl.xi.plus.conf
# 重载 Nginx 配置
nginx -s reload
```
### 4. 启动项目
#### 方式一:宝塔面板 NODE 项目 (推荐)
1. 在宝塔面板 → 软件商店 → Node.js 项目管理
2. 添加项目:
- 项目名称: `梦回高句丽`
- 项目路径: `/www/wwwroot/ggl`
- 启动文件: `start-production.cjs`
- 端口: `4001`
3. 点击启动项目
#### 方式二PM2 管理 (备选)
```bash
# 使用 PM2 启动
npm run pm2:start
# 查看状态
pm2 status
# 查看日志
pm2 logs ggl-xi-plus-production
```
### 5. 验证部署
#### 检查服务状态
```bash
# 检查端口占用
netstat -tlnp | grep :4001
netstat -tlnp | grep :4002
# 检查进程
ps aux | grep node
```
#### 访问测试
- 前端页面: `https://ggl.xi.plus`
- API 接口: `https://ggl.xi.plus/api/health`
- 上传文件: `https://ggl.xi.plus/uploads/`
### 6. 监控和维护
#### 日志文件位置
```
logs/
├── production.log # 应用日志
├── error.log # 错误日志
├── pm2-combined.log # PM2 综合日志
├── pm2-out.log # PM2 输出日志
└── pm2-error.log # PM2 错误日志
```
#### 常用维护命令
```bash
# 重启服务 (宝塔面板)
# 在 NODE 项目管理中点击重启
# 重启服务 (PM2)
npm run pm2:restart
# 查看实时日志
tail -f logs/production.log
# 清理日志
truncate -s 0 logs/*.log
```
### 7. 性能优化配置
#### 系统配置
- **内存限制**: 1GB (可根据实际情况调整)
- **文件上传限制**: 100MB
- **并发连接**: 根据服务器配置调整
- **缓存策略**: 静态资源 1 年API 响应适当缓存
#### 安全配置
- SSL/TLS 加密传输
- CORS 跨域限制
- 请求频率限制
- 敏感文件访问禁止
- 安全响应头设置
## 故障排除
### 常见问题
1. **端口冲突**
```bash
# 检查端口占用
lsof -i :4001
lsof -i :4002
```
2. **权限问题**
```bash
# 设置正确的文件权限
chown -R www:www /www/wwwroot/ggl
chmod -R 755 /www/wwwroot/ggl
```
3. **数据库连接失败**
```bash
# 检查数据库文件权限
ls -la database/
chmod 664 database/*.db
```
4. **前端资源 404**
```bash
# 重新构建前端
npm run build
```
### 联系支持
如遇到部署问题,请检查:
1. Node.js 版本是否符合要求
2. 依赖包是否完整安装
3. 端口是否被占用
4. 文件权限是否正确
5. Nginx 配置是否生效
---
**部署完成后,项目将在 https://ggl.xi.plus 上运行**
**最后更新**: 2024年1月
**维护者**: 系统管理员

View File

@@ -59,6 +59,8 @@ app.use('/api/health', (req: Request, res: Response, next: NextFunction): void =
}); });
}); });
/** /**
* error handler middleware * error handler middleware
*/ */

View File

@@ -234,6 +234,37 @@ router.post('/refresh', async (req: Request, res: Response): Promise<void> => {
} }
}); });
/**
* Get Captcha
* GET /api/auth/captcha
*/
router.get('/captcha', async (req: Request, res: Response): Promise<void> => {
try {
// 生成简单的数学验证码
const num1 = Math.floor(Math.random() * 10) + 1;
const num2 = Math.floor(Math.random() * 10) + 1;
const answer = num1 + num2;
// 生成验证码ID
const captchaId = Math.random().toString(36).substring(2, 15);
res.json({
success: true,
data: {
captchaId,
question: `${num1} + ${num2} = ?`,
answer // 在实际生产环境中,这应该存储在服务器端而不是返回给客户端
}
});
} catch (error) {
console.error('获取验证码失败:', error);
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
/** /**
* User Logout * User Logout
* POST /api/auth/logout * POST /api/auth/logout

View File

@@ -788,112 +788,7 @@ router.post('/upload', authenticateToken, upload.single('video'), handleMulterEr
} }
}); });
/**
* 批量删除视频
* DELETE /api/videos/batch
*/
router.delete('/batch', authenticateToken, async (req: Request, res: Response): Promise<void> => {
try {
const { videoIds } = req.body;
if (!videoIds || !Array.isArray(videoIds) || videoIds.length === 0) {
res.status(400).json({
success: false,
message: '请提供要删除的视频ID列表'
});
return;
}
// 验证所有ID都是有效数字
const validIds = videoIds.filter(id => typeof id === 'number' && !isNaN(id));
if (validIds.length === 0) {
res.status(400).json({
success: false,
message: '无效的视频ID列表'
});
return;
}
// 检查用户权限(只能删除自己上传的视频,或管理员可以删除所有视频)
const placeholders = validIds.map(() => '?').join(',');
const videos = await query(`
SELECT id, file_path, cover_image, user_id FROM videos
WHERE id IN (${placeholders})
`, validIds);
if (videos.length === 0) {
res.status(404).json({
success: false,
message: '未找到要删除的视频'
});
return;
}
// 检查权限
const unauthorizedVideos = videos.filter(video =>
video.user_id !== req.user!.id && req.user!.role !== 'admin'
);
if (unauthorizedVideos.length > 0) {
res.status(403).json({
success: false,
message: '没有权限删除部分视频'
});
return;
}
// 删除相关文件
for (const video of videos) {
try {
// 删除视频文件
if (video.file_path) {
const videoPath = path.join(process.cwd(), video.file_path);
if (fs.existsSync(videoPath)) {
fs.unlinkSync(videoPath);
}
}
// 删除封面文件
if (video.cover_image) {
const coverPath = path.join(process.cwd(), video.cover_image.replace(/^\//, ''));
if (fs.existsSync(coverPath)) {
fs.unlinkSync(coverPath);
}
}
} catch (fileError) {
// 删除文件失败
}
}
// 删除数据库记录
const foundIds = videos.map(v => v.id);
const foundPlaceholders = foundIds.map(() => '?').join(',');
// 删除相关统计数据
await execute(`DELETE FROM video_stats WHERE video_id IN (${foundPlaceholders})`, foundIds);
// 删除专题关联
await execute(`DELETE FROM video_topics WHERE video_id IN (${foundPlaceholders})`, foundIds);
// 删除视频记录
await execute(`DELETE FROM videos WHERE id IN (${foundPlaceholders})`, foundIds);
res.json({
success: true,
message: `成功删除 ${foundIds.length} 个视频`,
data: {
deletedCount: foundIds.length,
deletedIds: foundIds
}
});
} catch (error) {
// 批量删除视频失败
res.status(500).json({
success: false,
message: '服务器内部错误'
});
}
});
/** /**
* 批量删除视频 (POST方法) * 批量删除视频 (POST方法)
@@ -995,9 +890,11 @@ router.post('/batch/delete', authenticateToken, async (req: Request, res: Respon
}); });
} catch (error) { } catch (error) {
console.error('批量删除视频失败:', error); console.error('批量删除视频失败:', error);
console.error('错误堆栈:', error.stack);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: '服务器内部错误' message: '服务器内部错误',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
}); });
} }
}); });
@@ -1092,6 +989,8 @@ router.put('/batch/status', authenticateToken, async (req: Request, res: Respons
* DELETE /api/videos/batch * DELETE /api/videos/batch
*/ */
router.delete('/batch', authenticateToken, async (req: Request, res: Response): Promise<void> => { router.delete('/batch', authenticateToken, async (req: Request, res: Response): Promise<void> => {
console.log('批量删除视频开始,请求体:', req.body);
console.log('用户信息:', req.user);
try { try {
const { videoIds } = req.body; const { videoIds } = req.body;
@@ -1127,7 +1026,7 @@ router.delete('/batch', authenticateToken, async (req: Request, res: Response):
// 获取要删除的视频信息 // 获取要删除的视频信息
const placeholders = validIds.map(() => '?').join(','); const placeholders = validIds.map(() => '?').join(',');
const videos = await query(` const videos = await query(`
SELECT id, file_path, thumbnail SELECT id, file_path, cover_image
FROM videos FROM videos
WHERE id IN (${placeholders}) WHERE id IN (${placeholders})
`, validIds); `, validIds);
@@ -1150,8 +1049,8 @@ router.delete('/batch', authenticateToken, async (req: Request, res: Response):
} }
// 删除缩略图文件 // 删除缩略图文件
if (video.thumbnail) { if (video.cover_image) {
const thumbnailPath = path.join(process.cwd(), 'public', video.thumbnail); const thumbnailPath = path.join(process.cwd(), 'public', video.cover_image);
if (fs.existsSync(thumbnailPath)) { if (fs.existsSync(thumbnailPath)) {
fs.unlinkSync(thumbnailPath); fs.unlinkSync(thumbnailPath);
// 缩略图删除成功 // 缩略图删除成功
@@ -1185,9 +1084,11 @@ router.delete('/batch', authenticateToken, async (req: Request, res: Response):
}); });
} catch (error) { } catch (error) {
console.error('批量删除视频失败:', error); console.error('批量删除视频失败:', error);
console.error('错误堆栈:', error.stack);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: '服务器内部错误' message: '服务器内部错误',
error: process.env.NODE_ENV === 'development' ? error.message : undefined
}); });
} }
}); });

Binary file not shown.

View File

@@ -1,38 +1,40 @@
module.exports = { module.exports = {
apps: [{ apps: [
name: 'ggl-app', {
name: 'ggl-xi-plus-production',
script: 'start-production.cjs', script: 'start-production.cjs',
cwd: '/www/wwwroot/ggl',
instances: 1, instances: 1,
exec_mode: 'fork',
env: {
NODE_ENV: 'production',
PORT: 4001,
FRONTEND_PORT: 4002
},
env_production: {
NODE_ENV: 'production',
PORT: 4001,
FRONTEND_PORT: 4002
},
// 日志配置
log_file: './logs/combined.log',
out_file: './logs/out.log',
error_file: './logs/error.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
// 进程管理配置
autorestart: true, autorestart: true,
watch: false, watch: false,
max_memory_restart: '1G', max_memory_restart: '1G',
// 启动配置
min_uptime: '10s', min_uptime: '10s',
max_restarts: 10, max_restarts: 10,
restart_delay: 4000, restart_delay: 4000,
env: {
// 其他配置 NODE_ENV: 'production',
PORT: 4001,
FRONTEND_PORT: 4002,
DOMAIN: 'ggl.xi.plus'
},
log_file: './logs/pm2-combined.log',
out_file: './logs/pm2-out.log',
error_file: './logs/pm2-error.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true, merge_logs: true,
time: true kill_timeout: 5000,
}] listen_timeout: 8000,
shutdown_with_message: true
}
],
deploy: {
production: {
user: 'www',
host: 'localhost',
ref: 'origin/main',
repo: 'git@github.com:username/ggl.git',
path: '/www/wwwroot/ggl',
'post-deploy': 'npm install && npm run build && pm2 reload ecosystem.config.js --env production'
}
}
}; };

166
nginx-ggl-xi-plus.conf Normal file
View File

@@ -0,0 +1,166 @@
# 梦回高句丽 - ggl.xi.plus 域名配置
# 适用于宝塔面板 Nginx 配置
# HTTP 重定向到 HTTPS
server {
listen 80;
server_name ggl.xi.plus www.ggl.xi.plus;
return 301 https://ggl.xi.plus$request_uri;
}
# HTTPS 主配置
server {
listen 443 ssl http2;
server_name ggl.xi.plus www.ggl.xi.plus;
# 网站根目录
root /www/wwwroot/ggl/dist;
index index.html index.htm;
# SSL 证书配置 (宝塔面板自动管理)
# ssl_certificate /www/server/panel/vhost/cert/ggl.xi.plus/fullchain.pem;
# ssl_certificate_key /www/server/panel/vhost/cert/ggl.xi.plus/privkey.pem;
# SSL 安全配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# 安全头配置
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; media-src 'self' https:;" always;
# Gzip 压缩配置
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# 静态资源缓存配置
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
access_log off;
# 跨域配置
add_header Access-Control-Allow-Origin "https://ggl.xi.plus";
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept";
}
# API 请求代理到后端 Node.js 服务
location /api/ {
proxy_pass http://127.0.0.1:4003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# 超时配置
proxy_connect_timeout 30s;
proxy_read_timeout 60s;
proxy_send_timeout 30s;
# WebSocket 支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 缓冲配置
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
# 错误处理
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
}
# uploads 目录代理到后端
location /uploads/ {
proxy_pass http://127.0.0.1:4003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 文件上传大小限制
client_max_body_size 100M;
# 缓存配置
expires 30d;
add_header Cache-Control "public";
}
# 健康检查端点
location /health {
proxy_pass http://127.0.0.1:4003/health;
access_log off;
}
# 前端路由处理 - 所有其他请求返回 index.html (SPA)
location / {
try_files $uri $uri/ /index.html;
# HTML 文件不缓存
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
}
}
# 禁止访问敏感文件
location ~ ^/(\.|package\.json|package-lock\.json|\.env|\.git|node_modules|api/|database/) {
return 404;
}
# robots.txt
location = /robots.txt {
add_header Content-Type text/plain;
return 200 "User-agent: *\nDisallow: /api/\nDisallow: /uploads/\nSitemap: https://ggl.xi.plus/sitemap.xml\n";
}
# SSL 证书验证目录
location /.well-known/ {
root /www/wwwroot/ggl;
allow all;
}
# 错误页面配置
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /www/wwwroot/ggl/dist;
}
# 日志配置
access_log /www/wwwlogs/ggl.xi.plus.log combined;
error_log /www/wwwlogs/ggl.xi.plus.error.log warn;
}
# 限制请求频率 (防止 DDoS)
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=static:10m rate=30r/s;
# 应用限制到相应的 location
# 在 location /api/ 中添加: limit_req zone=api burst=20 nodelay;
# 在静态资源 location 中添加: limit_req zone=static burst=50 nodelay;

View File

@@ -4,15 +4,10 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"vite\" \"tsx api/server.ts\"", "dev": "vite",
"build": "vue-tsc && vite build", "build": "vue-tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"start": "node start-production.cjs", "start:bt": "node start-production.cjs"
"start:bt": "node start-production.cjs",
"pm2:start": "pm2 start ecosystem.config.js",
"pm2:stop": "pm2 stop ecosystem.config.js",
"pm2:restart": "pm2 restart ecosystem.config.js",
"check": "./check.sh"
}, },
"dependencies": { "dependencies": {
"16": "^0.0.2", "16": "^0.0.2",

View File

@@ -219,13 +219,21 @@ const handleSearchFromModal = (query: string, filters?: any) => {
// 分类数据 // 分类数据
const categories = ref([ const categories = ref([
{ id: 'all', name: '全部', icon: 'Grid', count: 0 }, { id: 'all', name: '全部', icon: 'Grid', count: 0 },
{ id: 'history', name: '历史文化', icon: 'Monument', count: 0 }, { id: 'history', name: '历史文化', icon: 'Monument', count: 0, dbId: 1 },
{ id: 'architecture', name: '建筑艺术', icon: 'Picture', count: 0 }, { id: 'art', name: '传统艺术', icon: 'Picture', count: 0, dbId: 2 },
{ id: 'archaeology', name: '考古发现', icon: 'Document', count: 0 }, { id: 'language', name: '语言学习', icon: 'Document', count: 0, dbId: 3 },
{ id: 'documentary', name: '纪录片', icon: 'VideoCamera', count: 0 }, { id: 'archaeology', name: '考古发现', icon: 'VideoCamera', count: 0, dbId: 4 },
{ id: 'featured', name: '精选推荐', icon: 'Star', count: 0 } { id: 'folklore', name: '民俗风情', icon: 'Star', count: 0, dbId: 5 },
{ id: 'other', name: '其他', icon: 'Grid', count: 0, dbId: 6 }
]) ])
// 分类映射函数
const getCategoryDbId = (categoryId: string) => {
if (categoryId === 'all') return undefined
const category = categories.value.find(c => c.id === categoryId)
return category?.dbId
}
// 统计数据 // 统计数据
const stats = ref({ const stats = ref({
totalVideos: 0, totalVideos: 0,
@@ -282,7 +290,7 @@ const loadVideos = async (append = false) => {
const params = { const params = {
page: currentPage.value, page: currentPage.value,
pageSize: pageSize.value, pageSize: pageSize.value,
category: selectedCategory.value === 'all' ? undefined : selectedCategory.value, category: getCategoryDbId(selectedCategory.value),
sortBy: sortBy.value, sortBy: sortBy.value,
search: searchQuery.value || undefined search: searchQuery.value || undefined
} }

View File

@@ -41,7 +41,7 @@
<div <div
v-if="showPoster" v-if="showPoster"
class="video-poster" class="video-poster"
:style="{ backgroundImage: `url(${video?.thumbnail})` }" :style="{ backgroundImage: `url(${video?.cover_image ? `https://ggl.xi.plus${video.cover_image}` : ''})` }"
@click="handleVideoClick" @click="handleVideoClick"
@dblclick="handleVideoDoubleClick" @dblclick="handleVideoDoubleClick"
> >
@@ -52,8 +52,7 @@
<video <video
ref="videoPlayerRef" ref="videoPlayerRef"
:src="video?.video_url || ''" :poster="video?.cover_image ? `https://ggl.xi.plus${video.cover_image}` : ''"
:poster="video?.thumbnail"
@loadedmetadata="handleVideoLoaded" @loadedmetadata="handleVideoLoaded"
@timeupdate="handleTimeUpdate" @timeupdate="handleTimeUpdate"
@ended="handleVideoEnded" @ended="handleVideoEnded"
@@ -61,11 +60,23 @@
@pause="handleVideoPause" @pause="handleVideoPause"
@click="handleVideoClick" @click="handleVideoClick"
@dblclick="handleVideoDoubleClick" @dblclick="handleVideoDoubleClick"
@error="handleVideoError"
@loadstart="handleVideoLoadStart"
@canplay="handleVideoCanPlay"
class="video-element" class="video-element"
:controls="false" :controls="false"
autoplay autoplay
preload="metadata" preload="auto"
></video> playsinline
:volume="0.8"
crossorigin="anonymous"
>
<!-- 添加多个视频源作为备用 -->
<source v-if="video" :src="getVideoUrl(video)" type="video/mp4">
<source v-if="video && video.file_path" :src="`https://ggl.xi.plus${video.file_path.replace(/\.[^/.]+$/, '.webm')}`" type="video/webm">
<source v-if="video && video.file_path" :src="`https://ggl.xi.plus${video.file_path.replace(/\.[^/.]+$/, '.ogg')}`" type="video/ogg">
<p>您的浏览器不支持视频播放请尝试使用现代浏览器或更新您的浏览器版本</p>
</video>
<!-- 功能菜单 --> <!-- 功能菜单 -->
<div <div
@@ -333,6 +344,12 @@ const countdownTimer = ref<NodeJS.Timeout>()
const showFilenameInFullscreen = ref(false) const showFilenameInFullscreen = ref(false)
const filenameTimer = ref<NodeJS.Timeout>() const filenameTimer = ref<NodeJS.Timeout>()
// 视频加载和错误处理相关
const videoLoadError = ref(false)
const videoRetryCount = ref(0)
const maxRetryCount = 3
const videoLoadStarted = ref(false)
// 界面状态 // 界面状态
const showFullDescription = ref(false) const showFullDescription = ref(false)
const relatedVideos = ref<any[]>([]) const relatedVideos = ref<any[]>([])
@@ -347,32 +364,105 @@ const videoId = computed(() => Number(route.params.id))
// 生命周期 // 生命周期
onMounted(() => { onMounted(() => {
loadVideoData() loadVideoData()
// 添加所有浏览器的全屏变化事件监听
document.addEventListener('fullscreenchange', handleFullscreenChange) document.addEventListener('fullscreenchange', handleFullscreenChange)
document.addEventListener('webkitfullscreenchange', handleFullscreenChange)
document.addEventListener('mozfullscreenchange', handleFullscreenChange)
document.addEventListener('MSFullscreenChange', handleFullscreenChange)
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown)
// 自动全屏播放 // 强化的自动全屏播放逻辑 - 确保在任何访问方式下都能工作
nextTick(() => { nextTick(() => {
setTimeout(() => { // 立即尝试全屏,不依赖视频加载状态(这个函数将被下面定义的强化版本替代)
// 确保视频元素已加载 const executeImmediateFullscreenOld = () => {
if (videoPlayerRef.value) { console.log('执行立即全屏逻辑')
// 先进入全屏模式
// 无条件进入全屏模式
if (!document.fullscreenElement) {
toggleFullscreen() toggleFullscreen()
// 然后开始播放 }
// 延迟尝试播放视频
setTimeout(() => {
const videoElement = videoPlayerRef.value
if (videoElement) {
showPoster.value = false
// 尝试播放,无论视频是否完全加载
videoElement.play().catch(error => {
console.log('自动播放失败,可能需要用户交互:', error)
// 如果自动播放失败,显示播放按钮提示用户点击
showPoster.value = true
})
}
}, 500)
}
// 等待视频数据加载完成后自动全屏播放(作为备用)
const checkVideoReady = () => {
if (video.value && videoPlayerRef.value) {
// 检查视频元素是否已经准备好
const videoElement = videoPlayerRef.value
// 如果视频元素已经有足够的数据,立即执行全屏播放
if (videoElement.readyState >= 2) { // HAVE_CURRENT_DATA
executeAutoFullscreenPlay()
} else {
// 监听视频数据加载事件
const onLoadedData = () => {
videoElement.removeEventListener('loadeddata', onLoadedData)
executeAutoFullscreenPlay()
}
videoElement.addEventListener('loadeddata', onLoadedData)
// 设置超时保护,避免无限等待
setTimeout(() => {
videoElement.removeEventListener('loadeddata', onLoadedData)
executeAutoFullscreenPlay()
}, 2000) // 减少超时时间
}
} else {
// 如果视频还没准备好,继续等待,但减少等待时间
setTimeout(checkVideoReady, 100)
}
}
// 执行自动全屏播放的函数
const executeAutoFullscreenPlay = () => {
console.log('执行自动全屏播放')
// 先进入全屏模式
if (!document.fullscreenElement) {
toggleFullscreen()
}
// 短暂延迟后开始播放,确保全屏切换完成
setTimeout(() => { setTimeout(() => {
if (videoPlayerRef.value) { if (videoPlayerRef.value) {
showPoster.value = false showPoster.value = false
videoPlayerRef.value.play().catch(error => { videoPlayerRef.value.play().catch(error => {
console.log('自动播放失败,可能需要用户交互:', error) console.log('自动播放失败,可能需要用户交互:', error)
// 如果自动播放失败,显示播放按钮提示用户点击
showPoster.value = true
}) })
} }
}, 500) }, 200)
} }
}, 1000)
// 立即执行全屏逻辑,不等待视频加载
setTimeout(executeImmediateFullscreen, 100)
// 同时启动增强的视频准备检查作为备用
setTimeout(checkVideoReady, 500)
}) })
}) })
onUnmounted(() => { onUnmounted(() => {
// 移除所有浏览器的全屏变化事件监听
document.removeEventListener('fullscreenchange', handleFullscreenChange) document.removeEventListener('fullscreenchange', handleFullscreenChange)
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange)
document.removeEventListener('mozfullscreenchange', handleFullscreenChange)
document.removeEventListener('MSFullscreenChange', handleFullscreenChange)
document.removeEventListener('keydown', handleKeydown) document.removeEventListener('keydown', handleKeydown)
if (controlsTimer.value) { if (controlsTimer.value) {
clearTimeout(controlsTimer.value) clearTimeout(controlsTimer.value)
@@ -389,6 +479,92 @@ watch(videoId, () => {
}) })
// 方法 // 方法
const getVideoUrl = (video: any) => {
if (!video) return ''
// 如果video_url已经是完整URL直接返回
if (video.video_url && video.video_url.startsWith('http')) {
return video.video_url
}
// 如果video_url是相对路径拼接完整URL
if (video.video_url) {
// 确保路径以/开头
const path = video.video_url.startsWith('/') ? video.video_url : `/${video.video_url}`
return `https://ggl.xi.plus${path}`
}
// 如果没有video_url使用file_path
if (video.file_path) {
// 确保路径以/开头,避免双斜杠
const path = video.file_path.startsWith('/') ? video.file_path : `/${video.file_path}`
return `https://ggl.xi.plus${path}`
}
return ''
}
// 验证视频URL是否可访问
const validateVideoUrl = async (url: string): Promise<boolean> => {
if (!url) return false
try {
const response = await fetch(url, {
method: 'HEAD',
cache: 'no-cache'
})
return response.ok
} catch (error) {
console.warn(`视频URL验证失败: ${url}`, error)
return false
}
}
// 获取优化的视频URL
const getOptimizedVideoUrl = async (video: any): Promise<string> => {
if (!video) return ''
const primaryUrl = getVideoUrl(video)
// 首先验证主要URL
if (await validateVideoUrl(primaryUrl)) {
return primaryUrl
}
// 如果主要URL不可用尝试备用方案
console.warn('主要视频URL不可用尝试备用方案')
// 尝试不同的路径组合
const fallbackUrls = []
if (video.file_path) {
const cleanPath = video.file_path.replace(/^\/+/, '/')
fallbackUrls.push(`https://ggl.xi.plus${cleanPath}`)
}
if (video.video_url) {
const cleanPath = video.video_url.replace(/^\/+/, '/')
fallbackUrls.push(`https://ggl.xi.plus${cleanPath}`)
}
// 尝试uploads目录
if (video.filename || video.file_name) {
const filename = video.filename || video.file_name
fallbackUrls.push(`https://ggl.xi.plus/uploads/videos/${filename}`)
}
// 验证备用URL
for (const url of fallbackUrls) {
if (await validateVideoUrl(url)) {
console.log(`找到可用的备用视频URL: ${url}`)
return url
}
}
console.error('所有视频URL都不可用')
return primaryUrl // 返回原始URL让浏览器处理错误
}
const loadVideoData = async () => { const loadVideoData = async () => {
try { try {
videoLoading.value = true videoLoading.value = true
@@ -457,10 +633,9 @@ const handleVideoLoaded = () => {
if (videoPlayerRef.value) { if (videoPlayerRef.value) {
duration.value = videoPlayerRef.value.duration duration.value = videoPlayerRef.value.duration
volume.value = videoPlayerRef.value.volume * 100 volume.value = videoPlayerRef.value.volume * 100
// 自动播放视频
videoPlayerRef.value.play().catch(error => { // 视频加载完成自动播放逻辑已在onMounted中的全屏播放逻辑中处理
console.log('自动播放失败,可能需要用户交互:', error) // 这里不再单独处理自动播放,避免重复播放
})
} }
} }
@@ -496,6 +671,352 @@ const handleVideoEnded = () => {
startAutoReplayCountdown() startAutoReplayCountdown()
} }
// 视频加载开始
const handleVideoLoadStart = () => {
videoLoadStarted.value = true
videoLoadError.value = false
console.log('视频开始加载...')
}
// 视频可以播放
const handleVideoCanPlay = () => {
videoLoadError.value = false
videoRetryCount.value = 0
console.log('视频可以播放')
}
// 获取备用视频源
const getBackupVideoSources = (video: any) => {
const sources = []
const baseUrl = getVideoUrl(video)
if (baseUrl) {
// 主要视频源
sources.push({
src: baseUrl,
type: 'video/mp4'
})
// 尝试不同的视频格式作为备用
const basePath = baseUrl.replace(/\.[^/.]+$/, '')
sources.push(
{ src: `${basePath}.webm`, type: 'video/webm' },
{ src: `${basePath}.ogg`, type: 'video/ogg' }
)
}
return sources
}
// 检查视频格式兼容性
const checkVideoFormatSupport = () => {
const video = document.createElement('video')
const formats = {
mp4: video.canPlayType('video/mp4; codecs="avc1.42E01E"'),
webm: video.canPlayType('video/webm; codecs="vp8, vorbis"'),
ogg: video.canPlayType('video/ogg; codecs="theora"')
}
console.log('支持的视频格式:', formats)
return formats
}
// 强化的视频加载函数
const loadVideoWithFallback = async (videoElement: HTMLVideoElement, video: any) => {
if (!videoElement || !video) return false
// 首先尝试获取优化的视频URL
try {
const optimizedUrl = await getOptimizedVideoUrl(video)
if (optimizedUrl && optimizedUrl !== getVideoUrl(video)) {
console.log(`使用优化的视频URL: ${optimizedUrl}`)
// 清除现有源
const sources = videoElement.querySelectorAll('source')
sources.forEach(source => source.remove())
// 添加新的源
const source = document.createElement('source')
source.src = optimizedUrl
source.type = 'video/mp4'
videoElement.appendChild(source)
// 尝试加载
const optimizedResult = await new Promise((resolve) => {
const timeout = setTimeout(() => {
console.warn('优化URL加载超时')
resolve(false)
}, 8000) // 8秒超时
const onLoad = () => {
clearTimeout(timeout)
videoElement.removeEventListener('loadeddata', onLoad)
videoElement.removeEventListener('error', onError)
console.log('优化URL加载成功')
resolve(true)
}
const onError = () => {
clearTimeout(timeout)
videoElement.removeEventListener('loadeddata', onLoad)
videoElement.removeEventListener('error', onError)
console.warn('优化URL加载失败')
resolve(false)
}
videoElement.addEventListener('loadeddata', onLoad, { once: true })
videoElement.addEventListener('error', onError, { once: true })
// 开始加载
videoElement.load()
})
if (optimizedResult) {
return true
}
}
} catch (error) {
console.warn('获取优化URL失败:', error)
}
// 如果优化URL失败使用原有的备用源策略
const sources = getBackupVideoSources(video)
let loadSuccess = false
for (const source of sources) {
try {
console.log(`尝试加载备用视频源: ${source.src}`)
// 设置视频源
videoElement.src = source.src
// 等待视频加载
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('视频加载超时'))
}, 5000) // 5秒超时
const onLoad = () => {
clearTimeout(timeout)
videoElement.removeEventListener('loadeddata', onLoad)
videoElement.removeEventListener('error', onError)
resolve(true)
}
const onError = (e: Event) => {
clearTimeout(timeout)
videoElement.removeEventListener('loadeddata', onLoad)
videoElement.removeEventListener('error', onError)
reject(e)
}
videoElement.addEventListener('loadeddata', onLoad, { once: true })
videoElement.addEventListener('error', onError, { once: true })
// 开始加载
videoElement.load()
})
loadSuccess = true
console.log(`备用视频源加载成功: ${source.src}`)
break
} catch (error) {
console.warn(`备用视频源加载失败: ${source.src}`, error)
continue
}
}
return loadSuccess
}
// 强化的立即全屏执行函数
const executeImmediateFullscreen = async () => {
try {
console.log('执行立即全屏逻辑')
// 检查是否已经全屏
if (document.fullscreenElement || isFullscreen.value) {
console.log('已经处于全屏状态,跳过')
return
}
// 尝试全屏
await toggleFullscreen()
// 无论视频是否加载成功,都尝试播放
setTimeout(() => {
if (videoPlayerRef.value) {
showPoster.value = false
videoPlayerRef.value.play().catch(error => {
console.log('自动播放失败,可能需要用户交互:', error)
// 如果自动播放失败,显示播放按钮提示用户点击
showPoster.value = true
})
}
}, 300)
} catch (error) {
console.warn('立即全屏执行失败:', error)
// 即使全屏失败,也要尝试播放视频
setTimeout(() => {
if (videoPlayerRef.value) {
showPoster.value = false
videoPlayerRef.value.play().catch(playError => {
console.log('播放失败:', playError)
showPoster.value = true
})
}
}, 300)
}
}
// 增强的视频准备检查函数
const checkVideoReady = async () => {
let attempts = 0
const maxAttempts = 10
const check = async () => {
attempts++
console.log(`检查视频准备状态 (${attempts}/${maxAttempts})`)
if (videoPlayerRef.value) {
const video = videoPlayerRef.value
// 检查视频是否有基本信息
if (video.duration && video.duration > 0) {
console.log('视频已准备就绪,执行全屏播放')
try {
// 确保全屏
if (!document.fullscreenElement && !isFullscreen.value) {
await toggleFullscreen()
}
// 尝试播放
showPoster.value = false
await video.play()
console.log('视频播放成功')
} catch (error) {
console.log('视频播放失败,但保持全屏状态:', error)
showPoster.value = true
}
return // 成功,退出检查
}
}
// 如果还没准备好且未达到最大尝试次数,继续检查
if (attempts < maxAttempts) {
setTimeout(check, 500)
} else {
console.log('视频准备检查超时,执行降级全屏')
// 即使视频未准备好,也要确保全屏
if (!document.fullscreenElement && !isFullscreen.value) {
try {
await toggleFullscreen()
} catch (error) {
console.warn('降级全屏失败:', error)
}
}
}
}
// 开始检查
setTimeout(check, 100)
}
// 视频加载错误处理
const handleVideoError = async (event: Event) => {
const videoElement = event.target as HTMLVideoElement
const error = videoElement.error
console.error('视频加载错误:', error)
videoLoadError.value = true
if (error) {
let errorMessage = '视频加载失败'
let shouldRetry = true
switch (error.code) {
case MediaError.MEDIA_ERR_ABORTED:
errorMessage = '视频加载被中止'
shouldRetry = true // 网络中止通常可以重试
break
case MediaError.MEDIA_ERR_NETWORK:
errorMessage = '网络错误,无法加载视频'
shouldRetry = true
break
case MediaError.MEDIA_ERR_DECODE:
errorMessage = '视频解码错误'
shouldRetry = false // 解码错误通常不需要重试
break
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = '视频格式不支持或文件不存在'
shouldRetry = true // 可能是URL问题尝试备用源
break
}
console.error(`视频错误 (${error.code}): ${errorMessage}`)
// 根据错误类型决定是否重试
if (shouldRetry && videoRetryCount.value < maxRetryCount) {
videoRetryCount.value++
console.log(`尝试重新加载视频 (${videoRetryCount.value}/${maxRetryCount})`)
// 对于网络中止错误,使用更短的延迟
const retryDelay = error.code === MediaError.MEDIA_ERR_ABORTED ? 500 : 1000 * videoRetryCount.value
setTimeout(async () => {
if (videoPlayerRef.value && video.value) {
try {
// 清除当前错误状态
videoLoadError.value = false
// 使用强化的加载函数重试
const success = await loadVideoWithFallback(videoPlayerRef.value, video.value)
if (success) {
videoRetryCount.value = 0
console.log('视频重新加载成功')
// 重新加载成功后,尝试播放
setTimeout(() => {
if (videoPlayerRef.value && !isPlaying.value) {
videoPlayerRef.value.play().catch(playError => {
console.log('重新加载后播放失败:', playError)
})
}
}, 200)
} else {
throw new Error('所有视频源都加载失败')
}
} catch (retryError) {
console.error('视频重试加载失败:', retryError)
videoLoadError.value = true
if (videoRetryCount.value >= maxRetryCount) {
ElMessage.error(`${errorMessage},请稍后重试`)
}
}
}
}, retryDelay)
} else {
ElMessage.error(`${errorMessage},请稍后重试`)
}
}
// 即使视频加载失败,也要尝试全屏(如果是自动播放模式)
// 延迟执行,确保不会被视频错误阻断
setTimeout(() => {
if (!document.fullscreenElement && !isFullscreen.value) {
console.log('视频加载失败,但仍尝试全屏')
executeImmediateFullscreen()
}
}, 1000)
}
// 视频点击事件处理 // 视频点击事件处理
const handleVideoClick = (event: Event) => { const handleVideoClick = (event: Event) => {
event.preventDefault() event.preventDefault()
@@ -583,16 +1104,50 @@ const toggleSpeed = () => {
} }
} }
const toggleFullscreen = () => { const toggleFullscreen = async () => {
try {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
videoPlayerRef.value?.requestFullscreen() // 尝试不同的全屏API以确保浏览器兼容性
const element = videoPlayerRef.value
if (element) {
if (element.requestFullscreen) {
await element.requestFullscreen()
} else if ((element as any).webkitRequestFullscreen) {
// Safari
await (element as any).webkitRequestFullscreen()
} else if ((element as any).mozRequestFullScreen) {
// Firefox
await (element as any).mozRequestFullScreen()
} else if ((element as any).msRequestFullscreen) {
// IE/Edge
await (element as any).msRequestFullscreen()
}
}
} else { } else {
document.exitFullscreen() // 退出全屏也需要考虑浏览器兼容性
if (document.exitFullscreen) {
await document.exitFullscreen()
} else if ((document as any).webkitExitFullscreen) {
await (document as any).webkitExitFullscreen()
} else if ((document as any).mozCancelFullScreen) {
await (document as any).mozCancelFullScreen()
} else if ((document as any).msExitFullscreen) {
await (document as any).msExitFullscreen()
}
}
} catch (error) {
console.warn('全屏操作失败:', error)
// 即使全屏失败,也不阻止其他操作
} }
} }
const handleFullscreenChange = () => { const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement // 检查不同浏览器的全屏状态
const fullscreenElement = document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement
isFullscreen.value = !!fullscreenElement
} }
const hideControlsAfterDelay = () => { const hideControlsAfterDelay = () => {

View File

@@ -126,7 +126,7 @@ http.interceptors.response.use(
const userStore = useUserStore() const userStore = useUserStore()
userStore.logout() userStore.logout()
ElMessage.error('登录已过期,请重新登录') ElMessage.error('登录已过期,请重新登录')
router.push('/login') router.push('/admin/login')
return Promise.reject(refreshError) return Promise.reject(refreshError)
} finally { } finally {
isRefreshing = false isRefreshing = false
@@ -140,7 +140,7 @@ http.interceptors.response.use(
const userStore = useUserStore() const userStore = useUserStore()
userStore.logout() userStore.logout()
ElMessage.error('登录已过期,请重新登录') ElMessage.error('登录已过期,请重新登录')
router.push('/login') router.push('/admin/login')
} }
break break
case 403: case 403:
@@ -150,7 +150,7 @@ http.interceptors.response.use(
const userStore = useUserStore() const userStore = useUserStore()
userStore.logout() userStore.logout()
ElMessage.error('登录已过期,请重新登录') ElMessage.error('登录已过期,请重新登录')
router.push('/login') router.push('/admin/login')
} else { } else {
ElMessage.error('权限不足') ElMessage.error('权限不足')
} }

View File

@@ -1,56 +1,117 @@
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const path = require('path'); const path = require('path');
const express = require('express'); const express = require('express');
const fs = require('fs');
// 生产环境配置 // 加载生产环境配置文件
require('dotenv').config({ path: path.join(__dirname, '.env.production') });
// 生产环境配置 - 宝塔面板兼容
const API_PORT = process.env.PORT || 4001; const API_PORT = process.env.PORT || 4001;
const FRONTEND_PORT = process.env.FRONTEND_PORT || 4002; const FRONTEND_PORT = process.env.FRONTEND_PORT || 4002;
const NODE_ENV = 'production'; const NODE_ENV = 'production';
const DOMAIN = process.env.DOMAIN || 'ggl.xi.plus';
// 验证端口范围 (4001-4010)
if (API_PORT < 4001 || API_PORT > 4010) {
console.warn(`警告: API端口 ${API_PORT} 不在推荐范围 4001-4010 内`);
}
if (FRONTEND_PORT < 4001 || FRONTEND_PORT > 4010) {
console.warn(`警告: 前端端口 ${FRONTEND_PORT} 不在推荐范围 4001-4010 内`);
}
// 设置环境变量 // 设置环境变量
process.env.NODE_ENV = NODE_ENV; process.env.NODE_ENV = NODE_ENV;
process.env.PORT = API_PORT; process.env.PORT = API_PORT;
process.env.DOMAIN = DOMAIN;
console.log(`启动生产环境服务器`); console.log(`=== 梦回高句丽 - 生产环境启动 ===`);
console.log(`域名: ${DOMAIN}`);
console.log(`后端API端口: ${API_PORT}`); console.log(`后端API端口: ${API_PORT}`);
console.log(`前端静态文件端口: ${FRONTEND_PORT}`); console.log(`前端静态文件端口: ${FRONTEND_PORT}`);
console.log(`工作目录: ${process.cwd()}`); console.log(`工作目录: ${process.cwd()}`);
console.log(`Node.js版本: ${process.version}`);
console.log(`启动时间: ${new Date().toLocaleString('zh-CN')}`);
// 检查dist目录是否存在
const distPath = path.join(__dirname, 'dist');
if (!fs.existsSync(distPath)) {
console.error(`错误: dist目录不存在 (${distPath})`);
console.log('请先运行 npm run build 构建前端项目');
process.exit(1);
}
// 启动前端静态文件服务 // 启动前端静态文件服务
const app = express(); const app = express();
app.use(express.static(path.join(__dirname, 'dist')));
app.get('*', (req, res) => { // 安全头设置
res.sendFile(path.join(__dirname, 'dist', 'index.html')); app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
next();
}); });
const frontendServer = app.listen(FRONTEND_PORT, () => { // 静态文件服务
console.log(`前端静态文件服务已启动,端口: ${FRONTEND_PORT}`); app.use(express.static(distPath, {
maxAge: '1y',
etag: true,
lastModified: true
}));
// SPA路由处理
app.get('*', (req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
}); });
const frontendServer = app.listen(FRONTEND_PORT, '0.0.0.0', () => {
console.log(`✅ 前端静态文件服务已启动`);
console.log(` - 端口: ${FRONTEND_PORT}`);
console.log(` - 访问地址: http://${DOMAIN}:${FRONTEND_PORT}`);
});
// 检查API服务器文件是否存在
const apiServerPath = path.join(__dirname, 'api', 'server.ts');
if (!fs.existsSync(apiServerPath)) {
console.error(`错误: API服务器文件不存在 (${apiServerPath})`);
process.exit(1);
}
// 启动后端API服务器 // 启动后端API服务器
console.log(`🚀 启动后端API服务器...`);
const apiServer = spawn('node', ['--import', 'tsx/esm', 'api/server.ts'], { const apiServer = spawn('node', ['--import', 'tsx/esm', 'api/server.ts'], {
stdio: 'inherit', stdio: 'inherit',
env: { env: {
...process.env, ...process.env,
NODE_ENV: NODE_ENV, NODE_ENV: NODE_ENV,
PORT: API_PORT PORT: API_PORT,
} DOMAIN: DOMAIN
},
cwd: __dirname
}); });
// 处理API服务器进程退出 // 处理API服务器进程退出
apiServer.on('close', (code) => { apiServer.on('close', (code) => {
console.log(`API服务器进程退出退出码: ${code}`); console.log(`API服务器进程退出退出码: ${code}`);
frontendServer.close(); frontendServer.close();
process.exit(code); process.exit(code);
}); });
// 处理API服务器错误 // 处理API服务器错误
apiServer.on('error', (err) => { apiServer.on('error', (err) => {
console.error('启动API服务器时发生错误:', err); console.error('启动API服务器时发生错误:', err);
frontendServer.close(); frontendServer.close();
process.exit(1); process.exit(1);
}); });
// API服务器启动成功提示
setTimeout(() => {
console.log(`✅ 后端API服务已启动`);
console.log(` - 端口: ${API_PORT}`);
console.log(` - API地址: http://${DOMAIN}:${API_PORT}/api`);
console.log(`\n🌐 服务器已完全启动,可通过宝塔面板管理`);
}, 2000);
// 优雅关闭 // 优雅关闭
process.on('SIGINT', () => { process.on('SIGINT', () => {
console.log('\n收到 SIGINT 信号,正在关闭服务器...'); console.log('\n收到 SIGINT 信号,正在关闭服务器...');

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.