websocket消息支持 (#188)

* Release (#162)

* feat:  websocket server demo

* feat:  ws server demo dev

* feat:  ws server and mobile page

* feat:  手机端发送消息

* feat:  手机网页发送消息

* 添加了抽奖中和抽奖完成时的音效

* feat:  自定义设置弹幕服务器地址

* feat:  ws not done

* fix: 🐛 fix pr-185 #185

为播放音效添加控制

* feat:  server worker demo not done

* feat:  websocket server

* feat:  全局接收websocket消息并存储到indexdb中

---------

Co-authored-by: Silence@2024 <707261624@qq.com>
This commit is contained in:
LOG1997
2026-01-09 17:11:43 +08:00
committed by GitHub
parent 52d2fcd0cb
commit 3eac4e1aac
40 changed files with 3489 additions and 279 deletions

View File

@@ -0,0 +1,32 @@
<script setup lang='ts'>
import { useI18n } from 'vue-i18n'
import PageHeader from '@/components/PageHeader/index.vue'
import MsgListContainer from './parts/MsgListContainer.vue'
import ServerSetting from './parts/ServerSetting.vue'
import { useViewModel } from './useViewModel'
const { t } = useI18n()
const { serverList, currentServerValue, wsStatus, handleConnectWs, closeWs, msgList } = useViewModel()
</script>
<template>
<div>
<PageHeader :title="t('sidebar.server')" />
<div>
<ServerSetting
v-model:current-server="currentServerValue"
:server-list="serverList"
:ws-status="wsStatus"
:open-ws="handleConnectWs"
:close-ws="closeWs"
/>
<MsgListContainer
:msg-list="msgList"
/>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,29 @@
<script setup lang='ts'>
import type { WsMsgData } from '@/types/storeType'
interface Props {
msgList: WsMsgData[]
}
defineProps<Props>()
</script>
<template>
<div class="w-1/2 h-1/2 border rounded-md shadow-lg">
<ul>
<li v-for="item in msgList" :key="item.id" class="mb-3">
<div class="chat chat-end">
<div class="chat-header">
<time class="text-xs opacity-50">{{ item.dateTime }}</time>
</div>
<div class="chat-bubble break-all whitespace-normal">
{{ item.data }}
</div>
</div>
</li>
</ul>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,107 @@
<script setup lang='ts'>
import type { ServerType } from '@/types/storeType'
import { ref, watch } from 'vue'
// import { useI18n } from 'vue-i18n'
interface Props {
serverList: ServerType[]
wsStatus: { status: WebSocket['readyState'], connected: boolean } | undefined
openWs: () => void
closeWs: () => void
}
defineProps<Props>()
const currentServer = defineModel<ServerType>('currentServer', { required: true })
const hostValue = ref('')
// const { t } = useI18n()
// 监听 currentServer 的 id 变化,重置 hostValue
watch(() => currentServer.value?.id, (newId, oldId) => {
if (newId !== oldId) {
hostValue.value = currentServer.value?.host || ''
}
}, { immediate: true })
// 监听 hostValue 变化,同步更新 currentServer.host
watch(hostValue, (newHost) => {
if (currentServer.value) {
currentServer.value.host = newHost
}
})
// 初始化 hostValue
hostValue.value = currentServer.value?.host || ''
</script>
<template>
<fieldset class="p-4 border text-setting fieldset bg-base-200 border-base-300 rounded-box w-xs pb-10">
<legend class="fieldset-legend">
弹幕服务
</legend>
<label class="flex flex-row items-center form-control">
<div class="">
<div class="label flex flex-col justify-start items-start">
<label class="label">
<span class="label-text text-left">弹幕服务地址</span>
<div class="tooltip" data-tip="改变弹幕服务地址后会断开连接">
<button class="btn btn-circle h-4 hover:bg-base-300">
?
</button>
</div>
</label>
<div class="radio-group flex gap-9">
<ul class="flex gap-3">
<li v-for="item in serverList" :key="item.id" class="flex flex-col">
<label for="default-server">{{ item.name }}</label>
<input id="default-server" type="radio" name="radio-1" class="radio" :checked="currentServer?.value === item.value" @change="currentServer = item">
</li>
</ul>
</div>
<input
type="text"
placeholder="请输入服务地址"
:disabled="currentServer.value === 'default'"
class="w-full max-w-xs input input-bordered"
:value="hostValue"
@input="hostValue = ($event.target as HTMLInputElement).value"
>
</div>
</div>
</label>
<label class="flex flex-row items-center form-control">
<div class="flex flex-col gap-3">
<div class="label">
<span class="label-text">弹幕服务器连接状态</span>
</div>
<div class="flex gap-2 items-center">
<div class="ws-status">
<div v-if="wsStatus && wsStatus.connected">
<div aria-label="success" class="status status-success" />
<span>已连接</span>
</div>
<div v-else-if="wsStatus && wsStatus.connected === false">
<div aria-label="error" class="status status-error" />
<span>已断开</span>
</div>
<div v-else-if="wsStatus && wsStatus.status">
<div aria-label="error" class="status status-error" />
<span>操作中</span>
</div>
<div v-else>
<div aria-label="warning" class="status status-warning" />
<span>未连接</span>
</div>
</div>
<div class="flex gap-3">
<button v-if="wsStatus?.connected === true" class="btn btn-error btn-sm" @click="closeWs">断开</button>
<button v-else class="btn btn-primary btn-sm" @click="openWs">连接</button>
</div>
</div>
</div>
</label>
</fieldset>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,55 @@
import type { ServerType, WsMsgData } from '@/types/storeType'
import { cloneDeep } from 'lodash-es'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { useWebsocket } from '@/hooks/useWebsocket'
import useStore from '@/store'
import { getUniqueSignature } from '@/utils/auth'
import { IndexDb } from '@/utils/dexie'
export function useViewModel() {
const serverConfig = useStore().serverConfig
const { getServerList: serverList, getCurrentServer: currentServer } = storeToRefs(serverConfig)
const currentServerValue = ref<ServerType>(cloneDeep(currentServer.value))
const wsUrl = ref<string>('ws://localhost:8080/echo')
const msgList = ref<WsMsgData[]>([])
const { open: openWs, close: closeWs, status: wsStatus } = useWebsocket()
const msgListDb = new IndexDb('msgList', ['msgList'], 1, ['createTime'])
const handleConnectWs = async () => {
const userSignature = await getUniqueSignature()
wsUrl.value = `ws://localhost:8080/echo?userSignature=${userSignature}`
openWs(wsUrl.value)
}
const getAllMsg = async () => {
msgListDb.getDataSortedByDateTime('msgList', 'dateTime').then((data) => {
msgList.value = data
})
}
watch(
() => currentServerValue.value.id,
(newValue) => {
serverList.value.forEach((item) => {
if (item.id === newValue) {
currentServerValue.value = item
serverConfig.setCurrentServer(currentServerValue.value)
}
})
},
)
watch(() => currentServer.value.host, (newValue) => {
currentServerValue.value.host = newValue
serverConfig.updateServerList(currentServerValue.value)
})
onMounted(() => {
getAllMsg()
})
return {
serverList,
currentServerValue,
wsStatus,
handleConnectWs,
closeWs,
msgList,
}
}

View File

@@ -1,241 +1,50 @@
<script setup lang="ts">
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useToast } from 'vue-toast-notification'
import { api_sendMsg } from '@/api/msg'
import { getOriginUrl, getUniqueSignature } from '@/utils/auth'
gsap.registerPlugin(ScrollTrigger)
const toast = useToast()
const mobileUrl = shallowRef<string>('')
const wsQuery = ref<{ userSignature: string }>({
userSignature: '',
})
const list = ref<any[]>([])
list.value = [{
label: 1,
value: 1,
color: 'red',
}, {
label: 2,
value: 2,
color: 'blue',
}, {
label: 3,
value: 3,
color: 'yellow',
}, {
label: 4,
value: 4,
color: 'green',
}, {
label: 5,
value: 5,
color: 'pink',
}, {
label: 6,
value: 6,
color: 'orange',
}, {
label: 7,
value: 7,
color: 'purple',
}, {
label: 8,
value: 8,
color: 'brown',
}, {
label: 9,
value: 9,
color: 'gray',
}, {
label: 10,
value: 10,
color: 'cyan',
}, {
label: 11,
value: 11,
color: 'white',
}, {
label: 12,
value: 12,
color: 'black',
}, {
label: 13,
value: 13,
color: 'orange',
}, {
label: 14,
value: 14,
color: 'yellow',
}, {
label: 15,
value: 14,
color: 'pink',
}, {
label: 15,
value: 15,
color: 'orange',
}, {
label: 16,
value: 16,
color: 'yellow',
}, {
label: 17,
value: 17,
color: 'green',
}, {
label: 18,
value: 18,
color: 'purple',
}]
// 为每个 li 元素创建引用
const liRefs = ref()
const scrollContainerRef = ref()
const ctx = ref()
const showUpButton = ref(false)
const showDownButton = ref(true)
function initGsapAnimation() {
ctx.value = gsap.context(() => {
liRefs.value.forEach((box: any) => {
gsap.fromTo(box, { rotationX: -90, rotateZ: -20, opacity: 0 }, {
rotationX: 0,
rotateZ: 0,
opacity: 1,
scrollTrigger: {
trigger: box,
scroller: scrollContainerRef.value, // <- Specify the scroller!
start: 'bottom 100%',
end: 'top 70%',
scrub: true,
},
})
function connectUserMsg() {
api_sendMsg(wsQuery.value.userSignature, `hello world ${wsQuery.value.userSignature}`).then((res: any) => {
toast.open({
message: res.msg || '发送成功',
type: 'success',
position: 'top-right',
})
}, scrollContainerRef.value) // <- Scope!
})
}
function disposeGsapAnimation() {
ctx.value.revert() // <- Easy Cleanup!
async function getFinger() {
const userSignature = await getUniqueSignature()
wsQuery.value.userSignature = userSignature
return userSignature
}
function scrollHandler() {
const scrollHeight = scrollContainerRef.value.scrollHeight
const scrollTop = scrollContainerRef.value.scrollTop
const containerHeight = scrollContainerRef.value.clientHeight
// 滚动滑到底部
if (scrollTop + containerHeight >= scrollHeight) {
showDownButton.value = false
showUpButton.value = true
}
// 在中间
else if (scrollTop && scrollTop + containerHeight < scrollHeight) {
showDownButton.value = true
showUpButton.value = true
}
// 滚动滑到顶部
else {
showDownButton.value = true
showUpButton.value = false
}
}
function listenScrollContainer() {
scrollContainerRef.value.addEventListener('scroll', scrollHandler)
}
function removeScrollContainer() {
if (scrollContainerRef.value) {
scrollContainerRef.value.removeEventListener('scroll', scrollHandler)
}
}
function handleScroll(h: number) {
scrollContainerRef.value.scrollTop += h
async function setMobileUrl() {
const originUrl = getOriginUrl()
const userSignature = await getFinger()
mobileUrl.value = `${originUrl}/log-lottery/mobile?userSignature=${userSignature}`
}
onMounted(() => {
initGsapAnimation()
listenScrollContainer()
})
onBeforeUnmount(() => {
removeScrollContainer()
getFinger()
setMobileUrl()
})
onUnmounted(() => {
disposeGsapAnimation()
})
</script>
<template>
<div class="h-full w-48 flex flex-col justify-center overflow-hidden relative">
<div class="w-full h-16 flex justify-center scroll-button scroll-button-up">
<SvgIcon v-show="showUpButton" name="chevron-up" size="64px" class="text-gray-200/80 cursor-pointer" @click="handleScroll(-100)" />
</div>
<div ref="scrollContainerRef" class="h-150 w-48 overflow-y-auto overflow-x-hidden relative scroll-smooth hide-scrollbar">
<ul class="li-container relative bg-slate-500/50">
<li
v-for="item in list" :key="item.value" ref="liRefs" :style="{ backgroundColor: item.color }"
class="w-full h-28 text-center leading-30 cursor-pointer duration-300"
>
{{ item.label }}
</li>
<li class="h-16" />
</ul>
</div>
<div class="w-full h-16 flex justify-center scroll-button scroll-button-down">
<SvgIcon v-show="showDownButton" name="chevron-down" size="64px" class="text-gray-200/80 cursor-pointer" @click="handleScroll(100)" />
</div>
<div class="flex flex-col gap-3">
<button class="btn btn-primary btn-sm w-32" @click="connectUserMsg">
connectUserMsg
</button>
</div>
</template>
<style lang="scss" scoped>
.scroll-button::before,
.scroll-button::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
transform: translate(12px 12px);
}
.scroll-button::before {
transform: translate(0, -6px);
opacity: 0.6;
}
.scroll-button::after {
transform: translate(0, 6px);
opacity: 0.4;
}
/* 添加动画效果 */
.scroll-button-down {
animation: bounce-down 2s infinite;
}
/* 添加动画效果 */
.scroll-button-up {
animation: bounce-up 2s infinite;
}
@keyframes bounce-down {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-5px);
}
60% {
transform: translateY(-2px);
}
}
@keyframes bounce-up {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(5px);
}
60% {
transform: translateY(2px);
}
}
.scroll-button:hover {
transform: translateY(-3px);
}
</style>
<style lang="scss" scoped></style>

View File

@@ -8,7 +8,9 @@ import { CSS3DObject, CSS3DRenderer } from 'three-css3d'
import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls.js'
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useToast } from 'vue-toast-notification'
import dongSound from '@/assets/audio/end.mp3'
import enterAudio from '@/assets/audio/enter.wav'
import worldCupAudio from '@/assets/audio/worldcup.mp3'
import { useElementPosition, useElementStyle } from '@/hooks/useElement'
import i18n from '@/locales/i18n'
import useStore from '@/store'
@@ -70,6 +72,10 @@ export function useViewModel() {
const isInitialDone = ref<boolean>(false)
const animationFrameId = ref<any>(null)
const playingAudios = ref<HTMLAudioElement[]>([])
// 抽奖音乐相关
const lotteryMusic = ref<HTMLAudioElement | null>(null)
function initThreeJs() {
const felidView = 40
const width = window.innerWidth
@@ -312,6 +318,104 @@ export function useViewModel() {
})
}
/**
* @description: 开始抽奖音乐
*/
function startLotteryMusic() {
if (!isPlayWinMusic.value) {
return
}
if (lotteryMusic.value) {
lotteryMusic.value.pause()
lotteryMusic.value = null
}
lotteryMusic.value = new Audio(worldCupAudio)
lotteryMusic.value.loop = true
lotteryMusic.value.volume = 0.7
lotteryMusic.value.play().catch((error) => {
console.error('播放抽奖音乐失败:', error)
})
}
/**
* @description: 停止抽奖音乐
*/
function stopLotteryMusic() {
if (!isPlayWinMusic.value) {
return
}
if (lotteryMusic.value) {
lotteryMusic.value.pause()
lotteryMusic.value = null
}
}
/**
* @description: 播放结束音效
*/
function playEndSound() {
if (!isPlayWinMusic.value) {
return
}
console.log('准备播放结束音效', dongSound)
// 清理已结束的音频
playingAudios.value = playingAudios.value.filter(audio => !audio.ended)
try {
const endSound = new Audio(dongSound)
endSound.volume = 1.0
// 简化播放逻辑
const playPromise = endSound.play()
if (playPromise) {
playPromise
.then(() => {
console.log('结束音效播放成功')
playingAudios.value.push(endSound)
})
.catch((err) => {
console.error('播放失败:', err.name, err.message)
if (err.name === 'NotAllowedError') {
console.warn('自动播放被阻止,需用户交互后播放')
}
})
}
endSound.onended = () => {
console.log('结束音效播放完成')
const index = playingAudios.value.indexOf(endSound)
if (index > -1)
playingAudios.value.splice(index, 1)
}
}
catch (error) {
console.error('创建音频对象失败:', error)
}
}
/**
* @description: 重置音频状态
*/
function resetAudioState() {
if (!isPlayWinMusic.value) {
return
}
// 停止抽奖音乐
stopLotteryMusic()
// 清理所有正在播放的音频
playingAudios.value.forEach((audio) => {
if (!audio.ended && !audio.paused) {
audio.pause()
}
})
playingAudios.value = []
}
/**
* @description: 开始抽奖,由横铺变换为球体(或其他图形)
* @returns 随机抽取球数据
@@ -321,6 +425,21 @@ export function useViewModel() {
if (!canOperate.value) {
return
}
// 重置音频状态
resetAudioState()
// 预加载音频资源以解决浏览器自动播放策略
try {
const audioContext = window.AudioContext || (window as any).webkitAudioContext
if (audioContext) {
console.log('音频上下文可用')
}
}
catch (e) {
console.warn('音频上下文不可用:', e)
}
if (!intervalTimer.value) {
randomBallData()
}
@@ -396,6 +515,10 @@ export function useViewModel() {
position: 'top-right',
duration: 8000,
})
// 开始播放抽奖音乐
startLotteryMusic()
currentStatus.value = LotteryStatus.running
rollBall(10, 3000)
if (definiteTime.value) {
@@ -413,6 +536,12 @@ export function useViewModel() {
if (!canOperate.value) {
return
}
// 停止抽奖音乐
stopLotteryMusic()
// 播放结束音效
playEndSound()
// clearInterval(intervalTimer.value)
// intervalTimer.value = null
canOperate.value = false
@@ -449,9 +578,8 @@ export function useViewModel() {
.easing(TWEEN.Easing.Exponential.InOut)
.start()
.onComplete(() => {
if (isPlayWinMusic.value) {
playWinMusic()
}
playWinMusic()
confettiFire()
resetCamera()
})
@@ -459,11 +587,20 @@ export function useViewModel() {
}
// 播放音频中将卡片越多audio对象越多声音越大
function playWinMusic() {
if (!isPlayWinMusic.value) {
return
}
// 清理已结束的音频
playingAudios.value = playingAudios.value.filter(audio => !audio.ended && !audio.paused)
if (playingAudios.value.length > maxAudioLimit) {
console.log('音频播放数量已达到上限,请勿重复播放')
return
}
const enterNewAudio = new Audio(enterAudio)
enterNewAudio.volume = 0.8
playingAudios.value.push(enterNewAudio)
enterNewAudio.play()
.then(() => {
@@ -483,6 +620,14 @@ export function useViewModel() {
playingAudios.value.splice(index, 1)
}
})
// 播放错误时从数组中移除
enterNewAudio.onerror = () => {
const index = playingAudios.value.indexOf(enterNewAudio)
if (index > -1) {
playingAudios.value.splice(index, 1)
}
}
}
/**
* @description: 继续,意味着这抽奖作数,计入数据库
@@ -514,6 +659,9 @@ export function useViewModel() {
* @description: 放弃本次抽奖,回到初始状态
*/
function quitLottery() {
// 停止抽奖音乐
stopLotteryMusic()
enterLottery()
currentStatus.value = LotteryStatus.init
}
@@ -523,7 +671,7 @@ export function useViewModel() {
* @param {string} mod 模式
*/
function randomBallData(mod: 'default' | 'lucky' | 'sphere' = 'default') {
// 两秒执行一次
// 两秒执行一次
intervalTimer.value = setInterval(() => {
// 产生随机数数组
const indexLength = 4
@@ -581,7 +729,7 @@ export function useViewModel() {
* @description: 清理资源,避免内存溢出
*/
function cleanup() {
// 停止所有Tween动画
// 停止所有Tween动画
TWEEN.removeAll()
// 清理动画循环
@@ -590,6 +738,21 @@ export function useViewModel() {
}
clearInterval(intervalTimer.value)
intervalTimer.value = null
// 停止抽奖音乐
stopLotteryMusic()
// 清理所有音频资源
playingAudios.value.forEach((audio) => {
if (!audio.ended && !audio.paused) {
audio.pause()
}
// 释放音频资源
audio.src = ''
audio.load()
})
playingAudios.value = []
if (scene.value) {
scene.value.traverse((object: Object3D) => {
if ((object as any).material) {

View File

@@ -0,0 +1,90 @@
<script setup lang='ts'>
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useViewModel } from './useViewModel'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn') // 设置为中文
const textareaRef = ref()
const messageArrayRef = ref()
// 存储定时器ID
const timer = ref()
// 创建一个响应式的时间戳,用于触发更新
const nowTimestamp = ref(Date.now())
const { sendMsg, userInputMsg, userMsgArray } = useViewModel()
async function handleEnterSend() {
sendMsg(userInputMsg.value)
textareaRef.value.blur()
messageArrayRef.value.scrollTop = messageArrayRef.value.scrollHeight
}
function scrollToBottom() {
if (!messageArrayRef.value) {
return
}
setTimeout(() => {
messageArrayRef.value.scrollTop = messageArrayRef.value.scrollHeight
}, 0)
}
// 带有实时更新的时间显示
const formattedMessages = computed(() => {
const _ = nowTimestamp.value
return userMsgArray.value.map(item => ({
...item,
formattedTime: dayjs(item.dateTime).fromNow(),
}))
})
watch(() => userMsgArray.value.length, () => {
scrollToBottom()
}, { immediate: true })
onMounted(() => {
timer.value = setInterval(() => {
nowTimestamp.value = Date.now()
}, 60000) // 每分钟更新一次
})
onUnmounted(() => {
if (timer.value) {
clearInterval(timer.value)
}
})
</script>
<template>
<div class="flex flex-col justify-around py-4">
<div class="h-12 drop-shadow-md shadow-lg">
<h2 class="text-center text-lg font-bold">
发送弹幕
</h2>
</div>
<div ref="messageArrayRef" class="overflow-y-auto h-[calc(100vh-15rem)]">
<ul>
<li v-for="item in formattedMessages" :key="item.id" class="mb-3">
<div class="chat chat-end">
<div class="chat-header">
<time class="text-xs opacity-50">{{ item.formattedTime }}</time>
</div>
<div class="chat-bubble break-all whitespace-normal">
{{ item.msg }}
</div>
</div>
</li>
</ul>
</div>
<div class="border-none rounded-2xl bg-base-200 mx-2 p-2 flex flex-col gap-3 items-center justify-center shadow-md mb-8 h-48">
<textarea ref="textareaRef" v-model="userInputMsg" class="textarea w-full rounded-xl border-none bg-transparent focus:outline-none focus:ring-0" placeholder="发送弹幕 | 只展示您发送过的弹幕" rows="5" cols="50" @keydown.enter.prevent="handleEnterSend" />
<div class="w-full flex justify-end">
<button class="btn btn-primary w-24 mb-4" @click="handleEnterSend">
发送
</button>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,50 @@
import type { IMsgType } from '@/types/msgType'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useToast } from 'vue-toast-notification'
import { api_sendMsg } from '@/api/msg'
import { IndexDb } from '@/utils/dexie'
export function useViewModel() {
const toast = useToast()
const route = useRoute()
const routeSignature = ref<string>('')
const userInputMsg = ref('')
const userMsgArray = ref<any[]>([])
const userMsgDb = new IndexDb('userMsg', ['userMsg'], 1, ['createTime'])
const getRouteSignature = async () => {
routeSignature.value = route.query.userSignature as string
}
const getAllMsg = async () => {
userMsgDb.getDataSortedByDateTime('userMsg', 'dateTime').then((data) => {
userMsgArray.value = data
})
}
function sendMsg(msg: string) {
const msgData: IMsgType = {
data: msg,
dateTime: new Date().toLocaleString(),
}
api_sendMsg(routeSignature.value, msgData).then((res: any) => {
toast.open({
message: res.msg || '发送成功',
type: 'success',
position: 'top-right',
})
userMsgDb.setData('userMsg', { msg })
getAllMsg()
userInputMsg.value = ''
})
}
onMounted(() => {
getRouteSignature()
getAllMsg()
})
return {
sendMsg,
userInputMsg,
userMsgArray,
}
}