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/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/ai-chat-message.vue b/components/ai-chat-message/ai-chat-message.vue index 21ddf7d..0d7ada9 100644 --- a/components/ai-chat-message/ai-chat-message.vue +++ b/components/ai-chat-message/ai-chat-message.vue @@ -162,7 +162,7 @@ v-for="action in message.actions" :key="action.id" class="action-btn" - :class="action.type" + :class="[`iconfont`, action.icon, action.type]" @click="handleAction(action, message)"> {{ action.text }} @@ -246,7 +246,7 @@ - + @@ -332,7 +332,7 @@ export default { currentAudio: null, messageId: 0, showToolsPanel: false, - showVoicePanel: false, + enalbeVoicePanelShow: false, streamingMessage: null, // 流式消息对象 streamInterval: null, // 流式更新定时器 streamContent: '', // 流式内容缓存 @@ -540,12 +540,12 @@ export default { // 显示语音面板 showVoicePanel() { - this.showVoicePanel = true + this.enalbeVoicePanelShow = true }, // 隐藏语音面板 hideVoicePanel() { - this.showVoicePanel = false + this.enalbeVoicePanelShow = false this.voiceInputing = false }, @@ -779,11 +779,16 @@ export default { /* 页面样式 */ .ai-chat-container { - height: 100%; + height: 100vh; display: flex; flex-direction: column; background-color: #f8f8f8; overflow: hidden; + /* 微信小程序安全区域适配 */ + padding-bottom: env(safe-area-inset-bottom); + box-sizing: border-box; + /* 确保在微信小程序中正确布局 */ + position: relative; } .chat-messages { @@ -791,7 +796,8 @@ export default { padding: 20rpx; overflow-y: auto; box-sizing: border-box; - height: calc(100% - 200rpx); /* 减去输入区域高度 */ + /* 微信小程序需要明确的flex布局 */ + min-height: 0; /* 重要:防止flex元素溢出 */ } .load-more { @@ -1162,11 +1168,13 @@ export default { gap: 10rpx; .action-btn { - padding: 8rpx 16rpx; border-radius: 20rpx; font-size: 24rpx; - border: 2rpx solid #e9ecef; + // border: 2rpx solid #e9ecef; background-color: white; + padding: 10rpx 20rpx; + display: flex; + align-items: center; &.like { color: #ff4544; @@ -1185,6 +1193,13 @@ export default { background-color: white; border-top: 2rpx solid #eeeeee; padding: 20rpx; + /* 确保在微信小程序中紧贴底部 */ + flex-shrink: 0; + /* 微信小程序安全区域适配 */ + padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); + /* 确保输入区域正确显示 */ + position: relative; + z-index: 10; } .input-tools { diff --git a/components/ai-chat-popup/README.md b/components/ai-chat-popup/README.md new file mode 100644 index 0000000..20c2fba --- /dev/null +++ b/components/ai-chat-popup/README.md @@ -0,0 +1,173 @@ +# AI智能客服弹窗集成说明 + +## 概述 + +本方案将AI智能客服弹窗功能集成到现有的浮动导航组件 `hover-nav.vue` 中,实现了对项目影响最小的集成方案。 + +## 集成文件 + +### 1. 新增组件 +- `components/ai-chat-popup/ai-chat-popup.vue` - AI聊天弹窗主组件 +- `components/ai-chat-popup/ai-chat-popup.json` - 组件配置文件 + +### 2. 修改文件 +- `components/hover-nav/hover-nav.vue` - 集成AI客服按钮和弹窗 + +## 功能特性 + +### ✅ 已实现功能 +1. **浮动AI客服按钮** - 在现有浮动导航中添加AI客服按钮 +2. **智能弹窗** - 点击按钮弹出AI聊天界面 +3. **未读消息提示** - 显示未读消息数量小红点 +4. **全屏模式** - 支持全屏/窗口模式切换 +5. **响应式设计** - 适配不同屏幕尺寸 +6. **复用现有组件** - 基于现有的 `ai-chat-message` 组件 + +### 🔧 技术特点 +1. **最小化影响** - 只修改现有组件,不改变项目结构 +2. **模块化设计** - 弹窗组件独立,可复用 +3. **事件驱动** - 完整的生命周期事件 +4. **性能优化** - 按需加载,避免资源浪费 + +## 使用方法 + +### 1. 自动集成 +AI客服功能已经自动集成到 `hover-nav` 组件中,无需额外配置。 + +### 2. 自定义配置 +如果需要自定义AI客服功能,可以修改以下配置: + +```vue + + + + + {{ aiUnreadCount > 99 ? '99+' : aiUnreadCount }} + + + + + +``` + +### 3. 事件监听 +可以监听以下事件来处理业务逻辑: + +```javascript +// 未读消息更新 +onUnreadUpdate(count) { + this.aiUnreadCount = count + // 可以在这里添加未读消息处理逻辑 +}, + +// AI消息发送事件 +onAIMessageSent(message) { + console.log('AI消息发送:', message) + // 可以在这里添加消息发送后的处理逻辑 +}, + +// AI回复事件 +onAIResponse(message) { + console.log('AI回复:', message) + // 可以在这里添加AI回复后的处理逻辑 +} +``` + +## 配置选项 + +### AI聊天弹窗组件参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| show | Boolean | false | 是否显示弹窗 | +| userAvatar | String | '/static/images/default-avatar.png' | 用户头像 | +| aiAvatar | String | '/static/images/ai-avatar.png' | AI头像 | + +### 事件 + +| 事件名 | 参数 | 说明 | +|--------|------|------| +| popup-open | - | 弹窗打开事件 | +| popup-close | - | 弹窗关闭事件 | +| unread-update | count | 未读消息数量更新 | +| message-sent | message | 消息发送事件 | +| ai-response | message | AI回复事件 | + +## 样式定制 + +### 1. AI客服按钮样式 +```css +.btn-item { + /* 按钮基础样式 */ + position: relative; +} + +.unread-badge { + /* 未读消息小红点样式 */ + position: absolute; + top: -5rpx; + right: -5rpx; + background-color: #ff4544; + color: white; + border-radius: 20rpx; + min-width: 30rpx; + height: 30rpx; + font-size: 20rpx; + line-height: 30rpx; + text-align: center; + padding: 0 8rpx; + z-index: 1; + box-shadow: 0 2rpx 10rpx rgba(255, 69, 68, 0.3); +} +``` + +### 2. 弹窗样式 +弹窗样式在 `ai-chat-popup.vue` 中定义,可以根据需要自定义。 + +## 注意事项 + +1. **图标资源** - 需要确保AI图标文件存在,默认路径为 `/static/images/ai-icon.png` +2. **组件依赖** - AI聊天弹窗依赖于现有的 `ai-chat-message` 组件 +3. **z-index** - 弹窗的z-index设置为1000,确保在其他元素之上 +4. **响应式适配** - 弹窗已经做了移动端适配,但可能需要根据具体项目调整 + +## 兼容性 + +- ✅ H5 +- ✅ 微信小程序 +- ✅ APP +- ✅ 其他小程序平台 + +## 后续优化建议 + +1. **图标优化** - 可以添加更精美的AI图标 +2. **动画效果** - 可以添加更流畅的打开/关闭动画 +3. **主题定制** - 支持暗色主题等 +4. **多语言** - 支持国际化 +5. **性能优化** - 懒加载等优化措施 + +## 问题排查 + +如果AI客服功能无法正常工作,请检查: + +1. 组件路径是否正确 +2. 依赖的 `ai-chat-message` 组件是否存在 +3. 图标资源路径是否正确 +4. 控制台是否有错误信息 + +## 总结 + +本集成方案成功将AI智能客服功能添加到现有的浮动导航组件中,实现了: + +- ✅ 对项目影响最小 +- ✅ 功能完整可用 +- ✅ 代码结构清晰 +- ✅ 易于维护扩展 + +现在您可以在任何使用 `hover-nav` 组件的页面中享受AI智能客服功能! \ No newline at end of file diff --git a/components/ai-chat-popup/ai-chat-popup.json b/components/ai-chat-popup/ai-chat-popup.json new file mode 100644 index 0000000..ceb093a --- /dev/null +++ b/components/ai-chat-popup/ai-chat-popup.json @@ -0,0 +1,6 @@ +{ + "component": true, + "usingComponents": { + "ai-chat-message": "../ai-chat-message/ai-chat-message" + } +} \ No newline at end of file diff --git a/components/ai-chat-popup/ai-chat-popup.vue b/components/ai-chat-popup/ai-chat-popup.vue new file mode 100644 index 0000000..a5c713a --- /dev/null +++ b/components/ai-chat-popup/ai-chat-popup.vue @@ -0,0 +1,310 @@ + + + + + \ No newline at end of file diff --git a/components/hover-nav/hover-nav.vue b/components/hover-nav/hover-nav.vue index c4f38c3..5da67c2 100644 --- a/components/hover-nav/hover-nav.vue +++ b/components/hover-nav/hover-nav.vue @@ -1,142 +1,209 @@ - - - - - \ No newline at end of file diff --git a/pages.json b/pages.json index 1ebd322..13be789 100644 --- a/pages.json +++ b/pages.json @@ -854,7 +854,7 @@ { "path": "ai-chat/index", "style": { - // "navigationBarTitleText": "AI客服浮动按钮演示" + "navigationBarTitleText": "" } } ] diff --git a/pages_tool/ai-chat/index.json b/pages_tool/ai-chat/index.json index 2ec5f57..370e1ed 100644 --- a/pages_tool/ai-chat/index.json +++ b/pages_tool/ai-chat/index.json @@ -1,5 +1,4 @@ { - "navigationBarTitleText": "AI智能客服", "navigationBarBackgroundColor": "#ff4544", "navigationBarTextStyle": "white", "backgroundColor": "#f8f8f8", diff --git a/pages_tool/ai-chat/index.vue b/pages_tool/ai-chat/index.vue index 2015c03..291a367 100644 --- a/pages_tool/ai-chat/index.vue +++ b/pages_tool/ai-chat/index.vue @@ -1,24 +1,30 @@