feat:ai对话和我们的视频播放

This commit is contained in:
小张 2025-09-23 09:30:52 +08:00
parent f152497cab
commit 2e3b6a6cf7
4 changed files with 927 additions and 159 deletions

396
src/api/modules/ai.ts Normal file
View File

@ -0,0 +1,396 @@
import { ApiRequest } from '../request'
import type { ApiResponse } from '../types'
// AI聊天请求接口
export interface AIChatRequest {
appId: string
content: string
responseMode: string
[property: string]: any
}
// AI聊天响应接口
export interface AIChatResponse extends ApiResponse {
data: {
content: string
messageId?: string
conversationId?: string
[property: string]: any
}
}
// AI聊天API类
export class AIApi {
/**
* AI聊天消息 -
* @param content
* @returns Promise<any>
*/
static async sendChatMessage(content: string): Promise<any> {
const request: AIChatRequest = {
appId: "1970031066993217537",
content: content,
responseMode: "streaming"
}
console.log('测试AI接口连接:', request)
try {
const response = await ApiRequest.post('/airag/chat/send', request)
console.log('AI接口响应:', response)
return response
} catch (error) {
console.error('AI聊天接口调用失败:', error)
throw error
}
}
/**
* fetch测试方法
* @param content
*/
static async testFetchConnection(content: string): Promise<void> {
const request: AIChatRequest = {
appId: "1970031066993217537",
content: content,
responseMode: "streaming"
}
const token = localStorage.getItem('X-Access-Token') || localStorage.getItem('token') || ''
// 在开发环境使用代理路径生产环境使用完整URL
const apiUrl = import.meta.env.DEV
? '/jeecgboot/airag/chat/send' // 开发环境使用代理
: `${import.meta.env.VITE_API_BASE_URL}/airag/chat/send` // 生产环境使用完整URL
console.log('测试fetch连接:', {
url: apiUrl,
request,
hasToken: !!token,
baseUrl: import.meta.env.VITE_API_BASE_URL,
isDev: import.meta.env.DEV
})
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Request-Time': Date.now().toString(),
...(token && {
'X-Access-Token': token
})
},
body: JSON.stringify(request)
})
console.log('Fetch响应:', {
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries())
})
if (!response.ok) {
const errorText = await response.text()
console.error('Fetch错误响应:', errorText)
throw new Error(`HTTP ${response.status}: ${errorText}`)
}
const responseText = await response.text()
console.log('响应内容:', responseText)
} catch (error) {
console.error('Fetch测试失败:', error)
throw error
}
}
/**
* AI聊天消息 - (EventStream)
* @param content
* @param onMessage
* @returns Promise<void>
*/
static async sendChatMessageStream(
content: string,
onMessage: (chunk: string) => void,
onComplete?: () => void,
onError?: (error: any) => void
): Promise<void> {
const request: AIChatRequest = {
appId: "1970031066993217537",
content: content,
responseMode: "streaming"
}
try {
// 获取token - 使用项目标准的token获取方式
const token = localStorage.getItem('X-Access-Token') || localStorage.getItem('token') || ''
// 在开发环境使用代理路径生产环境使用完整URL
const apiUrl = import.meta.env.DEV
? '/jeecgboot/airag/chat/send' // 开发环境使用代理
: `${import.meta.env.VITE_API_BASE_URL}/airag/chat/send` // 生产环境使用完整URL
console.log('准备发送AI请求:', {
url: apiUrl,
request,
hasToken: !!token,
isDev: import.meta.env.DEV
})
// 使用fetch进行SSE流式请求
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Request-Time': Date.now().toString(),
// 如果需要认证添加token
...(token && {
'X-Access-Token': token
})
},
body: JSON.stringify(request)
})
console.log('AI请求响应状态:', response.status, response.statusText)
console.log('响应头:', Object.fromEntries(response.headers.entries()))
if (!response.ok) {
const errorText = await response.text().catch(() => '无法读取错误信息')
console.error('AI请求失败:', {
status: response.status,
statusText: response.statusText,
errorText
})
throw new Error(`HTTP error! status: ${response.status} - ${response.statusText}. ${errorText}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('无法获取响应流')
}
const decoder = new TextDecoder()
let buffer = ''
let messageEndReceived = false
let endTimeout: NodeJS.Timeout | null = null
while (true) {
const { done, value } = await reader.read()
if (done) {
console.log('SSE流结束')
// 清除可能存在的超时
if (endTimeout) {
clearTimeout(endTimeout)
}
onComplete?.()
break
}
const chunk = decoder.decode(value, { stream: true })
console.log('收到原始数据块:', chunk)
// 如果收到新数据,清除结束超时
if (endTimeout) {
clearTimeout(endTimeout)
endTimeout = null
}
buffer += chunk
// 按行分割处理SSE数据
const lines = buffer.split('\n')
buffer = lines.pop() || '' // 保留最后一个可能不完整的行
for (const line of lines) {
const trimmedLine = line.trim()
if (trimmedLine === '') {
// 空行,跳过
continue
}
// 处理SSE数据行 - 支持 "data:" 和 "data: " 两种格式
let jsonStr = ''
let isDataLine = false
if (trimmedLine.startsWith('data: ')) {
// 标准格式data: {"message":"内容"}
jsonStr = trimmedLine.slice(6) // 去掉 "data: " 前缀
isDataLine = true
} else if (trimmedLine.startsWith('data:')) {
// 非标准格式data:{"message":"内容"}
jsonStr = trimmedLine.slice(5) // 去掉 "data:" 前缀
isDataLine = true
}
if (isDataLine) {
if (jsonStr === '[DONE]') {
console.log('收到结束标志')
onComplete?.()
return
}
try {
const data = JSON.parse(jsonStr)
console.log('收到SSE数据:', data)
// 处理不同的事件类型
console.log('处理事件:', data.event, '消息内容:', data.data?.message || '无消息')
if (data.event === 'MESSAGE_END') {
console.log('收到消息结束标志,设置延迟结束')
messageEndReceived = true
// 清除之前的超时
if (endTimeout) {
clearTimeout(endTimeout)
}
// 设置2秒超时如果没有新数据就结束
endTimeout = setTimeout(() => {
console.log('超时结束,调用完成回调')
onComplete?.()
}, 2000)
} else if (data.event === 'INIT_REQUEST_ID') {
console.log('收到初始化事件,跳过')
// 初始化事件,不处理消息内容,但继续处理后续数据
// 不要return让循环继续处理下一行
} else if (data.event === 'MESSAGE' && data.data && data.data.message) {
// 正常的消息事件
console.log('发送消息块到界面:', data.data.message)
onMessage(data.data.message)
} else if (data.data && data.data.message) {
// 兼容其他格式
console.log('发送消息块到界面(兼容格式):', data.data.message)
onMessage(data.data.message)
} else if (data.message) {
console.log('发送消息块到界面(直接格式):', data.message)
onMessage(data.message)
} else if (data.content) {
console.log('发送消息块到界面(content格式):', data.content)
onMessage(data.content)
} else if (typeof data === 'string') {
console.log('发送消息块到界面(字符串格式):', data)
onMessage(data)
} else {
console.log('未处理的数据格式:', data)
}
} catch (parseError) {
console.warn('解析SSE JSON数据失败:', parseError, '原始数据:', jsonStr)
// 如果解析失败,尝试直接使用原始字符串
if (jsonStr && jsonStr !== 'null') {
onMessage(jsonStr)
}
}
} else if (trimmedLine.startsWith('event: ') || trimmedLine.startsWith('id: ') || trimmedLine.startsWith('retry: ')) {
// SSE元数据行记录但不处理
console.log('SSE元数据:', trimmedLine)
} else {
// 其他格式的数据可能是直接的JSON
console.log('其他格式数据:', trimmedLine)
try {
const data = JSON.parse(trimmedLine)
// 处理不同的事件类型
if (data.event === 'MESSAGE_END') {
console.log('收到消息结束标志,但继续监听可能的后续数据')
// 不要立即return继续监听可能的后续数据
} else if (data.event === 'INIT_REQUEST_ID') {
console.log('收到初始化事件,跳过')
// 不要return让循环继续处理下一行
} else if (data.event === 'MESSAGE' && data.data && data.data.message) {
onMessage(data.data.message)
} else if (data.data && data.data.message) {
onMessage(data.data.message)
} else if (data.message) {
onMessage(data.message)
}
} catch (e) {
// 不是JSON格式可能是其他SSE元数据忽略
console.log('跳过非JSON数据:', trimmedLine)
}
}
}
}
} catch (error) {
console.error('AI流式聊天接口调用失败:', error)
onError?.(error)
throw error
}
}
/**
* 使EventSource发送AI聊天消息 -
* @param content
* @param onMessage
* @returns Promise<void>
*/
static async sendChatMessageWithEventSource(
content: string,
onMessage: (chunk: string) => void,
onComplete?: () => void,
onError?: (error: any) => void
): Promise<void> {
const request: AIChatRequest = {
appId: "1970031066993217537",
content: content,
responseMode: "streaming"
}
try {
// 获取token
const token = localStorage.getItem('token')
// 构建URL参数
const url = new URL(`${import.meta.env.VITE_API_BASE_URL}/airag/chat/send`)
// 创建EventSource连接
const eventSource = new EventSource(url.toString())
// 发送POST请求数据EventSource只支持GET所以这里需要特殊处理
// 注意标准EventSource不支持POST如果API必须使用POST需要使用fetch方式
eventSource.onmessage = (event) => {
console.log('EventSource收到消息:', event.data)
if (event.data === '[DONE]') {
eventSource.close()
onComplete?.()
return
}
try {
const data = JSON.parse(event.data)
if (data.data && data.data.message) {
onMessage(data.data.message)
} else if (data.message) {
onMessage(data.message)
} else if (data.content) {
onMessage(data.content)
}
} catch (e) {
onMessage(event.data)
}
}
eventSource.onerror = (error) => {
console.error('EventSource错误:', error)
eventSource.close()
onError?.(error)
}
eventSource.onopen = () => {
console.log('EventSource连接已建立')
}
} catch (error) {
console.error('EventSource聊天接口调用失败:', error)
onError?.(error)
throw error
}
}
}

View File

@ -1124,6 +1124,11 @@ export class CourseApi {
// 解析fileUrl中的多个清晰度URL
const qualities = this.parseVideoQualities(video.fileUrl)
// 选择默认清晰度优先选择720p如果没有则选择第一个可用的清晰度
const defaultQuality = qualities.find(q => q.value === '720')?.value ||
qualities.find(q => q.value === '480')?.value ||
qualities[0]?.value || '360'
return {
id: video.id,
name: video.name,
@ -1133,8 +1138,8 @@ export class CourseApi {
duration: video.duration,
fileSize: video.fileSize,
qualities: qualities,
defaultQuality: '360', // 默认360p
currentQuality: '360' // 当前选中360p
defaultQuality: defaultQuality,
currentQuality: defaultQuality
}
})
@ -1182,24 +1187,28 @@ export class CourseApi {
const urls = fileUrl.split(',').map(url => url.trim()).filter(url => url.length > 0)
console.log('🔍 分割后的视频URL:', urls)
// 支持的清晰度列表(按优先级排序)
const supportedQualities = [
{ value: '1080', label: '1080p' },
{ value: '720', label: '720p' },
{ value: '480', label: '480p' },
{ value: '360', label: '360p' }
]
// 根据URL中的清晰度标识来解析
urls.forEach((url) => {
let quality = { label: '360p', value: '360', url: url }
// 根据URL数量分配清晰度
// 假设URL按清晰度从高到低排列1080p, 720p, 480p, 360p
urls.forEach((url, index) => {
if (index < supportedQualities.length) {
qualities.push({
label: supportedQualities[index].label,
value: supportedQualities[index].value,
url: url
})
// 从URL中提取清晰度信息
if (url.includes('/1080p/')) {
quality = { label: '1080p', value: '1080', url: url }
} else if (url.includes('/720p/')) {
quality = { label: '720p', value: '720', url: url }
} else if (url.includes('/480p/')) {
quality = { label: '480p', value: '480', url: url }
} else if (url.includes('/360p/')) {
quality = { label: '360p', value: '360', url: url }
}
qualities.push(quality)
})
// 按清晰度从高到低排序
qualities.sort((a, b) => {
const order = { '1080': 4, '720': 3, '480': 2, '360': 1 }
return (order[b.value as keyof typeof order] || 0) - (order[a.value as keyof typeof order] || 0)
})
// 如果没有解析到任何清晰度使用第一个URL作为360p
@ -1295,6 +1304,91 @@ export class CourseApi {
}
}
// 获取更多课程列表
static async getMoreCourses(courseId: string): Promise<ApiResponse<any[]>> {
try {
console.log('🔍 获取更多课程数据课程ID:', courseId)
console.log('🔍 API请求URL: /aiol/aiolCourse/' + courseId + '/more_courses')
const response = await ApiRequest.get<any>(`/aiol/aiolCourse/${courseId}/more_courses`)
console.log('🔍 更多课程API响应:', response)
// 处理后端响应格式
if (response.data && response.data.success && response.data.result) {
console.log('✅ 响应状态码:', response.data.code)
console.log('✅ 响应消息:', response.data.message)
console.log('✅ 原始课程数据:', response.data.result)
console.log('✅ 课程数据数量:', response.data.result.length || 0)
// 适配数据格式
const adaptedCourses = response.data.result.map((course: any) => ({
id: course.id,
title: course.name || '',
description: this.stripHtmlTags(course.description || ''),
coverImage: course.cover || '',
thumbnail: course.cover || '',
instructor: course.teacherList?.map((t: any) => t.name).join(', ') || '未知讲师',
duration: course.duration || 0,
chaptersCount: course.chaptersCount || 0,
lessonsCount: course.lessonsCount || 0,
enrolledCount: course.enrollCount || 0,
price: course.price || 0,
originalPrice: course.originalPrice || 0,
rating: course.rating || 5,
category: course.subject || '',
tags: course.tags || [],
startTime: course.startTime || '',
endTime: course.endTime || '',
isEnrolled: course.isEnrolled || false,
createdAt: course.createTime ? new Date(course.createTime).getTime() : Date.now(),
updatedAt: course.updateTime ? new Date(course.updateTime).getTime() : Date.now()
}))
console.log('✅ 适配后的课程数据:', adaptedCourses)
return {
code: response.data.code,
message: response.data.message,
data: adaptedCourses
}
} else {
console.warn('⚠️ API返回的数据结构不正确:', response.data)
return {
code: 500,
message: '数据格式错误',
data: []
}
}
} catch (error) {
console.error('❌ 更多课程API调用失败:', error)
console.error('❌ 错误详情:', {
message: (error as Error).message,
stack: (error as Error).stack,
response: (error as any).response?.data,
status: (error as any).response?.status,
statusText: (error as any).response?.statusText
})
// 重新抛出错误,不使用模拟数据
throw error
}
}
// 去除HTML标签的辅助方法
private static stripHtmlTags(html: string): string {
if (!html) return ''
// 创建一个临时的div元素来解析HTML
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
// 获取纯文本内容
const textContent = tempDiv.textContent || tempDiv.innerText || ''
// 清理多余的空白字符
return textContent.trim().replace(/\s+/g, ' ')
}
}
export default CourseApi

View File

@ -183,12 +183,16 @@ const initializePlayer = async (videoUrl?: string) => {
player = null
}
// HLS
const isHLS = url.includes('.m3u8')
console.log('🔍 视频类型检测:', { url, isHLS })
// DPlayer
const dplayerConfig: any = {
container: dplayerContainer.value,
video: {
url: url,
type: 'auto'
type: isHLS ? 'hls' : 'auto'
},
autoplay: props.autoplay,
theme: '#007bff',
@ -211,6 +215,29 @@ const initializePlayer = async (videoUrl?: string) => {
]
}
//
if (props.videoQualities && props.videoQualities.length > 1) {
console.log('🔧 配置多清晰度支持:', props.videoQualities)
dplayerConfig.quality = {
default: props.currentQuality || props.videoQualities[0]?.value,
options: props.videoQualities.map(q => ({
name: q.label,
url: q.url,
type: isHLS ? 'hls' : 'auto'
}))
}
}
// HLSHLS.js
if (isHLS) {
console.log('🔧 配置HLS支持')
dplayerConfig.pluginOptions = {
hls: {
// HLS.js
}
}
}
// DPlayer
console.log('🔍 检查清晰度配置:', {
videoQualities: props.videoQualities,
@ -224,31 +251,56 @@ const initializePlayer = async (videoUrl?: string) => {
availableQualities: props.videoQualities?.length || 0
})
console.log('🔨 创建DPlayer实例配置:', {
...dplayerConfig,
quality: dplayerConfig.quality ? '已配置多清晰度' : '单一清晰度'
})
player = new DPlayer(dplayerConfig)
// DPlayerquality
console.log('🔍 DPlayer实例创建完成:', {
hasQuality: !!player.quality,
qualityOptions: player.quality?.options,
playerConfig: dplayerConfig
qualityDefault: player.quality?.default,
videoElement: player.video
})
//
player.on('play', () => {
console.log('🎬 视频开始播放')
isPlaying.value = true
emit('play')
})
player.on('pause', () => {
console.log('⏸️ 视频暂停')
isPlaying.value = false
emit('pause')
})
player.on('ended', () => {
console.log('🏁 视频播放结束')
isPlaying.value = false
emit('ended')
})
player.on('error', (error: any) => {
console.error('❌ DPlayer播放错误:', error)
emit('error', error)
})
player.on('loadstart', () => {
console.log('📥 开始加载视频')
})
player.on('canplay', () => {
console.log('✅ 视频可以播放')
})
player.on('loadedmetadata', () => {
console.log('📊 视频元数据加载完成')
})
player.on('error', (error: any) => {
//
const isHarmlessError = (

View File

@ -269,8 +269,8 @@
</div>
</div>
<!-- 视频播放器区域 - 已兑换状态讨论模式下隐藏 -->
<div v-else-if="!discussionMode" class="video-player-section">
<!-- 视频播放器区域 - 已兑换状态练习模式和讨论模式下隐藏 -->
<div v-else-if="!practiceMode && !discussionMode" class="video-player-section">
<div class="video-player enrolled">
<div class="video-container">
<!-- DPlayer 播放器 -->
@ -739,18 +739,9 @@
<div class="sidebar">
<!-- 学期选择器 -->
<div class="semester-selector">
<select class="semester-dropdown">
<option value="2025-spring">2025年上学期</option>
<option value="2025-fall">2025年下学期</option>
<option value="2024-spring">2024年上学期</option>
</select>
<div class="dropdown-arrow">
<svg width="12" height="8" viewBox="0 0 12 8" fill="none">
<path d="M1 1L6 6L11 1" stroke="#0088D1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- 学期显示 -->
<div class="semester-display">
<span class="semester-text">2025年上学期</span>
</div>
<!-- 开课时间 -->
@ -956,56 +947,43 @@
<div class="more-courses-header">
<h3>更多课程</h3>
</div>
<div class="more-courses-list">
<div class="course-card">
<div v-if="moreCoursesLoading" class="more-courses-loading">
<p>正在加载更多课程...</p>
</div>
<div v-else-if="moreCoursesError" class="more-courses-error">
<p>{{ moreCoursesError }}</p>
<button @click="loadMoreCourses" class="retry-btn">重试</button>
</div>
<div v-else-if="moreCourses.length > 0" class="more-courses-list">
<div v-for="course in moreCourses" :key="course.id" class="course-card">
<div class="course-cover">
<div class="course-image computer-bg">
<img src="/images/courses/course-activities1.png" alt="">
<img :src="course.coverImage || course.thumbnail || '/images/courses/course-activities1.png'"
:alt="course.title"
@error="handleImageError">
</div>
</div>
<div class="course-info">
<div class="course-desc">暑期名师领学提高班级教学质量高效冲分指南</div>
<div class="course-desc">{{ course.description || course.title }}</div>
<div class="course-stats">
<span class="stats-item">
<i class="icon-chapters"></i>
9章54
{{ course.chaptersCount || 0 }}{{ course.lessonsCount || 0 }}
</span>
<span class="stats-item">
<i class="icon-duration"></i>
12小时43分钟
{{ formatDuration(course.duration) }}
</span>
</div>
<div class="course-footer">
<span class="enrolled-count">324人已报名</span>
<button class="btn-enroll-course">去报名</button>
<span class="enrolled-count">{{ course.enrolledCount || 0 }}人已报名</span>
<button class="btn-enroll-course" @click="handleEnrollCourse(course)">去报名</button>
</div>
</div>
</div>
<div class="course-card">
<div class="course-cover">
<div class="course-image computer-bg">
<img src="/images/courses/course-activities2.png" alt="">
</div>
</div>
<div class="course-info">
<div class="course-desc">暑期名师领学提高班级教学质量高效冲分指南</div>
<div class="course-stats">
<span class="stats-item">
<i class="icon-chapters"></i>
共9章54节
</span>
<span class="stats-item">
<i class="icon-duration"></i>
12小时43分钟
</span>
</div>
<div class="course-footer">
<span class="enrolled-count">324人已报名</span>
<button class="btn-enroll-course">去报名</button>
</div>
</div>
</div>
</div>
<div v-else class="no-more-courses">
<p>暂无更多课程</p>
</div>
</div>
</div>
@ -1054,63 +1032,35 @@
<!-- AI小助手聊天界面 -->
<div v-if="aiActiveTab === 'assistant'" class="ai-chat-interface">
<!-- 聊天消息列表 -->
<div class="chat-messages">
<!-- AI欢迎消息 -->
<div class="message ai-message">
<div class="message-avatar">
<img src="/images/aiCompanion/AI小助手@2x.png" alt="AI小助手">
</div>
<div class="message-content">
<div class="message-bubble">
<p>您好我是您的AI学习助手很高兴为您服务</p>
<p>我可以帮助您</p>
<ul>
<li>解答课程相关问题</li>
<li>提供学习建议</li>
<li>协助完成作业</li>
<li>生成学习笔记</li>
</ul>
<p>请随时向我提问</p>
</div>
<div class="message-time">刚刚</div>
</div>
</div>
<!-- 用户消息示例 -->
<div class="message user-message">
<div class="chat-messages" ref="chatMessagesContainer">
<!-- 动态聊天消息 -->
<div
v-for="msg in chatMessages"
:key="msg.id"
class="message"
:class="msg.type === 'ai' ? 'ai-message' : 'user-message'"
>
<div class="message-avatar">
<img
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=50&q=80"
alt="用户">
:src="msg.type === 'ai' ? '/images/aiCompanion/AI小助手@2x.png' : 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=50&q=80'"
:alt="msg.type === 'ai' ? 'AI小助手' : '用户'"
>
</div>
<div class="message-content">
<div class="message-bubble">
<p>你好我想了解一下这个课程的主要内容</p>
<div v-if="msg.content" class="message-text" v-html="formatMessageContent(msg.content)"></div>
<div v-if="msg.isStreaming" class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div class="message-time">2分钟前</div>
<div class="message-time">{{ msg.timestamp }}</div>
</div>
</div>
<!-- AI回复消息 -->
<div class="message ai-message">
<div class="message-avatar">
<img src="/images/aiCompanion/AI小助手@2x.png" alt="AI小助手">
</div>
<div class="message-content">
<div class="message-bubble">
<p>这个课程主要包含以下几个部分</p>
<ol>
<li><strong>DeepSeek大语言模型介绍</strong> - 了解模型的基本原理和特点</li>
<li><strong>模型应用场景分析</strong> - 学习如何在实际工作中应用</li>
<li><strong>实战应用技巧</strong> - 掌握具体的操作方法和技巧</li>
</ol>
<p>课程总时长约12小时包含54节课程内容您想从哪个部分开始学习呢</p>
</div>
<div class="message-time">1分钟前</div>
</div>
</div>
<div class="ai-suggestion">
<!-- 只在没有对话时显示AI建议 -->
<div v-if="chatMessages.length === 0" class="ai-suggestion">
<div class="ai-suggestion-title">你可以尝试与AI进行以下对话:</div>
<div class="ai-suggestion-item">
@ -1155,12 +1105,24 @@
<!-- 聊天输入区域 -->
<div class="chat-input-area">
<div class="input-container">
<textarea type="text" v-model="chatMessage" placeholder="请输入您的问题..." class="chat-input"
@keyup.enter="sendMessage">
</textarea>
<button class="send-button" @click="sendMessage">
<img src="/images/aiCompanion/发送@2x.png" alt="发送" class="send-icon">
<textarea
v-model="chatMessage"
placeholder="请输入您的问题..."
class="chat-input"
:disabled="isAISending"
@keyup.enter="!isAISending && sendMessage()"
></textarea>
<button
class="send-button"
:disabled="isAISending || !chatMessage.trim()"
@click="sendMessage"
>
<img
src="/images/aiCompanion/发送@2x.png"
alt="发送"
class="send-icon"
:class="{ 'disabled': isAISending || !chatMessage.trim() }"
>
</button>
</div>
</div>
@ -1347,6 +1309,7 @@ import { useMessage } from 'naive-ui'
// import { useUserStore } from '@/stores/user'
import { CourseApi } from '@/api/modules/course'
import { CommentApi } from '@/api/modules/comment'
import { AIApi } from '@/api/modules/ai'
import type { Course, CourseSection, CourseComment } from '@/api/types'
import QuillEditor from '@/components/common/QuillEditor.vue'
import DPlayerVideo from '@/components/course/DPlayerVideo.vue'
@ -1428,10 +1391,8 @@ const isRefreshing = ref(false)
// AI
const sendPrompt = (prompt: string) => {
// AI
console.log('发送AI提示词:', prompt)
//
// AI
chatMessage.value = prompt
sendMessage()
}
//
@ -1593,6 +1554,11 @@ const courseActiveTab = ref('summary')
const showTipSection = ref(true)
const showAiAssistant = ref(true)
//
const moreCourses = ref<any[]>([])
const moreCoursesLoading = ref(false)
const moreCoursesError = ref('')
//
// const instructors = ref([
// {
@ -1856,7 +1822,28 @@ const replyToUsername = ref('')
//
const handleVideoPlay = async (section: CourseSection) => {
console.log('播放视频:', section.name)
console.log('🎬 点击视频播放按钮:', section.name)
console.log('🔍 当前状态:', {
practiceMode: practiceMode.value,
discussionMode: discussionMode.value,
sectionId: section.id
})
// 退
if (practiceMode.value) {
console.log('🔄 退出练习模式,切换到视频播放')
exitPractice()
}
if (discussionMode.value) {
console.log('🔄 退出讨论模式,切换到视频播放')
exitDiscussion()
}
console.log('🔍 模式切换后状态:', {
practiceMode: practiceMode.value,
discussionMode: discussionMode.value
})
//
await loadSectionVideo(section)
@ -1905,7 +1892,15 @@ const loadSectionVideo = async (section: CourseSection) => {
currentVideoSection.value = section
console.log('✅ 设置视频URL:', currentVideoUrl.value)
console.log('✅ 可用清晰度:', video.qualities)
console.log('✅ 默认清晰度:', video.defaultQuality)
console.log('✅ 传递给DPlayer的清晰度:', videoQualities.value)
} else {
// 使
if (video.qualities && video.qualities.length > 0) {
currentVideoUrl.value = video.qualities[0].url
currentVideoSection.value = section
console.log('✅ 使用第一个可用清晰度:', video.qualities[0])
}
}
} else {
console.warn('⚠️ 没有找到视频数据')
@ -2473,12 +2468,13 @@ const currentPracticeQuestion = computed(() => {
// 退
const exitPractice = () => {
console.log('🚪 正在退出练习模式...')
practiceMode.value = false
practiceStarted.value = false
practiceFinished.value = false
currentPracticeSection.value = null
practiceQuestions.value = []
console.log('✅ 已退出练习模式')
console.log('✅ 已退出练习模式practiceMode:', practiceMode.value)
}
@ -2742,12 +2738,13 @@ const loadDiscussionData = async (section: CourseSection) => {
}
const exitDiscussion = () => {
console.log('🚪 正在退出讨论模式...')
discussionMode.value = false
currentDiscussionSection.value = null
discussionList.value = []
newComment.value = ''
replyingTo.value = null
console.log('✅ 已退出讨论模式')
console.log('✅ 已退出讨论模式discussionMode:', discussionMode.value)
}
const submitDiscussionComment = () => {
@ -2797,10 +2794,10 @@ const handleSectionClick = (section: CourseSection) => {
name: section.name
})
//
//
if (isVideo) {
console.log('✅ 识别为视频课程,开始加载视频数据')
loadSectionVideo(section)
console.log('✅ 识别为视频课程,调用视频播放方法')
handleVideoPlay(section)
} else if (isResource) {
console.log('✅ 识别为资料课程')
handleDownload(section)
@ -2872,6 +2869,58 @@ const closePreviewModal = () => {
previewModalType.value = ''
}
//
const loadMoreCourses = async () => {
try {
moreCoursesLoading.value = true
moreCoursesError.value = ''
console.log('🔍 开始加载更多课程课程ID:', courseId.value)
const response = await CourseApi.getMoreCourses(courseId.value)
console.log('🔍 更多课程API响应:', response)
if (response.code === 0 || response.code === 200) {
moreCourses.value = response.data || []
console.log('✅ 更多课程加载成功,数量:', moreCourses.value.length)
} else {
moreCoursesError.value = response.message || '加载更多课程失败'
console.error('❌ 更多课程加载失败:', response.message)
}
} catch (error) {
console.error('❌ 加载更多课程失败:', error)
moreCoursesError.value = '加载更多课程失败,请稍后重试'
} finally {
moreCoursesLoading.value = false
}
}
//
const formatDuration = (duration: number): string => {
if (!duration || duration <= 0) return '0分钟'
const hours = Math.floor(duration / 3600)
const minutes = Math.floor((duration % 3600) / 60)
if (hours > 0) {
return `${hours}小时${minutes}分钟`
} else {
return `${minutes}分钟`
}
}
//
const handleImageError = (event: Event) => {
const img = event.target as HTMLImageElement
img.src = '/images/courses/course-activities1.png'
}
//
const handleEnrollCourse = (course: any) => {
console.log('点击报名课程:', course)
//
window.open(`/course/${course.id}`, '_blank')
}
//
// const handleEnrollCourse = () => {
// if (!userStore.isLoggedIn) {
@ -2965,6 +3014,16 @@ const switchTab = (tab: string) => {
// AI
const chatMessage = ref('')
const chatMessages = ref<Array<{
id: string
type: 'user' | 'ai'
content: string
timestamp: string
isStreaming?: boolean
}>>([
//
])
const isAISending = ref(false)
//
const showNoteEditor = ref(false)
@ -3106,11 +3165,96 @@ const calculateProgress = () => {
}
//
const sendMessage = () => {
if (chatMessage.value.trim()) {
console.log('发送消息:', chatMessage.value)
//
chatMessage.value = ''
const sendMessage = async () => {
if (!chatMessage.value.trim() || isAISending.value) {
return
}
const userMessage = chatMessage.value.trim()
const messageId = Date.now().toString()
//
chatMessages.value.push({
id: messageId,
type: 'user',
content: userMessage,
timestamp: new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
})
//
chatMessage.value = ''
//
scrollToBottom()
// AI
const aiMessageId = (Date.now() + 1).toString()
chatMessages.value.push({
id: aiMessageId,
type: 'ai',
content: '',
timestamp: new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
}),
isStreaming: true
})
isAISending.value = true
try {
// AI -
console.log('开始AI流式请求...')
await AIApi.sendChatMessageStream(
userMessage,
//
(chunk: string) => {
console.log('收到AI消息块:', chunk)
const aiMessage = chatMessages.value.find(msg => msg.id === aiMessageId)
if (aiMessage) {
aiMessage.content += chunk
scrollToBottom()
}
},
//
() => {
console.log('AI消息流完成')
const aiMessage = chatMessages.value.find(msg => msg.id === aiMessageId)
if (aiMessage) {
aiMessage.isStreaming = false
console.log('最终AI消息内容:', aiMessage.content)
//
if (!aiMessage.content.trim()) {
aiMessage.content = '抱歉,我没有收到完整的回复,请重新提问。'
console.log('AI回复为空显示默认消息')
}
}
isAISending.value = false
},
//
(error) => {
console.error('AI聊天失败:', error)
const aiMessage = chatMessages.value.find(msg => msg.id === aiMessageId)
if (aiMessage) {
aiMessage.content = `抱歉AI助手暂时无法回复${error.message || '网络连接异常'},请稍后再试。`
aiMessage.isStreaming = false
}
isAISending.value = false
message.error(`AI助手暂时无法回复${error.message || '请检查网络连接'}`)
}
)
} catch (error) {
console.error('发送消息失败:', error)
const aiMessage = chatMessages.value.find(msg => msg.id === aiMessageId)
if (aiMessage) {
aiMessage.content = '抱歉AI助手暂时无法回复请稍后再试。'
aiMessage.isStreaming = false
}
isAISending.value = false
message.error('发送消息失败,请检查网络连接')
}
}
@ -3165,6 +3309,36 @@ const deleteNote = (index: number) => {
}
}
//
const chatMessagesContainer = ref<HTMLElement>()
//
const formatMessageContent = (content: string): string => {
if (!content) return ''
// HTML
let formatted = content.replace(/\n/g, '<br>')
//
formatted = formatted.replace(/^• (.+)$/gm, '<li>$1</li>')
// ul
if (formatted.includes('<li>')) {
formatted = formatted.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
}
return formatted
}
//
const scrollToBottom = () => {
if (chatMessagesContainer.value) {
setTimeout(() => {
chatMessagesContainer.value!.scrollTop = chatMessagesContainer.value!.scrollHeight
}, 100)
}
}
//
// const sendQuickMessage = (message: string) => {
// chatMessage.value = message
@ -3198,6 +3372,7 @@ onMounted(() => {
loadCourseDetail()
loadCourseSections()
loadCourseComments() //
loadMoreCourses() //
//
const shouldRefresh = sessionStorage.getItem('refreshCourseExchanged')
@ -3422,49 +3597,33 @@ onActivated(() => {
border: 1px solid white;
}
/* 学期选择器 */
.semester-selector {
position: relative;
/* 学期显示 */
.semester-display {
width: 100%;
height: 42px;
margin: 20px 0 -3px 0;
box-sizing: border-box;
}
.semester-dropdown {
.semester-text {
display: block;
width: 100%;
height: 100%;
background: #E2F5FF;
border: 1px solid #0088D1;
border-radius: 4px;
padding: 0 40px 0 16px;
padding: 0 16px;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 14px;
color: #0088D1;
appearance: none;
cursor: pointer;
outline: none;
line-height: 40px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
}
.semester-dropdown:focus {
border-color: #0088D1;
box-shadow: 0 0 0 2px rgba(0, 136, 209, 0.1);
}
.dropdown-arrow {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
/* 开课时间信息 */
.course-time-info {
width: 100%;
@ -5705,6 +5864,57 @@ onActivated(() => {
text-align: left;
}
/* 消息文本样式 */
.message-text {
font-size: 14px;
line-height: 1.6;
color: #333;
}
.message-text ul {
margin: 8px 0;
padding-left: 20px;
}
.message-text li {
margin-bottom: 4px;
}
/* 打字指示器样式 */
.typing-indicator {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 0;
}
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #0088D1;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) {
animation-delay: -0.32s;
}
.typing-indicator span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes typing {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.chat-input-area {
height: 100%;
display: flex;
@ -5760,11 +5970,27 @@ onActivated(() => {
justify-content: center;
border: none;
z-index: 1000;
transition: opacity 0.3s ease;
}
.send-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.send-icon {
width: 100%;
height: 100%;
transition: opacity 0.3s ease;
}
.send-icon.disabled {
opacity: 0.5;
}
.chat-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
.quick-actions {