生产环境修正
@@ -1,20 +1,42 @@
|
||||
# 生产环境配置
|
||||
# 梦回高句丽 - 生产环境配置
|
||||
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_BACKUP_PATH=./database/goguryeo_video.db.backup
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your-production-jwt-secret-key-here
|
||||
# JWT配置 (生产环境请使用强密钥)
|
||||
JWT_SECRET=ggl-xi-plus-production-jwt-secret-2024
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# 文件上传配置
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_FILE_SIZE=100MB
|
||||
ALLOWED_FILE_TYPES=jpg,jpeg,png,gif,webp,mp4,avi,mov,wmv
|
||||
|
||||
# 服务器配置
|
||||
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_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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
* 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方法)
|
||||
@@ -995,9 +890,11 @@ router.post('/batch/delete', authenticateToken, async (req: Request, res: Respon
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('批量删除视频失败:', error);
|
||||
console.error('错误堆栈:', error.stack);
|
||||
res.status(500).json({
|
||||
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
|
||||
*/
|
||||
router.delete('/batch', authenticateToken, async (req: Request, res: Response): Promise<void> => {
|
||||
console.log('批量删除视频开始,请求体:', req.body);
|
||||
console.log('用户信息:', req.user);
|
||||
try {
|
||||
const { videoIds } = req.body;
|
||||
|
||||
@@ -1127,7 +1026,7 @@ router.delete('/batch', authenticateToken, async (req: Request, res: Response):
|
||||
// 获取要删除的视频信息
|
||||
const placeholders = validIds.map(() => '?').join(',');
|
||||
const videos = await query(`
|
||||
SELECT id, file_path, thumbnail
|
||||
SELECT id, file_path, cover_image
|
||||
FROM videos
|
||||
WHERE id IN (${placeholders})
|
||||
`, validIds);
|
||||
@@ -1150,8 +1049,8 @@ router.delete('/batch', authenticateToken, async (req: Request, res: Response):
|
||||
}
|
||||
|
||||
// 删除缩略图文件
|
||||
if (video.thumbnail) {
|
||||
const thumbnailPath = path.join(process.cwd(), 'public', video.thumbnail);
|
||||
if (video.cover_image) {
|
||||
const thumbnailPath = path.join(process.cwd(), 'public', video.cover_image);
|
||||
if (fs.existsSync(thumbnailPath)) {
|
||||
fs.unlinkSync(thumbnailPath);
|
||||
// 缩略图删除成功
|
||||
@@ -1185,9 +1084,11 @@ router.delete('/batch', authenticateToken, async (req: Request, res: Response):
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('批量删除视频失败:', error);
|
||||
console.error('错误堆栈:', error.stack);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
message: '服务器内部错误',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'ggl-app',
|
||||
apps: [
|
||||
{
|
||||
name: 'ggl-xi-plus-production',
|
||||
script: 'start-production.cjs',
|
||||
cwd: '/www/wwwroot/ggl',
|
||||
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,
|
||||
watch: false,
|
||||
max_memory_restart: '1G',
|
||||
|
||||
// 启动配置
|
||||
min_uptime: '10s',
|
||||
max_restarts: 10,
|
||||
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,
|
||||
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
@@ -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",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"tsx api/server.ts\"",
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "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"
|
||||
"start:bt": "node start-production.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"16": "^0.0.2",
|
||||
|
||||
@@ -219,13 +219,21 @@ const handleSearchFromModal = (query: string, filters?: any) => {
|
||||
// 分类数据
|
||||
const categories = ref([
|
||||
{ id: 'all', name: '全部', icon: 'Grid', count: 0 },
|
||||
{ id: 'history', name: '历史文化', icon: 'Monument', count: 0 },
|
||||
{ id: 'architecture', name: '建筑艺术', icon: 'Picture', count: 0 },
|
||||
{ id: 'archaeology', name: '考古发现', icon: 'Document', count: 0 },
|
||||
{ id: 'documentary', name: '纪录片', icon: 'VideoCamera', count: 0 },
|
||||
{ id: 'featured', name: '精选推荐', icon: 'Star', count: 0 }
|
||||
{ id: 'history', name: '历史文化', icon: 'Monument', count: 0, dbId: 1 },
|
||||
{ id: 'art', name: '传统艺术', icon: 'Picture', count: 0, dbId: 2 },
|
||||
{ id: 'language', name: '语言学习', icon: 'Document', count: 0, dbId: 3 },
|
||||
{ id: 'archaeology', name: '考古发现', icon: 'VideoCamera', count: 0, dbId: 4 },
|
||||
{ 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({
|
||||
totalVideos: 0,
|
||||
@@ -282,7 +290,7 @@ const loadVideos = async (append = false) => {
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
category: selectedCategory.value === 'all' ? undefined : selectedCategory.value,
|
||||
category: getCategoryDbId(selectedCategory.value),
|
||||
sortBy: sortBy.value,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<div
|
||||
v-if="showPoster"
|
||||
class="video-poster"
|
||||
:style="{ backgroundImage: `url(${video?.thumbnail})` }"
|
||||
:style="{ backgroundImage: `url(${video?.cover_image ? `https://ggl.xi.plus${video.cover_image}` : ''})` }"
|
||||
@click="handleVideoClick"
|
||||
@dblclick="handleVideoDoubleClick"
|
||||
>
|
||||
@@ -52,8 +52,7 @@
|
||||
|
||||
<video
|
||||
ref="videoPlayerRef"
|
||||
:src="video?.video_url || ''"
|
||||
:poster="video?.thumbnail"
|
||||
:poster="video?.cover_image ? `https://ggl.xi.plus${video.cover_image}` : ''"
|
||||
@loadedmetadata="handleVideoLoaded"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
@ended="handleVideoEnded"
|
||||
@@ -61,11 +60,23 @@
|
||||
@pause="handleVideoPause"
|
||||
@click="handleVideoClick"
|
||||
@dblclick="handleVideoDoubleClick"
|
||||
@error="handleVideoError"
|
||||
@loadstart="handleVideoLoadStart"
|
||||
@canplay="handleVideoCanPlay"
|
||||
class="video-element"
|
||||
:controls="false"
|
||||
autoplay
|
||||
preload="metadata"
|
||||
></video>
|
||||
preload="auto"
|
||||
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
|
||||
@@ -333,6 +344,12 @@ const countdownTimer = ref<NodeJS.Timeout>()
|
||||
const showFilenameInFullscreen = ref(false)
|
||||
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 relatedVideos = ref<any[]>([])
|
||||
@@ -347,32 +364,105 @@ const videoId = computed(() => Number(route.params.id))
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadVideoData()
|
||||
// 添加所有浏览器的全屏变化事件监听
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange)
|
||||
document.addEventListener('mozfullscreenchange', handleFullscreenChange)
|
||||
document.addEventListener('MSFullscreenChange', handleFullscreenChange)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
// 自动全屏播放
|
||||
// 强化的自动全屏播放逻辑 - 确保在任何访问方式下都能工作
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
// 确保视频元素已加载
|
||||
if (videoPlayerRef.value) {
|
||||
// 先进入全屏模式
|
||||
// 立即尝试全屏,不依赖视频加载状态(这个函数将被下面定义的强化版本替代)
|
||||
const executeImmediateFullscreenOld = () => {
|
||||
console.log('执行立即全屏逻辑')
|
||||
|
||||
// 无条件进入全屏模式
|
||||
if (!document.fullscreenElement) {
|
||||
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(() => {
|
||||
if (videoPlayerRef.value) {
|
||||
showPoster.value = false
|
||||
videoPlayerRef.value.play().catch(error => {
|
||||
console.log('自动播放失败,可能需要用户交互:', error)
|
||||
// 如果自动播放失败,显示播放按钮提示用户点击
|
||||
showPoster.value = true
|
||||
})
|
||||
}
|
||||
}, 500)
|
||||
}, 200)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// 立即执行全屏逻辑,不等待视频加载
|
||||
setTimeout(executeImmediateFullscreen, 100)
|
||||
|
||||
// 同时启动增强的视频准备检查作为备用
|
||||
setTimeout(checkVideoReady, 500)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 移除所有浏览器的全屏变化事件监听
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange)
|
||||
document.removeEventListener('mozfullscreenchange', handleFullscreenChange)
|
||||
document.removeEventListener('MSFullscreenChange', handleFullscreenChange)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
if (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 () => {
|
||||
try {
|
||||
videoLoading.value = true
|
||||
@@ -457,10 +633,9 @@ const handleVideoLoaded = () => {
|
||||
if (videoPlayerRef.value) {
|
||||
duration.value = videoPlayerRef.value.duration
|
||||
volume.value = videoPlayerRef.value.volume * 100
|
||||
// 自动播放视频
|
||||
videoPlayerRef.value.play().catch(error => {
|
||||
console.log('自动播放失败,可能需要用户交互:', error)
|
||||
})
|
||||
|
||||
// 视频加载完成,自动播放逻辑已在onMounted中的全屏播放逻辑中处理
|
||||
// 这里不再单独处理自动播放,避免重复播放
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +671,352 @@ const handleVideoEnded = () => {
|
||||
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) => {
|
||||
event.preventDefault()
|
||||
@@ -583,16 +1104,50 @@ const toggleSpeed = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const toggleFullscreen = async () => {
|
||||
try {
|
||||
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 {
|
||||
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 = () => {
|
||||
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 = () => {
|
||||
|
||||
@@ -126,7 +126,7 @@ http.interceptors.response.use(
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
router.push('/admin/login')
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
@@ -140,7 +140,7 @@ http.interceptors.response.use(
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
router.push('/admin/login')
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
@@ -150,7 +150,7 @@ http.interceptors.response.use(
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
router.push('/admin/login')
|
||||
} else {
|
||||
ElMessage.error('权限不足')
|
||||
}
|
||||
|
||||
@@ -1,56 +1,117 @@
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
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 FRONTEND_PORT = process.env.FRONTEND_PORT || 4002;
|
||||
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.PORT = API_PORT;
|
||||
process.env.DOMAIN = DOMAIN;
|
||||
|
||||
console.log(`启动生产环境服务器`);
|
||||
console.log(`=== 梦回高句丽 - 生产环境启动 ===`);
|
||||
console.log(`域名: ${DOMAIN}`);
|
||||
console.log(`后端API端口: ${API_PORT}`);
|
||||
console.log(`前端静态文件端口: ${FRONTEND_PORT}`);
|
||||
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();
|
||||
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服务器
|
||||
console.log(`🚀 启动后端API服务器...`);
|
||||
const apiServer = spawn('node', ['--import', 'tsx/esm', 'api/server.ts'], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: NODE_ENV,
|
||||
PORT: API_PORT
|
||||
}
|
||||
PORT: API_PORT,
|
||||
DOMAIN: DOMAIN
|
||||
},
|
||||
cwd: __dirname
|
||||
});
|
||||
|
||||
// 处理API服务器进程退出
|
||||
apiServer.on('close', (code) => {
|
||||
console.log(`API服务器进程退出,退出码: ${code}`);
|
||||
console.log(`❌ API服务器进程退出,退出码: ${code}`);
|
||||
frontendServer.close();
|
||||
process.exit(code);
|
||||
});
|
||||
|
||||
// 处理API服务器错误
|
||||
apiServer.on('error', (err) => {
|
||||
console.error('启动API服务器时发生错误:', err);
|
||||
console.error('❌ 启动API服务器时发生错误:', err);
|
||||
frontendServer.close();
|
||||
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', () => {
|
||||
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 |