2801 lines
106 KiB
Vue
2801 lines
106 KiB
Vue
<template>
|
||
<div class="min-h-screen bg-[#f6f4ef] text-[#2c2c2c]">
|
||
<!-- 顶部栏 -->
|
||
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-[#a88c6b]/15 px-4 sm:px-6 py-3 flex items-center justify-between gap-3">
|
||
<div class="flex items-center gap-3 min-w-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>
|
||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||
<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 v-for="s in POSTER_SIDES" :key="s.key" @click="openPosterPreview(s.key)">
|
||
{{ s.label }}({{ s.key }}) {{ s.route }}
|
||
</a-menu-item>
|
||
</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>
|
||
</header>
|
||
|
||
<div class="admin-shell" :class="menuLayout === 'side' ? 'admin-shell--side' : 'admin-shell--top'">
|
||
<nav 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="text-[12px] text-[#7a6550] mb-2 tracking-wide">地区分布</div>
|
||
<div ref="regionChartRef" class="h-[280px] w-full"></div>
|
||
</div>
|
||
<div class="chart-card">
|
||
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">机型画像</div>
|
||
<div ref="deviceChartRef" class="h-[280px] w-full"></div>
|
||
</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" @click="loadGiftStats">
|
||
<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>
|
||
</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="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||
<a-form-item label="封面图片">
|
||
<image-field v-model="cfg.heroImg" label="封面" @pick="openUpload(u => cfg.heroImg = u)" :uploading="uploading" />
|
||
<div class="text-[11px] text-[#8A8680] mt-2">用于首页封面和入场揭幕背景。</div>
|
||
</a-form-item>
|
||
<a-form-item label="尾页图片">
|
||
<image-field v-model="cfg.endImg" label="尾页" @pick="openUpload(u => cfg.endImg = u)" :uploading="uploading" />
|
||
<div class="text-[11px] text-[#8A8680] mt-2">用于最后的回执尾页,可与封面分开设置。</div>
|
||
</a-form-item>
|
||
</div>
|
||
</a-form>
|
||
</a-card>
|
||
</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-md">
|
||
访客首次交互后自动播放当前曲目。开启随机后,24 小时内本地缓存同一首。
|
||
</p>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
|
||
<a-switch v-model:checked="cfg.musicRandomEnabled" size="small" />
|
||
随机播放
|
||
</label>
|
||
<a-button type="primary" size="small"
|
||
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
|
||
@click="openMusicUpload">
|
||
<i class="fa-solid fa-plus mr-1"></i>上传音乐
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!cfg.musicList.length" class="admin-empty">
|
||
<div class="admin-empty-icon"><i class="fa-solid fa-music"></i></div>
|
||
<div class="text-[13px] text-[#5c5348]">尚未上传音乐</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-1">支持 mp3 等常见音频格式</div>
|
||
<a-button class="mt-4" @click="openMusicUpload">
|
||
<i class="fa-solid fa-upload mr-1 text-[#a88c6b]"></i>上传第一首
|
||
</a-button>
|
||
</div>
|
||
|
||
<ul v-else class="music-list">
|
||
<li
|
||
v-for="(t, i) in cfg.musicList"
|
||
:key="i"
|
||
class="music-track"
|
||
:class="{ 'music-track--active': t.url === cfg.activeMusicUrl }"
|
||
>
|
||
<div class="music-track-main">
|
||
<button
|
||
type="button"
|
||
class="music-cover"
|
||
@click="openUpload(u => t.coverUrl = u)"
|
||
:title="t.coverUrl ? '更换封面' : '上传封面'"
|
||
>
|
||
<img v-if="t.coverUrl" :src="t.coverUrl" alt="" />
|
||
<span v-else class="music-cover-fallback">
|
||
<i class="fa-solid fa-compact-disc"></i>
|
||
</span>
|
||
<span class="music-cover-index">{{ String(i + 1).padStart(2, '0') }}</span>
|
||
</button>
|
||
|
||
<div class="music-meta min-w-0 flex-1">
|
||
<a-input
|
||
v-model:value="t.name"
|
||
placeholder="曲目名称"
|
||
class="music-name-input"
|
||
:bordered="false"
|
||
/>
|
||
<div class="music-sub">
|
||
<span class="truncate">{{ shortUrl(t.url) }}</span>
|
||
<span
|
||
v-if="t.url === cfg.activeMusicUrl"
|
||
class="music-badge"
|
||
>正在播放</span>
|
||
</div>
|
||
<div class="music-chips">
|
||
<button type="button" class="music-chip" @click="openLrcUpload(t)">
|
||
<i class="fa-solid fa-file-lines"></i>
|
||
{{ t.lrcUrl ? '已有歌词' : '上传歌词' }}
|
||
</button>
|
||
<button
|
||
v-if="t.lrcUrl"
|
||
type="button"
|
||
class="music-chip music-chip--muted"
|
||
@click="t.lrcUrl = ''"
|
||
>清除歌词</button>
|
||
</div>
|
||
<audio v-if="t.url" :src="t.url" controls preload="none" class="music-audio"></audio>
|
||
</div>
|
||
|
||
<div class="music-actions">
|
||
<a-button
|
||
size="small"
|
||
:type="t.url === cfg.activeMusicUrl ? 'primary' : 'default'"
|
||
:class="t.url === cfg.activeMusicUrl ? '!bg-[#a88c6b] !border-[#a88c6b]' : ''"
|
||
@click="setActiveMusic(t.url)"
|
||
>
|
||
<i class="fa-solid fa-play mr-1 text-[10px]"></i>
|
||
{{ t.url === cfg.activeMusicUrl ? '当前曲' : '设为播放' }}
|
||
</a-button>
|
||
<a-button size="small" danger type="text" @click="removeMusic(i)" title="删除">
|
||
<i class="fa-solid fa-trash"></i>
|
||
</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_configs:1 男方 /hb-n,2 女方 /hb-nv,3 回门宴 /hb-hm) -->
|
||
<section v-if="activeTab === 'poster'">
|
||
<a-card :bordered="false" class="!shadow-sm">
|
||
<a-alert
|
||
type="info"
|
||
show-icon
|
||
class="mb-4"
|
||
message="海报配置独立存储。1 男方 /hb-n,2 女方 /hb-nv,3 回门宴 /hb-hm;可分别选择模板。"
|
||
/>
|
||
<a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-3">
|
||
<a-tab-pane v-for="s in POSTER_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" />
|
||
</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>
|
||
<span class="text-[11px] text-[#8A8680] ml-2">路由 {{ currentPosterSide.route }}</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="照片展示焦点">
|
||
<div
|
||
v-if="posterCfg.heroImg"
|
||
ref="heroFocusPreviewEl"
|
||
class="hero-focus-preview hero-focus-preview--drag"
|
||
:class="{ dragging: heroFocusDragging }"
|
||
@pointerdown="onHeroFocusPointerDown"
|
||
>
|
||
<img
|
||
:src="posterCfg.heroImg"
|
||
alt=""
|
||
draggable="false"
|
||
:style="{ objectPosition: heroFocusCss(posterCfg) }"
|
||
/>
|
||
<span
|
||
class="hero-focus-crosshair"
|
||
:style="{ left: posterCfg.heroFocusX + '%', top: posterCfg.heroFocusY + '%' }"
|
||
/>
|
||
</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">
|
||
在预览上拖拽十字准星,或点下方 9×9 网格;当前 {{ Math.round(posterCfg.heroFocusX) }}% · {{ Math.round(posterCfg.heroFocusY) }}%
|
||
</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="正文(每行一条,建议 2~4 行)">
|
||
<a-textarea v-model:value="posterLinesText" :rows="5" placeholder="每行一条正文" />
|
||
</a-form-item>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||
<a-form-item label="新郎">
|
||
<a-input v-model:value="posterCfg.groomName" />
|
||
</a-form-item>
|
||
<a-form-item label="新娘">
|
||
<a-input v-model:value="posterCfg.brideName" />
|
||
</a-form-item>
|
||
<a-form-item label="喜宴时间">
|
||
<a-input v-model:value="posterCfg.dateText" />
|
||
</a-form-item>
|
||
<a-form-item label="农历">
|
||
<a-input v-model:value="posterCfg.lunarText" />
|
||
</a-form-item>
|
||
</div>
|
||
<a-form-item label="地点">
|
||
<a-input v-model:value="posterCfg.venueText" />
|
||
</a-form-item>
|
||
<a-form-item label="底部 Love 文案">
|
||
<a-input v-model:value="posterCfg.loveFooter" placeholder="Love you" />
|
||
</a-form-item>
|
||
</template>
|
||
|
||
<div class="flex gap-2 flex-wrap">
|
||
<a-button type="primary" :loading="posterSaving" @click="onSavePosterConfig"
|
||
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]">
|
||
<i class="fa-solid fa-floppy-disk mr-1"></i>保存海报配置
|
||
</a-button>
|
||
<a-button @click="openPosterPreview(posterSideTab)">
|
||
<i class="fa-solid fa-eye mr-1"></i>打开 {{ currentPosterSide.route }} 预览
|
||
</a-button>
|
||
</div>
|
||
</a-form>
|
||
</a-card>
|
||
</section>
|
||
|
||
<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>
|
||
</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>
|
||
<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>
|
||
</a-table>
|
||
</div>
|
||
</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>
|
||
</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, getUploadConfig, saveUploadConfig,
|
||
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitList,
|
||
getPosterConfig, savePosterConfig, getGiftStats, saveGiftCosts,
|
||
} from '@/api/wedding'
|
||
import { useAdminStore } from '@/stores/admin'
|
||
import ImageField from '@/components/ImageField.vue'
|
||
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
|
||
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
|
||
import { GIFT_TYPES, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
|
||
import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
|
||
import {
|
||
defaultPosterBundle, normalizePosterBundle, normalizePosterConfig,
|
||
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_TEMPLATES, MOBILE_LAYOUTS,
|
||
HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, posterSideMeta,
|
||
} from '@/composables/posterDefaults'
|
||
|
||
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', 'rsvp', '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: 'danmaku', label: '弹幕审核', icon: 'fa-solid fa-comments' },
|
||
],
|
||
},
|
||
{
|
||
key: 'system',
|
||
label: '海报与系统',
|
||
items: [
|
||
{ key: 'poster', label: '海报', icon: 'fa-solid fa-scroll' },
|
||
{ key: 'upload', label: '上传设置', icon: 'fa-solid fa-cloud-arrow-up' },
|
||
],
|
||
},
|
||
]
|
||
|
||
const menuLayoutOptions = [
|
||
{ value: 'top', label: '顶部菜单' },
|
||
{ value: 'side', label: '侧边菜单' },
|
||
]
|
||
|
||
const SECTION_KEYS = {
|
||
basic: ['groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address', 'heroImg', 'endImg'],
|
||
copy: ['formalText1', 'formalText2', 'formalPageHeight'],
|
||
visual: [
|
||
'introExitEffect', 'scrollMode', 'freeScrollInterval', 'pageSwitchDuration', 'firstScreenDuration',
|
||
'petalEnabled', 'bubbleEnabled', 'introEnabled', 'danmakuEnabled', 'danmakuShowTime', 'nineGridAnimate',
|
||
],
|
||
music: ['musicList', 'activeMusicUrl', 'musicRandomEnabled'],
|
||
photos: ['photoPages'],
|
||
schedule: ['schedule'],
|
||
}
|
||
|
||
function readStoredTab() {
|
||
try {
|
||
const v = localStorage.getItem(TAB_KEY)
|
||
if (v && VALID_TABS.includes(v)) return v
|
||
} catch { /* ignore */ }
|
||
return 'overview'
|
||
}
|
||
|
||
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())
|
||
watch(menuLayout, (v) => {
|
||
try { localStorage.setItem(MENU_LAYOUT_KEY, v) } catch { /* ignore */ }
|
||
})
|
||
const activePhotoTab = ref('0')
|
||
const saving = ref(false)
|
||
const uploading = ref(false)
|
||
const resizingPage = 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 danmakuLoading = ref(false)
|
||
const danmakuList = ref([])
|
||
const danmakuFilter = ref('pending')
|
||
|
||
const visitStatsLoading = ref(false)
|
||
const visitListLoading = ref(false)
|
||
const giftStatsLoading = ref(false)
|
||
const giftCostsSaving = ref(false)
|
||
const giftTypeList = GIFT_TYPES
|
||
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)
|
||
let regionChart = null
|
||
let deviceChart = null
|
||
|
||
const cfg = reactive(defaultConfig())
|
||
const uploadCfg = reactive(defaultUploadConfig())
|
||
const posterSideTab = ref('1')
|
||
const posterBundle = reactive(defaultPosterBundle())
|
||
const posterCfg = computed(() => posterBundle[sideKey(posterSideTab.value)] || posterBundle['1'])
|
||
|
||
const heroFocusGrid = heroFocusGridPoints(HERO_FOCUS_GRID)
|
||
const heroFocusPreviewEl = ref(null)
|
||
const heroFocusDragging = ref(false)
|
||
|
||
function setHeroFocus(x, y) {
|
||
const cfg = posterCfg.value
|
||
if (!cfg) return
|
||
cfg.heroFocusX = clampHeroFocus(x)
|
||
cfg.heroFocusY = clampHeroFocus(y)
|
||
}
|
||
|
||
function isHeroFocusGridActive(pt) {
|
||
const cfg = posterCfg.value
|
||
if (!cfg) return false
|
||
return Math.abs(cfg.heroFocusX - pt.x) < 0.6
|
||
&& Math.abs(cfg.heroFocusY - 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 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 side = sideKey(posterSideTab.value)
|
||
if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
|
||
posterBundle[side].lines = String(v || '')
|
||
.split('\n')
|
||
.map((s) => s.trimEnd())
|
||
},
|
||
})
|
||
function setPosterHero(url) {
|
||
const side = sideKey(posterSideTab.value)
|
||
if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
|
||
posterBundle[side].heroImg = url
|
||
}
|
||
function setPosterQrCenter(url) {
|
||
const side = sideKey(posterSideTab.value)
|
||
if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
|
||
posterBundle[side].qrCenterImg = url
|
||
}
|
||
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',
|
||
endImg: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80',
|
||
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1' }],
|
||
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
|
||
musicRandomEnabled: false,
|
||
danmakuEnabled: true,
|
||
danmakuShowTime: true,
|
||
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);柔性版式:瀑布流/轮播/拍立得,仅首次补齐
|
||
const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
|
||
const need = fixed[p.type] || 6
|
||
p.images = Array.isArray(p.images) ? p.images : []
|
||
if (p.images.length === 0) while (p.images.length < need) p.images.push('')
|
||
else if (['one-large-five-small', 'nine-grid'].includes(p.type)) {
|
||
while (p.images.length < need) p.images.push('')
|
||
if (p.images.length > need) p.images.length = need
|
||
}
|
||
syncImagesResized(p)
|
||
delete p.img1
|
||
delete p.img2
|
||
delete p.img1Resized
|
||
delete p.img2Resized
|
||
delete p.img1ResizedMeta
|
||
delete p.img2ResizedMeta
|
||
}
|
||
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)
|
||
}
|
||
function removeImage(page, i) {
|
||
if (page.images.length > 1) {
|
||
page.images.splice(i, 1)
|
||
if (Array.isArray(page.imagesResized)) page.imagesResized.splice(i, 1)
|
||
if (Array.isArray(page.imagesResizedMeta)) page.imagesResizedMeta.splice(i, 1)
|
||
syncImagesResized(page)
|
||
}
|
||
}
|
||
// 图片排序:前移(-1) / 后移(+1)
|
||
function moveImage(page, i, dir) {
|
||
const j = i + dir
|
||
if (!Array.isArray(page.images) || j < 0 || j >= page.images.length) return
|
||
const t = page.images[i]; page.images[i] = page.images[j]; page.images[j] = t
|
||
syncImagesResized(page)
|
||
if (Array.isArray(page.imagesResized)) {
|
||
const r = page.imagesResized[i]
|
||
page.imagesResized[i] = page.imagesResized[j]
|
||
page.imagesResized[j] = r
|
||
}
|
||
if (Array.isArray(page.imagesResizedMeta)) {
|
||
const m = page.imagesResizedMeta[i]
|
||
page.imagesResizedMeta[i] = page.imagesResizedMeta[j]
|
||
page.imagesResizedMeta[j] = m
|
||
}
|
||
}
|
||
|
||
function pageHasOriginal(page) {
|
||
return getSlotResizeSpecs(page).some((s) => !!s.getOriginal())
|
||
}
|
||
|
||
function setOriginalAndClearResized(page, key, url) {
|
||
page[key] = url
|
||
page[`${key}Resized`] = ''
|
||
page[`${key}ResizedMeta`] = ''
|
||
}
|
||
|
||
function setOriginalImageAt(page, iidx, url) {
|
||
page.images[iidx] = url
|
||
syncImagesResized(page)
|
||
page.imagesResized[iidx] = ''
|
||
page.imagesResizedMeta[iidx] = ''
|
||
}
|
||
|
||
/** 缺 meta,或只有像素没有文件大小时,自动探测并补全 */
|
||
async function fillMissingResizedMeta(page) {
|
||
if (!page) return
|
||
if (page.type === 'single' || page.type === 'overlap') {
|
||
const keys = page.type === 'single' ? ['img1'] : ['img1', 'img2']
|
||
await Promise.all(keys.map(async (key) => {
|
||
const original = page[key]
|
||
const resized = page[`${key}Resized`]
|
||
const metaKey = `${key}ResizedMeta`
|
||
if (!resized || !original || metaHasFileSize(page[metaKey])) return
|
||
try {
|
||
page[metaKey] = await probeResizedMeta(original, resized)
|
||
} catch { /* 跨域/失效图忽略 */ }
|
||
}))
|
||
return
|
||
}
|
||
if (!Array.isArray(page.images)) return
|
||
syncImagesResized(page)
|
||
await Promise.all(page.images.map(async (original, i) => {
|
||
const resized = page.imagesResized[i]
|
||
if (!resized || !original || metaHasFileSize(page.imagesResizedMeta[i])) return
|
||
try {
|
||
page.imagesResizedMeta[i] = await probeResizedMeta(original, resized)
|
||
} catch { /* ignore */ }
|
||
}))
|
||
}
|
||
|
||
async function batchResizeAndUpload(page) {
|
||
const slots = getSlotResizeSpecs(page).filter((s) => !!s.getOriginal())
|
||
if (!slots.length) {
|
||
message.warning('请先上传原图')
|
||
return
|
||
}
|
||
resizingPage.value = true
|
||
resizeProgress.value = `0/${slots.length}`
|
||
let ok = 0
|
||
let fail = 0
|
||
try {
|
||
for (let i = 0; i < slots.length; i += 1) {
|
||
const slot = slots[i]
|
||
resizeProgress.value = `${i + 1}/${slots.length}`
|
||
try {
|
||
const { file, meta } = await resizeSlotToFile(
|
||
slot.getOriginal(),
|
||
slot.maxWidth,
|
||
slot.maxHeight,
|
||
`resized-${page.type}-${slot.key}-${Date.now()}.jpg`,
|
||
)
|
||
const res = await uploadImage(file)
|
||
if (!res?.url) throw new Error('上传无返回地址')
|
||
slot.setResized(res.url, meta)
|
||
ok += 1
|
||
} catch (err) {
|
||
fail += 1
|
||
console.warn('[resize]', slot.key, err)
|
||
}
|
||
}
|
||
if (fail === 0) message.success(`已生成 ${ok} 张展示图`)
|
||
else if (ok > 0) message.warning(`成功 ${ok} 张,失败 ${fail} 张(常见原因:跨域无法读取原图)`)
|
||
else message.error('缩放上传全部失败,请确认原图可跨域读取')
|
||
} finally {
|
||
resizingPage.value = false
|
||
resizeProgress.value = ''
|
||
}
|
||
}
|
||
|
||
// 相册页操作
|
||
function setActivePhotoIndex(i) {
|
||
const max = Math.max(cfg.photoPages.length - 1, 0)
|
||
activePhotoTab.value = String(Math.min(Math.max(i, 0), max))
|
||
}
|
||
function addPage() {
|
||
cfg.photoPages.push(ensurePageShape({ type: 'single', title: '新页面', subtitle: 'NEW', desc: '', img1: '' }))
|
||
setActivePhotoIndex(cfg.photoPages.length - 1)
|
||
}
|
||
function removePage(i) {
|
||
cfg.photoPages.splice(i, 1)
|
||
setActivePhotoIndex(Math.min(i, cfg.photoPages.length - 1))
|
||
}
|
||
function movePage(i, dir) {
|
||
const j = i + dir
|
||
if (j < 0 || j >= cfg.photoPages.length) return
|
||
const t = cfg.photoPages[i]; cfg.photoPages[i] = cfg.photoPages[j]; cfg.photoPages[j] = t
|
||
setActivePhotoIndex(j)
|
||
}
|
||
// 流程操作
|
||
function addSchedule() { cfg.schedule.push({ time: '', event: '', desc: '' }) }
|
||
function removeSchedule(i) { cfg.schedule.splice(i, 1) }
|
||
|
||
// 上传(图片)
|
||
function openUpload(onUrl) {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'; input.accept = 'image/*'
|
||
input.onchange = async (e) => {
|
||
const file = e.target.files[0]; if (!file) return
|
||
uploading.value = true
|
||
try {
|
||
const res = await uploadImage(file)
|
||
onUrl(res.url); message.success('上传成功')
|
||
} catch (err) {
|
||
message.error('上传失败')
|
||
} finally {
|
||
uploading.value = false; e.target.value = ''
|
||
}
|
||
}
|
||
input.click()
|
||
}
|
||
|
||
// 上传(背景音乐,多首)
|
||
function openMusicUpload() {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'; input.accept = 'audio/*'
|
||
input.onchange = async (e) => {
|
||
const file = e.target.files[0]; if (!file) return
|
||
uploading.value = true
|
||
try {
|
||
const res = await uploadImage(file) // 后端 /upload 同处理任意文件
|
||
const name = file.name.replace(/\.[^.]+$/, '') || '背景音乐'
|
||
const url = res.url
|
||
if (!cfg.musicList) cfg.musicList = []
|
||
cfg.musicList.push({ url, name, coverUrl: '', lrcUrl: '' })
|
||
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 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 {
|
||
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']
|
||
message.success('海报配置已保存')
|
||
} catch (e) {
|
||
message.error(e?.message || '海报配置保存失败')
|
||
} finally {
|
||
posterSaving.value = false
|
||
}
|
||
}
|
||
|
||
function pickSectionData(section) {
|
||
const keys = SECTION_KEYS[section] || []
|
||
const data = {}
|
||
for (const k of keys) data[k] = cfg[k]
|
||
return JSON.parse(JSON.stringify(data))
|
||
}
|
||
|
||
function applySectionData(section, loaded) {
|
||
if (!loaded || typeof loaded !== 'object') return
|
||
if (section === 'music') {
|
||
if (Array.isArray(loaded.musicList)) {
|
||
loaded.musicList = loaded.musicList.map((t) => ({
|
||
...t,
|
||
coverUrl: t.coverUrl || '',
|
||
lrcUrl: t.lrcUrl || '',
|
||
}))
|
||
}
|
||
if (typeof loaded.musicRandomEnabled !== 'boolean') loaded.musicRandomEnabled = false
|
||
}
|
||
if (section === 'photos') {
|
||
loaded.photoPages = (loaded.photoPages || []).map(ensurePageShape)
|
||
}
|
||
if (section === 'basic') {
|
||
loaded.endImg = loaded.endImg || loaded.heroImg || cfg.endImg
|
||
loaded.calendarDate = loaded.calendarDate || '2026-09-04'
|
||
}
|
||
if (section === 'copy') {
|
||
loaded.formalPageHeight = loaded.formalPageHeight || '100vh'
|
||
}
|
||
if (section === 'visual') {
|
||
loaded.introExitEffect = loaded.introExitEffect || 'wind'
|
||
loaded.freeScrollInterval = Number(loaded.freeScrollInterval) || 50
|
||
loaded.pageSwitchDuration = Number(loaded.pageSwitchDuration) || 800
|
||
loaded.firstScreenDuration = Number.isFinite(Number(loaded.firstScreenDuration))
|
||
? Number(loaded.firstScreenDuration)
|
||
: 4500
|
||
if (typeof loaded.danmakuEnabled !== 'boolean') loaded.danmakuEnabled = true
|
||
if (typeof loaded.danmakuShowTime !== 'boolean') loaded.danmakuShowTime = true
|
||
if (typeof loaded.nineGridAnimate !== 'boolean') loaded.nineGridAnimate = false
|
||
}
|
||
Object.assign(cfg, loaded)
|
||
if (section === 'photos') {
|
||
setActivePhotoIndex(currentPhotoIndex.value)
|
||
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
|
||
}
|
||
}
|
||
|
||
function renderPieChart(chartRef, holder, seriesData, emptyName = '暂无数据') {
|
||
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 }))
|
||
: [{ 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%'],
|
||
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
|
||
label: { color: '#5c5348', fontSize: 11 },
|
||
data,
|
||
}],
|
||
})
|
||
return holder
|
||
}
|
||
|
||
function renderRegionChart() {
|
||
regionChart = renderPieChart(
|
||
regionChartRef,
|
||
regionChart,
|
||
Array.isArray(visitStats.regions) ? visitStats.regions : [],
|
||
)
|
||
}
|
||
|
||
function renderDeviceChart() {
|
||
deviceChart = renderPieChart(
|
||
deviceChartRef,
|
||
deviceChart,
|
||
Array.isArray(visitStats.devices) ? visitStats.devices : [],
|
||
)
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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()
|
||
} 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') disposeVisitCharts()
|
||
if (tab === 'overview') {
|
||
await loadOverview()
|
||
return
|
||
}
|
||
if (tab === 'gifts') {
|
||
await loadGiftStats()
|
||
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 === 'rsvp') {
|
||
await loadRsvp()
|
||
return
|
||
}
|
||
if (tab === 'danmaku') {
|
||
await loadDanmaku()
|
||
}
|
||
}
|
||
|
||
function openPreview() { window.open('/', '_blank') }
|
||
function openPosterPreview(side = 1) {
|
||
const s = Number(sideKey(side))
|
||
const meta = POSTER_SIDES.find((x) => x.key === s) || POSTER_SIDES[0]
|
||
window.open(meta.route, '_blank')
|
||
}
|
||
|
||
function onLogout() {
|
||
adminStore.logout()
|
||
router.replace({ name: 'Login', query: { redirect: '/admin' } })
|
||
}
|
||
|
||
// RSVP
|
||
const rsvpColumns = [
|
||
{ title: '姓名', dataIndex: 'name', key: 'name' },
|
||
{ title: '出席', dataIndex: 'guest_count', key: 'guest_count' },
|
||
{ title: '祝福', dataIndex: 'wishes', key: 'wishes', ellipsis: true },
|
||
{ title: '提交时间', dataIndex: 'created_at', key: 'created_at' },
|
||
]
|
||
const rsvpCountLabel = (v) => ({ '1': '1 人出席', '2': '2 人出席', '3': '3 人及以上', '0': '遗憾缺席' }[v] || v)
|
||
function formatTime(t) {
|
||
if (!t) return ''
|
||
const d = new Date(t)
|
||
if (isNaN(d)) return t
|
||
return d.toLocaleString('zh-CN', { hour12: false })
|
||
}
|
||
async function loadRsvp() {
|
||
rsvpLoading.value = true
|
||
try { const res = await getRsvpList(); rsvpList.value = res.data || [] }
|
||
catch (e) { /* 忽略 */ }
|
||
finally { rsvpLoading.value = false }
|
||
}
|
||
|
||
const danmakuColumns = [
|
||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
|
||
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||
{ title: '来源', key: 'source', width: 80 },
|
||
{ title: '状态', key: 'status', width: 90 },
|
||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||
{ title: '操作', key: 'actions', width: 160 },
|
||
]
|
||
const danmakuStatusLabel = (s) => ({ pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s)
|
||
const 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)
|
||
},
|
||
)
|
||
|
||
const onResize = () => {
|
||
regionChart?.resize()
|
||
deviceChart?.resize()
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('resize', onResize)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', onResize)
|
||
onHeroFocusPointerUp()
|
||
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;
|
||
}
|
||
.admin-layout-seg :deep(.ant-segmented-item-selected) {
|
||
color: #a88c6b;
|
||
}
|
||
.admin-shell {
|
||
margin: 0 auto;
|
||
padding: 20px 16px 40px;
|
||
box-sizing: border-box;
|
||
}
|
||
.admin-shell--top {
|
||
max-width: 1040px;
|
||
}
|
||
.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);
|
||
}
|
||
.admin-shell--top .admin-nav {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px 18px;
|
||
padding: 14px 16px 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.admin-shell--side .admin-nav {
|
||
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-shell--top .admin-nav-group {
|
||
min-width: 0;
|
||
}
|
||
.admin-shell--side .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-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
.admin-shell--side .admin-nav-items {
|
||
flex-direction: column;
|
||
flex-wrap: nowrap;
|
||
}
|
||
.admin-nav-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
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-shell--side .admin-nav-item {
|
||
width: 100%;
|
||
}
|
||
.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-shell--top .admin-nav-credit {
|
||
width: 100%;
|
||
margin-top: 4px;
|
||
padding-top: 10px;
|
||
}
|
||
.admin-main {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
@media (max-width: 860px) {
|
||
.admin-shell--side {
|
||
flex-direction: column;
|
||
}
|
||
.admin-shell--side .admin-nav {
|
||
position: static;
|
||
width: 100%;
|
||
max-height: none;
|
||
}
|
||
.admin-shell--side .admin-nav-items {
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
}
|
||
.admin-shell--side .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-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--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;
|
||
}
|
||
</style>
|