weipengfei 8f979f5478 更新
2024-01-17 18:12:00 +08:00

405 lines
10 KiB
Vue

<script setup lang='ts'>
import { computed, onUpdated, reactive, ref, watch } from 'vue'
import { NDropdown, useMessage } from 'naive-ui'
import axios from 'axios'
import AvatarComponent from './Avatar.vue'
import TextComponent from './Text.vue'
import { SvgIcon } from '@/components/common'
import { useIconRender } from '@/hooks/useIconRender'
import { t } from '@/locales'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { copyToClip } from '@/utils/copy'
interface Props {
dateTime?: string
text?: string
tts?: string
autoplay?: boolean
inversion?: boolean
error?: boolean
loading?: boolean
}
interface Emit {
(ev: 'regenerate'): void
(ev: 'delete'): void
(ev: 'changeNowStatus'): string
}
const props = defineProps<Props>()
const emit = defineEmits<Emit>()
const { isMobile } = useBasicLayout()
const { iconRender } = useIconRender()
const message = useMessage()
const textRef = ref<HTMLElement>()
const asRawText = ref(props.inversion)
const messageRef = ref<HTMLElement>()
const options = computed(() => {
const common = [
{
label: t('chat.copy'),
key: 'copyText',
icon: iconRender({ icon: 'ri:file-copy-2-line' }),
},
{
label: t('common.delete'),
key: 'delete',
icon: iconRender({ icon: 'ri:delete-bin-line' }),
},
]
if (!props.inversion) {
common.unshift({
label: asRawText.value ? t('chat.preview') : t('chat.showRawText'),
key: 'toggleRenderType',
icon: iconRender({ icon: asRawText.value ? 'ic:outline-code-off' : 'ic:outline-code' }),
})
}
return common
})
function handleSelect(key: 'copyText' | 'delete' | 'toggleRenderType') {
switch (key) {
case 'copyText':
handleCopy()
return
case 'toggleRenderType':
asRawText.value = !asRawText.value
return
case 'delete':
emit('delete')
}
}
function handleRegenerate() {
messageRef.value?.scrollIntoView()
emit('regenerate')
}
async function handleCopy() {
try {
await copyToClip(props.text || '')
message.success('复制成功')
}
catch {
message.error('复制失败')
}
}
const http = (text: any) => {
return new Promise((resolve, reject) => {
axios.post('https://chat.lihaink.cn/index/tts', { data: text })
.then((response) => {
// 请求成功处理逻辑
// console.log('请求成功',response.data);
resolve(response.data)
})
.catch((error) => {
// 请求失败处理逻辑
console.error('请求失败', error)
reject(error)
})
})
}
// 创建音频对象的数组
const audioElements = reactive([])
let ttslength = 0
let ttsLoadFlag = 0
let lengthFlag = 0
let tts = props.tts ? JSON.parse(props.tts) : []
let ttsplay = true
async function loadTTS() {
lengthFlag = tts.length
if (tts.length > ttslength && audioElements.length == ttslength && ttsLoadFlag == 0) {
for (let i = ttslength; i < lengthFlag; i++) {
console.log(`加载了${i + 1}`, tts[i])
ttsLoadFlag = 1
if (tts[i] == '')
break
try {
const res = await http(tts[i])
const a = new Audio(res.data.mp3)
a.addEventListener('ended', () => {
onAudioEnd()
})
audioElements.push(a)
if (ttsplay) {
ttsplay = false
playAudio()
}
if (i == tts.length - 1) {
ttslength = tts.length
ttsLoadFlag = 0
}
}
catch (error) {
console.log(error)
}
}
}
}
// 监听到文本数据改变
const watchTTS = watch(() => props.tts, async (n: any, o: any) => {
console.log('文本变化', n);
if (props.tts == '') {
for (let i = 0; i < audioElements.length; i++) {
audioElements[i].pause()
if (i + 1 == audioElements.length)
audioElements.length = 0
}
}
tts = props.tts ? JSON.parse(props.tts) : []
loadTTS()
}, { deep: true })
// 停止播放音频
const stopPlay = () => {
console.log('停止播放音频')
pauseIndex = -1
playStatus.value = 'pause'
emit('changeNowStatus', 'input')
for (let i = 0; i < audioElements.length; i++)
audioElements[i].pause()
}
onUpdated(() => {
if (!props.autoplay) {
stopPlay()
watchTTS()// 取消监听
}
})
// console.log('组件渲染 ', props.text, props.tts)
const playStatus = ref('end')
let playing = 0
let sok = false
async function radioPlay() {
// return console.log('播放', props.tts[0])
audioElements.length = 0
playing = 0
emit('changeNowStatus', 'loding')
const socket = new WebSocket('wss://chat.lihaink.cn/zhanti/tts')
sok = true
const promise = () => {
return new Promise((resolve, reject) => {
// 监听WebSocket连接打开事件
socket.onopen = () => {
console.log('socket已连接')
playing = 0
resolve(null)
}
})
}
await promise()
// 监听WebSocket关闭事件
socket.onclose = (event: any) => {
sok = false
console.log('连接已关闭: ', event, sok)
console.log('sok状态', sok)
}
// socket.send(JSON.stringify({
// data: '快。科。技0月12。日消息。不宣而发的。华为Mate 60在上架官方商城后。直到现在都处于一机难求的状态。由于Mate 60系列的爆火。华为也是将明年的预计手机出货量翻倍到了7000万台。',
// }))
socket.send(JSON.stringify({
data: props.text,
}))
playStatus.value = 'playing'
let i = 0
// 监听WebSocket接收消息事件
socket.onmessage = (event: any) => {
const msg = JSON.parse(event.data)
console.log(i, msg.mp3)
if (msg.key == 'false')
return socket.close()
const a = new Audio(msg.mp3)
a.addEventListener('ended', () => {
onAudioEnd()
})
audioElements.push(a)
if (i == 0)
playAudio(i)
i++
}
// for (let i = 0; i < props.mp3.length; i++) {
// const a = new Audio(props.mp3[i])
// a.addEventListener('ended', () => {
// onAudioEnd(i)
// })
// audioElements.push(a)
// }
// playAudio()
}
let playFlag = false
const onAudioEnd = (index: any) => {
console.log('播放完 ', playing)
playing++
playFlag = false
if (playing < audioElements.length) {
playFlag = true
audioElements[playing].play()
emit('changeNowStatus', 'playing')
}
else {
playStatus.value = 'end'
emit('changeNowStatus', 'input')
}
}
// 播放音频
const playAudio = (playIndex: any) => {
// for (let i = 0; i < audioElements.length; i++)
try {
audioElements[0]!.play()
emit('changeNowStatus', 'playing')
}
catch (error) {
alert('请检查浏览器是否支持音频播放')
}
}
watch(() => audioElements.length, (newVal, oldVal) => {
if (playStatus.value == 'pause')
return
if (newVal > playing && playFlag == false && newVal > 0) {
audioElements[playing]!.play()
playStatus.value = 'playing'
}
})
let pauseIndex = -1
// 暂停音频
const pauseAudio = () => {
pauseIndex = playing + 0
playStatus.value = 'pause'
emit('changeNowStatus', 'input')
for (let i = 0; i < audioElements.length; i++)
audioElements[i].pause()
}
const noPauseAudio = () => {
if (pauseIndex === -1)
return null
playStatus.value = 'playing'
emit('changeNowStatus', 'playing')
audioElements[pauseIndex]?.play()
pauseIndex = -1
}
const reload = () => {
}
defineExpose({
stopPlay,
pauseAudio,
noPauseAudio,
radioPlay,
reload,
})
</script>
<template>
<div
ref="messageRef"
class="flex w-full mb-6 overflow-hidden"
:class="[{ 'flex-row-reverse': inversion }]"
>
<div
class="flex items-center justify-center flex-shrink-0 h-8 overflow-hidden rounded-full basis-8"
:class="[inversion ? 'ml-2' : 'mr-2']"
>
<AvatarComponent :image="inversion" />
</div>
<div class="overflow-hidden text-sm " :class="[inversion ? 'items-end' : 'items-start']">
<p class="text-xs text-[#b4bbc4]" :class="[inversion ? 'text-right' : 'text-left']">
{{ dateTime }}
</p>
<div
class=" items-end gap-1 mt-2"
:class="[inversion ? 'flex-row-reverse' : 'flex-row']"
>
<TextComponent
ref="textRef"
:inversion="inversion"
:error="error"
:text="text"
:loading="loading"
:as-raw-text="asRawText"
/>
<div class="flex-col btn">
<button
v-if="!inversion && sok == false && playStatus == 'end'"
class="mr-2 transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-300"
@click="radioPlay"
>
播放
<!-- <SvgIcon icon="ri:restart-line" /> -->
</button>
<button
v-if="!inversion && playStatus == 'playing'"
class="mr-2 transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-300"
@click="pauseAudio"
>
暂停
<!-- <SvgIcon icon="ri:restart-line" /> -->
</button>
<button
v-if="!inversion && playStatus == 'pause'"
class="mr-2 transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-300"
@click="noPauseAudio"
>
继续
<!-- <SvgIcon icon="ri:restart-line" /> -->
</button>
<!-- <button
v-if="!inversion"
class="mr-2 transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-300"
@click="handleRegenerate"
>
<SvgIcon icon="ri:restart-line" />
</button> -->
<NDropdown
v-if="!inversion"
:trigger="isMobile ? 'click' : 'hover'"
:placement="!inversion ? 'right' : 'left'"
:options="options"
@select="handleSelect"
>
<button class="transition text-neutral-300 hover:text-neutral-800 dark:hover:text-neutral-200">
<SvgIcon icon="ri:more-2-fill" />
</button>
</NDropdown>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.btn{
padding: 10px;
}
</style>