初始化

This commit is contained in:
李琦
2026-07-31 09:05:15 +08:00
parent 2a426eebe7
commit 5f00b644cf
31 changed files with 1084 additions and 106 deletions

View File

@@ -79,9 +79,8 @@
<a-tab-pane key="visual" tab="视觉与动效">
<a-card :bordered="false" class="!shadow-sm">
<a-form layout="vertical">
<a-form-item label="背景音乐链接 (URL)">
<a-input v-model:value="cfg.musicUrl" placeholder="留空则不播放音乐" />
<div class="text-[11px] text-[#8A8680] mt-1">支持 mp3 直链访客首次交互后自动播放可随时暂停</div>
<a-form-item label="背景音乐">
<div class="text-[12px] text-[#8A8680] leading-relaxed">多首背景音乐的上传试听与设为播放请在 <span class="text-[#a88c6b]">背景音乐</span> 标签页管理</div>
</a-form-item>
<a-form-item label="滚动方式">
<a-radio-group v-model:value="cfg.scrollMode">
@@ -105,6 +104,33 @@
</a-card>
</a-tab-pane>
<!-- 背景音乐 -->
<a-tab-pane key="music" tab="背景音乐">
<a-card :bordered="false" class="!shadow-sm">
<div class="text-[12px] text-[#8A8680] leading-relaxed mb-4">
上传多首背景音乐支持 mp3 等格式访客首次交互后自动播放当前曲目可在前台随时切换或暂停
</div>
<a-empty v-if="!cfg.musicList.length" description="尚未上传音乐" />
<a-card v-for="(t, i) in cfg.musicList" :key="i" :bordered="false"
class="!shadow-sm mb-3 !border" :class="t.url === cfg.activeMusicUrl ? '!border-[#a88c6b]' : '!border-[#a88c6b]/15'">
<div class="flex items-center gap-3 flex-wrap">
<a-input v-model:value="t.name" placeholder="曲目名称" class="flex-1 min-w-[140px]" />
<span class="text-[10px] text-[#8A8680] flex-1 min-w-[140px] truncate">{{ shortUrl(t.url) }}</span>
<a-button size="small" :type="t.url === cfg.activeMusicUrl ? 'primary' : 'default'"
:class="t.url === cfg.activeMusicUrl ? '!bg-[#a88c6b] !border-[#a88c6b]' : ''"
@click="setActiveMusic(t.url)">
{{ t.url === cfg.activeMusicUrl ? '播放中' : '设为播放' }}
</a-button>
<a-button size="small" danger @click="removeMusic(i)"><i class="fa-solid fa-trash"></i></a-button>
</div>
<audio v-if="t.url" :src="t.url" controls class="w-full mt-3" style="height: 36px;"></audio>
</a-card>
<a-button class="mt-1" @click="openMusicUpload">
<i class="fa-solid fa-upload mr-1 text-[#a88c6b]"></i>上传音乐
</a-button>
</a-card>
</a-tab-pane>
<!-- 相册页 -->
<a-tab-pane key="photos" tab="相册页管理">
<div class="flex justify-between items-center mb-3">
@@ -128,8 +154,23 @@
<a-select-option value="overlap">错落双图</a-select-option>
<a-select-option value="one-large-five-small">一大五小</a-select-option>
<a-select-option value="nine-grid">九宫格</a-select-option>
<a-select-option value="masonry">瀑布流</a-select-option>
<a-select-option value="carousel">轮播滑动</a-select-option>
<a-select-option value="polaroid">拍立得拼贴</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="页面高度">
<div class="flex gap-2 items-center">
<a-select :value="heightSelectValue(page)" @change="(v) => onHeightModeChange(page, v)" class="flex-1">
<a-select-option v-for="opt in HEIGHT_PRESETS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
</a-select>
<a-input-number v-if="heightSelectValue(page) === '__custom__'"
:value="customVh(page)" @change="(v) => onCustomVh(page, v)"
:min="20" :max="200" addon-after="vh" class="w-[150px]" />
</div>
</a-form-item>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="小标题 (英文)">
<a-input v-model:value="page.subtitle" />
</a-form-item>
@@ -150,11 +191,18 @@
<image-field v-model="page.img2" label="图二" @pick="openUpload(u => page.img2 = u)" :uploading="uploading" />
</template>
<template v-else>
<image-field v-for="(img, iidx) in page.images" :key="iidx"
v-model="page.images[iidx]"
:label="`图${iidx+1}`"
@pick="openUpload(u => page.images[iidx] = u)"
:uploading="uploading" />
<div v-for="(img, iidx) in page.images" :key="iidx" class="relative">
<image-field v-model="page.images[iidx]"
:label="`图${iidx+1}`"
@pick="openUpload(u => page.images[iidx] = u)"
:uploading="uploading" />
<button v-if="isFlexible(page.type) && page.images.length > 1"
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-[#a88c6b] text-white text-[10px] leading-none flex items-center justify-center shadow"
@click="removeImage(page, iidx)" title="移除该图"></button>
</div>
<button v-if="isFlexible(page.type)"
class="w-[96px] h-[96px] rounded-xl border border-dashed border-[#a88c6b]/40 text-[#a88c6b] text-[11px] flex items-center justify-center hover:border-[#a88c6b] transition-colors"
@click="addImage(page)">+ 添加图片</button>
</template>
</div>
</a-card>
@@ -228,13 +276,14 @@ function defaultConfig() {
hotel: '杭州星光万丽大酒店', address: '杭州市西湖区星光大道88号\n3楼大宴会厅',
formalText1: '两姓联姻 一堂缔约', formalText2: '良缘永结 匹配同称',
heroImg: 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80',
musicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1' }],
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
photoPages: [
{ type: 'single', title: '初遇', subtitle: 'FIRST MET', desc: '世界那么大,还是遇见了你。\n于千万人之中没有早一步也没有晚一步。', img1: 'https://images.unsplash.com/photo-1606800052052-a08af7148866?auto=format&fit=crop&w=600&q=80' },
{ type: 'overlap', title: '相知', subtitle: 'FALL IN LOVE', desc: '纵然万劫不复,纵然相思入骨。\n我也待你眉眼如初岁月如故。', img1: 'https://images.unsplash.com/photo-1532712938310-34cb3982ef74?auto=format&fit=crop&w=600&q=80', img2: 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=600&q=80' },
{ type: 'one-large-five-small', title: '相恋', subtitle: 'ROMANCE', desc: '星光坠入眼眸,晚风轻抚过裙摆。\n浪漫不止于此还有无数个明天。', images: fill(6) },
{ type: 'nine-grid', title: '相守', subtitle: 'FOREVER', desc: '往后余生,风雪是你,平淡是你。', images: fill(9) },
{ type: 'single', title: '初遇', subtitle: 'FIRST MET', desc: '世界那么大,还是遇见了你。\n于千万人之中没有早一步也没有晚一步。', img1: 'https://images.unsplash.com/photo-1606800052052-a08af7148866?auto=format&fit=crop&w=600&q=80', height: '100vh' },
{ type: 'overlap', title: '相知', subtitle: 'FALL IN LOVE', desc: '纵然万劫不复,纵然相思入骨。\n我也待你眉眼如初岁月如故。', img1: 'https://images.unsplash.com/photo-1532712938310-34cb3982ef74?auto=format&fit=crop&w=600&q=80', img2: 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=600&q=80', height: '100vh' },
{ type: 'one-large-five-small', title: '相恋', subtitle: 'ROMANCE', desc: '星光坠入眼眸,晚风轻抚过裙摆。\n浪漫不止于此还有无数个明天。', images: fill(6), height: '100vh' },
{ type: 'nine-grid', title: '相守', subtitle: 'FOREVER', desc: '往后余生,风雪是你,平淡是你。', images: fill(9), height: '100vh' },
],
schedule: [
{ time: '16:00', event: '签到迎宾', desc: 'Welcome' },
@@ -248,20 +297,56 @@ function fill(n) {
return Array.from({ length: n }, () => 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=400&q=80')
}
// 页面高度预设
const HEIGHT_PRESETS = [
{ label: '满屏(整屏)', value: '100vh' },
{ label: '大屏80%', value: '80vh' },
{ label: '半屏60%', value: '60vh' },
{ label: '自适应(内容高度)', value: 'auto' },
{ label: '自定义', value: '__custom__' },
]
function heightSelectValue(page) {
const match = HEIGHT_PRESETS.find((p) => p.value === page.height)
return match ? match.value : '__custom__'
}
function onHeightModeChange(page, val) {
if (val !== '__custom__') page.height = val
}
function customVh(page) {
const m = /^([\d.]+)vh$/.exec(page.height || '')
return m ? Number(m[1]) : 100
}
function onCustomVh(page, val) {
page.height = (val || 100) + 'vh'
}
function ensurePageShape(p) {
if (p.type === 'single') { p.img1 = p.img1 || ''; delete p.img2; delete p.images }
else if (p.type === 'overlap') { p.img1 = p.img1 || ''; p.img2 = p.img2 || ''; delete p.images }
else {
const need = p.type === 'one-large-five-small' ? 6 : 9
// 固定数量版式:一大五小(6) / 九宫格(9);柔性版式:瀑布流/轮播/拍立得,仅首次补齐
const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
const need = fixed[p.type] || 6
p.images = Array.isArray(p.images) ? p.images : []
while (p.images.length < need) p.images.push('')
if (p.images.length > need) p.images.length = need
if (p.images.length === 0) while (p.images.length < need) p.images.push('')
else if (['one-large-five-small', 'nine-grid'].includes(p.type)) {
while (p.images.length < need) p.images.push('')
if (p.images.length > need) p.images.length = need
}
delete p.img1; delete p.img2
}
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
p.height = p.height || '100vh'
return p
}
// 柔性版式(图片数量可增删)
function isFlexible(type) {
return ['masonry', 'carousel', 'polaroid'].includes(type)
}
function addImage(page) { if (!Array.isArray(page.images)) page.images = []; page.images.push('') }
function removeImage(page, i) { if (page.images.length > 1) page.images.splice(i, 1) }
// 相册页操作
function addPage() { cfg.photoPages.push(ensurePageShape({ type: 'single', title: '新页面', subtitle: 'NEW', desc: '', img1: '' })) }
function removePage(i) { cfg.photoPages.splice(i, 1) }
@@ -270,7 +355,7 @@ function movePage(i, dir) { const j = i + dir; if (j < 0 || j >= cfg.photoPages.
function addSchedule() { cfg.schedule.push({ time: '', event: '', desc: '' }) }
function removeSchedule(i) { cfg.schedule.splice(i, 1) }
// 上传
// 上传(图片)
function openUpload(onUrl) {
const input = document.createElement('input')
input.type = 'file'; input.accept = 'image/*'
@@ -289,6 +374,42 @@ function openUpload(onUrl) {
input.click()
}
// 上传(背景音乐,多首)
function openMusicUpload() {
const input = document.createElement('input')
input.type = 'file'; input.accept = 'audio/*'
input.onchange = async (e) => {
const file = e.target.files[0]; if (!file) return
uploading.value = true
try {
const res = await uploadImage(file) // 后端 /upload 同处理任意文件
const name = file.name.replace(/\.[^.]+$/, '') || '背景音乐'
const url = res.url
if (!cfg.musicList) cfg.musicList = []
cfg.musicList.push({ url, name })
if (!cfg.activeMusicUrl) cfg.activeMusicUrl = url
message.success('音乐上传成功')
} catch (err) {
message.error('上传失败')
} finally {
uploading.value = false; e.target.value = ''
}
}
input.click()
}
function setActiveMusic(url) { cfg.activeMusicUrl = url }
function removeMusic(i) {
cfg.musicList.splice(i, 1)
if (cfg.activeMusicUrl === cfg.musicList[i]?.url) {
cfg.activeMusicUrl = cfg.musicList[0]?.url || ''
}
}
function shortUrl(url) {
if (!url) return ''
const parts = url.split('/')
return parts[parts.length - 1] || url
}
// 保存
async function onSave() {
saving.value = true
@@ -337,6 +458,11 @@ onMounted(async () => {
const res = await getConfig()
if (res.data && res.data !== '{}') {
const loaded = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
// 兼容旧版单链接 musicUrl
if (loaded.musicUrl && !(loaded.musicList && loaded.musicList.length)) {
loaded.musicList = [{ url: loaded.musicUrl, name: '背景音乐' }]
loaded.activeMusicUrl = loaded.musicUrl
}
Object.assign(cfg, loaded)
cfg.photoPages = (cfg.photoPages || []).map(ensurePageShape)
}