Files
frontend-app/uni_modules/hashmall-customer-service/components/kefu-message-item/kefu-message-item.vue
T
2026-08-25 17:52:05 +08:00

621 lines
16 KiB
Vue

<template>
<view v-if="isRecalled" class="message-row message-row--recalled">
<text class="recall-notice">{{ isSelf ? '你撤回了一条消息' : '客服撤回了一条消息' }}</text>
</view>
<view v-else class="message-row" :class="{ 'message-row--self': isSelf }">
<view v-if="showTime || isSelf" class="message-meta" :class="{ 'message-meta--self': isSelf }">
<text>{{ displayName }}</text>
<text v-if="showTime">{{ formattedTime }}</text>
</view>
<view class="message-line" :class="{ 'message-line--self': isSelf }">
<image v-if="!isSelf" class="avatar" :src="avatar" mode="aspectFill" />
<view class="message-column">
<view
class="message-content"
:class="[isSelf ? 'message-content--self' : 'message-content--service', contentClass]"
@longpress.stop="openActions"
@contextmenu.prevent.stop="openActions"
>
<text v-if="normalizedType === contentType.TEXT" class="message-text" selectable>{{ message.content }}</text>
<image
v-else-if="normalizedType === contentType.IMAGE"
class="message-image"
:src="message.content"
mode="aspectFill"
@click="previewImage"
@load="$emit('layout-change', message)"
@error="$emit('layout-change', message)"
/>
<view v-else-if="normalizedType === contentType.AUDIO" class="audio-block">
<button class="audio-message" @click.stop="toggleAudio">
<view class="audio-message__left">
<u-icon :name="audioPlaying ? 'pause-circle' : 'play-circle'" size="26" color="#7934f6" />
<text class="audio-message__title">{{ audioPlaying ? '正在播放' : '语音消息' }}</text>
</view>
<text class="audio-duration">{{ attachment.duration ? `${attachment.duration}″` : '' }}</text>
</button>
<text v-if="attachment.transcript" class="audio-transcript" selectable>{{ attachment.transcript }}</text>
<button v-else class="transcribe-button" :disabled="transcribing" @click.stop="$emit('transcribe', message)">
<text>{{ transcribing ? '识别中…' : '转文字' }}</text>
</button>
</view>
<button v-else-if="normalizedType === contentType.FILE" class="file-message" @click.stop="openFile">
<u-icon name="file-text" size="28" color="#7934f6" />
<view class="file-message__body">
<text class="file-message__name">{{ attachment.name || '聊天文件' }}</text>
<text class="file-message__meta">{{ formatFileSize(attachment.size) }}</text>
</view>
</button>
<view v-else-if="card" class="message-card" @click="openCard">
<image class="card-image" :src="card.image || fallbackImage" mode="aspectFill" />
<view class="card-body">
<text class="card-kind">{{ normalizedType === contentType.PRODUCT ? '咨询商品' : '咨询订单' }}</text>
<text class="card-title">{{ card.title }}</text>
<text class="card-meta">{{ card.meta }}</text>
</view>
</view>
<view v-else-if="quote" class="quote-message">
<view class="quote-source">
<text class="quote-source__name">{{ quote.senderName }}</text>
<text class="quote-source__content">{{ quote.preview }}</text>
</view>
<text class="message-text quote-message__text" selectable>{{ quote.text }}</text>
</view>
<text v-else class="message-text message-text--muted">暂不支持的消息类型</text>
</view>
<view v-if="isSelf" class="send-state">
<text v-if="message.sendStatus === 'sending'" class="send-state__loading">发送中</text>
<button v-else-if="message.sendStatus === 'failed'" class="retry-button" @click.stop="$emit('retry', message)">重新发送</button>
<text v-else>已发送</text>
</view>
</view>
<image v-if="isSelf" class="avatar" :src="avatar" mode="aspectFill" />
</view>
</view>
</template>
<script>
import { CONTENT_TYPE } from '../../js_sdk/api.js'
export default {
name: 'CustomerServiceMessageItem',
props: {
message: { type: Object, required: true },
showTime: { type: Boolean, default: false },
serviceAvatar: { type: String, default: '' },
memberAvatar: { type: String, default: '' },
memberName: { type: String, default: '' },
transcribing: { type: Boolean, default: false }
},
emits: ['retry', 'open-card', 'actions', 'transcribe', 'layout-change'],
data() {
return {
contentType: CONTENT_TYPE,
fallbackImage: '/uni_modules/hashmall-customer-service/static/image-placeholder.png',
audioPlaying: false,
audioContext: null
}
},
computed: {
isSelf() {
return Number(this.message.senderType) === 2
},
normalizedType() {
return Number(this.message.contentType)
},
isRecalled() {
return this.normalizedType === CONTENT_TYPE.RECALLED
},
avatar() {
if (this.isSelf) {
return this.memberAvatar || this.message.senderAvatar || 'https://static.tbmall.xin/static/avater_not.png'
}
return this.serviceAvatar || '/uni_modules/hashmall-customer-service/static/tb-service-avatar.png'
},
displayName() {
if (this.isSelf) return this.memberName || this.message.senderName || '我'
return this.message.senderName || '系统'
},
formattedTime() {
const raw = this.message.ctdate || this.message.createdAt || Date.now()
const value = Number(raw) < 100000000000 ? Number(raw) * 1000 : Number(raw)
const date = new Date(value)
const pad = number => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
},
card() {
if (![CONTENT_TYPE.PRODUCT, CONTENT_TYPE.ORDER].includes(Number(this.message.contentType))) return null
try {
const data = typeof this.message.content === 'string' ? JSON.parse(this.message.content) : this.message.content
if (Number(this.message.contentType) === CONTENT_TYPE.PRODUCT) {
return {
data,
image: data.mainGraph || data.goodsMainGraph || data.image,
title: data.name || data.goodsName || '商品信息',
meta: data.price || data.currentPrice ? `¥${data.price || data.currentPrice}` : '查看商品详情'
}
}
return {
data,
image: data.mainGraph || data.goodsMainGraph || data.image,
title: data.goodsName || data.name || '订单信息',
meta: data.orderNo || data.no || '查看订单详情'
}
} catch (error) {
return null
}
},
attachment() {
if (![CONTENT_TYPE.FILE, CONTENT_TYPE.AUDIO].includes(this.normalizedType)) return {}
try {
const value = typeof this.message.content === 'string' ? JSON.parse(this.message.content) : this.message.content
return value && typeof value === 'object' ? value : { url: String(value || '') }
} catch (error) {
return { url: String(this.message.content || '') }
}
},
quote() {
if (this.normalizedType !== CONTENT_TYPE.QUOTE) return null
try {
const data = typeof this.message.content === 'string' ? JSON.parse(this.message.content) : this.message.content
const source = data.quote || {}
return {
text: String(data.text || ''),
senderName: source.senderName || '聊天消息',
preview: this.messagePreview(source)
}
} catch (error) {
return null
}
},
contentClass() {
if (this.normalizedType === CONTENT_TYPE.IMAGE) return 'message-content--image'
if ([CONTENT_TYPE.PRODUCT, CONTENT_TYPE.ORDER].includes(this.normalizedType)) return 'message-content--card'
if (this.normalizedType === CONTENT_TYPE.QUOTE) return 'message-content--quote'
return ''
}
},
beforeDestroy() {
this.destroyAudio()
},
beforeUnmount() {
this.destroyAudio()
},
methods: {
destroyAudio() {
if (this.audioContext) this.audioContext.destroy()
this.audioContext = null
this.audioPlaying = false
},
toggleAudio() {
if (!this.attachment.url) return
if (!this.audioContext) {
this.audioContext = uni.createInnerAudioContext()
this.audioContext.src = this.attachment.url
this.audioContext.onPlay(() => { this.audioPlaying = true })
this.audioContext.onPause(() => { this.audioPlaying = false })
this.audioContext.onStop(() => { this.audioPlaying = false })
this.audioContext.onEnded(() => { this.audioPlaying = false })
this.audioContext.onError(() => {
this.audioPlaying = false
uni.showToast({ title: '语音播放失败', icon: 'none' })
})
}
if (this.audioPlaying) this.audioContext.pause()
else this.audioContext.play()
},
openFile() {
if (!this.attachment.url) return
// #ifdef H5
window.open(this.attachment.url, '_blank', 'noopener')
// #endif
// #ifndef H5
uni.showLoading({ title: '正在打开文件' })
uni.downloadFile({
url: this.attachment.url,
success: result => uni.openDocument({ filePath: result.tempFilePath, showMenu: true, fail: () => uni.showToast({ title: '暂不支持打开该文件', icon: 'none' }) }),
fail: () => uni.showToast({ title: '文件下载失败', icon: 'none' }),
complete: () => uni.hideLoading()
})
// #endif
},
formatFileSize(size) {
const value = Number(size || 0)
if (!value) return '点击查看文件'
if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`
return `${(value / 1024 / 1024).toFixed(1)} MB`
},
previewImage() {
uni.previewImage({ urls: [this.message.content], current: this.message.content })
},
openCard() {
if (this.card) this.$emit('open-card', { type: this.message.contentType, data: this.card.data })
},
openActions() {
this.$emit('actions', this.message)
},
messagePreview(source) {
const type = Number(source.contentType)
if (type === CONTENT_TYPE.IMAGE) return '[图片]'
if (type === CONTENT_TYPE.FILE) return '[文件]'
if (type === CONTENT_TYPE.AUDIO) return '[语音]'
if (type === CONTENT_TYPE.PRODUCT) return '[商品]'
if (type === CONTENT_TYPE.ORDER) return '[订单]'
if (type === CONTENT_TYPE.QUOTE) return '[引用消息]'
return String(source.content || '').slice(0, 80)
}
}
}
</script>
<style scoped lang="scss">
.message-row {
display: flex;
width: 100%;
box-sizing: border-box;
flex-direction: column;
padding: 16rpx 34rpx;
}
.message-row--self {
align-items: flex-end;
}
.message-row--recalled {
align-items: center;
padding-top: 8rpx;
padding-bottom: 8rpx;
}
.recall-notice {
padding: 8rpx 18rpx;
border-radius: 18rpx;
background: rgba(255, 255, 255, 0.72);
color: #85868e;
font-size: 22rpx;
line-height: 1.4;
}
.avatar {
width: 84rpx;
height: 84rpx;
flex: 0 0 84rpx;
border-radius: 18rpx;
background: #f2ebff;
}
.message-column {
display: flex;
max-width: 72%;
flex-direction: column;
}
.message-row--self .message-column {
align-items: flex-end;
}
.message-meta {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
color: #9d9ea5;
font-size: 25rpx;
line-height: 1.35;
}
.message-meta--self {
justify-content: flex-end;
}
.message-line {
display: flex;
width: 100%;
min-width: 0;
align-items: flex-start;
gap: 18rpx;
}
.message-line--self {
justify-content: flex-end;
}
.message-content {
overflow: hidden;
border-radius: 25rpx;
}
.message-content--service {
border: 0;
border-top-left-radius: 4rpx;
background: #fff;
color: #1a1a1d;
}
.message-content--self {
border-top-right-radius: 4rpx;
background: #f2ebff;
color: #17181d;
}
.message-text {
display: block;
padding: 22rpx 30rpx;
font-size: 29rpx;
line-height: 1.55;
overflow-wrap: anywhere;
word-break: break-word;
}
.message-text--muted {
color: #5f6068;
}
.message-content--image,
.message-content--card {
border: 0;
background: transparent;
}
.message-content--quote {
min-width: 260rpx;
}
.quote-message {
display: flex;
flex-direction: column;
}
.quote-source {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4rpx;
margin: 14rpx 18rpx 0;
padding: 12rpx 14rpx;
border-radius: 10rpx;
background: rgba(76, 55, 115, 0.08);
color: #686970;
}
.quote-source__name {
font-size: 21rpx;
font-weight: 600;
}
.quote-source__content {
overflow: hidden;
font-size: 22rpx;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.quote-message__text {
padding-top: 14rpx;
}
.message-image {
display: block;
width: 224rpx;
min-height: 224rpx;
max-height: 420rpx;
border: 18rpx solid #f2ebff;
border-radius: 24rpx;
background: #f2ebff;
}
.message-card {
display: flex;
width: 500rpx;
gap: 18rpx;
padding: 18rpx;
border: 1rpx solid #e7e7ec;
border-radius: 20rpx;
background: #fff;
color: #1a1a1d;
}
.card-image {
width: 120rpx;
height: 120rpx;
flex: 0 0 120rpx;
border-radius: 14rpx;
background: #f5f5f7;
}
.card-body {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
justify-content: center;
}
.card-kind {
color: #7934f6;
font-size: 22rpx;
font-weight: 600;
}
.card-title {
display: -webkit-box;
overflow: hidden;
margin-top: 6rpx;
color: #1a1a1d;
font-size: 27rpx;
font-weight: 600;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.card-meta {
overflow: hidden;
margin-top: 6rpx;
color: #5f6068;
font-size: 23rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.send-state {
min-height: 40rpx;
padding-top: 8rpx;
color: #9d9ea5;
font-size: 23rpx;
text-align: right;
white-space: nowrap;
}
.send-state__loading {
color: #777983;
font-size: 21rpx;
}
.retry-button {
min-height: 44rpx;
padding: 0 8rpx;
background: transparent;
color: #c93636;
font-size: 21rpx;
line-height: 44rpx;
}
.copy-button {
width: 50rpx;
height: 64rpx;
align-self: center;
margin: 0;
padding: 0;
background: transparent;
line-height: 64rpx;
}
.copy-button::after,
.retry-button::after {
border: 0;
}
.audio-message,
.file-message {
display: flex;
min-width: 220rpx;
align-items: center;
gap: 14rpx;
margin: 0;
padding: 4rpx 0;
background: transparent;
color: #27272c;
text-align: left;
}
.audio-message::after,
.file-message::after {
border: 0;
}
.audio-block {
display: flex;
flex-direction: column;
min-width: 260rpx;
padding: 20rpx 26rpx;
box-sizing: border-box;
}
.audio-message {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin: 0;
padding: 0;
background: transparent;
color: #1f1f23;
text-align: left;
line-height: 1;
&::after {
border: 0;
}
}
.audio-message__left {
display: flex;
align-items: center;
gap: 10rpx;
}
.audio-message__title {
font-size: 27rpx;
font-weight: 500;
color: #1f1f23;
}
.audio-duration {
margin-left: auto;
color: #7934f6;
font-size: 25rpx;
font-weight: 600;
}
.audio-transcript {
margin-top: 14rpx;
padding-top: 12rpx;
border-top: 1rpx solid rgba(121, 52, 246, 0.15);
color: #27272c;
font-size: 26rpx;
line-height: 1.55;
white-space: normal;
}
.transcribe-button {
display: inline-flex;
align-items: center;
align-self: flex-start;
margin-top: 12rpx;
padding: 6rpx 16rpx;
border-radius: 12rpx;
background: rgba(121, 52, 246, 0.08);
color: #7934f6;
font-size: 22rpx;
font-weight: 500;
line-height: 1.4;
&::after {
border: 0;
}
&[disabled] {
color: #8e8f96;
background: rgba(0, 0, 0, 0.04);
}
}
.file-message {
min-width: 360rpx;
}
.file-message__body {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.file-message__name {
overflow: hidden;
color: #27272c;
font-size: 26rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-message__meta {
margin-top: 4rpx;
color: #777983;
font-size: 21rpx;
}
</style>