diff --git a/.gitignore b/.gitignore index 14d7d69..db83a01 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /.hbuilderx /.idea /node_modules +/iconfont-preview.html diff --git a/common/js/ai-service.js b/common/js/ai-service.js new file mode 100644 index 0000000..7b47481 --- /dev/null +++ b/common/js/ai-service.js @@ -0,0 +1,489 @@ +import Config from './config.js' +import http from './http.js' +import store from '@/store/index.js' +let currentConversationId = null; +const CONVERSATION_KEY = 'ai_conversation_id'; + +// 初始化时从本地读取 +try { + const saved = uni.getStorageSync(CONVERSATION_KEY); + if (saved) { + currentConversationId = saved; + } +} catch (e) { + console.warn('读取会话ID失败:', e); +} +export default { + /** + * 发送消息到Dify API + * @param {string} message 用户消息内容 + * @param {Object} options 配置选项 + * @returns {Promise} + */ + async sendMessage(message, options = {}) { + try { + const aiConfig = store.getters.globalAIKefuConfig + const params = { + url: '/api/kefu/chat', + data: { + user_id: store.state.memberInfo?.id || 'anonymous', + stream: false, + inputs: {}, + query: message, + response_mode: options.stream ? 'streaming' : 'blocking', + user: store.state.memberInfo?.id || 'anonymous', + conversation_id: this.getConversationId() || '' + }, + header: { + 'Content-Type': 'application/json' + } + } + console.log('Sending request to:', params.url); + console.log('Request data:', params.data); + + const response = await http.sendRequest({ + ...params, + async: false + }) + + const result = this.handleResponse(response, options); + if (result.conversationId) { + this.setConversationId(result.conversationId); + } + return result; + } catch (error) { + console.error('Dify API请求失败:', error) + throw new Error('AI服务暂时不可用,请稍后重试') + } + }, + + /** + * 流式消息处理 + */ + async sendStreamMessage(message, onChunk, onComplete) { + try { + return this.sendHttpStream(message, onChunk, onComplete) + } catch (error) { + console.error('流式消息发送失败:', error) + throw error + } + }, + + /** + * EventSource 流式聊天 + */ + chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) { + if (window.currentEventSource) { + window.currentEventSource.close(); + } + const params = new URLSearchParams({ + uniacid: store.state.uniacid || '1', + user_id: store.state.memberInfo?.id || '123456', + query: message, + conversation_id: conversationId || '', + stream: 'true' + }); + const url = `/api/kefu/chat?${params.toString()}`; + try { + const eventSource = new EventSource(url); + window.currentEventSource = eventSource; + let aiMessage = ''; + eventSource.addEventListener('message', (event) => { + try { + const data = JSON.parse(event.data); + if (data.event === 'message') { + aiMessage += data.answer || ''; + if (onMessage) onMessage(data.answer || ''); + } + if (data.event === 'message_end') { + if (onComplete) onComplete({ + conversation_id: data.conversation_id, + message: aiMessage + }); + } + if (data.conversation_id) { + conversationId = data.conversation_id; + } + } catch (error) { + console.error('解析消息失败:', error); + } + }); + eventSource.addEventListener('done', (event) => { + try { + const data = JSON.parse(event.data); + if (onComplete) onComplete(data); + } catch (error) { + console.error('解析完成事件失败:', error); + } + }); + eventSource.addEventListener('close', (event) => { + try { + const data = JSON.parse(event.data); + console.log('连接正常结束:', data); + } catch (error) { + console.error('解析关闭事件失败:', error); + } + window.currentEventSource = null; + }); + eventSource.addEventListener('error', (error) => { + console.error('EventSource错误:', error); + if (onError) onError({ error: 'EventSource连接错误' }); + window.currentEventSource = null; + }); + return eventSource; + } catch (error) { + console.error('创建EventSource失败:', error); + if (onError) onError({ error: error.message }); + return null; + } + }, + + /** + * Fetch API 流式聊天 + */ + async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) { + try { + let requestData; + let headers = { + 'Accept': 'text/event-stream' + }; + requestData = { + uniacid: store.state.uniacid || '1', + user_id: store.state.memberInfo?.id || '123456', + query: message, + conversation_id: conversationId || '', + stream: true, + inputs: {}, + response_mode: 'streaming', + user: store.state.memberInfo?.id || '123456' + }; + headers['Content-Type'] = 'application/json'; + const response = await fetch('/api/kefu/chat', { + method: 'POST', + headers: headers, + body: JSON.stringify(requestData) + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + if (!response.body) { + throw new Error('响应体不可用'); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let aiMessage = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let lineEnd; + while ((lineEnd = buffer.indexOf('\n')) !== -1) { + const line = buffer.substring(0, lineEnd); + buffer = buffer.substring(lineEnd + 1); + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.substring(6)); + if (data.event === 'message' || data.event === 'text_message') { + const textContent = data.answer || data.text || ''; + aiMessage += textContent; + if (onMessage) onMessage(textContent); + } else if (data.event === 'message_end' || data.event === 'done') { + if (onComplete) onComplete({ + conversation_id: data.conversation_id, + message: aiMessage, + ...data + }); + } else if (data.event === 'error' && onError) { + onError(data); + } + if (data.conversation_id) { + conversationId = data.conversation_id; + } + } catch (e) { + console.warn('解析流式数据失败:', e); + } + } + } + } + if (buffer.startsWith('data: ')) { + try { + const data = JSON.parse(buffer.substring(6)); + if ((data.event === 'message' || data.event === 'text_message') && onMessage) { + const textContent = data.answer || data.text || ''; + onMessage(textContent); + } else if ((data.event === 'message_end' || data.event === 'done') && onComplete) { + onComplete({ + conversation_id: data.conversation_id, + message: aiMessage, + ...data + }); + } + } catch (e) { + console.warn('解析剩余数据失败:', e); + } + } + } catch (error) { + console.error('Fetch流式聊天请求失败:', error); + if (onError) onError({ error: error.message }); + } + }, + + /** + * HTTP流式请求(修复conversationId未定义) + */ + async sendHttpStream(message, onChunk, onComplete) { + const params = { + url: '/api/kefu/chat', + data: { + query: message, + conversation_id: '', + user_id: store.state.memberInfo?.id || 'anonymous', + stream: true, + uniacid: store.state.uniacid, + inputs: {}, + response_mode: 'streaming', + user: store.state.memberInfo?.id || 'anonymous' + }, + header: { + 'Content-Type': 'application/json' + } + } + // #ifdef H5 + try { + const url = Config.baseUrl + `/api/kefu/chat`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }, + body: JSON.stringify({ + uniacid: store.state.uniacid || '1', + inputs: {}, + query: message, + response_mode: 'streaming', + user_id: store.state.memberInfo?.id || 'anonymous' + }) + }) + const reader = response.body.getReader() + const decoder = new TextDecoder() + let content = '' + let buffer = '' + // 修复:声明conversationId变量 + let conversationId = ''; + + function processStreamData(buffer, callback) { + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + lines.forEach(line => { + line = line.trim(); + if (!line) return; + if (line.startsWith('data:')) { + const dataPart = line.slice(5).trim(); + if (dataPart) { + try { + const data = JSON.parse(dataPart); + if (data.event === 'message') { + callback(data.answer || ''); + } + if (data.conversation_id) { + conversationId = data.conversation_id; + } + if (data.event === 'message_end') { + console.log('对话完成'); + } + } catch (error) { + console.error('解析流式数据失败:', error); + } + } + } + }); + return buffer; + } + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }); + buffer = processStreamData(buffer, (newData) => { + if (newData) { + content += newData; + if (onChunk) onChunk(newData); + } + }); + } + + if (onComplete) onComplete({ content, conversation_id: conversationId }); + return { content, conversation_id: conversationId }; + } catch (error) { + console.error('HTTP流式请求失败:', error) + throw error + } + // #endif + // #ifndef H5 + const response = await http.sendRequest({ + ...params, + async: false + }) + if (response.success && response.data) { + const content = response.data + const chunkSize = 3 + let index = 0 + const streamInterval = setInterval(() => { + if (index < content.length) { + const chunk = content.substring(index, index + chunkSize) + index += chunkSize + if (onChunk) onChunk(chunk) + } else { + clearInterval(streamInterval) + if (onComplete) onComplete(content) + } + }, 100) + } + // #endif + }, + + /** + * 处理API响应 + */ + handleResponse(response, options) { + if (response.code === 0 || response.success) { + return { + success: true, + content: response.data?.answer || response.data?.content || response.data?.reply|| response.data, + conversationId: response.data?.conversation_id, + messageId: response.data?.message_id + } + } else { + throw new Error(response.message || 'AI服务返回错误') + } + }, + + /** + * 生成会话ID + */ + async generateConversationId() { + const params = { + url: '/api/kefu/createConversation', + data: { + uniacid: store.state.uniacid, + user_id: store.state.memberInfo?.id || 'anonymous', + member_id: store.state.memberInfo?.id || '', + }, + header: { + 'Content-Type': 'application/json' + } + } + const response = await http.sendRequest({ + ...params, + async: false + }) + return this.handleResponse(response) + }, + + /** + * 获取AI服务状态 + */ + async getServiceStatus() { + try { + const response = await http.sendRequest({ + url: '/api/kefu/health', + async: false, + data: {} + }) + const available = [response?.data?.status, response?.data?.components?.ai_service_config?.status].filter(item => item === 'healthy').length > 0; + return { + available, + reason: available ? '服务正常' : '服务异常' + } + } catch (error) { + return { + available: false, + reason: '服务检查失败' + } + } + }, + + /** + * 清除会话历史 + */ + async clearConversation(conversationId) { + try { + const response = await http.sendRequest({ + url: '/api/kefu/clear-conversation', + data: { conversation_id: conversationId }, + async: false + }) + return response.success + } catch (error) { + console.error('清除会话失败:', error) + return false + } + }, + getConversationId() { + return currentConversationId; + }, + + setConversationId(id) { + if (id && id !== currentConversationId) { + currentConversationId = id; + try { + uni.setStorageSync(CONVERSATION_KEY, id); + } catch (e) { + console.error('保存会话ID失败:', e); + } + } + }, + + clearConversationId() { + currentConversationId = null; + try { + uni.removeStorageSync(CONVERSATION_KEY); + } catch (e) { + console.error('清除会话ID失败:', e); + } + }, + + /** + * 获取会话历史 + */ + async getConversationHistory(params = {}) { + try { + if (!params.conversation_id) { + throw new Error('会话ID(conversation_id)是必填参数'); + } + const requestData = { + uniacid: params.uniacid || store.state.uniacid || '1', + conversation_id: params.conversation_id, + user_id: params.user_id || store.state.memberInfo?.id || 'anonymous', + limit: params.limit || 20, + offset: params.offset || 0, + member_id: params.member_id || store.state.memberInfo?.id || '', + token: params.token || '' + }; + const response = await http.sendRequest({ + url: '/api/kefu/getHistory', + data: requestData, + header: { + 'Content-Type': 'application/json' + }, + async: false + }); + if (response.code === 0 || response.success) { + return { + success: true, + data: response.data, + messages: response.data?.messages || [], + total: response.data?.total || 0, + page_info: response.data?.page_info || { limit: 20, offset: 0 } + }; + } else { + throw new Error(response.message || '获取会话历史失败'); + } + } catch (error) { + console.error('获取会话历史失败:', error); + throw new Error(error.message || '获取会话历史时发生错误,请稍后重试'); + } + } +} \ No newline at end of file diff --git a/common/js/config.js b/common/js/config.js index e97d99d..a8b5e0c 100644 --- a/common/js/config.js +++ b/common/js/config.js @@ -14,16 +14,18 @@ try { // 调试版本,配置说明 const devCfg = { // 商户ID - uniacid: 460, //825 + uniacid: 1, //825 //api请求地址 - baseUrl: 'https://xcx30.5g-quickapp.com/', + baseUrl: 'https://dev.aigc-quickapp.com/', + // baseUrl: 'http://localhost:8010/', // 图片域名 - imgDomain: 'https://xcx30.5g-quickapp.com/', + imgDomain: 'https://dev.aigc-quickapp.com/', + //imgDomain: 'http://localhost:8010/', // H5端域名 - h5Domain: 'https://xcx30.5g-quickapp.com/', + h5Domain: 'https://dev.aigc-quickapp.com/', // // api请求地址 // baseUrl: 'https://tsaas.liveplatform.cn/', diff --git a/common/js/event-safety.js b/common/js/event-safety.js new file mode 100644 index 0000000..7798f8e --- /dev/null +++ b/common/js/event-safety.js @@ -0,0 +1,112 @@ +// 事件安全处理工具 +export class EventSafety { + // 创建安全的事件对象 + static createSafeEvent(originalEvent = {}) { + const safeEvent = { + type: originalEvent.type || 'unknown', + timeStamp: originalEvent.timeStamp || Date.now(), + detail: originalEvent.detail || {}, + // 安全的目标对象 + get target() { + return EventSafety.createSafeTarget(originalEvent.target) + }, + get currentTarget() { + return EventSafety.createSafeTarget(originalEvent.currentTarget) + }, + // 安全的 matches 方法 + matches(selector) { + return EventSafety.safeMatches(originalEvent.target, selector) + } + } + + return new Proxy(safeEvent, { + get(obj, prop) { + // 防止访问不存在的属性 + if (prop in obj) { + return obj[prop] + } + return undefined + } + }) + } + + // 创建安全的目标对象 + static createSafeTarget(target) { + if (!target || typeof target !== 'object') { + return EventSafety.getFallbackTarget() + } + + const safeTarget = { + // 基础属性 + tagName: target.tagName || '', + id: target.id || '', + className: target.className || '', + // 安全的方法 + matches: (selector) => EventSafety.safeMatches(target, selector), + // 数据集 + dataset: target.dataset || {} + } + + return safeTarget + } + + // 安全的 matches 检查 + static safeMatches(element, selector) { + if (!element || typeof element.matches !== 'function') { + return false + } + + try { + return element.matches(selector) + } catch (error) { + console.warn('matches 检查失败:', error) + return false + } + } + + // 回退目标对象 + static getFallbackTarget() { + return { + tagName: '', + id: '', + className: '', + matches: () => false, + dataset: {} + } + } + + // 包装事件处理器 + static wrapEventHandler(handler, options = {}) { + return function(event) { + try { + // 创建安全的事件对象 + const safeEvent = EventSafety.createSafeEvent(event) + return handler.call(this, safeEvent) + } catch (error) { + console.error('事件处理错误:', error) + // 可选的错误处理 + if (options.onError) { + options.onError(error, event, this) + } + } + } + } + + // 验证事件类型 + static isValidEventType(event, expectedType) { + return event && event.type === expectedType + } + + // 提取安全的事件数据 + static extractEventData(event, fields = ['type', 'timeStamp', 'detail']) { + const result = {} + + fields.forEach(field => { + if (event && field in event) { + result[field] = event[field] + } + }) + + return result + } +} \ No newline at end of file diff --git a/common/js/navigation.js b/common/js/navigation.js new file mode 100644 index 0000000..0550cd0 --- /dev/null +++ b/common/js/navigation.js @@ -0,0 +1,288 @@ +import { EventSafety } from './event-safety' + +export class NavigationHelper { + constructor() { + this.navigationCache = new Map() + } + + // 安全地获取导航栏高度 + async getNavigationHeight(component, options = {}) { + const cacheKey = `nav_height` + + // 检查缓存 + if (this.navigationCache.has(cacheKey) && !options.forceRefresh) { + return this.navigationCache.get(cacheKey) + } + + // 获取高度 + try { + // 尝试直接获取 uni-page-head + const height = await this.getDirectNavigationHeight(component) + if (height > 0) { + this.navigationCache.set(cacheKey, height) + return height + } + + // 备用方案:平台特定方法 + const platformHeight = await this.getPlatformNavigationHeight() + this.navigationCache.set(cacheKey, platformHeight) + return platformHeight + } catch (error) { + console.warn('获取导航栏高度失败,使用默认值:', error) + const defaultHeight = this.getDefaultNavHeight() + this.navigationCache.set(cacheKey, defaultHeight) + return defaultHeight + } + } + + // 直接查询导航栏高度 + getDirectNavigationHeight(component) { + return new Promise((resolve) => { + const query = uni.createSelectorQuery().in(component) + + query.select('.uni-page-head').boundingClientRect((rect) => { + if (rect && rect.height > 0) { + console.log('直接查询导航栏高度成功:', rect.height) + resolve(rect.height) + } else { + console.warn('未找到 uni-page-head 元素或高度为0') + resolve(0) + } + }).exec() + }) + } + + // 平台特定的高度获取 + getPlatformNavigationHeight() { + return new Promise((resolve) => { + // #ifdef MP-WEIXIN + // 微信小程序精确计算 + try { + const menuButtonInfo = wx.getMenuButtonBoundingClientRect() + const systemInfo = uni.getSystemInfoSync() + + const height = menuButtonInfo.bottom + + (menuButtonInfo.top - systemInfo.statusBarHeight) + console.log('微信小程序导航栏高度:', height) + resolve(height) + } catch (error) { + console.error('微信小程序高度计算失败:', error) + resolve(44) + } + + // #endif + + // #ifdef H5 + // H5环境:尝试获取自定义导航栏或使用默认值 + if (typeof document !== 'undefined') { + const customNav = document.querySelector('.uni-page-head') + if (customNav) { + resolve(customNav.offsetHeight) + } else { + resolve(44) // 默认导航栏高度 + } + } else { + resolve(44) + } + // #endif + + // #ifdef APP-PLUS + // App端:状态栏 + 导航栏 + try { + const statusBarHeight = plus.navigator.getStatusbarHeight() + resolve(statusBarHeight + 44) + } catch (error) { + console.error('App端高度获取失败:', error) + resolve(88) + } + // #endif + + // 默认值 + resolve(44) + }) + } + + // 获取默认高度 + getDefaultHeight() { + // #ifdef MP-WEIXIN + return 44 // 微信小程序默认 + // #endif + // #ifdef H5 + return 44 // H5默认 + // #endif + // #ifdef APP-PLUS + return 88 // App默认(状态栏44 + 导航栏44) + // #endif + return 44 + } + + // 获取状态栏高度 + getStatusBarHeight() { + // #ifdef MP-WEIXIN + const systemInfo = uni.getSystemInfoSync() + return systemInfo.statusBarHeight || 20 + // #endif + // #ifdef H5 + return 0 // H5通常没有状态栏 + // #endif + // #ifdef APP-PLUS + try { + return plus.navigator.getStatusbarHeight() + } catch (error) { + return 44 + } + // #endif + return 0 + } + + // 获取安全区域 + getSafeAreaInsets() { + try { + const systemInfo = uni.getSystemInfoSync() + return systemInfo.safeArea || { + top: 0, + bottom: 0, + left: 0, + right: 0 + } + } catch (error) { + return { + top: 0, + bottom: 0, + left: 0, + right: 0 + } + } + } + + // 创建安全的事件处理器 + createSafeEventHandler(handler, options = {}) { + return EventSafety.wrapEventHandler(handler, options) + } + + // 安全地处理服务请求事件 + createServiceRequestHandler(component) { + return this.createSafeEventHandler((event) => { + return this.handleServiceRequest(event, component) + }, { + onError: (error, event) => { + this.handleNavigationError(error, event, component) + } + }) + } + + // 处理服务请求 + async handleServiceRequest(event, component) { + console.log('处理导航相关服务请求:', event.type) + + // 安全检查事件目标 + if (this.shouldProcessNavigationRequest(event)) { + await this.processNavigationRequest(event, component) + } + } + + // 检查是否应该处理导航请求 + shouldProcessNavigationRequest(event) { + // 方法1:检查事件类型 + if (event.type === 'service.requestComponentInfo') { + return true + } + + // 方法2:检查目标元素 + if (event.matches('.navigation-component') || event.matches('.uni-page-head')) { + return true + } + + // 方法3:检查事件详情 + if (event.detail && event.detail.componentType === 'navigation') { + return true + } + + return false + } + + // 处理导航请求 + async processNavigationRequest(event, component) { + try { + // 获取导航栏信息 + const navInfo = await this.getNavigationInfo(component) + + // 发送响应 + this.emitNavigationResponse(navInfo, component) + + } catch (error) { + console.error('处理导航请求失败:', error) + throw error + } + } + + + // 获取完整的导航信息 + async getNavigationInfo(component) { + const [navHeight, statusBarHeight, safeArea] = await Promise.all([ + this.getNavigationHeight(component), + this.getStatusBarHeight(), + this.getSafeAreaInsets() + ]) + + return { + navHeight, + statusBarHeight, + safeArea, + timestamp: Date.now() + } + } + + // 发送导航响应 + emitNavigationResponse(navInfo, component) { + if (component && component.$emit) { + component.$emit('navigation.infoResponse', { + success: true, + data: navInfo, + timestamp: Date.now() + }) + } + } + + // 错误处理 + handleNavigationError(error, event, component) { + console.error('导航处理错误:', { + error: error.message, + eventType: event?.type, + component: component?.$options?.name + }) + + // 发送错误响应 + if (component && component.$emit) { + component.$emit('navigation.infoError', { + success: false, + error: error.message, + timestamp: Date.now() + }) + } + + // 显示用户友好的错误信息 + this.showError('导航服务暂时不可用') + } + + // 显示错误提示 + showError(message) { + uni.showToast({ + title: message, + icon: 'none', + duration: 2000 + }) + } + + // 清理缓存 + clearCache() { + this.navigationCache.clear() + console.log('导航缓存已清理') + } + +} + +// 创建全局实例 +const navigationHelper = new NavigationHelper() + +export default navigationHelper \ No newline at end of file diff --git a/components/ai-chat-message/README.md b/components/ai-chat-message/README.md new file mode 100644 index 0000000..0f999a8 --- /dev/null +++ b/components/ai-chat-message/README.md @@ -0,0 +1,271 @@ +# AI智能客服组件 + +一个功能完整的AI智能客服对话组件,支持多种消息类型和交互功能。 + +## 功能特性 + +- ✅ 支持对话上下文管理 +- ✅ 支持多种消息类型:文本、Markdown、文件、音频、视频、链接、商品卡片 +- ✅ 支持语音输入和录音 +- ✅ 支持图片、文件、位置等附件发送 +- ✅ 支持消息操作按钮(点赞、踩等) +- ✅ 支持历史消息加载 +- ✅ 响应式设计,适配多端 + +## 安装使用 + +### 1. 引入组件 + +在 `pages.json` 中注册组件: + +```json +{ + "usingComponents": { + "ai-chat-message": "/components/ai-chat-message/ai-chat-message" + } +} +``` + +### 2. 在页面中使用 + +```vue + + + +``` + +## Props 配置 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| initialMessages | Array | [] | 初始消息列表 | +| userAvatar | String | '/static/images/default-avatar.png' | 用户头像 | +| aiAvatar | String | '/static/images/ai-avatar.png' | AI头像 | +| showLoadMore | Boolean | true | 是否显示加载更多 | +| maxMessages | Number | 100 | 最大消息数量 | + +## Events 事件 + +| 事件名 | 参数 | 说明 | +|--------|------|------| +| message-sent | message | 用户发送消息 | +| ai-response | message | AI回复消息 | +| action-click | {action, message} | 操作按钮点击 | +| history-loaded | messages | 历史消息加载完成 | +| file-preview | message | 文件预览 | +| audio-play | message | 音频播放 | +| audio-pause | message | 音频暂停 | +| video-play | message | 视频播放 | +| video-pause | message | 视频暂停 | +| link-open | message | 链接打开 | +| product-view | message | 商品查看 | +| input-change | value | 输入内容变化 | + +## 消息类型格式 + +### 文本消息 +```javascript +{ + id: 1, + role: 'user', // 或 'ai' + type: 'text', + content: '消息内容', + timestamp: Date.now() +} +``` + +### Markdown消息 +```javascript +{ + id: 2, + role: 'ai', + type: 'markdown', + content: '# 标题\n**粗体** *斜体* `代码`', + timestamp: Date.now() +} +``` + +### 文件消息 +```javascript +{ + id: 3, + role: 'ai', + type: 'file', + fileName: '文档.pdf', + fileSize: 1024000, + url: '文件地址', + timestamp: Date.now() +} +``` + +### 音频消息 +```javascript +{ + id: 4, + role: 'ai', + type: 'audio', + title: '语音消息', + duration: 60, // 秒 + url: '音频地址', + timestamp: Date.now() +} +``` + +### 视频消息 +```javascript +{ + id: 5, + role: 'ai', + type: 'video', + title: '产品介绍', + duration: 120, // 秒 + url: '视频地址', + cover: '封面图', + timestamp: Date.now() +} +``` + +### 链接消息 +```javascript +{ + id: 6, + role: 'ai', + type: 'link', + title: '帮助文档', + description: '详细的使用说明', + url: 'https://example.com', + image: '缩略图', + timestamp: Date.now() +} +``` + +### 商品卡片 +```javascript +{ + id: 7, + role: 'ai', + type: 'product', + title: '商品名称', + price: 299, + description: '商品描述', + image: '商品图片', + timestamp: Date.now() +} +``` + +## 方法 + +通过 ref 调用组件方法: + +```javascript +// 添加消息 +this.$refs.chat.addMessage(message) + +// 清空消息 +this.$refs.chat.clearMessages() + +// 滚动到底部 +this.$refs.chat.scrollToBottom() +``` + +## 样式定制 + +组件使用 SCSS 编写,可以通过 CSS 变量进行主题定制: + +```css +.ai-chat-container { + --primary-color: #ff4544; + --bg-color: #f8f8f8; + --text-color: #333; +} +``` + +## 方法 + +通过 ref 调用组件方法: + +```javascript +// 添加消息 +this.$refs.chat.addMessage(message) + +// 清空消息 +this.$refs.chat.clearMessages() + +// 滚动到底部 +this.$refs.chat.scrollToBottom() + +// 开始语音输入 +this.$refs.chat.startVoiceInput() + +// 停止语音输入 +this.$refs.chat.stopVoiceInput() +``` + +## 样式定制 + +组件使用 SCSS 编写,可以通过 CSS 变量进行主题定制: + +```css +.ai-chat-container { + --primary-color: #ff4544; + --bg-color: #f8f8f8; + --text-color: #333; + --border-color: #eeeeee; + --user-bg: #e6f7ff; + --ai-bg: #f6f6f6; +} +``` + +## 图标字体 + +组件使用自定义图标字体,需要在页面中引入: + +```html + +``` + +## 注意事项 + +1. 组件已适配项目中已有的 `ns-loading` 组件 +2. 需要配置对应的图标字体文件 +3. 音频播放功能在 H5 和 APP 端支持较好 +4. 文件预览功能依赖平台能力 +5. 语音输入功能需要用户授权麦克风权限 \ No newline at end of file diff --git a/components/ai-chat-message/ai-chat-message.json b/components/ai-chat-message/ai-chat-message.json new file mode 100644 index 0000000..d806736 --- /dev/null +++ b/components/ai-chat-message/ai-chat-message.json @@ -0,0 +1,6 @@ +{ + "component": true, + "usingComponents": { + "ns-loading": "../ns-loading/ns-loading" + } +} \ No newline at end of file diff --git a/components/ai-chat-message/ai-chat-message.vue b/components/ai-chat-message/ai-chat-message.vue new file mode 100644 index 0000000..528f729 --- /dev/null +++ b/components/ai-chat-message/ai-chat-message.vue @@ -0,0 +1,2766 @@ +