Files
hunli/vite-tailwindcss/src/views/admin/AdminEditor.vue

6249 lines
224 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="min-h-screen bg-[#f6f4ef] text-[var(--c-ink)]" :class="{ 'preview-open': livePreviewOn }">
<!-- 顶部栏 -->
<header ref="adminHeaderRef" class="admin-header sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-[var(--c-primary)]/15">
<div class="admin-header-row">
<div class="flex items-center gap-3 min-w-0 shrink-0">
<span class="text-xl text-[var(--c-primary)] 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-[var(--c-muted)] tracking-[0.12em] mt-0.5">
制作者 · <span class="text-[var(--c-primary)]">年糕崽崽</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 }}
<span v-if="dirtySections.includes(item.key)" class="nav-dirty-dot" title="有未保存的更改"></span>
</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" class="admin-live-toggle" :class="{ '!border-[var(--c-primary)] !text-[var(--c-primary)]': livePreviewOn }" @click="toggleLivePreview">
<i class="fa-solid fa-mobile-screen mr-1 text-[var(--c-primary)]"></i>{{ livePreviewOn ? '关闭实时预览' : '实时预览' }}
</a-button>
<a-button size="small" @click="openPreview">
<i class="fa-solid fa-eye mr-1 text-[var(--c-primary)]"></i>预览请柬
</a-button>
<a-dropdown>
<a-button size="small">
<i class="fa-solid fa-scroll mr-1 text-[var(--c-primary)]"></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>
<span
v-if="isConfigTab && activeDirty"
class="admin-dirty-badge"
title="当前页有未保存的更改Ctrl+S 可快速保存"
>
<i class="admin-dirty-dot"></i>未保存
</span>
<a-button
v-if="isConfigTab"
type="primary"
size="small"
:loading="saving"
@click="onSave"
class="!bg-[var(--c-primary)] hover:!bg-[var(--c-primary-deep)] !border-[var(--c-primary)]"
:class="{ 'admin-save-attn': activeDirty }"
:title="activeDirty ? '有未保存更改Ctrl+S' : '保存当前页配置Ctrl+S'"
>
<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>
<!-- 手机壳实时预览面板1280px 显示改动自动同步无需保存 -->
<aside v-if="livePreviewOn" class="admin-preview-panel" :style="{ top: previewPanelTop + 'px' }">
<div class="admin-preview-head">
<span class="admin-preview-title"><i class="fa-solid fa-mobile-screen mr-1"></i>实时预览</span>
<span class="admin-preview-tip">改动自动同步 · 无需保存</span>
<div class="admin-preview-actions">
<button type="button" title="重新加载" @click="reloadPreview"><i class="fa-solid fa-rotate-right"></i></button>
<button type="button" title="关闭" @click="toggleLivePreview"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
<div class="admin-preview-body">
<div class="admin-phone-shell">
<div class="admin-phone-notch"></div>
<iframe ref="previewFrameRef" :src="previewUrl" class="admin-preview-iframe" title="请柬实时预览"></iframe>
</div>
</div>
</aside>
<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>
<span v-if="dirtySections.includes(item.key)" class="nav-dirty-dot" title="有未保存的更改"></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-[var(--c-ink)]">访客概览</div>
<div class="text-[11px] text-[var(--c-muted)] 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>
<span v-else class="text-[10px] text-[#a89a88] ml-2">点击城市筛选下方访客</span>
</div>
<a-button size="small" type="text" class="!text-[var(--c-primary)]" @click="clearVisitRegionFilter">
<i class="fa-solid fa-xmark mr-1"></i>清除地区筛选
</a-button>
</div>
<div ref="cityChartRef" class="h-[280px] w-full cursor-pointer"></div>
</div>
<div v-if="selectedRegion || visitFilter.hasInteraction || visitFilter.hasGift" class="flex flex-wrap items-center gap-2 mb-3">
<span class="text-[11px] text-[var(--c-muted)]">当前筛选</span>
<a-tag v-if="selectedRegion" closable color="processing" @close="clearVisitRegionFilter">
{{ selectedRegion }}{{ selectedCity ? ` · ${selectedCity}` : '' }}
</a-tag>
<a-tag v-if="visitFilter.hasInteraction" closable @close="visitFilter.hasInteraction = false; onVisitFilterChange()">有互动</a-tag>
<a-tag v-if="visitFilter.hasGift" closable @close="visitFilter.hasGift = false; onVisitFilterChange()">有礼物</a-tag>
</div>
<div class="flex justify-between items-center mb-2 gap-2 flex-wrap">
<div class="text-[12px] text-[#7a6550] tracking-wide">近期访客预览</div>
<div class="flex items-center gap-2 flex-wrap">
<a-checkbox v-model:checked="visitFilter.hasInteraction" @change="onVisitFilterChange">有互动</a-checkbox>
<a-checkbox v-model:checked="visitFilter.hasGift" @change="onVisitFilterChange">有礼物</a-checkbox>
<a-select
v-model:value="visitFilter.sort"
size="small"
class="w-[130px]"
:options="visitSortOptions"
@change="onVisitFilterChange"
/>
<a-button size="small" type="link" class="!text-[var(--c-primary)] !px-0" @click="activeTab = 'visits'">
查看全部访客记录 <i class="fa-solid fa-arrow-right ml-1 text-[10px]"></i>
</a-button>
</div>
</div>
<div class="visit-preview-wrap">
<a-table
class="admin-table visit-table-clickable"
:columns="visitPreviewColumns"
:data-source="visitPreviewList"
:loading="visitListLoading"
row-key="id"
size="small"
:pagination="false"
:scroll="{ y: 260 }"
:custom-row="visitTableCustomRow"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fingerprint'">
<span class="font-mono text-[11px] text-[var(--c-primary)] underline-offset-2 hover:underline" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
</template>
<template v-else-if="column.key === 'guest_name'">
<span class="text-[12px]" :title="formatGuestName(record)">{{ formatGuestName(record) }}</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 === 'gift_count'">
<span :class="record.gift_count > 0 ? 'text-[var(--c-primary)]' : 'text-[var(--c-muted)]'">{{ record.gift_count || 0 }}</span>
</template>
<template v-else-if="column.key === 'last_seen_at'">
<span class="text-[12px] text-[var(--c-muted)]">{{ 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-[var(--c-ink)]">礼物统计</div>
<div class="text-[11px] text-[var(--c-muted)] 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-[var(--c-muted)] 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-[var(--c-ink)]">礼物兑换配置</div>
<div class="text-[11px] text-[var(--c-muted)] 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-[var(--c-primary)]" :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-[var(--c-ink)]">礼物明细</div>
<div class="text-[11px] text-[var(--c-muted)] 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-[var(--c-muted)]">{{ 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-[var(--c-ink)]">访客记录</div>
<div class="text-[11px] text-[var(--c-muted)] 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="flex flex-wrap items-center gap-3 mb-3">
<a-checkbox v-model:checked="visitFilter.hasInteraction" @change="onVisitFilterChange">有互动</a-checkbox>
<a-checkbox v-model:checked="visitFilter.hasGift" @change="onVisitFilterChange">有礼物</a-checkbox>
<a-select
v-model:value="visitFilter.sort"
size="small"
class="w-[140px]"
:options="visitSortOptions"
@change="onVisitFilterChange"
/>
<template v-if="selectedRegion">
<a-tag closable color="processing" @close="clearVisitRegionFilter">
{{ selectedRegion }}{{ selectedCity ? ` · ${selectedCity}` : '' }}
</a-tag>
</template>
</div>
<div class="text-[11px] text-[var(--c-muted)] mb-2">点击行查看该访客的回执弹幕点赞与礼物详情</div>
<div class="visit-list-wrap">
<a-table
class="admin-table visit-table-clickable"
:columns="visitColumns"
:data-source="visitList"
:loading="visitListLoading"
row-key="id"
size="middle"
:pagination="visitPagination"
:scroll="{ y: 'calc(100dvh - 320px)', x: 1100 }"
:custom-row="visitTableCustomRow"
@change="onVisitTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fingerprint'">
<span class="font-mono text-[11px] text-[var(--c-primary)] underline-offset-2 hover:underline" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
</template>
<template v-else-if="column.key === 'guest_name'">
<span class="text-[12px]" :title="formatGuestName(record)">{{ formatGuestName(record) }}</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 === 'gift_count'">
<span :class="record.gift_count > 0 ? 'text-[var(--c-primary)]' : 'text-[var(--c-muted)]'">{{ record.gift_count || 0 }}</span>
</template>
<template v-else-if="column.key === 'like_count'">
<span :class="record.like_count > 0 ? 'text-[var(--c-primary)]' : 'text-[var(--c-muted)]'">{{ record.like_count || 0 }}</span>
</template>
<template v-else-if="column.key === 'user_agent'">
<span class="text-[11px] text-[var(--c-muted)]" :title="record.user_agent">{{ shortUa(record.user_agent) }}</span>
</template>
<template v-else-if="column.key === 'last_seen_at'">
<span class="text-[12px] text-[var(--c-muted)]">{{ formatTime(record.last_seen_at) }}</span>
</template>
</template>
</a-table>
</div>
</div>
</section>
<a-modal
v-model:open="visitDetailOpen"
:title="visitDetailTitle"
:footer="null"
width="720px"
destroy-on-close
>
<div v-if="visitDetailLoading" class="py-10 text-center text-[var(--c-muted)] text-[13px]">加载中</div>
<div v-else-if="visitDetail" class="visit-detail">
<div class="visit-detail-summary">
<div class="stat-card !mb-0">
<div class="stat-label">浏览次数</div>
<div class="stat-value !text-xl">{{ visitDetail.visitor?.visit_count || 0 }}</div>
</div>
<div class="stat-card !mb-0">
<div class="stat-label">点赞</div>
<div class="stat-value !text-xl">{{ visitDetail.like_count || 0 }}</div>
</div>
<div class="stat-card !mb-0">
<div class="stat-label">礼物</div>
<div class="stat-value !text-xl">{{ visitDetail.gift_count || 0 }}</div>
</div>
<div class="stat-card !mb-0">
<div class="stat-label">回执</div>
<div class="stat-value !text-xl">{{ (visitDetail.rsvps || []).length }}</div>
</div>
</div>
<div class="text-[12px] text-[var(--c-ink-soft)] mt-3 space-y-1">
<div>归属地{{ visitDetail.visitor?.raw_location || [visitDetail.visitor?.region, visitDetail.visitor?.city].filter(Boolean).join(' ') || '未知' }}</div>
<div>机型{{ visitDetail.visitor?.device || '未知' }}</div>
<div>首次{{ formatTime(visitDetail.visitor?.first_seen_at) }} · 最近{{ formatTime(visitDetail.visitor?.last_seen_at) }}</div>
<div class="font-mono text-[11px] text-[var(--c-muted)] break-all">指纹{{ visitDetail.visitor?.fingerprint }}</div>
</div>
<div class="visit-detail-section">
<div class="visit-detail-h">回执</div>
<div v-if="!(visitDetail.rsvps || []).length" class="text-[12px] text-[var(--c-muted)]">暂无回执</div>
<ul v-else class="visit-detail-list">
<li v-for="r in visitDetail.rsvps" :key="'rsvp-' + r.id">
<div class="font-medium text-[var(--c-ink)]">{{ r.name || '—' }} · {{ r.guest_count || '1' }} </div>
<div v-if="r.wishes" class="text-[var(--c-ink-soft)] mt-0.5 whitespace-pre-wrap">{{ r.wishes }}</div>
<div class="text-[11px] text-[var(--c-muted)] mt-0.5">{{ formatTime(r.created_at) }}</div>
</li>
</ul>
</div>
<div class="visit-detail-section">
<div class="visit-detail-h">弹幕</div>
<div v-if="!(visitDetail.danmakus || []).length" class="text-[12px] text-[var(--c-muted)]">暂无弹幕</div>
<ul v-else class="visit-detail-list">
<li v-for="d in visitDetail.danmakus" :key="'dm-' + d.id">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-[var(--c-ink)]">{{ d.name || '—' }}</span>
<span class="admin-pill admin-pill--muted">{{ d.source === 'rsvp' ? '回执' : '弹幕' }}</span>
<span class="admin-pill" :class="danmakuStatusPill(d.status)">{{ danmakuStatusLabel(d.status) }}</span>
</div>
<div class="text-[var(--c-ink-soft)] mt-0.5 whitespace-pre-wrap">{{ d.content }}</div>
<div class="text-[11px] text-[var(--c-muted)] mt-0.5">{{ formatTime(d.created_at) }}</div>
</li>
</ul>
</div>
<div class="visit-detail-section">
<div class="visit-detail-h">礼物明细</div>
<div v-if="!(visitDetail.gifts || []).length" class="text-[12px] text-[var(--c-muted)]">暂无礼物</div>
<ul v-else class="visit-detail-list">
<li v-for="g in visitDetail.gifts" :key="'gift-' + g.id">
<div class="font-medium text-[var(--c-ink)]">
{{ g.name || '—' }} · {{ giftTypeLabel(g.gift_type) }}
<span class="text-[var(--c-muted)] font-normal">消耗 {{ g.cost || 0 }}</span>
</div>
<div class="text-[11px] text-[var(--c-muted)] mt-0.5">{{ formatTime(g.created_at) }}</div>
</li>
</ul>
</div>
</div>
</a-modal>
<!-- 基础信息 -->
<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-[var(--c-muted)] 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-[var(--c-muted)]">
原图用于预览展示图为等比缩放后的体积不裁切前台封面/揭幕/尾页优先用展示图
<span v-if="resizeProgress && resizingBasic" class="text-[var(--c-primary)] ml-1">{{ resizeProgress }}</span>
</div>
<a-button
size="small"
type="primary"
class="!bg-[var(--c-primary)] hover:!bg-[var(--c-primary-deep)] !border-[var(--c-primary)]"
: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-[var(--c-primary)]">展示已生成</div>
<div v-if="cfg.heroImgResizedMeta" class="resized-meta">{{ cfg.heroImgResizedMeta }}</div>
</div>
</div>
<div class="text-[11px] text-[var(--c-muted)] 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-[var(--c-primary)]">展示已生成</div>
<div v-if="cfg.endImgResizedMeta" class="resized-meta">{{ cfg.endImgResizedMeta }}</div>
</div>
</div>
<div class="text-[11px] text-[var(--c-muted)] 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-[var(--c-muted)] mt-1">控制前台竖排邀请文案页的展示高度默认满屏</div>
</a-form-item>
</a-form>
</a-card>
</section>
<!-- 风格模板 -->
<section v-if="activeTab === 'theme'">
<a-card :bordered="false" class="!shadow-sm">
<a-form layout="vertical">
<a-form-item label="整站风格模板">
<div v-for="group in SITE_THEME_GROUPS" :key="group.key" class="theme-group">
<div class="theme-group-title">{{ group.label }}</div>
<div class="theme-picker">
<button
v-for="t in group.items"
:key="t.key"
type="button"
class="theme-card"
:class="{ active: cfg.siteTheme === t.key }"
:title="t.desc"
@click="cfg.siteTheme = t.key"
>
<span class="theme-swatch" :style="{ background: t.bg }">
<b :style="{ color: t.primary, fontFamily: themePreviewFont(t.key) }"></b>
<i :style="{ background: t.primary }"></i>
<i :style="{ background: t.line }"></i>
</span>
<span class="theme-name">{{ t.label }}</span>
<span class="theme-desc">{{ t.desc }}</span>
<i v-if="cfg.siteTheme === t.key" class="theme-check fa-solid fa-check"></i>
</button>
</div>
</div>
<div class="text-[11px] text-[var(--c-muted)] mt-1">
风格包在经典排版上成套更换配色字体与装饰纹样完整模板首屏封面章节标题与尾页的排版结构也随之重构杂志 / 报纸 / 像素 / 极简选择后本页即时预览配色保存后对宾客生效
</div>
</a-form-item>
<a-form-item label="日历样式" class="mt-2">
<div class="cal-picker">
<button
type="button"
class="cal-card"
:class="{ active: cfg.calendarStyle === 'auto' }"
@click="cfg.calendarStyle = 'auto'"
>
<span class="cal-thumb cal-thumb-auto">
<i class="fa-solid fa-wand-magic-sparkles"></i>
</span>
<span class="cal-name">跟随主题</span>
<span class="cal-desc">当前 {{ autoCalendarLabel }}</span>
<i v-if="cfg.calendarStyle === 'auto'" class="cal-check fa-solid fa-check"></i>
</button>
<button
v-for="s in CALENDAR_STYLES"
:key="s.key"
type="button"
class="cal-card"
:class="{ active: cfg.calendarStyle === s.key }"
:title="s.desc"
@click="cfg.calendarStyle = s.key"
>
<span class="cal-thumb" :class="`cal-thumb-${s.key}`">
<b class="ct-head">五月 · MAY</b>
<span class="ct-grid"><i v-for="n in 12" :key="n" :class="{ sel: n === 7 }"></i></span>
<span class="ct-tape" aria-hidden="true"></span>
</span>
<span class="cal-name">{{ s.label }}</span>
<span class="cal-desc">{{ s.desc }}</span>
<i v-if="cfg.calendarStyle === s.key" class="cal-check fa-solid fa-check"></i>
</button>
</div>
<div class="text-[11px] text-[var(--c-muted)] mt-1">婚礼日历屏的视觉模板婚期选中标记也随之变化红金圆章 / 朱砂方印 / 检票章 / 像素心跟随主题时切换风格模板自动匹配</div>
</a-form-item>
<a-form-item label="尾页装饰" class="mt-2">
<div class="cal-picker">
<button
v-for="s in ENDING_FRAME_STYLES"
:key="s.key"
type="button"
class="cal-card"
:class="{ active: cfg.endingFrameStyle === s.key }"
:title="s.desc"
@click="cfg.endingFrameStyle = s.key"
>
<span class="cal-thumb" :class="`ed-thumb-${s.key}`"></span>
<span class="cal-name">{{ s.label }}</span>
<span class="cal-desc">{{ s.desc }}</span>
<i v-if="cfg.endingFrameStyle === s.key" class="cal-check fa-solid fa-check"></i>
</button>
</div>
<div class="text-[11px] text-[var(--c-muted)] mt-1">敬候光临尾页的全屏装饰仅风格包主题生效完整模板杂志/报纸等走各自的收尾排版</div>
</a-form-item>
<a-form-item label="章节编号01/02…" class="mt-2">
<a-switch v-model:checked="cfg.sectionIndexEnabled" />
<div class="text-[11px] text-[var(--c-muted)] 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="入场揭幕退场方式">
<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-[var(--c-muted)] 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-[var(--c-muted)] 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-[var(--c-muted)] 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-[var(--c-muted)] 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-[var(--c-muted)] 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-[var(--c-muted)] 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-[var(--c-muted)] mt-1">开启后弹幕后缀展示相对时间刚刚x分钟前等</div>
</a-form-item>
<a-form-item label="九宫格入场动画">
<a-switch v-model:checked="cfg.nineGridAnimate" />
<div class="text-[11px] text-[var(--c-muted)] mt-1">开启进入视口后暂停滚动 300ms 入场一格方向动效全部完成后再继续关闭直接展示更稳推荐 iOS</div>
</a-form-item>
</div>
<a-form-item label="礼物特效方案" class="mt-2">
<a-radio-group v-model:value="cfg.giftEffectMode">
<a-radio-button value="classic">经典粒子2D</a-radio-button>
<a-radio-button value="three">立体炫效3D</a-radio-button>
<a-radio-button value="auto">智能选择</a-radio-button>
</a-radio-group>
<div class="text-[11px] text-[var(--c-muted)] mt-1">
宾客送礼时的全屏特效渲染方式经典粒子兼容性最好立体炫效基于 WebGLthree.js8 种礼物各有专属 3D 场景爱心爆发金雨双螺旋花瓣雪星河涟漪双星环绕聚合成心按需加载不影响打开速度不支持的设备自动回落经典粒子智能选择按宾客机型自动决定iOS / 低配用经典
</div>
</a-form-item>
</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-[var(--c-ink)] tracking-wide">曲目列表</div>
<p class="text-[11px] text-[var(--c-muted)] mt-1 leading-relaxed max-w-xl">
在下方选择<strong>播放设置</strong>随机模式按权重抽曲可开启记忆曲目让同一访客在设定时长内首次打开仍播同一首
</p>
</div>
<a-button type="primary" size="small"
class="!bg-[var(--c-primary)] hover:!bg-[var(--c-primary-deep)] !border-[var(--c-primary)]"
@click="openMusicUpload">
<i class="fa-solid fa-plus mr-1"></i>上传音乐
</a-button>
</div>
<div class="mb-4 rounded-xl border border-[#e7e3db] bg-[#faf8f5] px-3 py-3">
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">播放设置</div>
<a-radio-group v-model:value="cfg.musicPlayMode" class="music-play-mode-group">
<a-radio-button value="sequential">顺序</a-radio-button>
<a-radio-button value="reverse">倒序</a-radio-button>
<a-radio-button value="random">随机</a-radio-button>
<a-radio-button value="single">单曲循环</a-radio-button>
</a-radio-group>
<div class="text-[10px] text-[var(--c-muted)] mt-2 leading-relaxed">
{{ musicPlayModeHint }}
</div>
<div
v-if="cfg.musicPlayMode === 'random'"
class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-[#e7e3db] pt-3"
>
<label class="inline-flex items-center gap-2 text-[12px] text-[var(--c-ink-soft)] cursor-pointer select-none">
<a-switch v-model:checked="cfg.musicCacheEnabled" size="small" />
记忆曲目
</label>
<div class="inline-flex items-center gap-2 text-[12px] text-[var(--c-ink-soft)]">
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }">记忆时长</span>
<a-input-number
v-model:value="cfg.musicCacheHours"
:min="0.5"
:max="720"
:step="0.5"
size="small"
class="!w-[96px]"
:disabled="!cfg.musicCacheEnabled"
/>
<span :class="{ 'opacity-40': !cfg.musicCacheEnabled }" class="text-[var(--c-muted)]">小时</span>
</div>
<span class="text-[10px] text-[var(--c-muted)]">
{{ cfg.musicCacheEnabled
? `同一设备 ${cfg.musicCacheHours || 24} 小时内首次打开沿用上次抽中的歌;播完仍会换下一首。`
: '关闭后每次打开页面都会重新按权重抽曲。' }}
</span>
</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-[var(--c-ink-soft)]">尚未上传音乐</div>
<div class="text-[11px] text-[var(--c-muted)] mt-1">支持 mp3 等常见音频格式</div>
<a-button class="mt-4" @click="openMusicUpload">
<i class="fa-solid fa-upload mr-1 text-[var(--c-primary)]"></i>上传第一首
</a-button>
</div>
<ul v-else class="music-list">
<li
v-for="(t, i) in cfg.musicList"
:key="i"
class="music-track"
:class="{ 'music-track--active': t.url === cfg.activeMusicUrl }"
>
<div class="music-track-main">
<button
type="button"
class="music-cover"
@click="openUpload(u => t.coverUrl = u)"
:title="t.coverUrl ? '更换封面' : '上传封面'"
>
<img v-if="t.coverUrl" :src="t.coverUrl" alt="" />
<span v-else class="music-cover-fallback">
<i class="fa-solid fa-compact-disc"></i>
</span>
<span class="music-cover-index">{{ String(i + 1).padStart(2, '0') }}</span>
</button>
<div class="music-meta min-w-0 flex-1">
<a-input
v-model:value="t.name"
placeholder="曲目名称"
class="music-name-input"
:bordered="false"
/>
<div class="music-sub">
<span class="truncate">{{ shortUrl(t.url) }}</span>
<span
v-if="t.url === cfg.activeMusicUrl"
class="music-badge"
>正在播放</span>
</div>
<div class="music-chips">
<label
class="music-chip music-chip--weight"
:class="{ 'opacity-40': cfg.musicPlayMode !== 'random' }"
:title="cfg.musicPlayMode === 'random' ? '随机权重,越大越容易抽到' : '随机模式下生效'"
>
<i class="fa-solid fa-scale-balanced"></i>
<span>权重</span>
<a-input-number
v-model:value="t.weight"
:min="1"
:max="1000"
:step="1"
size="small"
class="music-weight-input"
:disabled="cfg.musicPlayMode !== 'random'"
/>
<span v-if="cfg.musicPlayMode === 'random'" class="music-weight-pct">
{{ musicWeightPercent(t) }}%
</span>
</label>
<button type="button" class="music-chip" @click="openLrcUpload(t)">
<i class="fa-solid fa-file-lines"></i>
{{ t.lrcUrl ? '已有歌词' : '上传歌词' }}
</button>
<button
v-if="t.lrcUrl"
type="button"
class="music-chip music-chip--muted"
@click="t.lrcUrl = ''"
>清除歌词</button>
</div>
<audio v-if="t.url" :src="t.url" controls preload="none" class="music-audio"></audio>
</div>
<div class="music-actions">
<a-button
size="small"
:type="t.url === cfg.activeMusicUrl ? 'primary' : 'default'"
:class="t.url === cfg.activeMusicUrl ? '!bg-[var(--c-primary)] !border-[var(--c-primary)]' : ''"
@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-[var(--c-muted)] leading-relaxed mb-4">
本地上传会继续保存到后端 uploads 目录返回 http://localhost:8080/uploads/ 开头的地址。
</div>
<a-button type="primary" :loading="uploadConfigSaving" @click="onSaveUploadConfig"
class="!bg-[var(--c-primary)] hover:!bg-[var(--c-primary-deep)] !border-[var(--c-primary)]">
<i class="fa-solid fa-floppy-disk mr-1"></i>保存上传设置
</a-button>
</a-form>
</a-card>
</section>
<!-- 海报独立 poster_configs1 男方 /hb-n2 女方 /hb-nv3 新人模板 /hb-xr -->
<section v-if="activeTab === 'poster'">
<a-card :bordered="false" class="!shadow-sm">
<a-alert
type="info"
show-icon
class="mb-4"
message="每一方同时维护竖版(/1)与横版(/2)。例:男方竖版 /hb-n/1横版 /hb-n/2女方 /hb-nv/1|/2新人模板 /hb-xr/1|/2。"
/>
<a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-2">
<a-tab-pane v-for="s in POSTER_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" />
</a-tabs>
<a-tabs v-model:activeKey="posterLayoutTab" size="small" class="mb-3">
<a-tab-pane
v-for="l in POSTER_LAYOUTS"
:key="l.value"
:tab="`${l.label} (/${l.value})`"
/>
</a-tabs>
<a-form layout="vertical" :class="{ 'opacity-60 pointer-events-none': posterLoading }">
<a-form-item label="所属方 / 风格">
<a-tag color="gold">{{ currentPosterSide.key }} · {{ currentPosterSide.label }}</a-tag>
<a-tag color="orange">{{ currentPosterLayout.label }}</a-tag>
<span class="text-[11px] text-[var(--c-muted)] 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-[var(--c-muted)] mt-1">
{{ POSTER_TEMPLATES.find(t => t.value === posterCfg.template)?.desc }}
</div>
</a-form-item>
<a-form-item label="风格模板">
<div class="poster-theme-picker">
<button
v-for="t in POSTER_THEMES"
:key="t.value"
type="button"
class="poster-theme-card"
:class="{ active: posterCfg.posterTheme === t.value }"
@click="posterCfg.posterTheme = t.value"
>
<span
class="poster-theme-swatch"
:style="{ background: `linear-gradient(150deg, ${t.mid}, ${t.deep})` }"
>
<i :style="{ borderColor: t.gold }"></i>
<b :style="{ color: t.gold }"></b>
</span>
<span class="poster-theme-name">{{ t.label }}</span>
<i v-if="posterCfg.posterTheme === t.value" class="poster-theme-check fa-solid fa-check"></i>
</button>
</div>
<div class="text-[11px] text-[var(--c-muted)] mt-1">
{{ POSTER_THEMES.find(t => t.value === posterCfg.posterTheme)?.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-[var(--c-muted)] 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-[var(--c-muted)] mt-1">
曲目来自背景音乐清空则该海报不播放可到
<a class="text-[var(--c-primary)] 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-[var(--c-muted)] mt-1">
{{ heroFocusDevice === 'mobile' ? '手机端' : 'PC 端' }}焦点拖拽十字准星或点网格当前
{{ Math.round(heroFocusActiveX) }}% · {{ Math.round(heroFocusActiveY) }}%
</div>
</a-form-item>
<a-form-item label="二维码中心图">
<image-field
v-model="posterCfg.qrCenterImg"
label="中心图(可选)"
@pick="openUpload(setPosterQrCenter)"
:uploading="uploading"
/>
<div class="text-xs text-stone-500 mt-1">不配置时二维码中心为圆圈大</div>
</a-form-item>
<!-- classic 字段 -->
<template v-if="posterCfg.template === 'classic'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item :label="posterNameLabels.left">
<div class="flex gap-2">
<a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterNameLabels.leftPh" />
<a-input v-model:value="posterCfg.sonName" placeholder="姓名" />
</div>
</a-form-item>
<a-form-item :label="posterNameLabels.right">
<div class="flex gap-2">
<a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterNameLabels.rightPh" />
<a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" />
</div>
</a-form-item>
</div>
<a-form-item label="主标题">
<a-input v-model:value="posterCfg.title" placeholder="婚礼邀请函" />
</a-form-item>
<a-form-item label="正文(每行一条)">
<a-textarea v-model:value="posterLinesText" :rows="10" placeholder="每行一条正文" />
</a-form-item>
<a-form-item label="底部文案">
<a-input v-model:value="posterCfg.footer" />
</a-form-item>
</template>
<!-- split 字段 -->
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-3 gap-x-6">
<a-form-item label="竖排主标">
<a-input v-model:value="posterCfg.verticalSlogan" placeholder="吾家有喜" />
</a-form-item>
<a-form-item label="副标一">
<a-input v-model:value="posterCfg.subSlogan1" placeholder="佳偶天成" />
</a-form-item>
<a-form-item label="副标二">
<a-input v-model:value="posterCfg.subSlogan2" placeholder="百年好合" />
</a-form-item>
</div>
<a-form-item label="问候语">
<a-input v-model:value="posterCfg.greeting" placeholder="尊敬的各位亲朋好友" />
</a-form-item>
<a-form-item label="正文(每行一条,建议 24 行)">
<a-textarea v-model:value="posterLinesText" :rows="5" placeholder="每行一条正文" />
</a-form-item>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<a-form-item :label="posterNameLabels.left">
<div class="flex gap-2">
<a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterNameLabels.leftPh" />
<a-input v-model:value="posterCfg.sonName" placeholder="姓名" @update:value="v => posterCfg.groomName = v" />
</div>
</a-form-item>
<a-form-item :label="posterNameLabels.right">
<div class="flex gap-2">
<a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterNameLabels.rightPh" />
<a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" @update:value="v => posterCfg.brideName = v" />
</div>
</a-form-item>
<a-form-item label="喜宴时间">
<a-input v-model:value="posterCfg.dateText" />
</a-form-item>
<a-form-item label="农历">
<a-input v-model:value="posterCfg.lunarText" />
</a-form-item>
</div>
<a-form-item label="地点">
<a-input v-model:value="posterCfg.venueText" />
</a-form-item>
<a-form-item label="底部 Love 文案">
<a-input v-model:value="posterCfg.loveFooter" placeholder="Love you" />
</a-form-item>
</template>
</a-form>
</a-card>
<div class="poster-action-bar-spacer" aria-hidden="true"></div>
</section>
<div v-if="activeTab === 'poster'" class="poster-action-bar">
<div class="poster-action-bar-inner">
<div class="poster-action-bar-meta">
<span>{{ currentPosterSide.label }} · {{ currentPosterLayout.label }}</span>
<span class="poster-action-bar-path">{{ currentPosterPreviewPath }}</span>
</div>
<div class="poster-action-bar-btns">
<a-button
v-if="posterUndoSnapshot"
size="small"
@click="undoPosterImport"
>
<i class="fa-solid fa-rotate-left mr-1"></i>撤回导入
</a-button>
<a-button size="small" :loading="posterCopying" @click="copyPosterTemplate">
<i class="fa-solid fa-copy mr-1"></i>复制
</a-button>
<a-button size="small" :loading="posterImporting" @click="importPosterTemplateDirect">
<i class="fa-solid fa-file-import mr-1"></i>导入
</a-button>
<a-button size="small" @click="openPosterPreview(posterSideTab, posterLayoutTab)">
<i class="fa-solid fa-eye mr-1"></i>预览
</a-button>
<a-button
type="primary"
size="small"
:loading="posterSaving"
class="!bg-[var(--c-primary)] hover:!bg-[var(--c-primary-deep)] !border-[var(--c-primary)]"
@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-[var(--c-ink)]">相册页</div>
<div class="text-[11px] text-[var(--c-muted)] mt-0.5"> {{ cfg.photoPages.length }} </div>
</div>
<a-button size="small" type="primary"
class="!bg-[var(--c-primary)] hover:!bg-[var(--c-primary-deep)] !border-[var(--c-primary)]"
@click="addPage">
<i class="fa-solid fa-plus mr-1"></i>新增相册页
</a-button>
</div>
<div v-if="cfg.photoPages.length" class="photo-page-chips mt-3">
<button
v-for="(page, idx) in cfg.photoPages"
:key="idx"
type="button"
class="photo-chip"
:class="{
active: idx === currentPhotoIndex,
'drag-over': chipDragOver === idx && chipDragIndex !== idx,
dragging: chipDragIndex === idx,
}"
draggable="true"
:title="`${pageTypeLabel(page.type)} · 拖拽可排序`"
@click="setActivePhotoIndex(idx)"
@dragstart="onChipDragStart(idx, $event)"
@dragover.prevent="chipDragOver = idx"
@dragleave="chipDragOver === idx && (chipDragOver = -1)"
@drop.prevent="onChipDrop(idx)"
@dragend="onChipDragEnd"
>
<span class="photo-chip-no">{{ String(idx + 1).padStart(2, '0') }}</span>
<span class="photo-chip-type">{{ pageTypeLabel(page.type) }}</span>
</button>
<span class="photo-chips-hint"><i class="fa-solid fa-up-down-left-right mr-1"></i>拖拽页签可排序</span>
</div>
</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-[var(--c-ink-soft)]">暂无相册页</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-[var(--c-primary)]/10">
<div class="flex justify-between items-center mb-3">
<span class="inline-flex items-center gap-2 text-[var(--c-primary)] text-sm">
<span class="w-7 h-7 rounded-lg bg-[var(--c-primary)]/10 text-[11px] inline-flex items-center justify-center tracking-wider">
{{ String(idx + 1).padStart(2, '0') }}
</span>
{{ idx + 1 }} · {{ pageTypeLabel(page.type) }}
</span>
<div class="flex gap-1">
<a-button size="small" :disabled="idx===0" @click="movePage(idx,-1)" title="前移"><i class="fa-solid fa-arrow-up"></i></a-button>
<a-button size="small" :disabled="idx===cfg.photoPages.length-1" @click="movePage(idx,1)" title="后移"><i class="fa-solid fa-arrow-down"></i></a-button>
<a-button size="small" @click="duplicatePage(idx)" title="复制本页"><i class="fa-solid fa-copy"></i></a-button>
<a-popconfirm title="删除此相册页?" ok-text="删除" cancel-text="取消" @confirm="removePage(idx)">
<a-button size="small" danger type="text" title="删除本页"><i class="fa-solid fa-trash"></i></a-button>
</a-popconfirm>
</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="triptych">三联屏3 </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="mosaic">杂志拼贴6 </a-select-option>
<a-select-option value="masonry">瀑布流</a-select-option>
<a-select-option value="carousel">轮播滑动</a-select-option>
<a-select-option value="filmstrip">胶片连拍4 图横滑</a-select-option>
<a-select-option value="fan">扇形叠影5 </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>
<a-form-item label="边框样式(点选即预览效果)">
<border-style-picker v-model="page.borderStyle" />
</a-form-item>
<!-- 单图版式的宽度设置逐页配置 -->
<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-[var(--c-muted)] 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-[var(--c-muted)]">
原图用于预览展示图为等比缩放后的体积不裁切
<span v-if="resizeProgress" class="text-[var(--c-primary)] ml-1">{{ resizeProgress }}</span>
</div>
<a-button
size="small"
type="primary"
class="!bg-[var(--c-primary)] hover:!bg-[var(--c-primary-deep)] !border-[var(--c-primary)]"
: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-[var(--c-primary)]">展示已生成</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-[var(--c-primary)]">展示已生成</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-[var(--c-primary)]">展示已生成</div>
<div v-if="page.img2ResizedMeta" class="resized-meta">{{ page.img2ResizedMeta }}</div>
</div>
</div>
</template>
<template v-else-if="page.type === 'heart-collage'">
<div class="w-full">
<!-- 拼接方案选择迷你轮廓预览数据驱动 -->
<div class="heart-layout-picker">
<button
v-for="ly in HEART_LAYOUTS"
:key="ly.key"
type="button"
class="heart-layout-card"
:class="{ active: (page.heartLayout || 'classic') === ly.key }"
:title="`${ly.desc}${ly.slots.length} 图)`"
@click="setHeartLayout(page, ly.key)"
>
<span class="hl-thumb">
<i v-for="(s, si) in ly.slots" :key="si" :style="hlThumbStyle(s)"></i>
</span>
<span class="hl-name">{{ ly.label }}</span>
<span class="hl-count">{{ ly.slots.length }} </span>
</button>
</div>
<div class="text-[11px] text-[var(--c-muted)] mb-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span>
<strong>单击</strong>两格对调顺序格内<strong>拖拽</strong>调整展示焦点同海报
空格点击上传切换方案会按新方案的格数调整图片列表
</span>
<a-button
v-if="heartSwapSelected?.page === page"
size="small"
type="link"
class="!px-0 !h-auto !text-[var(--c-primary)]"
@click="heartSwapSelected = null"
>取消选择已选{{ heartSlotLabel(page, heartSwapSelected.idx) }}</a-button>
</div>
<div class="heart-admin-board">
<div
v-for="(slot, iidx) in heartSlotsFor(page)"
:key="`${page.heartLayout || 'classic'}-${iidx}`"
class="heart-admin-slot"
:style="heartSlotStyle(slot)"
>
<div
class="heart-admin-cell"
:class="{
empty: !(page.images[iidx] || page.imagesResized?.[iidx]),
selected: heartSwapSelected?.page === page && heartSwapSelected?.idx === iidx,
'swap-target': heartSwapSelected?.page === page && heartSwapSelected?.idx !== iidx,
dragging: heartFocusDrag?.page === page && heartFocusDrag?.idx === iidx && heartFocusDrag?.moved,
}"
:style="{ borderRadius: slot.r }"
@pointerdown="onHeartSlotPointerDown($event, page, iidx)"
>
<img
v-if="page.images[iidx] || page.imagesResized?.[iidx]"
:src="page.imagesResized?.[iidx] || page.images[iidx]"
alt=""
draggable="false"
:style="heartAdminImgStyle(page, iidx, slot)"
/>
<div v-else class="heart-admin-empty">
<span class="text-[16px] leading-none">+</span>
<span>{{ heartSlotLabel(page, iidx) }}</span>
</div>
<span
v-if="page.images[iidx] || page.imagesResized?.[iidx]"
class="hero-focus-crosshair heart-admin-crosshair"
:style="{
left: heartFocusAt(page, iidx, 'x') + '%',
top: heartFocusAt(page, iidx, 'y') + '%',
}"
/>
<div class="heart-admin-toolbar">
<span class="heart-admin-label">{{ heartSlotLabel(page, iidx) }}</span>
<button
type="button"
class="heart-admin-btn"
title="上传/更换"
@click.stop="openUpload(u => setOriginalImageAt(page, iidx, u))"
>
<i class="fa-solid fa-image"></i>
</button>
</div>
<div
v-if="page.imagesResized?.[iidx]"
class="heart-admin-meta"
:title="page.imagesResizedMeta?.[iidx] || '展示已生成'"
>已缩放</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div v-for="(img, iidx) in page.images" :key="iidx" class="relative w-[96px]">
<image-field v-model="page.images[iidx]"
:label="`图${iidx + 1}`"
@pick="openUpload(u => setOriginalImageAt(page, iidx, u))"
:uploading="uploading" />
<div v-if="page.imagesResized?.[iidx]" class="text-center mt-1">
<div class="text-[9px] text-[var(--c-primary)]">展示已生成</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-[var(--c-primary)] 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-[var(--c-primary)]/50 text-[var(--c-primary)] text-[10px] leading-none flex items-center justify-center shadow hover:bg-[var(--c-primary)] 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-[var(--c-primary)]/50 text-[var(--c-primary)] text-[10px] leading-none flex items-center justify-center shadow hover:bg-[var(--c-primary)] 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-[var(--c-primary)]/40 text-[var(--c-primary)] text-[11px] flex items-center justify-center hover:border-[var(--c-primary)] 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-[var(--c-ink)]">流程环节</div>
<div class="text-[11px] text-[var(--c-muted)] mt-0.5"> {{ cfg.schedule.length }} 个环节</div>
</div>
<a-button size="small" type="primary"
class="!bg-[var(--c-primary)] hover:!bg-[var(--c-primary-deep)] !border-[var(--c-primary)]"
@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-[var(--c-ink-soft)]">暂无流程</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-[var(--c-ink)]">出席回执</div>
<div class="text-[11px] text-[var(--c-muted)] 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-[var(--c-muted)]">{{ 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-[var(--c-ink)]">礼金记录</div>
<div class="text-[11px] text-[var(--c-muted)] mt-0.5">
{{ cashGiftCount }} · 合计 ¥{{ cashGiftTotal }}
· 前台录入 <a class="text-[var(--c-primary)]" href="/lijin" target="_blank">/lijin</a>
· 明细 <a class="text-[var(--c-primary)]" 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-[var(--c-primary)] font-medium">¥{{ record.amount }}</span>
</template>
<template v-else-if="column.key==='created_at'">
<span class="text-[12px] text-[var(--c-muted)]">{{ formatTime(record.created_at) }}</span>
</template>
<template v-else-if="column.key==='actions'">
<a-space>
<a-button size="small" type="link" class="!text-[var(--c-primary)] !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-[var(--c-ink)]">记账口令</div>
<div class="text-[11px] text-[var(--c-muted)] mt-0.5">
前台 <a class="text-[var(--c-primary)]" href="/lijin" target="_blank">/lijin</a> 登记礼金前需输入此口令
</div>
</div>
<a-button
size="small"
type="primary"
class="!bg-[var(--c-primary)] !border-[var(--c-primary)]"
: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-[var(--c-ink)]">查看全部口令</div>
<div class="text-[11px] text-[var(--c-muted)] mt-0.5">
前台 <a class="text-[var(--c-primary)]" href="/lijin/list" target="_blank">/lijin/list</a> 需输入口令后查看完整礼金列表
</div>
</div>
<a-button
size="small"
type="primary"
class="!bg-[var(--c-primary)] !border-[var(--c-primary)]"
: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-[var(--c-ink)]">备注快捷选项</div>
<div class="text-[11px] text-[var(--c-muted)] 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-[var(--c-primary)] !border-[var(--c-primary)]"
: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-[var(--c-ink)]">弹幕审核</div>
<div class="text-[11px] text-[var(--c-muted)] 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-[var(--c-muted)]">{{ formatTime(record.created_at) }}</span>
</template>
<template v-else-if="column.key==='actions'">
<a-space>
<a-button size="small" type="primary" class="!bg-[var(--c-primary)] !border-[var(--c-primary)]"
: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, Modal } 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, getVisitDetail,
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 BorderStylePicker from '@/components/BorderStylePicker.vue'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { SITE_THEME_GROUPS, applySiteTheme } from '@/utils/themes'
import { CALENDAR_STYLES, resolveCalendarStyle } from '@/utils/calendarStyles'
import {
normalizeMusicWeight, normalizeMusicCacheHours, resolveMusicPlayMode,
} from '@/utils/musicPick'
import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
import {
getSlotResizeSpecs,
getBasicImageResizeSpecs,
resizeSlotToFile,
probeResizedMeta,
metaHasFileSize,
} from '@/composables/resizeImage'
import {
defaultPosterBundle, normalizePosterBundle, defaultPosterForLayout, normalizePosterConfig,
LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_LAYOUTS, POSTER_TEMPLATES, POSTER_THEMES, MOBILE_LAYOUTS,
HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, layoutKey,
posterSideMeta, posterLayoutMeta, posterRoute,
} from '@/composables/posterDefaults'
import {
HEART_LAYOUTS, HEART_MASK_CSS, heartSlotsFor, heartLayoutMeta, normalizeHeartLayout,
heartSlotStyle, heartSlotLabel, heartFocusAt, heartFocusCssAt,
syncImagesFocus, setHeartFocus,
} from '@/composables/heartCollage'
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
const router = useRouter()
const adminStore = useAdminStore()
const TAB_KEY = 'wedding_admin_active_tab'
const MENU_LAYOUT_KEY = 'wedding_admin_menu_layout'
const CONFIG_SECTIONS = ['basic', 'copy', 'theme', '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: 'theme', label: '风格模板', icon: 'fa-solid fa-palette' },
{ 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'],
theme: ['siteTheme', 'sectionIndexEnabled', 'calendarStyle', 'endingFrameStyle'],
visual: [
'introExitEffect', 'scrollMode', 'freeScrollInterval', 'pageSwitchDuration', 'firstScreenDuration',
'petalEnabled', 'bubbleEnabled', 'introEnabled', 'danmakuEnabled', 'danmakuShowTime', 'nineGridAnimate',
'giftEffectMode',
],
music: ['musicList', 'activeMusicUrl', 'musicPlayMode', 'musicRandomEnabled', 'musicCacheEnabled', 'musicCacheHours'],
photos: ['photoPages'],
schedule: ['schedule'],
}
function readStoredTab() {
try {
const v = localStorage.getItem(TAB_KEY)
if (v && VALID_TABS.includes(v)) return v
} catch { /* ignore */ }
return 'overview'
}
function readStoredMenuLayout() {
try {
const v = localStorage.getItem(MENU_LAYOUT_KEY)
if (v === 'top' || v === 'side') return v
} catch { /* ignore */ }
return 'side'
}
const activeTab = ref(readStoredTab())
const menuLayout = ref(readStoredMenuLayout())
const hotelWelcomeRef = ref(null)
watch(menuLayout, (v) => {
try { localStorage.setItem(MENU_LAYOUT_KEY, v) } catch { /* ignore */ }
})
function isMenuGroupActive(group) {
return Array.isArray(group?.items) && group.items.some((item) => item.key === activeTab.value)
}
const activePhotoTab = ref('0')
const saving = ref(false)
const uploading = ref(false)
const resizingPage = ref(false)
const resizingBasic = ref(false)
const resizeProgress = ref('')
const uploadConfigSaving = ref(false)
const posterSaving = ref(false)
const posterLoading = ref(false)
const posterMusicLoading = ref(false)
/** 海报选曲用:独立缓存请柬 musicList避免依赖是否打开过音乐页 */
const posterMusicOptions = ref([])
const tabLoading = ref(false)
const rsvpLoading = ref(false)
const rsvpList = ref([])
const cashGiftLoading = ref(false)
const cashGiftSaving = ref(false)
const cashGiftList = ref([])
const cashGiftTotal = ref(0)
const cashGiftCount = ref(0)
const cashGiftModalOpen = ref(false)
const cashGiftEditingId = ref(0)
const cashGiftForm = reactive({ name: '', amount: 1000, note: '' })
const cashNotePresets = ref([])
const cashNoteDefaults = ref([])
const cashNoteDraft = ref('')
const cashNotesSaving = ref(false)
const cashViewPass = ref('')
const cashViewPassSaving = ref(false)
const cashEntryPass = ref('')
const cashEntryPassSaving = ref(false)
const danmakuLoading = ref(false)
const danmakuList = ref([])
const danmakuFilter = ref('pending')
const visitStatsLoading = ref(false)
const visitListLoading = ref(false)
const visitDetailOpen = ref(false)
const visitDetailLoading = ref(false)
const visitDetail = ref(null)
const visitDetailTitle = computed(() => {
const fp = visitDetail.value?.visitor?.fingerprint
return fp ? `访客详情 · ${shortFp(fp)}` : '访客详情'
})
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 selectedCity = ref('')
const cityStats = ref([])
const cityStatsLoading = ref(false)
const visitFilter = reactive({
hasInteraction: false,
hasGift: false,
sort: 'last_seen_desc',
})
const visitSortOptions = [
{ label: '最近访问', value: 'last_seen_desc' },
{ label: '最早访问', value: 'last_seen_asc' },
{ label: '访问最多', value: 'visit_count_desc' },
{ label: '访问最少', value: 'visit_count_asc' },
]
const cfg = reactive(defaultConfig())
const uploadCfg = reactive(defaultUploadConfig())
const posterSideTab = ref('1')
const posterLayoutTab = ref('1')
const posterBundle = reactive(defaultPosterBundle())
const posterImporting = ref(false)
const posterCopying = ref(false)
/** 本会话最近一次复制的模板,导入时优先/兜底使用 */
let posterTemplateCache = null
/** 导入前快照,用于撤回 { s, l, config } */
const posterUndoSnapshot = ref(null)
const posterCfg = computed(() => {
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
if (!posterBundle[s]) posterBundle[s] = { '1': defaultPosterForLayout(s, 1), '2': defaultPosterForLayout(s, 2) }
if (!posterBundle[s][l]) posterBundle[s][l] = defaultPosterForLayout(s, l)
return posterBundle[s][l]
})
const mediaPickerOpen = ref(false)
const mediaPickerCurrent = ref('')
let mediaPickerOnUrl = null
function ensurePosterSlot(side = posterSideTab.value, layout = posterLayoutTab.value) {
const s = sideKey(side)
const l = layoutKey(layout)
if (!posterBundle[s] || typeof posterBundle[s] !== 'object') {
posterBundle[s] = { '1': defaultPosterForLayout(s, 1), '2': defaultPosterForLayout(s, 2) }
}
if (!posterBundle[s][l]) posterBundle[s][l] = defaultPosterForLayout(s, l)
return posterBundle[s][l]
}
const heroFocusGrid = heroFocusGridPoints(HERO_FOCUS_GRID)
const heroFocusPreviewEl = ref(null)
const heroFocusDragging = ref(false)
const heroFocusDevice = ref('pc') // pc | mobile
const heroFocusActiveX = computed(() => {
const cfg = posterCfg.value
if (!cfg) return 50
if (heroFocusDevice.value === 'mobile') {
return Number.isFinite(Number(cfg.heroFocusMobileX)) ? cfg.heroFocusMobileX : (cfg.heroFocusX ?? 50)
}
return cfg.heroFocusX ?? 50
})
const heroFocusActiveY = computed(() => {
const cfg = posterCfg.value
if (!cfg) return 50
if (heroFocusDevice.value === 'mobile') {
return Number.isFinite(Number(cfg.heroFocusMobileY)) ? cfg.heroFocusMobileY : (cfg.heroFocusY ?? 50)
}
return cfg.heroFocusY ?? 50
})
const heroFocusPreviewCss = computed(() => {
const cfg = posterCfg.value
if (!cfg) return '50% 50%'
return heroFocusCss(cfg, heroFocusDevice.value === 'mobile' ? 'mobile' : undefined)
})
function setHeroFocus(x, y) {
const cfg = posterCfg.value
if (!cfg) return
if (heroFocusDevice.value === 'mobile') {
cfg.heroFocusMobileX = clampHeroFocus(x)
cfg.heroFocusMobileY = clampHeroFocus(y)
} else {
cfg.heroFocusX = clampHeroFocus(x)
cfg.heroFocusY = clampHeroFocus(y)
}
}
function isHeroFocusGridActive(pt) {
return Math.abs(heroFocusActiveX.value - pt.x) < 0.6
&& Math.abs(heroFocusActiveY.value - pt.y) < 0.6
}
function heroFocusFromClient(clientX, clientY) {
const el = heroFocusPreviewEl.value
if (!el) return
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
const x = ((clientX - rect.left) / rect.width) * 100
const y = ((clientY - rect.top) / rect.height) * 100
setHeroFocus(x, y)
}
function onHeroFocusPointerMove(e) {
if (!heroFocusDragging.value) return
heroFocusFromClient(e.clientX, e.clientY)
}
function onHeroFocusPointerUp() {
if (!heroFocusDragging.value) return
heroFocusDragging.value = false
window.removeEventListener('pointermove', onHeroFocusPointerMove)
window.removeEventListener('pointerup', onHeroFocusPointerUp)
window.removeEventListener('pointercancel', onHeroFocusPointerUp)
}
function onHeroFocusPointerDown(e) {
if (!posterCfg.value?.heroImg) return
e.preventDefault()
heroFocusDragging.value = true
heroFocusFromClient(e.clientX, e.clientY)
window.addEventListener('pointermove', onHeroFocusPointerMove)
window.addEventListener('pointerup', onHeroFocusPointerUp)
window.addEventListener('pointercancel', onHeroFocusPointerUp)
}
const posterMusicSelectOptions = computed(() => {
const list = Array.isArray(posterMusicOptions.value) ? posterMusicOptions.value : []
const opts = list
.filter((t) => t && t.url)
.map((t, i) => ({
value: t.url,
label: t.name || `曲目 ${i + 1}`,
}))
const cur = posterCfg.value?.musicUrl
if (cur && !opts.some((o) => o.value === cur)) {
opts.unshift({ value: cur, label: `(原曲目)${cur.slice(0, 36)}${cur.length > 36 ? '…' : ''}` })
}
return opts
})
const currentPosterSide = computed(() => posterSideMeta(posterSideTab.value))
const currentPosterLayout = computed(() => posterLayoutMeta(posterLayoutTab.value))
const currentPosterPreviewPath = computed(() => posterRoute(posterSideTab.value, posterLayoutTab.value))
const posterNameLabels = computed(() => {
const s = sideKey(posterSideTab.value)
if (s === '2') {
return { left: '女儿称谓 / 姓名', right: '女婿称谓 / 姓名', leftPh: '女儿:', rightPh: '女婿:' }
}
if (s === '3') {
return { left: '新人左侧称谓 / 姓名', right: '新人右侧称谓 / 姓名', leftPh: '新郎:', rightPh: '新娘:' }
}
return { left: '儿子称谓 / 姓名', right: '儿媳称谓 / 姓名', leftPh: '儿子:', rightPh: '儿媳:' }
})
const posterLinesText = computed({
get: () => {
const lines = posterCfg.value?.lines
return Array.isArray(lines) ? lines.join('\n') : ''
},
set: (v) => {
const slot = ensurePosterSlot()
slot.lines = String(v || '')
.split('\n')
.map((s) => s.trimEnd())
},
})
function setPosterHero(url) {
ensurePosterSlot().heroImg = url
}
function setPosterQrCenter(url) {
ensurePosterSlot().qrCenterImg = url
}
watch([posterSideTab, posterLayoutTab], () => {
const slot = ensurePosterSlot()
const l = layoutKey(posterLayoutTab.value)
slot.layout = Number(l)
})
const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) || 0, Math.max(cfg.photoPages.length - 1, 0)))
const isConfigTab = computed(() => CONFIG_SECTIONS.includes(activeTab.value))
const visitPagination = computed(() => ({
current: visitPage.value,
pageSize: visitPageSize.value,
total: visitTotal.value,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (t) => `${t}`,
}))
function defaultConfig() {
return {
groom: '李明', bride: '王华', date: '2026-05-20', lunar: '农历四月初四 星期三',
hotel: '杭州星光万丽大酒店', address: '杭州市西湖区星光大道88号\n3楼大宴会厅',
formalText1: '两姓联姻 一堂缔约', formalText2: '良缘永结 匹配同称', formalPageHeight: '100vh',
calendarDate: '2026-09-04',
heroImg: 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80',
heroImgResized: '',
heroImgResizedMeta: '',
endImg: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80',
endImgResized: '',
endImgResizedMeta: '',
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1', weight: 1 }],
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
musicPlayMode: 'random',
musicRandomEnabled: true,
musicCacheEnabled: true,
musicCacheHours: 24,
danmakuEnabled: true,
danmakuShowTime: true,
nineGridAnimate: false,
giftEffectMode: 'classic',
siteTheme: 'champagne',
sectionIndexEnabled: true,
calendarStyle: 'auto',
endingFrameStyle: 'curtain',
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' },
{ label: '信封启封(蜡封拆信)', value: 'envelope' },
{ label: '贺卡掀页(纸张上翻)', value: 'paper' },
{ label: '穿越景深(镜头推进)', value: 'zoom' },
{ label: '圆窗收拢(虹膜谢幕)', value: 'iris' },
{ label: '花瓣飘散(化作花瓣)', value: 'petal' },
]
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 {
// 固定数量版式:三联屏(3)/胶片连拍(4)/扇形叠影(5)/一大五小(6)/杂志拼贴(6)/九宫格(9)/爱心拼接(按方案)
// 柔性版式:瀑布流/轮播/拍立得,仅首次补齐
if (p.type === 'heart-collage') p.heartLayout = normalizeHeartLayout(p.heartLayout)
else delete p.heartLayout
const fixed = {
'one-large-five-small': 6, 'nine-grid': 9,
'heart-collage': heartLayoutMeta(p.heartLayout).slots.length,
'masonry': 6, 'carousel': 5, 'polaroid': 6,
'triptych': 3, 'filmstrip': 4, 'fan': 5, 'mosaic': 6,
}
const FIXED_LEN_TYPES = ['one-large-five-small', 'nine-grid', 'heart-collage', 'triptych', 'filmstrip', 'fan', 'mosaic']
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 (FIXED_LEN_TYPES.includes(p.type)) {
while (p.images.length < need) p.images.push('')
if (p.images.length > need) p.images.length = need
}
syncImagesResized(p)
syncImagesFocus(p)
delete p.img1
delete p.img2
delete p.img1Resized
delete p.img2Resized
delete p.img1ResizedMeta
delete p.img2ResizedMeta
}
// 非爱心版式清掉焦点字段
if (p.type !== 'heart-collage') syncImagesFocus(p)
p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
p.height = p.height || '100vh'
p.borderStyle = p.borderStyle || 'mat'
// 单图宽度设置(仅 single 版式使用,逐页配置)
p.singleWidth = p.singleWidth === 'full' ? 'full' : 'default'
if (typeof p.singleKeepRatio !== 'boolean') p.singleKeepRatio = true
return p
}
// 柔性版式(图片数量可增删)
function isFlexible(type) {
return ['masonry', 'carousel', 'polaroid'].includes(type)
}
function addImage(page) {
if (!Array.isArray(page.images)) page.images = []
page.images.push('')
syncImagesResized(page)
syncImagesFocus(page)
}
function removeImage(page, i) {
if (page.images.length > 1) {
page.images.splice(i, 1)
if (Array.isArray(page.imagesResized)) page.imagesResized.splice(i, 1)
if (Array.isArray(page.imagesResizedMeta)) page.imagesResizedMeta.splice(i, 1)
if (Array.isArray(page.imagesFocusX)) page.imagesFocusX.splice(i, 1)
if (Array.isArray(page.imagesFocusY)) page.imagesFocusY.splice(i, 1)
syncImagesResized(page)
syncImagesFocus(page)
}
}
// 图片排序:前移(-1) / 后移(+1)
function moveImage(page, i, dir) {
const j = i + dir
if (!Array.isArray(page.images) || j < 0 || j >= page.images.length) return
const swap = (arr) => {
if (!Array.isArray(arr) || i >= arr.length || j >= arr.length) return
const t = arr[i]; arr[i] = arr[j]; arr[j] = t
}
swap(page.images)
syncImagesResized(page)
swap(page.imagesResized)
swap(page.imagesResizedMeta)
syncImagesFocus(page)
swap(page.imagesFocusX)
swap(page.imagesFocusY)
}
const heartSwapSelected = ref(null)
/** 切换爱心拼接方案:按新方案格数调整图片列表(多截少补) */
function setHeartLayout(page, key) {
if (!page || page.type !== 'heart-collage') return
page.heartLayout = normalizeHeartLayout(key)
heartSwapSelected.value = null
ensurePageShape(page)
}
/** 方案缩略图:把槽位渲染成迷你色块(保留圆形/裁切轮廓特征) */
function hlThumbStyle(s) {
const st = {
left: `${s.l}%`,
top: `${s.t}%`,
width: `${s.w}%`,
height: `${s.h}%`,
}
if (s.r && s.r.includes('%')) st.borderRadius = s.r
else st.borderRadius = '1px'
if (s.clip) st.clipPath = s.clip
if (s.shape === 'heart') {
st.webkitMask = HEART_MASK_CSS
st.mask = HEART_MASK_CSS
}
return st
}
/** 拼图板图片样式:焦点 + 方案的裁切/心形 mask圆角在 cell 上) */
function heartAdminImgStyle(page, iidx, slot) {
const st = { objectPosition: heartFocusCssAt(page, iidx) }
if (slot.clip) st.clipPath = slot.clip
if (slot.shape === 'heart') {
st.webkitMask = HEART_MASK_CSS
st.mask = HEART_MASK_CSS
}
return st
}
const heartFocusDrag = ref(null)
const HEART_FOCUS_DRAG_THRESHOLD = 6
function swapHeartSlots(page, i, j) {
if (!page || !Array.isArray(page.images) || i === j) return
if (i < 0 || j < 0 || i >= page.images.length || j >= page.images.length) return
syncImagesResized(page)
syncImagesFocus(page)
const swap = (arr) => {
if (!Array.isArray(arr)) return
const t = arr[i]
arr[i] = arr[j]
arr[j] = t
}
swap(page.images)
swap(page.imagesResized)
swap(page.imagesResizedMeta)
swap(page.imagesFocusX)
swap(page.imagesFocusY)
}
function applyHeartFocusFromClient(clientX, clientY) {
const drag = heartFocusDrag.value
if (!drag?.el || !drag.page) return
const rect = drag.el.getBoundingClientRect()
if (!rect.width || !rect.height) return
const x = ((clientX - rect.left) / rect.width) * 100
const y = ((clientY - rect.top) / rect.height) * 100
setHeartFocus(drag.page, drag.idx, x, y)
}
function onHeartSlotPointerDown(e, page, idx) {
if (e.target?.closest?.('.heart-admin-btn')) return
if (e.button != null && e.button !== 0) return
const empty = !(page.images?.[idx] || page.imagesResized?.[idx])
// 空格:交给 pointerup 做「上传 / 对调」
if (empty) {
heartFocusDrag.value = {
page, idx, el: e.currentTarget,
startX: e.clientX, startY: e.clientY,
moved: false, empty: true,
}
window.addEventListener('pointerup', onHeartSlotPointerUp)
window.addEventListener('pointercancel', onHeartSlotPointerUp)
return
}
e.preventDefault()
heartFocusDrag.value = {
page, idx, el: e.currentTarget,
startX: e.clientX, startY: e.clientY,
moved: false, empty: false,
}
window.addEventListener('pointermove', onHeartSlotPointerMove)
window.addEventListener('pointerup', onHeartSlotPointerUp)
window.addEventListener('pointercancel', onHeartSlotPointerUp)
}
function onHeartSlotPointerMove(e) {
const drag = heartFocusDrag.value
if (!drag || drag.empty) return
const dx = e.clientX - drag.startX
const dy = e.clientY - drag.startY
if (!drag.moved && (dx * dx + dy * dy) < HEART_FOCUS_DRAG_THRESHOLD ** 2) return
drag.moved = true
applyHeartFocusFromClient(e.clientX, e.clientY)
}
function onHeartSlotClickAction(page, idx) {
const sel = heartSwapSelected.value
const empty = !(page.images?.[idx] || page.imagesResized?.[idx])
if (empty && !(sel && sel.page === page)) {
openUpload((u) => setOriginalImageAt(page, idx, u))
return
}
if (!sel || sel.page !== page) {
heartSwapSelected.value = { page, idx }
return
}
if (sel.idx === idx) {
heartSwapSelected.value = null
return
}
swapHeartSlots(page, sel.idx, idx)
heartSwapSelected.value = null
}
function onHeartSlotPointerUp(e) {
const drag = heartFocusDrag.value
window.removeEventListener('pointermove', onHeartSlotPointerMove)
window.removeEventListener('pointerup', onHeartSlotPointerUp)
window.removeEventListener('pointercancel', onHeartSlotPointerUp)
heartFocusDrag.value = null
if (!drag?.page) return
// 拖过焦点:不对调
if (drag.moved) return
onHeartSlotClickAction(drag.page, drag.idx)
}
function clearHeartSlotPointer() {
heartFocusDrag.value = null
window.removeEventListener('pointermove', onHeartSlotPointerMove)
window.removeEventListener('pointerup', onHeartSlotPointerUp)
window.removeEventListener('pointercancel', onHeartSlotPointerUp)
}
function pageHasOriginal(page) {
return getSlotResizeSpecs(page).some((s) => !!s.getOriginal())
}
function setOriginalAndClearResized(page, key, url) {
page[key] = url
page[`${key}Resized`] = ''
page[`${key}ResizedMeta`] = ''
}
function setBasicOriginal(key, url) {
cfg[key] = url
cfg[`${key}Resized`] = ''
cfg[`${key}ResizedMeta`] = ''
}
async function fillMissingBasicResizedMeta() {
await Promise.all(['heroImg', 'endImg'].map(async (key) => {
const original = cfg[key]
const resized = cfg[`${key}Resized`]
const metaKey = `${key}ResizedMeta`
if (!resized || !original || metaHasFileSize(cfg[metaKey])) return
try {
cfg[metaKey] = await probeResizedMeta(original, resized)
} catch { /* 跨域/失效图忽略 */ }
}))
}
async function batchResizeBasicImages() {
const slots = getBasicImageResizeSpecs(cfg).filter((s) => !!s.getOriginal())
if (!slots.length) {
message.warning('请先上传封面或尾页原图')
return
}
resizingBasic.value = true
resizeProgress.value = `0/${slots.length}`
let ok = 0
let fail = 0
try {
for (let i = 0; i < slots.length; i += 1) {
const slot = slots[i]
resizeProgress.value = `${i + 1}/${slots.length}`
try {
const { file, meta } = await resizeSlotToFile(
slot.getOriginal(),
slot.maxWidth,
slot.maxHeight,
`resized-basic-${slot.key}-${Date.now()}.jpg`,
)
const res = await uploadImage(file)
if (!res?.url) throw new Error('上传无返回地址')
slot.setResized(res.url, meta)
ok += 1
} catch (err) {
fail += 1
console.warn('[resize-basic]', slot.key, err)
}
}
if (ok > 0) {
try {
await saveConfigSection('basic', pickSectionData('basic'))
if (fail === 0) message.success(`已生成并保存 ${ok} 张展示图`)
else message.warning(`成功 ${ok} 张并已保存,失败 ${fail}`)
} catch (e) {
message.warning(`已生成 ${ok} 张展示图,但自动保存失败:${e?.message || '请手动保存'}`)
}
} else {
message.error('缩放上传全部失败,请确认原图可跨域读取')
}
} finally {
resizingBasic.value = false
resizeProgress.value = ''
}
}
function setOriginalImageAt(page, iidx, url) {
page.images[iidx] = url
syncImagesResized(page)
syncImagesFocus(page)
page.imagesResized[iidx] = ''
page.imagesResizedMeta[iidx] = ''
}
/** 缺 meta或只有像素没有文件大小时自动探测并补全 */
async function fillMissingResizedMeta(page) {
if (!page) return
if (page.type === 'single' || page.type === 'overlap') {
const keys = page.type === 'single' ? ['img1'] : ['img1', 'img2']
await Promise.all(keys.map(async (key) => {
const original = page[key]
const resized = page[`${key}Resized`]
const metaKey = `${key}ResizedMeta`
if (!resized || !original || metaHasFileSize(page[metaKey])) return
try {
page[metaKey] = await probeResizedMeta(original, resized)
} catch { /* 跨域/失效图忽略 */ }
}))
return
}
if (!Array.isArray(page.images)) return
syncImagesResized(page)
await Promise.all(page.images.map(async (original, i) => {
const resized = page.imagesResized[i]
if (!resized || !original || metaHasFileSize(page.imagesResizedMeta[i])) return
try {
page.imagesResizedMeta[i] = await probeResizedMeta(original, resized)
} catch { /* ignore */ }
}))
}
async function batchResizeAndUpload(page) {
const slots = getSlotResizeSpecs(page).filter((s) => !!s.getOriginal())
if (!slots.length) {
message.warning('请先上传原图')
return
}
resizingPage.value = true
resizeProgress.value = `0/${slots.length}`
let ok = 0
let fail = 0
try {
for (let i = 0; i < slots.length; i += 1) {
const slot = slots[i]
resizeProgress.value = `${i + 1}/${slots.length}`
try {
const { file, meta } = await resizeSlotToFile(
slot.getOriginal(),
slot.maxWidth,
slot.maxHeight,
`resized-${page.type}-${slot.key}-${Date.now()}.jpg`,
)
const res = await uploadImage(file)
if (!res?.url) throw new Error('上传无返回地址')
slot.setResized(res.url, meta)
ok += 1
} catch (err) {
fail += 1
console.warn('[resize]', slot.key, err)
}
}
if (fail === 0) message.success(`已生成 ${ok} 张展示图`)
else if (ok > 0) message.warning(`成功 ${ok} 张,失败 ${fail} 张(常见原因:跨域无法读取原图)`)
else message.error('缩放上传全部失败,请确认原图可跨域读取')
} finally {
resizingPage.value = false
resizeProgress.value = ''
}
}
// 相册页操作
const PAGE_TYPE_LABELS = {
single: '单图',
overlap: '错落双图',
triptych: '三联屏',
'one-large-five-small': '一大五小',
'nine-grid': '九宫格',
mosaic: '杂志拼贴',
masonry: '瀑布流',
carousel: '轮播滑动',
filmstrip: '胶片连拍',
fan: '扇形叠影',
polaroid: '拍立得拼贴',
'heart-collage': '爱心拼接',
}
const pageTypeLabel = (t) => PAGE_TYPE_LABELS[t] || t || '未设置'
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 duplicatePage(i) {
const src = cfg.photoPages[i]
if (!src) return
const copy = JSON.parse(JSON.stringify(src))
cfg.photoPages.splice(i + 1, 0, copy)
setActivePhotoIndex(i + 1)
message.success(`已复制第 ${i + 1} 页,请记得保存`)
}
// 页签拖拽排序
const chipDragIndex = ref(-1)
const chipDragOver = ref(-1)
function onChipDragStart(idx, e) {
chipDragIndex.value = idx
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
try { e.dataTransfer.setData('text/plain', String(idx)) } catch { /* 部分浏览器限制 */ }
}
}
function onChipDrop(idx) {
const from = chipDragIndex.value
if (from >= 0 && from !== idx) {
const [moved] = cfg.photoPages.splice(from, 1)
cfg.photoPages.splice(idx, 0, moved)
setActivePhotoIndex(idx)
}
onChipDragEnd()
}
function onChipDragEnd() {
chipDragIndex.value = -1
chipDragOver.value = -1
}
// 流程操作
function addSchedule() { cfg.schedule.push({ time: '', event: '', desc: '' }) }
function removeSchedule(i) { cfg.schedule.splice(i, 1) }
// 选图:弹层图库 + 新上传
function openUpload(onUrl) {
mediaPickerOnUrl = typeof onUrl === 'function' ? onUrl : null
mediaPickerCurrent.value = ''
mediaPickerOpen.value = true
}
function onMediaPicked(url) {
if (mediaPickerOnUrl) mediaPickerOnUrl(url)
mediaPickerOnUrl = null
}
// 上传(背景音乐,多首)
function openMusicUpload() {
const input = document.createElement('input')
input.type = 'file'; input.accept = 'audio/*'
input.onchange = async (e) => {
const file = e.target.files[0]; if (!file) return
uploading.value = true
try {
const res = await uploadImage(file) // 后端 /upload 同处理任意文件
const name = file.name.replace(/\.[^.]+$/, '') || '背景音乐'
const url = res.url
if (!cfg.musicList) cfg.musicList = []
cfg.musicList.push({ url, name, coverUrl: '', lrcUrl: '', weight: 1 })
if (!cfg.activeMusicUrl) cfg.activeMusicUrl = url
message.success('音乐上传成功')
} catch (err) {
message.error('上传失败')
} finally {
uploading.value = false; e.target.value = ''
}
}
input.click()
}
function openLrcUpload(track) {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.lrc,text/plain'
input.onchange = async (e) => {
const file = e.target.files[0]
if (!file) return
uploading.value = true
try {
const res = await uploadImage(file)
track.lrcUrl = res.url
message.success('歌词上传成功')
} catch {
message.error('歌词上传失败')
} finally {
uploading.value = false
e.target.value = ''
}
}
input.click()
}
function setActiveMusic(url) { cfg.activeMusicUrl = url }
function removeMusic(i) {
cfg.musicList.splice(i, 1)
if (cfg.activeMusicUrl === cfg.musicList[i]?.url) {
cfg.activeMusicUrl = cfg.musicList[0]?.url || ''
}
}
function musicWeightPercent(track) {
const list = (cfg.musicList || []).filter((t) => t && t.url)
const total = list.reduce((s, t) => s + normalizeMusicWeight(t.weight), 0)
if (!total) return 0
return Math.round((normalizeMusicWeight(track?.weight) / total) * 100)
}
const MUSIC_PLAY_MODE_HINTS = {
sequential: '按列表顺序播放,播完自动下一首,到末尾回到第一首。',
reverse: '按列表倒序播放,播完自动上一首,到开头回到最后一首。',
random: '按权重随机抽曲,播完再抽下一首;可配置记忆与权重。',
single: '单曲循环当前曲(「设为播放」指定的曲目)。',
}
const musicPlayModeHint = computed(() => MUSIC_PLAY_MODE_HINTS[cfg.musicPlayMode] || MUSIC_PLAY_MODE_HINTS.random)
function shortUrl(url) {
if (!url) return ''
const parts = url.split('/')
return parts[parts.length - 1] || url
}
async function loadUploadConfig() {
try {
const res = await getUploadConfig()
Object.assign(uploadCfg, defaultUploadConfig(), res.data || {})
uploadCfg.accessKeySecret = ''
} catch (e) {
// 上传设置仅后台使用,读取失败不影响内容编辑。
}
}
async function onSaveUploadConfig() {
uploadConfigSaving.value = true
try {
const payload = {
provider: uploadCfg.provider,
accessKeyId: uploadCfg.accessKeyId,
accessKeySecret: uploadCfg.accessKeySecret,
bucket: uploadCfg.bucket,
folder: uploadCfg.folder,
domain: uploadCfg.domain,
region: uploadCfg.region,
endpoint: uploadCfg.endpoint,
}
const res = await saveUploadConfig(payload)
Object.assign(uploadCfg, defaultUploadConfig(), res.data || {})
uploadCfg.accessKeySecret = ''
message.success('上传设置已保存')
} catch (e) {
message.error(e?.message || '上传设置保存失败')
} finally {
uploadConfigSaving.value = false
}
}
async function loadPosterMusicOptions() {
posterMusicLoading.value = true
try {
const res = await getConfigSection('music')
const list = res.data?.musicList
if (Array.isArray(list) && list.length) {
posterMusicOptions.value = list.filter((t) => t && t.url)
} else if (Array.isArray(cfg.musicList) && cfg.musicList.length) {
posterMusicOptions.value = cfg.musicList.filter((t) => t && t.url)
} else {
posterMusicOptions.value = []
}
} catch {
posterMusicOptions.value = Array.isArray(cfg.musicList)
? cfg.musicList.filter((t) => t && t.url)
: []
} finally {
posterMusicLoading.value = false
}
}
async function loadPosterConfig() {
posterLoading.value = true
try {
const res = await getPosterConfig()
const bundle = normalizePosterBundle(res.data || {})
posterBundle['1'] = bundle['1']
posterBundle['2'] = bundle['2']
posterBundle['3'] = bundle['3']
} catch (e) {
message.error(e?.message || '海报配置加载失败')
} finally {
posterLoading.value = false
}
}
async function onSavePosterConfig() {
posterSaving.value = true
try {
// 仅同步 layout 编号与 Tab 一致,不覆盖用户所选 template / mobileLayout
for (const s of ['1', '2', '3']) {
ensurePosterSlot(s, '1').layout = 1
ensurePosterSlot(s, '2').layout = 2
}
const payload = normalizePosterBundle(posterBundle)
const res = await savePosterConfig(payload)
const bundle = normalizePosterBundle(res.data || payload)
posterBundle['1'] = bundle['1']
posterBundle['2'] = bundle['2']
posterBundle['3'] = bundle['3']
posterUndoSnapshot.value = null
message.success('海报配置已保存')
} catch (e) {
message.error(e?.message || '海报配置保存失败')
} finally {
posterSaving.value = false
}
}
function buildPosterTemplatePayload() {
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
const slot = ensurePosterSlot(s, l)
return {
version: 1,
kind: 'poster-slot',
side: Number(s),
layout: Number(l),
sideLabel: posterSideMeta(s).label,
layoutLabel: posterLayoutMeta(l).label,
route: posterRoute(s, l),
config: JSON.parse(JSON.stringify(slot)),
}
}
async function copyPosterTemplate() {
posterCopying.value = true
try {
const payload = buildPosterTemplatePayload()
posterTemplateCache = payload
const text = JSON.stringify(payload, null, 2)
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
message.success(`已复制「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}`)
} catch (e) {
message.error(e?.message || '复制失败,请检查浏览器剪贴板权限')
} finally {
posterCopying.value = false
}
}
function extractPosterImportConfig(parsed) {
if (!parsed || typeof parsed !== 'object') return null
if (parsed.kind === 'poster-slot' && parsed.config && typeof parsed.config === 'object') {
return parsed.config
}
if (parsed.config && typeof parsed.config === 'object' && !Array.isArray(parsed.config)) {
return parsed.config
}
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
const sideObj = parsed[s] || parsed[Number(s)]
if (sideObj && typeof sideObj === 'object') {
const slot = sideObj[l] || sideObj[Number(l)]
if (slot && typeof slot === 'object') return slot
}
if ('template' in parsed || 'heroImg' in parsed || 'lines' in parsed || 'sonName' in parsed || 'groomName' in parsed) {
return parsed
}
return null
}
async function readPosterImportSource() {
// 1) 剪贴板 2) 本会话刚复制的缓存
try {
if (navigator?.clipboard?.readText) {
const clip = String(await navigator.clipboard.readText() || '').trim()
if (clip) {
try {
const parsed = JSON.parse(clip)
if (extractPosterImportConfig(parsed)) return parsed
} catch { /* not json */ }
}
}
} catch { /* no permission */ }
if (posterTemplateCache && extractPosterImportConfig(posterTemplateCache)) {
return posterTemplateCache
}
return null
}
function applyImportedConfig(rawCfg) {
const s = sideKey(posterSideTab.value)
const l = layoutKey(posterLayoutTab.value)
posterUndoSnapshot.value = {
s,
l,
config: JSON.parse(JSON.stringify(ensurePosterSlot(s, l))),
}
const next = normalizePosterConfig(rawCfg, s, l)
next.side = Number(s)
next.layout = Number(l)
if (next.sonName && !next.groomName) next.groomName = next.sonName
if (next.daughterInLawName && !next.brideName) next.brideName = next.daughterInLawName
if (next.groomName && !next.sonName) next.sonName = next.groomName
if (next.brideName && !next.daughterInLawName) next.daughterInLawName = next.brideName
posterBundle[s][l] = next
}
async function importPosterTemplateDirect() {
posterImporting.value = true
try {
const parsed = await readPosterImportSource()
if (!parsed) {
message.warning('请先复制模板,再点导入')
return
}
const rawCfg = extractPosterImportConfig(parsed)
if (!rawCfg) {
message.error('无法识别的模板数据')
return
}
applyImportedConfig(rawCfg)
message.success(`已导入到「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}」,可撤回或保存`)
} catch (e) {
message.error(e?.message || '导入失败')
} finally {
posterImporting.value = false
}
}
function undoPosterImport() {
const snap = posterUndoSnapshot.value
if (!snap?.config) {
message.info('没有可撤回的导入')
return
}
posterSideTab.value = snap.s
posterLayoutTab.value = snap.l
posterBundle[snap.s][snap.l] = snap.config
posterUndoSnapshot.value = null
message.success('已撤回导入')
}
function pickSectionData(section) {
const keys = SECTION_KEYS[section] || []
const data = {}
for (const k of keys) data[k] = cfg[k]
const cloned = JSON.parse(JSON.stringify(data))
if (section === 'music') {
if (Array.isArray(cloned.musicList)) {
cloned.musicList = cloned.musicList.map((t) => ({
...t,
weight: normalizeMusicWeight(t?.weight),
}))
}
cloned.musicPlayMode = resolveMusicPlayMode(cloned)
cloned.musicRandomEnabled = cloned.musicPlayMode === 'random'
cloned.musicCacheEnabled = cloned.musicCacheEnabled !== false
cloned.musicCacheHours = normalizeMusicCacheHours(cloned.musicCacheHours)
}
return cloned
}
// ---------- 未保存更改保护 ----------
/** section -> 最近一次 加载/保存 后的 JSON 快照 */
const sectionSnapshots = {}
const dirtySections = ref([])
const activeDirty = computed(() => dirtySections.value.includes(activeTab.value))
/**
* 剔除 *ResizedMeta 字段后再比较:进入页签时 fillMissingResizedMeta 会异步探测
* 图片尺寸并写回 cfg这些派生展示数据不应触发"未保存"提示。
*/
function stripResizedMeta(value) {
if (Array.isArray(value)) return value.map(stripResizedMeta)
if (value && typeof value === 'object') {
const out = {}
for (const k of Object.keys(value)) {
if (k.endsWith('ResizedMeta')) continue
out[k] = stripResizedMeta(value[k])
}
return out
}
return value
}
function dirtySignature(section) {
return JSON.stringify(stripResizedMeta(pickSectionData(section)))
}
function sectionIsDirty(section) {
const snap = sectionSnapshots[section]
if (!snap) return false
try { return dirtySignature(section) !== snap } catch { return false }
}
function snapshotSection(section) {
if (!CONFIG_SECTIONS.includes(section)) return
try { sectionSnapshots[section] = dirtySignature(section) } catch { /* ignore */ }
recomputeDirtySections()
}
function recomputeDirtySections() {
dirtySections.value = Object.keys(sectionSnapshots).filter((s) => sectionIsDirty(s))
}
let dirtyTimer = null
watch(cfg, () => {
clearTimeout(dirtyTimer)
dirtyTimer = setTimeout(recomputeDirtySections, 350)
}, { deep: true })
// 整站主题:后台界面即时换肤预览(前台由配置下发生效)
watch(() => cfg.siteTheme, (t) => applySiteTheme(t), { immediate: true })
/** 「跟随主题」时实际匹配到的日历样式名(随所选模板联动展示) */
const autoCalendarLabel = computed(() => {
const key = resolveCalendarStyle('auto', cfg.siteTheme)
return CALENDAR_STYLES.find((s) => s.key === key)?.label || '香槟金卡'
})
/** 尾页装饰选项(风格包主题的「敬候光临」屏全屏装饰) */
const ENDING_FRAME_STYLES = [
{ key: 'curtain', label: '舞台幕布', desc: '帷幔垂坠 · 金带束幕' },
{ key: 'moonwreath', label: '满月花环', desc: '圆环花枝 · 柔美环抱' },
{ key: 'frwindow', label: '法式格窗', desc: '细格落地窗 · 巴黎公寓' },
{ key: 'starring', label: '星光圆环', desc: '光点环旋 · 四角光斑' },
{ key: 'ribbons', label: '缎带环绕', desc: '上下飘带 · 对角蝴蝶结' },
]
/** 主题色卡的字体样张(直观区分各风格的标题字体) */
function themePreviewFont(key) {
return {
champagne: '"Great Vibes", cursive',
'guofeng-new': 'var(--font-serif)',
'guofeng-old': '"Ma Shan Zheng", cursive',
french: '"Great Vibes", cursive',
modern: 'var(--font-sans)',
forest: '"Long Cang", cursive',
night: '"Playfair Display", serif',
watercolor: '"Great Vibes", cursive',
sakura: '"Great Vibes", cursive',
magazine: '"Playfair Display", serif',
newspaper: 'var(--font-serif)',
pixel: '"Fusion Pixel 12px Mono latin", "Fusion Pixel 12px Proportional SC", monospace',
zen: 'var(--font-sans)',
inkwash: 'var(--font-kai)',
cinema: '"Playfair Display", serif',
kodak: 'var(--font-sans)',
arthouse: '"Playfair Display", serif',
passport: '"Cinzel", serif',
scrapbook: '"Long Cang", cursive',
y2k: 'var(--font-sans)',
}[key] || 'var(--font-sans)'
}
function onBeforeUnload(e) {
recomputeDirtySections()
if (!dirtySections.value.length) return
e.preventDefault()
e.returnValue = ''
}
/** Ctrl/Cmd + S 保存当前页对应的配置 */
function onGlobalKeydown(e) {
if (!(e.ctrlKey || e.metaKey) || String(e.key).toLowerCase() !== 's') return
e.preventDefault()
if (isConfigTab.value) {
if (!saving.value) onSave()
} else if (activeTab.value === 'poster') {
if (!posterSaving.value) onSavePosterConfig()
} else if (activeTab.value === 'upload') {
if (!uploadConfigSaving.value) onSaveUploadConfig()
}
}
// ---------- 手机壳实时预览 ----------
const adminHeaderRef = ref(null)
/** 顶栏 sticky 高度不定(顶部菜单模式可能换行),动态量出面板起始位置 */
const previewPanelTop = ref(74)
function measurePreviewTop() {
previewPanelTop.value = (adminHeaderRef.value?.offsetHeight || 64) + 10
}
watch(menuLayout, () => nextTick(measurePreviewTop))
const LIVE_PREVIEW_KEY = 'wedding_admin_live_preview'
const livePreviewOn = ref((() => {
try { return localStorage.getItem(LIVE_PREVIEW_KEY) === '1' } catch { return false }
})())
const previewFrameRef = ref(null)
const previewReady = ref(false)
const previewUrl = '/?preview=1'
function toggleLivePreview() {
livePreviewOn.value = !livePreviewOn.value
if (!livePreviewOn.value) previewReady.value = false
try { localStorage.setItem(LIVE_PREVIEW_KEY, livePreviewOn.value ? '1' : '0') } catch { /* ignore */ }
}
function reloadPreview() {
previewReady.value = false
try { previewFrameRef.value?.contentWindow?.location.reload() } catch { /* ignore */ }
}
/** 把已加载过的配置段(含未保存修改)推给预览 iframe */
function pushPreviewConfig() {
if (!livePreviewOn.value || !previewReady.value) return
const win = previewFrameRef.value?.contentWindow
if (!win) return
const payload = {}
for (const s of Object.keys(sectionSnapshots)) Object.assign(payload, pickSectionData(s))
if (!Object.keys(payload).length) return
try {
win.postMessage({ type: 'wedding-preview-config', data: payload }, window.location.origin)
} catch { /* ignore */ }
}
/** iframe 内请柬页就绪后发来握手,再开始推送 */
function onAdminMessage(e) {
if (e.origin !== window.location.origin) return
if (e.data?.type === 'wedding-preview-ready') {
previewReady.value = true
pushPreviewConfig()
}
}
let previewPushTimer = null
watch(cfg, () => {
if (!livePreviewOn.value) return
clearTimeout(previewPushTimer)
previewPushTimer = setTimeout(pushPreviewConfig, 400)
}, { deep: true })
function applySectionData(section, loaded) {
if (!loaded || typeof loaded !== 'object') return
if (section === 'music') {
if (Array.isArray(loaded.musicList)) {
loaded.musicList = loaded.musicList.map((t) => ({
...t,
coverUrl: t.coverUrl || '',
lrcUrl: t.lrcUrl || '',
weight: normalizeMusicWeight(t.weight),
}))
}
loaded.musicPlayMode = resolveMusicPlayMode(loaded)
loaded.musicRandomEnabled = loaded.musicPlayMode === 'random'
if (typeof loaded.musicCacheEnabled !== 'boolean') loaded.musicCacheEnabled = true
loaded.musicCacheHours = normalizeMusicCacheHours(loaded.musicCacheHours)
}
if (section === 'photos') {
loaded.photoPages = (loaded.photoPages || []).map(ensurePageShape)
}
if (section === 'basic') {
loaded.endImg = loaded.endImg || loaded.heroImg || cfg.endImg
loaded.calendarDate = loaded.calendarDate || '2026-09-04'
loaded.heroImgResized = loaded.heroImgResized || ''
loaded.heroImgResizedMeta = loaded.heroImgResizedMeta || ''
loaded.endImgResized = loaded.endImgResized || ''
loaded.endImgResizedMeta = loaded.endImgResizedMeta || ''
}
if (section === 'copy') {
loaded.formalPageHeight = loaded.formalPageHeight || '100vh'
}
if (section === 'visual') {
loaded.introExitEffect = loaded.introExitEffect || 'wind'
loaded.freeScrollInterval = Number(loaded.freeScrollInterval) || 50
loaded.pageSwitchDuration = Number(loaded.pageSwitchDuration) || 800
loaded.firstScreenDuration = Number.isFinite(Number(loaded.firstScreenDuration))
? Number(loaded.firstScreenDuration)
: 4500
if (typeof loaded.danmakuEnabled !== 'boolean') loaded.danmakuEnabled = true
if (typeof loaded.danmakuShowTime !== 'boolean') loaded.danmakuShowTime = true
if (typeof loaded.nineGridAnimate !== 'boolean') loaded.nineGridAnimate = false
}
Object.assign(cfg, loaded)
if (section === 'photos') {
setActivePhotoIndex(currentPhotoIndex.value)
nextTick(() => {
const page = cfg.photoPages[currentPhotoIndex.value]
if (page) fillMissingResizedMeta(page)
})
}
// 加载/保存后以最新数据为基准快照
snapshotSection(section)
}
async function loadConfigSection(section, { silent = false } = {}) {
// 本地有未保存修改时不重新拉取,避免服务端数据覆盖编辑内容
if (sectionIsDirty(section)) {
if (!silent) message.info('该页有未保存的修改,已保留本地编辑(保存后生效)')
return
}
if (!silent) tabLoading.value = true
try {
const res = await getConfigSection(section)
applySectionData(section, res.data || {})
} catch (e) {
if (!silent) message.error(e?.message || '加载配置失败')
} finally {
if (!silent) 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: 100 },
{ title: '姓名', key: 'guest_name', width: 90, ellipsis: true },
{ title: '归属地', key: 'location', ellipsis: true },
{ title: '机型', key: 'device', width: 110, ellipsis: true },
{ title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 56 },
{ title: '礼物', key: 'gift_count', width: 56 },
{ title: '最近访问', key: 'last_seen_at', width: 150 },
]
const visitColumns = [
{ title: '指纹', key: 'fingerprint', width: 110 },
{ title: '姓名', key: 'guest_name', width: 100, ellipsis: true },
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 120 },
{ title: '归属地', key: 'location', ellipsis: true },
{ title: '机型', key: 'device', width: 130, ellipsis: true },
{ title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 64, sorter: true },
{ title: '礼物', key: 'gift_count', width: 64 },
{ title: '点赞', key: 'like_count', width: 64 },
{ title: 'UA', key: 'user_agent', ellipsis: true },
{ title: '最近访问', key: 'last_seen_at', width: 160 },
]
function shortFp(fp) {
if (!fp) return '-'
return fp.length > 12 ? `${fp.slice(0, 8)}${fp.slice(-4)}` : fp
}
function shortUa(ua) {
if (!ua) return '-'
return ua.length > 42 ? `${ua.slice(0, 42)}` : ua
}
function buildVisitListParams(extra = {}) {
const params = {
page: extra.page ?? visitPage.value,
pageSize: extra.pageSize ?? visitPageSize.value,
sort: visitFilter.sort,
}
if (selectedRegion.value) params.region = selectedRegion.value
if (selectedCity.value) params.city = selectedCity.value
if (visitFilter.hasInteraction) params.has_interaction = '1'
if (visitFilter.hasGift) params.has_gift = '1'
return params
}
function formatGuestName(record) {
if (record.guest_name) return record.guest_name
const names = Array.isArray(record.guest_names) ? record.guest_names : []
return names.length ? names.join('、') : '—'
}
function onVisitFilterChange() {
visitPage.value = 1
loadVisitList()
if (activeTab.value === 'overview') loadVisitPreview()
}
function clearVisitRegionFilter() {
clearSelectedRegion()
selectedCity.value = ''
visitPage.value = 1
loadVisitList()
if (activeTab.value === 'overview') loadVisitPreview()
}
const visitTableCustomRow = (record) => ({
onClick: () => openVisitDetail(record?.fingerprint),
style: { cursor: 'pointer' },
})
async function openVisitDetail(fingerprint) {
const fp = String(fingerprint || '').trim()
if (!fp) return
visitDetailOpen.value = true
visitDetailLoading.value = true
visitDetail.value = null
try {
const res = await getVisitDetail(fp)
visitDetail.value = res.data || null
} catch (e) {
message.error(e?.message || '加载访客详情失败')
visitDetailOpen.value = false
} finally {
visitDetailLoading.value = false
}
}
// echarts 走 canvas不支持 CSS 变量,固定用香槟色板
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: 'var(--c-ink-soft)', 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 : [],
'暂无市级数据',
{ selectable: true, selectedName: selectedCity.value },
)
if (!cityChart) return
cityChart.off('click')
cityChart.on('click', (params) => {
const name = params?.name
if (!name || name === '暂无市级数据') return
if (selectedCity.value === name) {
selectedCity.value = ''
} else {
selectedCity.value = name
}
renderCityChart()
visitPage.value = 1
loadVisitList()
if (activeTab.value === 'overview') loadVisitPreview()
})
}
function clearSelectedRegion(opts = {}) {
selectedRegion.value = ''
selectedCity.value = ''
cityStats.value = []
disposeCityChart()
if (!opts.silent) {
if (regionChart) renderRegionChart()
visitPage.value = 1
loadVisitList()
if (activeTab.value === 'overview') loadVisitPreview()
}
}
async function onSelectRegion(name) {
selectedRegion.value = name
selectedCity.value = ''
cityStatsLoading.value = true
cityStats.value = []
disposeCityChart()
renderRegionChart()
visitPage.value = 1
try {
const res = await getVisitCityStats(name)
const d = res.data || {}
cityStats.value = Array.isArray(d.cities) ? d.cities : []
await nextTick()
renderCityChart()
await Promise.all([loadVisitPreview(), loadVisitList()])
} 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(buildVisitListParams({ 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(buildVisitListParams())
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, _filters, sorter) {
visitPage.value = pag?.current || 1
visitPageSize.value = pag?.pageSize || visitPageSize.value
if (sorter?.field === 'visit_count' || sorter?.columnKey === 'visit_count') {
visitFilter.sort = sorter.order === 'ascend' ? 'visit_count_asc' : 'visit_count_desc'
}
loadVisitList()
}
async function onTabActivate(tab) {
if (!adminStore.isAdmin) return
if (tab !== 'overview') {
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() {
recomputeDirtySections()
const doLogout = () => {
adminStore.logout()
router.replace({ name: 'Login', query: { redirect: '/admin' } })
}
if (dirtySections.value.length) {
Modal.confirm({
title: '有未保存的更改',
content: '退出登录将丢失未保存的编辑内容,确定退出吗?',
okText: '仍要退出',
okType: 'danger',
cancelText: '返回保存',
onOk: doLogout,
})
return
}
doLogout()
}
// 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()
measurePreviewTop()
}
onMounted(() => {
measurePreviewTop()
window.addEventListener('resize', onResize)
window.addEventListener('beforeunload', onBeforeUnload)
window.addEventListener('keydown', onGlobalKeydown)
window.addEventListener('message', onAdminMessage)
// 风格模板影响后台换肤与实时预览,进入后台即预载(当前正是该页时由 onTabActivate 加载)
if (adminStore.isAdmin && activeTab.value !== 'theme') loadConfigSection('theme', { silent: true })
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
window.removeEventListener('beforeunload', onBeforeUnload)
window.removeEventListener('keydown', onGlobalKeydown)
window.removeEventListener('message', onAdminMessage)
clearTimeout(dirtyTimer)
clearTimeout(previewPushTimer)
onHeroFocusPointerUp()
clearHeartSlotPointer()
disposeVisitCharts()
})
</script>
<style scoped>
.resized-meta {
margin-top: 2px;
font-size: 9px;
line-height: 1.35;
color: var(--c-muted);
white-space: pre-line;
word-break: break-all;
}
/* 相册页 chip 页签(可拖拽排序) */
.photo-page-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.photo-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px 5px 7px;
border: 1px solid rgba(var(--c-primary-rgb), 0.25);
border-radius: 9999px;
background: #fff;
font-size: 11px;
color: var(--c-muted);
cursor: grab;
user-select: none;
transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
}
.photo-chip:hover {
border-color: rgba(var(--c-primary-rgb), 0.55);
background: #fbf9f4;
}
.photo-chip.active {
border-color: var(--c-primary);
background: var(--c-primary);
color: #fff;
box-shadow: 0 3px 10px rgba(var(--c-primary-rgb), 0.35);
}
.photo-chip.dragging {
opacity: 0.5;
cursor: grabbing;
}
.photo-chip.drag-over {
border-style: dashed;
border-color: var(--c-primary);
transform: scale(1.06);
}
.photo-chip-no {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 9999px;
background: rgba(var(--c-primary-rgb), 0.12);
font-size: 9px;
letter-spacing: 0.05em;
}
.photo-chip.active .photo-chip-no {
background: rgba(255, 255, 255, 0.22);
}
.photo-chips-hint {
font-size: 10px;
color: #b3ada4;
margin-left: 2px;
}
/* 未保存更改提示 */
.admin-dirty-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
line-height: 1;
color: #b45309;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 9999px;
padding: 4px 10px;
white-space: nowrap;
}
.admin-dirty-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #f59e0b;
animation: dirtyPulse 1.6s ease-in-out infinite;
}
@keyframes dirtyPulse {
50% { opacity: 0.3; }
}
.admin-save-attn {
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.25) !important;
}
.nav-dirty-dot {
display: inline-block;
width: 6px;
height: 6px;
margin-left: 6px;
border-radius: 50%;
background: #f59e0b;
vertical-align: middle;
animation: dirtyPulse 1.6s ease-in-out infinite;
}
/* 手机壳实时预览面板 */
.admin-preview-panel {
position: fixed;
right: 14px;
bottom: 14px;
width: 360px;
z-index: 24;
display: none;
flex-direction: column;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(8px);
border: 1px solid rgba(var(--c-primary-rgb), 0.22);
border-radius: 18px;
box-shadow: 0 18px 44px rgba(96, 72, 50, 0.16);
overflow: hidden;
}
@media (min-width: 1280px) {
.admin-preview-panel {
display: flex;
}
.preview-open .admin-shell {
width: auto;
max-width: none;
margin-left: 20px;
margin-right: 400px;
}
}
.admin-preview-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid rgba(var(--c-primary-rgb), 0.16);
}
.admin-preview-title {
font-size: 12px;
color: var(--c-primary);
letter-spacing: 0.08em;
white-space: nowrap;
}
.admin-preview-tip {
font-size: 10px;
color: #b3ada4;
margin-right: auto;
white-space: nowrap;
}
.admin-preview-actions {
display: flex;
gap: 6px;
}
.admin-preview-actions button {
width: 24px;
height: 24px;
border-radius: 8px;
border: 1px solid rgba(var(--c-primary-rgb), 0.25);
background: #fff;
color: var(--c-primary);
cursor: pointer;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.18s ease, color 0.18s ease;
}
.admin-preview-actions button:hover {
background: var(--c-primary);
color: #fff;
}
.admin-preview-body {
flex: 1;
overflow: auto;
display: flex;
justify-content: center;
padding: 14px 0;
/* 纤细主题色滚动条:面板高度不足时不再出现粗灰原生滚动条 */
scrollbar-width: thin;
scrollbar-color: rgba(var(--c-primary-rgb), 0.35) transparent;
}
.admin-preview-body::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.admin-preview-body::-webkit-scrollbar-track {
background: transparent;
}
.admin-preview-body::-webkit-scrollbar-thumb {
background: rgba(var(--c-primary-rgb), 0.32);
border-radius: 999px;
}
.admin-preview-body::-webkit-scrollbar-thumb:hover {
background: rgba(var(--c-primary-rgb), 0.55);
}
.admin-phone-shell {
--pv-scale: 0.78;
position: relative;
flex: none;
width: calc(390px * var(--pv-scale) + 20px);
height: calc(844px * var(--pv-scale) + 20px);
border-radius: 34px;
background: #1d1b19;
padding: 10px;
box-sizing: border-box;
box-shadow: 0 16px 40px rgba(30, 24, 18, 0.35);
}
.admin-phone-notch {
position: absolute;
top: 15px;
left: 50%;
transform: translateX(-50%);
width: 86px;
height: 8px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.16);
z-index: 2;
pointer-events: none;
}
.admin-preview-iframe {
width: 390px;
height: 844px;
border: 0;
border-radius: 26px;
background: var(--c-paper);
transform: scale(var(--pv-scale));
transform-origin: top left;
}
/* 爱心拼接方案选择器 */
.heart-layout-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.heart-layout-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 7px 8px 6px;
border: 1px solid rgba(var(--c-primary-rgb), 0.16);
border-radius: 10px;
background: #fff;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.heart-layout-card:hover {
border-color: rgba(var(--c-primary-rgb), 0.45);
}
.heart-layout-card.active {
border-color: var(--c-primary);
box-shadow: 0 0 0 2px rgba(var(--c-primary-rgb), 0.16);
}
.hl-thumb {
position: relative;
width: 52px;
aspect-ratio: 0.8 / 1;
display: block;
}
.hl-thumb i {
position: absolute;
background: rgba(217, 134, 141, 0.75);
}
.heart-layout-card.active .hl-thumb i {
background: var(--c-primary);
}
.hl-name {
font-size: 10px;
color: var(--c-ink-soft);
letter-spacing: 0.04em;
}
.hl-count {
font-size: 9px;
color: var(--c-muted);
}
.heart-layout-card.active .hl-name {
color: var(--c-primary);
font-weight: 600;
}
.heart-admin-board {
position: relative;
width: 100%;
max-width: 520px;
aspect-ratio: 0.8 / 1;
margin: 0 auto 8px;
background:
radial-gradient(ellipse at 50% 30%, rgba(201, 120, 120, 0.06), transparent 55%),
#faf8f5;
border: 1px solid #e7e3db;
border-radius: 12px;
overflow: hidden;
}
.heart-admin-slot {
position: absolute;
min-width: 0;
min-height: 0;
padding: 1px;
box-sizing: border-box;
}
.heart-admin-cell {
position: relative;
width: 100%;
height: 100%;
border-radius: 4px;
overflow: hidden;
background: var(--c-bg-deep);
border: 1px solid #e7e3db;
user-select: none;
touch-action: none;
cursor: pointer;
transition: box-shadow 0.15s, border-color 0.15s;
}
.heart-admin-cell.empty {
border-style: dashed;
border-color: rgba(var(--c-primary-rgb), 0.45);
background: #f7f4ef;
}
.heart-admin-cell.selected {
border-color: var(--c-primary);
box-shadow: 0 0 0 2px rgba(var(--c-primary-rgb), 0.65);
z-index: 3;
}
.heart-admin-cell.swap-target:hover {
border-color: #c4a484;
box-shadow: 0 0 0 2px rgba(196, 164, 132, 0.45);
z-index: 2;
}
.heart-admin-cell.dragging {
cursor: grabbing;
box-shadow: 0 0 0 2px rgba(var(--c-primary-rgb), 0.55);
z-index: 4;
}
.heart-admin-cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.heart-admin-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: var(--c-primary);
font-size: 10px;
line-height: 1.2;
padding: 2px;
text-align: center;
pointer-events: none;
}
.heart-admin-crosshair {
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
border-width: 1.5px;
}
.heart-admin-toolbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2px;
padding: 2px 3px;
background: linear-gradient(transparent, rgba(40, 30, 20, 0.55));
z-index: 2;
pointer-events: none;
}
.heart-admin-label {
font-size: 9px;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.heart-admin-btn {
pointer-events: auto;
width: 18px;
height: 18px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
color: var(--c-primary);
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.heart-admin-btn:hover {
background: var(--c-primary);
color: #fff;
}
.heart-admin-meta {
position: absolute;
top: 2px;
right: 2px;
z-index: 2;
font-size: 8px;
line-height: 1;
padding: 2px 3px;
border-radius: 3px;
background: rgba(var(--c-primary-rgb), 0.9);
color: #fff;
pointer-events: none;
}
.admin-layout-seg :deep(.ant-segmented-item-selected) {
color: var(--c-primary);
}
.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: var(--c-ink-soft);
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(var(--c-primary-rgb), 0.08);
color: var(--c-primary-deep);
}
.admin-top-menu-trigger.active {
color: var(--c-primary);
background: rgba(var(--c-primary-rgb), 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: var(--c-primary);
background: rgba(var(--c-primary-rgb), 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(var(--c-primary-rgb), 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: var(--c-muted);
margin-bottom: 6px;
padding: 0 6px;
}
.admin-nav-group + .admin-nav-group {
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed rgba(var(--c-primary-rgb), 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: var(--c-ink-soft);
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(var(--c-primary-rgb), 0.08);
color: var(--c-primary-deep);
}
.admin-nav-item.active {
background: rgba(var(--c-primary-rgb), 0.14);
border-color: rgba(var(--c-primary-rgb), 0.28);
color: var(--c-primary);
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(var(--c-primary-rgb), 0.22);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--c-primary);
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(var(--c-primary-rgb), 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(var(--c-primary-rgb), 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(var(--c-primary-rgb), 0.16);
}
.stat-label {
font-size: 11px;
color: var(--c-muted);
letter-spacing: 0.06em;
}
.stat-value {
margin-top: 6px;
font-size: 28px;
font-weight: 500;
color: var(--c-primary);
letter-spacing: 0.02em;
line-height: 1.1;
}
.visit-preview-wrap,
.visit-list-wrap {
border: 1px solid rgba(var(--c-primary-rgb), 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;
}
.visit-table-clickable :deep(.ant-table-tbody > tr:hover > td) {
background: rgba(var(--c-primary-rgb), 0.08) !important;
}
.visit-detail-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.visit-detail-section {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(var(--c-primary-rgb), 0.14);
}
.visit-detail-h {
font-size: 13px;
font-weight: 600;
color: var(--c-ink);
margin-bottom: 8px;
}
.visit-detail-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.visit-detail-list li {
font-size: 12px;
padding: 8px 10px;
border-radius: 10px;
background: var(--c-paper);
border: 1px solid rgba(var(--c-primary-rgb), 0.1);
}
@media (max-width: 640px) {
.visit-detail-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.chart-card {
border-radius: 14px;
padding: 14px 16px;
background: var(--c-paper);
border: 1px solid rgba(var(--c-primary-rgb), 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, var(--c-paper) 0%, #f7f3ec 100%);
border: 1px dashed rgba(var(--c-primary-rgb), 0.28);
}
.admin-empty-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(var(--c-primary-rgb), 0.12);
color: var(--c-primary);
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(var(--c-primary-rgb), 0.14);
border-radius: 14px;
background: var(--c-paper);
padding: 12px 14px;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.music-track--active {
border-color: rgba(var(--c-primary-rgb), 0.55);
background: linear-gradient(135deg, #fff 0%, #f8f1e6 100%);
box-shadow: 0 6px 18px rgba(var(--c-primary-rgb), 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(var(--c-primary-rgb), 0.2);
background: var(--c-bg-deep);
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: var(--c-primary);
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: var(--c-ink);
}
.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: var(--c-muted);
min-width: 0;
}
.music-badge {
flex-shrink: 0;
font-size: 10px;
letter-spacing: 0.04em;
color: var(--c-primary);
background: rgba(var(--c-primary-rgb), 0.12);
border: 1px solid rgba(var(--c-primary-rgb), 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(var(--c-primary-rgb), 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: var(--c-primary);
color: var(--c-primary);
}
.music-chip--muted {
color: var(--c-muted);
border-color: transparent;
background: transparent;
}
.music-chip--weight {
cursor: default;
gap: 6px;
}
.music-chip--weight:hover {
border-color: rgba(var(--c-primary-rgb), 0.22);
color: #7a6550;
}
.music-weight-input {
width: 72px !important;
}
.music-weight-input :deep(.ant-input-number-input) {
height: 22px;
font-size: 11px;
padding: 0 4px;
text-align: center;
}
.music-weight-pct {
font-size: 10px;
color: var(--c-primary);
min-width: 2.2em;
}
.music-audio {
width: 100%;
max-width: 420px;
height: 32px;
margin-top: 10px;
border-radius: 8px;
outline: none;
}
.music-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
@media (max-width: 640px) {
.music-track-main { flex-wrap: wrap; }
.music-actions {
flex-direction: row;
width: 100%;
justify-content: flex-end;
}
}
.schedule-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.schedule-row {
display: flex;
align-items: flex-end;
gap: 12px;
padding: 14px;
border-radius: 14px;
background: var(--c-paper);
border: 1px solid rgba(var(--c-primary-rgb), 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: var(--c-primary);
background: rgba(var(--c-primary-rgb), 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: var(--c-primary);
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(var(--c-primary-rgb), 0.16) !important;
}
.admin-table :deep(.ant-table-tbody > tr > td) {
border-bottom: 1px solid rgba(var(--c-primary-rgb), 0.08) !important;
font-size: 13px;
}
.admin-table :deep(.ant-table-tbody > tr:hover > td) {
background: var(--c-paper) !important;
}
.admin-table :deep(.ant-pagination-item-active) {
border-color: var(--c-primary);
}
.admin-table :deep(.ant-pagination-item-active a) {
color: var(--c-primary);
}
.admin-filter :deep(.ant-radio-button-wrapper-checked) {
background: var(--c-primary) !important;
border-color: var(--c-primary) !important;
color: #fff !important;
}
.admin-filter :deep(.ant-radio-button-wrapper:hover) {
color: var(--c-primary);
}
.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(var(--c-primary-rgb), 0.14);
border-color: rgba(var(--c-primary-rgb), 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: var(--c-muted);
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: var(--c-primary); }
.hero-focus-cell.active {
border-color: var(--c-primary);
background: rgba(var(--c-primary-rgb), 0.14);
}
.hero-focus-dot {
width: 5px;
height: 5px;
border-radius: 999px;
background: #c4b8a4;
}
.hero-focus-cell.active .hero-focus-dot { background: var(--c-primary); }
.hero-focus-preview {
position: relative;
width: min(100%, 280px);
aspect-ratio: 16 / 11;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e7e3db;
background: var(--c-bg-deep);
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: var(--c-muted);
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(var(--c-primary-rgb), 0.85), 0 1px 4px rgba(0, 0, 0, 0.25);
background: rgba(var(--c-primary-rgb), 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(var(--c-primary-rgb), 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: var(--c-ink-soft);
}
.poster-action-bar-path {
font-size: 11px;
color: var(--c-muted);
}
.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; }
}
/* ---------- 整站风格模板选择 ---------- */
.theme-group + .theme-group {
margin-top: 14px;
}
.theme-group-title {
font-size: 11px;
color: var(--c-primary);
letter-spacing: 0.12em;
margin-bottom: 8px;
}
.theme-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 10px;
max-width: 720px;
}
.theme-swatch b {
position: relative;
z-index: 1;
font-size: 22px;
line-height: 1;
font-weight: 700;
}
.theme-desc {
font-size: 9px;
color: var(--c-muted);
letter-spacing: 0.02em;
line-height: 1.4;
text-align: center;
padding: 0 2px;
}
.theme-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
padding: 10px 6px 8px;
border: 1px solid rgba(var(--c-primary-rgb), 0.16);
border-radius: 12px;
background: #fff;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.theme-card:hover {
border-color: rgba(var(--c-primary-rgb), 0.45);
}
.theme-card.active {
border-color: var(--c-primary);
box-shadow: 0 0 0 2px rgba(var(--c-primary-rgb), 0.18);
}
.theme-swatch {
width: 100%;
height: 40px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.theme-swatch i {
width: 16px;
height: 16px;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.theme-swatch i:last-child {
width: 11px;
height: 11px;
}
.theme-name {
font-size: 11px;
color: var(--c-ink-soft);
letter-spacing: 0.06em;
}
.theme-card.active .theme-name {
color: var(--c-primary);
font-weight: 600;
}
.theme-check {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--c-primary);
color: #fff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
/* ---------- 日历样式选择(微缩日历卡,每种风格独立绘制) ---------- */
.cal-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
gap: 10px;
max-width: 720px;
}
.cal-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
padding: 10px 8px 8px;
border: 1px solid rgba(var(--c-primary-rgb), 0.16);
border-radius: 12px;
background: #fff;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.cal-card:hover {
border-color: rgba(var(--c-primary-rgb), 0.45);
}
.cal-card.active {
border-color: var(--c-primary);
box-shadow: 0 0 0 2px rgba(var(--c-primary-rgb), 0.18);
}
.cal-name {
font-size: 11px;
color: var(--c-ink);
font-weight: 600;
}
.cal-desc {
font-size: 9px;
color: var(--c-muted);
letter-spacing: 0.02em;
line-height: 1.4;
text-align: center;
}
.cal-check {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--c-primary);
color: #fff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.cal-thumb {
position: relative;
width: 100%;
height: 66px;
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
overflow: hidden;
}
.cal-thumb .ct-head {
font-size: 8px;
letter-spacing: 0.14em;
line-height: 1;
font-weight: 700;
}
.cal-thumb .ct-grid {
display: grid;
grid-template-columns: repeat(6, auto);
gap: 4px 6px;
}
.cal-thumb .ct-grid i {
position: relative;
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
}
.cal-thumb .ct-grid i.sel {
opacity: 1;
}
.cal-thumb .ct-tape {
display: none;
}
/* 跟随主题 */
.cal-thumb-auto {
background: linear-gradient(135deg, #f6efe2, #eadcc8 55%, #e2cba8);
color: #8a6d3b;
font-size: 18px;
}
/* 香槟金卡:白卡金线红心 */
.cal-thumb-classic {
background: #fdfbf7;
border: 1px solid #d9c294;
box-shadow: inset 0 0 0 3px rgba(217, 194, 148, 0.18);
color: #8a6d3b;
}
.cal-thumb-classic .ct-head { color: #a8874f; }
.cal-thumb-classic .ct-grid i.sel {
background: transparent;
}
.cal-thumb-classic .ct-grid i.sel::after {
content: '♥';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -52%);
font-size: 11px;
color: #c25667;
line-height: 1;
}
/* 中式红金:红底金框金点 + 顶部金横幅 */
.cal-thumb-redgold {
background: linear-gradient(160deg, #a03040, #86222e);
border: 1px solid #c9a24a;
box-shadow: inset 0 0 0 3px rgba(201, 162, 74, 0.4);
color: #e7c584;
}
.cal-thumb-redgold .ct-head {
width: 100%;
padding: 2px 0;
color: #7a1a25;
background: linear-gradient(90deg, transparent, rgba(216, 171, 94, 0.95) 30%, rgba(216, 171, 94, 0.95) 70%, transparent);
}
.cal-thumb-redgold .ct-grid i.sel {
background: #e7c584;
box-shadow: 0 0 0 1.5px rgba(122, 26, 37, 0.6);
transform: scale(1.6);
}
/* 水墨素卡:宣纸墨线朱砂方印 */
.cal-thumb-ink {
background: #f9f8f2;
border: 1px solid #565c56;
color: #565c56;
}
.cal-thumb-ink .ct-head {
color: #3a3f3a;
font-family: 'Ma Shan Zheng', 'STKaiti', cursive;
font-weight: 400;
font-size: 9px;
}
.cal-thumb-ink .ct-grid i.sel {
border-radius: 1px;
background: #b5493f;
transform: scale(1.8) rotate(-6deg);
}
/* 报刊粗格:黑白双线衬线 */
.cal-thumb-paper {
background: #f7f2df;
border-top: 3px double #262420;
border-bottom: 3px double #262420;
border-left: 1px solid #cfc9b4;
border-right: 1px solid #cfc9b4;
border-radius: 0;
color: #4a463c;
}
.cal-thumb-paper .ct-head {
color: #262420;
font-family: Georgia, 'Times New Roman', serif;
text-transform: uppercase;
}
.cal-thumb-paper .ct-grid i { border-radius: 0; }
.cal-thumb-paper .ct-grid i.sel {
background: transparent;
box-shadow: 0 0 0 1.5px #262420;
transform: scale(1.7);
}
/* 复古票根:打孔虚线边 */
.cal-thumb-ticket {
background:
radial-gradient(circle 3px at 0 33%, #fff 97%, transparent) left center / 6px 22px repeat-y,
radial-gradient(circle 3px at 100% 33%, #fff 97%, transparent) right center / 6px 22px repeat-y,
#f3e7cf;
border: 1px dashed #b3915e;
color: #8a6d3b;
}
.cal-thumb-ticket .ct-head {
color: #7a5c30;
letter-spacing: 0.22em;
}
.cal-thumb-ticket::after {
content: '';
position: absolute;
left: 12px;
bottom: 5px;
width: 34px;
height: 7px;
background: repeating-linear-gradient(90deg, #5c4a2e 0 1px, transparent 1px 3px);
opacity: 0.8;
}
.cal-thumb-ticket .ct-grid i.sel {
background: transparent;
border: 1.5px dashed #a8323e;
transform: scale(2) rotate(-8deg);
}
/* 像素方格:硬边框硬阴影 */
.cal-thumb-pixel {
background: #fdf6e4;
border: 2px solid #33261f;
border-radius: 0;
box-shadow: 3px 3px 0 rgba(201, 79, 124, 0.75);
color: #6b5a4a;
}
.cal-thumb-pixel {
justify-content: flex-start;
padding-bottom: 8px;
}
.cal-thumb-pixel .ct-head {
width: 100%;
padding: 3px 0 2px;
background: #c94f7c;
color: #fff;
font-family: var(--font-pixel, monospace);
font-size: 7px;
letter-spacing: 0.2em;
border-bottom: 2px solid #33261f;
}
.cal-thumb-pixel .ct-grid i { border-radius: 0; }
.cal-thumb-pixel .ct-grid i.sel {
background: #c94f7c;
transform: scale(1.9);
box-shadow: 1px 1px 0 #33261f;
}
/* 极简数字:无框大留白 */
.cal-thumb-minimal {
background: #fefefd;
border: 1px solid #ececea;
color: #8b8a86;
}
.cal-thumb-minimal {
align-items: flex-start;
padding-left: 12px;
}
.cal-thumb-minimal .ct-head {
color: #1f1e1b;
font-weight: 300;
letter-spacing: 0.16em;
font-size: 11px;
}
.cal-thumb-minimal .ct-grid { gap: 5px 8px; }
.cal-thumb-minimal .ct-grid i {
width: 3px;
height: 3px;
opacity: 0.3;
}
.cal-thumb-minimal .ct-grid i.sel {
background: transparent;
box-shadow: 0 0 0 1.2px #2c2a26;
transform: scale(2);
opacity: 1;
}
/* 手账贴纸:虚线框顶部胶带涂鸦圈 */
.cal-thumb-journal {
background: #fffdf6;
border: 1.5px dashed #c99a6e;
color: #9a7a55;
overflow: visible;
}
.cal-thumb-journal .ct-head { color: #8a6a45; }
.cal-thumb-journal .ct-tape {
display: block;
position: absolute;
top: -4px;
left: 50%;
width: 30px;
height: 9px;
margin-left: -15px;
background: rgba(196, 122, 90, 0.45);
transform: rotate(-3deg);
border-radius: 1px;
}
.cal-thumb-journal .ct-grid i.sel {
background: transparent;
border: 1.5px solid rgba(181, 73, 63, 0.9);
border-radius: 58% 42% 55% 45% / 48% 55% 45% 52%;
transform: scale(2.4) rotate(-6deg);
opacity: 1;
}
/* 法式蕾丝:粉底双线衬 + 粉心 */
.cal-thumb-french {
background: linear-gradient(170deg, #fdf3f5, #fbe8ee);
border-radius: 10px;
box-shadow: inset 0 0 0 1px rgba(199, 138, 150, 0.4), inset 0 0 0 4px rgba(253, 243, 245, 0.9), inset 0 0 0 5px rgba(199, 138, 150, 0.45);
color: #c78a96;
}
.cal-thumb-french .ct-head { color: #a05468; font-style: italic; }
.cal-thumb-french .ct-grid i.sel { background: transparent; }
.cal-thumb-french .ct-grid i.sel::after {
content: '♥';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -52%);
font-size: 11px;
color: #e08aa0;
line-height: 1;
}
/* 瑞士网格:方格纸 + 红块头 + 红方块 */
.cal-thumb-mono {
background:
linear-gradient(rgba(17, 17, 17, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(17, 17, 17, 0.06) 1px, transparent 1px),
#fff;
background-size: 9px 9px, 9px 9px, auto;
border: 1px solid #e2e2e0;
border-radius: 0;
color: #333;
align-items: flex-start;
padding-left: 10px;
}
.cal-thumb-mono .ct-head {
background: #e63329;
color: #fff;
padding: 2px 6px;
font-weight: 800;
}
.cal-thumb-mono .ct-grid i { border-radius: 0; }
.cal-thumb-mono .ct-grid i.sel {
background: #e63329;
transform: scale(1.9);
}
/* 野餐格纹:绿白格布 + 樱桃 */
.cal-thumb-picnic {
background:
linear-gradient(#fffefb, #fffefb) 50% 50% / calc(100% - 14px) calc(100% - 14px) no-repeat,
repeating-linear-gradient(0deg, rgba(95, 138, 90, 0.25) 0 5px, transparent 5px 10px),
repeating-linear-gradient(90deg, rgba(95, 138, 90, 0.25) 0 5px, transparent 5px 10px),
#f6faf3;
border: 1px solid rgba(95, 138, 90, 0.4);
border-radius: 7px;
color: #5f8a5a;
}
.cal-thumb-picnic .ct-head { color: #3e5a42; font-style: italic; }
.cal-thumb-picnic .ct-grid i.sel {
background: radial-gradient(circle at 34% 28%, #e46856, #c23c2c 72%);
transform: scale(1.9);
}
/* 玻璃温室:尖顶格线 */
.cal-thumb-greenhouse {
clip-path: polygon(50% 0, 100% 22%, 100% 100%, 0 100%, 0 22%);
border-radius: 0;
border: none;
background:
linear-gradient(rgba(94, 138, 120, 0.5), rgba(94, 138, 120, 0.5)) 50% 22% / 100% 1px no-repeat,
repeating-linear-gradient(90deg, rgba(94, 138, 120, 0.18) 0 1px, transparent 1px 16px),
repeating-linear-gradient(0deg, rgba(94, 138, 120, 0.18) 0 1px, transparent 1px 18px),
linear-gradient(176deg, #f2faf6, #e2f0e8);
color: #5e8a78;
justify-content: flex-end;
padding-bottom: 8px;
}
.cal-thumb-greenhouse .ct-head { color: #3c5a48; font-style: italic; }
.cal-thumb-greenhouse .ct-grid i.sel {
background: radial-gradient(circle at 34% 28%, rgba(255, 255, 255, 0.85), rgba(148, 196, 172, 0.7) 62%);
box-shadow: 0 0 0 1.2px rgba(94, 138, 120, 0.65);
transform: scale(1.8);
}
/* 植物标本:纸底蕨叶胶带 + 红圈 */
.cal-thumb-herbarium {
background: #fbf9f1;
border: 1px solid #d8d2c0;
border-radius: 2px;
color: #84907c;
position: relative;
overflow: visible;
}
.cal-thumb-herbarium::before {
content: '';
position: absolute;
top: -4px;
left: 12px;
width: 26px;
height: 8px;
background: rgba(168, 186, 152, 0.55);
transform: rotate(-38deg);
}
.cal-thumb-herbarium .ct-head { color: #3c4a3a; font-style: italic; }
.cal-thumb-herbarium .ct-grid i.sel {
background: transparent;
border: 1.4px solid rgba(181, 73, 63, 0.9);
box-shadow: 0 0 0 2.5px rgba(181, 73, 63, 0.18);
transform: scale(1.9) rotate(-4deg);
opacity: 1;
}
/* 林间满月:夜空满月 + 金月 */
.cal-thumb-fullmoon {
background:
radial-gradient(22px 22px at 50% 30%, #f3ecd4 52%, rgba(243, 236, 212, 0.3) 60%, transparent 70%),
linear-gradient(178deg, #26342e, #131c18);
border: 1px solid rgba(214, 226, 208, 0.3);
color: #9db4a4;
}
.cal-thumb-fullmoon .ct-head { color: #2e3c30; position: relative; z-index: 1; }
.cal-thumb-fullmoon .ct-grid i.sel {
background: radial-gradient(circle at 38% 32%, #f5e8c0, #d9c288 70%);
box-shadow: 0 0 6px rgba(243, 236, 212, 0.7);
transform: scale(1.8);
}
/* ---------- 尾页装饰缩略 ---------- */
.ed-thumb-curtain {
background:
radial-gradient(9px 11px at 10px 0, #a8404e 66%, transparent 70%) 0 3px / 20px 18px repeat-x,
linear-gradient(#8c2f3c, #8c2f3c) 0 0 / 100% 7px no-repeat,
linear-gradient(#8c2f3c, #8c2f3c) 0 0 / 12px 100% no-repeat,
linear-gradient(#8c2f3c, #8c2f3c) 100% 0 / 12px 100% no-repeat,
linear-gradient(#d8b26e, #d8b26e) 0 0 / 100% 2px no-repeat,
#fdf8ef;
}
.ed-thumb-moonwreath {
background:
radial-gradient(circle at 50% 50%, transparent 21px, rgba(168, 184, 148, 0.9) 22px, rgba(168, 184, 148, 0.9) 23.5px, transparent 24.5px),
radial-gradient(3px at 50% 13%, #e4aeb6 60%, transparent 75%),
radial-gradient(3px at 50% 87%, #e4aeb6 60%, transparent 75%),
radial-gradient(2.5px at 15% 50%, #d9a0a5 60%, transparent 75%),
radial-gradient(2.5px at 85% 50%, #d9a0a5 60%, transparent 75%),
#fdfcf7;
}
.ed-thumb-frwindow {
background:
linear-gradient(rgba(168, 140, 107, 0.7), rgba(168, 140, 107, 0.7)) 50% 0 / 1.5px 100% no-repeat,
repeating-linear-gradient(0deg, transparent 0 14px, rgba(168, 140, 107, 0.4) 14px 15px),
repeating-linear-gradient(90deg, transparent 0 18px, rgba(168, 140, 107, 0.3) 18px 19px),
#fbf7ef;
border: 2px solid rgba(168, 140, 107, 0.65) !important;
}
.ed-thumb-starring {
background:
radial-gradient(circle at 50% 50%, transparent 20px, transparent 21px, rgba(217, 194, 136, 0) 21px),
#1d2440;
position: relative;
}
.ed-thumb-starring::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 46px;
height: 46px;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 2.5px dotted #d9c288;
filter: drop-shadow(0 0 3px rgba(217, 194, 136, 0.7));
}
.ed-thumb-ribbons {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 16' preserveAspectRatio='none'%3E%3Cpath d='M-2 11C16 3 34 3 50 8s34 6 52-3' fill='none' stroke='%23e8a8b2' stroke-width='3.4' stroke-linecap='round'/%3E%3C/svg%3E") 50% 6px / 100% 12px no-repeat,
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 16' preserveAspectRatio='none'%3E%3Cpath d='M102 11C84 3 66 3 50 8S16 14-2 5' fill='none' stroke='%23e8a8b2' stroke-width='3.4' stroke-linecap='round'/%3E%3C/svg%3E") 50% calc(100% - 6px) / 100% 12px no-repeat,
#fdfaf7;
}
/* 花园吊牌:木纹 + 麻绳孔 + 白漆圈 */
.cal-thumb-gardensign {
background:
repeating-linear-gradient(0deg, rgba(50, 34, 20, 0.16) 0 1px, transparent 1px 8px),
linear-gradient(174deg, #8a6844, #5a442c);
border: 1.5px solid rgba(253, 249, 238, 0.5);
border-radius: 8px;
color: #e2d4b8;
position: relative;
}
.cal-thumb-gardensign::before {
content: '';
position: absolute;
top: 4px;
left: 50%;
width: 30px;
height: 8px;
margin-left: -15px;
background:
radial-gradient(2.5px at 4px 4px, #241708 60%, transparent 75%),
radial-gradient(2.5px at 26px 4px, #241708 60%, transparent 75%);
}
.cal-thumb-gardensign .ct-head { color: #fdf9ee; }
.cal-thumb-gardensign .ct-grid i.sel {
background: transparent;
border: 1.5px solid rgba(253, 249, 238, 0.9);
border-radius: 52% 48% 55% 45% / 46% 56% 44% 54%;
transform: scale(2) rotate(-5deg);
opacity: 1;
}
/* 星夜弯月:深蓝星空 + 金星 */
.cal-thumb-starry {
background: linear-gradient(172deg, #1d2440, #10152a);
border: 1px solid rgba(232, 201, 135, 0.4);
color: #9aa4c8;
}
.cal-thumb-starry .ct-head { color: #e8c987; }
.cal-thumb-starry .ct-grid i.sel { background: transparent; }
.cal-thumb-starry .ct-grid i.sel::after {
content: '★';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -54%);
font-size: 11px;
color: #e8c987;
line-height: 1;
filter: drop-shadow(0 0 3px rgba(232, 201, 135, 0.9));
}
/* 水彩晕染:多彩晕斑 */
.cal-thumb-aquarelle {
background:
radial-gradient(50% 40% at 14% 12%, rgba(127, 155, 179, 0.35), transparent 70%),
radial-gradient(46% 36% at 88% 20%, rgba(217, 138, 158, 0.3), transparent 70%),
radial-gradient(52% 42% at 74% 96%, rgba(222, 196, 132, 0.35), transparent 70%),
#fdfefe;
border: 1px solid #e8edf0;
border-radius: 4px 12px 6px 14px / 10px 5px 12px 6px;
color: #7f9bb3;
}
.cal-thumb-aquarelle .ct-head { color: #4e6a80; font-style: italic; }
.cal-thumb-aquarelle .ct-grid i.sel {
background: radial-gradient(circle at 40% 32%, rgba(127, 155, 179, 0.95), rgba(150, 122, 190, 0.6) 66%, transparent 85%);
border-radius: 55% 45% 60% 40% / 50% 62% 42% 58%;
transform: scale(2.1);
}
/* 樱吹雪:粉卡樱花 */
.cal-thumb-hanami {
background: linear-gradient(170deg, #fdf4f7, #f8dfe8);
border: 1px solid rgba(217, 138, 158, 0.45);
border-radius: 12px;
color: #c9758c;
}
.cal-thumb-hanami .ct-head { color: #b0566e; font-style: italic; }
.cal-thumb-hanami .ct-grid i.sel { background: transparent; }
.cal-thumb-hanami .ct-grid i.sel::after {
content: '❀';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -54%);
font-size: 12px;
color: #d9748e;
line-height: 1;
}
/* 月洞窗:月白 + 顶部圆环 */
.cal-thumb-neochina {
position: relative;
background: linear-gradient(175deg, #f6f6ee, #ecefe2);
border: 1px solid rgba(72, 97, 86, 0.5);
color: #708a7c;
justify-content: flex-end;
padding-bottom: 8px;
overflow: visible;
}
.cal-thumb-neochina .ct-head {
position: absolute;
top: 4px;
left: 50%;
transform: translateX(-50%);
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(72, 97, 86, 0.65);
border-radius: 50%;
color: #3c5348;
font-size: 6px;
letter-spacing: 0;
}
.cal-thumb-neochina .ct-grid i.sel {
background: transparent;
border: 1.5px solid #6f8c7d;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.8);
transform: scale(1.8);
opacity: 1;
}
/* 杂志内页:黑下划线巨题 + 黑圈 */
.cal-thumb-magazine {
background: #fbfaf7;
border: 1px solid #e5e2da;
border-radius: 0;
color: #6b665e;
align-items: flex-start;
padding-left: 10px;
}
.cal-thumb-magazine .ct-head {
color: #1f1c19;
font-size: 10px;
font-weight: 900;
border-bottom: 2px solid #1f1c19;
padding-bottom: 2px;
}
.cal-thumb-magazine .ct-grid i.sel {
background: #fff;
box-shadow: 0 0 0 1.5px #1f1c19;
transform: scale(1.7);
}
/* 登机牌:航司蓝条 + 蓝环 */
.cal-thumb-boarding {
background: #f7f9fb;
border: 1px solid rgba(46, 74, 104, 0.45);
border-radius: 8px;
color: #5c7186;
justify-content: flex-start;
padding-bottom: 8px;
}
.cal-thumb-boarding .ct-head {
width: 100%;
padding: 3px 0 2px;
background: linear-gradient(90deg, #2e4a68, #3a5c80);
color: #fff;
letter-spacing: 0.18em;
}
.cal-thumb-boarding .ct-grid i.sel {
background: transparent;
border: 1.5px solid #2e4a68;
transform: scale(1.8);
opacity: 1;
}
/* 胶片场记:上下齿孔条 + 红框 */
.cal-thumb-film {
background: #f9f1dc;
border: 1px solid rgba(43, 39, 35, 0.55);
border-radius: 2px;
color: #7a6852;
position: relative;
overflow: hidden;
}
.cal-thumb-film::before,
.cal-thumb-film::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 9px;
background:
repeating-linear-gradient(90deg, transparent 0 4px, rgba(249, 241, 220, 0.92) 4px 9px, transparent 9px 13px) center / auto 5px no-repeat,
#2b2723;
}
.cal-thumb-film::before { top: 0; }
.cal-thumb-film::after { bottom: 0; }
.cal-thumb-film .ct-head { color: #b3541e; }
.cal-thumb-film .ct-grid i.sel {
background: transparent;
box-shadow: 0 0 0 1.5px #c9302c;
transform: scale(1.7);
opacity: 1;
}
/* 影院灯牌:黑底金灯点边 + 金泡 */
.cal-thumb-marquee {
background: linear-gradient(175deg, #1c1714, #120f0c);
border: 2px solid #3a2f26;
border-radius: 8px;
color: #c8b088;
position: relative;
}
.cal-thumb-marquee::before {
content: '';
position: absolute;
top: 3px;
left: 6px;
right: 6px;
height: 5px;
background: radial-gradient(circle 1.6px at 5px 2.5px, #f2cf7e 60%, transparent 80%) 0 0 / 10px 5px repeat-x;
}
.cal-thumb-marquee .ct-head { color: #f0e2c8; }
.cal-thumb-marquee .ct-grid i.sel {
background: radial-gradient(circle at 38% 32%, #f7e3ae, #f2cf7e 70%);
box-shadow: 0 0 6px rgba(242, 207, 126, 0.9);
transform: scale(1.8);
}
/* 千禧玻璃:彩虹渐变边 + 闪星 */
.cal-thumb-y2k {
border: 2px solid transparent;
border-radius: 12px;
background:
linear-gradient(rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.6)) padding-box,
linear-gradient(130deg, #8a5cf0, #4aaee0 52%, #e879c8) border-box;
color: #7a6aa8;
}
.cal-thumb-y2k .ct-head {
background: linear-gradient(120deg, #7a4ae0, #3f9be0);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 800;
}
.cal-thumb-y2k .ct-grid i.sel { background: transparent; }
.cal-thumb-y2k .ct-grid i.sel::after {
content: '✦';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -54%);
font-size: 11px;
color: #8a5cf0;
line-height: 1;
filter: drop-shadow(0 0 3px rgba(138, 92, 240, 0.8));
}
/* 画廊白盒:装裱框 + 右上红点 */
.cal-thumb-gallery {
background: #fff;
border: 1px solid #dcd9d2;
border-radius: 0;
box-shadow: inset 0 0 0 5px #fff, inset 0 0 0 6px #2c2a26;
color: #8b877e;
}
.cal-thumb-gallery .ct-head { color: #2c2a26; font-style: italic; }
.cal-thumb-gallery .ct-grid i.sel {
background: currentColor;
opacity: 1;
}
.cal-thumb-gallery .ct-grid i.sel::after {
content: '';
position: absolute;
top: -3px;
right: -3px;
width: 5px;
height: 5px;
border-radius: 50%;
background: #c9302c;
}
/* ---------- 海报风格模板选择 ---------- */
.poster-theme-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: 10px;
max-width: 560px;
}
.poster-theme-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px 6px 7px;
border: 1px solid rgba(var(--c-primary-rgb), 0.16);
border-radius: 12px;
background: #fff;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.poster-theme-card:hover {
border-color: rgba(var(--c-primary-rgb), 0.45);
}
.poster-theme-card.active {
border-color: var(--c-primary);
box-shadow: 0 0 0 2px rgba(var(--c-primary-rgb), 0.18);
}
.poster-theme-swatch {
position: relative;
width: 100%;
height: 46px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
overflow: hidden;
}
.poster-theme-swatch i {
position: absolute;
inset: 4px;
border-radius: 6px;
border: 1px solid;
opacity: 0.55;
}
.poster-theme-swatch b {
position: relative;
font-size: 20px;
line-height: 1;
font-weight: 700;
}
.poster-theme-name {
font-size: 11px;
color: var(--c-ink-soft);
letter-spacing: 0.06em;
}
.poster-theme-card.active .poster-theme-name {
color: var(--c-primary);
font-weight: 600;
}
.poster-theme-check {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--c-primary);
color: #fff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
</style>