main #1

Merged
Lzh merged 3 commits from main into dev 2025-07-28 09:57:56 +08:00
63 changed files with 13967 additions and 2498 deletions

5
.env Normal file
View File

@ -0,0 +1,5 @@
# API配置
VITE_API_BASE_URL=http://110.42.96.65:55510/api
# Mock配置 - 切换到真实API
VITE_ENABLE_MOCK=false

11
.env.development Normal file
View File

@ -0,0 +1,11 @@
# 开发环境配置
# API配置
VITE_API_BASE_URL=http://110.42.96.65:55510/api
# Mock配置
# 设置为 true 使用Mock数据false 使用真实API
VITE_ENABLE_MOCK=false
# 开发模式
NODE_ENV=development

66
.env.example Normal file
View File

@ -0,0 +1,66 @@
# 环境变量配置示例文件
# 复制此文件为 .env.local 并填入实际的配置值
# 应用基础配置
VITE_APP_TITLE=在线学习平台
VITE_APP_DESCRIPTION=专业的在线学习平台,提供优质的编程和技术课程
VITE_APP_VERSION=1.0.0
# API 配置
VITE_API_BASE_URL=http://localhost:3000/api
VITE_API_TIMEOUT=10000
# 开发环境配置
VITE_DEV_PORT=5173
VITE_DEV_HOST=localhost
# 生产环境配置
VITE_PROD_API_BASE_URL=https://api.yourdomain.com/api
# 第三方服务配置
# 文件上传服务
VITE_UPLOAD_BASE_URL=https://upload.yourdomain.com
VITE_CDN_BASE_URL=https://cdn.yourdomain.com
# 视频播放服务
VITE_VIDEO_BASE_URL=https://video.yourdomain.com
# 支付服务配置
VITE_ALIPAY_APP_ID=your_alipay_app_id
VITE_WECHAT_APP_ID=your_wechat_app_id
# 第三方登录配置
VITE_GITHUB_CLIENT_ID=your_github_client_id
VITE_QQ_APP_ID=your_qq_app_id
VITE_WECHAT_LOGIN_APP_ID=your_wechat_login_app_id
# 地图服务配置
VITE_MAP_API_KEY=your_map_api_key
# 统计分析配置
VITE_ANALYTICS_ID=your_analytics_id
# 错误监控配置
VITE_SENTRY_DSN=your_sentry_dsn
# 功能开关
VITE_ENABLE_MOCK=false
VITE_ENABLE_DEBUG=true
VITE_ENABLE_ANALYTICS=false
VITE_ENABLE_ERROR_TRACKING=false
# 缓存配置
VITE_CACHE_ENABLED=true
VITE_CACHE_PREFIX=study_platform_
# 安全配置
VITE_ENCRYPT_STORAGE=false
VITE_ENABLE_CSRF=true
# 主题配置
VITE_DEFAULT_THEME=light
VITE_ENABLE_DARK_MODE=true
# 语言配置
VITE_DEFAULT_LOCALE=zh-CN
VITE_ENABLE_I18N=false

10
.env.production Normal file
View File

@ -0,0 +1,10 @@
# 生产环境配置
# API配置
VITE_API_BASE_URL=http://110.42.86.55:5510/api
# Mock配置 - 生产环境禁用Mock
VITE_ENABLE_MOCK=false
# 生产模式
NODE_ENV=production

125
docs/Activities-Page.md Normal file
View File

@ -0,0 +1,125 @@
# 活动页面 (Activities Page)
## 📋 概述
活动页面是在线学习平台的重要组成部分展示所有可用的学习活动和课程。页面设计完全按照提供的UI设计图实现包含蓝色横幅区域和活动卡片网格布局。
## 🎨 页面结构
### 1. 蓝色横幅区域 (Hero Banner)
- **背景**: 蓝色渐变背景 (#4A90E2#357ABD)
- **左侧内容**:
- 分类标签: "理工/消安/药师/中经"
- 主标题: "免费海量题库" + 橙色"小程序"标签
- 特色说明: "优质试题 | 无需下载 | 随时刷题"
- **右侧**: 插图占位区域(预留给后续图片)
- **装饰元素**: 网格背景纹理
### 2. 活动列表区域
- **标题**: "全部活动"
- **布局**: 3列网格布局响应式
- **活动卡片**: 6个相同样式的卡片
### 3. 活动卡片设计
每个卡片包含:
- **头部**: 蓝绿色渐变背景
- 年份标签: "2025"
- 课程标题: "计算机二级"
- 课程副标题: "C语言讲练综合班"
- **主体内容**:
- 特色标签: "系统备考"、"考点详解"、"题考刷题"
- 课程信息: 证书名称、开课时间、适合年级、报名人数、价格
- **底部**: 蓝色"查看详情"按钮
## 🎯 功能特性
### 1. 响应式设计
- **桌面端**: 3列网格布局
- **平板端**: 2列网格布局
- **移动端**: 1列网格布局
### 2. 交互效果
- **卡片悬停**: 阴影加深 + 向上移动
- **按钮悬停**: 颜色变化 + 轻微上移
- **加载动画**: 骨架屏效果
### 3. 动画效果
- **页面加载**: 左右滑入动画
- **卡片显示**: 渐入上移动画(错开延迟)
- **加载状态**: 闪烁骨架屏
## 🛠️ 技术实现
### 文件结构
```
src/views/Activities.vue # 活动页面主组件
src/router/index.ts # 路由配置
src/components/layout/AppHeader.vue # 导航栏更新
```
### 核心技术
- **Vue 3 Composition API**: 组件逻辑
- **TypeScript**: 类型安全
- **CSS Grid**: 响应式布局
- **CSS Animations**: 动画效果
- **Vue Router**: 路由导航
### 样式特点
- **渐变背景**: 多层次视觉效果
- **卡片设计**: 现代化卡片样式
- **颜色系统**: 蓝色主题 + 橙色点缀
- **字体层级**: 清晰的信息层次
## 🔗 导航集成
活动页面已集成到主导航栏中:
- 导航项: "活动" (带NEW标签)
- 路由路径: `/activities`
- 页面标题: "全部活动 - 在线学习平台"
## 📱 响应式断点
```css
/* 大屏幕 (>1024px) */
.activities-grid { grid-template-columns: repeat(3, 1fr); }
/* 平板 (≤1024px) */
.activities-grid { grid-template-columns: repeat(2, 1fr); }
/* 手机 (≤768px) */
.activities-grid { grid-template-columns: 1fr; }
```
## 🎨 设计规范
### 颜色规范
- **主色调**: #4A90E2 (蓝色)
- **辅助色**: #44A08D (蓝绿色)
- **强调色**: #FF6B35 (橙色)
- **文字色**: #333 (深灰)
- **次要文字**: #666 (中灰)
### 间距规范
- **容器边距**: 20px (移动端) / 30px (桌面端)
- **卡片间距**: 20px (移动端) / 30px (桌面端)
- **内容边距**: 16px-24px
### 圆角规范
- **卡片圆角**: 12px
- **按钮圆角**: 6px
- **标签圆角**: 4px
## 🚀 使用方法
1. **访问页面**: 点击导航栏"活动"或直接访问 `/activities`
2. **浏览活动**: 滚动查看所有可用活动
3. **查看详情**: 点击"查看详情"按钮(当前为演示功能)
4. **响应式体验**: 在不同设备上自动适配布局
## 📝 后续扩展
1. **图片集成**: 添加横幅插图和活动图片
2. **数据接口**: 连接后端API获取真实活动数据
3. **详情页面**: 实现活动详情页面跳转
4. **筛选功能**: 添加活动分类和筛选功能
5. **搜索功能**: 实现活动搜索功能

158
docs/Banner-Image-Setup.md Normal file
View File

@ -0,0 +1,158 @@
# 活动页面横幅图片设置指南
## 📋 概述
活动页面的横幅区域已经改为支持一整张图片的形式。目前显示占位内容,您可以按照以下步骤添加横幅图片。
## 🖼️ 当前状态
- ✅ 横幅区域已改为图片容器
- ✅ 响应式设计已适配
- ✅ 占位内容显示中
- ⏳ 等待提供横幅图片
## 📐 图片规格建议
### 推荐尺寸
- **桌面端**: 1200px × 400px
- **平板端**: 1024px × 350px
- **移动端**: 768px × 300px
### 文件格式
- **推荐**: JPG/JPEG (文件小,加载快)
- **支持**: PNG (支持透明背景)
- **支持**: WebP (现代浏览器,更小文件)
### 文件大小
- **建议**: < 500KB
- **最大**: < 1MB
## 🚀 添加图片的方法
### 方法一:直接替换(推荐)
1. **将图片文件放到项目中**
```
public/images/activities-banner.jpg
```
2. **修改 Activities.vue 文件**
```typescript
// 在 onMounted 中取消注释并设置图片路径
onMounted(() => {
setTimeout(() => {
loading.value = false
}, 800)
// 设置横幅图片路径
setBannerImage('/images/activities-banner.jpg')
})
```
### 方法二:直接设置变量
`src/views/Activities.vue` 中找到这一行:
```typescript
const bannerImageSrc = ref('')
```
改为:
```typescript
const bannerImageSrc = ref('/images/activities-banner.jpg')
```
## 📁 推荐的文件结构
```
public/
├── images/
│ ├── activities-banner.jpg # 横幅图片
│ ├── activities-banner-tablet.jpg # 平板版本(可选)
│ └── activities-banner-mobile.jpg # 移动版本(可选)
```
## 🎨 图片内容建议
根据原设计图,横幅图片应该包含:
- 蓝色渐变背景
- "理工/消安/药师/中经" 分类文字
- "免费海量题库" 主标题
- "小程序" 橙色标签
- "优质试题 | 无需下载 | 随时刷题" 特色说明
- 右侧人物和手机界面插图
## 🔧 技术实现细节
### 当前代码结构
```vue
<template>
<div class="hero-banner">
<div class="banner-image-container">
<!-- 实际图片 -->
<img
v-if="hasBannerImage"
:src="bannerImageSrc"
alt="活动横幅"
class="banner-image"
/>
<!-- 占位区域 -->
<div v-else class="banner-placeholder">
<!-- 占位内容 -->
</div>
</div>
</div>
</template>
```
### 响应式适配
- 桌面端400px 高度
- 平板端350px 高度
- 移动端300px 高度
- 小屏手机250px 高度
### CSS 样式
```css
.banner-image {
width: 100%;
height: 100%;
object-fit: cover; /* 保持比例,裁剪多余部分 */
object-position: center; /* 居中显示 */
}
```
## ⚡ 性能优化建议
1. **图片压缩**: 使用工具压缩图片文件
2. **懒加载**: 大图片可考虑懒加载
3. **多尺寸**: 为不同设备提供不同尺寸的图片
4. **WebP格式**: 现代浏览器使用WebP格式
## 🔄 更新步骤
当您准备好横幅图片时:
1. **准备图片文件**
- 确保图片符合推荐规格
- 压缩图片文件大小
2. **上传图片**
- 将图片放到 `public/images/` 目录
3. **更新代码**
- 按照上述方法一或方法二设置图片路径
4. **测试效果**
- 刷新浏览器查看效果
- 测试不同设备的显示效果
## 📞 需要帮助?
如果您在设置横幅图片时遇到任何问题,请:
1. 检查图片路径是否正确
2. 确认图片文件是否存在
3. 查看浏览器控制台是否有错误信息
4. 联系开发人员获取技术支持
---
**注意**: 当前页面显示占位内容,一旦您提供横幅图片并按照上述步骤设置,占位内容将自动被实际图片替换。

View File

@ -0,0 +1,175 @@
# 师资力量页面横幅图片设置指南
## 📋 概述
师资力量页面的横幅区域已经改为支持一整张图片的形式。目前显示占位内容,您可以按照以下步骤添加横幅图片。
## 🖼️ 当前状态
- ✅ 横幅区域已改为图片容器
- ✅ 响应式设计已适配
- ✅ 占位内容显示中
- ⏳ 等待提供横幅图片
## 📐 图片规格建议
### 推荐尺寸
- **桌面端**: 1200px × 400px
- **平板端**: 1024px × 350px
- **移动端**: 768px × 300px
- **小屏手机**: 480px × 250px
### 文件格式
- **推荐**: JPG/JPEG (文件小,加载快)
- **支持**: PNG (支持透明背景)
- **支持**: WebP (现代浏览器,更小文件)
### 文件大小
- **建议**: < 500KB
- **最大**: < 1MB
## 🚀 添加图片的方法
### 方法一:直接替换(推荐)
1. **将图片文件放到项目中**
```
public/images/faculty-banner.jpg
```
2. **修改 Faculty.vue 文件**
```typescript
// 在文件开头找到这一行
const bannerImageSrc = ref('')
// 改为
const bannerImageSrc = ref('/images/faculty-banner.jpg')
```
### 方法二:使用设置方法
`src/views/Faculty.vue` 中添加初始化代码:
```typescript
import { ref, computed, onMounted } from 'vue'
// 在组件挂载时设置图片
onMounted(() => {
setBannerImage('/images/faculty-banner.jpg')
})
```
## 📁 推荐的文件结构
```
public/
├── images/
│ ├── faculty-banner.jpg # 师资力量横幅图片
│ ├── faculty-banner-tablet.jpg # 平板版本(可选)
│ └── faculty-banner-mobile.jpg # 移动版本(可选)
```
## 🎨 图片内容建议
根据师资力量页面的特点,横幅图片应该包含:
- 专业的教育背景或学术氛围
- "师资力量"相关的标题文字
- 可能包含教师形象或教学场景
- 体现专业性和权威性的设计元素
- 与整体网站风格保持一致的色调
## 🔧 技术实现细节
### 当前代码结构
```vue
<template>
<div class="page-header">
<div class="banner-image-container">
<!-- 实际图片 -->
<img
v-if="hasBannerImage"
:src="bannerImageSrc"
alt="师资力量横幅"
class="banner-image"
/>
<!-- 占位区域 -->
<div v-else class="banner-placeholder">
<!-- 占位内容 -->
</div>
</div>
</div>
</template>
```
### 响应式适配
- **桌面端**: 400px 高度
- **平板端**: 350px 高度
- **移动端**: 300px 高度
- **小屏手机**: 250px 高度
### CSS 样式特点
```css
.banner-image {
width: 100%;
height: 100%;
object-fit: cover; /* 保持比例,裁剪多余部分 */
object-position: center; /* 居中显示 */
}
.banner-placeholder {
background: linear-gradient(135deg, #4A90E2 0%, #357ABD 100%);
/* 蓝色渐变背景作为占位 */
}
```
## ⚡ 性能优化建议
1. **图片压缩**: 使用工具压缩图片文件
2. **适当尺寸**: 避免使用过大的图片
3. **格式选择**: 优先使用JPG格式
4. **懒加载**: 大图片可考虑懒加载
## 🔄 更新步骤
当您准备好师资力量横幅图片时:
1. **准备图片文件**
- 确保图片符合推荐规格
- 压缩图片文件大小
- 检查图片内容是否符合师资力量主题
2. **上传图片**
- 将图片放到 `public/images/` 目录
- 确保文件名清晰易懂
3. **更新代码**
- 按照上述方法设置图片路径
- 确保路径正确无误
4. **测试效果**
- 访问 `/faculty` 页面查看效果
- 测试不同设备的显示效果
- 检查图片加载速度
## 🎯 与活动页面的区别
师资力量页面与活动页面的横幅设置方法相同,但内容主题不同:
- **活动页面**: 侧重课程活动和学习资源
- **师资力量页面**: 侧重教师团队和专业实力
## 📞 需要帮助?
如果您在设置师资力量横幅图片时遇到任何问题,请:
1. 检查图片路径是否正确
2. 确认图片文件是否存在于 `public/images/` 目录
3. 查看浏览器控制台是否有错误信息
4. 确保图片格式被浏览器支持
5. 联系开发人员获取技术支持
## 🔗 相关页面
- [活动页面横幅设置指南](./Banner-Image-Setup.md)
- [师资力量页面功能说明](./Faculty-Page.md)
---
**注意**: 当前页面显示占位内容,一旦您提供师资力量横幅图片并按照上述步骤设置,占位内容将自动被实际图片替换。页面访问地址:`http://localhost:3000/faculty`

View File

@ -7,6 +7,10 @@
<title>在线学习平台</title>
<meta name="description" content="专业的在线学习平台,提供优质的编程和技术课程">
<meta name="keywords" content="在线学习,编程课程,技术培训,Vue.js,React,Node.js">
<!-- CKPlayer CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ckplayer@8.3.5/dist/ckplayer.css">
<!-- CKPlayer JS -->
<script src="https://cdn.jsdelivr.net/npm/ckplayer@8.3.5/dist/ckplayer.js"></script>
</head>
<body>
<div id="app"></div>

294
package-lock.json generated
View File

@ -9,6 +9,9 @@
"version": "0.0.0",
"dependencies": {
"@vicons/ionicons5": "^0.13.0",
"axios": "^1.11.0",
"ckplayer": "^3.1.2",
"hls.js": "^1.6.7",
"naive-ui": "^2.42.0",
"pinia": "^3.0.3",
"vue": "^3.5.17",
@ -1756,6 +1759,23 @@
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/birpc": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.5.0.tgz",
@ -1814,6 +1834,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001727",
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
@ -1835,6 +1868,24 @@
],
"license": "CC-BY-4.0"
},
"node_modules/ckplayer": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/ckplayer/-/ckplayer-3.1.2.tgz",
"integrity": "sha512-JHlWTSRm6aqZx+dYdsa6MWz7151omcGBBF9EKK49NL1WCJ2olbdkt7CPKZHvW4lLVgxxEopmuLYEqNdb2cOPhA==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1981,6 +2032,29 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.187",
"resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz",
@ -2010,6 +2084,51 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.7",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.7.tgz",
@ -2132,6 +2251,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.0.tgz",
@ -2162,6 +2317,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
@ -2172,6 +2336,43 @@
"node": ">=6.9.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "9.0.1",
"resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz",
@ -2189,6 +2390,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -2196,6 +2409,45 @@
"dev": true,
"license": "ISC"
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
@ -2215,6 +2467,12 @@
"node": ">=12.0.0"
}
},
"node_modules/hls.js": {
"version": "1.6.7",
"resolved": "https://registry.npmmirror.com/hls.js/-/hls.js-1.6.7.tgz",
"integrity": "sha512-QW2fnwDGKGc9DwQUGLbmMOz8G48UZK7PVNJPcOUql1b8jubKx4/eMHNP5mGqr6tYlJNDG1g10Lx2U/qPzL6zwQ==",
"license": "Apache-2.0"
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
@ -2424,6 +2682,36 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
@ -2685,6 +2973,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",

View File

@ -11,6 +11,9 @@
},
"dependencies": {
"@vicons/ionicons5": "^0.13.0",
"axios": "^1.11.0",
"ckplayer": "^3.1.2",
"hls.js": "^1.6.7",
"naive-ui": "^2.42.0",
"pinia": "^3.0.3",
"vue": "^3.5.17",

348
src/api/README.md Normal file
View File

@ -0,0 +1,348 @@
# API 接口文档
这是在线学习平台的前端API接口封装提供了完整的类型定义和请求方法。
## 📁 文件结构
```
src/api/
├── index.ts # 统一导出文件
├── types.ts # TypeScript 类型定义
├── request.ts # HTTP 请求封装
├── utils.ts # 工具函数
├── modules/ # API 模块
│ ├── auth.ts # 认证相关API
│ ├── course.ts # 课程相关API
│ ├── comment.ts # 评论相关API
│ ├── favorite.ts # 收藏相关API
│ ├── order.ts # 订单相关API
│ ├── upload.ts # 文件上传API
│ └── statistics.ts # 统计相关API
├── examples/ # 使用示例
│ └── usage.ts # API使用示例
└── README.md # 文档说明
```
## 🚀 快速开始
### 1. 环境配置
复制 `.env.example``.env.local` 并配置API地址
```bash
cp .env.example .env.local
```
```env
# API 配置
VITE_API_BASE_URL=http://localhost:3000/api
VITE_API_TIMEOUT=10000
```
### 2. 基础使用
```typescript
import { AuthApi, CourseApi } from '@/api'
// 用户登录
const login = async () => {
try {
const response = await AuthApi.login({
email: 'user@example.com',
password: 'password123'
})
if (response.code === 200) {
console.log('登录成功:', response.data)
}
} catch (error) {
console.error('登录失败:', error)
}
}
// 获取课程列表
const getCourses = async () => {
try {
const response = await CourseApi.getCourses({
page: 1,
pageSize: 20,
category: '前端开发'
})
if (response.code === 200) {
console.log('课程列表:', response.data)
}
} catch (error) {
console.error('获取课程失败:', error)
}
}
```
## 📚 API 模块说明
### 认证模块 (AuthApi)
提供用户认证相关的所有接口:
- `login()` - 用户登录
- `register()` - 用户注册
- `logout()` - 用户登出
- `getCurrentUser()` - 获取当前用户信息
- `updateProfile()` - 更新用户资料
- `changePassword()` - 修改密码
- `uploadAvatar()` - 上传头像
### 课程模块 (CourseApi)
提供课程相关的所有接口:
- `getCourses()` - 获取课程列表
- `searchCourses()` - 搜索课程
- `getCourseById()` - 获取课程详情
- `enrollCourse()` - 报名课程
- `getLearningProgress()` - 获取学习进度
- `getCourseChapters()` - 获取课程章节
- `getCourseLessons()` - 获取课程课时
### 评论模块 (CommentApi)
提供评论相关的所有接口:
- `getCourseComments()` - 获取课程评论
- `addCourseComment()` - 添加课程评论
- `likeComment()` - 点赞评论
- `updateComment()` - 更新评论
- `deleteComment()` - 删除评论
### 收藏模块 (FavoriteApi)
提供收藏相关的所有接口:
- `addFavorite()` - 添加收藏
- `removeFavorite()` - 取消收藏
- `getMyFavorites()` - 获取收藏列表
- `checkFavorite()` - 检查收藏状态
### 订单模块 (OrderApi)
提供订单相关的所有接口:
- `createOrder()` - 创建订单
- `getOrders()` - 获取订单列表
- `getOrderById()` - 获取订单详情
- `cancelOrder()` - 取消订单
- `confirmPayment()` - 确认支付
### 上传模块 (UploadApi)
提供文件上传相关的所有接口:
- `uploadFile()` - 上传单个文件
- `uploadAvatar()` - 上传头像
- `uploadCourseVideo()` - 上传课程视频
- `uploadMultipleFiles()` - 批量上传文件
### 统计模块 (StatisticsApi)
提供统计相关的所有接口:
- `getPlatformStats()` - 获取平台统计
- `getUserLearningStats()` - 获取用户学习统计
- `getCourseStats()` - 获取课程统计
## 🔧 工具函数
### 请求工具
```typescript
import { ApiRequest } from '@/api'
// GET 请求
const data = await ApiRequest.get('/endpoint', { param: 'value' })
// POST 请求
const result = await ApiRequest.post('/endpoint', { data: 'value' })
// 文件上传
const uploadResult = await ApiRequest.upload('/upload', file, (progress) => {
console.log('上传进度:', progress + '%')
})
```
### 工具函数
```typescript
import {
buildQueryString,
formatFileSize,
formatDuration,
isValidEmail,
storage
} from '@/api/utils'
// 构建查询字符串
const query = buildQueryString({ page: 1, size: 20 })
// 格式化文件大小
const size = formatFileSize(1024000) // "1000 KB"
// 格式化时长
const duration = formatDuration(3661) // "1小时1分1秒"
// 验证邮箱
const valid = isValidEmail('user@example.com') // true
// 本地存储
storage.set('user', { id: 1, name: 'John' })
const user = storage.get('user')
```
## 🎯 类型定义
所有API都有完整的TypeScript类型定义
```typescript
import type {
User,
Course,
Comment,
Order,
ApiResponse,
PaginationResponse
} from '@/api'
// 用户类型
const user: User = {
id: 1,
username: 'john',
email: 'john@example.com',
role: 'student'
}
// API响应类型
const response: ApiResponse<User> = {
code: 200,
message: '成功',
data: user
}
// 分页响应类型
const pageResponse: ApiResponse<PaginationResponse<Course>> = {
code: 200,
message: '成功',
data: {
list: [],
total: 100,
page: 1,
pageSize: 20,
totalPages: 5
}
}
```
## 🛠️ 错误处理
### 全局错误处理
请求拦截器会自动处理常见错误:
- 401: 自动跳转登录页
- 403: 显示权限不足提示
- 500: 显示服务器错误提示
- 网络错误: 显示网络连接提示
### 自定义错误处理
```typescript
import { getErrorMessage } from '@/api/utils'
try {
const response = await AuthApi.login(credentials)
} catch (error) {
const message = getErrorMessage(error)
console.error('登录失败:', message)
// 显示错误提示
}
```
## 📝 最佳实践
### 1. 使用TypeScript类型
```typescript
import type { Course, ApiResponse } from '@/api'
const handleCourseData = (response: ApiResponse<Course[]>) => {
if (response.code === 200) {
response.data.forEach(course => {
// TypeScript 会提供完整的类型提示
console.log(course.title, course.instructor.name)
})
}
}
```
### 2. 错误边界处理
```typescript
const fetchData = async () => {
try {
const response = await CourseApi.getCourses()
return response.data
} catch (error) {
// 记录错误日志
console.error('API Error:', error)
// 返回默认值或重新抛出错误
throw error
}
}
```
### 3. 加载状态管理
```typescript
const loading = ref(false)
const loadCourses = async () => {
loading.value = true
try {
const response = await CourseApi.getCourses()
// 处理数据
} catch (error) {
// 处理错误
} finally {
loading.value = false
}
}
```
### 4. 分页数据处理
```typescript
import { formatPaginationData } from '@/api/utils'
const loadCourses = async (page: number = 1) => {
try {
const response = await CourseApi.getCourses({ page, pageSize: 20 })
const pagination = formatPaginationData(response)
console.log('课程列表:', pagination.items)
console.log('分页信息:', {
current: pagination.currentPage,
total: pagination.totalPages,
hasNext: pagination.hasNext
})
} catch (error) {
console.error('加载失败:', error)
}
}
```
## 🔄 更新日志
### v1.0.0
- 初始版本
- 完整的API接口封装
- TypeScript类型定义
- 错误处理机制
- 工具函数库

371
src/api/examples/usage.ts Normal file
View File

@ -0,0 +1,371 @@
// API 使用示例文件
// 展示如何在组件中使用各种API接口
import {
AuthApi,
CourseApi,
CommentApi,
FavoriteApi,
OrderApi,
UploadApi,
StatisticsApi
} from '@/api'
// ===== 认证相关示例 =====
// 用户登录示例
export const loginExample = async () => {
try {
const response = await AuthApi.login({
email: 'user@example.com',
password: 'password123'
})
if (response.code === 200) {
const { user, token } = response.data
// 保存用户信息和token
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify(user))
console.log('登录成功:', user)
}
} catch (error) {
console.error('登录失败:', error)
}
}
// 用户注册示例
export const registerExample = async () => {
try {
const response = await AuthApi.register({
username: 'newuser',
email: 'newuser@example.com',
password: 'password123',
confirmPassword: 'password123',
captcha: 'abc123'
})
if (response.code === 200) {
console.log('注册成功:', response.data)
}
} catch (error) {
console.error('注册失败:', error)
}
}
// 获取当前用户信息示例
export const getCurrentUserExample = async () => {
try {
const response = await AuthApi.getCurrentUser()
if (response.code === 200) {
console.log('用户信息:', response.data)
return response.data
}
} catch (error) {
console.error('获取用户信息失败:', error)
}
}
// ===== 课程相关示例 =====
// 获取课程列表示例
export const getCoursesExample = async () => {
try {
const response = await CourseApi.getCourses({
page: 1,
pageSize: 20,
category: '前端开发',
level: 'intermediate',
sortBy: 'rating'
})
if (response.code === 200) {
const { list, total, page, pageSize } = response.data
console.log('课程列表:', list)
console.log('总数:', total)
return response.data
}
} catch (error) {
console.error('获取课程列表失败:', error)
}
}
// 搜索课程示例
export const searchCoursesExample = async () => {
try {
const response = await CourseApi.searchCourses({
keyword: 'Vue.js',
category: '前端开发',
level: 'intermediate',
price: 'paid',
rating: 4,
sortBy: 'rating',
page: 1,
pageSize: 10
})
if (response.code === 200) {
console.log('搜索结果:', response.data)
return response.data
}
} catch (error) {
console.error('搜索课程失败:', error)
}
}
// 获取课程详情示例
export const getCourseDetailExample = async (courseId: number) => {
try {
const response = await CourseApi.getCourseById(courseId)
if (response.code === 200) {
console.log('课程详情:', response.data)
return response.data
}
} catch (error) {
console.error('获取课程详情失败:', error)
}
}
// 报名课程示例
export const enrollCourseExample = async (courseId: number) => {
try {
const response = await CourseApi.enrollCourse(courseId)
if (response.code === 200) {
console.log('报名成功:', response.data)
return response.data
}
} catch (error) {
console.error('报名失败:', error)
}
}
// 获取学习进度示例
export const getLearningProgressExample = async (courseId: number) => {
try {
const response = await CourseApi.getLearningProgress(courseId)
if (response.code === 200) {
console.log('学习进度:', response.data)
return response.data
}
} catch (error) {
console.error('获取学习进度失败:', error)
}
}
// ===== 评论相关示例 =====
// 获取课程评论示例
export const getCourseCommentsExample = async (courseId: number) => {
try {
const response = await CommentApi.getCourseComments(courseId, {
page: 1,
pageSize: 10,
sortBy: 'newest'
})
if (response.code === 200) {
console.log('课程评论:', response.data)
return response.data
}
} catch (error) {
console.error('获取课程评论失败:', error)
}
}
// 添加课程评论示例
export const addCourseCommentExample = async (courseId: number) => {
try {
const response = await CommentApi.addCourseComment(courseId, {
content: '这门课程非常棒,讲解清晰,内容丰富!',
rating: 5
})
if (response.code === 200) {
console.log('评论添加成功:', response.data)
return response.data
}
} catch (error) {
console.error('添加评论失败:', error)
}
}
// 点赞评论示例
export const likeCommentExample = async (commentId: number) => {
try {
const response = await CommentApi.likeComment(commentId)
if (response.code === 200) {
console.log('点赞成功:', response.data)
return response.data
}
} catch (error) {
console.error('点赞失败:', error)
}
}
// ===== 收藏相关示例 =====
// 添加收藏示例
export const addFavoriteExample = async (courseId: number) => {
try {
const response = await FavoriteApi.addFavorite(courseId)
if (response.code === 200) {
console.log('收藏成功:', response.data)
return response.data
}
} catch (error) {
console.error('收藏失败:', error)
}
}
// 获取收藏列表示例
export const getFavoritesExample = async () => {
try {
const response = await FavoriteApi.getMyFavorites({
page: 1,
pageSize: 20,
sortBy: 'newest'
})
if (response.code === 200) {
console.log('收藏列表:', response.data)
return response.data
}
} catch (error) {
console.error('获取收藏列表失败:', error)
}
}
// ===== 订单相关示例 =====
// 创建订单示例
export const createOrderExample = async (courseIds: number[]) => {
try {
const response = await OrderApi.createOrder({
courseIds,
couponCode: 'DISCOUNT10',
paymentMethod: 'alipay'
})
if (response.code === 200) {
console.log('订单创建成功:', response.data)
return response.data
}
} catch (error) {
console.error('创建订单失败:', error)
}
}
// 获取订单列表示例
export const getOrdersExample = async () => {
try {
const response = await OrderApi.getOrders({
page: 1,
pageSize: 10,
status: 'paid'
})
if (response.code === 200) {
console.log('订单列表:', response.data)
return response.data
}
} catch (error) {
console.error('获取订单列表失败:', error)
}
}
// ===== 文件上传示例 =====
// 上传头像示例
export const uploadAvatarExample = async (file: File) => {
try {
const response = await UploadApi.uploadAvatar(file, (progress) => {
console.log('上传进度:', progress + '%')
})
if (response.code === 200) {
console.log('头像上传成功:', response.data)
return response.data
}
} catch (error) {
console.error('头像上传失败:', error)
}
}
// 上传课程视频示例
export const uploadCourseVideoExample = async (file: File, courseId: number) => {
try {
const response = await UploadApi.uploadCourseVideo(file, courseId, undefined, (progress) => {
console.log('视频上传进度:', progress + '%')
})
if (response.code === 200) {
console.log('视频上传成功:', response.data)
return response.data
}
} catch (error) {
console.error('视频上传失败:', error)
}
}
// ===== 统计相关示例 =====
// 获取平台统计示例
export const getPlatformStatsExample = async () => {
try {
const response = await StatisticsApi.getPlatformStats()
if (response.code === 200) {
console.log('平台统计:', response.data)
return response.data
}
} catch (error) {
console.error('获取平台统计失败:', error)
}
}
// 获取用户学习统计示例
export const getUserLearningStatsExample = async () => {
try {
const response = await StatisticsApi.getUserLearningStats()
if (response.code === 200) {
console.log('用户学习统计:', response.data)
return response.data
}
} catch (error) {
console.error('获取用户学习统计失败:', error)
}
}
// ===== 错误处理示例 =====
// 统一错误处理函数
export const handleApiError = (error: any) => {
if (error.response) {
// 服务器返回错误状态码
const { status, data } = error.response
switch (status) {
case 400:
console.error('请求参数错误:', data.message)
break
case 401:
console.error('未授权访问,请重新登录')
// 跳转到登录页
break
case 403:
console.error('没有权限访问')
break
case 404:
console.error('请求的资源不存在')
break
case 500:
console.error('服务器内部错误')
break
default:
console.error('请求失败:', data.message || '未知错误')
}
} else if (error.request) {
// 网络错误
console.error('网络错误,请检查网络连接')
} else {
// 其他错误
console.error('请求配置错误:', error.message)
}
}

264
src/api/index.ts Normal file
View File

@ -0,0 +1,264 @@
// API 统一导出文件
export * from './types'
export * from './request'
// 导出所有API模块
export { default as AuthApi } from './modules/auth'
export { default as CourseApi } from './modules/course'
export { default as CommentApi } from './modules/comment'
export { default as FavoriteApi } from './modules/favorite'
export { default as OrderApi } from './modules/order'
export { default as UploadApi } from './modules/upload'
export { default as StatisticsApi } from './modules/statistics'
// API 基础配置
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api'
// API 端点配置
export const API_ENDPOINTS = {
// 认证相关
AUTH: {
LOGIN: '/auth/login',
REGISTER: '/auth/register',
LOGOUT: '/auth/logout',
REFRESH: '/auth/refresh',
ME: '/auth/me',
PROFILE: '/auth/profile',
CHANGE_PASSWORD: '/auth/change-password',
FORGOT_PASSWORD: '/auth/forgot-password',
RESET_PASSWORD: '/auth/reset-password',
VERIFY_EMAIL: '/auth/verify-email',
VERIFY_PHONE: '/auth/verify-phone',
UPLOAD_AVATAR: '/auth/upload-avatar',
},
// 课程相关
COURSES: {
LIST: '/courses',
SEARCH: '/courses/search',
POPULAR: '/courses/popular',
LATEST: '/courses/latest',
RECOMMENDED: '/courses/recommended',
DETAIL: '/courses/:id',
CHAPTERS: '/courses/:id/chapters',
LESSONS: '/courses/:id/lessons',
ENROLL: '/courses/:id/enroll',
PROGRESS: '/courses/:id/progress',
PREVIEW: '/courses/:id/preview',
STATS: '/courses/:id/stats',
RELATED: '/courses/:id/related',
ACCESS: '/courses/:id/access',
},
// 分类相关
CATEGORIES: {
LIST: '/categories',
COURSES: '/categories/:id/courses',
},
// 章节课时相关
CHAPTERS: {
DETAIL: '/chapters/:id',
},
LESSONS: {
DETAIL: '/lessons/:id',
RESOURCES: '/lessons/:id/resources',
COMPLETE: '/lessons/:id/complete',
},
// 讲师相关
INSTRUCTORS: {
DETAIL: '/instructors/:id',
COURSES: '/instructors/:id/courses',
FOLLOW: '/instructors/:id/follow',
},
// 测验相关
QUIZZES: {
LIST: '/courses/:id/quizzes',
DETAIL: '/quizzes/:id',
SUBMIT: '/quizzes/:id/submit',
RESULTS: '/quizzes/:id/results',
},
// 评论相关
COMMENTS: {
COURSE: '/courses/:id/comments',
LESSON: '/lessons/:id/comments',
DETAIL: '/comments/:id',
REPLIES: '/comments/:id/replies',
LIKE: '/comments/:id/like',
DISLIKE: '/comments/:id/dislike',
HELPFUL: '/comments/:id/helpful',
REPORT: '/comments/:id/report',
MY_COMMENTS: '/my-comments',
STATS: '/comments/stats',
},
// 收藏相关
FAVORITES: {
LIST: '/favorites',
ADD: '/favorites',
REMOVE: '/favorites/:id',
CHECK: '/favorites/check/:id',
BATCH: '/favorites/batch',
STATS: '/favorites/stats',
EXPORT: '/favorites/export',
IMPORT: '/favorites/import',
FOLDERS: '/favorite-folders',
},
// 订单相关
ORDERS: {
LIST: '/orders',
CREATE: '/orders',
DETAIL: '/orders/:id',
BY_NO: '/orders/no/:orderNo',
CANCEL: '/orders/:id/cancel',
CONFIRM_PAYMENT: '/orders/:id/confirm-payment',
REFUND: '/orders/:id/refund',
INVOICE: '/orders/:id/invoice',
STATS: '/orders/stats',
CALCULATE: '/orders/calculate',
},
// 支付相关
PAYMENT: {
METHODS: '/payment-methods',
STATUS: '/orders/:id/payment-status',
RETRY: '/orders/:id/retry-payment',
},
// 优惠券相关
COUPONS: {
VALIDATE: '/coupons/validate',
AVAILABLE: '/coupons/available',
},
// 退款相关
REFUNDS: {
LIST: '/refunds',
DETAIL: '/refunds/:id',
},
// 上传相关
UPLOAD: {
FILE: '/upload/:type',
AVATAR: '/upload/avatar',
COURSE_THUMBNAIL: '/upload/course-thumbnail',
COURSE_VIDEO: '/upload/course-video',
COURSE_RESOURCE: '/upload/course-resource',
MULTIPLE: '/upload/multiple/:type',
CONFIG: '/upload/config',
TOKEN: '/upload/token/:type',
COMPRESS: '/upload/compress-image',
THUMBNAIL: '/upload/generate-thumbnail',
HISTORY: '/upload/history',
},
// 统计相关
STATISTICS: {
PLATFORM: '/statistics/platform',
USER_LEARNING: '/statistics/user-learning',
COURSE: '/statistics/course/:id',
INSTRUCTOR: '/statistics/instructor/:id',
LEARNING_PROGRESS: '/statistics/learning-progress',
REVENUE: '/statistics/revenue',
USER_BEHAVIOR: '/statistics/user-behavior',
SEARCH: '/statistics/search',
CONTENT: '/statistics/content',
COMMENTS: '/statistics/comments',
EXPORT: '/statistics/export/:type',
},
// 学习进度相关
LEARNING: {
PROGRESS: '/learning-progress',
MY_COURSES: '/my-courses',
},
// 资源相关
RESOURCES: {
DOWNLOAD: '/resources/:id/download',
},
}
// 请求配置
export const REQUEST_CONFIG = {
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
}
// HTTP状态码
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
VALIDATION_ERROR: 422,
TOO_MANY_REQUESTS: 429,
INTERNAL_ERROR: 500,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
GATEWAY_TIMEOUT: 504,
}
// 业务状态码
export const BUSINESS_CODE = {
SUCCESS: 0,
FAILED: 1,
INVALID_PARAMS: 1001,
UNAUTHORIZED: 1002,
FORBIDDEN: 1003,
NOT_FOUND: 1004,
ALREADY_EXISTS: 1005,
OPERATION_FAILED: 1006,
VALIDATION_FAILED: 1007,
RATE_LIMITED: 1008,
MAINTENANCE: 1009,
}
// 常用的API响应消息
export const API_MESSAGES = {
SUCCESS: '操作成功',
FAILED: '操作失败',
INVALID_PARAMS: '参数错误',
UNAUTHORIZED: '未授权访问',
FORBIDDEN: '禁止访问',
NOT_FOUND: '资源不存在',
ALREADY_EXISTS: '资源已存在',
NETWORK_ERROR: '网络错误',
SERVER_ERROR: '服务器错误',
TIMEOUT: '请求超时',
UNKNOWN_ERROR: '未知错误',
}
// 分页默认配置
export const PAGINATION_CONFIG = {
DEFAULT_PAGE: 1,
DEFAULT_PAGE_SIZE: 20,
MAX_PAGE_SIZE: 100,
}
// 文件上传配置
export const UPLOAD_CONFIG = {
MAX_FILE_SIZE: 100 * 1024 * 1024, // 100MB
ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
ALLOWED_VIDEO_TYPES: ['video/mp4', 'video/avi', 'video/mov', 'video/wmv'],
ALLOWED_DOCUMENT_TYPES: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
ALLOWED_AUDIO_TYPES: ['audio/mp3', 'audio/wav', 'audio/ogg'],
}
// 缓存配置
export const CACHE_CONFIG = {
DEFAULT_TTL: 5 * 60 * 1000, // 5分钟
USER_INFO_TTL: 30 * 60 * 1000, // 30分钟
COURSE_LIST_TTL: 10 * 60 * 1000, // 10分钟
STATIC_DATA_TTL: 60 * 60 * 1000, // 1小时
}

224
src/api/modules/auth.ts Normal file
View File

@ -0,0 +1,224 @@
// 认证相关API接口
import { ApiRequest } from '../request'
import type {
ApiResponse,
User,
LoginRequest,
LoginResponse,
BackendLoginResponse,
RegisterRequest,
UserProfile,
} from '../types'
/**
* API模块
*/
export class AuthApi {
// 用户登录
static async login(data: LoginRequest): Promise<ApiResponse<LoginResponse>> {
try {
// 调用后端API
const response = await ApiRequest.post<BackendLoginResponse>('/users/login', data)
// 适配后端响应格式为前端期望的格式
const adaptedResponse: ApiResponse<LoginResponse> = {
code: response.code,
message: response.message,
data: {
user: {
id: response.data.id, // 使用后端返回的用户ID
email: data.email || '',
phone: data.phone || '',
username: data.phone || data.email?.split('@')[0] || 'user',
nickname: '用户',
avatar: '',
role: 'student',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
token: response.data.token,
refreshToken: '', // 后端没有返回,使用空字符串
expiresIn: 3600 // 默认1小时可以根据expires字段计算
}
}
return adaptedResponse
} catch (error) {
throw error
}
}
// 用户注册
static register(data: RegisterRequest): Promise<ApiResponse<User>> {
return ApiRequest.post('/auth/register', data)
}
// 用户登出
static logout(): Promise<ApiResponse<null>> {
return ApiRequest.post('/auth/logout')
}
// 刷新Token
static refreshToken(refreshToken: string): Promise<ApiResponse<LoginResponse>> {
return ApiRequest.post('/auth/refresh', { refreshToken })
}
// 获取当前用户信息
static getCurrentUser(): Promise<ApiResponse<User>> {
return ApiRequest.get('/auth/me')
}
// 更新用户资料
static updateProfile(data: Partial<UserProfile>): Promise<ApiResponse<User>> {
return ApiRequest.put('/auth/profile', data)
}
// 修改密码
static changePassword(data: {
oldPassword: string
newPassword: string
confirmPassword: string
}): Promise<ApiResponse<null>> {
return ApiRequest.post('/auth/change-password', data)
}
// 忘记密码 - 发送重置邮件
static forgotPassword(email: string): Promise<ApiResponse<null>> {
return ApiRequest.post('/auth/forgot-password', { email })
}
// 重置密码
static resetPassword(data: {
token: string
password: string
confirmPassword: string
}): Promise<ApiResponse<null>> {
return ApiRequest.post('/auth/reset-password', data)
}
// 发送邮箱验证码
static sendEmailVerification(email: string): Promise<ApiResponse<null>> {
return ApiRequest.post('/auth/send-email-verification', { email })
}
// 验证邮箱
static verifyEmail(data: {
email: string
code: string
}): Promise<ApiResponse<null>> {
return ApiRequest.post('/auth/verify-email', data)
}
// 发送手机验证码
static sendSmsVerification(phone: string): Promise<ApiResponse<null>> {
return ApiRequest.post('/auth/send-sms-verification', { phone })
}
// 验证手机号
static verifyPhone(data: {
phone: string
code: string
}): Promise<ApiResponse<null>> {
return ApiRequest.post('/auth/verify-phone', data)
}
// 绑定第三方账号
static bindThirdParty(data: {
provider: 'wechat' | 'qq' | 'weibo' | 'github'
code: string
state?: string
}): Promise<ApiResponse<User>> {
return ApiRequest.post('/auth/bind-third-party', data)
}
// 解绑第三方账号
static unbindThirdParty(provider: string): Promise<ApiResponse<null>> {
return ApiRequest.delete(`/auth/unbind-third-party/${provider}`)
}
// 获取第三方登录URL
static getThirdPartyLoginUrl(provider: string, redirectUrl?: string): Promise<ApiResponse<{ url: string }>> {
return ApiRequest.get(`/auth/third-party-login-url/${provider}`, { redirectUrl })
}
// 第三方登录回调
static thirdPartyLoginCallback(data: {
provider: string
code: string
state?: string
}): Promise<ApiResponse<LoginResponse>> {
return ApiRequest.post('/auth/third-party-login-callback', data)
}
// 上传头像
static uploadAvatar(file: File, onProgress?: (progress: number) => void): Promise<ApiResponse<{ url: string }>> {
return ApiRequest.upload('/auth/upload-avatar', file, onProgress)
}
// 删除账号
static deleteAccount(password: string): Promise<ApiResponse<null>> {
return ApiRequest.post('/auth/delete-account', { password })
}
// 获取账号安全信息
static getSecurityInfo(): Promise<ApiResponse<{
hasPassword: boolean
hasEmail: boolean
hasPhone: boolean
emailVerified: boolean
phoneVerified: boolean
twoFactorEnabled: boolean
thirdPartyAccounts: Array<{
provider: string
nickname: string
avatar?: string
bindTime: string
}>
loginHistory: Array<{
ip: string
location: string
device: string
loginTime: string
}>
}>> {
return ApiRequest.get('/auth/security-info')
}
// 启用两步验证
static enableTwoFactor(): Promise<ApiResponse<{
qrCode: string
secret: string
backupCodes: string[]
}>> {
return ApiRequest.post('/auth/enable-two-factor')
}
// 确认启用两步验证
static confirmTwoFactor(code: string): Promise<ApiResponse<{
backupCodes: string[]
}>> {
return ApiRequest.post('/auth/confirm-two-factor', { code })
}
// 禁用两步验证
static disableTwoFactor(data: {
password: string
code: string
}): Promise<ApiResponse<null>> {
return ApiRequest.post('/auth/disable-two-factor', data)
}
// 生成新的备用码
static generateBackupCodes(): Promise<ApiResponse<{
backupCodes: string[]
}>> {
return ApiRequest.post('/auth/generate-backup-codes')
}
// 验证两步验证码
static verifyTwoFactor(code: string): Promise<ApiResponse<null>> {
return ApiRequest.post('/auth/verify-two-factor', { code })
}
}
export default AuthApi

198
src/api/modules/comment.ts Normal file
View File

@ -0,0 +1,198 @@
// 评论相关API接口
import { ApiRequest } from '../request'
import type {
ApiResponse,
PaginationResponse,
Comment,
} from '../types'
/**
* API模块
*/
export class CommentApi {
// 获取课程评论
static getCourseComments(courseId: number, params?: {
page?: number
pageSize?: number
sortBy?: 'newest' | 'oldest' | 'rating' | 'helpful'
rating?: number
}): Promise<ApiResponse<PaginationResponse<Comment>>> {
return ApiRequest.get(`/courses/${courseId}/comments`, params)
}
// 获取课时评论
static getLessonComments(lessonId: number, params?: {
page?: number
pageSize?: number
sortBy?: 'newest' | 'oldest' | 'helpful'
}): Promise<ApiResponse<PaginationResponse<Comment>>> {
return ApiRequest.get(`/lessons/${lessonId}/comments`, params)
}
// 添加课程评论
static addCourseComment(courseId: number, data: {
content: string
rating?: number
parentId?: number
}): Promise<ApiResponse<Comment>> {
return ApiRequest.post(`/courses/${courseId}/comments`, data)
}
// 添加课时评论
static addLessonComment(lessonId: number, data: {
content: string
parentId?: number
}): Promise<ApiResponse<Comment>> {
return ApiRequest.post(`/lessons/${lessonId}/comments`, data)
}
// 更新评论
static updateComment(commentId: number, data: {
content: string
rating?: number
}): Promise<ApiResponse<Comment>> {
return ApiRequest.put(`/comments/${commentId}`, data)
}
// 删除评论
static deleteComment(commentId: number): Promise<ApiResponse<null>> {
return ApiRequest.delete(`/comments/${commentId}`)
}
// 点赞评论
static likeComment(commentId: number): Promise<ApiResponse<{
likes: number
isLiked: boolean
}>> {
return ApiRequest.post(`/comments/${commentId}/like`)
}
// 取消点赞评论
static unlikeComment(commentId: number): Promise<ApiResponse<{
likes: number
isLiked: boolean
}>> {
return ApiRequest.delete(`/comments/${commentId}/like`)
}
// 踩评论
static dislikeComment(commentId: number): Promise<ApiResponse<{
dislikes: number
isDisliked: boolean
}>> {
return ApiRequest.post(`/comments/${commentId}/dislike`)
}
// 取消踩评论
static undislikeComment(commentId: number): Promise<ApiResponse<{
dislikes: number
isDisliked: boolean
}>> {
return ApiRequest.delete(`/comments/${commentId}/dislike`)
}
// 举报评论
static reportComment(commentId: number, data: {
reason: string
description?: string
}): Promise<ApiResponse<null>> {
return ApiRequest.post(`/comments/${commentId}/report`, data)
}
// 获取评论回复
static getCommentReplies(commentId: number, params?: {
page?: number
pageSize?: number
}): Promise<ApiResponse<PaginationResponse<Comment>>> {
return ApiRequest.get(`/comments/${commentId}/replies`, params)
}
// 获取我的评论
static getMyComments(params?: {
page?: number
pageSize?: number
type?: 'course' | 'lesson'
}): Promise<ApiResponse<PaginationResponse<Comment>>> {
return ApiRequest.get('/my-comments', params)
}
// 获取评论统计
static getCommentStats(courseId?: number, lessonId?: number): Promise<ApiResponse<{
totalComments: number
averageRating: number
ratingDistribution: Array<{
rating: number
count: number
percentage: number
}>
recentComments: Comment[]
}>> {
const params: any = {}
if (courseId) params.courseId = courseId
if (lessonId) params.lessonId = lessonId
return ApiRequest.get('/comments/stats', params)
}
// 标记评论为有用
static markCommentHelpful(commentId: number): Promise<ApiResponse<{
helpfulCount: number
isHelpful: boolean
}>> {
return ApiRequest.post(`/comments/${commentId}/helpful`)
}
// 取消标记评论为有用
static unmarkCommentHelpful(commentId: number): Promise<ApiResponse<{
helpfulCount: number
isHelpful: boolean
}>> {
return ApiRequest.delete(`/comments/${commentId}/helpful`)
}
// 置顶评论(管理员功能)
static pinComment(commentId: number): Promise<ApiResponse<null>> {
return ApiRequest.post(`/comments/${commentId}/pin`)
}
// 取消置顶评论(管理员功能)
static unpinComment(commentId: number): Promise<ApiResponse<null>> {
return ApiRequest.delete(`/comments/${commentId}/pin`)
}
// 审核评论(管理员功能)
static moderateComment(commentId: number, action: 'approve' | 'reject' | 'hide'): Promise<ApiResponse<null>> {
return ApiRequest.post(`/comments/${commentId}/moderate`, { action })
}
// 批量删除评论(管理员功能)
static batchDeleteComments(commentIds: number[]): Promise<ApiResponse<null>> {
return ApiRequest.post('/comments/batch-delete', { commentIds })
}
// 获取待审核评论(管理员功能)
static getPendingComments(params?: {
page?: number
pageSize?: number
}): Promise<ApiResponse<PaginationResponse<Comment>>> {
return ApiRequest.get('/comments/pending', params)
}
// 获取被举报的评论(管理员功能)
static getReportedComments(params?: {
page?: number
pageSize?: number
}): Promise<ApiResponse<PaginationResponse<Comment & {
reports: Array<{
id: number
reason: string
description?: string
reportedBy: string
reportedAt: string
}>
}>>> {
return ApiRequest.get('/comments/reported', params)
}
}
export default CommentApi

528
src/api/modules/course.ts Normal file
View File

@ -0,0 +1,528 @@
// 课程相关API接口
import { ApiRequest } from '../request'
import type {
ApiResponse,
PaginationResponse,
Course,
CourseCategory,
Chapter,
Lesson,
LessonResource,
CourseSection,
CourseSectionListResponse,
BackendCourseSection,
BackendCourseSectionListResponse,
Quiz,
QuizQuestion,
LearningProgress,
SearchRequest,
Instructor,
BackendCourse,
BackendCourseListResponse,
CourseListRequest,
} from '../types'
/**
* API模块
*/
export class CourseApi {
/**
* ISO字符串
*/
private static formatTimestamp(timestamp: number | null | undefined): string {
if (!timestamp || timestamp <= 0) {
return new Date().toISOString()
}
try {
// 如果时间戳是秒级的,转换为毫秒级
const ms = timestamp < 10000000000 ? timestamp * 1000 : timestamp
const date = new Date(ms)
// 检查日期是否有效
if (isNaN(date.getTime())) {
return new Date().toISOString()
}
return date.toISOString()
} catch (error) {
console.error('时间戳格式化失败:', error)
return new Date().toISOString()
}
}
/**
*
*/
private static calculateDuration(startTime: string, endTime: string): string {
try {
const start = new Date(startTime)
const end = new Date(endTime)
const diffMs = end.getTime() - start.getTime()
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
return `${diffDays}`
} catch (error) {
return '待定'
}
}
// 获取课程列表 - 适配后端接口
static async getCourses(params?: CourseListRequest): Promise<ApiResponse<PaginationResponse<Course>>> {
try {
console.log('调用课程列表API参数:', params)
// 构建查询参数根据API文档的参数名称
const queryParams: any = {}
if (params?.categoryId) queryParams.categoryId = params.categoryId
if (params?.difficulty !== undefined) queryParams.difficulty = params.difficulty
if (params?.subject) queryParams.subject = params.subject
if (params?.page) queryParams.page = params.page
if (params?.pageSize) queryParams.pageSize = params.pageSize
if (params?.keyword) queryParams.keyword = params.keyword
// 调用后端API
const response = await ApiRequest.get<BackendCourseListResponse>('/lesson/list', queryParams)
console.log('课程列表API响应:', response)
// 适配后端响应格式为前端期望的格式
const adaptedCourses: Course[] = response.data.list.map((backendCourse: BackendCourse) => ({
id: backendCourse.id,
title: backendCourse.name,
description: backendCourse.description,
thumbnail: backendCourse.cover,
coverImage: backendCourse.cover,
price: parseFloat(backendCourse.price || '0'),
originalPrice: parseFloat(backendCourse.price || '0'),
currency: 'CNY',
rating: 4.5, // 默认评分,后端没有返回
ratingCount: 0, // 默认评分数量
studentsCount: 0, // 默认学生数量
duration: '待定', // 默认时长
totalLessons: 0, // 默认课程数量
level: this.mapDifficulty(backendCourse.difficulty || 0),
language: 'zh-CN',
category: {
id: backendCourse.categoryId,
name: this.getCategoryName(backendCourse.categoryId),
slug: 'category-' + backendCourse.categoryId
},
tags: backendCourse.subject ? [backendCourse.subject] : [],
skills: [],
requirements: backendCourse.prerequisite ? [backendCourse.prerequisite] : [],
objectives: backendCourse.target ? [backendCourse.target] : [],
instructor: {
id: backendCourse.teacherId || 0,
name: backendCourse.school || '未知讲师',
title: '讲师',
bio: '',
avatar: '',
rating: 4.5,
studentsCount: 0,
coursesCount: 0,
experience: '',
education: [],
certifications: []
},
status: 'published' as const,
createdAt: this.formatTimestamp(backendCourse.createdTime),
updatedAt: this.formatTimestamp(backendCourse.updatedTime),
publishedAt: this.formatTimestamp(backendCourse.createdTime)
}))
const adaptedResponse: ApiResponse<PaginationResponse<Course>> = {
code: response.code,
message: response.message,
data: {
list: adaptedCourses,
total: response.data.total,
page: params?.page || 1,
pageSize: params?.pageSize || 10,
totalPages: Math.ceil(response.data.total / (params?.pageSize || 10))
}
}
return adaptedResponse
} catch (error) {
throw error
}
}
// 搜索课程
static searchCourses(params: SearchRequest): Promise<ApiResponse<PaginationResponse<Course>>> {
return ApiRequest.get('/courses/search', params)
}
// 获取热门课程
static getPopularCourses(limit?: number): Promise<ApiResponse<Course[]>> {
return ApiRequest.get('/courses/popular', { limit })
}
// 获取最新课程
static getLatestCourses(limit?: number): Promise<ApiResponse<Course[]>> {
return ApiRequest.get('/courses/latest', { limit })
}
// 获取推荐课程
static getRecommendedCourses(userId?: number, limit?: number): Promise<ApiResponse<Course[]>> {
return ApiRequest.get('/courses/recommended', { userId, limit })
}
// 获取课程详情 - 适配后端接口
static async getCourseById(id: number): Promise<ApiResponse<Course>> {
try {
// 调用后端课程详情接口
const response = await ApiRequest.get<BackendCourse>('/lesson/detail', { id })
// 适配数据格式
const adaptedCourse: Course = {
id: response.data.id,
title: response.data.name,
description: response.data.description,
content: response.data.outline, // 使用 outline 作为课程内容
thumbnail: response.data.cover,
coverImage: response.data.cover,
price: parseFloat(response.data.price),
originalPrice: parseFloat(response.data.price),
currency: 'CNY',
rating: 4.5,
ratingCount: 0,
studentsCount: 0,
duration: this.calculateDuration(response.data.startTime, response.data.endTime),
totalLessons: 0,
level: 'beginner' as const,
language: 'zh-CN',
category: {
id: response.data.categoryId,
name: '未分类',
slug: 'uncategorized'
},
tags: [],
skills: [],
requirements: response.data.prerequisite ? [response.data.prerequisite] : [],
objectives: response.data.target ? [response.data.target] : [],
instructor: {
id: response.data.teacherId || 0,
name: response.data.school || '未知讲师',
title: '讲师',
bio: response.data.position || '',
avatar: '',
rating: 4.5,
studentsCount: 0,
coursesCount: 0,
experience: response.data.arrangement || '',
education: [],
certifications: []
},
status: 'published' as const,
createdAt: this.formatTimestamp(response.data.createdAt),
updatedAt: this.formatTimestamp(response.data.updatedAt),
publishedAt: response.data.startTime
}
return {
code: response.code,
message: response.message,
data: adaptedCourse
}
} catch (error) {
throw error
}
}
// 获取课程章节
static getCourseChapters(courseId: number): Promise<ApiResponse<Chapter[]>> {
return ApiRequest.get(`/courses/${courseId}/chapters`)
}
// 获取课程所有课时
static getCourseLessons(courseId: number): Promise<ApiResponse<Lesson[]>> {
return ApiRequest.get(`/courses/${courseId}/lessons`)
}
// 获取章节详情
static getChapterById(id: number): Promise<ApiResponse<Chapter>> {
return ApiRequest.get(`/chapters/${id}`)
}
// 获取课时详情
static getLessonById(id: number): Promise<ApiResponse<Lesson>> {
return ApiRequest.get(`/lessons/${id}`)
}
// 获取课时资源
static getLessonResources(lessonId: number): Promise<ApiResponse<LessonResource[]>> {
return ApiRequest.get(`/lessons/${lessonId}/resources`)
}
// 获取课程分类
static getCategories(): Promise<ApiResponse<CourseCategory[]>> {
return ApiRequest.get('/categories')
}
// 获取分类下的课程
static getCoursesByCategory(categoryId: number, params?: {
page?: number
pageSize?: number
sortBy?: string
}): Promise<ApiResponse<PaginationResponse<Course>>> {
return ApiRequest.get(`/categories/${categoryId}/courses`, params)
}
// 报名课程
static enrollCourse(courseId: number): Promise<ApiResponse<{
enrollmentId: number
message: string
}>> {
return ApiRequest.post(`/courses/${courseId}/enroll`)
}
// 取消报名
static unenrollCourse(courseId: number): Promise<ApiResponse<null>> {
return ApiRequest.delete(`/courses/${courseId}/enroll`)
}
// 获取课程章节列表
static async getCourseSections(lessonId: number): Promise<ApiResponse<CourseSectionListResponse>> {
try {
console.log('尝试从API获取课程章节数据课程ID:', lessonId)
console.log('API请求URL: /lesson/section/list')
console.log('API请求参数:', { lesson_id: lessonId.toString() })
const backendResponse = await ApiRequest.get<BackendCourseSectionListResponse>('/lesson/section/list', { lesson_id: lessonId.toString() })
console.log('章节API响应:', backendResponse)
console.log('响应状态码:', backendResponse.code)
console.log('响应消息:', backendResponse.message)
console.log('原始章节数据:', backendResponse.data?.list)
console.log('章节数据数量:', backendResponse.data?.list?.length || 0)
// 检查数据是否存在
if (!backendResponse.data || !backendResponse.data.list) {
console.warn('API返回的数据结构不正确:', backendResponse.data)
return {
code: backendResponse.code,
message: backendResponse.message,
data: {
list: [],
timestamp: Date.now(),
traceId: backendResponse.timestamp?.toString() || ''
},
timestamp: backendResponse.timestamp?.toString()
}
}
// 适配数据格式
const adaptedSections: CourseSection[] = backendResponse.data.list.map((section: BackendCourseSection) => ({
id: section.id,
lessonId: section.lessonId,
outline: section.videoUrl, // 将videoUrl映射到outline
name: section.name,
parentId: section.parentId,
sort: section.sortOrder, // 将sortOrder映射到sort
level: section.level === 0 ? 1 : 0, // 转换level逻辑API中0=子级1=父级前端中0=父级1=子级
revision: section.revision,
createdAt: section.createdTime ? new Date(section.createdTime).getTime() : null,
updatedAt: section.updatedTime ? new Date(section.updatedTime).getTime() : null,
deletedAt: null,
completed: false,
duration: undefined
}))
console.log('适配后的章节数据:', adaptedSections)
const adaptedResponse: ApiResponse<CourseSectionListResponse> = {
code: backendResponse.code,
message: backendResponse.message,
data: {
list: adaptedSections,
timestamp: Date.now(),
traceId: backendResponse.timestamp?.toString() || ''
},
timestamp: backendResponse.timestamp?.toString()
}
return adaptedResponse
} catch (error) {
console.error('章节API调用失败:', error)
console.error('错误详情:', {
message: (error as Error).message,
stack: (error as Error).stack,
response: (error as any).response?.data,
status: (error as any).response?.status,
statusText: (error as any).response?.statusText
})
// 重新抛出错误,不使用模拟数据
throw error
}
}
// 获取我的课程
static getMyCourses(params?: {
page?: number
pageSize?: number
status?: 'all' | 'in_progress' | 'completed'
}): Promise<ApiResponse<PaginationResponse<Course>>> {
return ApiRequest.get('/my-courses', params)
}
// 获取学习进度
static getLearningProgress(courseId: number): Promise<ApiResponse<LearningProgress>> {
return ApiRequest.get(`/courses/${courseId}/progress`)
}
// 更新学习进度
static updateLearningProgress(data: {
courseId: number
lessonId: number
progress: number
timeSpent?: number
}): Promise<ApiResponse<LearningProgress>> {
return ApiRequest.post('/learning-progress', data)
}
// 标记课时完成
static markLessonCompleted(lessonId: number): Promise<ApiResponse<null>> {
return ApiRequest.post(`/lessons/${lessonId}/complete`)
}
// 获取课程测验
static getCourseQuizzes(courseId: number): Promise<ApiResponse<Quiz[]>> {
return ApiRequest.get(`/courses/${courseId}/quizzes`)
}
// 获取测验详情
static getQuizById(id: number): Promise<ApiResponse<Quiz>> {
return ApiRequest.get(`/quizzes/${id}`)
}
// 提交测验答案
static submitQuizAnswers(quizId: number, answers: Array<{
questionId: number
answer: string | string[]
}>): Promise<ApiResponse<{
score: number
totalScore: number
passed: boolean
correctAnswers: number
totalQuestions: number
results: Array<{
questionId: number
correct: boolean
userAnswer: string | string[]
correctAnswer: string | string[]
}>
}>> {
return ApiRequest.post(`/quizzes/${quizId}/submit`, { answers })
}
// 获取测验结果
static getQuizResults(quizId: number): Promise<ApiResponse<{
attempts: Array<{
id: number
score: number
totalScore: number
passed: boolean
submittedAt: string
}>
bestScore: number
averageScore: number
totalAttempts: number
}>> {
return ApiRequest.get(`/quizzes/${quizId}/results`)
}
// 下载课程资源
static downloadResource(resourceId: number): Promise<void> {
return ApiRequest.download(`/resources/${resourceId}/download`)
}
// 获取讲师信息
static getInstructorById(id: number): Promise<ApiResponse<Instructor>> {
return ApiRequest.get(`/instructors/${id}`)
}
// 获取讲师的课程
static getInstructorCourses(instructorId: number, params?: {
page?: number
pageSize?: number
}): Promise<ApiResponse<PaginationResponse<Course>>> {
return ApiRequest.get(`/instructors/${instructorId}/courses`, params)
}
// 关注讲师
static followInstructor(instructorId: number): Promise<ApiResponse<null>> {
return ApiRequest.post(`/instructors/${instructorId}/follow`)
}
// 取消关注讲师
static unfollowInstructor(instructorId: number): Promise<ApiResponse<null>> {
return ApiRequest.delete(`/instructors/${instructorId}/follow`)
}
// 获取课程统计信息
static getCourseStats(courseId: number): Promise<ApiResponse<{
totalStudents: number
totalLessons: number
totalDuration: string
averageRating: number
completionRate: number
enrollmentTrend: Array<{
date: string
count: number
}>
}>> {
return ApiRequest.get(`/courses/${courseId}/stats`)
}
// 预览课程(免费课时)
static previewCourse(courseId: number): Promise<ApiResponse<{
freeLessons: Lesson[]
previewVideo?: string
}>> {
return ApiRequest.get(`/courses/${courseId}/preview`)
}
// 获取相关课程推荐
static getRelatedCourses(courseId: number, limit?: number): Promise<ApiResponse<Course[]>> {
return ApiRequest.get(`/courses/${courseId}/related`, { limit })
}
// 检查课程访问权限
static checkCourseAccess(courseId: number): Promise<ApiResponse<{
hasAccess: boolean
reason?: string
expiresAt?: string
}>> {
return ApiRequest.get(`/courses/${courseId}/access`)
}
// 辅助方法:映射难度等级
private static mapDifficulty(difficulty: number): 'beginner' | 'intermediate' | 'advanced' {
switch (difficulty) {
case 0:
return 'beginner'
case 1:
return 'intermediate'
case 2:
return 'advanced'
default:
return 'beginner'
}
}
// 辅助方法:获取分类名称
private static getCategoryName(categoryId: number): string {
// 这里可以根据categoryId返回对应的分类名称
// 暂时返回默认值后续可以通过分类API获取
const categoryMap: { [key: number]: string } = {
1: '信息技术',
2: '数学',
3: '物理',
4: '化学',
5: '生物'
}
return categoryMap[categoryId] || '其他'
}
}
export default CourseApi

194
src/api/modules/favorite.ts Normal file
View File

@ -0,0 +1,194 @@
// 收藏相关API接口
import { ApiRequest } from '../request'
import type {
ApiResponse,
PaginationResponse,
Favorite,
Course,
} from '../types'
/**
* API模块
*/
export class FavoriteApi {
// 添加收藏
static addFavorite(courseId: number): Promise<ApiResponse<Favorite>> {
return ApiRequest.post('/favorites', { courseId })
}
// 取消收藏
static removeFavorite(courseId: number): Promise<ApiResponse<null>> {
return ApiRequest.delete(`/favorites/${courseId}`)
}
// 检查是否已收藏
static checkFavorite(courseId: number): Promise<ApiResponse<{
isFavorite: boolean
favoriteId?: number
}>> {
return ApiRequest.get(`/favorites/check/${courseId}`)
}
// 获取我的收藏列表
static getMyFavorites(params?: {
page?: number
pageSize?: number
category?: string
sortBy?: 'newest' | 'oldest' | 'rating' | 'price'
}): Promise<ApiResponse<PaginationResponse<Favorite>>> {
return ApiRequest.get('/favorites', params)
}
// 批量添加收藏
static batchAddFavorites(courseIds: number[]): Promise<ApiResponse<{
success: number[]
failed: number[]
message: string
}>> {
return ApiRequest.post('/favorites/batch', { courseIds })
}
// 批量取消收藏
static batchRemoveFavorites(courseIds: number[]): Promise<ApiResponse<{
success: number[]
failed: number[]
message: string
}>> {
return ApiRequest.delete('/favorites/batch', { courseIds })
}
// 获取收藏统计
static getFavoriteStats(): Promise<ApiResponse<{
totalFavorites: number
favoritesByCategory: Array<{
category: string
count: number
}>
recentFavorites: Course[]
favoritesTrend: Array<{
date: string
count: number
}>
}>> {
return ApiRequest.get('/favorites/stats')
}
// 导出收藏列表
static exportFavorites(format: 'json' | 'csv' | 'excel'): Promise<void> {
return ApiRequest.download(`/favorites/export?format=${format}`, `favorites.${format}`)
}
// 导入收藏列表
static importFavorites(file: File): Promise<ApiResponse<{
imported: number
failed: number
duplicates: number
message: string
}>> {
return ApiRequest.upload('/favorites/import', file)
}
// 清空收藏列表
static clearAllFavorites(): Promise<ApiResponse<null>> {
return ApiRequest.delete('/favorites/clear')
}
// 获取收藏夹分类(如果支持分类收藏)
static getFavoriteFolders(): Promise<ApiResponse<Array<{
id: number
name: string
description?: string
courseCount: number
createdAt: string
}>>> {
return ApiRequest.get('/favorite-folders')
}
// 创建收藏夹
static createFavoriteFolder(data: {
name: string
description?: string
}): Promise<ApiResponse<{
id: number
name: string
description?: string
}>> {
return ApiRequest.post('/favorite-folders', data)
}
// 更新收藏夹
static updateFavoriteFolder(folderId: number, data: {
name?: string
description?: string
}): Promise<ApiResponse<null>> {
return ApiRequest.put(`/favorite-folders/${folderId}`, data)
}
// 删除收藏夹
static deleteFavoriteFolder(folderId: number): Promise<ApiResponse<null>> {
return ApiRequest.delete(`/favorite-folders/${folderId}`)
}
// 将课程添加到收藏夹
static addCourseToFolder(courseId: number, folderId: number): Promise<ApiResponse<null>> {
return ApiRequest.post(`/favorite-folders/${folderId}/courses`, { courseId })
}
// 从收藏夹移除课程
static removeCourseFromFolder(courseId: number, folderId: number): Promise<ApiResponse<null>> {
return ApiRequest.delete(`/favorite-folders/${folderId}/courses/${courseId}`)
}
// 获取收藏夹中的课程
static getFolderCourses(folderId: number, params?: {
page?: number
pageSize?: number
}): Promise<ApiResponse<PaginationResponse<Course>>> {
return ApiRequest.get(`/favorite-folders/${folderId}/courses`, params)
}
// 移动课程到其他收藏夹
static moveCourseToFolder(courseId: number, fromFolderId: number, toFolderId: number): Promise<ApiResponse<null>> {
return ApiRequest.post('/favorites/move', {
courseId,
fromFolderId,
toFolderId
})
}
// 获取最近收藏的课程
static getRecentFavorites(limit?: number): Promise<ApiResponse<Course[]>> {
return ApiRequest.get('/favorites/recent', { limit })
}
// 获取收藏推荐(基于收藏历史推荐相似课程)
static getFavoriteRecommendations(limit?: number): Promise<ApiResponse<Course[]>> {
return ApiRequest.get('/favorites/recommendations', { limit })
}
// 分享收藏列表
static shareFavorites(data: {
folderId?: number
isPublic: boolean
description?: string
}): Promise<ApiResponse<{
shareId: string
shareUrl: string
expiresAt?: string
}>> {
return ApiRequest.post('/favorites/share', data)
}
// 获取分享的收藏列表
static getSharedFavorites(shareId: string): Promise<ApiResponse<{
title: string
description?: string
courses: Course[]
sharedBy: string
sharedAt: string
}>> {
return ApiRequest.get(`/favorites/shared/${shareId}`)
}
}
export default FavoriteApi

282
src/api/modules/order.ts Normal file
View File

@ -0,0 +1,282 @@
// 订单相关API接口
import { ApiRequest } from '../request'
import type {
ApiResponse,
PaginationResponse,
Order,
OrderItem,
} from '../types'
/**
* API模块
*/
export class OrderApi {
// 创建订单
static createOrder(data: {
courseIds: number[]
couponCode?: string
paymentMethod?: string
}): Promise<ApiResponse<{
order: Order
paymentInfo?: {
paymentUrl?: string
qrCode?: string
orderNo: string
}
}>> {
return ApiRequest.post('/orders', data)
}
// 获取订单列表
static getOrders(params?: {
page?: number
pageSize?: number
status?: string
startDate?: string
endDate?: string
}): Promise<ApiResponse<PaginationResponse<Order>>> {
return ApiRequest.get('/orders', params)
}
// 获取订单详情
static getOrderById(orderId: number): Promise<ApiResponse<Order>> {
return ApiRequest.get(`/orders/${orderId}`)
}
// 通过订单号获取订单
static getOrderByNo(orderNo: string): Promise<ApiResponse<Order>> {
return ApiRequest.get(`/orders/no/${orderNo}`)
}
// 取消订单
static cancelOrder(orderId: number, reason?: string): Promise<ApiResponse<null>> {
return ApiRequest.post(`/orders/${orderId}/cancel`, { reason })
}
// 确认支付
static confirmPayment(orderId: number, data: {
paymentMethod: string
transactionId?: string
paymentProof?: string
}): Promise<ApiResponse<{
success: boolean
message: string
paymentStatus: string
}>> {
return ApiRequest.post(`/orders/${orderId}/confirm-payment`, data)
}
// 申请退款
static requestRefund(orderId: number, data: {
reason: string
description?: string
refundAmount?: number
}): Promise<ApiResponse<{
refundId: number
message: string
}>> {
return ApiRequest.post(`/orders/${orderId}/refund`, data)
}
// 获取支付方式列表
static getPaymentMethods(): Promise<ApiResponse<Array<{
id: string
name: string
type: 'alipay' | 'wechat' | 'bank' | 'paypal' | 'stripe'
icon: string
enabled: boolean
description?: string
fee?: number
feeType?: 'fixed' | 'percentage'
}>>> {
return ApiRequest.get('/payment-methods')
}
// 获取支付状态
static getPaymentStatus(orderId: number): Promise<ApiResponse<{
status: 'pending' | 'processing' | 'success' | 'failed' | 'cancelled'
message: string
paidAt?: string
paymentMethod?: string
transactionId?: string
}>> {
return ApiRequest.get(`/orders/${orderId}/payment-status`)
}
// 重新支付
static retryPayment(orderId: number, paymentMethod?: string): Promise<ApiResponse<{
paymentUrl?: string
qrCode?: string
orderNo: string
}>> {
return ApiRequest.post(`/orders/${orderId}/retry-payment`, { paymentMethod })
}
// 获取发票信息
static getInvoice(orderId: number): Promise<ApiResponse<{
invoiceNo: string
invoiceUrl: string
invoiceDate: string
amount: number
taxAmount: number
items: Array<{
name: string
quantity: number
price: number
amount: number
}>
}>> {
return ApiRequest.get(`/orders/${orderId}/invoice`)
}
// 申请发票
static requestInvoice(orderId: number, data: {
type: 'personal' | 'company'
title: string
taxId?: string
address?: string
phone?: string
bank?: string
bankAccount?: string
email: string
}): Promise<ApiResponse<{
invoiceId: number
message: string
}>> {
return ApiRequest.post(`/orders/${orderId}/request-invoice`, data)
}
// 下载发票
static downloadInvoice(orderId: number): Promise<void> {
return ApiRequest.download(`/orders/${orderId}/invoice/download`, `invoice-${orderId}.pdf`)
}
// 获取订单统计
static getOrderStats(params?: {
startDate?: string
endDate?: string
}): Promise<ApiResponse<{
totalOrders: number
totalAmount: number
paidOrders: number
paidAmount: number
pendingOrders: number
cancelledOrders: number
refundedOrders: number
averageOrderValue: number
ordersByStatus: Array<{
status: string
count: number
amount: number
}>
ordersTrend: Array<{
date: string
orders: number
amount: number
}>
}>> {
return ApiRequest.get('/orders/stats', params)
}
// 验证优惠券
static validateCoupon(code: string, courseIds: number[]): Promise<ApiResponse<{
valid: boolean
coupon?: {
id: number
code: string
type: 'fixed' | 'percentage'
value: number
minAmount?: number
maxDiscount?: number
description: string
}
discount: number
message: string
}>> {
return ApiRequest.post('/coupons/validate', { code, courseIds })
}
// 获取可用优惠券
static getAvailableCoupons(courseIds?: number[]): Promise<ApiResponse<Array<{
id: number
code: string
name: string
type: 'fixed' | 'percentage'
value: number
minAmount?: number
maxDiscount?: number
description: string
expiresAt: string
usageLimit?: number
usedCount: number
}>>> {
return ApiRequest.get('/coupons/available', { courseIds })
}
// 计算订单金额
static calculateOrderAmount(data: {
courseIds: number[]
couponCode?: string
}): Promise<ApiResponse<{
subtotal: number
discount: number
tax: number
total: number
items: Array<{
courseId: number
title: string
price: number
discountPrice?: number
}>
coupon?: {
code: string
discount: number
description: string
}
}>> {
return ApiRequest.post('/orders/calculate', data)
}
// 获取退款列表
static getRefunds(params?: {
page?: number
pageSize?: number
status?: string
}): Promise<ApiResponse<PaginationResponse<{
id: number
orderId: number
orderNo: string
amount: number
reason: string
status: 'pending' | 'approved' | 'rejected' | 'processed'
requestedAt: string
processedAt?: string
note?: string
}>>> {
return ApiRequest.get('/refunds', params)
}
// 获取退款详情
static getRefundById(refundId: number): Promise<ApiResponse<{
id: number
order: Order
amount: number
reason: string
description?: string
status: string
requestedAt: string
processedAt?: string
processedBy?: string
note?: string
timeline: Array<{
status: string
note?: string
createdAt: string
createdBy?: string
}>
}>> {
return ApiRequest.get(`/refunds/${refundId}`)
}
}
export default OrderApi

View File

@ -0,0 +1,374 @@
// 统计相关API接口
import { ApiRequest } from '../request'
import type { ApiResponse, Statistics } from '../types'
/**
* API模块
*/
export class StatisticsApi {
// 获取平台总体统计
static getPlatformStats(): Promise<ApiResponse<Statistics>> {
return ApiRequest.get('/statistics/platform')
}
// 获取用户学习统计
static getUserLearningStats(userId?: number): Promise<ApiResponse<{
totalCourses: number
completedCourses: number
inProgressCourses: number
totalLearningTime: number
averageProgress: number
certificates: number
favoriteCount: number
commentsCount: number
learningStreak: number
lastActiveDate: string
monthlyProgress: Array<{
month: string
coursesCompleted: number
learningTime: number
}>
skillsAcquired: Array<{
skill: string
level: number
coursesCount: number
}>
achievements: Array<{
id: number
name: string
description: string
icon: string
unlockedAt: string
}>
}>> {
return ApiRequest.get('/statistics/user-learning', { userId })
}
// 获取课程统计
static getCourseStats(courseId: number): Promise<ApiResponse<{
totalStudents: number
activeStudents: number
completedStudents: number
averageProgress: number
averageRating: number
totalRatings: number
totalComments: number
totalLearningTime: number
completionRate: number
dropoutRate: number
enrollmentTrend: Array<{
date: string
enrollments: number
completions: number
}>
progressDistribution: Array<{
range: string
count: number
percentage: number
}>
ratingDistribution: Array<{
rating: number
count: number
percentage: number
}>
popularLessons: Array<{
lessonId: number
title: string
viewCount: number
averageWatchTime: number
}>
studentDemographics: {
ageGroups: Array<{
range: string
count: number
percentage: number
}>
locations: Array<{
country: string
count: number
percentage: number
}>
devices: Array<{
device: string
count: number
percentage: number
}>
}
}>> {
return ApiRequest.get(`/statistics/course/${courseId}`)
}
// 获取讲师统计
static getInstructorStats(instructorId: number): Promise<ApiResponse<{
totalCourses: number
publishedCourses: number
totalStudents: number
totalRevenue: number
averageRating: number
totalRatings: number
totalReviews: number
followers: number
coursesRanking: Array<{
courseId: number
title: string
students: number
rating: number
revenue: number
}>
monthlyStats: Array<{
month: string
newStudents: number
revenue: number
newCourses: number
}>
studentFeedback: {
positiveCount: number
neutralCount: number
negativeCount: number
commonKeywords: Array<{
keyword: string
count: number
sentiment: 'positive' | 'neutral' | 'negative'
}>
}
}>> {
return ApiRequest.get(`/statistics/instructor/${instructorId}`)
}
// 获取学习进度统计
static getLearningProgressStats(params?: {
courseId?: number
userId?: number
startDate?: string
endDate?: string
}): Promise<ApiResponse<{
totalSessions: number
totalLearningTime: number
averageSessionTime: number
completionRate: number
dailyProgress: Array<{
date: string
sessions: number
learningTime: number
lessonsCompleted: number
}>
weeklyProgress: Array<{
week: string
sessions: number
learningTime: number
lessonsCompleted: number
}>
deviceUsage: Array<{
device: string
sessions: number
percentage: number
}>
timeDistribution: Array<{
hour: number
sessions: number
percentage: number
}>
}>> {
return ApiRequest.get('/statistics/learning-progress', params)
}
// 获取收入统计
static getRevenueStats(params?: {
instructorId?: number
startDate?: string
endDate?: string
groupBy?: 'day' | 'week' | 'month' | 'year'
}): Promise<ApiResponse<{
totalRevenue: number
totalOrders: number
averageOrderValue: number
refundRate: number
revenueByPeriod: Array<{
period: string
revenue: number
orders: number
refunds: number
}>
revenueByCategory: Array<{
category: string
revenue: number
percentage: number
}>
topSellingCourses: Array<{
courseId: number
title: string
sales: number
revenue: number
}>
paymentMethods: Array<{
method: string
count: number
amount: number
percentage: number
}>
}>> {
return ApiRequest.get('/statistics/revenue', params)
}
// 获取用户行为统计
static getUserBehaviorStats(params?: {
startDate?: string
endDate?: string
}): Promise<ApiResponse<{
totalUsers: number
activeUsers: number
newUsers: number
returningUsers: number
userRetentionRate: number
averageSessionDuration: number
bounceRate: number
userActivity: Array<{
date: string
activeUsers: number
newUsers: number
sessions: number
}>
userEngagement: {
dailyActiveUsers: number
weeklyActiveUsers: number
monthlyActiveUsers: number
averageSessionsPerUser: number
averagePageViews: number
}
userJourney: Array<{
step: string
users: number
conversionRate: number
}>
popularPages: Array<{
page: string
views: number
uniqueViews: number
averageTime: number
}>
}>> {
return ApiRequest.get('/statistics/user-behavior', params)
}
// 获取搜索统计
static getSearchStats(params?: {
startDate?: string
endDate?: string
limit?: number
}): Promise<ApiResponse<{
totalSearches: number
uniqueSearches: number
averageResultsPerSearch: number
noResultsRate: number
topKeywords: Array<{
keyword: string
count: number
clickThroughRate: number
conversionRate: number
}>
searchTrends: Array<{
date: string
searches: number
uniqueSearches: number
}>
categorySearches: Array<{
category: string
searches: number
percentage: number
}>
searchSources: Array<{
source: string
count: number
percentage: number
}>
}>> {
return ApiRequest.get('/statistics/search', params)
}
// 获取内容统计
static getContentStats(): Promise<ApiResponse<{
totalCourses: number
publishedCourses: number
draftCourses: number
totalLessons: number
totalDuration: string
averageCourseDuration: string
averageLessonsPerCourse: number
contentByCategory: Array<{
category: string
courses: number
lessons: number
duration: string
}>
contentByLevel: Array<{
level: string
courses: number
percentage: number
}>
contentByLanguage: Array<{
language: string
courses: number
percentage: number
}>
contentGrowth: Array<{
month: string
newCourses: number
newLessons: number
}>
}>> {
return ApiRequest.get('/statistics/content')
}
// 获取评论统计
static getCommentStats(params?: {
courseId?: number
instructorId?: number
startDate?: string
endDate?: string
}): Promise<ApiResponse<{
totalComments: number
averageRating: number
responseRate: number
averageResponseTime: number
sentimentAnalysis: {
positive: number
neutral: number
negative: number
}
ratingDistribution: Array<{
rating: number
count: number
percentage: number
}>
commentTrends: Array<{
date: string
comments: number
averageRating: number
}>
topReviewedCourses: Array<{
courseId: number
title: string
comments: number
averageRating: number
}>
commonKeywords: Array<{
keyword: string
count: number
sentiment: 'positive' | 'neutral' | 'negative'
}>
}>> {
return ApiRequest.get('/statistics/comments', params)
}
// 导出统计报告
static exportStatsReport(type: string, params?: {
startDate?: string
endDate?: string
format?: 'pdf' | 'excel' | 'csv'
}): Promise<void> {
const format = params?.format || 'pdf'
return ApiRequest.download(`/statistics/export/${type}?format=${format}`, `stats-report.${format}`, params)
}
}
export default StatisticsApi

331
src/api/modules/upload.ts Normal file
View File

@ -0,0 +1,331 @@
// 文件上传相关API接口
import { ApiRequest } from '../request'
import type { ApiResponse } from '../types'
/**
* API模块
*/
export class UploadApi {
// 上传单个文件
static uploadFile(
file: File,
type: 'image' | 'video' | 'document' | 'audio' = 'image',
onProgress?: (progress: number) => void
): Promise<ApiResponse<{
url: string
filename: string
size: number
type: string
hash?: string
}>> {
return ApiRequest.upload(`/upload/${type}`, file, onProgress)
}
// 上传头像
static uploadAvatar(
file: File,
onProgress?: (progress: number) => void
): Promise<ApiResponse<{
url: string
filename: string
size: number
}>> {
return ApiRequest.upload('/upload/avatar', file, onProgress)
}
// 上传课程封面
static uploadCourseThumbnail(
file: File,
courseId?: number,
onProgress?: (progress: number) => void
): Promise<ApiResponse<{
url: string
filename: string
size: number
}>> {
const formData = new FormData()
formData.append('file', file)
if (courseId) {
formData.append('courseId', courseId.toString())
}
return ApiRequest.post('/upload/course-thumbnail', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
onProgress(progress)
}
},
})
}
// 上传课程视频
static uploadCourseVideo(
file: File,
courseId?: number,
lessonId?: number,
onProgress?: (progress: number) => void
): Promise<ApiResponse<{
url: string
filename: string
size: number
duration?: number
resolution?: string
}>> {
const formData = new FormData()
formData.append('file', file)
if (courseId) {
formData.append('courseId', courseId.toString())
}
if (lessonId) {
formData.append('lessonId', lessonId.toString())
}
return ApiRequest.post('/upload/course-video', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
onProgress(progress)
}
},
})
}
// 上传课程资源文件
static uploadCourseResource(
file: File,
lessonId: number,
title?: string,
description?: string,
onProgress?: (progress: number) => void
): Promise<ApiResponse<{
id: number
url: string
filename: string
title: string
size: number
type: string
}>> {
const formData = new FormData()
formData.append('file', file)
formData.append('lessonId', lessonId.toString())
if (title) {
formData.append('title', title)
}
if (description) {
formData.append('description', description)
}
return ApiRequest.post('/upload/course-resource', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
onProgress(progress)
}
},
})
}
// 批量上传文件
static uploadMultipleFiles(
files: File[],
type: 'image' | 'video' | 'document' | 'audio' = 'image',
onProgress?: (progress: number) => void
): Promise<ApiResponse<Array<{
url: string
filename: string
size: number
type: string
success: boolean
error?: string
}>>> {
const formData = new FormData()
files.forEach((file, index) => {
formData.append(`files`, file)
})
return ApiRequest.post(`/upload/multiple/${type}`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
onProgress(progress)
}
},
})
}
// 获取上传配置
static getUploadConfig(): Promise<ApiResponse<{
maxFileSize: number
allowedTypes: string[]
imageTypes: string[]
videoTypes: string[]
documentTypes: string[]
audioTypes: string[]
uploadUrl: string
cdnUrl: string
}>> {
return ApiRequest.get('/upload/config')
}
// 获取上传token用于直传OSS等
static getUploadToken(type: string = 'image'): Promise<ApiResponse<{
token: string
accessKeyId: string
accessKeySecret: string
securityToken: string
bucket: string
region: string
endpoint: string
expiration: string
policy: string
signature: string
}>> {
return ApiRequest.get(`/upload/token/${type}`)
}
// 删除文件
static deleteFile(url: string): Promise<ApiResponse<null>> {
return ApiRequest.delete('/upload/file', { url })
}
// 批量删除文件
static deleteMultipleFiles(urls: string[]): Promise<ApiResponse<{
success: string[]
failed: string[]
}>> {
return ApiRequest.delete('/upload/files', { urls })
}
// 获取文件信息
static getFileInfo(url: string): Promise<ApiResponse<{
url: string
filename: string
size: number
type: string
uploadedAt: string
uploadedBy?: string
downloads?: number
}>> {
return ApiRequest.get('/upload/file-info', { url })
}
// 压缩图片
static compressImage(
file: File,
options: {
quality?: number
maxWidth?: number
maxHeight?: number
format?: 'jpeg' | 'png' | 'webp'
} = {},
onProgress?: (progress: number) => void
): Promise<ApiResponse<{
url: string
filename: string
originalSize: number
compressedSize: number
compressionRatio: number
}>> {
const formData = new FormData()
formData.append('file', file)
formData.append('options', JSON.stringify(options))
return ApiRequest.post('/upload/compress-image', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
onProgress(progress)
}
},
})
}
// 生成缩略图
static generateThumbnail(
file: File,
sizes: Array<{ width: number; height: number }> = [
{ width: 150, height: 150 },
{ width: 300, height: 300 },
],
onProgress?: (progress: number) => void
): Promise<ApiResponse<{
original: string
thumbnails: Array<{
size: string
url: string
width: number
height: number
}>
}>> {
const formData = new FormData()
formData.append('file', file)
formData.append('sizes', JSON.stringify(sizes))
return ApiRequest.post('/upload/generate-thumbnail', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
onProgress(progress)
}
},
})
}
// 获取上传历史
static getUploadHistory(params?: {
page?: number
pageSize?: number
type?: string
startDate?: string
endDate?: string
}): Promise<ApiResponse<{
list: Array<{
id: number
filename: string
originalName: string
url: string
size: number
type: string
uploadedAt: string
downloads: number
}>
total: number
page: number
pageSize: number
}>> {
return ApiRequest.get('/upload/history', params)
}
}
export default UploadApi

527
src/api/request.ts Normal file
View File

@ -0,0 +1,527 @@
// HTTP 请求封装文件
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { useUserStore } from '@/stores/user'
import router from '@/router'
import type { ApiResponse } from './types'
// 消息提示函数 - 使用window.alert作为fallback实际项目中应该使用UI库的消息组件
const showMessage = (message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') => {
// 这里可以替换为你使用的UI库的消息组件
// 例如naive-ui的 useMessage()
console.log(`[${type.toUpperCase()}] ${message}`)
// 临时使用alert实际项目中应该替换为UI库的消息组件
if (type === 'error') {
alert(`错误: ${message}`)
}
}
// 创建axios实例
const request: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
request.interceptors.request.use(
(config: AxiosRequestConfig) => {
// 添加认证token
const userStore = useUserStore()
if (userStore.token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${userStore.token}`,
}
}
// 添加请求时间戳
config.headers = {
...config.headers,
'X-Request-Time': Date.now().toString(),
}
// 开发环境下打印请求信息
if (import.meta.env.DEV) {
console.log('🚀 Request:', {
url: config.url,
method: config.method,
params: config.params,
data: config.data,
})
}
return config
},
(error) => {
console.error('❌ Request Error:', error)
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { data } = response
// 开发环境下打印响应信息
if (import.meta.env.DEV) {
console.log('✅ Response:', {
url: response.config.url,
status: response.status,
data: data,
})
}
// 检查业务状态码
if (data.code === 200 || data.code === 0) {
return data
}
// 处理业务错误
const errorMessage = data.message || '请求失败'
// 不在这里显示错误消息,让组件自己处理
// showMessage(errorMessage, 'error')
// 创建一个包含完整响应信息的错误对象
const error = new Error(errorMessage)
;(error as any).response = {
data: data,
status: 200 // HTTP状态码是200但业务状态码不是成功
}
return Promise.reject(error)
},
(error) => {
console.error('❌ Response Error:', error)
// 处理HTTP状态码错误
const { response } = error
let errorMessage = '网络错误,请稍后重试'
if (response) {
switch (response.status) {
case 400:
errorMessage = '请求参数错误'
break
case 401:
errorMessage = '登录已过期,请重新登录'
// 清除用户信息,不跳转页面(使用模态框登录)
const userStore = useUserStore()
userStore.logout()
break
case 403:
errorMessage = '没有权限访问'
break
case 404:
errorMessage = '请求的资源不存在'
break
case 422:
errorMessage = '数据验证失败'
break
case 429:
errorMessage = '请求过于频繁,请稍后重试'
break
case 500:
errorMessage = '服务器内部错误'
break
case 502:
errorMessage = '网关错误'
break
case 503:
errorMessage = '服务暂时不可用'
break
case 504:
errorMessage = '网关超时'
break
default:
errorMessage = `请求失败 (${response.status})`
}
} else if (error.code === 'ECONNABORTED') {
errorMessage = '请求超时,请检查网络连接'
} else if (error.message === 'Network Error') {
errorMessage = '网络连接失败,请检查网络设置'
}
showMessage(errorMessage, 'error')
return Promise.reject(error)
}
)
// Mock数据处理
const handleMockRequest = async <T = any>(url: string, method: string, data?: any): Promise<ApiResponse<T>> => {
console.log('🚀 Mock Request:', { url, method, data })
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500))
// 登录Mock
if (url === '/users/login' && method === 'POST') {
const { email, phone, password } = data || {}
const loginField = phone || email
// 模拟登录验证
if (loginField && password) {
return {
code: 200,
message: '登录成功',
data: {
user: {
id: 1,
email: email || `${phone}@example.com`,
phone: phone || '123456789',
username: phone || email?.split('@')[0] || 'user',
nickname: '测试用户',
avatar: 'https://via.placeholder.com/100',
role: 'student',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z'
},
token: 'mock_jwt_token_' + Date.now(),
refreshToken: 'mock_refresh_token_' + Date.now(),
expiresIn: 3600
}
} as ApiResponse<T>
} else {
return {
code: 400,
message: '手机号/邮箱或密码不能为空',
data: null
} as ApiResponse<T>
}
}
// 注册Mock
if (url === '/auth/register' && method === 'POST') {
const { email, password, verificationCode } = data || {}
if (!email || !password) {
return {
code: 400,
message: '邮箱和密码不能为空',
data: null
} as ApiResponse<T>
}
if (!verificationCode) {
return {
code: 400,
message: '验证码不能为空',
data: null
} as ApiResponse<T>
}
return {
code: 200,
message: '注册成功',
data: {
id: 2,
email: email,
username: email.split('@')[0],
nickname: '新用户',
avatar: '',
role: 'student',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
} as ApiResponse<T>
}
// 发送验证码Mock
if (url === '/auth/send-verification' && method === 'POST') {
return {
code: 200,
message: '验证码已发送',
data: null
} as ApiResponse<T>
}
// 获取当前用户信息Mock
if (url === '/auth/me' && method === 'GET') {
return {
code: 200,
message: '获取成功',
data: {
id: 1,
email: 'test@example.com',
username: 'test',
nickname: '测试用户',
avatar: '',
role: 'student',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z'
}
} as ApiResponse<T>
}
// 课程详情Mock
if (url === '/lesson/detail' && method === 'GET') {
// 对于GET请求参数直接在data中data就是params对象
const id = data?.id
console.log('课程详情Mock - 获取到的ID:', id, '原始data:', data)
if (!id) {
return {
code: 400,
message: '课程ID必填',
data: null
} as ApiResponse<T>
}
// 根据课程ID提供不同的模拟数据
const courseData = {
1: {
name: 'DeepSeek大语言模型实战应用',
cover: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=400&h=300&fit=crop',
price: '299.00',
school: 'DeepSeek技术学院',
description: '本课程深度聚焦DeepSeek大语言模型的实际应用让每一位学员了解并学习使用DeepSeek结合办公自动化职业岗位标准以实际工作任务为引导强调课程内容的易用性和岗位要求的匹配性。',
position: 'AI技术专家 / 高级讲师'
},
2: {
name: 'Python编程基础与实战',
cover: 'https://images.unsplash.com/photo-1526379095098-d400fd0bf935?w=400&h=300&fit=crop',
price: '199.00',
school: '编程技术学院',
description: '从零开始学习Python编程涵盖基础语法、数据结构、面向对象编程等核心概念通过实际项目练习掌握Python开发技能。',
position: 'Python开发专家 / 资深讲师'
},
3: {
name: 'Web前端开发全栈课程',
cover: 'https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=400&h=300&fit=crop',
price: '399.00',
school: '前端技术学院',
description: '全面学习现代Web前端开发技术包括HTML5、CSS3、JavaScript、Vue.js、React等主流框架培养全栈开发能力。',
position: '前端架构师 / 技术总监'
}
}
const currentCourse = courseData[id as keyof typeof courseData] || courseData[1]
// 模拟课程详情数据
return {
code: 0,
message: '查询课程详情成功',
data: {
id: parseInt(id),
name: currentCourse.name,
cover: currentCourse.cover,
categoryId: 1,
price: currentCourse.price,
school: currentCourse.school,
description: currentCourse.description,
teacherId: 1,
outline: '<div><h4>课程大纲:</h4><ul><li><strong>第一章:基础入门</strong><br/>- 环境搭建与配置<br/>- 基本概念理解<br/>- 实践操作演示</li><li><strong>第二章:核心技能</strong><br/>- 核心功能详解<br/>- 实际应用场景<br/>- 案例分析讲解</li><li><strong>第三章:高级应用</strong><br/>- 进阶技巧掌握<br/>- 项目实战演练<br/>- 问题解决方案</li></ul></div>',
prerequisite: '具备基本的计算机操作能力',
target: '掌握核心技能,能够在实际工作中熟练应用',
arrangement: '理论与实践相结合,循序渐进的学习方式',
startTime: '2025-01-26 10:13:17',
endTime: '2025-03-26 10:13:17',
revision: 1,
position: currentCourse.position,
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null
}
} as ApiResponse<T>
}
// 课程列表Mock
if (url === '/lesson/list' && method === 'GET') {
// 模拟课程列表数据
const mockCourses = [
{
id: 1,
name: "暑期名师领学,提高班级教学质量!高效冲分指南",
cover: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
categoryId: 1,
price: "99.00",
school: "名师工作室",
description: "本课程深度聚焦问题让每一位教师了解并学习使用DeepSeek结合办公自动化职业岗位标准以实际工作任务为引导强调课程内容的易用性和岗位要求的匹配性。",
teacherId: 1,
outline: "课程大纲详细内容...",
prerequisite: "具备基本的计算机操作能力",
target: "掌握核心技能,能够在实际工作中熟练应用",
arrangement: "理论与实践相结合,循序渐进的学习方式",
startTime: "2025-01-26 10:13:17",
endTime: "2025-03-26 10:13:17",
revision: 1,
position: "高级讲师",
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null
},
{
id: 2,
name: "计算机二级考前冲刺班",
cover: "https://images.unsplash.com/photo-1517077304055-6e89abbf09b0?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
categoryId: 2,
price: "199.00",
school: "计算机学院",
description: "备考计算机二级,名师带你高效复习,掌握考试重点,轻松通过考试。",
teacherId: 2,
outline: "考试大纲详细解析...",
prerequisite: "具备基本的计算机基础知识",
target: "顺利通过计算机二级考试",
arrangement: "考点精讲+真题演练+模拟考试",
startTime: "2025-02-01 09:00:00",
endTime: "2025-02-28 18:00:00",
revision: 1,
position: "副教授",
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null
},
{
id: 3,
name: "摆脱哑巴英语,流利口语训练营",
cover: "https://images.unsplash.com/photo-1434030216411-0b793f4b4173?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
categoryId: 3,
price: "299.00",
school: "外语学院",
description: "专业外教授课,情景式教学,让你在短时间内突破口语障碍,自信开口说英语。",
teacherId: 3,
outline: "口语训练系统课程...",
prerequisite: "具备基本的英语基础",
target: "能够流利进行日常英语对话",
arrangement: "外教一对一+小班练习+实战演练",
startTime: "2025-02-15 19:00:00",
endTime: "2025-04-15 21:00:00",
revision: 1,
position: "外籍教师",
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null
}
]
return {
code: 0,
message: '查询课程列表成功',
data: {
list: mockCourses,
total: mockCourses.length
}
} as ApiResponse<T>
}
// 默认404响应
return {
code: 404,
message: '接口不存在',
data: null
} as ApiResponse<T>
}
// 请求方法封装
export class ApiRequest {
// GET 请求
static get<T = any>(
url: string,
params?: any,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
// 检查是否启用Mock
if (import.meta.env.VITE_ENABLE_MOCK === 'true') {
return handleMockRequest<T>(url, 'GET', params)
}
return request.get(url, { params, ...config })
}
// POST 请求
static post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
// 检查是否启用Mock
if (import.meta.env.VITE_ENABLE_MOCK === 'true') {
return handleMockRequest<T>(url, 'POST', data)
}
return request.post(url, data, config)
}
// PUT 请求
static put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
return request.put(url, data, config)
}
// PATCH 请求
static patch<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
return request.patch(url, data, config)
}
// DELETE 请求
static delete<T = any>(
url: string,
params?: any,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
return request.delete(url, { params, ...config })
}
// 文件上传
static upload<T = any>(
url: string,
file: File,
onProgress?: (progress: number) => void,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
const formData = new FormData()
formData.append('file', file)
return request.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
)
onProgress(progress)
}
},
...config,
})
}
// 文件下载
static download(
url: string,
filename?: string,
params?: any,
config?: AxiosRequestConfig
): Promise<void> {
return request
.get(url, {
params,
responseType: 'blob',
...config,
})
.then((response: any) => {
const blob = new Blob([response.data])
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = filename || 'download'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(downloadUrl)
})
}
}
export default request

407
src/api/types.ts Normal file
View File

@ -0,0 +1,407 @@
// API 接口类型定义文件
// 通用响应类型
export interface ApiResponse<T = any> {
code: number
message: string
data: T
timestamp?: string
}
// 分页响应类型
export interface PaginationResponse<T> {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
// 用户相关类型
export interface User {
id: number
username: string
email: string
phone?: string
avatar?: string
role: 'student' | 'teacher' | 'admin'
status: 'active' | 'inactive' | 'banned'
createdAt: string
updatedAt: string
profile?: UserProfile
}
export interface UserProfile {
realName?: string
gender?: 'male' | 'female' | 'other'
birthday?: string
bio?: string
location?: string
website?: string
socialLinks?: {
wechat?: string
qq?: string
weibo?: string
}
}
// 登录注册类型
export interface LoginRequest {
email?: string
phone?: string
password: string
captcha?: string
}
export interface LoginResponse {
user: User
token: string
refreshToken: string
expiresIn: number
}
// 后端实际返回的登录响应格式
export interface BackendLoginResponse {
token: string
id: number
timestamp: number
expires: string
}
export interface RegisterRequest {
username: string
email: string
phone?: string
password: string
confirmPassword: string
captcha: string
inviteCode?: string
}
// 课程相关类型
export interface Course {
id: number
title: string
subtitle?: string
description: string
content?: string
thumbnail: string
coverImage?: string
videoUrl?: string
price: number
originalPrice?: number
discountPrice?: number
currency: string
rating: number
ratingCount: number
studentsCount: number
duration: string
totalLessons: number
level: 'beginner' | 'intermediate' | 'advanced'
language: string
category: CourseCategory
tags: string[]
skills: string[]
requirements: string[]
objectives: string[]
instructor: Instructor
status: 'draft' | 'published' | 'archived'
isEnrolled?: boolean
progress?: number
isFavorite?: boolean
createdAt: string
updatedAt: string
publishedAt?: string
}
// 后端实际返回的课程数据格式
export interface BackendCourse {
id: number
name: string
cover: string
categoryId: number
video: string
school: string
description: string
target: string
outline: string
prerequisite: string
reference: string
arrangement: string
startTime: string
endTime: string
revision: number
question: string
createdBy: number
createdTime: string | null
updatedBy: number
updatedTime: string | null
// 可选字段根据API文档可能存在
difficulty?: number // 难度等级
subject?: string // 学科/主题
price?: string
teacherId?: number
position?: string
}
// 后端课程列表响应格式
export interface BackendCourseListResponse {
list: BackendCourse[]
total: number
}
// 课程列表请求参数
export interface CourseListRequest {
page?: number
pageSize?: number
categoryId?: number
keyword?: string
priceMin?: number
priceMax?: number
sortBy?: 'price' | 'startTime' | 'createdAt'
sortOrder?: 'asc' | 'desc'
difficulty?: number // 难度等级0=简单1=中等2=困难
subject?: string // 学科/主题
}
export interface CourseCategory {
id: number
name: string
slug: string
description?: string
icon?: string
parentId?: number
children?: CourseCategory[]
}
export interface Instructor {
id: number
name: string
title: string
bio: string
avatar: string
rating: number
studentsCount: number
coursesCount: number
experience: string
education: string[]
certifications: string[]
socialLinks?: {
website?: string
linkedin?: string
twitter?: string
}
}
// 课程章节类型
export interface Chapter {
id: number
courseId: number
title: string
description?: string
order: number
duration: string
isPublished: boolean
lessons: Lesson[]
}
export interface Lesson {
id: number
chapterId: number
courseId: number
title: string
description?: string
content?: string
videoUrl?: string
duration: string
order: number
type: 'video' | 'text' | 'quiz' | 'assignment'
isCompleted?: boolean
isFree: boolean
isPublished: boolean
resources?: LessonResource[]
quiz?: Quiz
createdAt: string
updatedAt: string
}
export interface LessonResource {
id: number
lessonId: number
title: string
description?: string
type: 'pdf' | 'doc' | 'video' | 'audio' | 'image' | 'link'
url: string
size?: number
downloadable: boolean
}
// 后端API返回的章节数据结构
export interface BackendCourseSection {
id: number
lessonId: number
videoUrl: string // 视频链接
name: string // 章节名称
sortOrder: number // 排序
parentId: number // 父章节ID
level: number // 层级0=子级课时1=父级(章节)
revision: number // 版本号
createdBy: number
createdTime: string | null
updatedBy: number
updatedTime: string | null
}
// 前端使用的课程章节类型(适配后的数据结构)
export interface CourseSection {
id: number
lessonId: number
outline: string // 章节大纲/内容链接从videoUrl适配
name: string // 章节名称
parentId: number // 父章节ID
sort: number // 排序从sortOrder适配
level: number // 层级0=父级章节1=子级(课时)- 已从后端数据转换
revision: number // 版本号
createdAt: number | null // 从createdTime适配
updatedAt: number | null // 从updatedTime适配
deletedAt: string | null
completed?: boolean // 是否已完成(前端状态)
duration?: string // 课时时长(前端计算)
}
// 后端章节列表响应格式
export interface BackendCourseSectionListResponse {
list: BackendCourseSection[]
total: number
}
// 前端章节列表响应格式
export interface CourseSectionListResponse {
list: CourseSection[]
timestamp: number
traceId: string
}
// 测验类型
export interface Quiz {
id: number
lessonId: number
title: string
description?: string
timeLimit?: number
passingScore: number
questions: QuizQuestion[]
}
export interface QuizQuestion {
id: number
quizId: number
question: string
type: 'single' | 'multiple' | 'text' | 'essay'
options?: string[]
correctAnswer?: string | string[]
explanation?: string
points: number
order: number
}
// 学习进度类型
export interface LearningProgress {
id: number
userId: number
courseId: number
lessonId?: number
progress: number
timeSpent: number
lastAccessedAt: string
completedAt?: string
status: 'not_started' | 'in_progress' | 'completed'
}
// 评论类型
export interface Comment {
id: number
userId: number
courseId?: number
lessonId?: number
parentId?: number
content: string
rating?: number
likes: number
dislikes: number
isLiked?: boolean
isDisliked?: boolean
user: {
id: number
username: string
avatar?: string
}
replies?: Comment[]
createdAt: string
updatedAt: string
}
// 收藏类型
export interface Favorite {
id: number
userId: number
courseId: number
course: Course
createdAt: string
}
// 订单类型
export interface Order {
id: number
userId: number
orderNo: string
totalAmount: number
discountAmount: number
finalAmount: number
currency: string
status: 'pending' | 'paid' | 'cancelled' | 'refunded'
paymentMethod?: string
paymentTime?: string
items: OrderItem[]
createdAt: string
updatedAt: string
}
export interface OrderItem {
id: number
orderId: number
courseId: number
course: Course
price: number
discountPrice?: number
}
// 搜索类型
export interface SearchRequest {
keyword?: string
category?: string
level?: string
price?: 'free' | 'paid' | 'all'
rating?: number
duration?: string
language?: string
sortBy?: 'newest' | 'oldest' | 'rating' | 'price_low' | 'price_high' | 'popular'
page?: number
pageSize?: number
}
// 统计类型
export interface Statistics {
totalCourses: number
totalStudents: number
totalInstructors: number
totalHours: number
popularCategories: Array<{
category: string
count: number
}>
recentEnrollments: Array<{
date: string
count: number
}>
}

366
src/api/utils.ts Normal file
View File

@ -0,0 +1,366 @@
// API 工具函数文件
import type { ApiResponse, PaginationResponse } from './types'
/**
*
*/
export const buildQueryString = (params: Record<string, any>): string => {
const searchParams = new URLSearchParams()
Object.keys(params).forEach(key => {
const value = params[key]
if (value !== undefined && value !== null && value !== '') {
if (Array.isArray(value)) {
value.forEach(item => searchParams.append(key, String(item)))
} else {
searchParams.append(key, String(value))
}
}
})
return searchParams.toString()
}
/**
* URL
*/
export const buildUrl = (baseUrl: string, endpoint: string, params?: Record<string, any>): string => {
let url = `${baseUrl}${endpoint}`
if (params) {
const queryString = buildQueryString(params)
if (queryString) {
url += `?${queryString}`
}
}
return url
}
/**
* URL中的路径参数
*/
export const replaceUrlParams = (url: string, params: Record<string, string | number>): string => {
let result = url
Object.keys(params).forEach(key => {
result = result.replace(`:${key}`, String(params[key]))
})
return result
}
/**
* API响应是否成功
*/
export const isApiSuccess = (response: ApiResponse): boolean => {
return response.code === 200 || response.code === 0
}
/**
* API响应数据
*/
export const extractApiData = <T>(response: ApiResponse<T>): T => {
if (isApiSuccess(response)) {
return response.data
}
throw new Error(response.message || 'API请求失败')
}
/**
*
*/
export const formatPaginationData = <T>(response: ApiResponse<PaginationResponse<T>>) => {
if (isApiSuccess(response)) {
const { list, total, page, pageSize, totalPages } = response.data
return {
items: list,
total,
currentPage: page,
pageSize,
totalPages: totalPages || Math.ceil(total / pageSize),
hasNext: page < (totalPages || Math.ceil(total / pageSize)),
hasPrev: page > 1,
}
}
throw new Error(response.message || '获取分页数据失败')
}
/**
*
*/
export const createPaginationParams = (page: number = 1, pageSize: number = 20) => {
return {
page: Math.max(1, page),
pageSize: Math.min(Math.max(1, pageSize), 100), // 限制最大页面大小
}
}
/**
* -
*/
export const debounce = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let timeout: NodeJS.Timeout | null = null
return (...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
func(...args)
}, wait)
}
}
/**
* -
*/
export const throttle = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let lastTime = 0
return (...args: Parameters<T>) => {
const now = Date.now()
if (now - lastTime >= wait) {
lastTime = now
func(...args)
}
}
}
/**
* -
*/
export const retry = async <T>(
fn: () => Promise<T>,
maxAttempts: number = 3,
delay: number = 1000
): Promise<T> => {
let lastError: Error
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error as Error
if (attempt === maxAttempts) {
throw lastError
}
// 指数退避延迟
const waitTime = delay * Math.pow(2, attempt - 1)
await new Promise(resolve => setTimeout(resolve, waitTime))
}
}
throw lastError!
}
/**
*
*/
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
*
*/
export const formatDuration = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)}`
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.round(seconds % 60)
return remainingSeconds > 0 ? `${minutes}${remainingSeconds}` : `${minutes}分钟`
} else {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
}
}
/**
*
*/
export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
/**
*
*/
export const isValidPhone = (phone: string): boolean => {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
/**
*
*/
export const validatePassword = (password: string): {
isValid: boolean
strength: 'weak' | 'medium' | 'strong'
issues: string[]
} => {
const issues: string[] = []
let score = 0
if (password.length < 8) {
issues.push('密码长度至少8位')
} else {
score += 1
}
if (!/[a-z]/.test(password)) {
issues.push('密码需包含小写字母')
} else {
score += 1
}
if (!/[A-Z]/.test(password)) {
issues.push('密码需包含大写字母')
} else {
score += 1
}
if (!/\d/.test(password)) {
issues.push('密码需包含数字')
} else {
score += 1
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
issues.push('密码需包含特殊字符')
} else {
score += 1
}
let strength: 'weak' | 'medium' | 'strong'
if (score < 3) {
strength = 'weak'
} else if (score < 5) {
strength = 'medium'
} else {
strength = 'strong'
}
return {
isValid: issues.length === 0,
strength,
issues
}
}
/**
*
*/
export const generateRandomString = (length: number = 8): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
/**
*
*/
export const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== 'object') {
return obj
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as unknown as T
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as unknown as T
}
if (typeof obj === 'object') {
const cloned = {} as T
Object.keys(obj).forEach(key => {
(cloned as any)[key] = deepClone((obj as any)[key])
})
return cloned
}
return obj
}
/**
*
*/
export const getErrorMessage = (error: any): string => {
if (typeof error === 'string') {
return error
}
if (error?.response?.data?.message) {
return error.response.data.message
}
if (error?.message) {
return error.message
}
return '未知错误'
}
/**
*
*/
export const storage = {
get: <T>(key: string, defaultValue?: T): T | null => {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : defaultValue || null
} catch {
return defaultValue || null
}
},
set: (key: string, value: any): void => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (error) {
console.error('存储数据失败:', error)
}
},
remove: (key: string): void => {
try {
localStorage.removeItem(key)
} catch (error) {
console.error('删除存储数据失败:', error)
}
},
clear: (): void => {
try {
localStorage.clear()
} catch (error) {
console.error('清空存储数据失败:', error)
}
}
}

View File

@ -1,44 +0,0 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@ -1,94 +0,0 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@ -0,0 +1,706 @@
<template>
<div class="video-player-wrapper">
<div class="video-container" ref="videoContainer">
<!-- 视频播放器 -->
<video
ref="videoElement"
class="video-element"
:poster="poster"
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@play="onPlay"
@pause="onPause"
@ended="onEnded"
@error="onError"
preload="metadata"
playsinline
webkit-playsinline
crossorigin="anonymous"
>
您的浏览器不支持视频播放
</video>
<!-- 播放按钮覆盖层 -->
<div v-if="showPlayButton" class="play-overlay" @click="togglePlay">
<div class="play-button">
<svg width="80" height="80" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="36" fill="rgba(0,0,0,0.7)" stroke="rgba(255,255,255,0.8)" stroke-width="2"/>
<path d="M32 24L32 56L56 40L32 24Z" fill="white"/>
</svg>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner">
<svg width="40" height="40" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="16" stroke="rgba(255,255,255,0.3)" stroke-width="3" fill="none"/>
<circle cx="20" cy="20" r="16" stroke="white" stroke-width="3" fill="none"
stroke-dasharray="100" stroke-dashoffset="75" stroke-linecap="round">
<animateTransform attributeName="transform" type="rotate" dur="1s" repeatCount="indefinite" values="0 20 20;360 20 20"/>
</circle>
</svg>
</div>
<p>加载中...</p>
</div>
<!-- 错误状态 -->
<div v-if="error" class="error-overlay">
<div class="error-content">
<svg width="60" height="60" viewBox="0 0 60 60" fill="none">
<circle cx="30" cy="30" r="25" stroke="#ff4757" stroke-width="3"/>
<path d="M20 20L40 40M40 20L20 40" stroke="#ff4757" stroke-width="3" stroke-linecap="round"/>
</svg>
<p>视频加载失败</p>
<button class="retry-button" @click="retryLoad">重试</button>
</div>
</div>
<!-- 自定义控制栏 -->
<div v-if="showControls && !error" class="video-controls" :class="{ 'controls-visible': controlsVisible }">
<!-- 进度条 -->
<div class="progress-container" @click="seekTo" @mousemove="showProgressPreview" @mouseleave="hideProgressPreview">
<div class="progress-track">
<div class="progress-buffer" :style="{ width: bufferPercent + '%' }"></div>
<div class="progress-played" :style="{ width: progressPercent + '%' }"></div>
<div class="progress-thumb" :style="{ left: progressPercent + '%' }"></div>
</div>
<!-- 进度预览 -->
<div v-if="showPreview" class="progress-preview" :style="{ left: previewPosition + '%' }">
{{ formatTime(previewTime) }}
</div>
</div>
<!-- 控制按钮 -->
<div class="controls-row">
<div class="controls-left">
<button class="control-btn play-btn" @click="togglePlay">
<svg v-if="!isPlaying" width="24" height="24" viewBox="0 0 24 24">
<path d="M8 5V19L19 12L8 5Z" fill="currentColor"/>
</svg>
<svg v-else width="24" height="24" viewBox="0 0 24 24">
<path d="M6 4H10V20H6V4ZM14 4H18V20H14V4Z" fill="currentColor"/>
</svg>
</button>
<div class="time-display">
<span class="current-time">{{ formatTime(currentTime) }}</span>
<span class="separator">/</span>
<span class="total-time">{{ formatTime(duration) }}</span>
</div>
</div>
<div class="controls-right">
<!-- 音量控制 -->
<div class="volume-container">
<button class="control-btn volume-btn" @click="toggleMute">
<svg v-if="volume > 50 && !muted" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
<path d="M12 7C12.5 7.5 12.8 8.2 12.8 9S12.5 10.5 12 11" stroke="currentColor" stroke-width="1"/>
<path d="M14 5C15 6 15.5 7.5 15.5 9S15 12 14 13" stroke="currentColor" stroke-width="1"/>
</svg>
<svg v-else-if="volume > 0 && !muted" width="20" height="20" viewBox="0 0 20 20">
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
<path d="M12 7C12.5 7.5 12.8 8.2 12.8 9S12.5 10.5 12 11" stroke="currentColor" stroke-width="1"/>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 20 20">
<path d="M10 2L7 5H4V15H7L10 18V2Z" fill="currentColor"/>
<path d="M12 7L16 11M16 7L12 11" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
</div>
<!-- 全屏按钮 -->
<button class="control-btn fullscreen-btn" @click="toggleFullscreen">
<svg width="20" height="20" viewBox="0 0 20 20">
<path d="M3 3H7V5H5V7H3V3ZM13 3H17V7H15V5H13V3ZM17 13V17H13V15H15V13H17ZM7 17H3V13H5V15H7V17Z" fill="currentColor"/>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- 视频信息 -->
<div v-if="title" class="video-info">
<h3 class="video-title">{{ title }}</h3>
<p v-if="description" class="video-description">{{ description }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import Hls from 'hls.js'
// Props
interface Props {
videoUrl: string
title?: string
description?: string
poster?: string
autoplay?: boolean
showControls?: boolean
}
const props = withDefaults(defineProps<Props>(), {
autoplay: false,
showControls: true
})
// Emits
const emit = defineEmits<{
play: []
pause: []
ended: []
timeupdate: [time: number]
error: [error: Event]
}>()
// Refs
const videoElement = ref<HTMLVideoElement>()
const videoContainer = ref<HTMLDivElement>()
// HLS
let hls: Hls | null = null
//
const isPlaying = ref(false)
const loading = ref(false)
const error = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const volume = ref(100)
const muted = ref(false)
//
const controlsVisible = ref(true)
const showPlayButton = ref(true)
const showPreview = ref(false)
const previewPosition = ref(0)
const previewTime = ref(0)
//
const progressPercent = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
const bufferPercent = computed(() => {
// video.buffered
return Math.min(progressPercent.value + 10, 100)
})
// URL
watch(() => props.videoUrl, (newUrl) => {
console.log('VideoPlayer: 视频URL变化:', newUrl)
if (newUrl && videoElement.value) {
loadVideo()
}
})
//
const onLoadedMetadata = () => {
if (videoElement.value) {
duration.value = videoElement.value.duration
loading.value = false
error.value = false
}
}
const onTimeUpdate = () => {
if (videoElement.value) {
currentTime.value = videoElement.value.currentTime
emit('timeupdate', currentTime.value)
}
}
const onPlay = () => {
isPlaying.value = true
showPlayButton.value = false
emit('play')
}
const onPause = () => {
isPlaying.value = false
showPlayButton.value = true
emit('pause')
}
const onEnded = () => {
isPlaying.value = false
showPlayButton.value = true
emit('ended')
}
const onError = (event: Event) => {
error.value = true
loading.value = false
emit('error', event)
}
//
const togglePlay = async () => {
if (!videoElement.value) return
try {
if (isPlaying.value) {
videoElement.value.pause()
} else {
await videoElement.value.play()
}
} catch (err) {
console.error('播放控制失败:', err)
}
}
const seekTo = (event: MouseEvent) => {
if (!videoElement.value) return
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const percent = (event.clientX - rect.left) / rect.width
const newTime = percent * duration.value
videoElement.value.currentTime = newTime
}
const showProgressPreview = (event: MouseEvent) => {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const percent = (event.clientX - rect.left) / rect.width
const time = percent * duration.value
previewPosition.value = Math.max(0, Math.min(100, percent * 100))
previewTime.value = Math.max(0, Math.min(duration.value, time))
showPreview.value = true
}
const hideProgressPreview = () => {
showPreview.value = false
}
const toggleMute = () => {
if (!videoElement.value) return
videoElement.value.muted = !videoElement.value.muted
muted.value = videoElement.value.muted
}
const toggleFullscreen = () => {
if (!videoContainer.value) return
if (!document.fullscreenElement) {
videoContainer.value.requestFullscreen()
} else {
document.exitFullscreen()
}
}
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(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 loadVideo = () => {
if (!videoElement.value || !props.videoUrl) return
loading.value = true
error.value = false
// HLS
if (hls) {
hls.destroy()
hls = null
}
// HLS
if (props.videoUrl.includes('.m3u8')) {
// 使HLS.jsHLS
if (Hls.isSupported()) {
hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90
})
hls.loadSource(props.videoUrl)
hls.attachMedia(videoElement.value)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('HLS manifest parsed successfully')
loading.value = false
})
hls.on(Hls.Events.ERROR, (event, data) => {
console.error('HLS error:', data)
if (data.fatal) {
error.value = true
loading.value = false
emit('error', new Event('error'))
}
})
} else if (videoElement.value.canPlayType('application/vnd.apple.mpegurl')) {
// SafariHLS
videoElement.value.src = props.videoUrl
videoElement.value.load()
} else {
console.error('HLS not supported')
error.value = true
loading.value = false
}
} else {
//
videoElement.value.src = props.videoUrl
videoElement.value.load()
}
}
const retryLoad = () => {
loadVideo()
}
//
let hideControlsTimer: number | null = null
const showControls = () => {
controlsVisible.value = true
if (hideControlsTimer) {
clearTimeout(hideControlsTimer)
}
hideControlsTimer = window.setTimeout(() => {
if (isPlaying.value) {
controlsVisible.value = false
}
}, 3000)
}
const onMouseMove = () => {
showControls()
}
//
onMounted(() => {
nextTick(() => {
if (props.videoUrl) {
loadVideo()
}
//
if (videoContainer.value) {
videoContainer.value.addEventListener('mousemove', onMouseMove)
}
})
})
onUnmounted(() => {
if (hideControlsTimer) {
clearTimeout(hideControlsTimer)
}
if (videoContainer.value) {
videoContainer.value.removeEventListener('mousemove', onMouseMove)
}
// HLS
if (hls) {
hls.destroy()
hls = null
}
})
//
defineExpose({
play: () => videoElement.value?.play(),
pause: () => videoElement.value?.pause(),
seek: (time: number) => {
if (videoElement.value) {
videoElement.value.currentTime = time
}
},
setVolume: (vol: number) => {
if (videoElement.value) {
videoElement.value.volume = vol / 100
volume.value = vol
}
}
})
</script>
<style scoped>
.video-player-wrapper {
width: 100%;
background: #000;
border-radius: 8px;
overflow: hidden;
}
.video-container {
position: relative;
width: 100%;
aspect-ratio: 16/9;
background: #000;
overflow: hidden;
}
.video-element {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
/* 播放按钮覆盖层 */
.play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: opacity 0.3s;
}
.play-button {
transition: transform 0.3s;
}
.play-overlay:hover .play-button {
transform: scale(1.1);
}
/* 加载状态 */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
color: white;
}
.loading-spinner {
margin-bottom: 16px;
}
/* 错误状态 */
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
color: white;
}
.error-content {
text-align: center;
}
.retry-button {
margin-top: 16px;
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.retry-button:hover {
background: #40a9ff;
}
/* 视频控制栏 */
.video-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 50%, transparent 100%);
padding: 16px;
transform: translateY(100%);
transition: transform 0.3s ease;
}
.video-controls.controls-visible {
transform: translateY(0);
}
.video-container:hover .video-controls {
transform: translateY(0);
}
/* 进度条 */
.progress-container {
position: relative;
margin-bottom: 12px;
cursor: pointer;
}
.progress-track {
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
position: relative;
overflow: hidden;
}
.progress-buffer {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: rgba(255, 255, 255, 0.5);
transition: width 0.3s;
}
.progress-played {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: #1890ff;
transition: width 0.1s;
}
.progress-thumb {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
background: #1890ff;
border-radius: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.3s;
}
.progress-container:hover .progress-thumb {
opacity: 1;
}
.progress-preview {
position: absolute;
bottom: 100%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
margin-bottom: 8px;
pointer-events: none;
}
/* 控制按钮行 */
.controls-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.controls-left,
.controls-right {
display: flex;
align-items: center;
gap: 12px;
}
.control-btn {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: background-color 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.control-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.play-btn {
padding: 12px;
}
.time-display {
color: white;
font-size: 14px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
.separator {
margin: 0 4px;
opacity: 0.7;
}
.volume-container {
display: flex;
align-items: center;
}
/* 视频信息 */
.video-info {
padding: 16px;
background: white;
}
.video-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
.video-description {
font-size: 14px;
color: #666;
line-height: 1.5;
margin: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.video-controls {
padding: 12px;
}
.controls-row {
flex-direction: column;
gap: 8px;
}
.controls-left,
.controls-right {
width: 100%;
justify-content: center;
}
.time-display {
font-size: 12px;
}
}
</style>

View File

@ -1,87 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@ -0,0 +1,360 @@
<template>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:close-on-esc="false"
class="login-modal"
>
<div class="login-modal-container">
<!-- 关闭按钮 -->
<button class="close-btn" @click="closeModal">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<div class="login-content">
<h2 class="form-title">账号登录</h2>
<form @submit.prevent="handleLogin">
<div class="form-group">
<div class="input-wrapper">
<svg class="input-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 8C10.2091 8 12 6.20914 12 4C12 1.79086 10.2091 0 8 0C5.79086 0 4 1.79086 4 4C4 6.20914 5.79086 8 8 8Z" fill="#999"/>
<path d="M8 10C3.58172 10 0 13.5817 0 18H16C16 13.5817 12.4183 10 8 10Z" fill="#999"/>
</svg>
<input
v-model="loginForm.account"
type="text"
placeholder="请输入手机号或邮箱"
class="form-input"
required
/>
</div>
</div>
<div class="form-group">
<div class="input-wrapper">
<svg class="input-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 7V5C4 2.79086 5.79086 1 8 1C10.2091 1 12 2.79086 12 5V7H13C13.5523 7 14 7.44772 14 8V14C14 14.5523 13.5523 15 13 15H3C2.44772 15 2 14.5523 2 14V8C2 7.44772 2.44772 7 3 7H4Z" fill="#999"/>
</svg>
<input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
class="form-input"
required
/>
</div>
</div>
<div class="form-options">
<label class="checkbox-wrapper">
<input v-model="loginForm.remember" type="checkbox" />
<span class="checkbox-text">下次自动登录</span>
</label>
<a href="#" class="forgot-password">忘记密码</a>
</div>
<button type="submit" class="submit-btn" :disabled="isLoading">
{{ isLoading ? '登录中...' : '登录' }}
</button>
</form>
<div class="form-footer">
<p>登录即代表同意我们的 <a href="#" class="link">服务协议和隐私政策</a></p>
</div>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useMessage } from 'naive-ui'
import { useUserStore } from '@/stores/user'
import { AuthApi } from '@/api'
interface Props {
show: boolean
}
interface Emits {
(e: 'update:show', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const message = useMessage()
const userStore = useUserStore()
const showModal = computed({
get: () => props.show,
set: (value) => emit('update:show', value)
})
const isLoading = ref(false)
//
const loginForm = reactive({
account: '',
password: '',
remember: false
})
//
const closeModal = () => {
showModal.value = false
}
//
const handleLogin = async () => {
if (!loginForm.account || !loginForm.password) {
message.warning('请填写完整的登录信息')
return
}
if (loginForm.password.length < 3) {
message.warning('密码长度不能少于3位')
return
}
isLoading.value = true
try {
//
const isPhone = /^[0-9]+$/.test(loginForm.account)
// API
const response = await AuthApi.login({
...(isPhone ? { phone: loginForm.account } : { email: loginForm.account }),
password: loginForm.password
})
if (response.code === 200 || response.code === 0) {
const { user, token, refreshToken } = response.data
// tokenstore
userStore.user = user
userStore.token = token
//
localStorage.setItem('token', token)
localStorage.setItem('refreshToken', refreshToken)
localStorage.setItem('user', JSON.stringify(user))
//
if (loginForm.remember) {
localStorage.setItem('rememberMe', 'true')
}
message.success('登录成功!')
emit('success')
closeModal()
//
loginForm.account = ''
loginForm.password = ''
loginForm.remember = false
} else {
message.error(response.message || '登录失败')
}
} catch (error: any) {
console.error('登录失败:', error)
//
if (error.response?.status === 401) {
message.error('邮箱或密码错误')
} else if (error.response?.status === 429) {
message.error('登录尝试过于频繁,请稍后再试')
} else if (error.response?.data?.message) {
//
message.error(error.response.data.message)
} else if (error.message) {
//
message.error(error.message)
} else {
message.error('网络错误,请检查网络连接')
}
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
.login-modal-container {
position: relative;
background: white;
border-radius: 12px;
width: 400px;
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.close-btn {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: #999;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
z-index: 10;
}
.close-btn:hover {
color: #666;
background: #f5f5f5;
}
.login-content {
padding: 40px;
}
.form-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 30px 0;
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 12px;
z-index: 2;
color: #999;
}
.form-input {
width: 100%;
height: 48px;
padding: 0 16px 0 40px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
color: #333;
background: #fafafa;
transition: all 0.2s;
}
.form-input:focus {
outline: none;
border-color: #1890ff;
background: white;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
.form-input::placeholder {
color: #999;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.checkbox-wrapper {
display: flex;
align-items: center;
cursor: pointer;
font-size: 14px;
color: #666;
}
.checkbox-wrapper input[type="checkbox"] {
margin-right: 8px;
width: 16px;
height: 16px;
}
.forgot-password {
color: #1890ff;
text-decoration: none;
font-size: 14px;
}
.forgot-password:hover {
text-decoration: underline;
}
.submit-btn {
width: 100%;
height: 48px;
background: #1890ff;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 20px;
}
.submit-btn:hover:not(:disabled) {
background: #40a9ff;
}
.submit-btn:disabled {
background: #d9d9d9;
cursor: not-allowed;
}
.form-footer {
text-align: center;
}
.form-footer p {
font-size: 12px;
color: #999;
line-height: 1.4;
margin: 0;
}
.link {
color: #1890ff;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-modal-container {
width: 95vw;
margin: 20px;
}
.login-content {
padding: 30px 20px;
}
.form-title {
font-size: 20px;
margin-bottom: 20px;
}
}
</style>

View File

@ -0,0 +1,431 @@
<template>
<n-modal
v-model:show="showModal"
:mask-closable="false"
:close-on-esc="false"
class="register-modal"
>
<div class="register-modal-container">
<!-- 关闭按钮 -->
<button class="close-btn" @click="closeModal">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<div class="register-content">
<h2 class="form-title">账号注册</h2>
<form @submit.prevent="handleRegister">
<div class="form-group">
<div class="input-wrapper">
<svg class="input-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 3C2 2.44772 2.44772 2 3 2H13C13.5523 2 14 2.44772 14 3V13C14 13.5523 13.5523 14 13 14H3C2.44772 14 2 13.5523 2 13V3Z" fill="#999"/>
<path d="M3 4L8 8L13 4" stroke="white" stroke-width="1"/>
</svg>
<input
v-model="registerForm.email"
type="email"
placeholder="请输入手机号/邮箱"
class="form-input"
required
/>
</div>
</div>
<div class="form-group">
<div class="input-wrapper verification-wrapper">
<svg class="input-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 1L10.5 6H15L11 9.5L12.5 15L8 11.5L3.5 15L5 9.5L1 6H5.5L8 1Z" fill="#999"/>
</svg>
<input
v-model="registerForm.verificationCode"
type="text"
placeholder="请输入验证码"
class="form-input verification-input"
required
/>
<button
type="button"
class="verification-btn"
:disabled="verificationCountdown > 0"
@click="sendVerificationCode"
>
{{ verificationCountdown > 0 ? `${verificationCountdown}s` : '获取验证码' }}
</button>
</div>
</div>
<div class="form-group">
<div class="input-wrapper">
<svg class="input-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 7V5C4 2.79086 5.79086 1 8 1C10.2091 1 12 2.79086 12 5V7H13C13.5523 7 14 7.44772 14 8V14C14 14.5523 13.5523 15 13 15H3C2.44772 15 2 14.5523 2 14V8C2 7.44772 2.44772 7 3 7H4Z" fill="#999"/>
</svg>
<input
v-model="registerForm.password"
type="password"
placeholder="请输入密码"
class="form-input"
required
/>
</div>
</div>
<div class="password-hint">
密码长度不少于3位字符
</div>
<button type="submit" class="submit-btn" :disabled="isLoading">
{{ isLoading ? '注册中...' : '注册' }}
</button>
</form>
<div class="form-footer">
<p>注册即代表同意我们的 <a href="#" class="link">服务协议和隐私政策</a></p>
</div>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useMessage } from 'naive-ui'
import { AuthApi } from '@/api'
interface Props {
show: boolean
}
interface Emits {
(e: 'update:show', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const message = useMessage()
const showModal = computed({
get: () => props.show,
set: (value) => emit('update:show', value)
})
const isLoading = ref(false)
const verificationCountdown = ref(0)
//
const registerForm = reactive({
email: '',
verificationCode: '',
password: ''
})
//
const closeModal = () => {
showModal.value = false
}
//
const handleRegister = async () => {
if (!registerForm.email || !registerForm.verificationCode || !registerForm.password) {
message.warning('请填写完整的注册信息')
return
}
//
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const phoneRegex = /^1[3-9]\d{9}$/
if (!emailRegex.test(registerForm.email) && !phoneRegex.test(registerForm.email)) {
message.warning('请输入正确的邮箱或手机号格式')
return
}
if (registerForm.password.length < 3) {
message.warning('密码长度不能少于3位')
return
}
if (registerForm.verificationCode.length !== 6) {
message.warning('请输入6位验证码')
return
}
isLoading.value = true
try {
// API
const response = await AuthApi.register({
username: registerForm.email.split('@')[0] || registerForm.email.substring(0, 8),
email: registerForm.email,
password: registerForm.password,
confirmPassword: registerForm.password,
captcha: registerForm.verificationCode
})
if (response.code === 200) {
message.success('注册成功!请使用您的账号登录')
emit('success')
closeModal()
//
registerForm.email = ''
registerForm.verificationCode = ''
registerForm.password = ''
} else {
message.error(response.message || '注册失败')
}
} catch (error: any) {
console.error('注册失败:', error)
//
if (error.response?.status === 400) {
message.error('请求参数错误,请检查输入信息')
} else if (error.response?.status === 409) {
message.error('邮箱已被注册,请使用其他邮箱')
} else if (error.response?.status === 422) {
message.error('验证码错误或已过期')
} else if (error.response?.data?.message) {
message.error(error.response.data.message)
} else {
message.error('网络错误,请检查网络连接')
}
} finally {
isLoading.value = false
}
}
//
const sendVerificationCode = async () => {
if (!registerForm.email) {
message.warning('请先输入邮箱地址')
return
}
//
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(registerForm.email)) {
message.warning('请输入正确的邮箱格式')
return
}
if (verificationCountdown.value > 0) {
return
}
try {
// API
const response = await AuthApi.sendEmailVerification(registerForm.email)
if (response.code === 200) {
message.success('验证码已发送到您的邮箱')
//
verificationCountdown.value = 60
const timer = setInterval(() => {
verificationCountdown.value--
if (verificationCountdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
} else {
message.error(response.message || '发送验证码失败')
}
} catch (error: any) {
console.error('发送验证码失败:', error)
if (error.response?.status === 429) {
message.error('发送过于频繁,请稍后再试')
} else if (error.response?.data?.message) {
message.error(error.response.data.message)
} else {
message.error('发送验证码失败,请稍后重试')
}
}
}
</script>
<style scoped>
.register-modal-container {
position: relative;
background: white;
border-radius: 12px;
width: 400px;
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.close-btn {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: #999;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
z-index: 10;
}
.close-btn:hover {
color: #666;
background: #f5f5f5;
}
.register-content {
padding: 40px;
}
.form-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 30px 0;
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-icon {
position: absolute;
left: 12px;
z-index: 2;
color: #999;
}
.form-input {
width: 100%;
height: 48px;
padding: 0 16px 0 40px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
color: #333;
background: #fafafa;
transition: all 0.2s;
}
.form-input:focus {
outline: none;
border-color: #1890ff;
background: white;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
.form-input::placeholder {
color: #999;
}
.verification-wrapper {
position: relative;
}
.verification-input {
padding-right: 120px;
}
.verification-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: #1890ff;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.verification-btn:hover:not(:disabled) {
background: #40a9ff;
}
.verification-btn:disabled {
background: #d9d9d9;
cursor: not-allowed;
}
.password-hint {
font-size: 12px;
color: #999;
margin-bottom: 20px;
line-height: 1.4;
}
.submit-btn {
width: 100%;
height: 48px;
background: #1890ff;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 20px;
}
.submit-btn:hover:not(:disabled) {
background: #40a9ff;
}
.submit-btn:disabled {
background: #d9d9d9;
cursor: not-allowed;
}
.form-footer {
text-align: center;
}
.form-footer p {
font-size: 12px;
color: #999;
line-height: 1.4;
margin: 0;
}
.link {
color: #1890ff;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
/* 响应式设计 */
@media (max-width: 768px) {
.register-modal-container {
width: 95vw;
margin: 20px;
}
.register-content {
padding: 30px 20px;
}
.form-title {
font-size: 20px;
margin-bottom: 20px;
}
}
</style>

View File

@ -0,0 +1,78 @@
<template>
<div
class="placeholder-image"
:style="{
width: width + 'px',
height: height + 'px',
borderRadius: rounded ? '50%' : '8px'
}"
>
<div class="placeholder-content">
<div class="placeholder-icon">{{ icon }}</div>
<div v-if="showText" class="placeholder-text">{{ text }}</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
width?: number
height?: number
text?: string
icon?: string
showText?: boolean
rounded?: boolean
}
withDefaults(defineProps<Props>(), {
width: 100,
height: 100,
text: '图片',
icon: '🖼️',
showText: true,
rounded: false
})
</script>
<style scoped>
.placeholder-image {
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e0e0e0;
overflow: hidden;
}
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #999;
}
.placeholder-icon {
font-size: 24px;
margin-bottom: 4px;
}
.placeholder-text {
font-size: 12px;
color: #666;
white-space: nowrap;
}
/* 小尺寸时隐藏文字 */
.placeholder-image[style*="width: 32px"] .placeholder-text,
.placeholder-image[style*="width: 40px"] .placeholder-text {
display: none;
}
.placeholder-image[style*="width: 32px"] .placeholder-icon,
.placeholder-image[style*="width: 40px"] .placeholder-icon {
font-size: 16px;
margin-bottom: 0;
}
</style>

View File

@ -0,0 +1,115 @@
<template>
<div
class="safe-avatar"
:style="{
width: size + 'px',
height: size + 'px'
}"
>
<img
v-if="!imageError && src"
:src="src"
:alt="alt"
@error="handleImageError"
@load="handleImageLoad"
/>
<div v-else class="avatar-placeholder">
<span class="avatar-text">{{ avatarText }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Props {
src?: string
alt?: string
size?: number
name?: string
}
const props = withDefaults(defineProps<Props>(), {
src: '',
alt: '头像',
size: 32,
name: '用户'
})
const imageError = ref(false)
//
const avatarText = computed(() => {
if (props.name) {
//
if (/[\u4e00-\u9fa5]/.test(props.name)) {
return props.name.slice(-1)
}
//
return props.name.charAt(0).toUpperCase()
}
return '用'
})
const handleImageError = () => {
imageError.value = true
}
const handleImageLoad = () => {
imageError.value = false
}
</script>
<style scoped>
.safe-avatar {
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f0f0f0;
}
.safe-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-text {
color: white;
font-weight: 500;
font-size: 14px;
}
/* 根据尺寸调整字体大小 */
.safe-avatar[style*="width: 24px"] .avatar-text {
font-size: 10px;
}
.safe-avatar[style*="width: 32px"] .avatar-text {
font-size: 12px;
}
.safe-avatar[style*="width: 40px"] .avatar-text {
font-size: 14px;
}
.safe-avatar[style*="width: 64px"] .avatar-text,
.safe-avatar[style*="width: 80px"] .avatar-text {
font-size: 18px;
}
.safe-avatar[style*="width: 100px"] .avatar-text {
font-size: 24px;
}
</style>

View File

@ -21,12 +21,12 @@
<!-- 讲师信息 -->
<div class="instructor">
<n-avatar
:src="course.instructorAvatar"
:fallback-src="'https://via.placeholder.com/32'"
size="small"
<SafeAvatar
:src="course.instructor?.avatar"
:name="course.instructor?.name"
:size="32"
/>
<span class="instructor-name">{{ course.instructor }}</span>
<span class="instructor-name">{{ course.instructor?.name }}</span>
</div>
<!-- 课程统计 -->
@ -96,6 +96,7 @@ import { computed } from 'vue'
import { useMessage } from 'naive-ui'
import { useCourseStore } from '@/stores/course'
import type { Course } from '@/stores/course'
import SafeAvatar from '@/components/common/SafeAvatar.vue'
import {
StarOutline,
PeopleOutline,

View File

@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@ -1,19 +0,0 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

View File

@ -17,15 +17,19 @@
<img src="/nav-icons/火.png" alt="" class="nav-icon" />
{{ t('header.courses') }}
</div>
<div class="nav-item" :class="{ active: activeKey === 'training' }" @click="handleMenuSelect('training')">
{{ t('header.training') }}
</div>
<div class="nav-item" :class="{ active: activeKey === 'practice' }" @click="handleMenuSelect('practice')">
{{ t('header.resources') }}
</div>
<div class="nav-item" :class="{ active: activeKey === 'resources' }" @click="handleMenuSelect('resources')">
<div class="nav-item" :class="{ active: activeKey === 'faculty' }" @click="handleMenuSelect('faculty')">
{{ t('header.learningPaths') }}
</div>
<div class="nav-item" :class="{ active: activeKey === 'resources' }" @click="handleMenuSelect('resources')">
{{ t('header.resources') }}
</div>
<div class="nav-item" :class="{ active: activeKey === 'activities' }" @click="handleMenuSelect('activities')">
{{ t('header.about') }}
<img src="/nav-icons/new.png" alt="new" class="new-badge" />
@ -79,26 +83,38 @@
<!-- 登录/注册按钮 -->
<div v-if="!userStore.isLoggedIn" class="auth-buttons">
<div class="auth-combined-btn">
<span class="auth-login" @click="$router.push('/login')">{{ t('header.login') }}</span>
<span class="auth-login" @click="showLoginModal">{{ t('header.login') }}</span>
<span class="auth-divider">|</span>
<span class="auth-register" @click="$router.push('/register')">{{ t('header.register') }}</span>
<span class="auth-register" @click="showRegisterModal">{{ t('header.register') }}</span>
</div>
</div>
<!-- 用户菜单 -->
<!-- 登录后的用户区域 -->
<div v-else class="user-menu">
<n-dropdown :options="userMenuOptions" @select="handleUserMenuSelect">
<div class="user-info">
<n-avatar
<SafeAvatar
:src="userStore.user?.avatar"
:fallback-src="'https://via.placeholder.com/32'"
size="small"
:name="userStore.user?.username"
:size="32"
/>
<span class="username">{{ userStore.user?.username }}</span>
</div>
</n-dropdown>
</div>
</div>
<!-- 登录模态框 -->
<LoginModal
v-model:show="loginModalVisible"
@success="handleAuthSuccess"
/>
<!-- 注册模态框 -->
<RegisterModal
v-model:show="registerModalVisible"
@success="handleAuthSuccess"
/>
</div>
</template>
@ -111,10 +127,12 @@ import { useUserStore } from '@/stores/user'
import {
PersonOutline,
LogOutOutline,
SettingsOutline,
MenuOutline,
CloseOutline
} from '@vicons/ionicons5'
import LoginModal from '@/components/auth/LoginModal.vue'
import RegisterModal from '@/components/auth/RegisterModal.vue'
import SafeAvatar from '@/components/common/SafeAvatar.vue'
const router = useRouter()
const { t, locale } = useI18n()
@ -127,6 +145,12 @@ const mobileMenuOpen = ref(false)
//
const activeKey = ref('home')
//
const loginModalVisible = ref(false)
const registerModalVisible = ref(false)
//
@ -154,20 +178,15 @@ const switchLanguage = (lang: string) => {
//
const userMenuOptions = computed(() => [
{
label: t('header.profile'),
label: '个人中心',
key: 'profile',
icon: () => h(PersonOutline)
},
{
label: t('header.settings'),
key: 'settings',
icon: () => h(SettingsOutline)
},
{
type: 'divider'
},
{
label: t('header.logout'),
label: '退出登录',
key: 'logout',
icon: () => h(LogOutOutline)
}
@ -190,17 +209,14 @@ const handleMenuSelect = (key: string) => {
//
router.push('/')
break
case 'practice':
//
router.push('/')
case 'faculty':
router.push('/faculty')
break
case 'resources':
//
router.push('/')
router.push('/resources')
break
case 'activities':
//
router.push('/')
router.push('/activities')
break
}
}
@ -211,9 +227,6 @@ const handleUserMenuSelect = (key: string) => {
case 'profile':
router.push('/profile')
break
case 'settings':
// TODO:
break
case 'logout':
userStore.logout()
router.push('/')
@ -221,6 +234,24 @@ const handleUserMenuSelect = (key: string) => {
}
}
//
const showLoginModal = () => {
loginModalVisible.value = true
}
//
const showRegisterModal = () => {
registerModalVisible.value = true
}
//
const handleAuthSuccess = () => {
//
console.log('认证成功')
}
//
@ -693,5 +724,7 @@ onUnmounted(() => {
}
}
/* 全屏模式样式现在在App.vue中统一管理 */
</style>

View File

@ -1,20 +1,22 @@
<template>
<n-layout class="app-layout">
<!-- 顶部导航 -->
<n-layout-header class="header" bordered>
<AppHeader />
</n-layout-header>
<n-message-provider>
<n-layout class="app-layout">
<!-- 顶部导航 -->
<n-layout-header class="header" bordered>
<AppHeader />
</n-layout-header>
<!-- 主要内容区域 -->
<n-layout-content class="content">
<slot />
</n-layout-content>
<!-- 主要内容区域 -->
<n-layout-content class="content">
<slot />
</n-layout-content>
<!-- 底部 -->
<n-layout-footer class="footer" bordered>
<AppFooter />
</n-layout-footer>
</n-layout>
<!-- 底部 -->
<n-layout-footer class="footer" bordered>
<AppFooter />
</n-layout-footer>
</n-layout>
</n-message-provider>
</template>
<script setup lang="ts">

View File

@ -0,0 +1,74 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
export function useAuth() {
const router = useRouter()
const userStore = useUserStore()
const loginModalVisible = ref(false)
const registerModalVisible = ref(false)
// 检查用户是否已登录,如果未登录则显示登录模态框
const requireAuth = (callback?: () => void) => {
if (userStore.isLoggedIn) {
// 已登录,执行回调
callback?.()
return true
} else {
// 未登录,显示登录模态框
loginModalVisible.value = true
return false
}
}
// 显示登录模态框
const showLoginModal = () => {
loginModalVisible.value = true
}
// 显示注册模态框
const showRegisterModal = () => {
registerModalVisible.value = true
}
// 认证成功处理
const handleAuthSuccess = (callback?: () => void) => {
loginModalVisible.value = false
registerModalVisible.value = false
callback?.()
}
// 跳转到课程详情页(需要登录检查)
const goToCourseDetail = (courseId: string | number) => {
requireAuth(() => {
router.push(`/course/${courseId}`)
})
}
// 报名课程(需要登录检查)
const enrollCourse = (courseId: string | number, successCallback?: () => void) => {
requireAuth(() => {
// 这里可以添加报名逻辑
console.log('报名课程:', courseId)
// 模拟报名成功,跳转到课程学习页面
if (successCallback) {
successCallback()
} else {
router.push(`/course/study/${courseId}`)
}
})
}
return {
loginModalVisible,
registerModalVisible,
requireAuth,
showLoginModal,
showRegisterModal,
handleAuthSuccess,
goToCourseDetail,
enrollCourse
}
}

View File

@ -1,11 +1,11 @@
{
"header": {
"home": "Home",
"courses": "Courses",
"training": "Training",
"learningPaths": "Learning Paths",
"resources": "Resources",
"about": "About Us",
"courses": "Popular Courses",
"training": "Special Training",
"learningPaths": "Faculty",
"resources": "Featured Resources",
"about": "Activities",
"languageSwitch": "Language",
"learningCenter": "Learning Center",
"management": "Management",

View File

@ -1,11 +1,11 @@
{
"header": {
"home": "首页",
"courses": "",
"courses": "热门好课",
"training": "专题训练",
"learningPaths": "学习路径",
"resources": "资源中心",
"about": "关于我们",
"learningPaths": "师资力量",
"resources": "精选资源",
"about": "活动",
"languageSwitch": "切换语言",
"learningCenter": "学习中心",
"management": "管理端",

View File

@ -5,6 +5,7 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import { useUserStore } from '@/stores/user'
// Naive UI
import {
@ -25,6 +26,7 @@ import {
NBreadcrumb,
NBreadcrumbItem,
NInput,
NInputGroup,
NForm,
NFormItem,
NSelect,
@ -72,7 +74,8 @@ import {
NSteps,
NStep,
NTimeline,
NTimelineItem
NTimelineItem,
NMessageProvider
} from 'naive-ui'
const naive = create({
@ -93,6 +96,7 @@ const naive = create({
NBreadcrumb,
NBreadcrumbItem,
NInput,
NInputGroup,
NForm,
NFormItem,
NSelect,
@ -140,7 +144,8 @@ const naive = create({
NSteps,
NStep,
NTimeline,
NTimelineItem
NTimelineItem,
NMessageProvider
]
})
@ -152,4 +157,18 @@ app.use(router)
app.use(i18n)
app.use(naive)
app.mount('#app')
// 初始化用户认证状态
const userStore = useUserStore()
// 异步初始化认证状态
const initializeApp = async () => {
try {
await userStore.initializeAuth()
} catch (error) {
console.error('初始化认证状态失败:', error)
} finally {
app.mount('#app')
}
}
initializeApp()

View File

@ -5,10 +5,16 @@ import type { RouteRecordRaw } from 'vue-router'
import Home from '@/views/Home.vue'
import Courses from '@/views/Courses.vue'
import CourseDetail from '@/views/CourseDetail.vue'
import CourseStudy from '@/views/CourseStudy.vue'
import Learning from '@/views/Learning.vue'
import Profile from '@/views/Profile.vue'
import Login from '@/views/Login.vue'
import Register from '@/views/Register.vue'
import LearningPaths from '@/views/LearningPaths.vue'
import Faculty from '@/views/Faculty.vue'
import Resources from '@/views/Resources.vue'
import Activities from '@/views/Activities.vue'
import TestSections from '@/views/TestSections.vue'
import VideoTest from '@/views/VideoTest.vue'
const routes: RouteRecordRaw[] = [
{
@ -35,6 +41,15 @@ const routes: RouteRecordRaw[] = [
title: '课程详情'
}
},
{
path: '/course/study/:id',
name: 'CourseStudy',
component: CourseStudy,
meta: {
title: '课程学习',
requiresAuth: true
}
},
{
path: '/learning/:id',
name: 'Learning',
@ -53,20 +68,53 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true
}
},
{
path: '/login',
name: 'Login',
component: Login,
path: '/learning-paths',
name: 'LearningPaths',
component: LearningPaths,
meta: {
title: '登录'
title: '学习路径'
}
},
{
path: '/register',
name: 'Register',
component: Register,
path: '/faculty',
name: 'Faculty',
component: Faculty,
meta: {
title: '注册'
title: '师资力量'
}
},
{
path: '/resources',
name: 'Resources',
component: Resources,
meta: {
title: '精选资源'
}
},
{
path: '/activities',
name: 'Activities',
component: Activities,
meta: {
title: '全部活动'
}
},
{
path: '/test-sections',
name: 'TestSections',
component: TestSections,
meta: {
title: '测试章节API'
}
},
{
path: '/video-test',
name: 'VideoTest',
component: VideoTest,
meta: {
title: '视频播放器测试'
}
},
{
@ -100,12 +148,16 @@ router.beforeEach((to, _from, next) => {
// 检查是否需要登录
if (to.meta.requiresAuth) {
// 这里可以检查用户登录状态
// 暂时跳过认证检查
next()
} else {
next()
// 检查用户登录状态
const token = localStorage.getItem('token')
if (!token) {
// 未登录时跳转到首页,用户可以通过模态框登录
next('/')
return
}
}
next()
})
export default router

View File

@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { CourseApi } from '@/api/modules/course'
export interface Course {
id: number
@ -77,10 +78,13 @@ export const useCourseStore = defineStore('course', () => {
const fetchCourses = async () => {
isLoading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟课程数据
console.log('尝试从API获取课程数据...')
const response = await CourseApi.getCourses()
console.log('API响应:', response)
courses.value = response.data.list
} catch (error) {
console.error('API调用失败使用模拟数据:', error)
// 如果API调用失败使用模拟数据作为后备
const mockCourses: Course[] = [
{
id: 1,
@ -135,10 +139,7 @@ export const useCourseStore = defineStore('course', () => {
updatedAt: '2024-01-25'
}
]
courses.value = mockCourses
} catch (error) {
console.error('Failed to fetch courses:', error)
} finally {
isLoading.value = false
}
@ -147,15 +148,15 @@ export const useCourseStore = defineStore('course', () => {
const fetchCourseById = async (id: number) => {
isLoading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
const response = await CourseApi.getCourseById(id)
currentCourse.value = response.data
} catch (error) {
console.error('Failed to fetch course:', error)
// 如果API调用失败从本地数据中查找
const course = courses.value.find(c => c.id === id)
if (course) {
currentCourse.value = course
}
} catch (error) {
console.error('Failed to fetch course:', error)
} finally {
isLoading.value = false
}

View File

@ -1,14 +1,9 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { AuthApi, type User as ApiUser } from '@/api'
export interface User {
id: number
username: string
email: string
avatar?: string
role: 'student' | 'teacher' | 'admin'
createdAt: string
}
// 扩展API用户类型以保持兼容性
export interface User extends ApiUser {}
export const useUserStore = defineStore('user', () => {
// 状态
@ -22,114 +17,131 @@ export const useUserStore = defineStore('user', () => {
const isTeacher = computed(() => user.value?.role === 'teacher')
const isAdmin = computed(() => user.value?.role === 'admin')
// 方法
// 方法 - 简化版本,主要用于状态管理
const login = async (credentials: { email: string; password: string }) => {
// 这个方法现在主要用于兼容性,实际登录逻辑在组件中处理
return { success: true, message: '请使用登录模态框进行登录' }
}
const register = async (userData: any) => {
// 这个方法现在主要用于兼容性,实际注册逻辑在组件中处理
return { success: true, message: '请使用注册模态框进行注册' }
}
const logout = async () => {
try {
// 调用登出API
await AuthApi.logout()
} catch (error) {
console.error('登出API调用失败:', error)
} finally {
// 无论API调用是否成功都清除本地数据
user.value = null
token.value = null
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
localStorage.removeItem('user')
localStorage.removeItem('rememberMe')
}
}
// 获取当前用户信息
const getCurrentUser = async () => {
if (!token.value) {
return { success: false, message: '未登录' }
}
// 如果已经有用户信息,直接返回成功
if (user.value) {
return { success: true, message: '用户信息已存在' }
}
// 尝试从localStorage恢复用户信息
const savedUser = localStorage.getItem('user')
if (savedUser) {
try {
user.value = JSON.parse(savedUser)
return { success: true, message: '用户信息已恢复' }
} catch (error) {
console.error('解析用户信息失败:', error)
}
}
// 暂时注释掉API调用因为后端可能没有这个接口
// isLoading.value = true
// try {
// const response = await AuthApi.getCurrentUser()
// if (response.code === 200 || response.code === 0) {
// user.value = response.data
// localStorage.setItem('user', JSON.stringify(response.data))
// return { success: true, message: '获取用户信息成功' }
// } else {
// return { success: false, message: response.message || '获取用户信息失败' }
// }
// } catch (error: any) {
// console.error('获取用户信息失败:', error)
// // 如果是401错误说明token已过期自动登出
// if (error.response?.status === 401) {
// await logout()
// return { success: false, message: '登录已过期,请重新登录' }
// }
// return { success: false, message: '获取用户信息失败' }
// } finally {
// isLoading.value = false
// }
return { success: false, message: '无法获取用户信息' }
}
const updateProfile = async (profileData: any) => {
isLoading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟登录成功
const mockUser: User = {
id: 1,
username: '张三',
email: credentials.email,
avatar: 'https://via.placeholder.com/100',
role: 'student',
createdAt: new Date().toISOString()
const response = await AuthApi.updateProfile(profileData)
if (response.code === 200) {
user.value = response.data
localStorage.setItem('user', JSON.stringify(response.data))
return { success: true, message: '更新成功' }
} else {
return { success: false, message: response.message || '更新失败' }
}
const mockToken = 'mock-jwt-token-' + Date.now()
user.value = mockUser
token.value = mockToken
localStorage.setItem('token', mockToken)
localStorage.setItem('user', JSON.stringify(mockUser))
return { success: true, message: '登录成功' }
} catch (error) {
return { success: false, message: '登录失败' }
} catch (error: any) {
console.error('更新用户资料失败:', error)
let message = '更新失败'
if (error.response?.data?.message) {
message = error.response.data.message
}
return { success: false, message }
} finally {
isLoading.value = false
}
}
const register = async (userData: {
username: string
email: string
password: string
confirmPassword: string
}) => {
isLoading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟注册成功
const mockUser: User = {
id: Date.now(),
username: userData.username,
email: userData.email,
role: 'student',
createdAt: new Date().toISOString()
}
const mockToken = 'mock-jwt-token-' + Date.now()
user.value = mockUser
token.value = mockToken
localStorage.setItem('token', mockToken)
localStorage.setItem('user', JSON.stringify(mockUser))
return { success: true, message: '注册成功' }
} catch (error) {
return { success: false, message: '注册失败' }
} finally {
isLoading.value = false
}
}
const logout = () => {
user.value = null
token.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
const updateProfile = async (profileData: Partial<User>) => {
isLoading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
if (user.value) {
user.value = { ...user.value, ...profileData }
localStorage.setItem('user', JSON.stringify(user.value))
}
return { success: true, message: '更新成功' }
} catch (error) {
return { success: false, message: '更新失败' }
} finally {
isLoading.value = false
}
}
const initializeAuth = () => {
const initializeAuth = async () => {
const savedUser = localStorage.getItem('user')
const savedToken = localStorage.getItem('token')
if (savedUser && savedToken) {
try {
user.value = JSON.parse(savedUser)
token.value = savedToken
// 验证token是否仍然有效
await getCurrentUser()
} catch (error) {
console.error('Failed to parse saved user data:', error)
logout()
console.error('Failed to parse saved user data or token expired:', error)
await logout()
}
}
}
return {
// 状态
user,
@ -144,6 +156,7 @@ export const useUserStore = defineStore('user', () => {
login,
register,
logout,
getCurrentUser,
updateProfile,
initializeAuth
}

630
src/views/Activities.vue Normal file
View File

@ -0,0 +1,630 @@
<template>
<div class="activities-page">
<!-- 横幅图片区域 -->
<div class="hero-banner">
<div class="banner-image-container">
<!-- 实际图片 -->
<img
v-if="hasBannerImage"
:src="bannerImageSrc"
alt="活动横幅"
class="banner-image"
/>
<!-- 图片占位区域 -->
<div v-else class="banner-placeholder">
<div class="placeholder-content">
<div class="placeholder-icon">🖼</div>
<div class="placeholder-text">横幅图片占位</div>
<div class="placeholder-desc">请提供横幅图片</div>
</div>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<div class="container">
<!-- 全部活动区域 -->
<section class="all-activities">
<h2 class="section-title">全部活动</h2>
<!-- 活动网格 -->
<div v-if="loading" class="loading-grid">
<div v-for="i in 6" :key="i" class="loading-card">
<div class="loading-shimmer"></div>
</div>
</div>
<div v-else class="activities-grid">
<div
v-for="activity in activities"
:key="activity.id"
class="activity-card"
>
<div class="card-header">
<div class="card-background">
<div class="year-badge">2025</div>
<div class="course-title">{{ activity.title }}</div>
<div class="course-subtitle">{{ activity.subtitle }}</div>
</div>
</div>
<div class="card-body">
<!-- 特色标签 -->
<div class="feature-tags">
<span
v-for="tag in activity.tags"
:key="tag"
class="feature-tag"
>
<i class="tag-icon"></i>
{{ tag }}
</span>
</div>
<!-- 课程信息 -->
<div class="course-info">
<div class="info-row">
<span class="info-label">{{ activity.courseTitle }}</span>
</div>
<div class="info-row">
<span class="info-text">{{ activity.schedule }}</span>
</div>
<div class="info-row">
<span class="info-text">{{ activity.duration }}</span>
</div>
<div class="info-row">
<span class="info-text">{{ activity.students }}</span>
</div>
<div class="info-row">
<span class="price">{{ activity.price }}</span>
</div>
</div>
<!-- 查看详情按钮 -->
<div class="card-footer">
<button class="detail-btn" @click="viewDetail(activity.id)">
查看详情
</button>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
//
const loading = ref(true)
// -
const bannerImageSrc = ref('')
//
const hasBannerImage = computed(() => bannerImageSrc.value.trim() !== '')
//
const activities = ref([
{
id: 1,
title: '计算机二级',
subtitle: 'C语言讲练综合班',
tags: ['系统备考', '考点详解', '题考刷题'],
courseTitle: '计算机二级C语言程序设计证书',
schedule: '开课时间2025.07.26-2025.09.28',
duration: '适合年级:高校本科生',
students: '已报名1468/2000',
price: '免费'
},
{
id: 2,
title: '计算机二级',
subtitle: 'C语言讲练综合班',
tags: ['系统备考', '考点详解', '题考刷题'],
courseTitle: '计算机二级C语言程序设计证书',
schedule: '开课时间2025.07.26-2025.09.28',
duration: '适合年级:高校本科生',
students: '已报名1468/2000',
price: '免费'
},
{
id: 3,
title: '计算机二级',
subtitle: 'C语言讲练综合班',
tags: ['系统备考', '考点详解', '题考刷题'],
courseTitle: '计算机二级C语言程序设计证书',
schedule: '开课时间2025.07.26-2025.09.28',
duration: '适合年级:高校本科生',
students: '已报名1468/2000',
price: '免费'
},
{
id: 4,
title: '计算机二级',
subtitle: 'C语言讲练综合班',
tags: ['系统备考', '考点详解', '题考刷题'],
courseTitle: '计算机二级C语言程序设计证书',
schedule: '开课时间2025.07.26-2025.09.28',
duration: '适合年级:高校本科生',
students: '已报名1468/2000',
price: '免费'
},
{
id: 5,
title: '计算机二级',
subtitle: 'C语言讲练综合班',
tags: ['系统备考', '考点详解', '题考刷题'],
courseTitle: '计算机二级C语言程序设计证书',
schedule: '开课时间2025.07.26-2025.09.28',
duration: '适合年级:高校本科生',
students: '已报名1468/2000',
price: '免费'
},
{
id: 6,
title: '计算机二级',
subtitle: 'C语言讲练综合班',
tags: ['系统备考', '考点详解', '题考刷题'],
courseTitle: '计算机二级C语言程序设计证书',
schedule: '开课时间2025.07.26-2025.09.28',
duration: '适合年级:高校本科生',
students: '已报名1468/2000',
price: '免费'
}
])
//
const viewDetail = (id: number) => {
console.log('查看活动详情:', id)
//
// router.push(`/activity/${id}`)
}
// 使
const setBannerImage = (imagePath: string) => {
bannerImageSrc.value = imagePath
}
//
onMounted(() => {
setTimeout(() => {
loading.value = false
}, 800)
//
// setBannerImage('/images/activities-banner.jpg')
})
</script>
<style scoped>
.activities-page {
min-height: 100vh;
background: #f8f9fa;
}
/* 横幅图片区域 */
.hero-banner {
width: 100%;
position: relative;
overflow: hidden;
}
.banner-image-container {
width: 100%;
height: 400px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.banner-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.banner-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #4A90E2 0%, #357ABD 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.banner-placeholder::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
opacity: 0.3;
}
.placeholder-content {
text-align: center;
color: white;
position: relative;
z-index: 1;
}
.placeholder-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.8;
}
.placeholder-text {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
}
.placeholder-desc {
font-size: 16px;
opacity: 0.8;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* 主要内容区域 */
.main-content {
padding: 80px 0;
}
.section-title {
font-size: 28px;
font-weight: 600;
color: #333;
text-align: center;
margin: 0 0 50px 0;
}
/* 活动网格 */
.activities-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 30px;
}
.activity-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
cursor: pointer;
}
.activity-card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
transform: translateY(-4px);
}
/* 卡片头部 */
.card-header {
position: relative;
height: 120px;
overflow: hidden;
}
.card-background {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #4ECDC4 0%, #44A08D 100%);
padding: 20px;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
}
.card-background::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
transform: translate(30px, -30px);
}
.card-background::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 60px;
height: 60px;
background: rgba(255, 255, 255, 0.08);
border-radius: 50%;
transform: translate(-20px, 20px);
}
.year-badge {
position: absolute;
top: 15px;
left: 20px;
background: rgba(255, 255, 255, 0.9);
color: #44A08D;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.course-title {
font-size: 18px;
font-weight: 600;
color: white;
margin-bottom: 4px;
position: relative;
z-index: 1;
}
.course-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
position: relative;
z-index: 1;
}
/* 卡片主体 */
.card-body {
padding: 20px;
}
.feature-tags {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.feature-tag {
display: flex;
align-items: center;
gap: 4px;
background: #FFF2E6;
color: #FF6B35;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.tag-icon {
font-size: 10px;
font-style: normal;
}
.course-info {
margin-bottom: 20px;
}
.info-row {
margin-bottom: 8px;
font-size: 13px;
color: #666;
line-height: 1.4;
}
.info-row:first-child {
margin-bottom: 12px;
}
.info-label {
font-weight: 600;
color: #333;
font-size: 14px;
}
.info-text {
color: #666;
}
.price {
color: #FF6B35;
font-weight: 600;
font-size: 14px;
}
/* 卡片底部 */
.card-footer {
text-align: center;
}
.detail-btn {
width: 100%;
padding: 10px 20px;
background: #4A90E2;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.detail-btn:hover {
background: #357ABD;
transform: translateY(-1px);
}
/* 加载状态 */
.loading-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 30px;
}
.loading-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
height: 320px;
}
.loading-shimmer {
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* 动画效果 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-50px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(50px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.banner-image-container {
animation: fadeInUp 0.8s ease-out;
}
.activity-card {
animation: fadeInUp 0.6s ease-out;
animation-fill-mode: both;
}
.activity-card:nth-child(1) { animation-delay: 0.1s; }
.activity-card:nth-child(2) { animation-delay: 0.2s; }
.activity-card:nth-child(3) { animation-delay: 0.3s; }
.activity-card:nth-child(4) { animation-delay: 0.4s; }
.activity-card:nth-child(5) { animation-delay: 0.5s; }
.activity-card:nth-child(6) { animation-delay: 0.6s; }
/* 响应式设计 */
@media (max-width: 1024px) {
.activities-grid {
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.banner-image-container {
height: 350px;
}
.placeholder-icon {
font-size: 40px;
}
.placeholder-text {
font-size: 20px;
}
}
@media (max-width: 768px) {
.activities-grid {
grid-template-columns: 1fr;
gap: 20px;
}
.banner-image-container {
height: 300px;
}
.placeholder-icon {
font-size: 36px;
}
.placeholder-text {
font-size: 18px;
}
.placeholder-desc {
font-size: 14px;
}
.main-content {
padding: 40px 0;
}
.container {
padding: 0 16px;
}
}
@media (max-width: 480px) {
.banner-image-container {
height: 250px;
}
.placeholder-icon {
font-size: 32px;
}
.placeholder-text {
font-size: 16px;
}
.placeholder-desc {
font-size: 12px;
}
.feature-tags {
justify-content: center;
}
}
</style>

File diff suppressed because it is too large Load Diff

1836
src/views/CourseStudy.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -187,7 +187,7 @@
<!-- 筛选结果提示 -->
<div class="filter-result" v-if="selectedSubject !== '全部' || selectedMajor !== '全部' || selectedDifficulty !== '全部'">
<span>筛选结果找到 {{ filteredCourses.length }} 门相关课程</span>
<span>筛选结果找到 {{ total }} 门相关课程</span>
</div>
<!-- 排序标签 -->
@ -197,8 +197,15 @@
<span class="sort-tab active">推荐</span>
</div>
<!-- 加载状态 -->
<div class="loading-state" v-if="loading">
<div class="loading-content">
<p>正在加载课程...</p>
</div>
</div>
<!-- 课程网格 -->
<div class="courses-grid" v-if="allCourses.length > 0">
<div class="courses-grid" v-else-if="allCourses.length > 0">
<div class="course-card" v-for="course in allCourses" :key="course.id">
<div class="course-image">
<img :src="course.thumbnail" :alt="course.title" />
@ -207,7 +214,7 @@
<h3 class="course-title">{{ getCourseTitle(course) }}</h3>
<div class="course-meta">
<span class="course-duration">📚 {{ course.duration }}</span>
<span class="course-time"> {{ course.totalTime }}</span>
<span class="course-price">💰 ¥{{ course.price }}</span>
</div>
<div class="course-footer">
<div class="course-stats">
@ -273,107 +280,17 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { CourseApi } from '@/api/modules/course'
import type { Course } from '@/api/types'
const router = useRouter()
// 248
const generateCourses = () => {
const baseCourses = [
{
title: '暑期冲关班',
description: '暑期名师带你,征服困难数学子题目,冲关学习训练',
thumbnail: '/images/courses/course1.png',
subject: '教育学',
major: '数学教育',
difficulty: '中级'
},
{
title: 'DeepSeek智能未来学习',
description: '深度学习掌握AI技术核心数学原理',
thumbnail: '/images/courses/course2.png',
subject: '心理学',
major: '学科教育',
difficulty: '高级'
},
{
title: '主观题案例长训班',
description: '主观题解题技巧,提升应试能力',
thumbnail: '/images/courses/course3.png',
subject: '教育学',
major: '统计学教育',
difficulty: '初级'
},
{
title: '摆脱哑巴英语',
description: '摆脱哑巴英语,掌握流利口语表达',
thumbnail: '/images/courses/course4.png',
subject: '文学史',
major: '基础数学',
difficulty: '零基础'
},
{
title: '计算机二级考前直播',
description: '计算机二级,掌握考试重点难点',
thumbnail: '/images/courses/course5.png',
subject: '名师课堂',
major: '线性代数',
difficulty: '中级'
},
{
title: '心理学基础课程',
description: '心理学基础理论与实践应用',
thumbnail: '/images/courses/course1.png',
subject: '心理学',
major: '个人成长',
difficulty: '零基础'
},
{
title: '训练营特训课',
description: '集中训练,快速提升专业技能',
thumbnail: '/images/courses/course2.png',
subject: '训练营',
major: '高等数学',
difficulty: '高级'
},
{
title: '考研数学冲刺',
description: '考研数学重点难点突破',
thumbnail: '/images/courses/course3.png',
subject: '考研课程',
major: '数学学科',
difficulty: '高级'
}
]
const courses = []
for (let i = 1; i <= 248; i++) {
const baseIndex = (i - 1) % baseCourses.length
const baseCourse = baseCourses[baseIndex]
const courseHours = Math.floor(Math.random() * 20) + 8
const totalMinutes = courseHours * 60 + Math.floor(Math.random() * 60)
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
courses.push({
id: i,
title: `${baseCourse.title} ${i}`,
description: baseCourse.description,
thumbnail: baseCourse.thumbnail,
studentsCount: Math.floor(Math.random() * 1000) + 100,
duration: `${courseHours}课时`,
totalTime: `${hours}小时${minutes}分钟`,
price: 0,
subject: baseCourse.subject,
major: baseCourse.major,
difficulty: baseCourse.difficulty
})
}
return courses
}
const courses = ref(generateCourses())
//
const courses = ref<Course[]>([])
const loading = ref(false)
const total = ref(0)
//
const selectedSubject = ref('全部')
@ -383,7 +300,7 @@ const selectedDifficulty = ref('全部')
//
const currentPage = ref(1)
const itemsPerPage = 20
const totalItems = computed(() => filteredCourses.value.length)
const totalItems = computed(() => total.value)
const totalPages = computed(() => Math.ceil(totalItems.value / itemsPerPage))
//
@ -427,10 +344,50 @@ const visiblePages = computed(() => {
return pages
})
//
const loadCourses = async () => {
try {
loading.value = true
const params = {
page: currentPage.value,
pageSize: itemsPerPage,
categoryId: selectedSubject.value !== '全部' ? getCategoryIdBySubject(selectedSubject.value) : undefined,
keyword: selectedMajor.value !== '全部' ? selectedMajor.value : undefined
}
const response = await CourseApi.getCourses(params)
if (response.code === 0 || response.code === 200) {
courses.value = response.data.list
total.value = response.data.total
} else {
console.error('获取课程列表失败:', response.message)
}
} catch (error) {
console.error('加载课程失败:', error)
} finally {
loading.value = false
}
}
// ID
const getCategoryIdBySubject = (subject: string): number | undefined => {
const categoryMap: Record<string, number> = {
'必修课': 1,
'高分课': 2,
'名师课堂': 3,
'训练营': 4,
'无考试': 5,
'专题讲座': 6
}
return categoryMap[subject]
}
//
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
loadCourses()
}
}
@ -454,64 +411,50 @@ const clearAllFilters = () => {
selectedMajor.value = '全部'
selectedDifficulty.value = '全部'
currentPage.value = 1
loadCourses()
}
//
const selectSubject = (subject: string) => {
selectedSubject.value = subject
currentPage.value = 1 //
loadCourses()
}
const selectMajor = (major: string) => {
selectedMajor.value = major
currentPage.value = 1 //
loadCourses()
}
const selectDifficulty = (difficulty: string) => {
selectedDifficulty.value = difficulty
currentPage.value = 1 //
loadCourses()
}
//
const filteredCourses = computed(() => {
return courses.value.filter(course => {
const subjectMatch = selectedSubject.value === '全部' || course.subject === selectedSubject.value
const majorMatch = selectedMajor.value === '全部' || course.major === selectedMajor.value
const difficultyMatch = selectedDifficulty.value === '全部' || course.difficulty === selectedDifficulty.value
return subjectMatch && majorMatch && difficultyMatch
})
})
//
// 使API
const allCourses = computed(() => {
const startIndex = (currentPage.value - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
return filteredCourses.value.slice(startIndex, endIndex)
return courses.value
})
//
const getCourseTitle = (course: any) => {
const titles = [
'Python语言基础与应用',
'PPT课件的设计与制作基础',
'暑期名师带学,提高班级数学学科!高效冲分指南',
'机器学习算法实战训练营',
'数据分析与可视化进阶',
'前端开发技术栈全解析',
'人工智能基础理论与实践',
'计算机网络原理与应用'
]
return titles[(course.id - 1) % titles.length] || '暑期名师带学,提高班级数学学科!高效冲分指南'
const getCourseTitle = (course: Course) => {
return course.title
}
//
const goToCourseDetail = (course: any) => {
const goToCourseDetail = (course: Course) => {
router.push({
name: 'CourseDetail',
params: { id: course.id }
})
}
//
onMounted(() => {
loadCourses()
})
</script>
<style scoped>
@ -594,6 +537,19 @@ const goToCourseDetail = (course: any) => {
font-size: 14px;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
text-align: center;
}
.loading-content p {
color: #666;
font-size: 16px;
}
.empty-state {
display: flex;
justify-content: center;
@ -767,8 +723,9 @@ const goToCourseDetail = (course: any) => {
color: #666;
}
.course-time {
color: #666;
.course-price {
color: #ff4d4f;
font-weight: 500;
}
.course-stats {

606
src/views/Faculty.vue Normal file
View File

@ -0,0 +1,606 @@
<template>
<div class="faculty-page">
<!-- 横幅图片区域 -->
<div class="page-header">
<div class="banner-image-container">
<!-- 实际图片 -->
<img
v-if="hasBannerImage"
:src="bannerImageSrc"
alt="师资力量横幅"
class="banner-image"
/>
<!-- 图片占位区域 -->
<div v-else class="banner-placeholder">
<div class="placeholder-content">
<div class="placeholder-icon">🖼</div>
<div class="placeholder-text">师资力量横幅图片占位</div>
<div class="placeholder-desc">请提供横幅图片</div>
</div>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<div class="container">
<!-- 筛选标签栏 -->
<div class="filter-tabs">
<button
v-for="tab in filterTabs"
:key="tab.id"
:class="['filter-tab', { active: activeTab === tab.id }]"
@click="activeTab = tab.id"
>
{{ tab.name }}
</button>
</div>
<!-- 师资卡片网格 -->
<div class="faculty-grid">
<div
v-for="teacher in paginatedTeachers"
:key="teacher.id"
class="faculty-card"
>
<div class="card-header">
<div class="avatar-container">
<!-- 头像占位 -->
<div class="avatar-placeholder"></div>
<div v-if="teacher.featured" class="featured-badge">金牌讲师</div>
</div>
<div class="card-arrow">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 4L10 8L6 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<div class="card-content">
<h3 class="teacher-name">{{ teacher.name }}</h3>
<p class="teacher-title">{{ teacher.title }}</p>
<p class="teacher-description">{{ teacher.description }}</p>
<div class="teacher-tags">
<span v-for="tag in teacher.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
</div>
</div>
<!-- 分页组件 -->
<div class="pagination">
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">
上一页
</button>
<button
v-for="page in visiblePages"
:key="page"
:class="['page-btn', { active: currentPage === page }]"
@click="typeof page === 'number' ? goToPage(page) : null"
:disabled="typeof page !== 'number'"
>
{{ page }}
</button>
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">
下一页
</button>
<span class="page-info">{{ totalPages }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
//
const bannerImageSrc = ref('')
const hasBannerImage = computed(() => bannerImageSrc.value.trim() !== '')
// 使
const setBannerImage = (imagePath: string) => {
bannerImageSrc.value = imagePath
}
//
const filterTabs = ref([
{ id: 'all', name: '全部讲师' },
{ id: 'main', name: '主讲' },
{ id: 'international', name: '注册国际讲师' },
{ id: 'consultant', name: '咨询师' },
{ id: 'expert', name: '专家顾问' },
{ id: 'senior', name: '资深讲师' },
{ id: 'featured', name: '金牌讲师' },
{ id: 'enterprise', name: '企业导师' }
])
const activeTab = ref('all')
//
const teachers = ref([
{
id: 1,
name: '黄天羽',
title: '注册国际人才测评师资格认证',
description: '注册国际企业学习设计师 认证',
tags: ['主讲', '资深人才测评师'],
featured: true
},
{
id: 2,
name: '蓝天',
title: '北京理工大学MBA企业文化专家顾问',
description: '多家知名上市企业高管',
tags: ['主讲', 'MBA企业文化专家']
},
{
id: 3,
name: '万精云',
title: '中国人事科学',
description: '中国科学院博士',
tags: ['主讲', '人事专家']
},
{
id: 4,
name: '张庆勋',
title: '北京大学博士',
description: '内蒙古财经大学',
tags: ['主讲', '金牌讲师']
},
{
id: 5,
name: '程毅',
title: '中国科技大学博士研究生',
description: '',
tags: ['主讲', '科技专家']
},
{
id: 6,
name: '王德华',
title: '数字经济与金融研究中心专家',
description: '多家知名上市企业高级管理顾问',
tags: ['主讲', '数字经济专家']
},
{
id: 7,
name: '马前程',
title: '清华大学管理学院',
description: '多家一线互联网企业高级管理顾问',
tags: ['主讲', '清华大学管理专家']
},
{
id: 8,
name: '陈宇',
title: '知名上市企业高级管理顾问专家',
description: '多家一线互联网企业高级管理顾问',
tags: ['主讲', '企业管理专家']
}
])
//
const currentPage = ref(1)
const pageSize = 8
const totalPages = computed(() => Math.ceil(teachers.value.length / pageSize))
const paginatedTeachers = computed(() => {
const start = (currentPage.value - 1) * pageSize
const end = start + pageSize
return teachers.value.slice(start, end)
})
const visiblePages = computed(() => {
const pages = []
const total = totalPages.value
const current = currentPage.value
if (total <= 7) {
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
if (current <= 4) {
for (let i = 1; i <= 5; i++) {
pages.push(i)
}
pages.push('...')
pages.push(total)
} else if (current >= total - 3) {
pages.push(1)
pages.push('...')
for (let i = total - 4; i <= total; i++) {
pages.push(i)
}
} else {
pages.push(1)
pages.push('...')
for (let i = current - 1; i <= current + 1; i++) {
pages.push(i)
}
pages.push('...')
pages.push(total)
}
}
return pages
})
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
}
}
</script>
<style scoped>
.faculty-page {
min-height: 100vh;
background: #f6f6f6;
}
/* 横幅图片区域 */
.page-header {
width: 100%;
position: relative;
overflow: hidden;
}
.banner-image-container {
width: 100%;
height: 400px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.banner-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.banner-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #4A90E2 0%, #357ABD 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.banner-placeholder::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
opacity: 0.3;
}
.placeholder-content {
text-align: center;
color: white;
position: relative;
z-index: 1;
}
.placeholder-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.8;
}
.placeholder-text {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
}
.placeholder-desc {
font-size: 16px;
opacity: 0.8;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* 主要内容区域 */
.main-content {
padding: 40px 0 80px;
}
/* 筛选标签栏 */
.filter-tabs {
display: flex;
gap: 0;
margin-bottom: 40px;
background: white;
border-radius: 8px;
padding: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.filter-tab {
padding: 12px 24px;
border: none;
background: transparent;
color: #666;
font-size: 14px;
cursor: pointer;
border-radius: 6px;
transition: all 0.3s;
white-space: nowrap;
}
.filter-tab:hover {
background: #f8f9fa;
color: #333;
}
.filter-tab.active {
background: #4A90E2;
color: white;
}
/* 师资卡片网格 */
.faculty-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 40px;
}
.faculty-card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.faculty-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.card-header {
position: relative;
height: 200px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-container {
position: relative;
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%);
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder::after {
content: '头像占位';
color: #999;
font-size: 14px;
}
.featured-badge {
position: absolute;
top: 12px;
left: 12px;
background: #FF6B35;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.card-arrow {
position: absolute;
top: 50%;
right: 16px;
transform: translateY(-50%);
color: #4A90E2;
background: white;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.card-content {
padding: 20px;
}
.teacher-name {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
.teacher-title {
font-size: 14px;
color: #666;
margin: 0 0 8px 0;
line-height: 1.4;
}
.teacher-description {
font-size: 13px;
color: #999;
margin: 0 0 12px 0;
line-height: 1.4;
min-height: 18px;
}
.teacher-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.tag {
background: #f0f8ff;
color: #4A90E2;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
border: 1px solid #e6f3ff;
}
/* 分页组件 */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 40px;
}
.page-btn {
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
color: #666;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
min-width: 40px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.page-btn:hover:not(:disabled) {
background: #f8f9fa;
border-color: #4A90E2;
color: #4A90E2;
}
.page-btn.active {
background: #4A90E2;
border-color: #4A90E2;
color: white;
}
.page-btn:disabled {
background: #f5f5f5;
color: #ccc;
cursor: not-allowed;
border-color: #eee;
}
.page-info {
margin-left: 16px;
color: #666;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.faculty-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1024px) {
.banner-image-container {
height: 350px;
}
.placeholder-icon {
font-size: 40px;
}
.placeholder-text {
font-size: 20px;
}
}
@media (max-width: 768px) {
.faculty-grid {
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.filter-tabs {
flex-wrap: wrap;
gap: 8px;
}
.filter-tab {
padding: 8px 16px;
font-size: 13px;
}
.banner-image-container {
height: 300px;
}
.placeholder-icon {
font-size: 36px;
}
.placeholder-text {
font-size: 18px;
}
.placeholder-desc {
font-size: 14px;
}
}
@media (max-width: 480px) {
.faculty-grid {
grid-template-columns: 1fr;
}
.container {
padding: 0 16px;
}
.main-content {
padding: 20px 0 40px;
}
.banner-image-container {
height: 250px;
}
.placeholder-icon {
font-size: 32px;
}
.placeholder-text {
font-size: 16px;
}
.placeholder-desc {
font-size: 12px;
}
}
</style>

View File

@ -77,7 +77,7 @@
<h3 class="course-title">{{ course.title }}</h3>
<div class="course-meta">
<span class="course-students">{{ course.studentsCount }}{{ t('home.popularCourses.studentsEnrolled') }}</span>
<button class="enroll-btn">{{ t('home.popularCourses.enroll') }}</button>
<button class="enroll-btn" @click="handleEnrollCourse(course.id)">{{ t('home.popularCourses.enroll') }}</button>
</div>
</div>
</div>
@ -203,17 +203,34 @@
</div>
</div>
</section>
<!-- 登录模态框 -->
<LoginModal
v-model:show="loginModalVisible"
@success="handleAuthSuccess"
/>
<!-- 注册模态框 -->
<RegisterModal
v-model:show="registerModalVisible"
@success="handleAuthSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useCourseStore } from '@/stores/course'
import { useAuth } from '@/composables/useAuth'
import LoginModal from '@/components/auth/LoginModal.vue'
import RegisterModal from '@/components/auth/RegisterModal.vue'
const { t, locale } = useI18n()
const router = useRouter()
const courseStore = useCourseStore()
const { loginModalVisible, registerModalVisible, enrollCourse, handleAuthSuccess } = useAuth()
//
const bannerImage = computed(() => {
@ -360,6 +377,12 @@ const featuredReviews = computed(() => [
}
])
// -
const handleEnrollCourse = (courseId: number) => {
//
router.push(`/course/${courseId}`)
}
onMounted(async () => {
await courseStore.fetchCourses()
})

732
src/views/LearningPaths.vue Normal file
View File

@ -0,0 +1,732 @@
<template>
<div class="learning-paths-page">
<!-- 页面标题区域 -->
<div class="page-header">
<div class="container">
<div class="header-content">
<h1 class="page-title">学习路径</h1>
<p class="page-subtitle">成就卓越的学习之路开启智慧之门</p>
</div>
<div class="header-decoration">
<!-- 装饰图片暂时隐藏 -->
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<div class="container">
<div class="content-layout">
<!-- 左侧筛选栏 -->
<div class="filter-sidebar">
<!-- 所有路径标题 - 无背景样式 -->
<h3 class="filter-title">所有路径</h3>
<div class="filter-section">
<div class="filter-options">
<div class="filter-option active" :class="{ active: selectedCategory === 'psychology' }" @click="selectCategory('psychology')">
<span class="option-icon blue-icon"></span>
<span class="option-text">数据心理学</span>
</div>
<div class="filter-option" :class="{ active: selectedCategory === 'ai' }" @click="selectCategory('ai')">
<span class="option-icon gray-icon"></span>
<span class="option-text">AI 时代生存指南从工程师到架构师...</span>
</div>
<div class="filter-option" :class="{ active: selectedCategory === 'english1' }" @click="selectCategory('english1')">
<span class="option-icon gray-icon"></span>
<span class="option-text">新视野英语训练营</span>
</div>
<div class="filter-option" :class="{ active: selectedCategory === 'english2' }" @click="selectCategory('english2')">
<span class="option-icon gray-icon"></span>
<span class="option-text">新视野英语训练营</span>
</div>
<div class="filter-option" :class="{ active: selectedCategory === 'learning' }" @click="selectCategory('learning')">
<span class="option-icon gray-icon"></span>
<span class="option-text">科学学习方法 | 强化记忆</span>
</div>
<div class="filter-option" :class="{ active: selectedCategory === 'health' }" @click="selectCategory('health')">
<span class="option-icon gray-icon"></span>
<span class="option-text">运动与健康</span>
</div>
<div class="filter-option" :class="{ active: selectedCategory === 'education' }" @click="selectCategory('education')">
<span class="option-icon gray-icon"></span>
<span class="option-text">数师教育学科技能体系</span>
</div>
</div>
</div>
</div>
<!-- 右侧内容区域 -->
<div class="content-main">
<!-- 课程标题 - 无背景样式 -->
<h3 class="content-title">课程</h3>
<!-- 统计信息和筛选行 -->
<div class="content-header">
<div class="result-info">
<span class="result-stats"> 34 | 567 课程 | 1243 小时</span>
</div>
<div class="sort-options">
<span class="sort-label">难度等级</span>
<select v-model="sortBy" class="sort-select">
<option value="default">全部</option>
<option value="beginner">初级</option>
<option value="intermediate">中级</option>
<option value="advanced">高级</option>
</select>
</div>
</div>
<!-- 学习路径列表 -->
<div class="paths-list">
<div class="path-card" v-for="path in paginatedPaths" :key="path.id" @click="goToPath(path.id)">
<div class="card-image">
<div class="image-placeholder"></div>
<div class="card-badge">热门</div>
</div>
<div class="card-content">
<h3 class="card-title">{{ path.title }}</h3>
<div class="card-meta">
<span class="meta-item">{{ path.level }}</span>
<span class="meta-item">{{ path.duration }}</span>
<span class="meta-item">免费</span>
</div>
<p class="card-description">{{ path.description }}</p>
<div class="card-footer">
<div class="card-stats">
<span class="stat-item">{{ path.studentsCount }}人学习</span>
<span class="stat-item">{{ path.lessonsCount }}个课程</span>
</div>
<div class="card-rating">
<span class="rating-icon">👍</span>
<span class="rating-text">5.0</span>
</div>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination">
<button class="page-btn" :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">
上一页
</button>
<button
class="page-btn"
:class="{ active: page === currentPage }"
v-for="page in visiblePages"
:key="page"
@click="goToPage(page)"
>
{{ page }}
</button>
<button class="page-btn" :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)">
下一页
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
//
const selectedCategory = ref('psychology')
const sortBy = ref('default')
//
const currentPage = ref(1)
const pageSize = 8
//
const learningPaths = ref([
{
id: 1,
title: '数据心理学的起源',
description: '本课程深度解析,让您一次性掌握数据分析的心理学基础。适合希望从心理学角度理解数据的学习者。这是一门综合性的课程,涵盖了数据分析的各个方面。通过本课程的学习,您将能够掌握数据分析的核心技能,并能够在实际工作中应用这些技能。',
image: 'https://images.unsplash.com/photo-1635070041078-e363dbe005cb?ixlib=rb-4.0.3&auto=format&fit=crop&w=400&q=80',
category: 'psychology',
level: '数学基础',
duration: '免费',
studentsCount: 1234,
lessonsCount: 24,
badge: '热门'
},
{
id: 2,
title: '数据心理学的发展历程',
description: '本课程深度解析,让您一次性掌握数据分析的心理学基础。适合希望从心理学角度理解数据的学习者。这是一门综合性的课程,涵盖了数据分析的各个方面。通过本课程的学习,您将能够掌握数据分析的核心技能。',
image: 'https://images.unsplash.com/photo-1509228468518-180dd4864904?ixlib=rb-4.0.3&auto=format&fit=crop&w=400&q=80',
category: 'psychology',
level: '数学基础',
duration: '免费',
studentsCount: 856,
lessonsCount: 30,
badge: '热门'
},
{
id: 3,
title: '研究情境性环境的影响',
description: '本课程深度解析,让您一次性掌握数据分析的心理学基础。适合希望从心理学角度理解数据的学习者。这是一门综合性的课程,涵盖了数据分析的各个方面。通过本课程的学习,您将能够掌握数据分析的核心技能。',
image: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?ixlib=rb-4.0.3&auto=format&fit=crop&w=400&q=80',
category: 'psychology',
level: '数学基础',
duration: '免费',
studentsCount: 678,
lessonsCount: 36,
badge: '热门'
},
{
id: 4,
title: '研究学习环境设计和有效教学模式',
description: '本课程深度解析,让您一次性掌握数据分析的心理学基础。适合希望从心理学角度理解数据的学习者。这是一门综合性的课程,涵盖了数据分析的各个方面。通过本课程的学习,您将能够掌握数据分析的核心技能。',
image: 'https://images.unsplash.com/photo-1551650975-87deedd944c3?ixlib=rb-4.0.3&auto=format&fit=crop&w=400&q=80',
category: 'psychology',
level: '数学基础',
duration: '免费',
studentsCount: 945,
lessonsCount: 30,
badge: '热门'
},
{
id: 5,
title: '数据心理学的起源',
description: '本课程深度解析,让您一次性掌握数据分析的心理学基础。适合希望从心理学角度理解数据的学习者。这是一门综合性的课程,涵盖了数据分析的各个方面。通过本课程的学习,您将能够掌握数据分析的核心技能。',
image: 'https://images.unsplash.com/photo-1596495578065-6e0763fa1178?ixlib=rb-4.0.3&auto=format&fit=crop&w=400&q=80',
category: 'psychology',
level: '数学基础',
duration: '免费',
studentsCount: 1567,
lessonsCount: 18,
badge: '热门'
},
])
//
const filteredPaths = computed(() => {
let filtered = learningPaths.value
if (selectedCategory.value) {
filtered = filtered.filter(path => path.category === selectedCategory.value)
}
return filtered
})
const totalPages = computed(() => Math.ceil(filteredPaths.value.length / pageSize))
const paginatedPaths = computed(() => {
const start = (currentPage.value - 1) * pageSize
const end = start + pageSize
return filteredPaths.value.slice(start, end)
})
const visiblePages = computed(() => {
const pages = []
const total = totalPages.value
const current = currentPage.value
for (let i = Math.max(1, current - 2); i <= Math.min(total, current + 2); i++) {
pages.push(i)
}
return pages
})
//
const selectCategory = (category: string) => {
selectedCategory.value = category
currentPage.value = 1
}
const goToPage = (page: number) => {
currentPage.value = page
}
const goToPath = (pathId: number) => {
router.push(`/learning-path/${pathId}`)
}
onMounted(() => {
//
console.log('学习路径页面加载完成')
})
</script>
<style scoped>
.learning-paths-page {
min-height: 100vh;
background-color: #f8f9fa;
}
/* 页面标题区域 */
.page-header {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
padding: 60px 0;
position: relative;
overflow: hidden;
}
.page-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, rgba(255,255,255,0.1) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255,255,255,0.1) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.1) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.1) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
opacity: 0.1;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
position: relative;
}
.header-content {
text-align: center;
color: white;
}
.page-title {
font-size: 48px;
font-weight: 700;
margin: 0 0 16px 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-subtitle {
font-size: 18px;
font-weight: 400;
margin: 0;
opacity: 0.9;
}
.header-decoration {
position: absolute;
right: 50px;
top: 50%;
transform: translateY(-50%);
}
.decoration-image {
width: 200px;
height: auto;
opacity: 0.8;
}
/* 主要内容区域 */
.main-content {
padding: 40px 0 80px;
background-color: rgb(246, 246, 246);
}
.content-layout {
display: flex;
gap: 30px;
align-items: flex-start;
}
/* 左侧筛选栏 */
.filter-sidebar {
width: 280px;
flex-shrink: 0;
}
.filter-section {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 0;
box-shadow: none;
border: 1px solid #e9ecef;
}
.filter-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 20px 0;
padding: 0;
/* 所有路径标题直接显示在灰色背景上,无背景样式 */
}
.filter-options {
display: flex;
flex-direction: column;
gap: 0;
}
.filter-option {
display: flex;
align-items: center;
padding: 10px 0;
border-radius: 0;
cursor: pointer;
transition: all 0.2s;
border: none;
border-bottom: 1px solid #e9ecef;
background: transparent;
}
.filter-option:last-child {
border-bottom: none;
}
.filter-option:hover {
background-color: transparent;
}
.filter-option.active .option-text {
color: #1976d2;
font-weight: 500;
}
.filter-option.active .blue-icon {
color: #1976d2;
}
.option-icon {
display: inline-block;
margin-right: 8px;
font-size: 12px;
width: 12px;
height: 12px;
line-height: 1;
flex-shrink: 0;
}
.blue-icon {
color: #1976d2;
}
.gray-icon {
color: #999;
}
.option-text {
font-size: 14px;
font-weight: 400;
color: #666;
line-height: 1.4;
word-wrap: break-word;
word-break: break-word;
flex: 1;
}
/* 右侧内容区域 */
.content-main {
flex: 1;
}
.content-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 20px 0;
padding: 0;
/* 课程标题直接显示在灰色背景上,无背景样式 */
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 0;
width: 100%;
}
.result-info {
color: #666;
font-size: 14px;
}
.result-stats {
font-weight: 400;
color: #666;
}
.sort-options {
display: flex;
align-items: center;
gap: 8px;
}
.sort-label {
font-size: 14px;
color: #666;
}
.sort-select {
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background: white;
cursor: pointer;
color: #666;
}
/* 学习路径列表 */
.paths-list {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 40px;
}
.path-card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
cursor: pointer;
display: flex;
min-height: 120px;
}
.path-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.card-image {
position: relative;
width: 160px;
flex-shrink: 0;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
}
.image-placeholder {
width: 100%;
height: 100%;
background: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
}
.card-badge {
position: absolute;
top: 8px;
left: 8px;
background: #ff6b35;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
}
.card-content {
padding: 16px 20px;
flex: 1;
display: flex;
flex-direction: column;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
line-height: 1.3;
}
.card-meta {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.meta-item {
font-size: 12px;
color: #666;
background: #f8f9fa;
padding: 2px 6px;
border-radius: 3px;
}
.card-description {
font-size: 13px;
color: #666;
line-height: 1.5;
margin: 0 0 12px 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
}
.card-stats {
display: flex;
gap: 12px;
}
.stat-item {
font-size: 12px;
color: #999;
}
.card-rating {
display: flex;
align-items: center;
gap: 4px;
}
.rating-icon {
font-size: 14px;
}
.rating-text {
font-size: 12px;
color: #666;
font-weight: 500;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 40px;
}
.page-btn {
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
color: #666;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
min-width: 40px;
}
.page-btn:hover:not(:disabled) {
border-color: #2196f3;
color: #2196f3;
}
.page-btn.active {
background: #2196f3;
border-color: #2196f3;
color: white;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.content-layout {
flex-direction: column;
}
.filter-sidebar {
width: 100%;
}
.filter-options {
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
}
.filter-option {
flex: 0 0 auto;
}
}
@media (max-width: 768px) {
.page-header {
padding: 40px 0;
}
.page-title {
font-size: 32px;
}
.page-subtitle {
font-size: 16px;
}
.header-decoration {
display: none;
}
.paths-list {
grid-template-columns: 1fr;
gap: 16px;
}
.content-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.sort-options {
align-self: flex-end;
}
}
@media (max-width: 480px) {
.container {
padding: 0 16px;
}
.main-content {
padding: 20px 0 40px;
}
.filter-section {
padding: 16px;
}
.card-content {
padding: 16px;
}
.pagination {
gap: 4px;
}
.page-btn {
padding: 6px 12px;
font-size: 12px;
min-width: 32px;
}
}
</style>

View File

@ -101,7 +101,12 @@
<!-- 侧边图片 -->
<div class="login-image">
<img src="https://via.placeholder.com/600x800" alt="登录" />
<PlaceholderImage
:width="600"
:height="800"
text="登录背景图"
icon="🎨"
/>
</div>
</div>
</div>
@ -112,6 +117,7 @@ import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage, type FormInst, type FormRules } from 'naive-ui'
import { useUserStore } from '@/stores/user'
import PlaceholderImage from '@/components/common/PlaceholderImage.vue'
import {
MailOutline,
LockClosedOutline,
@ -119,6 +125,7 @@ import {
LogoGoogle,
LogoWechat
} from '@vicons/ionicons5'
import { AuthApi } from '@/api'
const router = useRouter()
const message = useMessage()
@ -154,8 +161,8 @@ const rules: FormRules = {
trigger: ['input', 'blur']
},
{
min: 6,
message: '密码长度不能少于6位',
min: 3,
message: '密码长度不能少于3位',
trigger: ['input', 'blur']
}
]
@ -167,22 +174,56 @@ const handleSubmit = async () => {
try {
await formRef.value.validate()
const result = await userStore.login({
//
userStore.isLoading = true
// API
const response = await AuthApi.login({
email: formData.email,
password: formData.password
})
if (result.success) {
message.success(result.message)
if (response.code === 200) {
const { user, token, refreshToken } = response.data
// tokenstore
userStore.user = user
userStore.token = token
//
localStorage.setItem('token', token)
localStorage.setItem('refreshToken', refreshToken)
localStorage.setItem('user', JSON.stringify(user))
//
if (rememberMe.value) {
localStorage.setItem('rememberMe', 'true')
}
message.success('登录成功!')
//
const redirect = router.currentRoute.value.query.redirect as string
router.push(redirect || '/')
} else {
message.error(result.message)
message.error(response.message || '登录失败')
}
} catch (error) {
console.error('表单验证失败:', error)
} catch (error: any) {
console.error('登录失败:', error)
//
if (error.response?.status === 401) {
message.error('邮箱或密码错误')
} else if (error.response?.status === 429) {
message.error('登录尝试过于频繁,请稍后再试')
} else if (error.response?.data?.message) {
message.error(error.response.data.message)
} else {
message.error('网络错误,请检查网络连接')
}
} finally {
userStore.isLoading = false
}
}
</script>

View File

@ -25,10 +25,10 @@
>
<n-form-item label="头像">
<div class="avatar-section">
<n-avatar
<SafeAvatar
:src="userStore.user?.avatar"
:fallback-src="'https://via.placeholder.com/100'"
size="large"
:name="userStore.user?.username"
:size="100"
/>
<n-button size="small" @click="handleAvatarUpload">
更换头像
@ -156,6 +156,7 @@ import { useMessage, type FormInst, type FormRules } from 'naive-ui'
import { useUserStore } from '@/stores/user'
import { useCourseStore } from '@/stores/course'
import CourseCard from '@/components/course/CourseCard.vue'
import SafeAvatar from '@/components/common/SafeAvatar.vue'
import {
PersonOutline,
BookOutline,

View File

@ -1,307 +0,0 @@
<template>
<div class="register-page">
<div class="register-container">
<div class="register-form">
<div class="form-header">
<h1>注册</h1>
<p>加入我们开始您的学习之旅</p>
</div>
<n-form
ref="formRef"
:model="formData"
:rules="rules"
size="large"
@submit.prevent="handleSubmit"
>
<n-form-item path="username" label="用户名">
<n-input
v-model:value="formData.username"
placeholder="请输入用户名"
>
<template #prefix>
<n-icon>
<PersonOutline />
</n-icon>
</template>
</n-input>
</n-form-item>
<n-form-item path="email" label="邮箱">
<n-input
v-model:value="formData.email"
placeholder="请输入邮箱地址"
type="email"
>
<template #prefix>
<n-icon>
<MailOutline />
</n-icon>
</template>
</n-input>
</n-form-item>
<n-form-item path="password" label="密码">
<n-input
v-model:value="formData.password"
placeholder="请输入密码"
type="password"
show-password-on="mousedown"
>
<template #prefix>
<n-icon>
<LockClosedOutline />
</n-icon>
</template>
</n-input>
</n-form-item>
<n-form-item path="confirmPassword" label="确认密码">
<n-input
v-model:value="formData.confirmPassword"
placeholder="请再次输入密码"
type="password"
show-password-on="mousedown"
>
<template #prefix>
<n-icon>
<LockClosedOutline />
</n-icon>
</template>
</n-input>
</n-form-item>
<n-form-item path="agreement">
<n-checkbox v-model:checked="formData.agreement">
我已阅读并同意
<n-button text type="primary">用户协议</n-button>
<n-button text type="primary">隐私政策</n-button>
</n-checkbox>
</n-form-item>
<n-form-item>
<n-button
type="primary"
size="large"
block
:loading="userStore.isLoading"
attr-type="submit"
>
注册
</n-button>
</n-form-item>
</n-form>
<div class="form-footer">
<p>
已有账号
<n-button text type="primary" @click="$router.push('/login')">
立即登录
</n-button>
</p>
</div>
</div>
<!-- 侧边图片 -->
<div class="register-image">
<img src="https://via.placeholder.com/600x800" alt="注册" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage, type FormInst, type FormRules } from 'naive-ui'
import { useUserStore } from '@/stores/user'
import {
PersonOutline,
MailOutline,
LockClosedOutline
} from '@vicons/ionicons5'
const router = useRouter()
const message = useMessage()
const userStore = useUserStore()
const formRef = ref<FormInst | null>(null)
//
const formData = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
agreement: false
})
//
const rules: FormRules = {
username: [
{
required: true,
message: '请输入用户名',
trigger: ['input', 'blur']
},
{
min: 2,
max: 20,
message: '用户名长度应在2-20个字符之间',
trigger: ['input', 'blur']
}
],
email: [
{
required: true,
message: '请输入邮箱地址',
trigger: ['input', 'blur']
},
{
type: 'email',
message: '请输入有效的邮箱地址',
trigger: ['input', 'blur']
}
],
password: [
{
required: true,
message: '请输入密码',
trigger: ['input', 'blur']
},
{
min: 6,
message: '密码长度不能少于6位',
trigger: ['input', 'blur']
}
],
confirmPassword: [
{
required: true,
message: '请确认密码',
trigger: ['input', 'blur']
},
{
validator: (_rule, value) => {
return value === formData.password
},
message: '两次输入的密码不一致',
trigger: ['input', 'blur']
}
],
agreement: [
{
validator: (_rule, value) => {
return value === true
},
message: '请同意用户协议和隐私政策',
trigger: ['change']
}
]
}
//
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
const result = await userStore.register({
username: formData.username,
email: formData.email,
password: formData.password,
confirmPassword: formData.confirmPassword
})
if (result.success) {
message.success(result.message)
//
router.push('/')
} else {
message.error(result.message)
}
} catch (error) {
console.error('表单验证失败:', error)
}
}
</script>
<style scoped>
.register-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.register-container {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 1000px;
width: 100%;
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.register-form {
padding: 60px 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
.form-header {
text-align: center;
margin-bottom: 40px;
}
.form-header h1 {
font-size: 2rem;
color: #333;
margin-bottom: 8px;
}
.form-header p {
color: #666;
font-size: 1rem;
}
.form-footer {
text-align: center;
margin-top: 24px;
}
.register-image {
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
}
.register-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 768px) {
.register-container {
grid-template-columns: 1fr;
max-width: 400px;
}
.register-image {
display: none;
}
.register-form {
padding: 40px 24px;
}
}
</style>

610
src/views/Resources.vue Normal file
View File

@ -0,0 +1,610 @@
<template>
<div class="resources-page">
<!-- 轮播图区域 -->
<div class="banner-section">
<div class="banner-container">
<div class="banner-slide active">
<div class="banner-image">
<!-- 轮播图占位 -->
<div class="banner-placeholder"></div>
</div>
<div class="banner-content">
<h2 class="banner-title">海量资源聚合您的一站式数字资源库</h2>
</div>
</div>
<!-- 轮播指示器 -->
<div class="banner-indicators">
<span class="indicator active"></span>
<span class="indicator"></span>
<span class="indicator"></span>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<div class="container">
<!-- 精选视频区域 -->
<section class="featured-videos">
<h2 class="section-title">精选视频</h2>
<div class="featured-grid">
<div
v-for="video in featuredVideos"
:key="video.id"
class="featured-card"
>
<div class="card-image">
<div class="image-placeholder"></div>
<div class="play-button">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M8 5V19L19 12L8 5Z" fill="white"/>
</svg>
</div>
</div>
<div class="card-content">
<h3 class="card-title">{{ video.title }}</h3>
</div>
</div>
</div>
</section>
<!-- 全部视频区域 -->
<section class="all-videos">
<h2 class="section-title">全部视频</h2>
<!-- 筛选标签 -->
<div class="filter-tabs">
<button
v-for="tab in videoTabs"
:key="tab.id"
:class="['filter-tab', { active: activeVideoTab === tab.id }]"
@click="activeVideoTab = tab.id"
>
{{ tab.name }}
</button>
</div>
<!-- 视频网格 -->
<div class="video-grid">
<div
v-for="video in allVideos"
:key="video.id"
class="video-card"
>
<div class="card-image">
<div class="image-placeholder"></div>
<div class="play-button">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M8 5V19L19 12L8 5Z" fill="white"/>
</svg>
</div>
</div>
<div class="card-content">
<h3 class="card-title">{{ video.title }}</h3>
</div>
</div>
</div>
<div class="load-more">
<button class="load-more-btn">查看更多</button>
</div>
</section>
<!-- 全部图片区域 -->
<section class="all-images">
<h2 class="section-title">全部图片</h2>
<!-- 筛选标签 -->
<div class="filter-tabs">
<button
v-for="tab in imageTabs"
:key="tab.id"
:class="['filter-tab', { active: activeImageTab === tab.id }]"
@click="activeImageTab = tab.id"
>
{{ tab.name }}
</button>
</div>
<!-- 图片网格 -->
<div class="image-grid">
<div
v-for="image in allImages"
:key="image.id"
class="image-card"
>
<div class="card-image">
<div class="image-placeholder"></div>
</div>
<div class="card-content">
<h3 class="card-title">{{ image.title }}</h3>
</div>
</div>
</div>
<div class="load-more">
<button class="load-more-btn">查看更多</button>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
//
const featuredVideos = ref([
{ id: 1, title: '西安工业大学内部资源之一' },
{ id: 2, title: '华南工业大学内部资源之一' },
{ id: 3, title: '西安工业大学内部资源之一' }
])
//
const videoTabs = ref([
{ id: 'all', name: '全部' },
{ id: 'educational', name: '中小学教育资源' },
{ id: 'training', name: '师资培训' },
{ id: 'technology', name: '技术资源' },
{ id: 'management', name: '管理资源' }
])
const activeVideoTab = ref('all')
//
const allVideos = ref([
{ id: 1, title: '北京工业大学内部资源之一' },
{ id: 2, title: '北京工业大学内部资源之一' },
{ id: 3, title: '西安工业大学内部资源之一' },
{ id: 4, title: '北京工业大学内部资源之一' },
{ id: 5, title: '中国工业大学内部资源之一' },
{ id: 6, title: '西安工业大学内部资源之一' },
{ id: 7, title: '西安工业大学内部资源之一' },
{ id: 8, title: '内蒙古工业大学内部资源之一' }
])
//
const imageTabs = ref([
{ id: 'all', name: '全部' },
{ id: 'educational', name: '中小学教育资源' },
{ id: 'training', name: '师资培训' },
{ id: 'technology', name: '技术资源' },
{ id: 'management', name: '管理资源' }
])
const activeImageTab = ref('all')
//
const allImages = ref([
{ id: 1, title: '中国工业大学内部资源之一' },
{ id: 2, title: '西安工业大学内部资源之一' },
{ id: 3, title: '西安工业大学内部资源之一' },
{ id: 4, title: '内蒙古工业大学内部资源之一' },
{ id: 5, title: '北京工业大学内部资源之一' },
{ id: 6, title: '北京工业大学内部资源之一' },
{ id: 7, title: '西安工业大学内部资源之一' },
{ id: 8, title: '内蒙古工业大学内部资源之一' }
])
</script>
<style scoped>
.resources-page {
min-height: 100vh;
background: #f8f9fa;
}
/* 轮播图区域 */
.banner-section {
position: relative;
height: 400px;
overflow: hidden;
}
.banner-container {
position: relative;
width: 100%;
height: 100%;
}
.banner-slide {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.banner-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.banner-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
}
.banner-placeholder::after {
content: '轮播图占位';
color: rgba(255, 255, 255, 0.7);
font-size: 18px;
}
.banner-content {
position: relative;
z-index: 2;
text-align: center;
color: white;
max-width: 800px;
padding: 0 20px;
}
.banner-title {
font-size: 32px;
font-weight: 600;
margin: 0;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.banner-indicators {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
z-index: 3;
}
.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.3s;
}
.indicator.active {
background: white;
}
/* 主要内容区域 */
.main-content {
padding: 60px 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.section-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 30px 0;
}
/* 精选视频区域 */
.featured-videos {
margin-bottom: 80px;
}
.featured-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.featured-card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.featured-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.card-image {
position: relative;
height: 180px;
background: #f5f5f5;
overflow: hidden;
}
.image-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e0e0e0 0%, #f0f0f0 100%);
display: flex;
align-items: center;
justify-content: center;
}
.image-placeholder::after {
content: '图片占位';
color: #999;
font-size: 14px;
}
.play-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
}
.play-button:hover {
background: rgba(0, 0, 0, 0.8);
transform: translate(-50%, -50%) scale(1.1);
}
.card-content {
padding: 16px;
}
.card-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin: 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 筛选标签 */
.filter-tabs {
display: flex;
gap: 0;
margin-bottom: 30px;
background: white;
border-radius: 8px;
padding: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: fit-content;
}
.filter-tab {
padding: 8px 20px;
border: none;
background: transparent;
color: #666;
font-size: 14px;
cursor: pointer;
border-radius: 6px;
transition: all 0.3s;
white-space: nowrap;
}
.filter-tab:hover {
background: #f8f9fa;
color: #333;
}
.filter-tab.active {
background: #4A90E2;
color: white;
}
/* 全部视频区域 */
.all-videos {
margin-bottom: 80px;
}
.video-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 40px;
}
.video-card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.video-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.video-card .card-image {
height: 140px;
}
.video-card .play-button {
width: 40px;
height: 40px;
}
/* 全部图片区域 */
.all-images {
margin-bottom: 40px;
}
.image-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 40px;
}
.image-card {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
cursor: pointer;
}
.image-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.image-card .card-image {
height: 140px;
}
/* 查看更多按钮 */
.load-more {
text-align: center;
}
.load-more-btn {
padding: 12px 32px;
background: white;
border: 1px solid #ddd;
color: #666;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.load-more-btn:hover {
background: #f8f9fa;
border-color: #4A90E2;
color: #4A90E2;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.container {
padding: 0 16px;
}
.featured-grid {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.video-grid,
.image-grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.banner-title {
font-size: 28px;
}
}
@media (max-width: 768px) {
.banner-section {
height: 300px;
}
.banner-title {
font-size: 24px;
}
.main-content {
padding: 40px 0;
}
.featured-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.video-grid,
.image-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.section-title {
font-size: 20px;
margin-bottom: 20px;
}
.filter-tabs {
overflow-x: auto;
padding: 4px;
gap: 4px;
}
.filter-tab {
padding: 6px 16px;
font-size: 13px;
flex-shrink: 0;
}
.featured-videos,
.all-videos {
margin-bottom: 60px;
}
}
@media (max-width: 480px) {
.banner-section {
height: 250px;
}
.banner-title {
font-size: 20px;
padding: 0 16px;
}
.container {
padding: 0 12px;
}
.video-grid,
.image-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.card-image {
height: 160px;
}
.video-card .card-image,
.image-card .card-image {
height: 120px;
}
.section-title {
font-size: 18px;
}
.filter-tabs {
margin-bottom: 20px;
}
.filter-tab {
padding: 6px 12px;
font-size: 12px;
}
}
</style>

197
src/views/TestSections.vue Normal file
View File

@ -0,0 +1,197 @@
<template>
<div class="test-sections">
<h1>测试课程章节API</h1>
<div class="test-controls">
<label>课程ID:</label>
<input v-model.number="testLessonId" type="number" placeholder="输入课程ID" />
<button @click="testGetSections">获取章节列表</button>
</div>
<div class="results">
<h3>API调用结果:</h3>
<div v-if="loading" class="loading">
<p>正在加载...</p>
</div>
<div v-else-if="error" class="error">
<p>错误: {{ error }}</p>
</div>
<div v-else-if="sections.length > 0" class="success">
<h4>章节列表 ({{ sections.length }}):</h4>
<div class="section-list">
<div v-for="section in sections" :key="section.id" class="section-item">
<div class="section-info">
<strong>ID:</strong> {{ section.id }} <br>
<strong>标题:</strong> {{ section.title }} <br>
<strong>课程ID:</strong> {{ section.lessonId }} <br>
<strong>排序:</strong> {{ section.sortOrder }} <br>
<strong>链接:</strong> {{ section.sectionId }} <br>
<strong>层级:</strong> {{ section.level }}
</div>
</div>
</div>
</div>
<div v-else class="no-data">
<p>暂无数据</p>
</div>
<div class="raw-response" v-if="rawResponse">
<h4>原始响应:</h4>
<pre>{{ JSON.stringify(rawResponse, null, 2) }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { CourseApi } from '@/api/modules/course'
import type { CourseSection } from '@/api/types'
const testLessonId = ref(1)
const loading = ref(false)
const error = ref('')
const sections = ref<CourseSection[]>([])
const rawResponse = ref<any>(null)
const testGetSections = async () => {
if (!testLessonId.value) {
error.value = '请输入课程ID'
return
}
try {
loading.value = true
error.value = ''
sections.value = []
rawResponse.value = null
console.log('测试API调用课程ID:', testLessonId.value)
const response = await CourseApi.getCourseSections(testLessonId.value)
console.log('API响应:', response)
rawResponse.value = response
if (response.code === 0 || response.code === 200) {
sections.value = response.data.list || []
console.log('章节数据:', sections.value)
} else {
error.value = response.message || '获取章节失败'
}
} catch (err: any) {
console.error('API调用失败:', err)
error.value = err.message || '网络错误'
rawResponse.value = err
} finally {
loading.value = false
}
}
</script>
<style scoped>
.test-sections {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.test-controls {
margin-bottom: 20px;
padding: 16px;
background: #f5f5f5;
border-radius: 8px;
}
.test-controls label {
margin-right: 8px;
font-weight: 600;
}
.test-controls input {
margin-right: 12px;
padding: 6px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.test-controls button {
background: #1890ff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.test-controls button:hover {
background: #40a9ff;
}
.results {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
.loading {
text-align: center;
color: #666;
}
.error {
color: #ff4d4f;
background: #fff2f0;
padding: 12px;
border-radius: 4px;
border: 1px solid #ffccc7;
}
.success {
color: #52c41a;
}
.no-data {
text-align: center;
color: #999;
}
.section-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
.section-item {
background: #f8f9fa;
padding: 12px;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.section-info {
font-size: 14px;
line-height: 1.6;
}
.raw-response {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #eee;
}
.raw-response pre {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
max-height: 300px;
overflow-y: auto;
}
</style>

242
src/views/VideoTest.vue Normal file
View File

@ -0,0 +1,242 @@
<template>
<div class="video-test-page">
<div class="container">
<h1>视频播放器测试</h1>
<div class="test-controls">
<div class="url-input">
<label>视频URL:</label>
<input
v-model="testVideoUrl"
type="text"
placeholder="输入视频URL"
class="url-field"
/>
<button @click="loadTestVideo" class="load-btn">加载视频</button>
</div>
<div class="preset-videos">
<h3>预设视频:</h3>
<button @click="loadPresetVideo('hls')" class="preset-btn">HLS测试视频</button>
<button @click="loadPresetVideo('mp4')" class="preset-btn">MP4测试视频</button>
</div>
</div>
<div class="video-section">
<VideoPlayer
:video-url="currentVideoUrl"
:title="videoTitle"
:description="videoDescription"
:autoplay="false"
:show-controls="true"
@play="onPlay"
@pause="onPause"
@ended="onEnded"
@timeupdate="onTimeUpdate"
@error="onError"
/>
</div>
<div class="debug-info">
<h3>调试信息:</h3>
<div class="debug-item">
<strong>当前URL:</strong> {{ currentVideoUrl }}
</div>
<div class="debug-item">
<strong>播放状态:</strong> {{ isPlaying ? '播放中' : '已暂停' }}
</div>
<div class="debug-item">
<strong>当前时间:</strong> {{ formatTime(currentTime) }}
</div>
<div class="debug-item">
<strong>错误信息:</strong> {{ errorMessage || '无' }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import VideoPlayer from '@/components/VideoPlayer.vue'
//
const testVideoUrl = ref('http://110.42.96.65:55513/learn/hotgo/attachment/2025-07-26/30137510-ce75-433a-971c-52aae015c1b7.m3u8')
const currentVideoUrl = ref('')
const videoTitle = ref('测试视频')
const videoDescription = ref('这是一个测试视频,用于验证视频播放器功能')
//
const isPlaying = ref(false)
const currentTime = ref(0)
const errorMessage = ref('')
//
const presetVideos = {
hls: 'http://110.42.96.65:55513/learn/hotgo/attachment/2025-07-26/30137510-ce75-433a-971c-52aae015c1b7.m3u8',
mp4: 'https://vjs.zencdn.net/v/oceans.mp4'
}
//
const loadTestVideo = () => {
if (testVideoUrl.value.trim()) {
currentVideoUrl.value = testVideoUrl.value.trim()
errorMessage.value = ''
console.log('加载测试视频:', currentVideoUrl.value)
}
}
const loadPresetVideo = (type: 'hls' | 'mp4') => {
currentVideoUrl.value = presetVideos[type]
testVideoUrl.value = presetVideos[type]
errorMessage.value = ''
videoTitle.value = type === 'hls' ? 'HLS测试视频' : 'MP4测试视频'
console.log('加载预设视频:', type, currentVideoUrl.value)
}
//
const onPlay = () => {
isPlaying.value = true
console.log('视频开始播放')
}
const onPause = () => {
isPlaying.value = false
console.log('视频暂停')
}
const onEnded = () => {
isPlaying.value = false
console.log('视频播放结束')
}
const onTimeUpdate = (time: number) => {
currentTime.value = time
}
const onError = (error: Event) => {
console.error('视频播放错误:', error)
errorMessage.value = '视频播放失败请检查URL或网络连接'
isPlaying.value = false
}
//
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
//
loadPresetVideo('hls')
</script>
<style scoped>
.video-test-page {
min-height: 100vh;
background-color: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.test-controls {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.url-input {
margin-bottom: 20px;
}
.url-input label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.url-field {
width: 70%;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
margin-right: 10px;
}
.load-btn, .preset-btn {
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.load-btn:hover, .preset-btn:hover {
background: #40a9ff;
}
.preset-videos h3 {
margin: 0 0 10px 0;
color: #333;
font-size: 16px;
}
.preset-btn {
margin-right: 10px;
margin-bottom: 10px;
}
.video-section {
background: white;
border-radius: 8px;
overflow: hidden;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.debug-info {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.debug-info h3 {
margin: 0 0 15px 0;
color: #333;
font-size: 16px;
}
.debug-item {
margin-bottom: 10px;
font-size: 14px;
color: #666;
}
.debug-item strong {
color: #333;
margin-right: 8px;
}
</style>

0
v1.txt
View File

View File

@ -1,155 +0,0 @@
# 响应式设计说明
## 🎯 设计目标
网站现已实现完全的全屏占满和响应式设计,能够在各种设备上提供最佳的用户体验。
## 📱 响应式断点
### 断点定义
- **超大屏幕**: ≥1400px (大显示器、4K屏幕)
- **大屏幕**: 1200px-1399px (桌面显示器)
- **中等屏幕**: 992px-1199px (小桌面、大平板横屏)
- **小屏幕**: 768px-991px (平板竖屏)
- **移动设备**: 576px-767px (大手机横屏、小平板)
- **小移动设备**: ≤575px (手机竖屏)
## 🖥️ 各断点适配详情
### 超大屏幕 (≥1400px)
- **容器宽度**: 最大1400px
- **内边距**: 32px
- **课程网格**: 5列
- **试听网格**: 4列
- **统计网格**: 5列
- **最佳体验**: 大显示器用户
### 大屏幕 (1200px-1399px)
- **容器宽度**: 最大1200px
- **内边距**: 24px
- **课程网格**: 4列
- **试听网格**: 4列
- **统计网格**: 5列
- **适用设备**: 标准桌面显示器
### 中等屏幕 (992px-1199px)
- **容器宽度**: 最大960px
- **内边距**: 20px
- **课程网格**: 3列
- **试听网格**: 3列
- **统计网格**: 3列 + 特殊卡片独占一行
- **适用设备**: 小桌面、大平板横屏
### 小屏幕 (768px-991px)
- **容器宽度**: 最大720px
- **内边距**: 16px
- **横幅**: 保持双列布局
- **课程网格**: 2列
- **试听网格**: 2列
- **统计网格**: 2列
- **标题字体**: 减小到24px
- **适用设备**: 平板竖屏
### 移动设备 (576px-767px)
- **容器宽度**: 最大540px
- **内边距**: 12px
- **横幅**: 改为单列布局,居中对齐
- **课程网格**: 2列
- **试听网格**: 2列
- **统计网格**: 2列
- **学习路径**: 单列
- **讲师展示**: 单列
- **标题字体**: 22px
- **适用设备**: 大手机横屏
### 小移动设备 (≤575px)
- **容器宽度**: 100%
- **内边距**: 8px
- **横幅**: 单列,紧凑布局
- **所有网格**: 单列显示
- **统计**: 居中对齐
- **标题字体**: 20px
- **横幅标题**: 1.8rem
- **内容间距**: 减小到40px
- **适用设备**: 手机竖屏
## 🎨 布局特性
### 全屏占满
- **HTML/Body**: 100%高度和宽度
- **App容器**: 弹性布局,占满视口
- **防止横向滚动**: overflow-x: hidden
- **布局组件**: 弹性布局,头部和底部固定,内容区域自适应
### 网格系统
- **自适应网格**: 使用CSS Grid的auto-fit和minmax
- **最小宽度**: 每个卡片都有合理的最小宽度
- **自动换行**: 内容自动适应屏幕宽度
- **间距调整**: 不同屏幕尺寸使用不同的gap值
### 导航栏适配
- **大屏**: 完整导航菜单 + 搜索框 + 用户信息
- **中屏**: 隐藏部分菜单项,保留核心功能
- **小屏**: 隐藏导航菜单,缩小搜索框
- **手机**: 最小化布局,隐藏用户名显示
## 🔧 技术实现
### CSS Grid 响应式
```css
.courses-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
```
### 媒体查询策略
- **移动优先**: 基础样式适配小屏幕
- **渐进增强**: 大屏幕添加更多功能
- **断点覆盖**: 确保所有尺寸都有适配
### 弹性布局
- **Flexbox**: 用于组件内部对齐
- **Grid**: 用于整体布局和卡片网格
- **混合使用**: 根据需求选择最佳方案
## 📊 性能优化
### 图片适配
- **占位符**: 使用图标替代图片,减少加载时间
- **响应式图片**: 为不同屏幕准备不同尺寸
- **懒加载**: 可在后续添加图片懒加载
### 字体缩放
- **相对单位**: 使用rem和em确保可访问性
- **合理层级**: 不同屏幕使用合适的字体大小
- **行高调整**: 保持良好的阅读体验
## 🎯 用户体验
### 触摸友好
- **按钮大小**: 移动端按钮足够大,易于点击
- **间距合理**: 避免误触,提供舒适的操作空间
- **手势支持**: 为触摸设备优化交互
### 内容优先
- **重要信息**: 在小屏幕上优先显示核心内容
- **渐进披露**: 根据屏幕空间逐步展示更多信息
- **简化操作**: 移动端简化复杂操作流程
## 🚀 测试建议
### 设备测试
1. **桌面**: Chrome DevTools 各种尺寸
2. **平板**: iPad (768px) 和 iPad Pro (1024px)
3. **手机**: iPhone SE (375px) 到 iPhone Pro Max (428px)
4. **超宽屏**: 1440px+ 显示器
### 功能测试
- **导航**: 各尺寸下导航功能正常
- **搜索**: 搜索框在小屏幕下可用
- **卡片**: 内容卡片在各尺寸下显示完整
- **交互**: 按钮和链接在触摸设备上易用
现在网站已经完全实现了全屏占满和完整的响应式设计!🎉

View File

@ -1,188 +0,0 @@
# 浏览器缩放兼容性修复说明
## 🎯 问题描述
您遇到的问题是在浏览器缩放比例为100%或大于25%时导航栏和轮播图部分看不到。这是一个常见的CSS布局问题通常由以下原因导致
1. **视口单位问题**: 使用`100vh`在缩放时计算错误
2. **固定定位问题**: `position: sticky`在缩放时位置异常
3. **布局溢出**: 元素在缩放时超出可视区域
4. **Z-index层级**: 元素被其他层级覆盖
## ✅ 已修复的问题
### 1. 视口单位修复
**问题**: `100vh`在浏览器缩放时计算不准确
**解决方案**:
```css
/* 修改前 */
#app {
min-height: 100vh;
}
/* 修改后 */
#app {
min-height: 100%;
position: relative;
}
```
### 2. 固定定位修复
**问题**: `position: sticky`在缩放时位置异常
**解决方案**:
```css
/* 修改前 */
.header {
position: sticky;
top: 0;
}
/* 修改后 */
.header {
position: relative;
z-index: 1000;
}
```
### 3. 布局容器修复
**问题**: 容器高度和宽度在缩放时异常
**解决方案**:
```css
html, body {
height: 100%;
width: 100%;
overflow-x: auto;
overflow-y: auto;
}
```
### 4. 导航栏高度固定
**问题**: 导航栏高度使用`height: 100%`导致缩放异常
**解决方案**:
```css
.header-container {
height: 64px;
min-height: 64px;
position: relative;
z-index: 1001;
}
```
### 5. 轮播图区域修复
**问题**: 轮播图在缩放时被隐藏或位置异常
**解决方案**:
```css
.hero-banner {
min-height: 400px;
display: flex;
align-items: center;
position: relative;
z-index: 1;
}
```
## 🔧 技术修复详情
### CSS缩放兼容性
```css
/* 确保在所有缩放级别下都能正常显示 */
html {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
text-size-adjust: 100%;
zoom: 1;
}
/* 强制显示 - 防止在极端缩放下隐藏 */
.home,
.hero-banner,
.header-container {
visibility: visible !important;
display: flex !important;
opacity: 1 !important;
}
```
### 布局结构优化
```css
.app-layout {
min-height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.content {
flex: 1;
overflow-x: auto;
min-height: calc(100% - 64px - 200px);
}
```
## 📱 缩放测试结果
### 测试的缩放级别
- ✅ **25%**: 导航栏和轮播图正常显示
- ✅ **50%**: 所有元素正常显示
- ✅ **75%**: 布局完整,功能正常
- ✅ **100%**: 标准显示,完美呈现
- ✅ **125%**: 放大显示,元素清晰
- ✅ **150%**: 大字体模式,可访问性良好
- ✅ **200%**: 极大放大,依然可用
### 浏览器兼容性
- ✅ **Chrome**: 所有缩放级别完美支持
- ✅ **Firefox**: 缩放功能正常
- ✅ **Safari**: 响应式缩放良好
- ✅ **Edge**: 完全兼容
## 🎨 视觉效果保持
### 在所有缩放级别下保持
- ✅ **导航栏**: 始终在页面顶部可见
- ✅ **轮播图**: 完整显示,比例正确
- ✅ **内容区域**: 正常滚动,无溢出
- ✅ **响应式**: 断点正常工作
- ✅ **交互**: 所有按钮和链接可点击
### 布局特性
- **弹性布局**: 使用Flexbox确保元素正确排列
- **相对定位**: 避免固定定位的缩放问题
- **最小高度**: 确保内容区域有足够空间
- **Z-index管理**: 正确的层级关系
## 🚀 性能优化
### 渲染性能
- **GPU加速**: 使用transform属性优化渲染
- **重绘最小化**: 避免频繁的布局重计算
- **内存优化**: 合理的CSS选择器使用
### 用户体验
- **平滑缩放**: 所有元素在缩放时平滑过渡
- **内容可见**: 确保重要内容始终可见
- **交互保持**: 缩放不影响用户交互
## 📋 使用建议
### 开发者建议
1. **避免使用100vh**: 在需要全屏高度时使用100%
2. **谨慎使用sticky**: 考虑使用relative替代
3. **测试多缩放**: 开发时测试不同缩放级别
4. **使用相对单位**: em、rem比px更适合缩放
### 用户建议
1. **推荐缩放**: 100%-150%获得最佳体验
2. **极端缩放**: 25%和200%+可能影响可读性
3. **浏览器选择**: 现代浏览器支持更好
## 🔍 问题排查
如果仍然遇到缩放问题,请检查:
1. **浏览器版本**: 确保使用最新版本
2. **缓存清理**: 清除浏览器缓存
3. **开发者工具**: 使用F12检查元素位置
4. **控制台错误**: 查看是否有JavaScript错误
现在您的网站已经完全支持所有浏览器缩放级别!🎉

View File

@ -1,124 +0,0 @@
# 首页设计说明
## 🎨 设计概述
我已经根据您提供的图片,完全重新设计了首页,使其与原图样式完全一致。新的首页包含以下几个主要区域:
## 📋 页面结构
### 1. 主横幅区域 (Hero Banner)
- **蓝色渐变背景**: 使用了从 `#4facfe``#00f2fe` 的渐变效果
- **左侧内容**:
- 限时优惠标签 (半透明白色背景)
- 主标题: "考前冲刺课" (大字体,白色)
- 副标题: "名师授课 · 79元"
- 橙色CTA按钮: "立即抢购"
- **右侧内容**:
- 4个讲师头像采用2x2网格布局
- 圆形头像,带白色边框和阴影效果
### 2. 数据统计区域 (Stats Section)
- **白色背景**,带阴影效果
- **5列网格布局**:
- 前4列: 图标 + 数据展示
- 精品课程: 1000+ (蓝色图标)
- 名师团队: 100+ (绿色图标)
- 学员好评: 5000+ (黄色图标)
- 学习时长: 8000+ (红色图标)
- 第5列: 特殊卡片 "2024年全新升级" (紫色渐变背景)
### 3. 热门课程区域 (Popular Courses)
- **白色背景**
- **标题**: "热门课程" + "查看全部"链接
- **5列网格布局**,每个课程卡片包含:
- 课程缩略图
- 课程分类标签
- 课程标题
- 学习人数和评分
- 价格信息 (当前价格 + 原价)
### 4. 免费试听区域 (Free Trial)
- **浅灰色背景** (#f8f9fa)
- **标题**: "免费试听" + "查看全部"链接
- **4列网格布局**,每个试听卡片包含:
- 视频缩略图 + 播放按钮
- 视频标题和描述
- 时长和观看次数
### 5. 学习路径区域 (Learning Paths)
- **白色背景**
- **标题**: "学习路径" + "查看全部"链接
- **3列网格布局**,每个路径卡片包含:
- 路径图标和基本信息
- 课程数量和学员数量
- 技能标签
- "开始学习"按钮
### 6. 精品讲师区域 (Featured Instructors)
- **浅灰色背景** (#f8f9fa)
- **标题**: "精品讲师"
- **3列网格布局**,每个讲师卡片包含:
- 圆形讲师头像
- 讲师姓名和职位
- 个人简介
- 课程数量和学员数量
## 🎯 设计特点
### 颜色方案
- **主色调**: 蓝色渐变 (#4facfe#00f2fe)
- **强调色**: 橙色 (#ff6b35) 用于CTA按钮
- **背景色**: 白色和浅灰色 (#f8f9fa) 交替
- **文字色**: 深灰色 (#333) 主文字,中灰色 (#666) 辅助文字
### 布局特点
- **响应式设计**: 支持桌面端、平板和移动端
- **网格布局**: 使用CSS Grid实现灵活的多列布局
- **卡片设计**: 统一的卡片样式,带圆角和阴影
- **悬停效果**: 所有交互元素都有悬停动画
### 交互效果
- **卡片悬停**: 向上移动 + 阴影加深
- **按钮悬停**: 颜色变化和过渡动画
- **图片加载**: 占位符图片,等待真实素材替换
## 📱 响应式适配
### 桌面端 (>1200px)
- 完整的多列布局
- 所有内容正常显示
### 平板端 (768px-1200px)
- 适当减少列数
- 保持良好的视觉效果
### 移动端 (<768px)
- 单列或双列布局
- 优化触摸交互
- 调整字体大小和间距
## 🔄 待替换内容
目前使用的是占位符内容,等您提供真实素材后需要替换:
1. **图片素材**:
- 讲师头像
- 课程缩略图
- 视频预览图
- 学习路径图标
2. **文字内容**:
- 课程标题和描述
- 讲师信息
- 价格信息
- 统计数据
## 🚀 技术实现
- **Vue 3 Composition API**: 现代化的组件开发
- **TypeScript**: 类型安全
- **Naive UI**: 统一的UI组件
- **CSS Grid & Flexbox**: 灵活的布局系统
- **CSS变量**: 便于主题定制
页面已经完全按照您提供的图片样式实现,现在可以访问 http://localhost:3000 查看效果!