|
<script setup lang="ts"> |
|
import { nextTick, onMounted, ref } from 'vue' |
|
import { useRouter } from 'vue-router' |
|
import { ChatDotRound, Microphone, VideoPause } from '@element-plus/icons-vue' |
|
|
|
import VideoComponent from '@/components/VideoComponent.vue' |
|
import MessageComponent from '@/components/MessageComponent.vue' |
|
import { |
|
streamRoomOffline, |
|
genAsrResult, |
|
onAirRoomChatRequest, |
|
onAirRoomInfoRequest, |
|
onAirRoomNextProductRequest, |
|
sendAudioToServer, |
|
type StreamingRoomStatusItem |
|
} from '@/api/streamingRoom' |
|
import type { ProductItem, StreamerInfo } from '@/api/product' |
|
import { ElMessage, ElMessageBox } from 'element-plus' |
|
import { AxiosError } from 'axios' |
|
import { getUserInfoRequest, type UserInfo } from '@/api/user' |
|
|
|
const router = useRouter() |
|
|
|
|
|
const props = defineProps({ |
|
roomId: { |
|
type: String, |
|
default: '0' |
|
} |
|
}) |
|
|
|
|
|
const userInfoItem = ref({} as UserInfo) |
|
|
|
const getUserInfo = async () => { |
|
try { |
|
const { data } = await getUserInfoRequest() |
|
|
|
if (data.code === 0) { |
|
userInfoItem.value = data.data |
|
} else { |
|
ElMessage.error('获取用户信息失败: ' + data.message) |
|
} |
|
} catch (error: unknown) { |
|
if (error instanceof AxiosError) { |
|
ElMessage.error('获取用户信息失败: ' + error.message) |
|
} else { |
|
ElMessage.error('未知错误:' + error) |
|
} |
|
} |
|
} |
|
|
|
|
|
const inputValue = ref('') |
|
|
|
const currentStatus = ref({} as StreamingRoomStatusItem) |
|
currentStatus.value.currentProductInfo = {} as ProductItem |
|
currentStatus.value.streamerInfo = {} as StreamerInfo |
|
|
|
const getRoomInfo = async () => { |
|
// 获取主播视频地址 |
|
// 获取商品信息,显示在右下角的商品缩略图 |
|
// 获取后端对话记录 messageList ,进行渲染 |
|
try { |
|
const { data } = await onAirRoomInfoRequest(Number(props.roomId)) |
|
if (data.code === 0) { |
|
currentStatus.value = data.data |
|
console.info(currentStatus.value) |
|
} else { |
|
ElMessage.error('获取直播间信息失败' + data.message) |
|
} |
|
} catch (error: unknown) { |
|
if (error instanceof AxiosError) { |
|
ElMessage.error('获取直播间信息失败' + error.message) |
|
} else { |
|
ElMessage.error('未知错误:' + error) |
|
} |
|
} |
|
} |
|
|
|
|
|
const loadingStreamRes = ref(false) |
|
|
|
|
|
const disableInput = ref(false) |
|
|
|
|
|
const handelSendClick = async () => { |
|
console.info(inputValue.value) |
|
// 显示在对话框-用户 |
|
currentStatus.value.conversation.push({ |
|
role: 'user', |
|
userId: userInfoItem.value.user_id, |
|
userName: userInfoItem.value.username, |
|
avatar: userInfoItem.value.avatar, |
|
message: inputValue.value, |
|
send_time: '' |
|
}) |
|
|
|
disableInput.value = true |
|
|
|
loadingStreamRes.value = true |
|
|
|
|
|
|
|
try { |
|
const { data } = await onAirRoomChatRequest(Number(props.roomId), inputValue.value) |
|
|
|
|
|
loadingStreamRes.value = false |
|
if (data.code === 0) { |
|
// 更新 list |
|
await getRoomInfo() |
|
} else { |
|
ElMessage.error('更新对话信息失败' + data.message) |
|
} |
|
} catch (error: unknown) { |
|
if (error instanceof AxiosError) { |
|
ElMessage.error('更新对话信息失败' + error.message) |
|
} else { |
|
ElMessage.error('未知错误:' + error) |
|
} |
|
} |
|
|
|
|
|
disableInput.value = false |
|
|
|
|
|
nextTick(scrollToBottom) |
|
} |
|
|
|
|
|
const scrollbarRef = ref<HTMLElement | null>(null) |
|
const scrollToBottom = async () => { |
|
// 注意:需要通过 nextTick 以等待 DOM 更新完成 |
|
await nextTick() |
|
if (scrollbarRef.value) { |
|
// scrollbarRef.value.setScrollTop(10000) // TODO 先设置一个比较大的值,后续需要获取控件的高度进行赋值 |
|
scrollbarRef.value.scrollTop = 10000 // TODO 先设置一个比较大的值,后续需要获取控件的高度进行赋值 |
|
} |
|
} |
|
|
|
|
|
const handleNextProductClick = async () => { |
|
try { |
|
const { data } = await onAirRoomNextProductRequest(Number(props.roomId)) |
|
if (data.code === 0) { |
|
console.info('Next Product') |
|
await getRoomInfo() |
|
} else { |
|
ElMessage.error('失败' + data.message) |
|
} |
|
} catch (error: unknown) { |
|
if (error instanceof AxiosError) { |
|
ElMessage.error('失败' + error.message) |
|
} else { |
|
ElMessage.error('未知错误:' + error) |
|
} |
|
} |
|
} |
|
|
|
|
|
const handleOffLineClick = async () => { |
|
ElMessageBox.confirm(`确定要下播吗?`, '警告', { |
|
confirmButtonText: '确定', |
|
cancelButtonText: '取消', |
|
type: 'warning' |
|
}) |
|
.then(async () => { |
|
try { |
|
const { data } = await streamRoomOffline(Number(props.roomId)) |
|
if (data.code === 0) { |
|
ElMessage.success('下播成功') |
|
router.push({ name: 'StreamingOverview' }) |
|
} else { |
|
ElMessage.error('下播失败' + data.message) |
|
} |
|
} catch (error: unknown) { |
|
if (error instanceof AxiosError) { |
|
ElMessage.error('失败' + error.message) |
|
} else { |
|
ElMessage.error('未知错误:' + error) |
|
} |
|
} |
|
}) |
|
.catch((error) => { |
|
ElMessage.error('下播失败: ' + error.message) |
|
}) |
|
} |
|
|
|
onMounted(() => { |
|
// 获取用户信息 |
|
getUserInfo() |
|
|
|
// 获取直播间实时信息格信息 |
|
getRoomInfo() |
|
}) |
|
|
|
|
|
|
|
const isRecording = ref(false) |
|
let mediaRecorder: MediaRecorder | null = null |
|
let chunks: Blob[] = [] |
|
let stream: MediaStream | null = null |
|
|
|
|
|
const startRecording = () => { |
|
navigator.mediaDevices |
|
.getUserMedia({ audio: true }) |
|
.then((s) => { |
|
stream = s |
|
mediaRecorder = new MediaRecorder(s) |
|
mediaRecorder.start() |
|
mediaRecorder.addEventListener('dataavailable', handleDataAvailable) |
|
mediaRecorder.addEventListener('stop', handleStop) |
|
}) |
|
.catch((err) => { |
|
ElMessage.error('无法访问麦克风: ' + err.message) |
|
}) |
|
} |
|
|
|
|
|
const stopRecording = () => { |
|
if (mediaRecorder) { |
|
mediaRecorder.stop() |
|
} |
|
if (stream) { |
|
stream.getTracks().forEach((track) => track.stop()) |
|
} |
|
} |
|
|
|
|
|
const handleRecord = () => { |
|
if (isRecording.value) { |
|
stopRecording() |
|
} else { |
|
startRecording() |
|
} |
|
isRecording.value = !isRecording.value |
|
} |
|
|
|
|
|
const handleDataAvailable = (e: BlobEvent) => { |
|
chunks.push(e.data) |
|
} |
|
|
|
|
|
const handleStop = async () => { |
|
const blob = new Blob(chunks, { type: 'audio/webm' }) |
|
try { |
|
// 将 asr 文件发送到服务器 |
|
const { data } = await sendAudioToServer(blob) |
|
if (data.code === 0) { |
|
ElMessage.success('正在进行语音转文字,请稍候!') |
|
// 调取接口开始进行 asr 识别 |
|
|
|
console.info(data) |
|
const { data: asr_data } = await genAsrResult(Number(props.roomId), data.data) |
|
ElMessage.success('语音转文字成功!') |
|
|
|
|
|
if (asr_data.code === 0) { |
|
inputValue.value = asr_data.data |
|
handelSendClick() |
|
} |
|
} |
|
} catch (error: unknown) { |
|
if (error instanceof AxiosError) { |
|
ElMessage.error('语音转文字失败: ' + error.message) |
|
} else { |
|
ElMessage.error('未知错误:' + error) |
|
} |
|
} |
|
chunks = [] |
|
} |
|
</script> |
|
|
|
<template> |
|
<div> |
|
<el-row :gutter="1"> |
|
<el-col :span="14"> |
|
|
|
<VideoComponent |
|
:src="currentStatus.currentStreamerVideo" |
|
:key="currentStatus.currentStreamerVideo" |
|
:autoplay="true" |
|
:width="1300" |
|
:height="1300" |
|
:videoAfterEnd="currentStatus.streamerInfo.base_mp4_path" |
|
style="display: flex; justify-content: center; align-items: center" |
|
/> |
|
</el-col> |
|
<el-col :span="10"> |
|
<div> |
|
<el-scrollbar height="1110px" ref="scrollbarRef" id="scrollbarRef"> |
|
|
|
<MessageComponent |
|
v-for="(item, index) in currentStatus.conversation" |
|
:key="index" |
|
:role="item.role" |
|
:avatar="item.avatar" |
|
:userName="item.userName" |
|
:message="item.message" |
|
:datetime="item.send_time" |
|
/> |
|
|
|
|
|
|
|
</el-scrollbar> |
|
|
|
|
|
<div class="floating-card"> |
|
<el-card shadow="never"> |
|
<div class="product-info"> |
|
<p class="title">当前商品</p> |
|
|
|
|
|
<el-image |
|
style="width: 100px; height: 100px" |
|
:src="currentStatus.currentProductInfo.image_path" |
|
fit="contain" |
|
/> |
|
|
|
|
|
<p class="title">{{ currentStatus.currentProductInfo.product_name }}</p> |
|
<p class="content">{{ currentStatus.currentProductInfo.heighlights }}</p> |
|
<p class="price">¥ {{ currentStatus.currentProductInfo.selling_price }}</p> |
|
</div> |
|
</el-card> |
|
</div> |
|
|
|
|
|
<div class="bottom-items"> |
|
<el-button |
|
circle |
|
size="large" |
|
:type="isRecording ? 'danger' : 'primary'" |
|
@click="handleRecord" |
|
> |
|
<el-icon v-if="!isRecording"> |
|
<Microphone /> |
|
</el-icon> |
|
<el-icon v-else> |
|
<VideoPause /> |
|
</el-icon> |
|
</el-button> |
|
<el-input |
|
v-model="inputValue" |
|
style="width: 100%; border-radius: 8px; margin: 0px 10px 0px 10px" |
|
:autosize="{ minRows: 2, maxRows: 12 }" |
|
type="textarea" |
|
placeholder="向主播提问" |
|
:disabled="disableInput" |
|
size="large" |
|
/> |
|
<el-button circle @click="handelSendClick" size="large"> |
|
<el-icon> |
|
<ChatDotRound /> |
|
</el-icon> |
|
</el-button> |
|
</div> |
|
|
|
<div style="margin-top: 10px"> |
|
<div class="bottom-button"> |
|
<el-button |
|
type="primary" |
|
@click="handleNextProductClick" |
|
v-show="!currentStatus.finalProduct" |
|
> |
|
下一个商品 |
|
</el-button> |
|
<el-button type="danger" @click="handleOffLineClick">下播</el-button> |
|
</div> |
|
</div> |
|
</div> |
|
</el-col> |
|
</el-row> |
|
</div> |
|
</template> |
|
|
|
<style lang="scss" scoped> |
|
.bottom-items { |
|
margin-top: 10px; // 距离上面的控件有一定的距离 |
|
display: flex; |
|
align-items: center; |
|
width: auto; |
|
} |
|
|
|
.bottom-button { |
|
display: flex; |
|
margin-top: 10px; |
|
float: right; |
|
} |
|
|
|
::v-deep(.el-input__wrapper) { |
|
border-radius: 14px; |
|
} |
|
|
|
.el-card { |
|
margin-top: 10px; |
|
border-radius: 10px; |
|
} |
|
|
|
.floating-card { |
|
position: absolute; |
|
right: 10px; /* 调整到右边的距离 */ |
|
bottom: 200px; /* 调整到底部的距离 */ |
|
z-index: 30; /* 确保 card 显示在最上层 */ |
|
width: 200px; /* 可以根据需要调整 */ |
|
|
|
.product-info { |
|
display: flex; |
|
flex-direction: column; /* 将子元素垂直排列 */ |
|
justify-content: center; /* 垂直居中 */ |
|
align-items: center; /* 水平居中 */ |
|
|
|
.title { |
|
font-size: 18px; |
|
font-weight: 600; |
|
} |
|
|
|
.content { |
|
font-size: 15px; |
|
color: #b1b3b8; |
|
} |
|
|
|
.price { |
|
font-size: 16px; |
|
color: #fda100; |
|
} |
|
} |
|
} |
|
</style> |
|
|