打分评价
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// 系统消息样式(居中显示)
|
||||
&.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 {
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user