diff --git a/im-platform/src/main/java/com/bx/implatform/controller/AutoReplyController.java b/im-platform/src/main/java/com/bx/implatform/controller/AutoReplyController.java new file mode 100644 index 0000000..daf73ad --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/controller/AutoReplyController.java @@ -0,0 +1,29 @@ +package com.bx.implatform.controller; + +import com.bx.implatform.result.Result; +import com.bx.implatform.result.ResultUtils; +import com.bx.implatform.service.AutoReplyService; +import com.bx.implatform.vo.AutoReplyVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "常见问题") +@RestController +@RequestMapping("/auto/reply") +@RequiredArgsConstructor +public class AutoReplyController { + private final AutoReplyService AutoReplyService; + + @GetMapping("/list") + @Operation(summary = "获取常见问题列表", description = "前端聊天页调用") + public Result> getList(@RequestParam Long userId) { + return ResultUtils.success(AutoReplyService.getList(userId)); + } +} diff --git a/im-platform/src/main/java/com/bx/implatform/entity/AutoReply.java b/im-platform/src/main/java/com/bx/implatform/entity/AutoReply.java new file mode 100644 index 0000000..0150ef4 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/entity/AutoReply.java @@ -0,0 +1,24 @@ +package com.bx.implatform.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("im_auto_reply") +public class AutoReply { + @TableId(type = IdType.AUTO) + private Long id; + private String uniqueToken; + private String replyName; + private Integer replyType; + private String replyTitle; + private String replyContent; + private String remark; + private LocalDateTime createdTime; + private LocalDateTime updatedTime; + private Long creatorId; + private Long updaterId; +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/mapper/AutoReplyMapper.java b/im-platform/src/main/java/com/bx/implatform/mapper/AutoReplyMapper.java new file mode 100644 index 0000000..b3eed94 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/mapper/AutoReplyMapper.java @@ -0,0 +1,9 @@ +package com.bx.implatform.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.bx.implatform.entity.AutoReply; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AutoReplyMapper extends BaseMapper { +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/service/AutoReplyService.java b/im-platform/src/main/java/com/bx/implatform/service/AutoReplyService.java new file mode 100644 index 0000000..f295023 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/service/AutoReplyService.java @@ -0,0 +1,8 @@ +package com.bx.implatform.service; + +import com.bx.implatform.vo.AutoReplyVO; +import java.util.List; + +public interface AutoReplyService { + List getList(Long userId); +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/service/impl/AutoReplyServiceImpl.java b/im-platform/src/main/java/com/bx/implatform/service/impl/AutoReplyServiceImpl.java new file mode 100644 index 0000000..acf72a6 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/service/impl/AutoReplyServiceImpl.java @@ -0,0 +1,49 @@ +package com.bx.implatform.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bx.implatform.entity.AutoReply; +import com.bx.implatform.entity.User; +import com.bx.implatform.mapper.AutoReplyMapper; +import com.bx.implatform.mapper.UserMapper; +import com.bx.implatform.service.AutoReplyService; +import com.bx.implatform.vo.AutoReplyVO; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AutoReplyServiceImpl extends ServiceImpl implements AutoReplyService { + + private final UserMapper userMapper; + + @Override + public List getList(Long userId) { + // 1. 根据前端传的 userId 查询用户 + User user = userMapper.selectById(userId); + if (user == null) { + return List.of(); + } + + // 2. 拿到用户的 uniqueToken + String uniqueToken = user.getUniqueToken(); + + // 3. 匹配常见问题 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AutoReply::getUniqueToken, uniqueToken); + wrapper.orderByAsc(AutoReply::getCreatedTime); + + List list = this.list(wrapper); + + // 4. 转VO返回 + return list.stream().map(item -> { + AutoReplyVO vo = new AutoReplyVO(); + BeanUtils.copyProperties(item, vo); + return vo; + }).collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/im-platform/src/main/java/com/bx/implatform/vo/AutoReplyVO.java b/im-platform/src/main/java/com/bx/implatform/vo/AutoReplyVO.java new file mode 100644 index 0000000..8f65200 --- /dev/null +++ b/im-platform/src/main/java/com/bx/implatform/vo/AutoReplyVO.java @@ -0,0 +1,16 @@ +package com.bx.implatform.vo; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class AutoReplyVO { + private Long id; + private String uniqueToken; + private String replyName; + private Integer replyType; // 0文本 1图片 + private String replyTitle; + private String replyContent; + private String remark; + private LocalDateTime createdTime; +} \ No newline at end of file diff --git a/im-uniapp/pages/chat/chat-box.vue b/im-uniapp/pages/chat/chat-box.vue index df617c0..9c05767 100644 --- a/im-uniapp/pages/chat/chat-box.vue +++ b/im-uniapp/pages/chat/chat-box.vue @@ -3,6 +3,16 @@ {{ title }} + + + 请选择您想咨询的问题: + + + {{ q }} + + + @@ -126,43 +136,109 @@ import UNI_APP from '@/.env.js'; export default { data() { return { - // chat: {}, userInfo: {}, group: {}, groupMembers: [], - isReceipt: false, // 是否回执消息 - scrollMsgIdx: 0, // 滚动条定位为到哪条消息 + showAutoQuestionTip: true, + commonQuestions: [], // 空数组,接口获取 + autoReplyList: [], // 存储完整回复列表 + isReceipt: false, + scrollMsgIdx: 0, chatTabBox: 'none', - currentTargetId: null, // 加这一行 + currentTargetId: null, currentChatType: 'PRIVATE', - isLoading: true, // 添加加载状态 - defaultTitle: '加载中...', // 默认标题 - // activeChatIdx: 0, - _activeChatIdx: 0, // 添加这个私有变量 + _activeChatIdx: 0, showRecord: false, - chatMainHeight: 800, // 聊天窗口高度 - keyboardHeight: 290, // 键盘高度 - screenHeight: 1000, // 屏幕高度 - windowHeight: 1000, // 窗口高度 - initHeight: 1000, // h5初始高度 + chatMainHeight: 800, + keyboardHeight: 290, + screenHeight: 1000, + windowHeight: 1000, + initHeight: 1000, atUserIds: [], - showMinIdx: 0, // 下标小于showMinIdx的消息不显示,否则可能很卡 - reqQueue: [], // 请求队列 - isSending: false, // 是否正在发送请求 - isShowKeyBoard: false, // 键盘是否正在弹起 - editorCtx: null, // 编辑器上下文 - isEmpty: true, // 编辑器是否为空 - isFocus: false, // 编辑器是否焦点 - isReadOnly: false, // 编辑器是否只读 - playingAudio: null, // 当前正在播放的录音消息 - isInBottom: true, // 滚动条是否在底部 - newMessageSize: 0, // 滚动条不在底部时新的消息数量 - scrollTop: 0, // 用于ios h5定位滚动条 - scrollViewHeight: 0, // 滚动条总长度 - maxTmpId: 0 // 最后生成的临时id + showMinIdx: 0, + reqQueue: [], + isSending: false, + isShowKeyBoard: false, + editorCtx: null, + isEmpty: true, + isFocus: false, + isReadOnly: false, + playingAudio: null, + isInBottom: true, + newMessageSize: 0, + scrollTop: 0, + scrollViewHeight: 0, + maxTmpId: 0 } }, methods: { + loadCommonQuestions(userId) { + this.$http({ + url: "/auto/reply/list?userId=" + userId, + method: 'get', + }).then(res => { + // 你的接口是 res.data 才是列表 + let list = res || []; + this.autoReplyList = list; + this.commonQuestions = list.map(item => item.replyTitle); + }).catch(() => { + // this.commonQuestions = ["什么时候发货?", "如何申请退款?", "订单怎么取消?", "可以开发票吗?", "商品是否包邮?"]; + }); + }, + + sendQuestionMessage(replyTitle) { + let autoReply = this.autoReplyList.find(item => item.replyTitle === replyTitle); + if (!autoReply) return; + + // 检查是否被封禁 + if (this.isBanned) { + this.showBannedTip(); + return; + } + + let msgInfo = { + tmpId: this.generateId(), + receipt: this.isReceipt + }; + + // ============== 区分类型 ============== + if (autoReply.replyType === 0) { + // 文本 + msgInfo.type = this.$enums.MESSAGE_TYPE.TEXT; + msgInfo.content = autoReply.replyContent; + } else if (autoReply.replyType === 1) { + // 图片 + msgInfo.type = this.$enums.MESSAGE_TYPE.IMAGE; + msgInfo.content = JSON.stringify({ + originUrl: autoReply.replyContent, + thumbUrl: autoReply.replyContent + }); + } else { + return; + } + + this.fillTargetId(msgInfo, this.chat.targetId); + const chat = this.chat; + if (!chat) return; + + let tmpMessage = this.buildTmpMessage(msgInfo); + this.chatStore.insertMessage(tmpMessage, chat); + this.moveChatToTop(); + + this.sendMessageRequest(msgInfo).then((m) => { + tmpMessage = JSON.parse(JSON.stringify(tmpMessage)); + tmpMessage.id = m.id; + tmpMessage.status = m.status; + this.chatStore.updateMessage(tmpMessage, chat); + this.scrollToBottom(); + this.isReceipt = false; + }).catch(() => { + tmpMessage = JSON.parse(JSON.stringify(tmpMessage)); + tmpMessage.status = this.$enums.MESSAGE_STATUS.FAILED; + this.chatStore.updateMessage(tmpMessage, chat); + }); + }, + onRecorderInput() { this.showRecord = true; this.switchChatTabBox('none'); @@ -1038,7 +1114,6 @@ export default { }); }, generateId() { - // 生成临时id const id = String(new Date().getTime()) + String(Math.floor(Math.random() * 1000)); // 必须保证id是递增 if (this.maxTmpId > id) { @@ -1050,20 +1125,14 @@ export default { }, computed: { title() { - // 加载中显示默认标题 - if (this.isLoading) { - return this.defaultTitle; - } - if (!this.chat) { - return ""; - } - let title = this.chat.showName; - if (this.isGroup) { - let size = this.groupMembers.filter(m => !m.quit).length; - title += `(${size})`; - } - return title; - }, + if (!this.chat) return ""; + let title = this.chat.showName; + if (this.isGroup) { + let size = this.groupMembers.filter(m => !m.quit).length; + title += `(${size})`; + } + return title; + }, chat() { if (!this.currentTargetId) return null; return this.chatStore.chats.find(c => @@ -1076,37 +1145,13 @@ export default { get() { return this._activeChatIdx || 0; }, set(val) { this._activeChatIdx = val; } }, - - // activeChatIdx: { - // get() { - // return this._activeChatIdx || 0; - // }, - // set(val) { - // this._activeChatIdx = val; - // } - // }, - mine() { return this.userStore.userInfo; }, friend() { return this.friendStore.findFriend(this.userInfo.id); }, - title() { - if (!this.chat) { - return ""; - } - let title = this.chat.showName; - if (this.isGroup) { - let size = this.groupMembers.filter(m => !m.quit).length; - title += `(${size})`; - } - return title; - }, - // messageAction() { - // return `/message/${this.chat.type.toLowerCase()}/send`; - // }, - messageAction() { + messageAction() { if (!this.chat) return ''; return `/message/${this.chat.type.toLowerCase()}/send`; }, @@ -1171,8 +1216,6 @@ export default { deep: true, immediate: true }, - - // 监听消息数组长度变化 'chat.messages.length': function(newLength, oldLength) { if (newLength > oldLength && this.isInBottom) { this.$nextTick(() => { @@ -1180,8 +1223,6 @@ export default { }); } }, - - // 原有的 messageSize watch messageSize: function(newSize, oldSize) { if (newSize > oldSize && oldSize > 0) { let lastMessage = this.chat.messages[newSize - 1]; @@ -1218,12 +1259,10 @@ export default { let targetId = null; let type = 'PRIVATE'; - // 从列表进入 if (options.targetId) { targetId = Number(options.targetId); type = options.type || 'PRIVATE'; } else { - // 直接打开页面 if (this.friendStore.friends.length === 0) await this.friendStore.loadFriend(); const first = this.friendStore.friends[0]; if (!first) return uni.showToast({ title: '暂无好友', icon: 'none' }); @@ -1233,7 +1272,6 @@ export default { await this.chatStore.loadChat(); await new Promise(r => setTimeout(r, 300)); - // 查找会话 let chat = this.chatStore.chats.find(c => c.type === type && c.targetId === targetId); if (!chat) { const friend = this.friendStore.findFriend(targetId) || this.friendStore.friends[0]; @@ -1246,7 +1284,7 @@ export default { }; this.chatStore.chats.unshift(chat); } - + this.currentTargetId = targetId; this.currentChatType = type; @@ -1261,7 +1299,10 @@ export default { this.loadFriend(targetId); this.loadReaded(targetId); - + + // 加载常见问题 + this.loadCommonQuestions(targetId); + this.listenKeyBoard(); this.windowHeight = uni.getSystemInfoSync().windowHeight; this.screenHeight = uni.getSystemInfoSync().screenHeight; @@ -1329,9 +1370,46 @@ export default { overflow: hidden; position: relative; background-color: white; - + .scroll-box { height: 100%; + padding-top: 120rpx; /* 给常见问题条留出空间 */ + box-sizing: border-box; + } + + // 固定在聊天顶部的常见问题提示条(不会滚动) + .question-tip-fixed { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; + background-color: #ffffff; + padding: 12rpx 20rpx; + // border-bottom: 1rpx solid #f0f0f0; + .tip-title { + font-size: 26rpx; + color: #999; + text-align: center; + margin-bottom: 8rpx; + } + .question-list { + display: flex; + flex-wrap: wrap; + gap: 10rpx; + justify-content: center; + } + .question-item { + background-color: #f5f7fa; + border-radius: 30rpx; + padding: 8rpx 16rpx; + font-size: 26rpx; + color: #333; + border: 1rpx solid #eaeaea; + } + .question-item:active { + background-color: #e6e6e6; + } } .scroll-to-bottom { diff --git a/im-web/src/main.js b/im-web/src/main.js index 7573a82..9a95449 100644 --- a/im-web/src/main.js +++ b/im-web/src/main.js @@ -36,7 +36,6 @@ Vue.prototype.$str = str; // 字符串相关 Vue.prototype.$elm = element; // 元素操作 Vue.prototype.$enums = enums; // 枚举 Vue.prototype.$eventBus = new Vue(); // 全局事件 - Vue.config.productionTip = false; new Vue({