first commit
This commit is contained in:
20
.env.production
Normal file
20
.env.production
Normal file
@@ -0,0 +1,20 @@
|
||||
# 生产环境配置
|
||||
NODE_ENV=production
|
||||
PORT=4001
|
||||
|
||||
# 数据库配置
|
||||
DB_PATH=./database.db
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your-production-jwt-secret-key-here
|
||||
|
||||
# 文件上传配置
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_FILE_SIZE=100MB
|
||||
|
||||
# 服务器配置
|
||||
HOST=0.0.0.0
|
||||
CORS_ORIGIN=*
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=info
|
||||
25
.gitignore
vendored
Executable file
25
.gitignore
vendored
Executable file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.vite
|
||||
45
.htaccess
Normal file
45
.htaccess
Normal file
@@ -0,0 +1,45 @@
|
||||
# 宝塔面板Apache配置 - 前后端分离部署
|
||||
# 将API请求代理到Node.js后端服务(3001端口)
|
||||
# 其他请求服务前端静态文件
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# 代理API请求到后端Node.js服务
|
||||
RewriteCond %{REQUEST_URI} ^/api/.*
|
||||
RewriteRule ^api/(.*)$ http://127.0.0.1:3001/api/$1 [P,L]
|
||||
|
||||
# 代理uploads静态资源到后端
|
||||
RewriteCond %{REQUEST_URI} ^/uploads/.*
|
||||
RewriteRule ^uploads/(.*)$ http://127.0.0.1:3001/uploads/$1 [P,L]
|
||||
|
||||
# 前端路由处理 - 所有非API和非静态资源请求都指向index.html
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} !^/api/.*
|
||||
RewriteCond %{REQUEST_URI} !^/uploads/.*
|
||||
RewriteRule ^.*$ /dist/index.html [L]
|
||||
|
||||
# 设置静态文件缓存
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/jpg "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
ExpiresByType image/gif "access plus 1 year"
|
||||
ExpiresByType image/svg+xml "access plus 1 year"
|
||||
</IfModule>
|
||||
|
||||
# 启用gzip压缩
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/plain
|
||||
AddOutputFilterByType DEFLATE text/html
|
||||
AddOutputFilterByType DEFLATE text/xml
|
||||
AddOutputFilterByType DEFLATE text/css
|
||||
AddOutputFilterByType DEFLATE application/xml
|
||||
AddOutputFilterByType DEFLATE application/xhtml+xml
|
||||
AddOutputFilterByType DEFLATE application/rss+xml
|
||||
AddOutputFilterByType DEFLATE application/javascript
|
||||
AddOutputFilterByType DEFLATE application/x-javascript
|
||||
</IfModule>
|
||||
9
.npmrc
Normal file
9
.npmrc
Normal file
@@ -0,0 +1,9 @@
|
||||
# 本地npm配置文件,用于覆盖全局配置中的过时参数
|
||||
# 移除--init.module配置以消除警告
|
||||
|
||||
# 只保留不冲突的配置
|
||||
registry=https://registry.npmmirror.com/
|
||||
strict-ssl=false
|
||||
|
||||
# 使用新的配置格式覆盖过时的--init.module
|
||||
init-module=
|
||||
118
.trae/documents/TODO.md
Executable file
118
.trae/documents/TODO.md
Executable file
@@ -0,0 +1,118 @@
|
||||
# 梦回高句丽项目 TODO 文档
|
||||
|
||||
## 项目概述
|
||||
基于Vue3+TypeScript+Express+SQLite的视频分享平台,支持视频上传、播放、搜索、用户管理等功能。
|
||||
|
||||
## 开发阶段
|
||||
|
||||
### 第1阶段:项目初始化 ✅
|
||||
- [x] 创建Vue3+TypeScript+Vite前端项目
|
||||
- [x] 配置Tailwind CSS样式框架
|
||||
- [x] 创建Express+TypeScript后端项目
|
||||
- [x] 配置SQLite数据库(替代MySQL)
|
||||
- [x] 设置项目基础结构
|
||||
|
||||
### 第2阶段:数据库设计 ✅
|
||||
- [x] 设计用户表(users)
|
||||
- [x] 设计视频表(videos)
|
||||
- [x] 设计分类表(categories)
|
||||
- [x] 创建数据库初始化脚本
|
||||
- [x] 实现数据库连接和操作封装
|
||||
|
||||
### 第3阶段:后端API开发 ✅
|
||||
- [x] 用户认证API(登录/注册)
|
||||
- [x] 视频管理API(CRUD操作)
|
||||
- [x] 文件上传API(视频和封面)
|
||||
- [x] 搜索和分页API
|
||||
- [x] 统计数据API
|
||||
- [x] 中间件(认证、错误处理、文件上传)
|
||||
|
||||
### 第4阶段:前端页面开发 ✅
|
||||
- [x] 首页(瀑布流展示)
|
||||
- [x] 视频详情页
|
||||
- [x] 用户详情页
|
||||
- [x] 搜索结果页
|
||||
- [x] 管理员登录页
|
||||
- [x] 管理后台(Dashboard、视频管理、用户管理、统计)
|
||||
- [x] 响应式设计(PC端和移动端适配)
|
||||
|
||||
### 第5阶段:集成测试 ✅
|
||||
- [x] 前后端接口联调测试
|
||||
- [x] 用户登录流程测试
|
||||
- [x] 视频上传和播放功能测试
|
||||
- [x] 搜索和分页功能测试
|
||||
- [x] 缓存机制测试
|
||||
- [x] 移动端响应式测试
|
||||
- [x] 启动前后端项目验证功能
|
||||
|
||||
## 核心功能实现状态
|
||||
|
||||
### 前端功能 ✅
|
||||
- [x] 瀑布流视频展示
|
||||
- [x] 视频播放器集成
|
||||
- [x] 搜索功能
|
||||
- [x] 分页功能
|
||||
- [x] 用户详情页
|
||||
- [x] 管理后台界面
|
||||
- [x] 响应式设计
|
||||
- [x] 视频缓存机制(LRU算法,100MB限制,3天过期)
|
||||
|
||||
### 后端功能 ✅
|
||||
- [x] RESTful API设计
|
||||
- [x] JWT用户认证
|
||||
- [x] 文件上传处理
|
||||
- [x] 数据库操作封装
|
||||
- [x] 错误处理中间件
|
||||
- [x] CORS跨域配置
|
||||
|
||||
### 数据库功能 ✅
|
||||
- [x] SQLite数据库集成
|
||||
- [x] 用户表设计
|
||||
- [x] 视频表设计
|
||||
- [x] 分类表设计
|
||||
- [x] 数据库初始化
|
||||
|
||||
## 技术栈
|
||||
- **前端**: Vue3 + TypeScript + Vite + Tailwind CSS
|
||||
- **后端**: Node.js + Express + TypeScript
|
||||
- **数据库**: SQLite(替代MySQL)
|
||||
- **其他**: JWT认证、Multer文件上传、CORS
|
||||
|
||||
## 项目结构
|
||||
```
|
||||
ggl/
|
||||
├── src/ # 前端源码
|
||||
│ ├── components/ # 组件
|
||||
│ ├── pages/ # 页面
|
||||
│ ├── stores/ # 状态管理
|
||||
│ ├── utils/ # 工具函数
|
||||
│ └── styles/ # 样式文件
|
||||
├── api/ # 后端源码
|
||||
│ ├── routes/ # 路由
|
||||
│ ├── middleware/ # 中间件
|
||||
│ ├── config/ # 配置
|
||||
│ └── uploads/ # 上传文件
|
||||
├── database/ # 数据库文件
|
||||
└── public/ # 静态资源
|
||||
```
|
||||
|
||||
## 部署说明
|
||||
- 前端:使用Vite构建,可部署到Nginx
|
||||
- 后端:Node.js服务,支持PM2部署
|
||||
- 数据库:SQLite文件数据库,无需额外服务
|
||||
|
||||
## 最终状态
|
||||
✅ **项目开发完成** - 所有核心功能已实现并通过测试
|
||||
|
||||
### 测试结果总结
|
||||
1. ✅ 前后端接口联调正常
|
||||
2. ✅ 用户登录流程正常
|
||||
3. ✅ 视频上传播放功能正常
|
||||
4. ✅ 搜索分页功能正常
|
||||
5. ✅ 缓存机制工作正常
|
||||
6. ✅ 移动端响应式适配正常
|
||||
|
||||
项目已完成所有需求功能的开发和测试,可以进行部署使用。
|
||||
|
||||
---
|
||||
*最后更新时间: 2024年*
|
||||
268
.trae/documents/梦回高句丽项目TODO开发任务文档.md
Executable file
268
.trae/documents/梦回高句丽项目TODO开发任务文档.md
Executable file
@@ -0,0 +1,268 @@
|
||||
# 梦回高句丽视频分享平台 - TODO开发任务文档
|
||||
|
||||
## 项目概述
|
||||
本文档记录梦回高句丽视频分享平台的开发任务清单和进度跟踪。项目采用Vue3+TS+ElementPlus前端,Node.js+Express+SQLite后端架构。
|
||||
|
||||
## 重要变更记录
|
||||
### 数据库变更 (2025-01-06)
|
||||
- ✅ **数据库迁移**: 将数据库从MySQL改为SQLite
|
||||
- 替换package.json中的mysql2依赖为sqlite3
|
||||
- 修改database/init.sql为SQLite语法
|
||||
- 更新api/config/database.ts为SQLite连接配置
|
||||
- 确保所有数据表结构和索引适配SQLite
|
||||
- 测试SQLite数据库连接和表结构
|
||||
- 更新技术架构文档中的数据库配置说明
|
||||
- **变更原因**: 简化部署和维护,减少外部依赖
|
||||
- **影响范围**: 后端数据库连接、SQL语法、技术文档
|
||||
|
||||
## 任务状态说明
|
||||
- ⏳ 待开始
|
||||
- 🔄 进行中
|
||||
- ✅ 已完成
|
||||
- ❌ 已取消
|
||||
- 🔒 阻塞中
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目初始化任务 (优先级: 高)
|
||||
|
||||
### 1.1 环境搭建
|
||||
- [x] ✅ 创建项目根目录结构
|
||||
- [ ] ⏳ 初始化Git仓库
|
||||
- [x] ✅ 配置开发环境(Node.js 18+, MySQL 8.0+)
|
||||
- [x] ✅ 创建前端项目(Vue3 + Vite + TypeScript)
|
||||
- [x] ✅ 创建后端项目(Node.js + Express)
|
||||
|
||||
**完成标准**: 项目目录结构清晰,开发环境配置完成,可以正常启动前后端项目
|
||||
|
||||
### 1.2 依赖包安装
|
||||
- [x] ✅ 前端依赖:Vue3, TypeScript, Element Plus, Vue Router, Pinia, Axios
|
||||
- [x] ✅ 后端依赖:Express, SQLite3, bcrypt, jsonwebtoken, multer, cors
|
||||
- [x] ✅ 开发工具:ESLint, Prettier, nodemon
|
||||
|
||||
**完成标准**: 所有必要依赖包安装完成,package.json配置正确
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库设计任务 (优先级: 高)
|
||||
|
||||
### 2.1 数据库创建
|
||||
- [x] ✅ 创建SQLite数据库 `goguryeo_video.db`
|
||||
- [x] ✅ 配置数据库连接参数
|
||||
- [x] ✅ 创建数据库连接模块
|
||||
|
||||
**完成标准**: 数据库创建成功,连接测试通过
|
||||
|
||||
### 2.2 数据表设计
|
||||
- [x] ✅ 创建用户表 (users)
|
||||
- [x] ✅ 创建视频表 (videos)
|
||||
- [x] ✅ 创建视频统计表 (video_stats)
|
||||
- [x] ✅ 创建必要索引
|
||||
- [x] ✅ 插入初始管理员数据
|
||||
|
||||
**完成标准**: 所有数据表创建完成,索引配置正确,初始数据插入成功
|
||||
|
||||
---
|
||||
|
||||
## 3. 后端开发任务 (优先级: 高)
|
||||
|
||||
### 3.1 基础架构搭建
|
||||
- [ ] ⏳ 创建Express服务器基础结构
|
||||
- [ ] ⏳ 配置中间件(cors, body-parser, 静态文件服务)
|
||||
- [ ] ⏳ 创建路由模块结构
|
||||
- [ ] ⏳ 配置错误处理中间件
|
||||
- [ ] ⏳ 配置日志记录
|
||||
|
||||
**完成标准**: 服务器可以正常启动,基础中间件配置完成
|
||||
|
||||
### 3.2 用户认证模块
|
||||
- [ ] ⏳ 实现用户登录API (`POST /api/auth/login`)
|
||||
- [ ] ⏳ 实现JWT token生成和验证
|
||||
- [ ] ⏳ 创建认证中间件
|
||||
- [ ] ⏳ 实现登录状态检查API
|
||||
|
||||
**完成标准**: 管理员可以正常登录,JWT认证机制工作正常
|
||||
|
||||
### 3.3 视频管理API
|
||||
- [ ] ⏳ 实现视频列表查询API (`GET /api/videos`)
|
||||
- [ ] ⏳ 实现视频详情查询API (`GET /api/videos/:id`)
|
||||
- [ ] ⏳ 实现视频上传API (`POST /api/videos`)
|
||||
- [ ] ⏳ 实现视频更新API (`PUT /api/videos/:id`)
|
||||
- [ ] ⏳ 实现视频删除API (`DELETE /api/videos/:id`)
|
||||
- [ ] ⏳ 实现视频搜索API (`GET /api/videos/search`)
|
||||
- [ ] ⏳ 实现播放量统计API
|
||||
|
||||
**完成标准**: 所有视频相关API功能正常,支持分页、搜索、CRUD操作
|
||||
|
||||
### 3.4 文件上传处理
|
||||
- [ ] ⏳ 配置multer文件上传中间件
|
||||
- [ ] ⏳ 实现视频文件上传处理
|
||||
- [ ] ⏳ 实现封面图片上传处理
|
||||
- [ ] ⏳ 配置文件存储路径和访问URL
|
||||
- [ ] ⏳ 实现文件类型和大小验证
|
||||
|
||||
**完成标准**: 视频和图片文件可以正常上传,文件验证机制工作正常
|
||||
|
||||
### 3.5 用户相关API
|
||||
- [ ] ⏳ 实现用户信息查询API (`GET /api/users/:id`)
|
||||
- [ ] ⏳ 实现用户视频列表API (`GET /api/users/:id/videos`)
|
||||
- [ ] ⏳ 实现用户视频搜索API
|
||||
|
||||
**完成标准**: 用户相关API功能正常,支持用户视频查询和搜索
|
||||
|
||||
---
|
||||
|
||||
## 4. 前端开发任务 (优先级: 中) ✅ **已完成**
|
||||
|
||||
### 4.1 项目基础配置 ✅
|
||||
- [x] ✅ 配置Vue Router路由
|
||||
- [x] ✅ 配置Pinia状态管理
|
||||
- [x] ✅ 配置Axios HTTP客户端
|
||||
- [x] ✅ 配置Element Plus组件库
|
||||
- [x] ✅ 创建全局样式文件(高句丽主题)
|
||||
|
||||
**完成标准**: 前端基础架构搭建完成,可以正常路由跳转 ✅
|
||||
|
||||
### 4.2 公共组件开发 ✅
|
||||
- [x] ✅ 创建Header导航组件
|
||||
- [x] ✅ 创建Footer组件
|
||||
- [x] ✅ 创建Loading加载组件
|
||||
- [x] ✅ 创建VideoCard视频卡片组件
|
||||
- [x] ✅ 创建Pagination分页组件
|
||||
- [x] ✅ 创建SearchBox搜索组件
|
||||
|
||||
**完成标准**: 公共组件开发完成,样式符合高句丽主题设计 ✅
|
||||
|
||||
### 4.3 前台页面开发 ✅
|
||||
- [x] ✅ 首页瀑布流布局实现
|
||||
- [x] ✅ 首页搜索功能实现
|
||||
- [x] ✅ 首页分页功能实现
|
||||
- [x] ✅ 视频详情页开发
|
||||
- [x] ✅ 视频播放器集成
|
||||
- [x] ✅ 用户详情页开发
|
||||
- [x] ✅ 用户视频列表页面
|
||||
- [x] ✅ 搜索结果页面开发
|
||||
|
||||
**完成标准**: 前台所有页面开发完成,功能正常,响应式设计适配 ✅
|
||||
|
||||
### 4.4 后台管理页面开发 ✅
|
||||
- [x] ✅ 后台登录页面
|
||||
- [x] ✅ 后台主页面布局(侧边栏导航)
|
||||
- [x] ✅ 视频管理页面(列表、编辑、删除)
|
||||
- [x] ✅ 视频上传页面
|
||||
- [x] ✅ 数据统计页面
|
||||
- [x] ✅ 用户管理页面
|
||||
|
||||
**完成标准**: 后台管理功能完整,管理员可以正常管理视频内容 ✅
|
||||
|
||||
### 4.5 特殊功能实现 ✅
|
||||
- [x] ✅ 视频本地缓存机制(100MB限制)
|
||||
- [x] ✅ 缓存过期清理(3天限制)
|
||||
- [x] ✅ 火热标记显示逻辑
|
||||
- [x] ✅ 推荐标记显示逻辑
|
||||
- [x] ✅ 播放量统计和显示
|
||||
- [x] ✅ 移动端适配优化
|
||||
|
||||
**完成标准**: 所有特殊功能正常工作,缓存机制有效,移动端体验良好 ✅
|
||||
|
||||
---
|
||||
|
||||
## 5. 集成测试任务 (优先级: 中)
|
||||
|
||||
### 5.1 功能测试
|
||||
- [ ] ⏳ 前后端接口联调测试
|
||||
- [ ] ⏳ 用户登录流程测试
|
||||
- [ ] ⏳ 视频上传流程测试
|
||||
- [ ] ⏳ 视频播放功能测试
|
||||
- [ ] ⏳ 搜索功能测试
|
||||
- [ ] ⏳ 分页功能测试
|
||||
- [ ] ⏳ 缓存机制测试
|
||||
|
||||
**完成标准**: 所有核心功能测试通过,无重大bug
|
||||
|
||||
### 5.2 性能测试
|
||||
- [ ] ⏳ 视频加载性能测试
|
||||
- [ ] ⏳ 瀑布流滚动性能测试
|
||||
- [ ] ⏳ 移动端性能测试
|
||||
- [ ] ⏳ 并发访问测试
|
||||
|
||||
**完成标准**: 性能指标达到预期,用户体验流畅
|
||||
|
||||
---
|
||||
|
||||
## 6. 部署配置任务 (优先级: 低)
|
||||
|
||||
### 6.1 生产环境配置
|
||||
- [ ] ⏳ 配置生产环境变量
|
||||
- [ ] ⏳ 配置Nginx反向代理
|
||||
- [ ] ⏳ 配置SSL证书(如需要)
|
||||
- [ ] ⏳ 配置PM2进程管理
|
||||
- [ ] ⏳ 配置数据库生产环境
|
||||
|
||||
**完成标准**: 生产环境配置完成,可以正常部署运行
|
||||
|
||||
### 6.2 部署脚本
|
||||
- [ ] ⏳ 创建前端构建脚本
|
||||
- [ ] ⏳ 创建后端部署脚本
|
||||
- [ ] ⏳ 创建数据库迁移脚本
|
||||
- [ ] ⏳ 创建自动化部署脚本
|
||||
|
||||
**完成标准**: 部署脚本完成,可以一键部署
|
||||
|
||||
---
|
||||
|
||||
## 7. 文档和维护 (优先级: 低)
|
||||
|
||||
### 7.1 技术文档
|
||||
- [ ] ⏳ API接口文档
|
||||
- [ ] ⏳ 数据库设计文档
|
||||
- [ ] ⏳ 部署运维文档
|
||||
- [ ] ⏳ 用户使用手册
|
||||
|
||||
**完成标准**: 技术文档完整,便于后续维护
|
||||
|
||||
### 7.2 代码优化
|
||||
- [ ] ⏳ 代码重构和优化
|
||||
- [ ] ⏳ 性能优化
|
||||
- [ ] ⏳ 安全性检查
|
||||
- [ ] ⏳ 错误处理完善
|
||||
|
||||
**完成标准**: 代码质量达标,安全性和稳定性良好
|
||||
|
||||
---
|
||||
|
||||
## 开发进度跟踪
|
||||
|
||||
### 当前阶段: 前端开发完成,准备后端开发
|
||||
### 总体进度: 65%
|
||||
### 预计完成时间: 待评估
|
||||
|
||||
### 最近更新记录
|
||||
- 2024-01-XX: 创建TODO开发任务文档
|
||||
- 2024-01-XX: 完成项目初始化,创建Vue3+Express全栈项目架构
|
||||
- 2024-01-XX: 安装所有必要依赖包,包括Element Plus、Pinia、SQLite3等
|
||||
- 2025-01-06: ✅ **完成数据库迁移**: 将数据库从MySQL改为SQLite
|
||||
- 2025-01-06: ✅ **完成第4阶段前端开发任务**:
|
||||
- 完成前端基础配置(Vue Router、Pinia、Axios、Element Plus、高句丽主题样式)
|
||||
- 完成公共组件开发(Header、Footer、Loading、VideoCard、Pagination、SearchBox)
|
||||
- 完成前台页面开发(首页瀑布流、视频详情页、用户详情页、搜索结果页)
|
||||
- 完成后台管理页面开发(登录页、管理布局、视频管理、视频上传、数据统计、用户管理)
|
||||
- 完成特殊功能实现(视频缓存机制、标记显示逻辑、播放量统计、移动端适配)
|
||||
|
||||
### 风险和问题
|
||||
- 待识别...
|
||||
|
||||
### 下一步计划
|
||||
1. 完成项目初始化和环境搭建
|
||||
2. 设计和创建数据库结构
|
||||
3. 开发后端核心API
|
||||
4. 开发前端核心页面
|
||||
5. 集成测试和优化
|
||||
|
||||
---
|
||||
|
||||
**注意事项**:
|
||||
- 每完成一个任务,请及时更新状态和进度
|
||||
- 遇到问题及时记录在风险和问题部分
|
||||
- 定期评估和调整任务优先级
|
||||
- 保持代码质量和文档同步更新
|
||||
76
.trae/documents/梦回高句丽项目产品需求文档.md
Executable file
76
.trae/documents/梦回高句丽项目产品需求文档.md
Executable file
@@ -0,0 +1,76 @@
|
||||
# 梦回高句丽视频分享平台产品需求文档
|
||||
|
||||
## 1. Product Overview
|
||||
梦回高句丽是一个专注于高句丽文化内容的视频分享平台,为用户提供视频上传、观看、搜索和管理功能。
|
||||
- 解决高句丽文化内容传播和保存的问题,为文化爱好者和研究者提供专业的视频分享平台。
|
||||
- 目标是打造具有高句丽文化特色的专业视频分享社区,推广和传承高句丽文化。
|
||||
|
||||
## 2. Core Features
|
||||
|
||||
### 2.1 User Roles
|
||||
| Role | Registration Method | Core Permissions |
|
||||
|------|---------------------|------------------|
|
||||
| 游客用户 | 无需注册 | 可浏览视频、搜索、观看视频 |
|
||||
| 管理员 | 后台账号登录 | 可上传视频、管理内容、修改播放量、设置推荐 |
|
||||
|
||||
### 2.2 Feature Module
|
||||
我们的视频分享平台需求包含以下主要页面:
|
||||
1. **首页**:瀑布流视频展示、搜索功能、分页导航
|
||||
2. **视频详情页**:视频播放器、详细信息展示、相关推荐
|
||||
3. **用户详情页**:用户上传视频列表、用户信息、搜索功能
|
||||
4. **后台管理页**:视频管理、用户管理、数据统计
|
||||
5. **后台登录页**:管理员身份验证
|
||||
|
||||
### 2.3 Page Details
|
||||
| Page Name | Module Name | Feature description |
|
||||
|-----------|-------------|---------------------|
|
||||
| 首页 | 瀑布流展示区 | 16:9比例显示视频封面、标题、介绍、上传时间、上传用户,支持火热标记和推荐标记 |
|
||||
| 首页 | 搜索模块 | 关键词搜索视频标题,实时搜索结果展示 |
|
||||
| 首页 | 分页导航 | 分页切换,支持上一页下一页和页码跳转 |
|
||||
| 视频详情页 | 视频播放器 | 支持多格式视频播放,播放进度记录,缓存管理 |
|
||||
| 视频详情页 | 视频信息 | 显示标题、介绍、上传时间、上传用户、播放量统计 |
|
||||
| 用户详情页 | 用户视频列表 | 瀑布流展示用户上传的所有视频 |
|
||||
| 用户详情页 | 用户搜索 | 搜索用户上传视频中包含关键词的内容 |
|
||||
| 后台管理页 | 视频管理 | 视频增删改查、设置可见性、置顶、推荐、修改播放量 |
|
||||
| 后台管理页 | 内容编辑 | 视频信息编辑、封面设置、分类管理 |
|
||||
| 后台登录页 | 身份验证 | 管理员登录验证,会话管理 |
|
||||
|
||||
## 3. Core Process
|
||||
|
||||
**游客用户流程:**
|
||||
用户访问首页 → 浏览瀑布流视频 → 点击封面播放视频或点击标题进入详情页 → 观看视频 → 点击用户名进入用户详情页 → 使用搜索功能查找感兴趣的内容
|
||||
|
||||
**管理员流程:**
|
||||
管理员登录后台 → 上传视频并填写信息 → 设置封面和可见性 → 管理已上传视频 → 设置推荐和置顶 → 修改播放量数据
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[首页] --> B[视频详情页]
|
||||
A --> C[用户详情页]
|
||||
A --> D[搜索结果页]
|
||||
B --> E[视频播放]
|
||||
C --> F[用户视频列表]
|
||||
G[后台登录页] --> H[后台管理页]
|
||||
H --> I[视频上传]
|
||||
H --> J[内容管理]
|
||||
```
|
||||
|
||||
## 4. User Interface Design
|
||||
|
||||
### 4.1 Design Style
|
||||
- 主色调:深红色(#8B0000)和金黄色(#FFD700),体现高句丽文化特色
|
||||
- 按钮样式:圆角按钮,带有古典边框装饰
|
||||
- 字体:中文使用思源黑体,英文使用Roboto,主要字号16px
|
||||
- 布局风格:卡片式设计,顶部导航栏,响应式布局
|
||||
- 图标风格:使用古典风格图标,配合高句丽文化元素
|
||||
|
||||
### 4.2 Page Design Overview
|
||||
| Page Name | Module Name | UI Elements |
|
||||
|-----------|-------------|-------------|
|
||||
| 首页 | 瀑布流展示区 | 卡片式布局,16:9视频封面,渐变遮罩,火热标记(右上角红色火焰图标),推荐标记(左上角金色皇冠图标) |
|
||||
| 首页 | 搜索模块 | 顶部搜索栏,圆角输入框,搜索按钮带放大镜图标 |
|
||||
| 视频详情页 | 视频播放器 | 全屏播放器,自定义控制栏,进度条使用主题色 |
|
||||
| 后台管理页 | 管理界面 | 侧边栏导航,表格展示,操作按钮组,文件上传拖拽区域 |
|
||||
|
||||
### 4.3 Responsiveness
|
||||
网站采用移动端优先的响应式设计,支持PC端和移动端访问。移动端优化触摸交互,视频播放器支持手势控制,瀑布流在小屏幕上自适应为单列或双列布局。
|
||||
238
.trae/documents/梦回高句丽项目技术架构文档.md
Executable file
238
.trae/documents/梦回高句丽项目技术架构文档.md
Executable file
@@ -0,0 +1,238 @@
|
||||
# 梦回高句丽视频分享平台技术架构文档
|
||||
|
||||
## 1. Architecture design
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[用户浏览器] --> B[Vue3 前端应用]
|
||||
B --> C[Node.js + Express 后端]
|
||||
C --> D[SQLite 数据库]
|
||||
C --> E[文件存储系统]
|
||||
B --> F[本地缓存系统]
|
||||
|
||||
subgraph "前端层"
|
||||
B
|
||||
F
|
||||
end
|
||||
|
||||
subgraph "后端层"
|
||||
C
|
||||
end
|
||||
|
||||
subgraph "数据层"
|
||||
D
|
||||
E
|
||||
end
|
||||
```
|
||||
|
||||
## 2. Technology Description
|
||||
- 前端: Vue3@3.3+ + TypeScript@5.0+ + Element Plus@2.4+ + Vite@4.0+
|
||||
- 后端: Node.js@18+ + Express@4.18+ + SQLite@3.0+
|
||||
- 其他依赖: axios (HTTP客户端), vue-router (路由), pinia (状态管理), multer (文件上传)
|
||||
|
||||
## 3. Route definitions
|
||||
| Route | Purpose |
|
||||
|-------|----------|
|
||||
| / | 首页,瀑布流视频展示和搜索功能 |
|
||||
| /video/:id | 视频详情页,视频播放和详细信息 |
|
||||
| /user/:id | 用户详情页,用户上传视频列表 |
|
||||
| /search | 搜索结果页,关键词搜索结果展示 |
|
||||
| /admin/login | 后台登录页,管理员身份验证 |
|
||||
| /admin/dashboard | 后台管理首页,数据统计概览 |
|
||||
| /admin/videos | 后台视频管理,视频增删改查 |
|
||||
| /admin/upload | 后台视频上传,文件上传和信息编辑 |
|
||||
|
||||
## 4. API definitions
|
||||
|
||||
### 4.1 Core API
|
||||
|
||||
视频相关接口
|
||||
```
|
||||
GET /api/videos
|
||||
```
|
||||
|
||||
Request:
|
||||
| Param Name | Param Type | isRequired | Description |
|
||||
|------------|------------|------------|-------------|
|
||||
| page | number | false | 页码,默认1 |
|
||||
| limit | number | false | 每页数量,默认20 |
|
||||
| search | string | false | 搜索关键词 |
|
||||
| userId | string | false | 用户ID筛选 |
|
||||
|
||||
Response:
|
||||
| Param Name | Param Type | Description |
|
||||
|------------|------------|-------------|
|
||||
| success | boolean | 请求状态 |
|
||||
| data | object | 视频列表数据 |
|
||||
| total | number | 总数量 |
|
||||
|
||||
```
|
||||
POST /api/videos
|
||||
```
|
||||
|
||||
Request:
|
||||
| Param Name | Param Type | isRequired | Description |
|
||||
|------------|------------|------------|-------------|
|
||||
| title | string | true | 视频标题 |
|
||||
| description | string | false | 视频描述 |
|
||||
| videoFile | file | true | 视频文件 |
|
||||
| coverFile | file | false | 封面图片 |
|
||||
| isVisible | boolean | false | 是否可见 |
|
||||
| isRecommended | boolean | false | 是否推荐 |
|
||||
|
||||
用户认证接口
|
||||
```
|
||||
POST /api/auth/login
|
||||
```
|
||||
|
||||
Request:
|
||||
| Param Name | Param Type | isRequired | Description |
|
||||
|------------|------------|------------|-------------|
|
||||
| username | string | true | 用户名 |
|
||||
| password | string | true | 密码 |
|
||||
|
||||
Response:
|
||||
| Param Name | Param Type | Description |
|
||||
|------------|------------|-------------|
|
||||
| success | boolean | 登录状态 |
|
||||
| token | string | JWT令牌 |
|
||||
| user | object | 用户信息 |
|
||||
|
||||
## 5. Server architecture diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[客户端请求] --> B[Express 路由层]
|
||||
B --> C[中间件层]
|
||||
C --> D[控制器层]
|
||||
D --> E[服务层]
|
||||
E --> F[数据访问层]
|
||||
F --> G[(SQLite数据库)]
|
||||
|
||||
subgraph 服务器
|
||||
B
|
||||
C
|
||||
D
|
||||
E
|
||||
F
|
||||
end
|
||||
|
||||
subgraph 中间件
|
||||
H[身份验证]
|
||||
I[文件上传]
|
||||
J[错误处理]
|
||||
K[日志记录]
|
||||
end
|
||||
|
||||
C --> H
|
||||
C --> I
|
||||
C --> J
|
||||
C --> K
|
||||
```
|
||||
|
||||
## 6. Data model
|
||||
|
||||
### 6.1 Data model definition
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USERS ||--o{ VIDEOS : uploads
|
||||
VIDEOS ||--o{ VIDEO_STATS : has
|
||||
|
||||
USERS {
|
||||
int id PK
|
||||
string username
|
||||
string password_hash
|
||||
string email
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
}
|
||||
|
||||
VIDEOS {
|
||||
int id PK
|
||||
string title
|
||||
text description
|
||||
string video_url
|
||||
string cover_url
|
||||
int user_id FK
|
||||
int view_count
|
||||
boolean is_visible
|
||||
boolean is_recommended
|
||||
boolean is_hot
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
}
|
||||
|
||||
VIDEO_STATS {
|
||||
int id PK
|
||||
int video_id FK
|
||||
int view_count
|
||||
datetime last_viewed
|
||||
datetime created_at
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Data Definition Language
|
||||
|
||||
用户表 (users)
|
||||
```sql
|
||||
-- 创建用户表
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
email TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
|
||||
-- 初始化管理员账户
|
||||
INSERT INTO users (username, password_hash, email) VALUES
|
||||
('admin', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'admin@goguryeo.com');
|
||||
```
|
||||
|
||||
视频表 (videos)
|
||||
```sql
|
||||
-- 创建视频表
|
||||
CREATE TABLE videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
video_url TEXT NOT NULL,
|
||||
cover_url TEXT,
|
||||
user_id INTEGER NOT NULL,
|
||||
view_count INTEGER DEFAULT 0,
|
||||
is_visible INTEGER DEFAULT 1,
|
||||
is_recommended INTEGER DEFAULT 0,
|
||||
is_hot INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_videos_user_id ON videos(user_id);
|
||||
CREATE INDEX idx_videos_created_at ON videos(created_at DESC);
|
||||
CREATE INDEX idx_videos_view_count ON videos(view_count DESC);
|
||||
CREATE INDEX idx_videos_title ON videos(title);
|
||||
```
|
||||
|
||||
视频统计表 (video_stats)
|
||||
```sql
|
||||
-- 创建视频统计表
|
||||
CREATE TABLE video_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id INTEGER NOT NULL,
|
||||
view_count INTEGER DEFAULT 0,
|
||||
last_viewed DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_video_stats_video_id ON video_stats(video_id);
|
||||
CREATE INDEX idx_video_stats_last_viewed ON video_stats(last_viewed DESC);
|
||||
```
|
||||
3
.vscode/extensions.json
vendored
Executable file
3
.vscode/extensions.json
vendored
Executable file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
105
DEPLOYMENT.md
Normal file
105
DEPLOYMENT.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 宝塔面板 NODE 项目部署说明
|
||||
|
||||
## 项目配置
|
||||
|
||||
### 端口配置
|
||||
- 生产环境端口:4001
|
||||
- 端口范围:4001-4010(可在宝塔面板中调整)
|
||||
|
||||
### 启动方式
|
||||
|
||||
#### 方式一:直接启动(推荐用于宝塔面板)
|
||||
```bash
|
||||
npm run start
|
||||
# 或
|
||||
npm run start:bt
|
||||
```
|
||||
|
||||
#### 方式二:PM2 进程管理
|
||||
```bash
|
||||
# 安装 PM2(如果未安装)
|
||||
npm install -g pm2
|
||||
|
||||
# 启动应用
|
||||
npm run pm2:start
|
||||
|
||||
# 停止应用
|
||||
npm run pm2:stop
|
||||
|
||||
# 重启应用
|
||||
npm run pm2:restart
|
||||
```
|
||||
|
||||
## 宝塔面板配置步骤
|
||||
|
||||
1. **创建 Node.js 项目**
|
||||
- 在宝塔面板中选择「Node.js项目」
|
||||
- 项目路径:`/www/wwwroot/ggl`
|
||||
- 端口:4001(或4001-4010范围内任意端口)
|
||||
|
||||
2. **安装依赖**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **构建前端**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
4. **启动项目**
|
||||
- 启动文件:`start-production.cjs`
|
||||
- 或使用启动命令:`npm run start`
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
在宝塔面板的项目设置中添加以下环境变量:
|
||||
|
||||
```
|
||||
NODE_ENV=production
|
||||
PORT=4001
|
||||
JWT_SECRET=your-production-jwt-secret-key-here
|
||||
```
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
/www/wwwroot/ggl/
|
||||
├── api/ # 后端 API
|
||||
├── src/ # 前端源码
|
||||
├── dist/ # 前端构建产物
|
||||
├── uploads/ # 文件上传目录
|
||||
├── logs/ # 日志目录
|
||||
├── database.db # SQLite 数据库
|
||||
├── start-production.cjs # 生产环境启动文件
|
||||
├── ecosystem.config.js # PM2 配置文件
|
||||
└── .env.production # 生产环境配置
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保宝塔面板已安装 Node.js 环境(推荐 v18+)
|
||||
2. 确保端口 4001 未被其他服务占用
|
||||
3. 数据库文件 `database.db` 需要有读写权限
|
||||
4. `uploads` 目录需要有写入权限
|
||||
5. 如需修改端口,请同时修改以下文件:
|
||||
- `api/server.ts`
|
||||
- `ecosystem.config.js`
|
||||
- `.env.production`
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 端口冲突
|
||||
如果端口 4001 被占用,可以修改为 4002-4010 范围内的其他端口。
|
||||
|
||||
### 权限问题
|
||||
确保项目目录及子目录有正确的读写权限:
|
||||
```bash
|
||||
chmod -R 755 /www/wwwroot/ggl
|
||||
chown -R www:www /www/wwwroot/ggl
|
||||
```
|
||||
|
||||
### 日志查看
|
||||
- 应用日志:`logs/` 目录
|
||||
- PM2 日志:`pm2 logs ggl-app`
|
||||
- 宝塔面板日志:在项目管理页面查看
|
||||
5
README.md
Executable file
5
README.md
Executable file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
82
api/app.ts
Executable file
82
api/app.ts
Executable file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* This is a API server
|
||||
*/
|
||||
|
||||
import express, { type Request, type Response, type NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import videoRoutes from './routes/videos.js';
|
||||
import statsRoutes from './routes/stats.js';
|
||||
import uploadRoutes from './routes/upload.js';
|
||||
import topicsRoutes from './routes/topics.js';
|
||||
import categoriesRoutes from './routes/categories.js';
|
||||
import { initDatabase } from './config/database.js';
|
||||
|
||||
// for esm mode
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// load env
|
||||
dotenv.config();
|
||||
|
||||
|
||||
const app: express.Application = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
/**
|
||||
* Initialize Database
|
||||
*/
|
||||
initDatabase().catch(console.error);
|
||||
|
||||
/**
|
||||
* API Routes
|
||||
*/
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/videos', videoRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
app.use('/api/topics', topicsRoutes);
|
||||
app.use('/api/categories', categoriesRoutes);
|
||||
|
||||
/**
|
||||
* Static file serving for uploads
|
||||
*/
|
||||
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
||||
|
||||
/**
|
||||
* health
|
||||
*/
|
||||
app.use('/api/health', (req: Request, res: Response, next: NextFunction): void => {
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'ok'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* error handler middleware
|
||||
*/
|
||||
app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Server internal error'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 404 handler
|
||||
*/
|
||||
app.use((req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'API not found'
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
149
api/config/database.ts
Executable file
149
api/config/database.ts
Executable file
@@ -0,0 +1,149 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open, Database } from 'sqlite';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// SQLite数据库配置
|
||||
const DB_PATH = path.join(process.cwd(), 'database', 'goguryeo_video.db');
|
||||
const INIT_SQL_PATH = path.join(process.cwd(), 'database', 'init.sql');
|
||||
|
||||
let db: Database | null = null;
|
||||
|
||||
/**
|
||||
* 初始化数据库连接
|
||||
*/
|
||||
export async function initDatabase(): Promise<Database> {
|
||||
try {
|
||||
// 确保数据库目录存在
|
||||
const dbDir = path.dirname(DB_PATH);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 打开数据库连接
|
||||
db = await open({
|
||||
filename: DB_PATH,
|
||||
driver: sqlite3.Database
|
||||
});
|
||||
|
||||
console.log('SQLite数据库连接成功');
|
||||
|
||||
// 检查是否需要初始化数据库
|
||||
await initializeTables();
|
||||
|
||||
return db;
|
||||
} catch (error) {
|
||||
console.error('数据库连接失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据库表结构
|
||||
*/
|
||||
async function initializeTables(): Promise<void> {
|
||||
if (!db) {
|
||||
throw new Error('数据库连接未初始化');
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查用户表是否存在
|
||||
const userTableExists = await db.get(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
);
|
||||
|
||||
if (!userTableExists) {
|
||||
console.log('初始化数据库表结构...');
|
||||
|
||||
// 读取并执行初始化SQL脚本
|
||||
if (fs.existsSync(INIT_SQL_PATH)) {
|
||||
const initSql = fs.readFileSync(INIT_SQL_PATH, 'utf8');
|
||||
|
||||
// 清理SQL内容,移除注释和空行
|
||||
const cleanSql = initSql
|
||||
.split('\n')
|
||||
.filter(line => !line.trim().startsWith('--') && line.trim().length > 0)
|
||||
.join('\n');
|
||||
|
||||
// 分割SQL语句并执行
|
||||
const statements = cleanSql
|
||||
.split(';')
|
||||
.map(stmt => stmt.trim())
|
||||
.filter(stmt => stmt.length > 0);
|
||||
|
||||
for (const statement of statements) {
|
||||
if (statement.trim()) {
|
||||
try {
|
||||
await db.exec(statement);
|
||||
console.log('执行SQL成功:', statement.substring(0, 50) + '...');
|
||||
} catch (error) {
|
||||
console.error('执行SQL失败:', statement.substring(0, 50) + '...', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('数据库表结构初始化完成');
|
||||
} else {
|
||||
console.warn('初始化SQL文件不存在:', INIT_SQL_PATH);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化数据库表结构失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库连接
|
||||
*/
|
||||
export function getDatabase(): Database {
|
||||
if (!db) {
|
||||
throw new Error('数据库连接未初始化,请先调用 initDatabase()');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭数据库连接
|
||||
*/
|
||||
export async function closeDatabase(): Promise<void> {
|
||||
if (db) {
|
||||
await db.close();
|
||||
db = null;
|
||||
console.log('数据库连接已关闭');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行查询
|
||||
*/
|
||||
export async function query(sql: string, params: any[] = []): Promise<any[]> {
|
||||
const database = getDatabase();
|
||||
return await database.all(sql, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单条查询
|
||||
*/
|
||||
export async function queryOne(sql: string, params: any[] = []): Promise<any> {
|
||||
const database = getDatabase();
|
||||
return await database.get(sql, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行插入/更新/删除操作
|
||||
*/
|
||||
export async function execute(sql: string, params: any[] = []): Promise<any> {
|
||||
const database = getDatabase();
|
||||
return await database.run(sql, params);
|
||||
}
|
||||
|
||||
export default {
|
||||
initDatabase,
|
||||
getDatabase,
|
||||
closeDatabase,
|
||||
query,
|
||||
queryOne,
|
||||
execute
|
||||
};
|
||||
0
api/database.db
Executable file
0
api/database.db
Executable file
0
api/database.sqlite
Executable file
0
api/database.sqlite
Executable file
9
api/index.ts
Executable file
9
api/index.ts
Executable file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Vercel deploy entry handler, for serverless deployment, please don't modify this file
|
||||
*/
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
||||
import app from './app.js';
|
||||
|
||||
export default function handler(req: VercelRequest, res: VercelResponse) {
|
||||
return app(req, res);
|
||||
}
|
||||
91
api/middleware/auth.ts
Executable file
91
api/middleware/auth.ts
Executable file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* JWT认证中间件
|
||||
*/
|
||||
import { type Request, type Response, type NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
// JWT密钥
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
|
||||
|
||||
// 扩展Request接口以包含用户信息
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT认证中间件
|
||||
*/
|
||||
export function authenticateToken(req: Request, res: Response, next: NextFunction): void {
|
||||
console.log('认证中间件开始,请求路径:', req.path);
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
console.log('认证头:', authHeader ? '存在' : '不存在');
|
||||
|
||||
if (!token) {
|
||||
console.log('错误: 访问令牌缺失');
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '访问令牌缺失'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('开始验证JWT token');
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||
req.user = {
|
||||
id: decoded.id,
|
||||
username: decoded.username,
|
||||
email: decoded.email,
|
||||
role: decoded.role
|
||||
};
|
||||
console.log('JWT验证成功,用户信息:', req.user);
|
||||
console.log('认证中间件完成,调用next()');
|
||||
next();
|
||||
} catch (error) {
|
||||
console.log('JWT验证失败:', error);
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: '访问令牌无效或已过期'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选认证中间件(不强制要求token)
|
||||
*/
|
||||
export function optionalAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||
req.user = {
|
||||
id: decoded.id,
|
||||
username: decoded.username,
|
||||
email: decoded.email,
|
||||
role: decoded.role
|
||||
};
|
||||
} catch (error) {
|
||||
// 忽略token验证错误,继续处理请求
|
||||
console.warn('可选认证token验证失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export default {
|
||||
authenticateToken,
|
||||
optionalAuth
|
||||
};
|
||||
249
api/routes/auth.ts
Executable file
249
api/routes/auth.ts
Executable file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* This is a user authentication API route.
|
||||
* Handle user registration, login, token management, etc.
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { query, queryOne, execute } from '../config/database.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// JWT密钥
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
|
||||
|
||||
/**
|
||||
* User Registration
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
router.post('/register', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
// 验证输入
|
||||
if (!username || !email || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '用户名、邮箱和密码都是必填项'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
const existingUser = await queryOne(
|
||||
'SELECT id FROM users WHERE username = ? OR email = ?',
|
||||
[username, email]
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '用户名或邮箱已存在'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// 插入新用户
|
||||
const result = await execute(
|
||||
'INSERT INTO users (username, email, password, created_at) VALUES (?, ?, ?, datetime("now"))',
|
||||
[username, email, hashedPassword]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '用户注册成功',
|
||||
data: {
|
||||
id: result.lastID,
|
||||
username,
|
||||
email
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* User Login
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
router.post('/login', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// 验证输入
|
||||
if (!username || !password) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '用户名和密码都是必填项'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const user = await queryOne(
|
||||
'SELECT id, username, email, password, role FROM users WHERE username = ? OR email = ?',
|
||||
[username, username]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '用户名或密码错误'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isValidPassword = await bcrypt.compare(password, user.password);
|
||||
if (!isValidPassword) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '用户名或密码错误'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '登录成功',
|
||||
data: {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Token Refresh
|
||||
* POST /api/auth/refresh
|
||||
*/
|
||||
router.post('/refresh', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '访问令牌缺失'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证当前token(即使过期也要能解析出用户信息)
|
||||
let decoded: any;
|
||||
try {
|
||||
decoded = jwt.verify(token, JWT_SECRET);
|
||||
} catch (error: any) {
|
||||
// 如果是过期错误,尝试解析过期的token
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
decoded = jwt.decode(token);
|
||||
if (!decoded) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '无效的访问令牌'
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '无效的访问令牌'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证用户是否仍然存在
|
||||
const user = await queryOne(
|
||||
'SELECT id, username, email, role FROM users WHERE id = ?',
|
||||
[decoded.id]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '用户不存在'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成新的JWT token
|
||||
const newToken = jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Token刷新成功',
|
||||
data: {
|
||||
token: newToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token刷新失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* User Logout
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
router.post('/logout', async (req: Request, res: Response): Promise<void> => {
|
||||
// 由于使用JWT,logout主要在前端处理(删除token)
|
||||
res.json({
|
||||
success: true,
|
||||
message: '退出登录成功'
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
431
api/routes/categories.ts
Normal file
431
api/routes/categories.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 分类管理API路由
|
||||
* Handle category CRUD operations
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { query, queryOne, execute } from '../config/database.js';
|
||||
import { authenticateToken, optionalAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* 获取所有分类列表
|
||||
* GET /api/categories
|
||||
*/
|
||||
router.get('/', optionalAuth, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const categories = await query(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.description,
|
||||
c.created_at as createdAt,
|
||||
c.sort_order,
|
||||
COUNT(v.id) as videoCount
|
||||
FROM categories c
|
||||
LEFT JOIN videos v ON c.id = v.category
|
||||
GROUP BY c.id, c.name, c.description, c.created_at, c.sort_order
|
||||
ORDER BY c.sort_order ASC, c.created_at DESC
|
||||
`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '获取分类列表成功',
|
||||
data: categories
|
||||
});
|
||||
} catch (error) {
|
||||
// 获取分类列表失败
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取分类列表失败',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取分类详情
|
||||
* GET /api/categories/:id
|
||||
*/
|
||||
router.get('/:id', optionalAuth, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const category = await queryOne(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.description,
|
||||
c.created_at as createdAt,
|
||||
COUNT(v.id) as videoCount
|
||||
FROM categories c
|
||||
LEFT JOIN videos v ON c.id = v.category
|
||||
WHERE c.id = ?
|
||||
GROUP BY c.id, c.name, c.description, c.created_at
|
||||
`, [id]);
|
||||
|
||||
if (!category) {
|
||||
res.status(404).json({
|
||||
code: 404,
|
||||
message: '分类不存在',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '获取分类详情成功',
|
||||
data: category
|
||||
});
|
||||
} catch (error) {
|
||||
// 获取分类详情失败
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取分类详情失败',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建分类
|
||||
* POST /api/categories
|
||||
*/
|
||||
router.post('/', authenticateToken, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name, description = '' } = req.body;
|
||||
|
||||
if (!name || name.trim() === '') {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '分类名称不能为空',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查分类名称是否已存在
|
||||
const existingCategory = await queryOne(
|
||||
'SELECT id FROM categories WHERE name = ?',
|
||||
[name.trim()]
|
||||
);
|
||||
|
||||
if (existingCategory) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '分类名称已存在',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建分类
|
||||
const result = await execute(
|
||||
'INSERT INTO categories (name, description, created_at) VALUES (?, ?, datetime("now"))',
|
||||
[name.trim(), description.trim()]
|
||||
);
|
||||
|
||||
// 获取创建的分类信息
|
||||
const newCategory = await queryOne(
|
||||
'SELECT id, name, description, created_at as createdAt FROM categories WHERE id = ?',
|
||||
[result.lastID]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
code: 201,
|
||||
message: '创建分类成功',
|
||||
data: newCategory
|
||||
});
|
||||
} catch (error) {
|
||||
// 创建分类失败
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '创建分类失败',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新分类
|
||||
* PUT /api/categories/:id
|
||||
*/
|
||||
router.put('/:id', authenticateToken, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description = '' } = req.body;
|
||||
|
||||
if (!name || name.trim() === '') {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '分类名称不能为空',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查分类是否存在
|
||||
const existingCategory = await queryOne(
|
||||
'SELECT id FROM categories WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!existingCategory) {
|
||||
res.status(404).json({
|
||||
code: 404,
|
||||
message: '分类不存在',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查分类名称是否已被其他分类使用
|
||||
const duplicateCategory = await queryOne(
|
||||
'SELECT id FROM categories WHERE name = ? AND id != ?',
|
||||
[name.trim(), id]
|
||||
);
|
||||
|
||||
if (duplicateCategory) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '分类名称已存在',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新分类
|
||||
await execute(
|
||||
'UPDATE categories SET name = ?, description = ? WHERE id = ?',
|
||||
[name.trim(), description.trim(), id]
|
||||
);
|
||||
|
||||
// 获取更新后的分类信息
|
||||
const updatedCategory = await queryOne(
|
||||
'SELECT id, name, description, created_at as createdAt FROM categories WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '更新分类成功',
|
||||
data: updatedCategory
|
||||
});
|
||||
} catch (error) {
|
||||
// 更新分类失败
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '更新分类失败',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 删除分类
|
||||
* DELETE /api/categories/:id
|
||||
*/
|
||||
router.delete('/:id', authenticateToken, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// 检查分类是否存在
|
||||
const existingCategory = await queryOne(
|
||||
'SELECT id FROM categories WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!existingCategory) {
|
||||
res.status(404).json({
|
||||
code: 404,
|
||||
message: '分类不存在',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有视频使用该分类
|
||||
const videosUsingCategory = await queryOne(
|
||||
'SELECT COUNT(*) as count FROM videos WHERE category = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (videosUsingCategory.count > 0) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: `无法删除分类,还有 ${videosUsingCategory.count} 个视频使用该分类`,
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 删除分类
|
||||
await execute('DELETE FROM categories WHERE id = ?', [id]);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '删除分类成功',
|
||||
data: null
|
||||
});
|
||||
} catch (error) {
|
||||
// 删除分类失败
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '删除分类失败',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 批量删除分类
|
||||
* DELETE /api/categories/batch
|
||||
*/
|
||||
router.delete('/batch', authenticateToken, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { categoryIds } = req.body;
|
||||
|
||||
if (!Array.isArray(categoryIds) || categoryIds.length === 0) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '请选择要删除的分类',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有视频使用这些分类
|
||||
const videosUsingCategories = await query(
|
||||
`SELECT category, COUNT(*) as count FROM videos WHERE category IN (${categoryIds.map(() => '?').join(',')}) GROUP BY category`,
|
||||
categoryIds
|
||||
);
|
||||
|
||||
if (videosUsingCategories.length > 0) {
|
||||
const categoryNames = await query(
|
||||
`SELECT id, name FROM categories WHERE id IN (${categoryIds.map(() => '?').join(',')})`,
|
||||
categoryIds
|
||||
);
|
||||
|
||||
const usedCategories = videosUsingCategories.map(vc => {
|
||||
const category = categoryNames.find(cn => cn.id === vc.category);
|
||||
return `${category?.name || '未知分类'}(${vc.count}个视频)`;
|
||||
});
|
||||
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: `无法删除分类,以下分类还有视频在使用:${usedCategories.join(', ')}`,
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量删除分类
|
||||
await execute(
|
||||
`DELETE FROM categories WHERE id IN (${categoryIds.map(() => '?').join(',')})`,
|
||||
categoryIds
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: `成功删除 ${categoryIds.length} 个分类`,
|
||||
data: null
|
||||
});
|
||||
} catch (error) {
|
||||
// 批量删除分类失败
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '批量删除分类失败',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新分类顺序
|
||||
* PUT /api/categories/:id/order
|
||||
*/
|
||||
router.put('/:id/order', authenticateToken, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const categoryId = parseInt(req.params.id);
|
||||
const { direction } = req.body; // 'up' 或 'down'
|
||||
|
||||
if (isNaN(categoryId)) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '分类ID必须是有效的数字',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!direction || !['up', 'down'].includes(direction)) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '方向参数必须是 up 或 down',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前分类信息
|
||||
const currentCategory = await queryOne('SELECT * FROM categories WHERE id = ?', [categoryId]);
|
||||
if (!currentCategory) {
|
||||
res.status(404).json({
|
||||
code: 404,
|
||||
message: '分类不存在',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取需要交换的分类
|
||||
let targetCategory;
|
||||
if (direction === 'up') {
|
||||
// 获取排序值小于当前分类且最接近的分类
|
||||
targetCategory = await queryOne(`
|
||||
SELECT * FROM categories
|
||||
WHERE sort_order < ?
|
||||
ORDER BY sort_order DESC
|
||||
LIMIT 1
|
||||
`, [currentCategory.sort_order]);
|
||||
} else {
|
||||
// 获取排序值大于当前分类且最接近的分类
|
||||
targetCategory = await queryOne(`
|
||||
SELECT * FROM categories
|
||||
WHERE sort_order > ?
|
||||
ORDER BY sort_order ASC
|
||||
LIMIT 1
|
||||
`, [currentCategory.sort_order]);
|
||||
}
|
||||
|
||||
if (!targetCategory) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: direction === 'up' ? '已经是第一个分类' : '已经是最后一个分类',
|
||||
data: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 交换两个分类的sort_order值
|
||||
const tempOrder = currentCategory.sort_order;
|
||||
await execute('UPDATE categories SET sort_order = ? WHERE id = ?', [targetCategory.sort_order, categoryId]);
|
||||
await execute('UPDATE categories SET sort_order = ? WHERE id = ?', [tempOrder, targetCategory.id]);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '分类顺序更新成功',
|
||||
data: null
|
||||
});
|
||||
} catch (error) {
|
||||
// 更新分类顺序失败
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '更新分类顺序失败',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
export default router;
|
||||
196
api/routes/stats.ts
Executable file
196
api/routes/stats.ts
Executable file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Stats API routes
|
||||
*/
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import { getDatabase } from '../config/database.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Get overall statistics
|
||||
*/
|
||||
router.get('/overall', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
// Get total videos count
|
||||
const videosResult = await db.get('SELECT COUNT(*) as total FROM videos');
|
||||
const totalVideos = videosResult?.total || 0;
|
||||
|
||||
// Get total users count
|
||||
const usersResult = await db.get('SELECT COUNT(*) as total FROM users');
|
||||
const totalUsers = usersResult?.total || 0;
|
||||
|
||||
// Get total views (sum of all video views)
|
||||
const viewsResult = await db.get('SELECT SUM(views) as total FROM videos');
|
||||
const totalViews = viewsResult?.total || 0;
|
||||
|
||||
// Get videos uploaded today
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const todayResult = await db.get(
|
||||
'SELECT COUNT(*) as total FROM videos WHERE created_at >= ?',
|
||||
[todayStart.toISOString()]
|
||||
);
|
||||
const todayVideos = todayResult?.total || 0;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalVideos,
|
||||
totalUsers,
|
||||
totalViews,
|
||||
todayVideos
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取统计数据失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get video statistics by category
|
||||
*/
|
||||
router.get('/videos/category', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
const result = await db.all(`
|
||||
SELECT
|
||||
category,
|
||||
COUNT(*) as count,
|
||||
SUM(views) as totalViews
|
||||
FROM videos
|
||||
GROUP BY category
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取分类统计失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取分类统计失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get recent activity stats
|
||||
*/
|
||||
router.get('/activity', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
|
||||
// Get videos uploaded in last 7 days
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
const result = await db.all(`
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as count
|
||||
FROM videos
|
||||
WHERE created_at >= ?
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC
|
||||
`, [weekAgo.toISOString()]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取活动统计失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取活动统计失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get latest videos
|
||||
*/
|
||||
router.get('/latest', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
|
||||
const result = await db.all(`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
cover_url,
|
||||
video_url,
|
||||
duration,
|
||||
views,
|
||||
likes,
|
||||
category,
|
||||
status,
|
||||
user_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM videos
|
||||
WHERE status = 'published'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`, [limit]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取最新视频失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取最新视频失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get recent users
|
||||
*/
|
||||
router.get('/recent-users', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
|
||||
const result = await db.all(`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
avatar,
|
||||
role,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`, [limit]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取最近用户失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: '获取最近用户失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
356
api/routes/topics.ts
Executable file
356
api/routes/topics.ts
Executable file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 专题管理API路由
|
||||
* Handle topic CRUD operations
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { query, queryOne, execute } from '../config/database.js';
|
||||
import { authenticateToken, optionalAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* 获取所有专题列表
|
||||
* GET /api/topics
|
||||
*/
|
||||
router.get('/', optionalAuth, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const topics = await query(`
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.description,
|
||||
t.cover_image,
|
||||
t.sort_order,
|
||||
t.status,
|
||||
t.created_at,
|
||||
COUNT(vt.video_id) as video_count
|
||||
FROM topics t
|
||||
LEFT JOIN video_topics vt ON t.id = vt.topic_id
|
||||
WHERE t.status = 'active'
|
||||
GROUP BY t.id, t.name, t.description, t.cover_image, t.sort_order, t.status, t.created_at
|
||||
ORDER BY t.sort_order ASC, t.created_at DESC
|
||||
`);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: topics
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取专题列表失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取专题详情及其视频列表
|
||||
* GET /api/topics/:id
|
||||
*/
|
||||
router.get('/:id', optionalAuth, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const topicId = parseInt(req.params.id);
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 12;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
if (isNaN(topicId)) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '无效的专题ID'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取专题信息
|
||||
const topic = await queryOne(`
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.description,
|
||||
t.cover_image,
|
||||
t.sort_order,
|
||||
t.status,
|
||||
t.created_at,
|
||||
COUNT(vt.video_id) as video_count
|
||||
FROM topics t
|
||||
LEFT JOIN video_topics vt ON t.id = vt.topic_id
|
||||
WHERE t.id = ? AND t.status = 'active'
|
||||
GROUP BY t.id, t.name, t.description, t.cover_image, t.sort_order, t.status, t.created_at
|
||||
`, [topicId]);
|
||||
|
||||
if (!topic) {
|
||||
res.status(404).json({
|
||||
code: 404,
|
||||
message: '专题不存在'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取专题下的视频列表
|
||||
const videos = await query(`
|
||||
SELECT
|
||||
v.id,
|
||||
v.title,
|
||||
v.description,
|
||||
v.file_path,
|
||||
v.video_url,
|
||||
v.cover_image,
|
||||
v.duration,
|
||||
v.file_size,
|
||||
v.created_at,
|
||||
u.username as uploader,
|
||||
COALESCE(vs.views, 0) as views,
|
||||
COALESCE(vs.likes, 0) as likes
|
||||
FROM videos v
|
||||
INNER JOIN video_topics vt ON v.id = vt.video_id
|
||||
LEFT JOIN users u ON v.user_id = u.id
|
||||
LEFT JOIN video_stats vs ON v.id = vs.video_id
|
||||
WHERE vt.topic_id = ? AND v.status = 'active'
|
||||
ORDER BY v.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, [topicId, limit, offset]);
|
||||
|
||||
// 获取总数
|
||||
const totalResult = await queryOne(`
|
||||
SELECT COUNT(*) as total
|
||||
FROM videos v
|
||||
INNER JOIN video_topics vt ON v.id = vt.video_id
|
||||
WHERE vt.topic_id = ? AND v.status = 'active'
|
||||
`, [topicId]);
|
||||
|
||||
const total = totalResult.total;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
topic,
|
||||
videos: {
|
||||
list: videos,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取专题详情失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建新专题(管理员功能)
|
||||
* POST /api/topics
|
||||
*/
|
||||
router.post('/', authenticateToken, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name, description, cover_image, sort_order } = req.body;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '专题名称不能为空'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查专题名称是否已存在
|
||||
const existingTopic = await queryOne('SELECT id FROM topics WHERE name = ?', [name]);
|
||||
if (existingTopic) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '专题名称已存在'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await execute(`
|
||||
INSERT INTO topics (name, description, cover_image, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
`, [name, description || '', cover_image || '', sort_order || 0]);
|
||||
|
||||
res.status(201).json({
|
||||
code: 201,
|
||||
message: '专题创建成功',
|
||||
data: {
|
||||
id: result.lastID,
|
||||
name,
|
||||
description,
|
||||
cover_image,
|
||||
sort_order
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建专题失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新专题信息(管理员功能)
|
||||
* PUT /api/topics/:id
|
||||
*/
|
||||
router.put('/:id', authenticateToken, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const topicId = parseInt(req.params.id);
|
||||
const { name, description, cover_image, sort_order, status } = req.body;
|
||||
|
||||
if (isNaN(topicId)) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '无效的专题ID'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '专题名称不能为空'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查专题是否存在
|
||||
const existingTopic = await queryOne('SELECT id FROM topics WHERE id = ?', [topicId]);
|
||||
if (!existingTopic) {
|
||||
res.status(404).json({
|
||||
code: 404,
|
||||
message: '专题不存在'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查名称是否与其他专题冲突
|
||||
const nameConflict = await queryOne('SELECT id FROM topics WHERE name = ? AND id != ?', [name, topicId]);
|
||||
if (nameConflict) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '专题名称已存在'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await execute(`
|
||||
UPDATE topics
|
||||
SET name = ?, description = ?, cover_image = ?, sort_order = ?, status = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, [name, description || '', cover_image || '', sort_order || 0, status || 'active', topicId]);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '专题更新成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新专题失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 删除专题(管理员功能)
|
||||
* DELETE /api/topics/:id
|
||||
*/
|
||||
router.delete('/:id', authenticateToken, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const topicId = parseInt(req.params.id);
|
||||
|
||||
if (isNaN(topicId)) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '无效的专题ID'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查专题是否存在
|
||||
const existingTopic = await queryOne('SELECT id FROM topics WHERE id = ?', [topicId]);
|
||||
if (!existingTopic) {
|
||||
res.status(404).json({
|
||||
code: 404,
|
||||
message: '专题不存在'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 软删除:将状态设置为 inactive
|
||||
await execute(`
|
||||
UPDATE topics
|
||||
SET status = 'inactive', updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`, [topicId]);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '专题删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除专题失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取视频的专题列表
|
||||
* GET /api/topics/video/:videoId
|
||||
*/
|
||||
router.get('/video/:videoId', optionalAuth, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const videoId = parseInt(req.params.videoId);
|
||||
|
||||
if (isNaN(videoId)) {
|
||||
res.status(400).json({
|
||||
code: 400,
|
||||
message: '无效的视频ID'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const topics = await query(`
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.description,
|
||||
t.cover_image,
|
||||
t.sort_order
|
||||
FROM topics t
|
||||
INNER JOIN video_topics vt ON t.id = vt.topic_id
|
||||
WHERE vt.video_id = ? AND t.status = 'active'
|
||||
ORDER BY t.sort_order ASC, t.name ASC
|
||||
`, [videoId]);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: topics
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取视频专题失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
262
api/routes/upload.ts
Executable file
262
api/routes/upload.ts
Executable file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 文件上传API路由
|
||||
* Handle file uploads for videos and cover images
|
||||
*/
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Multer错误处理中间件
|
||||
const handleMulterError = (error: any, req: Request, res: Response, next: any) => {
|
||||
console.error('Multer错误:', error);
|
||||
|
||||
if (error instanceof multer.MulterError) {
|
||||
switch (error.code) {
|
||||
case 'LIMIT_FILE_SIZE':
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件大小超出限制(最大100MB)'
|
||||
});
|
||||
case 'LIMIT_FILE_COUNT':
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '文件数量超出限制'
|
||||
});
|
||||
case 'LIMIT_UNEXPECTED_FILE':
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '意外的文件字段'
|
||||
});
|
||||
default:
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `文件上传错误: ${error.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (error.message) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '文件上传失败'
|
||||
});
|
||||
};
|
||||
|
||||
// 确保上传目录存在
|
||||
const ensureUploadDir = (dir: string) => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
// 配置multer用于视频文件上传
|
||||
const videoStorage = multer.memoryStorage();
|
||||
const videoUpload = multer({
|
||||
storage: videoStorage,
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024 // 100MB限制
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
console.log('视频文件过滤器检查,文件类型:', file.mimetype, '文件名:', file.originalname);
|
||||
|
||||
// 获取文件扩展名
|
||||
const fileExtension = path.extname(file.originalname).toLowerCase();
|
||||
const mimeType = file.mimetype ? file.mimetype.toLowerCase() : '';
|
||||
|
||||
// 支持的MIME类型(与前端保持一致)
|
||||
const allowedTypes = [
|
||||
// 标准视频MIME类型
|
||||
'video/mp4', 'video/mpeg', 'video/quicktime', 'video/x-msvideo',
|
||||
'video/x-ms-wmv', 'video/x-flv', 'video/webm', 'video/ogg',
|
||||
'video/3gpp', 'video/3gpp2', 'video/x-matroska', 'video/mp2t',
|
||||
'video/x-ms-asf', 'video/x-m4v', 'video/divx', 'video/xvid',
|
||||
// 应用程序类型(某些视频格式)
|
||||
'application/octet-stream', 'application/x-msvideo',
|
||||
'application/vnd.rn-realmedia', 'application/x-shockwave-flash'
|
||||
];
|
||||
|
||||
// 支持的文件扩展名
|
||||
const allowedExtensions = [
|
||||
'.mp4', '.webm', '.ogg', '.avi', '.mov', '.3gp', '.3g2',
|
||||
'.wmv', '.flv', '.mkv', '.m4v', '.ts', '.asf', '.mpg',
|
||||
'.mpeg', '.m2v', '.rmvb', '.rm', '.divx', '.xvid',
|
||||
'.f4v', '.m2ts', '.mts', '.vob', '.dat', '.amv'
|
||||
];
|
||||
|
||||
// 验证逻辑:
|
||||
// 1. 扩展名匹配(优先级最高)
|
||||
// 2. MIME类型完全匹配
|
||||
// 3. MIME类型以video/开头且扩展名正确
|
||||
// 4. MIME类型为空但扩展名正确
|
||||
const isExtensionValid = allowedExtensions.includes(fileExtension);
|
||||
const isMimeTypeValid = allowedTypes.includes(mimeType);
|
||||
const isVideoMimeType = mimeType.startsWith('video/');
|
||||
|
||||
const isValid = isExtensionValid || isMimeTypeValid || (isVideoMimeType && isExtensionValid) || (!mimeType && isExtensionValid);
|
||||
|
||||
console.log('文件验证结果:', {
|
||||
fileExtension,
|
||||
mimeType,
|
||||
isExtensionValid,
|
||||
isMimeTypeValid,
|
||||
isVideoMimeType,
|
||||
isValid
|
||||
});
|
||||
|
||||
if (isValid) {
|
||||
console.log('视频文件类型检查通过');
|
||||
cb(null, true);
|
||||
} else {
|
||||
console.log('视频文件类型检查失败,不支持的格式:', mimeType, '扩展名:', fileExtension);
|
||||
cb(new Error(`不支持的视频格式。文件类型: ${mimeType}, 扩展名: ${fileExtension}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 配置multer用于封面图片上传
|
||||
const coverStorage = multer.memoryStorage();
|
||||
const coverUpload = multer({
|
||||
storage: coverStorage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB限制
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
console.log('封面图片过滤器检查,文件类型:', file.mimetype);
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
console.log('封面图片类型检查通过');
|
||||
cb(null, true);
|
||||
} else {
|
||||
console.log('封面图片类型检查失败,不支持的格式:', file.mimetype);
|
||||
cb(new Error('不支持的图片格式'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 上传视频文件
|
||||
* POST /api/upload/video
|
||||
*/
|
||||
router.post('/video', authenticateToken, videoUpload.single('video'), handleMulterError, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
console.log('视频文件上传请求开始');
|
||||
const file = req.file;
|
||||
|
||||
if (!file) {
|
||||
console.log('错误: 没有视频文件');
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '请选择要上传的视频文件'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成文件名
|
||||
const timestamp = Date.now();
|
||||
const originalName = file.originalname;
|
||||
const ext = path.extname(originalName);
|
||||
const filename = `video_${timestamp}_${Math.random().toString(36).substring(2)}${ext}`;
|
||||
|
||||
// 确保上传目录存在
|
||||
const uploadDir = path.join(process.cwd(), 'uploads', 'videos');
|
||||
ensureUploadDir(uploadDir);
|
||||
|
||||
// 保存文件
|
||||
const filePath = path.join(uploadDir, filename);
|
||||
const relativePath = path.join('uploads', 'videos', filename).replace(/\\/g, '/');
|
||||
|
||||
console.log('开始保存视频文件到:', filePath);
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
console.log('视频文件保存成功');
|
||||
|
||||
// 返回文件URL
|
||||
const fileUrl = `/${relativePath}`;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: '视频文件上传成功',
|
||||
data: {
|
||||
url: fileUrl,
|
||||
filename: filename,
|
||||
size: file.size,
|
||||
mimetype: file.mimetype
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('视频文件上传失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '视频文件上传失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 上传封面图片
|
||||
* POST /api/upload/cover
|
||||
*/
|
||||
router.post('/cover', authenticateToken, coverUpload.single('cover'), handleMulterError, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
console.log('封面图片上传请求开始');
|
||||
const file = req.file;
|
||||
|
||||
if (!file) {
|
||||
console.log('错误: 没有封面图片文件');
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '请选择要上传的封面图片'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成文件名
|
||||
const timestamp = Date.now();
|
||||
const originalName = file.originalname;
|
||||
const ext = path.extname(originalName);
|
||||
const filename = `cover_${timestamp}_${Math.random().toString(36).substring(2)}${ext}`;
|
||||
|
||||
// 确保上传目录存在
|
||||
const uploadDir = path.join(process.cwd(), 'uploads', 'covers');
|
||||
ensureUploadDir(uploadDir);
|
||||
|
||||
// 保存文件
|
||||
const filePath = path.join(uploadDir, filename);
|
||||
const relativePath = path.join('uploads', 'covers', filename).replace(/\\/g, '/');
|
||||
|
||||
console.log('开始保存封面图片到:', filePath);
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
console.log('封面图片保存成功');
|
||||
|
||||
// 返回文件URL
|
||||
const fileUrl = `/${relativePath}`;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: '封面图片上传成功',
|
||||
data: {
|
||||
url: fileUrl,
|
||||
filename: filename,
|
||||
size: file.size,
|
||||
mimetype: file.mimetype
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('封面图片上传失败:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '封面图片上传失败'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
1538
api/routes/videos.ts
Executable file
1538
api/routes/videos.ts
Executable file
File diff suppressed because it is too large
Load Diff
34
api/server.ts
Executable file
34
api/server.ts
Executable file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* local server entry file, for local development
|
||||
*/
|
||||
import app from './app.js';
|
||||
|
||||
/**
|
||||
* start server with port
|
||||
*/
|
||||
const PORT = process.env.PORT || 4001;
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`Server ready on port ${PORT}`);
|
||||
});
|
||||
|
||||
/**
|
||||
* close server
|
||||
*/
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SIGTERM signal received');
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SIGINT signal received');
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
7
check.sh
Executable file
7
check.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
# 独立的TypeScript检查脚本,完全避免npm警告
|
||||
# 直接调用node_modules中的vue-tsc
|
||||
|
||||
echo "正在进行TypeScript类型检查..."
|
||||
./node_modules/.bin/vue-tsc --noEmit
|
||||
echo "TypeScript检查完成"
|
||||
0
database.db
Executable file
0
database.db
Executable file
0
database.sqlite
Executable file
0
database.sqlite
Executable file
0
database/database.db
Normal file
0
database/database.db
Normal file
BIN
database/goguryeo_video.db
Executable file
BIN
database/goguryeo_video.db
Executable file
Binary file not shown.
BIN
database/goguryeo_video.db.backup
Executable file
BIN
database/goguryeo_video.db.backup
Executable file
Binary file not shown.
128
database/init.sql
Executable file
128
database/init.sql
Executable file
@@ -0,0 +1,128 @@
|
||||
-- 高句丽视频平台数据库初始化脚本
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(100),
|
||||
avatar VARCHAR(255),
|
||||
role VARCHAR(20) DEFAULT 'user',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 视频表
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
video_url VARCHAR(500) NOT NULL,
|
||||
file_path VARCHAR(500),
|
||||
cover_url VARCHAR(500),
|
||||
cover_image VARCHAR(500),
|
||||
duration INTEGER DEFAULT 0,
|
||||
file_size INTEGER DEFAULT 0,
|
||||
views INTEGER DEFAULT 0,
|
||||
likes INTEGER DEFAULT 0,
|
||||
category INTEGER,
|
||||
tags TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
user_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (category) REFERENCES categories(id)
|
||||
);
|
||||
|
||||
-- 视频分类表
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 视频标签表
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 视频标签关联表
|
||||
CREATE TABLE IF NOT EXISTS video_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
|
||||
UNIQUE(video_id, tag_id)
|
||||
);
|
||||
|
||||
-- 用户点赞表
|
||||
CREATE TABLE IF NOT EXISTS user_likes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
video_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE,
|
||||
UNIQUE(user_id, video_id)
|
||||
);
|
||||
|
||||
-- 播放历史表
|
||||
CREATE TABLE IF NOT EXISTS play_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
video_id INTEGER NOT NULL,
|
||||
play_time INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 视频统计表
|
||||
CREATE TABLE IF NOT EXISTS video_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id INTEGER UNIQUE NOT NULL,
|
||||
views INTEGER DEFAULT 0,
|
||||
likes INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 插入默认管理员用户
|
||||
INSERT OR IGNORE INTO users (username, password, email, role)
|
||||
VALUES ('admin', '$2b$10$rQZ8kHWKtGOZvlKJ5mXzKOqGYvKqGYvKqGYvKqGYvKqGYvKqGYvKq', 'admin@goguryeo.com', 'admin');
|
||||
|
||||
-- 插入默认分类
|
||||
INSERT OR IGNORE INTO categories (name, description) VALUES
|
||||
('历史文化', '高句丽历史文化相关视频'),
|
||||
('传统艺术', '传统艺术表演和展示'),
|
||||
('语言学习', '高句丽语言学习教程'),
|
||||
('考古发现', '考古发现和文物介绍'),
|
||||
('民俗风情', '传统民俗和风土人情'),
|
||||
('其他', '其他相关内容');
|
||||
|
||||
-- 插入默认标签
|
||||
INSERT OR IGNORE INTO tags (name) VALUES
|
||||
('高句丽'),
|
||||
('历史'),
|
||||
('文化'),
|
||||
('传统'),
|
||||
('艺术'),
|
||||
('语言'),
|
||||
('考古'),
|
||||
('民俗'),
|
||||
('教育'),
|
||||
('纪录片');
|
||||
|
||||
-- 插入示例视频数据
|
||||
INSERT OR IGNORE INTO videos (title, description, video_url, file_path, cover_url, cover_image, duration, file_size, views, likes, category, user_id) VALUES
|
||||
('高句丽历史概述', '介绍高句丽王朝的兴起与发展历程', '/videos/sample1.mp4', '/videos/sample1.mp4', '/covers/sample1.jpg', '/covers/sample1.jpg', 1800, 52428800, 1250, 89, 1, 1),
|
||||
('传统舞蹈表演', '展示高句丽传统舞蹈的魅力', '/videos/sample2.mp4', '/videos/sample2.mp4', '/covers/sample2.jpg', '/covers/sample2.jpg', 900, 31457280, 856, 67, 2, 1),
|
||||
('古代建筑艺术', '探索高句丽古代建筑的独特风格', '/videos/sample3.mp4', '/videos/sample3.mp4', '/covers/sample3.jpg', '/covers/sample3.jpg', 1200, 41943040, 743, 45, 1, 1),
|
||||
('语言学习入门', '高句丽语言基础教程第一课', '/videos/sample4.mp4', '/videos/sample4.mp4', '/covers/sample4.jpg', '/covers/sample4.jpg', 1500, 52428800, 432, 23, 3, 1),
|
||||
('考古新发现', '最新的高句丽考古发现解读', '/videos/sample5.mp4', '/videos/sample5.mp4', '/covers/sample5.jpg', '/covers/sample5.jpg', 2100, 73400320, 321, 18, 4, 1);
|
||||
16
database/migrations/add_sort_order_to_categories.sql
Normal file
16
database/migrations/add_sort_order_to_categories.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 为categories表添加sort_order字段支持排序功能
|
||||
-- Migration: add_sort_order_to_categories
|
||||
|
||||
-- 添加sort_order字段
|
||||
ALTER TABLE categories ADD COLUMN sort_order INTEGER DEFAULT 0;
|
||||
|
||||
-- 为现有分类设置初始排序值(按创建时间排序)
|
||||
UPDATE categories
|
||||
SET sort_order = (
|
||||
SELECT COUNT(*)
|
||||
FROM categories c2
|
||||
WHERE c2.created_at <= categories.created_at
|
||||
) * 10;
|
||||
|
||||
-- 创建索引以提高排序查询性能
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_sort_order ON categories(sort_order);
|
||||
54
database/migrations/add_topics_tables.sql
Executable file
54
database/migrations/add_topics_tables.sql
Executable file
@@ -0,0 +1,54 @@
|
||||
-- 添加专题功能相关表
|
||||
-- 创建时间: 2024-01-20
|
||||
|
||||
-- 专题表
|
||||
CREATE TABLE IF NOT EXISTS topics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
cover_image VARCHAR(500),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 视频专题关联表(多对多关系)
|
||||
CREATE TABLE IF NOT EXISTS video_topics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id INTEGER NOT NULL,
|
||||
topic_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE,
|
||||
UNIQUE(video_id, topic_id)
|
||||
);
|
||||
|
||||
-- 创建索引提高查询性能
|
||||
CREATE INDEX IF NOT EXISTS idx_video_topics_video_id ON video_topics(video_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_video_topics_topic_id ON video_topics(topic_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_status ON topics(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_topics_sort_order ON topics(sort_order);
|
||||
|
||||
-- 插入默认专题
|
||||
INSERT OR IGNORE INTO topics (name, description, sort_order) VALUES
|
||||
('历史纪录片', '高句丽历史相关的纪录片和教育视频', 1),
|
||||
('文化艺术', '传统文化艺术表演和展示', 2),
|
||||
('考古发现', '考古发现和文物研究相关视频', 3),
|
||||
('语言教学', '高句丽语言学习和教学视频', 4),
|
||||
('民俗传统', '传统民俗风情和节庆活动', 5),
|
||||
('建筑遗迹', '古代建筑和遗迹介绍', 6),
|
||||
('专家讲座', '学者专家的学术讲座和分析', 7),
|
||||
('文物展示', '珍贵文物的展示和介绍', 8);
|
||||
|
||||
-- 为现有视频随机分配专题(示例数据)
|
||||
INSERT OR IGNORE INTO video_topics (video_id, topic_id)
|
||||
SELECT v.id, t.id
|
||||
FROM videos v
|
||||
CROSS JOIN topics t
|
||||
WHERE
|
||||
(v.id = 1 AND t.name IN ('历史纪录片', '专家讲座')) OR
|
||||
(v.id = 2 AND t.name IN ('文化艺术', '民俗传统')) OR
|
||||
(v.id = 3 AND t.name IN ('建筑遗迹', '历史纪录片')) OR
|
||||
(v.id = 4 AND t.name IN ('语言教学')) OR
|
||||
(v.id = 5 AND t.name IN ('考古发现', '文物展示'));
|
||||
0
database/videos.db
Normal file
0
database/videos.db
Normal file
36
ecosystem.config.js
Normal file
36
ecosystem.config.js
Normal file
@@ -0,0 +1,36 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'ggl-app',
|
||||
script: 'start-production.cjs',
|
||||
cwd: '/www/wwwroot/ggl',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 4001
|
||||
},
|
||||
env_production: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 4001
|
||||
},
|
||||
// 日志配置
|
||||
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,
|
||||
|
||||
// 其他配置
|
||||
merge_logs: true,
|
||||
time: true
|
||||
}]
|
||||
};
|
||||
BIN
goguryeo_video.db
Executable file
BIN
goguryeo_video.db
Executable file
Binary file not shown.
22
index.html
Executable file
22
index.html
Executable file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>梦回高句丽</title>
|
||||
<script type="module">
|
||||
if (import.meta.hot?.on) {
|
||||
import.meta.hot.on('vite:error', (error) => {
|
||||
if (error.err) {
|
||||
console.error([error.err.message, error.err.frame].filter(Boolean).join('\n'));
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
56
nginx-config-lnmp.conf
Normal file
56
nginx-config-lnmp.conf
Normal file
@@ -0,0 +1,56 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name 192.168.1.88;
|
||||
root /www/wwwroot/ggl/dist;
|
||||
index index.html index.htm;
|
||||
|
||||
# 静态资源缓存配置
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# API请求代理到后端Node.js服务
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4001;
|
||||
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_connect_timeout 30s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# uploads目录代理到后端
|
||||
location /uploads/ {
|
||||
proxy_pass http://127.0.0.1:4001;
|
||||
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;
|
||||
}
|
||||
|
||||
# 前端路由处理 - 所有其他请求返回index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 禁止访问敏感文件
|
||||
location ~ ^/(\.|package\.json|package-lock\.json|\.env) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# SSL证书验证目录
|
||||
location /.well-known/ {
|
||||
root /www/wwwroot/ggl;
|
||||
}
|
||||
|
||||
# 日志配置
|
||||
access_log /www/wwwlogs/高句丽.log;
|
||||
error_log /www/wwwlogs/高句丽.error.log;
|
||||
}
|
||||
35
nginx-rewrite-fixed.conf
Normal file
35
nginx-rewrite-fixed.conf
Normal file
@@ -0,0 +1,35 @@
|
||||
# API请求代理到后端4001端口
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4001;
|
||||
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_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 上传文件代理
|
||||
location /uploads/ {
|
||||
proxy_pass http://127.0.0.1:4001;
|
||||
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;
|
||||
}
|
||||
|
||||
# 静态资源直接访问(排除uploads目录)
|
||||
location ~* ^(?!/uploads/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
root /www/wwwroot/ggl/dist;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Vue Router History模式支持 - 前端路由
|
||||
location / {
|
||||
root /www/wwwroot/ggl/dist;
|
||||
try_files $uri $uri/ /index.html;
|
||||
index index.html;
|
||||
}
|
||||
35
nginx-rewrite-rules.conf
Normal file
35
nginx-rewrite-rules.conf
Normal file
@@ -0,0 +1,35 @@
|
||||
# 静态资源缓存配置
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# API请求代理到后端Node.js服务
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4001;
|
||||
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_connect_timeout 30s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# uploads目录代理到后端
|
||||
location /uploads/ {
|
||||
proxy_pass http://127.0.0.1:4001;
|
||||
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;
|
||||
}
|
||||
|
||||
# 前端路由处理 - 所有其他请求返回index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
10
nodemon.json
Executable file
10
nodemon.json
Executable file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"watch": ["api"],
|
||||
"ext": "ts,mts,js,json",
|
||||
"ignore": ["api/dist/*"],
|
||||
"exec": "tsx api/server.ts",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
},
|
||||
"delay": 1000
|
||||
}
|
||||
5
npm-silent.sh
Executable file
5
npm-silent.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# 临时脚本用于运行npm命令而不显示--init.module警告
|
||||
# 通过重定向stderr来过滤警告信息
|
||||
|
||||
npm "$@" 2>&1 | grep -v "Unknown global config" | grep -v "init.module"
|
||||
17360
package-lock.json
generated
Normal file
17360
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
68
package.json
Executable file
68
package.json
Executable file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "ggl",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"tsx api/server.ts\"",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"16": "^0.0.2",
|
||||
"axios": "^1.12.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.1",
|
||||
"element-plus": "^2.4.0",
|
||||
"express": "^4.21.2",
|
||||
"form-data": "^4.0.4",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-vue-next": "^0.511.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"pinia": "^2.1.7",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"vite-plugin-xi-plus-badge": "^1.1.0",
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5",
|
||||
"vue-sonner": "^2.0.8",
|
||||
"pm2": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/multer": "^1.4.0",
|
||||
"@types/node": "^22.15.30",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.1",
|
||||
"@typescript-eslint/parser": "^7.0.1",
|
||||
"@vercel/node": "^5.3.6",
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"@vue/runtime-dom": "^3.4.15",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"concurrently": "^9.2.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-vue": "^9.20.1",
|
||||
"nodemon": "^3.1.10",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "~5.3.3",
|
||||
"unplugin-vue-dev-locator": "^1.0.0",
|
||||
"vite": "^5.0.12",
|
||||
"vite-plugin-trae-solo-badge": "^1.0.0",
|
||||
"vue-tsc": "^1.8.27"
|
||||
}
|
||||
}
|
||||
10
postcss.config.js
Executable file
10
postcss.config.js
Executable file
@@ -0,0 +1,10 @@
|
||||
/** WARNING: DON'T EDIT THIS FILE */
|
||||
/** WARNING: DON'T EDIT THIS FILE */
|
||||
/** WARNING: DON'T EDIT THIS FILE */
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
6
public/default-thumbnail.svg
Executable file
6
public/default-thumbnail.svg
Executable file
@@ -0,0 +1,6 @@
|
||||
<svg width="320" height="180" viewBox="0 0 320 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="320" height="180" fill="#F3F4F6"/>
|
||||
<circle cx="160" cy="90" r="30" fill="#D1D5DB"/>
|
||||
<path d="M150 75L175 90L150 105V75Z" fill="#9CA3AF"/>
|
||||
<text x="160" y="130" font-family="Arial, sans-serif" font-size="14" fill="#6B7280" text-anchor="middle">视频封面</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
4
public/favicon.svg
Executable file
4
public/favicon.svg
Executable file
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" fill="#0A0B0D"/>
|
||||
<path d="M26.6677 23.7149H8.38057V20.6496H5.33301V8.38159H26.6677V23.7149ZM8.38057 20.6496H23.6201V11.4482H8.38057V20.6496ZM16.0011 16.0021L13.8461 18.1705L11.6913 16.0021L13.8461 13.8337L16.0011 16.0021ZM22.0963 16.0008L19.9414 18.1691L17.7865 16.0008L19.9414 13.8324L22.0963 16.0008Z" fill="#32F08C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 453 B |
1
public/logo.png
Executable file
1
public/logo.png
Executable file
@@ -0,0 +1 @@
|
||||
data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiByeD0iMTAiIGZpbGw9IiNGRjY3MDAiLz4KPHN2ZyB4PSIyMCIgeT0iMjAiIHdpZHRoPSI2MCIgaGVpZ2h0PSI2MCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+Cjxwb2x5Z29uIHBvaW50cz0iMTMsMiAzLDEzIDEyLDEzIDExLDIyIDIxLDExIDEyLDExIDEzLDIiLz4KPC9zdmc+Cjwvc3ZnPgo=
|
||||
5
public/logo.svg
Executable file
5
public/logo.svg
Executable file
@@ -0,0 +1,5 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" rx="10" fill="#FF6700"/>
|
||||
<text x="50" y="60" font-family="Arial, sans-serif" font-size="24" font-weight="bold" text-anchor="middle" fill="white">GGL</text>
|
||||
<circle cx="50" cy="30" r="8" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 344 B |
243
scripts/batch-upload-animal-videos.cjs
Normal file
243
scripts/batch-upload-animal-videos.cjs
Normal file
@@ -0,0 +1,243 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const { exec } = require('child_process');
|
||||
const util = require('util');
|
||||
const execPromise = util.promisify(exec);
|
||||
|
||||
// 数据库连接
|
||||
const db = new sqlite3.Database('goguryeo_video.db');
|
||||
|
||||
// 视频文件目录
|
||||
const VIDEO_DIR = './video';
|
||||
const UPLOAD_VIDEO_DIR = './uploads/videos';
|
||||
const UPLOAD_COVER_DIR = './uploads/covers';
|
||||
|
||||
// 确保上传目录存在
|
||||
if (!fs.existsSync(UPLOAD_VIDEO_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_VIDEO_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(UPLOAD_COVER_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_COVER_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 动物分类映射
|
||||
const animalCategoryMap = {
|
||||
'羊': { category: 5, tags: ['萌宠', '小羊', '可爱', '动物'] },
|
||||
'老虎': { category: 5, tags: ['萌宠', '老虎', '动物园', '可爱'] },
|
||||
'荷兰猪': { category: 5, tags: ['萌宠', '荷兰猪', '可爱'] },
|
||||
'兔子': { category: 5, tags: ['萌宠', '兔子', '可爱'] },
|
||||
'企鹅': { category: 5, tags: ['萌宠', '企鹅', '可爱'] },
|
||||
'牛': { category: 5, tags: ['动物', '小牛', '农村生活', '可爱'] },
|
||||
'猴子': { category: 5, tags: ['猴子', '可爱', '动物'] },
|
||||
'马': { category: 5, tags: ['萌宠', '矮马', '可爱'] },
|
||||
'水獭': { category: 5, tags: ['水獭', '萌宠', '治愈', '可爱'] },
|
||||
'鸟': { category: 5, tags: ['可爱', '治愈', '萌宠'] },
|
||||
'狗': { category: 5, tags: ['宠物', '狗子', '萌宠'] },
|
||||
'象': { category: 5, tags: ['小象', '萌宠', '可爱'] },
|
||||
'猪': { category: 5, tags: ['小香猪', '小猪', '可爱'] },
|
||||
'猫': { category: 5, tags: ['小猫咪', '萌宠', '可爱', '治愈'] }
|
||||
};
|
||||
|
||||
// 根据文件名识别动物类型
|
||||
function identifyAnimalType(filename) {
|
||||
for (const [animal, config] of Object.entries(animalCategoryMap)) {
|
||||
if (filename.includes(animal)) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
// 默认分类为民俗风情
|
||||
return { category: 5, tags: ['萌宠', '可爱', '动物'] };
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
function generateUniqueFilename(originalName) {
|
||||
const timestamp = Date.now();
|
||||
const randomStr = Math.random().toString(36).substring(2, 15);
|
||||
const ext = path.extname(originalName);
|
||||
return `${timestamp}_${randomStr}${ext}`;
|
||||
}
|
||||
|
||||
// 获取视频时长
|
||||
async function getVideoDuration(videoPath) {
|
||||
try {
|
||||
const { stdout } = await execPromise(`ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${videoPath}"`);
|
||||
return Math.round(parseFloat(stdout.trim()));
|
||||
} catch (error) {
|
||||
console.log(`无法获取视频时长: ${videoPath}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成视频封面
|
||||
async function generateThumbnail(videoPath, outputPath) {
|
||||
try {
|
||||
await execPromise(`ffmpeg -i "${videoPath}" -ss 00:00:01 -vframes 1 -y "${outputPath}"`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(`生成封面失败: ${videoPath}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 插入视频到数据库
|
||||
function insertVideo(videoData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = `
|
||||
INSERT INTO videos (
|
||||
title, description, video_url, file_path, cover_url, cover_image,
|
||||
duration, file_size, category, tags, user_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
db.run(sql, [
|
||||
videoData.title,
|
||||
videoData.description,
|
||||
videoData.video_url,
|
||||
videoData.file_path,
|
||||
videoData.cover_url,
|
||||
videoData.cover_image,
|
||||
videoData.duration,
|
||||
videoData.file_size,
|
||||
videoData.category,
|
||||
videoData.tags,
|
||||
1 // admin user_id
|
||||
], function(err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(this.lastID);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 处理单个视频文件
|
||||
async function processVideo(filename) {
|
||||
try {
|
||||
const originalPath = path.join(VIDEO_DIR, filename);
|
||||
const stats = fs.statSync(originalPath);
|
||||
|
||||
// 生成新文件名
|
||||
const newVideoName = generateUniqueFilename(filename);
|
||||
const newCoverName = newVideoName.replace('.mp4', '_cover.jpg');
|
||||
|
||||
const newVideoPath = path.join(UPLOAD_VIDEO_DIR, newVideoName);
|
||||
const newCoverPath = path.join(UPLOAD_COVER_DIR, newCoverName);
|
||||
|
||||
// 复制视频文件
|
||||
fs.copyFileSync(originalPath, newVideoPath);
|
||||
console.log(`复制视频: ${filename} -> ${newVideoName}`);
|
||||
|
||||
// 生成封面
|
||||
const thumbnailGenerated = await generateThumbnail(newVideoPath, newCoverPath);
|
||||
|
||||
// 获取视频时长
|
||||
const duration = await getVideoDuration(newVideoPath);
|
||||
|
||||
// 识别动物类型和分类
|
||||
const animalConfig = identifyAnimalType(filename);
|
||||
|
||||
// 清理标题(移除文件扩展名和平台标识)
|
||||
const title = filename
|
||||
.replace('.mp4', '')
|
||||
.replace('-快手', '')
|
||||
.trim();
|
||||
|
||||
// 准备视频数据
|
||||
const videoData = {
|
||||
title: title,
|
||||
description: `可爱的动物视频:${title}`,
|
||||
video_url: `/uploads/videos/${newVideoName}`,
|
||||
file_path: `/uploads/videos/${newVideoName}`,
|
||||
cover_url: thumbnailGenerated ? `/uploads/covers/${newCoverName}` : null,
|
||||
cover_image: thumbnailGenerated ? `/uploads/covers/${newCoverName}` : null,
|
||||
duration: duration,
|
||||
file_size: stats.size,
|
||||
category: animalConfig.category,
|
||||
tags: animalConfig.tags.join(','),
|
||||
};
|
||||
|
||||
// 插入数据库
|
||||
const videoId = await insertVideo(videoData);
|
||||
console.log(`视频上传成功: ${title} (ID: ${videoId})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
videoId: videoId,
|
||||
title: title,
|
||||
category: animalConfig.category
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error(`处理视频失败 ${filename}:`, error.message);
|
||||
return {
|
||||
success: false,
|
||||
filename: filename,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
try {
|
||||
console.log('开始批量上传动物视频...');
|
||||
|
||||
// 读取视频目录
|
||||
const files = fs.readdirSync(VIDEO_DIR)
|
||||
.filter(file => file.endsWith('.mp4'));
|
||||
|
||||
console.log(`找到 ${files.length} 个视频文件`);
|
||||
|
||||
const results = [];
|
||||
const categoryStats = {};
|
||||
|
||||
// 处理每个视频文件
|
||||
for (const file of files) {
|
||||
console.log(`\n处理: ${file}`);
|
||||
const result = await processVideo(file);
|
||||
results.push(result);
|
||||
|
||||
if (result.success) {
|
||||
const category = result.category;
|
||||
categoryStats[category] = (categoryStats[category] || 0) + 1;
|
||||
}
|
||||
|
||||
// 添加延迟避免系统过载
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// 统计结果
|
||||
const successful = results.filter(r => r.success).length;
|
||||
const failed = results.filter(r => !r.success).length;
|
||||
|
||||
console.log('\n=== 上传完成 ===');
|
||||
console.log(`成功: ${successful} 个`);
|
||||
console.log(`失败: ${failed} 个`);
|
||||
|
||||
console.log('\n=== 分类统计 ===');
|
||||
for (const [categoryId, count] of Object.entries(categoryStats)) {
|
||||
console.log(`分类 ${categoryId}: ${count} 个视频`);
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\n=== 失败的文件 ===');
|
||||
results.filter(r => !r.success).forEach(r => {
|
||||
console.log(`${r.filename}: ${r.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('批量上传失败:', error);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 运行脚本
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { main, processVideo };
|
||||
262
scripts/batch-upload-videos.cjs
Executable file
262
scripts/batch-upload-videos.cjs
Executable file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 批量上传视频脚本
|
||||
* 从指定目录批量上传视频文件到服务器
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const FormData = require('form-data');
|
||||
const axios = require('axios');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
// 配置
|
||||
const VIDEO_SOURCE_DIR = '/www/wwwroot/ggl/video';
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
const UPLOAD_ENDPOINT = '/api/videos/upload';
|
||||
const LOGIN_ENDPOINT = '/api/auth/login';
|
||||
const DB_PATH = path.join(process.cwd(), 'database', 'goguryeo_video.db');
|
||||
|
||||
// 测试用户
|
||||
const TEST_USER = {
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
};
|
||||
|
||||
// 全局token
|
||||
let authToken = null;
|
||||
|
||||
// 支持的视频格式
|
||||
const SUPPORTED_FORMATS = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv'];
|
||||
|
||||
// 默认专题映射(根据文件名关键词)
|
||||
const TOPIC_MAPPING = {
|
||||
'可爱': ['文化艺术', '民俗传统'],
|
||||
'小': ['文化艺术'],
|
||||
'动物': ['民俗传统'],
|
||||
'萌': ['文化艺术'],
|
||||
'治愈': ['文化艺术'],
|
||||
'成长': ['文化艺术', '民俗传统']
|
||||
};
|
||||
|
||||
// 登录获取token
|
||||
async function login() {
|
||||
try {
|
||||
console.log('正在登录获取认证token...');
|
||||
const response = await axios.post(`${SERVER_URL}${LOGIN_ENDPOINT}`, TEST_USER);
|
||||
|
||||
if (response.data.success && response.data.data && response.data.data.token) {
|
||||
authToken = response.data.data.token;
|
||||
console.log('✅ 登录成功,获取到token');
|
||||
return true;
|
||||
} else {
|
||||
console.error('❌ 登录失败:', response.data.message || '未知错误');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 登录请求异常:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取专题ID
|
||||
async function getTopicIds() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
db.all('SELECT id, name FROM topics WHERE status = "active"', (err, rows) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
const topicMap = {};
|
||||
rows.forEach(row => {
|
||||
topicMap[row.name] = row.id;
|
||||
});
|
||||
resolve(topicMap);
|
||||
}
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 根据文件名推断专题
|
||||
function inferTopics(filename, topicMap) {
|
||||
const topicIds = [];
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
|
||||
for (const [keyword, topics] of Object.entries(TOPIC_MAPPING)) {
|
||||
if (lowerFilename.includes(keyword)) {
|
||||
topics.forEach(topicName => {
|
||||
if (topicMap[topicName] && !topicIds.includes(topicMap[topicName])) {
|
||||
topicIds.push(topicMap[topicName]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配到专题,默认分配到"文化艺术"
|
||||
if (topicIds.length === 0 && topicMap['文化艺术']) {
|
||||
topicIds.push(topicMap['文化艺术']);
|
||||
}
|
||||
|
||||
return topicIds;
|
||||
}
|
||||
|
||||
// 生成视频标题和描述
|
||||
function generateVideoInfo(filename) {
|
||||
// 移除文件扩展名和特殊字符
|
||||
let title = path.parse(filename).name;
|
||||
title = title.replace(/[\-快手]/g, '').trim();
|
||||
|
||||
// 如果标题太长,截取前50个字符
|
||||
if (title.length > 50) {
|
||||
title = title.substring(0, 50) + '...';
|
||||
}
|
||||
|
||||
const description = `精彩视频内容:${title}`;
|
||||
|
||||
return { title, description };
|
||||
}
|
||||
|
||||
// 上传单个视频文件
|
||||
async function uploadVideo(filePath, topicMap) {
|
||||
const filename = path.basename(filePath);
|
||||
const { title, description } = generateVideoInfo(filename);
|
||||
const topicIds = inferTopics(filename, topicMap);
|
||||
|
||||
console.log(`正在上传: ${filename}`);
|
||||
console.log(`标题: ${title}`);
|
||||
console.log(`专题: ${topicIds.map(id => Object.keys(topicMap).find(key => topicMap[key] === id)).join(', ')}`);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('video', fs.createReadStream(filePath));
|
||||
formData.append('title', title);
|
||||
formData.append('description', description);
|
||||
formData.append('category', '文化艺术');
|
||||
formData.append('topicIds', JSON.stringify(topicIds));
|
||||
|
||||
if (!authToken) {
|
||||
throw new Error('未获取到认证token,请先登录');
|
||||
}
|
||||
|
||||
const response = await axios.post(`${SERVER_URL}${UPLOAD_ENDPOINT}`, formData, {
|
||||
headers: {
|
||||
...formData.getHeaders(),
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
timeout: 300000 // 5分钟超时
|
||||
});
|
||||
|
||||
if (response.data.code === 201 || response.data.code === 200) {
|
||||
console.log(`✅ 上传成功: ${filename}`);
|
||||
return { success: true, filename, videoId: response.data.data.id };
|
||||
} else {
|
||||
console.error(`❌ 上传失败: ${filename} - ${response.data.message}`);
|
||||
return { success: false, filename, error: response.data.message };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 上传异常: ${filename} - ${error.message}`);
|
||||
return { success: false, filename, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// 获取视频文件列表
|
||||
function getVideoFiles(directory) {
|
||||
if (!fs.existsSync(directory)) {
|
||||
throw new Error(`目录不存在: ${directory}`);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(directory);
|
||||
const videoFiles = files.filter(file => {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return SUPPORTED_FORMATS.includes(ext);
|
||||
});
|
||||
|
||||
return videoFiles.map(file => path.join(directory, file));
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
try {
|
||||
console.log('开始批量上传视频...');
|
||||
console.log(`源目录: ${VIDEO_SOURCE_DIR}`);
|
||||
|
||||
// 先登录获取token
|
||||
console.log('\n=== 用户认证 ===');
|
||||
const loginSuccess = await login();
|
||||
if (!loginSuccess) {
|
||||
console.log('❌ 登录失败,无法继续上传');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取专题映射
|
||||
console.log('\n=== 获取专题信息 ===');
|
||||
const topicMap = await getTopicIds();
|
||||
console.log('可用专题:', Object.keys(topicMap));
|
||||
|
||||
// 获取视频文件列表
|
||||
console.log('\n=== 扫描视频文件 ===');
|
||||
const videoFiles = getVideoFiles(VIDEO_SOURCE_DIR);
|
||||
console.log(`发现 ${videoFiles.length} 个视频文件`);
|
||||
|
||||
if (videoFiles.length === 0) {
|
||||
console.log('没有找到视频文件,退出程序');
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量上传
|
||||
console.log('\n=== 开始上传 ===');
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < videoFiles.length; i++) {
|
||||
const filePath = videoFiles[i];
|
||||
console.log(`\n[${i + 1}/${videoFiles.length}]`);
|
||||
|
||||
const result = await uploadVideo(filePath, topicMap);
|
||||
results.push(result);
|
||||
|
||||
// 添加延迟避免服务器压力
|
||||
if (i < videoFiles.length - 1) {
|
||||
console.log('等待 2 秒...');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
// 统计结果
|
||||
console.log('\n=== 上传结果统计 ===');
|
||||
const successful = results.filter(r => r.success);
|
||||
const failed = results.filter(r => !r.success);
|
||||
|
||||
console.log(`总计: ${results.length} 个文件`);
|
||||
console.log(`成功: ${successful.length} 个`);
|
||||
console.log(`失败: ${failed.length} 个`);
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.log('\n失败的文件:');
|
||||
failed.forEach(f => {
|
||||
console.log(`- ${f.filename}: ${f.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n✅ 批量上传完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 批量上传过程中出现错误:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { uploadVideo, getVideoFiles, getTopicIds };
|
||||
133
scripts/clean-test-data.cjs
Executable file
133
scripts/clean-test-data.cjs
Executable file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 清理测试视频数据脚本
|
||||
* 删除所有视频记录、相关文件和数据库记录
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
// 数据库路径
|
||||
const DB_PATH = path.join(process.cwd(), 'database', 'goguryeo_video.db');
|
||||
|
||||
// 视频文件存储目录
|
||||
const VIDEOS_DIR = path.join(process.cwd(), 'uploads', 'videos');
|
||||
const COVERS_DIR = path.join(process.cwd(), 'uploads', 'covers');
|
||||
const THUMBNAILS_DIR = path.join(process.cwd(), 'uploads', 'thumbnails');
|
||||
|
||||
async function cleanDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||||
if (err) {
|
||||
console.error('数据库连接失败:', err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
console.log('已连接到数据库');
|
||||
});
|
||||
|
||||
// 开始事务
|
||||
db.serialize(() => {
|
||||
db.run('BEGIN TRANSACTION');
|
||||
|
||||
// 删除视频相关的所有数据
|
||||
const queries = [
|
||||
'DELETE FROM play_history',
|
||||
'DELETE FROM user_likes',
|
||||
'DELETE FROM video_stats',
|
||||
'DELETE FROM video_tags',
|
||||
'DELETE FROM videos',
|
||||
'DELETE FROM tags WHERE id > 10', // 保留默认标签
|
||||
// 重置自增ID
|
||||
'DELETE FROM sqlite_sequence WHERE name IN ("videos", "video_tags", "play_history", "user_likes", "video_stats")'
|
||||
];
|
||||
|
||||
let completed = 0;
|
||||
const total = queries.length;
|
||||
|
||||
queries.forEach((query, index) => {
|
||||
db.run(query, (err) => {
|
||||
if (err) {
|
||||
console.error(`执行查询失败 [${index}]:`, err);
|
||||
db.run('ROLLBACK');
|
||||
db.close();
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
completed++;
|
||||
console.log(`已完成查询 ${completed}/${total}: ${query.split(' ')[0]} ${query.split(' ')[2]}`);
|
||||
|
||||
if (completed === total) {
|
||||
db.run('COMMIT', (err) => {
|
||||
if (err) {
|
||||
console.error('提交事务失败:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('数据库清理完成');
|
||||
resolve();
|
||||
}
|
||||
db.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanFiles() {
|
||||
const directories = [VIDEOS_DIR, COVERS_DIR, THUMBNAILS_DIR];
|
||||
|
||||
for (const dir of directories) {
|
||||
if (fs.existsSync(dir)) {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
for (const file of files) {
|
||||
// 跳过示例文件和隐藏文件
|
||||
if (file.startsWith('.') || file.startsWith('sample')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`已删除文件: ${filePath}`);
|
||||
} catch (err) {
|
||||
console.error(`删除文件失败 ${filePath}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`已清理目录: ${dir}`);
|
||||
} else {
|
||||
console.log(`目录不存在: ${dir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log('开始清理测试数据...');
|
||||
|
||||
// 清理数据库
|
||||
console.log('\n=== 清理数据库 ===');
|
||||
await cleanDatabase();
|
||||
|
||||
// 清理文件
|
||||
console.log('\n=== 清理文件 ===');
|
||||
await cleanFiles();
|
||||
|
||||
console.log('\n✅ 测试数据清理完成!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 清理过程中出现错误:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { cleanDatabase, cleanFiles };
|
||||
124
scripts/redistribute-videos.cjs
Normal file
124
scripts/redistribute-videos.cjs
Normal file
@@ -0,0 +1,124 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
// 数据库连接
|
||||
const db = new sqlite3.Database('goguryeo_video.db');
|
||||
|
||||
// 重新分配视频到不同分类
|
||||
const redistributionPlan = [
|
||||
// 将一些动物视频重新分类为传统艺术(表演类)
|
||||
{
|
||||
videoIds: [17], // 巨肺狗子(表演性质)
|
||||
newCategory: 2, // 传统艺术
|
||||
reason: '表演性质的内容'
|
||||
},
|
||||
// 将一些动物视频重新分类为语言学习(教育性质)
|
||||
{
|
||||
videoIds: [11, 15], // 小牛叫妈妈、水獭
|
||||
newCategory: 3, // 语言学习
|
||||
reason: '教育和学习性质的内容'
|
||||
},
|
||||
// 将一些动物视频重新分类为考古发现(自然探索)
|
||||
{
|
||||
videoIds: [9, 18], // 小老虎、小象
|
||||
newCategory: 4, // 考古发现
|
||||
reason: '自然探索和发现类内容'
|
||||
},
|
||||
// 将一些动物视频重新分类为其他
|
||||
{
|
||||
videoIds: [16, 20], // 小肥啾、小猫咪
|
||||
newCategory: 6, // 其他
|
||||
reason: '综合性内容'
|
||||
}
|
||||
];
|
||||
|
||||
// 更新视频分类
|
||||
function updateVideoCategory(videoId, newCategory) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = 'UPDATE videos SET category = ? WHERE id = ?';
|
||||
db.run(sql, [newCategory, videoId], function(err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(this.changes);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 获取视频信息
|
||||
function getVideoInfo(videoId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = 'SELECT id, title, category FROM videos WHERE id = ?';
|
||||
db.get(sql, [videoId], (err, row) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(row);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
try {
|
||||
console.log('开始重新分配视频分类...');
|
||||
|
||||
for (const plan of redistributionPlan) {
|
||||
console.log(`\n重新分配到分类 ${plan.newCategory}: ${plan.reason}`);
|
||||
|
||||
for (const videoId of plan.videoIds) {
|
||||
try {
|
||||
// 获取视频信息
|
||||
const videoInfo = await getVideoInfo(videoId);
|
||||
if (!videoInfo) {
|
||||
console.log(`视频 ID ${videoId} 不存在`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` ${videoInfo.title} (ID: ${videoId}) 从分类 ${videoInfo.category} -> ${plan.newCategory}`);
|
||||
|
||||
// 更新分类
|
||||
await updateVideoCategory(videoId, plan.newCategory);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`更新视频 ${videoId} 失败:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n重新分配完成!');
|
||||
|
||||
// 显示最终的分类统计
|
||||
console.log('\n=== 最终分类统计 ===');
|
||||
const sql = `
|
||||
SELECT c.name, COUNT(v.id) as video_count
|
||||
FROM categories c
|
||||
LEFT JOIN videos v ON c.id = v.category
|
||||
GROUP BY c.id, c.name
|
||||
ORDER BY c.id
|
||||
`;
|
||||
|
||||
db.all(sql, [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('查询统计失败:', err);
|
||||
} else {
|
||||
rows.forEach(row => {
|
||||
console.log(`${row.name}: ${row.video_count} 个视频`);
|
||||
});
|
||||
}
|
||||
db.close();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('重新分配失败:', error);
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 运行脚本
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
3
src/App.vue
Executable file
3
src/App.vue
Executable file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
337
src/api/index.ts
Executable file
337
src/api/index.ts
Executable file
@@ -0,0 +1,337 @@
|
||||
import { httpGet, httpPost, httpPut, httpDelete, httpUpload } from '@/utils/http'
|
||||
import type { Video } from '@/stores/video'
|
||||
import type { User } from '@/stores/user'
|
||||
|
||||
// 响应数据类型定义
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface PaginationResponse<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
// 认证相关API
|
||||
export const authApi = {
|
||||
// 管理员登录
|
||||
login: (username: string, password: string) => {
|
||||
return httpPost<ApiResponse<{ user: User; token: string }>>('/auth/login', {
|
||||
username,
|
||||
password
|
||||
})
|
||||
},
|
||||
|
||||
// 检查登录状态
|
||||
checkAuth: () => {
|
||||
return httpGet<ApiResponse<{ user: User }>>('/auth/check')
|
||||
},
|
||||
|
||||
// 登出
|
||||
logout: () => {
|
||||
return httpPost<ApiResponse>('/auth/logout')
|
||||
}
|
||||
}
|
||||
|
||||
// 视频相关API
|
||||
export const videoApi = {
|
||||
// 获取视频列表
|
||||
getVideos: (params: PaginationParams = {}) => {
|
||||
return httpGet<ApiResponse<PaginationResponse<Video>>>('/videos', { params })
|
||||
},
|
||||
|
||||
// 获取视频详情
|
||||
getVideoById: (id: number) => {
|
||||
return httpGet<ApiResponse<Video>>(`/videos/${id}`)
|
||||
},
|
||||
|
||||
// 搜索视频
|
||||
searchVideos: (params: PaginationParams & { keyword: string }) => {
|
||||
return httpGet<ApiResponse<PaginationResponse<Video>>>('/videos/search', { params })
|
||||
},
|
||||
|
||||
// 上传视频
|
||||
uploadVideo: (formData: FormData, onProgress?: (progress: number) => void) => {
|
||||
return httpUpload('/videos/upload', formData, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 更新视频信息
|
||||
updateVideo: (id: number, data: Partial<Video>) => {
|
||||
return httpPut<ApiResponse<Video>>(`/videos/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除视频
|
||||
deleteVideo: (id: number) => {
|
||||
return httpDelete<ApiResponse>(`/videos/${id}`)
|
||||
},
|
||||
|
||||
// 增加播放量 (后端在获取视频详情时自动增加)
|
||||
incrementViews: (id: number) => {
|
||||
// 后端在 getVideo 时自动增加播放量,这里返回一个成功的 Promise
|
||||
return Promise.resolve({ code: 200, message: '播放量已增加', data: null })
|
||||
},
|
||||
|
||||
// 点赞视频
|
||||
likeVideo: (id: number) => {
|
||||
return httpPost<ApiResponse>(`/videos/${id}/like`)
|
||||
},
|
||||
|
||||
// 批量更新视频状态
|
||||
batchUpdateVideoStatus: (videoIds: number[], status: string) => {
|
||||
return httpPut<ApiResponse>('/videos/batch/status', { videoIds, status })
|
||||
},
|
||||
|
||||
// 批量删除视频
|
||||
batchDeleteVideos: (videoIds: number[]) => {
|
||||
return httpDelete<ApiResponse>('/videos/batch', { data: { videoIds } })
|
||||
},
|
||||
|
||||
// 批量更新视频分类
|
||||
batchUpdateVideoCategory: (videoIds: number[], category: string) => {
|
||||
return httpPut<ApiResponse>('/videos/batch/category', { videoIds, category })
|
||||
},
|
||||
|
||||
// 导出视频数据
|
||||
exportVideos: (params: { search?: string; status?: string; category?: string }) => {
|
||||
return httpGet<ApiResponse<Blob>>('/videos/export', { params, responseType: 'blob' })
|
||||
},
|
||||
|
||||
// 收藏视频
|
||||
collectVideo: (id: number) => {
|
||||
return httpPost<ApiResponse>(`/videos/${id}/collect`)
|
||||
},
|
||||
|
||||
// 获取视频详情(用于VideoDetail页面)
|
||||
getVideo: (id: number) => {
|
||||
return httpGet<ApiResponse<Video>>(`/videos/${id}`)
|
||||
},
|
||||
|
||||
// 获取相关视频 (暂时返回空数组,后续可实现)
|
||||
getRelatedVideos: (id: number) => {
|
||||
// 暂时返回空数组,避免 API 调用失败
|
||||
return Promise.resolve({ code: 200, message: '获取成功', data: [] })
|
||||
},
|
||||
|
||||
// 获取视频评论 (暂时返回空数组,后续可实现)
|
||||
getVideoComments: (id: number, params: PaginationParams = {}) => {
|
||||
// 暂时返回空数组,避免 API 调用失败
|
||||
return Promise.resolve({
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: { list: [], total: 0, page: 1, limit: 10 }
|
||||
})
|
||||
},
|
||||
|
||||
// 添加评论
|
||||
addComment: (videoId: number, content: string) => {
|
||||
return httpPost<ApiResponse<any>>(`/videos/${videoId}/comments`, { content })
|
||||
},
|
||||
|
||||
// 点赞评论
|
||||
likeComment: (commentId: number) => {
|
||||
return httpPost<ApiResponse>(`/comments/${commentId}/like`)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户相关API
|
||||
export const userApi = {
|
||||
// 获取所有用户
|
||||
getUsers: (params: PaginationParams = {}) => {
|
||||
return httpGet<ApiResponse<PaginationResponse<User>>>('/users', { params })
|
||||
},
|
||||
|
||||
// 获取用户信息
|
||||
getUserById: (id: number) => {
|
||||
return httpGet<ApiResponse<User>>(`/users/${id}`)
|
||||
},
|
||||
|
||||
// 删除用户
|
||||
deleteUser: (id: number) => {
|
||||
return httpDelete<ApiResponse>(`/users/${id}`)
|
||||
},
|
||||
|
||||
// 获取用户的视频列表
|
||||
getUserVideos: (id: number, params: PaginationParams = {}) => {
|
||||
return httpGet<ApiResponse<PaginationResponse<Video>>>(`/users/${id}/videos`, { params })
|
||||
},
|
||||
|
||||
// 搜索用户的视频
|
||||
searchUserVideos: (id: number, params: PaginationParams & { keyword: string }) => {
|
||||
return httpGet<ApiResponse<PaginationResponse<Video>>>(`/users/${id}/videos/search`, { params })
|
||||
},
|
||||
|
||||
// 创建用户
|
||||
createUser: (userData: Partial<User>) => {
|
||||
return httpPost<ApiResponse<User>>('/users', userData)
|
||||
},
|
||||
|
||||
// 更新用户信息
|
||||
updateUser: (id: number, userData: Partial<User>) => {
|
||||
return httpPut<ApiResponse<User>>(`/users/${id}`, userData)
|
||||
},
|
||||
|
||||
// 批量删除用户
|
||||
batchDeleteUsers: (userIds: number[]) => {
|
||||
return httpDelete<ApiResponse>('/users/batch', { data: { userIds } })
|
||||
},
|
||||
|
||||
// 批量更新用户
|
||||
batchUpdateUsers: (userIds: number[], updateData: Partial<User>) => {
|
||||
return httpPut<ApiResponse>('/users/batch', { userIds, ...updateData })
|
||||
},
|
||||
|
||||
// 导出用户数据
|
||||
exportUsers: (params: { search?: string; role?: string; status?: string } = {}) => {
|
||||
return httpGet<ApiResponse<Blob>>('/users/export', { params, responseType: 'blob' })
|
||||
},
|
||||
|
||||
// 关注用户
|
||||
followUser: (id: number) => {
|
||||
return httpPost<ApiResponse>(`/users/${id}/follow`)
|
||||
}
|
||||
}
|
||||
|
||||
// 统计相关API
|
||||
export const statsApi = {
|
||||
// 获取总体统计数据
|
||||
getOverallStats: () => {
|
||||
return httpGet<ApiResponse<{
|
||||
totalVideos: number
|
||||
totalViews: number
|
||||
totalUsers: number
|
||||
todayViews: number
|
||||
}>>('/stats/overall')
|
||||
},
|
||||
|
||||
// 获取热门视频
|
||||
getPopularVideos: (limit: number = 10) => {
|
||||
return httpGet<ApiResponse<Video[]>>(`/stats/popular-videos?limit=${limit}`)
|
||||
},
|
||||
|
||||
// 获取最新视频
|
||||
getLatestVideos: (limit: number = 10) => {
|
||||
return httpGet<ApiResponse<Video[]>>('/stats/latest', { params: { limit } })
|
||||
},
|
||||
|
||||
// 导出统计报告
|
||||
exportStatisticsReport: (timeRange: string) => {
|
||||
return httpGet<ApiResponse<Blob>>(`/stats/export?timeRange=${timeRange}`, { responseType: 'blob' })
|
||||
},
|
||||
|
||||
// 获取活跃用户
|
||||
getActiveUsers: (limit: number = 10) => {
|
||||
return httpGet<ApiResponse<User[]>>(`/stats/active-users?limit=${limit}`)
|
||||
},
|
||||
|
||||
// 获取系统状态
|
||||
getSystemStatus: () => {
|
||||
return httpGet<ApiResponse<any>>('/stats/system-status')
|
||||
},
|
||||
|
||||
// 获取统计数据
|
||||
getStatistics: (timeRange: string) => {
|
||||
return httpGet<ApiResponse<any>>(`/stats/statistics?timeRange=${timeRange}`)
|
||||
},
|
||||
|
||||
// 获取最近用户
|
||||
getRecentUsers: (params: { limit: number }) => {
|
||||
return httpGet<ApiResponse<User[]>>('/stats/recent-users', { params })
|
||||
}
|
||||
}
|
||||
|
||||
// 分类相关接口
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
videoCount?: number
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export const categoryApi = {
|
||||
// 获取分类列表
|
||||
getCategories: (params?: { page?: number; pageSize?: number; search?: string }) => {
|
||||
return httpGet('/categories', { params })
|
||||
},
|
||||
|
||||
// 获取分类详情
|
||||
getCategoryById: (id: number) => {
|
||||
return httpGet(`/categories/${id}`)
|
||||
},
|
||||
|
||||
// 创建分类
|
||||
createCategory: (data: { name: string; description?: string }) => {
|
||||
return httpPost('/categories', data)
|
||||
},
|
||||
|
||||
// 更新分类
|
||||
updateCategory: (id: number, data: { name: string; description?: string }) => {
|
||||
return httpPut(`/categories/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除分类
|
||||
deleteCategory: (id: number) => {
|
||||
return httpDelete(`/categories/${id}`)
|
||||
},
|
||||
|
||||
// 批量删除分类
|
||||
batchDeleteCategories: (ids: number[]) => {
|
||||
return httpDelete('/categories/batch', { data: { ids } })
|
||||
},
|
||||
|
||||
// 更新分类顺序
|
||||
updateCategoryOrder: (id: number, direction: 'up' | 'down') => {
|
||||
return httpPut(`/categories/${id}/order`, { direction })
|
||||
}
|
||||
}
|
||||
|
||||
// 文件上传相关API
|
||||
export const uploadApi = {
|
||||
// 上传封面图片
|
||||
uploadCover: (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('cover', file)
|
||||
return httpUpload<ApiResponse<{ url: string }>>('/upload/cover', formData)
|
||||
},
|
||||
|
||||
// 上传视频文件
|
||||
uploadVideoFile: (file: File, onProgress?: (progress: number) => void) => {
|
||||
const formData = new FormData()
|
||||
formData.append('video', file)
|
||||
return httpUpload<ApiResponse<{ url: string, duration?: number }>>('/upload/video', formData, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
authApi,
|
||||
videoApi,
|
||||
userApi,
|
||||
statsApi,
|
||||
categoryApi,
|
||||
uploadApi
|
||||
}
|
||||
1
src/assets/vue.svg
Executable file
1
src/assets/vue.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
3
src/components/Empty.vue
Executable file
3
src/components/Empty.vue
Executable file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>empty</div>
|
||||
</template>
|
||||
114
src/components/Footer.vue
Executable file
114
src/components/Footer.vue
Executable file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<footer class="app-footer">
|
||||
<div class="footer-container">
|
||||
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="footer-bottom">
|
||||
<div class="copyright">
|
||||
<p>© {{ currentYear }} 梦回高句丽项目. 保留所有权利.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 装饰元素 -->
|
||||
<div class="footer-decoration">
|
||||
<div class="decoration-pattern"></div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 当前年份
|
||||
const currentYear = new Date().getFullYear()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-footer {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #1a1a2e 100%);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-top: 0; /* 确保页脚与上方内容紧贴,不产生额外间距 */
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 15px 20px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 底部信息 */
|
||||
.footer-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.copyright p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 装饰元素 */
|
||||
.footer-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.decoration-pattern {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
right: -50px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: radial-gradient(circle, rgba(255, 193, 7, 0.1) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.decoration-pattern::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100px;
|
||||
left: -100px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: radial-gradient(circle, rgba(255, 193, 7, 0.05) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.footer-container {
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
.copyright p {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.footer-container {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
478
src/components/Header.vue
Executable file
478
src/components/Header.vue
Executable file
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-container">
|
||||
<!-- Logo区域 -->
|
||||
<div class="logo-section">
|
||||
<router-link to="/" class="logo-link">
|
||||
<div class="logo-icon">
|
||||
<div class="logo-pattern"></div>
|
||||
</div>
|
||||
<h1 class="logo-text">梦回高句丽</h1>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域已删除 -->
|
||||
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="nav-section">
|
||||
<ul class="nav-menu">
|
||||
<li class="nav-item">
|
||||
<router-link to="/" class="nav-link" active-class="active">
|
||||
<el-icon><House /></el-icon>
|
||||
<span>首页</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link to="/videos" class="nav-link" active-class="active">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span>视频库</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- 用户区域 -->
|
||||
<div class="user-section">
|
||||
<template v-if="userStore.isLoggedIn">
|
||||
<el-dropdown @command="handleUserCommand">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" :src="userStore.user?.avatar">
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
<span class="username">{{ userStore.user?.username }}</span>
|
||||
<el-icon class="dropdown-icon"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="admin">
|
||||
<el-icon><Setting /></el-icon>
|
||||
管理后台
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/admin/login" class="login-btn">
|
||||
<el-button type="primary" size="small">
|
||||
<el-icon><User /></el-icon>
|
||||
管理登录
|
||||
</el-button>
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单按钮 -->
|
||||
<div class="mobile-menu-btn" @click="toggleMobileMenu">
|
||||
<el-icon><Menu /></el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 移动端菜单 -->
|
||||
<div class="mobile-menu" :class="{ active: showMobileMenu }">
|
||||
<div class="mobile-search">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索视频..."
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<ul class="mobile-nav">
|
||||
<li><router-link to="/" @click="closeMobileMenu">首页</router-link></li>
|
||||
<li><router-link to="/videos" @click="closeMobileMenu">视频库</router-link></li>
|
||||
<li><router-link to="/history" @click="closeMobileMenu">历史文化</router-link></li>
|
||||
<li v-if="!userStore.isLoggedIn">
|
||||
<router-link to="/admin/login" @click="closeMobileMenu">管理登录</router-link>
|
||||
</li>
|
||||
<li v-else>
|
||||
<a @click="handleLogout">退出登录</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Search,
|
||||
House,
|
||||
VideoPlay,
|
||||
Clock,
|
||||
User,
|
||||
ArrowDown,
|
||||
Setting,
|
||||
SwitchButton,
|
||||
Menu
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 搜索相关
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 移动端菜单
|
||||
const showMobileMenu = ref(false)
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
if (!searchKeyword.value.trim()) {
|
||||
ElMessage.warning('请输入搜索关键词')
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
name: 'search',
|
||||
query: { q: searchKeyword.value.trim() }
|
||||
})
|
||||
searchKeyword.value = ''
|
||||
closeMobileMenu()
|
||||
}
|
||||
|
||||
// 用户菜单处理
|
||||
const handleUserCommand = (command: string) => {
|
||||
switch (command) {
|
||||
case 'admin':
|
||||
router.push('/admin')
|
||||
break
|
||||
case 'logout':
|
||||
handleLogout()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userStore.logout()
|
||||
ElMessage.success('退出登录成功')
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
ElMessage.error('退出登录失败')
|
||||
}
|
||||
closeMobileMenu()
|
||||
}
|
||||
|
||||
// 移动端菜单控制
|
||||
const toggleMobileMenu = () => {
|
||||
showMobileMenu.value = !showMobileMenu.value
|
||||
}
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
showMobileMenu.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
border-bottom: 3px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
/* Logo区域 */
|
||||
.logo-section {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.logo-link:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--accent-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo-pattern {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo-pattern::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
background: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
background: linear-gradient(45deg, white, var(--accent-light));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* 搜索区域 */
|
||||
.search-section {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
margin: 0 40px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 25px;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper):hover {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper.is-focus) {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.2);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 导航菜单 */
|
||||
.nav-section {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-decoration: none;
|
||||
border-radius: 20px;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--accent-color);
|
||||
color: var(--primary-dark);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 用户区域 */
|
||||
.user-section {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 500;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 12px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 移动端菜单按钮 */
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 移动端菜单 */
|
||||
.mobile-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--primary-color);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 20px;
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-menu.active {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mobile-search {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mobile-nav li {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mobile-nav a {
|
||||
display: block;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-nav a:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.header-container {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.search-section,
|
||||
.nav-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu-btn,
|
||||
.mobile-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header-container {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
502
src/components/Loading.vue
Executable file
502
src/components/Loading.vue
Executable file
@@ -0,0 +1,502 @@
|
||||
<template>
|
||||
<div class="loading-container" :class="{ fullscreen: fullscreen, overlay: overlay }">
|
||||
<div class="loading-content" :class="`loading-${type}`">
|
||||
<!-- 默认加载动画 -->
|
||||
<div v-if="type === 'default'" class="loading-spinner">
|
||||
<div class="spinner-ring">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p v-if="text" class="loading-text">{{ text }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 高句丽风格加载动画 -->
|
||||
<div v-else-if="type === 'goguryeo'" class="loading-goguryeo">
|
||||
<div class="goguryeo-symbol">
|
||||
<div class="symbol-outer">
|
||||
<div class="symbol-inner"></div>
|
||||
</div>
|
||||
<div class="symbol-rays">
|
||||
<div v-for="i in 8" :key="i" class="ray" :style="{ transform: `rotate(${i * 45}deg)` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="text" class="loading-text goguryeo-text">{{ text }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 点状加载动画 -->
|
||||
<div v-else-if="type === 'dots'" class="loading-dots">
|
||||
<div class="dot" v-for="i in 3" :key="i" :style="{ animationDelay: `${i * 0.2}s` }"></div>
|
||||
<p v-if="text" class="loading-text">{{ text }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 脉冲加载动画 -->
|
||||
<div v-else-if="type === 'pulse'" class="loading-pulse">
|
||||
<div class="pulse-circle">
|
||||
<div class="pulse-inner"></div>
|
||||
</div>
|
||||
<p v-if="text" class="loading-text">{{ text }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 进度条加载 -->
|
||||
<div v-else-if="type === 'progress'" class="loading-progress">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" :style="{ width: `${progress}%` }"></div>
|
||||
</div>
|
||||
<p v-if="text" class="loading-text">{{ text }}</p>
|
||||
<p v-if="showProgress" class="progress-text">{{ progress }}%</p>
|
||||
</div>
|
||||
|
||||
<!-- 骨架屏加载 -->
|
||||
<div v-else-if="type === 'skeleton'" class="loading-skeleton">
|
||||
<div class="skeleton-item skeleton-title"></div>
|
||||
<div class="skeleton-item skeleton-line"></div>
|
||||
<div class="skeleton-item skeleton-line short"></div>
|
||||
<div class="skeleton-item skeleton-image"></div>
|
||||
</div>
|
||||
|
||||
<!-- 视频加载动画 -->
|
||||
<div v-else-if="type === 'video'" class="loading-video">
|
||||
<div class="video-icon">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
</div>
|
||||
<div class="video-waves">
|
||||
<div v-for="i in 4" :key="i" class="wave" :style="{ animationDelay: `${i * 0.1}s` }"></div>
|
||||
</div>
|
||||
<p v-if="text" class="loading-text">{{ text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { VideoPlay } from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
type?: 'default' | 'goguryeo' | 'dots' | 'pulse' | 'progress' | 'skeleton' | 'video'
|
||||
text?: string
|
||||
fullscreen?: boolean
|
||||
overlay?: boolean
|
||||
progress?: number
|
||||
showProgress?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
type: 'default',
|
||||
text: '',
|
||||
fullscreen: false,
|
||||
overlay: true,
|
||||
progress: 0,
|
||||
showProgress: false,
|
||||
size: 'medium'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.loading-container.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-container.overlay {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin: 16px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-color-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 默认加载动画 */
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spinner-ring {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.spinner-ring div {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 4px;
|
||||
border: 3px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spinner-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
border-color: var(--primary-color) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.spinner-ring div:nth-child(1) { animation-delay: -0.45s; }
|
||||
.spinner-ring div:nth-child(2) { animation-delay: -0.3s; }
|
||||
.spinner-ring div:nth-child(3) { animation-delay: -0.15s; }
|
||||
|
||||
@keyframes spinner-ring {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 高句丽风格加载动画 */
|
||||
.loading-goguryeo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.goguryeo-symbol {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.symbol-outer {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 3px solid var(--accent-color);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: goguryeo-rotate 2s linear infinite;
|
||||
}
|
||||
|
||||
.symbol-inner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.symbol-inner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.symbol-rays {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.ray {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: var(--accent-color);
|
||||
transform-origin: 50% 38px;
|
||||
animation: goguryeo-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.goguryeo-text {
|
||||
color: var(--accent-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@keyframes goguryeo-rotate {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes goguryeo-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scaleY(0.5); }
|
||||
50% { opacity: 1; transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* 点状加载动画 */
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-dots .dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
margin: 0 4px;
|
||||
animation: dots-bounce 1.4s ease-in-out infinite both;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes dots-bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 脉冲加载动画 */
|
||||
.loading-pulse {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pulse-circle {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pulse-inner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: pulse-scale 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pulse-circle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: pulse-ring 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-scale {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 进度条加载 */
|
||||
.loading-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--accent-color));
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
animation: progress-shine 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 12px;
|
||||
color: var(--primary-color);
|
||||
font-weight: bold;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
@keyframes progress-shine {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* 骨架屏加载 */
|
||||
.loading-skeleton {
|
||||
width: 300px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 20px;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-line.short {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.skeleton-image {
|
||||
height: 120px;
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* 视频加载动画 */
|
||||
.loading-video {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-icon {
|
||||
font-size: 40px;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 16px;
|
||||
animation: video-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.video-waves {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wave {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
animation: video-wave 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes video-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
@keyframes video-wave {
|
||||
0% {
|
||||
transform: scale(0.5);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 尺寸变体 */
|
||||
.loading-small .spinner-ring,
|
||||
.loading-small .goguryeo-symbol,
|
||||
.loading-small .pulse-circle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.loading-small .video-icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.loading-large .spinner-ring,
|
||||
.loading-large .goguryeo-symbol,
|
||||
.loading-large .pulse-circle {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.loading-large .video-icon {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.loading-skeleton {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.loading-progress {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
543
src/components/Pagination.vue
Executable file
543
src/components/Pagination.vue
Executable file
@@ -0,0 +1,543 @@
|
||||
<template>
|
||||
<div class="pagination-container" v-if="total > 0">
|
||||
<!-- 分页信息 -->
|
||||
<div class="pagination-info" v-if="showInfo">
|
||||
<span class="info-text">
|
||||
共 <strong>{{ total }}</strong> 条记录,
|
||||
第 <strong>{{ currentPage }}</strong> / <strong>{{ totalPages }}</strong> 页
|
||||
</span>
|
||||
<span class="info-range" v-if="showRange">
|
||||
显示第 <strong>{{ startIndex }}</strong> - <strong>{{ endIndex }}</strong> 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<div class="pagination-controls">
|
||||
<!-- 每页显示数量选择 -->
|
||||
<div class="page-size-selector" v-if="showSizeChanger">
|
||||
<span class="selector-label">每页显示</span>
|
||||
<el-select
|
||||
v-model="currentPageSize"
|
||||
@change="handlePageSizeChange"
|
||||
size="small"
|
||||
class="size-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="size in pageSizeOptions"
|
||||
:key="size"
|
||||
:label="`${size} 条`"
|
||||
:value="size"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 分页按钮 -->
|
||||
<div class="pagination-buttons">
|
||||
<!-- 首页按钮 -->
|
||||
<el-button
|
||||
v-if="showFirstLast"
|
||||
:disabled="currentPage === 1"
|
||||
@click="handlePageChange(1)"
|
||||
size="small"
|
||||
class="page-btn first-btn"
|
||||
>
|
||||
<el-icon><DArrowLeft /></el-icon>
|
||||
首页
|
||||
</el-button>
|
||||
|
||||
<!-- 上一页按钮 -->
|
||||
<el-button
|
||||
:disabled="currentPage === 1"
|
||||
@click="handlePageChange(currentPage - 1)"
|
||||
size="small"
|
||||
class="page-btn prev-btn"
|
||||
>
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<span v-if="!simple">上一页</span>
|
||||
</el-button>
|
||||
|
||||
<!-- 页码按钮 -->
|
||||
<div class="page-numbers" v-if="!simple">
|
||||
<!-- 第一页 -->
|
||||
<el-button
|
||||
v-if="showFirstPage"
|
||||
:type="currentPage === 1 ? 'primary' : 'default'"
|
||||
@click="handlePageChange(1)"
|
||||
size="small"
|
||||
class="page-number"
|
||||
>
|
||||
1
|
||||
</el-button>
|
||||
|
||||
<!-- 左侧省略号 -->
|
||||
<span v-if="showLeftEllipsis" class="ellipsis">...</span>
|
||||
|
||||
<!-- 中间页码 -->
|
||||
<el-button
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
:type="currentPage === page ? 'primary' : 'default'"
|
||||
@click="handlePageChange(page)"
|
||||
size="small"
|
||||
class="page-number"
|
||||
>
|
||||
{{ page }}
|
||||
</el-button>
|
||||
|
||||
<!-- 右侧省略号 -->
|
||||
<span v-if="showRightEllipsis" class="ellipsis">...</span>
|
||||
|
||||
<!-- 最后一页 -->
|
||||
<el-button
|
||||
v-if="showLastPage"
|
||||
:type="currentPage === totalPages ? 'primary' : 'default'"
|
||||
@click="handlePageChange(totalPages)"
|
||||
size="small"
|
||||
class="page-number"
|
||||
>
|
||||
{{ totalPages }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 简单模式页码显示 -->
|
||||
<div class="simple-pager" v-if="simple">
|
||||
<span class="current-page">{{ currentPage }}</span>
|
||||
<span class="page-separator">/</span>
|
||||
<span class="total-pages">{{ totalPages }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 下一页按钮 -->
|
||||
<el-button
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="handlePageChange(currentPage + 1)"
|
||||
size="small"
|
||||
class="page-btn next-btn"
|
||||
>
|
||||
<span v-if="!simple">下一页</span>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<!-- 末页按钮 -->
|
||||
<el-button
|
||||
v-if="showFirstLast"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="handlePageChange(totalPages)"
|
||||
size="small"
|
||||
class="page-btn last-btn"
|
||||
>
|
||||
末页
|
||||
<el-icon><DArrowRight /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 快速跳转 -->
|
||||
<div class="quick-jumper" v-if="showQuickJumper">
|
||||
<span class="jumper-label">跳至</span>
|
||||
<el-input
|
||||
v-model="jumpPage"
|
||||
@keyup.enter="handleQuickJump"
|
||||
@blur="handleQuickJump"
|
||||
size="small"
|
||||
class="jump-input"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="totalPages"
|
||||
/>
|
||||
<span class="jumper-label">页</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
DArrowLeft,
|
||||
DArrowRight
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
total: number
|
||||
pageSize?: number
|
||||
currentPage?: number
|
||||
pageSizeOptions?: number[]
|
||||
showSizeChanger?: boolean
|
||||
showQuickJumper?: boolean
|
||||
showFirstLast?: boolean
|
||||
showInfo?: boolean
|
||||
showRange?: boolean
|
||||
simple?: boolean
|
||||
maxPageButtons?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
pageSizeOptions: () => [10, 20, 50, 100],
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showFirstLast: false,
|
||||
showInfo: true,
|
||||
showRange: true,
|
||||
simple: false,
|
||||
maxPageButtons: 7
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:currentPage': [page: number]
|
||||
'update:pageSize': [size: number]
|
||||
'change': [page: number, pageSize: number]
|
||||
'pageSizeChange': [size: number]
|
||||
}>()
|
||||
|
||||
// 响应式数据
|
||||
const currentPageSize = ref(props.pageSize)
|
||||
const jumpPage = ref('')
|
||||
|
||||
// 计算属性
|
||||
const totalPages = computed(() => Math.ceil(props.total / currentPageSize.value))
|
||||
|
||||
const startIndex = computed(() => (props.currentPage - 1) * currentPageSize.value + 1)
|
||||
|
||||
const endIndex = computed(() => {
|
||||
const end = props.currentPage * currentPageSize.value
|
||||
return end > props.total ? props.total : end
|
||||
})
|
||||
|
||||
// 可见页码计算
|
||||
const visiblePages = computed(() => {
|
||||
const pages: number[] = []
|
||||
const maxButtons = props.maxPageButtons
|
||||
const current = props.currentPage
|
||||
const total = totalPages.value
|
||||
|
||||
if (total <= maxButtons) {
|
||||
// 总页数小于等于最大按钮数,显示所有页码
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
// 计算显示范围
|
||||
const half = Math.floor((maxButtons - 2) / 2) // 减去首页和末页
|
||||
let start = Math.max(2, current - half)
|
||||
let end = Math.min(total - 1, current + half)
|
||||
|
||||
// 调整范围以保持按钮数量
|
||||
if (end - start + 1 < maxButtons - 2) {
|
||||
if (start === 2) {
|
||||
end = Math.min(total - 1, start + maxButtons - 3)
|
||||
} else {
|
||||
start = Math.max(2, end - maxButtons + 3)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const showFirstPage = computed(() => {
|
||||
return totalPages.value > props.maxPageButtons && !visiblePages.value.includes(1)
|
||||
})
|
||||
|
||||
const showLastPage = computed(() => {
|
||||
return totalPages.value > props.maxPageButtons && !visiblePages.value.includes(totalPages.value)
|
||||
})
|
||||
|
||||
const showLeftEllipsis = computed(() => {
|
||||
return showFirstPage.value && visiblePages.value[0] > 2
|
||||
})
|
||||
|
||||
const showRightEllipsis = computed(() => {
|
||||
return showLastPage.value && visiblePages.value[visiblePages.value.length - 1] < totalPages.value - 1
|
||||
})
|
||||
|
||||
// 事件处理
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page < 1 || page > totalPages.value || page === props.currentPage) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:currentPage', page)
|
||||
emit('change', page, currentPageSize.value)
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
currentPageSize.value = size
|
||||
emit('update:pageSize', size)
|
||||
emit('pageSizeChange', size)
|
||||
|
||||
// 调整当前页码,确保不超出范围
|
||||
const newTotalPages = Math.ceil(props.total / size)
|
||||
if (props.currentPage > newTotalPages) {
|
||||
handlePageChange(newTotalPages)
|
||||
} else {
|
||||
emit('change', props.currentPage, size)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickJump = () => {
|
||||
const page = parseInt(jumpPage.value)
|
||||
if (isNaN(page) || page < 1 || page > totalPages.value) {
|
||||
jumpPage.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
handlePageChange(page)
|
||||
jumpPage.value = ''
|
||||
}
|
||||
|
||||
// 监听 pageSize 变化
|
||||
watch(() => props.pageSize, (newSize) => {
|
||||
currentPageSize.value = newSize
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 分页信息 */
|
||||
.pagination-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--text-color-regular);
|
||||
}
|
||||
|
||||
.info-text strong,
|
||||
.info-range strong {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-range {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* 分页控件 */
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 每页显示数量选择器 */
|
||||
.page-size-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-color-regular);
|
||||
}
|
||||
|
||||
.size-select {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
/* 分页按钮 */
|
||||
.pagination-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 页码按钮 */
|
||||
.page-numbers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.page-number:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-number.el-button--primary {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
padding: 0 4px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 简单模式 */
|
||||
.simple-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-color-regular);
|
||||
}
|
||||
|
||||
.current-page {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-separator {
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.total-pages {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* 快速跳转 */
|
||||
.quick-jumper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-color-regular);
|
||||
}
|
||||
|
||||
.jump-input {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.jump-input :deep(.el-input__wrapper) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.pagination-container {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination-buttons {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-btn,
|
||||
.page-number {
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.first-btn span,
|
||||
.last-btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-size-selector,
|
||||
.quick-jumper {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pagination-buttons {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.page-numbers {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.page-btn,
|
||||
.page-number {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
font-size: 11px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.prev-btn span,
|
||||
.next-btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
font-size: 12px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高句丽主题样式 */
|
||||
.pagination-container {
|
||||
border-top: 1px solid var(--border-color-light);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.8) 0%, rgba(248, 249, 250, 0.8) 100%);
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.page-number.el-button--primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.quick-jumper .jump-input :deep(.el-input__wrapper):focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
698
src/components/SearchBox.vue
Executable file
698
src/components/SearchBox.vue
Executable file
@@ -0,0 +1,698 @@
|
||||
<template>
|
||||
<div class="search-box-container">
|
||||
<!-- 搜索输入框 -->
|
||||
<div class="search-input-wrapper" :class="{ 'focused': isFocused, 'expanded': isExpanded }">
|
||||
<el-input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
:placeholder="placeholder"
|
||||
:size="size"
|
||||
:clearable="clearable"
|
||||
:disabled="disabled"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
@input="handleInput"
|
||||
@keyup.enter="handleSearch"
|
||||
@keyup.up="handleKeyUp"
|
||||
@keyup.down="handleKeyDown"
|
||||
@keyup.esc="handleEscape"
|
||||
class="search-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon class="search-icon">
|
||||
<Search />
|
||||
</el-icon>
|
||||
</template>
|
||||
<template #suffix v-if="showSearchButton">
|
||||
<el-button
|
||||
type="primary"
|
||||
:size="size"
|
||||
:loading="loading"
|
||||
@click="handleSearch"
|
||||
class="search-button"
|
||||
>
|
||||
搜索
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<!-- 搜索建议下拉框 -->
|
||||
<div
|
||||
v-if="showSuggestions && (suggestions.length > 0 || searchHistory.length > 0)"
|
||||
class="suggestions-dropdown"
|
||||
:class="{ 'show': isFocused || showDropdown }"
|
||||
>
|
||||
<!-- 搜索建议 -->
|
||||
<div v-if="suggestions.length > 0" class="suggestions-section">
|
||||
<div class="section-title">
|
||||
<el-icon><Compass /></el-icon>
|
||||
搜索建议
|
||||
</div>
|
||||
<div
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="`suggestion-${index}`"
|
||||
:class="{ 'active': selectedIndex === index }"
|
||||
@click="handleSuggestionClick(suggestion)"
|
||||
@mouseenter="selectedIndex = index"
|
||||
class="suggestion-item"
|
||||
>
|
||||
<el-icon class="suggestion-icon"><Search /></el-icon>
|
||||
<span class="suggestion-text" v-html="highlightMatch(suggestion)"></span>
|
||||
<el-icon class="arrow-icon"><Top /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<div v-if="searchHistory.length > 0 && showHistory" class="history-section">
|
||||
<div class="section-title">
|
||||
<el-icon><Clock /></el-icon>
|
||||
搜索历史
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="clearHistory"
|
||||
class="clear-history-btn"
|
||||
>
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(history, index) in displayHistory"
|
||||
:key="`history-${index}`"
|
||||
:class="{ 'active': selectedIndex === suggestions.length + index }"
|
||||
@click="handleHistoryClick(history)"
|
||||
@mouseenter="selectedIndex = suggestions.length + index"
|
||||
class="history-item"
|
||||
>
|
||||
<el-icon class="history-icon"><Clock /></el-icon>
|
||||
<span class="history-text">{{ history }}</span>
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click.stop="removeHistory(history)"
|
||||
class="remove-history-btn"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门搜索 -->
|
||||
<div v-if="hotKeywords.length > 0 && showHotKeywords" class="hot-keywords-section">
|
||||
<div class="section-title">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
热门搜索
|
||||
</div>
|
||||
<div class="hot-keywords-list">
|
||||
<el-tag
|
||||
v-for="(keyword, index) in hotKeywords"
|
||||
:key="`hot-${index}`"
|
||||
:type="index < 3 ? 'danger' : 'info'"
|
||||
:effect="index < 3 ? 'dark' : 'plain'"
|
||||
@click="handleHotKeywordClick(keyword)"
|
||||
class="hot-keyword-tag"
|
||||
>
|
||||
<span class="hot-rank" v-if="index < 3">{{ index + 1 }}</span>
|
||||
{{ keyword }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索过滤器 -->
|
||||
<div v-if="showFilters && isExpanded" class="search-filters">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">分类:</span>
|
||||
<el-radio-group v-model="selectedCategory" @change="handleFilterChange">
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="video">视频</el-radio-button>
|
||||
<el-radio-button label="user">用户</el-radio-button>
|
||||
<el-radio-button label="tag">标签</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">排序:</span>
|
||||
<el-select v-model="sortBy" @change="handleFilterChange" size="small">
|
||||
<el-option label="相关度" value="relevance" />
|
||||
<el-option label="最新" value="latest" />
|
||||
<el-option label="最热" value="popular" />
|
||||
<el-option label="播放量" value="views" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import {
|
||||
Search,
|
||||
Compass,
|
||||
Clock,
|
||||
Close,
|
||||
Top,
|
||||
TrendCharts
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
size?: 'large' | 'default' | 'small'
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
showSearchButton?: boolean
|
||||
showSuggestions?: boolean
|
||||
showHistory?: boolean
|
||||
showHotKeywords?: boolean
|
||||
showFilters?: boolean
|
||||
maxHistory?: number
|
||||
maxSuggestions?: number
|
||||
debounceTime?: number
|
||||
suggestions?: string[]
|
||||
hotKeywords?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
placeholder: '搜索视频、用户...',
|
||||
size: 'default',
|
||||
clearable: true,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
showSearchButton: false,
|
||||
showSuggestions: true,
|
||||
showHistory: true,
|
||||
showHotKeywords: true,
|
||||
showFilters: false,
|
||||
maxHistory: 10,
|
||||
maxSuggestions: 8,
|
||||
debounceTime: 300,
|
||||
suggestions: () => [],
|
||||
hotKeywords: () => ['高句丽历史', '古代建筑', '文化遗产', '考古发现', '历史纪录片']
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'search': [query: string, filters: any]
|
||||
'suggestion': [query: string]
|
||||
'focus': []
|
||||
'blur': []
|
||||
'clear': []
|
||||
}>()
|
||||
|
||||
// 响应式数据
|
||||
const searchInputRef = ref()
|
||||
const searchQuery = ref(props.modelValue)
|
||||
const isFocused = ref(false)
|
||||
const isExpanded = ref(false)
|
||||
const showDropdown = ref(false)
|
||||
const selectedIndex = ref(-1)
|
||||
const selectedCategory = ref('all')
|
||||
const sortBy = ref('relevance')
|
||||
const debounceTimer = ref<NodeJS.Timeout>()
|
||||
|
||||
// 搜索历史(从 localStorage 获取)
|
||||
const searchHistory = ref<string[]>([])
|
||||
|
||||
// 计算属性
|
||||
const displayHistory = computed(() => {
|
||||
return searchHistory.value.slice(0, props.maxHistory)
|
||||
})
|
||||
|
||||
const totalSuggestions = computed(() => {
|
||||
return props.suggestions.length + displayHistory.value.length
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadSearchHistory()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
// 监听器
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
searchQuery.value = newValue
|
||||
})
|
||||
|
||||
watch(searchQuery, (newValue) => {
|
||||
emit('update:modelValue', newValue)
|
||||
|
||||
// 防抖处理搜索建议
|
||||
if (debounceTimer.value) {
|
||||
clearTimeout(debounceTimer.value)
|
||||
}
|
||||
|
||||
if (newValue.trim()) {
|
||||
debounceTimer.value = setTimeout(() => {
|
||||
emit('suggestion', newValue.trim())
|
||||
}, props.debounceTime)
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
const loadSearchHistory = () => {
|
||||
try {
|
||||
const history = localStorage.getItem('search-history')
|
||||
if (history) {
|
||||
searchHistory.value = JSON.parse(history)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load search history:', error)
|
||||
searchHistory.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const saveSearchHistory = () => {
|
||||
try {
|
||||
localStorage.setItem('search-history', JSON.stringify(searchHistory.value))
|
||||
} catch (error) {
|
||||
console.error('Failed to save search history:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const addToHistory = (query: string) => {
|
||||
if (!query.trim()) return
|
||||
|
||||
// 移除重复项
|
||||
const index = searchHistory.value.indexOf(query)
|
||||
if (index > -1) {
|
||||
searchHistory.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 添加到开头
|
||||
searchHistory.value.unshift(query)
|
||||
|
||||
// 限制历史记录数量
|
||||
if (searchHistory.value.length > props.maxHistory) {
|
||||
searchHistory.value = searchHistory.value.slice(0, props.maxHistory)
|
||||
}
|
||||
|
||||
saveSearchHistory()
|
||||
}
|
||||
|
||||
const clearHistory = () => {
|
||||
searchHistory.value = []
|
||||
saveSearchHistory()
|
||||
}
|
||||
|
||||
const removeHistory = (query: string) => {
|
||||
const index = searchHistory.value.indexOf(query)
|
||||
if (index > -1) {
|
||||
searchHistory.value.splice(index, 1)
|
||||
saveSearchHistory()
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
isFocused.value = true
|
||||
isExpanded.value = true
|
||||
showDropdown.value = true
|
||||
selectedIndex.value = -1
|
||||
emit('focus')
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
// 延迟隐藏,允许点击建议项
|
||||
setTimeout(() => {
|
||||
isFocused.value = false
|
||||
showDropdown.value = false
|
||||
}, 200)
|
||||
emit('blur')
|
||||
}
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
if (!value.trim()) {
|
||||
emit('clear')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
const query = searchQuery.value.trim()
|
||||
if (!query) return
|
||||
|
||||
addToHistory(query)
|
||||
|
||||
const filters = {
|
||||
category: selectedCategory.value,
|
||||
sortBy: sortBy.value
|
||||
}
|
||||
|
||||
emit('search', query, filters)
|
||||
|
||||
// 隐藏下拉框
|
||||
showDropdown.value = false
|
||||
searchInputRef.value?.blur()
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
searchQuery.value = suggestion
|
||||
nextTick(() => {
|
||||
handleSearch()
|
||||
})
|
||||
}
|
||||
|
||||
const handleHistoryClick = (history: string) => {
|
||||
searchQuery.value = history
|
||||
nextTick(() => {
|
||||
handleSearch()
|
||||
})
|
||||
}
|
||||
|
||||
const handleHotKeywordClick = (keyword: string) => {
|
||||
searchQuery.value = keyword
|
||||
nextTick(() => {
|
||||
handleSearch()
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyUp = () => {
|
||||
if (selectedIndex.value > 0) {
|
||||
selectedIndex.value--
|
||||
} else {
|
||||
selectedIndex.value = totalSuggestions.value - 1
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = () => {
|
||||
if (selectedIndex.value < totalSuggestions.value - 1) {
|
||||
selectedIndex.value++
|
||||
} else {
|
||||
selectedIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = () => {
|
||||
showDropdown.value = false
|
||||
selectedIndex.value = -1
|
||||
searchInputRef.value?.blur()
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const container = document.querySelector('.search-box-container')
|
||||
if (container && !container.contains(target)) {
|
||||
showDropdown.value = false
|
||||
isExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
if (searchQuery.value.trim()) {
|
||||
const filters = {
|
||||
category: selectedCategory.value,
|
||||
sortBy: sortBy.value
|
||||
}
|
||||
emit('search', searchQuery.value.trim(), filters)
|
||||
}
|
||||
}
|
||||
|
||||
const highlightMatch = (text: string) => {
|
||||
const query = searchQuery.value.trim()
|
||||
if (!query) return text
|
||||
|
||||
const regex = new RegExp(`(${query})`, 'gi')
|
||||
return text.replace(regex, '<mark>$1</mark>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-box-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 搜索输入框 */
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input-wrapper.focused {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.search-input-wrapper.expanded {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--border-color-light);
|
||||
transition: all 0.3s ease;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper):hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper.is-focus) {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: var(--text-color-secondary);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input-wrapper.focused .search-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.search-button {
|
||||
border-radius: 6px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 建议下拉框 */
|
||||
.suggestions-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.suggestions-dropdown.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* 区域标题 */
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
background: var(--bg-color-page);
|
||||
border-bottom: 1px solid var(--border-color-lighter);
|
||||
}
|
||||
|
||||
.clear-history-btn {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* 建议项 */
|
||||
.suggestion-item,
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border-bottom: 1px solid var(--border-color-lighter);
|
||||
}
|
||||
|
||||
.suggestion-item:hover,
|
||||
.history-item:hover,
|
||||
.suggestion-item.active,
|
||||
.history-item.active {
|
||||
background: var(--primary-color-light-9);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.suggestion-item:last-child,
|
||||
.history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.suggestion-icon,
|
||||
.history-icon {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.suggestion-text,
|
||||
.history-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text-color-regular);
|
||||
}
|
||||
|
||||
.suggestion-text :deep(mark) {
|
||||
background: var(--primary-color-light-8);
|
||||
color: var(--primary-color);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
color: var(--text-color-placeholder);
|
||||
font-size: 12px;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.remove-history-btn {
|
||||
color: var(--text-color-placeholder);
|
||||
font-size: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.remove-history-btn:hover {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* 热门搜索 */
|
||||
.hot-keywords-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.hot-keyword-tag {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hot-keyword-tag:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.hot-rank {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
background: white;
|
||||
color: var(--danger-color);
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 搜索过滤器 */
|
||||
.search-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
background: var(--bg-color-page);
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-color-regular);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.search-filters {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hot-keywords-list {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hot-keyword-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.suggestions-dropdown {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.suggestion-item,
|
||||
.history-item {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.suggestions-dropdown::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.suggestions-dropdown::-webkit-scrollbar-track {
|
||||
background: var(--bg-color-page);
|
||||
}
|
||||
|
||||
.suggestions-dropdown::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color-base);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.suggestions-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
817
src/components/VideoCard.vue
Executable file
817
src/components/VideoCard.vue
Executable file
@@ -0,0 +1,817 @@
|
||||
<template>
|
||||
<div
|
||||
ref="cardRef"
|
||||
class="video-card"
|
||||
:class="{
|
||||
'horizontal': layout === 'horizontal',
|
||||
'clickable': clickable && !loading
|
||||
}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- 视频封面区域 -->
|
||||
<div class="video-cover" :class="`cover-${layout}`">
|
||||
<div class="cover-image">
|
||||
<!-- 缩略图加载状态 -->
|
||||
<div v-if="imageLoading" class="thumbnail-loading">
|
||||
<Loading type="pulse" size="small" />
|
||||
</div>
|
||||
|
||||
<div v-if="showPlaceholder" class="thumbnail-placeholder">
|
||||
<el-icon class="placeholder-icon"><Picture /></el-icon>
|
||||
<span class="placeholder-text">{{ video.title }}</span>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-show="!imageLoading && !imageError && !showPlaceholder"
|
||||
:src="thumbnailUrl"
|
||||
:alt="video.title"
|
||||
class="video-thumbnail"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@loadstart="imageLoading = true"
|
||||
/>
|
||||
|
||||
<div v-if="imageLoading || isGeneratingThumbnail" class="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="loading-text">
|
||||
{{ video.thumbnail ? '加载封面中...' : '提取视频帧中...' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="cover-overlay">
|
||||
<div class="play-button">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
</div>
|
||||
<div class="video-duration" v-if="video.duration">
|
||||
{{ formatDuration(video.duration) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频标记 -->
|
||||
<div class="video-badges" v-if="showBadges">
|
||||
<span class="badge badge-new" v-if="isNew">新</span>
|
||||
<span class="badge badge-hot" v-if="isHot">热</span>
|
||||
<span v-if="video.status === 'featured'" class="badge featured">荐</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频信息区域 -->
|
||||
<div class="video-info" :class="`info-${layout}`">
|
||||
<h3 class="video-title" :title="video.title">
|
||||
{{ video.title }}
|
||||
</h3>
|
||||
|
||||
<div class="video-meta">
|
||||
<div class="meta-row">
|
||||
<span class="meta-item views">
|
||||
<el-icon><View /></el-icon>
|
||||
{{ formatNumber(video.views) }} 次观看
|
||||
</span>
|
||||
<span class="meta-item likes" v-if="showLikes">
|
||||
<el-icon><Like /></el-icon>
|
||||
{{ formatNumber(video.likes || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="meta-row">
|
||||
<span class="meta-item category" v-if="video.category">
|
||||
<el-icon><Folder /></el-icon>
|
||||
{{ video.category }}
|
||||
</span>
|
||||
<span class="meta-item upload-time" v-if="showUploader">
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ formatTime(video.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="video-description" v-if="showDescription && video.description">
|
||||
{{ truncateText(video.description, 100) }}
|
||||
</p>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="video-actions" v-if="showActions">
|
||||
<el-button size="small" type="primary" @click.stop="handlePlay">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
播放
|
||||
</el-button>
|
||||
<el-button size="small" @click.stop="handleLike">
|
||||
<el-icon><Like /></el-icon>
|
||||
{{ video.likes || 0 }}
|
||||
</el-button>
|
||||
<el-button size="small" @click.stop="handleShare">
|
||||
<el-icon><Share /></el-icon>
|
||||
分享
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div class="loading-overlay" v-if="loading">
|
||||
<Loading type="pulse" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
VideoPlay,
|
||||
View,
|
||||
StarFilled as Like,
|
||||
User,
|
||||
Clock,
|
||||
Share,
|
||||
CaretRight as Play,
|
||||
Picture,
|
||||
Folder
|
||||
} from '@element-plus/icons-vue'
|
||||
import type { Video } from '@/stores/video'
|
||||
import Loading from './Loading.vue'
|
||||
|
||||
interface Props {
|
||||
video: Video
|
||||
layout?: 'vertical' | 'horizontal'
|
||||
showBadges?: boolean
|
||||
showLikes?: boolean
|
||||
showUploader?: boolean
|
||||
showDescription?: boolean
|
||||
showActions?: boolean
|
||||
clickable?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'vertical',
|
||||
showBadges: true,
|
||||
showLikes: true,
|
||||
showUploader: true,
|
||||
showDescription: false,
|
||||
showActions: false,
|
||||
clickable: true,
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [video: Video]
|
||||
play: [video: Video]
|
||||
like: [video: Video]
|
||||
share: [video: Video]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const imageLoading = ref(false)
|
||||
const imageError = ref(false)
|
||||
const isGeneratingThumbnail = ref(false)
|
||||
|
||||
// 默认封面列表
|
||||
const defaultCovers = [
|
||||
'https://trae-api-us.mchost.guru/api/ide/v1/text_to_image?prompt=modern%20video%20player%20interface%20with%20play%20button%20dark%20theme&image_size=landscape_16_9',
|
||||
'https://trae-api-us.mchost.guru/api/ide/v1/text_to_image?prompt=abstract%20video%20thumbnail%20placeholder%20with%20film%20strip%20pattern&image_size=landscape_16_9',
|
||||
'https://trae-api-us.mchost.guru/api/ide/v1/text_to_image?prompt=minimalist%20video%20cover%20design%20with%20gradient%20background&image_size=landscape_16_9'
|
||||
]
|
||||
|
||||
const showPlaceholder = ref(false)
|
||||
|
||||
// 获取视频封面帧URL
|
||||
const getVideoFrameUrl = (videoId: number, timestamp: number = 1): string => {
|
||||
return `/api/videos/${videoId}/frame?t=${timestamp}`
|
||||
}
|
||||
|
||||
// 视频封面URL计算属性
|
||||
const thumbnailUrl = computed(() => {
|
||||
if (props.video.cover_image) {
|
||||
return props.video.cover_image
|
||||
}
|
||||
if (props.video.thumbnail) {
|
||||
return props.video.thumbnail
|
||||
}
|
||||
// 如果没有封面图,使用视频帧提取
|
||||
return `/api/videos/${props.video.id}/frame?t=1`
|
||||
})
|
||||
|
||||
// 预加载视频封面帧(可选的优化)
|
||||
const preloadVideoFrame = async () => {
|
||||
if (!props.video.id || isGeneratingThumbnail.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isGeneratingThumbnail.value = true
|
||||
|
||||
try {
|
||||
// 预加载封面帧以提升用户体验
|
||||
const frameUrl = getVideoFrameUrl(props.video.id, 1)
|
||||
const img = new Image()
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('封面帧加载超时'))
|
||||
}, 10000)
|
||||
|
||||
img.onload = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
reject(new Error('封面帧加载失败'))
|
||||
}
|
||||
|
||||
img.src = frameUrl
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.warn('预加载封面帧失败:', error)
|
||||
} finally {
|
||||
isGeneratingThumbnail.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const isNew = computed(() => {
|
||||
if (!props.video.createdAt) return false
|
||||
const createTime = new Date(props.video.createdAt).getTime()
|
||||
const now = Date.now()
|
||||
const daysDiff = (now - createTime) / (1000 * 60 * 60 * 24)
|
||||
return daysDiff <= 7 // 7天内为新视频
|
||||
})
|
||||
|
||||
const isHot = computed(() => {
|
||||
return props.video.views > 10000 // 观看量超过1万为热门
|
||||
})
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string): string => {
|
||||
if (!dateString) {
|
||||
return '未知时间'
|
||||
}
|
||||
|
||||
const date = new Date(dateString)
|
||||
|
||||
// 检查日期是否有效
|
||||
if (isNaN(date.getTime())) {
|
||||
return '未知时间'
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// 如果时间差为负数(未来时间),直接返回格式化日期
|
||||
if (diff < 0) {
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (minutes < 1) {
|
||||
return '刚刚'
|
||||
} else if (minutes < 60) {
|
||||
return `${minutes}分钟前`
|
||||
} else if (hours < 24) {
|
||||
return `${hours}小时前`
|
||||
} else if (days < 30) {
|
||||
return `${days}天前`
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
}
|
||||
|
||||
// 截断文本
|
||||
const truncateText = (text: string, maxLength: number): string => {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
const handleClick = () => {
|
||||
if (!props.clickable) return
|
||||
emit('click', props.video)
|
||||
router.push(`/video/${props.video.id}`)
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
emit('play', props.video)
|
||||
router.push(`/video/${props.video.id}?autoplay=1`)
|
||||
}
|
||||
|
||||
const handleLike = () => {
|
||||
emit('like', props.video)
|
||||
ElMessage.success('点赞成功')
|
||||
}
|
||||
|
||||
const handleShare = () => {
|
||||
emit('share', props.video)
|
||||
// 复制分享链接到剪贴板
|
||||
const shareUrl = `${window.location.origin}/video/${props.video.id}`
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
ElMessage.success('分享链接已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败,请手动复制链接')
|
||||
})
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
console.log('图片加载成功:', {
|
||||
videoId: props.video.id,
|
||||
thumbnailUrl: thumbnailUrl.value
|
||||
})
|
||||
|
||||
imageLoading.value = false
|
||||
imageError.value = false
|
||||
showPlaceholder.value = false
|
||||
}
|
||||
|
||||
const handleImageError = (event: Event) => {
|
||||
console.error('图片加载失败:', {
|
||||
videoId: props.video.id,
|
||||
thumbnailUrl: thumbnailUrl.value,
|
||||
event
|
||||
})
|
||||
|
||||
imageError.value = true
|
||||
imageLoading.value = false
|
||||
|
||||
// 尝试使用默认封面作为回退
|
||||
const img = event.target as HTMLImageElement
|
||||
const currentSrc = img.src
|
||||
|
||||
// 如果当前不是默认封面,尝试使用默认封面
|
||||
if (!currentSrc.includes('test_cover.jpg')) {
|
||||
img.src = '/uploads/covers/test_cover.jpg'
|
||||
imageError.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 如果默认封面也失败,显示占位符
|
||||
showPlaceholder.value = true
|
||||
}
|
||||
// 使用Intersection Observer实现懒加载优化
|
||||
const cardRef = ref<HTMLElement>()
|
||||
const isVisible = ref(false)
|
||||
|
||||
// 组件挂载时设置观察器
|
||||
onMounted(() => {
|
||||
if (cardRef.value) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
isVisible.value = true
|
||||
// 预加载视频封面帧以提升用户体验
|
||||
if (!props.video.thumbnail) {
|
||||
setTimeout(() => {
|
||||
preloadVideoFrame()
|
||||
}, 500)
|
||||
}
|
||||
observer.unobserve(entry.target)
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
rootMargin: '50px',
|
||||
threshold: 0.1
|
||||
}
|
||||
)
|
||||
observer.observe(cardRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 强制重新加载封面帧
|
||||
const forceReloadFrame = () => {
|
||||
preloadVideoFrame()
|
||||
}
|
||||
|
||||
// 生成的缩略图URL引用
|
||||
const generatedThumbnail = ref<string | null>(null)
|
||||
|
||||
// 强制重新生成缩略图
|
||||
const forceRegenerateThumbnail = async () => {
|
||||
// 清理之前生成的缩略图
|
||||
if (generatedThumbnail.value) {
|
||||
URL.revokeObjectURL(generatedThumbnail.value)
|
||||
generatedThumbnail.value = null
|
||||
}
|
||||
|
||||
// 重新加载封面帧
|
||||
await preloadVideoFrame()
|
||||
}
|
||||
|
||||
// 监听全局重新生成事件
|
||||
const handleRegenerateAll = () => {
|
||||
forceRegenerateThumbnail()
|
||||
}
|
||||
|
||||
// 组件挂载时添加事件监听器
|
||||
onMounted(() => {
|
||||
window.addEventListener('regenerate-all-thumbnails', handleRegenerateAll)
|
||||
})
|
||||
|
||||
// 清理生成的缩略图URL
|
||||
onUnmounted(() => {
|
||||
if (generatedThumbnail.value) {
|
||||
URL.revokeObjectURL(generatedThumbnail.value)
|
||||
}
|
||||
window.removeEventListener('regenerate-all-thumbnails', handleRegenerateAll)
|
||||
})
|
||||
|
||||
// 暴露方法供外部调用
|
||||
defineExpose({
|
||||
forceReloadFrame
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color-light);
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.video-card.card-horizontal {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 视频封面 */
|
||||
.video-cover {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cover-vertical {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cover-horizontal {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-image img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
background: var(--bg-color-light);
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: crisp-edges;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-card:hover .cover-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.cover-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.video-card:hover .cover-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: var(--primary-color);
|
||||
transform: scale(0.8);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.video-card:hover .play-button {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.video-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 视频标记 */
|
||||
.video-badges {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.badge-hot {
|
||||
background: var(--danger-color);
|
||||
}
|
||||
|
||||
.badge-recommended {
|
||||
background: var(--accent-color);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* 视频信息 */
|
||||
.video-info {
|
||||
padding: 12px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-horizontal {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-primary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.meta-item .el-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.views .el-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.likes .el-icon {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.uploader .el-icon {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.upload-time .el-icon {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.video-description {
|
||||
font-size: 13px;
|
||||
color: var(--text-color-regular);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.video-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.video-actions .el-button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 缩略图加载状态 */
|
||||
.thumbnail-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-color-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* 缩略图占位符 */
|
||||
.thumbnail-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 0 8px;
|
||||
line-height: 1.4;
|
||||
max-height: 2.8em;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.cover-image img[v-show="false"] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.video-card.card-horizontal {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cover-vertical {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.cover-horizontal {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.cover-vertical {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.cover-horizontal {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.video-actions .el-button {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
src/composables/useTheme.ts
Executable file
38
src/composables/useTheme.ts
Executable file
@@ -0,0 +1,38 @@
|
||||
import { ref, watchEffect, onMounted, computed } from 'vue'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
export function useTheme() {
|
||||
const theme = ref<Theme>('light')
|
||||
|
||||
const getPreferredTheme = (): Theme => {
|
||||
const saved = localStorage.getItem('theme') as Theme | null
|
||||
if (saved === 'light' || saved === 'dark') return saved
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
const applyTheme = (t: Theme) => {
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(t)
|
||||
localStorage.setItem('theme', t)
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
theme.value = getPreferredTheme()
|
||||
applyTheme(theme.value)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
applyTheme(theme.value)
|
||||
})
|
||||
|
||||
return {
|
||||
theme,
|
||||
toggleTheme,
|
||||
isDark: computed(() => theme.value === 'dark'),
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Executable file
6
src/lib/utils.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
27
src/main.ts
Executable file
27
src/main.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import './styles/theme.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './stores'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import { useUserStore } from './stores/user'
|
||||
|
||||
// 创建Vue应用实例
|
||||
const app = createApp(App)
|
||||
|
||||
// 使用插件
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
// 初始化用户状态
|
||||
const userStore = useUserStore()
|
||||
userStore.initUser()
|
||||
|
||||
// 挂载应用
|
||||
app.mount('#app')
|
||||
886
src/pages/HomePage.vue
Executable file
886
src/pages/HomePage.vue
Executable file
@@ -0,0 +1,886 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<!-- 页面头部 -->
|
||||
<Header />
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<main class="main-content">
|
||||
|
||||
|
||||
<!-- 悬浮搜索按钮 -->
|
||||
<div class="floating-search-btn" @click="toggleSearchModal">
|
||||
<el-icon :size="24">
|
||||
<Search />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<!-- 搜索模态框 -->
|
||||
<el-dialog
|
||||
v-model="showSearchModal"
|
||||
title="搜索视频"
|
||||
width="80%"
|
||||
:show-close="true"
|
||||
center
|
||||
class="search-modal"
|
||||
>
|
||||
<div class="modal-search-container">
|
||||
<SearchBox
|
||||
v-model="searchQuery"
|
||||
:show-filters="true"
|
||||
:show-hot-keywords="true"
|
||||
@search="handleSearchFromModal"
|
||||
class="modal-search"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 分类导航 -->
|
||||
<section class="categories-section">
|
||||
|
||||
</section>
|
||||
|
||||
<!-- 视频列表区域 -->
|
||||
<section class="videos-section">
|
||||
<div class="videos-container">
|
||||
<!-- 筛选和排序 -->
|
||||
<div class="videos-header">
|
||||
<div class="header-left">
|
||||
<h3 class="section-title">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
{{ currentSectionTitle }}
|
||||
</h3>
|
||||
<span class="videos-count">共 {{ totalVideos }} 个视频</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-select
|
||||
v-model="sortBy"
|
||||
@change="handleSortChange"
|
||||
size="small"
|
||||
class="sort-select"
|
||||
>
|
||||
<el-option label="最新发布" value="latest" />
|
||||
<el-option label="最多播放" value="popular" />
|
||||
<el-option label="最多点赞" value="likes" />
|
||||
<el-option label="时长排序" value="duration" />
|
||||
</el-select>
|
||||
<el-button-group class="view-mode-group">
|
||||
<el-button
|
||||
:type="viewMode === 'grid' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'grid'"
|
||||
size="small"
|
||||
>
|
||||
<el-icon><Grid /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="viewMode === 'list' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'list'"
|
||||
size="small"
|
||||
>
|
||||
<el-icon><List /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<Loading v-if="loading" type="goguryeo" />
|
||||
|
||||
<!-- 视频网格/列表 -->
|
||||
<div
|
||||
v-else-if="videos.length > 0"
|
||||
class="videos-grid"
|
||||
:class="{
|
||||
'grid-mode': viewMode === 'grid',
|
||||
'list-mode': viewMode === 'list'
|
||||
}"
|
||||
>
|
||||
<VideoCard
|
||||
v-for="video in videos"
|
||||
:key="video.id"
|
||||
:video="video"
|
||||
:view-mode="viewMode"
|
||||
@click="handleVideoClick(video)"
|
||||
@like="handleVideoLike"
|
||||
@share="handleVideoShare"
|
||||
class="video-item"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="empty-state">
|
||||
<el-empty
|
||||
:image-size="200"
|
||||
description="暂无视频内容"
|
||||
>
|
||||
<template #image>
|
||||
<el-icon :size="80" color="var(--text-color-placeholder)">
|
||||
<VideoCamera />
|
||||
</el-icon>
|
||||
</template>
|
||||
<el-button type="primary" @click="handleRefresh">
|
||||
刷新页面
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多指示器 -->
|
||||
<div v-if="hasMore && !loading" class="load-more-indicator">
|
||||
<div class="load-more-text">滚动加载更多...</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中指示器 -->
|
||||
<div v-if="loadingMore" class="loading-more">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>加载更多中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 没有更多数据提示 -->
|
||||
<div v-if="!hasMore && videos.length > 0" class="no-more-data">
|
||||
<span>已加载全部内容</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
|
||||
</main>
|
||||
|
||||
<!-- 页面底部 -->
|
||||
<Footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useVideoStore } from '@/stores/video'
|
||||
import { videoApi, statsApi, categoryApi } from '@/api'
|
||||
import type { Video } from '@/stores/video'
|
||||
import Header from '@/components/Header.vue'
|
||||
import Footer from '@/components/Footer.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import VideoCard from '@/components/VideoCard.vue'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import SearchBox from '@/components/SearchBox.vue'
|
||||
import {
|
||||
VideoPlay,
|
||||
Grid,
|
||||
VideoCamera,
|
||||
List,
|
||||
View,
|
||||
User,
|
||||
Clock,
|
||||
OfficeBuilding as Monument,
|
||||
Picture,
|
||||
Document,
|
||||
Star,
|
||||
Search
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 路由和状态管理
|
||||
const router = useRouter()
|
||||
const videoStore = useVideoStore()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('all')
|
||||
const sortBy = ref('latest')
|
||||
const viewMode = ref<'grid' | 'list'>('grid')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalVideos = ref(0)
|
||||
const hasMore = ref(true)
|
||||
const showSearchModal = ref(false)
|
||||
|
||||
// 搜索相关方法
|
||||
const toggleSearchModal = () => {
|
||||
showSearchModal.value = !showSearchModal.value
|
||||
}
|
||||
|
||||
const handleSearchFromModal = (query: string, filters?: any) => {
|
||||
searchQuery.value = query
|
||||
if (filters) {
|
||||
selectedCategory.value = filters.category || 'all'
|
||||
sortBy.value = filters.sortBy || 'latest'
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
showSearchModal.value = false
|
||||
|
||||
// 重新加载数据
|
||||
loadVideos(false)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 分类数据
|
||||
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 }
|
||||
])
|
||||
|
||||
// 统计数据
|
||||
const stats = ref({
|
||||
totalVideos: 0,
|
||||
totalViews: 0,
|
||||
totalUsers: 0,
|
||||
totalDuration: 0,
|
||||
todayViews: 0
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const videos = computed(() => videoStore.videos)
|
||||
|
||||
const currentSectionTitle = computed(() => {
|
||||
const category = categories.value.find(c => c.id === selectedCategory.value)
|
||||
return category ? category.name : '全部视频'
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadInitialData()
|
||||
// 添加滚动事件监听
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
// 组件卸载时移除滚动监听
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
// 监听器
|
||||
watch([selectedCategory, sortBy], () => {
|
||||
loadVideos(false) // 重新加载数据
|
||||
})
|
||||
|
||||
// 方法
|
||||
const loadInitialData = async () => {
|
||||
await Promise.all([
|
||||
loadVideos(),
|
||||
loadStats(),
|
||||
loadCategories()
|
||||
])
|
||||
}
|
||||
|
||||
const loadVideos = async (append = false) => {
|
||||
try {
|
||||
if (append) {
|
||||
loadingMore.value = true
|
||||
} else {
|
||||
loading.value = true
|
||||
currentPage.value = 1
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
category: selectedCategory.value === 'all' ? undefined : selectedCategory.value,
|
||||
sortBy: sortBy.value,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
|
||||
const response = await videoApi.getVideos(params)
|
||||
|
||||
if (response.code === 200) {
|
||||
const newVideos = response.data.list
|
||||
totalVideos.value = response.data.total
|
||||
|
||||
if (append) {
|
||||
// 追加模式:将新数据添加到现有数据后面
|
||||
const currentVideos = videoStore.videos
|
||||
videoStore.setVideos([...currentVideos, ...newVideos])
|
||||
} else {
|
||||
// 替换模式:替换所有数据
|
||||
videoStore.setVideos(newVideos)
|
||||
}
|
||||
|
||||
// 检查是否还有更多数据
|
||||
hasMore.value = videoStore.videos.length < totalVideos.value
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load videos:', error)
|
||||
ElMessage.warning('视频加载失败,请检查网络连接')
|
||||
// 网络错误时清空视频列表,避免显示过期数据
|
||||
if (!append) {
|
||||
videoStore.setVideos([])
|
||||
totalVideos.value = 0
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await statsApi.getOverallStats()
|
||||
if (response.code === 200) {
|
||||
stats.value = {
|
||||
...response.data,
|
||||
totalDuration: (response.data as any).totalDuration || 0
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error)
|
||||
// 使用模拟数据作为后备
|
||||
stats.value = {
|
||||
totalVideos: 156,
|
||||
totalViews: 89234,
|
||||
totalUsers: 1247,
|
||||
totalDuration: 45678,
|
||||
todayViews: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const response = await categoryApi.getCategories()
|
||||
if (response.code === 200 && response.data) {
|
||||
// 更新分类数据,保留原有的静态分类并添加API返回的分类
|
||||
const apiCategories = response.data
|
||||
|
||||
// 更新现有分类的计数
|
||||
categories.value.forEach(category => {
|
||||
if (category.id !== 'all') {
|
||||
const apiCategory = apiCategories.find((c: any) => c.name === category.name)
|
||||
if (apiCategory) {
|
||||
category.count = apiCategory.videoCount || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 计算全部分类的总数
|
||||
const totalCount = apiCategories.reduce((sum: number, cat: any) => sum + (cat.videoCount || 0), 0)
|
||||
const allCategory = categories.value.find(c => c.id === 'all')
|
||||
if (allCategory) {
|
||||
allCategory.count = totalCount
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories:', error)
|
||||
// 分类加载失败时不显示错误消息,保持静默
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = (query: string, filters?: any) => {
|
||||
searchQuery.value = query
|
||||
if (filters) {
|
||||
selectedCategory.value = filters.category || 'all'
|
||||
sortBy.value = filters.sortBy || 'latest'
|
||||
}
|
||||
|
||||
// 重新加载数据
|
||||
loadVideos(false)
|
||||
}
|
||||
|
||||
const handleCategoryClick = (category: any) => {
|
||||
selectedCategory.value = category.id
|
||||
}
|
||||
|
||||
const handleSortChange = () => {
|
||||
// 排序变化会被watch监听,自动重新加载数据
|
||||
}
|
||||
|
||||
const handleVideoClick = (video: any) => {
|
||||
router.push(`/video/${video.id}`)
|
||||
}
|
||||
|
||||
const handleVideoLike = async (video: Video) => {
|
||||
try {
|
||||
const response = await videoApi.likeVideo(video.id)
|
||||
if (response.data) {
|
||||
// 更新本地状态
|
||||
videoStore.updateVideo(video.id, {
|
||||
likes: response.data.likes
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to like video:', error)
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleVideoShare = (video: any) => {
|
||||
const shareUrl = `${window.location.origin}/video/${video.id}`
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: video.title,
|
||||
text: video.description,
|
||||
url: shareUrl
|
||||
})
|
||||
} else {
|
||||
// 复制到剪贴板
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
ElMessage.success('链接已复制到剪贴板')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多数据
|
||||
const loadMoreVideos = async () => {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
|
||||
currentPage.value += 1
|
||||
await loadVideos(true)
|
||||
}
|
||||
|
||||
// 滚动监听
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const windowHeight = window.innerHeight
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
|
||||
// 当滚动到距离底部200px时开始加载更多
|
||||
if (scrollTop + windowHeight >= documentHeight - 200) {
|
||||
loadMoreVideos()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadVideos(false)
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${minutes}分钟`
|
||||
}
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-color-page);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 悬浮搜索按钮 */
|
||||
.floating-search-btn {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
right: 30px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.floating-search-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
background: var(--primary-color-dark-2);
|
||||
}
|
||||
|
||||
.floating-search-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 搜索模态框 */
|
||||
.search-modal {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.modal-search-container {
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-search {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 分类导航 */
|
||||
.categories-section {
|
||||
padding: 0 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.categories-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-primary);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid var(--border-color-light);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.category-card.active {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-color-light-9);
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
font-size: 14px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* 视频列表区域 */
|
||||
.videos-section {
|
||||
padding: 0 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.videos-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.videos-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.videos-count {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sort-select {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.view-mode-group {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 视频网格 - 瀑布流布局 */
|
||||
.videos-grid.grid-mode {
|
||||
column-count: auto;
|
||||
column-width: 300px;
|
||||
column-gap: 1rem;
|
||||
column-fill: balance;
|
||||
}
|
||||
|
||||
.videos-grid.list-mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
transition: all 0.3s ease;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 0.75rem;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-item:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 无限滚动相关样式 */
|
||||
.load-more-indicator {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-color-light);
|
||||
border-top: 2px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.no-more-data {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-color-placeholder);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 统计信息 */
|
||||
.stats-section {
|
||||
padding: 40px 20px;
|
||||
background: linear-gradient(135deg, var(--primary-color-light-9) 0%, white 100%);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
color: var(--primary-color);
|
||||
background: var(--primary-color-light-9);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-color-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
.search-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.videos-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.videos-grid.grid-mode {
|
||||
column-count: auto;
|
||||
column-width: 280px;
|
||||
column-gap: 0.8rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 悬浮搜索按钮 */
|
||||
.floating-search-btn {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 30px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.floating-search-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
background: var(--primary-color-dark-2);
|
||||
}
|
||||
|
||||
.floating-search-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 搜索模态框样式 */
|
||||
.search-modal {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.search-modal .el-dialog__header {
|
||||
padding: 20px 24px 0;
|
||||
}
|
||||
|
||||
.search-modal .el-dialog__body {
|
||||
padding: 20px 24px 30px;
|
||||
}
|
||||
|
||||
.modal-search-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.main-content {
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.videos-grid.grid-mode {
|
||||
column-count: 1;
|
||||
column-width: auto;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.floating-search-btn {
|
||||
bottom: 100px;
|
||||
right: 20px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.search-modal {
|
||||
width: 95% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
176
src/pages/NotFound.vue
Executable file
176
src/pages/NotFound.vue
Executable file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="not-found-container">
|
||||
<div class="not-found-content">
|
||||
<div class="error-code">404</div>
|
||||
<h1 class="error-title">页面未找到</h1>
|
||||
<p class="error-message">抱歉,您访问的页面不存在或已被移除</p>
|
||||
<div class="actions">
|
||||
<router-link to="/" class="btn-home">
|
||||
<i class="icon-home"></i>
|
||||
返回首页
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="decoration">
|
||||
<div class="ancient-pattern"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
document.title = '页面未找到 - 高句丽文化遗产'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
max-width: 500px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 8rem;
|
||||
font-weight: bold;
|
||||
color: #d4af37;
|
||||
text-shadow: 0 0 20px rgba(212, 175, 55, 0.5);
|
||||
margin-bottom: 1rem;
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2.5rem;
|
||||
color: #ffffff;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1.2rem;
|
||||
color: #b0b0b0;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.btn-home {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 2rem;
|
||||
background: linear-gradient(45deg, #d4af37, #f4d03f);
|
||||
color: #1a1a2e;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(212, 175, 55, 0.3);
|
||||
}
|
||||
|
||||
.btn-home:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(212, 175, 55, 0.4);
|
||||
background: linear-gradient(45deg, #f4d03f, #d4af37);
|
||||
}
|
||||
|
||||
.icon-home::before {
|
||||
content: '🏠';
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.ancient-pattern {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border: 3px solid #d4af37;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, transparent 40%, rgba(212, 175, 55, 0.1) 41%, rgba(212, 175, 55, 0.1) 60%, transparent 61%);
|
||||
}
|
||||
|
||||
.ancient-pattern::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border: 2px solid #d4af37;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ancient-pattern::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 1px solid #d4af37;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-code {
|
||||
font-size: 6rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.ancient-pattern {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.ancient-pattern::before {
|
||||
width: 130px;
|
||||
height: 130px;
|
||||
}
|
||||
|
||||
.ancient-pattern::after {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
861
src/pages/SearchResult.vue
Executable file
861
src/pages/SearchResult.vue
Executable file
@@ -0,0 +1,861 @@
|
||||
<template>
|
||||
<div class="search-result-page">
|
||||
<!-- 搜索头部 -->
|
||||
<div class="search-header">
|
||||
<div class="search-info">
|
||||
<h1 class="search-title">
|
||||
搜索结果:<span class="keyword">{{ searchKeyword }}</span>
|
||||
</h1>
|
||||
<p class="search-meta" v-if="!loading">
|
||||
找到 <span class="count">{{ totalResults }}</span> 个相关结果
|
||||
<span class="time">(用时 {{ searchTime }}ms)</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-box-container">
|
||||
<SearchBox
|
||||
v-model="currentKeyword"
|
||||
:placeholder="'搜索高句丽相关视频...'"
|
||||
:show-filters="true"
|
||||
@search="handleSearch"
|
||||
@filter-change="handleFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选和排序 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-tabs">
|
||||
<button
|
||||
v-for="tab in filterTabs"
|
||||
:key="tab.key"
|
||||
class="filter-tab"
|
||||
:class="{ active: activeFilter === tab.key }"
|
||||
@click="activeFilter = tab.key; handleFilterChange()"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="tab-count" v-if="tab.count !== undefined">({{ tab.count }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sort-controls">
|
||||
<div class="sort-options">
|
||||
<label>排序方式:</label>
|
||||
<select v-model="sortBy" @change="handleSortChange">
|
||||
<option value="relevance">相关度</option>
|
||||
<option value="latest">最新发布</option>
|
||||
<option value="popular">最多播放</option>
|
||||
<option value="duration">时长</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="view-options">
|
||||
<button
|
||||
class="view-btn"
|
||||
:class="{ active: viewMode === 'grid' }"
|
||||
@click="viewMode = 'grid'"
|
||||
title="网格视图"
|
||||
>
|
||||
<i class="icon icon-grid"></i>
|
||||
</button>
|
||||
<button
|
||||
class="view-btn"
|
||||
:class="{ active: viewMode === 'list' }"
|
||||
@click="viewMode = 'list'"
|
||||
title="列表视图"
|
||||
>
|
||||
<i class="icon icon-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果内容 -->
|
||||
<div class="search-content">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<Loading type="goguryeo" />
|
||||
<p class="loading-text">正在搜索高句丽相关内容...</p>
|
||||
</div>
|
||||
|
||||
<!-- 无结果状态 -->
|
||||
<div v-else-if="results.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="icon icon-search-x"></i>
|
||||
</div>
|
||||
<h3 class="empty-title">未找到相关内容</h3>
|
||||
<p class="empty-desc">
|
||||
没有找到与 "{{ searchKeyword }}" 相关的视频内容
|
||||
</p>
|
||||
<div class="empty-suggestions">
|
||||
<p>建议您:</p>
|
||||
<ul>
|
||||
<li>检查关键词拼写是否正确</li>
|
||||
<li>尝试使用更简单的关键词</li>
|
||||
<li>使用相关的高句丽历史术语</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="hot-keywords">
|
||||
<p>热门搜索:</p>
|
||||
<div class="keyword-tags">
|
||||
<button
|
||||
v-for="keyword in hotKeywords"
|
||||
:key="keyword"
|
||||
class="keyword-tag"
|
||||
@click="searchHotKeyword(keyword)"
|
||||
>
|
||||
{{ keyword }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<div v-else class="results-container">
|
||||
<!-- 视频结果 -->
|
||||
<div v-if="activeFilter === 'all' || activeFilter === 'videos'" class="videos-section">
|
||||
<h2 v-if="activeFilter === 'all'" class="section-title">
|
||||
<i class="icon icon-video"></i>
|
||||
视频内容 ({{ videoResults.length }})
|
||||
</h2>
|
||||
|
||||
<div class="videos-grid" :class="{ 'list-view': viewMode === 'list' }">
|
||||
<VideoCard
|
||||
v-for="video in videoResults"
|
||||
:key="video.id"
|
||||
:video="video"
|
||||
:aspect-ratio="'9:16'"
|
||||
:show-user="true"
|
||||
:highlight-keyword="searchKeyword"
|
||||
@click="goToVideo(String(video.id))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户结果 -->
|
||||
<div v-if="(activeFilter === 'all' || activeFilter === 'users') && userResults.length > 0" class="users-section">
|
||||
<h2 v-if="activeFilter === 'all'" class="section-title">
|
||||
<i class="icon icon-users"></i>
|
||||
相关用户 ({{ userResults.length }})
|
||||
</h2>
|
||||
|
||||
<div class="users-grid">
|
||||
<div
|
||||
v-for="user in userResults"
|
||||
:key="user.id"
|
||||
class="user-card"
|
||||
@click="goToUser(user.id)"
|
||||
>
|
||||
<div class="user-avatar">
|
||||
<img :src="user.avatar || '/default-avatar.png'" :alt="user.username" />
|
||||
<div class="user-status" :class="{ online: user.isOnline }"></div>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<h3 class="user-name" v-html="highlightKeyword(user.username, searchKeyword)"></h3>
|
||||
<p class="user-bio" v-html="highlightKeyword(user.bio || '暂无简介', searchKeyword)"></p>
|
||||
<div class="user-stats">
|
||||
<span><i class="icon icon-video"></i> {{ user.videoCount || 0 }} 视频</span>
|
||||
<span><i class="icon icon-users"></i> {{ formatNumber(user.followers || 0) }} 粉丝</span>
|
||||
<span><i class="icon icon-eye"></i> {{ formatNumber(user.totalViews || 0) }} 播放</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button class="follow-btn" :class="{ following: user.isFollowing }">
|
||||
{{ user.isFollowing ? '已关注' : '关注' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<Pagination
|
||||
:current="currentPage"
|
||||
:total="totalResults"
|
||||
:page-size="pageSize"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useVideoStore } from '@/stores/video'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { videoApi, userApi } from '@/api'
|
||||
import VideoCard from '@/components/VideoCard.vue'
|
||||
import SearchBox from '@/components/SearchBox.vue'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import type { Video } from '@/stores/video'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
avatar?: string
|
||||
bio?: string
|
||||
videoCount: number
|
||||
followers: number
|
||||
totalViews: number
|
||||
isOnline: boolean
|
||||
isFollowing: boolean
|
||||
}
|
||||
|
||||
interface SearchFilters {
|
||||
category?: string
|
||||
duration?: string
|
||||
uploadTime?: string
|
||||
sortBy?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const videoStore = useVideoStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 搜索状态
|
||||
const loading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const currentKeyword = ref('')
|
||||
const searchTime = ref(0)
|
||||
const totalResults = ref(0)
|
||||
|
||||
// 筛选和排序
|
||||
const activeFilter = ref('all')
|
||||
const sortBy = ref('relevance')
|
||||
const viewMode = ref('grid')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 搜索结果
|
||||
const results = ref<(Video | User)[]>([])
|
||||
const videoResults = ref<Video[]>([])
|
||||
const userResults = ref<User[]>([])
|
||||
|
||||
// 筛选标签
|
||||
const filterTabs = computed(() => [
|
||||
{ key: 'all', label: '全部', count: totalResults.value },
|
||||
{ key: 'videos', label: '视频', count: videoResults.value.length },
|
||||
{ key: 'users', label: '用户', count: userResults.value.length }
|
||||
])
|
||||
|
||||
// 热门关键词
|
||||
const hotKeywords = ref([
|
||||
'高句丽历史', '古代建筑', '文化遗产', '考古发现',
|
||||
'王朝兴衰', '军事战争', '艺术文化', '民俗传统'
|
||||
])
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// 高亮关键词
|
||||
const highlightKeyword = (text: string, keyword: string): string => {
|
||||
if (!keyword || !text) return text
|
||||
const regex = new RegExp(`(${keyword})`, 'gi')
|
||||
return text.replace(regex, '<mark class="highlight">$1</mark>')
|
||||
}
|
||||
|
||||
// 执行搜索
|
||||
const performSearch = async (keyword: string, filters: SearchFilters = {}) => {
|
||||
if (!keyword.trim()) {
|
||||
ElMessage.warning('请输入搜索关键词')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const startTime = Date.now()
|
||||
|
||||
const searchParams = {
|
||||
keyword: keyword.trim(),
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
filter: activeFilter.value,
|
||||
...filters
|
||||
}
|
||||
|
||||
const response = await videoApi.getVideos(searchParams)
|
||||
|
||||
searchTime.value = Date.now() - startTime
|
||||
totalResults.value = response.data.total
|
||||
videoResults.value = response.data.list || []
|
||||
userResults.value = []
|
||||
|
||||
// 合并结果
|
||||
results.value = [...videoResults.value, ...userResults.value]
|
||||
|
||||
// 更新URL
|
||||
const query = {
|
||||
q: keyword,
|
||||
filter: activeFilter.value,
|
||||
sort: sortBy.value,
|
||||
page: currentPage.value.toString()
|
||||
}
|
||||
router.replace({ query })
|
||||
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
ElMessage.error('搜索失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = (keyword: string, filters?: SearchFilters) => {
|
||||
searchKeyword.value = keyword
|
||||
currentKeyword.value = keyword
|
||||
currentPage.value = 1
|
||||
performSearch(keyword, filters)
|
||||
}
|
||||
|
||||
// 处理筛选变化
|
||||
const handleFilterChange = (filters?: SearchFilters) => {
|
||||
currentPage.value = 1
|
||||
performSearch(searchKeyword.value, filters)
|
||||
}
|
||||
|
||||
// 处理排序变化
|
||||
const handleSortChange = () => {
|
||||
currentPage.value = 1
|
||||
performSearch(searchKeyword.value)
|
||||
}
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
performSearch(searchKeyword.value)
|
||||
// 滚动到顶部
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// 搜索热门关键词
|
||||
const searchHotKeyword = (keyword: string) => {
|
||||
handleSearch(keyword)
|
||||
}
|
||||
|
||||
// 跳转到视频详情
|
||||
const goToVideo = (videoId: string) => {
|
||||
router.push(`/video/${videoId}`)
|
||||
}
|
||||
|
||||
// 跳转到用户详情
|
||||
const goToUser = (userId: string) => {
|
||||
router.push(`/user/${userId}`)
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(() => route.query, (newQuery) => {
|
||||
const keyword = newQuery.q as string
|
||||
const filter = newQuery.filter as string || 'all'
|
||||
const sort = newQuery.sort as string || 'relevance'
|
||||
const page = parseInt(newQuery.page as string) || 1
|
||||
|
||||
if (keyword && keyword !== searchKeyword.value) {
|
||||
searchKeyword.value = keyword
|
||||
currentKeyword.value = keyword
|
||||
activeFilter.value = filter
|
||||
sortBy.value = sort
|
||||
currentPage.value = page
|
||||
performSearch(keyword)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听筛选变化
|
||||
watch(activeFilter, () => {
|
||||
if (searchKeyword.value) {
|
||||
handleFilterChange()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const keyword = route.query.q as string
|
||||
if (keyword) {
|
||||
searchKeyword.value = keyword
|
||||
currentKeyword.value = keyword
|
||||
activeFilter.value = (route.query.filter as string) || 'all'
|
||||
sortBy.value = (route.query.sort as string) || 'relevance'
|
||||
currentPage.value = parseInt(route.query.page as string) || 1
|
||||
performSearch(keyword)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-result-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-info {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #e2e8f0;
|
||||
margin: 0 0 0.5rem 0;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.keyword {
|
||||
color: #d4af37;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.search-meta {
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #d4af37;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.time {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.search-box-container {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
padding: 1rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border-bottom: 3px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-tab:hover {
|
||||
color: #d4af37;
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
color: #d4af37;
|
||||
border-bottom-color: #d4af37;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.sort-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.sort-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sort-options label {
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sort-options select {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(212, 175, 55, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.view-options {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(212, 175, 55, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.view-btn:hover,
|
||||
.view-btn.active {
|
||||
background: rgba(212, 175, 55, 0.2);
|
||||
color: #d4af37;
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.search-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.5rem;
|
||||
color: #e2e8f0;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 2rem 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.empty-suggestions {
|
||||
text-align: left;
|
||||
max-width: 400px;
|
||||
margin: 0 auto 2rem auto;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.empty-suggestions p {
|
||||
color: #d4af37;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.empty-suggestions ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-suggestions li {
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hot-keywords {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hot-keywords p {
|
||||
color: #d4af37;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.keyword-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.keyword-tag {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border: 1px solid rgba(212, 175, 55, 0.3);
|
||||
border-radius: 20px;
|
||||
color: #d4af37;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.keyword-tag:hover {
|
||||
background: rgba(212, 175, 55, 0.2);
|
||||
border-color: #d4af37;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.results-container {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #d4af37;
|
||||
margin: 0 0 2rem 0;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.videos-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.videos-grid.list-view {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.users-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.user-card:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: #d4af37;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #d4af37;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
border: 2px solid #1a1a2e;
|
||||
}
|
||||
|
||||
.user-status.online {
|
||||
background: #4ade80;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #d4af37;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.user-bio {
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 1rem 0;
|
||||
opacity: 0.8;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.user-stats span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.follow-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(135deg, #d4af37, #f1c40f);
|
||||
color: #1a1a2e;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.follow-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 15px rgba(212, 175, 55, 0.4);
|
||||
}
|
||||
|
||||
.follow-btn.following {
|
||||
background: linear-gradient(135deg, #64748b, #94a3b8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
/* 高亮样式 */
|
||||
:deep(.highlight) {
|
||||
background: rgba(212, 175, 55, 0.3);
|
||||
color: #d4af37;
|
||||
padding: 0.1rem 0.2rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.search-result-page {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.search-header,
|
||||
.filter-section,
|
||||
.search-content {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.sort-controls {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.videos-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.users-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.videos-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.keyword-tags {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.keyword-tag {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
823
src/pages/UserDetail.vue
Executable file
823
src/pages/UserDetail.vue
Executable file
@@ -0,0 +1,823 @@
|
||||
<template>
|
||||
<div class="user-detail-page">
|
||||
<!-- 用户信息区域 -->
|
||||
<div class="user-info-section">
|
||||
<div class="user-header">
|
||||
<div class="user-avatar">
|
||||
<img :src="user?.avatar || '/default-avatar.png'" :alt="user?.username" />
|
||||
<div class="user-status" :class="{ online: user?.isOnline }"></div>
|
||||
</div>
|
||||
<div class="user-meta">
|
||||
<h1 class="username">{{ user?.username }}</h1>
|
||||
<p class="user-bio">{{ user?.bio || '这个用户很神秘,什么都没有留下...' }}</p>
|
||||
<div class="user-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ user?.videoCount || 0 }}</span>
|
||||
<span class="stat-label">视频</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ formatNumber(user?.totalViews || 0) }}</span>
|
||||
<span class="stat-label">播放量</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ formatNumber(user?.followers || 0) }}</span>
|
||||
<span class="stat-label">粉丝</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-value">{{ formatNumber(user?.following || 0) }}</span>
|
||||
<span class="stat-label">关注</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-actions" v-if="!isCurrentUser">
|
||||
<button
|
||||
class="follow-btn"
|
||||
:class="{ following: isFollowing }"
|
||||
@click="toggleFollow"
|
||||
:disabled="followLoading"
|
||||
>
|
||||
<i class="icon" :class="isFollowing ? 'icon-user-minus' : 'icon-user-plus'"></i>
|
||||
{{ isFollowing ? '已关注' : '关注' }}
|
||||
</button>
|
||||
<button class="message-btn" @click="sendMessage">
|
||||
<i class="icon icon-message-circle"></i>
|
||||
私信
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="content-section">
|
||||
<!-- 标签页导航 -->
|
||||
<div class="tabs-nav">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="tab-count" v-if="tab.count !== undefined">({{ tab.count }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 视频列表 -->
|
||||
<div v-if="activeTab === 'videos'" class="videos-content">
|
||||
<div class="filter-bar">
|
||||
<div class="sort-options">
|
||||
<select v-model="sortBy" @change="loadVideos">
|
||||
<option value="latest">最新发布</option>
|
||||
<option value="popular">最多播放</option>
|
||||
<option value="oldest">最早发布</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="view-options">
|
||||
<button
|
||||
class="view-btn"
|
||||
:class="{ active: viewMode === 'grid' }"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon icon-grid"></i>
|
||||
</button>
|
||||
<button
|
||||
class="view-btn"
|
||||
:class="{ active: viewMode === 'list' }"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<i class="icon icon-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="videosLoading" class="loading-container">
|
||||
<Loading type="goguryeo" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="videos.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="icon icon-video-off"></i>
|
||||
</div>
|
||||
<p class="empty-text">暂无视频内容</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="videos-grid" :class="{ 'list-view': viewMode === 'list' }">
|
||||
<VideoCard
|
||||
v-for="video in videos"
|
||||
:key="video.id"
|
||||
:video="video"
|
||||
:aspect-ratio="'9:16'"
|
||||
@click="goToVideo(video.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
v-if="totalVideos > pageSize"
|
||||
:current="currentPage"
|
||||
:total="totalVideos"
|
||||
:page-size="pageSize"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 收藏列表 -->
|
||||
<div v-else-if="activeTab === 'favorites'" class="favorites-content">
|
||||
<div v-if="favoritesLoading" class="loading-container">
|
||||
<Loading type="goguryeo" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="favorites.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="icon icon-heart"></i>
|
||||
</div>
|
||||
<p class="empty-text">暂无收藏内容</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="videos-grid">
|
||||
<VideoCard
|
||||
v-for="video in favorites"
|
||||
:key="video.id"
|
||||
:video="video"
|
||||
:aspect-ratio="'9:16'"
|
||||
@click="goToVideo(video.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关注列表 -->
|
||||
<div v-else-if="activeTab === 'following'" class="following-content">
|
||||
<div v-if="followingLoading" class="loading-container">
|
||||
<Loading type="goguryeo" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="followingList.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="icon icon-users"></i>
|
||||
</div>
|
||||
<p class="empty-text">暂无关注用户</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="users-list">
|
||||
<div
|
||||
v-for="followUser in followingList"
|
||||
:key="followUser.id"
|
||||
class="user-item"
|
||||
@click="goToUser(followUser.id)"
|
||||
>
|
||||
<img :src="followUser.avatar || '/default-avatar.png'" :alt="followUser.username" class="user-avatar" />
|
||||
<div class="user-info">
|
||||
<h3 class="user-name">{{ followUser.username }}</h3>
|
||||
<p class="user-desc">{{ followUser.bio || '暂无简介' }}</p>
|
||||
<div class="user-stats">
|
||||
<span>{{ followUser.videoCount || 0 }} 视频</span>
|
||||
<span>{{ formatNumber(followUser.followers || 0) }} 粉丝</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useVideoStore } from '@/stores/video'
|
||||
import { userApi, videoApi } from '@/api'
|
||||
import VideoCard from '@/components/VideoCard.vue'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import type { User } from '@/stores/user'
|
||||
import type { Video } from '@/stores/video'
|
||||
|
||||
interface UserProfile extends User {
|
||||
bio?: string
|
||||
videoCount: number
|
||||
totalViews: number
|
||||
followers: number
|
||||
following: number
|
||||
isOnline: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const videoStore = useVideoStore()
|
||||
|
||||
// 用户信息
|
||||
const user = ref<UserProfile | null>(null)
|
||||
const userLoading = ref(true)
|
||||
|
||||
// 标签页
|
||||
const activeTab = ref('videos')
|
||||
const tabs = computed(() => [
|
||||
{ key: 'videos', label: '视频', count: user.value?.videoCount },
|
||||
{ key: 'favorites', label: '收藏', count: undefined },
|
||||
{ key: 'following', label: '关注', count: user.value?.following }
|
||||
])
|
||||
|
||||
// 视频相关
|
||||
const videos = ref<Video[]>([])
|
||||
const videosLoading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalVideos = ref(0)
|
||||
const sortBy = ref('latest')
|
||||
const viewMode = ref('grid')
|
||||
|
||||
// 收藏相关
|
||||
const favorites = ref<Video[]>([])
|
||||
const favoritesLoading = ref(false)
|
||||
|
||||
// 关注相关
|
||||
const followingList = ref<UserProfile[]>([])
|
||||
const followingLoading = ref(false)
|
||||
const isFollowing = ref(false)
|
||||
const followLoading = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const userId = computed(() => Number(route.params.id))
|
||||
const isCurrentUser = computed(() => userStore.user?.id === userId.value)
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// 加载用户信息
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
userLoading.value = true
|
||||
const response = await userApi.getUserById(userId.value)
|
||||
user.value = {
|
||||
...response.data,
|
||||
videoCount: response.data.video_count || 0,
|
||||
totalViews: response.data.views_count || 0,
|
||||
followers: response.data.followers_count || 0,
|
||||
following: 0,
|
||||
isOnline: false,
|
||||
createdAt: response.data.created_at || ''
|
||||
}
|
||||
|
||||
// 检查是否已关注
|
||||
if (!isCurrentUser.value && userStore.isLoggedIn) {
|
||||
// TODO: 实现检查关注状态的API
|
||||
// const followResponse = await api.checkFollow(userId.value)
|
||||
// isFollowing.value = followResponse.data.isFollowing
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户信息失败:', error)
|
||||
ElMessage.error('加载用户信息失败')
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载视频列表
|
||||
const loadVideos = async () => {
|
||||
try {
|
||||
videosLoading.value = true
|
||||
const response = await userApi.getUserVideos(userId.value, {
|
||||
page: currentPage.value,
|
||||
limit: pageSize.value
|
||||
})
|
||||
videos.value = response.data.list
|
||||
totalVideos.value = response.data.total
|
||||
} catch (error) {
|
||||
console.error('加载视频列表失败:', error)
|
||||
ElMessage.error('加载视频列表失败')
|
||||
} finally {
|
||||
videosLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载收藏列表
|
||||
const loadFavorites = async () => {
|
||||
if (!userStore.isLoggedIn || !isCurrentUser.value) return
|
||||
|
||||
try {
|
||||
favoritesLoading.value = true
|
||||
// TODO: 实现获取用户收藏的API
|
||||
// const response = await api.getUserFavorites()
|
||||
// favorites.value = response.data.videos
|
||||
favorites.value = []
|
||||
} catch (error) {
|
||||
console.error('加载收藏列表失败:', error)
|
||||
ElMessage.error('加载收藏列表失败')
|
||||
} finally {
|
||||
favoritesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载关注列表
|
||||
const loadFollowing = async () => {
|
||||
try {
|
||||
followingLoading.value = true
|
||||
// TODO: 实现获取用户关注列表的API
|
||||
// const response = await api.getUserFollowing(userId.value)
|
||||
// followingList.value = response.data.users
|
||||
followingList.value = []
|
||||
} catch (error) {
|
||||
console.error('加载关注列表失败:', error)
|
||||
ElMessage.error('加载关注列表失败')
|
||||
} finally {
|
||||
followingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换关注状态
|
||||
const toggleFollow = async () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
ElMessage.warning('请先登录')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
followLoading.value = true
|
||||
if (isFollowing.value) {
|
||||
// TODO: 实现取消关注的API
|
||||
// await api.userApi.unfollowUser(userId.value)
|
||||
isFollowing.value = false
|
||||
if (user.value) {
|
||||
user.value.followers--
|
||||
}
|
||||
ElMessage.success('已取消关注')
|
||||
} else {
|
||||
await userApi.followUser(userId.value)
|
||||
isFollowing.value = true
|
||||
if (user.value) {
|
||||
user.value.followers++
|
||||
}
|
||||
ElMessage.success('关注成功')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('关注操作失败:', error)
|
||||
ElMessage.error('操作失败,请重试')
|
||||
} finally {
|
||||
followLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 发送私信
|
||||
const sendMessage = () => {
|
||||
if (!userStore.isLoggedIn) {
|
||||
ElMessage.warning('请先登录')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
// TODO: 实现私信功能
|
||||
ElMessage.info('私信功能开发中...')
|
||||
}
|
||||
|
||||
// 页面变化处理
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
loadVideos()
|
||||
}
|
||||
|
||||
// 跳转到视频详情
|
||||
const goToVideo = (videoId: number) => {
|
||||
router.push(`/video/${videoId}`)
|
||||
}
|
||||
|
||||
// 跳转到用户详情
|
||||
const goToUser = (userId: number) => {
|
||||
router.push(`/user/${userId}`)
|
||||
}
|
||||
|
||||
// 监听标签页变化
|
||||
watch(activeTab, (newTab) => {
|
||||
if (newTab === 'videos' && videos.value.length === 0) {
|
||||
loadVideos()
|
||||
} else if (newTab === 'favorites' && favorites.value.length === 0) {
|
||||
loadFavorites()
|
||||
} else if (newTab === 'following' && followingList.value.length === 0) {
|
||||
loadFollowing()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听用户ID变化
|
||||
watch(userId, () => {
|
||||
loadUser()
|
||||
// 重置数据
|
||||
videos.value = []
|
||||
favorites.value = []
|
||||
followingList.value = []
|
||||
currentPage.value = 1
|
||||
activeTab.value = 'videos'
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
loadUser()
|
||||
loadVideos()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-detail-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #d4af37;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 4px 20px rgba(212, 175, 55, 0.3);
|
||||
}
|
||||
|
||||
.user-status {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
border: 3px solid #1a1a2e;
|
||||
}
|
||||
|
||||
.user-status.online {
|
||||
background: #4ade80;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: #d4af37;
|
||||
margin: 0 0 0.5rem 0;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.user-bio {
|
||||
color: #e2e8f0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
color: #d4af37;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.follow-btn,
|
||||
.message-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.follow-btn {
|
||||
background: linear-gradient(135deg, #d4af37, #f1c40f);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.follow-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(212, 175, 55, 0.4);
|
||||
}
|
||||
|
||||
.follow-btn.following {
|
||||
background: linear-gradient(135deg, #64748b, #94a3b8);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e2e8f0;
|
||||
border: 1px solid rgba(212, 175, 55, 0.3);
|
||||
}
|
||||
|
||||
.message-btn:hover {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border-color: #d4af37;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.content-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.tabs-nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 2px solid rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 1rem 2rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border-bottom: 3px solid transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #d4af37;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #d4af37;
|
||||
border-bottom-color: #d4af37;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.sort-options select {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(212, 175, 55, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.view-options {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(212, 175, 55, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.view-btn:hover,
|
||||
.view-btn.active {
|
||||
background: rgba(212, 175, 55, 0.2);
|
||||
color: #d4af37;
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.videos-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.videos-grid.list-view {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.users-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.user-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: #d4af37;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.user-item .user-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #d4af37;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-item .user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-item .user-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #d4af37;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.user-item .user-desc {
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.user-item .user-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.user-detail-page {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.user-info-section,
|
||||
.content-section {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.user-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tabs-nav {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
white-space: nowrap;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.videos-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.users-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.videos-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2083
src/pages/VideoDetail.vue
Executable file
2083
src/pages/VideoDetail.vue
Executable file
File diff suppressed because it is too large
Load Diff
596
src/pages/Videos.vue
Executable file
596
src/pages/Videos.vue
Executable file
@@ -0,0 +1,596 @@
|
||||
<template>
|
||||
<div class="videos-page">
|
||||
<!-- 悬浮搜索按钮 -->
|
||||
<div class="floating-search">
|
||||
<button @click="toggleSearch" class="search-toggle-btn" :class="{ active: isSearchExpanded }">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="search-bar" :class="{ expanded: isSearchExpanded }">
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索视频..."
|
||||
class="search-input"
|
||||
@keyup.enter="handleSearch"
|
||||
@blur="handleSearchBlur"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="search-section">
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-group">
|
||||
<el-button type="primary" @click="goToHome" class="back-home-btn">
|
||||
<el-icon><House /></el-icon>
|
||||
返回首页
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">分类筛选:</label>
|
||||
<select v-model="selectedCategoryId" @change="handleCategoryFilter" class="filter-select">
|
||||
<option value="">全部分类</option>
|
||||
<option v-for="category in categories" :key="category.id" :value="category.id">
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频网格 -->
|
||||
<div class="videos-grid">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 视频列表 -->
|
||||
<div v-else-if="videos.length > 0" class="video-grid">
|
||||
<VideoCard
|
||||
v-for="video in videos"
|
||||
:key="video.id"
|
||||
:video="video"
|
||||
@click="goToVideo(video.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>暂无视频</h3>
|
||||
<p>还没有上传任何视频内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无限滚动加载指示器 -->
|
||||
<div class="load-more-indicator" v-if="videos.length > 0">
|
||||
<div class="loading-more" v-if="loadingMore">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>加载更多...</span>
|
||||
</div>
|
||||
<div class="no-more-data" v-else-if="!hasMore">
|
||||
没有更多数据了
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, onUnmounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { videoApi, categoryApi } from '@/api'
|
||||
import type { Video } from '@/stores/video'
|
||||
import type { Category } from '@/api'
|
||||
import VideoCard from '@/components/VideoCard.vue'
|
||||
import { House } from '@element-plus/icons-vue'
|
||||
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 响应式数据
|
||||
const videos = ref<Video[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
// 筛选相关数据
|
||||
const selectedCategory = ref('')
|
||||
const selectedCategoryId = ref('')
|
||||
const categories = ref<Category[]>([])
|
||||
|
||||
// 悬浮搜索相关数据
|
||||
const isSearchExpanded = ref(false)
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const showSearch = ref(false)
|
||||
const totalVideos = ref(0)
|
||||
|
||||
|
||||
|
||||
// 加载分类列表
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const response = await categoryApi.getCategories()
|
||||
if (response.code === 200) {
|
||||
categories.value = response.data || []
|
||||
} else {
|
||||
console.error('加载分类失败:', response.message)
|
||||
// 使用默认分类作为后备
|
||||
categories.value = [
|
||||
{ id: 1, name: '历史文化', description: '历史文化相关视频', createdAt: new Date().toISOString() },
|
||||
{ id: 2, name: '传统艺术', description: '传统艺术表演和展示', createdAt: new Date().toISOString() },
|
||||
{ id: 3, name: '语言学习', description: '语言学习教程', createdAt: new Date().toISOString() }
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error)
|
||||
// 网络错误时使用默认分类
|
||||
categories.value = [
|
||||
{ id: 1, name: '历史文化', description: '历史文化相关视频', createdAt: new Date().toISOString() },
|
||||
{ id: 2, name: '传统艺术', description: '传统艺术表演和展示', createdAt: new Date().toISOString() },
|
||||
{ id: 3, name: '语言学习', description: '语言学习教程', createdAt: new Date().toISOString() }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 加载视频列表
|
||||
const loadVideos = async (append = false) => {
|
||||
try {
|
||||
if (append) {
|
||||
loadingMore.value = true
|
||||
} else {
|
||||
loading.value = true
|
||||
currentPage.value = 1
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
limit: pageSize,
|
||||
search: searchQuery.value || undefined,
|
||||
category: selectedCategoryId.value || undefined
|
||||
}
|
||||
|
||||
const response = await videoApi.getVideos(params)
|
||||
if (response.code === 200) {
|
||||
const rawVideos = response.data.list || []
|
||||
// 修复字段映射:后端返回cover_image,前端需要thumbnail
|
||||
const newVideos = rawVideos.map((video: any) => ({
|
||||
...video,
|
||||
thumbnail: video.cover_image || video.thumbnail || '/default-thumbnail.svg'
|
||||
}))
|
||||
if (append) {
|
||||
videos.value = [...videos.value, ...newVideos]
|
||||
} else {
|
||||
videos.value = newVideos
|
||||
}
|
||||
totalVideos.value = response.data.total || 0
|
||||
|
||||
// 判断是否还有更多数据
|
||||
hasMore.value = newVideos.length === pageSize && videos.value.length < totalVideos.value
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载视频失败:', error)
|
||||
// 显示用户友好的错误信息
|
||||
if (!append) {
|
||||
videos.value = []
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
loadVideos(false)
|
||||
}
|
||||
|
||||
// 切换搜索框显示状态
|
||||
const toggleSearch = () => {
|
||||
isSearchExpanded.value = !isSearchExpanded.value
|
||||
if (isSearchExpanded.value) {
|
||||
nextTick(() => {
|
||||
const searchInput = document.querySelector('.search-input') as HTMLInputElement
|
||||
if (searchInput) {
|
||||
searchInput.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索框失去焦点时的处理
|
||||
const handleSearchBlur = () => {
|
||||
if (!searchQuery.value.trim()) {
|
||||
isSearchExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 分类筛选处理
|
||||
const handleCategoryFilter = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const categoryId = target.value
|
||||
selectedCategoryId.value = categoryId
|
||||
|
||||
// 根据ID找到分类名称用于URL
|
||||
const category = categories.value.find(c => c.id.toString() === categoryId)
|
||||
const categoryName = category ? category.name : ''
|
||||
selectedCategory.value = categoryName
|
||||
|
||||
// 更新URL
|
||||
if (categoryName) {
|
||||
router.push(`/videos/${encodeURIComponent(categoryName)}`)
|
||||
} else {
|
||||
router.push('/videos')
|
||||
}
|
||||
|
||||
loadVideos(false)
|
||||
}
|
||||
|
||||
// 加载更多数据
|
||||
const loadMoreVideos = async () => {
|
||||
if (!loadingMore.value && hasMore.value) {
|
||||
currentPage.value++
|
||||
await loadVideos(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动事件处理
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const windowHeight = window.innerHeight
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
|
||||
// 当滚动到距离底部200px时触发加载
|
||||
if (scrollTop + windowHeight >= documentHeight - 200) {
|
||||
loadMoreVideos()
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到视频详情
|
||||
const goToVideo = (videoId: number) => {
|
||||
router.push(`/video/${videoId}`)
|
||||
}
|
||||
|
||||
// 回到首页
|
||||
const goToHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) {
|
||||
return '今天'
|
||||
} else if (days === 1) {
|
||||
return '昨天'
|
||||
} else if (days < 7) {
|
||||
return `${days}天前`
|
||||
} else {
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听路由参数变化
|
||||
watch(() => route.params.category, (newCategory) => {
|
||||
if (newCategory) {
|
||||
const categoryName = decodeURIComponent(newCategory as string)
|
||||
selectedCategory.value = categoryName
|
||||
// 根据分类名称找到对应的ID
|
||||
const category = categories.value.find(c => c.name === categoryName)
|
||||
selectedCategoryId.value = category ? category.id.toString() : ''
|
||||
} else {
|
||||
selectedCategory.value = ''
|
||||
selectedCategoryId.value = ''
|
||||
}
|
||||
loadVideos(false)
|
||||
}, { immediate: true })
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
// 先加载分类列表
|
||||
await loadCategories()
|
||||
|
||||
// 根据路由参数设置初始分类
|
||||
if (route.params.category) {
|
||||
const categoryName = decodeURIComponent(route.params.category as string)
|
||||
selectedCategory.value = categoryName
|
||||
// 根据分类名称找到对应的ID
|
||||
const category = categories.value.find(c => c.name === categoryName)
|
||||
selectedCategoryId.value = category ? category.id.toString() : ''
|
||||
}
|
||||
|
||||
// 加载视频列表
|
||||
loadVideos(false)
|
||||
|
||||
// 添加滚动事件监听
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
// 组件卸载时移除事件监听
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.videos-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 回首页按钮样式 */
|
||||
.back-home-section {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.back-home-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-home-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 悬浮搜索按钮样式 */
|
||||
.floating-search {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-toggle-btn {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-toggle-btn:hover {
|
||||
background: #2563eb;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.search-toggle-btn.active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.floating-search .search-bar {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
border-radius: 25px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.floating-search .search-bar.expanded {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.floating-search .search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.9rem;
|
||||
min-width: 150px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-select:hover {
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: #d4af37;
|
||||
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.videos-grid {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top: 3px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
column-count: auto;
|
||||
column-width: 300px;
|
||||
column-gap: 1rem;
|
||||
column-fill: balance;
|
||||
}
|
||||
|
||||
.video-grid .video-card {
|
||||
transition: all 0.3s ease;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 0.75rem;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-grid .video-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* 无限滚动相关样式 */
|
||||
.load-more-indicator {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.no-more-data {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.videos-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
column-count: auto;
|
||||
column-width: 280px;
|
||||
column-gap: 0.8rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1056
src/pages/admin/AdminLayout.vue
Executable file
1056
src/pages/admin/AdminLayout.vue
Executable file
File diff suppressed because it is too large
Load Diff
926
src/pages/admin/CategoryManagement.vue
Executable file
926
src/pages/admin/CategoryManagement.vue
Executable file
@@ -0,0 +1,926 @@
|
||||
<template>
|
||||
<div class="category-management">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h1 class="page-title">分类管理</h1>
|
||||
<p class="page-description">管理视频分类,支持增删改查操作</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<i class="icon icon-plus"></i>
|
||||
新建分类
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-section">
|
||||
<div class="search-bar">
|
||||
<div class="search-input-wrapper">
|
||||
<i class="icon icon-search"></i>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="handleSearch"
|
||||
type="text"
|
||||
placeholder="搜索分类名称..."
|
||||
class="search-input"
|
||||
/>
|
||||
<button v-if="searchQuery" @click="clearSearch" class="clear-btn">
|
||||
<i class="icon icon-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类列表 -->
|
||||
<div class="categories-section">
|
||||
<Loading v-if="isLoading" />
|
||||
|
||||
<div v-else-if="categories.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="icon icon-folder"></i>
|
||||
</div>
|
||||
<h3 class="empty-title">暂无分类</h3>
|
||||
<p class="empty-description">还没有创建任何分类,点击上方按钮创建第一个分类</p>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<i class="icon icon-plus"></i>
|
||||
创建分类
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="categories-grid">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="category-card"
|
||||
>
|
||||
<div class="category-header">
|
||||
<div class="category-info">
|
||||
<h3 class="category-name">{{ category.name }}</h3>
|
||||
<p class="category-description">{{ category.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
<div class="category-actions">
|
||||
<button @click="moveCategoryUp(category)" class="action-btn sort-btn" title="上移" :disabled="isFirstCategory(category)">
|
||||
<i class="icon icon-chevron-up"></i>
|
||||
<span class="btn-text">上移</span>
|
||||
</button>
|
||||
<button @click="moveCategoryDown(category)" class="action-btn sort-btn" title="下移" :disabled="isLastCategory(category)">
|
||||
<i class="icon icon-chevron-down"></i>
|
||||
<span class="btn-text">下移</span>
|
||||
</button>
|
||||
<button @click="editCategory(category)" class="action-btn edit-btn" title="编辑">
|
||||
<i class="icon icon-edit"></i>
|
||||
<span class="btn-text">编辑</span>
|
||||
</button>
|
||||
<button @click="deleteCategory(category)" class="action-btn danger delete-btn" title="删除">
|
||||
<i class="icon icon-trash-2"></i>
|
||||
<span class="btn-text">删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="category-stats">
|
||||
<div class="stat-item">
|
||||
<i class="icon icon-video"></i>
|
||||
<span class="stat-label">视频数量</span>
|
||||
<span class="stat-value">{{ category.videoCount || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<i class="icon icon-calendar"></i>
|
||||
<span class="stat-label">创建时间</span>
|
||||
<span class="stat-value">{{ formatDate(category.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-section" v-if="totalPages > 1">
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total="totalCategories"
|
||||
:page-size="pageSize"
|
||||
@page-change="handlePageChange"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑分类模态框 -->
|
||||
<div v-if="showCreateModal || showEditModal" class="modal-overlay" @click="closeModal">
|
||||
<div class="modal" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{ showCreateModal ? '新建分类' : '编辑分类' }}</h3>
|
||||
<button @click="closeModal" class="close-btn">
|
||||
<i class="icon icon-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
<form @submit.prevent="submitForm" class="category-form">
|
||||
<div class="form-group">
|
||||
<label for="categoryName" class="form-label">分类名称 *</label>
|
||||
<input
|
||||
id="categoryName"
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入分类名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="categoryDescription" class="form-label">分类描述</label>
|
||||
<textarea
|
||||
id="categoryDescription"
|
||||
v-model="formData.description"
|
||||
class="form-textarea"
|
||||
placeholder="请输入分类描述(可选)"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" @click="closeModal" class="btn btn-secondary">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
|
||||
<i v-if="isSubmitting" class="icon icon-loader spinning"></i>
|
||||
{{ showCreateModal ? '创建' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import Loading from '@/components/Loading.vue'
|
||||
import Pagination from '@/components/Pagination.vue'
|
||||
import { categoryApi } from '@/api'
|
||||
import { eventBus, EVENTS } from '@/utils/eventBus'
|
||||
|
||||
interface Category {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
videoCount?: number
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const isLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const categories = ref<Category[]>([])
|
||||
const totalCategories = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(12)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 模态框状态
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const editingCategory = ref<Category | null>(null)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const totalPages = computed(() => Math.ceil(totalCategories.value / pageSize.value))
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 加载分类列表
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
search: searchQuery.value
|
||||
}
|
||||
|
||||
const response = await categoryApi.getCategories(params)
|
||||
if (response.code === 200) {
|
||||
categories.value = response.data || []
|
||||
totalCategories.value = response.data.length
|
||||
} else {
|
||||
throw new Error(response.message || '获取分类列表失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载分类列表失败:', error)
|
||||
ElMessage.error('加载分类列表失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
let searchTimeout: NodeJS.Timeout
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
loadCategories()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 清除搜索
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
currentPage.value = 1
|
||||
loadCategories()
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
loadCategories()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
loadCategories()
|
||||
}
|
||||
|
||||
// 编辑分类
|
||||
const editCategory = (category: Category) => {
|
||||
editingCategory.value = category
|
||||
formData.value = {
|
||||
name: category.name,
|
||||
description: category.description || ''
|
||||
}
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
// 删除分类
|
||||
const deleteCategory = async (category: Category) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除分类「${category.name}」吗?删除后该分类下的视频将变为未分类状态。`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'error'
|
||||
}
|
||||
)
|
||||
|
||||
const response = await categoryApi.deleteCategory(category.id)
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('分类已删除')
|
||||
loadCategories()
|
||||
} else {
|
||||
throw new Error(response.message || '删除分类失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除分类失败:', error)
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
showCreateModal.value = false
|
||||
showEditModal.value = false
|
||||
editingCategory.value = null
|
||||
formData.value = {
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
isSubmitting.value = true
|
||||
|
||||
if (showCreateModal.value) {
|
||||
// 创建分类
|
||||
const response = await categoryApi.createCategory(formData.value)
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('分类创建成功')
|
||||
} else {
|
||||
throw new Error(response.message || '创建分类失败')
|
||||
}
|
||||
} else {
|
||||
// 更新分类
|
||||
const response = await categoryApi.updateCategory(editingCategory.value!.id, formData.value)
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('分类更新成功')
|
||||
} else {
|
||||
throw new Error(response.message || '更新分类失败')
|
||||
}
|
||||
}
|
||||
|
||||
closeModal()
|
||||
loadCategories()
|
||||
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否为第一个分类
|
||||
const isFirstCategory = (category: Category): boolean => {
|
||||
return categories.value.indexOf(category) === 0
|
||||
}
|
||||
|
||||
// 判断是否为最后一个分类
|
||||
const isLastCategory = (category: Category): boolean => {
|
||||
return categories.value.indexOf(category) === categories.value.length - 1
|
||||
}
|
||||
|
||||
// 上移分类
|
||||
const moveCategoryUp = async (category: Category) => {
|
||||
const currentIndex = categories.value.indexOf(category)
|
||||
if (currentIndex <= 0) {
|
||||
ElMessage.warning('已经是第一个分类,无法继续上移')
|
||||
return
|
||||
}
|
||||
|
||||
// 添加加载状态
|
||||
const loadingMessage = ElMessage({
|
||||
message: '正在调整分类顺序...',
|
||||
type: 'info',
|
||||
duration: 0
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await categoryApi.updateCategoryOrder(category.id, 'up')
|
||||
loadingMessage.close()
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success(`「${category.name}」已成功上移`)
|
||||
await loadCategories()
|
||||
// 触发分类顺序变化事件
|
||||
eventBus.emit(EVENTS.CATEGORY_ORDER_CHANGED, { categoryId: category.id, direction: 'up' })
|
||||
} else {
|
||||
throw new Error(response.message || '调整顺序失败')
|
||||
}
|
||||
} catch (error) {
|
||||
loadingMessage.close()
|
||||
console.error('上移分类失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '调整顺序失败'
|
||||
ElMessage.error(`上移失败:${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 下移分类
|
||||
const moveCategoryDown = async (category: Category) => {
|
||||
const currentIndex = categories.value.indexOf(category)
|
||||
if (currentIndex >= categories.value.length - 1) {
|
||||
ElMessage.warning('已经是最后一个分类,无法继续下移')
|
||||
return
|
||||
}
|
||||
|
||||
// 添加加载状态
|
||||
const loadingMessage = ElMessage({
|
||||
message: '正在调整分类顺序...',
|
||||
type: 'info',
|
||||
duration: 0
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await categoryApi.updateCategoryOrder(category.id, 'down')
|
||||
loadingMessage.close()
|
||||
|
||||
if (response.code === 200) {
|
||||
ElMessage.success(`「${category.name}」已成功下移`)
|
||||
await loadCategories()
|
||||
// 触发分类顺序变化事件
|
||||
eventBus.emit(EVENTS.CATEGORY_ORDER_CHANGED, { categoryId: category.id, direction: 'down' })
|
||||
} else {
|
||||
throw new Error(response.message || '调整顺序失败')
|
||||
}
|
||||
} catch (error) {
|
||||
loadingMessage.close()
|
||||
console.error('下移分类失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '调整顺序失败'
|
||||
ElMessage.error(`下移失败:${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.category-management {
|
||||
padding: 24px;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px 12px 44px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-input-wrapper .icon-search {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: #f1f5f9;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.categories-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
color: #6b7280;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.category-description {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.category-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
min-width: 80px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--border-light);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-dark);
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: var(--bg-primary);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.action-btn:disabled:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-light);
|
||||
border-color: var(--border-color);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.action-btn .btn-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.action-btn.edit-btn {
|
||||
background: linear-gradient(135deg, var(--info-color), #5A9BD4);
|
||||
color: white;
|
||||
border-color: var(--info-color);
|
||||
}
|
||||
|
||||
.action-btn.edit-btn:hover {
|
||||
background: linear-gradient(135deg, #3A7BC8, var(--info-color));
|
||||
color: white;
|
||||
border-color: #2E5984;
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-btn.delete-btn {
|
||||
background: linear-gradient(135deg, var(--error-color), #E6455A);
|
||||
color: white;
|
||||
border-color: var(--error-color);
|
||||
}
|
||||
|
||||
.action-btn.delete-btn:hover {
|
||||
background: linear-gradient(135deg, #B91C3C, var(--error-color));
|
||||
color: white;
|
||||
border-color: #991B1B;
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-btn.sort-btn {
|
||||
background: linear-gradient(135deg, var(--success-color), #32CD32);
|
||||
color: white;
|
||||
border-color: var(--success-color);
|
||||
}
|
||||
|
||||
.action-btn.sort-btn:hover {
|
||||
background: linear-gradient(135deg, #1E7E1E, var(--success-color));
|
||||
color: white;
|
||||
border-color: #166316;
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-btn.sort-btn:disabled {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-light);
|
||||
border-color: var(--border-color);
|
||||
opacity: 0.5;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.category-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-item .icon {
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6b7280;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
|
||||
color: white;
|
||||
border: 1px solid var(--primary-color);
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--primary-dark), var(--primary-color));
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: var(--text-light);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: var(--shadow-light);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--border-light);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-dark);
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #e2e8f0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.category-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.pagination-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.category-management {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 95%;
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
969
src/pages/admin/Dashboard.vue
Executable file
969
src/pages/admin/Dashboard.vue
Executable file
@@ -0,0 +1,969 @@
|
||||
<template>
|
||||
<div class="admin-dashboard">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">仪表盘</h1>
|
||||
<p class="page-subtitle">欢迎回来,{{ userStore.user?.username }}!这里是您的管理概览</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="refresh-btn" @click="refreshData" :disabled="isLoading">
|
||||
<i class="icon icon-refresh-cw" :class="{ spinning: isLoading }"></i>
|
||||
刷新数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 简化的内容区域 -->
|
||||
<div class="dashboard-content">
|
||||
<div class="content-grid">
|
||||
|
||||
<!-- 热门视频 -->
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<div class="data-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">快速操作</h3>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<router-link to="/admin/upload" class="quick-action">
|
||||
<div class="action-icon upload">
|
||||
<i class="icon icon-upload"></i>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4 class="action-title">上传视频</h4>
|
||||
<p class="action-description">添加新的视频内容</p>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/admin/videos" class="quick-action">
|
||||
<div class="action-icon videos">
|
||||
<i class="icon icon-video"></i>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4 class="action-title">视频管理</h4>
|
||||
<p class="action-description">管理所有视频</p>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/admin/categories" class="quick-action">
|
||||
<div class="action-icon categories">
|
||||
<i class="icon icon-folder"></i>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4 class="action-title">分类管理</h4>
|
||||
<p class="action-description">管理视频分类</p>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/admin/profile" class="quick-action">
|
||||
<div class="action-icon profile">
|
||||
<i class="icon icon-user"></i>
|
||||
</div>
|
||||
<div class="action-content">
|
||||
<h4 class="action-title">个人资料</h4>
|
||||
<p class="action-description">修改个人信息</p>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import api from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
|
||||
|
||||
interface Video {
|
||||
id: string | number
|
||||
title: string
|
||||
thumbnail?: string
|
||||
duration?: number
|
||||
views?: number
|
||||
createdAt?: string
|
||||
description?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
user_id?: number
|
||||
category_id?: number
|
||||
tags?: string[]
|
||||
status?: 'published' | 'draft' | 'processing' | 'failed' | 'blocked' | 'private' | 'featured'
|
||||
file_url?: string
|
||||
file_size?: number
|
||||
likes?: number
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string | number
|
||||
username: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
isOnline?: boolean
|
||||
videoCount?: number
|
||||
totalViews?: number
|
||||
createdAt?: string
|
||||
role?: 'admin' | 'user' | 'moderator' | 'vip'
|
||||
status?: 'active' | 'inactive' | 'banned'
|
||||
video_count?: number
|
||||
followers_count?: number
|
||||
likes_count?: number
|
||||
views_count?: number
|
||||
created_at?: string
|
||||
last_login?: string
|
||||
last_ip?: string
|
||||
device_info?: string
|
||||
}
|
||||
|
||||
interface SystemMetric {
|
||||
key: string
|
||||
name: string
|
||||
value: string
|
||||
percentage: number
|
||||
status: 'good' | 'warning' | 'danger'
|
||||
description: string
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 响应式数据
|
||||
const isLoading = ref(false)
|
||||
const uploadTrendPeriod = ref('30d')
|
||||
const viewsPeriod = ref('week')
|
||||
const activeUsersChartType = ref('line')
|
||||
|
||||
|
||||
|
||||
const popularVideos = ref<Video[]>([])
|
||||
const recentUsers = ref<User[]>([])
|
||||
|
||||
const systemStatus = ref({
|
||||
overall: 'good' as 'good' | 'warning' | 'danger'
|
||||
})
|
||||
|
||||
const systemMetrics = ref<SystemMetric[]>([
|
||||
{
|
||||
key: 'cpu',
|
||||
name: 'CPU 使用率',
|
||||
value: '45%',
|
||||
percentage: 45,
|
||||
status: 'good',
|
||||
description: '服务器CPU使用情况'
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
name: '内存使用率',
|
||||
value: '68%',
|
||||
percentage: 68,
|
||||
status: 'warning',
|
||||
description: '服务器内存使用情况'
|
||||
},
|
||||
{
|
||||
key: 'disk',
|
||||
name: '磁盘使用率',
|
||||
value: '32%',
|
||||
percentage: 32,
|
||||
status: 'good',
|
||||
description: '存储空间使用情况'
|
||||
},
|
||||
{
|
||||
key: 'bandwidth',
|
||||
name: '带宽使用率',
|
||||
value: '78%',
|
||||
percentage: 78,
|
||||
status: 'warning',
|
||||
description: '网络带宽使用情况'
|
||||
}
|
||||
])
|
||||
|
||||
// Canvas 引用
|
||||
const uploadTrendChart = ref<HTMLCanvasElement>()
|
||||
const viewsChart = ref<HTMLCanvasElement>()
|
||||
const activeUsersChart = ref<HTMLCanvasElement>()
|
||||
|
||||
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) return '今天'
|
||||
if (days === 1) return '昨天'
|
||||
if (days < 7) return `${days}天前`
|
||||
if (days < 30) return `${Math.floor(days / 7)}周前`
|
||||
if (days < 365) return `${Math.floor(days / 30)}个月前`
|
||||
return `${Math.floor(days / 365)}年前`
|
||||
}
|
||||
|
||||
// 获取状态图标
|
||||
const getStatusIcon = (status: string): string => {
|
||||
const iconMap: Record<string, string> = {
|
||||
good: 'icon-check-circle',
|
||||
warning: 'icon-alert-triangle',
|
||||
danger: 'icon-x-circle'
|
||||
}
|
||||
return iconMap[status] || 'icon-help-circle'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string): string => {
|
||||
const textMap: Record<string, string> = {
|
||||
good: '运行正常',
|
||||
warning: '需要关注',
|
||||
danger: '存在问题'
|
||||
}
|
||||
return textMap[status] || '未知状态'
|
||||
}
|
||||
|
||||
// 绘制上传趋势图表
|
||||
const drawUploadTrendChart = () => {
|
||||
if (!uploadTrendChart.value) return
|
||||
|
||||
const canvas = uploadTrendChart.value
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
// 设置画布大小
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = rect.width * window.devicePixelRatio
|
||||
canvas.height = rect.height * window.devicePixelRatio
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
|
||||
// 模拟数据
|
||||
const data = [12, 19, 15, 25, 22, 30, 28, 35, 32, 38, 42, 45, 48, 52, 55]
|
||||
const labels = data.map((_, i) => `${i + 1}日`)
|
||||
|
||||
// 绘制图表
|
||||
const padding = 40
|
||||
const chartWidth = rect.width - padding * 2
|
||||
const chartHeight = rect.height - padding * 2
|
||||
|
||||
const maxValue = Math.max(...data)
|
||||
const stepX = chartWidth / (data.length - 1)
|
||||
const stepY = chartHeight / maxValue
|
||||
|
||||
// 绘制网格线
|
||||
ctx.strokeStyle = '#f1f5f9'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding + (chartHeight / 5) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padding, y)
|
||||
ctx.lineTo(rect.width - padding, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 绘制折线
|
||||
ctx.strokeStyle = '#d4af37'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
|
||||
data.forEach((value, index) => {
|
||||
const x = padding + stepX * index
|
||||
const y = rect.height - padding - value * stepY
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
|
||||
ctx.stroke()
|
||||
|
||||
// 绘制数据点
|
||||
ctx.fillStyle = '#d4af37'
|
||||
data.forEach((value, index) => {
|
||||
const x = padding + stepX * index
|
||||
const y = rect.height - padding - value * stepY
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
})
|
||||
}
|
||||
|
||||
// 绘制播放量图表
|
||||
const drawViewsChart = () => {
|
||||
if (!viewsChart.value) return
|
||||
|
||||
const canvas = viewsChart.value
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = rect.width * window.devicePixelRatio
|
||||
canvas.height = rect.height * window.devicePixelRatio
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
|
||||
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
|
||||
// 模拟数据
|
||||
const data = [1200, 1900, 1500, 2500, 2200, 3000, 2800]
|
||||
const labels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
|
||||
const padding = 40
|
||||
const chartWidth = rect.width - padding * 2
|
||||
const chartHeight = rect.height - padding * 2
|
||||
|
||||
const maxValue = Math.max(...data)
|
||||
const barWidth = chartWidth / data.length * 0.6
|
||||
const barSpacing = chartWidth / data.length * 0.4
|
||||
|
||||
// 绘制柱状图
|
||||
data.forEach((value, index) => {
|
||||
const x = padding + (chartWidth / data.length) * index + barSpacing / 2
|
||||
const barHeight = (value / maxValue) * chartHeight
|
||||
const y = rect.height - padding - barHeight
|
||||
|
||||
// 渐变色
|
||||
const gradient = ctx.createLinearGradient(0, y, 0, rect.height - padding)
|
||||
gradient.addColorStop(0, '#d4af37')
|
||||
gradient.addColorStop(1, 'rgba(212, 175, 55, 0.3)')
|
||||
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x, y, barWidth, barHeight)
|
||||
})
|
||||
}
|
||||
|
||||
// 绘制用户活跃度图表
|
||||
const drawActiveUsersChart = () => {
|
||||
if (!activeUsersChart.value) return
|
||||
|
||||
const canvas = activeUsersChart.value
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
canvas.width = rect.width * window.devicePixelRatio
|
||||
canvas.height = rect.height * window.devicePixelRatio
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
|
||||
|
||||
ctx.clearRect(0, 0, rect.width, rect.height)
|
||||
|
||||
// 模拟数据
|
||||
const data = [45, 52, 48, 61, 58, 67, 72, 69, 75, 82, 78, 85]
|
||||
|
||||
const padding = 40
|
||||
const chartWidth = rect.width - padding * 2
|
||||
const chartHeight = rect.height - padding * 2
|
||||
|
||||
if (activeUsersChartType.value === 'line') {
|
||||
// 绘制折线图
|
||||
const maxValue = Math.max(...data)
|
||||
const stepX = chartWidth / (data.length - 1)
|
||||
const stepY = chartHeight / maxValue
|
||||
|
||||
ctx.strokeStyle = '#3b82f6'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
|
||||
data.forEach((value, index) => {
|
||||
const x = padding + stepX * index
|
||||
const y = rect.height - padding - value * stepY
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
|
||||
ctx.stroke()
|
||||
} else {
|
||||
// 绘制柱状图
|
||||
const maxValue = Math.max(...data)
|
||||
const barWidth = chartWidth / data.length * 0.6
|
||||
const barSpacing = chartWidth / data.length * 0.4
|
||||
|
||||
data.forEach((value, index) => {
|
||||
const x = padding + (chartWidth / data.length) * index + barSpacing / 2
|
||||
const barHeight = (value / maxValue) * chartHeight
|
||||
const y = rect.height - padding - barHeight
|
||||
|
||||
ctx.fillStyle = '#3b82f6'
|
||||
ctx.fillRect(x, y, barWidth, barHeight)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 加载仪表盘数据
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const [videosResponse, usersResponse] = await Promise.all([
|
||||
api.statsApi.getLatestVideos(5),
|
||||
api.statsApi.getRecentUsers({ limit: 5 })
|
||||
])
|
||||
|
||||
popularVideos.value = videosResponse.data
|
||||
recentUsers.value = usersResponse.data
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载仪表盘数据失败:', error)
|
||||
ElMessage.error('加载数据失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = async () => {
|
||||
await loadDashboardData()
|
||||
await nextTick()
|
||||
drawCharts()
|
||||
ElMessage.success('数据已刷新')
|
||||
}
|
||||
|
||||
// 绘制所有图表
|
||||
const drawCharts = () => {
|
||||
drawUploadTrendChart()
|
||||
drawViewsChart()
|
||||
drawActiveUsersChart()
|
||||
}
|
||||
|
||||
// 监听图表类型变化
|
||||
watch(activeUsersChartType, () => {
|
||||
nextTick(() => {
|
||||
drawActiveUsersChart()
|
||||
})
|
||||
})
|
||||
|
||||
// 监听时间周期变化
|
||||
watch([uploadTrendPeriod, viewsPeriod], () => {
|
||||
nextTick(() => {
|
||||
drawCharts()
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboardData()
|
||||
await nextTick()
|
||||
drawCharts()
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', () => {
|
||||
setTimeout(drawCharts, 100)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-dashboard {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #1e293b;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #d4af37;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #b8941f;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dashboard-content {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-card,
|
||||
.data-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.period-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chart-type-btn {
|
||||
padding: 0.5rem;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.chart-type-btn:hover,
|
||||
.chart-type-btn.active {
|
||||
background: #d4af37;
|
||||
color: white;
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.view-all-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #d4af37;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.view-all-link:hover {
|
||||
color: #b8941f;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.popular-videos,
|
||||
.recent-users {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.video-item,
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.video-item:hover,
|
||||
.user-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.video-rank {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #d4af37;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.video-thumbnail {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 45px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-duration {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.video-info,
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.video-title,
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.video-meta,
|
||||
.user-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.video-views,
|
||||
.user-email {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.video-actions,
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #d4af37;
|
||||
color: white;
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-status {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.user-status.online {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.user-status.offline {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item .stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat-item .stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.system-metrics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.metric-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric-value.good { color: #059669; }
|
||||
.metric-value.warning { color: #d97706; }
|
||||
.metric-value.danger { color: #dc2626; }
|
||||
|
||||
.metric-bar {
|
||||
height: 6px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-progress {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.metric-progress.good { background: #10b981; }
|
||||
.metric-progress.warning { background: #f59e0b; }
|
||||
.metric-progress.danger { background: #ef4444; }
|
||||
|
||||
.metric-description {
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-indicator.good {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.status-indicator.warning {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.status-indicator.danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.quick-action:hover {
|
||||
background: #f1f5f9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-icon.upload { background: linear-gradient(135deg, #d4af37, #f1c40f); }
|
||||
.action-icon.users { background: linear-gradient(135deg, #3b82f6, #1d4ed8); }
|
||||
.action-icon.analytics { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
.action-icon.settings { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
|
||||
.action-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-title {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.action-description {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-item,
|
||||
.user-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
929
src/pages/admin/Login.vue
Executable file
929
src/pages/admin/Login.vue
Executable file
@@ -0,0 +1,929 @@
|
||||
<template>
|
||||
<div class="admin-login-page">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="bg-decoration">
|
||||
<div class="decoration-pattern"></div>
|
||||
<div class="floating-elements">
|
||||
<div class="element" v-for="i in 6" :key="i"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录容器 -->
|
||||
<div class="login-container">
|
||||
<!-- 品牌区域 -->
|
||||
<div class="brand-section">
|
||||
<div class="brand-logo">
|
||||
<img src="/logo.svg" alt="梦回高句丽" class="logo-img" />
|
||||
</div>
|
||||
<h1 class="brand-title">梦回高句丽</h1>
|
||||
<p class="brand-subtitle">管理后台</p>
|
||||
<div class="brand-desc">
|
||||
<p>探索古代文明的数字化管理平台</p>
|
||||
<p>传承历史文化,连接过去与未来</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<div class="login-form-section">
|
||||
<div class="form-header">
|
||||
<h2 class="form-title">管理员登录</h2>
|
||||
<p class="form-subtitle">请使用管理员账号登录系统</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<!-- 用户名输入 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="icon icon-user"></i>
|
||||
用户名
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入管理员用户名"
|
||||
:class="{ error: errors.username }"
|
||||
@blur="validateUsername"
|
||||
@input="clearError('username')"
|
||||
required
|
||||
/>
|
||||
<div class="input-icon">
|
||||
<i class="icon icon-user"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-message" v-if="errors.username">
|
||||
{{ errors.username }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码输入 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="icon icon-lock"></i>
|
||||
密码
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
placeholder="请输入密码"
|
||||
:class="{ error: errors.password }"
|
||||
@blur="validatePassword"
|
||||
@input="clearError('password')"
|
||||
required
|
||||
/>
|
||||
<div class="input-icon">
|
||||
<i class="icon icon-lock"></i>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="password-toggle"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<i class="icon" :class="showPassword ? 'icon-eye-off' : 'icon-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="error-message" v-if="errors.password">
|
||||
{{ errors.password }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<i class="icon icon-shield"></i>
|
||||
验证码
|
||||
</label>
|
||||
<div class="captcha-wrapper">
|
||||
<div class="input-wrapper captcha-input">
|
||||
<input
|
||||
v-model="loginForm.captcha"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入验证码"
|
||||
:class="{ error: errors.captcha }"
|
||||
@blur="validateCaptcha"
|
||||
@input="clearError('captcha')"
|
||||
maxlength="4"
|
||||
required
|
||||
/>
|
||||
<div class="input-icon">
|
||||
<i class="icon icon-shield"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="captcha-image" @click="refreshCaptcha">
|
||||
<canvas ref="captchaCanvas" width="120" height="40"></canvas>
|
||||
<div class="captcha-refresh">
|
||||
<i class="icon icon-refresh-cw"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-message" v-if="errors.captcha">
|
||||
{{ errors.captcha }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 记住登录 -->
|
||||
<div class="form-options">
|
||||
<label class="checkbox-wrapper">
|
||||
<input
|
||||
v-model="loginForm.remember"
|
||||
type="checkbox"
|
||||
class="checkbox-input"
|
||||
/>
|
||||
<span class="checkbox-custom"></span>
|
||||
<span class="checkbox-label">记住登录状态</span>
|
||||
</label>
|
||||
<a href="#" class="forgot-link" @click.prevent="handleForgotPassword">
|
||||
忘记密码?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<button
|
||||
type="submit"
|
||||
class="login-btn"
|
||||
:class="{ loading: isLoading }"
|
||||
:disabled="isLoading || !isFormValid"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<i class="icon icon-loader" v-if="isLoading"></i>
|
||||
<i class="icon icon-log-in" v-else></i>
|
||||
<span>{{ isLoading ? '登录中...' : '立即登录' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- 安全提示 -->
|
||||
<div class="security-notice">
|
||||
<i class="icon icon-info"></i>
|
||||
<div class="notice-content">
|
||||
<p>为了您的账户安全,请妥善保管登录凭证</p>
|
||||
<p>如发现异常登录行为,请及时联系系统管理员</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页脚信息 -->
|
||||
<div class="login-footer">
|
||||
<div class="footer-links">
|
||||
<a href="#">使用条款</a>
|
||||
<a href="#">隐私政策</a>
|
||||
<a href="#">技术支持</a>
|
||||
</div>
|
||||
<div class="footer-copyright">
|
||||
<p>© 2024 梦回高句丽. 保留所有权利.</p>
|
||||
<p>Powered by 高句丽文化数字化平台</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { authApi } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
interface LoginForm {
|
||||
username: string
|
||||
password: string
|
||||
captcha: string
|
||||
remember: boolean
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
username?: string
|
||||
password?: string
|
||||
captcha?: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表单数据
|
||||
const loginForm = ref<LoginForm>({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
remember: false
|
||||
})
|
||||
|
||||
// 表单状态
|
||||
const isLoading = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const errors = ref<FormErrors>({})
|
||||
const captchaText = ref('')
|
||||
const captchaCanvas = ref<HTMLCanvasElement>()
|
||||
|
||||
// 表单验证
|
||||
const isFormValid = computed(() => {
|
||||
return loginForm.value.username.length >= 3 &&
|
||||
loginForm.value.password.length >= 6 &&
|
||||
loginForm.value.captcha.length === 4
|
||||
})
|
||||
|
||||
// 验证用户名
|
||||
const validateUsername = () => {
|
||||
if (!loginForm.value.username) {
|
||||
errors.value.username = '请输入用户名'
|
||||
} else if (loginForm.value.username.length < 3) {
|
||||
errors.value.username = '用户名至少3个字符'
|
||||
} else if (!/^[a-zA-Z0-9_]+$/.test(loginForm.value.username)) {
|
||||
errors.value.username = '用户名只能包含字母、数字和下划线'
|
||||
} else {
|
||||
delete errors.value.username
|
||||
}
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const validatePassword = () => {
|
||||
if (!loginForm.value.password) {
|
||||
errors.value.password = '请输入密码'
|
||||
} else if (loginForm.value.password.length < 6) {
|
||||
errors.value.password = '密码至少6个字符'
|
||||
} else {
|
||||
delete errors.value.password
|
||||
}
|
||||
}
|
||||
|
||||
// 验证验证码
|
||||
const validateCaptcha = () => {
|
||||
if (!loginForm.value.captcha) {
|
||||
errors.value.captcha = '请输入验证码'
|
||||
} else if (loginForm.value.captcha.length !== 4) {
|
||||
errors.value.captcha = '验证码为4位字符'
|
||||
} else {
|
||||
delete errors.value.captcha
|
||||
}
|
||||
}
|
||||
|
||||
// 清除错误
|
||||
const clearError = (field: keyof FormErrors) => {
|
||||
delete errors.value[field]
|
||||
}
|
||||
|
||||
// 生成验证码
|
||||
const generateCaptcha = () => {
|
||||
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 4; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
captchaText.value = result
|
||||
drawCaptcha()
|
||||
}
|
||||
|
||||
// 绘制验证码
|
||||
const drawCaptcha = () => {
|
||||
if (!captchaCanvas.value) return
|
||||
|
||||
const canvas = captchaCanvas.value
|
||||
const ctx = canvas.getContext('2d')!
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 设置背景
|
||||
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
gradient.addColorStop(0, 'rgba(212, 175, 55, 0.1)')
|
||||
gradient.addColorStop(1, 'rgba(212, 175, 55, 0.2)')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 绘制干扰线
|
||||
for (let i = 0; i < 5; i++) {
|
||||
ctx.strokeStyle = `rgba(212, 175, 55, ${Math.random() * 0.3 + 0.1})`
|
||||
ctx.lineWidth = Math.random() * 2 + 1
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height)
|
||||
ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// 绘制验证码文字
|
||||
const text = captchaText.value
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
ctx.font = `${Math.random() * 8 + 20}px Arial`
|
||||
ctx.fillStyle = `hsl(${Math.random() * 60 + 30}, 70%, ${Math.random() * 30 + 50}%)`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
const x = (canvas.width / text.length) * (i + 0.5)
|
||||
const y = canvas.height / 2 + (Math.random() - 0.5) * 8
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(x, y)
|
||||
ctx.rotate((Math.random() - 0.5) * 0.5)
|
||||
ctx.fillText(text[i], 0, 0)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// 绘制干扰点
|
||||
for (let i = 0; i < 30; i++) {
|
||||
ctx.fillStyle = `rgba(212, 175, 55, ${Math.random() * 0.5})`
|
||||
ctx.beginPath()
|
||||
ctx.arc(
|
||||
Math.random() * canvas.width,
|
||||
Math.random() * canvas.height,
|
||||
Math.random() * 2 + 1,
|
||||
0,
|
||||
2 * Math.PI
|
||||
)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新验证码
|
||||
const refreshCaptcha = () => {
|
||||
generateCaptcha()
|
||||
loginForm.value.captcha = ''
|
||||
clearError('captcha')
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
// 验证表单
|
||||
validateUsername()
|
||||
validatePassword()
|
||||
validateCaptcha()
|
||||
|
||||
// 验证验证码是否正确
|
||||
if (loginForm.value.captcha.toLowerCase() !== captchaText.value.toLowerCase()) {
|
||||
ElMessage.error('验证码错误')
|
||||
refreshCaptcha()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isFormValid.value) {
|
||||
ElMessage.error('请检查输入信息')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await authApi.login(
|
||||
loginForm.value.username,
|
||||
loginForm.value.password
|
||||
)
|
||||
|
||||
// 保存用户信息
|
||||
userStore.login(response.data.user, response.data.token)
|
||||
|
||||
ElMessage.success('登录成功')
|
||||
|
||||
// 跳转到管理后台首页
|
||||
router.push('/admin')
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
ElMessage.error('用户名或密码错误')
|
||||
} else if (error.response?.status === 400) {
|
||||
ElMessage.error('验证码错误')
|
||||
refreshCaptcha()
|
||||
} else {
|
||||
ElMessage.error('登录失败,请重试')
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 忘记密码
|
||||
const handleForgotPassword = () => {
|
||||
ElMessage.info('请联系系统管理员重置密码')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 如果已经登录,直接跳转
|
||||
if (userStore.isLoggedIn) {
|
||||
router.push('/admin')
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
generateCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 25%, #334155 50%, #475569 75%, #64748b 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.decoration-pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 25%, rgba(212, 175, 55, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 75%, rgba(212, 175, 55, 0.05) 0%, transparent 50%);
|
||||
background-size: 400px 400px;
|
||||
animation: patternMove 20s linear infinite;
|
||||
}
|
||||
|
||||
.floating-elements {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.floating-elements .element {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: rgba(212, 175, 55, 0.3);
|
||||
border-radius: 50%;
|
||||
animation: float 15s linear infinite;
|
||||
}
|
||||
|
||||
.floating-elements .element:nth-child(1) { left: 10%; animation-delay: 0s; }
|
||||
.floating-elements .element:nth-child(2) { left: 20%; animation-delay: -2s; }
|
||||
.floating-elements .element:nth-child(3) { left: 40%; animation-delay: -4s; }
|
||||
.floating-elements .element:nth-child(4) { left: 60%; animation-delay: -6s; }
|
||||
.floating-elements .element:nth-child(5) { left: 80%; animation-delay: -8s; }
|
||||
.floating-elements .element:nth-child(6) { left: 90%; animation-delay: -10s; }
|
||||
|
||||
@keyframes patternMove {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(-400px, -400px); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translateY(100vh) rotate(0deg); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { transform: translateY(-100px) rotate(360deg); opacity: 0; }
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
padding: 3rem;
|
||||
background: linear-gradient(135deg, rgba(212, 175, 55, 0.1), rgba(212, 175, 55, 0.05));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
border-right: 1px solid rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #d4af37;
|
||||
box-shadow: 0 8px 25px rgba(212, 175, 55, 0.3);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: #d4af37;
|
||||
margin: 0 0 0.5rem 0;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: #e2e8f0;
|
||||
margin: 0 0 2rem 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.brand-desc p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.login-form-section {
|
||||
padding: 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #e2e8f0;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #e2e8f0;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 1rem 1rem 1rem 3rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 2px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 10px;
|
||||
color: #e2e8f0;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #d4af37;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: #d4af37;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.captcha-wrapper {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: 2px solid rgba(212, 175, 55, 0.2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.captcha-image:hover {
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.captcha-image canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.captcha-refresh {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.captcha-image:hover .captcha-refresh {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox-custom {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(212, 175, 55, 0.3);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.checkbox-input:checked + .checkbox-custom {
|
||||
background: #d4af37;
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.checkbox-input:checked + .checkbox-custom::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #1a1a2e;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
color: #d4af37;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.forgot-link:hover {
|
||||
color: #f1c40f;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #d4af37, #f1c40f);
|
||||
color: #1a1a2e;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(212, 175, 55, 0.4);
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.login-btn.loading {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-content .icon-loader {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.security-notice {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #93c5fd;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notice-content p {
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.notice-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: #d4af37;
|
||||
}
|
||||
|
||||
.footer-copyright p {
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.admin-login-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
padding: 2rem;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.login-form-section {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.captcha-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
position: static;
|
||||
transform: none;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.brand-section,
|
||||
.login-form-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
573
src/pages/admin/PasswordChange.vue
Executable file
573
src/pages/admin/PasswordChange.vue
Executable file
@@ -0,0 +1,573 @@
|
||||
<template>
|
||||
<div class="password-change">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">修改密码</h1>
|
||||
<p class="page-description">为了账户安全,请定期更换密码</p>
|
||||
</div>
|
||||
|
||||
<div class="password-container">
|
||||
<div class="password-card">
|
||||
<div class="security-tips">
|
||||
<div class="tips-header">
|
||||
<i class="icon icon-shield"></i>
|
||||
<h3>密码安全提示</h3>
|
||||
</div>
|
||||
<ul class="tips-list">
|
||||
<li>密码长度至少8位字符</li>
|
||||
<li>包含大小写字母、数字和特殊字符</li>
|
||||
<li>不要使用常见的密码组合</li>
|
||||
<li>定期更换密码,建议每3个月更换一次</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="password-form">
|
||||
<form @submit.prevent="changePassword">
|
||||
<div class="form-group">
|
||||
<label for="currentPassword">当前密码</label>
|
||||
<div class="password-input-group">
|
||||
<input
|
||||
id="currentPassword"
|
||||
v-model="formData.currentPassword"
|
||||
:type="showCurrentPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
placeholder="请输入当前密码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showCurrentPassword = !showCurrentPassword"
|
||||
class="password-toggle"
|
||||
>
|
||||
<i :class="showCurrentPassword ? 'icon icon-eye-off' : 'icon icon-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newPassword">新密码</label>
|
||||
<div class="password-input-group">
|
||||
<input
|
||||
id="newPassword"
|
||||
v-model="formData.newPassword"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
placeholder="请输入新密码"
|
||||
required
|
||||
@input="checkPasswordStrength"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showNewPassword = !showNewPassword"
|
||||
class="password-toggle"
|
||||
>
|
||||
<i :class="showNewPassword ? 'icon icon-eye-off' : 'icon icon-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 密码强度指示器 -->
|
||||
<div v-if="formData.newPassword" class="password-strength">
|
||||
<div class="strength-bar">
|
||||
<div
|
||||
class="strength-fill"
|
||||
:class="`strength-${passwordStrength.level}`"
|
||||
:style="{ width: passwordStrength.percentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="strength-text" :class="`strength-${passwordStrength.level}`">
|
||||
{{ passwordStrength.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">确认新密码</label>
|
||||
<div class="password-input-group">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="formData.confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
:class="{ 'error': passwordMismatch }"
|
||||
placeholder="请再次输入新密码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
class="password-toggle"
|
||||
>
|
||||
<i :class="showConfirmPassword ? 'icon icon-eye-off' : 'icon icon-eye'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="passwordMismatch" class="error-message">
|
||||
两次输入的密码不一致
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" @click="resetForm" class="btn btn-outline">
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="isLoading || passwordMismatch || passwordStrength.level === 'weak'"
|
||||
>
|
||||
<i v-if="isLoading" class="icon icon-loader spinning"></i>
|
||||
<i v-else class="icon icon-lock"></i>
|
||||
{{ isLoading ? '修改中...' : '修改密码' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<div v-if="showSuccess" class="success-toast">
|
||||
<i class="icon icon-check"></i>
|
||||
密码修改成功!请重新登录。
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="showError" class="error-toast">
|
||||
<i class="icon icon-x"></i>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface PasswordStrength {
|
||||
level: 'weak' | 'medium' | 'strong'
|
||||
percentage: number
|
||||
text: string
|
||||
}
|
||||
|
||||
const formData = reactive({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const showCurrentPassword = ref(false)
|
||||
const showNewPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const showSuccess = ref(false)
|
||||
const showError = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const passwordStrength = ref<PasswordStrength>({
|
||||
level: 'weak',
|
||||
percentage: 0,
|
||||
text: ''
|
||||
})
|
||||
|
||||
const passwordMismatch = computed(() => {
|
||||
return formData.confirmPassword && formData.newPassword !== formData.confirmPassword
|
||||
})
|
||||
|
||||
const checkPasswordStrength = () => {
|
||||
const password = formData.newPassword
|
||||
let score = 0
|
||||
let feedback = []
|
||||
|
||||
if (password.length >= 8) score += 1
|
||||
else feedback.push('至少8位字符')
|
||||
|
||||
if (/[a-z]/.test(password)) score += 1
|
||||
else feedback.push('包含小写字母')
|
||||
|
||||
if (/[A-Z]/.test(password)) score += 1
|
||||
else feedback.push('包含大写字母')
|
||||
|
||||
if (/\d/.test(password)) score += 1
|
||||
else feedback.push('包含数字')
|
||||
|
||||
if (/[^\w\s]/.test(password)) score += 1
|
||||
else feedback.push('包含特殊字符')
|
||||
|
||||
if (score <= 2) {
|
||||
passwordStrength.value = {
|
||||
level: 'weak',
|
||||
percentage: 33,
|
||||
text: `弱密码 - 缺少: ${feedback.join('、')}`
|
||||
}
|
||||
} else if (score <= 3) {
|
||||
passwordStrength.value = {
|
||||
level: 'medium',
|
||||
percentage: 66,
|
||||
text: `中等强度 - 建议: ${feedback.join('、')}`
|
||||
}
|
||||
} else {
|
||||
passwordStrength.value = {
|
||||
level: 'strong',
|
||||
percentage: 100,
|
||||
text: '强密码'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const changePassword = async () => {
|
||||
if (passwordMismatch.value) {
|
||||
showErrorMessage('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordStrength.value.level === 'weak') {
|
||||
showErrorMessage('密码强度太弱,请设置更安全的密码')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// 模拟验证当前密码
|
||||
if (formData.currentPassword !== 'admin123') {
|
||||
throw new Error('当前密码不正确')
|
||||
}
|
||||
|
||||
showSuccess.value = true
|
||||
resetForm()
|
||||
|
||||
// 3秒后自动跳转到登录页
|
||||
setTimeout(() => {
|
||||
// 这里应该清除登录状态并跳转到登录页
|
||||
// 密码修改成功,跳转到登录页
|
||||
router.push('/admin/login')
|
||||
}, 3000)
|
||||
|
||||
// 修改密码成功
|
||||
} catch (error: any) {
|
||||
showErrorMessage(error.message || '密码修改失败,请重试')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showErrorMessage = (message: string) => {
|
||||
errorMessage.value = message
|
||||
showError.value = true
|
||||
setTimeout(() => {
|
||||
showError.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formData.currentPassword = ''
|
||||
formData.newPassword = ''
|
||||
formData.confirmPassword = ''
|
||||
passwordStrength.value = {
|
||||
level: 'weak',
|
||||
percentage: 0,
|
||||
text: ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.password-change {
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.password-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.password-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.security-tips {
|
||||
background: #f8fafc;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.tips-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tips-header i {
|
||||
color: #3b82f6;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.tips-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.tips-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.tips-list li {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.password-form {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.password-input-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 48px 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.form-input.error:focus {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
height: 4px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.strength-fill {
|
||||
height: 100%;
|
||||
transition: all 0.3s;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.strength-fill.strength-weak {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.strength-fill.strength-medium {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.strength-fill.strength-strong {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.strength-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.strength-text.strength-weak {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.strength-text.strength-medium {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.strength-text.strength-strong {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.success-toast,
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.success-toast {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error-toast {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.password-change {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.security-tips,
|
||||
.password-form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
465
src/pages/admin/UserProfile.vue
Executable file
465
src/pages/admin/UserProfile.vue
Executable file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="user-profile">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">个人资料</h1>
|
||||
</div>
|
||||
|
||||
<div class="profile-container">
|
||||
<div class="profile-card">
|
||||
<div class="profile-header">
|
||||
<div class="avatar">
|
||||
<i class="icon icon-user"></i>
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<h2>{{ userInfo.username }}</h2>
|
||||
<p class="role">管理员</p>
|
||||
<p class="last-login">上次登录:{{ formatDate(userInfo.lastLogin) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-form">
|
||||
<form @submit.prevent="updateProfile">
|
||||
<div class="form-section">
|
||||
<h3>基本信息</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="formData.username"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱地址</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
class="form-input"
|
||||
placeholder="请输入邮箱地址"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="phone">手机号码</label>
|
||||
<input
|
||||
id="phone"
|
||||
v-model="formData.phone"
|
||||
type="tel"
|
||||
class="form-input"
|
||||
placeholder="请输入手机号码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="realName">真实姓名</label>
|
||||
<input
|
||||
id="realName"
|
||||
v-model="formData.realName"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入真实姓名"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>账户统计</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ userStats.totalVideos }}</div>
|
||||
<div class="stat-label">管理视频</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ userStats.totalCategories }}</div>
|
||||
<div class="stat-label">管理分类</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ userStats.loginCount }}</div>
|
||||
<div class="stat-label">登录次数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ userStats.daysActive }}</div>
|
||||
<div class="stat-label">活跃天数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" @click="resetForm" class="btn btn-outline">
|
||||
重置
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="isLoading">
|
||||
<i v-if="isLoading" class="icon icon-loader spinning"></i>
|
||||
<i v-else class="icon icon-save"></i>
|
||||
{{ isLoading ? '保存中...' : '保存更改' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<div v-if="showSuccess" class="success-toast">
|
||||
<i class="icon icon-check"></i>
|
||||
个人资料更新成功!
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
|
||||
interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
phone: string
|
||||
realName: string
|
||||
lastLogin: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface UserStats {
|
||||
totalVideos: number
|
||||
totalCategories: number
|
||||
loginCount: number
|
||||
daysActive: number
|
||||
}
|
||||
|
||||
const userInfo = ref<UserInfo>({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
realName: '管理员',
|
||||
lastLogin: '2024-01-15T10:30:00Z',
|
||||
createdAt: '2023-12-01T09:00:00Z'
|
||||
})
|
||||
|
||||
const userStats = ref<UserStats>({
|
||||
totalVideos: 35,
|
||||
totalCategories: 8,
|
||||
loginCount: 127,
|
||||
daysActive: 45
|
||||
})
|
||||
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
realName: ''
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const showSuccess = ref(false)
|
||||
|
||||
const loadUserInfo = () => {
|
||||
// 模拟API调用
|
||||
formData.username = userInfo.value.username
|
||||
formData.email = userInfo.value.email
|
||||
formData.phone = userInfo.value.phone
|
||||
formData.realName = userInfo.value.realName
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const updateProfile = async () => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 更新用户信息
|
||||
userInfo.value = {
|
||||
...userInfo.value,
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
realName: formData.realName
|
||||
}
|
||||
|
||||
showSuccess.value = true
|
||||
setTimeout(() => {
|
||||
showSuccess.value = false
|
||||
}, 3000)
|
||||
|
||||
// 更新个人资料
|
||||
} catch (error) {
|
||||
// 更新失败
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
loadUserInfo()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUserInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-profile {
|
||||
padding: 24px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.profile-info h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.last-login {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.success-toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.user-profile {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
padding: 24px 20px;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1069
src/pages/admin/VideoEdit.vue
Executable file
1069
src/pages/admin/VideoEdit.vue
Executable file
File diff suppressed because it is too large
Load Diff
2221
src/pages/admin/VideoManagement.vue
Executable file
2221
src/pages/admin/VideoManagement.vue
Executable file
File diff suppressed because it is too large
Load Diff
2011
src/pages/admin/VideoUpload.vue
Executable file
2011
src/pages/admin/VideoUpload.vue
Executable file
File diff suppressed because it is too large
Load Diff
157
src/router/index.ts
Executable file
157
src/router/index.ts
Executable file
@@ -0,0 +1,157 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import HomePage from '@/pages/HomePage.vue'
|
||||
import VideoDetail from '@/pages/VideoDetail.vue'
|
||||
import Videos from '@/pages/Videos.vue'
|
||||
import UserDetail from '@/pages/UserDetail.vue'
|
||||
import SearchResult from '@/pages/SearchResult.vue'
|
||||
import Login from '@/pages/admin/Login.vue'
|
||||
import AdminLayout from '@/pages/admin/AdminLayout.vue'
|
||||
import Dashboard from '@/pages/admin/Dashboard.vue'
|
||||
import VideoManagement from '@/pages/admin/VideoManagement.vue'
|
||||
import VideoUpload from '@/pages/admin/VideoUpload.vue'
|
||||
import VideoEdit from '@/pages/admin/VideoEdit.vue'
|
||||
import CategoryManagement from '@/pages/admin/CategoryManagement.vue'
|
||||
import UserProfile from '@/pages/admin/UserProfile.vue'
|
||||
import PasswordChange from '@/pages/admin/PasswordChange.vue'
|
||||
import NotFound from '@/pages/NotFound.vue'
|
||||
|
||||
// 定义路由配置
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomePage,
|
||||
meta: { title: '梦回高句丽 - 首页' }
|
||||
},
|
||||
{
|
||||
path: '/video/:id',
|
||||
name: 'video-detail',
|
||||
component: VideoDetail,
|
||||
meta: { title: '视频详情' }
|
||||
},
|
||||
{
|
||||
path: '/videos',
|
||||
name: 'Videos',
|
||||
component: Videos
|
||||
},
|
||||
{
|
||||
path: '/videos/:category',
|
||||
name: 'VideosCategory',
|
||||
component: Videos,
|
||||
meta: { title: '视频分类' }
|
||||
},
|
||||
{
|
||||
path: '/user/:id',
|
||||
name: 'user-detail',
|
||||
component: UserDetail,
|
||||
meta: { title: '用户详情' }
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
name: 'search',
|
||||
component: SearchResult,
|
||||
meta: { title: '搜索结果' }
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'admin-login',
|
||||
component: Login,
|
||||
meta: { title: '管理员登录' }
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/admin/dashboard'
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'admin-dashboard',
|
||||
component: Dashboard,
|
||||
meta: { title: '管理后台' }
|
||||
},
|
||||
{
|
||||
path: 'videos',
|
||||
name: 'admin-videos',
|
||||
component: VideoManagement,
|
||||
meta: { title: '视频管理' }
|
||||
},
|
||||
{
|
||||
path: 'upload',
|
||||
name: 'admin-upload',
|
||||
component: VideoUpload,
|
||||
meta: { title: '视频上传' }
|
||||
},
|
||||
{
|
||||
path: 'videos/:id/edit',
|
||||
name: 'admin-video-edit',
|
||||
component: VideoEdit,
|
||||
meta: { title: '编辑视频' }
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'admin-categories',
|
||||
component: CategoryManagement,
|
||||
meta: { title: '分类管理' }
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'admin-profile',
|
||||
component: UserProfile,
|
||||
meta: { title: '个人资料' }
|
||||
},
|
||||
{
|
||||
path: 'password',
|
||||
name: 'admin-password',
|
||||
component: PasswordChange,
|
||||
meta: { title: '修改密码' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: NotFound,
|
||||
meta: { title: '页面未找到' }
|
||||
}
|
||||
]
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 设置页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title as string
|
||||
}
|
||||
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth) {
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.isLoggedIn) {
|
||||
next({ name: 'admin-login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已登录用户访问登录页,重定向到后台首页
|
||||
if (to.name === 'admin-login') {
|
||||
const userStore = useUserStore()
|
||||
if (userStore.isLoggedIn) {
|
||||
next({ name: 'admin-dashboard' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
5
src/shims-vue.d.ts
vendored
Normal file
5
src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
6
src/stores/index.ts
Executable file
6
src/stores/index.ts
Executable file
@@ -0,0 +1,6 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
// 创建Pinia实例
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
66
src/stores/user.ts
Executable file
66
src/stores/user.ts
Executable file
@@ -0,0 +1,66 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
role?: 'admin' | 'user' | 'vip'
|
||||
status?: 'active' | 'inactive' | 'banned'
|
||||
video_count?: number
|
||||
followers_count?: number
|
||||
likes_count?: number
|
||||
views_count?: number
|
||||
created_at?: string
|
||||
last_login?: string
|
||||
last_ip?: string
|
||||
device_info?: string
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const token = ref<string | null>(localStorage.getItem('token'))
|
||||
const isLoggedIn = ref<boolean>(!!token.value)
|
||||
|
||||
// 登录
|
||||
const login = (userData: User, authToken: string) => {
|
||||
user.value = userData
|
||||
token.value = authToken
|
||||
isLoggedIn.value = true
|
||||
localStorage.setItem('token', authToken)
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
}
|
||||
|
||||
// 登出
|
||||
const logout = () => {
|
||||
user.value = null
|
||||
token.value = null
|
||||
isLoggedIn.value = false
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
// 初始化用户信息
|
||||
const initUser = () => {
|
||||
const savedUser = localStorage.getItem('user')
|
||||
if (savedUser && token.value) {
|
||||
try {
|
||||
user.value = JSON.parse(savedUser)
|
||||
isLoggedIn.value = true
|
||||
} catch (error) {
|
||||
console.error('解析用户信息失败:', error)
|
||||
logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
isLoggedIn,
|
||||
login,
|
||||
logout,
|
||||
initUser
|
||||
}
|
||||
})
|
||||
155
src/stores/video.ts
Executable file
155
src/stores/video.ts
Executable file
@@ -0,0 +1,155 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface Video {
|
||||
id: number
|
||||
title: string
|
||||
description?: string
|
||||
cover_image: string
|
||||
thumbnail?: string
|
||||
video_url?: string
|
||||
duration: number
|
||||
views?: number
|
||||
likes?: number
|
||||
userId: number
|
||||
createdAt: string
|
||||
username?: string
|
||||
status?: 'published' | 'draft' | 'processing' | 'failed' | 'blocked' | 'private' | 'featured'
|
||||
category?: string
|
||||
tags?: string[]
|
||||
comments?: number
|
||||
uploader?: {
|
||||
id: number
|
||||
username: string
|
||||
avatar?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface VideoCache {
|
||||
[key: string]: {
|
||||
data: Blob
|
||||
timestamp: number
|
||||
size: number
|
||||
}
|
||||
}
|
||||
|
||||
export const useVideoStore = defineStore('video', () => {
|
||||
const videos = ref<Video[]>([])
|
||||
const currentVideo = ref<Video | null>(null)
|
||||
const loading = ref<boolean>(false)
|
||||
const cache = ref<VideoCache>({})
|
||||
const cacheSize = ref<number>(0)
|
||||
const maxCacheSize = 100 * 1024 * 1024 // 100MB
|
||||
const cacheExpiry = 3 * 24 * 60 * 60 * 1000 // 3天
|
||||
|
||||
// 设置视频列表
|
||||
const setVideos = (videoList: Video[]) => {
|
||||
videos.value = videoList
|
||||
}
|
||||
|
||||
// 添加视频到列表
|
||||
const addVideo = (video: Video) => {
|
||||
videos.value.unshift(video)
|
||||
}
|
||||
|
||||
// 更新视频信息
|
||||
const updateVideo = (id: number, updates: Partial<Video>) => {
|
||||
const index = videos.value.findIndex(v => v.id === id)
|
||||
if (index !== -1) {
|
||||
videos.value[index] = { ...videos.value[index], ...updates }
|
||||
}
|
||||
}
|
||||
|
||||
// 删除视频
|
||||
const removeVideo = (id: number) => {
|
||||
const index = videos.value.findIndex(v => v.id === id)
|
||||
if (index !== -1) {
|
||||
videos.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置当前视频
|
||||
const setCurrentVideo = (video: Video) => {
|
||||
currentVideo.value = video
|
||||
}
|
||||
|
||||
// 设置加载状态
|
||||
const setLoading = (state: boolean) => {
|
||||
loading.value = state
|
||||
}
|
||||
|
||||
// 清理过期缓存
|
||||
const cleanExpiredCache = () => {
|
||||
const now = Date.now()
|
||||
Object.keys(cache.value).forEach(key => {
|
||||
if (now - cache.value[key].timestamp > cacheExpiry) {
|
||||
cacheSize.value -= cache.value[key].size
|
||||
delete cache.value[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清理缓存以释放空间
|
||||
const cleanCacheForSpace = (neededSize: number) => {
|
||||
const entries = Object.entries(cache.value)
|
||||
.sort(([, a], [, b]) => a.timestamp - b.timestamp)
|
||||
|
||||
for (const [key, entry] of entries) {
|
||||
if (cacheSize.value + neededSize <= maxCacheSize) break
|
||||
cacheSize.value -= entry.size
|
||||
delete cache.value[key]
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存视频数据
|
||||
const cacheVideo = (videoId: string, data: Blob) => {
|
||||
cleanExpiredCache()
|
||||
|
||||
const size = data.size
|
||||
if (size > maxCacheSize) return // 单个文件太大,不缓存
|
||||
|
||||
if (cacheSize.value + size > maxCacheSize) {
|
||||
cleanCacheForSpace(size)
|
||||
}
|
||||
|
||||
cache.value[videoId] = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
size
|
||||
}
|
||||
cacheSize.value += size
|
||||
}
|
||||
|
||||
// 获取缓存的视频数据
|
||||
const getCachedVideo = (videoId: string): Blob | null => {
|
||||
const cached = cache.value[videoId]
|
||||
if (!cached) return null
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() - cached.timestamp > cacheExpiry) {
|
||||
cacheSize.value -= cached.size
|
||||
delete cache.value[videoId]
|
||||
return null
|
||||
}
|
||||
|
||||
return cached.data
|
||||
}
|
||||
|
||||
return {
|
||||
videos,
|
||||
currentVideo,
|
||||
loading,
|
||||
cache,
|
||||
cacheSize,
|
||||
maxCacheSize,
|
||||
setVideos,
|
||||
addVideo,
|
||||
updateVideo,
|
||||
removeVideo,
|
||||
setCurrentVideo,
|
||||
setLoading,
|
||||
cacheVideo,
|
||||
getCachedVideo,
|
||||
cleanExpiredCache
|
||||
}
|
||||
})
|
||||
17
src/style.css
Executable file
17
src/style.css
Executable file
@@ -0,0 +1,17 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica,
|
||||
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
251
src/styles/theme.css
Executable file
251
src/styles/theme.css
Executable file
@@ -0,0 +1,251 @@
|
||||
/* 梦回高句丽主题样式 */
|
||||
|
||||
/* 高句丽主题色彩定义 */
|
||||
:root {
|
||||
/* 主色调 - 高句丽传统色彩 */
|
||||
--primary-color: #8B4513; /* 深棕色 - 代表古朴厚重 */
|
||||
--primary-light: #CD853F; /* 浅棕色 */
|
||||
--primary-dark: #654321; /* 深棕色 */
|
||||
|
||||
/* 辅助色 */
|
||||
--secondary-color: #DAA520; /* 金黄色 - 代表皇室尊贵 */
|
||||
--secondary-light: #FFD700;
|
||||
--secondary-dark: #B8860B;
|
||||
|
||||
/* 中性色 */
|
||||
--text-primary: #2C1810; /* 深棕色文字 */
|
||||
--text-secondary: #5D4E37; /* 中等棕色文字 */
|
||||
--text-light: #8B7355; /* 浅棕色文字 */
|
||||
--text-white: #FFFFFF;
|
||||
|
||||
/* 背景色 */
|
||||
--bg-primary: #FFF8DC; /* 米白色背景 */
|
||||
--bg-secondary: #F5F5DC; /* 米色背景 */
|
||||
--bg-dark: #2F1B14; /* 深色背景 */
|
||||
|
||||
/* 边框色 */
|
||||
--border-color: #D2B48C; /* 浅棕色边框 */
|
||||
--border-light: #E6D3B7;
|
||||
--border-dark: #A0522D;
|
||||
|
||||
/* 状态色 */
|
||||
--success-color: #228B22;
|
||||
--warning-color: #FF8C00;
|
||||
--error-color: #DC143C;
|
||||
--info-color: #4682B4;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-light: 0 2px 4px rgba(139, 69, 19, 0.1);
|
||||
--shadow-medium: 0 4px 8px rgba(139, 69, 19, 0.15);
|
||||
--shadow-heavy: 0 8px 16px rgba(139, 69, 19, 0.2);
|
||||
|
||||
/* 圆角 */
|
||||
--border-radius-sm: 4px;
|
||||
--border-radius-md: 8px;
|
||||
--border-radius-lg: 12px;
|
||||
--border-radius-xl: 16px;
|
||||
|
||||
/* 间距 */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--spacing-2xl: 48px;
|
||||
}
|
||||
|
||||
/* 全局样式重置 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 高句丽风格装饰元素 */
|
||||
.goguryeo-pattern {
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 25%, var(--secondary-color) 2px, transparent 2px),
|
||||
radial-gradient(circle at 75% 75%, var(--primary-color) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.goguryeo-border {
|
||||
border: 2px solid var(--border-color);
|
||||
border-image: linear-gradient(45deg, var(--primary-color), var(--secondary-color), var(--primary-color)) 1;
|
||||
}
|
||||
|
||||
/* 高句丽风格按钮 */
|
||||
.btn-goguryeo {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-light));
|
||||
color: var(--text-white);
|
||||
border: none;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
.btn-goguryeo:hover {
|
||||
background: linear-gradient(135deg, var(--primary-dark), var(--primary-color));
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-goguryeo:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-light);
|
||||
}
|
||||
|
||||
/* 高句丽风格卡片 */
|
||||
.card-goguryeo {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--shadow-light);
|
||||
padding: var(--spacing-lg);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-goguryeo:hover {
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* 高句丽风格标题 */
|
||||
.title-goguryeo {
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
position: relative;
|
||||
padding-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.title-goguryeo::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 60px;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--secondary-color));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 高句丽风格输入框 */
|
||||
.input-goguryeo {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.input-goguryeo:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(139, 69, 19, 0.1);
|
||||
}
|
||||
|
||||
/* 高句丽风格标签 */
|
||||
.tag-hot {
|
||||
background: linear-gradient(135deg, #FF6B6B, #FF8E53);
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.tag-recommended {
|
||||
background: linear-gradient(135deg, var(--secondary-color), var(--secondary-light));
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 18px;
|
||||
--spacing-xl: 24px;
|
||||
}
|
||||
|
||||
.card-goguryeo {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes goguryeo-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-goguryeo {
|
||||
border: 3px solid var(--border-light);
|
||||
border-top: 3px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: goguryeo-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 瀑布流布局 */
|
||||
.waterfall-container {
|
||||
column-count: auto;
|
||||
column-width: 300px;
|
||||
column-gap: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.waterfall-item {
|
||||
break-inside: avoid;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.waterfall-container {
|
||||
column-width: 280px;
|
||||
column-gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.waterfall-item {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
59
src/utils/eventBus.ts
Normal file
59
src/utils/eventBus.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 创建事件总线
|
||||
class EventBus {
|
||||
private events: Record<string, Function[]> = {}
|
||||
|
||||
// 监听事件
|
||||
on(event: string, callback: Function) {
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = []
|
||||
}
|
||||
this.events[event].push(callback)
|
||||
}
|
||||
|
||||
// 移除事件监听
|
||||
off(event: string, callback?: Function) {
|
||||
if (!this.events[event]) return
|
||||
|
||||
if (callback) {
|
||||
const index = this.events[event].indexOf(callback)
|
||||
if (index > -1) {
|
||||
this.events[event].splice(index, 1)
|
||||
}
|
||||
} else {
|
||||
this.events[event] = []
|
||||
}
|
||||
}
|
||||
|
||||
// 触发事件
|
||||
emit(event: string, ...args: any[]) {
|
||||
if (!this.events[event]) return
|
||||
|
||||
this.events[event].forEach(callback => {
|
||||
callback(...args)
|
||||
})
|
||||
}
|
||||
|
||||
// 只监听一次
|
||||
once(event: string, callback: Function) {
|
||||
const onceCallback = (...args: any[]) => {
|
||||
callback(...args)
|
||||
this.off(event, onceCallback)
|
||||
}
|
||||
this.on(event, onceCallback)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局事件总线实例
|
||||
export const eventBus = new EventBus()
|
||||
|
||||
// 定义事件类型
|
||||
export const EVENTS = {
|
||||
CATEGORY_ORDER_CHANGED: 'category:order:changed',
|
||||
CATEGORY_CREATED: 'category:created',
|
||||
CATEGORY_UPDATED: 'category:updated',
|
||||
CATEGORY_DELETED: 'category:deleted'
|
||||
} as const
|
||||
|
||||
export default eventBus
|
||||
223
src/utils/http.ts
Executable file
223
src/utils/http.ts
Executable file
@@ -0,0 +1,223 @@
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
// Token刷新相关变量
|
||||
let isRefreshing = false
|
||||
let failedQueue: Array<{ resolve: Function; reject: Function }> = []
|
||||
|
||||
// 处理队列中的请求
|
||||
const processQueue = (error: any, token: string | null = null) => {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(token)
|
||||
}
|
||||
})
|
||||
|
||||
failedQueue = []
|
||||
}
|
||||
|
||||
// 刷新token函数
|
||||
const refreshToken = async (): Promise<string | null> => {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
const currentToken = userStore.token
|
||||
|
||||
if (!currentToken) {
|
||||
throw new Error('No token available')
|
||||
}
|
||||
|
||||
// 调用刷新token的API
|
||||
const response = await axios.post('/api/auth/refresh', {}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`
|
||||
}
|
||||
})
|
||||
|
||||
const newToken = response.data?.data?.token
|
||||
if (newToken) {
|
||||
// 更新store中的token
|
||||
userStore.token = newToken
|
||||
localStorage.setItem('token', newToken)
|
||||
return newToken
|
||||
}
|
||||
|
||||
throw new Error('No new token received')
|
||||
} catch (error) {
|
||||
console.error('Token刷新失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 创建axios实例
|
||||
const http: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
http.interceptors.request.use(
|
||||
(config: any) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 添加token到请求头
|
||||
if (userStore.token) {
|
||||
config.headers = config.headers || {}
|
||||
config.headers.Authorization = `Bearer ${userStore.token}`
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('请求拦截器错误:', error)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
console.error('HTTP请求错误:', error)
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
const errorMessage = data?.message || '请求失败'
|
||||
|
||||
// 处理401错误,尝试刷新token
|
||||
if (status === 401 && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
// 如果正在刷新token,将请求加入队列
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject })
|
||||
}).then(token => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
return http(originalRequest)
|
||||
}).catch(err => {
|
||||
return Promise.reject(err)
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const newToken = await refreshToken()
|
||||
if (newToken) {
|
||||
// 刷新成功,处理队列中的请求
|
||||
processQueue(null, newToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return http(originalRequest)
|
||||
} else {
|
||||
throw new Error('Token refresh failed')
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,清除用户信息并跳转登录
|
||||
processQueue(refreshError, null)
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
// 如果已经尝试过刷新token但仍然401,则直接登出
|
||||
if (originalRequest._retry) {
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
// 403可能是token过期或权限不足
|
||||
if (data?.message && data.message.includes('令牌')) {
|
||||
// Token相关错误,清除本地存储并提示重新登录
|
||||
const userStore = useUserStore()
|
||||
userStore.logout()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
} else {
|
||||
ElMessage.error('权限不足')
|
||||
}
|
||||
break
|
||||
case 404:
|
||||
ElMessage.error('请求的资源不存在')
|
||||
break
|
||||
case 500:
|
||||
ElMessage.error('服务器内部错误')
|
||||
break
|
||||
default:
|
||||
ElMessage.error(errorMessage)
|
||||
}
|
||||
|
||||
// 返回包含错误信息的对象,便于组件处理
|
||||
return Promise.reject({
|
||||
code: status,
|
||||
message: errorMessage,
|
||||
data: data,
|
||||
response: error.response
|
||||
})
|
||||
} else if (error.request) {
|
||||
const networkError = '网络连接失败,请检查网络'
|
||||
ElMessage.error(networkError)
|
||||
return Promise.reject({
|
||||
code: 'NETWORK_ERROR',
|
||||
message: networkError,
|
||||
data: null
|
||||
})
|
||||
} else {
|
||||
const configError = '请求配置错误'
|
||||
ElMessage.error(configError)
|
||||
return Promise.reject({
|
||||
code: -1,
|
||||
message: configError,
|
||||
data: null
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 封装常用的HTTP方法
|
||||
export const httpGet = <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return http.get(url, config)
|
||||
}
|
||||
|
||||
export const httpPost = <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return http.post(url, data, config)
|
||||
}
|
||||
|
||||
export const httpPut = <T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return http.put(url, data, config)
|
||||
}
|
||||
|
||||
export const httpDelete = <T = any>(url: string, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return http.delete(url, config)
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
export const httpUpload = <T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<T> => {
|
||||
return http.post(url, formData, {
|
||||
...config,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...config?.headers
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default http
|
||||
695
src/utils/mobileAdapter.ts
Executable file
695
src/utils/mobileAdapter.ts
Executable file
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* 移动端适配工具
|
||||
* 用于处理移动设备的响应式布局、触摸手势和设备特性检测
|
||||
*/
|
||||
|
||||
interface DeviceInfo {
|
||||
isMobile: boolean
|
||||
isTablet: boolean
|
||||
isDesktop: boolean
|
||||
isIOS: boolean
|
||||
isAndroid: boolean
|
||||
screenWidth: number
|
||||
screenHeight: number
|
||||
pixelRatio: number
|
||||
orientation: 'portrait' | 'landscape'
|
||||
hasTouch: boolean
|
||||
userAgent: string
|
||||
}
|
||||
|
||||
interface TouchGesture {
|
||||
type: 'tap' | 'swipe' | 'pinch' | 'pan'
|
||||
startX: number
|
||||
startY: number
|
||||
endX: number
|
||||
endY: number
|
||||
deltaX: number
|
||||
deltaY: number
|
||||
distance: number
|
||||
duration: number
|
||||
scale?: number
|
||||
velocity?: number
|
||||
direction?: 'up' | 'down' | 'left' | 'right'
|
||||
}
|
||||
|
||||
interface AdapterConfig {
|
||||
breakpoints: {
|
||||
mobile: number
|
||||
tablet: number
|
||||
desktop: number
|
||||
}
|
||||
swipeThreshold: number
|
||||
tapThreshold: number
|
||||
longPressDelay: number
|
||||
enableGestures: boolean
|
||||
enableOrientationLock: boolean
|
||||
enableViewportFix: boolean
|
||||
}
|
||||
|
||||
type GestureCallback = (gesture: TouchGesture, event: TouchEvent) => void
|
||||
type OrientationCallback = (orientation: 'portrait' | 'landscape') => void
|
||||
type ResizeCallback = (width: number, height: number) => void
|
||||
|
||||
class MobileAdapter {
|
||||
private config: AdapterConfig
|
||||
private deviceInfo: DeviceInfo
|
||||
private gestureCallbacks: Map<string, GestureCallback[]> = new Map()
|
||||
private orientationCallbacks: OrientationCallback[] = []
|
||||
private resizeCallbacks: ResizeCallback[] = []
|
||||
private touchStartTime: number = 0
|
||||
private touchStartPos: { x: number; y: number } = { x: 0, y: 0 }
|
||||
private lastTouchPos: { x: number; y: number } = { x: 0, y: 0 }
|
||||
private isGestureActive: boolean = false
|
||||
private gestureStartDistance: number = 0
|
||||
private gestureStartScale: number = 1
|
||||
|
||||
constructor(config?: Partial<AdapterConfig>) {
|
||||
this.config = {
|
||||
breakpoints: {
|
||||
mobile: 768,
|
||||
tablet: 1024,
|
||||
desktop: 1200
|
||||
},
|
||||
swipeThreshold: 50,
|
||||
tapThreshold: 10,
|
||||
longPressDelay: 500,
|
||||
enableGestures: true,
|
||||
enableOrientationLock: false,
|
||||
enableViewportFix: true,
|
||||
...config
|
||||
}
|
||||
|
||||
this.deviceInfo = this.detectDevice()
|
||||
this.init()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化适配器
|
||||
*/
|
||||
private init(): void {
|
||||
// 设置视口
|
||||
if (this.config.enableViewportFix) {
|
||||
this.setupViewport()
|
||||
}
|
||||
|
||||
// 绑定事件
|
||||
this.bindEvents()
|
||||
|
||||
// 设置CSS变量
|
||||
this.setCSSVariables()
|
||||
|
||||
// 初始化手势
|
||||
if (this.config.enableGestures && this.deviceInfo.hasTouch) {
|
||||
this.initGestures()
|
||||
}
|
||||
|
||||
// 方向锁定
|
||||
if (this.config.enableOrientationLock && this.deviceInfo.isMobile) {
|
||||
this.setupOrientationLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测设备信息
|
||||
*/
|
||||
private detectDevice(): DeviceInfo {
|
||||
const userAgent = navigator.userAgent
|
||||
const screenWidth = window.screen.width
|
||||
const screenHeight = window.screen.height
|
||||
const pixelRatio = window.devicePixelRatio || 1
|
||||
|
||||
const isMobile = /Mobile|Android|iPhone/.test(userAgent) || screenWidth < this.config.breakpoints.mobile
|
||||
const isTablet = /Tablet|iPad/.test(userAgent) || (screenWidth >= this.config.breakpoints.mobile && screenWidth < this.config.breakpoints.tablet)
|
||||
const isDesktop = !isMobile && !isTablet
|
||||
const isIOS = /iPhone|iPad|iPod/.test(userAgent)
|
||||
const isAndroid = /Android/.test(userAgent)
|
||||
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||
const orientation = screenWidth > screenHeight ? 'landscape' : 'portrait'
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isIOS,
|
||||
isAndroid,
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
pixelRatio,
|
||||
orientation,
|
||||
hasTouch,
|
||||
userAgent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置视口
|
||||
*/
|
||||
private setupViewport(): void {
|
||||
let viewport = document.querySelector('meta[name="viewport"]') as HTMLMetaElement
|
||||
|
||||
if (!viewport) {
|
||||
viewport = document.createElement('meta')
|
||||
viewport.name = 'viewport'
|
||||
document.head.appendChild(viewport)
|
||||
}
|
||||
|
||||
// 基础视口设置
|
||||
let content = 'width=device-width, initial-scale=1.0, user-scalable=no'
|
||||
|
||||
// iOS Safari 特殊处理
|
||||
if (this.deviceInfo.isIOS) {
|
||||
content += ', viewport-fit=cover'
|
||||
}
|
||||
|
||||
viewport.content = content
|
||||
|
||||
// 防止iOS Safari地址栏影响
|
||||
if (this.deviceInfo.isIOS) {
|
||||
this.fixIOSViewport()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复iOS视口问题
|
||||
*/
|
||||
private fixIOSViewport(): void {
|
||||
const setViewportHeight = () => {
|
||||
const vh = window.innerHeight * 0.01
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`)
|
||||
}
|
||||
|
||||
setViewportHeight()
|
||||
window.addEventListener('resize', setViewportHeight)
|
||||
window.addEventListener('orientationchange', () => {
|
||||
setTimeout(setViewportHeight, 100)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定事件
|
||||
*/
|
||||
private bindEvents(): void {
|
||||
// 窗口大小变化
|
||||
window.addEventListener('resize', this.handleResize.bind(this))
|
||||
|
||||
// 方向变化
|
||||
window.addEventListener('orientationchange', this.handleOrientationChange.bind(this))
|
||||
|
||||
// 页面可见性变化
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this))
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理窗口大小变化
|
||||
*/
|
||||
private handleResize(): void {
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
|
||||
// 更新设备信息
|
||||
this.deviceInfo.screenWidth = width
|
||||
this.deviceInfo.screenHeight = height
|
||||
this.deviceInfo.orientation = width > height ? 'landscape' : 'portrait'
|
||||
|
||||
// 更新CSS变量
|
||||
this.setCSSVariables()
|
||||
|
||||
// 触发回调
|
||||
this.resizeCallbacks.forEach(callback => {
|
||||
callback(width, height)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理方向变化
|
||||
*/
|
||||
private handleOrientationChange(): void {
|
||||
setTimeout(() => {
|
||||
const orientation = window.innerWidth > window.innerHeight ? 'landscape' : 'portrait'
|
||||
this.deviceInfo.orientation = orientation
|
||||
|
||||
this.orientationCallbacks.forEach(callback => {
|
||||
callback(orientation)
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理页面可见性变化
|
||||
*/
|
||||
private handleVisibilityChange(): void {
|
||||
if (document.hidden) {
|
||||
// 页面隐藏时的处理
|
||||
this.pauseGestures()
|
||||
} else {
|
||||
// 页面显示时的处理
|
||||
this.resumeGestures()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置CSS变量
|
||||
*/
|
||||
private setCSSVariables(): void {
|
||||
const root = document.documentElement
|
||||
|
||||
root.style.setProperty('--screen-width', `${this.deviceInfo.screenWidth}px`)
|
||||
root.style.setProperty('--screen-height', `${this.deviceInfo.screenHeight}px`)
|
||||
root.style.setProperty('--pixel-ratio', this.deviceInfo.pixelRatio.toString())
|
||||
root.style.setProperty('--is-mobile', this.deviceInfo.isMobile ? '1' : '0')
|
||||
root.style.setProperty('--is-tablet', this.deviceInfo.isTablet ? '1' : '0')
|
||||
root.style.setProperty('--is-desktop', this.deviceInfo.isDesktop ? '1' : '0')
|
||||
|
||||
// 安全区域(iOS刘海屏)
|
||||
if (this.deviceInfo.isIOS) {
|
||||
root.style.setProperty('--safe-area-inset-top', 'env(safe-area-inset-top)')
|
||||
root.style.setProperty('--safe-area-inset-bottom', 'env(safe-area-inset-bottom)')
|
||||
root.style.setProperty('--safe-area-inset-left', 'env(safe-area-inset-left)')
|
||||
root.style.setProperty('--safe-area-inset-right', 'env(safe-area-inset-right)')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化手势识别
|
||||
*/
|
||||
private initGestures(): void {
|
||||
document.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false })
|
||||
document.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false })
|
||||
document.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: false })
|
||||
document.addEventListener('touchcancel', this.handleTouchCancel.bind(this), { passive: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理触摸开始
|
||||
*/
|
||||
private handleTouchStart(event: TouchEvent): void {
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0]
|
||||
this.touchStartTime = Date.now()
|
||||
this.touchStartPos = { x: touch.clientX, y: touch.clientY }
|
||||
this.lastTouchPos = { x: touch.clientX, y: touch.clientY }
|
||||
this.isGestureActive = true
|
||||
} else if (event.touches.length === 2) {
|
||||
// 双指手势
|
||||
const touch1 = event.touches[0]
|
||||
const touch2 = event.touches[1]
|
||||
this.gestureStartDistance = this.getDistance(touch1, touch2)
|
||||
this.gestureStartScale = 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理触摸移动
|
||||
*/
|
||||
private handleTouchMove(event: TouchEvent): void {
|
||||
if (!this.isGestureActive) return
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0]
|
||||
this.lastTouchPos = { x: touch.clientX, y: touch.clientY }
|
||||
|
||||
// 计算移动距离
|
||||
const deltaX = touch.clientX - this.touchStartPos.x
|
||||
const deltaY = touch.clientY - this.touchStartPos.y
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||||
|
||||
// 如果移动距离超过阈值,触发pan手势
|
||||
if (distance > this.config.tapThreshold) {
|
||||
const gesture: TouchGesture = {
|
||||
type: 'pan',
|
||||
startX: this.touchStartPos.x,
|
||||
startY: this.touchStartPos.y,
|
||||
endX: touch.clientX,
|
||||
endY: touch.clientY,
|
||||
deltaX,
|
||||
deltaY,
|
||||
distance,
|
||||
duration: Date.now() - this.touchStartTime
|
||||
}
|
||||
|
||||
this.triggerGesture('pan', gesture, event)
|
||||
}
|
||||
} else if (event.touches.length === 2) {
|
||||
// 双指缩放
|
||||
const touch1 = event.touches[0]
|
||||
const touch2 = event.touches[1]
|
||||
const currentDistance = this.getDistance(touch1, touch2)
|
||||
const scale = currentDistance / this.gestureStartDistance
|
||||
|
||||
const gesture: TouchGesture = {
|
||||
type: 'pinch',
|
||||
startX: (touch1.clientX + touch2.clientX) / 2,
|
||||
startY: (touch1.clientY + touch2.clientY) / 2,
|
||||
endX: (touch1.clientX + touch2.clientX) / 2,
|
||||
endY: (touch1.clientY + touch2.clientY) / 2,
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
distance: currentDistance,
|
||||
duration: Date.now() - this.touchStartTime,
|
||||
scale
|
||||
}
|
||||
|
||||
this.triggerGesture('pinch', gesture, event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理触摸结束
|
||||
*/
|
||||
private handleTouchEnd(event: TouchEvent): void {
|
||||
if (!this.isGestureActive) return
|
||||
|
||||
const duration = Date.now() - this.touchStartTime
|
||||
const deltaX = this.lastTouchPos.x - this.touchStartPos.x
|
||||
const deltaY = this.lastTouchPos.y - this.touchStartPos.y
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||||
const velocity = distance / duration
|
||||
|
||||
// 判断手势类型
|
||||
if (distance < this.config.tapThreshold && duration < this.config.longPressDelay) {
|
||||
// 点击
|
||||
const gesture: TouchGesture = {
|
||||
type: 'tap',
|
||||
startX: this.touchStartPos.x,
|
||||
startY: this.touchStartPos.y,
|
||||
endX: this.lastTouchPos.x,
|
||||
endY: this.lastTouchPos.y,
|
||||
deltaX,
|
||||
deltaY,
|
||||
distance,
|
||||
duration
|
||||
}
|
||||
|
||||
this.triggerGesture('tap', gesture, event)
|
||||
} else if (distance > this.config.swipeThreshold) {
|
||||
// 滑动
|
||||
const direction = this.getSwipeDirection(deltaX, deltaY)
|
||||
const gesture: TouchGesture = {
|
||||
type: 'swipe',
|
||||
startX: this.touchStartPos.x,
|
||||
startY: this.touchStartPos.y,
|
||||
endX: this.lastTouchPos.x,
|
||||
endY: this.lastTouchPos.y,
|
||||
deltaX,
|
||||
deltaY,
|
||||
distance,
|
||||
duration,
|
||||
velocity,
|
||||
direction
|
||||
}
|
||||
|
||||
this.triggerGesture('swipe', gesture, event)
|
||||
}
|
||||
|
||||
this.isGestureActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理触摸取消
|
||||
*/
|
||||
private handleTouchCancel(): void {
|
||||
this.isGestureActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取两点间距离
|
||||
*/
|
||||
private getDistance(touch1: Touch, touch2: Touch): number {
|
||||
const deltaX = touch1.clientX - touch2.clientX
|
||||
const deltaY = touch1.clientY - touch2.clientY
|
||||
return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取滑动方向
|
||||
*/
|
||||
private getSwipeDirection(deltaX: number, deltaY: number): 'up' | 'down' | 'left' | 'right' {
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
return deltaX > 0 ? 'right' : 'left'
|
||||
} else {
|
||||
return deltaY > 0 ? 'down' : 'up'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发手势回调
|
||||
*/
|
||||
private triggerGesture(type: string, gesture: TouchGesture, event: TouchEvent): void {
|
||||
const callbacks = this.gestureCallbacks.get(type)
|
||||
if (callbacks) {
|
||||
callbacks.forEach(callback => {
|
||||
callback(gesture, event)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置方向锁定
|
||||
*/
|
||||
private setupOrientationLock(): void {
|
||||
if ('screen' in window && 'orientation' in window.screen) {
|
||||
// 尝试锁定为竖屏
|
||||
try {
|
||||
(window.screen.orientation as any).lock('portrait')
|
||||
} catch (error) {
|
||||
console.warn('无法锁定屏幕方向:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停手势识别
|
||||
*/
|
||||
private pauseGestures(): void {
|
||||
this.isGestureActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复手势识别
|
||||
*/
|
||||
private resumeGestures(): void {
|
||||
// 手势识别会在下次触摸时自动恢复
|
||||
}
|
||||
|
||||
// 公共API
|
||||
|
||||
/**
|
||||
* 获取设备信息
|
||||
*/
|
||||
getDeviceInfo(): DeviceInfo {
|
||||
return { ...this.deviceInfo }
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为移动设备
|
||||
*/
|
||||
isMobile(): boolean {
|
||||
return this.deviceInfo.isMobile
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为平板设备
|
||||
*/
|
||||
isTablet(): boolean {
|
||||
return this.deviceInfo.isTablet
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为桌面设备
|
||||
*/
|
||||
isDesktop(): boolean {
|
||||
return this.deviceInfo.isDesktop
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前方向
|
||||
*/
|
||||
getOrientation(): 'portrait' | 'landscape' {
|
||||
return this.deviceInfo.orientation
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加手势监听器
|
||||
*/
|
||||
onGesture(type: string, callback: GestureCallback): void {
|
||||
if (!this.gestureCallbacks.has(type)) {
|
||||
this.gestureCallbacks.set(type, [])
|
||||
}
|
||||
this.gestureCallbacks.get(type)!.push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除手势监听器
|
||||
*/
|
||||
offGesture(type: string, callback: GestureCallback): void {
|
||||
const callbacks = this.gestureCallbacks.get(type)
|
||||
if (callbacks) {
|
||||
const index = callbacks.indexOf(callback)
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加方向变化监听器
|
||||
*/
|
||||
onOrientationChange(callback: OrientationCallback): void {
|
||||
this.orientationCallbacks.push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除方向变化监听器
|
||||
*/
|
||||
offOrientationChange(callback: OrientationCallback): void {
|
||||
const index = this.orientationCallbacks.indexOf(callback)
|
||||
if (index > -1) {
|
||||
this.orientationCallbacks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加窗口大小变化监听器
|
||||
*/
|
||||
onResize(callback: ResizeCallback): void {
|
||||
this.resizeCallbacks.push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除窗口大小变化监听器
|
||||
*/
|
||||
offResize(callback: ResizeCallback): void {
|
||||
const index = this.resizeCallbacks.indexOf(callback)
|
||||
if (index > -1) {
|
||||
this.resizeCallbacks.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取安全区域
|
||||
*/
|
||||
getSafeArea(): {
|
||||
top: number
|
||||
bottom: number
|
||||
left: number
|
||||
right: number
|
||||
} {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
|
||||
return {
|
||||
top: parseInt(computedStyle.getPropertyValue('--safe-area-inset-top') || '0'),
|
||||
bottom: parseInt(computedStyle.getPropertyValue('--safe-area-inset-bottom') || '0'),
|
||||
left: parseInt(computedStyle.getPropertyValue('--safe-area-inset-left') || '0'),
|
||||
right: parseInt(computedStyle.getPropertyValue('--safe-area-inset-right') || '0')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用页面滚动
|
||||
*/
|
||||
disableScroll(): void {
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.body.style.position = 'fixed'
|
||||
document.body.style.width = '100%'
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用页面滚动
|
||||
*/
|
||||
enableScroll(): void {
|
||||
document.body.style.overflow = ''
|
||||
document.body.style.position = ''
|
||||
document.body.style.width = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 振动反馈
|
||||
*/
|
||||
vibrate(pattern: number | number[] = 100): void {
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁适配器
|
||||
*/
|
||||
destroy(): void {
|
||||
// 移除事件监听器
|
||||
window.removeEventListener('resize', this.handleResize)
|
||||
window.removeEventListener('orientationchange', this.handleOrientationChange)
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
|
||||
// 清空回调
|
||||
this.gestureCallbacks.clear()
|
||||
this.orientationCallbacks.length = 0
|
||||
this.resizeCallbacks.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局适配器实例
|
||||
export const mobileAdapter = new MobileAdapter()
|
||||
|
||||
// 导出类型和类
|
||||
export type { DeviceInfo, TouchGesture, AdapterConfig }
|
||||
export { MobileAdapter }
|
||||
|
||||
// Vue 3 组合式函数
|
||||
export function useMobileAdapter(config?: Partial<AdapterConfig>) {
|
||||
const adapter = new MobileAdapter(config)
|
||||
|
||||
const deviceInfo = adapter.getDeviceInfo()
|
||||
const isMobile = adapter.isMobile()
|
||||
const isTablet = adapter.isTablet()
|
||||
const isDesktop = adapter.isDesktop()
|
||||
const orientation = adapter.getOrientation()
|
||||
|
||||
const onGesture = (type: string, callback: GestureCallback) => {
|
||||
adapter.onGesture(type, callback)
|
||||
}
|
||||
|
||||
const onOrientationChange = (callback: OrientationCallback) => {
|
||||
adapter.onOrientationChange(callback)
|
||||
}
|
||||
|
||||
const onResize = (callback: ResizeCallback) => {
|
||||
adapter.onResize(callback)
|
||||
}
|
||||
|
||||
const getSafeArea = () => {
|
||||
return adapter.getSafeArea()
|
||||
}
|
||||
|
||||
const disableScroll = () => {
|
||||
adapter.disableScroll()
|
||||
}
|
||||
|
||||
const enableScroll = () => {
|
||||
adapter.enableScroll()
|
||||
}
|
||||
|
||||
const vibrate = (pattern?: number | number[]) => {
|
||||
adapter.vibrate(pattern)
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
const cleanup = () => {
|
||||
adapter.destroy()
|
||||
}
|
||||
|
||||
return {
|
||||
deviceInfo,
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
orientation,
|
||||
onGesture,
|
||||
onOrientationChange,
|
||||
onResize,
|
||||
getSafeArea,
|
||||
disableScroll,
|
||||
enableScroll,
|
||||
vibrate,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
579
src/utils/playbackStats.ts
Executable file
579
src/utils/playbackStats.ts
Executable file
@@ -0,0 +1,579 @@
|
||||
/**
|
||||
* 播放量统计工具
|
||||
* 用于管理视频播放量的统计、分析和上报功能
|
||||
*/
|
||||
|
||||
interface PlaybackEvent {
|
||||
videoId: string
|
||||
userId?: string
|
||||
sessionId: string
|
||||
timestamp: number
|
||||
eventType: PlaybackEventType
|
||||
data?: any
|
||||
}
|
||||
|
||||
type PlaybackEventType =
|
||||
| 'play' // 开始播放
|
||||
| 'pause' // 暂停
|
||||
| 'resume' // 恢复播放
|
||||
| 'seek' // 跳转
|
||||
| 'end' // 播放结束
|
||||
| 'error' // 播放错误
|
||||
| 'buffer' // 缓冲
|
||||
| 'quality_change' // 画质切换
|
||||
| 'fullscreen' // 全屏
|
||||
| 'volume_change' // 音量变化
|
||||
| 'speed_change' // 播放速度变化
|
||||
|
||||
interface PlaybackSession {
|
||||
sessionId: string
|
||||
videoId: string
|
||||
userId?: string
|
||||
startTime: number
|
||||
endTime?: number
|
||||
totalWatchTime: number
|
||||
maxProgress: number
|
||||
events: PlaybackEvent[]
|
||||
quality: string
|
||||
device: string
|
||||
browser: string
|
||||
ip?: string
|
||||
location?: string
|
||||
referrer?: string
|
||||
}
|
||||
|
||||
interface PlaybackStats {
|
||||
videoId: string
|
||||
totalViews: number
|
||||
uniqueViews: number
|
||||
totalWatchTime: number
|
||||
averageWatchTime: number
|
||||
completionRate: number
|
||||
engagementRate: number
|
||||
bounceRate: number
|
||||
retentionCurve: number[]
|
||||
qualityDistribution: Record<string, number>
|
||||
deviceDistribution: Record<string, number>
|
||||
geographicDistribution: Record<string, number>
|
||||
hourlyDistribution: number[]
|
||||
dailyDistribution: number[]
|
||||
}
|
||||
|
||||
interface StatsConfig {
|
||||
batchSize: number
|
||||
uploadInterval: number
|
||||
retryAttempts: number
|
||||
enableRealtime: boolean
|
||||
enableAnalytics: boolean
|
||||
minWatchTime: number // 最小观看时间(秒)才算有效播放
|
||||
validViewThreshold: number // 有效播放阈值(百分比)
|
||||
}
|
||||
|
||||
class PlaybackStatsManager {
|
||||
private config: StatsConfig
|
||||
private currentSession: PlaybackSession | null = null
|
||||
private eventQueue: PlaybackEvent[] = []
|
||||
private sessionQueue: PlaybackSession[] = []
|
||||
private uploadTimer: number | null = null
|
||||
private retryTimer: number | null = null
|
||||
private isUploading: boolean = false
|
||||
|
||||
constructor(config?: Partial<StatsConfig>) {
|
||||
this.config = {
|
||||
batchSize: 50,
|
||||
uploadInterval: 30000, // 30秒
|
||||
retryAttempts: 3,
|
||||
enableRealtime: true,
|
||||
enableAnalytics: true,
|
||||
minWatchTime: 3, // 3秒
|
||||
validViewThreshold: 0.1, // 10%
|
||||
...config
|
||||
}
|
||||
|
||||
this.initUploadScheduler()
|
||||
this.bindPageEvents()
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始播放会话
|
||||
*/
|
||||
startSession(videoId: string, userId?: string): string {
|
||||
// 结束当前会话
|
||||
if (this.currentSession) {
|
||||
this.endSession()
|
||||
}
|
||||
|
||||
const sessionId = this.generateSessionId()
|
||||
const deviceInfo = this.getDeviceInfo()
|
||||
|
||||
this.currentSession = {
|
||||
sessionId,
|
||||
videoId,
|
||||
userId,
|
||||
startTime: Date.now(),
|
||||
totalWatchTime: 0,
|
||||
maxProgress: 0,
|
||||
events: [],
|
||||
quality: '720p',
|
||||
device: deviceInfo.device,
|
||||
browser: deviceInfo.browser,
|
||||
referrer: document.referrer
|
||||
}
|
||||
|
||||
// 记录播放开始事件
|
||||
this.recordEvent('play', {
|
||||
quality: this.currentSession.quality,
|
||||
device: this.currentSession.device,
|
||||
browser: this.currentSession.browser
|
||||
})
|
||||
|
||||
return sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束播放会话
|
||||
*/
|
||||
endSession(): void {
|
||||
if (!this.currentSession) {
|
||||
return
|
||||
}
|
||||
|
||||
this.currentSession.endTime = Date.now()
|
||||
|
||||
// 记录播放结束事件
|
||||
this.recordEvent('end', {
|
||||
totalWatchTime: this.currentSession.totalWatchTime,
|
||||
maxProgress: this.currentSession.maxProgress
|
||||
})
|
||||
|
||||
// 检查是否为有效播放
|
||||
if (this.isValidView(this.currentSession)) {
|
||||
this.sessionQueue.push({ ...this.currentSession })
|
||||
}
|
||||
|
||||
this.currentSession = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录播放事件
|
||||
*/
|
||||
recordEvent(eventType: PlaybackEventType, data?: any): void {
|
||||
if (!this.currentSession) {
|
||||
return
|
||||
}
|
||||
|
||||
const event: PlaybackEvent = {
|
||||
videoId: this.currentSession.videoId,
|
||||
userId: this.currentSession.userId,
|
||||
sessionId: this.currentSession.sessionId,
|
||||
timestamp: Date.now(),
|
||||
eventType,
|
||||
data
|
||||
}
|
||||
|
||||
this.currentSession.events.push(event)
|
||||
this.eventQueue.push(event)
|
||||
|
||||
// 更新会话统计
|
||||
this.updateSessionStats(eventType, data)
|
||||
|
||||
// 实时上报关键事件
|
||||
if (this.config.enableRealtime && this.isKeyEvent(eventType)) {
|
||||
this.uploadEvents([event])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新播放进度
|
||||
*/
|
||||
updateProgress(currentTime: number, duration: number): void {
|
||||
if (!this.currentSession) {
|
||||
return
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? currentTime / duration : 0
|
||||
this.currentSession.maxProgress = Math.max(this.currentSession.maxProgress, progress)
|
||||
|
||||
// 记录观看时间(简化计算)
|
||||
const now = Date.now()
|
||||
const lastEventTime = this.currentSession.events.length > 0
|
||||
? this.currentSession.events[this.currentSession.events.length - 1].timestamp
|
||||
: this.currentSession.startTime
|
||||
|
||||
const timeDiff = now - lastEventTime
|
||||
if (timeDiff < 5000) { // 5秒内的时间差认为是连续观看
|
||||
this.currentSession.totalWatchTime += timeDiff / 1000
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话统计
|
||||
*/
|
||||
private updateSessionStats(eventType: PlaybackEventType, data?: any): void {
|
||||
if (!this.currentSession) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (eventType) {
|
||||
case 'quality_change':
|
||||
if (data?.quality) {
|
||||
this.currentSession.quality = data.quality
|
||||
}
|
||||
break
|
||||
case 'seek':
|
||||
// 跳转可能影响观看时间计算
|
||||
break
|
||||
case 'error':
|
||||
// 记录错误信息
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为有效播放
|
||||
*/
|
||||
private isValidView(session: PlaybackSession): boolean {
|
||||
// 观看时间超过最小阈值
|
||||
if (session.totalWatchTime < this.config.minWatchTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 播放进度超过有效阈值
|
||||
if (session.maxProgress < this.config.validViewThreshold) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为关键事件
|
||||
*/
|
||||
private isKeyEvent(eventType: PlaybackEventType): boolean {
|
||||
return ['play', 'end', 'error'].includes(eventType)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化上传调度器
|
||||
*/
|
||||
private initUploadScheduler(): void {
|
||||
this.uploadTimer = window.setInterval(() => {
|
||||
this.uploadBatch()
|
||||
}, this.config.uploadInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传数据
|
||||
*/
|
||||
private async uploadBatch(): Promise<void> {
|
||||
if (this.isUploading || (this.eventQueue.length === 0 && this.sessionQueue.length === 0)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isUploading = true
|
||||
|
||||
try {
|
||||
// 上传事件
|
||||
if (this.eventQueue.length > 0) {
|
||||
const events = this.eventQueue.splice(0, this.config.batchSize)
|
||||
await this.uploadEvents(events)
|
||||
}
|
||||
|
||||
// 上传会话
|
||||
if (this.sessionQueue.length > 0) {
|
||||
const sessions = this.sessionQueue.splice(0, this.config.batchSize)
|
||||
await this.uploadSessions(sessions)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传播放统计数据失败:', error)
|
||||
this.scheduleRetry()
|
||||
} finally {
|
||||
this.isUploading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传事件数据
|
||||
*/
|
||||
private async uploadEvents(events: PlaybackEvent[]): Promise<void> {
|
||||
const response = await fetch('/api/analytics/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ events })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`上传事件失败: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传会话数据
|
||||
*/
|
||||
private async uploadSessions(sessions: PlaybackSession[]): Promise<void> {
|
||||
const response = await fetch('/api/analytics/sessions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ sessions })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`上传会话失败: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度重试
|
||||
*/
|
||||
private scheduleRetry(): void {
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer)
|
||||
}
|
||||
|
||||
this.retryTimer = window.setTimeout(() => {
|
||||
this.uploadBatch()
|
||||
}, 5000) // 5秒后重试
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定页面事件
|
||||
*/
|
||||
private bindPageEvents(): void {
|
||||
// 页面卸载时上传剩余数据
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.endSession()
|
||||
this.uploadBatch()
|
||||
})
|
||||
|
||||
// 页面隐藏时暂停统计
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.recordEvent('pause', { reason: 'page_hidden' })
|
||||
} else {
|
||||
this.recordEvent('resume', { reason: 'page_visible' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成会话ID
|
||||
*/
|
||||
private generateSessionId(): string {
|
||||
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备信息
|
||||
*/
|
||||
private getDeviceInfo(): { device: string; browser: string } {
|
||||
const userAgent = navigator.userAgent
|
||||
|
||||
let device = 'desktop'
|
||||
if (/Mobile|Android|iPhone|iPad/.test(userAgent)) {
|
||||
device = 'mobile'
|
||||
} else if (/Tablet|iPad/.test(userAgent)) {
|
||||
device = 'tablet'
|
||||
}
|
||||
|
||||
let browser = 'unknown'
|
||||
if (userAgent.includes('Chrome')) {
|
||||
browser = 'chrome'
|
||||
} else if (userAgent.includes('Firefox')) {
|
||||
browser = 'firefox'
|
||||
} else if (userAgent.includes('Safari')) {
|
||||
browser = 'safari'
|
||||
} else if (userAgent.includes('Edge')) {
|
||||
browser = 'edge'
|
||||
}
|
||||
|
||||
return { device, browser }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话
|
||||
*/
|
||||
getCurrentSession(): PlaybackSession | null {
|
||||
return this.currentSession
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列状态
|
||||
*/
|
||||
getQueueStatus(): {
|
||||
eventQueueSize: number
|
||||
sessionQueueSize: number
|
||||
isUploading: boolean
|
||||
} {
|
||||
return {
|
||||
eventQueueSize: this.eventQueue.length,
|
||||
sessionQueueSize: this.sessionQueue.length,
|
||||
isUploading: this.isUploading
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.uploadTimer) {
|
||||
clearInterval(this.uploadTimer)
|
||||
this.uploadTimer = null
|
||||
}
|
||||
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer)
|
||||
this.retryTimer = null
|
||||
}
|
||||
|
||||
this.endSession()
|
||||
this.uploadBatch()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放统计分析器
|
||||
*/
|
||||
class PlaybackAnalyzer {
|
||||
/**
|
||||
* 分析播放统计数据
|
||||
*/
|
||||
static async analyzePlaybackStats(videoId: string, timeRange?: string): Promise<PlaybackStats> {
|
||||
const response = await fetch(`/api/analytics/stats/${videoId}?range=${timeRange || 'week'}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('获取播放统计失败')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算留存曲线
|
||||
*/
|
||||
static calculateRetentionCurve(sessions: PlaybackSession[]): number[] {
|
||||
const curve: number[] = new Array(100).fill(0)
|
||||
|
||||
sessions.forEach(session => {
|
||||
const progress = Math.floor(session.maxProgress * 100)
|
||||
for (let i = 0; i <= progress; i++) {
|
||||
curve[i]++
|
||||
}
|
||||
})
|
||||
|
||||
// 转换为百分比
|
||||
const totalSessions = sessions.length
|
||||
return curve.map(count => totalSessions > 0 ? (count / totalSessions) * 100 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算参与度指标
|
||||
*/
|
||||
static calculateEngagementMetrics(sessions: PlaybackSession[]): {
|
||||
averageWatchTime: number
|
||||
completionRate: number
|
||||
engagementRate: number
|
||||
bounceRate: number
|
||||
} {
|
||||
if (sessions.length === 0) {
|
||||
return {
|
||||
averageWatchTime: 0,
|
||||
completionRate: 0,
|
||||
engagementRate: 0,
|
||||
bounceRate: 0
|
||||
}
|
||||
}
|
||||
|
||||
const totalWatchTime = sessions.reduce((sum, s) => sum + s.totalWatchTime, 0)
|
||||
const completedSessions = sessions.filter(s => s.maxProgress >= 0.9).length
|
||||
const engagedSessions = sessions.filter(s => s.maxProgress >= 0.25).length
|
||||
const bouncedSessions = sessions.filter(s => s.maxProgress < 0.1).length
|
||||
|
||||
return {
|
||||
averageWatchTime: totalWatchTime / sessions.length,
|
||||
completionRate: (completedSessions / sessions.length) * 100,
|
||||
engagementRate: (engagedSessions / sessions.length) * 100,
|
||||
bounceRate: (bouncedSessions / sessions.length) * 100
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成热力图数据
|
||||
*/
|
||||
static generateHeatmapData(sessions: PlaybackSession[]): {
|
||||
hourly: number[]
|
||||
daily: number[]
|
||||
} {
|
||||
const hourly = new Array(24).fill(0)
|
||||
const daily = new Array(7).fill(0)
|
||||
|
||||
sessions.forEach(session => {
|
||||
const date = new Date(session.startTime)
|
||||
const hour = date.getHours()
|
||||
const day = date.getDay()
|
||||
|
||||
hourly[hour]++
|
||||
daily[day]++
|
||||
})
|
||||
|
||||
return { hourly, daily }
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局统计管理器实例
|
||||
export const playbackStatsManager = new PlaybackStatsManager()
|
||||
|
||||
// 导出类型和类
|
||||
export type {
|
||||
PlaybackEvent,
|
||||
PlaybackEventType,
|
||||
PlaybackSession,
|
||||
PlaybackStats,
|
||||
StatsConfig
|
||||
}
|
||||
export { PlaybackStatsManager, PlaybackAnalyzer }
|
||||
|
||||
// Vue 3 组合式函数
|
||||
export function usePlaybackStats(config?: Partial<StatsConfig>) {
|
||||
const statsManager = new PlaybackStatsManager(config)
|
||||
|
||||
const startSession = (videoId: string, userId?: string) => {
|
||||
return statsManager.startSession(videoId, userId)
|
||||
}
|
||||
|
||||
const endSession = () => {
|
||||
statsManager.endSession()
|
||||
}
|
||||
|
||||
const recordEvent = (eventType: PlaybackEventType, data?: any) => {
|
||||
statsManager.recordEvent(eventType, data)
|
||||
}
|
||||
|
||||
const updateProgress = (currentTime: number, duration: number) => {
|
||||
statsManager.updateProgress(currentTime, duration)
|
||||
}
|
||||
|
||||
const getCurrentSession = () => {
|
||||
return statsManager.getCurrentSession()
|
||||
}
|
||||
|
||||
const getQueueStatus = () => {
|
||||
return statsManager.getQueueStatus()
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
const cleanup = () => {
|
||||
statsManager.destroy()
|
||||
}
|
||||
|
||||
return {
|
||||
startSession,
|
||||
endSession,
|
||||
recordEvent,
|
||||
updateProgress,
|
||||
getCurrentSession,
|
||||
getQueueStatus,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
443
src/utils/videoCache.ts
Executable file
443
src/utils/videoCache.ts
Executable file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* 视频缓存管理工具
|
||||
* 用于管理视频的本地缓存、预加载和播放优化
|
||||
*/
|
||||
|
||||
interface CacheItem {
|
||||
id: string
|
||||
url: string
|
||||
blob?: Blob
|
||||
size: number
|
||||
timestamp: number
|
||||
accessCount: number
|
||||
lastAccess: number
|
||||
quality: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface CacheConfig {
|
||||
maxSize: number // 最大缓存大小(字节)
|
||||
maxItems: number // 最大缓存项数
|
||||
maxAge: number // 最大缓存时间(毫秒)
|
||||
preloadCount: number // 预加载视频数量
|
||||
qualityPriority: string[] // 画质优先级
|
||||
}
|
||||
|
||||
class VideoCache {
|
||||
private cache: Map<string, CacheItem> = new Map()
|
||||
private config: CacheConfig
|
||||
private currentSize: number = 0
|
||||
private preloadQueue: string[] = []
|
||||
private isPreloading: boolean = false
|
||||
|
||||
constructor(config?: Partial<CacheConfig>) {
|
||||
this.config = {
|
||||
maxSize: 500 * 1024 * 1024, // 500MB
|
||||
maxItems: 50,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24小时
|
||||
preloadCount: 5,
|
||||
qualityPriority: ['720p', '480p', '1080p', '360p'],
|
||||
...config
|
||||
}
|
||||
|
||||
// 初始化时清理过期缓存
|
||||
this.cleanup()
|
||||
|
||||
// 定期清理缓存
|
||||
setInterval(() => this.cleanup(), 5 * 60 * 1000) // 每5分钟清理一次
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的视频
|
||||
*/
|
||||
async get(videoId: string, quality: string = '720p'): Promise<string | null> {
|
||||
const cacheKey = this.getCacheKey(videoId, quality)
|
||||
const item = this.cache.get(cacheKey)
|
||||
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() - item.timestamp > this.config.maxAge) {
|
||||
this.remove(cacheKey)
|
||||
return null
|
||||
}
|
||||
|
||||
// 更新访问信息
|
||||
item.accessCount++
|
||||
item.lastAccess = Date.now()
|
||||
|
||||
// 如果有blob数据,返回blob URL
|
||||
if (item.blob) {
|
||||
return URL.createObjectURL(item.blob)
|
||||
}
|
||||
|
||||
return item.url
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存视频
|
||||
*/
|
||||
async set(videoId: string, url: string, quality: string = '720p', preload: boolean = false): Promise<void> {
|
||||
const cacheKey = this.getCacheKey(videoId, quality)
|
||||
|
||||
try {
|
||||
let blob: Blob | undefined
|
||||
let size = 0
|
||||
|
||||
// 如果需要预加载,下载视频数据
|
||||
if (preload) {
|
||||
const response = await fetch(url)
|
||||
if (response.ok) {
|
||||
blob = await response.blob()
|
||||
size = blob.size
|
||||
}
|
||||
}
|
||||
|
||||
// 检查缓存空间
|
||||
if (size > 0 && this.currentSize + size > this.config.maxSize) {
|
||||
await this.makeSpace(size)
|
||||
}
|
||||
|
||||
const item: CacheItem = {
|
||||
id: videoId,
|
||||
url,
|
||||
blob,
|
||||
size,
|
||||
timestamp: Date.now(),
|
||||
accessCount: 0,
|
||||
lastAccess: Date.now(),
|
||||
quality
|
||||
}
|
||||
|
||||
// 如果已存在,先移除旧的
|
||||
if (this.cache.has(cacheKey)) {
|
||||
this.remove(cacheKey)
|
||||
}
|
||||
|
||||
this.cache.set(cacheKey, item)
|
||||
this.currentSize += size
|
||||
|
||||
// 视频缓存成功
|
||||
} catch (error) {
|
||||
// 视频缓存失败
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载视频列表
|
||||
*/
|
||||
async preloadVideos(videoIds: string[], quality: string = '720p'): Promise<void> {
|
||||
if (this.isPreloading) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isPreloading = true
|
||||
const preloadList = videoIds.slice(0, this.config.preloadCount)
|
||||
|
||||
try {
|
||||
for (const videoId of preloadList) {
|
||||
const cacheKey = this.getCacheKey(videoId, quality)
|
||||
|
||||
// 如果已缓存,跳过
|
||||
if (this.cache.has(cacheKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 构造视频URL(这里需要根据实际API调整)
|
||||
const videoUrl = this.getVideoUrl(videoId, quality)
|
||||
await this.set(videoId, videoUrl, quality, true)
|
||||
|
||||
// 添加延迟避免过度占用带宽
|
||||
await this.delay(1000)
|
||||
}
|
||||
} catch (error) {
|
||||
// 预加载视频失败
|
||||
} finally {
|
||||
this.isPreloading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能预加载
|
||||
* 根据用户行为和网络状况智能预加载
|
||||
*/
|
||||
async smartPreload(currentVideoId: string, nextVideoIds: string[], userBehavior: any): Promise<void> {
|
||||
// 获取网络状况
|
||||
const connection = (navigator as any).connection
|
||||
const isSlowNetwork = connection && (connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g')
|
||||
|
||||
if (isSlowNetwork) {
|
||||
// 网络较慢,跳过预加载
|
||||
return
|
||||
}
|
||||
|
||||
// 根据用户行为调整预加载策略
|
||||
let preloadCount = this.config.preloadCount
|
||||
let quality = '720p'
|
||||
|
||||
if (userBehavior.skipRate > 0.7) {
|
||||
// 用户经常跳过,减少预加载
|
||||
preloadCount = Math.max(1, Math.floor(preloadCount / 2))
|
||||
quality = '480p'
|
||||
} else if (userBehavior.completionRate > 0.8) {
|
||||
// 用户观看完整度高,增加预加载
|
||||
preloadCount = Math.min(10, preloadCount * 2)
|
||||
}
|
||||
|
||||
// 优先预加载下一个视频
|
||||
const priorityList = [nextVideoIds[0], ...nextVideoIds.slice(1, preloadCount)].filter(Boolean)
|
||||
await this.preloadVideos(priorityList, quality)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除缓存项
|
||||
*/
|
||||
remove(cacheKey: string): void {
|
||||
const item = this.cache.get(cacheKey)
|
||||
if (item) {
|
||||
this.currentSize -= item.size
|
||||
if (item.blob) {
|
||||
URL.revokeObjectURL(URL.createObjectURL(item.blob))
|
||||
}
|
||||
this.cache.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期和低优先级缓存
|
||||
*/
|
||||
private cleanup(): void {
|
||||
const now = Date.now()
|
||||
const itemsToRemove: string[] = []
|
||||
|
||||
// 清理过期项
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (now - item.timestamp > this.config.maxAge) {
|
||||
itemsToRemove.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果超过最大项数,移除最少使用的项
|
||||
if (this.cache.size > this.config.maxItems) {
|
||||
const sortedItems = Array.from(this.cache.entries())
|
||||
.sort(([, a], [, b]) => {
|
||||
// 按访问次数和最后访问时间排序
|
||||
const scoreA = a.accessCount * 0.7 + (now - a.lastAccess) * 0.3
|
||||
const scoreB = b.accessCount * 0.7 + (now - b.lastAccess) * 0.3
|
||||
return scoreA - scoreB
|
||||
})
|
||||
|
||||
const removeCount = this.cache.size - this.config.maxItems
|
||||
for (let i = 0; i < removeCount; i++) {
|
||||
itemsToRemove.push(sortedItems[i][0])
|
||||
}
|
||||
}
|
||||
|
||||
// 执行移除
|
||||
itemsToRemove.forEach(key => this.remove(key))
|
||||
|
||||
if (itemsToRemove.length > 0) {
|
||||
// 清理缓存项
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为新缓存腾出空间
|
||||
*/
|
||||
private async makeSpace(requiredSize: number): Promise<void> {
|
||||
if (requiredSize > this.config.maxSize) {
|
||||
throw new Error('视频文件过大,无法缓存')
|
||||
}
|
||||
|
||||
const itemsToRemove: string[] = []
|
||||
let freedSize = 0
|
||||
|
||||
// 按优先级排序(访问次数少、时间久的优先移除)
|
||||
const sortedItems = Array.from(this.cache.entries())
|
||||
.sort(([, a], [, b]) => {
|
||||
const now = Date.now()
|
||||
const scoreA = a.accessCount - (now - a.lastAccess) / (60 * 60 * 1000) // 每小时减1分
|
||||
const scoreB = b.accessCount - (now - b.lastAccess) / (60 * 60 * 1000)
|
||||
return scoreA - scoreB
|
||||
})
|
||||
|
||||
for (const [key, item] of sortedItems) {
|
||||
if (freedSize >= requiredSize) {
|
||||
break
|
||||
}
|
||||
itemsToRemove.push(key)
|
||||
freedSize += item.size
|
||||
}
|
||||
|
||||
itemsToRemove.forEach(key => this.remove(key))
|
||||
// 为新缓存腾出空间
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
getStats(): {
|
||||
totalItems: number
|
||||
totalSize: number
|
||||
hitRate: number
|
||||
oldestItem: number
|
||||
newestItem: number
|
||||
} {
|
||||
const now = Date.now()
|
||||
let totalHits = 0
|
||||
let oldestTime = now
|
||||
let newestTime = 0
|
||||
|
||||
for (const item of this.cache.values()) {
|
||||
totalHits += item.accessCount
|
||||
oldestTime = Math.min(oldestTime, item.timestamp)
|
||||
newestTime = Math.max(newestTime, item.timestamp)
|
||||
}
|
||||
|
||||
return {
|
||||
totalItems: this.cache.size,
|
||||
totalSize: this.currentSize,
|
||||
hitRate: totalHits / Math.max(1, this.cache.size),
|
||||
oldestItem: now - oldestTime,
|
||||
newestItem: now - newestTime
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
clear(): void {
|
||||
for (const item of this.cache.values()) {
|
||||
if (item.blob) {
|
||||
URL.revokeObjectURL(URL.createObjectURL(item.blob))
|
||||
}
|
||||
}
|
||||
this.cache.clear()
|
||||
this.currentSize = 0
|
||||
// 已清空所有视频缓存
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存键
|
||||
*/
|
||||
private getCacheKey(videoId: string, quality: string): string {
|
||||
return `${videoId}_${quality}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频URL(需要根据实际API调整)
|
||||
*/
|
||||
private getVideoUrl(videoId: string, quality: string): string {
|
||||
return `/api/videos/${videoId}/stream?quality=${quality}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
private formatSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = bytes
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟函数
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局缓存实例
|
||||
export const videoCache = new VideoCache()
|
||||
|
||||
// 导出类型和类
|
||||
export type { CacheItem, CacheConfig }
|
||||
export { VideoCache }
|
||||
|
||||
// 缓存管理器
|
||||
export class VideoCacheManager {
|
||||
private static instance: VideoCacheManager
|
||||
private cache: VideoCache
|
||||
private userBehavior: {
|
||||
skipRate: number
|
||||
completionRate: number
|
||||
averageWatchTime: number
|
||||
} = {
|
||||
skipRate: 0,
|
||||
completionRate: 0,
|
||||
averageWatchTime: 0
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
this.cache = new VideoCache()
|
||||
this.initBehaviorTracking()
|
||||
}
|
||||
|
||||
static getInstance(): VideoCacheManager {
|
||||
if (!VideoCacheManager.instance) {
|
||||
VideoCacheManager.instance = new VideoCacheManager()
|
||||
}
|
||||
return VideoCacheManager.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化用户行为跟踪
|
||||
*/
|
||||
private initBehaviorTracking(): void {
|
||||
// 从localStorage恢复用户行为数据
|
||||
const savedBehavior = localStorage.getItem('user_video_behavior')
|
||||
if (savedBehavior) {
|
||||
try {
|
||||
this.userBehavior = JSON.parse(savedBehavior)
|
||||
} catch (error) {
|
||||
// 恢复用户行为数据失败
|
||||
}
|
||||
}
|
||||
|
||||
// 定期保存用户行为数据
|
||||
setInterval(() => {
|
||||
localStorage.setItem('user_video_behavior', JSON.stringify(this.userBehavior))
|
||||
}, 30000) // 每30秒保存一次
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户行为数据
|
||||
*/
|
||||
updateUserBehavior(behavior: Partial<typeof this.userBehavior>): void {
|
||||
Object.assign(this.userBehavior, behavior)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存实例
|
||||
*/
|
||||
getCache(): VideoCache {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能预加载视频
|
||||
*/
|
||||
async smartPreload(currentVideoId: string, nextVideoIds: string[]): Promise<void> {
|
||||
await this.cache.smartPreload(currentVideoId, nextVideoIds, this.userBehavior)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户行为数据
|
||||
*/
|
||||
getUserBehavior() {
|
||||
return { ...this.userBehavior }
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const videoCacheManager = VideoCacheManager.getInstance()
|
||||
623
src/utils/videoMarker.ts
Executable file
623
src/utils/videoMarker.ts
Executable file
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* 视频标记显示逻辑工具
|
||||
* 用于管理视频的各种标记状态和显示逻辑
|
||||
*/
|
||||
|
||||
interface VideoMarker {
|
||||
id: string
|
||||
type: MarkerType
|
||||
label: string
|
||||
color: string
|
||||
icon: string
|
||||
priority: number
|
||||
condition: (video: any, user: any) => boolean
|
||||
style?: {
|
||||
background?: string
|
||||
color?: string
|
||||
border?: string
|
||||
animation?: string
|
||||
}
|
||||
}
|
||||
|
||||
type MarkerType =
|
||||
| 'new' // 新视频
|
||||
| 'hot' // 热门
|
||||
| 'trending' // 趋势
|
||||
| 'featured' // 精选
|
||||
| 'premium' // 付费
|
||||
| 'live' // 直播
|
||||
| 'vip' // VIP专享
|
||||
| 'original' // 原创
|
||||
| 'hd' // 高清
|
||||
| 'subtitle' // 有字幕
|
||||
| 'watched' // 已观看
|
||||
| 'liked' // 已点赞
|
||||
| 'collected' // 已收藏
|
||||
| 'recommended' // 推荐
|
||||
| 'exclusive' // 独家
|
||||
| 'series' // 系列
|
||||
| 'updated' // 更新
|
||||
| 'completed' // 完结
|
||||
| 'preview' // 预览
|
||||
| 'sponsored' // 赞助
|
||||
|
||||
interface MarkerConfig {
|
||||
enabled: boolean
|
||||
showMultiple: boolean
|
||||
maxMarkers: number
|
||||
position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
||||
animation: boolean
|
||||
customMarkers: VideoMarker[]
|
||||
}
|
||||
|
||||
class VideoMarkerManager {
|
||||
private markers: Map<MarkerType, VideoMarker> = new Map()
|
||||
private config: MarkerConfig
|
||||
private userPreferences: any = {}
|
||||
|
||||
constructor(config?: Partial<MarkerConfig>) {
|
||||
this.config = {
|
||||
enabled: true,
|
||||
showMultiple: true,
|
||||
maxMarkers: 3,
|
||||
position: 'top-left',
|
||||
animation: true,
|
||||
customMarkers: [],
|
||||
...config
|
||||
}
|
||||
|
||||
this.initDefaultMarkers()
|
||||
this.loadUserPreferences()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化默认标记
|
||||
*/
|
||||
private initDefaultMarkers(): void {
|
||||
const defaultMarkers: VideoMarker[] = [
|
||||
{
|
||||
id: 'new',
|
||||
type: 'new',
|
||||
label: '新',
|
||||
color: '#ef4444',
|
||||
icon: 'icon-star',
|
||||
priority: 8,
|
||||
condition: (video) => {
|
||||
const now = Date.now()
|
||||
const uploadTime = new Date(video.uploadTime).getTime()
|
||||
return now - uploadTime < 7 * 24 * 60 * 60 * 1000 // 7天内
|
||||
},
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
|
||||
color: 'white',
|
||||
animation: 'pulse 2s infinite'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'hot',
|
||||
type: 'hot',
|
||||
label: '热门',
|
||||
color: '#f59e0b',
|
||||
icon: 'icon-fire',
|
||||
priority: 9,
|
||||
condition: (video) => {
|
||||
return video.views > 100000 && video.likes > 1000
|
||||
},
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
|
||||
color: 'white',
|
||||
animation: 'glow 3s ease-in-out infinite alternate'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'trending',
|
||||
type: 'trending',
|
||||
label: '趋势',
|
||||
color: '#8b5cf6',
|
||||
icon: 'icon-trending-up',
|
||||
priority: 7,
|
||||
condition: (video) => {
|
||||
const recentViews = video.recentViews || 0
|
||||
const totalViews = video.views || 0
|
||||
return recentViews / totalViews > 0.3 && recentViews > 5000
|
||||
},
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #8b5cf6, #7c3aed)',
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'featured',
|
||||
type: 'featured',
|
||||
label: '精选',
|
||||
color: '#d4af37',
|
||||
icon: 'icon-award',
|
||||
priority: 10,
|
||||
condition: (video) => video.featured === true,
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #d4af37, #b8941f)',
|
||||
color: 'white',
|
||||
border: '1px solid #f7d794'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'premium',
|
||||
type: 'premium',
|
||||
label: '付费',
|
||||
color: '#10b981',
|
||||
icon: 'icon-dollar-sign',
|
||||
priority: 6,
|
||||
condition: (video) => video.isPremium === true,
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'live',
|
||||
type: 'live',
|
||||
label: '直播',
|
||||
color: '#ef4444',
|
||||
icon: 'icon-radio',
|
||||
priority: 11,
|
||||
condition: (video) => video.isLive === true,
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #ef4444, #dc2626)',
|
||||
color: 'white',
|
||||
animation: 'blink 1s infinite'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'vip',
|
||||
type: 'vip',
|
||||
label: 'VIP',
|
||||
color: '#7c3aed',
|
||||
icon: 'icon-crown',
|
||||
priority: 5,
|
||||
condition: (video, user) => video.vipOnly === true,
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'original',
|
||||
type: 'original',
|
||||
label: '原创',
|
||||
color: '#06b6d4',
|
||||
icon: 'icon-edit',
|
||||
priority: 4,
|
||||
condition: (video) => video.isOriginal === true,
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #06b6d4, #0891b2)',
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'hd',
|
||||
type: 'hd',
|
||||
label: 'HD',
|
||||
color: '#64748b',
|
||||
icon: 'icon-monitor',
|
||||
priority: 2,
|
||||
condition: (video) => {
|
||||
const quality = video.quality || '480p'
|
||||
return ['720p', '1080p', '4k'].includes(quality)
|
||||
},
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #64748b, #475569)',
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
type: 'subtitle',
|
||||
label: '字幕',
|
||||
color: '#6b7280',
|
||||
icon: 'icon-type',
|
||||
priority: 1,
|
||||
condition: (video) => video.hasSubtitle === true,
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #6b7280, #4b5563)',
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'watched',
|
||||
type: 'watched',
|
||||
label: '已看',
|
||||
color: '#9ca3af',
|
||||
icon: 'icon-check',
|
||||
priority: 0,
|
||||
condition: (video, user) => {
|
||||
return user.watchHistory && user.watchHistory.includes(video.id)
|
||||
},
|
||||
style: {
|
||||
background: 'rgba(156, 163, 175, 0.8)',
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'liked',
|
||||
type: 'liked',
|
||||
label: '已赞',
|
||||
color: '#f87171',
|
||||
icon: 'icon-heart',
|
||||
priority: 3,
|
||||
condition: (video, user) => {
|
||||
return user.likedVideos && user.likedVideos.includes(video.id)
|
||||
},
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #f87171, #ef4444)',
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'collected',
|
||||
type: 'collected',
|
||||
label: '已藏',
|
||||
color: '#fbbf24',
|
||||
icon: 'icon-bookmark',
|
||||
priority: 3,
|
||||
condition: (video, user) => {
|
||||
return user.collections && user.collections.includes(video.id)
|
||||
},
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
color: 'white'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'recommended',
|
||||
type: 'recommended',
|
||||
label: '推荐',
|
||||
color: '#34d399',
|
||||
icon: 'icon-thumbs-up',
|
||||
priority: 6,
|
||||
condition: (video, user) => {
|
||||
return video.recommendedFor && video.recommendedFor.includes(user.id)
|
||||
},
|
||||
style: {
|
||||
background: 'linear-gradient(135deg, #34d399, #10b981)',
|
||||
color: 'white'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
defaultMarkers.forEach(marker => {
|
||||
this.markers.set(marker.type, marker)
|
||||
})
|
||||
|
||||
// 添加自定义标记
|
||||
this.config.customMarkers.forEach(marker => {
|
||||
this.markers.set(marker.type, marker)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载用户偏好设置
|
||||
*/
|
||||
private loadUserPreferences(): void {
|
||||
try {
|
||||
const saved = localStorage.getItem('video_marker_preferences')
|
||||
if (saved) {
|
||||
this.userPreferences = JSON.parse(saved)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载标记偏好设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户偏好设置
|
||||
*/
|
||||
private saveUserPreferences(): void {
|
||||
try {
|
||||
localStorage.setItem('video_marker_preferences', JSON.stringify(this.userPreferences))
|
||||
} catch (error) {
|
||||
console.error('保存标记偏好设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频的标记
|
||||
*/
|
||||
getVideoMarkers(video: any, user: any = {}): VideoMarker[] {
|
||||
if (!this.config.enabled) {
|
||||
return []
|
||||
}
|
||||
|
||||
const applicableMarkers: VideoMarker[] = []
|
||||
|
||||
for (const marker of this.markers.values()) {
|
||||
// 检查用户是否禁用了此标记
|
||||
if (this.userPreferences.disabled && this.userPreferences.disabled.includes(marker.type)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查标记条件
|
||||
try {
|
||||
if (marker.condition(video, user)) {
|
||||
applicableMarkers.push(marker)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`标记条件检查失败: ${marker.type}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 按优先级排序
|
||||
applicableMarkers.sort((a, b) => b.priority - a.priority)
|
||||
|
||||
// 限制显示数量
|
||||
const maxMarkers = this.config.showMultiple ? this.config.maxMarkers : 1
|
||||
return applicableMarkers.slice(0, maxMarkers)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成标记的HTML
|
||||
*/
|
||||
generateMarkerHTML(markers: VideoMarker[]): string {
|
||||
if (!markers.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const position = this.config.position
|
||||
const positionClass = `marker-${position}`
|
||||
const animationClass = this.config.animation ? 'marker-animated' : ''
|
||||
|
||||
const markerElements = markers.map(marker => {
|
||||
const style = marker.style ? this.generateInlineStyle(marker.style) : ''
|
||||
return `
|
||||
<div class="video-marker ${animationClass}"
|
||||
data-type="${marker.type}"
|
||||
style="${style}">
|
||||
<i class="${marker.icon}"></i>
|
||||
<span class="marker-label">${marker.label}</span>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<div class="video-markers ${positionClass}">
|
||||
${markerElements}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成内联样式
|
||||
*/
|
||||
private generateInlineStyle(style: any): string {
|
||||
return Object.entries(style)
|
||||
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 驼峰转短横线
|
||||
*/
|
||||
private camelToKebab(str: string): string {
|
||||
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标记的CSS样式
|
||||
*/
|
||||
getMarkerCSS(): string {
|
||||
return `
|
||||
.video-markers {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-markers.marker-top-left {
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.video-markers.marker-top-right {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.video-markers.marker-bottom-left {
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.video-markers.marker-bottom-right {
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.video-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(4px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.video-marker i {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.marker-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.video-marker.marker-animated {
|
||||
animation-duration: 2s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
from {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), 0 0 8px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), 0 0 16px rgba(245, 158, 11, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% {
|
||||
opacity: 1;
|
||||
}
|
||||
51%, 100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.video-marker {
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.video-marker i {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.marker-label {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加自定义标记
|
||||
*/
|
||||
addCustomMarker(marker: VideoMarker): void {
|
||||
this.markers.set(marker.type, marker)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除标记
|
||||
*/
|
||||
removeMarker(type: MarkerType): void {
|
||||
this.markers.delete(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
*/
|
||||
updateConfig(config: Partial<MarkerConfig>): void {
|
||||
Object.assign(this.config, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户偏好
|
||||
*/
|
||||
setUserPreference(key: string, value: any): void {
|
||||
this.userPreferences[key] = value
|
||||
this.saveUserPreferences()
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用特定标记
|
||||
*/
|
||||
disableMarker(type: MarkerType): void {
|
||||
if (!this.userPreferences.disabled) {
|
||||
this.userPreferences.disabled = []
|
||||
}
|
||||
if (!this.userPreferences.disabled.includes(type)) {
|
||||
this.userPreferences.disabled.push(type)
|
||||
this.saveUserPreferences()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用特定标记
|
||||
*/
|
||||
enableMarker(type: MarkerType): void {
|
||||
if (this.userPreferences.disabled) {
|
||||
const index = this.userPreferences.disabled.indexOf(type)
|
||||
if (index > -1) {
|
||||
this.userPreferences.disabled.splice(index, 1)
|
||||
this.saveUserPreferences()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用标记类型
|
||||
*/
|
||||
getAvailableMarkerTypes(): MarkerType[] {
|
||||
return Array.from(this.markers.keys())
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取标记统计信息
|
||||
*/
|
||||
getMarkerStats(videos: any[], user: any = {}): Record<MarkerType, number> {
|
||||
const stats: Record<string, number> = {}
|
||||
|
||||
for (const type of this.markers.keys()) {
|
||||
stats[type] = 0
|
||||
}
|
||||
|
||||
videos.forEach(video => {
|
||||
const markers = this.getVideoMarkers(video, user)
|
||||
markers.forEach(marker => {
|
||||
stats[marker.type]++
|
||||
})
|
||||
})
|
||||
|
||||
return stats as Record<MarkerType, number>
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局标记管理器实例
|
||||
export const videoMarkerManager = new VideoMarkerManager()
|
||||
|
||||
// 导出类型和类
|
||||
export type { VideoMarker, MarkerType, MarkerConfig }
|
||||
export { VideoMarkerManager }
|
||||
|
||||
// Vue 3 组合式函数
|
||||
export function useVideoMarkers(config?: Partial<MarkerConfig>) {
|
||||
const markerManager = new VideoMarkerManager(config)
|
||||
|
||||
const getMarkers = (video: any, user: any = {}) => {
|
||||
return markerManager.getVideoMarkers(video, user)
|
||||
}
|
||||
|
||||
const generateHTML = (markers: VideoMarker[]) => {
|
||||
return markerManager.generateMarkerHTML(markers)
|
||||
}
|
||||
|
||||
const getCSS = () => {
|
||||
return markerManager.getMarkerCSS()
|
||||
}
|
||||
|
||||
return {
|
||||
markerManager,
|
||||
getMarkers,
|
||||
generateHTML,
|
||||
getCSS
|
||||
}
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Executable file
1
src/vite-env.d.ts
vendored
Executable file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
46
start-production.cjs
Normal file
46
start-production.cjs
Normal file
@@ -0,0 +1,46 @@
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
// 生产环境配置
|
||||
const PORT = process.env.PORT || 4001;
|
||||
const NODE_ENV = 'production';
|
||||
|
||||
// 设置环境变量
|
||||
process.env.NODE_ENV = NODE_ENV;
|
||||
process.env.PORT = PORT;
|
||||
|
||||
console.log(`启动生产环境服务器,端口: ${PORT}`);
|
||||
console.log(`工作目录: ${process.cwd()}`);
|
||||
|
||||
// 启动后端服务器
|
||||
const server = spawn('node', ['--import', 'tsx/esm', 'api/server.ts'], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: NODE_ENV,
|
||||
PORT: PORT
|
||||
}
|
||||
});
|
||||
|
||||
// 处理进程退出
|
||||
server.on('close', (code) => {
|
||||
console.log(`服务器进程退出,退出码: ${code}`);
|
||||
process.exit(code);
|
||||
});
|
||||
|
||||
// 处理错误
|
||||
server.on('error', (err) => {
|
||||
console.error('启动服务器时发生错误:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 优雅关闭
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n收到 SIGINT 信号,正在关闭服务器...');
|
||||
server.kill('SIGINT');
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n收到 SIGTERM 信号,正在关闭服务器...');
|
||||
server.kill('SIGTERM');
|
||||
});
|
||||
169
start-simple.cjs
Normal file
169
start-simple.cjs
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 宝塔面板简化启动脚本 - 兼容Node.js v16
|
||||
* 专门用于后端服务启动
|
||||
*/
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// 设置环境变量
|
||||
process.env.PORT = process.env.PORT || 4001;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
console.log('=== 宝塔面板简化启动脚本 ===');
|
||||
console.log(`端口: ${process.env.PORT}`);
|
||||
console.log(`环境: ${process.env.NODE_ENV}`);
|
||||
console.log(`工作目录: ${process.cwd()}`);
|
||||
console.log(`Node.js版本: ${process.version}`);
|
||||
|
||||
// 检查关键文件
|
||||
const requiredFiles = [
|
||||
'api/server.ts',
|
||||
'api/app.ts',
|
||||
'package.json'
|
||||
];
|
||||
|
||||
for (const file of requiredFiles) {
|
||||
if (!fs.existsSync(file)) {
|
||||
console.error(`错误: 缺少必要文件 ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✓ 必要文件检查通过');
|
||||
|
||||
// 首先尝试编译TypeScript文件(仅后端)
|
||||
console.log('正在编译后端TypeScript文件...');
|
||||
|
||||
const tscProcess = spawn('npx', ['tsc', '--project', 'tsconfig.server.json'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
tscProcess.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('✓ 后端TypeScript编译成功');
|
||||
startCompiledServer();
|
||||
} else {
|
||||
console.log('后端TypeScript编译失败,尝试直接启动...');
|
||||
startWithTsNode();
|
||||
}
|
||||
});
|
||||
|
||||
tscProcess.on('error', (err) => {
|
||||
console.log('TypeScript编译器不可用,尝试直接启动...');
|
||||
startWithTsNode();
|
||||
});
|
||||
|
||||
function startCompiledServer() {
|
||||
console.log('使用编译后的JS文件启动服务器...');
|
||||
|
||||
const server = spawn('node', ['dist/api/server.js'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: process.env.PORT || 4001,
|
||||
NODE_ENV: 'production'
|
||||
}
|
||||
});
|
||||
|
||||
setupServerHandlers(server);
|
||||
}
|
||||
|
||||
function startWithTsNode() {
|
||||
console.log('使用ts-node启动服务器...');
|
||||
|
||||
// 使用ts-node,指定服务器专用配置
|
||||
const server = spawn('npx', ['ts-node', '--project', 'tsconfig.server.json', '--esm', 'api/server.ts'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: process.env.PORT || 4001,
|
||||
NODE_ENV: 'production'
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.log('ts-node启动失败,尝试使用tsx...');
|
||||
startWithTsx();
|
||||
});
|
||||
|
||||
server.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
console.log('ts-node启动失败,尝试使用tsx...');
|
||||
startWithTsx();
|
||||
}
|
||||
});
|
||||
|
||||
setupServerHandlers(server);
|
||||
}
|
||||
|
||||
function startWithTsx() {
|
||||
console.log('使用tsx启动服务器...');
|
||||
|
||||
const server = spawn('npx', ['tsx', 'api/server.ts'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: process.env.PORT || 4001,
|
||||
NODE_ENV: 'production'
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error('所有启动方式都失败了:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
setupServerHandlers(server);
|
||||
}
|
||||
|
||||
function setupServerHandlers(server) {
|
||||
server.on('error', (err) => {
|
||||
console.error('服务器启动失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.on('exit', (code, signal) => {
|
||||
console.log(`服务器进程退出,代码: ${code}, 信号: ${signal}`);
|
||||
if (code !== 0) {
|
||||
console.error('服务器异常退出');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// 处理进程退出信号
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('收到SIGTERM信号,正在关闭服务...');
|
||||
if (server) {
|
||||
server.kill('SIGTERM');
|
||||
}
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('收到SIGINT信号,正在关闭服务...');
|
||||
if (server) {
|
||||
server.kill('SIGINT');
|
||||
}
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// 防止进程意外退出
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('未捕获的异常:', err);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('未处理的Promise拒绝:', reason);
|
||||
});
|
||||
|
||||
console.log('✓ 启动脚本初始化完成');
|
||||
168
start.cjs
Normal file
168
start.cjs
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 宝塔面板启动脚本 - Node.js v16兼容版
|
||||
* 解决宝塔面板Node.js项目部署问题
|
||||
*/
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// 设置环境变量
|
||||
process.env.PORT = process.env.PORT || 4001;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
console.log('=== 宝塔面板启动脚本 ===');
|
||||
console.log(`端口: ${process.env.PORT}`);
|
||||
console.log(`环境: ${process.env.NODE_ENV}`);
|
||||
console.log(`工作目录: ${process.cwd()}`);
|
||||
|
||||
// 检查关键文件是否存在
|
||||
const requiredFiles = [
|
||||
'api/server.ts',
|
||||
'api/app.ts',
|
||||
'package.json'
|
||||
];
|
||||
|
||||
for (const file of requiredFiles) {
|
||||
if (!fs.existsSync(file)) {
|
||||
console.error(`错误: 缺少必要文件 ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✓ 必要文件检查通过');
|
||||
|
||||
// 检查Node.js版本
|
||||
const nodeVersion = process.version;
|
||||
console.log(`Node.js版本: ${nodeVersion}`);
|
||||
|
||||
// 启动后端服务
|
||||
console.log('正在启动后端服务...');
|
||||
|
||||
// 使用node --loader tsx/esm来启动,兼容Node.js v16
|
||||
let server;
|
||||
|
||||
// 尝试不同的启动方式
|
||||
function tryStartServer() {
|
||||
// 方式1: 使用node --loader (推荐用于Node.js v16)
|
||||
console.log('尝试使用 node --loader 启动...');
|
||||
server = spawn('node', ['--loader', 'tsx/esm', 'api/server.ts'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: process.env.PORT || 4001,
|
||||
NODE_ENV: 'production'
|
||||
}
|
||||
});
|
||||
|
||||
// 如果失败,尝试方式2
|
||||
server.on('error', (err) => {
|
||||
console.log('方式1失败,尝试方式2...');
|
||||
tryStartServerMethod2();
|
||||
});
|
||||
|
||||
server.on('exit', (code, signal) => {
|
||||
if (code === 1) {
|
||||
console.log('方式1失败,尝试方式2...');
|
||||
tryStartServerMethod2();
|
||||
} else {
|
||||
console.log(`服务器进程退出,代码: ${code}, 信号: ${signal}`);
|
||||
if (code !== 0) {
|
||||
console.error('服务器异常退出');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tryStartServerMethod2() {
|
||||
console.log('尝试使用 npx tsx 启动...');
|
||||
server = spawn('npx', ['tsx', 'api/server.ts'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: process.env.PORT || 4001,
|
||||
NODE_ENV: 'production'
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error('所有启动方式都失败了:', err);
|
||||
tryStartServerMethod3();
|
||||
});
|
||||
|
||||
server.on('exit', (code, signal) => {
|
||||
if (code === 1) {
|
||||
console.log('方式2失败,尝试方式3...');
|
||||
tryStartServerMethod3();
|
||||
} else {
|
||||
console.log(`服务器进程退出,代码: ${code}, 信号: ${signal}`);
|
||||
if (code !== 0) {
|
||||
console.error('服务器异常退出');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tryStartServerMethod3() {
|
||||
console.log('尝试使用 node --experimental-loader 启动...');
|
||||
server = spawn('node', ['--experimental-loader', 'tsx/esm', 'api/server.ts'], {
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: process.env.PORT || 4001,
|
||||
NODE_ENV: 'production'
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error('服务器启动失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.on('exit', (code, signal) => {
|
||||
console.log(`服务器进程退出,代码: ${code}, 信号: ${signal}`);
|
||||
if (code !== 0) {
|
||||
console.error('服务器异常退出');
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 开始启动
|
||||
tryStartServer();
|
||||
|
||||
// 处理进程退出信号
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('收到SIGTERM信号,正在关闭服务...');
|
||||
if (server) {
|
||||
server.kill('SIGTERM');
|
||||
}
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('收到SIGINT信号,正在关闭服务...');
|
||||
if (server) {
|
||||
server.kill('SIGINT');
|
||||
}
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// 防止进程意外退出
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('未捕获的异常:', err);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('未处理的Promise拒绝:', reason);
|
||||
});
|
||||
|
||||
console.log('✓ 启动脚本初始化完成');
|
||||
13
tailwind.config.js
Executable file
13
tailwind.config.js
Executable file
@@ -0,0 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: ["./index.html", "./src/**/*.{js,ts,vue}"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
},
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
116
test-video-covers.cjs
Normal file
116
test-video-covers.cjs
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 测试视频封面功能脚本
|
||||
* 验证所有视频的封面API是否正常工作
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
|
||||
// 数据库路径
|
||||
const DB_PATH = path.join(__dirname, 'database', 'goguryeo_video.db');
|
||||
|
||||
// 测试视频封面API
|
||||
function testVideoFrame(videoId) {
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 4001,
|
||||
path: `/api/videos/${videoId}/frame`,
|
||||
method: 'GET'
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
console.log(`视频 ${videoId}: 状态码 ${res.statusCode}, 内容类型: ${res.headers['content-type']}, 大小: ${res.headers['content-length']} bytes`);
|
||||
|
||||
if (res.statusCode === 200 && res.headers['content-type'] === 'image/jpeg') {
|
||||
resolve({ videoId, success: true, size: res.headers['content-length'] });
|
||||
} else {
|
||||
resolve({ videoId, success: false, statusCode: res.statusCode });
|
||||
}
|
||||
|
||||
// 消费响应数据
|
||||
res.on('data', () => {});
|
||||
res.on('end', () => {});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error(`视频 ${videoId} 请求错误:`, err.message);
|
||||
resolve({ videoId, success: false, error: err.message });
|
||||
});
|
||||
|
||||
req.setTimeout(10000, () => {
|
||||
console.error(`视频 ${videoId} 请求超时`);
|
||||
req.destroy();
|
||||
resolve({ videoId, success: false, error: 'timeout' });
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 主测试函数
|
||||
async function testAllVideoCovers() {
|
||||
console.log('开始测试视频封面功能...');
|
||||
|
||||
// 连接数据库
|
||||
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||||
if (err) {
|
||||
console.error('数据库连接失败:', err);
|
||||
return;
|
||||
}
|
||||
console.log('已连接到数据库');
|
||||
});
|
||||
|
||||
// 获取所有视频ID
|
||||
db.all('SELECT id, title FROM videos ORDER BY id', async (err, rows) => {
|
||||
if (err) {
|
||||
console.error('查询视频失败:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`找到 ${rows.length} 个视频,开始测试封面功能...\n`);
|
||||
|
||||
const results = [];
|
||||
|
||||
// 逐个测试视频封面
|
||||
for (const video of rows) {
|
||||
const result = await testVideoFrame(video.id);
|
||||
results.push(result);
|
||||
|
||||
// 添加延迟避免过快请求
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
// 统计结果
|
||||
const successful = results.filter(r => r.success);
|
||||
const failed = results.filter(r => !r.success);
|
||||
|
||||
console.log('\n=== 测试结果统计 ===');
|
||||
console.log(`总视频数: ${results.length}`);
|
||||
console.log(`成功生成封面: ${successful.length}`);
|
||||
console.log(`失败: ${failed.length}`);
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.log('\n失败的视频:');
|
||||
failed.forEach(f => {
|
||||
console.log(`- 视频 ${f.videoId}: ${f.error || '状态码 ' + f.statusCode}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (successful.length > 0) {
|
||||
console.log('\n成功的视频封面大小:');
|
||||
successful.forEach(s => {
|
||||
console.log(`- 视频 ${s.videoId}: ${s.size} bytes`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n测试完成!');
|
||||
|
||||
// 关闭数据库连接
|
||||
db.close();
|
||||
});
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testAllVideoCovers().catch(console.error);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user