6249 lines
224 KiB
Vue
6249 lines
224 KiB
Vue
<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">
|
||
宾客送礼时的全屏特效渲染方式。经典粒子兼容性最好;立体炫效基于 WebGL(three.js),8 种礼物各有专属 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_configs:1 男方 /hb-n,2 女方 /hb-nv,3 新人模板 /hb-xr) -->
|
||
<section v-if="activeTab === 'poster'">
|
||
<a-card :bordered="false" class="!shadow-sm">
|
||
<a-alert
|
||
type="info"
|
||
show-icon
|
||
class="mb-4"
|
||
message="每一方同时维护竖版(/1)与横版(/2)。例:男方竖版 /hb-n/1,横版 /hb-n/2;女方 /hb-nv/1|/2;新人模板 /hb-xr/1|/2。"
|
||
/>
|
||
<a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-2">
|
||
<a-tab-pane v-for="s in POSTER_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" />
|
||
</a-tabs>
|
||
<a-tabs v-model:activeKey="posterLayoutTab" size="small" class="mb-3">
|
||
<a-tab-pane
|
||
v-for="l in POSTER_LAYOUTS"
|
||
:key="l.value"
|
||
:tab="`${l.label} (/${l.value})`"
|
||
/>
|
||
</a-tabs>
|
||
<a-form layout="vertical" :class="{ 'opacity-60 pointer-events-none': posterLoading }">
|
||
<a-form-item label="所属方 / 风格">
|
||
<a-tag color="gold">{{ currentPosterSide.key }} · {{ currentPosterSide.label }}</a-tag>
|
||
<a-tag color="orange">{{ currentPosterLayout.label }}</a-tag>
|
||
<span class="text-[11px] text-[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="正文(每行一条,建议 2~4 行)">
|
||
<a-textarea v-model:value="posterLinesText" :rows="5" placeholder="每行一条正文" />
|
||
</a-form-item>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||
<a-form-item :label="posterNameLabels.left">
|
||
<div class="flex gap-2">
|
||
<a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterNameLabels.leftPh" />
|
||
<a-input v-model:value="posterCfg.sonName" placeholder="姓名" @update:value="v => posterCfg.groomName = v" />
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item :label="posterNameLabels.right">
|
||
<div class="flex gap-2">
|
||
<a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterNameLabels.rightPh" />
|
||
<a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" @update:value="v => posterCfg.brideName = v" />
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item label="喜宴时间">
|
||
<a-input v-model:value="posterCfg.dateText" />
|
||
</a-form-item>
|
||
<a-form-item label="农历">
|
||
<a-input v-model:value="posterCfg.lunarText" />
|
||
</a-form-item>
|
||
</div>
|
||
<a-form-item label="地点">
|
||
<a-input v-model:value="posterCfg.venueText" />
|
||
</a-form-item>
|
||
<a-form-item label="底部 Love 文案">
|
||
<a-input v-model:value="posterCfg.loveFooter" placeholder="Love you" />
|
||
</a-form-item>
|
||
</template>
|
||
</a-form>
|
||
</a-card>
|
||
<div class="poster-action-bar-spacer" aria-hidden="true"></div>
|
||
</section>
|
||
|
||
<div v-if="activeTab === 'poster'" class="poster-action-bar">
|
||
<div class="poster-action-bar-inner">
|
||
<div class="poster-action-bar-meta">
|
||
<span>{{ currentPosterSide.label }} · {{ currentPosterLayout.label }}</span>
|
||
<span class="poster-action-bar-path">{{ currentPosterPreviewPath }}</span>
|
||
</div>
|
||
<div class="poster-action-bar-btns">
|
||
<a-button
|
||
v-if="posterUndoSnapshot"
|
||
size="small"
|
||
@click="undoPosterImport"
|
||
>
|
||
<i class="fa-solid fa-rotate-left mr-1"></i>撤回导入
|
||
</a-button>
|
||
<a-button size="small" :loading="posterCopying" @click="copyPosterTemplate">
|
||
<i class="fa-solid fa-copy mr-1"></i>复制
|
||
</a-button>
|
||
<a-button size="small" :loading="posterImporting" @click="importPosterTemplateDirect">
|
||
<i class="fa-solid fa-file-import mr-1"></i>导入
|
||
</a-button>
|
||
<a-button size="small" @click="openPosterPreview(posterSideTab, posterLayoutTab)">
|
||
<i class="fa-solid fa-eye mr-1"></i>预览
|
||
</a-button>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
:loading="posterSaving"
|
||
class="!bg-[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>
|