840 lines
42 KiB
Vue
840 lines
42 KiB
Vue
<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>
|