打分评价
This commit is contained in:
@@ -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, -- 创建人(系统自动创建)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<template>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
|
|
||||||
</script>
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
@import url('./CommentMessageCard.scss');
|
|
||||||
</style>
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user