打分评价

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

@@ -272,5 +272,19 @@ export const workcaseChatAPI = {
async endVideoMeeting(meetingId: string): Promise<ResultDomain<VideoMeetingVO>> {
const response = await api.post<VideoMeetingVO>(`${this.baseUrl}/meeting/${meetingId}/end`)
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
messageCount?: number
unreadCount?: number
commentLevel?: number
lastMessageTime?: string
lastMessage?: string
closedBy?: string
@@ -169,6 +170,7 @@ export interface ChatRoomVO extends BaseVO {
agentCount?: number
messageCount?: number
unreadCount?: number
commentLevel?: number
lastMessageTime?: string
lastMessage?: string
closedBy?: string

View File

@@ -94,11 +94,14 @@
:room-name="currentRoom?.roomName"
:file-download-url="FILE_DOWNLOAD_URL"
:has-more="hasMore"
:guest-id="currentRoom?.guestId"
:comment-level="currentRoom?.commentLevel"
:loading-more="loadingMore"
@send-message="handleSendMessage"
@start-meeting="startMeeting"
@start-meeting="startMeeting()"
@download-file="downloadFile"
@load-more="loadMoreMessages"
@submit-comment="handleSubmitComment"
>
<template #header>
<div class="chat-room-header">
@@ -171,6 +174,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElInput, ElDialog, ElMessage } from 'element-plus'
import { Search, FileText, MessageSquare, MessageCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import ChatRoom from './chatRoom/ChatRoom.vue'
@@ -185,12 +189,12 @@ import { Client } from '@stomp/stompjs'
// WebSocket配置 (通过Nginx代理访问网关再到workcase服务)
// SockJS URL (http://)
const getWsUrl = () => {
const token = JSON.parse(localStorage.getItem('token')).value || ''
const token = JSON.parse(localStorage.getItem('token') || '').value || ''
const protocol = window.location.protocol
const host = window.location.host
return `${protocol}//${host}/api/urban-lifeline/workcase/ws/chat-sockjs?token=${encodeURIComponent(token)}`
}
const router = useRouter()
// STOMP客户端
let stompClient: 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) => {
window.open(`${FILE_DOWNLOAD_URL}/${fileId}`, '_blank')
@@ -525,11 +546,11 @@ const startMeeting = async () => {
const activeResult = await workcaseChatAPI.getActiveMeeting(currentRoomId.value)
if (activeResult.success && activeResult.data) {
// 已有活跃会议,直接加入
currentMeetingId.value = activeResult.data.meetingId!
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
const currentMeetingId = activeResult.data.meetingId!
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId)
if (joinResult.success && joinResult.data?.iframeUrl) {
// 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId.value}`
const meetingUrl = joinResult.data.iframeUrl + `&roomId=${currentRoomId}`
router.push(meetingUrl)
} else {
ElMessage.error(joinResult.message || '加入会议失败')
@@ -544,13 +565,13 @@ const startMeeting = async () => {
})
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
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId.value!)
const joinResult = await workcaseChatAPI.joinVideoMeeting(currentMeetingId)
if (joinResult.success && joinResult.data?.iframeUrl) {
// 使用router跳转到JitsiMeetingView页面附加roomId参数用于返回
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);
}
}
// 系统消息样式(居中显示)
&.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 {

View File

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