Files
hunli/vite-tailwindcss/src/views/admin/AdminEditor.vue
2026-08-01 16:55:39 +08:00

840 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">
<a-tabs v-model:activeKey="activeTab" type="card" class="admin-tabs" :destroyInactiveTabPane="true">
<!-- 基础信息 -->
<a-tab-pane key="basic" tab="基础信息">
<a-card v-if="activeTab === 'basic'" :bordered="false" class="!shadow-sm">
<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>
<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>
<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>
<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>
</a-form>
</a-card>
</a-tab-pane>
<!-- 文案 -->
<a-tab-pane key="copy" tab="邀请文案">
<a-card v-if="activeTab === 'copy'" :bordered="false" class="!shadow-sm">
<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>
<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>
</a-form>
</a-card>
</a-tab-pane>
<!-- 视觉 / 动效 -->
<a-tab-pane key="visual" tab="视觉与动效">
<a-card v-if="activeTab === 'visual'" :bordered="false" class="!shadow-sm">
<a-form layout="vertical">
<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-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>
<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>
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-6">
<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">间隔越小滚动越快默认 50ms仅在自由滚动模式下生效</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">整页/分屏模式下自动翻页的动画时长默认 800ms</div>
</a-form-item>
<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">首屏效果结束后等待多久开始自动滚动默认 4500ms</div>
</a-form-item>
</div>
<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>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<a-form-item label="祝福弹幕">
<a-switch v-model:checked="cfg.danmakuEnabled" />
<div class="text-[11px] text-[#8A8680] mt-1">关闭后前台不展示弹幕也不显示送祝福入口</div>
</a-form-item>
<a-form-item label="弹幕显示时间">
<a-switch v-model:checked="cfg.danmakuShowTime" :disabled="!cfg.danmakuEnabled" />
<div class="text-[11px] text-[#8A8680] mt-1">开启后弹幕后缀展示相对时间刚刚x分钟前等</div>
</a-form-item>
</div>
</a-form>
</a-card>
</a-tab-pane>
<!-- 背景音乐 -->
<a-tab-pane key="music" tab="背景音乐">
<a-card v-if="activeTab === 'music'" :bordered="false" class="!shadow-sm">
<div class="text-[12px] text-[#8A8680] leading-relaxed mb-4">
上传多首背景音乐支持 mp3 等格式访客首次交互后自动播放当前曲目可在前台随时切换或暂停
</div>
<a-form-item label="随机播放">
<a-switch v-model:checked="cfg.musicRandomEnabled" />
<div class="text-[11px] text-[#8A8680] mt-1">开启后访客进入时随机选曲本地缓存 24 小时内再次进入保持同一首</div>
</a-form-item>
<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="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>
<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="暂无相册页" />
<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">
<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>
<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 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>
<!-- 单图版式的宽度设置逐页配置 -->
<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>
<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>
<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>
<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>
<!-- 排序前移 / 后移 -->
<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>
</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>
</template>
</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-tab-pane key="danmaku" tab="弹幕审核">
<div class="flex items-center justify-between mb-3 gap-3 flex-wrap">
<a-radio-group v-model:value="danmakuFilter" button-style="solid" size="small">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="pending">待审核</a-radio-button>
<a-radio-button value="approved">已通过</a-radio-button>
<a-radio-button value="rejected">已拒绝</a-radio-button>
</a-radio-group>
<a-button size="small" @click="loadDanmaku"><i class="fa-solid fa-rotate-right mr-1"></i>刷新</a-button>
</div>
<a-table :columns="danmakuColumns" :data-source="danmakuList" :loading="danmakuLoading" row-key="id" size="small" :pagination="{ pageSize: 8 }">
<template #bodyCell="{ column, record }">
<template v-if="column.key==='content'">
<span class="inline-flex items-center gap-2 min-w-0">
<i class="danmaku-admin-dot" :style="{ background: resolveDanmakuColor(record.color) }" />
<span class="truncate">{{ record.content }}</span>
</span>
</template>
<template v-else-if="column.key==='source'">
<a-tag :color="record.source === 'rsvp' ? 'blue' : 'gold'">{{ record.source === 'rsvp' ? '回执' : '弹幕' }}</a-tag>
</template>
<template v-else-if="column.key==='status'">
<a-tag :color="danmakuStatusColor(record.status)">{{ danmakuStatusLabel(record.status) }}</a-tag>
</template>
<template v-else-if="column.key==='created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key==='actions'">
<a-space>
<a-button size="small" type="primary" class="!bg-[#a88c6b] !border-[#a88c6b]"
:disabled="record.status === 'approved'"
@click="setDanmakuStatus(record.id, 'approved')">通过</a-button>
<a-button size="small" danger
:disabled="record.status === 'rejected'"
@click="setDanmakuStatus(record.id, 'rejected')">拒绝</a-button>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
getConfig, saveConfig, uploadImage, getRsvpList, getUploadConfig, saveUploadConfig,
getDanmakuList, updateDanmakuStatus,
} from '@/api/wedding'
import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuColor } from '@/utils/danmakuColors'
const router = useRouter()
const adminStore = useAdminStore()
const activeTab = ref('basic')
const activePhotoTab = ref('0')
const saving = ref(false)
const uploading = ref(false)
const uploadConfigSaving = ref(false)
const rsvpLoading = ref(false)
const rsvpList = ref([])
const danmakuLoading = ref(false)
const danmakuList = ref([])
const danmakuFilter = ref('pending')
const cfg = reactive(defaultConfig())
const uploadCfg = reactive(defaultUploadConfig())
const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) || 0, Math.max(cfg.photoPages.length - 1, 0)))
function defaultConfig() {
return {
groom: '李明', bride: '王华', date: '2026-05-20', lunar: '农历四月初四 星期三',
hotel: '杭州星光万丽大酒店', address: '杭州市西湖区星光大道88号\n3楼大宴会厅',
formalText1: '两姓联姻 一堂缔约', formalText2: '良缘永结 匹配同称', formalPageHeight: '100vh',
calendarDate: '2026-09-04',
heroImg: 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80',
endImg: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80',
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',
musicRandomEnabled: false,
danmakuEnabled: true,
danmakuShowTime: true,
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
introExitEffect: 'wind',
freeScrollInterval: 50, pageSwitchDuration: 800, firstScreenDuration: 4500,
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', 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' },
],
schedule: [
{ time: '16:00', event: '签到迎宾', desc: 'Welcome' },
{ time: '17:08', event: '仪式开始', desc: 'Ceremony' },
{ time: '18:00', event: '敬备喜宴', desc: 'Banquet' },
],
}
}
function defaultUploadConfig() {
return {
provider: 'local',
accessKeyId: '',
accessKeySecret: '',
hasSecret: false,
bucket: '',
folder: '',
domain: '',
region: '',
endpoint: '',
}
}
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__' },
]
const INTRO_EXIT_EFFECTS = [
{ label: '风吹纸页', value: 'wind' },
{ label: '翻书开启', value: 'book' },
{ label: '上提淡出', value: 'lift' },
{ label: '双门揭幕', value: 'doors' },
]
function heightSelectValue(page) {
return heightFieldSelectValue(page.height)
}
function onHeightModeChange(page, val) {
if (val !== '__custom__') page.height = val
}
function customVh(page) {
return customVhValue(page.height)
}
function onCustomVh(page, val) {
page.height = (val || 100) + 'vh'
}
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'
}
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 {
// 固定数量版式:一大五小(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 : []
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.height = p.height || '100vh'
p.borderStyle = p.borderStyle || 'mat'
// 单图宽度设置(仅 single 版式使用,逐页配置)
p.singleWidth = p.singleWidth === 'full' ? 'full' : 'default'
if (typeof p.singleKeepRatio !== 'boolean') p.singleKeepRatio = true
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) }
// 图片排序:前移(-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
}
// 相册页操作
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)
}
// 流程操作
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/*'
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()
}
// 上传(背景音乐,多首)
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 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
}
}
// 保存
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 }
}
const danmakuColumns = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
{ title: '来源', key: 'source', width: 80 },
{ title: '状态', key: 'status', width: 90 },
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
{ title: '操作', key: 'actions', width: 160 },
]
const danmakuStatusLabel = (s) => ({ pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s)
const danmakuStatusColor = (s) => ({ pending: 'orange', approved: 'green', rejected: 'default' }[s] || 'default')
async function loadDanmaku() {
danmakuLoading.value = true
try {
const params = danmakuFilter.value ? { status: danmakuFilter.value } : undefined
const res = await getDanmakuList(params)
danmakuList.value = res.data || []
} catch (e) { /* 忽略 */ }
finally { danmakuLoading.value = false }
}
async function setDanmakuStatus(id, status) {
try {
await updateDanmakuStatus(id, status)
message.success(status === 'approved' ? '已通过' : '已拒绝')
loadDanmaku()
} catch (e) {
message.error(e.message || '操作失败')
}
}
watch(danmakuFilter, () => {
if (adminStore.isAdmin) loadDanmaku()
})
onMounted(async () => {
try {
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
}
loaded.endImg = loaded.endImg || loaded.heroImg || cfg.endImg
Object.assign(cfg, loaded)
cfg.formalPageHeight = cfg.formalPageHeight || '100vh'
cfg.calendarDate = cfg.calendarDate || '2026-09-04'
cfg.introExitEffect = cfg.introExitEffect || 'wind'
cfg.photoPages = (cfg.photoPages || []).map(ensurePageShape)
setActivePhotoIndex(currentPhotoIndex.value)
// 数值型参数兜底(兼容旧配置)
cfg.freeScrollInterval = Number(cfg.freeScrollInterval) || 50
cfg.pageSwitchDuration = Number(cfg.pageSwitchDuration) || 800
cfg.firstScreenDuration = Number.isFinite(Number(cfg.firstScreenDuration)) ? Number(cfg.firstScreenDuration) : 4500
if (typeof cfg.musicRandomEnabled !== 'boolean') cfg.musicRandomEnabled = false
if (typeof cfg.danmakuEnabled !== 'boolean') cfg.danmakuEnabled = true
if (typeof cfg.danmakuShowTime !== 'boolean') cfg.danmakuShowTime = true
}
} catch (e) { /* 使用默认配置 */ }
if (adminStore.isAdmin) {
loadRsvp()
loadDanmaku()
loadUploadConfig()
}
})
</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: #967b5c; }
.danmaku-admin-dot {
width: 10px; height: 10px; border-radius: 9999px; flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
</style>