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 >
2026-08-02 19:04:04 +08:00
< a-button
v - if = "isConfigTab"
type = "primary"
size = "small"
: loading = "saving"
@ click = "onSave"
class = "!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
>
2026-07-30 18:00:27 +08:00
< 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-08-02 19:04:04 +08:00
<!-- 概览 -- >
< a-tab-pane key = "overview" tab = "概览" >
< div v-if = "activeTab === 'overview'" class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }" >
< div class = "flex justify-between items-center mb-4 gap-3 flex-wrap" >
< div >
< div class = "text-sm text-[#2c2c2c]" > 访客概览 < / div >
< div class = "text-[11px] text-[#8A8680] mt-0.5" > 指纹去重访客与地区分布 < / div >
< / div >
< a-button size = "small" :loading = "visitLoading" @click ="loadOverview" >
< i class = "fa-solid fa-rotate-right mr-1" > < / i > 刷新
< / a-button >
< / div >
< div class = "grid grid-cols-1 sm:grid-cols-3 gap-3 mb-5" >
< div class = "stat-card" >
< div class = "stat-label" > 访客人数 UV < / div >
< div class = "stat-value" > { { visitStats . uv } } < / div >
< / div >
< div class = "stat-card" >
< div class = "stat-label" > 访问次数 PV < / div >
< div class = "stat-value" > { { visitStats . pv } } < / div >
< / div >
< div class = "stat-card" >
< div class = "stat-label" > 今日访客 < / div >
< div class = "stat-value" > { { visitStats . today _uv } } < / div >
< / div >
< / div >
< div class = "grid grid-cols-1 lg:grid-cols-2 gap-4 mb-5" >
< div class = "chart-card" >
< div class = "text-[12px] text-[#7a6550] mb-2 tracking-wide" > 地区分布 < / div >
< div ref = "regionChartRef" class = "h-[280px] w-full" > < / div >
< / div >
< div class = "chart-card" >
< div class = "text-[12px] text-[#7a6550] mb-2 tracking-wide" > 说明 < / div >
< ul class = "text-[12px] text-[#8A8680] leading-relaxed space-y-2 m-0 pl-4" >
< li > 进入请柬页会采集浏览器指纹与 IP , 服务端解析归属地 。 < / li >
< li > 同一指纹 24 小时内重复访问会累加次数 , 不新增访客 。 < / li >
< li > 切到本页会重新拉取最新统计与列表 。 < / li >
< / ul >
< / div >
< / div >
< div class = "text-[12px] text-[#7a6550] mb-2 tracking-wide" > 近期访客 < / div >
< a-table
class = "admin-table"
: columns = "visitColumns"
: data - source = "visitList"
: loading = "visitLoading"
row - key = "id"
size = "middle"
: pagination = "visitPagination"
@ change = "onVisitTableChange"
>
< template # bodyCell = "{ column, record }" >
< template v-if = "column.key === 'fingerprint'" >
< span class = "font-mono text-[11px]" :title = "record.fingerprint" > { { shortFp ( record . fingerprint ) } } < / span >
< / template >
< template v-else-if = "column.key === 'location'" >
< span > { { record . raw _location || [ record . region , record . city ] . filter ( Boolean ) . join ( ' ' ) || '未知' } } < / span >
< / template >
< template v-else-if = "column.key === 'user_agent'" >
< span class = "text-[11px] text-[#8A8680]" :title = "record.user_agent" > { { shortUa ( record . user _agent ) } } < / span >
< / template >
< template v-else-if = "column.key === 'last_seen_at'" >
< span class = "text-[12px] text-[#8A8680]" > { { formatTime ( record . last _seen _at ) } } < / span >
< / template >
< / template >
< / a-table >
< / div >
< / a-tab-pane >
2026-07-30 18:00:27 +08:00
<!-- 基础信息 -- >
< a-tab-pane key = "basic" tab = "基础信息" >
2026-08-02 19:04:04 +08:00
< a-card v-if = "activeTab === 'basic'" :bordered="false" class="!shadow-sm" :class="{ 'opacity-60': tabLoading }" >
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 >
2026-08-01 16:55:39 +08:00
< 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 >
2026-08-02 16:11:35 +08:00
< a-form-item label = "九宫格入场动画" >
< a-switch v -model :checked = "cfg.nineGridAnimate" / >
2026-08-02 17:39:19 +08:00
< div class = "text-[11px] text-[#8A8680] mt-1" > 开启 : 进入视口后暂停滚动 , 每 300 ms 入场一格 ( 方向动效 ) ; 全部完成后再继续 。 关闭 : 直接展示 , 更稳 ( 推荐 iOS ) 。 < / div >
2026-08-02 16:11:35 +08:00
< / a-form-item >
2026-08-01 16:55:39 +08:00
< / div >
2026-07-30 18:00:27 +08:00
< / a-form >
< / a-card >
< / a-tab-pane >
2026-07-31 09:05:15 +08:00
<!-- 背景音乐 -- >
< a-tab-pane key = "music" tab = "背景音乐" >
2026-08-02 19:04:04 +08:00
< div v-if = "activeTab === 'music'" class="admin-panel" >
< div class = "flex flex-wrap items-start justify-between gap-3 mb-5" >
< div >
< div class = "text-sm text-[#2c2c2c] tracking-wide" > 曲目列表 < / div >
< p class = "text-[11px] text-[#8A8680] mt-1 leading-relaxed max-w-md" >
访客首次交互后自动播放当前曲目 。 开启随机后 , 24 小时内本地缓存同一首 。
< / p >
< / div >
< div class = "flex items-center gap-3" >
< label class = "inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none" >
< a-switch v -model :checked = "cfg.musicRandomEnabled" size = "small" / >
随机播放
< / label >
< a-button type = "primary" size = "small"
class = "!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
@ click = "openMusicUpload" >
< i class = "fa-solid fa-plus mr-1" > < / i > 上传音乐
2026-07-31 09:05:15 +08:00
< / a-button >
< / div >
2026-08-02 19:04:04 +08:00
< / div >
< div v-if = "!cfg.musicList.length" class="admin-empty" >
< div class = "admin-empty-icon" > < i class = "fa-solid fa-music" > < / i > < / div >
< div class = "text-[13px] text-[#5c5348]" > 尚未上传音乐 < / div >
< div class = "text-[11px] text-[#8A8680] mt-1" > 支持 mp3 等常见音频格式 < / div >
< a-button class = "mt-4" @click ="openMusicUpload" >
< i class = "fa-solid fa-upload mr-1 text-[#a88c6b]" > < / i > 上传第一首
< / a-button >
< / div >
< ul v-else class = "music-list" >
< li
v - for = "(t, i) in cfg.musicList"
: key = "i"
class = "music-track"
: class = "{ 'music-track--active': t.url === cfg.activeMusicUrl }"
>
< div class = "music-track-main" >
< button
type = "button"
class = "music-cover"
@ click = "openUpload(u => t.coverUrl = u)"
: title = "t.coverUrl ? '更换封面' : '上传封面'"
>
< img v-if = "t.coverUrl" :src="t.coverUrl" alt="" / >
< span v-else class = "music-cover-fallback" >
< i class = "fa-solid fa-compact-disc" > < / i >
< / span >
< span class = "music-cover-index" > { { String ( i + 1 ) . padStart ( 2 , '0' ) } } < / span >
< / button >
< div class = "music-meta min-w-0 flex-1" >
< a-input
v - model : value = "t.name"
placeholder = "曲目名称"
class = "music-name-input"
: bordered = "false"
/ >
< div class = "music-sub" >
< span class = "truncate" > { { shortUrl ( t . url ) } } < / span >
< span
v - if = "t.url === cfg.activeMusicUrl"
class = "music-badge"
> 正在播放 < / span >
< / div >
< div class = "music-chips" >
< button type = "button" class = "music-chip" @click ="openLrcUpload(t)" >
< i class = "fa-solid fa-file-lines" > < / i >
{ { t . lrcUrl ? '已有歌词' : '上传歌词' } }
< / button >
< button
v - if = "t.lrcUrl"
type = "button"
class = "music-chip music-chip--muted"
@ click = "t.lrcUrl = ''"
> 清除歌词 < / button >
< / div >
< audio v-if = "t.url" :src="t.url" controls preload="none" class="music-audio" > < / audio >
< / div >
< div class = "music-actions" >
< a-button
size = "small"
: type = "t.url === cfg.activeMusicUrl ? 'primary' : 'default'"
: class = "t.url === cfg.activeMusicUrl ? '!bg-[#a88c6b] !border-[#a88c6b]' : ''"
@ click = "setActiveMusic(t.url)"
>
< i class = "fa-solid fa-play mr-1 text-[10px]" > < / i >
{ { t . url === cfg . activeMusicUrl ? '当前曲' : '设为播放' } }
< / a-button >
< a-button size = "small" danger type = "text" @click ="removeMusic(i)" title = "删除" >
< i class = "fa-solid fa-trash" > < / i >
2026-08-01 17:59:07 +08:00
< / a-button >
< / div >
< / div >
2026-08-02 19:04:04 +08:00
< / li >
< / ul >
< / div >
2026-07-31 09:05:15 +08:00
< / 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 = "相册页管理" >
2026-08-02 19:04:04 +08:00
< div class = "admin-panel !p-4 mb-3" >
< div class = "flex justify-between items-center gap-3 flex-wrap" >
< div >
< div class = "text-sm text-[#2c2c2c]" > 相册页 < / div >
< div class = "text-[11px] text-[#8A8680] mt-0.5" > 共 { { cfg . photoPages . length } } 页 < / div >
< / div >
< a-button size = "small" type = "primary"
class = "!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
@ click = "addPage" >
< i class = "fa-solid fa-plus mr-1" > < / i > 新增相册页
< / a-button >
< / div >
< a-tabs
v - if = "cfg.photoPages.length"
v - model : activeKey = "activePhotoTab"
size = "small"
type = "card"
class = "photo-page-tabs mt-3"
: destroyInactiveTabPane = "true"
>
< a-tab-pane v-for = "(page, idx) in cfg.photoPages" :key="String(idx)" :tab="`第 ${idx + 1} 页`" / >
< / a-tabs >
< / div >
< div v-if = "!cfg.photoPages.length" class="admin-empty" >
< div class = "admin-empty-icon" > < i class = "fa-regular fa-images" > < / i > < / div >
< div class = "text-[13px] text-[#5c5348]" > 暂无相册页 < / div >
2026-07-30 18:00:27 +08:00
< / div >
2026-07-31 13:09:49 +08:00
< template v-for = "(page, idx) in cfg.photoPages" :key="idx" >
2026-08-02 19:04:04 +08:00
< a-card v-if = "idx === currentPhotoIndex" :bordered="false" class="!shadow-sm mb-4 !rounded-2xl !border !border-[#a88c6b]/10" >
2026-07-30 18:00:27 +08:00
< div class = "flex justify-between items-center mb-3" >
2026-08-02 19:04:04 +08:00
< span class = "inline-flex items-center gap-2 text-[#a88c6b] text-sm" >
< span class = "w-7 h-7 rounded-lg bg-[#a88c6b]/10 text-[11px] inline-flex items-center justify-center tracking-wider" >
{ { String ( idx + 1 ) . padStart ( 2 , '0' ) } }
< / span >
第 { { idx + 1 } } 页
< / span >
2026-07-30 18:00:27 +08:00
< 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 >
2026-08-02 19:04:04 +08:00
< a-button size = "small" danger type = "text" @click ="removePage(idx)" > < i class = "fa-solid fa-trash" > < / i > < / a-button >
2026-07-30 18:00:27 +08:00
< / 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 = "婚礼流程" >
2026-08-02 19:04:04 +08:00
< div class = "admin-panel" >
< div class = "flex justify-between items-center mb-4" >
< div >
< div class = "text-sm text-[#2c2c2c]" > 流程环节 < / div >
< div class = "text-[11px] text-[#8A8680] mt-0.5" > 共 { { cfg . schedule . length } } 个环节 < / div >
2026-07-30 18:00:27 +08:00
< / div >
2026-08-02 19:04:04 +08:00
< a-button size = "small" type = "primary"
class = "!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
@ click = "addSchedule" >
< i class = "fa-solid fa-plus mr-1" > < / i > 新增环节
< / a-button >
2026-07-30 18:00:27 +08:00
< / div >
2026-08-02 19:04:04 +08:00
< div v-if = "!cfg.schedule.length" class="admin-empty" >
< div class = "admin-empty-icon" > < i class = "fa-regular fa-clock" > < / i > < / div >
< div class = "text-[13px] text-[#5c5348]" > 暂无流程 < / div >
< / div >
< ol v-else class = "schedule-list" >
< li v-for = "(item, idx) in cfg.schedule" :key="idx" class="schedule-row" >
< div class = "schedule-index" > { { String ( idx + 1 ) . padStart ( 2 , '0' ) } } < / div >
< div class = "schedule-fields" >
< div >
< div class = "field-label" > 时间 < / div >
< a-input v -model :value = "item.time" placeholder = "如 17:08" / >
< / div >
< div >
< div class = "field-label" > 环节 < / div >
< a-input v -model :value = "item.event" placeholder = "如 仪式开始" / >
< / div >
< div >
< div class = "field-label" > 英文副标题 < / div >
< a-input v -model :value = "item.desc" placeholder = "如 Ceremony" / >
< / div >
< / div >
< a-button danger size = "small" type = "text" @click ="removeSchedule(idx)" >
< i class = "fa-solid fa-trash" > < / i >
< / a-button >
< / li >
< / ol >
< / div >
2026-07-30 18:00:27 +08:00
< / a-tab-pane >
<!-- 回执列表 -- >
< a-tab-pane key = "rsvp" tab = "出席回执" >
2026-08-02 19:04:04 +08:00
< div class = "admin-panel" >
< div class = "flex justify-between items-center mb-4" >
< div >
< div class = "text-sm text-[#2c2c2c]" > 出席回执 < / div >
< div class = "text-[11px] text-[#8A8680] mt-0.5" > 共 { { rsvpList . length } } 条 < / div >
< / div >
< a-button size = "small" @click ="loadRsvp" > < i class = "fa-solid fa-rotate-right mr-1" > < / i > 刷新 < / a-button >
< / div >
< a-table
class = "admin-table"
: columns = "rsvpColumns"
: data - source = "rsvpList"
: loading = "rsvpLoading"
row - key = "id"
size = "middle"
: pagination = "{ pageSize: 8, hideOnSinglePage: true }"
>
< template # bodyCell = "{ column, record }" >
< template v-if = "column.key==='guest_count'" >
< span
class = "admin-pill"
: class = "record.guest_count === '0' ? 'admin-pill--muted' : 'admin-pill--rose'"
> { { rsvpCountLabel ( record . guest _count ) } } < / span >
< / template >
< template v-else-if = "column.key==='created_at'" >
< span class = "text-[12px] text-[#8A8680]" > { { formatTime ( record . created _at ) } } < / span >
< / template >
2026-07-30 18:00:27 +08:00
< / template >
2026-08-02 19:04:04 +08:00
< / a-table >
< / div >
2026-07-30 18:00:27 +08:00
< / a-tab-pane >
2026-08-01 16:55:39 +08:00
<!-- 弹幕审核 -- >
< a-tab-pane key = "danmaku" tab = "弹幕审核" >
2026-08-02 19:04:04 +08:00
< div class = "admin-panel" >
< div class = "flex items-center justify-between mb-4 gap-3 flex-wrap" >
< div >
< div class = "text-sm text-[#2c2c2c]" > 弹幕审核 < / div >
< div class = "text-[11px] text-[#8A8680] mt-0.5" > 共 { { danmakuList . length } } 条 < / div >
< / div >
< div class = "flex items-center gap-2 flex-wrap" >
< a-radio-group v -model :value = "danmakuFilter" button -style = " solid " size = "small" class = "admin-filter" >
< 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 >
< / div >
< a-table
class = "admin-table"
: columns = "danmakuColumns"
: data - source = "danmakuList"
: loading = "danmakuLoading"
row - key = "id"
size = "middle"
: pagination = "{ pageSize: 8, hideOnSinglePage: true }"
>
< 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: resolveDanmakuSwatch(record.color) }" / >
< span class = "truncate" > { { record . content } } < / span >
< / span >
< / template >
< template v-else-if = "column.key==='source'" >
< span class = "admin-pill" : class = "record.source === 'rsvp' ? 'admin-pill--sky' : 'admin-pill--gold'" >
{ { record . source === 'rsvp' ? '回执' : '弹幕' } }
< / span >
< / template >
< template v-else-if = "column.key==='status'" >
< span class = "admin-pill" :class = "danmakuStatusPill(record.status)" >
{ { danmakuStatusLabel ( record . status ) } }
< / span >
< / template >
< template v-else-if = "column.key==='created_at'" >
< span class = "text-[12px] text-[#8A8680]" > { { formatTime ( record . created _at ) } } < / span >
< / 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 type = "text"
: disabled = "record.status === 'rejected'"
@ click = "setDanmakuStatus(record.id, 'rejected')" > 拒绝 < / a-button >
< / a-space >
< / template >
2026-08-01 16:55:39 +08:00
< / template >
2026-08-02 19:04:04 +08:00
< / a-table >
< / div >
2026-08-01 16:55:39 +08:00
< / a-tab-pane >
2026-07-30 18:00:27 +08:00
< / a-tabs >
< / div >
< / div >
< / template >
< script setup >
2026-08-02 19:04:04 +08:00
import { computed , reactive , ref , onMounted , onUnmounted , watch , nextTick } from 'vue'
2026-07-30 18:00:27 +08:00
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
2026-08-02 19:04:04 +08:00
import * as echarts from 'echarts/core'
import { PieChart } from 'echarts/charts'
import { TooltipComponent , LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
2026-08-01 16:55:39 +08:00
import {
2026-08-02 19:04:04 +08:00
getConfigSection , saveConfigSection , uploadImage , getRsvpList , getUploadConfig , saveUploadConfig ,
getDanmakuList , updateDanmakuStatus , getVisitStats , getVisitList ,
2026-08-01 16:55:39 +08:00
} 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-08-02 16:11:35 +08:00
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
2026-07-30 18:00:27 +08:00
2026-08-02 19:04:04 +08:00
echarts . use ( [ PieChart , TooltipComponent , LegendComponent , CanvasRenderer ] )
2026-07-30 18:00:27 +08:00
const router = useRouter ( )
const adminStore = useAdminStore ( )
2026-08-02 19:04:04 +08:00
const TAB _KEY = 'wedding_admin_active_tab'
const CONFIG _SECTIONS = [ 'basic' , 'copy' , 'visual' , 'music' , 'photos' , 'schedule' ]
const VALID _TABS = [ 'overview' , ... CONFIG _SECTIONS , 'upload' , 'rsvp' , 'danmaku' ]
const SECTION _KEYS = {
basic : [ 'groom' , 'bride' , 'date' , 'lunar' , 'calendarDate' , 'hotel' , 'address' , 'heroImg' , 'endImg' ] ,
copy : [ 'formalText1' , 'formalText2' , 'formalPageHeight' ] ,
visual : [
'introExitEffect' , 'scrollMode' , 'freeScrollInterval' , 'pageSwitchDuration' , 'firstScreenDuration' ,
'petalEnabled' , 'bubbleEnabled' , 'introEnabled' , 'danmakuEnabled' , 'danmakuShowTime' , 'nineGridAnimate' ,
] ,
music : [ 'musicList' , 'activeMusicUrl' , 'musicRandomEnabled' ] ,
photos : [ 'photoPages' ] ,
schedule : [ 'schedule' ] ,
}
function readStoredTab ( ) {
try {
const v = localStorage . getItem ( TAB _KEY )
if ( v && VALID _TABS . includes ( v ) ) return v
} catch { /* ignore */ }
return 'overview'
}
const activeTab = ref ( readStoredTab ( ) )
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-08-02 19:04:04 +08:00
const tabLoading = ref ( false )
2026-07-30 18:00:27 +08:00
const rsvpLoading = ref ( false )
const rsvpList = ref ( [ ] )
2026-08-01 16:55:39 +08:00
const danmakuLoading = ref ( false )
const danmakuList = ref ( [ ] )
const danmakuFilter = ref ( 'pending' )
2026-07-30 18:00:27 +08:00
2026-08-02 19:04:04 +08:00
const visitLoading = ref ( false )
const visitStats = reactive ( { uv : 0 , pv : 0 , today _uv : 0 , regions : [ ] } )
const visitList = ref ( [ ] )
const visitPage = ref ( 1 )
const visitTotal = ref ( 0 )
const regionChartRef = ref ( null )
let regionChart = null
2026-07-30 18:00:27 +08:00
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-08-02 19:04:04 +08:00
const isConfigTab = computed ( ( ) => CONFIG _SECTIONS . includes ( activeTab . value ) )
const visitPagination = computed ( ( ) => ( {
current : visitPage . value ,
pageSize : 20 ,
total : visitTotal . value ,
hideOnSinglePage : true ,
showSizeChanger : false ,
} ) )
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-08-01 16:55:39 +08:00
musicRandomEnabled : false ,
danmakuEnabled : true ,
danmakuShowTime : true ,
2026-08-02 16:11:35 +08:00
nineGridAnimate : false ,
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 = [ ]
2026-08-01 17:59:07 +08:00
cfg . musicList . push ( { url , name , coverUrl : '' , lrcUrl : '' } )
2026-07-31 09:05:15 +08:00
if ( ! cfg . activeMusicUrl ) cfg . activeMusicUrl = url
message . success ( '音乐上传成功' )
} catch ( err ) {
message . error ( '上传失败' )
} finally {
uploading . value = false ; e . target . value = ''
}
}
input . click ( )
}
2026-08-01 17:59:07 +08:00
function openLrcUpload ( track ) {
const input = document . createElement ( 'input' )
input . type = 'file'
input . accept = '.lrc,text/plain'
input . onchange = async ( e ) => {
const file = e . target . files [ 0 ]
if ( ! file ) return
uploading . value = true
try {
const res = await uploadImage ( file )
track . lrcUrl = res . url
message . success ( '歌词上传成功' )
} catch {
message . error ( '歌词上传失败' )
} finally {
uploading . value = false
e . target . value = ''
}
}
input . click ( )
}
2026-07-31 09:05:15 +08:00
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-08-02 19:04:04 +08:00
function pickSectionData ( section ) {
const keys = SECTION _KEYS [ section ] || [ ]
const data = { }
for ( const k of keys ) data [ k ] = cfg [ k ]
return JSON . parse ( JSON . stringify ( data ) )
}
function applySectionData ( section , loaded ) {
if ( ! loaded || typeof loaded !== 'object' ) return
if ( section === 'music' ) {
if ( Array . isArray ( loaded . musicList ) ) {
loaded . musicList = loaded . musicList . map ( ( t ) => ( {
... t ,
coverUrl : t . coverUrl || '' ,
lrcUrl : t . lrcUrl || '' ,
} ) )
}
if ( typeof loaded . musicRandomEnabled !== 'boolean' ) loaded . musicRandomEnabled = false
}
if ( section === 'photos' ) {
loaded . photoPages = ( loaded . photoPages || [ ] ) . map ( ensurePageShape )
}
if ( section === 'basic' ) {
loaded . endImg = loaded . endImg || loaded . heroImg || cfg . endImg
loaded . calendarDate = loaded . calendarDate || '2026-09-04'
}
if ( section === 'copy' ) {
loaded . formalPageHeight = loaded . formalPageHeight || '100vh'
}
if ( section === 'visual' ) {
loaded . introExitEffect = loaded . introExitEffect || 'wind'
loaded . freeScrollInterval = Number ( loaded . freeScrollInterval ) || 50
loaded . pageSwitchDuration = Number ( loaded . pageSwitchDuration ) || 800
loaded . firstScreenDuration = Number . isFinite ( Number ( loaded . firstScreenDuration ) )
? Number ( loaded . firstScreenDuration )
: 4500
if ( typeof loaded . danmakuEnabled !== 'boolean' ) loaded . danmakuEnabled = true
if ( typeof loaded . danmakuShowTime !== 'boolean' ) loaded . danmakuShowTime = true
if ( typeof loaded . nineGridAnimate !== 'boolean' ) loaded . nineGridAnimate = false
}
Object . assign ( cfg , loaded )
if ( section === 'photos' ) setActivePhotoIndex ( currentPhotoIndex . value )
}
async function loadConfigSection ( section ) {
tabLoading . value = true
try {
const res = await getConfigSection ( section )
applySectionData ( section , res . data || { } )
} catch ( e ) {
message . error ( e ? . message || '加载配置失败' )
} finally {
tabLoading . value = false
}
}
// 保存当前配置段
2026-07-30 18:00:27 +08:00
async function onSave ( ) {
2026-08-02 19:04:04 +08:00
const section = activeTab . value
if ( ! CONFIG _SECTIONS . includes ( section ) ) {
message . info ( '当前页无需保存配置' )
return
}
2026-07-30 18:00:27 +08:00
saving . value = true
try {
2026-08-02 19:04:04 +08:00
const res = await saveConfigSection ( section , pickSectionData ( section ) )
applySectionData ( section , res . data || pickSectionData ( section ) )
2026-07-30 18:00:27 +08:00
message . success ( '配置已保存' )
} catch ( e ) {
2026-08-02 19:04:04 +08:00
const msg = e ? . message || '保存失败'
2026-07-30 18:00:27 +08:00
message . error ( msg )
2026-08-02 19:04:04 +08:00
if ( String ( msg ) . includes ( '未授权' ) || String ( msg ) . includes ( '登录' ) ) {
router . replace ( { name : 'Login' , query : { redirect : '/admin' } } )
}
2026-07-30 18:00:27 +08:00
} finally {
saving . value = false
}
}
2026-08-02 19:04:04 +08:00
const visitColumns = [
{ title : '指纹' , key : 'fingerprint' , width : 120 } ,
{ title : 'IP' , dataIndex : 'ip' , key : 'ip' , width : 130 } ,
{ title : '归属地' , key : 'location' , ellipsis : true } ,
{ title : '次数' , dataIndex : 'visit_count' , key : 'visit_count' , width : 70 } ,
{ title : 'UA' , key : 'user_agent' , ellipsis : true } ,
{ title : '最近访问' , key : 'last_seen_at' , width : 170 } ,
]
function shortFp ( fp ) {
if ( ! fp ) return '-'
return fp . length > 12 ? ` ${ fp . slice ( 0 , 8 ) } … ${ fp . slice ( - 4 ) } ` : fp
}
function shortUa ( ua ) {
if ( ! ua ) return '-'
return ua . length > 42 ? ` ${ ua . slice ( 0 , 42 ) } … ` : ua
}
function disposeRegionChart ( ) {
if ( regionChart ) {
regionChart . dispose ( )
regionChart = null
}
}
function renderRegionChart ( ) {
if ( ! regionChartRef . value ) return
if ( regionChart && regionChart . getDom ( ) !== regionChartRef . value ) {
disposeRegionChart ( )
}
if ( ! regionChart ) regionChart = echarts . init ( regionChartRef . value )
const regions = Array . isArray ( visitStats . regions ) ? visitStats . regions : [ ]
const data = regions . length
? regions . map ( ( r ) => ( { name : r . name || '未知' , value : Number ( r . value ) || 0 } ) )
: [ { name : '暂无数据' , value : 0 } ]
regionChart . setOption ( {
color : [ '#a88c6b' , '#c4a484' , '#e8b4b8' , '#7c9eb2' , '#b5a4c9' , '#d4a574' , '#9b7e9a' , '#64748b' ] ,
tooltip : { trigger : 'item' , formatter : '{b}: {c} ({d}%)' } ,
legend : {
type : 'scroll' ,
bottom : 0 ,
textStyle : { color : '#8A8680' , fontSize : 11 } ,
} ,
series : [ {
type : 'pie' ,
radius : [ '38%' , '62%' ] ,
center : [ '50%' , '46%' ] ,
itemStyle : { borderRadius : 4 , borderColor : '#fff' , borderWidth : 2 } ,
label : { color : '#5c5348' , fontSize : 11 } ,
data ,
} ] ,
} )
}
async function loadOverview ( ) {
visitLoading . value = true
try {
const [ statsRes , listRes ] = await Promise . all ( [
getVisitStats ( ) ,
getVisitList ( { page : visitPage . value , pageSize : 20 } ) ,
] )
const s = statsRes . data || { }
visitStats . uv = Number ( s . uv ) || 0
visitStats . pv = Number ( s . pv ) || 0
visitStats . today _uv = Number ( s . today _uv ) || 0
visitStats . regions = Array . isArray ( s . regions ) ? s . regions : [ ]
const listData = listRes . data || { }
visitList . value = listData . list || [ ]
visitTotal . value = Number ( listData . total ) || 0
await nextTick ( )
renderRegionChart ( )
} catch ( e ) {
message . error ( e ? . message || '加载访客数据失败' )
} finally {
visitLoading . value = false
}
}
function onVisitTableChange ( pag ) {
visitPage . value = pag ? . current || 1
loadOverview ( )
}
async function onTabActivate ( tab ) {
if ( ! adminStore . isAdmin ) return
if ( tab !== 'overview' ) disposeRegionChart ( )
if ( tab === 'overview' ) {
visitPage . value = 1
await loadOverview ( )
return
}
if ( CONFIG _SECTIONS . includes ( tab ) ) {
await loadConfigSection ( tab )
return
}
if ( tab === 'upload' ) {
await loadUploadConfig ( )
return
}
if ( tab === 'rsvp' ) {
await loadRsvp ( )
return
}
if ( tab === 'danmaku' ) {
await loadDanmaku ( )
}
}
2026-07-30 18:00:27 +08:00
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 }
}
2026-08-01 16:55:39 +08:00
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 )
2026-08-02 19:04:04 +08:00
const danmakuStatusPill = ( s ) => ( {
pending : 'admin-pill--warn' ,
approved : 'admin-pill--ok' ,
rejected : 'admin-pill--muted' ,
} [ s ] || 'admin-pill--muted' )
2026-08-01 16:55:39 +08:00
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 , ( ) => {
2026-08-02 19:04:04 +08:00
if ( adminStore . isAdmin && activeTab . value === 'danmaku' ) loadDanmaku ( )
2026-08-01 16:55:39 +08:00
} )
2026-08-02 19:04:04 +08:00
watch ( activeTab , ( tab ) => {
try { localStorage . setItem ( TAB _KEY , tab ) } catch { /* ignore */ }
onTabActivate ( tab )
} , { immediate : true } )
const onResize = ( ) => { regionChart ? . resize ( ) }
onMounted ( ( ) => {
window . addEventListener ( 'resize' , onResize )
} )
onUnmounted ( ( ) => {
window . removeEventListener ( 'resize' , onResize )
disposeRegionChart ( )
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 ; }
2026-08-02 19:04:04 +08:00
. admin - panel {
background : # fff ;
border : 1 px solid rgba ( 168 , 140 , 107 , 0.14 ) ;
border - radius : 16 px ;
padding : 20 px 20 px 16 px ;
box - shadow : 0 8 px 28 px rgba ( 88 , 68 , 42 , 0.05 ) ;
}
. stat - card {
border - radius : 14 px ;
padding : 16 px 18 px ;
background : linear - gradient ( 145 deg , # fff 0 % , # f8f1e6 100 % ) ;
border : 1 px solid rgba ( 168 , 140 , 107 , 0.16 ) ;
}
. stat - label {
font - size : 11 px ;
color : # 8 A8680 ;
letter - spacing : 0.06 em ;
}
. stat - value {
margin - top : 6 px ;
font - size : 28 px ;
font - weight : 500 ;
color : # a88c6b ;
letter - spacing : 0.02 em ;
line - height : 1.1 ;
}
. chart - card {
border - radius : 14 px ;
padding : 14 px 16 px ;
background : # fdfbf7 ;
border : 1 px solid rgba ( 168 , 140 , 107 , 0.12 ) ;
min - height : 300 px ;
}
. admin - empty {
display : flex ;
flex - direction : column ;
align - items : center ;
justify - content : center ;
padding : 48 px 16 px ;
border - radius : 14 px ;
background : linear - gradient ( 180 deg , # fdfbf7 0 % , # f7f3ec 100 % ) ;
border : 1 px dashed rgba ( 168 , 140 , 107 , 0.28 ) ;
}
. admin - empty - icon {
width : 48 px ;
height : 48 px ;
border - radius : 50 % ;
display : flex ;
align - items : center ;
justify - content : center ;
background : rgba ( 168 , 140 , 107 , 0.12 ) ;
color : # a88c6b ;
margin - bottom : 12 px ;
font - size : 18 px ;
}
. music - list {
list - style : none ;
margin : 0 ;
padding : 0 ;
display : flex ;
flex - direction : column ;
gap : 10 px ;
}
. music - track {
border : 1 px solid rgba ( 168 , 140 , 107 , 0.14 ) ;
border - radius : 14 px ;
background : # fdfbf7 ;
padding : 12 px 14 px ;
transition : border - color 0.2 s , box - shadow 0.2 s , background 0.2 s ;
}
. music - track -- active {
border - color : rgba ( 168 , 140 , 107 , 0.55 ) ;
background : linear - gradient ( 135 deg , # fff 0 % , # f8f1e6 100 % ) ;
box - shadow : 0 6 px 18 px rgba ( 168 , 140 , 107 , 0.12 ) ;
}
. music - track - main {
display : flex ;
align - items : flex - start ;
gap : 14 px ;
}
. music - cover {
position : relative ;
width : 72 px ;
height : 72 px ;
border - radius : 12 px ;
overflow : hidden ;
flex - shrink : 0 ;
border : 1 px solid rgba ( 168 , 140 , 107 , 0.2 ) ;
background : # f0ebe3 ;
cursor : pointer ;
padding : 0 ;
}
. music - cover img {
width : 100 % ;
height : 100 % ;
object - fit : cover ;
display : block ;
}
. music - cover - fallback {
width : 100 % ;
height : 100 % ;
display : flex ;
align - items : center ;
justify - content : center ;
color : # a88c6b ;
font - size : 22 px ;
background : radial - gradient ( circle at 30 % 30 % , # fff8ee , # efe4d4 ) ;
}
. music - cover - index {
position : absolute ;
left : 4 px ;
bottom : 4 px ;
font - size : 9 px ;
letter - spacing : 0.06 em ;
color : # fff ;
background : rgba ( 44 , 36 , 28 , 0.45 ) ;
padding : 1 px 5 px ;
border - radius : 999 px ;
}
. music - meta { min - width : 0 ; }
. music - name - input {
padding : 0 ! important ;
font - size : 14 px ! important ;
font - weight : 500 ;
color : # 2 c2c2c ;
}
. music - name - input : deep ( . ant - input ) {
padding : 0 ;
font - size : 14 px ;
font - weight : 500 ;
background : transparent ;
}
. music - sub {
display : flex ;
align - items : center ;
gap : 8 px ;
margin - top : 2 px ;
font - size : 11 px ;
color : # 8 A8680 ;
min - width : 0 ;
}
. music - badge {
flex - shrink : 0 ;
font - size : 10 px ;
letter - spacing : 0.04 em ;
color : # a88c6b ;
background : rgba ( 168 , 140 , 107 , 0.12 ) ;
border : 1 px solid rgba ( 168 , 140 , 107 , 0.25 ) ;
border - radius : 999 px ;
padding : 1 px 8 px ;
}
. music - chips {
display : flex ;
flex - wrap : wrap ;
gap : 6 px ;
margin - top : 8 px ;
}
. music - chip {
display : inline - flex ;
align - items : center ;
gap : 5 px ;
border : 1 px solid rgba ( 168 , 140 , 107 , 0.22 ) ;
background : # fff ;
color : # 7 a6550 ;
font - size : 11 px ;
padding : 3 px 10 px ;
border - radius : 999 px ;
cursor : pointer ;
transition : border - color 0.15 s , color 0.15 s ;
}
. music - chip : hover {
border - color : # a88c6b ;
color : # a88c6b ;
}
. music - chip -- muted {
color : # 9 a9084 ;
border - color : transparent ;
background : transparent ;
}
. music - audio {
width : 100 % ;
max - width : 420 px ;
height : 32 px ;
margin - top : 10 px ;
border - radius : 8 px ;
outline : none ;
}
. music - actions {
display : flex ;
flex - direction : column ;
align - items : flex - end ;
gap : 4 px ;
flex - shrink : 0 ;
}
@ media ( max - width : 640 px ) {
. music - track - main { flex - wrap : wrap ; }
. music - actions {
flex - direction : row ;
width : 100 % ;
justify - content : flex - end ;
}
}
. schedule - list {
list - style : none ;
margin : 0 ;
padding : 0 ;
display : flex ;
flex - direction : column ;
gap : 10 px ;
}
. schedule - row {
display : flex ;
align - items : flex - end ;
gap : 12 px ;
padding : 14 px ;
border - radius : 14 px ;
background : # fdfbf7 ;
border : 1 px solid rgba ( 168 , 140 , 107 , 0.12 ) ;
}
. schedule - index {
width : 36 px ;
height : 36 px ;
border - radius : 10 px ;
display : flex ;
align - items : center ;
justify - content : center ;
font - size : 12 px ;
letter - spacing : 0.06 em ;
color : # a88c6b ;
background : rgba ( 168 , 140 , 107 , 0.1 ) ;
flex - shrink : 0 ;
margin - bottom : 2 px ;
}
. schedule - fields {
flex : 1 ;
display : grid ;
grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ;
gap : 10 px ;
min - width : 0 ;
}
@ media ( max - width : 720 px ) {
. schedule - fields { grid - template - columns : 1 fr ; }
. schedule - row { align - items : flex - start ; }
}
. field - label {
font - size : 11 px ;
color : # a88c6b ;
margin - bottom : 4 px ;
letter - spacing : 0.04 em ;
}
. admin - table : deep ( . ant - table ) {
background : transparent ;
}
. admin - table : deep ( . ant - table - thead > tr > th ) {
background : # f7f3ec ! important ;
color : # 7 a6550 ;
font - weight : 500 ;
font - size : 12 px ;
border - bottom : 1 px solid rgba ( 168 , 140 , 107 , 0.16 ) ! important ;
}
. admin - table : deep ( . ant - table - tbody > tr > td ) {
border - bottom : 1 px solid rgba ( 168 , 140 , 107 , 0.08 ) ! important ;
font - size : 13 px ;
}
. admin - table : deep ( . ant - table - tbody > tr : hover > td ) {
background : # fdfbf7 ! important ;
}
. admin - table : deep ( . ant - pagination - item - active ) {
border - color : # a88c6b ;
}
. admin - table : deep ( . ant - pagination - item - active a ) {
color : # a88c6b ;
}
. admin - filter : deep ( . ant - radio - button - wrapper - checked ) {
background : # a88c6b ! important ;
border - color : # a88c6b ! important ;
color : # fff ! important ;
}
. admin - filter : deep ( . ant - radio - button - wrapper : hover ) {
color : # a88c6b ;
}
. admin - pill {
display : inline - flex ;
align - items : center ;
padding : 1 px 9 px ;
border - radius : 999 px ;
font - size : 11 px ;
line - height : 18 px ;
border : 1 px solid transparent ;
}
. admin - pill -- rose {
color : # b4536a ;
background : rgba ( 232 , 165 , 178 , 0.22 ) ;
border - color : rgba ( 232 , 165 , 178 , 0.4 ) ;
}
. admin - pill -- gold {
color : # 8 a7048 ;
background : rgba ( 168 , 140 , 107 , 0.14 ) ;
border - color : rgba ( 168 , 140 , 107 , 0.28 ) ;
}
. admin - pill -- sky {
color : # 4 d6f86 ;
background : rgba ( 124 , 158 , 178 , 0.16 ) ;
border - color : rgba ( 124 , 158 , 178 , 0.3 ) ;
}
. admin - pill -- warn {
color : # b45309 ;
background : rgba ( 251 , 191 , 36 , 0.18 ) ;
border - color : rgba ( 251 , 191 , 36 , 0.35 ) ;
}
. admin - pill -- ok {
color : # 3 f6b4a ;
background : rgba ( 134 , 179 , 143 , 0.18 ) ;
border - color : rgba ( 134 , 179 , 143 , 0.35 ) ;
}
. admin - pill -- muted {
color : # 8 A8680 ;
background : # f3f1ec ;
border - color : # e7e3db ;
}
2026-08-01 16:55:39 +08:00
. danmaku - admin - dot {
width : 10 px ; height : 10 px ; border - radius : 9999 px ; flex - shrink : 0 ;
box - shadow : inset 0 0 0 1 px rgba ( 0 , 0 , 0 , 0.08 ) ;
}
2026-07-30 18:00:27 +08:00
< / style >