From 2e3b6a6cf7023a25ce7103262b24e6f15dd941ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=BC=A0?= <2091066548@qq.com> Date: Tue, 23 Sep 2025 09:30:52 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9Aai=E5=AF=B9=E8=AF=9D=E5=92=8C?= =?UTF-8?q?=E6=88=91=E4=BB=AC=E7=9A=84=E8=A7=86=E9=A2=91=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/modules/ai.ts | 396 +++++++++++++++++++ src/api/modules/course.ts | 130 ++++++- src/components/course/DPlayerVideo.vue | 56 ++- src/views/CourseExchanged.vue | 504 ++++++++++++++++++------- 4 files changed, 927 insertions(+), 159 deletions(-) create mode 100644 src/api/modules/ai.ts diff --git a/src/api/modules/ai.ts b/src/api/modules/ai.ts new file mode 100644 index 0000000..891541f --- /dev/null +++ b/src/api/modules/ai.ts @@ -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 + */ + static async sendChatMessage(content: string): Promise { + 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 { + 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 + */ + static async sendChatMessageStream( + content: string, + onMessage: (chunk: string) => void, + onComplete?: () => void, + onError?: (error: any) => void + ): Promise { + 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 + */ + static async sendChatMessageWithEventSource( + content: string, + onMessage: (chunk: string) => void, + onComplete?: () => void, + onError?: (error: any) => void + ): Promise { + 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 + } + } +} diff --git a/src/api/modules/course.ts b/src/api/modules/course.ts index c7e82c0..4b1f4a4 100644 --- a/src/api/modules/course.ts +++ b/src/api/modules/course.ts @@ -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> { + try { + console.log('🔍 获取更多课程数据,课程ID:', courseId) + console.log('🔍 API请求URL: /aiol/aiolCourse/' + courseId + '/more_courses') + + const response = await ApiRequest.get(`/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 diff --git a/src/components/course/DPlayerVideo.vue b/src/components/course/DPlayerVideo.vue index 9ebfddb..b2d0f42 100644 --- a/src/components/course/DPlayerVideo.vue +++ b/src/components/course/DPlayerVideo.vue @@ -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' + })) + } + } + + // 如果是HLS流,添加HLS.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) // 检查DPlayer是否正确加载了quality配置 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 = ( diff --git a/src/views/CourseExchanged.vue b/src/views/CourseExchanged.vue index a9c70dd..6b1b726 100644 --- a/src/views/CourseExchanged.vue +++ b/src/views/CourseExchanged.vue @@ -269,8 +269,8 @@ - -
+ +
@@ -739,18 +739,9 @@