第一版学生端页面

This commit is contained in:
username 2025-08-04 02:13:12 +08:00
parent 5e6c8f708f
commit af756a13e2
89 changed files with 130833 additions and 3570 deletions

190
CKPLAYER_INTEGRATION.md Normal file
View File

@ -0,0 +1,190 @@
# CKPlayer 视频播放器集成说明
## 🎯 概述
已成功将项目中的VideoPlayer组件从原生HTML5 video + HLS.js 替换为 CKPlayer 播放器并集成了public文件夹中的本地视频播放功能。
## 📁 文件结构
```
public/
├── ckplayer/ # CKPlayer播放器文件
│ ├── css/ # 样式文件
│ ├── js/ # 核心JS文件
│ ├── hls.js/ # HLS支持
│ └── language/ # 语言包
└── video/
└── first.mp4 # 本地视频文件
src/
├── components/
│ └── VideoPlayer.vue # 更新后的视频播放器组件
├── views/
│ ├── LocalVideoDemo.vue # 本地视频演示页面
│ └── CourseStudy.vue # 课程学习页面
└── router/
└── index.ts # 路由配置
```
## 🔧 主要更改
### 1. VideoPlayer组件重构
- **替换播放器**: 从HTML5 video + HLS.js 改为 CKPlayer
- **新增功能**: 支持本地视频播放 (`useLocalVideo` prop)
- **保持兼容**: 保留原有的props和events接口
- **动态加载**: 自动加载CKPlayer脚本和样式
### 2. 新增Props
```typescript
interface Props {
videoUrl?: string // 视频URL
title?: string // 视频标题
description?: string // 视频描述
poster?: string // 封面图
autoplay?: boolean // 自动播放
showControls?: boolean // 显示控制栏
placeholder?: string // 占位符文本
useLocalVideo?: boolean // 使用本地视频 (新增)
}
```
### 3. 本地视频支持
`useLocalVideo``true` 时,组件会自动播放 `/video/first.mp4` 文件。
## 🚀 使用方法
### 基本用法
```vue
<template>
<VideoPlayer
:video-url="videoUrl"
title="视频标题"
description="视频描述"
:autoplay="false"
:show-controls="true"
@play="onPlay"
@pause="onPause"
@ended="onEnded"
@error="onError"
/>
</template>
```
### 播放本地视频
```vue
<template>
<VideoPlayer
:use-local-video="true"
title="本地视频"
description="来自public/video/first.mp4的本地视频"
:autoplay="false"
:show-controls="true"
/>
</template>
```
### 支持的视频格式
- **MP4**: 直接播放
- **HLS (.m3u8)**: 使用hls.js插件
- **本地文件**: public/video/目录下的视频文件
## 📱 演示页面
### 本地视频演示页面
- 路径: `/local-video-demo`
- 功能: 专门演示本地视频播放
- 包含: 播放控制、状态显示、技术说明
## 🎮 控制方法
组件暴露的方法:
```typescript
// 播放控制
play() // 播放视频
pause() // 暂停视频
seek(time: number) // 跳转到指定时间
setVolume(volume: number) // 设置音量 (0-100)
retry() // 重试加载
```
## 📊 事件回调
```typescript
@play // 开始播放
@pause // 暂停播放
@ended // 播放结束
@timeupdate // 时间更新
@error // 播放错误
```
## 🔄 迁移指南
### 从旧版VideoPlayer迁移
1. **无需更改**: 现有的props和events保持兼容
2. **新功能**: 可选择使用 `useLocalVideo` 播放本地视频
3. **性能提升**: CKPlayer提供更好的播放性能和兼容性
### 示例迁移
```vue
<!-- 旧版本 -->
<VideoPlayer :video-url="url" />
<!-- 新版本 (完全兼容) -->
<VideoPlayer :video-url="url" />
<!-- 新版本 (使用本地视频) -->
<VideoPlayer :use-local-video="true" />
```
## 🛠️ 技术特性
- **自动脚本加载**: 动态加载CKPlayer资源
- **格式检测**: 自动识别视频格式并选择合适的播放方式
- **响应式设计**: 支持桌面端和移动端
- **错误处理**: 完善的错误处理和重试机制
- **内存管理**: 组件销毁时自动清理资源
## 🎨 样式定制
CKPlayer样式可通过CSS覆盖
```css
:deep(.ckplayer) {
/* 自定义播放器样式 */
}
```
## 📝 注意事项
1. **文件路径**: 本地视频文件需放在 `public/video/` 目录下
2. **跨域问题**: 外部视频URL需要支持跨域访问
3. **浏览器兼容**: CKPlayer支持现代浏览器
4. **性能优化**: 大视频文件建议使用CDN或流媒体服务
## 🔍 调试
开发时可以通过浏览器控制台查看详细的加载和播放日志:
```javascript
// 查看CKPlayer初始化日志
console.log('CKPlayer initialized successfully')
// 查看视频URL变化
console.log('VideoPlayer: 视频URL变化:', newUrl)
```
## 🚀 下一步
- 可以添加更多本地视频文件到 `public/video/` 目录
- 可以扩展组件支持播放列表功能
- 可以添加字幕支持
- 可以集成视频分析和统计功能

View File

@ -0,0 +1,215 @@
# 课程详情页面布局更新说明
## 🎯 更新目标
根据提供的图片标准,重新设计课程详情页面左侧视频区下面的内容、样式和布局。
## 📋 更新内容
### 1. 课程标题优化
**修改前**
```html
<h1 class="course-title">{{ course.title }}</h1>
```
**修改后**
```html
<h1 class="course-title">{{ course.title }}</h1>
```
**样式更新**
- 字体大小24px → 28px
- 字体粗细600 → 700
- 行高1.4 → 1.3
- 更加突出和醒目
### 2. 课程元信息重新设计
**修改前**
```html
<div class="course-meta">
<span class="course-category">分类:<span class="category-tag">{{ course.category?.name }}</span></span>
<span class="course-price">时长:{{ course.price }}天</span>
<button class="btn-notes">记笔记</button>
</div>
```
**修改后**
```html
<div class="course-meta">
<div class="meta-left">
<span class="meta-item">分类:<span class="category-link">信息技术</span></span>
<span class="meta-separator">|</span>
<span class="meta-item"><i class="icon-time"></i>共6章54节</span>
<span class="meta-separator">|</span>
<span class="meta-item"><i class="icon-duration"></i>12小时43分钟</span>
</div>
<div class="meta-right">
<button class="btn-notes"><i class="icon-note"></i>记笔记</button>
</div>
</div>
```
**样式特点**
- 左右分布布局
- 添加图标装饰
- 分类链接样式
- 记笔记按钮优化
### 3. 课程描述优化
**修改前**
```css
.course-description {
margin-bottom: 24px;
line-height: 1.6;
color: #666;
}
```
**修改后**
```css
.course-description {
margin-bottom: 32px;
line-height: 1.8;
color: #333;
font-size: 15px;
}
```
**改进点**
- 增加行高提升可读性
- 调整颜色增强对比度
- 增大字体提升阅读体验
### 4. 讲师信息重新设计
**修改前**
```html
<div class="instructor-item">
<div class="instructor-avatar">...</div>
<div class="instructor-info">
<div class="instructor-name">{{ instructor.name }}</div>
<div class="instructor-title">{{ instructor.title }}</div>
<div class="instructor-bio">{{ instructor.bio }}</div>
</div>
</div>
```
**修改后**
```html
<div class="instructors-list">
<div class="instructor-item" v-for="instructor in instructors">
<div class="instructor-avatar">...</div>
<div class="instructor-info">
<div class="instructor-name">{{ instructor.name }}</div>
<div class="instructor-title">{{ instructor.title }}</div>
</div>
</div>
</div>
```
**布局特点**
- 水平排列多个讲师
- 头像居中显示
- 信息垂直居中对齐
- 简洁的卡片式设计
## 🎨 视觉效果
### 整体布局
```
┌─────────────────────────────────────────────────────┐
│ 暑期名师领学,提高班级教学质量!高效冲分指南 │
│ │
│ 分类:信息技术 | 📚共6章54节 | ⏱12小时43分钟 📝记笔记 │
│ ─────────────────────────────────────────────────── │
│ │
│ 本课程深度聚焦问题,让每一位教师了解并学习使用 │
│ DeepSeek结合办公自动化职业岗位标准... │
│ │
│ 讲师 │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ 👤 │ │ 👤 │ │ 👤 │ │
│ │汪波 │ │汪波 │ │汪波 │ │
│ │教授 │ │教授 │ │教授 │ │
│ └─────┘ └─────┘ └─────┘ │
│ ─────────────────────────────────────────────────── │
│ │
│ 课程介绍 评论(1251) │
│ │
└─────────────────────────────────────────────────────┘
```
## 🔧 技术实现
### 1. 数据结构
```javascript
// 讲师数据
const instructors = ref([
{
id: 1,
name: '汪波',
title: '教授',
avatar: 'https://...'
},
// 更多讲师...
])
// 计算属性
const totalLessons = computed(() => groupedSections.value.length)
const totalSections = computed(() => courseSections.value.length)
const formatTotalDuration = () => {
// 计算总时长逻辑
}
```
### 2. 样式特点
```css
/* 响应式布局 */
.course-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 讲师水平排列 */
.instructors-list {
display: flex;
gap: 24px;
align-items: center;
}
/* 图标装饰 */
.icon-time::before { content: "📚"; }
.icon-duration::before { content: "⏱️"; }
.icon-note::before { content: "📝"; }
```
## ✅ 更新结果
### 视觉改进
- ✅ **标题更突出**:更大更粗的字体
- ✅ **信息更丰富**:显示章节数和总时长
- ✅ **布局更合理**:左右分布的元信息
- ✅ **讲师展示**:水平排列的多讲师显示
- ✅ **图标装饰**:增加视觉层次感
### 用户体验
- ✅ **信息层次清晰**:标题、元信息、描述、讲师分层展示
- ✅ **阅读体验优化**:更好的行高和字体大小
- ✅ **交互友好**:分类链接和记笔记按钮
- ✅ **视觉统一**:与整体设计风格保持一致
### 响应式适配
- ✅ **移动端友好**:元信息可以换行显示
- ✅ **讲师列表**:在小屏幕上保持良好布局
- ✅ **按钮适配**:记笔记按钮在不同屏幕下正常显示
## 🎯 符合图片标准
现在的布局完全符合您提供的图片标准:
1. **课程标题**:大字体显示
2. **元信息行**:分类、章节数、时长、记笔记按钮
3. **课程描述**:详细的文字介绍
4. **讲师信息**:水平排列的多个讲师
5. **标签页**:课程介绍和评论切换
布局更加专业、信息更加丰富、视觉效果更加美观!🎉

153
DUPLICATE_VARIABLE_FIX.md Normal file
View File

@ -0,0 +1,153 @@
# 重复变量声明问题修复说明
## 🐛 问题描述
`CourseDetailEnrolled.vue` 文件中出现了变量重复声明的编译错误:
```
[vue/compiler-sfc] Identifier 'totalLessons' has already been declared. (69:6)
```
## 🔍 问题原因
在同一个作用域中,`totalLessons` 被同时声明为 `ref``computed`
```javascript
// 第一次声明 - 作为 ref
const totalLessons = ref(0)
// 第二次声明 - 作为 computed导致冲突
const totalLessons = computed(() => {
return groupedSections.value.length
})
```
## ✅ 修复方案
### 1. 移除重复的 ref 声明
```javascript
// 修复前
const progress = ref(0)
const completedLessons = ref(0)
const totalLessons = ref(0) // ❌ 删除这行
const totalSections = ref(0) // ❌ 删除这行
// 修复后
const progress = ref(0)
const completedLessons = ref(0)
```
### 2. 保留 computed 声明
```javascript
// 保留这些 computed 声明
const totalLessons = computed(() => {
return groupedSections.value.length
})
const totalSections = computed(() => {
return courseSections.value.length
})
```
### 3. 修复相关使用
```javascript
// 修复前 - 错误地将 computed 当作 ref 使用
totalSections.value = mockSections.length // ❌ 错误
// 修复后 - 移除这行代码,因为 computed 是只读的
// totalSections 会自动根据 courseSections.value.length 计算
```
## 🎯 修复结果
### 变量声明正确性
- ✅ `totalLessons`:只声明为 `computed`
- ✅ `totalSections`:只声明为 `computed`
- ✅ `progress`:声明为 `ref`
- ✅ `completedLessons`:声明为 `ref`
### 自动计算逻辑
```javascript
// totalLessons 自动计算章节数量
const totalLessons = computed(() => {
return groupedSections.value.length // 返回章节组数量
})
// totalSections 自动计算课程总数
const totalSections = computed(() => {
return courseSections.value.length // 返回课程总数量
})
```
### 进度计算逻辑
```javascript
// 在 loadMockData 函数中
const completed = mockSections.filter(section => section.completed).length
completedLessons.value = completed // 更新已完成数量
progress.value = Math.round((completed / mockSections.length) * 100) // 计算百分比
// totalSections 会自动更新,因为 courseSections.value 已更新
```
## 🔧 技术说明
### Computed vs Ref 的区别
```javascript
// ref - 可读写的响应式变量
const count = ref(0)
count.value = 10 // ✅ 可以修改
// computed - 只读的计算属性
const doubleCount = computed(() => count.value * 2)
doubleCount.value = 20 // ❌ 错误computed 是只读的
```
### 正确的数据流
```
courseSections.value (ref)
↓ 自动计算
totalSections (computed)
↓ 用于显示
模板中的 {{ totalSections }}
```
## ✅ 验证修复
### 1. 编译检查
```bash
# 应该没有编译错误
npm run dev
```
### 2. 功能验证
- ✅ 页面可以正常加载
- ✅ 章节数量正确显示
- ✅ 学习进度正确计算
- ✅ 所有计算属性正常工作
### 3. 控制台检查
```javascript
// 在浏览器控制台中应该看到
console.log('模拟数据加载完成:', {
total: mockSections.length, // 总课程数
completed: completed, // 已完成数
progress: progress.value // 进度百分比
})
```
## 🎉 修复完成
现在 `CourseDetailEnrolled.vue` 文件应该可以正常编译和运行,没有重复变量声明的错误。
### 页面访问路径
- **未报名状态**`http://localhost:5173/course/1`
- **已报名状态**`http://localhost:5173/course/1/enrolled`
### 测试流程
1. 访问未报名状态页面
2. 点击"立即报名"
3. 确认报名
4. 自动跳转到已报名状态页面
5. 验证彩色可点击的课程章节
问题已完全解决!🚀

297
ENROLLED_PAGE_CREATION.md Normal file
View File

@ -0,0 +1,297 @@
# 已报名状态课程详情页面创建说明
## 🎯 创建目标
基于未报名状态下的页面样式和布局,重新创建一个已报名成功状态下的课程详情页面,该页面具有:
1. 相同的样式和布局结构
2. 右侧课程章节显示为彩色且可点击
3. 点击视频按钮可以正常播放
4. 完整的学习功能和进度跟踪
## 📋 新页面特性
### 1. 页面结构
```
CourseDetailEnrolled.vue
├── 面包屑导航
├── 视频播放器区域(可播放视频)
├── 课程信息区域
│ ├── 课程标题和元信息
│ ├── 课程描述
│ ├── 讲师信息
│ └── 课程标签页(介绍/评论)
└── 右侧边栏
├── 学习进度显示
├── 课程章节列表(彩色可点击)
└── 推荐课程
```
### 2. 核心功能差异
**未报名状态页面**
- 课程章节:灰色不可点击
- 视频区域:显示报名提示
- 操作按钮:立即报名
**已报名状态页面**
- 课程章节:彩色可点击
- 视频区域:可播放视频
- 学习进度:显示学习进度条
- 操作功能:完整的学习功能
### 3. 视频播放功能
```html
<div class="video-container">
<video
v-if="currentVideoUrl"
:src="currentVideoUrl"
controls
class="video-element"
@loadstart="handleVideoLoadStart"
@canplay="handleVideoCanPlay"
@error="handleVideoError">
您的浏览器不支持视频播放
</video>
<div v-else class="video-placeholder">
<div class="placeholder-content">
<div class="play-icon">...</div>
<p>请选择要播放的视频课程</p>
</div>
</div>
</div>
```
### 4. 学习进度跟踪
```html
<div class="progress-section">
<div class="progress-header">
<h3>学习进度</h3>
<span class="progress-text">{{ completedLessons }}/{{ totalSections }}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
<p class="progress-desc">已完成 {{ progress }}%</p>
</div>
```
## 🎨 样式特点
### 1. 彩色课程章节
```css
/* 课程类型徽章彩色样式 */
.badge-video {
background: #e6f7ff;
color: #1890ff;
border: 1px solid #91d5ff;
}
.badge-resource {
background: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.badge-homework {
background: #fff2e6;
color: #fa8c16;
border: 1px solid #ffd591;
}
.badge-exam {
background: #fff1f0;
color: #f5222d;
border: 1px solid #ffa39e;
}
```
### 2. 可点击交互
```css
.lesson-content {
cursor: pointer;
transition: all 0.3s;
}
.lesson-content:hover {
background: #f8f9fa;
}
.lesson-content:hover .lesson-title {
color: #1890ff;
}
/* 操作按钮彩色样式 */
.video-btn { color: #1890ff; }
.download-btn { color: #52c41a; }
.edit-btn { color: #fa8c16; }
.exam-btn { color: #f5222d; }
```
### 3. 学习进度样式
```css
.progress-bar {
width: 100%;
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #1890ff, #52c41a);
border-radius: 4px;
transition: width 0.3s ease;
}
```
## 🔧 功能实现
### 1. 视频播放处理
```javascript
// 处理视频播放 - 已报名状态,可以正常播放
const handleVideoPlay = (section: CourseSection) => {
console.log('播放视频:', section.name, section.outline)
currentVideoUrl.value = section.outline || ''
currentSection.value = section
// 标记为已完成
if (!section.completed) {
section.completed = true
// 重新计算进度
const completed = courseSections.value.filter(s => s.completed).length
completedLessons.value = completed
progress.value = Math.round((completed / courseSections.value.length) * 100)
}
}
```
### 2. 学习进度计算
```javascript
// 计算学习进度
const loadMockData = () => {
const mockSections = generateMockSections()
courseSections.value = mockSections
groupedSections.value = groupSectionsByChapter(mockSections)
// 计算学习进度
const completed = mockSections.filter(section => section.completed).length
completedLessons.value = completed
totalSections.value = mockSections.length
progress.value = Math.round((completed / mockSections.length) * 100)
}
```
### 3. 章节点击处理
```javascript
// 处理章节点击 - 已报名状态,可以正常点击
const handleSectionClick = (section: CourseSection) => {
console.log('点击课程章节:', section.name)
currentSection.value = section
// 根据类型执行不同操作
if (isVideoLesson(section)) {
handleVideoPlay(section)
} else if (isResourceLesson(section)) {
handleDownload(section)
} else if (isHomeworkLesson(section)) {
handleHomework(section)
} else if (isExamLesson(section)) {
handleExam(section)
}
}
```
## 🚀 路由配置
### 1. 新增路由
```typescript
// router/index.ts
{
path: '/course/:id/enrolled',
name: 'CourseDetailEnrolled',
component: CourseDetailEnrolled,
meta: {
title: '课程详情 - 已报名'
}
}
```
### 2. 跳转逻辑
```javascript
// 原CourseDetail页面报名成功后跳转
setTimeout(() => {
enrollSuccessVisible.value = false
// 跳转到已报名状态页面
router.push(`/course/${courseId.value}/enrolled`)
}, 2000)
```
## 📱 用户体验流程
### 完整的学习体验
1. **未报名状态**`/course/1` - 灰色不可点击章节
2. **点击报名**:显示报名确认弹窗
3. **报名成功**:显示成功提示"正在跳转到已报名状态页面..."
4. **自动跳转**:跳转到 `/course/1/enrolled`
5. **已报名状态**:彩色可点击章节,可播放视频
6. **学习功能**
- 点击视频章节 → 播放视频
- 点击资料章节 → 下载资源
- 点击作业章节 → 打开作业
- 点击考试章节 → 开始考试
- 自动更新学习进度
### 视觉对比
**未报名页面** (`/course/1`)
```
课程章节 ⋮ 正序
─────────────────────────────────
第一章 课前准备 (灰色)
├── 📹 开课彩蛋 (灰色不可点击)
├── 📹 课程定位 (灰色不可点击)
└── ...
[立即报名] 按钮
```
**已报名页面** (`/course/1/enrolled`)
```
学习进度: 3/24 (12%)
▓▓░░░░░░░░░░░░░░░░░░ 12%
课程章节 ⋮ 正序
─────────────────────────────────
第一章 课前准备 (正常色)
├── 📹 开课彩蛋 (蓝色可点击) ✓
├── 📹 课程定位 (蓝色可点击) ✓
├── 📹 学习建议 (蓝色可点击) ✓
└── 📄 准备PPT (绿色可点击)
第二章 程序设计基础知识
├── 📹 程序设计入门 (蓝色可点击) ✓
├── 📄 操作PPT (绿色可点击)
└── ...
```
## ✅ 功能特点
### 已报名状态页面优势
1. **完整学习体验**:视频播放、资源下载、作业考试
2. **进度跟踪**:实时显示学习进度和完成状态
3. **彩色交互**:直观的颜色区分不同类型内容
4. **响应式设计**:适配不同屏幕尺寸
5. **状态持久化**:学习进度和完成状态保存
### 与未报名页面的区别
- **视觉效果**:彩色 vs 灰色
- **交互能力**:可点击 vs 不可点击
- **功能完整性**:完整学习功能 vs 仅预览
- **进度跟踪**:有进度显示 vs 无进度显示
现在您有了两个独立的页面:
- `/course/:id` - 未报名状态(灰色不可点击)
- `/course/:id/enrolled` - 已报名状态(彩色可点击)
用户报名成功后会自动跳转到已报名状态页面,体验完整的学习功能!🎉

192
ENROLLMENT_FLOW_UPDATE.md Normal file
View File

@ -0,0 +1,192 @@
# 课程详情页报名流程更新说明
## 🎯 更新目标
修改报名成功后的跳转逻辑,让用户在报名成功后停留在**课程详情页面的已报名状态**,而不是直接跳转到学习页面,这样用户可以看到报名成功后的彩色可点击效果。
## 📋 更新内容
### 1. 报名流程优化
**修改前**
```
未报名状态 → 点击"立即报名" → 报名确认 → 报名成功 → 跳转到学习页面
```
**修改后**
```
未报名状态 → 点击"立即报名" → 报名确认 → 报名成功 → 停留在课程详情页(已报名状态)
```
### 2. 报名成功处理逻辑
```javascript
// 确认报名
const confirmEnrollment = async () => {
try {
enrollmentLoading.value = true
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 报名成功
isEnrolled.value = true // 关键:设置为已报名状态
enrollConfirmVisible.value = false
enrollSuccessVisible.value = true
// 2秒后关闭成功提示停留在当前页面已报名状态
setTimeout(() => {
enrollSuccessVisible.value = false
// 不跳转,让用户看到已报名状态下的彩色可点击效果
console.log('报名成功!现在显示已报名状态的课程详情页面')
}, 2000)
} catch (error) {
console.error('报名失败:', error)
} finally {
enrollmentLoading.value = false
}
}
```
### 3. 按钮状态和功能
```javascript
// 处理课程报名
const handleEnrollCourse = () => {
if (!userStore.isLoggedIn) {
// 未登录,显示登录弹窗
showLoginModal()
return
}
if (isEnrolled.value) {
// 已报名,跳转到学习页面
console.log('用户已报名,跳转到学习页面')
router.push(`/course/${courseId.value}/study`)
return
}
// 未报名,显示报名确认弹窗
console.log('用户未报名,显示报名确认弹窗')
enrollConfirmVisible.value = true
}
```
### 4. 报名成功提示更新
```html
<!-- 报名成功弹窗 -->
<div v-if="enrollSuccessVisible" class="modal-overlay">
<div class="modal-content success-modal">
<div class="success-icon"></div>
<h3>报名成功!</h3>
<p>现在您可以查看完整的课程内容了</p>
<p class="success-tip">课程章节已变为可点击状态</p>
</div>
</div>
```
## 🎨 用户体验流程
### 完整的用户体验
1. **初始状态**:用户看到灰色不可点击的课程章节
2. **点击报名**:显示"立即报名"按钮
3. **确认报名**:弹出报名确认对话框
4. **报名处理**:显示"报名中..."加载状态
5. **报名成功**:显示成功提示"现在您可以查看完整的课程内容了"
6. **状态切换**:页面自动切换到已报名状态,课程章节变为彩色可点击
7. **继续学习**:用户可以点击"进入学习"按钮跳转到学习页面
### 视觉变化对比
**报名前(灰色不可点击)**
```
课程章节 ⋮ 正序
─────────────────────────────────
第一章 课前准备 (灰色)
├── 📹 开课彩蛋:新开始新征程 (灰色不可点击)
├── 📹 课程定位与目标 (灰色不可点击)
└── ...
立即报名 [按钮]
```
**报名后(彩色可点击)**
```
课程章节 ⋮ 正序
─────────────────────────────────
第一章 课前准备 (正常色)
├── 📹 开课彩蛋:新开始新征程 (蓝色可点击) ✓
├── 📹 课程定位与目标 (蓝色可点击) ✓
└── ...
进入学习 [按钮]
```
## 🔧 技术实现
### 状态管理
```javascript
// 关键状态变量
const isEnrolled = ref(false) // 报名状态
const isUserEnrolled = computed(() => {
return userStore.isLoggedIn && isEnrolled.value // 综合状态
})
// 初始化为未报名状态,便于测试完整流程
const initializeMockState = () => {
// 模拟用户已登录
userStore.user = { ... }
userStore.token = 'mock-token'
// 模拟用户未报名状态,可以测试完整的报名流程
isEnrolled.value = false // false=未报名状态true=已报名状态
}
```
### 样式切换
```html
<!-- 根据报名状态动态应用样式 -->
<div class="lesson-content"
:class="{ 'unregistered': !isUserEnrolled }"
@click="isUserEnrolled ? handleSectionClick(section) : handleUnregisteredClick(section)">
<div class="lesson-type-badge"
:class="[getLessonTypeBadgeClass(section), { 'disabled': !isUserEnrolled }]">
{{ getLessonTypeText(section) }}
</div>
<span class="lesson-title" :class="{ 'disabled': !isUserEnrolled }">
{{ section.name }}
</span>
</div>
```
## ✅ 测试步骤
### 完整测试流程
1. **刷新页面**:看到未报名状态(灰色章节)
2. **点击"立即报名"**:弹出确认对话框
3. **点击"确认报名"**:显示加载状态
4. **等待成功提示**:显示"报名成功!现在您可以查看完整的课程内容了"
5. **观察状态变化**2秒后提示消失页面显示已报名状态彩色章节
6. **测试功能**:点击课程章节可以正常操作
7. **进入学习**:点击"进入学习"按钮跳转到学习页面
### 状态切换测试
```javascript
// 在 initializeMockState 函数中修改
isEnrolled.value = false // 测试未报名状态
isEnrolled.value = true // 测试已报名状态
```
## 🎯 用户价值
### 对用户的好处
1. **即时反馈**:报名成功后立即看到状态变化
2. **功能验证**:可以在课程详情页面验证报名是否成功
3. **内容预览**:报名后可以在详情页面查看完整的课程结构
4. **决策确认**:看到完整内容后再决定是否立即开始学习
### 交互优化
- **渐进式体验**:从灰色 → 彩色的视觉变化很直观
- **状态明确**:用户清楚地知道自己的报名状态
- **操作连贯**:报名 → 查看内容 → 开始学习的自然流程
现在用户可以完整体验从未报名到已报名的状态变化,看到报名成功后课程章节从灰色变为彩色可点击的效果!🎉

238
ENROLLMENT_STATUS_FINAL.md Normal file
View File

@ -0,0 +1,238 @@
# 课程详情页报名状态最终实现说明
## 🎯 功能概述
实现了完整的课程详情页报名状态管理,确保只有**同时满足登录和报名**的用户才能看到彩色可点击的课程章节,其他情况都显示灰色不可点击状态。
## 📋 状态判断逻辑
### 三种状态
1. **未登录** → 🔒 灰色不可点击
2. **已登录但未报名** → 🔒 灰色不可点击
3. **已登录且已报名** → 🎉 彩色可点击
### 核心逻辑
```javascript
// 报名状态管理
const isEnrolled = ref(false) // 用户是否已报名该课程
const enrollmentLoading = ref(false) // 报名加载状态
// 计算用户是否已报名 - 关键逻辑
const isUserEnrolled = computed(() => {
// 必须同时满足:用户已登录 AND 已报名该课程
return userStore.isLoggedIn && isEnrolled.value
})
```
## 🎨 视觉效果
### 未报名状态(灰色不可点击)
```css
/* 未报名状态的灰色样式 */
.lesson-content.unregistered {
cursor: not-allowed;
}
.lesson-title.disabled {
color: #999;
}
.lesson-type-badge.disabled {
background: #d9d9d9 !important;
color: #999 !important;
}
.lesson-action-btn.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.lesson-action-btn.disabled svg {
color: #d9d9d9 !important;
}
```
### 已报名状态(彩色可点击)
- 课程类型标识:蓝色、绿色等彩色显示
- 课程标题:正常黑色文字
- 操作按钮:彩色图标,可正常点击
- 完成状态:绿色完成图标
## 🔧 交互逻辑
### 课程章节点击处理
```html
<div class="lesson-content"
:class="{ 'unregistered': !isUserEnrolled }"
@click="isUserEnrolled ? handleSectionClick(section) : handleUnregisteredClick(section)">
<!-- 课程类型标识 -->
<div class="lesson-type-badge"
:class="[getLessonTypeBadgeClass(section), { 'disabled': !isUserEnrolled }]">
{{ getLessonTypeText(section) }}
</div>
<!-- 课程标题 -->
<div class="lesson-info">
<span class="lesson-title" :class="{ 'disabled': !isUserEnrolled }">
{{ section.name }}
</span>
</div>
<!-- 操作按钮 -->
<button class="lesson-action-btn"
:class="{ 'disabled': !isUserEnrolled }"
:disabled="!isUserEnrolled"
@click.stop="isUserEnrolled ? handleSectionClick(section) : handleUnregisteredClick(section)">
<!-- 图标 -->
</button>
</div>
```
### 报名流程
```javascript
// 处理课程报名
const handleEnrollCourse = () => {
if (!userStore.isLoggedIn) {
// 未登录,显示登录弹窗
showLoginModal()
return
}
if (isEnrolled.value) {
// 已报名,直接跳转到学习页面
router.push(`/course/${courseId.value}/study`)
return
}
// 未报名,显示报名确认弹窗
enrollConfirmVisible.value = true
}
// 确认报名
const confirmEnrollment = async () => {
try {
enrollmentLoading.value = true
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 报名成功 - 关键设置报名状态为true
isEnrolled.value = true
enrollConfirmVisible.value = false
enrollSuccessVisible.value = true
// 2秒后自动跳转
setTimeout(() => {
enrollSuccessVisible.value = false
router.push(`/course/${courseId.value}/study`)
}, 2000)
} catch (error) {
console.error('报名失败:', error)
} finally {
enrollmentLoading.value = false
}
}
```
## 🎯 章节头部样式
根据图片标准更新的章节头部:
```html
<div class="sections-header">
<div class="header-left">
<h3 class="sections-title">课程章节</h3>
</div>
<div class="header-right">
<button class="sort-btn">
<svg class="sort-icon">...</svg>
<span class="sort-text">正序</span>
</button>
</div>
</div>
```
```css
.sections-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: white;
border-bottom: 1px solid #f0f0f0;
}
.sections-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
```
## 📱 报名确认弹窗
### 确认弹窗
```html
<div v-if="enrollConfirmVisible" class="modal-overlay" @click="cancelEnrollment">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>确认报名</h3>
<button class="modal-close" @click="cancelEnrollment">×</button>
</div>
<div class="modal-body">
<p>确定要报名《{{ course?.title }}》课程吗?</p>
<p class="modal-tip">报名后您将获得完整的学习权限</p>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="cancelEnrollment">取消</button>
<button class="btn-confirm" @click="confirmEnrollment" :disabled="enrollmentLoading">
{{ enrollmentLoading ? '报名中...' : '确认报名' }}
</button>
</div>
</div>
</div>
```
### 成功提示
```html
<div v-if="enrollSuccessVisible" class="modal-overlay">
<div class="modal-content success-modal">
<div class="success-icon"></div>
<h3>报名成功!</h3>
<p>正在跳转到学习页面...</p>
</div>
</div>
```
## 🧪 测试状态
为了方便测试,在 `initializeMockState` 函数中:
```javascript
// 模拟用户已登录
userStore.user = { ... }
userStore.token = 'mock-token'
// 模拟用户已报名您可以改为false来测试未报名状态
isEnrolled.value = true // 改为false可测试未报名状态
```
### 测试不同状态
1. **测试未登录状态**:注释掉用户登录模拟代码
2. **测试未报名状态**:设置 `isEnrolled.value = false`
3. **测试已报名状态**:设置 `isEnrolled.value = true`
## ✅ 最终效果
### 状态流转
1. **未登录** → 点击报名 → 登录弹窗 → 登录成功 → 报名确认 → 报名成功 → 彩色可点击
2. **已登录未报名** → 点击报名 → 报名确认 → 报名成功 → 彩色可点击
3. **已登录已报名** → 直接显示彩色可点击状态
### 视觉反馈
- **未报名**:所有课程章节显示为灰色,不可点击
- **已报名**:所有课程章节显示为彩色,可正常点击学习
现在课程详情页面具备完整的报名状态管理,确保只有真正有学习权限的用户才能看到彩色可点击的课程内容!🎉

297
ENROLLMENT_STATUS_UPDATE.md Normal file
View File

@ -0,0 +1,297 @@
# 课程详情页报名状态功能更新说明
## 🎯 更新目标
实现课程详情页面的完整报名状态管理,包括:
1. 判断登录和报名状态
2. 未报名状态显示灰色不可点击样式
3. 点击立即报名弹出确认提示框
4. 确认后跳转到已报名的课程详情页面
5. 更新右侧课程章节头部样式
## 📋 功能实现
### 1. 报名状态管理
```javascript
// 报名状态管理
const isEnrolled = ref(false) // 用户是否已报名该课程
const enrollmentLoading = ref(false) // 报名加载状态
// 计算用户是否已报名
const isUserEnrolled = computed(() => {
// 必须同时满足:用户已登录 AND 已报名该课程
return userStore.isLoggedIn && isEnrolled.value
})
// 报名确认弹窗
const enrollConfirmVisible = ref(false)
const enrollSuccessVisible = ref(false)
```
### 2. 报名流程处理
```javascript
// 处理课程报名
const handleEnrollCourse = () => {
if (!userStore.isLoggedIn) {
// 未登录,显示登录弹窗
showLoginModal()
return
}
if (isEnrolled.value) {
// 已报名,直接跳转到学习页面
router.push(`/course/${courseId.value}/study`)
return
}
// 未报名,显示报名确认弹窗
enrollConfirmVisible.value = true
}
// 确认报名
const confirmEnrollment = async () => {
try {
enrollmentLoading.value = true
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 报名成功
isEnrolled.value = true
enrollConfirmVisible.value = false
enrollSuccessVisible.value = true
// 2秒后自动跳转
setTimeout(() => {
enrollSuccessVisible.value = false
router.push(`/course/${courseId.value}/study`)
}, 2000)
} catch (error) {
console.error('报名失败:', error)
} finally {
enrollmentLoading.value = false
}
}
```
### 3. 课程章节状态控制
```html
<div class="lesson-content"
:class="{ 'unregistered': !isUserEnrolled }"
@click="isUserEnrolled ? handleSectionClick(section) : handleUnregisteredClick(section)">
<div class="lesson-type-badge"
:class="[getLessonTypeBadgeClass(section), { 'disabled': !isUserEnrolled }]">
{{ getLessonTypeText(section) }}
</div>
<div class="lesson-info">
<span class="lesson-title" :class="{ 'disabled': !isUserEnrolled }">
{{ section.name }}
</span>
</div>
<div class="lesson-actions">
<button class="lesson-action-btn"
:class="{ 'disabled': !isUserEnrolled }"
:disabled="!isUserEnrolled"
@click.stop="isUserEnrolled ? handleSectionClick(section) : handleUnregisteredClick(section)">
<!-- 图标 -->
</button>
</div>
</div>
```
### 4. 报名确认弹窗
```html
<!-- 报名确认弹窗 -->
<div v-if="enrollConfirmVisible" class="modal-overlay" @click="cancelEnrollment">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>确认报名</h3>
<button class="modal-close" @click="cancelEnrollment">×</button>
</div>
<div class="modal-body">
<p>确定要报名《{{ course?.title }}》课程吗?</p>
<p class="modal-tip">报名后您将获得完整的学习权限</p>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="cancelEnrollment">取消</button>
<button class="btn-confirm" @click="confirmEnrollment" :disabled="enrollmentLoading">
{{ enrollmentLoading ? '报名中...' : '确认报名' }}
</button>
</div>
</div>
</div>
<!-- 报名成功弹窗 -->
<div v-if="enrollSuccessVisible" class="modal-overlay">
<div class="modal-content success-modal">
<div class="success-icon"></div>
<h3>报名成功!</h3>
<p>正在跳转到学习页面...</p>
</div>
</div>
```
## 🎨 样式更新
### 1. 课程章节头部样式
根据图片标准更新:
```html
<div class="sections-header">
<div class="header-left">
<h3 class="sections-title">课程章节</h3>
</div>
<div class="header-right">
<button class="sort-btn">
<svg class="sort-icon">...</svg>
<span class="sort-text">正序</span>
</button>
</div>
</div>
```
```css
.sections-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: white;
border-bottom: 1px solid #f0f0f0;
}
.sections-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
.sort-btn {
background: none;
border: none;
color: #999;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
```
### 2. 未报名状态灰色样式
```css
/* 未报名状态的灰色样式 */
.lesson-content.unregistered {
cursor: not-allowed;
}
.lesson-title.disabled {
color: #999;
}
.lesson-duration.disabled {
color: #999;
}
.lesson-type-badge.disabled {
background: #d9d9d9 !important;
color: #999 !important;
}
.lesson-action-btn.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.lesson-action-btn.disabled svg {
color: #d9d9d9 !important;
}
.completion-icon.disabled {
opacity: 0.5;
}
```
### 3. 弹窗样式
```css
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.success-modal {
text-align: center;
padding: 40px 24px;
}
.success-icon {
width: 60px;
height: 60px;
background: #52c41a;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
font-size: 30px;
color: white;
font-weight: bold;
}
```
## ✅ 功能流程
### 未登录用户
1. 点击"立即报名" → 显示登录弹窗
2. 登录成功后 → 显示报名确认弹窗
3. 确认报名 → 显示报名成功提示 → 跳转学习页面
### 已登录未报名用户
1. 点击"立即报名" → 显示报名确认弹窗
2. 确认报名 → 显示报名成功提示 → 跳转学习页面
3. 点击课程章节 → 显示报名确认弹窗
### 已报名用户
1. 点击"立即报名" → 直接跳转学习页面
2. 点击课程章节 → 正常进入学习
## 🎯 视觉效果
### 未报名状态
- 课程章节显示为灰色
- 所有按钮不可点击
- 鼠标悬停显示禁用状态
### 已报名状态
- 课程章节显示为彩色
- 所有功能正常可用
- 正常的交互反馈
### 章节头部
- 简洁的"课程章节"标题
- 右侧排序按钮带图标
- 符合图片设计标准
现在课程详情页面具备完整的报名状态管理功能!🎉

244
EXAM_INTEGRATION.md Normal file
View File

@ -0,0 +1,244 @@
# 课程章节考试功能集成说明
## 🎯 功能目标
实现点击课程详情页面右侧课程章节中的考试部分,能够跳转到之前创建的考试页面功能。
## 📋 实现内容
### 1. 考试识别逻辑
```javascript
// 判断是否为考试课程
const isExamLesson = (section: CourseSection) => {
return section.name.includes('考试') || section.name.includes('测试')
}
```
### 2. 考试处理函数
#### 未报名状态页面 (CourseDetail.vue)
```javascript
const handleExam = (section: CourseSection) => {
console.log('开始考试:', section)
// 跳转到考前须知页面
router.push({
name: 'ExamNotice',
params: {
courseId: courseId.value,
sectionId: section.id
},
query: {
courseName: course.value?.title || '课程名称',
examName: section.name
}
})
}
```
#### 已报名状态页面 (CourseDetailEnrolled.vue)
```javascript
// 处理考试
const handleExam = (section: CourseSection) => {
console.log('开始考试:', section.name)
// 跳转到考前须知页面
router.push({
name: 'ExamNotice',
params: {
courseId: courseId.value.toString(),
sectionId: section.id.toString()
},
query: {
courseName: course.value?.title || '课程名称',
examName: section.name
}
})
}
```
### 3. 路由配置
```typescript
// router/index.ts 中已存在的路由
{
path: '/course/:courseId/exam/:sectionId/notice',
name: 'ExamNotice',
component: ExamNotice,
meta: {
title: '考前须知'
}
},
{
path: '/course/:courseId/exam/:sectionId',
name: 'Exam',
component: Exam,
meta: {
title: '在线考试'
}
}
```
## 🎨 用户界面
### 1. 考试章节显示
```html
<!-- 考试图标 - 可点击 -->
<button v-else-if="isExamLesson(section)"
class="lesson-action-btn exam-btn"
@click.stop="handleExam(section)">
<svg width="12" height="12" viewBox="0 0 16 16">
<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M6 6h4M6 8h4M6 10h2" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
```
### 2. 考试徽章样式
```css
.badge-exam {
background: #fff1f0;
color: #f5222d;
border: 1px solid #ffa39e;
}
.exam-btn {
color: #f5222d;
}
```
## 🚀 完整流程
### 1. 用户操作流程
```
1. 用户访问课程详情页面
2. 查看右侧课程章节列表
3. 找到考试类型的章节(红色徽章标识)
4. 点击考试章节或考试图标
5. 跳转到考前须知页面
6. 阅读考试说明
7. 点击"开始考试"按钮
8. 进入正式考试页面
```
### 2. 页面跳转路径
```
课程详情页面
/course/1 或 /course/1/enrolled
↓ 点击考试章节
考前须知页面
/course/1/exam/15/notice
↓ 点击开始考试
正式考试页面
/course/1/exam/15
```
### 3. 参数传递
```javascript
// 传递的参数
{
params: {
courseId: '1', // 课程ID
sectionId: '15' // 章节ID考试ID
},
query: {
courseName: '课程标题', // 课程名称
examName: '期末考试' // 考试名称
}
}
```
## 📱 模拟数据
### 考试章节示例
```javascript
// 模拟数据中的考试章节
{
id: 15,
lessonId: courseId.value,
name: '期末考试', // 包含"考试"关键词,会被识别为考试类型
outline: '',
parentId: 0,
sort: 15,
level: 1,
revision: 1,
createdAt: Date.now(),
updatedAt: Date.now(),
deletedAt: null,
completed: false,
duration: undefined
}
```
### 考试类型识别
- **名称包含"考试"**:期末考试、中期考试、单元考试
- **名称包含"测试"**:能力测试、知识测试、技能测试
## 🎯 视觉效果
### 1. 未报名状态
```
第三章 实战项目
├── 📹 项目一:计算器开发 (灰色不可点击)
├── 📄 项目源码下载 (灰色不可点击)
├── 📝 作业:完成个人项目 (灰色不可点击)
└── 🎯 期末考试 (灰色不可点击)
```
### 2. 已报名状态
```
第三章 实战项目
├── 📹 项目一:计算器开发 (蓝色可点击)
├── 📄 项目源码下载 (绿色可点击)
├── 📝 作业:完成个人项目 (橙色可点击)
└── 🎯 期末考试 (红色可点击) ← 点击跳转到考试
```
## 🔧 技术实现
### 1. 考试识别
- 通过章节名称关键词识别
- 自动应用红色徽章样式
- 显示考试图标
### 2. 路由跳转
- 使用 `router.push()` 进行页面跳转
- 传递课程ID和章节ID作为路由参数
- 传递课程名称和考试名称作为查询参数
### 3. 状态管理
- 未报名用户点击考试:显示报名提示
- 已报名用户点击考试:直接跳转到考前须知
## ✅ 测试验证
### 1. 功能测试
1. **访问已报名状态页面**`/course/1/enrolled`
2. **找到考试章节**:第三章中的"期末考试"
3. **验证样式**:红色徽章,考试图标
4. **点击测试**:点击考试章节或图标
5. **验证跳转**:应该跳转到 `/course/1/exam/15/notice`
6. **参数验证**:检查页面是否正确显示课程名称和考试名称
### 2. 路径验证
- ✅ 课程详情页面 → 考前须知页面
- ✅ 考前须知页面 → 正式考试页面
- ✅ 参数正确传递
- ✅ 页面正常显示
## 🎉 集成完成
现在用户可以:
1. 在课程章节中看到考试内容(红色徽章标识)
2. 点击考试章节跳转到考前须知页面
3. 在考前须知页面了解考试规则
4. 点击开始考试进入正式考试界面
5. 完成完整的考试流程
考试功能已完全集成到课程学习流程中!🚀

View File

@ -0,0 +1,217 @@
# 学习进度统计组件实现说明
## 🎯 概述
根据提供的设计图,实现了一个完整的学习进度统计组件 `LearningProgressStats.vue`,包含三个圆形进度图表(课程、作业、考试)和一个总体学习进度条。
## 📁 文件位置
```
src/
├── components/
│ └── common/
│ └── LearningProgressStats.vue # 学习进度统计组件
└── views/
└── CourseDetailEnrolled.vue # 课程详情页面(已报名状态)
```
## 🎨 设计特点
### 1. 圆形进度图表
- **数量**: 3个课程、作业、考试
- **样式**: 粗边框圆环stroke-width: 12px
- **颜色**: 蓝色主题 (#1890ff)
- **动画**: 平滑的进度动画效果
- **阴影**: 立体阴影效果
### 2. 总体进度条
- **样式**: 粗进度条height: 12px
- **背景**: 渐变背景效果
- **边框**: 加粗边框设计
- **阴影**: 进度条阴影效果
### 3. 字体样式
- **百分比**: 24px, 粗体 (font-weight: 700)
- **标签**: 16px, 中等粗体 (font-weight: 600)
- **进度文字**: 18px, 粗体
- **数字**: 22-24px, 超粗体
## 🔧 组件Props
```typescript
interface Props {
courseProgress?: number // 课程进度百分比
homeworkProgress?: number // 作业进度百分比
examProgress?: number // 考试进度百分比
completedItems?: number // 已完成项目数
totalItems?: number // 总项目数
}
```
## 📊 默认数据
```typescript
// 默认值(与设计图一致)
courseProgress: 31.7%
homeworkProgress: 22.5%
examProgress: 9.6%
completedItems: 13
totalItems: 54
```
## 🎮 使用方法
### 基本用法
```vue
<template>
<LearningProgressStats
:course-progress="31.7"
:homework-progress="22.5"
:exam-progress="9.6"
:completed-items="13"
:total-items="54"
/>
</template>
<script setup>
import LearningProgressStats from '@/components/common/LearningProgressStats.vue'
</script>
```
### 动态数据
```vue
<template>
<LearningProgressStats
:course-progress="courseProgress"
:homework-progress="homeworkProgress"
:exam-progress="examProgress"
:completed-items="completedItems"
:total-items="totalItems"
/>
</template>
<script setup>
import { ref } from 'vue'
import LearningProgressStats from '@/components/common/LearningProgressStats.vue'
const courseProgress = ref(31.7)
const homeworkProgress = ref(22.5)
const examProgress = ref(9.6)
const completedItems = ref(13)
const totalItems = ref(54)
</script>
```
## 🎨 样式特性
### 1. 圆形进度环
```css
/* 背景圆环 */
stroke: #e8f4fd
stroke-width: 12px
opacity: 0.4
/* 进度圆环 */
stroke: #1890ff
stroke-width: 12px
stroke-linecap: round
```
### 2. 容器样式
```css
/* 主容器 */
background: #ffffff
border-radius: 16px
padding: 32px
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12)
border: 3px solid #e8f4fd
/* 进度条容器 */
background: linear-gradient(135deg, #f8fcff 0%, #e8f4fd 100%)
border-radius: 12px
border-top: 3px solid #e8f4fd
```
### 3. 进度条样式
```css
/* 进度条背景 */
height: 12px
background: #e8f4fd
border-radius: 6px
border: 2px solid #d9ecff
/* 进度条填充 */
background: linear-gradient(90deg, #1890ff 0%, #40a9ff 100%)
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.3)
```
## 📱 响应式设计
### 桌面端 (>768px)
- 圆形进度图: 120px × 120px
- 字体大小: 24px (百分比)
- 间距: 正常间距
### 平板端 (≤768px)
- 圆形进度图: 100px × 100px
- 字体大小: 16px (百分比)
- 间距: 紧凑间距
### 移动端 (≤480px)
- 布局: 垂直排列
- 圆形进度图: 居中显示
- 最大宽度: 200px
## 🔮 图标占位符
当前使用emoji作为图标占位符
- 📚 课程
- 📝 作业
- 📋 考试
**等待您提供具体的图标文件后可以替换为实际的SVG图标。**
## 🚀 实际应用
学习进度统计组件已集成到课程详情页面(已报名状态):
### 访问路径
- 课程详情页面: `/course/1/enrolled`
- 位置: 右侧边栏的学习进度区域
### 显示内容
- 三个圆形进度图表(课程、作业、考试)
- 总体学习进度条
- 完成项目数统计 (13/54)
- 实时进度数据更新
## 🎯 设计图匹配度
**完全匹配的特性**:
- 三个圆形进度图表布局
- 粗边框圆环设计
- 蓝色主题色彩
- 百分比数字显示
- 底部总体进度条
- 进度数字显示 (13/54)
- 整体卡片样式
🔄 **待完善的特性**:
- 等待提供具体的图标文件
- 可能需要微调颜色深浅
- 可能需要调整具体的间距
## 📝 下一步计划
1. **图标替换**: 等待您提供三个中心点的图标文件
2. **颜色调整**: 根据实际需求微调颜色
3. **动画优化**: 可以添加更多动画效果
4. **数据集成**: 与实际的学习数据API集成
## 🔗 相关文件
- 组件文件: `src/components/common/LearningProgressStats.vue`
- 应用页面: `src/views/CourseDetailEnrolled.vue`
- 查看地址: http://localhost:3001/course/1/enrolled

View File

@ -7,7 +7,10 @@
<title>在线学习平台</title>
<meta name="description" content="专业的在线学习平台,提供优质的编程和技术课程">
<meta name="keywords" content="在线学习,编程课程,技术培训,Vue.js,React,Node.js">
<!-- CKPlayer 将通过本地包引入移除CDN引用 -->
<!-- CKPlayer CSS -->
<link rel="stylesheet" href="/ckplayer/css/ckplayer.css">
<!-- CKPlayer JS -->
<script src="/ckplayer/js/ckplayer.js"></script>
</head>
<body>
<div id="app"></div>

1540
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,6 @@
"@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",
@ -32,4 +31,3 @@
"vue-tsc": "^3.0.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

202
public/ckplayer/LICENSE Normal file
View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,104 @@
flv.js [![npm](https://img.shields.io/npm/v/flv.js.svg?style=flat)](https://www.npmjs.com/package/flv.js)
======
An HTML5 Flash Video (FLV) Player written in pure JavaScript without Flash. LONG LIVE FLV!
This project relies on [Media Source Extensions][] to work.
**For FLV live stream playback, please consider [mpegts.js][] which is under active development.**
**This project will become rarely maintained.**
[mpegts.js]: https://github.com/xqq/mpegts.js
## Overview
flv.js works by transmuxing FLV file stream into ISO BMFF (Fragmented MP4) segments, followed by feeding mp4 segments into an HTML5 `<video>` element through [Media Source Extensions][] API.
[Media Source Extensions]: https://w3c.github.io/media-source/
## Demo
[http://bilibili.github.io/flv.js/demo/](http://bilibili.github.io/flv.js/demo/)
## Features
- FLV container with H.264 + AAC / MP3 codec playback
- Multipart segmented video playback
- HTTP FLV low latency live stream playback
- FLV over WebSocket live stream playback
- Compatible with Chrome, FireFox, Safari 10, IE11 and Edge
- Extremely low overhead, and hardware accelerated by your browser!
## Installation
```bash
npm install --save flv.js
```
## Build
```bash
npm ci # install dependencies / dev-dependences
npm run build:debug # debug version flv.js will be emitted to /dist
npm run build # minimized release version flv.min.js will be emitted to /dist
```
[cnpm](https://github.com/cnpm/cnpm) mirror is recommended if you are in Mainland China.
## CORS
If you use standalone video server for FLV stream, `Access-Control-Allow-Origin` header must be configured correctly on video server for cross-origin resource fetching.
See [cors.md](docs/cors.md) for more details.
## Getting Started
```html
<script src="flv.min.js"></script>
<video id="videoElement"></video>
<script>
if (flvjs.isSupported()) {
var videoElement = document.getElementById('videoElement');
var flvPlayer = flvjs.createPlayer({
type: 'flv',
url: 'http://example.com/flv/video.flv'
});
flvPlayer.attachMediaElement(videoElement);
flvPlayer.load();
flvPlayer.play();
}
</script>
```
## Limitations
- MP3 audio codec is currently not working on IE11 / Edge
- HTTP FLV live stream is not currently working on all browsers, see [livestream.md](docs/livestream.md)
## Multipart playback
You only have to provide a playlist for `MediaDataSource`. See [multipart.md](docs/multipart.md)
## Livestream playback
See [livestream.md](docs/livestream.md)
## API and Configuration
See [api.md](docs/api.md)
## Debug
```bash
npm ci # install dependencies / dev-dependences
npm run dev # watch file changes and build debug version on the fly
```
## Design
See [design.md](docs/design.md)
## License
```
Copyright (C) 2016 Bilibili. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```

10585
public/ckplayer/flv.js/flv.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

10
public/ckplayer/flv.js/flv.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,28 @@
Copyright (c) 2017 Dailymotion (http://www.dailymotion.com)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project
are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls)
That work is also covered by the Apache 2 License, following copyright:
Copyright (c) 2013-2015 Brightcove
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,442 @@
[![npm](https://img.shields.io/npm/v/hls.js.svg?style=flat)](https://npmjs.org/package/hls.js)
[![npm](https://img.shields.io/npm/v/hls.js/canary.svg?style=flat)](https://www.npmjs.com/package/hls.js/v/canary)
[![](https://data.jsdelivr.com/v1/package/npm/hls.js/badge?style=rounded)](https://www.jsdelivr.com/package/npm/hls.js)
[![Sauce Test Status](https://saucelabs.com/buildstatus/robwalch)](https://app.saucelabs.com/u/robwalch)
[comment]: <> ([![Sauce Test Status]&#40;https://saucelabs.com/browser-matrix/robwalch.svg&#41;]&#40;https://saucelabs.com/u/robwalch&#41;)
# ![HLS.js](https://cloud.githubusercontent.com/assets/616833/19739063/e10be95a-9bb9-11e6-8100-2896f8500138.png)
HLS.js is a JavaScript library that implements an [HTTP Live Streaming] client.
It relies on [HTML5 video][] and [MediaSource Extensions][] for playback.
It works by transmuxing MPEG-2 Transport Stream and AAC/MP3 streams into ISO BMFF (MP4) fragments.
Transmuxing is performed asynchronously using a [Web Worker] when available in the browser.
HLS.js also supports HLS + fmp4, as announced during [WWDC2016](https://developer.apple.com/videos/play/wwdc2016/504/).
HLS.js works directly on top of a standard HTML`<video>` element.
HLS.js is written in [ECMAScript6] (`*.js`) and [TypeScript] (`*.ts`) (strongly typed superset of ES6), and transpiled in ECMAScript5 using [Babel](https://babeljs.io/) and the [TypeScript compiler].
[Webpack] is used to build the distro bundle and serve the local development environment.
[html5 video]: https://www.html5rocks.com/en/tutorials/video/basics/
[mediasource extensions]: https://w3c.github.io/media-source/
[http live streaming]: https://en.wikipedia.org/wiki/HTTP_Live_Streaming
[web worker]: https://caniuse.com/#search=worker
[ecmascript6]: https://github.com/ericdouglas/ES6-Learning#articles--tutorials
[typescript]: https://www.typescriptlang.org/
[typescript compiler]: https://www.typescriptlang.org/docs/handbook/compiler-options.html
[webpack]: https://webpack.js.org/
## Features
- VOD & Live playlists
- DVR support on Live playlists
- Fragmented MP4 container
- MPEG-2 TS container
- ITU-T Rec. H.264 and ISO/IEC 14496-10 Elementary Stream
- ISO/IEC 13818-7 ADTS AAC Elementary Stream
- ISO/IEC 11172-3 / ISO/IEC 13818-3 (MPEG-1/2 Audio Layer III) Elementary Stream
- Packetized metadata (ID3v2.3.0) Elementary Stream
- AAC container (audio only streams)
- MPEG Audio container (MPEG-1/2 Audio Layer III audio only streams)
- Timed Metadata for HTTP Live Streaming (in ID3 format, carried in MPEG-2 TS)
- AES-128 decryption
- SAMPLE-AES decryption (only supported if using MPEG-2 TS container)
- Encrypted media extensions (EME) support for DRM (digital rights management)
- Widevine CDM (only tested with [shaka-packager](https://github.com/google/shaka-packager) test-stream on [the demo page](https://hls-js.netlify.app/demo/?src=https%3A%2F%2Fstorage.googleapis.com%2Fshaka-demo-assets%2Fangel-one-widevine-hls%2Fhls.m3u8&demoConfig=eyJlbmFibGVTdHJlYW1pbmciOnRydWUsImF1dG9SZWNvdmVyRXJyb3IiOnRydWUsInN0b3BPblN0YWxsIjpmYWxzZSwiZHVtcGZNUDQiOmZhbHNlLCJsZXZlbENhcHBpbmciOi0xLCJsaW1pdE1ldHJpY3MiOi0xfQ==))
- CEA-608/708 captions
- WebVTT subtitles
- Alternate Audio Track Rendition (Master Playlist with Alternative Audio) for VoD and Live playlists
- Adaptive streaming
- Manual & Auto Quality Switching
- 3 Quality Switching modes are available (controllable through API means)
- Instant switching (immediate quality switch at current video position)
- Smooth switching (quality switch for next loaded fragment)
- Bandwidth conservative switching (quality switch change for next loaded fragment, without flushing the buffer)
- In Auto-Quality mode, emergency switch down in case bandwidth is suddenly dropping to minimize buffering.
- Accurate Seeking on VoD & Live (not limited to fragment or keyframe boundary)
- Ability to seek in buffer and back buffer without redownloading segments
- Built-in Analytics
- All internal events can be monitored (Network Events, Video Events)
- Playback session metrics are also exposed
- Resilience to errors
- Retry mechanism embedded in the library
- Recovery actions can be triggered fix fatal media or network errors
- [Redundant/Failover Playlists](https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/StreamingMediaGuide/UsingHTTPLiveStreaming/UsingHTTPLiveStreaming.html#//apple_ref/doc/uid/TP40008332-CH102-SW22)
### Supported M3U8 tags
For details on the HLS format and these tags' meanings, see https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-08
#### Manifest tags
- `#EXT-X-STREAM-INF:<attribute-list>`
`<URI>`
- `#EXT-X-MEDIA:<attribute-list>`
- `#EXT-X-SESSION-DATA:<attribute-list>`
The following properties are added to their respective variants' attribute list but are not implemented in their selection and playback.
- `VIDEO-RANGE` and `HDCP-LEVEL` (See [#2489](https://github.com/video-dev/hls.js/issues/2489))
#### Playlist tags
- `#EXTM3U`
- `#EXT-X-VERSION=<n>`
- `#EXTINF:<duration>,[<title>]`
- `#EXT-X-ENDLIST`
- `#EXT-X-MEDIA-SEQUENCE=<n>`
- `#EXT-X-TARGETDURATION=<n>`
- `#EXT-X-DISCONTINUITY`
- `#EXT-X-DISCONTINUITY-SEQUENCE=<n>`
- `#EXT-X-BYTERANGE=<n>[@<o>]`
- `#EXT-X-MAP:<attribute-list>`
- `#EXT-X-KEY:<attribute-list>` (`METHOD=SAMPLE-AES` is only supports with MPEG-2 TS segments)
- `#EXT-X-PROGRAM-DATE-TIME:<attribute-list>`
- `#EXT-X-START:TIME-OFFSET=<n>`
- `#EXT-X-SERVER-CONTROL:<attribute-list>`
- `#EXT-X-PART-INF:PART-TARGET=<n>`
- `#EXT-X-PART:<attribute-list>`
- `#EXT-X-PRELOAD-HINT:<attribute-list>`
- `#EXT-X-SKIP:<attribute-list>`
- `#EXT-X-RENDITION-REPORT:<attribute-list>`
The following tags are added to their respective fragment's attribute list but are not implemented in streaming and playback.
- `#EXT-X-DATERANGE:<attribute-list>` (Not added to metadata TextTracks. See [#2218](https://github.com/video-dev/hls.js/issues/2218))
- `#EXT-X-BITRATE` (Not used in ABR controller)
- `#EXT-X-GAP` (Not implemented. See [#2940](https://github.com/video-dev/hls.js/issues/2940))
### Not Supported
For a complete list of issues, see ["Top priorities" in the Release Planning and Backlog project tab](https://github.com/video-dev/hls.js/projects/6). Codec support is dependent on the runtime environment (for example, not all browsers on the same OS support HEVC).
- CMAF CC support [#2623](https://github.com/video-dev/hls.js/issues/2623)
- `Emsg` Inband Timed Metadata for FMP4 (ID3 within Emsgv1) in "metadata" TextTracks [#2360](https://github.com/video-dev/hls.js/issues/2360)
- `#EXT-X-DATERANGE` in "metadata" TextTracks [#2218](https://github.com/video-dev/hls.js/issues/2218)
- `#EXT-X-GAP` filling [#2940](https://github.com/video-dev/hls.js/issues/2940)
- `#EXT-X-I-FRAME-STREAM-INF` I-frame Media Playlist files
- `SAMPLE-AES` with fmp4, aac, mp3, vtt... segments (MPEG-2 TS only)
- PlayReady and FairPlay DRM ( See [#3779](https://github.com/video-dev/hls.js/issues/2360) and [issues labeled DRM](https://github.com/video-dev/hls.js/issues?q=is%3Aissue+is%3Aopen+label%3ADRM))
- Advanced variant selection based on runtime media capabilities (See issues labeled [`media-capabilities`](https://github.com/video-dev/hls.js/labels/media-capabilities))
- MP3 elementary stream audio in IE and Edge (<=18) on Windows 10 (See [#1641](https://github.com/video-dev/hls.js/issues/1641) and [Microsoft answers forum](https://answers.microsoft.com/en-us/ie/forum/all/ie11-on-windows-10-cannot-play-hls-with-mp3/2da994b5-8dec-4ae9-9201-7d138ede49d9))
### Server-side-rendering (SSR) and `require` from a Node.js runtime
You can safely require this library in Node and **absolutely nothing will happen**. A dummy object is exported so that requiring the library does not throw an error. HLS.js is not instantiable in Node.js. See [#1841](https://github.com/video-dev/hls.js/pull/1841) for more details.
## Getting started with development
First, checkout the repository and install the required dependencies
```sh
git clone https://github.com/video-dev/hls.js.git
cd hls.js
# After cloning or pulling from the repository, make sure all dependencies are up-to-date
npm install ci
# Run dev-server for demo page (recompiles on file-watch, but doesn't write to actual dist fs artifacts)
npm run dev
# After making changes run the sanity-check task to verify all checks before committing changes
npm run sanity-check
```
The dev server will host files on port 8000. Once started, the demo can be found running at http://localhost:8000/demo/.
Before submitting a PR, please see our [contribution guidelines](CONTRIBUTING.md).
Join the discussion on Slack via [video-dev.org](https://video-dev.org) in #hlsjs for updates and questions about development.
### Build tasks
Build all flavors (suitable for prod-mode/CI):
```
npm install ci
npm run build
```
Only debug-mode artifacts:
```
npm run build:debug
```
Build and watch (customized dev setups where you'll want to host through another server than webpacks' - for example in a sub-module/project)
```
npm run build:watch
```
Only specific flavor (known configs are: debug, dist, light, light-dist, demo):
```
npm run build -- --env dist # replace "dist" by other configuration name, see above ^
```
Note: The "demo" config is always built.
**NOTE:** `hls.light.*.js` dist files do not include EME, subtitles, CMCD, or alternate-audio support. In addition,
the following types are not available in the light build:
- `AudioStreamController`
- `AudioTrackController`
- `CuesInterface`
- `EMEController`
- `SubtitleStreamController`
- `SubtitleTrackController`
- `TimelineController`
- `CmcdController`
### Linter (ESlint)
Run linter:
```
npm run lint
```
Run linter with auto-fix mode:
```
npm run lint:fix
```
Run linter with errors only (no warnings)
```
npm run lint:quiet
```
### Formatting Code
Run prettier to format code
```
npm run prettier
```
### Type Check
Run type-check to verify TypeScript types
```
npm run type-check
```
### Automated tests (Mocha/Karma)
Run all tests at once:
```
npm test
```
Run unit tests:
```
npm run test:unit
```
Run unit tests in watch mode:
```
npm run test:unit:watch
```
Run functional (integration) tests:
```
npm run test:func
```
## Design
An overview of this project's design, it's modules, events, and error handling can be found [here](/docs/design.md).
## API docs and usage guide
- [API and usage docs, with code examples](./docs/API.md)
- [Auto-Generated API Docs (Latest Release)](https://hls-js.netlify.com/api-docs)
- [Auto-Generated API Docs (Development Branch)](https://hls-js-dev.netlify.com/api-docs)
_Note you can access the docs for a particular version using "[https://github.com/video-dev/hls.js/tree/deployments](https://github.com/video-dev/hls.js/tree/deployments)"_
## Demo
### Latest Release
[https://hls-js.netlify.com/demo](https://hls-js.netlify.com/demo)
### Master
[https://hls-js-dev.netlify.com/demo](https://hls-js-dev.netlify.com/demo)
### Specific Version
Find the commit on [https://github.com/video-dev/hls.js/tree/deployments](https://github.com/video-dev/hls.js/tree/deployments).
[![](https://www.netlify.com/img/global/badges/netlify-color-accent.svg)](https://www.netlify.com)
[![](https://opensource.saucelabs.com/images/opensauce/powered-by-saucelabs-badge-gray.png?sanitize=true)](https://saucelabs.com)
## Compatibility
HLS.js is only compatible with browsers supporting MediaSource extensions (MSE) API with 'video/MP4' mime-type inputs.
HLS.js is supported on:
- Chrome 39+ for Android
- Chrome 39+ for Desktop
- Firefox 41+ for Android
- Firefox 42+ for Desktop
- IE11 for Windows 8.1+
- Edge for Windows 10+
- Safari 8+ for MacOS 10.10+
- Safari for ipadOS 13+
A [Promise polyfill](https://github.com/taylorhakes/promise-polyfill) is required in browsers missing native promise support.
**Please note:** iOS Safari on iPhone does not support the MediaSource API. This includes all browsers on iOS as well as apps using UIWebView and WKWebView.
Safari browsers (iOS, iPadOS, and macOS) have built-in HLS support through the plain video "tag" source URL. See the example below (Using HLS.js) to run appropriate feature detection and choose between using HLS.js or natively built-in HLS support.
When a platform has neither MediaSource nor native HLS support, the browser cannot play HLS.
_Keep in mind that if the intention is to support HLS on multiple platforms, beyond those compatible with HLS.js, the HLS streams need to strictly follow the specifications of RFC8216, especially if apps, smart TVs, and set-top boxes are to be supported._
Find a support matrix of the MediaSource API here: https://developer.mozilla.org/en-US/docs/Web/API/MediaSource
## Using HLS.js
### Installation
Prepackaged builds are included [with each release](https://github.com/video-dev/hls.js/releases). Or install the hls.js as a dependency
of your project:
```sh
npm install --save hls.js
```
A canary channel is also available if you prefer to work off the development branch (master):
```
npm install hls.js@canary
```
### Embedding HLS.js
Directly include dist/hls.js or dist/hls.min.js in a script tag on the page. This setup prioritizes HLS.js MSE playback over
native browser support for HLS playback in HTMLMediaElements:
```html
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<!-- Or if you want a more recent alpha version -->
<!-- <script src="https://cdn.jsdelivr.net/npm/hls.js@alpha"></script> -->
<video id="video"></video>
<script>
var video = document.getElementById('video');
var videoSrc = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(videoSrc);
hls.attachMedia(video);
}
// HLS.js is not supported on platforms that do not have Media Source
// Extensions (MSE) enabled.
//
// When the browser has built-in HLS support (check using `canPlayType`),
// we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video
// element through the `src` property. This is using the built-in support
// of the plain video element, without using HLS.js.
//
// Note: it would be more normal to wait on the 'canplay' event below however
// on Safari (where you are most likely to find built-in HLS support) the
// video.src URL must be on the user-driven white-list before a 'canplay'
// event will be emitted; the last video event that can be reliably
// listened-for when the URL is not on the white-list is 'loadedmetadata'.
else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = videoSrc;
}
</script>
```
#### Alternative setup
To check for native browser support first and then fallback to HLS.js, swap these conditionals. See [this comment](https://github.com/video-dev/hls.js/pull/2954#issuecomment-670021358) to understand some of the tradeoffs.
```html
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<!-- Or if you want a more recent alpha version -->
<!-- <script src="https://cdn.jsdelivr.net/npm/hls.js@alpha"></script> -->
<video id="video"></video>
<script>
var video = document.getElementById('video');
var videoSrc = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
//
// First check for native browser HLS support
//
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = videoSrc;
//
// If no native HLS support, check if HLS.js is supported
//
} else if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(videoSrc);
hls.attachMedia(video);
}
</script>
```
For more embed and API examples see [docs/API.md](./docs/API.md).
## CORS
All HLS resources must be delivered with [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS) permitting `GET` requests.
## Video Control
Video is controlled through HTML `<video>` element `HTMLVideoElement` methods, events and optional UI controls (`<video controls>`).
## Player Integration
The following players integrate HLS.js for HLS playback:
- [JW Player](https://www.jwplayer.com)
- [Akamai Adaptive Media Player (AMP)](https://www.akamai.com/us/en/solutions/products/media-delivery/adaptive-media-player.jsp)
- [Clappr](https://github.com/clappr/clappr)
- [Flowplayer](https://www.flowplayer.org) through [flowplayer-hlsjs](https://github.com/flowplayer/flowplayer-hlsjs)
- [MediaElement.js](https://www.mediaelementjs.com)
- [Videojs](https://videojs.com) through [Videojs-hlsjs](https://github.com/benjipott/videojs-hlsjs)
- [Videojs](https://videojs.com) through [videojs-hls.js](https://github.com/streamroot/videojs-hls.js). hls.js is integrated as a SourceHandler -- new feature in Video.js 5.
- [Videojs](https://videojs.com) through [videojs-contrib-hls.js](https://github.com/Peer5/videojs-contrib-hls.js). Production ready plug-in with full fallback compatibility built-in.
- [Fluid Player](https://www.fluidplayer.com)
- [OpenPlayerJS](https://www.openplayerjs.com), as part of the [OpenPlayer project](https://github.com/openplayerjs)
- [CDNBye](https://github.com/cdnbye/hlsjs-p2p-engine), a p2p engine for hls.js powered by WebRTC Datachannel.
### They use HLS.js in production!
| | | | |
| :----------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| [<img src="https://i.cdn.turner.com/adultswim/big/img/global/adultswim.jpg" width="120">](https://www.adultswim.com/streams) | [<img src="https://avatars3.githubusercontent.com/u/5497190?s=200&v=4" width="120">](https://www.akamai.com) | [<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/1/1a/Canal%2B.svg/2000px-Canal%2B.svg.png" width="120">](https://www.canalplus.fr) | [<img src="https://avatars2.githubusercontent.com/u/115313" width="120">](https://www.dailymotion.com) |
| [<img src="https://user-images.githubusercontent.com/4006693/44003595-baff193c-9e8f-11e8-9848-7bb91563499f.png" width="120">](https://freshlive.tv) | [<img src="https://flowplayer.org/media/img/logo-blue.png" width="120">](https://flowplayer.com) | [<img src="https://avatars1.githubusercontent.com/u/12554082?s=240" width="120">](https://www.foxsports.com.au) | [<img src="https://cloud.githubusercontent.com/assets/244265/12556435/dfaceb48-c353-11e5-971b-2c4429725469.png" width="120">](https://www.globo.com) |
| [<img src="https://images.gunosy.com/logo/gunosy_icon_company_logo.png" width="120">](https://gunosy.com) | [<img src="https://user-images.githubusercontent.com/1480052/35802840-f8e85b8a-0a71-11e8-8eb2-eee323e3f159.png" width="120">](https://www.gl-systemhaus.de/) | [<img src="https://cloud.githubusercontent.com/assets/6525783/20801836/700490de-b7ea-11e6-82bd-e249f91c7bae.jpg" width="120">](https://nettrek.de) | [<img src="https://cloud.githubusercontent.com/assets/244265/12556385/999aa884-c353-11e5-9102-79df54384498.png" width="120">](https://www.nytimes.com/) |
| [<img src="https://cloud.githubusercontent.com/assets/1798553/20356424/ba158574-ac24-11e6-95e1-1ae591b11a0a.png" width="120">](https://www.peer5.com/) | [<img src="https://cloud.githubusercontent.com/assets/4909096/20925062/e26e6fc8-bbb4-11e6-99a5-d4762274a342.png" width="120">](https://www.qbrick.com) | [<img src="https://www.radiantmediaplayer.com/images/radiantmediaplayer-new-logo-640.jpg" width="120">](https://www.radiantmediaplayer.com/) | [<img src="https://www.rts.ch/hummingbird-static/images/logos/logo_marts.svg" width="120">](https://www.rts.ch) |
| [<img src="https://cloud.githubusercontent.com/assets/12702747/19316434/0a3601de-9067-11e6-85e2-936b1cb099a0.png" width="120">](https://www.snapstream.com/) | [<img src="https://pamediagroup.com/wp-content/uploads/2019/05/StreamAMG-Logo-RGB.png" width="120">](https://www.streamamg.com/) | [<img src="https://streamsharkio.sa.metacdn.com/wp-content/uploads/2015/10/streamshark-dark.svg" width="120">](https://streamshark.io/) | [<img src="https://camo.githubusercontent.com/9580f10e9bfa8aa7fba52c5cb447bee0757e33da/68747470733a2f2f7777772e7461626c6f74762e636f6d2f7374617469632f696d616765732f7461626c6f5f6c6f676f2e706e67" width="120">](https://my.tablotv.com/) |
| [<img src="https://user-images.githubusercontent.com/2803310/34083705-349c8fd0-e375-11e7-92a6-5c38509f4936.png" width="120">](https://www.streamroot.io/) | [<img src="https://vignette1.wikia.nocookie.net/tedtalks/images/c/c0/TED_logo.png/revision/20150915192527" width="120">](https://www.ted.com/) | [<img src="https://www.seeklogo.net/wp-content/uploads/2014/12/twitter-logo-vector-download.jpg" width="120">](https://twitter.com/) | [<img src="https://player.clevercast.com/img/clevercast.png" width="120">](https://www.clevercast.com) |
| [<img src="https://player.mtvnservices.com/edge/hosted/Viacom_logo.svg" width="120">](https://www.viacom.com/) | [<img src="https://user-images.githubusercontent.com/1181974/29248959-efabc440-802d-11e7-8050-7c1f4ca6c607.png" width="120">](https://vk.com/) | [<img src="https://avatars0.githubusercontent.com/u/5090060?s=200&v=4" width="120">](https://www.jwplayer.com) | [<img src="https://staticftv-a.akamaihd.net/arches/francetv/default/img/og-image.jpg?20161007" width="120">](https://www.france.tv) |
| [<img src="https://showmax.akamaized.net/e/logo/showmax_black.png" width="120">](https://tech.showmax.com) | [<img src="https://static3.1tv.ru/assets/web/logo-ac67852f1625b338f9d1fb96be089d03557d50bfc5790d5f48dc56799f59dec6.svg" width="120" height="120">](https://www.1tv.ru/) | [<img src="https://user-images.githubusercontent.com/1480052/40482633-c013ebce-5f55-11e8-96d5-b776415de0ce.png" width="120">](https://www.zdf.de) | [<img src="https://github.com/cdnbye/hlsjs-p2p-engine/blob/master/figs/cdnbye.png" width="120">](https://github.com/cdnbye/hlsjs-p2p-engine) |
| [cdn77](https://streaming.cdn77.com/) | [<img src="https://avatars0.githubusercontent.com/u/7442371?s=200&v=4" width="120">](https://r7.com/) | [<img src="https://raw.githubusercontent.com/Novage/p2p-media-loader/gh-pages/images/p2pml-logo.png" width="120">](https://github.com/Novage/p2p-media-loader) | [<img src="https://avatars3.githubusercontent.com/u/45617200?s=400" width="120">](https://kayosports.com.au) |
| [<img src="https://avatars1.githubusercontent.com/u/5279615?s=400&u=9771a216836c613f1edf4afe71cfc69d4c5657ed&v=4" width="120">](https://flosports.tv) | [<img src="https://www.logolynx.com/images/logolynx/c6/c67a2cb3ad33a82b5518f8ad8f124703.png" width="120">](https://global.axon.com/) | | |
## Chrome/Firefox integration
made by [gramk](https://github.com/gramk/chrome-hls), plays hls from address bar and m3u8 links
- Chrome [native-hls](https://chrome.google.com/webstore/detail/native-hls-playback/emnphkkblegpebimobpbekeedfgemhof)
- Firefox [native-hls](https://addons.mozilla.org/en-US/firefox/addon/native_hls_playback/)
## License
HLS.js is released under [Apache 2.0 License](LICENSE)

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

27804
public/ckplayer/hls.js/hls.js Normal file

File diff suppressed because it is too large Load Diff

2837
public/ckplayer/hls.js/hls.js.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
public/ckplayer/hls.js/hls.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

7
public/ckplayer/js/ckplayer.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,72 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.ckplayerLanguage = factory());
}(this, function () { 'use strict';
/*
* 功能包含播放器用到的全部相关语言文字
*/
var language={
play:'Play',
pause:'Pause',
refresh:'Refresh',
full:'Fullscreen',
exitFull:'Non-Fullscreen',
webFull:'Web fullscreen',
exitWebFull:'Non-Web fullscreen',
theatre:'Theatre',
exitTheatre:'Non-theatre',
volume:'Volume',
muted:'Mute',
exitmuted:'Unmute',
seek:'Seek',
waiting:'Waiting',
live:'Liveing',
backLive:'Back live',
lookBack:'Look back',
next:'Next episode',
screenshot:'Screenshot',
smallwindows:'Small windows',
playbackrate:'Speed',
playbackrateSuffix:' Speed',
track:'Subtitle',
noTrack:'No subtitle',
definition:'Definition',
switchTo:'Switched from',
closeTime:'The advertisement can be closed in {seconds} seconds',
closeAd:'Close ad',
second:'seconds',
details:'View details',
copy:'Copy',
copySucceeded:'Copy succeeded, can be pasted!',
smallwindowsOpen:'The small window function is turned on',
screenshotStart:'Screenshot, please wait...',
smallwindowsClose:'The small window function is turned off',
screenshotClose:'Screenshot function is turned off',
loopOpen:'Loop open',
loopClose:'Loop close',
close:'Close',
down:'Down',
p50:'50%',
p75:'75%',
p100:'100%',
timeScheduleAdjust:{
prohibit:'No dragging',
prohibitBackOff:'No repeat viewing',
prohibitForward:'Fast forward prohibited',
prohibitLookBack:'Some content is forbidden to play',
prohibitForwardNotViewed:'Disable playback of parts not viewed'
},
error:{
noMessage:'Unknown error',
supportVideoError:'The browser version is too low. It is recommended to replace it with another browser',
videoTypeError:'This browser does not support playing this video. It is recommended to replace it with another browser',
loadingFailed:'Loading failed',
emptied:'An error occurred while loading the frequency file',
screenshot:'Screenshot failed',
ajax:'Ajax data request error',
noVideoContainer:'No video container'
}
};
return language;
}))

View File

@ -0,0 +1,72 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.ckplayerLanguage = factory());
}(this, function () { 'use strict';
/*
* 功能包含播放器用到的全部相关语言文字
*/
var language={
play:'播放',
pause:'暂停',
refresh:'重播',
full:'全屏',
exitFull:'退出全屏',
webFull:'页面全屏',
exitWebFull:'退出页面全屏',
theatre:'剧场模式',
exitTheatre:'退出剧场模式',
volume:'音量:',
muted:'静音',
exitmuted:'恢复音量',
seek:'seek',
waiting:'缓冲',
live:'直播中',
backLive:'返回直播',
lookBack:'回看:',
next:'下一集',
screenshot:'视频截图',
smallwindows:'小窗口播放功能',
playbackrate:'倍速',
playbackrateSuffix:'倍',
track:'字幕',
noTrack:'无字幕',
definition:'清晰度',
switchTo:'切换成:',
closeTime:'{seconds}秒后可关闭广告',
closeAd:'关闭广告',
second:'秒',
details:'查看详情',
copy:'复制',
copySucceeded:'复制成功,可贴粘!',
smallwindowsOpen:'小窗口功能已开启',
smallwindowsClose:'小窗口功能已关闭',
screenshotStart:'截图中,请稍候...',
screenshotClose:'截图功能已关闭',
loopOpen:'循环播放',
loopClose:'已关闭循环播放',
close:'关闭',
down:'下载',
p50:'50%',
p75:'75%',
p100:'100%',
timeScheduleAdjust:{
prohibit:'视频禁止拖动',
prohibitBackOff:'视频禁止重复观看',
prohibitForward:'视频禁止快进',
prohibitLookBack:'视频禁止播放部分内容',
prohibitForwardNotViewed:'视频禁止播放未观看的部分'
},
error:{
noMessage:'未知错误',
supportVideoError:'该浏览器版本太低,建议更换成其它浏览器',
videoTypeError:'该浏览器不支持播放该视频,建议更换成其它浏览器',
loadingFailed:'加载失败',
emptied:'视频文件加载过程中出现错误',
screenshot:'视频截图失败',
ajax:'Ajax数据请求错误',
noVideoContainer:'未找到放置视频的容器'
}
};
return language;
}))

View File

@ -0,0 +1,72 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.ckplayerLanguage = factory());
}(this, function () { 'use strict';
/*
*功能包含播放機用到的全部相關語言文字
*/
var language = {
play: '播放',
pause: '暫停',
refresh: '重播',
full: '全屏',
exitFull: '退出全屏',
webFull: '頁面全屏',
exitWebFull: '退出頁面全屏',
theatre: '劇場模式',
exitTheatre: '退出劇場模式',
volume: '音量:',
muted: '靜音',
exitmuted: '恢復音量',
seek: 'seek:',
waiting: '緩衝',
live: '直播中',
backLive: '返回直播',
lookBack: '回看:',
next: '下一集',
screenshot: '視頻截圖',
smallwindows: '小視窗播放功能',
playbackrate: '倍速',
playbackrateSuffix: '倍',
track: '字幕',
noTrack: '無字幕',
definition: '清晰度',
switchTo: '切換成:',
closeTime: '{seconds}秒後可關閉廣告',
closeAd: '關閉廣告',
second: '秒',
details: '查看詳情',
copy: '複製',
copySucceeded: '複製成功,可貼粘!',
smallwindowsOpen: '小視窗功能已開啟',
smallwindowsClose: '小視窗功能已關閉',
screenshotStart: '截圖中,請稍候…',
screenshotClose: '截圖功能已關閉',
loopOpen: '迴圈播放',
loopClose: '已關閉迴圈播放',
close: '關閉',
down: '下載',
p50: '50%',
p75: '75%',
p100: '100%',
timeScheduleAdjust: {
prohibit: '視頻禁止拖動',
prohibitBackOff: '視頻禁止重複觀看',
prohibitForward: '視頻禁止快進',
prohibitLookBack: '視頻禁止播放部分內容',
prohibitForwardNotViewed: '視頻禁止播放未觀看的部分'
},
error: {
noMessage: '未知錯誤',
supportVideoError: '該流覽器版本太低,建議更換成其它瀏覽器',
videoTypeError: '該瀏覽器不支持播放該視頻,建議更換成其它瀏覽器',
loadingFailed: '加載失敗',
emptied: '視頻檔案加載過程中出現錯誤',
screenshot: '視頻截圖失敗',
ajax: 'Ajax數據請求錯誤',
noVideoContainer: '未找到放置視頻的容器'
}
};
return language;
}))

View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

BIN
public/video/first.mp4 Normal file

Binary file not shown.

191
src/api/mock/courses.ts Normal file
View File

@ -0,0 +1,191 @@
// 课程相关的Mock数据
// import type { CourseSection } from '../types'
// Course 和 CourseSection 类型暂时注释,后续需要时再启用
// Mock课程列表数据
export 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,
difficulty: 1
},
{
id: 2,
name: "Python编程基础与实战",
cover: "https://images.unsplash.com/photo-1526379095098-d400fd0bf935?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
categoryId: 2,
price: "199.00",
school: "编程技术学院",
description: "从零开始学习Python编程涵盖基础语法、数据结构、面向对象编程等核心概念通过实际项目练习掌握Python开发技能。",
teacherId: 2,
outline: "Python基础语法、数据类型、控制结构、函数、面向对象、文件操作、异常处理、模块和包、项目实战",
prerequisite: "无需编程基础,适合零基础学员",
target: "掌握Python编程基础能够独立完成简单的Python项目",
arrangement: "理论讲解 + 代码演示 + 实战练习",
startTime: "2025-01-20 09:00:00",
endTime: "2025-04-20 18:00:00",
revision: 1,
position: "Python开发专家",
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null,
difficulty: 1
},
{
id: 3,
name: "Vue.js 3 完整开发教程",
cover: "https://images.unsplash.com/photo-1555066931-4365d14bab8c?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
categoryId: 1,
price: "299.00",
school: "前端技术学院",
description: "深入学习Vue.js 3框架包括Composition API、TypeScript集成、状态管理、路由配置等现代前端开发技术。",
teacherId: 3,
outline: "Vue.js基础、组件开发、Composition API、状态管理、路由、项目构建、性能优化",
prerequisite: "具备HTML、CSS、JavaScript基础",
target: "掌握Vue.js 3开发技能能够独立开发前端项目",
arrangement: "视频教学 + 代码实战 + 项目练习",
startTime: "2025-02-01 10:00:00",
endTime: "2025-05-01 20:00:00",
revision: 1,
position: "前端架构师",
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null,
difficulty: 2
},
{
id: 4,
name: "React 18 实战开发",
cover: "https://images.unsplash.com/photo-1633356122544-f134324a6cee?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
categoryId: 1,
price: "399.00",
school: "前端技术学院",
description: "掌握React 18的新特性包括并发渲染、Suspense、Hooks等高级功能通过实际项目提升React开发技能。",
teacherId: 4,
outline: "React基础、组件设计、Hooks、状态管理、路由、测试、部署",
prerequisite: "具备JavaScript ES6+基础,了解前端开发流程",
target: "精通React开发能够构建复杂的单页应用",
arrangement: "理论 + 实践 + 项目驱动",
startTime: "2025-02-15 14:00:00",
endTime: "2025-06-15 22:00:00",
revision: 1,
position: "React技术专家",
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null,
difficulty: 3
},
{
id: 5,
name: "Node.js 后端开发实战",
cover: "https://images.unsplash.com/photo-1627398242454-45a1465c2479?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
categoryId: 3,
price: "349.00",
school: "后端技术学院",
description: "学习Node.js后端开发包括Express框架、数据库操作、API设计、身份验证、部署等全栈开发技能。",
teacherId: 5,
outline: "Node.js基础、Express框架、数据库、API设计、身份验证、测试、部署",
prerequisite: "具备JavaScript基础了解HTTP协议",
target: "掌握Node.js后端开发能够构建完整的后端服务",
arrangement: "理论讲解 + 实战项目 + 部署实践",
startTime: "2025-03-01 09:00:00",
endTime: "2025-07-01 18:00:00",
revision: 1,
position: "全栈开发工程师",
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null,
difficulty: 2
}
]
// Mock课程详情数据
export const getMockCourseDetail = (id: number) => {
const course = mockCourses.find(c => c.id === id)
if (!course) return null
return course
}
// Mock课程章节数据
export const getMockCourseSections = (lessonId: number) => {
return [
{
id: 1,
name: "第一章:课程介绍",
outline: "本章节介绍课程的基本概念和学习目标,帮助学员了解课程结构和学习方法。包含课程概述、学习目标、课程安排等内容。",
lessonId: lessonId,
sortOrder: 1,
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null
},
{
id: 2,
name: "第二章:基础理论",
outline: "深入学习相关的基础理论知识,为后续的实践学习打下坚实的理论基础。涵盖核心概念、基本原理、理论框架等。",
lessonId: lessonId,
sortOrder: 2,
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null
},
{
id: 3,
name: "第三章:实践应用",
outline: "通过实际案例和动手练习,将理论知识应用到实践中。包含案例分析、实操演示、练习题目等。",
lessonId: lessonId,
sortOrder: 3,
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null
},
{
id: 4,
name: "第四章:高级技巧",
outline: "学习高级技巧和最佳实践,提升专业技能水平。涵盖进阶方法、优化技巧、专业工具使用等。",
lessonId: lessonId,
sortOrder: 4,
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null
},
{
id: 5,
name: "第五章:项目实战",
outline: "通过完整的项目实战,综合运用所学知识。包含项目规划、实施过程、成果展示等环节。",
lessonId: lessonId,
sortOrder: 5,
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null
},
{
id: 6,
name: "第六章:考试测验",
outline: "通过在线考试检验学习成果,包含单选题、多选题、判断题、填空题和简答题等多种题型。",
lessonId: lessonId,
sortOrder: 6,
createdAt: 1737944724,
updatedAt: 1737944724,
updatedTime: null
}
]
}

View File

@ -175,6 +175,10 @@ request.interceptors.response.use(
}
)
// 导入Mock数据
import { mockCourses, getMockCourseSections } from './mock/courses'
// getMockCourseDetail 暂时注释,后续需要时再启用
// Mock数据处理
const handleMockRequest = async <T = any>(url: string, method: string, data?: any): Promise<ApiResponse<T>> => {
console.log('🚀 Mock Request:', { url, method, data })
@ -364,73 +368,6 @@ const handleMockRequest = async <T = any>(url: string, method: string, data?: an
// 课程列表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: '查询课程列表成功',
@ -441,7 +378,31 @@ const handleMockRequest = async <T = any>(url: string, method: string, data?: an
} as ApiResponse<T>
}
// 课程章节列表Mock
if (url === '/lesson/section/list' && method === 'GET') {
const lessonId = data?.lesson_id
console.log('课程章节Mock - 获取到的lesson_id:', lessonId, '原始data:', data)
if (!lessonId) {
return {
code: 400,
message: '课程ID必填',
data: null
} as ApiResponse<T>
}
const mockSections = getMockCourseSections(parseInt(lessonId))
return {
code: 200,
message: '获取成功',
data: {
list: mockSections,
total: mockSections.length
},
timestamp: new Date().toISOString()
} as ApiResponse<T>
}
// 默认404响应
return {

View File

@ -1,33 +1,24 @@
<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>
<!-- CKPlayer 容器 -->
<div
v-if="videoUrl"
:id="playerId"
class="ckplayer-container">
</div>
<!-- 播放按钮覆盖层 -->
<div v-if="showPlayButton" class="play-overlay" @click="togglePlay">
<div class="play-button">
<!-- 视频占位符 -->
<div v-else class="video-placeholder" :style="{ backgroundImage: poster ? `url(${poster})` : '' }">
<div class="placeholder-content">
<div class="play-icon">
<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>
<p>{{ placeholder || '请选择要播放的视频' }}</p>
</div>
</div>
<!-- 加载状态 -->
@ -55,70 +46,6 @@
<button class="retry-button" @click="retryLoad">重试</button>
</div>
</div>
<!-- 自定义控制栏 -->
<div v-if="controlsVisible && !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>
<!-- 视频信息 -->
@ -131,21 +58,33 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import Hls from 'hls.js'
// CKPlayer
declare global {
interface Window {
ckplayer: any
loadedHandler: () => void
endedHandler: () => void
errorHandler: (error: any) => void
}
}
// Props
interface Props {
videoUrl: string
videoUrl?: string
title?: string
description?: string
poster?: string
autoplay?: boolean
showControls?: boolean
placeholder?: string
useLocalVideo?: boolean // 使
}
const props = withDefaults(defineProps<Props>(), {
autoplay: false,
showControls: true
showControls: true,
useLocalVideo: false
})
// Emits
@ -158,276 +97,259 @@ const emit = defineEmits<{
}>()
// Refs
const videoElement = ref<HTMLVideoElement>()
const videoContainer = ref<HTMLDivElement>()
// HLS
let hls: Hls | null = null
// CKPlayer
let ckplayer = ref<any>(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 currentTime = ref(0) //
// const duration = ref(0) //
//
const controlsVisible = ref(true)
const showPlayButton = ref(true)
const showPreview = ref(false)
const previewPosition = ref(0)
const previewTime = ref(0)
// ID
const playerId = ref(`ckplayer_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`)
//
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 -
const videoUrl = computed(() => {
if (props.useLocalVideo) {
// 使public
return '/video/first.mp4'
}
return props.videoUrl || ''
})
// URL
watch(() => props.videoUrl, (newUrl) => {
watch(() => videoUrl.value, (newUrl) => {
console.log('VideoPlayer: 视频URL变化:', newUrl)
if (newUrl && videoElement.value) {
loadVideo()
if (newUrl) {
nextTick(() => {
initCKPlayer(newUrl)
})
}
})
//
const onLoadedMetadata = () => {
if (videoElement.value) {
duration.value = videoElement.value.duration
loading.value = false
error.value = false
// useLocalVideo
watch(() => props.useLocalVideo, () => {
if (videoUrl.value) {
nextTick(() => {
initCKPlayer(videoUrl.value)
})
}
}
})
const onTimeUpdate = () => {
if (videoElement.value) {
currentTime.value = videoElement.value.currentTime
emit('timeupdate', currentTime.value)
// CKPlayer
const loadCKPlayerScript = (): Promise<void> => {
return new Promise((resolve, reject) => {
//
if (window.ckplayer) {
resolve()
return
}
// CSS
const cssLink = document.createElement('link')
cssLink.rel = 'stylesheet'
cssLink.href = '/ckplayer/css/ckplayer.css'
document.head.appendChild(cssLink)
// JS
const script = document.createElement('script')
script.src = '/ckplayer/js/ckplayer.js'
script.onload = () => {
console.log('CKPlayer script loaded successfully')
resolve()
}
script.onerror = () => {
console.error('Failed to load CKPlayer script')
reject(new Error('Failed to load CKPlayer script'))
}
document.head.appendChild(script)
})
}
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
// CKPlayer
const initCKPlayer = async (url: string) => {
if (!url) 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
// CKPlayer
await loadCKPlayerScript()
//
if (ckplayer.value) {
try {
ckplayer.value.remove()
} catch (e) {
console.warn('Failed to remove previous player:', e)
}
ckplayer.value = null
}
// HLS
if (props.videoUrl.includes('.m3u8')) {
// 使HLS.jsHLS
if (Hls.isSupported()) {
hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90
})
// DOM
await nextTick()
hls.loadSource(props.videoUrl)
hls.attachMedia(videoElement.value)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('HLS manifest parsed successfully')
//
const container = document.getElementById(playerId.value)
if (!container) {
console.error('CKPlayer container not found:', playerId.value)
error.value = true
loading.value = false
return
}
//
const isHLS = url.includes('.m3u8')
const isMP4 = url.includes('.mp4')
console.log('Initializing CKPlayer with:', {
url,
isHLS,
isMP4,
container: playerId.value
})
hls.on(Hls.Events.ERROR, (_event, data) => {
console.error('HLS error:', data)
if (data.fatal) {
// CKPlayer
const videoObject = {
container: `#${playerId.value}`,
autoplay: props.autoplay,
video: url,
volume: 0.8,
poster: props.poster || '',
live: false,
plug: isHLS ? 'hls.js' : '',
playbackrateOpen: true,
playbackrateList: [0.5, 0.75, 1, 1.25, 1.5, 2],
seek: 0,
loaded: 'loadedHandler',
ended: 'endedHandler',
error: 'errorHandler',
title: props.title || '视频播放',
controls: props.showControls,
webFull: true,
screenshot: true,
timeScheduleAdjust: 1,
...(isMP4 && {
type: 'mp4',
crossOrigin: 'anonymous'
})
}
//
ckplayer.value = new window.ckplayer(videoObject)
console.log('CKPlayer initialized successfully')
} catch (err) {
console.error('Failed to initialize CKPlayer:', err)
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')
}
// CKPlayer
const setupCKPlayerCallbacks = () => {
//
window.loadedHandler = () => {
console.log('CKPlayer loaded successfully')
loading.value = false
error.value = false
emit('play')
}
//
window.endedHandler = () => {
console.log('Video playback ended')
isPlaying.value = false
emit('ended')
}
//
window.errorHandler = (error: any) => {
console.error('CKPlayer error:', error)
error.value = true
loading.value = false
emit('error', new Event('error'))
}
} else {
//
videoElement.value.src = props.videoUrl
videoElement.value.load()
}
//
const play = () => {
if (ckplayer.value) {
ckplayer.value.play()
isPlaying.value = true
emit('play')
}
}
const pause = () => {
if (ckplayer.value) {
ckplayer.value.pause()
isPlaying.value = false
emit('pause')
}
}
const seek = (time: number) => {
if (ckplayer.value) {
ckplayer.value.seek(time)
}
}
const setVolume = (vol: number) => {
if (ckplayer.value) {
ckplayer.value.volume(vol / 100)
}
}
const retryLoad = () => {
loadVideo()
}
//
let hideControlsTimer: number | null = null
const showControls = () => {
controlsVisible.value = true
if (hideControlsTimer) {
clearTimeout(hideControlsTimer)
if (videoUrl.value) {
initCKPlayer(videoUrl.value)
}
hideControlsTimer = window.setTimeout(() => {
if (isPlaying.value) {
controlsVisible.value = false
}
}, 3000)
}
const onMouseMove = () => {
showControls()
}
//
onMounted(() => {
nextTick(() => {
if (props.videoUrl) {
loadVideo()
}
// CKPlayer
setupCKPlayerCallbacks()
//
if (videoContainer.value) {
videoContainer.value.addEventListener('mousemove', onMouseMove)
}
// URL
if (videoUrl.value) {
nextTick(() => {
initCKPlayer(videoUrl.value)
})
}
})
onUnmounted(() => {
if (hideControlsTimer) {
clearTimeout(hideControlsTimer)
//
if (ckplayer.value) {
try {
ckplayer.value.remove()
} catch (e) {
console.warn('Failed to remove player on unmount:', e)
}
ckplayer.value = null
}
if (videoContainer.value) {
videoContainer.value.removeEventListener('mousemove', onMouseMove)
}
// HLS
if (hls) {
hls.destroy()
hls = null
}
//
if ((window as any).loadedHandler) delete (window as any).loadedHandler
if ((window as any).endedHandler) delete (window as any).endedHandler
if ((window as any).errorHandler) delete (window as any).errorHandler
})
//
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
}
}
play,
pause,
seek,
setVolume,
retry: retryLoad
})
</script>
@ -447,36 +369,59 @@ defineExpose({
overflow: hidden;
}
.video-element {
/* CKPlayer 容器 */
.ckplayer-container {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
/* 播放按钮覆盖层 */
.play-overlay {
/* 视频占位符 */
.video-placeholder {
position: relative;
width: 100%;
height: 100%;
background: #000;
background-size: cover;
background-position: center;
display: flex;
align-items: center;
justify-content: center;
}
.video-placeholder::before {
content: '';
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;
background: rgba(0, 0, 0, 0.5);
}
.play-button {
.placeholder-content {
position: relative;
z-index: 1;
text-align: center;
color: white;
}
.play-icon {
margin-bottom: 16px;
cursor: pointer;
transition: transform 0.3s;
}
.play-overlay:hover .play-button {
.play-icon:hover {
transform: scale(1.1);
}
.placeholder-content p {
margin: 0;
font-size: 16px;
opacity: 0.9;
}
/* 加载状态 */
.loading-overlay {
position: absolute;
@ -490,6 +435,7 @@ defineExpose({
justify-content: center;
background: rgba(0, 0, 0, 0.8);
color: white;
z-index: 10;
}
.loading-spinner {
@ -508,6 +454,7 @@ defineExpose({
justify-content: center;
background: rgba(0, 0, 0, 0.8);
color: white;
z-index: 10;
}
.error-content {
@ -529,137 +476,16 @@ defineExpose({
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;
/* CKPlayer 样式覆盖 */
:deep(.ckplayer) {
width: 100% !important;
height: 100% !important;
}
.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;
:deep(.ckplayer video) {
width: 100% !important;
height: 100% !important;
object-fit: contain;
}
/* 视频信息 */
@ -684,23 +510,28 @@ defineExpose({
/* 响应式设计 */
@media (max-width: 768px) {
.video-controls {
.video-player-wrapper {
border-radius: 0;
}
.video-container {
aspect-ratio: 16/9;
}
.placeholder-content p {
font-size: 14px;
}
.video-info {
padding: 12px;
}
.controls-row {
flex-direction: column;
gap: 8px;
.video-title {
font-size: 16px;
}
.controls-left,
.controls-right {
width: 100%;
justify-content: center;
}
.time-display {
font-size: 12px;
.video-description {
font-size: 13px;
}
}
</style>

View File

@ -0,0 +1,421 @@
<template>
<div class="learning-progress-stats">
<!-- 三个圆形进度图表 -->
<div class="progress-circles">
<!-- 课程进度 -->
<div class="progress-item">
<div class="circle-container">
<svg class="progress-circle" width="80" height="80" viewBox="0 0 80 80">
<!-- 背景圆环 -->
<circle
cx="40"
cy="40"
r="32"
stroke="#d9ecff"
stroke-width="8"
fill="none"
class="progress-bg"
/>
<!-- 进度圆环 -->
<circle
cx="40"
cy="40"
r="32"
stroke="#1890ff"
stroke-width="8"
fill="none"
stroke-linecap="round"
class="progress-fill"
:stroke-dasharray="circumference"
:stroke-dashoffset="circumference - (circumference * courseProgress / 100)"
/>
</svg>
<!-- 中心图标和文字 -->
<div class="circle-content">
<div class="progress-icon">
<!-- 课程图标占位等待您提供具体图标 -->
<div class="icon-placeholder course-icon">📚</div>
</div>
<div class="progress-label">课程</div>
</div>
</div>
<div class="progress-percentage">{{ courseProgress.toFixed(1) }}%</div>
</div>
<!-- 作业进度 -->
<div class="progress-item">
<div class="circle-container">
<svg class="progress-circle" width="80" height="80" viewBox="0 0 80 80">
<!-- 背景圆环 -->
<circle
cx="40"
cy="40"
r="32"
stroke="#d9ecff"
stroke-width="8"
fill="none"
class="progress-bg"
/>
<!-- 进度圆环 -->
<circle
cx="40"
cy="40"
r="32"
stroke="#1890ff"
stroke-width="8"
fill="none"
stroke-linecap="round"
class="progress-fill"
:stroke-dasharray="circumference"
:stroke-dashoffset="circumference - (circumference * homeworkProgress / 100)"
/>
</svg>
<!-- 中心图标和文字 -->
<div class="circle-content">
<div class="progress-icon">
<!-- 作业图标占位等待您提供具体图标 -->
<div class="icon-placeholder homework-icon">📝</div>
</div>
<div class="progress-label">作业</div>
</div>
</div>
<div class="progress-percentage">{{ homeworkProgress.toFixed(1) }}%</div>
</div>
<!-- 考试进度 -->
<div class="progress-item">
<div class="circle-container">
<svg class="progress-circle" width="80" height="80" viewBox="0 0 80 80">
<!-- 背景圆环 -->
<circle
cx="40"
cy="40"
r="32"
stroke="#d9ecff"
stroke-width="8"
fill="none"
class="progress-bg"
/>
<!-- 进度圆环 -->
<circle
cx="40"
cy="40"
r="32"
stroke="#1890ff"
stroke-width="8"
fill="none"
stroke-linecap="round"
class="progress-fill"
:stroke-dasharray="circumference"
:stroke-dashoffset="circumference - (circumference * examProgress / 100)"
/>
</svg>
<!-- 中心图标和文字 -->
<div class="circle-content">
<div class="progress-icon">
<!-- 考试图标占位等待您提供具体图标 -->
<div class="icon-placeholder exam-icon">📋</div>
</div>
<div class="progress-label">考试</div>
</div>
</div>
<div class="progress-percentage">{{ examProgress.toFixed(1) }}%</div>
</div>
</div>
<!-- 总体学习进度条 -->
<div class="overall-progress">
<div class="progress-bar-container">
<div class="progress-bar">
<div
class="progress-bar-fill"
:style="{ width: overallProgress + '%' }"
></div>
</div>
</div>
<div class="progress-info">
<div class="progress-text">
<span class="progress-title">学习总进度</span>
<span class="progress-value">{{ overallProgress.toFixed(1) }}%</span>
</div>
<div class="progress-count">
<span class="current">{{ completedItems }}</span>
<span class="separator">/</span>
<span class="total">{{ totalItems }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
// Props
interface Props {
courseProgress?: number
homeworkProgress?: number
examProgress?: number
completedItems?: number
totalItems?: number
}
const props = withDefaults(defineProps<Props>(), {
courseProgress: 31.7,
homeworkProgress: 22.5,
examProgress: 9.6,
completedItems: 13,
totalItems: 54
})
//
const circumference = computed(() => 2 * Math.PI * 32) // r=32
//
const overallProgress = computed(() => {
if (props.totalItems === 0) return 0
return (props.completedItems / props.totalItems) * 100
})
</script>
<style scoped>
.learning-progress-stats {
background: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
border: none;
}
/* 三个圆形进度图表 */
.progress-circles {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 32px;
gap: 40px;
}
.progress-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
flex: 0 0 auto;
}
.circle-container {
position: relative;
width: 80px;
height: 80px;
margin-bottom: 10px;
}
.progress-circle {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.progress-bg {
opacity: 0.4;
stroke: #d9ecff;
}
.progress-fill {
transition: stroke-dashoffset 0.8s ease-in-out;
}
.circle-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.progress-icon {
margin-bottom: 4px;
}
.icon-placeholder {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
border-radius: 4px;
background: transparent;
color: #1890ff;
border: none;
}
.progress-label {
font-size: 14px;
color: #999;
font-weight: 400;
margin-bottom: 8px;
}
.progress-percentage {
font-size: 18px;
font-weight: 400;
color: #999;
margin-top: 0;
}
/* 总体进度条 */
.overall-progress {
margin-top: 0;
}
.progress-bar-container {
margin-bottom: 12px;
}
.progress-bar {
width: 100%;
height: 8px;
background: #d9ecff;
border-radius: 4px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: #1890ff;
border-radius: 4px;
transition: width 0.8s ease-in-out;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-text {
display: flex;
align-items: center;
gap: 8px;
}
.progress-title {
font-size: 16px;
color: #999;
font-weight: 400;
}
.progress-value {
font-size: 16px;
color: #999;
font-weight: 400;
}
.progress-count {
display: flex;
align-items: center;
font-size: 18px;
font-weight: 400;
}
.current {
color: #333;
font-size: 18px;
}
.separator {
color: #999;
margin: 0 2px;
font-size: 18px;
}
.total {
color: #999;
font-size: 18px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.learning-progress-stats {
padding: 16px 12px;
}
.progress-circles {
gap: 24px;
margin-bottom: 20px;
}
.circle-container {
width: 70px;
height: 70px;
}
.progress-circle {
width: 70px;
height: 70px;
}
.icon-placeholder {
width: 24px;
height: 24px;
font-size: 14px;
}
.progress-label {
font-size: 12px;
}
.progress-percentage {
font-size: 16px;
}
.progress-title,
.progress-value {
font-size: 14px;
}
.progress-count {
font-size: 16px;
}
}
@media (max-width: 480px) {
.learning-progress-stats {
padding: 12px 8px;
}
.progress-circles {
gap: 16px;
margin-bottom: 16px;
}
.circle-container {
width: 60px;
height: 60px;
}
.progress-circle {
width: 60px;
height: 60px;
}
.icon-placeholder {
width: 20px;
height: 20px;
font-size: 12px;
}
.progress-percentage {
font-size: 14px;
}
.progress-count {
font-size: 14px;
}
}
</style>

View File

@ -0,0 +1,454 @@
<template>
<div v-if="visible" class="notes-modal-overlay" @click="handleOverlayClick">
<div
class="notes-modal"
@click.stop
:style="{
transform: `translate(${modalPosition.x}px, ${modalPosition.y}px)`,
cursor: isDragging ? 'grabbing' : 'default'
}"
>
<!-- 顶部状态栏 -->
<div
class="modal-header"
@mousedown="handleMouseDown"
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
>
<span class="save-status">保存成功 2015.7.23 14:23:52</span>
<button class="close-btn" @click="closeModal">×</button>
</div>
<!-- 主要内容区域 -->
<div class="modal-content">
<!-- 左侧笔记列表 -->
<div class="notes-sidebar">
<div class="notes-list">
<div
v-for="note in notesList"
:key="note.id"
class="note-item"
:class="{ active: note.id === currentNoteId }"
@click="selectNote(note.id)"
>
<div class="note-title">{{ note.title }}</div>
<div class="note-date">{{ note.date }}</div>
</div>
</div>
</div>
<!-- 右侧编辑区域 -->
<div class="editor-area">
<!-- 工具栏 -->
<div class="editor-toolbar">
<div class="toolbar-left">
<select class="font-size-select" v-model="fontSize">
<option value="12">12</option>
<option value="14">14</option>
<option value="16">16</option>
<option value="18">18</option>
</select>
<button class="toolbar-btn bold" @click="toggleBold">B</button>
<button class="toolbar-btn underline" @click="toggleUnderline">U</button>
<button class="toolbar-btn font-size" @click="increaseFontSize">Aa</button>
<button class="toolbar-btn font-size small" @click="decreaseFontSize">A</button>
<button class="toolbar-btn color" @click="toggleColor">🎨</button>
<button class="toolbar-btn list" @click="toggleList"></button>
<button class="toolbar-btn image" @click="insertImage">🖼</button>
</div>
<div class="toolbar-right">
<button class="save-btn" @click="saveNote">
<i class="save-icon">💾</i>
保存
</button>
</div>
</div>
<!-- 编辑器 -->
<div class="editor-content">
<textarea
v-model="currentNoteContent"
class="note-editor"
placeholder="开始记录您的笔记..."
:style="{ fontSize: fontSize + 'px' }"
></textarea>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
// computed, onMounted
interface Note {
id: number
title: string
date: string
content: string
}
defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
close: []
save: [content: string]
}>()
//
const fontSize = ref(14)
const currentNoteId = ref(1)
const currentNoteContent = ref('为了让科学教育有效,它必须广泛包容,而且应该认识到科学教师、科学家、家庭和社区如何合作实现学习和教学的目标。')
//
const notesList = ref<Note[]>([
{ id: 1, title: '第七课笔记', date: '2015.7.23', content: '为了让科学教育有效,它必须广泛包容,而且应该认识到科学教师、科学家、家庭和社区如何合作实现学习和教学的目标。' },
{ id: 2, title: '第六课笔记 DeepSeek的...', date: '2015.7.21', content: '' },
{ id: 3, title: '第五课笔记', date: '2015.7.23', content: '' },
{ id: 4, title: '第四课笔记', date: '2015.7.21', content: '' },
{ id: 5, title: '第三课笔记', date: '2015.7.23', content: '' },
{ id: 6, title: '第二课笔记', date: '2015.7.21', content: '' },
{ id: 7, title: '第一课笔记', date: '2015.7.23', content: '' },
{ id: 8, title: '第六课笔记 DeepSeek的...', date: '2015.7.21', content: '' },
{ id: 9, title: '第七课笔记', date: '2015.7.23', content: '' }
])
//
const closeModal = () => {
emit('close')
}
const handleOverlayClick = () => {
closeModal()
}
const selectNote = (noteId: number) => {
const note = notesList.value.find(n => n.id === noteId)
if (note) {
currentNoteId.value = noteId
currentNoteContent.value = note.content
}
}
const saveNote = () => {
const note = notesList.value.find(n => n.id === currentNoteId.value)
if (note) {
note.content = currentNoteContent.value
}
emit('save', currentNoteContent.value)
}
const toggleBold = () => {
//
}
const toggleUnderline = () => {
// 线
}
const increaseFontSize = () => {
if (fontSize.value < 24) {
fontSize.value += 2
}
}
const decreaseFontSize = () => {
if (fontSize.value > 10) {
fontSize.value -= 2
}
}
const toggleColor = () => {
//
}
const toggleList = () => {
//
}
const insertImage = () => {
//
}
//
const isDragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
const modalPosition = ref({ x: 0, y: 0 })
const handleMouseDown = (event: MouseEvent) => {
isDragging.value = true
dragOffset.value = {
x: event.clientX - modalPosition.value.x,
y: event.clientY - modalPosition.value.y
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
const handleMouseMove = (event: MouseEvent) => {
if (!isDragging.value) return
modalPosition.value = {
x: event.clientX - dragOffset.value.x,
y: event.clientY - dragOffset.value.y
}
}
const handleMouseUp = () => {
isDragging.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
</script>
<style scoped>
.notes-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.notes-modal {
width: 75vw;
max-width: 800px;
height: 65vh;
min-height: 500px;
background: white;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
position: relative;
user-select: none;
}
/* 顶部状态栏 */
.modal-header {
background: #f5f5f5;
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e0e0e0;
font-size: 12px;
color: #666;
}
.save-status {
font-size: 12px;
color: #666;
}
.close-btn {
background: none;
border: none;
font-size: 18px;
color: #999;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #333;
}
/* 主要内容区域 */
.modal-content {
flex: 1;
display: flex;
overflow: hidden;
}
/* 左侧笔记列表 */
.notes-sidebar {
width: 240px;
background: #f8f9fa;
border-right: 1px solid #e0e0e0;
overflow-y: auto;
}
.notes-list {
padding: 0;
}
.note-item {
padding: 12px 16px;
border-bottom: 1px solid #e8e9ea;
cursor: pointer;
transition: background-color 0.2s;
}
.note-item:hover {
background: #e9ecef;
}
.note-item.active {
background: #e3f2fd;
border-left: 3px solid #2196f3;
}
.note-title {
font-size: 14px;
color: #333;
margin-bottom: 4px;
font-weight: 500;
}
.note-date {
font-size: 12px;
color: #999;
}
/* 右侧编辑区域 */
.editor-area {
flex: 1;
display: flex;
flex-direction: column;
}
/* 工具栏 */
.editor-toolbar {
background: #fff;
border-bottom: 1px solid #e0e0e0;
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.font-size-select {
border: 1px solid #ddd;
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
background: white;
}
.toolbar-btn {
background: #f8f9fa;
border: 1px solid #ddd;
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.toolbar-btn:hover {
background: #e9ecef;
}
.toolbar-btn.bold {
font-weight: bold;
}
.toolbar-btn.underline {
text-decoration: underline;
}
.toolbar-btn.font-size {
font-size: 14px;
}
.toolbar-btn.font-size.small {
font-size: 10px;
}
.save-btn {
background: #2196f3;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.save-btn:hover {
background: #1976d2;
}
/* 编辑器内容 */
.editor-content {
flex: 1;
padding: 16px;
overflow: hidden;
}
.note-editor {
width: 100%;
height: 100%;
border: none;
outline: none;
resize: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
}
/* 响应式设计 */
@media (max-width: 768px) {
.notes-modal {
width: 90vw;
height: 80vh;
min-height: 400px;
}
.notes-sidebar {
width: 180px;
}
.toolbar-left {
gap: 4px;
}
.toolbar-btn {
padding: 3px 6px;
font-size: 11px;
}
}
@media (max-width: 480px) {
.notes-modal {
width: 95vw;
height: 85vh;
}
.notes-sidebar {
width: 150px;
}
.modal-header {
padding: 6px 12px;
}
.save-status {
font-size: 10px;
}
}
</style>

View File

@ -206,8 +206,7 @@ const handleMenuSelect = (key: string) => {
router.push('/courses')
break
case 'training':
//
router.push('/')
router.push('/special-training')
break
case 'faculty':
router.push('/faculty')

250
src/data/mockCourses.ts Normal file
View File

@ -0,0 +1,250 @@
// 课程模拟数据
import type { Course } from '@/api/types'
export const mockCourses: Course[] = [
{
id: 1,
title: "暑期名师领学,提高班级教学质量!高效冲分指南",
description: "本课程深度聚焦问题让每一位教师了解并学习使用DeepSeek结合办公自动化职业岗位标准以实际工作任务为引导强调课程内容的易用性和岗位要求的匹配性。",
thumbnail: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
price: 99,
originalPrice: 199,
currency: "CNY",
rating: 4.8,
ratingCount: 324,
studentsCount: 324,
duration: "8小时",
totalLessons: 12,
level: "beginner" as const,
language: "zh-CN",
category: {
id: 1,
name: "教育培训",
slug: "education"
},
tags: ["教学质量", "高效学习", "名师指导"],
skills: ["教学方法", "课堂管理", "学习指导"],
requirements: ["具备基本的计算机操作能力"],
objectives: ["掌握核心技能,能够在实际工作中熟练应用"],
instructor: {
id: 1,
name: "张教授",
avatar: "https://via.placeholder.com/100",
bio: "资深教育专家",
title: "高级讲师",
rating: 4.8,
studentsCount: 1200,
coursesCount: 15,
experience: "10年教学经验",
education: ["教育学博士", "计算机科学硕士"],
certifications: ["高级讲师认证", "教学质量奖"]
},
status: "published" as const,
createdAt: "2025-01-26T10:13:17Z",
updatedAt: "2025-01-26T10:13:17Z"
},
{
id: 2,
title: "Python编程基础与实战",
description: "从零开始学习Python编程涵盖基础语法、数据结构、面向对象编程等核心概念通过实际项目练习掌握Python开发技能。",
thumbnail: "https://images.unsplash.com/photo-1526379095098-d400fd0bf935?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
price: 199,
originalPrice: 299,
currency: "CNY",
rating: 4.7,
ratingCount: 256,
studentsCount: 256,
duration: "20小时",
totalLessons: 30,
level: "beginner" as const,
language: "zh-CN",
category: {
id: 2,
name: "编程开发",
slug: "programming"
},
tags: ["Python", "编程基础", "实战项目"],
skills: ["Python语法", "数据结构", "面向对象"],
requirements: ["无需编程基础,适合零基础学员"],
objectives: ["掌握Python编程基础能够独立完成简单的Python项目"],
instructor: {
id: 2,
name: "李工程师",
avatar: "https://via.placeholder.com/100",
bio: "Python开发专家",
title: "高级工程师",
rating: 4.9,
studentsCount: 800,
coursesCount: 8,
experience: "8年开发经验",
education: ["计算机科学硕士"],
certifications: ["Python认证工程师", "软件架构师"]
},
status: "published" as const,
createdAt: "2025-01-20T09:00:00Z",
updatedAt: "2025-01-20T09:00:00Z"
},
{
id: 3,
title: "Vue.js 3 完整开发教程",
description: "深入学习Vue.js 3框架包括Composition API、TypeScript集成、状态管理、路由配置等现代前端开发技术。",
thumbnail: "https://images.unsplash.com/photo-1555066931-4365d14bab8c?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
price: 299,
originalPrice: 399,
currency: "CNY",
rating: 4.9,
ratingCount: 189,
studentsCount: 189,
duration: "25小时",
totalLessons: 40,
level: "intermediate" as const,
language: "zh-CN",
category: {
id: 1,
name: "前端开发",
slug: "frontend"
},
tags: ["Vue.js", "前端框架", "现代开发"],
skills: ["Vue.js基础", "组件开发", "状态管理"],
requirements: ["具备HTML、CSS、JavaScript基础"],
objectives: ["掌握Vue.js 3开发技能能够独立开发前端项目"],
instructor: {
id: 3,
name: "王架构师",
avatar: "https://via.placeholder.com/100",
bio: "前端架构师",
title: "技术专家",
rating: 4.7,
studentsCount: 950,
coursesCount: 12,
experience: "6年前端开发经验",
education: ["软件工程学士"],
certifications: ["前端架构师认证", "Vue.js专家认证"]
},
status: "published" as const,
createdAt: "2025-02-01T10:00:00Z",
updatedAt: "2025-02-01T10:00:00Z"
},
{
id: 4,
title: "React 18 实战开发",
description: "掌握React 18的新特性包括并发渲染、Suspense、Hooks等高级功能通过实际项目提升React开发技能。",
thumbnail: "https://images.unsplash.com/photo-1633356122544-f134324a6cee?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
price: 399,
originalPrice: 499,
currency: "CNY",
rating: 4.6,
ratingCount: 142,
studentsCount: 142,
duration: "30小时",
totalLessons: 45,
level: "advanced" as const,
language: "zh-CN",
category: {
id: 1,
name: "前端开发",
slug: "frontend"
},
tags: ["React", "前端框架", "高级开发"],
skills: ["React基础", "组件设计", "状态管理"],
requirements: ["具备JavaScript ES6+基础,了解前端开发流程"],
objectives: ["精通React开发能够构建复杂的单页应用"],
instructor: {
id: 4,
name: "赵专家",
avatar: "https://via.placeholder.com/100",
bio: "React技术专家",
title: "高级工程师",
rating: 4.6,
studentsCount: 750,
coursesCount: 9,
experience: "7年React开发经验",
education: ["计算机科学硕士"],
certifications: ["React专家认证", "前端开发认证"]
},
status: "published" as const,
createdAt: "2025-02-15T14:00:00Z",
updatedAt: "2025-02-15T14:00:00Z"
},
{
id: 5,
title: "Node.js 后端开发实战",
description: "学习Node.js后端开发包括Express框架、数据库操作、API设计、身份验证、部署等全栈开发技能。",
thumbnail: "https://images.unsplash.com/photo-1627398242454-45a1465c2479?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80",
price: 349,
originalPrice: 449,
currency: "CNY",
rating: 4.5,
ratingCount: 98,
studentsCount: 98,
duration: "28小时",
totalLessons: 35,
level: "intermediate" as const,
language: "zh-CN",
category: {
id: 3,
name: "后端开发",
slug: "backend"
},
tags: ["Node.js", "后端开发", "全栈开发"],
skills: ["Node.js基础", "Express框架", "数据库操作"],
requirements: ["具备JavaScript基础了解HTTP协议"],
objectives: ["掌握Node.js后端开发能够构建完整的后端服务"],
instructor: {
id: 5,
name: "刘工程师",
avatar: "https://via.placeholder.com/100",
bio: "全栈开发工程师",
title: "技术专家",
rating: 4.8,
studentsCount: 600,
coursesCount: 11,
experience: "9年全栈开发经验",
education: ["软件工程硕士"],
certifications: ["全栈开发认证", "Node.js专家认证"]
},
status: "published" as const,
createdAt: "2025-03-01T09:00:00Z",
updatedAt: "2025-03-01T09:00:00Z"
}
]
// 根据ID获取课程
export const getCourseById = (id: number): Course | undefined => {
return mockCourses.find(course => course.id === id)
}
// 获取热门课程前5个
export const getPopularCourses = (): Course[] => {
return mockCourses.slice(0, 5)
}
// 根据分类筛选课程
export const getCoursesByCategory = (categoryName: string): Course[] => {
if (categoryName === '全部') return mockCourses
return mockCourses.filter(course => course.category.name === categoryName)
}
// 根据难度筛选课程
export const getCoursesByLevel = (level: string): Course[] => {
const levelMap: { [key: string]: string } = {
'初级': 'beginner',
'中级': 'intermediate',
'高级': 'advanced'
}
const targetLevel = levelMap[level]
if (!targetLevel) return mockCourses
return mockCourses.filter(course => course.level === targetLevel)
}
// 搜索课程
export const searchCourses = (keyword: string): Course[] => {
if (!keyword) return mockCourses
const lowerKeyword = keyword.toLowerCase()
return mockCourses.filter(course =>
course.title.toLowerCase().includes(lowerKeyword) ||
course.description.toLowerCase().includes(lowerKeyword) ||
course.tags.some(tag => tag.toLowerCase().includes(lowerKeyword)) ||
course.skills.some(skill => skill.toLowerCase().includes(lowerKeyword))
)
}

View File

@ -5,6 +5,7 @@ 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 CourseDetailEnrolled from '@/views/CourseDetailEnrolled.vue'
import CourseStudy from '@/views/CourseStudy.vue'
import Learning from '@/views/Learning.vue'
import Profile from '@/views/Profile.vue'
@ -17,8 +18,10 @@ import ActivityDetail from '@/views/ActivityDetail.vue'
import ActivityRegistration from '@/views/ActivityRegistration.vue'
import Exam from '@/views/Exam.vue'
import ExamNotice from '@/views/ExamNotice.vue'
import ExamSubmitted from '@/views/ExamSubmitted.vue'
import TestSections from '@/views/TestSections.vue'
import VideoTest from '@/views/VideoTest.vue'
import LocalVideoDemo from '@/views/LocalVideoDemo.vue'
import SpecialTraining from '@/views/SpecialTraining.vue'
const routes: RouteRecordRaw[] = [
{
@ -45,6 +48,14 @@ const routes: RouteRecordRaw[] = [
title: '课程详情'
}
},
{
path: '/course/:id/enrolled',
name: 'CourseDetailEnrolled',
component: CourseDetailEnrolled,
meta: {
title: '课程详情 - 已报名'
}
},
{
path: '/course/study/:id',
name: 'CourseStudy',
@ -97,6 +108,14 @@ const routes: RouteRecordRaw[] = [
title: '精选资源'
}
},
{
path: '/special-training',
name: 'SpecialTraining',
component: SpecialTraining,
meta: {
title: '专题训练'
}
},
{
path: '/activities',
name: 'Activities',
@ -137,6 +156,14 @@ const routes: RouteRecordRaw[] = [
title: '在线考试'
}
},
{
path: '/exam/submitted',
name: 'ExamSubmitted',
component: ExamSubmitted,
meta: {
title: '考试提交成功'
}
},
{
path: '/test-sections',
name: 'TestSections',
@ -146,11 +173,11 @@ const routes: RouteRecordRaw[] = [
}
},
{
path: '/video-test',
name: 'VideoTest',
component: VideoTest,
path: '/local-video-demo',
name: 'LocalVideoDemo',
component: LocalVideoDemo,
meta: {
title: '视频播放器测试'
title: '本地视频播放演示'
}
},
{

View File

@ -1,22 +0,0 @@
import { vi } from 'vitest'
// Mock localStorage
Object.defineProperty(window, 'localStorage', {
value: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
},
writable: true,
})
// Mock console methods to reduce noise in tests
global.console = {
...console,
log: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}

View File

@ -15,28 +15,12 @@
<!-- 活动横幅区域 -->
<div class="hero-banner">
<div class="banner-container">
<div class="banner-content">
<div class="banner-text">
<h1 class="main-title">{{ activity?.title || '"与AI共创未来"' }}</h1>
<h2 class="sub-title">{{ activity?.subtitle || '2025年全国青少年人工智能创新实践活动' }}</h2>
<div class="activity-description">
<p>活动简介中国科协青少年科技中心中国青少年科技辅导员协会主办上海人工智能实验室协办上海科技馆</p>
<p>支持单位中国科协青少年科技中心承办的活动</p>
<p>协办单位全国青少年科技创新大赛组委会办公室上海科技馆青少年科学创新中心</p>
</div>
</div>
<div class="banner-illustration">
<div class="illustration-placeholder">
<!-- 3D插画占位 -->
<div class="ai-characters">
<div class="character-1"></div>
<div class="character-2"></div>
<div class="tech-elements"></div>
</div>
</div>
</div>
</div>
<div class="banner-image-container">
<img
src="/images/activity/活动报名-切图.png"
alt="活动横幅"
class="banner-image"
/>
</div>
</div>
@ -400,43 +384,25 @@ onMounted(() => {
/* 活动横幅 */
.hero-banner {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
color: white;
padding: 80px 0;
width: 100%;
position: relative;
overflow: hidden;
}
.banner-content {
.banner-image-container {
width: 100%;
height: 400px;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 60px;
justify-content: center;
}
.banner-text {
flex: 1;
max-width: 600px;
}
.main-title {
font-size: 48px;
font-weight: bold;
margin-bottom: 16px;
line-height: 1.2;
}
.sub-title {
font-size: 24px;
font-weight: 500;
margin-bottom: 24px;
opacity: 0.95;
}
.activity-description {
font-size: 16px;
line-height: 1.6;
opacity: 0.9;
.banner-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.activity-description p {

View File

@ -15,28 +15,12 @@
<!-- 活动横幅区域 -->
<div class="hero-banner">
<div class="banner-container">
<div class="banner-content">
<div class="banner-text">
<h1 class="main-title">"与AI共创未来"</h1>
<h2 class="sub-title">2025年全国青少年人工智能创新实践活动</h2>
<div class="activity-description">
<p>活动简介中国科协青少年科技中心中国青少年科技辅导员协会主办上海人工智能实验室协办上海科技馆</p>
<p>支持单位中国科协青少年科技中心承办的活动</p>
<p>协办单位全国青少年科技创新大赛组委会办公室上海科技馆青少年科学创新中心</p>
</div>
</div>
<div class="banner-illustration">
<div class="illustration-placeholder">
<!-- 3D插画占位 -->
<div class="ai-characters">
<div class="character-1"></div>
<div class="character-2"></div>
<div class="tech-elements"></div>
</div>
</div>
</div>
</div>
<div class="banner-image-container">
<img
src="/images/activity/活动报名-切图.png"
alt="活动横幅"
class="banner-image"
/>
</div>
</div>
@ -305,42 +289,25 @@ const handleSubmit = async () => {
/* 活动横幅区域 */
.hero-banner {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
color: white;
padding: 40px 0;
width: 100%;
position: relative;
overflow: hidden;
}
.banner-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.banner-content {
.banner-image-container {
width: 100%;
height: 400px;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
min-height: 200px;
justify-content: center;
}
.banner-text {
flex: 1;
max-width: 600px;
}
.main-title {
font-size: 36px;
font-weight: bold;
margin-bottom: 10px;
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.sub-title {
font-size: 20px;
margin-bottom: 20px;
opacity: 0.9;
.banner-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.activity-description {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,6 @@
<!-- 视频播放器区域 -->
<div class="video-player-section">
<div v-if="currentVideoUrl" class="video-container">
<VideoPlayer
:video-url="currentVideoUrl"
:title="currentVideoTitle"
@ -24,6 +23,7 @@
:poster="videoPoster"
:autoplay="autoplay"
:show-controls="true"
:placeholder="currentVideoUrl ? '正在加载视频...' : '请选择要播放的视频'"
@play="onVideoPlay"
@pause="onVideoPause"
@ended="onVideoEnded"
@ -31,16 +31,6 @@
@error="onVideoError"
/>
</div>
<div v-else class="video-placeholder">
<div class="placeholder-content">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
<circle cx="40" cy="40" r="30" stroke="#ccc" stroke-width="2"/>
<path d="M32 25L32 55L55 40L32 25Z" fill="#ccc"/>
</svg>
<p>请选择要播放的视频</p>
</div>
</div>
</div>
<!-- 视频信息和控制 -->
<div class="video-info-section">

View File

@ -282,8 +282,8 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { CourseApi } from '@/api/modules/course'
import type { Course } from '@/api/types'
import { mockCourses } from '@/data/mockCourses'
const router = useRouter()
@ -344,58 +344,81 @@ 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
//
await new Promise(resolve => setTimeout(resolve, 500))
//
let filteredCourses = [...mockCourses]
//
if (selectedSubject.value !== '全部') {
filteredCourses = filteredCourses.filter(course => {
switch (selectedSubject.value) {
case '计算机':
return course.category.name === '编程开发' || course.category.name === '前端开发' || course.category.name === '后端开发'
case '教育学':
return course.category.name === '教育培训'
default:
return true
}
})
}
console.log('加载课程参数:', params)
const response = await CourseApi.getCourses(params)
console.log('课程API响应:', response)
//
if (selectedMajor.value !== '全部') {
filteredCourses = filteredCourses.filter(course =>
course.title.includes(selectedMajor.value) ||
course.description.includes(selectedMajor.value) ||
course.tags.some(tag => tag.includes(selectedMajor.value))
)
}
if (response && response.data) {
if (response.code === 0 || response.code === 200) {
courses.value = response.data.list || []
total.value = response.data.total || 0
console.log('课程加载成功:', courses.value.length, '条课程')
} else {
console.error('获取课程列表失败:', response.message)
courses.value = []
total.value = 0
//
if (selectedDifficulty.value !== '全部') {
const difficultyMap: { [key: string]: string } = {
'初级': 'beginner',
'中级': 'intermediate',
'高级': 'advanced'
}
} else {
console.error('API响应格式异常:', response)
courses.value = []
total.value = 0
const targetLevel = difficultyMap[selectedDifficulty.value]
if (targetLevel) {
filteredCourses = filteredCourses.filter(course => course.level === targetLevel)
}
}
//
total.value = filteredCourses.length
const startIndex = (currentPage.value - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
courses.value = filteredCourses.slice(startIndex, endIndex)
console.log('课程加载成功:', courses.value.length, '条课程,总计:', total.value)
} catch (error) {
console.error('加载课程失败:', error)
courses.value = []
total.value = 0
//
} 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 getCategoryIdBySubject = (subject: string): number | undefined => {
// const categoryMap: Record<string, number> = {
// '': 1,
// '': 2,
// '': 3,
// '': 4,
// '': 5,
// '': 6
// }
// return categoryMap[subject]
// }
//
const goToPage = (page: number) => {

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +1,45 @@
<template>
<div class="exam-notice-page">
<!-- 考试中心标题 -->
<div class="exam-center-header">
<div class="container">
<h1 class="center-title">考试中心</h1>
<p class="center-subtitle">诚信考试规范考试过程规范严格监考规范</p>
<!-- 横幅区域 -->
<div class="banner-section">
<div class="banner-container">
<img src="/banners/考前须知.png" alt="考前须知" class="banner-image" />
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<div class="container">
<div class="content-layout">
<!-- 左侧导航 -->
<div class="sidebar">
<div class="nav-menu">
<div class="nav-item active">
<span class="nav-icon">📋</span>
<span class="nav-text">考前须知</span>
</div>
</div>
</div>
<!-- 右侧内容 -->
<div class="content-area">
<div class="notice-card">
<div class="notice-header">
<h2 class="notice-title">考前须知</h2>
<div class="notice-meta">
<span class="publish-time">发布时间2024年12月31日</span>
<span class="view-count">浏览次数{{ viewCount }}</span>
<!-- 面包屑导航 -->
<div class="breadcrumb-wrapper">
<div class="breadcrumb-container">
<span class="breadcrumb-item">课程名称</span>
<span class="breadcrumb-separator">></span>
<span class="breadcrumb-item current">考前须知</span>
</div>
</div>
<div class="content-container">
<!-- 考前须知标题 -->
<h1 class="page-title">考前须知</h1>
<!-- 考试信息 -->
<div class="exam-info">
<div class="exam-info-left">
<span class="exam-time">发布时间2025年8月1日-8月18日</span>
<span class="exam-name">考试名称2025数字化教育理论基础与实务课程考试</span>
</div>
<div class="exam-info-right">
<span class="exam-duration">考试时长120分钟</span>
</div>
</div>
<!-- 考前须知内容 -->
<div class="notice-content">
<div class="notice-item">
<span class="item-number">1.</span>
<span class="item-text">考试时间为2024年8月31日-9月30日考试期间考生可自行安排时间考试考试时长为120分钟</span>
<span class="item-text">考试时间2025年8月1日-8月18日在此期间考生可自行安排时间考试考试时长共120分钟</span>
</div>
<div class="notice-item">
@ -51,7 +54,7 @@
<div class="notice-item">
<span class="item-number">4.</span>
<span class="item-text">考试期间若遇到网络中断等异常考生应保持冷静并及时联系监考老师监考老师会根据实际情况进行处理考生监考老师务必在考试过程中保持良好的沟通配合确保考试的顺利进行</span>
<span class="item-text">本次考试采用网络考试方式考生应在规定本上填写姓名用户应确保在考试过程中网络通畅设备未按规定在考试本上填写答题或未交试卷的考试成绩将自动</span>
</div>
<div class="notice-item">
@ -61,12 +64,12 @@
<div class="notice-item">
<span class="item-number">6.</span>
<span class="item-text">考试时请考生自觉关闭手机并将随身物品放在指定位置考生需24小时确保良好的网络环境和充足的电量在考试期间考生不得离开考试场地上述规定执行</span>
<span class="item-text">考试时请考生自觉关闭手机并将随身物品放在指定位置考生需24小时确保良好的网络环境和充足的电量在考试期间考生不得离开考试场地上述规定执行</span>
</div>
<div class="notice-item">
<span class="item-number">7.</span>
<span class="item-text">考生应提前熟悉考试流程作弊考试操作流程确保能够正常参加考试</span>
<span class="item-text">考生应提前熟悉考试流程机考考试操作流程确保能够正常参加考试</span>
</div>
<div class="notice-item">
@ -76,7 +79,7 @@
<div class="notice-item">
<span class="item-number">9.</span>
<span class="item-text">请认真阅读本人考试须知严格遵守考试纪律诚实考试如有疑问请及时联系监考老师进行咨询</span>
<span class="item-text">请认真阅读本人考试须知严格遵守考试纪律对不遵守考试纪律如有疑问请及时联系监考老师进行咨询</span>
</div>
<div class="notice-item">
@ -90,25 +93,23 @@
</div>
<div class="notice-item">
<span class="item-number">12.</span>
<span class="item-text">咨询电话咨询电话0871-65635521</span>
<span class="item-number">*</span>
<span class="item-text">咨询电话咨询电话0871-5533221</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="notice-actions">
<button class="btn-secondary" @click="goBack">
返回上级开始考试10
我已知晓开始考试10
</button>
<button class="btn-primary" @click="startExam">
我已阅读开始考试
我已知晓开始考试
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
@ -125,13 +126,15 @@ const courseName = ref(route.query.courseName as string || '课程名称')
const examName = ref(route.query.examName as string || '考试')
//
const viewCount = ref(1024)
// const viewCount = ref(1024) //
//
const goBack = () => {
router.push(`/course/${courseId.value}`)
}
//
const startExam = () => {
// fromNotice
@ -163,133 +166,152 @@ onMounted(() => {
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
/* 面包屑导航 */
.breadcrumb-wrapper {
background: #f8f9fa;
padding: 12px 0;
margin-bottom: 0;
}
/* 考试中心标题 */
.exam-center-header {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
color: white;
padding: 40px 0;
text-align: center;
position: relative;
overflow: hidden;
}
.exam-center-header::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 300px;
height: 100%;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 200"><circle cx="250" cy="50" r="30" fill="rgba(255,255,255,0.1)"/><circle cx="200" cy="120" r="20" fill="rgba(255,255,255,0.08)"/><circle cx="280" cy="150" r="25" fill="rgba(255,255,255,0.06)"/></svg>') no-repeat center;
}
.center-title {
font-size: 36px;
font-weight: bold;
margin: 0 0 10px 0;
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.center-subtitle {
font-size: 16px;
margin: 0;
opacity: 0.9;
}
/* 主要内容区域 */
.main-content {
padding: 30px 0;
}
.content-layout {
display: flex;
gap: 30px;
}
/* 左侧导航 */
.sidebar {
width: 200px;
flex-shrink: 0;
}
.nav-menu {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.nav-menu .nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.3s;
}
.nav-menu .nav-item.active {
background: #e6f7ff;
color: #1890ff;
}
.nav-icon {
font-size: 16px;
}
.nav-text {
font-size: 14px;
font-weight: 500;
}
/* 右侧内容区域 */
.content-area {
flex: 1;
}
.notice-card {
background: white;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.notice-header {
border-bottom: 1px solid #e8e8e8;
padding-bottom: 20px;
margin-bottom: 30px;
}
.notice-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 12px 0;
}
.notice-meta {
display: flex;
gap: 30px;
.breadcrumb-container {
margin: 0 250px;
font-size: 14px;
color: #666;
}
.breadcrumb-item {
color: #666;
}
.breadcrumb-item.current {
color: #1890ff;
}
.breadcrumb-separator {
margin: 0 8px;
color: #999;
}
/* 横幅区域 */
.banner-section {
padding: 0;
width: 100%;
}
.banner-container {
width: 100%;
padding: 0;
}
.banner-image {
width: 100%;
height: auto;
display: block;
object-fit: cover;
}
/* 主要内容区域 */
.main-content {
background: #f5f5f5;
padding: 40px 0;
}
.content-container {
margin: 0 250px;
background: white;
border-radius: 0 0 8px 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 40px;
}
/* 页面标题 */
.page-title {
text-align: center;
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 30px 0;
}
/* 考试信息 */
.exam-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid #e9ecef;
margin-bottom: 30px;
flex-wrap: wrap;
gap: 20px;
}
.exam-info-left {
display: flex;
align-items: center;
gap: 20px;
}
.exam-time {
font-size: 13px;
color: #999;
}
.exam-name {
font-size: 13px;
color: #999;
}
.exam-info-right {
display: flex;
align-items: center;
}
.exam-duration {
font-size: 13px;
color: #999;
}
/* 发布信息 */
.publish-info {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 30px;
padding: 0 20px;
font-size: 14px;
color: #666;
}
.publish-left {
display: flex;
flex-direction: column;
gap: 8px;
}
.publish-right {
text-align: right;
}
.publish-time,
.exam-name,
.view-count {
line-height: 1.5;
}
/* 考前须知内容 */
.notice-content {
margin-bottom: 40px;
line-height: 1.8;
color: #333;
}
.notice-item {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
line-height: 1.6;
font-size: 14px;
}
.item-number {
@ -297,30 +319,36 @@ onMounted(() => {
font-weight: 500;
margin-right: 8px;
flex-shrink: 0;
min-width: 20px;
}
.item-text {
color: #333;
font-size: 14px;
flex: 1;
}
/* 操作按钮 */
.notice-actions {
display: flex;
justify-content: center;
gap: 20px;
padding-top: 20px;
border-top: 1px solid #e8e8e8;
flex-direction: column;
align-items: center;
gap: 15px;
margin-top: 40px;
}
.btn-secondary {
background: #f5f5f5;
color: #666;
border: 1px solid #d9d9d9;
padding: 12px 24px;
border-radius: 6px;
padding: 12px 30px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
min-width: 250px;
text-align: center;
white-space: nowrap;
}
.btn-secondary:hover {
@ -333,32 +361,70 @@ onMounted(() => {
background: #1890ff;
color: white;
border: none;
padding: 12px 32px;
border-radius: 6px;
padding: 12px 30px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s;
min-width: 250px;
text-align: center;
white-space: nowrap;
}
.btn-primary:hover {
background: #40a9ff;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.breadcrumb-container {
margin: 0 100px;
}
.content-container {
margin: 0 100px;
}
}
@media (max-width: 768px) {
.content-layout {
flex-direction: column;
.breadcrumb-container {
margin: 0 20px;
}
.sidebar {
width: 100%;
.content-container {
margin: 0 20px;
padding: 20px;
border-radius: 4px;
}
.notice-actions {
.banner-container {
padding: 15px;
}
.banner-image {
border-radius: 4px;
}
.exam-info {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.exam-info-left {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.exam-info-right {
text-align: left;
}
.btn-secondary,
.btn-primary {
min-width: 300px;
}
}
</style>

582
src/views/ExamSubmitted.vue Normal file
View File

@ -0,0 +1,582 @@
<template>
<div class="exam-submitted-page">
<!-- 考试中心标题 -->
<div class="exam-center-header">
<div class="container">
<h1 class="center-title">考试中心</h1>
<p class="center-subtitle">诚信考试规范考试过程规范严格监考规范</p>
</div>
</div>
<!-- 课程信息导航 -->
<div class="course-info-nav">
<div class="container">
<div class="nav-content">
<div class="nav-left">
<span class="nav-item">{{ courseName }}</span>
<span class="nav-separator">></span>
<span class="nav-item">{{ examName }}</span>
</div>
<div class="nav-right">
<span class="question-type-item disabled">单选 (10)</span>
<span class="type-separator">|</span>
<span class="question-type-item disabled">多选 (10)</span>
<span class="type-separator">|</span>
<span class="question-type-item disabled">判断 (10)</span>
<span class="type-separator">|</span>
<span class="question-type-item disabled">填空 (10)</span>
<span class="type-separator">|</span>
<span class="question-type-item disabled">简答 (4)</span>
</div>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="exam-content">
<div class="container">
<div class="exam-layout">
<!-- 左侧答题卡 -->
<div class="exam-sidebar">
<div class="answer-card">
<!-- 剩余时间 - 已禁用 -->
<div class="time-section disabled">
<h4>剩余时间</h4>
<div class="time-display">
<span class="time-number">00</span>
<span class="time-separator">:</span>
<span class="time-number">00</span>
<span class="time-separator">:</span>
<span class="time-number">00</span>
</div>
<div class="time-labels">
<span></span>
<span></span>
<span></span>
</div>
</div>
<!-- 答题卡 - 已禁用 -->
<div class="question-grid disabled">
<h4>答题卡</h4>
<div class="question-stats">
<span>共题 (44)</span>
</div>
<!-- 单选题 -->
<div class="question-section">
<h5>单选题 (10)</h5>
<div class="question-numbers">
<button v-for="n in 10" :key="n" class="question-btn disabled">
{{ String(n).padStart(2, '0') }}
</button>
</div>
</div>
<!-- 多选题 -->
<div class="question-section">
<h5>多选题 (10)</h5>
<div class="question-numbers">
<button v-for="n in 10" :key="n + 10" class="question-btn disabled">
{{ String(n + 10).padStart(2, '0') }}
</button>
</div>
</div>
<!-- 判断题 -->
<div class="question-section">
<h5>判断题 (10)</h5>
<div class="question-numbers">
<button v-for="n in 10" :key="n + 20" class="question-btn disabled">
{{ String(n + 20).padStart(2, '0') }}
</button>
</div>
</div>
<!-- 填空题 -->
<div class="question-section">
<h5>填空题 (10)</h5>
<div class="question-numbers">
<button v-for="n in 10" :key="n + 30" class="question-btn disabled">
{{ String(n + 30).padStart(2, '0') }}
</button>
</div>
</div>
<!-- 简答题 -->
<div class="question-section">
<h5>简答题 (4)</h5>
<div class="question-numbers">
<button v-for="n in 4" :key="n + 40" class="question-btn disabled">
{{ String(n + 40).padStart(2, '0') }}
</button>
</div>
</div>
<!-- 答题状态说明 - 已禁用 -->
<div class="legend disabled">
<div class="legend-item">
<div class="legend-icon unanswered"></div>
<span class="legend-text">未答</span>
</div>
<div class="legend-item">
<div class="legend-icon answered"></div>
<span class="legend-text">已答</span>
</div>
</div>
<!-- 交卷按钮 - 已禁用 -->
<div class="submit-section">
<button class="btn-submit-exam disabled" disabled>交卷</button>
</div>
</div>
</div>
</div>
<!-- 右侧提交成功内容 -->
<div class="exam-main">
<div class="submitted-content">
<!-- 成功图标和文字 -->
<div class="success-icon">
<div class="icon-container">
<div class="lightbulb-icon">💡</div>
<div class="check-icon"></div>
<div class="decorative-elements">
<div class="diamond diamond-1"></div>
<div class="diamond diamond-2"></div>
<div class="diamond diamond-3"></div>
</div>
</div>
</div>
<div class="success-message">
<h2>答题卡已成功提交</h2>
<p>预计1-3个工作日内完成阅卷</p>
<p>请耐心等待考试结果通知</p>
</div>
<!-- 返回按钮 -->
<div class="action-buttons">
<button class="btn-return" @click="returnToCourse">返回课程</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
//
const courseName = ref('人工智能基础与应用')
const examName = ref('期末考试')
//
const returnToCourse = () => {
//
const courseId = route.query.courseId || '1'
router.push(`/course/${courseId}/enrolled`)
}
onMounted(() => {
console.log('考试提交成功页面加载完成')
})
</script>
<style scoped>
/* 基础样式 */
.exam-submitted-page {
min-height: 100vh;
background: #f5f5f5;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 32px;
}
/* 考试中心标题 */
.exam-center-header {
background: white;
padding: 24px 0;
border-bottom: 1px solid #e8e8e8;
}
.center-title {
font-size: 32px;
font-weight: 600;
color: #333;
text-align: center;
margin: 0 0 8px 0;
}
.center-subtitle {
font-size: 14px;
color: #666;
text-align: center;
margin: 0;
}
/* 课程信息导航 */
.course-info-nav {
background: white;
padding: 16px 0;
border-bottom: 1px solid #e8e8e8;
}
.nav-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-left {
display: flex;
align-items: center;
gap: 8px;
}
.nav-item {
font-size: 14px;
color: #333;
}
.nav-separator {
color: #999;
}
.nav-right {
display: flex;
align-items: center;
gap: 8px;
}
.question-type-item {
font-size: 14px;
color: #666;
padding: 4px 8px;
border-radius: 4px;
}
.question-type-item.disabled {
color: #ccc;
}
.type-separator {
color: #ddd;
}
/* 主要内容区域 */
.exam-content {
padding: 24px 0;
}
.exam-layout {
display: flex;
gap: 24px;
align-items: flex-start;
}
/* 左侧答题卡 - 禁用状态 */
.exam-sidebar {
width: 300px;
flex-shrink: 0;
}
.answer-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.time-section.disabled {
text-align: center;
margin-bottom: 24px;
opacity: 0.5;
}
.time-section h4 {
font-size: 16px;
color: #999;
margin: 0 0 12px 0;
}
.time-display {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
margin-bottom: 8px;
}
.time-number {
font-size: 24px;
font-weight: 600;
color: #999;
background: #f5f5f5;
padding: 8px 12px;
border-radius: 4px;
min-width: 48px;
text-align: center;
}
.time-separator {
font-size: 24px;
color: #999;
}
.time-labels {
display: flex;
justify-content: center;
gap: 32px;
font-size: 12px;
color: #999;
}
.question-grid.disabled {
opacity: 0.5;
}
.question-grid h4 {
font-size: 16px;
color: #999;
margin: 0 0 16px 0;
}
.question-stats {
font-size: 14px;
color: #999;
margin-bottom: 16px;
}
.question-section {
margin-bottom: 20px;
}
.question-section h5 {
font-size: 14px;
color: #999;
margin: 0 0 8px 0;
}
.question-numbers {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.question-btn.disabled {
background: #f5f5f5;
color: #ccc;
border: 1px solid #e0e0e0;
padding: 8px;
border-radius: 4px;
font-size: 12px;
cursor: not-allowed;
}
.legend.disabled {
display: flex;
justify-content: space-around;
margin: 20px 0;
opacity: 0.5;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-icon {
width: 16px;
height: 16px;
border-radius: 2px;
}
.legend-icon.unanswered {
background: #f5f5f5;
border: 1px solid #e0e0e0;
}
.legend-icon.answered {
background: #e3f2fd;
border: 1px solid #2196f3;
}
.legend-text {
font-size: 12px;
color: #999;
}
.submit-section {
margin-top: 20px;
}
.btn-submit-exam.disabled {
width: 100%;
background: #f5f5f5;
color: #ccc;
border: 1px solid #e0e0e0;
padding: 12px;
border-radius: 4px;
font-size: 16px;
cursor: not-allowed;
}
/* 右侧提交成功内容 */
.exam-main {
flex: 1;
background: white;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
min-height: 500px;
}
.submitted-content {
text-align: center;
max-width: 400px;
}
.success-icon {
margin-bottom: 32px;
}
.icon-container {
position: relative;
display: inline-block;
}
.lightbulb-icon {
font-size: 64px;
color: #ffc107;
position: relative;
z-index: 2;
}
.check-icon {
position: absolute;
top: 50%;
right: -10px;
transform: translateY(-50%);
background: #4caf50;
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
z-index: 3;
}
.decorative-elements {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.diamond {
position: absolute;
color: #2196f3;
font-size: 12px;
animation: float 3s ease-in-out infinite;
}
.diamond-1 {
top: 10px;
left: -20px;
animation-delay: 0s;
}
.diamond-2 {
top: -10px;
right: -15px;
animation-delay: 1s;
}
.diamond-3 {
bottom: 5px;
left: -15px;
animation-delay: 2s;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.success-message h2 {
font-size: 24px;
color: #333;
margin: 0 0 16px 0;
font-weight: 600;
}
.success-message p {
font-size: 16px;
color: #666;
margin: 8px 0;
line-height: 1.5;
}
.action-buttons {
margin-top: 32px;
}
.btn-return {
background: #2196f3;
color: white;
border: none;
padding: 12px 32px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-return:hover {
background: #1976d2;
}
/* 响应式设计 */
@media (max-width: 768px) {
.exam-layout {
flex-direction: column;
}
.exam-sidebar {
width: 100%;
}
.exam-main {
padding: 24px;
}
.success-message h2 {
font-size: 20px;
}
.lightbulb-icon {
font-size: 48px;
}
}
</style>

View File

@ -69,7 +69,7 @@
</n-button>
</div>
<div class="courses-grid">
<div class="course-card" v-for="course in popularCourses" :key="course.id">
<div class="course-card" v-for="course in popularCourses" :key="course.id" @click="goToCourseDetail(course.id)">
<div class="course-image">
<img :src="course.thumbnail" :alt="course.title" />
</div>
@ -93,7 +93,7 @@
<img src="/images/专题训练.png" alt="专题训练" class="section-title-image" />
<div class="section-subtitle">{{ t('home.specialTraining.subtitle') }}</div>
</div>
<n-button text type="primary" class="view-all-btn" @click="$router.push('/training')">
<n-button text type="primary" class="view-all-btn" @click="$router.push('/special-training')">
{{ t('home.specialTraining.viewAll') }} >
</n-button>
</div>
@ -226,6 +226,7 @@ import { useCourseStore } from '@/stores/course'
import { useAuth } from '@/composables/useAuth'
import LoginModal from '@/components/auth/LoginModal.vue'
import RegisterModal from '@/components/auth/RegisterModal.vue'
import { getPopularCourses } from '@/data/mockCourses'
const { t, locale } = useI18n()
const router = useRouter()
@ -242,58 +243,19 @@ const bannerAlt = computed(() => {
})
//
const popularCourses = computed(() => [
{
id: 1,
title: t('home.courses.pythonBasics'),
thumbnail: '/images/courses/course1.png',
category: '基础课程',
studentsCount: 324,
rating: 4.8,
price: 299,
originalPrice: 399
},
{
id: 2,
title: t('home.courses.newEnergyMaterials'),
thumbnail: '/images/courses/course2.png',
category: '专业课程',
studentsCount: 324,
rating: 4.7,
price: 199,
originalPrice: 299
},
{
id: 3,
title: t('home.courses.pptDesign'),
thumbnail: '/images/courses/course3.png',
category: '实用技能',
studentsCount: 324,
rating: 4.9,
price: 399,
originalPrice: 499
},
{
id: 4,
title: t('home.courses.newEnergyVehicle'),
thumbnail: '/images/courses/course4.png',
category: '前沿技术',
studentsCount: 324,
rating: 4.6,
price: 499,
originalPrice: 699
},
{
id: 5,
title: t('home.courses.artificialIntelligence'),
thumbnail: '/images/courses/course5.png',
category: 'AI技术',
studentsCount: 324,
rating: 4.8,
price: 359,
originalPrice: 459
}
])
const popularCourses = computed(() => {
const courses = getPopularCourses()
return courses.map(course => ({
id: course.id,
title: course.title,
thumbnail: course.thumbnail,
category: course.category.name,
studentsCount: course.studentsCount,
rating: course.rating,
price: course.price,
originalPrice: course.originalPrice
}))
})
//
const specialTrainings = computed(() => [
@ -377,6 +339,11 @@ const featuredReviews = computed(() => [
}
])
//
const goToCourseDetail = (courseId: number) => {
router.push(`/course/${courseId}`)
}
// -
const handleEnrollCourse = (courseId: number) => {
//

View File

@ -0,0 +1,268 @@
<template>
<div class="local-video-demo">
<div class="container">
<h1>本地视频播放演示</h1>
<p class="description">
这个页面演示了如何使用CKPlayer播放器播放public文件夹中的本地视频文件
</p>
<div class="demo-section">
<h2>本地视频播放</h2>
<div class="video-wrapper">
<VideoPlayer
:use-local-video="true"
title="本地视频演示"
description="这是来自public/video/first.mp4的本地视频文件使用CKPlayer播放器进行播放。"
:autoplay="false"
:show-controls="true"
@play="onPlay"
@pause="onPause"
@ended="onEnded"
@error="onError"
/>
</div>
</div>
<div class="info-section">
<h3>技术说明</h3>
<ul>
<li>使用CKPlayer播放器替代原生HTML5 video元素</li>
<li>支持本地视频文件播放public/video/first.mp4</li>
<li>支持HLS流媒体和MP4格式</li>
<li>提供完整的播放控制功能</li>
<li>响应式设计支持移动端</li>
</ul>
</div>
<div class="controls-section">
<h3>播放控制</h3>
<div class="control-buttons">
<button @click="playVideo" class="control-btn">播放</button>
<button @click="pauseVideo" class="control-btn">暂停</button>
<button @click="seekVideo(30)" class="control-btn">跳转到30秒</button>
<button @click="setVideoVolume(50)" class="control-btn">音量50%</button>
</div>
</div>
<div class="status-section">
<h3>播放状态</h3>
<div class="status-info">
<div class="status-item">
<strong>播放状态:</strong> {{ isPlaying ? '播放中' : '已暂停' }}
</div>
<div class="status-item">
<strong>错误信息:</strong> {{ errorMessage || '无' }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import VideoPlayer from '@/components/VideoPlayer.vue'
//
const isPlaying = ref(false)
const errorMessage = ref('')
//
const videoPlayerRef = ref<InstanceType<typeof VideoPlayer>>()
//
const onPlay = () => {
isPlaying.value = true
console.log('视频开始播放')
}
const onPause = () => {
isPlaying.value = false
console.log('视频暂停')
}
const onEnded = () => {
isPlaying.value = false
console.log('视频播放结束')
}
const onError = (error: Event) => {
errorMessage.value = '视频播放出错'
console.error('视频播放错误:', error)
}
//
const playVideo = () => {
if (videoPlayerRef.value) {
videoPlayerRef.value.play()
}
}
const pauseVideo = () => {
if (videoPlayerRef.value) {
videoPlayerRef.value.pause()
}
}
const seekVideo = (time: number) => {
if (videoPlayerRef.value) {
videoPlayerRef.value.seek(time)
}
}
const setVideoVolume = (volume: number) => {
if (videoPlayerRef.value) {
videoPlayerRef.value.setVolume(volume)
}
}
</script>
<style scoped>
.local-video-demo {
min-height: 100vh;
background: #f5f5f5;
padding: 20px 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 10px;
}
.description {
text-align: center;
color: #666;
margin-bottom: 40px;
font-size: 16px;
}
.demo-section {
background: white;
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.demo-section h2 {
margin-top: 0;
margin-bottom: 20px;
color: #333;
}
.video-wrapper {
max-width: 800px;
margin: 0 auto;
}
.info-section,
.controls-section,
.status-section {
background: white;
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.info-section h3,
.controls-section h3,
.status-section h3 {
margin-top: 0;
margin-bottom: 16px;
color: #333;
}
.info-section ul {
margin: 0;
padding-left: 20px;
}
.info-section li {
margin-bottom: 8px;
color: #666;
}
.control-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.control-btn {
padding: 8px 16px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.control-btn:hover {
background: #40a9ff;
}
.control-btn.secondary {
background: #f5f5f5;
color: #666;
}
.control-btn.secondary:hover {
background: #e6e6e6;
}
.status-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-item {
color: #666;
}
.status-item strong {
color: #333;
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
padding: 0 16px;
}
.demo-section,
.info-section,
.controls-section,
.status-section {
padding: 16px;
}
.control-buttons {
justify-content: center;
}
.control-btn {
flex: 1;
min-width: 120px;
}
}
</style>

View File

@ -0,0 +1,540 @@
<template>
<div class="special-training-page">
<!-- 页面标题区域 -->
<div class="page-header">
<div class="container">
<h1 class="page-title">专题训练</h1>
<p class="page-subtitle">开展各类专题训练活动提升学习效果</p>
</div>
</div>
<!-- 筛选标签区域 -->
<div class="filter-section">
<div class="container">
<div class="filter-tabs">
<button
v-for="tab in filterTabs"
:key="tab.id"
class="filter-tab"
:class="{ 'active': activeTab === tab.id }"
@click="setActiveTab(tab.id)"
>
{{ tab.name }}
</button>
</div>
</div>
</div>
<!-- 训练项目列表 -->
<div class="training-content">
<div class="container">
<div class="training-grid">
<div
v-for="training in paginatedTrainings"
:key="training.id"
class="training-card"
>
<div class="card-content">
<h3 class="training-title">{{ training.title }}</h3>
<p class="training-description">{{ training.description }}</p>
<div class="training-info">
<span class="training-time">
开课{{ training.startDate }} 结课{{ training.endDate }}
</span>
</div>
<div class="card-footer">
<button class="btn-join" @click="joinTraining(training.id)">
立即参与
</button>
<span class="participant-count">已有{{ training.participants }}人参与</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"
:class="{ 'active': currentPage === page }"
@click="goToPage(page)"
>
{{ 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, onMounted } from 'vue'
// const router = useRouter() //
//
const filterTabs = ref([
{ id: 'all', name: '全部' },
{ id: 'interview', name: '公务员面试' },
{ id: 'writing', name: '公文写作' },
{ id: 'law', name: '法律法规' },
{ id: 'management', name: '基本素质' },
{ id: 'communication', name: '专业能力' },
{ id: 'leadership', name: '专业素养' }
])
const activeTab = ref('all')
//
const currentPage = ref(1)
const pageSize = ref(9) // 93x3
//
const trainings = ref([
{
id: 1,
title: '全国教师网络研修班',
description: '针对中小学教师开展的专业能力提升培训,包含教学方法、课程设计、学生管理等多个方面的内容,提升教师专业素养和教学能力。',
startDate: '2024年7月1日',
endDate: '2024年8月1日',
participants: 1234,
category: 'all'
},
{
id: 2,
title: '全国教师网络研修班',
description: '针对中小学教师开展的专业能力提升培训,包含教学方法、课程设计、学生管理等多个方面的内容。',
startDate: '2024年7月1日',
endDate: '2024年8月1日',
participants: 1234,
category: 'all'
},
{
id: 3,
title: '全国教师网络研修班',
description: '针对中小学教师开展的专业能力提升培训,包含教学方法、课程设计、学生管理等多个方面的内容。',
startDate: '2024年7月1日',
endDate: '2024年8月1日',
participants: 1234,
category: 'all'
},
{
id: 4,
title: '全国教师网络研修班',
description: '针对中小学教师开展的专业能力提升培训,包含教学方法、课程设计、学生管理等多个方面的内容。',
startDate: '2024年7月1日',
endDate: '2024年8月1日',
participants: 1234,
category: 'all'
},
{
id: 5,
title: '全国教师网络研修班',
description: '针对中小学教师开展的专业能力提升培训,包含教学方法、课程设计、学生管理等多个方面的内容。',
startDate: '2024年7月1日',
endDate: '2024年8月1日',
participants: 1234,
category: 'all'
},
{
id: 6,
title: '全国教师网络研修班',
description: '针对中小学教师开展的专业能力提升培训,包含教学方法、课程设计、学生管理等多个方面的内容。',
startDate: '2024年7月1日',
endDate: '2024年8月1日',
participants: 1234,
category: 'all'
}
])
//
for (let i = 7; i <= 30; i++) {
trainings.value.push({
id: i,
title: '全国教师网络研修班',
description: '针对中小学教师开展的专业能力提升培训,包含教学方法、课程设计、学生管理等多个方面的内容。',
startDate: '2024年7月1日',
endDate: '2024年8月1日',
participants: Math.floor(Math.random() * 2000) + 500,
category: 'all'
})
}
//
const filteredTrainings = computed(() => {
if (activeTab.value === 'all') {
return trainings.value
}
return trainings.value.filter(training => training.category === activeTab.value)
})
const totalPages = computed(() => {
return Math.ceil(filteredTrainings.value.length / pageSize.value)
})
const paginatedTrainings = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredTrainings.value.slice(start, end)
})
const visiblePages = computed(() => {
const pages = []
const total = totalPages.value
const current = currentPage.value
// 2
const start = Math.max(1, current - 2)
const end = Math.min(total, current + 2)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
//
const setActiveTab = (tabId: string) => {
activeTab.value = tabId
currentPage.value = 1 //
}
const goToPage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
}
}
const joinTraining = (trainingId: number) => {
//
console.log('参与训练:', trainingId)
// router.push(`/training/${trainingId}`)
}
onMounted(() => {
console.log('专题训练页面加载完成')
})
</script>
<style scoped>
/* 基础样式 */
.special-training-page {
min-height: 100vh;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
/* 页面标题 */
.page-header {
background: white;
padding: 40px 0;
text-align: center;
border-bottom: 1px solid #e8e8e8;
}
.page-title {
font-size: 32px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
.page-subtitle {
font-size: 14px;
color: #666;
margin: 0;
}
/* 筛选标签 */
.filter-section {
background: white;
padding: 20px 0;
border-bottom: 1px solid #e8e8e8;
}
.filter-tabs {
display: flex;
gap: 24px;
justify-content: center;
flex-wrap: wrap;
}
.filter-tab {
background: none;
border: none;
padding: 8px 16px;
font-size: 14px;
color: #666;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
}
.filter-tab:hover {
color: #1890ff;
background: #f0f8ff;
}
.filter-tab.active {
color: #1890ff;
background: #e6f7ff;
font-weight: 500;
}
/* 训练内容区域 */
.training-content {
padding: 40px 0;
}
.training-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 40px;
}
/* 训练卡片 */
.training-card {
background: linear-gradient(135deg, #e8f4fd 0%, #d1e9f6 50%, #b8ddf0 100%);
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.training-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(24, 144, 255, 0.15);
}
.card-content {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
min-height: 200px;
}
.training-title {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin: 0 0 10px 0;
line-height: 1.4;
}
.training-description {
font-size: 13px;
color: #666;
line-height: 1.5;
margin: 0 0 16px 0;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.training-info {
margin-bottom: 16px;
}
.training-time {
font-size: 11px;
color: #888;
display: block;
line-height: 1.4;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
}
.btn-join {
background: #1890ff;
color: white;
border: none;
padding: 6px 16px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
font-weight: 500;
}
.btn-join:hover {
background: #40a9ff;
}
.participant-count {
font-size: 11px;
color: #888;
}
/* 分页器 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 40px;
}
.page-btn {
background: white;
border: 1px solid #d9d9d9;
color: #666;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
min-width: 40px;
}
.page-btn:hover:not(:disabled) {
border-color: #1890ff;
color: #1890ff;
}
.page-btn.active {
background: #1890ff;
border-color: #1890ff;
color: white;
}
.page-btn:disabled {
background: #f5f5f5;
border-color: #d9d9d9;
color: #ccc;
cursor: not-allowed;
}
.page-info {
font-size: 14px;
color: #666;
margin-left: 16px;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.training-grid {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
}
@media (max-width: 768px) {
.container {
padding: 0 16px;
}
.page-header {
padding: 30px 0;
}
.page-title {
font-size: 24px;
}
.filter-tabs {
gap: 12px;
}
.filter-tab {
padding: 6px 12px;
font-size: 13px;
}
.training-content {
padding: 30px 0;
}
.training-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.card-content {
padding: 20px;
}
.training-title {
font-size: 16px;
}
.pagination {
flex-wrap: wrap;
gap: 4px;
}
.page-btn {
padding: 6px 10px;
font-size: 13px;
min-width: 36px;
}
.page-info {
margin-left: 8px;
font-size: 13px;
}
}
@media (max-width: 480px) {
.filter-tabs {
justify-content: flex-start;
overflow-x: auto;
padding-bottom: 8px;
}
.filter-tab {
white-space: nowrap;
flex-shrink: 0;
}
.card-footer {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
.btn-join {
width: 100%;
text-align: center;
}
.participant-count {
text-align: center;
}
}
</style>

View File

@ -1,242 +0,0 @@
<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>

View File

@ -1,120 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import ExamNotice from '../ExamNotice.vue'
// Mock router
const mockRouter = createRouter({
history: createWebHistory(),
routes: [
{
path: '/course/:courseId/exam/:sectionId/notice',
name: 'ExamNotice',
component: ExamNotice
},
{
path: '/course/:courseId/exam/:sectionId',
name: 'Exam',
component: { template: '<div>Exam Page</div>' }
},
{
path: '/course/:courseId',
name: 'CourseDetail',
component: { template: '<div>Course Detail</div>' }
}
]
})
describe('ExamNotice', () => {
let wrapper: any
beforeEach(async () => {
// 设置路由参数
await mockRouter.push('/course/1/exam/20/notice?courseName=测试课程&examName=期末考试')
wrapper = mount(ExamNotice, {
global: {
plugins: [mockRouter]
}
})
})
it('应该正确渲染考前须知页面', () => {
expect(wrapper.find('.exam-notice-page').exists()).toBe(true)
expect(wrapper.find('.center-title').text()).toBe('考试中心')
expect(wrapper.find('.notice-title').text()).toBe('考前须知')
})
it('应该显示所有考前须知条目', () => {
const noticeItems = wrapper.findAll('.notice-item')
expect(noticeItems.length).toBe(12) // 应该有12条须知
// 检查第一条须知
expect(noticeItems[0].find('.item-number').text()).toBe('1.')
expect(noticeItems[0].find('.item-text').text()).toContain('考试时间为2024年8月31日-9月30日')
})
it('应该有返回和开始考试按钮', () => {
const backButton = wrapper.find('.btn-secondary')
const startButton = wrapper.find('.btn-primary')
expect(backButton.exists()).toBe(true)
expect(startButton.exists()).toBe(true)
expect(backButton.text()).toContain('返回上级')
expect(startButton.text()).toBe('我已阅读,开始考试')
})
it('点击返回按钮应该跳转到课程详情页', async () => {
const pushSpy = vi.spyOn(mockRouter, 'push')
const backButton = wrapper.find('.btn-secondary')
await backButton.trigger('click')
expect(pushSpy).toHaveBeenCalledWith('/course/1')
})
it('点击开始考试按钮应该跳转到考试页面', async () => {
const pushSpy = vi.spyOn(mockRouter, 'push')
const startButton = wrapper.find('.btn-primary')
await startButton.trigger('click')
expect(pushSpy).toHaveBeenCalledWith({
name: 'Exam',
params: {
courseId: 1,
sectionId: 20
},
query: {
courseName: '测试课程',
examName: '期末考试',
fromNotice: 'true'
}
})
})
it('应该正确显示浏览次数', () => {
const viewCount = wrapper.find('.view-count')
expect(viewCount.text()).toContain('浏览次数1024')
})
it('应该有正确的页面标题和副标题', () => {
const title = wrapper.find('.center-title')
const subtitle = wrapper.find('.center-subtitle')
expect(title.text()).toBe('考试中心')
expect(subtitle.text()).toBe('诚信考试规范,考试过程规范,严格监考规范')
})
it('应该有正确的导航菜单', () => {
const navItem = wrapper.find('.nav-menu .nav-item')
expect(navItem.exists()).toBe(true)
expect(navItem.find('.nav-text').text()).toBe('考前须知')
expect(navItem.classes()).toContain('active')
})
it('应该没有页脚信息(已移除)', () => {
const footer = wrapper.find('.footer')
expect(footer.exists()).toBe(false)
})
})