更新了机型映射、背景音乐播放逻辑
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user