生产环境修正
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
gsfKg36gD--OGPGB5GqQUSy8JXZ86aUF7nLeJRg8ztk.OSdKi3nCfYN2E9beqMDFL03NkU68V1A8He3MYctm_jw
|
||||||
191
BAOTA_DEPLOYMENT.md
Normal 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月
|
||||||
|
**维护者**: 系统管理员
|
||||||
@@ -59,6 +59,8 @@ app.use('/api/health', (req: Request, res: Response, next: NextFunction): void =
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* error handler middleware
|
* error handler middleware
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,38 +1,40 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [{
|
apps: [
|
||||||
name: 'ggl-app',
|
{
|
||||||
script: 'start-production.cjs',
|
name: 'ggl-xi-plus-production',
|
||||||
cwd: '/www/wwwroot/ggl',
|
script: 'start-production.cjs',
|
||||||
instances: 1,
|
instances: 1,
|
||||||
exec_mode: 'fork',
|
autorestart: true,
|
||||||
env: {
|
watch: false,
|
||||||
NODE_ENV: 'production',
|
max_memory_restart: '1G',
|
||||||
PORT: 4001,
|
min_uptime: '10s',
|
||||||
FRONTEND_PORT: 4002
|
max_restarts: 10,
|
||||||
},
|
restart_delay: 4000,
|
||||||
env_production: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
PORT: 4001,
|
PORT: 4001,
|
||||||
FRONTEND_PORT: 4002
|
FRONTEND_PORT: 4002,
|
||||||
},
|
DOMAIN: 'ggl.xi.plus'
|
||||||
// 日志配置
|
},
|
||||||
log_file: './logs/combined.log',
|
log_file: './logs/pm2-combined.log',
|
||||||
out_file: './logs/out.log',
|
out_file: './logs/pm2-out.log',
|
||||||
error_file: './logs/error.log',
|
error_file: './logs/pm2-error.log',
|
||||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
merge_logs: true,
|
||||||
// 进程管理配置
|
kill_timeout: 5000,
|
||||||
autorestart: true,
|
listen_timeout: 8000,
|
||||||
watch: false,
|
shutdown_with_message: true
|
||||||
max_memory_restart: '1G',
|
}
|
||||||
|
],
|
||||||
// 启动配置
|
|
||||||
min_uptime: '10s',
|
deploy: {
|
||||||
max_restarts: 10,
|
production: {
|
||||||
restart_delay: 4000,
|
user: 'www',
|
||||||
|
host: 'localhost',
|
||||||
// 其他配置
|
ref: 'origin/main',
|
||||||
merge_logs: true,
|
repo: 'git@github.com:username/ggl.git',
|
||||||
time: true
|
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
@@ -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;
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
|
||||||
if (videoPlayerRef.value) {
|
|
||||||
showPoster.value = false
|
|
||||||
videoPlayerRef.value.play().catch(error => {
|
|
||||||
console.log('自动播放失败,可能需要用户交互:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
}
|
}
|
||||||
}, 1000)
|
|
||||||
|
// 延迟尝试播放视频
|
||||||
|
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(() => {
|
||||||
|
if (videoPlayerRef.value) {
|
||||||
|
showPoster.value = false
|
||||||
|
videoPlayerRef.value.play().catch(error => {
|
||||||
|
console.log('自动播放失败,可能需要用户交互:', error)
|
||||||
|
// 如果自动播放失败,显示播放按钮提示用户点击
|
||||||
|
showPoster.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即执行全屏逻辑,不等待视频加载
|
||||||
|
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 () => {
|
||||||
if (!document.fullscreenElement) {
|
try {
|
||||||
videoPlayerRef.value?.requestFullscreen()
|
if (!document.fullscreenElement) {
|
||||||
} else {
|
// 尝试不同的全屏API以确保浏览器兼容性
|
||||||
document.exitFullscreen()
|
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 {
|
||||||
|
// 退出全屏也需要考虑浏览器兼容性
|
||||||
|
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 = () => {
|
||||||
|
|||||||
@@ -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('权限不足')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 信号,正在关闭服务器...');
|
||||||
|
|||||||
BIN
uploads/covers/1758635476112_uv35usqon2s_cover.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
uploads/covers/1758635587029_mn919ot6ap_cover.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
uploads/covers/1758635618518_j4v34cgrywo_cover.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
uploads/covers/1758635656860_tibi7tdru1l_cover.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
uploads/covers/1758635676482_k11pqslwssr_cover.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
uploads/covers/1758635699097_a80wyypucga_cover.jpg
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
uploads/covers/1758635716063_80w4a56dvzt_cover.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
uploads/covers/1758635730773_uwfe7752zhi_cover.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
uploads/covers/1758635751948_s73n8gxszy_cover.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
uploads/covers/1758635770693_mikzsixxle_cover.jpg
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
uploads/covers/1758635785954_khgxz5j64o_cover.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
uploads/covers/1758635800547_u7gzdafwr5_cover.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
uploads/covers/1758635818798_lcntc5bf9a_cover.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
uploads/covers/1758635839632_iev7vutltr_cover.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
uploads/covers/1758635854933_azqcx9o9qhn_cover.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
uploads/covers/1758635902834_c4hyd3bw3pv_cover.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
uploads/covers/1758635942774_85s9uzyxynh_cover.jpg
Normal file
|
After Width: | Height: | Size: 58 KiB |