feat:ai对话和我们的视频播放
This commit is contained in:
parent
f152497cab
commit
2e3b6a6cf7
396
src/api/modules/ai.ts
Normal file
396
src/api/modules/ai.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1124,6 +1124,11 @@ export class CourseApi {
|
|||||||
// 解析fileUrl中的多个清晰度URL
|
// 解析fileUrl中的多个清晰度URL
|
||||||
const qualities = this.parseVideoQualities(video.fileUrl)
|
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 {
|
return {
|
||||||
id: video.id,
|
id: video.id,
|
||||||
name: video.name,
|
name: video.name,
|
||||||
@ -1133,8 +1138,8 @@ export class CourseApi {
|
|||||||
duration: video.duration,
|
duration: video.duration,
|
||||||
fileSize: video.fileSize,
|
fileSize: video.fileSize,
|
||||||
qualities: qualities,
|
qualities: qualities,
|
||||||
defaultQuality: '360', // 默认360p
|
defaultQuality: defaultQuality,
|
||||||
currentQuality: '360' // 当前选中360p
|
currentQuality: defaultQuality
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1182,24 +1187,28 @@ export class CourseApi {
|
|||||||
const urls = fileUrl.split(',').map(url => url.trim()).filter(url => url.length > 0)
|
const urls = fileUrl.split(',').map(url => url.trim()).filter(url => url.length > 0)
|
||||||
console.log('🔍 分割后的视频URL:', urls)
|
console.log('🔍 分割后的视频URL:', urls)
|
||||||
|
|
||||||
// 支持的清晰度列表(按优先级排序)
|
// 根据URL中的清晰度标识来解析
|
||||||
const supportedQualities = [
|
urls.forEach((url) => {
|
||||||
{ value: '1080', label: '1080p' },
|
let quality = { label: '360p', value: '360', url: url }
|
||||||
{ value: '720', label: '720p' },
|
|
||||||
{ value: '480', label: '480p' },
|
|
||||||
{ value: '360', label: '360p' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 根据URL数量分配清晰度
|
// 从URL中提取清晰度信息
|
||||||
// 假设URL按清晰度从高到低排列:1080p, 720p, 480p, 360p
|
if (url.includes('/1080p/')) {
|
||||||
urls.forEach((url, index) => {
|
quality = { label: '1080p', value: '1080', url: url }
|
||||||
if (index < supportedQualities.length) {
|
} else if (url.includes('/720p/')) {
|
||||||
qualities.push({
|
quality = { label: '720p', value: '720', url: url }
|
||||||
label: supportedQualities[index].label,
|
} else if (url.includes('/480p/')) {
|
||||||
value: supportedQualities[index].value,
|
quality = { label: '480p', value: '480', url: url }
|
||||||
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
|
// 如果没有解析到任何清晰度,使用第一个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
|
export default CourseApi
|
||||||
|
@ -183,12 +183,16 @@ const initializePlayer = async (videoUrl?: string) => {
|
|||||||
player = null
|
player = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否为HLS流
|
||||||
|
const isHLS = url.includes('.m3u8')
|
||||||
|
console.log('🔍 视频类型检测:', { url, isHLS })
|
||||||
|
|
||||||
// 构建DPlayer配置
|
// 构建DPlayer配置
|
||||||
const dplayerConfig: any = {
|
const dplayerConfig: any = {
|
||||||
container: dplayerContainer.value,
|
container: dplayerContainer.value,
|
||||||
video: {
|
video: {
|
||||||
url: url,
|
url: url,
|
||||||
type: 'auto'
|
type: isHLS ? 'hls' : 'auto'
|
||||||
},
|
},
|
||||||
autoplay: props.autoplay,
|
autoplay: props.autoplay,
|
||||||
theme: '#007bff',
|
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'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是HLS流,添加HLS.js支持
|
||||||
|
if (isHLS) {
|
||||||
|
console.log('🔧 配置HLS支持')
|
||||||
|
dplayerConfig.pluginOptions = {
|
||||||
|
hls: {
|
||||||
|
// HLS.js配置选项
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 如果有多个清晰度,启用DPlayer原生清晰度切换
|
// 如果有多个清晰度,启用DPlayer原生清晰度切换
|
||||||
console.log('🔍 检查清晰度配置:', {
|
console.log('🔍 检查清晰度配置:', {
|
||||||
videoQualities: props.videoQualities,
|
videoQualities: props.videoQualities,
|
||||||
@ -224,31 +251,56 @@ const initializePlayer = async (videoUrl?: string) => {
|
|||||||
availableQualities: props.videoQualities?.length || 0
|
availableQualities: props.videoQualities?.length || 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('🔨 创建DPlayer实例,配置:', {
|
||||||
|
...dplayerConfig,
|
||||||
|
quality: dplayerConfig.quality ? '已配置多清晰度' : '单一清晰度'
|
||||||
|
})
|
||||||
player = new DPlayer(dplayerConfig)
|
player = new DPlayer(dplayerConfig)
|
||||||
|
|
||||||
// 检查DPlayer是否正确加载了quality配置
|
// 检查DPlayer是否正确加载了quality配置
|
||||||
console.log('🔍 DPlayer实例创建完成:', {
|
console.log('🔍 DPlayer实例创建完成:', {
|
||||||
hasQuality: !!player.quality,
|
hasQuality: !!player.quality,
|
||||||
qualityOptions: player.quality?.options,
|
qualityOptions: player.quality?.options,
|
||||||
playerConfig: dplayerConfig
|
qualityDefault: player.quality?.default,
|
||||||
|
videoElement: player.video
|
||||||
})
|
})
|
||||||
|
|
||||||
// 事件监听
|
// 事件监听
|
||||||
player.on('play', () => {
|
player.on('play', () => {
|
||||||
|
console.log('🎬 视频开始播放')
|
||||||
isPlaying.value = true
|
isPlaying.value = true
|
||||||
emit('play')
|
emit('play')
|
||||||
})
|
})
|
||||||
|
|
||||||
player.on('pause', () => {
|
player.on('pause', () => {
|
||||||
|
console.log('⏸️ 视频暂停')
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
emit('pause')
|
emit('pause')
|
||||||
})
|
})
|
||||||
|
|
||||||
player.on('ended', () => {
|
player.on('ended', () => {
|
||||||
|
console.log('🏁 视频播放结束')
|
||||||
isPlaying.value = false
|
isPlaying.value = false
|
||||||
emit('ended')
|
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) => {
|
player.on('error', (error: any) => {
|
||||||
// 检查是否为无害的错误
|
// 检查是否为无害的错误
|
||||||
const isHarmlessError = (
|
const isHarmlessError = (
|
||||||
|
@ -269,8 +269,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-player enrolled">
|
||||||
<div class="video-container">
|
<div class="video-container">
|
||||||
<!-- DPlayer 播放器 -->
|
<!-- DPlayer 播放器 -->
|
||||||
@ -739,18 +739,9 @@
|
|||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
|
|
||||||
|
|
||||||
<!-- 学期选择器 -->
|
<!-- 学期显示 -->
|
||||||
<div class="semester-selector">
|
<div class="semester-display">
|
||||||
<select class="semester-dropdown">
|
<span class="semester-text">2025年上学期</span>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 开课时间 -->
|
<!-- 开课时间 -->
|
||||||
@ -956,56 +947,43 @@
|
|||||||
<div class="more-courses-header">
|
<div class="more-courses-header">
|
||||||
<h3>更多课程</h3>
|
<h3>更多课程</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="more-courses-list">
|
<div v-if="moreCoursesLoading" class="more-courses-loading">
|
||||||
<div class="course-card">
|
<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-cover">
|
||||||
<div class="course-image computer-bg">
|
<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>
|
</div>
|
||||||
<div class="course-info">
|
<div class="course-info">
|
||||||
<div class="course-desc">暑期名师领学,提高班级教学质量!高效冲分指南</div>
|
<div class="course-desc">{{ course.description || course.title }}</div>
|
||||||
<div class="course-stats">
|
<div class="course-stats">
|
||||||
<span class="stats-item">
|
<span class="stats-item">
|
||||||
<i class="icon-chapters"></i>
|
<i class="icon-chapters"></i>
|
||||||
共9章54节
|
共{{ course.chaptersCount || 0 }}章{{ course.lessonsCount || 0 }}节
|
||||||
</span>
|
</span>
|
||||||
<span class="stats-item">
|
<span class="stats-item">
|
||||||
<i class="icon-duration"></i>
|
<i class="icon-duration"></i>
|
||||||
12小时43分钟
|
{{ formatDuration(course.duration) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="course-footer">
|
<div class="course-footer">
|
||||||
<span class="enrolled-count">324人已报名</span>
|
<span class="enrolled-count">{{ course.enrolledCount || 0 }}人已报名</span>
|
||||||
<button class="btn-enroll-course">去报名</button>
|
<button class="btn-enroll-course" @click="handleEnrollCourse(course)">去报名</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="course-card">
|
</div>
|
||||||
<div class="course-cover">
|
<div v-else class="no-more-courses">
|
||||||
<div class="course-image computer-bg">
|
<p>暂无更多课程</p>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1054,63 +1032,35 @@
|
|||||||
<!-- AI小助手聊天界面 -->
|
<!-- AI小助手聊天界面 -->
|
||||||
<div v-if="aiActiveTab === 'assistant'" class="ai-chat-interface">
|
<div v-if="aiActiveTab === 'assistant'" class="ai-chat-interface">
|
||||||
<!-- 聊天消息列表 -->
|
<!-- 聊天消息列表 -->
|
||||||
<div class="chat-messages">
|
<div class="chat-messages" ref="chatMessagesContainer">
|
||||||
<!-- AI欢迎消息 -->
|
<!-- 动态聊天消息 -->
|
||||||
<div class="message ai-message">
|
<div
|
||||||
<div class="message-avatar">
|
v-for="msg in chatMessages"
|
||||||
<img src="/images/aiCompanion/AI小助手@2x.png" alt="AI小助手">
|
:key="msg.id"
|
||||||
</div>
|
class="message"
|
||||||
<div class="message-content">
|
:class="msg.type === 'ai' ? 'ai-message' : 'user-message'"
|
||||||
<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="message-avatar">
|
<div class="message-avatar">
|
||||||
<img
|
<img
|
||||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&auto=format&fit=crop&w=50&q=80"
|
: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="用户">
|
:alt="msg.type === 'ai' ? 'AI小助手' : '用户'"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<div class="message-bubble">
|
<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>
|
||||||
<div class="message-time">2分钟前</div>
|
<div class="message-time">{{ msg.timestamp }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI回复消息 -->
|
<!-- 只在没有对话时显示AI建议 -->
|
||||||
<div class="message ai-message">
|
<div v-if="chatMessages.length === 0" class="ai-suggestion">
|
||||||
<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">
|
|
||||||
<div class="ai-suggestion-title">你可以尝试与AI进行以下对话:</div>
|
<div class="ai-suggestion-title">你可以尝试与AI进行以下对话:</div>
|
||||||
|
|
||||||
<div class="ai-suggestion-item">
|
<div class="ai-suggestion-item">
|
||||||
@ -1155,12 +1105,24 @@
|
|||||||
<!-- 聊天输入区域 -->
|
<!-- 聊天输入区域 -->
|
||||||
<div class="chat-input-area">
|
<div class="chat-input-area">
|
||||||
<div class="input-container">
|
<div class="input-container">
|
||||||
<textarea type="text" v-model="chatMessage" placeholder="请输入您的问题..." class="chat-input"
|
<textarea
|
||||||
@keyup.enter="sendMessage">
|
v-model="chatMessage"
|
||||||
|
placeholder="请输入您的问题..."
|
||||||
</textarea>
|
class="chat-input"
|
||||||
<button class="send-button" @click="sendMessage">
|
:disabled="isAISending"
|
||||||
<img src="/images/aiCompanion/发送@2x.png" alt="发送" class="send-icon">
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1347,6 +1309,7 @@ import { useMessage } from 'naive-ui'
|
|||||||
// import { useUserStore } from '@/stores/user'
|
// import { useUserStore } from '@/stores/user'
|
||||||
import { CourseApi } from '@/api/modules/course'
|
import { CourseApi } from '@/api/modules/course'
|
||||||
import { CommentApi } from '@/api/modules/comment'
|
import { CommentApi } from '@/api/modules/comment'
|
||||||
|
import { AIApi } from '@/api/modules/ai'
|
||||||
import type { Course, CourseSection, CourseComment } from '@/api/types'
|
import type { Course, CourseSection, CourseComment } from '@/api/types'
|
||||||
import QuillEditor from '@/components/common/QuillEditor.vue'
|
import QuillEditor from '@/components/common/QuillEditor.vue'
|
||||||
import DPlayerVideo from '@/components/course/DPlayerVideo.vue'
|
import DPlayerVideo from '@/components/course/DPlayerVideo.vue'
|
||||||
@ -1428,10 +1391,8 @@ const isRefreshing = ref(false)
|
|||||||
|
|
||||||
// AI建议相关
|
// AI建议相关
|
||||||
const sendPrompt = (prompt: string) => {
|
const sendPrompt = (prompt: string) => {
|
||||||
// 这里可以添加发送提示词到AI的逻辑
|
chatMessage.value = prompt
|
||||||
console.log('发送AI提示词:', prompt)
|
sendMessage()
|
||||||
// 可以将提示词设置到输入框中
|
|
||||||
// 或者直接发送给AI处理
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏区域的函数
|
// 隐藏区域的函数
|
||||||
@ -1593,6 +1554,11 @@ const courseActiveTab = ref('summary')
|
|||||||
const showTipSection = ref(true)
|
const showTipSection = ref(true)
|
||||||
const showAiAssistant = ref(true)
|
const showAiAssistant = ref(true)
|
||||||
|
|
||||||
|
// 更多课程相关状态
|
||||||
|
const moreCourses = ref<any[]>([])
|
||||||
|
const moreCoursesLoading = ref(false)
|
||||||
|
const moreCoursesError = ref('')
|
||||||
|
|
||||||
// 讲师数据
|
// 讲师数据
|
||||||
// const instructors = ref([
|
// const instructors = ref([
|
||||||
// {
|
// {
|
||||||
@ -1856,7 +1822,28 @@ const replyToUsername = ref('')
|
|||||||
|
|
||||||
// 处理视频播放
|
// 处理视频播放
|
||||||
const handleVideoPlay = async (section: CourseSection) => {
|
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)
|
await loadSectionVideo(section)
|
||||||
@ -1905,7 +1892,15 @@ const loadSectionVideo = async (section: CourseSection) => {
|
|||||||
currentVideoSection.value = section
|
currentVideoSection.value = section
|
||||||
console.log('✅ 设置视频URL:', currentVideoUrl.value)
|
console.log('✅ 设置视频URL:', currentVideoUrl.value)
|
||||||
console.log('✅ 可用清晰度:', video.qualities)
|
console.log('✅ 可用清晰度:', video.qualities)
|
||||||
|
console.log('✅ 默认清晰度:', video.defaultQuality)
|
||||||
console.log('✅ 传递给DPlayer的清晰度:', videoQualities.value)
|
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 {
|
} else {
|
||||||
console.warn('⚠️ 没有找到视频数据')
|
console.warn('⚠️ 没有找到视频数据')
|
||||||
@ -2473,12 +2468,13 @@ const currentPracticeQuestion = computed(() => {
|
|||||||
|
|
||||||
// 退出练习
|
// 退出练习
|
||||||
const exitPractice = () => {
|
const exitPractice = () => {
|
||||||
|
console.log('🚪 正在退出练习模式...')
|
||||||
practiceMode.value = false
|
practiceMode.value = false
|
||||||
practiceStarted.value = false
|
practiceStarted.value = false
|
||||||
practiceFinished.value = false
|
practiceFinished.value = false
|
||||||
currentPracticeSection.value = null
|
currentPracticeSection.value = null
|
||||||
practiceQuestions.value = []
|
practiceQuestions.value = []
|
||||||
console.log('✅ 已退出练习模式')
|
console.log('✅ 已退出练习模式,practiceMode:', practiceMode.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -2742,12 +2738,13 @@ const loadDiscussionData = async (section: CourseSection) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exitDiscussion = () => {
|
const exitDiscussion = () => {
|
||||||
|
console.log('🚪 正在退出讨论模式...')
|
||||||
discussionMode.value = false
|
discussionMode.value = false
|
||||||
currentDiscussionSection.value = null
|
currentDiscussionSection.value = null
|
||||||
discussionList.value = []
|
discussionList.value = []
|
||||||
newComment.value = ''
|
newComment.value = ''
|
||||||
replyingTo.value = null
|
replyingTo.value = null
|
||||||
console.log('✅ 已退出讨论模式')
|
console.log('✅ 已退出讨论模式,discussionMode:', discussionMode.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitDiscussionComment = () => {
|
const submitDiscussionComment = () => {
|
||||||
@ -2797,10 +2794,10 @@ const handleSectionClick = (section: CourseSection) => {
|
|||||||
name: section.name
|
name: section.name
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是视频课程,加载视频数据
|
// 如果是视频课程,调用视频播放处理方法
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
console.log('✅ 识别为视频课程,开始加载视频数据')
|
console.log('✅ 识别为视频课程,调用视频播放方法')
|
||||||
loadSectionVideo(section)
|
handleVideoPlay(section)
|
||||||
} else if (isResource) {
|
} else if (isResource) {
|
||||||
console.log('✅ 识别为资料课程')
|
console.log('✅ 识别为资料课程')
|
||||||
handleDownload(section)
|
handleDownload(section)
|
||||||
@ -2872,6 +2869,58 @@ const closePreviewModal = () => {
|
|||||||
previewModalType.value = ''
|
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 = () => {
|
// const handleEnrollCourse = () => {
|
||||||
// if (!userStore.isLoggedIn) {
|
// if (!userStore.isLoggedIn) {
|
||||||
@ -2965,6 +3014,16 @@ const switchTab = (tab: string) => {
|
|||||||
|
|
||||||
// AI聊天相关状态
|
// AI聊天相关状态
|
||||||
const chatMessage = ref('')
|
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)
|
const showNoteEditor = ref(false)
|
||||||
@ -3106,11 +3165,96 @@ const calculateProgress = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 发送聊天消息
|
// 发送聊天消息
|
||||||
const sendMessage = () => {
|
const sendMessage = async () => {
|
||||||
if (chatMessage.value.trim()) {
|
if (!chatMessage.value.trim() || isAISending.value) {
|
||||||
console.log('发送消息:', chatMessage.value)
|
return
|
||||||
// 这里可以添加发送消息的逻辑
|
}
|
||||||
chatMessage.value = ''
|
|
||||||
|
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) => {
|
// const sendQuickMessage = (message: string) => {
|
||||||
// chatMessage.value = message
|
// chatMessage.value = message
|
||||||
@ -3198,6 +3372,7 @@ onMounted(() => {
|
|||||||
loadCourseDetail()
|
loadCourseDetail()
|
||||||
loadCourseSections()
|
loadCourseSections()
|
||||||
loadCourseComments() // 加载评论
|
loadCourseComments() // 加载评论
|
||||||
|
loadMoreCourses() // 加载更多课程
|
||||||
|
|
||||||
// 检查是否需要刷新
|
// 检查是否需要刷新
|
||||||
const shouldRefresh = sessionStorage.getItem('refreshCourseExchanged')
|
const shouldRefresh = sessionStorage.getItem('refreshCourseExchanged')
|
||||||
@ -3422,49 +3597,33 @@ onActivated(() => {
|
|||||||
border: 1px solid white;
|
border: 1px solid white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 学期选择器 */
|
/* 学期显示 */
|
||||||
.semester-selector {
|
.semester-display {
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
margin: 20px 0 -3px 0;
|
margin: 20px 0 -3px 0;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.semester-dropdown {
|
.semester-text {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #E2F5FF;
|
background: #E2F5FF;
|
||||||
border: 1px solid #0088D1;
|
border: 1px solid #0088D1;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0 40px 0 16px;
|
padding: 0 16px;
|
||||||
font-family: PingFangSC, PingFang SC;
|
font-family: PingFangSC, PingFang SC;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #0088D1;
|
color: #0088D1;
|
||||||
appearance: none;
|
line-height: 40px;
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
box-sizing: border-box;
|
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 {
|
.course-time-info {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -5705,6 +5864,57 @@ onActivated(() => {
|
|||||||
text-align: left;
|
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 {
|
.chat-input-area {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -5760,11 +5970,27 @@ onActivated(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: none;
|
border: none;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-icon {
|
.send-icon {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 {
|
.quick-actions {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user