打分评价

This commit is contained in:
2025-12-29 16:17:27 +08:00
parent 23b4383563
commit a33720b9f6
23 changed files with 880 additions and 72 deletions

View File

@@ -30,7 +30,7 @@ DROP TABLE IF EXISTS workcase.tb_chat_room CASCADE;
CREATE TABLE workcase.tb_chat_room( CREATE TABLE workcase.tb_chat_room(
optsn VARCHAR(50) NOT NULL, -- 流水号 optsn VARCHAR(50) NOT NULL, -- 流水号
room_id VARCHAR(50) NOT NULL, -- 聊天室ID room_id VARCHAR(50) NOT NULL, -- 聊天室ID
workcase_id VARCHAR(50) DEFAULT NULL, -- 关联工单ID workcase_id VARCHAR(50) DEFAULT NULL, -- 关联工单ID
room_name VARCHAR(200) NOT NULL, -- 聊天室名称(如:工单#12345的客服支持 room_name VARCHAR(200) NOT NULL, -- 聊天室名称(如:工单#12345的客服支持
room_type VARCHAR(20) NOT NULL DEFAULT 'workcase', -- 聊天室类型workcase-工单客服 room_type VARCHAR(20) NOT NULL DEFAULT 'workcase', -- 聊天室类型workcase-工单客服
status VARCHAR(20) NOT NULL DEFAULT 'active', -- 状态active-活跃 closed-已关闭 archived-已归档 status VARCHAR(20) NOT NULL DEFAULT 'active', -- 状态active-活跃 closed-已关闭 archived-已归档
@@ -40,6 +40,7 @@ CREATE TABLE workcase.tb_chat_room(
message_count INTEGER NOT NULL DEFAULT 0, -- 消息总数 message_count INTEGER NOT NULL DEFAULT 0, -- 消息总数
last_message_time TIMESTAMPTZ DEFAULT NULL, -- 最后消息时间 last_message_time TIMESTAMPTZ DEFAULT NULL, -- 最后消息时间
last_message TEXT DEFAULT NULL, -- 最后一条消息内容(用于列表展示) last_message TEXT DEFAULT NULL, -- 最后一条消息内容(用于列表展示)
comment_level INTEGER DEFAULT 0, -- 服务评分1-5
closed_by VARCHAR(50) DEFAULT NULL, -- 关闭人 closed_by VARCHAR(50) DEFAULT NULL, -- 关闭人
closed_time TIMESTAMPTZ DEFAULT NULL, -- 关闭时间 closed_time TIMESTAMPTZ DEFAULT NULL, -- 关闭时间
creator VARCHAR(50) NOT NULL, -- 创建人(系统自动创建) creator VARCHAR(50) NOT NULL, -- 创建人(系统自动创建)

View File

@@ -60,6 +60,9 @@ public class TbChatRoomDTO extends BaseDTO {
@Schema(description = "最后一条消息内容") @Schema(description = "最后一条消息内容")
private String lastMessage; private String lastMessage;
@Schema(description = "服务评分1-5星")
private Integer commentLevel;
@Schema(description = "关闭人") @Schema(description = "关闭人")
private String closedBy; private String closedBy;

View File

@@ -208,4 +208,16 @@ public interface ChatRoomService {
*/ */
ResultDomain<CustomerServiceVO> assignCustomerService(String roomId); ResultDomain<CustomerServiceVO> assignCustomerService(String roomId);
// ========================= 聊天室评分管理 ==========================
/**
* @description 提交聊天室服务评分
* @param roomId 聊天室ID
* @param commentLevel 评分1-5星
* @param userId 评分用户ID
* @author cascade
* @since 2025-12-29
*/
ResultDomain<Boolean> submitCommentLevel(String roomId, Integer commentLevel, String userId);
} }

View File

@@ -63,6 +63,9 @@ public class ChatRoomVO extends BaseVO {
@Schema(description = "最后一条消息内容") @Schema(description = "最后一条消息内容")
private String lastMessage; private String lastMessage;
@Schema(description = "服务评分1-5星")
private Integer commentLevel;
@Schema(description = "关闭人") @Schema(description = "关闭人")
private String closedBy; private String closedBy;

View File

@@ -1,6 +1,7 @@
package org.xyzh.workcase.controller; package org.xyzh.workcase.controller;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -40,6 +41,7 @@ import io.swagger.v3.oas.annotations.Operation;
import java.util.Arrays; import java.util.Arrays;
import java.util.Date; import java.util.Date;
import java.util.Map;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -109,6 +111,31 @@ public class WorkcaseChatContorller {
return chatRoomService.closeChatRoom(roomId, closedBy); return chatRoomService.closeChatRoom(roomId, closedBy);
} }
/**
* 提交聊天室服务评分
* @param roomId 聊天室ID
* @param commentLevel 评分1-5星
* @return 提交结果
*/
@Operation(summary = "提交聊天室服务评分")
@PreAuthorize("hasAuthority('workcase:room:view')")
@PostMapping("/room/{roomId}/comment")
public ResultDomain<Boolean> submitComment(
@PathVariable("roomId") String roomId,
@RequestBody Map<String, Integer> body) {
Integer commentLevel = body.get("commentLevel");
if (commentLevel == null || commentLevel < 1 || commentLevel > 5) {
return ResultDomain.failure("评分必须在1-5之间");
}
// 获取当前登录用户ID
String userId = LoginUtil.getCurrentUserId();
// 调用服务层提交评分
return chatRoomService.submitCommentLevel(roomId, commentLevel, userId);
}
@Operation(summary = "获取聊天室详情") @Operation(summary = "获取聊天室详情")
@PreAuthorize("hasAuthority('workcase:room:view')") @PreAuthorize("hasAuthority('workcase:room:view')")
@GetMapping("/room/{roomId}") @GetMapping("/room/{roomId}")

View File

@@ -764,4 +764,60 @@ public class ChatRoomServiceImpl implements ChatRoomService {
logger.error("发布列表更新到Redis失败", e); logger.error("发布列表更新到Redis失败", e);
} }
} }
// ========================= 聊天室评分管理 ==========================
@Override
@Transactional
public ResultDomain<Boolean> submitCommentLevel(String roomId, Integer commentLevel, String userId) {
logger.info("提交聊天室服务评分: roomId={}, commentLevel={}, userId={}", roomId, commentLevel, userId);
// 参数校验
if (NonUtils.isEmpty(roomId)) {
return ResultDomain.failure("聊天室ID不能为空");
}
if (commentLevel == null || commentLevel < 1 || commentLevel > 5) {
return ResultDomain.failure("评分必须在1-5星之间");
}
try {
// 1. 检查聊天室是否存在
TbChatRoomDTO chatRoom = chatRoomMapper.selectChatRoomById(roomId);
if (chatRoom == null) {
return ResultDomain.failure("聊天室不存在");
}
// 2. 检查用户是否是聊天室成员(来客)
if (!userId.equals(chatRoom.getGuestId())) {
return ResultDomain.failure("只有来客可以对服务进行评分");
}
// 3. 检查是否已评分
if (chatRoom.getCommentLevel() != null && chatRoom.getCommentLevel() > 0) {
return ResultDomain.failure("已经评分过了,不能重复评分");
}
// 4. 更新评分
TbChatRoomDTO updateRoom = new TbChatRoomDTO();
updateRoom.setRoomId(roomId);
updateRoom.setCommentLevel(commentLevel);
int rows = chatRoomMapper.updateChatRoom(updateRoom);
if (rows > 0) {
logger.info("聊天室服务评分成功: roomId={}, commentLevel={}", roomId, commentLevel);
// TODO: 后续可以在这里更新客服人员的平均满意度评分
// updateCustomerServiceSatisfaction(chatRoom, commentLevel);
return ResultDomain.success("评分成功",true);
} else {
return ResultDomain.failure("评分提交失败");
}
} catch (Exception e) {
logger.error("提交聊天室服务评分失败: roomId={}, commentLevel={}", roomId, commentLevel, e);
return ResultDomain.failure("评分提交失败: " + e.getMessage());
}
}
} }

View File

@@ -370,10 +370,38 @@ public class WorkcaseServiceImpl implements WorkcaseService {
workcaseMapper.updateWorkcase(workcase); workcaseMapper.updateWorkcase(workcase);
} }
} else if (WorkcaseProcessAction.FINISH.getName().equals(action)) { } else if (WorkcaseProcessAction.FINISH.getName().equals(action)) {
// 1. 更新工单状态为已完成
TbWorkcaseDTO workcase = new TbWorkcaseDTO(); TbWorkcaseDTO workcase = new TbWorkcaseDTO();
workcase.setWorkcaseId(workcaseProcess.getWorkcaseId()); workcase.setWorkcaseId(workcaseProcess.getWorkcaseId());
workcase.setStatus("done"); workcase.setStatus("done");
workcaseMapper.updateWorkcase(workcase); workcaseMapper.updateWorkcase(workcase);
// 2. 发送系统评分消息到聊天室
try {
TbWorkcaseDTO workcaseData = workcaseMapper.selectWorkcaseById(workcaseProcess.getWorkcaseId());
if (workcaseData != null && workcaseData.getRoomId() != null) {
// 创建系统评分消息
org.xyzh.api.workcase.dto.TbChatRoomMessageDTO commentMessage = new org.xyzh.api.workcase.dto.TbChatRoomMessageDTO();
commentMessage.setMessageId(IdUtil.generateUUID());
commentMessage.setOptsn(IdUtil.getOptsn());
commentMessage.setRoomId(workcaseData.getRoomId());
commentMessage.setSenderId("system");
commentMessage.setSenderType("system"); // 系统消息
commentMessage.setSenderName("系统");
commentMessage.setMessageType("comment"); // 评分消息
commentMessage.setContent("请为本次服务评分");
commentMessage.setStatus("sent");
commentMessage.setCreator("system");
// 发送消息到聊天室
chatRoomService.sendMessage(commentMessage);
logger.info("工单完成,已发送系统评分消息: workcaseId={}, roomId={}",
workcaseProcess.getWorkcaseId(), workcaseData.getRoomId());
}
} catch (Exception e) {
logger.error("发送系统评分消息失败: workcaseId={}", workcaseProcess.getWorkcaseId(), e);
// 不影响工单完成流程,只记录错误日志
}
} else if (WorkcaseProcessAction.REPEAL.getName().equals(action)) { } else if (WorkcaseProcessAction.REPEAL.getName().equals(action)) {
TbWorkcaseDTO workcase = new TbWorkcaseDTO(); TbWorkcaseDTO workcase = new TbWorkcaseDTO();
workcase.setWorkcaseId(workcaseProcess.getWorkcaseId()); workcase.setWorkcaseId(workcaseProcess.getWorkcaseId());

View File

@@ -15,6 +15,7 @@
<result column="message_count" property="messageCount" jdbcType="INTEGER"/> <result column="message_count" property="messageCount" jdbcType="INTEGER"/>
<result column="last_message_time" property="lastMessageTime" jdbcType="TIMESTAMP"/> <result column="last_message_time" property="lastMessageTime" jdbcType="TIMESTAMP"/>
<result column="last_message" property="lastMessage" jdbcType="VARCHAR"/> <result column="last_message" property="lastMessage" jdbcType="VARCHAR"/>
<result column="comment_level" property="commentLevel" jdbcType="INTEGER"/>
<result column="closed_by" property="closedBy" jdbcType="VARCHAR"/> <result column="closed_by" property="closedBy" jdbcType="VARCHAR"/>
<result column="closed_time" property="closedTime" jdbcType="TIMESTAMP"/> <result column="closed_time" property="closedTime" jdbcType="TIMESTAMP"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/> <result column="creator" property="creator" jdbcType="VARCHAR"/>
@@ -38,6 +39,7 @@
<result column="unread_count" property="unreadCount" jdbcType="INTEGER"/> <result column="unread_count" property="unreadCount" jdbcType="INTEGER"/>
<result column="last_message_time" property="lastMessageTime" jdbcType="TIMESTAMP"/> <result column="last_message_time" property="lastMessageTime" jdbcType="TIMESTAMP"/>
<result column="last_message" property="lastMessage" jdbcType="VARCHAR"/> <result column="last_message" property="lastMessage" jdbcType="VARCHAR"/>
<result column="comment_level" property="commentLevel" jdbcType="INTEGER"/>
<result column="closed_by" property="closedBy" jdbcType="VARCHAR"/> <result column="closed_by" property="closedBy" jdbcType="VARCHAR"/>
<result column="closed_time" property="closedTime" jdbcType="TIMESTAMP"/> <result column="closed_time" property="closedTime" jdbcType="TIMESTAMP"/>
<result column="creator" property="creator" jdbcType="VARCHAR"/> <result column="creator" property="creator" jdbcType="VARCHAR"/>
@@ -49,7 +51,7 @@
<sql id="Base_Column_List"> <sql id="Base_Column_List">
room_id, optsn, workcase_id, room_name, room_type, status, guest_id, guest_name, room_id, optsn, workcase_id, room_name, room_type, status, guest_id, guest_name,
ai_session_id, message_count, last_message_time, last_message, closed_by, closed_time, ai_session_id, message_count, last_message_time, last_message, comment_level, closed_by, closed_time,
creator, create_time, update_time, delete_time, deleted creator, create_time, update_time, delete_time, deleted
</sql> </sql>
@@ -84,6 +86,7 @@
<if test="messageCount != null">message_count = #{messageCount},</if> <if test="messageCount != null">message_count = #{messageCount},</if>
<if test="lastMessageTime != null">last_message_time = #{lastMessageTime},</if> <if test="lastMessageTime != null">last_message_time = #{lastMessageTime},</if>
<if test="lastMessage != null">last_message = #{lastMessage},</if> <if test="lastMessage != null">last_message = #{lastMessage},</if>
<if test="commentLevel != null">comment_level = #{commentLevel},</if>
<if test="closedBy != null">closed_by = #{closedBy},</if> <if test="closedBy != null">closed_by = #{closedBy},</if>
<if test="closedTime != null">closed_time = #{closedTime},</if> <if test="closedTime != null">closed_time = #{closedTime},</if>
update_time = now() update_time = now()
@@ -122,7 +125,7 @@
<select id="selectChatRoomPage" resultMap="VOResultMap"> <select id="selectChatRoomPage" resultMap="VOResultMap">
SELECT r.room_id, r.optsn, r.workcase_id, r.room_name, r.room_type, r.status, SELECT r.room_id, r.optsn, r.workcase_id, r.room_name, r.room_type, r.status,
r.guest_id, r.guest_name, r.ai_session_id, r.message_count, r.guest_id, r.guest_name, r.ai_session_id, r.message_count,
r.last_message_time, r.last_message, r.closed_by, r.closed_time, r.last_message_time, r.last_message, r.comment_level, r.closed_by, r.closed_time,
r.creator, r.create_time, r.update_time, r.delete_time, r.deleted, r.creator, r.create_time, r.update_time, r.delete_time, r.deleted,
COALESCE(m.unread_count, 0) as unread_count COALESCE(m.unread_count, 0) as unread_count
FROM workcase.tb_chat_room r FROM workcase.tb_chat_room r

View File

@@ -272,5 +272,19 @@ export const workcaseChatAPI = {
async endVideoMeeting(meetingId: string): Promise<ResultDomain<VideoMeetingVO>> { async endVideoMeeting(meetingId: string): Promise<ResultDomain<VideoMeetingVO>> {
const response = await api.post<VideoMeetingVO>(`${this.baseUrl}/meeting/${meetingId}/end`) const response = await api.post<VideoMeetingVO>(`${this.baseUrl}/meeting/${meetingId}/end`)
return response.data return response.data
},
// ====================== 聊天室评分管理 ======================
/**
* 提交聊天室服务评分
* @param roomId 聊天室ID
* @param commentLevel 评分1-5星
*/
async submitComment(roomId: string, commentLevel: number): Promise<ResultDomain<boolean>> {
const response = await api.post<boolean>(`/urban-lifeline/workcase/chat/room/${roomId}/comment`, null, {
params: { commentLevel }
})
return response.data
} }
} }

View File

@@ -18,6 +18,7 @@ export interface TbChatRoomDTO extends BaseDTO {
agentCount?: number agentCount?: number
messageCount?: number messageCount?: number
unreadCount?: number unreadCount?: number
commentLevel?: number
lastMessageTime?: string lastMessageTime?: string
lastMessage?: string lastMessage?: string
closedBy?: string closedBy?: string
@@ -169,6 +170,7 @@ export interface ChatRoomVO extends BaseVO {
agentCount?: number agentCount?: number
messageCount?: number messageCount?: number
unreadCount?: number unreadCount?: number
commentLevel?: number
lastMessageTime?: string lastMessageTime?: string
lastMessage?: string lastMessage?: string
closedBy?: string closedBy?: string

View File

@@ -94,11 +94,14 @@
:room-name="currentRoom?.roomName" :room-name="currentRoom?.roomName"
:file-download-url="FILE_DOWNLOAD_URL" :file-download-url="FILE_DOWNLOAD_URL"
:has-more="hasMore" :has-more="hasMore"
:guest-id="currentRoom?.guestId"
:comment-level="currentRoom?.commentLevel"
:loading-more="loadingMore" :loading-more="loadingMore"
@send-message="handleSendMessage" @send-message="handleSendMessage"
@start-meeting="startMeeting" @start-meeting="startMeeting()"
@download-file="downloadFile" @download-file="downloadFile"
@load-more="loadMoreMessages" @load-more="loadMoreMessages"
@submit-comment="handleSubmitComment"
> >
<template #header> <template #header>
<div class="chat-room-header"> <div class="chat-room-header">
@@ -171,6 +174,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElInput, ElDialog, ElMessage } from 'element-plus' import { ElButton, ElInput, ElDialog, ElMessage } from 'element-plus'
import { Search, FileText, MessageSquare, MessageCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next' import { Search, FileText, MessageSquare, MessageCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import ChatRoom from './chatRoom/ChatRoom.vue' import ChatRoom from './chatRoom/ChatRoom.vue'
@@ -185,12 +189,12 @@ import { Client } from '@stomp/stompjs'
// WebSocket配置 (通过Nginx代理访问网关再到workcase服务) // WebSocket配置 (通过Nginx代理访问网关再到workcase服务)
// SockJS URL (http://) // SockJS URL (http://)
const getWsUrl = () => { const getWsUrl = () => {
const token = JSON.parse(localStorage.getItem('token')).value || '' const token = JSON.parse(localStorage.getItem('token') || '').value || ''
const protocol = window.location.protocol const protocol = window.location.protocol
const host = window.location.host const host = window.location.host
return `${protocol}//${host}/api/urban-lifeline/workcase/ws/chat-sockjs?token=${encodeURIComponent(token)}` return `${protocol}//${host}/api/urban-lifeline/workcase/ws/chat-sockjs?token=${encodeURIComponent(token)}`
} }
const router = useRouter()
// STOMP客户端 // STOMP客户端
let stompClient: any = null let stompClient: any = null
let roomSubscription: any = null let roomSubscription: any = null
@@ -488,6 +492,23 @@ const handleSendMessage = async (content: string, files: File[]) => {
} }
} }
// 处理评分提交从ChatRoom组件触发
const handleSubmitComment = async (rating: number) => {
if (!currentRoomId.value) return
try {
const result = await workcaseChatAPI.submitComment(currentRoomId.value, rating)
if (result.success) {
ElMessage.success('感谢您的评分!')
} else {
ElMessage.error(result.message || '评分提交失败')
}
} catch (error) {
console.error('评分提交失败:', error)
ElMessage.error('评分提交失败,请稍后重试')
}
}
// 下载文件 // 下载文件
const downloadFile = (fileId: string) => { const downloadFile = (fileId: string) => {
window.open(`${FILE_DOWNLOAD_URL}/${fileId}`, '_blank') window.open(`${FILE_DOWNLOAD_URL}/${fileId}`, '_blank')
@@ -525,11 +546,11 @@ const startMeeting = async () => {
const activeResult = await workcaseChatAPI.getActiveMeeting(currentRoomId.value) const activeResult = await workcaseChatAPI.getActiveMeeting(currentRoomId.value)
if (activeResult.success && activeResult.data) { if (activeResult.success && activeResult.data) {
// 已有活跃会议,直接加入 // 已有活跃会议,直接加入
currentMeetingId.value = activeResult.data.meetingId! const currentMeetingId = activeResult.data.meetingId!
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!) const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId)
if (joinResult.success && joinResult.data?.iframeUrl) { if (joinResult.success && joinResult.data?.iframeUrl) {
// 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回 // 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}` const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId}`
router.push(meetingUrl) router.push(meetingUrl)
} else { } else {
ElMessage.error(joinResult.message || '加入会议失败') ElMessage.error(joinResult.message || '加入会议失败')
@@ -544,13 +565,13 @@ const startMeeting = async () => {
}) })
if (createResult.success && createResult.data) { if (createResult.success && createResult.data) {
currentMeetingId.value = createResult.data.meetingId! const currentMeetingId = createResult.data.meetingId!
// 开始会议 // 开始会议
await workcaseChatAPI.startVideoMeeting(currentMeetingId.value!) await workcaseChatAPI.startVideoMeeting(currentMeetingId)
// 加入会议获取会议页面URL // 加入会议获取会议页面URL
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!) const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId)
if (joinResult.success && joinResult.data?.iframeUrl) { if (joinResult.success && joinResult.data?.iframeUrl) {
// 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回 // 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}` const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}`

View File

@@ -1,9 +0,0 @@
<template>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
@import url('./CommentMessageCard.scss');
</style>

View File

@@ -137,6 +137,35 @@ $brand-color-hover: #004488;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
} }
} }
// 系统消息样式(居中显示)
&.is-system {
justify-content: center;
margin-bottom: 32px;
.system-message-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
max-width: 80%;
.system-message-text {
padding: 8px 16px;
background: rgba(148, 163, 184, 0.15);
border-radius: 16px;
font-size: 13px;
color: #64748b;
text-align: center;
}
.message-time {
font-size: 12px;
color: #94a3b8;
text-align: center;
}
}
}
} }
.message-avatar { .message-avatar {

View File

@@ -21,53 +21,76 @@
v-for="message in messages" v-for="message in messages"
:key="message.messageId" :key="message.messageId"
class="message-row" class="message-row"
:class="message.senderId === currentUserId ? 'is-me' : 'other'" :class="getMessageClass(message)"
> >
<div> <!-- 系统消息居中显示 -->
<!-- 头像 --> <template v-if="message.senderType === 'system'">
<div class="message-avatar"> <div class="system-message-container">
<img v-if="message.senderAvatar" :src="FILE_DOWNLOAD_URL + message.senderAvatar" /> <!-- 评分消息卡片 -->
<span v-else class="avatar-text">{{ message.senderName?.charAt(0) || '?' }}</span> <template v-if="message.messageType === 'comment'">
</div> <CommentMessageCard
<div class="sender-name">{{ message.senderName || '未知用户' }}</div> :room-id="roomId"
</div> :can-comment="canComment"
:initial-rating="commentLevel"
<!-- 消息内容 --> @submit="handleCommentSubmit"
<div class="message-content-wrapper"> />
<!-- 会议消息卡片 --> </template>
<template v-if="message.messageType === 'meet'"> <!-- 其他系统消息 -->
<MeetingCard :meetingId="getMeetingId(message.contentExtra)" @join="handleJoinMeeting" /> <template v-else>
<div class="system-message-text">{{ message.content }}</div>
</template>
<div class="message-time">{{ formatTime(message.sendTime) }}</div> <div class="message-time">{{ formatTime(message.sendTime) }}</div>
</template> </div>
</template>
<!-- 普通消息气泡 --> <!-- 普通用户/客服消息 -->
<template v-else> <template v-else>
<div class="message-bubble"> <div>
<div <!-- 头像 -->
class="message-text" <div class="message-avatar">
v-html="renderMarkdown(message.content || '')" <img v-if="message.senderAvatar" :src="FILE_DOWNLOAD_URL + message.senderAvatar" />
></div> <span v-else class="avatar-text">{{ message.senderName?.charAt(0) || '?' }}</span>
</div>
<div class="sender-name">{{ message.senderName || '未知用户' }}</div>
</div>
<!-- 文件列表 --> <!-- 消息内容 -->
<div v-if="message.files && message.files.length > 0" class="message-files"> <div class="message-content-wrapper">
<!-- 会议消息卡片 -->
<template v-if="message.messageType === 'meet'">
<MeetingCard :meetingId="getMeetingId(message.contentExtra)" @join="handleJoinMeeting" />
<div class="message-time">{{ formatTime(message.sendTime) }}</div>
</template>
<!-- 普通消息气泡 -->
<template v-else>
<div class="message-bubble">
<div <div
v-for="file in message.files" class="message-text"
:key="file" v-html="renderMarkdown(message.content || '')"
class="file-item" ></div>
@click="$emit('download-file', file)"
> <!-- 文件列表 -->
<div class="file-icon"> <div v-if="message.files && message.files.length > 0" class="message-files">
<FileText :size="16" /> <div
</div> v-for="file in message.files"
<div class="file-info"> :key="file"
<div class="file-name">附件</div> class="file-item"
@click="$emit('download-file', file)"
>
<div class="file-icon">
<FileText :size="16" />
</div>
<div class="file-info">
<div class="file-name">附件</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div class="message-time">{{ formatTime(message.sendTime) }}</div>
<div class="message-time">{{ formatTime(message.sendTime) }}</div> </template>
</template> </div>
</div> </template>
</div> </div>
</div> </div>
</div> </div>
@@ -141,15 +164,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick } from 'vue' import { ref, nextTick, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { FileText, Video, Paperclip, Send } from 'lucide-vue-next' import { FileText, Video, Paperclip, Send } from 'lucide-vue-next'
import MeetingCreate from '../MeetingCreate/MeetingCreate.vue' import MeetingCreate from '../MeetingCreate/MeetingCreate.vue'
import MeetingCard from '../MeetingCard/MeetingCard.vue' import MeetingCard from '../MeetingCard/MeetingCard.vue'
import CommentMessageCard from './CommentMessageCard/CommentMessageCard.vue'
import type { ChatRoomMessageVO, VideoMeetingVO } from '@/types/workcase' import type { ChatRoomMessageVO, VideoMeetingVO } from '@/types/workcase'
import { workcaseChatAPI } from '@/api/workcase' import { workcaseChatAPI } from '@/api/workcase'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { FILE_DOWNLOAD_URL } from '@/config'
const router = useRouter() const router = useRouter()
interface Props { interface Props {
@@ -158,25 +182,31 @@ interface Props {
roomId: string roomId: string
roomName?: string roomName?: string
workcaseId?: string workcaseId?: string
fileDownloadUrl?: string commentLevel?: number
hasMore?: boolean hasMore?: boolean
loadingMore?: boolean loadingMore?: boolean
guestId?: string // 聊天室访客ID用于判断评价权限
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
roomName: '聊天室', roomName: '聊天室',
fileDownloadUrl: '',
hasMore: true, hasMore: true,
loadingMore: false loadingMore: false,
guestId: ''
}) })
const FILE_DOWNLOAD_URL = props.fileDownloadUrl
// 计算当前用户是否可以评价(只有访客可以评价)
const canComment = computed(() => {
return props.currentUserId === props.guestId
})
const emit = defineEmits<{ const emit = defineEmits<{
'send-message': [content: string, files: File[]] 'send-message': [content: string, files: File[]]
'download-file': [fileId: string] 'download-file': [fileId: string]
'load-more': [] 'load-more': []
'start-meeting': [] 'start-meeting': []
'submit-comment': [rating: number]
}>() }>()
// 会议相关状态 // 会议相关状态
@@ -333,6 +363,25 @@ function getMeetingId(contentExtra: Record<string, any> | undefined): string {
return contentExtra.meetingId as string return contentExtra.meetingId as string
} }
// 获取消息的CSS类
const getMessageClass = (message: ChatRoomMessageVO) => {
if (message.senderType === 'system') {
return 'is-system'
}
return message.senderId === props.currentUserId ? 'is-me' : 'other'
}
// 处理评分提交
const handleCommentSubmit = async (rating: number) => {
try {
emit('submit-comment', rating)
ElMessage.success('感谢您的评分!')
} catch (error) {
console.error('提交评分失败:', error)
ElMessage.error('提交评分失败,请稍后重试')
}
}
// Markdown渲染函数 // Markdown渲染函数
const renderMarkdown = (text: string): string => { const renderMarkdown = (text: string): string => {
if (!text) return '' if (!text) return ''

View File

@@ -0,0 +1,103 @@
.comment-message-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 20px;
color: #fff;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
max-width: 360px;
margin: 0 auto;
.comment-header {
margin-bottom: 16px;
text-align: center;
.comment-title {
font-size: 16px;
font-weight: 500;
}
}
.comment-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
.star-rating {
display: flex;
gap: 8px;
.star-item {
cursor: pointer;
transition: transform 0.2s ease;
&:hover:not(.is-disabled) {
transform: scale(1.15);
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.8;
}
}
}
.rating-desc {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.submitted-status {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 14px;
font-weight: 500;
}
.no-permission {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
}
.submit-btn {
padding: 10px 28px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 24px;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}
&.is-active {
background: rgba(255, 255, 255, 0.95);
color: #667eea;
border-color: transparent;
&:hover:not(:disabled) {
background: #fff;
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}
}

View File

@@ -0,0 +1,134 @@
<template>
<div class="comment-message-card">
<div class="comment-header">
<span class="comment-title">{{ title }}</span>
</div>
<div class="comment-body">
<!-- 星级评分 -->
<div class="star-rating">
<div
v-for="star in 5"
:key="star"
class="star-item"
:class="{
'is-active': star <= currentRating,
'is-disabled': !canComment || isSubmitted
}"
@click="handleStarClick(star)"
@mouseenter="handleStarHover(star)"
@mouseleave="handleStarLeave"
>
<Star
:size="28"
:fill="star <= (hoverRating || currentRating) ? '#FFD700' : 'none'"
:stroke="star <= (hoverRating || currentRating) ? '#FFD700' : '#d0d0d0'"
/>
</div>
</div>
<!-- 评分描述 -->
<div v-if="currentRating > 0" class="rating-desc">
{{ getRatingDesc(currentRating) }}
</div>
<!-- 不可评价提示 -->
<div v-if="!canComment && !isSubmitted" class="no-permission">
仅访客可评价
</div>
<!-- 已评分状态 -->
<div v-else-if="isSubmitted" class="submitted-status">
<Check :size="16" />
已评分
</div>
<!-- 提交按钮 -->
<button
v-else-if="canComment"
class="submit-btn"
:class="{ 'is-active': currentRating > 0 }"
:disabled="currentRating === 0 || submitting"
@click="handleSubmit"
>
{{ submitting ? '提交中...' : '提交评分' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Star, Check } from 'lucide-vue-next'
interface Props {
roomId: string
initialRating?: number
canComment?: boolean // 是否可以评价当前用户是否为guestId
title?: string
}
const props = withDefaults(defineProps<Props>(), {
initialRating: 0,
canComment: false,
title: '请为本次服务评分'
})
const emit = defineEmits<{
'submit': [rating: number]
}>()
const currentRating = ref(props.initialRating)
const hoverRating = ref(0)
const submitting = ref(false)
const isSubmitted = ref(props.initialRating > 0)
// 监听 initialRating 变化
watch(() => props.initialRating, (newVal) => {
currentRating.value = newVal
isSubmitted.value = newVal > 0
})
// 星级描述映射
const ratingDescriptions: Record<number, string> = {
1: '非常不满意',
2: '不满意',
3: '一般',
4: '满意',
5: '非常满意'
}
const getRatingDesc = (rating: number): string => {
return ratingDescriptions[rating] || ''
}
const handleStarClick = (star: number) => {
if (!props.canComment || isSubmitted.value) return
currentRating.value = star
}
const handleStarHover = (star: number) => {
if (!props.canComment || isSubmitted.value) return
hoverRating.value = star
}
const handleStarLeave = () => {
hoverRating.value = 0
}
const handleSubmit = async () => {
if (currentRating.value === 0 || !props.canComment || isSubmitted.value) return
submitting.value = true
try {
emit('submit', currentRating.value)
isSubmitted.value = true
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@import url('./CommentMessageCard.scss');
</style>

View File

@@ -263,5 +263,20 @@ export const workcaseChatAPI = {
*/ */
endVideoMeeting(meetingId: string): Promise<ResultDomain<any>> { endVideoMeeting(meetingId: string): Promise<ResultDomain<any>> {
return request({ url: `${this.baseUrl}/meeting/${meetingId}/end`, method: 'POST' }) return request({ url: `${this.baseUrl}/meeting/${meetingId}/end`, method: 'POST' })
},
// ====================== 聊天室评分管理 ======================
/**
* 提交聊天室服务评分
* @param roomId 聊天室ID
* @param commentLevel 评分1-5星
*/
submitComment(roomId: string, commentLevel: number): Promise<ResultDomain<boolean>> {
return request({
url: `/urban-lifeline/workcase/chat/room/${roomId}/comment`,
method: 'POST',
data: { commentLevel }
})
} }
} }

View File

@@ -0,0 +1,118 @@
.comment-message-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 24rpx;
padding: 40rpx;
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
max-width: 600rpx;
margin: 0 auto;
display: flex;
flex-direction: column;
.comment-header {
margin-bottom: 32rpx;
text-align: center;
.comment-title {
font-size: 32rpx;
font-weight: 500;
}
}
.comment-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 32rpx;
.star-rating {
display: flex;
flex-direction: row;
gap: 16rpx;
.star-item {
transition: transform 0.2s ease;
&:active:not(.is-disabled) {
transform: scale(1.15);
}
&.is-disabled {
opacity: 0.8;
}
.star-icon {
font-size: 56rpx;
line-height: 1;
&.star-filled {
color: #FFD700;
}
&.star-empty {
color: rgba(255, 255, 255, 0.4);
}
}
}
}
.rating-desc {
.rating-desc-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
}
.submitted-status {
display: flex;
align-items: center;
padding: 16rpx 32rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 40rpx;
.submitted-text {
font-size: 28rpx;
font-weight: 500;
}
}
.no-permission {
padding: 16rpx 32rpx;
background: rgba(255, 255, 255, 0.15);
border-radius: 40rpx;
.no-permission-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.7);
}
}
.submit-btn {
padding: 20rpx 56rpx;
background: rgba(255, 255, 255, 0.2);
border: 2rpx solid rgba(255, 255, 255, 0.4);
border-radius: 48rpx;
color: #fff;
font-size: 28rpx;
font-weight: 500;
&.is-active {
background: rgba(255, 255, 255, 0.95);
border-color: transparent;
.submit-btn-text {
color: #667eea;
}
}
&[disabled] {
opacity: 0.6;
}
.submit-btn-text {
color: #fff;
}
}
}
}

View File

@@ -0,0 +1,116 @@
<template>
<view class="comment-message-card">
<view class="comment-header">
<text class="comment-title">{{ title }}</text>
</view>
<view class="comment-body">
<!-- 星级评分 -->
<view class="star-rating">
<view
v-for="star in 5"
:key="star"
class="star-item"
:class="{
'is-active': star <= currentRating,
'is-disabled': !canComment || isSubmitted
}"
@tap="handleStarClick(star)"
>
<text class="star-icon" :class="star <= currentRating ? 'star-filled' : 'star-empty'">★</text>
</view>
</view>
<!-- 评分描述 -->
<view v-if="currentRating > 0" class="rating-desc">
<text class="rating-desc-text">{{ getRatingDesc(currentRating) }}</text>
</view>
<!-- 不可评价提示 -->
<view v-if="!canComment && !isSubmitted" class="no-permission">
<text class="no-permission-text">仅访客可评价</text>
</view>
<!-- 已评分状态 -->
<view v-else-if="isSubmitted" class="submitted-status">
<text class="submitted-text">✓ 已评分</text>
</view>
<!-- 提交按钮 -->
<button
v-else-if="canComment"
class="submit-btn"
:class="{ 'is-active': currentRating > 0 }"
:disabled="currentRating === 0 || submitting"
@tap="handleSubmit"
>
<text class="submit-btn-text">{{ submitting ? '提交中...' : '提交评分' }}</text>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
roomId: string
initialRating?: number
canComment?: boolean // 是否可以评价当前用户是否为guestId
title?: string
}
const props = withDefaults(defineProps<Props>(), {
initialRating: 0,
canComment: false,
title: '请为本次服务评分'
})
const emit = defineEmits<{
submit: [rating: number]
}>()
const currentRating = ref(props.initialRating)
const submitting = ref(false)
const isSubmitted = ref(props.initialRating > 0)
// 监听 initialRating 变化
watch(() => props.initialRating, (newVal) => {
currentRating.value = newVal
isSubmitted.value = newVal > 0
})
// 星级描述映射
const ratingDescriptions = {
1: '非常不满意',
2: '不满意',
3: '一般',
4: '满意',
5: '非常满意'
}
const getRatingDesc = (rating: number): string => {
return ratingDescriptions[rating as keyof typeof ratingDescriptions] || ''
}
const handleStarClick = (star: number) => {
if (!props.canComment || isSubmitted.value) return
currentRating.value = star
}
const handleSubmit = () => {
if (currentRating.value === 0 || !props.canComment || isSubmitted.value) return
submitting.value = true
try {
emit('submit', currentRating.value)
isSubmitted.value = true
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@import './CommentMessageCard.scss';
</style>

View File

@@ -462,3 +462,25 @@
font-size: 36rpx; font-size: 36rpx;
color: #4b87ff; color: #4b87ff;
} }
// ==================== 系统消息样式 ====================
.system-row {
justify-content: center;
margin-bottom: 48rpx;
}
.system-message-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
max-width: 80%;
}
.system-message-text {
padding: 16rpx 32rpx;
background: rgba(148, 163, 184, 0.15);
border-radius: 32rpx;
font-size: 26rpx;
color: #64748b;
text-align: center;
}

View File

@@ -54,8 +54,31 @@
<text class="loading-more-text">没有更多消息了</text> <text class="loading-more-text">没有更多消息了</text>
</view> </view>
<view class="message-list"> <view class="message-list">
<view class="message-item" v-for="msg in messages" :key="msg.messageId" <view class="message-item" v-for="msg in messages" :key="msg.messageId">
:class="msg.senderType === 'guest' ? 'self' : 'other'"> <!-- 系统消息(居中显示) -->
<view class="message-row system-row" v-if="msg.senderType === 'system'">
<view class="system-message-container">
<!-- 评分消息卡片 -->
<template v-if="msg.messageType === 'comment'">
<CommentMessageCard
:room-id="roomId"
:can-comment="getCanComment()"
:initial-rating="commentLevel"
@submit="handleCommentSubmit"
/>
</template>
<!-- 其他系统消息 -->
<template v-else>
<view class="system-message-text">
<text>{{ msg.content }}</text>
</view>
</template>
<text class="message-time">{{ formatTime(msg.sendTime) }}</text>
</view>
</view>
<!-- 普通用户/客服消息 -->
<view v-else :class="msg.senderType === 'guest' ? 'self' : 'other'">
<!-- 对方消息(左侧) --> <!-- 对方消息(左侧) -->
<view class="message-row other-row" v-if="msg.senderType !== 'guest'"> <view class="message-row other-row" v-if="msg.senderType !== 'guest'">
<view> <view>
@@ -95,6 +118,7 @@
<text class="avatar-text">我</text> <text class="avatar-text">我</text>
</view> </view>
</view> </view>
</view>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
@@ -120,6 +144,7 @@
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue' import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import MeetingCard from '../../meeting/meetingCard/MeetingCard.uvue' import MeetingCard from '../../meeting/meetingCard/MeetingCard.uvue'
import CommentMessageCard from './CommentMessageCard/CommentMessageCard.uvue'
import type { ChatRoomMessageVO, CustomerVO, ChatMemberVO, TbChatRoomMessageDTO, VideoMeetingVO } from '@/types/workcase' import type { ChatRoomMessageVO, CustomerVO, ChatMemberVO, TbChatRoomMessageDTO, VideoMeetingVO } from '@/types/workcase'
import { workcaseChatAPI } from '@/api/workcase' import { workcaseChatAPI } from '@/api/workcase'
import { wsClient } from '@/utils/websocket' import { wsClient } from '@/utils/websocket'
@@ -130,6 +155,8 @@ const headerTotalHeight = ref<number>(88)
const roomId = ref<string>('') const roomId = ref<string>('')
const workcaseId = ref<string>('') const workcaseId = ref<string>('')
const roomName = ref<string>('聊天室') const roomName = ref<string>('聊天室')
const guestId = ref<string>('') // 聊天室访客ID
const commentLevel = ref<number>(0) // 已有评分
const inputText = ref<string>('') const inputText = ref<string>('')
const scrollTop = ref<number>(0) const scrollTop = ref<number>(0)
const loading = ref<boolean>(false) const loading = ref<boolean>(false)
@@ -211,6 +238,9 @@ const totalMembers = computed<MemberDisplay[]>(() => {
return Array.from(memberMap.values()) return Array.from(memberMap.values())
}) })
function getCanComment(): boolean {
return currentUserId.value === guestId.value
}
// 生命周期 // 生命周期
onMounted(() => { onMounted(() => {
const windowInfo = uni.getWindowInfo() const windowInfo = uni.getWindowInfo()
@@ -280,6 +310,8 @@ async function refreshChatRoomInfo() {
if (roomRes.success && roomRes.data) { if (roomRes.success && roomRes.data) {
roomName.value = roomRes.data.roomName || '聊天室' roomName.value = roomRes.data.roomName || '聊天室'
workcaseId.value = roomRes.data.workcaseId || '' workcaseId.value = roomRes.data.workcaseId || ''
guestId.value = roomRes.data.guestId || ''
commentLevel.value = roomRes.data.commentLevel || 0
messageTotal.value = roomRes.data.messageCount || 0 messageTotal.value = roomRes.data.messageCount || 0
} }
} catch (e) { } catch (e) {
@@ -311,7 +343,9 @@ async function loadChatRoom() {
if (roomRes.success && roomRes.data) { if (roomRes.success && roomRes.data) {
roomName.value = roomRes.data.roomName || '聊天室' roomName.value = roomRes.data.roomName || '聊天室'
workcaseId.value = roomRes.data.workcaseId || '' workcaseId.value = roomRes.data.workcaseId || ''
guestId.value = roomRes.data.guestId || ''
messageTotal.value = roomRes.data.messageCount || 0 messageTotal.value = roomRes.data.messageCount || 0
commentLevel.value = roomRes.data.commentLevel!
} }
// 后端是降序查询page1是最新消息 // 后端是降序查询page1是最新消息
currentPage.value = 1 currentPage.value = 1
@@ -644,6 +678,31 @@ async function handleJoinMeeting(meetingId: string) {
} }
} }
// 处理评分提交
async function handleCommentSubmit(rating: number) {
console.log('[handleCommentSubmit] 提交评分:', rating)
try {
const result = await workcaseChatAPI.submitComment(roomId.value, rating)
if (result.success) {
uni.showToast({
title: '感谢您的评分!',
icon: 'success'
})
} else {
uni.showToast({
title: result.message || '评分提交失败',
icon: 'none'
})
}
} catch (error) {
console.error('[handleCommentSubmit] 评分提交失败:', error)
uni.showToast({
title: '评分提交失败,请稍后重试',
icon: 'none'
})
}
}
// 返回上一页 // 返回上一页
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()

View File

@@ -13,6 +13,7 @@ export interface TbChatRoomDTO extends BaseDTO {
roomType?: string roomType?: string
status?: string status?: string
guestId?: string guestId?: string
commentLevel?: number
guestName?: string guestName?: string
aiSessionId?: string aiSessionId?: string
currentAgentId?: string currentAgentId?: string
@@ -163,6 +164,7 @@ export interface ChatRoomVO extends BaseVO {
roomType?: string roomType?: string
status?: string status?: string
guestId?: string guestId?: string
commentLevel?: string
guestName?: string guestName?: string
aiSessionId?: string aiSessionId?: string
currentAgentId?: string currentAgentId?: string