2026-07-30 18:00:27 +08:00
< template >
< div class = "min-h-screen bg-[#f6f4ef] text-[#2c2c2c]" >
<!-- 顶部栏 -- >
2026-08-03 13:36:16 +08:00
< header class = "admin-header sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-[#a88c6b]/15" >
< div class = "admin-header-row" >
< div class = "flex items-center gap-3 min-w-0 shrink-0" >
< span class = "text-xl text-[#a88c6b] font-serif shrink-0" > ❀ < / span >
< div class = "min-w-0" >
< div class = "text-sm tracking-[0.18em] font-light truncate" > 婚礼邀请函 · 内容编辑后台 < / div >
< div class = "text-[10px] text-[#8A8680] tracking-[0.12em] mt-0.5" >
制作者 · < span class = "text-[#a88c6b]" > 年糕崽崽 < / span >
< / div >
2026-08-03 13:21:22 +08:00
< / div >
2026-07-30 18:00:27 +08:00
< / div >
2026-08-03 13:36:16 +08:00
<!-- 顶部菜单 : 一级分类 + 下拉 -- >
< nav v-if = "menuLayout === 'top'" class="admin-top-menu" aria-label="后台菜单" >
< a-dropdown
v - for = "group in adminMenuGroups"
: key = "group.key"
: trigger = "['hover', 'click']"
placement = "bottomLeft"
>
< button
type = "button"
class = "admin-top-menu-trigger"
: class = "{ active: isMenuGroupActive(group) }"
>
< span > { { group . label } } < / span >
< i class = "fa-solid fa-chevron-down admin-top-menu-caret" / >
< / button >
< template # overlay >
< a-menu
class = "admin-top-dropdown"
: selected - keys = "[activeTab]"
@ click = "({ key }) => { activeTab = String(key) }"
>
< a-menu-item v-for = "item in group.items" :key="item.key" >
< i :class = "item.icon" class = "mr-2 text-[12px] opacity-80" / >
{ { item . label } }
< / a-menu-item >
< / a-menu >
< / template >
< / a-dropdown >
< / nav >
< div class = "admin-header-actions" >
< a-segmented
v - model : value = "menuLayout"
size = "small"
class = "admin-layout-seg"
: options = "menuLayoutOptions"
/ >
< a-button size = "small" @click ="openPreview" >
< i class = "fa-solid fa-eye mr-1 text-[#a88c6b]" > < / i > 预览请柬
2026-08-03 09:31:32 +08:00
< / a-button >
2026-08-03 13:36:16 +08:00
< a-dropdown >
< a-button size = "small" >
< i class = "fa-solid fa-scroll mr-1 text-[#a88c6b]" > < / i > 预览海报 < i class = "fa-solid fa-chevron-down ml-1 text-[10px]" > < / i >
< / a-button >
< template # overlay >
< a-menu >
2026-08-03 16:00:13 +08:00
< a-menu-item-group v-for = "s in POSTER_SIDES" :key="'g-' + s.key" :title="`${s.label}(${s.key})`" >
< a-menu-item
v - for = "l in POSTER_LAYOUTS"
: key = "`${s.key}-${l.value}`"
@ click = "openPosterPreview(s.key, l.value)"
>
{ { l . label } } { { s . route } } / { { l . value } }
< / a-menu-item >
< / a-menu-item-group >
2026-08-03 13:36:16 +08:00
< / a-menu >
< / template >
< / a-dropdown >
< a-button
v - if = "isConfigTab"
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 >
2026-07-30 18:00:27 +08:00
< / div >
< / header >
2026-08-03 13:21:22 +08:00
< div class = "admin-shell" : class = "menuLayout === 'side' ? 'admin-shell--side' : 'admin-shell--top'" >
2026-08-03 13:36:16 +08:00
< nav v-if = "menuLayout === 'side'" class="admin-nav" aria-label="后台菜单" >
2026-08-03 13:21:22 +08:00
< div
v - for = "group in adminMenuGroups"
: key = "group.key"
class = "admin-nav-group"
>
< div class = "admin-nav-group-title" > { { group . label } } < / div >
< div class = "admin-nav-items" >
< button
v - for = "item in group.items"
: key = "item.key"
type = "button"
class = "admin-nav-item"
: class = "{ active: activeTab === item.key }"
@ click = "activeTab = item.key"
>
< i :class = "item.icon" class = "admin-nav-icon" / >
< span > { { item . label } } < / span >
< / button >
< / div >
< / div >
< div class = "admin-nav-credit" > 制作者 · 年糕崽崽 < / div >
< / nav >
< main class = "admin-main" >
2026-08-02 19:04:04 +08:00
<!-- 概览 -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'overview'" >
< div class = "admin-panel" : class = "{ 'opacity-60 pointer-events-none': tabLoading }" >
2026-08-02 19:04:04 +08:00
< div class = "flex justify-between items-center mb-4 gap-3 flex-wrap" >
< div >
< div class = "text-sm text-[#2c2c2c]" > 访客概览 < / div >
2026-08-03 10:27:56 +08:00
< div class = "text-[11px] text-[#8A8680] mt-0.5" > 指纹去重访客 · 地区 / 机型画像 < / div >
2026-08-02 19:04:04 +08:00
< / div >
2026-08-03 10:27:56 +08:00
< a-button size = "small" :loading = "visitStatsLoading" @click ="loadVisitStats" >
2026-08-02 19:04:04 +08:00
< 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" >
2026-08-03 10:27:56 +08:00
< div class = "text-[12px] text-[#7a6550] mb-2 tracking-wide" > 机型画像 < / div >
< div ref = "deviceChartRef" class = "h-[280px] w-full" > < / div >
2026-08-02 19:04:04 +08:00
< / div >
< / div >
2026-08-03 10:27:56 +08:00
< div class = "flex justify-between items-center mb-2 gap-2 flex-wrap" >
< div class = "text-[12px] text-[#7a6550] tracking-wide" > 近期访客预览 < / div >
< a-button size = "small" type = "link" class = "!text-[#a88c6b] !px-0" @click ="activeTab = 'visits'" >
查看全部访客记录 < i class = "fa-solid fa-arrow-right ml-1 text-[10px]" > < / i >
< / a-button >
< / div >
< div class = "visit-preview-wrap" >
< a-table
class = "admin-table"
: columns = "visitPreviewColumns"
: data - source = "visitPreviewList"
: loading = "visitListLoading"
row - key = "id"
size = "small"
: pagination = "false"
: scroll = "{ y: 260 }"
>
< 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 === 'device'" >
< span class = "text-[12px]" > { { record . device || '未知' } } < / span >
< / template >
< template v-else-if = "column.key === 'last_seen_at'" >
< span class = "text-[12px] text-[#8A8680]" > { { formatTime ( record . last _seen _at ) } } < / span >
< / template >
2026-08-02 19:04:04 +08:00
< / template >
2026-08-03 10:27:56 +08:00
< / a-table >
< / div >
< / div >
2026-08-03 13:21:22 +08:00
< / section >
2026-08-03 10:27:56 +08:00
2026-08-03 12:20:55 +08:00
<!-- 礼物 -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'gifts'" >
< div class = "admin-panel" : class = "{ 'opacity-60 pointer-events-none': tabLoading }" >
2026-08-03 12:20:55 +08:00
< 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" > 各礼物收到次数 · 合计 { { giftStats . total } } < / div >
< / div >
2026-08-03 15:15:12 +08:00
< a-button size = "small" : loading = "giftStatsLoading || giftListLoading" @click ="() => Promise.all([loadGiftStats(), loadGiftList()])" >
2026-08-03 12:20:55 +08:00
< i class = "fa-solid fa-rotate-right mr-1" > < / i > 刷新
< / a-button >
< / div >
< div class = "grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6" >
< div v-for = "g in giftTypeList" :key="g.key" class="stat-card" >
< div class = "stat-label calligraphy" > { { g . label } } < / div >
< div class = "stat-value" > { { giftStats . counts [ g . key ] || 0 } } < / div >
< div class = "text-[10px] text-[#8A8680] mt-1" > 兑换 { { giftCostsEdit [ g . key ] || g . cost } } 赞 < / div >
< / div >
< / div >
< div class = "flex justify-between items-center mb-3 gap-3 flex-wrap" >
< div >
< div class = "text-sm text-[#2c2c2c]" > 礼物兑换配置 < / div >
< div class = "text-[11px] text-[#8A8680] mt-0.5" > 每个礼物消耗的点赞次数 · 默认阶梯 50 / 100 / 200 < / div >
< / div >
< div class = "flex gap-2" >
< a-button size = "small" @click ="resetGiftCostsEdit" > 恢复默认 < / a -button >
< a-button size = "small" type = "primary" class = "!bg-[#a88c6b]" :loading = "giftCostsSaving" @click ="saveGiftCostsEdit" >
保存配置
< / a-button >
< / div >
< / div >
< div class = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3" >
< div v-for = "g in giftTypeList" :key="'cost-' + g.key" class="stat-card !py-3" >
< div class = "stat-label calligraphy mb-2" > { { g . label } } < / div >
< a-input-number
v - model : value = "giftCostsEdit[g.key]"
: min = "1"
: max = "10000"
: step = "10"
class = "w-full"
addon - after = "赞"
/ >
< / div >
< / div >
2026-08-03 15:15:12 +08:00
< div class = "flex justify-between items-center mt-8 mb-3 gap-3 flex-wrap" >
< div >
< div class = "text-sm text-[#2c2c2c]" > 礼物明细 < / div >
< div class = "text-[11px] text-[#8A8680] mt-0.5" > 共 { { giftList . length } } 条送礼记录 < / div >
< / div >
< a-button size = "small" :loading = "giftListLoading" @click ="loadGiftList" >
< i class = "fa-solid fa-rotate-right mr-1" > < / i > 刷新列表
< / a-button >
< / div >
< a-table
class = "admin-table"
: columns = "giftListColumns"
: data - source = "giftList"
: loading = "giftListLoading"
row - key = "id"
size = "middle"
: pagination = "{ pageSize: 10, hideOnSinglePage: true }"
: scroll = "{ x: 720 }"
>
< template # bodyCell = "{ column, record }" >
< template v-if = "column.key === 'gift_type'" >
< span class = "admin-pill admin-pill--rose" > { { giftTypeLabel ( record . gift _type ) } } < / span >
< / template >
< template v-else-if = "column.key === 'cost'" >
< span > { { record . cost || 0 } } 赞 < / 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-button size = "small" danger type = "text" @click ="onDeleteGift(record)" > 删除 < / a -button >
< / template >
< / template >
< / a-table >
2026-08-03 12:20:55 +08:00
< / div >
2026-08-03 13:21:22 +08:00
< / section >
2026-08-03 12:20:55 +08:00
2026-08-03 10:27:56 +08:00
<!-- 访客记录 -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'visits'" >
< div class = "admin-panel" : class = "{ 'opacity-60 pointer-events-none': tabLoading }" >
2026-08-03 10:27:56 +08:00
< 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" > 共 { { visitTotal } } 条 , 分页浏览 < / div >
< / div >
< a-button size = "small" :loading = "visitListLoading" @click ="loadVisitList" >
< i class = "fa-solid fa-rotate-right mr-1" > < / i > 刷新
< / a-button >
< / div >
< div class = "visit-list-wrap" >
< a-table
class = "admin-table"
: columns = "visitColumns"
: data - source = "visitList"
: loading = "visitListLoading"
row - key = "id"
size = "middle"
: pagination = "visitPagination"
: scroll = "{ y: 'calc(100dvh - 280px)', x: 960 }"
@ 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 === 'device'" >
< span class = "text-[12px]" :title = "record.user_agent" > { { record . device || '未知' } } < / 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 >
2026-08-02 19:04:04 +08:00
< / template >
2026-08-03 10:27:56 +08:00
< / a-table >
< / div >
2026-08-02 19:04:04 +08:00
< / div >
2026-08-03 13:21:22 +08:00
< / section >
2026-08-02 19:04:04 +08:00
2026-07-30 18:00:27 +08:00
<!-- 基础信息 -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'basic'" >
< a-card :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-08-03 16:00:13 +08:00
< div class = "flex flex-wrap items-center justify-between gap-2 mb-1" >
< div class = "text-[11px] text-[#8A8680]" >
原图用于预览 ; 展示图为等比缩放后的体积 ( 不裁切 ) , 前台封面 / 揭幕 / 尾页优先用展示图 。
< span v-if = "resizeProgress && resizingBasic" class="text-[#a88c6b] ml-1" > {{ resizeProgress }} < / span >
< / div >
< a-button
size = "small"
type = "primary"
class = "!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
: loading = "resizingBasic"
: disabled = "uploading || (!cfg.heroImg && !cfg.endImg)"
@ click = "batchResizeBasicImages"
>
< i class = "fa-solid fa-compress mr-1" > < / i > 一键缩放并上传
< / a-button >
< / 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 = "封面图片" >
2026-08-03 16:00:13 +08:00
< div class = "relative w-[96px]" >
< image-field
v - model = "cfg.heroImg"
label = "封面"
@ pick = "openUpload(u => setBasicOriginal('heroImg', u))"
: uploading = "uploading"
/ >
< div v-if = "cfg.heroImgResized" class="text-center mt-1" >
< div class = "text-[9px] text-[#a88c6b]" > 展示已生成 < / div >
< div v-if = "cfg.heroImgResizedMeta" class="resized-meta" > {{ cfg.heroImgResizedMeta }} < / div >
< / div >
< / div >
2026-07-31 13:09:49 +08:00
< div class = "text-[11px] text-[#8A8680] mt-2" > 用于首页封面和入场揭幕背景 。 < / div >
< / a-form-item >
< a-form-item label = "尾页图片" >
2026-08-03 16:00:13 +08:00
< div class = "relative w-[96px]" >
< image-field
v - model = "cfg.endImg"
label = "尾页"
@ pick = "openUpload(u => setBasicOriginal('endImg', u))"
: uploading = "uploading"
/ >
< div v-if = "cfg.endImgResized" class="text-center mt-1" >
< div class = "text-[9px] text-[#a88c6b]" > 展示已生成 < / div >
< div v-if = "cfg.endImgResizedMeta" class="resized-meta" > {{ cfg.endImgResizedMeta }} < / div >
< / div >
< / div >
2026-07-31 13:09:49 +08:00
< 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 >
2026-08-03 13:21:22 +08:00
< / section >
2026-07-30 18:00:27 +08:00
<!-- 文案 -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'copy'" >
< a-card :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 >
2026-08-03 13:21:22 +08:00
< / section >
2026-07-30 18:00:27 +08:00
<!-- 视觉 / 动效 -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'visual'" >
< a-card :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 >
2026-08-03 13:21:22 +08:00
< / section >
2026-07-30 18:00:27 +08:00
2026-07-31 09:05:15 +08:00
<!-- 背景音乐 -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'music'" >
< div class = "admin-panel" >
2026-08-02 19:04:04 +08:00
< 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-08-03 13:21:22 +08:00
< / section >
2026-07-31 09:05:15 +08:00
2026-07-31 13:09:49 +08:00
<!-- 上传设置 -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'upload'" >
< a-card :bordered = "false" class = "!shadow-sm" >
2026-07-31 13:09:49 +08:00
< 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 >
2026-08-03 13:21:22 +08:00
< / section >
2026-07-31 13:09:49 +08:00
2026-08-03 10:22:04 +08:00
<!-- 海报 ( 独立 poster _configs : 1 男方 / hb - n , 2 女方 / hb - nv , 3 回门宴 / hb - hm ) -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'poster'" >
< a-card :bordered = "false" class = "!shadow-sm" >
2026-08-03 09:31:32 +08:00
< a-alert
type = "info"
show - icon
class = "mb-4"
2026-08-03 16:00:13 +08:00
message = "每一方同时维护竖版(/1)与横版(/2)。例:男方竖版 /hb-n/1, 横版 /hb-n/2; 女方 /hb-nv/1|/2; 回门 /hb-hm/1|/2。"
2026-08-03 09:31:32 +08:00
/ >
2026-08-03 16:00:13 +08:00
< a-tabs v -model :activeKey = "posterSideTab" size = "small" type = "card" class = "mb-2" >
2026-08-03 10:22:04 +08:00
< a-tab-pane v-for = "s in POSTER_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" / >
2026-08-03 09:31:32 +08:00
< / a-tabs >
2026-08-03 16:00:13 +08:00
< a-tabs v -model :activeKey = "posterLayoutTab" size = "small" class = "mb-3" >
< a-tab-pane
v - for = "l in POSTER_LAYOUTS"
: key = "l.value"
: tab = "`${l.label} (/${l.value})`"
/ >
< / a-tabs >
2026-08-03 09:31:32 +08:00
< a-form layout = "vertical" : class = "{ 'opacity-60 pointer-events-none': posterLoading }" >
2026-08-03 16:00:13 +08:00
< a-form-item label = "所属方 / 风格" >
2026-08-03 10:22:04 +08:00
< a-tag color = "gold" > { { currentPosterSide . key } } · { { currentPosterSide . label } } < / a-tag >
2026-08-03 16:00:13 +08:00
< a-tag color = "orange" > { { currentPosterLayout . label } } < / a-tag >
< span class = "text-[11px] text-[#8A8680] ml-2" > 路由 { { currentPosterPreviewPath } } < / span >
2026-08-03 09:54:43 +08:00
< / a-form-item >
< a-form-item label = "版式模板" >
< a-radio-group v -model :value = "posterCfg.template" class = "poster-tpl-group" >
< a-radio-button v-for = "t in POSTER_TEMPLATES" :key="t.value" :value="t.value" >
{ { t . label } }
< / a-radio-button >
< / a-radio-group >
< div class = "text-[11px] text-[#8A8680] mt-1" >
{ { POSTER _TEMPLATES . find ( t => t . value === posterCfg . template ) ? . desc } }
< / div >
< / a-form-item >
2026-08-03 10:18:23 +08:00
< a-form-item label = "手机端布局" >
< a-radio-group v -model :value = "posterCfg.mobileLayout" >
< a-radio-button v-for = "t in MOBILE_LAYOUTS" :key="t.value" :value="t.value" >
{ { t . label } }
< / a-radio-button >
< / a-radio-group >
< div class = "text-[11px] text-[#8A8680] mt-1" >
{ { MOBILE _LAYOUTS . find ( t => t . value === posterCfg . mobileLayout ) ? . desc } } ( 仅窄屏生效 , PC 不受影响 )
< / div >
< / a-form-item >
2026-08-03 09:31:32 +08:00
< div class = "grid grid-cols-1 md:grid-cols-3 gap-4" >
< a-form-item label = "启用海报页" >
< a-switch v -model :checked = "posterCfg.enabled" / >
< / a-form-item >
< a-form-item label = "显示跳转请柬按钮" >
< a-switch v -model :checked = "posterCfg.showInviteButton" / >
< / a-form-item >
< a-form-item label = "按钮文案" >
< a-input v -model :value = "posterCfg.inviteButtonText" :disabled = "!posterCfg.showInviteButton" / >
< / a-form-item >
< / div >
< div class = "grid grid-cols-1 md:grid-cols-2 gap-x-6" >
< a-form-item label = "进页 Loading" >
< a-select v -model :value = "posterCfg.loadingEffect" >
< a-select-option v-for = "opt in LOADING_EFFECTS" :key="opt.value" :value="opt.value" > {{ opt.label }} < / a -select -option >
< / a-select >
< / a-form-item >
< a-form-item label = "打开页面过场" >
< a-select v -model :value = "posterCfg.enterEffect" >
< a-select-option v-for = "opt in ENTER_EFFECTS" :key="opt.value" :value="opt.value" > {{ opt.label }} < / a -select -option >
< / a-select >
< / a-form-item >
< a-form-item label = "跳转路径" >
< a-input v -model :value = "posterCfg.invitePath" placeholder = "/" / >
< / a-form-item >
< / div >
2026-08-03 13:01:08 +08:00
< a-form-item label = "背景音乐" >
< a-select
v - model : value = "posterCfg.musicUrl"
allow - clear
show - search
option - filter - prop = "label"
placeholder = "不播放(从已有曲目选择)"
: options = "posterMusicSelectOptions"
: loading = "posterMusicLoading"
/ >
< div class = "text-[11px] text-[#8A8680] mt-1" >
曲目来自 「 背景音乐 」 页 ; 清空则该海报不播放 。 可到
< a class = "text-[#a88c6b] cursor-pointer" @click ="activeTab = 'music'" > 背景音乐 < / a >
上传管理 。
< / div >
< / a-form-item >
2026-08-03 10:25:11 +08:00
< a-form-item label = "页面标题(浏览器标签 / 分享)" >
< a-input
v - model : value = "posterCfg.pageTitle"
: placeholder = "currentPosterSide.label + '海报页面标题'"
/ >
< / a-form-item >
< a-form-item label = "页面描述 description" >
< a-textarea
v - model : value = "posterCfg.pageDescription"
: rows = "2"
placeholder = "用于 meta description / 分享摘要"
/ >
< / a-form-item >
2026-08-03 09:54:43 +08:00
< a-form-item : label = "posterCfg.template === 'split' ? '左侧照片' : '顶部照片'" >
2026-08-03 10:22:04 +08:00
< image-field
v - model = "posterCfg.heroImg"
label = "海报图片"
@ pick = "openUpload(setPosterHero)"
: uploading = "uploading"
/ >
2026-08-03 09:31:32 +08:00
< / a-form-item >
2026-08-03 13:19:09 +08:00
< a-form-item label = "照片展示焦点" >
2026-08-03 16:54:14 +08:00
< a-radio-group v -model :value = "heroFocusDevice" size = "small" class = "mb-2" button -style = " solid " >
< a-radio-button value = "pc" > PC 端 < / a-radio-button >
< a-radio-button value = "mobile" > 手机端 < / a-radio-button >
< / a-radio-group >
2026-08-03 13:19:09 +08:00
< div
v - if = "posterCfg.heroImg"
ref = "heroFocusPreviewEl"
class = "hero-focus-preview hero-focus-preview--drag"
2026-08-03 16:54:14 +08:00
: class = "{ dragging: heroFocusDragging, 'is-mobile': heroFocusDevice === 'mobile' }"
2026-08-03 13:19:09 +08:00
@ pointerdown = "onHeroFocusPointerDown"
>
< img
: src = "posterCfg.heroImg"
alt = ""
draggable = "false"
2026-08-03 16:54:14 +08:00
: style = "{ objectPosition: heroFocusPreviewCss }"
2026-08-03 13:19:09 +08:00
/ >
< span
class = "hero-focus-crosshair"
2026-08-03 16:54:14 +08:00
: style = "{ left: heroFocusActiveX + '%', top: heroFocusActiveY + '%' }"
2026-08-03 13:19:09 +08:00
/ >
< / div >
< div v-else class = "hero-focus-preview hero-focus-preview--empty" >
请先上传海报图片 , 再拖拽设置焦点
< / div >
< div class = "hero-focus-pad mt-2" : style = "{ '--hero-focus-n': HERO_FOCUS_GRID }" >
< button
v - for = "pt in heroFocusGrid"
: key = "pt.key"
type = "button"
class = "hero-focus-cell"
: class = "{ active: isHeroFocusGridActive(pt) }"
: title = "`${Math.round(pt.x)}%, ${Math.round(pt.y)}%`"
@ click = "setHeroFocus(pt.x, pt.y)"
>
< span class = "hero-focus-dot" / >
< / button >
< / div >
< div class = "text-[11px] text-[#8A8680] mt-1" >
2026-08-03 16:54:14 +08:00
{ { heroFocusDevice === 'mobile' ? '手机端' : 'PC 端' } } 焦点 : 拖拽十字准星或点网格 ; 当前
{ { Math . round ( heroFocusActiveX ) } } % · { { Math . round ( heroFocusActiveY ) } } %
2026-08-03 13:19:09 +08:00
< / div >
< / a-form-item >
2026-08-03 10:18:23 +08:00
< a-form-item label = "二维码中心图" >
< image-field
v - model = "posterCfg.qrCenterImg"
label = "中心图(可选)"
2026-08-03 10:22:04 +08:00
@ pick = "openUpload(setPosterQrCenter)"
2026-08-03 10:18:23 +08:00
: uploading = "uploading"
/ >
< div class = "text-xs text-stone-500 mt-1" > 不配置时 , 二维码中心为圆圈大 「 囍 」 字 < / div >
< / a-form-item >
2026-08-03 09:54:43 +08:00
<!-- classic 字段 -- >
< template v-if = "posterCfg.template === 'classic'" >
< div class = "grid grid-cols-1 md:grid-cols-2 gap-x-6" >
2026-08-03 10:22:04 +08:00
< a-form-item :label = "posterNameLabels.left" >
2026-08-03 09:54:43 +08:00
< div class = "flex gap-2" >
2026-08-03 10:22:04 +08:00
< a-input v -model :value = "posterCfg.sonLabel" class = "!w-[100px]" :placeholder = "posterNameLabels.leftPh" / >
2026-08-03 09:54:43 +08:00
< a-input v -model :value = "posterCfg.sonName" placeholder = "姓名" / >
< / div >
< / a-form-item >
2026-08-03 10:22:04 +08:00
< a-form-item :label = "posterNameLabels.right" >
2026-08-03 09:54:43 +08:00
< div class = "flex gap-2" >
2026-08-03 10:22:04 +08:00
< a-input v -model :value = "posterCfg.daughterInLawLabel" class = "!w-[100px]" :placeholder = "posterNameLabels.rightPh" / >
2026-08-03 09:54:43 +08:00
< a-input v -model :value = "posterCfg.daughterInLawName" placeholder = "姓名" / >
< / div >
< / a-form-item >
< / div >
< a-form-item label = "主标题" >
< a-input v -model :value = "posterCfg.title" placeholder = "婚礼邀请函" / >
2026-08-03 09:31:32 +08:00
< / a-form-item >
2026-08-03 09:54:43 +08:00
< a-form-item label = "正文(每行一条)" >
< a-textarea v -model :value = "posterLinesText" :rows = "10" placeholder = "每行一条正文" / >
2026-08-03 09:31:32 +08:00
< / a-form-item >
2026-08-03 09:54:43 +08:00
< a-form-item label = "底部文案" >
< a-input v -model :value = "posterCfg.footer" / >
< / a-form-item >
< / template >
<!-- split 字段 -- >
< template v-else >
< div class = "grid grid-cols-1 md:grid-cols-3 gap-x-6" >
< a-form-item label = "竖排主标" >
< a-input v -model :value = "posterCfg.verticalSlogan" placeholder = "吾家有喜" / >
< / a-form-item >
< a-form-item label = "副标一" >
< a-input v -model :value = "posterCfg.subSlogan1" placeholder = "佳偶天成" / >
< / a-form-item >
< a-form-item label = "副标二" >
< a-input v -model :value = "posterCfg.subSlogan2" placeholder = "百年好合" / >
< / a-form-item >
< / div >
< a-form-item label = "问候语" >
< a-input v -model :value = "posterCfg.greeting" placeholder = "尊敬的各位亲朋好友" / >
< / a-form-item >
< a-form-item label = "正文(每行一条,建议 2~ 4 行)" >
< a-textarea v -model :value = "posterLinesText" :rows = "5" placeholder = "每行一条正文" / >
< / a-form-item >
< div class = "grid grid-cols-1 md:grid-cols-2 gap-x-6" >
< a-form-item label = "新郎" >
< a-input v -model :value = "posterCfg.groomName" / >
< / a-form-item >
< a-form-item label = "新娘" >
< a-input v -model :value = "posterCfg.brideName" / >
< / a-form-item >
< a-form-item label = "喜宴时间" >
< a-input v -model :value = "posterCfg.dateText" / >
< / a-form-item >
< a-form-item label = "农历" >
< a-input v -model :value = "posterCfg.lunarText" / >
< / a-form-item >
< / div >
< a-form-item label = "地点" >
< a-input v -model :value = "posterCfg.venueText" / >
< / a-form-item >
< a-form-item label = "底部 Love 文案" >
< a-input v -model :value = "posterCfg.loveFooter" placeholder = "Love you" / >
< / a-form-item >
< / template >
2026-08-03 09:31:32 +08:00
< div class = "flex gap-2 flex-wrap" >
< a-button type = "primary" :loading = "posterSaving" @click ="onSavePosterConfig"
class = "!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]" >
< i class = "fa-solid fa-floppy-disk mr-1" > < / i > 保存海报配置
< / a-button >
2026-08-03 16:00:13 +08:00
< a-button @click ="openPosterPreview(posterSideTab, posterLayoutTab)" >
< i class = "fa-solid fa-eye mr-1" > < / i > 打开 { { currentPosterPreviewPath } } 预览
2026-08-03 09:31:32 +08:00
< / a-button >
< / div >
< / a-form >
< / a-card >
2026-08-03 13:21:22 +08:00
< / section >
2026-08-03 09:31:32 +08:00
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'photos'" >
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 >
<!-- 图片上传区 : 按版式渲染 -- >
2026-08-03 08:57:18 +08:00
< div class = "flex flex-wrap items-center justify-between gap-2 mt-2 mb-2" >
< div class = "text-[11px] text-[#8A8680]" >
原图用于预览 ; 展示图为等比缩放后的体积 ( 不裁切 ) 。
< span v-if = "resizeProgress" class="text-[#a88c6b] ml-1" > {{ resizeProgress }} < / span >
< / div >
< a-button
size = "small"
type = "primary"
class = "!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
: loading = "resizingPage"
: disabled = "uploading || !pageHasOriginal(page)"
@ click = "batchResizeAndUpload(page)"
>
< i class = "fa-solid fa-compress mr-1" > < / i > 一键缩放并上传
< / a-button >
< / div >
2026-07-30 18:00:27 +08:00
< div class = "flex flex-wrap gap-3 mt-2" >
< template v-if = "page.type==='single'" >
2026-08-03 08:57:18 +08:00
< div class = "relative w-[96px]" >
< image-field v-model = "page.img1" @pick="openUpload(u => setOriginalAndClearResized(page, 'img1', u))" :uploading="uploading" / >
< div v-if = "page.img1Resized" class="text-center mt-1" >
< div class = "text-[9px] text-[#a88c6b]" > 展示已生成 < / div >
< div v-if = "page.img1ResizedMeta" class="resized-meta" > {{ page.img1ResizedMeta }} < / div >
< / div >
< / div >
2026-07-30 18:00:27 +08:00
< / template >
< template v-else-if = "page.type==='overlap'" >
2026-08-03 08:57:18 +08:00
< div class = "relative w-[96px]" >
< image-field v-model = "page.img1" label="图一" @pick="openUpload(u => setOriginalAndClearResized(page, 'img1', u))" :uploading="uploading" / >
< div v-if = "page.img1Resized" class="text-center mt-1" >
< div class = "text-[9px] text-[#a88c6b]" > 展示已生成 < / div >
< div v-if = "page.img1ResizedMeta" class="resized-meta" > {{ page.img1ResizedMeta }} < / div >
< / div >
< / div >
< div class = "relative w-[96px]" >
< image-field v-model = "page.img2" label="图二" @pick="openUpload(u => setOriginalAndClearResized(page, 'img2', u))" :uploading="uploading" / >
< div v-if = "page.img2Resized" class="text-center mt-1" >
< div class = "text-[9px] text-[#a88c6b]" > 展示已生成 < / div >
< div v-if = "page.img2ResizedMeta" class="resized-meta" > {{ page.img2ResizedMeta }} < / div >
< / div >
< / div >
2026-07-30 18:00:27 +08:00
< / template >
< template v-else >
2026-08-03 08:57:18 +08:00
< div v-for = "(img, iidx) in page.images" :key="iidx" class="relative w-[96px]" >
2026-07-31 09:05:15 +08:00
< image-field v-model = "page.images[iidx]"
: label = "`图${iidx+1}`"
2026-08-03 08:57:18 +08:00
@ pick = "openUpload(u => setOriginalImageAt(page, iidx, u))"
2026-07-31 09:05:15 +08:00
: uploading = "uploading" / >
2026-08-03 08:57:18 +08:00
< div v-if = "page.imagesResized?.[iidx]" class="text-center mt-1" >
< div class = "text-[9px] text-[#a88c6b]" > 展示已生成 < / div >
< div v-if = "page.imagesResizedMeta?.[iidx]" class="resized-meta" > {{ page.imagesResizedMeta [ iidx ] }} < / div >
< / div >
2026-07-31 09:05:15 +08:00
< 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-08-03 13:21:22 +08:00
< / section >
2026-07-30 18:00:27 +08:00
<!-- 婚礼流程 -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'schedule'" >
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-08-03 13:21:22 +08:00
< / section >
2026-07-30 18:00:27 +08:00
<!-- 回执列表 -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'rsvp'" >
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-08-03 14:27:32 +08:00
< template v-else-if = "column.key==='actions'" >
< a-button size = "small" danger type = "text" @click ="onDeleteRsvp(record)" > 删除 < / a -button >
< / template >
2026-07-30 18:00:27 +08:00
< / template >
2026-08-02 19:04:04 +08:00
< / a-table >
< / div >
2026-08-03 13:21:22 +08:00
< / section >
2026-08-01 16:55:39 +08:00
2026-08-03 14:27:32 +08:00
<!-- 礼金 -- >
< section v-if = "activeTab === 'cash'" >
< div class = "admin-panel" >
< 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" >
共 { { cashGiftCount } } 笔 · 合计 ¥ { { cashGiftTotal } }
· 前台录入 < a class = "text-[#a88c6b]" href = "/lijin" target = "_blank" > / lijin < / a >
· 明细 < a class = "text-[#a88c6b]" href = "/lijin/list" target = "_blank" > / lijin / list < / a >
< / div >
< / div >
< div class = "flex gap-2" >
< a-button size = "small" @click ="openCashGiftCreate" > 新增 < / a -button >
< a-button size = "small" :loading = "cashGiftLoading" @click ="loadCashGifts" >
< i class = "fa-solid fa-rotate-right mr-1" > < / i > 刷新
< / a-button >
< / div >
< / div >
< a-table
class = "admin-table"
: columns = "cashGiftColumns"
: data - source = "cashGiftList"
: loading = "cashGiftLoading"
row - key = "id"
size = "middle"
: pagination = "{ pageSize: 10, hideOnSinglePage: true }"
>
< template # bodyCell = "{ column, record }" >
< template v-if = "column.key==='amount'" >
< span class = "text-[#a88c6b] font-medium" > ¥ { { record . amount } } < / 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 = "link" class = "!text-[#a88c6b] !px-1" @click ="openCashGiftEdit(record)" > 编辑 < / a -button >
< a-button size = "small" danger type = "text" @click ="onDeleteCashGift(record)" > 删除 < / a -button >
< / a-space >
< / template >
< / template >
< / a-table >
< / div >
< div class = "admin-panel mt-4" >
< div class = "flex justify-between items-center mb-3 gap-3 flex-wrap" >
< div >
< div class = "text-sm text-[#2c2c2c]" > 记账口令 < / div >
< div class = "text-[11px] text-[#8A8680] mt-0.5" >
前台 < a class = "text-[#a88c6b]" href = "/lijin" target = "_blank" > / lijin < / a > 登记礼金前需输入此口令
< / div >
< / div >
< a-button
size = "small"
type = "primary"
class = "!bg-[#a88c6b] !border-[#a88c6b]"
: loading = "cashEntryPassSaving"
@ click = "saveCashEntryPass"
> 保存口令 < / a-button >
< / div >
< a-input-password
v - model : value = "cashEntryPass"
placeholder = "设置记账口令"
class = "!max-w-[320px]"
: maxlength = "64"
/ >
< / div >
< div class = "admin-panel mt-4" >
< div class = "flex justify-between items-center mb-3 gap-3 flex-wrap" >
< div >
< div class = "text-sm text-[#2c2c2c]" > 查看全部口令 < / div >
< div class = "text-[11px] text-[#8A8680] mt-0.5" >
前台 < a class = "text-[#a88c6b]" href = "/lijin/list" target = "_blank" > / lijin / list < / a > 需输入口令后查看完整礼金列表
< / div >
< / div >
< a-button
size = "small"
type = "primary"
class = "!bg-[#a88c6b] !border-[#a88c6b]"
: loading = "cashViewPassSaving"
@ click = "saveCashViewPass"
> 保存口令 < / a-button >
< / div >
< a-input-password
v - model : value = "cashViewPass"
placeholder = "设置查看口令"
class = "!max-w-[320px]"
: maxlength = "64"
/ >
< / div >
< div class = "admin-panel mt-4" >
< div class = "flex justify-between items-center mb-3 gap-3 flex-wrap" >
< div >
< div class = "text-sm text-[#2c2c2c]" > 备注快捷选项 < / div >
< div class = "text-[11px] text-[#8A8680] mt-0.5" > 前台礼金页可一键点选 ; 男方 / 女方分开配置 < / div >
< / div >
< div class = "flex gap-2" >
< a-button size = "small" @click ="resetCashNotePresets" > 恢复默认 < / a -button >
< a-button
size = "small"
type = "primary"
class = "!bg-[#a88c6b] !border-[#a88c6b]"
: loading = "cashNotesSaving"
@ click = "saveCashNotePresets"
> 保存选项 < / a-button >
< / div >
< / div >
< div class = "flex flex-wrap gap-2 mb-3" >
< a-tag
v - for = "(n, i) in cashNotePresets"
: key = "`${n}-${i}`"
closable
color = "gold"
@ close . prevent = "removeCashNotePreset(i)"
> { { n } } < / a-tag >
< / div >
< div class = "flex gap-2 items-center flex-wrap" >
< a-input
v - model : value = "cashNoteDraft"
placeholder = "新增快捷备注,如:男方亲戚"
class = "!max-w-[280px]"
: maxlength = "30"
@ pressEnter = "addCashNotePreset"
/ >
< a-button size = "small" @click ="addCashNotePreset" > 添加 < / a -button >
< / div >
< / div >
< a-modal
v - model : open = "cashGiftModalOpen"
: title = "cashGiftEditingId ? '编辑礼金' : '新增礼金'"
ok - text = "保存"
cancel - text = "取消"
: confirm - loading = "cashGiftSaving"
@ ok = "saveCashGiftModal"
>
< a-form layout = "vertical" class = "mt-2" >
< a-form-item label = "姓名" required >
< a-input v -model :value = "cashGiftForm.name" placeholder = "宾客姓名" :maxlength = "20" / >
< / a-form-item >
< a-form-item label = "金额(元)" required >
< a-input-number v -model :value = "cashGiftForm.amount" :min = "1" :max = "10000000" class = "w-full" / >
< / a-form-item >
< a-form-item label = "备注" >
< div class = "flex flex-wrap gap-1.5 mb-2" >
< a-tag
v - for = "n in cashNotePresets"
: key = "'m-' + n"
class = "!cursor-pointer"
: color = "cashGiftForm.note === n ? 'gold' : undefined"
@ click = "cashGiftForm.note = n"
> { { n } } < / a-tag >
< / div >
< a-input v -model :value = "cashGiftForm.note" placeholder = "可选" :maxlength = "100" / >
< / a-form-item >
< / a-form >
< / a-modal >
< / section >
2026-08-01 16:55:39 +08:00
<!-- 弹幕审核 -- >
2026-08-03 13:21:22 +08:00
< section v-if = "activeTab === 'danmaku'" >
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-03 13:21:22 +08:00
< / section >
< / main >
2026-07-30 18:00:27 +08:00
< / div >
2026-08-03 16:00:13 +08:00
< MediaPickerModal
v - model : open = "mediaPickerOpen"
: model - value = "mediaPickerCurrent"
@ select = "onMediaPicked"
/ >
2026-07-30 18:00:27 +08:00
< / 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-03 14:27:32 +08:00
getConfigSection , saveConfigSection , uploadImage , getRsvpList , deleteRsvp , getUploadConfig , saveUploadConfig ,
2026-08-02 19:04:04 +08:00
getDanmakuList , updateDanmakuStatus , getVisitStats , getVisitList ,
2026-08-03 15:15:12 +08:00
getPosterConfig , savePosterConfig , getGiftStats , getGiftList , deleteGift , saveGiftCosts ,
2026-08-03 14:27:32 +08:00
getCashGiftList , updateCashGift , deleteCashGift , submitCashGift ,
getCashGiftNotes , saveCashGiftNotes ,
getCashGiftViewPass , saveCashGiftViewPass ,
getCashGiftEntryPass , saveCashGiftEntryPass ,
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-08-03 16:00:13 +08:00
import MediaPickerModal from '@/components/MediaPickerModal.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-08-03 15:15:12 +08:00
import { GIFT _TYPES , GIFT _LABEL _MAP , emptyGiftCosts , DEFAULT _GIFT _COSTS } from '@/utils/giftTypes'
2026-08-03 09:31:32 +08:00
import {
2026-08-03 16:00:13 +08:00
getSlotResizeSpecs ,
getBasicImageResizeSpecs ,
resizeSlotToFile ,
probeResizedMeta ,
metaHasFileSize ,
} from '@/composables/resizeImage'
import {
defaultPosterBundle , normalizePosterBundle , defaultPosterForLayout ,
LOADING _EFFECTS , ENTER _EFFECTS , POSTER _SIDES , POSTER _LAYOUTS , POSTER _TEMPLATES , MOBILE _LAYOUTS ,
HERO _FOCUS _GRID , heroFocusGridPoints , clampHeroFocus , heroFocusCss , sideKey , layoutKey ,
posterSideMeta , posterLayoutMeta , posterRoute ,
2026-08-03 09:31:32 +08:00
} from '@/composables/posterDefaults'
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'
2026-08-03 13:21:22 +08:00
const MENU _LAYOUT _KEY = 'wedding_admin_menu_layout'
2026-08-02 19:04:04 +08:00
const CONFIG _SECTIONS = [ 'basic' , 'copy' , 'visual' , 'music' , 'photos' , 'schedule' ]
2026-08-03 14:27:32 +08:00
const VALID _TABS = [ 'overview' , 'gifts' , 'visits' , ... CONFIG _SECTIONS , 'upload' , 'poster' , 'rsvp' , 'cash' , 'danmaku' ]
2026-08-02 19:04:04 +08:00
2026-08-03 13:21:22 +08:00
/** 后台菜单分组 */
const adminMenuGroups = [
{
key : 'data' ,
label : '数据看板' ,
items : [
{ key : 'overview' , label : '概览' , icon : 'fa-solid fa-chart-pie' } ,
{ key : 'visits' , label : '访客记录' , icon : 'fa-solid fa-users' } ,
{ key : 'gifts' , label : '礼物' , icon : 'fa-solid fa-gift' } ,
] ,
} ,
{
key : 'content' ,
label : '请柬内容' ,
items : [
{ key : 'basic' , label : '基础信息' , icon : 'fa-solid fa-heart' } ,
{ key : 'copy' , label : '邀请文案' , icon : 'fa-solid fa-pen-fancy' } ,
{ key : 'visual' , label : '视觉动效' , icon : 'fa-solid fa-wand-magic-sparkles' } ,
{ key : 'music' , label : '背景音乐' , icon : 'fa-solid fa-music' } ,
{ key : 'photos' , label : '相册页' , icon : 'fa-solid fa-images' } ,
{ key : 'schedule' , label : '婚礼流程' , icon : 'fa-solid fa-list-ol' } ,
] ,
} ,
{
key : 'interact' ,
label : '宾客互动' ,
items : [
{ key : 'rsvp' , label : '出席回执' , icon : 'fa-solid fa-envelope-open-text' } ,
2026-08-03 14:27:32 +08:00
{ key : 'cash' , label : '礼金' , icon : 'fa-solid fa-yen-sign' } ,
2026-08-03 13:21:22 +08:00
{ key : 'danmaku' , label : '弹幕审核' , icon : 'fa-solid fa-comments' } ,
] ,
} ,
{
key : 'system' ,
label : '海报与系统' ,
items : [
{ key : 'poster' , label : '海报' , icon : 'fa-solid fa-scroll' } ,
{ key : 'upload' , label : '上传设置' , icon : 'fa-solid fa-cloud-arrow-up' } ,
] ,
} ,
]
const menuLayoutOptions = [
{ value : 'top' , label : '顶部菜单' } ,
{ value : 'side' , label : '侧边菜单' } ,
]
2026-08-02 19:04:04 +08:00
const SECTION _KEYS = {
2026-08-03 16:00:13 +08:00
basic : [
'groom' , 'bride' , 'date' , 'lunar' , 'calendarDate' , 'hotel' , 'address' ,
'heroImg' , 'heroImgResized' , 'heroImgResizedMeta' ,
'endImg' , 'endImgResized' , 'endImgResizedMeta' ,
] ,
2026-08-02 19:04:04 +08:00
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'
}
2026-08-03 13:21:22 +08:00
function readStoredMenuLayout ( ) {
try {
const v = localStorage . getItem ( MENU _LAYOUT _KEY )
if ( v === 'top' || v === 'side' ) return v
} catch { /* ignore */ }
return 'side'
}
2026-08-02 19:04:04 +08:00
const activeTab = ref ( readStoredTab ( ) )
2026-08-03 13:21:22 +08:00
const menuLayout = ref ( readStoredMenuLayout ( ) )
watch ( menuLayout , ( v ) => {
try { localStorage . setItem ( MENU _LAYOUT _KEY , v ) } catch { /* ignore */ }
} )
2026-08-03 13:36:16 +08:00
function isMenuGroupActive ( group ) {
return Array . isArray ( group ? . items ) && group . items . some ( ( item ) => item . key === activeTab . value )
}
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-08-03 08:57:18 +08:00
const resizingPage = ref ( false )
2026-08-03 16:00:13 +08:00
const resizingBasic = ref ( false )
2026-08-03 08:57:18 +08:00
const resizeProgress = ref ( '' )
2026-07-31 13:09:49 +08:00
const uploadConfigSaving = ref ( false )
2026-08-03 09:31:32 +08:00
const posterSaving = ref ( false )
const posterLoading = ref ( false )
2026-08-03 13:01:08 +08:00
const posterMusicLoading = ref ( false )
/** 海报选曲用:独立缓存请柬 musicList, 避免依赖是否打开过音乐页 */
const posterMusicOptions = ref ( [ ] )
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-03 14:27:32 +08:00
const cashGiftLoading = ref ( false )
const cashGiftSaving = ref ( false )
const cashGiftList = ref ( [ ] )
const cashGiftTotal = ref ( 0 )
const cashGiftCount = ref ( 0 )
const cashGiftModalOpen = ref ( false )
const cashGiftEditingId = ref ( 0 )
const cashGiftForm = reactive ( { name : '' , amount : 1000 , note : '' } )
const cashNotePresets = ref ( [ ] )
const cashNoteDefaults = ref ( [ ] )
const cashNoteDraft = ref ( '' )
const cashNotesSaving = ref ( false )
const cashViewPass = ref ( '' )
const cashViewPassSaving = ref ( false )
const cashEntryPass = ref ( '' )
const cashEntryPassSaving = ref ( false )
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-03 10:27:56 +08:00
const visitStatsLoading = ref ( false )
const visitListLoading = ref ( false )
2026-08-03 11:24:55 +08:00
const giftStatsLoading = ref ( false )
2026-08-03 15:15:12 +08:00
const giftListLoading = ref ( false )
2026-08-03 12:20:55 +08:00
const giftCostsSaving = ref ( false )
2026-08-03 11:24:55 +08:00
const giftTypeList = GIFT _TYPES
2026-08-03 15:15:12 +08:00
const giftList = ref ( [ ] )
2026-08-03 11:24:55 +08:00
const giftStats = reactive ( {
total : 0 ,
counts : Object . fromEntries ( GIFT _TYPES . map ( ( g ) => [ g . key , 0 ] ) ) ,
} )
2026-08-03 12:20:55 +08:00
const giftCostsEdit = reactive ( emptyGiftCosts ( ) )
function applyGiftCostsFromServer ( costs ) {
if ( ! costs || typeof costs !== 'object' ) return
for ( const g of GIFT _TYPES ) {
const v = Number ( costs [ g . key ] )
if ( Number . isFinite ( v ) && v > 0 ) giftCostsEdit [ g . key ] = v
}
}
function resetGiftCostsEdit ( ) {
Object . assign ( giftCostsEdit , emptyGiftCosts ( ) )
}
async function saveGiftCostsEdit ( ) {
giftCostsSaving . value = true
try {
const payload = { }
for ( const g of GIFT _TYPES ) {
payload [ g . key ] = Number ( giftCostsEdit [ g . key ] ) || DEFAULT _GIFT _COSTS [ g . key ]
}
const res = await saveGiftCosts ( payload )
applyGiftCostsFromServer ( res . data ? . costs || payload )
message . success ( '礼物兑换配置已保存' )
} catch ( e ) {
message . error ( e ? . message || '保存失败' )
} finally {
giftCostsSaving . value = false
}
}
2026-08-03 10:27:56 +08:00
const visitStats = reactive ( { uv : 0 , pv : 0 , today _uv : 0 , regions : [ ] , devices : [ ] } )
2026-08-02 19:04:04 +08:00
const visitList = ref ( [ ] )
2026-08-03 10:27:56 +08:00
const visitPreviewList = ref ( [ ] )
2026-08-02 19:04:04 +08:00
const visitPage = ref ( 1 )
2026-08-03 10:27:56 +08:00
const visitPageSize = ref ( 20 )
2026-08-02 19:04:04 +08:00
const visitTotal = ref ( 0 )
const regionChartRef = ref ( null )
2026-08-03 10:27:56 +08:00
const deviceChartRef = ref ( null )
2026-08-02 19:04:04 +08:00
let regionChart = null
2026-08-03 10:27:56 +08:00
let deviceChart = null
2026-08-02 19:04:04 +08:00
2026-07-30 18:00:27 +08:00
const cfg = reactive ( defaultConfig ( ) )
2026-07-31 13:09:49 +08:00
const uploadCfg = reactive ( defaultUploadConfig ( ) )
2026-08-03 09:54:43 +08:00
const posterSideTab = ref ( '1' )
2026-08-03 16:00:13 +08:00
const posterLayoutTab = ref ( '1' )
2026-08-03 09:31:32 +08:00
const posterBundle = reactive ( defaultPosterBundle ( ) )
2026-08-03 16:00:13 +08:00
const posterCfg = computed ( ( ) => {
const s = sideKey ( posterSideTab . value )
const l = layoutKey ( posterLayoutTab . value )
if ( ! posterBundle [ s ] ) posterBundle [ s ] = { '1' : defaultPosterForLayout ( s , 1 ) , '2' : defaultPosterForLayout ( s , 2 ) }
if ( ! posterBundle [ s ] [ l ] ) posterBundle [ s ] [ l ] = defaultPosterForLayout ( s , l )
return posterBundle [ s ] [ l ]
} )
const mediaPickerOpen = ref ( false )
const mediaPickerCurrent = ref ( '' )
let mediaPickerOnUrl = null
function ensurePosterSlot ( side = posterSideTab . value , layout = posterLayoutTab . value ) {
const s = sideKey ( side )
const l = layoutKey ( layout )
if ( ! posterBundle [ s ] || typeof posterBundle [ s ] !== 'object' ) {
posterBundle [ s ] = { '1' : defaultPosterForLayout ( s , 1 ) , '2' : defaultPosterForLayout ( s , 2 ) }
}
if ( ! posterBundle [ s ] [ l ] ) posterBundle [ s ] [ l ] = defaultPosterForLayout ( s , l )
return posterBundle [ s ] [ l ]
}
2026-08-03 13:19:09 +08:00
const heroFocusGrid = heroFocusGridPoints ( HERO _FOCUS _GRID )
const heroFocusPreviewEl = ref ( null )
const heroFocusDragging = ref ( false )
2026-08-03 16:54:14 +08:00
const heroFocusDevice = ref ( 'pc' ) // pc | mobile
const heroFocusActiveX = computed ( ( ) => {
const cfg = posterCfg . value
if ( ! cfg ) return 50
if ( heroFocusDevice . value === 'mobile' ) {
return Number . isFinite ( Number ( cfg . heroFocusMobileX ) ) ? cfg . heroFocusMobileX : ( cfg . heroFocusX ? ? 50 )
}
return cfg . heroFocusX ? ? 50
} )
const heroFocusActiveY = computed ( ( ) => {
const cfg = posterCfg . value
if ( ! cfg ) return 50
if ( heroFocusDevice . value === 'mobile' ) {
return Number . isFinite ( Number ( cfg . heroFocusMobileY ) ) ? cfg . heroFocusMobileY : ( cfg . heroFocusY ? ? 50 )
}
return cfg . heroFocusY ? ? 50
} )
const heroFocusPreviewCss = computed ( ( ) => {
const cfg = posterCfg . value
if ( ! cfg ) return '50% 50%'
return heroFocusCss ( cfg , heroFocusDevice . value === 'mobile' ? 'mobile' : undefined )
} )
2026-08-03 13:19:09 +08:00
function setHeroFocus ( x , y ) {
const cfg = posterCfg . value
if ( ! cfg ) return
2026-08-03 16:54:14 +08:00
if ( heroFocusDevice . value === 'mobile' ) {
cfg . heroFocusMobileX = clampHeroFocus ( x )
cfg . heroFocusMobileY = clampHeroFocus ( y )
} else {
cfg . heroFocusX = clampHeroFocus ( x )
cfg . heroFocusY = clampHeroFocus ( y )
}
2026-08-03 13:19:09 +08:00
}
function isHeroFocusGridActive ( pt ) {
2026-08-03 16:54:14 +08:00
return Math . abs ( heroFocusActiveX . value - pt . x ) < 0.6
&& Math . abs ( heroFocusActiveY . value - pt . y ) < 0.6
2026-08-03 13:19:09 +08:00
}
function heroFocusFromClient ( clientX , clientY ) {
const el = heroFocusPreviewEl . value
if ( ! el ) return
const rect = el . getBoundingClientRect ( )
if ( rect . width <= 0 || rect . height <= 0 ) return
const x = ( ( clientX - rect . left ) / rect . width ) * 100
const y = ( ( clientY - rect . top ) / rect . height ) * 100
setHeroFocus ( x , y )
}
function onHeroFocusPointerMove ( e ) {
if ( ! heroFocusDragging . value ) return
heroFocusFromClient ( e . clientX , e . clientY )
}
function onHeroFocusPointerUp ( ) {
if ( ! heroFocusDragging . value ) return
heroFocusDragging . value = false
window . removeEventListener ( 'pointermove' , onHeroFocusPointerMove )
window . removeEventListener ( 'pointerup' , onHeroFocusPointerUp )
window . removeEventListener ( 'pointercancel' , onHeroFocusPointerUp )
}
function onHeroFocusPointerDown ( e ) {
if ( ! posterCfg . value ? . heroImg ) return
e . preventDefault ( )
heroFocusDragging . value = true
heroFocusFromClient ( e . clientX , e . clientY )
window . addEventListener ( 'pointermove' , onHeroFocusPointerMove )
window . addEventListener ( 'pointerup' , onHeroFocusPointerUp )
window . addEventListener ( 'pointercancel' , onHeroFocusPointerUp )
}
2026-08-03 13:01:08 +08:00
const posterMusicSelectOptions = computed ( ( ) => {
const list = Array . isArray ( posterMusicOptions . value ) ? posterMusicOptions . value : [ ]
const opts = list
. filter ( ( t ) => t && t . url )
. map ( ( t , i ) => ( {
value : t . url ,
label : t . name || ` 曲目 ${ i + 1 } ` ,
} ) )
const cur = posterCfg . value ? . musicUrl
if ( cur && ! opts . some ( ( o ) => o . value === cur ) ) {
opts . unshift ( { value : cur , label : ` (原曲目) ${ cur . slice ( 0 , 36 ) } ${ cur . length > 36 ? '…' : '' } ` } )
}
return opts
} )
2026-08-03 10:22:04 +08:00
const currentPosterSide = computed ( ( ) => posterSideMeta ( posterSideTab . value ) )
2026-08-03 16:00:13 +08:00
const currentPosterLayout = computed ( ( ) => posterLayoutMeta ( posterLayoutTab . value ) )
const currentPosterPreviewPath = computed ( ( ) => posterRoute ( posterSideTab . value , posterLayoutTab . value ) )
2026-08-03 10:22:04 +08:00
const posterNameLabels = computed ( ( ) => {
const s = sideKey ( posterSideTab . value )
if ( s === '2' ) {
return { left : '女儿称谓 / 姓名' , right : '女婿称谓 / 姓名' , leftPh : '女儿:' , rightPh : '女婿:' }
}
if ( s === '3' ) {
return { left : '新郎称谓 / 姓名' , right : '新娘称谓 / 姓名' , leftPh : '新郎:' , rightPh : '新娘:' }
}
return { left : '儿子称谓 / 姓名' , right : '儿媳称谓 / 姓名' , leftPh : '儿子:' , rightPh : '儿媳:' }
} )
2026-08-03 09:31:32 +08:00
const posterLinesText = computed ( {
get : ( ) => {
const lines = posterCfg . value ? . lines
return Array . isArray ( lines ) ? lines . join ( '\n' ) : ''
} ,
set : ( v ) => {
2026-08-03 16:00:13 +08:00
const slot = ensurePosterSlot ( )
slot . lines = String ( v || '' )
2026-08-03 09:31:32 +08:00
. split ( '\n' )
. map ( ( s ) => s . trimEnd ( ) )
} ,
} )
2026-08-03 09:54:43 +08:00
function setPosterHero ( url ) {
2026-08-03 16:00:13 +08:00
ensurePosterSlot ( ) . heroImg = url
2026-08-03 09:54:43 +08:00
}
2026-08-03 10:18:23 +08:00
function setPosterQrCenter ( url ) {
2026-08-03 16:00:13 +08:00
ensurePosterSlot ( ) . qrCenterImg = url
2026-08-03 10:18:23 +08:00
}
2026-08-03 16:00:13 +08:00
watch ( [ posterSideTab , posterLayoutTab ] , ( ) => {
const slot = ensurePosterSlot ( )
const l = layoutKey ( posterLayoutTab . value )
slot . layout = Number ( l )
} )
2026-07-31 13:09:49 +08:00
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 ,
2026-08-03 10:27:56 +08:00
pageSize : visitPageSize . value ,
2026-08-02 19:04:04 +08:00
total : visitTotal . value ,
2026-08-03 10:27:56 +08:00
showSizeChanger : true ,
pageSizeOptions : [ '10' , '20' , '50' , '100' ] ,
showTotal : ( t ) => ` 共 ${ t } 条 ` ,
2026-08-02 19:04:04 +08:00
} ) )
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-08-03 16:00:13 +08:00
heroImgResized : '' ,
heroImgResizedMeta : '' ,
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-08-03 16:00:13 +08:00
endImgResized : '' ,
endImgResizedMeta : '' ,
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-08-03 08:57:18 +08:00
function syncImagesResized ( p ) {
if ( ! Array . isArray ( p . images ) ) {
delete p . imagesResized
delete p . imagesResizedMeta
return
}
if ( ! Array . isArray ( p . imagesResized ) ) p . imagesResized = [ ]
if ( ! Array . isArray ( p . imagesResizedMeta ) ) p . imagesResizedMeta = [ ]
while ( p . imagesResized . length < p . images . length ) p . imagesResized . push ( '' )
while ( p . imagesResizedMeta . length < p . images . length ) p . imagesResizedMeta . push ( '' )
if ( p . imagesResized . length > p . images . length ) p . imagesResized . length = p . images . length
if ( p . imagesResizedMeta . length > p . images . length ) p . imagesResizedMeta . length = p . images . length
}
2026-07-30 18:00:27 +08:00
function ensurePageShape ( p ) {
2026-08-03 08:57:18 +08:00
if ( p . type === 'single' ) {
p . img1 = p . img1 || ''
p . img1Resized = p . img1Resized || ''
p . img1ResizedMeta = p . img1ResizedMeta || ''
delete p . img2
delete p . img2Resized
delete p . img2ResizedMeta
delete p . images
delete p . imagesResized
delete p . imagesResizedMeta
} else if ( p . type === 'overlap' ) {
p . img1 = p . img1 || ''
p . img2 = p . img2 || ''
p . img1Resized = p . img1Resized || ''
p . img2Resized = p . img2Resized || ''
p . img1ResizedMeta = p . img1ResizedMeta || ''
p . img2ResizedMeta = p . img2ResizedMeta || ''
delete p . images
delete p . imagesResized
delete p . imagesResizedMeta
} 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-08-03 08:57:18 +08:00
syncImagesResized ( p )
delete p . img1
delete p . img2
delete p . img1Resized
delete p . img2Resized
delete p . img1ResizedMeta
delete p . img2ResizedMeta
2026-07-30 18:00:27 +08:00
}
2026-08-03 08:57:18 +08:00
p . title = p . title || '' ; p . subtitle = p . subtitle || '' ; p . desc = p . desc || ''
2026-07-31 09:05:15 +08:00
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 )
}
2026-08-03 08:57:18 +08:00
function addImage ( page ) {
if ( ! Array . isArray ( page . images ) ) page . images = [ ]
page . images . push ( '' )
syncImagesResized ( page )
}
function removeImage ( page , i ) {
if ( page . images . length > 1 ) {
page . images . splice ( i , 1 )
if ( Array . isArray ( page . imagesResized ) ) page . imagesResized . splice ( i , 1 )
if ( Array . isArray ( page . imagesResizedMeta ) ) page . imagesResizedMeta . splice ( i , 1 )
syncImagesResized ( page )
}
}
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-08-03 08:57:18 +08:00
syncImagesResized ( page )
if ( Array . isArray ( page . imagesResized ) ) {
const r = page . imagesResized [ i ]
page . imagesResized [ i ] = page . imagesResized [ j ]
page . imagesResized [ j ] = r
}
if ( Array . isArray ( page . imagesResizedMeta ) ) {
const m = page . imagesResizedMeta [ i ]
page . imagesResizedMeta [ i ] = page . imagesResizedMeta [ j ]
page . imagesResizedMeta [ j ] = m
}
}
function pageHasOriginal ( page ) {
return getSlotResizeSpecs ( page ) . some ( ( s ) => ! ! s . getOriginal ( ) )
}
function setOriginalAndClearResized ( page , key , url ) {
page [ key ] = url
page [ ` ${ key } Resized ` ] = ''
page [ ` ${ key } ResizedMeta ` ] = ''
}
2026-08-03 16:00:13 +08:00
function setBasicOriginal ( key , url ) {
cfg [ key ] = url
cfg [ ` ${ key } Resized ` ] = ''
cfg [ ` ${ key } ResizedMeta ` ] = ''
}
async function fillMissingBasicResizedMeta ( ) {
await Promise . all ( [ 'heroImg' , 'endImg' ] . map ( async ( key ) => {
const original = cfg [ key ]
const resized = cfg [ ` ${ key } Resized ` ]
const metaKey = ` ${ key } ResizedMeta `
if ( ! resized || ! original || metaHasFileSize ( cfg [ metaKey ] ) ) return
try {
cfg [ metaKey ] = await probeResizedMeta ( original , resized )
} catch { /* 跨域/失效图忽略 */ }
} ) )
}
async function batchResizeBasicImages ( ) {
const slots = getBasicImageResizeSpecs ( cfg ) . filter ( ( s ) => ! ! s . getOriginal ( ) )
if ( ! slots . length ) {
message . warning ( '请先上传封面或尾页原图' )
return
}
resizingBasic . value = true
resizeProgress . value = ` 0/ ${ slots . length } `
let ok = 0
let fail = 0
try {
for ( let i = 0 ; i < slots . length ; i += 1 ) {
const slot = slots [ i ]
resizeProgress . value = ` ${ i + 1 } / ${ slots . length } `
try {
const { file , meta } = await resizeSlotToFile (
slot . getOriginal ( ) ,
slot . maxWidth ,
slot . maxHeight ,
` resized-basic- ${ slot . key } - ${ Date . now ( ) } .jpg ` ,
)
const res = await uploadImage ( file )
if ( ! res ? . url ) throw new Error ( '上传无返回地址' )
slot . setResized ( res . url , meta )
ok += 1
} catch ( err ) {
fail += 1
console . warn ( '[resize-basic]' , slot . key , err )
}
}
if ( fail === 0 ) message . success ( ` 已生成 ${ ok } 张展示图 ` )
else if ( ok > 0 ) message . warning ( ` 成功 ${ ok } 张,失败 ${ fail } 张(常见原因:跨域无法读取原图) ` )
else message . error ( '缩放上传全部失败,请确认原图可跨域读取' )
} finally {
resizingBasic . value = false
resizeProgress . value = ''
}
}
2026-08-03 08:57:18 +08:00
function setOriginalImageAt ( page , iidx , url ) {
page . images [ iidx ] = url
syncImagesResized ( page )
page . imagesResized [ iidx ] = ''
page . imagesResizedMeta [ iidx ] = ''
}
/** 缺 meta, 或只有像素没有文件大小时, 自动探测并补全 */
async function fillMissingResizedMeta ( page ) {
if ( ! page ) return
if ( page . type === 'single' || page . type === 'overlap' ) {
const keys = page . type === 'single' ? [ 'img1' ] : [ 'img1' , 'img2' ]
await Promise . all ( keys . map ( async ( key ) => {
const original = page [ key ]
const resized = page [ ` ${ key } Resized ` ]
const metaKey = ` ${ key } ResizedMeta `
if ( ! resized || ! original || metaHasFileSize ( page [ metaKey ] ) ) return
try {
page [ metaKey ] = await probeResizedMeta ( original , resized )
} catch { /* 跨域/失效图忽略 */ }
} ) )
return
}
if ( ! Array . isArray ( page . images ) ) return
syncImagesResized ( page )
await Promise . all ( page . images . map ( async ( original , i ) => {
const resized = page . imagesResized [ i ]
if ( ! resized || ! original || metaHasFileSize ( page . imagesResizedMeta [ i ] ) ) return
try {
page . imagesResizedMeta [ i ] = await probeResizedMeta ( original , resized )
} catch { /* ignore */ }
} ) )
}
async function batchResizeAndUpload ( page ) {
const slots = getSlotResizeSpecs ( page ) . filter ( ( s ) => ! ! s . getOriginal ( ) )
if ( ! slots . length ) {
message . warning ( '请先上传原图' )
return
}
resizingPage . value = true
resizeProgress . value = ` 0/ ${ slots . length } `
let ok = 0
let fail = 0
try {
for ( let i = 0 ; i < slots . length ; i += 1 ) {
const slot = slots [ i ]
resizeProgress . value = ` ${ i + 1 } / ${ slots . length } `
try {
const { file , meta } = await resizeSlotToFile (
slot . getOriginal ( ) ,
slot . maxWidth ,
slot . maxHeight ,
` resized- ${ page . type } - ${ slot . key } - ${ Date . now ( ) } .jpg ` ,
)
const res = await uploadImage ( file )
if ( ! res ? . url ) throw new Error ( '上传无返回地址' )
slot . setResized ( res . url , meta )
ok += 1
} catch ( err ) {
fail += 1
console . warn ( '[resize]' , slot . key , err )
}
}
if ( fail === 0 ) message . success ( ` 已生成 ${ ok } 张展示图 ` )
else if ( ok > 0 ) message . warning ( ` 成功 ${ ok } 张,失败 ${ fail } 张(常见原因:跨域无法读取原图) ` )
else message . error ( '缩放上传全部失败,请确认原图可跨域读取' )
} finally {
resizingPage . value = false
resizeProgress . value = ''
}
2026-07-31 12:13:41 +08:00
}
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-08-03 16:00:13 +08:00
// 选图:弹层图库 + 新上传
2026-07-30 18:00:27 +08:00
function openUpload ( onUrl ) {
2026-08-03 16:00:13 +08:00
mediaPickerOnUrl = typeof onUrl === 'function' ? onUrl : null
mediaPickerCurrent . value = ''
mediaPickerOpen . value = true
}
function onMediaPicked ( url ) {
if ( mediaPickerOnUrl ) mediaPickerOnUrl ( url )
mediaPickerOnUrl = null
2026-07-30 18:00:27 +08:00
}
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-03 13:01:08 +08:00
async function loadPosterMusicOptions ( ) {
posterMusicLoading . value = true
try {
const res = await getConfigSection ( 'music' )
const list = res . data ? . musicList
if ( Array . isArray ( list ) && list . length ) {
posterMusicOptions . value = list . filter ( ( t ) => t && t . url )
} else if ( Array . isArray ( cfg . musicList ) && cfg . musicList . length ) {
posterMusicOptions . value = cfg . musicList . filter ( ( t ) => t && t . url )
} else {
posterMusicOptions . value = [ ]
}
} catch {
posterMusicOptions . value = Array . isArray ( cfg . musicList )
? cfg . musicList . filter ( ( t ) => t && t . url )
: [ ]
} finally {
posterMusicLoading . value = false
}
}
2026-08-03 09:31:32 +08:00
async function loadPosterConfig ( ) {
posterLoading . value = true
try {
const res = await getPosterConfig ( )
const bundle = normalizePosterBundle ( res . data || { } )
2026-08-03 09:54:43 +08:00
posterBundle [ '1' ] = bundle [ '1' ]
posterBundle [ '2' ] = bundle [ '2' ]
2026-08-03 10:22:04 +08:00
posterBundle [ '3' ] = bundle [ '3' ]
2026-08-03 09:31:32 +08:00
} catch ( e ) {
message . error ( e ? . message || '海报配置加载失败' )
} finally {
posterLoading . value = false
}
}
async function onSavePosterConfig ( ) {
posterSaving . value = true
try {
2026-08-03 16:54:14 +08:00
// 仅同步 layout 编号与 Tab 一致,不覆盖用户所选 template / mobileLayout
2026-08-03 16:00:13 +08:00
for ( const s of [ '1' , '2' , '3' ] ) {
ensurePosterSlot ( s , '1' ) . layout = 1
ensurePosterSlot ( s , '2' ) . layout = 2
}
2026-08-03 09:31:32 +08:00
const payload = normalizePosterBundle ( posterBundle )
const res = await savePosterConfig ( payload )
const bundle = normalizePosterBundle ( res . data || payload )
2026-08-03 09:54:43 +08:00
posterBundle [ '1' ] = bundle [ '1' ]
posterBundle [ '2' ] = bundle [ '2' ]
2026-08-03 10:22:04 +08:00
posterBundle [ '3' ] = bundle [ '3' ]
2026-08-03 09:31:32 +08:00
message . success ( '海报配置已保存' )
} catch ( e ) {
message . error ( e ? . message || '海报配置保存失败' )
} finally {
posterSaving . 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'
2026-08-03 16:00:13 +08:00
loaded . heroImgResized = loaded . heroImgResized || ''
loaded . heroImgResizedMeta = loaded . heroImgResizedMeta || ''
loaded . endImgResized = loaded . endImgResized || ''
loaded . endImgResizedMeta = loaded . endImgResizedMeta || ''
2026-08-02 19:04:04 +08:00
}
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 )
2026-08-03 08:57:18 +08:00
if ( section === 'photos' ) {
setActivePhotoIndex ( currentPhotoIndex . value )
nextTick ( ( ) => {
const page = cfg . photoPages [ currentPhotoIndex . value ]
if ( page ) fillMissingResizedMeta ( page )
} )
}
2026-08-02 19:04:04 +08:00
}
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-03 10:27:56 +08:00
const visitPreviewColumns = [
{ title : '指纹' , key : 'fingerprint' , width : 110 } ,
{ title : '归属地' , key : 'location' , ellipsis : true } ,
{ title : '机型' , key : 'device' , width : 120 , ellipsis : true } ,
{ title : '次数' , dataIndex : 'visit_count' , key : 'visit_count' , width : 64 } ,
{ title : '最近访问' , key : 'last_seen_at' , width : 160 } ,
]
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 } ,
2026-08-03 10:27:56 +08:00
{ title : '机型' , key : 'device' , width : 140 , ellipsis : true } ,
2026-08-02 19:04:04 +08:00
{ 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
}
2026-08-03 10:27:56 +08:00
const PIE _COLORS = [ '#a88c6b' , '#c4a484' , '#e8b4b8' , '#7c9eb2' , '#b5a4c9' , '#d4a574' , '#9b7e9a' , '#64748b' ]
function disposeVisitCharts ( ) {
2026-08-02 19:04:04 +08:00
if ( regionChart ) {
regionChart . dispose ( )
regionChart = null
}
2026-08-03 10:27:56 +08:00
if ( deviceChart ) {
deviceChart . dispose ( )
deviceChart = null
}
2026-08-02 19:04:04 +08:00
}
2026-08-03 10:27:56 +08:00
function renderPieChart ( chartRef , holder , seriesData , emptyName = '暂无数据' ) {
if ( ! chartRef . value ) return null
if ( holder && holder . getDom ( ) !== chartRef . value ) {
holder . dispose ( )
holder = null
2026-08-02 19:04:04 +08:00
}
2026-08-03 10:27:56 +08:00
if ( ! holder ) holder = echarts . init ( chartRef . value )
const data = seriesData . length
? seriesData . map ( ( r ) => ( { name : r . name || '未知' , value : Number ( r . value ) || 0 } ) )
: [ { name : emptyName , value : 0 } ]
holder . setOption ( {
color : PIE _COLORS ,
2026-08-02 19:04:04 +08:00
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 ,
} ] ,
} )
2026-08-03 10:27:56 +08:00
return holder
2026-08-02 19:04:04 +08:00
}
2026-08-03 10:27:56 +08:00
function renderRegionChart ( ) {
regionChart = renderPieChart (
regionChartRef ,
regionChart ,
Array . isArray ( visitStats . regions ) ? visitStats . regions : [ ] ,
)
}
function renderDeviceChart ( ) {
deviceChart = renderPieChart (
deviceChartRef ,
deviceChart ,
Array . isArray ( visitStats . devices ) ? visitStats . devices : [ ] ,
)
}
2026-08-03 11:24:55 +08:00
async function loadGiftStats ( ) {
giftStatsLoading . value = true
try {
const res = await getGiftStats ( )
const d = res . data || { }
const counts = d . counts || { }
for ( const g of GIFT _TYPES ) {
giftStats . counts [ g . key ] = Number ( counts [ g . key ] ) || 0
}
giftStats . total = Number ( d . total ) || Object . values ( giftStats . counts ) . reduce ( ( a , b ) => a + b , 0 )
2026-08-03 12:20:55 +08:00
applyGiftCostsFromServer ( d . costs )
2026-08-03 11:24:55 +08:00
} catch ( e ) {
message . error ( e ? . message || '加载礼物统计失败' )
} finally {
giftStatsLoading . value = false
}
}
2026-08-03 15:15:12 +08:00
const giftListColumns = [
{ title : '姓名' , dataIndex : 'name' , key : 'name' , width : 120 } ,
{ title : '礼物' , dataIndex : 'gift_type' , key : 'gift_type' , width : 120 } ,
{ title : '消耗' , dataIndex : 'cost' , key : 'cost' , width : 90 } ,
{ title : '时间' , dataIndex : 'created_at' , key : 'created_at' , width : 170 } ,
{ title : '操作' , key : 'actions' , width : 90 } ,
]
const giftTypeLabel = ( key ) => GIFT _LABEL _MAP [ key ] || key || '—'
async function loadGiftList ( ) {
giftListLoading . value = true
try {
const res = await getGiftList ( )
const data = res . data || { }
giftList . value = Array . isArray ( data . list ) ? data . list : ( Array . isArray ( data ) ? data : [ ] )
} catch ( e ) {
giftList . value = [ ]
message . error ( e ? . message || '加载礼物明细失败' )
} finally {
giftListLoading . value = false
}
}
async function onDeleteGift ( record ) {
try {
await deleteGift ( record . id )
message . success ( '已删除' )
await Promise . all ( [ loadGiftList ( ) , loadGiftStats ( ) ] )
} catch ( e ) {
message . error ( e ? . message || '删除失败' )
}
}
2026-08-03 10:27:56 +08:00
async function loadVisitStats ( ) {
visitStatsLoading . value = true
2026-08-02 19:04:04 +08:00
try {
2026-08-03 10:27:56 +08:00
const statsRes = await getVisitStats ( )
2026-08-02 19:04:04 +08:00
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 : [ ]
2026-08-03 10:27:56 +08:00
visitStats . devices = Array . isArray ( s . devices ) ? s . devices : [ ]
await nextTick ( )
renderRegionChart ( )
renderDeviceChart ( )
} catch ( e ) {
message . error ( e ? . message || '加载访客统计失败' )
} finally {
visitStatsLoading . value = false
}
}
async function loadVisitPreview ( ) {
visitListLoading . value = true
try {
const listRes = await getVisitList ( { page : 1 , pageSize : 10 } )
const listData = listRes . data || { }
visitPreviewList . value = listData . list || [ ]
visitTotal . value = Number ( listData . total ) || 0
} catch ( e ) {
message . error ( e ? . message || '加载访客预览失败' )
} finally {
visitListLoading . value = false
}
}
async function loadVisitList ( ) {
visitListLoading . value = true
try {
const listRes = await getVisitList ( {
page : visitPage . value ,
pageSize : visitPageSize . value ,
} )
2026-08-02 19:04:04 +08:00
const listData = listRes . data || { }
visitList . value = listData . list || [ ]
visitTotal . value = Number ( listData . total ) || 0
} catch ( e ) {
2026-08-03 10:27:56 +08:00
message . error ( e ? . message || '加载访客记录失败' )
2026-08-02 19:04:04 +08:00
} finally {
2026-08-03 10:27:56 +08:00
visitListLoading . value = false
2026-08-02 19:04:04 +08:00
}
}
2026-08-03 10:27:56 +08:00
async function loadOverview ( ) {
2026-08-03 12:20:55 +08:00
await Promise . all ( [ loadVisitStats ( ) , loadVisitPreview ( ) ] )
2026-08-03 10:27:56 +08:00
}
2026-08-02 19:04:04 +08:00
function onVisitTableChange ( pag ) {
visitPage . value = pag ? . current || 1
2026-08-03 10:27:56 +08:00
visitPageSize . value = pag ? . pageSize || visitPageSize . value
loadVisitList ( )
2026-08-02 19:04:04 +08:00
}
async function onTabActivate ( tab ) {
if ( ! adminStore . isAdmin ) return
2026-08-03 10:27:56 +08:00
if ( tab !== 'overview' ) disposeVisitCharts ( )
2026-08-02 19:04:04 +08:00
if ( tab === 'overview' ) {
await loadOverview ( )
return
2026-08-03 12:20:55 +08:00
}
if ( tab === 'gifts' ) {
2026-08-03 15:15:12 +08:00
await Promise . all ( [ loadGiftStats ( ) , loadGiftList ( ) ] )
2026-08-03 12:20:55 +08:00
return
2026-08-02 19:04:04 +08:00
}
2026-08-03 10:27:56 +08:00
if ( tab === 'visits' ) {
await loadVisitList ( )
return
}
2026-08-02 19:04:04 +08:00
if ( CONFIG _SECTIONS . includes ( tab ) ) {
await loadConfigSection ( tab )
return
}
if ( tab === 'upload' ) {
await loadUploadConfig ( )
return
}
2026-08-03 09:31:32 +08:00
if ( tab === 'poster' ) {
2026-08-03 13:01:08 +08:00
await Promise . all ( [ loadPosterConfig ( ) , loadPosterMusicOptions ( ) ] )
2026-08-03 09:31:32 +08:00
return
}
2026-08-02 19:04:04 +08:00
if ( tab === 'rsvp' ) {
await loadRsvp ( )
return
}
2026-08-03 14:27:32 +08:00
if ( tab === 'cash' ) {
await Promise . all ( [ loadCashGifts ( ) , loadCashNotePresets ( ) , loadCashViewPass ( ) , loadCashEntryPass ( ) ] )
return
}
2026-08-02 19:04:04 +08:00
if ( tab === 'danmaku' ) {
await loadDanmaku ( )
}
}
2026-07-30 18:00:27 +08:00
function openPreview ( ) { window . open ( '/' , '_blank' ) }
2026-08-03 16:00:13 +08:00
function openPosterPreview ( side = 1 , layout = 1 ) {
window . open ( posterRoute ( side , layout ) , '_blank' )
2026-08-03 09:31:32 +08:00
}
2026-07-30 18:00:27 +08:00
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' } ,
2026-08-03 14:27:32 +08:00
{ title : '操作' , key : 'actions' , width : 90 } ,
2026-07-30 18:00:27 +08:00
]
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-03 14:27:32 +08:00
async function onDeleteRsvp ( record ) {
if ( ! record ? . id ) return
try {
await deleteRsvp ( record . id )
message . success ( '已删除' )
await loadRsvp ( )
} catch ( e ) {
message . error ( e ? . message || '删除失败' )
}
}
const cashGiftColumns = [
{ title : '姓名' , dataIndex : 'name' , key : 'name' , width : 120 } ,
{ title : '金额' , key : 'amount' , width : 110 } ,
{ title : '备注' , dataIndex : 'note' , key : 'note' , ellipsis : true } ,
{ title : '时间' , dataIndex : 'created_at' , key : 'created_at' , width : 170 } ,
{ title : '操作' , key : 'actions' , width : 140 } ,
]
async function loadCashGifts ( ) {
cashGiftLoading . value = true
try {
const res = await getCashGiftList ( )
const data = res . data || { }
cashGiftList . value = Array . isArray ( data . list ) ? data . list : [ ]
cashGiftTotal . value = Number ( data . total ) || 0
cashGiftCount . value = Number ( data . count ) || cashGiftList . value . length
} catch {
cashGiftList . value = [ ]
cashGiftTotal . value = 0
cashGiftCount . value = 0
} finally {
cashGiftLoading . value = false
}
}
function openCashGiftCreate ( ) {
cashGiftEditingId . value = 0
cashGiftForm . name = ''
cashGiftForm . amount = 1000
cashGiftForm . note = ''
cashGiftModalOpen . value = true
}
function openCashGiftEdit ( record ) {
cashGiftEditingId . value = record . id
cashGiftForm . name = record . name || ''
cashGiftForm . amount = Number ( record . amount ) || 0
cashGiftForm . note = record . note || ''
cashGiftModalOpen . value = true
}
async function saveCashGiftModal ( ) {
const name = String ( cashGiftForm . name || '' ) . trim ( )
const amount = Math . round ( Number ( cashGiftForm . amount ) )
if ( ! name ) {
message . error ( '请填写姓名' )
return Promise . reject ( )
}
if ( ! Number . isFinite ( amount ) || amount <= 0 ) {
message . error ( '请填写正确金额' )
return Promise . reject ( )
}
cashGiftSaving . value = true
try {
const payload = { name , amount , note : String ( cashGiftForm . note || '' ) . trim ( ) }
if ( cashGiftEditingId . value ) await updateCashGift ( cashGiftEditingId . value , payload )
else await submitCashGift ( payload )
message . success ( '已保存' )
cashGiftModalOpen . value = false
await loadCashGifts ( )
} catch ( e ) {
if ( e ) message . error ( e ? . message || '保存失败' )
return Promise . reject ( e )
} finally {
cashGiftSaving . value = false
}
}
async function onDeleteCashGift ( record ) {
if ( ! record ? . id ) return
try {
await deleteCashGift ( record . id )
message . success ( '已删除' )
await loadCashGifts ( )
} catch ( e ) {
message . error ( e ? . message || '删除失败' )
}
}
async function loadCashNotePresets ( ) {
try {
const res = await getCashGiftNotes ( )
cashNotePresets . value = Array . isArray ( res . data ? . notes ) ? [ ... res . data . notes ] : [ ]
cashNoteDefaults . value = Array . isArray ( res . data ? . defaults ) ? [ ... res . data . defaults ] : [ ]
} catch {
cashNotePresets . value = [ ]
cashNoteDefaults . value = [ ]
}
}
async function loadCashViewPass ( ) {
try {
const res = await getCashGiftViewPass ( )
cashViewPass . value = res . data ? . password || ''
} catch {
cashViewPass . value = ''
}
}
async function saveCashViewPass ( ) {
const password = String ( cashViewPass . value || '' ) . trim ( )
if ( ! password ) return message . error ( '请设置查看口令' )
cashViewPassSaving . value = true
try {
await saveCashGiftViewPass ( password )
message . success ( '查看口令已保存' )
} catch ( e ) {
message . error ( e ? . message || '保存失败' )
} finally {
cashViewPassSaving . value = false
}
}
async function loadCashEntryPass ( ) {
try {
const res = await getCashGiftEntryPass ( )
cashEntryPass . value = res . data ? . password || ''
} catch {
cashEntryPass . value = ''
}
}
async function saveCashEntryPass ( ) {
const password = String ( cashEntryPass . value || '' ) . trim ( )
if ( ! password ) return message . error ( '请设置记账口令' )
cashEntryPassSaving . value = true
try {
await saveCashGiftEntryPass ( password )
message . success ( '记账口令已保存' )
} catch ( e ) {
message . error ( e ? . message || '保存失败' )
} finally {
cashEntryPassSaving . value = false
}
}
function addCashNotePreset ( ) {
const v = String ( cashNoteDraft . value || '' ) . trim ( )
if ( ! v ) return
if ( cashNotePresets . value . includes ( v ) ) {
message . warning ( '已存在该选项' )
return
}
cashNotePresets . value . push ( v )
cashNoteDraft . value = ''
}
function removeCashNotePreset ( index ) {
cashNotePresets . value . splice ( index , 1 )
}
function resetCashNotePresets ( ) {
cashNotePresets . value = cashNoteDefaults . value . length
? [ ... cashNoteDefaults . value ]
: [ '男方亲戚' , '女方亲戚' , '男方朋友' , '女方朋友' , '男方同学' , '女方同学' , '男方同事' , '女方同事' , '男方邻居' , '女方邻居' , '其他' ]
}
async function saveCashNotePresets ( ) {
if ( ! cashNotePresets . value . length ) return message . error ( '至少保留一个快捷备注' )
cashNotesSaving . value = true
try {
const res = await saveCashGiftNotes ( cashNotePresets . value )
cashNotePresets . value = Array . isArray ( res . data ? . notes ) ? [ ... res . data . notes ] : [ ... cashNotePresets . value ]
message . success ( '备注选项已保存' )
} catch ( e ) {
message . error ( e ? . message || '保存失败' )
} finally {
cashNotesSaving . value = false
}
}
2026-07-30 18:00:27 +08:00
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 } )
2026-08-03 08:57:18 +08:00
// 进入相册页 / 切换页签时,为缺 meta 的展示图自动探测尺寸
watch (
( ) => [ activeTab . value , currentPhotoIndex . value ] ,
( [ tab ] ) => {
if ( tab !== 'photos' ) return
const page = cfg . photoPages [ currentPhotoIndex . value ]
if ( page ) fillMissingResizedMeta ( page )
} ,
)
2026-08-03 16:00:13 +08:00
watch ( activeTab , ( tab ) => {
if ( tab === 'basic' ) fillMissingBasicResizedMeta ( )
} )
2026-08-03 10:27:56 +08:00
const onResize = ( ) => {
regionChart ? . resize ( )
deviceChart ? . resize ( )
}
2026-08-02 19:04:04 +08:00
onMounted ( ( ) => {
window . addEventListener ( 'resize' , onResize )
} )
onUnmounted ( ( ) => {
window . removeEventListener ( 'resize' , onResize )
2026-08-03 13:19:09 +08:00
onHeroFocusPointerUp ( )
2026-08-03 10:27:56 +08:00
disposeVisitCharts ( )
2026-07-30 18:00:27 +08:00
} )
< / script >
< style scoped >
2026-08-03 08:57:18 +08:00
. resized - meta {
margin - top : 2 px ;
font - size : 9 px ;
line - height : 1.35 ;
color : # 8 A8680 ;
white - space : pre - line ;
word - break : break - all ;
}
2026-08-03 13:21:22 +08:00
. admin - layout - seg : deep ( . ant - segmented - item - selected ) {
color : # a88c6b ;
}
2026-08-03 13:36:16 +08:00
. admin - header {
padding : 10 px 16 px 10 px ;
}
. admin - header - row {
display : flex ;
align - items : center ;
gap : 12 px 16 px ;
flex - wrap : wrap ;
}
. admin - header - actions {
display : flex ;
align - items : center ;
gap : 8 px ;
flex - wrap : wrap ;
justify - content : flex - end ;
margin - left : auto ;
}
. admin - top - menu {
display : flex ;
align - items : center ;
gap : 2 px ;
flex : 1 ;
min - width : 0 ;
overflow - x : auto ;
scrollbar - width : none ;
padding : 2 px 0 ;
}
. admin - top - menu : : - webkit - scrollbar { display : none ; }
. admin - top - menu - trigger {
display : inline - flex ;
align - items : center ;
gap : 6 px ;
border : none ;
background : transparent ;
color : # 5 c5348 ;
font - size : 13 px ;
letter - spacing : 0.04 em ;
padding : 8 px 12 px ;
border - radius : 10 px ;
cursor : pointer ;
white - space : nowrap ;
transition : background 0.15 s , color 0.15 s ;
}
. admin - top - menu - trigger : hover {
background : rgba ( 168 , 140 , 107 , 0.08 ) ;
color : # 967 b5c ;
}
. admin - top - menu - trigger . active {
color : # a88c6b ;
background : rgba ( 168 , 140 , 107 , 0.12 ) ;
font - weight : 500 ;
}
. admin - top - menu - caret {
font - size : 9 px ;
opacity : 0.65 ;
}
. admin - top - dropdown : deep ( . ant - dropdown - menu - item - selected ) {
color : # a88c6b ;
background : rgba ( 168 , 140 , 107 , 0.12 ) ;
}
2026-08-03 13:21:22 +08:00
. admin - shell {
margin : 0 auto ;
padding : 20 px 16 px 40 px ;
box - sizing : border - box ;
}
. admin - shell -- top {
2026-08-03 14:27:32 +08:00
max - width : 80 vw ;
width : 80 vw ;
2026-08-03 13:21:22 +08:00
}
. admin - shell -- side {
max - width : 1220 px ;
display : flex ;
align - items : flex - start ;
gap : 18 px ;
}
. admin - nav {
background : # fff ;
border : 1 px solid rgba ( 168 , 140 , 107 , 0.14 ) ;
border - radius : 16 px ;
box - shadow : 0 8 px 28 px rgba ( 88 , 68 , 42 , 0.05 ) ;
position : sticky ;
top : 68 px ;
width : 212 px ;
flex - shrink : 0 ;
padding : 14 px 10 px 16 px ;
max - height : calc ( 100 dvh - 88 px ) ;
overflow : auto ;
}
. admin - nav - group - title {
font - size : 11 px ;
letter - spacing : 0.12 em ;
color : # 8 A8680 ;
margin - bottom : 6 px ;
padding : 0 6 px ;
}
2026-08-03 13:36:16 +08:00
. admin - nav - group + . admin - nav - group {
2026-08-03 13:21:22 +08:00
margin - top : 14 px ;
padding - top : 12 px ;
border - top : 1 px dashed rgba ( 168 , 140 , 107 , 0.22 ) ;
}
. admin - nav - items {
display : flex ;
flex - direction : column ;
2026-08-03 13:36:16 +08:00
gap : 6 px ;
2026-08-03 13:21:22 +08:00
}
. admin - nav - item {
display : inline - flex ;
align - items : center ;
gap : 8 px ;
2026-08-03 13:36:16 +08:00
width : 100 % ;
2026-08-03 13:21:22 +08:00
border : 1 px solid transparent ;
background : transparent ;
color : # 5 c5348 ;
font - size : 13 px ;
line - height : 1.2 ;
padding : 7 px 10 px ;
border - radius : 10 px ;
cursor : pointer ;
transition : background 0.15 s , color 0.15 s , border - color 0.15 s ;
text - align : left ;
}
. admin - nav - item : hover {
background : rgba ( 168 , 140 , 107 , 0.08 ) ;
color : # 967 b5c ;
}
. admin - nav - item . active {
background : rgba ( 168 , 140 , 107 , 0.14 ) ;
border - color : rgba ( 168 , 140 , 107 , 0.28 ) ;
color : # a88c6b ;
font - weight : 500 ;
}
. admin - nav - icon {
width : 1 em ;
text - align : center ;
font - size : 12 px ;
opacity : 0.85 ;
}
. admin - nav - credit {
margin - top : 14 px ;
padding : 10 px 8 px 2 px ;
border - top : 1 px dashed rgba ( 168 , 140 , 107 , 0.22 ) ;
font - size : 11 px ;
letter - spacing : 0.08 em ;
color : # a88c6b ;
text - align : center ;
}
. admin - main {
min - width : 0 ;
flex : 1 ;
}
2026-08-03 13:36:16 +08:00
@ media ( max - width : 960 px ) {
. admin - top - menu {
order : 3 ;
flex : 1 1 100 % ;
border - top : 1 px solid rgba ( 168 , 140 , 107 , 0.12 ) ;
margin - top : 2 px ;
padding - top : 8 px ;
}
}
2026-08-03 13:21:22 +08:00
@ media ( max - width : 860 px ) {
. admin - shell -- side {
flex - direction : column ;
}
2026-08-03 13:36:16 +08:00
. admin - nav {
2026-08-03 13:21:22 +08:00
position : static ;
width : 100 % ;
max - height : none ;
}
2026-08-03 13:36:16 +08:00
. admin - nav - items {
2026-08-03 13:21:22 +08:00
flex - direction : row ;
flex - wrap : wrap ;
}
2026-08-03 13:36:16 +08:00
. admin - nav - item {
2026-08-03 13:21:22 +08:00
width : auto ;
}
}
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 ;
}
2026-08-03 10:27:56 +08:00
. visit - preview - wrap ,
. visit - list - wrap {
border : 1 px solid rgba ( 168 , 140 , 107 , 0.12 ) ;
border - radius : 12 px ;
overflow : hidden ;
background : # fffdf9 ;
}
. visit - preview - wrap : deep ( . ant - table - body ) ,
. visit - list - wrap : deep ( . ant - table - body ) {
max - height : inherit ;
}
2026-08-02 19:04:04 +08:00
. 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-08-03 13:19:09 +08:00
. hero - focus - pad {
display : grid ;
grid - template - columns : repeat ( var ( -- hero - focus - n , 9 ) , 22 px ) ;
gap : 4 px ;
width : fit - content ;
padding : 8 px ;
border - radius : 10 px ;
background : # f7f4ef ;
border : 1 px solid # e7e3db ;
}
. hero - focus - cell {
width : 22 px ;
height : 22 px ;
border - radius : 5 px ;
border : 1 px solid # ddd6cb ;
background : # fff ;
display : flex ;
align - items : center ;
justify - content : center ;
cursor : pointer ;
padding : 0 ;
transition : border - color 0.15 s , background 0.15 s ;
}
. hero - focus - cell : hover { border - color : # a88c6b ; }
. hero - focus - cell . active {
border - color : # a88c6b ;
background : rgba ( 168 , 140 , 107 , 0.14 ) ;
}
. hero - focus - dot {
width : 5 px ;
height : 5 px ;
border - radius : 999 px ;
background : # c4b8a4 ;
}
. hero - focus - cell . active . hero - focus - dot { background : # a88c6b ; }
. hero - focus - preview {
position : relative ;
width : min ( 100 % , 280 px ) ;
aspect - ratio : 16 / 11 ;
border - radius : 8 px ;
overflow : hidden ;
border : 1 px solid # e7e3db ;
background : # f0ebe3 ;
user - select : none ;
touch - action : none ;
}
2026-08-03 16:54:14 +08:00
. hero - focus - preview . is - mobile {
width : min ( 100 % , 200 px ) ;
aspect - ratio : 3 / 4 ;
}
2026-08-03 13:19:09 +08:00
. hero - focus - preview -- drag { cursor : crosshair ; }
. hero - focus - preview -- drag . dragging { cursor : grabbing ; }
. hero - focus - preview -- empty {
display : flex ;
align - items : center ;
justify - content : center ;
color : # 8 A8680 ;
font - size : 12 px ;
padding : 12 px ;
text - align : center ;
}
. hero - focus - preview img {
width : 100 % ;
height : 100 % ;
object - fit : cover ;
display : block ;
pointer - events : none ;
}
. hero - focus - crosshair {
position : absolute ;
width : 18 px ;
height : 18 px ;
margin : - 9 px 0 0 - 9 px ;
border : 2 px solid # fff ;
border - radius : 999 px ;
box - shadow : 0 0 0 1 px rgba ( 168 , 140 , 107 , 0.85 ) , 0 1 px 4 px rgba ( 0 , 0 , 0 , 0.25 ) ;
background : rgba ( 168 , 140 , 107 , 0.35 ) ;
pointer - events : none ;
z - index : 1 ;
}
2026-07-30 18:00:27 +08:00
< / style >