2026-07-30 18:00:27 +08:00
< template >
< div class = "min-h-screen bg-[#f6f4ef] text-[#2c2c2c]" >
<!-- 顶部栏 -- >
< header class = "sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-[#a88c6b]/15 px-6 py-3 flex items-center justify-between" >
< div class = "flex items-center gap-3" >
< span class = "text-xl text-[#a88c6b] font-serif" > ❀ < / span >
< div >
< div class = "text-sm tracking-[0.2em] font-light" > 婚礼纪 · 内容编辑后台 < / div >
< div class = "text-[10px] text-[#8A8680] tracking-[0.3em] uppercase" > Invitation Admin < / div >
< / div >
< / div >
< div class = "flex items-center gap-2" >
< a-button size = "small" @click ="openPreview" >
< i class = "fa-solid fa-eye mr-1 text-[#a88c6b]" > < / i > 预览请柬
< / a-button >
< a-button type = "primary" size = "small" :loading = "saving" @click ="onSave"
class = "!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]" >
< i class = "fa-solid fa-floppy-disk mr-1" > < / i > 保存配置
< / a-button >
< a-dropdown >
< a-button size = "small" > { { adminStore . user ? . account || '管理员' } } < i class = "fa-solid fa-chevron-down ml-1 text-[10px]" > < / i > < / a-button >
< template # overlay >
< a-menu >
< a-menu-item key = "logout" @click ="onLogout" > < i class = "fa-solid fa-right-from-bracket mr-2" > < / i > 退出登录 < / a-menu-item >
< / a-menu >
< / template >
< / a-dropdown >
< / div >
< / header >
< div class = "max-w-[960px] mx-auto px-4 py-6" >
2026-07-31 13:09:49 +08:00
< a-tabs v -model :activeKey = "activeTab" type = "card" class = "admin-tabs" :destroyInactiveTabPane = "true" >
2026-07-30 18:00:27 +08:00
<!-- 基础信息 -- >
< a-tab-pane key = "basic" tab = "基础信息" >
2026-07-31 13:09:49 +08:00
< a-card v-if = "activeTab === 'basic'" :bordered="false" class="!shadow-sm" >
2026-07-30 18:00:27 +08:00
< a-form layout = "vertical" >
< div class = "grid grid-cols-1 md:grid-cols-2 gap-x-6" >
< a-form-item label = "新郎" >
< a-input v -model :value = "cfg.groom" placeholder = "新郎姓名" / >
< / a-form-item >
< a-form-item label = "新娘" >
< a-input v -model :value = "cfg.bride" placeholder = "新娘姓名" / >
< / a-form-item >
< a-form-item label = "公历日期" >
< a-input v -model :value = "cfg.date" placeholder = "如 2026-05-20" / >
< / a-form-item >
< a-form-item label = "农历 / 星期" >
< a-input v -model :value = "cfg.lunar" placeholder = "如 农历四月初四 星期三" / >
< / a-form-item >
2026-07-31 15:26:55 +08:00
< a-form-item label = "日历圈选日期" >
< a-input v -model :value = "cfg.calendarDate" placeholder = "如 2026-09-04" / >
< div class = "text-[11px] text-[#8A8680] mt-1" > 前台婚礼日历会展示该日期所在月份 , 并用爱心圈出这一天 。 < / div >
< / a-form-item >
2026-07-30 18:00:27 +08:00
< a-form-item label = "酒店名称" >
< a-input v -model :value = "cfg.hotel" placeholder = "宴会酒店" / >
< / a-form-item >
< a-form-item label = "详细地址" >
< a-textarea v -model :value = "cfg.address" :rows = "2" placeholder = "可换行" / >
< / a-form-item >
< / div >
2026-07-31 13:09:49 +08:00
< div class = "grid grid-cols-1 md:grid-cols-2 gap-x-6" >
< a-form-item label = "封面图片" >
< image-field v-model = "cfg.heroImg" label="封面" @pick="openUpload(u => cfg.heroImg = u)" :uploading="uploading" / >
< div class = "text-[11px] text-[#8A8680] mt-2" > 用于首页封面和入场揭幕背景 。 < / div >
< / a-form-item >
< a-form-item label = "尾页图片" >
< image-field v-model = "cfg.endImg" label="尾页" @pick="openUpload(u => cfg.endImg = u)" :uploading="uploading" / >
< div class = "text-[11px] text-[#8A8680] mt-2" > 用于最后的回执尾页 , 可与封面分开设置 。 < / div >
< / a-form-item >
< / div >
2026-07-30 18:00:27 +08:00
< / a-form >
< / a-card >
< / a-tab-pane >
<!-- 文案 -- >
< a-tab-pane key = "copy" tab = "邀请文案" >
2026-07-31 13:09:49 +08:00
< a-card v-if = "activeTab === 'copy'" :bordered="false" class="!shadow-sm" >
2026-07-30 18:00:27 +08:00
< a-form layout = "vertical" >
< a-form-item label = "竖排文案 · 上联" >
< a-input v -model :value = "cfg.formalText1" placeholder = "如 两姓联姻 一堂缔约" / >
< / a-form-item >
< a-form-item label = "竖排文案 · 下联" >
< a-input v -model :value = "cfg.formalText2" placeholder = "如 良缘永结 匹配同称" / >
< / a-form-item >
2026-07-31 15:03:46 +08:00
< a-form-item label = "邀请文案页高度" >
< div class = "flex gap-2 items-center" >
< a-select :value = "heightFieldSelectValue(cfg.formalPageHeight)" @change ="(v) => onHeightFieldModeChange(cfg, 'formalPageHeight', 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 = "heightFieldSelectValue(cfg.formalPageHeight) === '__custom__'"
: value = "customVhValue(cfg.formalPageHeight)" @ change = "(v) => onHeightFieldCustomVh(cfg, 'formalPageHeight', v)"
: min = "20" : max = "200" addon - after = "vh" class = "w-[150px]" / >
< / div >
< div class = "text-[11px] text-[#8A8680] mt-1" > 控制前台竖排邀请文案页的展示高度 , 默认满屏 。 < / div >
< / a-form-item >
2026-07-30 18:00:27 +08:00
< / a-form >
< / a-card >
< / a-tab-pane >
<!-- 视觉 / 动效 -- >
< a-tab-pane key = "visual" tab = "视觉与动效" >
2026-07-31 13:09:49 +08:00
< a-card v-if = "activeTab === 'visual'" :bordered="false" class="!shadow-sm" >
2026-07-30 18:00:27 +08:00
< a-form layout = "vertical" >
2026-07-31 09:05:15 +08:00
< a-form-item label = "背景音乐" >
< div class = "text-[12px] text-[#8A8680] leading-relaxed" > 多首背景音乐的上传 、 试听与 「 设为播放 」 请在 < span class = "text-[#a88c6b]" > 背景音乐 < / span > 标签页管理 。 < / div >
2026-07-30 18:00:27 +08:00
< / a-form-item >
2026-07-31 15:26:55 +08:00
< a-form-item label = "入场揭幕退场方式" >
< a-select v -model :value = "cfg.introExitEffect" >
< a-select-option v-for = "opt in INTRO_EXIT_EFFECTS" :key="opt.value" :value="opt.value" > {{ opt.label }} < / a -select -option >
< / a-select >
< div class = "text-[11px] text-[#8A8680] mt-1" > 点击开启请柬时的退场动画 , 可按婚礼调性选择更轻盈或更有仪式感的方式 。 < / div >
< / a-form-item >
2026-07-30 18:00:27 +08:00
< a-form-item label = "滚动方式" >
< a-radio-group v -model :value = "cfg.scrollMode" >
< a-radio value = "snap" > 整页 / 分屏吸附滚动 < / a-radio >
< a-radio value = "free" > 自由滚动 < / a-radio >
< / a-radio-group >
< div class = "text-[11px] text-[#8A8680] mt-1" > 整页模式会带来更有仪式感的逐屏切换效果 。 < / div >
< / a-form-item >
2026-07-31 13:20:01 +08:00
< div class = "grid grid-cols-1 md:grid-cols-3 gap-x-6" >
2026-07-31 11:36:19 +08:00
< a-form-item label = "自由滚动速度(滚动间隔)" >
< div class = "flex gap-3 items-center" >
< a-slider v -model :value = "cfg.freeScrollInterval" :min = "10" :max = "500" class = "flex-1" / >
< a-input-number v -model :value = "cfg.freeScrollInterval" :min = "10" :max = "500" addon -after = " ms " class = "w-[120px]" / >
< / div >
< div class = "text-[11px] text-[#8A8680] mt-1" > 间隔越小滚动越快 , 默认 50 ms 。 仅在 「 自由滚动 」 模式下生效 。 < / div >
< / a-form-item >
< a-form-item label = "页面切换速度(动画时长)" >
< div class = "flex gap-3 items-center" >
< a-slider v -model :value = "cfg.pageSwitchDuration" :min = "200" :max = "3000" :step = "100" class = "flex-1" / >
< a-input-number v -model :value = "cfg.pageSwitchDuration" :min = "200" :max = "3000" :step = "100" addon -after = " ms " class = "w-[120px]" / >
< / div >
< div class = "text-[11px] text-[#8A8680] mt-1" > 整页 / 分屏模式下自动翻页的动画时长 , 默认 800 ms 。 < / div >
< / a-form-item >
2026-07-31 13:20:01 +08:00
< a-form-item label = "首屏停留时间" >
< div class = "flex gap-3 items-center" >
< a-slider v -model :value = "cfg.firstScreenDuration" :min = "0" :max = "12000" :step = "100" class = "flex-1" / >
< a-input-number v -model :value = "cfg.firstScreenDuration" :min = "0" :max = "12000" :step = "100" addon -after = " ms " class = "w-[120px]" / >
< / div >
< div class = "text-[11px] text-[#8A8680] mt-1" > 首屏效果结束后等待多久开始自动滚动 , 默认 4500 ms 。 < / div >
< / a-form-item >
2026-07-31 11:36:19 +08:00
< / div >
2026-07-30 18:00:27 +08:00
< div class = "grid grid-cols-1 md:grid-cols-3 gap-4" >
< a-form-item label = "飘落花瓣" >
< a-switch v -model :checked = "cfg.petalEnabled" / >
< / a-form-item >
< a-form-item label = "漂浮爱心" >
< a-switch v -model :checked = "cfg.bubbleEnabled" / >
< / a-form-item >
< a-form-item label = "入场揭幕" >
< a-switch v -model :checked = "cfg.introEnabled" / >
< / a-form-item >
< / div >
< / a-form >
< / a-card >
< / a-tab-pane >
2026-07-31 09:05:15 +08:00
<!-- 背景音乐 -- >
< a-tab-pane key = "music" tab = "背景音乐" >
2026-07-31 13:09:49 +08:00
< a-card v-if = "activeTab === 'music'" :bordered="false" class="!shadow-sm" >
2026-07-31 09:05:15 +08:00
< 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 >
2026-07-31 13:09:49 +08:00
<!-- 上传设置 -- >
< a-tab-pane key = "upload" tab = "上传设置" >
< a-card v-if = "activeTab === 'upload'" :bordered="false" class="!shadow-sm" >
< a-alert
type = "info"
show - icon
class = "mb-4"
message = "上传设置仅后台可见, OSS 密钥不会写入公开请柬配置。"
/ >
< a-form layout = "vertical" >
< a-form-item label = "上传方式" >
< a-radio-group v -model :value = "uploadCfg.provider" >
< a-radio value = "local" > 本地上传 < / a-radio >
< a-radio value = "oss" > 阿里云 OSS < / a-radio >
< / a-radio-group >
< / a-form-item >
< div v-if = "uploadCfg.provider === 'oss'" class="grid grid-cols-1 md:grid-cols-2 gap-x-6" >
< a-form-item label = "AccessKeyId" >
< a-input v -model :value = "uploadCfg.accessKeyId" placeholder = "请输入 AccessKeyId" / >
< / a-form-item >
< a-form-item label = "AccessKeySecret" >
< a-input-password v -model :value = "uploadCfg.accessKeySecret" : placeholder = "uploadCfg.hasSecret ? '已配置,留空则不修改' : '请输入 AccessKeySecret'" / >
< / a-form-item >
< a-form-item label = "Bucket" >
< a-input v -model :value = "uploadCfg.bucket" placeholder = "如 wedding-assets" / >
< / a-form-item >
< a-form-item label = "文件夹" >
< a-input v -model :value = "uploadCfg.folder" placeholder = "如 invitations" / >
< / a-form-item >
< a-form-item label = "访问域名" >
< a-input v -model :value = "uploadCfg.domain" placeholder = "如 https://cdn.example.com, 可留空" / >
< / a-form-item >
< a-form-item label = "Region" >
< a-input v -model :value = "uploadCfg.region" placeholder = "如 cn-hangzhou" / >
< / a-form-item >
< a-form-item label = "Endpoint" >
< a-input v -model :value = "uploadCfg.endpoint" placeholder = "如 https://oss-cn-hangzhou.aliyuncs.com" / >
< / a-form-item >
< / div >
< div v-else class = "text-[12px] text-[#8A8680] leading-relaxed mb-4" >
本地上传会继续保存到后端 uploads 目录 , 返回 http : //localhost:8080/uploads/ 开头的地址。
< / div >
< a-button type = "primary" :loading = "uploadConfigSaving" @click ="onSaveUploadConfig"
class = "!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]" >
< i class = "fa-solid fa-floppy-disk mr-1" > < / i > 保存上传设置
< / a-button >
< / a-form >
< / a-card >
< / a-tab-pane >
2026-07-30 18:00:27 +08:00
< a-tab-pane key = "photos" tab = "相册页管理" >
< div class = "flex justify-between items-center mb-3" >
< span class = "text-[12px] text-[#8A8680]" > 共 { { cfg . photoPages . length } } 个相册页 < / span >
< a-button size = "small" @click ="addPage" > < i class = "fa-solid fa-plus mr-1 text-[#a88c6b]" > < / i > 新增相册页 < / a-button >
< / div >
< a-empty v-if = "!cfg.photoPages.length" description="暂无相册页" / >
2026-07-31 13:09:49 +08:00
< a-tabs v-if = "cfg.photoPages.length" v-model:activeKey="activePhotoTab" size="small" type="card" class="photo-page-tabs" :destroyInactiveTabPane="true" >
< a-tab-pane v-for = "(page, idx) in cfg.photoPages" :key="String(idx)" :tab="`第 ${idx + 1} 页`" / >
< / a-tabs >
< template v-for = "(page, idx) in cfg.photoPages" :key="idx" >
< a-card v-if = "idx === currentPhotoIndex" :bordered="false" class="!shadow-sm mb-4" >
2026-07-30 18:00:27 +08:00
< div class = "flex justify-between items-center mb-3" >
< span class = "text-[#a88c6b] text-sm" > 第 { { idx + 1 } } 页 < / span >
< div class = "flex gap-1" >
< a-button size = "small" :disabled = "idx===0" @click ="movePage(idx,-1)" > < i class = "fa-solid fa-arrow-up" > < / i > < / a-button >
< a-button size = "small" :disabled = "idx===cfg.photoPages.length-1" @click ="movePage(idx,1)" > < i class = "fa-solid fa-arrow-down" > < / i > < / a-button >
< a-button size = "small" danger @click ="removePage(idx)" > < i class = "fa-solid fa-trash" > < / i > < / a-button >
< / div >
< / div >
< div class = "grid grid-cols-1 md:grid-cols-2 gap-x-6" >
< a-form-item label = "版式" >
< a-select v -model :value = "page.type" @change ="() => ensurePageShape(page)" >
< a-select-option value = "single" > 单图 < / a-select-option >
< 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 >
2026-07-31 09:05:15 +08:00
< a-select-option value = "masonry" > 瀑布流 < / a-select-option >
< a-select-option value = "carousel" > 轮播滑动 < / a-select-option >
< a-select-option value = "polaroid" > 拍立得拼贴 < / a-select-option >
2026-07-30 18:00:27 +08:00
< / a-select >
< / a-form-item >
2026-07-31 09:05:15 +08:00
< 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 >
2026-07-31 10:29:14 +08:00
< div class = "grid grid-cols-1 md:grid-cols-2 gap-x-6 items-start" >
< a-form-item label = "边框样式" >
< a-select v -model :value = "page.borderStyle" >
< a-select-option v-for = "b in BORDER_STYLES" :key="b.value" :value="b.value" > {{ b.label }} < / a -select -option >
< / a-select >
< / a-form-item >
< div class = "flex items-center gap-3" >
< span class = "text-[11px] text-[#a88c6b]" > 预览 < / span >
< div class = "w-[70px] h-[88px] shrink-0" >
< div :class = "borderClass(page.borderStyle)" style = "width:100%;height:100%" >
< img src = "https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=200&q=80" / >
< / div >
< / div >
< / div >
< / div >
2026-07-31 11:36:19 +08:00
<!-- 单图版式的宽度设置 ( 逐页配置 ) -- >
< div v-if = "page.type==='single'" class="grid grid-cols-1 md:grid-cols-2 gap-x-6 items-start" >
< a-form-item label = "图片宽度" >
< a-radio-group v -model :value = "page.singleWidth" >
< a-radio value = "default" > 默认 ( 居中卡幅 ) < / a-radio >
< a-radio value = "full" > 宽度铺满 < / a-radio >
< / a-radio-group >
< / a-form-item >
< a-form-item v-if = "page.singleWidth==='full'" label="保持原始宽高比" >
< a-switch v -model :checked = "page.singleKeepRatio" / >
< div class = "text-[11px] text-[#8A8680] mt-1" > 开启 : 按图片原始比例显示 ; 关闭 : 固定 4 : 5 比例裁切铺满 。 < / div >
< / a-form-item >
< / div >
2026-07-31 09:05:15 +08:00
< div class = "grid grid-cols-1 md:grid-cols-2 gap-x-6" >
2026-07-30 18:00:27 +08:00
< a-form-item label = "小标题 (英文)" >
< a-input v -model :value = "page.subtitle" / >
< / a-form-item >
< a-form-item label = "标题" >
< a-input v -model :value = "page.title" / >
< / a-form-item >
< / div >
< a-form-item label = "描述" >
< a-textarea v -model :value = "page.desc" :rows = "2" / >
< / a-form-item >
<!-- 图片上传区 : 按版式渲染 -- >
< div class = "flex flex-wrap gap-3 mt-2" >
< template v-if = "page.type==='single'" >
< image-field v-model = "page.img1" @pick="openUpload(u => page.img1 = u)" :uploading="uploading" / >
< / template >
< template v-else-if = "page.type==='overlap'" >
< image-field v-model = "page.img1" label="图一" @pick="openUpload(u => page.img1 = u)" :uploading="uploading" / >
< image-field v-model = "page.img2" label="图二" @pick="openUpload(u => page.img2 = u)" :uploading="uploading" / >
< / template >
< template v-else >
2026-07-31 09:05:15 +08:00
< 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 >
2026-07-31 12:13:41 +08:00
<!-- 排序 : 前移 / 后移 -- >
< button v-if = "iidx > 0"
class = "absolute -bottom-2 left-1 w-5 h-5 rounded-full bg-white border border-[#a88c6b]/50 text-[#a88c6b] text-[10px] leading-none flex items-center justify-center shadow hover:bg-[#a88c6b] hover:text-white transition-colors"
@ click = "moveImage(page, iidx, -1)" title = "前移" > < i class = "fa-solid fa-arrow-left" > < / i > < / button >
< button v-if = "iidx < page.images.length - 1"
class = "absolute -bottom-2 right-1 w-5 h-5 rounded-full bg-white border border-[#a88c6b]/50 text-[#a88c6b] text-[10px] leading-none flex items-center justify-center shadow hover:bg-[#a88c6b] hover:text-white transition-colors"
@ click = "moveImage(page, iidx, 1)" title = "后移" > < i class = "fa-solid fa-arrow-right" > < / i > < / button >
2026-07-31 09:05:15 +08:00
< / 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 >
2026-07-30 18:00:27 +08:00
< / template >
< / div >
< / a-card >
2026-07-31 13:09:49 +08:00
< / template >
2026-07-30 18:00:27 +08:00
< / a-tab-pane >
<!-- 婚礼流程 -- >
< a-tab-pane key = "schedule" tab = "婚礼流程" >
< div class = "flex justify-between items-center mb-3" >
< span class = "text-[12px] text-[#8A8680]" > 共 { { cfg . schedule . length } } 个环节 < / span >
< a-button size = "small" @click ="addSchedule" > < i class = "fa-solid fa-plus mr-1 text-[#a88c6b]" > < / i > 新增环节 < / a-button >
< / div >
< a-card v-for = "(item, idx) in cfg.schedule" :key="idx" :bordered="false" class="!shadow-sm mb-3" >
< div class = "flex items-end gap-3 flex-wrap" >
< div class = "flex-1 min-w-[120px]" >
< div class = "text-[11px] text-[#a88c6b] mb-1" > 时间 < / div >
< a-input v -model :value = "item.time" placeholder = "如 17:08" / >
< / div >
< div class = "flex-1 min-w-[140px]" >
< div class = "text-[11px] text-[#a88c6b] mb-1" > 环节 < / div >
< a-input v -model :value = "item.event" placeholder = "如 仪式开始" / >
< / div >
< div class = "flex-1 min-w-[140px]" >
< div class = "text-[11px] text-[#a88c6b] mb-1" > 英文副标题 < / div >
< a-input v -model :value = "item.desc" placeholder = "如 Ceremony" / >
< / div >
< a-button danger size = "small" @click ="removeSchedule(idx)" > < i class = "fa-solid fa-trash" > < / i > < / a-button >
< / div >
< / a-card >
< / a-tab-pane >
<!-- 回执列表 -- >
< a-tab-pane key = "rsvp" tab = "出席回执" >
< a-table :columns = "rsvpColumns" :data-source = "rsvpList" :loading = "rsvpLoading" row -key = " id " size = "small" : pagination = "{ pageSize: 8 }" >
< template # bodyCell = "{ column, record }" >
< template v-if = "column.key==='guest_count'" >
< a-tag : color = "record.guest_count==='0' ? 'default' : 'pink'" > { { rsvpCountLabel ( record . guest _count ) } } < / a-tag >
< / template >
< template v-else-if = "column.key==='created_at'" >
{ { formatTime ( record . created _at ) } }
< / template >
< / template >
< / a-table >
< / a-tab-pane >
< / a-tabs >
< / div >
< / div >
< / template >
< script setup >
2026-07-31 13:09:49 +08:00
import { computed , reactive , ref , onMounted } from 'vue'
2026-07-30 18:00:27 +08:00
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
2026-07-31 13:09:49 +08:00
import { getConfig , saveConfig , uploadImage , getRsvpList , getUploadConfig , saveUploadConfig } from '@/api/wedding'
2026-07-30 18:00:27 +08:00
import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
2026-07-31 10:29:14 +08:00
import { BORDER _STYLES , borderClass } from '@/composables/borderStyles'
2026-07-30 18:00:27 +08:00
const router = useRouter ( )
const adminStore = useAdminStore ( )
const activeTab = ref ( 'basic' )
2026-07-31 13:09:49 +08:00
const activePhotoTab = ref ( '0' )
2026-07-30 18:00:27 +08:00
const saving = ref ( false )
const uploading = ref ( false )
2026-07-31 13:09:49 +08:00
const uploadConfigSaving = ref ( false )
2026-07-30 18:00:27 +08:00
const rsvpLoading = ref ( false )
const rsvpList = ref ( [ ] )
const cfg = reactive ( defaultConfig ( ) )
2026-07-31 13:09:49 +08:00
const uploadCfg = reactive ( defaultUploadConfig ( ) )
const currentPhotoIndex = computed ( ( ) => Math . min ( Number ( activePhotoTab . value ) || 0 , Math . max ( cfg . photoPages . length - 1 , 0 ) ) )
2026-07-30 18:00:27 +08:00
function defaultConfig ( ) {
return {
groom : '李明' , bride : '王华' , date : '2026-05-20' , lunar : '农历四月初四 星期三' ,
hotel : '杭州星光万丽大酒店' , address : '杭州市西湖区星光大道88号\n3楼大宴会厅' ,
2026-07-31 15:03:46 +08:00
formalText1 : '两姓联姻 一堂缔约' , formalText2 : '良缘永结 匹配同称' , formalPageHeight : '100vh' ,
2026-07-31 15:26:55 +08:00
calendarDate : '2026-09-04' ,
2026-07-30 18:00:27 +08:00
heroImg : 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80' ,
2026-07-31 13:09:49 +08:00
endImg : 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80' ,
2026-07-31 09:05:15 +08:00
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' ,
2026-07-30 18:00:27 +08:00
scrollMode : 'snap' , petalEnabled : true , bubbleEnabled : true , introEnabled : true ,
2026-07-31 15:26:55 +08:00
introExitEffect : 'wind' ,
2026-07-31 13:20:01 +08:00
freeScrollInterval : 50 , pageSwitchDuration : 800 , firstScreenDuration : 4500 ,
2026-07-30 18:00:27 +08:00
photoPages : [
2026-07-31 10:29:14 +08:00
{ 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' , borderStyle : 'mat' } ,
{ 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' , borderStyle : 'mat' } ,
{ type : 'one-large-five-small' , title : '相恋' , subtitle : 'ROMANCE' , desc : '星光坠入眼眸,晚风轻抚过裙摆。\n浪漫不止于此, 还有无数个明天。' , images : fill ( 6 ) , height : '100vh' , borderStyle : 'mat' } ,
{ type : 'nine-grid' , title : '相守' , subtitle : 'FOREVER' , desc : '往后余生,风雪是你,平淡是你。' , images : fill ( 9 ) , height : '100vh' , borderStyle : 'mat' } ,
2026-07-30 18:00:27 +08:00
] ,
schedule : [
{ time : '16:00' , event : '签到迎宾' , desc : 'Welcome' } ,
{ time : '17:08' , event : '仪式开始' , desc : 'Ceremony' } ,
{ time : '18:00' , event : '敬备喜宴' , desc : 'Banquet' } ,
] ,
}
}
2026-07-31 13:09:49 +08:00
function defaultUploadConfig ( ) {
return {
provider : 'local' ,
accessKeyId : '' ,
accessKeySecret : '' ,
hasSecret : false ,
bucket : '' ,
folder : '' ,
domain : '' ,
region : '' ,
endpoint : '' ,
}
}
2026-07-30 18:00:27 +08:00
function fill ( n ) {
return Array . from ( { length : n } , ( ) => 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=400&q=80' )
}
2026-07-31 09:05:15 +08:00
// 页面高度预设
const HEIGHT _PRESETS = [
{ label : '满屏(整屏)' , value : '100vh' } ,
{ label : '大屏( 80%) ' , value : '80vh' } ,
{ label : '半屏( 60%) ' , value : '60vh' } ,
{ label : '自适应(内容高度)' , value : 'auto' } ,
{ label : '自定义' , value : '__custom__' } ,
]
2026-07-31 15:26:55 +08:00
const INTRO _EXIT _EFFECTS = [
{ label : '风吹纸页' , value : 'wind' } ,
{ label : '翻书开启' , value : 'book' } ,
{ label : '上提淡出' , value : 'lift' } ,
{ label : '双门揭幕' , value : 'doors' } ,
]
2026-07-31 09:05:15 +08:00
function heightSelectValue ( page ) {
2026-07-31 15:03:46 +08:00
return heightFieldSelectValue ( page . height )
2026-07-31 09:05:15 +08:00
}
function onHeightModeChange ( page , val ) {
if ( val !== '__custom__' ) page . height = val
}
function customVh ( page ) {
2026-07-31 15:03:46 +08:00
return customVhValue ( page . height )
2026-07-31 09:05:15 +08:00
}
function onCustomVh ( page , val ) {
page . height = ( val || 100 ) + 'vh'
}
2026-07-31 15:03:46 +08:00
function heightFieldSelectValue ( value ) {
const match = HEIGHT _PRESETS . find ( ( p ) => p . value === value )
return match ? match . value : '__custom__'
}
function customVhValue ( value ) {
const m = /^([\d.]+)vh$/ . exec ( value || '' )
return m ? Number ( m [ 1 ] ) : 100
}
function onHeightFieldModeChange ( target , key , val ) {
if ( val !== '__custom__' ) target [ key ] = val
}
function onHeightFieldCustomVh ( target , key , val ) {
target [ key ] = ( val || 100 ) + 'vh'
}
2026-07-31 09:05:15 +08:00
2026-07-30 18:00:27 +08:00
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 {
2026-07-31 09:05:15 +08:00
// 固定数量版式:一大五小(6) / 九宫格(9);柔性版式:瀑布流/轮播/拍立得,仅首次补齐
const fixed = { 'one-large-five-small' : 6 , 'nine-grid' : 9 , 'masonry' : 6 , 'carousel' : 5 , 'polaroid' : 6 }
const need = fixed [ p . type ] || 6
2026-07-30 18:00:27 +08:00
p . images = Array . isArray ( p . images ) ? p . images : [ ]
2026-07-31 09:05:15 +08:00
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
}
2026-07-30 18:00:27 +08:00
delete p . img1 ; delete p . img2
}
2026-07-31 09:05:15 +08:00
p . title = p . title || '' ; p . subtitle = p . subtitle || '' ; p . desc = p . desc || ''
p . height = p . height || '100vh'
2026-07-31 10:29:14 +08:00
p . borderStyle = p . borderStyle || 'mat'
2026-07-31 11:36:19 +08:00
// 单图宽度设置(仅 single 版式使用,逐页配置)
p . singleWidth = p . singleWidth === 'full' ? 'full' : 'default'
if ( typeof p . singleKeepRatio !== 'boolean' ) p . singleKeepRatio = true
2026-07-30 18:00:27 +08:00
return p
}
2026-07-31 09:05:15 +08:00
// 柔性版式(图片数量可增删)
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 ) }
2026-07-31 12:13:41 +08:00
// 图片排序:前移(-1) / 后移(+1)
function moveImage ( page , i , dir ) {
const j = i + dir
if ( ! Array . isArray ( page . images ) || j < 0 || j >= page . images . length ) return
const t = page . images [ i ] ; page . images [ i ] = page . images [ j ] ; page . images [ j ] = t
}
2026-07-31 09:05:15 +08:00
2026-07-30 18:00:27 +08:00
// 相册页操作
2026-07-31 13:09:49 +08:00
function setActivePhotoIndex ( i ) {
const max = Math . max ( cfg . photoPages . length - 1 , 0 )
activePhotoTab . value = String ( Math . min ( Math . max ( i , 0 ) , max ) )
}
function addPage ( ) {
cfg . photoPages . push ( ensurePageShape ( { type : 'single' , title : '新页面' , subtitle : 'NEW' , desc : '' , img1 : '' } ) )
setActivePhotoIndex ( cfg . photoPages . length - 1 )
}
function removePage ( i ) {
cfg . photoPages . splice ( i , 1 )
setActivePhotoIndex ( Math . min ( i , cfg . photoPages . length - 1 ) )
}
function movePage ( i , dir ) {
const j = i + dir
if ( j < 0 || j >= cfg . photoPages . length ) return
const t = cfg . photoPages [ i ] ; cfg . photoPages [ i ] = cfg . photoPages [ j ] ; cfg . photoPages [ j ] = t
setActivePhotoIndex ( j )
}
2026-07-30 18:00:27 +08:00
// 流程操作
function addSchedule ( ) { cfg . schedule . push ( { time : '' , event : '' , desc : '' } ) }
function removeSchedule ( i ) { cfg . schedule . splice ( i , 1 ) }
2026-07-31 09:05:15 +08:00
// 上传(图片)
2026-07-30 18:00:27 +08:00
function openUpload ( onUrl ) {
const input = document . createElement ( 'input' )
input . type = 'file' ; input . accept = 'image/*'
input . onchange = async ( e ) => {
const file = e . target . files [ 0 ] ; if ( ! file ) return
uploading . value = true
try {
const res = await uploadImage ( file )
onUrl ( res . url ) ; message . success ( '上传成功' )
} catch ( err ) {
message . error ( '上传失败' )
} finally {
uploading . value = false ; e . target . value = ''
}
}
input . click ( )
}
2026-07-31 09:05:15 +08:00
// 上传(背景音乐,多首)
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
}
2026-07-31 13:09:49 +08:00
async function loadUploadConfig ( ) {
try {
const res = await getUploadConfig ( )
Object . assign ( uploadCfg , defaultUploadConfig ( ) , res . data || { } )
uploadCfg . accessKeySecret = ''
} catch ( e ) {
// 上传设置仅后台使用,读取失败不影响内容编辑。
}
}
async function onSaveUploadConfig ( ) {
uploadConfigSaving . value = true
try {
const payload = {
provider : uploadCfg . provider ,
accessKeyId : uploadCfg . accessKeyId ,
accessKeySecret : uploadCfg . accessKeySecret ,
bucket : uploadCfg . bucket ,
folder : uploadCfg . folder ,
domain : uploadCfg . domain ,
region : uploadCfg . region ,
endpoint : uploadCfg . endpoint ,
}
const res = await saveUploadConfig ( payload )
Object . assign ( uploadCfg , defaultUploadConfig ( ) , res . data || { } )
uploadCfg . accessKeySecret = ''
message . success ( '上传设置已保存' )
} catch ( e ) {
message . error ( e ? . message || '上传设置保存失败' )
} finally {
uploadConfigSaving . value = false
}
}
2026-07-30 18:00:27 +08:00
// 保存
async function onSave ( ) {
saving . value = true
try {
await saveConfig ( JSON . parse ( JSON . stringify ( cfg ) ) )
message . success ( '配置已保存' )
} catch ( e ) {
const msg = e ? . response ? . status === 401 ? '登录已失效,请重新登录' : ( e ? . message || '保存失败' )
message . error ( msg )
if ( e ? . response ? . status === 401 ) router . replace ( { name : 'Login' , query : { redirect : '/admin' } } )
} finally {
saving . value = false
}
}
function openPreview ( ) { window . open ( '/' , '_blank' ) }
function onLogout ( ) {
adminStore . logout ( )
router . replace ( { name : 'Login' , query : { redirect : '/admin' } } )
}
// RSVP
const rsvpColumns = [
{ title : '姓名' , dataIndex : 'name' , key : 'name' } ,
{ title : '出席' , dataIndex : 'guest_count' , key : 'guest_count' } ,
{ title : '祝福' , dataIndex : 'wishes' , key : 'wishes' , ellipsis : true } ,
{ title : '提交时间' , dataIndex : 'created_at' , key : 'created_at' } ,
]
const rsvpCountLabel = ( v ) => ( { '1' : '1 人出席' , '2' : '2 人出席' , '3' : '3 人及以上' , '0' : '遗憾缺席' } [ v ] || v )
function formatTime ( t ) {
if ( ! t ) return ''
const d = new Date ( t )
if ( isNaN ( d ) ) return t
return d . toLocaleString ( 'zh-CN' , { hour12 : false } )
}
async function loadRsvp ( ) {
rsvpLoading . value = true
try { const res = await getRsvpList ( ) ; rsvpList . value = res . data || [ ] }
catch ( e ) { /* 忽略 */ }
finally { rsvpLoading . value = false }
}
onMounted ( async ( ) => {
try {
const res = await getConfig ( )
if ( res . data && res . data !== '{}' ) {
const loaded = typeof res . data === 'string' ? JSON . parse ( res . data ) : res . data
2026-07-31 09:05:15 +08:00
// 兼容旧版单链接 musicUrl
if ( loaded . musicUrl && ! ( loaded . musicList && loaded . musicList . length ) ) {
loaded . musicList = [ { url : loaded . musicUrl , name : '背景音乐' } ]
loaded . activeMusicUrl = loaded . musicUrl
}
2026-07-31 13:09:49 +08:00
loaded . endImg = loaded . endImg || loaded . heroImg || cfg . endImg
2026-07-30 18:00:27 +08:00
Object . assign ( cfg , loaded )
2026-07-31 15:03:46 +08:00
cfg . formalPageHeight = cfg . formalPageHeight || '100vh'
2026-07-31 15:26:55 +08:00
cfg . calendarDate = cfg . calendarDate || '2026-09-04'
cfg . introExitEffect = cfg . introExitEffect || 'wind'
2026-07-30 18:00:27 +08:00
cfg . photoPages = ( cfg . photoPages || [ ] ) . map ( ensurePageShape )
2026-07-31 13:09:49 +08:00
setActivePhotoIndex ( currentPhotoIndex . value )
2026-07-31 11:36:19 +08:00
// 数值型参数兜底(兼容旧配置)
cfg . freeScrollInterval = Number ( cfg . freeScrollInterval ) || 50
cfg . pageSwitchDuration = Number ( cfg . pageSwitchDuration ) || 800
2026-07-31 13:20:01 +08:00
cfg . firstScreenDuration = Number . isFinite ( Number ( cfg . firstScreenDuration ) ) ? Number ( cfg . firstScreenDuration ) : 4500
2026-07-30 18:00:27 +08:00
}
} catch ( e ) { /* 使用默认配置 */ }
2026-07-31 13:09:49 +08:00
if ( adminStore . isAdmin ) {
loadRsvp ( )
loadUploadConfig ( )
}
2026-07-30 18:00:27 +08:00
} )
< / script >
< style scoped >
. admin - tabs : deep ( . ant - tabs - tab . ant - tabs - tab - active . ant - tabs - tab - btn ) { color : # a88c6b ; }
. admin - tabs : deep ( . ant - tabs - ink - bar ) { background : # a88c6b ; }
. admin - tabs : deep ( . ant - tabs - tab : hover . ant - tabs - tab - btn ) { color : # 967 b5c ; }
< / style >