Files
xiaozhi-esp32-server/main/manager-mobile/src/pages/chat-history/detail.vue
T

333 lines
9.1 KiB
Vue

<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "聊天详情"
}
}
</route>
<script lang="ts" setup>
import type { ChatMessage, UserMessageContent } from '@/api/chat-history/types'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { getAudioId, getChatHistory } from '@/api/chat-history/chat-history'
import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
import { t } from '@/i18n'
defineOptions({
name: 'ChatDetail',
})
// 获取屏幕边界到安全区域距离
let safeAreaInsets: any
let systemInfo: any
// #ifdef MP-WEIXIN
systemInfo = uni.getWindowInfo()
safeAreaInsets = systemInfo.safeArea
? {
top: systemInfo.safeArea.top,
right: systemInfo.windowWidth - systemInfo.safeArea.right,
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
left: systemInfo.safeArea.left,
}
: null
// #endif
// #ifndef MP-WEIXIN
systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets
// #endif
// 页面参数
const sessionId = ref('')
const agentId = ref('')
// 智能体信息(简化)
const currentAgent = computed(() => {
return {
id: agentId.value,
agentName: t('chatHistory.assistantName'),
}
})
// 聊天数据
const messageList = ref<ChatMessage[]>([])
const loading = ref(false)
// 音频播放相关
const audioContext = ref<UniApp.InnerAudioContext | null>(null)
const playingAudioId = ref<string | null>(null)
// 返回上一页
function goBack() {
uni.navigateBack()
}
// 加载聊天记录
async function loadChatHistory() {
if (!sessionId.value || !agentId.value) {
console.error('缺少必要参数')
return
}
try {
loading.value = true
const response = await getChatHistory(agentId.value, sessionId.value)
messageList.value = response
}
catch (error) {
console.error('获取聊天记录失败:', error)
toast.error(t('chatHistory.loadFailed'))
}
finally {
loading.value = false
}
}
// 解析用户消息内容
function parseUserMessage(content: string): UserMessageContent | null {
try {
return JSON.parse(content)
}
catch {
return null
}
}
// 获取消息显示内容
function getMessageContent(message: ChatMessage): string {
if (message.chatType === 1) {
// 用户消息,需要解析JSON
const parsed = parseUserMessage(message.content)
return parsed ? parsed.content : message.content
}
else {
// AI消息,直接显示
return message.content
}
}
// 获取说话人名称
function getSpeakerName(message: ChatMessage): string {
if (message.chatType === 1) {
const parsed = parseUserMessage(message.content)
return parsed ? parsed.speaker : t('chatHistory.userName')
}
else {
return currentAgent.value?.agentName || t('chatHistory.aiAssistantName')
}
}
// 格式化时间
function formatTime(timeStr: string) {
const date = new Date(timeStr)
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
// 播放音频
async function playAudio(audioId: string) {
if (!audioId) {
toast.error(t('chatHistory.invalidAudioId'))
return
}
try {
// 如果正在播放其他音频,先停止
if (audioContext.value) {
audioContext.value.stop()
audioContext.value.destroy()
audioContext.value = null
}
// 获取音频下载ID
const downloadId = await getAudioId(audioId)
// 构造音频播放地址
const baseUrl = getEnvBaseUrl()
const audioUrl = `${baseUrl}/agent/play/${downloadId}`
// 创建音频上下文
audioContext.value = uni.createInnerAudioContext()
audioContext.value.src = audioUrl
// 设置播放状态
playingAudioId.value = audioId
// 监听播放完成
audioContext.value.onEnded(() => {
playingAudioId.value = null
if (audioContext.value) {
audioContext.value.destroy()
audioContext.value = null
}
})
// 监听播放错误
audioContext.value.onError((error) => {
console.error('音频播放失败:', error)
toast.error(t('chatHistory.audioPlayFailed'))
playingAudioId.value = null
if (audioContext.value) {
audioContext.value.destroy()
audioContext.value = null
}
})
// 开始播放
audioContext.value.play()
}
catch (error) {
console.error('播放音频失败:', error)
toast.error(t('chatHistory.playAudioFailed'))
playingAudioId.value = null
}
}
onLoad((options) => {
if (options?.sessionId && options?.agentId) {
sessionId.value = options.sessionId
agentId.value = options.agentId
loadChatHistory()
}
else {
console.error('缺少必要参数')
toast.error(t('chatHistory.parameterError'))
}
})
// 页面销毁时清理音频资源
onUnload(() => {
if (audioContext.value) {
audioContext.value.stop()
audioContext.value.destroy()
audioContext.value = null
}
})
</script>
<template>
<view class="h-screen flex flex-col bg-[#f5f7fb]">
<!-- 状态栏背景 -->
<view class="w-full bg-white" :style="{ height: `${safeAreaInsets?.top}px` }" />
<!-- 导航栏 -->
<wd-navbar :title="t('chatHistory.pageTitle')">
<template #left>
<wd-icon name="arrow-left" size="18" @click="goBack" />
</template>
</wd-navbar>
<!-- 聊天消息列表 -->
<scroll-view
scroll-y
:style="{ height: `calc(100vh - ${safeAreaInsets?.top || 0}px - 120rpx)` }"
class="box-border flex-1 bg-[#f5f7fb] p-[20rpx]"
:scroll-into-view="`message-${messageList.length - 1}`"
>
<view v-if="loading" class="flex flex-col items-center justify-center gap-[20rpx] p-[100rpx_0]">
<wd-loading />
<text class="text-[28rpx] text-[#65686f]">
{{ t('chatHistory.loading') }}
</text>
</view>
<view v-else class="flex flex-col gap-[20rpx]">
<view
v-for="(message, index) in messageList"
:id="`message-${index}`"
:key="index"
class="w-full flex"
:class="{
'justify-end': message.chatType === 1,
'justify-start': message.chatType === 2,
}"
>
<view
class="max-w-[80%] flex flex-col gap-[8rpx]"
:class="{
'items-end': message.chatType === 1,
'items-start': message.chatType === 2,
}"
>
<!-- 消息气泡 -->
<view
class="shadow-message break-words rounded-[20rpx] p-[24rpx] leading-[1.4]"
:class="{
'bg-[#336cff] text-white': message.chatType === 1,
'bg-white text-[#232338] border border-[#eeeeee]': message.chatType === 2,
}"
>
<!-- 内容区域 - 使用flex布局让图标和文本对齐 -->
<view class="flex items-center gap-[12rpx]">
<!-- 音频播放图标 -->
<view
v-if="message.audioId"
class="flex-shrink-0 cursor-pointer transition-transform duration-200 active:scale-90"
:class="{
'text-white animate-pulse-audio': message.chatType === 1 && playingAudioId === message.audioId,
'text-[#ffd700]': message.chatType === 1 && playingAudioId === message.audioId && playingAudioId,
'text-[#336cff] animate-pulse-audio': message.chatType === 2 && playingAudioId === message.audioId,
'text-[#ff6b35]': message.chatType === 2 && playingAudioId === message.audioId && playingAudioId,
'text-white': message.chatType === 1 && playingAudioId !== message.audioId,
'text-[#336cff]': message.chatType === 2 && playingAudioId !== message.audioId,
}"
@click="playAudio(message.audioId)"
>
<wd-icon
:name="playingAudioId === message.audioId ? 'pause-circle-filled' : 'play-circle-filled'"
size="20"
/>
</view>
<!-- 消息内容容器 -->
<view class="min-w-0 flex-1">
<!-- 消息内容 -->
<text class="block text-[28rpx]">
{{ getMessageContent(message) }}
</text>
</view>
</view>
</view>
<!-- 说话人信息 -->
<text
class="mx-[12rpx] text-[22rpx] text-[#9d9ea3]"
:class="{
'text-right': message.chatType === 1,
'text-left': message.chatType === 2,
}"
>
{{ formatTime(message.createdAt) }}
</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<style>
/* 自定义阴影和动画效果,无法用UnoCSS表示的样式 */
.shadow-message {
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
@keyframes pulse-audio {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.animate-pulse-audio {
animation: pulse-audio 1.5s infinite;
}
</style>