3873 lines
144 KiB
Vue
3873 lines
144 KiB
Vue
<template>
|
||
<div class="min-h-screen bg-[#f6f4ef] text-[#2c2c2c]">
|
||
<!-- 顶部栏 -->
|
||
<header class="admin-header sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-[#a88c6b]/15">
|
||
<div class="admin-header-row">
|
||
<div class="flex items-center gap-3 min-w-0 shrink-0">
|
||
<span class="text-xl text-[#a88c6b] font-serif shrink-0">❀</span>
|
||
<div class="min-w-0">
|
||
<div class="text-sm tracking-[0.18em] font-light truncate">婚礼邀请函 · 内容编辑后台</div>
|
||
<div class="text-[10px] text-[#8A8680] tracking-[0.12em] mt-0.5">
|
||
制作者 · <span class="text-[#a88c6b]">年糕崽崽</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 顶部菜单:一级分类 + 下拉 -->
|
||
<nav v-if="menuLayout === 'top'" class="admin-top-menu" aria-label="后台菜单">
|
||
<a-dropdown
|
||
v-for="group in adminMenuGroups"
|
||
:key="group.key"
|
||
:trigger="['hover', 'click']"
|
||
placement="bottomLeft"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="admin-top-menu-trigger"
|
||
:class="{ active: isMenuGroupActive(group) }"
|
||
>
|
||
<span>{{ group.label }}</span>
|
||
<i class="fa-solid fa-chevron-down admin-top-menu-caret" />
|
||
</button>
|
||
<template #overlay>
|
||
<a-menu
|
||
class="admin-top-dropdown"
|
||
:selected-keys="[activeTab]"
|
||
@click="({ key }) => { activeTab = String(key) }"
|
||
>
|
||
<a-menu-item v-for="item in group.items" :key="item.key">
|
||
<i :class="item.icon" class="mr-2 text-[12px] opacity-80" />
|
||
{{ item.label }}
|
||
</a-menu-item>
|
||
</a-menu>
|
||
</template>
|
||
</a-dropdown>
|
||
</nav>
|
||
|
||
<div class="admin-header-actions">
|
||
<a-segmented
|
||
v-model:value="menuLayout"
|
||
size="small"
|
||
class="admin-layout-seg"
|
||
:options="menuLayoutOptions"
|
||
/>
|
||
<a-button size="small" @click="openPreview">
|
||
<i class="fa-solid fa-eye mr-1 text-[#a88c6b]"></i>预览请柬
|
||
</a-button>
|
||
<a-dropdown>
|
||
<a-button size="small">
|
||
<i class="fa-solid fa-scroll mr-1 text-[#a88c6b]"></i>预览海报 <i class="fa-solid fa-chevron-down ml-1 text-[10px]"></i>
|
||
</a-button>
|
||
<template #overlay>
|
||
<a-menu>
|
||
<a-menu-item-group v-for="s in POSTER_SIDES" :key="'g-' + s.key" :title="`${s.label}(${s.key})`">
|
||
<a-menu-item
|
||
v-for="l in POSTER_LAYOUTS"
|
||
:key="`${s.key}-${l.value}`"
|
||
@click="openPosterPreview(s.key, l.value)"
|
||
>
|
||
{{ l.label }} {{ s.route }}/{{ l.value }}
|
||
</a-menu-item>
|
||
</a-menu-item-group>
|
||
</a-menu>
|
||
</template>
|
||
</a-dropdown>
|
||
<a-button
|
||
v-if="isConfigTab"
|
||
type="primary"
|
||
size="small"
|
||
:loading="saving"
|
||
@click="onSave"
|
||
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
|
||
>
|
||
<i class="fa-solid fa-floppy-disk mr-1"></i>保存配置
|
||
</a-button>
|
||
<a-dropdown>
|
||
<a-button size="small">{{ adminStore.user?.account || '管理员' }}<i class="fa-solid fa-chevron-down ml-1 text-[10px]"></i></a-button>
|
||
<template #overlay>
|
||
<a-menu>
|
||
<a-menu-item key="logout" @click="onLogout"><i class="fa-solid fa-right-from-bracket mr-2"></i>退出登录</a-menu-item>
|
||
</a-menu>
|
||
</template>
|
||
</a-dropdown>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="admin-shell" :class="menuLayout === 'side' ? 'admin-shell--side' : 'admin-shell--top'">
|
||
<nav v-if="menuLayout === 'side'" class="admin-nav" aria-label="后台菜单">
|
||
<div
|
||
v-for="group in adminMenuGroups"
|
||
:key="group.key"
|
||
class="admin-nav-group"
|
||
>
|
||
<div class="admin-nav-group-title">{{ group.label }}</div>
|
||
<div class="admin-nav-items">
|
||
<button
|
||
v-for="item in group.items"
|
||
:key="item.key"
|
||
type="button"
|
||
class="admin-nav-item"
|
||
:class="{ active: activeTab === item.key }"
|
||
@click="activeTab = item.key"
|
||
>
|
||
<i :class="item.icon" class="admin-nav-icon" />
|
||
<span>{{ item.label }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="admin-nav-credit">制作者 · 年糕崽崽</div>
|
||
</nav>
|
||
|
||
<main class="admin-main">
|
||
<!-- 概览 -->
|
||
<section v-if="activeTab === 'overview'">
|
||
<div class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
|
||
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">访客概览</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">指纹去重访客 · 地区 / 机型画像</div>
|
||
</div>
|
||
<a-button size="small" :loading="visitStatsLoading" @click="loadVisitStats">
|
||
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
|
||
</a-button>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-5">
|
||
<div class="stat-card">
|
||
<div class="stat-label">访客人数 UV</div>
|
||
<div class="stat-value">{{ visitStats.uv }}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">访问次数 PV</div>
|
||
<div class="stat-value">{{ visitStats.pv }}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">今日访客</div>
|
||
<div class="stat-value">{{ visitStats.today_uv }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-5">
|
||
<div class="chart-card">
|
||
<div class="flex items-baseline justify-between gap-2 mb-2">
|
||
<div class="text-[12px] text-[#7a6550] tracking-wide">地区分布</div>
|
||
<div class="text-[10px] text-[#a89a88]">点击省份查看市级</div>
|
||
</div>
|
||
<div ref="regionChartRef" class="h-[280px] w-full cursor-pointer"></div>
|
||
</div>
|
||
<div class="chart-card">
|
||
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">机型画像</div>
|
||
<div ref="deviceChartRef" class="h-[280px] w-full"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="selectedRegion" class="chart-card mb-5">
|
||
<div class="flex items-center justify-between gap-2 mb-2 flex-wrap">
|
||
<div class="text-[12px] text-[#7a6550] tracking-wide">
|
||
{{ selectedRegion }} · 市级分布
|
||
<span v-if="cityStatsLoading" class="text-[10px] text-[#a89a88] ml-2">加载中…</span>
|
||
</div>
|
||
<a-button size="small" type="text" class="!text-[#a88c6b]" @click="clearSelectedRegion">
|
||
<i class="fa-solid fa-xmark mr-1"></i>关闭
|
||
</a-button>
|
||
</div>
|
||
<div ref="cityChartRef" class="h-[280px] w-full"></div>
|
||
</div>
|
||
|
||
<div class="flex justify-between items-center mb-2 gap-2 flex-wrap">
|
||
<div class="text-[12px] text-[#7a6550] tracking-wide">近期访客预览</div>
|
||
<a-button size="small" type="link" class="!text-[#a88c6b] !px-0" @click="activeTab = 'visits'">
|
||
查看全部访客记录 <i class="fa-solid fa-arrow-right ml-1 text-[10px]"></i>
|
||
</a-button>
|
||
</div>
|
||
<div class="visit-preview-wrap">
|
||
<a-table
|
||
class="admin-table"
|
||
:columns="visitPreviewColumns"
|
||
:data-source="visitPreviewList"
|
||
:loading="visitListLoading"
|
||
row-key="id"
|
||
size="small"
|
||
:pagination="false"
|
||
:scroll="{ y: 260 }"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'fingerprint'">
|
||
<span class="font-mono text-[11px]" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'location'">
|
||
<span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'device'">
|
||
<span class="text-[12px]">{{ record.device || '未知' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'last_seen_at'">
|
||
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.last_seen_at) }}</span>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 礼物 -->
|
||
<section v-if="activeTab === 'gifts'">
|
||
<div class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
|
||
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">礼物统计</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">各礼物收到次数 · 合计 {{ giftStats.total }}</div>
|
||
</div>
|
||
<a-button size="small" :loading="giftStatsLoading || giftListLoading" @click="() => Promise.all([loadGiftStats(), loadGiftList()])">
|
||
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
|
||
</a-button>
|
||
</div>
|
||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||
<div v-for="g in giftTypeList" :key="g.key" class="stat-card">
|
||
<div class="stat-label calligraphy">{{ g.label }}</div>
|
||
<div class="stat-value">{{ giftStats.counts[g.key] || 0 }}</div>
|
||
<div class="text-[10px] text-[#8A8680] mt-1">兑换 {{ giftCostsEdit[g.key] || g.cost }} 赞</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-between items-center mb-3 gap-3 flex-wrap">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">礼物兑换配置</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">每个礼物消耗的点赞次数 · 默认阶梯 50 / 100 / 200</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<a-button size="small" @click="resetGiftCostsEdit">恢复默认</a-button>
|
||
<a-button size="small" type="primary" class="!bg-[#a88c6b]" :loading="giftCostsSaving" @click="saveGiftCostsEdit">
|
||
保存配置
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<div v-for="g in giftTypeList" :key="'cost-' + g.key" class="stat-card !py-3">
|
||
<div class="stat-label calligraphy mb-2">{{ g.label }}</div>
|
||
<a-input-number
|
||
v-model:value="giftCostsEdit[g.key]"
|
||
:min="1"
|
||
:max="10000"
|
||
:step="10"
|
||
class="w-full"
|
||
addon-after="赞"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex justify-between items-center mt-8 mb-3 gap-3 flex-wrap">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">礼物明细</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ giftList.length }} 条送礼记录</div>
|
||
</div>
|
||
<a-button size="small" :loading="giftListLoading" @click="loadGiftList">
|
||
<i class="fa-solid fa-rotate-right mr-1"></i>刷新列表
|
||
</a-button>
|
||
</div>
|
||
<a-table
|
||
class="admin-table"
|
||
:columns="giftListColumns"
|
||
:data-source="giftList"
|
||
:loading="giftListLoading"
|
||
row-key="id"
|
||
size="middle"
|
||
:pagination="{ pageSize: 10, hideOnSinglePage: true }"
|
||
:scroll="{ x: 720 }"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'gift_type'">
|
||
<span class="admin-pill admin-pill--rose">{{ giftTypeLabel(record.gift_type) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'cost'">
|
||
<span>{{ record.cost || 0 }} 赞</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'created_at'">
|
||
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.created_at) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'actions'">
|
||
<a-button size="small" danger type="text" @click="onDeleteGift(record)">删除</a-button>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 访客记录 -->
|
||
<section v-if="activeTab === 'visits'">
|
||
<div class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
|
||
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">访客记录</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ visitTotal }} 条,分页浏览</div>
|
||
</div>
|
||
<a-button size="small" :loading="visitListLoading" @click="loadVisitList">
|
||
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
|
||
</a-button>
|
||
</div>
|
||
<div class="visit-list-wrap">
|
||
<a-table
|
||
class="admin-table"
|
||
:columns="visitColumns"
|
||
:data-source="visitList"
|
||
:loading="visitListLoading"
|
||
row-key="id"
|
||
size="middle"
|
||
:pagination="visitPagination"
|
||
:scroll="{ y: 'calc(100dvh - 280px)', x: 960 }"
|
||
@change="onVisitTableChange"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'fingerprint'">
|
||
<span class="font-mono text-[11px]" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'location'">
|
||
<span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'device'">
|
||
<span class="text-[12px]" :title="record.user_agent">{{ record.device || '未知' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'user_agent'">
|
||
<span class="text-[11px] text-[#8A8680]" :title="record.user_agent">{{ shortUa(record.user_agent) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'last_seen_at'">
|
||
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.last_seen_at) }}</span>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 基础信息 -->
|
||
<section v-if="activeTab === 'basic'">
|
||
<a-card :bordered="false" class="!shadow-sm" :class="{ 'opacity-60': tabLoading }">
|
||
<a-form layout="vertical">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||
<a-form-item label="新郎">
|
||
<a-input v-model:value="cfg.groom" placeholder="新郎姓名" />
|
||
</a-form-item>
|
||
<a-form-item label="新娘">
|
||
<a-input v-model:value="cfg.bride" placeholder="新娘姓名" />
|
||
</a-form-item>
|
||
<a-form-item label="公历日期">
|
||
<a-input v-model:value="cfg.date" placeholder="如 2026-05-20" />
|
||
</a-form-item>
|
||
<a-form-item label="农历 / 星期">
|
||
<a-input v-model:value="cfg.lunar" placeholder="如 农历四月初四 星期三" />
|
||
</a-form-item>
|
||
<a-form-item label="日历圈选日期">
|
||
<a-input v-model:value="cfg.calendarDate" placeholder="如 2026-09-04" />
|
||
<div class="text-[11px] text-[#8A8680] mt-1">前台婚礼日历会展示该日期所在月份,并用爱心圈出这一天。</div>
|
||
</a-form-item>
|
||
<a-form-item label="酒店名称">
|
||
<a-input v-model:value="cfg.hotel" placeholder="宴会酒店" />
|
||
</a-form-item>
|
||
<a-form-item label="详细地址">
|
||
<a-textarea v-model:value="cfg.address" :rows="2" placeholder="可换行" />
|
||
</a-form-item>
|
||
</div>
|
||
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||
<div class="text-[11px] text-[#8A8680]">
|
||
原图用于预览;展示图为等比缩放后的体积(不裁切),前台封面/揭幕/尾页优先用展示图。
|
||
<span v-if="resizeProgress && resizingBasic" class="text-[#a88c6b] ml-1">{{ resizeProgress }}</span>
|
||
</div>
|
||
<a-button
|
||
size="small"
|
||
type="primary"
|
||
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
|
||
:loading="resizingBasic"
|
||
:disabled="uploading || (!cfg.heroImg && !cfg.endImg)"
|
||
@click="batchResizeBasicImages"
|
||
>
|
||
<i class="fa-solid fa-compress mr-1"></i>一键缩放并上传
|
||
</a-button>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||
<a-form-item label="封面图片">
|
||
<div class="relative w-[96px]">
|
||
<image-field
|
||
v-model="cfg.heroImg"
|
||
label="封面"
|
||
@pick="openUpload(u => setBasicOriginal('heroImg', u))"
|
||
:uploading="uploading"
|
||
/>
|
||
<div v-if="cfg.heroImgResized" class="text-center mt-1">
|
||
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||
<div v-if="cfg.heroImgResizedMeta" class="resized-meta">{{ cfg.heroImgResizedMeta }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-2">用于首页封面和入场揭幕背景。</div>
|
||
</a-form-item>
|
||
<a-form-item label="尾页图片">
|
||
<div class="relative w-[96px]">
|
||
<image-field
|
||
v-model="cfg.endImg"
|
||
label="尾页"
|
||
@pick="openUpload(u => setBasicOriginal('endImg', u))"
|
||
:uploading="uploading"
|
||
/>
|
||
<div v-if="cfg.endImgResized" class="text-center mt-1">
|
||
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||
<div v-if="cfg.endImgResizedMeta" class="resized-meta">{{ cfg.endImgResizedMeta }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-2">用于最后的回执尾页,可与封面分开设置。</div>
|
||
</a-form-item>
|
||
</div>
|
||
</a-form>
|
||
</a-card>
|
||
</section>
|
||
|
||
<!-- 文案 -->
|
||
<section v-if="activeTab === 'copy'">
|
||
<a-card :bordered="false" class="!shadow-sm">
|
||
<a-form layout="vertical">
|
||
<a-form-item label="竖排文案 · 上联">
|
||
<a-input v-model:value="cfg.formalText1" placeholder="如 两姓联姻 一堂缔约" />
|
||
</a-form-item>
|
||
<a-form-item label="竖排文案 · 下联">
|
||
<a-input v-model:value="cfg.formalText2" placeholder="如 良缘永结 匹配同称" />
|
||
</a-form-item>
|
||
<a-form-item label="邀请文案页高度">
|
||
<div class="flex gap-2 items-center">
|
||
<a-select :value="heightFieldSelectValue(cfg.formalPageHeight)" @change="(v) => onHeightFieldModeChange(cfg, 'formalPageHeight', v)" class="flex-1">
|
||
<a-select-option v-for="opt in HEIGHT_PRESETS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||
</a-select>
|
||
<a-input-number v-if="heightFieldSelectValue(cfg.formalPageHeight) === '__custom__'"
|
||
:value="customVhValue(cfg.formalPageHeight)" @change="(v) => onHeightFieldCustomVh(cfg, 'formalPageHeight', v)"
|
||
:min="20" :max="200" addon-after="vh" class="w-[150px]" />
|
||
</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-1">控制前台竖排邀请文案页的展示高度,默认满屏。</div>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-card>
|
||
</section>
|
||
|
||
<!-- 视觉 / 动效 -->
|
||
<section v-if="activeTab === 'visual'">
|
||
<a-card :bordered="false" class="!shadow-sm">
|
||
<a-form layout="vertical">
|
||
<a-form-item label="背景音乐">
|
||
<div class="text-[12px] text-[#8A8680] leading-relaxed">多首背景音乐的上传、试听与「设为播放」请在 <span class="text-[#a88c6b]">背景音乐</span> 标签页管理。</div>
|
||
</a-form-item>
|
||
<a-form-item label="入场揭幕退场方式">
|
||
<a-select v-model:value="cfg.introExitEffect">
|
||
<a-select-option v-for="opt in INTRO_EXIT_EFFECTS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||
</a-select>
|
||
<div class="text-[11px] text-[#8A8680] mt-1">点击开启请柬时的退场动画,可按婚礼调性选择更轻盈或更有仪式感的方式。</div>
|
||
</a-form-item>
|
||
<a-form-item label="滚动方式">
|
||
<a-radio-group v-model:value="cfg.scrollMode">
|
||
<a-radio value="snap">整页 / 分屏吸附滚动</a-radio>
|
||
<a-radio value="free">自由滚动</a-radio>
|
||
</a-radio-group>
|
||
<div class="text-[11px] text-[#8A8680] mt-1">整页模式会带来更有仪式感的逐屏切换效果。</div>
|
||
</a-form-item>
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-6">
|
||
<a-form-item label="自由滚动速度(滚动间隔)">
|
||
<div class="flex gap-3 items-center">
|
||
<a-slider v-model:value="cfg.freeScrollInterval" :min="10" :max="500" class="flex-1" />
|
||
<a-input-number v-model:value="cfg.freeScrollInterval" :min="10" :max="500" addon-after="ms" class="w-[120px]" />
|
||
</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-1">间隔越小滚动越快,默认 50ms。仅在「自由滚动」模式下生效。</div>
|
||
</a-form-item>
|
||
<a-form-item label="页面切换速度(动画时长)">
|
||
<div class="flex gap-3 items-center">
|
||
<a-slider v-model:value="cfg.pageSwitchDuration" :min="200" :max="3000" :step="100" class="flex-1" />
|
||
<a-input-number v-model:value="cfg.pageSwitchDuration" :min="200" :max="3000" :step="100" addon-after="ms" class="w-[120px]" />
|
||
</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-1">整页/分屏模式下自动翻页的动画时长,默认 800ms。</div>
|
||
</a-form-item>
|
||
<a-form-item label="首屏停留时间">
|
||
<div class="flex gap-3 items-center">
|
||
<a-slider v-model:value="cfg.firstScreenDuration" :min="0" :max="12000" :step="100" class="flex-1" />
|
||
<a-input-number v-model:value="cfg.firstScreenDuration" :min="0" :max="12000" :step="100" addon-after="ms" class="w-[120px]" />
|
||
</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-1">首屏效果结束后等待多久开始自动滚动,默认 4500ms。</div>
|
||
</a-form-item>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<a-form-item label="飘落花瓣">
|
||
<a-switch v-model:checked="cfg.petalEnabled" />
|
||
</a-form-item>
|
||
<a-form-item label="漂浮爱心">
|
||
<a-switch v-model:checked="cfg.bubbleEnabled" />
|
||
</a-form-item>
|
||
<a-form-item label="入场揭幕">
|
||
<a-switch v-model:checked="cfg.introEnabled" />
|
||
</a-form-item>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||
<a-form-item label="祝福弹幕">
|
||
<a-switch v-model:checked="cfg.danmakuEnabled" />
|
||
<div class="text-[11px] text-[#8A8680] mt-1">关闭后前台不展示弹幕,也不显示送祝福入口。</div>
|
||
</a-form-item>
|
||
<a-form-item label="弹幕显示时间">
|
||
<a-switch v-model:checked="cfg.danmakuShowTime" :disabled="!cfg.danmakuEnabled" />
|
||
<div class="text-[11px] text-[#8A8680] mt-1">开启后弹幕后缀展示相对时间(刚刚、x分钟前等)。</div>
|
||
</a-form-item>
|
||
<a-form-item label="九宫格入场动画">
|
||
<a-switch v-model:checked="cfg.nineGridAnimate" />
|
||
<div class="text-[11px] text-[#8A8680] mt-1">开启:进入视口后暂停滚动,每 300ms 入场一格(方向动效);全部完成后再继续。关闭:直接展示,更稳(推荐 iOS)。</div>
|
||
</a-form-item>
|
||
</div>
|
||
</a-form>
|
||
</a-card>
|
||
</section>
|
||
|
||
<!-- 背景音乐 -->
|
||
<section v-if="activeTab === 'music'">
|
||
<div class="admin-panel">
|
||
<div class="flex flex-wrap items-start justify-between gap-3 mb-5">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c] tracking-wide">曲目列表</div>
|
||
<p class="text-[11px] text-[#8A8680] mt-1 leading-relaxed max-w-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-xr) -->
|
||
<section v-if="activeTab === 'poster'">
|
||
<a-card :bordered="false" class="!shadow-sm">
|
||
<a-alert
|
||
type="info"
|
||
show-icon
|
||
class="mb-4"
|
||
message="每一方同时维护竖版(/1)与横版(/2)。例:男方竖版 /hb-n/1,横版 /hb-n/2;女方 /hb-nv/1|/2;新人模板 /hb-xr/1|/2。"
|
||
/>
|
||
<a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-2">
|
||
<a-tab-pane v-for="s in POSTER_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" />
|
||
</a-tabs>
|
||
<a-tabs v-model:activeKey="posterLayoutTab" size="small" class="mb-3">
|
||
<a-tab-pane
|
||
v-for="l in POSTER_LAYOUTS"
|
||
:key="l.value"
|
||
:tab="`${l.label} (/${l.value})`"
|
||
/>
|
||
</a-tabs>
|
||
<a-form layout="vertical" :class="{ 'opacity-60 pointer-events-none': posterLoading }">
|
||
<a-form-item label="所属方 / 风格">
|
||
<a-tag color="gold">{{ currentPosterSide.key }} · {{ currentPosterSide.label }}</a-tag>
|
||
<a-tag color="orange">{{ currentPosterLayout.label }}</a-tag>
|
||
<span class="text-[11px] text-[#8A8680] ml-2">路由 {{ currentPosterPreviewPath }}</span>
|
||
</a-form-item>
|
||
<a-form-item label="版式模板">
|
||
<a-radio-group v-model:value="posterCfg.template" class="poster-tpl-group">
|
||
<a-radio-button v-for="t in POSTER_TEMPLATES" :key="t.value" :value="t.value">
|
||
{{ t.label }}
|
||
</a-radio-button>
|
||
</a-radio-group>
|
||
<div class="text-[11px] text-[#8A8680] mt-1">
|
||
{{ POSTER_TEMPLATES.find(t => t.value === posterCfg.template)?.desc }}
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item label="手机端布局">
|
||
<a-radio-group v-model:value="posterCfg.mobileLayout">
|
||
<a-radio-button v-for="t in MOBILE_LAYOUTS" :key="t.value" :value="t.value">
|
||
{{ t.label }}
|
||
</a-radio-button>
|
||
</a-radio-group>
|
||
<div class="text-[11px] text-[#8A8680] mt-1">
|
||
{{ MOBILE_LAYOUTS.find(t => t.value === posterCfg.mobileLayout)?.desc }}(仅窄屏生效,PC 不受影响)
|
||
</div>
|
||
</a-form-item>
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<a-form-item label="启用海报页">
|
||
<a-switch v-model:checked="posterCfg.enabled" />
|
||
</a-form-item>
|
||
<a-form-item label="显示跳转请柬按钮">
|
||
<a-switch v-model:checked="posterCfg.showInviteButton" />
|
||
</a-form-item>
|
||
<a-form-item label="按钮文案">
|
||
<a-input v-model:value="posterCfg.inviteButtonText" :disabled="!posterCfg.showInviteButton" />
|
||
</a-form-item>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||
<a-form-item label="进页 Loading">
|
||
<a-select v-model:value="posterCfg.loadingEffect">
|
||
<a-select-option v-for="opt in LOADING_EFFECTS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="打开页面过场">
|
||
<a-select v-model:value="posterCfg.enterEffect">
|
||
<a-select-option v-for="opt in ENTER_EFFECTS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="跳转路径">
|
||
<a-input v-model:value="posterCfg.invitePath" placeholder="/" />
|
||
</a-form-item>
|
||
</div>
|
||
<a-form-item label="背景音乐">
|
||
<a-select
|
||
v-model:value="posterCfg.musicUrl"
|
||
allow-clear
|
||
show-search
|
||
option-filter-prop="label"
|
||
placeholder="不播放(从已有曲目选择)"
|
||
:options="posterMusicSelectOptions"
|
||
:loading="posterMusicLoading"
|
||
/>
|
||
<div class="text-[11px] text-[#8A8680] mt-1">
|
||
曲目来自「背景音乐」页;清空则该海报不播放。可到
|
||
<a class="text-[#a88c6b] cursor-pointer" @click="activeTab = 'music'">背景音乐</a>
|
||
上传管理。
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item label="页面标题(浏览器标签 / 分享)">
|
||
<a-input
|
||
v-model:value="posterCfg.pageTitle"
|
||
:placeholder="currentPosterSide.label + '海报页面标题'"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="页面描述 description">
|
||
<a-textarea
|
||
v-model:value="posterCfg.pageDescription"
|
||
:rows="2"
|
||
placeholder="用于 meta description / 分享摘要"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item :label="posterCfg.template === 'split' ? '左侧照片' : '顶部照片'">
|
||
<image-field
|
||
v-model="posterCfg.heroImg"
|
||
label="海报图片"
|
||
@pick="openUpload(setPosterHero)"
|
||
:uploading="uploading"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="照片展示焦点">
|
||
<a-radio-group v-model:value="heroFocusDevice" size="small" class="mb-2" button-style="solid">
|
||
<a-radio-button value="pc">PC 端</a-radio-button>
|
||
<a-radio-button value="mobile">手机端</a-radio-button>
|
||
</a-radio-group>
|
||
<div
|
||
v-if="posterCfg.heroImg"
|
||
ref="heroFocusPreviewEl"
|
||
class="hero-focus-preview hero-focus-preview--drag"
|
||
:class="{ dragging: heroFocusDragging, 'is-mobile': heroFocusDevice === 'mobile' }"
|
||
@pointerdown="onHeroFocusPointerDown"
|
||
>
|
||
<img
|
||
:src="posterCfg.heroImg"
|
||
alt=""
|
||
draggable="false"
|
||
:style="{ objectPosition: heroFocusPreviewCss }"
|
||
/>
|
||
<span
|
||
class="hero-focus-crosshair"
|
||
:style="{ left: heroFocusActiveX + '%', top: heroFocusActiveY + '%' }"
|
||
/>
|
||
</div>
|
||
<div v-else class="hero-focus-preview hero-focus-preview--empty">
|
||
请先上传海报图片,再拖拽设置焦点
|
||
</div>
|
||
<div class="hero-focus-pad mt-2" :style="{ '--hero-focus-n': HERO_FOCUS_GRID }">
|
||
<button
|
||
v-for="pt in heroFocusGrid"
|
||
:key="pt.key"
|
||
type="button"
|
||
class="hero-focus-cell"
|
||
:class="{ active: isHeroFocusGridActive(pt) }"
|
||
:title="`${Math.round(pt.x)}%, ${Math.round(pt.y)}%`"
|
||
@click="setHeroFocus(pt.x, pt.y)"
|
||
>
|
||
<span class="hero-focus-dot" />
|
||
</button>
|
||
</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-1">
|
||
{{ heroFocusDevice === 'mobile' ? '手机端' : 'PC 端' }}焦点:拖拽十字准星或点网格;当前
|
||
{{ Math.round(heroFocusActiveX) }}% · {{ Math.round(heroFocusActiveY) }}%
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item label="二维码中心图">
|
||
<image-field
|
||
v-model="posterCfg.qrCenterImg"
|
||
label="中心图(可选)"
|
||
@pick="openUpload(setPosterQrCenter)"
|
||
:uploading="uploading"
|
||
/>
|
||
<div class="text-xs text-stone-500 mt-1">不配置时,二维码中心为圆圈大「囍」字</div>
|
||
</a-form-item>
|
||
|
||
<!-- classic 字段 -->
|
||
<template v-if="posterCfg.template === 'classic'">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||
<a-form-item :label="posterNameLabels.left">
|
||
<div class="flex gap-2">
|
||
<a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterNameLabels.leftPh" />
|
||
<a-input v-model:value="posterCfg.sonName" placeholder="姓名" />
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item :label="posterNameLabels.right">
|
||
<div class="flex gap-2">
|
||
<a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterNameLabels.rightPh" />
|
||
<a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" />
|
||
</div>
|
||
</a-form-item>
|
||
</div>
|
||
<a-form-item label="主标题">
|
||
<a-input v-model:value="posterCfg.title" placeholder="婚礼邀请函" />
|
||
</a-form-item>
|
||
<a-form-item label="正文(每行一条)">
|
||
<a-textarea v-model:value="posterLinesText" :rows="10" placeholder="每行一条正文" />
|
||
</a-form-item>
|
||
<a-form-item label="底部文案">
|
||
<a-input v-model:value="posterCfg.footer" />
|
||
</a-form-item>
|
||
</template>
|
||
|
||
<!-- split 字段 -->
|
||
<template v-else>
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-6">
|
||
<a-form-item label="竖排主标">
|
||
<a-input v-model:value="posterCfg.verticalSlogan" placeholder="吾家有喜" />
|
||
</a-form-item>
|
||
<a-form-item label="副标一">
|
||
<a-input v-model:value="posterCfg.subSlogan1" placeholder="佳偶天成" />
|
||
</a-form-item>
|
||
<a-form-item label="副标二">
|
||
<a-input v-model:value="posterCfg.subSlogan2" placeholder="百年好合" />
|
||
</a-form-item>
|
||
</div>
|
||
<a-form-item label="问候语">
|
||
<a-input v-model:value="posterCfg.greeting" placeholder="尊敬的各位亲朋好友" />
|
||
</a-form-item>
|
||
<a-form-item label="正文(每行一条,建议 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="posterNameLabels.left">
|
||
<div class="flex gap-2">
|
||
<a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterNameLabels.leftPh" />
|
||
<a-input v-model:value="posterCfg.sonName" placeholder="姓名" @update:value="v => posterCfg.groomName = v" />
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item :label="posterNameLabels.right">
|
||
<div class="flex gap-2">
|
||
<a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterNameLabels.rightPh" />
|
||
<a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" @update:value="v => posterCfg.brideName = v" />
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item label="喜宴时间">
|
||
<a-input v-model:value="posterCfg.dateText" />
|
||
</a-form-item>
|
||
<a-form-item label="农历">
|
||
<a-input v-model:value="posterCfg.lunarText" />
|
||
</a-form-item>
|
||
</div>
|
||
<a-form-item label="地点">
|
||
<a-input v-model:value="posterCfg.venueText" />
|
||
</a-form-item>
|
||
<a-form-item label="底部 Love 文案">
|
||
<a-input v-model:value="posterCfg.loveFooter" placeholder="Love you" />
|
||
</a-form-item>
|
||
</template>
|
||
</a-form>
|
||
</a-card>
|
||
<div class="poster-action-bar-spacer" aria-hidden="true"></div>
|
||
</section>
|
||
|
||
<div v-if="activeTab === 'poster'" class="poster-action-bar">
|
||
<div class="poster-action-bar-inner">
|
||
<div class="poster-action-bar-meta">
|
||
<span>{{ currentPosterSide.label }} · {{ currentPosterLayout.label }}</span>
|
||
<span class="poster-action-bar-path">{{ currentPosterPreviewPath }}</span>
|
||
</div>
|
||
<div class="poster-action-bar-btns">
|
||
<a-button
|
||
v-if="posterUndoSnapshot"
|
||
size="small"
|
||
@click="undoPosterImport"
|
||
>
|
||
<i class="fa-solid fa-rotate-left mr-1"></i>撤回导入
|
||
</a-button>
|
||
<a-button size="small" :loading="posterCopying" @click="copyPosterTemplate">
|
||
<i class="fa-solid fa-copy mr-1"></i>复制
|
||
</a-button>
|
||
<a-button size="small" :loading="posterImporting" @click="importPosterTemplateDirect">
|
||
<i class="fa-solid fa-file-import mr-1"></i>导入
|
||
</a-button>
|
||
<a-button size="small" @click="openPosterPreview(posterSideTab, posterLayoutTab)">
|
||
<i class="fa-solid fa-eye mr-1"></i>预览
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
:loading="posterSaving"
|
||
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
|
||
@click="onSavePosterConfig"
|
||
>
|
||
<i class="fa-solid fa-floppy-disk mr-1"></i>保存
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<section v-if="activeTab === 'photos'">
|
||
<div class="admin-panel !p-4 mb-3">
|
||
<div class="flex justify-between items-center gap-3 flex-wrap">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">相册页</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ cfg.photoPages.length }} 页</div>
|
||
</div>
|
||
<a-button size="small" type="primary"
|
||
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
|
||
@click="addPage">
|
||
<i class="fa-solid fa-plus mr-1"></i>新增相册页
|
||
</a-button>
|
||
</div>
|
||
<a-tabs
|
||
v-if="cfg.photoPages.length"
|
||
v-model:activeKey="activePhotoTab"
|
||
size="small"
|
||
type="card"
|
||
class="photo-page-tabs mt-3"
|
||
:destroyInactiveTabPane="true"
|
||
>
|
||
<a-tab-pane v-for="(page, idx) in cfg.photoPages" :key="String(idx)" :tab="`第 ${idx + 1} 页`" />
|
||
</a-tabs>
|
||
</div>
|
||
<div v-if="!cfg.photoPages.length" class="admin-empty">
|
||
<div class="admin-empty-icon"><i class="fa-regular fa-images"></i></div>
|
||
<div class="text-[13px] text-[#5c5348]">暂无相册页</div>
|
||
</div>
|
||
<template v-for="(page, idx) in cfg.photoPages" :key="idx">
|
||
<a-card v-if="idx === currentPhotoIndex" :bordered="false" class="!shadow-sm mb-4 !rounded-2xl !border !border-[#a88c6b]/10">
|
||
<div class="flex justify-between items-center mb-3">
|
||
<span class="inline-flex items-center gap-2 text-[#a88c6b] text-sm">
|
||
<span class="w-7 h-7 rounded-lg bg-[#a88c6b]/10 text-[11px] inline-flex items-center justify-center tracking-wider">
|
||
{{ String(idx + 1).padStart(2, '0') }}
|
||
</span>
|
||
第 {{ idx + 1 }} 页
|
||
</span>
|
||
<div class="flex gap-1">
|
||
<a-button size="small" :disabled="idx===0" @click="movePage(idx,-1)"><i class="fa-solid fa-arrow-up"></i></a-button>
|
||
<a-button size="small" :disabled="idx===cfg.photoPages.length-1" @click="movePage(idx,1)"><i class="fa-solid fa-arrow-down"></i></a-button>
|
||
<a-button size="small" danger type="text" @click="removePage(idx)"><i class="fa-solid fa-trash"></i></a-button>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||
<a-form-item label="版式">
|
||
<a-select v-model:value="page.type" @change="() => ensurePageShape(page)">
|
||
<a-select-option value="single">单图</a-select-option>
|
||
<a-select-option value="overlap">错落双图</a-select-option>
|
||
<a-select-option value="one-large-five-small">一大五小</a-select-option>
|
||
<a-select-option value="nine-grid">九宫格</a-select-option>
|
||
<a-select-option value="masonry">瀑布流</a-select-option>
|
||
<a-select-option value="carousel">轮播滑动</a-select-option>
|
||
<a-select-option value="polaroid">拍立得拼贴</a-select-option>
|
||
<a-select-option value="heart-collage">爱心拼接</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="页面高度">
|
||
<div class="flex gap-2 items-center">
|
||
<a-select :value="heightSelectValue(page)" @change="(v) => onHeightModeChange(page, v)" class="flex-1">
|
||
<a-select-option v-for="opt in HEIGHT_PRESETS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||
</a-select>
|
||
<a-input-number v-if="heightSelectValue(page) === '__custom__'"
|
||
:value="customVh(page)" @change="(v) => onCustomVh(page, v)"
|
||
:min="20" :max="200" addon-after="vh" class="w-[150px]" />
|
||
</div>
|
||
</a-form-item>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 items-start">
|
||
<a-form-item label="边框样式">
|
||
<a-select v-model:value="page.borderStyle">
|
||
<a-select-option v-for="b in BORDER_STYLES" :key="b.value" :value="b.value">{{ b.label }}</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-[11px] text-[#a88c6b]">预览</span>
|
||
<div class="w-[70px] h-[88px] shrink-0">
|
||
<div :class="borderClass(page.borderStyle)" style="width:100%;height:100%">
|
||
<img src="https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=200&q=80" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 单图版式的宽度设置(逐页配置) -->
|
||
<div v-if="page.type==='single'" class="grid grid-cols-1 md:grid-cols-2 gap-x-6 items-start">
|
||
<a-form-item label="图片宽度">
|
||
<a-radio-group v-model:value="page.singleWidth">
|
||
<a-radio value="default">默认(居中卡幅)</a-radio>
|
||
<a-radio value="full">宽度铺满</a-radio>
|
||
</a-radio-group>
|
||
</a-form-item>
|
||
<a-form-item v-if="page.singleWidth==='full'" label="保持原始宽高比">
|
||
<a-switch v-model:checked="page.singleKeepRatio" />
|
||
<div class="text-[11px] text-[#8A8680] mt-1">开启:按图片原始比例显示;关闭:固定 4:5 比例裁切铺满。</div>
|
||
</a-form-item>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||
<a-form-item label="小标题 (英文)">
|
||
<a-input v-model:value="page.subtitle" />
|
||
</a-form-item>
|
||
<a-form-item label="标题">
|
||
<a-input v-model:value="page.title" />
|
||
</a-form-item>
|
||
</div>
|
||
<a-form-item label="描述">
|
||
<a-textarea v-model:value="page.desc" :rows="2" />
|
||
</a-form-item>
|
||
<!-- 图片上传区:按版式渲染 -->
|
||
<div class="flex flex-wrap items-center justify-between gap-2 mt-2 mb-2">
|
||
<div class="text-[11px] text-[#8A8680]">
|
||
原图用于预览;展示图为等比缩放后的体积(不裁切)。
|
||
<span v-if="resizeProgress" class="text-[#a88c6b] ml-1">{{ resizeProgress }}</span>
|
||
</div>
|
||
<a-button
|
||
size="small"
|
||
type="primary"
|
||
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
|
||
:loading="resizingPage"
|
||
:disabled="uploading || !pageHasOriginal(page)"
|
||
@click="batchResizeAndUpload(page)"
|
||
>
|
||
<i class="fa-solid fa-compress mr-1"></i>一键缩放并上传
|
||
</a-button>
|
||
</div>
|
||
<div class="flex flex-wrap gap-3 mt-2">
|
||
<template v-if="page.type==='single'">
|
||
<div class="relative w-[96px]">
|
||
<image-field v-model="page.img1" @pick="openUpload(u => setOriginalAndClearResized(page, 'img1', u))" :uploading="uploading" />
|
||
<div v-if="page.img1Resized" class="text-center mt-1">
|
||
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||
<div v-if="page.img1ResizedMeta" class="resized-meta">{{ page.img1ResizedMeta }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="page.type==='overlap'">
|
||
<div class="relative w-[96px]">
|
||
<image-field v-model="page.img1" label="图一" @pick="openUpload(u => setOriginalAndClearResized(page, 'img1', u))" :uploading="uploading" />
|
||
<div v-if="page.img1Resized" class="text-center mt-1">
|
||
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||
<div v-if="page.img1ResizedMeta" class="resized-meta">{{ page.img1ResizedMeta }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="relative w-[96px]">
|
||
<image-field v-model="page.img2" label="图二" @pick="openUpload(u => setOriginalAndClearResized(page, 'img2', u))" :uploading="uploading" />
|
||
<div v-if="page.img2Resized" class="text-center mt-1">
|
||
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||
<div v-if="page.img2ResizedMeta" class="resized-meta">{{ page.img2ResizedMeta }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div v-if="page.type === 'heart-collage'" class="w-full text-[11px] text-[#8A8680] mb-1">
|
||
共 13 张:按心形槽位排列(上叶 → 中段对称 → 下横 → 心尖)。建议竖图放中轴,横图放上下叶。
|
||
</div>
|
||
<div v-for="(img, iidx) in page.images" :key="iidx" class="relative w-[96px]">
|
||
<image-field v-model="page.images[iidx]"
|
||
:label="heartSlotLabel(page.type, iidx)"
|
||
@pick="openUpload(u => setOriginalImageAt(page, iidx, u))"
|
||
:uploading="uploading" />
|
||
<div v-if="page.imagesResized?.[iidx]" class="text-center mt-1">
|
||
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||
<div v-if="page.imagesResizedMeta?.[iidx]" class="resized-meta">{{ page.imagesResizedMeta[iidx] }}</div>
|
||
</div>
|
||
<button v-if="isFlexible(page.type) && page.images.length > 1"
|
||
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-[#a88c6b] text-white text-[10px] leading-none flex items-center justify-center shadow"
|
||
@click="removeImage(page, iidx)" title="移除该图">✕</button>
|
||
<!-- 排序:前移 / 后移 -->
|
||
<button v-if="iidx > 0"
|
||
class="absolute -bottom-2 left-1 w-5 h-5 rounded-full bg-white border border-[#a88c6b]/50 text-[#a88c6b] text-[10px] leading-none flex items-center justify-center shadow hover:bg-[#a88c6b] hover:text-white transition-colors"
|
||
@click="moveImage(page, iidx, -1)" title="前移"><i class="fa-solid fa-arrow-left"></i></button>
|
||
<button v-if="iidx < page.images.length - 1"
|
||
class="absolute -bottom-2 right-1 w-5 h-5 rounded-full bg-white border border-[#a88c6b]/50 text-[#a88c6b] text-[10px] leading-none flex items-center justify-center shadow hover:bg-[#a88c6b] hover:text-white transition-colors"
|
||
@click="moveImage(page, iidx, 1)" title="后移"><i class="fa-solid fa-arrow-right"></i></button>
|
||
</div>
|
||
<button v-if="isFlexible(page.type)"
|
||
class="w-[96px] h-[96px] rounded-xl border border-dashed border-[#a88c6b]/40 text-[#a88c6b] text-[11px] flex items-center justify-center hover:border-[#a88c6b] transition-colors"
|
||
@click="addImage(page)">+ 添加图片</button>
|
||
</template>
|
||
</div>
|
||
</a-card>
|
||
</template>
|
||
</section>
|
||
|
||
<!-- 婚礼流程 -->
|
||
<section v-if="activeTab === 'schedule'">
|
||
<div class="admin-panel">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">流程环节</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ cfg.schedule.length }} 个环节</div>
|
||
</div>
|
||
<a-button size="small" type="primary"
|
||
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
|
||
@click="addSchedule">
|
||
<i class="fa-solid fa-plus mr-1"></i>新增环节
|
||
</a-button>
|
||
</div>
|
||
<div v-if="!cfg.schedule.length" class="admin-empty">
|
||
<div class="admin-empty-icon"><i class="fa-regular fa-clock"></i></div>
|
||
<div class="text-[13px] text-[#5c5348]">暂无流程</div>
|
||
</div>
|
||
<ol v-else class="schedule-list">
|
||
<li v-for="(item, idx) in cfg.schedule" :key="idx" class="schedule-row">
|
||
<div class="schedule-index">{{ String(idx + 1).padStart(2, '0') }}</div>
|
||
<div class="schedule-fields">
|
||
<div>
|
||
<div class="field-label">时间</div>
|
||
<a-input v-model:value="item.time" placeholder="如 17:08" />
|
||
</div>
|
||
<div>
|
||
<div class="field-label">环节</div>
|
||
<a-input v-model:value="item.event" placeholder="如 仪式开始" />
|
||
</div>
|
||
<div>
|
||
<div class="field-label">英文副标题</div>
|
||
<a-input v-model:value="item.desc" placeholder="如 Ceremony" />
|
||
</div>
|
||
</div>
|
||
<a-button danger size="small" type="text" @click="removeSchedule(idx)">
|
||
<i class="fa-solid fa-trash"></i>
|
||
</a-button>
|
||
</li>
|
||
</ol>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 回执列表 -->
|
||
<section v-if="activeTab === 'rsvp'">
|
||
<div class="admin-panel">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">出席回执</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ rsvpList.length }} 条</div>
|
||
</div>
|
||
<a-button size="small" @click="loadRsvp"><i class="fa-solid fa-rotate-right mr-1"></i>刷新</a-button>
|
||
</div>
|
||
<a-table
|
||
class="admin-table"
|
||
:columns="rsvpColumns"
|
||
:data-source="rsvpList"
|
||
:loading="rsvpLoading"
|
||
row-key="id"
|
||
size="middle"
|
||
:pagination="{ pageSize: 8, hideOnSinglePage: true }"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key==='guest_count'">
|
||
<span
|
||
class="admin-pill"
|
||
:class="record.guest_count === '0' ? 'admin-pill--muted' : 'admin-pill--rose'"
|
||
>{{ rsvpCountLabel(record.guest_count) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key==='created_at'">
|
||
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.created_at) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key==='actions'">
|
||
<a-button size="small" danger type="text" @click="onDeleteRsvp(record)">删除</a-button>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 礼金 -->
|
||
<section v-if="activeTab === 'cash'">
|
||
<div class="admin-panel">
|
||
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">礼金记录</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">
|
||
共 {{ cashGiftCount }} 笔 · 合计 ¥{{ cashGiftTotal }}
|
||
· 前台录入 <a class="text-[#a88c6b]" href="/lijin" target="_blank">/lijin</a>
|
||
· 明细 <a class="text-[#a88c6b]" href="/lijin/list" target="_blank">/lijin/list</a>
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<a-button size="small" @click="openCashGiftCreate">新增</a-button>
|
||
<a-button size="small" :loading="cashGiftLoading" @click="loadCashGifts">
|
||
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
<a-table
|
||
class="admin-table"
|
||
:columns="cashGiftColumns"
|
||
:data-source="cashGiftList"
|
||
:loading="cashGiftLoading"
|
||
row-key="id"
|
||
size="middle"
|
||
:pagination="{ pageSize: 10, hideOnSinglePage: true }"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key==='amount'">
|
||
<span class="text-[#a88c6b] font-medium">¥{{ record.amount }}</span>
|
||
</template>
|
||
<template v-else-if="column.key==='created_at'">
|
||
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.created_at) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key==='actions'">
|
||
<a-space>
|
||
<a-button size="small" type="link" class="!text-[#a88c6b] !px-1" @click="openCashGiftEdit(record)">编辑</a-button>
|
||
<a-button size="small" danger type="text" @click="onDeleteCashGift(record)">删除</a-button>
|
||
</a-space>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
|
||
<div class="admin-panel mt-4">
|
||
<div class="flex justify-between items-center mb-3 gap-3 flex-wrap">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">记账口令</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">
|
||
前台 <a class="text-[#a88c6b]" href="/lijin" target="_blank">/lijin</a> 登记礼金前需输入此口令
|
||
</div>
|
||
</div>
|
||
<a-button
|
||
size="small"
|
||
type="primary"
|
||
class="!bg-[#a88c6b] !border-[#a88c6b]"
|
||
:loading="cashEntryPassSaving"
|
||
@click="saveCashEntryPass"
|
||
>保存口令</a-button>
|
||
</div>
|
||
<a-input-password
|
||
v-model:value="cashEntryPass"
|
||
placeholder="设置记账口令"
|
||
class="!max-w-[320px]"
|
||
:maxlength="64"
|
||
/>
|
||
</div>
|
||
|
||
<div class="admin-panel mt-4">
|
||
<div class="flex justify-between items-center mb-3 gap-3 flex-wrap">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">查看全部口令</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">
|
||
前台 <a class="text-[#a88c6b]" href="/lijin/list" target="_blank">/lijin/list</a> 需输入口令后查看完整礼金列表
|
||
</div>
|
||
</div>
|
||
<a-button
|
||
size="small"
|
||
type="primary"
|
||
class="!bg-[#a88c6b] !border-[#a88c6b]"
|
||
:loading="cashViewPassSaving"
|
||
@click="saveCashViewPass"
|
||
>保存口令</a-button>
|
||
</div>
|
||
<a-input-password
|
||
v-model:value="cashViewPass"
|
||
placeholder="设置查看口令"
|
||
class="!max-w-[320px]"
|
||
:maxlength="64"
|
||
/>
|
||
</div>
|
||
|
||
<div class="admin-panel mt-4">
|
||
<div class="flex justify-between items-center mb-3 gap-3 flex-wrap">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">备注快捷选项</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">前台礼金页可一键点选;男方 / 女方分开配置</div>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<a-button size="small" @click="resetCashNotePresets">恢复默认</a-button>
|
||
<a-button
|
||
size="small"
|
||
type="primary"
|
||
class="!bg-[#a88c6b] !border-[#a88c6b]"
|
||
:loading="cashNotesSaving"
|
||
@click="saveCashNotePresets"
|
||
>保存选项</a-button>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 mb-3">
|
||
<a-tag
|
||
v-for="(n, i) in cashNotePresets"
|
||
:key="`${n}-${i}`"
|
||
closable
|
||
color="gold"
|
||
@close.prevent="removeCashNotePreset(i)"
|
||
>{{ n }}</a-tag>
|
||
</div>
|
||
<div class="flex gap-2 items-center flex-wrap">
|
||
<a-input
|
||
v-model:value="cashNoteDraft"
|
||
placeholder="新增快捷备注,如:男方亲戚"
|
||
class="!max-w-[280px]"
|
||
:maxlength="30"
|
||
@pressEnter="addCashNotePreset"
|
||
/>
|
||
<a-button size="small" @click="addCashNotePreset">添加</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<a-modal
|
||
v-model:open="cashGiftModalOpen"
|
||
:title="cashGiftEditingId ? '编辑礼金' : '新增礼金'"
|
||
ok-text="保存"
|
||
cancel-text="取消"
|
||
:confirm-loading="cashGiftSaving"
|
||
@ok="saveCashGiftModal"
|
||
>
|
||
<a-form layout="vertical" class="mt-2">
|
||
<a-form-item label="姓名" required>
|
||
<a-input v-model:value="cashGiftForm.name" placeholder="宾客姓名" :maxlength="20" />
|
||
</a-form-item>
|
||
<a-form-item label="金额(元)" required>
|
||
<a-input-number v-model:value="cashGiftForm.amount" :min="1" :max="10000000" class="w-full" />
|
||
</a-form-item>
|
||
<a-form-item label="备注">
|
||
<div class="flex flex-wrap gap-1.5 mb-2">
|
||
<a-tag
|
||
v-for="n in cashNotePresets"
|
||
:key="'m-' + n"
|
||
class="!cursor-pointer"
|
||
:color="cashGiftForm.note === n ? 'gold' : undefined"
|
||
@click="cashGiftForm.note = n"
|
||
>{{ n }}</a-tag>
|
||
</div>
|
||
<a-input v-model:value="cashGiftForm.note" placeholder="可选" :maxlength="100" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
</section>
|
||
|
||
<!-- 酒店迎宾海报 -->
|
||
<section v-if="activeTab === 'hotelWelcome'">
|
||
<HotelWelcomePosterAdmin ref="hotelWelcomeRef" />
|
||
</section>
|
||
|
||
<!-- 弹幕审核 -->
|
||
<section v-if="activeTab === 'danmaku'">
|
||
<div class="admin-panel">
|
||
<div class="flex items-center justify-between mb-4 gap-3 flex-wrap">
|
||
<div>
|
||
<div class="text-sm text-[#2c2c2c]">弹幕审核</div>
|
||
<div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ danmakuList.length }} 条</div>
|
||
</div>
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<a-radio-group v-model:value="danmakuFilter" button-style="solid" size="small" class="admin-filter">
|
||
<a-radio-button value="">全部</a-radio-button>
|
||
<a-radio-button value="pending">待审核</a-radio-button>
|
||
<a-radio-button value="approved">已通过</a-radio-button>
|
||
<a-radio-button value="rejected">已拒绝</a-radio-button>
|
||
</a-radio-group>
|
||
<a-button size="small" @click="loadDanmaku"><i class="fa-solid fa-rotate-right mr-1"></i>刷新</a-button>
|
||
</div>
|
||
</div>
|
||
<a-table
|
||
class="admin-table"
|
||
:columns="danmakuColumns"
|
||
:data-source="danmakuList"
|
||
:loading="danmakuLoading"
|
||
row-key="id"
|
||
size="middle"
|
||
:pagination="{ pageSize: 8, hideOnSinglePage: true }"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key==='content'">
|
||
<span class="inline-flex items-center gap-2 min-w-0">
|
||
<i class="danmaku-admin-dot" :style="{ background: resolveDanmakuSwatch(record.color) }" />
|
||
<span class="truncate">{{ record.content }}</span>
|
||
</span>
|
||
</template>
|
||
<template v-else-if="column.key==='source'">
|
||
<span class="admin-pill" :class="record.source === 'rsvp' ? 'admin-pill--sky' : 'admin-pill--gold'">
|
||
{{ record.source === 'rsvp' ? '回执' : '弹幕' }}
|
||
</span>
|
||
</template>
|
||
<template v-else-if="column.key==='status'">
|
||
<span class="admin-pill" :class="danmakuStatusPill(record.status)">
|
||
{{ danmakuStatusLabel(record.status) }}
|
||
</span>
|
||
</template>
|
||
<template v-else-if="column.key==='created_at'">
|
||
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.created_at) }}</span>
|
||
</template>
|
||
<template v-else-if="column.key==='actions'">
|
||
<a-space>
|
||
<a-button size="small" type="primary" class="!bg-[#a88c6b] !border-[#a88c6b]"
|
||
:disabled="record.status === 'approved'"
|
||
@click="setDanmakuStatus(record.id, 'approved')">通过</a-button>
|
||
<a-button size="small" danger type="text"
|
||
:disabled="record.status === 'rejected'"
|
||
@click="setDanmakuStatus(record.id, 'rejected')">拒绝</a-button>
|
||
</a-space>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
|
||
<MediaPickerModal
|
||
v-model:open="mediaPickerOpen"
|
||
:model-value="mediaPickerCurrent"
|
||
@select="onMediaPicked"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, reactive, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { message } from 'ant-design-vue'
|
||
import * as echarts from 'echarts/core'
|
||
import { PieChart } from 'echarts/charts'
|
||
import { TooltipComponent, LegendComponent } from 'echarts/components'
|
||
import { CanvasRenderer } from 'echarts/renderers'
|
||
import {
|
||
getConfigSection, saveConfigSection, uploadImage, getRsvpList, deleteRsvp, getUploadConfig, saveUploadConfig,
|
||
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitCityStats, getVisitList,
|
||
getPosterConfig, savePosterConfig, getGiftStats, getGiftList, deleteGift, saveGiftCosts,
|
||
getCashGiftList, updateCashGift, deleteCashGift, submitCashGift,
|
||
getCashGiftNotes, saveCashGiftNotes,
|
||
getCashGiftViewPass, saveCashGiftViewPass,
|
||
getCashGiftEntryPass, saveCashGiftEntryPass,
|
||
} from '@/api/wedding'
|
||
import { useAdminStore } from '@/stores/admin'
|
||
import ImageField from '@/components/ImageField.vue'
|
||
import MediaPickerModal from '@/components/MediaPickerModal.vue'
|
||
import HotelWelcomePosterAdmin from '@/views/admin/HotelWelcomePosterAdmin.vue'
|
||
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
|
||
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
|
||
import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
|
||
import {
|
||
getSlotResizeSpecs,
|
||
getBasicImageResizeSpecs,
|
||
resizeSlotToFile,
|
||
probeResizedMeta,
|
||
metaHasFileSize,
|
||
} from '@/composables/resizeImage'
|
||
import {
|
||
defaultPosterBundle, normalizePosterBundle, defaultPosterForLayout, normalizePosterConfig,
|
||
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_LAYOUTS, POSTER_TEMPLATES, MOBILE_LAYOUTS,
|
||
HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, layoutKey,
|
||
posterSideMeta, posterLayoutMeta, posterRoute,
|
||
} from '@/composables/posterDefaults'
|
||
|
||
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
||
|
||
const router = useRouter()
|
||
const adminStore = useAdminStore()
|
||
|
||
const TAB_KEY = 'wedding_admin_active_tab'
|
||
const MENU_LAYOUT_KEY = 'wedding_admin_menu_layout'
|
||
const CONFIG_SECTIONS = ['basic', 'copy', 'visual', 'music', 'photos', 'schedule']
|
||
const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'hotelWelcome', 'rsvp', 'cash', 'danmaku']
|
||
|
||
/** 后台菜单分组 */
|
||
const adminMenuGroups = [
|
||
{
|
||
key: 'data',
|
||
label: '数据看板',
|
||
items: [
|
||
{ key: 'overview', label: '概览', icon: 'fa-solid fa-chart-pie' },
|
||
{ key: 'visits', label: '访客记录', icon: 'fa-solid fa-users' },
|
||
{ key: 'gifts', label: '礼物', icon: 'fa-solid fa-gift' },
|
||
],
|
||
},
|
||
{
|
||
key: 'content',
|
||
label: '请柬内容',
|
||
items: [
|
||
{ key: 'basic', label: '基础信息', icon: 'fa-solid fa-heart' },
|
||
{ key: 'copy', label: '邀请文案', icon: 'fa-solid fa-pen-fancy' },
|
||
{ key: 'visual', label: '视觉动效', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||
{ key: 'music', label: '背景音乐', icon: 'fa-solid fa-music' },
|
||
{ key: 'photos', label: '相册页', icon: 'fa-solid fa-images' },
|
||
{ key: 'schedule', label: '婚礼流程', icon: 'fa-solid fa-list-ol' },
|
||
],
|
||
},
|
||
{
|
||
key: 'interact',
|
||
label: '宾客互动',
|
||
items: [
|
||
{ key: 'rsvp', label: '出席回执', icon: 'fa-solid fa-envelope-open-text' },
|
||
{ key: 'cash', label: '礼金', icon: 'fa-solid fa-yen-sign' },
|
||
{ key: 'danmaku', label: '弹幕审核', icon: 'fa-solid fa-comments' },
|
||
],
|
||
},
|
||
{
|
||
key: 'system',
|
||
label: '海报与系统',
|
||
items: [
|
||
{ key: 'poster', label: '海报', icon: 'fa-solid fa-scroll' },
|
||
{ key: 'hotelWelcome', label: '迎宾易拉宝', icon: 'fa-solid fa-hotel' },
|
||
{ key: 'upload', label: '上传设置', icon: 'fa-solid fa-cloud-arrow-up' },
|
||
],
|
||
},
|
||
]
|
||
|
||
const menuLayoutOptions = [
|
||
{ value: 'top', label: '顶部菜单' },
|
||
{ value: 'side', label: '侧边菜单' },
|
||
]
|
||
|
||
const SECTION_KEYS = {
|
||
basic: [
|
||
'groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address',
|
||
'heroImg', 'heroImgResized', 'heroImgResizedMeta',
|
||
'endImg', 'endImgResized', 'endImgResizedMeta',
|
||
],
|
||
copy: ['formalText1', 'formalText2', 'formalPageHeight'],
|
||
visual: [
|
||
'introExitEffect', 'scrollMode', 'freeScrollInterval', 'pageSwitchDuration', 'firstScreenDuration',
|
||
'petalEnabled', 'bubbleEnabled', 'introEnabled', 'danmakuEnabled', 'danmakuShowTime', 'nineGridAnimate',
|
||
],
|
||
music: ['musicList', 'activeMusicUrl', 'musicRandomEnabled'],
|
||
photos: ['photoPages'],
|
||
schedule: ['schedule'],
|
||
}
|
||
|
||
function readStoredTab() {
|
||
try {
|
||
const v = localStorage.getItem(TAB_KEY)
|
||
if (v && VALID_TABS.includes(v)) return v
|
||
} catch { /* ignore */ }
|
||
return 'overview'
|
||
}
|
||
|
||
function readStoredMenuLayout() {
|
||
try {
|
||
const v = localStorage.getItem(MENU_LAYOUT_KEY)
|
||
if (v === 'top' || v === 'side') return v
|
||
} catch { /* ignore */ }
|
||
return 'side'
|
||
}
|
||
|
||
const activeTab = ref(readStoredTab())
|
||
const menuLayout = ref(readStoredMenuLayout())
|
||
const hotelWelcomeRef = ref(null)
|
||
watch(menuLayout, (v) => {
|
||
try { localStorage.setItem(MENU_LAYOUT_KEY, v) } catch { /* ignore */ }
|
||
})
|
||
|
||
function isMenuGroupActive(group) {
|
||
return Array.isArray(group?.items) && group.items.some((item) => item.key === activeTab.value)
|
||
}
|
||
|
||
const activePhotoTab = ref('0')
|
||
const saving = ref(false)
|
||
const uploading = ref(false)
|
||
const resizingPage = ref(false)
|
||
const resizingBasic = ref(false)
|
||
const resizeProgress = ref('')
|
||
const uploadConfigSaving = ref(false)
|
||
const posterSaving = ref(false)
|
||
const posterLoading = ref(false)
|
||
const posterMusicLoading = ref(false)
|
||
/** 海报选曲用:独立缓存请柬 musicList,避免依赖是否打开过音乐页 */
|
||
const posterMusicOptions = ref([])
|
||
const tabLoading = ref(false)
|
||
const rsvpLoading = ref(false)
|
||
const rsvpList = ref([])
|
||
const cashGiftLoading = ref(false)
|
||
const cashGiftSaving = ref(false)
|
||
const cashGiftList = ref([])
|
||
const cashGiftTotal = ref(0)
|
||
const cashGiftCount = ref(0)
|
||
const cashGiftModalOpen = ref(false)
|
||
const cashGiftEditingId = ref(0)
|
||
const cashGiftForm = reactive({ name: '', amount: 1000, note: '' })
|
||
const cashNotePresets = ref([])
|
||
const cashNoteDefaults = ref([])
|
||
const cashNoteDraft = ref('')
|
||
const cashNotesSaving = ref(false)
|
||
const cashViewPass = ref('')
|
||
const cashViewPassSaving = ref(false)
|
||
const cashEntryPass = ref('')
|
||
const cashEntryPassSaving = ref(false)
|
||
const danmakuLoading = ref(false)
|
||
const danmakuList = ref([])
|
||
const danmakuFilter = ref('pending')
|
||
|
||
const visitStatsLoading = ref(false)
|
||
const visitListLoading = ref(false)
|
||
const giftStatsLoading = ref(false)
|
||
const giftListLoading = ref(false)
|
||
const giftCostsSaving = ref(false)
|
||
const giftTypeList = GIFT_TYPES
|
||
const giftList = ref([])
|
||
const giftStats = reactive({
|
||
total: 0,
|
||
counts: Object.fromEntries(GIFT_TYPES.map((g) => [g.key, 0])),
|
||
})
|
||
const giftCostsEdit = reactive(emptyGiftCosts())
|
||
|
||
function applyGiftCostsFromServer(costs) {
|
||
if (!costs || typeof costs !== 'object') return
|
||
for (const g of GIFT_TYPES) {
|
||
const v = Number(costs[g.key])
|
||
if (Number.isFinite(v) && v > 0) giftCostsEdit[g.key] = v
|
||
}
|
||
}
|
||
|
||
function resetGiftCostsEdit() {
|
||
Object.assign(giftCostsEdit, emptyGiftCosts())
|
||
}
|
||
|
||
async function saveGiftCostsEdit() {
|
||
giftCostsSaving.value = true
|
||
try {
|
||
const payload = {}
|
||
for (const g of GIFT_TYPES) {
|
||
payload[g.key] = Number(giftCostsEdit[g.key]) || DEFAULT_GIFT_COSTS[g.key]
|
||
}
|
||
const res = await saveGiftCosts(payload)
|
||
applyGiftCostsFromServer(res.data?.costs || payload)
|
||
message.success('礼物兑换配置已保存')
|
||
} catch (e) {
|
||
message.error(e?.message || '保存失败')
|
||
} finally {
|
||
giftCostsSaving.value = false
|
||
}
|
||
}
|
||
const visitStats = reactive({ uv: 0, pv: 0, today_uv: 0, regions: [], devices: [] })
|
||
const visitList = ref([])
|
||
const visitPreviewList = ref([])
|
||
const visitPage = ref(1)
|
||
const visitPageSize = ref(20)
|
||
const visitTotal = ref(0)
|
||
const regionChartRef = ref(null)
|
||
const deviceChartRef = ref(null)
|
||
const cityChartRef = ref(null)
|
||
let regionChart = null
|
||
let deviceChart = null
|
||
let cityChart = null
|
||
const selectedRegion = ref('')
|
||
const cityStats = ref([])
|
||
const cityStatsLoading = ref(false)
|
||
|
||
const cfg = reactive(defaultConfig())
|
||
const uploadCfg = reactive(defaultUploadConfig())
|
||
const posterSideTab = ref('1')
|
||
const posterLayoutTab = ref('1')
|
||
const posterBundle = reactive(defaultPosterBundle())
|
||
const posterImporting = ref(false)
|
||
const posterCopying = ref(false)
|
||
/** 本会话最近一次复制的模板,导入时优先/兜底使用 */
|
||
let posterTemplateCache = null
|
||
/** 导入前快照,用于撤回 { s, l, config } */
|
||
const posterUndoSnapshot = ref(null)
|
||
const posterCfg = computed(() => {
|
||
const s = sideKey(posterSideTab.value)
|
||
const l = layoutKey(posterLayoutTab.value)
|
||
if (!posterBundle[s]) posterBundle[s] = { '1': defaultPosterForLayout(s, 1), '2': defaultPosterForLayout(s, 2) }
|
||
if (!posterBundle[s][l]) posterBundle[s][l] = defaultPosterForLayout(s, l)
|
||
return posterBundle[s][l]
|
||
})
|
||
const mediaPickerOpen = ref(false)
|
||
const mediaPickerCurrent = ref('')
|
||
let mediaPickerOnUrl = null
|
||
|
||
function ensurePosterSlot(side = posterSideTab.value, layout = posterLayoutTab.value) {
|
||
const s = sideKey(side)
|
||
const l = layoutKey(layout)
|
||
if (!posterBundle[s] || typeof posterBundle[s] !== 'object') {
|
||
posterBundle[s] = { '1': defaultPosterForLayout(s, 1), '2': defaultPosterForLayout(s, 2) }
|
||
}
|
||
if (!posterBundle[s][l]) posterBundle[s][l] = defaultPosterForLayout(s, l)
|
||
return posterBundle[s][l]
|
||
}
|
||
|
||
const heroFocusGrid = heroFocusGridPoints(HERO_FOCUS_GRID)
|
||
const heroFocusPreviewEl = ref(null)
|
||
const heroFocusDragging = ref(false)
|
||
const heroFocusDevice = ref('pc') // pc | mobile
|
||
|
||
const heroFocusActiveX = computed(() => {
|
||
const cfg = posterCfg.value
|
||
if (!cfg) return 50
|
||
if (heroFocusDevice.value === 'mobile') {
|
||
return Number.isFinite(Number(cfg.heroFocusMobileX)) ? cfg.heroFocusMobileX : (cfg.heroFocusX ?? 50)
|
||
}
|
||
return cfg.heroFocusX ?? 50
|
||
})
|
||
const heroFocusActiveY = computed(() => {
|
||
const cfg = posterCfg.value
|
||
if (!cfg) return 50
|
||
if (heroFocusDevice.value === 'mobile') {
|
||
return Number.isFinite(Number(cfg.heroFocusMobileY)) ? cfg.heroFocusMobileY : (cfg.heroFocusY ?? 50)
|
||
}
|
||
return cfg.heroFocusY ?? 50
|
||
})
|
||
const heroFocusPreviewCss = computed(() => {
|
||
const cfg = posterCfg.value
|
||
if (!cfg) return '50% 50%'
|
||
return heroFocusCss(cfg, heroFocusDevice.value === 'mobile' ? 'mobile' : undefined)
|
||
})
|
||
|
||
function setHeroFocus(x, y) {
|
||
const cfg = posterCfg.value
|
||
if (!cfg) return
|
||
if (heroFocusDevice.value === 'mobile') {
|
||
cfg.heroFocusMobileX = clampHeroFocus(x)
|
||
cfg.heroFocusMobileY = clampHeroFocus(y)
|
||
} else {
|
||
cfg.heroFocusX = clampHeroFocus(x)
|
||
cfg.heroFocusY = clampHeroFocus(y)
|
||
}
|
||
}
|
||
|
||
function isHeroFocusGridActive(pt) {
|
||
return Math.abs(heroFocusActiveX.value - pt.x) < 0.6
|
||
&& Math.abs(heroFocusActiveY.value - pt.y) < 0.6
|
||
}
|
||
|
||
function heroFocusFromClient(clientX, clientY) {
|
||
const el = heroFocusPreviewEl.value
|
||
if (!el) return
|
||
const rect = el.getBoundingClientRect()
|
||
if (rect.width <= 0 || rect.height <= 0) return
|
||
const x = ((clientX - rect.left) / rect.width) * 100
|
||
const y = ((clientY - rect.top) / rect.height) * 100
|
||
setHeroFocus(x, y)
|
||
}
|
||
|
||
function onHeroFocusPointerMove(e) {
|
||
if (!heroFocusDragging.value) return
|
||
heroFocusFromClient(e.clientX, e.clientY)
|
||
}
|
||
|
||
function onHeroFocusPointerUp() {
|
||
if (!heroFocusDragging.value) return
|
||
heroFocusDragging.value = false
|
||
window.removeEventListener('pointermove', onHeroFocusPointerMove)
|
||
window.removeEventListener('pointerup', onHeroFocusPointerUp)
|
||
window.removeEventListener('pointercancel', onHeroFocusPointerUp)
|
||
}
|
||
|
||
function onHeroFocusPointerDown(e) {
|
||
if (!posterCfg.value?.heroImg) return
|
||
e.preventDefault()
|
||
heroFocusDragging.value = true
|
||
heroFocusFromClient(e.clientX, e.clientY)
|
||
window.addEventListener('pointermove', onHeroFocusPointerMove)
|
||
window.addEventListener('pointerup', onHeroFocusPointerUp)
|
||
window.addEventListener('pointercancel', onHeroFocusPointerUp)
|
||
}
|
||
|
||
const posterMusicSelectOptions = computed(() => {
|
||
const list = Array.isArray(posterMusicOptions.value) ? posterMusicOptions.value : []
|
||
const opts = list
|
||
.filter((t) => t && t.url)
|
||
.map((t, i) => ({
|
||
value: t.url,
|
||
label: t.name || `曲目 ${i + 1}`,
|
||
}))
|
||
const cur = posterCfg.value?.musicUrl
|
||
if (cur && !opts.some((o) => o.value === cur)) {
|
||
opts.unshift({ value: cur, label: `(原曲目)${cur.slice(0, 36)}${cur.length > 36 ? '…' : ''}` })
|
||
}
|
||
return opts
|
||
})
|
||
const currentPosterSide = computed(() => posterSideMeta(posterSideTab.value))
|
||
const currentPosterLayout = computed(() => posterLayoutMeta(posterLayoutTab.value))
|
||
const currentPosterPreviewPath = computed(() => posterRoute(posterSideTab.value, posterLayoutTab.value))
|
||
const posterNameLabels = computed(() => {
|
||
const s = sideKey(posterSideTab.value)
|
||
if (s === '2') {
|
||
return { left: '女儿称谓 / 姓名', right: '女婿称谓 / 姓名', leftPh: '女儿:', rightPh: '女婿:' }
|
||
}
|
||
if (s === '3') {
|
||
return { left: '新人左侧称谓 / 姓名', right: '新人右侧称谓 / 姓名', leftPh: '新郎:', rightPh: '新娘:' }
|
||
}
|
||
return { left: '儿子称谓 / 姓名', right: '儿媳称谓 / 姓名', leftPh: '儿子:', rightPh: '儿媳:' }
|
||
})
|
||
const posterLinesText = computed({
|
||
get: () => {
|
||
const lines = posterCfg.value?.lines
|
||
return Array.isArray(lines) ? lines.join('\n') : ''
|
||
},
|
||
set: (v) => {
|
||
const slot = ensurePosterSlot()
|
||
slot.lines = String(v || '')
|
||
.split('\n')
|
||
.map((s) => s.trimEnd())
|
||
},
|
||
})
|
||
function setPosterHero(url) {
|
||
ensurePosterSlot().heroImg = url
|
||
}
|
||
function setPosterQrCenter(url) {
|
||
ensurePosterSlot().qrCenterImg = url
|
||
}
|
||
|
||
watch([posterSideTab, posterLayoutTab], () => {
|
||
const slot = ensurePosterSlot()
|
||
const l = layoutKey(posterLayoutTab.value)
|
||
slot.layout = Number(l)
|
||
})
|
||
|
||
const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) || 0, Math.max(cfg.photoPages.length - 1, 0)))
|
||
const isConfigTab = computed(() => CONFIG_SECTIONS.includes(activeTab.value))
|
||
const visitPagination = computed(() => ({
|
||
current: visitPage.value,
|
||
pageSize: visitPageSize.value,
|
||
total: visitTotal.value,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: ['10', '20', '50', '100'],
|
||
showTotal: (t) => `共 ${t} 条`,
|
||
}))
|
||
|
||
function defaultConfig() {
|
||
return {
|
||
groom: '李明', bride: '王华', date: '2026-05-20', lunar: '农历四月初四 星期三',
|
||
hotel: '杭州星光万丽大酒店', address: '杭州市西湖区星光大道88号\n3楼大宴会厅',
|
||
formalText1: '两姓联姻 一堂缔约', formalText2: '良缘永结 匹配同称', formalPageHeight: '100vh',
|
||
calendarDate: '2026-09-04',
|
||
heroImg: 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80',
|
||
heroImgResized: '',
|
||
heroImgResizedMeta: '',
|
||
endImg: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80',
|
||
endImgResized: '',
|
||
endImgResizedMeta: '',
|
||
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1' }],
|
||
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) / 爱心拼接(13);柔性版式:瀑布流/轮播/拍立得,仅首次补齐
|
||
const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'heart-collage': 13, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
|
||
const need = fixed[p.type] || 6
|
||
p.images = Array.isArray(p.images) ? p.images : []
|
||
if (p.images.length === 0) while (p.images.length < need) p.images.push('')
|
||
else if (['one-large-five-small', 'nine-grid', 'heart-collage'].includes(p.type)) {
|
||
while (p.images.length < need) p.images.push('')
|
||
if (p.images.length > need) p.images.length = need
|
||
}
|
||
syncImagesResized(p)
|
||
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)
|
||
}
|
||
|
||
const HEART_SLOT_LABELS = [
|
||
'左上叶', '右上叶', '左外上', '左外下', '左大图', '中轴', '右大图',
|
||
'右外上', '右外下', '左下', '中下横', '右下', '心尖',
|
||
]
|
||
function heartSlotLabel(type, i) {
|
||
if (type === 'heart-collage' && HEART_SLOT_LABELS[i]) return HEART_SLOT_LABELS[i]
|
||
return `图${i + 1}`
|
||
}
|
||
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 setBasicOriginal(key, url) {
|
||
cfg[key] = url
|
||
cfg[`${key}Resized`] = ''
|
||
cfg[`${key}ResizedMeta`] = ''
|
||
}
|
||
|
||
async function fillMissingBasicResizedMeta() {
|
||
await Promise.all(['heroImg', 'endImg'].map(async (key) => {
|
||
const original = cfg[key]
|
||
const resized = cfg[`${key}Resized`]
|
||
const metaKey = `${key}ResizedMeta`
|
||
if (!resized || !original || metaHasFileSize(cfg[metaKey])) return
|
||
try {
|
||
cfg[metaKey] = await probeResizedMeta(original, resized)
|
||
} catch { /* 跨域/失效图忽略 */ }
|
||
}))
|
||
}
|
||
|
||
async function batchResizeBasicImages() {
|
||
const slots = getBasicImageResizeSpecs(cfg).filter((s) => !!s.getOriginal())
|
||
if (!slots.length) {
|
||
message.warning('请先上传封面或尾页原图')
|
||
return
|
||
}
|
||
resizingBasic.value = true
|
||
resizeProgress.value = `0/${slots.length}`
|
||
let ok = 0
|
||
let fail = 0
|
||
try {
|
||
for (let i = 0; i < slots.length; i += 1) {
|
||
const slot = slots[i]
|
||
resizeProgress.value = `${i + 1}/${slots.length}`
|
||
try {
|
||
const { file, meta } = await resizeSlotToFile(
|
||
slot.getOriginal(),
|
||
slot.maxWidth,
|
||
slot.maxHeight,
|
||
`resized-basic-${slot.key}-${Date.now()}.jpg`,
|
||
)
|
||
const res = await uploadImage(file)
|
||
if (!res?.url) throw new Error('上传无返回地址')
|
||
slot.setResized(res.url, meta)
|
||
ok += 1
|
||
} catch (err) {
|
||
fail += 1
|
||
console.warn('[resize-basic]', slot.key, err)
|
||
}
|
||
}
|
||
if (ok > 0) {
|
||
try {
|
||
await saveConfigSection('basic', pickSectionData('basic'))
|
||
if (fail === 0) message.success(`已生成并保存 ${ok} 张展示图`)
|
||
else message.warning(`成功 ${ok} 张并已保存,失败 ${fail} 张`)
|
||
} catch (e) {
|
||
message.warning(`已生成 ${ok} 张展示图,但自动保存失败:${e?.message || '请手动保存'}`)
|
||
}
|
||
} else {
|
||
message.error('缩放上传全部失败,请确认原图可跨域读取')
|
||
}
|
||
} finally {
|
||
resizingBasic.value = false
|
||
resizeProgress.value = ''
|
||
}
|
||
}
|
||
|
||
function setOriginalImageAt(page, iidx, url) {
|
||
page.images[iidx] = url
|
||
syncImagesResized(page)
|
||
page.imagesResized[iidx] = ''
|
||
page.imagesResizedMeta[iidx] = ''
|
||
}
|
||
|
||
/** 缺 meta,或只有像素没有文件大小时,自动探测并补全 */
|
||
async function fillMissingResizedMeta(page) {
|
||
if (!page) return
|
||
if (page.type === 'single' || page.type === 'overlap') {
|
||
const keys = page.type === 'single' ? ['img1'] : ['img1', 'img2']
|
||
await Promise.all(keys.map(async (key) => {
|
||
const original = page[key]
|
||
const resized = page[`${key}Resized`]
|
||
const metaKey = `${key}ResizedMeta`
|
||
if (!resized || !original || metaHasFileSize(page[metaKey])) return
|
||
try {
|
||
page[metaKey] = await probeResizedMeta(original, resized)
|
||
} catch { /* 跨域/失效图忽略 */ }
|
||
}))
|
||
return
|
||
}
|
||
if (!Array.isArray(page.images)) return
|
||
syncImagesResized(page)
|
||
await Promise.all(page.images.map(async (original, i) => {
|
||
const resized = page.imagesResized[i]
|
||
if (!resized || !original || metaHasFileSize(page.imagesResizedMeta[i])) return
|
||
try {
|
||
page.imagesResizedMeta[i] = await probeResizedMeta(original, resized)
|
||
} catch { /* ignore */ }
|
||
}))
|
||
}
|
||
|
||
async function batchResizeAndUpload(page) {
|
||
const slots = getSlotResizeSpecs(page).filter((s) => !!s.getOriginal())
|
||
if (!slots.length) {
|
||
message.warning('请先上传原图')
|
||
return
|
||
}
|
||
resizingPage.value = true
|
||
resizeProgress.value = `0/${slots.length}`
|
||
let ok = 0
|
||
let fail = 0
|
||
try {
|
||
for (let i = 0; i < slots.length; i += 1) {
|
||
const slot = slots[i]
|
||
resizeProgress.value = `${i + 1}/${slots.length}`
|
||
try {
|
||
const { file, meta } = await resizeSlotToFile(
|
||
slot.getOriginal(),
|
||
slot.maxWidth,
|
||
slot.maxHeight,
|
||
`resized-${page.type}-${slot.key}-${Date.now()}.jpg`,
|
||
)
|
||
const res = await uploadImage(file)
|
||
if (!res?.url) throw new Error('上传无返回地址')
|
||
slot.setResized(res.url, meta)
|
||
ok += 1
|
||
} catch (err) {
|
||
fail += 1
|
||
console.warn('[resize]', slot.key, err)
|
||
}
|
||
}
|
||
if (fail === 0) message.success(`已生成 ${ok} 张展示图`)
|
||
else if (ok > 0) message.warning(`成功 ${ok} 张,失败 ${fail} 张(常见原因:跨域无法读取原图)`)
|
||
else message.error('缩放上传全部失败,请确认原图可跨域读取')
|
||
} finally {
|
||
resizingPage.value = false
|
||
resizeProgress.value = ''
|
||
}
|
||
}
|
||
|
||
// 相册页操作
|
||
function setActivePhotoIndex(i) {
|
||
const max = Math.max(cfg.photoPages.length - 1, 0)
|
||
activePhotoTab.value = String(Math.min(Math.max(i, 0), max))
|
||
}
|
||
function addPage() {
|
||
cfg.photoPages.push(ensurePageShape({ type: 'single', title: '新页面', subtitle: 'NEW', desc: '', img1: '' }))
|
||
setActivePhotoIndex(cfg.photoPages.length - 1)
|
||
}
|
||
function removePage(i) {
|
||
cfg.photoPages.splice(i, 1)
|
||
setActivePhotoIndex(Math.min(i, cfg.photoPages.length - 1))
|
||
}
|
||
function movePage(i, dir) {
|
||
const j = i + dir
|
||
if (j < 0 || j >= cfg.photoPages.length) return
|
||
const t = cfg.photoPages[i]; cfg.photoPages[i] = cfg.photoPages[j]; cfg.photoPages[j] = t
|
||
setActivePhotoIndex(j)
|
||
}
|
||
// 流程操作
|
||
function addSchedule() { cfg.schedule.push({ time: '', event: '', desc: '' }) }
|
||
function removeSchedule(i) { cfg.schedule.splice(i, 1) }
|
||
|
||
// 选图:弹层图库 + 新上传
|
||
function openUpload(onUrl) {
|
||
mediaPickerOnUrl = typeof onUrl === 'function' ? onUrl : null
|
||
mediaPickerCurrent.value = ''
|
||
mediaPickerOpen.value = true
|
||
}
|
||
function onMediaPicked(url) {
|
||
if (mediaPickerOnUrl) mediaPickerOnUrl(url)
|
||
mediaPickerOnUrl = null
|
||
}
|
||
|
||
// 上传(背景音乐,多首)
|
||
function openMusicUpload() {
|
||
const input = document.createElement('input')
|
||
input.type = 'file'; input.accept = 'audio/*'
|
||
input.onchange = async (e) => {
|
||
const file = e.target.files[0]; if (!file) return
|
||
uploading.value = true
|
||
try {
|
||
const res = await uploadImage(file) // 后端 /upload 同处理任意文件
|
||
const name = file.name.replace(/\.[^.]+$/, '') || '背景音乐'
|
||
const url = res.url
|
||
if (!cfg.musicList) cfg.musicList = []
|
||
cfg.musicList.push({ url, name, coverUrl: '', lrcUrl: '' })
|
||
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 {
|
||
// 仅同步 layout 编号与 Tab 一致,不覆盖用户所选 template / mobileLayout
|
||
for (const s of ['1', '2', '3']) {
|
||
ensurePosterSlot(s, '1').layout = 1
|
||
ensurePosterSlot(s, '2').layout = 2
|
||
}
|
||
const payload = normalizePosterBundle(posterBundle)
|
||
const res = await savePosterConfig(payload)
|
||
const bundle = normalizePosterBundle(res.data || payload)
|
||
posterBundle['1'] = bundle['1']
|
||
posterBundle['2'] = bundle['2']
|
||
posterBundle['3'] = bundle['3']
|
||
posterUndoSnapshot.value = null
|
||
message.success('海报配置已保存')
|
||
} catch (e) {
|
||
message.error(e?.message || '海报配置保存失败')
|
||
} finally {
|
||
posterSaving.value = false
|
||
}
|
||
}
|
||
|
||
function buildPosterTemplatePayload() {
|
||
const s = sideKey(posterSideTab.value)
|
||
const l = layoutKey(posterLayoutTab.value)
|
||
const slot = ensurePosterSlot(s, l)
|
||
return {
|
||
version: 1,
|
||
kind: 'poster-slot',
|
||
side: Number(s),
|
||
layout: Number(l),
|
||
sideLabel: posterSideMeta(s).label,
|
||
layoutLabel: posterLayoutMeta(l).label,
|
||
route: posterRoute(s, l),
|
||
config: JSON.parse(JSON.stringify(slot)),
|
||
}
|
||
}
|
||
|
||
async function copyPosterTemplate() {
|
||
posterCopying.value = true
|
||
try {
|
||
const payload = buildPosterTemplatePayload()
|
||
posterTemplateCache = payload
|
||
const text = JSON.stringify(payload, null, 2)
|
||
if (navigator?.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(text)
|
||
} else {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = text
|
||
ta.style.position = 'fixed'
|
||
ta.style.left = '-9999px'
|
||
document.body.appendChild(ta)
|
||
ta.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(ta)
|
||
}
|
||
message.success(`已复制「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}」`)
|
||
} catch (e) {
|
||
message.error(e?.message || '复制失败,请检查浏览器剪贴板权限')
|
||
} finally {
|
||
posterCopying.value = false
|
||
}
|
||
}
|
||
|
||
function extractPosterImportConfig(parsed) {
|
||
if (!parsed || typeof parsed !== 'object') return null
|
||
if (parsed.kind === 'poster-slot' && parsed.config && typeof parsed.config === 'object') {
|
||
return parsed.config
|
||
}
|
||
if (parsed.config && typeof parsed.config === 'object' && !Array.isArray(parsed.config)) {
|
||
return parsed.config
|
||
}
|
||
const s = sideKey(posterSideTab.value)
|
||
const l = layoutKey(posterLayoutTab.value)
|
||
const sideObj = parsed[s] || parsed[Number(s)]
|
||
if (sideObj && typeof sideObj === 'object') {
|
||
const slot = sideObj[l] || sideObj[Number(l)]
|
||
if (slot && typeof slot === 'object') return slot
|
||
}
|
||
if ('template' in parsed || 'heroImg' in parsed || 'lines' in parsed || 'sonName' in parsed || 'groomName' in parsed) {
|
||
return parsed
|
||
}
|
||
return null
|
||
}
|
||
|
||
async function readPosterImportSource() {
|
||
// 1) 剪贴板 2) 本会话刚复制的缓存
|
||
try {
|
||
if (navigator?.clipboard?.readText) {
|
||
const clip = String(await navigator.clipboard.readText() || '').trim()
|
||
if (clip) {
|
||
try {
|
||
const parsed = JSON.parse(clip)
|
||
if (extractPosterImportConfig(parsed)) return parsed
|
||
} catch { /* not json */ }
|
||
}
|
||
}
|
||
} catch { /* no permission */ }
|
||
if (posterTemplateCache && extractPosterImportConfig(posterTemplateCache)) {
|
||
return posterTemplateCache
|
||
}
|
||
return null
|
||
}
|
||
|
||
function applyImportedConfig(rawCfg) {
|
||
const s = sideKey(posterSideTab.value)
|
||
const l = layoutKey(posterLayoutTab.value)
|
||
posterUndoSnapshot.value = {
|
||
s,
|
||
l,
|
||
config: JSON.parse(JSON.stringify(ensurePosterSlot(s, l))),
|
||
}
|
||
const next = normalizePosterConfig(rawCfg, s, l)
|
||
next.side = Number(s)
|
||
next.layout = Number(l)
|
||
if (next.sonName && !next.groomName) next.groomName = next.sonName
|
||
if (next.daughterInLawName && !next.brideName) next.brideName = next.daughterInLawName
|
||
if (next.groomName && !next.sonName) next.sonName = next.groomName
|
||
if (next.brideName && !next.daughterInLawName) next.daughterInLawName = next.brideName
|
||
posterBundle[s][l] = next
|
||
}
|
||
|
||
async function importPosterTemplateDirect() {
|
||
posterImporting.value = true
|
||
try {
|
||
const parsed = await readPosterImportSource()
|
||
if (!parsed) {
|
||
message.warning('请先复制模板,再点导入')
|
||
return
|
||
}
|
||
const rawCfg = extractPosterImportConfig(parsed)
|
||
if (!rawCfg) {
|
||
message.error('无法识别的模板数据')
|
||
return
|
||
}
|
||
applyImportedConfig(rawCfg)
|
||
message.success(`已导入到「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}」,可撤回或保存`)
|
||
} catch (e) {
|
||
message.error(e?.message || '导入失败')
|
||
} finally {
|
||
posterImporting.value = false
|
||
}
|
||
}
|
||
|
||
function undoPosterImport() {
|
||
const snap = posterUndoSnapshot.value
|
||
if (!snap?.config) {
|
||
message.info('没有可撤回的导入')
|
||
return
|
||
}
|
||
posterSideTab.value = snap.s
|
||
posterLayoutTab.value = snap.l
|
||
posterBundle[snap.s][snap.l] = snap.config
|
||
posterUndoSnapshot.value = null
|
||
message.success('已撤回导入')
|
||
}
|
||
|
||
function pickSectionData(section) {
|
||
const keys = SECTION_KEYS[section] || []
|
||
const data = {}
|
||
for (const k of keys) data[k] = cfg[k]
|
||
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'
|
||
loaded.heroImgResized = loaded.heroImgResized || ''
|
||
loaded.heroImgResizedMeta = loaded.heroImgResizedMeta || ''
|
||
loaded.endImgResized = loaded.endImgResized || ''
|
||
loaded.endImgResizedMeta = loaded.endImgResizedMeta || ''
|
||
}
|
||
if (section === 'copy') {
|
||
loaded.formalPageHeight = loaded.formalPageHeight || '100vh'
|
||
}
|
||
if (section === 'visual') {
|
||
loaded.introExitEffect = loaded.introExitEffect || 'wind'
|
||
loaded.freeScrollInterval = Number(loaded.freeScrollInterval) || 50
|
||
loaded.pageSwitchDuration = Number(loaded.pageSwitchDuration) || 800
|
||
loaded.firstScreenDuration = Number.isFinite(Number(loaded.firstScreenDuration))
|
||
? Number(loaded.firstScreenDuration)
|
||
: 4500
|
||
if (typeof loaded.danmakuEnabled !== 'boolean') loaded.danmakuEnabled = true
|
||
if (typeof loaded.danmakuShowTime !== 'boolean') loaded.danmakuShowTime = true
|
||
if (typeof loaded.nineGridAnimate !== 'boolean') loaded.nineGridAnimate = false
|
||
}
|
||
Object.assign(cfg, loaded)
|
||
if (section === 'photos') {
|
||
setActivePhotoIndex(currentPhotoIndex.value)
|
||
nextTick(() => {
|
||
const page = cfg.photoPages[currentPhotoIndex.value]
|
||
if (page) fillMissingResizedMeta(page)
|
||
})
|
||
}
|
||
}
|
||
|
||
async function loadConfigSection(section) {
|
||
tabLoading.value = true
|
||
try {
|
||
const res = await getConfigSection(section)
|
||
applySectionData(section, res.data || {})
|
||
} catch (e) {
|
||
message.error(e?.message || '加载配置失败')
|
||
} finally {
|
||
tabLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 保存当前配置段
|
||
async function onSave() {
|
||
const section = activeTab.value
|
||
if (!CONFIG_SECTIONS.includes(section)) {
|
||
message.info('当前页无需保存配置')
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
const res = await saveConfigSection(section, pickSectionData(section))
|
||
applySectionData(section, res.data || pickSectionData(section))
|
||
message.success('配置已保存')
|
||
} catch (e) {
|
||
const msg = e?.message || '保存失败'
|
||
message.error(msg)
|
||
if (String(msg).includes('未授权') || String(msg).includes('登录')) {
|
||
router.replace({ name: 'Login', query: { redirect: '/admin' } })
|
||
}
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
const visitPreviewColumns = [
|
||
{ title: '指纹', key: 'fingerprint', width: 110 },
|
||
{ title: '归属地', key: 'location', ellipsis: true },
|
||
{ title: '机型', key: 'device', width: 120, ellipsis: true },
|
||
{ title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 64 },
|
||
{ title: '最近访问', key: 'last_seen_at', width: 160 },
|
||
]
|
||
|
||
const visitColumns = [
|
||
{ title: '指纹', key: 'fingerprint', width: 120 },
|
||
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
|
||
{ title: '归属地', key: 'location', ellipsis: true },
|
||
{ title: '机型', key: 'device', width: 140, ellipsis: true },
|
||
{ title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 70 },
|
||
{ title: 'UA', key: 'user_agent', ellipsis: true },
|
||
{ title: '最近访问', key: 'last_seen_at', width: 170 },
|
||
]
|
||
|
||
function shortFp(fp) {
|
||
if (!fp) return '-'
|
||
return fp.length > 12 ? `${fp.slice(0, 8)}…${fp.slice(-4)}` : fp
|
||
}
|
||
function shortUa(ua) {
|
||
if (!ua) return '-'
|
||
return ua.length > 42 ? `${ua.slice(0, 42)}…` : ua
|
||
}
|
||
|
||
const PIE_COLORS = ['#a88c6b', '#c4a484', '#e8b4b8', '#7c9eb2', '#b5a4c9', '#d4a574', '#9b7e9a', '#64748b']
|
||
|
||
function disposeVisitCharts() {
|
||
if (regionChart) {
|
||
regionChart.dispose()
|
||
regionChart = null
|
||
}
|
||
if (deviceChart) {
|
||
deviceChart.dispose()
|
||
deviceChart = null
|
||
}
|
||
if (cityChart) {
|
||
cityChart.dispose()
|
||
cityChart = null
|
||
}
|
||
}
|
||
|
||
function disposeCityChart() {
|
||
if (cityChart) {
|
||
cityChart.dispose()
|
||
cityChart = null
|
||
}
|
||
}
|
||
|
||
function renderPieChart(chartRef, holder, seriesData, emptyName = '暂无数据', opts = {}) {
|
||
if (!chartRef.value) return null
|
||
if (holder && holder.getDom() !== chartRef.value) {
|
||
holder.dispose()
|
||
holder = null
|
||
}
|
||
if (!holder) holder = echarts.init(chartRef.value)
|
||
const data = seriesData.length
|
||
? seriesData.map((r) => ({
|
||
name: r.name || '未知',
|
||
value: Number(r.value) || 0,
|
||
selected: opts.selectedName ? (r.name || '未知') === opts.selectedName : false,
|
||
}))
|
||
: [{ name: emptyName, value: 0 }]
|
||
holder.setOption({
|
||
color: PIE_COLORS,
|
||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||
legend: {
|
||
type: 'scroll',
|
||
bottom: 0,
|
||
textStyle: { color: '#8A8680', fontSize: 11 },
|
||
},
|
||
series: [{
|
||
type: 'pie',
|
||
radius: ['38%', '62%'],
|
||
center: ['50%', '46%'],
|
||
selectedMode: opts.selectable ? 'single' : false,
|
||
selectedOffset: 8,
|
||
itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
|
||
label: { color: '#5c5348', fontSize: 11 },
|
||
data,
|
||
}],
|
||
}, { notMerge: true })
|
||
return holder
|
||
}
|
||
|
||
function renderRegionChart() {
|
||
regionChart = renderPieChart(
|
||
regionChartRef,
|
||
regionChart,
|
||
Array.isArray(visitStats.regions) ? visitStats.regions : [],
|
||
'暂无数据',
|
||
{ selectable: true, selectedName: selectedRegion.value },
|
||
)
|
||
if (!regionChart) return
|
||
regionChart.off('click')
|
||
regionChart.on('click', (params) => {
|
||
const name = params?.name
|
||
if (!name || name === '暂无数据') return
|
||
if (selectedRegion.value === name) {
|
||
clearSelectedRegion()
|
||
return
|
||
}
|
||
onSelectRegion(name)
|
||
})
|
||
}
|
||
|
||
function renderDeviceChart() {
|
||
deviceChart = renderPieChart(
|
||
deviceChartRef,
|
||
deviceChart,
|
||
Array.isArray(visitStats.devices) ? visitStats.devices : [],
|
||
)
|
||
}
|
||
|
||
function renderCityChart() {
|
||
cityChart = renderPieChart(
|
||
cityChartRef,
|
||
cityChart,
|
||
Array.isArray(cityStats.value) ? cityStats.value : [],
|
||
'暂无市级数据',
|
||
)
|
||
}
|
||
|
||
function clearSelectedRegion(opts = {}) {
|
||
selectedRegion.value = ''
|
||
cityStats.value = []
|
||
disposeCityChart()
|
||
if (!opts.silent && regionChart) renderRegionChart()
|
||
}
|
||
|
||
async function onSelectRegion(name) {
|
||
selectedRegion.value = name
|
||
cityStatsLoading.value = true
|
||
cityStats.value = []
|
||
disposeCityChart()
|
||
renderRegionChart()
|
||
try {
|
||
const res = await getVisitCityStats(name)
|
||
const d = res.data || {}
|
||
cityStats.value = Array.isArray(d.cities) ? d.cities : []
|
||
await nextTick()
|
||
renderCityChart()
|
||
} catch (e) {
|
||
message.error(e?.message || '加载市级分布失败')
|
||
cityStats.value = []
|
||
} finally {
|
||
cityStatsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadGiftStats() {
|
||
giftStatsLoading.value = true
|
||
try {
|
||
const res = await getGiftStats()
|
||
const d = res.data || {}
|
||
const counts = d.counts || {}
|
||
for (const g of GIFT_TYPES) {
|
||
giftStats.counts[g.key] = Number(counts[g.key]) || 0
|
||
}
|
||
giftStats.total = Number(d.total) || Object.values(giftStats.counts).reduce((a, b) => a + b, 0)
|
||
applyGiftCostsFromServer(d.costs)
|
||
} catch (e) {
|
||
message.error(e?.message || '加载礼物统计失败')
|
||
} finally {
|
||
giftStatsLoading.value = false
|
||
}
|
||
}
|
||
|
||
const giftListColumns = [
|
||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
|
||
{ title: '礼物', dataIndex: 'gift_type', key: 'gift_type', width: 120 },
|
||
{ title: '消耗', dataIndex: 'cost', key: 'cost', width: 90 },
|
||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||
{ title: '操作', key: 'actions', width: 90 },
|
||
]
|
||
const giftTypeLabel = (key) => GIFT_LABEL_MAP[key] || key || '—'
|
||
|
||
async function loadGiftList() {
|
||
giftListLoading.value = true
|
||
try {
|
||
const res = await getGiftList()
|
||
const data = res.data || {}
|
||
giftList.value = Array.isArray(data.list) ? data.list : (Array.isArray(data) ? data : [])
|
||
} catch (e) {
|
||
giftList.value = []
|
||
message.error(e?.message || '加载礼物明细失败')
|
||
} finally {
|
||
giftListLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function onDeleteGift(record) {
|
||
try {
|
||
await deleteGift(record.id)
|
||
message.success('已删除')
|
||
await Promise.all([loadGiftList(), loadGiftStats()])
|
||
} catch (e) {
|
||
message.error(e?.message || '删除失败')
|
||
}
|
||
}
|
||
|
||
async function loadVisitStats() {
|
||
visitStatsLoading.value = true
|
||
try {
|
||
const statsRes = await getVisitStats()
|
||
const s = statsRes.data || {}
|
||
visitStats.uv = Number(s.uv) || 0
|
||
visitStats.pv = Number(s.pv) || 0
|
||
visitStats.today_uv = Number(s.today_uv) || 0
|
||
visitStats.regions = Array.isArray(s.regions) ? s.regions : []
|
||
visitStats.devices = Array.isArray(s.devices) ? s.devices : []
|
||
await nextTick()
|
||
renderRegionChart()
|
||
renderDeviceChart()
|
||
if (selectedRegion.value) {
|
||
await onSelectRegion(selectedRegion.value)
|
||
}
|
||
} catch (e) {
|
||
message.error(e?.message || '加载访客统计失败')
|
||
} finally {
|
||
visitStatsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadVisitPreview() {
|
||
visitListLoading.value = true
|
||
try {
|
||
const listRes = await getVisitList({ page: 1, pageSize: 10 })
|
||
const listData = listRes.data || {}
|
||
visitPreviewList.value = listData.list || []
|
||
visitTotal.value = Number(listData.total) || 0
|
||
} catch (e) {
|
||
message.error(e?.message || '加载访客预览失败')
|
||
} finally {
|
||
visitListLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadVisitList() {
|
||
visitListLoading.value = true
|
||
try {
|
||
const listRes = await getVisitList({
|
||
page: visitPage.value,
|
||
pageSize: visitPageSize.value,
|
||
})
|
||
const listData = listRes.data || {}
|
||
visitList.value = listData.list || []
|
||
visitTotal.value = Number(listData.total) || 0
|
||
} catch (e) {
|
||
message.error(e?.message || '加载访客记录失败')
|
||
} finally {
|
||
visitListLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadOverview() {
|
||
await Promise.all([loadVisitStats(), loadVisitPreview()])
|
||
}
|
||
|
||
function onVisitTableChange(pag) {
|
||
visitPage.value = pag?.current || 1
|
||
visitPageSize.value = pag?.pageSize || visitPageSize.value
|
||
loadVisitList()
|
||
}
|
||
|
||
async function onTabActivate(tab) {
|
||
if (!adminStore.isAdmin) return
|
||
if (tab !== 'overview') {
|
||
clearSelectedRegion({ silent: true })
|
||
disposeVisitCharts()
|
||
}
|
||
if (tab === 'overview') {
|
||
await loadOverview()
|
||
return
|
||
}
|
||
if (tab === 'gifts') {
|
||
await Promise.all([loadGiftStats(), loadGiftList()])
|
||
return
|
||
}
|
||
if (tab === 'visits') {
|
||
await loadVisitList()
|
||
return
|
||
}
|
||
if (CONFIG_SECTIONS.includes(tab)) {
|
||
await loadConfigSection(tab)
|
||
return
|
||
}
|
||
if (tab === 'upload') {
|
||
await loadUploadConfig()
|
||
return
|
||
}
|
||
if (tab === 'poster') {
|
||
await Promise.all([loadPosterConfig(), loadPosterMusicOptions()])
|
||
return
|
||
}
|
||
if (tab === 'hotelWelcome') {
|
||
hotelWelcomeRef.value?.refresh?.()
|
||
return
|
||
}
|
||
if (tab === 'rsvp') {
|
||
await loadRsvp()
|
||
return
|
||
}
|
||
if (tab === 'cash') {
|
||
await Promise.all([loadCashGifts(), loadCashNotePresets(), loadCashViewPass(), loadCashEntryPass()])
|
||
return
|
||
}
|
||
if (tab === 'danmaku') {
|
||
await loadDanmaku()
|
||
}
|
||
}
|
||
|
||
function openPreview() { window.open('/', '_blank') }
|
||
function openPosterPreview(side = 1, layout = 1) {
|
||
window.open(posterRoute(side, layout), '_blank')
|
||
}
|
||
|
||
function onLogout() {
|
||
adminStore.logout()
|
||
router.replace({ name: 'Login', query: { redirect: '/admin' } })
|
||
}
|
||
|
||
// RSVP
|
||
const rsvpColumns = [
|
||
{ title: '姓名', dataIndex: 'name', key: 'name' },
|
||
{ title: '出席', dataIndex: 'guest_count', key: 'guest_count' },
|
||
{ title: '祝福', dataIndex: 'wishes', key: 'wishes', ellipsis: true },
|
||
{ title: '提交时间', dataIndex: 'created_at', key: 'created_at' },
|
||
{ title: '操作', key: 'actions', width: 90 },
|
||
]
|
||
const rsvpCountLabel = (v) => ({ '1': '1 人出席', '2': '2 人出席', '3': '3 人及以上', '0': '遗憾缺席' }[v] || v)
|
||
function formatTime(t) {
|
||
if (!t) return ''
|
||
const d = new Date(t)
|
||
if (isNaN(d)) return t
|
||
return d.toLocaleString('zh-CN', { hour12: false })
|
||
}
|
||
async function loadRsvp() {
|
||
rsvpLoading.value = true
|
||
try { const res = await getRsvpList(); rsvpList.value = res.data || [] }
|
||
catch (e) { /* 忽略 */ }
|
||
finally { rsvpLoading.value = false }
|
||
}
|
||
async function onDeleteRsvp(record) {
|
||
if (!record?.id) return
|
||
try {
|
||
await deleteRsvp(record.id)
|
||
message.success('已删除')
|
||
await loadRsvp()
|
||
} catch (e) {
|
||
message.error(e?.message || '删除失败')
|
||
}
|
||
}
|
||
|
||
const cashGiftColumns = [
|
||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
|
||
{ title: '金额', key: 'amount', width: 110 },
|
||
{ title: '备注', dataIndex: 'note', key: 'note', ellipsis: true },
|
||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||
{ title: '操作', key: 'actions', width: 140 },
|
||
]
|
||
async function loadCashGifts() {
|
||
cashGiftLoading.value = true
|
||
try {
|
||
const res = await getCashGiftList()
|
||
const data = res.data || {}
|
||
cashGiftList.value = Array.isArray(data.list) ? data.list : []
|
||
cashGiftTotal.value = Number(data.total) || 0
|
||
cashGiftCount.value = Number(data.count) || cashGiftList.value.length
|
||
} catch {
|
||
cashGiftList.value = []
|
||
cashGiftTotal.value = 0
|
||
cashGiftCount.value = 0
|
||
} finally {
|
||
cashGiftLoading.value = false
|
||
}
|
||
}
|
||
function openCashGiftCreate() {
|
||
cashGiftEditingId.value = 0
|
||
cashGiftForm.name = ''
|
||
cashGiftForm.amount = 1000
|
||
cashGiftForm.note = ''
|
||
cashGiftModalOpen.value = true
|
||
}
|
||
function openCashGiftEdit(record) {
|
||
cashGiftEditingId.value = record.id
|
||
cashGiftForm.name = record.name || ''
|
||
cashGiftForm.amount = Number(record.amount) || 0
|
||
cashGiftForm.note = record.note || ''
|
||
cashGiftModalOpen.value = true
|
||
}
|
||
async function saveCashGiftModal() {
|
||
const name = String(cashGiftForm.name || '').trim()
|
||
const amount = Math.round(Number(cashGiftForm.amount))
|
||
if (!name) {
|
||
message.error('请填写姓名')
|
||
return Promise.reject()
|
||
}
|
||
if (!Number.isFinite(amount) || amount <= 0) {
|
||
message.error('请填写正确金额')
|
||
return Promise.reject()
|
||
}
|
||
cashGiftSaving.value = true
|
||
try {
|
||
const payload = { name, amount, note: String(cashGiftForm.note || '').trim() }
|
||
if (cashGiftEditingId.value) await updateCashGift(cashGiftEditingId.value, payload)
|
||
else await submitCashGift(payload)
|
||
message.success('已保存')
|
||
cashGiftModalOpen.value = false
|
||
await loadCashGifts()
|
||
} catch (e) {
|
||
if (e) message.error(e?.message || '保存失败')
|
||
return Promise.reject(e)
|
||
} finally {
|
||
cashGiftSaving.value = false
|
||
}
|
||
}
|
||
async function onDeleteCashGift(record) {
|
||
if (!record?.id) return
|
||
try {
|
||
await deleteCashGift(record.id)
|
||
message.success('已删除')
|
||
await loadCashGifts()
|
||
} catch (e) {
|
||
message.error(e?.message || '删除失败')
|
||
}
|
||
}
|
||
|
||
async function loadCashNotePresets() {
|
||
try {
|
||
const res = await getCashGiftNotes()
|
||
cashNotePresets.value = Array.isArray(res.data?.notes) ? [...res.data.notes] : []
|
||
cashNoteDefaults.value = Array.isArray(res.data?.defaults) ? [...res.data.defaults] : []
|
||
} catch {
|
||
cashNotePresets.value = []
|
||
cashNoteDefaults.value = []
|
||
}
|
||
}
|
||
|
||
async function loadCashViewPass() {
|
||
try {
|
||
const res = await getCashGiftViewPass()
|
||
cashViewPass.value = res.data?.password || ''
|
||
} catch {
|
||
cashViewPass.value = ''
|
||
}
|
||
}
|
||
|
||
async function saveCashViewPass() {
|
||
const password = String(cashViewPass.value || '').trim()
|
||
if (!password) return message.error('请设置查看口令')
|
||
cashViewPassSaving.value = true
|
||
try {
|
||
await saveCashGiftViewPass(password)
|
||
message.success('查看口令已保存')
|
||
} catch (e) {
|
||
message.error(e?.message || '保存失败')
|
||
} finally {
|
||
cashViewPassSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function loadCashEntryPass() {
|
||
try {
|
||
const res = await getCashGiftEntryPass()
|
||
cashEntryPass.value = res.data?.password || ''
|
||
} catch {
|
||
cashEntryPass.value = ''
|
||
}
|
||
}
|
||
|
||
async function saveCashEntryPass() {
|
||
const password = String(cashEntryPass.value || '').trim()
|
||
if (!password) return message.error('请设置记账口令')
|
||
cashEntryPassSaving.value = true
|
||
try {
|
||
await saveCashGiftEntryPass(password)
|
||
message.success('记账口令已保存')
|
||
} catch (e) {
|
||
message.error(e?.message || '保存失败')
|
||
} finally {
|
||
cashEntryPassSaving.value = false
|
||
}
|
||
}
|
||
|
||
function addCashNotePreset() {
|
||
const v = String(cashNoteDraft.value || '').trim()
|
||
if (!v) return
|
||
if (cashNotePresets.value.includes(v)) {
|
||
message.warning('已存在该选项')
|
||
return
|
||
}
|
||
cashNotePresets.value.push(v)
|
||
cashNoteDraft.value = ''
|
||
}
|
||
|
||
function removeCashNotePreset(index) {
|
||
cashNotePresets.value.splice(index, 1)
|
||
}
|
||
|
||
function resetCashNotePresets() {
|
||
cashNotePresets.value = cashNoteDefaults.value.length
|
||
? [...cashNoteDefaults.value]
|
||
: ['男方亲戚', '女方亲戚', '男方朋友', '女方朋友', '男方同学', '女方同学', '男方同事', '女方同事', '男方邻居', '女方邻居', '其他']
|
||
}
|
||
|
||
async function saveCashNotePresets() {
|
||
if (!cashNotePresets.value.length) return message.error('至少保留一个快捷备注')
|
||
cashNotesSaving.value = true
|
||
try {
|
||
const res = await saveCashGiftNotes(cashNotePresets.value)
|
||
cashNotePresets.value = Array.isArray(res.data?.notes) ? [...res.data.notes] : [...cashNotePresets.value]
|
||
message.success('备注选项已保存')
|
||
} catch (e) {
|
||
message.error(e?.message || '保存失败')
|
||
} finally {
|
||
cashNotesSaving.value = false
|
||
}
|
||
}
|
||
|
||
const danmakuColumns = [
|
||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
|
||
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||
{ title: '来源', key: 'source', width: 80 },
|
||
{ title: '状态', key: 'status', width: 90 },
|
||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||
{ title: '操作', key: 'actions', width: 160 },
|
||
]
|
||
const danmakuStatusLabel = (s) => ({ pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s)
|
||
const danmakuStatusPill = (s) => ({
|
||
pending: 'admin-pill--warn',
|
||
approved: 'admin-pill--ok',
|
||
rejected: 'admin-pill--muted',
|
||
}[s] || 'admin-pill--muted')
|
||
|
||
async function loadDanmaku() {
|
||
danmakuLoading.value = true
|
||
try {
|
||
const params = danmakuFilter.value ? { status: danmakuFilter.value } : undefined
|
||
const res = await getDanmakuList(params)
|
||
danmakuList.value = res.data || []
|
||
} catch (e) { /* 忽略 */ }
|
||
finally { danmakuLoading.value = false }
|
||
}
|
||
|
||
async function setDanmakuStatus(id, status) {
|
||
try {
|
||
await updateDanmakuStatus(id, status)
|
||
message.success(status === 'approved' ? '已通过' : '已拒绝')
|
||
loadDanmaku()
|
||
} catch (e) {
|
||
message.error(e.message || '操作失败')
|
||
}
|
||
}
|
||
|
||
watch(danmakuFilter, () => {
|
||
if (adminStore.isAdmin && activeTab.value === 'danmaku') loadDanmaku()
|
||
})
|
||
|
||
watch(activeTab, (tab) => {
|
||
try { localStorage.setItem(TAB_KEY, tab) } catch { /* ignore */ }
|
||
onTabActivate(tab)
|
||
}, { immediate: true })
|
||
|
||
// 进入相册页 / 切换页签时,为缺 meta 的展示图自动探测尺寸
|
||
watch(
|
||
() => [activeTab.value, currentPhotoIndex.value],
|
||
([tab]) => {
|
||
if (tab !== 'photos') return
|
||
const page = cfg.photoPages[currentPhotoIndex.value]
|
||
if (page) fillMissingResizedMeta(page)
|
||
},
|
||
)
|
||
|
||
watch(activeTab, (tab) => {
|
||
if (tab === 'basic') fillMissingBasicResizedMeta()
|
||
})
|
||
|
||
const onResize = () => {
|
||
regionChart?.resize()
|
||
deviceChart?.resize()
|
||
cityChart?.resize()
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener('resize', onResize)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('resize', onResize)
|
||
onHeroFocusPointerUp()
|
||
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-header {
|
||
padding: 10px 16px 10px;
|
||
}
|
||
.admin-header-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.admin-header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
margin-left: auto;
|
||
}
|
||
.admin-top-menu {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow-x: auto;
|
||
scrollbar-width: none;
|
||
padding: 2px 0;
|
||
}
|
||
.admin-top-menu::-webkit-scrollbar { display: none; }
|
||
.admin-top-menu-trigger {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
border: none;
|
||
background: transparent;
|
||
color: #5c5348;
|
||
font-size: 13px;
|
||
letter-spacing: 0.04em;
|
||
padding: 8px 12px;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
.admin-top-menu-trigger:hover {
|
||
background: rgba(168, 140, 107, 0.08);
|
||
color: #967b5c;
|
||
}
|
||
.admin-top-menu-trigger.active {
|
||
color: #a88c6b;
|
||
background: rgba(168, 140, 107, 0.12);
|
||
font-weight: 500;
|
||
}
|
||
.admin-top-menu-caret {
|
||
font-size: 9px;
|
||
opacity: 0.65;
|
||
}
|
||
.admin-top-dropdown :deep(.ant-dropdown-menu-item-selected) {
|
||
color: #a88c6b;
|
||
background: rgba(168, 140, 107, 0.12);
|
||
}
|
||
.admin-shell {
|
||
margin: 0 auto;
|
||
padding: 20px 16px 40px;
|
||
box-sizing: border-box;
|
||
}
|
||
.admin-shell--top {
|
||
max-width: 80vw;
|
||
width: 80vw;
|
||
}
|
||
.admin-shell--side {
|
||
max-width: 1220px;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 18px;
|
||
}
|
||
.admin-nav {
|
||
background: #fff;
|
||
border: 1px solid rgba(168, 140, 107, 0.14);
|
||
border-radius: 16px;
|
||
box-shadow: 0 8px 28px rgba(88, 68, 42, 0.05);
|
||
position: sticky;
|
||
top: 68px;
|
||
width: 212px;
|
||
flex-shrink: 0;
|
||
padding: 14px 10px 16px;
|
||
max-height: calc(100dvh - 88px);
|
||
overflow: auto;
|
||
}
|
||
.admin-nav-group-title {
|
||
font-size: 11px;
|
||
letter-spacing: 0.12em;
|
||
color: #8A8680;
|
||
margin-bottom: 6px;
|
||
padding: 0 6px;
|
||
}
|
||
.admin-nav-group + .admin-nav-group {
|
||
margin-top: 14px;
|
||
padding-top: 12px;
|
||
border-top: 1px dashed rgba(168, 140, 107, 0.22);
|
||
}
|
||
.admin-nav-items {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.admin-nav-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
border: 1px solid transparent;
|
||
background: transparent;
|
||
color: #5c5348;
|
||
font-size: 13px;
|
||
line-height: 1.2;
|
||
padding: 7px 10px;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||
text-align: left;
|
||
}
|
||
.admin-nav-item:hover {
|
||
background: rgba(168, 140, 107, 0.08);
|
||
color: #967b5c;
|
||
}
|
||
.admin-nav-item.active {
|
||
background: rgba(168, 140, 107, 0.14);
|
||
border-color: rgba(168, 140, 107, 0.28);
|
||
color: #a88c6b;
|
||
font-weight: 500;
|
||
}
|
||
.admin-nav-icon {
|
||
width: 1em;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
opacity: 0.85;
|
||
}
|
||
.admin-nav-credit {
|
||
margin-top: 14px;
|
||
padding: 10px 8px 2px;
|
||
border-top: 1px dashed rgba(168, 140, 107, 0.22);
|
||
font-size: 11px;
|
||
letter-spacing: 0.08em;
|
||
color: #a88c6b;
|
||
text-align: center;
|
||
}
|
||
.admin-main {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
@media (max-width: 960px) {
|
||
.admin-top-menu {
|
||
order: 3;
|
||
flex: 1 1 100%;
|
||
border-top: 1px solid rgba(168, 140, 107, 0.12);
|
||
margin-top: 2px;
|
||
padding-top: 8px;
|
||
}
|
||
}
|
||
@media (max-width: 860px) {
|
||
.admin-shell--side {
|
||
flex-direction: column;
|
||
}
|
||
.admin-nav {
|
||
position: static;
|
||
width: 100%;
|
||
max-height: none;
|
||
}
|
||
.admin-nav-items {
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
}
|
||
.admin-nav-item {
|
||
width: auto;
|
||
}
|
||
}
|
||
|
||
.admin-panel {
|
||
background: #fff;
|
||
border: 1px solid rgba(168, 140, 107, 0.14);
|
||
border-radius: 16px;
|
||
padding: 20px 20px 16px;
|
||
box-shadow: 0 8px 28px rgba(88, 68, 42, 0.05);
|
||
}
|
||
.stat-card {
|
||
border-radius: 14px;
|
||
padding: 16px 18px;
|
||
background: linear-gradient(145deg, #fff 0%, #f8f1e6 100%);
|
||
border: 1px solid rgba(168, 140, 107, 0.16);
|
||
}
|
||
.stat-label {
|
||
font-size: 11px;
|
||
color: #8A8680;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.stat-value {
|
||
margin-top: 6px;
|
||
font-size: 28px;
|
||
font-weight: 500;
|
||
color: #a88c6b;
|
||
letter-spacing: 0.02em;
|
||
line-height: 1.1;
|
||
}
|
||
.visit-preview-wrap,
|
||
.visit-list-wrap {
|
||
border: 1px solid rgba(168, 140, 107, 0.12);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: #fffdf9;
|
||
}
|
||
.visit-preview-wrap :deep(.ant-table-body),
|
||
.visit-list-wrap :deep(.ant-table-body) {
|
||
max-height: inherit;
|
||
}
|
||
.chart-card {
|
||
border-radius: 14px;
|
||
padding: 14px 16px;
|
||
background: #fdfbf7;
|
||
border: 1px solid rgba(168, 140, 107, 0.12);
|
||
min-height: 300px;
|
||
}
|
||
|
||
.admin-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 48px 16px;
|
||
border-radius: 14px;
|
||
background: linear-gradient(180deg, #fdfbf7 0%, #f7f3ec 100%);
|
||
border: 1px dashed rgba(168, 140, 107, 0.28);
|
||
}
|
||
.admin-empty-icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(168, 140, 107, 0.12);
|
||
color: #a88c6b;
|
||
margin-bottom: 12px;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.music-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.music-track {
|
||
border: 1px solid rgba(168, 140, 107, 0.14);
|
||
border-radius: 14px;
|
||
background: #fdfbf7;
|
||
padding: 12px 14px;
|
||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||
}
|
||
.music-track--active {
|
||
border-color: rgba(168, 140, 107, 0.55);
|
||
background: linear-gradient(135deg, #fff 0%, #f8f1e6 100%);
|
||
box-shadow: 0 6px 18px rgba(168, 140, 107, 0.12);
|
||
}
|
||
.music-track-main {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 14px;
|
||
}
|
||
.music-cover {
|
||
position: relative;
|
||
width: 72px;
|
||
height: 72px;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
border: 1px solid rgba(168, 140, 107, 0.2);
|
||
background: #f0ebe3;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
.music-cover img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.music-cover-fallback {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #a88c6b;
|
||
font-size: 22px;
|
||
background: radial-gradient(circle at 30% 30%, #fff8ee, #efe4d4);
|
||
}
|
||
.music-cover-index {
|
||
position: absolute;
|
||
left: 4px;
|
||
bottom: 4px;
|
||
font-size: 9px;
|
||
letter-spacing: 0.06em;
|
||
color: #fff;
|
||
background: rgba(44, 36, 28, 0.45);
|
||
padding: 1px 5px;
|
||
border-radius: 999px;
|
||
}
|
||
.music-meta { min-width: 0; }
|
||
.music-name-input {
|
||
padding: 0 !important;
|
||
font-size: 14px !important;
|
||
font-weight: 500;
|
||
color: #2c2c2c;
|
||
}
|
||
.music-name-input :deep(.ant-input) {
|
||
padding: 0;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
background: transparent;
|
||
}
|
||
.music-sub {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 2px;
|
||
font-size: 11px;
|
||
color: #8A8680;
|
||
min-width: 0;
|
||
}
|
||
.music-badge {
|
||
flex-shrink: 0;
|
||
font-size: 10px;
|
||
letter-spacing: 0.04em;
|
||
color: #a88c6b;
|
||
background: rgba(168, 140, 107, 0.12);
|
||
border: 1px solid rgba(168, 140, 107, 0.25);
|
||
border-radius: 999px;
|
||
padding: 1px 8px;
|
||
}
|
||
.music-chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
}
|
||
.music-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
border: 1px solid rgba(168, 140, 107, 0.22);
|
||
background: #fff;
|
||
color: #7a6550;
|
||
font-size: 11px;
|
||
padding: 3px 10px;
|
||
border-radius: 999px;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, color 0.15s;
|
||
}
|
||
.music-chip:hover {
|
||
border-color: #a88c6b;
|
||
color: #a88c6b;
|
||
}
|
||
.music-chip--muted {
|
||
color: #9a9084;
|
||
border-color: transparent;
|
||
background: transparent;
|
||
}
|
||
.music-audio {
|
||
width: 100%;
|
||
max-width: 420px;
|
||
height: 32px;
|
||
margin-top: 10px;
|
||
border-radius: 8px;
|
||
outline: none;
|
||
}
|
||
.music-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
@media (max-width: 640px) {
|
||
.music-track-main { flex-wrap: wrap; }
|
||
.music-actions {
|
||
flex-direction: row;
|
||
width: 100%;
|
||
justify-content: flex-end;
|
||
}
|
||
}
|
||
|
||
.schedule-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.schedule-row {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 12px;
|
||
padding: 14px;
|
||
border-radius: 14px;
|
||
background: #fdfbf7;
|
||
border: 1px solid rgba(168, 140, 107, 0.12);
|
||
}
|
||
.schedule-index {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
letter-spacing: 0.06em;
|
||
color: #a88c6b;
|
||
background: rgba(168, 140, 107, 0.1);
|
||
flex-shrink: 0;
|
||
margin-bottom: 2px;
|
||
}
|
||
.schedule-fields {
|
||
flex: 1;
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 10px;
|
||
min-width: 0;
|
||
}
|
||
@media (max-width: 720px) {
|
||
.schedule-fields { grid-template-columns: 1fr; }
|
||
.schedule-row { align-items: flex-start; }
|
||
}
|
||
.field-label {
|
||
font-size: 11px;
|
||
color: #a88c6b;
|
||
margin-bottom: 4px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.admin-table :deep(.ant-table) {
|
||
background: transparent;
|
||
}
|
||
.admin-table :deep(.ant-table-thead > tr > th) {
|
||
background: #f7f3ec !important;
|
||
color: #7a6550;
|
||
font-weight: 500;
|
||
font-size: 12px;
|
||
border-bottom: 1px solid rgba(168, 140, 107, 0.16) !important;
|
||
}
|
||
.admin-table :deep(.ant-table-tbody > tr > td) {
|
||
border-bottom: 1px solid rgba(168, 140, 107, 0.08) !important;
|
||
font-size: 13px;
|
||
}
|
||
.admin-table :deep(.ant-table-tbody > tr:hover > td) {
|
||
background: #fdfbf7 !important;
|
||
}
|
||
.admin-table :deep(.ant-pagination-item-active) {
|
||
border-color: #a88c6b;
|
||
}
|
||
.admin-table :deep(.ant-pagination-item-active a) {
|
||
color: #a88c6b;
|
||
}
|
||
|
||
.admin-filter :deep(.ant-radio-button-wrapper-checked) {
|
||
background: #a88c6b !important;
|
||
border-color: #a88c6b !important;
|
||
color: #fff !important;
|
||
}
|
||
.admin-filter :deep(.ant-radio-button-wrapper:hover) {
|
||
color: #a88c6b;
|
||
}
|
||
|
||
.admin-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 1px 9px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
line-height: 18px;
|
||
border: 1px solid transparent;
|
||
}
|
||
.admin-pill--rose {
|
||
color: #b4536a;
|
||
background: rgba(232, 165, 178, 0.22);
|
||
border-color: rgba(232, 165, 178, 0.4);
|
||
}
|
||
.admin-pill--gold {
|
||
color: #8a7048;
|
||
background: rgba(168, 140, 107, 0.14);
|
||
border-color: rgba(168, 140, 107, 0.28);
|
||
}
|
||
.admin-pill--sky {
|
||
color: #4d6f86;
|
||
background: rgba(124, 158, 178, 0.16);
|
||
border-color: rgba(124, 158, 178, 0.3);
|
||
}
|
||
.admin-pill--warn {
|
||
color: #b45309;
|
||
background: rgba(251, 191, 36, 0.18);
|
||
border-color: rgba(251, 191, 36, 0.35);
|
||
}
|
||
.admin-pill--ok {
|
||
color: #3f6b4a;
|
||
background: rgba(134, 179, 143, 0.18);
|
||
border-color: rgba(134, 179, 143, 0.35);
|
||
}
|
||
.admin-pill--muted {
|
||
color: #8A8680;
|
||
background: #f3f1ec;
|
||
border-color: #e7e3db;
|
||
}
|
||
|
||
.danmaku-admin-dot {
|
||
width: 10px; height: 10px; border-radius: 9999px; flex-shrink: 0;
|
||
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
|
||
}
|
||
|
||
.hero-focus-pad {
|
||
display: grid;
|
||
grid-template-columns: repeat(var(--hero-focus-n, 9), 22px);
|
||
gap: 4px;
|
||
width: fit-content;
|
||
padding: 8px;
|
||
border-radius: 10px;
|
||
background: #f7f4ef;
|
||
border: 1px solid #e7e3db;
|
||
}
|
||
.hero-focus-cell {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 5px;
|
||
border: 1px solid #ddd6cb;
|
||
background: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
transition: border-color 0.15s, background 0.15s;
|
||
}
|
||
.hero-focus-cell:hover { border-color: #a88c6b; }
|
||
.hero-focus-cell.active {
|
||
border-color: #a88c6b;
|
||
background: rgba(168, 140, 107, 0.14);
|
||
}
|
||
.hero-focus-dot {
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 999px;
|
||
background: #c4b8a4;
|
||
}
|
||
.hero-focus-cell.active .hero-focus-dot { background: #a88c6b; }
|
||
.hero-focus-preview {
|
||
position: relative;
|
||
width: min(100%, 280px);
|
||
aspect-ratio: 16 / 11;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
border: 1px solid #e7e3db;
|
||
background: #f0ebe3;
|
||
user-select: none;
|
||
touch-action: none;
|
||
}
|
||
.hero-focus-preview.is-mobile {
|
||
width: min(100%, 200px);
|
||
aspect-ratio: 3 / 4;
|
||
}
|
||
.hero-focus-preview--drag { cursor: crosshair; }
|
||
.hero-focus-preview--drag.dragging { cursor: grabbing; }
|
||
.hero-focus-preview--empty {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #8A8680;
|
||
font-size: 12px;
|
||
padding: 12px;
|
||
text-align: center;
|
||
}
|
||
.hero-focus-preview img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
pointer-events: none;
|
||
}
|
||
.hero-focus-crosshair {
|
||
position: absolute;
|
||
width: 18px;
|
||
height: 18px;
|
||
margin: -9px 0 0 -9px;
|
||
border: 2px solid #fff;
|
||
border-radius: 999px;
|
||
box-shadow: 0 0 0 1px rgba(168, 140, 107, 0.85), 0 1px 4px rgba(0, 0, 0, 0.25);
|
||
background: rgba(168, 140, 107, 0.35);
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
}
|
||
|
||
.poster-action-bar-spacer {
|
||
height: 72px;
|
||
}
|
||
.poster-action-bar {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 40;
|
||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
|
||
background: rgba(255, 255, 255, 0.94);
|
||
backdrop-filter: blur(10px);
|
||
border-top: 1px solid rgba(168, 140, 107, 0.2);
|
||
box-shadow: 0 -8px 24px rgba(88, 68, 42, 0.08);
|
||
}
|
||
.poster-action-bar-inner {
|
||
max-width: 1220px;
|
||
margin: 0 auto;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.admin-shell--top ~ .poster-action-bar .poster-action-bar-inner,
|
||
.admin-shell--top .poster-action-bar-inner {
|
||
max-width: 80vw;
|
||
}
|
||
.poster-action-bar-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
font-size: 12px;
|
||
color: #5c5348;
|
||
}
|
||
.poster-action-bar-path {
|
||
font-size: 11px;
|
||
color: #8A8680;
|
||
}
|
||
.poster-action-bar-btns {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
margin-left: auto;
|
||
}
|
||
@media (max-width: 720px) {
|
||
.poster-action-bar-meta { width: 100%; }
|
||
.poster-action-bar-btns { width: 100%; justify-content: stretch; }
|
||
.poster-action-bar-btns :deep(.ant-btn) { flex: 1 1 auto; }
|
||
}
|
||
</style>
|