Files
hunli/vite-tailwindcss/src/views/admin/AdminEditor.vue
2026-08-04 12:54:33 +08:00

4285 lines
157 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-screen bg-[#f6f4ef] text-[#2c2c2c]">
<!-- 顶部栏 -->
<header class="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>
</div>
</div>
<!-- 顶部菜单一级分类 + 下拉 -->
<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>预览请柬
</a-button>
<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>
<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>
</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>
</div>
</header>
<div class="admin-shell" :class="menuLayout === 'side' ? 'admin-shell--side' : 'admin-shell--top'">
<nav v-if="menuLayout === 'side'" class="admin-nav" aria-label="后台菜单">
<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">
<!-- 概览 -->
<section v-if="activeTab === 'overview'">
<div class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
<div>
<div class="text-sm text-[#2c2c2c]">访客概览</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">指纹去重访客 · 地区 / 机型画像</div>
</div>
<a-button size="small" :loading="visitStatsLoading" @click="loadVisitStats">
<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="flex items-baseline justify-between gap-2 mb-2">
<div class="text-[12px] text-[#7a6550] tracking-wide">地区分布</div>
<div class="text-[10px] text-[#a89a88]">点击省份查看市级</div>
</div>
<div ref="regionChartRef" class="h-[280px] w-full cursor-pointer"></div>
</div>
<div class="chart-card">
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">机型画像</div>
<div ref="deviceChartRef" class="h-[280px] w-full"></div>
</div>
</div>
<div v-if="selectedRegion" class="chart-card mb-5">
<div class="flex items-center justify-between gap-2 mb-2 flex-wrap">
<div class="text-[12px] text-[#7a6550] tracking-wide">
{{ selectedRegion }} · 市级分布
<span v-if="cityStatsLoading" class="text-[10px] text-[#a89a88] ml-2">加载中</span>
</div>
<a-button size="small" type="text" class="!text-[#a88c6b]" @click="clearSelectedRegion">
<i class="fa-solid fa-xmark mr-1"></i>关闭
</a-button>
</div>
<div ref="cityChartRef" class="h-[280px] w-full"></div>
</div>
<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>
</template>
</a-table>
</div>
</div>
</section>
<!-- 礼物 -->
<section v-if="activeTab === 'gifts'">
<div class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
<div>
<div class="text-sm text-[#2c2c2c]">礼物统计</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">各礼物收到次数 · 合计 {{ giftStats.total }}</div>
</div>
<a-button size="small" :loading="giftStatsLoading || giftListLoading" @click="() => Promise.all([loadGiftStats(), loadGiftList()])">
<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>
<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>
</div>
</section>
<!-- 访客记录 -->
<section v-if="activeTab === 'visits'">
<div class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
<div>
<div class="text-sm text-[#2c2c2c]">访客记录</div>
<div class="text-[11px] text-[#8A8680] mt-0.5"> {{ 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>
</template>
</a-table>
</div>
</div>
</section>
<!-- 基础信息 -->
<section v-if="activeTab === 'basic'">
<a-card :bordered="false" class="!shadow-sm" :class="{ 'opacity-60': tabLoading }">
<a-form layout="vertical">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="新郎">
<a-input v-model:value="cfg.groom" placeholder="新郎姓名" />
</a-form-item>
<a-form-item label="新娘">
<a-input v-model:value="cfg.bride" placeholder="新娘姓名" />
</a-form-item>
<a-form-item label="公历日期">
<a-input v-model:value="cfg.date" placeholder="如 2026-05-20" />
</a-form-item>
<a-form-item label="农历 / 星期">
<a-input v-model:value="cfg.lunar" placeholder="如 农历四月初四 星期三" />
</a-form-item>
<a-form-item label="日历圈选日期">
<a-input v-model:value="cfg.calendarDate" placeholder="如 2026-09-04" />
<div class="text-[11px] text-[#8A8680] mt-1">前台婚礼日历会展示该日期所在月份并用爱心圈出这一天</div>
</a-form-item>
<a-form-item label="酒店名称">
<a-input v-model:value="cfg.hotel" placeholder="宴会酒店" />
</a-form-item>
<a-form-item label="详细地址">
<a-textarea v-model:value="cfg.address" :rows="2" placeholder="可换行" />
</a-form-item>
</div>
<div class="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>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="封面图片">
<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>
<div class="text-[11px] text-[#8A8680] mt-2">用于首页封面和入场揭幕背景</div>
</a-form-item>
<a-form-item label="尾页图片">
<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>
<div class="text-[11px] text-[#8A8680] mt-2">用于最后的回执尾页可与封面分开设置</div>
</a-form-item>
</div>
</a-form>
</a-card>
</section>
<!-- 文案 -->
<section v-if="activeTab === 'copy'">
<a-card :bordered="false" class="!shadow-sm">
<a-form layout="vertical">
<a-form-item label="竖排文案 · 上联">
<a-input v-model:value="cfg.formalText1" placeholder="如 两姓联姻 一堂缔约" />
</a-form-item>
<a-form-item label="竖排文案 · 下联">
<a-input v-model:value="cfg.formalText2" placeholder="如 良缘永结 匹配同称" />
</a-form-item>
<a-form-item label="邀请文案页高度">
<div class="flex gap-2 items-center">
<a-select :value="heightFieldSelectValue(cfg.formalPageHeight)" @change="(v) => onHeightFieldModeChange(cfg, 'formalPageHeight', v)" class="flex-1">
<a-select-option v-for="opt in HEIGHT_PRESETS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
</a-select>
<a-input-number v-if="heightFieldSelectValue(cfg.formalPageHeight) === '__custom__'"
:value="customVhValue(cfg.formalPageHeight)" @change="(v) => onHeightFieldCustomVh(cfg, 'formalPageHeight', v)"
:min="20" :max="200" addon-after="vh" class="w-[150px]" />
</div>
<div class="text-[11px] text-[#8A8680] mt-1">控制前台竖排邀请文案页的展示高度默认满屏</div>
</a-form-item>
</a-form>
</a-card>
</section>
<!-- 视觉 / 动效 -->
<section v-if="activeTab === 'visual'">
<a-card :bordered="false" class="!shadow-sm">
<a-form layout="vertical">
<a-form-item label="背景音乐">
<div class="text-[12px] text-[#8A8680] leading-relaxed">多首背景音乐的上传试听与设为播放请在 <span class="text-[#a88c6b]">背景音乐</span> 标签页管理</div>
</a-form-item>
<a-form-item label="入场揭幕退场方式">
<a-select v-model:value="cfg.introExitEffect">
<a-select-option v-for="opt in INTRO_EXIT_EFFECTS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
</a-select>
<div class="text-[11px] text-[#8A8680] mt-1">点击开启请柬时的退场动画可按婚礼调性选择更轻盈或更有仪式感的方式</div>
</a-form-item>
<a-form-item label="滚动方式">
<a-radio-group v-model:value="cfg.scrollMode">
<a-radio value="snap">整页 / 分屏吸附滚动</a-radio>
<a-radio value="free">自由滚动</a-radio>
</a-radio-group>
<div class="text-[11px] text-[#8A8680] mt-1">整页模式会带来更有仪式感的逐屏切换效果</div>
</a-form-item>
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-6">
<a-form-item label="自由滚动速度(滚动间隔)">
<div class="flex gap-3 items-center">
<a-slider v-model:value="cfg.freeScrollInterval" :min="10" :max="500" class="flex-1" />
<a-input-number v-model:value="cfg.freeScrollInterval" :min="10" :max="500" addon-after="ms" class="w-[120px]" />
</div>
<div class="text-[11px] text-[#8A8680] mt-1">间隔越小滚动越快默认 50ms仅在自由滚动模式下生效</div>
</a-form-item>
<a-form-item label="页面切换速度(动画时长)">
<div class="flex gap-3 items-center">
<a-slider v-model:value="cfg.pageSwitchDuration" :min="200" :max="3000" :step="100" class="flex-1" />
<a-input-number v-model:value="cfg.pageSwitchDuration" :min="200" :max="3000" :step="100" addon-after="ms" class="w-[120px]" />
</div>
<div class="text-[11px] text-[#8A8680] mt-1">整页/分屏模式下自动翻页的动画时长默认 800ms</div>
</a-form-item>
<a-form-item label="首屏停留时间">
<div class="flex gap-3 items-center">
<a-slider v-model:value="cfg.firstScreenDuration" :min="0" :max="12000" :step="100" class="flex-1" />
<a-input-number v-model:value="cfg.firstScreenDuration" :min="0" :max="12000" :step="100" addon-after="ms" class="w-[120px]" />
</div>
<div class="text-[11px] text-[#8A8680] mt-1">首屏效果结束后等待多久开始自动滚动默认 4500ms</div>
</a-form-item>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<a-form-item label="飘落花瓣">
<a-switch v-model:checked="cfg.petalEnabled" />
</a-form-item>
<a-form-item label="漂浮爱心">
<a-switch v-model:checked="cfg.bubbleEnabled" />
</a-form-item>
<a-form-item label="入场揭幕">
<a-switch v-model:checked="cfg.introEnabled" />
</a-form-item>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<a-form-item label="祝福弹幕">
<a-switch v-model:checked="cfg.danmakuEnabled" />
<div class="text-[11px] text-[#8A8680] mt-1">关闭后前台不展示弹幕也不显示送祝福入口</div>
</a-form-item>
<a-form-item label="弹幕显示时间">
<a-switch v-model:checked="cfg.danmakuShowTime" :disabled="!cfg.danmakuEnabled" />
<div class="text-[11px] text-[#8A8680] mt-1">开启后弹幕后缀展示相对时间刚刚x分钟前等</div>
</a-form-item>
<a-form-item label="九宫格入场动画">
<a-switch v-model:checked="cfg.nineGridAnimate" />
<div class="text-[11px] text-[#8A8680] mt-1">开启进入视口后暂停滚动 300ms 入场一格方向动效全部完成后再继续关闭直接展示更稳推荐 iOS</div>
</a-form-item>
</div>
</a-form>
</a-card>
</section>
<!-- 背景音乐 -->
<section v-if="activeTab === 'music'">
<div class="admin-panel">
<div class="flex flex-wrap items-start justify-between gap-3 mb-5">
<div>
<div class="text-sm text-[#2c2c2c] tracking-wide">曲目列表</div>
<p class="text-[11px] text-[#8A8680] mt-1 leading-relaxed max-w-xl">
开启随机后按<strong>权重</strong>抽曲播完自动再随机下一首不单曲循环
可开启记忆曲目让同一访客在设定时长内首次打开仍播同一首
</p>
</div>
<div class="flex flex-wrap items-center gap-3 justify-end">
<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>上传音乐
</a-button>
</div>
</div>
<div
v-if="cfg.musicRandomEnabled"
class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[#e7e3db] bg-[#faf8f5] px-3 py-2.5"
>
<label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
<a-switch v-model:checked="cfg.musicCacheEnabled" size="small" />
记忆曲目
</label>
<div class="inline-flex items-center gap-2 text-[12px] text-[#5c5348]">
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }">记忆时长</span>
<a-input-number
v-model:value="cfg.musicCacheHours"
:min="0.5"
:max="720"
:step="0.5"
size="small"
class="!w-[96px]"
:disabled="!cfg.musicCacheEnabled"
/>
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }" class="text-[#8A8680]">小时</span>
</div>
<span class="text-[10px] text-[#8A8680]">
{{ cfg.musicCacheEnabled
? `同一设备 ${cfg.musicCacheHours || 24} 小时内首次打开沿用上次抽中的歌;播完仍会换下一首。`
: '关闭后每次打开页面都会重新按权重抽曲。' }}
</span>
</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">
<label
class="music-chip music-chip--weight"
:class="{ 'opacity-40': !cfg.musicRandomEnabled }"
:title="cfg.musicRandomEnabled ? '随机权重,越大越容易抽到' : '开启随机播放后生效'"
>
<i class="fa-solid fa-scale-balanced"></i>
<span>权重</span>
<a-input-number
v-model:value="t.weight"
:min="1"
:max="1000"
:step="1"
size="small"
class="music-weight-input"
:disabled="!cfg.musicRandomEnabled"
/>
<span v-if="cfg.musicRandomEnabled" class="music-weight-pct">
{{ musicWeightPercent(t) }}%
</span>
</label>
<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>
</a-button>
</div>
</div>
</li>
</ul>
</div>
</section>
<!-- 上传设置 -->
<section v-if="activeTab === 'upload'">
<a-card :bordered="false" class="!shadow-sm">
<a-alert
type="info"
show-icon
class="mb-4"
message="上传设置仅后台可见OSS 密钥不会写入公开请柬配置。"
/>
<a-form layout="vertical">
<a-form-item label="上传方式">
<a-radio-group v-model:value="uploadCfg.provider">
<a-radio value="local">本地上传</a-radio>
<a-radio value="oss">阿里云 OSS</a-radio>
</a-radio-group>
</a-form-item>
<div v-if="uploadCfg.provider === 'oss'" class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="AccessKeyId">
<a-input v-model:value="uploadCfg.accessKeyId" placeholder="请输入 AccessKeyId" />
</a-form-item>
<a-form-item label="AccessKeySecret">
<a-input-password v-model:value="uploadCfg.accessKeySecret" :placeholder="uploadCfg.hasSecret ? '已配置,留空则不修改' : '请输入 AccessKeySecret'" />
</a-form-item>
<a-form-item label="Bucket">
<a-input v-model:value="uploadCfg.bucket" placeholder="如 wedding-assets" />
</a-form-item>
<a-form-item label="文件夹">
<a-input v-model:value="uploadCfg.folder" placeholder="如 invitations" />
</a-form-item>
<a-form-item label="访问域名">
<a-input v-model:value="uploadCfg.domain" placeholder="如 https://cdn.example.com可留空" />
</a-form-item>
<a-form-item label="Region">
<a-input v-model:value="uploadCfg.region" placeholder="如 cn-hangzhou" />
</a-form-item>
<a-form-item label="Endpoint">
<a-input v-model:value="uploadCfg.endpoint" placeholder="如 https://oss-cn-hangzhou.aliyuncs.com" />
</a-form-item>
</div>
<div v-else class="text-[12px] text-[#8A8680] leading-relaxed mb-4">
本地上传会继续保存到后端 uploads 目录返回 http://localhost:8080/uploads/ 开头的地址。
</div>
<a-button type="primary" :loading="uploadConfigSaving" @click="onSaveUploadConfig"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]">
<i class="fa-solid fa-floppy-disk mr-1"></i>保存上传设置
</a-button>
</a-form>
</a-card>
</section>
<!-- 海报独立 poster_configs1 男方 /hb-n2 女方 /hb-nv3 新人模板 /hb-xr -->
<section v-if="activeTab === 'poster'">
<a-card :bordered="false" class="!shadow-sm">
<a-alert
type="info"
show-icon
class="mb-4"
message="每一方同时维护竖版(/1)与横版(/2)。例:男方竖版 /hb-n/1横版 /hb-n/2女方 /hb-nv/1|/2新人模板 /hb-xr/1|/2。"
/>
<a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-2">
<a-tab-pane v-for="s in POSTER_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" />
</a-tabs>
<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>
<a-form layout="vertical" :class="{ 'opacity-60 pointer-events-none': posterLoading }">
<a-form-item label="所属方 / 风格">
<a-tag color="gold">{{ currentPosterSide.key }} · {{ currentPosterSide.label }}</a-tag>
<a-tag color="orange">{{ currentPosterLayout.label }}</a-tag>
<span class="text-[11px] text-[#8A8680] ml-2">路由 {{ currentPosterPreviewPath }}</span>
</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>
<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>
<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>
<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>
<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>
<a-form-item :label="posterCfg.template === 'split' ? '左侧照片' : '顶部照片'">
<image-field
v-model="posterCfg.heroImg"
label="海报图片"
@pick="openUpload(setPosterHero)"
:uploading="uploading"
/>
</a-form-item>
<a-form-item label="照片展示焦点">
<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>
<div
v-if="posterCfg.heroImg"
ref="heroFocusPreviewEl"
class="hero-focus-preview hero-focus-preview--drag"
:class="{ dragging: heroFocusDragging, 'is-mobile': heroFocusDevice === 'mobile' }"
@pointerdown="onHeroFocusPointerDown"
>
<img
:src="posterCfg.heroImg"
alt=""
draggable="false"
:style="{ objectPosition: heroFocusPreviewCss }"
/>
<span
class="hero-focus-crosshair"
:style="{ left: heroFocusActiveX + '%', top: heroFocusActiveY + '%' }"
/>
</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">
{{ heroFocusDevice === 'mobile' ? '手机端' : 'PC 端' }}焦点拖拽十字准星或点网格当前
{{ Math.round(heroFocusActiveX) }}% · {{ Math.round(heroFocusActiveY) }}%
</div>
</a-form-item>
<a-form-item label="二维码中心图">
<image-field
v-model="posterCfg.qrCenterImg"
label="中心图(可选)"
@pick="openUpload(setPosterQrCenter)"
:uploading="uploading"
/>
<div class="text-xs text-stone-500 mt-1">不配置时二维码中心为圆圈大</div>
</a-form-item>
<!-- classic 字段 -->
<template v-if="posterCfg.template === 'classic'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item :label="posterNameLabels.left">
<div class="flex gap-2">
<a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterNameLabels.leftPh" />
<a-input v-model:value="posterCfg.sonName" placeholder="姓名" />
</div>
</a-form-item>
<a-form-item :label="posterNameLabels.right">
<div class="flex gap-2">
<a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterNameLabels.rightPh" />
<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="婚礼邀请函" />
</a-form-item>
<a-form-item label="正文(每行一条)">
<a-textarea v-model:value="posterLinesText" :rows="10" placeholder="每行一条正文" />
</a-form-item>
<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="正文(每行一条,建议 24 行)">
<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="posterNameLabels.left">
<div class="flex gap-2">
<a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterNameLabels.leftPh" />
<a-input v-model:value="posterCfg.sonName" placeholder="姓名" @update:value="v => posterCfg.groomName = v" />
</div>
</a-form-item>
<a-form-item :label="posterNameLabels.right">
<div class="flex gap-2">
<a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterNameLabels.rightPh" />
<a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" @update:value="v => posterCfg.brideName = v" />
</div>
</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>
</a-form>
</a-card>
<div class="poster-action-bar-spacer" aria-hidden="true"></div>
</section>
<div v-if="activeTab === 'poster'" class="poster-action-bar">
<div class="poster-action-bar-inner">
<div class="poster-action-bar-meta">
<span>{{ currentPosterSide.label }} · {{ currentPosterLayout.label }}</span>
<span class="poster-action-bar-path">{{ currentPosterPreviewPath }}</span>
</div>
<div class="poster-action-bar-btns">
<a-button
v-if="posterUndoSnapshot"
size="small"
@click="undoPosterImport"
>
<i class="fa-solid fa-rotate-left mr-1"></i>撤回导入
</a-button>
<a-button size="small" :loading="posterCopying" @click="copyPosterTemplate">
<i class="fa-solid fa-copy mr-1"></i>复制
</a-button>
<a-button size="small" :loading="posterImporting" @click="importPosterTemplateDirect">
<i class="fa-solid fa-file-import mr-1"></i>导入
</a-button>
<a-button size="small" @click="openPosterPreview(posterSideTab, posterLayoutTab)">
<i class="fa-solid fa-eye mr-1"></i>预览
</a-button>
<a-button
type="primary"
size="small"
:loading="posterSaving"
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
@click="onSavePosterConfig"
>
<i class="fa-solid fa-floppy-disk mr-1"></i>保存
</a-button>
</div>
</div>
</div>
<section v-if="activeTab === 'photos'">
<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>
</div>
<template v-for="(page, idx) in cfg.photoPages" :key="idx">
<a-card v-if="idx === currentPhotoIndex" :bordered="false" class="!shadow-sm mb-4 !rounded-2xl !border !border-[#a88c6b]/10">
<div class="flex justify-between items-center mb-3">
<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>
<div class="flex gap-1">
<a-button size="small" :disabled="idx===0" @click="movePage(idx,-1)"><i class="fa-solid fa-arrow-up"></i></a-button>
<a-button size="small" :disabled="idx===cfg.photoPages.length-1" @click="movePage(idx,1)"><i class="fa-solid fa-arrow-down"></i></a-button>
<a-button size="small" danger type="text" @click="removePage(idx)"><i class="fa-solid fa-trash"></i></a-button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="版式">
<a-select v-model:value="page.type" @change="() => ensurePageShape(page)">
<a-select-option value="single">单图</a-select-option>
<a-select-option value="overlap">错落双图</a-select-option>
<a-select-option value="one-large-five-small">一大五小</a-select-option>
<a-select-option value="nine-grid">九宫格</a-select-option>
<a-select-option value="masonry">瀑布流</a-select-option>
<a-select-option value="carousel">轮播滑动</a-select-option>
<a-select-option value="polaroid">拍立得拼贴</a-select-option>
<a-select-option value="heart-collage">爱心拼接</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="页面高度">
<div class="flex gap-2 items-center">
<a-select :value="heightSelectValue(page)" @change="(v) => onHeightModeChange(page, v)" class="flex-1">
<a-select-option v-for="opt in HEIGHT_PRESETS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
</a-select>
<a-input-number v-if="heightSelectValue(page) === '__custom__'"
:value="customVh(page)" @change="(v) => onCustomVh(page, v)"
:min="20" :max="200" addon-after="vh" class="w-[150px]" />
</div>
</a-form-item>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 items-start">
<a-form-item label="边框样式">
<a-select v-model:value="page.borderStyle">
<a-select-option v-for="b in BORDER_STYLES" :key="b.value" :value="b.value">{{ b.label }}</a-select-option>
</a-select>
</a-form-item>
<div class="flex items-center gap-3">
<span class="text-[11px] text-[#a88c6b]">预览</span>
<div class="w-[70px] h-[88px] shrink-0">
<div :class="borderClass(page.borderStyle)" style="width:100%;height:100%">
<img src="https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=200&q=80" />
</div>
</div>
</div>
</div>
<!-- 单图版式的宽度设置逐页配置 -->
<div v-if="page.type==='single'" class="grid grid-cols-1 md:grid-cols-2 gap-x-6 items-start">
<a-form-item label="图片宽度">
<a-radio-group v-model:value="page.singleWidth">
<a-radio value="default">默认居中卡幅</a-radio>
<a-radio value="full">宽度铺满</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="page.singleWidth==='full'" label="保持原始宽高比">
<a-switch v-model:checked="page.singleKeepRatio" />
<div class="text-[11px] text-[#8A8680] mt-1">开启按图片原始比例显示关闭固定 4:5 比例裁切铺满</div>
</a-form-item>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item label="小标题 (英文)">
<a-input v-model:value="page.subtitle" />
</a-form-item>
<a-form-item label="标题">
<a-input v-model:value="page.title" />
</a-form-item>
</div>
<a-form-item label="描述">
<a-textarea v-model:value="page.desc" :rows="2" />
</a-form-item>
<!-- 图片上传区按版式渲染 -->
<div class="flex flex-wrap 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>
<div class="flex flex-wrap gap-3 mt-2">
<template v-if="page.type==='single'">
<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>
</template>
<template v-else-if="page.type==='overlap'">
<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>
</template>
<template v-else-if="page.type === 'heart-collage'">
<div class="w-full">
<div class="text-[11px] text-[#8A8680] mb-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span>
<strong>单击</strong>两格对调顺序格内<strong>拖拽</strong>调整展示焦点同海报
空格点击上传
</span>
<a-button
v-if="heartSwapSelected?.page === page"
size="small"
type="link"
class="!px-0 !h-auto !text-[#a88c6b]"
@click="heartSwapSelected = null"
>取消选择已选{{ heartSlotLabel(heartSwapSelected.idx) }}</a-button>
</div>
<div class="heart-admin-board">
<div
v-for="(slot, iidx) in HEART_SLOTS"
:key="iidx"
class="heart-admin-slot"
:style="heartSlotStyle(slot)"
>
<div
class="heart-admin-cell"
:class="{
empty: !(page.images[iidx] || page.imagesResized?.[iidx]),
selected: heartSwapSelected?.page === page && heartSwapSelected?.idx === iidx,
'swap-target': heartSwapSelected?.page === page && heartSwapSelected?.idx !== iidx,
dragging: heartFocusDrag?.page === page && heartFocusDrag?.idx === iidx && heartFocusDrag?.moved,
}"
@pointerdown="onHeartSlotPointerDown($event, page, iidx)"
>
<img
v-if="page.images[iidx] || page.imagesResized?.[iidx]"
:src="page.imagesResized?.[iidx] || page.images[iidx]"
alt=""
draggable="false"
:style="{ objectPosition: heartFocusCssAt(page, iidx) }"
/>
<div v-else class="heart-admin-empty">
<span class="text-[16px] leading-none">+</span>
<span>{{ heartSlotLabel(iidx) }}</span>
</div>
<span
v-if="page.images[iidx] || page.imagesResized?.[iidx]"
class="hero-focus-crosshair heart-admin-crosshair"
:style="{
left: heartFocusAt(page, iidx, 'x') + '%',
top: heartFocusAt(page, iidx, 'y') + '%',
}"
/>
<div class="heart-admin-toolbar">
<span class="heart-admin-label">{{ heartSlotLabel(iidx) }}</span>
<button
type="button"
class="heart-admin-btn"
title="上传/更换"
@click.stop="openUpload(u => setOriginalImageAt(page, iidx, u))"
>
<i class="fa-solid fa-image"></i>
</button>
</div>
<div
v-if="page.imagesResized?.[iidx]"
class="heart-admin-meta"
:title="page.imagesResizedMeta?.[iidx] || '展示已生成'"
>已缩放</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div v-for="(img, iidx) in page.images" :key="iidx" class="relative w-[96px]">
<image-field v-model="page.images[iidx]"
:label="`图${iidx + 1}`"
@pick="openUpload(u => setOriginalImageAt(page, iidx, u))"
:uploading="uploading" />
<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>
<button v-if="isFlexible(page.type) && page.images.length > 1"
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-[#a88c6b] text-white text-[10px] leading-none flex items-center justify-center shadow"
@click="removeImage(page, iidx)" title="移除该图"></button>
<button v-if="iidx > 0"
class="absolute -bottom-2 left-1 w-5 h-5 rounded-full bg-white border border-[#a88c6b]/50 text-[#a88c6b] text-[10px] leading-none flex items-center justify-center shadow hover:bg-[#a88c6b] hover:text-white transition-colors"
@click="moveImage(page, iidx, -1)" title="前移"><i class="fa-solid fa-arrow-left"></i></button>
<button v-if="iidx < page.images.length - 1"
class="absolute -bottom-2 right-1 w-5 h-5 rounded-full bg-white border border-[#a88c6b]/50 text-[#a88c6b] text-[10px] leading-none flex items-center justify-center shadow hover:bg-[#a88c6b] hover:text-white transition-colors"
@click="moveImage(page, iidx, 1)" title="后移"><i class="fa-solid fa-arrow-right"></i></button>
</div>
<button v-if="isFlexible(page.type)"
class="w-[96px] h-[96px] rounded-xl border border-dashed border-[#a88c6b]/40 text-[#a88c6b] text-[11px] flex items-center justify-center hover:border-[#a88c6b] transition-colors"
@click="addImage(page)">+ 添加图片</button>
</template>
</div>
</a-card>
</template>
</section>
<!-- 婚礼流程 -->
<section v-if="activeTab === 'schedule'">
<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>
</div>
<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>
</div>
<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>
</section>
<!-- 回执列表 -->
<section v-if="activeTab === 'rsvp'">
<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>
<template v-else-if="column.key==='actions'">
<a-button size="small" danger type="text" @click="onDeleteRsvp(record)">删除</a-button>
</template>
</template>
</a-table>
</div>
</section>
<!-- 礼金 -->
<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>
<!-- 酒店迎宾海报 -->
<section v-if="activeTab === 'hotelWelcome'">
<HotelWelcomePosterAdmin ref="hotelWelcomeRef" />
</section>
<!-- 弹幕审核 -->
<section v-if="activeTab === 'danmaku'">
<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>
</template>
</a-table>
</div>
</section>
</main>
</div>
<MediaPickerModal
v-model:open="mediaPickerOpen"
:model-value="mediaPickerCurrent"
@select="onMediaPicked"
/>
</div>
</template>
<script setup>
import { computed, reactive, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import * as echarts from 'echarts/core'
import { PieChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import {
getConfigSection, saveConfigSection, uploadImage, getRsvpList, deleteRsvp, getUploadConfig, saveUploadConfig,
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitCityStats, getVisitList,
getPosterConfig, savePosterConfig, getGiftStats, getGiftList, deleteGift, saveGiftCosts,
getCashGiftList, updateCashGift, deleteCashGift, submitCashGift,
getCashGiftNotes, saveCashGiftNotes,
getCashGiftViewPass, saveCashGiftViewPass,
getCashGiftEntryPass, saveCashGiftEntryPass,
} from '@/api/wedding'
import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
import MediaPickerModal from '@/components/MediaPickerModal.vue'
import HotelWelcomePosterAdmin from '@/views/admin/HotelWelcomePosterAdmin.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { normalizeMusicWeight, normalizeMusicCacheHours } from '@/utils/musicPick'
import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
import {
getSlotResizeSpecs,
getBasicImageResizeSpecs,
resizeSlotToFile,
probeResizedMeta,
metaHasFileSize,
} from '@/composables/resizeImage'
import {
defaultPosterBundle, normalizePosterBundle, defaultPosterForLayout, normalizePosterConfig,
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_LAYOUTS, POSTER_TEMPLATES, MOBILE_LAYOUTS,
HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, layoutKey,
posterSideMeta, posterLayoutMeta, posterRoute,
} from '@/composables/posterDefaults'
import {
HEART_SLOTS, HEART_SLOT_COUNT,
heartSlotStyle, heartSlotLabel, heartFocusAt, heartFocusCssAt,
syncImagesFocus, setHeartFocus,
} from '@/composables/heartCollage'
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
const router = useRouter()
const adminStore = useAdminStore()
const TAB_KEY = 'wedding_admin_active_tab'
const MENU_LAYOUT_KEY = 'wedding_admin_menu_layout'
const CONFIG_SECTIONS = ['basic', 'copy', 'visual', 'music', 'photos', 'schedule']
const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'hotelWelcome', 'rsvp', 'cash', 'danmaku']
/** 后台菜单分组 */
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' },
{ key: 'cash', label: '礼金', icon: 'fa-solid fa-yen-sign' },
{ key: 'danmaku', label: '弹幕审核', icon: 'fa-solid fa-comments' },
],
},
{
key: 'system',
label: '海报与系统',
items: [
{ key: 'poster', label: '海报', icon: 'fa-solid fa-scroll' },
{ key: 'hotelWelcome', label: '迎宾易拉宝', icon: 'fa-solid fa-hotel' },
{ key: 'upload', label: '上传设置', icon: 'fa-solid fa-cloud-arrow-up' },
],
},
]
const menuLayoutOptions = [
{ value: 'top', label: '顶部菜单' },
{ value: 'side', label: '侧边菜单' },
]
const SECTION_KEYS = {
basic: [
'groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address',
'heroImg', 'heroImgResized', 'heroImgResizedMeta',
'endImg', 'endImgResized', 'endImgResizedMeta',
],
copy: ['formalText1', 'formalText2', 'formalPageHeight'],
visual: [
'introExitEffect', 'scrollMode', 'freeScrollInterval', 'pageSwitchDuration', 'firstScreenDuration',
'petalEnabled', 'bubbleEnabled', 'introEnabled', 'danmakuEnabled', 'danmakuShowTime', 'nineGridAnimate',
],
music: ['musicList', 'activeMusicUrl', 'musicRandomEnabled', 'musicCacheEnabled', 'musicCacheHours'],
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'
}
function readStoredMenuLayout() {
try {
const v = localStorage.getItem(MENU_LAYOUT_KEY)
if (v === 'top' || v === 'side') return v
} catch { /* ignore */ }
return 'side'
}
const activeTab = ref(readStoredTab())
const menuLayout = ref(readStoredMenuLayout())
const hotelWelcomeRef = ref(null)
watch(menuLayout, (v) => {
try { localStorage.setItem(MENU_LAYOUT_KEY, v) } catch { /* ignore */ }
})
function isMenuGroupActive(group) {
return Array.isArray(group?.items) && group.items.some((item) => item.key === activeTab.value)
}
const activePhotoTab = ref('0')
const saving = ref(false)
const uploading = ref(false)
const resizingPage = ref(false)
const resizingBasic = ref(false)
const resizeProgress = ref('')
const uploadConfigSaving = ref(false)
const posterSaving = ref(false)
const posterLoading = ref(false)
const posterMusicLoading = ref(false)
/** 海报选曲用:独立缓存请柬 musicList避免依赖是否打开过音乐页 */
const posterMusicOptions = ref([])
const tabLoading = ref(false)
const rsvpLoading = ref(false)
const rsvpList = ref([])
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)
const danmakuLoading = ref(false)
const danmakuList = ref([])
const danmakuFilter = ref('pending')
const visitStatsLoading = ref(false)
const visitListLoading = ref(false)
const giftStatsLoading = ref(false)
const giftListLoading = ref(false)
const giftCostsSaving = ref(false)
const giftTypeList = GIFT_TYPES
const giftList = ref([])
const giftStats = reactive({
total: 0,
counts: Object.fromEntries(GIFT_TYPES.map((g) => [g.key, 0])),
})
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
}
}
const visitStats = reactive({ uv: 0, pv: 0, today_uv: 0, regions: [], devices: [] })
const visitList = ref([])
const visitPreviewList = ref([])
const visitPage = ref(1)
const visitPageSize = ref(20)
const visitTotal = ref(0)
const regionChartRef = ref(null)
const deviceChartRef = ref(null)
const cityChartRef = ref(null)
let regionChart = null
let deviceChart = null
let cityChart = null
const selectedRegion = ref('')
const cityStats = ref([])
const cityStatsLoading = ref(false)
const cfg = reactive(defaultConfig())
const uploadCfg = reactive(defaultUploadConfig())
const posterSideTab = ref('1')
const posterLayoutTab = ref('1')
const posterBundle = reactive(defaultPosterBundle())
const posterImporting = ref(false)
const posterCopying = ref(false)
/** 本会话最近一次复制的模板,导入时优先/兜底使用 */
let posterTemplateCache = null
/** 导入前快照,用于撤回 { s, l, config } */
const posterUndoSnapshot = ref(null)
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]
}
const heroFocusGrid = heroFocusGridPoints(HERO_FOCUS_GRID)
const heroFocusPreviewEl = ref(null)
const heroFocusDragging = ref(false)
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)
})
function setHeroFocus(x, y) {
const cfg = posterCfg.value
if (!cfg) return
if (heroFocusDevice.value === 'mobile') {
cfg.heroFocusMobileX = clampHeroFocus(x)
cfg.heroFocusMobileY = clampHeroFocus(y)
} else {
cfg.heroFocusX = clampHeroFocus(x)
cfg.heroFocusY = clampHeroFocus(y)
}
}
function isHeroFocusGridActive(pt) {
return Math.abs(heroFocusActiveX.value - pt.x) < 0.6
&& Math.abs(heroFocusActiveY.value - pt.y) < 0.6
}
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)
}
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
})
const currentPosterSide = computed(() => posterSideMeta(posterSideTab.value))
const currentPosterLayout = computed(() => posterLayoutMeta(posterLayoutTab.value))
const currentPosterPreviewPath = computed(() => posterRoute(posterSideTab.value, posterLayoutTab.value))
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: '儿媳:' }
})
const posterLinesText = computed({
get: () => {
const lines = posterCfg.value?.lines
return Array.isArray(lines) ? lines.join('\n') : ''
},
set: (v) => {
const slot = ensurePosterSlot()
slot.lines = String(v || '')
.split('\n')
.map((s) => s.trimEnd())
},
})
function setPosterHero(url) {
ensurePosterSlot().heroImg = url
}
function setPosterQrCenter(url) {
ensurePosterSlot().qrCenterImg = url
}
watch([posterSideTab, posterLayoutTab], () => {
const slot = ensurePosterSlot()
const l = layoutKey(posterLayoutTab.value)
slot.layout = Number(l)
})
const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) || 0, Math.max(cfg.photoPages.length - 1, 0)))
const isConfigTab = computed(() => CONFIG_SECTIONS.includes(activeTab.value))
const visitPagination = computed(() => ({
current: visitPage.value,
pageSize: visitPageSize.value,
total: visitTotal.value,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (t) => `${t}`,
}))
function defaultConfig() {
return {
groom: '李明', bride: '王华', date: '2026-05-20', lunar: '农历四月初四 星期三',
hotel: '杭州星光万丽大酒店', address: '杭州市西湖区星光大道88号\n3楼大宴会厅',
formalText1: '两姓联姻 一堂缔约', formalText2: '良缘永结 匹配同称', formalPageHeight: '100vh',
calendarDate: '2026-09-04',
heroImg: 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80',
heroImgResized: '',
heroImgResizedMeta: '',
endImg: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80',
endImgResized: '',
endImgResizedMeta: '',
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1', weight: 1 }],
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
musicRandomEnabled: false,
musicCacheEnabled: true,
musicCacheHours: 24,
danmakuEnabled: true,
danmakuShowTime: true,
nineGridAnimate: false,
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
introExitEffect: 'wind',
freeScrollInterval: 50, pageSwitchDuration: 800, firstScreenDuration: 4500,
photoPages: [
{ type: 'single', title: '初遇', subtitle: 'FIRST MET', desc: '世界那么大,还是遇见了你。\n于千万人之中没有早一步也没有晚一步。', img1: 'https://images.unsplash.com/photo-1606800052052-a08af7148866?auto=format&fit=crop&w=600&q=80', height: '100vh', borderStyle: 'mat' },
{ type: 'overlap', title: '相知', subtitle: 'FALL IN LOVE', desc: '纵然万劫不复,纵然相思入骨。\n我也待你眉眼如初岁月如故。', img1: 'https://images.unsplash.com/photo-1532712938310-34cb3982ef74?auto=format&fit=crop&w=600&q=80', img2: 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=600&q=80', height: '100vh', borderStyle: 'mat' },
{ type: 'one-large-five-small', title: '相恋', subtitle: 'ROMANCE', desc: '星光坠入眼眸,晚风轻抚过裙摆。\n浪漫不止于此还有无数个明天。', images: fill(6), height: '100vh', borderStyle: 'mat' },
{ type: 'nine-grid', title: '相守', subtitle: 'FOREVER', desc: '往后余生,风雪是你,平淡是你。', images: fill(9), height: '100vh', borderStyle: 'mat' },
],
schedule: [
{ time: '16:00', event: '签到迎宾', desc: 'Welcome' },
{ time: '17:08', event: '仪式开始', desc: 'Ceremony' },
{ time: '18:00', event: '敬备喜宴', desc: 'Banquet' },
],
}
}
function defaultUploadConfig() {
return {
provider: 'local',
accessKeyId: '',
accessKeySecret: '',
hasSecret: false,
bucket: '',
folder: '',
domain: '',
region: '',
endpoint: '',
}
}
function fill(n) {
return Array.from({ length: n }, () => 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=400&q=80')
}
// 页面高度预设
const HEIGHT_PRESETS = [
{ label: '满屏(整屏)', value: '100vh' },
{ label: '大屏80%', value: '80vh' },
{ label: '半屏60%', value: '60vh' },
{ label: '自适应(内容高度)', value: 'auto' },
{ label: '自定义', value: '__custom__' },
]
const INTRO_EXIT_EFFECTS = [
{ label: '风吹纸页', value: 'wind' },
{ label: '翻书开启', value: 'book' },
{ label: '上提淡出', value: 'lift' },
{ label: '双门揭幕', value: 'doors' },
]
function heightSelectValue(page) {
return heightFieldSelectValue(page.height)
}
function onHeightModeChange(page, val) {
if (val !== '__custom__') page.height = val
}
function customVh(page) {
return customVhValue(page.height)
}
function onCustomVh(page, val) {
page.height = (val || 100) + 'vh'
}
function heightFieldSelectValue(value) {
const match = HEIGHT_PRESETS.find((p) => p.value === value)
return match ? match.value : '__custom__'
}
function customVhValue(value) {
const m = /^([\d.]+)vh$/.exec(value || '')
return m ? Number(m[1]) : 100
}
function onHeightFieldModeChange(target, key, val) {
if (val !== '__custom__') target[key] = val
}
function onHeightFieldCustomVh(target, key, val) {
target[key] = (val || 100) + 'vh'
}
function 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
}
function ensurePageShape(p) {
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 {
// 固定数量版式:一大五小(6) / 九宫格(9) / 爱心拼接(13);柔性版式:瀑布流/轮播/拍立得,仅首次补齐
const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'heart-collage': HEART_SLOT_COUNT, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
const need = fixed[p.type] || 6
p.images = Array.isArray(p.images) ? p.images : []
if (p.images.length === 0) while (p.images.length < need) p.images.push('')
else if (['one-large-five-small', 'nine-grid', 'heart-collage'].includes(p.type)) {
while (p.images.length < need) p.images.push('')
if (p.images.length > need) p.images.length = need
}
syncImagesResized(p)
syncImagesFocus(p)
delete p.img1
delete p.img2
delete p.img1Resized
delete p.img2Resized
delete p.img1ResizedMeta
delete p.img2ResizedMeta
}
// 非爱心版式清掉焦点字段
if (p.type !== 'heart-collage') syncImagesFocus(p)
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
p.height = p.height || '100vh'
p.borderStyle = p.borderStyle || 'mat'
// 单图宽度设置(仅 single 版式使用,逐页配置)
p.singleWidth = p.singleWidth === 'full' ? 'full' : 'default'
if (typeof p.singleKeepRatio !== 'boolean') p.singleKeepRatio = true
return p
}
// 柔性版式(图片数量可增删)
function isFlexible(type) {
return ['masonry', 'carousel', 'polaroid'].includes(type)
}
function addImage(page) {
if (!Array.isArray(page.images)) page.images = []
page.images.push('')
syncImagesResized(page)
syncImagesFocus(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)
if (Array.isArray(page.imagesFocusX)) page.imagesFocusX.splice(i, 1)
if (Array.isArray(page.imagesFocusY)) page.imagesFocusY.splice(i, 1)
syncImagesResized(page)
syncImagesFocus(page)
}
}
// 图片排序:前移(-1) / 后移(+1)
function moveImage(page, i, dir) {
const j = i + dir
if (!Array.isArray(page.images) || j < 0 || j >= page.images.length) return
const swap = (arr) => {
if (!Array.isArray(arr) || i >= arr.length || j >= arr.length) return
const t = arr[i]; arr[i] = arr[j]; arr[j] = t
}
swap(page.images)
syncImagesResized(page)
swap(page.imagesResized)
swap(page.imagesResizedMeta)
syncImagesFocus(page)
swap(page.imagesFocusX)
swap(page.imagesFocusY)
}
const heartSwapSelected = ref(null)
const heartFocusDrag = ref(null)
const HEART_FOCUS_DRAG_THRESHOLD = 6
function swapHeartSlots(page, i, j) {
if (!page || !Array.isArray(page.images) || i === j) return
if (i < 0 || j < 0 || i >= page.images.length || j >= page.images.length) return
syncImagesResized(page)
syncImagesFocus(page)
const swap = (arr) => {
if (!Array.isArray(arr)) return
const t = arr[i]
arr[i] = arr[j]
arr[j] = t
}
swap(page.images)
swap(page.imagesResized)
swap(page.imagesResizedMeta)
swap(page.imagesFocusX)
swap(page.imagesFocusY)
}
function applyHeartFocusFromClient(clientX, clientY) {
const drag = heartFocusDrag.value
if (!drag?.el || !drag.page) return
const rect = drag.el.getBoundingClientRect()
if (!rect.width || !rect.height) return
const x = ((clientX - rect.left) / rect.width) * 100
const y = ((clientY - rect.top) / rect.height) * 100
setHeartFocus(drag.page, drag.idx, x, y)
}
function onHeartSlotPointerDown(e, page, idx) {
if (e.target?.closest?.('.heart-admin-btn')) return
if (e.button != null && e.button !== 0) return
const empty = !(page.images?.[idx] || page.imagesResized?.[idx])
// 空格:交给 pointerup 做「上传 / 对调」
if (empty) {
heartFocusDrag.value = {
page, idx, el: e.currentTarget,
startX: e.clientX, startY: e.clientY,
moved: false, empty: true,
}
window.addEventListener('pointerup', onHeartSlotPointerUp)
window.addEventListener('pointercancel', onHeartSlotPointerUp)
return
}
e.preventDefault()
heartFocusDrag.value = {
page, idx, el: e.currentTarget,
startX: e.clientX, startY: e.clientY,
moved: false, empty: false,
}
window.addEventListener('pointermove', onHeartSlotPointerMove)
window.addEventListener('pointerup', onHeartSlotPointerUp)
window.addEventListener('pointercancel', onHeartSlotPointerUp)
}
function onHeartSlotPointerMove(e) {
const drag = heartFocusDrag.value
if (!drag || drag.empty) return
const dx = e.clientX - drag.startX
const dy = e.clientY - drag.startY
if (!drag.moved && (dx * dx + dy * dy) < HEART_FOCUS_DRAG_THRESHOLD ** 2) return
drag.moved = true
applyHeartFocusFromClient(e.clientX, e.clientY)
}
function onHeartSlotClickAction(page, idx) {
const sel = heartSwapSelected.value
const empty = !(page.images?.[idx] || page.imagesResized?.[idx])
if (empty && !(sel && sel.page === page)) {
openUpload((u) => setOriginalImageAt(page, idx, u))
return
}
if (!sel || sel.page !== page) {
heartSwapSelected.value = { page, idx }
return
}
if (sel.idx === idx) {
heartSwapSelected.value = null
return
}
swapHeartSlots(page, sel.idx, idx)
heartSwapSelected.value = null
}
function onHeartSlotPointerUp(e) {
const drag = heartFocusDrag.value
window.removeEventListener('pointermove', onHeartSlotPointerMove)
window.removeEventListener('pointerup', onHeartSlotPointerUp)
window.removeEventListener('pointercancel', onHeartSlotPointerUp)
heartFocusDrag.value = null
if (!drag?.page) return
// 拖过焦点:不对调
if (drag.moved) return
onHeartSlotClickAction(drag.page, drag.idx)
}
function clearHeartSlotPointer() {
heartFocusDrag.value = null
window.removeEventListener('pointermove', onHeartSlotPointerMove)
window.removeEventListener('pointerup', onHeartSlotPointerUp)
window.removeEventListener('pointercancel', onHeartSlotPointerUp)
}
function pageHasOriginal(page) {
return getSlotResizeSpecs(page).some((s) => !!s.getOriginal())
}
function setOriginalAndClearResized(page, key, url) {
page[key] = url
page[`${key}Resized`] = ''
page[`${key}ResizedMeta`] = ''
}
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 (ok > 0) {
try {
await saveConfigSection('basic', pickSectionData('basic'))
if (fail === 0) message.success(`已生成并保存 ${ok} 张展示图`)
else message.warning(`成功 ${ok} 张并已保存,失败 ${fail}`)
} catch (e) {
message.warning(`已生成 ${ok} 张展示图,但自动保存失败:${e?.message || '请手动保存'}`)
}
} else {
message.error('缩放上传全部失败,请确认原图可跨域读取')
}
} finally {
resizingBasic.value = false
resizeProgress.value = ''
}
}
function setOriginalImageAt(page, iidx, url) {
page.images[iidx] = url
syncImagesResized(page)
syncImagesFocus(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 = ''
}
}
// 相册页操作
function setActivePhotoIndex(i) {
const max = Math.max(cfg.photoPages.length - 1, 0)
activePhotoTab.value = String(Math.min(Math.max(i, 0), max))
}
function addPage() {
cfg.photoPages.push(ensurePageShape({ type: 'single', title: '新页面', subtitle: 'NEW', desc: '', img1: '' }))
setActivePhotoIndex(cfg.photoPages.length - 1)
}
function removePage(i) {
cfg.photoPages.splice(i, 1)
setActivePhotoIndex(Math.min(i, cfg.photoPages.length - 1))
}
function movePage(i, dir) {
const j = i + dir
if (j < 0 || j >= cfg.photoPages.length) return
const t = cfg.photoPages[i]; cfg.photoPages[i] = cfg.photoPages[j]; cfg.photoPages[j] = t
setActivePhotoIndex(j)
}
// 流程操作
function addSchedule() { cfg.schedule.push({ time: '', event: '', desc: '' }) }
function removeSchedule(i) { cfg.schedule.splice(i, 1) }
// 选图:弹层图库 + 新上传
function openUpload(onUrl) {
mediaPickerOnUrl = typeof onUrl === 'function' ? onUrl : null
mediaPickerCurrent.value = ''
mediaPickerOpen.value = true
}
function onMediaPicked(url) {
if (mediaPickerOnUrl) mediaPickerOnUrl(url)
mediaPickerOnUrl = null
}
// 上传(背景音乐,多首)
function openMusicUpload() {
const input = document.createElement('input')
input.type = 'file'; input.accept = 'audio/*'
input.onchange = async (e) => {
const file = e.target.files[0]; if (!file) return
uploading.value = true
try {
const res = await uploadImage(file) // 后端 /upload 同处理任意文件
const name = file.name.replace(/\.[^.]+$/, '') || '背景音乐'
const url = res.url
if (!cfg.musicList) cfg.musicList = []
cfg.musicList.push({ url, name, coverUrl: '', lrcUrl: '', weight: 1 })
if (!cfg.activeMusicUrl) cfg.activeMusicUrl = url
message.success('音乐上传成功')
} catch (err) {
message.error('上传失败')
} finally {
uploading.value = false; e.target.value = ''
}
}
input.click()
}
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()
}
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 musicWeightPercent(track) {
const list = (cfg.musicList || []).filter((t) => t && t.url)
const total = list.reduce((s, t) => s + normalizeMusicWeight(t.weight), 0)
if (!total) return 0
return Math.round((normalizeMusicWeight(track?.weight) / total) * 100)
}
function shortUrl(url) {
if (!url) return ''
const parts = url.split('/')
return parts[parts.length - 1] || url
}
async function loadUploadConfig() {
try {
const res = await getUploadConfig()
Object.assign(uploadCfg, defaultUploadConfig(), res.data || {})
uploadCfg.accessKeySecret = ''
} catch (e) {
// 上传设置仅后台使用,读取失败不影响内容编辑。
}
}
async function onSaveUploadConfig() {
uploadConfigSaving.value = true
try {
const payload = {
provider: uploadCfg.provider,
accessKeyId: uploadCfg.accessKeyId,
accessKeySecret: uploadCfg.accessKeySecret,
bucket: uploadCfg.bucket,
folder: uploadCfg.folder,
domain: uploadCfg.domain,
region: uploadCfg.region,
endpoint: uploadCfg.endpoint,
}
const res = await saveUploadConfig(payload)
Object.assign(uploadCfg, defaultUploadConfig(), res.data || {})
uploadCfg.accessKeySecret = ''
message.success('上传设置已保存')
} catch (e) {
message.error(e?.message || '上传设置保存失败')
} finally {
uploadConfigSaving.value = false
}
}
async function 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
}
}
async function loadPosterConfig() {
posterLoading.value = true
try {
const res = await getPosterConfig()
const bundle = normalizePosterBundle(res.data || {})
posterBundle['1'] = bundle['1']
posterBundle['2'] = bundle['2']
posterBundle['3'] = bundle['3']
} catch (e) {
message.error(e?.message || '海报配置加载失败')
} finally {
posterLoading.value = false
}
}
async function onSavePosterConfig() {
posterSaving.value = true
try {
// 仅同步 layout 编号与 Tab 一致,不覆盖用户所选 template / mobileLayout
for (const s of ['1', '2', '3']) {
ensurePosterSlot(s, '1').layout = 1
ensurePosterSlot(s, '2').layout = 2
}
const payload = normalizePosterBundle(posterBundle)
const res = await savePosterConfig(payload)
const bundle = normalizePosterBundle(res.data || payload)
posterBundle['1'] = bundle['1']
posterBundle['2'] = bundle['2']
posterBundle['3'] = bundle['3']
posterUndoSnapshot.value = null
message.success('海报配置已保存')
} catch (e) {
message.error(e?.message || '海报配置保存失败')
} finally {
posterSaving.value = false
}
}
function buildPosterTemplatePayload() {
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
const slot = ensurePosterSlot(s, l)
return {
version: 1,
kind: 'poster-slot',
side: Number(s),
layout: Number(l),
sideLabel: posterSideMeta(s).label,
layoutLabel: posterLayoutMeta(l).label,
route: posterRoute(s, l),
config: JSON.parse(JSON.stringify(slot)),
}
}
async function copyPosterTemplate() {
posterCopying.value = true
try {
const payload = buildPosterTemplatePayload()
posterTemplateCache = payload
const text = JSON.stringify(payload, null, 2)
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
message.success(`已复制「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}`)
} catch (e) {
message.error(e?.message || '复制失败,请检查浏览器剪贴板权限')
} finally {
posterCopying.value = false
}
}
function extractPosterImportConfig(parsed) {
if (!parsed || typeof parsed !== 'object') return null
if (parsed.kind === 'poster-slot' && parsed.config && typeof parsed.config === 'object') {
return parsed.config
}
if (parsed.config && typeof parsed.config === 'object' && !Array.isArray(parsed.config)) {
return parsed.config
}
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
const sideObj = parsed[s] || parsed[Number(s)]
if (sideObj && typeof sideObj === 'object') {
const slot = sideObj[l] || sideObj[Number(l)]
if (slot && typeof slot === 'object') return slot
}
if ('template' in parsed || 'heroImg' in parsed || 'lines' in parsed || 'sonName' in parsed || 'groomName' in parsed) {
return parsed
}
return null
}
async function readPosterImportSource() {
// 1) 剪贴板 2) 本会话刚复制的缓存
try {
if (navigator?.clipboard?.readText) {
const clip = String(await navigator.clipboard.readText() || '').trim()
if (clip) {
try {
const parsed = JSON.parse(clip)
if (extractPosterImportConfig(parsed)) return parsed
} catch { /* not json */ }
}
}
} catch { /* no permission */ }
if (posterTemplateCache && extractPosterImportConfig(posterTemplateCache)) {
return posterTemplateCache
}
return null
}
function applyImportedConfig(rawCfg) {
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
posterUndoSnapshot.value = {
s,
l,
config: JSON.parse(JSON.stringify(ensurePosterSlot(s, l))),
}
const next = normalizePosterConfig(rawCfg, s, l)
next.side = Number(s)
next.layout = Number(l)
if (next.sonName && !next.groomName) next.groomName = next.sonName
if (next.daughterInLawName && !next.brideName) next.brideName = next.daughterInLawName
if (next.groomName && !next.sonName) next.sonName = next.groomName
if (next.brideName && !next.daughterInLawName) next.daughterInLawName = next.brideName
posterBundle[s][l] = next
}
async function importPosterTemplateDirect() {
posterImporting.value = true
try {
const parsed = await readPosterImportSource()
if (!parsed) {
message.warning('请先复制模板,再点导入')
return
}
const rawCfg = extractPosterImportConfig(parsed)
if (!rawCfg) {
message.error('无法识别的模板数据')
return
}
applyImportedConfig(rawCfg)
message.success(`已导入到「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}」,可撤回或保存`)
} catch (e) {
message.error(e?.message || '导入失败')
} finally {
posterImporting.value = false
}
}
function undoPosterImport() {
const snap = posterUndoSnapshot.value
if (!snap?.config) {
message.info('没有可撤回的导入')
return
}
posterSideTab.value = snap.s
posterLayoutTab.value = snap.l
posterBundle[snap.s][snap.l] = snap.config
posterUndoSnapshot.value = null
message.success('已撤回导入')
}
function pickSectionData(section) {
const keys = SECTION_KEYS[section] || []
const data = {}
for (const k of keys) data[k] = cfg[k]
const cloned = JSON.parse(JSON.stringify(data))
if (section === 'music' && Array.isArray(cloned.musicList)) {
cloned.musicList = cloned.musicList.map((t) => ({
...t,
weight: normalizeMusicWeight(t?.weight),
}))
cloned.musicCacheEnabled = cloned.musicCacheEnabled !== false
cloned.musicCacheHours = normalizeMusicCacheHours(cloned.musicCacheHours)
}
return cloned
}
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 || '',
weight: normalizeMusicWeight(t.weight),
}))
}
if (typeof loaded.musicRandomEnabled !== 'boolean') loaded.musicRandomEnabled = false
if (typeof loaded.musicCacheEnabled !== 'boolean') loaded.musicCacheEnabled = true
loaded.musicCacheHours = normalizeMusicCacheHours(loaded.musicCacheHours)
}
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'
loaded.heroImgResized = loaded.heroImgResized || ''
loaded.heroImgResizedMeta = loaded.heroImgResizedMeta || ''
loaded.endImgResized = loaded.endImgResized || ''
loaded.endImgResizedMeta = loaded.endImgResizedMeta || ''
}
if (section === 'copy') {
loaded.formalPageHeight = loaded.formalPageHeight || '100vh'
}
if (section === 'visual') {
loaded.introExitEffect = loaded.introExitEffect || 'wind'
loaded.freeScrollInterval = Number(loaded.freeScrollInterval) || 50
loaded.pageSwitchDuration = Number(loaded.pageSwitchDuration) || 800
loaded.firstScreenDuration = Number.isFinite(Number(loaded.firstScreenDuration))
? Number(loaded.firstScreenDuration)
: 4500
if (typeof loaded.danmakuEnabled !== 'boolean') loaded.danmakuEnabled = true
if (typeof loaded.danmakuShowTime !== 'boolean') loaded.danmakuShowTime = true
if (typeof loaded.nineGridAnimate !== 'boolean') loaded.nineGridAnimate = false
}
Object.assign(cfg, loaded)
if (section === 'photos') {
setActivePhotoIndex(currentPhotoIndex.value)
nextTick(() => {
const page = cfg.photoPages[currentPhotoIndex.value]
if (page) fillMissingResizedMeta(page)
})
}
}
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
}
}
// 保存当前配置段
async function onSave() {
const section = activeTab.value
if (!CONFIG_SECTIONS.includes(section)) {
message.info('当前页无需保存配置')
return
}
saving.value = true
try {
const res = await saveConfigSection(section, pickSectionData(section))
applySectionData(section, res.data || pickSectionData(section))
message.success('配置已保存')
} catch (e) {
const msg = e?.message || '保存失败'
message.error(msg)
if (String(msg).includes('未授权') || String(msg).includes('登录')) {
router.replace({ name: 'Login', query: { redirect: '/admin' } })
}
} finally {
saving.value = false
}
}
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 },
]
const visitColumns = [
{ title: '指纹', key: 'fingerprint', width: 120 },
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
{ title: '归属地', key: 'location', ellipsis: true },
{ title: '机型', key: 'device', width: 140, ellipsis: true },
{ title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 70 },
{ title: 'UA', key: 'user_agent', ellipsis: true },
{ title: '最近访问', key: 'last_seen_at', width: 170 },
]
function shortFp(fp) {
if (!fp) return '-'
return fp.length > 12 ? `${fp.slice(0, 8)}${fp.slice(-4)}` : fp
}
function shortUa(ua) {
if (!ua) return '-'
return ua.length > 42 ? `${ua.slice(0, 42)}` : ua
}
const PIE_COLORS = ['#a88c6b', '#c4a484', '#e8b4b8', '#7c9eb2', '#b5a4c9', '#d4a574', '#9b7e9a', '#64748b']
function disposeVisitCharts() {
if (regionChart) {
regionChart.dispose()
regionChart = null
}
if (deviceChart) {
deviceChart.dispose()
deviceChart = null
}
if (cityChart) {
cityChart.dispose()
cityChart = null
}
}
function disposeCityChart() {
if (cityChart) {
cityChart.dispose()
cityChart = null
}
}
function renderPieChart(chartRef, holder, seriesData, emptyName = '暂无数据', opts = {}) {
if (!chartRef.value) return null
if (holder && holder.getDom() !== chartRef.value) {
holder.dispose()
holder = null
}
if (!holder) holder = echarts.init(chartRef.value)
const data = seriesData.length
? seriesData.map((r) => ({
name: r.name || '未知',
value: Number(r.value) || 0,
selected: opts.selectedName ? (r.name || '未知') === opts.selectedName : false,
}))
: [{ name: emptyName, value: 0 }]
holder.setOption({
color: PIE_COLORS,
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%'],
selectedMode: opts.selectable ? 'single' : false,
selectedOffset: 8,
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
label: { color: '#5c5348', fontSize: 11 },
data,
}],
}, { notMerge: true })
return holder
}
function renderRegionChart() {
regionChart = renderPieChart(
regionChartRef,
regionChart,
Array.isArray(visitStats.regions) ? visitStats.regions : [],
'暂无数据',
{ selectable: true, selectedName: selectedRegion.value },
)
if (!regionChart) return
regionChart.off('click')
regionChart.on('click', (params) => {
const name = params?.name
if (!name || name === '暂无数据') return
if (selectedRegion.value === name) {
clearSelectedRegion()
return
}
onSelectRegion(name)
})
}
function renderDeviceChart() {
deviceChart = renderPieChart(
deviceChartRef,
deviceChart,
Array.isArray(visitStats.devices) ? visitStats.devices : [],
)
}
function renderCityChart() {
cityChart = renderPieChart(
cityChartRef,
cityChart,
Array.isArray(cityStats.value) ? cityStats.value : [],
'暂无市级数据',
)
}
function clearSelectedRegion(opts = {}) {
selectedRegion.value = ''
cityStats.value = []
disposeCityChart()
if (!opts.silent && regionChart) renderRegionChart()
}
async function onSelectRegion(name) {
selectedRegion.value = name
cityStatsLoading.value = true
cityStats.value = []
disposeCityChart()
renderRegionChart()
try {
const res = await getVisitCityStats(name)
const d = res.data || {}
cityStats.value = Array.isArray(d.cities) ? d.cities : []
await nextTick()
renderCityChart()
} catch (e) {
message.error(e?.message || '加载市级分布失败')
cityStats.value = []
} finally {
cityStatsLoading.value = false
}
}
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)
applyGiftCostsFromServer(d.costs)
} catch (e) {
message.error(e?.message || '加载礼物统计失败')
} finally {
giftStatsLoading.value = false
}
}
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 || '删除失败')
}
}
async function loadVisitStats() {
visitStatsLoading.value = true
try {
const statsRes = await getVisitStats()
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 : []
visitStats.devices = Array.isArray(s.devices) ? s.devices : []
await nextTick()
renderRegionChart()
renderDeviceChart()
if (selectedRegion.value) {
await onSelectRegion(selectedRegion.value)
}
} 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,
})
const listData = listRes.data || {}
visitList.value = listData.list || []
visitTotal.value = Number(listData.total) || 0
} catch (e) {
message.error(e?.message || '加载访客记录失败')
} finally {
visitListLoading.value = false
}
}
async function loadOverview() {
await Promise.all([loadVisitStats(), loadVisitPreview()])
}
function onVisitTableChange(pag) {
visitPage.value = pag?.current || 1
visitPageSize.value = pag?.pageSize || visitPageSize.value
loadVisitList()
}
async function onTabActivate(tab) {
if (!adminStore.isAdmin) return
if (tab !== 'overview') {
clearSelectedRegion({ silent: true })
disposeVisitCharts()
}
if (tab === 'overview') {
await loadOverview()
return
}
if (tab === 'gifts') {
await Promise.all([loadGiftStats(), loadGiftList()])
return
}
if (tab === 'visits') {
await loadVisitList()
return
}
if (CONFIG_SECTIONS.includes(tab)) {
await loadConfigSection(tab)
return
}
if (tab === 'upload') {
await loadUploadConfig()
return
}
if (tab === 'poster') {
await Promise.all([loadPosterConfig(), loadPosterMusicOptions()])
return
}
if (tab === 'hotelWelcome') {
hotelWelcomeRef.value?.refresh?.()
return
}
if (tab === 'rsvp') {
await loadRsvp()
return
}
if (tab === 'cash') {
await Promise.all([loadCashGifts(), loadCashNotePresets(), loadCashViewPass(), loadCashEntryPass()])
return
}
if (tab === 'danmaku') {
await loadDanmaku()
}
}
function openPreview() { window.open('/', '_blank') }
function openPosterPreview(side = 1, layout = 1) {
window.open(posterRoute(side, layout), '_blank')
}
function onLogout() {
adminStore.logout()
router.replace({ name: 'Login', query: { redirect: '/admin' } })
}
// RSVP
const rsvpColumns = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '出席', dataIndex: 'guest_count', key: 'guest_count' },
{ title: '祝福', dataIndex: 'wishes', key: 'wishes', ellipsis: true },
{ title: '提交时间', dataIndex: 'created_at', key: 'created_at' },
{ title: '操作', key: 'actions', width: 90 },
]
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 }
}
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
}
}
const danmakuColumns = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
{ title: '来源', key: 'source', width: 80 },
{ title: '状态', key: 'status', width: 90 },
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
{ title: '操作', key: 'actions', width: 160 },
]
const danmakuStatusLabel = (s) => ({ pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s)
const danmakuStatusPill = (s) => ({
pending: 'admin-pill--warn',
approved: 'admin-pill--ok',
rejected: 'admin-pill--muted',
}[s] || 'admin-pill--muted')
async function loadDanmaku() {
danmakuLoading.value = true
try {
const params = danmakuFilter.value ? { status: danmakuFilter.value } : undefined
const res = await getDanmakuList(params)
danmakuList.value = res.data || []
} catch (e) { /* 忽略 */ }
finally { danmakuLoading.value = false }
}
async function setDanmakuStatus(id, status) {
try {
await updateDanmakuStatus(id, status)
message.success(status === 'approved' ? '已通过' : '已拒绝')
loadDanmaku()
} catch (e) {
message.error(e.message || '操作失败')
}
}
watch(danmakuFilter, () => {
if (adminStore.isAdmin && activeTab.value === 'danmaku') loadDanmaku()
})
watch(activeTab, (tab) => {
try { localStorage.setItem(TAB_KEY, tab) } catch { /* ignore */ }
onTabActivate(tab)
}, { immediate: true })
// 进入相册页 / 切换页签时,为缺 meta 的展示图自动探测尺寸
watch(
() => [activeTab.value, currentPhotoIndex.value],
([tab]) => {
if (tab !== 'photos') return
const page = cfg.photoPages[currentPhotoIndex.value]
if (page) fillMissingResizedMeta(page)
},
)
watch(activeTab, (tab) => {
if (tab === 'basic') fillMissingBasicResizedMeta()
})
const onResize = () => {
regionChart?.resize()
deviceChart?.resize()
cityChart?.resize()
}
onMounted(() => {
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
onHeroFocusPointerUp()
clearHeartSlotPointer()
disposeVisitCharts()
})
</script>
<style scoped>
.resized-meta {
margin-top: 2px;
font-size: 9px;
line-height: 1.35;
color: #8A8680;
white-space: pre-line;
word-break: break-all;
}
.heart-admin-board {
position: relative;
width: 100%;
max-width: 520px;
aspect-ratio: 0.8 / 1;
margin: 0 auto 8px;
background:
radial-gradient(ellipse at 50% 30%, rgba(201, 120, 120, 0.06), transparent 55%),
#faf8f5;
border: 1px solid #e7e3db;
border-radius: 12px;
overflow: hidden;
}
.heart-admin-slot {
position: absolute;
min-width: 0;
min-height: 0;
padding: 1px;
box-sizing: border-box;
}
.heart-admin-cell {
position: relative;
width: 100%;
height: 100%;
border-radius: 4px;
overflow: hidden;
background: #f0ebe3;
border: 1px solid #e7e3db;
user-select: none;
touch-action: none;
cursor: pointer;
transition: box-shadow 0.15s, border-color 0.15s;
}
.heart-admin-cell.empty {
border-style: dashed;
border-color: rgba(168, 140, 107, 0.45);
background: #f7f4ef;
}
.heart-admin-cell.selected {
border-color: #a88c6b;
box-shadow: 0 0 0 2px rgba(168, 140, 107, 0.65);
z-index: 3;
}
.heart-admin-cell.swap-target:hover {
border-color: #c4a484;
box-shadow: 0 0 0 2px rgba(196, 164, 132, 0.45);
z-index: 2;
}
.heart-admin-cell.dragging {
cursor: grabbing;
box-shadow: 0 0 0 2px rgba(168, 140, 107, 0.55);
z-index: 4;
}
.heart-admin-cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.heart-admin-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #a88c6b;
font-size: 10px;
line-height: 1.2;
padding: 2px;
text-align: center;
pointer-events: none;
}
.heart-admin-crosshair {
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
border-width: 1.5px;
}
.heart-admin-toolbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2px;
padding: 2px 3px;
background: linear-gradient(transparent, rgba(40, 30, 20, 0.55));
z-index: 2;
pointer-events: none;
}
.heart-admin-label {
font-size: 9px;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.heart-admin-btn {
pointer-events: auto;
width: 18px;
height: 18px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
color: #a88c6b;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.heart-admin-btn:hover {
background: #a88c6b;
color: #fff;
}
.heart-admin-meta {
position: absolute;
top: 2px;
right: 2px;
z-index: 2;
font-size: 8px;
line-height: 1;
padding: 2px 3px;
border-radius: 3px;
background: rgba(168, 140, 107, 0.9);
color: #fff;
pointer-events: none;
}
.admin-layout-seg :deep(.ant-segmented-item-selected) {
color: #a88c6b;
}
.admin-header {
padding: 10px 16px 10px;
}
.admin-header-row {
display: flex;
align-items: center;
gap: 12px 16px;
flex-wrap: wrap;
}
.admin-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
}
.admin-top-menu {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
padding: 2px 0;
}
.admin-top-menu::-webkit-scrollbar { display: none; }
.admin-top-menu-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
border: none;
background: transparent;
color: #5c5348;
font-size: 13px;
letter-spacing: 0.04em;
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.admin-top-menu-trigger:hover {
background: rgba(168, 140, 107, 0.08);
color: #967b5c;
}
.admin-top-menu-trigger.active {
color: #a88c6b;
background: rgba(168, 140, 107, 0.12);
font-weight: 500;
}
.admin-top-menu-caret {
font-size: 9px;
opacity: 0.65;
}
.admin-top-dropdown :deep(.ant-dropdown-menu-item-selected) {
color: #a88c6b;
background: rgba(168, 140, 107, 0.12);
}
.admin-shell {
margin: 0 auto;
padding: 20px 16px 40px;
box-sizing: border-box;
}
.admin-shell--top {
max-width: 80vw;
width: 80vw;
}
.admin-shell--side {
max-width: 1220px;
display: flex;
align-items: flex-start;
gap: 18px;
}
.admin-nav {
background: #fff;
border: 1px solid rgba(168, 140, 107, 0.14);
border-radius: 16px;
box-shadow: 0 8px 28px rgba(88, 68, 42, 0.05);
position: sticky;
top: 68px;
width: 212px;
flex-shrink: 0;
padding: 14px 10px 16px;
max-height: calc(100dvh - 88px);
overflow: auto;
}
.admin-nav-group-title {
font-size: 11px;
letter-spacing: 0.12em;
color: #8A8680;
margin-bottom: 6px;
padding: 0 6px;
}
.admin-nav-group + .admin-nav-group {
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed rgba(168, 140, 107, 0.22);
}
.admin-nav-items {
display: flex;
flex-direction: column;
gap: 6px;
}
.admin-nav-item {
display: inline-flex;
align-items: center;
gap: 8px;
width: 100%;
border: 1px solid transparent;
background: transparent;
color: #5c5348;
font-size: 13px;
line-height: 1.2;
padding: 7px 10px;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
text-align: left;
}
.admin-nav-item:hover {
background: rgba(168, 140, 107, 0.08);
color: #967b5c;
}
.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: 1em;
text-align: center;
font-size: 12px;
opacity: 0.85;
}
.admin-nav-credit {
margin-top: 14px;
padding: 10px 8px 2px;
border-top: 1px dashed rgba(168, 140, 107, 0.22);
font-size: 11px;
letter-spacing: 0.08em;
color: #a88c6b;
text-align: center;
}
.admin-main {
min-width: 0;
flex: 1;
}
@media (max-width: 960px) {
.admin-top-menu {
order: 3;
flex: 1 1 100%;
border-top: 1px solid rgba(168, 140, 107, 0.12);
margin-top: 2px;
padding-top: 8px;
}
}
@media (max-width: 860px) {
.admin-shell--side {
flex-direction: column;
}
.admin-nav {
position: static;
width: 100%;
max-height: none;
}
.admin-nav-items {
flex-direction: row;
flex-wrap: wrap;
}
.admin-nav-item {
width: auto;
}
}
.admin-panel {
background: #fff;
border: 1px solid rgba(168, 140, 107, 0.14);
border-radius: 16px;
padding: 20px 20px 16px;
box-shadow: 0 8px 28px rgba(88, 68, 42, 0.05);
}
.stat-card {
border-radius: 14px;
padding: 16px 18px;
background: linear-gradient(145deg, #fff 0%, #f8f1e6 100%);
border: 1px solid rgba(168, 140, 107, 0.16);
}
.stat-label {
font-size: 11px;
color: #8A8680;
letter-spacing: 0.06em;
}
.stat-value {
margin-top: 6px;
font-size: 28px;
font-weight: 500;
color: #a88c6b;
letter-spacing: 0.02em;
line-height: 1.1;
}
.visit-preview-wrap,
.visit-list-wrap {
border: 1px solid rgba(168, 140, 107, 0.12);
border-radius: 12px;
overflow: hidden;
background: #fffdf9;
}
.visit-preview-wrap :deep(.ant-table-body),
.visit-list-wrap :deep(.ant-table-body) {
max-height: inherit;
}
.chart-card {
border-radius: 14px;
padding: 14px 16px;
background: #fdfbf7;
border: 1px solid rgba(168, 140, 107, 0.12);
min-height: 300px;
}
.admin-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 16px;
border-radius: 14px;
background: linear-gradient(180deg, #fdfbf7 0%, #f7f3ec 100%);
border: 1px dashed rgba(168, 140, 107, 0.28);
}
.admin-empty-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(168, 140, 107, 0.12);
color: #a88c6b;
margin-bottom: 12px;
font-size: 18px;
}
.music-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.music-track {
border: 1px solid rgba(168, 140, 107, 0.14);
border-radius: 14px;
background: #fdfbf7;
padding: 12px 14px;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.music-track--active {
border-color: rgba(168, 140, 107, 0.55);
background: linear-gradient(135deg, #fff 0%, #f8f1e6 100%);
box-shadow: 0 6px 18px rgba(168, 140, 107, 0.12);
}
.music-track-main {
display: flex;
align-items: flex-start;
gap: 14px;
}
.music-cover {
position: relative;
width: 72px;
height: 72px;
border-radius: 12px;
overflow: hidden;
flex-shrink: 0;
border: 1px 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: 22px;
background: radial-gradient(circle at 30% 30%, #fff8ee, #efe4d4);
}
.music-cover-index {
position: absolute;
left: 4px;
bottom: 4px;
font-size: 9px;
letter-spacing: 0.06em;
color: #fff;
background: rgba(44, 36, 28, 0.45);
padding: 1px 5px;
border-radius: 999px;
}
.music-meta { min-width: 0; }
.music-name-input {
padding: 0 !important;
font-size: 14px !important;
font-weight: 500;
color: #2c2c2c;
}
.music-name-input :deep(.ant-input) {
padding: 0;
font-size: 14px;
font-weight: 500;
background: transparent;
}
.music-sub {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
font-size: 11px;
color: #8A8680;
min-width: 0;
}
.music-badge {
flex-shrink: 0;
font-size: 10px;
letter-spacing: 0.04em;
color: #a88c6b;
background: rgba(168, 140, 107, 0.12);
border: 1px solid rgba(168, 140, 107, 0.25);
border-radius: 999px;
padding: 1px 8px;
}
.music-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.music-chip {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid rgba(168, 140, 107, 0.22);
background: #fff;
color: #7a6550;
font-size: 11px;
padding: 3px 10px;
border-radius: 999px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.music-chip:hover {
border-color: #a88c6b;
color: #a88c6b;
}
.music-chip--muted {
color: #9a9084;
border-color: transparent;
background: transparent;
}
.music-chip--weight {
cursor: default;
gap: 6px;
}
.music-chip--weight:hover {
border-color: rgba(168, 140, 107, 0.22);
color: #7a6550;
}
.music-weight-input {
width: 72px !important;
}
.music-weight-input :deep(.ant-input-number-input) {
height: 22px;
font-size: 11px;
padding: 0 4px;
text-align: center;
}
.music-weight-pct {
font-size: 10px;
color: #a88c6b;
min-width: 2.2em;
}
.music-audio {
width: 100%;
max-width: 420px;
height: 32px;
margin-top: 10px;
border-radius: 8px;
outline: none;
}
.music-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
@media (max-width: 640px) {
.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: 10px;
}
.schedule-row {
display: flex;
align-items: flex-end;
gap: 12px;
padding: 14px;
border-radius: 14px;
background: #fdfbf7;
border: 1px solid rgba(168, 140, 107, 0.12);
}
.schedule-index {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
letter-spacing: 0.06em;
color: #a88c6b;
background: rgba(168, 140, 107, 0.1);
flex-shrink: 0;
margin-bottom: 2px;
}
.schedule-fields {
flex: 1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
min-width: 0;
}
@media (max-width: 720px) {
.schedule-fields { grid-template-columns: 1fr; }
.schedule-row { align-items: flex-start; }
}
.field-label {
font-size: 11px;
color: #a88c6b;
margin-bottom: 4px;
letter-spacing: 0.04em;
}
.admin-table :deep(.ant-table) {
background: transparent;
}
.admin-table :deep(.ant-table-thead > tr > th) {
background: #f7f3ec !important;
color: #7a6550;
font-weight: 500;
font-size: 12px;
border-bottom: 1px solid rgba(168, 140, 107, 0.16) !important;
}
.admin-table :deep(.ant-table-tbody > tr > td) {
border-bottom: 1px solid rgba(168, 140, 107, 0.08) !important;
font-size: 13px;
}
.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: 1px 9px;
border-radius: 999px;
font-size: 11px;
line-height: 18px;
border: 1px 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: #8a7048;
background: rgba(168, 140, 107, 0.14);
border-color: rgba(168, 140, 107, 0.28);
}
.admin-pill--sky {
color: #4d6f86;
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: #3f6b4a;
background: rgba(134, 179, 143, 0.18);
border-color: rgba(134, 179, 143, 0.35);
}
.admin-pill--muted {
color: #8A8680;
background: #f3f1ec;
border-color: #e7e3db;
}
.danmaku-admin-dot {
width: 10px; height: 10px; border-radius: 9999px; flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
.hero-focus-pad {
display: grid;
grid-template-columns: repeat(var(--hero-focus-n, 9), 22px);
gap: 4px;
width: fit-content;
padding: 8px;
border-radius: 10px;
background: #f7f4ef;
border: 1px solid #e7e3db;
}
.hero-focus-cell {
width: 22px;
height: 22px;
border-radius: 5px;
border: 1px solid #ddd6cb;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: border-color 0.15s, background 0.15s;
}
.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: 5px;
height: 5px;
border-radius: 999px;
background: #c4b8a4;
}
.hero-focus-cell.active .hero-focus-dot { background: #a88c6b; }
.hero-focus-preview {
position: relative;
width: min(100%, 280px);
aspect-ratio: 16 / 11;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e7e3db;
background: #f0ebe3;
user-select: none;
touch-action: none;
}
.hero-focus-preview.is-mobile {
width: min(100%, 200px);
aspect-ratio: 3 / 4;
}
.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: #8A8680;
font-size: 12px;
padding: 12px;
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: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border: 2px solid #fff;
border-radius: 999px;
box-shadow: 0 0 0 1px rgba(168, 140, 107, 0.85), 0 1px 4px rgba(0, 0, 0, 0.25);
background: rgba(168, 140, 107, 0.35);
pointer-events: none;
z-index: 1;
}
.poster-action-bar-spacer {
height: 72px;
}
.poster-action-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(168, 140, 107, 0.2);
box-shadow: 0 -8px 24px rgba(88, 68, 42, 0.08);
}
.poster-action-bar-inner {
max-width: 1220px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.admin-shell--top ~ .poster-action-bar .poster-action-bar-inner,
.admin-shell--top .poster-action-bar-inner {
max-width: 80vw;
}
.poster-action-bar-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
font-size: 12px;
color: #5c5348;
}
.poster-action-bar-path {
font-size: 11px;
color: #8A8680;
}
.poster-action-bar-btns {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
margin-left: auto;
}
@media (max-width: 720px) {
.poster-action-bar-meta { width: 100%; }
.poster-action-bar-btns { width: 100%; justify-content: stretch; }
.poster-action-bar-btns :deep(.ant-btn) { flex: 1 1 auto; }
}
</style>