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:
32
src/views/Config/Server/index.vue
Normal file
32
src/views/Config/Server/index.vue
Normal 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>
|
||||
29
src/views/Config/Server/parts/MsgListContainer.vue
Normal file
29
src/views/Config/Server/parts/MsgListContainer.vue
Normal 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>
|
||||
107
src/views/Config/Server/parts/ServerSetting.vue
Normal file
107
src/views/Config/Server/parts/ServerSetting.vue
Normal 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>
|
||||
55
src/views/Config/Server/useViewModel.ts
Normal file
55
src/views/Config/Server/useViewModel.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
90
src/views/Mobile/index.vue
Normal file
90
src/views/Mobile/index.vue
Normal 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>
|
||||
50
src/views/Mobile/useViewModel.ts
Normal file
50
src/views/Mobile/useViewModel.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user