更新了机型映射、背景音乐播放逻辑

This commit is contained in:
李琦
2026-08-07 09:43:52 +08:00
parent 33cea285c1
commit 1f9ac09829
10 changed files with 870 additions and 186 deletions

View File

@@ -183,7 +183,7 @@
</div>
<div class="visit-preview-wrap">
<a-table
class="admin-table"
class="admin-table visit-table-clickable"
:columns="visitPreviewColumns"
:data-source="visitPreviewList"
:loading="visitListLoading"
@@ -191,10 +191,11 @@
size="small"
:pagination="false"
:scroll="{ y: 260 }"
:custom-row="visitTableCustomRow"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fingerprint'">
<span class="font-mono text-[11px]" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
<span class="font-mono text-[11px] text-[#a88c6b] underline-offset-2 hover:underline" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
</template>
<template v-else-if="column.key === 'location'">
<span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
@@ -306,9 +307,10 @@
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
</a-button>
</div>
<div class="text-[11px] text-[#8A8680] mb-2">点击行查看该访客的回执弹幕点赞与礼物详情</div>
<div class="visit-list-wrap">
<a-table
class="admin-table"
class="admin-table visit-table-clickable"
:columns="visitColumns"
:data-source="visitList"
:loading="visitListLoading"
@@ -316,11 +318,12 @@
size="middle"
:pagination="visitPagination"
:scroll="{ y: 'calc(100dvh - 280px)', x: 960 }"
:custom-row="visitTableCustomRow"
@change="onVisitTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fingerprint'">
<span class="font-mono text-[11px]" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
<span class="font-mono text-[11px] text-[#a88c6b] underline-offset-2 hover:underline" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
</template>
<template v-else-if="column.key === 'location'">
<span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
@@ -340,6 +343,84 @@
</div>
</section>
<a-modal
v-model:open="visitDetailOpen"
:title="visitDetailTitle"
:footer="null"
width="720px"
destroy-on-close
>
<div v-if="visitDetailLoading" class="py-10 text-center text-[#8A8680] text-[13px]">加载中</div>
<div v-else-if="visitDetail" class="visit-detail">
<div class="visit-detail-summary">
<div class="stat-card !mb-0">
<div class="stat-label">浏览次数</div>
<div class="stat-value !text-xl">{{ visitDetail.visitor?.visit_count || 0 }}</div>
</div>
<div class="stat-card !mb-0">
<div class="stat-label">点赞</div>
<div class="stat-value !text-xl">{{ visitDetail.like_count || 0 }}</div>
</div>
<div class="stat-card !mb-0">
<div class="stat-label">礼物</div>
<div class="stat-value !text-xl">{{ visitDetail.gift_count || 0 }}</div>
</div>
<div class="stat-card !mb-0">
<div class="stat-label">回执</div>
<div class="stat-value !text-xl">{{ (visitDetail.rsvps || []).length }}</div>
</div>
</div>
<div class="text-[12px] text-[#5c5348] mt-3 space-y-1">
<div>归属地{{ visitDetail.visitor?.raw_location || [visitDetail.visitor?.region, visitDetail.visitor?.city].filter(Boolean).join(' ') || '未知' }}</div>
<div>机型{{ visitDetail.visitor?.device || '未知' }}</div>
<div>首次{{ formatTime(visitDetail.visitor?.first_seen_at) }} · 最近{{ formatTime(visitDetail.visitor?.last_seen_at) }}</div>
<div class="font-mono text-[11px] text-[#8A8680] break-all">指纹{{ visitDetail.visitor?.fingerprint }}</div>
</div>
<div class="visit-detail-section">
<div class="visit-detail-h">回执</div>
<div v-if="!(visitDetail.rsvps || []).length" class="text-[12px] text-[#8A8680]">暂无回执</div>
<ul v-else class="visit-detail-list">
<li v-for="r in visitDetail.rsvps" :key="'rsvp-' + r.id">
<div class="font-medium text-[#2c2c2c]">{{ r.name || '—' }} · {{ r.guest_count || '1' }} </div>
<div v-if="r.wishes" class="text-[#5c5348] mt-0.5 whitespace-pre-wrap">{{ r.wishes }}</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">{{ formatTime(r.created_at) }}</div>
</li>
</ul>
</div>
<div class="visit-detail-section">
<div class="visit-detail-h">弹幕</div>
<div v-if="!(visitDetail.danmakus || []).length" class="text-[12px] text-[#8A8680]">暂无弹幕</div>
<ul v-else class="visit-detail-list">
<li v-for="d in visitDetail.danmakus" :key="'dm-' + d.id">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-[#2c2c2c]">{{ d.name || '—' }}</span>
<span class="admin-pill admin-pill--muted">{{ d.source === 'rsvp' ? '回执' : '弹幕' }}</span>
<span class="admin-pill" :class="danmakuStatusPill(d.status)">{{ danmakuStatusLabel(d.status) }}</span>
</div>
<div class="text-[#5c5348] mt-0.5 whitespace-pre-wrap">{{ d.content }}</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">{{ formatTime(d.created_at) }}</div>
</li>
</ul>
</div>
<div class="visit-detail-section">
<div class="visit-detail-h">礼物明细</div>
<div v-if="!(visitDetail.gifts || []).length" class="text-[12px] text-[#8A8680]">暂无礼物</div>
<ul v-else class="visit-detail-list">
<li v-for="g in visitDetail.gifts" :key="'gift-' + g.id">
<div class="font-medium text-[#2c2c2c]">
{{ g.name || '—' }} · {{ giftTypeLabel(g.gift_type) }}
<span class="text-[#8A8680] font-normal">消耗 {{ g.cost || 0 }}</span>
</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">{{ formatTime(g.created_at) }}</div>
</li>
</ul>
</div>
</div>
</a-modal>
<!-- 基础信息 -->
<section v-if="activeTab === 'basic'">
<a-card :bordered="false" class="!shadow-sm" :class="{ 'opacity-60': tabLoading }">
@@ -524,49 +605,54 @@
<div>
<div class="text-sm text-[#2c2c2c] tracking-wide">曲目列表</div>
<p class="text-[11px] text-[#8A8680] mt-1 leading-relaxed max-w-xl">
开启随机后按<strong>权重</strong>抽曲播完自动再随机下一首不单曲循环
可开启记忆曲目让同一访客在设定时长内首次打开仍播同一首
在下方选择<strong>播放设置</strong>随机模式按权重抽曲可开启记忆曲目让同一访客在设定时长内首次打开仍播同一首
</p>
</div>
<div class="flex flex-wrap items-center gap-3 justify-end">
<label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
<a-switch v-model:checked="cfg.musicRandomEnabled" size="small" />
随机播放
</label>
<a-button type="primary" size="small"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
@click="openMusicUpload">
<i class="fa-solid fa-plus mr-1"></i>上传音乐
</a-button>
</div>
<a-button type="primary" size="small"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
@click="openMusicUpload">
<i class="fa-solid fa-plus mr-1"></i>上传音乐
</a-button>
</div>
<div
v-if="cfg.musicRandomEnabled"
class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[#e7e3db] bg-[#faf8f5] px-3 py-2.5"
>
<label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
<a-switch v-model:checked="cfg.musicCacheEnabled" size="small" />
记忆曲目
</label>
<div class="inline-flex items-center gap-2 text-[12px] text-[#5c5348]">
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }">记忆时长</span>
<a-input-number
v-model:value="cfg.musicCacheHours"
:min="0.5"
:max="720"
:step="0.5"
size="small"
class="!w-[96px]"
:disabled="!cfg.musicCacheEnabled"
/>
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }" class="text-[#8A8680]">小时</span>
<div class="mb-4 rounded-xl border border-[#e7e3db] bg-[#faf8f5] px-3 py-3">
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">播放设置</div>
<a-radio-group v-model:value="cfg.musicPlayMode" class="music-play-mode-group">
<a-radio-button value="sequential">顺序</a-radio-button>
<a-radio-button value="reverse">倒序</a-radio-button>
<a-radio-button value="random">随机</a-radio-button>
<a-radio-button value="single">单曲循环</a-radio-button>
</a-radio-group>
<div class="text-[10px] text-[#8A8680] mt-2 leading-relaxed">
{{ musicPlayModeHint }}
</div>
<div
v-if="cfg.musicPlayMode === 'random'"
class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-[#e7e3db] pt-3"
>
<label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
<a-switch v-model:checked="cfg.musicCacheEnabled" size="small" />
记忆曲目
</label>
<div class="inline-flex items-center gap-2 text-[12px] text-[#5c5348]">
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }">记忆时长</span>
<a-input-number
v-model:value="cfg.musicCacheHours"
:min="0.5"
:max="720"
:step="0.5"
size="small"
class="!w-[96px]"
:disabled="!cfg.musicCacheEnabled"
/>
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }" class="text-[#8A8680]">小时</span>
</div>
<span class="text-[10px] text-[#8A8680]">
{{ cfg.musicCacheEnabled
? `同一设备 ${cfg.musicCacheHours || 24} 小时内首次打开沿用上次抽中的歌;播完仍会换下一首。`
: '关闭后每次打开页面都会重新按权重抽曲。' }}
</span>
</div>
<span class="text-[10px] text-[#8A8680]">
{{ cfg.musicCacheEnabled
? `同一设备 ${cfg.musicCacheHours || 24} 小时内首次打开沿用上次抽中的歌;播完仍会换下一首。`
: '关闭后每次打开页面都会重新按权重抽曲。' }}
</span>
</div>
<div v-if="!cfg.musicList.length" class="admin-empty">
@@ -616,8 +702,8 @@
<div class="music-chips">
<label
class="music-chip music-chip--weight"
:class="{ 'opacity-40': !cfg.musicRandomEnabled }"
:title="cfg.musicRandomEnabled ? '随机权重,越大越容易抽到' : '开启随机播放后生效'"
:class="{ 'opacity-40': cfg.musicPlayMode !== 'random' }"
:title="cfg.musicPlayMode === 'random' ? '随机权重,越大越容易抽到' : '随机模式下生效'"
>
<i class="fa-solid fa-scale-balanced"></i>
<span>权重</span>
@@ -628,9 +714,9 @@
:step="1"
size="small"
class="music-weight-input"
:disabled="!cfg.musicRandomEnabled"
:disabled="cfg.musicPlayMode !== 'random'"
/>
<span v-if="cfg.musicRandomEnabled" class="music-weight-pct">
<span v-if="cfg.musicPlayMode === 'random'" class="music-weight-pct">
{{ musicWeightPercent(t) }}%
</span>
</label>
@@ -1573,7 +1659,7 @@ import { TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import {
getConfigSection, saveConfigSection, uploadImage, getRsvpList, deleteRsvp, getUploadConfig, saveUploadConfig,
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitCityStats, getVisitList,
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitCityStats, getVisitList, getVisitDetail,
getPosterConfig, savePosterConfig, getGiftStats, getGiftList, deleteGift, saveGiftCosts,
getCashGiftList, updateCashGift, deleteCashGift, submitCashGift,
getCashGiftNotes, saveCashGiftNotes,
@@ -1586,7 +1672,9 @@ import MediaPickerModal from '@/components/MediaPickerModal.vue'
import HotelWelcomePosterAdmin from '@/views/admin/HotelWelcomePosterAdmin.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { normalizeMusicWeight, normalizeMusicCacheHours } from '@/utils/musicPick'
import {
normalizeMusicWeight, normalizeMusicCacheHours, resolveMusicPlayMode,
} from '@/utils/musicPick'
import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
import {
getSlotResizeSpecs,
@@ -1676,7 +1764,7 @@ const SECTION_KEYS = {
'introExitEffect', 'scrollMode', 'freeScrollInterval', 'pageSwitchDuration', 'firstScreenDuration',
'petalEnabled', 'bubbleEnabled', 'introEnabled', 'danmakuEnabled', 'danmakuShowTime', 'nineGridAnimate',
],
music: ['musicList', 'activeMusicUrl', 'musicRandomEnabled', 'musicCacheEnabled', 'musicCacheHours'],
music: ['musicList', 'activeMusicUrl', 'musicPlayMode', 'musicRandomEnabled', 'musicCacheEnabled', 'musicCacheHours'],
photos: ['photoPages'],
schedule: ['schedule'],
}
@@ -1745,6 +1833,13 @@ const danmakuFilter = ref('pending')
const visitStatsLoading = ref(false)
const visitListLoading = ref(false)
const visitDetailOpen = ref(false)
const visitDetailLoading = ref(false)
const visitDetail = ref(null)
const visitDetailTitle = computed(() => {
const fp = visitDetail.value?.visitor?.fingerprint
return fp ? `访客详情 · ${shortFp(fp)}` : '访客详情'
})
const giftStatsLoading = ref(false)
const giftListLoading = ref(false)
const giftCostsSaving = ref(false)
@@ -1986,7 +2081,8 @@ function defaultConfig() {
endImgResizedMeta: '',
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1', weight: 1 }],
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
musicRandomEnabled: false,
musicPlayMode: 'random',
musicRandomEnabled: true,
musicCacheEnabled: true,
musicCacheHours: 24,
danmakuEnabled: true,
@@ -2523,6 +2619,14 @@ function musicWeightPercent(track) {
if (!total) return 0
return Math.round((normalizeMusicWeight(track?.weight) / total) * 100)
}
const MUSIC_PLAY_MODE_HINTS = {
sequential: '按列表顺序播放,播完自动下一首,到末尾回到第一首。',
reverse: '按列表倒序播放,播完自动上一首,到开头回到最后一首。',
random: '按权重随机抽曲,播完再抽下一首;可配置记忆与权重。',
single: '单曲循环当前曲(「设为播放」指定的曲目)。',
}
const musicPlayModeHint = computed(() => MUSIC_PLAY_MODE_HINTS[cfg.musicPlayMode] || MUSIC_PLAY_MODE_HINTS.random)
function shortUrl(url) {
if (!url) return ''
const parts = url.split('/')
@@ -2762,11 +2866,15 @@ function pickSectionData(section) {
const data = {}
for (const k of keys) data[k] = cfg[k]
const cloned = JSON.parse(JSON.stringify(data))
if (section === 'music' && Array.isArray(cloned.musicList)) {
cloned.musicList = cloned.musicList.map((t) => ({
...t,
weight: normalizeMusicWeight(t?.weight),
}))
if (section === 'music') {
if (Array.isArray(cloned.musicList)) {
cloned.musicList = cloned.musicList.map((t) => ({
...t,
weight: normalizeMusicWeight(t?.weight),
}))
}
cloned.musicPlayMode = resolveMusicPlayMode(cloned)
cloned.musicRandomEnabled = cloned.musicPlayMode === 'random'
cloned.musicCacheEnabled = cloned.musicCacheEnabled !== false
cloned.musicCacheHours = normalizeMusicCacheHours(cloned.musicCacheHours)
}
@@ -2784,7 +2892,8 @@ function applySectionData(section, loaded) {
weight: normalizeMusicWeight(t.weight),
}))
}
if (typeof loaded.musicRandomEnabled !== 'boolean') loaded.musicRandomEnabled = false
loaded.musicPlayMode = resolveMusicPlayMode(loaded)
loaded.musicRandomEnabled = loaded.musicPlayMode === 'random'
if (typeof loaded.musicCacheEnabled !== 'boolean') loaded.musicCacheEnabled = true
loaded.musicCacheHours = normalizeMusicCacheHours(loaded.musicCacheHours)
}
@@ -2885,6 +2994,28 @@ function shortUa(ua) {
return ua.length > 42 ? `${ua.slice(0, 42)}` : ua
}
const visitTableCustomRow = (record) => ({
onClick: () => openVisitDetail(record?.fingerprint),
style: { cursor: 'pointer' },
})
async function openVisitDetail(fingerprint) {
const fp = String(fingerprint || '').trim()
if (!fp) return
visitDetailOpen.value = true
visitDetailLoading.value = true
visitDetail.value = null
try {
const res = await getVisitDetail(fp)
visitDetail.value = res.data || null
} catch (e) {
message.error(e?.message || '加载访客详情失败')
visitDetailOpen.value = false
} finally {
visitDetailLoading.value = false
}
}
const PIE_COLORS = ['#a88c6b', '#c4a484', '#e8b4b8', '#7c9eb2', '#b5a4c9', '#d4a574', '#9b7e9a', '#64748b']
function disposeVisitCharts() {
@@ -3820,6 +3951,47 @@ onUnmounted(() => {
.visit-list-wrap :deep(.ant-table-body) {
max-height: inherit;
}
.visit-table-clickable :deep(.ant-table-tbody > tr:hover > td) {
background: rgba(168, 140, 107, 0.08) !important;
}
.visit-detail-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.visit-detail-section {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(168, 140, 107, 0.14);
}
.visit-detail-h {
font-size: 13px;
font-weight: 600;
color: #2c2c2c;
margin-bottom: 8px;
}
.visit-detail-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.visit-detail-list li {
font-size: 12px;
padding: 8px 10px;
border-radius: 10px;
background: #fdfbf7;
border: 1px solid rgba(168, 140, 107, 0.1);
}
@media (max-width: 640px) {
.visit-detail-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.chart-card {
border-radius: 14px;
padding: 14px 16px;

View File

@@ -852,7 +852,9 @@ import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from
import { getAlbumImages, getConfig, submitRsvp, submitDanmaku, generateAiBlessing, getLikeStatus, submitLike, getGiftStatus, submitGift } from '@/api/wedding'
import { useAudio } from '@/composables/useAudio'
import { toast } from '@/composables/useToast'
import { pickMusicUrl, pickWeightedMusicUrl, rememberMusicPick } from '@/utils/musicPick'
import {
pickMusicUrl, pickWeightedMusicUrl, rememberMusicPick, resolveMusicPlayMode,
} from '@/utils/musicPick'
import { getClientId } from '@/utils/clientId'
import { getGuestName, setGuestName } from '@/utils/guestName'
import {
@@ -863,7 +865,7 @@ import {
chunkGiftTypes,
LIKE_QUICK_AMOUNTS,
} from '@/utils/giftTypes'
import { sendVisitBeacon } from '@/utils/visitBeacon'
import { sendVisitBeacon, getFingerprint } from '@/utils/visitBeacon'
import { optimizeImageUrl, isIOS } from '@/composables/useAndroidOptimize'
import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
import { parseLrc, formatAudioTime, findLrcIndex } from '@/utils/parseLrc'
@@ -877,7 +879,7 @@ import PhotoGallery from '@/components/PhotoGallery.vue'
const DEFAULTS = {
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
musicList: [], activeMusicUrl: '', musicRandomEnabled: false,
musicList: [], activeMusicUrl: '', musicPlayMode: 'random', musicRandomEnabled: true,
musicCacheEnabled: true, musicCacheHours: 24,
danmakuEnabled: true, danmakuShowTime: true, nineGridAnimate: false,
freeScrollInterval: 50, // 自由滚动速度:滚动间隔 ms越小越快
@@ -926,13 +928,16 @@ const toggleVolumeSlider = () => {
showVolumeSlider.value = !showVolumeSlider.value
if (showVolumeSlider.value) showPanelTrackList.value = false
}
const currentMusicPlayMode = () => resolveMusicPlayMode(data.value)
const musicCacheOptions = () => ({
mode: currentMusicPlayMode(),
cacheEnabled: data.value.musicCacheEnabled !== false,
cacheHours: data.value.musicCacheHours,
})
const rememberIfCached = (url) => {
if (data.value.musicRandomEnabled && data.value.musicCacheEnabled !== false && url) {
if (currentMusicPlayMode() === 'random' && data.value.musicCacheEnabled !== false && url) {
rememberMusicPick(url)
}
}
@@ -948,21 +953,30 @@ const playRandomTrack = async (excludeUrl) => {
}
const syncMusicPlaybackMode = () => {
const random = !!data.value.musicRandomEnabled
// 随机:不单曲循环,播完再抽;非随机:单曲循环当前曲
audio.setLoop(!random)
// single → 原生循环;其余模式播完由 onEnded 接管
audio.setLoop(currentMusicPlayMode() === 'single')
}
const playPrevTrack = () => {
audio.prev()
const mode = currentMusicPlayMode()
if (mode === 'reverse') {
audio.next()
} else {
audio.prev()
}
rememberIfCached(audio.activeUrl.value)
}
const playNextTrack = () => {
if (data.value.musicRandomEnabled && (data.value.musicList || []).filter((t) => t?.url).length > 1) {
const mode = currentMusicPlayMode()
if (mode === 'random' && (data.value.musicList || []).filter((t) => t?.url).length > 1) {
playRandomTrack(audio.activeUrl.value)
return
}
audio.next()
if (mode === 'reverse') {
audio.prev()
} else {
audio.next()
}
rememberIfCached(audio.activeUrl.value)
}
const activeLrcIndex = computed(() => findLrcIndex(lrcLines.value, audio.currentTime.value))
@@ -1868,7 +1882,7 @@ const selectTrack = (url) => {
audio.setActive(url)
showPanelTrackList.value = false
showTrackList.value = false
if (data.value.musicRandomEnabled) rememberIfCached(url)
if (currentMusicPlayMode() === 'random') rememberIfCached(url)
}
const mapKeyword = computed(() => `${data.value.hotel || ''} ${data.value.address || ''}`.replace(/\s+/g, ' ').trim())
@@ -1939,10 +1953,16 @@ const pickAiStyle = async (target, style) => {
}
}
const visitorIds = async () => {
const fingerprint = await getFingerprint()
return { fingerprint: fingerprint || '', client_id: getClientId() }
}
const handleSubmitRsvp = async () => {
if (!rsvpForm.name.trim()) return toast.error('请填写姓名')
try {
const res = await submitRsvp(rsvpForm)
const ids = await visitorIds()
const res = await submitRsvp({ ...rsvpForm, ...ids })
if (res.code === 200) {
setGuestName(rsvpForm.name)
syncGuestNameIntoForms()
@@ -1957,10 +1977,12 @@ const handleSubmitBlessing = async () => {
if (!blessingForm.name.trim()) return toast.error('请填写姓名')
if (!blessingForm.content.trim()) return toast.error('请填写祝福')
try {
const ids = await visitorIds()
const res = await submitDanmaku({
name: blessingForm.name.trim(),
content: blessingForm.content.trim(),
color: blessingForm.color || DEFAULT_DANMAKU_COLOR,
...ids,
})
if (res.code === 200) {
setGuestName(blessingForm.name)
@@ -2057,10 +2079,12 @@ const openGiftNameModal = (giftType) => {
const sendGiftRequest = async (giftType, name) => {
giftBusy.value = true
try {
const fingerprint = await getFingerprint()
const res = await submitGift({
giftType,
name,
clientId: getClientId(),
fingerprint,
})
setGuestName(name)
giftForm.name = name
@@ -2146,7 +2170,8 @@ const handleLike = async (count = 1) => {
const n = Math.max(1, Math.min(200, Number(count) || 1))
likeBusy.value = true
try {
const res = await submitLike(getClientId(), n)
const fingerprint = await getFingerprint()
const res = await submitLike(getClientId(), n, fingerprint)
likeCount.value = Number(res.data?.count) || likeCount.value + n
likePulse.value = true
clearTimeout(likePulseTimer)
@@ -2231,16 +2256,28 @@ onMounted(async () => {
// 未开启揭幕时Loading 结束后再播积压特效
if (!data.value.introEnabled) flushPendingEntranceFx()
// 兼容旧配置:补全 musicPlayMode
data.value.musicPlayMode = resolveMusicPlayMode(data.value)
data.value.musicRandomEnabled = data.value.musicPlayMode === 'random'
const pickedUrl = pickMusicUrl(
data.value.musicList,
data.value.activeMusicUrl,
data.value.musicRandomEnabled,
musicCacheOptions(),
)
syncMusicPlaybackMode()
audio.onEnded(() => {
if (!data.value.musicRandomEnabled) return
playRandomTrack(audio.activeUrl.value)
const mode = currentMusicPlayMode()
if (mode === 'single') return
if (mode === 'random') {
playRandomTrack(audio.activeUrl.value)
return
}
if (mode === 'reverse') {
audio.prev()
return
}
audio.next()
})
audio.setTracks(data.value.musicList, pickedUrl)
let autoMusicOk = true